Assembly code汇编代码,寄存器register,操作数operands与机器代码machine code

这篇文章写一下X86系统中的ISA(instruction set architecture)指令集架构,machine code以及汇编代码assembly code相关的内容。

一个简单的overview:

不用一下子全看得懂,先有个大致的概念,因为后面还会细讲其中一些部分。

总的来说,程序执行时,CPU中存放了一些最重要的部分,如PC,是program counter,用来存储下一个指令,在 x86-64 中称为 "RIP"(instruction pointer);用来进行最近一次逻辑操作和条件判断的condition codes;存储大量临时变量的Registers。这些是我们运行程序时最需要放在“手边”的数据,就都放在CPU里面。

当然CPU大小是有限的,程序的主要数据、代码以及支持递归跳转的堆栈都放在内存里,也就是图中Memory的部分。内存可以理解成“字节寻址数组”,我们都是通过地址来访问内存中的数据。不像int或者double类型的数据,addresses往往是无类型的指针 untyped pointers。取决于系统的不同,address大小也不同,一般来说是64位的。

寄存器一览:

 这些就是x86系统中的寄存器名称(%rax,%rbx,%rcx……),它们分别具有不同的职责,比如%rax负责存放返回值,%rsp栈顶指针stack pointer等。不用太纠结这些寄存器为什么叫这些名字,因为也没有啥逻辑,统一归为历史原因就好。

在上图中,这些寄存器都是64位的,这是x86-64系统下的寄存器。但在早期如IA32系统中,寄存器只有32位,其实也就是当前64位寄存器的低32位,它们又有自己本来的名字(%eax,%ebx,%ecx……)

这些32位寄存器的低16位,低8位也还有细分,看图对照意会即可。

从.c文件到最终可执行文件

下面是当我们写好一个c程序后,它是如何一步步变成最终可执行文件的。

上面讲了那么多寄存器名称有啥用?马上就知道它们会在哪用到。对于我们写的普通程序,可以通过一步步的指令逐步将它变成汇编代码assembler code,机器代码machine code以及最终的计算机执行的二进制代码。寄存器的名称可以在assembly code中看到。

 寄存器与内存之间的值传递mov操作

Assembly code中表示值传递操作用的是mov命令,即: movq Source, Dest。这里的q代表着移动8个字节(64位),movl就是4个字节,低32位。一共有三种操作数类型:

  • Immediate value :是常量整数数据,用于表示固定值。如包括 $0x400 和 $-533,用美元符号($)作为前缀。
  • 寄存器 (Register):如 %rax 和 %r13,如果写寄存器,就指的是寄存器里面的值。
  • 内存 (Memory):内存操作数表示内存中的一个地址。在最简单的情况下,可以使用一个寄存器作为基址,例如 (%rax)。这表示 %rax 寄存器中存储的地址处的8个连续字节,加一个括号就像是在语言中加一个*,表示取该地址对应的值。

下面是mov操作的一些组合示例,对比assembly code和C code,就很好看懂mov操作了。

要注意的是,这里默认mov的第一个参数是source,第二个的destination,但有时根据机器的不同这个顺序是反过来的。 另外我们可以发现,没有从memory到memory的一步操作,啥操作都要经过(包含)寄存器。上面图中比较难懂的就是加了括号代表取该地址对应的值,所以对应c代码里也是用了*号。即(R)代表 Mem[Reg[R]]。

括号两边还有其他扩展形式,如:

D(R)  代表  Mem[Reg[R]+D], movq 8(%rbp),%rdx,意思就是把寄存器%rbp中的地址先加8,再取结果地址的值,赋给寄存器%rdx。D在这里就是一个偏置displacement。

D(Rb,Ri,S) 代表 Mem[Reg[Rb]+S*Reg[Ri]+ D]。其中D:常量“位移”,可以是1、2或4个字节。Rb:基址寄存器,可以是16个整数寄存器中的任意一个。Ri:索引寄存器,可以是除了%rsp之外的任意寄存器。S:缩放因子,可以是1、2、4或8。这些数字允许我们访问连续内存块中的元素,例如数组或结构体。

举个例子就懂了:比如一个数组a存放了3个double类型的数据。数组a的地址在Rb里,那么(Rb,1,8)就是先计算地址:Rb+8*1 = Rb+8,因为double类型位8个字节,所以算出来的结果正好是索引为1的元素的地址;因为右括号,再取这个地址对应的值,就得到了数组中的第一个元素a[1]。这就是为什么S的取值基本为1、2、4或8,因为都是基本数据类型的长度。

一些其他情况和例子:

leaq操作

lea与与mov操作的不同在于它不需要根据地址去访问内存。比如:

leaq (%rdi,%rdi,2), %rax # t = x+2*x

这个例子,就是直接把寄存器%rdi中的值乘以3然后给%rax。不用根据计算结果去内存中找值再给%rax。

其他运算操作:

只要是能在c程序里写出来的运算,加减乘除,左移右移,位运算等,在汇编指令中自然都能表示:

addq Src,Dest //Dest = Dest + Src
subq Src,Dest //Dest = Dest − Src
imulq Src,Dest //Dest = Dest * Src
salq Src,Dest //Dest = Dest << Src Also called shlq
sarq Src,Dest //Dest = Dest >> Src Arithmetic
shrq Src,Dest //Dest = Dest >> Src Logical
xorq Src,Dest //Dest = Dest ^ Src
andq Src,Dest //Dest = Dest & Src
orq Src,Dest //Dest = Dest | Src

incq Dest //Dest = Dest + 1
decq Dest //Dest = Dest − 1
negq Dest //Dest = − Dest
notq Dest //Dest = ~Dest

 一个例子:一个交换函数与其汇编代码:

void swap
(long *xp, long *yp)
{
long t0 = *xp;    //movq (%rdi), %rax
long t1 = *yp;    //movq (%rsi), %rdx
*xp = t1;        //movq %rdx, (%rdi)
*yp = t0;        //movq %rax, (%rsi)
}                //ret

从assembly code来看,其实就是先把两个地址中的值取出来放在两个寄存器rax和rdx中,再把这些值换个位置放回两个地址。这里可以发现,对于传入的参数xp和yp,默认是放在了寄存器%rdi和%rsi中,算是这两个寄存器的常见用途:用来存放函数参数。

另一个例子:一个运算函数与其汇编代码:

long arith
(long x, long y, long z)
{
long t1 = x+y; //leaq (%rdi,%rsi), %rax # t1
long t2 = z+t1; //addq %rdx, %rax # t2
long t3 = x+4; //leaq (%rsi,%rsi,2), %rdx # t3
long t4 = y * 48; //salq $4, %rdx # t4
long t5 = t3 + t4; //leaq 4(%rdi,%rdx), %rcx # t5
long rval = t2 * t5; //imulq %rcx, %rax # rval
return rval; //ret
}

这里必须要强调的是,这里我给的例子因为比较简单所以实际代码和汇编代码能逐一对应,但实际复杂情况下,编译器得到的assembly code并不一定能和原来代码逐行对应,这里涉及到编译器如何编译、优化以及“按自己的方式”生成assembly code。

汇编器assembler和连接器linker,以及disassemble

我们有了汇编代码(.s文件),可以通过汇编器,让它变成机器代码(.o文件)。

  1. 汇编器(Assembler):

    • 将汇编语言源代码(.s 文件)翻译成目标文件(.o 文件)。
    • 为每条指令生成二进制编码。
    • 生成可执行代码的几乎完整映像,但不包括不同文件之间的代码链接。
  2. 链接器(Linker):

    • 解析不同文件之间的引用。
    • 将目标文件与静态运行时库(如 malloc 和 printf 等函数的实现)合并。
    • 链接器还处理动态链接库,这些库在程序开始执行时进行链接

       3. 反汇编Disassemble (图中我画出的向上的红色箭头)

                反编译就是对于已经生成的目标文件进行操作,从而得到汇编代码进行分析。 比如对于sum.o文件,通过命令:

objdump –d sum

就可以得到类似于这样的结果,左边是原目标文件(machine code)的内容,右边是可以看懂的汇编代码:

0000000000400595 <sumstore>:
400595: 53 push %rbx
400596: 48 89 d3 mov %rdx,%rbx
400599: e8 f2 ff ff ff callq 400590 <plus>
40059e: 48 89 03 mov %rax,(%rbx)
4005a1: 5b pop %rbx
4005a2: c3 retq

这里再补充一下,其实原本的机器代码是下面这样,左边连续的数组其实就是指令的地址,右边则是16进制下表示的操作指令。

400595: 53
400596: 48 89 d3
400599: e8 f2 ff ff ff
…………

可以看到十六进制的53占用了一个byte,所以下一个指令的地址只+1,而紧接着48 89 d3占了三个byte,所以第三行指令的地址开头+3。当然,具体这些十六进制啥意思是很难读懂的(这里其实有专门整理好的表格来对照,比如汇编代码下的mov %rax, %rdx 对应16进制的machine code下的什么之类的,在attack lab中就是通过那个对照表来找到我们需要植入什么机器代码从而实现相关操作),这就是为什么我们要disassemble一下,得到汇编代码后至少能看懂一点。

gdb是一个常用的反汇编和debug工具,在CSAPP的bomb lab中,就是使用gbd来反汇编现有的可执行bomb文件,从而分析程序到底在干啥。

小结

这篇文章讲了C程序,汇编与机器代码的不同,介绍了寄存器、操作数和移动,运算指令等。简单入门这些知识对于深入理解计算机底层原理和编写高效代码还是挺重要的。

猜你喜欢

转载自blog.csdn.net/weixin_44492824/article/details/131296573