最近试着写操作系统真是狠狠地感受了一下汇编的重要性... 所以特地再回来加强学习一下自己的汇编水平, 好了, 不多说, 这几天就要开始继续看书了...

3.1 历史观点

接下来要书里要讲的是汇编语言, 这里会先讲IA32(从Intel早期的16位处理器发展起来的), 接着讲x86-64(最初是由AMD发展过来的). 在这里我觉得有必要讲一些背景知识, 我们平时说的x86, 源于Intel 1978年发布的新的微处理器8086, 处理器本身并没什么, 但是伴随该处理器机器语言指令集就是大名鼎鼎的x86. 一般人们说的x86架构, 意思就是建立在这套指令集下的处理器... 由于x86对后世的深远印象, 它成为了Intel处理器系列的俗称, 所以我们通常也把Intel系列处理器称作x86... 总结来说就是x86既指的是Intel处理器, 又可以是那一套指令集. 那x86-64是怎么回事呢?  这是64位微处理器架构及其相应指令集的一种,也是Intel x86架构的延伸产品。“x86-64”1999由AMD设计,AMD 首次公开 64 位集以扩充给 IA-32,称为 x86-64(后来改名为 AMD64). 后面也被Intel采用了, 为了区分两者, 后来就分别叫做 AMD64 和 Intel 64, 但是由于AMD64和Intel64基本上一致,很多软硬件产品都使用一种不倾向任何一方的词汇来表明它们对两种架构的同时兼容。出于这个目的,AMD对这种CPU架构的原始称呼——“x86-64”被不时地使用. 通过这个背景介绍我们至少知道了以下几点信息.

1. 我们要学的IA32指令集, 其实就是x86指令集.(也就IA64指令集, 值得注意的是, 这是一套Intel开发的全新指令集, 并不兼容IA32.)

2. 并不存在86位指令集, x86数字更多其实指的反而是32位...(我没学计算机之前一直以为86指的是86位...)

3. 由于x86-64是IA32发展的延伸, 所以使用该指令集的机器同样向下兼容IA32...

 

3.2 程序编码

一个C语言要生产汇编代码, 我们需要使用编译器, 这里书中用的是gcc编译器(在比较新的osx上虽然你用的是gcc, 但其实gcc只是另一个编译器clang的别名, 想用gcc要自己下载), -O1 表示优化级别为第一级优化,  优化级别越高生成难度越大, 同时所需要的编译时间也更长, 而且生产代码甚至会变得难以理解, 处于学习目的我们使用一级优化, 如果处于程序性能选择二级优化被认为是最好的选择...  

gcc -O1 -o p p1.c p2.c

这里简要解释一下敲完这一句指令之后发生了什么 :

1. C预处理器扩展源代码主要完成插入#include指定的文件以及扩展所有用#define定义的宏, 你会发现所有#开头的语句都是给预处理器看的...

2. 编译器处理预处理器处理过的编代码生产汇编代码p1.s 和 p2.s

3. 汇编器将汇编代码转化成二进制目标代码p1.o 和 p2.o, 目标代码是机器代码的一种形式(我个人理解是不完整的机器代码), 包含所有的指令的而二进制表示但是还没有填入地址的全局值.

4. 连接器将目标代码文件与实现库函数(printf 之类的)合并, 并最终生产可执行文件p, 这是机器代码的另外一种形式(完整的机器代码, 可以被处理器执行).

这一部分简单的说好像也就这么简单, 但是这里面其实很复杂... 要研究的话有太多东西要研究了, 所幸我们现在只看关于汇编的这一部分.

 

这里我还要先提一下, 我们要说的汇编代码, 属于ATT(AT&T)风格的汇编代码, 这也是我们所用的gcc, objdump和一些工具的默认格式, 因为这种风格的汇编是贝尔实验室的产物, 而贝尔实验室隶属于ATT. 但是需要知道的是, 仍然有另外一种格式叫做Intel格式, Intel格式的汇编代码在微软的工具以及Intel的文档上运用较多(我学着写的操作系统的时候启动扇区的汇编好像也是好像也是Intel格式, 用的nasm编译的, 当然支持该风格的编译器还有微软的MASM). 至于ATT和Intel汇编格式的区别 :

1. Intel 省略指示大小的后缀, 比如使用mov 而不是movl (指示要移动的数据的大小为长字)...

2. Intel 省略寄存器名字前面的 % 号, 比如使用了 esp 而不是 %esp.

3. Intel 表示寻址的格式为 DWORD PR [ebp + 8] 而不是 8 (%ebp)

