kernel 101 - 动手写内核

目录 [−]

  1. X86机器是怎样启动的?
  2. 我们需要哪一些工具?
  3. 源码
  4. 用汇编代码来编写内核入口
  5. 用C实现的内核
  6. 链接部分
  7. Grub和多重引导
  8. 生成内核
  9. 配置grub, 启动内核

Arjun Sreedharan写的内核编写教程。 超级简单。
目前写了两篇:
Kernel 101 – Let’s write a Kernel
Kernel 201 - Let’s write a Kernel with keyboard and screen support

国内也有人翻译了,如

以下是我根据上面的文章整理的翻译。

Hi, 大家好。

在这篇文章中,我们将从零开始,动手编写一个可以用GRUB来引导的简单x86内核,该内核会在屏幕上打印一条信息后挂起。

X86机器是怎样启动的?

在我们思考怎样写一个内核之前,让我们先看一下x86机器从启动到把控制权交给内核的过程是怎样的:
x86 CPU在机器启动之后就会从地址 [0xFFFFFFF0]处开始执行,这个地址就是在32位寻址空间中的最后16个字节处,这里存放了一条跳转指令,会跳转到内存中BIOS代码起始处。
接着,cpu就开始开始执行BIOS代码块了,BIOS首先会在我们配置好的启动设备序列中,通过检查一个特定的魔数,找到第一个可以引导的设备。
一旦BIOS找到一个可以引导的设备后,它就会把该设备第一个扇区的代码复制到物理内存的[0x7c00]的位置,然后跳转到这个地址开始执行这一段代码,我们习惯把这一段代码叫作bootloader
Bootloader会将内核代码加载到物理内存[0x100000]的位置,[0x100000]这个地址是所有x86机器宏内核代码的起始地址。

我们需要哪一些工具?

  • 一个x86构架的计算机
  • Linux
  • NASM 汇编器
  • GCC
  • LD(GNU 连接器)
  • GRUB

源码

源代码可以在我的Github找到 Github repository - mkernel

用汇编代码来编写内核入口

我们喜欢用c来做所有的事情,但是我们无可避免地需要用到一点儿汇编,我们将会写一小段x86的汇编代码来作为内核入口,这一段汇编代码会在调用我们的c代码后停止整个程序流程。
我们怎样确认汇编代码会作为内核的起始点呢?

我们将用一个连接器脚本将这些目标文件链接成我们最终的内核程序(稍后解释更多),在连接器脚本里,我们指定了这段二进制代码会被加载到内存 [0x100000]处。这个地址就是我之前说过的,内核所希望的起始地址。

汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
;;kernel.asm
bits 32 ;nasm directive - 32 bit
section .text
global start
extern kmain ;kmain is defined in the c file
start:
cli ;block interrupts
call kmain
hlt ;halt the CPU

第一条指令 bit32 不是x86汇编指令,它是一条NASM 指令,指定nasm汇编器产生32位的程序,这条语句并不是必不可少的,但加上它是一个好的编程习惯。
第二行是text段(代码段)的开始,在这里存放着我们的代码块。
global是另外一个NASM指令,用将一个符号设置为全局符号。这样做连接器才会知道符号start在哪儿开始,start是我们程序的入口地址。
kmain是我们定义在kernel.c文件中的函数。
extern关键字声明了该函数定义在别的文件中。
到这里,我们的函数start调用kmian函数之后就会使用hlt指令将CPU挂起,中断会cpu从hlt指令中唤醒,我们要在挂起之前用cli指令来关闭系统的中断响应,cli指令是清除中断(clear-interrupts)的缩写。

用C实现的内核

kernle.asm中,我们调用了kmain()函数,所以我们的c代码将会在kmain()中开始运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
* kernel.c
*/
void kmain(void)
{
char *str = "my first kernel";
char *vidptr = (char*)0xb8000; //video mem begins here.
unsigned int i = 0;
unsigned int j = 0;
//clear all
while(j < 80 * 25 * 2) {
//blank character
vidptr[j] = ' ';
//attribute-byte: light grey on black screen
vidptr[j+1] = 0x07;
j = j + 2;
}
j = 0;
while(str[j] != '\0') {
vidptr[i] = str[j];
vidptr[i+1] = 0x07;
++j;
i = i + 2;
}
return;
}

我们的内核首先会清空整个屏幕,然后打印出字符串。
首先,我们用一个vidptr指针,指向地址[0xb8000], 这个地址是保护模式下显存的起始地址。屏幕的文本内容对应着的内存空间中一个内存段,即屏幕的输出输出映射到了内存中地址[0xb8000]的地方,整个屏幕共支持25行,每行80个ASCII字符。
在文本内存中每一个字符由16bits(2个字节)表示,这不像我们以前使用8bits来定义。其中第一个字节是该字符的ASCII码,第二个字节是属性字节attribute-byte, 它描述了字符的表现形式,包括了字符颜色等属性。
为了在黑色的背景下打印绿色字符s,我们将字符s放在显存中的第一个字节,接着将[0x02]放在第二个字节中, 其中 0表示黑色背景,2表示绿色前景。

下面是不同颜色的定义:

