跳转至

操作系统概述

约 4569 个字 97 行代码 5 张图片 预计阅读时间 16 分钟

内容说明

本文可能涉及一些专有名词。大多数专有名词附上了说明链接;若碰到了陌生的专有名词,请自行搜索或问 AI。

从软件的维度审视,现代操作系统本质上是内核(Kernel)系统程序(System Programs)系统库(System Libraries) 这三层核心组件构成的有机整体。它构成了整个计算机软件生态运行的基石。以常见的操作系统为例:

  • Ubuntu:由 Linux 内核、systemd(初始化与服务管理)、binutils(二进制工具集)、apt(包管理)等系统程序,以及 glibc(C标准库)、libssl(加密库)、libz(压缩库)等关键系统库共同构成。
  • Windows:则由 Windows NT 内核、DWM(桌面窗口管理器)、cmd(命令提示符)等系统程序,以及 UCRT(通用 C 运行时库)等系统库组成。

下图描绘了经典的操作系统层级结构:内核作为核心,直接控制 CPU、内存和各种硬件设备;而普通的用户态程序则运行在受保护的环境中,通过明确定义的接口与内核交互,其行为受到内核的严格限制。

系统层级结构图 (OS Layout)

内核态与用户态

计算机组成原理课程中讲述过 CPU 特权级(Privilege Levels) 机制,它将系统运行状态和操作系统功能组成严格划分为内核态(Kernel Mode)用户态(User Mode)

  1. 内核态:拥有最高权限。可直接访问和控制所有 CPU 寄存器(包括关键的状态寄存器、页表寄存器等),可以直接操作硬件,且能通过页表机制(详见内存章节)访问全部物理内存空间(包括内核空间和 MMIO 映射空间)。
  2. 用户态:权限被最小化。无法直接访问或修改硬件设备,只能访问其所属进程的用户空间内存(页表机制强制隔离),无权更改关键 CPU 状态(如中断屏蔽、特权指令执行)。用户态程序若需执行受限操作(如文件读写、网络通信、内存申请),必须通过系统调用(System Call, syscall) 等受控机制切换到内核态,由内核代为执行。

系统内核(包含其加载的驱动模块等插件)始终运行在内核态。它肩负着很多职责:

  • 硬件抽象与管理(CPU、内存、I/O设备)
  • 内存管理(分配、回收、虚拟化)
  • 进程与线程调度
  • 文件系统管理

下图是 Linux 内核结构示意图。1

Kernel Structure Diagram

因其拥有近乎无限制的操作内存和硬件的能力,内核必须确保自身代码的高度安全性和可信赖性。与之相对,用户态程序来源多样且可能存在风险,因此被严格限制在隔离、安全的用户态中运行。

系统调用(syscall) 是用户态程序进入内核态的最主要途径。其工作原理是:用户程序根据特定架构和操作系统的调用约定(例如,将系统调用号和相关参数按顺序放入指定寄存器),然后执行一条特殊的指令(如 int 0x80 / syscall / sysenter)。CPU 捕获此指令后,自动提升特权级并跳转到内核中预设的 syscall 服务例程进行处理。

strace

strace 是一个利用 ptrace 机制追踪一个进程使用的所有系统调用的 utility。

  1. 请尝试用 strace <prog> 运行一个程序,查看其使用的系统调用,搜索这些系统调用。
  2. 解释一下以下调用的作用,猜测可能的调用目的
execve("/usr/bin/ls", ["ls"], 0x7fff21ad0c70 /* 51 vars */)
brk(NULL)
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
access("/etc/ld.so.preload", R_OK)
mprotect(0x153c1670d000, 16384, PROT_READ)
ioctl(1, TCGETS, 0x7ffced321670)
exit_group(0)

C 标准库 (libc) 在操作系统接口层面扮演着关键角色。它封装了底层的、操作系统特定的 syscall,提供了一套标准化的、高级的 C 语言函数接口(如 open, read, write, fork, malloc)。由于 C 语言的广泛流行及其标准库(如 POSIX 标准)的普遍支持,使得基于 libc 编写的程序具有极佳的可移植性,能够在不同内核(如 Linux, Windows NT, macOS)的操作系统上编译运行。libc 不仅屏蔽了直接使用原始 syscall 的复杂性(避免“满天飞”的汇编级调用),还提供了字符串处理、数学计算、内存管理等实用功能。

