阅读约 10 分钟 | 适合:写 STM32/ESP32/RTOS 项目,已经被 Flash 爆仓、栈溢出、野生
printf折磨过的嵌入式工程师
作者:困困困困好困啊
前段时间我接了一个老项目,STM32F407,Flash 1MB,RAM 192KB,听起来很宽裕。结果加了一个日志模块之后,板子开始“随机死”:有时候启动就 HardFault,有时候跑半小时才挂。
第一反应当然是怀疑中断、DMA、野指针。J-Link 连上去看了半天,最后发现原因非常朴素:
.bss 里被同事塞了一个 80KB 的全局缓存;printf("debug...");这类问题最恶心的地方是:它不是语法错误,编译器不会救你。 但是它又完全可以在提交前用脚本拦住。
所以我后来给项目加了一个很土但很好用的工具:固件体检脚本。每次编译后自动检查 Flash/RAM 占用、危险符号、任务栈配置、调试打印残留。今天这篇就从零写一个可运行版本。
嵌入式项目里,最值得自动检查的不是“代码风格”,而是这些会让板子半夜死机的东西:
printf、sprintf、malloc 在小 MCU 上不是不能用,但必须知道它们还在。这些检查不用上什么复杂平台,一个 Python 脚本就能做掉 80%。
为了让你不用真的拿一块板子,我这里用一段模拟的 arm-none-eabi-size 输出和 map 片段演示。实际项目里只要把输入替换成真实编译产物就行。
先创建文件 firmware_check.py:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re
import sys
from pathlib import Path
FLASH_LIMIT = 1024 * 1024 # 1MB Flash
RAM_LIMIT = 192 * 1024 # 192KB RAM
FLASH_WARN = 0.90
RAM_WARN = 0.85
SIZE_TEXT = """
text data bss dec hex filename
812000 12288 91000 915288 df798 build/app.elf
"""
MAP_TEXT = """
.bss.big_log_buffer
0x20001000 0x14000 build/logger.o
.bss.camera_frame_cache
0x20015000 0x8000 build/camera.o
.text.printf 0x08012340 0x120 libc_nano.a(lib_a-printf.o)
.text.sprintf 0x08013000 0x80 libc_nano.a(lib_a-sprintf.o)
.text.vTaskStartScheduler
0x08020000 0x300 freertos/tasks.o
"""
SOURCE_TEXT = """
#define UART_TASK_STACK 256
#define SENSOR_TASK_STACK 384
#define UI_TASK_STACK 1024
void debug_dump(void) {
printf("adc=%d\\n", 123);
}
"""
def parse_size(size_text: str):
lines = [line.strip() for line in size_text.splitlines() if line.strip()]
if len(lines) < 2:
raise ValueError("size 输出格式不对")
nums = re.findall(r"\d+", lines[1])
if len(nums) < 3:
raise ValueError("没有解析到 text/data/bss")
text, data, bss = map(int, nums[:3])
flash_used = text + data
ram_used = data + bss
return flash_used, ram_used
def human(n: int):
return f"{n / 1024:.1f}KB"
def check_memory(size_text: str):
flash_used, ram_used = parse_size(size_text)
flash_ratio = flash_used / FLASH_LIMIT
ram_ratio = ram_used / RAM_LIMIT
print("== 内存占用 ==")
print(f"Flash: {human(flash_used)} / {human(FLASH_LIMIT)} ({flash_ratio:.1%})")
print(f"RAM : {human(ram_used)} / {human(RAM_LIMIT)} ({ram_ratio:.1%})")
failed = False
if flash_ratio > FLASH_WARN:
print("[WARN] Flash 使用率超过 90%,后面加功能会很难受")
if ram_ratio > RAM_WARN:
print("[FAIL] RAM 使用率超过 85%,建议立刻排查全局变量和任务栈")
failed = True
return failed
def check_large_bss(map_text: str, threshold=32 * 1024):
print("\n== 大块 BSS 检查 ==")
failed = False
pattern = re.compile(r"(\.bss\.[\w_]+)\s+0x[0-9a-fA-F]+\s+0x([0-9a-fA-F]+)\s+(.+)")
for name, size_hex, obj in pattern.findall(map_text.replace("\n ", " ")):
size = int(size_hex, 16)
if size >= threshold:
print(f"[WARN] {name} 占用 {human(size)},来自 {obj.strip()}")
failed = True
if not failed:
print("OK,没有发现超过阈值的大 BSS")
return failed
def check_danger_symbols(map_text: str):
print("\n== 危险符号检查 ==")
danger = ["printf", "sprintf", "malloc", "free"]
failed = False
for sym in danger:
if re.search(rf"\b{sym}\b", map_text):
print(f"[WARN] 链接结果里发现 {sym},确认是否允许进入固件")
failed = True
if not failed:
print("OK,没有发现常见危险符号")
return failed
def check_task_stack(source_text: str, min_stack=512):
print("\n== FreeRTOS 任务栈检查 ==")
failed = False
pattern = re.compile(r"#define\s+(\w+_STACK)\s+(\d+)")
for name, value in pattern.findall(source_text):
value = int(value)
if value < min_stack:
print(f"[WARN] {name} = {value},低于建议值 {min_stack}")
failed = True
if not failed:
print("OK,任务栈宏看起来正常")
return failed
def main():
failed = False
failed |= check_memory(SIZE_TEXT)
failed |= check_large_bss(MAP_TEXT)
failed |= check_danger_symbols(MAP_TEXT)
failed |= check_task_stack(SOURCE_TEXT)
print("\n== 结论 ==")
if failed:
print("固件体检未通过:建议处理 WARN/FAIL 后再提交。")
return 1
print("固件体检通过。")
return 0
if __name__ == "__main__":
sys.exit(main())运行:
python firmware_check.py你会看到类似输出:
== 内存占用 ==
Flash: 804.9KB / 1024.0KB (78.6%)
RAM : 100.9KB / 192.0KB (52.6%)
== 大块 BSS 检查 ==
[WARN] .bss.big_log_buffer 占用 80.0KB,来自 build/logger.o
[WARN] .bss.camera_frame_cache 占用 32.0KB,来自 build/camera.o
== 危险符号检查 ==
[WARN] 链接结果里发现 printf,确认是否允许进入固件
[WARN] 链接结果里发现 sprintf,确认是否允许进入固件
== FreeRTOS 任务栈检查 ==
[WARN] UART_TASK_STACK = 256,低于建议值 512
[WARN] SENSOR_TASK_STACK = 384,低于建议值 512
== 结论 ==
固件体检未通过:建议处理 WARN/FAIL 后再提交。这玩意不高级,但非常有效。它把“上线后才发现”的问题,提前变成“编译后立刻发现”。
实际项目里不用手写 SIZE_TEXT 和 MAP_TEXT,直接从编译产物读。
以 GCC 工程为例,Makefile 里一般会生成:
build/app.elf: $(OBJS)
$(CC) $(OBJS) -Wl,-Map=build/app.map -o build/app.elf
arm-none-eabi-size build/app.elf我们改一下脚本入口:
import subprocess
from pathlib import Path
elf = Path("build/app.elf")
map_file = Path("build/app.map")
size_text = subprocess.check_output(
["arm-none-eabi-size", str(elf)],
text=True,
encoding="utf-8"
)
map_text = map_file.read_text(encoding="utf-8", errors="ignore")然后在 Makefile 里加一行:
check: build/app.elf
python tools/firmware_check.py以后每次提交前跑:
make -j8 && make check更狠一点,直接把它挂到 CI 或 git hook。比如 .git/hooks/pre-commit:
#!/bin/sh
make -j8 || exit 1
make check || exit 1这样只要体检脚本发现问题,提交就会被拦住。别嫌烦,它拦你一次,可能就少一次周末加班。
这里说点工程经验,不是标准答案。
很多人觉得 Flash 还剩 10% 很安全,其实不一定。产品后期一定会加功能、加日志、加协议字段。你现在 92%,下个月 OTA、字库、加密库一来,马上爆。
我的习惯是:
RAM 比 Flash 更危险,因为它不是静态占用完就结束。还有:
arm-none-eabi-size 看到的 RAM 只是 .data + .bss,看不到运行时栈峰值。所以静态占用已经 85% 的项目,运行时大概率在悬崖边。
我不是那种“嵌入式绝对不能用 printf”的人。调试阶段它很好用,问题是你要知道它有没有进入最终固件。
尤其是 float printf,一不小心就把 newlib 的一大坨东西拖进来,Flash 暴涨十几 KB。更惨的是在中断里打印,串口阻塞一下,时序直接变形。
我的做法是:Release 版本允许 log_info 这种封装,但禁止裸 printf/sprintf。需要格式化字符串就用 snprintf,并且限制长度。
很多团队的检查脚本看起来很努力,输出一堆 WARNING,最后退出码还是 0。CI 看到 0 就认为通过,时间久了大家也不看日志,这个脚本就变成摆设。
关键问题要返回非 0:
if failed:
sys.exit(1)让机器替你当坏人,不要靠人肉自觉。
不同芯片阈值不一样。STM32F103 的 20KB RAM 和 STM32H743 的 1MB RAM,肯定不能用同一套规则。
我建议把阈值放到配置文件里,例如 firmware_check.json:
{
"flash_limit": 1048576,
"ram_limit": 196608,
"flash_warn": 0.9,
"ram_warn": 0.85,
"bss_large_threshold": 32768
}脚本读取配置,项目之间就能复用。
GCC、ARMCC、IAR 的 map 文件格式都不一样。不要指望一个正则吃遍所有编译器。我的经验是先服务当前团队最常用的 GCC,把最痛的 3 个检查做稳,再慢慢扩展。
脚本越聪明,越容易误报;误报多了,大家就想关掉它。第一版建议只检查确定性强的东西:内存占用、危险符号、大对象、栈宏。别一上来就做复杂静态分析。
嵌入式工程师最怕的不是 bug,而是那种“昨晚还好好的,今天客户现场随机死”的 bug。固件体检脚本解决不了所有问题,但它能把一批低级又高代价的问题提前暴露出来。
我的建议很简单:下次你维护一个老项目时,不要急着重构架构,先加三个东西:
这套东西半天能搭起来,但它救回来的时间可能是好几个周末。
困困困困好困啊 | 嵌入式工程师,用最笨的办法讲最硬的知识。