Songqian Li's Blog

去历史上留点故事

针对汇编

image.png
几个知识点:

  1. 第 1 行和第 4 行的 mov 操作,机器码第 1 个宇节都是B8,而另外第 2、3 行同样是 mov 指令,机器码却有天壤之别,似乎找不到共性。原因是机器码是由很多部分组成的,比如指令前缀、主操作码字节以及寻址方式字节。寻址方式由 ModR/M 字节、SIB 字节、位移量、立即数组成。第 1 行和第 4 行的 mov 的机器码中第 1 宇节都是B8 ,其原因是寻址方式都是立即数。第 5 行的“无条件跳转 jmp”对应的机器码第 宇节是EB。 还有"小于时跳转 j1"、"等于时跳 je ",“不小于等于时跳转 jnle” ······若对此感兴趣,可以参考指令格式。
  2. 第 1 行的 mov 指令,$$表示的是所在的 section 的起始地址,由于这6行代码中没有定义section,故nasm默认把全体文件当成一个大的section,全体文件自然偏移地址为0,所以在反汇编代码那列中,起始地址$$被置换为 0
  3. 第 2 行代码是真指令,不牵扯到符号转换,所以反汇编后的代码同源码一致。
  4. 第 3 行引用了 var 变量的值,[]符号是取所在地址处的内容。在响应的反汇编代码中,响应的第三行中 var 这个符号地址被编译器替换为 0xd。结合地址列查看一下内容列,地址为 0xd 的内容为 99,这正是 var 变量的值。
  5. 第 4 行源码为label: mov ax,$,label 是个标号,代表指令mov ax, $所在地址。$是个隐式的标号,表示当前行地址。按理说这两个标号值应该是一致的。在地址列第 4 行查看,本行 mov 指令地址是0x8,在反汇编代码列查看,$被替换为0x8,吻合。
  6. 第 5 行的jmp label编译后被替换为jmp short 0x8,这是短跳转指令,0x8是第 4 行 mov 指令的地址,吻合。
  7. 第 6 行是数据定义,dw 是定义一个字,双字节,并且 x86 是小端字节序,低位的 99 在低地址,高位的 00 在高地址;
  8. CPU 是不去判断给它的内容是指令还是数据,它也分不清楚。CPU 执行指令时是顺次向下执行的,所以如果没有第 5 行的jmp形成死循环,CPU 执行到第 6 行时,会把 var 变量的值 99 当成指令,是否会报错还不得而知,这得看给它的数是否恰好能成为个指令。如此处 var 变量值 99 就恰好是汇编语言中的字扩展指令 CWD,它的功能时将一个字形变量扩展为双字型变量,即 Change Word to Double word。

针对 section

编译器提供的关键字 Section 只是为了让程序员在逻辑上将程序划分成为几个部分,以为它是伪指令,CPU 都不知道有这个东西。
image.png
代码 3-2 在上一个基础上稍做了一些改变,添加了变量及 section 的应用。
几个知识点:

  1. 第 1 行和第 8 行为空,并没有产生响应地址或机器指令,这就是 section 是伪指令的原因;
  2. 第 3 行中用到了 section.data.start,其用法是 section.节名.start,这里是获得名为 data 的 section 在本文件中的真实偏移,即起始地址,是 nasm 提供的伪指令。查看“反编译代码”列的同一行,编译后已经被替换为0x10,说明定义的数据 section 起始地址为0x10。可见定义的 section 其起始地址默认是从上一个数据的地址延续下来的。
  3. 由于var1是数据 section 的首个数据,其地址必然是和数据 setion 一致。故对比下源码列和反汇编代码列的第 4 行,var1的地址确实被替换为0x10
  4. var2是继var1之后的变量,由于var1的类型是dd,即双字,故其占用 4 字节,所以var2的地址应该是0x14。这一点可以通过对比源码列和反汇编代码列看出;

针对 vstart

nasm 说法:

Sections can be given a virtual start address, which will be used for the calculation of all memory references within that section with vstart=.

大概意思是 section 用 vstart=来修饰后,可以被赋予一个虚拟其实地址,它被用来计算在该 section 内的所有内存引用地址。
image.png
代码 3-3 在 section 中添加了 vstart,这个参数是让编译器将 section 中的数据的地址以 vstart 的值为起始,不再从整个程序开头算起。只有以程序开头 0 算起的地址才是真实存在的,在这个地址上能访问到响应的符号,所以不以程序开头算起的地址,必然在程序内部不存在,是虚拟的。
几个知识点:

  1. 源码第 1 行的 section 含有 svtart=0x7c00,故该节中的数据地址以0x7c00为起始编址。此0x7c00便是虚拟的地址,在程序中没有偏移文件开头为0x7c00的地址,整个程序不到 100 字节;
  2. 源码第 2 行的$$在编译后被替换为vstart的值0x7c00,可见,$$以该节的虚拟起始地址为主,若该节未用 vstart 来制定则以在文件中的其实地址(较虚拟地址来说,此地址便为真实地址)为主,而该节在文件中的起始地址是 code.节名.start;
  3. 第 3 行和第 4 行的源码和相应反汇编代码表明"code.节名.start"是节在整个程序中的地址,即相对于文件开头的偏移量。
  4. 第 5 行的$在文件中的地址是 0x9,经编译后变成了0x7c09,类似于重定位:新的地址+在文件中的地址(也相对于整个文件的偏移量),即 0x7c00+9;
  5. 第 6、7 行中引用的变量var1var2属于 data 节,由于该节有 vstart=0x900,所以该节终端 var1 地址是 0x900,var2 是 0x900+var1 的内存空间 4 字节=0x904。这里的0x900也属于虚拟地址,原因同0x7c00一样。
  6. 第 8 行的jmp $,按理说可以编译成相对转移的形式:jmp -2,而用 ndisasm 反汇编的结果是jmp short 0x120x12是相对于整个文件的绝对地址,可称为真实地址。此处的反汇编结果是作者手工修改成jmp -2的。

    我心想,既然我已经用 vstart 指定虚拟起始地址了, jmp $编译的结果按理说要么是 jmp near 0x7c12 ,操作 码以 E9 开头,这说明这是相对近转移的语法: jmp 16 位地址,要么是jmp short -2,操作码是 EB ,这是相对短 转移的语法: jmp -128~127之间的数。可是看这jmp short 0x12, 0x12 是真实的地址,似乎不正确。鉴于 jmp short 是相对短转移,其操作码是 EB ,占 1 字节,操作数是相对于跳转目标地址的偏移量,是 1 字节的有符号数,故可正可负,只能在段内跳转。于是我用 ndisasm 反汇编时用参数-o 0x7c00 给其添加了个起始地址来测试,如命令ndisasm -o Ox7c0,但反汇编的结果变成了 jmp short 0x7c00 ,这结果明显不对,偏移量 0x7c00不在-128~127 之间。由于 jmp short 指令操作码和操作数各是 1 字节,总共加起来是 2 字节。于是怀着忐忑的心情在 bochs 验证了下,执行到jmp $的时候果然是jmp -2 。所以上面第 8 行是被我手工改为jmp -2的,请大家知晓。

