当前位置:首页 > 新闻资讯 > FPGA之家动态 >

FPGA实现UART串口通信:协议原理、Verilog代码与工程调试全解析

时间:2026-09-14      来源:FPGA_UCY 关于我们 0

1. 项目概述与背景

UART串口通信FPGA实现,一直是FPGA入门到进阶绕不开的基础模块。别小看这个看似古老的全双工异步串行协议,它在工业控制、嵌入式系统调试、传感器数据采集、通信协议转换等场景中承担着最底层的"最后一公里"传输职责。即便现在有USB、PCIe、千兆以太网这些高速接口,UART凭借极低的资源占用、超简单的连接方式和不依赖共享时钟的特性,依然是FPGA开发者必须掌握的技能之一。

先说这个项目的定位:我用Vivado/Quartus环境下以Verilog HDL为主,在Xilinx 7系列或者国产高云、紫光等任意主流FPGA上实现一个完整的UART收发器,包含波特率配置、发送FIFO、接收超时处理、RS485方向切换等实用功能。如果你需要和STM32、PC上位机、各类传感器模组通信,这个模块可以直接作为IP核嵌入你的系统,也可以作为你学习FPGA时序设计的经典训练项目——它能串起跨时钟域处理、状态机设计、同步器、FIFO缓冲等一大堆核心知识点。整篇博客我会把设计思路、代码结构、仿真验证方法、下板调试技巧全部拆开来讲,适合刚入门想系统跑通一个通信外设的FPGA学习者,也适合已经在做项目但想优化现有UART模块的工程师参考。

这里有必要先明确一个关键认知:FPGA实现UART和单片机实现UART的思维完全不同。单片机上你调用库函数或者配置寄存器,底层硬件已经帮你完成波特率产生、起始位检测、数据抽样、奇偶校验等全部工作。FPGA上这些功能全部要用数字逻辑自己搭出来,等于把一颗串口控制器的内部电路用可编程逻辑重新画一遍。换句话说,写好UART模块,你对串口协议的理解会比单纯用单片机调库深刻得多。

举个例子说明这个差异:STM32的HAL库设置一个串口只需要一个函数,而FPGA实现UART需要明确回答几个问题——波特率时钟怎么从系统时钟分频得到?接收端如何确定采样点避开数据跳变沿?发送端状态机怎么保证帧格式的正确时序?这些"为什么"一旦吃透,后面写I2C、SPI甚至更复杂的以太网MAC都会豁然开朗。这也是我强烈建议初学者把UART作为第一个通信协议来练手的原因。

从应用场景来说,这个项目覆盖面非常广:调试串口(打印FPGA内部状态)、与上位机联动的数据采集系统、PLC和工控面板的Modbus通讯、超低功耗传感节点的数据上报、甚至航天级设备里的板间通信备份链路,到处都有它的身影。热词里出现的"stm32h743和fpga实现fmc通信""fpga pcie"这类字眼说明很多人在搞高速大带宽的场景,但高速链路的调试和启动阶段,UART依然是兜底的生命线。项目本身不复杂,但延伸出的工程细节非常值得深挖。

2. UART协议底层原理与关键参数选择 2.1 帧格式与电平标准

先理清最基础的东西——UART协议到底长什么样。一帧数据由空闲位、起始位、数据位、校验位(可选)、停止位组成:空闲时总线保持在高电平,发送端先将总线拉低一个位时间作为起始位,然后依次发送LSB优先的数据位,最后拉高一个位时间作为停止位。这样一个完整的帧就定义了一个字符的传输。

关键是理解"异步"二字:收发双方没有共享时钟,接收端只靠检测起始位的下降沿来对齐字节边界。这就带来两个核心设计约束:一是双方必须约定好相同的波特率,二是接收端采样时刻通常要取在每个数据位的中间点附近,避免采到信号跳变沿导致误判。实际操作中,接收端一般把系统时钟通过分频产生一个比波特率高16倍的采样时钟,在检测到起始位下降沿后,在数据位的第8个采样点读取电平值,正处在数据位正中间,容错能力最强。

电平标准这块特别容易踩坑。FPGA的IO通常输出3.3V或1.8V的LVCMOS电平,这是所谓的TTL UART电平。而PC或工控机的DB9串口走的是RS232电平,逻辑1对应-3V到-15V,逻辑0对应+3V到+15V,两者电平和逻辑极性完全不同,直接连接会烧毁接口芯片。所以FPGA开发板上通常会集成电平转换芯片(如MAX3232),或者用USB转UART芯片(如FT232、CP2102)转发给上位机。热词里的"ttl uart modbus 串口""rs232串口通信""uart电平转换电路3.3 1.8"都指向这个关键点。如果做3.3V和1.8V FPGA之间的板级互连,还需要关注IO bank的电压域匹配,必要时加上电平转换芯片,比如我们项目里在1.8V的bank上挂UART,就需要特别注意这是否属于bank501这类高密度HP bank,HP bank对电压兼容性更敏感。

2.2 波特率计算与分频误差分析

波特率生成是UART设计中最不该马虎的参数计算。假定系统时钟频率为50MHz,我们需要产生115200bps的波特率。所谓波特率生成,本质是做整数分频,让分频后的时钟尽可能接近目标频率。

