Wishbone 串口控制器
既然我们造的计算机要用串口来与用户进行交互,那就需要相应的硬件逻辑来实现串口协议。本次实验中,这部分硬件逻辑已经提供给了同学们,也就是接下来要介绍的 Wishbone 串口控制器。
简介
本文是给同学们提供的 Wishbone 串口控制器的文档。对于原理部分,只需要大概理解即可,后面的接口部分与本实验密切相关,请仔细阅读。
串口控制器原理
前文已经提到,串口要做的事情就是发送和接收一个个字节。很自然地,对于软件来说,希望直接向串口控制器写入一个个字节,然后串口控制器负责按照 UART 协议的标准,把字节发送出去。同理,当串口控制器接收到数据的时候,要从信号中恢复出要传输的字节,然后软件再从串口控制器读取出一个个字节。
发送逻辑
因此,串口控制器的发送逻辑,首先从 CPU 接收一个字节的数据,然后按照 UART 协议的要求发送出去。回忆一下 UART 协议的发送方式:
- 不发送的时候保持 1
- 发送前,输出 0
- 从低位到高位,逐位输出
- 8 个位输出完成后,输出 1
那么,用状态机实现就是:
- IDLE:等待 CPU 的发送请求,如果 CPU 写入一个字节的数据,转到 SEND_BEGIN 状态
- SEND_BEGIN:输出 0 到串口的 tx 信号,然后转到 SEND_DATA 状态
- SEND_DATA:输出当前数据的其中一位到串口的 tx 信号,一共输出 8 个时间间隔的时间,完成后转到 SEND_DONE 状态
- SEND_DONE:输出 1 到串口的 tx 信号,转到 IDLE 状态
接收逻辑
接收的时候,考虑到发送第一个字节前,一定会出现一个 1 到 0 的变化,因此我们可以这么实现接收逻辑的状态机:
- IDLE:持续采样串口的 rx 信号,如果发现信号从 1 变成了 0,进入 RECV_DATA 状态
- RECV_DATA:继续采样 rx 信号,并按照顺序保存到字节的相应位置,直到经过了 8 个时间间隔,保存了一个字节的数据
- RECV_DONE:这时候 rx 信号应该恢复到了 1,把数据保存下来,用于 CPU 读取,进入 IDLE 状态
实现细节
很抱歉,如果按照上面的方式实现,大概率会遇到很多问题,例如:
- 时钟频率和波特率不一样,例如时钟周期是 50MHz,波特率只有 115200,中间差距很大;解决方法是分频,也就是数拍子,发送的时候,要等待许多个周期,直到经过了一个串口的时间间隔
- 接收数据的时候,由于波特率相比时钟频率很低,所以每次接收一个 bit 都会经历很多个周期,所以接收的时候也要等待,选择合适的时间把数据存下来
- 相比 CPU,串口控制器的处理是十分慢的,所以需要告诉 CPU 什么时候可以发送下一个字节、什么时候可以读取一个字节。发送的时候,只要状态机转移就算完成,不需要等到一次完整的发送完成,那样会浪费很多 CPU 时间。
基于上面这些难点,我们给同学们提供了 Wishbone 串口控制器,同学们只需要对 Wishbone 总线接口进行操作,即可实现串口通信。因此接下来我们来介绍,串口控制器是如何通过 Wishbone 总线来实现数据的收发的。
串口控制器总线接口
回顾一下,我们在上一个实验中,实现了一个 Wishbone SRAM 控制器,也就是把 CPU 访问内存地址的操作,变成实际对 SRAM 芯片的操作。于是很自然地,SRAM 上的数据就被映射到了内存里。
但是串口控制器呢?串口控制器并不保存一段数据,看起来好像和总线没什么关系。但是如果要为了实现串口通信,设计和实现一些新的指令,又有些不划算。怎么办呢?能不能让串口控制器假装自己保存了什么数据,然后我对地址的访问,实际上就是在操作串口寄存器呢?
这就引出了一个很重要的设计思想,即 MMIO(Memory Mapped IO),也就是把 IO 操作映射到内存地址上。对于 CPU 来说,访问内存和访问串口寄存器,都是同样的方法,经过同样的 Wishbone 总线接口;对于软件来说,都是对于指针指向的地址的读和写。那么,只要约定好,哪个地址代表什么意思,我们就可以用一个 Wishbone 总线接口解决所有的内存和外设控制器的访问问题。
那么,怎么“伪装”出保存了什么数据呢?首先我们来考虑发送,既然是发送,肯定是要软件往串口控制器写入数据,然后串口控制器再通过串口发送出去。那我们就定义,写入地址 0x10000000
就是发送一个字节的数据。那么这时候,伪装出来的“内存”就是:
- 写入
0x10000000
:向串口发送一个字节的数据 - 写入其他地址:无效
- 读取所有地址:无效
前面我们提到过,串口相比 CPU 来说是很慢的,如果 CPU 很快速地往 0x10000000
写入数据,串口控制器还没有传输完前一个字节的时候,后一个就来了,但是又没有地方存,只能含泪丢弃。为了解决这个问题,我们需要添加一个定义,读取 0x10000001
,如果发现它不为零,就说明可以发送数据:
- 写入
0x10000000
:向串口发送一个字节的数据 - 写入其他地址:无效
- 读取
0x10000001
:不为零,表示现在串口控制器可以发送数据 - 读取其他地址:无效
根据上面的定义,我们就可以写如下的汇编代码来输出一个字节到串口:
// 假设要发送的数据在 a0
LOOP:
// 读取 0x10000001
li a1, 0x10000001
lb a2, 0(a1)
beqz a2, LOOP
// 可以发送数据了
li a1, 0x10000000
// 发送
sb a0, 0(a1)
看到这里,你可能会有点熟悉,因为这就是几乎就是实验 1 中,打印内容到串口的汇编代码。
接下来,考虑一下读取:CPU 要读取数据的时候,串口控制器可能已经读取了一个字节的数据,也可能还没有。所以需要让 CPU 读取一个状态,判断是否有数据可以读取。一个简单的方法,就是再定义一个 0x10000002
,如果发现它不为零,就说明可以读取数据:
- 写入
0x10000000
:向串口发送一个字节的数据 - 写入其他地址:无效
- 读取
0x10000001
:不为零,表示现在串口控制器可以发送数据 - 读取
0x10000002
:不为零,表示现在串口控制器已经接受到了数据,CPU 可以读取 - 读取其他地址:无效
最后再定义一个 0x10000003
:如果 CPU 读取这个地址,就会读取出串口控制器当前收到的数据:
- 写入
0x10000000
:向串口发送一个字节的数据 - 写入其他地址:无效
- 读取
0x10000001
:不为零,表示现在串口控制器可以发送数据 - 读取
0x10000002
:不为零,表示现在串口控制器已经接受到了数据,CPU 可以读取 - 读取
0x10000003
:读取串口寄存器当前接受到的字节 - 读取其他地址:无效
在早年的串口控制器设计里,由于资源的限制,设计的时候尽量节省地址空间的使用,所以最终的定义是:
地址 | 位 | 说明 |
---|---|---|
0x10000000 | [7:0] | 串口数据,读、写地址分别表示串口接收、发送一个字节 |
0x10000005 | [5] | 只读,为 1 时表示串口空闲,可发送数据 |
0x10000005 | [0] | 只读,为 1 时表示串口收到数据 |
你可能会觉得很眼熟,没错,这就是实验 1 中同学们在监控程序文档中看到的串口控制器定义。这也是我们提供给大家的 Wishbone 串口控制器所实现的定义。
到这里,同学们应该可以理解在实验 1 中,为什么要那样去读写内存地址来实现串口的读和写,并且在这个读写的背后,又是如何实现出来的。
为什么地址直接从 0x10000000 跳到了 0x10000005,中间的地址呢?
这是因为我们采取了一个广泛使用的串口控制器芯片 UART 16550 的寄存器定义的子集。它的详细定义可以在Serial UART, an introduction文章里看到。我们取其中发送和接收最基本的两个寄存器的地址,就得到了 0x10000000
和 0x10000005
这两个地址。