一、先纠正课程里的 3 个核心错误

  1. 0 号进程不是 fork 出来的:它是系统唯一一个内核手工构造的进程,没有父进程,是所有进程的 “根”。
  2. TSS 不是用来做硬件任务切换的:Linux 全程用软件做进程切换,TSS 只干一件核心事 —— 保存每个进程的内核栈地址,供中断 / 特权级切换时使用。
  3. 不是分配 64MB 物理内存:Linux 0.11 每个进程最大有 64MB 虚拟地址空间,不是直接分配 64MB 物理内存,物理内存是用到多少分配多少。

二、进程的 “身份证”:task_struct 核心字段

task_struct 就是操作系统里的 “进程档案”,也叫进程控制块(PCB)。Linux 0.11 最多支持 64 个进程,本质就是内核里有一个长度为 64 的数组存这些档案。

挑运维最常用的字段讲:

1. 进程状态(对应 ps 命令里的 STAT)

状态含义运维场景
运行 / 就绪态正在跑 CPU,或者等着被调度ps 里的 R 状态
可中断睡眠等 IO、等资源,可以被信号唤醒ps 里的 S 状态,大部分进程都在这
不可中断睡眠等硬件 IO,不能被信号打断ps 里的 D 状态,杀不死,只能等 IO 完成
僵尸态进程已经退出,但父进程没回收它的档案ps 里的 Z 状态,排查僵尸进程的核心
停止态被调试 / 暂停信号停下ps 里的 T 状态

2. 身份与权限

  • pid:进程唯一 ID,全局不重复。
  • ppid:父进程 ID,用来梳理进程父子关系。
  • uid / euid:用户 ID、有效用户 ID,决定进程能访问哪些文件、资源。

运维常识:孤儿进程(父进程先死)会被 1 号进程(init)收养,ppid 变成 1。

什么是 fork ()

fork() 是 类 Unix(Linux、macOS) 的系统调用,作用:创建一个新进程(子进程),调用它的进程叫父进程。

通俗理解:分身术。父进程调用 fork(),操作系统复制一份几乎一模一样的进程副本(子进程)。

✅ 核心特点

  1. 一次调用,两次返回

    pid_t pid = fork();
    
    • 父进程:返回子进程 PID(大于 0 的整数)
    • 子进程:返回 0
    • 失败:返回 -1 代码只写一次 fork(),但是父子两边都会继续往下执行,从fork之后的代码开始跑,不会从头重新执行 main。
  2. 内存:拷贝(写时复制 COW) 早期是完整复制内存;现代 Linux 用写时复制:

刚 fork 完,父子暂时共享同一块内存,一旦任意一方修改内存数据,内核才单独拷贝一份。 逻辑上子进程拥有独立地址空间,父子互不干扰。

  1. 文件描述符(fd)会继承 fork 时,子进程复制父进程的文件描述符表:
  • 父子的 fd 指向内核同一个打开文件对象 (struct file)
  • ✅ 共享文件读写偏移、文件状态标志
  • 文件引用计数 + 1;必须父子都 close (fd),文件才真正关闭

📌 常见使用场景

  1. Shell 命令:敲命令时,shell 调用 fork 创建子进程,在子进程里exec运行程序
  2. 多进程服务:比如 Nginx 主进程 fork 多个 worker 子进程
  3. 注意:fork 只创建进程,不会加载新程序;想要跑别的程序,fork 后子进程调用exec()系列函数替换程序。

容易混淆的点

  • ❌ 不是线程:线程是共享同一个地址空间;fork 出来是独立进程,默认内存隔离(COW)
  • ❌ 不是函数多线程分支:是操作系统新建一个独立进程

3. 资源管理

  • 文件描述符表:最多 20 个,存进程打开的文件。fork 时子进程会继承这些文件句柄,对应文件的引用计数 +1。

    运维场景:文件句柄泄漏、父子进程共享文件偏移量,都源于这个机制。

  • current_root / current_directory:根目录、当前工作目录,fork 时同样继承。
  • 内存边界:代码段、数据段、栈、堆的地址范围。

4. 调度相关

  • counter:剩余时间片,单位是 10ms(1 个 jiffy),调度器就靠这个选进程。
  • priority:进程优先级,也是时间片的基准值,数值越大优先级越高。

5. 信号相关

  • 32 位信号位图:每一位代表一个待处理的信号(比如 kill 发的 9 号信号)。
  • 每个信号对应的处理方式:默认、忽略、自定义函数。
  • 每个信号对应的处理方式:默认、忽略、自定义函数。

三、进程隔离的底层:LDT 与 TSS 干嘛用的

不用深究段描述符格式,记住核心作用就行:实现进程之间的内存隔离,保证一个进程崩了不拖垮整个系统。

1. LDT(局部描述符表)

每个进程都有自己独立的 LDT,可以理解成每个进程私有的 “内存地址地图”。

  • 里面存着进程自己的代码段、数据段的位置。
  • 同样一个内存地址(比如 0x10000),在 A 进程里映射到物理内存 A 页,在 B 进程里映射到物理内存 B 页,互相不干扰。

运维视角:这就是进程内存隔离的底层原理。你在容器里看到的 “独立内存”,本质也是这套隔离思想的延伸。

2. TSS(任务状态段)

每个进程也有自己的 TSS,核心只存一个关键信息:该进程的内核栈地址。

  • 当进程从用户态进入内核态(比如调系统调用、触发中断),CPU 会自动从当前进程的 TSS 里取出内核栈地址,切换到内核栈。
  • 每个进程有独立的内核栈,进程 A 在内核里栈溢出,不会破坏进程 B 的栈。

纠正课程误区:Linux 不用硬件自动切换 TSS,都是软件手动切换上下文。TSS 只负责提供内核栈地址,不负责保存全部寄存器。


四、系统启动:从内核到用户态,0 号与 1 号进程

这部分对应系统启动流程,运维理解了能串起从开机到登录的完整链路。

1. 内核前期准备

bootloader 把内核加载进内存后,先做基础初始化:

  • 内存管理、中断向量表、驱动、文件系统全部初始化好
  • 初始化调度器的数据结构,清空 64 个进程槽位

2. 手工构造 0 号进程(PID=0)

这是系统第一个进程,完全由内核代码手动拼出来的,不是 fork 的。

  • 它没有独立的用户内存空间,直接用内核地址空间。
  • 它的代码就是一个死循环:没事干的时候执行 hlt 让 CPU 休眠,等中断唤醒。

运维视角:0 号进程也叫空闲进程(idle)。系统没活儿干的时候,CPU 就跑它,保证 CPU 永远不会 “空转”。用 top 看到的 % id 就是它占的时间。

3. 从内核态跳入用户态

内核没有直接降权限的指令,用 “模拟中断返回” 的方式,假装从中断里返回,顺便把特权级从 0 降到 3,正式进入用户态。 执行完这一步,0 号进程就开始在用户态跑它的死循环了。

4. 1 号进程(PID=1,init 进程)诞生

0 号进程运行后做的第一件事,就是调用 fork() 创建 1 号进程。 1 号进程是所有用户态进程的 “祖宗”,负责:

  • 打开标准输入、输出、错误(对应终端的 0、1、2 文件描述符)
  • 执行 /etc/rc 开机脚本,启动系统服务
  • 最后启动 shell,给用户提供命令行界面

运维核心常识:

  • 所有用户进程、服务进程,最终父进程追溯上去都是 1 号。
  • 父进程异常退出,子进程变成孤儿,会被 1 号进程收养。
  • 僵尸进程的根因:子进程退出了,父进程没调用 wait 回收,task_struct 没释放。

五、进程怎么 “生” 出来:fork 核心机制

