制作内核需要什么?

内核这个。内核那个。人们经常提到这个操作系统或那个操作系统的内核,但并不真正了解它做什么,如何工作,或者制作一个内核需要什么。编写自定义(非 Linux)内核需要什么?

那么,我在这里要做什么呢?在 2018 年 6 月,我写了一篇指南,教你如何从源代码包构建完整的 Linux 发行版,在 2019 年 1 月,我扩展了该指南,在原始指南中添加了更多软件包。现在是深入研究自定义操作系统主题的时候了。本文描述了如何从头开始编写您自己的内核,然后启动到其中。听起来很简单,对吧?现在,不要激动。这个内核不会做太多事情。它会在屏幕上打印一些消息,然后停止 CPU。当然,您可以在此基础上构建并创建更多内容,但这并不是本文的目的。我的主要目标是让您,读者,深入了解内核是如何编写的。

很久以前,在很久远的时代,嵌入式 Linux 并不是真正存在的东西。我知道这听起来有点疯狂,但这是真的!如果您使用微控制器,您会从供应商那里获得规范、设计图、所有寄存器的手册,仅此而已。翻译:您必须从头开始编写自己的操作系统(包括内核)。 虽然本指南假设标准的通用 32 位 x86 架构,但其中很多内容反映了过去必须做的事情。

以下练习要求您在您喜欢的 Linux 发行版中安装一些软件包。例如,在 Ubuntu 机器上,您将需要以下软件包

  • binutils
  • gcc
  • grub-common
  • make
  • nasm
  • xorriso
汇编语言速成课程

注意:我将通过假装使用一个不太复杂的 8 位微处理器来简化事情。这并不反映现代(也可能是过去的)任何商业处理器的设计。

当微处理器的设计者创建一个新的芯片时,他们会为其编写一些非常专业的微代码。该微代码将包含通过操作码或操作码访问的已定义操作。这些已定义的操作码包含指令(对于微处理器)来加、减、移动值和地址等等。处理器将读取这些操作码作为更大命令格式的一部分。此格式将由包含一系列二进制数字的字段组成——即 0 和 1。请记住,此处理器仅理解高电平(1)和低电平(0)信号,当这些信号(作为指令的一部分)以正确的顺序馈送给它时,处理器将解析/解释该指令,然后执行它。

以下是虚构处理器的命令结构概述

  • 0, 1 — opcode
  • 2, 3 — source 1
  • 4, 5 — source 2
  • 6, 7 — destination

现在,汇编语言到底是什么?它是编程微处理器时最接近机器代码的语言。它是基于机器支持的指令集的可读代码,而不仅仅是一系列二进制数字。我想您可以记住每个指令的所有二进制数字(按正确的顺序),但这没有多大意义,特别是如果您可以使用更易于阅读的命令来简化代码编写的话。

这个虚构且完全不真实的处理器仅支持四个指令,其中 ADD 指令映射到二进制代码 00 的操作码,SUB(或减法)映射到二进制代码 01 的操作码。您将访问四个 CPU 内存寄存器:A 或 00、B 或 01、C 或 10 和 D 或 11。

使用上述命令结构,您编译的代码将发送以下指令


ADD A, B, C

或者,以以下二进制机器语言格式“将 A 和 B 的内容相加并存储到寄存器 C 中”


00000110

假设您想从 C 中减去 A 并将其存储在 D 寄存器中。人类可读的代码如下所示


SUB C, A, D

并且,它将转换为以下机器代码,供处理器的微代码处理


01100011

正如您所预料的那样,芯片越先进(16 位、32 位、64 位),支持的指令越多,地址空间也越大。

引导代码

我在本教程中使用的汇编器称为 NASM。开源 NASM,或 Net-Wide Assembler,将汇编汇编代码为称为目标代码的文件格式。生成的目标文件是生成可执行二进制文件或程序的中间步骤。此中间步骤的原因是,单个大型源代码文件最终可能会被分割成较小的源代码文件,以使其在大小和复杂性方面更易于管理。例如,当您编译 C 代码时,您将指示 C 编译器仅生成目标文件。所有目标代码(从您的 ASM 和 C 文件创建)将构成您内核的各个部分。为了完成编译,您将使用链接器来获取所有必要的目标文件,将它们组合起来,然后生成程序。