需要注意的是,libc 某些函数实现的性能可能并非最优;如果在 Profiling 中发现某个 libc 函数(如 memcpy, strlen)是热点(Hotspot),可以试着寻找其的高性能替代库(如针对特定 CPU 优化的 memcpy),这有时能带来显著提升(当然,更常见的情况是程序自身逻辑存在问题)。

线程与进程

线程与进程是 Linux 运行单元。他们很相似,但又有不同之处。下图展示了一个单线程进程和一个多线程进程。如图所示,其中的多个线程拥有各自的寄存器、堆栈和计数器,但它们共享代码段和数据段。2

What is a Thread in OS and what are the differences between a Process and a Thread?

线程(Thread) 是操作系统进行 CPU 任务调度(Scheduling) 的基本单元。多个线程可以“并发”或“并行”地在同一个物理系统上运行,各自执行相对独立的任务流。

  • 执行上下文:每个线程拥有独立的运行时状态,包括私有栈(Stack)寄存器组(Registers) 的状态以及可选的线程局部存储(Thread Local Storage, TLS)。当操作系统决定将 CPU 从一个线程切换到另一个线程(上下文切换,Context Switch)时,内核负责保存当前线程的上下文,并在恢复该线程运行时精确还原。
  • 调度策略:现代操作系统(如 Linux 使用 完全公平调度器 (CFS))旨在为所有可运行的(Runnable)线程提供公平(Fair) 的 CPU 时间片。只要线程未主动阻塞(Blocking),它就有机会获得执行。然而,当活跃线程数量超过物理 CPU 核心数时,线程间会产生竞争,导致频繁的上下文切换,造成显著的性能开销。因此,在进行并行计算时,使线程数与可用 CPU 核心数相匹配至关重要。
  • 线程状态:线程生命周期中存在多种状态,常见的有:

    • 运行 (Running, R):正在 CPU 上执行。
    • 可中断睡眠 (Interruptible Sleep, S):通常因等待 I/O 操作完成、获取锁(Lock)失败或调用 sleep() 等函数而主动让出 CPU。在 htop 等工具中显示为 S
    • (其他状态如不可中断睡眠 D、停止 T、僵尸 Z 等未在此详述)

    在高性能计算(HPC)场景中,计算密集型线程应主要处于 R 态。如果通过 htop 观察到计算线程频繁进入 S 态,或者系统整体 CPU 使用条的“红色”部分(代表内核态 sy 占用率)异常偏高,这往往是存在锁竞争激烈低效 I/O 问题的信号,是性能优化的关键切入点。

进程(Process) 是操作系统进行资源管理(Resource Management) 的基本单元。一个进程包含一个或多个线程,并为这些线程提供共享的资源和执行环境,包括但不限于:

  • 独立且受保护的虚拟内存地址空间 (由页表实现)
  • 文件描述符表 (File Descriptors, FDs):管理打开的文件、套接字等资源。
  • 环境变量 (Environment Variables)
  • 信号处理器 (Signal Handlers)

    信号机制

    信号(Signal) 是 Linux/Unix 类操作系统中一种进程间通信(IPC)内核向用户进程通知异步事件的核心机制。它本质上是一种软件中断,由内核、其他进程(需权限)或进程自身(如通过 kill())触发,异步地发送给目标进程,通知其发生了特定事件(如用户按下 Ctrl+C 产生 SIGINT、程序非法内存访问触发 SIGSEGV、子进程结束发出 SIGCHLD 或定时器到期发送 SIGALRM)。

    进程可针对每种信号配置处理方式

    1. 执行默认操作(如 SIGTERM 终止进程、SIGIGN 忽略信号)。
    2. 忽略信号(部分信号如 SIGKILLSIGSTOP 不可忽略或捕获)。
    3. 注册自定义信号处理函数(Signal Handler)。当信号被递送时,内核会临时中断进程的正常执行流,切换至用户态执行其注册的 Handler。Handler 执行完毕后(除非指定了特殊标志),进程通常恢复到被中断点继续执行(如同中断服务例程 ISR)。Handler 需要设计得简短且可重入,避免在异步中断场景下引发复杂状态问题。

    信号分为标准信号(不可靠信号)实时信号(可靠信号)。前者信号值较小(1-31),在传递过程中可能被合并或丢失;后者(SIGRTMIN 起)则支持排队,保证按发送顺序可靠递送。信号机制为进程提供了一种响应外部事件或错误条件的轻量级手段,是构建健壮应用(如优雅退出、处理异常)和实现基础功能(如作业控制)的底层基石。可通过 man 7 signal 查看完整信号列表及行为。

    Linux 下,我们通常使用 kill 命令向进程发送信号。感兴趣的同学可以尝试停止这个 Python 脚本:

    import signal
    for sig in [x for x in dir(signal) if x.startswith('SIG') and not x.startswith('SIG_')]:
        try:
            signum = getattr(signal, sig)
            signal.signal(signum, signal.SIG_IGN)
        except (ValueError, OSError, RuntimeError):
            pass
    
    while True:
        signal.pause()
    
  • 进程属性:如用户 ID (UID)、组 ID (GID)、工作目录 (CWD)。

  • 资源限制 (Resource Limits):如 CPU 时间、内存大小、文件数限制 (ulimit)。

