C 语言内存越界不要靠崩溃位置猜原因。可靠顺序是:固定触发输入,用 -g -fsanitize=address 重新编译,读取第一次非法访问的类型、源码栈和内存分配栈;在 Linux 上再用 Valgrind 补查泄漏与未初始化值。修复后必须重新运行同一输入,并确认工具报告归零。

本文面向 GCC/Clang 与 Linux 环境,给出一个可以复现、定位、修复和回归的完整例子。Windows 原生工具链的开关不同,但“先取得访问证据,再改代码”的原则相同。读完后,你应能区分堆越界、栈越界和释放后使用,并知道报告中的哪一行才是根因。
一、先看结论:ASan 与 Valgrind 怎么分工
| 对比项 | AddressSanitizer | Valgrind Memcheck |
|---|---|---|
| 是否需要重新编译 | 需要 | 不需要 |
| 运行速度 | 快 | 慢 |
| 越界检测 | 强 | 强 |
| 释放后使用 | 强 | 强 |
| 内存泄漏 | LeakSanitizer | --leak-check=full |
| 未初始化值 | 部分支持 | 支持 |
| 适用阶段 | 开发、CI | 专项排查、发布前 |
| 报告详细度 | 访问栈 + 分配栈 | 访问栈 + 分配栈 + 泄漏摘要 |
一句话:ASan 负责快速失败,Valgrind 负责补漏;两者不能互相替代。
二、用最小程序稳定复现越界
如果还不熟悉数组、指针和 malloc 的对应关系,可先补看 C 语言教程;排错时则不要一边改业务逻辑一边改内存代码,否则复现条件会被破坏。
#include <stdio.h>
#include <stdlib.h>
int main(void) {
// 分配 3 个 int 的空间
int *values = malloc(3 * sizeof *values);
// 分配失败时退出
if (values == NULL) return 1;
// 越界写入:i 可以等于 3,会写到 values[3]
for (int i = 0; i <= 3; ++i) {
values[i] = i * 10;
}
// 打印合法范围内的第三个元素
printf("%d\n", values[2]);
// 释放内存
free(values);
return 0;
}
循环条件 i <= 3 会写入第四个元素,而实际只分配三个。程序可能看似输出正常,这正是未定义行为危险之处:错误写入没有立刻撞到受保护页面,不代表数据没有被破坏。
| 代码位置 | 行为 | 问题 |
|---|---|---|
malloc(3 * sizeof *values) |
分配 3 个 int | 合法范围是 values[0]~values[2] |
for (int i = 0; i <= 3; ++i) |
循环 4 次 | 最后一次写入 values[3],越界 |
values[i] = i * 10 |
写入 | 越界写入点 |
free(values) |
释放 | 越界后释放,可能触发更多问题 |
先把示例保存为 demo.c,记录编译器版本与输入,不要同时修改循环、分配大小和输出语句。
三、AddressSanitizer 定位第一次非法访问
# 用 ASan 编译:-g 保留调试信息,-O1 兼顾可读性,-fno-omit-frame-pointer 保留调用栈
clang -g -O1 -fsanitize=address -fno-omit-frame-pointer demo.c -o demo
# 运行 ASan 版本
./demo
实际执行时应保留完整报告。典型报告会标出 heap-buffer-overflow、写入大小、源码行,以及该内存块由哪一次 malloc 分配。
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
WRITE of size 4 at 0x...
#0 main /path/demo.c:12
#1 __libc_start_main
...
0x... is located 0 bytes after 12-byte region [0x...,0x...)
allocated by thread T0 here:
#0 malloc
#1 main /path/demo.c:7
...
SUMMARY: AddressSanitizer: heap-buffer-overflow /path/demo.c:12 in main
优先看第一段错误,而不是后面由内存破坏引发的连锁崩溃;访问地址“位于某内存块右侧 0 字节”通常正是多写了一个元素。
修复循环为 i < 3 后,用相同命令重新编译,不能直接运行旧二进制。正常结果应只输出 20,进程退出码为 0,ASan 不再打印错误。
报告里的三组证据怎么读
| 证据 | 报告位置 | 你要回答的问题 |
|---|---|---|
| 错误类型 | ERROR: AddressSanitizer: ... |
是越界、释放后使用还是泄漏? |
| 访问栈 | #0 main ... demo.c:12 |
谁在什么时候访问了非法地址? |
| 分配/释放栈 | allocated by thread T0 here: ... demo.c:7 |
这块内存从哪里来、何时失效? |
把这三组证据连起来,才能回答“谁分配、谁访问、当时是否仍有效”。只根据信号编号或最后一行日志改代码,很容易把症状挪到别处。
| 错误类型 | 含义 | 排查方向 |
|---|---|---|
heap-buffer-overflow |
堆越界 | 检查数组下标和分配大小 |
stack-buffer-overflow |
栈越界 | 检查局部数组和循环边界 |
heap-use-after-free |
释放后使用 | 检查生命周期和所有权 |
double-free |
重复释放 | 检查释放路径 |
LeakSanitizer |
内存泄漏 | 检查 malloc/free 配对 |
四、Valgrind 适合补查泄漏和非法读写
# 编译普通调试版本,不需要 ASan
cc -g -O0 demo.c -o demo
# 用 Valgrind 检查内存错误和泄漏
valgrind --leak-check=full --track-origins=yes ./demo
Valgrind Memcheck 会报告非法读写、未初始化值和泄漏,但运行通常明显更慢。ASan 适合在开发和持续集成中快速失败,Valgrind 适合检查普通调试构建;两者的观察范围不同,不应把其中一个“没报错”当作另一个也必然通过。
| Valgrind 报告 | 含义 | 是否等于泄漏 |
|---|---|---|
Invalid read/write |
非法读写 | 否,越界或释放后使用 |
definitely lost |
确定泄漏 | 是 |
indirectly lost |
间接泄漏 | 是,通常随 definitely lost 一起 |
possibly lost |
可能泄漏 | 需进一步判断 |
still reachable |
仍可达 | 不一定算泄漏 |
运行 Valgrind 时同时查看错误数和泄漏摘要。still reachable 不一定等同于泄漏,definitely lost 才表示程序已经失去指向已分配内存的指针。修复越界后若仍有泄漏,应把泄漏作为第二个独立问题处理,不要为了让报告好看而关闭检查项。
五、报告不出现时检查执行路径
| 现象 | 常见原因 | 检查方法 |
|---|---|---|
| ASan 不报错 | 错误分支未执行 | 确认输入真的触发失败路径 |
| ASan 不报错 | 二进制是旧版本 | 重新编译,打印构建时间 |
| 报告栈不可读 | 优化级别过高 | 用 -O1 或 -O0 |
| sanitizer 运行库缺失 | 链接或环境问题 | 检查编译器版本和运行库 |
| Valgrind 不报错 | 未初始化值未影响分支 | 用 --track-origins=yes |
| 报告位置漂移 | 优化改变了代码布局 | 降低优化级别,保持同一输入 |
确认错误分支真的执行、二进制是刚编译的版本、带有调试信息且 sanitizer 运行库可用。优化可能改变堆栈可读性,首次定位用 -O1 或 -O0。还可以在程序启动时打印版本或构建时间,避免终端实际执行到另一个同名文件。
ASan 没报错也可能是测试没有覆盖失败路径。例如错误只在第四次循环、特定长度输入或并发时出现,就要把触发条件固化成测试。若是 use-after-free,同时阅读释放栈和访问栈;若是栈数组越界,报告会写 stack-buffer-overflow,并标出受影响的局部变量。
如果同一模块实际按 C++ 编译,还要考虑容器、析构和对象生命周期,不能把 C 的 malloc/free 修复直接套过去;可先用 C++ 教程 核对对应的资源管理语义,再保留同样的 sanitizer 回归链路。
六、把修复写成可重复的回归测试
至少保留四组输入:空数组、刚好装满、超过上限一个元素和大批量正常数据。
| 测试输入 | 验证目标 |
|---|---|
| 空数组 | 函数能安全处理空输入 |
| 刚好装满 | 边界恰好合法时不误报 |
| 超过上限一个元素 | 越界被拒绝或修正 |
| 大批量正常数据 | 正常路径不受影响 |
| 返回值和错误码 | 不仅检查“没崩溃” |
边界测试不能只断言“程序没有崩溃”,还要检查返回值、输出内容和错误码。对接受长度参数的函数,应验证长度与实际缓冲区容量来自同一事实来源。
#include <stddef.h>
#include <stdio.h>
// 把容量显式传入,并在写入前验证
int fill(int *values, size_t capacity) {
// 空指针或容量不足时返回错误
if (values == NULL || capacity < 3) return -1;
// 只在容量允许的范围内写入
for (size_t i = 0; i < 3; ++i) {
values[i] = (int)i * 10;
}
return 0;
}
int main(void) {
// 局部数组,容量为 3
int values[3] = {0};
// 传入容量,函数内部验证
if (fill(values, 3) != 0) return 1;
printf("%d\n", values[2]);
return 0;
}
这个版本把容量显式传入,并在写入前验证。它仍不能自动证明调用者给出的容量真实,因此接口设计最好使用包含长度的结构体,或在更高层统一分配与释放。关注性能时也不要先移除边界判断;优化应建立在真实测量和正确性之上。
建议在测试环境保留一套 ASan 构建,并让边界用例在每次合并前执行。若项目依赖的第三方库与 sanitizer 不兼容,可以先缩小到自己的模块,而不是彻底取消检测。
此外要区分“工具没有报告”和“程序已经证明安全”。自定义内存池、内联汇编、未执行分支或被抑制规则过滤的库代码,都可能形成观察盲区。交付记录至少写明:
| 交付项 | 内容 |
|---|---|
| 编译器版本 | GCC/Clang 版本号 |
| 编译参数 | -g -O1 -fsanitize=address 等 |
| 测试输入 | 触发越界的最小输入 |
| ASan 报告 | 错误类型、访问栈、分配栈 |
| Valgrind 命令 | --leak-check=full --track-origins=yes |
| 退出码 | 修复前后对比 |
| 已知未覆盖范围 | 第三方库、自定义内存池等 |
这样下一位维护者能复现同一结论,而不是依赖一句“本机测试通过”。
团队代码评审还应检查长度的单位:元素个数、字节数和字符串终止符不能混用。malloc(count * sizeof *values) 比手写类型大小更不容易在重构后出错;复制字符串时则要为结尾的 \0 留空间。很多 C 语言内存越界并非复杂指针技巧造成,而是单位和所有权没有写进接口。

