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 |
|
译注:亲测无需下载 github 库也可实现下面的实验。
UART 回顾
本博客的目的是在 virt
机器上,使用 UART 在屏幕打印出字符。为了使这一工作顺利,我们先来回顾一下 UART 在 virt
机器上的布局。
1 |
|
参考之前的博客,我们已经了解到 UART 是用于传输、接收系列数据的硬件设备,根据上面所列的数据,UART 的时钟频率是3.6864 MHz,UART 在内存的位置起始于 0x10000000
,长度为 0x100
字节,我们在链接器脚本中定义了全局符号 __uart_base_add
。UART 与 NS16550a 编程模型兼容。
UART 细节
完整的了解该硬件是写驱动程序的前提。我细读了 Serial UART information 和 TECHNICAL DATA ON 16550,算是大致清楚了 UART 结构的数据存储传递情况 : )
。
考虑到文章的篇幅,以及读者的耐心等原因,我在这里简单的扯两句,想要完整的了解 UART ,啃英文技术文档是必不可少的。
在 UART 中,可访问的 I/O 端口一共有8个,他们的功能作用列于下表,base address
指的就是 UART 的起始内存位置,而 DLAB
位于 LCR
寄存器,是一个用于转换功能的转换位。
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 |
|
大家一定注意到,除了宏定义,我还声明了几个函数。这些函数的实现,全部放在了 ns16550a.c
文件中。
很明显,函数的实现就是本博客的重头戏了。
首先,我们实现函数 Reg
它接收一个偏移量,返回一个 char
指针,即该寄存器的数值。从上一节的讨论中,我们已经明白,UART 的寄存器地址就是它的起始地址加上偏移量,这样一来,这个函数应该没什么大问题:
1 |
|
唯一要注意的是,因为寄存器的值非常容易变化,volatile
关键字可以保证程序每次读取寄存器值的内容都是最新的。
实现了这个函数后,ReadReg
函数和 WriteReg
函数应该不成问题:
1 |
|
接下来就是对 UART 设备的初始化,在计算机刚刚开机时,我们无法保证设备内(尤其是寄存器)的值到底是什么的,因此,在使用 UART 设备前,初始化是完全必要的。
这是我在 ns16550a.c
中编写的初始化代码:
1 |
|
如果你弄懂了之前我写的注意事项,那么这边的代码对你来说就问题不大了。如你所见,WriteReg
函数可以让我们的代码变得更加简洁明了。在 uartinit
函数中,首先要将中断置为失效(我们当然不希望在 UART 初始化的时候就开始产生中断),然后,设置 UART 的传输速率,再设置传输的字长为 8 bit,并打开 FIFO 功能,最后不能忘记,一定要打开中断功能,那么,UART 的初始化就算是完成了!
好了,将 UART 初始化后,接下来就是把字符传到屏幕上的时刻了,回顾一下我们之前了解到的,UART 中寄存器 LSR 的 bit 5 是用来指示 THR 寄存器是否为空,那么我们可以写个死循环,每时每刻判断是否有字符需要传输。
1 |
|
我承认使用死循环来处理是一个非常愚蠢的做法:浪费了大量的 CPU 计算资源。但这却是最简单的方案。我早已经迫不及待地想看到我们的成果了!
现在,我们已经接近完工了,只要把之前博客中的 main
函数稍加改造,就可以了:
1 |
|
先将 UART 初始化,再让 UART 输出一个 ‘h’ 和 ‘i’ 。
08-05 Update
也许我该写一下如何实现 uartgetc
函数?emmm… 只要我们认真学习了 UART 的相关知识,会发现 uartgetc
函数不难写:
1 |
|
嗯哼,貌似只要每次调用该函数时,判断 LSR 寄存器来检查 RHR 寄存器是否为空就可以了。但是这样真的可以吗?
1 |
|
若真的将 main
函数构建并运行,你会发现得到的输出永远是 -1 。你的屏幕上永远不会显示出你在键盘上摁下的字符。为什么?道理很简单,我们的小内核还不足以“感知”键盘的敲击。换句话说,我在交代完小内核所有的任务并让它开始运行后,它飞快地帮我做完了所有事情,不会等待我在键盘上敲击字符,直接认为什么东西都没有输入,以至于我根本来不及反应。因此,我们需要一个完整的中断响应机制,让内核感知到键盘发出信号,才能实现 echo
。
构建工程
到目前为止,我们的工程算是初具规模,如果大家都按着我的步调来的话,那么工程中的文件应当有
1 |
|
编译过程与之前博客提到的类似,只不过要改几个文件名
1 |
|
-ffreestanding
告诉编译器标准库可能不存在,因此不能做任何假设。在主机环境中运行应用程序时,此选项不是必需的,但是我们没有这样做,因为重要的是告诉编译器该信息。
-Wl
是逗号分隔的标志列表,以传递给链接器 ld
。 --gc-sections
代表“垃圾收集 section”,告诉ld
在链接后删除未使用的节。 -nostartfiles
,-nostdlib
和 -nodefaultlibs
分别告诉链接器不要链接任何标准系统启动文件(例如默认 crt0
),任何标准系统 stdlib 实现或任何标准系统默认可链接库。我们提供了自己的 crt0
和链接描述文件,因此传递这些标志以告知编译器,我们不希望使用这些默认设置中的任何一个。
-T
允许你将你的链接器脚本路径传给链接器,在我们这次实验中就是 riscv64-virt.ld
。最后,加上我们想要编译的文件名就可以了。
每次打这么长的命令想必令大家感到不愉快,这里还是建议大家写个 Makefile
:
1 |
|
这样的话,大家每次在 shell 中敲入 make
就可以了。紧接着,我们就需要用 qemu
运行一下,看看它会不会与我们打招呼:
1 |
|
还是一样,我怕麻烦,将这些命令都放到了 Makefile
中,敲入 make qemu
,就会运行这些命令了!
运行与调试
如果大家成功地做完了上面的步骤,那么,在你的文件夹下,输入:
1 |
|
应该会出现下面的情况:
看,在最后一行,hi 已经出现了,说明我们的驱动已经可以工作了(虽然粗糙拙劣至极)。
如果大家想要调试,在 GDB 中看程序一条一条执行的话,那么建议在 Makefile
中写入:
1 |
|
然后在终端输入:
1 |
|
再打开另一个终端,使用 riscv64-unknown-elf-gdb
,具体内容参考该博客
1 |
|
接下来
好了,UART 驱动程序我们大致算是完成了!
到此为止,我们的小内核干了什么?很遗憾,几乎什么也没有,它与我们说了 hi 后就死机了😅,要写出更加复杂的功能,我们还有很长的路要走,接下来,我们要细细研究一下 RISC-V 体系结构的三种模式:machine mode
、user mode
、supervisor mode
,并要合理安排一下内存布局,做一些更棒的事情。