嵌入式软件编程规范:从排版注释到代码可测性实践
2026/9/19 10:57:16 网站建设 项目流程

简介:面向嵌入式软件开发与测试人员的编程规范文档,系统梳理了从排版、注释、标识符命名到变量、结构、宏、函数、过程及可测性等环节的约束要求,并引入GIT分支管理与代码提交规范,适合团队用于统一编码风格、提升代码可维护性与可靠性。包体为单份PDF电子书,体积仅413KB,便于随时查阅与分发;虽文件精简,但章节划分完整,从文档修改历史、排版注释、命名可读性,到变量结构、宏、函数过程、可测性及附录推荐编辑器配置均有涉及。目前已有218人学习,适合嵌入式初学者建立规范意识,也适合开发团队作为内部评审与培训的参考基线。通过阅读可快速掌握代码可读性设计、防御式编程要点、命名规则及测试性评估方法,同时理解代码质量定义与GIT提交约定,降低后期维护与联调成本。

1. 嵌入式软件编程规范:先把排版和注释写对,再谈架构

嵌入式开发里有个很反直觉的现象:代码能跑不重要,能让人接手才算完成。很多团队卡住的不是算法,而是一个 .c 文件里两种缩进风格并存——有人用 Tab,有人用 4 个空格,拿到 Keil 里看是对齐的,在 VS Code 里就错位,代码评审在「这一行该不该留空行」上能吵十分钟。这份嵌入式软件编程规范从 2016 年的 0.1 版本起步,把排版、注释、命名、宏、函数、可测性和 Git 提交顺序收敛成带编号的规则,很多条还明确标着「必须」。它不是网上流传的嵌入式八股文,而是把单片机开发里寄存器操作、中断上下文、协议缓冲区这类容易写坏的地方提前画好了边界。适合刚带嵌入式团队的组长抄作业,也适合嵌入式软件工程师对照自查,嵌入式面试题里问代码规范的部分,答案基本都能从这里找到出处。

2. 排版与可读性:Tab 陷阱、80 字符路口与操作符空格

2.1 Tab 与空格的战争:为什么缩进必须是 4 个空格

规范第一条硬性排版规则是程序块缩进 4 个空格,对齐一律使用空格键,不得使用 Tab 键。原因很实际:不同编辑器对 Tab 的渲染宽度不一样,同一个工程在 Keil uVision5 里按 8 显示,在 VS Code 里按 4 显示,两个人看到的源码布局完全不同,git diff 也会把整块代码判定为改动,review 的时候很难分清哪一行是真的动了逻辑,哪一行只是被 Tab 顶了一下。我一般会把编辑器 Tab size 设为 4,并选择 Insert spaces,让 Tab 键实际插入的是 4 个空格而不是制表符,具体配置路径放在最后一节统一说。

static uint32_t gs_timer_tick; /* system tick counter */ void timer_isr(void) { uint32_t diff; if (gs_timer_tick < UINT32_MAX) { gs_timer_tick++; diff = gs_timer_tick; } /* ... */ }

