栈溢出 写入相邻的内存区域 GCC 的 -fstack-protector 会在每个函数的栈帧底部放一个随机“金丝雀值”,函数返回前检查它是否被改掉,以此检测栈缓冲区溢出。
栈溢出到底有多隐蔽?我用一个真实案例讲清楚
"栈溢出"这三个字,嵌入式工程师听了无数次,教材里也讲了无数次。
但绝大多数人对它的理解,停留在"局部变量太多、栈空间不够、程序崩溃"这个层面。
这个理解是对的,但不完整。
栈溢出最可怕的地方,不是它让程序崩溃,而是它让程序以一种完全看不出来是栈溢出的方式出错——可能是随机数据错误,可能是莫名其妙的逻辑跳转,可能是某个全局变量突然被改成了一个匪夷所思的值。
让我用一个真实案例说清楚。
案例背景
项目是一个数据网关,负责从多个传感器采集数据,做一定处理之后通过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", 256, NULL, 2, NULL);
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.
Sensor_Task读取传感器数据,把数据存在自己的栈上的SensorData_t里 - 2.
Upload_Task开始运行,调用Build_UploadJSON,栈溢出,局部变量写入了Sensor_Task的栈区 - 3. 写入的数据(字符串的各种中间状态)恰好把
temperature字段覆盖成了一个随机值 - 4.
Sensor_Task继续运行,读取自己栈上的数据,拿到的是被覆盖的值 - 5. 这个被污染的值经过上报流程,最终出现在云端的数据里
整个过程,两个任务都"正常运行",没有任何崩溃,没有任何错误码,日志里没有任何异常报告。 就是数据莫名其妙地错了。
这就是栈溢出最隐蔽的地方:它不一定让程序崩溃,它会悄悄地改变你不期望被改变的内存,然后让程序带着错误继续运行。
为什么用了FreeRTOS的栈检测还没发现
FreeRTOS有一个uxTaskGetStackHighWaterMark()函数,可以查看任务栈的历史最低余量(水位线)。我们当时是有定期打印这个值的。
但这次没有发现问题,原因是:栈水位检测的原理是在栈底填充一个特定的值(通常是0xA5A5A5A5),然后检查这个值有没有被改写。
如果栈溢出得很少——比如只溢出了几个字节,那个填充值还没有被覆盖,水位检测就认为栈还好。但这几个字节的溢出,已经足以污染相邻内存里的一个关键变量。
我们的溢出量恰好就是"刚好超过一点点,但水位检测的填充区还没被碰到"。
最终的修复
改法有两个,同时做:
第一:增大Upload_Task的栈
// 原来:256 words = 1024字节
xTaskCreate(Upload_Task, "Upload", 256, NULL, 2, NULL);
// 修改后:512 words = 2048字节,留足余量
xTaskCreate(Upload_Task, "Upload", 512, NULL, 2, NULL);
第二:把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,把栈溢出加进排查清单的前三位。
它藏的地方,比你想象的深。
矿工下井时带一只金丝雀——金丝雀对瓦斯比人敏感得多,一旦金丝雀倒下了,人就知道该跑了。我们可以借鉴这个思路:在堆栈和全局变量之间的边界上,放一个对溢出极其敏感的“金丝雀变量”,一旦它被踩到,我们就知道堆栈越界了。这个来自工业时代的隐喻,在编译器领域早已落地:GCC 的 -fstack-protector 会在每个函数的栈帧底部放一个随机“金丝雀值”,函数返回前检查它是否被改掉,以此检测栈缓冲区溢出。但编译器的栈保护有一个盲区:它只能管函数调用栈内部的事。全局栈空间的溢出——比如中断嵌套太深、函数调用链太长,把整个堆栈区撑爆了——编译器看不见。我们要做的,是在全局堆栈边界上放上“堆栈溢出缓冲区”,这就是我们的的金丝雀。

浙公网安备 33010602011771号