程序的机器级表示
1.1程序编码与汇编
1.1.1C语言程序的编译系统
一个C语言程序需要经过四个阶段才能变成一个可执行的二进制代码。
预处理阶段:预处理器cpp根据编译文件以“#”开头的命令,读取系统头文件stdio.h(.h结尾的表示头文件,.c表示可执行文件)的内容,并把它插入到程序文本中,得到一个新的文件。
编译阶段:编译器ccl将预处理后的文件翻译成.s结尾的文本文件,里面包含一个汇编程序。(linux命令:gcc -Og -s hello.c)
汇编阶段:汇编器ss将汇编程序翻译成二进制的机器语言,并把结果保存在以.o结尾的二进制文件中。(linux命令:gcc -Og -c hello.c)
链接阶段:链接器ld将程序用到的C语言类库的函数汇编后的代码合并到hello.o,得到可执行的目标文件。(linux命令:gcc -o hello hello.c)
对二进制文件进行反编译:objdump -d hello.o
随着高级编程语言的到来,我们只需要关注语言本身,其他的交给编译器我们就可以写出不错的代码,但是作为一个好奇心很强的程序员了解程序是怎么运行的,对我们的学习会有很大的帮助。
Inter使用术语“字(word)”表示16位数据类型,因此32位数为“双字”,64位数为“四字”。
大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如数据传送指令有五个变种:movb=传送字节、movw=传送字、movl=传送双子、movq=传送四字、movbsq=传送绝对的四字。
寄存器
最初的8086中有8个16位的寄存器,即上图的%ax到%bp。扩展到IA32架构时,这些寄存器也扩展到32为位寄存器,标号从%eax到%ebp。扩展到x86-64后,原来的8位寄存器扩展到64位,标号从%rax到%rbp。除此之外还增加了8个新的寄存器,命名为%r8到%r15。
在再常见的程序里不同的寄存器扮演着不同的角色。其中最重要的是栈指针%rsp,用来指明运行时栈的结束位置。
指令操作数的寻址
大多数指令有一个或者多个操作数,指示该操作的元数据,以及放置目标的位置。x86-64支持多种操作数格式,源数据可以以常数形式给出,或是从寄存器或者内存中读出。根据读出位置的不同操作数的寻址大致分为三种形式。
立即数寻址:用来表示常数。在ATT格式的汇编代码中,立即数的表示方式为‘$’后面跟一个标准C语言表示的整数。
寄存器寻址:表示某个寄存器的内容,汇编中使用%+寄存器表示。
内存引用:根据计算出来的地址访问某个内存地址。
1.2汇编指令
数据传送指令:将数据从一个位置复制到另一个位置的指令。
S表示源操作指定的值是一个立即数,存储在寄存器中或者内存中。
D表示目的操作数指定一个位置,要么是一个寄存器或者是一个内存地址。x86-64加入了一条限制,传送指令两个操作数不能都指向内存位置。
压入与弹出栈数据
栈是一种数据结构,可以添加和删除数据,不过要遵循“后进先出”的原则,通过push操作将数据压入栈中,通过pop操作删除栈中数据。栈可以实现为一个数组,总是从栈的一端插入和删除元素,这一端称为栈顶。在x86-64中,程序栈存放在内存中的某个位置。
在内存中栈顶元素的地址是所有栈中元素地址中最低的。(按照惯例,我们的栈是倒过来画的,栈顶在底部。)栈指针%rsp保存着栈顶元素的地址。
上图中,开始%rsp = 0x108,%rax = 0x123。执行pushq %rax的效果,首先%rsp会减8,得到0x100,然后会将0x123存放到内存地址0x100处。
其中push指令相当于这两条指令subq $8 %rsp,mov %rbp %rsp;
算数和逻辑运算
条件码
除了整数寄存器,CPU还维护着一组单个位的条件码寄存器,它们描述了最近算术或逻辑运算的属性。假设我们用一条ADD指令完成C表达式t = a + b 的功能。
CF:进位标志。最近的操作使最高位产生了进位。 (unsigned)t < (unsigned)
ZF:零标志。最近的操作得到的结果为零。 (t == 0)
SF:符号标志。最近的操作得到的结果为负值。 (t < 0)
OF:溢出标志,最近的操作产生了溢出。 (a<0==b<0)&&(t<0!=a<0)
除了上面的算数逻辑运算可以设置条件码,还有两类指令会设置条件码,并且不更新目的寄存器,它们分别是CMP和TEST。CMP和SUB指令相似,TEST和AND指令相似。
跳转指令
1.3过程的实现
过程是软件中一种很重要的抽象。他提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。
过程机制的构建需要实现下面的一到多个机制
传递控制:在进入过程Q的时候,程序计数器必须被设置为Q代码的起始位置,然后返回时,要把程序程序计数器设置为调用的那一条语句。
传递数据:P必须向Q传递n个参数,Q必须向P返回一个值。
分配和释放内存:在开始是,Q可能需要为局部空间分配内存,而在返回之前必须释放掉这些存储空间。
x86-64的过程实现包括特殊的指令和一些对机器资源使用的约束。
1.3.1运行时的栈
当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间(栈帧)。下图给出了运行是栈的通用结构,包括划分“栈帧”。当前正在执行的过程的栈帧总是在栈顶。(数据段、代码段、堆栈段、BBS段的区别)
当过程P调用过程Q时,会把返回的地址压入P的栈帧中,指明当Q返回时,P从哪里开始执行。
Q的代码会扩展当前栈的边界,分配他的栈帧所需要的空间,在这个空间,它可以保存寄存器的值,分配局部变量的空间,为调用过程设置参数。当Q运行时,P以及所有在向上追溯到P的调用链中的过程都是被挂起的,同时此时Q的栈帧在栈顶。
为了提高空间和时间的效率,许多过程有6个或者更少的参数,那么所有参数都保存在寄存器中。
1.3.2转移控制
转移控制的实现需要上面两条汇编指令的支持。在1.3.1中也提到啦,P调用个过程Q,执行call Q指令,该指令会把调用过程的下一条指令A保存在P的栈帧中,并把PC寄存器设置为Q的起始位置。对应的指令会将PC设置为A,并将A弹出P的栈帧。
1.3.3数据传送
当调用一个过程的时候,除了要把控制传递给调用过程,调用还需要把数据作为参数传递过去,调用过程可能返回一个值。
大部分数据的传送是通过寄存器来实现的,通过1.1.1寄存器的功能也可以看到,寄存器最多传输6个小于等于64位的数据,并通过%rax返回数据。
如果一个函数有大于6个整型参数,超出6个的部分就通过保存在调用者的栈帧来传递。
上面的程序代码,前六个参数可以通过寄存器传递,后面的两个通过栈传递。
1.3.4栈上的局部存储
目前为止我们看到的大多数程序示例都不需要超过寄存器大小的本地存储。不过以下情况局部数据必须要放入内存中。
- 寄存器不足以存放所有的本地数据。
- 对一个局部变量使用运算符“&”。
- 某些局部变量是数组或者是结构体,必须能够通过数据的引用访问到。
看一个汇编程序
上面的汇编代码是一个交换两个int数据,并得到两个数之和的程序。<main>函数中,首先在栈上分配了24个字节,其中可以看到的是栈顶的前四个字节用来保存变量‘a’,之后的四个用来保存变量‘b’,将寄存器%rax的值保存在0x8-0x18(新分配的字节在返回地址的顶部)。
汇编代码2c到34是开始为swap函数的调用做准备,把数据从栈中复制到寄存器中,执行函数的调用。在反汇编的汇编代码中好像省略了printf的函数调用。下面为编译器编译的.s文件。
.file "process.c"
.text
.globl swap
.type swap, @function
swap:
.LFB23:
.cfi_startproc
movl (%rdi), %eax
movl (%rsi), %edx
movl %edx, (%rdi)
movl %eax, (%rsi)
addl (%rdi), %eax
ret
.cfi_endproc
.LFE23:
.size swap, .-swap
.section .rodata.str1.1,"aMS",@progbits,1
.LC0:
.string "%d"
.text
.globl main
.type main, @function
main:
.LFB24:
.cfi_startproc
subq $24, %rsp
.cfi_def_cfa_offset 32
movq %fs:40, %rax
movq %rax, 8(%rsp)
xorl %eax, %eax
movl $5, (%rsp)
movl $3, 4(%rsp)
leaq 4(%rsp), %rsi
movq %rsp, %rdi
call swap
movl %eax, %edx
movl $.LC0, %esi
movl $1, %edi
movl $0, %eax
call __printf_chk
movq 8(%rsp), %rcx
xorq %fs:40, %rcx
je .L3
call __stack_chk_fail
.L3:
movl $0, %eax
addq $24, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE24:
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
当执行完输出语句,程序会跳转到L3的程序段,进行最后的处理。程序重置了%esx寄存器的值,把栈指针加24,释放了栈帧。
寄存器的局部存储空间
寄存器是唯一在所有过程中共享的资源。经过函数的调用,可能会改变参数寄存器里面的值,当函数调用结束后让,调用函数使用改变后的寄存器的值是不正确的,所以调用的函数采用了这种机制,就是将寄存器的值先保存在,调用者的栈帧中,在被调用者返回前,会通过栈帧里的数据回复寄存器里面的值。
1.3.5递归过程
因为寄存器和栈帧的存在是的x86-64过程能够递归的调用自身,每个过程调用在栈中都有自己的私有空间,因此多个未完成的调用的局部空间不会相互影响,栈的原则也提供了适当的策略,当过程被调用时分配局部存储,返回时释放局部存储。
1.4知识点
1.4.1强制对齐(数据地址对其为固定的值,确保每次内存操作可以读取或者更改相应的值)
window:如果数据类型需要 K 个字节,那么地址都必须是 K 的倍数”
linux:2字节数据类型的地址必须为2的倍数,较大的数据类型(int,double,float)的地址必须是4的倍数
struct sc{
int i;
char c;
int j;
}
1.4.2理解指针
- 每个指针都对应一种数据类型。
- 每个指针都有一个值,保存着某个指定类型对象的地址。
- 指针可以用&(取地址符号)得到。
- *(指针云算法)访问指针的数据。
- 数组的名字是一个指针。因为指针具有数据类型,可以通过算术运算访问其他数组元素。
- 将指针从一种类型强制转化为另一种类型,而不改变它的值。