本章是操作系统从 0 到 1 系列的第三篇文章,主要讲解了计算机从通电到启动操作系统前的过程。在本章中,我将会介绍引导启动中的核心概念和知识,并最终自己实现一个 MBR。

Concepts you may want to Google beforehand: assembler, BIOS, BOOT, Real mode, interrupts, CPU registers, control structures, function calling, strings

# BIOS 和 BOOT

即使不了解计算机的人也可能对于 bootbios 这两个词有所耳闻,然而即使是计算机专业的人也可能会混淆这两者的概念,这里我们先来区分一下这两个概念,以便于我们后续的展开。

  • BIOS 是英文 "Basic Input Output System" 的缩略语,直译过来后中文名称就是 "基本输入输出系统"。它是一组固化到计算机内主板上一个 ROM 芯片上的程序,由主板的制造厂商编写代码;我们有时听到的 “刷 BIOS” 就是指将主版上的 BIOS 程序进行更换。
  • BOOT 其实是指 Boot Loader ,boot 本身就是 “引导” 的意思,是指一种用来引导操作系统激活的程序,广泛应用于各种系统之中。

# 对比

BIOS 保存着计算机最重要的基本输入输出程序系统设置信息开机后自检程序系统自启动程序,其主要功能是为计算机提供最底层的、最直接的硬件设置和控制。可从 CMOS 中读写系统设置的具体信息。

当按下电脑开机键的一瞬间,CPU 会先被激活去寻找 BIOS,接着 BIOS 会先在 Flash Memory 中执行,初始化计算机各种硬件并检测完整性,再执行 CMOS 中用户所喜好的设置,然后 BIOS 将自己解压缩到计算机的主存储器中。再启动引导程序 boot,然后由 boot 将操作系统加载到内存中执行。

Boot 的过程其实就是由 mbr + loader 一起实现的,后面会详细讲解什么是 mbr ,什么是 loader ,耐心看下去吧~

# MBR 实现

尽管很多教程中直接提供了 mbrloader 供我们使用而无需自己编写,但是我仍推荐至少自行实现一次这两者,原因如下;

  • 有助于提高汇编能力,这一点在后面也至关重要。
  • 它的编写并不困难,可以借助 mbr + loader 的编写来入门操作系统的编写。
  • 从 CPU 通电开始写起有助于建立对于计算机的全面认知,为后面的内核、可执行程序的加载,以及虚拟内存的建立做好准备。
  • boot 阶段会初步搭建起 segment 以及虚拟内存的框架,为后续 kernel 编写打下基础

💗真是激动人心,我们要开始编写自己的 mbr 了!

# 从通电开始

一个经典的问题:计算机通电后是怎么就进入到了操作系统呢?

计算机通电后,CPU 处于实模式,在实模式下,CPU 寻址方式为:由 16 位段寄存器值 * 10H 加 16 位偏移地址来形成 20 位的寻址空间,即只有 1M 的空间是可以被 CPU 读取的,而其他空间 CPU 都无法使用,CPU 必须借助这 1M 空间内的存储的各种程序来引导操作系统启动。

为什么是20位寻址空间呢?

这与 CPU 的发展历史有关,在 CPU 早期 (8086 时期),由于 CPU 性能有限,一共只有 20 位地址线,可寻址空间只有 1M (220 bytes),以及 8 个 16 位的通用寄存器,和 4 个 16 位的段寄存器;16 位的物理地址只能访问 64KB (216 bytes) 的内存。所以,为了能够通过这些 16 位的寄存器去构成 20 位的寻址地址,访问 1 MB 的内存,必须采取一种特殊的方式。

特殊方式即由 段基址 + 段内偏移 的方式来将 16 位的地址扩展为 20 位,具体方式如下:

  • 段基址:由段寄存器提供,并将寄存器值左移 4 位,将结果作为基地址。
  • 段内偏移地址:由通用寄存器来提供 (如 EIP)

