跳转至

Wishbone 串口控制器

既然我们造的计算机要用串口来与用户进行交互,那就需要相应的硬件逻辑来实现串口协议。本次实验中,这部分硬件逻辑已经提供给了同学们,也就是接下来要介绍的 Wishbone 串口控制器。

简介

本文是给同学们提供的 Wishbone 串口控制器的文档。对于原理部分,只需要大概理解即可,后面的接口部分与本实验密切相关,请仔细阅读

串口控制器原理

前文已经提到,串口要做的事情就是发送和接收一个个字节。很自然地,对于软件来说,希望直接向串口控制器写入一个个字节,然后串口控制器负责按照 UART 协议的标准,把字节发送出去。同理,当串口控制器接收到数据的时候,要从信号中恢复出要传输的字节,然后软件再从串口控制器读取出一个个字节。

发送逻辑

因此,串口控制器的发送逻辑,首先从 CPU 接收一个字节的数据,然后按照 UART 协议的要求发送出去。回忆一下 UART 协议的发送方式:

  1. 不发送的时候保持 1
  2. 发送前,输出 0
  3. 从低位到高位,逐位输出
  4. 8 个位输出完成后,输出 1

那么,用状态机实现就是:

  1. IDLE:等待 CPU 的发送请求,如果 CPU 写入一个字节的数据,转到 SEND_BEGIN 状态
  2. SEND_BEGIN:输出 0 到串口的 tx 信号,然后转到 SEND_DATA 状态
  3. SEND_DATA:输出当前数据的其中一位到串口的 tx 信号,一共输出 8 个时间间隔的时间,完成后转到 SEND_DONE 状态
  4. SEND_DONE:输出 1 到串口的 tx 信号,转到 IDLE 状态

接收逻辑

接收的时候,考虑到发送第一个字节前,一定会出现一个 1 到 0 的变化,因此我们可以这么实现接收逻辑的状态机:

  1. IDLE:持续采样串口的 rx 信号,如果发现信号从 1 变成了 0,进入 RECV_DATA 状态
  2. RECV_DATA:继续采样 rx 信号,并按照顺序保存到字节的相应位置,直到经过了 8 个时间间隔,保存了一个字节的数据
  3. RECV_DONE:这时候 rx 信号应该恢复到了 1,把数据保存下来,用于 CPU 读取,进入 IDLE 状态

实现细节

很抱歉,如果按照上面的方式实现,大概率会遇到很多问题,例如:

  1. 时钟频率和波特率不一样,例如时钟周期是 50MHz,波特率只有 115200,中间差距很大;解决方法是分频,也就是数拍子,发送的时候,要等待许多个周期,直到经过了一个串口的时间间隔
  2. 接收数据的时候,由于波特率相比时钟频率很低,所以每次接收一个 bit 都会经历很多个周期,所以接收的时候也要等待,选择合适的时间把数据存下来
  3. 相比 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文章里看到。我们取其中发送和接收最基本的两个寄存器的地址,就得到了 0x100000000x10000005 这两个地址。


最后更新: 2024年9月8日
作者:Jiajie Chen