fork 是创建进程的唯一方式(0 号除外),最经典的特点:调用一次,返回两次。

  • 父进程返回:子进程的 PID
  • 子进程返回:0

1. fork 的核心流程

第一步:找空位、分配 PID

遍历 64 个进程槽,找一个空位置,分配一个唯一的 PID。槽位满了就报错,这就是系统进程数上限的来源。

第二步:复制进程档案(copy_process)

给子进程分配一个新的 task_struct,把父进程的档案几乎完整拷贝一份,然后修改关键信息:

  • 子进程状态先设为 “不可中断睡眠”,防止创建到一半被调度跑了
  • 改 PID、PPID、创建时间
  • 时间片父子平分(防止通过 fork 刷时间片)
  • 清空待处理信号(信号不继承)
第三步:内存拷贝
  • 拷贝父进程的地址空间映射,为子进程建立独立的页表
  • Linux 0.11 是完整拷贝物理内存,父进程有多少数据就复制多少
  • 现代 Linux 优化成了写时复制(COW):fork 后父子先共享同一块物理内存,只有其中一方修改内存时,才真正复制一页。

运维视角:写时复制就是为什么 fork 一个大进程很快、而且初始内存占用不翻倍的原因。

第四步:资源继承
  • 文件描述符:子进程继承父进程所有打开的文件,对应文件引用计数 +1。父子进程共享同一个文件读写偏移量。

    场景:父进程打开日志文件写日志,fork 出的子进程接着写,会接着父进程的位置往后写。

  • 根目录、工作目录、权限掩码、信号处理函数,全部继承。
第五步:设为就绪态

把 TSS、LDT 都配置好,子进程状态改成 “就绪”,放进调度队列,等待被调度执行。


六、进程怎么 “跑”:调度原理

进程调度的本质就是:分时复用 CPU,让多个进程看起来像同时运行。整个系统的驱动力就是时钟中断。

1. 时钟中断:系统的脉搏

每 10ms 触发一次时钟中断,内核做三件事:

  1. 全局计数器 jiffies +1,系统时间往前走一格
  2. 给当前进程记账:在用户态就加用户时间,在内核态就加系统时间

    这就是 ps、top 里 user%、sys% 的来源

  3. 把当前进程的 counter(剩余时间片)-1

2. 什么时候触发调度?

(1)主动调度

进程自己调用 sleep、wait 等函数,主动放弃 CPU,进入睡眠,然后主动调用调度器选下一个进程。

(2)被动调度(时间片用完)

时钟中断里发现当前进程的 counter 减到 0 了,并且当前处于用户态,就打个 “需要调度” 的标记。 等中断返回用户态的前夕,检查到标记,就执行调度。

重要特性:Linux 0.11 是内核态不可抢占的。进程跑内核代码的时候,哪怕时间片用完了也不能被抢,必须等它返回用户态或主动放弃 CPU。 运维场景:某个进程调用了一个很慢的内核操作,可能会导致系统整体卡顿,就是这个原因。

3. 调度算法怎么选进程?

核心逻辑很简单:

  1. 遍历所有进程,跳过睡眠、停止、僵尸的,只看就绪态的
  2. 选 counter 最大的那个进程(剩余时间片最多)
  3. 如果所有就绪进程的 counter 都是 0,就用公式重新算一遍: counter = counter / 2 + priority
这个算法的巧妙之处(运维必懂)
  • IO 密集型进程占便宜:经常等磁盘 / 网络,大部分时间在睡觉,counter 用不完,重新计算时会累积,优先级变相提高,下次优先被调度。所以数据库、网关这类 IO 进程响应很快。
  • CPU 密集型进程公平跑:一直占 CPU,时间片很快用完,重新计算后就是 priority 的值,大家同一起跑线,轮流跑。
  • 不会饿死:所有进程时间片都会动态衰减,高优先级进程也不会一直霸占 CPU。

七、运维视角总结:这些知识有什么用

  1. 排查僵尸进程:知道僵尸态的本质是 task_struct 没回收,根因在父进程没调用 wait,解决方向就是找父进程或者直接杀父进程。
  2. 理解文件句柄继承:为什么子进程能访问父进程打开的文件、为什么句柄泄漏会牵连子进程,都源于 fork 的继承机制。
  3. 系统启动排错:知道 1 号进程是 init,启动失败、服务起不来,能顺着 0→1→rc→服务的链路排查。
  4. CPU 性能分析:明白 user%、sys%、id% 分别对应什么场景,为什么 IO 进程响应快、CPU 进程调度公平。
  5. 进程隔离认知:理解了 LDT、独立页表、独立内核栈,就懂了进程之间为什么安全,也能更好理解容器的底层隔离思想。

进程调度与状态管理


3 个常见表述误区

  1. 不是 “冒泡排序” 选进程 schedule 函数就是简单遍历一遍 64 个进程槽,找出 counter 值最大的就绪进程,本质是「一次遍历找最大值」,不是冒泡排序。冒泡排序要做多轮交换,这里不需要全排序,只挑最大的那个,效率很高。
  2. 睡眠队列不是 “栈” 结构 wait_queue 是链表实现的等待队列,先进先出,不是栈(后进先出)。进程按等待顺序挂进去,唤醒的时候按顺序唤醒,课程里 “栈中创建” 的说法不准确。
  3. TSS 不负责保存全部寄存器 进程切换时的通用寄存器是软件手动保存 / 加载的,TSS 只存内核栈指针。Linux 全程用软件做上下文切换,不用 x86 的硬件任务切换机制。

一、进程状态与转换:对应 ps 命令

操作系统里进程的所有状态,最终都会体现在ps命令的 STAT 列里,先把状态和转换逻辑讲透。

5 种核心状态

内核状态对应 ps 状态核心含义运维常见场景
TASK_RUNNING(运行 / 就绪态)R要么正在占 CPU 跑,要么在就绪队列等着被调度跑计算、跑脚本的进程
TASK_INTERRUPTIBLE(可中断睡眠)S等资源 / 等 IO,可以被信号唤醒大部分后台服务、等待终端输入的 shell
TASK_UNINTERRUPTIBLE(不可中断睡眠)D等硬件 IO,只能等 IO 完成被 wake_up 唤醒,信号杀不死等待磁盘读写、等待 NFS IO 的进程
TASK_STOPPED(停止态)T被调试 / 暂停信号停下,不调度也不运行用 ctrl+z 挂起的进程
TASK_ZOMBIE(僵尸态)Z进程已经退出,但父进程没回收它的 task_struct 档案父进程异常退出、没调用 wait 回收子进程

运维核心常识:

  • 只有TASK_RUNNING状态的进程,才会被调度器选中执行。
  • D 状态进程为什么杀不死?因为它根本不响应信号,只能等它等待的 IO 完成,或者重启系统。
  • Z 状态进程本身已经死了,不占 CPU 内存,只占一个进程槽位,大量僵尸会占满 64 个槽位,导致没法创建新进程。

状态转换的核心路径

  • 就绪 → 运行:调度器选中它,分配 CPU
  • 运行 → 可中断 / 不可中断睡眠:主动调用 sleep、wait 等函数,放弃 CPU 等资源
  • 睡眠 → 就绪:IO 完成调用 wake_up,或者收到可响应的信号
  • 运行 → 停止:收到 SIGSTOP 等暂停信号
  • 运行 → 僵尸:进程执行完退出,父进程还没回收

二、调度器什么时候工作?两个触发时机

调度器的核心就是「选下一个跑的进程」,它不会无缘无故运行,只有两个时机触发:

1. 主动调度:进程自己放弃 CPU

进程运行时调用sleep()、wait()等阻塞函数,主动把自己改成睡眠状态,然后主动调用调度器,选下一个进程跑。