将二者相加,得到一个 20 位的地址,覆盖 CPU 20 位的寻址空间,这里给出一个例子以便于更形象的说明:

例如,段基址为 0x1000 ,段内偏移为 0x1234 ,则此时物理地址可以由下式计算得出:

0x1000<<4+0x1234=0x10000+0x1234=0x11234\begin {array}{c} 0x1000 << 4 + 0x1234 = 0x10000 + 0x1234 = 0x11234 \end {array}

因此,这至关重要的 1MB 空间的划分是已经固定好的,每一块都有规定的用途的,被映射到不同的设备上:

实模式下1M寻址空间分配表

# BIOS 的工作

我们前面提到,CPU 通电后,将会执行 BIOS 程序,那么 BIOS 具体的工作有哪些呢?包括以下几步:

  1. 开机后 CPU 的指令寄存器 EIP 被设置为地址 0xFFFF0 ,这一地址指向 BIOS 固件上的代码,CPU 从这里执行开机后的第一条指令;
  2. CPU 开始执行 BIOS 上的代码,这一部分主要是硬件输入输出设备相关的检查,以及建立一个最初的中断向量表(后面会提到);
  3. BIOS 代码最后阶段的工作,就是检查启动盘上的 mbr 分区,所谓 mbr 分区就是指磁盘上的第一个 512 bytes 的内容,又叫 引导分区 ;BIOS 会对这 512 bytes 做一个检查:它的最后 2 个字节必须是特定的两个 magic number: 0x550xaa ,否则它就不是一个合法的启动盘;
  4. 检查通过后,BIOS 将这 512B 加载到内存 0x7C00 处,到 0x7E00 为止,然后指令跳转到 0x7C00 开始执行;至此 BIOS 退出舞台;

我们可以将这个过程画成一个图,来帮助我们理解

  • 黄色部分是 mbr ,被 bios 从磁盘复制到内存中去执行
  • 粉色部分是 bios 代码
  • 黄色部分的两边的白色部分 (0x500 开始) 是我们可以自由使用的空间

# mbr 的工作

mbr 的大小被限制在了 512 bytes,这对于引导一个操作系统的重任来说有点太小了,里面根本放不下太多的数据和代码,所以 mbr 的工作其实只是将一个 laoder 加载到内存,然后跳转到 loader 去继续执行。

我们还是用一个示意图来表示这个过程的变化和内存布局:

mbr工作阶段内存布局

「假设我们把 loader 加载到 0x8000 处(可以任意指定加载的位置,只需要 mbr 中对应起来就可以), loader 大约 4KB 大小」

上一节中我们说过, mbr 被加载到内存后,前后都有一块可供我们自由使用的空间,前面那块 (0x500 开始) 较小,大约 30KB,而后面这块 (0x7E00 开始) 就大的多了,大约有 608KB 左右的空间,我们就可以利用这块内存空间,将 loader 加载进内存并执行。

# 牛刀小试

理论知识已经学了很多了,但是直接就开始写一个完整 mbr 还是有点难度的,不如我们从 hello world 开始。

🎯编写一个 mbr,并打印 'Hello World!'

原理:通过 0x10H 中断触发 bios 中的打印服务,向屏幕输出字符,该中断的调用规则为:

Teletype outputAH=0EhAL = Character, BH = Page Number, BL = Color (only in graphic mode)no return

然后我们就可以编写代码了:

mov ah, 0x0e ; set tty mode
mov al, 'H'
int 0x10
mov al, 'e'
int 0x10
mov al, 'l'
int 0x10
int 0x10 ; 'l' is still on al
mov al, 'o'
int 0x10
mov al, ' '
int 0x10
mov al, 'W'
int 0x10
mov al, 'o'
int 0x10
mov al, 'r'
int 0x10
mov al, 'l'
int 0x10
mov al, 'd'
int 0x10
mov al, '!'
int 0x10
jmp $;  jump to current address = infinite loop
times 510 - ($-$$) db 0 ; fill with 0,length = (510 - previous code)
dw 0xaa55 ; x86 is little-endian

