4. QNX内存管理:虚拟内存、物理内存、mmap、共享内存、内存池优化

内存管理,说实在的,是块设备驱动里最容易出鬼的地方。我见过太多驱动跑着跑着就崩了,最后定位下来都是内存访问越界或者分配策略不对。QNX的微内核架构下,内存管理有自己的脾气,摸透了它,你的驱动才能稳如老狗。

4.1 虚拟内存与物理内存:你得搞清楚的底层逻辑

QNX用的是虚拟内存管理,每个进程都有自己的4GB虚拟地址空间。但你要记住,虚拟地址只是个“门牌号”,真正的数据住在物理内存里。MMU负责做这个地址翻译的工作。

我刚开始做驱动时,犯过一个低级错误——直接拿用户态传下来的虚拟地址给DMA用。结果DMA写了个寂寞,数据全飞到不知道哪里去了。为什么?因为DMA控制器不认识虚拟地址,它只认物理地址。

⚠️ 避坑指南: 在QNX中,用户态程序传给你的指针是虚拟地址。如果你要做DMA操作,必须先用 mmap_device_memory() 或者 as_physical_map() 拿到物理地址。

QNX的虚拟内存页大小默认是4KB,但你可以配置为64KB甚至2MB的大页。大页的好处是TLB miss减少,适合大量连续内存访问的场景。我在做NVMe驱动时,就特意把队列内存映射到大页上,延迟降了差不多15%。

4.2 mmap:把设备内存映射到用户空间

块设备驱动里,mmap是个高频操作。它的本质就是把设备的内存(比如DMA缓冲区、寄存器空间)映射到进程的虚拟地址空间里,这样用户态程序就能直接访问了,省去了系统调用的开销。

QNX的mmap实现和Linux不太一样。在QNX里,你要用 mmap_device_memory() 来映射物理地址,而不是标准的 mmap()。这个函数会帮你处理好缓存一致性、内存属性这些麻烦事。

// 映射设备内存到用户空间
void *map_addr = mmap_device_memory(
    NULL,                    // 系统选择虚拟地址
    size,                    // 映射大小
    PROT_READ | PROT_WRITE,  // 读写权限
    MAP_SHARED | MAP_PHYS,   // 共享映射,物理地址
    phys_addr,               // 设备物理地址
    NULL                     // 偏移量
);

if (map_addr == MAP_FAILED) {
    // 处理错误
}

这里有个细节要注意:MAP_PHYS 标志告诉系统,你给的是物理地址而不是文件描述符。我见过有人忘了加这个标志,结果mmap返回了个莫名其妙的地址,查了半天才发现是标志位没设对。

💡 经验之谈: 映射设备内存时,建议用 MAP_SHARED 而不是 MAP_PRIVATE。因为设备内存是多个进程共享的,用PRIVATE模式会导致写时复制,性能直接拉胯。

4.3 共享内存:进程间通信的利器

块设备驱动经常需要和多个进程打交道。比如一个进程发IO请求,另一个进程处理中断,它们之间怎么高效传数据?共享内存就是答案。

QNX提供了两种共享内存方式:shm_open() + mmap()posix_typed_mem_open()。我个人更推荐后者,因为它支持内存类型指定,比如你可以指定用非缓存内存,这对DMA操作特别友好。

// 创建共享内存对象
int shm_fd = shm_open("/my_driver_shm", O_CREAT | O_RDWR, 0666);
ftruncate(shm_fd, SHM_SIZE);

// 映射到进程地址空间
void *shm_ptr = mmap(NULL, SHM_SIZE, 
                     PROT_READ | PROT_WRITE, 
                     MAP_SHARED, 
                     shm_fd, 0);

共享内存的同步是个大问题。你不能让两个进程同时写同一个位置。QNX提供了 SyncMutexSyncCondvar 来做同步。我习惯把互斥锁放在共享内存的开头,这样所有进程都能访问到。

🔑 关键点: 共享内存的命名要规范。我一般用 "/drv_模块名_功能" 的格式,比如 "/drv_nvme_ioqueue"。这样在 ls /dev/shmem 时一目了然。