场景:进程读磁盘,磁盘没准备好,进程主动去睡觉,让 CPU 跑别的进程。

2. 被动调度:时间片用完被抢占

时钟中断每 10ms 触发一次,每次都会把当前进程的剩余时间片counter减 1。

  • 当 counter 减到 0,并且当前进程跑在用户态,就会打一个 “需要调度” 的标记。
  • 等中断处理完,准备返回用户态的前夕,检查到标记,就会调用调度器,切换进程。

关键特性(运维必懂):内核态不可抢占 如果进程正跑在内核态(比如正在执行系统调用),哪怕时间片用完了,也不能抢它的 CPU。必须等它返回用户态,或者主动放弃 CPU,才能调度。

运维场景:为什么有时候一个进程调用了很慢的内核操作(比如大量磁盘 IO),整个系统都会卡?就是因为它占着内核态不出来,别的进程抢不到 CPU。


三、调度算法怎么选进程?简单但很巧妙

schedule 函数的逻辑非常简单,分两步:找最大 counter → 全归零就重算。

第一步:遍历找最大剩余时间片

  1. 遍历系统里 64 个进程槽
  2. 跳过睡眠、停止、僵尸的,只看 TASK_RUNNING 状态的
  3. 记录 counter 值最大的那个进程
  4. 只要找到 counter>0 的进程,直接选它,准备切换

第二步:时间片全用完就重新分配

如果遍历完所有就绪进程,发现 counter 全是 0,说明大家时间片都用完了,就用公式重新计算:

counter = counter / 2 + priority

这个算法的精妙之处(运维性能分析必懂)

别小看这个简单公式,它解决了多任务调度最核心的三个问题:

  1. 优先级说了算 priority 是基准值,优先级越高,每次重新分配的时间片基数越大,能拿到更多 CPU 时间。
  2. IO 密集型进程自动优先 经常等 IO 的进程(比如数据库、网关服务),大部分时间在睡觉,counter 用不完。重新计算时,剩余的 counter 会折半累加,counter 值会比一直跑 CPU 的进程大,下次优先被调度。

效果:IO 进程响应快,不会因为频繁阻塞而 “饿死”,这就是为什么数据库平时 CPU 不高但查询响应还很快的底层原因。

  1. CPU 密集型进程公平轮转 一直占 CPU 跑的进程,时间片很快用完,counter 变成 0。重新分配后就是 priority 的值,大家同一起跑线,轮流跑,不会一个进程霸占 CPU。
  2. 不会有永久的高优先级 所有进程的 counter 都会动态衰减,哪怕优先级再高,也不会一直占着 CPU 不放,低优先级进程也总能轮到。

四、进程切换:switch_to 到底干了啥

选好新进程后,就靠switch_to做上下文切换,不用懂汇编,记住核心做了四件事就行:

  1. 保存旧进程现场 把当前 CPU 的通用寄存器、栈指针,都保存到旧进程的内核栈里。
  2. 切换内核栈 把栈指针换成新进程的内核栈地址,从这一步开始,当前内核栈就是新进程的了。
  3. 切换地址空间 加载新进程的 LDT,更新页表基址。从此用户态的内存地址就映射到新进程的物理内存了。
  4. 恢复新进程现场 从新进程的内核栈里,把之前保存的寄存器弹出来,CPU 就从新进程上次被切走的位置继续跑。

运维视角:上下文切换是有开销的 每次切换都要做保存、加载、切换页表这些操作,切换太频繁会浪费大量 CPU 在切换上,而不是跑业务。 用vmstat看到的cs(context switch)列数值很高,就是上下文切换频繁,通常是线程太多、IO 太碎导致的。


五、睡眠与唤醒:等待队列的工作原理

进程要等资源(比如等磁盘、等锁)的时候,就会调用sleep_on把自己挂起来,等资源可用了再被wake_up唤醒。

sleep_on:把自己挂进等待队列

当进程申请资源失败(比如读磁盘没准备好),就会调用 sleep_on:

  1. 把自己的状态改成不可中断睡眠(也有可中断版本)
  2. 把自己挂到这个资源对应的等待队列链表里
  3. 主动调用调度器,放弃 CPU,去跑别的进程

wake_up:把队列里的进程唤醒

当资源可用了(比如磁盘数据读出来了),驱动就会调用 wake_up:

  1. 找到这个资源对应的等待队列
  2. 把队列里的进程状态改成就绪态
  3. 把它从等待队列里摘下来,放进调度就绪队列,等调度器选中它

运维场景对应:

  • 为什么会有大量 D 状态进程?通常是磁盘 IO 瓶颈、NFS 挂载故障、存储设备响应慢,进程都在等 IO,全挂在等待队列里。
  • 可中断睡眠(S 状态)可以用 kill 唤醒,不可中断(D 状态)不行,只能等 IO 完成。

补充:信号怎么唤醒睡眠进程?

比如 kill 发信号给一个 S 状态的进程:

  1. 内核给进程的信号位图打上标记
  2. 发现它处于可中断睡眠状态,就把它唤醒,改成就绪态
  3. 进程被调度运行后,第一件事就是处理信号,执行对应的操作

这就是为什么kill -TERM能杀掉大部分睡眠中的进程 —— 因为它们是 S 状态,可中断。


总结:

  1. 排查进程状态异常 看到大量 D 状态,直接查磁盘 / 存储 IO 瓶颈;看到大量 Z 状态,找父进程是不是挂了,或者有没有回收逻辑问题。
  2. 性能分析有抓手 CPU 使用率高但业务慢,先看 cs(上下文切换)是不是过高;IO 密集型进程响应慢,要考虑是不是优先级太低,或者调度队列太长。
  3. 理解系统行为本质 为什么 kill 杀不掉 D 状态进程?为什么父进程死了子进程还在?为什么 IO 进程天生响应快?这些日常问题,本质都是调度和状态机制决定的。
  4. 排障有底层逻辑支撑 系统卡、进程 hang 住、创建进程失败,都能顺着「进程槽→状态→调度→等待队列」这条链路往下排查,而不是靠瞎试。

进程生命周期:退出、僵尸、会话与信号

这是进程管理的收官一课。课程整体框架没错,但有几个关键表述需要纠正,而且进程退出、僵尸、会话、信号这几块恰恰是你日常运维最常打交道的部分(查僵尸、nohup 后台跑、kill 杀进程、排查 SIGHUP 掉线),我会重点讲透。


一、先纠正课程里的 4 个错误 / 歧义

  1. "rezie 函数" 是误听 Linux 0.11 里没有叫 rezie 的函数。课程讲的实际是两段不同的代码:
    • 子进程自己死的时候:走 do_exit(),它不释放 task_struct,只是把进程置为僵尸态、发 SIGCHLD。
    • 父进程收尸的时候:走 sys_waitpid(),由它把僵尸子进程的 task_struct 真正释放、把 task 数组槽位清空。 这两步是分开的,别混成一个函数。
  2. kill (pid=-1) 不是 "发给所有进程" 准确说法是:发给当前进程有权限发信号的所有进程(除了自己和进程 1 之外的所有)。不是字面意义上的 "全系统乱杀",内核会做权限过滤。
  3. do_exit 不释放 task_struct 这是最关键的一个点:进程死了,task_struct 还留着,目的是让父进程能拿到退出码。真正释放 task_struct 是父进程 wait 之后的事。这就是为什么会有僵尸进程。
  4. 会话头进程退出 ≠ "终止会话里所有进程" 准确说:是控制终端(tty)挂断时,内核向前台进程组发 SIGHUP。会话头进程退出本身不直接杀全组,而是它带走了控制终端,终端关闭这个动作才触发 SIGHUP。

