64位程序怎么判断指针是否有效_深入理解计算机系统-程序的机器级表示

知乎对于表达式语法支持不太行,大家可以去看原文

深入理解计算机系统-程序的机器级表示 - 掘金​juejin.im

程序的机器级表示

v2-9364bd94da38fe4f5ec378f3b79b8bb7_b.jpg

前言

汇编代码与特定机器密切相关,能够阅读和理解汇编代码是一项很重要的技能。

历史观点

这,没啥很好说的,就,无了。

程序编码

对于Linux机,可以使用 gcc -Og -S xxx.c来进行学习。因为参数-Og表明不进行优化,这可以让汇编代码尽可能地保持和C源码一样的顺序,位置,排列等。

机器级代码

对于机器编程来说,有两种很重要的抽象。一种是对于程序执行的抽象(使用指令集架构定义程序执行的具体情况),一种是对于内存的抽象,使得整个虚拟地址空间在OS的加持下成为一个超大的可用数组。

x86-64的机器代码和原始的C代码相差颇大,比如:

  1. 程序计数器(PC)在x86-64机中代表下一条指令的位置。
  2. 整数寄存器文件包含了16个命名的位置(就是寄存器可见的意思),分别存储64位的值。寄存器可以用来存储地址(就像C语言的指针),或者保存程序状态,再或者某些寄存器用于保存临时数据,列如过程调用的参数和局部变量,以及函数返回值。
  3. 条件码寄存器,用来实现控制和数据流中的条件变化,比如if和while语句。
  4. 一组向量寄存器可以用来存放一个或多个整数或浮点数值。

对于C语言的聚合类型,比如数组和结构体,在机器代码中就是一组连续的字节表示。同时对于汇编代码来说,它不区分有符号数和无符号数,各种指针类型,甚至不区分整数和指针(因为指针就是整数类型,只是C强调了指针类型这个概念,指针的值就是无符号数)。

程序内存指的是程序运行时需要的内存,包括程序的可执行机器代码,操作系统需要的某些信息以用来管理过程调用和返回的运行时栈,用户分配的内存块。

一般来收,一条机器指令只执行一个非常基本的操作,比如加减运算,在存储器(主存)和寄存器之间传递数据,或者是根据条件分支转移到新的指令地址。

机器执行的程序只是一个字节序列,它是对一系列指令的编码,机器对源程序一无所知。对于这些字节序列,从某个位置开始,连续或单个字节(块)可以被解码成一条指令,或者这么理解,指令被编码成一个或多个连续的字节,根据起始字节定位并解码就能得到这一小段字节指的是哪个指令。

关于格式的注解

对于.s文件,所有以'.'开头的行都是指导汇编器和链接器工作的伪指令。

本文即接下来的文章阐述汇编代码使用的都是AT&T格式,另一种格式是Intel格式,至于它们的区别,可以自行搜索得知,但是不论知晓哪一种,都可以理解另一种,因为它们都是x86-64架构的汇编。

有时C语言程序可能需要汇编程序才能运行,此时有两种解决方案,一是独立建立一个汇编文件并链接到C程序中,另一种是使用GCC的内联汇编特性,使用asm伪指令直接在C程序里包含汇编代码。

数据格式

由于计算机发展是从16位到32位再到64位的,所以Intel使用字(word)表示16位数据类型,用双字(double word)表示32位数据类型,四字(quad word)表示64位数据类型。

来看一下C语言的数据类型对应的x86-64表示:

v2-bfe684630a33a2871202ea6c10bda669_b.jpg

一般而言,指令后面都会跟着一个后缀,表明操作数的大小。浮点数和整数使用的是完全不同的指令和寄存器。

访问信息

一个标准的x86-64CPU包含一组16个用来存储64位数据的通用寄存器。他们用来存储整数数据和指针。来看看这16个小可爱吧!

v2-4d0395661148d9e3d8fc7520c6457403_b.jpg

指令可以对这16个寄存器的低位字节存放的大小不同的有效数据进行操作。这些寄存器可以向前兼容,就是64位可以存储32位,16位和8位的数据,但是32位无法存储64位的数据,如果是大寄存器存小数据,那么只能存在低位中,高位有相对应的填充措施。

对于高位的数据填充,一般有两个策略:一是生成1字节和2字节的指令保持其他高位不变;生成4字节(2字)的指令会把高位填充为0。

对于所有的寄存器来说,最特别的就是%rsp,它用来指明运行时栈的结束位置。

操作数指示符

