Files
912-notes/thu_os/lab1_report.md

29 KiB
Raw Blame History

Lab 1 Report

实验目的

操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。为此我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader为启动操作系统ucore做准备。lab1提供了一个非常小的bootloader和ucore OS整个bootloader执行代码小于512个字节这样才能放到硬盘的主引导扇区中。通过分析和实现这个bootloader和ucore OS读者可以了解到

  • 计算机原理

    • CPU的编址与寻址: 基于分段机制的内存管理
    • CPU的中断机制
    • 外设:串口/并口/CGA时钟硬盘
    • Bootloader软件
  • 编译运行bootloader的过程

    • 调试bootloader的方法
    • PC启动bootloader的过程
    • ELF执行文件的格式和加载
    • 外设访问读硬盘在CGA上显示字符串
    • ucore OS软件
  • 编译运行ucore OS的过程

    • ucore OS的启动过程
    • 调试ucore OS的方法
    • 函数调用关系:在汇编级了解函数调用栈的结构和处理过程
    • 中断管理:与软件相关的中断处理
    • 外设管理:时钟

实验内容

lab1中包含一个bootloader和一个OS。这个bootloader可以切换到X86保护模式能够读磁盘并加载ELF执行文件格式并显示字符。而这lab1中的OS只是一个可以处理时钟中断和显示字符的幼儿园级别OS。

练习

为了实现lab1的目标lab1提供了6个基本练习和1个扩展练习要求完成实验报告。

对实验报告的要求:

  • 基于markdown格式来完成以文本方式为主。
  • 填写各个基本练习中要求完成的报告内容
  • 完成实验后请分析ucore_lab中提供的参考答案并请在实验报告中说明你的实现与参考答案的区别
  • 列出你认为本实验中重要的知识点以及与对应的OS原理中的知识点并简要说明你对二者的含义关系差异等方面的理解也可能出现实验中的知识点没有对应的原理知识点
  • 列出你认为OS原理中很重要但在实验中没有对应上的知识点

练习1理解通过make生成执行文件的过程。

列出本实验各练习中对应的OS原理的知识点并说明本实验中的实现部分如何对应和体现了原理中的基本概念和关键知识点。

在此练习中,大家需要通过静态分析代码来了解:

  • 操作系统镜像文件ucore.img是如何一步一步生成的(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义以及说明命令导致的结果)
  • 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

分析ucore.img的生成过程

通过make V=命令可以看到ucore.img生成过程中各个命令的执行次序。整个生成过程大致可以分为三个阶段现简述如下

  • step1 生成操作系统kernel文件

    • 首先是对于kernel的各个依赖项分别进行编译。这里只是举了几个例子实际上通过这种方式生成了许多内核需要用到的.o文件,如vectors.o, pmm.o, stdio.o, clock.o...
    cc kern/init/init.c
    gcc -Ikern/init/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  \-fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/init/init.c -o obj/kern/init/init.o
    cc kern/driver/console.c
    gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/console.c -o obj/kern/driver/console.o
    ......
    
    • 将编译生成的这些.o文件,利用链接指令ld,生成操作系统内核可执行文件kernel
    ld bin/kernel
    ld -m    elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel  obj/kern/init/init.o \
    obj/kern/libs/readline.o obj/kern/libs/stdio.o obj/kern/debug/kdebug.o \
    obj/kern/debug/kmonitor.o obj/kern/debug/panic.o obj/kern/driver/clock.o \
    obj/kern/driver/console.o obj/kern/driver/intr.o obj/kern/driver/picirq.o \
    obj/kern/trap/trap.o obj/kern/trap/trapentry.o obj/kern/trap/vectors.o \
    obj/kern/mm/pmm.o  obj/libs/printfmt.o obj/libs/string.o
    
  • step2 生成bootloader可执行文件bootblock

    • 同样,也是先对几个依赖项进行编译,包括bootasm.S, bootmain.c
    cc boot/bootasm.S
    gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
    cc boot/bootmain.c
    gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o
    
    • 将编译生成的bootasm.obootmain.o链接生成bootloader可执行文件。这里可以注意到在链接指令ld中,指定了该文件读到内存中的地址0x7C00即bootloader起始地址。并且该bootloader文件大小为512字节正好为一个磁盘扇区的大小这也与上课讲的理论知识相符合。
    ld bin/bootblock
    ld -m    elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o
    'obj/bootblock.out' size: 488 bytes
    build 512 bytes boot sector: 'bin/bootblock' success!
    

    但是这里有一个问题,就是bootblock.o是怎么生成bootclock.out以及bootclock并且为什么它们的大小从488B变到了512B。

    sign.c在其中起了作用将488B的的bootclock.out读到了512B的bin/bootclock(后面将继续讨论)

  • step 3 将上面两步生成的kernel 以及 bootblock 连接生成虚拟磁盘镜像文件ucore.img,使用dd命令(convert and copy a file)

    dd if=/dev/zero of=bin/ucore.img count=10000
    10000+0 records in
    10000+0 records out
    5120000 bytes (5.1 MB) copied, 0.0530962 s, 96.4 MB/s
    dd if=bin/bootblock of=bin/ucore.img conv=notrunc
    1+0 records in
    1+0 records out
    512 bytes (512 B) copied, 0.000104995 s, 4.9 MB/s
    dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
    146+1 records in
    146+1 records out
    74923 bytes (75 kB) copied, 0.000316792 s, 237 MB/s
    

