x86架构的概要
完成中间代码的生成之后,需要转成汇编语言,也就是代码生成这一步骤.首先了解计算机系统的架构.
计算机系统架构
计算所包含的基本单元分为:
- cpu
- 存储器
- 其他设备
cpu中含有多个寄存器,寄存器是CPU计算的存储单元,,存储二进制的数据.首先寄存器读取存储器中某个数据,然后计算完毕在写回存储器.物理地址和虚拟地址
由于计算机上有多个进程需要同时占有存储器,为了让所有进程的地址空间都能从0-0xFFFFFFFF,我们将进程空间对应的地址为虚拟地址,虚拟地址通过MMU地址转换映射到物理地址上,物理地址按照单位为页进行分割,一页为4kb或8kb大小,当进程需要新的内存时,会分配新的页给进程.
x86系列的cpu按照64位和32位划分,即包含两个条件:
- 通用寄存器的宽度为32bit.
- 地址空间的长度32位以上.
指令集架构IA-32
x86系列cpu中的32位cpu的指令集架构为IA-32,也是我们要使用的指令集.首先了解一下其中含有的寄存器:
- 通用寄存器.有eax,ebx,ecx,edx,esi,edi,esp,ebp共8个,用于整数运算和指针处理
- 指令指针,eip,用来存放下一条要执行的代码的地址.
- 标志寄存器,用于保存cpu的运行模式(常见的用户模式内核模式)和表示运算状态
- 浮点数寄存器,存放浮点数的寄存器
- 其他
我们主要关注通用寄存器和标志寄存器的使用:
ebp和esp用来操作机器栈帧,esp用来当做栈指针存放栈顶地址的寄存器,其余的6个寄存器可随意使用.
由于寄存器的数量有限,因此在一条语句生成的临时变量过多时,需要将临时变量压入当前栈中,然后在计算完成后弹出,恢复栈的原来状态.
一个函数在调用前,首先需要将当前执行代码的地址push进栈,然后将当前%ebp的值push进栈,因为当前%ebp的值保存着调用者函数的栈底,我们需要在调用函数执行完后,代码地址要弹出,这样能返回继续执行代码,函数栈底也要知道才能返回调用前的状态.存放下一条指令的寄存器为指令指针,也称为程序计数器.
标志寄存器中每一bit用来表示一个标志位,用户可以用的只有状态标志和控制标志,比如说比较标志位用来存放cmp指令的结果,用来做跳转.数据的表现形式
一个整数有32位,如果是有符号整数类型则第一位用来存放正负,1为负,0为正,如果为负数的话后面31位存放的是绝对值的补码.
一个数的补码计算方式如下:
- 先按位取反
- 然后加1
比如3的补码为011->100->101
那么-3在计算机中为11111101
(16进制)
基于x86汇编器编程
gnu汇编指令语法包括:
- 指令,有CPU直接运行的指令,例如
movl %esp %ebp
. - 汇编伪操作.语法为以.开头,伪操作不是由cpu处理而是由汇编器处理的语句,一般用于目标文件中记录元数据或者设定指令的属性等.例如
.string "hello world".text
- 标签,语法为.开头冒号结尾,为汇编伪操作生成的数据或者指令命名的功能,这样就可以在文件的其他地方调用通过标签定义的符号.
- 各种各样的操作数:
- 立即数 相当于字面量,需要在前面加上$符号,否则会变成直接内存引用.
- 寄存器 寄存器都已%开头
- 直接内存引用 直接一个整数字面量表示内存地址.
- 间接内存引用 间接内存引用格式disp(Base,index,scale),地址为disp+(base+index*scale),其中disp为偏移量.index和scale在数组访问时用到.比如说4(%eax)就是访问eax中值再加4的地址.
对于寄存器有外加括号的写法,意思是寄存器所含地址上保存的值.
指令有后缀,例如movb,movw,movl表示操作数的位数为8,16,32位.传输指令
- mov 单纯的1对1的传输
- push,pop 压栈和出栈
- lea 加载地址
- movsx,movzx 伴随有符号扩展/零扩展的数据传输
mov指令后面带有两个操作对象,可以为寄存器,内存,立即数.第一个操作对象为源,第二个为目标.内存的读取方法一般可以通过立即数或者寄存器寻址例如(%eax)
push指令后可以加寄存器或者立即数,将操作对象的值压入栈顶.
pop后加一个寄存器,表示将栈顶值弹出到寄存器内.
lea后加上内存和寄存器,表示将内存的地址直接加载到寄存器,而不是寻址后加载.与mov不同.
算数运算
- add
- sub
- imul 有符号乘法
- idiv 有符号除法
- div 无符号除法
- inc 自增
- dec 自减
- neg 取反
add 将第一个操作数和第二个操作数相加,并将结果写入第二操作数.
sub 将第二个操作数减去第一个然后写入第二个操作数.
imul 将第二个操作数乘上第一个保存到第二个操作数中,不推荐使用无符号mul指令.
div后面只能加上一个操作数用来做除数,由于被除数的位宽必须为除数的倍,被除数固定为%eax和%edx拼接而成,一个保存高32位,一个保存低32位,除法的商保存在%eax余数保存在%edx中.
cltd指令是用来对寄存器edx进行零扩展的指令,为了让32位数据与32位数据相除,对edx需要零扩展.
下面是一个对栈上两个变量做除法的例子:1234movl -12(%ebp),%ecxmovl -8(%ebp),%eaxcltdidivl %ecx
inc,dec指令是用来将后面的操作数的值加1减1
neg指令是用来将操作数的符号反转.
位运算指令
- and 按位与
- or 按位或
- xor 按位异或
- not 按位取反
- sar 算数右移
- sal 算数左移
- shr 逻辑右移
流程控制
- jmp 无条件跳转
- jz,jnz,je,jne 条件跳转
- cmp 数据比较
- test 数据的非0检查
- sete,setne,setg,setge,setl,setle 获取eflags寄存器中的各个标志位
- call 函数调用
- ret 从子程序返回
jmp+立即数或者寄存器,常用的是jmp+一个符号,符号是有标签定义的,例如:123456movl $1,%eax #if语句的then部分jmp end_if0 #跳转结尾else0:movl $4,%eaxend_if0:push %eax
所以中间代码中的Jump节点可以直接转成jmp指令.
条件跳转指令例如jnz+立即数or寄存器,如果标志寄存器ZF为零,程序计数器pc跳转到后面操作数的地址,ZF位由cmp指令操作,cmp比较两个数是否相等,相等置1.
比如说if(x==y)then的语句可以转成:
先说一下cmp指令,cmp指令比较两个操作数的差(第2个-第1个)然后根据结果来设定标志位:
- CF 进位或借位置1
- ZF 比较结果为0置1(相等)
- OF 溢出位
- SF 为负数时置1
操作数的关系 | CF | ZF | OF |
---|---|---|---|
1<2 | 0 | 0 | SF |
1==2 | 0 | 1 | 0 |
1>2 | 1 | 0 | not SF |
test比较两个操作数的逻辑与,根据结果设置标志寄存器eflags中的标志位,test指令本质上和and指令相同,但不会改变操作数的值.
test指令一般用来检查某个位是否被置位.
那么eflags中的标志位如何读取,根据指令sete来读取.sete+操作数,将对应标志位的值置入寄存器或内存.
- sete,setne 对应ZF
- seta,setae 对应CF
setcc指令通常和cmp一起用,在cmp指令调用后利用setcc获取数据比较的结果,例如用cmp指令比较eax寄存器和ecx寄存器的值之后,调用sete指令获取比较结果.
call指令,可以调用由第一个操作数指定的函数,一般是用符号来写成call f
的形式.call指令可分解成两步:1234pushl $next_insnjmp 第1操作数next_insn:
ret指令用于从子函数返回,也等价于popl %eip
,弹出call指令下一条指令的地址.同时函数返回值通过寄存器%eax来保存.
程序调用约定
esp寄存器总是指向栈顶,ebp总是指向当前函数栈的栈底.在调用一个函数的开头一般要创建该函数的栈帧:
在结尾处的处理:
调用的完整过程是:
- 下拉栈顶,然后把要传入的参数从右往左顺序push进栈
- 调用call func语句
- 准备栈帧
- 执行func的代码
- 释放栈帧
- 调用ret
- 释放push的参数地址,即上拉栈顶
寄存器还可以分成两种,调用者寄存器和被调用者寄存器,在发生函数调用时将前者类型的寄存器中的值强制保存,这样在子函数中就可以直接使用这些寄存器,而后者是在被调用函数的开始将要使用的寄存器的值保存下来.前者适合保存一些临时变量,后者适合保存一些生命周期较长的数据.
- caller-save eax,ecx,edx,esp
- callee-save ebx,ebp,esi,edi
x86汇编对象的类
我们生成的汇编代码有三种类型:
程序本身
程序本身有四种情况:是一个标签,一个汇编伪操作,一条指令,一个注释.
那么需要编写表示每种情况的类:123456AssemblyCommentDirective //伪操作InstructionLabelAssemblyCode //用来管理这些类的实例列表表示指令的操作数
包括:立即数,寄存器,内存引用1234567OperandImmediateValueMemoryReferenceDirectMemoryReferenceIndirectMemoryReferenceRegisterAbsoluteAddress表示字面量的类
12345LiteralIntegerLiteralSymbolNamedSymbol //例如源代码里定义的函数UnnamedSymbol //伪操作生成的.;例如`.LC0`
如何生成汇编对象
CodeGenerator类中用来添加一个汇编对象的方式如下:as.mov(imm(0),edx());
对应的汇编代码为:movl $0,%edx
用来返回一个寄存器实例的方法有:
一个寄存器的类中需要有区分寄存器的名称的枚举类型,用一个RegisterClass枚举类,含有8个对象.还需要一个asm.type,来表示需要寄存器的多少位.比如char就是INT8.
表示立即数和引用
总共有如下方法:
- imm(long num) 用来返回一个整数num的值(ImmediateValue类型,$num)
- imm(Symbol sym) 返回一个符号sym的值(ImmediateValue类型,$num)
- mem(Symbol sym) 符号sym的直接地址引用(sym)
- mem(Register reg) 根据寄存器reg返回间接引用地址
- mem(long off,Register reg) 根据偏移量和寄存器reg返回间接引用地址(off(reg))
指令生成
是AssemblyCode类的方法,总共有如下几个方法:
- mov(Register s,Register d)
- mov
- mov
- push
- pop
- add
- sub
- imul
- call(Symbol sym) 使用call指令调用函数sym,函数是一个NamedSymbol
- jmp(Label lab) 标签用来跳转,函数的Symbol用来调用
这些方法都会在一个该类的指令列表中添加一个Instruction类对象.
表示汇编伪操作,标签和注释
首先是伪操作的部分,我们需要用到三个伪操作分别是:
- _file(String name) 使用汇编伪操作.file声明当前的文件名
- _globl(Symbol sym) .globl能使得sym成为全局变量
- _section(String sect) .section切换到sect代码片段
标签和注释: - label(Symbol sym) 输出定义了符号sym的标签
- label(Label label) 输出定义了和标签label同样符号的标签
- comment(String str) 输出注释.
在汇编中跳转的目标有两种,一种是函数入口和字符串常量,属于NamedSymbol,还有一种是后期加入的标签,也就是.xxx:的形式,适用于while,if这些.