第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看到的不是同一份数据。

解决办法?嗯,就两个:

  1. 使用一致性内存(Non-cacheable):把DMA缓冲区所在的页标记为不可缓存。简单粗暴,但性能差。
  2. 手动同步缓存:在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描述符,没问题。又检查了音频缓冲区,数据也是对的。最后发现,问题出在缓存同步上。

音频驱动的工作流程是这样的:

  1. Core0负责填充音频数据到缓冲区
  2. DMA控制器从缓冲区读取数据发送到I2S接口
  3. 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内存管理这块基本就稳了。下一章我们会讲中断和调度,那又是另一番天地了。