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

CPU眼里的:字符串 vs 数组

ruisui883周前 (04-05)技术分析11

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

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 数组” 的相关文章

git的几种分支模式

编写代码,是软件开发交付过程的起点,发布上线,是开发工作完成的终点。代码分支模式贯穿了开发、集成和发布的整个过程,是工程师们最亲切的小伙伴。那如何根据自身的业务特点和团队规模来选择适合的分支模式呢?本文分享几种主流 Git 分支模式的流程及特点,并给出选择建议。分支的目的是隔离,但多一个分支也意味着...

前后端分离自动化运维平台开发

运维平台采用前后端分离:前端vue,框架vue-element-admin;后端python,框架django-rest-framework.目前运维平台模块如下:1、 CMDB管理应用管理、环境管理、开发语言管理、产品项目管理、资产管理2、 构建发布持续构建、持续部署、Jar工程依赖构建3、 容器...

内存问题探微

这篇文章是我在公司 TechDay 上分享的内容的文字实录版,本来不想写这么一篇冗长的文章,因为有不少的同学问是否能写一篇相关的文字版,本来没有的也就有了。说起来这是我第二次在 TechDay 上做的分享,四年前第一届 TechDay 不知天高地厚,上去讲了一个《MySQL 最佳实践》,现在想起来那...

面试被逼疯:聊聊Python Import System?

面试官一个小时逼疯面试者:聊聊Python Import System?对于每一位Python开发者来说,import这个关键字是再熟悉不过了,无论是我们引用官方库还是三方库,都可以通过import xxx的形式来导入。可能很多人认为这只是Python的一个最基础的常识之一,似乎没有可以扩展的点了,...

HTML5学习笔记三:HTML5语法规则

1.标签要小写2.属性值可加可不加””或”3.可以省略某些标签 html body head tbody4.可以省略某些结束标签 tr td li例:显示效果:5.单标签不用加结束标签img input6.废除的标签font center big7.新添加的标签将在下一HTML5学习笔记中重点阐述。...

html5+css3做的响应式企业网站前端源码

大家好,今天给大家介绍一款,html5+css3做的响应式企业网站前端源码 (图1)。送给大家哦,获取方式在本文末尾。首页banner幻灯片切换特效(图2)首页布局简约合理(图3)关于我们页面(图4)商品列表(图5)商品详情(图6)服务介绍(图7)新闻列表(图8)联系我们(图9)源码完整,需要的朋友...