[关闭]
@taqikema 2020-02-03T04:41:58.000000Z 字数 18402 阅读 2037

华为 C语言编程规范笔记

华为 C语言 规范 笔记


此文档虽说是阅读《华为 C语言编程规范》的笔记,但更多的是原文的提纲+阅读感受,建议大家还是阅读原文,毕竟原文本身也不多,很快就能看完,重点是要在自己平时写代码时能够想到、坚持这样的代码风格!

0 规范制订说明

0.1 前言

0.2 代码总体原则

1.清晰第一
代码首先是给人读的,一般情况下,代码的可阅读性高于性能,只有确定性能是瓶颈时,才应该主动优化。
2.简洁为美
废弃的代码(没有被调用的函数和全局变量)要及时清除,重复代码应该尽可能提炼成函数。
3.选择合适的风格,与代码原有风格保持一致
如果重构/修改其他风格的代码时,比较明智的做法是根据现有代码的现有风格继续编写代码,或者使用格式转换工具进行转换成公司内部风格。

0.3 规范实施、解释

0.4 术语定义

1 头文件

对于C语言来说,头文件的设计体现了大部分的系统设计。不合理的头文件布局是编译时间过长的根因,不合理的头文件实际上不合理的设计。想要设计出合理的头文件,需要考虑到实现者(做出来是否方便)和使用者(用起来是否方便)。
依赖将导致编译时间的上升。

原则1.1 头文件中适合放置接口的声明,不适合放置实现。

头文件中应放置对外部的声明,如对外提供的函数声明、宏定义、类型定义等,而*内部使用的函数(相当于类的私有方法)声明、内部使用的宏、枚举、结构定义、变量定义不应放在头文件中。
变量的声明尽量不要放在头文件中,亦即尽量不要使用全局变量作为接口。变量是模块或单元的内部实现细节,不应通过在头文件中声明的方式直接暴露给外部,应通过函数接口的方式进行对外暴露。即使必须使用全局变量,也只应当在.c中定义全局变量,在.h中仅声明变量为全局的。

原则1.2 头文件应当职责单一。

头文件过于复杂,依赖过于复杂是导致编译时间过长的主要原因。

原则1.3 头文件应向稳定的方向包含。

头文件的包含关系是一种依赖,一般来说,应当让不稳定的模块依赖稳定的模块,从而当不稳定的模块发生变化时,不会影响(编译)稳定的模块。产品依赖于平台,平台依赖于标准库。

规则1.1 每一个.c文件应有一个同名.h文件,用于声明需要对外公开的接口。

规则1.2 禁止头文件循环依赖。

规则1.3 .c/.h文件禁止包含用不到的头文件。

规则1.4 头文件应当自包含。

简单的说,自包含就是任意一个头文件均可独立编译。如果一个文件包含某个头文件,还要包含另外一个头文件才能工作的话,就会增加交流障碍,给这个头文件的用户增添不必要的负担。

规则1.5 总是编写内部#include保护符(#define 保护)。

1.所有头文件都应当使用#define 防止头文件被多重包含,命名格式为FILENAME_H,为了保证唯一性,更好的命名是PROJECTNAME_PATH_FILENAME_H。
2.没有在宏最前面加上“_",即使用FILENAME_H代替_FILENAME_H,是因为一般以"_"和”__"开头的标识符为系统保留或者标准库使用,在有些静态检查工具中,若全局可见的标识符以"_"开头会给出告警。

规则1.6 禁止在头文件中定义变量。

在头文件中定义变量,将会由于头文件被其他.c文件包含而导致变量重复定义。

规则1.7 只能通过包含头文件的方式使用其他.c提供的接口,禁止在.c中通过extern的方式使用外部函数接口、变量。

extern写法容易在函数定义改变时可能导致声明和定义不一致。

规则1.8 禁止在extern "C"中包含头文件。

示例:错误的使用方式:

  1. extern "C"
  2. {
  3. #include "xxx.h"
  4. ...
  5. }

正确的使用方式:

  1. #include "xxx.h"
  2. extern "C"
  3. {
  4. ...
  5. }

建议1.1 一个模块通常包含多个.c文件,建议放在同一个目录下,目录名即为模块名。为方便外部使用者,建议每一个模块提供一个.h,文件名为目录名。

可以的情况下,对于使用者仅提供一个 .h文件,这样,用户不必知道模块内部各个文件的关系,也方便开发人员能在不改变用户使用接口的同时进行后续维护。

建议1.2 如果一个模块包含多个子模块,则建议每一个子模块提供一个对外的.h,文件名为子模块名。

建议1.3 头文件不要使用非习惯用法的扩展名,如.inc。

一些IDE工具无法识别其为头文件,导致很多功能不可用。

建议1.4 同一产品统一包含头文件排列方式。

常见的包含头文件排列方式:功能块排序、文件名升序、稳定度排序。
1.以升序方式排列头文件可以避免头文件被重复包含。
2.以稳定度排序,建议将不稳定的头文件放在前面,如把产品的头文件放在平台的头文件前面。

2 函数

函数设计的精髓:编写整洁函数,同时把代码有效组织起来。
整洁函数要求:简单直接、直截了当的控制语句
代码的有效组织:逻辑层,主要是把不同功能的函数通过某种联系组织起来,主要关注模块间的接口,也就是模块的架构;物理层,用一种标准的方法将函数组织起来,如目录结构、函数命名。

