C语言编程常见错误

1.    数据引用错误... 1

1.1      是否有引用的变量未赋值或未初始化... 1

1.2      数组维度是否写死在代码里面... 6

1.3      数组下标的值是否在范围之内... 7

1.4      对全局变量赋值的检查... 7

1.5      宏定义中的宏变量被多次使用... 8

1.6      宏定义的表达式未加括号... 8

1.7      宏定义的变量未加括号... 9

1.8      字节序... 9

2.    数据声明错误... 11

2.1      外部变量的声明和定义... 11

2.2      数组和字符串的初始化是否正确?... 13

2.3      变量是否赋予了正确的长度、类型... 15

3.    运算错误... 16

3.1      存在非算术变量的运算... 16

3.2      不同字长变量间的运算... 16

3.3      丢失精度... 16

3.4      目标变量类型小于赋值变量类型... 17

3.5      除数为0. 17

3.6      计算结果溢出... 17

3.7      操作符优先级有错... 18

3.8      乘法和除法能否使用移位代替... 18

4.    比较运算... 19

4.1      不同类型变量间的比较... 19

4.2      比较操作符错误... 20

4.3      布尔表达式错误... 21

4.4      比较运算与布尔表达式混合... 22

4.5      操作符优先顺序错误... 23

5.    控制流程错误... 24

5.1      超出多条分支路径... 24

5.2      循环未终止... 25

5.3      Switch语句的default分支... 26

5.4      Switch语句的break. 26

5.5      循环体入口条件不满足... 29

5.6      循环体内操作最简化... 29

5.7      存在不能穷尽的判断... 29

5.8      存在执行不到的代码... 30

5.9      必须配对的操作... 30

6.    函数错误... 34

6.1      函数定义与声明不一致... 34

6.2      没有声明函数原型... 37

6.3      实参与形参不一致... 39

6.4      改变仅为输入形参的值... 41

6.5      是否以常数形式传递实参... 44

6.6      函数可重入... 44

6.7      调用标准库函数错误... 46

7.    内存的使用... 47

7.1      数组越界... 48

7.2      动态内存分配... 48

7.3      内存释放与内存泄露... 49

7.4      动态内存越界... 50

7.5      任务堆栈溢出... 50

8.    其它错误... 51

8.1      字符串操作越界... 51

8.2      回调函数错误... 52

8.3      信号量死锁... 53

9.    接口错误... 55

9.1      SW层接口要求... 55

9.2      接口使用者注意事项... 56

9.3      接口提供者注意事项... 56

10.         编码规范... 56

10.1       是否给单个循环、条件语句也加了花括号... 56

10.2       判断和开关语句格式是否符合规范... 58

10.3       代码段落是否被合适 地以空行分隔... 59

10.4       所有的判断是否使用了(常量 == 变量)的形式... 61

10.5       是否有多个短语句写在一行中... 62

10.6       是否每个if-else语句都有最后一个else以确保处理了所有情况... 62

10.7       显式使用IN/OUT来说明输入、输出... 63

10.8       显式地使用const来说明常量... 64

10.9       复杂的分支流程是否已经被注释... 65

10.10     条件编译的书写格式是否正确... 66

10.11     函数粒度是否保持适度大小... 67

11.         错误处理... 68

11.1       是否对所有可能的错误条件进行了处理... 68

1.    数据引用错误

1.1  是否有引用的变量未赋值或未初始化

不合理的初始化数据是产生程序错误的常见根源之一,掌握一些能够避免初始化问题的行之有效的方法对于节省调试时间很有帮助。

不恰当的初始化所导致的一系列问题根源于变量的默认初始值与你预期的不同,以下行为都会产生此类问题。

  1. 从未对变量赋值。它的值只是程序启动时变量所处内存区域的值。
  2. 变量值已过期。变量在某个地方曾经被赋值,但该值已经不再有效。
  3. 变量的一部分被赋值,而另一部分没有。

比如在我们实际的开发过程中出现次数比较多,造成的后果比较严重问题有:操作未经初始化的指针变量,指针在未初始化的状态下指向的是一个随机的地址,然后对其进行操作,这个随机的地址可能存放的是数据也可能是代码,甚至可能指向操作系统内部,甚至可能不是存储器的地址,而是其它设备在CPU地址空间的一个地址。指针操作错误产生的现象可能会很奇怪,并且每次都不相同,这也导致调试这类BUG比其它错误更加困难。

下面给出一些避免初始化错误的建议:

1)     在定义变量的时候初始化,定义变量的时候初始化是一种非常简单方便的防范初始化错误的保险策略。如下代码:

int *p = NULL;

char a[MAX_NUM] = {0};

struct TEST_STRUCT test = {0};

图 1

2)     在靠近变量第一次使用的位置初始化它,如下代码:

struct TEST_STRUCT test;

...

memset(&test,0,sizeof(struct TEST_STRUCT));

if ( ERR_NO_ERROR == getValue(&test))

{

图 2

【知识拓展】

n  关于变量的存储空间、生存周期的相关讨论

变量按照性质划分可以分为:自动变量(auto),静态变量(static),由于自动变量的生存周期依赖于某一段代码,当开始运行这段代码时,自动变量才有会被分配空间,赋初值,当这段代码结束执行时,自动变量的空间会被收回,从而自动变量被销毁;而静态变量的生存周期是从整个程序运行开始到结束,并不依赖于某一段代码执行与否。

由于外部变量肯定是静态变量、自动变量肯定是内部变量,所以我们可以将变量分为:外部变量、静态局部变量和自动变量三种类型。

如下代码所示:

int gNum;

static int lNum = 10;

int function()

{

int a;

static int sta;

return 0;

}

图 3

上图代码中:

gNum是外部变量,作用域是全局空间,在编译阶段就给该变量分配了存储单元,由于是未初始化的全局变量,所以存放在bss段中。使用外部变量的好处有:它可以减少函数的实参与型参的个数,从而降低内存空间以及传递数据时的时间消耗,同时增加了函数间数据传递的渠道。但是我们在使用外部变量时需要注意:

1, 外部变量在程序的全部执行过程中都占用存储单元,而不是仅在需要时分配。

2, 它使函数的通用性降低了,因为函数在执行时要依赖于其所在的外部变量,还可能会降低程序的可靠性和可移植性等。

3, 全局变量使用过多,会降低程序的可读性,人们很难清楚的判断出每个全部变量在执行过程中的状态,程序容易出错。

4, 外部变量和局部变量同名情况下,外部变量将会被屏蔽掉。

所以;如不必要,请尽量不要使用全局变量。

Num是外部变量,作用域是自身所在源文件,在编译阶段就给变量分配存储单元,并初始化。初始化后存放在data段中。

a 为自动变量,作用域是整个function内部,该变量的存放地址为进程的栈,这里我们也就很容易理解为什么自动变量的生存周期依赖于某一段执行的代码,因为只有执行这段代码的时候相关指令才为自动变量从栈里面分配空间,当然,对自动变量的赋初值也不是在编译时进行,而是在函数调用时进行的,每调用一次函数重新给一次初值,执行一次赋值语句,而如果不给自动变量赋初值,则它的值是一个不确定的值。当代码执行完毕时,相关指令将会调整栈指针收回自动变量占用的空间,此时该自动变量也不复存在了。

sta 是静态局部变量,作用域是定义点到function函数结束为止,编译阶段就给该变量分配内存单元,函数调用结束后该存储单元也不会被释放,未初始化状态下该变量存放在bss段。与自动变量不同,定义局部静态变量时不赋初值的话,编译器会自动赋初值为 0 或空字符串,程序运行过程中它已经有初值,以后每次调用函数时不再重新赋初值而只是保留上次调用结束时的值,虽然静态局部变量在函数调用结束后仍然存在,但其它函数不能引用它。

       到这里我们都知道了外部变量和静态局部变量都是在编译阶段就给它分配存储单元,我们可以再总结一下这些变量的存储位置:

  1. 已初始化的外部变量和静态局部变量保存在data段中。
  2. 未初始化的外部变量和静态局部变量编译器会自动赋初值为0或空字符串,他们保存在bss段中。

n  外部变量的命名及初始化

我们经常在编程中碰到一种情况叫做符号重复定义。多个目标文件中含有相同名字的全局符号定义,那么这些目标文件链接的时候就会出现符号重复定义的错误。比如我们在目标文件A和目标文件B中定义一个全局变量global ,并将它初始化,那么链接器将A和B链接时会报错:

(A.o)(.data+0x8): multiple definition of ‘global’

(B.o)(.data+0x4): first defined here

C语言中,初始化了的全局变量称为强符号,未初始化的全局变量为弱符号。针对强弱符号的概念,链接器会按照如下规则处理与选择被多次定义的全局符号:

规则1:不允许强符号被多次定义,即不同的目标文件中不能有同名的强符号,如果有多个强符号定义,则链接器会报错。

规则2:如果一个符号在某个目标文件中是强符号,在其它文件中是弱符号,那么选择强符号。

规则3:如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。比如目标文件A定义全局变量global 为int型,占4个字节;目标文件B定义global 为 double型,占8个字节,那么目标文件A 和B链接后,选择占用空间大double型 global。

我们在实际开发过程中遇到过这样一个问题:

/*test1.c*/

int gNum;

图 4

/*test2.c*/

int gNum;

图 5

文件test1.c 和test2.c同时定义了一个同名的没有经过初始化的全局变量gNum。也许有人会认为这样的定义在链接阶段会有重复定义的错误信息。但是很遗憾,并没有报错,两个gNum都是弱符号,不确定链接器到底选择了哪个符号,这样由于全局变量命名冲突造成的影响是非常致命的,很容易导致很难发现的程序错误。

对于全局变量的命名和初始化,有以下几点建议:

  1. 全局变量的命名必须按照编码规范:类型说明符号 g【模块名字】【后缀】的方式,比如:       int gGarpTimer = 0。静态全局变量命名为:类型说明符号l【模块名字】【后缀】的方式,这里需要说明一下,虽然静态全局变量并不会有冲突的地方,但是按照规则命名后,在开发过程中如果你想将其修改为全局变量,就不需要花很多时间逐个修改变量名了。
  2. 尽量初始化全局变量,让链接器帮你检查有可能产生的命名冲突问题。

n  枚举常量

枚举类型作为ANSI C新标准里面所加内容,在实际开发过程中引用的比较频繁,简单介绍下枚举常量的使用注意事项:

  1. 枚举类型不是变量,不能对它们赋值。
  2. 枚举类型作为常量它们是有值的,按定义顺序他们的默认值为0,1,2….。也可以在定义时由程序员指定其值,后面的元素按序递增。
  3. 枚举值可以用来做判断比较。
  4. 枚举常量并不像宏那样在预编译阶段进行简单的替换,而是在编译阶段确定其值的。

n  volatile、restrict、register修饰的变量

  1. volatile关键字

volatile是易变的,不稳定的意思,很多人根本没有见过这个关键字,不知道它的存在,也有人知道它的存在,但是很少用到它,但是作为嵌入式开发人员,可能要与硬件、中断等打交道,所有这些都要用到volatile变量,不懂这个变量可能会带来灾难,所以掌握这个关键字是比较重要的。

volatile是一种类型修饰符,用它修饰的变量编译器不会对访问该变量的语句进行优化,从而可以提供对这个变量的稳定实时访问。

先看看下面的例子:

int i = 10;   ①

int j = i; ②

int k = i; ③

图 6

编译器在编译该段代码的时候会进行优化,由于在语句(2)和(3)之间没有对变量i的写操作,这时候编译器认为i的值没有发生变化,所以在(2)语句时从内存读出i的值赋给j之后,这个值并没有被丢掉,而是在(3)语句时继续用这个值给k赋值,。编译器不会生成汇编代码从内存中重新取一次i的值,这样提高了效率,但要注意:(2)(3)语句之间没有被用作左值才行。

但是如果此处我们将i 用volatile修饰,那么volatile关键字将告诉编译器 i 的值可能随时发生变化,每次使用它的时候必须从内存中重新读取该值,因而编译器生成的汇编代码会重新从i的地址处读取数据放在k中。

这样看来,如果i是一个寄存器变量或者表示一个端口数据或者多个线程的共享数据,如果不用volatile关键字修饰,那么读出来的可能不是实时最新的值,就容易发生错误,所以使用volatile可以保证对特殊地址的稳定访问,这对我们嵌入式开发是很重要的。

  1. restrict 关键字

restrict是C99引入的,它只能用于修饰指针,该关键字用来告知编译器,所有修改该指针指向内容的操作全部都基于该指针进行,即不存在其它进行修改的操作,这样限制的好处就是方便编译器进行优化,使其生成更加高效的汇编代码,从这个层面来看,restrict和volatile所起到的作用刚好是相反的。如下例子:

char ar[10];

char *restrict restar = (char *)malloc(10*sizeof(char));

char *par = ar;

 

for ( n = 0; n < 10; n++ )

{

par[n] += 5;

restar[n] +=5;

ar[n] *= 2;

par[n] +=3;

restar[n] += 3;

}

图 7

这里说明restar是访问由malloc()分配的内存的唯一且初始的方式,那么编译器就可以对restar的操作进行优化:restar[n] += 8; par就不是了,因为par并不是访问数组的唯一方式,因此并不能进行优化。

void *memcpy(void *restrict dest,const void *restrict src, int size);

图 8

上图代码是一个很有用的内存复制函数,由于两个参数都加了restrict限定,所以两块区域不能重叠,即 dest指针所指的区域,不能让别的指针来修改,即src的指针不能修改.,增加了对内存操作的安全性。

需要强调一点:restrict是C99引入的,GCC编译器中需要加入“-std=c99”的编译选项才能编过,这个关键字极少能用到的,这里不多做介绍。

  1. register 关键字

大家都知道寄存器变量是为了减少频繁访问内存进而影响执行效率提出的,用register修饰。变量值保存在CPU的寄存器中,不在内存。使用寄存器变量需要注意:

  1. 只有局部变量和形式参数可以作为寄存器变量。
  2. CPU中寄存器的数量是很有限的,它只是尽可能的被保存到寄存器中,不是绝对。不能定义任意多个寄存器变量。
  3. 寄存器变量必须是一个单个的值,其长度不能超过一个整型的长度。
  4. register变量在内存中没有地址,不能用&做取地址操作。

1.2  数组维度是否写死在代码里面

C 语言中数组的定义方式为:类型说明符  数组名 [常量表达式];

对于这样的定义相信大家都很熟悉,这里我们只讨论常量表达式。常量表达式表示元素的个数,即数组的长度,常量表达式中可以包含常量和符号常量,不能包含变量。也就是说,C 不允许数组的大小做动态定义,数组的大小不依赖于程序运行过程中变量的值。

【典型错误】

1)     定义一个全局的数组array,此处,数组长度使用的是一个返回值为整型数字的函数,它不属于常量。大家需要知道全局变量是在编译阶段就给它分配内存空间的,而在编译阶段getPortNumber();这个函数的返回值并不能确定,编译器不知道该给这个数组分配多大的内存空间,所以这样的定义编译阶段就会报错。如下代码所示:

int array[getPortNumber()];

图 9

2)     同样的,在函数内部定义一个静态数组的时候也不能用getPortNumber();控制数组的长度,理由很简单,静态局部变量也是在编译阶段就分配存贮单元的,但是编译阶段getPortNumber();的值并不确定,所以这样的定义编译阶段就会报错。如下代码:

void function()

{

static int array[getPortNumber()];

   ...

}

图 10

