*本章是理解程序如何运行的关键所在,这一章将学到:
程序时如何从高级代码变为汇编代码;
程序编译后运行时在内存中是如何分布的; 3…*
程序代码 首先介绍各种单片机、处理器,不同的处理器所使用的能够识别的汇编语言也是不一样的,如何根据不同处理器把同一段高级语言转换成对应的汇编语言,这个是编译器的事儿,本文所有的汇编语言我的机器就跟他不同,导致我学习的时候也为此很头疼,严重影响了我对本来就一知半解的汇编语言的理解。
程序编码 在深入了解程序代码之前需要简单的了解一下GCC编译器的使用方法。
首先编写一个简单的C文件:code.c1 2 3 4 5 6 7 8 9 //code.c int accum = 0; int sum(int x, int y) { int t = x + y; accum += t; return t; }
然后利用gcc命令加上-S
参数生产一个汇编代码文件:1 2 3 4 gcc -O1 -S code.c ll code.c code.s
可见生成了一个.s文件,打开这个汇编文件,内容如下:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 .file"code.c" .text .globl sum .type sum, @function sum: .LFB0: .cfi_startproc leal (%rsi,%rdi), %eax addl %eax, accum(%rip) ret .cfi_endproc .LFE0: .size sum, .-sum .globl accum .bss .align 4 .type accum, @object .size accum, 4 accum: .zero4 .ident "GCC: (GNU) 4.4.6 20110731 (Red Hat 4.4.6-3)" .section .note.GNU-stack,"",@progbits
但是书中给出的生成的代码却是这样的:1 2 3 4 5 6 7 8 sum: pushl %ebp movl %esp, %ebp movl 12(%ebp), %eax addl 8(%ebp), %eax addl %eax, accum popl %ebp ret
可以看到这两段代码差别很大,这是因为所在的处理器环境不同,所以汇编命令格式会有差别;而且gcc的版本也不同,会导致优化的程度也不同。为了能够更好的理解课本,以后就按照书上的来理解。 如果在使用gcc的时候加-C
参数,则汇编器会把汇编文件编译成二进制文件: 这是生产了code.o文件,但是它的内容是二进制的,直接看不到。用vi打开文件,然后在normal模式下输入::%!xxd
1 2 3 4 5 6 7 8 9 10 11 0000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............ 0000010: 0100 3e00 0100 0000 0000 0000 0000 0000 ..>............. 0000020: 0000 0000 0000 0000 1001 0000 0000 0000 ................ 0000030: 0000 0000 4000 0000 0000 4000 0c00 0900 ....@.....@..... 0000040: 8d04 3e01 0500 0000 00c3 0000 0047 4343 ..>..........GCC 0000050: 3a20 2847 4e55 2920 342e 342e 3620 3230 : (GNU) 4.4.6 20 0000060: 3131 3037 3331 2028 5265 6420 4861 7420 110731 (Red Hat 0000070: 342e 342e 362d 3329 0000 0000 0000 0000 4.4.6-3)........ 0000080: 1400 0000 0000 0000 017a 5200 0178 1001 .........zR..x.. 0000090: 1b0c 0708 9001 0000 1400 0000 1c00 0000 ................ ......
还可以通过gdb这个调试工具进行查看二进制文件:1 2 3 4 $gbd code.o (gdb) x/17xb sum 0x0 <sum>:0x8d0x040x3e0x010x050x000x000x 00 0x8 <sum+8>:0x000xc3Cannot access memory at address 0xa
但是要查看目标代码文件的内容,最有价值的还是反汇编器(disassembler),这玩意儿听上去就很NB有木有?使用方法入下:1 2 3 4 5 6 7 8 9 10 objdump -d code.o code.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <sum>: 0:8d 04 3e lea (%r si,%rdi,1),%eax 3:01 05 00 00 00 00 add %eax,0x0(%rip) # 9 <sum+0x9> 9:c3 retq
这个是我电脑上的结果,跟书上的有不一样,书上的是:1 2 3 4 5 6 7 8 9 10 11 Disassembly of funciton sum in binary file code.o 00000000 <sum> Offset Bytes Equivalent assembly language 0: 55 push %ebp 1: 89 e5 mov %esp,%ebp 3: 8b 45 0c mov 0xc(%ebp),%eax 6: 03 45 08 add 0x8(%ebp), %eax 9: 01 05 00 00 00 00 add %eax,0x0 f: 5d pop %ebp 10: c3 ret
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数,假设文件main.c中有下面这样一个函数:1 2 3 4 int main() { return sum(1,3) }
然后用下面的方法生成可执行文件prog:1 gcc -O1 -o prog code.o main.c
看到生成了一个prog可执行文件,对其进行反编译 可看到一大坨的汇编代码,但是其中有一段跟上面的代码一样
1 2 3 4 5 6 7 8 9 10 objdump -d code.o code.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <sum>: 0:8d 04 3e lea (%rsi,%rdi,1),%eax 3:01 05 00 00 00 00 add %eax,0x0(%rip) # 9 <sum+0x9> 9:c3 retq
这个是我电脑上的结果,跟书上的有不一样,书上的是:1 2 3 4 5 6 7 8 9 10 11 Disassembly of funciton sum in binary file code.o 00000000 <sum> Offset Bytes Equivalent assembly language 0: 55 push %ebp 1: 89 e5 mov %esp,%ebp 3: 8b 45 0c mov 0xc(%ebp),%eax 6: 03 45 08 add 0x8(%ebp), %eax 9: 01 05 00 00 00 00 add %eax,0x0 f: 5d pop %ebp 10: c3 ret
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数,假设文件main.c中有下面这样一个函数:1 2 3 4 int main() { return sum(1,3) }
然后用下面的方法生成可执行文件prog:1 gcc -O1 -o prog code.o main.c
看到生成了一个prog可执行文件,对其进行反编译 可看到一大坨的汇编代码,但是其中有一段跟上面的代码一样1 2 3 4 5 6 0000000000400474 <sum>: 400474: 8d 04 3e lea (%rsi,%rdi,1),%eax 400477: 01 05 d3 03 20 00 add %eax,0x2003d3(%rip) # 600850 <accum> 40047d: c3 retq 40047e: 90 nop 40047f: 90 nop
与前面的代码对比差不多,但是其地址有明显的改变,应为第二段代码编译后说有的地址都是相对程序的地址指定了的。
访问信息 访问信息分为:操作数指示符、数据传送指令等操作。通过一个练习题来巩固一下操作数指示符:
例题3.1 假设下面的值存放在指明的存储器地址和寄存器中:
地址
值
0x100
0xFF
0x104
0xAB
0x108
0x13
0x10C
0x11
寄存器
值
%eax
0x100
%ecx
0x1
%edx
0x3
填写下表,给出所示操作数的值。
操作数
值
思路
%eax
0x100
直接从上面获取寄存器的初始值
0x104
0xAB
从上面表中获取0x104地址的值
$0x108
0x108
立即数
(%eax)
0xFF
先获取寄存器%eax的值0x100,然后在获取地址0x100对应的值,间接寻址
4(%eax)
0xAB
(基址+偏移量)寻址:(4 + 0x100) = (0x104) = 0xAB
9(%eax, %edx)
0x11
变址寻址:(9 + %eax + %edx) = (0x10C) = 0x11
260(%ecx, %edx)
0x13
变址寻址:(260 + %ecx + %edx) = (0x108) = 0x13
0xFC(, %ecx, 4)
0xFF
比例变址寻址:(0xFC + 0 + %ecx * 4) = (0xFC+0x4) = (0x100)=0xFF
(%eax, %edx, 4)
0x11
比例变址寻址:(%eax + %edx * 4)=(0x10C)=0x11
栈操作说明:
通过书中图3.5可知,当push一个数值时,栈指针减小,向下移动;当pop一个数据时栈指针向上移动。一般用 %esp
来存储栈指针的地址。
下面通过一个例子说明C语言中指针使用的方式,函数exchange代码如下:1 2 3 4 5 6 7 8 9 10 int exchange(int *xp, int y) { int x = *xp; *xp = y; return x; } int a = 4; int b = exchange(&a, 3); printf("a = %d, b = %d\n", a, b);
这段代码会打印出:a = 3, b = 4
关键部分的汇编代码如下:1 2 3 4 5 # xp地址的值存储在8(%ebp), y的值存储在12(%ebp) movl 8(%ebp) %edx #获取xp地址的值,存储在%edx movl (%edx), %eax #获取xp地址所指向的值赋予变量x,函数结束时返回这个值 movl 12(%ebp), %ecx #获取y的值,存储在%ecx movl %ecx, (%edx) #%ecx的值存储在%edx所指向的值, 这时候*xp的值为y,xp地址的值没有变化
局部变量比如x,通常时保存在寄存器中。
算术和逻辑操作 加载有效地址 加载有效地址(load effective address) 指令leal
实际上是movl指令的变形。通过下面一个例子来说明它的含义:1 2 3 # 假设%edx的值为x movl 7(%edx, %edx, 4), %eax #计算7 + x + 4*x = 5x +7 那么%eax的值就是地址5x+7地址处所存储的值. leal 7(%edx, %edx, 4), %eax #计算7 + x + 4*x = 5x +7 那么%eax的值就是地址的值5x+7,而不是这个地址存储的值.
一元操作和二元操作 如果一个操作只有一个操作数,既是源又是目的,这个操作就是一元操作。 如果一个操作有两个操作数,第二个操作数既是源又是目的,这个操作就是二元操作。 如下:
指令
效果
操作
INC D
D <- D + 1
一元操作
ADD S, D
D <- D + S
二元操作
移位操作
类型
操作
命令
示意图
非循环移位
逻辑左移/算术左移
SHL/SAL
非循环移位
逻辑右移
SHR
非循环移位
算术右移
SAR
循环移位
不含进位位的循环左移
ROL
循环移位
不含进位位的循环右移
ROR
循环移位
含进位位的循环左移
RCL
循环移位
含进位位的循环右移
RCR
控制 条件码 除了整数寄存器,CPU还维护着一组单个位的条件码(codition code) 寄存器,最常用的条件码有:
CF :进位标志。最近的操作使最高位产生了进位。可以用来检测无符号操作数的溢出。
ZF :零标志。最近的操作得出的结果为0。
SF :符号标志。最近的操作得到的结果为负。
OF :溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。
访问条件码 条件码通常不会直接读取常用的使用方法有三种:
可以根据条件码的某个组合,将一个字节设置为0或者1;
可以条件跳转到程序的某个其它部分;
可以有条件地传送数据.
对于第一种情况,举例说明一下:
指令
同义名
效果
设置条件
sete D
setz
D<-ZF
相等/零
setne D
setnz
D<-~ZF
不等/非零
sets D
D<-SF
负数
setns D
D<-~SF
非负数
setg D
setnle
D<-~(SF ^ DF)&~ZF
大于(有符号>)
SET指令。每条指令根据条件码的某个组合将一个字节设置为0或者1,而不是直接访问条件码寄存器。
跳转指令及其编码 正常情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump) 指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label) 指明。当执行与PC(程序计数器)相关的寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。例如一段汇编代码及其反汇编代码:1 2 3 4 5 6 7 8 9 10 jle .L2 #if <=, goto dest2 .l5: #dest1: movl %edx, %eax sar1 %eax subl %eax, %edx leal (%edx, %edx, 2), %edx testl %edx, %edx jg .L5 #if >, goto dest1 .L2: #dest2 movl %edx, %eax
1 2 3 4 5 6 7 8 8: 7e 0d jle 17 <silly+0x17> a: 89 d0 mov %edx, %eax c: d1 f8 sar %eax e: 29 c2 sub %eax, %edx 10: 8d 14 52 lea (%edx, %edx, 2), %edx 13: 85 d2 test %edx, %edx 15: 7f f3 jg a <silly+0xa> 17: 89 d0 mov %edx, %eax
我们看到在反汇编代码里的第一行,前面地址为0x8
的地方,0x7e
代表jle
, 0xd
代表要跳转的地址,但是对应的汇编地址却是0x17
, 其实0x17
是通过0x8
处的0xd
与下一行0xa
的地址(正是PC程序计数器的值)相加得出的。
翻译条件分支 如何将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。通过一个例子来说明: a) 原始的C语言代码1 2 3 4 5 6 int absdiff(int x , int y) { if (x < y) return y - x; else return x - y; }
b) 与之等价的goto版本1 2 3 4 5 6 7 8 9 10 11 int absdiff(int x , int y) { int result; if (x >= y) goto x_ge_y; result = y - x; goto done; x_ge_y: result = x - y; done: return result; }
1 2 3 4 5 6 7 8 9 10 11 # x at %ebp + 8, y at %ebp + 12 movl 8(%ebp), %edx #get x movl 12(%ebp), %eax #get y cmpl %eax, %edx #compare y:x 即用x-y的值更新标记位 jge .L2 # x > y subl %edx, %eax # y = y - x jmp .L3 #goto L3 .L2: subl %eax, %edx #x = x - y movl %edx, %eax #y = x .L3: #done return x
循环 其实循环和条件分支所利用的都是jump指令和标记寄存器的值,这里就不再说明了。
条件传送指令 实现条件操作的传统方法是利用控制 的条件转移。但是在现代处理器上,它可能会非常的低效率。数据 的条件转移是一种替代的策略。这种方法先计算一个条件操作的两种结果,然后再根据条件是否满足选取一个。通过一个例子来说明:1 2 3 int absdiff(int x , int y) { return x < y ? y-x:x-y; }
产生的汇编代码:1 2 3 4 5 6 7 8 9 # x at %ebp + 8, y at %ebp + 12 movl 8(%ebp), %ecx #get x movl 12(%ebp), %edx #get y movl %edx, %ebx #copy y subl %ecx, %ebx #compute y - x movl %ecx, %eax #copy x subl %edx, %eax #compute x - y, set as return value cmpl %edx, %ecx #compare x:y cmovl %ebx, %eax #if <, replace return value with y - x
注意这个与上面的翻译条件分支 一节的汇编做比较。这种方式效率会更好。原因是跟第4章和第5章讲的处理器的结构有关系,处理器通过流水线(pipelining) 来获得高性能。第二种方式可以把所有的指令都放入到流水线中,而第一种方式则需要根据条件判断哪个指令放入到流水线,从这点可以看出第二种方式总是能把指令都放入流水线中,保证了执行的效率。
switch语句 switch(开关)语句可以根据一个整数索引值进行多重分支(multi-way branching)。处理具有多种可能结果的测试时。这种语句特别有用。它们不仅提高了C代码的可读性,而且通过使用跳转表(jump table) 这种数据结构使得实现更加高效。
过程 一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。数据传递、局部变量的分配和释放通过操纵程序栈来实现。
栈桢结构 为单个过程分配的那部分栈称为栈帧(stack frame) 。寄存器%ebp为帧指针 ,而寄存器%esp为栈指针 。栈帧结构(栈用来传递参数、存储返回信息、保存寄存器,以及本地存储)
转移控制 支持过程调用和返回的指令:
指令
描述
call Label
过程调用
call *Operand
过程调用
leave
为返回准备栈
ret
从过程调用中返回
call指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。返回地址是在程序中紧跟在call后面的那条指令地址。 例如: 执行call的之后栈顶入栈的地址为call命令后那条命令的地址,然后ret执行后把这个地址弹出到%eip,开始执行%eip处的命令。%eip应该是程序计数器。还有一个要注意的是保存的%ebp ,这个起始是上一个帧的起始地址,当前过程执行完后,这个保存的%ebp就会返回到%ebp中,更新当前帧的开始地址为上一帧的地址。
寄存器使用惯例 程序寄存器组是唯一能被所有过程共享的资源。虽然在给定时刻只能有一个过程是活动的,但是我们必须保证当一个过程(调用者 )调用另一个过程(被调用者 )时,被调用者不会覆盖某个调用者稍后会使用的寄存器的值。根据惯例,寄存器%eax、%edx和%ecx被划分为调用者保存 寄存器。当过程P调用Q时,Q可以覆盖这些寄存器,而不会破任何P所需要的数据。另一方面,寄存器%ebx、%esi和%edi被划分为被调用者保存 寄存器。这意味着Q必须在覆盖这些寄存器之前,先把它们保存到栈中,并在返回前恢复它们。
过程示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 int swap_add(int *xp, int *yp) { int x = *xp; int y = *yp; *xp = y; *yp = x; return x + y; } int caller() { int arg1 = 534; int arg2 = 1057; int sum = swap_add(&arg1, &arg2); int diff = arg1 - arg2; return sum * diff; }
1 2 3 4 5 6 7 8 9 10 11 12 caller: pushl %ebp #save old %ebp, 保存上一个帧的开始地址 movl %esp, %ebp #set %ebp as frame pointer,更新%ebp地址为当前帧的地址 subl $24, %esp #alllocate 24 bytes on stack, 申请栈空间,指针头向下移动 movl $534, -4(%ebp) #set arg1 to 534 movl $1057, -8(%ebp) #set arg2 to 1057 leal -8(%ebp), %eax #compute &arg2 movl %eax, 4(%esp) #store on stack leal -4(%ebp), %eax #compute &arg1 movl %eax, (%esp) #store to stack call swap_add #call the swap_add function
ps:为什么GCC分配从不使用的空间 GCC为caller分配了24个字节,但是却只是用了16个字节,这是因为GCC坚持一个x86编程指导方针,也就是一个函数使用的所有栈空间必须是16字节的整数倍。包括%ebp值的4个字节和返回值的4个字节,caller一共使用了32个字节。采用这个规则是为了保证访问数据的严格对齐(alignment)。
swap_add开始代码:1 2 3 4 swap_add: pushl %ebp movl %esp, %ebp pushl %ebx #需要寄存器%ebp做为临时存储。因为这是一个被调用者保存的寄存器。
swap_add主体代码:1 2 3 4 5 6 7 movl 8(%ebp), %edx #get xp movl 12(%ebp), %ecx #get yp movl (%edx), %ebx #get x movl (%ecx), %eax #get y movl %eax, (%edx) #store y at xp movl %ebx, (%ecx) #store x at yp addl %ebx, %eax #return value = x+y
这段代码从caller的栈帧中取出它的参数,这点需要注意。 swap_add结束代码:1 2 3 popl %ebx #restore %ebx, 从栈帧中弹出之前存储的%ebx的值到%ebx,恢复%ebx的值 popl %ebp #restore %ebp, 从栈帧中弹出之前存储的%ebp的值到%ebp,恢复%ebp的值 ret #return, 弹出地址到%eip, 并且从上个帧调用call下一行的地址开始执行。
递归过程 上面描述的栈链接惯例使得过程能够递归地调用它们自身。因为每个调用过程都有它自己的私有空间,多个未完成调用的局部变量不会相互影响。
递归的阶乘程序的C代码:
1 2 3 4 5 6 7 8 9 int rfact(int n) { int result; if (n <= 1) result = 1; else result = n * rfact(n-1); return result; }
对应的汇编代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # argument: n at %ebp + 8 # registes: n in %ebx, result in %eax rfact: pushl %ebp #save old %ebp movl %esp, %ebp #set %ebp as frame pointer pushl %ebx #save callee save register %ebx subl $4, %esp #allocate 4 byte on stack movl 8(%ebp), %ebx #get n movl $1, %eax #result = 1 cmpl $1, $ebx # 比较参数 n 与 1 jle .L53 #if n <= 1, 跳到L53 leal -1(%ebx), %eax #%eax <- n-1 movl %eax, (%esp) #n-1保存到栈顶 call rfact #调用rfact(n-1) imull %ebx, $eax #result = return value * n .L53: addl $4, %esp popl %ebx popl %ebp ret
当n=1时,调用L53,被调用过程返回,然后继续执行imull指令 %eax 值 1
递归的阶乘函数的栈帧,这是递归调用之前的帧状态。
数组分配和访问 C语言中数组是一种将标量数据聚集成为更大数据类型的方式。
基本原则 对于数据类型T和整型常数N,声明如下: T A[N] 它有两个效果:
它在存储器中分配一个\( L \bullet N \)字节的连续区域;这里L是数据类型T的大小(单位为字节), 用 \( x_{A} \) 来表示起始位置。
它引入了标符A;可以用A作为指向数组开头的指针,这个指针的值就是 \(x_{A}\) 。数组元素i被存放在地址为 \(x_{A} + L \bullet i\)的地方。 通过例子加深理解:1 2 3 4 char A[12]; char *B[8]; //指针数组,数组的值是(char *)类型,指针的地址是int型的。 double C[6]; double *D[5];
数组
元素大小
总的大小
起始地址
元素i
A
1
12
\(x_{A}\)
\(x_{A} + i\)
B
4
32
\(x_{B}\)
\(x_{B} + 4i\)
C
8
48
\(x_{C}\)
\(x_{C} + 8i\)
D
4
20
\(x_{D}\)
\(x_{D} + 4i\)
异质的数据结构 C语言提供了两种结合不同类型的对象来创建数据类型的机制:结构(structure) ,用关键字struct声明, 将多个对象集合到一个单位中;联合(union) , 用关键字union声明,允许用集中不同的类型来引用一个对象。
数据对齐 许多计算机系统对基本数据类型合法地址做出了一些限制,要求某种类型对象的地址必须是某个值k(通常是2,4或8)的倍数。这种对齐限制 简化了形成处理器和存储器系统之间接口的硬件设计。 例如:1 2 3 4 5 struct S1 { int i; char c; int j; };
按照最小字节分配应该是这样的: 但是要遵循字节对齐,真实的分配情况是这样的:
综合:理解指针 指针和它们映射到机器代码的关键原则:
每个指针都对应一个类型。void * 类型代表通用指针。
每个指针都有一个值。这个值是某个指定类型对象的地址。
指针用&运算符创建。常用leal
指令来计算表达式的值。int * p = &x
操作符用于指针的间接引用。
数组与指针紧密联系。
将指针从一种类型强制转换为另一种类型,只改变它的类型,而不改变它的值。
指针也可以指向函数。
存储器的越界引用和缓冲区溢出 C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址),都存放在栈中。这两种情况结合到一起就可能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。一种常见的状态破坏称为缓冲区溢出(buffer overflow) 。入下图所示: 为buf分配的空间只有8个字节元素,上面地址保存的是%ebx, %ebp和返回地址的值,如果buf的长度超过分配的长度,就会把多出来的元素写入到保存这些寄存器地址的值,破坏%ebx,%ebp和返回地址的值,这样程序就不能正确的返回%ebx的值,不能正确的返回上以栈帧的地址,不能正确返回下一条要执行的命令地址。如果这些输入的元素包含一些可执行代码的字节编码,称为攻击代码(exploit code) , 比如串改返回地址的值,使程序返回到恶意程序的代码地址进行执行,就会成为我们所说的蠕虫和病毒 。 对抗缓冲区溢出攻击的方法:(详细的原理不再赘述,可以自己google)
栈随机化。
栈破坏检测。
限制可执行代码区域。