二、进程是怎么 "死" 的:do_exit 完整流程

进程退出有三种触发方式:

  • 正常结束:main 函数 return、调用 exit ()
  • 异常结束:收到致命信号(如 SIGKILL、SIGSEGV)、内核 oops
  • 自己主动调用 exit 系统调用

不管哪种,最终都汇到同一个内核函数 do_exit()。按顺序做这些事:

第 1 步:释放内存空间

释放代码段、数据段、页表 —— 进程占的物理内存全部还回去。

运维视角:这一步完成后,进程虽然还占着一个 task 槽,但已经不占内存了。

第 2 步:关闭文件

  • 把打开的文件描述符表全部关掉,每个 file 结构体引用计数 -1,归零就真正释放
  • 当前目录、根目录、i 节点的引用计数也减一

运维视角:这就是为什么文件句柄泄漏的进程死掉后,句柄数会降下来。

第 3 步:处理子进程(过继给 init)

遍历自己的所有子进程,把它们的父进程 PPID 改成 1 号进程(init)。

这就是 "孤儿进程被 init 收养" 的实现。注意:过继不等于杀子进程,子进程继续跑,只是换了个爹。以后这些子进程死了,由 init 来收尸。

第 4 步:如果是会话头进程,释放控制终端

如果这个进程是会话领头进程,它退出会导致控制终端被释放。终端一旦挂断,内核会给前台进程组里的每个进程发 SIGHUP。

这一条直接决定了你关 SSH 窗口时,后台作业为什么会跟着死 —— 后面讲 nohup 会用到。

第 5 步:给父进程发 SIGCHLD

向父进程发送 SIGCHLD 信号,告诉它 "我死了,来收尸"。

第 6 步:状态改成 TASK_ZOMBIE(僵尸态)

注意:到这里进程就结束了,但 task_struct 还在内存里,槽位还占着。 然后调用 schedule () 切走 CPU,再也不会被调度回来。

第 7 步(另一条线):父进程 wait 收尸

父进程要么阻塞在 wait ()/waitpid () 里,要么收到 SIGCHLD 后调用 waitpid ()。内核发现子进程是僵尸态,就:

  • 把子进程的退出码、运行时间(utime/stime)累加到父进程
  • 释放 task_struct 内存
  • 把 task 数组对应的槽位清空

到这里,进程才算彻底消失。


三、僵尸进程与孤儿进程:运维天天见的两个东西

这是 do_exit 流程直接衍生出来的两个现象,必须讲透。

1. 僵尸进程(Z 状态)

产生原因:子进程死了,父进程没有调用 wait/waitpid 来收尸。

  • 子进程已经走到第 6 步,task_struct 还留着
  • 它不占 CPU、不占内存,只占一个 task 槽位
  • ps 里显示为 Z 状态,命令名带 <defunct>

为什么会产生:

  • 父进程代码里根本没写 wait(常见于自己 fork 的脚本 / 程序)
  • 父进程对 SIGCHLD 的处理设成了 SIG_IGN,但这又分情况(POSIX 规定显式设为忽略时内核会自动回收,但 Linux 0.11 时代还没有这个行为,所以更爱出僵尸)
  • 父进程自己卡死了,永远不调 wait

运维怎么处理:

  • 僵尸自己杀不死(它已经死了),kill -9 没用
  • 正确做法:杀掉它的父进程。父进程一死,僵尸子进程会被 init 收养,init 会自动 wait 收尸
  • 或者重启父进程程序,让它补上 wait 逻辑
  • 大量僵尸会占满 task 槽位(0.11 是 64 个槽位),导致系统没法再创建新进程

一句话:僵尸不是病,是父进程不负责任。 治本要改父进程,治标杀父进程。

2. 孤儿进程

产生原因:父进程先死了,子进程还在跑。

  • 内核把这些子进程的 PPID 改成 1(init)
  • 子进程继续正常运行,不影响业务
  • 等子进程自己退出时,init 会自动 wait 收尸,不会产生僵尸

运维视角:孤儿进程本身不是问题,是内核设计好的正常机制。你用 nohup ./server & 把父 shell 退掉,server 就变成孤儿被 init 收养,继续跑 —— 这正是我们想要的。

两者对比

表格

孤儿进程僵尸进程
本质父先死,子还活着子先死,父没收尸
状态正常运行状态(S/R)Z 态,已死
占资源正常占 CPU / 内存只占 task 槽
谁来收尸init 自动收养并 wait必须等亲爹 wait,杀爹才能解
是否有害正常现象大量积累有害

四、会话、进程组、控制终端:nohup 的底层原理

这部分课程讲得比较散,但对运维极其重要 —— 你天天用的 &、nohup、disown、setsid、tmux、screen,底层全是这套机制。

1. 三个概念先分清

  • 进程(process):一个运行实例,有 PID
  • 进程组(process group):一组相关进程,有 PGID。通常是一个 shell 启动的一条命令管道(比如 a | b | c 里三个进程同组)
  • 会话(session):一组进程组,有 SID。通常对应一个登录会话,比如你 SSH 上去开的那个 shell

2. 控制终端(controlling terminal)

一个会话最多绑定一个控制终端(就是你登录的那个 tty/pts)。它的作用是:

  • 终端输入(键盘)产生的信号(Ctrl+C=SIGINT、Ctrl+Z=SIGTSTP)发给前台进程组
  • 终端关闭 / SSH 断开时,内核给前台进程组发 SIGHUP

3. SIGHUP 到底是什么

HUP = hang up(挂起)。最早是电话拨号时代,电话线断了要通知进程。现在语义是:"控制终端没了"。

  • 你在终端跑 ./server &,然后关掉终端窗口
  • 内核发现终端挂断,给这个会话的前台 / 后台进程组发 SIGHUP
  • 进程默认收到 SIGHUP 就退出 → 你的 server 没了

4. 这就是 nohup /disown/setsid /tmux 的底层区别

表格

手段做了什么为什么能让进程活着
cmd &放后台跑没用,关终端照样死
nohup cmd &把 cmd 对 SIGHUP 的处理设成忽略收到 SIGHUP 不退出,继续跑;stdout/stderr 重定向到 nohup.out
disown把作业从 shell 的作业表里移除shell 退出时不再给它发 SIGHUP(和 nohup 效果类似,但时机不同)
setsid cmd让 cmd 开一个新会话,脱离原控制终端原终端挂断时,新会话根本没绑这个终端,收不到 SIGHUP
tmux/screen服务端开一个常驻会话,你 attach 上去操作你断开的只是 attach,tmux server 还在跑,业务进程从没离开过它的会话

运维一句话总结:要让进程脱离 SSH 活着,本质就是 "别让它收到 SIGHUP"。nohup 是忽略信号,setsid/tmux 是换个不绑终端的会话。生产环境跑服务,推荐用 systemd/supervisor,比裸 nohup 更靠谱(能自动重启、管日志)。


五、信号与 kill 系统调用

1. 信号是怎么发出去的:send_signal 流程

内核发信号不是 "远程投递",就是在目标进程的 signal 位图上打一个标记位:

  1. 校验信号号是否合法
  2. 校验权限:发送者必须是 root,或者实际 UID 等于目标进程的 UID(同用户)
  3. 把目标进程的 signal 位图对应位置 1
  4. 如果目标进程在可中断睡眠态,就把它唤醒成就绪态

注意:不可中断睡眠(D 状态)的进程收不到信号,这就是为什么 kill -9 对 D 状态进程无效。

2. kill 系统调用的 PID 规则(运维必会)

课程讲了个大概,这里给你完整版:

表格

pid 参数含义
pid > 0发给 PID = pid 的那个进程
pid = 0发给当前进程所在进程组的所有进程
pid = -1发给当前进程有权限发的所有进程(除自己和 init)
pid < -1发给进程组 PGID = -pid 的所有进程

