正文
大家好,我是bug菌~
到了一年一度的公司风向标会议,各种做调研、做方案、做报告,那是忙得一个不可开交,其中各个部门提得最多的还是AI在部门工作中的加持下所预计会带来的收益,但是也是想了下既然大家都提AI那我就写一个关于AI安全相关的主题来展开吧,其实每年都加都提了非常多的方案和所谓的风口,不过都推进得非常缓慢,这会议就当做公司的仪式感吧。
那么这篇文章呢不是谈AI,还是开发中的一些小思考吧,玩C或者C++都离不开对堆栈的理解,那么堆栈究竟要配置多大其实一直也是一个经验性的问题,堆栈小了导致所谓的"爆栈"溢出引发 HardFault、随机死机,栈太大了又会资源增加成本。所以你特别像知道你的嵌入式程序每个人物所需要的最大堆栈是多少,然后在最大堆栈的基础上预留一点点就可以了。
然而大部分的工具都只能分析编译后拿到静态的堆栈使用数据,而无法知道程序运行起来真实的堆栈消耗,那这不就矛盾了吗?
好吧,一步一步来,我们先看看堆栈的静态分析都干了些啥:
1
堆栈静态分析
当我们的嵌入式程序还没有运行的时候,我们能拿到的无非是程序的源码、函数的调用关系、编译后的二进制,当然这里面也包含对堆栈的操作。
有了这些信息的话,静态堆栈分析就能去做一些事情了,最直接的就是单函数栈帧的计算,编译器可以准确计算出每个独立函数的栈帧大小,这部分是完全确定的:
函数内的局部变量、数组、结构体的总大小
函数调用时需要保存的 CPU 寄存器(如 ARM Cortex-M 的 R4-R11 等)
函数参数、返回地址的存储开销
栈对齐所需的填充字节
比如 GCC 编译器提供的-fstack-usage选项,GCC会为每个编译单元(.c/.cpp)生成一个对应的.su文件(Stack Usage 文件)。该文件记录了该编译单元内每一个函数的堆栈帧大小信息,类似于这样的格式:
其实这里的:
static表示函数的堆栈帧大小完全在编译时确定,全部是静态分配的局部变量、保存的寄存器、参数区域等。
dynamic表示函数中使用了运行时动态栈分配,比如alloca或可变长度数组(VLA),因此报告中的数值只包含静态部分,实际运行时会动态增加。
当然前面只是简单的单函数栈帧的分析,相对全面一点是静态调用图的理论栈深估算。
静态分析工具会扫描所有确定的函数调用关系,构建调用树(Call Graph),然后找到最深的那条调用路径,把路径上所有函数的栈帧大小累加起来,得到一个理论上的最大栈深。
比如 IAR、Keil 等 IDE 的静态栈分析功能,就是通过这个逻辑,在 map 文件中输出类似这样的报告:
************************************************************************* *** STACK USAGE *** Call Graph Root Category Max Use Total Use ------------------------ ------- --------- Program entry 288 288 Maximum call chain 288 bytes "__iar_program_start" 4 "_main" 8 "_printf" 8 "__PrintfFullNoMb" 152 "__LdtobFullNoMb" 802
堆栈动态分析
没办法,动态堆栈信息还必须得程序跑起来才能获取,因为静态分析的所有结论,都建立在一个假设上:程序的执行路径、调用关系、触发时机都是编译期可预测的。但在嵌入式系统实际运行的运行过程中,这个前提似乎很不全面。
1、动态调用:编译期根本不知道你会调用谁
嵌入式代码中充满了大量的间接调用:
函数指针:比如状态机的跳转表、驱动的回调函数,编译期根本不知道这个指针最终会指向哪个函数;
回调函数:比如外设中断的回调、RTOS 的定时器回调,调用关系是运行时注册的;
这就导致前面我们分析的静态分析的调用图没法一层一层往下调用,这也就是我常常说的“断链”,当然如果编译器足够聪明,把所有路径的栈开销都加起来,找到最深的栈,否则就会导致漏算,bug菌觉得当你程序比较大的时候,编译器去跟你这样分析也是非常吃力的。
2、谈到堆栈必定要聊递归调用
因为递归调用尝尝是导致爆栈的元凶,因为递归的深度完全取决于输入数据:比如快速排序的递归深度,取决于输入数组的有序程度;通常静态分析都是直接忽略递归的,要么你手动指定一个最大递归深度。
3、异常堆栈的隐形栈开销
大家都知道中断是异步的!它随时可能打断当前正在执行的代码,不管你现在的函数调用到了哪一层。
比如说我们的主程序正在执行最深的调用链,已经用了 2KB 的栈空间;这时候一个高优先级中断触发了,CPU 立刻跳转到中断服务程序;中断服务程序自己又调用了 FFT 函数,又用了 1.5KB 的栈;如果你的总栈空间只有 3KB,直接就溢出了~
所以中断随时可能插队,把两个栈开销叠加起来,这个叠加效应只有运行时才能测到。再来个中断嵌套:如果你的系统支持中断嵌套,那可能一个中断里又来另一个中断,栈开销会层层叠加。
4、RTOS多任务更复杂
大家都知道RTOS任务的栈都是独立的、动态的,高优先级任务随时可以抢占低优先级任务;而且中断的栈开销,是随机扣在某个任务的栈上的!![]()
总的来说动态堆栈就还是在程序跑起来的复杂工况下去测试吧。
3
运行中的堆栈检测
堆栈的静态分析还是有一些局限,进行堆栈的动态分析也就是我们常说的“栈水印“(High Watermark)方法进行检测:
以前的文章有写,再翻一翻:
【进阶】三种" 堆栈溢出检测 "方法,请拿去吹牛!
一种省内存的MCU堆栈溢出检测方法
【进阶】" 堆栈溢出 ",也就这么回事!
大致就是:做栈标记->暴力测试->看水位,比如 IAR 的 C-SPY 调试器、FreeRTOS 的uxTaskGetStackHighWaterMark()函数,也基本都是这个原理。
bug菌做一些稳定性、可靠性要求较高的产品,基本上都是:
1、先用静态分析做第一轮评估,设置初始的堆栈大小;
2、然后各种工况下做长时间的压力测试,用动态分析拿到真实的栈峰值;最后
3、最后预留至少 20%~40% 的安全余量,确保极端情况下也不会溢出。
没错就是这么稳~
最后
好了,今天就跟大家分享这么多了,如果你觉得有所收获,一定记得点个赞,标个星~
唯一、永久、免费分享嵌入式技术知识平台~
推荐专辑 点击蓝色字体即可跳转
☞MCU进阶专辑
☞嵌入式C语言进阶专辑
☞“bug说”专辑
☞专辑|Linux应用程序编程大全
☞专辑|学点网络知识
☞专辑|手撕C语言
☞专辑|手撕C++语言
☞专辑|经验分享
☞专辑|电能控制技术
☞专辑 | 从单片机到Linux