栈溢出 写入相邻的内存区域 GCC 的 -fstack-protector 会在每个函数的栈帧底部放一个随机“金丝雀值”,函数返回前检查它是否被改掉,以此检测栈缓冲区溢出。

栈溢出到底有多隐蔽?我用一个真实案例讲清楚

16字节的金丝雀,搞定32位MCU的堆栈溢出检测

 

栈溢出到底有多隐蔽?我用一个真实案例讲清楚

"栈溢出"这三个字,嵌入式工程师听了无数次,教材里也讲了无数次。

但绝大多数人对它的理解,停留在"局部变量太多、栈空间不够、程序崩溃"这个层面。

这个理解是对的,但不完整。

栈溢出最可怕的地方,不是它让程序崩溃,而是它让程序以一种完全看不出来是栈溢出的方式出错——可能是随机数据错误,可能是莫名其妙的逻辑跳转,可能是某个全局变量突然被改成了一个匪夷所思的值。

让我用一个真实案例说清楚。


案例背景

项目是一个数据网关,负责从多个传感器采集数据,做一定处理之后通过4G上报云端。

有一天,现场反馈:设备偶发性地发送错误数据——某次上报的温度值是-8632.5°C,某次压力值是4294967296 Pa(这个数字你认识,是2^32,uint32_t的最大值加一)。

这两个值都是物理上不可能出现的,一眼就能看出是数据错误。

但问题在哪里,完全看不出来。


排查:走了很多弯路

第一反应是传感器问题——联系传感器厂商,确认传感器本身没问题,数据输出是正常的。

然后怀疑是通信问题——RS-485总线有干扰,数据帧在传输过程中被破坏?检查了CRC校验,校验通过的帧里数据就是那个异常值。所以不是传输被破坏,是发送端就发了这个值。

然后怀疑是数据处理逻辑的bug——把数据处理函数翻了好几遍,逻辑上没有任何问题。

然后怀疑是浮点运算精度问题——换了double,结果还是一样。

折腾了将近两周,没有头绪。


转机:一个偶然的观察

有一天在看日志的时候,发现了一个规律:异常数据几乎都出现在设备完成一次云端数据上报之后的几秒钟内。

这个观察很关键。上报云端走的是4G,4G发包会调用一个JSON序列化函数——把采集到的数据打包成JSON字符串,再发送出去。

我去看了这个JSON序列化函数:

void Build_UploadJSON(char* out_buf, SensorData_t* data)
{
    char temp_str[16];
    char pressure_str[16];
    char humidity_str[16];
    char timestamp_str[32];
    char device_id_str[64];
    char location_str[128];

    // 格式化各个字段
    snprintf(temp_str, sizeof(temp_str), "%.2f", data->temperature);
    snprintf(pressure_str, sizeof(pressure_str), "%.2f", data->pressure);
    snprintf(humidity_str, sizeof(humidity_str), "%.2f", data->humidity);
    snprintf(timestamp_str, sizeof(timestamp_str), "%lu", data->timestamp);
    snprintf(device_id_str, sizeof(device_id_str), "%s", data->device_id);

    // 位置信息格式化
    snprintf(location_str, sizeof(location_str),
             "{\"lat\":%.6f,\"lon\":%.6f,\"alt\":%.2f}",
             data->latitude, data->longitude, data->altitude);

    // 最终JSON
    snprintf(out_buf, JSON_BUF_SIZE,
             "{\"temp\":%s,\"pressure\":%s,\"humidity\":%s,"
             "\"ts\":%s,\"id\":%s,\"loc\":%s}",
             temp_str, pressure_str, humidity_str,
             timestamp_str, device_id_str, location_str);
}

局部变量加起来:16+16+16+32+64+128 = 272字节。

看起来不多。

但这个函数是在哪个任务里被调用的?去找调用栈:

Upload_Task()
    → Prepare_Upload_Data()
        → Build_UploadJSON()    ← 在这里分配272字节局部变量

