tg-rcore-tutorial-ch1-uart3 0.1.1-preview.1

Chapter 1: Application and Basic Execution Environment (UART driver version) for rCore tutorial.
tg-rcore-tutorial-ch1-uart3-0.1.1-preview.1 is not a library.

!Attention

本crate包为AI4OS内测的T2L1实验包,尚在敏捷开发迭代中,包括但不限于文档完善、评分机制开发、报告提交等工作,未完成工作会紧跟列出:

TODO:

  • 完善《原理驱动+进度满足型学习者原理驱动者型学习文档》
  • 练习题开发
  • 评分进度cli程序开发
  • 提交实践报告

与uart2 crate的对比

参考uart2 crate之后,我发现我的设计与uart2的设计主要差别在于:

  1. 我的实验尽可能基于ch1的架构而来,保持了对sbi crate的依赖,只在ch1基础上对输出部分做了改造:也即采用串口(MMIO)的方式输出字符。 而uart2的crate则自包含,将sbi的汇编以及rust handler代码也包含在内,推测目的是让学生直接了解特权级切换和上下文保存机制。 但根据我个人的学习历程,在学习ch1的时候我就已经同步通读了sbi的代码,不需要再重复自包含,所以我选择同ch1一样的架构,依赖完整也可以直接运行。
  2. 我的实验对于串口的一些处理机制还不够完善,个人认为可以加到实验里让同学们自行完善一部分

第一章:UART 驱动实践

本章通过实现一个基于 UART(Universal Asynchronous Receiver/Transmitter)驱动的裸机程序,学习硬件设备驱动的基本原理和编程技巧。你将了解到如何通过内存映射 I/O(MMIO)直接操作串口硬件,并对比传统的 SBI 调用方式的差异。

学习目标

通过本章的学习和实践,你将掌握:

  • UART 串口硬件的工作原理和寄存器布局
  • 内存映射 I/O(MMIO)的访问机制与 volatile 关键字的必要性
  • UART 驱动与 SBI 调用在终端输出上的实现差异
  • 轮询式 I/O 的实现方法与适用场景
  • 裸机环境下设备驱动的集成方法

项目结构

tg-rcore-tutorial-ch1-uart/
├── .cargo/
│   └── config.toml     # Cargo 配置:交叉编译目标和 QEMU runner
├── build.rs            # 构建脚本:自动生成链接脚本
├── Cargo.toml          # 项目配置与依赖
├── README.md           # 本文档
├── rust-toolchain.toml # Rust 工具链配置
└── src/
    └── main.rs         # 程序源码:入口、主函数、panic 处理

源码阅读导航

建议按以下顺序阅读代码,聚焦 UART 驱动的实现与使用:

阅读顺序 位置 重点问题
1 src/main.rsrust_main 函数 如何使用 UART 驱动进行字符输出?
2 tg-rcore-tutorial-uart/src/uart.rsUart::init UART 初始化流程涉及哪些关键寄存器?
3 uart.rsUart::put_char 轮询等待发送器空闲的机制如何实现?
4 uart.rsread_reg / write_reg 为什么需要使用volatile 访问?
5 src/main.rspanic_handler panic 时如何通过串口输出调试信息?

DoD 验收标准(本章完成判据)

  • tg-rcore-tutorial-ch1-uart 目录执行 cargo run,看到 "Hello from UART!" 输出并正常关机
  • 能够解释 MMIO 与 SBI 调用在终端输出实现上的本质区别
  • 能够说明 UART 初始化流程中 IER、LCR、FCR 等寄存器的作用
  • 能够从源码分析字符输出的轮询等待机制
  • 能够描述 volatile 关键字在设备驱动中的必要性

环境准备

1. 安装 Rust 工具链

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"

验证安装:

rustc --version
cargo --version

2. 添加 RISC-V 64 编译目标

rustup target add riscv64gc-unknown-none-elf

3. 安装 QEMU 模拟器

Ubuntu/Debian:

sudo apt update
sudo apt install qemu-system-misc

macOS(Homebrew):

brew install qemu

验证安装:

qemu-system-riscv64 --version

编译与运行

编译

cargo build

编译过程为交叉编译,目标平台为 riscv64gc-unknown-none-elf,由 .cargo/config.toml 配置指定。

运行

cargo run

该命令会自动启动 QEMU 虚拟机,加载编译后的内核。预期输出:

Hello from UART!

核心概念