用 vstart 的时机是:我预先知道我的程 序将来被加载到某地址处。程序只有加载到非地址时 vstart 才是有用的,程序默认起始地址是 0。

3.2 CPU 的实模式

实模式指的是 8086CPU 的工作环境、工作方式、 工作状态,这是一整套的内容,并不是单指某一方面的设置。

3.2.2.1 实模式下的段寄存器

image.png
代码段简而言之就是把所有指令都连续连续排放在一起,形成了一个全部都是指令的区域,里面存储的是指令的操作码及寻址方式等。 代码段寄存器 CS 就是用来指向内存中这段指令区域的起始地址。
数据段和代码段类似,只是这段区域中的内容不是指令,而是纯粹的数据,也就是说里面存储的是程序运行所需要的数据,属于指令的操作数。数据段寄存器 DS 便是用来指向此数据区域的起始地址。
栈段是在内存中,硬盘文件中没有。一般的栈段是由操作系统分配指定的。栈段寄存器 SS 就是用来指向此区域的起始地址。
在 16 位 CPU 中,只有一个附加段寄存器——ES。而 FS 和 GS 附加段寄存器是在 32 位 CPU 中增加的。
IP 寄存器是不可见寄存器,CS 寄存器是可见寄存器。访问内存要用"段:段内偏移”的形式,所以 CS 寄存器用来存代码段段基址,IP 寄存器用来存储代码段端内偏移地址,同 CS 寄存器一样都是 16 位宽。

3.2.2.2 实模式下的 IP 寄存器

flags 寄存器是计算机的窗口,展示了 CPU 内部各项设置、指标。
任何一个指令的执行、其执行的细节、对计算机造成了哪些影响,都在 flags 寄存器中通过一些标志位反应出来。有些指令需要满足某些条件才能执行,他们的条件是判断上一条指令的执行过程。所以标志寄存器终的标志位就成了这些指定所需要满足的条件。实模式下的 flags 寄存器是 16 位的,如图 3-5 所示。
image.png
有关通用寄存器。无论实模式还是保护模式,通用寄存器有 8 个,分别是 AX、BX、CX、DX、SI、DI、BP、SP,他们的名称及关系如图 3-6 所示。
image.png
拿 AX 寄存器举例,AX 寄存器是由 AH 寄存器和 AL 寄存器组成的,他们都是 8 位寄存器,AX 寄存器的低 8 位是 AL 寄存器,高 8 位是 AH 寄存器。由于某种原因,例如数学计算和 32 位保护模式等,16 位 AX 寄存器不够用了,故将其扩展为 32 位,在 AX 原有的基础上,增加 16 位作为高 16 位便是扩展的 AX,即 EAX。所以 EAX 归根结底也是由 AL、AH 组成的,AL 或 AH 值变了直接影响 EAX。
虽说是通用寄存器,但还是约定了他们的惯用法, 除了通用的用途外每个寄存器肩负特定的功用。比如一般情况下,ex寄存器用作循环的次数控制, bx寄存器用于存储起始地址。这是大家约定俗成的东西,不这样做也可以,用其他通用寄存器也能完成任务。不过还是有个公共的约定更好,这样一些通用的函数,在为其传递参数时会方便很多。比如 BIOS 或 DOS 中断调用,一般情况下, cx还就是用作循环次数的控制。另外, 指令已经固定用一些特定的寄存器作为参数了,比如esi寄存器作为很多有关数据复制指令的源地 址, edi作为目的地址。
image.png

3.2.3 实模式下内存分段由来

CPU 中本来是没有实模式这一称呼的,是因为有了保护模式后,为了与老的模式区别开来,所以称老的模式为实模式。
实模式的“实”体现在:程序中用到的地址都是真实的物理地址,"段基址:段内偏移”产生的逻辑 地址就是物理地址,也就是程序员看到的完全是真实的内存。
8086 之前的 CPU 对内存的访问没有段的概念,程序中要访问内存,需要把地址写死,也就是所谓的“硬编码”。这种寻址方式会导致程序无法重定位,必须加载到内存中固定的位置, 如果在此位置有其他程序在用,要等人家运行完腾出内存后才轮到自己。这种方式下可用内存很多,但却因为某一个字节的内存被占用而让后来的程序等很久。有些开发人员等不及了,干脆把程序中的地址改成别的,重新编译后发现还是有某个地址被占用,还是没法上 CPU 运行,只能再改地址······
为了解决这个问题,Intel 早期的工程师发明了“段”,即 CPU 访问内存用“段+偏移”的形式,它首次在 8086 上出现。为了支持段机制,CPU 中新增了段寄存器,如 cs、ds、es 等。