这段示例展示了 4 空格缩进的效果:函数体一层、if 块一层,代码结构从行首缩进就能直接看出来。if的大括号独立占一行,{if左对齐,这是文档里反复强调的写法。使用空格而不是 Tab,还避免了「TAB 键在不同编辑器宽度不同」这个最隐蔽的协作问题,空格的渲染在任何工具里都是一致的。

2.2 80 字符行宽:长表达式和参数表怎么断行

规则规定较长的语句超过 80 字符要分成多行书写,长表达式要在低优先级操作符处划分新行,划分出的新行要适当缩进。实际项目里最容易超长的位置是函数调用和赋值表达式,尤其是通信协议解析、寄存器位拼接这类代码,一行塞进去三四个字段很容易就破百。

rpr_n7statStrCompare((uint8_t *) &statObject, (uint8_t *) &(g_sys_act_task_table[taskno].statObject), sizeof(SYS_STAT_OBJECT)); rpr_n7statFlashActDuration(statItem, frame_id * SYS_STAT_TASK_CHECK_NUMBER + index, statObject);

换行后的参数与第一个参数左对齐,这样读起来像一张表格,每个参数在哪一目了然。第二个函数里frame_id * SYS_STAT_TASK_CHECK_NUMBER + index是一个整体表达式,作为参数传入时缩进到参数起始位置,而不是简单地对齐到行尾。这里有个细节:文档里其实同时出现了操作符放新行之首和放行尾两种风格,规则 2-3 的示例把操作符放在新行之首,规则 2-4 的示例放在行尾。我的经验是选一种并坚持,推荐放行首,因为行首的&&+这类操作符能一眼提醒读者:上一行的表达式还没结束,阅读时不会误以为语句已经终结。

注意:操作符换行位置在两处示例中并不统一,团队落地时不要两边都学,挑一种写进自己的编码规范即可。

2.3 空行与大括号:让代码块有呼吸感

规则 2-2 要求相对独立的程序块之间、变量说明之后必须加空行。这条容易被当成形式主义,但它直接决定一段代码能不能被「扫读」。变量声明和业务逻辑挤在一起、两个 if 块之间没有空行,人眼很难快速找到某个变量的使用边界。同时,iffordowhile语句无论执行部分有多少行,都必须加上大括号{}

/* 不推荐:if 与 return 挤在一起,且没有 {} */ if (p_user_cr == NULL) return; /* 推荐写法 */ if (NULL == p_user_cr) { return; }

不加大括号在 C 语言里语法完全合法,但嵌入式代码维护周期长,后来者想在这个条件分支里加一行日志或错误处理时,很容易忘记补括号,逻辑就被悄悄吞掉。文档里还有一条容易被忽略的建议:比较表达式里尽量把常量放在左边,写成NULL == p_user_cr而不是p_user_cr == NULL。这样万一==误敲成=,编译直接报错,不用等到运行期去查一个诡异的赋值 bug。

uint32_t frame_len; uint8_t buffer[128]; frame_len = 0; while (frame_len < sizeof(buffer)) { /* ... */ }

变量声明完成之后空一行,再进入业务逻辑,这行空白的含义是「声明区到此结束」。类似的,一个完整处理块和下一个处理块之间也要有空行,让每一段逻辑像段落一样有边界。

2.4 操作符空格对照表:哪些地方必须留白,哪些必须贴紧

规则 2-10 的信息量很大,它把操作符分成对等操作和非对等操作:赋值、比较、算术、逻辑、位操作这类双目操作符前后要加空格;!~++--、地址操作符&、内容操作*这类单目操作符与操作数之间不加空格;->.是关系最紧密的成员操作符,前后也都不加空格。同时ifforwhile这些关键字与后面的括号之间要加空格,让关键字更突出。

操作符类型示例是否加空格说明
双目操作符= + == && << ^前后加空格赋值、比较、算术、逻辑、位运算
单目操作符! ~ ++ -- & *不加空格与操作数紧贴
成员操作符.->不加空格结构体与指针成员访问
关键字与括号if (x)while (x)加空格关键字后加一个空格
括号内部if ((a >= b) && (c > d))不加空格括号内不需要额外留白
if (current_time >= MAX_TIME_VALUE) { a = b + c; a *= 2; } *p = 'a'; flag = !is_empty; p_ctx->id = pid; i++;

这套空格规则的效果是:双目操作符两侧的留白让表达式产生「呼吸感」,单目操作符贴紧让*pp++不会被误读成乘法或指针运算。括号内侧不加空格,因为在 C/C++ 里括号本身已经是足够清晰的分界标志。混合使用这些规则时,整体清晰比每条规则机械执行更重要,长语句里局部不加空格是允许的。

3. 注释规范:20% 注释量与三种头模板

3.1 注释量 20% 起步:怎么统计,为什么不是越多越好

规则 3-1 要求源程序有效注释量必须在 20% 以上,建议 20%~30%。注释的行数统计可以交给工具,cloc能按文件输出代码行、注释行和空白行:

cloc --by-file src/*.c

cloc的运行结果里,Comment列除以Code + Comment就是该文件的注释占比。如果环境里没有 cloc,也可以在 VS Code 里用插件统计,但我更习惯在代码评审前用git diff --check配合人工检查。注释量不是越高越好,超过 30% 的注释往往意味着代码本身的可读性出了问题,命名没有表达清楚意图,才需要大段文字来补充。规范里另一条规则 3-19 说的是「通过正确的命名和合理的结构让代码自注释」,好名字本身就是注释,把a改成is_ready之后,「* ready flag */」这行注释就可以删掉。

3.2 文件头注释模板:让每个 .c / .h 自带说明页

文档对说明性文件的要求是:头部注释列出版权、模块名、文件名、作者、内容介绍、修改日志,头文件的注释里还要有函数功能简要说明。同时规则 3-3-2 规定,为了防止头文件被重复引用,必须用#ifndef / #define / #endif结构生成预处理块。一个可以直接套用的头文件模板:

#ifndef SYS_TIMER_H #define SYS_TIMER_H /********************************************************************** * Module: timer driver * File: sys_timer.h * Author: Zhang San * Description: Kernel timer interface for stm32. * History: * 1. 2024-01-10 Zhang San Create * 2. 2024-02-01 Zhang San Add period mode **********************************************************************/ #include "stdint.h" void sys_timer_start(uint32_t period_ms); void sys_timer_stop(void); #endif /* SYS_TIMER_H */

头文件保护宏的命名用「模块名_文件名_H」,可以避免不同模块的同名头文件冲突;末尾的#endif /* SYS_TIMER_H */注释是给维护者确认这个编译块的归属。引用头文件时,标准库用#include <>格式,编译器会从标准库目录开始搜索;非标准库用#include ""格式,编译器先从用户工作目录搜索。这不仅是写法习惯,在某些交叉编译工具链里,两种引号格式的搜索顺序不同,混用可能导致「同样的头文件,在同事机器上编译通过,在自己机器上报找不到头文件」的问题。

3.3 函数头注释模板:把调用关系写进接口契约

外部函数必须有函数头注释,内部函数强烈建议。函数头注释要列出:功能、输入参数、输出参数、返回值、调用关系。外部函数是跨文件的接口,调用者看不到实现,只能靠这个注释判断怎么调用、会返回什么。一个符合规范的函数头注释示例:

/********************************************************************** * Function: flash_write * Input: addr - start address, aligned to page; * buf - data buffer; * len - bytes to write, > 0. * Output: None * Returns: OK - write success; * ERR_TIMEOUT - chip no response; * ERR_LEN - len out of range. * Calls: flash_cmd_write * Called By: sys_ota_update, app_config_save **********************************************************************/ STATUS flash_write(uint32_t addr, const uint8_t *buf, uint32_t len);

Input/Output/Returns构成了函数的接口契约,Calls表示这个函数内部调用了谁,Called By表示谁调用了这个函数。在嵌入式项目里,驱动接口、中断回调、协议栈函数相互交织,把这两个字段写清楚,定位问题时直接翻函数头就能画出调用链,不用再靠 IDE 的查找功能一层层跳。内部函数虽然没有强制要求,但在文件内如果存在多个静态函数互相调用,类似的头注释能显著降低阅读成本。

3.4 注释位置与二义性:放在该放的地方

规则 3-9 规定注释要放在代码的上方或右方,不能放在下面;注释放上方时要与上面的代码用空行隔开。规则 3-18 补充:避免在一行代码或表达式中间插入注释,除非是特殊检查工具的禁用标记。看一个典型的反例:

/* 反例:注释块与上面代码之间没有空行 */ timer_create(1000, one_shot_cb); /* create a one shot timer */

把注释放在代码下面,读代码时先看到执行语句,再看到解释,人的视线会来回跳。更严重的是在长表达式中间插注释,本来一行能写完的表达式被拆成三行,读的时候很难判断注释到底属于哪个子表达式。遇到这种情况,不如把复杂的子表达式提前提取成有名字的局部变量,让变量名自己承担解释工作。

规则编号内容优先级
3-1注释量 20% 以上必须
3-3头文件头部注释必须
3-4源文件头部注释必须
3-5外部函数必须有函数头注释必须
3-6修改代码同时修改注释必须
3-18避免在表达式中间插入注释必须
3-19通过命名和结构自注释建议

4. 命名风格与宏、函数的边界

4.1 从示例反推命名体系:g 前缀、模块缩写与类型标记

原始文档第 4 章「标识符命名」在正文里没有展开具体规则,但从全文示例可以反推出整套命名体系。以规范里反复出现的gRprRepssnIndgSysAcbTaskTableSYS_MAX_ACT_TASK_NUMBERRPR_STAT_SIZE_PER_FRAM为例:

命名要素示例含义
全局变量g前缀gRprRepssnIndglobal,标识全局变量
模块缩写RprSys区分消息处理模块、系统任务模块
语义部分RepssnInd变量实际含义:挂接子系统索引 + 指示位
全大写宏/常量SYS_MAX_ACT_TASK_NUMBER配置参数、常量
类型命名UINT8UINT32STATUS统一类型长度,不依赖编译器 int 实现

这套命名里最有价值的是g前缀:看到变量名就知道它的作用域是全局,不能随便塞进一个局部作用域里隐藏掉。模块缩写保留了业务归属,两个同名的countRprSys模块下不会混用。类型UINT32这类写法来自 C99stdint.h的二次封装,在 8 位单片机上UINT32恒为 4 字节,不依赖编译器对int的实现宽度,跨平台移植时能少踩很多隐式类型转换的坑。

4.2 变量与结构:物理含义写进声明

规则 3-10、3-11、3-12 组合起来的效果是:变量、常量、数据结构必须在声明处说清物理含义、取值范围、谁在存取它。这在嵌入式场景中尤其重要,因为大量变量直接对应寄存器位、协议字段和硬件状态。枚举和结构体是重灾区,成员多了以后,没人记得每个枚举值代表的实际含义:

typedef enum { SCP_UNITDATA_IND, /* 收到底层的单元数据 */ SCP_NOTICE_IND, /* 通知上层:七号网无法投递 */ SCP_UNITDATA_REQ /* 上层发送单元数据请求 */ } SCP_USER_PRIMITIVE_T; typedef struct { uint32_t frame_id; /* 帧序号,0 起始 */ uint8_t slot_num; /* 时隙号,0~31 */ uint8_t is_occupied; /* 0=空闲 1=占用 */ } sys_slot_t;

每个成员注释放在右方,保持同一列对齐,读起来像一张表格。在协议解析里,SCP_UNITDATA_INDSCP_UNITDATA_REQ的区别直接决定消息往哪条链路走,这类注释省去的是每次翻协议文档的时间。全局变量要求更严格,要写出取值范围和存取它的函数,避免多个模块随意改写同一个状态:

/* 全局错误码。0=SUCCESS, 1=表错误, 2=参数错误。 * 仅 sys_gt_parse() 可写,其他模块通过 sys_gt_get_err() 读取。 */ static uint8_t g_gt_err;

把「谁可以改」写进注释,在多人协作中能有效防止某个模块为了临时处理业务,直接覆盖掉另一个模块还在用的全局状态。静态全局变量把可访问范围限制在本文件,再配合注释说明存取规则,基本可以杜绝跨文件的全局变量滥用。

4.3 宏与常量:全大写、参数加括号、能不用则不用

宏的规则虽然在第 6 章,但结合示例SYS_MAX_ACT_TASK_NUMBER可以看清楚使用边界。嵌入式 C 里宏主要承担三类工作:配置参数、寄存器位定义、条件编译。宏有个经典坑是参数展开时的优先级问题,官方教材里讲了很多遍,但实际工程里依然会犯:

#define MAX_NUM(a, b) ((a) > (b) ? (a) : (b)) #define CLOCK_HZ 72000000u #define GPIO_MODE_INPUT 0x01u

MAX_NUM外层加括号,避免整个宏替换进表达式后与相邻操作符错误结合;参数(a)(b)也加括号,避免传入a++a + b这类表达式时展开结果和预期不一致。CLOCK_HZ后面的72u后缀表示无符号数,参与除法、比较时不会触发隐式符号转换,这在嵌入式里很实用,因为裸机代码里经常拿时钟频率去做超时换算。条件编译则是宏最难被替代的场景,比如用#ifdef BOARD_A编译不同硬件版本的驱动。能用enumstatic const的地方优先使用它们,原因是它们有类型信息,宏在预处理阶段就完成替换,编译报错时错误信息指向的是宏展开后的代码,排查时要心里先算一步「这个错误来自哪里」。

4.4 函数设计:STATUS 返回值与注释分级

规则 3-5-1 明确外部函数必须有函数头注释,3-5-2 建议内部函数也写。结合函数设计,嵌入式 C 的函数通常会约定一个STATUS状态码返回值,而不是返回void然后在函数内部自己打印错误信息:

typedef int32_t STATUS; #define OK (STATUS)0 #define ERR_TIMEOUT (STATUS)-1 #define ERR_PARAM (STATUS)-2 STATUS sys_uart_send(uint8_t port, const uint8_t *buf, uint32_t len, uint32_t timeout_ms);

状态码让错误能够向上传播,上层调用者可以根据返回值决定重试、回滚还是上报故障。参数数量超过 5 个时,建议把相关参数打包成结构体传入,一方面避免函数调用行过长触发 80 字符规则,另一方面结构体本身就是参数的自然文档,字段名比靠位置记忆的形参列表友好得多。内部静态函数建议写注释,是因为static函数虽然只在本文件内可见,但复杂算法和中断安全的临界区处理一旦失去说明,调用者很难判断它是否允许在中断上下文里被调用,这个信息只能靠函数头注释传递。

5. 可测性、GIT 提交与 Keil 配置

5.1 可测性靠函数结构,不靠测试工具

原始文档第 9 章「可测性」没有展开细节,但结合前几章的规则可以整理出三个可测性的抓手:入参检查、单一出口、错误码返回。单元测试要构造非法参数分支,入参检查不写在函数入口,测试就无从下手:

STATUS sys_uart_send(uint8_t port, const uint8_t *buf, uint32_t len, uint32_t timeout_ms) { if (buf == NULL || len == 0) { return ERR_PARAM; } /* ... */ return OK; }

入口用NULL和一个非法长度挡住明显错误的调用,后续逻辑不需要再判断这些边界条件。可测性的另一个关键约束是「谁分配谁释放」,函数内部malloc的资源必须在函数内部释放,或者在函数头注释里明确写出调用者负责释放,否则测试用例跑一遍就会把堆内存耗光。

5.2 GIT 提交顺序与 commit message

规范第 10 章要求使用 git 提交代码时填写充分、准确的 message,并定义了代码引入规定和 COMMIT 顺序。常见的落地做法是:先格式化代码,再提交功能改动;每个 commit 只做一件事;message 使用「模块: 动作 对象」的格式。提交前养成一个习惯,效果立竿见影:

git add sys_timer.c sys_timer.h git commit -m "timer: add period mode support" git diff --check HEAD

git diff --check会检查 diff 里的空白错误,包括行尾空格、Tab 导致的缩进混乱。这个命令比代码评审机器人都早一步发现问题,每次提交前跑一遍,能避免后续 review 时满屏的空白 diff。提交文件时,不要把编译输出目录带进版本库,Keil 工程里的 Objects、Listings 目录需要在.gitignore里排除,否则每次编译都会产生大量无关的 diff,干扰真正的代码变更。格式化和功能逻辑分开提交,也是为了避免「一个 commit 里既有缩进调整又有业务修改」,出了问题没法单独回滚。

5.3 Keil uVision5 编辑器配置

附录 A 专门讲了推荐编辑器的默认配置修改。Keil uVision5 中打开Edit -> Configuration -> Editor:Tab size 设为 4,勾选 Insert spaces,这样按下 Tab 键实际插入 4 个空格,而不是制表符;Show Line Numbers 建议勾选,代码评审时说「第 123 行」比描述上下文快得多;Encoding 根据项目实际情况选择 UTF-8 或 GB2312,如果源码里有中文注释且多人协作,建议统一 UTF-8,否则在 Linux 交叉编译环境或用 VS Code 打开时会出现乱码。配置完成后,旧文件里可能还残留历史 Tab 字符,用 VS Code 或 Notepad++ 打开,把\t替换为 4 个空格,提交一个 format-only 的 commit,之后再提交功能改动,diff 就会干净很多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询