用 GRUB 引导自己的操作系统

时间:2021-02-22 11:44:32
在 PC 机上捣鼓自己的操作系统遇到的第一个难题就是如何将内核加载到内存中执行。如果读过于渊写的《自己动手写操作系统》就会知道这部分的工作还是蛮繁琐的。而且实际上这部分工作和操作系统没太大的关系。好在随着 linux 等开源操作系统的发展,开源的引导加载程序也已经发展的很成熟了。我们可以利用前人的成果,将自己的操作系统改造成可以用现有引导加载程序引导的内核。
 
引导加载程序(BootLoader)是系统加电后运行的第一段软件代码。x86 系统中的引导加载程序由 BIOS(Basic Input Output System,基本输入输出系统)和位于硬盘主引导记录(MBR,Master Boot Record)中的操作系统引导加载程序(比如,LILO 和 GRUB 等)一起组成。BIOS 在完成硬件检测和资源分配后,将硬盘 MBR 中的引导加载程序读到系统的 RAM 中,然后将控制权交给操作系统引导加载程序。引导加载程序的主要运行任务就是将内核映象从硬盘上读到 RAM 中,然后跳转到内核的入口点去运行,也即开始启动操作系统。
 
引导加载程序并非操作系统的一部分,但是没有引导加载程序加载操作系统,操作系统是无法自动运行起来的。可以在 x86 系统中运行的操作系统超过 100 种,其中较为有名的如微软公司出品的 DOS、Windows 系列,开放源代码的 Linux、FreeBS D等也不下 10 余种。这些各具特色的操作系统几乎都有其专用的引导加载程序,并且互相之间并不兼容。
 
经过对比各种常见的引导加载程序的功能和可靠性,我选择了多重操作系统启动管理器 GRUB 作为引导加载程序。GRUB 是一个来自*软件基金会项目的多操作系统启动程序,它允许用户可以在计算机内同时拥有多个操作系统,并在计算机启动时选择希望运行的操作系统。GRUB 可用于选择操作系统分区上的不同内核,也可用于向这些内核传递启动参数。
 
GRUB 引导加载程序广泛应用于 Linux、各种 BSD 系统的引导,具有极高的可靠性。满足多重引导规范(The Multiboot Specification),可以引导各种满足多重引导规范的操作系统内核。并且可以通过配置文件配置为多引导模式,当加载的系统出现故障无法工作时可以自动启用备用系统,极大的提高了系统的可靠性。
 
多重引导规范
 
多重引导规范并不强制要求内核的格式,但是如果采用 ELF 格式,将会带来许多方便。本文下面的介绍都是基于内核采用 ELF 格式。如果您的内核碰巧不能采用 ELF 格式,请您参考多重引导规范的官方文本中 3.1 节关于 Multiboot Header 的介绍。
 
能够被 GRUB 引导的内核有两个条件:
(1) 需要有一个 Multiboot Header ,这个  Multiboot Header 必须在内核镜像的前 8192 个字节内,并且是首地址是 4 字节对其的。  
(2) 内核的加载地址在 1MB 以上的内存中,这个要求是 GRUB 附加的,并非多重引导规范的规定。
 
Multiboot Header
 
Multiboot Header的分布必须如下所示:
 
偏移量    类型    域名                 备注
0     u32     magic                       必需
4     u32     flags                         必需
8     u32     checksum               必需
12     u32     header_addr        如果flags[16]被置位
16     u32     load_addr             如果flags[16]被置位
20     u32     load_end_addr    如果flags[16]被置位
24     u32     bss_end_addr     如果flags[16]被置位
28     u32     entry_addr            如果flags[16]被置位
32     u32     mode_type           如果flags[2]被置位
36     u32     width                      如果flags[2]被置位
40     u32     height                    如果flags[2]被置位
44     u32     depth                     如果flags[2]被置位  
 
magic  
    域是标志头的魔数,它必须等于十六进制值 0x1BADB002。
 
flags
    flags域指出OS映像需要引导程序提供或支持的特性。0-15 位指出需求:如果引导程序发现某些值被设置但出于某种原因不理解或不能不能满足相应的需求,它必须告知用户并宣告引导失败。16-31位指出可选的特性:如果引导程序不能支持某些位,它可以简单的忽略它们并正常引导。自然,所有 flags 字中尚未定义的位必须被置为 0。这样,flags 域既可以用于版本控制也可以用于简单的特性选择。
 
    如果设置了 flags 字中的 0 位,所有的引导模块将按页(4KB)边界对齐。有些操作系统能够在启动时将包含引导模块的页直接映射到一个分页的地址空间,因此需要引导模块是页对齐的。
 
    如果设置了 flags 字中的 1 位,则必须通过 Multiboot 信息结构(参见引导信息格式)的 mem_* 域包括可用内存的信息。如果引导程序能够传递内存分布(mmap_*域)并且它确实存在,则也包括它。
 
    如果设置了 flags 字中的 2 位,有关视频模式表(参见引导信息格式)的信息必须对内核有效。
 
    如果设置了 flags 字中的 16 位,则 Multiboot 头中偏移量 8-24 的域有效,引导程序应该使用它们而不是实际可执行头中的域来计算将 OS 映象载入到那里。如果内核映象为 ELF 格式则不必提供这样的信息,但是如果映象是 a.out 格式或者其他什么格式的话就必须提供这些信息。
 
