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 防御性编程的完整流程

下面这张图,是我多年总结的防御性编程流程。你可以把它贴在工位上。

防御性编程核心流程 函数入口 参数校验(空指针、范围、类型) 边界检查(数组、缓冲区、循环) 状态校验(硬件状态、资源可用性) 执行核心逻辑 校验失败处理 ● 返回错误码(推荐) ● 记录日志 ● 触发看门狗复位 —— ● 不要用assert处理 ● 不要直接忽略 ● 不要静默修复

这个流程我用了很多年。每次写一个新函数,我都会问自己:如果有人故意传坏参数进来,我的代码扛得住吗?

18.5 实战中的避坑指南

讲几个我亲身踩过的坑,希望能帮你省点时间。

坑1:assert和参数校验混用
我曾经在一个项目中看到这样的代码:
assert(data != NULL);
if (data == NULL) return RET_ERR;
这是典型的“双重保险”思维,但其实是多余的。开发阶段assert会帮你抓住问题,发布阶段参数校验会兜底。两个都写,反而让代码变得冗余。我建议:开发阶段用assert,生产阶段用参数校验,两者选其一即可。
坑2:边界检查只做一半
比如你检查了数组的下界,却忘了上界:
if (index >= 0) { arr[index] = value; }
这等于没检查。正确的写法是:
if (index >= 0 && index < ARRAY_SIZE) { arr[index] = value; }
记住:边界检查必须是完整的区间
坑3:忽略字符串的结尾符
C语言的字符串以'\0'结尾。如果你分配了10字节的缓冲区,实际只能存9个字符。我曾经见过一个同事用strcpy复制字符串,结果源字符串长度刚好等于缓冲区大小,结尾符没地方放,直接写到了后面的内存里。嗯,那个bug查了整整一天。
建议:永远用strncpysnprintf这类带长度限制的函数

18.6 总结

防御性编程不是“过度设计”,而是职业习惯。它不会让你的代码变慢多少,但能让你少熬很多夜。

记住三个要点:

  • 开发阶段:多用assert,快速暴露逻辑错误
  • 生产阶段:做好参数校验,返回明确的错误码
  • 任何时候:边界检查不能省,数组、缓冲区、循环都要照顾到

好了,今天就聊到这里。下次你写函数的时候,不妨试试先写参数校验,再写核心逻辑。你会发现,代码的质量,往往就体现在这些细节里


公众号:蓝海资料掘金营,微信deep3321