这一篇在干嘛?

进程是操作系统最核心的抽象,本章把”进程”从概念落到数据结构:进程描述符长什么样、内核怎么用一条 esp 指令找到当前进程、切换进程时寄存器怎么搬、fork 到底复制了什么、进程死了之后尸体(僵尸)谁来收。学完这章,“内核在跑谁的代码”这个问题就有了精确答案。

图 1:第 3 章题图(Understanding the Linux Kernel, 3rd Edition)

进程、轻量级进程与线程 | 进程描述符 | 进程间的关系与等待队列 | 进程资源限制 | 进程切换 | FPU/MMX/SSE 寄存器的懒保存 | 创建进程 | 销毁进程

进程、轻量级进程与线程

进程的标准定义是”程序在执行中的一个实例”——可以把它想成完整描述”程序执行到了哪一步”的所有数据结构的集合。16 个用户同时跑 vi,就有 16 个独立进程(它们可以共享同一份可执行代码)。内核视角下,进程存在的意义是充当系统资源(CPU 时间、内存等)的分配对象。Linux 源码里进程常被称为 task 或 thread。

进程像人一样:被生出来、过或长或短的一生、可能生若干孩子、最后死去(书里还打趣:进程没有”性别”——每个进程只有一个父亲)。新进程创建时几乎和父进程一模一样:得到父进程地址空间的(逻辑)副本,从创建系统调用的下一条指令开始执行同样的代码;代码页可以共享,但栈和堆各有各的副本,孩子改内存父亲看不见。

从不支持线程到轻量级进程

现代 Unix 都支持多线程应用——一个程序由多条相对独立的执行流组成、共享大部分数据结构,通常用 POSIX 的 pthread 库编写。老版本 Linux 内核完全不感知线程:多线程应用就是一个普通进程,多条执行流全靠用户态的 pthread 库自己管理调度。这种做法有个硬伤:设想国际象棋程序有两个线程,一个等人落子并画棋盘,一个思考下一步。如果整个程序是一个进程,第一个线程不能发出阻塞的系统调用干等输入——否则思考线程也被一起堵死,只能用复杂的非阻塞技巧勉强维持。

Linux 的答案是轻量级进程(LWP):两个轻量级进程可以共享地址空间、打开文件等资源,一方修改共享资源另一方立刻可见(当然访问时要自行同步)。每个线程对应一个轻量级进程,就能同时享受两头好处——共享同一套应用数据结构,又各自能被内核独立调度(一个睡了另一个照跑)。LinuxThreads、NPTL、IBM NGPT 这些符合 POSIX 的 pthread 库都是这么实现的。

POSIX 1003.1c 要求多线程应用的所有线程有同一个 PID(比如发信号能一箭双雕命中全组)。为兼容这一点,Linux 引入线程组(thread group):一组实现同一多线程应用的轻量级进程,在 getpid()、kill()、_exit() 这类系统调用面前整体行动(详见下文 tgid 字段)。

进程描述符

内核要管理进程,就必须对每个进程了如指掌:优先级多少、在 CPU 上跑还是阻塞在等事件、分到哪个地址空间、能打开哪些文件……这些信息全部放在**进程描述符(process descriptor)**里,即 task_struct 类型的结构体。它是全本书最复杂的数据结构之一:除了海量属性字段,还有大量指向其他结构的指针。

图 2:Linux 进程描述符(原书图 3-1)——右侧六个数据结构对应进程拥有的具体资源

进程状态

state 字段是一组互斥的标志(同一时刻恰好一个被置位),刻画进程的当前状态:

状态含义
TASK_RUNNING正在 CPU 上执行,或在排队等待执行
TASK_INTERRUPTIBLE可中断睡眠:挂起直到某条件成真(硬件中断、资源释放、信号到来)后回到 TASK_RUNNING
TASK_UNINTERRUPTIBLE不可中断睡眠:信号也唤醒不了。很少用,但关键——比如驱动探测硬件设备时绝不能被打断,否则硬件可能被留在不可预测的状态
TASK_STOPPED进程被停止:收到 SIGSTOP、SIGTSTP、SIGTTIN 或 SIGTTOU 后进入
TASK_TRACED被调试器停止:调试器用 ptrace() 监控时,每个信号都可能让进程进入此状态
EXIT_ZOMBIE进程已终止,但父进程还没发 wait4()/waitpid() 来取信息——在此之前内核不能丢弃描述符里的数据,因为父进程可能还要用
EXIT_DEAD最终态:父进程刚发出 wait 类调用,进程正在被移除。从 ZOMBIE 改成 DEAD 是为了避免多个执行流同时对同一进程做 wait 类调用引发竞态

状态通常用简单赋值设置(p->state = TASK_RUNNING;),但内核更常用 set_task_state / set_current_state 宏——它们保证赋值指令不会被编译器或 CPU 重排到其他指令中间,乱序有时会带来灾难性后果(第 5 章)。

PID 与线程组

每个可独立调度的执行上下文都必须有自己的进程描述符,所以轻量级进程虽然共享大量内核数据结构,也各有 task_struct。描述符地址本身(32 位)就被内核当作进程标识——进程描述符指针,内核内部引用进程大多用它。

用户看到的是 PID:顺序分配,新建进程的 PID 通常是上一个 +1;到达上限(默认 32767,管理员可经 /proc/sys/kernel/pid_max 调整,64 位机最多可放大到 4,194,303)就回收低位未用的号。回收靠 pidmap_array 位图标记哪些 PID 在用;32 位架构上一个页框 32768 位正好装下,64 位架构可动态追加页(永不释放)。