checksum
    域 checksum 是一个 32 位的无符号值,当与其他的 magic 域(也就是 magic 和 flags)相加时,结果必须是 32 位的无符号值 0(即magic + flags + checksum = 0)
 
header_addr  
    这里往后的 32 个字节不是必须的,并且对于内核为 ELF 格式时是不需要的,因此就不介绍了。
 
当引导程序调用32位操作系统时,机器状态必须如下:
 
EAX
    必须包含魔数 0x2BADB002;这个值指出操作系统是被一个符合 Multiboot 规范的引导程序载入的。
EBX
    必须包含由引导程序提供的 Multiboot 信息结构的物理地址(参见引导信息格式)。
CS
    必须是一个偏移量位于 0 到 0xFFFFFFFF 之间的 32 位可读/可执行代码段。这里的精确值未定义。
DS
ES
FS
GS
SS
    必须是一个偏移量位于 0 到 0xFFFFFFFF 之间的 32 位可读/可执行代码段。这里的精确值未定义。
A20 gate
    必须已经开启。
CR0
    第31位(PG)必须为 0。第 0 位(PE)必须为 1。其他位未定义。
EFLAGS
    第17位(VM)必须为 0。第 9 位(IF)必须为 1 。其他位未定义。
 
所有其他的处理器寄存器和标志位未定义。这包括:
 
ESP
    当需要使用堆栈时,OS 映象必须自己创建一个。
GDTR
    尽管段寄存器像上面那样定义了,GDTR 也可能是无效的,所以 OS 映象决不能载入任何段寄存器(即使是载入相同的值也不行!)直到它设定了自己的 GDT。
IDTR
    OS 映象必须在设置完它的 IDT 之后才能开中断。
 
Multiboot 信息结构(就目前为止定义的)的格式如下:
 
             +-------------------+
     0       | flags                |    (必需)
             +-------------------+
     4       | mem_lower         |    (如果flags[0]被置位则出现)
     8       | mem_upper         |    (如果flags[0]被置位则出现)
             +-------------------+
     12      | boot_device       |    (如果flags[1]被置位则出现)
             +-------------------+
     16      | cmdline           |    (如果flags[2]被置位则出现)
             +-------------------+
     20      | mods_count        |    (如果flags[3]被置位则出现)
     24      | mods_addr         |    (如果flags[3]被置位则出现)
             +-------------------+
     28 - 40 | syms              |    (如果flags[4]或flags[5]被置位则出现)
             |                   |                  
             +-------------------+
     44      | mmap_length       |    (如果flags[6]被置位则出现)
     48      | mmap_addr         |    (如果flags[6]被置位则出现)
             +-------------------+
     52      | drives_length     |    (如果flags[7]被置位则出现)
     56      | drives_addr       |    (如果flags[7]被置位则出现)
             +-------------------+
     60      | config_table      |    (如果flags[8]被置位则出现)
             +-------------------+
     64      | boot_loader_name  |    (如果flags[9]被置位则出现)
             +-------------------+
     68      | apm_table         |    (如果flags[10]被置位则出现)
             +-------------------+
     72      | vbe_control_info  |    (如果flags[11]被置位则出现)
     76      | vbe_mode_info     |
     80      | vbe_mode          |
     82      | vbe_interface_seg |
     84      | vbe_interface_off |
     86      | vbe_interface_len |
             +-------------------+
      
第一个 longword 指出 Multiboot 信息结构中的其它域是否有效。所有目前未定义的位必须被引导程序设为 0。操作系统应该忽略任何它不理解的位。因此,flags 域也可以视作一个版本标志符,这样可以无破坏的扩展Multiboot信息结构。
 
如果设置了 flags 中的第 0 位,则 mem_* 域有效。mem_lower 和 mem_upper 分别指出了低端和高端内存的大小,单位是 K。低端内存的首地址是 0,高端内存的首地址是 1M。低端内存的最大可能值是 640K。返回的高端内存的最大可能值是最大值减去 1M。但并不保证是这个值。
 
flags 的其他位我没有用到,这里就不介绍了。需要了解的请自己阅读相关文档。
 
下面是一个最简单的例子:
 
 
/**
* boot.S
*/

#define MULTIBOOT_HEADER_MAGIC 0x1BADB002
#define MULTIBOOT_HEADER_FLAGS 0x00000003
#define STACK_SIZE 0x4000

.text
.globl start, _start

start:
_start:
jmp multiboot_entry

.align 4

multiboot_header:
.long MULTIBOOT_HEADER_MAGIC
.long MULTIBOOT_HEADER_FLAGS
.long -(MULTIBOOT_HEADER_MAGIC + MULTIBOOT_HEADER_FLAGS)

multiboot_entry:
/* 初始化堆栈指针。 */
movl $(stack + STACK_SIZE), %esp