可以看到,最终ucore.img由三部分组成:

  • 第一部分为/dev/zero,不清楚是什么文件,是一个character special file
  • 第二部分为bootloader
  • 第三部分为操作系统内核kernel

符合规范的硬盘主引导扇区的特征

关于此项,老师给的提示是查看tools/sign.c文件。其主要功能是读取一个文件(第一个参数)到内存中该文件大小小于510B然后在其尾部添加主引导记录的结束标志0x55AA,并将512B(文件本身有效部分可能没有512B)写回到磁盘上的目标文件中(第二个参数)。

由此可以总结出符合规范的硬盘主引导扇区的特征:

  • 文件大小为512B即一个硬盘扇区的大小。(当然也有可能其中有效文件根本没有这么大但是BIOS还是读入一整个磁盘扇区的512B)
  • 文件尾部为0x55AA。这里文件尾部是指文件的第511字节和第512字节而不管文件实际的有效大小有多大。

练习2使用qemu执行并调试lab1中的软件。

为了熟悉使用qemu和gdb进行的调试工作我们进行如下的小练习

  • 从CPU加电后执行的第一条指令开始单步跟踪BIOS的执行。
  • 在初始化位置0x7c00设置实地址断点,测试断点正常。
  • 0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.Sbootblock.asm进行比较。
  • 自己找一个bootloader或内核中的代码位置设置断点并进行测试。

跟踪BIOS运行

老师说在命令行运行make debug就可以跟踪BIOS的运行了然而实际操作的时候并不是这样。

$ make debug
The target architecture is assumed to be i8086
0x0000fff0 in ?? ()
Breakpoint 1 at 0x7d00: file boot/bootmain.c, line 88.

Breakpoint 1, bootmain () at boot/bootmain.c:88

可以看到程序并没有在BIOS第一条指令中断而是直接运行到了bootmain.cbootmain()。为了解决这个问题,可以查看Makefile里面关于debug命令的描述

debug: $(UCOREIMG)
        $(V)$(QEMU) -S -s -parallel stdio -hda $< -serial null &
        $(V)sleep 2
        $(V)$(TERMINAL)  -e "cgdb -q -x tools/gdbinit"

这里说运行gdb的时候是去读tools/gdbinit文件,因此去看看这个文件

1 file obj/bootblock.o
2 set architecture i8086
3 target remote :1234
4 break bootmain
5 continue

问题出现了,这里定义是在bootmain这个函数这里设置到断点,然后它居然就continue了,所以程序运行就直接来到了bootmain。解决方案也很简单,我选择使用make lab1-mon指令正常运行时断点在bootloader的第一条指令(0x7c00)处,我把它的continue给删掉就可以了。删掉之后tools/lab1init长这样:

1 set architecture i8086
2 target remote :1234

现在,运行make lab1-mon就可以跟踪BIOS运行了。

The target architecture is assumed to be i8086
0x0000fff0 in ?? ()
(gdb) x /2i $pc
=> 0xfff0:	add    %al,(%bx,%si)
   0xfff2:	add    %al,(%bx,%si)
(gdb) x $cs
   0xf000:	add    %al,(%bx,%si)
(gdb) 

在上面的指令中,我查看了当前的ip寄存器以及cs寄存器,从而可以得出当前运行的指令在0xffff0,这就是BIOS第一条指令存储的位置也是CPU上电后执行的第一条指令。现在去查看这条指令

(gdb) x /2i 0xffff0
   0xffff0:	ljmp   $0xf000,$0xe05b
   0xffff5:	xor    %dh,0x322f

这是一个长跳转指令,会跳转到0xfe05b这个位置,然后继续执行。

跟踪Bootloader的执行

在原理里面有讲到BIOS加载Bootloader到内存中的0x7c00位置并且跳转到这个位置执行。为了跟踪Bootloader执行可以在0x7c00处设置一个断点。

(gdb) b *0x7c00
Breakpoint 1 at 0x7c00

要注意的是这里设置断点要使用*标志符,表示是对内存中某个地址设置断点。

之后就可以查看Bootloader的汇编代码了

(gdb) c
Continuing.

Breakpoint 1, 0x00007c00 in ?? ()
(gdb) x /10i $pc
=> 0x7c00:	cli    
   0x7c01:	cld    
   0x7c02:	xor    %ax,%ax
   0x7c04:	mov    %ax,%ds
   0x7c06:	mov    %ax,%es
   0x7c08:	mov    %ax,%ss
   0x7c0a:	in     $0x64,%al
   0x7c0c:	test   $0x2,%al
   0x7c0e:	jne    0x7c0a
   0x7c10:	mov    $0xd1,%al

