嵌入式 C 语言检查清单
系统整理嵌入式 C 中的数据类型、位操作、指针、volatile、中断、状态机、模块接口和可测试性。
本页目录
嵌入式 C 的核心特点
嵌入式 C 与普通应用程序使用同一种语言,但运行环境不同。代码经常直接访问寄存器,内存和计算资源有限,还要处理中断、时序、并发和硬件异常。
因此代码是否“能编译”只是最低要求,还要确认数据位宽、执行时间、对象生命周期、硬件访问顺序和异常状态。写每个模块前应先明确输入、输出、更新时机和失败后的处理方式。
固定宽度整数与类型转换
优先使用 stdint.h 中的 uint8_t、int16_t、uint32_t 等类型表达硬件寄存器、通信协议和存储格式。int 的位宽由编译器和平台决定,不适合表示必须固定长度的字段。
有符号数与无符号数混合运算时,负数可能被转换成很大的无符号数。整数提升还可能让 uint8_t 在表达式中先转换为 int。因此比较、位移和乘法前要先确认操作数最终使用什么类型。
uint16_t adc = 3000U;
uint32_t millivolts = ((uint32_t)adc * 3300U) / 4095U;
先转换为 32 bit 再乘,可以避免 16 bit 中间结果溢出。常量后的 U 表示无符号类型,有助于让表达式类型更加明确。
溢出、边界与单位
无符号整数溢出按模运算回绕,有符号整数溢出的行为不应依赖。计数器、时间戳和环形缓冲区索引经常利用无符号回绕,但必须写清设计意图。
变量名应尽量携带单位,例如 timeout_ms、frequency_hz、voltage_mv。不同单位之间转换时集中处理,避免同一个变量有时表示毫秒、有时表示时钟周期。
检查计算范围时应同时考虑最小值、最大值和中间值。传感器换算公式最终结果没有越界,不代表乘法中间结果也不会溢出。
位操作与寄存器访问
位操作常用于配置寄存器和解析协议字段。常用操作包括置位、清零、翻转和读取:
reg |= (1U << bit); /* 置位 */
reg &= ~(1U << bit); /* 清零 */
reg ^= (1U << bit); /* 翻转 */
state = (reg >> bit) & 1U;/* 读取 */
位移前要确认 bit 小于类型位宽,左移对象最好使用无符号类型。给寄存器写入多个字段时,应使用明确的掩码,不要依赖难以检查的十六进制魔法数。
某些硬件寄存器具有“写 1 清零”“读后清零”或保留位必须保持原值等特殊行为,不能一律使用普通的读改写操作。寄存器访问方式必须以芯片参考手册为准。
指针与对象生命周期
指针保存地址,但不自动携带缓冲区长度、对象类型安全或生命周期信息。函数接收缓冲区时应同时接收长度:
bool uart_send(const uint8_t *data, size_t length);
局部变量离开作用域后不再有效,不能返回它的地址。动态内存分配在资源有限或长期运行的系统中还会带来碎片和失败路径,因此许多嵌入式项目会优先使用静态分配或固定大小内存池。
区分三种常见指针形式:
const uint8_t *p:不能通过p修改所指数据。uint8_t * const p:指针本身不能改指向。const uint8_t * const p:指针和所指数据都不能通过它修改。
const 能表达接口意图并减少误修改,但它不等于数据一定存放在只读存储器中。
数组、字符串与缓冲区
数组传入函数后通常退化为指针,函数无法通过 sizeof 得到原数组长度。处理数组时必须显式传长度,处理字符串时还要确认是否存在结尾的 \0。
接收串口数据或网络数据时,外部长度不能直接信任。写入缓冲区前先检查剩余空间,解析多字节字段前先检查数据是否完整。
字符串格式化应优先使用带长度限制的接口,并检查返回值。二进制数据可能包含 0,不能使用字符串函数处理。
字节序、对齐与数据布局
多字节整数在内存中的排列可能是小端或大端。通信协议通常会规定网络字节序,解析时应逐字节组合或使用明确的转换函数,不要直接把字节缓冲区强制转换成结构体指针。
强制转换还可能遇到地址未对齐、结构体填充和严格别名问题。即使某个处理器允许非对齐访问,也可能降低性能或在另一平台上失败。
uint16_t read_u16_be(const uint8_t *data)
{
return (uint16_t)(((uint16_t)data[0] << 8) | data[1]);
}
这种写法明确表达了协议字节序,也不依赖结构体布局。
volatile 的作用与边界
volatile 告诉编译器:这个对象可能在当前代码看不到的位置发生变化,因此每次访问都应真正读取或写入。常见对象包括硬件寄存器,以及会被中断服务程序修改并由主循环读取的变量。
volatile 不保证以下内容:
- 多字节读写是原子的。
- 多个操作不会被中断打断。
- 不同线程或处理器核心之间自动同步。
- 一组寄存器访问形成不可分割事务。
如果主循环和中断共享一个超过处理器原生位宽的变量,可能需要在临界区中访问。使用 RTOS 时,应根据场景使用队列、信号量、互斥量或原子操作,而不是只添加 volatile。
中断服务程序
中断服务程序应尽可能短,只完成读取状态、保存必要数据、清除中断标志和通知主循环或任务。复杂计算、长时间循环、阻塞等待和大量日志输出通常不适合放在中断中。
中断设计需要确认:
- 中断源和清除标志的正确顺序。
- 共享数据是否存在竞争。
- 中断优先级和最大执行时间。
- 是否允许中断嵌套。
- RTOS 接口是否有专用的 ISR 版本。
若生产者在中断中写入环形缓冲区、消费者在主循环中读取,需要明确读写索引的所有者,并保证更新顺序不会让消费者读到尚未写完的数据。
状态机代替阻塞等待
外设初始化、通信协议和按键处理常包含多个步骤。若使用长时间 while 等待,会阻塞其他任务,也难以处理超时。状态机把流程拆成若干可观察状态,每次调用只推进一步。
switch (state) {
case UART_IDLE:
if (start_requested) {
start_transfer();
deadline = now_ms + 100U;
state = UART_WAIT;
}
break;
case UART_WAIT:
if (transfer_done()) {
state = UART_IDLE;
} else if (time_reached(now_ms, deadline)) {
abort_transfer();
state = UART_ERROR;
}
break;
case UART_ERROR:
report_error();
state = UART_IDLE;
break;
}
状态机应列出每个状态的进入条件、执行动作、退出条件和超时处理。这样既方便测试,也能从日志中判断程序卡在哪一步。
时间比较与计数器回绕
系统毫秒计数器最终会回绕。不要简单使用 now >= deadline 处理所有情况,可以利用无符号减法并限制最大超时时间:
bool time_elapsed(uint32_t now, uint32_t start, uint32_t duration)
{
return (uint32_t)(now - start) >= duration;
}
这种写法在一次回绕范围内仍能正确工作。前提是 duration 不超过所选计数类型能够无歧义表示的时间范围。
模块接口与分层
硬件寄存器访问应集中在驱动层,业务逻辑尽量处理普通数据。这样更换芯片或在电脑上做单元测试时,不需要重写全部逻辑。
一个简单分层可以是:
- 驱动层:GPIO、UART、SPI、ADC 等寄存器访问。
- 服务层:传感器读取、协议解析、数据缓存。
- 应用层:状态机、控制策略和用户逻辑。
头文件只暴露稳定接口和必要类型,内部变量与辅助函数使用 static 限制在源文件内。模块初始化应明确调用顺序,接口返回值要能区分成功、忙、超时和参数错误等必要状态。
错误处理与断言
对调用者可以合理恢复的错误,应返回错误码;对开发阶段绝不应发生的内部条件,可以使用断言。量产固件中的断言策略要明确,是安全复位、保存故障信息,还是进入受控失败状态。
不要静默忽略外设返回值。I2C 无应答、Flash 写入失败或缓冲区满都应有确定处理方式。错误日志最好包含模块、错误原因和关键上下文,但不要在高频路径中输出过量日志影响实时性。
可测试性与验证
可测试代码应把纯计算与硬件副作用分开。例如把 ADC 原始值转换成温度的函数写成只接收数值、返回结果的普通函数,就能在电脑上测试边界值,不需要真实 ADC。
每个模块至少检查:
- 正常输入是否得到预期输出。
- 最小值、最大值和空输入如何处理。
- 超时、校验失败和缓冲区满时状态是否正确。
- 重复初始化或重复调用是否符合约定。
- 中断与主循环交错时共享数据是否一致。
编译时应打开合理的警告级别,并认真处理类型转换、未使用变量、可能越界和返回值遗漏等警告。静态分析可以补充人工检查,但不能替代对硬件时序和并发关系的理解。
提交前检查清单
- 固定格式数据是否使用了明确位宽的整数类型?
- 乘法、位移和类型转换的中间结果会不会溢出?
- 缓冲区接口是否同时传递了长度?
- 多字节协议字段是否明确处理字节序?
volatile是否只用于确实可能异步变化的对象?- 共享数据是否需要临界区、队列或原子操作?
- 中断中是否存在阻塞、长循环或不必要的日志?
- 所有等待过程是否有超时和错误出口?
- 状态机是否覆盖每个状态及异常转移?
- 硬件访问和业务逻辑是否合理分层?
- 外设返回值、边界条件和失败状态是否经过验证?
- 代码是否能在脱离硬件的条件下测试核心计算逻辑?