本章主要介绍了计算机中的机器代码——汇编语言。当我们使用高级语言(C、Java等)编程时,代码会屏蔽机器级的细节,我们无法了解到机器级的代码实现。
那么,既然有了高级语言,我们为什么还需要学习汇编语言呢?学习程序的机器级实现,可以帮助我们理解编译器的优化能力,可以让我们了解一个程序是如何运行的,哪些部分是可以优化的;当程序受到攻击(漏洞)时,都会涉及到程序运行时控制信息的细节,并且很多程序都会利用系统程序中的漏洞信息重写程序,从而获得系统的控制权(著名的蠕虫病毒就是利用了gets函数的漏洞)
Intel
处理器系列俗称 x86
,经历了一个长期的、不断进化的发展过程。从16位到32位的x86再到64位的x86-64。
16位32位64位是指操作系统所能支配的内存寻址空间, 就是说该操作系统所能支持的最大内存限制,分别是2的16次方,32次方,64次方,位数越高,该操作系统支持的数据吞吐量越大。
Linux
系统默认的编译器时 GCC C
编译器。编译器选项 -Og
会指示编译器使用会生成符合原始 C
代码整体结构的机器代码的优化等级,通常使用 -O1
或 -O2
选项。但是优化等级越高代码越变形,不易于理解编译代码和原始代码之间的关系。所以用-Og。
例:
上图中左上边是mstore.c文件,左下是Linux的编译指令,
其中-S指的是生成编译文件.s ,
右边是生成的编译文件.s
我们再来回顾一下第一章学过的计算机的编译系统:
上图可知,如果我们输入指令:
linux>gcc -
Og -c mstore.c
那么生成的就是机器代码,由于机器代码都是二进制文件,我们没法查看,所以这里引入一个新名词:反汇编工具——objdump
linux>objdump -d mstore.o
寄存器是在CPU中暂时的存数据来执行操作。
存储器是存储数据,寄存器是暂时存储且操作数据。
一个x86-64的CPU包含一组16个存储64位值的通用目的寄存器,这些寄存器用来存储整数数据和指针。如下图:
%rax:保存函数的返回值
%rbx:被调用者保存意思就是被调用的函数来执行存储原先寄存器数据的义务
%rbp:基指针,用来存储栈中一个位置的指针
%rsp:保存程序栈的结束位置
这里寄存器其实就是介绍了多大的数据存储需要怎样的寄存器,现在我们需要知道这些数据被存放好后,到底是如何找寻的。而这种被指令拿来调度的数据被称作操作数,指令执行时要有一个或者多个操作数,操作数被调用又要找到存放的位置。
介绍完寄存器,我们再回到汇编代码:
可以看到,汇编代码分成左右两部分,左边是 操作码 , 右边是 操作数:
操作数又分为:
那么计算机是如何计算地址的呢?
在计算机中,我们常把内存抽象成一个字节数组,在查找内存的过程中我们需要知道内存的起始地址以及数据长度,通常我们省略数据长度b,我们用M[addr]来表示内存引用。
以下Imm(rb,ri,s)为一个最常见的内存引用:
基址寄存器,变址寄存器 :都是用来存放偏移量的。
而有效的地址addr = Imm + R[rb] + R[ri] * s
所以根据Imm(rb,ri,s) , 我们可以知道内存引用的内存位置为 M[Imm + R[rb] + R[ri] * s]
注意⚠️:比例因子s 的值必须为 1 , 2, 4 ,8。这是因为数据类型的字节倍数。
以下为全部有效地址的计算方式:
以上我们介绍了如何找到存储的数据。
接下来我们学习数据传输的过程中,计算机是如何通过指令来进行的。
我们介绍一个数据传输常用指令:数据传送指令---MOV类(将数据从源位置复制到目的位置)
对于MOV操作码 ,其还有两个操作数——源操作数 、 目的操作数
其中最后一个字母与数据大小有关,并且不可以 源操作数和目的操作数都是内存引用。
MOV指令只会更新目的操作数制定的那些寄存器字节或内存位置。唯一的例外是movl指令以寄存器作为目的时,在源操作数是32位且目的寄存器是64位的情况下,它会把该寄存器的高位4字节(位64 ~ 位32)这部分设置为0。这是x86-64的规则
前面的位置扩展为0
前面的位置扩展为符号位
C语言中所谓的“指针”其实就是地址。间接引用指针就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器。
栈区会在函数执行时通过扩容或者释放空间进行函数调用。那么如何使得栈帧大小变化?压入和弹出栈的指令就是关键。
将一个四字值压入栈中,分为两步,首先先将栈指针减 8,然后将值写到新的栈顶地址,因此 pushq
指令等价于下面两条指令:
subq $8,%rsp //递减堆栈指针
moq %rbp,(%rsp)
等于是闭口屁股在栈底(也就是最上面)
以下为要介绍的相关指令:
其实是mov的变形,mov是将原操作数复制到目的操作数,而leaq 指令可以简洁地描述普通的算术操作,如果寄存器 %rdx 的值为 x,那么指令 leaq 7(%rdx, %rdx, 4),%rax
将设置寄存器 %rax
的值为 5x+7
。目的操作数必须是一个寄存器。
一元操作:++ -- 之类的操作,操作数只有一个,这个操作数既是源操作数又是目的操作数(可以是寄存器或内存引用)
二元操作:+ - * / 之类,操作数有两个,源操作数与目的操作数计算后结果存于目的操作数(所以二元的目的操作数不能是立即数)
左操作数k是移位量,移位量可以是立即数,或者在单字节寄存器 %cl
中,移位量是由 %c1
寄存器的低 m
位决定的。例如当寄存器 %c1
的十六进制位 0xFF 时,指令 salb
会移 7 位,salw
会移 15 位,sall
会移 31 位,salq
会移 63 位。
右移分为逻辑右移和算数右移,区别就是保不保留符号位。
我们上面讲的基本都是直线运算,而在C语言中,我们还学过条件运算,那么CPU是如何进行条件运算的呢?
在CPU中,ALU是专门进行逻辑和算数运算的,它维护着条件寄存器,我们可以通过检测条件码寄存器来执行条件分支指令。
CF
: 进位标志。最近的操作使最高位产生了进位,可用来检查无符号操作的溢出。ZF(zero)
: 零标志。最近的操作得出的结果为 0。SF(sign)
: 符号标志位。最近的操作得到的结果为负数。OF(overflows)
: 溢出标志。最近的操作导致一个补码溢出-正溢出或负溢出。特别的,leaq指令不会对条件码进行改变,因为该指令用于计算地址,不需要进行逻辑判断。但是,其他的算数和逻辑计算会影响条件码,使得后续CPU知道我们计算后的情况。
以下有两个操作数只是用来通过计算改变条件码,对其他的数不造成改变。
指令cmp会根据( x - y )来设置条件寄存器中的符号标志SF 和 溢出标志OF 。
上面我们了解了基本的条件码,那么接下来我们要学会如何访问条件码(即知道条件码中的值),以此来进行判断条件。
条件码不会被直接读取,通常有三种方法用来使用:
先介绍2、3,其实这两个不需要我们人为调用,当计算结果需要进行条件码判断时,系统自动提取数据进行判断。
而1方法,其实是有一个SET指令,这个指令是一个大全,连接算数逻辑运算和条件码的桥梁,只要我们判断后,通过SET就能知道运算结果是怎么样的,这背后的原因就是SET帮我们访问了条件码的组合,返回给我们人能看的到的结果0/1。
如下图的sete:
跳转指令会导致执行切换到程序中的一个全新的位置,这些跳转的目的地通常用一个标号指明。
以下为跳转指令jump图。
例如下图的jl,当x < y时,程序跳转到.L4执行,反之程序就顺序执行。
条件控制实现:
实现的思想很简单,就是通过控制进行选择,满足条件走哪边
判断时,先比较两个操作数,设置对应的条件码,通过条件码的信息,做出跳转的指令。
实现条件操作的传统方法是通过使用控制的条件转移。当条件满足时,程序沿着一条路径执行,而当条件不满足时,就走另一条路径。这种机制虽然简单通用,但是在现代处理器上,它可能会非常低效。所以,我们换种实现方法——条件传送:
至于为什么条件传送比条件控制高效,这就涉及到第四章流水线的问题了。
C 语言提供了多种循环结构,即 do-while, while 和 for 汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。
do-while和while的区别只是跳转(jg)的位置不同
#2.
switch(开关)语句可以根据一个整数索引值进行多重分支在处理具有多种可能结果的测试时,这种语句特别有用。不仅提高可读性,还通过跳转表使得代码更高效。
写好switch,汇编会开辟一个跳转表,不同的条件对应不同的跳转,类似一个数组,每个条件是索引,它以空间换时间,在每个分支都开辟直达的指令,从而不需要预先猜测,直接跳转即可。
C 中的函数、Java中的方法都可以称为过程,过程是一个抽象(接口)。
编程语言过程调用机制的一个关键特性在于使用了栈数据结构提供的先进后出的内存管理原则。
传递参数:当一个函数的参数量大于6时,大于6的部分就要用栈来传递。且传输的参数需要进行字节对齐(比如说int和char在一起,那么栈就需要多留3个空位给char对齐,4对4),局部变量不需要字节对齐。
结构体也一样要字节对齐。
同时,为了提高系统程序的安全性,栈的地址是不同的,随机的,并且栈为了避免缓冲区
溢出,编译器会在产生的汇编代码中加入一个检测栈缓冲区越界的栈保护者(就是在缓冲区和栈保存的值之间存储一个特殊值),名为金丝雀值(每次程序运行时随机产生的),我们可以在函数返回之前判断金丝雀值是否被改变来判断缓冲区是否越界。
参考:
csapp第三章(1) --- 程序的机器级表示_csapp条件码_哈里沃克的博客-CSDN博客
深入理解计算机系统-第3章程序的机器级表示笔记-云社区-华为云