【第12期】C语言的嵌入式特化 (六) —— 断言 (Assert) 与防御性编程

在嵌入式开发中,很多严重的 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. 最后归纳下

  1. Assert 不是错误处理:它用来捕获逻辑 Bug(程序员的锅),而不是运行时错误(环境的锅)。

  2. 自定义宏:别用标准库,自己写一个能打印 File/Line 并挂起系统的宏。

  3. 编译期检查:善用 _Static_assert 锁死结构体大小和对齐。

  4. 防御心态:假设调用者都是“猪队友”,假设 Switch 可能会跳到火星去,用 Assert 守住入口和出口。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值