我们知道,铁匠经常把金属工件加热到一定温度,然后突然浸在水或油中使其冷却,从而有效增加其硬度。类似的,软件开发人员也可以采取某种手段,从而显著提高软件的可靠性和质量,我们形象地称之为代码淬火技术。
一、代码淬火的好处
理想中,软件淬火技术首先能够预见代码中的哪些地方可能出错,然后,要么带给我们一种能够完全避免错误的方式编写代码,要么能及时识别出错误,从而更轻松地跟踪源代码。尤其是对于C语言,因为它不是一种安全语言,所以更需要软件淬火技术来提高软件的可靠性。本文将详细介绍Linux下常用的代码淬火方法。
二、常见的码淬火技术
代码淬火技术的形式各种各样,我们这里要讲的是帮助我们构建更加强健的代码的各种技术。
1 .缓冲区溢出问题
缓冲区是一个非常严重的安全问题,最好的情况下可能导致软件的行为错乱;严重时将会导致被缓冲区利用程序所控制而执行任何攻击者所想要执行的代码。请看下面的示例代码:
static char ourArray[100];
...
int i;
for ( i = 0 ; i < 100 ; i++ ) {
ourArray[i] = (char)(0x30+i);
}
ourArray[i] = 0; // 越界,危险!
上例中,我们的赋值操作已经越过了数组的边界。它并没有在我们的数组内执行写操作,它是在数组边界之后的第一个字节内进行的写操作。换句话说,数组之后的第一个对象,无论它是结构体也好,还是指针也罢,现在已经被破坏了。为了改变这种糟糕的情况,我们只需要养成一种良好的习惯,那就是使用符号常量来指示数组的边界。就像在下例中一样,我们创建了一个常量,来定义数组的长度。这里有一个小技巧,那就是假设我们需要的数组长度为A,那么在定义数组长度时,实际长度是A+1。这时,无论数组长度如何,可以肯定的是,数组[A]一定是该数组的边界。换句话说,我们留了一个元素的余量。如下所示:
#define ARRAY_SIZE 100
static char ourArray[ARRAY_SIZE+1];
...
int i;
for (i = 0 ; i < ARRAY_SIZE ; i++ ) {
ourArray[i] = (char)(0x30+i);
}
ourArray[ARRAY_SIZE] = 0; //为数组的最后一个元素赋值
2.检验返回值
现在,软件中最常见的错误是忽略了返回值的检验。许多程序员在调用系统函数或用户自定义的函数后,通常会盲目乐观地认为这些函数会成功的执行。当我们打造经淬火处理的软件时,应当检查返回值,并且在返回失败时,还要进行妥善处理。比如:
ret = printf( "Current value is %d\n", value );
if ( ret < 0 ) {
ret = printf( "An error occured emitting value.\n" );
}
3.检查输入/输出数据
当我们开发应用时,无论涉及到用户的输入,或者是通过网络进行输入,一定要严密关切输入数据的情况:比如对于给定的操作数据是否充分,或者收到的数据是否超过了预留的缓冲区空间。
4.为分支语句提供备选方案
对于switch语句来说,常常会遗漏default部分,这会导致无法预料的后果。举例来说:
switch( mode ) {
case OPERATIONAL_MODE:
/* 切换到运行模式进行处理 */
break;
case BUILT_IN_TEST_MODE:
/* 切换到测试模式进行处理*/
break;
}
假设现在我们又添加了一种模式,但是上面的代码段没有及时得到更新,这时如果执行了这段代码,执行结果将无法预料。如果包括default部分,至少也能利用该部分在出现问题时通知调用者,即使在此放一个assert,也能在调试时捕获当时的状况。如下例所示:
switch( mode ) {
case OPERATIONAL_MODE:
/*切换到运行模式进行处理 */
break;
case BUILT_IN_TEST_MODE:
/* 切换到测试模式进行处理 */
break;
default:
assert(0);
break;
}
上述问题除了存在于switch语句外,还存在于if/then/else分支语句中。下面分别举例说明:
float coefficient = 0.0;
if (state == FIRST_STAGE) coefficient = 0.75;
else if (state == SECOND_STAGE) coefficient = 1.25;
作者的意图很明显,它是希望根据变量state的值的不同,也让变量coefficient取不同的值。但是如果变量state的值被破坏(比如遇到上面提到的缓冲区溢出的破坏),或者使它取了一个预期之外的值,那么变量coefficient的值就会一直是0.0。如果这是用来计算付费的话,后果的严重可想而知。因此,else分支语句至少应该包含一个分支用来捕获错误,例如:
float coefficient = 0.0;
if (state == FIRST_STAGE) coefficient = 0.75;
else if (state == SECOND_STAGE) coefficient = 1.25;
else coefficient = SAFE_ COEFFICIENT;
尽管许多情况下尾随的else分支不是必须的,但这样做无疑为我们的代码加了一把保护伞,以免铸成大错。
4.自标识结构体
在强类型语言中,如果使用非法的数据类型将会导致运行时错误。但对于弱类型语言,比如C语言,在指针的传递和类型转换过程中,很容易把类型混淆。为此,我们引入类似于强类型语言中的运行时类型检验思想。
现在我们通过实例来讲解自标识结构体,具体代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#define TARGET_MARKER_SIG 0xFAF32000
typedef strUCt {
unsigned int signature;
unsigned int targetType;
double x, y, z;
} targetMarker_t;
#define INIT_TARGET_MARKER(ptr) \
((( targetMarker_t *)ptr)->signature = TARGET_MARKER_SIG)
#define CHECK_TARGET_MARKER(ptr) \
assert(((targetMarker_t *)ptr)->signature == \
TARGET_MARKER_SIG)
void displayTarget( targetMarker_t *target )
{
/* 预先检查 target结构体 */
CHECK_TARGET_MARKER(target);
printf( "Target type is %d\n", target->targetType );
return;
}
int main()
{
void *object1, *object2;
/* 新建两个对象 */
object1 = (void *)malloc( sizeof(targetMarker_t) );
assert(object1);
object2 = (void *)malloc( sizeof(targetMarker_t) );
assert(object2);
/*按照target marker结构体初始化object1 */
INIT_TARGET_MARKER(object1);
/* 尝试显示object1 */
displayTarget( (targetMarker_t *)object1 );
/* 尝试显示object2 */
displayTarget( (targetMarker_t *)object2 );
return 0;
}
在代码的第6-12行,定义了我们的目标结构体,其中有一个专门的头部,名为signature(识别标志),即该结构类型的运行时类型标识符。并且在第4行为该类型定义了一个识别标志,作为该结构类型的唯一描述符号。此外,代码中还提供了两个宏INIT_TARGET_MARKER和CHECK_TARGET_MARKER,分别用来初始化和检验该结构体中的识别标志。
继续往后看,请注意代码的34-54行,其中分配了两个等同长度(targetMarker_t)的内存空间来供两个对象使用,然后利用宏INIT_TARGET_MARKER将其中一个初始化,最后,分别利用displayTarget函数显示。
对于第22-31行的displayTarget函数,首先调用CHECK_TARGET_MARKER来检验收到的对象的识别标志,如果该标志非法,assert就会发挥作用。当然,这里只是一个概念性的演示。
5.错误报告
对于错误报告而言,根据开发的应用类型的不同,需要采取不同的处理方法。例如,如果开发的是命令行工具的话,常用的方法是通过把错误消息递给stderr来告知用用户出错情况。如果开发的是诸如嵌入式Linux应用之类的具有I/O能力的应用程序的话,错误报告的形式就多了,比如专门的日志或标准系统日志(syslog)。其中,syslog函数的原型如下所示:
#include <syslog.h>
void syslog( int priority, char *format, ... );
对于syslog函数,我们需要提供优先级、格式化字符串等参数。它的用法类似于printf函数。优先级可以从这里选择一个:LOG_EMERG、 LOG_ALERT、LOG_CRIT、LOG_ERR、LOG_WARNING、LOG_NOTICE、LOG_INFO或者 LOG_DEBUG。 下面以实例说明如何使用syslog函数为系统日志生成一个消息:
#include <syslog.h>
int main()
{
syslog( LOG_ERR, "Unable to load configuration!" );
return 0;
}
程序执行后,位于 /var/log/_messages的系统日志更新如下:
Aug 16 21:33:16 Ian test: Unable to load configuration!
本例中,test是上面列出的程序名,Ian是主机名。系统日志的好处是可以记录许多的错误报告。凭借它,开发人员能够了解消息在哪里生成的、哪一个能够帮助理解问题等等。对于系统应用和守护进程来说,syslog的格外有用。
除此之外,错误报告还有一个注意事项,那就是报告的错误一定要具体。为了能够让用户恰当的处理发生的错误,错误消息一定要唯一的标识出错误来。错误消息本身不能模棱两可,或者具有歧义性。
6.降低代码的复杂性
降低复杂性就等于降低出错的机率,因为代码越复杂,包含bug的可能性越大,并且找到bug的难度也越大。为此,我们可以将一个复杂的代码段,分解成多个更容易理解的几段代码。这样一来,随着代码的可维护性的提高,软件质量自然有很大提升。
7.自保护性函数
使用自保护性函数是一种有效的调试机制,它能保证软件的正确性。这里的自保护意味着,当你写一个函数的时候,必需审核该函数的输入;并且在输入数据处理成后,还要审核数据的输出,从而确保没有出错。下面我们用一个实例函数加以说明:
STATUS_T checkRegisterStatus( REGISTER_T register, MODE_T *mode )
{
REGISTER_STS_T retStatus;
/* 验证输入 */
assert( validRegister( register ) );
assert( validMode( mode ) );
/*--------------------*/
/*这里省略checkRegisterStatus 的内部处理部分 */
/*--------------------*/
/* 可能改变了模式,检验之 */
assert( validMode( mode ) );
return retStatus;
}
注意,如果表达是结果为非(即0),assert函数就会停止应用,并在标准输出设备上生成一个错误消息。可以通过定义符号NDEBUG的方法来停用assert。
从上例中我们看到,函数首先通过验证输入来确保得到的数据是正确的,然后通过验证输出来保证它给出的数据也是正确的。根据具体情况,我们可能收到错误消息,即使这样我们也能轻而易举地发现错误之所在。另外,assert的作用不仅限于确保函数的输入输出的正确性,还能用来确保内部的一致性。所有应该在调试期间发现的严重错误,都可以利用assert来轻松处理。
8.优化调试输出
太多的输出能够掩盖错误,但输出过少也会漏掉错误,因此我们需要寻找一个平衡点,使得提供的调试输出和错误消息够用,但又不会过量,这需要在实践中具体把握。
9.内存调试技术
在Linux中,有许多程序库都可以用来调试动态内存管理。最常见的 Electric Fence是一个功能强大并且能及时发现内存错误的库,它不仅可以利用低层处理器的内存管理单元MMU的段故障来捕获内存错误,而且还能侦察数组越界问题。
10.编译器的支持
实际上,编译器本身就是一个识别代码问题的无价之宝,当构建程序时,一定要使用-Wall项来启用报警功能。
此外,还可以利用-Werror把警告作为错误对待,从而停止对源文件进行进一步的编译,这选项对于大型应用程序格外有用。当我们构建有多个源文件组成的应用程序时,我们可以将两个选项组合使用。如下例所示:
gcc -Wall -Werror test.c -o test
如果我们想让源代码能够兼容ANSI,我们还能利用编译器来进行检查,用法如下所示:
gcc -ansi -pedantic test.c -o test
确认变量已经初始化是非常有用的,除了使用报警选项外,还需要使用优化选项,因为只有经过优化的代码才能使用数据流信息:
gcc -Wall -O -Wuninitialized test.c -o test
要想了解更多,可以参考gcc的main页。
三、 小结
本文详细介绍了代码淬火技术的概念以及Linux下常用的几种代码淬火方法。在下一篇文章中,我们将继续介绍用来提高Linux应用程序安全性和可靠性的开源工具和代码追踪技术。