嵌入式 C 代码规范与防御性编程实践
嵌入式 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 的规范与防御,是一种对”设备背后的人”负责的态度。规范让我们写得出十年后仍可维护的代码,防御让我们写得出的代码经受得住真实世界的考验。希望这篇文章能给你的下一个固件项目带来一点启发。
如果你在实际项目里踩过防御性编程相关的坑,欢迎在评论区交流。