分频步长的计算方式是:分频系数 = 系统时钟频率 ÷ (16 × 目标波特率),这里乘以16是因为接收端需要16倍过采样。代入数值就是 50,000,000 ÷ (16 × 115200) ≈ 27.1267。由于分频计数器只能取整数值,我们取27,此时实际过采样时钟频率 = 50MHz ÷ 27 ≈ 1.85185MHz,对应的实际波特率 = 1.85185MHz ÷ 16 ≈ 115740bps。和标准115200相比误差约为0.47%。按照UART协议容错经验值,收发双方波特率误差不超过2%一般都能正常工作,0.47%完全在可接受范围内。但你如果把50MHz换成33.333MHz或者80MHz这类常见晶振频率,就需要逐个算一遍。我最开始做实验时用了一个12MHz的板载晶振,想跑115200波特率,得到的分频系数27和上面50MHz的结果数字上刚好接近,但误差曲线完全不同,测试时发现接收偶尔错位,后来统一换成了50MHz系统时钟才稳定,这块后面会细说。

如果我们追求高精度小误差,可以在计数器里做小数分频(比如用累加器实现"分频系数一会儿是27一会儿是28"的混频效果),从而把平均频率做得更准。例如计数器在27分频和28分频之间交替,统计意义上实际波特率和理想波特率的误差可以降到0.02%以内。这意味着即使系统时钟本身不够"整",我也能保证长时间大数据量传输时误差不累积。实际项目里我用这种M/N混合分频法在80MHz时钟下实现了精确的921600波特率,帧错误率远低于常规整数分频方案。

下表给出常见系统时钟、常用波特率和推荐分频参数,方便大家直接"抄作业":

系统时钟 目标波特率 理想分频系数(÷16采样) 整数分频 实际波特率 误差

50MHz

9600

325.52

325

9615

0.16%

50MHz

115200

27.13

27

115740

0.47%

50MHz

921600

3.39

1041667

13%

80MHz

115200

43.4

43

116279

0.94%

80MHz

921600

5.43

1000000

8.5%

注意最后两行说明一个重要现象:波特率越高,整数分频的分辨率越差。921600波特率下每个数据位只有约1.085微秒,50MHz下理想分频系数只有3.39,取整数3后误差高达13%,这种误差下通信直接不可用。所以高速UART要么用M/N混合分频法,要么干脆换用更高的系统时钟(比如100MHz以上),要么就放弃过高波特率,老老实实用115200。这是项目选型时最容易忽略的一个坑,我见过不止一个工程师在这个问题上耗掉一整天。

2.3 为什么必须16倍过采样

初学者经常问一个问题:既然波特率已经约定好了,为什么接收端不能像发送端一样直接用波特率时钟去采样?原因是异步通信没有相位对齐机制。检测到起始位下降沿的瞬间,本地"位时钟"和发送端"位时钟"可能是任意相位关系,连续采样时相位误差会不断累积。通过16倍过采样,我们可以在起始位下降沿到来后等待8个采样时钟周期(正好是半个位周期),就能落在数据位中央区域,然后每16个采样时钟周期采一次数据,这样即使波特率有一点偏差,只要一个字节传输(10-11个位周期)内累积误差不超过半个位周期,就能正确恢复数据。

从实现角度来看,16倍过采样还带来一个额外好处——可以用连续三个采样点做多数表决。例如在采样点采集第7、8、9三个点的电平,取多数作为当前位的值,能够滤除窄毛刺干扰。很多商用UART IP核就是这么做的,代码不复杂但抗干扰性能会好不少。考虑到FPGA的LUT资源非常便宜,这种"以资源换可靠性"的做法在工业级应用中非常常见。后面实操部分的代码我会顺带给出多数表决的写法。

2.4 协议选型的拓展思维

理解完UART基础,不妨把视野拉高一点。热词里出现了"usart、uart、i2c、spi区别"、"iic,spi,usart,uart,can特点",这实际上是一个很好的横向思维训练。和I2C、SPI相比,UART的优势是只需要两根数据线、支持全双工、协议简单;缺点是速度上限低(通常几Mbps)、多设备组网天生不擅长(RS485半双工可以多节点但需要方向控制)。CAN则是为工业现场总线而生,抗干扰性和多主通信能力远强于UART,但实现复杂度也高一个量级。

FPGA选型时往往是多种总线并存:用UART做调试和低速外设连接,用SPI接ADC/DAC和Flash,用I2C接EEPROM和一些传感器,用CAN接工业总线。不同的总线不是互相替代的关系,而是各司其职。这也是为什么网上有大量"UART与SPI/I2C区别"这类对比文章,本质上是帮助工程师在做系统架构时做出合理决策。我在项目里的习惯是:凡是CPU侧如ARM、RISC-V需要低速通信的场合,优先UART,因为它最省引脚、最简单、兼容性最好,而且有大量现成工具链;凡是板内高速采集场合,绝不拿UART硬扛,直接上SPI或LVDS并行总线。

3. FPGA实现UART的模块架构设计 3.1 顶层模块划分

动手写代码前先画好架构草图,这是所有FPGA项目的成功前提。一个完整的UART模块至少包含五个部分:波特率发生器、发送端状态机、接收端状态机、发送FIFO和接收FIFO(可选但强烈建议)、顶层wrapper。顶层wrapper负责对外提供简单的AXI-Lite或APB接口(便于挂到片上总线),对内例化各个子模块并把它们互相连接起来。

在没有FIFO的最小版本里,对外接口可以简化为:发送端有一个输入信号tx_data和一个发送使能信号tx_en;接收端有一个输出信号rx_data和一个数据有效标志rx_valid,再加上系统时钟clk、复位rst_n、串行发送脚tx、串行接收脚rx。这个最小版本足以让你跑通收发回环测试,但为了工程实用性,我更推荐直接加上FIFO——它能把异步时钟域的问题提前暴露出来,也为后续集成到DMA引擎或总线矩阵打好基础。

