grub2使用教程 (grub2 教程)

在linux启动启动的时候经常看到一个选择菜单,这个菜单可以让我们选择希望启动的系统,在这里我们所指示的系统都是基于Linux的系统。这个菜单由引导器进行绘制。这里举一个例子

这个启动界面我们使用Qemu模拟,提供一个菜单选项,这个菜单定义了一个由busybox作为根文件的操作系统,它使用了linux做为内核。如果您好奇这个背后的原理,这篇文章非常适合您。在这篇文章中我们将以实现者的角度介绍这个界面的由来。

之前很多的文章也介绍了系统的启动过程,在这个章节我们将详细的描述这个过程,以及这个过程所使用的的技术。

BIOS引导启动后将MBR分区的第一个扇区加载到 0x7c00 的内存区域,早期的Linux可以由内核直接引导,但专业的事交给专业的人。随着多种操作系统的发展,对于多系统启动的需求也日益迫切,所以由独立除了一个单独的引导程序,这个引导程序专门负责引导操作系统的系统,它可以支持多个系统和多种类型操作系统的启动。

在这里我们介绍的引导程序即GRUB,GRUB早期的版本已经被遗弃,现在所说的GRUB基本指的是GRUB2。这里我们不再说历史版本,我们所指的GRUB就是GRUB2。

而GRUB专门负责引导操作系统的启动,这里我们专门指的操作系统指的是Linux操作系统。Grub由分为BIOS版本和UEFI版本。我们首先看BIOS版本的GRUB引导过程。

这篇文章我们将详细介绍在BIOS固件中,GRUB的原理,调试等方式。

GRUB硬盘布局

现在的发行版的linux,大多都是通过GRUB引导,首先我们先从总体上看一下在GRUB在BIOS中的布局。这个是静态的,后面我们说一下动态加载到内存中的情况。

这张图中详细的说明了一个使用MBR磁盘分区的磁盘,以GRUB作为引导器的前几个扇区中的数据情况。以下的描述基于i386的处理器描述

图例总的1是MBR前446字节对应grub中的源码,这是MBR分区的第一个扇区的数据,在这个阶段使用grub安装的时候会将二进制文件 boot.img 填充到这个扇区中,这段代码由汇编编写而成,主要功能是为了加载第二个扇区的信息编号为2的位置是MBR的主磁盘分区,详细的描述参考《计算机启动知识系列 - BIOS/MBR》编号为3和4是磁盘中第二个扇区的数据内容,这段代码也是由汇编编写而成,这段代码最主要的功能是根据安装时候填充的后续代码的信息加载到内存中,这里特殊标记了4的位置,这个位置由grub安装程序填充,主要告诉后续需要加载数据的描述信息编号5中的代码主要作用用于将处理器切换到保护模式,并且将压缩的grub核心代码解压到内存的指定位置编号5,6组成了grub最为重要的代码,这段代码会在安装的时候进行压缩,所以需要5中的代码进行解压。

编号1,2组成了boot.img二进制文件,被填充到磁盘的第一个扇区。而编号3,4,5,6,7则组成了core.img二进制文件,core.img中包含了grub的核心代码,grub中的所有功能都是在这个阶段完成。

GRUB 内存布局

上面是一个硬盘的固态形式,下面我们将介绍数据被加载到内存中的布局

BIOS启动的时候会将磁盘的第一个扇区加载到内存中,这个点就是GRUB触发后续工作的初始点,在图中

首先,BIOS将第一个扇区加载到 0x7c00 的内存位置,处理器此时处于实地址模式。对应的GRUB中boot.img二进制文件,之前将GRUB硬盘布局的使用,也说了boot.img的功能,它主要用于加载后续的grub代码,默认情况下boot.img会将第二个扇区的数据加载到 0x8000 的内存位置第二,diskboot.img是默认磁盘引导的二进制文件,这个文件中对应了磁盘中的第二个扇区,这个扇区会根据grub安装的时候配置的加载信息将后续的代码加载到 0x8200第三,第三个扇区开始就是真正的grub代码了,这段代码包含两部分,一部分用于将处理器切换到保护模式和解压grub核心代码,一部分就是被压缩的grub核心代码。grub主要由模块和内核组成,grub模块为grub提供了丰富的功能。第三个扇区开始包含了用于解压的算法,主要将核心压缩的数据解压到 0x100000 的内存位置。