操作系统提供了创建新进程的系统调用(最经典的是 Linux 的 fork())。fork() 会创建当前进程的一个副本(Clone)。为了高效,现代操作系统普遍采用 写时复制(Copy-on-Write, COW) 技术:父子进程最初共享相同的物理内存页,只有当任一进程尝试修改某个内存页时,内核才会为该页创建真正的物理副本。新创建的子进程默认继承父进程的上述所有资源(FDs、环境变量等)。

在 Linux 内核里,无论是线程还是进程都对应着一个 struct task_struct。源码中其定义有近千行,这里简略如下:

struct task_struct {
    // 进程标识
    pid_t pid;              // 进程 ID (虽然叫做 process id 但实际含义就是 task id)
    pid_t tgid;             // 线程组 ID(主线程的 PID,实际含义是(与 task id 共享空间的)进程 id)

    // 资源指针
    struct mm_struct *mm;   // 内存管理结构
    struct files_struct *files; // 文件描述符表
    struct signal_struct *signal; // 信号处理

    // 其他资源管理
    struct nsproxy *nsproxy; // 命名空间
    struct fs_struct *fs;    // 文件系统信息(如当前目录)
};

Linux的线程设计

Linux内核中没有设置线程和进程的数据结构,其均由task_struct表示。Linux实际上将线程设计为了LWP(轻量级进程),本质上是共享资源的特殊进程。有时为了区分人们会称线程的pid为TID。 本指南对于线程和进程的pid均称为PID。

其中资源的部分都是指针,对于同进程 task_struct 的资源指针相同,通过引用计数维护对象生命周期。

htop

htop 是一个 Linux/BSD 系统监控与进程管理软件。启动 htop 程序,可以看到所有线程的列表。

htop

  1. 请解释表格首行每个字段的含义
  2. 使用 Kill 功能 (F9),向某个多线程进程的某个线程发送信号(如 SIGKILL),查看结果

proc fs

proc 是 Linux 里的一个伪文件系统 (pseudo file system),它为内核进程线程相关数据结构提供了访问接口。通常我们的 init 会自动把它挂载到 /proc

/proc/:pid 存储了 pid 对应线程的信息。非主线程的 pid 没有对应 /proc/:pid;而线程信息在 /proc/:tgid/task/:pid

如果你尝试运行对非主线程运行ls /proc/:pid你会发现仍然可以显示信息,此时实际查看的信息是该线程的主线程的/proc/:pid信息。但是如果运行ls /proc则不会显示非主线程的pid对应的文件夹。

/proc/self 映射到当前进程对应的文件夹 /proc/:tgid;而 /proc/self-thread 映射到当前线程对应文件夹 /proc/:tgid/task/:pid

