做嵌入式Linux和内核开发这一行,SMP多核启动这块迟早要啃。很多人的第一反应是:系统上电以后,四个核不是应该一起跑起来吗?实际情况是,你在串口上看到的第一行内核日志往往只有一个CPU在工作,直到某一行"smp: Bringing up secondary CPUs ..."出现,剩下的核才陆续醒来。这个唤醒过程,就是Linux SMP多核启动机制在做的事。
我先说结论:Linux的多核启动,本质上是先让一个主核(Boot CPU,通常是CPU0)把内存、时钟、中断控制器、调度器这些家底都整理好,再通过一套状态机和跨核中断,把其他从核逐个拉起来。这篇文章按调用的先后顺序,把主核启动、从核唤醒、CPU hotplug状态机、内核日志、故障排查一条链路讲完。做内核移植、嵌入式BSP,或者面试前想系统过一遍这块知识的,都可以拿这篇当个索引。
1. SMP多核启动:到底在启动什么
1.1 对称多处理的不对称起点
SMP(Symmetric Multi-Processing)的关键词是“对称”:多个CPU共享同一块物理内存,地位平等,谁都能执行内核代码、谁都能响应外部中断。x86服务器、ARM64派系、多数嵌入式多核SoC,跑的都是SMP这套逻辑。
但真正上电那一刻,“对称”是不成立的,必须有一个不对称的起点。原因很朴素:几个核一上电就同时执行,首先抢的就是内存控制器,连一个能统一指挥的人都没有。内核需要有人先把页表建立起来,把异常向量表、中断控制器、时钟源挨个初始化,再告诉其他核“你们可以上了”。这个起跑的人就是Boot CPU。
这有点像开公司,合伙人再多,注册那天也得有一个人先去把营业执照办下来,其他人随后入场。Linux设计上没有让所有CPU从复位开始就“齐步走”,就是为了避免这种群龙无首的混乱。
1.2 谁来决定谁是Boot CPU
Boot CPU的选定通常是硬件和固件商量好的。ARM64平台上,主核从复位向量开始执行,其他核要么进入WFI(Wait For Interrupt)低功耗状态,要么在指定的spin-table地址上原地打转,等主核发信号。x86则是BSP(Bootstrap Processor)先启动,AP(Application Processor)被INIT信号按住,等待SIPI指令唤醒。
这里有个容易忽略的细节:Boot CPU不保证永远是CPU0。某些平台固件自己就用了CPU1跑了一段代码,Linux解析到之后会把CPU编号重新梳理。所以写BSP代码时,不要想当然地认为“CPU0一定先跑”,更可靠的做法是让内核自己在smp_init_cpus阶段去枚举。
1.3 主核要准备哪些“基础设施”
从复位到start_kernel,主核必须搞定三类基础设施。
第一是内存。早期汇编阶段要建立恒等映射和内核镜像映射,否则后面的C代码连取指都没法做。Linux内核从汇编跳到start_kernel之后跑的是虚拟地址,如果页表没把内核映射好,PC一跳就非法访问,后面全是空谈。
第二是中断。GIC(Generic Interrupt Controller)的distributor必须提前初始化,因为从核被唤醒后要跟主核完成“握手”,底层靠的是SGI(Software Generated Interrupt),也就是IPI的一种。GIC没初始化,这个中断发不出去,从核在线流程直接卡死。
第三是时钟。tick device、clocksource不初始化,调度器的时钟节拍就是乱的。多核调度器要把负载分到各个CPU上,每个CPU都要有能用的本地定时器,所以这些在唤醒从核之前都得就位。
这些基础设施都搞定后,主核才会走进smp_init和cpu_up,去唤醒从核。记住这个顺序,后面排障时你就能明白:为什么日志里某一步没出现,就说明前面某个子系统已经出了问题。
2. 从Boot CPU到start_kernel:主核的完整引导链
2.1 汇编阶段:建页表、设栈、跳C代码
主核的引导是从架构相关的汇编代码开始的。以ARM64为例,入口是stext,它要干的事情非常聚焦:建立早期页表、设置临时栈、然后跳转到C语言的入口。
这一步有几个容易踩坑的点。第一个是栈指针。汇编阶段设置的临时栈必须足够大,因为start_kernel早期就会有比较深的函数调用,栈太小会在没有日志的情况下莫名崩溃。第二个是异常等级。ARM64有EL1到EL3,内核跑在EL1,但早期可能从EL2(虚拟化扩展)甚至EL3下来,需要把异常等级切对,同时把相关的系统寄存器配好。
x86那边更繁琐一点,要经历16位实模式、32位保护模式、64位长模式的切换,中间还要解析Zero Page里的内存布局。但逻辑内核主线是一致的:先让自己的执行环境可靠,再往下走。
2.2 start_kernel里谁在管“有多少CPU”
进入start_kernel之后,一个很容易被忽略但非常关键的节点是:到底有多少CPU,不是靠某个宏写死的,而是由固件、设备树或ACPI表告诉内核的。
ARM64的setup_arch里会调用smp_init_cpus,把设备树里cpus节点、CPU的enable-method逐一解析出来,填充possible和present掩码。x86则是从ACPI的MADT表里读Local APIC信息,判断有哪些处理器。
这里我补充一点:possible掩码不是最终决定实际能用的CPU数量,它只代表“系统里最多可能有这些CPU”。present代表“硬件上确认存在的CPU”。online才是真正被拉起来参与调度的。这三个掩码在排障时非常有用——如果possible里有CPU4但present里没有,说明枚举阶段就丢了;如果present有但online没有,说明唤醒阶段挂了。
2.3 设备树与ACPI怎么描述多核拓扑
设备树里,CPU节点长这样:
cpus { #address-cells = <1>; #size-cells = <0>; cpu@0 { device_type = "cpu"; compatible = "arm,cortex-a53"; reg = <0x0>; enable-method = "psci"; cpu-release-addr = <0x0 0x8000fff8>; }; };其中enable-method决定了内核用哪种机制唤醒这个核心。如果是"spin-table",内核会往cpu-release-addr写入唤醒地址;如果是"psci",内核通过固件接口的CPU_ON指令唤醒。这个属性如果漏掉或者写错,从核就永远起不来。
ACPI下发的CPU信息则是解析MADT(Multiple APIC Description Table)里的GICC结构体,逻辑上等价于设备树。这套信息最终汇总成struct cpu数组,驱动后面的cpu hotplug流程。
3. 从核唤醒的三种主流姿势
3.1 spin-table:最朴素的握手
spin-table是早期ARM64平台很常见的唤醒方式,思路非常朴素:从核上电后在某个约定的内存地址上死循环轮询,主核把从核的入口地址写进去,然后触发一个事件,从核看到新地址后跳过去执行。
这里有个关键点:写入地址的操作必须具备release语义,而从核轮询必须是acquire语义,否则会出现缓存不一致,从核读到的是旧值。Linux内核在写release地址时用了smp_store_release,从核侧用smp_load_acquire配套,这一对屏障缺一不可。
spin-table的优点是逻辑简单,不依赖安全固件,很适合裸机环境和早期调试。缺点是它把“电源管理”这种本该由固件管的事放到了内核里,扩展性差。现代平台已经逐渐转向PSCI,但理解spin-table仍然有意义,因为它代表了最基础的“共享内存加中断”的协作模型。
3.2 PSCI:把活交给固件
PSCI(Power State Coordination Interface)是ARM定义的电源管理规范,内核通过smc指令陷入EL3,让可信固件去执行CPU上电、下电等操作。从核唤醒用的是CPU_ON这个接口,主核传入目标CPU编号和入口地址,固件负责把核心从低功耗状态拉起来。
为什么PSCI更受青睐?因为它把电源状态管理集中到了固件侧,内核不需要关心具体平台怎么操作寄存器,也不用担心spin-table里缓存一致性的边界问题。同时,PSCI还能处理安全状态切换,比如从核在Secure World里的话,普通内核代码根本没权限碰。
在实际开发中,如果设备树里写的是enable-method = "psci",内核会拼接一个cpu_on消息,通过smc调用触发。启动日志里如果能看到"PSCI: SMC call done"或者类似固件信息,说明这一路是通的。
3.3 x86的INIT-SIPI与通用IPI唤醒
x86的唤路径和ARM完全不同。BSP通过Local APIC发送INIT信号让AP复位,再发送SIPI(Startup IPI)让AP从实模式的warm reset vector开始执行。AP在trampoline代码里从16位实模式一路切换到长模式,最后跳进secondary_startup_64。
SIPI一般发两次,因为第一次可能因为总线竞争没收到,这是硬件层面的容错惯例。
无论哪种架构,从核真正进入内核C代码后的路径是大同小异的:设置current、初始化栈、通知主核“我已经起来了”,然后进入idle循环等待调度。区别只在于“走到内核这一步之前”,硬件和固件帮你做了多少。
4. CPU hotplug状态机与从核上线全流程
4.1 cpuhp状态机里发生了什么
直接把从核拉起来是最简单的想法,但Linux没有这么干。原因在于,一个CPU从离线到在线,要走完很多子系统的初始化:per-cpu时钟、RCU、workqueue、调度器、热插拔回调等等。这些子系统之间有依赖关系,必须按顺序来。所以内核把整个生命周期拆成了状态机,从CPUHP_OFFLINE一路走到CPUHP_ONLINE,中间有几十个状态。
从唤醒从核的角度,最关键的几个状态是:CPUHP_CREATE_THREADS负责创建per-cpu内核线程,CPUHP_BRINGUP_CPU是架构相关的实施阶段,它会调用__cpu_up,真正去执行spin-table或PSCI的唤醒动作。
状态机的好处是,每个回调函数只干一件小事,出错时可以精确定位到是哪一步失败。坏处是,排障的时候不看状态机日志,你根本不知道从核卡在哪个环节。所以,遇到从核不起来的问题,第一步不是改代码,而是先确认状态机走到哪一步。
4.2 从核自举:secondary_start_kernel要干的事
从核被唤醒后,汇编层先设置好栈指针,然后跳进secondary_start_kernel。这个函数是每个从核的“二次人生起点”,它主要做几件事:设置current指向自己的idle任务,初始化per-cpu的寄存器状态,配置好本地定时器,然后进入CPU hotplug的AP侧回调序列。
一个常见的坑是:从核执行的代码路径里不能随便调用可能需要主核配合的函数,否则可能死锁。因为从核起来的早期,调度器对它的感知还不完整,有些全局锁确实拿到了,但等待方是主核,而主核正在等待从核完成上线——这就是经典的ABBA死锁。内核为了让从核上线安全,专门设计了cpuhp_thread机制,从核在状态机的AP侧回调里由自己创建的cpuhp线程执行,而不是直接在中断上下文硬跑。
4.3 possible/online/present掩码是怎么维护的
前面提到过possible、present、online三个掩码,这里把它们的维护时机说清楚。
possible在smp_init_cpus阶段逐步设置,内核根据设备树或ACPI把所有可能存在的CPU编号加进去。present同样在早期发现硬件时设置,它表示“这些CPU确实存在”。online则是cpuhp状态机到达CPUHP_ONLINE后,由内核把对应的位图置上。
系统运行起来后,你随时可以通过sysfs查看这些掩码:
cat /sys/devices/system/cpu/possible cat /sys/devices/system/cpu/present cat /sys/devices/system/cpu/online用这个能快速判断问题是出在CPU“存在性”上还是“可运行性”上。如果possible和present都是0-3,但online只有0,那明显是从核唤醒或上线的环节出了问题。
5. 实操观察:怎么确认你的系统真的“多核启动”了
5.1 从内核日志里读启动过程
启动完成后,先看dmesg里和SMP相关的日志:
dmesg | grep -i smp dmesg | grep -i cpu一个正常的多核启动过程,日志大致长这样:
smp: Bringing up secondary CPUs ... CPU1: Booted secondary processor 0x0000000001 [0x410fd083] CPU2: Booted secondary processor 0x0000000002 [0x410fd083] CPU3: Booted secondary processor 0x0000000003 [0x410fd083] smp: Brought up 1 node, 4 CPUs看到"Brought up 1 node, 4 CPUs"说明四个核都已经在线。如果只有"Bringing up secondary CPUs ..."而没有后续,说明某个从核卡在唤醒阶段,接下来要按状态机去查。
值得一提的是,"Booted secondary processor"后面那串数字是该CPU的MIDR寄存器值,不同核心型号不一样。看到两个CPU的MIDR不同,说明系统里混了大小核,配置调度域时要小心。
5.2 用sysfs和命令行验证CPU状态
内核起来后,用几个命令就能确认当前CPU拓扑。
nproc lscpu cat /sys/devices/system/cpu/onlinelscpu能看到CPU(s)、Core(s) per socket、Thread(s) per core这些信息,底层读的是sysfs和/proc/cpuinfo。在线状态则直接看/sys/devices/system/cpu/online这个文件,输出"0-3"表示从0到3全部在线。
如果想看每个核当前的工作负荷,可以用top然后按"1",或者用mpstat -P ALL。在调试多核负载均衡时,这两个命令比看平均负载更直接。
5.3 中断亲和性与负载均衡配置
多核起来只是第一步,能不能把活儿均匀分到各核上是另一件事。中断亲和性是一个关键维度。
查看中断分布用:
cat /proc/interrupts每一列对应一个CPU。如果发现某个网卡中断全落在CPU0上,那CPU0很快会成为瓶颈。可以用echo手动设置affinity:
echo 2 > /proc/irq/78/smp_affinity这里的2是二进制位图,表示只允许CPU1处理该中断。生产环境通常用irqbalance自动调度,但在嵌入式场景里,手动绑亲和性往往更可控。
进程绑核则用taskset:
taskset -c 0,1 ./my_benchmark这表示进程只允许在CPU0和CPU1上运行。多核性能调优时,先分清楚是中断不均衡还是任务不均衡,再决定动哪个参数,这个排查顺序很重要。
6. 多核启动的典型故障与排查实录
6.1 从核没起来:日志和错误码怎么定位
最常见的问题是某个从核没有成功online。日志里可能报错:
CPU1: failed to come online或者干脆卡在"Bringing up secondary CPUs ..."这里没有任何后续。这时候按这个顺序查。
先确认设备树或ACPI里CPU节点有没有配enable-method。很多从核起不来,是因为设备树里漏了enable-method = "psci",内核不知道用什么方式唤醒,干脆跳过。再看固件侧,PSCI的CPU_ON是否实现正确,这个可以用固件日志或者尝试手动调用CPU_ON接口验证。
如果确认唤醒指令发出去了,但从核没反应,接下来看中断。从核和主核握手的完成通知依赖IPI,GIC初始化有问题的话,从核可能已经跑了,但主核收不到完成中断,只能超时。这时候用内核命令maxcpus=1启动,对比一下单核是否正常,能快速把问题范围缩小。
6.2 从核启动后随机死机:栈与中断的问题
比“完全起不来”更难查的是“从核起来了,但系统运行一会儿就死”。这种偶发问题优先怀疑两类原因。
一类是从核栈问题。每个CPU的idle任务栈是独立分配的,大小一般是16K。如果某个驱动在硬中断里用了过深的栈,或者配置了过大的percpu变量,很容易溢出。内核里有CONFIG_DEBUG_STACK_USAGE选项,配合ftrace的stack_max_size可以抓到峰值栈使用,排查时开起来非常有用。
另一类是中断亲和性问题。如果外设中断在多个核之间迁移,而驱动没有正确处理per-cpu数据,就会出现竞争。典型现象是:单核跑怎么都正常,多核跑就偶发crash,而且crash现场看起来毫无规律。遇到这种,先把irqaffinity固定到一个核上,如果问题消失,说明跟中断并发有关,再进一步查锁或禁用中断的临界区。
6.3 手头调多核启动的三个建议
最后分享几个我自己调多核启动时的习惯。
第一,善用内核cmdline。nr_cpus=1和maxcpus=1都能限制CPU数量,但含义不同。nr_cpus是限制possible掩码,maxcpus是限制online数量。排障时先用maxcpus=1把系统降级为单核,确认基线功能正常,再逐步放开。
第二,打开cpuhp的ftrace事件。把/sys/kernel/debug/tracing/events/cpuhp/enable置1,启动阶段就能看到每个CPU在状态机上执行了哪些回调、停在哪一步。这比盲改代码高效得多。状态机回调是分阶段的,日志里会有明确提示,卡在哪一目了然。
第三,怀疑缓存一致性时,优先检查固件和内核的内存屏障是否匹配。spin-table这种共享内存协作方式,如果从核的固件轮询没有做acquire,主核的release写得再对也白搭。这不是内核能单方面解决的问题,得固件和内核一起改。
我实际项目里遇到的坑,七成出在设备树和固件,三成才轮到内核。先把“系统认为有几个CPU、能唤醒哪些核”搞清楚,问题往往就解决了一半。再配合状态机日志和合理的内核cmdline,基本上能把问题范围缩到很小。多核启动机制本身不难理解,难的是把这条链路上的所有参与者串起来,从硬件到固件再到内核,每一步都走对了,才能看到那行让人安心的"smp: Brought up 1 node, 4 CPUs"。