一句话: 从目录架构、命名规则、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/.happ_cmis.c, app_monitor.c
功能模块mod_<模块名>.c/.hmod_tec.c, mod_alarm.c
驱动层drv_<外设名>.c/.hdrv_pi12030.c, drv_flash.c
BSP 层bsp_<功能>.c/.hbsp_gpio.c

统一前缀的好处:看到文件名就知道它属于哪一层、能不能改。不需要打开文件猜测。


三、变量命名:一眼看出类型和作用域

这套是匈牙利命名法的嵌入式版本:

前缀含义示例
g_全局变量g_u32SysTick
s_static 变量s_u8RxCnt
p一级指针pu16Buf
pp二级指针*ppu8Buf
b / u8uint8_t / BOOLbFlag, u8State
u16 / u32uint16_t / uint32_tu16AdcVal, u32Freq
i8 / i16 / i32有符号整型i16Offset
f / dfloat / doublefKp, 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

类型后缀对照:

后缀类型示例
Uuint8_t/uint16_t100U
ULuint32_t50000UL
Ffloat3.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 BOMKeil + VSCode 通用
全编译 0 错误 0 警告必须

总结

这套规范的核心思想就三句话:

  1. 看得懂:命名即文档,不用跳到声明处猜类型
  2. 改得动:分层单向调用,改底层不影响上层
  3. 死不了:安全逻辑独立,Flash 双备份,看门狗兜底

刚开始用可能觉得啰嗦,但项目超过 5000 行之后,你会感谢自己定了这些规矩。


实测对比:无规范项目: Bug修复平均 2 小时/个 | 有规范项目: Bug修复平均 30 分钟/个

有用的话点个收藏,下次调试直接用。有问题欢迎评论区交流,看到了都会回。

下一篇:保姆级教程——状态机、编码规范、编译链接——每个嵌入式新人都要过的三道坎

更多推荐