键盘操作

按照上一章的路线,我们将实现键盘操作。
实现键盘操作的核心是编写键盘驱动KeyboardDriver,它是一个InterruptRoutine的子类,对应了键盘操作的中断服务例程。
类中的核心方法是routine,它是中断服务例程的定义。
和键盘相关的最重要的硬件是两个芯片。一个是 intel 8042 芯片,位于主板上,CPU 通过 I/O 端口直接和这个芯片通信,获得按键的扫描码或者发送各种键盘命令。另一个是 intel 8048 芯片或者其兼容芯片,位于键盘中,这个芯片主要作用是从键盘的硬件中得到被按的键所产生的扫描码,与 8042 通信,控制键盘本身。8042有两个端口,驱动中0x60叫数据端口,0x64命令端口。

#define __KEYBOARD_H__

#include "types.h"
#include "interrupts.h"
#include "port.h"

class KeyboardDriver : public InterruptRoutine
{
public:
    KeyboardDriver(InterruptManager *manager);
    ~KeyboardDriver();

    virtual uint32_t routine(uint32_t esp) override;

private:
    Port8Bit dataPort;
    Port8Bit commandPort;
};

首先实现构造函数和析构函数。构造函数涉及到了键盘的硬件编程。
我们将实现键盘的中断服务例程,在列表初始化中需要构造基类对象,并初始化键盘控制芯片的端口。键盘的中断向量号为0x21,命令端口和数据端口已经提到。
这里是键盘端口的详尽描述。

#include "keyboard.h"

KeyboardDriver::KeyboardDriver(InterruptManager *manager)
    : InterruptRoutine(0x01 + manager->getOffset(), manager),
      dataPort(0x60),
      commandPort(0x64)
{
    // 如果commandPort的最低位是1,则清除输出缓冲区
    while (commandPort.read() & 0x1)
    {
        dataPort.read();
    }
    // 开启键盘
    commandPort.write(0xae);
    // 准备读取commandPort
    commandPort.write(0x20);
    uint8_t status = (dataPort.read() | 0x01) & (~0x10);
    // 准备进行写入操作
    commandPort.write(0x60);
    // 写入status
    dataPort.write(status);
    // 清空键盘输出缓冲区,可以继续扫描输入
    dataPort.write(0xf4);
}

KeyboardDriver::~KeyboardDriver()
{
}

键盘的中断服务例程是读取键盘的输入并显示在屏幕上,这需要对每一个按键进行读取判断。当按下shift键时,输出大写字母,使用一个变量标记shift是否被按下即可。每一个按键都有扫描码和断码。实际上按下一个按键时,扫描码和断码会分别触发一次中断,而我们只需要一次中断来打印一个字符,因此只检测扫描码。

#include "keyboard.h"



KeyBoardDriver::KeyBoardDriver(InterruptManager* manager) 
    : InterruptHandler(0x01 + manager->HardwareInterruptOffset(), manager),
    //0X01+中断号 = 0x21也就是键盘的中断号
    dataport(0x60),
    //键盘数据,读写端口:0X60
    commandport(0x64)
    {
    //控制端口 0x64
    //从0x64端口来判断低位数据
    while (commandport.Read() & 0x1)
    //输入池为空,这是用的指令端口的低位数据,来判断输入池的状态
     {
        dataport.Read();
    }
    commandport.Write(0xae);
    //表示我们写完了,可以读了,激活键盘,前面的while操作,一直在清空键盘的数据,对键盘的command进行写操作
    commandport.Write(0x20);
    //,键盘使用的8420芯片,准备读取8420芯片的Command数据
    uint8_t status = (dataport.Read() | 1) & ~0x10;
    //|1的目的是开启键盘中断,&~0x10就是在确保一定能够开启键盘中断,第四位置为0
    commandport.Write(0x60);
    //准备写入
    dataport.Write(status);
    dataport.Write(0xf4);
    //8042在主板上, 8048在键盘上,写完之后给键盘的芯片说,我们写完了。清空输出池,回一个ACK确认帧
    }

KeyBoardDriver::~KeyBoardDriver() {}

void printf(const char*);

