上回我们讲(翻译)了一些预备知识,并准备好了交叉编译器和其他工具链,现在开始动手写代码吧!我们的“空内核”需要三个源文件:
- boot.S,作为内核的入口,用于初始化运行环境
- kernel.c,作为内核的主函数
- linker.ld,作为链接上面两个文件的脚本
Booting the Operating System
当你编译好一个内核后,它是存储于磁盘上的,然而一个没有内核的机器该如何把磁盘上的内核读进内存,从而去运行里面的指令呢?这就要用到一个内核之外的东西,叫做bootloader。原文提到了GNU有一个叫做GRUB的现成工具可以直接用。
那么为什么推荐用GRUB,而不是自己写一个bootloader呢?说好的“自己动手”呢?这就要提到GNU的操作系统多重引导规范(Multiboot Specification)。此规范针对的是这样一个问题——操作系统有茫茫多,平台架构也有不少,而bootloader是同时取决于这两者的,因为它既要初始化平台环境,又要装载内核。假如大家随意发挥,那么当我在一台PC上运行其他内核时就有可能出现冲突,因为其他内核的bootloader不一定支持该PC。因此,Multiboot Specification同时对bootloader和内核作了约束,保证符合规范的bootloader能装载任何符合规范的内核。GRUB就是这样的bootloader,而且它还具备其他特性,例如可配置等。
可能有人会问:bootloader又是怎么被读进内存的呢?实际上bootloader并不需要其他程序去读取,因为它存在于一个特殊的固件里,叫做BIOS (Basic Input/Output System)。电脑一通上电,最先做的事情之一就是去执行BIOS里的指令,这是出厂时就预设好的。当然,现在BIOS快被淘汰了,取而代之的是UEFI。
Installing GRUB 2 on OS X
首先你需要一个交叉编译器及其目标平台的工具链(这个在前篇就完成了),然后安装objconv。如果后面遇到了关于“aclocal”的错,你需要安装automake。
下载grub的源码,编译并安装。注意我的下载地址,参考这里。
git clone git@github.com:ar-OS/grub.git --depth=1
mkdir build-grub
cd build-grub
../grub/configure --disable-werror TARGET_CC=i686-elf-gcc TARGET_OBJCOPY=i686-elf-objcopy TARGET_STRIP=i686-elf-strip TARGET_NM=i686-elf-nm TARGET_RANLIB=i686-elf-ranlib --target=i686-elf
make
make install
Bootstrap Assembly
首先创建一份boot.S代码,下面我把原文里的注释稍微概括一下。
/* 多重引导规范所要求的一些常量 */
.set ALIGN, 1<<0 /* align loaded modules on page boundaries */
.set MEMINFO, 1<<1 /* provide memory map */
.set FLAGS, ALIGN | MEMINFO /* this is the Multiboot 'flag' field */
.set MAGIC, 0x1BADB002 /* 'magic number' lets bootloader find the header */
.set CHECKSUM, -(MAGIC + FLAGS) /* checksum of above, to prove we are multiboot */
/*
声明一个符合“多重引导规范”的头部,让bootloader能找到并确认这里是内核的开头
bootloader会在内核的前8KiB里去寻找这些内容
*/
.section .multiboot /* 单独起一个程序段,确保它能在内核的最开头出现 */
.align 4 /* 要求32-bit对齐 */
.long MAGIC
.long FLAGS
.long CHECKSUM
/*
内核自己起一个内核栈,并用符号标明栈顶和栈底的位置
x86的栈是从栈顶向栈底生长的
*/
.section .bss /* BSS段不占据程序空间,而是在装载时由装载程序分配空间 */
.align 16 /* 一定要对齐,否则会产生未定义行为 */
stack_bottom:
.skip 16384 # 16 KiB
stack_top:
.section .text
.global _start /* 链接脚本会将这里指定为程序入口,bootloader装载完内核后会跳转到这里,并且再也不返回 */
.type _start, @function
_start:
/*
bootloader会开启x86的32-bit保护模式,同时关闭中断和分页,
将CPU设置为Multiboot规范所要求的状态。此时没有printf函数,
没有安全级别限制,也没有debug机制,只有内核自己。
此时内核拥有对机器的完全控制权。
*/
/*
设置esp寄存器。只能用汇编语言完成,
因为C语言程序的运行必须依赖栈,而此时还没有栈
*/
mov $stack_top, %esp
/*
在进入内核的更上层之前,我们最好先初始化一些关键的CPU状态。
浮点运算指令、扩展指令集都还没有启用。
应该在这里装载GDT、开启分页。
It's best to minimize the early environment where crucial features are offline.
C++ features such as global constructors and exceptions will require runtime support to work as well.
*/
/*
进入内核的更上一层。根据System V ABI的要求,执行call指令时,
栈必须是16-byte对齐的,现在我们满足这个要求
*/
call kernel_main
/*
如果系统已经没事做了,就进入无限循环:
1. 用cli指令禁止中断,我们在bootloader已经做过了。不过
你可能还会在kernel_main里打开中断并返回这里,所以还
是要cli一次。当然,从kernel_main返回这件事本身就挺扯的。
2. 用hlt指令锁住电脑,直到下一次中断来临。
3. 由于我们已经关掉了中断,所以只有不可屏蔽中断和x86的
“系统管理模式”会唤醒电脑。万一这种情况发生了,我们再跳回hlt指令。
*/
cli
1: hlt
jmp 1b
/*
Set the size of the _start symbol to the current location '.' minus its start.
This is useful when debugging or when you implement call tracing.
*/
.size _start, . - _start
Implementing the Kernel
独立环境和宿主环境
我们平时在用户空间写C/C++程序时,都处于“宿主环境”下,其重要特征之一就是C标准库的可用性。然而现在我们处于一个“独立环境”,只有一个交叉编译器,因此我们只能在程序里include非常非常基础的头文件,例如编译器自带的<stdbool.h>(定义了布尔类型)、 <stddef.h>(定义了size_t和NULL)、 <stdint.h>(定义了定长的数据类型intx_t和uintx_t ,这对操作系统编程非常重要——万一哪天short的长度就变了呢?)。此外还有 <float.h>、<iso646.h>、<limits.h>、<stdarg.h>等头文件。
下面的kernel.c非常self-explanatory,我就不多解释了,大意就是。注意缺少C标准库是一件多么麻烦的事情,我们得自己实现strlen函数。还有,很多新机器已经不支持VGA文字模式(以及BIOS)了,而是转向了UEFI,而在后者的情况下你甚至需要自己设计每个字符的像素buffer。
用C语言写一个内核
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* Check if the compiler thinks you are targeting the wrong operating system. */
#if defined(__linux__)
#error "You are not using a cross-compiler, you will most certainly run into trouble"
#endif
/* This tutorial will only work for the 32-bit ix86 targets. */
#if !defined(__i386__)
#error "This tutorial needs to be compiled with a ix86-elf compiler"
#endif
/* Hardware text mode color constants. */
enum vga_color {
VGA_COLOR_BLACK = 0,
VGA_COLOR_BLUE = 1,
VGA_COLOR_GREEN = 2,
VGA_COLOR_CYAN = 3,
VGA_COLOR_RED = 4,
VGA_COLOR_MAGENTA = 5,
VGA_COLOR_BROWN = 6,
VGA_COLOR_LIGHT_GREY = 7,
VGA_COLOR_DARK_GREY = 8,
VGA_COLOR_LIGHT_BLUE = 9,
VGA_COLOR_LIGHT_GREEN = 10,
VGA_COLOR_LIGHT_CYAN = 11,
VGA_COLOR_LIGHT_RED = 12,
VGA_COLOR_LIGHT_MAGENTA = 13,
VGA_COLOR_LIGHT_BROWN = 14,
VGA_COLOR_WHITE = 15,
};
static inline uint8_t vga_entry_color(enum vga_color fg, enum vga_color bg) {
return fg | bg << 4;
}
static inline uint16_t vga_entry(unsigned char uc, uint8_t color) {
return (uint16_t) uc | (uint16_t) color << 8;
}
size_t strlen(const char* str) {
size_t len = 0;
while (str[len])
len++;
return len;
}
static const size_t VGA_WIDTH = 80;
static const size_t VGA_HEIGHT = 25;
size_t terminal_row;
size_t terminal_column;
uint8_t terminal_color;
uint16_t* terminal_buffer;
void terminal_initialize(void) {
terminal_row = 0;
terminal_column = 0;
terminal_color = vga_entry_color(VGA_COLOR_LIGHT_GREY, VGA_COLOR_BLACK);
terminal_buffer = (uint16_t*) 0xB8000; // VGA默认的彩色显示器缓存地址,可以去查查这个数
for (size_t y = 0; y < VGA_HEIGHT; y++) {
for (size_t x = 0; x < VGA_WIDTH; x++) {
const size_t index = y * VGA_WIDTH + x;
terminal_buffer[index] = vga_entry(' ', terminal_color);
}
}
}
void terminal_setcolor(uint8_t color) {
terminal_color = color;
}
void terminal_putentryat(char c, uint8_t color, size_t x, size_t y) {
const size_t index = y * VGA_WIDTH + x;
terminal_buffer[index] = vga_entry(c, color);
}
void terminal_putchar(char c) {
terminal_putentryat(c, terminal_color, terminal_column, terminal_row);
if (++terminal_column == VGA_WIDTH) {
terminal_column = 0;
if (++terminal_row == VGA_HEIGHT)
terminal_row = 0;
}
}
void terminal_write(const char* data, size_t size) {
for (size_t i = 0; i < size; i++)
terminal_putchar(data[i]);
}
void terminal_writestring(const char* data) {
terminal_write(data, strlen(data));
}
void kernel_main(void) {
/* Initialize terminal interface */
terminal_initialize();
/* Newline support is left as an exercise. */
terminal_writestring("Hello, kernel World!\n");
}
Linking_the_Kernel
在用户空间下编程时,GCC可以用自带的脚本自动链接多个object文件,但是系统编程时不要这么做。我们要写一个自己的链接脚本linker.ld。你可能需要先了解一下程序链接的知识,例如程序段、链接地址、加载地址的概念,还可以参考ld的文档来学习编写链接脚本:
/* bootloader会从_start处进入内核 */
ENTRY(_start)
/* 指定内核每个程序段的链接地址、加载地址等 */
SECTIONS
{
/* 从1MiB这个地址开始,因为bootloader一般都是把内核加载到这里的 */
. = 1M;
/* 把multiboot段放在最前面 */
.text BLOCK(4K) : ALIGN(4K)
{
*(.multiboot)
*(.text)
}
/* 只读数据 */
.rodata BLOCK(4K) : ALIGN(4K)
{
*(.rodata)
}
/* 已初始化的可读写数据 */
.data BLOCK(4K) : ALIGN(4K)
{
*(.data)
}
/* 未初始化的可读写数据(包括栈) */
.bss BLOCK(4K) : ALIGN(4K)
{
*(COMMON)
*(.bss)
}
/* The compiler may produce other sections, by default it will put them in
a segment with the same name. Simply add stuff here as needed. */
}
编译、链接成内核镜像
i686-elf-as boot.s -o boot.o
i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra
i686-elf-gcc -T linker.ld -o myos.bin -ffreestanding -O2 -nostdlib boot.o kernel.o -lgcc
你应该可以看到上面的程序合成了一个内核镜像myos.bin。
运行你的内核!
安装xorriso,安装QEMU
把下面的代码保存为grub.cfg文件。
menuentry "myos" {
multiboot /boot/myos.bin
}
- 通过
grub-mkrescue把myos.bin打包成GRUB能直接用的CD-ROM镜像文件myos.iso
mkdir -p isodir/boot/grub
cp myos.bin isodir/boot/myos.bin
cp grub.cfg isodir/boot/grub/grub.cfg
grub-mkrescue -o myos.iso isodir
- 虚拟环境启动:运行
qemu-system-i386 -cdrom myos.iso或者qemu-system-i386 -kernel myos.bin,都能启动内核,但可以看出前者调出了GRUB的图形界面,而后者没有。 - 真实环境启动:把myos.iso烧录至你的U盘、光盘或磁盘,然后插在电脑上,选择用外部存储介质启动!(我没敢试,哈哈)
(全文完)