20年前的C/C++编码规范:可读性优先,仍是团队落地标准
简介:《中兴通讯软件编程编码规范》是一份由中兴通讯技术中心研究部发布的企业级C/C++编码标准,适用于公司内使用C/C++编码的所有软件项目,也为外部开发者提供了系统化的编码指导。压缩包共1个文件,为doc格式文档,整体大小486KB,内容完整包含规范正文、附录A编程模板与附录B规范检查表。规范从基本原则入手,强调代码是为人编写、应保持简明清晰,并针对布局缩进、注释写法、命名规则、变量与类型使用、表达式与语句、函数设计、可靠性、可测试性、断言与错误处理等核心环节给出了规则性约束和建议性指引,同时提供大量正反例帮助理解。已有301人学习下载,适合希望统一团队编码风格、提升代码可维护性与健壮性的开发者和技术管理者参考。
1. 一份20年前的C/C++编码规范,凭什么还能当团队的落地标准
很多项目组的C/C++编码规范,最后都长成了一个样子:没人看,也没人执行。我手头这份《软件编程规范——C/C++》,是某通信设备企业2003年发布的企业标准,覆盖布局、注释、命名规则、变量与类型、可靠性、可测试性、断言与错误处理十几个章节,附录还带了一份编程模板和一份规范检查表。它的核心立场很直接:代码首先是写给人看的,其次才给计算机。对于正在搭团队规范的技术负责人,以及想统一自己C/C++编码习惯的嵌入式与通信软件开发者,这份标准给出的不是口号,而是能直接抄进项目里的条款。工具链换了多少代,可读性、可维护性这些诉求一点没变,所以它到今天依然能当落地标准用。
2. 先把总原则立住:文件布局与类定义的基本盘
这一章把规范里最“宏观”的两块拆开:开头的基本原则决定代码的气质,文件布局决定新人打开工程时先从哪读起。这两块没立住,后面再细的格式条款都只是表面功夫。
2.1 七条基本原则:从“为人写程序”到“少用全局变量”
规范开篇给了七条原则,头一条就是“首先是为人编写程序,其次才是计算机”。这句话不是鸡汤。通信设备软件的生命周期很长,从开发、测试、生产到现场维护,代码被阅读的次数远多于被执行的次数。一份难读的代码,后续维护成本会成倍吃掉开发时省下的时间。
其余几条也值得逐条对照:保持代码简明清晰,避免过分的编程技巧;尽量遵循ANSI C标准,不用编译器扩展;先满足正确性再谈效率;避免或少用全局变量;尽量避免GOTO;尽可能重用老代码;减少同样错误出现的次数。其中“正确性优先于效率”在嵌入式领域经常被反着执行——有人为了省几条指令写出位运算炫技代码,结果一换编译器就翻车。效率应该建立在清晰的逻辑之上,让编译器去做优化,比让人去猜你的意图可靠得多。
这份规范还做了一层区分:条款分成“规则性”和“建议性”。规则性条款要求所有开发人员严格执行,建议性条款由项目组根据实际情况取舍。落地的时候,我会把规则性条款直接写进团队的红线清单,建议性条款放进编码模板里引导,既保证底线不被突破,又给项目留出灵活空间。
提示:原则不是摆设。代码评审时拿这七条逐项过一遍,能挡住大部分“看着能跑但没法维护”的提交。
2.2 头文件布局:用固定次序消除“打开文件就懵”的问题
规范要求头文件按固定次序书写:文件头、防重复包含宏、条件编译选项、按标准库到非标准库排列的include、常量定义、全局宏、全局数据类型、类定义模板、全局函数原型、#endif。这个次序的核心逻辑是“从稳定到易变”:先看到不常变的基础设施,再看到业务类型与接口。
下面是我按这份规范整理出的头文件骨架:
/* * 文件头:模块名、创建日期、功能简述、修改记录 */ #ifndef MODULE_H #define MODULE_H #include <stdio.h> /* 标准库头文件用尖括号 */ #include <string.h> #include "local_cfg.h" /* 非标准库头文件用双引号 */ #define MODULE_MAX_BUF 256 #define MODULE_VERSION "1.0" typedef struct { int iLen; char *pcBuf; } ModuleBuf; int ModuleInit(void); int ModuleProcess(int iCmd, void *pvData); #endif /* MODULE_H */这里有两个容易忽略的约定。第一,#include的顺序不能乱写,标准库在前、非标准库在后,能尽早暴露头文件自身的依赖缺失;如果某个非标准头文件里用了标准库类型却没包含对应头文件,编译时会在这个次序下快速报出来。第二,防重复包含宏用MODULE_H这种全大写形式,与文件名对应;宏名别在多个头文件之间撞车,否则后包含的那个头文件会被整个跳过,报出一堆莫名其妙的未定义符号。条件编译选项排在防重复包含宏之后、include之前,是为了让跨平台分支在一打开文件时就能看到。
2.3 实现文件与类定义:先声明后实现,先公有后私有
实现文件的布局更细:文件头、include、常量定义、文件内部宏、文件内部数据类型、静态全局变量、局部函数原型、类实现、全局函数、局部函数。这条规则的意义在于把“对外可见”和“文件内部私有”严格分开。静态全局变量和局部函数原型放在靠前位置,阅读者一眼就能看出哪些东西不跨文件,评审时也方便确认封装边界。
类的定义与实现同样有次序。定义文件里依次是公有属性、公有函数、保护属性、保护函数、私有属性、私有函数;实现文件里依次是构造函数、析构函数、公有函数、保护函数、私有函数。我见过不少团队把私有函数插在公有函数中间,理由是“顺手写的”,结果头文件读起来像一道迷宫。按这份规范排序后,接口区、继承区、私有区界限分明,新成员改代码时不会误碰不该碰的东西。
class CCount { public: CCount(void); ~CCount(void); int GetCount(void); void SetCount(int iCount); protected: int m_iProtectedCount; private: int m_iCount; }; CCount::CCount(void) /* 实现文件:构造、析构、公有、保护、私有 */ { m_iCount = 0; m_iProtectedCount = 0; }这套布局规则不是排版洁癖。它让每个文件的结构可预期,而可预期本身就是可维护性的一部分。下一章进入更具体的格式条款,那是新手最容易“看着规范也对不上号”的地方。
3. 布局细节落地:缩进、大括号、空格与断行的可抄作业写法
如果说第2章是骨架,这一章就是血肉。布局部分的条款最细,也最适合直接抄进编辑器配置和代码模板里。照着写,代码风格立刻统一。
3.1 缩进:禁用TAB,统一4个空格
规范写得很绝对:禁止使用TAB键,必须使用空格进行缩进,缩进为4个空格。当年这么定,是因为不同编辑器的TAB宽度设置五花八门,同一个\t在A编辑器里是4格,到B编辑器里变成8格,代码对齐瞬间崩塌。这个坑今天依然存在——只要跨IDE协作,TAB宽度就有差异,用空格就没有这个争端。
void Function(int iVar) { if (iVar > 0) { DoSomething(); /* 块内缩进4个空格 */ } }注意上面的写法里,{独占一行并与函数名左对齐,}也独占一行并回到同一列。我曾经在混合使用TAB和空格的代码里排查过一次对齐问题,最终定位是某行前面混进了两个\t和六个空格,肉眼完全看不出来。从那以后,我所在的项目组编辑器一律配置“插入空格代替TAB”,提交前再跑一次检查脚本,这个问题就绝迹了。
3.2 大括号规则:if、else、for、while一律强制加{ }
规范要求:if、else、else if、for、while、do等语句自占一行,执行语句不得紧跟其后,不论执行语句有多少都要加{ }。这个条款最直接的价值是防止“悬挂else”和后续增加语句时忘记加括号。
/* 反例:执行语句紧跟if,且没有大括号 */ if (iVar1 < iVar2) iVar1 = iVar2; /* 正例:条件自占一行,执行语句用大括号包住 */ if (iVar1 < iVar2) { iVar1 = iVar2; }反例在只有一行语句时没问题,但下一个人往里加第二行时就容易写错归属。项目里有过血泪教训:某段网上流传的代码,维护的人想当然地在iVar1 = iVar2;下面加了一行日志,没加括号,日志就变成了无条件执行。规范要求“不论多少都要加{ }”,实际上是用一行代码的成本买断这一类低级故障。
3.3 空格:一元操作符不加,二元操作符加,关键字后留
这一组规则看似琐碎,作用却很大。一元操作符(!、~、++、--、*、地址&)前后不加空格;[]、.、->前后不加空格;二元操作符与操作数之间至少一个空格;关键字(if、for、while)之后留空格再接左括号,而函数名之后不留空格。
!bValue ++iCount *strSource fValue = fOldValue + fNewValue; iCount += 2; if (iVar > 0) { DoSomething(iWidth, iHeight); } for (i = 0; i < MAX_BUF_NUM; i++) { /* ... */ }这套约定的真正价值,在于区分“关键字”和“函数”。if (和Foo(在视觉上一眼可辨:带空格的是语法关键字,紧跟括号的是函数调用。函数名后不留空格、括号向后紧跟、,之后留空格,都服务于同一个目的——让代码的语法结构通过空白直接显形,减少阅读时的解析负担。注释符与注释内容之间留一个空格,也是同一套逻辑,让注释正文不与定界符黏在一起。
3.4 长表达式断行:在低优先级操作符处拆,操作符放行首
规范要求超过80列的长表达式在低优先级操作符处拆行,操作符放在新行之首,方便在行首看到“这是个加法还是比较”。续行要对齐:条件表达式在第一个条件处对齐,for循环在初始化语句处对齐,函数调用在第一个参数处对齐,赋值语句在赋值号处对齐。
if ((iFormat == CH_A_FORMAT_M) && (iOfficeType == CH_BSC_M)) { /* &&放行首,条件对齐 */ DoSomething(); } for (iIndex = 0; iIndex < MAX_BUF_NUM; iIndex++) { /* for续行对齐初始化处 */ /* ... */ } fTotalBill = fTotalBill + faCustomerPurchases[iID] /* 对齐赋值号 */ + fSalesTax(faCustomerPurchases[iID]);行首放操作符的做法在评审时特别好用:扫一眼每行行首的&&、||、+,就能看出这一段表达式是叠加还是分支。80列的限制则保证了代码在任何终端、任何对比工具里都完整可读,不至于出现横向滚动才能看清的尴尬。函数声明时还有一条配套约束:类型与名称不允许分行书写,保证函数签名在搜索和diff时都是一个完整整体。
4. 命名、注释与函数设计:可读性是约定出来的
布局解决“形”,这一章解决“名”和“意”。命名规则、注释规范和函数设计要求,是这份规范里被引用得最多的部分,也是团队风格差距最容易暴露的地方。
4.1 命名规则:前缀表达类型,后缀表达语义
规范里的示例透露了这套命名体系的大致样貌:指针变量用p前缀,整数用i,无符号字用w,浮点用f,字符用c,字符串数组用str;类名用C开头,成员变量用m_打头再加类型前缀;类型定义用大写或特定命名;宏和常量用全大写加下划线。
| 前缀/标记 | 含义 | 示例 |
|---|---|---|
| p | 指针 | float *pfBuffer |
| i | 整型 | int iLength |
| w | 字宽无符号 | WORD wTotal |
| f | 浮点 | float fValue |
| c | 字符 | char cFlag |
| str | 字符串数组 | char strName[32] |
| m_ | 类成员变量 | int m_iCount |
| C | 类名开头 | class CCount |
float *pfBuffer; /* p=指针,f=浮点 */ int iLength; /* i=整型 */ WORD wTotal; /* w=字宽 */ char cFlag; /* c=字符 */ char strName[32]; /* str=字符串 */ class CCount { public: CCount(void); int GetCount(void); void SetCount(int iCount); private: int m_iCount; /* m_=成员变量 */ };这套前缀体系最大的价值在于“不用跳转定义就能猜出类型”:看到pf开头就知道是指向浮点的指针,看到m_就知道是类内部状态。它也有代价——类型一变,名字就得跟着改,否则就是货不对板。我一般会在团队里把前缀表做成固定对照表,评审时按表核对,比各人凭感觉起名可靠得多。变量名的语义部分用驼峰补齐,函数名尽量动词开头,像GetCount、SetCount这样,调用点读起来已经像一句人话。
4.2 注释规则:写意图,不写表面
注释部分的条款相对简洁:C语言注释用/* ... */,C++里多行用/* ... */、单行用// ...,注释符与注释内容之间要留一个空格。更重要的是规范强调的注释层次——在功能、意图层次上做注释,而不是重复代码表面。
/* 反例:注释复述代码,毫无信息量 */ iCount++; // 把iCount加1 /* 正例:注释解释为什么这么做 */ iCount++; // 补偿上一次查询遗漏的超时记录,避免计数偏低写注释最容易犯的毛病就是把代码翻译成中文。无效注释不仅不帮忙,还会随着代码修改变得越来越假,最后误导后来人。我习惯的检查标准是:把注释遮住,只读代码,能不能知道这行为什么存在?如果不能,说明注释缺的是“原因”,而不是“动作”。文件头注释建议用固定模板:模块名、功能简述、创建日期、修改记录,这是附录里编程模板给出的做法,能让一份文件的变更历史不依赖版本管理工具就快速追溯。
4.3 函数与过程:单一职责、参数检查、声明不断行
函数设计的规则可以提炼成三条:函数尽量小,只做一件事;参数和返回值要清晰,必要时做有效性检查;函数声明时类型与名称不允许分行书写,保证签名一眼可读。
/* 正例:一次只做一件事,参数检查前置 */ int ModuleSetBuf(ModuleBuf *ptBuf, int iLen) { if (ptBuf == NULL) { return -1; } if (iLen <= 0 || iLen > MODULE_MAX_BUF) { return -2; } ptBuf->iLen = iLen; return 0; }函数内部逻辑清晰的重要标志是“读起来像文章”:开头是参数校验,中间是主流程,结尾是返回值。参数校验放在最前面的好处是失败路径集中,不会等执行到中段才发现参数不合法,把状态改了一半才返回错误码。返回值方面,我推荐用0表示成功、负数枚举错误的约定,调用点统一处理,比到处散落return 1、return -1这种魔法数字好维护得多。规范里“类型与名称不允许分行书写”那条,则是防止函数签名被断行拆碎,导致搜索和diff都不方便。
5. 避坑排查:可靠性、可测试性与断言的实战翻车记录
前四章讲的都是“怎么写”,这一章讲“写坏了会怎样”。可靠性、可测试性、断言与错误处理这三章,是规范里最有实战分量、也最容易在评审时被跳过的地方。下面四条踩坑记录,每条都按“现象—原因—解决”展开,你大概率在项目里见过同款。
5.1 全局变量失控:改一处,崩一片
现象:一个用全局数组做通信缓冲区的模块,某次改造给数组加了个保护锁,结果消息处理线程和解复用线程同时改了相邻字段,现场偶发死机,问题压了两周。
原因:模块间通过全局变量隐式共享数据,谁都可以读写,开发和维护过程中根本数不清有多少调用点。全局变量的每一次修改都像在黑匣子里做手术,看不见牵连面。
解决:按规范原则“避免或少用全局变量”,把共享数据封装进结构体,通过函数接口访问,读写加锁逻辑收拢到两个函数里。从那以后,我在代码里看到新引入的extern变量,评审时都会追着问一句:这个必须全局吗?
5.2 函数多个return出口:单元测试无从下手
现象:一个函数里有三处return,分别藏在两个if嵌套和一个switch分支里。测例覆盖了主干,漏掉了中间那个提前返回的分支,结果线上触发后数据没有落盘。
原因:控制流出口太多,测试分支数量成倍增长,人脑根本数不全。多出口函数还有一个隐蔽问题:每个出口前都要记得释放资源,漏掉一个就是泄漏。
解决:规范里可测试性部分的导向就是控制流清晰、尽量单入口单出口。把提前返回的条件先集中判断,失败统一走末尾的错误处理,函数出口收敛到一两个,测试路径立刻可数。不是所有函数都必须单出口,但出口越多,越要怀疑函数是不是塞了太多逻辑。
5.3 断言当错误处理用:发布版本直接崩溃
现象:代码里用assert(ptr != NULL)校验从消息里解析出来的指针,自测时没事,上线后收到一个空指针,程序直接终止。
原因:断言是用来发现“程序自身编程错误”的,不是用来处理“外部输入、运行时异常”的。正式发布版本里断言要么被编译掉,要么触发abort,拿它挡用户输入,等于把崩溃开关交给对方。
解决:按规范第12章的划分,编程错误用断言(内部不变量被破坏),运行时错误用条件判断加错误码返回。我在团队里立了条习惯:断言只出现在“这里不可能为假”的地方,凡是数据可能不合法的地方一律走if分支。
5.4 GOTO清理资源:控制流变成意大利面
现象:某模块在错误处理时用goto跳转到统一释放资源的标签,起初只有三处跳转,后来加了两个分支,标签前的逻辑越来越长,阅读时需要在多个位置之间来回跳。
原因:GOTO本身不可怕,可怕的是它让控制流变成一张网。规范原则说“尽量避免使用GOTO”,实际项目里资源清理场景确实需要集中出口,但跳转满天飞就违背了初衷。
解决:优先把每个分支的资源释放写进各自作用域内,或者用do { ... } while(0)包裹,用break代替goto;即使保留GOTO,也要保证跳转方向单一、标签只在函数末尾出现一次。评审时看到goto不是一票否决,而是重点审查它的跳转距离和位置数量。
6. 把规范变成提交前动作:检查表与两个自动化小脚本
附录里的规范检查表和编程模板,是这份标准里最容易被忽略的宝藏。检查表的用法不是评审时拿在手里一条条念,而是把它拆成提交前的自检清单:布局是否用了4空格缩进、有没有TAB混入、行宽有没有超过80列、新文件头注释是否齐全、命名前缀是否符合对照表、函数出口是否收敛。我习惯把这张表简化成十个问题贴在工位旁,每次提交前过一遍,五分钟能挡住八成格式问题。
检查表里“可执行”的部分,可以交给脚本自动化。第一个脚本扫TAB和行宽:
grep -rnP '\t' src include && echo "find TAB, please replace with 4 spaces" awk 'length($0) > 80 {print FILENAME":"NR" length:"length($0)}' src/*.c include/*.hgrep -P '\t'用Perl风格正则匹配真正的TAB字符,跑出来就是需要替换的位置;awk按行统计长度,超过80列就打印文件名和行号。这两个命令在Linux和Windows的Git Bash环境下都能直接跑,不需要装额外工具。
第二个脚本做命名抽查,比如搜索类成员声明里有没有漏掉m_前缀,或者检查全局函数原型之前是否都有注释块。这种检查不需要覆盖全部规则,抓住高成本的几项就够了,剩下人工评审重点放在逻辑上。从那以后我每次改完代码都强制自己跑一遍这两个命令再加提交,算是对20年前那份标准最基本的尊重。希望帮到你。
本文还有配套的精品资源,点击获取