3)     定义一个作用域为函数内部的数组 array,此处,数组长度使用的也是一个返回值为整型数字的函数。VC下编译此段代码会有”cannot allocate an array of constant size 0”的错误信息。而在GCC下编译并不会有任何错误,而且对该数组的操作并没有发现任何异常,可见这里array是正常分配到了内存。为什么GCC下对全局变量和局部变量的定义会有如此大的差异?现在我们分析一下原因:前面我们提到了,全局变量是在编译阶段就给它分配了相应的内存空间,所以定义数组必须要有一个常量来确定数组的长度。而函数内部定义的数组是在运行阶段从所属任务栈中分配内存的,GCC支持动态数组这个概念,可以在函数内部给数组动态分配内存。但这只是GCC编译器的特性,我们不提倡这样的定义方法。

如下代码所示:

void function()

{

int array[getPortNumber()];

   ...

}

图 11

4)     有的程序员习惯直接用数字来定义数组的维度,这样做并不会有什么问题,但是会给代码的维护和后续的再开发带来很多不必要的工作量。

总结:数组维度的定义应该用宏。

【知识拓展】

n  const 修饰的只读变量

const在C语言中仅仅意味着“只能读的普通变量”,并不是常量。所以const修饰的变量也不能定义数组的长度。C编码中我们可以认为下面的定义是非法的:

const int SIZE = 10;

char a[SIZE];

图 12

1.3  数组下标的值是否在范围之内

数组是最简单的和最常用的结构化数据类型,一个数组中含有一组类型完全相同的数据,并且可以用数组下标直接访问这些数据。所以与数组有关的问题都根源于这个事实:数组元素可以被随机访问,最常见的就是程序试图超出数组边界的下标访问数组元素。

【典型错误】

如下代码所示,该示例是实际开发过程中发现的一个真实BUG,a为静态全局数组,函数function访问数组a时产生了越界操作,此操作刚好修改了其它模块的全局变量值,表现出来的现象非常诡异,很难定位问题的根源,而它所造成的影响也是致命的。

强调一点:对数组的操作,要求对上界的判断尽量使用 < 而不是 <=。

static char a[MAX_NUM] = {0};

void function()

{

int i;

for ( i = 0; i  <= MAX_NUM; i++)

{

       a[i] = i;

}

}

图 13

【知识拓展】

n  提防下标串位:

程序中很多地方都用到了嵌套循环,经常有程序员把array [ i ]  写成了 array [ j ],检查这种错误也是件比较头疼的事情,所以更好的方式是使用比 i 和 j 更有意义的下标名,例如,vid,lagId等等,从而在一开始就降低下标串位发生的几率。

1.4  对全局变量赋值的检查

全局变量对于所有文件、所有函数都是可见的,这就意味着所有模块和都可以访问和修改全局变量的值。全局变量增加了模块间的耦合性。因此在对全局变量进行赋值时,程序员需要谨慎小心,检查该全局变量对其他模块的影响,确保自己对全局变量的赋值不会导致其他模块运行异常。

1.5  宏定义中的宏变量被多次使用

如果一个宏定义中的某个宏变量在宏定义的表达式中被多次使用,则有可能产生意想不到的后果:

 

1: #define    max(a,b)   ((a) >= (b)) ? (a):(b)

 

图 14

上面的宏定义中,宏变量a和b都被引用了两次,采用如下的调用:

 

1:     i = 6;

2:     j = 7;

3:     max(i++, j);

 

图 15

程序调用后返回的结果将是8,而不是7,因为i在宏定义展开后会被引用两次,其值将会增加至8,这有可能与程序员的预期不符,如果程序员没有意识到i被引用了两次的话。这就是宏变量多次引用带来的不良后果。解决的方法是,如果一个宏变量一定会被多次引用,那么就采用其他方式代替宏定义,比如可以定义函数代替宏。

1.6  宏定义的表达式未加括号

宏定义的表达式未加括号,当在一个算术表达式中加入该宏,则很有可能产生操作符优先级顺序问题,同样是图 14中的宏定义,如果以如下方式使用:

 

1:     result = max(i, j) + 3;

 

图 16

则表达式展开为:

 

1:     result = (i) > (j) ? (i):(j) + 3;

 

图 17

而程序员的本意为:

 

1:     result = ((i) > (j) ? (i):(j)) + 3;

 

图 18

很明显实际效果与程序员的预期大相径庭,实际效果是i和j+3中的较大者赋予变量result,程序员的意图是i和j中的较大者,再加上3后赋予变量result。因此,为避免出现此类问题,宏定义表达式必须加上括号。

1.7  宏定义的变量未加括号

宏定义中的变量未加括号,当使用宏时,展开的结果,也会让程序员头疼不已。如下宏定义:

 

1: #define    OR(a, b)   (a || b)

 

图 19

如果这样像这样使用宏:

 

1:     result = OR(i && j, i || j);

 

图 20

展开后就会得到错误的结果:

 

1:     result = i && j || i || j;

 

图 21

而程序员的期望的效果是这样的:

 

1:     result = (i && j) || (i || j);

 

图 22

因此,宏定义中的变量,切记要加括号。

1.8  字节序

字节序是指一个类型大于char型的数据变量在内存中的存储方式,比如int型变量a=0x12345678,那么它的第一个字节是0x12呢,还是0x78,这就是字节序。

字节序由处理器决定,它分为两大阵营,一方以Intel为代表,其处理器的字节序是小尾端模式,即低地址存储数据的低位部分,高地址存储数据的高位部分;另一方以IBM为代表,其处理器的字节序是大尾端模式,即低地址存储数据的高位部分,高地址存储数据的低位部分。

以a=0x12345678为例,假定它存在在地址0起始处,大尾端处理器上,它的存储方式如图 23所示,小尾端处的存储方式如图 24所示。

 

图 23

 

图 24

【典型错误】 未考虑字节序

 

1  int registerSet(unsigned int val, unsigned int addr)

2  {

3      int i;

4      /* data数组用于存储val的各个字节,data[0]是最低位 */

5      unsigned char data[4] = {0};

6      unsigned char *tmpVal = NULL;

7            ...

8            tmpVal = (unsigned char *)(&val);

9            for(i = 0; i < 4; i++)

10          {

11              Data[i] = *tmpVal;

12              tmpVal++;

13          }

14          ...

15 }

 

图 25

函数registerSet用于设置芯片寄存器,寄存器的位数可能是8、16、24或32,因此需要将传入的参数val分解成4个字节,以便根据具体寄存器来决定写入的值。for循环用于分解参数val,很明显,程序员没有考虑字节序,简单的认为val的第一个字节就是低位,存入data[0]。这段代码,只有在小端机上才能按照程序员的预期执行,在大端机上不能正常工作。

【知识拓展】 结构体的位域成员变量

为了节省空间,如果结构体有几个成员变量的取值范围很小,那么可以考虑将它们合并在一个字节里或一个short、int变量里,定义形式如下:

 

1 typedef struct

2 {

3      int flag:1;

4      int priority:10;

5      int portNum:6;

6      int vid:15;

7 }QOS_PORT_STATUS;

 

图 26

使用一个类型超过一个字节的变量时,需要考虑字节序,那么在结构体中使用位域时,需不需要考虑bit序呢?即一个整形变量内包含多个位域变量,各变量的bit顺序是不是固定的呢?通过测试发现,各CPU体系结构的位域bit序跟其字节序大小端是一致的。以图 26所示位域结构体为例,在小端机上,位域变量flag为第32bit,vid为第1~15bit;在大端机上,flag为第1bit,vid为第17~32bit.

2.    数据声明错误

2.1  外部变量的声明和定义

我们知道,外部变量的声明(extern)只是告诉编译器其它源文件中存在该变量的定义,同时告诉编译器该变量的具体类型,编译器并不会为它分配存储空间,而定义则不同,它不仅要告诉编译器变量的类型,还会促使编译器为变量分配空间。因此,外部变量的声明可以重复多次,只要保持这些声明保持一致就可以,而外部变量的定义只能有一次。下面将列举一些由于声明和定义不正确而引入的BUG。

【典型错误】

如下代码所示

/*A.c*/

int x = 1;

int y = 2;

printf_xy()

{

   printf(“printf_xy x=%d,y=%d\n”,x,y);

}

图 28

/*B.c*/

extern double x;

extern int y;

main()

{

printf_xy();

x = 3.0;

printf_xy();

}

图 29

打印信息如下:

A.c定义了变量x和y的类型为四字节的int型,而在B.c里面将x声明为8字节的double型,这样的声明编译器和链接器并不会报错。这就出现了问题,变量只有在定义阶段才给它分配存储单元,A.c中只给x分配了4字节的空间,但是在B.c中声明的类型为8个字节,并对其进行了赋值。于是,错误就不可避免的发生了。赋值语句中 (x = 3.0;)导致内存中8个字节的内容被修改,这很不幸的波及到变量y,变量y的值由原来的0x00000002 无声无息的被修改成了0x40080000!如果变量x和变量y是由不同代码模块不同程序员编写的话,要查出这类的错误非常耗时间和精力。

图 30

类似的错误还有:

/*A.c*/

int a = 1;

int b = 2;

printf_ab()

{

printf(“printf_ab a=%d,b=%d\n”,a,b);

}

图 31

/*B.c*/

extern char a[100];

extern int b;

main()

{

printf_ab();

a[4] = 3;

printf_ab();

}

图 32

打印信息如下:

由此可见,对int型数据声明为char 型数组,编译器也不会有任何错误提示信息,下面的赋值操作同样修改了变量b的值。

题外话:由打印信息我们还可以得知,当前系统为小端模式,不知道你看出来没有?

【知识拓展】

对于外部变量的定义和声明既有联系又有区别,有时候看起来挺混乱,但是要正确区分声明与定义也很容易,主要是看两个方面:

  1. 观察变量有没有初始化
  2. 观察有没有关键字extern

如此一来就有下面几种情形:。

情形

举例

结论

带初始化部分

没有extern关键字

int a = 2

static char a = ‘A’;

带初始化部分的都属于定义

无初始化

有extern关键字

extern int x;

extern double y;

声明

带初始化部分

有extern关键字

extern int x = 0;

extern double d = 3.0

定义

无初始化

不带extern 关键字

int x;

double y;

暂时定义

这里提到了一个概念“暂时定义”,它是C89引入的,链接阶段如果发现有相同名字的变量定义,则所有对该变量的暂时定义都会被链接器忽略,一切就跟没存在一样,如果没有相同名字的定义,系统会为该变量分配一次内存空间,相同名字的其它暂时定义都会被忽略。暂时定义是C标准为了兼容旧式C代码而作出的妥协性安排,一个合格的程序员开发过程中不应该使用暂时定义,对于外部变量要么声明要么定义.。下面是一个暂时定义的错误示例:

/*A.c*/

int a[100];

void main()

{

a[99] = 100;

}

图 33

/B.c/

int a = 0;

图 34

A.c的暂时定义中a是数组,而B.c的定义里a是整型变量,既然存在相同名字的外部变量定义,于是链接程序会忽略数组的暂时定义,但由于两个目标代码文件中符号a所代表的数据长度不一致,所以链接程序会有警告信息,然而可执行文件还是正常生成,运行代码的时候main函数就会修改int变量a开始的第99 × 4字节后面连续四个字节的内容,就会出现莫名其妙的BUG。

2.2  数组和字符串的初始化是否正确?

数组初始化的方式和其它变量的初始化一样,也取决于它们的存储类型。静态类型(外部变量和静态局部变量)的数组只在编译阶段初始化一次,也就是在程序运行之前就已经初始化好了,如果数组未被初始化,数组元素的初始值将会自动设置为零。但是对于自动变量来说初始化过程就没那么浪漫了,初始化是在每次执行代码时才进行的,缺省情况下是未初始化的。

下面的初始化会发生什么情况呢:

#define MAX_NUM 3

int array[MAX_NUM] = {1,2,3,4};

int array[MAX_NUM] = {1};

int array[] = {1,2,3,4,5};

图 35

大家可以看出前两种初始化的数目和数组元素的数目并不匹配。第一个初始化是错误的,我们没有办法把4个整型值装到大小为3的数组中。但是,第二个初始化是合法的,它为数组的第一个元素提供了初始值,后面元素都默认为0.,且这里只允许省略最后几个初始值。第三个初始化是个比较有用的初始化例子,定义中并未给出数组的长度,编译器就把数组的长度设置为刚好能够容纳所有初始值的长度,如果初始值列表经常修改,这个技巧尤其有用。

字符数组的初始化中,如下两种初始化方式:

char array[] = {‘H’,’e’,’l’,’l’,’o’,’\0’};

char array[] = “Hello”;

图 36

这两种初始化是等价的,第一个初始化方式有点笨拙,而第二个虽然看上去有点像字符串常量,但实际上并不是。

这里还有一个例子:

char array[] = “Hello”;

char *array = “Hello”;

图 37

这两个初始化看上去很像,但是它们有不同含义,前者是初始化一个字符数组的元素,而后者是一个真正的字符串常量,这个指针变量被初始化为指向这个字符串常量的存储位置。

如下例子:

/*A.c*/

void functionA(void)

{

char *p = “abcde”;

p[0] = ‘A’;

}

图 38

/*B.c*/

void functionB(void)

{

char array[] = “abcde”;

array[0] = ‘A’;

}

图 39

上面的例子中,执行函数functionB之后将数组array的第一个元素改成了“A”,而执行functionA的时候会出现写操作错误,这是为什么呢?原因很简单,因为p指向的是字符串常量,该常量拥有只读属性,一般放在.rodata(只读数据段中),而下面的语句试图修改只读数据段中的内容, 会产生访问异常。

2.3  变量是否赋予了正确的长度、类型

嵌入式C程序中很少用到浮点类型的数据,所以本节我们只讨论整数类型。整数类型可以分为两大类:有符号类和无符号类,如下表格的分类

标准无符号 整数

标准有符号整数

unsigned char

signed char

unsigned short

signed short

unsigned int

signed int

unsigned long

signed long

unsigned long long

singed long long

C标准并没有给出一个硬性规定来确定某种平台上的某种整型类型究竟占有几个字节,能够表示的范围的数值等,只是给出一个原则:long long 能够表示的范围必须是大于等于long表示的范围,long表示的范围必须大于等于int表示的范围,int表示的范围必须大于等于short表示的范围,short表示的范围必须大于等于char表示的范围。

同一种数据类型在不同平台上可能会有不同的长度或者表示,比如在某些C平台上将char 视为unsigned char,所以char 和 unsigned char是等价的,要使用带符号的char类型必须明确指出:signed char。而另一部分C实现则刚好相反,char和singed char是等价的,如果要使用无符号的char类型必须明确指出:unsigned char

32位计算机架构上各数据的通常长度为:

整数类型

长度

char

1byte (8bit)

short

2byte (16bit)

int

4byte (32bit)

long

4byte (32bit)

long long

8byte (64bit)

64位计算机架构上数据的通常长度为:

整数类型

长度

char

1byte (8bit)

short

2byte (16bit)

int

4byte (32bit)

long

8byte (64bit)

long long

8byte (64bit)

3.    运算错误

本章仅指算术运算和移位运算。

3.1  存在非算术变量的运算

只有基本类型的变量和枚举变量才可以进行算术运算,指针也可以加减一个整型变量,除此之外,其他类型变量间的算术运算都是非法的,如指针和指针相加,结构体和结构体相加,结构体和整形变量相加,都是非法的,都会引起编译器报错。程序员可能出错的地方是,在间接引用指针所指向变量的值时,错误地遗漏了符号*,变成了对指针的运算,导致运算结果出错,这种错误是不会引起编译器报错的,因此需要程序员注意。

【典型错误】 算术变量错误

 

1            int a;

2            int *p;

3            ...

4            P = p + a;

5            ...

6             

图 40