大多数指令都有一个或多个操作数,指出执行一个操作中要使用的源数据和目的地位置。源数据可以是立即数(常量),寄存器,或从内存地址里读出;操作结果可以存放到寄存器或内存里。来看看常用的表述方式:

v2-ce9143937b395cda54162418e2af448e_b.jpg

在这张表里,只有前两个得到的是直接的值,其余都要翻译成内存地址来进行寻址操作然后取值。

数据传送指令

对于指令,习惯把它们划分成不同的类,每一类的指令作用相同,只是操作的数据大小不同。

先来看看数据传送指令,它有点像C语言的赋值运算。MOV类指令有四条指令,他们的主要区别是操作的数据大小不同:

v2-8e798f29088542f862a5ea9887567de6_b.jpg

最后一个指令用来移动完全的四字数据。

x86-64对于MOV指令加了一个限制,就是不能直接把一个值从内存的某个位置复制到另一个位置,而必须使用寄存器作为中介。MOV指令的后缀表明了操作数据的大小,也就是寄存器的大小,不管这个寄存器是源数据还是目的地址,都必须符合指令指定的大小。MOV指令只会更新目的操作数指定的寄存器字节或内存位置(就是只更新指定的低位字节),高位字节有其他的设置,但是有一个例外,就是movl指令,当它把寄存器作为目的地址时,会把高32位设为0。

v2-3143e709d286881b5c0ace7ac859adba_b.jpg

常规的movq指令只能以表示32位补码的数字的立即数作为源数据利用符号扩展放到64位寄存器里去;movabsq能以任意64位立即数值作为源数据,并且只能以寄存器作为目的地址。

有两类指令,适用于把较小(位长)的源数据复制到较大(位长)的目的地时使用,MOVZ使用0扩展位长;MOVS使用符号扩展位长,就是复制源数据的最高有效位。然后它们的后两个都是位长,第一个指定了源数据的大小,第二个指定了目的的大小。

v2-a677d7dec5001f513229e9f95c79a118_b.jpg

v2-51976c969938fb5ce0ac477a8b6f4dc4_b.jpg

v2-dedaaae6f9770d67cab7ed322d0716e7_b.jpg

生成4字节值并以寄存器作为目的的指令会把高4字节自动置为0。

来看一个字节传送指令的比较:

v2-30b2dfad191a0f49b49d0569fd635804_b.jpg

压入和弹出数据

这两个数据传送操作可以把数据压入栈中,以及从中弹出数据。在x86-64的机器中,程序栈存放在内存中的某个区域。栈向下增长,因此栈顶元素时所有栈元素里地址最小的。所以为了方便理解,栈是倒过来画的,也是向下增长的。

v2-101342171a0ab19b5b8d543c952f51a3_b.jpg

至于涉及到的操作:

v2-75e92faa2f8fde2c18e4c8ca52e77595_b.jpg

pushq指令的功能是把数据压入栈中,而popq是弹出指令。不过它们都涉及两个操作,pushq是首先在栈底指针%rsp里存着新的值得地址,然后设置内存中的这个位置得值为准备压入得数据。所以它的操作数是数据源,而popq则是首先把栈顶的值复制到操作数里,然后指针回退,也就是+8(栈顶指针+8意味着减小8字节的空间,因为它是向下增长,+的话就代表往上回去了)。注意哈!这操作的都是8字节下,64位。

因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方法访问栈内的任意位置。

算数和逻辑操作

来看一些常见的整数以及逻辑操作。它们被分成了四大类,分别是:加载有效地址,一元操作,二元操作和移位。

v2-8f625a8dfe27729f954a50d120dcec07_b.jpg

除了第一个leaq操作之外,其他的都会改变条件码。至于加载有效地址的真正含义,就是不进行把它翻译成地址进行内存寻址,而是直接处理,这就好像获得了这个位置的地址;其实这么想,你又没有把这个值当成地址进行寻址,当然就好像取地址操作咯!

加载有效地址

刚刚已经阐述过了,就和取地址符用法一样,不对源操作数解析得到的地址进行寻址,二是直接对解析得到的地址进行操作。

比如leaq (%rdi,%rsi,4), %rax的操作结果是R[rax] = R[rsi]*4+R[rdi]。而不是R[rax] = M[R[rsi]*4+R[rdi]]。

所以leaq指令的目的操作数必须是一个寄存器。leaq指令能执行加法和有限形式的乘法,在编译简单的算术表达式时,这是很有用的。

一元操作和二元操作

一元操作指令只有一个操作数,这个操作数既是源又是目的。所以它们就像C语言的自增,自减操作一样。

