OS_Lab4

OS_Lab4:中断

实验要求

  • DDL:2024年4月20号 24:00
  • 提交的内容:将4个assignment的代码实验报告放到压缩包中,命名为“lab4-学号-姓名”,并交到课程并交到课程邮箱 os_sysu_lab@163.com
    将实验报告的pdf提交至 http://inbox.weiyun.com/NOKI03hf
  • Example材料的代码放置在src目录下

实验概述

本章会学习:

  • C代码变成C程序的过程。
  • C/C++项目的组织方法。
  • makefile的使用。
  • C和汇编混合编程。
  • 保护模式中断处理机制。
  • 8259A可编程中断处理部件。
  • 时钟中断的处理。

通过本章的学习,同学们将掌握使用C语言来编写内核的方法,理解保护模式的中断处理机制和处理时钟中断,为后面的二级分页机制和多线程/进程打下基础。

Resources

C语言-从代码到程序的过程理解 - 亥码 - 博客园

C代码变成C程序的过程

  1. 预处理:输入源程序并保存(.C文件)。
    • 处理源代码中以“#”开头的预编译指令
    • 删掉注释行
    • .i文件中不包含任何宏定义和注释行
  2. 将源文件转换成汇编代码(.s文件)的过程
    • 词法分析 -> 语法分析 -> 语义分析及相关的优化-> 中间代码生成 -> 目标代码生成(汇编文件.s)
  3. 汇编阶段是把编译阶段生成的”.s”文件转成二进制目标代码(.o文件)。
  4. 将多个目标文件链接生成可执行文件( .EXE文件(windows),.out文件(Linux))。

gcc指令生成中间过程文件:
gcc [选项] 要编译的文件 [选项] [目标文件]
or
gcc [option] filename [option] [objectfile]

1
2
3
4
5
gcc main.c //直接生成可执行文件main.out
gcc hello.c -o hello
gcc -E main.c -o hello.i //生成预处理后的代码(还是文本文件)
gcc -S main.c -o hello.s //汇编代码
gcc -c main.c -o hello.o //目标代码

example0

使用gcc跑一下程序

or(可以不列举.h,因为.c文件中已经#include了,在.c=>.i阶段会将.h文件内容展开插入到)

  • 在.h中编写函数声明,不要在.h中实现函数,如果.h被多次引用可能会导致出现函数重定义问题
  • 编译时要把所有.c和.cpp文件都加上去编译

查看各个步骤

  • 预处理生成.i文件:
    gcc -E main.c -o main
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 0 "main.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.c"
# 1 "print.h" 1


void print_something();
# 2 "main.c" 2

int main() {

    print_something();

}
  • 编译生成汇编文件
    gcc -S main.c -o main.s -masm=intel
    这里 -masm=intel 指示生成intel风格的汇编代码,否则默认AT&T风格代码
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
	.file	"main.c"
.intel_syntax noprefix
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
push rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp
.cfi_def_cfa_register 6
mov eax, 0
call print_something@PLT
mov eax, 0
pop rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
  • 重定位
    gcc main.c print.c -o main.o
    • 在linux下,可重定位文件的格式是ELF文件格式,其包含了ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)等信息。
  • gcc是编译工具集合,在进行这些操作时会自动调用as、ld等

Makefile(OS_Lab3中也有相关内容)


C/C++和汇编混合编程

  • 在C/C++代码中使用汇编代码实现的函数。
  • 在汇编代码中使用C/C++中的函数。

混合编程是必要的,例如在bootloader初始化后,我们需要跳转到C/C++编写的函数中执行;又如我们需要在C/C++中调用使用汇编代码读取硬盘的函数。

  • 使用汇编函数(使用汇编函数实现的函数)来代替内联汇编

=>汇编代码和C代码最终都会转换成可执行代码

  • 如果要在汇编代码中使用c函数,只需要在汇编代码中声明这个函数来自外部(在链接阶段才会使用到函数实现)
  • 同理,要在c中使用汇编函数也只需要声明用到的函数来自外部即可
    =>how?
  • 汇编代码
    使用来自C的函数:
    声明:
1
extern function_from_C

使用时:

1
call function_from_C