原则2.1 一个函数仅完成一件功能。

将没有关联或者关联很弱的语句放到同一函数中,会导致函数职责不明确,难以理解,难以测试和改动。

原则2.2 重复代码应该尽可能提炼成函数。

项目组应当使用代码重复度检查工具,在持续集成环境中持续检查代码重复度指标变化趋势,并对新增重复代码及时重构。当一段代码重复两次时,即应考虑消除重复,当代码重复超过三次时,应当立刻着手消除重复。

规则2.1 避免函数过长,新增函数不超过50行(非空非注释行)。

过长的函数往往意味着函数功能不单一,过于复杂,但算法实现类函数除外。

规则2.2 避免函数的代码块嵌套过深,新增函数的代码块嵌套不超过4层。

函数的代码块嵌套深度指的是函数中的代码控制块(例如:if、for、while、switch等)之间互相包含的深度。每级嵌套都会增加阅读代码时的脑力消耗,因为需要在脑子里维护一个“栈”(比如,进入条件语句、进入循环……)。应该做进一步的功能分解,从而避免使代码的阅读者一次记住太多的上下文。

规则2.3 可重入函数应避免使用共享变量;若需要使用,则应通过互斥手段(关中断、信号量)对其加以保护。

共享变量指的全局变量和static变量

规则2.4 对参数的合法性检查,由调用者负责还是由接口函数负责,应在项目组/模块内应统一规定。缺省由调用者负责。

对于模块间接口函数的参数的合法性检查这一问题,往往有两个极端现象,即:要么是调用者和被调用者对参数均不作合法性检查,结果就遗漏了合法性检查这一必要的处理过程,造成问题隐患;要么就是调用者和被调用者均对参数进行合法性检查,这种情况虽不会造成问题,但产生了冗余代码,降低了效率。

规则2.5 对函数的错误返回码要全面处理。

一个函数(标准库中的函数/第三方库函数/用户定义的函数)要能够提供一些指示错误发生的方法,这可以通过使用错误标记、特殊的返回数据或者其他手段。

规则2.6 设计高扇入,合理扇出(小于7)的函数。

扇出是指一个函数直接调用(控制)其它函数的数目,而扇入是指有多少上级函数调用它。
扇出过大,表明函数过分复杂,需要控制和协调过多的下级函数;而扇出过小,例如:总是1,表明函数的调用层次可能过多,这样不利于程序阅读和函数结构的分析,并且程序运行时会对系统资源如堆栈空间等造成压力。通常函数比较合理的扇出(调度函数除外)通常是3~5。
较良好的软件结构通常是顶层函数的扇出较高,中层函数的扇出较少,而底层函数则扇入到公共模块中。

规则2.7 废弃代码(没有被调用的函数和变量)要及时清除。

建议2.1 函数不变参数使用const。

不仅使代码更安全,也更易于源码的阅读和理解。

建议2.2 函数应避免使用全局变量、静态局部变量和I/O操作,不可避免的地方应集中使用。

带有内部“存储器”的函数的功能可能是不可预测的,因为它的输出可能取决于内部存储器(如某标记)的状态。这样的函数既不易于理解又不利于测试和维护。

建议2.3 检查函数所有非参数输入的有效性,如数据文件、公共变量等。

建议2.4 函数的参数个数不超过5个。

建议2.5 除打印类函数外,不要使用可变长参函数。

可变长参函数的处理过程比较复杂容易引入错误,而且性能也比较低,使用过多的可变长参函数将导致函数的维护难度大大增加。

建议2.6 在源文件范围内声明和定义的所有函数,除非外部可见,否则应该增加static关键字。

建议定义一个STATIC宏,在调试阶段,将STATIC定义为static,版本发布时,改为空,以便于后续的打热补丁等操作

3 标识符命名与定义

unix like风格:单词用小写字母,每个单词直接用下划线‘_’分割,例如text_mutex。
Windows风格:大小写字母混用,单词连在一起,每个单词首字母大写。
匈牙利命名法:基本类型、一个或更多的前缀、一个限定词。
对标识符定义主要是为了让团队的代码看起来尽可能统一,有利于代码的后续阅读和修改,产品可以根据自己的实际需要指定某一种命名风格,规范中不再做统一的规定。

3.1 通用命名规则

原则3.1 标识符的命名要清晰、明了,有明确含义,同时使用完整的单词或大家基本可以理解的缩写,避免使人产生误解。

尽可能给出描述性名称,不要节约空间,让别人很快理解你的代码更重要
好的命名:

  1. int error_number;

不好的命名:使用模糊的缩写或随意的字符:

  1. int n;
  2. int nerr;

原则3.2 除了常见的通用缩写以外,不使用单词缩写,不得使用汉语拼音。

较短的单词可通过去掉“元音”形成缩写,较长的单词可取单词的头几个字母形成缩写,一些单词有大家公认的缩写,常用单词的缩写必须统一。协议中的单词的缩写与协议保持一致。
常见可以缩写的例子:
argument 可缩写为 arg
buffer 可缩写为 buff
clock 可缩写为 clk
command 可缩写为 cmd
compare 可缩写为 cmp
configuration 可缩写为 cfg
device 可缩写为 dev
error 可缩写为 err
hexadecimal 可缩写为 hex
increment 可缩写为 inc、
initialize 可缩写为 init
maximum 可缩写为 max
message 可缩写为 msg
minimum 可缩写为 min
parameter 可缩写为 para
previous 可缩写为 prev
register 可缩写为 reg
semaphore 可缩写为 sem
statistic 可缩写为 stat
synchronize 可缩写为 sync
temp 可缩写为 tmp