上面的代码是否错误取决于程序员的意图,如果程序员的意图是对指针p指向的变量增加a,则代码是错误的,应该用*p代替p;如果程序员的意图就是对指针指向的地址进行偏移,则代码是正确的。

3.2  不同字长变量间的运算

在一个算术表达式中,如果同时存在不同类型变量的运算,如下表达式:

 

7            10 + ‘a’ + 1.5 – 8765.1234 * ‘b’

8             

图 41

那么所有的变量都会提升为最大精度的类型进行运算,在上面表达式中,所有变量都提升范围double再进行运算。这种类型提升存储在临时中间变量中,不会修改原有变量的类型。

3.3  丢失精度

涉及到双精度浮点数的运算时,程序员需要避免用一个很大的数直接与一个很小的数相加减,否则的话会丢失精度,即运算无效。由于浮点数不是嵌入式系统讨论的重点,在此不深入讨论。

【典型错误】丢失精度

 

9            float a, b;

10          a = 123456.789e5;

11          b = a + 20;

12           

图 42

如上代码,b的结果并不为12345678920,仍然是12345678900。因为一个实型变量只能保证有效数字是7位,后面的位数是无意义的。与此类似,1.0 / 3 * 3的结果并不为1。

3.4  目标变量类型小于赋值变量类型

比如将一个32位的整型变量赋给一个8位的char型变量,编译器直接将整形变量的低8位原封不动赋值给char型变量。像这样目标变量类型(左值)小于赋值变量类型(右值)的情况,程序员需要意识到,这到底是自己有意这样做,还是编程出现了低级错误,自己错误地将一个大类型数据赋值给1个小类型变量,造成了信息的遗失。

【典型错误】 遗失数据

 

13          int a;

14          char c;

15          ...

16          c = a;

17          ...

18           

图 43

如上代码,如果程序员不是有意这样做,那么就造成了数据遗失。

3.5  除数为0

如果表达式中有除法运算,程序员必须对除数表达式进行判断,一定要确保除数不为0,否则会引起系统崩溃。如果除数是立即数或单独的一个变量,程序员都会牢记对其进行等于0的异常处理,但如果是个表达式,则有的人可能会忘记判断除数表达式是否为0.

【典型错误】 除数为0

 

19          c = getSum()/(getNumber() * b - a)

20           

图 44

如上表达式,如果程序员没有对除数表达式进行是否为0的判断,则很可能出现异常。

3.6  计算结果溢出

在有符号数的算术表达式中,有时候会出现令人惊异的结果:

 

21          int a = 0x7fffffff;

22          int b = a * a;

23           

图 45

如上表达式,请你测试一下它的运算结果。

没错,结果是b < 0,非常不可思议,一个整数的平方居然会是一个负数。这是因为有符号数的最高位是符号位,当它为1时,表示这个数为负数,这样当两个数的平方溢出了有符号数的范围时,结果就是一个负数!因此,在使用有符号数的变量时,如果该变量有可能变得非常大,那么你需要对它是否溢出有效范围进行判断,以免出现很奇怪的结果。

【典型错误】 使用%d来格式化输出无符号数

 

24          httpPrintf(reqId, “%d, %d, %d, %d,”,

25                rxPkts, txPkts, rxOct, txOct);

26           

图 46

这是TL-SL3428/5428实际开发过程中端口模块出现的一个bug,这是RPM函数输出给静态页面的端口流量统计数据,4个变量都是无符号数,由于采用%d来输出,当4个无符号数变量的值很大时,页面上会打印出负数!

3.7  操作符优先级有错

算术运算符的优先级,程序员一般都能牢记。需要注意的是算术运算符与 移位运算符&、|优先级的混淆。与算术运算相关的操作符优先级按从高到底排列如下:

自加自减:++、--

指针引用和取地址:*、&

乘除,取余:*、/、%

加减:+、-

左移右移:<<、>>

按位运算:&、|、^

注意按位操作符&、|的优先级是最低的,使用它们与+、-、<<、>>混合使用时需要小心。如果程序员无法确保操作符的优先级,请使用()来保证自己的意图得以实现。

3.8  乘法和除法能否使用移位代替

众所周知,乘法指令和除法指令是非常耗费CPU时间的,除非不得已,否则不应该使用乘法和除法指令。当乘数和除数是2的幂时,也可以使用左移和右移指令来代替乘除指令,左移和右移一般只需要1个CPU指令,与乘法除法需要几十个CPU指令相比,性能大大提高了。

4.    比较运算

比较运算又称关系运算,用于测试操作数之间的各种关系。比较运算的结果是一个整型值,而不是布尔值,因为C语言是没有布尔类型的。当比较操作符两端的操作数满足运算符指定的关系时,表达式的结果为1,反之为0。因此,比较运算表达式的结果可以赋值给整型变量,如:rv = ( a > b );

4.1  不同类型变量间的比较

对不同类型的变量进行比较,在语法上并没有错,而且在大多数情况下都能得到正确的结果,如:1.01 > 1,100 > ‘A’等。但也有出错的时候,而且当错误比较的结果被用于判断循环条件时,这种bug就比较隐蔽和头疼。

不妨将不同类型变量间的比较粗略分为以下两类:

1) 相同符号,不同类型的比较

即无符号数之间进行比较,有符号数之间进行比较。如unsigend char 和 unsigned int 进行比较,int 和 float 进行比较等。编译器将关系操作符两端的变量隐式地进行类型提升,提升的结果保存到中间变量,不改变原变量的值,然后再进行比较。这种比较是安全的,也能得到正确的预期结果。但为了代码的可读性,如果不是出于特殊目的,请不要直接对同符号不同类型的变量进行比较。基于编码规范,要求对上述不同类型的变量显式地进行强制类型转化,然后再进行比较。

2) 不同符号的比较

即有符号数和无符号数的比较,如 int 和 unsigned int,int 和 unsigned char 等进行比较。当有符号数大于0时,这种比较不会发生什么异常,但当有符号数小于0时,比较的结果是不正确的。如下代码:

 

27          int a = -1;

28          unsigned int b = 1;

29          a < b;

30           

图 47

在x86和MIPS下,用vc或gcc进行编译,表达式 a < b 的结果都为0,即a < b不成立。这是因为,当有符号数和无符号数进行比较时,编译器会转化为无符号数进行比较。而当有符号数小于0时,其存储在内存中的值为其补码形式,如-1,其在内存中的值为0xFFFFFFFF,转化为无符号数就是它的补码,也即0XFFFFFFFF,这个数字显然要比1大,所以a < b不成立。

由上分析,不管出于什么目的,禁止对不同符号的变量进行比较。

【典型错误】无符号数与有符号数的运算

如下代码所示,当传入参数length=0时,也许很多人认为程序不会进入for循环,但事实上,程序不但会进入for循环,而且极有可能以内存错误结束。因为length是无符号数,当length = 0时,length-1的结果为0xFFFFFFFF!

1    int sum(int a[], unsigned int length)

2    {

3        int result;

4        int i;

5       

6        result = 0;

7        for(i = 0; i < length -1; i++)

8        {

9            Result += a[i];

10      }

11  }

 

图 48

【知识拓展】

在数学上,一个N位的二进制无符号数X表示为:

                     X =

而一个N位的二进制有符号数Y表示为:

                     Y =

是符号权位,当Y > 0时, = 0,Y < 0时, = 1.

对照有符号数和无符号数的数学表达式,C语言中二者之间的转换规则如下:

转化方向

最高位是否为1

原变量X

转化后变量X’

有符号数

无符号数

X < 0

X’ = (~|X|) + 1

X > 0

X’ = X

无符号数

有符号数

以32位为例,

X ≥ 0x80000000

以32位为例,

X’ = (X & 0xFFFFFFF) – 0x80000000

X < 0x80000000

X’ = X

4.2  比较操作符错误

比较操作符总共有六个:>,<,>=,<=,!=,==。

使用前4个操作符时,需要注意边界问题,如在使用 > 时,切勿错用 >= 。

在遍历数组元素时,为了遵循C语言数组元素的不对称边界,要求对上界的判断尽量使用 < 而不是 <=,即遍历采用 for(i = 0; i < n; i++)的格式,而非for(i = 0; i <= n -1; i++),以尽量避免边界错误。

在上面六个比较操作符中,最后一个操作符,也就是 == ,程序员使用时,很容易与赋值运算符混淆,从而带来令人恼火的bug,因此,当你的代码使用了 == 进行比较,而程序又出了异常,那么,请你尽快检查 == 操作符是否被误写为 =,这样往往能节省你不少的时间。

【典型错误】

例1 误用 <= 代替 <

 

31          int i;

32          int a[10];

33          for(i = 1; i <= 10; i++)

34          {

35            a[i] = 0;

36          }

37           

图 49

只要CPU的栈空间是向下增长的,那么上面的for循环代码就会永远执行下去。因为在对数组元素的上界进行判断时,使用操作符 <= 比较,造成了数组边界溢出,而在向下增长的栈地址空间中,局部变量i的地址紧跟着数组a最后一个元素,于是当执行操作 a[10] = 0时,实际效果是 i = 0,这样在循环体内,变量i永远不会大于10,循环永远进行。

例2 误用 == 代替 =

 

38          int x;

39          x = getValue();

40          if(x = 5)

41          {

42            ...

43          }

44           

图 50

这是一个十分常见的错误。程序员的意图是比较x是否等于5,但因误用 = 代替 == ,if中的条件变成了永远为真,程序不按照程序员的预期来执行,于是出现异常。为避免此类错误,常见的解决办法是改变比较表达式中常量和变量的书写顺序,将常量写在 == 的左边,这样如果误将 == 写作 = ,编译器可以捕捉到此类错误。

4.3  布尔表达式错误

C语言虽然没有布尔类型True和False,但支持逻辑操作符与(&&) ,或(||) 以及 非(!),使用逻辑操作符进行求值的表达式称为布尔表达式。布尔表达式的结果也是一个整型值,如果表达式成立,则结果为1,否则为0。表达式的结果可以赋值给一个整型变量,如:rv = ((a != 1) && (b > 2))。

程序员使用操作符 && 和 || 进行求值时,容易出现的一个低级错误是误把 && 写作 位操作符按位与 (&),把|| 写作按位或 (|),很多时候,这是一种很隐蔽的bug,详情请参考本小节【典型错误】的分析。

操作符 && 和 || 存在一个有趣之处,它们能控制表达式的求值顺序。

expression1 && expression2

在上面的表达式中,左边的表达式总是首先求值,如果expression1的值为0,则expression2不会被执行,整个表达式的结果为0。对 || 而言,则当expression1的值为1时,expression2不会被执行,整个表达式的值为1。这种行为,称为短路求值(short-circuited evaluation)。

【典型错误】误用 & 代替 &&

 

45          if(a < b & c > d)

46          {

47            ...

48          }

49          if((a = getVal1()) & (b = getVal2()))

50          {

51            ...

52          }

53           

图 51

如上的两个if条件判断中,都误用 & 代替 &&,在第一个if条件中,& 两端的表达式为关系表达式,其值或为1或为0,没有第三个值,因此,& 用在这里的效果与 && 是一致的!在第二个if条件中,程序员的本意是当变量a和b都不为0时,条件为真,但实际的结果很可能令他失望。因为用 & 代替 &&,则当a=1,b=2时,该表达式的结果为0!这个表达式的实际效果是:当a,b的值不存在相同的为1的bit时,结果为0,反之为1。于是,程序员发现代码大部分时候都能正常工作,但偶尔会出现异常,如果他不能很快的检查出是误用& 代替 && ,那么,这个bug将要花费相当大的力气去摆平它。

4.4  比较运算与布尔表达式混合

比较运算采用运算符<、>、==等对两个表达式进行比较,比较的结果是0或者1;布尔表达式则使用&&、||、!这三个运算符来完成特定布尔逻辑的组合,布尔表达式的值是0或者1。在实际的编码过程中,这两种表达式经常混合使用,以完成对程序流程的控制,如下图的if表达式中,同时含有!=和&&表达式,这就是比较运算与布尔表达式混合。

 

54          if((status != 1) && (status != 0))

55          {

56            ...

57          }

图 52

这种混合表达式非常常见,也没有隐蔽的易错属性。但程序员在使用的时候,还是需要细致和谨慎,要清晰表达自己的逻辑,不要误用布尔运算符。如表达这样一个逻辑:端口的连接状态既不为1也不为0,如下这样表达是错误的:

 

58          if((linkStatus != 1) || (linkStatus != 0))

59          {

60            ...

61          }

图 53

正确的表达应该是这样的:

 

62          if((linkStatus != 1) && (linkStatus != 0))

63          {

64            ...

65          }

图 54

4.5  操作符优先顺序错误

有关比较操作符优先级,需要记住以下两条:

1) 六个比较操作符:< ,<=, >,>=,==,!=,前4个操作符的优先级高于后面两个;

2) 算术操作符 > 比较操作符 > 赋值操作符.。

在表达式中混合使用了比较操作符和其它操作符时,对操作符的优先级一定要查证和确保,否则代码很容易出现低级而又隐蔽的bug,如果程序员即不能记住符号优先级,又不愿意去查证,那么请务必使用括号来保证你的代码按照预期执行。

【典型错误】混淆 == 与 & 的优先级

 

66          if(0 == a & b)

67          {

68            ...

69          }

图 55

在if的条件判断中,程序员的意图是判断 a & b的结果是否为0,即if(0 == (a & b) ),但因为比较操作符 == 的优先级高于 &,if条件的实际效果为 if( (0 == a)  & b),与程序的意图大相径庭。这种代码的bug的特点就是很多时候能正常工作,只有在特定条件下才会被激活,使程序出现异常,非常令人头疼。

5.    控制流程错误

C语言的控制流程语句有:if语句,switch语句,while语句,do…while语句,for语句,goto语句,break语句,continue语句以及return语句。其中break语句只能用于while, do….while,for循环语句和switch语句中,不能用于if和goto构成的循环中;continue语句也只能用于while, do….while,for三种循环语句。

5.1  超出多条分支路径

使用if语句进行选择结构程序设计时,如果不遵循编码规范或者粗心大意,则很有可能出现以下常见的错误:

1) 悬挂else

多重if语句嵌套,没有使用花括弧界定代码块范围,如果if和else不配对的话,则无论程序员如何缩进排版,else语句只从属于最近一个if语句,这就是else悬挂问题。因此,为避免悬挂else导致程序异常,要求程序员遵循这两条编码规范:a) 使用嵌套if语句时,务必为每一重if语句添加花括弧,使代码块范围清晰明了;b) if和else尽量配对出现。

2) 超出分支

只有if语句没有else语句,或者有if语句和几个else if语句而没有else语句,程序员需要确保可能发生的条件是否都已罗列完整。

【典型错误】

例1 悬挂else

如图 7所示代码,这是一个判断输入年份是否为闰年的函数。从代码的缩进来看,程序员的意图是当year不能整除400时,不做处理,两个else分别对应第一重和第二重if. 然而,程序实际运行的效果将会令他大吃一惊。由于else悬挂,代码中的两个else实际上对应第三和第二重if. 于是,当输入year = 1996时,得到的结果是FALSE!

 

1    int IsLeapYear(int year)

2    {

3    if(year % 4 == 0)

4        if(year % 100 == 0)

5            if(year % 400 == 0)

6                return TRUE;

7        else

8            return TRUE;

9    else

10      return FALSE;

11  return FALSE;

12  }

13   

图 56

例2 遗漏分支情况

1     

2    int swPortSetSpeedDuplex(int port, int speed, int duplex)