总结
C 内存越界的可靠修复链路是:固定输入、工具复现、读取访问与分配证据、做最小修改、用相同输入回归。
ASan 擅长快速指出非法访问,Valgrind 适合补查泄漏与未初始化值;二者都不能替代边界设计和覆盖失败路径的测试。不要因为一次未崩溃,就认为 C 语言内存越界已经消失。
延伸学习
- C 语言文件读取 可继续练资源生命周期;
- 程序中节省几 KB 内存是否值得 可继续理解优化与正确性的取舍;
- C 字符串终止符解析 可补充字符缓冲区的边界细节。
常见问题
Q:程序没崩溃是不是没有越界?
A:不是。越界属于未定义行为,可能暂时正常,也可能在另一优化级别或输入下崩溃。
Q:ASan 和 Valgrind 应该同时跑吗?
A:重要项目可以分别跑。先用 ASan 获得较快反馈,再用 Valgrind 补查泄漏和未初始化值。
Q:修复后为什么还要用同一输入复测?
A:只有复现条件保持一致,才能证明原失败路径被覆盖;换输入会把问题隐藏起来。
Q:ASan 和 Valgrind 哪个更好?
A:不是二选一。ASan 快,适合开发和 CI;Valgrind 慢但不需要重新编译,适合发布前补查泄漏和未初始化值。两者互补,不能互相替代。

免费 AI IDE