uint32_t KeyBoardDriver::HandleInterrupt(uint32_t esp) {
    uint8_t key = dataport.Read();
    //从数据池中获得Key

    static bool shift = false;
    switch (key) {
     case 0x02: if (shift) printf("!"); else printf("1"); break;
    case 0x03: if (shift) printf("@"); else printf("2"); break;
    case 0x04: if (shift) printf("#"); else printf("3"); break;
    case 0x05: if (shift) printf("$"); else printf("4"); break;
    case 0x06: if (shift) printf("%"); else printf("5"); break;
    case 0x07: if (shift) printf("^"); else printf("6"); break;
    case 0x08: if (shift) printf("&"); else printf("7"); break;
    case 0x09: if (shift) printf("*"); else printf("8"); break;
    case 0x0A: if (shift) printf("("); else printf("9"); break;
    case 0x0B: if (shift) printf(")"); else printf("0"); break;

    case 0x10: if (shift) printf("Q"); else printf("q"); break;
    case 0x11: if (shift) printf("W"); else printf("w"); break;
    case 0x12: if (shift) printf("E"); else printf("e"); break;
    case 0x13: if (shift) printf("R"); else printf("r"); break;
    case 0x14: if (shift) printf("T"); else printf("t"); break;
    case 0x15: if (shift) printf("Y"); else printf("y"); break;
    case 0x16: if (shift) printf("U"); else printf("u"); break;
    case 0x17: if (shift) printf("I"); else printf("i"); break;
    case 0x18: if (shift) printf("O"); else printf("o"); break;
    case 0x19: if (shift) printf("P"); else printf("p"); break;

    case 0x1E: if (shift) printf("A"); else printf("a"); break;
    case 0x1F: if (shift) printf("S"); else printf("s"); break;
    case 0x20: if (shift) printf("D"); else printf("d"); break;
    case 0x21: if (shift) printf("F"); else printf("f"); break;
    case 0x22: if (shift) printf("G"); else printf("g"); break;
    case 0x23: if (shift) printf("H"); else printf("h"); break;
    case 0x24: if (shift) printf("J"); else printf("j"); break;
    case 0x25: if (shift) printf("K"); else printf("k"); break;
    case 0x26: if (shift) printf("L"); else printf("l"); break;

    case 0x2C: if (shift) printf("Z"); else printf("z"); break;
    case 0x2D: if (shift) printf("X"); else printf("x"); break;
    case 0x2E: if (shift) printf("C"); else printf("c"); break;
    case 0x2F: if (shift) printf("V"); else printf("v"); break;
    case 0x30: if (shift) printf("B"); else printf("b"); break;
    case 0x31: if (shift) printf("N"); else printf("n"); break;
    case 0x32: if (shift) printf("M"); else printf("m"); break;
    case 0x33: if (shift) printf("<"); else printf(","); break;
    case 0x34: if (shift) printf(">"); else printf("."); break;
    case 0x35: if (shift) printf("_"); else printf("-"); break;

    case 0x1C: printf("\n"); break;
    case 0x39: printf(" "); break;
    case 0x2A: case 0x36: shift = true; break;
    //按下shift
    case 0xAA: case 0xB6: shift = false; break;
    //弹起shift

    case 0x45: break; // NumLock键盘锁,不支持

    default:
        if (key < 0x80) {
            //为什么在这里要求<0x80呢,因为当按一个键的时候,其实是两次中断,一次按下的响应,一次抬起的相应
            //我们这里只处理按下去的一次
            char* foo = (char*)"KEYBOARD 0X00  ";
            const char* hex = "0123456789ABCDEF";
            foo[11] = hex[(key >> 4) & 0x0f];
            foo[12] = hex[key & 0x0f];
            printf((const char*)foo);
        }
    }
    
    return esp;
}

鼠标的私有成员和键盘略有不同,每次读取数据的时候读取的是一个数据流而不是一个扫描码。它有两种模式,分别是发送3字节流和4字节流,我们使用3字节流。简单来说,它包含了鼠标的位置(后2位)和状态信息(第1位),使用buffer[3]存储。然而触发中断时,只能读取一位信息,因此使用offset来记录当前读取的是哪一位的信息。除此之外,再使用一个buttons变量来记录鼠标当前的状态,如是否是按下状态等等。还有两个变量x和y用于记录鼠标当前的位置,这个位置将被初始化为屏幕的中心。

#ifndef __MOUSE_H__
#define __MOUSE_H__

#include "types.h"
#include "interrupts.h"
#include "port.h"

class MouseDriver : public InterruptRoutine
{
public:
    MouseDriver(InterruptManager *manager);
    ~MouseDriver();

    virtual uint32_t routine(uint32_t esp) override;

private:
    uint8_t offset = 0;
    uint8_t buttons = 0;
    uint8_t buffer[3];
    int8_t x, y;

    Port8Bit dataPort;
    Port8Bit commandPort;
};

#endif

鼠标的中断向量号是0x2C,使用和键盘相同的控制器,数据端口和命令端口和键盘一致。
构造函数和键盘类似。

#include "mouse.h"

MouseDriver::MouseDriver(InterruptManager *manager)
    : InterruptRoutine(0x0C + manager->getOffset(), manager),
      dataPort(0x60),
      commandPort(0x64),
      x(40), y(12)
{
    // 在屏幕上打印一个全白的字符表示鼠标
    uint16_t* videoMemory = (uint16_t*)0xb8000;
    videoMemory[y * 80 + x] = ((videoMemory[y * 80 + x] & 0xf000) >> 4) | 
                              ((videoMemory[y * 80 + x] & 0x0f00) << 4) |
                              (videoMemory[y * 80 + x] & 0x00ff);
    // 开启鼠标
    commandPort.write(0xa8);
    // 准备读取数据
    commandPort.write(0x20);
    // 设置状态,开启鼠标中断
    uint8_t status = (dataPort.read() | 0x02) & (~0x20);
    // 准备写入数据
    commandPort.write(0x60);
    // 写入鼠标状态
    dataPort.write(status);
    // 写入鼠标(而不是键盘)
    commandPort.write(0xd4);
    // 继续扫描输入
    dataPort.write(0xf4);
    // 读取数据流
    dataPort.read();
}

这段代码存在bug,后面将会看到移动鼠标时,鼠标轨迹不会消失。
将鼠标和键盘的源文件添加到Makefile的依赖中。
生成iso文件后的运行效果为
image.png
Image

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。一些号码由处理器的设计者分配,如除0、缺页、内存访问违例、断点、算术运算溢出。其它号码由操作系统内核的设计者分配:系统调用、来自外部I/O设备的信号。
异常号是异常表中的索引,异常表的起始地址放在 异常表基址寄存器(exception table base register) 的特殊CPU寄存器里。
中断描述符表(Interrupt Descriptor Table, IDT)是保护模式下用于存储中断处理程序入口的表。表中所有描述符都记录一段程序的起始地址,是通向某段程序的“门”,因此中断描述符表中的描述符被称为“门”。
所有的描述符大小都是8字节,门描述符和段描述符类似。各种门都属于系统段,有4种门描述符:

  1. 任务门任务门和任务状态段(Task Status Segment, TSS)是Intel处理器在硬件一级提供的任务切换机制。任务门需要和TSS配合使用,在任务门中记录TSS选择子,偏移量未使用。任务门可以存在于GDT,LDT和IDT中,但大多数操作系统都未用TSS实现任务切换。image.pngImage
  2. 中断门中断门包含了中断处理程序所在段的段选择子和段内偏移地址。通过此方式进入中断后,标志寄存器eflags中的IF位自动置0,即自动关闭中断,避免中断嵌套。中断门只允许存在于IDT中。image.pngImage
  3. 陷阱门和中断门类似,但由陷阱门引发异常后,IF位不会置0。陷阱门只允许存在于IDT中。image.pngImage
  4. 调用门调用门提供给用户进程进入特权0级的方式,DPL为3。调用门记录例程的地址,只能用call和jmp指令调用。调用门可以安装在GDT和LDT中。image.pngImage