以下代码应写入并保存在名为 boot.asm 的文件中。您应该将该文件存储在项目的专用工作目录中。

boot.asm


bits 32

section .multiboot               ;according to multiboot spec
        dd 0x1BADB002            ;set magic number for
                                 ;bootloader
        dd 0x0                   ;set flags
        dd - (0x1BADB002 + 0x0)  ;set checksum

section .text
global start
extern main                      ;defined in the C file

start:
        cli                      ;block interrupts
        mov esp, stack_space     ;set stack pointer
        call main
        hlt                      ;halt the CPU

section .bss
resb 8192                        ;8KB for stack
stack_space:

所以,这看起来像是一堆无意义的胡言乱语,对吧?事实并非如此。同样,这应该是人类可读的代码。例如,在 multiboot 部分下,并按照 multiboot 规范的正确顺序(请参阅下面标记为“参考”的部分),您正在定义三个双字变量。等等,什么?什么是双字?好吧,让我们退一步。汇编 DD 伪指令转换为定义双字(word),在 x86 32 位系统上为 4 字节(32 位)。DW 或定义字是 2 字节(或 16 位),再进一步,DB 或定义字节是 8 位。将其视为高级编程语言中的整数短整型长整型

注意:伪指令不是真正的 x86 机器指令。它们是汇编器支持的特殊指令,用于帮助汇编器方便内存初始化和空间预留。

multiboot 部分下方,您有一个标记为 text 的部分,紧随其后的是一个标记为 start 的函数。此 start 函数将为您的主内核代码设置环境,然后执行该内核代码。它以 cli 开头。CLI 命令,或清除中断标志,清除 EFLAGS 寄存器中的 IF 标志。以下行将空的 stack_space 函数移动到堆栈指针中。堆栈指针是微处理器上的一个小寄存器,其中包含程序从称为堆栈的后进先出 (LIFO) 数据缓冲区发出的最后一个请求的地址。示例汇编程序将调用在您的 C 文件中定义的 main 函数(见下文),然后停止 CPU。如果您向上看,这将通过 extern main 行告诉汇编器,此函数的代码存在于此文件之外。

内核的主函数

所以,您编写了引导代码,并且您的引导代码知道它需要加载到一个外部 main 函数中,但是您没有外部 main 函数——至少现在还没有。在同一个工作目录中创建一个文件,并将其命名为 kernel.c。该文件的内容应如下所示

kernel.c


#define VGA_ADDRESS 0xB8000   /* video memory begins here. */

/* VGA provides support for 16 colors */
#define BLACK 0
#define GREEN 2
#define RED 4
#define YELLOW 14
#define WHITE_COLOR 15

unsigned short *terminal_buffer;
unsigned int vga_index;

void clear_screen(void)
{
    int index = 0;
    /* there are 25 lines each of 80 columns;
       each element takes 2 bytes */
    while (index < 80 * 25 * 2) {
            terminal_buffer[index] = ' ';
            index += 2;
    }
}

void print_string(char *str, unsigned char color)
{
    int index = 0;
    while (str[index]) {
            terminal_buffer[vga_index] = (unsigned
             ↪short)str[index]|(unsigned short)color << 8;
            index++;
            vga_index++;
    }
}

void main(void)
{
    /* TODO: Add random f-word here */
    terminal_buffer = (unsigned short *)VGA_ADDRESS;
    vga_index = 0;

    clear_screen();
    print_string("Hello from Linux Journal!", YELLOW);
    vga_index = 80;    /* next line */
    print_string("Goodbye from Linux Journal!", RED);
    return;
}

如果您滚动到 C 文件的底部并查看 main 函数内部,您会注意到它执行以下操作

  • 将视频内存的起始地址分配给字符串缓冲区。
  • 重置字符串缓冲区中您所在位置的内部位置标记。
  • 清除终端屏幕。
  • 打印一条消息(一种颜色)。
  • 为下一行设置内部位置标记。
  • 打印另一条消息(另一种颜色)。
  • 并且,返回到引导代码(如果您还记得,它会停止 CPU)。

