Misaka.r.Me

计算机是如何启动的


这篇博客以BootLoader代码为例, 具体介绍了计算机的启动流程. 旧博客(纯文字描述)见https://github.com/misakar/misakar.github.io/issues/8


开机!

想必阅读这篇博客的同学都打开过计算机:-), 按下主机上的电源键, 我们便可进入~~Windows或者~~(删除语法还没支持,那就手动删除!)Linux桌面. 但是这个过程究竟是怎样的? 从计算机启动到加载操作系统究竟执行了哪些代码? 做了哪些工作? 这篇博客将以一个实际的OS代码为例, 介绍计算机的启动过程.

BootLoader

BootLoader分为两大过程, BootLoader, Boot负责计算机启动初始化(BIOS)和加载MBR到内存, Loader则负责加载操作系统内核.
这篇博客会分解这两个核心流程, 说明每个部分需要解决的问题并给出相应代码.

先有鸡还是先有蛋?

究竟是先有计算机还是先有程序? 计算机的启动需要执行程序、程序的运行离不开计算机环境, 这是一个先有鸡还是先有蛋的问题, 所以计算机的启动过程也被形象的称为bootstrap--用鞋带把自己提起来.
程序需要加载到内存中执行, 内存分为两大类: ROMRAM, RAM内存是我们通常执行程序的地方, 一旦关机, 内存中的数据就会被擦除; 而ROM内存是Read Only Memory, 断电后数据会被保留. 考虑到BIOS程序是固定的流程, 可以固化到主板上的ROM芯片上, 这样每次计算机开机时就可以读取BIOS程序, 执行Boot流程.
如此, 便解决了先有鸡还是先有蛋的问题.

开始的开始, 我们都是~寄存器

BIOS程序被固化到ROM芯片上, 计算机可以通过地址总线寻址并执行BIOS程序, BIOS程序所在的地址会被初始化到CS:IP寄存器. 查阅IA-32 Intel Architecture Software Developer ’s Manual:

IA-32-Table-9

可以看到CS(代码段寄存器)的基址Base为0xFFFF0000, EIP(指令指针寄存器)的初值为0x0000FFF0, CPU一开始运行在实模式下: CS_Base + EIP = 0xFFFFFFF0, 这个地址就是BIOS所在ROM的地址.

BIOS

计算机接通电源后, 初始化各寄存器, 读取固化到ROM芯片上的BIOS(Basic Input Output System)程序。首先计算机会进行硬件带电自检(POST: Power On Self Test), 如果硬件出现问题, 启动终止, 主板发出蜂鸣; 如果一切正常, 屏幕上会显示CPU,内存信息。 初始化和检测完成后, BIOS需要把控制权交给下一阶段启动程序, 这可以在BIOS启动顺序列表中设置, 比如通过U盘安装ubuntu时设置先从U盘启动。
下面启动bochs虚拟机来演示BIOS阶段:

bochs-start

看到最后一行, 计算机启动后访问的第一个内存地址确实是0x0000FFFFFFF0, 不过有意思的是这段地址并不是直接指向BIOS程序, 而是执行了一个跳转指令: jmpf 0xf000:e05b, 所以根据实际实验结果修正上段的结论: 0xFFFFFFF0只是BIOS程序的入口地址, BIOS程序所在的ROM地址是0xfe05b.

实模式

鉴于出现了地址计算, 这里暂时离开启动流程, 简单的介绍一下CPU实模式下的内存寻址.
计算机通电后, CPU处于实模式运行, 内存地址=段基址:段内偏移地址, 即内存地址=代码段寄存器(CS)的值左移4位+指令指针寄存器(IP)的值. 在不跨段的情况下, CPU以当前IP值+当前指令的机器码长度更新IP寄存器, 再到新的IP值处读取指令并执行; 如果下一条指令需要跨段访问, 则加载新的段基址到CS寄存器. 如此往复, CPU通过CS:IP实现取址、执行的循环.
所以jmpf 0xf000:e05b跳转到的地址就是0xf000 << 4 + 0xe05b = 0xf0000 + 0xe05b = 0xfe05b.
CPU在实模式下地址总线20位, 最多可访问:2^20Byte = 1MB内存.

MBR