现代操作系统很少用到调用门和任务门。
门描述符的结构定义为

struct GateDescriptor
{
    uint16_t lowbits;
    uint16_t segmentSelector;
    uint8_t reserved;
    uint8_t access;
    uint16_t highbits;
} __attribute__((packed));

一个中断源就会产生一个中断向量,一个中断向量对应IDT中的一个门描述符,通过门描述符可以找到对应的中断处理程序。
CPU内部有个中断描述符表寄存器(Interrupt Descriptor Table Register, IDTR),中断描述符表地址加载到IDTR中。IDTR寄存器的结构如下图。
GDT中的第0个描述符不可用,但IDT无此限制。16位Limit可容纳描述符的个数是8192个,但处理器只支持256个中断,剩下的描述符不可用。

4.2 中断处理过程及保护

  1. 处理器根据中断向量号定位中断门描述符
  2. 处理器进行特权级检查中断向量号是个整数,其中并没有请求特权等级(Request Privilege Level, RPL),在对由中断引起的特权级转移做特权级检查中不涉及RPL。对于软中断,当前特权级CPL必须在门描述符DPL和门中目标代码段DPL之间,这是为了防止位于3特权级的用户程序主动调用某些只为内核服务的例程。
  3. 执行中断处理程序

v2-bf8cfc50e550ac84642b226611a9f2da_1440w.jpg
门描述符中保存的是中断处理程序所在代码段的选择子及段内偏移量。处理器加载选择子到代码段寄存器CS,加载偏移量到指令指针寄存器EIP。当前进程被中断打断后,处理器自动把CS和EIP保存到中断处理程序使用的栈中。还要保存EFLAGS。不同特权级下处理器使用不同的栈,如果特权级变化,还要压入SS和ESP寄存器。

  1. 处理器根据中断向量号找到中断描述符,若CPL权限比DPL低,则将SS和ESP压栈;
  2. 压入EFLAGS寄存器;
  3. 切换目标代码段,将CS和EIP保存到栈中备份;
  4. 某些异常会有错误码,错误码包含选择子等信息,会随EIP之后入栈,记作ERROR_CODE。

在使用iret返回前,要保证栈顶往上的顺序是正确的。

Image
错误码本质上是个描述符选择子,通过低3位属性来修饰此选择子指向哪个表中的哪个描述符。如果错误码全为0,表示中断的发生与特定的段无关。

4.3 定义外部中断和异常

我们要实现许多中断,比较好的方法是创建一个管理类InterruptManager管理所有的中断。
使用函数handleInterrupts()处理中断,每个中断用一个中断向量号标识,它将作为函数的第一个参数。由于这个函数需要在程序运行期间一直存在,且不是被某个InterruptManager对象所调用的,需要作为类的静态方法。
中断发生时,处理器会将某些数据压入堆栈,我们需要在中断处理完毕后返回堆栈的原有位置继续运行,因此在进入中断时还要传入当前的栈指针esp作为第二个参数,并且在函数返回时返回esp。

class InterruptManager
{
public:
    static uint32_t handleInterrupt(uint8_t interruptNumber, uint32_t esp);
};

在handleInterrupt()中用printf()打印一句话作为中断发生的标志。printf()是外部函数,使用前需要进行声明。

#include "interrupts.h"

void printf(const char*);
uint32_t InterruptManager::handleInterrupt(uint8_t interruptNumber, uint32_t esp) 
{ 
    printf("interrupt");
    return esp; 
}

使用反汇编工具可以查找到它的汇编代码中的名字为__ZN16InterruptManager15handleInterruptEhj。
建立汇编文件asm_interrupts.s,加上asm前缀是为了生成.o文件时与.cpp生成的.o文件区分开。在汇编文件中我们声明一个代码段,在代码段中声明外部函数handleInterrupt()。接下来在int_bottom函数中执行一些压栈操作,最后压入函数的两个参数并调用。
调用完毕后,进行出栈操作,它与入栈操作一一对应。恢复现场完毕后,使用iret指令返回。

section .text
.extern __ZN16InterruptManager15handleInterruptEhj

int_bottom:
    pushl %ebp
    pushl %edi
    pushl %esi
    pushl %edx
    pushl %ecx
    pushl %ebx
    pushl %eax  # 将通用寄存器压栈

    pushl %esp  # 压入栈指针
    push (interruptnumber)  # 压入中断向量号
    # 此时需要中断向量号,因此在下面的数据段中声明并初始化
    call __ZN16InterruptManager15handleInterruptEhj

    # 将函数返回值作为esp
    movl %eax, %esp
    # 恢复现场
    popl %eax
    popl %ebx
    popl %ecx
    popl %edx
    popl %esi
    popl %edi
    popl %ebp

    add $5, %esp

    iret

.data
    interruptnumber: .byte 0    # byte类型,初始化为0