对于二元操作,一般都是第二个操作数 减去/加上/除以/乘以/异或/或/且第一个操作数。注意,如果第二个操作数是内存地址,那么必须先从内存中读出值,再把结果写回内存。

移位操作

移位指令很特别,因为它们只允许以特定的寄存器作为操作数。一般来说,选择长度为8的寄存器就可以了,因为移位数值一定是无符号数,所以8位最长可以移动255位,可是255位长的计算机还没出现,所以一定够用了。

在对位长为$w$位的数据进行移位操作时,移位量是寄存器的低$m$位决定的(也就是只有低$m$位的值会被当做移位量,哪怕存放移位量的寄存器是8位,也可能只读取低3位的值,此时最多移动7位),其中$m=log_2(w)$,高位会被忽略。

SAL和SHL执行逻辑移位,高位补0;SAR执行算术右移,补最高位,SHR执行逻辑右移,补0.移位操作的目的操作数可以是寄存器,也可以是内存地址。

特殊的算术操作

对于整数相乘,可能需要128位来存储结果,所以Intel提出了8字的大小,它是128位。来看一些处理整数乘法和除法的指令

v2-c634e4516bcdc4abc90883cf605d2af9_b.jpg

在此解释一下,imulq是处理有符号数的乘法指令,mulq是处理无符号数的,它们都把128位结果的高64位存在%rdx里,把低64位结果存在%rax里;至于操作数,那是乘以的参数,另一个参数存放在%rax中

对于除法,带i的是处理补码数的,不过在这里,给出的操作数是除数,而被除数则是以128位的形式给出,其中高64位存在%rdx中,%rax存放被除数的低64位;然后把商存在%rax中,余数存在%rdx中,这也是取模操作。

刚刚说除法的被除数是128位的,所以可以通过cqto指令完成,它把64位参数转成128位的,它隐式地读出%rax的符号位,然后把它复制到%rdx中去。

控制

机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据结果来改变控制流数据流

条件码

除了整数寄存器,CPU还维护一组单个位的条件码寄存器,它们描述了最近的算术或逻辑操作的属性。

CF:进位标志。最近的操作使得最高位产生了进位。可以用来检测无符号数的溢出。 ZF:零标志。表明最近的操作得出的结果为0。 SF:符号标志。最近的操作得到的结果是负数。 OF:溢出标志。最近的操作导致了一个补码的溢出——正溢出或负溢出。

刚刚说的那些一元二元操作以及移位操作,会改变条件码和寄存器的值,因为它们确确实实进行了算术或逻辑运算。

而有些指令只设置条件码而不改变寄存器,比如如下所示的CMP家族和TEST家族,它们仅仅根据两个操作数之差来设置条件码。除了只设置条件码而不进行目的寄存器的更新外,CMP和SUB指令是一样的。TEST指令的行为和AND指令一样,除了它们不会更改目的寄存器的值外。

v2-7f39cc71bddbadc2ea621823a9670030_b.jpg

访问条件码

条件码通常不会直接读取,常用的使用方法有3种:一是可以根据条件码的某种组合将一个字节设置为0或1,二是可以跳转到程序的其他某个部分,三是可以有条件地传送数据。

对于第一种情况,常用来实现SET指令,SET成员之间的区别就在于它们考虑的条件码组合是什么,这些指令的后缀指明了这一点,它们在此不作为寄存器长度要求来使用。

v2-07c1b4208e259ce66aeab70b0417c4c9_b.jpg

一般而言,SET指令存在于那些可以更改条件码的指令的后面,这样就可以读取到最新的值了,当然也可以不这么做。至于每个指令把目的操作数到底设置成了什么?可以通过条件码计算,也可以通过a-b(如果前面是CMP b,a指令(AT&T格式))或a+b(如果前面是TEST b,a指令(AT&T格式))的计算结果来判断。

表格中的第三组只作用于有符号数操作,第四组只作用于无符号数操作。至于究竟用哪个,编译器会在编译期根据源码进行判断选择。

注意,即使是计算发生了进位,溢出,依旧可以获得正确的值,具体原因在于对条件码的巧妙组合,书上有详细的示例,在此不再详述。

跳转指令

对于需要指定执行位置时,可以使用跳转指令。跳转的目的地通常使用一个标号指明。

对于jmp指令来说,它是无条件跳转,它既可以是直接跳转,也可以是间接跳转,此时跳转目标从寄存器或内存位置中读出。

v2-30a6d4f38a208076feaef8d0c91a72bd_b.jpg