运维常用命令对应:

  • kill 1234 → 杀单个进程
  • kill 0 → 杀掉整个当前进程组(在脚本里慎用,会把自己也带走)
  • kill -2 1234 → 注意!这里的 -2 不是 "pid=-2",是信号号 = 2(SIGINT)。kill 的第一个参数是信号号
  • kill -9 -1234 → 杀掉进程组 1234 整组(常用于杀一条 fork 出来的进程树)
  • killall/pkill/pgrep → 按名字找进程再杀,本质还是调 kill

3. 常用信号速查(运维版,别背 32 个)

表格

信号值含义什么时候用
SIGHUP1终端挂断 / 重载配置让 nginx/apache 重读配置:kill -HUP <pid>
SIGINT2Ctrl+C前台中断
SIGKILL9强制杀,不可捕获kill -9 兜底,进程卡死时用
SIGTERM15请求终止(默认)kill <pid> 默认发这个,让进程优雅退出
SIGCHLD17子进程退出父进程收尸用,业务基本不直接管
SIGSTOP19暂停,不可捕获Ctrl+Z 等价
SIGCONT18继续运行fg/bg 用

核心原则:先 kill <pid>(发 SIGTERM),给进程清理资源的机会;不行再 kill -9。一上来就 -9 容易让进程留下临时文件、没刷日志、没关连接。


六、整体生命周期串一遍

把进程从生到死完整串起来,你就有一张全景图了:

fork() 创建子进程
  → 拷贝 task_struct、内存、文件
  → 状态 TASK_RUNNING,进调度队列
  ↓
被调度器选中,在 CPU 上跑
  → 时间片用完/主动 sleep → 切走
  → 等 IO → 挂到等待队列,睡眠
  → IO 完成 → wake_up,回就绪态
  ↓
进程退出(return / exit / 被信号杀)
  → do_exit():
      释放内存、关文件
      子进程过继给 init
      给父进程发 SIGCHLD
      状态 → TASK_ZOMBIE
  ↓
父进程 wait()/waitpid()
  → 取走退出码、累加运行时间
  → 释放 task_struct,清空槽位
  → 进程彻底消失

七、运维视角总结:这些知识落地到哪

  1. 查僵尸进程:ps aux | grep defunct,找到 PPID,杀父进程或重启父进程程序。
  2. 进程杀不死:先看 STAT,D 状态的 kill -9 没用,去查磁盘 / 存储 / NFS IO;Z 状态的直接杀父进程。
  3. 后台服务掉线:关 SSH 后进程没了,是 SIGHUP 干的。换 nohup/setsid/systemd,别用裸 &。
  4. 优雅停服:发 SIGTERM(kill <pid>),不要上来就 -9,给进程收尾时间。
  5. 重启服务 reload 配置:很多服务用 SIGHUP 触发重读配置,比重启更平滑(nginx -s reload 底层也是这个思路)。
  6. 进程数打满:排查是不是僵尸堆积、是不是 fork 炸弹,task 槽位耗尽会导致 fork: Resource temporarily unavailable。

至此,Linux 0.11 进程管理四大块(中断与时间、task_struct、调度、生命周期)就全部讲完了。核心骨架就是:jiffies 驱动时间片 → schedule 选进程 → switch_to 切上下文 → fork 生 → do_exit 死 → wait 收尸。

Linux 内核引导启动


先纠偏:4 个核心表述错误

  1. 没有 “SVC32 保护模式”:SVC 是 ARM 架构的特权模式,x86 架构对应的是32 位保护模式 Ring 0(内核态),课程混淆了不同 CPU 架构的术语。
  2. Bootsect 会先移动自身:BIOS 把引导扇区加载到0x7C00,bootsect 第一件事是把自己搬到0x90000,腾出低地址空间给内核,不是直接原地加载后续代码。
  3. Setup 会移动内核到 0 地址:bootsect 把内核临时加载到0x10000,setup 阶段会把整个内核搬到内存最底端0x00000,为保护模式地址映射做准备,课程遗漏了这关键一步。
  4. 虚拟盘不是 “逻辑内存”:ramdisk 是把一段物理内存模拟成块设备,应用看到的是磁盘,本质还是占用物理内存,不是 “逻辑内存” 的概念。

一、启动全流程总览

从按下电源到出现 shell,一共分 5 层,每层只做自己的事,层层交接控制权:

BIOS自检 → Bootsect(第一阶段引导) → Setup(第二阶段引导) → Head.s(内核前置汇编) → main.c(内核初始化) → 用户态init进程

这套分层逻辑从 Linux 0.11 到现在的 CentOS/Ubuntu,核心框架没变,只是每个环节功能更复杂了。


二、分阶段详解:每个阶段做什么、为什么这么做

阶段 1:BIOS 自检与引导(硬件固件层)

按下电源后,CPU 首先运行主板上的 BIOS 固件:

  1. POST 上电自检:检查内存、硬盘、键盘、显卡等硬件是否正常
  2. 按启动顺序找引导设备:比如优先从硬盘启动,就读取硬盘的第一个扇区(MBR,512 字节)
  3. 加载引导扇区:把这 512 字节的引导代码加载到内存物理地址0x7C00
  4. 交权:CPU 跳转到0x7C00,把控制权交给引导程序

运维视角:

  • 开机卡主板 logo、报硬件错误,都是这一层的问题
  • 现在的 UEFI 是 BIOS 的升级版,本质还是硬件固件,负责初始化硬件、加载 bootloader
  • 引导扇区损坏、硬盘识别失败,都会在这一步报错,比如 “Missing operating system”

阶段 2:Bootsect(第一阶段引导,512 字节汇编)

这就是硬盘第一个扇区的代码,体积只有 512 字节,做不了复杂的事,核心目标只有一个:把更大的引导程序和内核从硬盘搬进内存。

核心动作:

  1. 自身搬家:把自己从0x7C00移动到0x90000,腾出低地址空间
  2. 加载 Setup:读硬盘,把紧跟在自己后面的 setup 模块(几个扇区大小)加载到0x90200(紧挨着自己)
  3. 加载内核:读硬盘,把整个内核镜像(system 模块)加载到0x10000(64KB 位置)
  4. 跳转交权:跳转到 setup 的入口,把控制权交给第二阶段引导

运维视角:

  • 这就是传统 MBR 引导的原型。现在的 GRUB、SysLinux 就是功能更强的 bootsect,能识别文件系统、读配置文件、支持多系统启动
  • 修复 grub、重装引导,本质就是重写这部分引导扇区和后续的引导程序

阶段 3:Setup(第二阶段引导,汇编)

bootsect 太小干不了细活,setup 负责做内核运行前的所有硬件准备,核心目标:收集硬件信息、配置保护模式、把内核放到正确位置。

核心动作:

  1. 收集硬件参数 调用 BIOS 中断,读取系统信息:总内存大小、硬盘参数、显示模式、键盘配置等,存在内存固定位置,后面传给内核 main 函数。

    运维对应:现在dmesg里的硬件探测信息、内核启动参数,最早就起源于这一步。

  2. 重新配置中断控制器 重新编程 8259A 中断芯片,把硬件中断映射到合适的中断号,避免和 CPU 自身异常冲突。
  3. 搭建保护模式基础环境 设置临时的 GDT(全局描述符表)和 IDT(中断描述符表),满足进入保护模式的硬件要求。
  4. 移动内核到 0 地址 把之前临时放在0x10000的整个内核,搬到内存最底端0x00000。

    为什么?保护模式下内核代码段基址设为 0,搬过去后地址一一对应,方便后续分页和内存管理。

  5. 开启保护模式 设置 CPU 控制寄存器的保护模式位,CPU 从 16 位实模式切换到 32 位保护模式。
  6. 跳转交权:跳转到内核的最入口head.s,正式进入内核本身。
