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

开始编写操作系统,第四部分

2009年12月31日

CPOL

12分钟阅读

viewsIcon

60509

downloadIcon

700

实时时钟、可编程中断定时器和键盘控制器。

本系列文章

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

引言

读完上一篇文章,你可能会对编写操作系统的庞大范围感到有些畏惧。在某种程度上,你是对的;里面确实有一些非常复杂的部分。但事情并非完全如此。操作系统的某些部分却出奇地简单。其中有四个方面是控制台、RTC、PIT 和键盘。由于我们已经研究了控制台驱动程序,现在有机会研究其他三个了。

我们暂时休息一下,暂缓那些要求较高的内容,以更慢的节奏进行。但是,在初始化一些更有趣的硬件时,我们也需要能够访问 MSRs。文章末尾还有一些重要的、有趣的规范链接,它们将提供大量上下文和硬件细节,这些细节超出了本文的范围,但在故障排除时可能会有所帮助。

RTC

让我们来看看实时时钟(Real-Time Clock)。它是一个简单的时钟,可以在计算机关闭时跟踪当前时间。它由一个小电池供电;在我的电脑上,一直都是纽扣电池(CR2032),但你的情况可能不同。

当然,事情并非这么基础。它还有一个简单的低精度定时器和一个闹钟。构成当前和闹钟时间及日期的信息存储在少量非易失性内存(通常为 256 字节)中,称为互补金属氧化物半导体(Complementary Metal Oxide Semiconductor)。我们可以通过输入/输出端口 0x70 和 0x71 访问此内存。

要访问 CMOS,我们需要发送两个字节。第一个字节发送到输出端口 0x70,用于指定我们要访问的寄存器。第二个字节要么从端口 0x71 发送,要么从端口 0x71 读取,它负责写入或读取该寄存器中的字节。

既然我们知道如何读写寄存器,那么知道每个寄存器应该包含什么信息可能会很有用。前十五个字节包含有关 RTC 的信息,之后是有关系统引导状态、配置设置、关机状态和硬盘驱动器信息的信息。本文档教程重要的寄存器如下所示;布局的其他部分可以在 这里 找到。

偏移量 名称

0x0

0x2

分钟数

0x4

小时数

0x7

0x8

0x9

年份

偏移量 0x6 处还有一个“星期几”字段,但这通常不准确,并且会因 BIOS 的不同而有所差异,这取决于“年份”字段的功能。“年份”字段不存储完整年份(最大值为 256),而是存储最后两位数字。例如,2010 年的“年份”字段将是 10。如果你查看日历,你会发现星期几取决于年份;没有完整的年份,这个字段将是不准确的。顺便说一句,当你开始解析 ACPI 表时,固定 ACPI 描述表(Fixed ACPI Description Table)中(偏移量 108 字节处)的“世纪”(CENTURY)字段将包含 CMOS 中的一个偏移量,你可以用它来创建正确的年份。因此,你可能希望计算星期几,并做出相对安全的假设,即世纪是 20。

理论知识掌握了,现在让我们开始实践。如果你创建一个简单的 `DateTime` 结构体,并支持 `AddXXX` 方法,这样你就可以操作内部字段,那么你就可以轻松地读取正确的字段。

unsigned char readByte(unsigned char offset)
{
    outportByte(0x70, offset);
    return inportByte(0x71);
}
void writeByte(unsigned char offset, unsigned char value)
{
    outportByte(0x70, offset);
    outportByte(0x71, value);
}

DateTime *currentDate = new DateTime();
currentDate->Year = readByte(0x9) + 2000;
currentDate->Month = readByte(0x8);
currentDate->Day = readByte(0x7);
currentDate->Hour = readByte(0x4);
currentDate->Minute = readByte(0x2);
currentDate->Second = readByte(0x0);

请注意,我从最大的时间单位开始读取到最小的单位;这是因为如果我们读取端口时时间发生变化,我们的数据就会不准确。虽然 RTC 始终存在一定程度的不准确性,但这可以尽量避免。

当我们考虑精度损失时,我们只读取一次日期和时间。说这是一个问题简直是轻描淡写;我们需要一种方法来始终保持最新。如果我们有多任务处理,那么我们可以通过一个不断轮询端口的线程来解决。但即使这样效率也很低。我们需要的是能够*中断*程序的流程,并在我们需要更新时钟时通知我们。恰好,我们在上一篇教程中已经为此奠定了基础,我们在其中设置了 IRQ 支持。

RTC 的 IRQ 是 8。要启用它,我们只需要设置偏移量 0xB 处字节的第 6 位。过程非常简单,你可能已经猜到了。

unsigned char registerB = readByte(0xB) | 0x40;

installIRQHandler(8, rtcHandler);
writeByte(0xB, registerB);