3    {

4    …

5    if(port < 1 || port > getPortNumber())

6    {

7        …

8    }

9    Else if(speed < PORT_10M || speed > PORT_1G)

10  {

11      …

12  }

13  …

14  }

15   

图 57

上面代码是一个配置交换机端口速率和双工模式的接口函数,函数使用if语句判断参数是否合法,由于程序员疏忽大意,漏掉了对参数duplex的检查,应该再添加一个else if分支,检查duplex是否合法。

5.2  循环未终止

程序员使用循环语句时,如果不是出于特殊目的,一定要检查循环终止条件是否正确,确保程序不会陷入无限循环中。循环语句未能按照预期正常终止,除因程序员粗心大意,出现误写漏写循环终止条件这样非常低级的错误外,还有一些比较隐蔽的循环终止条件错误,这些错误一般是由前面论述过的操作符优先级错误,不同符号变量的比较导致的,如一个有符号数与无符号数的比较(见图 47代码),在循环条件的判断中将 == 误写作 = (见本小节典型错误代码),或者for循环的边界出错导致循环变量被覆盖(见图 48代码)等。

【典型错误】 循环终止条件错误导致死循环

1     

2    ERR_FLAG:

3    rv = swGetUsrCfg();

4    if(rv = ERR_HW_OP_FAIL)

5    {

6        delay(100);

7        goto ERR_FLAG;

8    }

9     

图 58

上面代码是一个由if语句和goto语句构成的循环。程序员的意图是如果函数swGetUsrCfg()由于硬件错误执行不成功,则等待一段时间后再重试一次。很不幸,由于误把 == 写做 = ,这里出现死循环了,无论函数swGetUsrCfg()是否执行成功,这段代码都会永远执行下去。

【知识拓展】死循环的用途

除了bug,有些时候死循环也是相当有价值的,下面是一些著名的死循环:

1)操作系统是死循环;

2)WIN32程序是死循环;

3)嵌入式系统软件是死循环;

4)多线程程序的线程处理函数是死循环。

5.3  Switch语句的default分支

在switch语句中,除已经罗列的case外,如果其他情况需要一个默认处理,则这个默认处理动作加在default分支语句中。如果你确定一定以及肯定其他情况不需要默认处理,则default语句也可以省略不写。出于提醒和警示,一般的编码规范都要求switch语句中必须有default分支,虽然很多时候它只有一个break语句。

5.4  Switch语句的break

如果在某个case后面忘记(请注意是“忘记”)写break语句,那么恭喜你,一个很诡异的bug将缠上你了。

C语言switch语句的一个特性,如果case后面没有break语句,那么程序会一直顺序执行下去,直到遇到一个break语句。如果整个switch代码块都没有break,那么程序会顺序将整个switch执行完毕!这个特性,既是C语言的一个优点,也是一个弱点。如下两段代码:

1    /* 两个case处理一致 */

2    case 1:

3    case 2:

4        i++;

5        break;

6    case 3:

7        …

/* 遗漏break */

8    Case 1:

9        i--;

10  case 2:

11      i++;

12      break;

13  case 3:

           …

第一段代码是利用没有break顺序往下执行的特性,将case1和case2进行相同处理;第二段代码则展示了程序员忘记写break语句的情况。第二段代码将会这样执行,当为case1时,变量自减,完成处理后,因没有break语句,程序继续往下执行,变量i自加。于是程序员将莫名其妙,为什么当case 1时得不到预期的结果呢?

【典型错误】case缺少break带来的问题

1     

2    case ERR_SNTP_MONTH:

3        sprintf(cErrBufTemp, “Invalid month.”);

4    case ERR_SNTP_DAY:

5        sprintf(cErrBufTemp, “Invalid day.”);

6        break;

7    case ERR_SNTP_YEAR:

8        sprintf(cErrBufTemp, “Invalid year.”);

9        break;

10   

图 59

这是一个CLI模块编码中出现的真实bug,当错误码为ERR_SNTP_MONTH,打印出来的错误信息是“Invalid day”,而不是期望的“Invalid month”。你知道错误在哪里吗?

【知识拓展】switch与if…else if…else的区别

当程序需要处理多种分支情况时,与使用一组很长的if…else if语句相比,使用switch语句显得更为明智。Switch语句不仅提高了代码的可读性,而且通过一种称为跳转表的数据结构,可以得到更高效率的实现。

跳转表是一个数组,它的每一个元素i存储的是一个代码段的地址,与switch的开关索引值i相对应,程序使用开关索引值i来执行跳转表内的数组引用,实现跳转指令目标。跳转表的优点是,执行开关语句的时间与开关情况的数量无关。如果分支数量比较多,并且开关值的范围跨度小,编译器就会使用跳转表来将C代码翻译成目标机器的汇编代码。

下面图 59、图 60是一段使用switch语句的C代码与其对应的汇编代码,图 61是把汇编代码“翻译”成C语言的形式,比较清晰的展示了跳转表的结构及其与C源代码的关系。

1     

2    void foo(int i)

3    {

4    int a = 0;

5    switch(i)

6    {

7        case 0:a = 2;break;

8        case 1:a = 5;break;

9        case 2:a = 4;break;

10      case 5:a = 6;break;

11      case 6:a = 10;break;

12      default:a = 1;break;

13  }

14  }

图 60

去掉与switch部分无关的代码,再添加注释和标号,得到汇编代码如下:

 

图 61

对汇编代码进行“解码”得到图 61所示C代码。C本身并不支持图 61代码中定义的“code“结构的跳转表数组结构,图 61的C代码只是为了让读者更好地理解switch语句经编译器用跳转表处理后是什么模样,请读者不要误解。

1     

2    void foo(int i)

3    {

4    code *jt[7] =

5    {

6        loc_0, loc_1, loc_2, loc_def,

7        loc_def, loc_5, loc_6

8    };

9   

10  int a = 0;

11  if(i > 6)

12  {

13      Goto loc_def;

14  }

15 

16  Goto jt[i];

17 

18  loc_0:

19  a = 2;

20  goto done;

21  loc_1:

22  a = 5;

23  goto done;

24  loc_2:

25  a = 4;

26  goto done;

27  loc_5:

28  a = 6;

29  goto done;

30  loc_6:

31  a = 10;

32  goto done;

33  loc_def:

34  a = 1;

35  goto done;

36  done:

37  }

图 62

5.5  循环体入口条件不满足

因误写入口条件,致使循环体永远得不到执行,这种情况对while语句、for语句是可能发生的。但do…while循环无论如何,循环体都会进入,至少执行一次,因此不存在入口条件不满足的状况。

循环体的入口条件,一般由关系表达式的结果决定,结果为1,则进入循环体,否则不进入;如循环条件是算术表达式的结果,则结果非0为真,进入循环体,结果为0则不进入。

误写循环体入口条件,要么是程序员过于粗心大意,要么是程序员无法掌控自己的模块,设计意图混乱,因为这里并不涉及到C语言隐蔽的特性或其它容易出错之处。只有程序员自己才最清楚循环体的入口条件是否为设计意图的体现。在代码检查(code review)时,其他程序员只能结合代码上下文尽可能对循环体入口条件做一些合理的推测。

5.6  循环体内操作最简化

这是一般编码规范都会有的一条。可以移到循环体外的操作,请尽量移走。循环体内的操作越少,越简单,程序运行的效率就越高。举手之劳,轻易地提高程序的效率,有什么理由拒绝这样做呢?

【典型错误】循环体内冗余操作

1    for(i = 0; i < getPortNumber(); i++)

2    {

3        …

4    }

图 63

上面的代码并没有错,但不符合循环体内操作最简化的要求,可以将其作如下修改:

1    int portNum = getPortNumber();

2    for(i = 0; i < portNum; i++)

3    {

4        …

5    }

图 64

5.7  存在不能穷尽的判断

如果出现这种情况,则是程序员的代码设计有问题,算法不对,选取的用来进行判断的开关不对。毫无疑问程序员需要变化思路,更改自己的设计。

5.8  存在执行不到的代码

存在多个分支时,为了减少冗余代码,程序员需要检查自己的代码中是否存在永远不会被执行的分支,这种分支不包括用于错误处理的分支。一般是由于程序员对程序执行的可能状态理解错误导致误写了冗余代码。

【典型错误】 存在执行不到的代码

5     

6    void showPortStatus(int portNum)

7    {

8        …

9        If(PORT_100MFD == linkStatus)/* 如果是100M全双工 */

10      {

11      …

12      }

13     

14      If(PORT_1GHD == linkStatus)/* 如果是千兆半双工 */

15      {

16      …

17      }

18  }

很明显上面的代码存在冗余,判断端口的连接状态是否为千兆半双工是毫无意义的,因为交换机自动协商机制不存在千兆半双工的概念!

5.9  必须配对的操作

程序员使用一些危险的操作时,如打开文件,使用信号量,动态申请内存等,必须保证及时正确地释放这些资源,以免造成死锁或资源耗尽。

在vxWorks平台下,常见的申请/释放的操作有:

1)    取消进程调度操作 TaskLock() / TaskUnlock();

2)    信号量获取和释放 semTake() / semGive();

3)    内存的申请和释放 malloc() / free();

4)    文件的打开和关闭,包括普通文件、套接字、管道等。

程序员都会牢记,申请资源必须及时释放,在代码中,申请和释放必须配对出现。然而,因为资源没有及时释放而造成死锁、内存错误等bug还是经常出现。究其根源,程序流程复杂,分支众多是原因之一。在顺序程序流程中,程序员都能及时释放资源,而一旦程序流程复杂,有多条分支,并且分支上有出错返回,goto跳转等,程序员往往遗忘在return语句前释放自己已经申请了的资源。可能的原因还有,资源的申请操作封装在一个接口函数里(比如portAbilityCreate()),程序员调用该接口函数就申请了资源,如程序员没有意识到这一点的话,他往往会忘记调用与该接口函数相对应的释放资源的函数(portAbilityDestroy())。这两种原因造成的错误在本小节的【典型错误】中都有相应实例。

【编程技巧】有资源申请和释放的程序流程控制

1     

2    void f()

3    {

4    Create(_A);

5    Create(_B);

6    Create(_C);

7    …

8    if(…)

9    {

10      …

11      goto done;

12  }

13  else

14  {

15      …

16      goto done;

17  }

18 

19  while(…)

20  {

21      …

22      goto done;

23  }

24  …

25  done:

26  Destroy(_C);

27  Destroy(_B);

28  Destroy(_A);

29  return …;

30  }

图 65

为避免程序因分支众多,在分支上返回而造成资源泄漏,对有资源申请和释放的程序,应避免在分支上返回,其程序流程可参考图 16的设计。

读者需要注意到图 64代码中的逆序释放资源,因为资源_A,_B,_C可能有依赖关系,如_A是一个结构体指针,_B是该结构体中的一个成员变量指针,在释放时,就需要先释放_B,再释放_A,请读者在实际编码中注意这一点。

【典型错误】

例1 忘记在分支返回前释放资源

19 

20  TaskLock()

21  Rv = swGetPortStatus(&pStatus);

22  If(rv)

23  {

24      return ERR_HW_OP_FAIL;

25  }

26  gPortLinkStatus = pStatus;

27  TaskUnlock;

28   

图 66

如图 65代码,gPortLinkStatus是一个全局变量,这段代码明显地在分支遗忘释放资源。其实这段代码至少有三个缺陷:

1)不该用TaskLock和TaskUnlock,应采用其他技术保证全局变量的安全;

2)TaskLock锁住的代码应越少越好,本例中,不该从调用函数处即锁住;

3)没有在return语句前释放资源;

不过在本小节本例,请将注意力集中到return ERR_HW_OP_FAIL语句前没有TaskUnlock操作这一严重错误上。

例2 遗忘调用封装资源申请/释放操作的接口函数

例2(图 66)对资源申请/释放操作再做了一次封装,调用函数portStatusCreate即申请了一块内存,但程序员粗心大意,没有意识到这一点,于是无论分支返回还是函数最终返回,都没有调用函数portStatusDestroy以释放资源。

1    PORT_STATUS_STRUCT *portStatusCreate(int unit)

2    {

3    PORT_STATUS_STRUCT *p;

4    …

5    P = malloc(sizeof(PORT_STATUS_STRUCT));

6    …

7    return p;

8    }

9   

10  void portStatusDestroy(PORT_STATUS_STRUCT *p)

11  {

12  …

13  Free(p);

14  …

15  }

16 

17  int f()

18  {

19  PORT_STATUS_STRUCT *pStatus;

20  …

21  pStatus = portStatusCreate(BCM_53314_A0);

22  …

23  if(…)

24  {

25      return ERR_HW_OP_FAIL;

26  }

27  …

28  return ERR_NO_ERROR;

29  }

图 67

例 3 对同一个指针free()两次

资源释放后,并非万事大吉,程序员还是有可能犯错。请看代码图 67,你知道这段程序的问题在哪里吗?

对函数f的流程进行分析如下:

1) 定义指针p;

2) p申请内存;

3) 在各分支上释放p申请的内存;

4) 程序最后判断p申请的内存是否已释放,没有就再释放一次,然后从函数返回。

问题就出在4)的判断条件上,程序员想当然地认为,如果p申请的内存释放后,p值就为NULL,可以使用条件(NULL != p)来判断p是否已被free()过。

其实,free()函数只是把指针与其指向的内存的联系斩断,从而使原来标记为p所有的已分配的内存现在标记为空闲的。但free()函数并没有对p赋值为NULL,p的指向是不确定的,p值通常并不为NULL。像p这样free()后又没有重新赋值的指针,术语称为“悬垂指针”或“野指针”。

基于上述分析,图 67的代码,p毫无疑问会被free()两次。对大多数操作系统而言,对同一个不为NULL的指针free()两次,都会出现异常。

1    int f()

2    {

3    int *p;

4    …

5    p = malloc(…);

6    …

7    if(…)

8    {

9        …

10      free(p);

11  }

12  else if(…)

13  {

14      …

15      free(p);

16  }

17  …

18  if(NULL != p)

19  {

20      free(p);

21  }

22  return …;

23  }

图 68

例4 TasklLock() / TaskUnlock()锁住的代码运行时间过长

图 68代码,在vxWorks操作系统平台下,程序员在写flash时,为阻止高优先级进程抢占CPU,操作同一flash地址,造成数据丢失,程序员调用了TaskLock ()函数,取消进程调度,锁住flash操作代码。乍看之下,这样做似乎没什么问题。然而,因为写flash操作需要很长一段的CPU时间,在这样一段时间内,CPU一直被此操作占据,致使其他进程一直处于饥饿状态,严重影响了实时性,那些需要实时处理的任务得不到执行,严重者甚至可导致整个系统异常。比如网络收包进程,如果TaskLock很长时间没有释放,收包进程一直被阻塞,缓冲区耗尽,导致丢包。

TaskLock一般用于操作系统内核层次,对于应用层次的代码,尽量使用信号量取代TaskLock来保证线程安全。

1    …

2    TaskLock();

3    flashBlkWrite(sectorNum, buff, offset, count);

4    TaskUnlock();

5    …

图 69

6.    函数错误

在C程序设计中,需要将一些常用的功能模块编写成函数,由主函数调用,或各个函数之间调用,以实现程序的功能。

函数的概念涉及到函数定义、声明、调用以及组成函数必须的函数返回值类型、参数列表,还有函数指针等多方面的概念和特性。函数的这些概念,很多时候并不像直觉上应该的那样,程序员如果没有深入理解C语言函数的诸多隐蔽特性,想当然的使用函数,则往往很容易遇到奇怪的bug.

6.1  函数定义与声明不一致