如果继续运行bochs, 会发生报错: [BIOS ] No bootable device bootable 要想知道报错的原因, 就需要知道BIOS是如何加载可启动设备的(bootable device, 如硬盘、U盘等).
BIOS会检查配置顺序中的第一个启动设备上的第一个扇区, 这个扇区也被称为主引导扇区, 主引导扇区上的512字节程序就是Master Boot Record(MBR). 因为此时bochs的虚拟硬盘(默认配置bochs从硬盘启动)还没有写入MBR程序, 所以这里就报错了.
BIOS会检查0盘0道1扇区最后两个字节是不是0xaa55(魔数), 若是, 证明是有效的MBR程序, 便将MBR加载到内存的0x7c00.
下面来具体分析MBR的作用:

;---------------------------------------------------------------------------
; UIHARU::MBR E bootloader (512字节)
;---------------------------------------------------------------------------
%include "boot.inc"
SECTION MBR vstart=0x7c00 ; bios把mbr加载到0x7c00处~31KB

首先MBR要给各个寄存器合适的值

mov ax, cs     ; code segment: cs寄存器CPU启动的时候就初始化过了0xf000;
mov ds, ax     ; generally points at segment where variables are defined;
mov es, ax     ; extra segment register, it's up to a coder to define its usage;
mov fs, ax     ; fs,gs are commonly used by OS kernels to access thread-specific memory;
mov ss, ax     ; stack segment register: 把整个实模式下0~0x7c00内存看作一个大栈;
mov sp, 0x7c00 ; 栈指针寄存器指向0x7c00;
mov ax, 0xb800 ; 显卡寄存器地址0xb8000, 实模式寻址会左移4位, 所以值为0xb800;
mov gs, ax     ; gs显卡基址, 通过gs可以操作显卡;

因为bochs输出了大量信息, 所以我们要先清屏. 清屏操作是利用0x10中断实现的.

;---------------------------------------------------------------------------
; 清屏操作
; INT 0x10  功能号: 0x06  功能描述: 上卷窗口
;---------------------------------------------------------------------------
mov ax, 0600h  ; bios 0x10中断第0x600号功能
mov cx, 0      ; 左上角: (0, 0)
mov dx, 0x184f ; VGA[文本模式]中, 一行容纳80个字符, 共25行
               ; 下标从0开始, 0x18=24, 0x4f=79
               ; 由于x86CPU是[小端模式] -> 0x184f
               ; 左上->右下: clear screen
int 0x10       ; INT 0x10

接下来我希望输出文字表示MBR已被加载, 输出功能可以通过直接把要输出的字节放到显存上实现, 显卡基址已被放到了gs寄存器中:

;---------------------------------------------------------------------------
; 操作显卡输出字符
; PRINT BG-COLOR GREEN, FR-COLOR RED, FLASH STRING "misaka"
;---------------------------------------------------------------------------
mov byte [gs:0x00], 'm'
mov byte [gs:0x01], 0xA4

mov byte [gs:0x02], 'i'
mov byte [gs:0x03], 0xA4

mov byte [gs:0x04], 's'
mov byte [gs:0x05], 0xA4

mov byte [gs:0x06], 'a'
mov byte [gs:0x07], 0xA4

mov byte [gs:0x08], 'k'
mov byte [gs:0x09], 0xA4

最后别忘了写入魔数哦!

times (512-2)-($-$$) db 0 ; 填充0, 保证第一个扇区510字节
db 0x55, 0xaa       ; db:(define byte), 最后2位0xaa55

OK! 让我们把上述MBR程序写入硬盘

# 编译汇编程序
$ nasm -I include/ -o mbr.bin mbr.S
$ dd if=./mbr.bin of=/path/to/bochs/hd60MB.img bs=512 count=1 conv=notrunc

然后启动bochs:

bochs-mbr

MBR程序被成功加载! 不过目前我们的Boot程序也就悬停在这了.
MBR被称为主引导记录, 可见它的关键作用在于引导操作系统内核进入内存. 将内核加载到内存的功能由Loader程序完成, MBR所要做的就是在硬盘上找到Loader程序并把Loader程序放入内存, 以便启动加载OS内核的程序. 完成这部分功能就需要读硬盘:

;---------------------------------------------------------------------------
; 函数: rd_disk_m_16
; -> 功能: 读取硬盘的n个扇区
; --> eax: 加载器的LBA扇区号
; --> bx:  将数据写入的内存地址
; --> cx:  读入的扇区数
;---------------------------------------------------------------------------
rd_disk_m_16:
    mov esi, eax    ; 备份eax
    mov di, cx      ; 备份cx
; 读写硬盘:
; step1->设置要读取的扇区数
    mov dx, 0x1f2   ; 0x1f2: primary通道, sector count寄存器
    mov al, cl      ; cl: the least significant 8 bits of the cx
    out dx, al      ; 读取的扇区数

    mov eax, esi    ; 恢复eax
