硬件抽象层¶
硬件抽象层(HAL)隔离内核与具体硬件,让内核代码不需要关心底层细节。这提高了可移植性,也简化了驱动开发。
为什么需要硬件抽象层?¶
没有抽象层的问题¶
假设内核代码直接操作硬件寄存器:
// 直接写 UART
*(volatile u32*)0x10000000 = 'A';
问题: 1. 不可移植:换一个平台,地址可能不同 2. 难以维护:硬件细节散落各处 3. 测试困难:无法模拟硬件行为
抽象层的价值¶
// 通过抽象层
uart_putchar('A');
好处:
1. 可移植:不同平台实现不同的 uart_putchar
2. 清晰:意图明确
3. 可测试:可以提供模拟实现
RISC-V 的特权级¶
RISC-V 定义了三层特权级:
| 特权级 | 名称 | 用途 |
|---|---|---|
| M-Mode | Machine | 固件(OpenSBI),完全控制硬件 |
| S-Mode | Supervisor | 内核,受限制地访问硬件 |
| U-Mode | User | 用户程序,无法直接访问硬件 |
NoobKernel 运行在 S-Mode,通过 SBI(Supervisor Binary Interface)和 CSR 访问硬件。
CSR:控制和状态寄存器¶
CSR 是什么?¶
CSR 是 RISC-V 的控制寄存器,用于配置 CPU 行为和查询状态。
关键 CSR:
| CSR | 用途 |
|---|---|
| sstatus | 状态寄存器(中断使能、权限模式等) |
| stvec | 中断向量地址 |
| sepc | 异常发生时的 PC |
| scause | 异常原因 |
| satp | 页表基址 |
CSR 访问方式¶
RISC-V 提供专用指令:
csrr rd, csr # 读取 CSR 到 rd
csrw csr, rs # 将 rs 写入 CSR
csrs csr, rs # 设置 CSR 中的位
csrc csr, rs # 清除 CSR 中的位
在 C 语言中,使用内联汇编:
static inline u64 r_sstatus(void) {
u64 x;
asm volatile("csrr %0, sstatus" : "=r" (x));
return x;
}
为什么用内联函数而不是宏?¶
内联函数有类型检查,更安全:
// 宏:无类型检查
#define READ_CSR(csr) ...
// 内联函数:有类型检查
static inline u64 r_sstatus(void) { ... }
SBI:Supervisor Binary Interface¶
SBI 是什么?¶
S-Mode 软件无法直接执行某些操作(如设置定时器、关机),需要通过 SBI 请求 M-Mode 执行。
SBI 定义了 S-Mode 与 M-Mode 的接口规范。OpenSBI 是常用的 SBI 实现。
SBI 调用方式¶
使用 ecall 指令触发系统调用:
1. 设置参数寄存器(a0-a7)
2. 设置扩展 ID(a7)
3. 执行 ecall
4. 从 a0-a1 获取返回值
Legacy 与标准扩展¶
SBI 有两种接口风格:
Legacy(传统):简单但不规范
- sbi_console_putchar:输出字符
- sbi_shutdown:关机
标准扩展:规范化,返回错误码 - Time 扩展:定时器 - IPI 扩展:核间中断 - Base 扩展:版本查询
NoobKernel 混用两种风格:简单操作用 Legacy,复杂操作用标准扩展。
为什么需要 SBI?¶
某些操作只能在 M-Mode 执行: - 设置定时器(mtimecmp) - 关机 - 某些 CSR 访问
SBI 让 S-Mode 内核不需要实现 M-Mode 代码,简化开发。
PLIC:中断控制器¶
PLIC 是什么?¶
PLIC(Platform Level Interrupt Controller)管理外部中断。它决定: - 哪些中断源使能 - 中断优先级 - 哪个 CPU 处理哪个中断
PLIC 的工作流程¶
外设产生中断
│
▼
PLIC 接收中断信号
│ 检查优先级和使能状态
▼
发送给目标 CPU
│
▼
CPU 响应中断
│
▼
软件调用 plic_claim
│ PLIC 返回中断号
▼
处理中断
│
▼
软件调用 plic_complete
│ 告知 PLIC 处理完成
▼
PLIC 可以再次发送该中断
Claim-Complete 协议¶
为什么需要显式 Complete?
因为 PLIC 需要知道中断何时处理完毕。如果不 Complete: - 该中断源被"卡住",无法再次触发 - 可能丢失后续中断
中断优先级¶
PLIC 支持为每个中断源设置优先级(数值越小优先级越高)。plic_claim 总是返回当前挂起的最高优先级中断。
阈值可以屏蔽低优先级中断:只有优先级 > 阈值的中断才会送达。
定时器:时间的来源¶
RISC-V 定时器¶
RISC-V 定义了定时器 CSR 和内存映射寄存器:
mtime:当前时间(只读,MMIO)mtimecmp:比较值(写入触发定时器中断)
当 mtime >= mtimecmp 时,触发定时器中断。
SBI 定时器¶
S-Mode 无法直接访问 mtimecmp,需要通过 SBI:
sbi_set_timer(next_time);
定时器中断的作用¶
定时器中断是操作系统的心跳:
- 时间片轮转:定期触发调度
- 时间统计:统计进程运行时间
- 定时任务:实现 sleep、定时器等
精度与开销¶
定时器频率的选择:
- 高频(1000 Hz):响应快,但中断开销大
- 低频(10 Hz):开销小,但响应慢
NoobKernel 选择 100 Hz,平衡响应性和开销。
VirtIO:虚拟设备标准¶
VirtIO 是什么?¶
VirtIO 是虚拟化环境的设备接口标准。QEMU 使用 VirtIO 模拟设备(磁盘、网卡等)。
相比传统设备模拟,VirtIO 更高效: - 简化的接口 - 批量传输 - 零拷贝(部分场景)
VirtIO 的通信模型¶
VirtIO 使用虚拟队列(virtqueue)通信:
驱动程序 设备
│ │
│ 放入请求到队列 │
│───────────────────────▶│
│ │ 处理请求
│ │
│◀───────────────────────│
│ 从队列取出响应 │
VirtIO 块设备¶
VirtIO 块设备的请求格式:
请求类型(读/写)
保留字段
起始扇区号
数据缓冲区
状态字节
读写过程: 1. 构造请求描述符链 2. 加入可用环 3. 通知设备(写门铃寄存器) 4. 等待中断 5. 从已用环获取结果
为什么用 VirtIO 而不是真实硬件?¶
教学目的: - 接口简单,易于理解 - QEMU 支持,方便调试 - 无需真实硬件
真实硬件驱动更复杂: - 需要处理 DMA - 需要处理中断合并 - 需要处理电源管理
块设备抽象¶
块设备接口¶
NoobKernel 定义了统一的块设备接口:
struct blk_operations:
- read: 读取扇区
- write: 写入扇区
- flush: 刷新缓存
- get_capacity: 获取容量
不同块设备实现这个接口: - VirtIO 块设备 - SD 卡(未来) - RAM 盘(测试用)
设备号¶
每个块设备有唯一设备号:
dev_t = (major << 16) | minor
major: 设备类型(VirtIO、SD 等)
minor: 同类型的序号
设备号用于查找设备操作表。
平台适配¶
平台差异¶
不同硬件平台有不同的: - 内存布局 - 设备地址 - 中断号 - 定时器频率
平台配置头文件¶
每个平台一个头文件:
// qemu_virt.h
#define PM_START 0x80000000
#define PLIC_ADDR 0x0c000000
#define VIRTIO0_ADDR 0x10001000
...
通过条件编译选择:
#if defined(QEMU)
#include <platform/qemu_virt.h>
#elif defined(K210)
#include <platform/k210.h>
#endif
可移植性原则¶
编写可移植代码的原则:
- 使用抽象接口:不直接写硬件地址
- 参数化配置:不从代码中硬编码
- 隔离平台代码:集中在少数文件
设计权衡¶
为什么不用设备树?¶
Linux 使用设备树描述硬件,但 NoobKernel 选择硬编码配置:
- 简单:不需要解析设备树
- 固定平台:只支持 QEMU virt
- 启动快:省去解析开销
如果支持多平台,设备树是更好的选择。
为什么用轮询而不是中断做 VirtIO?¶
VirtIO 可以用中断或轮询。NoobKernel 用中断:
优点: - 不浪费 CPU - 支持其他操作
缺点: - 中断开销 - 实现复杂
对于高性能场景,可以结合轮询和中断。
思考题¶
- SBI 调用和系统调用有什么相似之处?有什么不同?
- 如果 PLIC 的 Complete 被忘记调用,会发生什么?
- 为什么定时器中断频率影响系统响应性?
- 如何添加对新硬件平台的支持?