规则3.1 产品/项目组内部应保持统一的命名风格

建议3.1 用正确的反义词组命名具有互斥意义的变量或相反动作的函数等。

示例:
add/remove begin/end create/destroy insert/delete first/last get/release increment/decrement put/get add/delete lock/unlock open/close min/max old/new start/stop next/previous source/target show/hide send/receive source/destination copy/paste up/down

建议3.2 尽量避免名字中出现数字编号,除非逻辑上的确需要编号。

建议3.3 标识符前不应添加模块、项目、产品、部门的名称作为前缀。

很多已有代码中已经习惯在文件名中增加模块名,这会导致文件名太长,并且不利于维护和代码的移植。

建议3.4 平台/驱动等适配代码的标识符命名风格保持和平台/驱动一致。

建议3.5 重构/修改部分代码时,应保持和原有代码的命名风格一致。

3.2 文件命名规则

建议3.6 文件命名统一采用小写字符

因为不同系统对文件名大小写处理会不同(如MS的DOS、Windows系统不区分大小写,但是Linux系统则区分),所以代码文件命名建议统一采用全小写字母命名。

3.3 变量命名规则

规则3.2 全局变量应增加“g_”前缀。

规则3.3 静态变量应增加“s_”前缀。

规则3.4 禁止使用单字节命名变量,但允许定义i、j、k作为局部循环变量。

建议3.7 不建议使用匈牙利命名法

变量命名需要说明的是变量的含义,而不是变量的类型。在变量命名前增加类型说明,反而降低了变量的可读性;更麻烦的问题是,如果修改了变量的类型定义,那么所有使用该变量的地方都需要修改。

建议3.8 使用名词或者形容词+名词方式命名变量。

3.4 函数命名规则

建议3.9 函数命名应以函数要执行的动作命名,一般采用动词或者动词+名词的结构。

如 GetCurrentDirectory。

建议3.10 函数指针除了前缀,其他按照函数的命名规则命名。

3.5 宏的命名规则

规则3.5 对于数值或者字符串等等常量的定义,建议采用全大写字母,单词之间加下划线'_'的方式命名(枚举同样建议使用此方式定义)。

规则3.6 除了头文件或编译开关等特殊标识定义,宏定义不能使用下划线'_'开头和结尾。

4 变量

原则4.1 一个变量只有一个功能,不能把一个变量用作多种用途。

原则4.2 结构功能单一;不要设计面面俱到的数据结构。

相关的一组信息才是构成一个结构体的基础,结构的定义应该可以明确的描述一个对象,而不是一组相关性不强的数据的集合。

原则4.3 不用或者少用全局变量。

直接使用其他模块的私有数据,将使模块间的关系逐渐走向“剪不断理还乱”的耦合状态,这种情形是不允许的。

规则4.1 防止局部变量与全局变量同名。

规则4.2 通讯过程中使用的结构,必须注意字节序。

通讯报文中,字节序是一个重要的问题,我司设备使用的cpu类型复杂多样,大小端、32位/64位的处理器也都有,如果结构会在报文交互过程中使用,必须考虑字节序问题。
数据成员发送前,都应该进行主机序到网络序的转换;接收时,也必须进行网络序到主机序的转换。

规则4.3 严禁使用未经初始化的变量作为右值。

建议4.1 构造仅有一个模块或函数可以修改、创建,而其余有关模块或函数只访问的全局变量,防止多个不同模块或函数都可以修改、创建同一全局变量的现象。

建议4.2 使用面向接口编程思想,通过API访问数据:如果本模块的数据需要对外部模块开放,应提供接口函数来设置、获取,同时注意全局数据的访问互斥。

避免直接暴露内部数据给外部模型使用,是防止模块间耦合最简单有效的方法。

建议4.3 在首次使用前初始化变量,初始化的地方离使用的地方越近越好。

未初始化变量是C和C++程序中错误的常见来源。在变量首次使用前确保正确初始化。在较好的方案中,变量的定义和初始化要做到亲密无间。

建议4.4 明确全局变量的初始化顺序,避免跨模块的初始化依赖。

建议4.5 尽量减少没有必要的数据类型默认转换与强制转换。

有符号和无符号类型的相互转换,在平台迁移时有时会出现问题。建议变量定义可以使用类似 S8/U8的宏进行,这样也便于变量类型的修改。

5 宏、常量

规则5.1 用宏定义表达式时,要使用完备的括号。

规则5.2 将宏所定义的多条表达式放在大括号中。

防止在 if或 for中直接使用宏而不添加大括号时出错,更好的方法是多条语句写成do while(0)的方式。

规则5.3 使用宏时,不允许参数发生变化。

规则5.4 不允许直接使用魔鬼数字。

对于广泛使用的数字,必须定义const全局变量/宏;同样变量/宏命名应是自注释的。

建议5.1 除非必要,应尽可能使用函数代替宏。

