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
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

UART 是 “Universal Asynchronous Receiver-Transmitter” 的缩写,它是用于传输、接收系列数据的硬件设备。串行数据传输是逐位顺序发送数据的过程。 相反,并行数据传输是一次发送多个位的过程。 关于串行并行通信,此图很好地说明了差异:

{:.align-center}

UART 从不指定数据接收或发送的速率(也称为时钟速率或时钟信号),这是它们异步而不是同步的原因。正因为异步的要求,UART 使用开始和停止位来将数据截断为帧,开始位和停止位会告诉 UART 何时开始和停止读取数据。

你可能听说过 USARTs (Universal Synchronous/Asynchronous Receiver-Transmitter) ,该设备既可以同步也可以异步工作,当同步工作时,USART 会放弃使用开始位和停止位,而是在单独的线路上发送时钟信号,实现发送与接受的同步。

事实上,UART和USART随处可见。 它们内置于几乎所有现代微控制器(包括我们的虚拟机)中。 这些设备工作在交通信号灯、冰箱以及绕地球轨道运行了多年的卫星上。

硬件布局回顾

在我们正式开始写驱动前,我们需要一些额外的信息来解决一些问题。我们如何配置虚拟机的 UART ? 我们可以在哪个内存地址找到接收和发送缓冲区?

接下来,我们使用 dtc 工具,回顾一下 uart 的 devicetree 节点的一些信息。

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
# Install 'dtc' if you don't already have it.
# I use 'brew' for MacOS - you may need to do something else.
brew install dtc
# Use qemu to dump info about the 'virt' machine in dtb (device tree blob)
# format.
# The data in this file represents hardware components of a given
# machine / device / board.
qemu-system-riscv64 -machine virt -machine dumpdtb=riscv64-virt.dtb
# Convert our .dtb into a human-readable .dts (device tree source) file.
dtc -I dtb -O dts -o riscv64-virt.dts riscv64-virt.dtb
# Search for 'uart' and display 2 lines before and 6 lines after each match.
grep uart riscv64-virt.dts -B 2 -A 6
chosen {
bootargs = [00];
stdout-path = "/uart@10000000";
};
--
};

uart@10000000 {
interrupts = <0x0a>;
interrupt-parent = <0x02>;
clock-frequency = <0x384000>;
reg = <0x00 0x10000000 0x00 0x100>;
compatible = "ns16550a";
};

grep 输出的最上面,chosen 节点出现了,该节点内容表明,输出信息会通过 UART 设备打印出来。根据此篇文档chosen 节点不代表任何物理硬件设备,通常用于在固件和运行在裸机上的程序(比如操作系统)之间的数据交换,我们接下来的操作不需要用到该节点,不必理会。

接下来才是我们想要的东西—— uart 节点。根据前面的知识,我们很容易就发现 UART 的内存地址位于 0x10000000 ,还有 interruptsinterrupt-parent 属性,表示 UART 是会产生中断的。

可能有读者不太熟悉计算机系统,因此我这里简单介绍一下中断 interrupt,中断是硬件或软件向处理器发出的信号,指示事件需要立即处理执行。例如,在以下情况下,UART 可能会产生中断:

  • 新的数据进入了接收缓存
  • 数据传送机 (transmitter) 完成了缓存中数据的发送
  • UART 遇到了发送错误的情况

这些中断行为充当 hook ,程序员可编写代码适当地响应这些事件,不过接下来的内容我们不会用到中断,因此先忽略到这些内容吧。

再来看一下 clock-frequency = <0x38400> ,参考 devicetree specificationclock-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
2
3
4
5
6
7
8
9
10
11
12
.global uart_put_char
.global uart_get_char

uart_get_char:
.cfi_startproc
.cfi_endproc

uart_put_char:
.cfi_startproc
.cfi_endproc

.end

我们从 .global 汇编指令开始,将 uart_put_charuart_get_char 声明为其他文件可访问的符号。以 . 开头的指令都是伪指令,它们只向汇编器提供信息,不是可执行代码。所有基本 GNU 汇编器指令的详细说明都可以在这里找到。

接下来,将会有每个符号的定义,当前仅包含 .cfi 汇编程序指令。这些 .cfi 指令将框架的结构及其展开方法通知工具(例如汇编器或异常展开器)。.cfi_startproc.cfi_endproc 分别表示函数的开始和结束。

