嵌入式 C 语言编码规范怎么写?170 条实战总结
一句话: 从目录架构、命名规则、ISR规范到Flash存储、安全保护,覆盖嵌入式C编码的15个方面。文末附提交前检查清单,可直接作为团队规范模板。
系列文章:《嵌入式编程基础》 — 状态机、编码规范、编译链接——每个嵌入式新人都要过的三道坎
📄 保姆级教程 ✅ 170条实战总结 📄 3分钟看懂Keil编译输出 📄 一条浮点指令差几十倍 📄 为什么两个都要 📄 复制粘贴超过2次就该停 📄 两个变量搞定开/关/空闲 📄 先画条件矩阵再取名 📄 dead-code-chain-deletion 📄 macro-derive-coefficients
刚学嵌入式的时候,代码能跑就行。但项目一过 5000 行,问题就来了——变量名看不懂、函数 300 行、ISR 里塞满延时、跨文件全局变量满天飞。
后来给团队定了一套规范,整理了 17 个方面,至今没出过大的维护事故。分享出来,可以直接拿去用。
@[TOC]
适合谁读:适合嵌入式开发者、单片机初学者及遇到类似问题的工程师
一、目录架构:五层单向调用
User/ → 应用层 (main 入口、业务调度)
Module/ → 功能模块层 (算法、状态机、PID)
Drive/ → 驱动层 (外设驱动、板级 IC 驱动)
Libraries/ → SDK 外设库 (不修改)
Sys/ → CMSIS Core + 启动文件 (不修改)
铁律:调用链单向,禁止反向。
User → Module → Drive → Libraries → Sys
为什么要这样?举个例子:如果 Drive 层反向调了 User 层的全局变量,哪天你换一片 MCU 改 Drive 层,User 层也跟着崩。分层隔离就是防火墙——改底层不影响上层。
二、文件命名
| 层级 | 命名格式 | 示例 |
|---|---|---|
| 应用层 | app_<模块名>.c/.h | app_cmis.c, app_monitor.c |
| 功能模块 | mod_<模块名>.c/.h | mod_tec.c, mod_alarm.c |
| 驱动层 | drv_<外设名>.c/.h | drv_pi12030.c, drv_flash.c |
| BSP 层 | bsp_<功能>.c/.h | bsp_gpio.c |
统一前缀的好处:看到文件名就知道它属于哪一层、能不能改。不需要打开文件猜测。
三、变量命名:一眼看出类型和作用域
这套是匈牙利命名法的嵌入式版本:
| 前缀 | 含义 | 示例 |
|---|---|---|
g_ | 全局变量 | g_u32SysTick |
s_ | static 变量 | s_u8RxCnt |
p | 一级指针 | pu16Buf |
pp | 二级指针 | *ppu8Buf |
b / u8 | uint8_t / BOOL | bFlag, u8State |
u16 / u32 | uint16_t / uint32_t | u16AdcVal, u32Freq |
i8 / i16 / i32 | 有符号整型 | i16Offset |
f / d | float / double | fKp, fKi |
e | 枚举 | eSysState |
命名顺序:作用域 + 指针级别 + 类型 + 含义
// 一看就懂:全局的,uint8_t 类型,接收计数器
volatile uint8_t g_u8RxCnt;
// 一看就懂:static 的,枚举类型,当前状态
static E_SYS_STATE s_eState;
不用猜变量类型,不用跳到声明处看——名字本身就是文档。
四、类型命名
| 类型 | 格式 | 示例 |
|---|---|---|
| 结构体 | T_ + 全大写 + 下划线 | T_APP_CONTEXT, T_FLASH_PARAMS |
| 联合体 | U_ + 全大写 | U_FLASH_DATA |
| 枚举 | E_ + 全大写 + 下划线 | E_SYS_STATE, E_ERROR_CODE |
枚举的每个值显式赋值,不依赖编译器默认值:
typedef enum {
STATE_INIT = 0,
STATE_IDLE = 1,
STATE_RUNNING = 2,
STATE_FAULT = 3,
STATE_COUNT // 总数, 方便做数组
} E_SYS_STATE;
五、函数命名
格式:模块前缀_功能描述
Drv_ADC_Init(); // 驱动层 - ADC 初始化
App_Protocol_Parse(); // 应用层 - 协议解析
Mod_PID_Calculate(); // 模块层 - PID 计算
函数规范:
| 规则 | 值 |
|---|---|
| 单函数最大行数 | ≤ 200 行 |
| ISR 最大行数 | ≤ 50 行 |
| 参数个数 | ≤ 5 个 |
| 超过 5 个参数 | 封装为结构体指针 |
// 烂代码:参数太多
void Mod_TEC_Start(uint8_t u8Ch, uint16_t u16SetMv, uint8_t u8Dir,
uint16_t u16MaxI, uint16_t u16MaxV, uint8_t u8Timeout);
// 好代码:用结构体
typedef struct {
uint8_t u8Ch;
uint16_t u16SetMv;
uint8_t u8Dir;
uint16_t u16MaxI;
uint16_t u16MaxV;
uint8_t u8Timeout;
} T_TEC_CONFIG;
void Mod_TEC_Start(const T_TEC_CONFIG *ptCfg);
六、宏和常量
// 宏:全大写 + 下划线
#define PWM_MAX_DUTY 4095
#define FLASH_SECTOR_SIZE 512
// 常量:小写 k 前缀,加类型后缀
#define kAdcSampleCount 16U // uint8_t
#define kDefaultKp 3.14F // float
#define kTimeoutMs 5000UL // uint32_t
类型后缀对照:
| 后缀 | 类型 | 示例 |
|---|---|---|
U | uint8_t/uint16_t | 100U |
UL | uint32_t | 50000UL |
F | float | 3.14F |
不加后缀编译器可能做隐式类型提升,在一些场景下产生诡异的 bug。
七、数据类型
统一用 stdint.h,不用 int、char、short:
uint8_t uint16_t uint32_t
int8_t int16_t int32_t
float double _Bool
ISR 与主循环共享的变量必须加 volatile:
volatile uint8_t g_bTick100ms; // 定时器 ISR 置位, 主循环读取
volatile uint8_t g_bI2cRxDone; // I2C ISR 置位, 主循环处理
不加 volatile 的后果:编译器优化后,主循环可能永远看不到 ISR 修改的值。
八、注释规范
| 位置 | 要求 |
|---|---|
| 文件头 | 文件名 / 时间 / 功能 / 作者 / 修改记录 |
| 每个函数 | 功能 / @param 参数 / @return 返回值 / 注意事项 |
| ISR | 中断源 / 优先级 / 修改的全局变量 / 注意事项 |
| switch-case | 每个分支必须有注释 |
/*!
* @brief Flash 参数初始化 — 上电读 Flash,找有效参数
* @return 0=读到有效参数, 1=全损坏, 已用默认安全值
* @note 两份都有效时选 writeCount 大的(最新)
* 两份都坏了 → 用默认值(激光全关,TEC 25°C)
*/
uint8_t Drv_Flash_Init(void);
九、代码格式
// 函数/结构体:大括号另起一行
void MyFunction(void)
{
// 4 空格缩进,不用 Tab
}
// if / for / while:大括号紧跟
if (u8Val > 0U) {
do_something();
}
// 指针 * 靠变量名
uint8_t *pBuf; // 不是 uint8_t* pBuf;
// 一行不超过 100 字符
ARMCC V5 兼容:局部变量必须在函数/代码块开头统一声明。
void foo(void)
{
uint8_t u8i; // 变量声明放最前面
uint32_t u32Temp; // ARMCC V5 不支持 C99 混写
// 之后才是代码
u8i = 0U;
...
}
十、ISR 规范(最重要的一条)
中断服务函数是嵌入式 bug 的集中爆发地。记住一句话:
ISR 里只做四件事:读硬件寄存器、写硬件寄存器、置标志位、搬数据。别的全扔给主循环。
| 禁止 | 原因 |
|---|---|
Delay() / for 延时 | 卡死中断,其他中断进不来 |
printf() | 阻塞几百毫秒,PLL 都可能丢锁 |
| Flash 读写 | 擦写期间 CPU 不能取指令 |
| 调复杂函数 | 你不知道它里面会不会延时 |
| 阻塞等待 | 等硬件状态位也不行 |
// 好代码:ISR 只贴标签
void GPT0_Int_Handler(void)
{
pADI_GPT0->TCLRI = 1U; // 清中断
g_bTick100ms = 1U; // 贴标签, 主循环看到后处理
}
// 主循环
while (1) {
if (g_bTick100ms != 0U) {
g_bTick100ms = 0U;
App_Monitor_Run(); // 真正的逻辑在主循环做
}
}
十一、延时管理
| 阶段 | 允许 | 限制 |
|---|---|---|
| main 初始化 | Delay ≤ 5000ms | 仅上电一次 |
| 外设初始化 | Delay ≤ 100ms | 仅一次 |
| 主循环 | 禁止 | 用定时器标志位 + 状态机 |
| ISR | 绝对禁止 | 任何形式延时 |
主循环里的延时用计数器代替:
// 烂代码
void TEC_Wait(void) {
Delay(10000); // 卡死 10 秒, 狗都咬人了
}
// 好代码: 每 100ms 调一次, 计数器累加
uint8_t Mod_TEC_Run(void)
{
if (s_eState == TEC_STATE_STARTING) {
if (BSP_TMPGD_Read() != 0U) {
s_eState = TEC_STATE_STABLE; // 温度到了
} else {
s_u8TmpgdCnt++;
if (s_u8TmpgdCnt > 150U) { // 100ms × 150 = 15 秒超时
return 1U; // 超时 → 上报故障
}
}
}
return 0U;
}
十二、Flash 存储
// 三原则:
// 1. 擦写前关全局中断, 完成后恢复
// 2. 关键参数双备份 (两个扇区交替写)
// 3. 上电读参数必须 CRC16 校验, 失败用默认安全值
为什么双备份?假设正在写 A 扇区时断电 → A 损坏 → 上电从 B 恢复。交替写还能做磨损均衡。
// 上电初始化
uint8_t Drv_Flash_Init(void)
{
uint8_t u8RetA = Flash_ReadSector(ADDR_A, &tParamA); // 校验 CRC
uint8_t u8RetB = Flash_ReadSector(ADDR_B, &tParamB);
if ((0U == u8RetA) && (0U == u8RetB)) {
// 两份都有效 → 选 writeCount 大的 (最新)
s_tParams = (tParamA.u32WriteCount >= tParamB.u32WriteCount)
? tParamA : tParamB;
} else if (0U == u8RetA) {
s_tParams = tParamA; // 只有 A 有效 (B 写一半断电了)
} else if (0U == u8RetB) {
s_tParams = tParamB; // 只有 B 有效
} else {
s_tParams = s_tDefaultParams; // 全坏了 → 用默认安全值
}
}
十三、安全保护
| 原则 | 说明 |
|---|---|
| 关键输出必须硬件看门狗 | 激光器/调制器等,软件死循环也能切断 |
| 传感器异常立即切断输出 | 不等确认、不等重试 |
| IWDG 必须使能 | 超时 = 正常周期 2~3 倍 |
| 安全逻辑独立于业务逻辑 | 不放在状态机里,单独判断 |
// 安全逻辑独立:不依赖状态机当前态
if (s_bPLLLost != 0U) {
App_State_Set(STATE_FAULT); // PLL 失锁 → 直接切故障
}
// 喂狗只在主循环一处
while (1) {
IWDG_Reload();
// ... 所有业务逻辑 ...
}
十四、跨模块通信
- 通过结构体指针传递,不用全局变量直接跨层
- 参数超过 5 个封装为
T_APP_CONTEXT上下文结构体 - 回调用函数指针,驱动不依赖上层模块
// 驱动层:注册回调函数指针
typedef uint8_t (*T_I2C_READ_HOOK)(uint8_t u8RegAddr, uint8_t u8Offset);
extern T_I2C_READ_HOOK g_pfnI2cReadHook;
// 应用层:注册自己的读函数
g_pfnI2cReadHook = App_CMIS_ReadHook;
// 驱动层不知道自己被谁调了, 也不在乎 — 换协议换应用层就行
十五、快速检查清单
提交代码前逐条过一遍:
| 检查项 | 说明 |
|---|---|
| 文件头注释 | 文件名/时间/功能/作者 |
| 函数注释 | 功能/@param/@return |
| 命名规范 | 变量有类型前缀, 函数有模块前缀 |
| 变量声明在开头 | ARMCC V5 兼容 |
| ISR 无延时/printf/Flash | 致命 |
| ISR ≤ 50 行 | 超了就拆 |
| 参数 ≤ 5 个 | 超了封结构体 |
| 指针 NULL 检查 | 对外接口必须 |
| volatile 正确 | ISR 共享变量 |
| switch-case 有 default | 每个分支有注释 |
| 无幻数 | 宏或常量定义 |
| 常量有类型后缀 | U/F/UL |
| UTF-8 BOM | Keil + VSCode 通用 |
| 全编译 0 错误 0 警告 | 必须 |
总结
这套规范的核心思想就三句话:
- 看得懂:命名即文档,不用跳到声明处猜类型
- 改得动:分层单向调用,改底层不影响上层
- 死不了:安全逻辑独立,Flash 双备份,看门狗兜底
刚开始用可能觉得啰嗦,但项目超过 5000 行之后,你会感谢自己定了这些规矩。
实测对比:无规范项目: Bug修复平均 2 小时/个 | 有规范项目: Bug修复平均 30 分钟/个
有用的话点个收藏,下次调试直接用。有问题欢迎评论区交流,看到了都会回。
下一篇:保姆级教程——状态机、编码规范、编译链接——每个嵌入式新人都要过的三道坎
更多推荐

所有评论(0)