在当前的 x86 架构中,您的视频内存以保护模式运行,并从内存地址 0xB8000 开始。因此,所有与视频相关的内容都将从该地址空间开始,并将支持最多 25 行,每行 80 个 ASCII 字符。此外,正在运行的视频模式最多支持 16 种颜色(我在 C 文件的顶部添加了一些颜色供您使用)。

在这些视频定义之后,定义了一个全局数组以映射到视频内存,并定义了一个索引以了解您在视频内存中的位置。例如,索引从 0 开始,如果您想移动到屏幕上下一行的第一个字符空间,您需要将索引增加到 80,依此类推。

正如以下两个函数的名称所暗示的那样,第一个函数使用 ASCII 空字符清除整个屏幕,第二个函数写入您传递给它的任何字符串。请注意,视频内存缓冲区的预期输入是每个字符 2 个字节。两个字节中的第一个是您要输出的字符,第二个是颜色。这在 print_string() 函数中更加明显,其中颜色代码实际上被传递到函数中。

无论如何,在这两个函数之后是 main 例程,其操作已在上面提及。请记住,这是一个学习练习,除了在屏幕上打印一些内容外,此内核不会执行任何特殊操作。除了添加真正的功能外,此内核代码绝对缺少一些亵渎性内容。(您可以稍后添加。)

在现实世界中……

每个内核都会有一个 main() 例程(由引导加载程序生成),并且在该主例程中,将进行所有适当的系统初始化。在真实且功能正常的内核中,主例程最终将进入一个无限 while() 循环,所有未来的内核函数都将在此循环中发生,或者生成一个线程来完成几乎相同的事情。Linux 也是这样做的。引导加载程序将调用 init/main.c 中找到的 start_kernel() 例程,而该例程又将生成一个 init 线程。

将所有内容链接在一起

如前所述,链接器具有非常重要的用途。它将获取所有随机目标文件,将它们放在一起,并提供一个可引导的单个二进制文件(您的内核)。

linker.ld


OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
SECTIONS
 {
   . = 1M;
   .text BLOCK(4K) : ALIGN(4K)
   {
       *(.multiboot)
       *(.text)
   }
   .data : { *(.data) }
   .bss  : { *(.bss)  }
 }

让我们将输出格式设置为 32 位 x86 可执行文件。此二进制文件的入口点是汇编文件中的 start 函数,该函数最终加载 C 文件中的 main 程序。更进一步,这实际上是在告诉链接器如何合并您的目标代码以及在什么偏移量处合并。在链接器文件中,您明确指定加载内核二进制文件的地址。在本例中,它是 1M 或 1 兆字节偏移量。这是主内核代码预期所在的位置,引导加载程序将在加载时在此处找到它。

启动内核

这项工作最令人兴奋的部分是,您可以利用非常流行的 GRand Unified Bootloader (GRUB) 来加载您的内核。为此,您需要创建一个 grub.cfg 文件。目前,将以下内容写入该名称的文件中,并将其保存在您当前的工作目录中。当构建 ISO 映像时,您将把此文件安装到其适当的目录路径中。

grub.cfg


set timeout=3

menuentry "The Linux Journal Kernel" {
        multiboot /boot/kernel
}

编译时间

boot.asm 构建为目标文件


$ nasm -f elf32 boot.asm -o boot.o

kernel.c 构建为目标文件


$ gcc -m32 -c kernel.c -o kernel.o

链接两个目标文件并创建最终的可执行程序(即您的内核)


$ ld -m elf_i386 -T linker.ld -o kernel boot.o kernel.o

现在,您应该在同一个工作目录中有一个名为 kernel 的已编译文件


$ ls
boot.asm  boot.o  grub.cfg  kernel  kernel.c  kernel.o
 ↪linker.ld

此文件是您的内核。您很快将启动到该内核中。

构建可引导的 ISO 映像