需要注意的这里我们没有描述段寄存器,在实模式下段寄存器是很重要的,这里我们默认将CS段寄存器设置为0。

我们已经设置到grub在磁盘中的布局和内存加载的位置,下面通过代码将分析grub在bios固件下内核引导启动的过程。这里不会详细介绍所有的代码,只会介绍我认为比较重要的代码

GRUB源码分析

磁盘中第一个扇区的代码被加载到 0x7c00 的内存位置,我们可以使用gdb进行验证。这里不会详细介绍调试的步骤,详细的可以参考《Grub2那些事 - 如何调试》章节中关于bios调试的描述

real-mode-gdb$ b *0x7c00...---------------------------[ CODE ]----=> 0x7c00: jmp 0x7c65 0x7c02: nop 0x7c03: add BYTE PTR [bx+si],al 0x7c05: add BYTE PTR [bx+si],al 0x7c07: add BYTE PTR [bx+si],al 0x7c09: add BYTE PTR [bx+si],al 0x7c0b: add BYTE PTR [bx+si],al 0x7c0d: add BYTE PTR [bx+si],al 0x7c0f: add BYTE PTR [bx+si],al 0x7c11: add BYTE PTR [bx+si],al...real-mode-gdb$ x/10i 0x7c65 0x7c65: cli 0x7c66: nop 0x7c67: nop 0x7c68: test dl,0x80 0x7c6b: je 0x7c72 0x7c6d: test dl,0x70 0x7c70: je 0x7c74 0x7c72: mov dl,0x80 0x7c74: jmp 0x0:0x7c79 0x7c79: xor ax,ax

可以看到这段汇编的代码和位于 grub-core/boot/i386/pc/boot.S 的代码是一致的。之前说过这段汇编的主要作用用于加载磁盘的第二个引导扇区。这段汇编代码简单,这里我们重点关注的是以下这段汇编

xorw%ax, %axmovw%ax, 4(%si)incw%ax/* set the mode to non-zero */movb%al, -1(%si)/* the blocks */movw%ax, 2(%si)/* the size and the reserved byte */movw$0x0010, (%si)/* the absolute address */movlLOCAL(kernel_sector), %ebxmovl%ebx, 8(%si) /*设置lba的低4字节*/movlLOCAL(kernel_sector_high), %ebxmovl%ebx, 12(%si) /*设置lba的高4字节*//* the segment of buffer address */movw$GRUB_BOOT_MACHINE_BUFFER_SEG, 6(%si)/* * BIOS call "INT 0x13 Function 0x42" to read sectors from disk into memory *Call with%ah = 0x42 *%dl = drive number *%ds:%si = segment:offset of disk address packet *Return: *%al = 0x0 on success; err code on failure */movb$0x42, %ahint$0x13

这段汇编的主要作用就是使用bios提供的功能加载磁盘的第二个扇区。它使用了BIOS 0x13 号的 0x42 子功能,使用lba寻址的方式加载磁盘中的数据,这个子功能引入了一个数据结构叫做 disk address packet 。这个结构包含了用于读取磁盘数据的所有信息。而这段代码主要就是为了组装这个数据结构。我们可以看一下网上对这个功能的介绍。

0x42功能表

寄存器

描述

AH

功能号,这里值为0x42

DL

驱动器编号,第一块硬盘编号为0x80

DS:SI

指向DAP数据结构

DAP(Disk Address Packet)

偏移量

大小

描述

0x00

1byte

disk address packet 的大小,固定值 0x10

0x01

1byte

保留,设置为0

0x02-0x03

2byte

需要读取的扇区个数

0x04-0x07

4byte

段描述符:偏移量,Intel是小段序,偏移量在低2个字节,段描述符占高2个字节

0x08-0x0F

8byte

使用lba寻址的扇区编号,注意这个字段分为高四个字节和低4个字节,低四个字节保存扇区编号的低位数,高四个字节保存扇区编号的高字节

这个图中详细的描述这个功能所需要的信息,其中我们重点关注 disk address packet 这个数据结构。这个数据结构其实不复杂,复杂的是数据的组织方式,Intel中是小段序,高字节在高地址。我们将汇编代码和功能表对应,那么这个数据将显而易见。这段代码填充读取磁盘第二个扇区所需要的所有信息

偏移量

大小

内容

0x00

1byte

0x10

0x01

1byte

0

0x02-0x03

2byte

1

0x04-0x07

4byte