16 位寄存器寻址能够访问 20 位的地址空间

image.png
为了让 16 位的寄存器寻址能够访问 20 位的地址空间,CPU 工程师通过先把 16 位的段基址左移 4 位后变成 20 位,再加段内偏移地址,这样便形成了 20 位地址,只要保证了段基址是 20 位的,偏移地址是多少位都不关心了,从而突破了 16 位寄存器作为偏移地址而无法访问 1MB 空间的限制。
有了 20 位地址便能访问到 20 位的空间,虽然解决了一个大问题,但是引入了一个小问题。
拿 0xFFFF 来说,现在能访问的最大的地址是0xFFFF:0xFFFF,经过左移段基址 4 位后得到的最大地址是:0xFFFF*16+0xFFFF = 0xFFFF0 + 0xFFFF = 0xFFFFF + 0xFFF0 = 1M - 1 + 64KB -16 = 0x10FFEF
现在虽然能够访问 20 位地址空间,但反而有点过了,过头的原因是段基址为0xFFFF0,偏移地址应该小于等于0xF,而这个偏移地址却是0xFFFF,超出了0xFFF0的空间,这部分内存就是传说中的高端内存区(High Memory Area,HMA)。那这部分超出的内存应该如何处理?
答案是不用处理。因为 8086 一共就 20 条地址,地址线是从 0 开始的,即 A0~A19,所以其地址空间才是 1MB 的。要访问内存地址0xFFFFF+是要用到 A20 地址线,可 8086 没有,只能接收 20 位长的地址。所以由于超过了 20 位而产生的进位,这部分数据就给丢掉了。例如0xFFFFF+2,理论上会变成0x100001,但由于只能容纳 20 位长的数据,所以最终结果是0x00001。这是地址回卷的结果,即超过最大范文后,从 0 重新开始计数。这就引出了从实模式到保护模式要打开 A20 地址线的问题。(后续补充)

3.2.4 实模式下 CPU 内存寻址

从 8086 的寻址模式来说分为:

  1. 寄存器寻址

    寄存器寻址是指“数”在寄存器中,直接从寄存器中拿数据。

1
2
3
4
5
6
mov ax, OxlO
mov dx, Ox9
mul dx
第一条命令是将 0x10 存入缸寄存器,第二条命令是将 0x9 存入缸,第 条指令是求础和 dx 的乘积,
乘积的高 16 位在 dx 寄存器,低 16 位在ax寄存器。只要牵扯到寄存器的操作,无论其是源操作数,还是目
的操作数,都是寄存器寻址。上面的第 条指令,它们的源操作数都是立即数,所以也属于立即数寻址。
  1. 立即数寻址

    指令由操作码和操作数组成,得到一个数往往不容易,或者说不那么直接。这个数要么在寄存器中,要么在内存中,都是间接给出的,所以得到数就要花费一些 CPU 周期。如果操作数“直接” 存在指令中,直接拿过来,立即就能用了。为了突显“立即就能用”的高效率,此数便称为立即数。 立即数免去了找数的过程,例如:

1
2
mov ax, Ox18
mov ds, ax

第一条指令中的源操作数 0x18 是立即数,目的操作数 ax 是寄存器,所以它既是立即数寻址,也是寄存器寻址。第二条指令中,源操作数和目的操作数都是寄存器,所以纯粹是寄存器寻址。
by the way,像这样的寻址也是立即数寻址:

1
2
mov ax, macro_selector
mov ax, label_start

第一条指令的源操作数 macro selector 是个宏,第 条指令的源操作数 label start 是个标号,这两个在编译阶段会转换为数字,最终可执行文件中的依然是立即数。

  1. 内存寻址

    CPU 中有很多寄存器,有些是程序员不可见的,它们是为了 CPU 正常运行而存在的,属于 CPU 运行框架内的需求。 CPU 给程序员用的寄存器并不是很多,所以操作数一多起来的时候,基本就倒腾不开了。内存空间相对就大多了,于是 CPU 工程师们自然而然想到了用内存来存储操作数。另外,用立即数寻址,得提前知道立即数是多少,否则还真用不了。而且,大多数时候操作数位于内存中的某个位置,只知道操作数所在的内存地址, 不知道操作数的值,更谈不上将其变成立即数用在指令中了,这就更加有理由让内存寻址成为“应该”。
    访问内存是用“段基址:偏内偏移地址”的形式,此形式只用在内存访问中。默认情况下数据段寄存器是 DS ,即段基址已经有了,只要再给出段内偏移地址就可以访问内存了,最终起决定作用的、有效的是段内偏移地址,所以段内偏移地址称为有效地址。

    1. 直接寻址

      直接寻址,就是将直接在操作数中给出的数字作为内存地址,通过中括号的形式告诉 CPU ,取此地址中的值作为操作数。 例如:

1
2
mov ax, [0x1234]
mov ax, [fs:0x5678]

第一条指令中,0x1234 是段内偏移地址,默认的段地址是 DS。这条指令是将内存地址 DS:0x1234 处的值写入 ax 寄存器。
第二条指令中,由于使用了段跨越前缀 fs,0x5678 的段基址则变成了 gs 寄存器。最终的内存地址是gs寄存器的值*16+0x5678,CPU 到此内存地址取值再存入 ax 寄存器。

注意不要和立即数寻址混了,立即数寻址中的数字是直接拿来就用作操作数了,直接寻址中的数字是用来进一步寻址的。

  1. 基址寻址

    基址寻址,就是在操作书中用 bx 寄存器或寄存器作为地址的起始,地址的变化以它为基础。这里说的是只能用 bx 或 bp 作为基址寄存器。用寄存器作为内存寻址,在实模式下必须用 bx 或 bp 寄存器。到了保护模式就没这个限制了,基址寄存器可选择的很多,可以是全部的通用寄存器。