bootasm.S以及bootblock.asm比对的话,代码都是一样的。(当然是一样的,执行的就是它的代码...

练习3分析bootloader进入保护模式的过程

BIOS将通过读取硬盘主引导扇区到内存并转跳到对应内存中的位置执行bootloader。请分析bootloader是如何完成从实模式进入保护模式的。

提示需要阅读小节“保护模式和分段机制”和lab1/boot/bootasm.S源码了解如何从实模式切换到保护模式需要了解

  • 为何开启A20以及如何开启A20
  • 如何初始化GDT表
  • 如何使能和进入保护模式

bootloader进入保护模式的过程

其实在题干上面已经说清楚了Bootloader从实模式进入保护模式主要需要进行三方面的工作

  • 开启A20地址线。
  • 初始化段表(GDT表)。这是因为实模式和保护模式的寻址方式不同,在保护模式中使用段表来进行寻址,因此在进入保护模式之前应该首先设置好段表(GDT)以及段表寄存器(GDTR)。
  • 设置系统寄存器CR0以使能保护模式位进入保护模式。
  • 进入保护模式后寻址方式发生了改变。当前的CS:IP已经不能寻址到下一条本应执行的指令因此需要一条长跳转指令开始保护模式的运行。

下面将针对各条具体进行说明。

A20地址线

A20地址线有什么作用

A20地址线的控制简单说来就是可以使能A20地址线。在A20地址线禁止的情况下内存地址的第20位(对于32位地址是从0~31位)恒为零。这样系统就只能访问奇数兆的空间即0--1M, 2--3M...所以在保护模式下必须要打开A20地址线

为什么会出现A20地址线的控制

这是具有一定的历史原因是为了intel x86产品向下兼容而出现的。

说的是本来8086的芯片本来只有20位的地址线只能访问1M的内存。但是实际上在cs = 0xffff, ip = 0xffff时cs:ip = 0x10ffef。这就超过了1M的内存访问空间。8086的芯片在这种情况下会出现“回卷”即不会出现异常而是会从零地址0x0重新定位。

但是在后来的芯片中地址线的位数增加了比如这时有了24位地址线。但是为了向下兼容芯片提供了实模式使之也可以工作在8086的条件下。但是由于地址线多了寻址超过1M内存时系统不再会回卷了而是会实际去访问那部分不能访问的空间(想来应该是硬件电路设计的问题),这就造成了向下不兼容。

为了实现向下兼容的回卷IBM给整个系统加上了硬件逻辑这样就出现了A20 Gate。在A20 Gate禁止的情况下访问超过1M内存的空间时由于第20位地址始终为零就又出现了回卷。

如何控制A20地址线

这就完全是硬件方面的知识了。需要查看相关控制器的手册。这些控制器一般都是以外设的形式与CPU连接的所以主要是通过与外设通信的inout指令,来控制这些外设的功能。

对于A20地址线来说控制它的外设即8042键盘控制器(叫是叫这个名字,但是好像和键盘没有任何联系)

8042键盘控制器的IO端口是0x600x6f, 实际上使用的只有0x60和0x64两个端口。其中0x60端口是数据端口命令的参数以及返回值都是通过这个读写端口获得 0x64端口是命令端口命令是通过写这个端口来控制8042键盘控制器。除此以外8042还有3个内部端口Input Port、Outport Port和Test Port。它们的一些具体操作如下

  • 读Output Port向64h发送0d0h命令然后从60h读取Output Port的内容
  • 写Output Port向64h发送0d1h命令然后向60h写入Output Port的数据
  • 禁止键盘操作命令向64h发送0adh
  • 打开键盘操作命令向64h发送0aeh

A20 Gate被定义在Output Port的bit 1上。所以理论上讲我们只要操作8042芯片的输出端口64h的bit 1就可以控制A20 Gate但实际上当你准备向8042的输入缓冲区里写数据时可能里面还有其它数据没有处理所以我们要首先禁止键盘操作同时等待数据缓冲区中没有数据以后才能真正地去操作8042打开或者关闭A20 Gate。打开A20 Gate的具体步骤大致如下

  • 等待8042 Input buffer为空
  • 发送Write 8042 Output Port P2命令到8042 Input buffer
  • 等待8042 Input buffer为空
  • 将8042 Output PortP2得到字节的第2位置1然后写入8042 Input buffer

具体操作代码

查看bootasm.S可以找到使能A20地址线的代码

seta20.1:
    inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
    testb $0x2, %al
    jnz seta20.1

    movb $0xd1, %al                                 # 0xd1 -> port 0x64
    outb %al, $0x64                                 # 0xd1 means: write data to 8042's P2 port

seta20.2:
    inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
    testb $0x2, %al
    jnz seta20.2

    movb $0xdf, %al                                 # 0xdf -> port 0x60
    outb %al, $0x60                                 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1

其中需要做一些说明:

  • inb $0x64, %al指令读0x64端口。之前说0x64端口是命令端口但是读这个端口可以获得关于8042的一些状态信息因此可以判断该控制器是否就绪。如果没有就绪的话就持续读该端口直到控制器就绪。
  • movb $0xdf, %al;outb %al, $0x60直接将11011111b写到了8042控制器对于我来说感觉有点莽撞。或许先读Output Port然后再只修改A20位的值为1再写回到控制器是不是会更稳妥一点

初始化段表

上面提到了为什么要首先初始化段表?这是由于保护模式和实模式的寻址方式不同,下面将具体讨论。

保护模式和实模式的寻址方式有什么不同?

实模式就比较简单直接cs:ip然后cs左移4为就完事儿了非常简单。

保护模式就不一样了因为地址空间已经变成了32位而段寄存器的长度还是16位直接用段寄存器的值来索引内存的某一个段感觉有点寒碜啊。这时候段寄存器的值已经不是某一个地址了而是用来索引段表中某一项的段选择子(segment selector)

我们知道段表的结构是一个线性表里面每一项包括8字节其中前32位指令该段在内存中的起始地址。段表中至多可以有8192(2^13)项也就是允许同时存在8192个段。此时CS寄存器中的值只是对段表这个线性表的一个索引表示当前选择的是段表中的第几个段。

由于至多有8192个段因此可以只用CS中的高13位就足以指定段表中的任意一个段。还有低3位是用来表示一些控制信息

  • 第2位表指示位(TI, Table Indicator)用来指定应该访问哪一个描述符表。0表示全局描述符表(GDT), 1表示局部描述符表(LGT)。
  • 第0--1位请求特权级(RPL, Requested Privilege Level),制定当前段的优先级,是用于保护机制的。

如何找到段表的位置?

段表作为一个特殊的数据结构,本身并不属于任意一个段,而是相当于一个独立的段。通过全局描述符表寄存器(GDTR)来标志其位置。GDTR是一个48位寄存器其中前32位标志了段表的位置后16位为limit指示了段表的大小(段表的最后一个字节),其值通常是$8·N-1$其中N为段表中的段描述符的数量。8是因为段表中一个段描述符为8个字节64bits。

通过上面的讨论,现在可以总结出保护模式下内存访问的步骤:

  • 读取CS寄存器获得要访问的段描述符在段表中的偏移量
  • 读取GDTR寄存器验证访问的偏移没有越界并得到段表在内存中的地址
  • 找到段表,并访问目标段描述符,获得目标段的内存地址。
  • 将段描述符中的段大小(20位)与IP寄存器中的偏移量进行比较确保没有越界
  • 一些访问权限的检验
  • 将段的内存地址与IP中的偏移量相加获得要访问的物理内存地址。

初始化段表的代码

在bootasm.S中可以找到初始化段表的代码

lgdt gdtdesc

gdt:
    SEG_NULLASM                                     # null seg
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel

gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt

其中,SEG_NULLASM以及SET_ASM在asm.h中定义

#define SEG_NULLASM                                             \
    .word 0, 0;                                                 \
    .byte 0, 0, 0, 0

#define SEG_ASM(type,base,lim)                                  \
    .word (((lim) >> 12) & 0xffff), ((base) & 0xffff);          \
    .byte (((base) >> 16) & 0xff), (0x90 | (type)),             \
        (0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)


/* Application segment type bits */
#define STA_X       0x8     // Executable segment
#define STA_E       0x4     // Expand down (non-executable segments)
#define STA_C       0x4     // Conforming code segment (executable only)
#define STA_W       0x2     // Writeable (non-executable segments)
#define STA_R       0x2     // Readable (executable segments)
#define STA_A       0x1     // Accessed

在上面的代码中,

  • gdt段定义了段表中的三个段描述符,即空描述符(第一个总是空描述符) 代码段描述符数据段描述符。这里注意到代码段和数据段是重合的都是从0地址开始。然后也和当前CS寄存器的值相同确保进入保护模式后程序可以正常跳转。
  • gdtdesc段定义了GDTR寄存器中应该有的值其高32位是段表的起始地址(gdt)低16位为段表的limit(之前我以为它错了怎么可能是17然后我刚刚发现它是16进制的所以应该是23即3·8-1)
  • ldgt gdtdescgdtdesc的值加载到GDTR中这样段表就初始化完毕了。

设置系统寄存器CR0

系统寄存器CR0的第0位PE(Protection Enable)用于使能保护模式。设置为1时就可以开启保护模式。这样就是简单的寄存器操作了。

.set CR0_PE_ON,		0x1 		# protected mode enable flag

movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0

太简单了,就不多说了。

执行长跳转指令

为什么需要长跳转指令?

改变寻址方式后使用之前的CS和IP已经不能正确访问到下一条要执行的指令了。就比如说当前cs = 0X0, 使用这个段选择子只能选择到段表中的第一项,即SEG_NULLASM,而这时一个无效项。要正确访问到段表中的代码段描述符,应该将cs设置为段表中第一项的偏移量即0x0008,这是因为段选择子的低三位为控制位第二位为0表示访问GDT,低两位为零表示最高特权级。

但是存在一个问题,cs寄存器是不能直接设置的。它只能通过程序跳转指令,如CALL, RET, INT, LJMP指令来改变。因此这里需要一个长跳转指令来改变cs寄存器中的值使之可以正确访问下一条要执行的指令。

.set PROT_MODE_CSEG,        0x8                     # kernel code segment selector
.set PROT_MODE_DSEG,        0x10                    # kernel data segment selector
.set CR0_PE_ON,             0x1                     # protected mode enable flag

.code16
	......

	# Jump to next instruction, but in 32-bit code segment.
	# Switches processor into 32-bit mode.
	ljmp $PROT_MODE_CSEG, $protcseg

.code32                                             # Assemble for 32-bit mode
protcseg:
    # Set up the protected-mode data segment registers

其中,$PROT_MODE_CSEG就是段表中代码段的偏移即0x8, $protcseg则为下一条指令的偏移地址。这样Bootloader才算完全实现了从实模式进入到保护模式。

练习4分析bootloader加载ELF格式的OS的过程。

通过阅读bootmain.c了解bootloader如何加载ELF文件。通过分析源代码和通过qemu来运行并调试bootloader&OS

  • bootloader如何读取硬盘扇区的
  • bootloader是如何加载ELF格式的OS

提示可阅读“硬盘访问概述”“ELF执行文件格式概述”这两小节。

Bootloader读取硬盘扇区

Bootloader的主要功能就是要读取硬盘扇区上的操作系统把它加载到内存中然后把控制权转交给操作系统。因此这里就涉及到程序要直接读取硬盘扇区。

我们知道硬盘设备是作为外设与CPU连接的因此读取硬盘扇区本质上也是通过I/O操作发送命令并且读取数据以将硬盘上的数据读取到内存中这与上面A20的开启本质上是一样的。

硬盘扇区的硬件格式

一般主板有2个IDE(Integrated Drive Electronics)通道每个通道可以接2个IDE硬盘。访问第一个硬盘的扇区可设置IO地址寄存器0x1f0-0x1f7来实现。

读写硬盘根据寻址空间大小的不同,可以分为多个模式,如

  • chs方式 小于8G (8064MB)
  • LBA28方式小于137GB
  • LBA48方式小于144,000,000 GB

其中LBA28模式表示28位的逻辑区块地址(Logical Block Address)每一个扇区为512字节这样28位代表了256M个扇区所以可以存储128GB的数据。我们这里就是使用LBA28的方式来读取硬盘。

LBA28模式具有多个寄存器

  • data寄存器位于端口号0x1F0。可以用来读取或写入数据
  • features寄存器位于端口号0x1F1。用于读取时的错误信息写入时的额外参数
  • sector count寄存器位于0x1F2。用于指定读取或写入的扇区数
  • LBA low寄存器位于0x1F3。用于指定lba地址的低8位
  • LBA mid寄存器位于0x1F4。用于指定lba地址的中8位
  • LBA high寄存器 位于0x1F5。用于指定lba地址的高8位
  • device寄存器位于端口号0x1F6。用于指定lba地址的前4位占用device寄存器的低4位主盘值为0占用device寄存器的第5位第6位值为1第7位LBA模式为1CHS模式为0第8位值为1
  • command寄存器位于端口号啊0x1F7。用于读取写入的命令返回磁盘状态。其中0x20命令表示读取扇区0x30命令表示写入扇区

读取硬盘扇区的方法

简单说来,可以分为下面几步

  • 等待磁盘准备好

读取command寄存器判断硬盘状态是否就绪

/* waitdisk - wait for disk ready */
static void
waitdisk(void) {
    while ((inb(0x1F7) & 0xC0) != 0x40)
        /* do nothing */;
}
  • 发出读取扇区的命令

将要读取的硬盘扇区的信息先写入前面那些寄存器然后通过command寄存器发出读取的命令。

    outb(0x1F2, 1);                         // count = 1
    outb(0x1F3, secno & 0xFF);
    outb(0x1F4, (secno >> 8) & 0xFF);
    outb(0x1F5, (secno >> 16) & 0xFF);
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
    outb(0x1F7, 0x20);                      // cmd 0x20 - read sectors
}
  • 再次等待磁盘准备好