nasm 语法:

  • $ :当前行在所在段的相对位移
  • $$ :当前段的起始地址
  • dd :即 define double ,定义一个 4 字节值
  • dw :即 define word ,定义一个 2 字节值
  • db :即 define byte ,定义一个 1 字节值

我们把上面的代码保存成 boot_hello_world.asm , 然后用 nasm 编译他:

nasm -fbin boot_hello_world.asm -o boot_hello_world.bin

然后你就可以通过 xxd 来查看生成的 bin 文件:

xxd of bin file

我们可以看到,结尾时 Magic Number 0x55aa ,前面是我们写的 asm 代码转成的机器码,中间用 0 填充,一共 0x200 也就是 512bytes。

接着就可以用 qemu 来执行他了

# mac
qemu-system-i386 boot_hello_world.bin
# linux
qemu boot_hello_world.bin

不出意外的话,你就会看到 Hello World! 了🎉

qemu with hello world

# Problem Time

  1. 以下哪行代码可以使 Hello World! 以绿色字体输出:

    • mov bl, 0x0a
    • mov bh, 0x0a
    • mov bl, 0x0c
    • 以上都不对
    • 该设置只有在图形化模式下才生效
    • 需设置 bl 才能影响输出字符颜色
    • 根据手册 10 才是绿色对应的代码

# 更进一步 (打印信息)

上面我们已经学会了打印字符,那么接下来我们尝试编写几个打印函数用于输出错误或信息,帮助我们在后面的过程中定位问题。

# 打印新行

先简单一点,我们写一个打印换行符的函数 print_nl() ,其实就是依次打印 ascii 表特殊字符中的换行符,很简单,做一点点解释

  • \r :对应 ascii 码为 13 的字符,表示回车,将当前位置移到本行头

  • \n :对应 ascii 码为 10 的字符,表示换行,将当前位置移动到下一行

print_nl()
; print a new line
print_nl:
    pusha
    mov ah, 0x0e ; tty mode
    mov al, 0x0a ; '\r'
    int 0x10
    mov al, 0x0d ; '\n'
    int 0x10
    popa
    ret

# 打印字符串

然后加大一点难度,通过循环来实现一个打印字符串的函数 print() ,本质就是一个一个的打印字符,一些要点如下:

  • 因为调用 print() 的场景是程序运行过程中,所以函数开始要 pusha 保存 “现场”,在调用结束前还需要用 popa 还原 “现场”
  • 通过 bx 寄存器来传递参数 (寄存器有默认的用法,这里推荐用 bx 来传递参数)
  • 可以通过 cmpje 命令来进行条件判断和分支跳转。
print( [bx]string s )
; print a string pointed with bx
print:
    pusha
; keep this in mind:
; while (string[i] != 0) { print string[i]; i++ }
; the comparison for string end (null byte)
print_start:
    mov al, [bx] ; 'bx' is the base address for the string
    cmp al, 0 ; if '\0', then stop loop
    je print_end
    ; 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 print_start
print_end:
    popa
    ret

# 以十六进制打印数字