/* 重置 EFLAGS。 */
pushl $0
popf

pushl %ebx
pushl %eax

/* 现在进入 C main 函数... */
call cmain

loop: hlt
jmp loop

.comm stack, STACK_SIZE


/**
* kernel.c
*/

/* a.out 符号表。 */
typedef struct aout_symbol_table
{
unsigned long tabsize;
unsigned long strsize;
unsigned long addr;
unsigned long reserved;
} aout_symbol_table_t;

/* ELF 的 section header table。 */
typedef struct elf_section_header_table
{
unsigned long num;
unsigned long size;
unsigned long addr;
unsigned long shndx;
} elf_section_header_table_t;

/* Multiboot 信息。 */
typedef struct multiboot_info
{
unsigned long flags;
unsigned long mem_lower;
unsigned long mem_upper;
unsigned long boot_device;
unsigned long cmdline;
unsigned long mods_count;
unsigned long mods_addr;
union
{
aout_symbol_table_t aout_sym;
elf_section_header_table_t elf_sec;
} u;
unsigned long mmap_length;
unsigned long mmap_addr;
} multiboot_info_t;

/* 检测 FLAGS 中的位 BIT 是否被置位。 */
#define CHECK_FLAG(flags,bit) ((flags) & (1 << (bit)))

/* 与显示相关的设置。 */
#define COLUMNS 80
#define LINES 24
#define ATTRIBUTE 7
#define VIDEO 0xB8000

static int xpos; /* X 坐标。 */
static int ypos; /* Y 坐标。 */
static volatile unsigned char *video; /* 指向显存。 */

static void cls (void);
static void itoa (char *buf, int base, int d);
static void putchar (int c);
void printf (const char *format, ...);

void cmain (unsigned long magic, unsigned long addr)
{
multiboot_info_t *mbi;

/* 清屏。 */
cls ();

/* 将 MBI 指向 Multiboot 信息结构。 */
mbi = (multiboot_info_t *) addr;

/* mem_* 是否有效? */
if (CHECK_FLAG (mbi->flags, 0))
printf ("mem_lower = %uKB, mem_upper = %uKB\n", (unsigned) mbi->mem_lower, (unsigned) mbi->mem_upper);

/* your code here. */
}

/* 清屏并初始化 VIDEO,XPOS 和 YPOS。 */
static void cls (void)
{
int i;

video = (unsigned char *) VIDEO;

for (i = 0; i < COLUMNS * LINES * 2; i++)
*(video + i) = 0;

xpos = 0;
ypos = 0;
}

/* 将整数 D 转换为字符串并保存在 BUF 中。如果 BASE 为 'd',则 D 为十进制,如果 BASE 为 'x',则 D 为十六进制。 */
static void itoa (char *buf, int base, int d)
{
char *p = buf;
char *p1, *p2;
unsigned long ud = d;
int divisor = 10;

/* 如果指定了 %d 并且 D 是负数,在开始添上负号。 */
if (base == 'd' && d < 0)
{
*p++ = '-';
buf++;
ud = -d;
}
else if (base == 'x')
divisor = 16;

/* 用 DIVISOR 去除 UD 直到 UD == 0。 */
do
{
int remainder = ud % divisor;

*p++ = (remainder < 10) ? remainder + '0' : remainder + 'a' - 10;
}
while (ud /= divisor);

/* 在字符串尾添上终结符。 */
*p = 0;

/* 反转 BUF。 */
p1 = buf;
p2 = p - 1;
while (p1 < p2)
{
char tmp = *p1;
*p1 = *p2;
*p2 = tmp;
p1++;
p2--;
}
}

/* 在屏幕上输出字符 C 。 */
static void putchar (int c)
{
if (c == '\n' || c == '\r')
{
newline:
xpos = 0;
ypos++;
if (ypos >= LINES)
ypos = 0;
return;
}

*(video + (xpos + ypos * COLUMNS) * 2) = c & 0xFF;
*(video + (xpos + ypos * COLUMNS) * 2 + 1) = ATTRIBUTE;

xpos++;
if (xpos >= COLUMNS)
goto newline;
}

/* 格式化字符串并在屏幕上输出,就像 libc 函数 printf 一样。 */
void printf (const char *format, ...)
{
char **arg = (char **) &format;
int c;
char buf[20];

arg++;

while ((c = *format++) != 0)
{
if (c != '%')
putchar (c);
else
{
char *p;

c = *format++;
switch (c)
{
case 'd':
case 'u':
case 'x':
itoa (buf, c, *((int *) arg++));
p = buf;
goto string;
break;

case 's':
p = *arg++;
if (! p)
p = "(null)";

string:
while (*p)
putchar (*p++);
break;

default:
putchar (*((int *) arg++));
break;
}
}
}
}


下面是编译命令:

gcc kernel.c -c -fno-builtin
gcc boot.S -c
ld kernel.o boot.o -o kernel -s -Ttext 0x100000 --entry=start


最后是用 bochs 运行的结果:

用 GRUB 引导自己的操作系统

 

如何运行自己的内核,可以参考我的文章:制作带有 GRUB 引导功能的软盘镜像文件