尽管我们还没有完全开始写驱动(你肯定能察觉到我们只是搭建了个框架),我们先把他编译一下,看看这个框架是否可用。

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

如果你很想知道这些编译选项是什么意思,建议参考这里

然后,我们得到了一个错误:

1
2
3
/Users/twilco/usys/riscv/riscv64-unknown-elf-gcc-8.2.0-2019.02.0-x86_64-apple-darwin/bin/../lib/gcc/riscv64-unknown-elf/8.2.0/../../../../riscv64-unknown-elf/bin/ld: /var/folders/rg/hbr8vy7d13z9k7pdn0l_n9z51y1g13/T//ccjYQiJc.o: in function `.L0 ':
/Users/twilco/projects/riscv-from-scratch/work/crt0.s:12: undefined reference to `main'
collect2: error: ld returned 1 exit status

不过,放轻松,只是缺少 main 函数而已。这是因为在 crt0.s 文件中,我们曾经用到过 main 函数的地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.section .init, "ax"
.global _start
_start:
.cfi_startproc
.cfi_undefined ra
.option push
.option norelax
la gp, __global_pointer$
.option pop
la sp, __stack_top
add s0, sp, zero
jal zero, main # <~~~~~~~~~~
.cfi_endproc
.end

那么,为了简单起见,先创建个文件 main.c ,然后把 main 函数的定义写出来:

1
2
3
int main() {
uart_put_char();
}

最后,将这几个文件一起编译,就不会报错了:

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

除此之外,我们可以使用 nm 工具,查看一下 a.out 文件里面符号定义的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
riscv64-unknown-elf-nm a.out

00000000800010a0 R __BSS_END__
000000008000109c R __DATA_BEGIN__
000000008000109c R __SDATA_BEGIN__
000000008000109c R __bss_start
000000008000189c A __global_pointer$
0000000088000000 T __stack_top
000000008000109c R _edata
00000000800010a0 R _end
0000000080000000 T _start
0000000080000018 T main
0000000080000018 T uart_get_char
0000000080000018 T uart_put_char

设置基础地址

从这篇资料得知,NS16550A UART 有十二个寄存器,访问每个寄存器只需要在基址的基础上加上若干字节的偏移量即可。为了能方便地访问这些寄存器,我们首先需要定义一个代表该基址的符号。 正如我们从 riscv64-virt.dts 中发现的那样,基址位于 0x00 + 0x10000000 = 0x10000000,这就是 reg 属性中的内容:

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

riscv64-virt.ld 文件中,加入这个符号:

1
2
3
4
5
6
7
8
9
10
11
12
...more above...
SECTIONS
{
/* Read-only sections, merged into text segment: */
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x10000));
. = SEGMENT_START("text-segment", 0x10000) + SIZEOF_HEADERS;
PROVIDE(__stack_top = ORIGIN(RAM) + LENGTH(RAM));
/* >>>>>> Our newest addition. <<<<<< */
PROVIDE(__uart_base_addr = 0x10000000);
/* >>>>>> End of our addition. <<<<<< */
.interp : { *(.interp) }
...more below...

__uart_base_addr 定义完成后,我们就可以很轻松地访问 NS16550A 的寄存器了!

接下来

今天,我们了解了 UART 和 USART 、NS16550A 规范,中断以及一些其他 devicetree 属性。 我们还为UART 组装驱动程序创建了基础框架,并已将 __uart_base_addr 编码为链接器文件中的符号,以方便对 UART 寄存器访问。

在下一篇文章中,我们将讨论和实现两个驱动程序函数 uart_get_charuart_put_char 。 函数是在汇编世界中使函数调用成为可能的重要部分。 我们将逐步介绍函数的序幕,并提供详细说明堆栈更改和每条指令寄存器的图表。


我的尝试

OK!原博文翻译到此结束!现在介绍一下我的实验方案:

事实上,在跟着写完 crt0.s 文件,并将他们编译、链接,运行在虚拟机上时,我的思想就与原博主最初的想法不太一样了,原博主只是想要探究一下 RISC-V 的底层技术,但我想要做的却是一个 RISC-V 内核。

原博主的实验步骤中,创建 crt0.s 以及它的前因后果解释非常详细,让我受益良多。但同时我也马上明白,这些步骤只要再稍加调整,就完全可以当作操作系统的启动工作了!那么接下来,我将会继续我自己的实验,敬请期待。


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