再次调用waitdist函数。

  • 把磁盘扇区数据读到指定内存

从data寄存器读取请求的数据。

    // read a sector
    insl(0x1F0, dst, SECTSIZE / 4);

要注意的是这里的insl(0x1F0, dst, SECTSIZE / 4),为什么SECSIZE要除以4呢这是因为insl指令是读入一个长字符(l for long)序列,读入的单位是4B第三个参数是制定要读入的4字节的数量所以应该除以4以保证读入512字节即一个扇区的大小。

上面给出的程序是读取一个硬盘扇区的函数通过恰当的封装可以实现通过从0开始的字节数来读取恰当硬盘扇区的函数(bootmain.c中正是这样做的)。但由于其基本原理还是那样,在这里不多做叙述。

Bootloader加载ELF格式的OS

在上面的讨论中已经可以做到将OS读入到内存当中之后还剩下的问题是Bootloader需要将控制权转交给操作系统为此就需要知道OS应该从哪条指令执行然后跳转到那条指令。简单说来就是要能解析OS的文件格式而这里是elf格式。

什么是elf格式?

elf其实就是精灵。即Executable and Linkable Format为可执行和可链接格式。如它的名字所指示的elf格式文件包括三种主要类型

  • 可执行文件(executable file)。显然我们这里的OS就是可执行文件。
  • 可重定位文件(relocatable file)
  • 共享目标文件(shared object file)

