《操作系统真象还原》学习——实模式下的MBR

第1-3章的内容

理论

  • 8086CPU的寄存器只有16位,而地址空间有1M(20位),如何做到用16位寄存器访问20位地址空间呢?采用的是段基址(cs)+段偏移(ip)的方法,将段基址乘以16(二进制左移4位),再与段偏移相加。
  • CPU工作原理:操作控制器从指令指针寄存器读取下一条要执行的指令,放到指令寄存器,由指令译码器解码,取出操作数,从内存中得到操作数放到缓存中(或者直接用寄存器里的),由运算单元进行运算。

  • 段寄存器(sreg)用来保存段基址,在实模式和保护模式下都是16位宽,有CS(代码),DS(数据),ES/FS/GS(附加),SS(栈)段寄存器几种。IP寄存器是不可见寄存器,用来存放偏移地址。通用寄存器可以用来保存任何数据,在实模式和保护模式下都有8个,分别是AX,BX,CX,DX,SI,DI,BP,SP。其中AX,BX,CX,DX寄存器由相应的L(0-7位)和H(8-15位)寄存器组成。x86架构从16位到32位扩展后,出现了32位的eax寄存器,AL为低16位, AH为高16位。按照惯例,cx寄存器用作循环的次数控制,bx寄存器用于存储起始地址。

  • BIOS(Basic Input/Output System,基本输入输出系统)、MBR(Master Boot Record,主引导记录)和Loader(引导加载器)是操作系统启动过程中三个关键的组件,它们在系统从电源打开到操作系统完全加载的过程中各自扮演不同的角色。下面简要概述它们之间的关系:
    • BIOS
      • 作用:BIOS 是计算机主板上的一个固件,负责在计算机加电或重启后执行一系列初始化任务,如检查硬件、初始化CPU和内存等。
      • 流程:BIOS 执行 POST(Power-On Self-Test,上电自检)并搜索可启动设备。
      • 引导设备:BIOS 根据其配置(通常在 BIOS 设置界面中设定)选择一个启动设备(通常是硬盘、U盘或光驱等)。
      • 加载 MBR:BIOS 从选定的启动设备读取其第一个扇区(即 MBR)到内存,并跳转到那里执行代码。
    • MBR
      • 作用:MBR 是硬盘或其他存储设备的第一个扇区(通常是 512 字节)。
      • 结构:包括一个小段的引导代码和分区表。
      • 任务:MBR 的代码检查分区表,找到活动(active)分区,然后从这个活动分区加载引导加载器(Loader)到内存,并将控制权转交给它。
    • Loader
      • 作用:Loader 是一个小型程序,负责加载操作系统内核到内存。
      • 流程:Loader 通常会先加载一些必要的文件(例如,Linux 下的 initramfs)和驱动,然后加载并执行操作系统的内核。
      • 转交控制权:一旦操作系统内核被加载并初始化,Loader 将控制权转交给操作系统,完成启动过程。
    • 关系:BIOS 加载 MBR。MBR 找到活动分区并加载 Loader。Loader 加载操作系统内核并开始执行。

实践

安装bochs,遇到了https://zhuanlan.zhihu.com/p/161217153中提到的大多数问题。

用以下命令创建虚拟硬盘并将结果(该写什么会显示在terminal上)写入bochsrc.disk配置文件:

1
bin/bximage -hd -mode="flat" -size=60 -q hd60M.img

安装完成后,如下图显示,CPU的cs:ip寄存器被强制初始化为0xF000:0xFFF0,在实模式下乘以16等效地址为0xFFFF0,便是BIOS的入口地址,其指令为跳转到f000:e05b地址处,其中存放指令jmp 0:7c00。

MBR的存放位置为0x7c00:MBR需要加载内核加载器,不能过早被覆盖,因此最好在内存尾部。以内存32KB为例,MBR程序以及其栈所占空间大概1KB左右,因此32KB-1KB换算成16进制为0x8000-0x400=0x7c00。BIOS会通过jmp0:0x7c00跳转到MBR,以下为MBR示例代码:

