FPGA实现UART串口通信:协议解析与Verilog代码实战
2026/9/7 15:15:45 网站建设 项目流程

做FPGA开发最常碰到的第一个“正儿八经的通信模块”,八成是UART。我自己刚接触FPGA的时候,跑完流水灯、按键消抖,下一步就是想着怎么让板子和电脑说上话——串口打印“Hello FPGA”,那感觉完全不一样。UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)之所以成为经典入门项目,不只是因为简单,更因为它麻雀虽小五脏俱全,异步协议、时序分析、状态机设计、跨时钟处理这些概念它全沾边。

这篇文章我打算把FPGA实现UART串口通信这件事从头到尾拆开讲:先从协议帧格式说起,然后分别给出发送端、接收端的Verilog实现,再做顶层回环测试,最后把板级调试中常见的乱码、丢帧、采样点偏移这些坑也一并整理清楚。适合两类读者:一类是刚入门FPGA、想找一个“能跑通、有深度”的练手项目的人;另一类是已经在写代码但总是“仿真没问题、上板就乱码”的工程师。两种视角我都会覆盖到。

1. UART协议说穿了就三件事:电平、帧格式、波特率

很多教程上来就让你写代码,代码写完了,波形仿真也通过了,结果一接到真实设备上就出现各种莫名问题。原因很简单:协议层面的细节没吃透,代码只是“形似”而不是“神似”。所以先把协议这一层讲清楚,后面排查问题才有理论依据。

1.1 一帧数据到底长什么样

UART是异步串行协议,意思就是发送端和接收端之间没有一条共享时钟线。双方想协调节奏,靠的只有两条:事先约定好的波特率(每秒传输多少bit),以及数据线上的电平跳变。

空闲状态下,TX线保持高电平。要发一帧数据时,先把线拉低一个bit时间——这就是起始位。接收端看到这个下降沿,就知道“数据来了”。之后从LSB(最低位)到MSB(最高位)逐位发送数据位,可选地跟一个校验位,最后拉高一个bit以上作为停止位。

以最常用的“115200-8-N-1”为例,这一串参数的意思是:波特率115200bps,8位数据位,无校验位,1位停止位。那么一帧就是1 + 8 + 1 = 10个bit。115200波特率下每bit的时间约为8.68us,一帧约86.8us。发0x55(二进制0101_0101)时,示波器上看到的就是一个很规整的方波序列:起始位为低,然后是10101010的交替波形,最后停止位拉高。0x55和0xAA是串口调试最常用的测试数据,就是因为这种交替的01序列很容易在波形上一眼看穿每一位有没有发错。

1.2 波特率怎么定、误差怎么算

波特率的选择主要看传输距离、线缆质量和接收端处理能力。调试用9600或115200都够,吞吐量要求高时可以用921600甚至更高。但波特率越高,每bit的时间越短,信号边沿经过线缆后的畸变和反射影响就越明显。我的经验是:能115200解决的事就不要上921600,除非你确实有吞吐量压力,否则纯属给自己增加调试难度。

FPGA里产生波特率的方式,不是真的去配置一个独立时钟源,而是把系统时钟分频成一个波特率时钟使能(tick)信号。核心参数就一个:

BAUD_DIV = 系统时钟频率 / 波特率

比如50MHz系统时钟、115200波特率:50_000_000 / 115200 = 434.0278,取434。这就引出一个必须面对的问题:整除不了时舍入带来的误差。实际波特率等于50_000_000 / 434,约115207.37bps,误差只有0.006%,完全在可接受范围内。

真正要警惕的是另一种情况:板卡晶振和代码里的参数对不上。板子实际上是27MHz晶振,你代码里却按50MHz算分频系数,那出来的波特率直接就差了快一倍,串口助手那边肯定会乱码。波特率误差的容限,工程上一般控制在±2%~±3%以内比较稳。实际上UART接收端有16倍过采样机制兜底,短帧传输时即使有2%的误差也能正确采样,但如果分频系数差得太离谱,或者数据帧很长,误差积累到采样点偏移,就会开始丢位。

