构建安全的编译环境

来源:互联网 发布:初级程序员考试时间 编辑:程序博客网 时间:2024/05/29 16:04
<P>预处理是编译环境处理C 程序的第一个环节,但往往最先被程序员忽略。这份看似只是由编译环境做的简单工作,其实也是机关重重。通过介绍MISRA C 与预处理相关的规则,希望读者能够更准确地认识编译器的预处理过程,避免出错。无论是自定义函数还是由编译环境提供的标准库函数,如果使用不当,都会存在安全隐患。<BR><BR>能不能保证函数被正确的定义、声明和调用,关乎到整个程序的成败。这里介绍MISAR C 中涉及函数部分有代表性的规则,并试图分析制定这些规则的出发点,以帮助读者构建更为安全的编译环境。<BR><BR></P>
<H2>1  函数的定义和声明</H2>1. 1  在哪里定义<BR>读者也许会有这样的经历:在一个头文件中定义了一个变量,又让这个头文件被多个源文件引用。这时编译器会“报怨”说重复定义了同一个函数。出错的原因与其说是粗心,不如说是一个习惯的问题。<BR><BR>  <STRONG>规则8. 5 :头文件中不允许包含对象或函数的定义。</STRONG><BR>  当源文件包含某一头文件时,预处理器会将头文件的内容在包含指令处展开。显然,在头文件中函数的定义会在其他源文件中一模一样的出现,导致函数被重复定义。解决这一问题的关键是明确一个概念:所有可执行的代码或者对象和函数的定义都应在. C 的源文件中,头文件中只能存在其声明。具体的做法是:为全局变量的声明增加extern 修饰符,并在相应的. C 源文件中定义对象或函数。<BR><BR>例如,在Globle. h 文件中仅声明变量GCounter 。<BR>/ * 在Globle. h 中 * /<BR>extern uint32_t GCounter ;<BR>&#8943;&#8943;<BR>而在. C 文件中定义变量GCounter :<BR>/ * 在GlobleVariables. C 中* /<BR>uint23_t GCounter ;<BR>&#8943;&#8943;<BR>这样,就可以在所有需要用到全局变量的地方直接引用”Globle. h”头文件了。不过也有一些程序员喜欢采取以下的做法:<BR>/ * 在Globle. h 中* /<BR># ifdef GLOBL ES<BR># define EXT<BR># else<BR># define EXT extern<BR># endif<BR>EXT uint32_t GCounter ;<BR>&#8943;&#8943;<BR>/ * 在GlobleVariables. C 中* /<BR># define GLOBL E<BR># include“Globle. h”<BR>&#8943;&#8943;<BR><BR><BR>/ * 在其他C 文件中* /<BR># include“Globle. h”<BR># include“MyLib. h”<BR>  &#8943;&#8943;<BR>这样做的好处是只需要维护“Globle. h”就可以维护所有全局变量。其他的文件中,直接包含“Globle. h”就可以使用这些全局变量了。上述的两种做法是等价的,读者<BR>可选择任意一种方式。<BR><BR>1. 2  用好编译器的检查功能<BR>在MISRA C 制定关于函数编程规则的背后,有一条很重要的思想:要充分利用编译器的类型检查功能来提高函数的可靠性。这里的类型检查包括在函数定义和调用时对函数参数和返回值的类型检查。<BR><BR><STRONG>规则8. 1 :函数必须声明原型,在函数定义和调用时原型必须可见。</STRONG><BR><BR>首先明确一下什么是原型声明。在科尼汉和里查( K&amp; R) 的著名著作《C 程序设计语言(第二版) 》的前言中提到:“C 不是一种强类型语言,但随着它的发展,其类型检<BR>查机制已得到了加强&#8943;&#8943;在这个方向上,新的函数声明方式是迈出的另外一步。”<BR><BR>这里“新的函数声明方式”指的就是原型声明。原型声明是标准C 语言中出现的概念,它可以提供更多关于函数参数的信息。在原型声明中,函数的参数要在声明时<BR>指定参数名和类型;而非原型声明,参数的类型可以缺省,被忽略的参数声明默认为int 型。<BR><BR>请看下面的声明:<BR>int f (int i , long j) { &#8943;&#8943;} (原型声明)<BR>int f (i ,j) int i ; { &#8943;&#8943;} (非原型声明)<BR><BR>要求程序使用原型声明函数,主要是希望可以利用编译器检查函数调用时数据类型的一致性。如果调用函数时,没有进行原型声明,则编译器不会检查出函数形式参数与调用参数的不一致。请看下面这段程序:<BR>double square (x)<BR>double x ;<BR>{<BR>&#8943;&#8943;<BR>}<BR>调用时:<BR>long func (i)<BR>long i ;<BR>{  return square (i) ;<BR>}<BR><BR>函数square () 的形式参数类型是double 型,但实际调用时,调用参数是long 类型。因为没有进行原型声明,编译器不需要对此给出警告,结果在没有出错信息的情况下,函数返回了一个不正确的值。<BR><BR>现在将这段程序改写为原型声明:<BR>double square (double x)<BR>{  &#8943;<BR>}<BR>调用时:<BR>long func (i)<BR>long i ;<BR>{  return square (i) ;<BR>}<BR><BR>这里,编译器会检查出函数square 的实际调用参数和形式参数类型不符,并且会将实际参数转换成相应的形式参数的数据类型。这样,参数i 就在程序员不知情的情<BR>况下被编译器自动转换为double 类型,函数返回正确值。<BR><BR>了解了原型调用的一些机制,下面的问题就是如何操作才能保证每个函数调用都使用原型调用,也就是说,要使原型声明对于函数定义和调用都“可见”。简单的方法是:每一个外部函数都在头文件中有一个唯一的原型声明;需要调用此外部函数的源文件要包含这一头文件,保证调用时由原型控制(原型对于“调用”可见) ;同时,函数定义所在的源文件也包含这一头文件,以便编译器可以检查原型声明和其定义相匹配(原型对于“定义”可见) 。<BR><BR>使用原型调用除了可以帮助检查参数的一致性,还可以使“编译器产生更为有效的函数调用序列”。<BR>为了配合编译器对函数参数的检查,程序员应牢记规则16. 1 。<BR><STRONG><BR>规则16. 1 :不允许定义参数数量不确定的函数。</STRONG><BR><BR>标准库函数printf ( ) 深受许多程序员的喜爱,因为printf () 允许不确定的参数数量,用起来很方便。但是,参数数量的不确定很可能会造成编译器无法检查函数调用时的参数一致性。对于像printf () 这种使用广泛的标准库函数,编译器提供了一些合适的调用机制。但程序员必须明确,编译器无法保证对用户自行定义的参数数量不确定的函数进行数据类型检查。因此,MISRA2C 不允许用户冒险去定义新的参数数量不确定的函数。<BR><BR>
<H2>2  函数的调用和标准库函数</H2>程序员应该清楚,嵌入式应用开发中系统的资源往往十分有限,在程序开发上会有特殊的限制。比如,在RAM空间的使用上往往会捉襟见肘。像早期的PC 机程序员一样,对RAM 空间的使用可以用“吝惜”来形容,往往因可以使程序少占用几个字节的RAM 而大做文章。<BR><BR>一个典型的例子就是递归函数的调用。递归函数的代码紧凑,且容易理解,很受C 程序员的推崇。但递归函数的一个缺点就是:占用RAM(这里主要是指的栈空间)<BR><BR>资源太多。对于嵌入式系统来说这是尤为严重的问题。一旦递归调用的层数过多,就会出现栈空间不足的情况。唯一可以避免该情况发生的方法就是能够预先估计出最大的递归调用层数,从而算出最大栈空间。遗憾的是,很多情况下程序员根本没法做出估计,这时系统中的递归函数成为一个巨大的隐患。MISRA C 从系统安全角度考虑,选择了最为安全的做法,不准使用递归调用。<BR><BR>  <STRONG>规则16. 2 :函数不得调用本身,无论是直接的调用,还是间接的调用。</STRONG><BR><BR>  一般来说,标准库函数是很好用的。它的定义和使用都很清晰,尤其是像printf ( ) 这样的函数,对于程序员的调试工作帮助很大。但某些库函数的使用也可能会造成问题。要尽量安全地使用库函数,需要注意三个方面的问题。<BR><BR>①要保证库函数头文件中的宏、标识符和函数的定义不受干扰。<BR><BR>  <STRONG>规则20. 1 :不得定义、重新定义或是取消定义标准函数库中的标识符、宏和函数。</STRONG><BR><BR>②要按照正确的方法使用库函数。库函数对参数的类型、数值都有很明确的要求,只有传递给库函数正确的参数,才能保证结果的正确性。<BR>  <STRONG>规则20. 3 :必须检查传递给库函数的数值的有效性。</STRONG><BR><BR>③避免使用可能有问题的库函数或者其结果。比如很多库函数都会通过一个叫做errno 的变量为非零值来表示执行失败。但是,由于没有强制库函数在执行成功后将errno 清零,一个非零的errno 有可能是因为当前库函数执行失败了,也有可能是因为之前某个库函数没有正确执行。因此,完全依赖errno 来判断库函数的执行成功与否是不可靠的。<BR><BR>  <STRONG>规则20. 5 :不得使用错误指示符errno 。</STRONG><BR>可能带来问题的库函数还有很多,MISRA C 为此做了一份总结。<BR><BR>  <STRONG>规则20. 4 :不得使用动态堆空间分配。</STRONG><BR><BR>  <STRONG>规则20. 6 :不得使用库函数&lt; stddef . h &gt; 中的宏offsetof</STRONG><BR><BR>  <STRONG>规则20. 7 :不得使用longjmp 函数中的宏setjmp</STRONG><BR><BR>  <STRONG>规则20. 8 :不得使用信号处理函数&lt; signal . h &gt;</STRONG><BR><BR>  <STRONG>规则20. 9 :不得用输入/ 输出库函数&lt; stdio. h &gt; 来产生代码</STRONG><BR><BR>  <STRONG>规则20. 10 :不得使用标准库&lt; stdlib. h &gt; 中的库函数atof 、atoi 和atol</STRONG><BR><BR>  <STRONG>规则20. 11 :不得使用标准库&lt; stdlib. h &gt; 中的库函数abort 、exit 和system</STRONG><BR><BR>  <STRONG>规则20. 12 :不得使用标准库&lt; time. h &gt; 中的时间处理函数</STRONG><BR><BR>
<H2>3  预处理———看似简单的第一步</H2>预处理是编译器处理程序的第一步。预处理会在编译器编译程序代码前做一些准备工作,最为常见的工作是处理文件包和宏定义(分别对应# include 和# define 两个<BR>预处理指令) 。<BR>预处理器并不对源代码做编译,只是进行一些转换工作,例如将文件或宏展开等。很多程序员认为这种类似复制、粘贴的活没什么了不起,也就放松了对预处理工作的检<BR>查。其实,很多程序的失败就从这看似简单的第一步开始。<BR>宏定义是最常见的预处理指令之一。MISRA C 关于宏定义有一些很有代表性的规则。<BR><BR>
<H4>3. 1  小括号———一个也不能少</H4>当程序中多处出现同一个数值的时候,程序员就会想起使用宏。宏定义最大的好处就是使一些常量集中起来,修改其值只需要修改一次,大大提高了程序的可维护性。<BR>编译器对宏的处理原则比较简单,宏定义只对程序文本起作用。一个经典的例子是:<BR># define abs (x) (x &gt; = 0) x : - x<BR><BR>显然,程序员希望求变量x 的绝对值。但是,下面的调用会是什么结果呢?<BR>abs (a - b) ;<BR>展开后为(a - b &gt; = 0) a - b : - a - b 。显然这里的- a - b<BR>并不是程序员想要的结果,原因是变元x 两边没有加小括号。那么在x 加上括号以后呢?<BR># define abs (x) ( (x) &gt; = 0) (x) : - (x)<BR>如果此时是<BR>abs (a) + 1 ;<BR>展开后是(a &gt; = 0) a : - a + 1 ,也不是我们想要的结果。<BR>看来还要把整个宏定义加上括号,这样才能得到安全可靠的宏定义:<BR># define abs (x) ( ( (x) &gt; = 0) (x) : - (x) )<BR>为了防止宏展开后因缺少括号而存在的优先级错误问题,MISRA C 有如下规定。<BR><BR>  <STRONG>规则19. 10 :在函数式宏定义中,任何一个参数都应加上小括号,除非是在# 或# # 运算符中。</STRONG><BR><BR>
<H4>3. 2  宏定义不是函数</H4><STRONG>规则19. 7 (推荐) :应优先考虑使用函数而非函数式宏定义。</STRONG><BR><BR>利用类似函数式的宏定义来取代函数调用,是一个常用的技巧。这样做有很多好处,主要是能够提高程序的运行速度。MISRA C 从代码安全的角度制定这一规则,主要有两点考虑。一是宏定义不能像函数调用那样提供参数类型检查,错误的变元类型无法得到纠正,运行的结果就可能不正确。二是宏定义中的变元可能会多次求值,当变元表达式带有副作用时,就会出现问题。例如:<BR><BR># define SQUARE(x) ( (x) 3 (x) )<BR>当有如下语句时:<BR>a = 3 ;<BR>b = SQUARE(a + + ) ;<BR><BR>程序员肯定希望得到b = 9 和a = 4 的结果,可实际上的结果却是b = 12 和a = 5 ,这是为什么呢?<BR>如果考虑到宏展开只是做文本的展开,那么上面的预处理结果应该是:<BR>a = 3 ;<BR>b = (a + + ) 3 (a + + ) ;<BR>很明显,这里a + + 运行了两次,运行后a = 5 。至于b ,其结果应该是b = 3 ×4 = 12 。<BR>现在,读者应该可以看出来类似函数的宏展开并不完全和函数一样。考虑到系统可靠性是我们所关注的,上面的工作还不如直接用函数来完成。多数情况下,函数的运<BR>行速度应该让位于其结果的正确性。<BR>关于宏定义还有很多有趣的问题可以讨论,这里就不一一赘述了。<BR><BR>