bx 寄存器的默认段寄存器是 DS,而 bp 寄存器的默认段寄存器是 SS,即 bp 和 sp 都是栈的有效地址。
访问栈有两种方式,一种是把栈当成“栈”来使用,也就是用 push 和 pop 指令操作栈,但这样我们只能访问到栈顶,即 sp 指向的地址,没有办法直接访问到栈底和栈顶之间的数据。很多时候我们需要读写栈中的数据,即需要把栈当成普通数据段那样访问。为了让开发人员方便控制枝中数据,提供了把栈当成数据段来访问的方式,可以用寄存器 bp 来给出栈中偏移量,所以 bp 默认的段寄存器就是 SS ,这样就可通过 SS:bp 的方式把栈当成普通的数据段来访问了。

举个栗子:

1
2
3
4
5
int a = 0;
function(int b, int c) {
int d;
}
a++;

image.png

by the way,堆栈框架的工作是为函数分配局部变量空间,因此应该在刚刚进入函数时就进行为局部变量分配局部变量空间,因此应该在刚刚进入函数时就进行为局部变量分配空间的工作,离开函数时再回收局部变量的空间,所以堆栈框架的创建和回收工作是在进入函数和离开函数时进行的。为了在名称上推按堆栈框架这两个阶段,有一条指令叫 enter,它是在进入函数时执行的,其功能就是备份 ebp 并使 ebp 更新为 esp,即先“push ebp”再“mov ebp,esp”,因此第 3~4 步的两条指令通常会由一条 enter 指令来代替。另一条指令是 leave,它是在离开函数时执行的,其功能是回收局部变量的空间并恢复 ebp 的值,即先“mov esp,ebp”再“pop ebp”,因此第 6~7 步的两条指令也通常由一条 leave 指令来代替。

  1. 变址寻址

变址寻址和基址寻址类似,只是寄存器由 bx、bp 换成了 si 和 di。si 是指源索引寄存器,di 是指目的索引寄存器。两个寄存器的默认段寄存器也是 ds。

1
2
mov [di], ax	;将寄存器ax的值存入ds:di指向的内存
mov [si+0x1234], ax ;变址中也可以加个偏移量

变址寻址主要是用于字符搬运方面的指令,这两个寄存器在很多指令中都要成对使用,如 movsb, movsw, movsd 等。变址寻址只是为了配合基址寻址,用来实现基址变址寻址。

  1. 基址变址寻址

    从名字上看,这是基址寻址和变址寻址的结合,即基址寄存器 bx 或 bp 加一个变址寄存器 si 或 di 。

例如:

1
2
mov [bx+di], ax;
add [bx+si], ax;

第一条指令是将 ax 中的值送入以 ds 为段基址, bx+di 为偏移地址的内存。
第二条指令是将 ax 与[ds: bx+si ]处的值相加后存入内存[ds: bx+si]。

3.2.5 栈到底是什么玩意儿

物理上的栈同数据段、代码段一样, 是个内存中的区域,也就是找段寄存器 SS 和枝指针 SP 所指向的内存区域。我们常听说的横溢出,指的就是这个内存区域无法容纳数据了。