条件跳转只能是直接跳转。这些指令的命名格式和跳转条件和SET指令是一致的。

跳转指令的编码

对于如何确定跳转的位置,有多种方法,但是最常用的是PC相对法。这种方法会把目标指令的地址和紧跟在跳转指令后面的指令的地址的差值作为值编码在跳转指令后面。

在执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址。

v2-13f4a0e064c53e316d667d65fb3ef26a_b.jpg

使用PC相对法进行编码,好处就是指令很简洁,而且目标代码可以不做修改就移植到别的机器上。

至于rep和repz指令,并没有实际用处,只是可以让AMD的U上的程序跑得更快,AMD自己说的。

用条件控制来实现条件分支

将条件表达式和语句(if-else)从C语言翻译成机器代码,最常用的方式就是结合有条件和无条件跳转。另一种方式使用数据的条件转移实现,这里是使用控制的条件转移实现。

对于C语言的if-else模块:

v2-2c21b0bf9e8ccb1f4864a88f93ad057a_b.jpg

对应的汇编模块如下:

v2-a4c89f0250c54be0cbffaddc9407a583_b.jpg

也就是说,汇编器会为$then-statement$和$else-statement$产生各自的代码块,它会插入有条件和无条件分支,以保证正确的执行。有条件的就是if那里,goto都是无条件的。

用条件传送来实现条件分支

对于使用条件控制来实现条件转移,在现代处理器中可能会很低效。而可以通过使用数据的条件分支来实现,后者先计算出所有的分支的结果,再在最后进行条件判断,输出分支结果之一。

首先来看看为什么条件控制可能是低效的。现代处理器使用了称为流水线的处理结构,使得一个时钟周期内可以处理多条指令的不同阶段操作。但是这种高效率依赖于流水线的满载,如果流水线空荡荡的那反而是降低了性能。于是需要CPU提前把指令填充到流水线,而有些指令没法提前填充,于是CPU使用它的预测算法,把那些未来的指令放到它可能被执行的流水线上。但是!一旦放错了,后果开销更大,此时因为指令的跳转,需要清空流水线,重新载入新的指令。当代CPU可以做到90%准确率,不过有时如果输入偏于随机的话,那么性能就下来了。

那么使用条件传送为什么就高效呢?因为它不需要预测结果,因为所有的可能结果全部完成运算,仅仅在最后输出时选择一个正确的结果就好,这就把预测取消了。同时因为取消了预测,此时控制流与输入数据无关,流水线一直是满载。

来看C语言对应的典型用法:

v2-4383ca4257afed7f1a7d5b547690b50a_b.jpeg

对应的汇编模板如下:

v2-24e2511663a26283ef025f2dd0fd0b17_b.jpg

呃,可能不太明显,反正就是对所有的分支求值,然后再根据条件判断选取哪个结果作为最终结果。而条件控制则是先判断走那个代码块,再运算结果。

不过这种条件转移的策略不是万全之计,对于可能导致错误的操作,以及复杂的表达式(因为每个分支都求,会花费很多的时钟周期,反而没起到优化的意思),它是不适用的。因此编译器只有在两个表达式都很容易计算时才会使用这种方法进行优化。

循环

对于C语言的循环操作,可以考虑使用条件测试和跳转组合起来实现。

先从do-while开始了解。

do-while的一般形式如下:

v2-798e009ee035503467d92db9adeffcd8_b.jpg

在这个循环里,循环体至少被执行一次,这也是它和while循环的区别。

它对应的汇编形式的模板如下:

v2-2f4515c24ccd52fb26bf22bcc9154d02_b.jpg

接下来是while循环,它对应的C模板是这样的:

v2-2af88f159ed5fc37ddaa0291d18f3316_b.jpg

而在汇编中的模板有两种,第一种是跳转到中间,它执行一个无条件跳转,然后跳转到循环结尾的测试,以此来执行初始化测试,它是这样的:

v2-6cc3185248cedd076a811e2d52200cfb_b.jpg

另一种方法是,guarded-do,首先使用条件分支,如果初始化条件不成立就直接跳过循环,把代码变成do-while循环。对应的汇编模板如下:

v2-6d6efaa28907d190c2b09279c2003628_b.jpg

这种方法常用于高优化级别的情况。

最后一种是for循环,它的C语言形式如下:

v2-121c31aa2f71e3fec428ef92d0674bf7_b.jpeg

抛开continue语句,它等效于下面的while语句:

v2-947f3bde2c68e111e2576d71b17a09cd_b.jpg

