Lab 1:内核启动与时钟中断¶
Estimated time to read: 6 minutes
DDL
本实验文档正在编写中,尚未正式发布。
实验简介¶
在 Lab 1 中我们将开始构建自己的内核。梳理一下目前的思路:
-
在实验导读和 Lab0 中,我们已经了解了启动过程:
- OpenSBI 跳转到内核第一条指令处。
arch/riscv/boot.S
是内核最开始执行的代码。查看它,你会发现它最终执行了call start_kernel
。这个函数位于arch/riscv/init/main.c
,是一个 C 语言写的函数。
那么有如下问题:
- 从 OpenSBI 跳到内核时,系统的状态(寄存器、内存等)是什么样的?这需要你动手调试,用 Lab0 学习的 QEMU Monitor 和 GDB 自行查看。
- 从汇编代码跳到 C 函数,调用者、被调用者各需要做什么工作?回忆《计算机组成》课程,我们学习过汇编与 C 语言的关系。这需要你学习RISC-V 调用约定,然后补全
arch/riscv/boot.S
。 - 如何控制内核最开始执行的代码放置的位置?这需要你学习链接器脚本,然后理解
arch/riscv/kernel/vmlinux.lds
。 - 没有了标准库,打印字符等基本功能如何实现?这需要你学习SBI 规范,然后补全
arch/riscv/kernel/sbi.c
。
完成这些,你就能够启动自己的内核,并打印 Hello, kernel!
了。
-
我们的下一个目标是为 Lab2 线程调度做准备:
操作系统是事件驱动的,意味着系统的执行流是由各种事件触发的,例如 I/O 完成、进程创建、用户输入等。时钟中断是其中最基础、最重要的事件源之一。如果没有时钟中断,操作系统将无法感知时间的流逝,无法在合适的时机进行进程切换、调度、超时处理或实现延时等待。通过周期性触发的时钟中断,内核可以被动地转变为主动:不再依赖进程自愿交出 CPU,而是能在中断到来时打断当前执行流,检查是否有更高优先级的任务或等待超时的事件需要处理,从而保证多任务系统的公平性和响应性。
那么有如下问题:
- RISC-V 是如何设计中断与异常的?怎么开启时钟中断?这需要你学习RISC-V 特权级、中断与异常、CSR 寄存器。
- 时钟中断可能在任何时候发生。应该如何实现中断处理程序,保存和恢复现场,从而保证内核和用户程序的正确执行?这需要你学习RISC-V 中断异常委派、中断处理,然后补全
arch/riscv/kernel/entry.S
。
完成这些,你就能做 Lab2 了。
Part 1:启动工作¶
OpenSBI 调试¶
<在内核第一条指令处设置断点,查看当前环境状态>
RISC-V 调用约定¶
同学们上次熟悉 RISC-V 汇编与 C 语言的关系,应该是在《计算机组成》课程中。上《计算机体系结构》的时候,同学们只烧二进制 Bit 文件,应该已经把相关内容忘得差不多了。