4. Intel 中 MOV eax 0 是把0存到eax寄存器, 而表达相同的意思的ATT代码应该是 0 放在左边 而 eax 放在右边即为 movl $0 %eax...

3.2.1 机器级代码 

正如我们之前提过的, 计算机中一个很重要的概念就是抽象, 而机器级编程的抽象主要是这两种:

1. 机器级程序的格式和行为, 定义为ISA(指令集体系结构), 它定义了每个指令对状态的影响. 使得处理器看上去是一条一条按顺序地执行指令, 然而实际上处理器的实际硬件行为要比我们所模拟的复杂得多...

2. 第二种抽象是机器级程序所谓的存储地址其实是虚拟地址, 该模型看上去想一个很大的字节数组, 而实际上这确实由多个硬件存储器和操作系统的软件组合起来形成的...

 

IA32的机器代码和原始的C代码差距巨大,  在这里我们可以看到一些在C语言下隐藏的处理器状态 :

1. 程序计数器(program coutner, IA32中称之为PC, 用%eip表示), 用来指示下一条要执行的指令在存储器中的位置.

2. 整数寄存器文件包含8个命名的位置, 分别存储32位的值(这句话描述实际不是很确切, 这里要表达的意思应该是IA32的机器中有8个整数寄存器, 每个都能存储32位的整数值...)

3. 条件码寄存器用来执行最近执行的算术和逻辑指令的状态信息, 他们可以实现控制或数据流中的条件变化(if, while的底层实现)...

4. 一组浮点数寄存器...

同时我们在C语言中提供的在存储器中定义和分配各种数据类型的对象, 在机器代码上,  这个存储器实际上只是一个很大的, 按字节寻址的数组而已. 所以数组, 结构体等等, 在机器代码中都是用一组连续的字节表示; 同时有无符号整数以及指针,  实际都可以看作是这个大数组中某段连续的字节而已... 所以我们可以看到程序存储器(也就是我们这里提到的这个数组)实际上主要包括了这些数据信息 :

1. 程序的可执行机器代码

2. 操作系统需要的一些信息

3. 管理过程和调用的运行时栈(我们平时函数调用出入栈, 局部变量的各种操作, 都在这上面...)

4. 用户分配的存储器块(其实就是堆, 也就是我们用malloc动态分配的内存)

 

3.2.2 代码示例

这里通过编译简单的C语言程序产生的汇编代码以及反汇编等等展示来表明(这部分我不贴出来是因为我发现产生的汇编代码和它这里写出来的根本就不一样)下面几点 :

1.  IA32 是变长指令集(我大二上学期学过MIPS, 是的定长指令集), 长度从1到15个字节不等, 越常用的所需字节越少.

2. 对于输出的二进制字节码, 从给定的位置开始(当然这个位置必须正确), 只能解码为唯一的机器指令(也就是异前置码, 一个指令的二进制串不可能是另一个的前一部分. 1, 2点倒是和霍夫曼编码很像啊...)

3. 反汇编只是(也只需要, 根据第二条可以得出)利用机器代码文件来确定汇编代码, 生产的指令中会忽略指令的后缀(例如b, w, l)之类的大小指示符.

 

当然, 这些.o结尾的目标代码要想实际执行还需要用链接器链接在一起, 同时着一些目标代码文件中必须含有一个main函数作为程序的入口...  最终生产的可执行代码与之前还未被链接目标代码有两点不同 :

1. 目标代码的地址和最终的可执行代码的地址不同...

2. 可执行代码确定了全局变量的地址.

 

3.2.3 关于格式的注解

按照书中的示例, 我自己把那一段代码敲出来编译的结果...

int simple(int *xp, int y){
    int t = *xp + y;
    *xp = t;
    return t;
}
    .section    __TEXT,__text,regular,pure_instructions
    .macosx_version_min 10, 11
    .globl    _simple
    .align    4, 0x90
_simple:                                ## @simple
    .cfi_startproc
## BB#0:
    pushq    %rbp
Ltmp0:
    .cfi_def_cfa_offset 16
Ltmp1:
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
Ltmp2:
    .cfi_def_cfa_register %rbp
    addl    (%rdi), %esi
    movl    %esi, (%rdi)
    movl    %esi, %eax
    popq    %rbp
    retq
    .cfi_endproc


.subsections_via_symbols

所有以 . 开头的都是知道汇编器和链接器的命令, 这不是本节讲述的重点, 所以之后我们的讲解会忽略这些行...

 

3.3 数据格式

由于IA 32最早是由16位体系发展过来的, 所以里面的word表示的16位, 而32位就算是double words, 64位是 quad words ...  和C语言的对应关系大概可以用这张图来概括 :