请尝试查看以下文件或目录内容:(提示:$$ 是 bash 本身的 tgid)

  • /proc/<PID>/cwd -> 进程当前工作目录的符号链接

    相对路径与绝对路径

    在程序中我们经常使用相对路径来寻找一个文件,这个相对路径就是相对于/proc/<PID>/cwd的路径。

  • /proc/<PID>/fd/ -> 包含进程打开的所有文件描述符

  • /proc/<PID>/cmdline -> 进程启动命令及参数(以\0分隔)
  • /proc/<PID>/environ -> 进程环境变量(以\0分隔)
  • /proc/<PID>/status -> 进程状态摘要(包括内存使用、线程数等)

内存

现代 CPU 访问内存的过程是一个涉及硬件与操作系统(内核)紧密协作的复杂流程:

  1. 指令执行:CPU 执行到一条访存指令(Load/Store),得到一个虚拟地址(Virtual Address, VA)
  2. 地址转换:CPU 的 内存管理单元(Memory Management Unit, MMU) 负责将此 VA 转换为可直接在内存总线上使用的物理地址(Physical Address, PA)
    1. TLB 查询:MMU 首先查找 Translation Lookaside Buffer (TLB) ;若命中(TLB Hit),转换瞬间完成(通常耗时 0.5 - 1 个 CPU 周期)。
    2. 页表遍历:若 TLB 未命中(TLB Miss),MMU 则需查询内存中由内核维护的多级页表(Multi-level Page Tables)。这是一个类似 Trie 树的结构,逐级索引。此过程相对较慢,通常耗时 10 - 100+ 个 CPU 周期(称为 Page Walk)。
  3. 物理访存:获得物理地址后,CPU 通过高速缓存层级(Cache Hierarchy:L1, L2, L3)最终访问物理内存。若数据不在缓存中,则需从主存(DRAM)加载,耗时更长。

内核是页表的创建者和管理者。页表是虚拟地址到物理地址的映射表。它是一个基数树。下图是页表的示意图,页表通过层层映射得到页表项。3

x86_64-page-table-translation

页表项(Page Table Entry, PTE)不仅存储着虚拟页到物理页帧的映射关系,还包含重要的控制标志位(Flags),常见的包括:

  • 存在/有效(Present/Valid)
  • 可读(Readable)
  • 可写(Writable)
  • 可执行(eXecutable - 取决于架构和配置)
  • 用户/内核(User/Supervisor - 决定用户态能否访问)
  • 已修改(Dirty - 页内容被写过)
  • 已访问(Accessed - 页内容被读过或写过)
  • 缓存禁用(Cache Disable - 用于 MMIO 等特殊内存区域)

一张页表定义了一个独立的虚拟地址空间每个进程拥有自己专属的页表,其内部的所有线程共享这个地址空间,这也是同一进程内线程间能通过共享内存进行高效通信的基础。不同进程之间若需共享内存,则需通过操作系统提供的机制(如 Linux 的 mmap() 结合 MAP_SHARED 标志,或 System V / POSIX 共享内存 API)显式地将同一块物理内存区域映射到它们各自的虚拟地址空间中。

现代页表除了支持标准的 4KB 小页(Page)外,还普遍支持大页(Huge Pages)(如 2MB, 1GB)。使用大页能显著减少 TLB Miss 的发生率(因为单条 TLB 条目能覆盖更大的内存范围),提升访问大块连续内存的性能。然而,大页的分配通常需要内核在初始化时预留较大的连续物理内存块,这与现代操作系统倾向的按需分配(Demand Paging)和惰性分配(Lazy Allocation)策略存在一定冲突。因此,即使用户程序在 mmap() 中指定了大页标志(如 MAP_HUGETLB),内核也可能基于当前系统内存状况拒绝该请求或回退到使用标准小页。

练习:mmap