; step2->set lba address
    ;lbalow 7~0
    mov dx, 0x1f3
    out dx, al      ; 写入lbalow寄存器

    ;lbamid 15-8
    mov cl, 8
    shr eax, cl     ; 扇区起始地址shr(右移8位)
    mov dx, 0x1f4
    out dx, al

    ;lbahigh 23-16
    shr eax, cl
    mov dx, 0x1f5
    out dx, al
; step3->set device register
    ;lba device
    shr eax, cl
    or al, 0xe0     ; lba(23~27)写入device低4位, 高4位1111, 设置主盘、lab寻址
    mov dx, 0x1f6   ; device寄存器的低4位
    out dx, al

    mov dx, 0x1f7   ; 初始化状态寄存器~
    mov al, 0x20    ; 0010 0000~ DRDY: 设备就绪等待指令
    out dx, al      ; 写操作时状态寄存器"变成"command寄存器, 硬盘开始工作
; step4->read data
; 检测硬盘状态: BSY位
  .not_ready:
    nop             ; 空操作, 类似sleep
    in al, dx       ; 读取状态寄存器(无需重新初始化dx寄存器)
    and al, 0x88    ; 10001000 检测BSY和DRQ
    cmp al, 0x08    ; 00001000 检测BSY位
    jnz .not_ready  ; 判断结果是否为0, 得出此时的硬盘工作状态
                    ; jnz判断eflags寄存器的ZF位,如果结果为不为0,执行.not_ready
                    ; 如果结果为0, 说明BSY位为0, 硬盘当前空闲, 执行下面的读操作

; 从0x1f0端口(primary通道的data寄存器)读数据
    mov ax, di      ; di<-cx<-待读入的扇区数
    mov dx, 256     ; (512)/2<-一次in读取2个字节: 每个扇区in操作的次数
    mul dx          ; 乘法操作, dx是操作数, 另一个操作数隐含在ax或al寄存器中
                    ; -> [待读入的扇区数*每个扇区in操作的次数] = 4*256 = 1024
                    ; -> 16位乘法32位乘积的低16位在ax寄存器, 高16位在dx寄存器
    mov cx, ax      ; cx作为loop循环计数
                    ; -> ax: 1024, 1024次循环,每次2个字节,正好2048->4个扇区,能够完全读取loader

    mov dx, 0x1f0   ; 访问0x1f0寄存器读取数据

  .go_on_read:
    in ax, dx       ; 从Primary通道主盘读数据
    mov [bx], ax    ; 把加载器写入内存: bx存放加载器所在的内存地址: 0x900
    add bx, 2       ; 移动2个字节
    loop .go_on_read; 循环
    ret             ; 函数返回, call函数的时候会将返回地址压入栈中

考虑这篇文章主要介绍的是计算机的启动流程, 所以关于如何读写硬盘我就不在这里做介绍了, 想了解的同学可以参考用汇编读写硬盘.
最后调用rd_disk_m_16函数, 将Loader程序加载到指定地址.

mov eax, LOADER_START_SECTOR    ; loader在硬盘上的LBA地址: 0x2,3块扇区
mov bx, LOADER_BASE_ADDR        ; loader在内存中的位置: 0x900
mov cx, 4                       ; 待读入的扇区数(2048byte=2KB=4*512byte <- loader)
call rd_disk_m_16               ; call调用函数: 从磁盘中读加载器到内存

; jmp LOADER_BASE_ADDR          ; 跳转访问加载器()
jmp LOADER_BASE_ADDR + 0x300    ; 使用绝对内存大小跳转
; LOADER_BASE_ADDR + 0x300 是loader_start的地址

为什么不把MBR集成到BIOS中?

或许有同学会问: 既然实际加载OS内核是Loader干的事, 那么为什么不把MBR的功能放到BIOS程序中实现呢?
如果MBR只需引导一个系统, 那么写入BIOS也是可以的. 但是现实情况是双系统甚至多个系统需要被引导, 而BIOS是固化在ROM上的, 想在BIOS中列举所有系统是不可能的. 所以便通过每个设备第一个扇区上的MBR来引导各个盘上的操作系统.
这篇文章只考虑单操作系统加载.

中断

全局描述符表

保护模式

分页机制

Loader

MBR从硬盘特定位置读取loader程序. loader程序主要负责:

  1. 构建gdt及其内部描述符
  2. 利用bios 0x15中断获取物理内存布局
  3. 开启保护模式
  4. 开启分页机制
  5. 加载内核

内核