使用来自CPP的函数:

  • 需要在C++代码的函数定义前加上extern "C"
  • 因为C++支持函数重载,为了区别同名的重载函数,C++在编译时会进行名字修饰。也就是说,function_from_CPP编译后的标号不再是function_from_CPP,而是要带上额外的信息。而==C代码编译后的标号还是原来的函数名==。
  • extern “C” 目的是告诉编译器按C代码规则编译,不加名字修饰。
    在C++代码中声明:
1
extern "C" void functionFromCpp();

在汇编代码中声明:

1
extern function_from_CPP
  • C/CPP:
    要先在汇编代码中奖函数声明为 global
1
global function_from_asm

C/C++中声明其来自外部:

1
extern void function_from_asm();

在C++中需要声明为 extern "C"

1
extern "C" void function_from_asm();

如果函数带返回值和参数

  • 如果函数有参数,那么参数==从右向左==依次入栈
  • 如果函数有返回值,返回值放在eax中。
  • 放置于栈的参数一般使用ebp来获取。
    特别注意,汇编函数并没有函数参数和返回值的概念,因此汇编函数也被称为过程,不过是一段指令序列而已。

Example1 混合编程

  • 在文件c_func.c中定义C函数function_from_C
  • 在文件cpp_func.cpp中定义C++函数function_from_CPP
  • 在文件asm_func.asm中定义汇编函数function_from_asm,在function_from_asm中调用function_from_Cfunction_from_CPP
  • 在文件main.cpp中调用汇编函数function_from_asm

指令:

1
2
3
4
5
gcc -o c_func.o -m32 -c c_func.c
g++ -o cpp_func.o -m32 -c cpp_func.cpp
g++ -o main.o -m32 -c main.cpp
nasm -o asm_utils.o -f elf32 asm_utils.asm
g++ -o main.out main.o c_func.o cpp_func.o asm_utils.o -m32

-f elf32指定了nasm编译生成的文件格式是ELF32文件格式

Example2:内核加载

项目结构:

  • project/build。存放Makefile,make之后生成的中间文件如.o.bin等会放置在这里,目的是防止这些文件混在代码文件中。
  • project/include。存放.h等函数定义和常量定义的头文件等。
  • project/run。存放gdb配置文件,硬盘映像.img文件等。
  • project/src。存放.c.cpp等函数实现的文件。

不需要在.cpp文件中写出.h的具体地址,只需要在编译时指出include和.cpp文件的地址就行了。
编译指令中使用-I参数指明头文件的位置即可:

1
g++ -o hello -I../include ../src/hello.cpp

mbr => bootloader => kernel
进入内核后,定义内核起始点
src/boot/entry.asm:

1
2
3
extern setup_kernel
enter_kernel:
jmp setup_kernel

在链接阶段巧妙地将entry.asm的代码放在内核代码的最开始部分,使得bootloader在执行跳转之后,转到的就是内核代码的起始指令,执行 jmp setup_kernel 。然后就跳转到了C++编写的函数setup_kernel,即可以使用c++来写内核了。

