Lab2 - 系统调用
实验要求
本次实验内容包括三方面:
-
磁盘加载,即引入内核,bootloader加载kernel,由kernel加载用户程序
-
开始区分内核态和用户态,完善中断机制与系统中断机制
-
通过具体的系统调用和库函数的实现(如
getChar()
、getStr()
、printf()
、sleep()
、now()
等),深入理解用户态执行系统调用的完整过程。
中断机制
为实现用户态服务和保护特权级代码,需要完善中断机制,具体包括优化和实现 IDT(中断描述符表)、TSS(任务状态段)以及中断处理程序等关键结构。
实现库函数 printf()
和对应的处理例程
完善系统调用处理函数 sysWrite()
,类似Linux中的系统调用处理函数 _write()
printf
拥有完整的格式化输出,包括%d
, %x
, %s
,%c
四种格式转换输出功能。
增加一个阻塞方式的sleep()
库函数
利用lab1完成的时钟中断,实现阻塞方式的sleep()
库函数(非系统调用!)
void sleep(unsigned int seconds);
键盘按键的串口回显
这一步是为了输入函数做准备,我们提供了键盘按键信息和库函数,需要大家实现由键盘输入到屏幕的显示。
实现系统调用库函数 getChar()
、getStr()
和对应的处理例程
我们需要大家实现两个基础的输入函数,其中getChar()
函数返回键盘输入的一个字符,getStr()
返回键盘输入的一条字符串,不做额外的格式化输入要求。
增加now
系统调用以及对应的库函数实现
读取系统RTC值,now()
返回日期与时间各分量。
为了读取系统RTC值并返回日期和时间的各个分量(
hours
、minutes
、seconds
、day_of_month
、month
、year
),建议定义一个包含上述分量的结构体(struct
),这样可以使now
函数更方便地返回所有所需的值,并保持数据的组织性和可读性
应用程序每隔1秒钟输出当前的时间
printf("RTC time: %d-%d-%d %d:%d:%d. \n",
t->year, t->month, t->m_day, t->hour, t->minute, t->second);
实验流程如下
-
由实模式开启保护模式并跳转到bootloader
-
由bootloader加载kernel
-
完善kernel相关的初始化设置
-
由kernel加载用户程序
-
实现用户需要的库函数
-
用户程序调用自定义实现的库函数完成格式化输入输出,通过测试代码
开始前的建议
本实验框架代码较为复杂,以下列出一些建议希望能更好地帮助大家完成实验:
建议大家在正式开始写代码前把框架通读一遍,这对层次复杂的稍大型工程任务很重要
本次实验量较大,建议大家早点开始准备
你遇到的问题绝大多数都有其他人遇见过,STFW!
找配置环境相同的答案更为靠谱
运行
QEMU
时若显示异常但代码逻辑无误,可能是环境配置问题,例如 gcc 版本过高影响实验,可检查编译器版本与配置如果遇到了
boot block is too large
的问题,可以采用index引导实验中所用的objcopy
命令提问前请明确自己的疑问,并结合自身的思考,不要笼统地询问“怎么做”或“哪里错了”
相关资料
写磁盘扇区
以下代码用于写一个磁盘扇区,框架代码已提供
static inline void outLong(uint16_t port, uint32_t data) {
asm volatile("out %0, %1" : : "a"(data), "d"(port));
}
void writeSect(void *src, int offset) {
int i;
waitDisk();
outByte(0x1F2, 1 );
outByte(0x1F3, offset);
outByte(0x1F4, offset >> 8 );
outByte(0x1F5, offset >> 16 );
outByte(0x1F6, (offset >> 24 ) | 0xE0);
outByte(0x1F7, 0x30);
waitDisk();
for (i = 0 ; i < SECTOR_SIZE / 4 ; i ++) {
outLong(0x1F0, ((uint32_t *)src)[i]);
}
}
串口输出
在以往的编程作业中,我们通常可以通过 printf()
在屏幕上输出程序的内部状态来进行调试。然而,在操作系统实验中,由于尚未实现 printf()
等屏幕输出函数,我们需要采用其他方法进行调试。不必担心,在 lab2 的 Makefile
中,我们可以找到一些有用的内容来帮助我们实现类似的功能。
play: os.img
$(QEMU) -serial stdio os.img
其中-serial stdio
表示将qemu
模拟的串口数据即时输出到stdio(即宿主机的标准输出)
在 lab2/kernel/main.c
的第一行代码中,initSerial()
用于初始化串口设备。初始化完成后,我们可以通过调用 putChar()
(定义在 lab2/kernel/include/device/serial.h
,实现在 lab2/kernel/kernel/serial.c
)进行调试或输出日志。此外,框架代码还基于 putChar()
提供了一个调试接口 assert()
(定义在 lab2/kernel/include/common/assert.h
),方便我们在调试时使用。
思考题
有兴趣的同学可以对
putChar()
进行封装,实现一个类似printf()
的串口格式化输出sprintf()
从系统启动到用户程序
我们首先按 OS 的启动顺序来确认一下:
1. 从实模式进入保护模式(lab1)
2. 加载内核到内存某地址并跳转运行(lab1)
-
初始化串口输出
-
初始化中断向量表(
initIdt()
) -
初始化8259a中断控制器(
initIntr()
) -
初始化 GDT、配置 TSS 段(
initSeg()
) -
初始化VGA设备(
initVga()
) -
配置好键盘映射表(
initKeyTable()
) -
从磁盘加载用户程序到内存相应地址(
loadUMain()
) -
进入用户空间(
enterUserSpace()
) -
调用库函数
sleep()
,printf()
,getChar()
,getStr()
内核程序和用户程序将分别运行在内核态以及用户态, 在 Lab1 中我们提到过保护模式除了寻址长度达到32
位之外, 还能让内核有效地进行权限控制。
在实验的最后, 用户程序擅自修改显存是不被允许的.
特权级代码的保护 :
- x86 平台 CPU 有 0 、 1 、 2 、 3 四个特权级,其中 level0 是最高特权级,可以执行所有指令
- level3 是最低特权级,只能执行算数逻辑指令,很多特殊操作(例如 CPU 模式转换,I/O 操作指令)都不能在这个级别下进行
- 现代操作系统往往只使用到 level0 和 level3 两个特权级,操作系统内核运行时,系统处于level0(即 CS 寄存器的低两位为
00b
),而用戶程序运行时系统处于 level3(即 CS 寄存器的低两位为11b) - x86 平台使用 CPL、DPL、RPL 来对代码、数据的访存进行特权级检测
CPL
(current privilege level)为CS
寄存器的低两位,表示当前指令的特权级DPL
(discriptor privilege level)为描述符中的 DPL 字段,表示访存该内存段的最低特权级(有时表示访存该段的最高特权级,比如 Conforming-Code Segment)RPL
(requested privilege level)为DS
、ES
、FS
、GS
、SS
寄存器的低两位,用于对 CPL 表示的特权级进行补充- 一般情况下,同时满足
CPL ≤ DPL
,RPL ≤ DPL
才能实现对内存段的访存,否则产生#GP
异常
- 基于中断机制可以实现对特权级代码的保护
初始化中断向量表
保护模式下 80386 执行指令过程中产生的异常如下表总结
向量号 | 助记符 | 描述 | 类型 | 有无出错码 | 源 |
---|---|---|---|---|---|
0 | #DE | 除法错误 | Fault | 无 | DIV 和 IDIV 指令 |
1 | #DB | 调试异常 | Fault/Trap | 无 | 任何代码和数据的访问 |
2 | – | 非屏蔽中断 | Interrupt | 无 | 非屏蔽中断 |
3 | #BP | 调试断点 | Trap | 无 | 指令 INT 3 |
4 | #OF | 溢出 | Trap | 无 | 指令 INTO |
5 | #BR | 越界 | Fault | 无 | 指令 BOUND |
6 | #UD | 无效(未定义操作) | Fault | 无 | 指令 UD2 或者无效指令 |
7 | #NM | 设备不可用(无数学协处理器) | Fault | 无 | 浮点指令或 WAIT/FWAIT 指令 |
8 | #DF | 双重错误 | Abort | 有(或零) | 所有可能产生异常或 NMI 或 INTR 的指令 |
9 | #AC | 对齐检查 | Fault | 有(ZERO) | 内存中的数据访问(486开始) |
10 | #TS | 无效TSS | Fault | 有 | 任务切换访问 TSS 时 |
11 | #NP | 段不存在 | Fault | 无 | 加载段寄存器或访问段系统段时 |
12 | #SS | 堆栈段错误 | Fault | 无 | 堆栈操作时 |
13 | #GP | 常规保护错误 | Fault | 无 | 内存或其他访问检查 |
14 | #PF | 页错误 | Fault | 无 | 内存访问时 |
15 | – | Intel 保留,未使用 | Fault | 无 | 保留 |
16 | #MF | x87FPU浮点错(数字错) | Fault | 无 | x87FPU 浮点指令或 WAIT/FWAIT 指令 |
17 | #AC | 对齐检查 | Fault | 有(ZERO) | 内存中的数据访问(486开始) |
18 | #MC | Machine Check | Abort | 无 | 错误码(如果有的话)和原依赖于具体体系结构(跨腾 CPU 开始支持) |
19 | #XF | SIMD浮点异常 | Fault | 无 | SSE 和 SSE2浮点指令(奔腾 III 开始) |
20-31 | – | Intel 保留,未使用 | Fault | 无 | 保留 |
32-255 | – | 用户定义中断 | Interrupt | 无 | 外部中断或 int n 指令 |
以上所列的异常中包括 Fault/Trap/Abort
三种, 当然你也可以称之为错误, 陷阱和终止
- Fault : 一种可被更正的异常。一旦被更正, 程序可以不失连续性地继续执行, 中断程序返回地址为产生 Fault 的指令
- Trap : 发生 Trap 的指令执行之后立刻被报告的异常, 也允许程序不失连续性地继续执行, 但中断程序返回地址是产生 Trap 之后的那条指令
- Abort : Abort 异常不总是精确报告发生异常的位置, 它不允许程序继续执行, 而是用来报告严重错误.
初始化8259a中断控制器
硬件外设I/O :内核的一个主要功能是管理硬件外设的 I/O 操作。由于 CPU 的速度通常远快于硬件外设,在多任务系统中,CPU 可以在外设准备数据时处理其他任务,待外设完成准备后再处理 I/O。常见的 I/O 处理方式包括轮询、中断和 DMA 等。中断机制能够有效解决轮询方式效率低下的问题,从而提高系统的整体性能
中断产生的原因可以分为两种:
- 外部中断:即由硬件产生的中断
- 内部中断:由指令
int n
产生的中断
下面要讲的是外部中断.
外部中断分为:不可屏蔽中断(NMI) 和可屏蔽中断两种, 分别由 CPU 得两根引脚 NMI
和 INTR
来接收, 如图所示
外部终端示意图
NMI(不可屏蔽中断)是一种特殊的中断类型,它不受标志寄存器中 IF
位的影响。NMI
的中断向量号为 2
,通常由一些特定事件触发,例如硬件故障或电源问题。相比之下,可屏蔽中断通过可编程中断控制器 8259A 与 CPU 进行交互,其触发和处理可以通过软件进行控制。
Q:那如何让这些设备发出的中断请求和中断向量对于起来呢?
A:在 BIOS 初始化 8259A 的时候,
IRQ0-IRQ7
被设置为对应的向量号0x08-0x0F
, 但是我们发现在保护模式下, 这些向量号已经被占用了, 因此我们不得不重新设置主从8259A(两片级联的8259A).
设置的细节你不需要详细了解, 你只需要知道我们将外部中断重新设置到了0x20-0x2F
号中断上
IA-32的中断机制
保护模式下的中断源:
- 外部硬件产生的中断(Interrupt):例如时钟、磁盘、键盘等外部硬件
- CPU 执行指令过程中产生的异常(Exception):例如除法错(
#DE
),页错误(#PF
),常规保护错误(#GP
) - 由
int
等指令产生的软中断(Software Interrupt):例如系统调用使用的int $0x80
前文提到,I/O 设备发出的 IRQ
由 8259A 这个可编程中断控制器(PIC)统一处理,并转化为 8-Bits 中断向量由 INTR 引脚输入 CPU。对于这些这些由8259A控制的可屏蔽中断有两种方式控制:
- 通过
sti
,cli
指令设置 CPU 的EFLAGS
寄存器中的IF
位,可以控制对这些中断进行屏蔽与否 - 通过设置 8259A 芯片,可以对每个 IRQ 分别进行屏蔽
在我们的实验过程中,不涉及对IRQ分别进行屏蔽
IDT
在保护模式下,每个中断(Exception,Interrupt,Software Interrupt)都由一个 8-Bits 的向量来标识,Intel 称其为中断向量。8-Bits表示一共有 256 个中断向量,因此共有 256 个中断向量。IDT 中存有 256 个表项,表项称为⻔描述符(Gate Descriptor),每个描述符占 8 个字节
中断到来之后,基于中断向量,IA-32硬件利用IDT
与GDT
这两张表寻找到对应的中断处理程序,并从当前程序跳转执行,下图显示的是基于中断向量寻找中断处理程序的流程
IDT EXECUTABLE SEGMENT
+---------------+ +---------------+
| | OFFSET| |
|---------------| +------------------------->| ENTRY POINT |
| | | LDT OR GDT | |
|---------------| | +---------------+ | |
| | | | | | |
INTERRUPT |---------------| | |---------------| | |
ID-----> TRAP GATE OR |--+ | | | |
|INTERRUPT GATE |--+ |---------------| | |
|---------------| | | | | |
| | | |---------------| | |
|---------------| +-->| SEGMENT |-+ | |
| | | DESCRIPTOR | | | |
|---------------| |---------------| | | |
| | | | | | |
|---------------| |---------------| | | |
| | | | |BASE| |
+---------------+ |---------------| +--->+---------------+
| |
| |
| |
+---------------+
在开启外部硬件中断前,内核需对 IDT 完成初始化,其中IDT的基地址由IDTR寄存器(中断描述符表寄存器)保存,可利用lidt
指令进行加载,其结构如下
INTERRUPT DESCRIPTOR TABLE
+------+-----+-----+------+
+---->| | | | |
| |- GATE FOR INTERRUPT #N -|
| | | | | |
| +------+-----+-----+------+
| * *
| * *
| * *
| +------+-----+-----+------+
| | | | | |
| |- GATE FOR INTERRUPT #2 -|
| | | | | |
| |------+-----+-----+------|
IDT REGISTER | | | | | |
| |- GATE FOR INTERRUPT #1 -|
15 0 | | | | | |
+---------------+ | |------+-----+-----+------|
| IDT LIMIT |----+ | | | | |
+----------------+---------------| |- GATE FOR INTERRUPT #0 -|
| IDT BASE |--------->| | | | |
+--------------------------------+ +------+-----+-----+------+
31 0
IDT中每个表项称为门描述符(Gate Descriptor)
门描述符可以分为 3 种
- Task Gate,Intel设计用于任务切换,现代操作系统中一般不使用
- Interrupt Gate,跳转执行该中断对应的处理程序时,
EFLAGS
中的IF
位会被硬件置为 0 ,即关中断,以避免嵌套中断的发生- Trap Gate,跳转执行该中断对应的处理程序时,
EFLAGS
中的IF
位不会置为 0 ,也就是说,不关中断
门描述符的结构如下
80386 TASK GATE
31 23 15 7 0
+-----------------+-----------------+---+---+---------+-----------------+
|#############(NOT USED)############| P |DPL|0 0 1 0 1|###(NOT USED)####|4
|-----------------------------------+---+---+---------+-----------------|
| SELECTOR |#############(NOT USED)############|0
+-----------------+-----------------+-----------------+-----------------+
80386 INTERRUPT GATE
31 23 15 7 0
+-----------------+-----------------+---+---+---------+-----+-----------+
| OFFSET 31..16 | P |DPL|0 1 1 1 0|0 0 0|(NOT USED) |4
|-----------------------------------+---+---+---------+-----+-----------|
| SELECTOR | OFFSET 15..0 |0
+-----------------+-----------------+-----------------+-----------------+
80386 TRAP GATE
31 23 15 7 0
+-----------------+-----------------+---+---+---------+-----+-----------+
| OFFSET 31..16 | P |DPL|0 1 1 1 1|0 0 0|(NOT USED) |4
|-----------------------------------+---+---+---------+-----+-----------|
| SELECTOR | OFFSET 15..0 |0
+-----------------+-----------------+-----------------+-----------------+
SELECTOR
字段表示该中断处理程序所在段的段描述符在GDT
中的索引。通过这个字段,CPU 可以定位并跳转到正确的中断处理程序。
若中断是由为int
等指令触发的软中断, IA-32硬件处理该中断时,会检查产生该中断的程序的CPL
(当前特权级)与中断对应的门描述符的DPL
(描述符特权级)。若CPL
数值上大于DPL
,则会产生General Protect Fault,即#GP
异常
什么是IDT,为什么需要IDT?IDT相比IVT有什么优势
请你在实验报告中阐述
IDT
的相关内容,以及你在实验过程中的感悟
TSS
中断会改变程序的正常执行流程。为了便于说明,我们称中断发生前 CPU 正在执行的任务为 A。当中断发生时,CPU 需要暂停任务 A,转而去处理中断任务 B。为此,CPU 会跳转到中断处理代码,执行完后再恢复任务 A 的执行。
由于中断打断了任务 A 的执行流程,为了确保任务 A 能够正确恢复,CPU 在处理中断之前会先保存任务 A 的状态(如寄存器值、程序计数器等)。当中断处理完成后,CPU 会根据保存的状态恢复到中断发生前的环境,使任务 A 能够继续执行。对于任务 A 来说,中断就像从未发生过一样。
Question?
接下来的问题是, 哪些内容表征了A的状态? CPU又应该将它们保存到哪里去?
在 IA-32 架构中,当中断发生时,硬件会自动保存一些关键的寄存器状态,以便后续恢复被中断的任务。这些寄存器包括:
-
EIP(指令指针):指示任务 A 被打断时正在执行的指令。
-
EFLAGS(标志寄存器):保存了 CPU 的状态标志。
-
CS(代码段寄存器):包含了当前代码段的段选择子和特权级(CPL)。
这些寄存器的内容必须由硬件保存,因为它们直接关系到任务 A 的执行状态。
此外,通用寄存器(GPR,General Purpose Registers)的值对任务 A 也非常重要。然而,硬件不会自动保存这些寄存器的值,而中断处理程序又需要使用这些寄存器。因此,我们需要在中断处理程序中手动保存和恢复通用寄存器的值,以确保任务 A 能够正确恢复执行。
要将这些信息保存到哪里去呢?
一个合适的地方就是程序的栈.
中断到来时, 硬件会自动将EFLAGS
,CS
, EIP
寄存器的值保存到栈上. 此外, IA-32提供了pusha
和popa
指令, 用于把通用寄存器的值压入/弹出栈, 但你需要注意压入的顺序(请查阅i386手册)。
TIP
如果希望支持中断嵌套(即在处理低优先级中断时,能够响应更高优先级的中断),堆栈是保存状态信息的唯一选择。
如果将状态信息保存在固定位置,当中断嵌套发生时,第一次中断保存的状态信息可能会被高优先级中断的处理过程覆盖,从而导致数据丢失和程序错误。
在IA-32中,CPU借助TR
(任务寄存器)和TSS
(任务状态段)来确定用于保存EFLAGS
,CS
,EIP
等寄存器信息的新堆栈。TSS
中包含了不同特权级下的堆栈指针(SS
和 ESP
),当中断发生时,CPU 会根据当前特权级从 TSS
中加载相应的堆栈指针,从而确保状态信息能够正确保存到合适的堆栈中。
TR
(Task state segment Register)是 16 位的任务状态段寄存器,结构和CS
这些段寄存器完全一样,它存放了GDT的一个索引,可以使用ltr
指令进行加载。
通过TR
可以在GDT
中找到一个TSS段描述符,索引过程如下
+-------------------------+
| |
| |
| TASK STATE |
| SEGMENT |<---------+
| | |
| | |
+-------------------------+ |
16-BIT VISIBLE ^ |
REGISTER | HIDDEN REGISTER |
+--------------------+---------+----------+-------------+------+
TR | SELECTOR | (BASE) | (LIMT) |
+---------+----------+--------------------+--------------------+
| ^ ^
| +-----------------+ |
| GLOBAL DESCRIPTOR TABLE | |
| +-------------------------+ | |
| | TSS DESCRIPTOR | | |
| +------+-----+-----+------+ | |
| | | | | |---+ |
| |------+-----+-----+------| |
+------->| | |-------+
+------------+------------+
| |
+-------------------------+
TSS
是任务状态段。不同于代码段、数据段,TSS
是一个系统段,用于存放任务的状态信息,主要用在硬件上下文切换
TSS
(任务状态段)提供了 3 个堆栈位置(SS
和 ESP
),用于在特权级别之间进行堆栈切换。当 CPU 执行特权级切换时,它会根据目标代码的特权级,从 TSS
中加载相应的堆栈信息。例如,当发生中断并进入 ring0
特权级时,CPU 会从 TSS 中读取 SS0
和 ESP0
,以进行堆栈切换。
为了在堆栈切换时让硬件能够找到新堆栈,内核需要将新的堆栈位置写入 TSS
的相应字段。虽然 TSS
中的其他内容主要用于硬件上下文切换,但由于效率考虑,现代操作系统通常不依赖硬件上下文切换,因此 TSS
中的大部分字段在实际应用中并未被使用。
其结构如下图所示:
31 23 15 7 0
+---------------+---------------+---------------+-------------+-+
| I/O MAP BASE | 0 0 0 0 0 0 0 0 0 0 0 0 0 |T|64
|---------------+---------------+---------------+-------------+-|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| LDT |60
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| GS |5C
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| FS |58
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| DS |54
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| SS |50
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| CS |4C
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| ES |48
|---------------+---------------+---------------+---------------|
| EDI |44
|---------------+---------------+---------------+---------------|
| ESI |40
|---------------+---------------+---------------+---------------|
| EBP |3C
|---------------+---------------+---------------+---------------|
| ESP |38
|---------------+---------------+---------------+---------------|
| EBX |34
|---------------+---------------+---------------+---------------|
| EDX |30
|---------------+---------------+---------------+---------------|
| ECX |2C
|---------------+---------------+---------------+---------------|
| EAX |28
|---------------+---------------+---------------+---------------|
| EFLAGS |24
|---------------+---------------+---------------+---------------|
| INSTRUCTION POINTER (EIP) |20
|---------------+---------------+---------------+---------------|
| CR3 (PDPR) |1C
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| SS2 |18
|---------------+---------------+---------------+---------------|
| ESP2 |14
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| SS1 |10
|---------------+---------------+---------------+---------------|
| ESP1 |0C
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| SS0 |8
|---------------+---------------+---------------+---------------|
| ESP0 |4
|---------------+---------------+---------------+---------------|
|0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0| BACK LINK TO PREVIOUS TSS |0
+---------------+---------------+---------------+---------------+
ring3的堆栈在哪里?
IA-32提供了 4 个特权级, 但TSS中只有 3 个堆栈位置信息, 分别用于ring0, ring1, ring2的堆栈切换.
为什么什么
TSS
中没有ring3的堆栈信息?
在硬件进行堆栈切换后,硬件的行为如下:中断到来时的处理和从中断返回时的操作。
# 伪代码
old_CS = CS
old_EIP = EIP
old_SS = SS
old_ESP = ESP
target_CS = IDT[vec].selector
target_CPL = GDT[target_CS].DPL
if(target_CPL < GDT[old_CS].DPL)
TSS_base = GDT[TR].base
switch(target_CPL)
case 0:
SS = TSS_base->SS0
ESP = TSS_base->ESP0
case 1:
SS = TSS_base->SS1
ESP = TSS_base->ESP1
case 2:
SS = TSS_base->SS2
ESP = TSS_base->ESP2
push old_SS
push old_ESP
push EFLAGS
push old_CS
push old_EIP
################### iret ####################
old_CS = CS
pop EIP
pop CS
pop EFLAGS
if(GDT[old_CS].DPL < GDT[CS].DPL)
pop ESP
pop SS
硬件堆栈切换只会在目标代码特权级比当前堆栈特权级高的时候发生,即GDT[target_CS].DPL <GDT[SS].DPL
(这里的小于是数值上的),当GDT[target_CS].DPL = GDT[SS].DPL
时,CPU将不会进行硬件堆栈切换.
下图显示中断到来后内核堆栈的变化
WITHOUT PRIVILEGE TRANSITION
D O 31 0 31 0
I F |-------+-------| |-------+-------|
R |#######|#######| OLD |#######|#######| OLD
E E |-------+-------| SS:ESP |-------+-------| SS:ESP
C X |#######|#######| | |#######|#######| |
T P |-------+-------| <----+ |-------+-------|<----+
I A | OLD EFLAGS | | OLD EFLAGS |
O N |-------+-------| |-------+-------|
N S |#######|OLD CS | NEW |#######|OLD CS |
I |-------+-------| SS:ESP |-------+-------|
| O | OLD EIP | | | OLD EIP | NEW
| N |---------------|<----+ |---------------| SS:ESP
| | | | ERROR CODE | |
! * * |---------------|<----+
* * | |
* *
WITHOUT ERROR CODE WITH ERROR CODE
WITH PRIVILEGE TRANSITION
D O 31 0 31 0
I F +-------+-------+<----+ +-------+-------+<----+
R |#######|OLD SS | | |#######|OLD SS | |
E E |-------+-------| SS:ESP |-------+-------| SS:ESP
C X | OLD ESP | FROM TSS | OLD ESP | FROM TSS
T P |---------------| |---------------|
I A | OLD EFLAGS | | OLD EFLAGS |
O N |-------+-------| |-------+-------|
N S |#######|OLD CS | NEW |#######|OLD CS |
I |-------+-------| SS:ESP |-------+-------|
| O | OLD EIP | | | OLD EIP | NEW
| N |---------------|<----+ |---------------| SS:ESP
| | | | ERROR CODE | |
! * * |---------------|<----+
* * | |
* *
WITHOUT ERROR CODE WITH ERROR CODE
什么是TSS,为什么需要TSS?TSS的结构是如何
请你在实验报告,用你自己的话和实验中的例子,阐述
TSS
的相关内容.
系统调用
在我们开始讲述系统调用之前,大家有没有好奇过,操作系统是如何“悄悄地”将应用程序的请求处理得如此高效的呢?如果你认为一切都在用户空间优雅地进行,那么恭喜你,你正站在了系统调用的大门前——这扇门打开时,程序的执行就会突然“跳进”内核的怀抱,告别轻松的用户空间,迎接复杂而严肃的内核世界。
就像你在现实生活中走进餐厅,和服务员说:“我要一份披萨!”而系统调用就像是服务员,带你从客厅(用户空间)一路引导到厨房(内核空间)。接下来,程序要和内核打交道,正是通过我们将要讨论的“syscall”函数,它帮助我们完成这项“跨界合作”。所以,准备好了吗?让我们从
syscall()
函数开始,一窥这扇神秘大门的奥秘吧!
系统调用的入口定义在 lib/syscall.c
中。在 syscall()
函数中,首先通过嵌入式汇编将各个参数依次赋值给寄存器:EAX
、EBX
、ECX
、EDX
、EDI
和 ESI
。然后,我们约定将系统调用的返回值放入 EAX
中(将返回值放入 EAX
的过程需要在内核中实现)。在执行 int
指令陷入内核之前,为了保证系统调用返回时能够恢复正确的状态,我们需要先保存寄存器的内容。接下来,int
指令会触发从用户空间到内核空间的切换。在 lab2/lib/syscall.c
中,syscall()
函数的实现如下:
int32_t syscall(int num, uint32_t a1,uint32_t a2,uint32_t a3, uint32_t a4, uint32_t a5){
int32_t ret = 0 ;
uint32_t eax, ecx, edx, ebx, esi, edi;
asm volatile("movl %%eax, %0":"=m"(eax));
asm volatile("movl %%ecx, %0":"=m"(ecx));
asm volatile("movl %%edx, %0":"=m"(edx));
asm volatile("movl %%ebx, %0":"=m"(ebx));
asm volatile("movl %%esi, %0":"=m"(esi));
asm volatile("movl %%edi, %0":"=m"(edi));
asm volatile("movl %0, %%eax"::"m"(num));
asm volatile("movl %0, %%ecx"::"m"(a1));
asm volatile("movl %0, %%edx"::"m"(a2));
asm volatile("movl %0, %%ebx"::"m"(a3));
asm volatile("movl %0, %%esi"::"m"(a4));
asm volatile("movl %0, %%edi"::"m"(a5));
asm volatile("int $0x80");
asm volatile("movl %%eax, %0":"=m"(ret));
asm volatile("movl %0, %%eax"::"m"(eax));
asm volatile("movl %0, %%ecx"::"m"(ecx));
asm volatile("movl %0, %%edx"::"m"(edx));
asm volatile("movl %0, %%ebx"::"m"(ebx));
asm volatile("movl %0, %%esi"::"m"(esi));
asm volatile("movl %0, %%edi"::"m"(edi));
return ret;
}
保存寄存器的旧值
我们在使用
eax
,ebx
,ecx
,edx
,esi
,edi
前将寄存器的值保存到了栈中,如果去掉保存和恢复的步骤,从内核返回之后会不会产生不可恢复的错误?
int
指令接收一个8-Bits的立即数为参数,产生一个以该操作数为中断向量的软中断,其流程分为以下几步
-
根据
IDTR
中的地址找到中断描述符表(IDT
),然后通过该地址查找相应的中断向量的门描述符。 -
比较当前特权级(
CPL
)和门描述符的描述符特权级(DPL
)。如果CPL
大于DPL
,触发#GP
异常;如果不大于,继续执行。 -
若是从
Ring 3
切换到Ring 0
的陷入操作,根据TR
和GDT
找到TSS
的位置,从TSS
中读取SS0
和ESP0
,并将这些值加载到堆栈中。注意,这些值代表的是用户态数据。 -
将
EFLAGS
、CS
和EIP
压入堆栈,以保存当前执行上下文。 -
如果门描述符是一个 Interrupt Gate,则将
EFLAGS
中的IF
位清零,以禁用中断。 -
对于特定的中断向量,需要将错误码压入堆栈。
-
使用
IDT
中对应的描述符设置CS
和EIP
,并跳转到相应的中断处理程序执行。
中断处理程序执行结束,需要从ring0
返回ring3
的用户态的程序时,使用iret
指令
iret
指令流程如下
-
iret
指令将当前栈顶的数据依次pop至EIP
,CS
,EFLAGS
寄存器 -
若Pop出的
CS
寄存器的CPL
数值上大于当前的CPL
,则继续将当前栈顶的数据依次Pop至ESP
,SS
寄存器 -
恢复CPU的执行
系统调用的参数传递
每个系统调用至少需要一个参数,即系统调用号,用来指示通过中断陷入内核后该执行哪个内核函数。普通 C 语言函数的参数传递通常通过将参数从右向左依次压入堆栈来完成。然而,系统调用涉及从用户态堆栈切换到内核态堆栈,这使得传统的堆栈参数传递方式不可行。因此,框架代码通过使用 eax
、ebx
等通用寄存器,将系统调用的参数从用户态传递到内核态。
框架代码 kernel/irqHandle.c
中使用了TrapFrame
这一数据结构,其中保存了内核堆栈中存储的 7个寄存器的值,其中的通用寄存器的取值即是通过上述方法从用戶态传递至内核态,并通过 pushal
指令压入内核堆栈的
键盘驱动
以下代码用于获取键盘扫描码。每个键的按下与释放都会分别产生一个键盘中断,并对应不同的扫描码;对于不同类型的键盘,其扫描码也不完全一致
uint32_t getKeyCode() {
uint32_t code = inByte(0x60);
uint32_t val = inByte(0x61);
outByte(0x61, val | 0x80);
outByte(0x61, val);
return code;
}
RTC获取日期信息
在汇编语言中,我们可以通过读取 CMOS 中的日期和时间信息来获取 RTC 数据。具体步骤如下:
1. RTC I/O 端口
RTC 的数据存储在 CMOS 内存中,并通过一组 I/O 端口进行访问:
- 0x70:选择寄存器(用于选择 RTC 中的某个寄存器)
- 0x71:数据寄存器(读取或写入数据)
什么是CMOS内存
CMOS内存(Complementary Metal-Oxide-Semiconductor memory)通常指的是用于存储计算机基本输入输出系统(BIOS)设置的非易失性内存。
其他的内容请你STFW!
2. CMOS 中存储的数据结构
RTC 在 CMOS 中以特定的格式存储时间数据。常用的时间数据结构如下:
- 秒(Seconds):寄存器 0x00
- 分(Minutes):寄存器 0x02
- 时(Hours):寄存器 0x04
- 日(Day of the Month):寄存器 0x07
- 月(Month):寄存器 0x08
- 年(Year):寄存器 0x09
3. 获取时间的步骤
我们通过汇编语言读取 RTC 寄存器的值,然后将 BCD(Binary-Coded Decimal,二进制编码十进制)格式转换为正常的数字格式。
解决思路
磁盘加载不再赘述。关于中断机制,你可以单独完成,也可以结合printf()
或是按键串口回显的逻辑完成中断。下面以按键回显例,介绍按键和中断的实现思路:
键盘按键的串口回显
当用户按下或释放按键时,键盘接口会接收到对应的键盘扫描码,并触发一个中断请求。键盘中断服务程序首先从接口获取扫描码,然后根据扫描码判断用户按下的键并进行相应处理。处理完成后,程序会通知中断控制器中断已处理完毕,并执行中断返回。
设置门描述符
要想加上键盘中断的处理,首先要在IDT
中加上键盘中断对应的门描述符,根据前文,8259a将硬件中断映射到键盘中断的向量号0x20
-0x2F
,键盘为IRQ1
,所以键盘中断号为0x21
。
框架代码也提供了键盘中断的处理函数irqKeyboard()
,所以需要同学们在initIdt()
中完成门描述符设置。
值得注意的一点是:硬件中断不受DPL
影响,8259A的 15 个中断都为内核级可以禁止用户程序用int
指令模拟硬件中断完成这一步后,每次按键,内核会调用irqKeyboard()
进行处理
完善中断服务例程
追踪irqKeyboard()
的执行,最终落到keyboardHandle()
。请同学们需要在这里利用 键盘驱动接口 和 串口输出接口 完成键盘按键的串口回显。完成这一步之后,你就能在stdio显示你按的键;另外,你也可以采用我们熟悉的显存的方式,将按键直接打印在屏幕上
在保护模式下实现定时器
kernel/timer.h
与 kernel/timer.c
是本次实验提供的定时器的声明与定义。请大家回顾lab1定时器的使用以及查看lab2中中断调用的过程,自己实现定时器功能。
完成这个目标:定时器间隔一定时间,产生时钟中断,访问中断处理程序
void initTimer() {
int counter = FREQ_8253 / HZ;
//assert(TIMER_PORT < 65536);
outByte(TIMER_PORT + 3, 0x34);
outByte(TIMER_PORT + 0, counter % 256);
outByte(TIMER_PORT + 0, counter / 256);
}
8253 定时器产生的时钟中断为 IRQ0
。在保护模式下,0x0
到 0x1F
的中断号是系统保留的,因此定时器中断的实际中断号为 0x20
。可以在中断描述符表(IDT)中设置中断号 0x20
的处理程序,以处理定时器中断。
在kernel/doIrq.S
中,已经为你提供了定时器的中断处理程序。你需要设置IDT
让中断号与中断处理程序绑定。
.global irqTimer
irqTimer:
pushl $0
# TODO: 将irqTimer的中断向量号压入栈
实现sleep()
库函数
- 设置定时器间隔一定时间(如10ms ) 产生一次时钟中断;
- 内核增加时钟标志变量
timeFlag
,初值为0; - 时钟中断处理程序,将
timeFlag
置1; - 实现 清零时钟标志
setTimeFlag
系统调用,用于设置内核标志变量值为0; - 实现 获取时钟标志
getTimeFlag
系统调用,用于获取内核timeFlag
值,并返回; - 阻塞方式
sleep()
库函数实现,先系统调用setTimeFlag()
,将内核时钟标志置0,再用while (getTimeFlag() == 0);
每跳出一次循环大约需要10ms
;则sleep(1)
,需要100次上述操作过程
在lab2/lib/lib.h
中提供了sleep()
的原型
void sleep(unsigned int seconds);
在 kernel/kernel/irqHandle.c
中,我们声明了sysSetTimeFlag
和sysGetTimeFlag
void sysSetTimeFlag(struct TrapFrame *tf);
void sysGetTimeFlag(struct TrapFrame *tf);
你需要设置syscall()
的调用号(存储在eax
中),进入到系统调用的内核函数中,完成指定的功能。你可以自定义指定系统调用号,只要能够实现相应的功能。
尽管时钟中断程序的执行时间相较于 10 毫秒来说非常短暂,你是否考虑过更高精度的
sleep
实现方式?
你可以尝试设置不同的定时器时间间隔,观察sleep()
的时间精度,并在实验报告中阐述你的发现
RDTSC
是一个很有用的工具,可以用来比较sleep()
函数的执行时间与RDTSC
的时间差异。
实现 printf()
的处理例程
与键盘中断类似,系统调用也需要设置门描述符。在本次实验中,系统调用的中断号设为 0x80
,对应的中断处理函数为 irqSyscall()
,并将 DPL
设置为用户级。以后所有的系统调用都将通过 0x80
中断来完成,不同的系统调用会通过传递不同的系统调用号(syscall()
的第一个参数)来选择对应的处理例程。
当用户执行 int $0x80
指令后,最终与显存相关的操作将在 sysPrint()
函数中完成,具体实现部分请同学们根据要求自行填充。
void sysPrint(struct TrapFrame *tf) {
int sel = USEL(SEG_UDATA);
char *str = (char*)tf->edx;
int size = tf->ebx;
int i = 0;
int pos = 0;
char character = 0;
uint16_t data = 0;
asm volatile("movw %0, %%es"::"m"(sel));
for (i = 0; i < size; i++) {
asm volatile("movb %%es:(%1), %0":"=r"(character):"r"(str+i));
// TODO: 完成光标的维护和打印到显存
}
updateCursor(displayRow, displayCol);
}
Tip
asm volatile("movb %%es:(%1), %0":"=r"(character):"r"(str+i));
表示将待str
的第i
个字符赋值给character
以下这段代码可以将字符ch
显示在屏幕的displayRow
行displayCol
列
data = character | (0x0c << 8);
pos = (80*displayRow+displayCol)*2;
asm volatile("movw %0, (%1)"::"r"(data),"r"(pos+0xb8000));
需要注意的是,处理换行和滚屏时,QEMU模拟的屏幕的大小是80*25
完成这一步后,用户调用printf()
就能在屏幕上进行输出了.
完善 printf()
的格式化输出
在框架代码中已经提供了printf()
最基本的功能
void printf(const char *format,...){
int i=0; // format index
char buffer[MAX_BUFFER_SIZE];
int count=0; // buffer index
int index=0; // parameter index
void *paraList=(void*)&format; // address of format in stack
int state=0; // 0: legal character; 1: '%'; 2: illegal format
int decimal=0;
uint32_t hexadecimal=0;
char *string=0;
char character=0;
while(format[i]!=0){
// TODO: support format %d %x %s %c
}
if(count!=0)
syscall(SYS_WRITE, STD_OUT, (uint32_t)buffer, (uint32_t)count, 0, 0);
}
为了方便使用, 你需要实现%d
, %x
, %s
,%c
四种格式转换说明符, 如果你不清楚它们的含义, 请查阅相关资料。
Tip
如果你学有余力,可以尝试更复杂的
printf
格式化功能(整数格式拓展%o
、%p
,格式控制选项%05d
等等)
实现 getChar()
, getStr()
的处理例程
这两个函数的处理方式比较灵活,getChar()
相对来说要容易一些,你可以等按键输入完成的时候,将末尾字符通过eax
寄存器传递回来。需要注意的是,在用户态向内核态传递参数的过程中,eax
既扮演了参数的角色,又扮演了返回值的角色。这一段在汇编代码lab2/lib/syscall.c
中有体现
asm volatile("int $0x80");
asm volatile("movl %%eax, %0":"=m"(ret));
相比之下,getStr()
的实现要复杂一些,涉及到字符串的传递。即使采用通用寄存器作为中间桥梁,字符串地址仍然涉及不同的数据段。我们在处理 printf()
时已经遇到过类似问题,解决方案是在内核态进行段描述符切换。内核态可以访问用户态的数据段,但反之不行。因此,在实现时,我们不能将内核中获取的显存字符串地址传递给用户,而是需要在用户态预先分配一块内存,将该地址传入内核。
当然,getStr()
的实现方式并非唯一,任何有效的实现方式都可以被接受。
实现now()
库函数
lab2/lib/lib.h
中声明了now()
库函数
struct TimeInfo {
int second;
int minute;
int hour;
int m_day;
int month;
int year;
};
void now(struct TimeInfo* tm_info);
在 x86 架构中,查询系统时间(如获取当前的日期、时间、年月日等)通常需要与 RTC(Real-Time Clock) 进行交互。RTC 通常是通过 I/O 端口访问的,x86 系统使用 CMOS(Complementary Metal-Oxide-Semiconductor)内存来存储时间信息。
相关资料中已经阐述RTC的相关内容。
测试用例
最后,我们还为大家准备了用户态I/O调用的测试代码,放置在用户程序主函数中,大家在实现功能时可以把这些复杂的测试用例先注释掉,自己写一些简单的调用进行验证。
然后在你觉得大功告成了之后,将上述测试用例取消注释,如果你能完全通过测试,恭喜你,lab2已经全部完成了,下次再见!
代码框架
lab2-STUID #自行修改后打包(.zip)提交
├── lab
│ ├── Makefile
│ ├── app #用户代码
│ │ ├── Makefile
│ │ └── main.c #主函数
│ ├── bootloader #引导程序
│ │ ├── Makefile
│ │ ├── boot.c
│ │ ├── boot.h
│ │ └── start.S
│ ├── kernel
│ │ ├── Makefile
│ │ ├── include #头文件
│ │ │ ├── common
│ │ │ │ ├── assert.h
│ │ │ │ ├── const.h
│ │ │ │ └── types.h
│ │ │ ├── common.h
│ │ │ ├── device
│ │ │ │ ├── disk.h
│ │ │ │ ├── keyboard.h
│ │ │ │ ├── serial.h
│ │ │ │ ├── timer.h
│ │ │ │ └── vga.h
│ │ │ ├── device.h
│ │ │ ├── x86
│ │ │ │ ├── cpu.h
│ │ │ │ ├── io.h
│ │ │ │ ├── irq.h
│ │ │ │ └── memory.h
│ │ │ └── x86.h
│ │ ├── kernel #内核代码
│ │ │ ├── disk.c #磁盘读写API
│ │ │ ├── doIrq.S #中断处理
│ │ │ ├── i8259.c #重设主从8259A
│ │ │ ├── idt.c #初始化中断描述
│ │ │ ├── irqHandle.c #中断处理函数
│ │ │ ├── keyboard.c #初始化键码表
│ │ │ ├── kvm.c #初始化 GDT 和加载用户程序
│ │ │ ├── serial.c #初始化串口输出
│ │ │ ├── timer.c #设置8253/4定时器芯片
│ │ │ └── vga.c
│ │ ├── lib
│ │ │ └── abort.c
│ │ ├── main.c #主函数
│ │ └── Makefile
│ ├── lib #库函数
│ │ ├── lib.h
│ │ ├── syscall.c #系统调用入口
│ │ └── types.h
│ ├── Makefile
│ └── utils
│ ├── genBoot.pl #生成引导程序
│ └── genKernel.pl #生成内核程序
└── report
└── 231220000.pdf
相关资源
作业提交
- 本次作业需提交可通过编译的实验相关源码与报告,提交前请确保执行
make clean
动作. - 提交的最后结果应该要能完整实现三个系统调用库函数
printf()
,getChar()
,getStr()
,sleep()
,now()
. - 其他问题参看
Introduction
中的 作业规范与提交 一章