mmap 是把文件/块设备映射到内存的 syscall,它本质上暴露了页表接口,使用户程序可以申请占用一段页表。

  1. 在前面的 strace 练习中就能看出,mmap 不仅可以映射文件,也可以使用 MAP_PRIVATE|MAP_ANONYMOUS 参数申请一大块内存,而不用绑定文件,常常被用作申请一大块内存。理解以下代码,并尝试运行,查看 htop 是否变化符合你的预期?若感兴趣还尝试根据文档修改一些 flag (MAP_GROWSDOWN, MAP_HUGE_2MB)。

    #include <stdio.h>
    #include <sys/mman.h>
    #include <unistd.h>
    
    #define SIZE (64UL * 1024 * 1024 * 1024) // 64GB
    
    int main() {
        void* memory = mmap(NULL, SIZE, PROT_READ | PROT_WRITE,
                            MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        if (memory == MAP_FAILED) perror("mmap failed");
        printf("Successfully allocated 64GB of memory at %p\n", memory);
        pause(); // wait signal to exit
    }
    
  2. 尝试写一个基于 mmap 的、可并发读写的跨进程 spsc ringbuffer,并对比 unix domain socket 和 pipe 的性能(包括吞吐和延迟,Reference1, Reference2)。参考接口如下:

    typedef struct mmap_ringbuf mrb;
    
    int mrb_init(mrb* p, int fd /* shared file */, size_t cap); // return 0 or ERRNO
    int mrb_write(mrb* p, const u8* data, size_t len); // return written length or ERRNO
    int mrb_read(mrb* p, u8* buf, size_t cap); // return read length or ERRNO; 0 for no more data
    

现代服务器和工作站普遍采用 NUMA(Non-Uniform Memory Access,非一致性内存访问) 架构。简而言之,系统由多个节点(Node) 组成,每个节点包含自己的 CPU(或多个 CPU 核心)和本地内存。访问本地节点(Local Node)的内存速度很快,而访问其他节点(Remote Node)的内存则延迟更高、带宽可能更低,如同访问网络上的另一台机器一样。Linux 内核是 NUMA-aware 的,会尽量优化调度和内存分配策略。对于性能要求极高的应用:

  1. 内存绑定:使用工具如 numactl 将进程及其内存分配绑定到特定的 NUMA 节点上(例如 numactl --cpunodebind=0 --membind=0 ./program)。这消除了跨节点访问的延迟,但代价是该进程只能使用绑定节点的 CPU 核心和内存资源
  2. 跨 NUMA 设备通信:当一个 NUMA 节点上的 CPU 需要频繁访问另一个 NUMA 节点上的设备(如 GPU 或 NVMe SSD)时,跨节点内存访问(尤其对于设备 DMA)可能成为瓶颈。此时,利用设备自身的高速互连能力(例如 GPU 的 NVLink/NVSwitch,或通过 RDMA 网卡)在设备间直接传输数据,或者让 CPU 通过该设备所在节点的 PCIe 总线与之通信(PCIe x16 的带宽通常远高于跨 NUMA 内存复制带宽),有时是比通过共享内存更优的选择。

练习:扩展性

找一个你熟悉的多线程应用,使用 taskset -c 0-x 限定可用 CPU 核数(从 1 到最大线程数),计算其性能(例如同任务运行时间的倒数);性能是否是线性扩展的?为什么会出现拐点?

示例代码:很烂的矩阵乘

To compile: gcc mm.c -o mm -O3 -fopenmp

// mm.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <omp.h>

#define N 4096  // 矩阵大小 (越大越能体现内存访问差异)

void init_matrix(double *mat) {

    return;
}

void matrix_multiply(double *A, double *B, double *C, int size) {
    #pragma omp parallel for
    for (int i = 0; i < size; i++) {
        for (int k = 0; k < size; k++) {
            double tmp = A[i*size + k];
            for (int j = 0; j < size; j++) {
                C[i*size + j] += tmp * B[k*size + j];
            }
        }
    }
}

int main() {
    double *A, *B, *C;
    size_t size = N * N * sizeof(double);

    A = (double*)malloc(size);
    B = (double*)malloc(size);
    C = (double*)malloc(size);

    memset(A, 0x55, N*N * sizeof(double)); // 1.194531e+103
    memset(B, 0x55, N*N * sizeof(double));

    double start = omp_get_wtime();
    matrix_multiply(A, B, C, N);
    double end = omp_get_wtime();

    printf("Elapsed: %.3f seconds\n", (end - start));

    free(A);
    free(B);
    free(C);

    return 0;
}

(tip: 如果你对矩阵乘感兴趣可以学习 How to optimize DGEMM on x86 CPU platforms