运行代码:
way1:不使用makefile

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
cd build
# 编译mbr和bootloader
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`指定了生成的文件格式是二进制的文件。

# 编译内核的代码,将所有的代码都统一编译成可重定位文件,然后再链接成一个可执行文件
# 编译 entry.asm和asm_utils.asm
nasm -o entry.obj -f elf32 ../src/boot/entry.asm
nasm -o asm_utils.o -f elf32 ../src/utils/asm_utils.asm

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

# 链接生成可重定位文件:kernel.bin和kernel.o(只包含代码的文件和可执行文件)

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

# 最后将mbr.bin bootloader.bin kernel.bin写入硬盘

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目录下启动

qemu-system-i386 -hda ../run/hd.img -serial null -parallel stdio -no-reboot

way2:使用makefile

1
2
3
cd build
make
make run

一些解释:

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

参数介绍:

  • -O0告诉编译器不开启编译优化。(如果要开启有几种可以选择,O1,O2,O3…
  • -Wall告诉编译器显示所有编译器警告信息
  • -march=i386告诉编译器生成i386处理器下的.o文件格式。
  • -m32告诉编译器生成32位的二进制文件。
  • -nostdlib -fno-builtin -ffreestanding -fno-pic是告诉编译器不要包含C的任何标准库。
  • -g表示向生成的文件中加入debug信息供gdb使用。
  • -I指定了代码需要的头文件的目录。
  • -c表示生成可重定位文件。
1
2
3
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指定输出文件格式

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

=>为了简便,我们只希望链接生成的文件只有内核的代码,不包含其他信息。

[!NOTE]
输出的二进制文件的机器指令顺序和链接时给出的文件顺序相同
所以要注意把entry.o放到最前面

保护模式下的中断

中断:在外设产生请求时,通过一种信号告诉CPU应该暂停当前状态,转向处理外设请求,处理完之后再恢复到原先暂停的状态继续运行。
中断:

  • 外部中断(硬件产生,硬中断)
    • 屏蔽中断–INTR引脚产生
    • 不可屏蔽中断–NMI引脚产生
  • 内部中断(软件产生,在程序中使用int指令调用,软中断)

保护模式下的中断向量号:

向量号 助记符 说明 类型 错误号 产生源
0 #DE 除出错 故障 DIV或IDIV指令
1 #DB 调试 故障/陷阱 任何代码或数据引用,或是INT 1指令
2 NMI中断 中断 非屏蔽外部中断
3 #BP 断点 陷阱 INT 3指令
4 #OF 溢出 陷阱 INTO指令
5 #BR 边界范围超出 故障 BOUND指令
6 #UD 无效操作码(未定义操作码) 故障 UD2指令或保留的操作码。(Pentium Pro中加入的新指令)
7 #NM 设备不存在(无数学协处理器) 故障 浮点或WAIT/FWAIT指令
8 #DF 双重错误 异常终止 有(0) 任何可产生异常、NMI或INTR的指令
9 协处理器段超越(保留) 故障 浮点指令(386以后的CPU不产生该异常)
10 #TS 无效的任务状态段TSS 故障 任务交换或访问TSS
11 #NP 段不存在 故障 加载段寄存器或访问系统段
12 #SS 堆栈段错误 故障 堆栈操作和SS寄存器加载
13 #GP 一般保护错误 故障 任何内存引用和其他保护检查
14 #PF 页面错误 故障 任何内存引用
15 (Intel保留,请勿使用)
16 #MF x87 FPU浮点错误(数学错误) 故障 x87 FPU浮点或WAIT/FWAIT指令
17 #AC 对起检查 故障 有(0) 对内存中任何数据的引用
18 #MC 机器检查 异常终止 错误码(若有)和产生源与CPU类型有关(奔腾处理器引进)
19 #XF SIMD浮点异常 故障 SSE和SSE2浮点指令(PIII处理器引进)
20-31 (Intel保留,请勿使用)
32-255 用户定义(非保留)中断 中断 外部中断或者INT n指令

中断处理机制

保护模式下中断处理程序处理过程:

  • 中断前的准备。
  • CPU 检查是否有中断信号
  • CPU根据中断向量号到IDT中取得处理这个向量的中断描述符
  • CPU根据中断描述符中的段选择符到 GDT 中找到相应的段描述符。
  • CPU 根据特权级的判断设定即将运行程序的栈地址。
  • CPU保护现场。
  • CPU跳转到中断服务程序的第一条指令开始处执行。
  • 中断服务程序运行。
  • 中断服务程序处理完成,使用iret返回。

中断前的准备

  • 为了标识中断处理程序的位置,保护模式使用了中断描述符(64位)。

  • 段选择子:中断程序所在段的选择子。

  • 偏移量:中断程序的代码在中断程序所在段的偏移位置。

  • P位:段存在位。 0表示不存在,1表示存在。

  • DPL:特权级描述。 0-3 共4级特权,特权级从0到3依次降低。

  • D位: D=1表示32位代码,D=0表示16位代码。

  • 保留位:保留不使用。

中断描述符的结合被称为中断描述符表IDT,并存放在IDTR中。

中断描述符最多有2^16/2^3=2^13
但CPU只能处理前256个中断,所以我们只会往IDT中放入256个中断描述符。
类似地,我们使用lidt指令对IDTR赋值
使用lgdt为GDTR赋值

CPU检查是否有中断信号

  • 除了主动调用中断之外,CPU每执行完一条指令之后,就回去中断控制器8259A中检查是否有中断请求。
  • 若有中断请求,在相应的时钟脉冲到来时,CPU就会从总线上读取中断向量号
  • 根据中断向量号到IDT中取得对应的中断描述符(中断的向量号就是中断描述符在IDT的序号)
  • CPU根据中断描述符中的段选择符GDT中找到相应的段描述符
  • CPU 根据特权级的判断设定即将运行程序的栈地址
  • CPU保护现场
    • 依次将EFLAGS,CS,EIP中的内容压栈(特权级不变时)
    • 从用户态切换到内核态后,CPU会依次将SS,ESP,EFLAGS、CS、EIP压栈(特权级改变时)
  • CPU跳转到中断服务程序的第一条指令开始处执行
  • 中断服务程序运行
  • 中断服务程序处理完成,使用iret返回。 
    • 在特权级不发生变化的情况下,iret会将之前压入栈的EFLAGS,CS,EIP的值送入对应的寄存器,然后便实现了中断返回。若特权级发生变化,CPU还会更新SS和ESP。

Example3

=>what to do:初始化IDT的256个中断

  • 这256个中断的中断处理程序均是向栈中压入0xdeadbeef后做死循环。
    我们要做的事情只有三件。
  • 确定IDT的地址。
  • 定义中断默认处理函数。
  • 初始化256个中断描述符。

中断管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#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
初始化IDT: initialize()
1
2
3
4
5
6
7
8
9
10
11
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);
}
}

设置IDT地址,然后初始化256个中断描述符(每个8字节)

  • IDT_START_ADDRESS=…
  • CPU=>IDTR=>IDT
    • CPU先到IDTR寻找IDT的地址
    • 根据中断向量号在IDT找到对应的中断描述符
    • 跳转到对应的函数
  • 此处确定IDTR的32位基地址为0x8880,表界限为2047(8 * 256 - 1)
  • lidt [tag]
    • 将以tag为起始地址的48字节放入到寄存器IDTR中
    • C语言中初始化IDT的方法:在汇编代码中实现函数 asm_lidt 用于将IDT信息放入到IDTR中
定义中断描述符

中断描述符中有几个值是定值:

  • P=1表示存在。
  • D=1表示32位代码。
  • DPL=0表示特权级0.
  • 代码段选择子等于bootloader中的代码段选择子,也就是寻址4GB空间的代码段选择子。
1
2
3
4
5
6
7
8
9
// 设置中断描述符
// 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是起始地址指针
中断默认处理函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 $

8259A 芯片(可编程中断控制器)

[!NOTE]
硬中断和软中断指示调用方式不同,而中断的初始化和中断描述符的设置方式是完全相同的

计算机需要知道这些中断请求的中断向量号和优先级,可以通过8259A芯片解决(用代码来修改其处理优先级、屏蔽某个中断等)

8259A的初始化

初始化过程是依次通过向8259A的特定端口发送4个ICW,ICW1~ICW4(初始化命令字,Initialization Command Words)来完成的。

[!NOTE]
四个ICW必须严格按照顺序依次发送

ICW结构

ICW1:发送到0x20端口(主片)和0xA0端口(从片)

  • I位:若置1,表示ICW4会被发送。置0表示ICW4不会被发送。我们会发送ICW4,所以I位置1。

  • C位:若置0,表示8259A工作在==级联==环境下。8259A的主片和从片我们都会使用到,所以C位置0。

  • M位:指出中断请求的电平触发模式,在PC机中,M位应当被置0,表示采用“边沿触发模式”。

ICW2:发送到0x21(主片)和0xA1(从片)端口

对于主片和从片,ICW2都是用来表示当==IRQ0==的中断发生时,8259A会向CPU提供的中断向量号。

此后,IRQ0,IRQ1,…,IRQ7的中断号为ICW2,ICW2+1,ICW2+2,…,ICW2+7。

==ICW2的低3位必须是0==

ICW3:发送到0x21(主片)和0xA1(从片)端口
  • ICW3只有在级联工作时才会被发送,主要用来建立两处PIC之间的连接,对于主片和从片,其结构是不一样的
  • 主片:

    上面的相应位被置1,则相应的IRQ线就被用作于与从片相连,若置0则表示被连接到外围设备。

从片被连接到主片的IRQ2位,所以主片只有第2位被置1=>主片ICW3=0x04

  • 从片:

    IRQ指出是主片的哪一个IRQ连接到了从片,这里,从片的ICW3=0x02,即IRQ=0x02,其他位置均为0。
ICW4:发送到0x21(主片)和0xA1(从片)端口

  • EOI位:若置1表示自动结束,在PC位上这位需要被清零,详细原因在后面再提到。

  • 80x86位:置1表示PC工作在80x86架构下,因此我们置1。

[!NOTE]
ICW1,ICW3,ICW4的值已经固定,可变的只有ICW2

8259A的工作流程

(无需掌握)

[!NOTE]
对于8259A芯片产生的中断,我们需要手动在中断返回前向8259A发送EOI消息。如果没有发送EOI消息,那么此后的中断便不会被响应

发送EOI消息的示例代码:

1
2
3
4
;发送OCW2字
mov al, 0x20
out 0x20, al
out 0xa0, al
  • 8259A的中断处理函数末尾必须加上这段代码,否则中断不会被响应

优先级、中断屏蔽字和EOI消息的动态改变

初始化8259A后,可以在任何时优先级、中断屏蔽字和EOI消息的动态改变候发送OCW(Operation Command Words)字来实现

OCW有三个:OCW1,OCW2,OCW3

  • OCW1:中断屏蔽,发送到0x21(主片)或0xA1(从片)端口

    位置1表示屏蔽相应的IRQ请求
    在初始化8259A的代码末尾,将0xFF发送到0x21和0xA1端口。这是因为我们还没建立起处理8259A芯片的中断处理函数,所以暂时屏蔽主片和从片的所有中断。

  • OCW2:一般用于发送EOI消息,发送到0x20(主片)或0xA0(从片)端口。

    EOI消息是发送0x20,即只有EOI位是1其他位置为0

  • OCW3:用于设置下一个读端口动作将要读取的IRR或ISR,我们不需要使用。

中断程序编写思路

  • 保护现场。保存寄存器中的内容(压栈)
  • 中断处理。执行中断处理程序
  • 恢复现场。处理完中断后恢复之前放在栈中的寄存器内容,然后执行 iret 返回。执行 iret前,如果有错误码,则需要将错误码弹出栈;如果是8259A芯片产生的中断,则需要在中断返回前发送EOI消息。
    • 注意,8259A芯片产生的中断不会错误码。事实上,只有中断向量号1-19的部分中断才会产生错误码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interrupt_handler_example:
pushad
... ; 中断处理程序
popad

; 非必须

; 1 弹出错误码,没有则不可以加入
add esp, 4

; 2 对于8259A芯片产生的中断,最后需要发送EOI消息,若不是则不可以加入
mov al, 0x20
out 0x20, al
out 0xa0, al

iret ;中断返回

Example4 8259A编程

  • 初始化8259A芯片:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 指令的封装
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
  • asm_in_port 是对 in 指令的封装
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
; void asm_in_port(uint16 port, uint8 *value)
asm_in_port:
push ebp
mov ebp, esp

push edx
push eax
push ebx

xor eax, eax
mov edx, [ebp + 4 * 2] ; port
mov ebx, [ebp + 4 * 3] ; *value

in al, dx
mov [ebx], al

pop ebx
pop eax
pop edx
pop ebp
ret
  • 处理时钟中断:主片的IRQ0中断
    • 8253芯片能以一定频率来产生时钟中断。当产生了时钟中断后,信号会被8259A截获,从而产生IRQ0中断。处理时钟中断:
      • 编写中断处理函数
      • 设置主片IRQ0中断对应的中断描述符
      • 开启时钟中断
      • 开中断

=>编写中断处理函数:
不再使用放置字符到显存地址的方式来显示字符,而是去封装一个函数来处理屏幕输出的类 STDIO

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
#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
  • 处理光标位置
    • 与光标读写相关的端口: 0x3d40x3d5
    • 需要向端口0x3d4写入数据,表明处理的是高8位(0x0e)还是低8位(0x0f)
    • 0x3d5读取数据可以读取到光标的位置,如果要改变光标的位置,将新位置写入`0x3d5
      移动光标:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void STDIO::moveCursor(uint position)
{
if (position >= 80 * 25) //判断是否溢出
{
return;
}

uint8 temp;

// 处理高8位
temp = (position >> 8) & 0xff;
asm_out_port(0x3d4, 0x0e);
asm_out_port(0x3d5, temp);//out--写入

// 处理低8位
temp = position & 0xff;
asm_out_port(0x3d4, 0x0f);
asm_out_port(0x3d5, temp);
}

