UART协议与RTL设计实战:从帧结构到Verilog状态机
2026/9/16 9:09:10 网站建设 项目流程

1. 为什么我觉得UART值得专门花一周:先弄清楚它在通信协议里的位置

1.1 UART、USART、串口、RS232/485,这些称呼差点把我绕晕

很多刚接触嵌入式或FPGA的同学,看到UART、USART、串口、RS232、RS485这些词,第一反应是"这不都是串口吗"。但实际做设计时,这几个概念根本不在一个层面上。UART全称是Universal Asynchronous Receiver/Transmitter,它是一套异步串行通信协议,只管"怎么把一字节数据变成一根线上的高低电平变化,再在接收端还原出来"。RS232、RS485则是电气层标准,规定了电平幅度、阻抗、连接器形态。这就像UART是"说话的语法",RS232是"用多大的音量、什么样的麦克风",两者配合使用但绝对不能混为一谈。

USART则是Universal Synchronous/Asynchronous Receiver/Transmitter,比UART多了一个同步模式,像STM32上的USART既能跑异步串口,也能跑带时钟线的同步模式。实际项目中绝大多数时候我们用的是异步模式,也就是UART那一套。FPGA里自己写UART时,其实只需要处理异步逻辑,不需要关心RS232电平转换芯片怎么工作,那是板级硬件的事,但你需要知道外部会接一个类似MAX3232的芯片,把TTL电平转成RS232电平。

我在带新人时经常问一个问题:CPU和FPGA之间、传感器和MCU之间,为什么要用UART这种看似很老的协议?答案其实很朴素:它只需要一根发送线、一根接收线,再加一根地线,就能实现双向通信。相比SPI需要时钟线、I2C需要地址和应答机制,UART的实现简单到可以用几十行状态机写出来,调试也直观,逻辑分析仪一抓就能看到波形。正因为简单,它成了几乎所有芯片默认保留的调试接口,也是学习串行协议的绝佳入门题材。

1.2 调试口之外的隐形江湖:UART在芯片间通信中的真实角色

很多人以为UART只是开发板上的调试打印口,接个USB转串口模块看log用的。但真正做过产品的人会告诉你,UART在产品内部承担着大量看不见的通信任务。比如GPS模块、蓝牙模块、4G模组、指纹模组、激光雷达,很多都是通过UART和主控交互的。这些模块内部跑着复杂的协议栈,但对外暴露的物理接口非常朴素,常常就是TXD、RXD两根线。

这类场景有几个特点:波特率不高(常见9600、115200、460800),数据量不大,但对可靠性有要求。比如一个定位模块每秒输出一串NMEA语句,主控要用UART接收并解析;一个Wi-Fi模组通过AT指令控制,主控发"AT+CWJAP=...",模组回"OK"或"ERROR"。这种一问一答的交互模式,用UART再合适不过。

还有个容易被忽略的场景是Bootloader。很多MCU支持通过UART烧写固件,开机时检测特定引脚电平或接收特定字符,进入升级模式。这里就要求Bootloader里的UART收发逻辑足够健壮,波特率自动检测、超时处理都要考虑。FPGA项目中,UART也常被用做上位机与FPGA之间的低速控制通道,比如配置寄存器、触发采集、回传状态。可以说,只要做FPGA或嵌入式开发,UART就是那个"躲不掉的基础设施"。

所以我的观点是:花一周时间把UART的原理和RTL设计吃透,这笔投资非常划算。它不是冷门知识,而是串行通信的"最小可运行样本",理解了它,后面学SPI、I2C、甚至PCIe都会有很大帮助。

2. 协议层必须抠的细节:帧结构、波特率误差和采样时刻

2.1 一帧数据从空闲到停止,每一位都有它的职责

不写RTL的时候,很多人对UART帧格式的理解就是"起始位+数据位+停止位",看起来很简单。但一旦要自己设计RTL,这些位的行为细节就变得非常关键。标准UART一帧包括:空闲态(Idle)为高电平;起始位(Start bit)为低电平,持续一个bit时间;数据位(Data bits)通常是5到8位,LSB first,也就是说第一个发送的数据位是最低有效位;可选校验位(Parity bit);最后是停止位(Stop bit),高电平,持续1、1.5或2个bit时间。