宏对比函数,有一些明显的缺点:宏缺乏类型检查,不如函数调用检查严格;宏展开可能会产生意想不到的副作用;以宏形式写的代码难以调试难以打断点,不利于定位问题。

建议5.2 常量建议使用const定义代替宏。

尽量用编译器而不用预处理。
#define ASPECT_RATIO 1.653,编译器会永远也看不到ASPECT_RATIO这个符号名,因为在源码进入编译器之前,它会被预处理程序去掉,于是ASPECT_RATIO不会加入到符号列表中。如果涉及到这个常量的代码在编译时报错,就会很令人费解,因为报错信息指的是1.653,而不是ASPECT_RATIO。如果ASPECT_RATIO不是在你自己写的头文件中定义的,你就会奇怪1.653是从哪里来的,甚至会花时间跟踪下去。

建议5.3 宏定义中尽量不使用return、goto、continue、break等改变程序流程的语句。

如果在宏定义中使用这些改变流程的语句,很容易引起资源泄漏问题,使用者很难自己察觉。

6 质量保证

原则6.1 代码质量保证优先原则(性能并没有放在那么靠前的位置,有些意外!)

原则6.2 要时刻注意易混淆的操作符。

易混淆的操作符,如:赋值操作符“=” 逻辑操作符“==” 关系操作符“<” 位操作符"<<" 关系操作符“>” 位操作符“>>” 逻辑操作符“||” 位操作符"|" 逻辑操作符“&&” 位操作符"&" 逻辑操作符"!" 位操作符“~”。
易用错的操作符,如:除操作符"/"、求余操作符"%"、自加、自减操作符“++”、“--”。

原则6.3 必须了解编译系统的内存分配方式,特别是编译系统对不同类型的变量的内存分配规则,如局部变量在何处分配、静态变量在何处分配等。

原则6.4 不仅关注接口,同样要关注实现。

规则6.1 禁止内存操作越界。坚持下列措施可以避免内存越界:

规则6.2 禁止内存泄漏。坚持下列措施可以避免内存泄漏:

规则6.3 禁止引用已经释放的内存空间。坚持下列措施可以避免引用已经释放的内存空间:

规则6.4 编程时,要防止差1错误。

此类错误一般是由于把“<=”误写成“<”或“>=”误写成“>”等造成的,由此引起的后果,很多情况下是很严重的,所以编程时,一定要在这些地方小心。

规则6.5 所有的if ... else if结构应该由else子句结束;switch语句必须有default分支。

建议6.1 函数中分配的内存,在函数退出之前要释放。

有很多函数申请内存,保存在数据结构中,要在申请处加上注释,说明在何处释放。

建议6.2 if语句尽量加上else分支,对没有else分支的语句要小心对待。

建议6.3 不要滥用goto语句。

goto语句会破坏程序的结构性,所以除非确实需要,最好不使用goto语句。但好处是可以利用goto语句方面退出多重循环。

建议6.4 时刻注意表达式是否会上溢、下溢。

此种问题一般是出现在使用无符号变量时可能会出现边界i溢出情况。

7 程序效率

原则7.1 在保证软件系统的正确性、简洁、可维护性、可靠性及可测性的前提下,提高代码效率。

让一个正确的程序更快速,比让一个足够快的程序正确,要容易得太多。大多数时候,不要把注意力集中在如何使代码更快上,应首先关注让代码尽可能地清晰易读和更可靠。

原则7.2 通过对数据结构、程序算法的优化来提高效率。

建议7.1 将不变条件的计算移到循环体外。

建议7.2 对于多维大数组,避免来回跳跃式访问数组成员。

建议7.3 创建资源库,以减少分配对象的开销。

例如,使用线程池机制,避免线程频繁创建、销毁的系统调用;使用内存池,对于频繁申请、释放的小块内存,一次性申请一个大块的内存,当系统申请内存时,从内存池获取小块内存,使用完毕再释放到内存池中,避免内存申请释放的频繁系统调用

建议7.4 将多次被调用的 “小函数”改为inline函数或者宏实现。

inline函数的优点:其一编译时不用展开,代码SIZE小。其二可以加断点,易于定位问题,例如对于引用计数加减的时候。其三函数编译时,编译器会做语法检查。

8 注释

原则8.1 优秀的代码可以自我解释,不通过注释即可轻易读懂。

优秀的代码不写注释也可轻易读懂,注释无法把糟糕的代码变好,需要很多注释来解释的代码往往存在坏味道,需要重构。

原则8.2 注释的内容要清楚、明了,含义准确,防止注释二义性。

有歧义的注释反而会导致维护者更难看懂代码,正如带两块表反而不知道准确时间。

原则8.3 在代码的功能、意图层次上进行注释,即注释解释代码难以直接表达的意图,而不是重复描述代码。

注释不是为了名词解释(what),不是为了重复描述代码,而是说明用途(why)。

规则8.1 修改代码时,维护代码周边的所有注释,以保证注释与代码的一致性。不再有用的注释要删除。

这个要求本身不难,但是却是在开发过程中很难坚持做到的一点,也是现在我们公司代码里面存在较为广泛的现象。

规则8.2 文件头部应进行注释,注释必须列出:版权说明、版本号、生成日期、作者姓名、工号、内容、功能说明、与其它文件的关系、修改日志等,头文件的注释中还应有函数功能简要说明。

通常头文件要对功能和用法作简单说明,源文件包含了更多的实现细节或算法讨论。

