os操作系统——第7讲:多核启动与核间中断(IPI)
目录
第7讲:当单身汉遇到双胞胎——多核启动与核间中断(IPI)
前面的6讲,我们都是假设系统只有一个 CPU 核心。这个单身汉兢兢业业,处理所有中断、运行所有任务。
但今天的手机、电脑、服务器,甚至高端嵌入式芯片,都有两个、四个甚至上百个核心。
单核已经无法满足性能需求:你一边编译代码一边听歌,如果只有一个核,编译时音乐会卡顿;浏览器渲染页面时,视频解码就会掉帧。多核系统并不是简单的“复制粘贴”单核,而是一套全新的架构:
每个核心独立运行调度器,它们共享同一片内存,却又各自有自己的缓存。
多核启动、任务分配、核间通信,就是这一讲的主题。
1. 单核的尽头:性能墙与功耗墙
过去几十年,CPU 性能提升靠的是提高主频和指令级并行。但主频超过 3GHz 后,功耗和发热急剧上升(功耗 ∝ 频率³)。
于是,芯片厂商转向了多核:在同样功耗下,两个 2GHz 的核心能提供接近单核 4GHz 的吞吐量(理想情况)。
但多核带来了新的编程模型:原来的单核程序假设“同一时刻只有一件事在发生”,现在多个核心可以同时访问共享内存。
这对于操作系统内核来说,是翻天覆地的变化:
- 关中断只能管住当前核心,其他核心依然可以闯入临界区。
- 一个核心修改了任务状态,另一个核心可能正在运行该任务,导致数据错乱。
- 每个核心有自己的缓存,一个核心改了内存变量,另一个核心的缓存里还是旧值(缓存一致性问题,第9讲)。
因此,多核操作系统必须重新设计调度器、同步原语和内存管理。
2. 多核硬件基础:私有与共享
一个典型的多核 SoC(如 ARM Cortex-A53 四核)包含:
-
每个核心独立拥有:
- 寄存器组(R0-R31、PC、SP、CPSR)
- L1 指令/数据缓存(32KB 左右)
- 定时器(每个核心有自己的 private timer 或 generic timer)
- 中断控制器接口(GIC 的 CPU interface)
-
所有核心共享:
- L2/L3 缓存(可选)
- DDR 内存
- 外设(UART、GPIO、以太网)
- 全局中断控制器(GIC distributor)
关键点:每个核心都能访问任何物理内存地址,只是访问速度不同(缓存命中快,未命中慢)。
3. 多核启动:谁先醒来?
上电时,所有核心都会从 ROM 中的复位向量开始执行。但为了防止多个核心互相踩踏,硬件设计通常让一个特定的核心先启动,其他核心处于等待状态。
这个先启动的核心叫 BSP(Bootstrap Processor),其他叫 AP(Application Processors)。
3.1 BSP 的任务
BSP 负责初始化全局数据结构(如就绪队列、内存堆、中断控制器),然后向每个 AP 发送核间中断(IPI) 或写入特定寄存器,唤醒它们。
以 ARM Cortex-A 为例,通过自定义的 SEV(Send Event) 指令或 GIC 的 SGI(Software Generated Interrupt)来唤醒其他核心。
更通用的方法是使用 PSCI(Power State Coordination Interface) 固件调用:
// 在 BSP 中唤醒核心 1
psci_cpu_on(core1_entry, stack_pointer);
AP 被唤醒后,从指定的入口地址开始执行(通常是内核的一个函数),进行自初始化:设置自己的栈、页表、GIC CPU 接口、定时器,然后进入空闲循环或开始运行任务。
3.2 简单示例:用汇编启动双核(RISC-V 风格)
# 启动代码,BSP 和 AP 都会执行
_start:
csrr t0, mhartid # 读取当前核心 ID
li t1, 0
beq t0, t1, bsp # 核心0 跳转到 BSP 路径
# 其他核心等待 BSP 唤醒
wfi
j _start
bsp:
# 初始化全局数据结构、堆栈、中断
call init_global
# 设置核心1 的入口和栈
la a0, ap_entry
la a1, ap_stack_top
call wake_core1 # 内部通过 IPI 写入特定地址
# 核心0 开始运行调度器
call scheduler_main
4. 每核独立调度器:全局队列 vs 本地队列
单核只有一个就绪队列。多核时代,两种主流设计:
4.1 全局就绪队列(Global Ready Queue)
所有核心共享一个全局就绪队列。调度器在 Tick 中断或任务创建时,从队列中取出最高优先级任务,分配给当前空闲的核心。
优点:负载均衡天然实现(谁空闲谁取任务)。
缺点:全局队列需要加锁(自旋锁),多个核心争用同一把锁会严重影响性能(锁争用)。而且缓存不友好:一个核心取走任务后,可能另一个核心的缓存里还留着这个任务的 TCB。
4.2 每核本地就绪队列(Per-CPU Ready Queue)
每个核心有自己的就绪队列。任务创建时,被分配到一个指定的核心(可以基于负载、亲缘性)。每个核心只调度自己队列里的任务。
优点:无锁或很少锁(只有跨核迁移时需要锁),缓存局部性好。
缺点:可能导致负载不均(一个核心忙死,另一个核心闲死),需要额外的负载均衡机制(定期迁移任务)。
大多数现代商用操作系统(Linux、Windows、Zephyr)采用 混合模型:每个核心有本地队列,同时维护一个全局队列用于迁移或新任务分配,并有守护线程定期平衡。
为了教学简化,我们采用全局就绪队列 + 自旋锁的方式,让读者先体验多核协调的复杂性。
5. 核间中断(IPI):多核的心跳
IPI(Inter-Processor Interrupt)是一种特殊的中断,由一个核心发送给另一个核心(或所有其他核心),用来通知对方做某件事。
5.1 IPI 的典型用途
- 唤醒空闲核心:当全局就绪队列有新任务,而某个核心正在 idle 状态(执行
wfi),BSP 发送 IPI 让它醒来取任务。 - TLB 刷新:如果一个核心修改了页表,它必须让其他核心无效化它们的 TLB 缓存,否则会访问到旧映射。发送 IPI 执行 TLB 无效化。
- 全局事件:如系统关机、panic 时,通知所有核心停止运行。
- 负载均衡:触发其他核心检查是否有多余任务可以迁移。
5.2 如何触发 IPI
在 ARM GICv2 中,使用 SGI(Software Generated Interrupt),每个核心可以通过写 GICD_SGIR 寄存器发送中断编号 0~15 给指定核心集合。
// 发送 IPI 给核心 1
void send_ipi_to_core1(void) {
// GICD_SGIR: 目标核心列表,中断号
*GICD_SGIR = (1 << 16) | // 目标核心1
(1 << 24) | // 使用 list 模式
0; // SGI 编号0
}
接收方核心的 GIC CPU interface 会触发 IRQ,在中断处理函数中识别 SGI 编号,执行相应动作(比如重新调度或刷新 TLB)。
5.3 简单 IPI 处理示例
void ipi_handler(void) {
int ipi_num = get_ipi_number(); // 读取 GICC_IAR
switch (ipi_num) {
case IPI_RESCHEDULE:
// 强制该核心重新调度(例如全局就绪队列有新任务)
need_reschedule[core_id()] = 1;
break;
case IPI_TLB_FLUSH:
// 无效化本地 TLB
asm volatile("tlbi alle2" : : : "memory");
break;
default:
break;
}
// 写 EOI 结束中断
}
6. 实战:一个简陋的双核调度器雏形
我们用伪代码勾勒一个双核系统,全局就绪队列受自旋锁保护(自旋锁将在第8讲详细解释)。
// 全局就绪队列(单向链表,受自旋锁保护)
tcb_t *ready_list;
spinlock_t ready_lock;
// 每个核心当前运行的任务
tcb_t *current_task[NR_CPUS];
// 核心0 和 核心1 各自运行调度循环
void core_main(int core_id) {
while (1) {
tcb_t *next = NULL;
// 关本地中断(保证调度原子性,但其他核心还能运行)
local_irq_disable();
spin_lock(&ready_lock);
if (ready_list != NULL) {
next = ready_list;
ready_list = ready_list->next;
}
spin_unlock(&ready_lock);
if (next == NULL) {
// 无任务,进入低功耗,等待 IPI
local_irq_enable();
asm volatile("wfi");
continue;
}
current_task[core_id] = next;
// 切换上下文到 next 任务(类似单核过程,但需要保存/恢复该核心的寄存器)
switch_to_task(next);
local_irq_enable();
// 任务主动让出或被抢占后会回到这里,循环重新取任务
}
}
当某个任务被创建时,它被插入全局就绪队列。如果当前所有核心都在 idle(等待 IPI),则 BSP 向其他核心发送 IPI 唤醒它们。
7. 新挑战:这只是冰山一角
刚才是多核调度的最简化版本,但它暴露了许多新问题:
- 自旋锁争用:全局就绪队列的锁会成为瓶颈,尤其当核心数增多时。
- 缓存一致性问题:即使加锁,任务的 TCB 可能被多个核心的缓存缓存,一个核心修改后,另一个核心可能读到旧值(第9讲)。
- 优先级反转又回来了:多核下,一个核心上的低优先级任务持有自旋锁,另一个核心的高优先级任务自旋等待,造成浪费。
- 确定性下降:任务完成时间难以预测,因为可能被其他核心上的锁阻塞。
后面两讲会集中火力解决这些硬骨头。
8. 本讲小结
今天我们跨出了从单核到多核的第一步:
- 多核系统:每个核心有私有 L1 缓存、定时器、中断接口,共享内存与外设。
- 多核启动:BSP 先运行,逐次唤醒 AP。
- 调度模型:全局就绪队列简单但需锁保护;每核本地队列性能高但需负载均衡。
- 核间中断(IPI):核心之间通信的基础,用于唤醒、TLB 刷新、重调度请求。
现在,我们的操作系统已经可以在双核上跑多个任务了(尽管锁竞争和缓存问题可能让它比单核还慢)。下一讲,我们将直面多核并发的最底层敌人:内存乱序与自旋锁。
下一讲:看不见的敌人——多核并发与内存屏障
为什么 a=1; flag=1; 在另一个核看来可能是 flag=1 先于 a=1?如何用内存屏障修复?自旋锁怎么写才是正确的?
✍️ 思考与练习
- 观察多核:在你的 PC 上运行
lscpu(Linux)或查看任务管理器,看看有多少个核心,每个核心的 L1/L2 缓存大小是多少。 - 模拟全局队列争用:在双核模拟器(如 QEMU 的
smp选项)中运行一个多任务负载,统计关闭/开启自旋锁时任务的完成时间差异。 - 实现负载均衡:为每核本地队列模型写一个简单的负载均衡器,每隔 100ms,检查各核心队列长度,将任务从长队列迁移到短队列。
- 深入思考:为什么多核系统中“关中断”不再能保护共享资源?请举一个具体的例子。
在评论区写下你对多核调度的疑问或实验心得,我们下一讲见!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)