; 主引导程序 
SECTION MBR vstart=0x7c00  ; SECTION是逻辑段,告诉编译器起始地址编译为0x7c00
	mov ax,cs  ; 将cs=0赋值到ax通用寄存器
	mov ds,ax  ; 将ax中的值赋值到ds,ds为sreg(段寄存器),不能直接赋值,需要用ax中转
	mov es,ax
	mov ss,ax
	mov fs,ax
	mov sp,0x7c00	; 这条指令是将栈指针(SP)设置为0x7c00的意思。这条指令是在进行初始化操作时的一步,为的是给栈(stack)保留一块空间。0x7c00以下暂时是安全的区域
	; 清屏
	mov ax, 0x600   ; 用0x06功能号上卷窗口。其中ah(ax的高八位)表示功能号,al(ax的低八位)表示滚动的行数,如果为0则表示全部
	mov bx, 0x700   ; 白底黑字
	mov cx, 0				; 左上角(0,0)
	mov dx, 0x184f  ; 0x18=24,0x4f=79,x86为小端字节,表示右下角坐标(80,25)
	int 0x10				; 实际的清屏操作。BIOS中断0x10用于处理与显示相关的任务,例如获取或设置光标位置,设置显示模式等。
	; 获取光标位置
	mov ah, 3       ; 将3放入AH寄存器。在x86的BIOS中断中,AH为3表示获取光标位置。
	mov bh, 0       ; 将0放入BH寄存器。BH寄存器在这里用于表示页号,0表示第一页,也就是整个屏幕的默认页。
	int 0x10        ; 在调用这个中断后,光标的位置会被返回到ch=光标开始行,cl=光标结束行,dh=光标所在行号,dl=光标所在列号
	; 打印字符串
	mov ax, message
	mov bp, ax	    ; bp是基址指针。es开头已初始化成cs=0,es:bp是指向段寄存器的基址,也是串首地址
	mov cx, 5				; 字符串5个字符
	mov ax, 0x1301  ; 0x13为显示字符串功能,al=1为显示方式
	mov bx, 0x2     ; 黑底绿字
	int 0x10
	; 使程序悬停
	jmp $           ; 本行指令的地址,近跳转,死循环
	
	message db "1 MBR"  ; db=define byte
	times 510-($-$$) db 0   ; $-$$=本行到本section的偏移量。MBR有512字节,减去最后两字节,还有510字节,用0把其余字节填满
	db 0x55, 0xaa  ; magic number,表示MBR结束

接着,将其编译为bin文件,其中file.S为汇编文件,file.bin为编译成的纯二进制文件。

1
nasm -o file.bin file.S

然后把这个文件写入我们的虚拟硬盘中,其中if为输入文件,of为输出文件,bs为块的大小是多少字节,count是拷贝的块数,conv是如何转换文件

1
dd if=/your_path/file.bin of=/your_path/hard_disk.img bs=512 count=1 conv=notrunc

最后启动bochs

1
bin/bochs -f bochsrc.disk

显示如下所示:

进一步改进MBR,直接操作显卡(而不是实模式下的中断):

; 主引导程序 
SECTION MBR vstart=0x7c00  ; SECTION是逻辑段,告诉编译器起始地址编译为0x7c00
	mov ax,cs  ; 将cs=0赋值到ax通用寄存器
	mov ds,ax  ; 将ax中的值赋值到ds,ds为sreg(段寄存器),不能直接赋值,需要用ax中转
	mov es,ax
	mov ss,ax
	mov fs,ax
	mov sp,0x7c00	; 这条指令是将栈指针(SP)设置为0x7c00的意思。这条指令是在进行初始化操作时的一步,为的是给栈(stack)保留一块空间。0x7c00以下暂时是安全的区域
	mov ax,0xb800  ; 0xb8000是文本模式显示适配器的起始位置
	mov gs,ax
	
; 清屏
	mov ax, 0x600   ; 用0x06功能号上卷窗口。其中ah(ax的高八位)表示功能号,al(ax的低八位)表示滚动的行数,如果为0则表示全部
	mov bx, 0x700   ; 白底黑字
	mov cx, 0				; 左上角(0,0)
	mov dx, 0x184f  ; 0x18=24,0x4f=79,x86为小端字节,表示右下角坐标(80,25)
	int 0x10				; 实际的清屏操作。BIOS中断0x10用于处理与显示相关的任务,例如获取或设置光标位置,设置显示模式等。

; 输出背景色绿色,前景色红色,并且跳动的字符串“1 MBR”
  mov byte [gs:0x00],'1'    ; 段跨越前缀,不用ds作为数据段基址寄存器。一个字符用2字节表示,低字节是字符的ASCII码
  mov byte [gs:0x01],0xA4   ; 字符的高字节是属性
  mov byte [gs:0x02],' '
  mov byte [gs:0x03],0xA4
  mov byte [gs:0x04],'M'
  mov byte [gs:0x05],0xA4
  mov byte [gs:0x06],'B'
  mov byte [gs:0x07],0xA4
  mov byte [gs:0x08],'R'
  mov byte [gs:0x09],0xA4
  
  times 510-($-$$) db 0   ; $-$$=本行到本section的偏移量。MBR有512字节,减去最后两字节,还有510字节,用0把其余字节填满
	db 0x55, 0xaa  ; magic number,表示MBR结束

进一步改进MBR,让MBR可以读取硬盘,从硬盘上把loader(加载器)加载到内存:

......
mov eax, LOADER_START_SECTOR  ; 起始扇区lba地址,来源
mov bx, LOADER_BASE_ADDR      ; 写入的地址,目的
mov cx, 1											; 待读入的扇区数
call rd_disk_m_16							; 以下读取程序的起始部分(一个扇区)

