串口通信协议
通信协议是一种规定通信双方在信息交换过程中所采用的语法和语义规则的约定。 它定义了通信的格式、顺序、错误检测和纠正方法,以及参与通信实体的行为。 通信协议使得不同系统或设备能够有效地交换信息,确保了彼此通信 过程的正确性、可靠性和完整性。
在日常生活中,通信协议的应用场景已经十分广泛。例如, TCP/IP 协议是互联网上最常用的通信协议,它用于定义数据在网络上的传输方式, 包括数据包的格式、传输控制等;以太网协议是在局域网中广泛应用的通信协议; 蓝牙协议用于在短距离无线通信中连接设备,例如连接手机和耳机、键盘等; 串口协议用于在设备之间通过串口进行数据传输,如计算机核心处理器与外部设备的连接。
本方向我们将带大家实现一个基础的串口通信协议。
2.1.1 简介
从广义上来说,采用串行接口进行数据通信的接口都可以称为串口,如 SPI 接口、IIC 接口等,但我们所说的串口一般是指通用异步收发器(Universal Asynchronous Receiver/Transmitter),简称 UART,主要包含 RX、TX、GND 三个接口信号,其中 GND 为共地信号,TX、RX 信号负责数据的发送和接收。在嵌入式系统开发中,串口是一种必备的通信接口,在系统开发测试阶段和实际工作阶段都起着非常重要的作用。
在 Nexys4DDR 开发板中,UART 通信与 USB 烧写功能集成在了一个 microUSB 接口中,如下图所示:
用户将 Nexys4DDR 开发板与 PC 设备相连,并连接电源之后,便可以在 PC 端的设备管理器中发现对应的串行接口。在 FPGAOL 平台上,我们已经在浏览器界面中集成了一个串口通信窗口,该窗口在浏览器端实现了串口通信协议,因此用户只需要在开发板上实现串口通信协议即可实现串口通信的功能。
补充:通信协议
通信协议是指双方实体完成通信或服务所必须遵循的规则和约定。在串口通信中,信道(也就是连接我们和开发板的网络通道)中传输的内容是特定的串行数据。而这些数据是无法被我们直接阅读的,因此就需要一些特定的操作,将我们日常使用的二进制数据转换成特定的串行数据后再进行发送,以及对接收到的串行数据解码后得到二进制数据。
除了编码和解码功能,通信协议同时还规定了双方通信的一些准则。即:如何知道此时信道中的数据是有效的数据、如何确定每一段数据的范围、双方的通信频率等。因此,通信协议是一段通信的核心,正确实现了通信协议也就保证了通信的正确性。
回到我们的串口协议上。在串口通信中,所有的数据都是通过两个 1bit 位宽的信号 TX、RX 传输的。其中 TX 信号用于传输从用户到开发板的数据,RX 信号用于传输从开发板到用户的数据。两个信号(或者叫两条信道)之间是彼此独立的,也就是说,我们可以同时向开发板发送数据并接收来自开发板的数据。
假定现在有一个 8bits 位宽的数据 8'H3A
。如果使用并行数据传输,我们将采用 8bits 位宽的通道同时传输所有的位;如果使用的是串行数据传输,我们将一位一位地传输数据的所有位。下面的波形图展示了二者的区别:
其中,波形图的横轴表示时间,因此左侧的波形比右侧的波形更早发出。我们可以直观感受到串口中「串」的含义。
注意
以上的波形图仅作为示例,但不同平台上波形的发送方式可能有所不同。例如,我们的 FPGAOL 平台在浏览器端发送数据时是从低位开始的。
为了规范串口的通信过程,我们很自然地提出了下面的问题:
- 双方的工作频率需要一致。因为串行数据发送时每一位占据的时间应当是固定的,如果双方工作的频率不一致,就会导致解码时出现错位,也就无法得到正确的信息。
- 需要有特定的标识位表明数据的范围。发送序列可以看作一串长长的 0-1 串,我们需要对该串进行正确的分割,从而获取正确的数据。这就要求我们在通信时添加一些固定格式的位用于识别。
串口协议中支持的数据收发频率(又称波特率,bps)有多种,如 9600、19200、115200、256000 等,以 9600 为例,其表示 1s 时间内可以传送 9600 位的数据。在本实验中,我们约定使用 9600 的波特率进行讲解和设计。
串口的收发信号采用相同的数据格式,我们称之为数据帧;当没有数据需要发送时,可以发送空闲帧。在默认情况下,我们认为信道中始终为高电平,即一直发送空闲帧,仅在有数据传输时才会出现低电平。数据帧和空闲帧的格式如下图所示:
每一数据帧都由「起始位 + 数据位 + 停止位」三部分组成,相邻两个数据帧之间可以插入始终为高电平信号的空闲帧。串口通信协议规定:数据帧起始位为低电平、停止位为高电平,数据位长度可选择 5~8 中的任意数字。本实验中选择「1 位起始位 + 8 位数据位 + 1 位停止位」的数据帧结构。因此,8'h3A
可以转换为如下的波形图:
★ 2.1.2 简单使用
2.1.2.1 串口回显
下面,我们通过一个简单的程序来感受串口的使用过程。在 FPGA 内,将 UART_TX(F15 引脚)输入到 FPGA 内的信号直接赋值给 UART_RX(F13 引脚),这样我们就可以实时接收刚刚发送出去的数据(在 FPGA 侧不进行解码,直接原样返回,我发送给我自己.jpg)。对应的 Verilog 代码为:
简易的收发器 | |
---|---|
1 2 3 4 5 6 |
|
与之对应的约束文件为
## UART
set_property -dict { PACKAGE_PIN F15 IOSTANDARD LVCMOS33 } [get_ports {uart_din}]
set_property -dict { PACKAGE_PIN F13 IOSTANDARD LVCMOS33 } [get_ports {uart_dout}]
题目 2-1:串口测试程序
请根据以上内容,按要求完成本项练习。
2.1.2.2 串口发送模块
接下来,我们将实现串口的发送模块。简单来说,发送模块需要将来自开发板的 8 位数据转换成符合串口协议的数据帧。相比接收模块,发送模块需要考虑的事情更少,只需要将将对应位上的信号维持一定的时间即可,而无需考虑采样等实际的细节。
开发板的时钟频率为 100MHz,因此当波特率为 9600 时,发送时每一位持续的时间约为 \(\frac{1}{9600}/\frac{1}{100\times10^6}\approx10417\) 个时钟周期。基于此,我们可以使用分频计数器在 0\(\sim\)10416 之间进行计数,保证数据帧中的每一位都能持续 10417 个时钟周期。
Send 模块的输入输出端口介绍如下:
Send.v | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
其中
dout
信号直接连接 UART 的 RX 端口,用于向用户发送来自开发板的串行数据;dout_vld
信号用于指示当前dout_data
是否有效,持续一个时钟周期;dout_data
信号用于存储即将发送的 8 位数据。
发送示例
假定某时刻 dout_data 的值为 8'h3A,那么 Send 模块就需要在一定的时钟周期内给出下图所示的结果:
图中前半部分的间隔为 1 个时钟周期,外界的其他模块将 dout_data 的值准备好后,给出一个周期的 dout_vld 信号,随后 Send 模块开始进行转换。在转换的过程中,dout_data 的改变并不影响转换的结果,也就是说 Send 模块在接收到 dout_vld 信号后会暂存此时 dout_data 的结果。
经过一定的时间后,Send 模块开始输出我们期待的数据帧。空闲帧始终为高电平信号,而数据帧的首位为低电平(起始位)。随后,Send 模块按照从低位到高位的顺序逐位发送 8'h3A 的结果,每一位持续 10417 个时钟周期,最后以一个停止位结束该数据帧的发送过程。完成发送后,dout 信号将继续保持高电平,即持续发送空白帧。
基于上面的过程,我们可以分析出,发送模块需要一个状态机和对应的分频计数器、位计数器。分频计数器在发送状态下在 0~10416 的范围内计数。每次分频计数器达到 10416 时,位计数器就自增 1。位计数器用于指示当前发送的位的编号。我们一共有 1 + 8 + 1 = 10 位数据需要发送,因此位计数器的范围为 0 ~ 9。在发送时,我们需要根据位计数器的值确定当前发送的内容是起始位、中间数据还是终止位。
模块对应的框架代码如下:
Send.v | |
---|---|
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
|
题目 2-2:串口发送模块
请根据以上内容,按要求完成本项练习。
2.1.2.3 串口接收模块
接下来,我们将实现一个简单的数据接收模块,在开发板上将 UART_TX 发来的数据进行串并转换,并将结果输出到数码管上。模块工作流程可通过以下时序图说明:
我们同样可以使用分频计数器进行计数:当接收信号为 0 时(起始位),分频计数器开始计数;当计数值达到 5208 时(起始位中间时刻),状态机从空闲状态跳转到接收状态。
接下来,计数器将在 0~10416 之间循环计数,同时启用位计数器进行位计数。从图中可以看出,当计数器值为 10416 时,对应的就是串行接收信号某一位的最佳采样时刻(处于该位的中间时刻)。此时即采样信号接收 1bit 的数据,并保存到输出数据(8bits)的对应位中。当位计数器达到 8 时,表明当前的 8bits 数据已经接收完毕,将输出使能信号置位为高电平,并将接收到的整个字节输出出去。这样就完成了一个数据帧的串并转换过程。
下面的状态机展示了上面叙述的流程:
我们将使用 Verilog 实现串口接收模块。首先,我们明确模块的输入输出端口:
Receive.v | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
其中
din
信号直接连接 UART 的 TX 端口,用于接收来自用户的串行数据;din_vld
信号用于指示当前din_data
是否有效(数据帧内的 8 位数据全部解码完成),持续一个时钟周期;din_data
信号用于存储数据帧内的 8 位数据。
模块的代码框架如下:
Receive.v | |
---|---|
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
|
简单来说,Receive 模块包含一个三段式状态机和输出信号处理两部分。你需要参考串口通信协议补全代码,进而实现模块的功能。
题目 2-3:串口接收模块
请根据以上内容,按要求完成本项练习。
2.1.3 进阶使用
2.1.3.1 接收编码
很多情况下,我们不会一次仅向串口发送一个字节的数据,而是会发送一系列字符串。然而,Receive 模块一次仅能存储并解码 8bits 数据。这种情况下应该如何处理呢?
我们可以类比 Lab5 教程中数字锁的例子。在那个例子里,每一次输入是一个 1bits 数据,我们的目标是检测序列 0100。现在假定串口输入的是英文单词,那么一次输入的就是一个英文字符(占据 8bits 空间,但可以被 Receive 模块一次性处理好),而我们的目标是检测特定的字符串。这样,我们就可以通过状态机实现进一步的解码了。
例子:串口接收解码
假设现在我们需要控制内部某模块的运行状态。当检测到串口输入为 start;
时模块开始运行,当检测到输入为 stop;
后模块停止运行。这个过程对应的状态图如下所示:
Tips:终止符
为什么要在字符串的末尾加一个 ;
呢?这是用来告诉状态机,当前的输入序列已经结束了。在 C 语言中,我们可以通过换行符标识一行输入的结束,而串口通信自然也可以这样做。但简单起见,我们使用一个特定的非英文字符作为终结符,例如这里的 ;
。
图中的绿色状态表明系统的初始状态,紫色状态表明系统的接受状态。该状态机是一个 Moore 型状态机,因此在接受状态下会向内部模块发送对应的控制信号。每一个蓝色状态都对应着一种中间状态,状态以字符命名。从前一蓝色状态跳转到后一蓝色状态的过程对应串口接收了后一状态代表的字符。例如,接收 start;
的状态跳转路径是 \(WAIT\to S\to T_1\to A\to R\to T_2\to ;_1\)。
这里需要注意的是状态跳转之间的判断条件。如果此时 din_vld
为 0,表明 Receive 模块并没有识别到串口的输入(或者正在识别),此时应当停留在某一状态不动(这是因为串口输入的过程并不一定是连续的,且相邻字符的间隔大于一个时钟周期);如果此时 din_vld
为 1,但输入的字符不是我们期望的字符,这表明截至目前输入的序列并不是 start;
或 stop;
,此时就需要令状态机跳转到 WAIT 状态。
此外,同一字符的不同状态不应合并(例如 \(T_1\) 和 \(T_2\)),因为合并后我们无法确定该字符在序列中的位置。一种思路是维护一个计数器,这样可以节省一部分状态空间,但带来了更为复杂的硬件逻辑开销。
2.1.3.2 输出编码
同样地,串口输出的过程中,我们往往也不会仅输出一个字符,而是以字符串的形式进行输出。这就需要我们使用一个特定的模块,以一个数据帧对应的时间作为间隔向 Send 模块发送 dout_data
和 dout_vld
信号。
我们可以如下编写 Verilog 代码:
UartOut.v | |
---|---|
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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
|
我们来对上面的代码进行解读。整段代码可以分为四个部分:
- 状态机。状态机按照输出的可能性划分为 WAIT、PRINT 以及对应的若干输出状态(例如 HELLO_WORLD)。WAIT 状态为复位和等待状态,PRINT 状态用于输出当前待输出的内容,HELLO_WORLD 状态用于向输出缓冲区载入待输出的字符。
- 分频计数器。串口输出单个字符(8bits)需要的时钟周期约为 \(10417\times10=104170\),因此相邻两个字符之间的输出间隔应当不低于 104170 个时钟周期。保险起见,我们令输出间隔为 120000 个时钟周期。
- 输出缓冲。输出缓冲区由 20 个 8bits 位宽的寄存器组成,初始状态下均为 0。在 HELLO_WORLD 状态下,这 20 个寄存器会被同时待输出字符的 ASCII 码;在 PRINT 状态下,寄存器以 120000 个时钟周期为间隔依次向前传递待输出的内容。
- UART 输出。串口输出当前缓冲区最前面的字符(也就是 1 号寄存器的内容),并生成对应的
dout_vld
信号。我们选择在第 100 个时钟周期生成该信号,你也可以在任意大于 1 的时钟周期生成,但需要为后续 Send 模块的发送过程预留足够的时间。
单个字符的输出时序图可以概括如下:
STATE: WAIT--------HELLO_WORLD-------------PRINT-------------------------------------------------------------------
| | | | | |
div_cnt: 0 1 100 101 110000 120000
| | | | | |
Action: reg <= 0 reg <= "hello world" dout_vld = 1 dout_vld = 0 reg[i] <= reg[i+1] div_cnt <= 0
dout_data = reg[1]
提醒
如果你想输出某变量的值,需要将先其从 2 进制转换成对应的 ASCII 码。