拓冰建站拓冰建站
首页 / 资讯中心 / 正文

嵌入式软件编程规范:从排版注释到代码可测性实践

简介面向嵌入式软件开发与测试人员的编程规范文档系统梳理了从排版、注释、标识符命名到变量、结构、宏、函数、过程及可测性等环节的约束要求并引入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 块之间没有空行人眼很难快速找到某个变量的使用边界。同时if、for、do、while语句无论执行部分有多少行都必须加上大括号{}。/* 不推荐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 的信息量很大它把操作符分成对等操作和非对等操作赋值、比较、算术、逻辑、位操作这类双目操作符前后要加空格!、~、、--、地址操作符、内容操作*这类单目操作符与操作数之间不加空格-和.是关系最紧密的成员操作符前后也都不加空格。同时if、for、while这些关键字与后面的括号之间要加空格让关键字更突出。操作符类型示例是否加空格说明双目操作符 ^前后加空格赋值、比较、算术、逻辑、位运算单目操作符! ~ -- *不加空格与操作数紧贴成员操作符.-不加空格结构体与指针成员访问关键字与括号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;这套空格规则的效果是双目操作符两侧的留白让表达式产生「呼吸感」单目操作符贴紧让*p和p不会被误读成乘法或指针运算。括号内侧不加空格因为在 C/C 里括号本身已经是足够清晰的分界标志。混合使用这些规则时整体清晰比每条规则机械执行更重要长语句里局部不加空格是允许的。3. 注释规范20% 注释量与三种头模板3.1 注释量 20% 起步怎么统计为什么不是越多越好规则 3-1 要求源程序有效注释量必须在 20% 以上建议 20%~30%。注释的行数统计可以交给工具cloc能按文件输出代码行、注释行和空白行cloc --by-file src/*.ccloc的运行结果里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 章「标识符命名」在正文里没有展开具体规则但从全文示例可以反推出整套命名体系。以规范里反复出现的gRprRepssnInd、gSysAcbTaskTable、SYS_MAX_ACT_TASK_NUMBER、RPR_STAT_SIZE_PER_FRAM为例命名要素示例含义全局变量g前缀gRprRepssnIndglobal标识全局变量模块缩写Rpr、Sys区分消息处理模块、系统任务模块语义部分RepssnInd变量实际含义挂接子系统索引 指示位全大写宏/常量SYS_MAX_ACT_TASK_NUMBER配置参数、常量类型命名UINT8、UINT32、STATUS统一类型长度不依赖编译器 int 实现这套命名里最有价值的是g前缀看到变量名就知道它的作用域是全局不能随便塞进一个局部作用域里隐藏掉。模块缩写保留了业务归属两个同名的count在Rpr和Sys模块下不会混用。类型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_IND和SCP_UNITDATA_REQ的区别直接决定消息往哪条链路走这类注释省去的是每次翻协议文档的时间。全局变量要求更严格要写出取值范围和存取它的函数避免多个模块随意改写同一个状态/* 全局错误码。0SUCCESS, 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 0x01uMAX_NUM外层加括号避免整个宏替换进表达式后与相邻操作符错误结合参数(a)、(b)也加括号避免传入a、a b这类表达式时展开结果和预期不一致。CLOCK_HZ后面的72u后缀表示无符号数参与除法、比较时不会触发隐式符号转换这在嵌入式里很实用因为裸机代码里经常拿时钟频率去做超时换算。条件编译则是宏最难被替代的场景比如用#ifdef BOARD_A编译不同硬件版本的驱动。能用enum和static 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 HEADgit diff --check会检查 diff 里的空白错误包括行尾空格、Tab 导致的缩进混乱。这个命令比代码评审机器人都早一步发现问题每次提交前跑一遍能避免后续 review 时满屏的空白 diff。提交文件时不要把编译输出目录带进版本库Keil 工程里的 Objects、Listings 目录需要在.gitignore里排除否则每次编译都会产生大量无关的 diff干扰真正的代码变更。格式化和功能逻辑分开提交也是为了避免「一个 commit 里既有缩进调整又有业务修改」出了问题没法单独回滚。5.3 Keil uVision5 编辑器配置附录 A 专门讲了推荐编辑器的默认配置修改。Keil uVision5 中打开Edit - Configuration - EditorTab 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 就会干净很多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门