编写内核

中断处理函数和时钟中断的实现

Posted by lzzmm on April 15, 2021
About 15 minutes to read

DCS218 - Operating Systems Lab 2021 Spring

前言

本文章介绍操作系统内核最重要的功能之一————时钟中断的实现。

编写内核

  1. bootloader 中加载操作系统内核到地址 0x20000,然后跳转到 0x20000 。内核接管控制权后,输出 “19335025CYH”。

  2. 假设我们实现的内核很小,因此下面我们约定内核的大小是 200 个扇区,起始地址是 0x20000 ,内核存放在硬盘的起始位置是第6个扇区。bootloader 在进入保护模式后,从硬盘的第6个扇区中加载200个扇区到内存起始地址 0x20000 处,然后跳转执行。

  3. 我们在 bootloader 的最后加上读取内核的代码,代码放置在 src/boot/bootloader.asm 下,如下所示。

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    ... ; 进入保护模式并初始化的代码 
       
    mov eax, KERNEL_START_SECTOR
    mov ebx, KERNEL_START_ADDRESS
    mov ecx, KERNEL_SECTOR_COUNT
       
    load_kernel: 
        push eax
        push ebx
        call asm_read_hard_disk  ; 读取硬盘
        add esp, 8
        inc eax
        add ebx, 512
        loop load_kernel
       
    jmp dword CODE_SELECTOR:KERNEL_START_ADDRESS       ; 跳转到kernel
       
    jmp $ ; 死循环
       
    ; asm_read_hard_disk(memory,block)
    ; 加载逻辑扇区号为block的扇区到内存地址memory
       
    ... ;省略
    

    常量的定义放置在 include/boot.inc 下,新增的内容如下。

    0
    1
    2
    3
    
    ; __________kernel_________
    KERNEL_START_SECTOR equ 6
    KERNEL_SECTOR_COUNT equ 200
    KERNEL_START_ADDRESS equ 0x20000
    
  4. 首先,我们在 src/boot/entry.asm 下定义内核进入点。

    0
    1
    2
    
    extern setup_kernel
    enter_kernel:
        jmp setup_kernel
    

    我们会在链接阶段巧妙地将 entry.asm 的代码放在内核代码的最开始部份,使得bootloader在执行跳转到 0x20000 后,即内核代码的起始指令,执行的第一条指令是 jmp setup_kernel。在 jmp 指令执行后,我们便跳转到使用C++编写的函数 setup_kernel。此后,我们便可以使用C++来写内核了。

    setup_kernel 的定义在文件 src/kernel/setup.cpp 中,内容如下。

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    #include "asm_utils.h"
       
    extern "C" void setup_kernel()
    {
        // asm_hello_world();
        asm_hello_world_cyh();
        while(1) {
       
        }
    }
    
  5. 为了方便汇编代码的管理,我们将汇编函数放置在 src/utils/asm_utils.h 下,如下所示。这里我添加了打印自己学号的函数 asm_hello_world_cyh :

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    global asm_hello_world_cyh
       
    asm_hello_world_cyh:
        push eax
        xor eax, eax
       
        mov ah, 0x03 ;青色
        mov al, '1'
        mov [gs:2 * 0], ax
    
         ...
    
        mov al, 'H'
        mov [gs:2 * 10], ax
       
        pop eax
        ret
       
    
  6. 然后我们统一在文件 include/asm_utils.h 中声明所有的汇编函数,这样我们就不用单独地使用 extern 来声明了,只需要 #include "asm_utils.h" 即可,如下所示。

    0
    1
    2
    3
    4
    5
    6
    
    #ifndef ASM_UTILS_H
    #define ASM_UTILS_H
       
    extern "C" void asm_hello_world();
    extern "C" void asm_hello_world_cyh();
       
    #endif
    
  7. 然后我们在 build 文件夹下开始编译,我们首先编译 MBR、bootloader。

    0
    1
    
    nasm -o mbr.bin -f bin -I../include/ ../src/boot/mbr.asm
    nasm -o bootloader.bin -f bin -I../include/ ../src/boot/bootloader.asm
    

    其中,-I 参数指定了头文件路径,-f 指定了生成的文件格式是二进制的文件。

    接着编译内核的代码。我们的方案是将所有的代码(C/C++,汇编代码)都同一编译成可重定位文件,然后再链接成一个可执行文件。

    我们首先编译 src/boot/entry.asmsrc/utils/asm_utils.asm

    0
    1
    
    nasm -o entry.obj -f elf32 ../src/boot/entry.asm
    nasm -o asm_utils.o -f elf32 ../src/utils/asm_utils.asm
    

    回忆一下,在Linux下的可重定位文件的格式是ELF文件格式。加上我们是32位保护模式,-f 参数指定的生成文件格式是 elf32,而不再是 bin

    接着编译 setup.cpp

    0
    
    g++ -g -Wall -march=i386 -m32 -nostdlib -fno-builtin -ffreestanding -fno-pic -I../include -c ../src/kernel/setup.cpp
    

    上面的参数:

    • -O0 告诉编译器不开启编译优化。
    • -Wall 告诉编译器显示所有编译器警告信息
    • -march=i386 告诉编译器生成i386处理器下的 .o 文件格式。
    • -m32 告诉编译器生成32位的二进制文件。
    • -nostdlib -fno-builtin -ffreestanding -fno-pic 是告诉编译器不要包含C的任何标准库。
    • -g 表示向生成的文件中加入debug信息供gdb使用。
    • -I 指定了代码需要的头文件的目录。
    • -c 表示生成可重定位文件。

    最后我们链接生成的可重定位文件为两个文件:只包含代码的文件 kernel.bin ,可执行文件 kernel.o

    0
    1
    
    ld -o kernel.o -melf_i386 -N entry.obj setup.o asm_utils.o -e enter_kernel -Ttext 0x00020000
    ld -o kernel.bin -melf_i386 -N entry.obj setup.o asm_utils.o -e enter_kernel -Ttext 0x00020000 --oformat binary
    

    这里面同样涉及很多参数,我们逐一来看。

    • -m 参数指定模拟器为i386。
    • -N 参数告诉链接器不要进行页对齐。
    • -Ttext 指定标号的起始地址。
    • -e 参数指定程序进入点。
    • --oformat 指定输出文件格式。

    为什么要生成两个文件呢?注意到上面两条指令差别仅在于是否有 -oformat binary 。实际上,kernel.o 也是 ELF32 格式的,其不仅包含代码和数据,还包含 debug 信息和 elf 文件信息等。特别地,kernel.o 开头并不是内核进入点,而是 ELF 的文件头,因此我们需要解析ELF文件才能找到真正的内核进入点。

    为了简便起见,我们希望链接生成的文件只有内核的代码,不会包含其他的信息,即一开头就是可执行的指令。此时,我们加上了 -oformat binary 生成这样的文件。也就是说,kernel.bin 从头到尾都是我们编写的代码对应的机器指令,不再是 ELF 格式的。此时,我们将其加载到内存后,跳转执行即可。

    kernel.o 仅用在gdb的debug过程中,通过 kernel.o ,gdb就能知道每一个地址对应的C/C++代码或汇编代码是什么,这样为我们的debug过程带来了极大的方便。

    特别注意,输出的二进制文件的机器指令顺序和链接时给出的文件顺序相同。也就是说,如果我们按如下命令链接

    0
    
    ld -o kernel.bin -melf_i386 -N setup.o entry.obj asm_utils.o -e enter_kernel -Ttext 0x0 --oformat binary
    

    那么 bootloader.bin 的第一条指令是 setup.o 的第一条指令,这样就会导致错误。

    链接后我们使用dd命令将 mbr.bin bootloader.bin kernel.bin 写入硬盘即可,如下所示。

    0
    1
    2
    
    dd if=mbr.bin of=../run/hd.img bs=512 count=1 seek=0 conv=notrunc
    dd if=bootloader.bin of=../run/hd.img bs=512 count=5 seek=1 conv=notrunc
    dd if=kernel.bin of=../run/hd.img bs=512 count=200 seek=6 conv=notrunc
    

    run 目录下,启动。

    0
    
    qemu-system-i386 -hda ../run/hd.img -serial null -parallel stdio -no-reboot
    
  8. 我们可以使用makefile的命令自动帮我们找到 .c.cpp 文件,然后编译生成 .o 文件。然后我们又可以使用makefile找到所有生成的 .o 文件,使用 ld 链接生成二进制文件。这样做的好处是当我们新增一个 .c.cpp 文件后,我们几乎不需要修改makefile,大大简化了编译过程:

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    
    ASM_COMPILER = nasm
    C_COMPLIER = gcc
    CXX_COMPLIER = g++
    CXX_COMPLIER_FLAGS = -g -Wall -march=i386 -m32 -nostdlib -fno-builtin -ffreestanding -fno-pic
    LINKER = ld
       
    SRCDIR = ../src
    RUNDIR = ../run
    BUILDDIR = build
    INCLUDE_PATH = ../include
       
    CXX_SOURCE += $(wildcard $(SRCDIR)/kernel/*.cpp)
    CXX_OBJ += $(CXX_SOURCE:$(SRCDIR)/kernel/%.cpp=%.o)
       
    ASM_SOURCE += $(wildcard $(SRCDIR)/utils/*.asm)
    ASM_OBJ += $(ASM_SOURCE:$(SRCDIR)/utils/%.asm=%.o)
       
    OBJ += $(CXX_OBJ)
    OBJ += $(ASM_OBJ)
       
    build : mbr.bin bootloader.bin kernel.bin kernel.o
    	dd if=mbr.bin of=$(RUNDIR)/hd.img bs=512 count=1 seek=0 conv=notrunc
    	dd if=bootloader.bin of=$(RUNDIR)/hd.img bs=512 count=5 seek=1 conv=notrunc
    	dd if=kernel.bin of=$(RUNDIR)/hd.img bs=512 count=145 seek=6 conv=notrunc
    # nasm的include path有一个尾随/
       
    mbr.bin : $(SRCDIR)/boot/mbr.asm
    	$(ASM_COMPILER) -o mbr.bin -f bin -I$(INCLUDE_PATH)/ $(SRCDIR)/boot/mbr.asm
       	
    bootloader.bin : $(SRCDIR)/boot/bootloader.asm 
    	$(ASM_COMPILER) -o bootloader.bin -f bin -I$(INCLUDE_PATH)/ $(SRCDIR)/boot/bootloader.asm
       	
    entry.obj : $(SRCDIR)/boot/entry.asm
    	$(ASM_COMPILER) -o entry.obj -f elf32 $(SRCDIR)/boot/entry.asm
       	
    kernel.bin : entry.obj $(OBJ)
    	$(LINKER) -o kernel.bin -melf_i386 -N entry.obj $(OBJ) -e enter_kernel -Ttext 0x00020000 --oformat binary
       	
    kernel.o : entry.obj $(OBJ)
    	$(LINKER) -o kernel.o -melf_i386 -N entry.obj $(OBJ) -e enter_kernel -Ttext 0x00020000
       	
    $(CXX_OBJ):
    	$(CXX_COMPLIER) $(CXX_COMPLIER_FLAGS) -I$(INCLUDE_PATH) -c $(CXX_SOURCE)
       	
    asm_utils.o : $(SRCDIR)/utils/asm_utils.asm
    	$(ASM_COMPILER) -o asm_utils.o -f elf32 $(SRCDIR)/utils/asm_utils.asm
    clean:
    	rm -f *.o* *.bin 
       	
    run:
    	qemu-system-i386 -hda $(RUNDIR)/hd.img -serial null -parallel stdio -no-reboot
       
    debug: 
    	qemu-system-i386 -S -s -parallel stdio -hda $(RUNDIR)/hd.img -serial null&
    	@sleep 1
    	gnome-terminal -e "gdb -q -tui -x $(RUNDIR)/gdbinit"
    
  9. 编译运行:

    0
    1
    2
    
    make clean
    make
    make run
    

    image-20210414235414035

中断的处理

  1. 以下代码在 assignment2 基础上修改。为了能够抽象地描述中断处理模块,我们不妨定义一个类,称为中断管理器 InterruptManager ,其定义放置在 include/interrupt.h 中,如下所示:

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    #ifndef INTERRUPT_H
    #define INTERRUPT_H
       
    #include "os_type.h"
       
    class InterruptManager
    {
    private:
        // IDT起始地址
        uint32 *IDT;
           
    public:
        InterruptManager();
        // 初始化
        void initialize();
        // 设置中断描述符
        // index   第index个描述符,index=0, 1, ..., 255
        // address 中断处理程序的起始地址
        // DPL     中断描述符的特权级
        void setInterruptDescriptor(uint32 index, uint32 address, byte DPL);
    };
       
    #endif
    
  2. 在使用中断之前,首先需要初始化IDT,在 boot/kernel/interrupt 中添加函数 InterruptManager::initialize ,如下所示:默认函数

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    void InterruptManager::initialize()
    {
        // 初始化IDT
        IDT = (uint32 *)IDT_START_ADDRESS;
        asm_lidt(IDT_START_ADDRESS, 256 * 8 - 1);
       
        for (uint i = 0; i < 256; ++i)
        {
            setInterruptDescriptor(i, (uint32)asm_interrupt_empty_handler, 0);
        }
    }
    

    InterruptManager::initialize 先设置IDTR,然后再初始化256个中断描述符,将IDT设定在地址 0x8880 处,即 IDT_START_ADDRESS=0x8880 。为了使CPU能够找到IDT中的中断处理函数,我们需要将IDT的信息放置到寄存器IDTR中。当中断发生时,CPU会自动到IDTR中找到IDT的地址,然后根据中断向量号在IDT找到对应的中断描述符,最后跳转到中断描述符对应的函数中进行处理.

    由于我们只有256个中断描述符,每个中断描述符的大小均为8字节,因此我们有

    $表界限=8*256-1=2047$

    此时,IDTR的32位基地址是 0x8880 ,表界限是 2047

    确定了IDT的基地址和表界限后,我们就可以初始化IDTR了。IDTR的初始化需要用到指令 lidtlidt 实际上是将以 tag 为起始地址的48字节放入到寄存器IDTR中。由于我们打算在C代码中初始化IDT,而C语言的语法并未提供 lidt 语句。因此我们需要在汇编代码中实现能够将IDT的信息放入到IDTR的函数 asm_lidt,代码添加到 src/utils/asm_utils.asm 中,如下所示:

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    ; void asm_lidt(uint32 start, uint16 limit)
    asm_lidt:
        push ebp
        mov ebp, esp
        push eax
       
        mov eax, [ebp + 4 * 3]
        mov [ASM_IDTR], ax
        mov eax, [ebp + 4 * 2]
        mov [ASM_IDTR + 2], eax
        lidt [ASM_IDTR]
       
        pop eax
        pop ebp
        ret
           
    ASM_IDTR dw 0
          dd 0
    
  3. 将IDT的信息放入到IDTR后,我们就可以插入256个默认的中断处理描述符到IDT中。实际上在我们的实验中,对于中断描述符,有几个值是定值:

    • P=1表示存在。
    • D=1表示32位代码。
    • DPL=0表示特权级0.
    • 代码段选择子等于bootloader中的代码段选择子,也就是寻址4GB空间的代码段选择子。

    因此,从目前来看,不同的中断描述符间变化的只是中断处理程序在目标代码段中的偏移。由于我们的程序运行在平坦模式下,也就是段起始地址从内存地址0开始,长度为4GB。此时,函数名就是中断处理程序在目标代码段中的偏移。

    将段描述符的设置定义在函数 InterruptManager::setInterruptDescriptor 中,如下所示:

    0
    1
    2
    3
    4
    5
    6
    7
    8
    
    // 设置中断描述符
    // index   第index个描述符,index=0, 1, ..., 255
    // address 中断处理程序的起始地址
    // DPL     中断描述符的特权级
    void InterruptManager::setInterruptDescriptor(uint32 index, uint32 address, byte DPL)
    {
        IDT[index * 2] = (CODE_SELECTOR << 16) | (address & 0xffff);
        IDT[index * 2 + 1] = (address & 0xffff0000) | (0x1 << 15) | (DPL << 13) | (0xe << 8);
    }
    

    其中,IDT 是中断描述符表的起始地址指针,实际上我们可以认为中断描述符表就是一个数组。在 InterruptManager 中,我们将变量 IDT 视作是一个 uint32 类型的数组。由于每个中断描述符的大小是两个 uint32 ,第 index 个中断描述符是 IDT[2 * index],IDT[2 * index + 1]

  4. 接下来,我们定义一个默认的中断处理函数是 asm_interrupt_empty_handler ,放置在 src/utils/asm_utils.asm 中,如下所示。

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    ASM_UNHANDLED_INTERRUPT_INFO db 'Unhandled interrupt happened, halt...'
                                 db 0
       
    ; void asm_unhandled_interrupt()
    asm_unhandled_interrupt:
        cli
        mov esi, ASM_UNHANDLED_INTERRUPT_INFO
        xor ebx, ebx
        mov ah, 0x03
    .output_information:
        cmp byte[esi], 0
        je .end
        mov al, byte[esi]
        mov word[gs:bx], ax
        inc esi
        add ebx, 2
        jmp .output_information
    .end:
        jmp $
    

    asm_interrupt_empty_handler 首先关中断,然后输出提示字符串,最后做死循环。

  5. InterruptManager::initialize 最后,我们调用 setInterruptDescriptor 放入256个默认的中断描述符即可,这256个默认的中断描述符对应的中断处理函数是 asm_unhandled_interrupt

    0
    1
    2
    
    for (uint i = 0; i < 256; ++i) {
    	setInterruptDescriptor(i, (uint32)asm_unhandled_interrupt, 0);
    }
    
  6. 在函数 src/kernel/setup.cpp 中定义并初始化中断处理器。注意,只定义一个 InterruptManager 的实例,因为中断管理器有且只有一个。

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    #include "asm_utils.h"
    #include "interrupt.h"
       
    // 中断管理器
    InterruptManager interruptManager;
       
    extern "C" void setup_kernel() {
           
        asm_hello_world_cyh();
        // asm_hello_world();
        // while(1) {
       
        // }
        // 中断处理部件
        interruptManager.initialize();
       
        // 尝试触发除0错误
        int a = 1 / 0;
       
        // 死循环
        asm_halt();
    }
    

    然后在 include/os_modules.h 中声明这个实例,以便在其他 .cpp 文件中使用。

    0
    1
    2
    3
    4
    5
    6
    7
    
    #ifndef OS_MODULES_H
    #define OS_MODULES_H
       
    #include "interrupt.h"
       
    extern InterruptManager interruptManager;
       
    #endif
    
  7. 最后将一些常量统一定义在文件 include/os_constant.h 下:

    0
    1
    2
    3
    4
    5
    6
    
    #ifndef OS_CONSTANT_H
    #define OS_CONSTANT_H
       
    #define IDT_START_ADDRESS 0x8880
    #define CODE_SELECTOR 0x20
       
    #endif
    
  8. 更新 include/asm_utils.h

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    #ifndef ASM_UTILS_H
    #define ASM_UTILS_H
       
    #include "os_type.h"
       
    extern "C" void asm_hello_world_cyh();
    extern "C" void asm_hello_world();
       
    extern "C" void asm_lidt(uint32 start, uint16 limit);
    extern "C" void asm_unhandled_interrupt();
    extern "C" void asm_halt();
       
    #endif
    

    include/utils/assm_utils.asm 中添加 halt函数:

    0
    1
    
    asm_halt:
        jmp $
    
  9. 编译,以debug模式运行

    0
    1
    
    make
    make debug
    

    2021-04-15 21-04-54 的屏幕截图

    2021-04-15 21-05-13 的屏幕截图

    2021-04-15 21-05-26 的屏幕截图

    把默认函数更改为自己的函数:

    2021-04-15 21-35-44 的屏幕截图

时钟中断

  1. 为中断控制器 InterruptManager 加入如下成员变量和函数。

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    
    class InterruptManager
    {
    private:
        uint32 *IDT;              // IDT起始地址
           
        uint32 IRQ0_8259A_MASTER; // 主片中断起始向量号
        uint32 IRQ0_8259A_SLAVE;  // 从片中断起始向量号
       
    public:
        InterruptManager();
        void initialize();
        // 设置中断描述符
        // index   第index个描述符,index=0, 1, ..., 255
        // address 中断处理程序的起始地址
        // DPL     中断描述符的特权级
        void setInterruptDescriptor(uint32 index, uint32 address, byte DPL);
           
        // 开启时钟中断
        void enableTimeInterrupt();
        // 禁止时钟中断
        void disableTimeInterrupt();
        // 设置时钟中断处理函数
        void setTimeInterrupt(void *handler);
       
    private:
        // 初始化8259A芯片
        void initialize8259A();
    };
    
  2. 初始化8259A

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    void InterruptManager::initialize8259A()
    {
        // ICW 1
        asm_out_port(0x20, 0x11);
        asm_out_port(0xa0, 0x11);
        // ICW 2
        IRQ0_8259A_MASTER = 0x20;
        IRQ0_8259A_SLAVE = 0x28;
        asm_out_port(0x21, IRQ0_8259A_MASTER);
        asm_out_port(0xa1, IRQ0_8259A_SLAVE);
        // ICW 3
        asm_out_port(0x21, 4);
        asm_out_port(0xa1, 2);
        // ICW 4
        asm_out_port(0x21, 1);
        asm_out_port(0xa1, 1);
       
        // OCW 1 屏蔽主片所有中断,但主片的IRQ2需要开启
        asm_out_port(0x21, 0xfb);
        // OCW 1 屏蔽从片所有中断
        asm_out_port(0xa1, 0xff);
    }
    

    初始化8259A芯片的过程是通过设置一系列的ICW字来完成的。由于并未建立处理8259A中断的任何函数,因此在初始化的最后,需要屏蔽主片和从片的所有中断。

    其中,asm_out_port 是对 out 指令的封装,放在 asm_utils.asm 中,如下所示:

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    ; void asm_out_port(uint16 port, uint8 value)
    asm_out_port:
        push ebp
        mov ebp, esp
       
        push edx
        push eax
       
        mov edx, [ebp + 4 * 2] ; port
        mov eax, [ebp + 4 * 3] ; value
        out dx, al
           
        pop eax
        pop edx
        pop ebp
        ret
    
  3. 接下来处理主片的IRQ0中断。在计算机中,有一个称为8253的芯片,其能够以一定的频率来产生时钟中断。当其产生了时钟中断后,信号会被8259A截获,从而产生IRQ0中断。处理时钟中断并不需要了解8253芯片,只需要对8259A芯片产生的时钟中断进行处理即可,步骤如下。

    • 编写中断处理函数。
    • 设置主片IRQ0中断对应的中断描述符。
    • 开启时钟中断。
    • 开中断。

    我们首先编写中断处理的函数。

    此时需要对屏幕进行输出,之前只是单纯地往显存地址上赋值来显示字符。但是这样做并不太方便。因此简单封装一个能够处理屏幕输出的类 STDIO ,声明放置在文件 include/stdio.h

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    
    #ifndef STDIO_H
    #define STDIO_H
       
    #include "os_type.h"
       
    class STDIO
    {
    private:
        uint8 *screen;
       
    public:
        STDIO();
        // 初始化函数
        void initialize();
        // 打印字符c,颜色color到位置(x,y)
        void print(uint x, uint y, uint8 c, uint8 color);
        // 打印字符c,颜色color到光标位置
        void print(uint8 c, uint8 color);
        // 打印字符c,颜色默认到光标位置
        void print(uint8 c);
        // 移动光标到一维位置
        void moveCursor(uint position);
        // 移动光标到二维位置
        void moveCursor(uint x, uint y);
        // 获取光标位置
        uint getCursor();
       
    public:
        // 滚屏
        void rollUp();
    };
       
    #endif
    

    代码实现放置在 src/kernel/stdio.cpp ,此处略。

  4. 接下来定义中断处理函数 c_time_interrupt_handler 。由于我们需要显示中断发生的次数,我们需要在 src/kernel/interrupt.cpp 中定义一个全局变量来充当计数变量,如下所示。

    0
    
    int times = 0;
    

    中断处理函数 c_time_interrupt_handler 如下所示:

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    
    // 中断处理函数
    extern "C" void c_time_interrupt_handler()
    {
        // 清空屏幕
        for (int i = 0; i < 80; ++i)
        {
            stdio.print(0, i, ' ', 0x07);
        }
       
        // 输出中断发生的次数
        ++times;
        char str[] = "interrupt happend: ";
        char number[10];
        int temp = times;
       
        // 将数字转换为字符串表示
        for(int i = 0; i < 10; ++i ) {
            if(temp) {
                number[i] = temp % 10 + '0';
            } else {
                number[i] = '0';
            }
            temp /= 10;
        }
       
        // 移动光标到(0,0)输出字符
        stdio.moveCursor(0);
        for(int i = 0; str[i]; ++i ) {
            stdio.print(str[i]);
        }
       
        // 输出中断发生的次数
        for( int i = 9; i > 0; --i ) {
            stdio.print(number[i]);
        }
    }
    
  5. 由于C语言缺少可以编写一个完整的中断处理函数的语法,因此当中断发生后,CPU首先跳转到汇编实现的代码,然后使用汇编代码保存寄存器的内容。保存现场后,汇编代码调用 call 指令来跳转到C语言编写的中断处理函数主体。C语言编写的函数返回后,指令的执行流程会返回到 call 指令的下一条汇编代码。此时,我们使用汇编代码恢复保存的寄存器的内容,最后使用 iret 返回。

    一个完整的时钟中断处理函数如下所示,代码保存在 asm_utils.asm 中。

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    asm_time_interrupt_handler:
        pushad
           
        nop ; 否则断点打不上去
        ; 发送EOI消息,否则下一次中断不发生
        mov al, 0x20
        out 0x20, al
        out 0xa0, al
           
        call c_time_interrupt_handler
       
        popad
        iret
    

    其中,pushad 指令是将 EAX , ECX , EDX , EBX , ESP , EBP , ESI , EDI 依次入栈,popad 则相反。注意,对于8259A芯片产生的中断,我们需要在中断返回前发送EOI消息。否则,8259A不会产生下一次中断。

    编写好了中断处理函数后,我们就可以设置时钟中断的中断描述符,也就是主片IRQ0中断对应的描述符,如下所示。

    0
    1
    2
    3
    
    void InterruptManager::setTimeInterrupt(void *handler)
    {
        setInterruptDescriptor(IRQ0_8259A_MASTER, (uint32)handler, 0);
    }
    
  6. 然后封装一下开启和关闭时钟中断的函数。关于8259A上的中断开启情况,可以通过读取OCW1来得知;如果要修改8259A上的中断开启情况就需要先读取再写入对应的OCW1。

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
    void InterruptManager::enableTimeInterrupt()
    {
        uint8 value;
        // 读入主片OCW
        asm_in_port(0x21, &value);
        // 开启主片时钟中断,置0开启
        value = value & 0xfe;
        asm_out_port(0x21, value);
    }
       
    void InterruptManager::disableTimeInterrupt()
    {
        uint8 value;
        asm_in_port(0x21, &value);
        // 关闭时钟中断,置1关闭
        value = value | 0x01;
        asm_out_port(0x21, value);
    }
    
  7. 最后在 setup_kernel 中定义 STDIO 的实例 stdio ,最后初始化内核的组件,然后开启时钟中断和开中断。

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    extern "C" void setup_kernel()
    {
        // 中断处理部件
        interruptManager.initialize();
        // 屏幕IO处理部件
        stdio.initialize();
        interruptManager.enableTimeInterrupt();
        interruptManager.setTimeInterrupt((void *)asm_time_interrupt_handler);
        asm_enable_interrupt();
        asm_halt();
    }
    

    include/os_modules.h 声明这个实例。

    0
    1
    2
    3
    4
    5
    6
    7
    8
    
    #ifndef OS_MODULES_H
    #define OS_MODULES_H
       
    #include "interrupt.h"
       
    extern InterruptManager interruptManager;
    extern STDIO stdio;
       
    #endif
    

    开中断需要使用sti指令,如果不开中断,那么CPU不会响应可屏蔽中断。也就是说,即使8259A芯片发生了时钟中断,CPU也不会处理。开中断指令被封装在函数asm_enable_interrupt中,如下所示:

    0
    1
    2
    3
    
    ; void asm_enable_interrupt()
    asm_enable_interrupt:
        sti
        ret
    
  8. 现在编译运行代码:

    0
    
    make && make run
    

    最后加载qemu运行,效果如下:

    image-20210415222735197

  9. 实现跑马灯,在中断函数添加:

    0
    1
    2
    3
    4
    5
    6
    7
    
    // 跑马灯
        for(int i = 0; i<times%40;i++) {
            stdio.print(' ');
        }
       
        for(int i = 0; str1[i]; ++i) {
            stdio.print(str1[i]);
        }
    
  10. 编译运行,效果如下:

    image-20210415223532450

    image-20210415223547368

    image-20210415223605520

总结

此次实验学习并实现了保护模式下的中断处理,并了解了IDT的机制和中断处理芯片8259A的使用方法。最后使用混合编程实现了时钟中断。在实验过程中体会到了保护模式下中断的精妙和混合编程的便利,收获良多。

参考

https://gitee.com/nelsoncheung/sysu-2021-spring-operating-system/tree/main

Creative Commons License本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.