第四节、程序的机器语言
一、x86的历史观点
x86架构于1978年推出的Intel 8086中央处理器中首度出现,它是从Intel 8008处理器中发展而来的,而8008则是发展自Intel 4004的。8086在三年后为IBM PC所选用,之后x86便成为了个人计算机的标准平台,成为了历来最成功的CPU架构。
二、程序编码
机器级代码
计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。
对于机器级编程来说,其中两种抽象尤为重要:
①、指令集体系结构(Instruction set architecture ISA)
它定义了处理器状态、指令的格式,以及每条指令对状态的影响。
IA32将程序的行为描述成好像每条指令时按顺序执行的,一条指令结束后,下一条再开始。(实际上处理器并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行完全一致)
②、机器级程序使用的存储器地址是虚拟地址
提供的存储器模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
③、程序存储器(program memory)包含:程序的可执行机器代码、操作系统需要的一些信息、栈、堆。程序存储器用虚拟地址来寻址(此虚拟地址不是机器级虚拟地址)。操作系统负责管理虚拟地址空间(程序级虚拟地址),将虚拟地址翻译成实际处理器存储器中的物理地址(机器级虚拟地址)。
三、数据格式
汇编没有声明数据类型的语句,使用的是代码后缀。
包括,字节 b ,字 w ,双字 1,单精度 s等。。
四、访问信息
与其他等级的编程语言一样,汇编语言能够用许多方式来访问变量。变量有三种基本的存储方式。
1. 全局变量/静态变量- 在程序数据区(program data section)分配
2. 局部变量/参数- 在栈上分配
3. 堆变量- 在堆上分配
①全局,静态变量
全局变量存储在一个固定的地址上(至少对于程序来说,他们是固定的)。访问这些变量的最通常的方式是在指令中明确指出那个固定的地址。
MOV EAX,[1234134H] ; loads EAX with value stored at location 12341234H
INC DWORD PTR TEST2!_nCount ; increments DWORD variable nCount
注意,在symbolic信息可用的时候,debugger会去使用它。
局部变量,参数
局部变量和参数存在于栈上,并且是通过EBP(有时候是ESP)来访问的。优化过的代码通常会清除掉对栈基指针(frame pointer)的依赖,在这样的情况下ESP寄存器被用来访问局部变量,而EBP可以被用来做一个额外的通用寄存器来使用。当你使用一个标准栈基指针的时候,指令看起来应该是这样的。
MOV EAX,[EBP+8] ; load EAX with argument
MOV EAX,[EBP-4] ; load EAX with local variable
有一个记忆的小窍门,当EBP没有作为通用寄存器使用的时候,也就是绝大多数时候,当位移是正的时候,访问的是参数。当位移是负的时候,访问的是局部变量。
注意,典型的第一个传递给函数的参数是EBP+8
②堆变量
堆变量存在于堆上,他们是通过指针来访问的。典型情况下需要不只一条指令来访问堆变量。
MOV ESI, TEST2!_m_pFileList ; load the pointer
MOV EAX, [Esi+4] ; read second DWORD (pszName) in heap
另一个需要注意的是,大多数编译器会将经常访问的变量放到寄存器中,以便于提高访问速度。尤其是精简指令计算机。
执行流控制
控制流命令要不就是有条件的(条件满足的时候),要不就是无条件的。这些语句支持函数调用,if-then-else,switch case等高级的语言成分。
③无条件跳转指令
1. JMP命令
这个命令简单的设置EIP寄存器为下一条指令的地址。没有任何数据会被存储到栈上,并且不会设置任何标志位。JMP被用在固定的指令分支上。大多数的if-then-else语句族至少需要一条JMP指令。
2. CALL命令
这条指令先存储EIP的值到栈上,然后设置EIP为下一条指令的地址。将EIP压栈允许程序在结束了函数调用之后,回来继续执行CALL语句后面的语句。
对于JMP和CALL指令来说,操作数可以是固定的地址,寄存器的值,或者一个指向分支地址的指针。
3. RET命令
RET指令将当前栈上的值赋给EIP寄存器。该命令用来为传递给栈的参数修复栈指针。
4. INT命令
当INT命令的操作数是一个中断号的时候,该指令会引发一个软件中断。这个与CALL指令差不多,不同之处是EFLAGS寄存器被压入栈中。还有,如果是在user mode中被调用,在切换到kernel mode时也会发生将EFLAG寄存器压栈的操作。中断函数结束的时候,随着RETI指令的执行,EFLAGS寄存器和EIP都会从栈中恢复。
④、条件跳转指令
1, LOOP Adress
LOOP指令被用来实现高级语言中的循环。直到ECX(计数器)的值为0的时候,它才会走向分支地址。如果ECX不是0,那么ECX会被减一,然后继续循环操作。
XOR EAX,EAX ; clear EAX register
MOV ECX, 5 ; load loop count
START:
ADD EAX,1 ; add one to eax
LOOP START
2. JNX,JE等等
根据条件来跳转的指令会去判断所指定的条件是否为真,若果是就执行跳转。比如,JNZ(jump not zero),操作数中指定的地址直到ZERO标志位被设置为1的时候才会被转过去。这些指令主要被用在if语句块中。
XOR EAX,EAX ; clear eax
MOV ECX,5
START:
ADD EAX,1 ; add one to EAX
DEC ECX ; decrement loop counter
JNZ START
五、控制
程序不可能一顺到底的执行,需要有一些分支流程控制的语法,对高级语言来讲,有分支循环等,对于汇编,有一个“跳”,或者选择性跳,跳转指令本身非常简单,仅仅一个jmp指令,类似于c语言的goto,语法为: label: ... jmp label 跳转分为段跳转(小于128字节),远跳转(分段模式下跨段跳转),近跳转(其他),不过这些在AT&T里编译器会根据参数的 变化而选择性的生成机器码,但对于MASM,需要自己指定,jmp near ptr label, jmp far ptr label。 但本质上讲,倘若只有这样的jmp,那不论如何跳都将是个死循环,所以便有了条件跳转(Jcond),在一定条件下进行跳转,这里所谓的条件,仍然是eflags的不同标记位,如下:
|
第七节过程
过程可以理解为c中的函数,当调用者(caller)调用被调用者(be caller)的时候,系统会为被调用者在栈内分配空间,这个空间就称为栈帧。栈的结构大概如下:
程序栈是向低地址生长的栈,与数据结构当中的栈结构类似,有后进先出的性质,寄存器%esp(stack pointer)保存栈顶指针的地址,寄存器%ebp(** pointer)保存帧指针的地址。 程序执行的时候,栈指针可以移动,以便增大或者缩小程序栈的空间,而帧指针是固定的,因为大多数程序栈中存储的数据都是相对于帧指针的(帧指针+偏移量)。
当调用者调用另一个过程的时候:
- 首先,如果这个被调用过程如果有参数的话,调用的栈帧中会构造这些参数,并存入到调用者的栈帧中(所以上面的图参数n...参数1,就是这个原因了);
- 将返回地址入栈。返回地址是当被调用过程执行完毕之后,调用者应该继续执行的指令地址;它属于调用者栈帧的部分,形成了调用者栈帧的末尾
- 到这一步就进入了被调用者的栈帧了,所谓当前栈帧。保存调用者的帧指针,以便在之后找回调用者的程序栈;
- 最后进入程序执行,一般过程会sub 0xNh %esp来分配当前程序栈的大小,用来存取临时变量啊,暂存寄存器的值啊等等。
- 如果被调用者又要调用另一个过程,回到第一步即可;
- 当过程结束之时,会将栈指针,帧指针恢复,经常会在反汇编中看到如下: 同时,返回地址会被恢复到PC。
- 这时回到了打调用者应该继续执行的地方。
上面的文字可以更概括,反汇编一个过程(函数)会有建立(初始化),主体(执行),结束(返回)。之前很容易把栈和堆搞混(不是数据结构里面),找到一个好文章与大家分享:栈和堆的区别。据说被转了无数次了,说明写的不错。 过程调用和返回在汇编语言中分别用call和ret(return)来实现。call和ret的做法不是很透明,
- call将返回地址入栈,并将PC跳转到被调用过程的起始地址;
- ret与call相反,从栈中弹出返回地址,并跳转PC。
参考文献
一、 百度百科
二、 博客园中道学友
三、 菜鸟的自留地博客
四、 电子发烧友官网
疑问与解答
本周的内容和上学期汇编内容有一些相似,但是讲解更为深入。
本周问题主要是汇编语言没有数据类型声明,很容易造成输入错误。