Linux内核0号进程:idle进程的诞生与启动全解析
讲个冷知识你从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 往往显示为 Rrunning但不代表系统很忙。恰恰相反说明没有其他任务可运行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_init0 号进程的诞生路径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 的 idle3.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 - 20nice 级别约 -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_commbash prev_pid1234 prev_stateS next_commswapper/0 next_pid0 next_stateR看到next_commswapper/0和next_pid0就说明那个 CPU 上暂时没事可做0 号进程接管了。这个输出还能用来验证load average高低和 idle 切换频率之间的关系排查性能问题时比 top 精准得多。4.3 用启动日志确认 rest_init 之后系统状态如果你还想确认 rest_init 在真实启动链路中的位置最简单的方法是在启动参数里加上loglevel8然后看 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后面的调度路径每一个都比表面看起来更有意思。