众所周知,在C语言中,如果被调用函数与调用者不在同一个文件,或者在同一个文件中,但被调用函数定义在调用者之后,那么在调用函数之前,必须声明该函数,编译器依据声明的函数原型来检查调用是否正确。如果没有声明函数,编译器会把第一次遇到的该函数的调用形式当作其函数原型。

需要格外注意的一点是:C语言在函数定义与声明之间,没有提供检查机制。因此,即使函数定义与声明不一致,只要函数名相同(在本小节,函数定义与声明不一致,仅限于参数列表,返回值类型不一致),并且声明与定义不会出现在同一个文件中(一般只要定义的.c文件不去include声明的.h文件就可以实现),编译器就检查不到这样的错误。而编译器是完全按照声明的函数类型来check函数的调用是否正确。于是乎,在编译阶段,函数定义与声明不一致的错误,没有办法发现。

那么在link阶段,这样的错误能否发现呢?很遗憾,也不能。

链接过程主要是确定各个符号的地址,将库函数,自定义函数,全局变量,静态局部变量等在内存中的地址确定下来。链接过程只能发现符号未定义,它不会也不可能去检查函数的声明与定义是否一致。

有时,因为软件架构的关系,不能include某一层的.h文件,但又需要用到该.h文件中声明的某个函数,这时候,可以在需要调用的文件中声明该函数,参考如下例子:

6    void vxSpawn(void)

7    {

8    extern void diag_shell(void *);

9    extern void tp_broadcom_init(void);

10 

11  sal_core_init();

12  sal_appl_init();

13 

14  tp_broadcom_init();

15  sal_thread_create(“bcmCLI”, 128*1024, 100, diag_shell, 0);

16  }

图 70

函数vxSpawn需要调用在其它文件中定义的diag_shell,tp_broadcom_init函数,但又不需要include整个头文件,则可以在vxSpawn内声明这两个函数,这是除在.h文件集中声明函数外的又一种声明函数的形式。

相比在.h文件中声明函数,这种直接在调用函数体内声明函数的方式更容易出错,请看如下示例:

1    void f1()

2    {

3    int g(int a, char *b);

4    …

5    }

6   

7    Void f2()

8    {

9    int g(int *a, char b);

10  …

11  }

图 71

在同一个文件内,函数f1(),f2()都对函数g()进行了声明,很明显,两处的声明不一致。然而,由于函数声明放在一个函数体内,其作用域仅限于函数体内,当函数执行结束后,编译器就把声明的信息丢弃,所以,即使两处声明冲突,编译器也检测不到。这两个声明,至少有一个是错误的,但无论是其中一个错误,还是两个声明都错误,编译器都检查不到。其原因:

1)声明放在函数体内,在这个作用域内,两个声明没有冲突;

2)编译器没有提供函数定义和声明的检查机制,定义和声明的一致性,由程序员自己保证。

【典型错误】例 1 声明与定义不一致

函数max定义在api.c文件中,其内容如下:

1    unsigned int max(int a, int b)

2    {

3    return a > b ? a:b;

4    }

图 72

其函数原型声明在api.h中如下:

1    #ifndef _API_H_

2    #define _API_H_

3     

4    int max(int a, int b, int c);

5   

6    #endif

图 73

main函数调用max函数如下:

1    #include <stdio.h>

2    #include <api.h>

3   

4    int main()

5    {

6    int a = -1;

7    Int b = -2;

8    int c = 2;

9    int tMax = max(a, b, c);

10  printf(“%d\n”, tMax);

11 

12  return 0;

13  }

图 74

编译、链接都没有报错,最后运行也没有错,但在a,b,c三个数中,输出的最大值却是a!

max的函数定义只有两个int参数,但在api.h文件里,它被声明为含有3个int参数,main函数也按照3个参数来调用它,整个过程都没有报错。但很显然,程序输出的结果并不是程序员想要的。

例2 参见图 22代码

【知识拓展】

1. K&R C风格的函数声明

在阅读一些古老的代码,比如vxWorks操作系统内核代码,有时候看到如下风格的函数定义:

5    double copysign(x,y)

6    double x, y;

7    {

8    …

9    }

图 75

这种函数定义风格比较古老,为标准C出现之前的C语言所属,称为K&R C风格。它声明的函数头部没有形参类型,只有返回值类型,参数类型放在函数体起始处。这种函数定义和声明形式,仍为现在的标准所允许,以兼容较老的程序。

C语言经典名著《The C Programming Language》于1978出版,由于其巨大影响,于是,这个版本的C语言称为“K&R C”——取自该书的两个作者姓名Brian Kernighan和Dennis Ritchie.

2. 可变参数列表

如果你想写一个类似printf()的函数,调用时可以输入任意个数的参数(但至少要有1个),那你一定会考虑到:该如何在函数体里访问所有可能的参数呢?

有些函数的参数列表可以包含可变的参数数量和类型,这部分可变的参数必须位于一个或多个已确定参数(可称为普通参数或命名参数)的后面,以一个省略号表示。已确定参数必须以某种形式提示可变部分实际所传递的参数数量。如printf函数使用字串“%d%f\n”之类来提示后面有几个参数。使用标准库定义的宏可以实现可变参数的功能。标准库的头文件stdarg.h定义了一个类型:va_list;三个宏:va_start、va_arg和va_end。利用这些工具,就可以实现一个带有可变参数的函数。具体用法参考代码图 24,这是一个计算输入参数平均值的例子,命名参数n1指定了需要计算几个输入参数的平均数,va_list类型变量var_arg用来遍历可变参数列表。

1    #include <stdarg.h>

2   

3    float average(int n1, …)

4    {

5    va_list var_arg;

6    int count;

7    float sum = 0.0;

8   

9    va_start(var_arg, n1);

10  for(count = 0; count < n1; count++)

11  {

12      sum += va_arg(var_arg, int);

13  }

14  va_end(var_arg);

15 

16  return sum/n1;

17  }

图 76

3. 静态函数

函数的作用域默认是全局的,如果要强制一个函数只能被本文件中的其他函数调用,可以在函数定义时,加上static关键字,使其成为一个静态函数。静态函数有一个明显的好处,不同的文件可以定义相同函数名的静态函数,而编译器不会将它们混淆。不过需要注意的是,静态函数名只存在于.c文件编译出的.o文件里,在最终编译生成的可执行文件的符号表里是找不到静态函数符号的,因此,调试器或者其他有用到符号表的工具,得不到静态函数的信息,无法对静态函数进行操作。一个显著的例子是,在vxWorks镜像的shell下,不能运行静态函数。

6.2  没有声明函数原型

没有声明函数原型,可以归为函数声明与函数定义不一致的一种特殊情况。但由于这种情况引起的错误比较特别,故将其另列一类。

函数没有声明,当调用该函数时,编译器会作如下处理:

1) 编译器假定函数返回一个整型值;

2) 函数调用时,所有实参将隐式进行类型提升,char和short提升为int,float提升为double

针对以上编译器的两种处理操作,在此特别强调,调用函数之前,程序员一定要声明函数,以避免返回值由于不可预测的类型转化导致的错误以及参数隐式提升类型带来的错误。

【典型错误】 参数类型隐式提升的危害

文件print.c定义函数如下:

1    #include <stdio.h>

2     

3    print_1(float c1, char c2)

4    {

5    printf(“%f, %c”, c1, c2);

6    }

图 77

文件main.c定义如下:

1    main()

2    {

3    float b = 1.0;

4    char a = ‘e’;

5    print_1(b, a);

6    return 0;

7    }

图 78

利用gcc编译:gcc –o test main.c print.c;

程序输出结果:

 

图 79

这个程序的输出结果,应该会让很多人大跌眼镜吧,main 函数中明明传入的是1.0和字符‘e’,为何输出的结果是0.00000和空字符呢?

这就是参数类型隐式提升带来的结果。由于没有声明print()的函数原型,在main()函数调用它时,编译器将第一个参数由float提升为double,第二个参数char提升为int,类型提升就意味着存储函数参数的栈空间有变化,下图展示了类型提升与正常参数的栈空间存储分布:

 

图 80 参数类型提升前在栈中的存储分布

 

图 81 参数类型提升后在栈中的存储分布

需要说明一点,x86平台的二进制应用程序接口标准(ABI),函数的参数从右向左入栈,因此本例中,参数a先入栈,b其次。

函数的调用者main()传入的参数如图 32 参数类型提升后在栈中的存储分布所示,而函数print()本身的指令是严格按照函数定义的参数类型从栈中取出参数。于是,从sp+4取出参数b,sp+5中取出参数a,类型提升后,sp~sp+8都是参数b的存储空间,参考浮点数的存储方式,由于b=1.0,因此、sp~sp+5的值都为0,print实际从栈中取出的参数值为b=0.000000,a=‘\0’!

6.3  实参与形参不一致

函数声明与定义不一致的话,编译器无法检查出错误。那么,函数调用的时候,传入的实参类型与函数声明的形参类型不一致又会怎么样呢?

实参与形参不一致,可能的情形有:

1) 参数个数不一致;

2) 参数类型不一致;

3) 参数顺序不一致;

当已经声明函数类型后,形参与实参的个数不一致,编译器在编译过程可以发现并报错;类型不一致,编译器无法发现;顺序不一致,编译器也无法发现。这就意味着,在程序员调用函数的时候,除个数不一致的错误外,几乎其他形参与实参不一致的错误,编译器都无法发现,需要依靠其他工具(如pc-lint)或人工代码检查才能发现。甚至,如果一个函数的几个参数类型都一致,但程序员调用时将实参的顺序搞错,这种错误,人工代码检查也无法发现,只有程序员自己清楚。

【典型错误】 实参顺序弄错

图 81是一个配置端口速率和双工的例子,当sw层的接口调用adapter层的接口时,将参数speed和duplex顺序弄错了。编译器捕捉不到这样的错误,只有在代码实际运行时,程序员才会发现配置的效果跟预期不一致。

1    int adPortSetSpeedDuplex(int userPort, int speed, int duplex)

2    {

3    …

4    }

5   

6    int swPortSetSpeedDuplex(int userPort, int speed, int duplex)

7    {

8    …

9    adPortSetSpeedDuplex(userPort, PORT_HALF, PORT_100M);

10  …

11  }

图 82

【知识拓展】 参数传递

很多程序员也许会认为函数的参数传递规则是由C语言规定,或者由编译器决定,实际并非如此。参数传递是由CPU平台决定的。处理器的二进制应用程序标准(ABI)规定了编译器应该如何编译C语言程序。ABI涉及到许多特性,比如字节对齐、大小端、类型长度、参数传递和函数返回值等,本小节只讨论参数传递。

先看一个例子:

1    main()

2    {

3    int i = 1;

4    printf(“%d,%d,%d,%d,%d,%d”,i++,i++,i++,i++,i++,i++);

5    return 0;

6    }

图 83

你认为这段代码的输出结果是什么呢?

暂不公布答案,先对开发过程中常见的三种平台x86、MIPS、ARM,分别讨论它们的参数传递规则,之后,你应该会恍然大悟。限于篇幅,这里只讨论整型参数(含指针)的传递,对结构体参数的传递不予讨论,读者有兴趣可以参考各处理器相关体系架构的书籍或说明文档。

1) x86平台的参数传递

调用者传入的参数从右向左依次进入调用函数的栈帧。所谓栈帧,是指隶属于一个函数的栈区域,一般包括需要传入被调用函数的参数、局部变量、返回地址以及其他需要保存的寄存器的值,可能还有其他中间暂时变量,如存储很长的表达式的计算结果的中间变量等。参数传递,就是将参数复制到目的地址(可能是栈,也可能是寄存器),参数的入栈顺序,表示了参数的被操作顺序,它有可能影响代码的执行效果——如果程序依赖操作数的执行顺序的话。因此,对图 82的例子,在x86平台上,printf()函数的参数从右向左入栈,i每一次入栈,都自加1,于是输出结果为:6,5,4,3,2,1

形如f(arg1,arg2,arg3)的函数调用,实参arg1、arg2、arg3在函数f的栈帧中布局如图 35所示。

 

图 84

2) MIPS平台的参数传递

参数从左向右,存储在寄存器a0-a3($4-$7)中,如果参数个数超过4个,其余部分也按从左向右的顺序依次存储到栈中。

因此,对图 34的例子,在MIPS平台上,输出结果为:1,2,3,4,5,6

3) ARM平台的参数传递

与MIPS平台类似,参数从左向右,存储在寄存器r0-r3中,参数个数超过4个,其余部分从左向右存储到栈中。

在ARM平台上,图 34的例子的输出结果为:1,2,3,4,5,6

6.4  改变仅为输入形参的值

在C程序中,参数的传递只有一种方式,那就是按值传递。可能有人会反驳:可以通过传递指针来修改指针所指向的地址的值。确实是这样。不过呢,传给函数的那个指针,并不是原来的指针,它只是原来指针的一个拷贝。因此,C语言,除了按值传递,没有别的参数传递方式。

可以进一步讨论。C语言函数的参数,无外乎是变量、指针、结构体三种。参数是普通变量的传递,在6.3节的【知识拓展】中可以了解到,编译器将参数拷贝一份,复制到寄存器或被调用函数的栈中。参数是指针的传递,与此相同,编译器将指针的值(它指向的地址)复制到寄存器或栈中。

参数是结构体的传递较为复杂,这里不深入解析,大致讲一下过程。在MIPS平台上,结构体的成员变量先依次复制到寄存器a0-a3中,不足4个字节的成员变量,需要和相邻的成员变量凑成4字节共用一个寄存器。如果寄存器不够,再复制到栈中。如下是一个结构体参数的传递示意图:

7    struct thing

8    {

9    char letter;

10  short count;

11  int value;

12  } = {‘z’, 46, 100000};

13 

14  void processthing(thing);

图 85

 

图 86

在x86平台下,结构体的传递更为复杂。编译器在调用函数的栈帧顶部开辟一个临时的存储空间,将被传递的结构体复制到该临时存储空间,然后将临时存储空间的地址添加到参数列表的最后面,一齐传给被调用函数。

综上分析,C语言只有按值传递这一种参数传递方式。

由于是按值传递,所以如果程序员在函数体内修改了传入参数的值(对指针而言,是指修改指针的指向地址,而不是修改指针指向地址存储的值),这并没有使参数原来的值发生改变,只不过修改了原来值的一个复制品而已。这样的修改是没有意义的。如下两个示例,都不会影响原来的参数。

1    void fi(int a)

2    {

3    …

4    a = 0;

5    …

6    }

7   

8    void fp(int *p)

9    {

10  …

11  p = NULL;

12  …

13  }

图 87

【典型错误】

1修改指针参数的指向

如图 87所示代码,它的输出结果是什么呢?请不要担心会出现运行时错误,函数test虽然修改了参数p的值,但并没有影响到“真正”的main函数中的p值,p还是指向a,程序的输出结果是:1。

所以,请不要试图在函数中修改指针参数的指向,那样是徒劳无功的。

1    void test(int *p)

2    {

3    p = NULL;

4    }

5   

6    main()

7    {

8    int *p;

9    int a = 1;

10  p = &a;

11  test(p);

12  printf(“%d\n”, *p);

13  return 0;

14  }

图 88

2 修改只读变量的值

1    int test(char *p)

2    {

3    *p = ‘0’;

4    return 0;

5    }

6   

7    main()

8    {

9    char *p = “1234567”;

10  test(p);

11  printf(“%s”, p);

12  return 0;

13  }

图 89

在图 88所示代码中,函数test试图修改指针参数p指向的内容,很显然,间接修改指针参数指向的地址的内容,在C语言中是允许的。然而,这段代码在运行中会出现异常。