获取光标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
uint STDIO::getCursor()
{
uint pos;
uint8 temp;

pos = 0;
temp = 0;
// 处理高8位
asm_out_port(0x3d4, 0x0e);
asm_in_port(0x3d5, &temp);//in--读取
pos = ((uint)temp) << 8;

// 处理低8位
asm_out_port(0x3d4, 0x0f);
asm_in_port(0x3d5, &temp);
pos = pos | ((uint)temp);

return pos;
}
  • 中断处理函数
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
// 中断处理函数
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]);
}
}

上面这个函数还不完全是一个中断处理函数,因为我们进入中断后需要保护现场,离开中断需要恢复现场。这里,现场指的是寄存器的内容。但是,C语言并未提供相关指令。最重要的是,中断的返回需要使用iret指令,而C语言的任何函数编译出来的返回语句都是ret。因此,我们只能在汇编代码中完成保护现场、恢复现场和中断返回

中断发生后 => CPU跳转到汇编实现的代码 => 使用汇编代码保存寄存器的内容 => 保护现场后,调用 call 指令来跳转到C语言编写的中断函数主题 => C语言函数返回后 => 返回到 call 指令的下一条汇编代码 => 汇报代码中恢复保存的寄存器内容 => iret返回

1
2
3
4
5
6
7
8
9
10
11
12
13
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不会产生下一次中断。

  • 设置中断描述符

