Linux --- 内存故障
在嵌入式Linux、工业机器人、上位机服务等需要7×24小时长期运行的系统中,“运行几小时/几天后突然崩溃”“内存莫名持续上涨”是极为常见且定位困难的故障。
内存故障的分类树
Linux C/C++内存故障可以分为四大类、六小种:
一、Memory Leak 内存泄漏
1.1 定义
内存泄漏是对象生命周期结束后,对应的堆内存没有被正确释放,导致系统持续失去对该内存块的跟踪与回收能力
典型的代码场景是异常分支遗漏释放:
void process()
{
int* p = new int[1024];
if (error_condition()) {
return; // 异常路径遗漏 delete[]
}
delete[] p;
}
单次调用可能仅泄漏4KB,完全无法感知。但对于运行频率50Hz的工业控制或机器人程序:4 KB × 50 次/秒 × 3600 秒≈ 720 MB / 小时
这也是为什么工业软件、ROS节点、设备守护进程对内存泄漏极为敏感的原因:短期运行完全正常,长期运行下RSS持续缓慢上涨,最终触发内核内存回收、性能抖动,直至OOM杀死进程。其演化链路为:
Memory Leak
↓
长期内存增长
↓
Memory Pressure
↓
Reclaim / Swap
↓
OOM
1.2 free() 不等于 RSS 立即下降
这是排查内存泄漏最容易踩的坑:执行delete[]或free()后,进程RSS并不保证立即下降。
原因在于,应用程序与内核之间并非直接交互,中间存在多层分配器架构:
程序
│
malloc/free
│
▼
glibc allocator
├── tcache(线程本地缓存)
├── arena(内存区)
├── bins(空闲块链表)
├── free chunks
│
▼
brk / mmap 系统调用
│
▼
Linux 虚拟内存子系统
free()的语义首先是将内存块归还给进程内的分配器管理,而非直接归还给操作系统。glibc为了减少系统调用开销,设计了arena、per-thread cache、trim threshold等一系列缓存与归还阈值机制;对于大于mmap_threshold的大块分配,会直接使用mmap()分配,释放时直接归还给系统,但小块内存通常会保留在分配器内部。
glibc提供了trim_threshold、mmap_threshold、arena_max、tcache_count等可配置参数,用于调整分配器的缓存与归还策略。
因此,进程RSS不下降存在多种可能:
- 真正的内存泄漏
- 分配器内部缓存
- 内存碎片
- arena未触发归还阈值
- 页缓存与mmap映射
- 程序本身仍在持有有效数据
不能仅通过RSS指标判断是否存在内存泄漏。
1.3 观测与排查手段
Linux提供了丰富的进程内存观测接口:
/proc/<pid>/status:可查看VmSize、VmRSS、RssAnon、RssFile、VmStk等宏观指标,快速判断内存分布/proc/<pid>/smaps/smaps_rollup:提供更细粒度的虚拟内存区域(VMA)级统计,可精确到每个映射段的RSS、PSS、Swap、匿名/文件映射等信息,是定位泄漏区域的核心工具
开发阶段检测工具
- AddressSanitizer + LeakSanitizer
Clang/LLVM提供的内存错误检测工具链,编译时加入-fsanitize=address即可同时检测越界访问、释放后使用等错误;加入-fsanitize=leak可单独启用泄漏检测。LeakSanitizer可以集成在ASan中运行,是开发阶段定位泄漏的首选方案
# 编译示例
clang++ -g -O1 \
-fsanitize=address \
-fno-omit-frame-pointer \
main.cpp
- Valgrind Memcheck
经典的动态内存错误检测工具,通过二进制指令插桩实现,无需重新编译。启用--leak-check=full可输出详细的泄漏堆栈与分类统计,支持区分“直接丢失”“间接丢失”“可能丢失”“仍可到达”等不同泄漏类型
valgrind --tool=memcheck \
--leak-check=full \
./program
1.4 工程化规避
现代C++开发中,应优先使用RAII机制管理内存生命周期,替代裸new/delete:
std::unique_ptr:独占所有权,零开销std::shared_ptr:共享所有权,引用计数std::vector/std::string:自动管理缓冲区
注意:shared_ptr并非银弹。如果对象间形成循环引用,双方引用计数永远无法归零,会形成逻辑泄漏,需要使用std::weak_ptr打破引用环。
二、Buffer Overflow:缓冲区溢出
2.1 定义
缓冲区溢出是程序拥有一块N字节的合法内存,却访问了N字节以外的位置。
示例:
char buffer[8];
memcpy(buffer, data, 20); // 写入长度超出缓冲区边界
内存布局示意:
buffer
0 1 2 3 4 5 6 7
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ │ │ │ │ │ │ │ │
└────┴────┴────┴────┴────┴────┴────┴────┘
│
合法范围 ─────────────────────────────┘
继续写入 8、9、10... 字节 → 覆盖相邻内存数据
CPU与MMU并不感知上层的“数组边界”,仅按照地址 = 基址 + 偏移的方式计算目标地址。只要目标虚拟地址位于可写页面内,写入就会直接执行,不会立刻触发段错误。
这导致缓冲区溢出最隐蔽的特点:破坏可能不会立即触发崩溃,而是在后续其他模块读取被损坏的数据时才异常,故障现场与根因可能相隔很远,定位难度极高。
2.2 溢出发生的三个位置
缓冲区溢出可以发生在内存的任意区域:
- 栈上溢出:局部数组越界,可能覆盖栈帧上的返回地址、栈基址等关键数据,是栈溢出攻击的基础;
- 堆上溢出:malloc/new分配的块越界,破坏堆元数据与相邻堆块,导致堆损坏;
- 全局/静态区溢出:全局数组越界,破坏全局变量与静态数据。
2.3 防护与检测
- GCC栈保护机制:
-fstack-protector、-fstack-protector-strong通过在栈帧中插入金丝雀值(canary),检测栈溢出破坏。但该机制仅针对栈,且只能检测部分溢出,无法替代完整的内存错误检测 - AddressSanitizer:可全面检测堆、栈、全局区的越界读写,同时支持use-after-free、double-free等多种内存错误。数据显示典型运行时开销约为2倍,主要用于开发测试阶段,不建议直接用于生产环境
三、Use After Free:释放后重用
3.1 定义
Use After Free,即内存已经被释放,但程序之后仍然重新访问或引用这块内存
示例:
int* p = new int(42);
delete p;
std::cout << *p; // Use After Free
为什么delete以后指针还能访问?
因为delete p做的仅仅是将该内存块的所有权归还给分配器,但指针变量本身的值并不会自动置空,仍然指向原来的地址,形成悬垂指针(Dangling Pointer)。
p
│
▼
address
│
X
object lifetime ended
p = dangling pointer
更具迷惑性的是:刚释放的内存可能暂时没有被覆盖,里面的数据依然有效,导致访问看起来“正常”。直到后续其他分配复用了这块内存,才会出现随机的数据损坏或崩溃。
3.2 典型高危场景:异步回调
在多线程、异步回调系统中,UAF问题尤为隐蔽且高发。经典模式:
Object
│
├── 启动 worker thread
│
├── 注册 callback
│
└── 异步任务保存 this 指针
│
▼
Object 已经析构
│
▼
callback 被执行
│
▼
this->xxx 访问
│
▼
UAF
在ROS2、相机驱动、工业控制等多线程异步系统中,必须统一设计线程生命周期、回调生命周期、对象生命周期与资源所有权,避免对象已销毁但回调仍在触发的情况。
3.3 检测原理
AddressSanitizer对UAF检测效果极佳。它通过编译时插桩和运行时受保护内存区域(redzone)追踪非法访问,在访问已释放内存时立刻报错并输出分配与释放的完整堆栈。该技术的经典论文发表于USENIX ATC 2012,是动态内存错误检测领域的里程碑工作
四、Stack Overflow:栈溢出
4.1 两个不同的概念
“栈溢出”这个术语存在歧义,实际包含两类完全不同的问题:
Stack Overflow
│
├── A. Stack Exhaustion(栈耗尽)
│ 线程栈空间被完全消耗
│
└── B. Stack Buffer Overflow(栈缓冲区溢出)
栈上数组越界访问
通常系统编程意义上的Stack Overflow主要指前者,即栈耗尽。
4.2 栈的线程私有性
Linux中每个线程拥有独立的栈空间:
Process
│
├── Thread A
│ └── Stack A
│
├── Thread B
│ └── Stack B
│
└── Thread C
└── Stack C
每次函数调用都会在栈上创建栈帧(stack frame),保存局部变量、返回地址、栈基址等信息。调用链越深,栈消耗越大。
4.3 常见触发原因
- 无限递归:最经典的栈耗尽原因,函数不断调用自身,栈帧无限增长。Uncontrolled Recursion,失控递归会持续消耗栈资源直至崩溃
void f() { f(); } // 无限递归
- 巨型局部变量:将大体积数组、对象直接作为局部变量分配在栈上,例如几十MB的图像缓冲区。
- 调用链过深:多层嵌套函数调用,尤其在递归解析、深度遍历场景中。
- alloca()动态栈分配:运行时动态在栈上分配空间,边界控制不当极易溢出。
4.4 排查与防护
- 查看系统默认栈大小:
ulimit -s - 多线程程序:检查pthread创建时的栈大小配置
- GCC编译选项:
-fstack-check生成栈边界检查代码,在多线程环境中也可生效 - AddressSanitizer也可检测栈上的越界访问与栈耗尽问题
五、OOM:Out Of Memory 内存耗尽
5.1 基本概念
OOM是一种资源耗尽状态,而非某一种具体bug。内存泄漏最终可能导致OOM,但OOM不一定由泄漏引起——程序正常处理大数据、内存需求真实超过系统容量同样会触发OOM。
Linux内存分配的链路:
Application
│
│ malloc/new
▼
用户态分配器 (glibc)
│
│ brk / mmap
▼
虚拟内存子系统
│
▼
内核页分配器
│
├── 空闲页链表
│
├── 内存回收 (reclaim)
│
├── 交换分区 (swap)
│
├── 内存规整 (compaction)
│
└── ...
│
▼
无法满足需求
│
▼
OOM Killer
当内存彻底耗尽、内核已经无法回收足够内存继续运行时,会调用OOM killer,选择一个任务终止,尝试释放足够内存让系统继续运行
因此程序出现Killed信息时,不一定是段错误,很可能是被OOM killer选中杀死。排查时优先检查内核日志:
dmesg -T | grep -Ei "oom|out of memory|killed process"
5.2 内存超售(Overcommit)
Linux默认允许内存超售,即分配的虚拟地址空间总量可以超过物理内存+swap的总和。这是因为malloc()本质上是申请虚拟地址空间,并不等于立刻占用等量物理内存。
overcommit模式
| 模式 | 名称 | 说明 |
|---|---|---|
| 0 | 启发式超售 | 默认模式。拒绝明显不合理的超量分配,允许适度超售以减少swap使用 |
| 1 | 总是超售 | 允许任意超量分配,适用于稀疏数组等科学计算场景 |
| 2 | 严格不超售 | 总提交地址空间不得超过swap + 物理RAM的指定比例(默认50%) |
为什么malloc()成功了,程序后面还是被OOM killer杀掉?
因为malloc()成功仅代表虚拟地址空间预留成功,当程序真正写入页面触发缺页中断时,才需要分配物理页。如果此时物理内存不足,就会触发OOM。即:虚拟地址预留 ≠ 物理内存永久可用
/proc/meminfo中的CommitLimit与Committed_AS字段分别对应当前系统的超售上限与已提交量,是评估超售状态的核心指标
5.3 Cgroup级OOM
容器化与cgroup环境中还存在另一层OOM:
系统RAM还有很多
↓
但 Docker/systemd/cgroup 设置 memory.max
↓
进程达到 cgroup 内存限制
↓
cgroup 内触发 OOM killer
当memory.max达到限制且无法回收时,可以在该cgroup内触发OOM killer。这在容器部署场景中极为常见,排查时不能只看整机内存,必须检查对应cgroup的内存限制与使用量。
5.4 嵌入式系统的特殊风险
对于嵌入式设备,通常具备RAM有限、无swap或swap很小、7×24小时运行的特点,长期微小泄漏会逐步累积,最终触发OOM并杀死关键服务。工业系统中最危险的不是程序退出本身,而是退出发生在设备控制过程中,导致安全事故。
六、Fragmentation:内存碎片
6.1 定义
内存碎片最直观的描述:有内存,但没有合适形状的内存。
示例:
总空闲内存 = 12 KB
┌─────┬─────┬─────┬─────┬─────┐
│Free │Used │Free │Used │Free │
│4 KB │ │4 KB │ │4 KB │
└─────┴─────┴─────┴─────┴─────┘
此时申请连续8KB会失败,虽然总空闲有12KB,但最大连续块只有4KB。
Linux系统中需要区分两个层级的碎片:
Fragmentation
│
├── 用户态分配器碎片
│
└── 内核物理页碎片
6.2 用户态分配器碎片
频繁的不同大小分配与释放,会导致glibc堆中出现大量离散的空闲块。这些空闲块总和可能很大,但无法满足较大的连续分配需求。
这也是另一个常见现象的原因:程序已经free了很多对象,但RSS仍然较高。
glibc提供malloc_trim()尝试将堆顶部的空闲页面归还给系统,但这只是“尝试释放”,并非所有空闲块都能被归还
观测分配器内部状态可使用mallinfo2(),可以获取已使用块数、空闲块数、可释放空间等统计,但这些统计存在局限性,不能作为进程内存的完整真相
6.3 内核物理页碎片
内核管理物理内存使用伙伴系统(Buddy Allocator),将内存按2的幂次分级管理:
order 0 → 1 页 (4KB)
order 1 → 2 页 (8KB)
order 2 → 4 页 (16KB)
order 3 → 8 页 (32KB)
...
长期运行后,物理页可能逐渐碎片化,高阶连续块减少。对于普通用户态malloc,依赖虚拟连续即可,物理不连续通常没有影响。但以下场景需要大块物理连续内存:
- 设备驱动DMA缓冲区
- CMA(连续内存分配器)
- 大页(Huge Page)
- 部分硬件专用缓冲区
此时物理碎片就会成为瓶颈。
Linux内核的内存规整(Memory Compaction) 机制就是用来解决物理碎片的:通过移动可移动页面,将空闲页重新聚集,形成大的连续块
6.4 碎片观测工具
/proc/buddyinfo:显示每个NUMA节点、每个内存区中不同order的空闲页块数量。其可用于分析外部碎片,诊断大内存分配失败的原因/proc/pagetypeinfo:按迁移类型(不可移动、可回收、可移动等)细分空闲页块分布,更精细地分析碎片成因- extfrag_index:debugfs中的碎片指数,趋近1000时表示分配失败更可能由碎片导致,趋近0时更倾向于单纯内存不足
总结
7.1 六类故障
| 故障类型 | 破坏规则 | 典型表现 | 首选排查工具 |
|---|---|---|---|
| Memory Leak | 用完应当释放 | RSS/匿名内存长期上涨,最终OOM | LSan、Valgrind、heaptrack |
| Buffer Overflow | 不能访问对象边界之外 | 随机崩溃、数据踩踏 | AddressSanitizer |
| Use After Free | 对象死亡后不能再访问 | 偶现SIGSEGV、堆损坏 | AddressSanitizer |
| Stack Overflow | 线程栈不能无限增长 | 深层调用后SIGSEGV | GDB、栈大小配置、ASan |
| OOM | 系统无法继续满足内存需求 | 进程被kill、malloc失败 | dmesg、/proc/meminfo、cgroup统计 |
| Fragmentation | 空闲空间布局无法满足分配 | 有空闲内存仍分配失败、RSS偏高 | buddyinfo、pagetypeinfo、分配器统计 |
7.2 排查思路
Linux C/C++内存问题是一个从用户态对象生命周期、libc分配器、虚拟地址空间、Linux VM子系统到物理页分配器的完整链条问题。内存泄漏、越界访问、释放后重用、栈溢出、OOM、碎片分别处于这条链路的不同层级,对应不同的检测方法与解决思路。
对于嵌入式、工业、机器人等长期运行场景,建立完整的内存认知体系,比单纯记住某个工具的用法重要得多。理解了这条链路,才能在面对“跑几天就崩”“内存莫名涨”这类疑难杂症时,快速定位根因而非盲目猜测。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)