1.3 TTL电平和RS232电平,这是第一个容易翻车的点

这个值得单独拿出来强调。FPGA的IO引脚一般是TTL电平标准,高电平约3.3V或2.5V,低电平0V。而电脑DB9串口用的是RS232电平,逻辑0是+3V到+15V,逻辑1是-3V到-15V,是负逻辑。

这里说清楚一点:不能直接把FPGA的UART引脚接到电脑DB9串口上。电平标准对不上,轻则通信完全失败,重则烧引脚。实际开发中,FPGA板卡上一般会集成USB转串口芯片,比如FT232、CH340、CP2102这些,把FPGA侧的TTL电平转成USB信号,电脑端装好驱动后直接就是一个虚拟串口。你可以把这种方案理解成“翻译官”:FPGA说TTL,电脑说USB/RS232,中间有人帮你翻译。

如果是自己搭电路,常用的电平转换方案是MAX3232或者SP3232这一类的RS232收发芯片,把TTL转成RS232电平再接DB9。

1.4 串口参数速查表:别再被“115200-8-N-1”吓住

很多人看到“115200-8-N-1”这种写法觉得很高深,其实就是一个速记法。第一个数是波特率,第二个数是数据位,第三个数是校验方式(N=无,O=奇校验,E=偶校验),最后一个数是停止位位数。写清楚之后,通信两端照着配就行。

参数常用值说明
数据位8最常用,正好一个字节
校验位无 / 奇校验 / 偶校验追求简单可靠选无校验
停止位1默认选项,1.5和2一般特定场景才用
波特率115200调试默认,速度和稳定性均衡
流控硬件流控RTS/CTS在FPGA里不常用

这些参数里,数据位8、无校验、停止位1是默认组合,调试阶段基本不用改。校验位能检测出奇数个bit的错误,但对偶数个bit的错误无能为力。工程上如果链路底噪大,与其折腾校验位,不如直接降低波特率或者把线缆换短,效果更直接。

2. 发送端实现:状态机驱动一个移位过程

发送端的任务可以概括成一句话:把并行总线上的一个字节,按约定好的帧格式,一位一位地从TX引脚送出去。虽然逻辑简单,但它是FPGA里典型的“状态机 + 计数器”组合,实现方式值得好好打磨。

2.1 端口定义和参数计算

先看端口,一个最小可用的发送模块应该包含这些信号:

信号方向位宽说明
clkin1系统时钟,比如50MHz
rst_nin1异步复位,低有效
tx_startin1发送启动脉冲,单周期高电平
tx_datain8待发送数据
txdout1串行输出
tx_busyout1忙指示,高电平时不可发送新数据

参数上,我用两个parameter把系统时钟和波特率提出来,方便不同板卡之间移植:

parameter CLK_FREQ = 50_000_000; // 50MHz parameter BAUD_RATE = 115_200; // 115200bps localparam BAUD_DIV = CLK_FREQ / BAUD_RATE; // 434

这里BAUD_DIV就是波特率分频系数,含义是每过434个系统时钟周期,发出一个波特率tick。tick就是“该动一下了”的使能信号,整个发送状态机都跟着tick走。注意这里我刻意用了“时钟使能”而不是真正分频出一个独立时钟,这是FPGA设计里一个很重要的习惯:避免在内部逻辑里产生门控时钟,因为门控时钟容易带来毛刺和时序分析问题,而时钟使能信号则不存在这个问题。

2.2 状态机和代码:四个状态说清楚整个发送过程

发送端状态机我习惯分成四个状态:

  • IDLE:空闲态,TX输出高电平,等待发送请求
  • START:发送起始位,输出低电平,持续一个bit时间
  • DATA:逐位发送8个数据位,从bit0到bit7
  • STOP:发送停止位,输出高电平,持续一个bit时间

状态转移的核心逻辑是:每个状态下等一个BAUD_DIV计数周期,然后自动进入下一状态。判断发送完成的条件是STOP状态结束。