1
0 - Black, 1 - Blue, 2 - Green, 3 - Cyan, 4 - Red, 5 - Magenta, 6 - Brown, 7 - Light Grey, 8 - Dark Grey, 9 - Light Blue, 10/a - Light Green, 11/b - Light Cyan, 12/c - Light Red, 13/d - Light Magenta, 14/e - Light Brown, 15/f – White.

在我们的内核中,我们将字符颜色设置为灰色,将背景颜色设定为黑色,因此我们的属性字节的值是[0x07].
在第一个while循环中,程序将属性值为[0x07]的空格字符(‘ ’)写到整个屏幕中(共25行,每行80个字符),这样就会将整个屏幕清空了。
在第二个while循环中,我们将null结尾的字符串 “my first kernel” ,从显存的起始处开始写入。
这样字符串就打印在屏幕上了

链接部分

我们用NASM,GCC分别将kernale.asmkernel.c编译成目标文件,接着将这些目标文件链接成一个可引导的内核程序。
我们指定ld连接器按照我们脚本规定来进行链接。

1
2
3
4
5
6
7
8
9
10
11
12
/*
* link.ld
*/
OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
SECTIONS
{
. = 0x100000;
.text : { *(.text) }
.data : { *(.data) }
.bss : { *(.bss) }
}

脚本指定了输出格式为 32位的ELF文件格式. ELF(Executable and Linkable Format))是x86构架的类Unix系统标准的二进制格式。
ENTRY 接收一个参数。它指定了可执行文件的入口符号。
SECTIONS 对我们来讲是最重要的。在这里,我们定义即将生成的可执行文件的布局。我们可以定义各个段链接融合的方式以及放置的位置。
SECTIONS 后的花括号中,符号 (.) 表示的是一个位置计数器。它通常会被初始化为[0x0],作为SECTIONS 块的起始地址 ,它的值是可以被修改的。 之前我说过,内核代码需要在地址[0x100000]处,所以我们将它修改为[0x100000]

接着看下一行的 .text : { *(.text) }
星号( )是一个通配符,表示所有的文件名。`(.text)表示将所有输入文件的.text` 段
因此,按照这个设定,连接器将所有目标文件的text段融合到最终可执行文件的text 段中,即在位置计数器所标识的地址处 ([0x100000])。
在连接器将处理好输出的text段后,地址计数器的值会变为[0x100000]+text段的长度。

类似的,data段和bss段也会相应得融合后放置到地址计数器所标识的位置。

Grub和多重引导

现在我们已经准备好所有制作内核所需的文件了,但我们还有一步工作,我们还需要用grub Bootloader来启动我们的内核。
在按照Mutileboot 规范来编译我们的内核后,它就可以被GRUB引导了。
按照Mutileboot 的规范说明,内核必须在起始的8KB中包含这一个多引导项头(Multiboot header)。
而且,这个多引导项头里面必须有3个4字节对齐的块。

  • 一个魔术块:包含了魔数[0x1BADB002],是多引导项头结构的定义值。
  • 一个标志块:我们不关心这个块的内容,我们简单设定为0。
  • 一个校检块:校检块,魔术块和标志块的数值的总和必须是0。

因此,我们的内核代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
;;kernel.asm
;nasm directive - 32 bit
bits 32
section .text
;multiboot spec
align 4
dd 0x1BADB002 ;magic
dd 0x00 ;flags
dd - (0x1BADB002 + 0x00) ;checksum. m+f+c should be zero
global start
extern kmain ;kmain is defined in the c file
start:
cli ;block interrupts
call kmain
hlt ;halt the CPU

dd 指令定义了个4字节的双字。

生成内核

我们现在开始将kernel.asm和kernel.c编译成目标文件,接着将它们根据我们的连接器脚本的设定链接到一起:

1
nasm -f elf32 kernel.asm -o kasm.o

启动NASM汇编器将kernel.asm编译成ELF-32位格式的目标文件。

1
gcc -m32 -c kernel.c -o kc.o

-c选项告知GCC编译器在将源文件编译成目标文件后,不要对它们进行链接。

1
ld -m elf_i386 -T link.ld -o kernel kasm.o kc.o

启动链接器,根据我们的链接脚本生成一个名为kernel的可执行的文件。

配置grub, 启动内核

GRUB 需要以kernel-<version>的形式来命名内核程序,所以,我将它重名为kernel-701.
接着将它放在/boot目录下,这一步需要你需要拥有超级用户权限才能够进行操作。
在你的GRUB配置文件grub.cfg中加上一个引导入口,如下:

1
2
3
title myKernel
root (hd0,0)
kernel /boot/kernel-701 ro

如果存在一个hiddenmenu的指令,记得要把它移除掉。
重启电脑,你就能够看到你的内核也在启动选择项列表中了。
选择启动它之后,结果如下:

好了,你实现了一个简单的内核。

PS:

  • 建议你在虚拟机中进行你所有内核hacking。
  • 在一些新的发行版中,使用了grub2作为默认的bootloader,你需要向下面这样来配置你的配置文件。

    (感谢 Rubén Laguna提供了grub2的配置)

1
2
3
4
menuentry 'kernel 7001' {
set root='hd0,msdos1'
multiboot /boot/kernel-7001 ro
}
  • 如果你想用qemu模拟器代替GRUB来启动你的内核程序的话,你可以怎么做:
1
qemu-system-i386 -kernel kernel

本系列的下一篇文章。
Kernel 201 - Let’s write a Kernel with keyboard and screen support