第三十章:综合案例实战——一个完整的防御式编程项目

终于到了最后一章。说实话,前面二十九章我们聊了很多防御式编程的“零件”——断言、错误码、日志、边界检查……但零件堆在一起,不等于一台能跑的机器。

这一章,我们就来组装一台完整的机器。我会带你走一遍真实项目的流程:从需求分析开始,到代码实现,再到审查和持续集成。你想想看,这才是防御式编程真正落地的地方。

30.1 项目背景与需求:一个嵌入式传感器数据采集器

假设我们要做一个工业级的温度传感器采集模块。它通过 I2C 总线读取传感器数据,然后通过 UART 上报给上位机。需求很简单,但坑很多。

核心需求:

  • 每 100ms 采集一次温度数据
  • 数据范围:-40°C 到 +125°C
  • 支持 3 次重试,如果连续 3 次读取失败,上报错误码
  • UART 输出格式:ID,TEMP,STATUS\n
  • 系统必须 7×24 小时运行,不能死机

嗯,这里要注意:需求里没有写“如果传感器返回 0xFFFF 怎么办”,也没有写“UART 缓冲区满了怎么处理”。这些,就是防御式编程要补的窟窿。

30.2 从需求到实现的防御策略

我个人习惯,拿到需求后先画一张“防御地图”。不是画架构图,而是画“哪里会死”。

防御策略清单:
  • 输入防御:I2C 数据必须校验 CRC,无效数据直接丢弃
  • 状态防御:每个函数执行前检查模块状态(是否初始化、是否忙)
  • 边界防御:温度值超出 [-40, 125] 时,按上限/下限截断并记录日志
  • 资源防御:UART 发送缓冲区满时,丢弃旧数据,保证新数据不丢
  • 时序防御:采集超时(超过 150ms)时,强制复位 I2C 总线

我曾经在一个项目里,就是因为没做“时序防御”,结果 I2C 总线卡死,整机复位。从那以后,我每个外设都加看门狗超时。

30.3 核心代码实现(带防御)

下面这段代码是采集函数的核心。注意看,每一处防御都不是多余的。

#define TEMP_MIN    (-40.0f)
#define TEMP_MAX    (125.0f)
#define RETRY_MAX   3
#define I2C_TIMEOUT_MS 150

typedef enum {
    STATUS_OK = 0,
    STATUS_ERR_I2C,
    STATUS_ERR_CRC,
    STATUS_ERR_TIMEOUT,
    STATUS_ERR_RANGE
} sensor_status_t;

sensor_status_t sensor_read_temperature(float *temp_out) {
    // 防御1:空指针检查
    if (temp_out == NULL) {
        log_error("temp_out is NULL");
        return STATUS_ERR_RANGE;  // 用合适的错误码
    }

    uint8_t raw_data[4] = {0};
    int retry = 0;
    sensor_status_t status = STATUS_ERR_I2C;

    // 防御2:重试机制
    while (retry < RETRY_MAX) {
        status = i2c_read(0x48, raw_data, 4, I2C_TIMEOUT_MS);
        if (status == STATUS_OK) {
            break;
        }
        retry++;
        log_warn("I2C read retry %d", retry);
    }

    if (status != STATUS_OK) {
        log_error("I2C read failed after %d retries", RETRY_MAX);
        return STATUS_ERR_I2C;
    }

    // 防御3:CRC校验
    if (!crc8_check(raw_data, 4)) {
        log_error("CRC mismatch on sensor data");
        return STATUS_ERR_CRC;
    }

    // 防御4:数据范围截断
    float temp = (float)((raw_data[0] << 8) | raw_data[1]) * 0.125f;
    if (temp < TEMP_MIN) {
        temp = TEMP_MIN;
        log_warn("Temperature clipped to min: %.2f", temp);
    } else if (temp > TEMP_MAX) {
        temp = TEMP_MAX;
        log_warn("Temperature clipped to max: %.2f", temp);
    }

    *temp_out = temp;
    return STATUS_OK;
}

你看,每一层防御都对应一个可能的“死法”。空指针、I2C 失败、CRC 错误、数据越界——全堵上了。

30.4 代码审查与改进:别人怎么看你的代码?

代码写完了,不代表就安全了。我建议你找个同事做一次正式的代码审查。我自己做审查时,会重点关注这几个点:

审查项 常见问题 防御式改进
错误码处理 调用方忽略返回值 强制检查返回值,用 __attribute__((warn_unused_result))
缓冲区溢出 UART 发送时未检查剩余空间 使用环形缓冲区 + 水位标记
全局变量 多任务环境下未加锁 使用原子操作或关中断保护
日志级别 所有错误都打 ERROR 区分 WARN(可恢复)和 ERROR(不可恢复)

我记得有一次审查,同事发现我用了 memcpy 但没有检查源地址和目标地址是否重叠。嗯,那确实是个隐患。从那以后,我所有内存操作都改用 memmove

30.5 持续集成中的防御检查

代码审查是人工的,但有些检查可以交给机器。持续集成(CI)里,我至少会加这三道防线:

  1. 静态分析:cppcheckclang-tidy 检查空指针、越界、未初始化变量。我习惯在 CI 脚本里加 --error-exitcode=1,让任何警告都导致构建失败。
  2. 单元测试:每个防御分支都要有测试用例。比如“传入 NULL 指针是否返回错误码”、“温度超出范围是否被截断”。覆盖率目标:分支覆盖率 ≥ 90%。
  3. 边界值测试:-41.0126.00xFFFF 这些值去撞你的防御代码。如果没触发截断或错误,说明防御有漏洞。
小技巧:在 CI 里加一个“防御检查清单”脚本。每次提交代码,自动扫描所有函数入口是否有参数校验。我写过这样一个脚本,帮团队抓出过 30 多个漏检的入口函数。

30.6 知识体系总览:一张图看懂防御式编程

下面这张 SVG 图,是我对防御式编程整个知识体系的总结。从需求到部署,每一层都有对应的防御策略。

防御式编程知识体系总览 需求层:识别所有“可能出错”的场景 设计层:防御策略制定(输入/状态/边界/资源/时序) 实现层:断言、错误码、日志、截断、重试、CRC 审查层:人工审查 + 静态分析 + 边界测试 持续集成层:自动化防御检查(cppcheck/单元测试/覆盖率) 防御层级
警告:不要以为 CI 过了就万事大吉。CI 只能检查你写出来的防御代码,检查不了你没写的。比如,你忘了检查 I2C 从设备是否在线,CI 不会告诉你。防御式编程的核心,是“人机结合”——机器做重复性检查,人做创造性思考。

30.7 写在最后:防御是一种习惯

好了,三十章的内容,到这里就结束了。你可能会问:学了这么多,到底有没有用?

我跟你讲个真事。有一次,我们团队的产品在客户现场跑了三个月,突然某天上报了一组异常数据:温度 -273.15°C。客户差点以为是设备坏了。后来一查,是传感器在极端环境下输出了一个非法值。但因为我们的代码做了范围截断和日志记录,问题在 5 分钟内就定位到了。

这就是防御式编程的价值。它不是让你写出“不会出错的代码”,而是让你写出“出错后还能优雅处理”的代码。说白了,防御式编程不是技术,是习惯。

希望你在未来的每一个项目里,都能带着这种习惯去写代码。哪怕只是多写一行 if (ptr == NULL) return;,都可能救你一命。


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