module uart_tx #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115_200 ) ( input wire clk, input wire rst_n, input wire tx_start, input wire [7:0] tx_data, output reg txd, output wire tx_busy ); localparam BAUD_DIV = CLK_FREQ / BAUD_RATE; localparam IDLE = 2'd0, START = 2'd1, DATA = 2'd2, STOP = 2'd3; reg [1:0] state; reg [15:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] data_buf; assign tx_busy = (state != IDLE); always @(posedge clk or negedge rst_n) begin if (~rst_n) begin state <= IDLE; baud_cnt <= 16'd0; bit_cnt <= 3'd0; txd <= 1'b1; data_buf <= 8'd0; end else begin case (state) IDLE: begin txd <= 1'b1; baud_cnt <= 16'd0; bit_cnt <= 3'd0; if (tx_start) begin data_buf <= tx_data; state <= START; end end START: begin txd <= 1'b0; if (baud_cnt == BAUD_DIV - 1) begin baud_cnt <= 16'd0; state <= DATA; end else begin baud_cnt <= baud_cnt + 1'b1; end end DATA: begin txd <= data_buf[bit_cnt]; if (baud_cnt == BAUD_DIV - 1) begin baud_cnt <= 16'd0; if (bit_cnt == 3'd7) begin state <= STOP; end else begin bit_cnt <= bit_cnt + 1'b1; end end else begin baud_cnt <= baud_cnt + 1'b1; end end STOP: begin txd <= 1'b1; if (baud_cnt == BAUD_DIV - 1) begin state <= IDLE; end else begin baud_cnt <= baud_cnt + 1'b1; end end endcase end end endmodule

这段代码有几个细节我得强调一下。

第一,IDLE状态里我把baud_cnt和bit_cnt都清零了,这是为了每次发送都是一次干净的“冷启动”,避免上一次发送残留的计数值污染这一次的时序。如果不做这个清零,第一次发送和后续发送的时序可能就不一致,这种问题在仿真里容易漏掉,因为仿真里初始值都是0。

第二,DATA状态下txd直接等于data_buf[bit_cnt],而不是再用一个中间变量去控制。bit_cnt每次加1,下一个bit周期就会输出下一位,保证每bit稳定输出一个完整周期。这种方式简洁,可读性也好。

第三,tx_start应该是一个单周期脉冲。如果外部给的是持续的高电平,需要自己做边沿检测或者用tx_busy做互锁,不然状态机可能在你还没准备好的时候就再次进入发送流程。

2.3 两个容易踩的细节:启动脉冲和busy信号

发送模块本身不难,真正容易踩坑的是它和外部逻辑的配合。tx_busy这个信号就是用来做配合的——外部模块想发数据前先检查tx_busy,如果忙就等待,不忙再拉高tx_start,这样能避免数据在发送半截的时候被新请求打断。

另一个细节是,如果上位机连续下发多个字节,接收端每收完一个字节立刻触发发送,而发送模块还在busy状态,那第二个字节的启动请求就会被直接忽略。这个问题放在后面第4章和第5章细说,但你在设计阶段就要有这个概念:UART的收发不是一个天然全双工无缝衔接的过程,中间一定要考虑缓冲和节流。

3. 接收端实现:16倍过采样定位每一位的中心

接收端比发送端难了一个量级。难点不在于状态机的复杂度,而在于“你永远不知道对端什么时候开口说话”。发送端有明确的时钟节拍,接收端却要靠数据线本身的电平跳变来同步。怎么在噪声存在的情况下稳健地采样数据,是这部分的核心命题。

3.1 为什么每个bit要采样16次

如果直接把系统时钟拿来对RXD采样,会遇到两个问题。第一是毛刺干扰:RXD上一小段瞬间的低电平毛刺,可能被当成起始位,后续整帧数据就全乱了;第二是采样点位置:如果采样恰好落在bit翻转沿附近,数字电路里常见的亚稳态就会冒出来,这一位可能采到0也可能采到1,结果不可预测。

解决思路是把每个bit时间均分成16份,在bit的中间位置采样。中间的采样点离两端的跳变沿最远,最不容易受到边沿抖动的影响。这就是“16倍过采样”的来源。