为什么起始位必须是低电平?因为空闲时线路是高电平,接收端就是靠检测到"高到低的跳变"来判断一帧开始了。这个跳变是所有异步通信的同步锚点,没有它,收发双方就没有时间基准。数据位为什么是LSB first?这是历史习惯沿袭,早期电传打字机就是这么干的,协议定了就大家都遵守,设计RTL时只需要注意发送和接收的顺序就行。

停止位的作用很多人理解成"给接收端一点处理时间"。从协议角度讲,它保证了帧与帧之间有明确的分界,并且恢复高电平为下一帧的起始位跳变做准备。接收端在检测停止位时,如果发现应该是高电平的时间点采到低电平,就说明这帧数据有帧错误(Framing Error)。RTL里要不要检测这个错误,取决于你的应用。如果只是发给调试助手看,漏检问题不大;如果用在工业通信,帧错误必须上报。

还有一种容易忽略的情况:空闲态持续时间是任意的,接收端不能假设两帧之间间隔固定。所以设计接收状态机时,必须实现"任意时刻检测到起始位都能正确同步"。这个听起来简单,但初学者写接收逻辑时经常把时序搞死,比如要求"收到停止位后立即准备接收下一帧",结果两帧间隔很短时漏掉起始位。真正的做法是:每次采样到下降沿,重新开始同步过程。

2.2 波特率不是配个数字就完事:分频、误差和容忍度

波特率就是每秒传输的符号数,单位是bps。对UART来说,一bit的时间就是波特率的倒数。比如115200bps,一个bit大约是8.68微秒。假设系统时钟是50MHz,一个时钟周期20纳秒,那么每个bit需要434个时钟周期。这个分频值就是RTL里波特率发生器的核心参数。

但实际设计中没有那么理想。系统时钟不一定能整除波特率。比如50MHz要产生115200波特率,50_000_000 / 115200 = 434.0277,取整后要么用434,要么用435。我们用434的话,实际波特率是50_000_000 / 434 = 115207.37,误差只有0.006%,完全没问题。但如果时钟是某个奇怪频率,分频后误差可能接近甚至超过2%。

那误差多大才算可用?UART接收端每个bit通常会在中点附近采样,如果收发双方波特率误差累积到一定值,采样点就会偏出数据位的有效区间。理论上一帧10个bit(起始+8数据+停止),如果误差累计超过0.5个bit就会采错。所以通常建议总误差控制在2%以内。实际上很多UART接收机可以容忍3%甚至更高,但设计时保守一点,按2%以内来控制。

这里有个经验:不要只算理论误差,还要看模块本身的晶振精度。如果你用的是内部RC振荡器,误差可能本身就有1%~2%,这时候分频误差要尽量小。我在一个项目里用48MHz时钟产生1000000波特率,50个时钟一个bit,但接收对端是9600,完全没问题。反而是有些新手直接用50_000_000 / 9600 = 5208,实际波特率是9596,误差0.03%,这也行。最怕的是在需要长帧传输时,接收端采样点逐渐偏移,最后几个bit开始出错。

设计分频器时,我建议把分频参数做成可配置的parameter或寄存器,不要在代码里写死magic number。这样换波特率时不用改逻辑,只改顶层参数。还要注意分频计数器是"每个bit周期清零重计"还是"连续累加",这两种写法在发送和接收模块里都有讲究,后面章节会详细说。

2.3 接收端如果可以选采样点,为什么选中点附近

接收端最核心的问题:线上一会儿高一会儿低,你凭什么知道这一位是0还是1?答案是:在bit持续时间内,选一个相对稳定的时刻采样。最典型的做法是用16倍波特率时钟,在检测到起始位下降沿后,等8个过采样时钟,也就是接近bit中点,采样确认确实是低电平;之后每16个过采样时钟采一次数据位。为什么要选中点而不是开始或结束?因为信号跳变沿附近电平不稳定,加上噪声、线缆电容、地弹等因素,容易采到毛刺。中点恰好离两个跳变沿最远,是最佳观察窗口。

