4、驱动中的内存管理:mmap实现、物理地址映射、DMA缓冲区申请与释放

内存管理这块,说实在的,是驱动开发里最容易出bug的地方之一。我早年做视频采集卡驱动时,就因为DMA缓冲区没对齐,导致数据错位,排查了整整三天。嗯,今天咱们就把这块彻底讲透。

4.1 为什么驱动要操心内存?

用户态程序申请内存,基本就是malloc一把梭。但驱动不行。原因很简单:

  • 物理地址连续性:DMA操作需要连续的物理内存,而用户态看到的是虚拟地址
  • 内存访问权限:硬件设备需要直接访问内存,这需要建立物理地址到内核空间的映射
  • 性能要求:数据拷贝开销大,最好让用户态直接操作设备内存

说白了,驱动就是用户态和硬件之间的「翻译官」。内存管理就是翻译官手里的字典,翻错了就出大事。

4.2 mmap实现:让用户态直接操作设备内存

mmap是字符设备驱动里最常用的内存共享机制。用户态调用mmap,驱动里对应的是mmap文件操作接口。

4.2.1 驱动侧mmap实现框架

#include <sys/mman.h>
#include <stdint.h>

// 设备结构体
typedef struct {
    void *hw_regs;          // 硬件寄存器基地址
    size_t regs_size;       // 寄存器区域大小
    void *dma_buf;          // DMA缓冲区
    size_t dma_size;        // DMA缓冲区大小
} my_device_t;

// mmap实现
int my_dev_mmap(resmgr_context_t *ctp, io_mmap_t *msg,
                RESMGR_OCB_T *ocb) {
    my_device_t *dev = ocb->attr;
    uint64_t offset = msg->offset;
    uint64_t len = msg->len;
    
    // 判断映射区域
    if (offset >= dev->regs_size && 
        offset < dev->regs_size + dev->dma_size) {
        // 映射DMA缓冲区
        uint64_t dma_offset = offset - dev->regs_size;
        void *phys_addr = get_dma_phys(dev->dma_buf) + dma_offset;
        
        return mmap_device_memory(
            NULL, len,
            PROT_READ | PROT_WRITE,
            MAP_SHARED | MAP_PHYS,
            (uint64_t)phys_addr, 0);
    }
    
    // 映射硬件寄存器
    return mmap_device_io(len, offset);
}

我个人习惯把寄存器区域和DMA缓冲区放在连续的偏移空间里。这样用户态只需要一次mmap调用,就能访问所有资源。你想想看,少一次系统调用,性能就多一分。

小提示:mmap_device_memory和mmap_device_io的区别在于,前者映射的是普通内存,后者映射的是IO空间。硬件寄存器通常用mmap_device_io,DMA缓冲区用mmap_device_memory。

4.2.2 用户态调用示例

#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

int main() {
    int fd = open("/dev/mydev", O_RDWR);
    
    // 映射寄存器区域(偏移0,大小4KB)
    void *regs = mmap(NULL, 4096, 
                      PROT_READ | PROT_WRITE,
                      MAP_SHARED, fd, 0);
    
    // 映射DMA缓冲区(偏移4KB,大小1MB)
    void *dma = mmap(NULL, 1024*1024,
                     PROT_READ | PROT_WRITE,
                     MAP_SHARED, fd, 4096);
    
    // 直接操作硬件寄存器
    *(volatile uint32_t *)(regs + 0x00) = 0x1;  // 启动DMA
    
    // 直接读写DMA缓冲区
    memcpy(dma, data, size);
    
    munmap(regs, 4096);
    munmap(dma, 1024*1024);
    close(fd);
    return 0;
}

4.3 物理地址映射:从虚拟到物理的桥梁

QNX是微内核系统,驱动跑在用户态。这意味着驱动看到的地址都是虚拟地址。但硬件设备只认物理地址。怎么办?

嗯,这里就要用到物理地址映射了。

4.3.1 常用API

函数 用途 注意事项
mmap_device_memory 将物理内存映射到进程空间 需要MAP_PHYS标志
mmap_device_io 映射IO端口或寄存器 通常用于PCIe BAR空间
as_phys2virt 物理地址转虚拟地址 仅限内核态使用
as_virt2phys 虚拟地址转物理地址 需要进程权限

4.3.2 实战:PCIe设备BAR空间映射

// 假设我们已经通过PCI枚举拿到了BAR0的物理地址
uint64_t bar0_phys = 0xFE000000;
size_t bar0_size = 0x1000;  // 4KB