具体来说,接收端会生成一个频率为波特率乘以16的采样tick。比如115200波特率,16倍采样频率就是1,843,200Hz。50MHz系统时钟下的分频系数是:

50_000_000 / (115200 × 16) = 27.12

取27。实际16个采样点对应432个系统时钟周期,而理论值是434.03个系统时钟,误差约0.47%。一帧10bit下来累计误差约4.7个系统时钟,小于一个采样间隔27个时钟,完全在容限范围内。这也是UART能靠约定波特率正常工作的底气——只要误差积累不到半个bit,接收端就能稳定采对每一位。

3.2 起始位检测是接收端的“起跑线”

接收端状态机的起点是检测RXD的下降沿。但是直接拿原始RXD信号检测有个问题:引脚上的毛刺会导致误触发。稳妥的做法是先把RXD打两拍同步到系统时钟域,然后用“前一拍为高、当前拍为低”来确认下降沿。打两拍本质上是做跨时钟域同步和亚稳态消除,RXD来自另一个异步系统,在进入本逻辑之前必须先经过这一道。

reg rxd_d1, rxd_d2; always @(posedge clk or negedge rst_n) begin if (~rst_n) begin rxd_d1 <= 1'b1; rxd_d2 <= 1'b1; end else begin rxd_d1 <= rxd; rxd_d2 <= rxd_d1; end end wire rxd_negedge = rxd_d2 & ~rxd_d1;

这个negedge信号就可以作为状态机从IDLE跳出去的触发条件。

但仅仅检测到下降沿还不够。如果这是一个毛刺,它撑不了多少时间就会回到高电平。所以比较稳健的接收机在检测到下降沿后,会延时到起始位的中间位置再采样一次RXD,确认确实是低电平,才认为是有效起始位,正式进入数据接收;如果此时已经是高电平,就当作毛刺忽略,回到IDLE重新等待。这一步是低成本抗干扰的关键,尤其是板子旁边有电机、继电器这些干扰源的时候,效果立竿见影。

3.3 接收状态机完整代码:采样点怎么对齐

接收状态机我命名为IDLE、CONFIRM、DATA、STOP四个状态。其中CONFIRM是很多人会忽略、但对稳定性至关重要的一个状态。

时序的关键在于:下降沿出现是起始位的起点,起始位中点在8个采样tick之后。CONFIRM阶段sample_cnt从0数到7,在sample_cnt为7时采样RXD,如果为低,就确认是有效起始位,然后sample_cnt清零重新计时。进入DATA后,从0数到15,在sample_cnt为15时采样第一个数据位的中点。这样从下降沿到第一次数据采样,一共经历了8 + 16 = 24个tick,正好对应从起始位起点到第一个数据位中点的时长。下面这个对齐逻辑请直接对着时序图看,比空口解释直观得多。

