18、断言与防御性编程:assert使用、参数校验、边界检查
各位同学,今天我们来聊一个看似简单、实则极其重要的话题——防御性编程。
我做了十几年嵌入式开发,见过太多因为“想当然”而翻车的案例。你写了一个函数,觉得调用者肯定会传合法参数进来,结果呢?某个深夜,产品在现场跑飞了,查了三天,最后发现是一个空指针导致的。嗯,这种经历,一次就够了。
防御性编程,说白了就是别信任何人——包括你自己。你写的代码,要能扛得住各种“不按套路出牌”的输入。今天我们就从三个层面来聊:assert断言、参数校验、边界检查。
18.1 assert:开发阶段的“安全气囊”
assert是C标准库提供的一个宏,定义在<assert.h>中。它的作用很简单:如果条件为假,就打印错误信息并终止程序。
我个人习惯,在开发阶段把assert用得很频繁。它就像安全气囊——平时不触发,一旦触发,说明出大事了。
#include <assert.h>
void set_gpio_mode(int pin, int mode) {
assert(pin >= 0 && pin < 16); // 引脚号必须在0~15之间
assert(mode == OUTPUT || mode == INPUT); // 模式必须合法
// 实际配置代码...
}
你看,这两行assert就把非法参数挡在了门外。如果有人在调用时传了pin=20,程序会立刻崩溃并告诉你:Assertion failed: pin >= 0 && pin < 16, file gpio.c, line 12。
assert只在#define NDEBUG未定义时生效。发布版本中,通常会在编译时定义NDEBUG,此时所有assert都会被移除。所以不要用assert来做运行时错误处理,它只适合开发阶段捕捉逻辑错误。
18.2 参数校验:生产环境的“守门员”
既然assert在发布版中会被去掉,那生产环境怎么办?答案是:显式的参数校验。
我建议每个对外接口函数,开头都要做参数校验。这不是啰嗦,这是职业素养。
#include <stdbool.h>
typedef enum {
RET_OK = 0,
RET_ERR_INVALID_PARAM,
RET_ERR_NULL_PTR,
RET_ERR_TIMEOUT
} ret_code_t;
ret_code_t uart_send_data(uint8_t *data, uint32_t len) {
// 参数校验
if (data == NULL) {
return RET_ERR_NULL_PTR;
}
if (len == 0 || len > MAX_UART_PACKET_SIZE) {
return RET_ERR_INVALID_PARAM;
}
// 实际发送逻辑...
return RET_OK;
}
这样做的好处很明显:调用者能立刻知道哪里出了问题,而不是等到程序跑飞了才去猜。我曾经接手过一个项目,里面所有函数都不做参数校验,出了问题只能靠串口打印慢慢排查。那感觉,就像在黑暗里找一只黑猫。
if (条件) return 错误码;if (条件) return 错误码;// 正常逻辑...
18.3 边界检查:数组越界的“防火墙”
数组越界是C语言中最常见的bug之一。我敢说,每个C程序员都踩过这个坑。我自己就曾经因为一个for循环的边界条件写错,导致堆栈被破坏,程序在某个随机时刻崩溃。查了整整两天。
边界检查的核心原则:永远不要假设数组长度是“足够”的。
// 错误示范:没有边界检查
void copy_data(uint8_t *src, uint8_t *dst, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
dst[i] = src[i]; // 如果dst空间不够,直接写穿
}
}
// 正确示范:带边界检查
ret_code_t copy_data_safe(uint8_t *src, uint8_t *dst,
uint32_t src_len, uint32_t dst_len) {
if (src == NULL || dst == NULL) {
return RET_ERR_NULL_PTR;
}
if (src_len > dst_len) {
return RET_ERR_INVALID_PARAM; // 源比目标还大,不行
}
for (uint32_t i = 0; i < src_len; i++) {
dst[i] = src[i];
}
return RET_OK;
}
你可能会说:“这样写是不是太啰嗦了?” 嗯,我理解。但你要想想,一个边界检查能省掉你多少个通宵。这笔账,划得来。
18.4 防御性编程的完整流程
下面这张图,是我多年总结的防御性编程流程。你可以把它贴在工位上。
这个流程我用了很多年。每次写一个新函数,我都会问自己:如果有人故意传坏参数进来,我的代码扛得住吗?
18.5 实战中的避坑指南
讲几个我亲身踩过的坑,希望能帮你省点时间。
我曾经在一个项目中看到这样的代码:
assert(data != NULL);if (data == NULL) return RET_ERR;这是典型的“双重保险”思维,但其实是多余的。开发阶段
assert会帮你抓住问题,发布阶段参数校验会兜底。两个都写,反而让代码变得冗余。我建议:开发阶段用assert,生产阶段用参数校验,两者选其一即可。
比如你检查了数组的下界,却忘了上界:
if (index >= 0) { arr[index] = value; }这等于没检查。正确的写法是:
if (index >= 0 && index < ARRAY_SIZE) { arr[index] = value; }记住:边界检查必须是完整的区间。
C语言的字符串以
'\0'结尾。如果你分配了10字节的缓冲区,实际只能存9个字符。我曾经见过一个同事用strcpy复制字符串,结果源字符串长度刚好等于缓冲区大小,结尾符没地方放,直接写到了后面的内存里。嗯,那个bug查了整整一天。建议:永远用
strncpy或snprintf这类带长度限制的函数。
18.6 总结
防御性编程不是“过度设计”,而是职业习惯。它不会让你的代码变慢多少,但能让你少熬很多夜。
记住三个要点:
- 开发阶段:多用
assert,快速暴露逻辑错误 - 生产阶段:做好参数校验,返回明确的错误码
- 任何时候:边界检查不能省,数组、缓冲区、循环都要照顾到
好了,今天就聊到这里。下次你写函数的时候,不妨试试先写参数校验,再写核心逻辑。你会发现,代码的质量,往往就体现在这些细节里。
公众号:蓝海资料掘金营,微信deep3321