前言
csapp 第三章从 CPU 的架构入手,了解处理器如何处理指令,并将C程序翻译成汇编,根据汇编和cpu结构讲解你的程序是如何运行的,并讲解常用的数据结构是如何在内存中存储和表示的,通过这些了解计算机系统设计的缺陷以及你的程序运行时出现的奇怪bug(奇怪的错误,以及摸不着头绪的性能低下)的原因。
本片文章,先介绍汇编基础知识。。。。。可以跳读。
重点:常用c表达的汇编表示
数据结构的内存表示
奇怪bug的原理和解决办法
快速了解汇编
想要看懂且深入理解这一章的内容,掌握基本的汇编知识是必不可少的,这里讲解看懂这一章所必要的汇编基本知识。
1. 汇编的种类(只需要稍微了解一下)
汇编大致分为两种:
- 基于x86架构的处理器使用的汇编指令
- 基于ARM架构的处理器所使用的汇编指令
第1种x86汇编又分为两种, Intel汇编(对应windows系统),AT&T汇编(对应Linux系统),这两种汇编知识语言上风格不同,其他的没什么不同。
要注意,Intel的mov指令和Linux上的mov指令有些不同:
Linux
mov 源 目的
例如:mov $0x01 %e9
Intel
mov 目的,源
例如: mov e9 1
(nasm编译器多用于Intel风格的汇编)
2. CPU架构和寄存器
- 16个常用的64位寄存器
- %rax, %rbx, %rcx , %rdx, %rsi, %rdi, %rbp, %rsp
- %r8, %r9, %r10, %r11, %r12, %r13, %r14, %r15
- 这些寄存器的用途。
注意这里说的是64位cpu,32位cpu寄存器名把r改成e就可以了,比如 %rax–>%eax %rbx–>%ebx 以此类推。
- %rax 保存函数返回值,以及参与计算
- %rdi, %rsi, %rdx, %rcx, %r8, %r9, %r10等寄存器用于存放函数参数
- %rsp 指向栈顶,%rbp 指向栈底
3.汇编快速入门
下面会给出常见的汇编指令,以便大家快速的认识和学习汇编。
学习指南:
重点看jump指令,call指令和ret指令,其他的快速浏览一遍就可以了(其他的比较容易)。
汇编指令通常可以分为数据指令,逻辑计算指令和控制流指令,先做如下约定:
3.1 数据传输指令
mov
mov指令将第二个操作数复制到第一个操作数中。mov唯一不能的是直接从内存复制到内存。
语法如下:
mov <reg>,<reg>
mov <reg>,<mem>
mov <mem>,<reg>
mov <reg>,<const>
mov <mem>,<const>
Examples
mov eax, ebx — 将ebx的值拷贝到eax
push
push指令将操作数亚如栈中。
push <reg32>
push <mem>
push <con32>
Examples
push eax — 将eax内容压栈
pop
pop指令与push指令相反,将rsp寄存器(栈顶)指向的数据弹出。
pop <reg32>
pop <mem>
Examples
pop edi — 将栈顶数据弹出且保存到edi寄存器中
lea
实际上是一个载入有效地址的指令,将第二个操作数表示的地址载入到第一个操作数(寄存器)中。
lea <reg32>,<mem>
Examples
lea edi, [ebx+4*esi] — ebx+4*esi表示的地址载入到edi中,这实际是上面所说的寻址模式的一种表示方式.
lea 指令和 mov指令的区别是 lea 的第二个操作数可以放表达式计算出地址,哪有人就会问lea指令存在有什么意义?
是的,没有意义!
(开个玩笑)
lea方便啊,一条顶两条指令啊,看不出来么?
3.2 算数和逻辑指令
add
add指令将两个操作数相加,且相加后的结果保存到第一个操作数中。
add <reg>,<reg>
add <reg>,<mem>
add <mem>,<reg>
add <reg>,<con>
add <mem>,<con>
Examples
add eax, 10 — EAX ← EAX + 10
sub
sub指令第一个操作数减去第二个操作数,并将结果保存在第一个操作数中。
sub <reg>,<reg>
sub <reg>,<mem>
sub <mem>,<reg>
sub <reg>,<con>
sub <mem>,<con>
Examples
sub al, ah — AL ← AL - AH
inc ,dec
inc,dec分别表示将操作数自加1,自减1。
inc <reg>
inc <mem>
dec <reg>
dec <mem>
Examples
dec eax — eax中的值自减1.
imul
整数相乘指令,两种指令格式:
两个操作数,第一个操作数必须为寄存器,将两个操作数的值相乘,并将结果保存到第一个操作数中;
三个操作数,第一个操作数必须为寄存器,将第二个操作数与第三个操作数相乘,将结果保存到第一个操作数中。
imul <reg32>,<reg32>
imul <reg32>,<mem>
imul <reg32>,<reg32>,<con>
imul <reg32>,<mem>,<con>
Examples
imul eax, [var] — eax→ eax * [var]
imul esi, edi, 25 — ESI → EDI * 25
idiv
idiv指令完成整数除法,idiv只有一个操作数,此操作数为被除数,除数为
and,or,xor
逻辑与,逻辑或,逻辑异或操作指令,用于操作数的位操作,操作结果放在第一个操作数中。
and <reg>,<reg>
and <reg>,<mem>
and <mem>,<reg>
and <reg>,<con>
and <mem>,<con>
or <reg>,<reg>
or <reg>,<mem>
or <mem>,<reg>
or <reg>,<con>
or <mem>,<con>
xor <reg>,<reg>
xor <reg>,<mem>
xor <mem>,<reg>
xor <reg>,<con>
xor <mem>,<con>
Examples
and eax, 0fH — 将eax中的钱28位全部置为0,最后4位保持不变.
xor edx, edx — 设置edx中的内容为0.
not
位翻转指令,将操作数中的为一位翻转。
not <reg>
not <mem>
Example
not BYTE PTR [var] — 将var指示的一个字节中的所有位翻转.
取负指令
neg <reg>
neg <mem>
Example
neg eax — EAX → - EAX
shl,shr
移位指令
shl <reg>,<con8>
shl <mem>,<con8>
shl <reg>,<cl>
shl <mem>,<cl>
shr <reg>,<con8>
shr <mem>,<con8>
shr <reg>,<cl>
shr <mem>,<cl>
Examples
shl eax, 1 — 左移1位,相当于乘以2
shr ebx, 1 — 右移1位,相当于除以2
3.3 控制转移指令
x86处理器维持着一个寄存器,称为IP寄存器,当一条指令执行后自动保存下一条指令,也就说你运行过程序都经过IP寄存器的手。
该寄存器不可以直接操控,但是我们有控制命令可间接操控它的值。
cmp
cmp指令比较两个操作数的值,并根据结果设置机器状态字中的条件码,该指令类似sub指令,但是cmp不用将结果保存在操作数中。
什么是机器状态字中的条件码?
它也被称作标识码,不给你上定义了,看下面的jump指令就明白了。
cmp指令的例子一起放在jump指令中给出。
jump
jump是跳转指令
大家都知道计算机执行程序是从上到下逐行逐句的执行的,跳转指令可以让程序跳段执行。
那么jump是随意跳的吗?肯定不是,如果是的话那我们直接定义一个指令直接操作IP寄存器不就得了,jump指令是根据运算结果来进行跳转的,例如c预言的判断语句就会被翻译成jump语句。
大体步骤如下:CPU计算,根据计算结果改变标识位,根据标识位进行跳转。
四个标识位:CF,ZF,SF,OF(这些标识位是存在于EFLAGS寄存器中的,该寄存器还有其他标识位,我们只需要了解这四个即可)
jump指令用法:
直接跳转:
jmp <label>
Example
jmp mylabel ---- 跳转到mylabel定一段指定代码
刚刚不是才说jump指令是根据计算机计算结果,然后自动改变标识位,在根据标识位进行跳转的吗?
别急,这是直接跳转命令,接下来为你介绍条件跳转命令。
je <label> (jump when equal)
jne <label> (jump when not equal)
jz <label> (jump when last result was zero)
jg <label> (jump when greater than)
jge <label> (jump when greater than or equal to)
jl <label> (jump when less than)
jle <label>(jump when less than or equal to)
Example
cmp eax, ebx ;这个操作(计算)改变了标识位
jle done , ;如果eax中的值小于ebx中的值,跳转到done指示的区域执行,否则,执行下一条指令。
call,ret
call 和 ret 指令分别实现子程序的调用和返回。
用call指令直接跳转到子程序开始的地方,这时候就有同学问了,这不跟jump指令一样吗?
是的,一样的,只是call指令还多做了现场保护的工作,而ret指令就是恢复被保护的现场。
至于什么是现场保护和现场恢复先不用关心,文章下面讲解,现在只需要知道call和ret是做这个的,先对他们有个概念。
Example
call <label>
ret
4. 函数调用过程及原理
这小结讲解cpu是如何调用函数的,在这过程中发生了什么。
4.1 栈的结构
我们需要先了解x86运行时栈的结构,如下图:
唯一要注意的就是栈的增长方向是从底地址往高地址增长的。
4.2 函数调用规则
先理解一个概念,程序是在内存中运行的,我们换句话说函数是在内存中运行的,可以认为是在内存(系统分配的栈)中运行的,但是系统栈这么大不可能只给某一个函数使用,所以每个函数都在系统栈中划分出一块空间自己使用,大家都用自己的互不侵犯,这块空间就叫做栈帧。
先从栈的角度去看一个程序运行时栈发生了什么变化。
假设一个程序在运行,这里我们借用一下DIO的能力(不知道DIO是谁的快去补JOJO),暂停时间看看栈这时候是怎么样的。
无论我们怎么停止时间,程序都是在运行一个函数,这没错吧,栈的情况如下:
通过上图我们可以看到,有两个指针EBP和ESP指向栈的一段空间,其实EBP和ESP也是寄存器,EBP保存的是当前函数栈帧的栈底,ESP保存的是栈帧的栈顶,也就是说当前运行的函数在使用EBP到ESP这块内存空间,当然这块空间是可以增长的,只要ESP向低地址移动就可以了。
那么接下来解释函数调用子函数时内存(系统栈)中发生了什么就好理解了。
就假设main函数调用其子函数f好了,因为main是程序的入口嘛:
一开始只有main函数运行,这时候还没运行f,看看内存(系统栈)是怎样的:
这时候只有main函数在运行,理所当然地EBP指向main函数空间的开始处,ESP指向main函数空间的结束处。
这时候运行到call指令,计算机意识到要调用子函数f了,开始进行现场保护(就是把main函数当前运行数据保存起来),将数据压入栈中,并且把当前IP寄存器的值也压入栈中(因为IP寄存器保存的是当前程序运行的位置,值函数调用完后你总得知道自己本来运行到哪吧)。
接下来就开始进入f函数了,f函数将当前的EBP寄存器的值压入栈中,也就是main函数栈帧的栈低地址入栈,为什么要这么做?
因为EBP和ESP总是指向正在运行函数的内存空间的,如果不保存上个函数的EBP值,那调用完f函数后,在内存空间中就找不到main函数的位置了。
(读者可以思考一下两个问题:
- 需不需要保存main函数的ESP值?
- 为什么保存EBP值的操作是由函数f来做,而不是由main函数来做?
)
保存完main函数的EBP值之后,EBP寄存器就自由了,但自由总是短暂的,他开始指向f函数栈帧的栈低,那么f函数栈帧栈低在哪呢?没错为了节省空间就是当前ESP值的位置(要注意我们并没有释放掉ESP的值,ESP寄存器依然指向的是main函数栈帧的栈顶),这样就不会浪费内存。
(EBP和ESP指向同一个位置的视图)
所以经常会在汇编中看到调用子函数时的操作:
push EBP ; EBP寄存器中的值入栈
mov EBP ESP ; EBP指向ESP
接下来就是f函数开始耕耘了,开辟新的内存空间,f函数调用完后也就能根据栈中的数据恢复到上一个函数断点处,皆大欢喜。
总结一下:
- main函数在调用f函数前,首先将参入的参数压入栈中;
- 接着,将当前程序执行的下一个的位置EIP加入栈中,后面f函数返回时用;
- 进入f函数,f函数首先将当前的EBP压入栈中,接着mov esp, ebp(将EBP指向当前的栈顶),接着以EBP为基础构建自己的函数帧结构;
- 在f函数执行完成后,会将栈中的EBP值弹出,恢复到EBP寄存器中,还原ESP寄存器,接着弹出EIP变量,程序根据EIP变量指向的位置接着执行main函数后面的程序部分。
4.3 进一步理解
接下来我们在听听比较官方的说法。
官方说法更加紧凑抽象和附加更多的细节,没有一个字的废话,但是一开始阅读会有困难,但是经过我上面的解释相信你不会有任何问题了,好了,废话不多说,let’s see it
4.3.1 调用者规则:
1)在调用子程序之前,调用者应该保存一系列被设计为调用者保存的寄存器的值。调用者保存寄存器有eax,ecx,edx。由于被调用的子程序会修改这些寄存器,所以为了在调用子程序完成之后能正确执行,调用者必须在调用子程序之前将这些寄存器的值入栈。
2)在调用子程序之前,将参数入栈。参数入栈的顺序应该是从最后一个参数开始,如上图中parameter3先入栈。
3)利用call指令调用子程序。这条指令将返回地址放置在参数的上面,并进入子程序的指令执行。(子程序的执行将按照被调用者的规则执行)
当子程序返回时,调用者期望找到子程序保存在eax中的返回地址。为了恢复调用子程序执行之前的状态,调用者应该执行以下操作:
1)清除栈中的参数;
2)将栈中保存的eax值、ecx值以及edx值出栈,恢复eax、ecx、edx的值(当然,如果其它寄存器在调用之前需要保存,也需要完成类似入栈和出栈操作)
4.3.2 被调用者规则
1)将ebp入栈,并将esp中的值拷贝到ebp中,其汇编代码如下:
push ebp
mov ebp, esp
上述代码的目的是保存调用子程序之前的基址指针,基址指针用于寻找栈上的参数和局部变量。当一个子程序开始执行时,基址指针保存栈指针指示子程序的执行。为了在子程序完成之后调用者能正确定位调用者的参数和局部变量,ebp的值需要返回。
2)在栈上为局部变量分配空间。
3)保存callee-saved寄存器的值,callee-saved寄存器包括ebx,edi和esi,将ebx,edi和esi压栈。
4)在上述三个步骤完成之后,子程序开始执行,当子程序返回时,必须完成如下工作:
4.1)将返回的执行结果保存在eax中
4.2)弹出栈中保存的callee-saved寄存器值,恢复callee-saved寄存器的值(ESI和EDI)
4.3)收回局部变量的内存空间。实际处理时,通过改变EBP的值即可:mov esp, ebp。
4.4)通过弹出栈中保存的ebp值恢复调用者的基址寄存器值。
4.5)执行ret指令返回到调用者程序。
经过上面的讲解,你应该能在脑子了构建比较立体概念关于程序是如何在内存中运行的,从汇编语句到内存具体变化你应该有了一定的了解。