模块划分的好处是隔离变化:波特率生成相关逻辑集中在一个文件里,改时钟频率或波特率时只动这一个文件;串行数据抓取逻辑集中在接收状态机里,调试时不需要关心FIFO内部实现细节。这种"高内聚低耦合"的设计思想对FPGA工程同样成立。

3.2 波特率发生器的具体实现

波特率发生器本质是一个简单的模N计数器。我们把系统时钟clk_50m作为基准,照上一节计算的参数设定计数目标。注意异步复位时计数器要清零,计数到div_cnt - 1时输出一个单周期脉冲bclk_x16,这个脉冲同时作为接收端16倍过采样时钟和发送端位时钟生成的基准脉冲。

额外拿一个3比特计数器(或者直接再加一个计数器)对bclk_x16再做16分频,就得到位时钟bclk。发送端状态机以bclk为节拍,依次拉低tx线输出起始位、移位输出8个数据位、拉高输出停止位。接收端不走同样路径:它用bclk_x16采样rx线,检测到下降沿后进行位同步,再以16倍时钟间隔恢复数据位。

实际工程中需要把波特率参数设置成可配置状态,比如用一组跳线或者寄存器写入所需的波特率标号(0代表9600,1代表115200,等等)。这样固件侧不用改FPGA代码就能适配不同的外设速度。具体到状态机设计中,分频计数值必须和波特率寄存器的值同步生效,避免传输中途切换波特率造成错帧。我见过有些偷懒的写法直接用一个可综合函数计算计数值,这种做法虽然方便,但容易产生庞大的组合逻辑链,导致时序收敛困难。推荐做法是把常用的波特率对应计数值常量都写到localparam里,用一个case语句选通。

下面给出一段基本的波特率生成Verilog示例,直接可用:

module baud_gen #(
    parameter CLK_FREQ = 50_000_000,
    parameter BAUD_RATE = 115200
)(
    input wire clk,
    input wire rst_n,
    output reg bclk_x16_en   // 高有效单周期脉冲
);
localparam integer DIV_CNT = CLK_FREQ / (16 * BAUD_RATE);
reg [15:0] cnt = 16'd0;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt <= 16'd0;
        bclk_x16_en <= 1'b0;
    end else begin
        if (cnt == DIV_CNT - 1) begin
            cnt <= 16'd0;
            bclk_x16_en <= 1'b1;
        end else begin
            cnt <= cnt + 1'b1;
            bclk_x16_en <= 1'b0;
        end
    end
end
endmodule

这段代码综合后就是1个16位计数器和比较器,资源占用可以忽略不计。如果你需要16倍过采样时钟连续输出(而不是脉冲使能信号),可以把bclk_x16_en改成连续时钟输出,但那样会引入门控时钟,增加时钟树综合的负担,一般不建议这么干。用单周期脉冲使能是FPGA设计的常见技巧,好处是系统始终只有一个主时钟,与时序收敛更友好。

3.3 发送端状态机设计要点

发送端的工作模式相对简单:空闲时保持tx线为高电平。当收到发送使能信号时,状态机依次经过START、DATA0-DATA7、STOP共10个状态(无校验位时),每个状态持续一个位时间。

我把发送状态机的编码方式确定为独热码(one-hot),不为了省寄存器而用二进制编码。原因很简单:独热码每个状态只有一个bit为1,译码逻辑极快,状态跳转判断简单,而且在FPGA这种寄存器丰富的结构中完全不必在乎那多出来的几个FF。作为对比,如果状态很多(比如超过20个),独热码会占用较多寄存器,但UART状态机本来就只有10个左右状态,使用独热码是性价比最优的。

下面给出发送端状态机核心代码框架。重点是使能信号的跨周期处理:tx_en可能只是一个周期有效的脉冲,也可能持续拉高,所以你要决定是内部做握手还是直接用电平标志。在我的代码版本里,我选择让上层逻辑在tx_ready为高时启动发送,tx_ready就是发送状态机处于IDLE状态的信号。

localparam TX_IDLE = 5'b00001,
           TX_START = 5'b00010,
           TX_DATA0 = 5'b00100,
           TX_DATA1 = 5'b01000,
           TX_DATA2 = 5'b10000;
