65.9K
CodeProject 正在变化。 阅读更多。
Home

操作系统开发入门,第二部分

2009 年 8 月 18 日

CPOL

13分钟阅读

viewsIcon

91490

downloadIcon

1176

C++ 支持代码和控制台。

引言

  1. 环境设置
  2. C++ 支持代码和控制台
  3. 描述符表和中断
  4. 实时时钟、可编程中断定时器和键盘控制器

我将从一开始就假设您已经阅读并遵循了之前的文章。如果您没有,那么接下来的内容对您来说将毫无用处。您可以 此处 阅读之前的文章。

我们需要的第一件事是一些支持代码。G++ 会期望其中的一些,但我们需要添加其他一些来避免稍微更微妙的错误。为此,我们将添加的代码将

  • 调用构造函数
  • 提供 newdelete 实现存根
  • 提供一个当 GCC 无法调用虚方法时被调用的方法

除了这三项之外,就是这样了。一些操作系统会在内核代码的末尾调用析构函数,但我看不出有什么意义,因为大多数多任务操作系统会在 Main 的末尾无限循环,以便计时器 IRQ 能够捕获它们(稍后会详细介绍)。

首先,我们需要调用构造函数。我们的链接器脚本告诉我们这些构造函数指针的开始和结束位置。要调用它们,我们只需遍历它们即可。链接器有可能将它们放置在错误的顺序,但这极不可能。考虑到本文的范围,您可以信任 GCC。

要调用构造函数,我们使用此代码

extern "C" void LoadConstructors()
{
    //This function pointer refers to the first global constructor
    extern int (*firstConstructor)();
    //Same as the above, but refers to the end of the constructor list
    extern int (*lastConstructor)();
    //This represents a pointer to an individual constructor
    int (**constructor)();

    for(constructor = &firstConstructor; constructor < 
                      &lastConstructor; constructor++)
        (*constructor)();
}

现在,重要的是要认识到我们需要递增*函数指针的指针*,而不是函数指针本身。这是因为否则我们可能会过度,并开始运行我们的局部变量(请记住,汇编代码就像数字序列一样,就像我们的局部变量一样)。

您需要理解的一个问题是名称修饰。这是 G++ 允许函数重载、类等的手段。其要点是 G++ 将参数类型(和其他一些东西)编码到方法名称中。虽然这对我们来说通常非常方便,但当我们要从汇编语言调用这些方法时,它有明显的缺点。要覆盖此机制,我们必须使用 extern "C" 关键字。这些关键字简化了汇编的调用约定。我将假设您知道如何使用它。如果不知道,源代码应该会有帮助,但您也应该自己研究。

接下来的两个支持项只是存根。当 G++ 通过 new 遇到对象创建时,它会分配所需的内存、进行类型转换并调用相关构造函数。我们现在不需要这样做,所以只需将这些存根添加到您的通用代码文件中(我假设您维护着一个整洁的源代码树——如果您没有,您以后只会给自己增加麻烦)。

extern "C" void __cxa_pure_virtual()
{
    //This doesn't need to have an implementation. 
    //If a virtual call cannot be made, nothing needs to be done
}

//These methods will require an implementation when you implement a memory manager
void *operator new(long unsigned int size)
{
    return (void *)0;
}

void *operator new[](long unsigned int size)
{
    return (void *)0;
}

void operator delete(void *p)
{
}

void operator delete[](void *p)
{
}

这些应该相当明显——new 需要调用您的内存分配例程,而 delete 只是释放分配的内存。您无法获得比这更简单的了。

唯一需要解释的方法是 __cxa_pure_virtual。这是 G++ 要求我们的唯一强制性的魔法方法名称。如果由于某种原因无法进行虚调用,就会调用此方法。您可以检查调用堆栈来找出是哪个方法导致了问题,但您不必这样做。您可以将其留空。

这样,C++ 支持代码就完成了。现在,您只需要一个可以接管 GrUB 离开的地方的程序。为此,我们需要使用汇编代码,当我们编写内核时,汇编代码就是我们最接近原始二进制的方式。

汇编代码

我们的汇编代码必须做两件事。首先,它必须设置堆栈。虽然这不是严格必需的,但了解一切确切位置总是好的。我们通过将相同的指针 MOVESPEBP 来做到这一点。我们可以通过在 BSS 段中预留一些字节来设置我们自己的内存段。