同样的道理,我们来将数字以 16 进制打印出来,由于我们直接是直接对接底层 CPU 的处理,没有现成的进制转换函数供我们使用,此时我们需要自己处理进制转换(数字到字符)和格式化输出,步骤如下:

  1. 参数:通过 dx 寄存器来传递参数
  2. 预处理:用寄存器来记录需要被转换的值,寄存器为 16 位,每次处理 4 位(一个 16 进制字符),所以要建立一个循环 4 次的函数
  3. 取数字:可以通过和 0x000f 进行 与操作 的方式,来仅取出 4 位数,方便后续处理
  4. 进制转换:原数字 + 0x30 就会转换成对应的数字的字符,与 9 做比较,如果更大,就加 7(对应 ascii 表的 A -> F
  5. 格式化处理:结合索引,通过移位的方式来将字符放在合适的位置,
  6. 打印:调用上面写的 print() 函数

注意:字符串在定义时末尾必须加上 0 ,来作为字符串结束的标识(例如 HEX_OUT

print_hex( [dx]number n )
; 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  
print_hex_start:
    cmp cx, 4 ; loop 4 times
    je print_hex_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, 0x07 ; '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_string + 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 print_hex_start
print_hex_end:
    ; prepare the parameter and call the function
    ; remember that print receives parameters in 'bx'
    mov bx, hex_string
    call print
    popa
    ret
hex_string:
    db '0x0000', 0 ; reserve memory for our new string

完成以上打印函数后,我们可以将他们保存在 print.nasm 中,然后通过 %include "boot_print.asm" 的方式在别的 asm 中使用我们定义好的打印函数

# 一步登天

好了,现在你已经学会 1+1 了,现在请计算 9.121*7.32/6.97 吧

根据我们之前说的,仔细想想,编写的 mbr 的功能,拆解一下其实并不复杂,本质就是读取一块硬盘,并将 loader 加载到内存中,然后跳转到 loader 执行就可以了,稍微复杂的点在于,由于历史原因,读磁盘时采用的是 CHS 的模式,这点也你会在后面更近一步学习,接着往下看吧。

「以下代码的部分希望读者能自己根据原理完成,如果觉得较为困难,可以打开折叠栏查看」

# nasm 读硬盘

好消息是我们无需去直接跟磁盘打交道,bios 中同样写好了读写磁盘的程序,我们只需要跟打印程序一样通过中断来调用即可。我么可以通过 0x13H 中断来读写磁盘,该中断的调用参数为:

RegisterValue
AH02H => bios 磁盘服务程序中的读磁盘程序
AL要读的扇区个数 (1-128 dec.)
CH磁道号的低 8 位 (0-1023 dec.)
CL低 5 位为所读起始扇区号, 6-7 位表示磁道号的高 2 位 (1-17 dec.)
DH所读磁盘的磁头号 (0-15 dec.)
DL需要进行读操作的驱动器号 (0=A:, 1=2nd floppy, 80h=drive 0, 81h=drive 1)
ES:BX缓冲区地址

返回值为:

RegisterValue
AHstatus (see INT 13,STATUS)
ALnumber of sectors read
CF0 = successful OR 1 = error

在 mbr 启动的时候,CPU 处于实模式,此时只能使用 bios 按 CHS 的寻址方式来找到 loader 的位置。

CHS到底是什么呢?

CHS 即 (Cylinder/Head/Sector),是非常古老的硬盘参数了,很久以前,硬盘的容量还很小,人们采用类似软盘的结构生产硬盘,即硬盘盘片的每一条磁道都具有相同的扇区数,由此产生了磁头数 (Heads),柱面数 (Cylinders),扇区数 (Sectors) 以及相应的寻址方式.

其中:

  • 磁头数 (Heads):表示硬盘总共有几个磁头,也就是有几面盘片,最大为 255 (用 8 个二进制位存储);
  • 柱面数 (Cylinders):表示硬盘每一面盘片上有几条磁道,最大为 1023 (用 10 个二进制位存储) => 每条磁道都有编号,叫做磁道号;
  • 扇区数 (Sectors):表示每一条磁道上有几个扇区,最大为 63 (用 6 个二进制位存储).

每个扇区一般是 512 个字节,理论上讲这不是必须的,但好象没有取别的值的。

因此,数据寻址方式为:先找到磁头,然后再通过柱面和扇区来确定唯一一个磁道,磁道中记录了数据。(如果你想了解更多有关早期硬盘的知识

# 参数值讲解

RegisterValueRemark
AL0x02要读两个扇区 (根据实际情况)
CH0x0仍然是 0 号磁道,只不过是去读第二个扇区)
CL0x02mbr 总是在 fisrt of cylinder 0 of head 0 of hdd 0 ,因此任何字节都只能在第二个扇区之后
DH0x00读取第一个磁头 (即第一个盘片)
DL由 bios 自动设置

我们这里先将读磁盘内容的命令封装成一个函数,同时增加一点错误输出函数来帮助我们定位可能存在的错误

disk_load([dh]sector n)
; load 'dh' sectors from drive 'dl' into ES:BX
disk_load:
		pusha
		
		push dx ; store the argument in 'dh'
		
		mov ah, 0x02 ; 0x02 = 'read disk'
		mov al, dh ; al <- number of sectors to read (0x01 .. 0x80)
		mov cl, 0x02 ; cl <- sector (0x01 .. 0x11)
		mov ch, 0x00 ; ch <- cylinder (0x0 .. 0x3FF, upper 2 bits in 'cl')
		mov dh, 0x00 ; dh <- head number (0x0 .. 0xF)
		
		; [es:bx] <- pointer to buffer where the data will be stored
    ; caller sets it up for us, and it is actually the standard location for int 13h
    int 0x13      ; BIOS interrupt
    jc disk_error ; if error (stored in the carry bit)
    
    pop dx
    cmp al, dh    ; BIOS also sets 'al' to the # of sectors read. Compare it.
    jne sectors_error
    popa
    ret
    
disk_error:
    mov bx, DISK_ERROR
    call print
    call print_nl
    mov dh, ah ; ah = error code, dl = disk drive that dropped the error
    call print_hex ; check out the code at http://stanislavs.org/helppc/int_13-1.html
    jmp disk_loop
sectors_error:
    mov bx, SECTORS_ERROR
    call print
disk_loop:
    jmp $
DISK_ERROR: db "Disk read error", 0
SECTORS_ERROR: db "Incorrect number of sectors read", 0

此时还没有结束,让我们写一个 main 函数在将所有之前写的功能串联起来吧,注意这里还有如下几点需要注意:

  • 开头要加上 [org 0x7c00] ,该命令会将后续出现的所有地址在寻址时加上该偏移,因为这里才是 mbr 真实执行的地址,也即我们各种数据储存的 “基地址”,如果不加的话,在寻址时会出现错误。
  • 在安全的位置设置我们自己的堆栈
  • 记得要设置 bx 寄存器指向 loader 的加载地址(因为 es:bx 指向读取出的硬盘数据的存储位置)
  • 由于我们还没有写 loader ,可以暂时先将 sector 2 设置成某特殊字符,来检验读取效果
main.asm
[org 0x7c00]
    mov bp, 0x7B00 ; set the stack safely away from us
    mov sp, bp
    mov bx, 0x8000 ; es:bx = 0x0000:0x8000 = 0x08000
    mov dh, 2 ; read 2 sectors
    ; the bios sets 'dl' for our boot disk number
    ; if you have trouble, use the '-fda' flag: 'qemu-system-i386 -fda file.bin'
    call disk_load
    mov dx, [0x8000] ; retrieve the first loaded word, 0xdada
    call print_hex
    call print_nl
    mov dx, [0x8000 + 512] ; first word from second loaded sector, 0xface
    call print_hex
    jmp $
%include "boot_print.asm"
%include "boot_sect_disk.asm"
; Magic number
times 510 - ($-$$) db 0
dw 0xaa55
; boot sector = sector 1 of cyl 0 of head 0 of hdd 0
; from now on = sector 2 ...
times 256 dw 0xdada ; sector 2 = 512 bytes
times 256 dw 0xface ; sector 3 = 512 bytes

🎉以上就是本章的全部内容,下一章将会继续讲解 loader 的实现~


更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Gality 微信支付

微信支付

Gality 支付宝

支付宝