POSIX 要求同组线程同 PID 怎么办?Linux 让线程组里所有线程共享线程组组长(组里第一个轻量级进程)的 PID,存于 tgid 字段。getpid() 返回的是 tgid 而不是 pid,于是全组”共用一个 PID”;而绝大多数进程的线程组只有一个成员(自己),tgid == pid,getpid() 行为照旧。每个轻量级进程的 pid 仍然互不相同——这是最大的灵活性来源。

thread_union:描述符 + 内核栈打包

进程寿命从几毫秒到几个月不等,描述符因此放在动态内存而非内核永久区。Linux 为每个进程把两个数据结构打包进同一块(通常 8KB = 两个页框的)内存:thread_info 结构(与描述符互相链接的小结构)和内核态进程栈。第一个页框按 2¹³ 对齐;内存紧张时连续两页难找,所以编译时可配置成单页框 4KB——此时内核会借助额外的栈来避免深嵌套中断导致的溢出(第 4 章)。内核栈只几千字节就够用,8KB 绰绰有余。

图 3:thread_info 结构与进程内核栈共存于两个页框(原书图 3-2)——thread_info 在开头,栈从末端向下生长,两者经 task / thread_info 字段互指

C 语言用 union 表达这种”共用一块内存”的关系:

union thread_union {
    struct thread_info thread_info;
    unsigned long stack[2048]; /* 4KB 栈时为 1024 */
};

图 3 的例子:thread_info 位于 0x015fa000,栈从 0x015fc000 向下长,esp 指向当前栈顶 0x015fa878。thread_info 只有 52 字节,栈最多能扩到 8140 字节。alloc_thread_info / free_thread_info 宏负责分配和释放这块内存。

这个打包设计带来了全章最漂亮的一个技巧:从 esp 寄存器值直接算出当前进程的 thread_info 地址——8KB 联合体时只需把 esp 低 13 位屏蔽掉:

movl $0xfffffe000,%ecx  /* 4KB 栈时用 0xfffff000 */
andl %esp,%ecx
movl %ecx,p

三条指令后,p 就是当前 CPU 上运行进程的 thread_info 指针。内核更常要进程描述符本身,current 宏(等价于 current_thread_info()->task,task 字段恰在偏移 0 处)多一条间接:andl %esp,%ecx; movl (%ecx),p。内核代码里到处可见 current->pid 这种写法,指的就是”当前进程”。多处理器系统上这个设计尤其占便宜:每个 CPU 查自己的栈就知道自己该跑谁,而老版本 Linux 得为每个 CPU 维护一个全局 current 变量数组。

双向链表与进程链表

内核到处用链表,于是抽象出通用的 list_head 结构:只有 next、prev 两个指针,指向其他 list_head 字段而不是宿主结构(见图 4a)。LIST_HEAD(list_name) 宏声明一个哑元头节点并把 prev/next 都指向自己(图 4b)。常用原语见表:

函数/宏作用
list_add(n,p)把 n 插到 p 之后(表头插入时 p 取头节点地址)
list_add_tail(n,p)把 n 插到 p 之前(表尾插入时 p 取头节点地址)
list_del(p)删除 p 指向的元素(无需指定表头)
list_empty(p)判断链表是否为空
list_entry(p,t,m)由 list_head 字段地址反推宿主结构(类型 t、字段名 m)的地址
list_for_each(p,h) / list_for_each_entry(p,h,m)遍历链表,分别返回 list_head 地址 / 宿主结构地址

图 4:用 list_head 构建的双向链表(原书图 3-3)

进程链表把所有现存的进程描述串成一条双向循环链表:每个 task_struct 的 tasks 字段(list_head 类型)前后互指,链表头是 init_task 的描述符——也就是进程 0 / swapper(见 s7)。SET_LINKS / REMOVE_LINKS 宏负责插入/移除(顺带维护父子关系),for_each_process 宏扫描全表:

#define for_each_process(p) \
    for (p=&init_task; (p=list_entry((p)->tasks.next, \
    struct task_struct, tasks) \
    ) != &init_task; )

可运行进程队列(runqueue):调度器找下一个进程时只该看 TASK_RUNNING 状态的。老版本把它们放同一条链表,为保持按优先级有序代价太高,只能整体扫描。Linux 2.6 的思路是用空间换常数时间:把 runqueue 按优先级拆成 140 条链表(每个进程描述符有 run_list 字段,优先级 k 就挂进第 k 条),每 CPU 各有一套。所有链表由一个 prio_array_t 结构描述:nr_active(挂入的进程数)、bitmap(5 个 unsigned long 的位图,第 k 位为 1 当且仅当优先级 k 的链表非空)、queue[140](140 个链表头)。查位图找最高非空优先级、取链表头,常数时间搞定。enqueue_task(p,array) 的代码一目了然:

list_add_tail(&p->run_list, &array->queue[p->prio]);
__set_bit(p->prio, array->bitmap);
array->nr_active++;
p->array = array;

这是”把数据结构做复杂来换性能”的经典范例。细节在第 7 章展开。

进程间的关系与等待队列

父子、兄弟与其他关系

程序创建的进程构成父子关系,多孩子之间是兄弟关系。描述符里表达这些关系的字段(针对进程 P):

字段含义
real_parent创建 P 的进程;若其父已不存在则指向进程 1(init)——比如用户后台起个进程然后退出 shell,后台进程就归了 init
parentP 的当前父进程(子进程终止时被通知的那个),通常等于 real_parent;例外:别的进程用 ptrace() 监控 P 时两者可能不同
childrenP 创建的所有孩子组成的链表头
sibling兄弟链表(同父进程)的前后指针

图 5:五个进程间的父子关系(原书图 3-4)——P0 依次创建 P1、P2、P3,P3 又创建 P4

