“它们十分相似,但又非常不同”
01
提出问题
字符串和字符数组,在内存分布上,跟普通数组(例如:int类型的数组)有很高的相似性。但使用字符串的危险系数,却远远高于普通数组。是什么细微的差异导致了二者在使用上,有这么大的不同呢?暂时告别教条的标准答案,让我们一起掀开引擎盖,看看到底发生了什么?
02
数值特性
打开Compiler Explorer,编写一个常规的函数func1,里面定义了一个字符串a,它的初值是字符串“abc”;接着我们再定义一个函数func2,里面定义了一个字符数组b,它的初值分别是:a、b、c和0,其中0是字符串的结束符,由于这个结束符的存在,所以称数组b为字符串数组更为合适。具体代码如下所示:
void func1()
{
char a[] = "abc";
}
void func2()
{
char b[4] = {'a', 'b', 'c', 0 };
}
好了,比较一下二者的CPU指令,如图所示。
如你所见,它们对应的CPU指令完全相同!只是这个赋值语句有点让人费解,这不像是为数组a和b,赋值字符串,而是一个神奇的数字:6513249。
不用着急,让我们再写一个函数func3,这里是给数组c赋值,只是这里的初值,我们不写成字符,而是二进制数,具体代码如下:
void func3()
{
char c[4] = { 0x61, 0x62, 0x63, 0x0 };
}
它所对应的CPU指令,如图所示。
通过它们完全一致的CPU指令,相信你也猜到了:6513249对应的16进制数正好是:0x00、0x63、0x62、0x61,这4个字节正好对应了:结束符'\0'、和字符c、b、a的ASCII码。由于x86 CPU是小端模式,所以这个顺序是倒着排的,具体原因,也可以参看“CPU眼里的大端、小端”。
由此可见,无论人类世界的文字,多么妖娆,但在计算机的世界里面,它依然只是一个数字。我们不用纠结是用二进制数来初始化字符串,还是用字符来初始化数组,因为它们只是表示数的方法不同,并没有本质的区别。
对于资深读者的话,可能下面的函数func4更加原汁原味,它跟汇编指令符合的更好。
void func4()
{
int x = 0x00636261;
}
之所以可以把数组写成int x,是因为数值0x00636261,无论是以数组的形式存在,还是以int类型存在,它们在内存中的分布是相同的。
不仅如此,有时候我也会看到用字符来初始化int类型的变量,例如:
void func5()
{
int y = '\0cba';
}
其实函数func4和func5的本质相同,对应的CPU指令也完全一致,不同的只是表现的手法。如果只在语法层面记忆这些怪异规则的话,显然是非常突兀的,但如果从底层视角看它们的话,似乎一切又是丝滑和顺利成章的。
或许你也发现了:一条简单的汇编指令,其对应的高级语言实现方式是可以多种多样的。这也是逆向工程,往往不能精准还原源代码的原因之一,因为可选的还原路径确实太多了,至少字符串是这样的。
03
字符越界
在了解完字符的数值特征后,让我们再看看为什么字符串比数组更加容易越界?
这里我们定义了两个全局的字符串数组aa和bb,其中定义数组aa的时候,我们故意遗漏了“结束符”(\0),具体代码和运行结果如图所示。
如你所见,对于没有结束符的数组aa,在汇编文件中,其初值“abcd”,会被注明成:ASCII码;而对于有结束符的数组bb,其初值“efgh”才会被注明成:字符串(.string)。
最后,在main函数中,输出字符数组aa,并分别输出其表示的字符串长度和数组aa的长度。输出结果如上图右下角所示:尽管数组aa的size还是4个字节,但它所表示的字符串的长度则超过了它的size,达到了8个字节。数组aa所代表的字符串已经越界到数组bb了,此时aa代表的字符串不再是:abcd;而是:abcdefgh!
这个结果可能跟程序员的初衷是违背的,虽然不会立刻导致程序崩溃,但随着时间的推移,可能引发更多的逻辑错误,随着这些逻辑错误,距离字符串代码渐行渐远,可能导致开发者无法意识到:逻辑错误的根本原因是字符串,从而陡增了调试的难度。
再看看数组aa和bb的内存分布图,如图所示。
如你所见,由于字符数组aa和bb之间没有结束符(\0),所以在没有源代码的提示下,我们也无法知道这是两个字符数组。同样,由于我们只为库函数strlen输入了字符串的内存首地址,所以,库函数strlen也无法知道字符串数组aa的真实长度,除非遇到结束符(\0)。
当然不仅是库函数strlen,那些专门用来处理字符串的库函数,都会存在类似的问题。例如库函数:strlen(),strcmp(),strcpy(),strcat()…
不过有些时候,即使不加入结束符,编译器或者操作系统会将一些数据段初始化为0,这相当于为潜在的字符串加上“结尾符”(\0),这一定程度上可以防止字符串因为遗漏“结束符”导致的访问越界。但这也可能纵容了大家不规范的编码习惯,一旦没有编译器和操作系统兜底,就可能酿成字符串越界的错误。
为了应对这种安全问题,可以考虑改用更加安全的字符串库函数,例如:strnlen
size_t strnlen(const char *s, size_t maxlen);
它会要求开发者输入字符串的最大长度,从而减少字符串越界的机会。
04
字符串的访问属性
最后,我们来看一个非常隐蔽、且容易被大家忽视的字符串问题。在main函数中定义一个字符指针d,并将其赋值为字符串“xyz”;然后再把第一个字符'x',改写成字符'a'。改写的方式,既可以采用指针形式:*d = ‘a’,也可以采用数组形式:d[0] = ‘a’,二者背后的CPU指令是完全相同的。具体代码如下:
int main()
{
char* d = "xyz";
d[0] = 'a';
}
代码对应的CPU指令,如图所示。
虽然这两行代码十分简单,但却至少埋了两个大雷!第一个雷,是眼力大挑战:这里定义指针变量d的代码,跟定义一个数组的代码非常相似:char d[] = "xyz";但指针变量和数组变量,它们存储字符串的方式,有天壤之别!
对于指针变量d,它跟普通变量一样,是某一段内存地址的别名,从上图对应的CPU指令可以看出:变量d代表的内存首地址是:[rbp - 8],属于函数堆栈内存,在该内存里面,存储着字符串“abc”的内存首地址。
需要注意的是:字符串“xyz”本身并没用跟随变量d,也存储在函数的堆栈内存里面,相反它存储在全局数据段里面。
这样,字符串“xyz”的生命周期,不会像函数中的临时变量(栈变量)或临时数组a、b、c那样,随着函数的返回,其生命周期也随之结束;相反,字符串“xyz”的生命周期,将贯穿整个程序的运行过程。如果分别打印出指针变量d和字符串“xyz”的内存首地址的话,我们会发现它们之间有很大的距离,显然它们不是同一个存储区域。
第二个雷,就是写操作(读操作是被允许的),这里我们试图把第一个字符‘x’改成字符‘a’。运行结果如图所示。
程序居然崩溃了,segmentation fault!其实这段超简单的代码里面暗藏玄机。原来很多编译器会把字符串“xyz”安排在只读数据段。如果CPU集成了MMU,当我们试图对只读内存作写操作时,就会产生page fault,进而导致程序崩溃!没想到吧?如此简单的代码,竟然还涉及到了数据段和内存保护这类底层知识。这是不是非常超纲呢?
但对于没用MMU的单片机设备的话,可能不会导致程序崩溃,甚至系统对这种情况可能视而不见,毫无反应。但改写操作,却很有可能失败。不过这种写入失败,不会产生任何提示,这会让错误继续延续下去,直到出现了肉眼可见的逻辑错误。关于MMU的相关内容,可以参看章节“CPU眼里的:虚拟内存”。
当然,现代编译器也会警告我们这个char*类型的变量d,跟const char*类型的字符串“xyz”是不匹配的,比较正确的写法应该是这样的:
const char* d = "xyz";
d[0] = 'a'; //error here
这样,当我们试图编写改变字符的代码时,编译器直接给出编译错误,从而禁止这种错误代码的运行。
05
总结
总的来说,字符也是一个数字。每个字符是一个特定的ASCII码,这跟普通变量表示是数字没有本质差异。字符串和字符数组的区别如下:
- 结束符:字符串以空字符(\0)结束,字符数组则不一定
- 初始化:字符串可以使用字符串字面量(例如"Hello")进行初始化,字符数组则需要逐个字符初始化或使用字符数组初始化。
- 处理方式:C标准库提供了许多处理字符串的函数,例如strlen、strcpy、strcmp等,这些函数依赖于字符串的结束符。而对于普通的字符数组,这些函数可能无法正常工作,除非字符数组恰好符合字符串的格式(即以结束符(\0)结束)。
06
更多知识
如果喜欢阿布这种解读方式,希望更加系统学习这些编程知识的话,也可以考虑看看由阿布亲自编写,并由多位微软大佬联袂推荐的新书《CPU眼里的C/C++》
【京东热卖】好评度:> 98%
【微信读书】推荐度:> 82%
<script type="text/javascript" src="//mp.toutiao.com/mp/agw/mass_profit/pc_product_promotions_js?item_id=7478862543725871650"></script>