规则8.3 函数声明处注释描述函数功能、性能及用法,包括输入和输出参数、函数返回值、可重入的要求等;定义处详细描述函数功能和实现要点,如实现的简要步骤、实现的理由、设计约束等。

重要的、复杂的函数,提供外部使用的接口函数应编写详细的注释。

规则8.4 全局变量要有较详细的注释,包括对其功能、取值范围以及存取时注意事项等的说明。

规则8.5 注释应放在其代码上方相邻位置或右方,不可放在下面。如放于上方则需与其上面的代码用空行隔开,且与下方代码缩进相同。

这样比较清楚程序编写者的意图,有效防止无故遗漏break语句。

规则8.6 对于switch语句下的case语句,如果因为特殊情况需要处理完一个case后进入下一个case处理,必须在该case语句处理完、下一个case语句前加上明确的注释。

规则8.7 避免在注释中使用缩写,除非是业界通用或子系统内标准化的缩写。

规则8.8 同一产品或项目组统一注释风格。

建议8.1 避免在一行代码或表达式的中间插入注释。

建议8.2 注释应考虑程序易读及外观排版的因素,使用的语言若是中、英兼有的,建议多使用中文,除非能用非常流利准确的英文表达。对于有外籍员工的,由产品确定注释语言。

注释语言不统一,影响程序易读性和外观排版,出于对维护人员的考虑,建议使用中文。

建议8.3 文件头、函数头、全局常量变量、类型定义的注释格式采用工具可识别的格式。

以doxygen格式为例,文件头,函数和全部变量的注释的示例如下:
文件头注释:

  1. /**
  2. * @file (本文件的文件名eg:mib.h)
  3. * @brief (本文件实现的功能的简述)
  4. * @version 1.1 (版本声明)
  5. * @author (作者,eg:张三)
  6. * @date (文件创建日期,eg:2010年12月15日)
  7. */

函数头注释:

  1. /**
  2. *@ Description:向接收方发送SET请求
  3. * @param req - 指向整个SNMP SET 请求报文.
  4. * @param ind - 需要处理的subrequest 索引.
  5. * @return 成功:SNMP_ERROR_SUCCESS,失败:SNMP_ERROR_COMITFAIL
  6. */
  7. Int commit_set_request(Request *req, int ind);

全局变量注释:

  1. /** 模拟的Agent MIB */
  2. agentpp_simulation_mib * g_agtSimMib;

函数头注释建议写到声明处。并非所有函数都必须写注释,建议针对这样的函数写注释:重要的、复杂的函数,提供外部使用的接口函数。

9 排版与格式

规则9.1 程序块采用缩进风格编写,每级缩进为4个空格。

宏定义、编译开关、条件预处理语句可以顶格(或使用自定义的排版方案,但产品/模块内必须保持一致)。

规则9.2 相对独立的程序块之间、变量说明之后必须加空行。

规则9.3 一条语句不能过长,如不能拆分需要分行写。一行到底多少字符换行比较合适,产品可以自行确定。换行时有如下建议:

规则9.4 多个短语句(包括赋值语句)不允许写在同一行内,即一行只写一条语句。

规则9.5 if、for、do、while、case、switch、default等语句独占一行。

规则9.6 在两个以上的关键字、变量、常量进行对等操作时,它们之间的操作符之前、之后或者前后要加空格;进行非对等操作时,如果是关系密切的立即操作符(如->),后不应加空格。

1.在已经非常清晰的语句中没有必要再留空格,如括号内侧(即左括号后面和右括号前面)不需要加空格,多重括号间不必加空格,因为在C语言中括号已经是最清晰的标志了。
2.逗号、分号只在后面加空格
3.比较操作符, 赋值操作符"="、 "+=",算术操作符"+"、"%",逻辑操作符"&&"、"&",位域操作符"<<"、"^"等双目操作符的前后加空格。
4."!"、"~"、"++"、"--"、"&"(地址操作符)等单目操作符前后不加空格。
5."->"、"."前后不加空格。
6. if、for、while、switch等与后面的括号间应加空格,使if等关键字更为突出、明显。