原因在于指针参数p的指向。它指向的是一个只读的字符串!main函数定义指针p的时候,将其指向了字符串“1234567”,这个字符串在编译的时候被存储在只读数据段中!于是乎,当指针p作为参数传递给函数test的时候,test不能修改它指向的内容!如果函数试图去修改只读数据段的内容,就会发生运行时异常。

【编程技巧】

1使用宏 IN 和 OUT 来标记输入输出参数

为了增强代码的可读性,使代码易于维护,在函数声明和定义的时候,可以在函数首部的参数前加上宏定义IN和OUT来标记参数的输入输出属性。IN表示该参数为输入参数,OUT表示该参数为输出参数。宏IN 和 OUT以及函数定义示例如下:

1    #define IN

2    #define OUT

3    …

4    int fun(IN int a, OUT int *b)

5    {

6    …

7    }

图 90

2 禁止函数修改指针参数指向的内容

有时候传给函数的指针参数,它指向的内容不希望被函数修改,比如传给函数一个字符串的指针。这个字符串只允许被复制,不允许被修改(比如3.4节的【典型错误】2)。这时候,可以在函数首部的参数前面加上const关键字如下:

1     

2    int fun(const char *p)

3     

图 91

如果函数fun试图通过指针p来修改其指向的内容,编译器就会报错。这样可以一定程度上避免函数修改只读变量。如果你在函数fun里面定义一个同类型的指针,使其等于p,然后通过该指针来修改p指向的内容,编译器不会报错。但请你千万不要恶意这样做。

6.5  是否以常数形式传递实参

这是一条编码规范。传递参数的时候直接传常数并没有错,但是,不利于代码的维护和扩展,可读性也不强。一般要求代码中不要出现硬编码(直接的常数赋值,计算,传递参数等),程序员应该将常数转化为宏定义或枚举变量。

6.6  函数可重入

函数的可重入性是多任务运行环境下衍生的一个概念。在多任务的环境下,程序不再是从头到尾地顺序执行,每个任务都有机会得到执行,有可能一个任务在执行时被停止,转而执行另一个任务。任务在这里可指进程,也可指线程。如何保证各个任务的执行互不影响,就是线程安全所要考虑的问题。函数可重入性属于线程安全的一部分。

函数的可重入性有两层意思:

1) 函数可以同时被多个任务执行;

比如任务task1在执行函数fun时,task2,task3等都可以通过抢占CPU来执行fun。这里的“同时”,指的是宏观意义上的“同时”,只要task1开始运行函数fun到运行结束的时间段与task2开始运行fun到结束运行的时间段有重叠,就可以认为task1、task2在同时执行函数fun。对照这层意思,如果task1执行函数fun时,将fun中的资源锁住,导致task2、task3等其他任务无法执行函数fun,那么函数fun就不是一个可重入函数。

2) 多个任务同时执行该函数时,互不影响;

上面的解释可能过于抽象,我们举实例来说明:

1     

2    int sum(int a, int b)

3    {

4    return a + b;

5    }

图 92

1    int gPortLinkStatus;

2    …

3    int adPortGetLinkStatus(int *status)

4    {

5    *status = gPortLinkStatus;

6    if(*status)

7    {

8        …

9    }

10  }

图 93

1     

2    int ucPortSetConfig()

3    {

4    semTake(…);

5    …

6    semGive(…);

7    }

图 94

图 91所示函数sum是一个可重入函数,图 92所示函数adPortGetLinkStatus、图 93所示函数ucPortSetConfig都不是一个可重入函数。图 91 sum函数不依赖任何外部变量和静态局部变量,它所有用到的变量都保存在当前调用它的任务的栈中,不会被其他任务修改,它也没有对函数体内任何部分进行互斥加锁,不会阻碍其他进程同时执行,所以它是可重入的。

图 92的adPortGetLinkStatus函数就不一样了,它需要读取全局变量gPortLinkStatus的值,我们知道全局变量并不为单个任务私有,全局变量存储在数据段中,所有任务在运行的时候都可以读取或修改全局变量的值,如果没有同步机制的话,其中一个任务修改了全局变量,就会影响其它任务继续执行函数adPortGetLinkStatus,因此函数adPortGetLinkStatus不是一个 可重入函数。

图 93的函数ucPortSetConfig很显然也不是一个可重入函数,该函数使用了互斥信号量将函数体内的一部分代码锁住,互斥信号量的特点是:信号量只能被占有的任务释放,不能被其他任务释放。因此,当task1执行ucPortSetConfig时,它占有互斥信号量,只要task1未释放该互斥信号量,其他任务就无法执行函数ucPortSetConfig,故该函数不可重入。

一个可重入的函数必须满足以下条件:

1) 不使用全局变量、静态局部变量;

2) 不返回全局变量、静态局部变量;

3) 仅依赖调用方提供的参数;

4) 不依赖任何单个资源的锁;

5) 不调用不可重入函数

常用的C标准库函数malloc,free,calloc以及标准输入输出函数都不是可重入函数。

【典型错误】 数据访问不同步

图 92所示代码,对全局变量gPortLinkStatus没有任何同步机制,如果task1在读取它的值后挂起,挂起的时间内,task2修改了gPortLinkStatus的值,等到task1恢复执行时gPortLinkStatus的值已不再是task1上一次读取到的值,task1如果继续使用上一次读取的值作为条件判断,就会导致程序出现异常。

正确的做法应该采用同步机制,确保每个任务对全局变量gPortLinkStatus的操作期间,gPortLinkStatus不会被其他任务修改。图 94所示代码采用了互斥信号量来达到这一目的。

1    int gPortLinkStatus;

2    …

3    int adPortGetLinkStatus(int *status)

4    {

5    semTake(…);

6    *status = gPortLinkStatus;

7    if(*status)

8    {

9        …

10  }

11  semGive(…);

12  }

13   

图 95

6.7  调用标准库函数错误

C标准库函数并不神秘和特殊,它和程序员自己写的普通函数没有本质上的差别。程序员在使用库函数时,出错的方式大概有以下几类:

1) 没有判断函数返回值

经验不足的程序员,可能会非常信赖调用库函数的结果,想当然的认为库函数的执行必定会成功。这个认识显然是不正确的。固然,库函数的代码不大可能有问题,但受限于系统资源和依赖于程序员传入的参数,库函数的执行结果,未必如程序员所愿。一个典型的例子是动态内存分配函数malloc,程序员调用时,容易忘记判断函数返回结果,是正常分配,还是返回NULL。常见的一般需要判断函数返回结果的标准库函数有:动态内存分配函数malloc、calloc和文件操作函数fopen、fclose等。

2) 缓冲区溢出

在使用一些字符串操作函数和标准输入输出函数时,由于库函数的缺陷,很容易导致缓冲区溢出,典型的两个函数是strcpy和gets。strcpy的函数原型为:

char *strcpy(char *str1, char *str2)

这个函数将指针str2指向的字符串复制到指针str1处,遇到字符‘\0’,复制结束。调用者如果没有判断str2的长度是否大于str1,那么就很容易造成str1溢出。

3) 自定义函数与库函数同名

C语言的函数默认是全局作用域的,即函数名对所有模块和文件都是可见的。这就带来一个问题,如果程序员定义了一个与库函数同名的函数,那会怎么样呢?

结果有两种可能。

如果程序员在同一个文件里包含了库函数所在的头文件,那么编译的时候不会报错,链接的时候会报错。如果程序员没有意识到是自己定义了一个与库函数同名的函数,那就够他头疼一阵子了。

如果程序员没有包含库函数所在的头文件,那么程序编译连接正常,运行正常,程序员自己定义的函数生效。

【典型错误】 没有判断malloc函数的返回结果

11  int f()

12  {

13  int *p;

14  …

15  p = malloc(sizeof(PORT_STATUS_STRUCT));

16  memcpy(p, …);

17  …

18  }

图 96

上图所示代码,程序员申请内存赋给指针p,没有判断malloc的返回值是否为NULL,直接对p进行了内存内容复制操作,这里隐藏了一个很严重的bug。

7.    内存的使用

所有的程序都必须留出足够内存来存储它们使用的数据。一些内存分配是自动完成的。例如,

1    int rc;

2    char tempName[ ] = “DoS Defend”;

图 97

于是,系统将为存储rc和字符串tempName的分配足够的内存空间。也可以更明确地请求确切数量的内存:

3    #define ARP_DEFNED_IP_STRING_LEN 4

4    ……

5    char ipStr[ARP_DEFNED_IP_STRING_LEN] = {0};

图 98

这一声明为ipStr分配了ARP_DEFNED_IP_STRING_LEN个字节的空间,每个位置可存储一个char值。在以上这些情形中,声明同时给出了内存的标志符,因此可以使用rc,tempName,ipStr来引用内存中的数据。

C语言也允许程序员手动分配内存。主要工具是malloc函数。它接受一个参数:需要分配的字节数。然后malloc找到一个大小合适的内存块。这样分配的内存是匿名的。Malloc没有为它指定标志符,但返回了那块内存第一个字节的地址。可以把这个地址分配给一个指针变量。例如,

6    int * ptd;

7    ptd = (int *) malloc ( 30 * sizeof(int) );

图 99

这段代码请求了30个int类型值的空间,并把ptd指向该空间所在的位置。

不当的内存使用方式,是许多由C语言构建的系统不稳定的罪魁祸首。因为诞生的年代比较久远,C语言的设计者没有对一些错误的内存使用提供检测和预警。这种运行时才表现出来的错误很难追踪。因此,这里需要对各种内存使用的方式进行规范。下面将列出一些常见的问题。并尽可能给出解决方案。

7.1  数组越界

数组越界是一个很容易不小心就犯的错误。C语言是不对数组越界做判断的。当发生数组越界时,程序的执行有时会发生难以预料的情况。如下面这段程序。

8     void func()

9    {

10       int   i;  

11       int   num[10];  

12       for(i=0;i<=10;i++)  

13           num[i] = 0;  

14       printf("loop   end.");

15  }

图 100

当这段函数运行的栈空间是从高向低增长时,这段代码会在系统中造成一个死循环。因为对num[10]赋值的操作,实际上写的是变量i的内存空间。

解决数组越界问题的关键是编码时处理好循环条件的判断。建议的编码方式是从数组的第一个下标开始计数,循环终止条件使用sizeof,让系统自己计算需要循环的次数。

16  For(i = 0 ; i < sizeof(num) / sizeof(int) ; i++ )

7.2  动态内存分配

Malloc函数的原型如下所示:

17  void *malloc( size_t size );

malloc的参数size以字节为单位。建议使用“数量乘以数据类型大小”的方式计算需要的内存空间。比如分配10个字节的整数空间,代码如下。

Int * ptr = (int *) malloc ( 10 * sizeof(10));

和静态分配内存的方式不同。动态分配内存是存在不成功可能的。在嵌入式系统中,当所分配的内存空间越大时时,分配不成功的概率也会越大。所有基于动态内存的操作都必须基于已经成功分配这一个事实,进行分配结果检查是必须的。建议的编码方式应该如下所示。

if ((ptr= malloc(BUF_SIZE)) == NULL)

{

       printf("Not enough memory for image buffer\n");

       goto  ERROR_DEBUG;

}

需要指出,嵌入式系统中内存是很宝贵的资源。应该尽量少的使用malloc。一次malloc操作实际消耗的内存是:内存块头部+实际分配的空间。内存块头部是系统内存池管理内存块使用的数据结构。实际分配的空间,是对传入的size进行对齐处理后真正分配的内存空间。假如使用malloc(1)分配一个字节的空间,系统实际上一共分配的内存块远大于1字节。另外需要指出的是malloc是有一定时延的。它需要查找空闲内存池链表,为程序返回一个大小合适的内存块。对于要求高实时性的代码,是不适合频繁使用malloc分配内存的。

7.3  内存释放与内存泄露

C语言的运行环境没有提供垃圾回收器。每次malloc分配的内存,都需要在使用完成后的第一时间使用free释放掉。Free的参数是先前malloc返回的地址,它释放先前分配的内存。这样,动态分配的内存的生存时间从malloc开始,到free结束。局部变量的内存数量在程序执行时自动地增加或减少;与此不同,动态分配的内存如果不使用free,数量会不断累计。看下面错误的代码:

18  int main()

19  {

20  int i = 0;

21  for( i = 0 ; i < 10000 ; i++ )

22  {

23      memLeak(2000)

24  }

25  }

26  Void memLeak(int n)

27  {

28  Double * temp = (double *) malloc ( n * sizeof(double) );

29  }

图 101

循环执行了10000次,在循环结束时,已经有80M字节的内存从空闲内存池中移走。事实上,在到达这一步之前,内存可能已经耗尽了。

另一种错误的编码方式是malloc和free没有成对出现。看下面的代码。

30  char * function1(void)

31  {

32   char * p;

33   p = ( char * )malloc( 100 * sizeof(char) );

34   if(p == NULL)

35  {

36    …;

37    }

38    …

39   return p;

40  }

41   

42  void function2(void)

43  {

44  char * q = function1();

45  …

46  free(q);

47  }

图 102

这段代码并不会在运行时产生错误。但是,这种编写方式很不可取。它造成的耦合度很大,完全违反了"谁申请,就谁释放"的原则。Malloc和free出现在两个函数中。使得实现function2的用户在调用function1函数时需要知道其内部细节。显然,这是一种糟糕的设计。这种代码怎么写呢,下面提供一段建议的编写方式。

48  char * function1(void)

49  {

50  char *p=malloc(…);

51  if(p==NULL)

52  …;

53  Function2(p);

54  …

55  free(p);

56  p=NULL;

57  }

58   

59  /*而函数function则接收参数p,如下:*/

60  void function2(char *p)

61  {

62   … /* 一系列针对p的操作 */

63  }

64   

图 103

7.4  动态内存越界

动态内存越界访问造成的后果非常严重,是程序稳定性的致命威胁之一。它造成的后果是随机的,表现出来的症状和时机也是随机的,让BUG的现象和本质看似没有什么联系,这给BUG的定位带来极大的困难。

动态内存的大小由工程师分配时指定。如果工程师对所用的内存大小估计不足,会诱发很难发现的写越界,破坏内存池链表的完整性,从而导致系统崩溃。这个问题没有很好的解决方案。需要工程师编写代码时更小心谨慎。

7.5  任务堆栈溢出

任务堆栈是任务运行时必须使用的空间。它的大小由工程师在创建任务时指定。在PC上,Windows或Linux的任务的堆栈空间通常很大,可以达到几十兆字节,定义较大的局部变量一般不会发生堆栈溢出。而在一些嵌入式系统中,任务的堆栈空间可能很小。在嵌入式平台中,堆栈溢出是最常发生的错误之一。在编程时应该清楚自己平台的限制,避免堆栈溢出的可能。如果任务使用较大的数组,采用层次较深的递归调用或函数调用,任务堆栈就会存在溢出的危险。

在VxWorks系统中,一般的任务建议分配4096 Bytes大小的堆栈。对于可能超过这个大小的任务,建议分配远大于预计大小的堆栈。在运行时使用checkStack查看这个堆栈的使用情况,在没有溢出危险时,动态调整堆栈大小。

8.    其它错误

本章集中讲了字符串操作、回调函数、信号量三个主题,都是较难把握的概念。

8.1  字符串操作越界

C语言没有字符串数据类型,因此,字符串只能存储于字符数组或动态分配的内存中。对字符串的操作,很多时候就是对字符数组的操作。C程序员都知道,字符串必须以字符‘\0’结束,所以在定义字符数组或者动态申请内存存放字符串时,一定要预留字符‘\0’的存储空间。因为这个特性,C语言字符串的操作,很容易造成内存溢出错误。

