来个更短的,没有其他乱七八糟的东西,只有一个简短的 C文件,不需要 linux 环境:
miniboot.c
asm(".long 0x1badb002, 0, (-(0x1badb002 + 0))"); unsigned char *videobuf = (unsigned char*)0xb8000; const char *str = "Hello, World !! "; int start_entry(void) { int i; for (i = 0; str[i]; i++) { videobuf[i * 2 + 0] = str[i]; videobuf[i * 2 + 1] = 0x17; } for (; i < 80 * 25; i++) { videobuf[i * 2 + 0] = ' '; videobuf[i * 2 + 1] = 0x17; } while (1) { } return 0; }
编译:
gcc -c -fno-builtin -ffreestanding -nostdlib -m32 miniboot.c -o miniboot.o ld -e start_entry -m elf_i386 -Ttext-seg=0x100000 miniboot.o -o miniboot.elf
运行:
qemu-system-i386 -kernel miniboot.elf
结果:
满足条件:
解释一下:
Q:-m elf_i386 是什么鬼?为何使用 elf 格式?
是输出为 elf 的目标格式,386是目标平台,还可以输出成 binary 格式,就是没有任何额外信息,你代码里面写了什么就是什么。一般用在 boot loader stage1 的第一个 512字节扇区那里,就是用这种纯格式。
但是这里我们需要 elf 格式,因为 elf格式除了代码和数据外还有很多有用的信息,可以被标准 boot loader 识别,你如果不想自己花费10+天去自己实现 stage1 和 stage2 的 bootloader 的话,elf文件可以帮你省很多时间,因为 grub 可以直接加载,qemu 也可以直接运行。
Q:elf 包含了哪些内容?
代码段,数据段的运行位置(比如你有个全局变量,需要知道到哪个线性地址可以找到这个全局变量),各个段的线性地址从低到高的排列顺序,以及他们的加载地址,加载地址和运行地址可以不同,加载地址是告诉 boot loader 把这个段加载到哪里,比如加载地址可以是0x100000即1MB地址开始处,也是最常见做法,而运行地址可以是 0xc0000000 即最高端内存 1GB 的位置,还有程序入口的起始地址,最后还可以包含符号信息便于调试。这些丰富的信息可以告诉外层的 boot loader 如何加载我们的 elf 文件,到哪里去初始化我们的 bss 数据段,以及最终跳转到哪里去执行入口处的代码,这些内存布局信息需要再一个 .ld 文件里说明,这里我们不用 .ld 文件,全部使用默认配置,但后期你想详细指定这些的话,需要写 .ld 文件,链接的时候传递给 ld 程序。
Q:是不是所有 elf 文件都可以被 boot loader 加载?
不是,需要第一个段中(没有ld文件的话,第一个段默认就是 text)头部包含 multiboot header,就是:
asm(".long 0x1badb002, 0, (-(0x1badb002 + 0))");
一共定义了十二个字节的 multiboot header,第一个long是 magic code, grub/qemu等需要检查,第二个 long 代表你需要 grub 提供哪些信息(比如内存布局,elf结构),这里填写0,不需要它提供任何信息。第三个long是代表前两个运算以后的一个 checksum,grub/qemu 会检查这个值确认你真的是一个可以引导的 kernel。
Q:为什么加载到 0x100000 地址?
这是最常规的做法,因为如果低于 1MB的地址你可能会破坏到 BIOS 程序,显存,中断引射表,而且低端地址需要保留给 dma 使用。而如果加载到,比如 0x800000,也不是不可以,只是你需要保证目标电脑内存 > 8MB + 你的程序大小,所以 0x100000 这个地址是大家都可以接受的一个通用地址。
Q:我想再高端内存(0xc0000000)运行?
可以,这也是 linux 干的事情,这样可以给下面的应用程序留出足够的线性空间来,但是你需要初始化页表,把你自己映射到高端地址,并且需要再 .ld 文件里面指明加载地址(0x100000处)和运行地址(0xc0000000)是两个不同的地址,你还是会被加载到 0x100000 处,但是你的所有C代码都认为自己再 0xc0000000 处执行,所以此时还不能跑任何C代码,需要一段叫做 relocation 的汇编初始化页面映射,还有其他一些环境,并最终跳转到高端地址的 entry 入口。
Q:为什么不写 boot loader ?
给你节省时间,完善的boot loader涉及到大量的技巧,用现成的boot loader,可以让你马上看到结果,有正反馈。
Q:如果我执意要写个完善的 boot loader 呢?
首先要写引导扇区代码,调用 BIOS 磁盘中断,把你后面的 elf 文件加载进来,但是这时boot loader还处在实模式,只能访问 1MB以内的空间,也就是说你的 elf 文件大小受到了限制,要加载到 1MB以上的空间,需要 boot loader 切换到保护模式,但是此时又不能访问传统的 BIOS 中断请求磁盘了,所有你需要来回切换保护模式和实模式,或者用 v86 模式,或者写保护模式下的硬盘驱动,显然这不是 512字节能够完成的事情,所以你还需要 stage 2 的 boot loader。
传统 512 字节的 boot loader 先在实模式下把 stage 2 加载到 0x10000 处(64KB处),然后又由 stage2 的程序进入保护模式,并用 v86 模式调用 BIOS 的磁盘中断服务程序,把你的 elf 文件找出来,并且解析 elf 结构,把各个段加载到它期望的位置,最后初始化一个临时的 GDT 和栈,把 bss 段全部置0,关闭中断标志,最后再跳转到 elf 的 entry 处,完成引导。
注意:stage1 的 512字节,和 stage2的后续扇区代码,目标格式都是纯二进制,不是 elf。
注意:stage2 的代码里需要找出你的 elf,这时你的 elf 可以简单的写到一个裸分区上,这样找起来容易点,如果你需要把 elf 放到 fat16/fat32 分区的某个目录中,那么 stage2 代码就要写相关 fat 文件系统的识别代码(只需要简单的文件/目录读取,不需要写,所以不需要实现整个文件系统),而如果你想把 elf 文件放到除 fat 外的其他文件系统需要支持多文件系统的话,stage2就搞不定了,需要 stage3 可以动态加载不同文件系统的模块化程序,做更复杂的动作。
Q:这个 miniboot 程序可以接着自由往下写么?
很遗憾,可能你只能写点很短的代码,因为:
先把这九个问题解决,你就可以继续往下写更多复杂的内容了。
先写这么多吧。
现代 OS kernel 是没有 loader 的。就是说先需要一个 loader 把 OS kernel image 调入到预先约定的内存区域,然后再调用 kernel 的 entry function。这样 kernel 就运行起来了。对于这样的 kernel,针对题主的问题,从依赖性来考虑,DOS 时代的任何一个 app 都是这样的。因为 DOS 本质来说只是一个 loader。(当然 DOS 还构建了一个 Int 21,不过从依赖角度说这个 Int 21 和一个普通函数差不多。)
其实当时以 DOS 为 loader 的真正的操作系统也不少,Windows 1.x - 3.x,还有 DOS/4G 都是如此。
至于 kernel 做到独立,你的源代码里不要调用诸如 system call 或者 C runtime 的函数就可以了。如果需要类似的功能,你要自己写代码向特定的 I/O port 或者 I/O mapping address 读写数据来完成。或者你和硬件开发者商量好一个 device driver 接口,让他们写底层的细节。
至于 loader 本身也没什么神奇的,因为 CPU 里有一个「硬 loader entry」,一加电就自动运行。所以你就把你的 loader 安装到这个「硬 loader entry」指定的区域就好。普通 PC 的硬 loader 是 BIOS,现在是 UEFI。这个 BIOS/UEFI 又规定了从磁盘的什么地方再调一个 loader。
现代编译器一般有两个功能:一是指定一个特定函数在生成后的文件里的偏移量,二是在生成后的文件里保存一张 symbol table,说明每个函数的偏移量。对于要求比较死的 loader,就把 entry function 设置成规定的偏移量就行。对于高级 loader,基本上不用 compiler 做特定工作,直接把 loader 配置好就行了。
如果编译器功能实在达不到,loader 又比较傻,那 kernel 开发者就要写自己的工具了。比如写个程序,生成一段汇编,放到整个文件最前面(如果 loader 规定如此),让控制流 jmp 跳到真正的 entry fucntion 位置(这个位置也可以在 build kernel 的时候用自制或者通用工具得到)。