它还必须与 GrUB 进行接口。GrUB 通过可执行文件开头的值知道这是一个有效的 Multiboot 内核。首先是一个魔术值,然后是标志,最后是校验和。校验和是使这三个字段的总和为零所需的任何值。指向 Multiboot 信息结构的指针位于 EBX 中,因此我们需要将其传递给我们的 C++ 代码。

完成这些之后,它只需要调用 Main 并循环。这可以防止处理器执行程序末尾可能位于内存中的任何值。这些值可能随机变化,因为内存中的电“残余”会随着时间推移而衰减。在 Bochs 或 Virtual PC 等模拟器中,我们很幸运——内存被清零了,所以我们可以很快知道这一点。但是,如果您在实际硬件上运行它,您可能就不那么幸运了,这可能需要很长时间才能显现。最好从一开始就避免它。

这样,让我们看一些代码。这段代码在整个文章中都不会改变,但您不太可能得到更多现成的代码。希望您不是逐字复制粘贴代码——如果只是复制粘贴所有内容并尝试在此基础上构建您的更改,那不是“您的”内核。

; Define some constants, just to make life easier for us
STACKSIZE           equ 0x8000

MBOOT_PAGE_ALIGN    equ 1 << 0   ; Load kernel and modules on a page boundary
MBOOT_MEM_INFO      equ 1 << 1   ; Provide the kernel with memory info
MBOOT_GRAPHICS      equ 1 << 2

MBOOT_HEADER_MAGIC  equ 0x1BADB002     ; Multiboot Magic value
MBOOT_HEADER_FLAGS  equ MBOOT_PAGE_ALIGN | MBOOT_MEM_INFO | MBOOT_GRAPHICS
MBOOT_CHECKSUM      equ -(MBOOT_HEADER_MAGIC + MBOOT_HEADER_FLAGS)

[BITS 32]                     ; Tell NASM to output 32-bit code
[GLOBAL Multiboot]            ; This lets LD reorder the file to put
                              ; the Multiboot header at the start of our file
[GLOBAL start]                ; The assembly entry point

[EXTERN code]
[EXTERN bss]
[EXTERN end]                  ; This marks the end of the kernel
[EXTERN Main]                 ; This is the C++ entry point. We have to call this manually
[EXTERN LoadConstructors]     ; Called before Main(), this calls the global constructors

这实际上并不是代码本身,它只是通过显示传递标志和这些标志的哪些位被设置后我们可以预期的情况来使我们的生活更轻松。它还将堆栈大小设置为一个常量,这样我们就可以更改此值而无需搜索和替换,就可以拥有不同的堆栈大小。如果 Bochs 持续抱怨执行了错误的内存,您就需要这个。

接下来,我们布局我们的内核。

Multiboot:
  dd  MBOOT_HEADER_MAGIC     ; GRUB will search for this value
                             ; on each 4-byte boundary in the ELF file
  dd  MBOOT_HEADER_FLAGS
  dd  MBOOT_CHECKSUM         ; Ensures that the above values are correct
   
  dd  Multiboot              ; Location of this section
  dd  code                   ; Start of kernel '.text' (code) section
  dd  bss                    ; End of kernel '.data' section
  dd  end                    ; End of kernel
  dd  start                    ; Kernel entry point (initial EIP)

在标志之后,有一个校验和,这是一个健全性检查,用于确保 Multiboot 头部的每个字段加在一起等于零。最后五行不包含在此之内,因为它们只是使我们的内核能够引导所需的附加信息。它们是内核中 Multiboot 头部的位置(通常在 1 MiB 标记处)、我们编译代码的起始位置、未初始化变量段(BSS)的起始位置、内核的结束位置以及内核入口点。这将是执行开始的地方。

既然 GrUB 拥有了它需要的一切,它就可以引导我们的内核了。这就是将要执行的内容——它是运行在程序传统入口点之前并设置好一切的存根的对应部分。这个入口点只会运行一次,并且完全不能依赖任何内存保护。我们甚至不知道我们的代码在哪里执行。

Start:

    mov esp, stackEnd      ; Set up a stack which is 0x8000 bytes long
    mov ebp, 0             ; Give us a landing point for stack traces
    cli                    ; Disable interrupts.
    call LoadConstructors  ; Load global constructors
                           ; Parameters for the C++ entry point need to be pushed backwards
    push ebx               ; Load multiboot header location
    push esp               ; This is the stack address
    call Main              ; Invoke the Main() function
    jmp $                  ; Enter an infinite loop, to stop the processor
                           ; executing whatever rubbish is in the memory after the kernel