C标准库函数提供两种类型的字符串操作函数,程序员在使用的时候不够谨慎的话,就会导致内存溢出。第一种函数是只通过寻找字符‘\0’来结束对字符串的操作,比如strcpy,strcat;另一种函数是隐式地在字符串末尾加上字符‘\0’,如sprintf。 本小节的【典型错误】较深入地揭示了这两类函数不当使用造成的内存溢出。如果要确保程序的安全,建议使用strncpy、snprintf等有指定操作长度的函数代替strcpy、sprintf。

如果需要对字符数组中的元素‘\0’进行操作,那字符操作库函数就无能为力了。此时,可以使用内存操作函数memcpy、memset等对指定大小的内存块进行操作,这些函数无视字符‘\0’,它们以程序员指定的length来判断是否该结束操作。

【典型错误】 1 使用strcpy导致的内存溢出

65  char str[4];

66  …

67  strcpy(str, “chk_1”);

68  …

图 104

这是很低级的错误,主要为了说明,如果程序员定义的字符数组不够大的话,很容易在复制字符串的时候出现内存溢出。

2 使用sprintf导致的内存溢出

69  char tmpStr[6];

70  …

71  for(i = 1; i < 24; i++)

72  {

73      sprintf(tmpStr, “chk_%d”, i);

74  }

75  …

图 105

如上代码中,当i大于等于10时,由于 sprintf函数会在字符串末尾自动添加结束符‘\0’,该字符串实际需要7个字节来存储,但tmpStr只有6个字节,于是内存溢出了。

【知识拓展】字符操作库函数的返回值

这应该是一个很隐蔽的死角。函数strcpy完整声明如下:

1   

2    size_t strlen(char const *string);

3     

图 106

返回值类型size_t定义在头文件stddef.h中,它是一个无符号整数类型。所以,在使用strlen的返回值时,请格外谨慎:当strlen(str) = 0时,strlen(str) – 1并不等于-1,而是0xffffffff!

与strlen相同,关键字sizeof的结果也是一个无符号数。

8.2  回调函数错误

讨论回调函数之前,先要讨论函数指针。

先看一个有趣的问题:要使程序执行从地址0处起始的代码,该处代码等效为一个返回值为空,参数列表为空的函数,如何以调用函数的形式显示调用从地址0处起始的代码?

思路:将地址0强制转化为一个指向返回值为空、参数列表为空的函数指针,然后通过函数指针调用之。

函数指针的定义形式为:数据类型 (* 指针变量名)();

所以一个指向返回值为空、参数列表为空的函数指针强制类型转化符为:void (*)();

将地址0转化为一个函数指针:(void (*)())0;

通过该函数指针调用之:((void)(*)()0)();

上面的示例很清楚讨论了函数指针的定义、通过函数指针调用函数。函数指针是一种指向函数起始地址的指针,函数名就可以认为是一个函数指针。

函数指针的一个重要的用途就是作为参数传递给其他函数,这时候,它指向的那个函数就称为回调函数。

如下示例:

1    void linkscan_thread(…, func)

2    {

3    …

4    func();

5    …

6    }

图 107

函数func()是函数linkscan_thread的回调函数。回调函数func的定义是有讲究的。func的定义越简单越好,func里面的操作越少越好。假设函数linkscan_thread是一个交换机的端口扫描进程的执行函数,因为func是由linkscan_thread所属的进程执行的,如果func的操作复杂,就会造成该进程延迟,如果func的执行有异常,就会造成该进程被阻塞、死锁甚至被终止。所以对回调函数,一般有如下要求:

1)          如果其它进程通过回调函数与执行回调函数的进程通信,那么回调函数只需要发送消息给其它进程即可;

2)          如果回调函数属于公共函数,那么它的操作必须尽可能简单;

3)          回调函数里不允许有同步操作,二元信号量、互斥信号量、临界区、共享锁等不允许存在。

【典型错误】 回调函数使用信号量的隐患

1    void func(void)

2    {

3    semTake(…);

4    …

5    semGive(…);

6    }

7   

8    void linkscan_thread(…, func)

9    {

10  …

11  func();

12  …

13  }

图 108

如上代码,假定函数linkscan_thread属于进程A,func试图获取的信号量属于进程B,则当linkscan_thread执行func时,如果进程B没有释放信号量,则func将把进程A挂起!这显然是不能允许的。

8.3  信号量死锁

信号量是一种同步技术,使用信号量,可以在多任务环境下,对多个进程共享的资源加锁,以确保各进程得到的资源是同步的。VxWorks操作系统支持以下两种信号量:

1)          二元信号量,简称信号量。创建一个信号量后,如果进程A获取了该信号量后,其他进程无法访问该信号量锁住的资源,但其他进程可以释放该信号量。

2)          互斥信号量,比二元信号量更严厉。进程A获得信号量后,只能由进程A释放,其他进程无法释放。

信号量的使用需要谨慎小心,否则很容易造成死锁。常见的一种死锁模式为:进程A占有信号量s1,进程B占有信号量s2,如果此时进程A需要获得信号量s2以进一步操作,它发现s2不是空闲的,于是进程A挂起(请记住,它还没有释放信号量s1),而进程B恰巧需要获取信号量s1,由于s1被进程A占有,进程A又挂起,信号量s1非空闲,于是进程B也挂起(请记住,它没有释放信号量s2就挂起),就这样,信号量s1,s2永远不会被释放,进程A、B永远挂起,永远不会被唤醒,这就产生了一个死锁。

由上面的分析可知,死锁往往是由于两个进程都试图获取对方的信号量造成的,这个原因虽然很清晰,但在较大规模的软件系统里,一个信号量的获取和释放涉及到很多进程,程序员难以彻底弄清楚各进程与信号量的关系,因而会出现误用的情况。很难规定预防死锁的具体的有效措施,一般要求各个模块在获取每个信号量之前,都要仔细检查是否可能造成死锁。对于信号量的使用,建议各模块都提出来让大家讨论,以尽可能避免死锁。

【典型错误】 两个进程互相获取对方信号量造成死锁

1    void thread1()

2    {

3    …

4    semTake(s1);

5    …

6    semTake(s2);

7    …

8    semGive(s2);

9    …

10  semGive(s1);

11  …

12  }

13 

14  void thread2()

15  {

16  …

17  semTake(s2);

18  …

19  semTake(s1);

20  …

21  semGive(s1);

22  …

23  semGive(s2);

24  …

25 

26  }

图 109

如上代码,threa1是进程1的入口函数,thread2是进程2的入口函数。这两个进程是一个很明显的死锁。

【知识拓展】线程安全概述

多任务操作系统,各个进程的执行顺序是变化不定的,对于所有进程都可以访问和修改的全局变量和堆数据(指存储在进程堆上的数据,比如mallco分配的内存上保存的数据。有的操作系统,进程有私有的堆空间,如Linux,这种情况,堆数据主要是被同一进程的其他线程修改;大部分嵌入式操作系统的进程都没有自己私有的堆空间,如vxWorks,这时候堆数据更容易被别的进程修改。)而言,确保它们的一致性是必须的。否则,如果所有进程共享的资源没有一种机制来保证一致性的话,程序运行的结果完全是无法预料的,如下操作:

1    void f()

2    {

3    …

4    gSum++;

5    if(gSum > …)

6    {

7        …

8    }

9    }

图 110

如果在对全局变量gSum加1后,CPU被其他进程抢占,其它进程修改了gSum的值,则当函数f返回执行时,gSum已不是上次加1后的值,如果仍然采用之前的gSum进行判断,程序极有可能出错。

为了保证多个进程共享的资源的一致性,一般可采用的技术有:

1) 原子操作

原子操作需要处理器支持,其意是CPU将原来需要多条指令的操作合并成一条指令完成,CPU在完成该操作之前,不会被其它进程或异常、中断打断。

2) 同步

同步是使用软件技术来使对共享数据的操作实现原子性。即进程在完成对共享数据的修改之前,不会被其它进程抢占对该共享数据的控制权。常用的同步技术有:信号量、临界区、共享锁。这些技术需要由操作系统提供。

信号量已由本小节讨论过,在此不赘述。临界区是比互斥信号量更为严格的技术,当一个任务的执行进入临界区时,除非该任务执行完临界区的指令,否则其它任务无法抢占到CPU,中断和异常也得不到响应。

共享锁是较为宽松的一种技术。由于使用信号量和临界区带来的性能消耗太大,当多个进程对共享数据绝大部分操作都是只读时,为了避免频繁的信号量获取和释放,可以采用共享锁。共享锁工作原理为:所有读取资源A的线程给资源A加上一把读操作的锁,所有线程可以同时读取A。此时其中某个线程如果要修改A,它必须等待所有线程都释放读操作锁之后,给资源A加上一把写操作锁,然后对资源A进行修改。期间,如果其他线程试图读取A,需要先判断A当前是否被写操作锁住,只有当写操作解锁后,才能读取。概括为:读共享,写互斥,写时不能读,都不读时可以写。

9.    接口错误

本章讨论SMB交换机组软件架构中sw层接口的要求,如本章所述接口要求与现行SMB交换机产品线的软件架构有冲突,以现行软件架构要求为准。

9.1  SW层接口要求

由于sw层接口主要提供UI层使用,各模块在设计sw层接口时,需要综合考虑web、cli、snmp三种UI的需求,不能只考虑web或cli一种UI的需求。这就要求sw层接口分工、功能封装要更合理更通用,以便满足三种不同UI的要求。如端口模块配置端口的速率、双工、流控、协商等端口属性,web页面可以一次性传入所有属性参数,但CLI设计了多条命令来设置端口各个独立的属性,因此sw层不能将设置端口属性的接口设计为一个接收所有端口属性参数一次性设置所有端口属性的接口,必须把设置每种端口属性的功能都单独设计一个接口。

为了使三种UI的错误处理、参数判断能够统一,也要求sw层接口必须判断UI传入的参数,必须返回错误类型代码。

9.2  接口使用者注意事项

使用者不需要查阅接口所在的.c文件,只需要根据.h文件声明的接口来使用。使用sw层接口,需要注意以下事项:

1)     正确包含接口所属的头文件,要求包含路径已为include文件夹内的相对路径,如函数swPortSetUsrCfg()定义在include文件夹内子文件夹sw内的头文件swPort.h中,则在调用swPortSetUsrCfg的.c文件中,需要如下包含头文件:

 

#include <sw/swPort.h>

 

图 111

2)     正确处理sw层接口的返回值。这里有两层意思,一是必须处理每一个错误返回,不允许遗漏错误处理,二是处理要正确,每种错误类型都有一个唯一的返回类型值及其对应的错误提示信息,调用者不能张冠李戴,将错误类型A以错误信息B告诉用户。

3)     正确传入参数,虽然接口里面会对参数进行判断,但使用者不能依赖接口的参数判断。

9.3  接口提供者注意事项

接口提供者需要注意以下事项:

1)          必须确保声明在.h文件中的sw层接口与定义在.c文件中的接口一致,因为使用者只会依据.h文件来调用接口;

2)          确保对所有参数进行合法范围的检查;

3)          确保接口不会发生内存泄漏及其他资源未释放,如taskLock未释放,信号量未释放等;

4)          确保所有错误情况都能返回正确的错误类型返回值。

10.         编码规范

本编码规范是对checklist_codeRerverw.xls所列项目的逐条解释,在解释过程中,会涉及到需要这样做的原因,并且给出例子进行说明。本章说明的编码规范如果与现行的编码规范有冲突,以现行编码规范为准。

10.1            是否给单个循环、条件语句也加了花括号

 循环语句的标准形式有三种,分别如下所示:

1.

   do

   {

       语句1;

   }while();

2.

   while()

   {

       语句2;

   };

3.

   for()

   {

       语句3;

   };

图 112

其中,当循环体内的语句只有一条的时候,循环语句的花括弧可以省略不写,如下所示:

1.

  do

       单条语句;

   while();

2.

   while()

       单条语句;

  

3.

   for()

       单条语句;

  

图 113

但是我们并不提倡这种做法,一来代码的层次感,结构感很不强,增加了阅读代码的人的负担,使人往往不知道循环从哪里开始,到哪里结束。二来,更重要的原因是,有时候在书写语句的时候会顺手多写一个分号,这样一来,循环就会出现错误,即使不是语法错误,也会是逻辑错误,肯定和作者的意思相悖。例如下面的错误做法:

1.

  do

       ;          /*循环多一个分号,编译不过*/

       单条语句;

   while();

2.

   while();      /*该处多了个分号,造成循环体语句为空,和作者的意思相悖*/

       单条语句;

  

3.

   for();    /*类似while循环*/

       单条语句;

  

图 114

由次可见,由于粗心大意带来的错误往往会违背作者的原意,而在循环体内加入表示语句块的花括号,就能避免这类错误,即使是单个语句,也建议加入花括号,因为处于花括号内的语句,会被条件和循环语句抽象成一条语句,和单条语句的作用类似。同时要注意循环体内的语句需要缩进一个制表符,这样看起来比较美观。

以后,书写循环 语句的时候,就需要执行标准语句的格式。

类似地,对于条件语句,首先给出不太标准的形式:

if ()

   单条语句;

图 115

一般地,如果条件语句只有一条语句,这样写法是没有任何问题的,但是有时候也会很随意地,就多写了一个分号,如下所示:

if (); /*多了个分号,判读语句的执行内容就是一个空语句*/

   单条语句;

图 116

由于一个小小的手误,条件语句的执行效果就和预想的效果完全不符合。为了防止这类错误,无论条件语句的执行体语句有多少,都应该给他们加上花括号,如下面所示:

if ()

{

   单条语句;

}

图 117

注意:花括号需要独占一行,并且语句需要缩进一个制表符长度,这样要求的目的除了前文所述,还达到了美观的效果。

10.2            判断和开关语句格式是否符合规范

判断和开关语句只要包括以下两种:if 和switch-case ,标准格式如下图所示:

if ()                   switch()

{                       {

   语句;                    case :

}                           语句;

else                        break;

{                       case :

   语句;                        语句;

}                           break;

                        default:

                            语句;

                            break;

                        }

图 118

以上是比较规范的写法,它包括以下内容:

1. 每段语句体都使用花括号来包含起来,表明该语句属于同一个语句体,在花括号里面的语句抽象成一条语句,而且需要缩进一个制表符,如此显得雅观些。

2. 一般if语句,如果不是仅仅判断某种情况是否出现,都需要在后面街上else  处理语句,这样做的目的是防止遗漏任何需要处理的分支。

3. 推荐case 一般和switch处于同一列。

5. 每个case分支的语句处理完之后都需要有一个break 语句,否则switch会一直执行下去而不退出,如果有特殊情况,那么应该注释。

6. switch语句需要一个default处理,这样可以保证无论传递进来的是任何参数,一定能够得到适当的处理。同时default语句也需要一个break,整体结构比较对称。

10.3            代码段落是否被合适 地以空行分隔

首先,我们来看一下以下代码的美观程度。

     struct sockaddr_in dst;

    struct sockaddr_in from;

    struct icmp * sicmp = NULL;

    struct icmp * ricmp = NULL;

    struct ip *      ipp = NULL;

    char rcvbuf [1024];

    char sndbuf [ICMP_MINLEN];

    int rlen = 0;

    int fromlen = 0;

    int i = 0;

    int sockfd = 0;

    u_short pid = 0;

    bzero ( (char *)&dst, sizeof (dst));

    bzero ( (char *)&from, sizeof (from));

    bzero (sndbuf, sizeof (sndbuf));

    bzero (rcvbuf, sizeof (rcvbuf));

    sicmp = (struct icmp *) sndbuf;

    pid = (short)taskIdSelf () & 0xffff;

    dst.sin_family = AF_INET;

    dst.sin_addr.s_addr = ip->s_addr;

    sicmp->icmp_type = ICMP_ECHO;

    sicmp->icmp_code = 0;

    sicmp->icmp_cksum = 0;

    sicmp->icmp_id = pid;

    sicmp->icmp_seq = 0;

    sicmp->icmp_cksum = checksum ( (u_short *)sndbuf, sizeof (sndbuf));

    fromlen = sizeof (from);

    i = sendto (sockfd, sndbuf, sizeof (sndbuf), 0,

                (struct sockaddr *) &dst, sizeof (dst));

