在嵌入式Linux、工业机器人、上位机服务等需要7×24小时长期运行的系统中,“运行几小时/几天后突然崩溃”“内存莫名持续上涨”是极为常见且定位困难的故障。

内存故障的分类树

Linux C/C++内存故障可以分为四大类、六小种:

内存问题

1️⃣ 生命周期管理错误

2️⃣ 边界访问错误

3️⃣ 容量耗尽

4️⃣ 内存布局问题

🗑️ Memory Leak
内存泄漏

该释放的内存没有释放

♻️ Use After Free
释放后重用

已经释放的内存又被访问

📏 Buffer Overflow
缓冲区溢出

访问超出对象合法边界

📚 Stack Overflow
栈溢出

单个线程的栈空间耗尽

💥 OOM
内存耗尽

系统 / cgroup 可用内存不足

🧩 Fragmentation
内存碎片

用户态分配器碎片

内核物理页碎片

一、Memory Leak 内存泄漏

1.1 定义

内存泄漏是对象生命周期结束后,对应的堆内存没有被正确释放,导致系统持续失去对该内存块的跟踪与回收能力

③ 结果:Memory Leak

② 引用丢失

① 分配:malloc / new

指向

引用丢失

未 free / delete

malloc / new

Heap 块
已分配内存

pointer
唯一引用

pointer 丢失
覆盖 / 提前返回 / 作用域结束

❌ 引用链断裂

Heap 块依然存在
仍占用堆内存

程序无法找到并释放它

➡️ Memory Leak
内存泄漏

典型的代码场景是异常分支遗漏释放:

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、匿名/文件映射等信息,是定位泄漏区域的核心工具
开发阶段检测工具
  1. AddressSanitizer + LeakSanitizer
    Clang/LLVM提供的内存错误检测工具链,编译时加入-fsanitize=address即可同时检测越界访问、释放后使用等错误;加入-fsanitize=leak可单独启用泄漏检测。LeakSanitizer可以集成在ASan中运行,是开发阶段定位泄漏的首选方案
# 编译示例
clang++ -g -O1 \
    -fsanitize=address \
    -fno-omit-frame-pointer \
    main.cpp
  1. 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 溢出发生的三个位置

缓冲区溢出可以发生在内存的任意区域:

  1. 栈上溢出:局部数组越界,可能覆盖栈帧上的返回地址、栈基址等关键数据,是栈溢出攻击的基础;
  2. 堆上溢出:malloc/new分配的块越界,破坏堆元数据与相邻堆块,导致堆损坏;
  3. 全局/静态区溢出:全局数组越界,破坏全局变量与静态数据。

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 常见触发原因

  1. 无限递归:最经典的栈耗尽原因,函数不断调用自身,栈帧无限增长。Uncontrolled Recursion,失控递归会持续消耗栈资源直至崩溃
void f() { f(); } // 无限递归
  1. 巨型局部变量:将大体积数组、对象直接作为局部变量分配在栈上,例如几十MB的图像缓冲区。
  2. 调用链过深:多层嵌套函数调用,尤其在递归解析、深度遍历场景中。
  3. 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 碎片观测工具

  1. /proc/buddyinfo:显示每个NUMA节点、每个内存区中不同order的空闲页块数量。其可用于分析外部碎片,诊断大内存分配失败的原因
  2. /proc/pagetypeinfo:按迁移类型(不可移动、可回收、可移动等)细分空闲页块分布,更精细地分析碎片成因
  3. extfrag_index:debugfs中的碎片指数,趋近1000时表示分配失败更可能由碎片导致,趋近0时更倾向于单纯内存不足

总结

7.1 六类故障

故障类型破坏规则典型表现首选排查工具
Memory Leak用完应当释放RSS/匿名内存长期上涨,最终OOMLSan、Valgrind、heaptrack
Buffer Overflow不能访问对象边界之外随机崩溃、数据踩踏AddressSanitizer
Use After Free对象死亡后不能再访问偶现SIGSEGV、堆损坏AddressSanitizer
Stack Overflow线程栈不能无限增长深层调用后SIGSEGVGDB、栈大小配置、ASan
OOM系统无法继续满足内存需求进程被kill、malloc失败dmesg、/proc/meminfo、cgroup统计
Fragmentation空闲空间布局无法满足分配有空闲内存仍分配失败、RSS偏高buddyinfo、pagetypeinfo、分配器统计

7.2 排查思路

内存异常

💥 程序崩溃

📈 内存上涨

非法访问

栈问题

ASan / GDB

overflow / UAF

stack

真正 Leak

非 Leak 增长

LSan

allocator

mmap / 页缓存

内存碎片

OOM?

总量不足

连续块不足

meminfo

cgroup

buddyinfo

pagetypeinfo


Linux C/C++内存问题是一个从用户态对象生命周期、libc分配器、虚拟地址空间、Linux VM子系统到物理页分配器的完整链条问题。内存泄漏、越界访问、释放后重用、栈溢出、OOM、碎片分别处于这条链路的不同层级,对应不同的检测方法与解决思路。

对于嵌入式、工业、机器人等长期运行场景,建立完整的内存认知体系,比单纯记住某个工具的用法重要得多。理解了这条链路,才能在面对“跑几天就崩”“内存莫名涨”这类疑难杂症时,快速定位根因而非盲目猜测。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