接下来需要定义intel设计者设计的17个中断和20个异常的服务例程。作为中断服务例程,它们也是静态成员函数。产生中断时,将调用相应的中断服务例程,并在这些中断服务例程中统一执行handleInterrput()作为一个初始的中断实现。
_// os/interrupts.cpp _class InterruptManager { private: static uint32_t handleInterrupt(uint8_t interruptNumber, uint32_t esp); static void HandleInterruptRequest0x00(); static void HandleInterruptRequest0x01(); static void HandleInterruptRequest0x02(); static void HandleInterruptRequest0x03(); static void HandleInterruptRequest0x04(); static void HandleInterruptRequest0x05(); static void HandleInterruptRequest0x06(); static void HandleInterruptRequest0x07(); static void HandleInterruptRequest0x08(); static void HandleInterruptRequest0x09(); static void HandleInterruptRequest0x0A(); static void HandleInterruptRequest0x0B(); static void HandleInterruptRequest0x0C(); static void HandleInterruptRequest0x0D(); static void HandleInterruptRequest0x0E(); static void HandleInterruptRequest0x0F(); static void HandleInterruptRequest0x31(); static void HandleException0x00(); static void HandleException0x01(); static void HandleException0x02(); static void HandleException0x03(); static void HandleException0x04(); static void HandleException0x05(); static void HandleException0x06(); static void HandleException0x07(); static void HandleException0x08(); static void HandleException0x09(); static void HandleException0x0A(); static void HandleException0x0B(); static void HandleException0x0C(); static void HandleException0x0D(); static void HandleException0x0E(); static void HandleException0x0F(); static void HandleException0x10(); static void HandleException0x11(); static void HandleException0x12(); static void HandleException0x13(); };
在汇编文件asm_interrupts.s中要实现它们的定义。为了简化书写,使用汇编的宏替换。在宏中,定义了这些中断服务例程的操作是确定中断号,并赋值给interruptnumber作为后面handleInterrupt()的参数使用。执行中断服务例程后,将跳转到中断处理程序.int_bottom中,进而在.int_bottom中执行handleInterrupt()。为方便展示调用关系,使用C++格式的伪代码
同样需要通过反汇编查询这些函数在汇编代码中的名字,并修改汇编文件asm_interrupts.s。注意,外部中断的中断向量号从20开始,然而在函数命名时我们从0开始命名,传入中断向量号时需要加上一个20的偏移量,设为IRQ_BASE。

class InterruptManager
{
private:
    static uint32_t handleInterrupt(uint8_t interruptNumber, uint32_t esp);

    static void HandleInterruptRequest0x00();
    static void HandleInterruptRequest0x01();
    static void HandleInterruptRequest0x02();
    static void HandleInterruptRequest0x03();
    static void HandleInterruptRequest0x04();
    static void HandleInterruptRequest0x05();
    static void HandleInterruptRequest0x06();
    static void HandleInterruptRequest0x07();
    static void HandleInterruptRequest0x08();
    static void HandleInterruptRequest0x09();
    static void HandleInterruptRequest0x0A();
    static void HandleInterruptRequest0x0B();
    static void HandleInterruptRequest0x0C();
    static void HandleInterruptRequest0x0D();
    static void HandleInterruptRequest0x0E();
    static void HandleInterruptRequest0x0F();
    static void HandleInterruptRequest0x31();

    static void HandleException0x00();
    static void HandleException0x01();
    static void HandleException0x02();
    static void HandleException0x03();
    static void HandleException0x04();
    static void HandleException0x05();
    static void HandleException0x06();
    static void HandleException0x07();
    static void HandleException0x08();
    static void HandleException0x09();
    static void HandleException0x0A();
    static void HandleException0x0B();
    static void HandleException0x0C();
    static void HandleException0x0D();
    static void HandleException0x0E();
    static void HandleException0x0F();
    static void HandleException0x10();
    static void HandleException0x11();
    static void HandleException0x12();
    static void HandleException0x13();
};

同时我们也需要将这个20的偏移量记录在类InterruptManager中。这个偏移量在构造函数中设置。
// os/interrupts.h _class InterruptManager { private: _// ... uint16_t hardwareInterruptOffset; };
到此为止,我们定义了外部中断和异常的服务例程。接下来是实现从中断向量号查找到这些服务例程的地址的过程。

4.4 定义IDT