这里我们只分析可执行文件类型。

elf可执行文件由几个部分组成

  • ELF头部。ELF header在文件开始处描述了整个文件的组织。比如文件的适用机器文件的大小版本程序头的入口以及程序头的数量还有整个可执行文件的入口地址。
struct elfhdr {
  uint magic;  // must equal ELF_MAGIC
  uchar elf[12];
  ushort type;
  ushort machine;
  uint version;
  uint entry;  // 程序入口的虚拟地址
  uint phoff;  // program header 表的位置偏移
  uint shoff;
  uint flags;
  ushort ehsize;
  ushort phentsize;
  ushort phnum; //program header表中的入口数目
  ushort shentsize;
  ushort shnum;
  ushort shstrndx;
};

其中通过判断magic字段可以确定这是否为一个合法的ELF文件。若是合法的ELF文件则可以通过程序头的偏移量(phoff)以及程序头的数量(phnum),来读到程序头,从而可以获得程序运行的更加具体的信息。

  • 程序头表。描述与程序执行直接相关的目标文件结构信息,用来在文件中定位各个段的映像,同时包含其他一些用来为程序创建进程映像所必需的信息。
struct proghdr {
  uint type;   // 段类型
  uint offset;  // 段相对文件头的偏移值
  uint va;     // 段的第一个字节将被放到内存中的虚拟地址
  uint pa;
  uint filesz;
  uint memsz;  // 段在内存映像中占用的字节数
  uint flags;
  uint align;
};

可以看到,其中描述了程序段相对于文件头开始位置offset,以及被加载到内存当中的地址va。利用这些信息可以把相应的程序加载到指定的位置从而使得后续控制权可以正常地转交给os。

  • 节头表

加载elf格式的os并且转交控制权的过程

可以查看bootmain.c的代码。可以看到Bootloader加载elf格式的os可以分为几个步骤

  • 将os从硬盘读入到内存中,并判断是否合法
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
if (ELFHDR->e_magic != ELF_MAGIC) {
	goto bad;
}
  • 通过elf文件头获得该os的基本信息如程序头的入口。并且将可执行代码从程序头标志的位置读入到内存中被程序头指定的地址。
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph ++) {
    readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
  • 跳转到elf文件标志的起始地址开始执行os的指令。至此CPU的控制权转交给了os。
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();