GCC为for循环生成的汇编代码是两种while循环之一,这取决于优先等级。

综上所述,C语言中三种形式的所有循环——do-while和for都可以用一种简单的策略来翻译,以此来产生包含一个或多个条件分支的代码。对于控制的条件转移机制提供了将循环翻译成机器代码的基本机制。

switch语句

汇编代码会考虑使用一种称为跳转表的数据结构来使switch语句实现更加高效,此时,跳转的时间复杂度和语句数目无关。

跳转表就像一个数组,索引是switch的索引值,数组值是代码块地址。这样就可以通过以switch值寻址数组的形式来完成控制跳转。

编译器会在切换情况比较多,且切换值比较密集的情况下使用跳转表。使用跳转表是一种非常高效的实现多重分支的方法。

过程

过程是软件中的一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能,然后可以在程序的不同地方调用这个过程。不同的编程语言中,过程描述不同,在C语言中,它们被称为函数,在Java和C++中,它们被称为方法,在进程中,它们可能被称为子进程,在多线程中,可能又叫子线程...

为了讨论方便,假设关于过程的调用有P过程调用Q过程,P被称为调用者,Q被称为被调用者。关于调用的细节,一般包含以下机制:

  1. 传递控制。在准备进入Q过程时,需要设置PC为Q的起始地址,设置参数(如果有的话),压入返回地址(其实就是call指令后面那条指令的地址),返回时设置PC为返回地址。
  2. 传递数据。P能向Q传递一个或多个参数。Q也能向P返回一个返回值。
  3. 分配和释放内存。在开始时,Q可能需要为自己的局部变量分配空间,而在返回前,又必须释放掉这些存储空间。

运行时栈

程序是运行在内存中的,一般来说,内存里包括程序正文,程序运行堆栈。程序正文就是可执行机器代码,程序运行堆栈就是程序运行需要的额外空间,比如内存分配,数组,局部变量等。

C语言的内存管理采用栈的结构,先进后出的顺序。程序可以通过这种方式来管理它所需要的存储空间,当P调用Q时,会把Q添加到栈顶,然后运行结束再释放。

当x86-64程序需要的空间超过寄存器所能满足的时候,就会在栈上分配空间,这个部分称为过程的栈帧。

来看一个一般的程序堆栈结构:

v2-714e654660b739c26ef80c64ff6cacfb_b.jpg

寄存器%rsp保存着栈指针,它指向栈顶(图中底部),而前面提过,内存向下增长,所以把%rsp减少一个值就完成了对于空间的分配,加上一个值就是对空间的回收。

此时来看看这张图的大致结构:较早的帧部分表示这是上一个过程的区域。注意到过程P的区域是返回地址到n参数,为什么是参数7开始呢?因为寄存器在作为参数传递使用时,最多只能传递六个,再多的参数只能使用栈来保存并传递了;因为过程P和过程Q的机器代码都是GCC生成的,所以它知道怎么在Q里访问栈里的参数。过程Q的被保存的寄存器部分用来保护那些需要被调用者保存的寄存器(详见后述)。局部变量区就是Q自己的局部变量了。参数构造区就是Q为其调用的过程准备的当寄存器不够用时的参数构建区域,和P区域的参数7-参数n一个意思。

转移控制

对于过程Q的调用是通过CALL指令实现的。该指令后面跟着Q的地址。此时,过程P把PC设置成Q的地址,然后CALL指令把它后面的指令的地址压入栈中,这个就是返回地址,当Q调用ret指令进行返回时,会弹出这个返回地址,设置到PC上。

v2-c40f02d549b19a206a323aec27586e5c_b.jpg

值得注意的是,CALL指令后面的地址,既可以是直接的,也可以是间接的。

来看一个CALL实例加强理解:

v2-e146a9a98ab2a54422821fcdff6cf937_b.jpg

数据传送

当过程P调用过程Q时,P的代码必须首先设置参数,如果参数数目小于等于6个,直接设置在寄存器就可以;而Q在返回到P之前,必须首先设置%rax来实现返回值。

在x86-64架构中,寄存器的使用是有顺序的,而且它们的名字取决于参数的大小。

v2-b2cf98a7b3e8d29a037a6f25e550d220_b.jpg

如果参数数目超过了6个,超过的部分要使用栈来传递,参数7位于最下面,通过栈传递参数时,所有的数据大小都必须向8的倍数对齐。

一旦参数到位,就可以调用CALL指令来把控制移交至Q了。

栈上的局部存储