section .bss

stackStart:
    resb STACKSIZE

stackEnd:

这是一段非常简单的代码。为了解决我们稍后将面临的问题,我们将堆栈移到一个安全干净的地方——还有什么比我们内核中的一个段更干净的呢?ELF 规范要求该段用零填充。

重要的是要指出一个小细节。我们将堆栈末尾的位置移动到 ESP。这是因为堆栈向后增长,如果我们给它堆栈的开始,我们会覆盖内核数据。我们将 EBP 设置为 0,这样当我们能够进行堆栈跟踪时,我们就可以找到一个位置来防止任何恼人的无限循环。

堆栈正常工作后,我们禁用中断。这并非必不可少,但最好还是确保一下。

然后,我们调用我们的 LoadConstructors 函数。这是将要实际执行的第一段 C++ 代码。通常,C++ 方法名称会被修饰,以便能够进行方法重载;我们不希望 LoadConstructors 函数发生这种情况,因此我们只需在其前面加上 extern "C" 前缀,以防止这种情况发生(除非您喜欢使您的内核具有编译器特异性)。

当该函数中的所有工作完成后,我们就推送 Main 的参数。请注意,此函数也没有名称修饰。现在,我们需要堆栈的位置,所以我们会将其与 Multiboot 结构一起传递,后者可以提供有关系统的大量信息。缺点是我们将在参数中传递它们,而参数需要向后传递。因此,方法签名将类似于此

void Main(unsigned int esp, multibootInfo *mbootPtr)
{
}

当我们到达这里时,我们就在我们的内核中。我们已经着陆。现在我们只需要向世界展示这一点。让我们先从屏幕打印开始。

打印到屏幕

计算机以文本模式启动。我们有一个映射到屏幕的内存区域。屏幕大小为 80 列,25 行。请记住,这不是普通内存。读写可能像正常一样工作,但在底层,它通过一组电路映射到显卡。

我们不能直接将句子写入视频内存;我们必须提供额外的信息。这些额外的信息是背景和前景色,位于 16 字节整数的最高八个字节中。实际上,我们有 16 种文本或背景颜色值。它们是

0 黑色 0000
1 蓝色 0001
2 绿色 0010
3 青色 0011
4 红色 0100
5 品红色 0101
6 棕色 0110
7 浅灰色 0111
8 深灰色 1000
9 亮蓝色 1001
10 亮绿色 1010
11 亮青色 1011
12 亮红色 1100
13 亮品红色 1101
14 棕色 1110
15 白色 1111

正如您从二进制表示中可以看出,保存值的字段是 4 位宽的。因为我们需要文本颜色和背景颜色,所以颜色的总宽度是 8 位,即一个字节。这实际上意味着,如果我们把内存映射地址转换为指向字节(或无符号字符)的指针,我们可以交替进行,所以 address[0] 将是第一个要写入的字符,而 address[1] 将保存该字符的属性。

您可能已经注意到的一点是,虽然屏幕实际上是二维的,但我们拥有的内存块是一维的。通过“展平”屏幕来解决这个问题,这样一行就直接跟在内存中的另一行后面,VGA 控制器会处理后续的事情。这使我们的工作更简单,因为我们只需要弄清楚在哪里放置下一个字符。

这个公式应该是显而易见的:如果我们有 25 列,每列 80 个字符长,那么我们可以计算内存中的偏移量为 80Y+X。但是,如果我们以无符号字符的形式写入数据,我们需要将其加倍,以得到 2(80Y + X)。这个公式并不完全是显而易见的,所以我们将使用无符号短整型,以保持第一个公式。

现在我们有了偏移量,我们只需要知道在哪里写入数据。该地址被标准化为两个整数之一:0xB8000 或 0xB0000。0xB8000 用于彩色显示器,0xB0000 用于单色。通过读取 BIOS 数据区域,您可以找出这一点,但您遇到的每个模拟器默认都会有一个彩色屏幕,所以没有紧迫的需要这样做。

重要的是我们知道我们要写入的数据的格式。我们将以两个无符号字符的形式写入:字符值后跟属性。这就是确切的格式

字段大小(位) 描述
0 : 7 8 字符代码
8 : 11 4 文本颜色
12 : 15 4 背景颜色

字符代码不会有问题。稍微棘手的是创建属性字节。现在,理论上我们可以使用结构,但这对于我们所需的功能来说过于臃肿;根据您的操作系统最终如何,您可能只在文本模式下停留几秒钟。