UART 驱动 vs. SBI 调用

在操作系统开发中,终端输出有两种常见的实现方式:

方式 实现原理 性能特点 适用场景
SBI 调用 通过 SBI(Supervisor Binary Interface)固件提供的服务,如console_putchar 简单、稳定,但存在上下文切换开销 快速原型、教学演示、基础功能
UART 驱动 直接通过内存映射 I/O 操作 UART 硬件寄存器 更低延迟、更高控制权,但需要处理硬件细节 生产环境、性能敏感场景、自定义硬件

关键区别:

  • SBI 调用是软件抽象层,内核通过 ecall 指令陷入 M-mode,由固件处理硬件细节
  • UART 驱动是硬件直接操作,内核通过内存读写指令直接访问设备寄存器

内存映射 I/O(MMIO)

MMIO 是一种硬件设计,将设备寄存器映射到处理器的内存地址空间。CPU 通过普通的内存读写指令(ld/sd)访问这些地址,实际上是在与设备寄存器交互。

在 QEMU virt 平台中,NS16550A 兼容串口被映射到地址 0x10000000

volatile 关键字的重要性

编译器在进行优化时,可能会认为对同一内存地址的多次读取是冗余的,从而合并或删除某些访问。对于设备寄存器,这种优化会导致错误,因为寄存器的值可能随时被硬件改变。

volatile 访问强制编译器:

  1. 按代码顺序执行每次访问
  2. 不缓存寄存器值
  3. 不进行冗余访问优化

示例:

// 错误:编译器可能优化掉第二次读取
let status = ptr.read_volatile();
if status & TX_READY != 0 {
    // 编译器可能认为 status 未改变,直接重用
}

// 正确:每次读取都是显式的
while ptr.read_volatile() & TX_READY == 0 {}

UART 硬件与寄存器

UART(通用异步收发器)是一种异步串行通信接口,负责将并行数据转换为串行数据发送,并将接收到的串行数据转换为并行数据。

NS16550A 关键寄存器

寄存器 偏移 读写 说明
RHR 0 接收保持寄存器:存放接收到的字符
THR 0 发送保持寄存器:写入待发送的字符
IER 1 读写 中断使能寄存器:控制哪些事件产生中断
FCR 2 FIFO 控制寄存器:启用/清空 FIFO
LCR 3 读写 线路控制寄存器:设置数据位、停止位、奇偶校验
LSR 5 线路状态寄存器:反映发送/接收状态

关键标志位

  • LSR_RX_READY (bit 0):接收器有数据可读
  • LSR_TX_IDLE (bit 5):发送器空闲,可接受新字符
  • LCR_BAUD_LATCH (bit 7):置 1 时访问波特率除数寄存器
  • LCR_EIGHT_BITS (bits 1:0 = 11):8 位数据位

UART 初始化流程

参考 xv6 实现,UART 初始化步骤如下:

  1. 禁用中断IER = 0(使用轮询方式,不启用中断)
  2. 设置波特率:进入 LCR_BAUD_LATCH 模式,设置波特率为 38.4K
  3. 设置数据格式LCR = LCR_EIGHT_BITS(8 位数据位,1 位停止位,无奇偶校验)
  4. 启用 FIFOFCR = FIFO_ENABLE | FIFO_CLEAR(启用 16 字节 FIFO 并清空)

轮询式 I/O

轮询(Polling)是一种简单的 I/O 处理方式,CPU 不断检查设备状态,直到设备就绪。

字符输出流程:

  1. 等待发送器空闲:读取 LSR 寄存器,检查 TX_IDLE 标志
  2. 写入字符:向 THR 寄存器写入字符
  3. 重复:继续等待并写入下一个字符

优缺点:

  • 优点:实现简单,无需中断处理机制
  • 缺点:CPU 利用率低,在等待期间忙循环

代码解读

项目配置

.cargo/config.toml 配置交叉编译和 QEMU runner:

[build]
target = "riscv64gc-unknown-none-elf"

[target.riscv64gc-unknown-none-elf]
runner = [
    "qemu-system-riscv64",
    "-machine", "virt",
    "-nographic",
    "-bios", "none",
    "-kernel",
]

Cargo.toml 中的关键依赖:

[dependencies]
tg-rcore-tutorial-uart = { path = "../tg-rcore-tutorial-uart", version = "0.1.0-preview.1" }
tg-sbi = { path = "../tg-sbi", version = "0.4.2-preview.1", features = ["nobios"] }