有时寄存器可能不能够存储参数,这时就需要使用栈上的空间来完成存储。常见的情况包括:

  1. 寄存器不足以存放所有的本地数据
  2. 对一个局部变量使用取地址符,就一定需要栈,因为寄存器没有地址这一说。
  3. 对于局部变量时数组或结构的情况,因为访问它们需要产生地址引用。

一般来说,过程通过减小栈指针来实现空间的分配,分配的结果作为栈帧的一部分。

寄存器的局部存储空间

寄存器是唯一被所有过程共享的资源(虽然内存也是,但是每个过程都有自己独立的空间,所以不算共享,他们只是共享物理内存这个设备而已,设备里的东西不共享)。

为了防止P的寄存器被Q过程覆盖,x86-64规定了一组规则,把寄存器做了分类。根据惯例,寄存器%rbx, %rbp, %r12~%r15被划分成被调用者保存寄存器;而除了这几个寄存器,以及栈指针%rsp之外的寄存器,都是调用者保存寄存器。什么意思呢?假如P调用了Q,那么被调用者保存寄存器要求Q对这几个寄存器进行保存,以便在返回到P时,这几个寄存器的值不变。后者要求P提前保存好相关的寄存器,因为Q可能会产生修改。

递归过程

介于每个过程调用在栈中都有它们自己的私有空间,因此多个未完成调用的局部变量并不会相互影响。这就为递归的实现提供了可能。

递归地调用一个函数与调用其他函数是一样的。栈机制就可以保证每个函数调用都有它自己私有的状态信息存储空间。比如局部变量,返回地址等。

栈分配和释放的规则很自然地就与函数调用-返回的顺序一致。即使对于更加复杂的情况,甚至是相互调用也可以适用。

数组分配和访问

C语言的数组是一种将标量数据聚合成更大的数据类型的方式。

基本原则

在讨论之前,来约束一些规则:

$T A[N];$

起始位置是$x_A$,$L$是$T$类型的大小。这个声明有两个效果,首先,它在内存分配了一段长度为$L cdot N$的连续空间。其次,它引入了标识符A,可以用来当作指向数组开头的指针。这个指针的值就是数组首元素的地址,也就是这里的$x_A$。数组元素i(从0开始)的地址是$x_A+L cdot i$。

x86-64的数组引用指令可以简化对于数组的访问。假设数组的首地址放在%rdx中,而下标i放在%rcx中。那么指令

v2-bad2e42815b83d12e39f418172d70680_b.jpeg

会把元素i的值放在%eax中。其中的常数4代表数据大小,如果是int64类型,那么可以改成8。伸缩因子1,1,4,8覆盖了所有基本数据类型的大小。

指针运算

C语言允许对指针进行运算,运算的结果会根据该指针引用的数据类型的大小进行伸缩。如果p是一个指向类型T的指针,p的值是$x_p$,那么表达式p+i的值就是$x_p+L cdot i$,这里$L$是数据类型T的大小。

对于指针操作,有取地址符&和解引用符,在数组里,可以有些骚操作。比如A[i]等价于(A+i)。来看一下详细的操作吧!

v2-85e8c893bdc9a9521b8a2fbd045f6fda_b.jpg

注意到GCC对于在指针上的加法进行了重定义,什么意思呢?看最后一个。经过本人测试,&E[i]得到的是地址,它确实与E相差了4*i个但是连起来用就是i,因为GCC重新解释了这个操作。

嵌套的数组

最典型的就是二维数组,一般来说,GCC是把多维数组映射为一个更长的一维数组来实现的。来看一个二维数组声明:

$T D[R][C];$

则它的数组元素D[i][j]的内存地址为$&D[i][j] = x_D + L cdot (C cdot i + j)$,这里$L$是数据类型$T$的大小。

对应的x86-64汇编代码可能复杂一些,因为需要转换成一维数组进行处理。

定长数组

C语言编译器能够优化定长多维数组上的代码。

变长数组

对于变成数组,编译器会尝试在计算数组位置时,把数组大小换成常数,这样就可以使用常数运算加快速度。否则必须调用乘法指令进行计算,而直接调用乘法指令是一件耗时的事情。不过对于必须在运行时才能确定的大小来说,确实只能使用乘法,但是对于循环或者一些类似的结构,则可以这么考虑。

异质的数据结构

C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制,它们分别是结构联合

结构体

结构的所有组成部分都存在内存中的一段连续的区域内,而指向结构的指针就是指向它的第一个字节的地址,编译器负责维护每个结构类型的信息,对于结构元素的引用,会计算每个元素的偏移量,然后在内存中实现对其的引用。