中断会几乎立即触发,所以我们必须在写入切换中断的字节之前安装一个 IRQ 处理程序。中断的另一个要点是时序。默认频率是 1024 Hz,这意味着它每秒触发 1024 次。可以通过更改偏移量 0xA 处寄存器中低 4 位来更改它。频率的计算方法如下:

frequency = 32768 >> (rate - 1)

我们很容易在每次 IRQ 触发时读取时间,但那样有什么意义呢?我们使用的精度单位是秒,所以我们最终会进行不必要的读写。相反,我们可以每 1024 次*才*读取一次时间。这是一个很大的优化,本身就很好。但是,我们可以做得更进一步。如果我们每秒读取一次时间,我们就已经知道自上次中断以来经过了多久。我们只需要在当前时间上加一秒,让它承担重担(希望你还记得,一秒钟也可能让你进入下一分钟、小时、天、月和年)。

还有一个寄存器。偏移量 0xC 处的寄存器告诉中断处理程序发生了什么。为了接收更多中断,我们需要读取它。无论我们是否对其进行任何操作都无关紧要。

不幸的是,CMOS 寄存器中的一些数据是编码的。虽然并非所有 BIOS 都存在这种情况,但你需要处理它。你可以通过查看偏移量 0xB 处寄存器的第 3 位来检查是否使用了二进制编码的十进制(Binary Coded Decimal)。如果该位被设置,那么你可以照常进行。如果为零,则需要解码构成日期和时间的数据。解码过程非常简单。

unsigned char decoded = ((encoded >> 4) * 10) + (encoded & 0xF);

为了效率起见,你可能想将其放入一个函数或宏中。

当我们把所有这些放在一起时,我们就得到了一个简单的时钟。这是系统中可用的多个时钟之一(RTC、PIT、HPET 和 LAPIC),所以你可能想构建一个健壮的基类,所有计时设备都从中继承。

PIT

Intel 8254,也称为可编程间隔定时器(Programmable Interval Timer),是另一个定时器。然而,与 RTC 不同的是,它允许在中断之间有更精细的时间间隔。它通常用于跟踪秒以及抢占式多任务处理。

其本身的晶体振荡器只能以一种速度运行。然而,它还有三个分频器,允许使用三个任意频率。我们的操作系统在振荡器以给定频率(赫兹)振荡时也会收到通知。频率是硬接线数字除以分频器。这个数字是 119318.2 赫兹,或 1.193862 兆赫兹。之所以如此,是因为计算机的旧部分曾经使用电视振荡器,经过分频和与运算,得出了一个奇特的频率。

尽管 PIC 有三个输出,但我们只有通道 0 和 2 可用。这是因为通道 1 用于刷新某些内存,以防止其丢失状态。在现代计算机中,这不再是必需的,但我们不使用它,因为(与 x86 架构的许多部分一样)如果我们的代码在旧机器上运行,我们会弄乱内存,导致(看似)随机错误。

所以我们的通道是 0 和 2。我们可以将这两个通道用于两种目的:计时和声音。这是一个相对容易设置的设备;我们只需发送一个命令字节,然后是分频器的上半部分和下半部分字节。对于通道 0,此命令字节是 0x36,对于通道 2,此命令字节是 0xB6。因此,要设置通道 0 上的定时器,我们使用此代码:

void activateChannel(unsigned char channel, unsigned int frequency)
{
    unsigned int divider = 1193180 / frequency;

    outportByte(0x43, channel == 0 ? 0x36 : 0xB6);
    outportByte(0x40 + channel, (unsigned char)(divider & 0xFF));
    outportByte(0x40 + channel, (unsigned char)((divider >> 8) & 0xFF);
}

从这里我们可以看到,PIT 的基本端口是 0x40。如果我们想使用定时器,我们只需在为 IRQ0 设置 IRQ 后运行此方法,我们将每秒收到频率中断。为了节省时间,你可以将 PIT 和 RTC 连接在一起,这样你只需时不时地检索时间,并在必要的 IRQ 上将当前时间加一秒,而不是使用昂贵的 IO 端口。

如果你尝试使用上面的代码片段激活通道 2,你会注意到在 Microsoft 或 Windows Virtual PC 上,计算机不会停止发出你设置频率的音调。要做到这一点,只需从端口 0x61 读取字节,关闭前两位(& 0xFC),然后将其写回。因此,要发出指定时间的蜂鸣声,你只需要一个 `sleep` 函数,这并不难,留给读者作为练习。

KBC

如果你比平时更仔细地阅读,你会注意到我们正在从这三个驱动程序中最难的移向最容易的。那么,逻辑上,KBC 应该很简单。事实也是如此;你只需要在 IRQ1 上挂载一个中断处理程序。唯一的例外是 Bochs 不总是刷新键盘缓冲区,这可能会干扰命令行提示符。我们可以通过读取端口 0x60 的字节,直到连续出现两个相同的值,来手动完成此操作。

IRQ 处理程序的内容也很简单。你只需从 0x60 读取一个字节(这是接收多个 IRQ 的必要步骤),并将其映射到一个字符。要执行此映射,通常是加载一个文本文件作为模块,并将读取的字节用作索引,或者拥有一大堆字符数组,映射 num lock、caps lock 和 shift 的所有可能组合。然而,你不必通过反复试验来解决这个问题。这个页面包含了你需要创建的键映射的大部分内容;由于我们使用的是 C++ 并且可能希望支持本地化,你可以考虑创建一个派生类,其中包含一个 `TranslateScancode` 方法。但请注意,并非每个字节都对应一个可打印字符。还有其他按键,如 Shift、Tab、Caps lock 和 Fn 键。每个键还有一个“开启”和“关闭”值,用于告知我们在用户按下和释放每个字符时。当用户按下某个键时,中断会一直发生,直到他们松开为止。

如果你决定编写一个引导加载程序,你还可以使用 KBC 来防止对地址高于 1 MiB 的访问发生回绕,方法是使用 A20 网关。目前,我们只需要启用和禁用键盘上的 LED。然而,计算机的逻辑组织仍在继续体现;我们还可以通过将字节 0xFE 写入端口 0x64 来重置计算机。旧有东西难道不妙吗?

所以,要启用 LED,我们向端口 0x60 发送一个命令字节(0xED),后面跟着一个具有精确位格式的字符。

字段大小(位) 描述
0 1 滚动锁定
1 1 数字锁定
2 1 大写锁定
3 : 7 5 未使用

这将打开与我们发送到其中一个键盘端口的位对应的 LED。

接下来呢?

我们已经涵盖了我们打算涵盖的所有内容;希望你现在已经有了三个功能齐全的驱动程序,可以在你的内核中使用。从这里,你可以转向多任务处理。然而,我们首先需要涵盖内存管理,以便我们可以为每个进程提供其内存的独立视图,并为我们的程序提供内存。由于我因考试而暂时无法发布下一篇文章,所以喜欢冒险的人可能会想继续阅读,而我将涵盖一些代码片段,填补一个有用的空白,这将使调试更容易。

MSRs

因为将来某个时候你会需要这个代码片段(LAPIC 具有一些非常有趣的特性),这里是一个读取和写入模型特定寄存器(Model Specific Register)的基本代码片段。我们在这里使用 `unsigned long long` 数据类型,因为所有 MSR 都是 64 位宽。

struct MSR
{
    public unsigned int Offset;

    public void SetValue(unsigned long long value)
    {
        //EAX contains the bottom 32 bits. EDX contains the top 32 bits. ECX contains the offset
        asm volatile ("wrmsr" : : "a"(value >> 32), 
            "d"(value & 0xFFFFFFFF00000000), "c"(Offset));
    }

    public unsigned long long GetValue()
    {
        unsigned int low = 0, high = 0;

        asm volatile ("rdmsr" : "=a"(&low), 
                      "=d"(&high) : "c"(Offset));
        //We stitch the two halves together to form a 64-bit integer
        return (high << 32) | low;
    }
};

堆栈跟踪

要理解接下来的代码片段,你需要了解 x86 CDECL 架构的堆栈布局。如果 GCC 决定更改调用约定,你也需要重写此代码。你可以在这里找到一个全面的列表;它稍微超出了本教程的范围,但仍然是推荐阅读的。

//EBP is passed to the exception handler by the CPU
void printStackTrace(unsigned int ebp)
{
    unsigned int *stackPosition = (unsigned int *)ebp;
    
    while(stackPosition != 0)
    {
        //methodLocation is the dereference of EIP
        //(which is itself just above EBP on the stack)
        unsigned int methodLocation = *(stackPosition + 1);
        
        //You can look methodLocation up to get a method name
        writeHexadecimal(methodLocation);
        if(*stackPosition != 0)
            writeLine();
        //Keep derefencing EBP until we reach 0. If you infinite 
        //loop here, make certain you set EBP to zero in the assembly stub
        stackPosition = (unsigned int *)(*stackPosition);
    }
}

现在你有了地址列表,你只需要将它们解析为内核符号和方法名。这并不难;你只需要使用 Multiboot 头(你的主函数收到了一个指向它的指针)来获取 ELF 段头字符串表,然后从那里继续处理符号表和字符串表。扫描符号表,直到找到一个覆盖每个值的符号,然后查看字符串表。如果这听起来很复杂,那么不用担心,因为下面有 ELF 规范。

参考文献

© . All rights reserved.