0x70000000,段寄存器值为0x7000,偏移量为0

0x08-0x0F

8byte

0x00000000 00000001,第一个扇区,lba以0开始编号,即磁盘的第二个扇区

将第二个扇区读取到 DS:SI 的临时区域后,grub将这个临时数据拷贝到 GRUB_BOOT_MACHINE_KERNEL_ADDR 。这里,这个值为 0x8000。对于硬盘引导后面就进入了 grub-core/boot/i386/pc/diskboot.S 代码。

diskboot.S将根据安装时候配置的参数按照相同的方式将磁盘中后续的grub代码加载到内存中。

LOCAL(firstlist):/* this label has to be before the first list entry!!! */ /* fill the first data listing with the default */blocklist_default_start:/* this is the sector start parameter, in logical sectors from the start of the disk, sector 0 */.long 2, 0blocklist_default_len:/* this is the number of sectors to read. grub-mkimage will fill this up */.word 0blocklist_default_seg:/* this is the segment of the starting address to load the data into */.word (GRUB_BOOT_MACHINE_KERNEL_SEG + 0x20)

这三个参数由grub_install程序在安装grub代码到硬盘的时候进行填充,它计算后续加载的代码的扇区个数。将后续的代码加载到内存后,grub就开始执行 grub-core/boot/i386/pc/startup_raw.S 的代码。这个代码主要作用就是切换到保护模式并且将压缩的grub内核代码解压到指定的内存位置后,执行grub的内核代码。这里我们重点关注如何将当前的处理器模式切换到保护模式

/* transition to protected mode */calllreal_to_prot/* The ".code32" directive takes GAS out of 16-bit mode. */.code32cld

这段代码调用了real_to_prot函数将处理器切换到保护模式。之所以在这里将这段代码单独拉出来是因为,这个指令实际的操作数是32位的,它使用的是32位的地址,在执行call指令后,压入栈的是一个32位的地址,它执行下一个指令。将这段代码编译成运行的汇编为

0x8235: call 0x82d2 0x823b: cld

我们将这段指令编译成二进制后的数据为

0x8235: 0x66 0xe8 0x97 0x00 0x00 0x00 0xfc

这段二进制最为重要的是 0x66 0xe8 0x97 0x00 0x00 0x00 ,在指令解析的章节我们知道,运行在16位处理器中,0x66 作为前缀,用于更改操作数的大小,这里将操作数更改为了32位,即 0x00000097。而_e8_ 是 call 指令的编码数据,而且是个近端跳转指令(没有更改代码段寄存器),所以这段代码对应的编码规则是 CALL rel32 。执行着这段代码后,栈中的数据如下

高地址 --------| 0x00 || 0x00 || 0x82 || 0x3b | <- esp --------低地址

栈中的数据整对应于下一个指令的地址。而 real_to_prot 函数位于 grub-core/kern/i386/realmode.S,这个函数也很简单,主要是通过lgdt指令加载段描述符,更改cr0指令打开保护模式。需要重点关注的是下面这段代码

/* jump to relocation, flush prefetch queue, and reload %cs */ljmpl$GRUB_MEMORY_MACHINE_PROT_MODE_CSEG, $protcseg.code32protcseg:

这段代码在初始化了段描述符表后,使用跳转指令将 CS 寄存器设置为了8,段选择子的值为1,对应全局段描述符的第一项

CS = 0x8/* -- code segment -- * base = 0x00000000, limit = 0xFFFFF (4 KiB Granularity), present * type = 32bit code execute/read, DPL = 0 */.word0xFFFF, 0.byte0, 0x9A, 0xCF, 0

这里我们只是列举出来段寄存器的值和对应的段描述符中的数据,我们不能像对数据段寄存器一样通过赋值指令向CS寄存器赋值,这里就提供了一种方式,使用跳转指令复写代码段寄存器对CS寄存器进行赋值。后面的代码同样也很简单,这里我们在分析最后一个指令

/* return on the old (or initialized) stack! */ret

ret 指令将栈中数据赋值为EIP寄存器,然后处理器执行函数后的下一个指令,正如我们之前说的,它取出的是4个字节数据,取出后栈中数据如下

高地址 --------| 0x00 | <- esp| 0x00 || 0x00 || 0x00 | --------低地址

栈指针也指向的栈顶,将栈恢复成原来的样子,需要注意的是我们此时已经处于32位的保护模式中了。后续的代码也是保护模式代码。

