系列目录

kernel 的世界

接上一篇 加载并进入 kernel,我们终于来到了kernel 的大门,本篇开始将正式展开 kernel 阶段的工作。有一个好消息是我们终于可以开始以 C 语言为主的编程,似乎可以告别汇编的汪洋大海了,不过汇编仍然会在后面用到,它们都是小规模地出现,但都处于十分重要的关键节点上。

总的来说,kernel 的主要任务将包括以下几个部分:

  • 建立完善的内存管理机制,这主要包括了 virtual memory,以及 heap / kmalloc 的实现;
  • 建立多任务管理系统,即 thread / process 的运行和管理;
  • 实现简单的硬件驱动,主要是 diskkeyboard
  • 实现用户态程序的加载和运行,提供系统调用(system call);

不过在开始之前,我们需要做一些前期准备工作,其中很重要的一项就是屏幕显示,毕竟总得能有些看得见摸得着的东西,才能让我们能持续获得一些正反馈,而且其中 print 相关的函数也是对后面的开发调试至关重要。所以本篇的主要内容就是对屏幕显示的控制,以及打印 string 等功能的开发,相对而言没什么难度,轻松愉快。

本篇的实现我主要是参考了之前推荐的 JamesM's kernel development tutorials,它讲的还是很清楚的,你也可以参考下。

VGA 显示

按惯例,首先给出本篇的代码,主要在 src/monitor/ 目录下。

我们用到的是 VGA text mode,一种古老的显示模式,它的原理简单来说就是用 32KB 内存来控制一个 25 行 * 80 列 的屏幕终端。这 32KB 内存被映射到了哪里呢?

答案是低 1MB 内存的 0xB800 ~ 0xBFFF 这一段,我们可以通过访问并修改这一段内存的值来控制屏幕显示。

当然我们已经打开 paging 并进入了 kernel,低 1MB 的内存已经被映射到了 0xC0000000 以上,所以我们可以使用 0xC000B800 ~ 0xC000BFFF 来访问,即图中深蓝色部分。

我们在代码里定义了显示内存的地址:

// The VGA framebuffer starts at 0xB8000.
uint16* video_memory = (uint16*)0xC00B8000;

上面说了屏幕上有 25 * 80 = 2000 个字符,每个字符需要使用 2 个 byte 控制,这样一屏幕就是 4000 个 byte,所以 32 KB 可以容纳大约 8 屏的内容。不过虽然有 8 屏幕的数据,我们为了简单起见,只控制第一屏幕的数据,超出部分就不予显示,也不支持上下翻屏等功能。

要在屏幕上某处打印字符,就是去修改(0xC00B8000 + 对应偏移量) 的位置上的内存就可以了。

字符显示

在屏幕上,一个字符由 2 个 byte 控制,我直接贴 wiki 百科上的图了:

其中低 byte 存储了字符的 ASCII 值,高 byte 则控制颜色(包括前景色和背景色)和闪烁, 非常简单。

3 个 bit 可以显示 8 种颜色:

#define COLOR_BLACK     0
#define COLOR_BLUE      1
#define COLOR_GREEN     2
#define COLOR_CYAN      3
#define COLOR_RED       4
#define COLOR_FUCHSINE  5
#define COLOR_BROWN     6
#define COLOR_WHITE     7

前面再加上一个 bit 可以控制高亮或者普通,注意只有前景色是 4-bit 可以支持这个:

#define COLOR_LIGHT_BLACK     8
#define COLOR_LIGHT_BLUE      9
#define COLOR_LIGHT_GREEN     10
#define COLOR_LIGHT_CYAN      11
#define COLOR_LIGHT_RED       12
#define COLOR_LIGHT_FUCHSINE  13
#define COLOR_LIGHT_BROWN     14
#define COLOR_LIGHT_WHITE     15

光标控制

除了字符外,屏幕上还有一个重要的角色就是光标,一般用来标记了当前所处的位置。但实际上光标位置和打印字符的位置完全没有任何关系,你只要指定了坐标,可以在任何地方打印字符,而让光标在远处看寂寞。不过通常按照习惯,我们总是让光标在下一个打印位置上闪烁。

所以代码里定义了光标的位置:

// Stores the cursor position.
int16 cursor_x = 0;
int16 cursor_y = 0;

更新光标位置,需要对几个硬件端口进行操作:

static void move_cursor_position() {
  // The screen is 80 characters wide.
  uint16 cursorLocation = cursor_y * 80 + cursor_x;
  // Tell the VGA board we are setting the high cursor byte.
  outb(0x3D4, 14);
  // Send the high cursor byte.
  outb(0x3D5, cursorLocation >> 8);
  // Tell the VGA board we are setting the low cursor byte.
  outb(0x3D4, 15);
  // Send the low cursor byte.
  outb(0x3D5, cursorLocation);
}

outb 函数,以及它对应的 inb 函数,定义在 src/common/io.c 里,是操作端口用的函数。

打印字符

下面我们需要定义几个 print 功能的函数,最基础的当然是打印一个字符:

void monitor_write_char_with_color(char c, uint8 color);

详细的代码我不贴了,主要几个步骤:

  • 拼出这个打印的字符的 2-bytes 表示;
  • 在当前光标的位置上打印这个字符,其实就是把 2-bytes 赋值给相应位置的显示内存上;
  • 滚动屏幕,如果需要的话(溢出了最后一行);
  • 将光标移动到下一个位置;

有了最基础的打印一个字符的功能,接下来就可以实现字符串,十进制,十六进制整数的打印等功能,这样 print 相关的函数就比较丰富了,可以满足我们的很多需要,不过其中我认为最重要的一个函数还没有实现,那就是 printf

printf 的实现

就像 C 标准库里的 printf,它需要能支持多个模板参数:

void printf(char* str, ...);

那应该如何实现这样的函数?

其实我也不太清楚正确的做法应该是什么,这里只是介绍我个人的实现方式。这里关键就是需要能获取省略号部分的可变参数,而它们其实在 printf 函数调用时被压到了 stack 上:

因此,后面的可变参数起始位置就在 ebp + 12 的位置处。

void monitor_printf(char* str, ...) {
  void* ebp = get_ebp();
  void* arg_ptr = ebp + 12;
  monitor_printf_args(str, arg_ptr);
}

get_ebp 这个函数定义在了 中,非常简单:

[GLOBAL get_ebp]
get_ebp:
  mov eax, ebp
  ret

其实还有一个更简单的方法就是用 char* str 的地址加 4,也可以得到后面参数的地址。

当然这个方法获取参数的方法其实并不是严谨的,它完全依赖于体系架构和编译器的行为。当前这个方案只适合于 32 位 x86 架构,并且要在目前给出的编译选项下才行得通。如果想要支持更多的平台和编译器,还需要做一些扩展。不过对于我们的项目而言,它应该是完全够用的,毕竟这只是一个教学实践用的系统,不必过于苛求这些。

03-05 23:58