02linux进程与调度管理
一、先纠正课程里的 3 个核心错误
- 0 号进程不是 fork 出来的:它是系统唯一一个内核手工构造的进程,没有父进程,是所有进程的 “根”。
- TSS 不是用来做硬件任务切换的:Linux 全程用软件做进程切换,TSS 只干一件核心事 —— 保存每个进程的内核栈地址,供中断 / 特权级切换时使用。
- 不是分配 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(),操作系统复制一份几乎一模一样的进程副本(子进程)。
✅ 核心特点
-
一次调用,两次返回
pid_t pid = fork();- 父进程:返回子进程 PID(大于 0 的整数)
- 子进程:返回 0
- 失败:返回 -1 代码只写一次
fork(),但是父子两边都会继续往下执行,从fork之后的代码开始跑,不会从头重新执行 main。
- 内存:拷贝(写时复制 COW) 早期是完整复制内存;现代 Linux 用写时复制:
刚 fork 完,父子暂时共享同一块内存,一旦任意一方修改内存数据,内核才单独拷贝一份。 逻辑上子进程拥有独立地址空间,父子互不干扰。
- 文件描述符(fd)会继承 fork 时,子进程复制父进程的文件描述符表:
- 父子的 fd 指向内核同一个打开文件对象 (struct file)
- ✅ 共享文件读写偏移、文件状态标志
- 文件引用计数 + 1;必须父子都 close (fd),文件才真正关闭
📌 常见使用场景
- Shell 命令:敲命令时,shell 调用 fork 创建子进程,在子进程里
exec运行程序 - 多进程服务:比如 Nginx 主进程 fork 多个 worker 子进程
- 注意: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 触发一次时钟中断,内核做三件事:
- 全局计数器
jiffies+1,系统时间往前走一格 - 给当前进程记账:在用户态就加用户时间,在内核态就加系统时间
这就是
ps、top里 user%、sys% 的来源 - 把当前进程的
counter(剩余时间片)-1
2. 什么时候触发调度?
(1)主动调度
进程自己调用 sleep、wait 等函数,主动放弃 CPU,进入睡眠,然后主动调用调度器选下一个进程。
(2)被动调度(时间片用完)
时钟中断里发现当前进程的 counter 减到 0 了,并且当前处于用户态,就打个 “需要调度” 的标记。 等中断返回用户态的前夕,检查到标记,就执行调度。
重要特性:Linux 0.11 是内核态不可抢占的。进程跑内核代码的时候,哪怕时间片用完了也不能被抢,必须等它返回用户态或主动放弃 CPU。 运维场景:某个进程调用了一个很慢的内核操作,可能会导致系统整体卡顿,就是这个原因。
3. 调度算法怎么选进程?
核心逻辑很简单:
- 遍历所有进程,跳过睡眠、停止、僵尸的,只看就绪态的
- 选
counter最大的那个进程(剩余时间片最多) - 如果所有就绪进程的 counter 都是 0,就用公式重新算一遍:
counter = counter / 2 + priority
这个算法的巧妙之处(运维必懂)
- IO 密集型进程占便宜:经常等磁盘 / 网络,大部分时间在睡觉,counter 用不完,重新计算时会累积,优先级变相提高,下次优先被调度。所以数据库、网关这类 IO 进程响应很快。
- CPU 密集型进程公平跑:一直占 CPU,时间片很快用完,重新计算后就是 priority 的值,大家同一起跑线,轮流跑。
- 不会饿死:所有进程时间片都会动态衰减,高优先级进程也不会一直霸占 CPU。
七、运维视角总结:这些知识有什么用
- 排查僵尸进程:知道僵尸态的本质是 task_struct 没回收,根因在父进程没调用 wait,解决方向就是找父进程或者直接杀父进程。
- 理解文件句柄继承:为什么子进程能访问父进程打开的文件、为什么句柄泄漏会牵连子进程,都源于 fork 的继承机制。
- 系统启动排错:知道 1 号进程是 init,启动失败、服务起不来,能顺着 0→1→rc→服务的链路排查。
- CPU 性能分析:明白 user%、sys%、id% 分别对应什么场景,为什么 IO 进程响应快、CPU 进程调度公平。
- 进程隔离认知:理解了 LDT、独立页表、独立内核栈,就懂了进程之间为什么安全,也能更好理解容器的底层隔离思想。
进程调度与状态管理
3 个常见表述误区
- 不是 “冒泡排序” 选进程 schedule 函数就是简单遍历一遍 64 个进程槽,找出 counter 值最大的就绪进程,本质是「一次遍历找最大值」,不是冒泡排序。冒泡排序要做多轮交换,这里不需要全排序,只挑最大的那个,效率很高。
- 睡眠队列不是 “栈” 结构 wait_queue 是链表实现的等待队列,先进先出,不是栈(后进先出)。进程按等待顺序挂进去,唤醒的时候按顺序唤醒,课程里 “栈中创建” 的说法不准确。
- 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 → 全归零就重算。
第一步:遍历找最大剩余时间片
- 遍历系统里 64 个进程槽
- 跳过睡眠、停止、僵尸的,只看 TASK_RUNNING 状态的
- 记录 counter 值最大的那个进程
- 只要找到 counter>0 的进程,直接选它,准备切换
第二步:时间片全用完就重新分配
如果遍历完所有就绪进程,发现 counter 全是 0,说明大家时间片都用完了,就用公式重新计算:
counter = counter / 2 + priority
这个算法的精妙之处(运维性能分析必懂)
别小看这个简单公式,它解决了多任务调度最核心的三个问题:
- 优先级说了算 priority 是基准值,优先级越高,每次重新分配的时间片基数越大,能拿到更多 CPU 时间。
- IO 密集型进程自动优先 经常等 IO 的进程(比如数据库、网关服务),大部分时间在睡觉,counter 用不完。重新计算时,剩余的 counter 会折半累加,counter 值会比一直跑 CPU 的进程大,下次优先被调度。
效果:IO 进程响应快,不会因为频繁阻塞而 “饿死”,这就是为什么数据库平时 CPU 不高但查询响应还很快的底层原因。
- CPU 密集型进程公平轮转 一直占 CPU 跑的进程,时间片很快用完,counter 变成 0。重新分配后就是 priority 的值,大家同一起跑线,轮流跑,不会一个进程霸占 CPU。
- 不会有永久的高优先级 所有进程的 counter 都会动态衰减,哪怕优先级再高,也不会一直占着 CPU 不放,低优先级进程也总能轮到。
四、进程切换:switch_to 到底干了啥
选好新进程后,就靠switch_to做上下文切换,不用懂汇编,记住核心做了四件事就行:
- 保存旧进程现场 把当前 CPU 的通用寄存器、栈指针,都保存到旧进程的内核栈里。
- 切换内核栈 把栈指针换成新进程的内核栈地址,从这一步开始,当前内核栈就是新进程的了。
- 切换地址空间 加载新进程的 LDT,更新页表基址。从此用户态的内存地址就映射到新进程的物理内存了。
- 恢复新进程现场 从新进程的内核栈里,把之前保存的寄存器弹出来,CPU 就从新进程上次被切走的位置继续跑。
运维视角:上下文切换是有开销的 每次切换都要做保存、加载、切换页表这些操作,切换太频繁会浪费大量 CPU 在切换上,而不是跑业务。 用
vmstat看到的cs(context switch)列数值很高,就是上下文切换频繁,通常是线程太多、IO 太碎导致的。
五、睡眠与唤醒:等待队列的工作原理
进程要等资源(比如等磁盘、等锁)的时候,就会调用sleep_on把自己挂起来,等资源可用了再被wake_up唤醒。
sleep_on:把自己挂进等待队列
当进程申请资源失败(比如读磁盘没准备好),就会调用 sleep_on:
- 把自己的状态改成不可中断睡眠(也有可中断版本)
- 把自己挂到这个资源对应的等待队列链表里
- 主动调用调度器,放弃 CPU,去跑别的进程
wake_up:把队列里的进程唤醒
当资源可用了(比如磁盘数据读出来了),驱动就会调用 wake_up:
- 找到这个资源对应的等待队列
- 把队列里的进程状态改成就绪态
- 把它从等待队列里摘下来,放进调度就绪队列,等调度器选中它
运维场景对应:
- 为什么会有大量 D 状态进程?通常是磁盘 IO 瓶颈、NFS 挂载故障、存储设备响应慢,进程都在等 IO,全挂在等待队列里。
- 可中断睡眠(S 状态)可以用 kill 唤醒,不可中断(D 状态)不行,只能等 IO 完成。
补充:信号怎么唤醒睡眠进程?
比如 kill 发信号给一个 S 状态的进程:
- 内核给进程的信号位图打上标记
- 发现它处于可中断睡眠状态,就把它唤醒,改成就绪态
- 进程被调度运行后,第一件事就是处理信号,执行对应的操作
这就是为什么kill -TERM能杀掉大部分睡眠中的进程 —— 因为它们是 S 状态,可中断。
总结:
- 排查进程状态异常 看到大量 D 状态,直接查磁盘 / 存储 IO 瓶颈;看到大量 Z 状态,找父进程是不是挂了,或者有没有回收逻辑问题。
- 性能分析有抓手 CPU 使用率高但业务慢,先看 cs(上下文切换)是不是过高;IO 密集型进程响应慢,要考虑是不是优先级太低,或者调度队列太长。
- 理解系统行为本质 为什么 kill 杀不掉 D 状态进程?为什么父进程死了子进程还在?为什么 IO 进程天生响应快?这些日常问题,本质都是调度和状态机制决定的。
- 排障有底层逻辑支撑 系统卡、进程 hang 住、创建进程失败,都能顺着「进程槽→状态→调度→等待队列」这条链路往下排查,而不是靠瞎试。
进程生命周期:退出、僵尸、会话与信号
这是进程管理的收官一课。课程整体框架没错,但有几个关键表述需要纠正,而且进程退出、僵尸、会话、信号这几块恰恰是你日常运维最常打交道的部分(查僵尸、nohup 后台跑、kill 杀进程、排查 SIGHUP 掉线),我会重点讲透。
一、先纠正课程里的 4 个错误 / 歧义
- "rezie 函数" 是误听 Linux 0.11 里没有叫
rezie的函数。课程讲的实际是两段不同的代码:- 子进程自己死的时候:走
do_exit(),它不释放 task_struct,只是把进程置为僵尸态、发 SIGCHLD。 - 父进程收尸的时候:走
sys_waitpid(),由它把僵尸子进程的 task_struct 真正释放、把 task 数组槽位清空。 这两步是分开的,别混成一个函数。
- 子进程自己死的时候:走
- kill (pid=-1) 不是 "发给所有进程" 准确说法是:发给当前进程有权限发信号的所有进程(除了自己和进程 1 之外的所有)。不是字面意义上的 "全系统乱杀",内核会做权限过滤。
- do_exit 不释放 task_struct 这是最关键的一个点:进程死了,task_struct 还留着,目的是让父进程能拿到退出码。真正释放 task_struct 是父进程 wait 之后的事。这就是为什么会有僵尸进程。
- 会话头进程退出 ≠ "终止会话里所有进程" 准确说:是控制终端(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 位图上打一个标记位:
- 校验信号号是否合法
- 校验权限:发送者必须是 root,或者实际 UID 等于目标进程的 UID(同用户)
- 把目标进程的 signal 位图对应位置 1
- 如果目标进程在可中断睡眠态,就把它唤醒成就绪态
注意:不可中断睡眠(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 个)
表格
| 信号 | 值 | 含义 | 什么时候用 |
|---|---|---|---|
| SIGHUP | 1 | 终端挂断 / 重载配置 | 让 nginx/apache 重读配置:kill -HUP <pid> |
| SIGINT | 2 | Ctrl+C | 前台中断 |
| SIGKILL | 9 | 强制杀,不可捕获 | kill -9 兜底,进程卡死时用 |
| SIGTERM | 15 | 请求终止(默认) | kill <pid> 默认发这个,让进程优雅退出 |
| SIGCHLD | 17 | 子进程退出 | 父进程收尸用,业务基本不直接管 |
| SIGSTOP | 19 | 暂停,不可捕获 | Ctrl+Z 等价 |
| SIGCONT | 18 | 继续运行 | 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,清空槽位
→ 进程彻底消失
七、运维视角总结:这些知识落地到哪
- 查僵尸进程:
ps aux | grep defunct,找到 PPID,杀父进程或重启父进程程序。 - 进程杀不死:先看 STAT,D 状态的 kill -9 没用,去查磁盘 / 存储 / NFS IO;Z 状态的直接杀父进程。
- 后台服务掉线:关 SSH 后进程没了,是 SIGHUP 干的。换 nohup/setsid/systemd,别用裸
&。 - 优雅停服:发 SIGTERM(
kill <pid>),不要上来就 -9,给进程收尾时间。 - 重启服务 reload 配置:很多服务用 SIGHUP 触发重读配置,比重启更平滑(nginx -s reload 底层也是这个思路)。
- 进程数打满:排查是不是僵尸堆积、是不是 fork 炸弹,task 槽位耗尽会导致
fork: Resource temporarily unavailable。
至此,Linux 0.11 进程管理四大块(中断与时间、task_struct、调度、生命周期)就全部讲完了。核心骨架就是:jiffies 驱动时间片 → schedule 选进程 → switch_to 切上下文 → fork 生 → do_exit 死 → wait 收尸。
Linux 内核引导启动
先纠偏:4 个核心表述错误
- 没有 “SVC32 保护模式”:SVC 是 ARM 架构的特权模式,x86 架构对应的是32 位保护模式 Ring 0(内核态),课程混淆了不同 CPU 架构的术语。
- Bootsect 会先移动自身:BIOS 把引导扇区加载到
0x7C00,bootsect 第一件事是把自己搬到0x90000,腾出低地址空间给内核,不是直接原地加载后续代码。 - Setup 会移动内核到 0 地址:bootsect 把内核临时加载到
0x10000,setup 阶段会把整个内核搬到内存最底端0x00000,为保护模式地址映射做准备,课程遗漏了这关键一步。 - 虚拟盘不是 “逻辑内存”:ramdisk 是把一段物理内存模拟成块设备,应用看到的是磁盘,本质还是占用物理内存,不是 “逻辑内存” 的概念。
一、启动全流程总览
从按下电源到出现 shell,一共分 5 层,每层只做自己的事,层层交接控制权:
BIOS自检 → Bootsect(第一阶段引导) → Setup(第二阶段引导) → Head.s(内核前置汇编) → main.c(内核初始化) → 用户态init进程
这套分层逻辑从 Linux 0.11 到现在的 CentOS/Ubuntu,核心框架没变,只是每个环节功能更复杂了。
二、分阶段详解:每个阶段做什么、为什么这么做
阶段 1:BIOS 自检与引导(硬件固件层)
按下电源后,CPU 首先运行主板上的 BIOS 固件:
- POST 上电自检:检查内存、硬盘、键盘、显卡等硬件是否正常
- 按启动顺序找引导设备:比如优先从硬盘启动,就读取硬盘的第一个扇区(MBR,512 字节)
- 加载引导扇区:把这 512 字节的引导代码加载到内存物理地址
0x7C00 - 交权:CPU 跳转到
0x7C00,把控制权交给引导程序
运维视角:
- 开机卡主板 logo、报硬件错误,都是这一层的问题
- 现在的 UEFI 是 BIOS 的升级版,本质还是硬件固件,负责初始化硬件、加载 bootloader
- 引导扇区损坏、硬盘识别失败,都会在这一步报错,比如 “Missing operating system”
阶段 2:Bootsect(第一阶段引导,512 字节汇编)
这就是硬盘第一个扇区的代码,体积只有 512 字节,做不了复杂的事,核心目标只有一个:把更大的引导程序和内核从硬盘搬进内存。
核心动作:
- 自身搬家:把自己从
0x7C00移动到0x90000,腾出低地址空间 - 加载 Setup:读硬盘,把紧跟在自己后面的 setup 模块(几个扇区大小)加载到
0x90200(紧挨着自己) - 加载内核:读硬盘,把整个内核镜像(system 模块)加载到
0x10000(64KB 位置) - 跳转交权:跳转到 setup 的入口,把控制权交给第二阶段引导
运维视角:
- 这就是传统 MBR 引导的原型。现在的 GRUB、SysLinux 就是功能更强的 bootsect,能识别文件系统、读配置文件、支持多系统启动
- 修复 grub、重装引导,本质就是重写这部分引导扇区和后续的引导程序
阶段 3:Setup(第二阶段引导,汇编)
bootsect 太小干不了细活,setup 负责做内核运行前的所有硬件准备,核心目标:收集硬件信息、配置保护模式、把内核放到正确位置。
核心动作:
-
收集硬件参数 调用 BIOS 中断,读取系统信息:总内存大小、硬盘参数、显示模式、键盘配置等,存在内存固定位置,后面传给内核 main 函数。
运维对应:现在
dmesg里的硬件探测信息、内核启动参数,最早就起源于这一步。 - 重新配置中断控制器 重新编程 8259A 中断芯片,把硬件中断映射到合适的中断号,避免和 CPU 自身异常冲突。
- 搭建保护模式基础环境 设置临时的 GDT(全局描述符表)和 IDT(中断描述符表),满足进入保护模式的硬件要求。
-
移动内核到 0 地址 把之前临时放在
0x10000的整个内核,搬到内存最底端0x00000。为什么?保护模式下内核代码段基址设为 0,搬过去后地址一一对应,方便后续分页和内存管理。
- 开启保护模式 设置 CPU 控制寄存器的保护模式位,CPU 从 16 位实模式切换到 32 位保护模式。
- 跳转交权:跳转到内核的最入口
head.s,正式进入内核本身。
关键概念补充:实模式 vs 保护模式(运维必懂)
这是理解操作系统权限隔离的基础:
- 实模式:CPU 刚上电的原始模式,16 位寻址,最多访问 1MB 内存,没有权限隔离,程序能直接读写所有硬件和内存。DOS 就是实模式系统。
- 保护模式:32 位寻址,支持 4GB 内存,有 4 个特权级(Ring0 最高,Ring3 最低),有段机制和分页机制,能实现进程内存隔离、权限控制。现代操作系统都跑在保护模式下。
运维场景:
- 为什么用户态程序不能直接操作硬件?因为用户态跑在 Ring3,内核跑在 Ring0,越权就会触发段错误
- 为什么内核崩溃会 panic?因为 Ring0 级别的错误系统无法自我修复,只能停机
阶段 4:Head.s(内核最前端的汇编)
进入内核后的第一段汇编代码,核心目标:初始化保护模式核心机制,为 C 语言内核运行搭好运行环境。
核心动作:
- 重新加载段寄存器,设置内核专属的代码段、数据段
- 重新设置完整的中断描述符表(IDT),先全部设为默认处理,后面驱动再逐个注册
- 开启分页机制:设置页目录和页表,把虚拟地址映射到物理地址,正式开启虚拟内存。
从此进程看到的都是虚拟地址,由内核和 CPU 的 MMU 单元负责映射到物理内存,进程之间天然隔离。
- 初始化浮点协处理器
- 最后跳转到
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,系统正式可用。
三、运维视角总结:这些知识有什么用
- 启动故障分层排查
- 卡 BIOS/UEFI:查硬件、启动顺序
- 卡 bootloader(grub):查引导扇区、grub 配置、硬盘识别
- 卡内核加载:查内核镜像、内存、硬件兼容性
- 卡用户态:查 init 进程、文件系统挂载、服务启动 这套排查逻辑从 0.11 到现代 Linux,底层思路完全一致。
- 理解内存与缓存的本质 为什么
free命令里 cache 占了大量内存?为什么释放缓存后 IO 会变慢?本质就是 0.11 就定下的设计:用空闲内存做磁盘缓存,提升 IO 性能,需要时再回收给进程。 - 理解 ramdisk 类技术的底层 tmpfs、initramfs、内存盘这些运维常用的东西,本质都是 “把内存模拟成块设备”,原理和 0.11 的虚拟盘一脉相承。
- 权限隔离的底层逻辑 为什么普通用户不能改内核参数、为什么段错误会杀死进程、为什么内核 panic 会宕机,根源都是保护模式的特权级隔离机制。
引导启动是内核的 “入口”,理解了这套分层逻辑,再看现在的 UEFI+GRUB+systemd 启动流程,就只是每个环节功能变多了,骨架还是当年那一套。
Linux 内核启动与 init 进程
先纠偏:5 个核心表述错误
- init 不会调用 setup setup 是引导阶段的汇编代码(实模式下运行),硬件参数是内核启动时就拿到的;init 是 1 号用户态进程,根本碰不到底层 setup 代码。
- 1 号进程不会 “关闭 0 号进程的标准 IO” 0 号进程是 idle 空闲进程,跑在内核态,没有用户态的标准输入输出。1 号进程是自己打开
/dev/tty0作为自己的 0/1/2 文件描述符。 - 不是 “ETCRC 配置文件” 是
/etc/rc开机脚本,属于口误。 - 不是每个新进程都 “关闭旧控制台、设置 SID” 只有会话首进程才会设置 SID(会话 ID)、重新关联终端。普通 shell 命令的子进程只是 fork + exec,不会重建会话。课程把会话创建和普通 fork 混淆了。
- 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 号进程的核心工作
-
打开标准输入输出错误 打开
/dev/tty0(控制台终端),复制出 0(标准输入)、1(标准输出)、2(标准错误)三个文件描述符。这就是为什么你在控制台能输入命令、能看到输出 —— 所有后续进程默认都会继承这三个文件描述符。
-
执行 /etc/rc 开机脚本 调用 shell 解释器执行
/etc/rc文件,里面是开机要跑的命令:比如挂载额外文件系统、启动后台服务、打印系统 logo。运维对应:这就是现在
/etc/rc.d/、systemd服务启动的雏形。系统开机自启的服务,最早就是从这个脚本跑起来的。 - 启动交互式 Shell rc 脚本执行完之后,1 号进程会 fork 出一个子进程,执行
/bin/sh,给用户提供命令行交互界面。
3. Shell 的循环机制:为什么能一直输命令
课程里讲的 “进程循环机制”,本质就是 shell 的工作模式:
- shell 进程阻塞在终端输入,等待用户敲命令
- 用户敲完命令回车,shell 就
fork出一个子进程 - 子进程
exec执行用户输入的命令程序 - shell 父进程
wait阻塞,等着子进程退出 - 子进程退出后,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 命令:
- 把内核镜像从 flash/SD 卡读到内存指定位置
- 准备好三个关键参数:机器 ID(板子型号)、启动参数地址、架构信息
- 调用
do_bootm_linux,直接跳转到内核的入口地址,把控制权交给内核
运维视角:
- 路由器、机顶盒、安卓手机、嵌入式设备,基本都是 U-Boot 引导内核
- 设备变砖、启动卡内核,很多时候是 U-Boot 环境变量、内核镜像地址、设备树配置错了
- 对应 x86 平台的 BIOS + GRUB 组合
2. 内核汇编入口(对应 0.11 的 head.s)
内核最开头的汇编代码,做最底层的硬件准备:
- 校验机器 ID:对比 U-Boot 传进来的机器 ID,不支持就直接报错退出。
这就是为什么不同型号板子的固件不能互刷的底层原因。
- 创建临时页表,开启 MMU:建立虚拟内存映射,开启内存保护机制。
- 清理 BSS 段、拷贝数据段:初始化 C 语言运行环境。
- 保存 U-Boot 传进来的参数到指定内存位置,后面传给 C 代码。
- 跳转到
start_kernel,正式进入 C 语言内核主体。
3. start_kernel:内核初始化的总入口(对应 0.11 的 main 函数)
这是现代内核的初始化总控函数,按顺序启动所有核心子系统,挑运维相关的讲:
- 解析启动参数(就是 U-Boot/GRUB 传进来的 cmdline)
- 初始化内存管理、页表、缓存
- 初始化中断控制器、系统时钟、定时器
- 初始化进程调度器、进程管理框架
- 初始化块设备、字符设备、网络协议栈
- 初始化虚拟文件系统,准备挂载根目录
4. rest_init:拉起第一个内核线程
所有核心子系统初始化完之后,调用 rest_init,做三件关键事:
- 创建 init 内核线程(PID=1),后续会转去执行用户态的 init 程序
- 创建 kthreadd 内核线程(PID=2),负责管理所有后续内核线程
- 0 号进程退化为 idle 死循环,没有就绪进程时就休眠
运维对应:
- 现在系统里 PID=1 的还是 init 进程(CentOS6 是 sysvinit,CentOS7 + 是 systemd),祖宗地位没变
- PID=2 的 kthreadd 是所有内核线程的父进程,
ps看到的带[]的内核线程,都是它的子进程
5. init 进程的最终归宿
init 内核线程继续往下执行:
- 挂载根文件系统(rootfs)
- 按顺序尝试执行
/sbin/init、/etc/init、/bin/sh,找到第一个能执行的程序,就切换到用户态 - 从此 PID=1 就变成了用户态的 init 进程,负责启动所有服务、管理会话、回收孤儿进程
和 0.11 的对应:核心逻辑完全一样 ——PID=1 是所有用户进程的根,负责开机初始化、提供交互环境、回收进程。只是现代 init 功能强得多,从简单的 rc 脚本变成了 systemd 这样的完整服务管理系统。
三、运维视角总结:这些知识怎么用
- 启动故障分层排障
- 卡 U-Boot/BIOS:查硬件、启动顺序、引导设备识别
- 卡内核启动:查启动参数、硬件驱动、根文件系统挂载
- 卡用户态:查 init 进程、开机脚本、服务启动 这套分层思路从 0.11 到现代 Linux、从 x86 到 ARM,完全通用。
- 理解 PID 1 的特殊地位
- 为什么
kill -9杀不掉 init?因为它是所有进程的根,内核做了特殊保护 - 为什么孤儿进程最终 PPID 都是 1?因为 init 兜底收尸
- 为什么 systemd 崩了系统就崩了?因为所有进程都是它的子进程,根没了整个进程树就崩溃了
- 为什么
- 对应日常工具与现象
top的 % id ←→ 0 号 idle 进程- systemd /sysvinit ←→ 1 号 init 进程
/etc/rc.local、systemd 服务 ←→ 原始的/etc/rc脚本- 嵌入式设备变砖排查 ←→ U-Boot + 内核入口校验机制
- 僵尸 / 孤儿进程的底层逻辑 僵尸进程等父进程收尸,孤儿进程被 init 收养。大量僵尸找父进程,大量孤儿看是不是父进程批量退出了。
Linux 内核多平台适配与启动流程(运维视角精讲)
先纠偏:5 个核心表述修正
- 不是 “machine describe 结构体” 标准名称是
struct machine_desc(机器描述符),课程是口误。它是 ARM Linux 用来描述一款硬件板子信息的核心结构体。 - 不是 “lookup process type” 正确函数是
lookup_processor_type,用来匹配 CPU 型号,和进程(process)完全没关系,是发音口误。 - 不是 “set_up 函数” 是架构层的初始化函数(
setup_arch/setup_machine),课程做了简称,它负责匹配板子、解析硬件参数。 - “代码段编程思想” 不是通用高级编程 本质是 Linux 内核专属的
.init段机制—— 专门用来放只执行一次的初始化代码,启动完就释放内存,是内核的经典优化设计。 - 课程只讲了旧的 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 寄存器里传入三个关键信息:
- 机器 ID:当前板子的硬件型号编号
- 参数地址:硬件参数列表(ATAG)或设备树(DTB)在内存中的地址
- CPU 架构信息
阶段 2:CPU 型号校验
内核汇编入口第一件事,调用lookup_processor_type:
- 遍历内核里编译进去的「支持 CPU 列表」
- 和当前 CPU 的 ID 比对,匹配上就继续
- 匹配不上直接报错终止,根本不会往下跑
运维场景:为什么给 ARMv7 的板子刷了 ARMv9 的固件,直接黑屏变砖?第一步 CPU 校验就没过,内核直接停了。
阶段 3:板子型号匹配
CPU 校验通过后,进入架构初始化,匹配板子:
- 遍历
.arch.info.init段里所有的machine_desc - 拿 Uboot 传的机器 ID,和每个描述符的 ID 比对
- 找到匹配的,就把这个描述符里的所有硬件信息、初始化函数,复制到全局变量
- 后面所有的内存、中断、驱动初始化,全都基于这份匹配到的配置
这一步就是内核的「硬件识别」。匹配到哪款板子,就用哪款板子的参数和驱动来初始化。
阶段 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 段优化,减少不必要的初始化步骤
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)