3. 物理内存管理:物理内存分配器、页面大小与对齐、内存池技术

好,咱们今天聊聊物理内存管理。这部分内容,说实在的,是QNX系统稳定性的基石。你想想看,所有进程、所有驱动,最终都要落到物理内存上。如果这层没管好,上层再花哨的优化都是白搭。

我个人习惯把物理内存管理拆成三个层面来看:分配器怎么工作页面大小怎么选、以及内存池怎么用。咱们一个一个说。

3.1 物理内存分配器:伙伴系统与 slab

QNX内核里,物理内存分配器主要用的是伙伴系统(Buddy System)。这个算法很经典,核心思想就是把内存按2的幂次方分成块。比如4KB、8KB、16KB……一直到几MB。

分配的时候,找一块大小刚好够用的。如果没有,就拆大块。释放的时候,看看相邻的块是不是也空闲,是的话就合并回去。这样做的好处是外部碎片很少

我在项目中遇到过一个问题:一个网络驱动频繁申请和释放64KB的缓冲区。用伙伴系统分配,每次都要拆128KB的块,用完了再合并。时间一长,系统响应就变慢了。后来我改用slab分配器,专门为这个64KB的缓冲区建了一个缓存池,性能一下就上来了。

核心要点:

  • 伙伴系统:适合大块内存、频繁申请释放的场景。优点是碎片少,缺点是分配速度稍慢。
  • slab分配器:适合固定大小的小对象。比如task_struct、inode等内核对象。速度快,缓存友好。

QNX里,你可以通过procctrl工具查看伙伴系统的状态。比如:

# 查看物理内存分配情况
procctrl -m

# 查看slab缓存信息
procctrl -s

嗯,这里要注意:slab分配器虽然快,但如果你申请的对象大小不是2的幂次方,可能会有内部碎片。比如你申请30字节,slab可能给你分配32字节的块,那2字节就浪费了。

3.2 页面大小与对齐:为什么4KB不够用?

页面大小,说白了就是内存管理的最小单位。QNX默认是4KB,但现代硬件都支持大页,比如2MB甚至1GB。

你可能会问:4KB不是挺好的吗?粒度细,浪费少。没错,但TLB(页表缓存)是有限的。如果你用4KB页面,一个需要2MB内存的进程,就要占用512个TLB条目。换成2MB大页,只需要1个条目。

我在调优一个视频处理系统时,发现内存带宽上不去。查了半天,发现是TLB miss太多。换成2MB大页后,性能提升了15%。

我的建议:

  • 数据库、大数据处理:用2MB或1GB大页。TLB命中率提升明显。
  • 实时控制、小内存设备:用4KB。避免浪费。
  • 混合场景:QNX支持混合页面大小。你可以把关键数据放在大页里,普通数据用4KB。

对齐问题也很关键。QNX要求物理地址按页面大小对齐。比如你用2MB大页,起始地址必须是2MB的整数倍。我曾经犯过一个错:在驱动里直接申请物理内存,没注意对齐,结果DMA传输老是出错。后来加了mmapMAP_ALIGNED标志才搞定。

代码示例:

#include <sys/mman.h>

// 申请2MB对齐的物理内存
void *addr = mmap(NULL, 2 * 1024 * 1024,
                  PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS | MAP_ALIGNED(21),
                  -1, 0);
// 注意:MAP_ALIGNED(21) 表示2^21 = 2MB对齐

3.3 内存池技术:自己管,更放心

内存池,说白了就是预分配一大块内存,然后自己管理。为什么需要它?因为内核的分配器再快,也快不过你自己定制的分配器。

我做过一个工业相机项目,每帧图像都要申请和释放几十个大小固定的缓冲区。用系统自带的malloc,每帧要花几百微秒。后来我改用内存池,预分配好所有缓冲区,每帧只需要从池里取一个,用完了还回去。时间降到了几微秒。

QNX里实现内存池,常用的方式有两种:

方式 适用场景 优点 缺点
posix_memalign + 链表管理 固定大小对象 实现简单,跨平台 需要自己处理并发
shm_open + mmap 多进程共享 支持进程间通信 管理稍复杂

一个简单的内存池实现思路:

// 伪代码,展示核心逻辑
struct mem_pool {
    void *block;        // 预分配的大块内存
    size_t block_size;  // 每个对象的大小
    int free_list[MAX]; // 空闲索引列表
    int free_count;
};

void *pool_alloc(struct mem_pool *pool) {
    if (pool->free_count == 0) return NULL;
    int idx = pool->free_list[--pool->free_count];
    return pool->block + idx * pool->block_size;
}

void pool_free(struct mem_pool *pool, void *ptr) {
    int idx = (ptr - pool->block) / pool->block_size;
    pool->free_list[pool->free_count++] = idx;
}

避坑指南:

我曾经在内存池里犯过一个低级错误:没有处理并发。两个线程同时调用pool_alloc,结果拿到了同一个地址。后来加了原子操作或者互斥锁才解决。记住:内存池不是线程安全的,除非你明确设计成线程安全的。

3.4 实战建议:怎么选?

好了,三个技术都讲完了。你可能会问:我到底该用哪个?

我的经验是:

  • 系统启动、驱动加载:用伙伴系统。这时候内存需求不确定,伙伴系统最灵活。
  • 高频申请释放固定大小对象:用slab或内存池。比如网络包缓冲区、文件系统缓存。
  • 大块连续内存:用大页。比如视频帧缓冲区、DMA缓冲区。
  • 实时性要求极高:用内存池。分配时间确定,没有锁竞争。

嗯,最后说一句:没有银弹。每个项目都要根据实际负载来调。我一般会在原型阶段用默认配置,然后用procctrltop观察内存分配情况。如果发现分配耗时波动大,再考虑换方案。

这一节就到这里。下一节咱们聊聊虚拟内存和MMU的配置,那又是另一番天地了。