硬件实现这个栈,首先得满足栈的概念,具备栈的特性,两个条件:一是线性结构、二是在栈顶对数据存取。
线性结构这个简单,内存就是,直接用物理内存存取最方便了,咱们要做的就是给栈指定一片内存区域,区域的起始地址作为栈基址,存入梳基址寄存器 SS 中,另一端是动态变化的,用栈指针寄存器 SP 来指定。栈在使用过程中是向下扩展的,所以栈顶地址肯定小于栈底地址。
栈既然是一片内存区域,访问内存就要用“段基址:段内偏移地址”的形式,所以栈中的内存地址也是用“段基址 SS 的值 * 16 +栈指针 SP(段内偏移地址)形成的位地址”访问到的。
栈顶(SP 指针〉是栈的出口和入口,它指向的内存中存储的始终是最新的数据。 push 和 pop 就是操作这个指针所指向的内存。由于栈从高地址向低地址发展,所以栈顶、栈指针指向的地址会越来越低。 push 压入数据的过程是:先将 SP 减去字长,目的是避免将栈顶的数据破坏,所得的差再存入栈,栈顶在此被更新,这样栈顶就指向了栈中下一个存储单元的位置。再将数据压入 SP (新的栈顶)指向的新的内存地址。pop 指令相反,既然是在栈中弹出数据 ,栈指针寄存器 SP 的值应该是增大一个数据单位。由于要弹出的数据就在当前栈顶,所以在弹出数据后,才将 SP 加上宇长,所得的和再存入 SP,从而更新了栈顶。 这样 SP 就指向了上个存储单元的位置。
上面提到的字长,是指 CPU 字长,即一次可处理的数据的长度。在实模式下的字长是 16
image.png
如图 3-9 所示,虽然栈是向下发展的,但栈也是内存,访问内存依然是从低地址往高地址, 假如当前栈顶是 0x1233E ,栈顶数据占 2 字节的话,其范围是 0x1233E~0x1233F。

3.2.6 实模式下的 ret

call 指令用来执行一段新的代码,让 CPU 踏上新的征途,为避免这是一条不归路,还需要返回指令 ret 来帮忙。
call 指令调用一个函数时,发生了什么呢?压入返回地址,为将来能够回来埋下伏笔。 call 指令不负责“回来”,它只负责如何“去”,回来的工作要交给 ret。
call 指令不是一去不回头,它执行完目标函数后还是要回来的,所以它得提前把回来的路〈返回地址)记好了,对于 CPU 来说,它是靠程序计数器 PC 来指路的,所以路就在 PC 中。凡是调用 call 指令, CPU 就要找地方把返回地址存起来以备将来函数执行时有路可以返回。在哪里保存返回地址合适呢,这需要考虑函数嵌 套调用的问题。由于函数有可能嵌套调用,也就是随着函数调用的层数增加,会有更多的返回地址需要保存 用宝贵且有限的寄存器来保存无数的返回地址,这显然是不现实的。内存空间相对是无限的,在内存中保存数 量未知的返回地址比较理想。战这种数据结构是再适合不过的,利用其后进先出的特性,可以保证函数嵌套调 用及嵌套返回顺序的一致性,而且其空间只受限于内存大小。于是在内存中创建这样一个数据结构就完美地解 决了这个问题,所以 CPU 在战中保留程序计数器 PC 的值。在 x86 中的程序计数器是 CS: IP ,具体保留部分还是 CS 都保留,是要看目标函数的段基址是否和当前段基址一致,也就是说,是否跨段访问了。
保留的这个返回地址并不是给 call 指令用的, call 指令不会自动回来,它只会留下返回地址后井踏上新的 征程,返回地址是给 ret retf 指令准备的,也就是说,在目标函数中必须有这两个指令之一, CPU 才能回来。
ret (return )指令的功能是在栈顶(寄存器 ss: sp 所指向的地址)弹出 2 字节的内容来替换 IP 寄存器,注意,我这里说的是“内容”, ret 指令不管里面的内容是不是地址,它只负责把当前栈顶处的内容弹出栈并用它为 IP 寄存器赋值。至于内容的正确性应该由程序员自己控制。ret 只置换了 IP 寄存器,也就是说,不用换段基址,属于近返回。既然我们称之为弹出栈,也就是说 ret 指令也要负责维护栈顶指针,由于栈是从高地址往低地址发展,所以被回收的栈顶空间应该是使sp指针值变大,故 ret 指令会使 sp 指针+2。
retf(return far)是从栈顶取得 4 字节,栈顶处的 2 字节用来替换 IP 寄存器,另外的 2 字节用来替换 CS 寄存器。
同样,retf 也不会去检查从栈顶往上的 4 字节内容是不是偏移地址和段基址,它只负责弹出它们,并将它们分别载入代码段寄存器CS和指令指针寄存器**IP,**由程序员负责栈中数据的正确性。换句话说,在用 ret 或 retf 之前,程序员应该知道此时栈顶中的数据是什么,能补鞥呢用作返回地址。段寄存器都换了,这说明这属于元返回。retf 指令也要负责维护栈顶指针,所以 retf 指令会使 sp 指针+4.
call 和 ret 是一对配合,用于近调用和近返回,call far 和 retf 是一对配合,用于远调用和远返回。

3.2.7 实模式下的 call

call 指令调用函数有四种方式。

  1. 16 位实模式相对近调用
1
2
3
4
5
6
call near near_proc
jmp $
addr dd 4
near_proc:
mov ax, 0x1234
ret

