系统异常调试定位方法体系:从观测到根因的完整路径
本文融合了"内存管理调试"、MP-learn 系列(内存泄露/越界/重复释放/调试方法二)、/proc/meminfo 解读、top 命令操作速查、GDB Debug Info 学习笔记共 7 篇文章的全部内容,按照"问题分类 → 系统级观测 → 进程级定位 → 代码级根因 → 防御性编程"的自顶向下逻辑重新组织。所有原始内容均完整保留,未做任何删减。
一、内存问题常见的类别
- 内存泄漏——内存malloc后未free(或者是有缓存未实际释放)
- 内存越界
- 内存重复释放
- 内存释放后还在使用
二、系统级观测:确定问题层面
2.1 Linux 系统层面:/proc/meminfo
可以通过/proc/meminfo来查看系统内存使用情况如下,详细说明见:/proc/meminfo解读
[root@iZ2vc9tzeqqfy8lzwtvn1aZ ~]# cat /proc/meminfo
MemTotal: 3649004 kB
MemFree: 720336 kB
MemAvailable: 1878912 kB
Buffers: 209796 kB
Cached: 907400 kB
SwapCached: 14988 kB
Active: 695344 kB
Inactive: 1745148 kB
Active(anon): 49308 kB
Inactive(anon): 1292364 kB
Active(file): 646036 kB
Inactive(file): 452784 kB
Unevictable: 4 kB
Mlocked: 4 kB
SwapTotal: 2097148 kB
SwapFree: 2008684 kB
Dirty: 1568 kB
Writeback: 0 kB
AnonPages: 1315248 kB
Mapped: 1130364 kB
Shmem: 18376 kB
KReclaimable: 347364 kB
Slab: 412504 kB
SReclaimable: 347364 kB
SUnreclaim: 65140 kB
KernelStack: 5616 kB
PageTables: 23320 kB
NFS_Unstable: 0 kB
Bounce: 0 kB
WritebackTmp: 0 kB
CommitLimit: 3921648 kB
Committed_AS: 2552812 kB
VmallocTotal: 34359738367 kB
VmallocUsed: 17076 kB
VmallocChunk: 0 kB
Percpu: 8576 kB
HardwareCorrupted: 0 kB
AnonHugePages: 655360 kB
ShmemHugePages: 0 kB
ShmemPmdMapped: 0 kB
FileHugePages: 63488 kB
FilePmdMapped: 55296 kB
CmaTotal: 0 kB
CmaFree: 0 kB
DupText: 0 kB
MemZeroed: 0 kB
Unaccepted: 0 kB
HugePages_Total: 0
HugePages_Free: 0
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
Hugetlb: 0 kB
DirectMap4k: 384284 kB
DirectMap2M: 3643392 kB
DirectMap1G: 0 kB
[root@iZ2vc9tzeqqfy8lzwtvn1aZ ~]#
AnanPages、Mapped的和大致等于用户态消耗的内存:
AnonPages: 1315248 kB
Mapped: 1130364 kB
Slab是内核小内存的消耗:
Slab: 412504 kB
如果这两项都没有明显的增长,说明是内核buddy处泄露
决策流程图
MemAvailable 持续下降?
├─ 否 → 无泄漏
└─ 是 → drop_caches 后是否恢复?
├─ 是 → 缓存行为,非泄漏
└─ 否 → AnonPages+Mapped 是否同步增长?
├─ 是 → ✅ 用户态泄漏(查进程 RSS)
└─ 否 → SUnreclaim 是否同步增长?
├─ 是 → ✅ 内核 Slab 泄漏(查 /proc/slabinfo)
└─ 否 → 计算残差是否持续增长?
├─ 是 → ✅ 内核 Buddy 泄漏(查 alloc_pages 调用点)
└─ 否 → 检查 HardwareCorrupted / CmaUsed 等罕见字段
2.2 top 命令:找到谁在消耗资源
top 命令常用操作速查
以下示例均来自aliyun服务器真实输出,可对照实操验证。
1. 基础用法
top # 交互模式,每3秒刷新
top -b -n 1 # 批处理模式,只输出一次(适合脚本/笔记采集)
头部信息解读(本机实际输出)
top - 14:29:26 up 14 days, 7:16, 1 user, load average: 1.74, 1.42, 1.34
Tasks: 138 total, 2 running, 136 sleeping, 0 stopped, 0 zombie
%Cpu(s): 71.0 us, 9.7 sy, 0.0 ni, 19.4 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 3563.5 total, 717.8 free, 2368.1 used, 723.5 buff/cache
MiB Swap: 2048.0 total, 1971.0 free, 77.0 used. 1195.4 avail Mem
| 字段 | 含义 |
|---|---|
load average |
1/5/15分钟平均负载,超过CPU核数需关注 |
us/sy/id/wa |
用户态/内核态/空闲/IO等待 CPU占比 |
buff/cache |
可回收缓存,不算真正占用 |
avail Mem |
应用程序实际可用内存(比free更准确) |
2. 排序(最常用)
命令行指定排序字段
top -b -n 1 -o %CPU # 按CPU使用率降序
top -b -n 1 -o %MEM # 按内存使用率降序
top -b -n 1 -o RES # 按物理内存占用降序
top -b -n 1 -o TIME+ # 按累计运行时间降序
交互模式下切换排序
在 top 运行中按以下键:
P— 按 %CPU 排序M— 按 %MEM 排序T— 按 TIME+ 排序N— 按 PID 排序
本机按内存排序实际输出
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
2124184 root 20 0 3295344 848196 29580 S 0.0 23.2 4:30.94 java
1240 admin 20 0 21.8g 658152 36228 S 0.0 18.0 12:36.73 openclaw-gatewa
466 root 20 0 289724 177156 170096 S 0.0 4.9 6:31.16 systemd-journal
3028 root 20 0 657752 97700 0 S 0.0 2.7 0:01.15 searxng worker-
938 root 20 0 2416184 66400 27568 S 0.0 1.8 16:03.09 dockerd
💡 java 进程占 23.2% 内存(848MB),是当前最大内存消费者。
3. 按线程显示
top -H # 交互模式,显示所有线程
top -b -n 1 -H # 批处理模式
本机线程模式实际输出
Threads: 355 total, 8 running, 347 sleeping, 0 stopped, 0 zombie
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
2584260 root 20 0 1224028 229256 56244 R 68.8 6.3 0:01.66 node
2584262 root 20 0 1224028 229256 56244 S 6.2 6.3 0:00.15 node
2584263 root 20 0 1224028 229256 56244 R 6.2 6.3 0:00.14 node
2584264 root 20 0 1224028 229256 56244 S 6.2 6.3 0:00.15 node
💡 注意头部从
Tasks变为Threads,同一进程的多个线程共享 VIRT/RES,但 %CPU 各自独立。
交互模式中切换
H— 切换 进程/线程 视图
4. 只看指定进程
top -p <PID> # 只看单个进程
top -p 1234,5678 # 看多个进程(逗号分隔)
top -b -n 1 -p 2124184 # 批处理模式
本机查看 java 进程实际输出
Tasks: 1 total, 0 running, 1 sleeping, 0 stopped, 0 zombie
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
2124184 root 20 0 3295344 848196 29580 S 0.0 23.2 4:30.95 java
💡 Tasks 总数变为 1,只聚焦目标进程,排查问题时非常实用。
配合 pgrep 快速定位
top -p $(pgrep -d',' nginx) # 查看所有nginx进程
top -H -p $(pgrep -x java) # 查看java所有线程
5. 其他实用交互快捷键
| 按键 | 功能 |
|---|---|
k |
向选中进程发送信号(默认SIGTERM) |
r |
调整进程优先级(renice) |
f |
选择显示的列 |
c |
显示完整命令路径 |
1 |
展开每个CPU核心的使用情况 |
E |
切换内存单位(KB/MB/GB) |
q |
退出 |
6. 常见排查场景速查
| 场景 | 命令 |
|---|---|
| 找出CPU最高的进程 | top -b -n 1 -o %CPU | head -10 |
| 找出内存泄漏嫌疑进程 | top -b -n 1 -o RES | head -10 |
| 某进程CPU飙高看线程 | top -H -p <PID> |
| IO等待高确认瓶颈 | 看 %wa + iostat -x 1 |
| 僵尸进程检查 | 看头部 zombie 计数 |
三、进程级定位:MP 操作系统专用诊断工具
3.1 内存泄漏
分析和解决思路
采用分层定位法,明确泄露层级:
- 系统层面初步判断泄露发生在:内核态 or 用户态 —— 大概率是用户态
- 确定是用户态之后,泄露进程 --> 泄露函数 --> 问题代码段
MP 定位到进程和函数
当内存短缺出现时,会自动记录内存使用情况到/syslog/meminfoxxx
哪个进程在泄露
show diagnose memory top
一般来说mpos是占大头,如果aaa、ipv6之类的进程占大头,很明显就是这个异常的进程在泄露。
如果在mpos泄露,就需要进一步定位到是哪个模块的问题,因为mpos里面是有多个模块的。
哪个函数在泄露
操作系统里通过pub.so提供了内存申请和释放的接口,会替代glibc里的malloc和free,此外也提供了一些统计信息的工具。因此可以通过如下的命令很快的定位到内存泄漏的函数。
申请了1M以上的函数地址
show slab alloc exclude mega-bytes-se 1
申请了10000次以上的函数地址
show slab alloc exclude blocks-se 10000
根据函数地址查函数名
display symbol addr xxxxxxxx
补充:根据函数名查地址使用 display symbol sym xxxxxxxx
增量观测方式
记录当前状态
slab alloc statistics save
查看增量
show slab alloc increment summay
C库缓存确认方式
show diagnose memory anon-mem-info
其中
system bytes是申请的内存,in use bytes是使用中的内存,差值即为缓存
编程注意事项
- 谁分配,谁释放——从源头控制住
- 推动分配接口可追溯——尽量避免新增公共内存分配接口,如确需新增,需支持传入调用者信息的接口。因为不可能传入整个调用栈信息(太庞大了)
- 统一内存接口封装,提升可维护性与可观测性
3.2 内存越界
分析与解决思路
- 通过在内存块前后放置特定数据(红区)等管理信息来判断。
- 先区分内存越界者是主动free被检测到还是越界写了别人的内存(有时候发生问题的模块不一定是出问题的模块,可能是别人往他的内存写数据了)
- 关键是先找到问题内存块地址,才能通过gdb关注此内存块前后范围红区情况,查看前面或后面有红区的内存块函数信息,通过这个函数找到可能的问题模块
- 如果是free被内存管理检测到,则通过关键信息区分通用池和专用池,不同类别池子内存管理信息布局有差异
实际可用工具
detect
先进入configure terminal模式,然后配置dfm mem detect-sshd on 。该工具可以动态监测某个进程的内存的管理区是否正常,可以检测到越界但还没释放的内存。
efence
同样先进入configure terminal模式,然后配置dfm mem efence-sshd on 。此工具开销太大,但很精准,可以抛出越界的第一现场。其底层用的是mprotect接口。
小结
如何定位内存越界问题
- 主动free被内存管理检测到越界,根据打印信息判断,如果是前管理区不对,大概率是前一块内存写越界了,向前找。后管理区不对,大概率是当前输出信息的caller有问题
- 其他模块挂机或者功能不对找到被修改的内存块地址,向前或者向后多打印一些内存信息,更好定位问题模块。
专用池
专用池问题内存块前后块挨着,但是通用池前后块可能隔很远
- 关键点一是定位到出问题的内存块地址,二是异常内存的值是否有特征,有特征可以通过gdb的find指令查询这个内存地址
编程注意事项
-
严格遵循内存编码规范
-
使用malloc等接口分配的内存写的时候不能超过分配的大小
-
注意数组的索引值的有效性检查,避免向后或向前越界
-
分配的内存在读写时,如果读写的size是变量,一定要检测有效范围
-
建议使用宏封装统一管理内存分配和释放接口,新写代码交付前建议使用头文件sdt_efence_pub.h中提供的接口进行检测,这里的接口在越界时会直接出错病输出越界的代码位置。(正式提交时要改回原来的接口)
3.3 重复释放与释放后使用
重复释放问题
通用池重复释放会直接报出double free 的caller,大概率就是这个caller。两种情况:
- 自己的指针释放两次
- 释放了别人的指针
如果重复释放时,该内存已经被还给内核,则不会打印出明确的信息,那么需要结合代码逻辑以及其他信息来确定,比如:/proc//maps可以查看该进程是否还有报错的内存地址,如果没有则有可能是重复释放问题
检测机制:红区设置为比如abcdefgh,释放时先把红区取反,缓存时间过后,将内存毒化为6b6b6b6b
通过dfm mem free-use-processname 可以将通用池内存块释放后仍然缓存指定时间,便于增大概率检测出此类问题
释放仍在使用问题
通过dfm mem free-use-processname 可以将通用池内存块释放后仍然缓存指定时间,便于增大概率检测出此类问题,如果看到6b6b6b6b也要想是不是内存释放单仍在使用的问题。
3.4 信号量死锁、异常挂机、CPU 问题、线程栈问题
信号量死锁
信号量死锁是多个进程(或线程)在使用信号量进行同步时,因彼此等待对方释放信号量,导致所有相关进程都无法继续执行的状态
解决核心思路
- 找出线程1拥有的信号量和等待的信号量1
- 找出信号量1的拥有者线程2
- 找出线程2拥有的信号量和等待的信号量2
- 重复2、3,直到查清死锁人物间的等待关系
调试命令
# 1. semLib.h : 查看信号量使用情况
show semaphore ?
# 查看互斥锁使用情况,pended代表有挂起的线程,找到这个锁的owner,看看这个owner为什么不释放锁
show semaphore mutex pended summary
# trace上一步得到的owner
trace <owner>
# 2. gnl_sem_pub.h 查看互斥信号量使用情况
show diagnose gnl semaphore mutex pend
# 查看指定类型信号量使用情况
show diagnose gnl semaphore
编程注意事项
-
避免信号量保护范围过大
-
获取和释放要成对,不要遗漏释放
-
避免死锁参考:
-
主动切断外部模块持有信号量再获取自己信号量的可能
-
避免持有自己信号量再同步调用外部模块接口,从而持有外部信号量
-
参考百科
-
全新代码使用gnl_sem_pub.h提供的接口。
异常挂机
解决方式
应用程序不管启动多少次,函数地址都是固定的,但是动态库的函数地址是不固定的。如何找到动态库的调用函数?
- 根据打印出来动态库的地址(可能会变),和具体挂机点的调用函数的地址算出一个偏移
- 通过这个偏移,可以在有addr2line的环境下,用addr2line工具找到具体的调用点(行号)
addr2line工具
addr2line工具在x86操作系统可以直接用,如果是非x86系统,可以在/config/product_config里找到工具对应的路径
addr2line -e out/image/libso/cli/libmem-cli.so <偏移>
可字节定位到so行号
后续学习:gdb debug符号如何导入导出?
小结
- 注意挂机信号,不同的挂机信号有不同含义,如11信号是常见的异常地址访问。
- 确认挂机线程号后想办法进入linux shell,通过gdb调试问题线程
- 如果能够导入调试符号且设备内存够,则可导入gdb符号表
- 如果不能导入符号,结合编译环境分析挂机位置代码信息
- 动态库函数挂机如何定位代码行号?先获取偏移然后addr2line
CPU(死循环,调度问题)
关键思路
CPU占用高,可能死循环,系统会自动检测输出相关日志(也可以通过工具命令来查看具体线程占用CPU情况),遇到此类问题尽量多输出几次线程堆栈信息,因为堆栈输出过程漫长有滞后性,多输出才好定位
线程得不到CPU调度?确认是否线程在等待资源(信号量等);如果确认不是等待资源,查看指定线程的调度统计信息确认;
总线程被调度的少?梳理下业务模块框架,是否锁过多,或者锁的使用不合理,导致等锁时间多?因为拿锁和放锁都涉及到CPU的重新调度。
调试命令
MPOS命令
spy cpu
show cpu
linux命令
top
top命令常见操作见下方链接:
https://ypp123.top/archives/5G7WHrnp
也可以查看/proc目录下的进程、线程状态,详见下方链接:
https://ypp123.top/archives/PNzpc9t0
小结
-
线程CPU占用高问题,系统基础会自动检测并输出日志,此外也可在mpos上使用以下命令来追踪:
-
spy cpu后执行show cpu —— 查看当前系统各线程CPU占用情况
-
show cpu monitor —— 查看系统CPU整体随着时间占用情况
-
router#trace pthreadname —— 显示问题线程堆栈信息
-
线程没有被调度,可以通过内核调度统计信息确认,详见前文提到的《proc文件系统》
-
线程调度慢或不正常?硬中断是否异常、软中断是否异常、实时线程是否异常、其他线程CPU占用是否异常等逐一确认。
-
sysrq TASK-CPU-INFO(a)可以显示CPU占用异常线程情况,在MP shell和linux shell都不能使用时可以尝试
线程栈(溢出,栈破坏)
关键思路
线程栈异常一般会触发异常堆栈信息
- 先确认是否栈溢出问题,有明确的提示信息:Detect stack overflow;
- 其他栈问题收集sp指针指向的栈信息,结合代码、AI等进一步分析
- gdb中
info registers找到栈顶指针 - 然后x/1280x 栈顶指针,尽量多收集附近的信息
小结
- 先看是否有Detect stack overflow,
有时候即使栈溢出,栈使用占比也不会显示占用100%,最根本还是要看 /proc/xxx/maps 看异常地址在哪个区域,是否在正常栈范围。 - 其他栈异常充分收集栈相关寄存器sp指向的信息,结合代码、AI等分析。栈异常大概率栈信息显示??,无法解析函数符号,无法显示完整堆栈信息
其他工具和手段
工作线程job信息
主要命令:show diagnose util workthread xxx
文件fd占用信息
主要命令:show diagnose util kernelfd xxx
日志
主要命令:collect diagnose sysinfo xxx
四、代码级根因:从地址回到源码
4.1 GDB Debug Info:符号与源码的桥梁
Linux C GDB Debug Info 学习笔记
1. Debug 信息的设置与有无对比
编译选项
| 选项 | 说明 |
|---|---|
-g / -g2 |
生成标准 DWARF debug 信息(推荐) |
-g1 |
仅生成最小调试信息(函数名、行号) |
-g3 |
包含宏定义等额外信息 |
-O0 |
关闭优化,保证源码与指令一一对应 |
-O2/-O3 |
开启优化,可能导致调试时变量被优化掉 |
验证命令
gcc -g -O0 demo.c -o demo_debug # 带 debug 信息
gcc demo.c -o demo_release # 不带 debug 信息
readelf -S demo_debug | grep debug # 查看 .debug_* 段
2. 为什么要分离 Debug 信息
- 减小发布体积:debug 信息通常占二进制 50%~80%
- 保护源码:避免源码路径、变量名泄露
- 按需加载:GDB 仅在需要时加载,减少内存占用
- 独立更新:代码小改无需重新分发完整 debug 包
3. 分离 Debug 信息的方法
使用 objcopy
# 1. 提取 debug 信息到独立文件
objcopy --only-keep-debug demo demo.debug
# 2. 从原文件中剥离 debug 段
objcopy --strip-debug demo demo_stripped
# 3. 添加关联链接(让 GDB 自动找到 debug 文件)
objcopy --add-gnu-debuglink=demo.debug demo_stripped
验证关联
readelf -x .gnu_debuglink demo_stripped
# 输出包含 debug 文件名 + CRC32 校验值
4. GDB 如何使用分离的 Debug 信息
自动查找顺序
- 同目录下
demo_stripped.debug .debug/子目录/usr/lib/debug/系统目录- build-id 匹配(
.build-id/xx/yyyy.debug)
手动指定
# 方式1:启动后加载
(gdb) symbol-file demo.debug
# 方式2:设置搜索目录
(gdb) set debug-file-directory /path/to/debug/files
# 方式3:启动时指定
gdb -ex "set debug-file-directory ./debug" demo_stripped
验证加载成功
(gdb) info sources # 应显示源码文件列表
(gdb) list main # 能正常显示源码
(gdb) print variable # 能查看变量值
5. 最佳实践总结
| 场景 | 建议 |
|---|---|
| 开发阶段 | gcc -g3 -O0 全量调试 |
| 测试/预发 | gcc -g2 -O1 平衡性能与调试 |
| 生产发布 | 分离 debug + strip,保留 .gnu_debuglink |
| 崩溃分析 | 确保 debug 文件版本与二进制严格匹配 |
| CI/CD | 自动归档 debug 文件到符号服务器 |
⚠️ 关键提醒:debug 文件必须与对应二进制同一次编译产物,否则 CRC 校验失败或符号错位
4.2 动态库崩溃定位:addr2line
应用程序不管启动多少次,函数地址都是固定的,但是动态库的函数地址是不固定的。如何找到动态库的调用函数?
- 根据打印出来动态库的地址(可能会变),和具体挂机点的调用函数的地址算出一个偏移
- 通过这个偏移,可以在有addr2line的环境下,用addr2line工具找到具体的调用点(行号)
addr2line -e out/image/libso/cli/libmem-cli.so <偏移>
可直接定位到so行号。
addr2line工具在x86操作系统可以直接用,如果是非x86系统,可以在/config/product_config里找到工具对应的路径。
4.3 无 core dump 时的寄存器现场分析
当只有 oops 日志中的寄存器和栈信息时,按以下优先级分析:
第一步:ERR + CR2 定性错误类型
| ERR 值 | CR2 特征 | 结论 |
|---|---|---|
0x04 |
≈ 0x0 | 空指针读 |
0x05 |
≈ 0x0 | 空指针写 |
0x07 |
合法地址 | 写只读/COW 失败 |
0x15 |
数据段地址 | NX 违规(执行了非代码内存) |
0x06 |
合法地址 | 读权限违规(ASan redzone / mprotect) |
第二步:RIP 定位源码
addr2line -e /path/to/binary -f -C <RIP地址>
第三步:GPR 还原参数(x86_64 ABI)
| 寄存器 | 角色 | 崩溃时检查要点 |
|---|---|---|
| RDI | arg1 | 是否为 NULL / 野指针 |
| RSI | arg2 | 同上;若为长度值,是否异常大 |
| RDX | arg3 | 计数器/偏移量,是否溢出 |
| RAX | 返回值 | 前一调用返回 NULL/错误码后未检查 |
| RBP | 帧指针 | 若为 0 或非栈地址 → 栈已损坏 |
💡 核心原则:先定性(ERR+CR2)再定位(RIP+addr2line),用固定 ABI 约定弥补缺失的动态内存视图。
五、防御性编程:让 bug 不再发生
5.1 内存安全
- 谁分配,谁释放——从源头控制
- 推动分配接口可追溯——避免新增公共内存分配接口,如确需新增,需支持传入调用者信息
- 统一内存接口封装——提升可维护性与可观测性
- 严格边界检查——malloc 写入不超过分配大小;数组索引有效性校验;size 为变量时必须检测范围
- 交付前用 efence 检测——使用
sdt_efence_pub.h提供的接口,正式提交时改回原接口
5.2 并发安全
- 避免信号量保护范围过大
- 获取和释放成对,不要遗漏
- 避免死锁:切断外部模块持锁再获取自己锁的可能;避免持自己锁再同步调用外部模块接口
- 全新代码使用
gnl_sem_pub.h接口
5.3 可调试性设计
- 生产构建保留符号关联:strip +
.gnu_debuglink,确保崩溃时可回溯 - CI/CD 自动归档 debug 文件到符号服务器
- 开启 ASan 做 CI 门禁:
-fsanitize=address在测试阶段拦截内存错误 - 日志分级与结构化:关键操作留痕,便于事后关联分析