这是一个普通的结构体:

v2-5c87f9d8bf5cdabc5f429cc87bcb7390_b.jpg

这是该结构体在内存中的表示:

v2-bf4a78368488d132f353030866585b6f_b.jpeg

要想产生对一个结构内部对象的引用,只要把结构指针加上该元素在内存字段中的偏移量即可。结构体各个字段的选取完全是在编译时处理的,机器代码不包含任何有关字段声明或字段名字的信息。

联合

数据对齐

有时候,CPU访问内存不是一字节一字节访问的,虽然这是最小的内存可寻址单位不假,但是这样访问未免过于低效,于是很多时候都是4字节,8字节甚至16字节访问。

当使用结构体时,这就引入了一个问题,就是结构体的元素的数据长度大小不一,导致某些数据横跨多个“可访问区”,比如CPU访问是0-3字节,4-7字节这样,某个结构体的数据分布在2-5字节,这样不可避免地访问两次内存才能完全读取。

所谓的数据对齐就是在短的数据类型后面填补空位置,让它后面的数据在内存中是对齐于内存“可访问区”的。 不仅是对结构体的元素的对其,结构体也可以对其,补齐结构体后面的空间,让每个结构体的地址空间开始于“可访问区”。

在机器级程序中将控制与数据结合起来

理解指针

这没什么好说的。每个指针对应于一个类型,这个类型就是这个指针指向的类型,但是指针自身就是一个类型,所以不管是什么指针都是8字节长(64位机)。指针的类型仅仅说明指针指向的类型,和指针自身无关。

把一个指针转换成另一个类型仅仅改变了指针的类型,而不会改变指针的值。

指针也可以指向函数,这就是函数指针,所以在《现代操作系统》篇章里提到过OS给用户进程提供的是一个包含系统调用的一组数据,就是一个由多个函数指针组成的容器。

应用:使用GDB调试器

内存越界引用和缓冲区溢出

数组是保存在栈里的,所以对数组的越界访问可能会破坏栈结构。想一想,如果某时某个数组长度为10而访问其第12个元素,就有可能访问到上一个过程设置的返回地址,再修改就会造成当前调用返回到未知区域。

缓冲区溢出的一个更加致命的使用就是使得程序调用一个它本来不应该调用的函数,这也是一种常见的计算机网络攻击的方法。

对抗缓冲区溢出攻击

缓冲区溢出攻击是很可怕的,所以应该采取措施来进行防范。

  1. 栈随机化。这种方法旨在随机化每次栈的起始位置,来让恶意程序无法推算出栈的位置。不过攻击代码还是可以通过多次执行nop指令(只是单纯的递增程序计数器而不执行行为)来推算栈的位置。
  2. 栈破化检测。在缓冲区末尾和其他栈区域之间添加一个特殊的值。程序通过检测这个值与内存中的只读的备份值是否一致,如果不一致证明发生了不被允许的访问。此时程序终止。这个值称为金丝雀值,因为早期金丝雀用于检测矿洞的有毒气体。所以在汇编代码里看到的%fs:40指令就是通过段寻址的方式从内存读入值设置金丝雀值。
  3. 最后一种方法是通过限制代码的可执行区域来实现的。这种方法把内存区域划分成可执行区,可读取,可读写区。只有可执行区的代码才是可执行的。这样可以限制程序对于栈的更改触及到可执行代码。

支持变成栈帧

这个功能的主要意义是对于可变数组,因为可变数组的大小是在运行时才能确定。所以对于堆栈空间的分配可能是可变的。

为了管理可变栈帧,x86-64使用基指针(%rbp)来管理。当使用基指针时,栈的使用情况和下图类似:

v2-08bf31c98f7b5511996fbb17cb2026a3_b.jpg

程序必须把%rbp的值保存在栈中,因为它是一个被调用者保存寄存器。然后把%rbp指向此时的位置(此时还未为数组等可变数据分配空间),然后它就不动了,至于引用局部变量,保存的寄存器等栈里面的数据,就用它们相对于%rbp的偏移量来实现。

为什么不同栈指针%rsp呢?因为它忙着指向栈顶,而此时栈顶的大小随每次运行的输入而不一致,所以不能通过栈顶指针的偏移量来引用局部变量等栈数据。

说白了就是,程序不知道某个变量当前和栈顶指针的偏移量是多少,因为中间隔着一个长度可变的数组,遂使用一个不变的,位置固定的基指针来引用局部变量它们。而基指针位置一般都和返回地址隔了一个位置这样。