关键概念补充:实模式 vs 保护模式(运维必懂)

这是理解操作系统权限隔离的基础:

  • 实模式:CPU 刚上电的原始模式,16 位寻址,最多访问 1MB 内存,没有权限隔离,程序能直接读写所有硬件和内存。DOS 就是实模式系统。
  • 保护模式:32 位寻址,支持 4GB 内存,有 4 个特权级(Ring0 最高,Ring3 最低),有段机制和分页机制,能实现进程内存隔离、权限控制。现代操作系统都跑在保护模式下。

运维场景:

  • 为什么用户态程序不能直接操作硬件?因为用户态跑在 Ring3,内核跑在 Ring0,越权就会触发段错误
  • 为什么内核崩溃会 panic?因为 Ring0 级别的错误系统无法自我修复,只能停机

阶段 4:Head.s(内核最前端的汇编)

进入内核后的第一段汇编代码,核心目标:初始化保护模式核心机制,为 C 语言内核运行搭好运行环境。

核心动作:

  1. 重新加载段寄存器,设置内核专属的代码段、数据段
  2. 重新设置完整的中断描述符表(IDT),先全部设为默认处理,后面驱动再逐个注册
  3. 开启分页机制:设置页目录和页表,把虚拟地址映射到物理地址,正式开启虚拟内存。

    从此进程看到的都是虚拟地址,由内核和 CPU 的 MMU 单元负责映射到物理内存,进程之间天然隔离。

  4. 初始化浮点协处理器
  5. 最后跳转到main()函数,正式进入 C 语言编写的内核主体。

纠偏强调:这里就是 x86 保护模式的 Ring 0 特权级,也就是内核态,和 “SVC” 无关,SVC 是 ARM 架构的概念。


阶段 5:main 函数(内核 C 语言初始化入口)

从这里开始就是内核的业务逻辑了,核心目标:读取硬件参数,初始化所有子系统,最终拉起用户态。

1. 读取硬件参数

从 setup 存在固定内存地址的地方,取出内存大小、硬盘参数、根设备号这些信息,转成 C 语言全局变量,供后续初始化使用。

2. 内存规划与初始化

这是运维最相关的部分,Linux 0.11 把物理内存从低到高分成三大块:

表格

区域作用现代 Linux 对应物
内核代码 + 数据区内核本身的代码、全局变量、内核栈内核镜像占用内存
高速缓冲区(buffer cache)磁盘 IO 的缓存,读盘先读缓存,写盘先写缓存再刷盘Page Cache / Buffer Cache
用户主内存所有进程的内存都从这里分配,包括进程代码、数据、栈、进程控制块、页表等用户空间内存 + 内核动态内存
  • 内核会先计算总内存:基本内存(640KB)+ 扩展内存,对齐到 4KB 边界
  • 缓冲区大小根据总内存动态调整:内存越大,缓存越大,充分利用内存资源

运维视角:

  • 为什么 Linux 总显示 “内存占用高”?因为空闲内存会自动拿去做缓存,这个设计从 0.11 版本就定了
  • 磁盘 IO 性能、缓存命中率,底层都和这个缓冲区机制直接相关
3. 虚拟盘(ramdisk)初始化(可选)

从主内存里划出一段固定大小的空间,模拟成磁盘块设备。

  • 优点:读写速度极快
  • 缺点:断电丢失,占用物理内存

运维视角:

  • 现在的 initramfs、tmpfs、ramfs,都是这个思路的进化版
  • 安装系统的镜像、Live CD、系统启动时的临时根文件系统,本质都是 ramdisk
  • 用 tmpfs 放日志、缓存,就是利用了内存盘的高速特性
4. 逐项初始化子系统

陷阱、中断、时钟、块设备、字符设备、文件系统、进程调度…… 全部初始化一遍。

5. 拉起用户态

手工创建 0 号进程,切换到用户态,fork 出 1 号 init 进程,最终启动 shell,系统正式可用。


三、运维视角总结:这些知识有什么用

  1. 启动故障分层排查
    • 卡 BIOS/UEFI:查硬件、启动顺序
    • 卡 bootloader(grub):查引导扇区、grub 配置、硬盘识别
    • 卡内核加载:查内核镜像、内存、硬件兼容性
    • 卡用户态:查 init 进程、文件系统挂载、服务启动 这套排查逻辑从 0.11 到现代 Linux,底层思路完全一致。
  2. 理解内存与缓存的本质 为什么free命令里 cache 占了大量内存?为什么释放缓存后 IO 会变慢?本质就是 0.11 就定下的设计:用空闲内存做磁盘缓存,提升 IO 性能,需要时再回收给进程。
  3. 理解 ramdisk 类技术的底层 tmpfs、initramfs、内存盘这些运维常用的东西,本质都是 “把内存模拟成块设备”,原理和 0.11 的虚拟盘一脉相承。
  4. 权限隔离的底层逻辑 为什么普通用户不能改内核参数、为什么段错误会杀死进程、为什么内核 panic 会宕机,根源都是保护模式的特权级隔离机制。

引导启动是内核的 “入口”,理解了这套分层逻辑,再看现在的 UEFI+GRUB+systemd 启动流程,就只是每个环节功能变多了,骨架还是当年那一套。

Linux 内核启动与 init 进程


先纠偏:5 个核心表述错误

  1. init 不会调用 setup setup 是引导阶段的汇编代码(实模式下运行),硬件参数是内核启动时就拿到的;init 是 1 号用户态进程,根本碰不到底层 setup 代码。
  2. 1 号进程不会 “关闭 0 号进程的标准 IO” 0 号进程是 idle 空闲进程,跑在内核态,没有用户态的标准输入输出。1 号进程是自己打开 /dev/tty0 作为自己的 0/1/2 文件描述符。
  3. 不是 “ETCRC 配置文件” 是 /etc/rc 开机脚本,属于口误。
  4. 不是每个新进程都 “关闭旧控制台、设置 SID” 只有会话首进程才会设置 SID(会话 ID)、重新关联终端。普通 shell 命令的子进程只是 fork + exec,不会重建会话。课程把会话创建和普通 fork 混淆了。
  5. rest_init 不是直接运行 init 函数 它是先创建内核线程,最终由内核线程挂载根文件系统、执行用户态的 /sbin/init 程序,不是直接调用 C 语言的 init 函数。

一、经典 Linux 0.11:从内核启动到 Shell 交互

对应课程前半段,我们把「0 号进程→1 号进程→rc 脚本→shell 循环」这条主线讲透。

1. 启动的起点:内核初始化收尾

内核把中断、内存、驱动、文件系统、调度器所有子系统都初始化完之后,就到了进程管理的起点:

  • 手工构造 0 号进程(idle 进程):系统唯一一个不是 fork 出来的进程,所有字段由内核手动填充。
  • 调用 move_to_user_mode(),用「模拟中断返回」的方式,从内核态(Ring0)降到用户态(Ring3)。
  • 0 号进程正式在用户态运行,它的代码就是一个死循环:没事就执行 hlt 让 CPU 休眠,等中断唤醒。

运维视角:0 号进程就是系统的 “兜底”。top 里的 %id(空闲 CPU 占比),本质就是 0 号进程运行的时间占比。系统没活儿干的时候,CPU 就跑它。

2. 1 号进程(init 进程)诞生

0 号进程运行后做的第一件事,就是调用 fork() 创建 1 号进程(PID=1)。 这是系统第一个真正的用户态业务进程,也是所有用户进程的 “祖宗”。