// 映射到进程空间
void *bar0_virt = mmap_device_memory(
    NULL,                   // 系统选择地址
    bar0_size,              // 映射大小
    PROT_READ | PROT_WRITE, // 读写权限
    MAP_SHARED | MAP_PHYS,  // 物理地址映射
    bar0_phys,              // 物理地址
    0);                     // 偏移

if (bar0_virt == MAP_FAILED) {
    perror("mmap_device_memory failed");
    return -1;
}

// 现在可以直接操作硬件寄存器了
uint32_t id = *(volatile uint32_t *)(bar0_virt + 0x00);
printf("Device ID: 0x%x\n", id);

注意:我在项目中遇到过一个问题——mmap_device_memory返回的地址是虚拟地址,但如果你在驱动里把这个地址传给DMA引擎,那就完蛋了。DMA需要的是物理地址!所以一定要用as_virt2phys或者从设备树/PCI配置空间里获取物理地址。

4.4 DMA缓冲区申请与释放

DMA缓冲区是驱动开发的核心难点。为什么?因为DMA要求:

  • 物理连续:DMA引擎通常不支持分散-聚集
  • 对齐要求:通常需要页对齐(4KB),有些设备要求更高
  • 缓存一致性:CPU和DMA同时访问时,数据可能不一致

4.4.1 申请DMA缓冲区

#include <sys/dma.h>

dma_addr_t dma_handle;
void *cpu_addr;
size_t buf_size = 1024 * 1024;  // 1MB

// 申请DMA缓冲区
int ret = dma_alloc(&dma_handle, buf_size, 
                    DMA_PHYS_CONTIGUOUS | DMA_ALIGN_4K);
if (ret != EOK) {
    perror("dma_alloc failed");
    return -1;
}

// 获取CPU可访问的虚拟地址
cpu_addr = dma_addr_cpu(&dma_handle);
if (cpu_addr == NULL) {
    dma_free(&dma_handle);
    return -1;
}

// 获取DMA引擎需要的物理地址
uint64_t phys_addr = dma_addr_phys(&dma_handle);
printf("DMA buffer: CPU=%p, PHYS=0x%llx\n", 
       cpu_addr, phys_addr);

4.4.2 缓存同步

DMA操作完成后,CPU看到的数据可能还是旧的。这是因为CPU有缓存。需要手动同步:

// DMA写入数据到缓冲区后,CPU读取前
dma_cache_sync(&dma_handle, 
               DMA_CACHE_INVALIDATE);

// CPU写入数据到缓冲区后,DMA读取前
dma_cache_sync(&dma_handle, 
               DMA_CACHE_FLUSH);

// 或者两者都做
dma_cache_sync(&dma_handle, 
               DMA_CACHE_FLUSH_INVALIDATE);

核心要点

  • DMA写完后,CPU读之前:INVALIDATE(让CPU缓存失效,从内存重新读)
  • CPU写完后,DMA读之前:FLUSH(把CPU缓存刷到内存)
  • 不确定时:FLUSH_INVALIDATE(先刷再失效,最安全但性能稍差)

4.4.3 释放DMA缓冲区

// 使用完毕后释放
dma_free(&dma_handle);
dma_handle = NULL;
cpu_addr = NULL;

我曾经犯过一个低级错误——忘记在驱动卸载时释放DMA缓冲区。结果系统运行几天后,内存碎片化严重,新的DMA申请总是失败。嗯,从那以后我养成了习惯:申请时就在注释里写好释放的位置。

4.5 避坑指南

做内存管理,坑是真的多。我列几个常见的:

  1. 忘记页对齐:mmap要求偏移和大小都是页对齐的(4KB)。不对齐的话,mmap会返回EINVAL。
  2. DMA缓冲区太小:有些设备要求最小缓冲区大小,比如网卡通常需要2KB以上的缓冲区。
  3. 缓存同步遗漏:这是最常见的bug。数据明明写进去了,读出来却是旧的。八成是缓存没同步。
  4. 物理地址泄露:用户态通过mmap拿到了物理地址,如果驱动不做好权限控制,恶意程序可能直接读写物理内存。

我的习惯:在驱动初始化时,一次性申请好所有DMA缓冲区。运行时只做分配和释放的记账,不做实际的内存操作。这样能避免运行时内存碎片化的问题。

4.6 总结

内存管理是驱动开发的基石。mmap让用户态直接操作设备内存,物理地址映射连接了虚拟和物理世界,DMA缓冲区则是高性能数据传输的保障。

记住三个原则:

  • 物理地址给硬件,虚拟地址给软件
  • DMA操作前后一定要做缓存同步
  • 申请的资源一定要释放,否则内存泄漏等着你

下一章我们会讲中断处理。嗯,那又是另一个精彩的话题了。