RISC-V from Scratch 4

RISC-V from scratch 4: 写 UART 驱动

接上一篇博客,我今天继续写 RISC-V from scratch 系列博客。我原本打算将该系列全部翻译成中文,但原作者貌似没有把这一系列完成就咕咕了,因此本文的内容是我自己实践的内容,以及一些自己的想法,放在这里同大家探讨,算是狗尾续貂,弥补遗憾

简介

欢迎再次来到 RISC-V from scratch ,先快速回顾一下我们之前做过的内容,我们之前已经探索了很多与 RISC-V 及其生态相关的底层概念(例如编译、链接、原语运行时、汇编等)。具体来说,在上一篇文章中,我们初步认识了 UART,并从 riscv64-virt.dts 中找到了关于 UART 的基本信息,我们还在链接器脚本里添加了 UART 的基本地址,且已经搭建了一个驱动程序框架。

在我实际动手操作的过程中,发现使用 RISC-V 汇编写 UART 驱动程序是吃力不讨好的行为,因此,我使用 C 语言完成了驱动的编写,这就是本篇博客的主要内容。如果大家想要看一下原博主的内容,那么就请参考 RISC-V from scratch 4: Creating a function prologue for our UART driver (2 / 3)

搭建环境

如果你还未看本系列博客的第一部分,没有安装 riscv-qemu 和 RISC-V 工具链,那么赶紧点击上面标题的链接,跳转到 “QEMU and RISC-V toolchain setup”

之后,再将博主创建的 github 库下载下来,作为我们的工作点。

1
2
3
4
5
6
git clone git@github.com:twilco/riscv-from-scratch.git
# or `git clone https://github.com/twilco/riscv-from-scratch.git` to clone
# via HTTPS rather than SSH
# alternatively, if you are a GitHub user, you can fork this repo.
# https://help.github.com/en/articles/fork-a-repo
cd riscv-from-scratch/work

译注:亲测无需下载 github 库也可实现下面的实验。

UART 回顾

本博客的目的是在 virt 机器上,使用 UART 在屏幕打印出字符。为了使这一工作顺利,我们先来回顾一下 UART 在 virt 机器上的布局。

1
2
3
4
5
6
7
uart@10000000 {
interrupts = <0x0a>;
interrupt-parent = <0x02>;
clock-frequency = <0x384000>;
reg = <0x00 0x10000000 0x00 0x100>;
compatible = "ns16550a";
};

参考之前的博客,我们已经了解到 UART 是用于传输、接收系列数据的硬件设备,根据上面所列的数据,UART 的时钟频率是3.6864 MHz,UART 在内存的位置起始于 0x10000000 ,长度为 0x100 字节,我们在链接器脚本中定义了全局符号 __uart_base_add 。UART 与 NS16550a 编程模型兼容。

UART 细节

完整的了解该硬件是写驱动程序的前提。我细读了 Serial UART informationTECHNICAL DATA ON 16550,算是大致清楚了 UART 结构的数据存储传递情况 : )

考虑到文章的篇幅,以及读者的耐心等原因,我在这里简单的扯两句,想要完整的了解 UART ,啃英文技术文档是必不可少的。

在 UART 中,可访问的 I/O 端口一共有8个,他们的功能作用列于下表,base address 指的就是 UART 的起始内存位置,而 DLAB 位于 LCR 寄存器,是一个用于转换功能的转换位。

UART register to port conversion table
  DLAB = 0 DLAB = 1
I/O port Read Write Read Write
base RHR
receiver
buffer
THR
transmitter
holding
DLL divisor latch LSB
base + 1 IER
interrupt
enable
IER
interrupt
enable
DLM divisor latch MSB
base + 2 IIR
interrupt
identification
FCR
FIFO
control
IIR
interrupt
identification
FCR
FIFO
control
base + 3 LCR line control
base + 4 MCR modem control
base + 5 LSR
line
status

factory
test
LSR
line
status

factory
test
base + 6 MSR
modem
status

not
used
MSR
modem
status

not
used
base + 7 SCR scratch

上表简单罗列了一下各个寄存器的位置、名称、相关功能等,基于这些内容,我们就已经可以明白很多了:

  • 访问寄存器的方法非常简单,将之前定义的 __uart_base_addr 加上偏移量,就是各个寄存器的地址了

  • 我们的任务是在屏幕上打印出字符,显然不能将 DLAB 设置为1,DLAB = 1 时,RHR IER 寄存器就被用来设置通信速率了。具体描述见下:

    DLL (Divisor Latch LSB Register) contains the lower 8-bit value that the MCU divides into the MCU clock (PCLK) to generate the UART baud rate.

    DLM (Divisor Latch MSB Register) contains the upper 8-bit value that the MCU divides into the MCU clock (PCLK) to generate the UART baud rate.

  • IER 寄存器是中断使能寄存器,到现在为止,我似乎还未解答读者的一个疑问,就是 UART 为什么需要中断。这里我稍加解释一下:在计算机运行起来时,需要处理很多驱动、系统调用等程序,若没有中断,那么 CPU 只能非常卑微地、每时每刻地“询问各个寄存器的变化情况”,以及时作出响应,若每个驱动都这么干,CPU 不得累死。有了中断,UART 就可以自己发送信号给 CPU ,通知它来处理事件。