module uart_rx #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115_200 ) ( input wire clk, input wire rst_n, input wire rxd, output reg [7:0] rx_data, output reg rx_done, output reg frame_err ); localparam OV_TICK = CLK_FREQ / (BAUD_RATE * 16); // 过采样分频数 localparam IDLE = 2'd0, CONFIRM = 2'd1, DATA = 2'd2, STOP = 2'd3; reg [1:0] state; reg [3:0] sample_cnt; reg [2:0] bit_cnt; reg [7:0] data_buf; reg [7:0] ov_cnt; reg rxd_d1, rxd_d2; wire rxd_negedge = rxd_d2 & ~rxd_d1; wire ov_tick = (ov_cnt == OV_TICK - 1); // 输入同步 always @(posedge clk or negedge rst_n) begin if (~rst_n) begin rxd_d1 <= 1'b1; rxd_d2 <= 1'b1; end else begin rxd_d1 <= rxd; rxd_d2 <= rxd_d1; end end // 过采样tick生成 always @(posedge clk or negedge rst_n) begin if (~rst_n) ov_cnt <= 8'd0; else if (ov_cnt == OV_TICK - 1) ov_cnt <= 8'd0; else ov_cnt <= ov_cnt + 1'b1; end // 主状态机 always @(posedge clk or negedge rst_n) begin if (~rst_n) begin state <= IDLE; sample_cnt <= 4'd0; bit_cnt <= 3'd0; data_buf <= 8'd0; rx_data <= 8'd0; rx_done <= 1'b0; frame_err <= 1'b0; end else begin case (state) IDLE: begin rx_done <= 1'b0; if (rxd_negedge) begin state <= CONFIRM; sample_cnt <= 4'd0; end end CONFIRM: begin if (ov_tick) begin if (sample_cnt == 4'd7) begin if (~rxd_d2) begin state <= DATA; bit_cnt <= 3'd0; sample_cnt <= 4'd0; end else begin state <= IDLE; // 毛刺,忽略 sample_cnt <= 4'd0; end end else begin sample_cnt <= sample_cnt + 1'b1; end end end DATA: begin if (ov_tick) begin if (sample_cnt == 4'd15) begin data_buf[bit_cnt] <= rxd_d2; if (bit_cnt == 3'd7) begin state <= STOP; sample_cnt <= 4'd0; end else begin bit_cnt <= bit_cnt + 1'b1; sample_cnt <= 4'd0; end end else begin sample_cnt <= sample_cnt + 1'b1; end end end STOP: begin if (ov_tick) begin if (sample_cnt == 4'd15) begin state <= IDLE; rx_data <= data_buf; rx_done <= 1'b1; if (~rxd_d2) frame_err <= 1'b1; // 停止位应为高 end else begin sample_cnt <= sample_cnt + 1'b1; end end end endcase end end endmodule

注意:CONFIRM状态下sample_cnt数到7就采样并清零,DATA状态下数到15才采样并清零。这个“7”和“15”的差异不是随手写的,而是分别对应起始位中点和数据位中心。如果两个地方的采样计数基准没对齐,接收到的数据就会整体偏移,轻则边沿附近误码,重则整帧错位。

3.4 接收端的抗干扰升级:多数投票采样

上面代码是单点采样,即在每个bit的中心附近采一次。如果传输线缆比较长、环境电磁干扰明显,单点采样偶尔会翻车。这时候可以升级为多数投票:在bit中点的前一个采样点、中点、后一个采样点(比如sample_cnt为7、8、9时)各采一次,取三个值中数量多的那个作为该bit的最终值。

多数投票的逻辑量不大,但效果立竿见影,尤其适合RS232长线或者电机驱动器旁边这种干扰比较大的场景。平时调试用单点采样就够了,但心里要有这个升级方案。我实际项目中做过对比,在一条1米长的杜邦线上跑115200,单点采样偶尔出现误码,改成多数投票后连续发几千字节都没问题。

4. 顶层回环测试:跑通第一版收发链路

发送端和接收端单独写完,下一步就是拼起来。回环(loopback)测试是串口调试里最常用也最省事的验证方法:把FPGA的TX输出直接连回自己的RX输入。你在电脑串口助手里发的任意字符,FPGA接收后再原样发送回来。如果屏幕上回显的内容和你发的完全一致,说明收发链路正常工作。

4.1 板载回环和外部回环的区别

板载回环最简单:如果FPGA板上的USB转串口芯片已经和FPGA的UART引脚直连,那只需要在顶层代码里把uart_tx的txd赋给uart_rx的rxd,形成一个内部环路。好处是全程不需要飞线,代码里一行搞定:

wire uart_txd; wire uart_rx_loopback; assign uart_rx_loopback = uart_txd; // 内部回环 uart_tx u_tx ( .clk (clk), .rst_n (rst_n), .tx_start (tx_start), .tx_data (tx_data), .txd (uart_txd), .tx_busy (tx_busy) ); uart_rx u_rx ( .clk (clk), .rst_n (rst_n), .rxd (uart_rx_loopback), .rx_data (rx_data), .rx_done (rx_done), .frame_err (frame_err) );

但这里要提醒一句:内部回环测的是“FPGA内部逻辑”本身,比如状态机、时序、波特率分频有没有问题。它测不到IO引脚的电平转换、USB转串口芯片的驱动、接线接触这些环节。所以更接近真实链路的是外部回环:把TX和RX通过杜邦线在板外短接,或者直接连到电脑串口做双向通信。建议两者都做:先内部回环排除逻辑问题,再外部回环验证物理链路。