这里可以看到double浮点类型在这里也使用l后缀, 但是这里并不会歧义, 因为浮点数将使用与整数完全不同的指令和寄存器.

 

3.4 访问信息

IA中有8个32位值的整数寄存器, 如图所示. 据我了解, 其实在早起的x86架构下这八个寄存器只有16位, 其实就是每个寄存器的低16位部分, 例如从ax到eax, 其中e代表的就是extend... 同时在最开始的时候这些寄存器的名字反映的是他们的用途, 但是在平坦寻址中, 他们的使用以及没有限制(这两句不太理解), 这意味着大多数情况下前六个寄存器可以看做通用寄存器... 但是其实有些时候还是有区别的, 关于这一点之后会讨论...

 

为了便于理解和记忆, 我还是想说一下这八个寄存器的全名, 分别是 : accumulator, count, data, base, source index, destination index, stack pointer, base pointer. 同时你可以发现前4个寄存器还能独立地第16位中的高8位字节和低8位字节... 这也是处于对于较古老的处理器的兼容...

 

3.4.1 操作数的指示符

大多数指令都可以有一个或者多个操作数, 如下图IA32支持多种操作数模式. 但是根据操作数类型的不同又可以把操作数分为3类.

1. 立即数 : 也就是常数值, 在ATT风格里面常数是用 $ 开头, 然后后面跟着标准的C语言表示法表示的整数(0x开头表示16进制, 0开头表示8进制), 可以容纳所有的32为数字, 但是编译器可能会使用一个或者两个字节编码(这里我觉得奇怪的地方在于对于一个要用32位来表示的整数, 两个字节仅仅够保存这个数, 那么如何表达实际操作的信息呢...)

2. 寄存器 : 它表示的是寄存器的内容, 可以是32位的八个寄存器中的任何一个, 也可以是16位的(32位的前四个寄存器中的低16位寄存器), 同时还可以是8位的(还是前4个寄存器)... 从图中可以看出其实有时候我们还会把所有的寄存器看出一个数组, 用R[E]来表示某个寄存器的值....

3. 存储器引用 : 也就是根据提供的有效地址进行访问, 有各种不同的形式. 要注意的是 Imm(Eb, Ea, s), 该方式是通用表达形式, 可以发现其他方式都是该方式的特殊情况, 这里比例因子s必须是1, 2, 4, 或者8...

 

3.4.2 数据传送指令

这个图里效果那一栏要仔细看 : 虽然箭头是指向左边的, 但是其实你要知道在指令当中D是最右边的操作数, 所以实际的数据流动的方向是向右, 你会发现在类unix系统的终端中, mv也是从左向右, 说起来unix也是贝尔实验室的产物, 不知道有没有联系呢...(Intel风格才是向左边) 同时对于mov而言不允许两个操作数都指向存储器的情况...

还要说明的是最后两个和栈有关的移动指令, push 和 pop... 有必要知道的是 : 栈是向下增长的, 也就是说最新放入的元素总是处于较低的地址中(也就是放在栈顶), 而指向栈顶的指针也就是之前八个寄存器中的%esp中就存放着栈顶的地址... 所以popl %ebp所进行的操作就是先将栈指针减4, 然后把%ebp中保存的数写入, 等价于 :

subl $4, %esp

movl %ebp, (%esp)

所以可以发现栈有以下特点 :

1. 栈读取方向是向着高地址, 可以看到下图中0x104, 如果我们movl (0x104)的话, 读到的将是0x123...

2. 栈的增长方向是向着低地址.

3. 压栈操作先减少栈指针(相当于分配空间), 再存值; 出栈操作先读值, 再增加栈指针(释放空间).

 

从题目中可以得出来的一点非常重要的信息在于 : 只能使用双字节(e开头的)寄存器作为地址引用...

 

3.4.3 数据传送实例 

从途中我们可以得出的信息在于 C语言中所谓的指针实际上就是数据的地址, 同时取地址操作就是将该指针存放在寄存器当中然后在寄存器引用中使用这个寄存器.

我个人感觉题目中值得注意的反而是 : 在C语言中, 对于既涉及到大小又涉及到符号变化的强制类型转换, 操作时会先改变大小... 这其实我们之前就学过, 这里可以更好的理解这句话, 因为对于符号数的解释是在汇编级别之上的, 到了汇编这一层只有在扩展时会涉及有无符号数...

 

posted on 2016-06-24 18:22  内脏坏了  阅读(357)  评论(0编辑  收藏  举报