2. 进程内存管理:堆栈、malloc/free 与碎片化

好,咱们接着聊进程内存管理。这部分内容,说实在的,是很多 QNX 开发者容易忽略的坑。我见过不少项目,功能跑得挺好,一上压力测试就崩,查到最后,十有八九是内存管理出了问题。

进程的内存布局,咱们得先心里有数。一个进程跑起来,它的虚拟地址空间里,主要就这几块:代码段、数据段、BSS 段、堆(Heap)和栈(Stack)。今天咱们重点聊堆和栈,以及那个让人又爱又恨的 malloc/free

2.1 栈(Stack):自动管理,但空间有限

栈,是编译器自动管理的。函数调用、局部变量、返回地址,都压在这上面。它的特点是:快,真的快。分配和释放就是移动一下栈指针,几乎没有开销。

但栈有个致命弱点——空间有限。QNX 里,每个线程的栈大小默认是 256KB(我记得在有些老版本里是 128KB)。你想想看,要是你在函数里定义一个大的局部数组,比如 char buffer[1024*1024],那直接就栈溢出了。

注意: 我曾经在调试一个网络协议栈时,发现程序莫名其妙地崩溃。查了两天,最后发现是一个递归调用太深,把栈给撑爆了。QNX 的栈溢出检测默认是开启的,但如果你自己实现了信号处理函数,可能会把 SIGSEGV 给吞掉,导致问题更难排查。

我个人习惯,对于可能递归较深的函数,或者需要大缓冲区的场景,我会明确用 pthread_attr_setstacksize() 来设置线程栈大小。别偷懒,默认值不一定适合你。

2.2 堆(Heap):灵活,但暗藏玄机

堆,是动态内存分配的地盘。你用 malloccallocrealloc 申请的内存,都来自这里。堆的管理,是 QNX 内核和 C 运行时库共同完成的。

QNX 的 malloc 实现,底层用的是 mmap 或者 sbrk。对于大块内存(通常超过 128KB),它会直接用 mmap 从内核映射一块匿名内存。对于小块内存,它会在堆区里维护一个空闲链表。

这里有个关键点:QNX 的 free 并不一定会把内存归还给操作系统。它只是把这块内存标记为“空闲”,放回到空闲链表里。下次 malloc 时,会优先从空闲链表里找合适的块。这样做的好处是快,坏处是——进程的 RSS(常驻内存集)只增不减。

核心观点: 在 QNX 里,free 释放的内存,大部分情况下不会立即还给内核。它留在进程的堆里,等着被复用。这是性能与内存占用之间的一个 trade-off。

2.3 malloc/free 的实现机制

咱们深入一点,看看 malloc 到底怎么工作的。QNX 的 C 运行时库(libc)用的是 dlmalloc 算法的一个变种。它的核心思想是:

  • 边界标记(Boundary Tags): 每个内存块的头尾都存着元信息,包括块大小、是否空闲、前后块的指针。这样 free 的时候可以快速合并相邻的空闲块。
  • 空闲链表分桶(Bins): 不同大小的空闲块,被放到不同的链表里。比如 16 字节、32 字节、64 字节……这样 malloc 时能快速找到合适大小的块,不用遍历整个链表。
  • 延迟合并(Deferred Coalescing): 释放小块内存时,不立即合并相邻的空闲块。等到 malloc 找不到合适大小的块时,才触发一次合并操作。这能减少 free 的开销。

看个简单的例子:

#include <stdlib.h>
#include <stdio.h>

int main() {
    void *p1 = malloc(64);   // 从 64 字节的 bin 里拿一块
    void *p2 = malloc(128);  // 从 128 字节的 bin 里拿一块
    void *p3 = malloc(64);   // 再从 64 字节的 bin 里拿一块

    printf("p1: %p\n", p1);
    printf("p2: %p\n", p2);
    printf("p3: %p\n", p3);

    free(p1);  // p1 回到 64 字节的 bin
    free(p3);  // p3 回到 64 字节的 bin,此时 p1 和 p3 相邻吗?不一定
    free(p2);  // p2 回到 128 字节的 bin

    return 0;
}

你想想看,如果 p1p3 在地址上是相邻的,free(p3) 时就会触发合并,形成一个 128 字节的空闲块。但如果不相邻,那就各自待在自己的 bin 里。

2.4 内存碎片化:性能的隐形杀手

碎片化,说白了就是内存被切成了一堆小碎片,导致你明明有足够的总空闲内存,却申请不到一块连续的大内存。

碎片化分两种:

  • 外部碎片: 空闲块分散在堆的各处,没有一块足够大来满足请求。
  • 内部碎片: 你申请了 50 字节,但 malloc 给了你一个 64 字节的块,那 14 字节就浪费了。

我在项目中遇到过最典型的场景:一个长期运行的网络服务,不断地分配和释放各种大小的数据包。跑了几天后,malloc 开始频繁返回 NULL。一查,堆里全是 16 字节、32 字节的小碎片,连一个 1KB 的连续空间都凑不出来。

避坑指南: 我曾经用 mallinfo() 函数来监控堆的状态。这个函数能返回堆的总大小、空闲块数量、已用块数量等信息。如果发现 uordblks(已用空间)和 fordblks(空闲空间)之和远大于 arena(总堆大小),那说明碎片化已经很严重了。

怎么缓解碎片化?我有几个建议:

  1. 固定大小分配: 如果可能,尽量用固定大小的内存池。比如网络数据包,你可以预分配一批 1.5KB 的缓冲区,用 slab 分配器来管理。这能彻底避免外部碎片。
  2. 减少频繁的 malloc/free: 对于频繁使用的对象,考虑复用。比如用对象池,用完了放回去,下次直接拿,不释放。
  3. 使用 jemalloc 或 tcmalloc: QNX 默认的 dlmalloc 在碎片化控制上不算优秀。你可以替换成 jemalloc,它对多线程和碎片化有更好的优化。不过要注意,替换 libc 的 malloc 实现需要谨慎,得确保所有动态库都用同一个分配器。
  4. 定期整理: 嗯,这个在 QNX 上不太容易做到,因为 malloc 不提供内存压缩功能。但你可以通过重启进程来“重置”堆。对于长时间运行的服务,可以考虑设计成无状态、可热重启的架构。

2.5 实战:用 proc 文件系统观察堆

QNX 的 /proc 文件系统提供了很多进程内存信息。比如:

# 查看进程 1234 的内存映射
cat /proc/1234/maps

# 查看进程 1234 的堆使用情况
cat /proc/1234/status | grep -i heap

我个人习惯在调试时,用 pidin -p 1234 -m 来查看进程的内存段信息。它能显示堆的起始地址、结束地址、当前大小。如果发现堆的大小在持续增长,而业务逻辑没有内存泄漏,那多半是碎片化导致 free 的内存无法合并回大块,堆只能不断向内核申请新空间。

好了,关于进程内存管理,咱们先聊到这儿。下一节,咱们会深入 QNX 内核的内存管理机制,看看内核是怎么给进程分配物理内存的。到时候你会发现,用户态的 malloc 只是冰山一角。