在C 语言中,表达式是最重要的组成部分之一,几乎所有的代码都由表达式构成。表达式的使用如此广泛,读者也许会产生这样的疑问,像+ 、- 、3 、/ 、& & 这样简单的运算也会出现问题吗? 程序员在编写表达式时,往往带有一些不良的习惯。即使是编写很简单的表达式,这些不良习惯也可能造成隐患,这个小小的隐患甚至可能引起整个系统的崩溃。实际上,在程序调试过程中,表达式中存在的大部分隐患皆来源于程序员的主观臆测,即认为表达式应该是按自己认为的方式执行,但结果可能完全相反。这是因为程序设计语言或编译器的某些内在机制并不如我们所想的那样。所有的编译器都遵从这一假定:程序员都是“神”,他们既了解编程语言的各种特性,也了解编译器本身一些鲜为人知的处理原则。当然, 程序员不是“神”。因此,程序员在编写程序的过程中需要小心地避免编译器“设置”的各种陷阱,而问题是有些时候很难预测下一步是否会踏上一个陷阱。
MISRA C 规则中包含了大量关于表达式书写的规范,最大程度地防范上述可能发生的错误,告诉程序员如何编写规范的C 语言表达式。
本文将首先深入剖析在编译器内部表达式的解析方式,然后罗列和分析书写表达式过程中常见的不规范写法,以帮助读者避免各种不良的编程习惯。文中凡是未加特殊说明的都是强制( required) 规则,个别推荐(advisory)规则加了“推荐”标示。
1 表达式的求值顺序
首先,分析下面两段代码。
问题1 :执行以下程序,从串口依次输入2 和4 ,变量result 将等于多少?
uint8_t result ;
result = uart_GetChar () $ uart_GetChar () ;
/ * 这里,uint8_t 表示8 位无符号整数类型“, uint8_t uart_GetChar () ”是从串口接收一个ASCII 字符的函数。*/
问题2 :执行以下程序,变量result 将等于多少?
uint8_t result ;
uint8_t temp = 2 ;
result = temp + + + - - temp ;
也许读者会不假思索地写出结果:
第一题的答案是“ - 2”,即result = 0x32 - 0x34 = 0xFE ;
第二题的答案是“4”,即result = ( temp + + ) + ( - - temp) = 2 + 2 = 4 。
推理过程看起来似乎是正确的,可是,程序的运算结果是这样吗? 为了弄清楚这个问题,看一下C 语言中有关运算符的规定。
表1 给出了C 语言中运算符和结合律的对应关系。
第一行中, + + 和- - 为后缀自增运算符和后缀自减运算符;第二行中, + + 和- - 为前缀自增运算符和前缀自减运算符, + 、- 、3 、& 分别对应一元正运算符、一元负运算符、间接取值运算符和取地址运算符;其他的+ 、- 、3 、& 分别对应二元加运算符、二元减运算符、二元乘运算符和位与运算符。
表1 C 语言运算符列表(按优先级从高到低)
优先级 |
运算符 |
结合律 |
类型 |
从高到低 排列 |
( ) [ ] - > . + + (后缀) - - (后缀) |
从左至右 |
一元运算符 |
|
! ~ + + (前缀) - - (前 缀) sizeof ( type) ( 强制类型转 换) + - 3 & |
从右至左 |
一元运算符 |
|
* / % |
从左至右 |
二元运算符 |
|
+ - |
从左至右 |
|
|
< < > > |
从左至右 |
|
|
< < = > > = |
从左至右 |
|
|
= = ! = |
从左至右 |
|
|
& |
从左至右 |
|
|
^ |
从左至右 |
|
|
| |
从左至右 |
|
|
& & |
从左至右 |
|
|
| | |
从左至右 |
|
|
?: |
从右至左 |
三元运算符 |
|
= + = - = 3 = / = %= & = ^ = | = < < = > > = |
从右至左 |
二元运算符 |
|
, |
从左至右 |
二元运算符 |
从表1 中可以看出,赋值运算符为右结合,二元运算符都满足左结合;条件运算符(即问号表达式) 为右结合;一元运算符和后缀运算符为右结合。这意味着3 p + + 将
被解释成3 (p + + ) 而非( 3 p) + + 。
回到上述问题的讨论,既然二元加运算符和二元减运算符是左结合的,上述的计算结果应该没有什么问题。很遗憾,C 语言标准规定的只是运算符的结合顺序,而对于
二元运算符两边操作数的求值顺序则未作定义。“对于二元操作符,要先对两个操作数求值(但没有特定顺序) 之后再进行运算”。因此,上述问题的答案已经揭晓:最终的结
果取决于编译器特性。
如果使用的编译器总是从左向右解析表达式,则结果是:
问题一的答案是result = ‘2’$ ‘4’= 0xFE;
问题二的答案是result = 2 + 2 = 4 。
如果使用的编译器总是从右向左解析表达式,则结果是:
问题一的答案是result = ‘4’$ ‘2’= 0x02 ;
问题二的答案是result = 1 + 1 = 2 。
为了避免使用不同编译器而导致的程序结果差异,MISRA - C 提出了如下强制性规则。
规则12. 2 :表达式的值必须在任何求值顺序下保持一致。那么,什么时候会出现表达式的值不一致的情况呢?
MISRA - C 列出了几种可能,如自增运算符和自减运算符使用时、函数参数传递时、函数调用时等。
归纳起来,当表达式中的操作数(也可能是一个表达式) 能够影响某个共享的变量,而这个共享变量又可能导致其余操作数的值发生变化时,就需要对求值顺序保持警惕。典型的一个例子就是i + ( + + i) ,此时+ + i 的操作会引起共享变量i 的变化,而i 的变化将直接影响到第一个操作数的值,于是不同的求值顺序导致了不同的求值结果。在问题1 中,表达式的两边都使用了uart_ GetChar ()操作(确切地说是通过该函数共享了同一个数据寄存器) ,所以求值顺序会影响结果。
解决此类问题的最好办法是把表达式重组,即将一个较为复杂的表达式分解成若干个简单的表达式,使运算符的多个操作数之间的耦合关系得以解除,由此保证求值的顺序。例如,可以将问题1 和问题2 改写成如下的形式。
问题一:
result = uart_GetChar () ;
result - = uart_GetChar () ;
问题二:
result = temp + + ;
result + = + + temp ;
为了防止表达式的求值结果和所期望的结果相悖,MISRA C 还提出了许多行之有效的表达式的书写规则。
规则12. 1 (推荐) :应该减少表达式对C 语言运算符优先级的依赖性。这意味着在更多的情况下,应该用括号“() ”来保证运算顺序,而不依赖于C 语言的运算符优先级,因为C 语言某些运算符的优先级容易引起误解。
下面分析如下程序:
if (STATRegister & BUSYMask = = 0) {
do_something ;
}
该程序的本意是读状态寄存器( STATRegister ) 的值,如果其BUSY位是0 (即被掩码BUSYMask 选中) ,则做某些操作。但是,由于判等运算符“= = ”的优先级高于位与操作符“&”,实际的判断表达式变成了“( STATReg2ister & (BUSYMask = = 0) ) ! = 0”。此时应该加入括号“() ”以保证判断表达式的正确性:
if ( (STA TRegister & BUSYMask ) = = 0) {
do_something ;
}
在程序中,容易出现混淆的地方,也应该通过括号“() ”来组织语句,如
if (a & & b | | c & & d)
应该改成:
if ( (a & & b) | | (c & & d) )
这将使得层次更加清晰,维护起来更加方便。
2 表达式的副作用
所谓的副作用,是指在表达式执行后对程序运行环境可能会造成影响。赋值语句、自增操作等都是典型的具有副作用的操作。此类操作关系到程序运行环境的改变,因此对有副作用的表达式需要格外小心。
规则12. 3 :不允许将sizeof 运算符作用于有副作用的表达式上。
试分析以下的代码:
int32_t i ;
int16_t j ;
j = sizeof (i = 1234) ;
本意是先将1234 赋给i ,再把i 所占用的空间大小传给j 。可是由于sizeof 运算符只针对数据类型进行操作,所以“j = sizeof (i = 1234) ”实际上被替换成“j = sizeof(int32_t) ”。故表达式“i = 1234”的操作不会进行,这就带来了可能的隐患。正确的做法是将最后一句替换成:
i = 1234 ;
j = sizeof (i) ;
规则12. 4 :逻辑运算( & & 和| | ) 的右操作数不允许包含副作用。在C 语言的表达式中,部分代码可能不被求值。如果这些代码具有副作用,就会产生一些隐患。典型的例子出现在逻辑运算中:
if (ist rue | | do_something_with_side_effect s () ) {
do_something ;
}
如果ist rue 非0 ,编译器认为表达式的值已经确定为真,从而不再进行后面的求值,于是有副作用的操作被忽略,影响了后继操作。为了避免出现这种问题,必须把较复杂的操作数放在逻辑运算符的左边,把简单表达式放在右边。如果两个表达式都比较复杂,应该先对某一个表达式求值,并将结果作为逻辑运算符的右操作数,如:
value = expression1 ;
if (expression2 | | value) {
do_something ;
}
MISRA C 中还有一些防止表达式运算结果出现歧义的规则。
规则12. 5 :逻辑运算符的操作数必须是一个主表达式。(注:这里主表达式包括标识符、常量和括号括起来的表达式。)
规则12. 6 (推荐) :逻辑运算符( & &、| | 和!) 的操作数必须为一个有效的布尔值,布尔值表达式不允许进行逻辑运算以外的操作。(注:这是为了防止误用。)
规则12. 7 :不允许对有符号数进行位操作。(注: 这是为了防止结果的不确定性。)
规则12. 8 :移位操作的右操作数只能在0 和操作数的位数减1 之间。(注:移位操作的右操作数就是移位的位数,比如一个8 位的无符号整数,允许移位的位数范围是0~7 ,这是为了防止未定义的操作。)
规则12. 9 :不允许无符号性的表达式进行一元负运算符。(注:同规则12. 8 。)
规则12. 10 :不允许使用逗号表达式。(注:这是为了防止阅读混乱,另外,逗号表达式也可以用其他等价形式替代。)
规则12. 12 :不允许对浮点型值进行位操作。(注:这是为了防止不同标准产生的差异性。)
规则12. 13 (建议) :不允许在同一个表达式中混合使用+ + 和- - 。(注:这是为了防止阅读混乱,并防止出现歧义。)
3 小 结
在C 语言编程中,大部分潜在错误来自于程序员对程序元素的误用或滥用,以及程序员对某些结构一厢情愿的理解。
编写代码时,没有必要太多地去追求所谓程序的“优雅”和“风度”,程序的价值不仅在于运行,还在于为后继的可能升级和维护提供一个参考。一个“优雅”的句式和写法也许能够让作者煞费苦心并乐此不疲,但同样也会让几个月或几年后的你或其他人伤透脑筋。在硬件资源日益丰富的今天,大部分程序员已经不需要像先辈那样去考虑如何缩减一个位的存储单元。随着项目规模的扩大,对程序可读性和可维护性的要求日益突出。程序的可读性和可维护性已经成为衡量程序价值的重要标准。
MISRA C 为嵌入式软件的可靠性提出了中肯的建议,一些嵌入式C 编译器的开发商开始把MISRA 出版物作为标准并予以支持。通过学习MISRA C ,可以更多地了解ANSI C 中的不确定因素,并在开发中尽可能避免误入“表达式失控”的“雷区”。
参考文献
1 MISRA C:2004 ,Guidelines for the use of the C language in
critical systems. The Motor Indust ry Software Reliability As2
sociation ,2004
2 Harbison III. Samuel P , Steele J r. Guy L. C 语言参考手册,
邱仲潘,等译, 第5 版,北京:机械工业出版社,2003
3 林锐. 高质量C + + / C 编程指南,2001
4 ISO/ IEC 9899 :1999. International Organization of Standardi2
zation , 1999
本文pdf下载