这里有一个很多人忽略的细节:起始位检测时,第一次检测到下降沿可能只是因为噪声。可靠的方式是检测到下降沿后,延迟8个过采样时钟,再采样一次。如果此时仍然是低电平,就确认是真正的起始位;如果已经恢复高电平,说明是毛刺,放弃本次同步。这个过程叫"起始位确认"。不做这个确认的接收机,在噪声环境下会频繁误触发。

采样点设计还与波特率误差相关。如果收发双方误差为1%,一帧10个bit累积误差最多0.1bit。采样点在中点附近,允许误差至少有0.4bit的裕量,所以中点采样可以容忍约4%的误差。反过来,如果你在bit开始后第3个过采样时钟就采样,那提前误差一累积就容易采到前一位的尾巴。所以几乎所有的UART接收机设计,都遵循"中点采样"原则。RTL实现时,我们还要考虑过采样时钟与系统时钟的关系,可以用系统时钟直接分频产生16倍波特率时钟,也可以用一个更高频时钟来计数采样。后者更常用,因为不需要真的生成一个分频时钟,只需要计数到相应位置打拍采样。

3. 发送端RTL设计:状态机、分频器和可综合代码实战

3.1 模块划分先把接口定死,后面才不会被自己坑

很多初学者写RTL喜欢一上来就写状态机,代码写到一半发现少了一个信号,又回去改接口。我的习惯是先画接口信号列表,把模块边界定清楚,再写内部逻辑。UART发送模块的接口其实非常固定,以FPGA设计为例,我一般这样规划:

  • 时钟clk和异步复位rst_n,复位低有效,这是几乎所有时序逻辑的标配。
  • 发送使能tx_start,高电平有效,一个时钟周期的脉冲,表示请求发送一字节。
  • 并行数据tx_data[7:0],在tx_start有效时被锁存。
  • 发送忙信号tx_busy,高电平表示发送模块正在发送,无法接收新的请求。
  • 串行输出tx,连接到外部引脚。

还有一个经常被忽略的信号:发送完成标志tx_done,在停止位发送完成后输出一个脉冲。它在FIFO设计中很有用,可以驱动下一个数据出队。假如你后面要把这个发送模块接一个FIFO,tx_busytx_done都必不可少。tx_busy用于阻止上游继续发起新请求,tx_done用于通知上游"可以送下一个了"。

在定接口时,我还会考虑是否要做波特率参数化。我倾向于把波特率分频值作为parameter,默认值根据目标时钟和波特率算好。比如50MHz时钟、115200波特率,分频值434。在顶层例化时可以覆盖parameter,这样模块可以复用到不同时钟下。

3.2 发送状态机的标准写法与退出条件

发送模块的状态机相对简单,通常只有四个状态:IDLE、START、DATA、STOP。下面这个Verilog代码是我在项目中常用的结构,为了方便阅读,省略了分频器的具体计数过程:

localparam IDLE = 3'd0; localparam START = 3'd1; localparam DATA = 3'd2; localparam STOP = 3'd3; reg [2:0] state, next_state; reg [3:0] bit_cnt; // 已发送/准备发送的数据位编号 0~7 reg [8:0] baud_cnt; // 波特率分频计数器 reg tx_reg; // tx 输出寄存器 reg tx_busy_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end always @(*) begin next_state = state; case (state) IDLE: if (tx_start) next_state = START; START: if (baud_cnt == DIV_PARAM - 1) next_state = DATA; DATA: if (baud_cnt == DIV_PARAM - 1 && bit_cnt == 7) next_state = STOP; STOP: if (baud_cnt == DIV_PARAM - 1) next_state = IDLE; endcase end