Upload_Task是一个FreeRTOS任务,我们给它分配的栈是多少?

找到任务创建的代码:

xTaskCreate(Upload_Task, "Upload"256NULL2NULL);

256。

FreeRTOS的栈大小单位是字(word,4字节),所以Upload_Task的栈是256×4 = 1024字节。

这个任务的调用栈:Upload_Task自身的局部变量和函数调用开销,加上Prepare_Upload_Data的开销,再加上Build_UploadJSON里272字节的局部变量,加上snprintf本身的栈开销(snprintf的实现在很多平台上会在栈上分配几十到一百多字节的临时缓冲区)……

全部加起来,超过了1024字节。

栈溢出了。


溢出之后发生了什么

理解这件事,需要知道FreeRTOS(以及大多数RTOS)的内存布局。

在内存里,每个任务的栈是一块连续的内存区域。任务栈从高地址向低地址生长——局部变量分配,就是把栈指针往下移。

当栈指针超过了栈底(分配的内存的低地址端),局部变量就开始写到栈之外的内存——那块内存是什么?

这取决于内存分配器怎么排布的,但在很多情况下,紧挨着任务栈的,是另一个任务的栈,或者是堆,或者是全局变量区。

在我们这个案例里,Upload_Task的栈溢出之后,Build_UploadJSON里那些局部变量开始写入相邻的内存区域——而那块内存,正好是另一个任务(Sensor_Task)的栈里,存放着它的某个局部变量:就是那个正在被处理的SensorData_t结构体。

于是发生了这件事:

  1. 1. Sensor_Task读取传感器数据,把数据存在自己的栈上的SensorData_t
  2. 2. Upload_Task开始运行,调用Build_UploadJSON,栈溢出,局部变量写入了Sensor_Task的栈区
  3. 3. 写入的数据(字符串的各种中间状态)恰好把temperature字段覆盖成了一个随机值
  4. 4. Sensor_Task继续运行,读取自己栈上的数据,拿到的是被覆盖的值
  5. 5. 这个被污染的值经过上报流程,最终出现在云端的数据里

整个过程,两个任务都"正常运行",没有任何崩溃,没有任何错误码,日志里没有任何异常报告。 就是数据莫名其妙地错了。

这就是栈溢出最隐蔽的地方:它不一定让程序崩溃,它会悄悄地改变你不期望被改变的内存,然后让程序带着错误继续运行。


为什么用了FreeRTOS的栈检测还没发现

FreeRTOS有一个uxTaskGetStackHighWaterMark()函数,可以查看任务栈的历史最低余量(水位线)。我们当时是有定期打印这个值的。

但这次没有发现问题,原因是:栈水位检测的原理是在栈底填充一个特定的值(通常是0xA5A5A5A5),然后检查这个值有没有被改写。

如果栈溢出得很少——比如只溢出了几个字节,那个填充值还没有被覆盖,水位检测就认为栈还好。但这几个字节的溢出,已经足以污染相邻内存里的一个关键变量。

我们的溢出量恰好就是"刚好超过一点点,但水位检测的填充区还没被碰到"。


最终的修复

改法有两个,同时做:

第一:增大Upload_Task的栈

// 原来:256 words = 1024字节
xTaskCreate(Upload_Task, "Upload"256NULL2NULL);

// 修改后:512 words = 2048字节,留足余量
xTaskCreate(Upload_Task, "Upload"512NULL2NULL);

第二:把Build_UploadJSON里的局部变量从栈上移走

栈上分配272字节,加上snprintf的内部开销,确实太多了。改成静态分配:

void Build_UploadJSON(char* out_buf, SensorData_t* data)
{
    // 改成static,分配在BSS段(全局内存),不占用栈空间
    // 注意:static局部变量不是线程安全的,这里因为只有一个Upload_Task调用,所以可以
    static char temp_str[16];
    static char pressure_str[16];
    static char humidity_str[16];
    static char timestamp_str[32];
    static char device_id_str[64];
    static char location_str[128];

    // ... 其余代码不变
}