4.4 内存池优化:告别频繁分配释放

块设备驱动里,内存分配释放是个高频操作。每个IO请求来了,你要分配缓冲区;处理完了,又要释放。频繁的malloc/free会导致内存碎片,性能也会下降。

我的做法是用内存池。提前分配一大块内存,切成固定大小的块,用链表管理起来。需要时从池里取,用完再还回去。这样既避免了碎片,又减少了系统调用开销。

// 简单的内存池实现
typedef struct {
    void *pool_start;      // 池起始地址
    size_t block_size;     // 每个块大小
    int total_blocks;      // 总块数
    int free_blocks;       // 空闲块数
    void *free_list;       // 空闲块链表
    pthread_mutex_t lock;  // 互斥锁
} mem_pool_t;

// 从池中分配
void *pool_alloc(mem_pool_t *pool) {
    pthread_mutex_lock(&pool->lock);
    if (pool->free_blocks == 0) {
        pthread_mutex_unlock(&pool->lock);
        return NULL;  // 池已耗尽
    }
    
    // 从空闲链表头部取一个块
    void *block = pool->free_list;
    pool->free_list = *(void **)block;
    pool->free_blocks--;
    
    pthread_mutex_unlock(&pool->lock);
    return block;
}

内存池的大小怎么定?我一般根据IO队列深度来算。比如队列深度是256,每个IO需要4KB缓冲区,那池大小就是256 * 4KB = 1MB。再留20%的余量,差不多1.2MB就够了。

⚠️ 注意: 内存池的块大小要统一。如果IO请求大小变化很大,可以考虑用多个不同大小的池。我曾经在一个项目里用了3个池:4KB、16KB、64KB,覆盖了95%以上的IO场景。

4.5 缓存一致性:别让数据“打架”

QNX运行在多核处理器上,每个核都有自己的L1/L2缓存。如果CPU修改了内存数据,但DMA设备不知道,它读到的就是缓存里的旧数据。这就是缓存一致性问题。

QNX提供了 CacheFlush()CacheInvalidate() 来处理这个问题。在DMA操作前,你要flush缓存,把数据写回内存;操作后,要invalidate缓存,让CPU重新从内存读取。

// DMA操作前:flush缓存
CacheFlush(CACHE_FLUSH_DATA, buffer, size);

// 启动DMA传输...

// DMA操作后:invalidate缓存
CacheInvalidate(CACHE_FLUSH_DATA, buffer, size);

嗯,这里要注意:如果你用的是非缓存内存(比如通过 mmap_device_memory() 映射时指定了 MAP_UNCACHED),那就不需要手动处理缓存一致性了。但非缓存内存的访问速度会慢一些,适合DMA缓冲区这种CPU不常访问的场景。

4.6 实战建议:内存管理的“黄金法则”

做了这么多年QNX驱动,我总结了几条内存管理的经验:

  • 能静态分配就别动态分配:驱动初始化时就把内存规划好,运行时尽量少用malloc
  • DMA缓冲区用非缓存内存:省去缓存一致性的麻烦,性能反而更好
  • 共享内存要加版本号:防止新旧进程对共享内存结构理解不一致
  • 内存池要监控使用率:如果经常用完,说明池太小了,需要调整
  • 虚拟地址转物理地址用 as_physical_map():别自己算页表,容易出错

我曾经在一个项目里,因为没处理好共享内存的同步,导致两个进程同时写同一个缓冲区,数据全乱了。后来加了互斥锁,问题就解决了。所以啊,内存管理不只是分配释放,同步和一致性同样重要。

💡 小技巧: 调试内存问题时,可以用QNX的 pidin 命令查看进程内存映射。比如 pidin -p 1234 -m 会显示进程1234的所有内存区域,包括共享内存和设备映射。

好了,内存管理这块就讲这么多。下一章我们聊聊中断处理,那也是个容易出幺蛾子的地方。记住我今天说的这些,你的块设备驱动在内存这块基本不会翻车。