虽然我们已经清楚了各个寄存器的地址以及它们的作用,但事实上具体如何控制我们仍旧不清楚,为此,我们必须参考更加详细的文档。


A2 A1 A0 REG. BIT 7 BIT 6 BIT 5 BIT 4 BIT 3 BIT 2 BIT 1 BIT 0
0 0 0 RHR bit 7 bit 6 bit 5 bit 4 bit 3 bit 2 bit 1 bit 0
0 0 0 THR bit 7 bit 6 bit 5 bit 4 bit 3 bit 2 bit 1 bit 0
0 0 1 IER 0 0 0 0 modem status interrupt receive line status interrupt transmit holding register interrupt receive holding register interrupt
0 1 0 FCR RCVR trigger MSB RCVR trigger LSB 0 0 DMA mode select transmit FIFO reset receiver FIFO reset FIFO enable
0 1 0 ISR 0/FIFO enabled 0/FIFO enabled 0 0 interrupt prior. bit 2 interrupt prior. bit 1 interrupt prior. bit 0 interrupt status
0 1 1 LCR divisor latch enable set break set parity even parity parity enable stop bits word length bit 1 word length bit 0
1 0 0 MCR 0 0 0 loop back OP2 OP1 RTS DTR
1 0 1 LSR 0/FIFO error transmit empty transmit holding empty break interrupt framing error parity error overrun error receive data ready
1 1 0 MSR CD RI DSR CTS delta CD delta RI delta DSR delta CTS
1 1 1 SPR bit 7 bit 6 bit 5 bit 4 bit 3 bit 2 bit 1 bit 0
为了完成接下来的工作,有几点必须**注意**:
  • RHR (Receiver Holding Register) 与 THR (Transmitter Holding Register) 事实上共用一个地址,在 UART 读模式时,地址0被解读为 RHR 寄存器,它将 RHR 中的数据读入。在写模式时,被解读为 THR 寄存器,将数据传输到目标地。

  • LCR 寄存器的 0,1 bit 位,规定了字长,相当于在说一次能传几位数据。ASCII 码都是用一个字节8位表示的,所以一次传8位肯定是最方便的。

    BIT-1 BIT-0 Word Length
    0 0 5
    0 1 6
    1 0 7
    1 1 8
  • FCR 寄存器主要控制 FIFO,那么我们到底需不需要 FIFO 呢?参考 Serial UART information ,我发现如果不使用 FIFO,那么 THR 一次只能存储一个 character,这在 5G 已经开始商用的年代,是不是有点太少了😅,而使用 FIFO 就可以将多个 character 写入或者传输,效率明显提高了,因此我们选择使用 FIFO。

  • 那么我们怎么知道数据是否被写入或传输了?LSR 寄存器貌似可以回答这个问题,LSR bit 5 为 0 时,表明 THR 已经满了,而为 1 时,THR为空。LSR bit 0 为 0 时,表示没有数据在 RHR 中,而为 1 时,表示数据已经被放入到 RHR 中,可以读取数据了。

好啦,我们所需要的内容,我已经介绍的差不多了。大家一定发现这里面还有很多很多内容尚待挖掘,但是这里空白太小,我写不下。还是那句话,要想深入研究的话,啃英文技术文档是必不可少的。

开工!UART 驱动

再次强调,本博客的驱动是使用 C 语言实现的,要想看原博主的 RISC-V 汇编语言实现,那么就点这里

首先,创建一个新文件,就叫它 ns16550a.h 吧,这里面主要放一些宏定义和函数声明,有了这些宏,整个程序就更加人性化了,编写更加方便,理解更加轻松。这些宏的值,参考上一节的内容,大家应该不难理解吧。

虽然宏定义这些年被批判的很惨,主要缘由是无法进入符号表导致 debug 困难,以及一不小心会出现一些与程序员主观不符的表达式,或者改变了某个变量的值而不自知等等。但是考虑到驱动程序小而快的要求,全部定义成全局常量显然不合适,而直接使用立即数肯定会被批判一番,所以,在有些地方,宏定义还是绕不开的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// file ns16550a.h
#ifndef _DF_RISCV_NS16550A_H
#define _DF_RISCV_NS16550A_H