后续的代码就是将grub代码解压到 GRUB_MEMORY_MACHINE_DECOMPRESSION_ADDR 位置,这里这个值为0x100000,这段代码从 grub-core/kern/i386/pc/startup.S 开始。这里这段代码的作用主要就是将代码拷贝到链接的起始地址 0x9000,初始化用于执行C程序所需要的栈和BSS等环境。然后就跳转到和grub的C语言代码入口 grub_main

/* * Call the start of main body of C code. */call EXT_C(grub_main)

这里的代码需要一些C语言的功底,例如

movl$(_start), %edirepmovsbmovl$LOCAL (cont), %esijmp*%esi

这里的 _start 指的是grub程序指令部分的起始地址,而下面的代码

movl$BSS_START_SYMBOL, %edi/* compute the bss length */movl$END_SYMBOL, %ecx

BSS_START_SYMBOL 表示的是__bss_start符号引用,END_SYMBOL是_end符号的引用,一个C语言的程序运行时通常具有以下的内存布局

High Addresses ---> .----------------------. | Environment | |----------------------| | | | STACK |base pointer ---> | - - - - - - - - - - -| | | | | v | : : . . . Empty . . . . . . . : : | ^ | | | | brk point ---> | - - - - - - - - - - -| | HEAP | | | _end ---> |----------------------| | BSS | Uninitialized data (BSS)__bss_start ---> |----------------------| | Data | Initialized data (DS) |----------------------| | Text | Binary code _start ---> '----------------------'Low Addresses ---->

这是一张加载到内存后程序的布局,这里对这个图了解。以下,后面我们写一个详细的文章介绍这个结构,这里其中的Text,BSS可以帮助我们了解这段代码,我再这里标注了相关符号的位置。Text节中保存的是二进制指令,Data中保存的是已经初始化的数据,而BSS中保存的是没有初始化的数据。

到这里我们就进入了C语言编写的GRUB内核代码,我不会一行行的解析GRUB的代码,这里还需要大家去阅读了解,下面我们将重点介绍GRUB使用linux模块引导内核启动的过程。

Grub的Linux模块

linux 模块提供了linux命令用于引导Linux内核,我们看一个简单的用于linux引导的配置

linux /boot/bzImage root=/dev/sda1 rootwait console=tty1

这个配置指定了内核的位置,根文件等信息。这些信息为linux模块提供了配置信息。i386的linux模块位于 grub-core/loader/i386/linux.c

linux模块主要作用可以归纳为以下几点

将内核文件从磁盘读入内存根据Linux引导协议引导linux内核组织linux启动的命令参数将CPU的控制权交付给Linux内核

前3条参考源码基本都可以理解这个过程,而第四条就有点复杂。所以在这里我们重点关注第四条。首先我们可以在gdb中直接调试,我们这里不再详细描述如何调试grub,这里我们分析linux模块,所以将 gdb_grub 最后一行修改为以下内容就可以在运行grub的时候直接进入linux模块的调试点。

# inform when module is loadedbreak grub_dl_addcommands silent load_module mod if $_streq(mod->name, "linux") break grub_cmd_linux end contend

这样我们就在linux模块的进入点 grub_cmd_linux 打了一个断点,运行gdb后就可以调试了。

这里我们分析的是32位加载内核的过程,我们这里重点介绍第四条的过程,linux模块在引导真正的内核镜像之前,会首先将磁盘中的内核镜像加载到 0x100000 中,并且构建linux启动的控制参数,这个参数的地址由grub动态分配。我们看一下加载的详细过程。

这里需要注意的是从 0x100000 开始加载的是运行于32位的代码,这段代码不包括内核的 setup 镜像。 grub_relocator32_start 是由汇编编写的函数,它负责将真正的引导内核启动。

grub将加载到 0x100000 的内核重定向到 0x1000000 位置,将linux启动参数由grub的内存重定向到 0x8b000 ,同时将 grub_relocator32_start 拷贝到 0x1000 的内存位置。然后将代码调转到 0x1000 后执行 grub_relocator32_start 的代码,这个函数最为重要的 功能就是 将_esi_ 寄存器设置为 real_mode_data 的内存地址 0x8b000 ,然后跳转到 0x1000000 位置执行内核代码。

我们可以看看这个过程的汇编代码(使用gdb调试,反编译对应的地址即可)

