ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Verilog从零实现UART串口协议:FPGA工程与仿真详解

用Verilog从零实现UART串口协议:FPGA工程与仿真详解 简介UART串口协议的Verilog纯代码实现及仿真工程面向FPGA开发与数字通信学习者。设计不依赖任何IP核用原生Verilog分别构建UART_TX发送模块与UART_RX接收模块完整覆盖起始位、数据位、奇偶校验位与停止位的帧结构。配套的testbench测试文件能够生成仿真激励配合Vivado工程内的仿真脚本和波形配置文件可直接编译运行直观观察逐位发送与接收时的时序对齐、波特率采样点及校验错误检测机制。压缩包共140个文件主要有Verilog源码、工程配置、仿真波形、日志、Tcl脚本、批处理文件及说明文档等总大小约16.45MB目录结构清晰便于对照学习与二次修改。已有249人学习下载尤其适合希望理解异步串行通信底层细节、需要快速验证UART模块功能或正准备集成到更大FPGA系统的工程师与学生。通过该资源还能了解纯Verilog搭建常见接口通信的完整思路为后续扩展多通道或更高波特率设计打下基础。 UART串口协议这玩意儿搞嵌入式和FPGA的人早晚都得正面刚一次。别看它协议简单就两根线一收一发但里面涉及的分频计数、边沿采样、状态机跳转全都是数字逻辑的基本功。网上讲UART的教程不少但要么只贴代码不讲为什么要么直接甩个IP核完事真正从零开始用Verilog纯代码实现、还带完整Vivado工程和仿真流程的资料其实没那么好找。这篇我就把自己实际调通的工程从头到尾拆开讲从协议细节到代码每一行怎么来的再到仿真波形怎么才算对一次说清楚。这个工程适合谁刚学完Verilog语法、想找个完整项目练手的同学或者做嵌入式想彻底搞懂串口底层时序的工程师再或者项目里需要定制非标准波特率或特殊帧格式、不想被IP核绑死的人。看完你不仅能写出自己的UART收发模块还能在Vivado里建工程、写testbench、跑仿真、看波形一条龙搞定。1. 项目整体设计与模块划分思路1.1 为什么用纯Verilog写UART而不是直接用IP核Vivado里自带UART IP核双击配置一下就能用为什么还要自己写我的看法是IP核确实省事但它是个黑盒子你没法控制里面的实现细节。比如你想做一个非标准的波特率或者想在接收端做特殊的滤波处理IP核的灵活性就不够了。更重要的是UART协议是理解串行通信的最佳入门案例自己写一遍你对分频、采样、状态机、亚稳态这些概念的理解会上一个台阶。从工程角度说UART模块代码量不大纯逻辑实现大概两百行左右占用的资源也极少几个计数器加一个状态机完全没必要为了这点逻辑引入IP核依赖。自己写的代码可移植性还强今天在Vivado里用明天搬到Quartus或者开源工具链几乎不用改。1.2 顶层架构与子模块划分整个工程我拆成了三个子模块加一个顶层baud_gen波特率发生器本质就是一个分频计数器产生发送时钟和接收采样时钟uart_tx发送模块把并行的8位数据变成串行比特流带起始位和停止位uart_rx接收模块检测起始位在数据位中间采样恢复出8位并行数据顶层文件把这三个模块例化在一起对外暴露的接口就是标准UART接口tx、rx再加上时钟和复位。为什么要把波特率发生器单独拆出来因为发送和接收都用它而且分频逻辑独立出来后修改波特率只动一个地方代码维护起来很舒服。这里有个小设计取舍发送模块用的时钟是波特率时钟即分频后的时钟接收模块用的则是波特率时钟的16倍频时钟用于过采样。两种时钟频率不同所以需要分开处理不能简单共用一条时钟线。2. 协议细节与核心参数计算2.1 UART帧格式起始位、数据位、校验位、停止位UART是异步串行协议没有独立的时钟线收发双方靠约定好的波特率来同步。每一帧数据在空闲状态下线路保持高电平。发送开始后先拉低一个位时间这是起始位告诉接收端“数据要来了”。然后是8个数据位低位先发。接着是可选的校验位最后是停止位必须拉高至少一个位时间。这个帧结构是理解收发逻辑的基础。发送模块本质上是按时间顺序把帧里的每个比特送到tx引脚上接收模块则是反过来在正确的时刻把rx引脚上的电平采样下来按顺序拼成字节。工程里我用的配置是8个数据位、无校验位、1个停止位也就是常说的8N1。这是最常用的配置也最容易调试。如果后面需要支持奇偶校验在帧格式里加一个校验位即可代码改动不大。2.2 波特率分频计算以50MHz时钟产生9600bps为例波特率Baud Rate是每秒传输的符号数在UART场景下等同于比特率。工程默认系统时钟50MHz目标波特率9600bps那每个比特的时间是T_bit 1 / 9600 ≈ 104.167 微秒用50MHz时钟计数每个时钟周期20ns那么一个比特需要计数的周期数是50,000,000 / 9,600 5208.33取整为5208。也就是说计数器从0数到5207输出一个周期的高电平脉冲这个脉冲的频率就是9600Hz。注意这里有个小数部分直接取整会带来一点点误差。算一下实际波特率50,000,000 / 5208 ≈ 9600.6 bps误差只有0.006%远小于UART通信允许的误差范围一般认为收发双方波特率误差在2%以内都能正常工作。所以直接取整完全没问题。如果是其他波特率比如115200的典型值在50MHz下分频数是434.02取434后实际波特率是115207误差也很小。这里我建议把分频参数做成parameter顶层通过参数传入这样换波特率不用改代码例化时传参就行。2.3 接收端的16倍过采样设计为什么不是直接按波特率采样发送端简单按波特率时钟移位输出就行。接收端麻烦得多你不知道数据什么时候来起始位是异步的而且线路上的噪声可能导致边沿抖动。如果只在理论上的数据位中心采一次一旦采样点偏了就可能采错。解决办法是过采样。工程里接收端用16倍波特率时钟采样也就是说每个数据位被采样16次。起始位检测逻辑是空闲状态下rx为高当检测到rx拉低后继续等8个过采样周期即半个位时间再采样一次。如果此时仍为低确认是真正的起始位而不是毛刺。这就是“中点确认”策略能有效过滤掉短于半个位时间的干扰脉冲。确认起始位后后续每个数据位都在第8个过采样周期位的正中点采样把16次采样中的中间那一次作为该位的值。这样的设计对时钟频率误差的容忍度大幅提升只要一个位时间内误差不超过半个位就能正确采样。3. Vivado工程搭建与Verilog代码实现3.1 工程文件结构与新建工程要点Vivado里新建工程我不啰嗦了就说几个容易踩的坑。第一芯片型号选型要提前想好工程用的是Artix-7系列XC7A35T如果你手头板子是其他型号器件选型要对应选好否则综合会报错或者引脚约束对不上。第二工程名和路径不要带中文和空格不然有些版本的Vivado会出莫名其妙的问题。第三建议把RTL文件和仿真文件分目录放养成好习惯后面工程大了才好管理。工程文件结构uart_project/ ├── rtl/ │ ├── uart_top.v │ ├── baud_gen.v │ ├── uart_tx.v │ └── uart_rx.v ├── sim/ │ └── uart_tb.v ├── constraint/ │ └── uart_top.xdc └── uart_project.xpr3.2 波特率发生器模块代码这个模块的输入是系统时钟和复位输出是baud_clk波特率时钟和baud_x16_clk16倍波特率时钟。注意我这里的输出信号本质是脉冲使能信号不是真正的时钟信号。在FPGA里用门控时钟是大忌正确做法是用高电平脉冲作为使能信号在系统时钟的上升沿工作。这一点很重要尤其在做接收过采样时如果用分频后的时钟去驱动逻辑时序分析会非常痛苦。module baud_gen #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9_600 )( input wire clk, input wire rst_n, output reg baud_clk, output reg baud_x16_clk ); localparam DIV_CNT CLK_FREQ / BAUD_RATE; localparam DIV_CNT_X16 CLK_FREQ / (BAUD_RATE * 16); reg [15:0] cnt_baud; reg [15:0] cnt_x16; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_baud 16d0; baud_clk 1b0; end else if (cnt_baud DIV_CNT - 1) begin cnt_baud 16d0; baud_clk 1b1; end else begin cnt_baud cnt_baud 1b1; baud_clk 1b0; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_x16 16d0; baud_x16_clk 1b0; end else if (cnt_x16 DIV_CNT_X16 - 1) begin cnt_x16 16d0; baud_x16_clk 1b1; end else begin cnt_x16 cnt_x16 1b1; baud_x16_clk 1b0; end end endmodule这里有个细节baud_clk和baud_x16_clk都是高电平脉冲持续一个系统时钟周期不是持续一个波特率周期。用脉冲做使能在数据位切换时非常干净利落不会产生竞争风险。3.3 UART发送模块实现状态机与移位逻辑发送模块的设计思路是空闲时tx保持高电平收到发送请求后拉低tx一个位时间起始位然后依次发送8个数据位低位在前最后拉高一个位时间停止位。整个过程用一个3比特的位计数器bit_cnt控制。module uart_tx #( parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire tx_start, input wire [DATA_WIDTH-1:0] tx_data, output reg tx, output reg tx_busy ); localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [2:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; tx 1b1; tx_busy 1b0; bit_cnt 3d0; shift_reg {DATA_WIDTH{1b0}}; end else begin case (state) IDLE: begin tx 1b1; tx_busy 1b0; if (tx_start) begin state START; shift_reg tx_data; tx_busy 1b1; end end START: begin tx 1b0; state DATA; bit_cnt 3d0; end DATA: begin tx shift_reg[0]; shift_reg {1b0, shift_reg[DATA_WIDTH-1:1]}; if (bit_cnt DATA_WIDTH - 1) begin bit_cnt 3d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end STOP: begin tx 1b1; state IDLE; tx_busy 1b0; end default: state IDLE; endcase end end endmodule代码里用shift_reg右移实现逐位发送每发送一位就把数据右移一位最低位就是当前要发的比特。这里注意发送顺序是低位先发所以取的是shift_reg[0]。如果要用高位先发的模式改成取最高位即可但两边要约定一致。我在写这个模块时踩过一个坑tx_busy信号必须在START状态就拉高否则外部逻辑可能在上一个字节还没发完时又触发新的发送请求导致数据覆盖。现在这个写法里tx_busy在IDLE拉低、收到请求后立即拉高外部逻辑检查tx_busy为低再发起新发送就不会出问题。3.4 UART接收模块实现起始位确认与数据采样接收模块比发送复杂核心是三段逻辑起始位检测与确认、数据位采样、帧同步。module uart_rx #( parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire rx, output reg [DATA_WIDTH-1:0] rx_data, output reg rx_done ); localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [3:0] sample_cnt; reg [2:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_reg; reg rx_d1, rx_d2; // 两级同步消除亚稳态 wire rx_negedge; assign rx_negedge rx_d2 ~rx_d1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_d1 1b1; rx_d2 1b1; end else begin rx_d1 rx; rx_d2 rx_d1; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; sample_cnt 4d0; bit_cnt 3d0; shift_reg {DATA_WIDTH{1b0}}; rx_data {DATA_WIDTH{1b0}}; rx_done 1b0; end else begin rx_done 1b0; case (state) IDLE: begin sample_cnt 4d0; bit_cnt 3d0; if (rx_negedge) begin state START; end end START: begin if (baud_x16_clk) begin if (sample_cnt 4d7) begin if (rx_d2 1b0) begin state DATA; sample_cnt 4d0; end else begin state IDLE; end end else begin sample_cnt sample_cnt 1b1; end end end DATA: begin if (baud_x16_clk) begin if (sample_cnt 4d15) begin sample_cnt 4d0; shift_reg {rx_d2, shift_reg[DATA_WIDTH-1:1]}; if (bit_cnt DATA_WIDTH - 1) begin bit_cnt 3d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin sample_cnt sample_cnt 1b1; end end end STOP: begin if (baud_x16_clk) begin if (sample_cnt 4d15) begin state IDLE; rx_data shift_reg; rx_done 1b1; end else begin sample_cnt sample_cnt 1b1; end end end default: state IDLE; endcase end end wire baud_x16_clk; baud_gen #( .CLK_FREQ(50_000_000), .BAUD_RATE(9_600) ) baud_gen_inst ( .clk(clk), .rst_n(rst_n), .baud_clk(), .baud_x16_clk(baud_x16_clk) ); endmodule接收端的采样点设计是这段代码的核心。起始位检测到下降沿后从0开始计数数到7第8个过采样周期时处于位的正中。注意此时我判断的是rx_d2而不是直接判断rx因为同步过两级触发器后的信号才是稳定可靠的。如果第8个采样点读到的是低电平说明刚才的下降沿是真正的起始位进入数据接收状态如果读到高电平说明是毛刺或者干扰放弃这一帧回到IDLE。数据位采样用的是每个位的最后第16个周期。也就是说只在每个数据位的边界位置采一次。为什么不是像起始位那样采中间因为进入DATA状态后采样点已经和位的边沿对齐了后续每个位都在同一个相位偏移处采样等效于采在每位中间。这个设计的好处是不需要为每个位重新计算采样点逻辑简单且可靠。3.5 顶层模块与引脚约束顶层把各模块连接起来对外接口就是简洁的UART引脚module uart_top #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9_600 )( input wire clk, input wire rst_n, input wire rx, output wire tx ); wire tx_start; wire [7:0] tx_data; wire tx_busy; wire [7:0] rx_data; wire rx_done; // 这里可以接一个回环测试逻辑收到什么就发什么 assign tx_data rx_data; assign tx_start rx_done; uart_tx #( .DATA_WIDTH(8) ) tx_inst ( .clk(clk), .rst_n(rst_n), .tx_start(tx_start), .tx_data(tx_data), .tx(tx), .tx_busy(tx_busy) ); uart_rx #( .DATA_WIDTH(8) ) rx_inst ( .clk(clk), .rst_n(rst_n), .rx(rx), .rx_data(rx_data), .rx_done(rx_done) ); endmodule这里我做了个回环收到一字节数据立即通过发送模块发回去。这是最便于验证的拓扑你在串口调试助手上发什么就能收到什么。实际项目里rx_data和rx_done应该接到你的业务逻辑模块比如控制LED、解析指令等。引脚约束文件XDC按你的板子实际引脚来写。如果用开发板自带的USB转串口芯片常见的是CP2102或CH340通常在板卡原理图里能查到对应的FPGA引脚号和UART的TXD/RXD连接关系。约束写法set_property PACKAGE_PIN E3 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN F2 [get_ports rx] set_property IOSTANDARD LVCMOS33 [get_ports rx] set_property PACKAGE_PIN F1 [get_ports tx] set_property IOSTANDARD LVCMOS33 [get_ports tx] create_clock -period 20.0 [get_ports clk]4. 仿真验证过程与常见问题排查4.1 Testbench编写模拟发送端和接收端仿真文件的核心思路是例化顶层模块后在测试脚本里模拟对端设备的行为。具体来说产生一个波特率时钟信号按9600bps的时序发送一个字节给接收模块的rx输入同时等待顶层回环逻辑把数据发出来在tx输出上按波特率时钟采样检验数据是否正确。timescale 1ns / 1ps module uart_tb; reg clk; reg rst_n; reg rx; wire tx; parameter CLK_FREQ 50_000_000; parameter BAUD_RATE 9_600; localparam BIT_TIME 1_000_000_000 / BAUD_RATE; uart_top #( .CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) uut ( .clk(clk), .rst_n(rst_n), .rx(rx), .tx(tx) ); // 50MHz时钟 always #10 clk ~clk; // 模拟UART发送一帧数据到FPGA的rx引脚 task uart_send_byte(input [7:0] byte_in); integer i; begin rx 1b1; #BIT_TIME; rx 1b0; // 起始位 #BIT_TIME; for (i 0; i 8; i i 1) begin rx byte_in[i]; #BIT_TIME; end rx 1b1; // 停止位 #BIT_TIME; end endtask // 在FPGA的tx输出上采样验证数据 task uart_check_tx(reg [7:0] recv_data); integer i; begin #(BIT_TIME / 2); if (tx ! 1b0) $display(Error: missed START bit); #BIT_TIME; for (i 0; i 8; i i 1) begin recv_data[i] tx; #BIT_TIME; end #(BIT_TIME / 2); if (tx ! 1b1) $display(Error: missed STOP bit); $display(Received byte: %h, recv_data); end endtask initial begin clk 0; rst_n 0; rx 1b1; #100; rst_n 1; // 发送一个字节 0xA5 给FPGA uart_send_byte(8hA5); #(BIT_TIME * 3); // 等待FPGA回环发出采样检查 uart_check_tx(); #(BIT_TIME * 3); $display(Test done.); $finish; end endmodule测试任务uart_send_byte是按真实UART时序逐位翻转rx信号完全模拟对端设备的发送过程。uart_check_tx则是从FPGA的tx引脚上按波特率时钟逐个采样拼出接收到的字节。这个任务里有个细节采样点设在每个位的后半段再加半个位时间这样可以避开边沿附近的毛刺。跑完仿真后波形图上你应该能看到rx上出现一帧完整的波形起始位拉低、8个数据位、停止位拉高随后tx上也出现一帧相同的波形数据一致rx_done在接收完成时产生一个高电平脉冲tx_busy在发送期间拉高。4.2 常见问题速查表与排查技巧我把自己和身边朋友实际调试中遇到的高频问题整理成了一张表照着排能省很多时间。现象可能原因排查方法接收完全没反应引脚约束错误核对XDC里引脚号和板卡原理图接收数据全错波特率不匹配检查分频参数计算对比两侧波特率误差偶尔丢字节缺少tx_busy判断确认发送侧没在忙时被新数据覆盖起始位误触发没有做中点确认检查过采样逻辑确认起始位第8个采样点是否有效数据位错乱采样点不在位的中心确认进入DATA状态后采样计数器是否从0开始串口助手上收到乱码停止位长度不对确认停止位数配的是1还是2两侧保持一致这里重点说两个排查思路。第一遇到通信异常先用回环模式测如果自发自收正常说明模块本身没问题问题大概率出在外部连接或对端配置上。第二仿真是定位问题的第一利器不要一上来就上板调试。仿真可以精确控制时间波形上能清楚看到每个比特的采样点比用示波器在板子上抓快得多。4.3 上板调试经验串口助手与逻辑分析仪配合仿真通过之后上板验证才是真正考验。把bitstream下载到FPGA用USB转串口线连接电脑和板子打开串口调试助手推荐用正点原子XCOM或者SSCOM配置好对应波特率发数据看回环。实际调试中我发现两个容易忽略的问题。第一是USB转串口芯片的驱动Windows系统下CP2102和CH340都需要装驱动不装的话电脑上根本找不到串口号。这些驱动在芯片厂商官网都能下到安装后设备管理器里能看到COM口不识别就先查这步。第二是串口助手里的波特率、数据位、停止位必须和FPGA里配的完全一致任何一个不一致都会导致乱码——最常见的就是模块默认8N1没改助手那边却选了7位数据或者2位停止位。如果回环测试数据对了可以进一步接真实的外设比如接一个GPS模块或者另一个开发板验证收发双向都正常。我自己的习惯是上板调试前先把仿真波形截图存档出问题的时候对比波形能快速定位是逻辑问题还是外部干扰问题。5. 扩展思路与个人实操心得5.1 从9600到1M自适应波特率检测方案工程目前是固定波特率通过模块参数配置。如果想做自适应波特率检测Auto-Baud思路也不难接收模块在检测到起始位下降沿后不直接进入数据采样而是用更高频的时钟去测量起始位的长度。9600bps下起始位长度约104微秒用系统时钟50MHz测量计数值约5208。把这个计数值换算成接收模块的分频参数就能自动适配未知波特率。这个功能在做即插即用型设备时很有用但对初学者来说先把手动配置的路走通更重要。5.2 个人实操小结三个核心习惯最后分享点个人体会。第一次写UART模块时我犯过一个印象深刻的错误直接用了分频产生的时钟作为逻辑时钟结果在实现后发现时序收敛困难各种毛刺问题。后来改成脉冲使能方式困扰全消失了。这是我认为最重要的一个习惯在FPGA里始终用系统时钟驱动逻辑用使能信号控制节奏。第二个习惯是每个模块的复位同步要做好。UART模块比较简单用异步复位没问题但务必保证复位释放时没有毛刺。在工程规模变大后建议统一用异步复位、同步释放的复位方式。第三个习惯是接口信号命名要统一。比如系统级信号用clk、rst_n模块间信号用带模块前缀的命名如rx_data、tx_busy调试时查信号名一眼就能看出信号属于哪个模块排查问题效率高不少。UART这个项目虽然小但五脏俱全有计数分频、有移位寄存器、有状态机、有跨时钟域处理同步器、有验证方法。把它吃透了后续学SPI、I2C、AXI等接口协议的路就顺了。代码和工程都在照着仿真跑一遍再上板调通你自己就能体会到这里面各个设计细节的意义了。本文还有配套的精品资源点击获取
返回列表