1 号进程的核心工作
  1. 打开标准输入输出错误 打开 /dev/tty0(控制台终端),复制出 0(标准输入)、1(标准输出)、2(标准错误)三个文件描述符。

    这就是为什么你在控制台能输入命令、能看到输出 —— 所有后续进程默认都会继承这三个文件描述符。

  2. 执行 /etc/rc 开机脚本 调用 shell 解释器执行 /etc/rc 文件,里面是开机要跑的命令:比如挂载额外文件系统、启动后台服务、打印系统 logo。

    运维对应:这就是现在 /etc/rc.d/、systemd 服务启动的雏形。系统开机自启的服务,最早就是从这个脚本跑起来的。

  3. 启动交互式 Shell rc 脚本执行完之后,1 号进程会 fork 出一个子进程,执行 /bin/sh,给用户提供命令行交互界面。

3. Shell 的循环机制:为什么能一直输命令

课程里讲的 “进程循环机制”,本质就是 shell 的工作模式:

  1. shell 进程阻塞在终端输入,等待用户敲命令
  2. 用户敲完命令回车,shell 就 fork 出一个子进程
  3. 子进程 exec 执行用户输入的命令程序
  4. shell 父进程 wait 阻塞,等着子进程退出
  5. 子进程退出后,shell 回到第 1 步,继续等待下一条命令

纠正课程误区:不是 “系统后台循环创建进程”,是 shell 前台循环等待用户输入,有命令才创建子进程。没命令的时候,shell 就在睡眠状态,不占 CPU。

4. 孤儿进程的归宿

父进程先退出,子进程还活着,就变成孤儿进程。内核会统一把孤儿进程的父进程改成 1 号进程(init)。

  • 1 号进程会一直循环调用 wait,回收所有孤儿子进程的资源,不会产生僵尸进程。

运维对应:为什么父进程挂了,子进程还在跑?为什么杀了父进程,僵尸进程就消失了?本质就是 1 号进程兜底收尸。


二、现代 Linux + U-Boot:启动流程的演进

对应课程后半段,Linux 3.4.2 是典型的现代内核架构,加上 ARM 平台常用的 U-Boot 引导,和 0.11 的核心逻辑一脉相承,只是每个环节更复杂、功能更强。

1. U-Boot 引导阶段(对应 0.11 的 BIOS + Bootsect + Setup)

嵌入式 / ARM 平台没有 BIOS,用 U-Boot 替代 BIOS + bootloader 的全部功能。

U-Boot 怎么跳内核

U-Boot 本身就是个轻量系统,能识别存储、读文件、配置硬件。启动内核用 bootm 命令:

  1. 把内核镜像从 flash/SD 卡读到内存指定位置
  2. 准备好三个关键参数:机器 ID(板子型号)、启动参数地址、架构信息
  3. 调用 do_bootm_linux,直接跳转到内核的入口地址,把控制权交给内核

运维视角:

  • 路由器、机顶盒、安卓手机、嵌入式设备,基本都是 U-Boot 引导内核
  • 设备变砖、启动卡内核,很多时候是 U-Boot 环境变量、内核镜像地址、设备树配置错了
  • 对应 x86 平台的 BIOS + GRUB 组合

2. 内核汇编入口(对应 0.11 的 head.s)

内核最开头的汇编代码,做最底层的硬件准备:

  1. 校验机器 ID:对比 U-Boot 传进来的机器 ID,不支持就直接报错退出。

    这就是为什么不同型号板子的固件不能互刷的底层原因。

  2. 创建临时页表,开启 MMU:建立虚拟内存映射,开启内存保护机制。
  3. 清理 BSS 段、拷贝数据段:初始化 C 语言运行环境。
  4. 保存 U-Boot 传进来的参数到指定内存位置,后面传给 C 代码。
  5. 跳转到 start_kernel,正式进入 C 语言内核主体。

3. start_kernel:内核初始化的总入口(对应 0.11 的 main 函数)

这是现代内核的初始化总控函数,按顺序启动所有核心子系统,挑运维相关的讲:

  1. 解析启动参数(就是 U-Boot/GRUB 传进来的 cmdline)
  2. 初始化内存管理、页表、缓存
  3. 初始化中断控制器、系统时钟、定时器
  4. 初始化进程调度器、进程管理框架
  5. 初始化块设备、字符设备、网络协议栈
  6. 初始化虚拟文件系统,准备挂载根目录

4. rest_init:拉起第一个内核线程

所有核心子系统初始化完之后,调用 rest_init,做三件关键事:

  1. 创建 init 内核线程(PID=1),后续会转去执行用户态的 init 程序
  2. 创建 kthreadd 内核线程(PID=2),负责管理所有后续内核线程
  3. 0 号进程退化为 idle 死循环,没有就绪进程时就休眠

运维对应:

  • 现在系统里 PID=1 的还是 init 进程(CentOS6 是 sysvinit,CentOS7 + 是 systemd),祖宗地位没变
  • PID=2 的 kthreadd 是所有内核线程的父进程,ps 看到的带 [] 的内核线程,都是它的子进程

5. init 进程的最终归宿

init 内核线程继续往下执行:

  1. 挂载根文件系统(rootfs)
  2. 按顺序尝试执行 /sbin/init、/etc/init、/bin/sh,找到第一个能执行的程序,就切换到用户态
  3. 从此 PID=1 就变成了用户态的 init 进程,负责启动所有服务、管理会话、回收孤儿进程

和 0.11 的对应:核心逻辑完全一样 ——PID=1 是所有用户进程的根,负责开机初始化、提供交互环境、回收进程。只是现代 init 功能强得多,从简单的 rc 脚本变成了 systemd 这样的完整服务管理系统。


三、运维视角总结:这些知识怎么用

  1. 启动故障分层排障
    • 卡 U-Boot/BIOS:查硬件、启动顺序、引导设备识别
    • 卡内核启动:查启动参数、硬件驱动、根文件系统挂载
    • 卡用户态:查 init 进程、开机脚本、服务启动 这套分层思路从 0.11 到现代 Linux、从 x86 到 ARM,完全通用。
  2. 理解 PID 1 的特殊地位
    • 为什么 kill -9 杀不掉 init?因为它是所有进程的根,内核做了特殊保护
    • 为什么孤儿进程最终 PPID 都是 1?因为 init 兜底收尸
    • 为什么 systemd 崩了系统就崩了?因为所有进程都是它的子进程,根没了整个进程树就崩溃了
  3. 对应日常工具与现象
    • top 的 % id ←→ 0 号 idle 进程
    • systemd /sysvinit ←→ 1 号 init 进程
    • /etc/rc.local、systemd 服务 ←→ 原始的 /etc/rc 脚本
    • 嵌入式设备变砖排查 ←→ U-Boot + 内核入口校验机制
  4. 僵尸 / 孤儿进程的底层逻辑 僵尸进程等父进程收尸,孤儿进程被 init 收养。大量僵尸找父进程,大量孤儿看是不是父进程批量退出了。

Linux 内核多平台适配与启动流程(运维视角精讲)


先纠偏:5 个核心表述修正

  1. 不是 “machine describe 结构体” 标准名称是 struct machine_desc(机器描述符),课程是口误。它是 ARM Linux 用来描述一款硬件板子信息的核心结构体。
  2. 不是 “lookup process type” 正确函数是 lookup_processor_type,用来匹配 CPU 型号,和进程(process)完全没关系,是发音口误。
  3. 不是 “set_up 函数” 是架构层的初始化函数(setup_arch / setup_machine),课程做了简称,它负责匹配板子、解析硬件参数。
  4. “代码段编程思想” 不是通用高级编程 本质是 Linux 内核专属的.init段机制—— 专门用来放只执行一次的初始化代码,启动完就释放内存,是内核的经典优化设计。
  5. 课程只讲了旧的 ATAG 方式 Linux 3.4 已经支持设备树(Device Tree / DTB)传参,这是现在嵌入式的主流方案,ATAG 是更早期的做法,我会补充两者的区别和运维对应场景。