0x3d9e000: mov $0x1000000,%eax 0x3d9e005: mov %eax,%edi 0x3d9e007: mov $0x100000,%eax 0x3d9e00c: mov %eax,%esi 0x3d9e00e: mov $0x2d9e000,%ecx 0x3d9e013: add %ecx,%esi 0x3d9e015: add %ecx,%edi 0x3d9e017: sub $0x1,%esi 0x3d9e01a: sub $0x1,%edi 0x3d9e01d: std 0x3d9e01e: rep movsb %ds:(%esi),%es:(%edi) # 将0x100000的内容拷贝到0x1000000 0x3d9e020: mov $0x8b000,%eax 0x3d9e025: mov %eax,%edi 0x3d9e027: mov $0x1ffcd0b0,%eax 0x3d9e02c: mov %eax,%esi 0x3d9e02e: mov $0x5000,%ecx 0x3d9e033: cld 0x3d9e034: rep movsb %ds:(%esi),%es:(%电脑edi) # 将0x1ffcd0b0的内容拷贝到0x8b000 0x3d9e036: mov $0x1000,%eax 0x3d9e03b: mov %eax,%edi 0x3d9e03d: mov $0x1ffda790,%eax 0x3d9e042: mov %eax,%esi 0x3d9e044: mov $0xd0,%ecx 0x3d9e049: cld 0x3d9e04a: rep movsb %ds:(%esi),%es:(%edi) # 将0x1ffda790的内容拷贝到0x1000 0x3d9e04c: mov $0x1000,%eax 0x3d9e051: jmp *%eax 0x3d9e053: add %al,(%eax) 0x3d9e055: add %al,(%eax) 0x3d9e057: add %al,(%eax)

这段汇编主要作用就是重定向相关的数据,详细的描述在代码中,这里就不在赘述了,下面是 grub_relocator32_start 的汇编代码

0x1000: mov %eax,%esi 0x1002: add $0x9,%eax 0x1007: jmp *%eax 0x1009: lea 0x48(%esi),%eax 0x100f: mov %eax,0x40(%esi) 0x1015: lea 0xb0(%esi),%eax 0x101b: mov %eax,0x32(%esi) 0x1021: lgdtl 0x30(%esi) # 重新加载段描述符 0x1028: 电脑 ljmp *0x40(%esi) 0x102e: xchg %ax,%ax 0x1030: and %al,(%eax) 0x1032: add %al,(%eax) 0x1034: add %al,(%eax) 0x1036: lea 0x0(%esi,%eiz,1),%esi 0x103d: lea 0x0(%esi),%esi 0x1040: add %al,(%eax) 0x1042: add %al,(%eax) 0x1044: adc %al,(%eax) 0x1046: add %al,(%eax) 0x1048: mov $0x18,%eax 0x104d: mov %eax,%ds 0x104f: mov %eax,%es 0x1051: mov %eax,%fs 0x1053: mov %eax,%gs 0x1055: mov %eax,%ss 0x1057: mov %cr0,%eax 0x105a: and $0x7fffffff,%eax 0x105f: mov %eax,%cr0 0x1062: mov %cr4,%eax 0x1065: and $0xffffffdf,%eax 0x1068: mov %eax,%cr4 0x106b: jmp 0x106d 0x106d: mov $0x8b000,%eax 0x1072: mov %eax,%esp 0x1074: mov $0x0,%eax 0电脑x1079: mov %eax,%ebp 0x107b: mov $0x8b000,%eax 0x1080: mov %eax,%esi # 将esi设置为0x8b000 0x1082: mov $0x0,%eax 0x1087: mov %eax,%edi 0x1089: mov $0x0,%eax 0x108e: mov $0x0,%ebx 0x1093: mov $0x1ff7a270,%ecx 0x1098: mov $0x0,%edx 0x109d: cld 0x109e: ljmp $0x10,$0x1000000 # 执行跳转,CS=0x10,OFFSET=0x1000000 0x10a5: lea 0x0(%esi,%eiz,1),%esi 0x10ac: lea 0x0(%esi,%eiz,1),%esi

所以,grub加载内核后,将CPU控制权交给内核32位代码后,关键寄存器的信息如下

eax = 0x0ecx = 0x1ff7a270edx = 0x0ebx = 0x0esp = 0x8b000ebp = 0x0esi = 0x8b000edi = 0x0eip = 0x1000000cs = 0x10ss = 0x18ds = 0x18es = 0x18fs = 0x18gs = 0x18
电脑