RISC-V from Scratch 3
RISC-V from scratch 3: 写 UART 驱动
今天为大家继续翻译 RISC-V from scratch 系列博客,接着上一部分内容,我们本此的目标是实现 UART 协议的驱动程序,继续完善 RISC-V 的内核。本文译自 RISC-V from scratch 3: Writing a UART driver in nasm (1 / 3) 。
由于我发现该系列的原作者貌似没有把这一系列完成就咕咕了,因此从本文开始,我将加上一些自己实践的内容,以及一些自己的想法,同大家探讨,算是狗尾续貂,弥补遗憾。
简介
欢迎再次来到 RISC-V from scratch ,先快速回顾一下我们之前做过的内容,我们之前已经探索了很多与 RISC-V 及其生态相关的底层概念(例如编译、链接、原语运行时、汇编等)。具体来说,在上一篇文章中,我们使用 dtc
工具检查了 virt
QEMU
虚拟机中的硬件布局,确定了 RAM
在该计算机中的存放地址,如果你观察仔细的话,会发现 virt
还有很多有趣的地方,其中一个是 UART
。
为了进一步学习 RISC-V 汇编的知识,我们将在接下来的三篇文章中为该 UART 编写驱动程序,深入探索 ABI,函数以及其中的底层堆栈操作等重要概念。
译注:由于原作者说的三篇文章中的最后一篇还未完成,而译者认为使用 RISC-V 汇编写 UART 驱动程序是吃力不讨好的行为,因此,译者使用 C 语言完成了驱动的编写,以后的内容也会介绍。
搭建环境
如果你还未看本系列博客的第一部分,没有安装 riscv-qemu
和 RISC-V 工具链,那么赶紧点击上面标题的链接,跳转到 “QEMU and RISC-V toolchain setup” 。
之后,再将博主创建的 github 库下载下来,作为我们的工作点。
1 |
|
译注:亲测无需下载 github 库也可实现下面的实验。
什么是 UART
UART 是 “Universal Asynchronous Receiver-Transmitter” 的缩写,它是用于传输、接收系列数据的硬件设备。串行数据传输是逐位顺序发送数据的过程。 相反,并行数据传输是一次发送多个位的过程。 关于串行并行通信,此图很好地说明了差异:
{:.align-center}
UART 从不指定数据接收或发送的速率(也称为时钟速率或时钟信号),这是它们异步而不是同步的原因。正因为异步的要求,UART 使用开始和停止位来将数据截断为帧,开始位和停止位会告诉 UART 何时开始和停止读取数据。
你可能听说过 USARTs (Universal Synchronous/Asynchronous Receiver-Transmitter) ,该设备既可以同步也可以异步工作,当同步工作时,USART 会放弃使用开始位和停止位,而是在单独的线路上发送时钟信号,实现发送与接受的同步。
事实上,UART和USART随处可见。 它们内置于几乎所有现代微控制器(包括我们的虚拟机)中。 这些设备工作在交通信号灯、冰箱以及绕地球轨道运行了多年的卫星上。
硬件布局回顾
在我们正式开始写驱动前,我们需要一些额外的信息来解决一些问题。我们如何配置虚拟机的 UART ? 我们可以在哪个内存地址找到接收和发送缓冲区?
接下来,我们使用 dtc
工具,回顾一下 uart
的 devicetree 节点的一些信息。
1 |
|
在 grep
输出的最上面,chosen
节点出现了,该节点内容表明,输出信息会通过 UART 设备打印出来。根据此篇文档,chosen
节点不代表任何物理硬件设备,通常用于在固件和运行在裸机上的程序(比如操作系统)之间的数据交换,我们接下来的操作不需要用到该节点,不必理会。
接下来才是我们想要的东西—— uart
节点。根据前面的知识,我们很容易就发现 UART 的内存地址位于 0x10000000
,还有 interrupts
和 interrupt-parent
属性,表示 UART 是会产生中断的。
可能有读者不太熟悉计算机系统,因此我这里简单介绍一下中断 interrupt
,中断是硬件或软件向处理器发出的信号,指示事件需要立即处理执行。例如,在以下情况下,UART 可能会产生中断:
- 新的数据进入了接收缓存
- 数据传送机 (transmitter) 完成了缓存中数据的发送
- UART 遇到了发送错误的情况
这些中断行为充当 hook ,程序员可编写代码适当地响应这些事件,不过接下来的内容我们不会用到中断,因此先忽略到这些内容吧。
再来看一下 clock-frequency = <0x38400>
,参考 devicetree specification ,clock-frequency
代表了时钟的初始频率,其值为十六进制的 0x38400
Hz ,即3.6864 MHz,每秒36.864百万个时钟滴答,这是标准的晶体振荡器频率。
下一个属性就很熟悉了 reg = <0x00 0x10000000 0x00 0x100>
,决定了 UART 的内存位置,以及它的长度,在上一篇文章中,我们知道有两个 32-bit 的值在描述信息。通过给的信息来看,不难得出 UART 的内存位置起始于 0x00 + 0x10000000 = 0x10000000
,且长度为 0x00 + 0x100 = 0x100
字节。
uart
节点的最后一个属性,compatible =“ ns16550a” ;
,它告知我们 UART 与哪种编程模型兼容。 操作系统使用此属性来确定其可用于外围设备的设备驱动程序。网上有很多的实现与 NS16550A 兼容的 UART 所需的资料,这篇是本文所引用的。
驱动程序的基本框架
现在,我们创建新文件,取名 ns16550a.s
,在这里我们开始构建驱动程序的基本框架,首先,我们仅仅先实现一个读写字符的函数,不管那些复杂的中断。
1 |
|
我们从 .global
汇编指令开始,将 uart_put_char
和 uart_get_char
声明为其他文件可访问的符号。以 .
开头的指令都是伪指令,它们只向汇编器提供信息,不是可执行代码。所有基本 GNU 汇编器指令的详细说明都可以在这里找到。
接下来,将会有每个符号的定义,当前仅包含 .cfi
汇编程序指令。这些 .cfi
指令将框架的结构及其展开方法通知工具(例如汇编器或异常展开器)。.cfi_startproc
和 .cfi_endproc
分别表示函数的开始和结束。
尽管我们还没有完全开始写驱动(你肯定能察觉到我们只是搭建了个框架),我们先把他编译一下,看看这个框架是否可用。
1 |
|
如果你很想知道这些编译选项是什么意思,建议参考这里。
然后,我们得到了一个错误:
1 |
|
不过,放轻松,只是缺少 main
函数而已。这是因为在 crt0.s
文件中,我们曾经用到过 main
函数的地址:
1 |
|
那么,为了简单起见,先创建个文件 main.c
,然后把 main
函数的定义写出来:
1 |
|
最后,将这几个文件一起编译,就不会报错了:
1 |
|
除此之外,我们可以使用 nm
工具,查看一下 a.out
文件里面符号定义的情况:
1 |
|
设置基础地址
从这篇资料得知,NS16550A UART 有十二个寄存器,访问每个寄存器只需要在基址的基础上加上若干字节的偏移量即可。为了能方便地访问这些寄存器,我们首先需要定义一个代表该基址的符号。 正如我们从 riscv64-virt.dts
中发现的那样,基址位于 0x00 + 0x10000000 = 0x10000000
,这就是 reg
属性中的内容:
1 |
|
在 riscv64-virt.ld
文件中,加入这个符号:
1 |
|
当 __uart_base_addr
定义完成后,我们就可以很轻松地访问 NS16550A 的寄存器了!
接下来
今天,我们了解了 UART 和 USART 、NS16550A 规范,中断以及一些其他 devicetree 属性。 我们还为UART 组装驱动程序创建了基础框架,并已将 __uart_base_addr
编码为链接器文件中的符号,以方便对 UART 寄存器访问。
在下一篇文章中,我们将讨论和实现两个驱动程序函数 uart_get_char
和 uart_put_char
。 函数是在汇编世界中使函数调用成为可能的重要部分。 我们将逐步介绍函数的序幕,并提供详细说明堆栈更改和每条指令寄存器的图表。
我的尝试
OK!原博文翻译到此结束!现在介绍一下我的实验方案:
事实上,在跟着写完 crt0.s
文件,并将他们编译、链接,运行在虚拟机上时,我的思想就与原博主最初的想法不太一样了,原博主只是想要探究一下 RISC-V 的底层技术,但我想要做的却是一个 RISC-V 内核。
原博主的实验步骤中,创建 crt0.s
以及它的前因后果解释非常详细,让我受益良多。但同时我也马上明白,这些步骤只要再稍加调整,就完全可以当作操作系统的启动工作了!那么接下来,我将会继续我自己的实验,敬请期待。