程序编码
机器级代码
理解机器级代码有2种抽象需要理解。
- 指令集架构:来定义机器级程序的格式以及行为。定义了处理器的状态,指令格式,以及每条指令的影响
- 机器级指令使用的是虚拟内存。这个是编译器来决定的。具体把虚拟内存翻译成物理内存运行时有专门的硬件(MMU)来处理。目前的x86-64的虚拟内存中,地址的高16位被设置成0,所以寻址范围的,大小在256T.
long mult2(long x,long y);
void multstore(long x,long y,long *dst){
long t = mult2(x,y);
*dst = t;
}
使用gcc -Og -S 产生的汇编代码(去除掉伪指令)
multstore:
#%rdi %rsi %rdx分别存储着x,y,以及dst的值,默认没有显示出来。
.LFB0:
pushq %rbx #保存%rbx寄存器到栈上 rbx rbp r12~r15为被调用者保存的寄存器
movq %rdx, %rbx #移动%rdx到%rbx中
call mult2 #调用函数mult2
movq %rax, (%rbx) #返回值存在在%rax中,然后存储到M[%rbx]等价于 *dst = t
popq %rbx #出栈,弹出压栈时保存到栈上的%rbx的值
ret
在看一个例子我们编写main.c函数
#include<stdio.h>
void mulstore(long,long ,long*);
long mult2(long a,long b){
long s = a * b;
return s;
}
int main(){
long d;
multstore(2,3,&d);
printf("2*3=%ld\n",d);
return 0;
}
使用命令 gcc -Og -o prog main.c multstore.c,然后使用objdump -d prog 查看反汇编代码。
000000000040061b <multstore>:
40061b: 53 push %rbx
40061c: 48 89 d3 mov %rdx,%rbx
40061f: e8 92 ff ff ff callq 4005b6 <mult2> #mult2具体地址已经由链接器替换了。
400624: 48 89 03 mov %rax,(%rbx)
400627: 5b pop %rbx
400628: c3 retq
400629: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) #插入空指令 保证使得下一个函数地址按照16字节对齐。
而且prog可执行程序的反汇编后,得到的地址都是虚拟内存空间的地址,由此可见,在prog文件内,虚拟内存地址已经由链接器分配了。
数据格式以及访问信息
大多数gcc生成的汇编指令都带有一个后缀来表示操作数的大小,比如movb(传送字节),movw(传送字),movl(传送双字),movq(传送四字)。
x86-64的cpu包含一组16个存储64位值的通用寄存器,这些寄存器存储整数变量以及指针(浮点有专门的寄存器)。
指令可以对这16个寄存器的地位存储不同大小的数据。%rax作为函数返回值只用,%rdi,%rsi,%rdx以及%rcx作为函数的参数使用。%rsp栈针,这个指向运行时栈结束的位置。
这里说明下调用者保存以及被调用者保存的意思。比如过程P 调用Q。Q如果使用调用者保存的寄存器,必须在返回之前把寄存器的值还原,Q的做法是把寄存器压入栈中,然后结束后弹出栈(注意是:相反顺序,因为栈是先进后出)。
操作数指示符
大多数指令都有一个或者多个操作数。操作数的类型:
- 立即数(ATT汇编的写法,数字加一个1024,表示十进制的1024立即数),汇编器会自动选择最紧凑的方法进行数值编码。浮点数就不能表示立即数,这个后面再介绍。
2.寄存器。16个通用寄存器中的低1,2,4或者8字节的一个作为操作数,如果表示寄存器那么 来引用他的值。
3.内存引用。通常用来书写表示内存引用的值。
其中比例因子s必须是1,2,4,8。比例变址寻址只能做有限的乘法。所以一些其他比例时,往往通过多条指令组合来形成。
数据传送指令
指令格式 MOV S D。表示把S传送入D。
MOV类指令,movb,movw,movl以及movq。分别表示传送不同长度的操作数。
x86-64加了一条限制,2个操作数都位内存的引用。所以当内存位置复制到另外一个内存位置时,只能先把内存位置引用的值先传送给一个寄存器,然后在从该寄存器传送至另外一个内存位置。
MOV指令正常只会更新目的操作数寄存器指定的字节或者内存位置。movl这个是例外,当以寄存器作为目的时,会把该寄存器的高4位置为0。
特别之处,movq和movabsq的区别,movq的源参数如果为立即数,只能是32位的补码,然后符号扩展到64位。movabsq可以的源参数可以为一个64位的立即数。
int main(){
__asm__("movq $-0x80000000,%rax");
__asm__("movq $0x80000000,%rax");
__asm__("movabsq $0x80000000,%rax");
return 0;
}
使用gdb反汇编后
(gdb) disas main
Dump of assembler code for function main:
0x00000000004004d6 <+0>: push %rbp
0x00000000004004d7 <+1>: mov %rsp,%rbp
0x00000000004004da <+4>: mov $0xffffffff80000000,%rax
0x00000000004004e1 <+11>: movabs $0x80000000,%rax
0x00000000004004eb <+21>: movabs $0x80000000,%rax
0x00000000004004f5 <+31>: mov $0x0,%eax
0x00000000004004fa <+36>: pop %rbp
0x00000000004004fb <+37>: retq
我们可以观察到当立即数可以用32位补码表示时,会拓展符号位到64位。
当无法用32位补码表示时,指令mov被转换成movabsq。
其中-0x80000000为Tmin的大小。而+0x80000000为Tmax + 1,超过32位补码表示范围。
有两类指令可以将较小的源值复制到较大的目的使用。
1.MOVZ类指令,进行零扩展,分别为movzbw,movzbl,movzbq,movzwl,movzwq.可以根据字面意思去理解,里面没有把双字拓展成四字的指令。(这个可以用movl来替代,movl自动会把高4字节置0)
2.MOVS类指令,进行符号扩展,分别为movsbw,movsbl,movsbq,movswl,movswq,movslq,以及ctlq。其中ctlq只能作用与寄存器%eax和%rax,是将%eax符号扩展成%rax。%ctlq等价于 movslq %eax,%rax。只是指令更紧凑。
MOVS以及MOVZ类扩展指令的目的都是一个寄存器。
压入和弹出栈数据
栈遵循着“先进后出”的规则。通过push操作把数据压入栈中,通过pop操作删除数据。x86中,栈是向下增长的,栈顶元素的地址是所有栈中元素最低的。栈指针%rsp保存着栈顶元素的地址。
- pushq S 等价于 R[%rsp] R[%rsp] - 8; M[R[%rsp]] S ; //将双字压入栈
- popq D 等价于 D M[R[%rsp]] ; R[%rsp] R[%rsp] + 8; //将双字弹出栈。
push可以用subq和movq指令来替代。pushq指令编码只需要一个字节。而subq和movq指令需要更多的字节。
int main(){
__asm__("movq %rbp,%rax");
__asm__("subq %rbp,%rax");
__asm__("pushq %rdi");
__asm__("pushq %rsi");
__asm__("popq %rsi");
__asm__("popq %rdi");
return 0;
}
写如上测试函数,查看反汇编的地址,可以看到mov和sub分别占用了3个字节,而push和pop指令分别只占用一个字节。
0x00000000004004d7 <+1>: mov %rsp,%rbp
0x00000000004004da <+4>: mov %rbp,%rax
0x00000000004004dd <+7>: sub %rbp,%rax
0x00000000004004e0 <+10>: push %rdi
0x00000000004004e1 <+11>: push %rsi
0x00000000004004e2 <+12>: pop %rsi
0x00000000004004e3 <+13>: pop %rdi
0x00000000004004e4 <+14>: mov $0x0,%eax
0x00000000004004e9 <+19>: pop %rbp
一个字节的push和pop如何编码的,其后还加了一个寄存器。我们查看下内存。
(gdb) x /1bx 0x00000000004004e0
0x4004e0 <main+10>: 0x57
(gdb) x /1bx 0x00000000004004e1
0x4004e1 <main+11>: 0x56
(gdb) x /1bx 0x00000000004004e2
0x4004e2 <main+12>: 0x5e
(gdb) x /1bx 0x00000000004004e3
0x4004e3 <main+13>: 0x5f
我们可以观察到指令编码中已经把寄存器的“编号”编码进去了。一个字节就能实现我们6个字节才能实现的功能。
算术和逻辑操作。
这些操作分为4组:加载有效地址,一元操作,二元操作以及移位。
加载有效地址
leaq本质是mov指令的一个变种,只是名字太迷惑了,实际上根本没有引用内存。如果%rdx的值为x,那么leaq 7(%rdx,rdx,4) ,%rax 等价于 %rax 7+5x。类似与比例变址寻址,leaq指令也只能做有限的乘法。leaq指令可以间接的描述一些算数运算。
int fun(long x){
long *px = &x;
return 0;
}
查看汇编结果
0000000000400546 <fun>:
400546: 55 push %rbp
400547: 48 89 e5 mov %rsp,%rbp
40054a: 48 89 7d e8 mov %rdi,-0x18(%rbp)
40054e: 48 8d 45 e8 lea -0x18(%rbp),%rax
400552: 48 89 45 f8 mov %rax,-0x8(%rbp)
400556: b8 00 00 00 00 mov $0x0,%eax
40055b: 5d pop %rbp
40055c: c3 retq
其中%rdi表示参数x。leaq本质上还是一个做一个算数运算。只是和栈指针进行减法运算得到栈上存储x的地址,然后把地址传送到%rax寄存器。
一元和二元操作
一元操作(++,--)对应指令INC和DEC,以及取负取反对应指令(NEG和NOT)。参数只有一个,所以参数即是源又是目标。
二元操作,类似y+=x,第二个操作数即是源又是目标。指令%subq %rax,%rdx表达从%rdx中减去%rax存储的值。这些操作没啥好说的。
移位操作
移位操作,左移都是最低位都是填充0.右移分为算术右移和逻辑右移。算术右移对应的是补码,逻辑右移对应的是无符号数。所以对应的汇编指令中也是不同的指令了。
- 左移指令 SAL和SHL分别为算术左移和逻辑左移,都是一样,都是最低位填充0.
- 右移指令 SAR和SAR分别为算术右移和逻辑左移,逻辑左移高位填充0,算术右移高位填充符号位。
特殊的算术操作
两个64位数的乘法,不论是补码的形式还是无符号的,乘法的结果需要用128位来表示。
- 针对乘法而言,由于没有128位的通用寄存器,所以用两个寄存器%rax %rdx来存储。
imulq R[%rdx]:R[%rax] SR[%rax] 有符号乘法
mulq R[%rdx]:R[%rax] SR[%rax] 无符号乘法
#include<inttypes.h>
typedef unsigned __int128 uint128_t;
void fun(unsigned long x,unsigned long y,uint128_t* p){
*p = x * (uint128_t) y;
}
反汇编后得到的结果
0x00000000004004f0 <+0>: mov %rdi,%rax
0x00000000004004f3 <+3>: mov %rdx,%r8
0x00000000004004f6 <+6>: mul %rsi
0x00000000004004f9 <+9>: mov %rax,(%r8)
0x00000000004004fc <+12>: mov %rdx,0x8(%r8)
0x0000000000400500 <+16>: retq
%rdi存储x,%rsi存储y,%rdx存储p。由于是小端所以%rax结果应该放在低内存,%rdx内容应该放在高内存。
特别注意的时,得到这个结果使用了gcc编译时使用-O2的优化,如果使用O0的话,表达式 (uint128_t) y)这个就会展开,生成一个临时的%rdx:%rax的组合表达128位寄存器。
如果没有定义为128位类型,那么乘法并没有用到%rdx:%rax的组合,针对溢出的部分,直接丢失。
#include<stdio.h>
int fun(unsigned long arg,unsigned long *p){
unsigned long x = 0x8000000000000001u;
unsigned long y = x *arg;
*p = y;
printf("y=%llx\n",y);
return 0;
}
int main(){
unsigned long a;
fun(2u,&a);
return 0;
}
得到结果为2,这里并没有直接写 0x8000000000000001u*2u,而是用一个函数调用的方式就是为了避免编译器会用移位来替代乘法指令。
- 针对除法,有符号除法指令idiv将%rdx:%rax作为一个被除数,而除数由操作数给出。结果是将商存在寄存器%rax中,余数存储在%rdx中。大多数除法应用是被除数往往64位就够了,所以%rax存储被除数,%rdx设置全0(无符号数)或者%rax的符号位(补码)。继续实验来证明。
int div(long x,long y,long*qp,long*rp){
long q = x/y;
long r = x%y;
*qp = q;
*rp = r;
}
同样基于O2的优化,产生的汇编代码。%rdi存储x,%rsi存储y,%rdx存储qp,%rcx存储rp.
0x0000000000400520 <+0>: mov %rdi,%rax #保存x到%rax
0x0000000000400523 <+3>: mov %rdx,%rdi #保存qp到%rdi
0x0000000000400526 <+6>: cqto #拓展%rax到%rdx:%rax,即符号位拓展。
0x0000000000400528 <+8>: idiv %rsi #用%rdx:%rax里的值 除%rsi里的值(y)
0x000000000040052b <+11>: mov %rax,(%rdi) #把商存储到*qp
0x000000000040052e <+14>: mov %rdx,(%rcx)#把余数存储到*qp
#cqto R[%rdx]:R[%rax]$\longleftarrow$符号拓展R[%rax].
#针对无符号的除法时,要把%rdx全部设置成0.所以可以用异或指令xor %edx,%edx,反汇编出来后也是这么做的。