非亲属关系也有字段:group_leader(组长描述符指针)、signal->pgrp(进程组长 PID)、tgid(线程组长 PID)、signal->session(会话组长 PID)、ptrace_children/ptrace_list(被调试器跟踪的孩子)。

PID 哈希表

内核经常要从 PID 反推描述符指针(比如 kill() 系统调用)。顺序扫进程链表太慢,于是建了四张哈希表——为什么四张?因为描述符里有四种 PID:pid(PIDTYPE_PID)、tgid(PIDTYPE_TGID)、pgrp(PIDTYPE_PGID)、session(PIDTYPE_SID),各需一张表。表在内核初始化时动态分配,地址存于 pid_hash 数组;表大小随内存而定(512MB 内存的系统每表 4 个页框、2048 项)。散列函数 pid_hashfn 展开为 hash_long(x, pidhash_shift),而 hash_long 的核心一行是:

unsigned long hash_long(unsigned long val, unsigned int bits)
{
    unsigned long hash = val * 0x9e370001UL;
    return hash >> (32 - bits);
}

魔数 0x9e370001 的来历

这个哈希靠乘一个大素数让 32 位结果溢出,余下的效果等同于取模。Knuth 建议:乘数取与 2³² 成黄金分割比例的素数效果最好。2,654,404,609 正是接近 2³² × (√5 − 1)/2 的素数,而且能分解成 2³¹ + 2²⁹ − 2²⁵ + 2²² − 2¹⁹ − 2¹⁶ + 1——只用加法和移位就能算乘法。

不同 PID 散列到同一槽位就是碰撞,Linux 用链地址法解决:每个表项是双向链表的头。之所以用哈希+链而不是把 32768 个 PID 一一线性映射到表,是因为任一时刻系统进程数远小于 32768,建大表纯属浪费。

数据结构还有个难点:按 tgid 查线程组只查到组长一个人,要快速取回全组成员,必须为每个 PID 号维护一条进程链表(per-PID list)。核心结构是嵌在描述符 pids 字段里的四个 pid 结构(nr=PID 号,pid_chain=哈希链前后指针,pid_list=per-PID 链表头)。

图 6:PID 哈希表与链地址法(原书图 3-5)——PID 2890 和 29384 碰撞在第 200 项

图 7:PID 哈希表数据结构(原书图 3-6)——以 PIDTYPE_TGID 表为例:第 71 项的链上有 PID 246 和 4351,4351 线程组的 per-PID 链表挂在组长的 pid_list 字段上

操作这些表的函数:find_task_by_pid_type(type, nr) 按类型查哈希表返回描述符指针(找不到返回 NULL);attach_pid(task, type, nr) 插入(同 PID 已存在就挂进它的 per-PID 链表);detach_pid(task, type) 移除(链表空了才从哈希表摘除;PID 不再出现在任何表里才清位图,号码可回收);next_thread(task) 返回 TGID 链表中的下一个轻量级进程;do_each_task_pid/while_each_task_pid 遍历 per-PID 链表。

进程的组织与等待队列

TASK_RUNNING 的进程进了 runqueue;其他状态各有去处:STOPPED / ZOMBIE / DEAD 的进程不进任何专用链表——它们通过 PID 或父进程的孩子链表访问就够了;INTERRUPTIBLE / UNINTERRUPTIBLE 的进程按等待的事件分类,挂入等待队列(wait queue)

等待队列实现”对事件的条件等待”:想等某事件的进程把自己挂进对应队列然后让出 CPU;事件发生时内核唤醒队列里的睡眠进程。它是双向链表,头节点带自旋锁(因为等待队列会被中断处理程序和内核大函数并发修改,不同步会出乱子):

