引子:从一颗到多颗CPU
早期PC只有一颗CPU,所有指令串行执行。操作系统调度器的工作很简单——只要有一个任务在跑,别的任务就得等着。
但物理世界很快碰到了瓶颈:单核性能提升遇到了"功耗墙"——主频不能再高,散热压不住。要更强的算力,只剩一条路:在同一个系统里塞进多颗CPU。
这就是 SMP(Symmetric Multi-Processing,对称多处理) 诞生的背景。多个CPU核心平等地共享同一块物理内存,每个核心都有自己的寄存器、L1/L2缓存,但共享内存总线和DRAM。Linux内核从2.0开始支持SMP, wandos 同样实现了这一机制。
本文基于 wandos 的 SMP 源码(arch/x86/smp.cpp、kernel/smp/smp_scheduler.cpp),深入解析多核系统从开机到调度的一切细节。
一、APIC体系:多核通信的基石
在单核时代,外设中断由 8259A PIC(可编程中断控制器)统一管理,一颗芯片串起15个IRQ。但多核时代,PIC 无法将中断精准投递给某个CPU核心——它只知道"有中断发生",不知道"哪颗CPU来处理"。
APIC(Advanced Programmable Interrupt Controller) 解决了这个问题,采用双层架构:
1.1 Local APIC(LAPIC)—— 每CPU一个
每个CPU核心内部集成一颗 LAPIC,负责:
- 接收来自 IOAPIC 的外部中断
- 接收来自其他CPU的 IPI(Inter-Processor Interrupt)
- 管理本地中断(LINT,即Local Interrupt,如温度传感器、计时器等)
LAPIC 内部有一组寄存器,映射到 MMIO 地址 0xFEE00000(这是物理地址,在开启分页后通过恒等映射访问):
// apic.cpp
#define LAPIC_BASE 0xFEE00000
static inline uint32_t apic_read(uint32_t reg) {
return *((volatile uint32_t*)(LAPIC_BASE + reg));
}
LAPIC ID 是每颗CPU的唯一标识,通过读取 LAPIC_ID 寄存器得到:
uint32_t apic_get_id() {
return (apic_read(LAPIC_ID) >> 24) & 0xFF;
}
wandos 在 smp_init() 中,先获取 BSP 的 LAPIC ID 作为主标识,再向其他 AP 发送启动信号:
bsp_lapic_id = apic_get_id();
auto cpu_count = apic_get_cpu_count();
log_debug("系统总 CPU 数量: %d, BSP ID: %d\n", cpu_count, bsp_lapic_id);
1.2 IOAPIC —— 外设中断的路由器
与每核一个的 LAPIC 不同,系统只有 一颗 IOAPIC,负责将外设IRQ路由到正确的 LAPIC。IOAPIC 有24个重定向表项(Redirection Table Entry),每个对应一个IRQ线:
#define IOAPIC_BASE 0xFEC00000
#define IOAPIC_IOREGSEL 0x00
#define IOAPIC_IOWIN 0x10
IOAPIC 的中断重定向配置非常灵活——可以指定:
- 目标中断向量(决定最终跳转到哪个中断处理程序)
- 目标 LAPIC(哪个CPU处理)
- 触发模式(边沿触发 vs 电平触发)
- 投递模式(物理模式 vs 逻辑模式)
wandos 在初始化时把24个IRQ全部配置好:
void ioapic_set_irq(uint8_t irq, uint64_t value) {
uint32_t ioredtbl = 0x10 + 2 * irq;
ioapic_write(ioredtbl, (uint32_t)value);
ioapic_write(ioredtbl + 1, (uint32_t)(value >> 32));
}
Linux 的 IOAPIC 初始化类似,在 setup_IO_APIC() 中构建重定向表,实现"外设 → IOAPIC → LAPIC → CPU"的中断投递链。
二、BSP与AP:谁是主人
SMP系统里,CPU 分为两类角色:
| 角色 |
全称 |
职责 |
| BSP |
Bootstrap Processor |
唯一由BIOS/UEFI引导的CPU,负责初始化系统、构建页表、加载内核、发送启动信号 |
| AP |
Application Processor |
被 BSP 唤醒的多核,负责执行应用代码 |
系统上电时,只有 BSP 活跃。其他 CPU 核心处于"未定义"状态——没有GDT、没有IDT、没有页表,甚至不知道自己是一颗CPU。BSP 的任务就是逐一唤醒它们。
wandos 的 smp_init() 清晰展示了这个过程:
void smp_init() {
bsp_lapic_id = apic_get_id();
auto cpu_count = apic_get_cpu_count();
// 发送 INIT-SIPI-SIPI 序列启动 APs
for(uint32_t i = 0; i < cpu_count; i++) {
uint32_t target_id = i;
if(target_id != bsp_lapic_id) {
apic_send_init(target_id); // 1. INIT:复位AP
for(int j = 0; j < 1000000; j++)
__asm__ volatile("pause");
apic_send_sipi(0x8000, target_id); // 2. SIPI:AP从0x8000开始执行
for(int j = 0; j < 1000000; j++)
__asm__ volatile("pause");
apic_send_sipi(0x8000, target_id); // 3. 再发一次SIPI,确保收到
for(int j = 0; j < 1000000; j++)
__asm__ volatile("pause");
}
}
// 等待所有AP就绪
while(cpu_ready_count < apic_get_cpu_count() - 1) {
__asm__ volatile("pause");
}
}
INIT-SIPI-SIPI 序列
这套序列背后的原理值得细说:
INIT IPI:向目标 AP 发送 Reset 信号。AP 会执行一次软件复位,清除内部部分状态,但不初始化段寄存器和页表。AP 随后进入"等待启动向量"状态。
SIPI(Startup IPI):携带一个物理地址(必须是4KB对齐的),AP 收到后将这个地址的向量值(vector << 12)作为入口地址,跳转执行。wandos 使用 0x8000,对应向量值 0x80 >> 12 = 0x02,实际上 AP 会从物理地址 0x8000(即 0x02 << 12)开始取指令。
为什么要发两次 SIPI?因为 AP 可能还没完全处理完第一次的 Reset 状态,第二发是"保险"。在真实硬件上,部分 AP 可能错过第一次 SIPI。
AP_entry:AP的C语言入口
AP 收到 SIPI 后,跳转到 ap_entry() 函数继续执行:
void ap_entry() {
uint32_t current_cpu_id = apic_get_id();
log_debug("ap_entry, CPU ID: %d\n", current_cpu_id);
apic_init(); // 初始化当前AP的LAPIC
apic_init_timer(100); // 设置100Hz本地定时器
cpu_init_percpu(); // 初始化Per-CPU数据区
GDT::loadGDT(); // 加载GDT(与BSP共享)
GDT::loadTR(current_cpu_id); // 加载当前CPU的TSS
// 切换到与BSP相同的页目录
kernel.kernel_mm().paging().loadPageDirectory(0x400000);
kernel.kernel_mm().paging().enablePaging();
// 加载BSP创建的第一个任务的上下文
auto task = kernel.scheduler().get_current_task();
asm volatile("mov %0, %%cr3" ::"r"(task->regs.cr3));
asm volatile("mov %0, %%esp" ::"r"(task->stacks.esp0));
// 原子增加就绪计数器
__atomic_add_fetch(&cpu_ready_count, 1, __ATOMIC_SEQ_CST);
log_debug("CPU %d 已就绪\n", current_cpu_id);
asm volatile("sti"); // 启用中断
while(true) {
asm volatile("hlt"); // 进入空闲循环,等调度
}
}
注意几个细节:
- GDT和IDT共享:所有CPU使用同一套GDT和IDT。BSP初始化好了,AP 直接
loadGDT() 即可。 - Per-CPU 初始化:
cpu_init_percpu() 为当前CPU设置Per-CPU数据区的基址——每个CPU看到这个基址不同,因此访问到的 Per-CPU 变量也不同。 - 原子计数器:
cpu_ready_count 用 __atomic_add_fetch 递增,使用 __ATOMIC_SEQ_CST 顺序一致性保证 BSP 能看到准确的AP就绪数量。 - idle任务:AP 最终进入
hlt(halt)指令组成的空闲循环,直到被定时器中断唤醒。
Linux 的 AP 启动流程与 wandos 完全一致:AP 在 smpboot.c 的 start_secondary() 中完成类似初始化后进入 cpu_idle(),该函数核心就是 while(!need_resched()) halt()。
三、Per-CPU数据:给每个CPU一份专属变量
SMP系统里,调度器必须有独立的运行队列——如果所有CPU共享一个队列,加锁开销巨大,严重影响性能(想象一下8核CPU争抢一把锁)。每个CPU必须有自己专属的数据副本。
wandos 用模板实现了 Per-CPU 数据:
// percpu.h
template<typename T>
class PerCPU {
T* data_[MAX_CPUS] = {nullptr};
T* operator->() {
return data_[get_cpu_id()]; // 当前CPU访问自己的副本
}
void set(uint32_t cpu, T *t) {
data_[cpu] = t; // 写入指定CPU的副本
}
T* get_for_cpu(unsigned int cpu) {
return data_[cpu]; // 获取指定CPU的副本
}
};
get_cpu_id() 通过 LAPIC ID 实现,返回当前正在执行的CPU编号。
基于这个模板,调度器为每颗CPU维护独立的运行队列:
// smp_scheduler.h
class SMP_Scheduler {
arch::PerCPU<RunQueue> scheduler_runqueue; // 每CPU独立运行队列
arch::PerCPU<Task> current_task; // 每CPU当前任务
arch::PerCPU<Task> idle_task; // 每CPU idle任务
};
struct RunQueue {
SpinLock lock;
uint32_t nr_running;
struct list_head runnable_list;
};
调度器初始化时,对每颗CPU分配独立的堆(heap)上的 RunQueue 对象:
void SMP_Scheduler::init() {
for (unsigned int cpu = 0; cpu < arch::apic_get_cpu_count(); cpu++) {
scheduler_runqueue.set(cpu, new RunQueue());
auto* rq = scheduler_runqueue.get_for_cpu(cpu);
rq->lock = SPINLOCK_INIT;
rq->nr_running = 0;
INIT_LIST_HEAD(&rq->runnable_list);
if(cpu != 0) {
auto task = create_idle_task(ProcessManager::kernel_context, cpu);
idle_task.set(cpu, task);
current_task.set(cpu, task);
}
}
}
调度时,pick_next_task() 永远操作当前CPU的运行队列:
Task* SMP_Scheduler::pick_next_task() {
RunQueue* rq = scheduler_runqueue.operator->(); // 当前CPU的队列
spin_lock(&rq->lock);
if (list_empty(&rq->runnable_list)) {
spin_unlock(&rq->lock);
return load_balance(); // 本地队列空,尝试偷任务
}
Task* next = list_entry(rq->runnable_list.next, Task, sched_list);
list_del_init(&next->sched_list);
rq->nr_running--;
spin_unlock(&rq->lock);
return next;
}
Linux 内核同样使用 Per-CPU 数据结构。在 kernel/sched/core.c 中,struct rq(运行队列)是完全 Per-CPU 的,通过 raw_rq() Per-CPU 变量访问。pick_next_task() 也遵循同样的"先本地、后偷取"策略。
四、自旋锁:SMP同步原语
多核系统里,数据竞争无处不在。当两个CPU同时往同一个运行队列里入队任务时,必须有同步机制。
wandos 实现了一个经典的自旋锁:
// spinlock.h
class SpinLock {
volatile uint8_t locked;
void acquire() {
while(__atomic_test_and_set(&locked, __ATOMIC_ACQUIRE)) {
asm volatile("pause");
}
}
void release() {
__atomic_clear(&locked, __ATOMIC_RELEASE);
}
// 保存中断状态的加锁版本
void acquire_irqsave(uint32_t& flags) {
asm volatile("pushf; pop %0" : "=r"(flags));
asm volatile("cli"); // 关中断,防止死锁
acquire();
}
};
几个关键点:
__atomic_test_and_set:这是 GCC 内建的原子指令(底层是 x86 的 xchg 指令),保证"读取-修改-写入"三步在硬件层面不可分割。没有这层原子性,两个CPU可能同时认为锁是"空闲"的,同时闯入临界区。
pause 指令:在锁竞争时,pause 提示CPU"我在忙等",让出执行端口的带宽,减少总线仲裁的无效流量。在 x86 上 spin-wait 循环必须用 pause,否则会影响 CPU 的乱序执行效率。
acquire / release 语义:__ATOMIC_ACQUIRE 确保在获得锁之前,所有后续内存读写都在锁释放后才对其他线程可见;__ATOMIC_RELEASE 确保释放锁之前,所有本地修改都对获取锁的线程可见。这涉及编译器和CPU的内存屏障,是SMP代码中最容易出错的细节之一。
Linux 内核的 spinlock 在 kernel/locking/spinlock.c 中实现,逻辑完全一致:使用 xchg 指令做 test-and-set,配合 queued_spin_lock 的排队机制避免"锁总线"问题。
五、调度器:多核任务分配
wandos 的 SMP 调度器核心在 pick_next_task() 和 load_balance() 两个函数。
5.1 入队与选核
当一个任务被创建或从阻塞中唤醒时,需要决定它进入哪个CPU的运行队列:
void SMP_Scheduler::enqueue_task(Task* p, int cpu_id) {
RunQueue* rq = scheduler_runqueue.get_for_cpu(cpu_id);
spin_lock(&rq->lock);
list_add_tail(&p->sched_list, &rq->runnable_list);
rq->nr_running++;
spin_unlock(&rq->lock);
}
注意这里可以指定入队到某颗CPU——这对 CPU 亲和性(affinity)很重要。如果一个进程被绑定了特定的 CPU 掩码(mask),调度器必须遵守这个约束。
5.2 任务窃取(Load Balance)
当某颗CPU的本地运行队列空了(所有任务都在其他核上运行),调度器不会闲着,而是从最繁忙的CPU那里"偷"一个任务来:
Task* SMP_Scheduler::load_balance() {
RunQueue* rq = scheduler_runqueue.operator->(); // 当前CPU队列
spin_lock(&rq->lock);
if (!list_empty(&rq->runnable_list)) {
auto* stolen = list_entry(rq->runnable_list.next, Task, sched_list);
list_del_init(&stolen->sched_list);
rq->nr_running--;
spin_unlock(&rq->lock);
log_debug("stealing task %d from CPU %d\n",
stolen->task_id, arch::apic_get_id());
return stolen;
}
spin_unlock(&rq->lock);
return get_idle_task(); // 真的没活干,返回idle任务
}
wandos 的实现较为简单:当本地队列为空时,直接从当前CPU队列的头部(runnable_list.next)取一个任务。Linux 的 load_balance 远比这复杂:它会遍历所有CPU找出最繁忙的队列(find_busiest_cpu()),并一次最多搬移 sysctl_sched_nr_migrate 个任务。
5.3 CPU亲和性
调度器支持将进程绑定到特定CPU子集:
void SMP_Scheduler::set_affinity(Task* p, uint32_t cpu_mask) {
if (!p) return;
uint32_t valid_mask = (1 << MAX_CPUS) - 1;
cpu_mask &= valid_mask;
if (cpu_mask == 0) cpu_mask = valid_mask; // 空掩码 = 所有CPU
p->affinity = cpu_mask;
}
Linux 调度器严格遵守 cpus_allowed 掩码。如果一个进程被绑定到特定核心,调度器只会在这些核心上选择它。
六、多核与Linux内核:更大的图景
wandos 展示了一个SMP系统的核心要素,但Linux内核的SMP实现要复杂得多。几个关键扩展:
6.1 调度类与CFS
Linux 从 O(1) 调度器(2.6内核)演进到 CFS(Completely Fair Scheduler),用红黑树维护"虚拟运行时间",保证每个进程获得公平的CPU份额。CFS 是 SMP友好的——每个CPU有独立的 CFS 树,锁只保护树操作本身而非整个调度器。
6.2 内存屏障与MESI
在多核系统里,CPU 缓存一致性(CACHE COHERENCY)是核心挑战。x86 使用 MESI 协议(Modified/Exclusive/Shared/Invalid)维护缓存一致性。wandos 的原子操作只涉及单变量;如果涉及跨CPU的数据结构(如全局进程链表),必须配合 smp_call_function_all() 或真正的内存屏障指令(mfence/lfence/sfence)。
6.3 IPI 实际应用
APIC 不仅用于启动 CPU,还用于:
- 调度器时钟中断(Reschedule IPI):强制目标CPU重新调度
- TLB shootdown:当一个CPU修改了页表项,其他CPU的TLB中的旧条目需要失效
- 停止/重启 CPU:用于热插拔或在特定场景下暂停某核
wandos 的 apic_send_sipi() 发送 IPI 时,用 ICR(Interrupt Command Register)的 delivery_status 位轮询确认发送完成:
while (apic_read(LAPIC_ICR0) & APIC_ICR_PENDING_MASK) {
asm volatile("pause");
}
总结:多核系统的本质矛盾
SMP 系统看似简单——"多颗CPU共享内存"——但实现处处是陷阱:
| 层次 |
挑战 |
wandos 处理 |
| CPU启动 |
AP没有初始化,如何唤醒 |
INIT-SIPI-SIPI序列 |
| 中断路由 |
外设中断如何精准投递 |
IOAPIC重定向表+LAPIC ID |
| 数据竞争 |
多核同时修改同一结构 |
Per-CPU数据(消除竞争) + 自旋锁(保护共享数据) |
| 缓存一致 |
修改对其他核可见 |
x86硬件保证(TSO)+ 内存屏障 |
| 负载均衡 |
空载核如何获得任务 |
任务窃取(load_balance) |
Linux 内核用了20年把这些机制打磨到极致,而 wandos 用几百行代码完整实现了 SMP 的核心框架麻雀虽小,五脏俱全。下一篇文章我们将探讨 进程间通信(IPC)——管道、消息队列、共享内存——看看 Linux 和 wandos 如何在多核环境里安全地传递数据。
作者:Dean
相关项目:wandos
系列目录:从零写OS内核 — 目录
系列说明:本文为"从零写OS内核"系列第255篇,结合 Linux 原理与 wandos 源码(zhangfuwen/wandos)深入解析操作系统核心概念。wandos 当前覆盖:内存管理(buddy/slab/paging)、进程调度(scheduler/elf)、文件系统(VFS/ext2/memfs)、系统调用、中断与SMP。