#define REG_RHR 0 // read mode: Receive holding reg
#define REG_THR 0 // write mode: Transmit Holding Reg
#define REG_IER 1 // write mode: interrupt enable reg
#define REG_FCR 2 // write mode: FIFO control Reg
#define REG_ISR 2 // read mode: Interrupt Status Reg
#define REG_LCR 3 // write mode:Line Control Reg
#define REG_MCR 4 // write mode:Modem Control Reg
#define REG_LSR 5 // read mode: Line Status Reg
#define REG_MSR 6 // read mode: Modem Status Reg


#define UART_DLL 0 // LSB of divisor Latch when enabled
#define UART_DLM 1 // MSB of divisor Latch when enabled

#define UART_BASE_ADDR 0x10000000L

volatile unsigned char *Reg(int reg);
unsigned char ReadReg(int reg);
void WriteReg(int reg, char c);

void uartinit();
void uartputc(int c);
int uartgetc();

#endif // _DF_RISCV_NS16550A_H

大家一定注意到,除了宏定义,我还声明了几个函数。这些函数的实现,全部放在了 ns16550a.c 文件中。

很明显,函数的实现就是本博客的重头戏了。

首先,我们实现函数 Reg 它接收一个偏移量,返回一个 char 指针,即该寄存器的数值。从上一节的讨论中,我们已经明白,UART 的寄存器地址就是它的起始地址加上偏移量,这样一来,这个函数应该没什么大问题:

1
2
3
inline volatile unsigned char *Reg(int reg) {
return (volatile unsigned char *)(UART_BASE_ADDR+reg);
}

唯一要注意的是,因为寄存器的值非常容易变化,volatile 关键字可以保证程序每次读取寄存器值的内容都是最新的。

实现了这个函数后,ReadReg 函数和 WriteReg 函数应该不成问题:

1
2
3
4
5
6
7
inline unsigned char ReadReg(int reg) {
return (*(Reg(reg)));
}

inline void WriteReg(int reg, char c) {
(*Reg(reg)) = c;
}

接下来就是对 UART 设备的初始化,在计算机刚刚开机时,我们无法保证设备内(尤其是寄存器)的值到底是什么的,因此,在使用 UART 设备前,初始化是完全必要的。

这是我在 ns16550a.c 中编写的初始化代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void uartinit() {
// disable interrupt
WriteReg(REG_IER, 0x00);
// set baud rate
WriteReg(REG_LCR, 0x80);
WriteReg(UART_DLL, 0x03);
WriteReg(UART_DLM, 0x00);
// set word length to 8-bits
WriteReg(REG_LCR, 0x03);
// enable FIFOs
WriteReg(REG_FCR, 0x07);
// enable receiver interrupts
WriteReg(REG_IER, 0x01);
}

如果你弄懂了之前我写的注意事项,那么这边的代码对你来说就问题不大了。如你所见,WriteReg 函数可以让我们的代码变得更加简洁明了。在 uartinit 函数中,首先要将中断置为失效(我们当然不希望在 UART 初始化的时候就开始产生中断),然后,设置 UART 的传输速率,再设置传输的字长为 8 bit,并打开 FIFO 功能,最后不能忘记,一定要打开中断功能,那么,UART 的初始化就算是完成了!

好了,将 UART 初始化后,接下来就是把字符传到屏幕上的时刻了,回顾一下我们之前了解到的,UART 中寄存器 LSR 的 bit 5 是用来指示 THR 寄存器是否为空,那么我们可以写个死循环,每时每刻判断是否有字符需要传输。

1
2
3
4
5
void uartputc(int c) {
while(ReadReg(REG_LSR) & (1 << 5) == 0)
;
WriteReg(REG_THR, c);
}

我承认使用死循环来处理是一个非常愚蠢的做法:浪费了大量的 CPU 计算资源。但这却是最简单的方案。我早已经迫不及待地想看到我们的成果了!

现在,我们已经接近完工了,只要把之前博客中的 main 函数稍加改造,就可以了:

1
2
3
4
5
6
7
int main() {
char p = 'h';
uartinit();
uartputc(p);
p++;
uartputc(p);
}

先将 UART 初始化,再让 UART 输出一个 ‘h’ 和 ‘i’ 。


08-05 Update

也许我该写一下如何实现 uartgetc 函数?emmm… 只要我们认真学习了 UART 的相关知识,会发现 uartgetc 函数不难写:

1
2
3
4
5
6
7
int uartgetc() {
if (ReadReg(REG_LSR) & (0x01)) {
return ReadReg(REG_RHR);
} else {
return -1;
}
}

嗯哼,貌似只要每次调用该函数时,判断 LSR 寄存器来检查 RHR 寄存器是否为空就可以了。但是这样真的可以吗?

