第4章 SMP内存管理:多核缓存一致性、MMU配置与页表共享、DMA与缓存同步
好,咱们进入SMP移植中最容易出坑的部分——内存管理。说实话,我见过太多团队在单核上跑得飞起,一上多核就各种随机死机、数据错乱。十有八九,问题都出在缓存一致性上。
这一章,我会把多核缓存、MMU配置、DMA同步这几个硬骨头掰开揉碎讲清楚。嗯,都是我用血泪换来的经验。
4.1 多核缓存一致性:为什么你的数据会“消失”?
先问个问题:两个CPU核同时访问同一个内存地址,会发生什么?
你可能会说“加锁啊”。但问题没这么简单。每个核都有自己的L1缓存,Core0改了变量A,数据还在自己的缓存里没写回内存。Core1去读变量A,读到的却是内存里的旧值。这就是经典的缓存一致性问题。
我个人习惯把缓存一致性分为三个层次:
- 写通(Write-Through):每次写都直接穿透到内存。简单,但慢得要命。
- 写回(Write-Back):先写缓存,等缓存行被替换时才写回内存。性能好,但一致性难搞。
- 写合并(Write-Combine):多个写操作合并后再写。常用于显存等场景。
在NuttX的SMP实现中,默认使用写回策略。为什么?因为性能。但代价就是你必须手动处理一致性。
核心原则:多核共享的数据,要么用缓存一致性协议(如MESI)自动维护,要么你手动刷缓存。
我在项目中遇到过最典型的一个bug:两个核通过共享内存做消息队列,Core0写完消息后没刷缓存,Core1读到的全是乱码。查了三天,最后发现是漏了 up_flush_dcache() 调用。
4.2 MMU配置与页表共享:别让每个核都建一套
单核时代,MMU配置很简单——一个页表搞定所有。但到了SMP,问题来了:每个核要不要有自己的页表?
我的建议是:共享页表。原因很简单——节省内存,而且保证所有核看到的内存视图一致。
NuttX的SMP启动流程中,主核(CPU0)负责初始化页表,从核(CPU1、CPU2...)直接复用。具体怎么做?看代码:
// 主核初始化页表
void arm64_mmu_init(void)
{
// 创建内核空间映射
// 创建外设空间映射
// 设置TTBR0_EL1(用户空间页表基址)
// 设置TTBR1_EL1(内核空间页表基址)
}
// 从核启动时,直接读取主核设置好的页表基址
void arm64_mmu_secondary_init(void)
{
// 读取主核设置的TTBR1_EL1
// 设置自己的TTBR0_EL1(用户空间页表,每个核独立)
// 设置自己的TTBR1_EL1(内核空间页表,所有核共享)
}
这里有个关键点:内核空间页表共享,用户空间页表独立。为什么?因为内核是所有核共同管理的,而每个核可能运行不同的用户进程。
小技巧:页表共享后,修改页表项时必须刷TLB。而且所有核都要刷!我习惯用 up_flush_tlb_all() 而不是 up_flush_tlb_local(),虽然慢一点,但不会漏。
4.3 DMA与缓存同步:硬件在背后偷偷改数据
DMA是另一个大坑。DMA控制器直接读写内存,不经过CPU缓存。这就导致一个问题:
- CPU写数据到内存(其实在缓存里),然后启动DMA传输。DMA读到的可能是旧数据。
- DMA写数据到内存,然后CPU去读。CPU读到的可能是缓存里的旧数据。
说白了,就是CPU和DMA看到的不是同一份数据。
解决办法?嗯,就两个:
- 使用一致性内存(Non-cacheable):把DMA缓冲区所在的页标记为不可缓存。简单粗暴,但性能差。
- 手动同步缓存:在DMA操作前后,手动刷缓存或无效化缓存。
我个人更推荐第二种。毕竟性能是嵌入式系统的命根子。
在NuttX中,缓存同步的API长这样:
// DMA传输前:把CPU缓存中的数据刷到内存
void dma_prepare_buffer(void *buf, size_t size, enum dma_direction dir)
{
if (dir == DMA_FROM_DEVICE) {
// 从设备到内存:无效化缓存,让CPU重新从内存读
up_invalidate_dcache((uintptr_t)buf, (uintptr_t)buf + size);
} else {
// 从内存到设备:刷缓存,确保内存中的数据是最新的
up_flush_dcache((uintptr_t)buf, (uintptr_t)buf + size);
}
}
// DMA传输后:同样需要同步
void dma_finish_buffer(void *buf, size_t size, enum dma_direction dir)
{
if (dir == DMA_FROM_DEVICE) {
// 设备写完了,CPU要读,先无效化缓存
up_invalidate_dcache((uintptr_t)buf, (uintptr_t)buf + size);
} else {
// CPU写完了,设备要读,先刷缓存
up_flush_dcache((uintptr_t)buf, (uintptr_t)buf + size);
}
}
警告:千万不要在中断上下文里刷大块缓存!我曾经在一个网卡驱动里,在中断处理函数中刷了64KB的DMA缓冲区,结果导致系统响应延迟飙升。正确的做法是:在中断里只标记缓冲区需要同步,然后在任务上下文中处理。
4.4 实战:一个典型的多核DMA驱动调试案例
讲个真实案例。去年我在移植一个多核音频驱动时,遇到了一个诡异的问题:播放音频时,偶尔会出现“咔咔”的杂音。
一开始我以为是DMA传输错误,检查了DMA描述符,没问题。又检查了音频缓冲区,数据也是对的。最后发现,问题出在缓存同步上。
音频驱动的工作流程是这样的:
- Core0负责填充音频数据到缓冲区
- DMA控制器从缓冲区读取数据发送到I2S接口
- Core1负责处理音频控制命令
问题出在哪?Core0填充数据时,数据还在L1缓存里。DMA去读内存,读到的是旧数据。于是播放出来的就是“咔咔”声。
解决方案很简单:在Core0填充完数据后,调用 up_flush_dcache() 刷缓存。但要注意,刷缓存的范围要精确,不能多刷也不能少刷。
// 音频缓冲区地址和大小
#define AUDIO_BUF_ADDR 0x48000000
#define AUDIO_BUF_SIZE 4096
// Core0填充数据
void audio_fill_buffer(int16_t *data, int len)
{
// 填充数据到AUDIO_BUF_ADDR
memcpy((void *)AUDIO_BUF_ADDR, data, len);
// 刷缓存,确保DMA能看到最新数据
up_flush_dcache(AUDIO_BUF_ADDR, AUDIO_BUF_ADDR + len);
// 启动DMA传输
dma_start(AUDIO_BUF_ADDR, len);
}
改完之后,杂音消失了。嗯,有时候问题就是这么简单,但你不往缓存一致性上想,可能查一周都找不到原因。
4.5 性能调优:缓存同步的代价
缓存同步不是免费的。每次刷缓存或无效化缓存,都会导致CPU停顿几十到几百个时钟周期。如果频繁刷大块缓存,性能损失会非常明显。
我总结了几条优化原则:
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 小数据量(< 64字节) | 使用一致性内存 | 刷缓存的开销比直接读内存还大 |
| 大数据量(> 4KB) | 手动同步缓存 | 一致性内存的访问延迟太高 |
| 高频DMA操作 | 使用DMA环形缓冲区 | 减少缓存同步次数 |
| 多核共享数据 | 使用原子操作或自旋锁 | 避免频繁刷缓存 |
我的习惯:在项目初期,先用一致性内存把功能调通。等系统稳定了,再逐个模块优化成手动同步。这样既能快速验证功能,又能保证最终性能。
好了,这一章的内容就到这里。缓存一致性、MMU配置、DMA同步,这三个点你吃透了,SMP内存管理这块基本就稳了。下一章我们会讲中断和调度,那又是另一番天地了。