图 119

显然,这是一段极其糟糕的代码,完全没有可读性,同样的内容,我们把它改造成下面的结构:

     /*变量定义*/

     struct sockaddr_in dst;

    struct sockaddr_in from;

    struct icmp * sicmp = NULL;

    struct icmp * ricmp = NULL;

    struct ip *      ipp = NULL;

    char rcvbuf [1024];

    char sndbuf [ICMP_MINLEN];

    int rlen = 0;

    int fromlen = 0;

    int i = 0;

    int sockfd = 0;

    u_short pid = 0;

  

    /*清零dst,from,sndbuf及rcvbuf变量*/

     bzero ( (char *)&dst, sizeof (dst));

    bzero ( (char *)&from, sizeof (from));

    bzero (sndbuf, sizeof (sndbuf));

bzero (rcvbuf, sizeof (rcvbuf));

 

/*初始化sicmp及pid 变量*/

    sicmp = (struct icmp *) sndbuf;

    pid = (short)taskIdSelf () & 0xffff;

 

     /*给dst变量赋值*/

    dst.sin_family = AF_INET;

    dst.sin_addr.s_addr = ip->s_addr;

 

     /*给变量sicmp赋值*/

    sicmp->icmp_type = ICMP_ECHO;

    sicmp->icmp_code = 0;

    sicmp->icmp_cksum = 0;

    sicmp->icmp_id = pid;

    sicmp->icmp_seq = 0;

    sicmp->icmp_cksum = checksum ( (u_short *)sndbuf, sizeof (sndbuf));

    fromlen = sizeof (from);

   

     /*各个变量完毕之后,调用sendto来把相应数据发送出去*/

     i = sendto (sockfd, sndbuf, sizeof (sndbuf), 0,

                (struct sockaddr *) &dst, sizeof (dst));

图 120

同样的代码,仅仅是多加一些注释和使用空格来隔开,代码的可读性马上改变。

以下两个地方是一定要使用空格来分隔的:

1. 变量定义和实际执行代码之间。

例如u_short pid = 0;是最后一个定义的变量,它和后面的执行代码,需要使用至少一个空格来分隔,同时,尽可能地加上注释,来表明每个定义的变量的作用。

2. 各个具有明显不同功能或者性质的代码段之间。

代码是具有内聚性的,也就是说大段代码是由众多的小段代码组成的,其中每一个小段都具有相同的性质,小段与小段之间的性质不同。在这种情况下,各个小段之间需要加入空格来分隔,表明它们的性质不相同。

例如:

    /*清零dst,from,sndbuf及rcvbuf变量*/

     bzero ( (char *)&dst, sizeof (dst));

    bzero ( (char *)&from, sizeof (from));

    bzero (sndbuf, sizeof (sndbuf));

bzero (rcvbuf, sizeof (rcvbuf));

 

/*初始化sicmp及pid 变量*/

    sicmp = (struct icmp *) sndbuf;

    pid = (short)taskIdSelf () & 0xffff;

图 121

第一段都是使用bzero函数来清零变量,可以认为它们的性质是相同的,成为一个具有内聚性的小段。第二段是使用赋值语句的方式来给变量sicmp和pid赋值,也可以认为是一小段,两段之间使用一个空格来分隔,而且每一小段需要通过注释的方式来指明该小段的作用。

10.4            所有的判断是否使用了(常量 == 变量)的形式

在使用if 语句来判断变量是否等于某个特定值的时候,有以下两种形式:

       左                       右

if (varName == VALUE)          if (VALUE  == varName)

{                       {

   执行语句;                   执行语句;

}                       }

图 122

左边的使用方式是最常见的方式,但是这样的使用方法容易导致以下的错误:

原意:                   误写:

if (varName == VALUE)       if (varName = VALUE)

{                    {

   执行语句;                执行语句;

}                    }

图 123

把判断操作‘ == ’误写成 赋值操作‘=’,这样有两个严重后果:

1. 判断语句永远为真,或者永远为假(取决于VALUE是否为零)

如此一来,就失去了作者原来的意思。

2. 改变变量varName的值

这个是更为严重的错误,产生的后果就不多说了,往往极其诡异的bug就是这样产生的。

所以,在需要判断一个变量是否等于某个常数的时候,建议采用以下格式,在这样的格式下,即使把判断操作写成赋值操作,也不会出问题,在编译过程中,编译器会报错:常数不能作为左值。

if (VALUE == varName)

   执行语句;

图 124

10.5            是否有多个短语句写在一行中

多个短语句写在一行中也是一个很不好的编程习惯,它把代码复杂化了,极其难看,如下面例子:

int i = 0 ; int j = 0 ;i++; j++ ;printf(“i = %d j = %d \r\n”,i,j);

图 125

上面的代码完成了变量i、,j的定义,自增,然后打印出变量i、j的值。5条语句揉在一行,使人看得很不舒服,改成下面格式,就美观了许多:

int i = 0 ;   /*变量i的作用,以下省略*/

int j = 0 ;   /*变量j的作用,以下省略*/

 

/*执行i,j自增,然后打印*/    /*还记得1.3原则吗?变量定义结束需要加一个空格行来分隔*/

i++;

j++;

printf(“i = %d j = %d \r\n”,i,j);

图 126

经过改造的代码,看起来至少要雅观了许多。

总的原则就是:一行一条语句,不要把多个语句挤在一行,这样的代码可读性差。

10.6            是否每个if-else语句都有最后一个else以确保处理了所有情况

判断语句有以下两种形式:

简单判断语句                 复杂判断语句

if()                    if ()

{                       {

   执行语句;                    执行语句;

}                       }

else                    else if() 

{                       {

   执行语句;                   执行语句;

}                       }

                        else if()

                        {

                            执行语句; 

                        }

                        ……

                        else

                        {

                            执行语句

                        }

图 127

无论如何,一般地,不要忘记最后的else 判断分支,它表示如果前面的所有情况都不匹配的前提下,无条件地执行else分支的执行语句。这样做的好处在于:无论出现什么情况,都会得到适当的处理。

但是情况也有例外,有些时候最后的else 分支也是可以不需要的。例如下面的代码,作者的意思在于:如果变量达到某个阈值,则发送一条报警信息,否则,继续执行。

if (var >= threshold)

{

   printf(“warning!!\r\n”);

}

图 128

在这种情况下,就不需要加入else语句,否则就只是画蛇添足。

10.7            显式使用IN/OUT来说明输入、输出

推荐使用这样的代码风格,但是不强制,本人不喜欢使用,因为觉得没有必要。我们首先来看看如何使用这个格式,IN/OUT一般用于函数调用,用于指示参数的传递方向,代码如下:

#define IN       /*定义两个无意义的宏,仅仅起到指示作用*/

#define OUT  

STATUS  function(IN int num , OUT int *result)

{

   if(NULL == result )

   {

       return ERROR;

   }

   *result = num * 2;

   return OK;

}

图 129

上述代码很简单,仅仅是把传递进来的num变量乘以2,然后赋值给result所指向的变量,当然,需要首先判断result所指向内存的合法性。

在代码中,IN/OUT是两个无意义的宏,只是用来表明函数参数的方向性,IN 表示从函数外面把值传递进来,而相对地,OUT 则表示从函数内部把值传递出去,即,有可能改变函数外面的变量的值。如下所示:

 

图 130

一般地,函数传递参数有以下三种:1. 按值传递,2. 按地址传递,3.按引用传递

1. 按值传递

“按值传递”是通过传递对象来传递参数,一个对象就是一个结构体。如下所示:

 

图 131

函数体中所使用的变量var其实仅仅是外部参数的一个复制,无论如何修改该参数,它不能影响外部参数的值,它仅仅在函数运行期中存在,函数运行结束,也就马上释放掉。所以这种类型的参数的传递,显然就是IN类型。

2. 按地址传递

 

图 132

按地址传递,就是采用指针的形式,实际上传递给函数的也是一个值:一个32或者64位的指针值,该指针指向一个struct Type类型的变量.由于得到了该变量的地址,那么就可以通过该地址来修改变量,而该变量实际上是在函数外面定义的,所以可能会影响外面变量的值,属于OUT类型。

补充一点:如果使用const struct Type *var形式来声明参数,则严格地说,函数内部是不能改变函数外部所定义的变量的。

3. 按引用传递

       这种方式显然属于OUT类型。

10.8            显式地使用const来说明常量

常量就是常值的变量,也就是说在该变量的生存期内,它的值不能,不会,也不该改变,由于常量不能两次赋值,所以一般要求常量在定义的时候就初始化完毕。

对于常量,有两种方式来定义,一种是使用宏的方式,另外一种是使用const来说明常量。第一种方式是在预编译的时候进行处理,第二种方式则不是,它仅仅是个普通变量,但是它有个特点,只能读,而不能写。

使用const来定义常量的三种典型情况:

第一种:全局变量

/*file1. c */

const int  var = 10 ; /*定义了一个全局常量,它的值永远为10*/

void function(void)

{

   代码;

}

图 133

上述代码定义了一个常量var ,并且初始化为10,在整个代码运行期间,它的值不变,它能够被file.c和其它文件所引用和访问,但是不能被修改。

第二种:局部变量

/*file2. c */

void function(void)

{

   const int  var = 10 ; /*定义了一个局部常量,初始化值为10*/

   其它代码;

}

图 134

在这种情况下,var虽然也是一个常值变量,但是它的生存期仅仅局限于函数function,同样地,它的可见范围也只能局限于函数function,同一个文件的其它函数,或者不同文件的函数,是不能访问var的。

第三种:函数参数

/*file3. c */

void function(const int var) /*定义了一个局部常量,没有初始值*/

{

   其它代码;

}

图 135

在这种情况下,var和第二种情况差不多,唯一的区别在于初始化值,由于是作为函数的参数而存在,所以它不能在定义的同时进行初始化,而是要等到函数实际运行的时候,由调用函数的一方进行参数值的传递。

10.9            复杂的分支流程是否已经被注释

复杂的分支流程,主要包括两大类:if语句和switch语句,如下图所示:

复杂switch语句              复杂判断语句

switch()                if ()

{                       {

case:                       执行语句1;

    语句1;                 }

    break;                  else if()

case:                   {

    语句2;                     执行语句2;

    break;                  }

case:                   else if()

    语句3;                 {

    break;                     执行语句3;

...                  }

default:                else

    语句n;              {

    break;                     执行语句4;

}                       }

图 136

对于复杂switch语句,有三个地方需要注意:

1. 对于每个case开始,都必须在case 旁边进行注释,对该case的基本情况进行说明。

2. 每个case分支都必须在最后加上break语句,目的是为了结束该case的处理,跳出switch匹配语句。如果需要继续匹配下面的case分支而取消break语句,则需要在该分支中进行注释,解释原因。

3. 如果某个case语句比较多,比如占了上百行,这个时候,需要在该case的结束地方插入注释,说明此处结束的是那一个case,否则容易带来混乱。

对于复杂的if  判断语句,基本上和switch语句一样,需要注意以下两点:

1. 每个判断的分支的开始,都必须进行注释,讲明白该分支所进行的处理内容。

2. 对于某个if分支代码比较长的情况,必须在该分支结束的时候进行注释,写清楚此处结束的对应那一个if分支。

10.10         条件编译的书写格式是否正确

条件编译,顾名思义,就是指有条件地编译。条件编译有两种典型的代码结构,如下所示:

              判断某个表达式是否为0

#if  表达式              #if 表达式

   编译代码;                   编译代码;

#endif                      #else

                            编译代码;

                        #endif

图 137

上述代码根据表达式的值是否是0来编译相应的代码。

 编译器在进行编译过程的时候,需要经过4个步骤:预编译(pre-process) ,编译(compile),汇编(assemble),链接(Link)。条件编译是在预编译过程中进行的,预编译根据相应的条件删除一部分代码,留下另外一部分。

              判断某个标识符是否已经定义

#ifdef  标识符               #ifndef 标识符

   编译代码;                   编译代码;

#endif                      #else

                            编译代码;

                        #endif

图 138

上述代码通过判断某个标识符是否已经被定义来决定代码的取舍。

关于函数中的宏定义,由于和条件编译类似,所以要求有统一的格式。函数中的宏定义,如下所示:

void function(void)

{

#define DEBUG_FUNCTIION  1

#define MAGIC_NUMBER 23

……..

#undef MAGIC_NUMBER

#undef DEBUG_FUNCTION

}

图 139

在实际代码中,为了使得我们的代码结构清晰,条例合理,我们对条件编译的格式进行规范,如下所示:

void function(void)

{

#define DEBUG_FUNCTIION  1

 

#ifdef DEBUG_FUNCTION

 

       do_something;

 

#else

 

       do_otherThing;

 

#endif

 

#undef DEBUG_FUNCTION

}

图 140

要求如下:

条件编译或者宏定义,需要顶格书写,与函数中的花括号在同一列,其它代码缩进一个制表符。这样做的好处是:它可以一眼看出哪些代码是条件编译的代码。

10.11         函数粒度是否保持适度大小

如果一个函数的代码很多,结构就会非常混乱,代码清晰度就很差,有时候甚至完全搞不清楚代码的开始或者结束的位置在哪里。所以需要保持一个函数的行数的大小,行数太少,则会消减或者浪费函数的效率;行数太多,容易导致结构的混乱。因此需要保持函数的粒度大小,建议一个函数最好不要超过50行。

11.         错误处理

11.1            是否对所有可能的错误条件进行了处理

这里所指的处理主要是对函数返回值的处理上,如下所示:

STATUS parsePacket(const char * packet, int length)

{

   ……

   return  ERROR_TYPY1;

   …….

   return  ERROR_TYPY2;

   ……..

   return  ERROR_TYPY3;

   ……...

   return OK;

}

图 141

上述代码是一个报文内容进行处理的代码,它总共有1个OK状态和3个错误状态。因此要求在调用该函数的时候,需要处理的状态有4个,代码如下:

 

status = parsePacket(packet,lenght);

 

switch(status)

{

case ERROR_TYPE1:

   do_error1();         /*对错误类型1进行处理*/

   break;

case ERROR_TYPE2:

   do_error2();         /*对错误类型2进行处理*/

   break;

case ERROR_TYPE3:

   do_error3();         /*对错误类型3进行处理*/

   break;

default:

   do_OK();             /*对没有错误的情况进行处理*/

   break;

}

图 142

使用一个switch语句来处理所有的错误状态,其中把返回成功的处理放置在default是为了提高代码的效率,因为switch的default分支的处理比其它分支的处理所花费的代价最小。

当然也存在另外的处理方式,比如下面的使用if语句进行处理:

status = parsePacket(packet,lenght);

 

if(OK == status)

{

   do_ok();      /* 成功返回时候的处理过程; */

}

else

{

   do_error();       /*所有错误类型的处理过程*/

}

图 142

这种办法的处理方式是:把返回的状态分为两种:成功或者失败。分别对应一个if 语句来处理。如果所有的错误的类型的处理办法都一样,那么这种处理的方式值得推荐。

猜你喜欢

转载自www.cnblogs.com/chinhao/p/4699751.html
今日推荐