第四章 内存管理与MMU:虚拟地址与物理地址映射

内存管理,说白了就是让芯片知道「数据该放哪儿、怎么找回来」。

我刚开始接触嵌入式AI芯片驱动时,总觉得内存这块儿太底层了,不就是malloc和free嘛?直到第一次调试DMA传输,数据莫名其妙地错乱,我才意识到——嗯,这里的水很深。

4.1 虚拟地址与物理地址:为什么要有两套地址?

先问个问题:为什么CPU不直接用物理地址?

你想想看,如果多个进程同时跑,每个进程都直接操作物理内存,那不乱套了?A进程写了个数据,B进程不小心就给覆盖了。虚拟地址的出现,就是为了给每个进程一个「独立的内存空间幻觉」。

在我的项目中,AI芯片通常有512MB到2GB的DDR。每个神经网络模型加载时,驱动会为它分配一段连续的虚拟地址空间。模型本身并不知道物理内存长什么样,它只认虚拟地址。

核心概念:

  • 虚拟地址(VA):CPU看到的地址,每个进程独立
  • 物理地址(PA):硬件总线上的真实地址
  • MMU:负责把VA翻译成PA的硬件模块

MMU是怎么工作的?它维护了一张页表。每次CPU访问内存,MMU就查这张表,把虚拟地址映射到物理地址。页表的最小单位叫「页」,通常是4KB大小。

我曾经调试过一个bug:模型推理时偶尔崩溃,查了两天才发现是页表项被意外修改了。从那以后,我养成了一个习惯——每次映射完内存,都做一次完整性校验。

4.2 ioremap:把外设寄存器映射到内核空间

驱动开发中,我们经常要操作硬件寄存器。但这些寄存器在物理地址上,内核不能直接访问。怎么办?用ioremap

ioremap的作用,就是把一段物理地址映射到内核的虚拟地址空间。这样驱动就能像操作普通内存一样,读写寄存器了。

#include <linux/io.h>

void __iomem *reg_base;
phys_addr_t reg_phys = 0x1A000000;  // 假设寄存器基地址

reg_base = ioremap(reg_phys, 0x1000);
if (!reg_base) {
    pr_err("ioremap failed\n");
    return -ENOMEM;
}

// 读写寄存器
writel(0x01, reg_base + 0x00);  // 写控制寄存器
val = readl(reg_base + 0x04);   // 读状态寄存器

// 使用完后取消映射
iounmap(reg_base);

个人经验:我习惯在ioremap后立即打印映射结果,方便调试时确认地址是否正确。另外,记得检查返回的指针是不是NULL——别问我怎么知道的。

这里有个坑:ioremap映射的内存,不能直接用memcpymemset操作。必须用readl/writel这类访问函数。为什么?因为寄存器可能有副作用,读一次和读两次结果不一样。

4.3 mmap:把内核内存映射到用户空间

用户空间的应用程序,怎么访问内核分配的内存?答案是mmap系统调用。

在AI芯片驱动中,我们经常用mmap把模型权重数据映射到用户空间。这样用户程序可以直接读写,省去了数据拷贝的开销。

// 驱动中的mmap实现
static int my_mmap(struct file *filp, struct vm_area_struct *vma)
{
    unsigned long size = vma->vm_end - vma->vm_start;
    phys_addr_t phys = get_phys_addr_from_buffer();  // 获取物理地址

    // 设置内存属性:不缓存(防止Cache一致性问题)
    vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);

    if (remap_pfn_range(vma, vma->vm_start, phys >> PAGE_SHIFT,
                        size, vma->vm_page_prot)) {
        return -EAGAIN;
    }
    return 0;
}

注意:mmap映射时,一定要考虑Cache一致性问题。如果驱动和用户空间同时访问同一块内存,而Cache策略不一致,数据就会错乱。我踩过这个坑,后面会详细讲。

用户空间的调用方式:

int fd = open("/dev/my_ai_device", O_RDWR);
void *buf = mmap(NULL, 1024*1024, PROT_READ | PROT_WRITE,
                 MAP_SHARED, fd, 0);
