关于C语言字符串函数的思考
C语言并不是一种很方便的语言,它的字符串就是一例。按照C语言的定义,“字符串就是一段内存空间,里面包含ASCII字符,并且,以”\0”结尾,总共能存放n-1个字符。”按照这个描述,字符串处理确实很麻烦,还很容易出错。
为了方便用户,C语言标准库向用户提供了一些字符串函数,如字符串拷贝、构造、清空等函数,在一定程度上方便了用户的使用。但是,我无意中发现,这些函数还是有些隐患的。
事情很简单,我注意到我写的一些程序,老是有内存读写错误,但是,经过仔细检查我所有的数据Buffer,以及相关的处理函数,又没有找到什么错误。于是我把怀疑的目光投向我常用的一些字符串处理函数上,如strcpy、sprintf等。在经过几次仔细地跟踪之后,我发现内存错误出自于此。于是,我开始研究如何安全地使用字符串这个话题。
1 字符串拷贝函数 1.1 不安全的strcpy
首先,我写了这样一个测试函数:
void strcpyTest0()
{
int i;
char szBuf[128];
for(i=0;i<128;i++) szBuf[i]='*';
szBuf[127]='\0'; //构造一个全部是*的字符串
char szBuf2[256];
for(i=0;i<256;i++) szBuf2[i]='#';
szBuf2[255]='\0'; //构造一个全部是#的字符串
strcpy(szBuf,szBuf2);
printf("%s\n",szBuf);
}
很简单,把一个字符串拷贝到另外一个空间,但是,很不幸,源字符串比目标地址要长,因此,程序很悲惨地死去了。
1.2 还是不安全的strncpy
通过上例,我发现我需要在拷贝时多输入一个参数,来标明目的地址有多长,检查C语言的库函数说明,有一个strncpy可以达到这个目的,这个函数的原型如下:
char *strncpy( char *strDest, const char *strSource, size_t count );
好了,这下我们的问题解决了,我写出了如下代码:
void strcpyTest1()
{
int i;
char szBuf[128];
for(i=0;i<128;i++) szBuf[i]='*';
szBuf[127]='\0';
char szBuf2[256];
for(i=0;i<256;i++) szBuf2[i]='#';
szBuf2[255]='\0';
strncpy(szBuf,szBuf2,128);
printf("%s\n",szBuf);
}
一切都显得很好,但是,当我输出结果的时候,发现了问题,字符串后面有时会跟几个奇怪的字符,好像没有用”\0”结束,于是我把上面的拷贝语句改成“strncpy(szBuf,szBuf2,8);”,只拷贝8个字符,问题出现了,程序输出如下:
########***********************************************************************************************************************
果然,当请求的目标地址空间比源字符串空间要小的时候,strncpy将不再用”\0”来结束字符串。巨大的隐患。
1.3 安全地字符串拷贝函数
我仔细想了想,我认为我需要如下一个字符串拷贝函数:
1、 允许用一个整数界定目标地址空间尺寸。
2、 当目标地址空间nD小于源字符串长度nS时,应该只拷贝nD个字节。
3、 任何情况下,目标地址空间均应该以”\0”结束,保持一个合法的字符串身份。因此,得到的字符串最大长度为nD-1。
于是,我写了这么一个字符串拷贝函数:
void xg_strncpy1(char *pD, char *pS,int nDestSize)
{
memcpy(pD,pS,nDestSize);
*(pD+nDestSize-1)='\0';
}
很EASY是不,将这个拷贝函数代入上面的例子,只输出7个”#”, 结果正确。
1.4 内存读错误的思考
本来以为可以就此打住了,不过,没多久,我就发现一个奇怪的现象,这个函数在VC的Debug模式下有错误,但是Release模式下却一切正常。
我奇怪了很久,终于有一天我忍不住了,决定解决这个问题,我把上面的memcpy用自己的一个复制循环代替,单步跟踪,想看看究竟怎么回事?
原因找到了,我希望拷贝一个256字节长的字符串,但是,拷贝到第33字节时出错,检查程序,发现我的源字符串空间只有32 Bytes,原来,我上面的代码只是防止了内存写出界,但没有针对读出界进行检查,在VC的Debug模式下,内存读出界也是一种非法错误,因此被报错。
知道了原因,解决就很简单了,我把上面的拷贝函数改成如下形状:
void xg_strncpy2(char *pD, char *pS,int nDestSize)
{
int nLen=strlen(pS)+1;
if(nLen>nDestSize) nLen=nDestSize;
memcpy(pD,pS,nLen);
*(pD+nLen-1)='\0';
}
一切OK。
2 字符串构造函数 2.1 不安全的sprintf
如同上例,我在修改拷贝函数的同时,我也想到了另外一个我常用的字符串构造函数sprintf,显然,这个函数没有界定目标地址空间的尺寸,也是不安全的,下面的代码将会造成崩溃:
void sprintfTest0()
{
int i;
char szBuf[128];
for(i=0;i<128;i++) szBuf[i]='*';
szBuf[127]='\0';
char szBuf2[256];
for(i=0;i<256;i++) szBuf2[i]='#';
szBuf2[255]='\0';
sprintf(szBuf,szBuf2);
printf("%s\n",szBuf);
}
2.2 还是不安全的_snprintf
查阅库函数手册,找到这么一个函数_snprintf,其函数原型如下:
int _snprintf( char *buffer, size_t count, const char *format [, argument] ... );
这个函数允许界定目标地址尺寸,但是,由于研究拷贝函数的经验,我怀疑它也有strncpy相同的问题,因此,我写了这么一段代码测试:
void sprintfTest1()
{
int i;
char szBuf[128];
for(i=0;i<128;i++) szBuf[i]='*';
szBuf[127]='\0';
char szBuf2[256];
for(i=0;i<256;i++) szBuf2[i]='#';
szBuf2[255]='\0';
_snprintf(szBuf,8,szBuf2);
printf("%s\n",szBuf);
}
果然,程序输出如下:
########***********************************************************************************************************************
同样的错误,没有用”\0”结束,我必须另外想方法。
另外,还发现了另外一个不足,就是这个时候,_snprintf函数返回-1,不再返回打印的字符数,那么,我们如果使用如下代码将会造成逻辑错误,甚至可能崩溃:
char szBuf[256];
int nCount=0;
while(1) //这里表示循环构造
{
nCount+=_snprintf(szBuf+nCount,256-nCount,”... ...”); //多个字符串构造成一个字符串
}
注意,代码利用_snprintf返回的值,来确定下一个起始点,这很常用,但是,当_snprintf返回-1的时候,有可能会写到*(szBuf-1)的位置上,典型的内存写出界。
2.3 安全地字符串构造函数
经过仔细思考,我构造了如下一个函数:
int xg_printf(char* szBuf,int nDestSize,char *szFormat, ...)
{
int nListCount=0;
va_list pArgList;
va_start (pArgList,szFormat);
nListCount+=_vsnprintf(szBuf+nListCount,
nDestSize-nListCount,szFormat,pArgList);
va_end(pArgList);
*(szBuf+nDestSize-1)='\0';
return strlen(szBuf);
}
注意,这里我采用了变参函数设计,为的是和sprintf一样方便,另外,最后一个return也非常重要,因为很多场合,我们需要知道究竟打印了多少字符。将这段函数代入上面的例子后一切正常。
总结:C语言字符串库函数可能是出于提高性能目的,在一旦条件不够的时候,往往直接返回,忘了采用”\0”结束字符串。这会造成下一次读取字符串时,数据边界不可控。格式化打印函数,返回值设计不合理,不永远是一个正整数,会造成逻辑隐患。因此,建议大家有兴趣可以参考一下我提供的两个函数。
另外,以上仅为我个人测试之作,限于本人水平所限,肯定还有没考虑到的地方,欢迎大家展开讨论。如果大家需要上面的源代码,请和我联系。
目前,在IT职场上,甚至在整个社会上,普遍存在一种现象,就是刚刚毕业的大学生,不好找工作。究其原因,我们很多人在大学中,虽然学到了很好知识,但是缺乏运用知识的技能,无法满足企业用人的要求,导致就业困难,很多人因此甚至“回炉重造”,以参加各种培训班来弥补这种经验上的缺失,以期尽快找到工作,获得社会的认可。这究竟是广大学生的学习能力出了问题,还是大学的教育出了问题呢?
另外,很多年轻的朋友即使顺利进入到企业中,也会由于经验的缺失,无法快速融入企业商业化开发的氛围,尽管大多数年轻的程序员已经很努力地在学习和工作,但做出来的产品就是无法满足市场的需求,这令他们很苦恼,用他们的企业也很苦恼,这究竟又是怎么一回事呢?
还有个问题,程序员作为一种创造性极强的社会职业,已经获得了大家的认可,但是,大家也几乎公认,程序员是一门非常辛苦的工作。在商业公司中,由于市场竞争的压力,程序员经常面临的环境是任务紧,压力大,加班成为家常便饭,同时,新技术层出不穷,程序员在完成本职工作的前提下,还要不断努力学习,稍有懈怠,即有因知识老化被淘汰的危险。虽然我们说,市场化的竞争社会,各行各业都压力,但是无疑,程序员这个职业是压力较大的一种。那么,我们程序员究竟有没有办法,通过自己的一点努力,让自己的生活变得轻松一点,压力小一点,更多一点时间来陪伴家人,共享天伦之乐呢?
笔者从事IT程序开发几十年,被人招聘,也招聘过人,有压力,也有心得,看过很多书籍,也做过一些工程项目,很多时候,笔者和朋友们也都面临上述的问题。不过,经过这么多年的思考,笔者发现,上述问题其实并非无解,简单说来,无非是一个年轻人,从学校出来,需要迅速扭转学生思维,做工程不搞科研,做项目不做学问,以务实的态度,迅速进入商用软件开发领域,并在成本和质量意识的基础上,不断对自己的技能精益求精,高质量地完成各种商用软件工程项目,最终也使自己成为企业合用的人才。
但是很遗憾,笔者看了很多计算机软件编程方面的教科书,这些书虽然都讲解了很好的知识,但是,也许是笔者孤陋寡闻,几乎没有一本书,是站在商用软件开发,站在企业、市场化的角度,来论述软件开发的原则和道理,同时,很多教科书上,讲解了很多正确的开发知识,但对于如何开发正确的程序,如何杜绝程序中的bug,如何开发出高安全度,高可用性的商用软件,论述甚少,尤其在近年来32位多任务操作系统大行其道时,如何以一种简便、安全的方式,开发出合用的并行商用工程,更是很少有书籍涉及。这几乎已经成为IT软件全行业的一块“短板”。简单说来,目前的软件开发书籍,正确的多,实用的少,对于商业企业中的职业化开发,缺乏指导资料。
笔者有鉴于此,决定写这本书,一方面,笔者希望把自己这么多年的开发经验做个总结,并share给各位读者,另一方面,笔者也希望从另外一个角度,从纯粹商业化的,务实的角度,论述一点商用工程开发的道理,希望能帮助更多的朋友,能迅速掌握商用开发的基本原则,迅速掌握企业开发的基本原则,最终,成为企业合用的人才。
本书不仅仅是一本技术类书籍,虽然本书给出了很多诸如内存池、线程池、任务池、锁、队列、debug和日志系统等常见商用软件开发的范例,也给大家展示了一个可用度较高的工程库,但笔者认为这并不是本书的重点,贯穿本书始终的以需求为主导的系统分析方法,实用主义的开发态度,以及务实的程序实践理念,才是本书的精华所在,因此,笔者请各位读者在阅读本书时,不要就技术谈技术,尽量多思考技术之外的东西,这样的话,各位读者的收获可能更多。