4. QNX内存管理:虚拟内存、物理内存、mmap、共享内存、内存池优化
内存管理,说实在的,是块设备驱动里最容易出鬼的地方。我见过太多驱动跑着跑着就崩了,最后定位下来都是内存访问越界或者分配策略不对。QNX的微内核架构下,内存管理有自己的脾气,摸透了它,你的驱动才能稳如老狗。
4.1 虚拟内存与物理内存:你得搞清楚的底层逻辑
QNX用的是虚拟内存管理,每个进程都有自己的4GB虚拟地址空间。但你要记住,虚拟地址只是个“门牌号”,真正的数据住在物理内存里。MMU负责做这个地址翻译的工作。
我刚开始做驱动时,犯过一个低级错误——直接拿用户态传下来的虚拟地址给DMA用。结果DMA写了个寂寞,数据全飞到不知道哪里去了。为什么?因为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提供了 SyncMutex 和 SyncCondvar 来做同步。我习惯把互斥锁放在共享内存的开头,这样所有进程都能访问到。
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就够了。
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():别自己算页表,容易出错
我曾经在一个项目里,因为没处理好共享内存的同步,导致两个进程同时写同一个缓冲区,数据全乱了。后来加了互斥锁,问题就解决了。所以啊,内存管理不只是分配释放,同步和一致性同样重要。
pidin 命令查看进程内存映射。比如 pidin -p 1234 -m 会显示进程1234的所有内存区域,包括共享内存和设备映射。
好了,内存管理这块就讲这么多。下一章我们聊聊中断处理,那也是个容易出幺蛾子的地方。记住我今天说的这些,你的块设备驱动在内存这块基本不会翻车。