
做FPGA开发最常碰到的第一个“正儿八经的通信模块”八成是UART。我自己刚接触FPGA的时候跑完流水灯、按键消抖下一步就是想着怎么让板子和电脑说上话——串口打印“Hello FPGA”那感觉完全不一样。UARTUniversal Asynchronous Receiver/Transmitter通用异步收发器之所以成为经典入门项目不只是因为简单更因为它麻雀虽小五脏俱全异步协议、时序分析、状态机设计、跨时钟处理这些概念它全沾边。这篇文章我打算把FPGA实现UART串口通信这件事从头到尾拆开讲先从协议帧格式说起然后分别给出发送端、接收端的Verilog实现再做顶层回环测试最后把板级调试中常见的乱码、丢帧、采样点偏移这些坑也一并整理清楚。适合两类读者一类是刚入门FPGA、想找一个“能跑通、有深度”的练手项目的人另一类是已经在写代码但总是“仿真没问题、上板就乱码”的工程师。两种视角我都会覆盖到。1. UART协议说穿了就三件事电平、帧格式、波特率很多教程上来就让你写代码代码写完了波形仿真也通过了结果一接到真实设备上就出现各种莫名问题。原因很简单协议层面的细节没吃透代码只是“形似”而不是“神似”。所以先把协议这一层讲清楚后面排查问题才有理论依据。1.1 一帧数据到底长什么样UART是异步串行协议意思就是发送端和接收端之间没有一条共享时钟线。双方想协调节奏靠的只有两条事先约定好的波特率每秒传输多少bit以及数据线上的电平跳变。空闲状态下TX线保持高电平。要发一帧数据时先把线拉低一个bit时间——这就是起始位。接收端看到这个下降沿就知道“数据来了”。之后从LSB最低位到MSB最高位逐位发送数据位可选地跟一个校验位最后拉高一个bit以上作为停止位。以最常用的“115200-8-N-1”为例这一串参数的意思是波特率115200bps8位数据位无校验位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系统时钟比如50MHzrst_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到bit7STOP发送停止位输出高电平持续一个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 2d0, START 2d1, DATA 2d2, STOP 2d3; 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 16d0; bit_cnt 3d0; txd 1b1; data_buf 8d0; end else begin case (state) IDLE: begin txd 1b1; baud_cnt 16d0; bit_cnt 3d0; if (tx_start) begin data_buf tx_data; state START; end end START: begin txd 1b0; if (baud_cnt BAUD_DIV - 1) begin baud_cnt 16d0; state DATA; end else begin baud_cnt baud_cnt 1b1; end end DATA: begin txd data_buf[bit_cnt]; if (baud_cnt BAUD_DIV - 1) begin baud_cnt 16d0; if (bit_cnt 3d7) begin state STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin baud_cnt baud_cnt 1b1; end end STOP: begin txd 1b1; if (baud_cnt BAUD_DIV - 1) begin state IDLE; end else begin baud_cnt baud_cnt 1b1; 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 1b1; rxd_d2 1b1; 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 2d0, CONFIRM 2d1, DATA 2d2, STOP 2d3; 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 1b1; rxd_d2 1b1; 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 8d0; else if (ov_cnt OV_TICK - 1) ov_cnt 8d0; else ov_cnt ov_cnt 1b1; end // 主状态机 always (posedge clk or negedge rst_n) begin if (~rst_n) begin state IDLE; sample_cnt 4d0; bit_cnt 3d0; data_buf 8d0; rx_data 8d0; rx_done 1b0; frame_err 1b0; end else begin case (state) IDLE: begin rx_done 1b0; if (rxd_negedge) begin state CONFIRM; sample_cnt 4d0; end end CONFIRM: begin if (ov_tick) begin if (sample_cnt 4d7) begin if (~rxd_d2) begin state DATA; bit_cnt 3d0; sample_cnt 4d0; end else begin state IDLE; // 毛刺忽略 sample_cnt 4d0; end end else begin sample_cnt sample_cnt 1b1; end end end DATA: begin if (ov_tick) begin if (sample_cnt 4d15) begin data_buf[bit_cnt] rxd_d2; if (bit_cnt 3d7) begin state STOP; sample_cnt 4d0; end else begin bit_cnt bit_cnt 1b1; sample_cnt 4d0; end end else begin sample_cnt sample_cnt 1b1; end end end STOP: begin if (ov_tick) begin if (sample_cnt 4d15) begin state IDLE; rx_data data_buf; rx_done 1b1; if (~rxd_d2) frame_err 1b1; // 停止位应为高 end else begin sample_cnt sample_cnt 1b1; 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 1b0; reg rst_n 1b0; reg tx_start 1b0; reg [7:0] tx_data 8h00; wire txd; wire rx_done; wire [7:0] rx_data; always #10 clk ~clk; // 50MHz initial begin #100 rst_n 1b1; #100; tx_data 8h55; tx_start 1b1; #20 tx_start 1b0; #100000; // 等待约 10bit * 8.68us 86.8us tx_data 8hAA; tx_start 1b1; #20 tx_start 1b0; #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.68usrx_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要接到对端串口芯片的RXDFPGA的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线、打开串口助手结果收发不通所有变量搅在一起根本不知道从哪查起。按这个顺序来一次成功的概率会高很多。