为了得到中断服务例程的入口地址,中断向量号将作为IDT的索引查找中断描述符。
中断描述符具有下面的形式,按照从低位到高位排列,并禁用编译器的内存对齐。
struct GateDescriptor { uint16_t lowbits; uint16_t codeSegmentSelector; uint8_t reserved; uint8_t access; uint16_t highbits; } _attribute__((packed));
它是InterruptManager的私有成员。中断描述符表可看做是中断门描述符的数组,用IDT表示,大小为256。它也是一个静态变量。
它的构造函数中需要传入以下信息:中断向量号、中断服务例程所在的代码段选择子、中断服务例程的入口地址、特权级DPL、描述符类型type。
_// os/interrupts.cpp _class InterruptManager { private: struct GateDescriptor { _// ...
} _attribute__((packed)); static void setGateDescriptor(uint8_t interruptNumber, uint16_t codeSegmentSelector, void (handle)(), uint8_t DPL, uint8_t type); static GateDescriptor IDT[256]; private: static uint32_t handleInterrupt(uint8_t interruptNumber, uint32_t esp); static void HandleInterruptRequest0x00(); // ... static void HandleException0x13();
在InterruptManager类的构造函数中,需要传入全局描述符表gdt。
// os/interrupts.h
class InterruptManager { public: InterruptManager(uint16_t hardwareInterruptOffset_, GlobalDescriptorTable
gdt); ~InterruptManager(); private: _// ... _};
接下来是在.cpp文件中实现上面的方法。
_// os/interrupts.cpp _#include "interrupts.h" void printf(const char); uint32_t InterruptManager::handleInterrupt(uint8_t interruptNumber, uint32_t esp) { _// ... _} _// 静态变量IDT在头文件中声明,在.cpp文件中定义 _InterruptManager::GateDescriptor InterruptManager::IDT[256]; _// 按位设置门描述符 _void InterruptManager::setGateDescriptor(uint8_t interruptNumber, uint16_t codeSegmentSelector_, void (handle)(), uint8_t DPL, uint8_t type) { IDT[interruptNumber].lowbits = ((uint32_t)handle) & 0xffff; IDT[interruptNumber].highbits = ((uint32_t)handle >> 16) & 0xffff; IDT[interruptNumber].codeSegmentSelector = codeSegmentSelector_; IDT[interruptNumber].access = 0x80 | ((DPL & 3) << 5) | type; IDT[interruptNumber].reserved = 0; }
InterruptManager的构造函数比较复杂,首先需要由代码段选择子得到段偏移,方式如下
uint16_t codeSegment = (gdt->getCodeSegmentSelector()) << 3;
接着需要初始化256个异常。
for (uint16_t i = 0; i < 256; i++) { setGateDescriptor(i, codeSegment, &interruptIgnore, 0, __IDT_INTERRUPT_GATE_TYPE_); }
其中中断服务例程的入口地址暂时设为&interruptIgnore。它是一个地址的标识符,我们在头文件中声明它,并不需要定义。
_// os/interrupts.h _class InterruptManager { private: static void interruptIgnore(); _// ... _};
但是对于我们已经定义过的17个外部中断和20个异常,我们需要重新给定它们的中断服务例程的入口地址。为方便书写,采用C++宏替换。
_// os/interrupts.cpp _#include "interrupts.h" void printf(const char); uint32_t InterruptManager::handleInterrupt(uint8_t interruptNumber, uint32_t esp) { _// ... _} InterruptManager::GateDescriptor InterruptManager::IDT[256]; void InterruptManager::setGateDescriptor(uint8_t interruptNumber, uint16_t codeSegmentSelector_, void (handle)(), uint8_t DPL, uint8_t type) { _// ... _} InterruptManager::InterruptManager(uint16_t hardwareInterruptOffset_, GlobalDescriptorTable gdt) : priCommand(0x20), priData(0x21), semiCommand(0xA0), semiData(0xA1) { hardwareInterruptOffset = hardwareInterruptOffset_; hardwareInterruptOffset = hardwareInterruptOffset_; const uint8_t _IDT_INTERRUPT_GATE_TYPE = 0xe; uint16_t codeSegment = (gdt->getCodeSegmentSelector()) << 3; for (uint16_t i = 0; i < 256; i++) { setGateDescriptor(i, codeSegment, &interruptIgnore, 0, __IDT_INTERRUPT_GATE_TYPE_); } #define XX(name) setGateDescriptor(0x##name, codeSegment, &HandleException0x##name, 0, __IDT_INTERRUPT_GATE_TYPE_) XX(00); XX(01); XX(02); XX(03); XX(04); XX(05); XX(06); XX(07); XX(08); XX(09); XX(0A); XX(0B); XX(0C); XX(0D); XX(0E); XX(0F); XX(10); XX(11); XX(12); XX(13); #undef XX #define XX(name) setGateDescriptor(hardwareInterruptOffset + 0x##name, codeSegment, &HandleInterruptRequest0x##name, 0, __IDT_INTERRUPT_GATE_TYPE_) XX(00); XX(01); XX(02); XX(03); XX(04); XX(05); XX(06); XX(07); XX(08); XX(09); XX(0A); XX(0B); XX(0C); XX(0D); XX(0E); XX(0F); XX(31); #undef XX
和GDT一样,IDT的地址也放在一个特殊的寄存器idtr中,使用lidt指令进行装载。我们在类中创建一个符合这个寄存器结构的结构体。它的结构和装载GDT的寄存器类似。
// os/interrupts.h
class InterruptManager { public: // ... private: struct InterruptDescriptorTablePointer { uint16_t limit; uint32_t base; } __attribute__((packed)); _// ... _};
在构造函数中创建这个结构体,设置好合适的值后采用内联汇编将IDT的地址加载到idtr中。
InterruptManager
::InterruptManager(uint16_t hardwareInterruptOffset_, GlobalDescriptorTable
gdt) : priCommand(0x20), priData(0x21), semiCommand(0xA0), semiData(0xA1) { hardwareInterruptOffset = hardwareInterruptOffset_; hardwareInterruptOffset = hardwareInterruptOffset_; const uint8_t __IDT_INTERRUPT_GATE_TYPE
= 0xe; uint16_t codeSegment = (gdt->getCodeSegmentSelector()) << 3; for (uint16_t i = 0; i < 256; i++) { setGateDescriptor(i, codeSegment, &interruptIgnore, 0, _IDT_INTERRUPT_GATE_TYPE_); } #define XX _// ... _#undef XX InterruptDescriptorTablePointer idtr; idtr.limit = 256 * sizeof(GateDescriptor) - 1; idtr.base = (uint32_t)IDT; asm volatile("lidt %0" : : "m"(idtr)); } _// 顺便增加析构函数 _InterruptManager::~InterruptManager() {}
CPU开启中断也需要一个特殊的指令sti,我们用类方法activate实现它。
_// os/interrupts.h _class InterruptManager { public: _// ...
void activate(); };
_// os/interrupts.cpp // ... _void InterruptManager::activate() { asm volatile("sti"); }

4.5 修改kernel主函数和中断控制器

在kernel.cpp中引入interrupts,并在主函数kernelMain中加入全局描述符表GDT和中断管理类。
#include "interrupts.h" _// ... void kernelMain(void multiboot_structure, uint32_t magicnumber) { printf("hello worldn"); printf("hello myosn"); GlobalDescriptorTable gdt; InterruptManager interrupts(0x20, &gdt); interrupts.activate(); while (1); } // ...
下面一块内容与硬件编程相关,中断控制器需要进行一些初始化操作。中断控制器芯片是8259A,一个控制器能够控制8个引脚,最多实现8个中断。
image.png
Image
8259A通过两个I/O地址来进行中断相关的数据传送,对于单个的8259A或者是两级级联中的主8259A而言,这两个I/O地址是0x20和0x21。对于两级级联的从8259A而言,这两个I/O地址是0xA0和0xA1。
我们需要在中断管理类中定义这四个端口并初始化。
// os/interrupts.h
#include "port.h" private: Port8BitSlow priCommand; Port8BitSlow priData; Port8BitSlow semiCommand; Port8BitSlow semiData; // ...
具体的初始化和写入的操作如下。
_// os/interrupts.cpp _InterruptManager
::InterruptManager(uint16_t hardwareInterruptOffset_, GlobalDescriptorTable
gdt) : priCommand(0x20), priData(0x21), semiCommand(0xA0), semiData(0xA1) { _// ...
priCommand.write(0x11); semiCommand.write(0x11); priData.write(hardwareInterruptOffset); semiData.write(hardwareInterruptOffset + 8); priData.write(0x04); semiData.write(0x02); priData.write(0x01); semiData.write(0x01); priData.write(0x00); semiData.write(0x00); InterruptDescriptorTablePointer idtr; idtr.limit = 256 * sizeof(GateDescriptor) - 1; idtr.base = (uint32_t)IDT; asm volatile("lidt %0" : : "m"(idtr)); }
在Makefile文件中加入可执行目标asm_interrupts.o和interrupts.o,生成新的镜像文件。在虚拟机中运行,屏幕上首先打印输出
hello world hello myos
随后敲击键盘,操作系统发出键盘中断,打印出中断信息
interrupt

4.6 单例模式的InterruptManager

? 为什么需要单例模式?
在操作系统中,只存在一个全局的InterruptManager,这在C++中使用单例设计模式可以实现。但是由于我们禁用了C++标准库的链接(实际上也不能使用),无法用new分配内存,就无法在一个Singleton类模板中创建一个新的静态指针m_instance,因此采用一个类似的方法,在InterruptManager类中创建一个指针activeInterruptManager,它指向当前激活的InterruptManager实例对象。记住,在实现文件中定义声明过的静态指针。
很显然,它是一个静态指针,并且所有的中断处理请求需要由该实例对象进行处理,如果这个指针不为空的话。处理这种请求的函数应该是一个成员函数。
我们修改activate的实现,它应该将静态指针activeInterruptManager指向一个正确的InterruptManager实例对象,并且将原来的实例中断给关闭,这要求增加一个deactivate()关中断函数。
_// os/interrupts.cpp _InterruptManager *InterruptManager::activeInterruptManager = nullptr; _// ... _void InterruptManager::activate() { if (activeInterruptManager != nullptr) { activeInterruptManager->deactivate(); } activeInterruptManager = this; asm volatile("sti"); } void InterruptManager::deactivate() { if (activeInterruptManager == this) { activeInterruptManager = nullptr; asm volatile("cli"); } }
上面提到成员函数处理中断,这个成员函数命名为handleInt()。由静态指针指向的实例来调用这个函数作为中断处理,还需要修改之前定义的静态中断处理函数handleInterrupt()。
另一个问题是在硬件中断处理结束后,需要对硬件端口写入一些特定值来告知硬件中断处理已经完成。这部分应写在handleInt()中。
注意到,我们对时钟中断不进行打印interrupt字符串的操作。
_// os/interrupts.cpp _uint32_t InterruptManager::handleInterrupt(uint8_t interruptNumber, uint32_t esp) { if (activeInterruptManager) { return activeInterruptManager->handleInt(interruptNumber, esp); } return esp; } _// ... _uint32_t InterruptManager::handleInt(uint8_t interruptNumber, uint32_t esp) { if (interruptNumber != hardwareInterruptOffset) { printf("interrupt"); } if (interruptNumber >= hardwareInterruptOffset && interruptNumber < hardwareInterruptOffset + 16) { priCommand.write(0x20); if (interruptNumber >= hardwareInterruptOffset + 8) { semiCommand.write(0x20); } } return esp; }
这样一来,先前在4.3节提到的调用关系就又多了一层。
InterruptManager::HandleInterruptRequest0x00(); InterruptManager::HandleInterruptRequest0x00() { .int_bottom(); } .int_bottom() { handleInterrupt(); } handleInterrupt() { activeInterruptManager->handleInt(); }
这就完成了单例模式的设计,保证同时最多只有一个InterruptManager实例对象在处理中断请求。这个实例是在kernelMain中定义的,由于在之前没有定义过实例,此时的静态指针为空指针,程序将把调用activate函数的实例赋给该静态指针,更新IDT为最新定义的对象的IDT(这在构造函数中就完成了)。发生中断时,操作系统找到了IDT中断服务例程的入口地址,从而调用相应的处理函数。在虚拟机中运行操作系统,仍然能够得到与之前相同的结果。

4.7 定义异常基类

我们之后会定义各种类型的中断,包括鼠标、键盘等中断。之前的中断处理很简单,仅仅是打印了一行输出,下面将实现对所有256个中断的中断服务例程。
首先定义一个中断服务例程的基类InterruptRoutine,并需要在InterruptManager中包含256个中断服务例程,将它们存放在数组routine中。由于在定义之前需要使用InterruptRoutine的数组,需要做前置声明。
每定义一个InterruptRoutine,就需要将它加入中断管理实例的中断服务例程的数组中,它要访问InterruptManager的私有成员,需要作为友元类。每一个中断服务例程类对应一个中断服务例程。
// os/interrupt.h _class InterruptRoutine; class InterruptManager { friend class InterruptRoutine; public: _// ... _private: InterruptRoutine routines[256]; } class InterruptRoutine { public: uint32_t routine(uint32_t esp); protected: InterruptRoutine(uint8_t interruptNumber, InterruptManager interruptManager); ~InterruptRoutine(); uint8_t interruptNumber; InterruptManager interruptManager; };
在实现文件中,需要在InterruptManager的构造函数中对256个中断服务例程初始化,设置为空指针。
构造InterruptRoutine类时,需要将对应的InterruptManager类中的中断服务例程数组的相应元素设置成服务例程类的中断服务例程函数。析构函数被调用时,意味着这个服务例程被销毁,InterruptManager对应的服务例程也应修改为空指针。
InterruptManager
::InterruptManager(uint16_t hardwareInterruptOffset_, GlobalDescriptorTable
gdt) : priCommand(0x20), priData(0x21), semiCommand(0xA0), semiData(0xA1) { _// ...
for (uint16_t i = 0; i < 256; i++) { routines[i] = nullptr; setGateDescriptor(i, codeSegment, &interruptIgnore, 0, _IDT_INTERRUPT_GATE_TYPE_); } _// ... _} InterruptRoutine::InterruptRoutine(uint8_t interruptNumber_, InterruptManager interruptManager_) { interruptNumber = interruptNumber_; interruptManager = interruptManager_; interruptManager->routines[interruptNumber] = this; } InterruptRoutine::~InterruptRoutine() { if (interruptManager->routines[interruptNumber] == this) { interruptManager->routines[interruptNumber] = nullptr; } }
我们每定义一个中断服务例程类,就应当优先使用我们定义的这个类,而不能再简单地打印一串interrupt字符作为处理。我们还希望对于那些没有写中断服务例程的中断,在打印字符串的同时也能打印中断号,但是我们的printf()函数是不完善的,需要投机取巧一阵子,作字符替换。
uint32_t InterruptManager::handleInt(uint8_t interruptNumber, uint32_t esp) { if (routines[interruptNumber]) { esp = routines[interruptNumber]->routine(esp); } else if (interruptNumber != hardwareInterruptOffset) { char
msg = "unprocessed interrupt 0x00n"; const char *hex = "0123456789ABCDEF"; msg[22] = hex[(interruptNumber >> 4) & 0x0f]; msg[23] = hex[interruptNumber & 0x0f]; printf(msg); } if (interruptNumber >= hardwareInterruptOffset && interruptNumber < hardwareInterruptOffset + 16) { _// ...
} return esp; }
现在我们在虚拟机上运行,随意敲击键盘按键,就可以看到中断号了。下一步的任务是编写键盘、鼠标等中断服务例程和它们的驱动程序。

I/O端口

操作系统需要实现鼠标、键盘等硬件设备的相关操作,涉及到硬件编程,控制硬件是通过操作硬件芯片端口实现的。
我们将编写一个I/O端口类来实现端口管理,包括对8bit,16bit和32bit的端口。

#ifndef __PORT_H__
#define __PORT_H__

#include "types.h"

class Port 
{
protected:
    uint16_t portnumber;
    Port(uint16_t portnumber);
    ~Port();
};

class Port8Bit : public Port 
{
public:
    Port8Bit(uint16_t portnumber);
    ~Port8Bit();
    virtual void write(uint8_t data);
    virtual uint8_t read();
};

class Port16Bit : public Port
{
public:
    Port16Bit(uint16_t portnumber);
    ~Port16Bit();
    virtual void write(uint16_t data);
    virtual uint16_t read();
};

class Port32Bit : public Port
{
public:
    Port32Bit(uint16_t portnumber);
    ~Port32Bit();
    virtual void write(uint32_t data);
    virtual uint32_t read();
};

#endif

对端口的读写操作的汇编指令分别为in和out,后缀bwl表示端口的位数。这里同样使用了内联汇编的写法

  class Port8Bit : public Port {
        public:
            Port8Bit( uint16_t portnumber);
            ~Port8Bit();
            virtual void Write( uint8_t data);
            //写8bit
            virtual  uint8_t Read();
            //读8bit
        protected:
            static inline  uint8_t Read8( uint16_t _port) {
                 uint8_t result;
                __asm__ volatile("inb %1, %0" : "=a" (result) : "Nd" (_port));
                //从i/o端口读取一个字节放进result中
                return result;
            }
            static inline void Write8( uint16_t _port,  uint8_t _data) {
                __asm__ volatile("outb %0, %1" : : "a" (_data), "Nd" (_port));
                //从i/O端口输出一个字节放在data中。
            }
        };

        class Port8BitSlow : public Port8Bit {
            //继承,慢读
        public:
            Port8BitSlow( uint16_t portnumber);
            ~Port8BitSlow();
            virtual void Write( uint8_t data);
        protected:
            static inline void Write8Slow( uint16_t _port,  uint8_t _data) {
                __asm__ volatile("outb %0, %1\njmp 1f\n1: jmp 1f\n1:" : : "a" (_data), "Nd" (_port));
                //(为了实现慢读而实现,jmp 1f先向后跳,再向前跳,如此实现的慢读)
            }
        };

使用Multiboot规范编写loader.s

BootLoader是一段汇编代码,文件名为loader.s,它需要按照Multiboot规范来编译内核才可以被GRUB引导。
按照Mutileboot规范,内核必须在起始的8KB中的包含一个多引导项头(Multiboot header),里面必须包含3个4字节对齐的块:这个多引导项头里面必须有3个4字节对齐的块。

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

我们在loader.s中定义他们
image.png

.set MAGIC, 0x1badb002;                 # 魔数块
.set FLAGS, (1<<0 | 1<<1);              # 标志块<br />.set CHECKSUM, -(MAGIC + FLAGS);        # 校验块

下面的伪指令声明了Multiboot标准中的多引导项头
三个块都是32位字段

.section .multiboot
.long MAGIC//long即是4字节
.long FLAGS
.long CHECKSUM

在多引导项头之后,是程序的入口点。kernel的代码在文件kernel.cpp中,loader.s在入口点中跳转到kernel.cpp的函数中执行。首先需要使用.global伪指令告诉链接器程序的入口点,用loader表示,最后是stop代码。

kernel.cpp中主函数的名称是kernelMain,需要传入两个参数,分别是BootLoader的地址和魔数,它们存放于寄存器%eax和%ebx中,将它们压栈以传递参数。kernelMain是一个外部符号,需要提前在loader.s中声明,这些外部符号存在于.text段中。同样地,为了使得loader函数对外部可见,使用.global伪指令向外暴露loader。

.section .multiboot
    # ...
    .long CHECKSUM

.section .text
.extern kernelMain
.global loader

loader:
    push %eax       # bootloader's address in %eax
    push %ebx       # magic number in %ebx
    call kernelMain

stop:
    # 禁用中断
    cli
    # 禁用中断后使用hlt暂停CPU,以后无法再唤醒
    hlt
    jmp stop

linker脚本文件是用来控制link过程的文件,文件中包含内容为linker的处理命令,主要用于描述输入文件到输出文件(目标文件)时各个内容的的分布及内存映射等等。在上一节中的linker.ld已经告诉了链接器把需要初始化的部分放在_start_ctors和_end_ctors之间,关于链接脚本格式还有一些其他内容需要指定。
linker脚本最简单的格式为

SECTIONS
{
    . = 0x10000;
    .text : { *(.text) }
    . = 0x8000000;
    .data : { *(.data) }
    .bss : { *(.bss) }
}

.text是输出节。在大括号里列出输入节的名字,它们会放入输出节中。使用通配符匹配任何文件名,表达式(.text)意味着所有的输入文件中的输入节.text。表示通配符,.是位置计数器,它以输出节的大小增加其值,链接器会设置输出文件中的.text节的地址为0x10000。
剩下的行定义输出文件中的.data和.bss节。链接器会把输出节.data放置到地址0x8000000。之后,链接器把输出节.data的大小加到位置计数器的值0x8000000,并立即设置.bss输出节,效果是在内存中,.bss节会紧随.data之后(两个节之间可能产生必要的对齐)。
对于一个被抛弃的的输出段,链接器将会忽略给该段指定的地址,除非在输出段中有符号定义,那样的话,即使这个输出段被抛弃,链接器依然会遵守该段地址的指定。一个特殊的输出段名/DSICARD/可以用来给抛弃输入段使用,任何被放在名字为/DISCARD/输出段中的输入段都不会被包含到输出文件中。

1.5 操作系统的引导过程

有了linker.ld脚本文件,就可以执行链接的过程。在Makefile中加入链接过程。

%.o: %.s
    as ${ASPARAMS} -o $@ $<

mykernel.bin: linker.ld ${objects}
    ld ${LDPARAMS} -T $< -o $@ ${objects}

我们使用VMware Workstation启动操作系统,VMware Workstation的测试版本为16.0.0,它使用操作系统镜像启动一个操作系统。Linux有制作镜像的工具,在命令行中使用下面命令安装:

sudo apt install xorriso grub-efi-amd64 grub-pc

sudo apt install xorriso grub-efi-amd64 grub-pc
安装完毕以后可以使用指令grub-mkrescue创建iso镜像
//这里就是可以让iso镜像自动更新

set timeout=0
set default=0
menuentry "my os" {
    multiboot /boot/mykernel.bin
    boot
}

Multiboot规范规定GRUB根据/boot/grub/grub.conf文件查找Kernel信息并加载Kernel程序,grub.conf的信息如下:

set timeout=0
set default=0
menuentry "my os" {
    multiboot /boot/mykernel.bin
    boot
}

我们在Makefile文件中创建这个文件,添加代码如下:

mykernel.bin: linker.ld ${objects}
    ld ${LDPARAMS} -T $< -o $@ ${objects}

mykernel.iso: mykernel.bin
    mkdir iso
    mkdir iso/boot
    mkdir iso/boot/grub
    cp $< iso/boot/
    echo 'set timeout=0' > iso/boot/grub/grub.cfg
    echo 'set default=0' >> iso/boot/grub/grub.cfg
    echo 'menuentry "my os" {' >> iso/boot/grub/grub.cfg
    echo '    multiboot /boot/mykernel.bin' >> iso/boot/grub/grub.cfg
    echo '    boot' >> iso/boot/grub/grub.cfg
    echo '}' >> iso/boot/grub/grub.cfg
    grub-mkrescue --output=$@ iso
    rm -rf iso

clean:
    rm kernel.o loader.o mykernel.bin mykernel.iso
    //当.s文件由更改时,必须重新编译生成.o文件,然后重新链接,生成.iso

在命令行中执行

make mykernel.bin

可以看到在os目录下生成了mykernel.iso文件。
打开VMware Workstation并选择“文件 - 新建虚拟机 - 典型 - 安装程序光盘映像文件 - 选择镜像文件所在的路径”。操作系统类型更改为“其他”,内存分配64M,不需要创建磁盘。创建完毕后开启虚拟机可以看到屏幕上的hello world。
c48c175774bfe30d891a554bf526b43.png