1
2
3
4
void InterruptManager::setTimeInterrupt(void *handler)
{
setInterruptDescriptor(IRQ0_8259A_MASTER, (uint32)handler, 0);
}
  • 封装开启和关闭时钟中断的函数
    • 读取OCW1可以得知中断开启情况
    • 要修改中断开启情况:先读取再写入对应的OCW1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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);
}

实验任务

Assignment 1 混合编程的基本思路

复现Example 1,结合具体的代码说明C代码调用汇编函数的语法和汇编代码调用C函数的语法。例如,结合代码说明globalextern关键字的作用,为什么C++的函数前需要加上extern "C"等, 结果截图并说说你是怎么做的。同时,学习make的使用,并用make来构建Example 1,结果截图并说说你是怎么做的。

实现:

Assignment 2 使用C/C++来编写内核

复现Example 2,在进入setup_kernel函数后,将输出 Hello World 改为输出你的学号,结果截图并说说你是怎么做的。

Assignment 3 中断的处理

复现Example 3,你可以更改Example中默认的中断处理函数为你编写的函数,然后触发之,结果截图并说说你是怎么做的。

Assignment 4 时钟中断

复现Example 4,仿照Example中使用C语言来实现时钟中断的例子,利用C/C++、 InterruptManager、STDIO和你自己封装的类来实现你的时钟中断处理过程,结果截图并说说你是怎么做的。注意,不可以使用纯汇编的方式来实现。(例如,通过时钟中断,你可以在屏幕的第一行实现一个跑马灯。跑马灯显示自己学号和英文名,即类似于LED屏幕显示的效果。)

  • 只需要修改c_time_interrupt_handler()这个函数
  • 在原本的基础上简单修改就好了
    • stdio中有几种不同的print函数,使用可以自定义color的那个,产生跑马灯效果
    • 把名字、学号放在第一行(设置光标位置即可实现)
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
uint8 color = 0;
int cnt = 0;

