tags: 嵌入式,编码规范,C语言,ARM,单片机,Keil category: 嵌入式 type: original嵌入式 C 语言编码规范怎么写170 条实战总结附检查清单一句话: 从目录架构、命名规则、ISR规范到Flash存储、安全保护覆盖嵌入式C编码的15个方面。文末附提交前检查清单可直接作为团队规范模板。刚学嵌入式的时候代码能跑就行。但项目一过 5000 行问题就来了——变量名看不懂、函数 300 行、ISR 里塞满延时、跨文件全局变量满天飞。后来给团队定了一套规范整理了 17 个方面至今没出过大的维护事故。分享出来可以直接拿去用。一、目录架构五层单向调用User/ → 应用层 (main 入口、业务调度) Module/ → 功能模块层 (算法、状态机、PID) Drive/ → 驱动层 (外设驱动、板级 IC 驱动) Libraries/ → SDK 外设库 (不修改) Sys/ → CMSIS Core 启动文件 (不修改)铁律调用链单向禁止反向。User → Module → Drive → Libraries → Sys为什么要这样举个例子如果 Drive 层反向调了 User 层的全局变量哪天你换一片 MCU 改 Drive 层User 层也跟着崩。分层隔离就是防火墙——改底层不影响上层。二、文件命名层级命名格式示例应用层app_模块名.c/.happ_cmis.c,app_monitor.c功能模块mod_模块名.c/.hmod_tec.c,mod_alarm.c驱动层drv_外设名.c/.hdrv_pi12030.c,drv_flash.cBSP 层bsp_功能.c/.hbsp_gpio.c统一前缀的好处看到文件名就知道它属于哪一层、能不能改。不需要打开文件猜测。三、变量命名一眼看出类型和作用域这套是匈牙利命名法的嵌入式版本前缀含义示例g_全局变量g_u32SysTicks_static 变量s_u8RxCntp一级指针pu16Bufpp二级指针*ppu8Bufb/u8uint8_t / BOOLbFlag,u8Stateu16/u32uint16_t / uint32_tu16AdcVal,u32Freqi8/i16/i32有符号整型i16Offsetf/dfloat / doublefKp,fKie枚举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类型后缀对照后缀类型示例Uuint8_t/uint16_t100UULuint32_t50000ULFfloat3.14F不加后缀编译器可能做隐式类型提升在一些场景下产生诡异的 bug。七、数据类型统一用stdint.h不用int、char、shortuint8_t uint16_t uint32_t int8_t int16_t int32_t float double _BoolISR 与主循环共享的变量必须加volatilevolatile 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/ULUTF-8 BOMKeil VSCode 通用全编译 0 错误 0 警告必须总结这套规范的核心思想就三句话看得懂命名即文档不用跳到声明处猜类型改得动分层单向调用改底层不影响上层死不了安全逻辑独立Flash 双备份看门狗兜底刚开始用可能觉得啰嗦但项目超过 5000 行之后你会感谢自己定了这些规矩。