struct __wait_queue_head {
    spinlock_t lock;
    struct list_head task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;

队列元素 wait_queue_t 里的 task 指向睡眠进程描述符,flags 区分两类睡眠者:独占进程(flags=1,事件发生时内核选择性唤醒)和非独占进程(flags=0,一律唤醒)。独占的典型场景:多个进程争一个只能给一个人的资源——全唤醒只会制造”惊群(thundering herd)“:一帮进程醒来抢一个资源,抢到的只有一个,其余白醒一遍再睡回去。反之等”一组磁盘块传输全部完成”的进程就该是非独占的,大家一起醒。

睡眠与唤醒的 API 是本章的重点操练场:

void sleep_on(wait_queue_head_t *wq)
{
    wait_queue_t wait;
    init_waitqueue_entry(&wait, current);
    current->state = TASK_UNINTERRUPTIBLE;
    add_wait_queue(wq, &wait); /* wq 指向等待队列头 */
    schedule();
    remove_wait_queue(wq, &wait);
}

sleep_on() 置状态、入队、调调度器睡过去,醒来后出队。interruptible_sleep_on() 只是换成 TASK_INTERRUPTIBLE(信号也能唤醒);两个带 timeout 的版本用 schedule_timeout() 限时唤醒。2.6 新增的 prepare_to_wait()/prepare_to_wait_exclusive()/finish_wait() 更灵活,标准用法:

DEFINE_WAIT(wait);
prepare_to_wait_exclusive(&wq, &wait, TASK_INTERRUPTIBLE);
    /* wq 是等待队列头 */
...
if (!condition)
    schedule();
finish_wait(&wq, &wait);

要点:sleep_on() 类函数无法原子地”检查条件、条件不成立就睡”,是著名的竞态源头,已不推荐使用;独占插入必须用 prepare_to_wait_exclusive()(或直接 add_wait_queue_exclusive()),其他辅助函数一律按非独占插入。wait_event(wq, condition) 宏把”循环检查条件—不成立就睡—醒来再查”包成一个循环,用起来最省心。

唤醒由一组 wake_up 宏完成,命名规律:带 interruptible 的只管 TASK_INTERRUPTIBLE 的睡眠者,不带的同时也管 UNINTERRUPTIBLE 的;非独占进程一律全醒;带 nr 的唤醒指定个数个独占进程、带 all 的唤醒全部独占进程、都不带的只醒一个;带 sync 的不检查”被唤醒者优先级是否更高、要不要立即重新调度”,因此高优先级进程可能被稍微推迟;wake_up_locked 用于已持有队列自旋锁的场合。非独占进程都排在链表前部、独占的排后部,所以 wake_up 总是先全醒非独占的、再按需唤醒独占的。

进程资源限制

每个进程有一组资源上限,防止用户压垮系统(CPU、磁盘空间等)。限值存在 current->signal->rlim 数组里,每项是一个 struct rlimit { unsigned long rlim_cur; unsigned long rlim_max; }。常用限制:

限制含义
RLIMIT_AS进程地址空间最大字节数(malloc 扩空间时检查)
RLIMIT_COREcore dump 文件最大字节数(为 0 则不生成 core 文件)
RLIMIT_CPU最大 CPU 时间(秒);超限先收 SIGXCPU,不退再收 SIGKILL
RLIMIT_DATA堆最大字节数(扩堆前检查)
RLIMIT_FSIZE最大文件字节数;超限写文件收 SIGXFSZ
RLIMIT_NOFILE打开文件描述符最大个数
RLIMIT_NPROC用户可拥有的最大进程数
RLIMIT_MEMLOCK不可换出的内存上限(mlock 时检查)

rlim_cur 是当前软限制,rlim_max 是硬上限:用户可用 getrlimit()/setrlimit() 把 cur 提到 max,但只有超级用户(确切说持有 CAP_SYS_RESOURCE 能力者)能提高 max 或把 cur 设得超过 max。多数限制默认 RLIM_INFINITY(0xffffffff,不设限)。管理员下放限制的手法很巧:用户登录时内核先建一个超级用户的进程,由它 setrlimit() 压低限制,再让这个进程执行登录 shell”变身”为用户进程——之后用户的所有子进程继承 rlim 数组,想改也改不回去。

进程切换

进程切换(task switch / context switch)= 挂起 CPU 上的当前进程、恢复另一个先前挂起的进程。每个进程可以有私有地址空间,但 CPU 寄存器是全体进程共享的——恢复执行前,必须把每个寄存器装回该进程被挂起时的值。这组要装载数据叫硬件上下文(执行上下文的子集),Linux 把它一部分存在进程描述符里、一部分存在内核态栈上。切换频繁发生,省时间至关重要。

80x86 硬件其实提供了一条 far jmp 到 TSS 描述符选择符就自动完成上下文切换的指令,老版本 Linux 用过;2.6 改用软件切换,理由:逐条 mov 的方式能检查将要装载的数据合法性(尤其 ds、es 段寄存器可能被恶意用户伪造,单条 far jmp 没法查);耗时差不多,而软件实现还有优化空间。注意:进程切换只发生在内核态,用户态寄存器在切换前已经存到内核栈上了(包括用户态栈指针 ss:esp)。

TSS:为什么 Linux 不用它却必须留着它

80x86 的**任务状态段(TSS)**本是为存硬件上下文设计的,Intel 原设想一进程一个 TSS。Linux 一 CPU 只放一个 TSS(init_tss 数组),但仍被迫配置它,因为两个硬性依赖:

  1. CPU 从用户态切入内核态时,要从 TSS 取内核栈地址(见 /linux内核/lk04/linux内核/lk10);
  2. 用户态进程执行 in/out 指令访问 I/O 端口时,CPU 要查 TSS 里的 I/O 许可位图:先看 eflags 的 2 位 IOPL 字段(=3 直接放行),否则经 tr 寄存器找到当前 TSS 查位图对应位——位清 0 才执行,置 1 就触发”General protection”异常。

每个 TSS 有自己的 8 字节 TSSD 描述符(S=0 系统段,Type=9 或 11)。Intel 原设计里 Type 字段的次低位是 Busy 位(进程在 CPU 上执行时为 1);Linux 一 CPU 一个 TSS,Busy 位恒 1。TSSD 放 GDT,每 CPU 的 tr 寄存器存对应选择符(含隐藏的 Base/Limit 字段,CPU 不必再查 GDT)。每次进程切换内核更新 TSS 的一些字段即可——TSS 只反映”当前在跑的进程”,闲置进程无需维护。

thread 字段与 switch_to

既然不能存 TSS(那是 CPU 的,不是进程的),被换下进程的硬件上下文就存进描述符的 thread 字段(thread_struct 类型)——包括大多数寄存器;通用寄存器(eax 等)的值在内核栈上。

切换发生在 schedule() 函数内(第 7 章),两步走:① 切换页全局目录装上新地址空间(第 9 章);② 切换内核栈和硬件上下文——由 switch_to 宏完成,这是全内核硬件相关性最强的代码之一。

switch_to 有三个参数:prev、next 好理解(被换下/换上的进程描述符地址所在内存位置),第三个 last 参数最烧脑:一次进程切换其实涉及三个进程。A 换成 B 时,A 的执行流冻结;等内核某天要把 A 换回来时,被换下的是第三个进程 C。A 恢复执行后,它的栈上 prev 指向 A、next 指向 B——调度器从此失去了对 C 的引用!但完成切换收尾(见第 7 章)恰恰需要知道 C 是谁。于是:宏先把 prev 的值(A 的栈上的局部变量)存进 eax,切换完成 A 复活后,把 eax 写进 last 指定的内存位置——eax 跨越整个切换期间没被动过,里面还是 C 的地址。schedule() 里的 last 参数就指向 A 的 prev 变量,prev 被覆写成 C 的地址。

图 8:进程切换中保留对进程 C 的引用(原书图 3-7)——注意图中 prev 画的是被 eax 覆写前的值

switch_to 用扩展内联汇编写成(靠位置记号让编译器自由选寄存器),用标准汇编描述它干的九件事:

