x86 汇编/引导加载程序
当计算机打开时,会发出一些哔哔声,闪烁一些灯光,然后出现一个加载屏幕。然后,操作系统神奇地加载到内存中。随之而来的问题是,操作系统是如何加载的?是什么启动了它?答案是 引导加载程序。
引导加载程序是小型软件片段,在计算机开机时发挥作用,将操作系统加载并准备好执行。这种过程在不同的计算机设计之间有所不同(早期计算机需要用户在每次开机时手动设置),并且在引导加载过程中通常会存在多个阶段。
重要的是要理解,“引导加载程序”只是一个软件分类(而且有时是一个模糊的分类)。对处理器来说,引导加载程序只是它盲目执行的另一段代码。存在许多不同类型的引导加载程序。有些很小,有些很大;有些遵循非常简单的规则,而另一些则显示漂亮的屏幕并提供用户选择。
在 IBM PC 兼容机上,第一个加载的程序是基本输入输出系统 (BIOS)。BIOS 执行许多测试和初始化,如果一切正常,BIOS 的引导加载程序就开始运行。它的目的是加载另一个引导加载程序!它从一个磁盘(或其他存储介质)中选择一个磁盘,并从该磁盘中加载一个二级引导加载程序。
在某些情况下,这个引导加载程序会加载足够的操作系统以开始运行它。在其他情况下,它会从其他地方加载另一个引导加载程序。这通常发生在同一台计算机上安装了多个操作系统的情况下;每个操作系统可能都有自己特定的引导加载程序,以及一个“中心”引导加载程序,根据用户的选择加载其中一个特定的引导加载程序。
大多数引导加载程序都是专门用汇编语言(甚至机器代码)编写的,因为它们需要紧凑,它们没有访问其他语言可能需要的操作系统例程(例如内存分配),它们需要遵循一些特殊的规则,并且它们频繁地使用底层功能。但是,一些引导加载程序,特别是那些具有许多功能并允许用户输入的引导加载程序,相当重量级。这些通常是用汇编语言和 C 语言的组合编写的。例如,GRand Unified Bootloader (GRUB) 就是一个例子。
一些引导加载程序高度特定于操作系统,而另一些则不那么特定 - 当然,BIOS 引导加载程序不特定于操作系统。MS-DOS 引导加载程序(它被放置在所有 MS-DOS 格式化的软盘上)只检查文件 IO.SYS 和 MSDOS.SYS 是否存在;如果不存在,它会显示错误消息“非系统磁盘或磁盘错误”,否则它会加载并开始执行 IO.SYS。
最终阶段的引导加载程序可能需要(由操作系统)以某种方式准备计算机,例如,将处理器置于保护模式并将中断控制器编程。虽然可以在操作系统的初始化过程中执行这些操作,但将它们移到引导加载程序中可以简化操作系统设计。一些操作系统要求它们的引导加载程序设置一个小的基本 GDT (全局描述符表) 并进入保护模式,以消除操作系统需要任何 16 位代码的需要。但是,操作系统可能很快用自己的复杂 GDT 替换它。
磁盘的前 512 字节称为 引导扇区 或 主引导记录。引导扇区是磁盘上为引导目的保留的区域。如果磁盘的引导扇区包含有效的引导扇区(扇区的最后一个字必须包含签名 0xAA55),则 BIOS 将磁盘视为可引导的。
当打开或重置时,x86 处理器开始执行它在地址 FFFF:0000 处找到的指令(在此阶段它正在 实模式 下运行)(Intel 软件开发人员手册卷 3 第 9 章与该信息相矛盾:执行从物理地址 0xFFFFFFF0 开始,等等)。在 IBM PC 兼容处理器中,该地址映射到包含计算机基本输入输出系统 (BIOS) 代码的 ROM 芯片。BIOS 负责许多测试和初始化;例如,BIOS 可能会执行内存测试,初始化中断控制器和系统计时器,并测试这些设备是否正常工作。
最终,实际的引导加载开始。首先,BIOS 搜索并初始化可用的存储介质(例如软盘驱动器、硬盘驱动器、CD 驱动器),然后它决定将尝试从哪个介质引导。它检查每个设备的可用性(例如,确保软盘驱动器包含一个磁盘),然后检查 0xAA55 签名,以某种预定义的顺序(通常,顺序可以使用 BIOS 设置工具配置)。它将第一个可引导设备的第一个扇区加载到 RAM 中,并启动执行。
理想情况下,这将是另一个引导加载程序,它将继续工作,进行一些准备,然后将控制权传递给其他内容。
虽然 BIOS 保持与 20 年前的软件兼容,但它们也随着时间的推移变得越来越复杂。早期的 BIOS 无法从 CD 驱动器引导,但现在 CD 甚至 DVD 引导都是标准的 BIOS 功能。从 USB 存储设备引导也是可能的,一些系统可以从网络引导。为了实现这种高级功能,BIOS 有时会进入保护模式等,但随后会返回到实模式,以便与旧版引导加载程序兼容。这造成了一个先有鸡还是先有蛋的问题:引导加载程序被编写为与无处不在的 BIOS 协同工作,而 BIOS 被编写为支持所有这些引导加载程序,从而阻止了太多新的引导加载功能。
但是,一种新的引导技术,UEFI,正在开始获得发展势头。它更复杂,本文不会对此进行讨论。
还要注意,其他计算机系统 - 即使是使用 x86 处理器的系统 - 也可能以不同的方式引导。事实上,一些嵌入式系统,其软件足够紧凑以至于可以存储在 ROM 芯片上,可能根本不需要引导加载程序。
引导加载程序在某些条件下运行,程序员必须了解这些条件才能创建一个成功的引导加载程序。以下内容适用于由 PC BIOS 启动的引导加载程序。
- 驱动器的第一个扇区包含它的引导加载程序。
- 一个扇区是 512 字节 - 其中必须是 0xAA55(即 0x55 后跟 0xAA),否则 BIOS 将驱动器视为不可引导的。
- 如果一切正常,第一个扇区将被放置在 RAM 地址 0000:7C00,而 BIOS 的作用就结束了,因为它将控制权转移到 0000:7C00(也就是说,它跳转到该地址)。
- DL 寄存器将包含正在从其引导的驱动器号,如果您想从驱动器上的其他位置读取更多数据,这很有用。
- BIOS 会留下大量代码,这些代码用于处理硬件中断(例如按键)并为引导加载程序和操作系统提供服务(例如键盘输入、磁盘读取和写入屏幕)。您必须了解中断向量表 (IVT) 的目的,并注意不要干扰您所依赖的 BIOS 部分。大多数操作系统会用自己的代码替换 BIOS 代码,但引导加载程序只能使用自己的代码和 BIOS 提供的代码。有用的 BIOS 服务包括
int 10h
(用于显示文本/图形)、int 13h
(磁盘功能)和int 16h
(键盘输入)。 - 这意味着引导加载程序需要的任何代码或数据都必须包含在第一个扇区中(注意不要意外地执行数据)或手动从磁盘的另一个扇区加载到 RAM 中的某个位置。因为操作系统尚未运行,所以大多数 RAM 将处于未使用状态。但是,您必须注意不要干扰上述 BIOS 中断处理程序和服务所需的 RAM。
- 操作系统代码本身(或下一个引导加载程序)也需要加载到 RAM 中。
- BIOS 将堆栈指针放在引导扇区末尾的 512 字节之外,这意味着堆栈不能超过 512 字节。可能需要将堆栈移动到更大的区域。
- 如果要使磁盘在主流操作系统下可读,需要遵循一些约定。例如,您可能希望在软盘上包含一个 BIOS 参数块,以使磁盘在大多数 PC 操作系统下可读。
大多数汇编程序将有一个类似于 ORG 7C00h
的命令或指令,用于通知汇编程序代码将从偏移量 7C00h 开始加载。汇编程序将在计算指令和数据地址时考虑到这一点。如果您省略了这一点,汇编程序会假设代码从地址 0 加载,并且必须在代码中手动进行补偿。
通常,引导加载程序会将内核加载到内存中,然后跳转到内核。 内核将能够回收引导加载程序使用的内存(因为它已经完成了其工作)。 但是,可以在引导扇区中包含操作系统代码,并在操作系统启动后将其保留在内存中。
这是一个为 NASM 设计的简单引导加载程序演示
org 7C00h
jmp short Start ;Jump over the data (the 'short' keyword makes the jmp instruction smaller)
Msg: db "Hello World! "
EndMsg:
Start: mov bx, 000Fh ;Page 0, colour attribute 15 (white) for the int 10 calls below
mov cx, 1 ;We will want to write 1 character
xor dx, dx ;Start at top left corner
mov ds, dx ;Ensure ds = 0 (to let us load the message)
cld ;Ensure direction flag is cleared (for LODSB)
Print: mov si, Msg ;Loads the address of the first byte of the message, 7C02h in this case
;PC BIOS Interrupt 10 Subfunction 2 - Set cursor position
;AH = 2
Char: mov ah, 2 ;BH = page, DH = row, DL = column
int 10h
lodsb ;Load a byte of the message into AL.
;Remember that DS is 0 and SI holds the
;offset of one of the bytes of the message.
;PC BIOS Interrupt 10 Subfunction 9 - Write character and colour
;AH = 9
mov ah, 9 ;BH = page, AL = character, BL = attribute, CX = character count
int 10h
inc dl ;Advance cursor
cmp dl, 80 ;Wrap around edge of screen if necessary
jne Skip
xor dl, dl
inc dh
cmp dh, 25 ;Wrap around bottom of screen if necessary
jne Skip
xor dh, dh
Skip: cmp si, EndMsg ;If we're not at end of message,
jne Char ;continue loading characters
jmp Print ;otherwise restart from the beginning of the message
times 0200h - 2 - ($ - $$) db 0 ;Zerofill up to 510 bytes
dw 0AA55h ;Boot Sector signature
;OPTIONAL:
;To zerofill up to the size of a standard 1.44MB, 3.5" floppy disk
;times 1474560 - ($ - $$) db 0
要编译上面的文件,假设它被称为'floppy.asm',你可以使用以下命令
nasm -f bin -o floppy.img floppy.asm
虽然严格来说这不是一个引导加载程序,但它是可启动的,并且展示了一些内容
- 如何在引导扇区中包含和访问数据
- 如何跳过包含的数据(这对于 BIOS 参数块是必需的)
- 如何在扇区末尾放置 0xAA55 签名(如果代码太多无法容纳在一个扇区中,NASM 会发出错误)
- BIOS 中断的使用
在 Linux 上,你可以发出类似的命令
cat floppy.img > /dev/fd0
将映像写入软盘(映像可能小于磁盘大小,在这种情况下,只有映像中的信息会被写入磁盘)。 另一种更复杂的选择是使用 dd 实用程序
dd if=floppy.img of=/dev/fd0
在 Windows 下,你可以使用诸如 RAWRITE 之类的软件。
硬盘
[edit | edit source]硬盘通常在此过程中添加一层额外的内容,因为它们可能会被分区。 硬盘的第一个扇区称为主引导记录 (MBR)。 按照惯例,硬盘的分区信息包含在 MBR 的末尾,就在 0xAA55 签名之前。
BIOS 的作用与之前相同:将磁盘的第一个扇区(即 MBR)读入 RAM,并将执行权转移到该扇区的第一个字节。 BIOS 不知道分区方案 - 它只检查 0xAA55 签名的存在。
虽然这意味着可以使用任何想要的方式使用 MBR(例如,省略或扩展分区表),但很少这样做。 尽管分区表设计非常老旧且有限 - 它仅限于四个分区 - 几乎所有为 IBM PC 兼容机设计的操作系统都假设 MBR 将以这种方式格式化。 因此,违反惯例将使你的磁盘无法操作,除非针对专门使用它的操作系统。
实际上,MBR 通常包含一个引导加载程序,其目的是加载另一个引导加载程序 - 位于其中一个分区的开头。 这通常是一个非常简单的程序,它找到第一个标记为活动的分区,将它的第一个扇区加载到 RAM 中,并开始执行。 由于按照惯例,新的引导加载程序也被加载到地址 7C00h,因此旧的加载程序可能需要在执行此操作之前将自己的一部分或全部重新定位到不同的位置。 此外,ES:SI 预计包含分区表在 RAM 中的地址,而 DL 则是引导驱动器号。 违反这些惯例可能会使引导加载程序与其他引导加载程序不兼容。
但是,许多引导管理器(允许用户选择要启动的分区,有时甚至内核的软件)使用自定义 MBR 代码,它从磁盘上的某个位置加载引导管理器代码的其余部分,然后向用户提供有关如何继续引导过程的选项。 引导管理器也可以驻留在分区内,在这种情况下,它必须首先由另一个引导加载程序加载。
大多数引导管理器支持链式加载(即通过通常的将分区第一个扇区加载到地址 7C00 的过程启动另一个引导加载程序),这通常用于 DOS 和 Windows 等系统。 但是,一些引导管理器(特别是 GRUB)支持加载用户选择的内核映像。 这可以与 GNU/Linux 和 Solaris 等系统一起使用,从而在启动系统时提供更大的灵活性。 机制可能与链式加载略有不同。
显然,分区表提出了一个先有鸡还是先有蛋的问题,这对分区方案造成了不合理的限制。 一个正在流行的解决方案是 GUID 分区表;它使用一个虚拟 MBR 分区表,以便传统的操作系统不会干扰 GPT,而更新的操作系统则可以利用该系统提供的众多改进。
GNU GRUB
[edit | edit source]The GRand Unified Bootloader 支持灵活的 multiboot 引导协议。 该协议旨在通过为各种操作系统提供单一、灵活的协议来简化引导过程。 许多免费操作系统可以使用 multiboot 启动。
GRUB 非常强大,实际上是一个小型操作系统。 它可以读取各种文件系统,因此允许你按文件名指定内核映像以及内核可能使用的单独模块文件。 命令行参数也可以传递给内核 - 这是以维护模式、"安全模式"或使用 VGA 图形等方式启动操作系统的不错方法。 GRUB 可以为用户提供一个菜单供选择,以及允许输入自定义加载参数。
显然,这种功能不可能在 512 字节的代码中提供。 这就是为什么 GRUB 被分成两到三个"阶段"的原因
- 阶段 1 - 这是一个 512 字节的块,其中包含阶段 1.5 或阶段 2 的位置硬编码到其中。 它加载下一阶段。
- 阶段 1.5 - 一个可选的阶段,它了解阶段 2 所在的文件系统(例如 FAT32 或 ext3)。 它将找出阶段 2 的位置并加载它。 这个阶段非常小,位于固定区域,通常位于阶段 1 之后。
- 阶段 2 - 这是一个更大的映像,包含所有 GRUB 功能。
请注意,阶段 1 可以安装到硬盘的主引导记录,也可以安装到其中一个分区中并由另一个引导加载程序链式加载。
Windows 不能使用 multiboot 加载,但 Windows 引导加载程序(就像其他非 multiboot 操作系统的引导加载程序一样)可以从 GRUB 链式加载,这并不是很好,但确实可以让你启动这些系统。
引导加载程序示例 - Linux 内核 v0.01
[edit | edit source]SYSSIZE=0x8000
|
| boot.s
|
| boot.s is loaded at 0x7c00 by the bios-startup routines, and moves itself
| out of the way to address 0x90000, and jumps there.
|
| It then loads the system at 0x10000, using BIOS interrupts. Thereafter
| it disables all interrupts, moves the system down to 0x0000, changes
| to protected mode, and calls the start of system. System then must
| RE-initialize the protected mode in it's own tables, and enable
| interrupts as needed.
|
| NOTE! currently system is at most 8*65536 bytes long. This should be no
| problem, even in the future. I want to keep it simple. This 512 kB
| kernel size should be enough - in fact more would mean we'd have to move
| not just these start-up routines, but also do something about the cache-
| memory (block IO devices). The area left over in the lower 640 kB is meant
| for these. No other memory is assumed to be "physical", i.e. all memory
| over 1Mb is demand-paging. All addresses under 1Mb are guaranteed to match
| their physical addresses.
|
| NOTE1 above is no longer valid in it's entirety. cache-memory is allocated
| above the 1Mb mark as well as below. Otherwise it is mainly correct.
|
| NOTE 2! The boot disk type must be set at compile-time, by setting
| the following equ. Having the boot-up procedure hunt for the right
| disk type is severe brain-damage.
| The loader has been made as simple as possible (had to, to get it
| in 512 bytes with the code to move to protected mode), and continuous
| read errors will result in a unbreakable loop. Reboot by hand. It
| loads pretty fast by getting whole sectors at a time whenever possible.
| 1.44Mb disks:
sectors = 18
| 1.2Mb disks:
| sectors = 15
| 720kB disks:
| sectors = 9
.globl begtext, begdata, begbss, endtext, enddata, endbss
.text
begtext:
.data
begdata:
.bss
begbss:
.text
BOOTSEG = 0x07c0
INITSEG = 0x9000
SYSSEG = 0x1000 | system loaded at 0x10000 (65536).
ENDSEG = SYSSEG + SYSSIZE
entry start
start:
mov ax,#BOOTSEG
mov ds,ax
mov ax,#INITSEG
mov es,ax
mov cx,#256
sub si,si
sub di,di
rep
movw
jmpi go,INITSEG
go: mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov sp,#0x400 | arbitrary value >>512
mov ah,#0x03 | read cursor pos
xor bh,bh
int 0x10
mov cx,#24
mov bx,#0x0007 | page 0, attribute 7 (normal)
mov bp,#msg1
mov ax,#0x1301 | write string, move cursor
int 0x10
| ok, we've written the message, now
| we want to load the system (at 0x10000)
mov ax,#SYSSEG
mov es,ax | segment of 0x010000
call read_it
call kill_motor
| if the read went well we get current cursor position ans save it for
| posterity.
mov ah,#0x03 | read cursor pos
xor bh,bh
int 0x10 | save it in known place, con_init fetches
mov [510],dx | it from 0x90510.
| now we want to move to protected mode ...
cli | no interrupts allowed !
| first we move the system to it's rightful place
mov ax,#0x0000
cld | 'direction'=0, movs moves forward
do_move:
mov es,ax | destination segment
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax | source segment
sub di,di
sub si,si
mov cx,#0x8000
rep
movsw
j do_move
| then we load the segment descriptors
end_move:
mov ax,cs | right, forgot this at first. didn't work :-)
mov ds,ax
lidt idt_48 | load idt with 0,0
lgdt gdt_48 | load gdt with whatever appropriate
| that was painless, now we enable A20
call empty_8042
mov al,#0xD1 | command write
out #0x64,al
call empty_8042
mov al,#0xDF | A20 on
out #0x60,al
call empty_8042
| well, that went ok, I hope. Now we have to reprogram the interrupts :-(
| we put them right after the intel-reserved hardware interrupts, at
| int 0x20-0x2F. There they won't mess up anything. Sadly IBM really
| messed this up with the original PC, and they haven't been able to
| rectify it afterwards. Thus the BIOS puts interrupts at 0x08-0x0f,
| which is used for the internal hardware interrupts as well. We just
| have to reprogram the 8259's, and it isn't fun.
mov al,#0x11 | initialization sequence
out #0x20,al | send it to 8259A-1
.word 0x00eb,0x00eb | jmp $+2, jmp $+2
out #0xA0,al | and to 8259A-2
.word 0x00eb,0x00eb
mov al,#0x20 | start of hardware int's (0x20)
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x28 | start of hardware int's 2 (0x28)
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x04 | 8259-1 is master
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x02 | 8259-2 is slave
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x01 | 8086 mode for both
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0xFF | mask off all interrupts for now
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
| well, that certainly wasn't fun :-(. Hopefully it works, and we don't
| need no steenking BIOS anyway (except for the initial loading :-).
| The BIOS-routine wants lots of unnecessary data, and it's less
| "interesting" anyway. This is how REAL programmers do it.
|
| Well, now's the time to actually move into protected mode. To make
| things as simple as possible, we do no register set-up or anything,
| we let the gnu-compiled 32-bit programs do that. We just jump to
| absolute address 0x00000, in 32-bit protected mode.
mov ax,#0x0001 | protected mode (PE) bit
lmsw ax | This is it!
jmpi 0,8 | jmp offset 0 of segment 8 (cs)
| This routine checks that the keyboard command queue is empty
| No timeout is used - if this hangs there is something wrong with
| the machine, and we probably couldn't proceed anyway.
empty_8042:
.word 0x00eb,0x00eb
in al,#0x64 | 8042 status port
test al,#2 | is input buffer full?
jnz empty_8042 | yes - loop
ret
| This routine loads the system at address 0x10000, making sure
| no 64kB boundaries are crossed. We try to load it as fast as
| possible, loading whole tracks whenever we can.
|
| in: es - starting address segment (normally 0x1000)
|
| This routine has to be recompiled to fit another drive type,
| just change the "sectors" variable at the start of the file
| (originally 18, for a 1.44Mb drive)
|
sread: .word 1 | sectors read of current track
head: .word 0 | current head
track: .word 0 | current track
read_it:
mov ax,es
test ax,#0x0fff
die: jne die | es must be at 64kB boundary
xor bx,bx | bx is starting address within segment
rp_read:
mov ax,es
cmp ax,#ENDSEG | have we loaded all yet?
jb ok1_read
ret
ok1_read:
mov ax,#sectors
sub ax,sread
mov cx,ax
shl cx,#9
add cx,bx
jnc ok2_read
je ok2_read
xor ax,ax
sub ax,bx
shr ax,#9
ok2_read:
call read_track
mov cx,ax
add ax,sread
cmp ax,#sectors
jne ok3_read
mov ax,#1
sub ax,head
jne ok4_read
inc track
ok4_read:
mov head,ax
xor ax,ax
ok3_read:
mov sread,ax
shl cx,#9
add bx,cx
jnc rp_read
mov ax,es
add ax,#0x1000
mov es,ax
xor bx,bx
jmp rp_read
read_track:
push ax
push bx
push cx
push dx
mov dx,track
mov cx,sread
inc cx
mov ch,dl
mov dx,head
mov dh,dl
mov dl,#0
and dx,#0x0100
mov ah,#2
int 0x13
jc bad_rt
pop dx
pop cx
pop bx
pop ax
ret
bad_rt: mov ax,#0
mov dx,#0
int 0x13
pop dx
pop cx
pop bx
pop ax
jmp read_track
/*
* This procedure turns off the floppy drive motor, so
* that we enter the kernel in a known state, and
* don't have to worry about it later.
*/
kill_motor:
push dx
mov dx,#0x3f2
mov al,#0
outb
pop dx
ret
gdt:
.word 0,0,0,0 | dummy
.word 0x07FF | 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 | base address=0
.word 0x9A00 | code read/exec
.word 0x00C0 | granularity=4096, 386
.word 0x07FF | 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 | base address=0
.word 0x9200 | data read/write
.word 0x00C0 | granularity=4096, 386
idt_48:
.word 0 | idt limit=0
.word 0,0 | idt base=0L
gdt_48:
.word 0x800 | gdt limit=2048, 256 GDT entries
.word gdt,0x9 | gdt base = 0X9xxxx
msg1:
.byte 13,10
.ascii "Loading system ..."
.byte 13,10,13,10
.text
endtext:
.data
enddata:
.bss
endbss:
测试引导加载程序
[edit | edit source]也许测试引导加载程序的最简单方法是在虚拟机中,例如 VirtualBox 或 VMware。[1]
有时,如果引导加载程序支持 GDB 远程调试协议,这将很有用。[2]
进一步阅读
[edit | edit source]- ↑ "如何开发你自己的引导加载程序" by Alex Kolesnyk 2009
- ↑ "RedBoot 调试和引导固件"
- 嵌入式系统/引导加载程序和引导扇区 描述了各种嵌入式系统的引导加载程序。(大多数嵌入式系统没有 x86 处理器)。