使用 GCC 编译器编译一个简单的程序,得到的汇编如下(请点击 Edit on Compiler Explorer 放大查看):
请回忆你在《计算机组成》课程中学习的相关内容,回答下面的问题。如果有想不来的地方,请阅读 RISC-V ELF psABI Document 的以下章节:
- 1.1. Integer Register Convention
- 2.1. Integer Calling Convention
考点
- 每个函数的开头都操作了
sp
,这是在干什么? - 尝试修改 C 语言代码,你会发现
sp
的差值总是 16 的倍数,这是为什么? - 调用函数前后做了什么?
要点
参数寄存器与返回值
- RISC-V 提供 8 个参数寄存器 a0–a7:
- a0、a1:用于返回值
- a2–a7:用于传递参数,超出部分放在栈上
- 超过寄存器数量的参数会通过栈传递
参数传递规则
标量(Scalars)
- ≤ XLEN 位:放在一个寄存器或栈上传递
- 小于 XLEN 的整数会符号扩展(sign-extend)到 XLEN 位
- 小于 XLEN 的浮点数会零扩展到 XLEN 位,高位不定义
- 2 × XLEN 位:用连续两个寄存器传递,高位在编号大的寄存器中
- > 2 × XLEN 位:通过引用(地址)传递
结构体和联合体(Aggregates)
- ≤ XLEN 位:单寄存器传递
- ≤ 2 × XLEN 位:双寄存器传递,若寄存器不足,剩余部分放栈上
- > 2 × XLEN 位:按引用传递(传地址)
栈对齐和栈传参
- 栈指针(SP)必须在函数入口时 128-bit 对齐
- 参数在栈上的布局按 ≥ type alignment 和 XLEN 对齐,但不会超过栈本身的对齐要求
- 传栈的第一个参数在 SP + 0 位置,之后依次递增
变参函数(Variadic Functions)
- 变参与命名参数传递方式相同
- 特殊规则:
- 2 × XLEN 位对齐的参数必须使用偶数号寄存器对
- 一旦有参数被传到栈上,之后的参数都必须放栈上
返回值
- 返回值的传递方式 = 该类型第一个命名参数的传递方式
- 如果是按引用传递的,调用者分配空间,传地址,callee 写结果
结构体对齐与位域规则
- 小于一个 XLEN 的结构体直接放在寄存器里,按内存布局排列字段
- 位域按小端序(little-endian)打包:
- 如果跨越对齐边界,跳到下一个对齐边界
- 多余位按 padding 处理
寄存器保存规则
- s0–s11 必须在函数调用间保持(callee-save)
- 临时寄存器 t0–t6、a0–a7 调用不保证保存(caller-save)
链接器脚本与内核内存布局¶
在 C 语言编程课上,我们了解过编译器的基本流程:预处理、编译、汇编、链接。具体命令例:
riscv64-linux-gnu-gcc -S main.c -o main.S
riscv64-linux-gnu-as main.S -o main.o
riscv64-linux-gnu-gcc -S func.c -o func.S
riscv64-linux-gnu-as func.S -o func.o
riscv64-linux-gnu-ld main.o func.o -o main
链接过程合并多个目标文件,并重定位它们的数据,链接各类符号(函数名、变量名等)。链接器 ld
通过链接脚本来控制最终的生成的文件,默认使用内置的脚本生成 ELF 格式的可执行文件,你可以使用下面的命令查看这个脚本:
请阅读 3 Linker Scripts - LD 的以下章节,学习基本的链接器脚本语法:
要点
Linker Script 基础概念
-
目标文件(Object File)
- GCC 或其他编译器生成的
.o
文件。 - 每个目标文件包含若干 输入节(Input Section),例如
.text
,.data
,.bss
。 - 每节有名字、大小和数据内容。
- GCC 或其他编译器生成的
-
输出文件(Output File)
- 链接器将多个输入文件合并生成单一输出文件。
- 输出文件也有若干 输出节(Output Section)。
- 输出节是由输入节组成的,它们可以被加载到内存中或只分配空间。
-
节的属性
- Loadable(可加载):节内容需要加载到内存,例如代码和初始化数据。
- Allocatable(可分配):节不包含数据,但需要在内存中保留空间,例如
.bss
。 - 非加载非分配:节通常用于调试信息,例如
.debug_*
。
-
符号(Symbol)
- 每个目标文件中定义或引用的函数、全局变量都是符号。
- 链接器会解析符号的地址,未定义符号需要在链接时找到定义。
-
VMA 和 LMA
- VMA(Virtual Memory Address):程序运行时的虚拟地址。
- LMA(Load Memory Address):程序加载到内存时的地址。
- 例如,ROM 加载到 RAM 时 LMA ≠ VMA。
-
位置计数器
.
- 链接器使用
.
来表示当前内存位置。 - 每定义一个输出节,
.
会自动增加节大小。
- 链接器使用
Linker Script 文件格式
- LD Script 是文本文件。
-
支持 注释:
-
语法结构:
- 关键字 + 参数,例如
OUTPUT_ARCH("riscv")
- 符号赋值,例如
BASE_ADDR = 0x80000000
- 使用
SECTIONS
定义输出节布局。
- 关键字 + 参数,例如
SECTIONS 指令
SECTIONS
是最核心的命令,用于描述输出文件的内存布局。示例:
SECTIONS
{
. = 0x10000; /* 设置位置计数器 */
.text : { *(.text) } /* 将所有输入文件的 .text 节放入输出节 .text */
. = 0x8000000;
.data : { *(.data) }
.bss : { *(.bss) }
}
解释:
. = 0x10000
:将位置计数器设置为 0x10000。.text : { *(.text) }
:输出节.text
包含所有输入节.text
。.data
和.bss
同理。- 链接器会保证输出节按需对齐,如果地址不符合要求,会在节之间插入间隙。
现在,我们来简单对比一下 Linux 内核链接脚本和 ld
内置链接脚本的区别:
关键要点:
- 起始位置:ELF 程序的在
0x10000 + SIZEOF_HEADERS
,而内核是0xffffffff80000000
。学习虚拟内存后你会理解这些地址受操作系统内存布局影响。 - 对齐:内核对齐要求十分严格,通常使用页面对齐(4KB 或 2MB 等)、缓存行对齐(64B)等,需要考虑内存分页、NUMA 架构 Cache 一致性等问题。
- 节的选择:内核不使用动态链接、重定位,这些技术是为了应用程序设计的。因此没有
.dynsym
、.rela.dyn
等节。 -
_start
符号:两者都定义了_start
作为程序入口,但内核的_start
定义在脚本中,而应用程序的_start
由标准库定义(标准库需要做一些初始化工作),并不在应用程序代码中。你可以尝试编译一个简单的程序而不带上标准库,
ld
就会找不到_start
符号:
Take Home Message
总而言之,与普通的应用程序相比,操作系统的链接需要精细控制内存布局、初始化代码段、只读/可写数据段、调试信息和特定硬件相关表格的放置。
至于具体怎么做并不重要,感兴趣的同学可以自行结合 ld
文档,阅读实验代码和内核中的 arch/riscv/kernel/vmlinux.lds
。
Task 1:为 start_kernel()
准备运行环境¶
SBI 与 ECALL¶
请阅读 Volume I: Unprivileged ISA Specification 的以下章节:
-
2.8. Environment Call and Breakpoints
考点
- ECALL 指令的作用是什么?
- 对于我们实现的操作系统来说,服务请求的参数传递由谁定义?
请阅读 RISC-V Supervisor Binary Interface Specification 的以下章节:
-
Chapter 1. Introduction
考点
- 什么是 SBI?它为谁提供服务?
-
Chapter 3. Binary Encoding 的章节导言
考点
- 在本课程中,谁是 Supervisor?谁是 SEE?
- 如何标识一个特定的 SBI 调用?
- SBI 调用的参数和返回值是如何传递的?
- SBI 调用时,哪些寄存器的值不会被保存?
- 如何判断 SBI 调用是否成功?
C 内联汇编¶
Task 2:使用 SBI 实现 printk()
¶
请阅读 RISC-V Supervisor Binary Interface Specification 的以下章节:
- Chapter 12. Debug Console Extension (EID #0x4442434E "DBCN")
在 arch/riscv/sbi.c
中使用内联汇编实现 sbi_debug_console_write
。
Part 2:时钟中断及其处理¶
RISC-V 特权级¶
请阅读 Volume I: Unprivileged ISA Specification 的以下章节:
-
1.6. Exceptions, Traps, and Interrupts 的第一段(直接贴在下面了):
We use the term exception to refer to an unusual condition occurring at run time associated with an instruction in the current RISC-V hart. We use the term interrupt to refer to an external asynchronous event that may cause a RISC-V hart to experience an unexpected transfer of control. We use the term trap to refer to the transfer of control to a trap handler caused by either an exception or an interrupt.
考点
- RISC-V 中 exception、interrupt 和 trap 有何区别?
- 举两个 Trap 的例子
请阅读 Volume II: Privileged Architecture Specification 的以下章节:
-
1.2. Privilege Levels
考点
- 特权级是用来干什么的?
- 执行当前特权级不允许的操作会发生什么?
- M、U、S 模式分别是为了什么设计的?
RISC-V 中断、异常与 CSR 寄存器¶
请阅读 Volume II: Privileged Architecture Specification 的以下章节:
-
Chapter 2. Control and Status Registers (CSRs) 的章节导言
考点
- 读取、修改、写入 CSR 的指令定义在哪个扩展?
- S 模式的 CSR 能被 M 模式访问吗?反之呢?
-
Chapter 3. Machine-Level ISA
-
3.1.6.1. Privilege and Global Interrupt-Enable Stack in mstatus register
考点
- mstatus 寄存器的作用是什么?
- xIE bit 的作用是什么?
- 运行在 S 模式且 mstatus.SIE=0,mstatus.MIE=0 时,发生中断会进入哪个模式? 当从特权级 y 陷入到更高的特权级 x 时,xPIE、xIE 和 xPP 会如何变化?
- xRET 指令返回时,特权级和中断使能位会如何恢复?xPP 会设置为什么?
-
3.1.9. Machine Interrupt (mip and mie) Registers
考点
- mip 和 mie 寄存器的作用分别是什么?
- 在什么条件下,中断会陷入 M 模式?
- 列举一些中断源
- 如果中断委派到 S 模式,它在 mip 和 mie 中的行为是什么?
- 为什么软件中断的优先级高于定时器中断?
-
Task3:开启时钟中断¶
RISC-V 中断异常委派¶
请阅读 Volume II: Privileged Architecture Specification 的以下章节:
-
Chapter 3. Machine-Level ISA
-
3.1.7. Machine Trap-Vector Base-Address (mtvec) Register
考点
- mtvec 寄存器的作用是什么?
- 为什么这个寄存器的低两位可以分配给 MODE 字段?
- 向量中断模式下,发生中断后 PC 会被设置成多少?
-
3.1.8. Machine Trap Delegation (medeleg and mideleg) Registers
考点
- 为什么需要委派机制?
- medeleg 和 mideleg 寄存器的作用分别是什么?
- 当一个 trap 被委派到 S 模式后,下面这些地方的值会如何变化?
- scause
- stval
- sepc
- mstatus.SPP
- mstatus.SPIE
- mstatus.SIE
- 如果一个 trap 是在 M 模式下发生的,但它在 medeleg 中已被设置委派给 S 模式,会在哪里处理?
- mideleg 中的某一位被设置后,这个中断在 M 模式下会被触发吗?
-