做QNX开发的应该都遇到过这种场景:项目跑得正顺,突然收到内存告警,车机或工控现场又没法随意停板子,你习惯的那套Linux下ps、top、/proc/{pid}/maps的排查思路在这套系统里基本失灵。我之前接手一个音频服务内存缓慢增长的问题,系统物理内存从开机70%一路爬到90%,眼看着要触发看门狗重启。当时第一反应就是找工具,最后救场的就是QNX自带的pmap。
这篇内容就是围绕pmap做内存分析的一次深入探究,覆盖输出字段解读、泄漏定位思路、线程级反查方法,以及什么时候该换别的工具。适合刚开始碰QNX的嵌入式开发者,也适合已经在调内存问题但总觉得差点思路的朋友。说白了,pmap就是QNX进程的“户型图”,每个房间多大、什么属性、是不是共享,它都能告诉你,问题是很多人拿过来只会看个总大小。
1. 为什么在QNX上调内存问题,我第一个打开的一定是pmap
1.1 QNX的进程内存管理跟Linux根本不是一回事
在Linux上你可以靠/proc伪文件系统看到五花八门的信息,maps、smaps、status一个个翻。QNX是微内核架构,进程地址空间的信息由proc系统服务管理,你没法像Linux那样直接进目录翻文件,很多在Linux上惯用的脚本到QNX上都得重写。
pmap这个工具就是从这个系统服务里读取进程地址空间的段信息。它的输出形态简单,一条条线性列出虚拟地址段,但对QNX这种偏封闭的系统来说已经是排查内存问题最顺手的一个入口。
pidin也能看到一些系统信息,但它更偏“生命体征”:谁活着、CPU用了多少、内存总量多少。而pmap提供的是“解剖视图”:一个进程的地址空间分成哪些区域、每个区域多大、是私有还是共享。这两者配合起来,一个看宏观趋势,一个看微观结构,缺一不可。
1.2 一份pmap输出能回答哪些关键问题
一句话概括:pmap能告诉你进程虚拟地址空间里到底放了什么。具体来说,至少能回答这几类问题:
- 哪个段占了最大的虚拟空间,是堆、栈、共享库还是共享内存映射;
- 哪些段来自可执行文件或者动态库,哪些是进程私有的匿名内存;
- 堆和栈的整体布局长什么样,有没有异常多的匿名段出现;
- 配合物理内存映射选项,还能看到物理连续内存被分配到了哪里。
这些信息对排查内存泄漏、栈溢出、共享内存使用异常、库加载导致的地址空间暴涨都有直接帮助。我当时定位音频服务的问题,第一步就是拿一份pmap -A <pid>的输出,看看到底是哪类段在持续增长。
基础用法非常简单,在目标板上执行:
pmap -A 17891789是进程PID,-A表示打印完整的地址映射。不同SDP版本的选项可能有细微差别,拿到机器上先敲一下pmap --help确认即可,核心逻辑是不变的。
2. 读懂pmap的每一列:地址、大小、类型和名字不是摆着好看的
2.1 先看一份真实形态的输出
我在一台QNX设备上对某个服务进程执行pmap -A后拿到的输出大致是这个样子:
vaddr size (KB) type name 0x100000 462 open /usr/bin/audio_service 0x300000 128 open /proc/boot/libc.so.7 0x500000 256 open /proc/boot/libm.so.7 0xb00000 64 shared /dev/shmem/snd_pcm_buf 0xc00000 4096 mem 0xd00000 512 mem ...每一列都别放过:
vaddr:这段映射在虚拟地址空间中的起始地址。后面的列都跟这个地址绑定。size:段大小,QNX很多版本按KB显示。看到size突然变大,基本就是内存问题的第一信号。type:映射类型,常见有open、shared、mem三种,这是整个输出的核心。name:映射的对象名字,可能是一个可执行文件路径、共享库路径、/dev/shmem下的共享内存对象名,或者为空(匿名内存)。
2.2 open、shared、mem三类背后代表了什么
这是读懂pmap最关键的一步,我直接给一个对照表:
| type | 含义 | 典型来源 | 排查优先级 |
|---|---|---|---|
| open | 由文件映射产生,代码段、只读数据 | 可执行程序、动态库 | 低,通常固定不变 |
| shared | 显式共享内存映射 | /dev/shmem、mmap(SHARED) | 中,看物理内存占用时注意去重 |
| mem | 匿名内存、私有映射 | malloc堆、线程栈、TLS、bss | 高,泄漏高发区 |
open段是程序加载进来的可执行文件映射,比如libc.so、libm.so、你自己的可执行程序。多个进程加载同一个动态库时,物理内存页可以在内核层共享,但各自虚拟地址空间里都会有一段独立映射。这段大小一般从进程启动就固定了,除非你反复dlopen/dlclose动态加载库,正常运行时基本纹丝不动。
shared段是显式创建的共享内存对象,通常挂着/dev/shmem/xxx的名字。做进程间通信时很常见,多路进程共同映射同一块物理内存。排查时最容易踩的坑就是:每个进程的pmap输出里都显示同一段共享内存,别把虚拟映射大小当成物理占用的叠加。
mem段是匿名内存,没有文件名,程序自己伸手向内核要的内存都算这里:malloc出来的堆、每条线程的栈、线程本地存储(TLS)、全局数据段等。内存泄漏十有八九发生在这一类的某一段上,所以排查时的注意力要集中在mem类型段的增长趋势上,而不是盯着总大小看。
2.3 从输出里还原一个QNX进程的典型内存布局
把一份正常的pmap -A输出对着地址从低往高看,其实是有一套规律可循的。
低地址区域一般是从可执行文件映射出来的代码段和数据段,也就是open /usr/bin/xxx那段;接着是bss和堆,体现为多个大小不一的mem段,堆会随malloc申请而动态扩张;再往高地址走,是各动态库的映射区,open /proc/boot/libc.so.7、open /proc/boot/libm.so.7这类,一段挨着一段;靠近地址空间顶部,会有比较多的固定大小mem段,这些多半就是线程栈。
有个小经验:当你看到一段进程输出里连续出现N块相同大小的mem段,基本就是N条线程的栈。QNX线程栈大小默认有一套配置,可以在构建线程时通过属性设置,默认值一般从几十KB到几MB不等。如果这些相同大小的段中间又多出一块新的,说明有线程新创建了。这个特征后面在“线程级定位”里还会用到。
3. 内存缓慢上涨的定位实战:匿名段与共享段的分野
3.1 先确认整体水位,再把嫌疑锁定到进程
我处理的那个音频服务问题,现场现象很典型:板子开机内存充足,跑一段时间后系统越来越慢,最终OOM触发重启。第一步先看整体水位:
pidin mem这条命令把系统物理内存总量、空闲量、页面缓存等打出来。观察到空闲内存在持续下降,系统没有其他大任务变化,基本确定是某个常驻服务在吃内存。用pidin找到目标进程PID后,立刻给它开一份pmap -A快照。
这里有一个非常关键的习惯:不要只看当前这一刻的输出,内存泄漏是时间维度上的问题,必须有样本对比。我当时每30秒采一次样,连续采了几个小时,把每次的pmap输出追加到日志文件里,然后逐项对比各段的size变化。
3.2 一个简单但有效的采样脚本
现场没有复杂监控工具,一个while循环脚本就够了:
#!/bin/sh PID=$(pidin | grep audio_service | awk '{print $1}') while true; do echo "===== $(date) =====" >> pmap_samples.log pmap -A "$PID" >> pmap_samples.log sleep 30 done脚本跑一段时间之后,用awk按vaddr聚合各次采样的size,找出持续增长的地址段:
grep -A999 '=====' pmap_samples.log | awk '$2 ~ /^[0-9]+$/ {print $1, $2}' | sort | uniq -c重点说明一下为什么要逐段统计而不是只盯总额:内存分配存在此消彼长的情况,某个段涨了1MB、另一个段缩了1MB,总量看起来不变,问题就被掩盖了。逐段对比能精确看到是哪一段虚拟地址区间在持续扩张,这一步直接决定了后面排查的方向。
3.3 从段类型缩小到堆,再用libc能力交叉验证
我那个案例里,连续对比几小时后,open段的大小从未变化,shared段也没动,问题集中在几个mem段上。特别是地址落在堆区域附近的一个mem段,每30秒大约增长几KB,趋势呈阶梯状。
到这里可以比较有把握地说:内存泄漏大概率发生在堆分配上,也就是某个代码路径持续调用malloc/realloc却没有对应释放。
为了进一步验证,我在目标进程的调试版本里临时加了一个线程,周期性地调用libc提供的堆统计接口,把堆的使用情况打印到系统日志。QNX的libc提供了类似mallinfo()的接口,能拿到堆当前的总分配量、空闲量、块数等指标。把堆指标和pmap采样放到同一时间轴上,发现两者增长曲线完全吻合,这就从工具层面双向确认了“堆在涨”的事实。
3.4 根因可能不在pmap里:定位之后还需要代码证据
pmap能做到的是告诉你“问题出在堆上”,但它不会告诉你“是哪个函数泄漏的”。我那个案例最后是通过代码评审定位到根因的:某个解码器库在处理特定格式的输入时,内部申请了一个临时缓冲区并把指针保存在全局引用里,下次切换声道时不会再清理上一次的缓冲区,属于典型的状态性泄漏。
为了确认这个假设,我把输入切到问题格式,再切到正常格式,观察mem段增长是否停止。实测结果和推测完全一致。这里的经验是:内存分析工具负责把范围从“整个进程”缩小到“堆”,但根因追踪还需要配合代码走查、日志和最小复现用例。
4. 进程级信息不够时,怎么用pmap配合其他工具看到线程级
4.1 pmap不显示线程名,但线程栈在地址空间里有固定“指纹”
最近有个热词叫“qnx查看单个线程的指令”,对应的现实需求通常是两种:一种是崩溃堆栈要还原线程正在执行的指令地址,另一种是怀疑某个线程创建后栈资源不回收,导致地址空间持续膨胀。
pmap本身是进程维度的,不显示线程名。但线程栈在地址空间里是有规律的,前面提到:一个进程存在N条线程,pmap输出里就会出现N块大小相近的匿名mem段,且地址分布相对靠近。
利用这个特征,第一步是先获取线程的栈底地址或栈顶地址。在QNX上可以通过调试器、或类似pidin/pinfo这类查看线程详细信息的工具读取每个线程的栈属性。拿到地址后回到pmap输出里反查对应的vaddr区间,就能把“某一堆匿名段”对应到“具体某一条线程”。
4.2 拿到线程地址反查映射段:一个可以跟着做的操作流程
我通常按这个流程操作:
- 用进程查看工具列出目标进程的全部线程,记下线程ID(Tid);
- 读取目标线程的
paddr(线程控制块地址)、栈起始地址、栈大小等属性; - 把栈起始地址在
pmap -A输出里做匹配,找到落在同样vaddr范围的那个mem段; - 检查该段的
size是否与预期的栈大小一致,如果多个线程栈段中有一块已经接近上限,说明该线程有栈溢出风险; - 如果在线程栈区域出现了本不该存在的额外
mem段,多半是线程创建后没有正确释放栈资源。
举个例子,某进程有5条线程,pmap输出里看到6块相同大小的匿名mem段,就值得怀疑了。通过工具查到某个线程的栈底地址为0x90400000,再回pmap里找,发现0x90400000确实是其中一块栈的起点。多出来的那一段无法归属到任何现存活线程,很可能就是残留的僵尸线程栈。
4.3 顺着指令地址反查代码归属:线程在跑什么
“查看单个线程的指令”说到底就是想知道线程当前的PC(程序计数器)指向哪里。通过调试器挂上目标进程,读取某线程的PC值之后,下一步就是把这PC值拿到pmap输出里反查:
- 如果PC落在某个
open段的范围内,说明线程正在执行对应动态库或主程序里的代码; - 如果PC落在
mem段上,并且属性允许执行,请注意,这通常是JIT生成的代码、自修改代码或某些动态加载机制产生的映射,不是常见的可执行文件段。
这种“PC值+段归属”的组合分析在排查崩溃、死循环、热点异常时非常有效:你不仅知道线程卡在哪,还能立刻知道卡在什么映射对象里。对QNX这种集成调试器支持有限的平台来说,pmap反而是最容易被忽略的代码归属分析工具。
4.4 一套固定的组合拳
我平时遇到QNX上进程行为异常时,会按这个顺序打一套组合拳:
- 先
pidin mem看整体物理内存水位; - 再用
pmap -A <pid>观察目标进程的地址空间全貌; - 接着用进程/线程查看工具列出线程的PC、栈地址;
- 最后回到
pmap输出做反查,确认PC和栈归属到哪个段。
这套流程帮我解决过栈泄漏、线程栈溢出、动态库反复加载导致地址空间膨胀等多种问题。pmap本身不复杂,但和其他工具组合使用时价值会放大很多倍。
5. pmap的边界与替代方案:什么时候不该依赖它
5.1 虚拟映射不等于物理占用:注意虚拟与常驻的区别
pmap默认输出的是虚拟地址空间的映射情况,而不是物理内存的真实占用。一个进程pmap里看到一个1GB的mem段,物理内存不一定真的分配了1GB,可能只是映射了虚拟地址范围,页还没真正访问。反过来,进程声明的虚拟空间不大,但高频访问导致的物理页占用也可能比想象中高。
所以在QNX上做物理内存视角的分析,要换工具或换选项。pmap -P这种物理映射视角能看到具体物理地址的分配情况,系统级的内存信息则看pidin mem。排查整机物理内存耗尽问题时,虚拟地址空间的大小参考价值有限,重点要看物理页归属。
5.2 共享映射在多个进程间的“假叠加”陷阱
QNX上大量使用共享内存做进程间通信,特别是音频、图像这类多媒体业务。一个共享缓冲区,可能被三个进程同时映射,每个进程的pmap输出里都会显示一段shared /dev/shmem/xxx。
如果把三个进程的这段大小直接相加,就会得出“共享内存占了3倍”的错误结论。物理内存实际只分配了一份,其他进程只是映射到同一份物理页。排查时要根据name列里的共享对象名去重,确认物理内存的真实开销。
这也是为什么在处理共享内存相关问题时,我会特别关注pmap里的name列,而不是只看type。同类对象不同名,可能对应不同物理内存块;不同进程同名对象,可能只占一份物理内存。
5.3 什么时候该换更重的工具:pmap的定位边界
pmap擅长回答“哪里在涨、属于什么类型”,但不擅长回答“谁干的”。一旦确认问题是堆泄漏但查不出调用路径,就不要在pmap上继续死磕,该换更重的手段:
- 在QNX上移植或使用
valgrind的Memcheck工具,可以跟踪每次分配/释放的调用栈; - 把libc的分配器调试能力打开,或启用malloc钩子,记录分配调用点的返回地址;
- 用Sanitizer类的工具做插桩编译,在测试阶段提前暴露泄漏;
- 对可疑进程做代码插桩,打印关键接口调用的内存统计差异。
此外还有一类问题pmap几乎帮不上忙:内核内存分配异常、DMA连续物理内存分配失败、内存碎片化导致大块连续物理内存拿不到。这些问题的分析需要配合系统日志、驱动模型和构建配置,pmap只能提供外围辅助。
不过有一点可以确定:QNX上的内存问题,十次里有八次最后都能从pmap的输出里找到方向。它不一定能直接告诉你答案,但一定能帮你把问题范围从一整个系统缩小到一个进程、一条线程、甚至一个地址段。
最后说一个我自己的习惯:每次在代码里新增Buffer、新开线程、新建立共享内存对象,我都会在稳定版本上抓一份pmap基线留档。等真出了内存问题,拿现场的pmap输出跟基线做diff,哪里多出来了、哪里不对,一眼就能看到。这个习惯帮我省下的排查时间,远比我写这些字花的时间多。