所以,要将字符串写入屏幕,我们只需要做类似这样的事情

void writeCharacter(char c, unsigned char backColour, 
                    unsigned char textColour, int x, int y)
{
    unsigned short *videoMemory = (unsigned char *)(0xB8000 + 80 * y + x);
    unsigned char attribute = (backColour << 4) | (textColour & 0xF);

    *videoMemory = c | ((unsigned short)attribute << 8);
}

如您所见,它出奇地简单。首先,我们使用 X 和 Y 坐标作为指导,获取指向所需视频内存部分的指针(由于我们使用的是无符号短整型而不是无符号字符,因此无需乘以 2)。然后,我们创建属性字节。这是基本的位移运算。首先,我们将背景颜色左移 4 位,这就创建了数据格式的左半部分。然后,我们将文本颜色与 0xF 进行掩码操作,这就从左边创建了下一个。完成这些后,我们只需将两部分 OR 起来形成属性字节。

现在,我们只需要将属性字节左移 8 位并将其与字符 OR 起来。这就创建了所需格式的 16 位整数,然后我们将其写入内存。好了;您已经将第一个字符写入了屏幕!

现在,我将把创建您自己的 Console 类的工作留给您。只需记住以下实现规则

  • 换行符 ("\n") 需要增加 Y 并将 X 设置为零。
  • 当 X 坐标大于 80 时,您需要像处理换行符一样做出反应。
  • 您需要跟踪 X 和 Y 坐标。
  • 要清除 GrUB 留下的所有内容,您需要写入一个空格 (0x20) 字符,背景色为黑色 (0),文本颜色任意。
  • 您会想编写的不仅仅是字符串。十六进制和十进制打印将大有帮助。
  • 要打印字符串,只需迭代直到遇到空字符,打印每个字符。
  • 为了节省时间,您可能需要一个 printf 实现。

    当您到达这一点时,您可能不想要(或不需要)完整的格式字符串,只需最少的 %x、%d、%s 和 %c。

如果您想立即看到一些结果,您可以尝试打印在传递给 Main 函数的 Multiboot 指针中可以找到的值。您会发现那里有很多有趣的信息。

实用函数

现在您有了控制台驱动程序,您需要有实用函数。您(除了移动文本光标之外——有很多现成的代码可以为您做到这一点;将其视为一项研究任务)直到下一个教程才使用它们,但您仍然需要它们。您还需要一些类型别名,以便让您的生活更轻松。您可以在内核中任意命名这些类型,但我将我的类型命名为 C# 名称,因为我最熟悉它们。我将在我的教程中使用这些名称。作为参考,下方有一个表格描述了它们

unsigned char 8 位整数;称为 uchar
unsigned short 16 位整数;称为 ushort
无符号整数 32 位整数;称为 uint
无符号长长整型 64 位整数;称为 ulong

现在,我们来看实用函数。这些允许端口输入和输出,它们出奇地简单。它们使用特权指令 INxOUTx。您将在多个场合用到这些函数,一旦完成,您(理论上)就可以直接进行硬盘访问。如果您想这样做,欢迎您;但这需要很长时间才会被涵盖,因为当您可以检测到驱动器并以尽可能少的假设进行工作时,这会更好。

由于这些函数的重要性,我将提供代码,而不是指向数据表或规格的链接

void outportByte(unsigned short port, unsigned char value)
{
    asm volatile ("outb %1, %0" : : "dN" (port), "a" (value));
}

void outportWord(unsigned short port, unsigned short value)
{
    asm volatile ("outw %1, %0" : : "dN" (port), "a" (value));
}

void outportLong(unsigned short port, unsigned long value)
{
    asm volatile ("outl %1, %0" : : "dN" (port), "a" (value));
}

unsigned char inportByte(unsigned short port)
{
    unsigned char result;

    asm volatile("inb %1, %0" : "=a" (result) : "dN" (port));
    return result;
}

unsigned short inportWord(unsigned short port)
{
    unsigned short result;

    asm volatile("inw %1, %0" : "=a" (result) : "dN" (port));
    return result;
}

unsigned long inportLong(unsigned short port)
{
    ulong result;

    asm volatile("inl %1, %0" : "=a" (result) : "dN" (port));
    return result;
}

不要忘记正确头文件中的方法签名。

结束

大致就是这样。创建自己的操作系统一开始并不难,但在后面的部分会变得更加有趣。

接下来:描述符表和中断。

© . All rights reserved.