call 指令是要占用内存空间的,这里我们解释的是相对近调用,此指令机器码是 e81lhh,占用 3 字节。其中 e8 是操作码,表示相对近调用,ll 表示操作数的低位,hh 表示操作数的高位, hhll 表示跳转目标为 4 位地址。由于 x86 平台是小端字节序,故写成了 llhh ,即高位在高地址,低位在低地址。这 4 位地址是个相对增量,请问这是如何得出的呢?首先用目标函数的地址减去当前 call 指令的地址,所得的差再减去此 call 指令机器码的大小(此类相对近调用的机器码大小是 3 字节,故要减去 3 ,最终的结果便是 call 指令中的操作数,即与目标地址的相对地址增量。
由于此操作数并不是目标函数的绝对地址,只是相对于目标函数地址的相对增量,所以此操作数并不能直接被 CPU 使用(“直接”就是操作数以立即数的形式给 CPU 后, CPU 拿来就用,不用转换)。CPU 在实际执行中还要将此增量还原成绝对地址。所以此相对近调用并不能称为“直接”相对近调用。既然是相对量,就有正负之分。如果目标地址比当前 call 指令地址大,地址相对量则为正数。如果目标地址比当前 call 指令地址小,地址相对量便为负数。由此可见,操作数是个有符号数。由于段是个 16 位大小的空间,所以,正负数的范围是-32768~32767

  1. 16 实模式间接绝对近调用
1
2
3
4
5
6
7
8
9
10
section call_test vstart=0x900
mov word [addr], near_proc
call [addr]
mov ax, near_proc
call ax
jmp $
addr dd 4
near_proc:
mov ax, 0x1234
ret
  1. 16 位实模式直接绝对远调用

在各种转移指令中,凡是包含“直接”,都意指不需要经过寄存器或内存,操作数以立即数的形式给出。
凡是包含“远”,就意指要跨段啦,目标函数和当前指令不在同一个段中。
由于是远调用,所以 CS 和 IP 都要用新的, call 指令将来还是要回来的,所以要在栈中保留回来的路, 即先把老的 CS 寄存器压入栈,再把老的 IP 寄存器压入栈后,用新的 CS 和 IP 寄存器替换,从此开启新的旅途。
指令的一般形式是:
call far 段基址(立即数):段内偏移地址(立即数)
对于直接绝对远调用,far 也可以不加。操作吗是 0x9a。机器码是 0x9a+2 字节的偏移地址+2 字节的段基址,即偏移地址在前,段基址在后,和指令的调用形式是相反的。

1
2
3
4
5
6
section call_test vstart=0x900
call 0: far_proc
jmp $
far_proc:
mov ax, 0x1234
retf

代码极其简单,直接看第 2 行,这个函数调用就用了直接绝对远调用的形式。
段基址是 0,段内偏移地址就是 far_proc 。由于此类 call 是远调用,所以要和 retf 来配合,见第 6 行, retf 来返回。
给大家交待个背景,此程序是由 MBR 调用的,在执行此程序前, CS 的值是 0。而我们的 call 在此处用的段基址依然是 0,段基址没变。这能算跨段吗?其实 CPU 它不判断新的段值是否和当前段值一致,只要重新加载段寄存器,它就加载。

  1. 16 位实模式间接绝对远调用

指令格式是:call far 内存寻址,如call far [bx],call far [0x1234],操作码是 ffle。在该内存中的内容大小是 4 字节,此内容便是地址,前(低)2 字节是段内偏移地址,后(高)2 字节是段基址。在此调用方式中一定要价格关键字 far,否则就和第 2 中的间接绝对近调用一样了。
新的段基址和段内偏移既然是在内存中,访问内存的话,也要按照“段基址:段内偏移地址”的形式去操作。例如上面的 call far [0x1234],由于没有段跨越前缀,则将默认的段基址寄存器 ds*16 后再与 0x1234 相加,得到的和为物理地址,再到该物理地址处去读取新的偏移地址和段基址,以该物理地址为起始的 2 个字节是段内偏移地址,以(该物理地址+2)为起始的 2 个字节是段基址。既然是段基址和段内偏移地址都要用新的,CPU 为了记得回来的路,先把老的 CS 寄存器压入栈,再把老的 IP 寄存器压入栈中保存起来,在用新的段基址替换 CS,新的段内偏移地址替换 IP。

1
2
3
4
5
6
7
section call_test vstart=0x900
call far [addr]
jmp $
addr dw far_proc, 0
far_proc:
mov ax, 0x1234
retf

第 2 行执行间接绝对远调用,addr 是个变量,在第 4 行定义的,其值的低 2 字节是函数 far_proc 的地址,高 2 位是 0,即段基址。相对短转移的“短”,体现在操作数中,即跳转的范围只能是 1 字节有符号数所表示的范围,即-128~127。

3.2.8 实模式下的 jmp

5 类转移方式:

  1. 16 位实模式相对短转移

指令格式是jmp short 立即数地址
相对短转移的机器码大小是 2 字节,操作码是 0xeb ,可知其为 2 宇节大小。那操作数占 1 字节。

1
2
3
4
5
6
section call_test vstart_0x900
jmp short start
times 127 db 0
start:
mov ax, 0x1234
jmp $

第 2 行的jmp short start采用短转移方式。目标地址是第四行的 start
在第 3 行特意定义了 127 字节的数据,目的是用来间隔目标地址 start,使第 2 行的 jmp 和第 4 行 jmp shart start 的地址后,再减去 2 字节等于 127。

  1. 16 位实模式相对近转移

指令格式是 jmp near 立即数地址,其操作码是 0xe9。操作数范围是-32768~32767。
由于相对近转移的机器码是 字节,所以操作数=目标地址·当前指令地址-3。
补充一下,按照目前 2.10.07 版本的 nasm ,如果超过了 16 位有符号数的范围-32768~32767 ,编译器并不会报错,只是会将超过 16 位的部分忽略,只保留 16 位的结果。 jmp “相对”转移的形式介绍完了,分别是相对短转移和相对近转移 。其中的 hort near 如果省略, nasm 编译器会根据目的地址和当前地址的偏移量大小来自行判断,若偏移量属于范围- 128 127. 则编译 short 短转移形式。若超过了短转移的范围就编译为 near 近转移形式

  1. 16 位实模式间接绝对近转移

间接,是指操作数并不直接给出 ,而是存储在寄存器或内存中。
指令格式是jmp near 寄存器寻址,或者jmp near 内存寻址
寄存器寻址:

1
2
3
4
5
6
7
section call_test vstart=0x900
mov ax, start
jmp near ax
times 128 db 0
start:
mov ax, 0x1234
jmp $

内存寻址:

1
2
3
4
5
6
7
section call_test vstart=0x900
mov word [addr], start
jmp near [addr]
times 128 db 0
start:
mov ax, 0x1234
jmp $
  1. 16 位实模式直接绝对远转移

“直接”是指操作数不仅是立即数,而且 CPU 直接拿来就用,不用再转换。
“绝对”是指提供的操作数是绝对地址。
“远”是指目的地址和当前指令不是一个段,有跨段的需求,所以要操作数要包括新的段基址和段内偏移。
指令格式为jmp 立即数形式的段基址:立即数形式的段内偏移地址

1
2
3
4
5
6
section call_test vstart=0x900
jmp 0: start
times 128 db 0
start:
mov ax, 0x1234
jmp $
  1. 16 位实模式间接绝对远转移

为了指示 CPU 在内存中取 4 个字节,需要在指令中用关键字 far,即前两个字节是段内偏移地址,后两个字节是段基址。
所以其指令格式是jmp far 内存寻址。 由于操作数在内存中,在不使用段跨越前缀的情况下,段基址寄存器是 DS。

1
2
3
4
5
6
7
section call_test vstart=0x900
jmp far [addr]
times 128 db 0
addr dw start, 0
start:
mov ax, 0x1234
jmp $

3.2.9 标志寄存器 flags

按理说,既然有“无条件转移”,就应该有“有条件转移”,真实情况也确实是这样。讲完了无条件转移指令后,该到有条件转移指令啦,可是我们得知道这个条件在哪里,是什么条件。这样我们才能根据这些条件做出是否转移的判断。
实模式下标志寄存器是 16 位的 flags,在 32 位保护模式下,扩展了标志寄存器,称为了 32 位的 eflags。
IA32 指令中并没有提供高级逻辑的指令,但无论逻辑多复杂,都可以通过最简单的判断和转移来实现。判断哪里?判断什么?这个判断的对象就是标志寄存器中的标志位。
image.png
以下标志位仅在 8088 以上 CPU 中有效。

  1. 第 0 位的是 CF 位,即 Carry Flag ,意为进位。运算中,数值的最高位有可能是进位,也有可能是借位,所以 carry 表示这两种状态。不管最高位是进位,还是借位,CF 位都会置 1,否则为 0。它可用于检测无符号数加减法是否有溢出,因为 CF 时,也就是最高位有进位或借位,肯定是溢出。

  2. 第 1/3/5/15 位没有专门的标志位,空着占位用。

  3. 第 2 位为 PF 位,即 Parity Flag,意味奇偶位。用于标记结果低 8 位中 1 的个数,如果为偶数,PF 位为 1,否则为 0。注意是最低的那位,不管操作数是 16 位,还是 32 位。奇偶校验经常用于数据传 输开始时和结束后的对比,判断传输过程中是否出现错误。

  4. 第 4 位为 AF 位,即 Auxiliary carry Flag,意为辅助进位标志,用来记录运算结果低 4 位的进、借位情况,若低半字节有进、错位,AF 为 1,否则为 0.

  5. 第 6 位为 ZF 位,则 Zero Flag,意为零标志位。若计算结果为 0,此标志为 1,否则为 0.

  6. 第 7 位为 SF 位,即 Sign Flag,意为符号标志位。若运算结果为负,则 SF 位为 1,否则为 0.

  7. 第 8 位为 TF 位,即 Trap Flag,意为陷阱标志位。此位若为 1,用于让 CPU 进入单步运行方式,若为 0,则为连续工作的方式。平时我们用的 debug 程序,在单步调试时,原理上就是让 TF 位为 1。可见,软件上的很多功能,必须有硬件的原生支持才能得以实现。

  8. 第 9 位为 IF 位,即 Interrupt Flag,意为中断标志位。若 IF 位为 1,表示中断开启,CPU 可以响应外部可屏蔽中断。若为 0,表示中断关闭,CPU 不再响应来自 CPU 外部的可屏蔽中断,但 CPU 内部的异常还是要响应的,因为它关不住。

  9. 第 10 位为 DF 位,即 Direction Flag,意为方向标志位。此标志位用于字符串操作指令中,当 DF 为 1 时,指令中的操作数地址会自动减少一个单位,当 DF 为 0 时,指令中的操作数地址会自动增加一个单位,意即给地址的变化提供个方向。其中提到的这个单位的大小,取决于用什么指令。

  10. 第 11 位为 OF 位,即 Overflow Flag,意为溢出标志位。用于表示计算的结果是否超过了数据类型可表示的范围,若超出了范围,就像水从锅里溢出去了一样。若 OF 为 1,表示有溢出,为 0 则未发生溢出。专门用于检测有符号数运算结果是否有溢出现象。

    以下标志位仅在 80286 以上 CPU 中有效。相对于 8088,它支持特权级和多任务。

  11. 第 12~13 位为 IOPL ,即 Input Output Privilege Level,这用在有特权级概念的 CPU 中。有 4 个任务特权级,即特权级 0、特权级 1、特权级 2 和特权级 3。故 IOPL 要占用位来表示这种特权级。如果您对此感到迷茫,不用担心,这些将来咱们在保护模式下也得实践。

  12. 第 14 位为 NT,即 Nest Task ,意为任务嵌套标志位。 8088 支持多任务,一个任务就是一个进程。当一个任务中又嵌套调用了另一个任务(进程)时,此 NT 位为 1,否则为 0。

    以下标志位仅用于 80386 以上的 CPU

  13. 第 16 位为 RF 位,即 Resume Flag ,意即恢复标志位。该标志位用于程序调试,指示是否接受调试故障,它需要与调试寄存器起使用。当 RF 为 1 时忽略调试故障,为 0 时接受。

  14. 第 17 位为 VM 位, 即 Vrrtual 8086 Model ,意为虚拟 8086 模式。这是实模式向保护模式过渡时的产物,现 在己经没有了。CPU 有了保护模式后,功能更加强大了,但为了兼容实模式下的用户程序,允许将此位置为 1, 这样便可以在保护模式下运行实模式下的程序了。实模式下的程序不支持多任务,而且程序中的地址就是真实的物理地址。所以在保护模式下每运行一个实模式下的程序,就要为其虚拟一个实模式环境,故称为虚拟模式。

    以下标志位仅用于 80486 以上的 CPU

  15. 第 18 位为 AC 位,即 Alignment Check,意为对齐检查。什么是对齐呢?是指程序中的数据或指令其内存地址是否是偶数,是否是 16、32 的整数倍,没有余数,这样硬件每次对地址以自增地方式(每次自加 2、16、32 等〉访问内存时,自增后的地址正好对齐数据所在的起始地址上,这就是对齐的原理。对齐并不是软件逻辑中的要求,而是硬件上的偏好,如果待访问的内存地址是 16 或 32 的整数倍,硬件上好处理,所以运行较快。若 AC 位为 1 时,则进行地址对齐检查,为 0 时不检查。

    以下标志位只对 80586 (奔腾)以上 CPU 有效

  16. 19 位为 VTF 位,即 Virtual Interrupt Flag ,意为虚拟中断标志位,虚拟模式下的中断标志。

  17. 20 位为 VIP 位,即 Virtual Interrupt Pending ,意为虚拟中断挂起标志位。在多任务情况下,为操作系统提供的虚拟中断挂起信息,需要与 VIF 位配合。

  18. 21 位为 ID 位,即 Identification ,意思为识别标志位。系统经常要判断 CPU 型号,若 ID 为 1,表 示当前 CPU 支持 CPU id 指令,这样便能获取 CPU 的型号、厂商等信息 ,则 ID 为 0,表示当前 CPU 不支持 CPU id 指令

  19. 其余剩下的 22~31 位都没有实际用途,纯粹是占位用,为了将来扩展。

3.2.10 有条件转移

有条件转移不是简单的一个指令,它是一个指令族,我们在此简单称 jxx 。如果条件满足, jxx 将会跳转到指定的位置去执行,否则继续顺序地执行下一条指令。
其格式为 jxx 目标地址。若条件满足则跳转到目标地址,否则顺序执行下一条指令。
其中,目标地址只能是段内偏移地址。

  • 在实模式下,由编译器根据当前指令与目标地址的偏移量,自行将其编译成短转移或近转移。
  • 在保护模式下,寄存器中宽度已经到了 32 位, 32 位的偏移地址可以访问 到整个 32 位地线总线的 4GB 内存空间,编译器不再区分转移方式。

条件转移指令中所说的条件就是指标志寄存器中的标志位。 jxx 中的 xx,就是各种条件的分类,每种条件有不同的转移指令。下面将条件展开,将各指令实例化列出,见表 3-12。
image.png
这里面同义的好多啊,比如 jl 和 jnge ,直接就理解为“小于时转移”就成 了,何必再弄个同义词 jnge “不大于等于时转移”呢?其实不用那么闹心,经常用的就两三个。而且,这 些转移指令是由意义明确的字符拼成的。

  • a 表示 above
  • b 表示 below
  • c 表示 carry
  • e 表示 equal
  • j 表示 jmp
  • l 表示 less
  • n 表示 not
  • o 表示 overflow
  • p 表示 parity

3.2.11 实模式小结

实模式被保护模式淘汰的原因,最主要是安全隐患。
在实模式下,用户程序和操作系统可以说是同一特权的程序,因为实模式下没有特权级,它处处和操作系统平起平坐,所以可以执行一些具有破坏性的指令。
程序可以随意修改自己的段基址,这样便在 1MB 的内存空间内不受阻拦,可以随意访问任意物理内存,包括访问操作系统所在的内存数据。这就给程序员开放了无限的自由,程序员访问内存可以说是指哪打哪。
由于完全没有保护性可言,用户程序甚至可以覆盖操作系统在内存中的映像,整个计算机世界的和平全靠程序员的心情。

相关文章
评论
分享
  • 《操作系统真象还原》:第八章 内存管理系统

    8.1 makefile 简介 这部分可参考阮一峰的讲解:https://www.ruanyifeng.com/blog/2015/02/make.html 8.1.1 makefile 是什么 makefile 是 Linu...

    《操作系统真象还原》:第八章 内存管理系统
  • 《操作系统真象还原》:第七章 中断

    7.1 中断是什么,为什么要有中断 运用中断能够显著提升并发,从而大幅提升效率。 7.2 操作系统是中断驱动的 略 7.3 中断分类 把中断按事件来源分类,来自 CPU 外部的中断就称为外部中断,来自 CPU 内部的中断称为内部...

    《操作系统真象还原》:第七章 中断
  • 《操作系统真象还原》:第六章 完善内核

    6.1 函数调用约定简介 咱们实验使用cdecl。这里提一下stdcall,cdecl与stdcall的区别在于由谁来回收栈空间。 stdcall是被调用者清理参数所占的栈空间。 举例来说: 12int subtract(int ...

    《操作系统真象还原》:第六章 完善内核
  • 《操作系统真象还原》:第五章 保护模式进阶——加载内核

    5.3 加载内核 5.3.1 用 C 语言写内核 第一个 C 语言代码: 1234int main(void) { while(1); return 0;} 这个内核文件什么都没做,通过while(1)这个死循...

    《操作系统真象还原》:第五章 保护模式进阶——加载内核
  • 《操作系统真象还原》:第五章 保护模式进阶——内存分页机制

    从这一刻起,我们才算开始了真正的操作系统学习之旅 5.1 获取物理内存容量 5.1.1 Linux 获取内存的方法 在 Linux 2.6 内核总是用detect_memory函数来获取内存容量的。其函数本质上是通过调用 BI...

    《操作系统真象还原》:第五章 保护模式进阶——内存分页机制
  • 《操作系统真象还原》:第四章 保护模式入门

    4.1 保护模式概述 在本章大家会见到全局描述符表、中断描述符表、各种门结构,这是 CPU 提供给应用的,咱们用好就行。 保护模式强调的是“保护”,它是在 Intel 80286 CPU 中首次出现,这是继 8086 之后,Inte...

    《操作系统真象还原》:第四章 保护模式入门
  • 《操作系统真象还原》:第三章 完善MBR——I/O接口

    3.3 让我们对显示器说点什么吧 3.3.1 CPU 如何与外设通信——IO 接口 IO 接口功能: 设置数据缓冲,解决 CPU 与外设的速度不匹配 设置信号电平转换电路 设置数据格式转换 设置时序控制电路来同步 CPU 和外部...

    《操作系统真象还原》:第三章 完善MBR——I/O接口
  • 《操作系统真象还原》:第二章 编写 MBR

    先了解 CPU 的两种工作模式:实模式和保护模式 实模式(英语:Real mode)是 Intel 80286 和之后的 x86 兼容 CPU 的操作模式。实模式的特性是一个 20 比特的区段存储器地址空间(意思为只有 1MB 的存...

    《操作系统真象还原》:第二章 编写 MBR
  • 《操作系统真象还原》:第一章 环境配置

    第 0 章:一些你可能正感到迷惑的问题 摘记 0.28 MBR、EBR、DBR 和 OBR 各是什么 MBR 位于整个硬盘最开始的块, EBR 位于每个子扩展分区,各子扩展分区中只有一个逻辑分区。 MBR 和 EBR 位于分...

    《操作系统真象还原》:第一章 环境配置
  • 6月阅读总结

    “零拷贝”技术 Sogou C++ Workflow:搜狗公司的 C++服务器引擎,支持 500k QPS Reducing CPU scheduler latency in Linux:CPU 调度算法 BMQ 和 CFS 的对比...

    6月阅读总结