extern "C" void c_time_interrupt_handler()
{
// 清空屏幕
for (int i = 0; i < 80; ++i) {
stdio.print(0, i, ' ', 0x07);
}

// 输出中断发生的次数
++times;

const char str[] = "LQT 23336139";
const int str_len = 13; // 实际显示长度
char number[13] = {0};

// 正确转换数字为字符串
int temp = times;
int i = 0;
do {
number[i++] = temp % 10 + '0';
temp /= 10;
} while(temp && i < 10);

// 第一行输出
stdio.moveCursor(0);
for(int i = 0; i < str_len; i++) {
if(i >= cnt && i < cnt + 3 && cnt + 2 < str_len) {
stdio.print(str[i], ++color % 16);
} else {
stdio.print(str[i]);
}
}

cnt = (cnt + 1) % (str_len - 2); // 限制cnt范围

// 第二行输出
const char str1[] = "interrupt happend: ";
stdio.moveCursor(80); // 第二行开始

// 输出前缀字符串
for(int i = 0; str1[i]; i++) {
stdio.print(str1[i]);
}

// 逆序输出数字(从最高位开始)
for(int j = i-1; j >= 0; j--) {
stdio.print(number[j]);
}
}


OS_Lab4
https://pqcu77.github.io/2025/04/02/OS-Lab4/
作者
linqt
发布于
2025年4月2日
许可协议