jmp LOADER_BASE_ADDR

rd_disk_m_16:
	; eax=起始扇区lba地址,bx=将数据写入的内存地址,cx=读入的扇区数。
	; esi, edi 是源和目标索引寄存器
	mov esi,eax ; 备份eax
	mov di,cx   ; 备份cx
	; 读写硬盘,第一步:设置要读取的扇区数
	mov dx,0x1f2 ; 此端口负责扇区计数
	mov al,cl 
	out dx,al   ; 读取的扇区数。out指令通常要求其第二个操作数是一个累加器寄存器(al, ax 或 eax),而不能是其他的通用寄存器。这是由硬件的微架构决定的。因此上一行mov al,cl不能省略。
	mov eax,esi ; 恢复ax,在mov al,cl这行中,eax被改变了,这也是前面备份eax的意义
	
	; 第二步:将LBA地址存入0x1f3~0x1f6
	; LBA地址7~0位写入端口0x1f3
	mov dx,0x1f3
	out dx,al  ; out dx, al 指令并没有修改 dx 寄存器的值。dx 寄存器仅仅是用来指示应向哪个I/O端口写数据(类似指针的含义)。实际写入的数据来自 al 寄存器。dx 的值没有被覆盖或更改,它还是 0x1F3。指令 out dx, al 只是按照 dx 指定的地址,将 al 的内容输出到那个I/O端口。
	; LBA地址15~8位写入端口0x1f4
	mov cl,8
	shr eax,cl
	mov dx,0x1f4
	out dx,al
	; LBA地址23~16位写入端口0x1f5
	shr eax,cl
	mov dx,0x1f5
	out dx,al
	
	; 以下操作确保了0x1f6端口的高4位(位7-4)和低4位(位3-0)分别被正确设置。与前面的端口不同(0x1F3, 0x1F4, 0x1F5),这里需要特殊处理是因为该端口具有多重功能。
	; 在28位的LBA(逻辑块寻址)模式中,实际上只有28位用于表示扇区地址。这28位是从0到27位,没有28-31位。所以,这里只关注到第27位就足够了。在48位的LBA模式中,更多的地址位会被使用。
	shr eax,cl
	and al,0x0f  ; LBA第24~27位,这样做是为了保留LBA地址的24-27位,而将28-31位清零
	or al,0xe0   ; 设置7~4位为1110,表示lba模式
	mov dx,0x1f6 ; 在这个端口内,不仅包含了LBA地址的一部分(位24-27),还包含了硬盘的操作模式和其他标志。
	out dx,al
	
	; 第三步:向0x1f7端口写入命令,0x20,这是硬盘读取的命令代码
	mov dx,0x1f7
	mov al,0x20
	out dx,al
	
	; 第四步:检测硬盘状态
	.not_ready:  ; 局部标签
	  ;同一端口,写时表示写入命令字,读时表示读入硬盘状态
	  nop
	  in al,dx
	  and al,0x88     ; 第4位为1表示硬盘控制器已准备好数据传输;第7位为1表示硬盘忙
	  cmp al,0x08     ; 比较AL和0x08,目的是检查第4位是否设置(表示硬盘准备好)
	  jnz .not_ready  ; 若未准备好,继续等
  
  ; 第五步:从0x1f0端口读数据
     mov ax,di     ; 前面定义过了di=cx
     mov dx,256    ; di为要读取的扇区数,一个扇区共有512字节,每次读入一个字,共需di*512/2次,所以di*256
     mul dx        ; mul 指令用于无符号乘法。当使用 mul dx 这样的指令时,实际上是把 ax 寄存器和 dx 寄存器的值相乘,然后把结果存储在 dx:ax 寄存器对中。这里的 dx:ax 表示一个32位的结果,其中 dx 包含高16位,ax 包含低16位。
     mov cx,ax
     mov dx,0x1f0  ; 设置I/O端口为0x1f0,用于从硬盘读取数据
  
  .go_on_read:
  	in ax,dx
  	mov [bx],ax    ; 将ax的内容写入[bx]指向的内存位置
  	add bx,2       ; 移动目标内存指针
  	loop .go_on_read  ; 使用了 loop 指令。这个指令会自动减少 CX 寄存器的值并检查它是否为零。
  	ret

bochs调试

1
2
3
4
5
6
7
8
9
10
11
12
# 显示地址处内容
<bochs:3> xp/2 0xffff0
[bochs]:
0x000ffff0 <bogus+       0>:	0x00e05bea	0x2f3131f0
# 反汇编地址
<bochs:4> u/1 0xffff0
000ffff0: (                    ): jmp far f000:e05b         ; ea5be000f0
# 看是什么中断
show int
show extint
show softint
show iret