0x120-从头开始写操作系统-启动扇区与内存的关系及内存寻址的应用

目录

回顾

上一篇,我们讲到了以下内容:

  • 16-bit Real Mode 是 x86 系列 CPU 的一种工作模式,所有 x86 系列 CPU 启动时,都处于 16-bit Real Mode
  • 16 bit 系统中,只有 64 KB 内存可以被识别,CPU 一次只能处理 16 bit 的数据
  • 8086 架构,因为有 20 bit 的地址总线,所以寻址总量有 1 MB
  • 8086 架构的内存管理采用内存分段机制,内存分段将程序不同的部分加载到不同的不连续的内存中
  • 16-bit Real Mode 真实物理地址的计算公式为:物理地址 = 段选择符左移 4 位 + 偏移地址
  • 中断可以让 CPU 暂时停止当前任务,执行我们指定的任务,然后回去执行原先的任务
  • 中断在 interrupt vector 中由一个数值做索引,interrupt vector 包含 interrupt service routines 的内存地址
  • 第一个 Hello World 程序使用中断,在屏幕上显示 HelloWorld 字符串

今日目标

本篇,我们将结束 16-bit Real Mode 的内容,储备更多的知识,为下一篇开启 32-bit Protected Mode 做准备。

本篇内容主要以汇编代码为主,讲解一些必要的指令。我们会从一个打印 16 进制字符串的程序开始,将

  • boot sector 与内存的关系
  • 字符串定义
  • 堆栈的使用
  • 方法调用
  • 条件跳转
  • 文件包含
  • 物理内存寻址

这些内容一一覆盖到。

需要大家注意的是,我们现在的编程环境和编程的内容都有很大的局限性,所以不要将现在所讲的东西不加推敲直接应用到其他环境中,会造成不正确的结果。

比如现在的环境只是 8086 16-bit,那么不能认为通用寄存器就只是 AX,而不去考虑是否因为环境不同,寄存器会发生变化。32-bit 环境下,通用寄存器为 EAX,64-bit 环境下,为 RAX。

又比如目前的 boot sector 程序没有分段,同样不能认为其他所有的汇编程序都不用分段。

当前讨论的,是如何在 8086 架构下,从头写一个简易操作系统,一切都围绕这个目标来展开。

Boot Sector 与内存的关系

我们从研究 boot sector 程序与内存的关系开始。

回想之前,计算机启动,BIOS 做自检(专业术语叫 POST - Power-On Self-Test,详见这篇文章),然后读取存储介质上的前 512 个字节,如果 0x55 和 0xaa 出现在第 510 和 511 个字节上,BIOS 就认为这是一个正规的 boot sector,开始执行其代码,然后加载操作系统。

一切程序的执行,第一步要将程序加载到内存。那么 boot sector 被 BIOS 加载到内存的哪一部分呢?

首先,肯定不是整个内存的起始位置(0x0)。因为之前说过,远在 BIOS 寻找 boot sector 之前,就已经开始做检测硬件等的操作。想必有一些必要的指令,已经被加载到内存最初的位置上。另外,还记得 interrupt vector table 以及 interrupt service routines (ISRs),这些都必须首先存在于内存,我们在 boot sector 中才有能力去调用中断,做相应的操作。

书中说,boot sector,会被 BIOS 固定加载到 0x7c00 这个位置上。

