Preview、计算机的结构体系:
(每条线怎么走的都能理解最好,笔者不多阐述)
“通俗一点,主存相当于图书馆的书架,GPRs相当于宿舍的书架,你要在宿舍学习这本书必然是要从书架上把书拿出来才用”
——————————————————————————————分界线
首先我们温习一下程序的生成过程,用hello.c来举个例子:
在整个编译的过程中,编译器会完成大部分工作,将把用C语言提供的相对比较抽象的执行模型标识的程序转化成处理器执行的基本的指令,汇编代码比较接近于机器代码,机器代码是二进制格式,而汇编代码有可读性更好的文本形式。一条机器语言只执行一个很基本的操作。
Main Frame:
一、程序计数器(program counter)
是用于存放下一条指令所在单元的地址的地方。当执行一条指令时,首先需要根据PC中存放的指令地址,将指令由内存取到指令寄存器中,此过程称为“取指令”。与此同时,PC中的地址或自动加1或由转移指针给出下一条指令的地址。此后经过分析指令,执行指令。完成第一条指令的执行,而后根据PC取出第二条指令的地址,如此循环,执行每一条指令。
二、程序内存包含:
程序的可执行机器代码,操作系统需要的一些信息,管理过程调用和返回的运行时栈,以及用户在程序中分配的内存块(malloc,for example)。
三、IA32:
ISA(操作指令集架构Instrucrion set architecture)它规定了如何使用硬件,是对硬件的抽象,而又建立在软件的层面之上,所以它是介于软件与硬件之间重要的抽象层。而IA32可以被目前通用的x86-32向后兼容。程序的设计是一个不断抽象化的过程,我们现在写的代码具有高度的抽象性,可是早期的代码并不是,它们密切的和机器有关。
ISA规定了一台机器的指令系统涉及到的所有方面,包括所有指令的指令格式、功能,通用寄存器GPRs的个数、位数、编号和功能,存储地址空间的大小,编制方式,大小端,指令的寻址方式,等等等等。
在IA32体系中,有八个GPR,一个标志寄存器EFLAGS,PC为EIP,可寻址空间为4GB,0~0xFFFFFFFF,小端。32位寄存器包括了EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI这八个寄存器组织。而x86-64还多了八个Gprs,%r8到%r15,寄存器里面存放的是主存中的地址。
四、IA-32常用指令类型:(其中的重点是(1),(2))
(0)虽然后面你还能看得到下面这个图,数据类型:
所以数据传送指令有:movb(传送字节),movw(传送字),movl(传送双字),movq(传送四字)。传送的是字节还是字取决于指令中涉及的寄存器是8位还是16位。
操作数(Operand):
- 立即数(Imm,immediate): 直接表示常数值。例子:$
577
, $0x1F,$-147
。 - 寄存器(register):某个寄存器里的内容。例子:
%rax
,%ecx
。 - 内存引用(只要有括号都是访问内存):根据计算出来的地址访问内存中的位置。例子:
(%rax)
,(0x100)
。
引用内存的多种形式:
- (R) : Mem[Reg[R]] : 直接访问。如:
(%rax)
。 - D(R) : Mem[Reg[R] + D] : 访问原始地址加上偏移量后的地址。如:
8(%rbp)
, 访问的是 %rbp + 8 地址的值。 - D(Rb, Ri, s) : Mem(Reg[Rb] + Reg[Ri] s) : 比例变址寻址。如:
4(%rax, %rdx, 4)
,访问的是 %rax + %rdx 4 + 4地址的值。 【此段摘自基友的博客】
SRC和DST:
双操作数指令中,第一操作数为源操作数,第二个操作数为目的操作数。为了见名知义,所以在学习指令格式时,通常分别用SRC(source)和DST(destination)表示源操作数和目的操作数,但在指令中SRC用立即数、寄存器或存储器替代;DST用寄存器或存储器替代。
(1)传送指令MOV类:(要求理解)
MOV Src, Dst :把Src上面的数据传送到Dst上。两个操作数至少有一个为register。源操作数和目的操作数的组合是有要求的,比如你不能由内存到内存。
通用数据传送指令:
MOV:一般传送,包括movb、movw和movl等,相同大小位段可以传送,movabsq代表传送绝对四字。
MOVS,MOVZ:符号扩展传送和零扩展传送,如movsbw、movswl等 MOVZ:零扩展传送,如movzwl、movzbl等 ,主要用在低位比如%ax到%rax这种,因为字段大小不统一,所以需要低位(符号or零)扩展到高位之后再传送。
XCHG:数据交换 PUSH/POP:入栈/出栈,如pushl,pushw,popl,popw等
地址传送指令 LEA:加载有效地址,你譬如说,leaq 7(%rdi, %rsi, 4), %rax # 设%rdi总存数据x,%rsi中存数据y,则这条指令是将 x+4y+7 存入%rax中。leaq指令: leaq Src, Dst:直接将有效地址(即把括号内的值,不读入对应内存的数据)写到目的。
输入输出指令 IN和OUT:I/O端口与寄存器之间的交换
标志传送指令 PUSHF、POPF:将EFLAG压栈,或将栈顶内容送EFLAG
到这里,我们先举出一个简单的swap例子来看看:
void swap(long *xp,long *yp){
long t0=*xp;
long t1=*yp;
*xp=t1;
*yp=t0;
}
//它对应的汇编语言:
swap:
movq (%rdi),%rax
movq (%rsi),%rdx
movq %rdx,(%rdi)
movq %rax,(%rsi)
ret
我们做一个图示来康康其细节:
然后,我们再进一步,看看略复杂的例子,用第五版块的这个例子:
int add(int i,int j)
{
int x=i+j;
return x;
}
我们只看划红线的:
第一行 push %ebp 将32位(四字节)寄存器ebp的内容压入栈(找出一个栈顶区域,把ebp压入)
第二行 32位寄存器传到另一个32位寄存器,将esp的内容传入ebp
第三四行 把某个寄存器单元的内容放入某个寄存器中
第五行 完成有效地址装入,后略
红线标出的是传送指令。
2.定点算术运算指令:
首先:在整数加/减运算部件 基础上,加上寄存器、 移位器以及控制逻辑, 就可实现ALU、乘/除 运算以及浮点运算电路。
加 / 减运算(影响标志、不区分无/带符号:
ADD:加,包括addb、addw、addl等
SUB:减,包括subb、subw、subl等 增1 / 减1运算(影响除CF以外的标志、不区分无/带符号),比如
add S,D | D = D + S |
sub S,D | D = D - S |
imul S,D | D = D * S |
INC:加,包括incb、incw、incl等 ,比如INC D---》D=D+1
DEC:减,包括decb、decw、decl等
取负运算(影响标志、若对0取负,则结果为0/CF=0,否则CF=1)
NEG:取负,包括negb、negw、negl等
比较运算(做减法得到标志、不区分无/带符号)
CMP:比较,包括cmpb、cmpw、cmpl等
乘 / 除运算(不影响标志、区分无/带符号)
MUL / IMUL:无符号乘 / 带符号乘
DIV/ IDIV:带无符号除 / 带符号除
3、按位运算指令
逻辑运算(仅NOT不影响标志,其他指令OF=CF=0,而ZF和SF根据结果设置:若全0,则ZF=1;若最高位为1,则SF=1 ) NOT:非,包括 notb、notw、notl等,比如NOT D--》D=~D
AND:与,包括 andb、andw、andl等
OR:或,包括 orb、orw、orl等
XOR:异或,包括 xorb、xorw、xorl等
TEST:做“与”操作测试,仅影响标志
移位运算(左/右移时,最高/最低位送CF)
SHL/SHR:逻辑左/右移,包括 shlb、shrw、shrl等
SAL/SAR:算术左/右移,左移判溢出,右移高位补符
ROL/ROR: 循环左/右移,包括 rolb、rorw、roll等
RCL/RCR: 带循环左/右移,将CF作为操作数一部分循环移位,比如:
and S,D | D = D & S |
sal k,D | D = D << k |
shl k,D | D = D << k |
sar k,D | D = D >>算术k |
shr k,D | D = D >>逻辑k |
eg.*12函数:
4.控制跳转指令
到目前为止,我们只考虑了顺序的代码行为,就是一条条的运行。而C语言中某些结构有别的条件来执行操作指令的执行,主要有两种低级的机制:测试数据值,然后根据测试的结果来改变控制流或者数据流。与数据相关的控制流是更常见的实现有条件行为的方法。而jump指令可以改变一组机器代码指令的执行顺序。我们需要一些基本的储备知识:
条件码:除了整数寄存器,CPU还维护一组单个位的条件码,描述了最近算术逻辑操作的属性。(这就当然和标志寄存器有关啦)
CF:进位标志。最近的操作使得最高位产生了进位。
ZF:零标志。最近的操作得出的结果为0.
SF:符号标志。最近得到的操作结果为负数
OF:溢出标志,操作导致补码溢出,正溢出或者负溢出
访问条件码:
条件码一般不会直接读取。常见的使用方法有三种:1)根据条件码的组合将一个字节设置为0 or 1. (2)跳转到程序某个其他的部分 (3)可以有条件地传送数据对于第一种情况,我们有一个SET指令。我们不多说。
比较CMP和测试TEST指令:
非常常见的比较和测试指令:这两种指令不修改任何寄存器的值,只设置条件码
CMP (cmpb, cmpw, cmpl, cmpq),主要是基于CMP S1,S2 ---> S2 - S1
CMP S1, S2:就是计算S2 - S1,以设置条件码得以看出比较的结果。
CF = 1: 发生了进位或借位(这里做减法一般是借位,借位了就表明S2 < S1)
ZF = 1: S1 = S2
SF = 1: S2 - S1 < 0(补码运算意义上的)
OF = 1: (a > 0 && b < 0 && (a - b) < 0) || (a < 0 && b > 0 && (a - b) > 0)
TEST (testb, testw, testl, testq) ,主要是基于TEST S1,S2 ----> S1&S2
TEST S1, S2:就是计算S1 & S2,以设置条件码。
ZF = 1: S1 & S2 = 0
SF = 1: S1 & S2 < 0(补码运算意义上的)
经常使用这个指令测试一个数是不是负数:testq %rax, %rax
SET指令:(貌似不重要)
单目SET类的指令可以将一个字节的值设置为条件码的某种组合,这种指令的目的操作数是低位单字节寄存器之一或一个字节的内存位置(如%al),一般是配合比较和测试指令使用,下面列出常用的SET类指令:(此段set摘自基友博客)
指令 | 同义名 | 效果 | 设置条件 |
---|---|---|---|
sete D | setz | D <– ZF | 相等/零 |
setne D | setnz | D <– ~ZF | 不等/非零 |
sets D | D <– SF | 负数 | |
setns D | D <– ~SF | 非负数 | |
setg D | setnle | D <– ~(SF ^ OF) & ~ZF | 有符号> (greater) |
setge D | setnl | D <– ~(SF ^ OF) | 有符号 >=(greater or equal) |
setl D | setnge | D <– SF ^ OF | 有符号<(lower) |
setle D | setng | D <– (SF ^ OF) | ZF | 有符号<= |
seta D | setnbe | D <– ~CF & ~ZF | 无符号> (above) |
setae D | setnb | D <– ~CF | 无符号>= |
setb D | setnae | D <– CF | 无符号< (below) |
setbe D | setna | D <– CF | ZF | 无符号<= |
————————————条件码实现本质(考完半期了,这个果然重要,哎)
跳转指令:(Significant)
跳转指令会导致跳转执行程序,跳转的destination一般有一个label指明,比如.L1这一行。
我们从jmp这个直接跳转指令开始,jmp是一个无条件跳转(直接跳转or间接跳转),对于直接跳转,比如
jmp .L1就直接跳到.L1这一行;对于间接跳转。跳转目标是从寄存器或者内存中读出。比如jmp *%rax用寄存器rax的值作为跳转destination,而jmp *(%rax)是以rax内的值作为地址,从内存中读出跳转目标。除此之外,还有:
他们都是有条件的,当条件满足会跳到一条带Label的目的地。
if-else语句:
条件传送语句实现条件分支:
转移控制CALL指令:
将控制从函数P转移到函数Q只需要简单地把程序计数器的指令地址值修改位Q的代码起始位置。并且为了返回,处理器必须记录好继续P的代码执行位置。在x86-64这个信息是由call Q来调用Q并且记录。该指令把返回地址A压入栈中,并且修改PC中的值为Q的起始地址。这是给出call和相应的ret指令:
call Label //过程调用
call *Operand //过程调用
ret //从过程调用中返回
还有栈指针%rsp的概念:一个过程共享一个栈指针,而%rip是程序计数器。
学完这些,我们先举一个含数组的例子来看看:
int sum(int a[ ], unsigned len)
{
int i,sum = 0;
for (i = 0; i <= len–1; i++)
sum += a[i];
return sum;
}
//当参数len为0时,返回值应该是0 ,但是在机器上执行时,却发生了存储器访
//问异常。Why?
sum: …
.L3: …
movl -4(%ebp), %eax
movl 12(%ebp), %edx
subl $1, %edx
cmpl %edx, %eax
jbe .L3 …
/*“cmpl %edx,%eax”执行结果是 CF=1, ZF=0, OF=0, SF=0, 说明满足条件,应转移到.L3执行! 显然,对于每个 i 都满足条 件,因为任何无符号数都比32个1小,因此循环体被不断执行, 最终导致数组访问越界而发生存储器访问异常。*/
五、回顾:指令和数据:
指令在执行的过程中,指令和数据同时从存储器取到CPU,存放在CPU内的register里面,指令在IR,数据在GPRs中。
指令需要给出一些机器能看懂的信息,如操作码(做什么操作),操作数i,j,k,etc,目的操作数的地址(寄存器编号、存储地址),存储地址的描述和操作数的数据结构密切相关。指令分为微指令(硬件范畴),伪指令(软件大范畴)、机器指令(The interface between software and hardware)以及机器指令对应的形象化符号化的汇编指令,后两周都是和具体的机器结构有关,属于机器级指令。比如下例:
其中前面三行给出了生成汇编语言的三种方式,目标文件test.o可以反汇编到汇编语言。
(细心的你可能会发现左右汇编有点不一样,这是进制的问题,右侧三行分别是指令的位移量,机器指令和汇编指令)
Linux里面,test是最终可执行文件,没有后缀。而.o文件是可重定位的目标文件。而对他们同时反汇编:
而对于可执行文件的存储器映像(文件->虚拟内存区,虚拟内存的介绍)我们会在之后的课程中学习。
{附: 1)文本文件:这类文件以文本的ASCII码形式存储在计算机中。它是以"行"为基本结构的一种信息组织和存储方式。
2)二进制文件:这类文件以文本的二进制形式存储在计算机中,用户一般不能直接读懂它们,只有通过相应的软件才能将其显示出来。二进制文件一般是可执行程序、图形、图像、声音等等。}
——————————————————————————————————分界线
汇编语言初探: 代码示例:
void multstore(long x,long y,long *dest)
{
long t=mult2(x,y); //mult2 is a function which is realized to "multiply"
*dest =t; //store in dest address
}
//Then we compile this code pile in 命令行,like this
linux> gcc -Og -S mstore.c
//gcc will run the complier and produce a mstore.s ,but not do further work
Compile code file contains many kinds of statements,in this case,it contains:
multstore:
pushq %rbx
movq %rdx,%rbx
call mult2
movq %rax,(%rbx)
popq %rbx
ret
每一行代码都对应于一条机器指令,比如pushq指令就是表示将寄存器%rx的内容压入程序栈中。
而如果我们用linux> gcc -Og -c mstore.c就会产生目标代码文件mstore.o这个二进制文件。这就是目标代码,机器执行的程序只是一个字节序列,一个二进制序列。
补充:编译选项-Og是告诉编辑器使用胜场符合原始C代码整体结构的机器代码的优化等级,因为较高级别的优化会产生代码变形的现象。
而查看机器代码就要用OBJDUMP了,这个在我linux工具链的水文里面应该有提到。
传统的objdump: linux> objdump -d mstore.o ,下面是csapp的解读:
2.访问信息:
_____________________________下课了,等会儿继续更
Appendix:
Inspiration from CSAPP &NJU Professor.Yuan &Dear friend hzy &and very little from my ics teachr.