1
2
3
4
5
// the echo program I wrote in naive way
int main() {
int a = uartgetc();
uartputc(a);
}

若真的将 main 函数构建并运行,你会发现得到的输出永远是 -1 。你的屏幕上永远不会显示出你在键盘上摁下的字符。为什么?道理很简单,我们的小内核还不足以“感知”键盘的敲击。换句话说,我在交代完小内核所有的任务并让它开始运行后,它飞快地帮我做完了所有事情,不会等待我在键盘上敲击字符,直接认为什么东西都没有输入,以至于我根本来不及反应。因此,我们需要一个完整的中断响应机制,让内核感知到键盘发出信号,才能实现 echo

构建工程

到目前为止,我们的工程算是初具规模,如果大家都按着我的步调来的话,那么工程中的文件应当有

1
2
3
4
5
work
|-- crt0.s
|-- main.c
|-- ns16550a.c
|-- ns16550a.h

编译过程与之前博客提到的类似,只不过要改几个文件名

1
2
3
riscv64-unknown-elf-gcc -g -ffreestanding -O0 -Wl,--gc-sections \
-nostartfiles -nostdlib -nodefaultlibs -Wl,-T,riscv64-virt.ld \
crt0.s main.c ns16550a.c

-ffreestanding 告诉编译器标准库可能不存在,因此不能做任何假设。在主机环境中运行应用程序时,此选项不是必需的,但是我们没有这样做,因为重要的是告诉编译器该信息。

-Wl 是逗号分隔的标志列表,以传递给链接器 ld--gc-sections 代表“垃圾收集 section”,告诉ld 在链接后删除未使用的节。 -nostartfiles-nostdlib-nodefaultlibs 分别告诉链接器不要链接任何标准系统启动文件(例如默认 crt0),任何标准系统 stdlib 实现或任何标准系统默认可链接库。我们提供了自己的 crt0 和链接描述文件,因此传递这些标志以告知编译器,我们不希望使用这些默认设置中的任何一个。

-T 允许你将你的链接器脚本路径传给链接器,在我们这次实验中就是 riscv64-virt.ld 。最后,加上我们想要编译的文件名就可以了。

每次打这么长的命令想必令大家感到不愉快,这里还是建议大家写个 Makefile

1
2
3
4
5
6
CC=riscv64-unknown-elf-gcc
FLAG= -g -ffreestanding -O0 -Wl,--gc-sections -nostartfiles -nostdlib -nodefaultlibs
VIRTLD=-Wl,-T,kernel/riscv64-virt.ld
TARGET=build/a.out
all: work/crt0.s work/ns16550a.c work/main.c
$(CC) $(FLAG) $(VIRTLD) $^ -o $(TARGET)

这样的话,大家每次在 shell 中敲入 make 就可以了。紧接着,我们就需要用 qemu 运行一下,看看它会不会与我们打招呼:

1
2
3
4
qemu: $(TARGET)
qemu-system-riscv64 -machine virt -m 128M -nographic \
-kernel build/a.out \
-bios none

还是一样,我怕麻烦,将这些命令都放到了 Makefile 中,敲入 make qemu ,就会运行这些命令了!

运行与调试

如果大家成功地做完了上面的步骤,那么,在你的文件夹下,输入:

1
2
> make
> make qemu

应该会出现下面的情况:

看,在最后一行,hi 已经出现了,说明我们的驱动已经可以工作了(虽然粗糙拙劣至极)。

如果大家想要调试,在 GDB 中看程序一条一条执行的话,那么建议在 Makefile 中写入:

1
2
3
4
qemudebug: $(TARGET)
qemu-system-riscv64 -machine virt -m 128M -nographic -gdb tcp::1234 \
-kernel build/a.out \
-bios none -S

然后在终端输入:

1
make qemudebug

再打开另一个终端,使用 riscv64-unknown-elf-gdb,具体内容参考该博客

1
2
3
4
5
(gdb) target remote :1234                                                                             │
Remote debugging using :1234

(gdb) b main
Breakpoint 1 at 0x80000xxx: file main.c, line 2.

接下来

好了,UART 驱动程序我们大致算是完成了!

到此为止,我们的小内核干了什么?很遗憾,几乎什么也没有,它与我们说了 hi 后就死机了😅,要写出更加复杂的功能,我们还有很长的路要走,接下来,我们要细细研究一下 RISC-V 体系结构的三种模式:machine modeuser modesupervisor mode,并要合理安排一下内存布局,做一些更棒的事情。


RISC-V from Scratch 4
https://dingfen.github.io/2020/08/01/2020-8-1-riscv-from-scratch-4/
作者
Bill Ding
发布于
2020年8月1日
更新于
2024年4月9日
许可协议