UART 驱动使用

src/main.rs 中:

use tg_rcore_tutorial_uart::Uart;
use tg_sbi::shutdown;

#[no_mangle]
unsafe extern "C" fn rust_main() -> ! {
    // 初始化 UART
    Uart::init();
  
    // 通过 UART 输出字符串
    Uart::put_str("Hello from UART!\n");
  
    // 尝试读取字符(非阻塞)
    for _ in 0..8 {
        if let Some(c) = Uart::try_get_char() {
            Uart::put_char(c); // 回显
        }
    }
  
    // 使用 SBI 关机(仅此功能仍依赖 SBI)
    shutdown(false);
}

UART 驱动实现

tg-rcore-tutorial-uart/src/uart.rs 中的核心函数:

impl Uart {
    /// 初始化 UART 硬件
    pub fn init() {
        unsafe {
            // 禁用中断
            Self::write_reg(Reg::IER, 0x00);
        
            // 设置波特率
            Self::write_reg(Reg::LCR, LCR_BAUD_LATCH);
            Self::write_reg(Reg::DLL, 0x03); // 38.4K 波特率
            Self::write_reg(Reg::DLM, 0x00);
        
            // 8 位数据位,1 位停止位,无奇偶校验
            Self::write_reg(Reg::LCR, LCR_EIGHT_BITS);
        
            // 启用 FIFO
            Self::write_reg(Reg::FCR, FIFO_ENABLE | FIFO_CLEAR);
        }
    }
  
    /// 输出单个字符(阻塞等待)
    pub fn put_char(c: u8) {
        unsafe {
            // 等待发送器空闲
            while Self::read_reg(Reg::LSR) & LSR_TX_IDLE == 0 {}
            // 写入字符
            Self::write_reg(Reg::THR, c);
        }
    }
  
    /// 读取寄存器(volatile 访问)
    unsafe fn read_reg(reg: Reg) -> u8 {
        let ptr = (UART_BASE + reg as usize) as *const u8;
        ptr.read_volatile()
    }
  
    /// 写入寄存器(volatile 访问)
    unsafe fn write_reg(reg: Reg, value: u8) {
        let ptr = (UART_BASE + reg as usize) as *mut u8;
        ptr.write_volatile(value);
    }
}

本章小结

本章通过实现 UART 驱动,深入学习了硬件设备驱动的基本原理:

  1. MMIO 机制:理解了设备寄存器通过内存地址映射的访问方式,掌握了 volatile 关键字在防止编译器优化中的关键作用。
  2. UART 驱动实现:学习了 NS16550A 串口芯片的寄存器布局,掌握了初始化流程、字符发送的轮询等待机制。
  3. 驱动 vs. SBI 对比:理解了直接硬件操作与通过固件抽象层调用的本质区别,能够根据场景选择合适的技术方案。
  4. 轮询式 I/O:掌握了简单的轮询实现方式,为后续学习中断驱动、DMA 等高级 I/O 技术打下基础。

这是操作系统设备驱动开发的第一步,后续章节将在此基础上引入中断机制、块设备驱动、文件系统等更复杂的设备管理技术。

思考题

  1. volatile 的必要性:如果不使用 volatile 访问 UART 寄存器,可能会发生什么问题?请结合编译器优化策略和硬件寄存器特性进行分析。
  2. 轮询 vs. 中断:轮询方式和中断驱动方式在 CPU 利用率、响应延迟、实现复杂度等方面有何区别?在什么场景下你会选择轮询方式?
  3. 波特率计算:UART 初始化代码中设置了波特率为 38.4K,这个值是如何计算出来的?如果希望改为 115200 波特率,需要修改哪些寄存器值?
  4. 地址硬编码:UART 基地址 0x10000000 在代码中是硬编码的,这种方式有什么优缺点?在实际的操作系统中,如何更灵活地管理设备地址?
  5. 错误处理:当前的 UART 驱动缺乏错误处理机制(如超时、奇偶校验错误等)。如果要增强鲁棒性,需要考虑哪些错误情况?如何设计相应的错误处理机制?

参考资料

依赖

依赖 说明
tg-rcore-tutorial-uart NS16550A UART 驱动实现,提供字符输入输出
tg-sbi SBI 调用封装(仅用于关机功能)

License

Licensed under GNU GENERAL PUBLIC LICENSE, Version 3.0.