改完之后用uxTaskGetStackHighWaterMark()验证,Upload_Task的栈余量从"几乎为零"变成了稳定的几百字节,异常数据再也没有出现过。


这件事给了我几个教训

教训一:栈溢出不一定崩溃,这才是它真正危险的地方。

很多人觉得"程序没崩就没有栈溢出",这个想法要改掉。程序崩溃是栈溢出的一种后果,但不是唯一的后果。悄悄污染相邻内存,让程序带着错误继续运行,往往更难被发现。

教训二:snprintf是个隐藏的栈杀手。

snprintf的内部实现会在栈上分配临时缓冲区,具体大小取决于平台和实现,有时候几十字节,有时候一百多字节。在栈空间本来就不宽裕的任务里频繁调用snprintf,是一个很常见的栈溢出来源。换成简单的字符串拼接函数,或者把缓冲区改成静态分配,都能解决。

教训三:任务栈大小要量过,不能靠"感觉"。

给任务分配多大的栈,不是凭感觉或者拍脑袋——要把这个任务的完整调用栈里所有函数的局部变量加起来算,再加上函数调用本身的压栈开销,再加上20%的余量。

uxTaskGetStackHighWaterMark()定期监控,在余量低于某个阈值的时候报警,而不是等到真的溢出了才发现。

教训四:相邻内存被污染,找根因要往上层找。

数据错误出现在Sensor_Task,但根因在Upload_Task的栈溢出。调试这类问题,不能只看出错的地方,要看那块内存的相邻区域是什么任务或者什么变量,往那个方向查。


最后

排查这个问题花了两周多,根因找到之后改了不到半小时。

这个比例,在嵌入式里不算罕见。因为栈溢出的现象和根因之间,往往隔了好几层间接关系——你看到的是数据错误,真正的问题是内存被写坏,写坏内存的是另一个任务的栈溢出。

如果下次遇到"随机出现的、重现条件不固定的、数据莫名其妙错误的"bug,把栈溢出加进排查清单的前三位。

它藏的地方,比你想象的深。

 

 
 
 
疑问三:MPU((Memory Protection Unit,内存保护单元)能直接检测堆栈溢出,还有必要软件检测吗?的确,越来越多的MCU集成了MPU(Memory Protection Unit,内存保护单元),可以通过设置区域属性,当SP(堆栈指针)访问超出指定堆栈范围时产生MemManageFault或HardFault。尽管MPU能实现实时的硬件拦截,但其资源是有限的。Cortex-M架构的MCU通常只有8个或16个MPU区域。由于这些区域还要用于隔离代码区、数据区、外设寄存器等,很难为系统中的每一个任务栈都分配一个专属的MPU区域。所以,真相是:OS+硬件MPU+系统级软件的多层级防护是最佳实践。那,到底该怎么办呢?思路:在堆栈边界上放一只金丝雀


矿工下井时带一只金丝雀——金丝雀对瓦斯比人敏感得多,一旦金丝雀倒下了,人就知道该跑了。我们可以借鉴这个思路:在堆栈和全局变量之间的边界上,放一个对溢出极其敏感的“金丝雀变量”,一旦它被踩到,我们就知道堆栈越界了。这个来自工业时代的隐喻,在编译器领域早已落地:GCC 的 -fstack-protector 会在每个函数的栈帧底部放一个随机“金丝雀值”,函数返回前检查它是否被改掉,以此检测栈缓冲区溢出。但编译器的栈保护有一个盲区:它只能管函数调用栈内部的事。全局栈空间的溢出——比如中断嵌套太深、函数调用链太长,把整个堆栈区撑爆了——编译器看不见。我们要做的,是在全局堆栈边界上放上“堆栈溢出缓冲区”,这就是我们的的金丝雀。
 
 
 
 

 

posted @ 2026-08-05 21:01  papering  阅读(4)  评论(0)    收藏  举报