此外,顶层设计里通常还会加一个FIFO做缓冲。最简单的做法是收到rx_done后把rx_data写进一个深度适当的同步FIFO,发送端空闲时从FIFO取数据发送。这样即使上位机连续下发数据,只要FIFO深度足够,就不会因为发送端busy而丢字节。

4.2 仿真验证:先虚拟跑通再上板

写Verilog不上仿真,等于闭眼开车。至少要在仿真里看到过uart_txd和uart_rxd的波形,确认每个bit的宽度和位置都正确,再上板调试。

Testbench的思路不复杂:例化uart_tx和uart_rx,把txd直连到rxd,模拟一条回环链路;然后给一个tx_start脉冲和tx_data,跑一段足够长的时间,观察rx_done是否在一个正确的时间点拉高,rx_data内容是否等于tx_data。

`timescale 1ns / 1ps module tb_uart_loopback; reg clk = 1'b0; reg rst_n = 1'b0; reg tx_start = 1'b0; reg [7:0] tx_data = 8'h00; wire txd; wire rx_done; wire [7:0] rx_data; always #10 clk = ~clk; // 50MHz initial begin #100 rst_n = 1'b1; #100; tx_data = 8'h55; tx_start = 1'b1; #20 tx_start = 1'b0; #100000; // 等待约 10bit * 8.68us = 86.8us tx_data = 8'hAA; tx_start = 1'b1; #20 tx_start = 1'b0; #200000; $finish; end uart_tx u_tx ( .clk (clk), .rst_n (rst_n), .tx_start(tx_start), .tx_data (tx_data), .txd (txd), .tx_busy () ); uart_rx u_rx ( .clk (clk), .rst_n (rst_n), .rxd (txd), .rx_data (rx_data), .rx_done (rx_done), .frame_err() ); initial begin $monitor("time=%t rx_done=%b rx_data=%h", $time, rx_done, rx_data); end endmodule

仿真里可以重点观察几个节点:txd拉低的时间点是否在tx_start之后的一个周期左右;每个bit的宽度是否约为8.68us;rx_done拉高时rx_data是否等于刚才发送的字节。如果这些都验证通过,再上板调试,出问题的范围就小了一大截。

5. 板级调试:乱码、丢字节、第一帧丢失的完整排查方法

5.1 乱码的根源往往不只是波特率

乱码是最常见的串口故障,很多人第一反应就是“波特率不对”,但多数时候确实就是波特率不对——只不过要搞清楚是哪个环节的波特率不对。

第一种是PC串口助手上配的波特率和FPGA代码里的分频参数不一致。比如代码是115200,助手却选了9600,这种属于操作层面的错误,检查一下就能发现。

第二种是FPGA代码里的系统时钟参数和实际晶振不一致。这是最容易骗人的:板卡上晶振明明是27MHz,代码里的parameter CLK_FREQ却写成50_000_000,那么实际波特率就是115200 × 27 / 50,约62160bps。这种问题用示波器或逻辑分析仪看txd引脚波形才能迅速定位。

第三种是分频系数计算时边界条件写错。比如应该计数到BAUD_DIV - 1,却写成了等于BAUD_DIV,导致每个bit实际多了一个时钟周期。这种误差在短帧下可能不明显,但连续发几百个字节后采样点会逐渐漂移,最终误码。

排查乱码时,我的习惯顺序是:先确认两边的参数配置;再用逻辑分析仪或示波器抓txd的实际波形,量一下每个bit的宽度,和理论值对比;最后看接收端有没有连续误码的规律——如果固定错一个bit,大概率是采样点偏移,如果完全乱套,大概率是整体波特率错了。串口助手里看回显乱码时,可以试着降低波特率再发一次,如果降低后正常,那基本就是信号完整性的问题。

5.2 丢字节、第一帧丢失是怎么回事

“上位机发了5个字节,FPGA只回了4个”这种问题,十有八九出在发送启动逻辑上。最常见的情况是:上位机的数据连续到达,接收端在rx_done之后立刻去驱动发送,但发送模块还处在busy状态,新的启动信号就被忽略了。解决办法是给接收到的数据加一级缓冲。最简单的做法是寄存器缓冲:rx_done后把rx_data锁存到一个寄存器,同时给发送端产生一个启动脉冲;更健壮的做法是上FIFO。很多工程项目里UART接收端后面跟一个FIFO是标配,这样无论数据来得多快,只要FIFO深度足够,就不会丢。

第一帧丢失还有另一个常见原因:接收端上电后第一次检测下降沿时,采样计数器可能还处在随机状态。如果代码里IDLE状态下没有及时清零相关计数器,第一次进入CONFIRM或DATA时采样点的位置就是错的,导致第一帧的起始位没被正确确认,整个帧就被吞掉了。所以接收端所有计数器在进入IDLE、CONFIRM、DATA这些状态时都要有明确的对齐动作。我在仿真里遇到过这个问题,仿真器里寄存器初始值都是确定的0,所以暴露不出来,但上电后实际寄存器状态是随机的,必须靠复位逻辑和对齐清零来保证。

5.3 外部回环不通,先查接线和电平

如果外部回环不通,而内部回环一切正常,那问题基本出在物理层。重点检查这几项:

  • 杜邦线是否接触良好,TX和RX是否正确交叉连接。FPGA的TX要接到对端串口芯片的RXD,FPGA的RX要接到对端串口芯片的TXD,方向搞反是新手常见错误。
  • 两个设备之间是否共地,也就是GND要连在一起。UART是电平参考型协议,不共地的话双方的“高”和“低”是互相漂移的,轻则乱码,重则完全无法通信。
  • 如果走的是RS232电平,确认板卡串口芯片的供电和电荷泵电容是否正确。MAX3232这种芯片的外围电容接错,电平根本转换不出来,引脚上量到的就是奇怪的波形。
  • 波特率太高时,杜邦线过长会导致信号边沿严重畸变。几十厘米的杜邦线跑115200问题不大,但如果你用一米长的线还跑到921600,那就别怪它不稳定了。我建议长线场景尽量降到38400以下,或者换成带屏蔽的线缆。

5.4 用内嵌逻辑分析仪直接看采样点

最后推荐一个调试利器:Vivado的ILA和Quartus的SignalTap。这两个工具本质上是把一堆逻辑探针嵌到FPGA内部,实时抓取你想观察的信号波形,然后通过JTAG回传到电脑上看波形。

调试UART时我会把rxd、rx_done、sample_cnt、baud_cnt这些信号加进ILA,触发条件设为rx_done上升沿,抓一段接收数据的过程。从波形上可以直观地看到:起始位下降沿出现后,sample_cnt是否从预期的位置开始计数;每个数据bit采样时,sample_cnt是否精确落在bit中心附近;停止位采样时RXD的状态是什么。这些都是理论计算验证不了的实测证据,一旦发现偏差,能直接定位到是分频系数、状态跳转还是外部信号质量问题。

设置触发条件时有个小技巧:如果抓接收路径,以rx_done上升沿触发能稳定抓到完整的一帧;如果抓发送路径,以tx_start为触发能抓到发送的全过程。ILA触发深度默认不大,注意把采样深度设得足够覆盖一帧的时间,否则抓到的波形不完整,反而增加排查难度。

写在最后

UART这个模块代码量不大,但把它吃透的价值远超它本身。它背后涉及的异步握手、状态机设计、采样同步、跨时钟处理,几乎每个概念都能平移到更复杂的接口协议上。我自己后来再去做IIC、SPI、甚至MAC层协议,发现很多思路都是当年调UART时沉淀下来的。

最后分享一个小经验:不管代码写得多自信,上板调试前一定先做内部回环,再用外部回环,最后才接真实设备。每增加一个环节,排查问题的范围就收窄一层。很多人一调试就直接插上USB线、打开串口助手,结果收发不通,所有变量搅在一起,根本不知道从哪查起。按这个顺序来,一次成功的概率会高很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询