当前位置:首页 > 技术分析 > 正文内容

CPU眼里的:字符串 vs 数组

ruisui883个月前 (04-05)技术分析20

“它们十分相似,但又非常不同

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码,这跟普通变量表示是数字没有本质差异。字符串和字符数组的区别如下:

  1. 结束符:字符串以空字符(\0)结束,字符数组则不一定
  2. 初始化:字符串可以使用字符串字面量(例如"Hello")进行初始化,字符数组则需要逐个字符初始化或使用字符数组初始化。
  3. 处理方式: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>

扫描二维码推送至手机访问。

版权声明:本文由ruisui88发布,如需转载请注明出处。

本文链接:http://www.ruisui88.com/post/3308.html

分享给朋友:

“CPU眼里的:字符串 vs 数组” 的相关文章

壹啦罐罐 Android 手机里的 Xposed 都装了啥

这是少数派推出的系列专题,叫做「我的手机里都装了啥」。这个系列将邀请到不同的玩家,从他们各自的角度介绍手机中最爱的或是日常使用最频繁的 App。文章将以「每周一篇」的频率更新,内容范围会包括 iOS、Android 在内的各种平台和 App。本期继续歪楼,由少数派撰稿作者@壹啦罐罐介绍他正在使用的...

2024最新版:前端性能优化方案汇总

前端训练营:1v1私教,终身辅导计划,帮你拿到满意的 offer。 已帮助数百位同学拿到了中大厂 offer。欢迎来撩~~~~~~~~Hello,大家好,我是 Sunday。前端性能优化一直是很多同学非常关注的问题,在日常的面试中也是经常会被问到的点。所以今天咱们就花一点时间来了解一下2024最新的...

「干货」FPGA设计中深度约束技巧及调试经验总结

今天跟大家分享的内容很重要,也是我们调试FPGA经验的总结。随着FPGA对时序和性能的要求越来越高,高频率、大位宽的设计越来越多。在调试这些FPGA样机时,需要从写代码时就要小心谨慎,否则写出来的代码可能无法满足时序要求。另外,最近跟网友聊天时,有谈到公众号寿命的问题,我觉得网络交换FPGA公众号应...

最快清除数组空值?分享 1 段优质 JS 代码片段!

本内容首发于工粽号:程序员大澈,每日分享一段优质代码片段,欢迎关注和投稿!大家好,我是大澈!本文约 600+ 字,整篇阅读约需 1 分钟。今天分享一段优质 JS 代码片段,用最简洁的代码清除了数组中的空值。老规矩,先阅读代码片段并思考,再看代码解析再思考,最后评论区留下你的见解!const arr...

Python中的11 种数组算法

1. 创建数组 创建数组意味着留出一个连续的内存块来存储相同类型的元素。在大多数语言中,您可以在创建数组时指定数组的大小。假设您正在书架上整理一组书籍,并且您需要为正好 10 本书预留空间。功能架上的每个空间都对应于数组中的一个索引。# Example in Python arr = [1, 2,...

vue.js 双向绑定如何理解,有什么好处!#云南小程序开发

Vue.js 的双向数据绑定是借助于 JavaScript 的一些特性,如对象的属性 getter 和 setter 以及 Vue 的依赖追踪系统实现的。简单来说,双向数据绑定就是数据与视图间的双向通信,也就是说数据的改变会马上反映到视图中,视图的改变也会立刻改变数据。具体来说,当你改变了数据时,视...