// 现在可以直接操作buf了
memcpy(buf, model_data, model_size);
munmap(buf, 1024*1024);
close(fd);

4.4 DMA缓冲区分配:别让数据在路上丢了

DMA(直接内存访问)是AI芯片的命脉。模型推理时,数据从DDR搬到NPU(神经网络处理器),全靠DMA。但DMA缓冲区分配,有讲究。

普通的内存分配,物理地址可能不连续。DMA需要连续的物理内存,因为硬件不认虚拟地址。所以要用专门的API:

#include <linux/dma-mapping.h>

dma_addr_t dma_handle;
void *cpu_addr;

// 分配DMA缓冲区
cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);
if (!cpu_addr) {
    pr_err("DMA buffer allocation failed\n");
    return -ENOMEM;
}

// cpu_addr是CPU看到的虚拟地址
// dma_handle是DMA控制器看到的物理地址(总线地址)
// 把dma_handle告诉硬件,它就知道去哪儿搬数据了

// 使用完后释放
dma_free_coherent(dev, size, cpu_addr, dma_handle);

关键点:

  • dma_alloc_coherent分配的内存,CPU和DMA访问时自动保持Cache一致性
  • 如果分配大块内存(比如几MB),建议用dma_alloc_coherent而不是kmalloc
  • 有些芯片支持SMMU(系统MMU),可以不用连续物理内存,但大部分嵌入式AI芯片还是要求连续

我记得有一次,分配了4MB的DMA缓冲区,结果系统启动时内存碎片化严重,分配失败。后来改用CMA(连续内存分配器)才解决。嗯,大块连续内存分配,永远是个头疼的问题。

4.5 Cache一致性处理:最容易被忽视的坑

Cache一致性,说白了就是「CPU缓存里的数据和内存里的数据不一样了」。这个问题在AI芯片驱动中特别常见。

为什么?因为CPU和DMA(或NPU)访问内存的路径不同。CPU通过Cache,DMA直接访问内存。如果CPU写了一个数据到Cache,还没刷回内存,DMA就去读——读到的就是旧数据。

反过来也一样:DMA写入了新数据,CPU从Cache读——读到的还是旧数据。

我曾经踩过的坑:调试一个图像预处理驱动,CPU把图像数据写到缓冲区,然后通知NPU处理。结果NPU处理出来的图像全是花的。查了两天,发现是CPU写完后没刷Cache,NPU读到了脏数据。从那以后,我每次写完数据都手动刷Cache。

解决Cache一致性的几种方法:

方法 适用场景 性能影响
使用dma_alloc_coherent DMA缓冲区分配 低(硬件自动处理)
手动刷Cache(dma_map_single 已有内存需要DMA访问 中等
设置页表为non-cacheable 共享内存区域 高(每次访问都走内存)
使用dma_sync_single_for_cpu/device 流式DMA传输 低(按需同步)

手动刷Cache的代码示例:

// 准备DMA传输前,刷Cache
dma_addr_t dma_handle;
void *buffer = kmalloc(size, GFP_KERNEL);

// 把数据写入buffer
memcpy(buffer, data, size);

// 告诉DMA控制器:数据准备好了
dma_handle = dma_map_single(dev, buffer, size, DMA_TO_DEVICE);
// 这里内部会刷Cache

// DMA传输完成后,告诉CPU:数据可能被DMA修改了
dma_unmap_single(dev, dma_handle, size, DMA_FROM_DEVICE);
// 这里内部会无效化Cache

我的建议:能用dma_alloc_coherent就别手动管理Cache。实在不行,也要用dma_map_single这类标准API。自己写刷Cache的代码,十有八九会漏掉某个边界情况。

最后说一句:Cache一致性不是「有或没有」的问题,而是「什么时候不一致」的问题。你写100次代码,可能99次都没事,但第100次就崩了。所以,从一开始就按规范来,别偷懒。


好了,内存管理和MMU这块儿,今天就聊到这儿。下一章我们讲中断处理——嗯,那又是另一个让人又爱又恨的话题。