建议9.1 注释符(包括‘/’‘//’‘/’)与注释内容之间要用一个空格进行分隔。

建议9.2 源程序中关系较为紧密的代码应尽可能相邻。

10 表达式

本小节内容虽少,但却是平时写代码过程中容易忽略并且会产生较大影响的问题,需要额外注意。

规则10.1 表达式的值在标准所允许的任何运算次序下都应该是相同的。

说明:除了逗号(,),逻辑与(&&),逻辑或(||)之外,C标准没有规定同级操作符是从左还是从右开始计算,需要保证一个表达式有且只有一个计算结果,较好的方法就是将复合表达式分开写成若干个简单表达式,明确表达式的运算次序,就可以有效消除非预期副作用。
1.自增或自减操作符

  1. x = b[i] + i++;
  2. b[i] 的运算是先于还是后于 i++ 的运算,表达式会产生不同的结果,把自增运算做为单独的语句,可以避免这个问题。

2.函数参数,函数参数通常从右到左压栈,但函数参数的计算次序不一定与压栈次序相同。
示例:

  1. x = func( i++, i);

应该修改代码明确先计算第一个参数:

  1. i++;
  2. x = func(i, i);

3.函数指针
示例:

  1. p->task_start_fn(p++);

求函数地址p与计算p++无关,结果是任意值。必须单独计算p++:

  1. p->task_start_fn(p);
  2. p++;

4.函数调用
示例:

  1. int g_var = 0;
  2. int fun1()
  3. {
  4. g_var += 10;
  5. return g_var;
  6. }
  7. int fun2()
  8. {
  9. g_var += 100;
  10. return g_var;
  11. }
  12. int x = fun1() + fun2();

编译器可能先计算fun1(),也可能先计算fun2(),由于x的结果依赖于函数fun1()/fun2()的计算次序(fun1()/fun2()被调用时修改和使用了同一个全局变量),则上面的代码存在问题。
5.嵌套赋值语句
6.volatile访问
限定符volatile表示可能被其它途径更改的变量,例如硬件自动更新的寄存器。编译器不会优化对volatile变量的读取。

建议10.1 函数调用不要作为另一个函数的参数使用,否则对于代码的调试、阅读都不利。

如下代码不合理,仅用于说明当函数作为参数时,由于参数压栈次数不是代码可以控制的,可能造成未知的输出:

  1. int g_var;
  2. int fun1()
  3. {
  4. g_var += 10;
  5. return g_var;
  6. }
  7. int fun2()
  8. {
  9. g_var += 100;
  10. return g_var;
  11. }
  12. int main(int argc, char *argv[], char *envp[])
  13. {
  14. g_var = 1;
  15. printf("func1: %d, func2: %d\n", fun1(), fun2());
  16. g_var = 1;
  17. printf("func2: %d, func1: %d\n", fun2(), fun1());
  18. }

建议10.2 赋值语句不要写在if等语句中,或者作为函数的参数使用。

1.因为if语句中,会根据条件依次判断,如果前一个条件已经可以判定整个条件,则后续条件语句不会再运行,所以可能导致期望的部分赋值没有得到运行。
2.作用函数参数来使用,参数的压栈顺序不同可能导致结果未知。

  1. int g_var;
  2. int main(int argc, char *argv[], char *envp[])
  3. {
  4. g_var = 1;
  5. printf("set 1st: %d, add 2nd: %d\n", g_var = 10, g_var++);
  6. g_var = 1;
  7. printf("add 1st: %d, set 2nd: %d\n", g_var++, g_var = 10);
  8. }

建议10.3 用括号明确表达式的操作顺序,避免过分依赖默认优先级。

1.一元操作符,不需要使用括号
2.二元以上操作符,如果涉及多种操作符,则应该使用括号
3.即使所有操作符都是相同的,如果涉及类型转换或者量级提升,也应该使用括号控制计算的次序

  1. /* 除了逗号(,),逻辑与(&&),逻辑或(||)之外,C标准没有规定同级操作符是从左还是从右开始计算,以上表达式存在种计算次序:f4 = (f1 + f2) + f3 或f4 = f1 + (f2 + f3),浮点数计算过程中可能四舍五入,量级提升,计算次序的不同会导致f4的结果不同,以上表达式在不同编译器上的计算结果可能不一样,建议增加括号明确计算顺序*/
  2. f4 = f1 + f2 + f3;

建议10.4 赋值操作符不能使用在产生布尔值的表达式上。

11 代码编辑、编译

规则11.1 使用编译器的最高告警级别,理解所有的告警,通过修改代码而不是降低告警级别来消除所有告警。

规则11.2 在产品软件(项目组)中,要统一编译开关、静态检查选项以及相应告警清除策略。

某些语句经编译/静态检查产生告警,但如果你认为它是正确的,那么应通过某种手段去掉告警信息。

规则11.3 本地构建工具(如PC-Lint)的配置应该和持续集成的一致。

规则11.4 使用版本控制(配置管理)系统,及时签入通过本地构建的代码,确保签入的代码不会影响构建成功。

及时签入代码降低集成难度。

建议11.1 要小心地使用编辑器提供的块拷贝功能编程。

12 可测性

原则12.1 模块划分清晰,接口明确,耦合性小,有明确输入和输出,否则单元测试实施困难。

单元测试实施依赖于:

规则12.1 在同一项目组或产品组内,要有一套统一的为集成测试与系统联调准备的调测开关及相应打印函数,并且要有详细的说明。

本规则是针对项目组或产品组的。代码至始至终只有一份代码,不存在开发版本和测试版本的说法。测试与最终发行的版本是通过编译开关的不同来实现的。并且编译开关要规范统一。统一使用编译开关来实现测试版本与发行版本的区别,一般不允许再定义其它新的编译开关。

规则12.2 在同一项目组或产品组内,调测打印的日志要有统一的规定。

统一的调测日志记录便于集成测试,具体包括:

规则12.3 使用断言记录内部假设。

规则12.4 不能用断言来检查运行时错误。

断言的使用是有条件的。断言只能用于程序内部逻辑的条件判断,而不能用于对外部输入数据的判断,因为在网上实际运行时,是完全有可能出现外部输入非法数据的情况。

13 安全性

原则13.1 对用户输入进行检查。

以下场景需要对用户输入进行检验,以确保安全:

这些情况下如果不对用户数据做合法性验证,很可能导致DOS、内存越界、格式化字符串漏洞、命令注入、SQL注入、缓冲区溢出、数据破坏等问题。
可采取以下措施对用户输入检查:
* 用户输入作为数值的,做数值范围检查
* 用户输入是字符串的,检查字符串长度
* 用户输入作为格式化字符串的,检查关键字“%”
* 用户输入作为业务数据,对关键字进行检查、转义

13.1 字符串操作安全

规则13.1 确保所有字符串是以NULL结束。

C语言中’\0’作为字符串的结束符,即NULL结束符。标准字符串处理函数(如strcpy()、strlen())依赖NULL结束符来确定字符串的长度。没有正确使用NULL结束字符串会导致缓冲区溢出和其它未定义的行为。
为了避免缓冲区溢出,常常会用相对安全的限制字符数量的字符串操作函数代替一些危险函数。如:

错误示例:

  1. char a[16];
  2. strncpy(a, "0123456789abcdef", sizeof(a));

正确示例:

  1. char a[16];
  2. strncpy(a, "0123456789abcdef", sizeof(a) - 1 );
  3. a[sizeof(a) - 1] = '\0';

规则13.2 不要将边界不明确的字符串写到固定长度的数组中。

边界不明确的字符串(如来自gets()、getenv()、scanf()的字符串),长度可能大于目标数组长度,直接拷贝到固定长度的数组中容易导致缓冲区溢出。
错误示例:

  1. char buff[256];
  2. char *editor = getenv("EDITOR");
  3. if (editor != NULL)
  4. {
  5. strcpy(buff, editor);
  6. }

正确示例,使用malloc分配指定长度的内存:

  1. char *buff;
  2. char *editor = getenv("EDITOR");
  3. if (editor != NULL)
  4. {
  5. buff = malloc(strlen(editor) + 1);
  6. if (buff != NULL)
  7. {
  8. strcpy(buff, editor);
  9. }
  10. }

13.2 整数安全

规则13.3 避免整数溢出。

当一个整数被增加超过其最大值时会发生整数上溢,被减小小于其最小值时会发生整数下溢。带符号和无符号的数都有可能发生溢出。

规则13.4 避免符号错误。

带符号整型转换到无符号整型,最高位(high-order bit)会丧失其作为符号位的功能。如果该带符号整数的值非负,那么转换后值不变;如果该带符号整数的值为负,那么转换后的结果通常是一个非常大的正数。
错误示例,符号错误绕过长度检查:

  1. #define BUF_SIZE 10
  2. int main(int argc,char* argv[])
  3. {
  4. int length;
  5. char buf[BUF_SIZE];
  6. if (argc != 3)
  7. {
  8. return -1;
  9. }
  10. length = atoi(argv[1]); //如果atoi返回的长度为负数
  11. if (length < BUF_SIZE) // len为负数,长度检查无效
  12. {
  13. memcpy(buf, argv[2], length); /* 带符号的len被转换为size_t类型的无符号整数,负值被解释为一个极大的正整数。memcpy()调用时引发buf缓冲区溢出 */
  14. printf("Data copied\n");
  15. }
  16. else
  17. {
  18. printf("Too many data\n");
  19. }
  20. }

正确示例,将len声明为无符号整型:

  1. #define BUF_SIZE 10
  2. int main(int argc, char* argv[])
  3. {
  4. unsigned int length;
  5. char buf[BUF_SIZE];
  6. if (argc != 3)
  7. {
  8. return -1;
  9. }
  10. length = atoi(argv[1]);
  11. if (length < BUF_SIZE)
  12. {
  13. memcpy(buf, argv[2], length);
  14. printf("Data copied\n");
  15. }
  16. else
  17. {
  18. printf("Too much data\n");
  19. }
  20. return 0;
  21. }

规则13.5 避免截断错误。

将一个较大整型转换为较小整型,并且该数的原值超出较小类型的表示范围,就会发生截断错误,原值的低位被保留而高位被丢弃。截断错误会引起数据丢失。
错误示例,符号错误绕过长度检查:

  1. int main(int argc, char* argv[])
  2. {
  3. unsigned short total = strlen(argv[1]) + strlen(argv[2]) + 1;
  4. char* buffer = (char*)malloc(total);
  5. strcpy(buffer, argv[1]);
  6. strcat(buffer, argv[2]);
  7. free(buffer);
  8. return 0;
  9. }

示例代码中total被定义为unsigned short,相对于strlen()的返回值类型size_t(通常为unsigned long)太小。如果攻击者提供的两个入参长度分别为65500和36,unsigned long的65500+36+1会被取模截断,total的最终值是(65500+36+1)%65536 = 1。malloc()只为buff分配了1字节空间,为strcpy()和strcat()的调用创造了缓冲区溢出的条件。
正确示例,将涉及到计算的变量声明为统一的类型,并检查计算结果:

  1. int main(int argc, char* argv[])
  2. {
  3. size_t total = strlen(argv[1]) + strlen(argv[2]) + 1;
  4. if ((total <= strlen(argv[1])) || (total <= strlen(argv[2])))
  5. {
  6. /* handle error */
  7. return -1;
  8. }
  9. char* buffer = (char*)malloc(total);
  10. strcpy(buffer, argv[1]);
  11. strcat(buffer, argv[2]);
  12. free(buffer);
  13. return 0;
  14. }

13.3 格式化输出安全

规则13.6 确保格式字符和参数匹配。

使用格式化字符串应该小心,确保格式字符和参数之间的匹配,保留数量和数据类型。格式字符和参数之间的不匹配会导致未定义的行为。大多数情况下,不正确的格式化字符串会导致程序异常终止。大部分格式化字符串出问题,都是由于 copy-paste省事导致的,需要格外注意!

规则13.7 避免将用户输入作为格式化字符串的一部分或者全部。

调用格式化I/O函数时,不要直接或者间接将用户输入作为格式化字符串的一部分或者全部。攻击者对一个格式化字符串拥有部分或完全控制,存在以下风险:进程崩溃、查看栈的内容、改写内存、甚至执行任意代码。
错误示例:

  1. char input[1000];
  2. if (fgets(input, sizeof(input) - 1, stdin) == NULL)
  3. {
  4. /* handle error */
  5. }
  6. input[sizeof(input)-1] = '\0';
  7. printf(input);

上述代码input直接来自用户输入,并作为格式化字符串直接传递给printf()。当用户输入的是“%s%s%s%s%s%s%s%s%s%s%s%s”,就可能触发无效指针或未映射的地址读取。格式字符%s显示栈上相应参数所指定的地址的内存。这里input被当成格式化字符串,而没有提供参数,因此printf()读取栈中任意内存位置,指导格式字符耗尽或者遇到一个无效指针或未映射地址为止。
正确示例,给printf()传两个参数,第一个参数为”%s”,目的是将格式化字符串确定下来;第二个参数为用户输入input:

  1. char input[1000];
  2. if (fgets(input, sizeof(input)-1, stdin) == NULL)
  3. {
  4. /* handle error */
  5. }
  6. input[sizeof(input)-1] = '\0';
  7. printf(“%s”, input);

13.4 文件I/O安全

规则13.8 避免使用strlen()计算二进制数据的长度。

strlen()函数用于计算字符串的长度,它返回字符串中第一个NULL结束符之前的字符的数量。因此用strlen()处理文件I/O函数读取的内容时要小心,因为这些内容可能是二进制也可能是文本。
错误示例:

  1. char buf[BUF_SIZE + 1];
  2. if (fgets(buf, sizeof(buf), fp) == NULL)
  3. {
  4. /* handle error */
  5. }
  6. buf[strlen(buf) - 1] = '\0';

上述代码试图从一个输入行中删除行尾的换行符(\n)。如果buf的第一个字符是NULL,strlen(buf)返回0,这时对buf进行数组下标为[-1]的访问操作将会越界。
正确示例,在不能确定从文件读取到的数据的类型时,不要使用依赖NULL结束符的字符串操作函数:

  1. char buf[BUF_SIZE + 1];
  2. char *p;
  3. if (fgets(buf, sizeof(buf), fp))
  4. {
  5. p = strchr(buf, '\n');
  6. if (p)
  7. {
  8. *p = '\0';
  9. }
  10. }
  11. else
  12. {
  13. /* handle error condition */
  14. }

规则13.9 使用int类型变量来接受字符I/O函数的返回值。

字符I/O函数fgetc()、getc()和getchar()都从一个流读取一个字符,并把它以int值的形式返回。如果这个流到达了文件尾或者发生读取错误,函数返回EOF。fputc()、putc()、putchar()和ungetc()也返回一个字符或EOF。
如果这些I/O函数的返回值需要与EOF进行比较,不要将返回值转换为char类型。因为char是有符号8位的值,int是32位的值。如果getchar()返回的字符的ASCII值为0xFF,转换为char类型后将被解释为EOF。因为这个值被有符号扩展为0xFFFFFFFF(EOF的值)执行比较。
错误示例:

  1. char buf[BUF_SIZE];
  2. char ch;
  3. int i = 0;
  4. while ( (ch = getchar()) != '\n' && ch != EOF )
  5. {
  6. if ( i < BUF_SIZE - 1 )
  7. {
  8. buf[i++] = ch;
  9. }
  10. }
  11. buf[i] = '\0'; /* terminate NTBS */

正确做法:使用int类型的变量接受getchar()的返回值。

  1. char buf[BUF_SIZE];
  2. int ch;
  3. int i = 0;
  4. while (((ch = getchar()) != '\n') && ch != EOF)
  5. {
  6. if (i < BUF_SIZE - 1)
  7. {
  8. buf[i++] = ch;
  9. }
  10. }
  11. buf[i] = '\0'; /* terminate NTBS */

13.5 其它

规则13.10 防止命令注入。

如果system()的参数由用户的输入组成,恶意用户可以通过构造恶意输入,改变system()调用的行为。
示例:

  1. system(sprintf("any_exe %s", input));

如果恶意用户输入参数:

  1. happy; useradd attacker

最终shell会将字符串解释为两条独立的命令:“any_exe happy; useradd attacker”。

14 单元测试

规则14.1 在编写代码的同时,或者编写代码前,编写单元测试用例验证软件设计/编码的正确。

建议14.1 单元测试关注单元的行为而不是实现,避免针对函数的测试。

15 可移植性

规则15.1 不能定义、重定义或取消定义标准库/平台中保留的标识符、宏和函数。

建议15.1 不使用与硬件或操作系统关系很大的语句,而使用建议的标准语句,以提高软件的可移植性和可重用性。

使用标准的数据类型,有利于程序的移植。

建议15.2 除非为了满足特殊需求,避免使用嵌入式汇编。

16 业界编程规范

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注