在嵌入式开发中,很多严重的 Bug(比如数组越界、空指针解引用)在测试阶段往往很难复现,直到产品到了客户手里才爆发。
如何让代码学会“自我体检”?如何在 Bug 发生的第一现场将其捕获? 答案就是:断言 (Assert)。
1. 什么是断言?为什么要“自杀”?
断言的核心逻辑非常简单:
“我打赌这个条件一定是真的。如果输了(条件为假),我就立刻报警并死给你看。”
void motor_set_speed(int speed) {
// 我打赌速度一定在 0 到 100 之间
// 如果传进来 200,说明调用者写错了,必须立刻暴露这个问题!
ASSERT(speed >= 0 && speed <= 100);
hardware_set_pwm(speed);
}
为什么不直接用 if 返回错误?
很多人会这样写:
if (speed < 0 || speed > 100) {
return ERROR_PARAM; // 温柔地返回错误
}
区别在于:
-
错误处理 (Error Handling):用于处理**“预期内”**的异常(如网络断开、文件不存在)。这些是可以恢复的。
-
断言 (Assertion):用于捕获**“绝不该发生”**的逻辑错误(如空指针、除数为0、状态机状态非法)。这代表代码有 Bug,必须修复,而不是忽略。
在开发阶段,Fail Fast (快速失败) 远比 Fail Safe (故障掩盖) 重要。你希望 Bug 在你办公桌上立刻崩溃,而不是在客户现场偷偷把错误数据吞下去。
2. 嵌入式里的 Assert 怎么写?
2.1 别用标准库 <assert.h>
标准库的 assert() 默认行为是调用 fprintf(stderr, ...) 然后 abort()。 在单片机上,你没有屏幕,abort() 通常会导致死循环或未定义的复位,你根本不知道死在哪了。
2.2 打造适合 MCU 的 ASSERT
我们需要自定义一个宏,当断言失败时,打印出文件名和行号,然后挂起系统(方便调试器挂上去看)。
// my_debug.h
#ifdef DEBUG
// 调试模式:开启断言
#define ASSERT(expr) \
if (!(expr)) { \
assert_failed(__FILE__, __LINE__); \
}
#else
// 发布模式:断言消失,代码体积 0 负担
#define ASSERT(expr) ((void)0)
#endif
// 断言失败处理函数 (在 .c 中实现)
void assert_failed(char *file, int line);
实现 assert_failed:
void assert_failed(char *file, int line) {
// 1. 关中断 (防止被其他中断打扰现场)
__disable_irq();
// 2. 打印错误信息 (如果有串口)
printf("ASSERT FAILED! File: %s, Line: %d\r\n", file, line);
// 3. 死锁在这里,亮红灯
while(1) {
LED_RED_TOGGLE();
Delay(200);
}
}
ST 公司的做法: STM32 HAL 库中广泛使用的 assert_param 就是这个原理。它极大地降低了查 Bug 的难度。
3. 静态断言 (Static Assert):编译期的守门员
普通的 ASSERT 是在运行时检查的。如果代码没跑到那一行,Bug 就发现不了。 有没有一种办法,在编译阶段就报错?
场景: 你定义了一个结构体,准备通过 Flash 存储。你要求这个结构体的大小必须严格等于 64 字节,多一个少一个都不行。
typedef struct {
uint32_t head;
uint8_t data[58];
uint16_t crc;
} Config_t; // 理论上 4+58+2 = 64
3.1 C11 标准:_Static_assert
如果你的编译器支持 C11(现代 GCC/Keil AC6 都支持):
_Static_assert(sizeof(Config_t) == 64, "Config_t size mismacth!");
如果大小不对,编译直接报错,连固件都生成不出来。这是最安全的防御。
3.2 老土办法 (C89 兼容)
如果编译器很老,可以用这个奇技淫巧:
#define STATIC_ASSERT(expr) typedef char static_assertion[(expr) ? 1 : -1]
原理:如果 expr 为假,数组长度变成 -1,编译器会报错“数组大小为负数”。虽然报错信息难看点,但效果达到了。
4. 防御性编程的哲学:契约式设计
断言的本质是契约 (Design by Contract)。
场景:指针入参
int get_average(int *data, int len) {
// 契约 1:数据源不能为空
ASSERT(data != NULL);
// 契约 2:长度必须合理
ASSERT(len > 0);
int sum = 0;
for(int i=0; i<len; i++) sum += data[i];
return sum / len;
}
加上这两行断言,你实际上是告诉所有调用者:“别给我传空指针,否则后果自负。”
场景:Switch 的 Default
即使你确定 enum 只有 3 种状态,也要加上 default 来捕获“不可能”。
switch(state) {
case IDLE: ... break;
case RUN: ... break;
case STOP: ... break;
default:
// 理论上永远跑不到这里
// 如果跑到了,说明内存被踩坏了,或者状态机跑飞了
ASSERT(0);
break;
}
5. 权衡:Release 版要不要留 Assert?
这是一个极具争议的话题。
-
传统派:定义
NDEBUG宏,Release 版把ASSERT全部优化掉(变为空)。-
理由:节省 Flash 空间,提高运行速度。
-
-
安全派:关键的 Assert 必须保留!
-
理由:如果产品在客户那里遇到严重逻辑错误(如指针乱飞),让看门狗复位或者进入安全模式,总比带着错误数据继续“裸奔”并烧毁硬件要好。
-
折中方案: 保留**“轻量级断言”**。Release 版不打印文件名和行号(省空间),只记录错误码并复位。
6. 最后归纳下
-
Assert 不是错误处理:它用来捕获逻辑 Bug(程序员的锅),而不是运行时错误(环境的锅)。
-
自定义宏:别用标准库,自己写一个能打印 File/Line 并挂起系统的宏。
-
编译期检查:善用
_Static_assert锁死结构体大小和对齐。 -
防御心态:假设调用者都是“猪队友”,假设 Switch 可能会跳到火星去,用 Assert 守住入口和出口。
1969

被折叠的 条评论
为什么被折叠?



