一、UART简介
异步通用串行收发器,以下简称串口,应该是大部分人学习FPGA接触到的第一种接口协议,协议简单,使用场合多,方便操作,我第一次完全自己编写的模块也是UART,属于是梦开始的地方了。
协议内容很简单,空闲为高电平,第一个低电平为起始位,之后是一连串的数据位,然后是奇偶校验位,最后是一到两个高电平的结束位,速率由收发两端波特率决定,物理连接两根线。
由于UART协议的标准网上一搜一大堆,我就不多废话,直接进入代码部分了。
二、UART模块verilog实现思路
verilog的编写思路相对比较固定,一般来说,计数器和状态机就可以解决绝大部分问题,剩下的就是根据条件对各信号赋值。网上能看到很多用状态机写串口的,讲道理,多少有点小题大做了,两个计数器就可以完美解决串口模块。
唯一要解决的问题就是波特率,波特率其实就是串口收发的时钟频率,例如115200的波特率其实就是意思用115.2kHz的时钟就可以实现收发,但是一般FPGA没有那么低频的时钟,所以用高频时钟分频产生一个低频时钟去实现串口收发,分频就简单了,用输入时钟计数,计数达到需求的值时清零,产生一个类似时钟跳变的信号,然后在每次跳变沿进行相应的操作就可以了。
然后现在的问题就到了波特率计数值的计算,在网上总能看到问不同时钟频率的波特率计数怎么算的,其实最简单的理解方式就是看每个参数的单位,时钟的频率是该时钟在一秒钟内的周期数,说通俗点就是一秒钟里跳了多少次,我们以(次/秒)作为时钟频率的单位,波特率就简单了,他的单位是bps,也就是(比特/秒),本来就很容易理解,即每秒发多少个比特。用(次/秒)除以(比特/秒),得到的参数单位是(次/比特),那这两者之间的关系就很清晰了,即每以当前波特率发一个比特,当前频率的时钟需要跳多少次,正是我们需要的计数值。
总体的计数器思路有了之后,现在问题回到了串口收发,这个就很简单了,如上文所述,在每次跳变时进行串口操作即可,从起始位开始,以此收发数据位,校验位,停止位,最后结束整个过程。
还有一个需要注意的点是,串口是从低位开始发送的,接收也需要从低位开始寄存。
以下是按照思路绘制的波形图,其中baud_cnt是波特率计数,在收发过程中每个时钟周期都加一,bit_cnt是串口操作比特的计数,baud_cnt计满之后bit_cnt加一。
对于接收端,起始位开始接收操作,1到8位采样串口输入的值并寄存到对应位。
对于发送端,开始操作后,先发送一个低电平作为起始位,1到8位数据寄存器的对应位,因为是发送端,还要发送校验位和停止位,至少有一位停止位,所以最少也要计数到9。
三、UART发送模块的verilog实现
刚学习FPGA时,看到过一个说法,对于各种接口,实现起来一般都是发送端比较容易,接收端比较困难,虽然我觉得都差不多但还是先从发送端开始编写吧,而且没有发送端也不方便进行接收端的仿真。
接下来看看如何用verilog实现一个可配置参数的串口发送模块,可配置的参数包括:
输入时钟频率、串口波特率、串口停止位个数、奇偶校验模式。
模块端口声明:
module uart_tx #(
parameter SYS_CLK_FREQ = 50_000_000, // 输入时钟频率,单位 MHz
parameter BAUD_RATE = 115200, // 串口波特率,单位 bps
parameter STOP_BIT_CNT = 1, // 串口停止位个数
parameter PARITY_CODE = 2 // 奇偶校验,0:无校验 1:奇校验 2:偶校验
) (
input wire clk, // 输入时钟
input wire rst_n, // 输入同步复位
output wire dreq, // 输出数据请求信号
input wire [7:0] din, // 输入待发送数据
input wire tx_start, // 输入发送启动信号
output wire uart_tx // 输出串口发送
);
此处多提一嘴,为了提高模块的复用性,要注意当前模块与其他模块交互的接口,可以看到我预留的上游模块的接口是dreq和din,典型的fifo接口,这样不管上游模块是什么,只要通过一个fifo就可以和当前的串口发送模块进行交互。这其实是一个很容易注意到的问题,但是我发现那些卖板子的机构很多代码都不注意这一点。曾经有一次我要调试一个I2C的从机,想找一个写好的主机代码用一下,看了下芯路恒和正点原子的,正点原子的首先就吓我一跳,一个bit一个bit写状态机也太逆天了,芯路恒的还好,就是有上述的问题,对上游模块的接口太复杂了,虽然命名很清晰,但是还要专门写一个针对这种接口的控制模块,很麻烦,为了避免每写一个接口模块就要写一个控制模块,还是尽量用通用接口吧。
组合逻辑:
// 波特率计数(次/比特) = 时钟频率(次/秒)/波特率(比特/秒)
localparam BAUD_CNT = SYS_CLK_FREQ / BAUD_RATE;
// 校验位长度
localparam PARITY_BIT_CNT = PARITY_CODE >= 1 ? 1 : 0;
// 寄存器
reg start_ff1;
reg start_ff2;
reg parity_bit;
reg data_req;
reg [7:0] data_in;
reg tx_reg;
reg tx_busy;
reg [9:0] baud_cnt;
reg [3:0] bit_cnt;
// 线网
// 两拍同步后的发送启动信号
wire start_sync = start_ff2;
// 发送一比特
wire one_bit = baud_cnt == BAUD_CNT - 1;
// 发送一字节(包括校验位和停止位)
wire one_byte = (bit_cnt == 8 + PARITY_BIT_CNT + STOP_BIT_CNT) & one_bit;
// 奇偶校验类型
wire [1:0] parity_type = PARITY_CODE;
// 模块输出连线
assign dreq = data_req;
assign uart_tx = tx_reg;
组合逻辑能用连续赋值写就别用always@(*)写,连续赋值又简单又清晰。
还有一点,使用多次的分支条件最好用连续赋值单独写出来,这样时序逻辑块看上去比较简单明了,修改条件的时候也比较容易。
时序逻辑:
// 寄存器同步链,打两拍寄存发送启动信号
always @(posedge clk) begin
if(!rst_n) begin
start_ff1 <= 0;
start_ff2 <= 0;
end else begin
start_ff1 <= tx_start;
start_ff2 <= start_ff1;
end
end
// 根据配置参数选择奇偶校验或无校验
always @(posedge clk) begin
if(!rst_n)
parity_bit <= 0;
else if(parity_type[0])
parity_bit <= ~(^data_in);
else if(parity_type[1])
parity_bit <= ^data_in;
end
// 检测到同步发送启动信号后开始发送
always @(posedge clk) begin
if(!rst_n)
tx_busy <= 0;
else if(start_sync)
tx_busy <= 1;
else if(one_byte)
tx_busy <= 0;
end
// 开始发送后在开始位期间寄存待发送数据
always @(posedge clk) begin
if(!rst_n)
data_in <= 0;
else if(tx_busy & bit_cnt == 0)
data_in <= din;
end
// 开始发送后启动波特率计数,计满一比特后清零,同时比特计数加一
always @(posedge clk) begin
if(!rst_n)
baud_cnt <= 0;
else if(tx_busy) begin
if(one_bit)
baud_cnt <= 0;
else
baud_cnt <= baud_cnt + 1;
end
end
always @(posedge clk) begin
if(!rst_n)
bit_cnt <= 0;
else if(one_bit) begin
if(one_byte)
bit_cnt <= 0;
else
bit_cnt <= bit_cnt + 1;
end
end
// 每计数计满一比特时发送一比特
// 第0位为起始位,空闲状态与结束位均为高电平
always @(posedge clk) begin
if(!rst_n)
tx_reg <= 1;
else if(tx_busy) begin
case (bit_cnt)
0:tx_reg <= 0;
1:tx_reg <= data_in[0];
2:tx_reg <= data_in[1];
3:tx_reg <= data_in[2];
4:tx_reg <= data_in[3];
5:tx_reg <= data_in[4];
6:tx_reg <= data_in[5];
7:tx_reg <= data_in[6];
8:tx_reg <= data_in[7];
9:tx_reg <= PARITY_BIT_CNT ? parity_bit : 1;
default: tx_reg <= 1;
endcase
end
end
// 完整发送一字节(不包括校验位和停止位)后,拉高数据请求信号一个周期,以便与上游模块交互
always @(posedge clk) begin
if(!rst_n)
data_req <= 0;
else if((bit_cnt == 8) & one_bit)
data_req <= 1;
else
data_req <= 0;
end
时钟域同步是很重要的一件事,就算是同一个时钟都会有偏斜,更别说时钟域不同了,有可能来自其他时钟域的信号,都要在本模块时钟域进行同步,即打拍,这样的设计才可靠。
至于外部复位,我比较习惯使用同步复位,所以每个设计都会有专门的复位同步模块,时序逻辑块也按照同步复位的写法来写,在FPGA内部同步复位是比异步复位。
这里data_req信号是在一字节发送完成之后才拉高,是因为我习惯使用fifo的first word fall through模式(Altera叫做show ahead模式),这样读端口的数据可以直接用,用完了再读相当于请求下一次数据,如果不使用这个模式的fifo,可以在起始位拉高一次data_req,这样也可以实现读fifo。
编写完成后,对模块进行仿真,仿真的模块参数为停止位1,奇校验。
sync_fifo#(
.DATA_WIDTH (8),
.FIFO_DEPTH (8),
.FIRST_WORD_FALL_THROUGH (1))
sync_fifo_inst(
.clk(clk),
.rst_n(rst_n),
.wr_en(fifo_en),
.din(fifo_in),
.rd_en(dreq),
.dout(din),
.empty(empty));
uart_tx #(
.SYS_CLK_FREQ (50_000_000),
.BAUD_RATE (115200),
.STOP_BIT_CNT (1),
.PARITY_CODE (1)
) uart_tx_inst(
.clk(clk),
.rst_n(rst_n),
.dreq(dreq),
.din(din),
.tx_start(~empty),
.uart_tx(uart));
发送模块前端连接一个fifo,读模式设置为FIRST_WORD_FALL_THROUGH,发送启动信号连接fifo的empty,所以当fifo非空时自动启动串口发送。
上图能看到给fifo中写入了8个字节的数据,当empty为低后,经过几个同步周期,uart信号拉低,开始发送起始位。
上图是发送一字节的操作过程,bit_cnt为a时是上一个发送周期的停止位,uart为高,
下一个周期开始bit_cnt清零,因为fifo中还有数据,所以uart拉低,发送下一个数据的起始位,同时从fifo中取出下一个数据并寄存在data_in,数据为2a,对应的二进制数据为0010_1010,从低位开始发送就是0101_0100,可以看到从1到8正确对应发送数据,第9位为奇偶校验位,此处为奇校验,因为之前的数据中有三个1,所以奇校验位为0,然后到第a位就是本周期的结束位。
上图可以看到,此模块正确地发送了八个字节的数据,并且在fifo读空后tx_busy拉低,停止了发送。
至此,发送模块完成,理论上可以一直连续发数据,fifo有多少就发多少,也可以用别的方式启动发送,使用起来非常的方便。
四、UART接收模块的verilog实现
接收模块其实就是发送模块的一个逆过程,核心逻辑都是一样的,只不过把每个比特发送变成了每个比特接收。
对于接收模块,不用考虑停止位有几位,接受完数据位和校验位后直接退出接收过程即可,所以可配置的参数少了停止位数。
module uart_rx #(
parameter SYS_CLK_FREQ = 50_000_000, // 输入时钟频率,单位MHz
parameter BAUD_RATE = 115200, // 串口波特率,单位bps
parameter PARITY_CODE = 2 // 奇偶校验,0:无校验 1:奇校验 2:偶校验
) (
input wire clk, // 输入时钟
input wire rst_n, // 输入同步复位
output wire dvld, // 输出数据有效信号
output wire [7:0] dout, // 输出数据
input wire uart_rx // 输入串口接收
);
和其他模块交互的接口也更简单,只有简单地fifo写接口,接收模块不用考虑fifo有没有空间,不管有没有空间,该接收还得接收,fifo的空满就交给其他模块去操心吧。
组合逻辑:
// 波特率计数(次/比特) = 时钟频率(次/秒)/波特率(比特/秒)
localparam BAUD_CNT = SYS_CLK_FREQ / BAUD_RATE;
// 校验位长度
localparam PARITY_BIT_CNT = PARITY_CODE >= 1 ? 1 : 0;
// 寄存器
reg rx_ff1;
reg rx_ff2;
reg parity_bit;
reg rx_busy;
reg [9:0] baud_cnt;
reg [3:0] bit_cnt;
reg data_valid;
reg [7:0] data_out;
// 线网
// 两拍同步后的串口接收信号
wire serial_sync = rx_ff2;
// 串口接收信号的下降沿
wire serial_fall = rx_ff2 & ~rx_ff1;
// 接收半比特
wire half_bit = baud_cnt == BAUD_CNT/2 - 1;
// 接收一比特
wire one_bit = baud_cnt == BAUD_CNT - 1;
// 接收一字节
wire one_byte = (bit_cnt == 8 + PARITY_BIT_CNT) & one_bit;
// 奇偶校验类型
wire [1:0] parity_type = PARITY_CODE;
// 奇偶校验检查
wire parity_pass = parity_type[1] ? (parity_bit == ^data_out) : (parity_bit == ~(^data_out));
// 模块输出连线
assign dvld = data_valid;
assign dout = data_out;
组合逻辑中用到了很多常量,有些值其实可以用组合逻辑表达,比如PARITY_BIT_CNT可以用|parity_type轻易的得到,但是使用常量的话可以确保综合之后省去这一部分组合逻辑,其实以vivado的综合策略,很可能用这种只有一种结果的组合逻辑去写,也会优化成常量,现在这样写就只是图个稳当。
时序逻辑:
// 寄存器同步链,消除亚稳态的同时获取串口输入下降沿
always @(posedge clk) begin
if(!rst_n) begin
rx_ff1 <= 1;
rx_ff2 <= 1;
end else begin
rx_ff1 <= uart_rx;
rx_ff2 <= rx_ff1;
end
end
//检测到串口输入的下降沿后开始接收
always @(posedge clk) begin
if(!rst_n)
rx_busy <= 0;
else if(serial_fall)
rx_busy <= 1;
else if(one_byte)
rx_busy <= 0;
end
// 开始接收后启动波特率计数,计满一比特后清零,同时比特计数加一
always @(posedge clk) begin
if(!rst_n)
baud_cnt <= 0;
else if(rx_busy) begin
if(one_bit)
baud_cnt <= 0;
else
baud_cnt <= baud_cnt + 1;
end
end
always @(posedge clk) begin
if(!rst_n)
bit_cnt <= 0;
else if(one_bit) begin
if(one_byte)
bit_cnt <= 0;
else
bit_cnt <= bit_cnt + 1;
end
end
// 波特率计数到波特率的一半时寄存当前电平,此时数据最稳定
// 串口先发送低位,所以从低位到高位寄存
always @(posedge clk) begin
if(!rst_n) begin
data_out <= 0;
parity_bit <= 0;
end else if(baud_cnt == BAUD_CNT/2 - 1) begin
case (bit_cnt)
1: data_out[0] <= serial_sync;
2: data_out[1] <= serial_sync;
3: data_out[2] <= serial_sync;
4: data_out[3] <= serial_sync;
5: data_out[4] <= serial_sync;
6: data_out[5] <= serial_sync;
7: data_out[6] <= serial_sync;
8: data_out[7] <= serial_sync;
9: parity_bit <= serial_sync;
default: data_out <= data_out;
endcase
end
end
// 完整接收一字节数据后,拉高数据有效信号一个时钟周期,以便与其他模块交互
always @(posedge clk) begin
if(!rst_n)
data_valid <= 0;
else if(one_byte)
data_valid <= PARITY_BIT_CNT ? parity_pass : 1;
else
data_valid <= 0;
end
在发送模块的文章中提到了要做时序同步,串口作为异步通讯,即使速度很低,还是应该做好同步工作,再加上串口抗干扰能力差的特性,同步链的存在就显得更加重要了。
在代码中可以看到接收端是在波特率计数到一半的时候采样并寄存数据,这是因为在这个时刻,数据是最稳定的,即使波特率有细微的差距,也能够因此采样到正确的数据。这种数据更新沿和数据采样沿错开的模式在各种接口中非常常见,例如SPI,I2C,网口等,其中蕴含着时序分析的大道理,一定要理解这种思想。
编写完成后,同样例化进行仿真,就直接例化在上文的仿真文件中,将串口发送端连接接收端进行验证,记得要和发送端设置同样的参数。
uart_rx #(
.SYS_CLK_FREQ (50_000_000),
.BAUD_RATE (115200),
.PARITY_CODE (1))
uart_rx_inst(
.clk(clk),
.rst_n(rst_n),
.uart_rx(uart));
通过上文的发送端发送出的数据传入串口接收端之后,经过两拍同步,在下降沿启动接收过程。
下图是接收一字节的过程,可以看到在10个比特的计数之后,成功接收到了与发送端同样的数据。其中parity_pass是校验通过信号,比特9是校验位,在采样校验位后的parity_pass为高的时候,证明数据有效,输出一个周期的data_valid。
下图是完整接收八个字节的时序,可以看到每次数据有效信号拉高的时候,数据寄存器中的值都和发送端发送的数据相同,仿真验证完成。
接收模块也算是完成了,接下来就该上板进行测试了。
五、UART环回收发测试
接下来要进行接口测试最常用的环回测试,在顶层例化串口收发模块,中间通过一个fifo连接,FPGA通过串口连接PC,就可以通过串口调试软件向FPGA发送数据,如果设计正确,FPGA应该会返回同样的数据。
整体逻辑框图如下:
顶层模块:
module top(
input sys_clk,
input sys_rst_n,
input wire uart_rx,
output wire uart_tx
);
(* ASYNC_REG = "true" *)
reg sync_rst_ff1;
(* ASYNC_REG = "true" *)
reg sync_rst_ff2;
wire clk_50M;
wire sync_rst_n;
wire dvld;
wire [7:0] dout;
wire dreq;
wire [7:0] din;
assign sync_rst_n = sync_rst_ff2;
always @(posedge clk_50M) begin
sync_rst_ff1 <= sys_rst_n;
sync_rst_ff2 <= sync_rst_ff1;
end
BUFG BUFG_inst(
.I(sys_clk),
.O(clk_50M)
);
uart_rx #(
.SYS_CLK_FREQ (50_000_000),
.BAUD_RATE (115200))
uart_rx_inst(
.clk(clk_50M),
.rst_n(sync_rst_n),
.dvld(dvld),
.dout(dout),
.uart_rx(uart_rx));
sync_fifo#(
.DATA_WIDTH (8),
.FIFO_DEPTH (8),
.FIRST_WORD_FALL_THROUGH (1))
sync_fifo_inst(
.clk(clk_50M),
.rst_n(sync_rst_n),
.wr_en(dvld),
.din(dout),
.rd_en(dreq),
.dout(din),
.empty(empty));
uart_tx #(
.SYS_CLK_FREQ (50_000_000),
.BAUD_RATE (115200),
.STOP_BIT_CNT (1)
) uart_tx_inst(
.clk(clk_50M),
.rst_n(sync_rst_n),
.dreq(dreq),
.din(din),
.tx_start(~empty),
.uart_tx(uart_tx));
endmodule
可以看到在例化的时候我没有配置奇偶校验的参数,模块中设置的缺省参数为2,即偶校验,等下在上板时要记得将串口工具设置为偶校验。
还有,此处同步FIFO的深度参数例化为8,但是串口的速度远低于FIFO的读写速度的,理论无论多长的数据,都可以完整的环回出去。
然后开始综合,布局布线,生成比特流。
程序上板之后连接串口,将串口调试助手设置为波特率115200,校验位even,停止位1,开启串口,测试结果如下:
可以看到成功的收到了与发送内容一模一样的数据,收发数据计数也是正确的,环回测试成功!
后续还进行了大量数据测试,发了10万字节收了10万字节,粗略的看了一下应该是没有错字,这个串口的设计可靠性还是可以的,结构也很简单,很适合应用在实际项目中。
串口的内容到此结束,后续还会上传串口环回完整工程,接下来计划更新SPI,相较于UART,SPI要稍微复杂一点,如何把SPI做的简单可靠又通用呢,欢迎各位持续关注。