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文件)。
- 处理源代码中以“#”开头的预编译指令
- 删掉注释行
- .i文件中不包含任何宏定义和注释行
- 将源文件转换成汇编代码(.s文件)的过程
- 词法分析 -> 语法分析 -> 语义分析及相关的优化-> 中间代码生成 -> 目标代码生成(汇编文件.s)
- 汇编阶段是把编译阶段生成的”.s”文件转成二进制目标代码(.o文件)。
- 将多个目标文件链接生成可执行文件( .EXE文件(windows),.out文件(Linux))。
gcc指令生成中间过程文件:gcc [选项] 要编译的文件 [选项] [目标文件]
orgcc [option] filename [option] [objectfile]
1 |
|
example0
使用gcc跑一下程序
or(可以不列举.h,因为.c文件中已经#include了,在.c=>.i阶段会将.h文件内容展开插入到)
- 在.h中编写函数声明,不要在.h中实现函数,如果.h被多次引用可能会导致出现函数重定义问题
- 编译时要把所有.c和.cpp文件都加上去编译
查看各个步骤
- 预处理生成.i文件:
gcc -E main.c -o main
1 |
|
- 编译生成汇编文件
gcc -S main.c -o main.s -masm=intel
这里-masm=intel
指示生成intel风格的汇编代码,否则默认AT&T风格代码
1 |
|
- 重定位
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 |
|
使用时:
1 |
|
使用来自CPP的函数:
- 需要在C++代码的函数定义前加上
extern "C"
- 因为C++支持函数重载,为了区别同名的重载函数,C++在编译时会进行名字修饰。也就是说,
function_from_CPP
编译后的标号不再是function_from_CPP
,而是要带上额外的信息。而==C代码编译后的标号还是原来的函数名==。 - extern “C” 目的是告诉编译器按C代码规则编译,不加名字修饰。
在C++代码中声明:
1 |
|
在汇编代码中声明:
1 |
|
- C/CPP:
要先在汇编代码中奖函数声明为global
。
1 |
|
C/C++中声明其来自外部:
1 |
|
在C++中需要声明为 extern "C"
1 |
|
如果函数带返回值和参数
- 如果函数有参数,那么参数==从右向左==依次入栈。
- 如果函数有返回值,返回值放在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_C
和function_from_CPP
。 - 在文件
main.cpp
中调用汇编函数function_from_asm
。
指令:
1 |
|
-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 |
|
mbr => bootloader => kernel
进入内核后,定义内核起始点
src/boot/entry.asm:
1 |
|
在链接阶段巧妙地将entry.asm
的代码放在内核代码的最开始部分,使得bootloader在执行跳转之后,转到的就是内核代码的起始指令,执行 jmp setup_kernel
。然后就跳转到了C++编写的函数setup_kernel
,即可以使用c++来写内核了。
运行代码:
way1:不使用makefile
1 |
|
way2:使用makefile
1 |
|
一些解释:
1 |
|
参数介绍:
-O0
告诉编译器不开启编译优化。(如果要开启有几种可以选择,O1,O2,O3…-Wall
告诉编译器显示所有编译器警告信息-march=i386
告诉编译器生成i386处理器下的.o
文件格式。-m32
告诉编译器生成32位的二进制文件。-nostdlib -fno-builtin -ffreestanding -fno-pic
是告诉编译器不要包含C的任何标准库。-g
表示向生成的文件中加入debug信息供gdb使用。-I
指定了代码需要的头文件的目录。-c
表示生成可重定位文件。
1 |
|
-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赋值
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 |
|
初始化IDT: initialize()
1 |
|
设置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 |
|
- IDT是起始地址指针
中断默认处理函数
1 |
|
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 |
|
- 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 |
|
Example4 8259A编程
- 初始化8259A芯片:
1 |
|
初始化8259A芯片的过程是通过设置一系列的ICW字来完成的。由于我们并未建立处理8259A中断的任何函数,因此在初始化的最后,我们需要屏蔽主片和从片的所有中断。
asm_out_port
是对out
指令的封装
1 |
|
asm_in_port
是对in
指令的封装
1 |
|
- 处理时钟中断:主片的IRQ0中断
- 8253芯片能以一定频率来产生时钟中断。当产生了时钟中断后,信号会被8259A截获,从而产生IRQ0中断。处理时钟中断:
- 编写中断处理函数
- 设置主片IRQ0中断对应的中断描述符
- 开启时钟中断
- 开中断
- 8253芯片能以一定频率来产生时钟中断。当产生了时钟中断后,信号会被8259A截获,从而产生IRQ0中断。处理时钟中断:
=>编写中断处理函数:
不再使用放置字符到显存地址的方式来显示字符,而是去封装一个函数来处理屏幕输出的类 STDIO
1 |
|
- 处理光标位置
- 与光标读写相关的端口:
0x3d4
和0x3d5
- 需要向端口
0x3d4
写入数据,表明处理的是高8位(0x0e)还是低8位(0x0f) - 从
0x3d5
读取数据可以读取到光标的位置,如果要改变光标的位置,将新位置写入`0x3d5
移动光标:
- 与光标读写相关的端口:
1 |
|
获取光标:
1 |
|
- 中断处理函数
1 |
|
上面这个函数还不完全是一个中断处理函数,因为我们进入中断后需要保护现场,离开中断需要恢复现场。这里,现场指的是寄存器的内容。但是,C语言并未提供相关指令。最重要的是,中断的返回需要使用iret
指令,而C语言的任何函数编译出来的返回语句都是ret
。因此,我们只能在汇编代码中完成保护现场、恢复现场和中断返回
中断发生后 => CPU跳转到汇编实现的代码 => 使用汇编代码保存寄存器的内容 => 保护现场后,调用 call
指令来跳转到C语言编写的中断函数主题 => C语言函数返回后 => 返回到 call
指令的下一条汇编代码 => 汇报代码中恢复保存的寄存器内容 => iret
返回
1 |
|
pushad
指令是将EAX
,ECX
,EDX
,EBX
,ESP
,EBP
,ESI
,EDI
依次入栈,popad
则相反对于8259A芯片产生的中断,我们需要在中断返回前发送EOI消息。否则,8259A不会产生下一次中断。
设置中断描述符
1 |
|
- 封装开启和关闭时钟中断的函数
- 读取OCW1可以得知中断开启情况
- 要修改中断开启情况:先读取再写入对应的OCW1
1 |
|
实验任务
Assignment 1 混合编程的基本思路
复现Example 1,结合具体的代码说明C代码调用汇编函数的语法和汇编代码调用C函数的语法。例如,结合代码说明global
、extern
关键字的作用,为什么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 |
|