这个写法有几个关键点。第一,状态跳转都放在"分频计数器计满"的时刻,保证每个状态持续的时间正好是一个bit周期。第二,DATA状态内bit_cnt会从0递增到7,当发送完第8个数据位后,才跳转到STOP。注意tx_data在进入START时就要锁存,移位的逻辑在DATA状态每个bit周期内做一次。第三,STOP状态计满后直接回到IDLE,同时让tx_busy拉低、tx_done输出一个脉冲。

初学者容易犯的错误是:把tx_busy拉高放在tx_start有效的同一周期,但外部逻辑可能也在同一周期检查tx_busy,导致握手冲突。更稳妥的做法是用tx_busy在START状态拉高,并让它保持到STOP结束。如果上游是在tx_start有效后的下一个周期才看tx_busy,那基本没问题,但为了通用起见,可以用一个辅助信号在检测到tx_start时立即拉高tx_busy

3.3 波特率发生器的实现细节:连续分频还是每次清零

发送模块的波特率发生器有两种常见写法。第一种是"周期计数":每个bit周期内,baud_cnt从0计到DIV_PARAM-1,计满后产生一个脉冲,同时baud_cnt清零。这种写法思路简单,但要注意在跳转到新状态的第一个周期就把baud_cnt清零,否则每个状态会多一个周期的偏移。第二种是"连续累加溢出":baud_cnt持续累加,当达到DIV_PARAM时溢出并产生脉冲,不清零,只减去DIV_PARAM或直接回绕。这种写法在突发连续发送多个字节时更平滑,因为时钟计数不会在每个bit起点重新对齐,也就不会有累计的相位偏移。

我实际设计时,发送端两种写法都可以,但更推荐第一种,因为它直观、不容易出错。真正的差别体现在接收端:接收端需要一个"相对起始位下降沿的时序基准",每次检测到下降沿会重新对齐采样点,所以接收端计数器的清零时机很关键。发送端则是持续输出,不需要外部参考,周期清零反而更容易理解。

再讲一个分频参数的小坑。如果DIV_PARAM比较小,比如7、8这样,那么baud_cnt位宽可以很小;但如果是50MHz时钟、9600波特率,DIV_PARAM约5208,需要13位计数器。不要为了省寄存器把位宽算错,否则计数器一满就出问题。另外,分频参数的取整会影响实际波特率,前面已经说过,一定要在模块注释里标出计算公式,方便后续维护。