使用以下目录路径创建暂存环境(从您当前的工作目录路径开始)


$ mkdir -p iso/boot/grub

让我们再次检查内核是否为 multiboot 文件类型(预期没有输出,返回代码为 0)


$ grub-file --is-x86-multiboot kernel

现在,将内核复制到您的 iso/boot 目录中


$ cp kernel iso/boot/

并且,将您的 grub.cfg 复制到 iso/boot/grub 目录中


$ cp grub.cfg iso/boot/grub/

制作指向您当前工作目录路径中 iso 子目录的最终 ISO 映像

$ grub-mkrescue -o my-kernel.iso iso/
xorriso 1.4.8 : RockRidge filesystem manipulator,
 ↪libburnia project.

Drive current: -outdev 'stdio:my-kernel.iso'
Media current: stdio file, overwriteable
Media status : is blank
Media summary: 0 sessions, 0 data blocks, 0 data, 10.3g free
Added to ISO image: directory '/'='/tmp/grub.fqt0G4'
xorriso : UPDATE : 284 files added in 1 seconds
Added to ISO image: directory
 ↪'/'='/home/petros/devel/misc/kernel/iso'
xorriso : UPDATE : 288 files added in 1 seconds
xorriso : NOTE : Copying to System Area: 512 bytes from file
 ↪'/usr/lib/grub/i386-pc/boot_hybrid.img'
ISO image produced: 2453 sectors
Written to medium : 2453 sectors at LBA 0
Writing to 'stdio:my-kernel.iso' completed successfully.

附加说明

假设您想通过自动化构建最终映像的整个过程来扩展本教程。完成此操作的最佳方法是将 Makefile 放入项目的根目录中。以下是 Makefile 的示例

Makefile


CP := cp
RM := rm -rf
MKDIR := mkdir -pv

BIN = kernel
CFG = grub.cfg
ISO_PATH := iso
BOOT_PATH := $(ISO_PATH)/boot
GRUB_PATH := $(BOOT_PATH)/grub

.PHONY: all
all: bootloader kernel linker iso
    @echo Make has completed.

bootloader: boot.asm
    nasm -f elf32 boot.asm -o boot.o

kernel: kernel.c
    gcc -m32 -c kernel.c -o kernel.o

linker: linker.ld boot.o kernel.o
    ld -m elf_i386 -T linker.ld -o kernel boot.o kernel.o

iso: kernel
    $(MKDIR) $(GRUB_PATH)
    $(CP) $(BIN) $(BOOT_PATH)
    $(CP) $(CFG) $(GRUB_PATH)
    grub-file --is-x86-multiboot $(BOOT_PATH)/$(BIN)
    grub-mkrescue -o my-kernel.iso $(ISO_PATH)

.PHONY: clean
clean:
        $(RM) *.o $(BIN) *iso

要构建(包括最终 ISO 映像),请输入


$ make

要清除所有构建对象,请输入


$ make clean

关键时刻

您现在有了一个 ISO 映像,如果您正确地完成了所有操作,您应该能够从物理机器上的 CD 或虚拟机(例如 VirtualBox 或 QEMU)启动到其中。配置虚拟机的配置文件以从 ISO 启动后,启动虚拟机。您将立即看到 GRUB(图 1)。

""

图 1. GRUB 引导加载程序倒计时以加载内核

超时时间过后,内核将启动。

""

图 2. Linux Journal 内核已启动。是的,它只做这些。

总结

您成功了!您从头开始编写了自己的内核。再说一次,它没有做太多事情,但您肯定可以在此基础上进行扩展。现在,如果您能原谅我,我需要在 USENET 新闻组 comp.os.minix 上发布一条消息,内容是关于我如何开发了一个新的内核,并且它不会像 GNU 那样庞大和专业

资源

Petros Koutoupis,LJ 特约编辑,目前是 Cray 公司 Lustre 高性能文件系统部门的高级性能软件工程师。他还是 RapidDisk 项目的创建者和维护者。Petros 在数据存储行业工作了十多年,并帮助开创了当今广泛使用的许多技术。

加载 Disqus 评论