用栈水位线与结构体哨兵检测内存损坏
问题
偶发复位、看门狗、跑飞、字段值莫名变化,调试器接上现场已经丢。背后的根因通常是:
- 栈溢出
- 数组越界写
memcpy/memset长度算错- 野指针 / 错误偏移
- 协议解析相信对端长度字段
在主机和现代芯片上,ASan、MPU、编译器栈保护、KASLR 都能定位这类问题。换到下列环境就用不上:
- 裸机 / 轻量 RTOS
- 低端单片机(8 位 / 16 位)
- 内核早期启动、自实现分配器
- 任何 sanitizer、stack-protector 都开不起来的工程
下文给两种低成本检测方法,运行时开销可控,编译器无要求。共同思路:往内存里写一个业务代码不会自然写入的标记字节,事后回来看它有没有被改过。下文统一用 0xA5 与 0x5A,挑值理由见文末。
一、栈水位线

要回答的问题是:线上跑这么久,栈历史最深用到过哪。
反例:周期采样 SP
void timer_tick_isr(void) {
uintptr_t sp_now = read_sp();
if (sp_now < sp_min) sp_min = sp_now; // 栈向低地址增长
}采样式测量。两次 tick 之间的瞬时栈峰值采不到。线上偶发复位排查无效。
正解:上电填 0xA5,按需反扫
启动早期把栈区全部填为 0xA5。栈字节被压过又弹出后,值不再是 0xA5。任意时刻扫描栈区,第一个非 0xA5 字节的位置就是历史最大栈深度。
// 以栈向低地址增长为例(Cortex-M、x86 等)
extern uint8_t __stack_base; // 栈区低地址端
extern uint8_t __stack_top; // 栈区高地址端,SP 初值
#define STACK_MAGIC 0xA5
void stack_paint(void) {
for (uint8_t *p = &__stack_base; p < &__stack_top; ++p)
*p = STACK_MAGIC;
}
size_t stack_used_bytes(void) {
uint8_t *p = &__stack_base;
while (p < &__stack_top && *p == STACK_MAGIC) ++p;
return (size_t)(&__stack_top - p);
}stack_paint 必须在任何业务函数压栈之前调用,常见放法是 main 入口第一行,或链接脚本指定的启动钩子。栈向高地址增长(8051 等)只需把 stack_used_bytes 的扫描方向反过来。
局限
stack_paint自身用到的栈在调用结束后无法回填,存在数字节误差- 真实业务恰好写入
0xA5会让水位线被低估对应字节数,对 KB 级判定无影响 - 要求栈方向已知、栈区物理边界明确(多数 RTOS 任务栈和裸机栈都提供)
- 属于诊断手段;不阻止溢出,只在溢出后留下痕迹
二、结构体 / 缓冲区哨兵

要回答的问题是:这块缓冲区 / 这个结构体有没有被别人踩过。
做法是给关键对象前后各加一段哨兵字节,定期检查:
typedef struct {
uint8_t guard_head[4]; // A5 A5 A5 A5
sensor_pkt_t obj;
uint8_t guard_tail[4]; // 5A 5A 5A 5A
} safe_sensor_pkt_t;
void guard_init(safe_sensor_pkt_t *p) {
for (int i = 0; i < 4; ++i) {
p->guard_head[i] = 0xA5;
p->guard_tail[i] = 0x5A;
}
}
bool guard_check(const safe_sensor_pkt_t *p) {
for (int i = 0; i < 4; ++i) {
if (p->guard_head[i] != 0xA5) return false;
if (p->guard_tail[i] != 0x5A) return false;
}
return true;
}head 用 0xA5、tail 用 0x5A,破坏发生时立即区分前向越界与后向溢出。
检查时机
- 协议帧处理完成后
- 涉及
memcpy/memset的关键路径之后 - 低频任务(100 ms / 1 s)
- 避免放进高频 ISR
检测覆盖
- 命中:4 字节内的越界、长度算错、错误偏移、野指针落入哨兵区
- 漏检:跨越哨兵区的远距离写入;
obj内部字段的合法逻辑错误 - 哨兵宽度 4 字节是底线,关键对象用 8 字节更稳
三、组合套餐
| 层级 | 手段 | 检查时机 | RAM 成本 |
|---|---|---|---|
| 栈 | 上电 paint,按需反扫 | 故障时 / 低频任务 | 占用栈区,无额外 RAM |
| 关键对象 | head / tail 哨兵 | 协议入口、低频任务 | 每对象 8 B |
| Flash 配置 | CRC + 魔术头 | 上电、写入后 | 数字节 |
| 复位原因 | 保留 1 字节 + WDT | 上电早期读取 | 1 B |
复位原因码做法:保留 1 字节非初始化 RAM,检测到哨兵破坏时写入原因码并触发软件复位,上电早期通过串口或 LED 输出。代价 1 字节 RAM,能把"看门狗复位"区分成"哨兵 A 被踩"、"栈撞顶"等可定位信号。
四、注意
0xA5 / 0x5A用作诊断标记,不能当安全机制;保护逻辑仍要靠代码层的长度检查、指针校验、边界判断- 哨兵字节自身也可能被覆盖;再对哨兵加 CRC 校验并不提供额外保护
- 多任务 RTOS 下每个任务栈独立 paint,结果按任务汇报
stack_paint与guard_init必须在对应对象生命周期开始时执行;调用顺序错乱会让首次检查直接判定为已被破坏
附:魔法数字选值

挑值的三个硬性条件:
- 比特分布均匀,1 与 0 各占 4 位;单 bit 卡死立即改变比例
- 业务代码极少自然写入,远离
0x00和0xFF - 在 hex dump 中一眼可识别
满足条件的常用值:
| 值 | 二进制 | 备注 |
|---|---|---|
0xA5 |
1010 0101 |
高低交错 |
0x5A |
0101 1010 |
与 0xA5 互补 |
0xAA |
1010 1010 |
严格交替 |
0xA5 与 0x5A 互为镜像,用作 head / tail 哨兵能区分越界方向。0x00 与 0xFF 不可用:前者与未初始化 RAM 难以区分,后者与擦除态 Flash 和上电默认值难以区分。0x55 与 0xA5 等价,可作备用。
总结
- 栈水位线靠"启动填
0xA5+ 反扫",绕过 ISR 采样盲区 - 结构体哨兵用 head=
0xA5、tail=0x5A区分越界方向 - 全部依赖魔法数字在内存里留下痕迹,事后取证
- 适用于任何缺少 sanitizer / MPU / 栈保护的工程;零编译器依赖,RAM 成本可控
本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。