发送数据移位时,我通常用这样的方式:在START状态把tx_data拷贝到一个移位寄存器shift_reg,之后每个bit周期结束前shift_reg <= {1'b0, shift_reg[7:1]},同时把最低位赋给tx_reg。这就是LSB first的发送顺序。停止位阶段,tx_reg保持高电平即可。这样做的好处是,数据位切换时刻与分频脉冲对齐,不会出现额外延迟。

4. 接收端RTL设计:起始位检测、三倍采样与数据拼装

4.1 异步输入的同步处理:两级触发器只是第一步

接收端面对的第一个难题是:外部输入的rx信号与本地时钟完全异步,可能在任何时刻变化。如果直接把这个信号接入时序逻辑,非常容易产生亚稳态,导致状态机跑飞。标准做法是用两级触发器同步,消除亚稳态传播。

reg rx_r1, rx_r2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_r1 <= 1'b1; rx_r2 <= 1'b1; end else begin rx_r1 <= rx; rx_r2 <= rx_r1; end end

这里要注意:两级触发器只能把亚稳态发生的概率降到很低,并不能完全消除。更稳妥的做法是,在同步之后再做边沿检测,并且用采样窗口判断。比如检测到下降沿后,不要立刻确认,而是等半个bit周期再采一次,确认是低电平。这个操作本质上又加了一道滤波,有效降低毛刺误触发。

很多人以为同步完就能直接边沿检测,完全没问题。但如果你把rx_r1和rx_r2直接拿来触发状态机,并且在一段噪声很大的线路上运行,很容易误判。我见过一个案例,接收端频繁出现帧错误,最后发现是RX引脚上的干扰导致同步器输出出现毛刺,边沿检测逻辑触发了假的起始位。后来在检测到下降沿后追加了一个中点采样确认,问题就消失了。

另外,不要把同步后的电平用于产生时钟。有些初学者想用rx信号作为时钟来采数据,这在FPGA里是大忌,会产生门控时钟,时序分析很难收敛。正确做法永远是:rx信号只作为普通数据输入,内部用同一个系统时钟做所有时序操作。

4.2 起始位检测的时机:怎么避免把噪声当起始位

起始位检测逻辑其实是一个"下降沿检测 + 电平确认"的组合。先看下降沿:

wire rx_neg = rx_r2 & ~rx_r1; // 检测 rx_r2 为高、rx_r1 为低

这个rx_neg信号只在同步后的rx从高变低的那一拍拉高一个时钟周期。如果没有任何防抖措施,这个信号一出现,接收状态机就会从IDLE跳到确认状态。问题是,噪声也可能造成一个窄的低脉冲,让rx_neg误以为来了起始位。所以更可靠的做法是:检测到下降沿后,启动一个计数器,计数到半个bit周期(比如16倍波特率时钟下的第8拍),此时再次采样同步后的rx信号,如果仍然为低,才确认是起始位。

这种设计本质上是把起始位检测拆成了两个阶段:触发阶段和确认阶段。触发阶段只负责捕捉跳变,确认阶段负责排除毛刺。在确认阶段,如果发现rx已经变高,就放弃这次触发,回IDLE继续等待。注意,确认阶段消耗的时间不能计入后续数据位采样,也就是说,确认成功后,计数器需要从确认那一刻开始重新计数,再经过一个完整的bit周期才采第一位数据。如果这里处理不好,会把起始位当成第一个数据位,导致整帧错位。

学过通信的人可能会说,起始位确认其实就是在做"位同步"。没错,UART没有独立的时钟线,它必须靠起始位下降沿来重新同步收发双方。只要做好这一步,后面的数据位采样就有了可靠的时基。

4.3 用16倍波特率时钟采样,数据处理节奏如何安排

前面提到了16倍波特率过采样,这是非常经典的UART接收策略。系统时钟不一定要真的等于16倍波特率,而是用一个更高频时钟,按计数方式模拟出"每bit采16个点"的效果。假设系统时钟50MHz,波特率115200,那么一个bit是434个时钟周期,接近16个过采样点,实际上每个过采样点约27个系统时钟周期。你不需要生成一个真正频率为1.8432MHz的时钟,只需要按这个节奏在特定的计数位置打拍采样即可。

设计状态机时,接收模块的状态可以比发送更细:

localparam IDLE = 3'd0; localparam START = 3'd1; // 起始位确认 localparam DATA = 3'd2; // 数据位采样 localparam STOP = 3'd3; // 停止位采样 localparam ERROR = 3'd4; // 可选错误状态

进入DATA状态后,bit_cnt从0开始,每采完一个bit且确认不在停止位阶段时递增。采样点怎么定?如果采用过采样计数器oversample_cnt从0到15循环,那么起始位确认后,再过8个过采样点(即接近起始位中点)确认低电平;随后每个bit在当前bit的中点,即oversample_cnt == 7或8时,采样一次。实现上可以用oversample_cnt寄存器,每到该值就采一次rx_r2,然后清零或继续计数。为了避免因分频误差导致采样点漂移,实际代码里最好让oversample_cnt在每个bit周期的0时刻清零,这样每次采样点都相对bit起点对齐。

这里有一个性能优化点:不要在每个system clock周期都做比较判断,可以把采样点做成一个使能信号sample_en。当sample_en为高时,才把rx_r2的值存入数据移位寄存器rx_shift_reg <= {rx_shift_reg[6:0], rx_r2}。这样既保证采样时刻正确,又减少组合逻辑负担。最后,当bit_cnt计数到7且sample_en有效时,一字节拼装完成,把rx_shift_reg输出到rx_data,同时拉高rx_done脉冲。

停止位阶段同样要在中点采样,如果采到低电平,可以打一个framing_error标志。是否需要这个标志取决于应用,但我觉得作为一个完整的UART IP,最好加上,方便上层排查问题。特别强调:rx_done脉冲必须与数据对齐,不能早一个周期也不能晚一个周期。仿真时最容易查的就是这个时序。

5. 仿真与上板验证:Testbench写法、波形排查和串口助手配合

5.1 一个能用的UART Testbench长什么样

有的同学写完RTL急着上板,接上串口助手发现乱码,然后在板子上折腾半天。我的建议是先在仿真环境里把收发逻辑验证清楚,再上板。Testbench不需要太复杂,但一定要覆盖正常发送、连续发送、接收错误这几个场景。

发送模块的Testbench,核心是模拟上游给出tx_starttx_data,然后观察tx波形是否符合UART时序。我一般会在Testbench里写一个"参考发送模型":用#(BIT_TIME)时钟延迟和$display打印发送出的每一位,同时解码RTL输出的tx信号,比较两者是否一致。这样做虽然代码量多一点,但可以在仿真一开始就抓住位序问题,而不用等板子反馈。

接收模块的Testbench更关键。你需要用task产生一串波形,模拟一个外部UART发送器往rx脚上发送数据。比如可以这样写:

task send_byte(input [7:0] data); integer i; begin rx = 1'b1; #(CLK_PERIOD*DIV_PARAM); rx = 1'b0; // start bit #(CLK_PERIOD*DIV_PARAM); for (i = 0; i < 8; i = i + 1) begin rx = data[i]; #(CLK_PERIOD*DIV_PARAM); end rx = 1'b1; // stop bit #(CLK_PERIOD*DIV_PARAM); end endtask

这里用DIV_PARAM来模拟与实际分频器一致的波特率,可以让Testbench在参数改变时自动适配。仿真时还要故意加一点噪声,比如在起始位前后插入很窄的低脉冲,测试你的起始位确认逻辑是否有效。很多人做验证只测理想波形,结果上板就被干扰打垮,问题就在于仿真用例没有覆盖噪声场景。

注意Verilog仿真中的#延迟是仿真用的,不可综合,Testbench和RTL代码要分文件管理。综合时工程里只添加RTL文件,不要混入Testbench。有些新手把Testbench一起拿来综合,报出一堆错误,其实就是文件列表没分清楚。

5.2 波形图上最容易看漏的问题:启动瞬间、连续发送、停止位

仿真通过后,还要养成"看波形"的好习惯。我列出几个特别容易看漏的点。

第一,发送模块启动瞬间。当tx_start有效后,观察tx是否在一个bit周期后开始拉低进入起始位。如果tx在tx_start有效的那一拍就变了,说明状态机跳转没有等待分频计数,时序错了。正确波形应该是:tx_start高电平,然后保持高电平直到起始位开始;起始位持续一个完整bit时间;起始位结束后进入数据位,LSB first。你可以把光标放在起始位下降沿,测量到第一个数据位翻转的时间差,应该正好是一个bit。

第二,连续发送多个字节。用Testbench连续发0x55、0xAA、0x00、0xFF这类数据,观察帧与帧之间是否有足够的停止位恢复高电平时间。如果你的发送状态机在STOP结束后的下一拍就允许新的tx_start,而外部连续发起请求,要看是否会出现两帧粘连。好的设计应该保证停止位完整输出,并且在STOP结束输出tx_done。如果tx_done与停止位结束没有对齐,外部逻辑可能会提前发起下一帧,导致发送位序错误。

第三,接收端起始位确认和采样点。在接收波形里,把rx_negoversample_cntsample_enrx_shift_reg一起显示。检查是否在起始位中点位置能采到低电平,在第一位数据的中点能采到正确的数据值。如果采样点偏了,通常是因为状态机进入DATA的时机晚了或早了,需要调整确认阶段的计数。

还有一个非常隐蔽的问题:停止位之后,如果线路上出现一个短促的低电平毛刺,接收状态机是否会误以为新的起始位开始?好的接收机在停止位结束后,回到IDLE时应该重新启动边沿检测,下一次下降沿确实会被视为新帧。但如果毛刺恰好出现在停止位末端,可能被当成下一帧的起始位,导致错帧。这个问题在电平噪声较大的场合几乎必然出现,所以我才反复强调起始位确认的重要性。

5.3 上板调试时遇到乱码,先按这个顺序排查

上板之后最常见的现象是:发送正常,但电脑串口助手收到的全是乱码;或者FPGA收不到数据。乱码的排查顺序,我建议按下面几步来,不要一上来就怀疑代码。

第一步,检查接线和电平。确认UART_TX对UART_RX,UART_RX对UART_TX,地线必须共地。如果TX和RX接反,通常是什么都收不到,或者收到完全无意义的字节。还要确认外部芯片的电平转换方向,比如RS232电平转换芯片的方向控制引脚是否配置正确。

第二步,检查波特率是否匹配。这是乱码最常见的原因。FPGA实际输出的波特率与上位机设置的波特率误差超过2%就会开始出错。可以用示波器或逻辑分析仪测量TX引脚上起始位低脉冲的宽度,换算成实际波特率。如果没有示波器,可以先用一个固定频率的输出翻转GPIO,辅助验证系统时钟频率是否和预期一致。内部晶振不准是大概率原因。

第三步,检查数据位长度、校验位、停止位设置。UART协议里,数据位可以是5~8位,校验位可有可无,停止位有1位或2位。如果你的RTL设计默认8N1(8数据位、无校验、1停止位),而上位机设置成7E1,那必然乱码。设计RTL时,建议把数据位长度和校验使能做成参数,方便适配不同模块。

第四步,用回环测试定位问题。把FPGA的TX直接短接到自己的RX,然后用串口助手发送一个字节,看是否原样返回。如果回环都错,问题大概率在FPGA内部逻辑;如果回环正确,但和外部设备通信乱码,那就是外部设备的协议参数或接线问题。这个手段能快速缩小范围,值得养成习惯。

第五步,检查干扰。如果波特率、参数都正确,但还是偶发乱码,多半是信号完整性问题。线缆过长、地线阻抗不均、电源纹波大都会导致UART信号变形。这种场景下,先把波特率降到9600,看是否明显改善。降低波特率后比特时间变长,采样窗口变大,抗干扰能力增强,能帮你判断是不是干扰问题。

6. 一周自学路线总结:每天该练什么,避坑清单

6.1 前四天基础,后三天强化:日程安排参考

既然标题说"一周带你吃透",我根据自己的带人经验,给一套可执行的一周学习安排。这套路线默认你已经有Verilog基础,知道怎么写状态机、怎么跑仿真,但对UART协议还没有完整认识。

第一天,只做协议功课,不写代码。把UART帧格式、波特率、采样原理弄明白,用逻辑分析仪或示波器抓一个真实的串口波形,手动标注起始位、数据位、停止位。如果没有硬件,可以在仿真里用理想的发送模型生成波形练习。这一天的目标是:看到波形图,能一眼说出每一位是什么。

第二天,写发送模块。从接口定义开始,然后实现波特率发生器和发送状态机。仿真验证发送0x55,观察波形是否出现"低-低-高-低-高-低-高-低-高-高"这样的位序列。0x55二进制是01010101,LSB first发送时线上的序列很有规律,非常适合目测验证。

第三天,写接收模块。先实现两级同步、下降沿检测、起始位确认,再实现数据采样和拼装。用Testbench的发送task回环测试:自己发一个字节,接收模块能正确收到。这一天最关键是采样点调试,如果收不到,多半是计数器时序偏了。

第四天,把发送和接收模块打包成顶层,加入回环模式,用仿真验证连续发送多字节,然后上板接串口助手做回环测试。目标:电脑发什么,FPGA回什么。

第五天到第七天,开始做扩展。比如给UART加一个小的FIFO,发送端支持连续发送一串数据;或者做一个波特率自动检测模块,接收端先测量起始位宽度,自动适配波特率。这些扩展虽然工作量不大,但能让你真正掌握UART在不同场景下的边界条件。

按这个路线来,绝大多数人一周内都能把UART收发逻辑跑通。关键是第一天的基础不能省,很多人跳过协议直接写RTL,后面遇到各种看似诡异的问题,实际上都是协议理解不到位。

6.2 我见过的初学者高频错误

写UART RTL时,有几个错误出现频率非常高,我干脆列成一个表,方便对照自查。

错误场景现象根因解决办法
接收端收到错误数据数据位顺序不对没有按LSB first移位移位时把最低位放在第一位,采样结果从bit0开始
发送间隔多出一个bit帧与帧之间有空隙,波特率偏慢分频计数清零时机不对状态跳转当拍统一清零baud_cnt
偶发乱码外部噪声引起误触发缺少起始位确认检测下降沿后过半个bit再确认低电平
上板完全不通信号输入未同步直接使用外部rx作为时钟用两级触发器同步,再用边沿检测
发送tx_start没反应状态机卡在某个状态缺少IDLE回到条件,或tx_busy逻辑错误检查状态机所有转移条件是否完备
接收停止位后立即误触发停止位后的毛刺被当成起始位没有对停止位后半段做滤波确认停止位电平,回到IDLE后重新边沿检测

还有一个很微妙的错误:发送模块在IDLE状态时,tx输出必须保持高电平。有些新手在复位后把tx默认置为0,结果一上电电脑就收到一堆0xFF或乱码。复位后txrx的空闲电平都要处理为高,这是UART协议的基本要求。

数据拼装时的位序问题也值得多说一句。接收端rx_shift_reg在采样第一个数据位时存入的是LSB,所以如果最后直接输出rx_shift_reg,它就是正确顺序。但有些同学喜欢用{rx_shift_reg[6:0], rx_r2}的方式拼接,第一个采到的值会逐渐移到最高位,最后输出时反而需要反转位序。这个问题在做协议对接时特别容易踩,建议在RTL内部统一好"采一位拼一位到低位"的习惯。

6.3 下一步扩展:FIFO、流控和收发一体

一周之后,如果你想继续深入,我建议往三个方向扩展,每个方向都能学到新东西。

第一个方向是加FIFO。很多UART应用里,外部输入数据不是按字节间隔来的,可能一会儿突发几十个字节。如果接收模块每收完一字节就产生rx_done脉冲,但CPU或FPGA内部逻辑来不及读取,数据就会丢。加一个异步FIFO或同步FIFO,先把接收到的字节缓存起来,再由顶层模块按需读取,这是最典型的UART IP设计。你需要学习FIFO的空满标志、读写指针同步、FIFO深度怎么根据最大突发长度计算。

第二个方向是流控。硬件流控用RTS/CTS两根线,接收端在缓冲区快满时拉低RTS,通知对端暂停发送。实现流控需要把接收模块的状态信息和FIFO的空满标志联动起来,发送端要检测CTS信号,暂停时状态机停在当前位或当前字节的边界。这个功能在工业通信里很常用,也能加深你对"背压"机制的理解。

第三个方向是收发一体。把发送模块和接收模块合并,共享波特率发生器或状态机资源,再加上一个小的寄存器组,做成一个可以通过UART读写内部寄存器的通用接口。这基本就是一个微型外设的雏形了,做出来之后你会发现,很多芯片的寄存器配置接口就是这么实现的。

这三个方向做完,你不仅掌握了UART,对FIFO设计、跨时钟域、流控协议、寄存器读写都有了一次完整实践。这些能力在FPGA和IC设计岗位中都很看重,而且它们是相通的,后面学SPI、I2C、AXI等协议时会轻松很多。按照这套路线坚持一周,每天花三四个小时,周末再集中调试,我相信你能把UART从协议到RTL实现吃得比较透。最后再送一条个人经验:所有串行协议学习的核心都是"采样点",抓住了采样点,你就抓住了异步通信的命脉。

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

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

立即咨询