嵌入式 C 代码规范与防御性编程实践

2026年8月30日 0 By admin

嵌入式 C 代码规范与防御性编程实践

嵌入式开发里有一个流传很广的说法:“能用 C 写出能跑的程序不算本事,能写出在严苛环境下十年不崩、还能被人看懂的 C 才算本事。” 作为嵌入式软件工程师,我们面对的往往不是一次性脚本,而是要在电磁干扰、极端温度、资源受限的单片机上连续运行数年的固件。今天这篇不谈某个具体外设,聊聊更底层、也更容易被忽视的东西——代码规范与防御性编程。

为什么嵌入式比桌面更需要”防御”

桌面程序崩溃了,大不了重启一次;嵌入式设备可能被放在变电站、工厂产线,甚至病人床边,重启一次意味着停机、意味着成本,甚至意味着安全风险。同时,嵌入式系统的资源极度受限:几 KB 的 RAM、几十 KB 的 Flash,外加没有操作系统兜底的裸机环境。任何一个野指针、一次数组越界、一次整数溢出,都可能在数月后才以幽灵般的偶发故障形式暴露出来。

因此,嵌入式 C 的编码规范不是”洁癖”,而是工程可靠性的第一道防线。接下来我从几个实战维度展开。

一、整数类型:先想清楚范围再写代码

嵌入式平台最经典的坑之一,就是 int 的宽度不固定。同一份代码在 8 位 AVR 上 int 是 16 位,到 32 位 ARM 上又是 32 位。避免歧义的第一原则是显式使用 stdint.h 中的定宽类型

#include <stdint.h>

/* 不推荐:宽度随平台变化 */
unsigned int tick_count;

/* 推荐:意图明确 */
uint32_t tick_count;   /* 毫秒级系统节拍,32 位 */
uint16_t adc_raw;      /* ADC 原始值,12 位 */
int8_t    temp_offset; /* 温度偏移,可能有负值 */

另一个高频隐患是整数溢出。看一个经典的反例:

/* 隐患:delay_ms 传入较大值时,tmp 会溢出 */
void delay_until(uint32_t delay_ms)
{
    uint16_t tmp = base_tick + delay_ms / 1000;
    while (current_tick < tmp) { /* ... */ }
}

如果 base_tick 已经接近 uint16_t 上限,相加就会发生回绕,导致延时逻辑完全失效。正确的写法是避免中间变量截断,并用无符号回绕语义(这是 C 标准保证的)来安全判断:

void delay_until(uint32_t delay_ms)
{
    uint32_t target = base_tick + delay_ms;
    while ((uint32_t)(current_tick - base_tick) < delay_ms) { /* ... */ }
}

二、枚举与常量:别让”魔法数字”散落代码

硬编码数字是维护者的噩梦。三个月后你回头看 if (status == 3),一定想不起 3 代表什么。用枚举或宏把语义固定下来:

typedef enum {
    SENSOR_OK         = 0x00,
    SENSOR_NOT_READY  = 0x01,
    SENSOR_OVERRANGE  = 0x02,
    SENSOR_CRC_ERROR  = 0x04,
} sensor_status_t;

sensor_status_t read_sensor(uint16_t *out);

顺便提一句,嵌入式里经常用位域/掩码来表示可组合的状态。此时枚举值应当取 2 的幂,方便用 | 组合、用 & 判断:

#define FLAG_TX_DONE   (1u << 0)
#define FLAG_RX_READY  (1u << 1)
#define FLAG_TIMEOUT   (1u << 3)

uint8_t flags;
if (flags & FLAG_TX_DONE) { /* ... */ }

三、防御式编程:对输入永远保持怀疑

很多固件 Bug 的根源,是代码”假设输入一定是合法的”。防御式编程的核心心法只有一句话——不信任任何函数边界之外的数据

1. 参数合法性检查

对外部模块暴露的接口,尤其是可能被中断或任务切换扰动的函数,头一件事就是校验参数:

#define ARRAY_SIZE(x)  (sizeof(x) / sizeof((x)[0]))

int uart_send_buffer(const uint8_t *buf, uint16_t len)
{
    if (buf == NULL || len == 0) {
        return -1;   /* 参数非法,直接拒绝 */
    }
    /* ... 实际发送逻辑 ... */
    return 0;
}

2. 数组越界防护

越界是 C 里最隐蔽、危害最大的错误之一。除了检查下标,还要注意在数组末尾留哨兵或坚持”长度+指针”成对传递,而不是传一个裸指针幻想函数知道自己能读多远:

/* 反例:函数无法知道 buf 的真实容量 */
void fill_pattern(uint8_t *buf);

/* 正例:容量显式传入,内部做边界校验 */
void fill_pattern(uint8_t *buf, uint16_t capacity)
{
    if (capacity < 16) return;
    for (uint16_t i = 0; i < 16; i++) {
        buf[i] = (uint8_t)(i * 0x11);
    }
}

3. 资源池保护

动态内存(malloc)在资源受限的 MCU 上往往是定时炸弹:碎片化、失败未处理、泄漏。很多嵌入式项目干脆禁用动态内存,改用静态分配或固定大小的内存池:

#define MSG_POOL_SIZE  8
static msg_t msg_pool[MSG_POOL_SIZE];
static uint8_t pool_used = 0;

msg_t *msg_alloc(void)
{
    if (pool_used >= MSG_POOL_SIZE) {
        return NULL;   /* 池耗尽,调用方必须处理 */
    }
    return &msg_pool[pool_used++];
}

即使非用 malloc 不可,也务必检查返回值,并配套一个统一的错误处理路径。

四、中断与共享变量:volatile 与临界区

嵌入式里相当一部分”玄学 Bug”来自中断与主循环共享变量。这里有两个常见误区。

误区一:以为 volatile 能解决并发问题。 volatile 只是告诉编译器”这个变量可能被外部改变,每次都要从内存重新读”,它不能保证操作的原子性。对 32 位系统上的 64 位变量,或需要”读-改-写”的复合操作,仍要配合临界区:

volatile uint32_t sys_tick;   /* 中断里累加,主循环读取 */

/* 读-改-写操作需要关中断保护 */
void critical_increment(void)
{
    __disable_irq();
    sys_tick += 1;
    __enable_irq();
}

误区二:在主循环里做 64 位变量自增却不加保护。 即使中断只改了低 32 位,主循环读到的也可能是一个”撕裂”的值。要么用原子操作,要么干脆用 32 位节拍 + 软件扩展,要么把共享数据设计成单写多读的简单类型。

一个实用的建议是:明确每个共享变量的”读写方向”,并用注释标注,比如:

/* 只由 TIM2 中断写,主循环只读,32 位原子访问安全 */
volatile uint32_t g_hall_count;

五、错误处理:别把错误”吞掉”

防御式编程的另一半,是出错之后要有明确的处理路径,而不是 return 0; 假装一切正常。常见做法是用返回值(或 errno 风格)向上层传递错误码,并形成分级策略:

typedef enum {
    ERR_NONE = 0,
    ERR_PARAM,
    ERR_BUSY,
    ERR_TIMEOUT,
    ERR_IO,
    ERR_FATAL,
} err_code_t;

对致命错误(如看门狗喂不进去、关键外设初始化失败),宁可进入一个明确的安全停机状态(safe state),也不要继续带病运行:

void fatal_error_handler(err_code_t code)
{
    /* 记录错误码(可存到断电不丢失区域) */
    nvm_store_error(code);
    /* 进入安全态:关输出、停电机、常亮报警灯 */
    hal_enter_safe_state();
    while (1) {
        hal_toggle_led(LED_FAULT);
        hal_delay_ms(200);
    }
}

六、可读性即可靠性

最后,规范终究服务于”人”。几条对我最有效的实践:

  • 命名一致:用统一的风格区分类型(_t 后缀)、模块前缀(uart_adc_)、全局变量(g_)。
  • 一个函数只做一件事,长度控制在可读范围内。
  • 注释写”为什么”而非”做了什么”。代码本身已经说明了做什么,注释应解释那些反直觉的设计决策:
/* AD7124 上电后需至少 500us 才允许 SPI 访问,
   此处等待 1ms 留足余量,见数据手册 Rev.C 第 22 页 */
hal_delay_ms(1);
  • 统一格式化,用 clang-format 或 EditorConfig 固化风格,减少代码评审时的无谓争论。

写在最后

防御性编程不是写一堆臃肿的 if 判断让代码变得啰嗦,而是在关键边界上花心思:对外接口的参数、共享变量的并发、危险操作的返回值、数值运算的范围。这些地方多一道检查,往往能省下几个月的现场排障。

说到底,嵌入式 C 的规范与防御,是一种对”设备背后的人”负责的态度。规范让我们写得出十年后仍可维护的代码,防御让我们写得出的代码经受得住真实世界的考验。希望这篇文章能给你的下一个固件项目带来一点启发。


如果你在实际项目里踩过防御性编程相关的坑,欢迎在评论区交流。