v2-51cf5d686ae35e2a5d4efeae6f7b7b14_b.jpg

这是一个常见的实例,就此分析一下(因为这个实例属实有点东西,所以读者要是觉得我说的不对就直接疑惑就好了,疑惑完之后请自行解决): 数组元素类型的长度是8字节。注释上有的我就不解释了,那个没啥好说的。现在来看看这个5,6行,第5行不难理解,%rax的值为n*8+22,为什么是+22呢?看后面;第6行把%rax=%rax^(-16),而-16的二进制是1...10000,这个的结果是把rax变成了一个8的倍数,且最少比数组实际需要的空间大8。第7行分配空间没什么好说的。第8行的意思是%rax=%rsp+7,为什么减少了7个呢?为了节省空间,因为刚刚分配多了。第9行逻辑右移3为,相当于除以8并向下取整得到%rax,猜猜看这是什么意思?再看第10行,数组首地址等于%rax*8,哦!第9行在对齐呢!这时就可以说得清了,多出来的$e_1$和$e_2$是用来补全对齐后的数组的空间。只是这次的对齐是前后都有补全。

最后,在过程返回之前,记得把%rbp的值归位。

浮点代码

处理器的浮点体系包括多个方面: 1. 如何存储和访问浮点值。通常是通过某种寄存器完成的。 2. 对浮点数据操作的指令。 3. 向函数传递浮点数据以及从中返回浮点数据。 4. 函数调用过程中保存寄存器的规则。

说到浮点计算,就会提及x8664架构的浮点指令集。为了支持浮点计算,Intel和AMD对指令集追加了扩展,比如现在的AVX2标准。

直接看浮点数寄存器吧!

v2-fb24e426db2e23c3c4bd07bbcf76239a_b.jpg

其中每个XMM寄存器都是对应的YMM寄存器的低128位。

浮点传送和转换操作

来看一些浮点数传送指令:

v2-1d667b1fca8e93e74dc1ccda4548d8ed_b.jpg

对于传送数据来说,程序复制整个寄存器或者只复制寄存器低位值既不会影响程序功能,也不会影响执行速度。所以使用这些指令还是针对标量数据的指令没有实质上的差别。

把浮点数值换成整数时,指令会执行截断,把值向0进行舍入,这是C和大多数其他编程语言的要求。

来看看双操作数浮点转换指令:

v2-5718b4902aaa60a332ac167ff12d8c09_b.jpeg

这是三操作数浮点转换指令:

v2-4ea470bc9cadc895f635f710a4e06b48_b.jpg

在这里,可以忽略第二个操作数,因为它的值只影响结果的高位字节。

过程中的浮点代码

来看看过程调用中的浮点数操作: 1. XMM寄存器%xmm0~%xmm7,最多可以传递8个浮点数。按照参数列出的顺序使用这些寄存器,可以通过栈传递额外的浮点参数。 2. 函数使用寄存器%xmm0来返回浮点值。 3. 所有的XMM寄存器都是调用者保存的。

当调用过程时,参数到寄存器的映射取决于它们的类型和排列的顺序。

浮点运算操作

来看一些浮点数运算指令:

v2-4d67c23a9a79cfd95985e9434f04a409_b.jpg

在这些运算中,第二个源操作数和目的操作数都必须是XMM寄存器

定义和使用浮点数

在AVX中,没法使用立即数作为操作数,必须把常数放进栈或寄存器中来进行操作,这和整数运算不同。

在浮点代码中使用位级操作

来看看对于浮点数的位级操作:

v2-bc233df2d0586a9f83ad862b83328cc7_b.jpg

这些操作都作用于封装好的寄存器,所以他们会更新整个目的XMM寄存器,对两个源寄存器的所有位都实施指定的位级操作。

浮点比较操作

来看看AVX2提供的浮点数比较操作:

v2-e7b8f6943bcaa4255908513ba9ed7890_b.jpeg

其中,参数S2必须在XMM寄存器中,而S1既可以在XMM寄存器中,也可以在内存中。

浮点数比较指令会设置三个条件码:零标志位ZF,进位标志位CF,奇偶标志位PF。其实整数运算也有奇偶标志位,只是不常见;当两个浮点操作数中的任一个是NaN时,就会设置此位。

当操作数出现NaN时,会出现无序的现象,可以通过奇偶标志位发现这种情况。jp指令是条件跳转,跳转条件就是运算发生了无序的结果。除此之外,进位和零标志位都和对应的无符号数比较一样。

对浮点代码的观察结论

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值