  1. movl prev,%eax; movl next,%edx——保存 prev、next;
  2. pushfl; pushl %ebp——把 eflags 和 ebp 压入 prev 的内核栈(编译器假定这两个寄存器到 switch_to 结束都不变);
  3. movl %esp,484(%eax)——把 esp 存进 prev->thread.esp,让它指向 prev 内核栈顶;
  4. movl 484(%edx),%esp——把 next->thread.esp 装进 esp。从此刻起内核改用 next 的内核栈——这条指令才是真正的进程切换。进程描述符地址与内核栈紧密相关(thread_union!),换栈即换进程;
  5. movl $1f,480(%eax)——把标号 1 的地址存进 prev->thread.eip(prev 恢复执行时从这继续);
  6. pushl 480(%edx)——把 next->thread.eip 压到 next 的内核栈上(通常是标号 1 的地址);
  7. jmp __switch_to——跳进 C 函数干重活;
  8. 1: popl %ebp; popfl——A 重新获得 CPU 后从标号 1 恢复 eflags 和 ebp(此刻 esp 指的是 prev 的栈);
  9. movl %eax,last——完成 last 参数的写入(eax 里是刚被换下的 C 的描述符地址)。

__switch_to() 函数

C 函数 __switch_to() 完成切换的绝大部分工作。它的参数不走栈而是从 eax、edx 寄存器取——用 gcc 非标准扩展声明:__switch_to(struct task_struct *prev_p, struct task_struct *next_p) __attribute__(regparm(3));。它做的事:

  1. __unlazy_fpu(prev_p)——必要时保存 prev 的 FPU/MMX/XMM 寄存器(见 s6);
  2. smp_processor_id() 取本地 CPU 号;
  3. init_tss[cpu].esp0 = next_p->thread.esp0;——更新 TSS 的 esp0,将来 sysenter 指令引发的用户态→内核态切换会把这个地址装进 esp(第 10 章);
  4. 把 next 的三个 TLS 段装进本地 CPU 的 GDT(GDT 第 6、7、8 项 ← next_p->thread.tls_array[0..2],TLS 概念见 /linux内核/lk02);
  5. 把 fs、gs 段寄存器的值存进 prev->thread.fs/gs(movl %fs,40(%esi) 等);
  6. 若 fs 或 gs 非零,把 next->thread 里存的值装回寄存器(movl 40(%ebx),%fs 等)——实际代码更绕,因为段寄存器值非法时 CPU 会触发异常,代码用”修正(fix-up)“手段兜底(第 10 章);
  7. 若 next 被挂起时在用调试寄存器(next_p->thread.debugreg[7] 非 0),装载 dr0~dr7 中的六个(无 4、5)——不需要”存”,因为 prev 的 debugreg 数组只在调试器想监控 prev 时才被改:
if (next_p->thread.debugreg[7]){
    loaddebug(&next_p->thread, 0);
    loaddebug(&next_p->thread, 1);
    loaddebug(&next_p->thread, 2);
    loaddebug(&next_p->thread, 3);
    /* 无 4 和 5 */
    loaddebug(&next_p->thread, 6);
    loaddebug(&next_p->thread, 7);
}
  1. 必要时更新 TSS 的 I/O 位图——采用惰性策略:只要 prev 或 next 有自己的定制 I/O 许可位图就调 handle_io_bitmap(),但真位图只在当前时间片内真的发生 I/O 端口访问时才拷贝进 TSS。做法是故意把 TSS 的 io_bitmap 字段设成指向 TSS 限界之外的非法值(无定制位图 0x8000、有定制位图 0x9000):用户态一碰 I/O 端口就触发 General protection 异常,do_general_protection() 查该字段——0x8000 就给进程发 SIGSEGV;0x9000 就把进程真位图拷进 TSS、改成合法偏移 104、重新执行出错指令。进程极少改位图,这样根本不用每次切换都搬大位图;
  2. return prev_p;——编译成 movl %edi,%eax; ret。eax 装着返回值 prev_p,保证跨越整个 __switch_to() 调用 eax 仍是”被换下进程的描述符地址”——switch_to 宏依赖这一点。而 ret 指令从栈顶取返回地址:__switch_to 是被 jmp 进来的,栈顶正是 switch_to 压入的标号 1 地址;若 next 是第一次执行的新进程,那里存的是 ret_from_fork() 的起始地址(见 s7)。

FPU/MMX/SSE 寄存器的懒保存

80486DX 起 FPU 集成进 CPU(“数学协处理器”是历史叫法),浮点运算用 ESCAPE 指令(前缀字节 0xd8~0xdf)操作浮点寄存器。后来的 MMX 指令复用浮点寄存器(代价是不能与浮点指令混用,好处是内核的保存机制自动覆盖 MMX);Pentium III 的 SSE 增加 8 个 128 位 XMM 寄存器(与 FPU/MMX 不重叠,可随意混用);Pentium 4 的 SSE2 同用 XMM 寄存器、支持更高精度。

80x86 不自动保存这些寄存器(连 TSS 都不存),但提供硬件支持让内核”需要时才存”:cr0 的 TS(Task-Switching)标志——硬件上下文切换发生时 TS 置 1;TS=1 时执行任何 ESCAPE/MMX/SSE/SSE2 指令会触发”Device not available”异常。于是可以懒保存:A 用着协处理器,切到 B 时内核置 TS、把浮点寄存器存进 A 的描述符;B 不用协处理器就什么都不恢复,B 一碰浮点指令立刻异常,由异常处理程序把 B 的值装回去。整机九成进程切换都省掉了搬浮点寄存器的开销。

数据结构:描述符的 thread.i387 字段,类型是三选一的 union:

union i387_union {
    struct i387_fsave_struct   fsave;  /* 有协处理器/可选 MMX 的 CPU */
    struct i387_fxsave_struct  fxsave; /* 支持 SSE/SSE2 的 CPU */
    struct i387_soft_struct    soft;   /* 无协处理器的老 CPU,软件模拟 */
};

两个相关标志:thread_info 的 TS_USEDFPU(本次执行是否用过 FPU/MMX/XMM)和 task_struct 的 PF_USED_MATH(thread.i387 内容是否有效)。后者在两种情况被清掉:进程 execve() 换了新程序(旧浮点值永远不会被用);进程开始执行信号处理程序(信号异步于主流程,浮点寄存器对处理程序可能无意义——但内核会先存好旧值、处理程序结束后恢复,所以处理程序里照样可以用浮点)。

保存:__switch_to() 开头的 __unlazy_fpu(prev_p) 查 prev 的 TS_USEDFPU,置位则调 save_init_fpu():一条 asm volatile("fxsave %0 ; fnclex" : "=m" (prev->thread.i387.fxsave));(支持 SSE/SSE2 时)或 fnsave %0 ; fwait(否则)把寄存器倾倒进描述符并复位协处理器;清 TS_USEDFPU;用 stts() 置 cr0 的 TS 标志。

加载:next 恢复执行时并不立即恢复浮点寄存器(TS 已被置位)。它第一次执行浮点指令时触发”Device not available”异常,处理程序调 math_state_restore():

void math_state_restore()
{
    asm volatile ("clts"); /* 清 cr0 的 TS 标志 */
    if (!(current->flags & PF_USED_MATH))
        init_fpu(current);
    restore_fpu(current);
    current->thread.status |= TS_USEDFPU;
}

清 TS 后续浮点指令不再触发异常;PF_USED_MATH 无效则 init_fpu() 复位 thread.i387;restore_fpu() 按是否支持 SSE/SSE2 用 fxrstor 或 frstor 装载;最后置 TS_USEDFPU。

内核自己也能用协处理器,但要守规矩:用前调 kernel_fpu_begin()(内部先 save_init_fpu() 存下用户进程的寄存器、清 TS),用后调 kernel_fpu_end()(重新置 TS),之后用户进程再碰浮点指令照常走 math_state_restore() 恢复。注意 kernel_fpu_begin() 在用户进程正用协处理器时代价很大,足以吞掉内核用 SIMD 赚来的加速——所以内核只在少数地方用它:搬移/清零大块内存、算校验和。

创建进程

Unix 靠疯狂创建进程满足用户请求(shell 收到命令就 fork 一个新进程去执行)。传统做法把父进程的资源整个复制给子进程——又慢又蠢:孩子往往根本不需要全部继承资源,常常 execve() 一来,刚精心复制的地址空间整个作废。现代内核用三个机制化解:

  1. 写时复制(Copy On Write):父子先读同一批物理页,谁写谁触发内核复制该页(实现见 /linux内核/lk09);
  2. 轻量级进程:父子可共享页表(即整个用户态地址空间)、打开文件表、信号处置方式等;
  3. vfork():创建共享父进程内存地址空间的进程,父进程被阻塞到孩子退出或换程序为止,防止父进程覆盖孩子要用的数据。

clone()、fork()、vfork()

Linux 用 clone() 创建轻量级进程,参数:fn(新进程要执行的函数,返回即退出)、arg(传给 fn 的数据)、flags(低字节是子进程终止时发给父进程的信号号,一般 SIGCHLD;高三个字节是克隆标志)、child_stack(子进程的用户态栈指针,父进程应当为它分配新栈)、tls(CLONE_SETTLS 时指定线程局部存储段)、ptid/ctid(CLONE_PARENT_SETTID / CLONE_CHILD_SETTID 时存放 PID 的用户态变量地址)。克隆标志决定共享什么:

标志共享内容
CLONE_VM内存描述符和全部页表
CLONE_FS根目录/当前工作目录表 + 文件创建权限掩码 umask
CLONE_FILES打开文件表
CLONE_SIGHAND信号处理程序表、被阻塞/待处理信号表(须与 CLONE_VM 同用)
CLONE_THREAD加入父进程的线程组,共享信号描述符,tgid/group_leader 随之设置(须与 CLONE_SIGHAND 同用)
CLONE_PARENT把孩子的父进程设为调用者的父进程
CLONE_VFORK标记这是 vfork()
CLONE_NEWNS要自己的命名空间(挂载文件系统的视图);不能与 CLONE_FS 同用
CLONE_SETTLS为轻量级进程创建 TLS 段
CLONE_PTRACE / CLONE_UNTRACED跟踪相关的传递与禁止

clone() 其实是 C 库包装函数:它布置好新进程的栈(把 fn 指针放在包装函数自己的返回地址位置、arg 放在 fn 下面),再调用对程序员隐藏的 clone() 系统调用——所以内核的 sys_clone() 没有 fn 和 arg 参数;包装函数返回时 CPU 从栈上取”返回地址”,实际执行的就是 fn(arg)。

fork() = clone() 且所有克隆标志清零 + SIGCHLD + child_stack 取父进程当前栈指针——父子暂时共享用户态栈,谁先写栈谁触发写时复制拿到副本。vfork() = clone() 且 CLONE_VM | CLONE_VFORK + SIGCHLD + 同样的栈

do_fork() 与 copy_process()

clone()/fork()/vfork() 三个系统调用都由 do_fork() 处理,主要步骤:

  1. 从 pidmap_array 位图给子进程分配新 PID;
  2. 检查父进程的 ptrace 字段:父进程正被跟踪且调试器想跟踪孩子时,置 CLONE_PTRACE;
  3. copy_process() 复制进程描述符——真正的干活的,返回新 task_struct 地址;
  4. CLONE_STOPPED 或需要跟踪时,子进程状态置 TASK_STOPPED 并挂一个待处理的 SIGSTOP(直到别人用 SIGCONT 之类恢复它);
  5. 否则调 wake_up_new_task():调整父子调度参数;若孩子在同一 CPU 上跑且不共享页表,把孩子插到父进程的 runqueue 里父进程前面——孩子先跑:fork 后紧跟 execve() 的场景下,若让父先跑,写时复制会白复制一堆页(孩子马上就把地址空间换掉了);孩子不在同一 CPU 或共享页表(CLONE_VM)时排在队尾;
  6. CLONE_VFORK 时把父进程挂入等待队列,睡到孩子释放地址空间(终止或 execve)为止;
  7. 返回子进程 PID。

copy_process() 的要点(28 步,拣要紧的说):

  • 先查标志相容性(CLONE_NEWNS 与 CLONE_FS 互斥;CLONE_THREAD 必须带 CLONE_SIGHAND;CLONE_SIGHAND 必须带 CLONE_VM——同组轻量级进程必须共享信号,共享信号必须共享内存描述符),再做安全钩子检查(第 20 章);
  • dup_task_struct():先 __unlazy_fpu() 存好父进程的浮点寄存器(待会儿连值一起复制),alloc_task_struct() 拿描述符、alloc_thread_info 拿”thread_info+内核栈”内存块,把 current 的两个结构整体复制过来并互指,usage 计数置 2(活着且在用);
  • 资源检查:用户进程数是否超过 rlim[RLIMIT_NPROC](root 豁免)、系统总进程数 nr_threads 是否超过 max_threads(默认规则:所有 thread_info + 内核栈不得超过物理内存的 1/8,可经 /proc/sys/kernel/threads-max 调整);
  • 初始化关键字段:lock_depth = -1、did_exec = 0、清 PF_SUPERPRIV、置 PF_FORKNOEXEC;
  • copy_semundo()/copy_files()/copy_fs()/copy_sighand()/copy_signal()/copy_mm()/copy_namespace() 按 clone_flags 决定”新建+复制”还是”共享引用”;
  • copy_thread():用系统调用发出时保存的 CPU 寄存器值初始化子进程内核栈——但强行把 eax 对应字段清 0(这就是子进程 fork() 返回 0 的机关!);thread.esp 指向子进程内核栈基址;thread.eip 存 ret_from_fork() 汇编函数地址;
  • sched_fork():完成调度数据结构初始化,状态置 TASK_RUNNING,preempt_count 置 1(禁内核抢占),并把父进程剩余时间片平分给父子(保持调度公平,第 7 章);
  • SET_LINKS 挂入进程链表;attach_pid() 挂入 PIDTYPE_PID 哈希表;是组长(CLONE_THREAD 清)则 tgid=pid、group_leader=自己、再挂 TGID/PGID/SID 三张表;属于父线程组则 tgid 和 group_leader 继承父进程的、挂进组长的 per-PID 链表。

copy_process() 返回后,子进程已是”万事俱备、只欠 CPU”。将来某个进程切换点,调度器用子进程描述符 thread 字段的值装载寄存器:esp ← thread.esp(子进程内核栈),eip ← ret_from_fork()。这个汇编函数调 schedule_tail()(内部 finish_task_switch() 收尾切换,第 7 章)、从栈上重装其余寄存器、切回用户态——新进程就从 fork()/vfork()/clone() 系统调用的结尾开始执行,eax 里是返回值:子进程得 0,父进程得子 PID。应用程序员写 if (fork() == 0) { 子进程逻辑 } else { 父进程逻辑 } 就是这么实现的。

内核线程

内核把刷盘缓存、换页、服务网络连接这类关键杂务交给内核线程——在后台被调度比严格线性执行高效得多,而且它们只在内核态运行、不带多余的用户态上下文。与普通进程的三点不同:只在内核态运行;只用大于 PAGE_OFFSET 的线性地址;没有自己的用户态上下文。创建用 kernel_thread(fn, arg, flags),内部就是 do_fork(flags|CLONE_VM|CLONE_UNTRACED, 0, pregs, 0, NULL, NULL)——CLONE_VM 免得复制根本不会被访问的用户态页表;CLONE_UNTRACED 保证谁也跟踪不了它。pregs 指向的栈区里,ebx/edx 预置为 fn/arg,eip 指向:

movl %edx,%eax
pushl %edx
call *%ebx
pushl %eax
call do_exit

即线程一开始就执行 fn(arg),函数返回后把返回值当退出码调 do_exit()。

进程 0(idle / swapper,所有进程的祖先)是初始化阶段从零创建的内核线程,用静态分配的数据结构:init_task 变量里的描述符(INIT_TASK 宏)、init_thread_union 里的 thread_info + 内核栈、init_files/init_signals/init_sighand 三张表、主内核页全局目录 swapper_pg_dir(见 /linux内核/lk02)。start_kernel() 初始化完一切、开中断后创建进程 1:kernel_thread(init, NULL, CLONE_FS|CLONE_SIGHAND);。随后进程 0 执行 cpu_idle()——开着中断反复执行 hlt 指令;只有系统里没有任何 TASK_RUNNING 进程时调度器才选它。多处理器系统每个 CPU 有一个进程 0:BIOS 上电先启动 CPU 0,CPU 0 上的 swapper 初始化完内核数据结构后启用其余 CPU 并用 copy_process()(PID 传 0)再造出额外的 swapper。

进程 1(init):先以内核线程身份执行 init() 函数完成内核初始化,然后 execve() 装载 /sbin/init 可执行程序,摇身变成有自己全套进程数据结构的普通进程。它创建并监控实现操作系统外层的所有进程,活到关机为止(收养孤儿僵尸的机制见 /linux内核/lk01)。

其他常见内核线程:keventd(跑 keventd_wq 工作队列,第 4 章)、kapmd(高级电源管理事件)、kswapd(回收内存,第 17 章)、pdflush(把脏缓冲刷盘,第 15 章)、kblockd(周期性激活块设备驱动,第 14 章)、ksoftirqd(跑 tasklet,第 4 章,每 CPU 一个)。

销毁进程

进程的常规死法是执行完该跑的代码:exit() 库函数(程序员显式调用,或编译器自动插在 main() 最后一条语句之后)释放 C 库资源、执行注册的清理函数,最后触发系统调用把进程逐出系统。内核也可能强迫整个线程组死亡——典型情形是收到无法处理/无法忽略的信号(第 11 章),或内核态发生不可恢复的 CPU 异常(第 4 章)。

Linux 2.6 有两个终止系统调用:exit_group() 终止整个线程组(exit() 库函数应调它,内核函数 do_group_exit());_exit() 只终止单个进程、不管组里其他人(LinuxThreads 的 pthread_exit() 调它,内核函数 do_exit())。

do_group_exit():查 SIGNAL_GROUP_EXIT 标志——已置说明退出流程已启动,直接取 group_exit_code 当退出码走第 4 步;否则置标志、存退出码;调 zap_other_threads() 沿 TGID 哈希表的 per-PID 链表给组里其他进程(自己除外)逐个发 SIGKILL(它们最终都会执行 do_exit());最后调 do_exit()——它永不返回。

do_exit() 负责所有进程的死亡善后:

  1. 置 PF_EXITING 标志(表示正在被清除);
  2. del_timer_sync() 把描述符从动态定时器队列摘掉(第 6 章);
  3. 依次用 exit_mm()、exit_sem()、__exit_files()、__exit_fs()、exit_namespace()、exit_thread() 摘除分页、信号量、文件系统、打开文件、命名空间、I/O 许可位图相关的数据结构——没有别人共享时就地释放;
  4. 执行域/可执行格式在模块里的话递减模块使用计数;
  5. 把终止码存进 exit_code 字段;
  6. exit_notify():更新父子关系——所有孩子过继给同线程组的其他进程(有活着的)或 init;检查 exit_signal ≠ -1 且自己是线程组最后一个成员(普通进程恒成立):是则向父进程发 SIGCHLD 报丧;exit_signal 为 -1 且未被跟踪的(如线程组里的普通轻量级进程死亡)则置 EXIT_DEAD 并调 release_task() 回收;否则置 EXIT_ZOMBIE 等父进程来收;
  7. 调 schedule() 换进程——ZOMBIE 状态被调度器无视,进程从此不再执行。

僵尸与进程回收

为什么不能进程一死就立刻销毁描述符?因为 Unix 允许父进程查询孩子的终止状态(wait 类调用):内核必须等父进程发过 wait 类系统调用才能丢弃描述符数据——这就是 EXIT_ZOMBIE 存在的意义:进程技术上已死,描述必须保存到父进程被通知为止。若父进程先死呢?孤儿们全部过继给 init,由 init 的 wait 例行调用顺手收尸,系统不会被永不消亡的僵尸淹没。

release_task() 对僵尸做最后的清理,有两条触发路径:do_exit() 里父进程对报丧没兴趣(此时由调度器完成内存回收,第 7 章),或父进程收到 SIGCHLD 后的 wait4()/waitpid()(此时就地回收描述符内存)。步骤:递减 user_struct 里的用户进程计数;被跟踪则从调试器列表移除、归还给亲生父母;__exit_signal() 取消待处理信号、释放 signal_struct、exit_itimers() 摘除 POSIX 间隔定时器;__exit_sighand() 清理信号处理程序;__unhash_process()——nr_threads 减 1、detach_pid() 两次摘出 PID/TGID 哈希表(是组长再两次摘 PGID/SID 表)、REMOVE_LINKS 摘出进程链表;是组长的最后一名组员则通知组长父进程;sched_exit() 调整父进程时间片(与 copy_process() 的时间片平分互补);put_task_struct() 递减 usage 计数——归零则释放 user_struct 引用、最终释放进程描述符和”thread_info+内核栈”内存块。

通关标准:能画出 thread_union 的内存布局并写出从 esp 求 current 的三行汇编;能解释 switch_to 为什么需要第三个参数 last(A→B→…→C 的三角关系);能说出 fork 返回值 0/PID 分别写在哪一步;能讲清僵尸态为什么必须存在、init 如何收尸。

常见坑:以为 fork 会复制一切

fork 几乎什么都不复制——代码页共享,数据页写时复制,内核数据结构是”新建+选择性引用”(clone_flags 决定共享哪些)。真正立即发生的只有:分配新 PID、复制进程描述符和 thread_info+内核栈、把 eax 清 0。理解了这一点,“fork 后父子谁先跑""fork 慢不慢”这类问题就都有了确定的答案。