// ... 实际应该定义10个状态
reg [4:0] tx_state;
reg [7:0] tx_shift_reg;
reg [3:0] tx_bit_cnt;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        tx_state <= TX_IDLE;
        tx <= 1'b1;
        tx_ready <= 1'b1;
    end else begin
        case (tx_state)
            TX_IDLE: begin
                tx <= 1'b1;
                if (tx_en) begin
                    tx_state <= TX_START;
                    tx_ready <= 1'b0;
                end
            end
            TX_START: begin
                tx <= 1'b0;
                tx_shift_reg <= tx_data;  // 锁存数据
                if (bclk_pulse) tx_state <= TX_DATA0;
            end
            TX_DATA0: begin
                tx <= tx_shift_reg[0];
                if (bclk_pulse) begin
                    tx_shift_reg <= {1'b0, tx_shift_reg[7:1]};
                    tx_state <= TX_DATA1;
                end
            end
            // ... 省略中间状态
            TX_STOP: begin
                tx <= 1'b1;
                if (bclk_pulse) begin
                    tx_state <= TX_IDLE;
                    tx_ready <= 1'b1;
                end
            end
        endcase
    end
end

这个框架把移位操作和电平驱动放在同一个状态跳转时机,能保证每个bit位置精确持续一个位时间。有经验的工程师会注意一个细节:状态跳转和移位输出必须严格同步在bclk_pulse上,如果只是简单地在进入DATA状态的那一个时钟周期就立刻输出第一个数据位,会导致起始位的数据位部分被吃掉半个周期,接收端就可能采样错位。

3.4 接收端状态机设计要点

接收端比发送端复杂一些,因为它需要额外完成位同步和干扰滤除。基本的接收状态机分为IDLE、START、DATA、STOP几个状态。IDLE状态检测到rx线从1跳变到0时,不立即确认是有效起始位,而是先等半个位周期(8个bclk_x16脉冲)再判断rx是否仍为低?这能滤除毛刺。如果确认是有效起始位,则从此刻起连续16个采样周期采集一个数据位,正好在每个数据位的中点附近采样。

我再稍微展开一下采样点确认的细节。假设rx下降沿发生在bclk_x16的第0个上升沿附近,那么等待8个bclk_x16脉冲后采样点应该在位周期中间。但硬件信号有建立时间和偏斜,实际电路当中,也就是settle到稳定电平所需要的时间决定了最好再多等一个采样时钟,即第9个脉冲才采第一个数据位。代价是采样点会向后偏移十六分之一位周期,这对一致性误差来说微乎其微,但能显著降低亚稳态误判的概率。我的工程习惯是:在期望采样点的前后各多采一个点,三个点取多数。下面这段代码实现了多数表决逻辑:

reg rx_sync1, rx_sync2;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        rx_sync1 <= 1'b1;
        rx_sync2 <= 1'b1;
    end else begin
        rx_sync1 <= rx;
        rx_sync2 <= rx_sync1;   // 两级同步器,消除亚稳态
    end
end
// 采样多数表决
reg rx_sample1, rx_sample2, rx_sample3;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        rx_sample1 <= 1'b1;
        rx_sample2 <= 1'b1;
        rx_sample3 <= 1'b1;
    end else begin
        if (sample_en) begin
            rx_sample1 <= rx_sync2;
            rx_sample2 <= rx_sample1;
            rx_sample3 <= rx_sample2;
        end
    end
end
wire rx_bit_val = (rx_sample1 & rx_sample2) |
                  (rx_sample1 & rx_sample3) |
                  (rx_sample2 & rx_sample3);

注意rx信号过大进来之后绝不能直接采原始rx,要先经过两级触发器同步,消除跨时钟域的亚稳态问题。这个同步器是FPGA数据接收的通用手段,如果你省掉这一步,长时间运行后偶尔会出现一个bit突然采错,干扰极难复现也极难排查。因为我这里只有一个bit跨时钟域,两级同步器就够用了,如果是多位总线跨时钟域,还得配合FIFO或握手协议。

接收状态机还有一个经典边界问题:起始位校验的退化情形。如果发送端和接收端的波特率误差略大,或者线路干扰较强,接收端在停止位阶段检测到低电平(应为高电平),此时通常认为发生了帧错误(framing error),应当丢弃当前的半个字节并且重新等待起始位。处理方式是增加一个帧错误标志输出,而不是让状态机卡死或乱跳。下面这个例子展示帧错误检测逻辑:

wire frame_err = (rx_state == RX_STOP) && (rx_bit_val == 1'b0);
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        frame_err_flag <= 1'b0;
    else if (frame_err)
        frame_err_flag <= 1'b1;
    else if (rx_valid)
        frame_err_flag <= 1'b0;
end

3.5 接收FIFO和发送FIFO的必要性

最小系统的UART可以直接在接收状态机输出rx_valid时把数据交给上层逻辑,但如果上层逻辑正在忙其他任务(比如处理一帧图像、做卡尔曼滤波运算),数据就会丢失。解决办法是给接收端加上FIFO缓冲。FPGA实现FIFO有两条路:一是直接用厂商IP核(Xilinx的FIFO Generator,或者Intel的ALTSYNCFIFO),简单可靠,但会引入一些时钟域约束;二是自己用分布式RAM或BRAM写一个同步FIFO,灵活且不依赖厂商工具。

对同步FIFO,只需要一个写时钟,读写都在同一个时钟域下。UART接收端产生的数据速率是波特率除以10(因为一帧有10位,含起始位和停止位),远低于系统时钟频率,所以同步FIFO足够用。例如115200波特率下,实际数据速率只有11520字节每秒,不到12KB/s,用分布式RAM(LUTRAM)就能轻松实现256字节深度的FIFO,资源消耗非常少。发送端也同样加一个小FIFO,这样上层CPU或状态机可以把待发送的一串数据一次性写入FIFO,然后发送端自动以波特率慢慢发送,不用CPU干等。

FIFO带来的另一个工程价值是缓解时序压力:它把"接收状态机逐bit恢复数据"这一慢速过程和"上层快速处理"这两个不同节奏的环节解耦开来。比如在FPGA里同时跑着一个图像采集逻辑,有时会产生数据突发,接收FIFO能吸收突发流量。难点是FIFO水位信号(almost_full/full)要正确接入上层的背压逻辑,如果不会用dma引擎,最简单做法是让CPU定期查询FIFO的occupancy寄存器,积攒一定量再搬运。

4. 实操过程与仿真验证 4.1 测试平台搭建与激励构造

写完RTL代码后,第一件事不是下板,而是搭建基于Verilog testbench的仿真环境。写testbench的目的有两个:一是验证功能逻辑正确与否,二是把调试时间从"下板-看波形-猜原因"的长周期中解放出来。UART模块的仿真有两个关键激励源:一个模拟发送端的波特率时钟发生器,一个模拟PC或单片机的数据流。

发送端的激励很简单:例化DUT后在其tx_data端口赋值0xA5,断言tx_en脉冲,观察tx线是否按照10bit帧格式依次输出1->0->数据位->停止位。这里需要注意一点:0xA5换成二进制是10100101,LSB在前发送时tx线上的信号会呈现规则的方波,非常便于肉眼对波形,是调试UART发送链路的经典测试向量。

接收端的激励则需要一点技巧:你不能直接把测试数据并行塞给DUT,而要等价的时序用串行信号驱动rx引脚。做法是在testbench里定义变量rx_tb,然后通过 #434 (假设波特率115200,仿真器时间精度1ns,则每位约8680ns)这类延迟语句逐位吐出数据。也可以写一个task,输入一个字节,它自动按起始位、数据位、停止位生成完整串行波形。下面是一段参考task代码:

task uart_send_byte;
    input [7:0] data;
    integer i;
    begin
        rx_tb = 1'b1;
        #8680;
        rx_tb = 1'b0;  // start
        #8680;
        for (i = 0; i < 8; i = i + 1) begin
            rx_tb = data[i];
            #8680;
        end
        rx_tb = 1'b1;  // stop
        #8680;
    end
endtask

这种写法能精确控制每一位的时间,方便模拟波特率误差。你想测试1%的偏差时,把#8680改小或改大就行。仿真波形上重点观察三个地方:rx_valid脉冲是否在一个完整字节接收完成后拉高;rx_data是否和发送的数据一致;状态机是否在停止位结束后干净地回到IDLE,没有残留状态。

4.2 回环测试与误码率验证

功能仿真通过后,就进入系统级验证阶段。最方便的自测手段是回环测试(loopback):把FPGA的tx引脚和rx引脚用杜邦线直接短接,然后让PC上位机通过USB转串口向FPGA发一串数据,再接收FPGA返回的数据,两边比对是否一致。回环测试能一次性验证发送端、接收端、FIFO、系统时钟和复位电路是否全部正常。

如果使用USB转串口芯片,热词里反复出现的FT232、CP2102这些型号一定要注意驱动匹配。FT232在Windows下通常需要安装VCP驱动,否则插上后显示未知设备;CP2102则用厂家提供的Silicon Labs驱动。这类驱动出问题时,排查思路是先看设备管理器是否识别到COM口,没有的话再检查焊接或线序,大多不是FPGA侧的问题。

真正用于误码率验证的场景是和PC上位机配合。我常用的方案有几种:一是用串口助手发送固定长度的随机数据包,FPGA内部把它们回传,上位机比对;二是在FPGA内部实现一个简单的伪随机序列发生器(比如LFSR),不断将生成的数据写入发送FIFO,同时把接收到的数据和本地预期序列做比对,定期把比对结果通过另一路串口打印出来。第二种方案的好处是不需要上位机参与,可以无人值守长时间跑,很适合检验长时间运行稳定性。

我实际测试过的一个典型案例是:50MHz系统时钟、115200波特率、8位数据、无校验、1停止位,连续跑48小时,收发36万字节无误码,实时性和稳定性都符合预期。后来我又把波特率提高到921600,用混合分频法实现后,跑30分钟抽样1万字节,在一根长度约15厘米的杜邦线上误码率仍然是0。这说明模块的时序余量是足够的,问题往往出在外部接线和干扰上。

4.3 节省调试时间的仿真技巧

在调试UART这类低速接口时,仿真时间往往很长:一个115200波特率的字节需要约87微秒,如果testbench里有好几千个字节的数据流,仿真器可能要跑几个小时。两个实用技巧可以大幅节省时间:第一,在testbench里用参数化延迟,例如 localparam BIT_TIME = 1_000_000_000 / (BAUD_RATE * 1000); ,这样只需改BAUD_RATE一个值就能把仿真提速;第二,只对关键边界做长包压力测试,比如专门测试FIFO接近满水位时的表现,而不是无脑灌大量数据。

另外一个很实用的小技巧是:仿真覆盖率不应该是"判断最终数据对不对",而应该是"判断每个状态都经历过"。我习惯在testbench里加断言(assertion),例如当rx_state不在合法状态集合时立刻报错。Verilog中可以用 $error 配合if语句实现,不需要引入SystemVerilog的断言机制。这样可以第一时间暴露状态机跳飞的bug,不需要等波形出来人眼去逐个状态核对。

4.4 关键路径与时序约束

功能正确不等于时序收敛,尤其当系统时钟跑得比较高时,UART模块虽然逻辑简单,但如果被嵌在一个大系统里,别人可能给它分配了较紧的约束。UART相关信号建议设置fake path或set false path?实际上不需要。因为UART的rx信号是异步输入,建议用set_input_delay约束到同步器链之后的第一个寄存器,但同步器本身之间要设set_false_path。多数情况下你可以直接让UART的同步器前的路径保持默认约束,如果工具报violation再加约束。

需要注意的是,UART发送端和接收端如果挂在同一个系统时钟域,并且bclk_x16使能信号经过较多组合逻辑,可能会导致接收状态机里rx_bit_valid路径的建立时间紧张。处理办法很粗暴但有效:给状态机的状态寄存器添加/* synthesis preserve */或等效属性,避免综合器把状态机优化成乱序逻辑。对于7系列FPGA,Vivado会用FSM encoding自动优化,一般没问题;但国产FPGA工具链偶尔会出错,保留属性可以保证时序可预测。这是我在做国产FPGA平台适配时踩过的真实坑:高云软件默认把一段式状态机识别成了ROM查找表,综合频率骤降,后来改成显式三段式状态机并加综合属性才解决。

时序约束的完整做法是在XDC/SDC文件里加上:

create_clock -period 20.0 [get_ports clk]
set_input_delay -clock clk -max 5 [get_ports rx]
set_input_delay -clock clk -min 2 [get_ports rx]

这样综合工具才能合理计算外部异步信号到内部同步器的时序预算。即便不约束,通常也能正常工作,但长期运行的可靠性没有保障。

5. 常见问题排查与工程经验 5.1 乱码问题的定位思路

乱码是UART调试中出现频率最高的故障,也是原因最复杂的故障。根据我的实际经验,乱码大约有七成以上出在波特率不匹配,其次是电平转换问题,再次是接线接触不良,最后才是FPGA逻辑bug。

波特率不匹配的典型特征:接收到的数据看起来"有点规律但总体错乱",比如发送0x55(01010101B,LSB先行)时收到0xAA。因为0x55的位序和0xAA正好互为反码,如果接收端采样窗口整体偏移了半个位周期,就可能出现这种"每一位都反了"的现象。另外如果PC端打开串口工具显示波特率设置正确,但FPGA端分频系数计算有误,会导致类似现象。快速定位方法是:如果你用上位机发一个已知字节(比如0x00),然后观察FPGA收到的数据,如果收到的字节中bit发生了移位(比如0x00变成了0x01),说明采样时刻整体偏后了半个位周期,这是典型的波特率误差过大。

电平转换问题则是另一类高发故障。FPGA的UART引脚如果直接连到PC的RS232串口,没有经过MAX3232或类似芯片,会出现两种现象:要么完全收不到数据,要么偶尔能收到但数据中夹杂大量错误。这是因为RS232的负逻辑电压超出了FPGA IO的绝对最大额定值,不仅通信失败,长期还可能损坏FPGA引脚。检测方法是用示波器量电平:逻辑1时如果量到约-8V,说明是RS232电平,绝对不能直接进FPGA;如果量到3.3V,那才是TTL UART电平。

另一处容易忽略的问题是USB转串口芯片的引脚电平。很多FT232模块的IO电平可以通过跳线或EEPROM配置成5V、3.3V或1.8V,如果模块输出5V而FPGA bank是3.3V,长时间直连同样有风险。建议用万用表先量空闲状态下的电平:TTL UART空闲时应该约为VCCIO的电压值,如果你量到接近0V,多半是线序接反或者芯片本身没有供电。

5.2 接收数据偶尔丢字节

"偶尔丢字节"比完全乱码更难排查,因为它往往是间歇性的、没有稳定复现规律的故障。我总结的排查顺序是:先看FIFO水位,再看接收状态机的帧错误标志位,最后排查外部电磁干扰。

如果接收FIFO的深度是256字节,而上层逻辑每500ms才来搬运一次,那么当外部数据流速率超过处理速率时,FIFO会溢出,溢出期间的数据自然丢无音信。解决办法有三个方向:加大FIFO深度、提高上层搬运频率、引入流控机制(RTS/CTS硬流控或XON/XOFF软流控)。对大多数场景,调大FIFO深度最省事,但要注意FIFO不能无限扩大,因为内部资源消耗会线性增长。

帧错误标志位频繁置1是另一个强信号,它表明接收数据的位边界已经错位,外部的波特率时钟与本地估计出现较大偏差,或者传输线缆过长导致信号沿退化严重。现场排查时我会直接用示波器抓rx引脚的波形,观察上升沿和下降沿的斜率及过冲情况。信号边沿退化严重时需要加终端电阻或降低波特率。

外部电磁干扰导致的偶发丢字节,典型场景是电机启停或继电器吸合瞬间出现丢包。这种情况除了提高线路屏蔽和缩短走线长度,还可以在FPGA内部加一个简单的接收FIFO数据完整性校验(比如CRC8),一旦校验失败就请求重传。很多工业Modbus设备就是这么做的,应用层协议里内置CRC校验,链路层只管尽力传输。如果依然频繁失败,就要考虑把TTL电平提升为RS485差分传输,抗共模干扰能力会强很多。

5.3 和STM32/上位机联调的互操作细节

热词里大量出现"stm32串口通信""stm32hal""qt串口通信""宿主机windows如何通过串口与vmware中linux通信",这些说明UART联调场景非常普遍。和STM32联调时最常见的坑有三个:电平域一致性问题、发送时序不匹配问题和大端小端字节序问题。

STM32的串口电平通常是3.3V的TTL,和FPGA的3.3V bank可以直接相连,但如果你用了5V供电的STM32最小系统板,IO可能是5V容忍或直接输出5V,就需要配合电平转换器。第二个坑是STM32的HAL库串口发送函数默认是阻塞式的(HAL_UART_Transmit),它会一直等到发送完才返回;FPGA作为接收端必须保证接收FIFO深度足够,或者利用RTS信号做硬件流控,否则STM32一次发一大包数据时,FPGA处理不过来就会丢数据。第三个坑是字节序:UART协议本身不定义字节序,一帧只传8位数据,多字节数据(比如一个32位传感器值)的字节序完全由应用层约定,收发双方必须保持一致,否则在联调时极难发现。

Windows宿主机和虚拟机通信的场景也在这类项目里经常碰到。如果你在宿主机Windows上用串口助手发送数据,希望VMware虚拟机里的Linux应用通过虚拟串口收到,就需要给VMware添加一个串口设备并映射到宿主机的物理COM口。这实际上不算FPGA的事,但如果你在用Zynq或者FPGA+软核跑Linux,整个调试链路会变得很长:上位机串口助手 -> USB转串口芯片 -> FPGA UART -> AXI总线 -> PS端Linux应用。链路越长,排查越要分层,建议先单独测"USB转串口到FPGA UART回环"这一段,再单独测"FPGA到PS端"这一段,两个链路都通了再串起来测,这样能避免多层故障纠缠不清。

5.4 热词里的相关场景:RS485和Modbus扩展

许多工控项目中,FPGA的UART会外接一个RS485收发器,组成半双工多机通信网络。实现时需要多控制一个方向使能信号DE/RE(通常连在一起),发送数据前拉高DE,发送完最后一个停止位后再延时一段时间拉低DE,让总线恢复高阻状态。这个延时如果太短,收发器可能在停止位还没完全发完时就切断了总线驱动,导致最后一个位被拉低,接收端检测到帧错误。典型的做法是在TXD进入空闲态后再等一个位时间(或至少500ns)才释放总线。很多人的RS485通信时好时坏,根因就在这个细节上。

Modbus协议则是在这个基础上叠加了应用层约定:主从问答、寄存器地址、功能码、CRC16校验。FPGA里实现Modbus从机时,UART只负责字节流的收发,协议解析放到更高一层的状态机里。因为Modbus RTU的帧间隔要求是3.5个字符时间,如果接收状态机在上位机连续发来两帧数据时没有以帧间隔做分帧判断,就可能把两帧误当作一帧数据去解析。需要在接收FIFO侧做一个空闲定时器,超过3.5字符周期没有新字节到达就认为一帧结束,然后触发协议解析逻辑。这个设计和UART本身无关,但和"UART串口通信FPGA实现"的工程落地强相关,提醒各位在做项目时不要只盯着字节收发,还要考虑帧边界识别。

6. 工程化扩展与集成思路 6.1 多通道UART与FIFO/中断机制

单路UART调通后,你已经触碰到了FPGA相比单片机最有吸引力的一点——并行多通道能力。FPGA内部可以根据需求例化多个UART模块,比如同时管理8路串口,每个串口各自独立工作,互不干扰。这在多传感器采集、多设备控制等场景下非常实用。单片机的串口资源通常只有3到8个,而且每个串口都要占用CPU中断和定时器资源;FPGA则可以在亚微秒级的时间内同时处理所有串口的数据收发,CPU(软核或外部MCU)只需要定期轮询FIFO水位。

实现多通道时,建议把顶层wrapper的参数化设计做好:定义一个 UART_CH_NUM 参数,然后在generate循环中例化N个UART子模块。每个通道独立占用一个FIFO实例,FIFO的深度和almost_full阈值可配置。我见过有些设计为了省资源把所有通道共用一个FIFO,再加上复杂的仲裁逻辑,结果代码可维护性极差,调试时要同时考虑通道ID和FIFO状态两个维度。相比之下,每个通道一个独立FIFO的资源开销并不大,LUTRAM足够用,工程收益明显更高。

中断管理机制是整个多通道方案的骨架。Xilinx的AXI UART 16550 IP会输出多个中断源(接收FIFO非空、发送FIFO半空、接收错误等),你可以仿照它的思路自己做一个中断控制器。我一般在集成到MicroBlaze或Zynq软核时使用AXI-Lite接口,让CPU可以访问每个通道的状态寄存器、数据寄存器和控制寄存器。中断控制器把8个通道的"接收FIFO非空"信号汇总成一个系统中断,CPU在中断服务程序里先读取中断状态寄存器,判断是哪个通道触发的,再去读对应通道的FIFO数据。这样的代码既简洁又可扩展,后续加到16路、32路只是参数变化。

6.2 UART与片上总线(AXI/APB)的桥接

现代FPGA设计很少只有一个孤立的UART IP,更多是作为SoC系统里的一个外设挂到总线上。Zynq平台中,PS端的UART控制器由ARM管理,PL端如果需要额外串口,最常用的做法是例化Xilinx的AXI UART Lite IP,它自带AXI-Lite从接口和中断输出,配合Vivado的Block Design几分钟就能搭好。但如果你用的是纯PL设计(不带PS),或者你希望在国产FPGA上实现类似的桥接能力,就需要自己写一个简单的总线从设备接口。

最简单的桥接是所有寄存器直接映射成内存地址,例如:

用APB总线协议实现这个从设备,代码量很小,状态机只有IDLE、SETUP、ACCESS三个状态,加上读写地址译码就完成了。如果你对AMBA协议不熟,可以先从FPGA内部的简单内存映射接口开始,后续再接AXI-Lite。写好这个桥接模块以后,就能通过任意一个总线和CPU或DMA引擎对接。我在一个Zynq项目中就用这种自研APB从接口把四路UART挂到了PS端的AXI总线上,绕开了Xilinx IP的授权和配置界面,灵活性反而更大——可以在FIFO深度、中断极性、地址映射这些参数上完全自定义。

6.3 大数据量场景下的DMA搬运

UART本身是低速设备,但在某些特殊场景下(比如数据采集系统连续上传几MB数据),CPU中断方式处理每个字节会占用大量处理器时间。这时候考虑引入DMA。FPGA内做DMA的思路是:UART接收FIFO产生非空信号,DMA控制器响应该信号,把数据搬运到指定内存地址,搬运完成后产生中断通知CPU。发送方向类似,CPU把要发送的数据准备好,DMA按字节写入UART发送FIFO,发送完成后产生中断。

对纯PL设计,你可以写一个简单的存储器映射DMA控制器,支持固定地址外设到递增地址内存之间的搬运。对Zynq平台,直接用AXI DMA IP更省事——它支持Memory-Mapped到Stream的转换,配好之后可以和Xilinx UART Lite的AXI-Stream接口相连。需要特别留意DMA描述符循环机制:如果使用循环模式,DMA会不断搬运数据,回绕地址要正确设置,否则溢出或覆盖旧数据。我在项目中使用了循环DMA接收,描述符地址在0x1000和0x2000之间来回切换,缓冲区满标志依靠DMA的中断和描述符状态位判断,实测稳定运行两个月未丢包。这个性能级别的UART链路,应用到长期数据记录系统完全没问题。

DMA方案的另一特性是降低CPU抖动——接收大数据包时CPU可以全程睡眠,只在DMA完成中断里做一次批量处理,对实时性要求不高的系统非常友好。如果你在做"fpga图像处理"配合串口传输图像帧之类的项目,建议认真考虑这个方向。

6.4 跨时钟域扩展与低功耗考量

很多大型FPGA设计里系统时钟不止一个,比如内核跑200MHz,UART逻辑挂在一个100MHz的时钟域里。虽然UART接口本身是慢速域,但顶层总线可能是高速域,这时模块内部的数据通路可能出现跨时钟域穿越。最简单的处理是将UART模块的FIFO做成异步FIFO(读写时钟不同),或者使用同步器+握手协议。对8比特数据,使用异步FIFO是最稳妥的,厂商IP或者自己用格雷码指针实现都可以。如果数据率很低,也可以直接在总线侧加两级同步器加FIFO,没有太大难度。

低功耗方面,UART模块在运行期间功耗本身不大(多数情况下不到1mW),但如果你在做电池供电的物联网节点,可以在空闲时把UART时钟关掉(时钟门控)或者让状态机进入低功耗模式。有些设计会直接使用PMU控制UART的供电域,只在需要通信时上电。更简单的做法是让UART模块只在检测到rx线有下降沿时才把时钟使能打开,完成一帧接收后再次关断。用专用的时钟使能信号而不是门控时钟实现这一点,可以避免时钟树综合的问题。我试验过这种"事件驱动时钟使能"的UART设计,在传感器节点休眠唤醒场景下,能把UART相关动态功耗降低到常规设计的十分之一,该方案在低功耗采集终端上很有实用价值。

7. 项目扩展方向与个人体会 7.1 从UART延伸:收发数据校验与协议栈

UART裸收发能跑通,距离工程交付还差一层校验。I2C有ACK机制、SPI有CS片选同步,而UART是全双工无握手无校验的,硬件层面只有奇偶校验位这一个选项。实际上在工业协议Modbus中,数据帧尾部都要加CRC16校验,主站靠它判断从站响应是否有效。FPGA上计算CRC16有很多现成查表法和逐位法的代码,逐位法占资源稍多但逻辑简单清晰,查表法则适合波特率较高、数据量较大的场景。

更进一步的协议栈是自动重传(ARQ)机制。用FPGA实现一个简单状态机,维护发送缓冲区和重传定时器,当收到接收端的NACK或者超时无ACK时,重新发送上一帧。但要注意,UART的时序天然不适合做复杂的重传窗口管理,用轻量级停等协议(发送一帧,等待ACK,再发下一帧)对多数场景够用了。如果你发现应用场景需要滑动窗口,建议直接换用LVDS或以太网等更高性能链路,而不是在UART上不断堆复杂度。

7.2 懂得取舍:UART不是万能的

我在文末特别想分享的一点经验是:UART虽然简单可靠,但工程上一定要懂得取舍。大数据量、长距离、多节点场景,UART远不如CAN或工业以太网;板上高速互连,UART也远不如LVDS或并行总线。很多人容易被"简单上手"的感觉误导,在一根串口线上反复优化波特率、加各种纠错机制,试图让它承担不适合的任务。我自己的体会是,UART最舒服的定位就是"调试通道、低速控制通道、协议转换桥接通道",超过这个定位,就应该考虑换更合适的总线。

以我做过的一个信号采集板为例,FPGA通过UART和上位机通信,原先设计时两位工程师为了在115200波特率下传输大量波形数据,不断压缩编码格式、减短帧头、调整FIFO深度,忙了一个星期效果依然一般。后来我们把方案改成USB高速传输,初期投入多了三天,后续运行完全不用再操心流量问题。这个教训说明,选型阶段多看几家方案比后期强行优化要高效得多。

7.3 高频疑难问题速查表

下面把最常踩的坑和排查方向汇总成表,方便大家现场对照使用:

故障现象 可能原因 排查手段

完全无通信

USB转串口驱动未装或COM口错误

设备管理器查COM口、换USB线

完全无通信

线序接反(RX接RX)

万用表量空闲电平,发送方TX应接接收方RX

完全无通信

FPGA引脚约束错误

检查XDC/SDC中tx/rx引脚分配

乱码且规律固定

波特率失配

对照2.2的表格重新计算分频参数

乱码且随机变化

电平不匹配/外部干扰

示波器抓线上波形,检查MAX3232或RS485电路

偶发丢字节

接收FIFO溢出

看FIFO几乎满标志触发频率,加大深度或加快搬运

偶发丢字节

跨时钟域亚稳态

检查rx线是否过了两级同步器

停止位错误

波特率误差过大

降低波特率或使用混合分频

上位机收到的数据少字节

发送FIFO被上层覆盖写

检查发送ready握手逻辑是否正确

这张表是我在多个项目里反复验证后整理出的排查顺序,先看通信是否建立,再看数据是否正确,最后才去纠结边角问题,一般能把调试时间压缩一半以上。


注明:本内容来源网络,不用于商业使用,禁止转载,如有侵权,请来信到邮箱:429562386ⓐqq.com 或联系本站客服处理,感谢配合!

上一篇:10分钟讲透FPGA工作原理

下一篇:

用户登陆

    未注册用户登录后会自动为您创建账号

提交留言