一、字符串在百度PS的地位
字符串在百度PS的地位自然不必多说,如果你有代码权限的话,在检索端任意模块的源码中grep一下strcpy就知道了。从用户输入的一个query到返回给用户的整个页面都是用字符串来组织的,怎样将用户输入的字符串经过缜密的分析、最终决定返回给用户哪些结果,并持续提升这两者的相关性是我们不断追求并永远追求的目标。这个过程中充斥着对各种字符串倒来倒去的无休止的复杂操作。详细了解字符串相关技术并思考如何最大程度、最小成本的保证程序中对字符串操作的正确性就显得尤为重要了,本文就与您共同探索一下字符串的奥秘。
二、字符串的基本概念
先介绍大家都知道的字符串定义:由零个或多个字符顺序排列组成的有限序列。它是一种特殊的线性表,其特殊性主要体现在组成表的每个元素均为一个字符,以及与此相应的一些特殊操作。
这个简单定义中值得我们关注的地方有:
1、 字符串可能由0个字符组成,也就是空串,千万不要小瞧空串,很多程序bug就是在没有考虑空串这个特殊情况时产生的。
2、 字符串是有限序列,一般的说,有两种类型的字符串数据类型:“定长字符串”,和“变长字符串”。在现代高级编程语言中大多支持变长字符串,例如c++中的StringBuffer类。所有变长字符串还是在长度上有个极限,一般的说这个极限只依赖于可获得的内存的数量。
3、 定义中并没有限定字符串的表示法和组织形式,虽然通常c编程语言中都是以NULL(ASCII码是0)为结束符,而组织形式通常是以字符数组的形式。但是并不排除其他的组织形式存在,例如,c++和java中的string类型就是将基本字符串包装成了类的高级应用;而Pascal语言的组织更是诡异:以一个整数值开头来表示整个字符串的长度,而没有任何结束符标志,大家坐公交车时也可以想一想这种组织方法有哪些弊端,反正我只知道Pascal是输掉了与c的争宠,?。当然,可能还有其它的表示法,使用树和列表可以使得一些字符串操作(如插入和删除)更高效,这里就不多加讨论了,后面讨论的都是c风格的字符串,即以’\0’结尾的字符串,这里大家不要认为只有一种字符串的组织形式就好了。
4、 定义中提到每个元素均为一个字符,这里的一个字符是广义的字符概念,并不是我们日常理解的1个byte,这个字符是编码字符,常见的就是单字节表示的ascII编码字符,但是有些意音文字的语言比如汉语、日语和朝鲜语(合称为CJK)的合理表示需要远远多于256个字符(每字符一个字节编码的极限),由此产生的编码方式真是五花八门,足够写一本史记,从GBK、BIG-5到UTF-8、再到强大的Unicode,百家争鸣的繁华背后全是字符编码测试人员的眼泪,Unicode有望一统天下,但也是任重而道远……
5、 各种针对字符串操作的库可能随着c语言的出现就存在了,古老的要死,虽然我们一直在使用它们,但是对其中某些函数的了解还不够深入,基于效率考虑,函数的设计者将某些责任推给了程序员,我们仍然非常有必要知道这些规范。
检查点 | 测试方法 |
注意空串 | [代码检查] |
是否在栈上定义了超大字符数组 | [代码检查+valgrind] |
三、字符串与数组的暧昧关系
1、共同点——char型数组
因为字符串本质与数组并没有什么实质区别,字符串完全可以看做是char型数组加结束符,因此可以像操作数组那样自由操作字符串,可以充分发挥c语言指针的强大作用,在一个字符串上做你想要的任何操作,比如:可以使用&(取地址)运算符来找到字符串中某个字符的内存地址;也可以通过对指向某个字符串的指针使用*(取内容)运算符来得到实际的字符;再取地址、地址++、再取值、值++、等等,有点像那些变态的面试题哈?。
2、不同点——结束符
结束符是字符串和数组最大的不同,一些输入输出函数就是通过判断结束符来运行的,比如在执行printf("%s",str);函数时,每输出一个字符检查一次,看下一个字符是否'\0'。遇'\0'就停止输出,没有’\0’当然就停不下来了,结束符的重要性立显。
四、字符串的初始化
1、字符串常量和字符数组
说起字符串的初始化就不得不说字符串常量和字符数组。字符数组是元素类型为字符的数组,它既具有普通数组的一般性质,又具有某些特殊性质。字符串常量是用双引号包围的字符序列。存储字符串常量时,系统会在字符序列后自动加上'\0',标志字符串的结束。字符串变量是以'\0'作为结束标志的字符数组,这个特点也是字符数组不同于一般数组的最大特点。字符数组有两种用法:一是当作字符的数组来使用。这时的用法与整数的数组、实数的数组等相同,对字符数组的输入、输出、赋值、引用等都是针对单个的元素进行。二是更为重要的用法即存储、处理字符串。这时它除了可以像普通数组一样使用外,还可以把字符串作为一个整体进行操作。
对字符数组的初始化有两种方式。一种是用字符常量进行初始化,另一种是用字符串常量进行初始化。用单引号字符常量放在大括号中初始化字符数组需要一个一个的指定,够笨、工作量也比较大,?;用字符串常量初始化一个字符数组时如果字符串常量长度小于数组长度,则只将这些字符赋绐数组中前面的元素,其余元素自动定为空字符(即'\0'),够方便,但是要注意系统会在字符数组的末尾自动加上一个字符'\0'。因此,要考虑数组的长度比实际字符的个数大1,比如:
char str[5]="HELLO";
在编译时是会失败的,并无大碍,但是有时程序中暂时不能确定字符数组的初始值,需要先定义数组空间,再进行赋值,代码如下:
char str[5];
strcpy(str,"hello");
这次编译器就形同虚设了,strcpy函数也假定程序员知道源字符串的长度而不做越界检查,结果导致str后面的内存被写越界,会引发大问题并很难追查。
检查点 | 测试方法 |
直接赋值初始化越界 | [编译器+pclint] char str[5]="123456";会有报警“Warning 540: Excessive size” |
strcpy方式初始化越界 | [pclint] 对于使用strcpy方式的初始化越界pclint扫描输出 “Warning 419: Apparent data overrun for function ”很清晰的指出越界原因 |
2、是否有初始值
字符串初始化另一个需要特殊关注的点是不同存储区有不同的初始值,其实这个不是字符串数组特有的,全局变量和静态变量,不管是数组还是简单变量还是复合变量,都默认全部置0。局部变量和动态分配的变量,不管是什么变量都是随机数,小时候初识vc6.0的人还记得变量查看窗口中显示的字符数组“烫烫烫烫烫烫烫。。。”吧,随机数0xcc所致,告诉你要注意我啦,我还没有初始化,很烫!!!?
检查点 测试方法
是否有默认初始值 [valgrind]
全局变量和静态变量有默认初始值,而堆栈上的局部变量和动态变量未置初始值由valgrind扫描可以发现,在使用时报未初始化错误。
3、没有什么是不可改变的
另外字符串常量会存储在程序的常量存储区,属于只读数据段,内容不允许修改(当然,原则上不可以修改,但是理论上没有什么是不可改变的,通过绕过编译器检查的机制来改变),如下代码:
char* str = "abc";
(*str)++;
将会在vc debug版运行时报非法访问错误,linux环境下运行会出core,但是在vc Release下是可以更改的,不信您试一试,同样,怎样在gnu c编译器下修改只读区我也不会,期待高手指点。有人说,将str定义成const char*就万事大吉,编译时会报错,不错的想法,但别忘了c/c++是强类型语言,可以通过最流氓的方式——强制类型转换方式来“逼良为娼”:
const char* str = "abc";
char *p = (char *)str;
(*p) ++;
编译会过,但是还是会出core。定义成const就告诉编译器这个东东我不想让别人修改,但是挡不住就是想修改的人(执意要改的人也并非都是坏人)。但是话说回来,如果想避免无意的错误修改,那就将能定义成const的都定义成const好了,益处多多!
检查点 测试方法
修改只读存储区 [功能测试+gdb]
只要保证功能测试走到这部分代码就会出core,正常功能测试走不到的异常分支可以通过gdb使异常分支被覆盖。
4、编译器的“智能”分配
对于字符串常量的初始化,再多说两句,请看下面代码:
char* str1 = "abc";
char* str2 = "abc";
cout << ( str1==str2 ) << endl; // 输出1
编译器对相同的字符串常量在分配内存空间时放在了一起,有任何不同都不会放在一个地址空间中,这样做带来的好处是节省了内存,但是像上面说的,只读区存储的字符串常量也是可以改变的,当改变了str1时也会改变str2的值,这个就需要特别注意了。
五、字符串的输入输出
1、字符串的输入
scanf函数
用带 %s格式符的scanf函数可以进行字符串的输入。在使用中要注意两个问题:一是scanf函数读入的字符串开始于第一个非空白符,包括下一个空白符('\n','\t',' ')之前的所有字符,最后自动加上’\0’。
例: char str[10];
scanf("%s",str);
输入:” hello world”
实际存入str的只有"hello",前面的空格被忽略掉,而"world"被留在输入缓冲区中等待下一次输入函数的读入。
二是要保证字符数组的长度足够大,能容纳下可能的最大输入串。
例: char t[10],s[5];
scanf("%s",s);
printf("%s %s",t,s);
输入:ddddddddddddddddddddddddddddddd
则不仅存入了s的空间,还侵占了t的空间,如果输入再长一些的话就会出core。
gets函数
gets函数专门用于输入字符串,一般形式是:
gets(字符数组名);
其中, 函数参数"字符数组名" 规定了只能使用数组名而不能是字符串常量。与scanf函数不同,gets函数将用户键入的换行符之前的所有字符(包括'\t'和' ')存入字符数组,然后加上'\0',但换行符被丢掉。与scanf函数相同的是gets 函数也不检查用户输入字符串长度是否超过了字符数组的容纳能力,因此编程者要确保数组足够大。
2、字符串输出
printf函数
用带%s格式字符的printf函数能进行字符串的输出。存放在字符数组中的字符串被全部输出,直至遇到'\0'为止。
puts函数
puts函数专门用于字符串输出。一般形式是:
puts(字符串);
其中,参数"字符串" 可以是字符串常量, 也可以是字符串变量。puts函数打印字符串的全部内容,直至遇到'\0'为止,然后自动多打印一个'\n',起到换行的作用。而printf函数无此功能。
到底应该使用scanf和printf还是使用gets和puts,没有一个绝对的标准。一般而言,当多种类型的混合输入输出时,选用scanf和printf;当大量文字信息输入输出时,使用gets和puts,这两个函数要稍稍快一些。
检查点 测试方法
是否使用危险输入输出函数 [代码检查+valgrind]
上述函数都属于危险函数,尽量都不要使用,很容易发生缓冲区溢出,可以使用snprintf、fgets等有长度限制的输入输出函数,同时注意判断sscanf的返回值是否合法。对于缓冲区溢出的情况valgrind也可以检查出来。
六、基本字符串处理函数介绍
字符测试函数想必大家都使用过,如isalpha(测试字符是否为英文字母)、islower(测试字符是否为小写字母)、isdigit(测试字符是否为阿拉伯数字)等等,它们会省去我们自己来判断某个字符的类型,不必担心他们的效率,这些字符测试函数实际都是宏而非函数调用,放心使用就好了。
介绍最有用又最常见的四个字符串处理函数:strlen、strcat、strcmp、strcpy。这些函数的原型存放在string.h文件中,在程序中使用它们时别忘了用#include命令包含string.h文件。
strlen函数
strlen函数测试字符串的实际长度(不包括'\0'),并将该长度作为函数的值返回。函数参数“字符串”可以是字符串常量,也可以是字符变量,一般形式是:
length=strlen(字符串)
例:"ABC" 长度为3。
"" 长度为0,空字符串没有有效字符,所以长度为0。
strcat函数
strcat函数用于连结两个字符串。一般形式是:
strcat(字符串1,字符串2);
strcat函数把字符串2连结在字符串1的后面。其中,参数“字符串1"必须是字符串变量,而"字符串2"则可以是字符串常量或变量。
调用strcat函数后,str1中字符后的'\0'取消,只在新串最后保留一个'\0'。
注意:strcat函数不检查字符串1的空白位置是否装得下字符串2。如果没有足够的空间,多余的字符将溢出至邻近的内存单元,破坏这些单元原来的内容。所以连结前应调用strlen函数进行检验,确保不发生溢出。记住在检验时给长度加1,为新字符串的结束符'\0'留一个位置。
strcmp函数
strcmp函数是比较两个字符串的大小,返回比较的结果。一般形式是:
rtn=strcmp(s1,s2);
其中,s1、s2均可为字符串常量或变量;rtn是用于存放比较结果的整型变量。比较结果是这样规定的:
①s1小于s2,strcmp函数返回一个负值;
②s1等于s2,strcmp函数返回零;
③s1大于s2,strcmp函数返回一个正值;
字符串大小的比较是以ASCII 码表上的顺序来决定,此顺序亦为字符的值。strcmp()首先将s1第一个字符值减去s2第一个字符值,若差值为0则再继续比较下个字符,若差值不为0则将差值返回。
strcpy函数
strcpy函数用于实现两个字符串的拷贝。一般形式是:
strcpy(dest, source)
其中,dest必须是字符串变量,而不能是字符串常量。strcpy函数把source的内容完全复制到dest所指的内存空间中,而不管dest中原先存放的是什么,如果dest空间不够,则会引起 buffer overflow,复制后,source保持不变。
注意,由于字符串是数组类型,所以两个字符串复制不通过赋值运算进行。
t=s; /*错误的字符串复制*/
strcpy(t,s); /*正确的字符串复制*/
对于strcpy函数的使用要尤其注意,强调一下:
1)、要正确使用,确保dest所在的内存空间大于等于source所在的内存空间
2)、复制的结束是以source指针遇到'\0’为依据的,没有遇到'\0’,就会一直复制下去。
3)、要确保dest有实际的内存也是很重要的,而这往往被忽略,dest为NULL,或未初始化,都是会产生运行时错误的。
同时附上strcpy的函数原型,世界上最精简的函数(与bs中一个函数2000行相比小巫见大巫了哈):
char * strcpy(char *dest, char *source){
char *temp = dest;
while((*dest++ = *source++) != '\0');
return temp;
}
除此之外,还有很多关于字符串的库函数,比如字符串转换以及更复杂的字符串操作,请参见http://man.chinaunix.net/develop/c&c++/linux_c/default.htm,可能您已经在使用了。
另外string类给我们封装了更加丰富的方法,仅查找函数就由数十种之多,什么插入、删除、替换、比较、连接、赋值等等,琳琅满目的,甚至在字符串超过了当前分配的空间长度string类还会自动分配空间,非常方便,各种用法上网随处都能找到。
七、常见错误分析
除了上面介绍的技术原理和错误之外,还有一些常见错误,如下:
1、sizeof和strlen
如果您的代码中充分考虑了程序未来的可维护性和扩展性,那么就对下面代码中的sizeof和strlen不会陌生,请看下面的代码:
while(fgets(Buffer, sizeof(Buffer), fp) != NULL)
{
if (Buffer[0] != '\0' && Buffer[strlen(Buffer)-1] != '\n')
{
ul_writelog(UL_LOG_WARNING, "line is too long", file);
}
}
是否对sizeof和strlen的使用非常清楚呢?这段代码的意思是从fp文件中读取最大Buffer缓冲区长度,然后判断读取到的字符串是否非空以及末尾是否以换行结束,非空并且没有换行符则说明行超长,打印日志。首先,Sizeof是运算符,strlen是库函数;再者,Sizeof计算的是结构体空间大小,而strlen只判断结尾符之前的字符个数(不包括结尾符),两者有根本的区别,而且大多数情况下计算出来的值并不相等。
一个容易犯错的例子如下:需求时发送一个字符串给对端,但是不发送最后的\0字符,代码如下:
char str[] = "hello";
if ((len = send(socket_descriptor,str,sizeof(str),0)) == -1)
{
perror("Error in send\n");
exit(1);
}
发送socket数据时多发送了一个字节,sizeof(str)为6,而实际要发送的应该是5,错用了sizeof,如果需要按实际长度发送,应使用strlen(str)取字符串的长度。
另外,sizeof的对象也要格外注意,例如:
char str[][6] = {"hello","world","haha"};
for(int i=0;i<3;i++)str[i][sizeof(str)-1] = 0;
这又是一个内存写越界,sizeof(str)和sizeof(str[i])是完全不同的。
检查点 测试方法
区分strlen与sizeof [代码检查]
见到strlen想想是否该用sizeof,见到sizeof想想是否该用strlen,没有更好的办法。
sizeof对象是否正确 [pclint]
对于上例中的由于sizeof对象被放大造成内存写越界,pclint可以直接扫描出来:Warning 661: Possible access of out-of-bounds pointer,不战而屈人之兵
2、提防魔鬼数字
一些库函数,比如strncat、strncpy、strncasecmp等都提供指定字符串长度的参数,这些参数非常有用,可以指定最大参与操作的字符个数,可以有效避免目的地址越界问题,但是如果有如下代码:
strncpy(目的字符串,源字符串,13);
那么想必谁都很头疼“13”这个数字的由来,这个数字的学名也叫“魔鬼数字”,像魔鬼一样来无影、去无踪,这样的编码很可能本次升级没有任何问题,但是如果以后的某次升级修改了源字符串的有效长度很可能这个“13”就会出问题,而这种问题的追查也是破费心机的,因为要想定位这种改动代码对以往代码的影响的bug是比较麻烦的。所以最好的方法是用sizeof、strlen或宏来避免魔鬼数字吧,让我们在代码review时一起捉鬼!
检查点 测试方法
找出魔鬼数字 [代码检查]
建议用宏或其他形式,程序中几乎没有必须要写成魔鬼数字而不能用宏定义的地方
3、时刻想到结束符
既然字符串与数组的最大不同在于末尾的结束符,我们就没有理由忽视它的存在,没有结束符的字符串就像脱缰的野马,不知道哪里才是它的终点,strlen函数的计算值错误,printf函数也无法正常工作,怎么样,很可怕吧。
丢失结束符的情况是很多的,典型的是使用了某些库函数比如strncpy,这个函数一度被认为是避免strcpy内存越界的完美替代函数,包括当前asm模块代码大量使用strncpy,但是这个函数有很大的问题,函数声明是:
char *strncpy(char *s1, const char *s2, size_t n);
但 strncpy 其行为是很诡异的(不符合我们的通常习惯)。关于strncpy最大的误解就是:它会用'\0'来结束目的字符串。而实际上仅当源字符串的长度小于参数n时才正确,大于等于的情况会丢掉结束符。当拷贝源字符串的一部分时,正确的做法是使用strncpy之后,自己手工添加NUL来结束字符串。请看程序段:
char buf[8];
strncpy( buf, "123456789", sizeof(buf)); //执行后buf为”12345678”,无结尾’\0’
buf[sizeof(buf)-1]=’\0’; //这句话是很容易忘记的
另外,如果s2的内容比较少,而n又比较大的话,strncpy将会把之间的空间都用‘\0’填充。这又出现了一个效率上的问题,如下:
char buf[80];
strncpy( buf, "1234", 80 );
上面的 strncpy 会填写 80 个char,有效字符”1234”之后全部以’\0’填充,做了很多无用功。
关于strncat最大的误用就是错误地使用长度参数n。虽然strncat保证以NUL来结束字符串,但你不应该将NUL也计算在参数n内,下面的长度参数计算是否让你看了都头疼呢?
char url[100] = “http://www.baidu.com”;
strncat(url, "/",sizeof(url) - strlen(url) - 1);
strncat(url, "family",sizeof(url) - strlen(url) - 1);
len = strlen(url);
其实我们可以使用另外2个OpenBSD的替代函数,声明如下:
size_t strlcpy(char *dst, const char *src, size_t n);
size_t strlcat(char *dst, const char *src, size_t n);
strlcpy函数从NUL结尾的字符串src复制size-1个字符到dst,用NUL作为结果的结尾。它们总是保证以NUL结束字符串,它们都把目的字符数组的全部长度作为参数,而且它们返回的是程序员想得到的字符串的总长度。上面的函数用strlcpy和strlcat实现起来如下:
char url[100];
strlcpy(url, “http://www.baidu.com”,sizeof(url));
strlcat(url, "/",sizeof(url));
len = strlcat(url, "family",sizeof(url));
if ( len >= sizeof(url) )
printf("buffer overflow");
strlcpy 返回的是strlen(src),因此我们也很方便的通过返回值可以判断数据是否被截断。通过判断strlcat 的返回值可以知道缓冲区是否定义的太小以至于不足以放下2个字符串,怎么样,方便吧,损失的一点效率换来的是太平盛世!唯一的不足之处是这两个函数不是标准库函数,不是大多数类Unix系统缺省安装的,不过这并不是个难题:因为它们只是小函数,甚至可以在自己程序的源码里包含它们(至少作为一个选项),附上strlcpy和strlcat的源码如下:
size_t strlcpy (char *dst, const char *src, size_t dst_sz)
{
size_t n;
for (n = 0; n < dst_sz; n++) {
if ((*dst++ = *src++) == '\0')
break;
}
if (n < dst_sz)
return n;
if (n > 0)
*(dst - 1) = '\0';
return n + strlen (src);
}
size_t strlcat (char *dst, const char *src, size_t dst_sz)
{
size_t len = strlen(dst);
if (dst_sz < len)
/* the total size of dst is less than the string it contains;
this could be considered bad input, but we might as well
handle it */
return len + strlen(src);
return len + strlcpy (dst + len, src, dst_sz - len);
}
Windows 下是没有 strlcpy 的,对应的是strcpy_s函数。扯远了哈,回归正题。
相似的丢失结束符的例子还有fread以二进制的方式将文件载入内存,不会在未尾加'\0',此时需要调用者在fread之后主动为文本的末尾加上'\0'结束符。
检查点 测试方法
字符串拷贝时是否丢掉结尾符 [valgrind]
在使用丢失了结束符的字符串时是很容易被valgrind捕捉到的,例如,在打印时会报:Conditional jump or move depends on uninitialised value(s)
字符串拷贝的效率问题 [代码检查]
效率问题需要权衡,最安全的方式是strlcpy和strlcat,但是函数内部也会多一些判断而影响效率,一个折中的方案是使用strncpy和strncat(glibc中没有加入strlcpy和strlcat估计也是基于效率考虑),只是要计算好最后一个参数,避免上面说过的向大的目标缓冲区拷贝小字符串带来的低效率问题
一些人认为strncpy或strcpy在做内存拷贝时需要判断是否到达结束符而没有memcpy等内存操作函数高效,memcpy 虽然高效,但是memcpy却有如下死穴:
A. 需要额外提供拷贝的内存长度这一参数,易错且使用不便;
B. 如果长度指定过大的话(最优长度是源字符串长度 + 1),还会带来性能的下降。其实 strcpy 函数一般是在内部调用 memcpy 函数或者用汇编直接实现的,以达到高效的目的。因此,使用 memcpy 和 strcpy 拷贝字符串在性能上没有什么大的差别;
C. 使用memcpy时指定的长度参数不是指定成源字符串长度(用strlen)就是指定成目的buffer的大小(用sizeof)都是不好的,前者容易越界、后者容易截断字符串导致丢失结束符,没有结束符的危害大家都知道了。
尽管strncpy有时候做的不是那么完美,但却也差强人意了,所以不要对正统的字符串使用内存操作函数为好。一些无法作为字符串看待的情况(比如一些包含\0的“字符串”)就只能使用memcpy了,使用这个函数要格外小心。
4、目的地址空间是否足够大
正如前面所说的,一些库函数的设计者假定程序员都是谨慎的,把诸如检查目的内存是否足够放下源字符串的任务推给了程序员,比如strcpy和strcat函数,如果参数dest所指的内存空间不够大,可能会造成缓冲溢出(buffer Overflow)的错误情况,在编写程序时请特别留意,或者用strncpy()来取代,这在上面已经说过了。
另外一个解决方法是在strcpy、strcat甚至memcpy实际拷贝之前就对字符串和目的内存长度作检查,而不是在拷贝之后做,因为那时缓冲区溢出可能已经发生了,当然,合法性检查肯定会带来效率的损失,这是需要衡量的,一定可以保证没有问题的话就不必做了。
检查点 测试方法
目的空间是否够大 [valgrind+pclint]
除了上面初始化一节介绍的pclint可以检查出直接赋值的缓冲区溢出外,这类错误在使用发生溢出的字符串时一般都会发生访问越界或使用未初始化错误,这类错误是逃不过valgrind的法眼的,valgrind和pclint一动一静,确保代码质量!
5、地址重叠
即使检查了目的地址空间够大也不能保证字符串拷贝一定正确,一个很难发现的bug是地址重叠导致内存覆盖的问题,比如在vc6下执行如下代码:
char s[100]=”1234”
strcpy(s+3,s);
结果s变成了”1231234234”,如果字符串长一些输出会更诡异,以至于你不跟踪到strcpy内部都搞不清楚是怎么拷贝的,如果执行strcpy(s+4,s);则会无限递归拷贝,最终耗尽buffer,产生非法访问错误。笔者在测试机上试验了一下,上面例子在64位机上都不会有问题,猜想是因为64位机器寄存器一下子读入8个字节,在拷贝时已经读到了结束符,不会再读源串的缘故,改成下面的样子:
char s[100]=”12345678”
strcpy(s+8,s);
core就出来了?,这个地址重叠的例子可能很容易发现,但是真实的产品线代码结构体都很大,而且多层嵌套,靠review来发现就不是很容易了。
检查点 测试方法
目标地址与源地址是否有交叠 [valgrind + pclint]
那上面第一个不会出core的例子来说,pclint检查的结果很直接的指出了存在数据越界,但是pclint对于发现复杂结构体的内存交叠不是很准确,用valgrind执行demo程序竟然出core,分析可能是valgrind不是一次读入64位,而是逐个字节的读取源字符串,严格按照strcpy函数的实现来执行程序,丝毫没有编译器的优化,valgrind说:“我很慢,但是我很可靠!”
6、函数返回值有效么
还有这么一类库函数,他们完成的是查找功能,比如:strchr(查找字符串中第一个出现的指定字符),如果找到指定的字符则返回该字符所在地址,否则返回NULL。这个函数的应用场景丝毫不比字符串拷贝少,问题代码如下:
if(strcmp(grepstr,"")!=0)
{
strncpy(name,grepstr,(strchr(grepstr,':')-grepstr-1));
}
程序中只考虑了正常情况下找到的情况,对于异常情况,即如果没有找到指定字符函数strchr返回NULL的情况没有相应处理,如果if中的代码写成如下形式,程序就会健壮的多:
Uint index=strchr(grepstr,':');
If(index){
strncpy(name,grepstr,(index-grepstr-1));
}
正如stl容器stack类模板中pop和top没有在一个方法中实现一样,在程序设计中每一时刻只做一件事的原则是有它的道理所在的,如果想同时做多件事情并且做的漂亮、没有bug可不是件容易的事情。
检查点 测试方法
函数返回值是否做有效性判断 [代码检查]
碰见上面那种恐怖的链式表达式就要加万分小心,毕竟干多件事又能干好的人不多,特别注意字符串查找函数,由于查不到而返回值NULL非常容易被遗漏,同样,字符串比较类函数也要关注相等情况的处理。
7、游标越界
利用指针操作字符串是程序员司空见惯的事情,如下代码会有游标越界的问题:
char str[10] = "123",*p=str;
while(*(p++)!='\0'){
cout<<"haha"<<endl;
}
strcpy(p,”456”);
cout<<str;
循环退出后p没有指向str的结束符,而是指向了str后面的地址,一般都是不符合rd预期的(rd预期输出”123456”),”456”被接在了”123”的结束符后面。
检查点 测试方法
游标越界 [代码检查]
这类错误属于游标(指针)的非预期移动,并没有内存非法访问之类的错误,所以只能靠代码review来发现,其实上面的bug也是由于想同时干两件事引起的(既++又取内容),大家要养成用怀疑的眼光看待这种同时干多件事情的表达式。
8、是否负越界
绝大多数内存越界都是正越界,即写过了;其实还有一种越界是写到前面去了,因为不容易发生,所以更难追查,问题代码如下:
在使用fgets等函数时,为了处理尾部的转行,通常会使用下面这样的代码:
int len = strlen(str);
while (str[len-1] == '\r' || str[len-1] == '\n')
str[--len] = '\0';
以32位机为例,如果str[0xFFFFFFFF]中恰好是\r或\n(毕竟没有什么是不可能的),则可能产生len<0的情况,导致越界。
检查点 测试方法
检查负越界 [valgrind]
valgrind扫吧,一定可以发现
9、别操作string变量的底层实现
上面讲到了,String类很强大,但是由于它封装了字符串长度、游标偏移等信息,不能将string变量直接赋值给基本字符串变量,需要通过c_str()或data()方法转换之后才能取出真正的c类型字符串,他们的区别是c_str会返回以null结尾的“完整”字符串,而data方法会返回非null终止的“裸”字符串,示例代码如下:
string s1;
char *str = s1;// 编译时报错
char *str = s1.c_str();// 编译还是过不去,这是为什么?
几乎就成功了,问题出在string类为了防止有人试图直接修改其存放字符串的实际地址(这样会破坏c++的封装思想,而且会带来很多风险),c_str()返回值了一个const 指针,赋给char *当然编译过不去了,解决方法大家都知道了,不赘述了。但是字符串变量却能直接赋给string变量,那是因为string类重载了“=”运算符的缘故。但是我强烈的建议你不要使用这个char *变量做任何操作,下面的bug无论从代码review和运行期间都极难发现:
string str="123",str_long[1000];
char * p= (char *)str.c_str();
str+="very long str…";//这里加上一个很长的字符串常量
cout<<p<<endl;
在linux下输出的东东还是原来的“123”,而vc6就会输出乱码。因为就像前面说的,str会在空间不够时自动分配其他地址空间,p所指向的字符串实际地址被string释放掉了,再使用释放了的内存肯定是非常危险的,所以使用string类的话就尽量不要再操作它的内部实现好了,否则既破坏了c++的封装思想,又极有可能费力不讨好。
检查点 测试方法
是否使用了指向string类的实际字符串地址的指针 [valgrind]
代码review要想发现这类错误是不太可能了,幸好我们有强大的valgrind,运行时会报非法读错误:Invalid read of size 1。
10、是否初始化
把未初始化错误放在最后的原因很简单:由于内存未初始化导致的程序bug可能是各种bug中比例最高的,使用未初始化字符串指针就像在非洲原始森林中捡起一个无比好看的果子往嘴里放,能吐出来算你走运!!!
检查点 测试方法
字符串是否未初始化 [valgrind]
valgrind是检查未初始化的专家,不再啰嗦了
checklist汇总
检查点 测试方法
注意空串 [代码检查]
看到代码中有对字符串的操作就要想起是否可以正确处理空串,一般字符串为空的处理逻辑都比较简单(谁也不可能对空串情况执行什么复杂的操作),用眼睛“执行”一下应该可以发现一些问题。
是否在栈上定义了超大字符数组 [代码检查+valgrind]
考虑是否可以把这个数组放在堆上,栈空间不是无限使用的,听说过“爆栈”么?我们的线上ui的每线程8M栈空间曾经就,因为堆栈“爆炸”,core记录的调用栈信息也不可读,原因追的那叫一个惨烈,但是用valgrind套件的massif工具可以记录栈空间的使用情况,“爆栈事件与原因分析”详见 http://com.baidu.com/twiki/bin/view/Test/UiStack%E6%BA%A2%E5%87%BA
直接赋值初始化越界 [编译器+pclint]
直接赋值的初始化越界可以由编译器发现,pclint能检测出不计算结尾符的常量字符串长度大于目标变量长度的情况,如: char str[5]="123456";会有报警“Warning 540: Excessive size”
strcpy方式初始化越界 [pclint]
对于使用strcpy方式的初始化越界pclint扫描输出 “Warning 419: Apparent data overrun for function 'strcpy(signed char *, const signed char *)', argument 2 (size=6) exceeds argument 1 (size=5)”很清晰的指出越界原因”
是否有默认初始值 [valgrind]
全局变量和静态变量有默认初始值,而堆栈上的局部变量和动态变量未置初始值由valgrind扫描可以发现,在使用时报未初始化错误。
是否修改只读存储区 [功能测试+gdb]
只要保证程序的执行流走到这部分代码就会出core,正常功能测试走不到的异常分支可以通过gdb使异常分支被覆盖。
是否使用危险输入输出函数 [代码检查+valgrind]
上述函数都属于危险函数,尽量都不要使用,很容易发生缓冲区溢出,可以使用snprintf、fgets等有长度限制的输入输出函数,同时注意判断sscanf的返回值是否合法。对于缓冲区溢出的情况valgrind也可以检查出来。
区分strlen与sizeof [代码检查]
见到strlen想想是否该用sizeof,见到sizeof想想是否该用strlen,没有更好的办法。
sizeof对象是否正确 [pclint]
对于上例中的由于sizeof对象被放大造成内存写越界,pclint可以直接扫描出来:Warning 661: Possible access of out-of-bounds pointer,不战而屈人之兵。
找出魔鬼数字 [代码检查]
魔鬼数字都是很好识别的,建议用宏或其他形式,程序中几乎没有必须要写成魔鬼数字而不能用宏定义的地方
字符串拷贝时是否丢掉结尾符 [valgrind]
在使用丢失了结束符的字符串时是很容易被valgrind捕捉到的,例如,在打印时会报:Conditional jump or move depends on uninitialised value(s)
字符串拷贝的效率问题 [代码检查]
效率问题需要权衡,最安全的方式是strlcpy和strlcat,但是函数内部也会多一些判断而影响效率,一个折中的方案是使用strncpy和strncat(glibc中没有加入strlcpy和strlcat估计也是基于效率考虑),只是要计算好最后一个参数,避免上面说过的向大的目标缓冲区拷贝小字符串带来的低效率问题
目的空间是否够大 [valgrind+pclint]
除了上面初始化一节介绍的pclint可以检查出直接赋值的缓冲区溢出外,这类错误在使用发生溢出的字符串时一般都会发生访问越界或使用未初始化错误,这类错误是逃不过valgrind的法眼的,valgrind和pclint一动一静,确保代码质量!
目标地址与源地址是否有交叠 [valgrind + pclint]
拿上面第一个不会出core的例子来说,pclint检查的结果很直接的指出了存在数据越界,但是pclint对于发现复杂结构体的内存交叠不是很准确,用valgrind执行demo程序竟然出core,分析可能是valgrind不是一次读入64位,而是逐个字节的读取源字符串,严格按照strcpy函数的实现来执行程序,丝毫没有编译器的优化,valgrind说:“我很慢,但是我很可靠!”,这个正是我们想要的。
函数返回值是否做有效性判断 [代码检查]
碰见上面那种恐怖的链式表达式就要加万分小心,毕竟同时做多件事又能干好的人不多,特别注意字符串查找函数,由于查不到而返回值NULL非常容易被遗漏,同样,字符串比较类函数也要关注相等情况的处理。
游标越界 [代码检查]
这类错误属于游标(指针)的非预期移动,并没有内存非法访问之类的错误,所以只能靠代码review来发现,其实上面的bug也是由于想同时干两件事引起的(既++又取内容),大家要养成用怀疑的眼光看待这种同时干多件事情的表达式。
检查负越界 [valgrind]
valgrind扫扫吧,一定可以发现这类bug
是否使用了指向string类的实际字符串地址的指针 [valgrind]
代码review要想发现这类错误是不太可能了,幸好我们有强大的valgrind,运行时会报非法读错误:Invalid read of size 1。
字符串是否未初始化 [valgrind]
valgrind是检查变量未初始化的专家,不再啰嗦,扫吧