直接引用书上的内存示意图(来源:https://www.cs.bham.ac.uk/~exr/lectures/opsys/10_11/lectures/os-dev.pdf)。

在这里插入图片描述

原书中作者写着写着打了一个问号在这里。本着怀疑一切的态度,我们要证实一下,boot secotr 是不是被加载到了 0x7c00 这个位置。

在这里插入图片描述

打印任意内存位置上的内容(以 16 进制输出)

我们有能力打印字符(上一个 HelloWorld 程序),那么要证实 boot sector 被加载到 0x7c00,只需要打印出该内存地址开始的连续一些字节,和 od 命令的输出做比对,就能知道书中是否正确。记住 16-bit Real Mode 下内存没有保护机制,所以,是可以访问或者写入到任意内存,目前也没有什么后果,大家可以自行尝试。

这里的关注点不要放在代码上,后面会对代码做深入讨论。

打印 16 进制的代码在 @cfenollosa 的 Github 上能找到。这里需要两个文件,一个是 print 方法,负责打印字符串,另一个,是 print_hex 方法,负责转换 16 进制数为字符串,然后调用 print 来打印。

print.asm:

print:
    pusha

; keep this in mind:
; while (string[i] != 0) { print string[i]; i++ }

; the comparison for string end (null byte)
start:
    mov al, [bx] ; 'bx' is the base address for the string
    cmp al, 0 
    je done

    ; the part where we print with the BIOS help
    mov ah, 0x0e
    int 0x10 ; 'al' already contains the char

    ; increment pointer and do next loop
    add bx, 1
    jmp start

done:
    popa
    ret

print_nl:
    pusha

    mov ah, 0x0e
    mov al, 0x0a ; newline char
    int 0x10
    mov al, 0x0d ; carriage return
    int 0x10

    popa
    ret

print_hex.asm:

; receiving the data in 'dx'
; For the examples we'll assume that we're called with dx=0x1234
print_hex:
    pusha

    mov cx, 0 ; our index variable

; Strategy: get the last char of 'dx', then convert to ASCII
; Numeric ASCII values: '0' (ASCII 0x30) to '9' (0x39), so just add 0x30 to byte N.
; For alphabetic characters A-F: 'A' (ASCII 0x41) to 'F' (0x46) we'll add 0x40
; Then, move the ASCII byte to the correct position on the resulting string
hex_loop:
    cmp cx, 4 ; loop 4 times
    je end

    ; 1. convert last char of 'dx' to ascii
    mov ax, dx ; we will use 'ax' as our working register
    and ax, 0x000f ; 0x1234 -> 0x0004 by masking first three to zeros
    add al, 0x30 ; add 0x30 to N to convert it to ASCII "N"
    cmp al, 0x39 ; if > 9, add extra 8 to represent 'A' to 'F'
    jle step2
    add al, 7 ; 'A' is ASCII 65 instead of 58, so 65-58=7

step2:
    ; 2. get the correct position of the string to place our ASCII char
    ; bx <- base address + string length - index of char
    mov bx, HEX_OUT + 5 ; base + length
    sub bx, cx  ; our index variable
    mov [bx], al ; copy the ASCII char on 'al' to the position pointed by 'bx'
    ror dx, 4 ; 0x1234 -> 0x4123 -> 0x3412 -> 0x2341 -> 0x1234

    ; increment index and loop
    add cx, 1
    jmp hex_loop

end:
    ; prepare the parameter and call the function
    ; remember that print receives parameters in 'bx'
    mov bx, HEX_OUT
    call print

    popa
    ret

HEX_OUT:
    db '0x0000',0 ; reserve memory for our new string

利用 cfenollosa 代码,我们写一个简单的测试代码:

boot_sect_mem_chk.asm

org 0x7c00
mov dx, [0x7c00] ; 将内存地址 0x7c00 位置上的数据,写入到 dx 寄存器
call print_hex ; 调用 print_hex 方法输出 dx 中的内容

jmp $  ; 跳转到当前地址(无限循环)

%include "print.asm"
%include "print_hex.asm"

times 510 - ($ - $$) db 0 ; 512 个字节中剩余字节全部填充 0
dw 0xaa55 ; 最后两个字节,是 0xaa55,让 BIOS 知道这是 boot sector

编译:

nasm -f bin boot_sect_mem_chk.asm -o boot_sect_mem_chk.bin

运行:

在这里插入图片描述

od 命令的输出做对比:

od -t x1 -A n boot_sect_mem_chk.bin

在这里插入图片描述

证实了 boot sector 被加载到 0x7c00 内存的说法。也可以写一个循环,输出前 N 个字符,对比输出与 od 命令是完全一致的。

在这里插入图片描述

不要忘了,x86 架构是小字节序,QEMU 中打印出来的每个字节,与 od 命令显示的是相反的。

物理寻址的应用

前一篇文章铺垫了很多关于 8086 架构物理地址计算的信息,现在,该尝试一下在代码中做应用,加深理解。

找到 Boot Sector 在内存中的前两个字节的位置

我们还是拿 boot_sect_mem_chk.asm 举例,顺便开始讲解一些必须知道的汇编指令。

我们的目标是,用前文所说的寄存器,换一种方式,使用段寄存器来找到 Boot Sector 的前两个字节的内容,打印出来。

org 0x7c00
mov dx, [0x7c00] ; 将内存地址 0x7c00 位置上的数据,写入到 dx 寄存器
call print_hex ; 调用 print_hex 方法输出 dx 中的内容

jmp $  ; 跳转到当前地址(无限循环)

%include "print.asm"
%include "print_hex.asm"

times 510 - ($ - $$) db 0 ; 512 个字节中剩余字节全部填充 0
dw 0xaa55 ; 最后一个字节,是 0xaa55,让 BIOS 知道这是 boot sector

我们先讲一些完成这个任务的必要知识,然后完成这个任务,再然后,我们来拆解这个程序,进入汇编指令的讲解。

寄存器

如果有人问说计算机有哪些寄存器?你的回答只是计算机有 AX,BX,CX,DX 等,然后就没有然后了。那么,再次了解一下寄存器。

最基本的寄存器描述如下:

由于 CPU 访问内存的操作会大大降低计算机运行效率,所以 CPU 内部内置了一些记忆单元用于临时储存要处理的小量数据,这些记忆单元,就被称之为寄存器。

寄存器的分类

x86 架构下,按照寄存器的作用,可以把寄存器分为 5 大类,分别是:

  • 通用寄存器 General Registers
  • 段寄存器 Segment Registers
  • 指针寄存器 Pointer Registers
  • 索引寄存器 Index Registers
  • 控制寄存器 Control Registers


- 通用寄存器 -

x86 架构的 CPU 包含 4 个通用寄存器,32 位架构下,这 4 个寄存器分别是:

EAXEBXECXEDX

相应的,我们现在讨论的 80806 16 位架构下,这 4 个寄存器分别是:

  • AX:'A' 代表 Accumulator,该寄存器也称为累积寄存器,通常用于做数学运算
  • BX:'B' 代表 Base,该寄存器也称为基址寄存器,可以用于存放临时值,也可以用于做内存寻址
  • CX:'C' 代表 Count,该寄存器也称为计数寄存器,通常用于做循环计数
  • DX:'D' 代表 Data,该寄存器也称为数据寄存器,通常用于输入输出的操作,同时也可以和 AX 配合用于大数值乘除的运算


- 段寄存器 -

对于段寄存器的讲解比较重要,粗体字加深理解

8086 CPU 有 3 个常用段寄存器,3 个附加段寄存器。

常用段寄存器分别是:

  • 代码段寄存器 Code Segment Register:代码段包含所有的可以被执行的指令;代码段寄存器存放代码段的起始内存地址(逻辑地址)
  • 数据段寄存器 Data Segment Register:数据段包含程序需要的所有数据,常量,字符串等;数据段寄存器存放数据段的起始内存地址(逻辑地址)
  • 栈段寄存器 Stack Segement Register:栈段包含方法调用所需的参数,方法的返回地址等数据;栈段寄存器存放栈段的起始内存地址(逻辑地址)

逻辑地址指的是 16-bit 地址,也是后面段和段寄存器小结中提到的段选择符。

例如,DS 的值是 0x6F70,那么 mov ax, [0x1000] 中的物理内存地址是

0x6F70 * 10H + 0x1000 = 0x6F700 + 0x1000 = 0x70700

附加段寄存器分别是:

ESFSGS。这些寄存器提供了额外储存数据的空间。通常,MOVSCMPS 等字符串操作的会用 ES。程序员也可以在代码中手动指定这些寄存器的使用。


- 指针寄存器 -

3 个指针寄存器分别是:

  • 指令指针寄存器 Instruction Pointer(IP):存放下一条指令的内存地址偏移量,与 CS 一起,[CS:IP] 提供下一条指令的真实物理地址
  • 栈指针寄存器 Stack Pointer(SP):存放栈中的当前数据的内存地址偏移量,与 SS 一起,[SS:SP] 提供当前数据的真实物理地址,获得当前位置上的数据
  • 基址指针寄存器 Base Pointer(BP):存放栈底的内存地址偏移量,与 SS 一起,[SS:BP] 提供当前方法参数的真实物理地址;同时,BP 还可以跟 DI,SI 这两个索引寄存器一起使用,用于物理地址计算,如 [BP + SI + 0x10]


- 索引寄存器 -

2 个指针寄存器分别是:

  • 源索引寄存器 Source Index(SI):常用于字符串操作的源索引
  • 目标索引寄存器 Destination Index(DI):常用于字符串操作的目标索引

不过实际中,这两个寄存器也常与段寄存器配合,进行物理地址寻址,如 [DS:SI],[ES:DI]。


- 控制寄存器 -

控制寄存器不在讨论范围。有兴趣的同学参考这篇文章

在这里插入图片描述

段和段寄存器小结

8086 架构中,内存段十分重要,所以我们把段和段寄存器拿出来做一个小总结。

关于每一个段的特征以及每个段与内存寻址的关系,总结如下:

  1. 8086 架构下,每个段容量最大 64 KB(216 Bits)
  2. 汇编中,任何段中的任一内存地址的寻址,都是相对于段的起始内存地址
  3. 一个段,总是开始于一个能被 16 整除(10 进制,或者 16 进制被 10H 整除)的内存地址上
  4. 任一段寄存器存放的,都是相应段的起始内存地址。段寄存器中的地址,被称为*段选择符(Segment Selector 这里有详解),段选择符 16 (16 进制乘以 10H,二进制左移 4 位)之后的,才是段地址(所以,上一篇文章勘误,直接说段寄存器中储存的是段地址是错误的表述)**
  5. 物理地址是通过 段地址 + 偏移量 计算得到

来自 Wiki 的图片,解释物理地址的计算(二进制形式)。

在这里插入图片描述

完成任务

好了,有了上面的铺垫,我们来使用段寄存器找到 boot sector 的前两个字节。

首先,我们讲 org 指令注释掉。汇编的注释,使用分号 ;

我们看一下注释掉之后是个什么情况,还能打印出我们需要的前两个字节吗?

答案是否定的。

在这里插入图片描述

为什么会出现这样的情况?

先看一下这条指令

mov dx, [0x7c00]

[] 操作符中的地址,都是一个相对于段起始内存地址的内存偏移量

当我们使用

mov dx, [0x7c00]

的时候,事实上,assembler 内部是这样处理这个内存寻址的

mov dx, [DS:0x7c00]

DS 是段寄存器,我们会使用段寄存器中的段选择符,乘以 16 再加上 0x7c00 这个偏移量来计算最后的物理地址。

那我们来看一下没有 org 指令的情况下,DS 寄存器的值是多少?

我们尝试将 DS 的值打印出来,结果发现没有输出,意味着 DS 中没有存放任何值(没有值不代表是 0)。

在这里插入图片描述

因此,无法找到 boot sector 前两个字节是理所当然的。

解决方案就很简单了,存放一个段选择符到 DS 中即可。

前文说过,汇编中所有寻址,都是相对于段的起始地址而言。我们已经知道并证实 boot sector 代码会被加载到 0x7c00 这个位置,我们的目标是打印物理地址位 0x7c00 这个位置上的数据。

根据

物理地址 = 段选择符 * 10H + 偏移量

的公式,DS 的值应该是 0x7c0,偏移量应该是 0x0

0x7c0 * 10H + 0x0 = 0x7c00

因此,修改代码如下,即可得到 boot sector 前两个字节。

在这里插入图片描述

这里有一个点要说明一下,所有的段寄存器和索引寄存器都是不能直接写入的,mov ds, 0x7c0 无法编译通过,必须有一个中间过渡,所以用 ax 作为过渡,将值写入到 ds 中。

拓展部分
另外,再抛出一个问题,为什么不能设置 DS 寄存器的值为其他值,如 0x0,或者 0x100,然后设置偏移量到相应的值去获取前两个字符。

我目前的解释是,因为我们编译出来的是原始字节文件,意味着所有字节都是数据,那么将被加载到 1 个段中(一个段 64 KB,我们的数据只有 512 Bytes),段的起始逻辑地址,就应该被写入到 DS 中,所有的对于段中字节的寻址,都以 DS 为相对地址。
为了证实这一点,我们可以获取一下最后两个字节 0xaa55。按照公式,DS 为 0x7c0,偏移量为 0x1fe(第 510 和 511 个字节)。

可以成功获取到最后两个字节的内容。
在这里插入图片描述
当然,我试过将 DS 设置为 0x0,偏移为 0x7c00 会有很奇怪的现象出现,大家自行尝试。

程序拆解及必要汇编指令

拆解一下前文的程序,代码如下:

org 0x7c00
mov dx, [0x7c00] ; 将内存地址 0x7c00 位置上的数据,写入到 dx 寄存器
call print_hex ; 调用 print_hex 方法输出 dx 中的内容

jmp $  ; 跳转到当前地址(无限循环)

%include "print.asm"
%include "print_hex.asm"

times 510 - ($ - $$) db 0 ; 512 个字节中剩余字节全部填充 0
dw 0xaa55 ; 最后一个字节,是 0xaa55,让 BIOS 知道这是 boot sector

【1】:org 指令,明确告诉 assembler 我们的 Boot Sector 代码被加载到 0x7c00 的位置
【2】:mov 指令,将相对于 0x0,偏移量为 0x7c00 内存位置上的值写入 dx
【3】:调用 print_hex 方法,打印出 dx 寄存器中的值
【4】:挂起 CPU
【5】【6】:包含两个打印方法的文件
【7】:除去该行指令以上所有指令的长度,除去最后两个字节的长度,其余位置全部填充 0
【8】:最后两个字节固定值,0xaa55

org 指令

这个解释只有英文才能区分了,不知道该怎么翻译才好,中文的翻译都是指令。各种资料对于 org 的解释,指出 org 不是一个 instruction,而是一个 directive。类似 C 语言中的 define

org 指令,在书中的解释是,明确告诉 assembler 我们的 Boot Sector 代码被加载到指定的位置(0x7c00)。但是范范这么一句话,感觉什么都没有讲明白。根据资料,assembler 内部,有一个 Location Counter(LC),它负责记录当前内存中下一个可以用于存放编译后指令的空位。

org 指令更改 LC 到指定的内存地址。例如这里的 0x7c00

当前的 LC 的值,可以使用之前看到过的 $ 符号来表示。

我们可以打印出来看一下。

没有使用 org 指令,LC 的值是 0000 (打印出来的乱码至今未理解是为什么)

在这里插入图片描述

使用了 org 指令,LC 的值是指定值。

在这里插入图片描述

LC 的值,是根据指令递增的,一条指令被编译存放入上一个 LC 的位置之后,LC 会增加这个指令的长度,准备存放下一个指令。

下图展示了 LC 在第一个 call print_hex 指令之后,递增了 3 个字节。

在这里插入图片描述

LC 作为概念大家明白就行。我试着找 LC 与内存寻址相关的资料,结果都没有找到。意味着 LC 对于我们理解内存寻址来说没有什么作用。

但是我们可以通过程序来的行为来进一步解释 org 指令到底干了什么。

我们可以打印寄存器中的值,那么,我就把所有寄存器的值全部打印出来,然后对比一下使用 org 指令和没有使用 org 指令前后的区别,看是否是因为 org 指令初始化了 DS 寄存器,让 mov dx, [0x7c00] 可以获取到相应数据。

对比结果如下图。

使用了 org 指令,除了 sp 是一个非 0 值,其他所有寄存器都被设置成了 0x0。因此,根据寻址公式,0x0 * 10H + 0x7c00 = 0x0 + 0x7c00 = 0x7c00,就可以获取到 boot sector 前两个字节的内容。

在这里插入图片描述

然后我将 org 指令注释掉,结果是各个寄存器全部是乱码。最后也无法获取到 boot sector 前两个字节的内容。

在这里插入图片描述

可以初步得出结论,org 指令除了资料上说的设置 LC 到当前地址,还初始化段寄存器的值为 0x0

jmp 指令

jmp $ 在这里的作用,是做一个无限循环,让 CPU 停在该指令处,不能再往下执行。

试想一下如果没有这个无限循环,CPU 就会按着 CS:IP 一路往下执行,能执行的则执行,不能执行的就 crash,我们不想让这样的事情发生。

jmp 跳转分 short jump,long jump,还分向前跳转,向后跳转。细看这个 jmp $ 指令,机器码是 eb fe,还有很多可以挖掘的地方,它是一个 short jump,是一个 reverse short jump,意思是向后跳转。具体操作是从 jmp $ 紧接着的下一个指令的地址开始算,往回跳转两个字节,因为 eb fe 就是两个字节,所以跳回指令本身,造成一个无限循环。

详细不展开,关于 jmp $ 指令,好文一篇

times 指令

重复执行后面的操作 N 次。在 boot sector 程序中,最后两个字节固定,因此,填充 0 的操作应该进行 times 指令当前的地址,减去段起始地址的结果这么多次。

$ 操作符代表当前指令地址,$$ 代表段起始地址。

pusha popa 指令

调用方法的时候,如果方法不小心修改了寄存器的值,可能会造成意想不到的结果。我们不希望寄存器的值被修改,有一种办法是在调用方法之前,将所欲寄存器的值以及方法的返回地址都 push 到栈中,然后调用结束,再全部 pop 回来。

这样的操作很麻烦,所以 pusha 和 popa 指令,会帮助我们完成这一操作。pusha 在方法中调用一次,会将所有寄存器以及方法返回地址都保存到栈,popa 则执行相反的操作。

print_hex:
    pusha
...
    popa
    ret

条件控制指令

看完这篇文章,一切都很清楚

条件跳转跟在 CMP 指令之后使用,如:

cmp ax, 0x4
je jump_point

jump_point:
    do_something_here

最常用的是下面几个:

  • JE 如果相等,跳转
  • JNE 如果不相等,跳转
  • JG 如果大于目标,跳转
  • JGE 如果大于等于目标,跳转
  • JL 如果小于目标,跳转
  • JLE 如果小于等于目标,跳转

其他必要的汇编指令

最重要的部分已经讲完了,接下来,用示例代码的方式带过剩余简单的部分。

  • 字符串定义

最后的 0,添加一个 null byte 作为字符串结尾

HELLO:
    db 'Hello, World', 0

GOODBYE:
    db 'Goodbye', 0
  • 文件包含

关于文件包含,有一个问题还没有解决,就是为什么文件包含要写在 jmp $ 指令之后。我试过将他们放在文件其他地方,确实会发生无法预料的结果。有待研究。

%include "boot_sect_print.asm"
%include "boot_sect_print_hex.asm"
  • 堆栈的使用

关于栈,记住几个点即可。

第一,后进先出 (LIFO);第二,BP 寄存器指向栈底,SP 寄存器指向栈顶;第三,栈从内存高位地址向低位地址增长;第四,push 一个值到栈,SP - 2;pop 一个值出栈,SP + 2

可以使用书中的代码进行理解。

mov ah, 0x0e ; int 10/ ah = 0eh -> scrolling teletype BIOS routine
mov bp, 0x8000 ; Set the base of the stack a little above where BIOS
mov sp, bp ; loads our boot sector - so it won ’t overwrite us.
push 'A' ; Push some characters on the stack for later
push 'B' ; retreival. Note , these are pushed on as
push 'C' ; 16 - bit values , so the most significant byte
; will be added by our assembler as 0 x00.
pop bx ; Note , we can only pop 16 - bits , so pop to bx
mov al, bl ; then copy bl ( i.e. 8- bit char ) to al
int 0x10 ; print (al)
pop bx ; Pop the next value
mov al, bl
int 0x10 ; print (al)
mov al , [0x7ffe ] ; To prove our stack grows downwards from bp ,
; fetch the char at 0 x8000 - 0x2 ( i.e. 16 - bits )
int 0x10 ; print (al)
jmp $ ; Jump forever.
; Padding and magic BIOS number.
times 510 - ($ - $$) db 0
dw 0xaa55

总结

  • Boot Sector 被 BIOS 加载到 0x7c00 的内存位置
  • 用程序证实了 0x7c00 物理内存位置上,确实是我们的 Boot Sector 程序
  • 寄存器的分类,以及寄存器的作用
  • 用段寄存器来完成寻找 Boot Sector 前两个字节内容的任务
  • 必要的汇编指令

下一篇,我们将讲解如何读取磁盘数据,之后就要开启我们的 32-bit Protected Mode 之旅。


推荐阅读(参考链接):