讲个冷知识:你从ps aux里永远找不到swapper进程,但整个系统里的每个 CPU 其实都在被它"兜底"运行着。这个进程的 pid 永远是 0,内核源码里习惯叫它 idle、boot idle task,或者进程 0。这篇文章是idle 的启动系列的第一篇,我准备先把 0 号进程是怎么"出生"的、它和start_kernel到rest_init这条启动主线有什么关系、以及我平时在真机上验证这套机制时用的几个土办法,一次性讲透。
这篇内容适合三类人看:想读懂内核启动源码的人、做底层性能排查时被ksoftirqd和swapper搞晕的人,以及正在看《深入理解Linux内核》想找一个具体切入点的人。
1. 为什么要专门分析 idle 进程的启动过程
1.1 一个没有 ppid、没有命令行、永远 R 状态的"幽灵进程"
我刚接触内核调度时有个很深的困惑:运行队列上如果真的没有任务了,调度器应该选谁?如果什么都不选,那 CPU 上的"当前进程"就悬空了,中断回来之后switch_to根本不知道该切给谁。idle 进程就是为这个场景准备的。
它在每个 CPU 上都存在,调度器实在挑不出其他任务时,就选它。pick_next_task的最后一个return一般就是pick_next_task_idle(rq),也就是说 idle 永远处于可运行状态。它的 task 结构里comm通常是swapper/0、swapper/1,没有用户态地址空间,也没有父进程概念,某些发行版的 ps 里看不到它。第一眼确实挺"幽灵"的。
这里有个容易被误导的点:它的 state 往往显示为 R(running),但不代表系统很忙。恰恰相反,说明没有其他任务可运行,CPU 才会一直待在 idle 里。这个区别后面我会在常见问题里展开。
1.2 看懂 idle 的启动,是理解内核启动收尾与调度器的捷径
很多介绍内核启动的文章会顺着start_kernel把setup_arch、mm_init、sched_init都讲一遍,但我更推荐先盯住rest_init之后发生的事情。因为从rest_init开始,系统才正式从"单线程初始化"跳进"多任务调度",而 0 号进程恰好就是这个转变的枢纽。
start_kernel干了很多事,但它自己并不进入用户态。它最后要做的是把 pid 1 和 pid 2 造出来,然后自己变成 idle。你读懂了 rest_init 的三步,基本上就把调度器启动前的逻辑打通了。后续不管是看cpuidle省电、看 CPU 热插拔、还是排查某个核上的软中断为啥一直跑,都绕不开这条线。
2. 从 start_kernel 到 rest_init:0 号进程的诞生路径
2.1 启动主线里哪一步轮到 idle 登场
整个启动过程用一句话概括:固件把内核镜像放进内存,然后跳到内核入口,入口经过一系列汇编和 C 语言的初始化,最终在start_kernel里把核心子系统全部点亮。
start_kernel的位置在init/main.c。它的执行顺序并不是随便排的,比如必须先setup_arch(解析体系结构相关参数和内存布局),再mm_init(建立页表),然后sched_init(让调度器基础设施可用),之后才敢说"我可以开始创建进程了"。最后那一步就是rest_init():
asmlinkage __visible void __init start_kernel(void) { char *command_line; char *after_dashes; setup_arch(&command_line); mm_init(); sched_init(); ... rest_init(); }也就是说,idle不是启动早期的某个瞬间"生成"的,而是启动主线走到最后,当前正在执行start_kernel的那个线程直接把身份切换成了 idle。整个过程没有新进程,没有 fork,只是"同一个执行上下文"换了角色。
2.2 rest_init 里那三个关键的"造人"动作
rest_init这个函数不长,但信息量非常大。第一次读的时候建议拆成三步理解:
static noinline void __ref rest_init(void) { struct task_struct *tsk; int pid; rcu_scheduler_starting(); pid = user_mode_thread(kernel_init, NULL, CLONE_FS | CLONE_SIGHAND); ... pid = kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES); ... system_state = SYSTEM_SCHEDULING; complete(&kthreadd_done); schedule_preempt_disabled(); cpu_startup_entry(CPUHP_ONLINE); }第一步,用user_mode_thread(kernel_init, ...)创建出 pid 1,也就是我们熟悉的 init 进程。注意它带着CLONE_FS | CLONE_SIGHAND,说明会进入用户态执行/sbin/init。第二步,用kernel_thread(kthreadd, ...)创建 pid 2,这是所有内核线程的"接生婆"。第三步有点意思:当前这个执行流没有退出,没有消失,而是调用schedule_preempt_disabled()先主动让出一次 CPU,让刚出生的 init 有机会跑起来,然后走进cpu_startup_entry,正式开始 idle 循环。
我会在后面的文章里拆解kernel_init和kthreadd,但第一篇先记住一个结论:0 号进程不是被"创建"的,它是启动流程走到最后"演变"出来的。
2.3 为什么 pid 0 不是 fork 出来的
这个问题我问过不少人,很多人第一反应是"内核里肯定有个fork_idle之类的函数把它造出来"。对了一半。对于非 0 号 CPU,确实有fork_idle(cpu)这个函数。但对于 boot CPU 上那个最原始的 0 号进程,没有任何 fork 动作。
原因很简单:fork 需要一个父进程、需要 pid 分配器、需要把一个现有任务的上下文复制一份。但在启动最早期,这些机制本来就不存在。0 号进程是直接静态定义在镜像里的。代码里对应着两个标志性的东西:init_task和init_thread_union。
init_task是struct task_struct的静态实例,用INIT_TASK(init_task)宏初始化,里头把state、prio、stack、flags全部预设好。init_thread_union是它的内核栈。启动早期,CPU 在进入start_kernel之前用的就是这块栈,所以与其说 0 号进程被创建,不如说"整个内核启动过程本身就是跑在 0 号进程的上下文里"。
3. 原地变身:0 号进程如何成为各 CPU 的 idle
3.1 init_task 与 init_thread_union:最原始的执行流
翻内核源码时你会看到init/init_task.c里有类似这样的定义:
struct task_struct init_task = INIT_TASK(init_task); EXPORT_SYMBOL(init_task);INIT_TASK宏会把init_task初始化为:state = 0(可运行)、prio = MAX_PRIO - 20(nice 级别约 -20)、stack指向内嵌的init_thread_union栈。也就是说,它在系统刚开始时就存在,甚至比 pid 1、 pid 2 上的任何代码都要更早出现在内存里。
理解这一点,再看启动早期的汇编入口会有豁然开朗的感觉:从startup_64到start_kernel,那些代码运行在什么栈上?就是init_thread_union。那这个上下文是谁?就是 0 号进程。所以"idle 的启动"实际上从 CPU 上电那一刻就开始了,系统在用它完成所有初始化工作之后,才把它最后定义为 idle。
3.2 do_idle 死循环的内部逻辑
rest_init最后调用cpu_startup_entry(CPUHP_ONLINE),而cpu_startup_entry实现就是一个死循环:
void cpu_startup_entry(enum cpuhp_state state) { current->flags |= PF_NOFREEZE; arch_cpu_idle_prepare(); cpuhp_online_idle(state); while (1) do_idle(); }真正的核心在kernel/sched/idle.c里的do_idle(),它的逻辑可以理解成"望风":
static void do_idle(void) { int cpu = smp_processor_id(); __current_set_polling(); while (!need_resched()) { rmw_barrier(); if (cpu_is_offline(cpu)) cpuhp_report_idle_dead(); local_irq_disable(); arch_cpu_idle(); } __current_clr_polling(); ... schedule_idle(); }简单拆一下这个过程:
- 先看
need_resched(),如果有人给当前 CPU 设置了调度请求,就不 idle 了,直接去schedule_idle()。 - 没调度请求,就把本地中断关掉,然后执行
arch_cpu_idle(),在 x86 上最终一般会落到hlt指令或者mwait之类,让 CPU 真正歇着。 - 等外部中断一来,CPU 从 halt 醒来,重新循环,检查有没有新的调度需求。
这个过程是不是很像一个值班保安:没人来找,就坐下休息;有人按门铃(中断),就醒来看看情况。idle 进程的 R 状态其实是调度器视角的"永远可运行",但物理上 CPU 通过 hlt 把功耗降下来了。这两个视角叠加在一起,就是很多人观察 idle 时觉得矛盾的地方。
3.3 非 0 号 CPU 的 idle 线程创建机制
boot CPU 上的 0 号进程不需要重新造,但其他 CPU 呢?在 SMP 系统里,每个 CPU 都要有一个自己的 idle 线程。这部分由smpboot机制负责。
关键的流程是:系统枚举可能的 CPU 编号,对每个非 boot CPU 调用fork_idle(cpu),用dup_task_struct以 0 号进程为模板复制出一个新线程,然后调用init_idle(task, cpu)把它挂到目标 CPU 的运行队列上,最后设置rq->idle指针指向它。
这里有一个很有意思的点:init_idle不只是简单地把 task 塞进去,它要把这个 idle 线程的cpu字段改成目标 CPU,更新其调度相关属性,还必须保证它是"最后一个被选中的人"。就算配置了isolcpus或者设置了亲和性,idle 依然承担着兜底职责。如果你做实时性调优,会发现隔离核上的空闲进程还是会以某种形式存在,这就是原因之一。
4. 实操:在你的机器上给 idle 进程"验明正身"
4.1 从 /proc 和 ps 观察 0 号进程
我之前在排查一个"内核线程 CPU 高"的问题时,养成了先看 0 号进程状态的习惯。在宿主机上直接访问/proc/0是可以的,只是 ps 命令通常不会显示它。如果想"看一眼本尊",可以用:
cat /proc/0/comm cat /proc/0/stat如果/proc/0不存在(某些容器或 pid namespace 隔离环境下),说明你看到的是当前命名空间内的进程视角,并不是内核态真的没有 pid 0。这一点在做排查时特别容易踩坑。
想看看每个 CPU 的 idle 线程 comm 是什么,可以用:
for f in /proc/[0-9]*/comm; do c=$(cat "$f" 2>/dev/null) if [[ "$c" == swapper* ]]; then echo "$f -> $c" fi done能看到的输出通常是swapper/0、swapper/1、swapper/2这样的形式,有的内核版本里也会显示成idle/1,这个差异取决于发行版改没改 task comm。
4.2 用 ftrace 抓取 idle 进程的调度切换
观察 idle 进程最直观的办法,不是看静态状态,而是看调度切换事件。用 ftrace 的sched_switch事件可以看到 CPU 上到底从谁切到了谁:
mount -t tracefs tracefs /sys/kernel/tracing cd /sys/kernel/tracing echo 0 > tracing_on echo > trace echo "sched_switch" > set_event echo 1 > tracing_on sleep 2 echo 0 > tracing_on grep -E "swapper|idle" trace | head -20只要机器上有任何进程在睡眠和唤醒,你基本一定会看到swapper/0在切换记录中出现。比如:
bash-1234 [000] d..2. 12345.678901: sched_switch: prev_comm=bash prev_pid=1234 prev_state=S ==> next_comm=swapper/0 next_pid=0 next_state=R看到next_comm=swapper/0和next_pid=0,就说明那个 CPU 上暂时没事可做,0 号进程接管了。这个输出还能用来验证load average高低和 idle 切换频率之间的关系,排查性能问题时比 top 精准得多。
4.3 用启动日志确认 rest_init 之后系统状态
如果你还想确认 rest_init 在真实启动链路中的位置,最简单的方法是在启动参数里加上loglevel=8,然后看 dmesg 中 "Freeing unused kernel image memory" 前后打印的内容。free_initmem相关的日志通常是在rest_init之后的kernel_init阶段触发的,所以它的出现意味着 0 号进程已经成功让位给了 1 号进程。
对内核调试比较熟的朋友,可以在 QEMU 里用 gdb 给rest_init打断点。断点命中后,你会看到$rip停在 rest_init 函数头,再单步到cpu_startup_entry,能看到调用链从start_kernel平滑切到 idle 循环。
5. 常见问题与排查技巧实录
5.1 启动卡在 "Freeing unused kernel memory" 之后
我遇到过几次内核启动卡住的现象,日志停在类似 "Freeing unused kernel image memory" 的位置,后面用户态迟迟不起来。很多人第一反应是 init 程序的问题,但实际上这个位置恰好是 0 号进程把控制权移交给 pid 1 的临界点。如果 0 号进程在schedule_preempt_disabled()之后没有正确把 CPU 让出去,或者 pid 1 的内核态入口kernel_init里在run_init_process之前就失败,日志就会卡在这一带。
排查时我会先做三件事:看 dmesg 最后几条是否出现Kernel panic - not syncing、检查有没有Unable to handle kernel paging request at virtual address、再用earlycon串口输出把更早的日志抓下来。这类问题十有八九不是 idle 本身坏了,而是它的"下线交接"机制没有完成,比如某个驱动在initcall阶段死锁。
5.2 top 看到 swapper/0 占 100%,正常吗
这个问题我解释过很多次,尤其在内核线程排查时容易误报。top 里如果某个 CPU 长时间显示swapper/0占用很高,恰恰说明该 CPU 绝大部分时间没有任务可跑,处于真正的空闲状态。idle 进程的"R 状态 + 高占用"是调度器视角的正常情况,因为它永远可运行,物理上却通过 hlt 指令把功耗压到最低。
真正要警惕的是ksoftirqd/0、rcuos这类内核线程占用高,那才是网络中断、RCU 回调、软中断处理异常的典型信号。判断"CPU 是不是真的空闲",最简单有力的指标是top里的%Cpu(s): id那一栏,以及mpstat -P ALL 1输出的%idle,而不是看某个进程到底叫不叫 swapper。
5.3 0 号进程为什么没有 mm 和命令行
用/proc/0/status看时你会发现它的VmSize之类全是 0,也很少有正常的 cmdline。因为swapper/0是一个纯内核线程,不访问用户空间页表,mm指针为空,active_mm在init_idle时被显式设置成init_mm。它的"命令行"其实就是内核线程的 comm 字段,没有 argv 环境。
理解这一点有助于区分内核线程和用户进程:所有用[]括起来的 ps 输出条目都是内核线程,而swapper/0连[]都不一定显式给你。它不是一个"残缺的进程",而是"不进入用户态的执行流",所以没有文件描述符表、没有信号处理这些用户态概念也很正常。
结尾的一点个人体会
linux 里几百个内核线程里,idle 算是最"闷声干活"的一个。我刚调试调度器时,总觉得它不过是个空转的占位符,后来顺着启动代码读下来,才发现系统把所有初始化做完之后,是把整台机器"托付"给这个 0 号进程的。它不参与业务,却是整个调度体系里最不可能缺席的角色。
另外分享一个我调试时的习惯:与其从start_kernel从头看,不如直接从rest_init往回看,因为这条线能把你瞬间带进进程管理、调度器、SMP 初始化这几个核心交汇点。后面我会在这个系列里继续拆cpu_startup_entry里的 cpuidle 框架、非 boot CPU 的 idle 初始化细节,还有schedule_idle后面的调度路径,每一个都比表面看起来更有意思。