1. 项目背景与核心挑战
树莓派作为一款广受欢迎的单板计算机,其ARM架构的多核处理器潜力常常被Linux系统掩盖。裸机编程(Bare Metal)让我们能够直接操控硬件,充分发挥四核Cortex-A72的性能优势。这个项目最吸引人的地方在于:当所有教程都在教你如何使用操作系统时,我们反其道而行之,从零开始构建一个多核协作的裸机环境。
与传统的单核裸机开发不同,多核启动涉及处理器架构层面的复杂交互。ARM的SMP(对称多处理)机制要求我们精确控制核心间的启动顺序、共享内存同步以及中断分配。我曾在一个工业控制项目中,就因为忽略了缓存一致性问题,导致三个核心同时修改同一内存区域,最终引发难以复现的随机故障。
2. 硬件基础与启动流程
2.1 树莓派4B的处理器架构
BCM2711 SoC搭载的四核Cortex-A72处理器,每个核心都有独立的L1缓存(32KB指令+32KB数据),共享1MB L2缓存。上电时,只有Core 0会执行初始代码,其他核心被置于WFI(等待中断)状态。这种设计带来一个关键问题:如何唤醒其他核心并确保它们执行正确的代码?
通过查阅ARM的处理器技术参考手册,我们发现每个核心的唤醒需要以下步骤:
- 在共享内存区域设置目标PC指针(通常位于0x8000)
- 使用SEV(发送事件)指令触发事件信号
- 被唤醒核心从复位向量跳转到指定地址
2.2 多核启动的底层实现
以下是核心唤醒的典型汇编代码示例,需要放在所有核心共享的内存区域:
assembly复制.global _start
_start:
mrc p15, 0, r0, c0, c0, 5 // 读取CPU ID
and r0, r0, #3 // 获取核心编号
cmp r0, #0 // 判断是否为主核心
bne secondary_core_spin // 非0核心进入自旋等待
primary_core_init:
// 主核心初始化代码
bl main
b .
secondary_core_spin:
wfi // 等待中断
ldr r1, =core_entry // 加载入口地址
ldr r1, [r1]
cmp r1, #0 // 检查是否设置入口
beq secondary_core_spin // 未设置则继续等待
mov pc, r1 // 跳转到指定代码
关键细节:必须使用DMB(数据内存屏障)指令确保内存写入对其他核心可见,否则可能出现核心永远无法唤醒的情况。
3. 多核间的通信与同步
3.1 共享内存的实践方案
我们选择0x40000000开始的16KB区域作为共享内存空间,这个地址在GPU内存映射之外,且不会被缓存一致性问题影响。为了管理多核访问,需要实现原子操作:
c复制#define LOCK_ADDR (volatile uint32_t*)0x40000000
void spin_lock(volatile uint32_t *lock) {
while (__atomic_exchange_n(lock, 1, __ATOMIC_ACQ_REL)) {
asm volatile("wfi"); // 等待期间进入低功耗状态
}
}
void spin_unlock(volatile uint32_t *lock) {
__atomic_store_n(lock, 0, __ATOMIC_RELEASE);
}
实测发现,如果不使用__ATOMIC_ACQ_REL内存序,在核心间传递数据时会有约5%的概率出现数据错乱。这是ARM弱内存模型带来的典型挑战。
3.2 任务分配策略
在工业控制应用中,我们采用这样的任务划分:
- Core 0:主控制循环,处理高优先级中断
- Core 1:实时数据采集(ADC/DAC)
- Core 2:通信协议栈(UART/SPI)
- Core 3:后台计算(FFT/滤波)
这种分配避免了核心间的频繁锁竞争。一个实测数据:当四个核心同时访问同一个锁时,系统吞吐量下降至单核的30%;而合理分区后能达到单核的3.2倍。
4. 中断处理的特殊考量
4.1 多核中断路由
树莓派的GIC-400中断控制器支持SPI(共享外设中断)和PPI(私有外设中断)的核间分配。我们需要特别注意:
- 定时器中断默认是PPI,需要重配置为SPI才能在核心间共享
- 外设中断的亲和性设置(GICD_ITARGETSR寄存器)
- 每个核心的本地定时器(CNTP)需要单独初始化
c复制void init_interrupts(uint32_t core_id) {
if (core_id == 0) {
// 仅核心0配置全局中断
mmio_write(GICD_CTLR, 0x1); // 使能分配器
mmio_write(GICC_CTLR, 0x1); // 使能CPU接口
}
// 所有核心配置本地定时器
asm volatile("msr cntp_ctl_el0, %0" :: "r"(1)); // 使能定时器
asm volatile("msr cntp_tval_el0, %0" :: "r"(0x100000)); // 设置初始值
}
4.2 中断负载均衡
我们在电机控制项目中发现,当所有中断都路由到Core 0时,在高负载下会出现响应延迟。解决方案是:
- 将UART中断分配给Core 1
- SPI DMA中断由Core 2处理
- 仅保留最高优先级的PWM故障中断给Core 0
调整后,最坏情况下的中断响应时间从1.2ms降低到0.3ms。
5. 调试技巧与性能优化
5.1 多核调试实践
没有操作系统的支持,传统的gdb调试变得困难。我们开发了以下调试方案:
-
通过UART输出带核心ID的调试信息
c复制void debug_print(const char *msg) { uint32_t core_id; asm volatile("mrc p15, 0, %0, c0, c0, 5" : "=r"(core_id)); core_id &= 3; uart_printf("[Core%d] %s", core_id, msg); } -
在共享内存中保留4KB作为调试缓冲区
-
使用GPIO引脚输出波形信号,用逻辑分析仪捕获核心活动
5.2 缓存一致性陷阱
ARM的缓存一致性协议(CCI)有时会带来意外行为。在一次图像处理项目中,我们遇到这样的问题:
- Core 0生成图像数据
- Core 1读取数据进行压缩
- 结果出现随机性数据损坏
根本原因是DMA直接访问内存绕过了缓存。解决方案:
c复制void flush_cache(void *addr, size_t size) {
uintptr_t start = (uintptr_t)addr & ~0x3F;
uintptr_t end = (uintptr_t)addr + size;
for (uintptr_t p = start; p < end; p += 64) {
asm volatile("dc civac, %0" :: "r"(p)); // 数据缓存行写回
}
asm volatile("dsb sy");
}
6. 实战案例:并行FFT计算
以一个实际的256点FFT计算为例,展示多核协作的优势:
6.1 任务分解策略
- Core 0:准备输入数据(从ADC读取)
- Core 1:计算前64点
- Core 2:计算中间128点
- Core 3:计算最后64点
- Core 0:合并结果并输出
6.2 性能对比数据
| 核心数 | 计算时间(ms) | 加速比 |
|---|---|---|
| 1 | 4.82 | 1.0x |
| 2 | 2.71 | 1.78x |
| 4 | 1.65 | 2.92x |
虽然理论加速比应该是4倍,但由于内存带宽限制和同步开销,实际只能达到3倍左右。这也印证了Amdahl定律——并行化带来的收益受限于串行部分的比例。
7. 电源管理与能效优化
在电池供电的应用中,我们实现了这样的电源策略:
- 动态核心休眠:
c复制void core_sleep(uint32_t core_id) {
if (core_id != 0) {
// 设置唤醒地址为当前PC
mmio_write(CORE_MBOX_SET(core_id), (uint32_t)&wake_up_handler);
asm volatile("wfi");
}
}
- 根据负载动态调整核心频率(通过修改CRMN_GLB_CNTL寄存器)
实测在传感器数据采集场景下,四核动态调度相比全核常开可节省约42%的能耗。