一、核心问题:内核为什么能适配不同硬件板子?

同样一个内核镜像,为什么能在 A 型号路由器上跑,也能在 B 型号开发板上跑?核心就是 machine_desc 机器描述符机制。

1. 什么是 machine_desc?

可以把它理解成「硬件板子的档案卡」。每一款支持的硬件板子,都对应一个machine_desc结构体,里面记录了这款板子的所有关键信息:

  • 机器 ID(和 Uboot 传的 ID 对应)
  • 板子名称、硬件版本
  • 内存分布、中断号映射
  • 串口、时钟、总线等硬件配置
  • 这款板子专属的初始化函数指针

2. 怎么把所有板子的档案集中管理?

内核用了一个非常巧妙的设计:特殊内存段 + 编译期注册

  • 在链接脚本里定义一个专门的段:.arch.info.init
  • 每款板子的代码里,都用宏把自己的machine_desc“塞” 到这个段里
  • 最终编译出来的内核镜像里,所有支持的板子档案,都连续排在这个固定的内存段里

通俗比喻:就像内核里有一本「硬件支持目录」,编译的时候把所有支持的板子信息都印在这本书里,启动的时候翻书找匹配的型号。

3. 运维视角的对应意义

  • 为什么有通用固件和专用固件? 通用固件编译时编入了多个machine_desc,支持多款板子;专用固件只编了一款,体积小、启动快。
  • 为什么跨型号刷固件容易变砖? 机器 ID 不匹配,内核找不到对应的硬件描述,要么直接启动失败,要么驱动配置错误,外设全部用不了。
  • 内核裁剪是在剪什么? 嵌入式定制内核,很大一部分工作就是删掉不需要的machine_desc和驱动,减小镜像体积、加快启动速度。

二、启动匹配全流程:从 Uboot 跳转到内核适配

结合上一节的启动流程,把「硬件匹配」这一步补进去,完整逻辑是:

阶段 1:Uboot 传参

Uboot 启动内核时,会往 CPU 寄存器里传入三个关键信息:

  1. 机器 ID:当前板子的硬件型号编号
  2. 参数地址:硬件参数列表(ATAG)或设备树(DTB)在内存中的地址
  3. CPU 架构信息

阶段 2:CPU 型号校验

内核汇编入口第一件事,调用lookup_processor_type:

  • 遍历内核里编译进去的「支持 CPU 列表」
  • 和当前 CPU 的 ID 比对,匹配上就继续
  • 匹配不上直接报错终止,根本不会往下跑

运维场景:为什么给 ARMv7 的板子刷了 ARMv9 的固件,直接黑屏变砖?第一步 CPU 校验就没过,内核直接停了。

阶段 3:板子型号匹配

CPU 校验通过后,进入架构初始化,匹配板子:

  1. 遍历.arch.info.init段里所有的machine_desc
  2. 拿 Uboot 传的机器 ID,和每个描述符的 ID 比对
  3. 找到匹配的,就把这个描述符里的所有硬件信息、初始化函数,复制到全局变量
  4. 后面所有的内存、中断、驱动初始化,全都基于这份匹配到的配置

这一步就是内核的「硬件识别」。匹配到哪款板子,就用哪款板子的参数和驱动来初始化。

阶段 4:解析启动参数

根据 Uboot 传的参数地址,解析硬件参数和启动命令:

  • 旧方式 ATAG:Uboot 把内存大小、串口地址、启动命令行拼成标签列表,内核逐个标签解析
  • 新方式 设备树(DTB):Uboot 加载独立的 dtb 文件到内存,内核解析 dtb 文件,获取完整的硬件拓扑和配置
补充:ATAG vs 设备树(运维必懂)

表格

ATAG(旧)设备树 DTB(新)
信息多少只能传少量核心参数(内存、串口、cmdline)能描述完整硬件拓扑:有哪些外设、地址多少、中断号多少
灵活性参数格式固定,改硬件要改内核代码独立文件,改硬件只需要换 dtb,不用重新编译内核
现在使用老版本内核、简单嵌入式设备现在 ARM Linux、安卓、路由器、机顶盒的主流方案

运维场景:

  • 嵌入式设备固件里经常看到的.dtb文件,就是设备树二进制文件
  • 为什么内核升级经常要同步更新 dtb?因为硬件描述改了,不匹配就会识别不了外设、驱动加载失败
  • 你在 Uboot 里改的bootargs(比如console=ttyS0,115200 root=/dev/mmcblk0p1),就是通过这个机制传给内核的

三、.init 段机制:初始化代码为什么不占运行内存

课程提到的 “代码段编程思想”,核心就是.init段机制,这是内核非常经典的优化,运维也能直观感受到。

原理

所有只在启动时执行一次的代码和数据,都标记为.init段:

  • 比如machine_desc、板子初始化函数、早期硬件探测代码
  • 它们在启动阶段正常执行,和普通代码没区别
  • 内核启动完成后,会把整个.init 段占用的内存一次性全部释放

运维视角

  • 这就是为什么内核运行时占用的内存,比内核镜像文件大小要小的重要原因之一 —— 一次性的初始化代码用完就扔了
  • 嵌入式系统优化启动内存,很大程度就是优化.init 段的大小,让启动过程少占内存

四、现代内核启动完整流程(串讲)

把之前讲的和今天的内容整合起来,就是完整的现代 ARM Linux 启动链:

Uboot初始化硬件
  → 加载内核镜像 + DTB到内存
  → 传入机器ID、DTB地址、启动参数
  → 跳转到内核入口
  ↓
内核汇编入口
  → 校验CPU型号
  → 创建临时页表、开启MMU
  → 跳转到start_kernel
  ↓
架构层初始化(setup_arch)
  → 匹配machine_desc
  → 解析DTB/ATAG硬件参数
  → 解析bootargs启动命令行
  ↓
内核通用初始化
  → 内存管理、中断、时钟、调度器
  → 驱动子系统、文件系统
  ↓
rest_init
  → 创建init内核线程(PID=1)
  → 创建kthreadd内核线程(PID=2)
  → 0号进程退化为idle死循环
  ↓
init进程
  → 挂载根文件系统
  → 执行用户态/sbin/init(systemd/sysvinit)
  → 启动服务、拉起终端

五、运维视角总结:这些知识落地在哪

1. 嵌入式设备启动故障分层排查

  • 完全黑屏、一点输出都没有:查 Uboot、查 CPU 架构匹配、查机器 ID
  • 有内核开头打印,后面卡住:查 DTB 对不对、内存配置对不对、驱动有没有匹配
  • 挂载根文件系统失败:查 bootargs 里的 root 参数、查存储驱动有没有编进内核

2. 固件适配性判断

  • 同架构、同机器 ID 的板子,固件大概率通用;机器 ID 不同,不要乱刷
  • 设备树时代,同架构的内核镜像可以通用,只需要换对应板子的 DTB 文件

3. 启动参数调优

  • 不需要改内核,只需要在 Uboot 里修改bootargs,就能调整控制台、根分区、开启 / 关闭内核功能
  • 比如加init=/bin/sh就能进入单用户模式,本质就是修改了内核启动参数里的 init 程序路径

4. 理解内核裁剪与优化

  • 定制嵌入式内核,核心就是保留需要的 machine_desc 和驱动,删掉不用的
  • 启动慢可以查 init 段优化,减少不必要的初始化步骤

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