我前后做了三四个带串口的FPGA小项目,从最早照着开发板例程抄代码,到后来自己独立写完收发模块并且一次跑通,中间踩的坑足够写满一页A4纸。这篇文章就把UART串口通信在FPGA上的实现思路、代码细节、板级调试经验一次性讲清楚,不说废话,只讲能落地的操作。无论你是刚学FPGA想做个小玩意儿练手,还是工作中需要在FPGA和PC或单片机之间打通数据链路,这篇文章都值得你完整看一遍。
先说清楚UART这件事的本质:它就是把一个字节的数据,按照约定好的节奏,在一根线上一位一位地送出去,再从另一根线上一位一位地收回来。听起来简单,但真要在FPGA里把它做好,牵扯到分频计算、边沿检测、采样时机选择、状态机设计、跨时钟域处理一堆问题。文章会按照从原理到代码、从仿真到上板的顺序,把你需要知道的每个点都拆开揉碎。
1. 搞清楚UART到底在传什么:协议时序和电平标准
很多初学者上来就抄代码,抄了半天也不知道起始位和停止位为什么要那么长。我们先从协议本身讲清楚,后面写代码才有底。
1.1 一个字节的空闲、起始、数据和停止时序
UART串口通信的全部秘密,就在一根发送线TX和一根接收线RX上。平时线上都是高电平,这叫空闲态。要发送一个字节时,先把线拉低一个位时间,这一个低电平就是起始位,它告诉对端"注意,数据要来了"。然后从数据的最低位开始,依次输出8个数据位(也有5、6、7位的古老模式,基本用不到),每个位持续相同的时长。最后拉高一个位时间作为停止位,标志着这一帧结束。如果后面还有数据,下一个起始位会紧跟而来;如果没有,线就一直是高电平空闲。
这个"位持续的时间"就是波特率的核心。波特率9600表示每秒钟传输9600个bit,那么每个bit持续的时间就是1/9600秒,约104.16微秒。波特率115200时每个bit约8.68微秒。收发的两端必须用同样的波特率,否则就是鸡同鸭讲——发端说"我这个高电平持续8.68微秒代表一个1",收端却拿104微秒的尺子去量,自然全是乱码。
1.2 UART和USART、RS232、RS485、TTL这些名词的关系
这几个名词日常经常混着说,但严格讲完全不是一回事。UART是通用异步收发器,USART是通用同步异步收发器,后者多了同步时钟线,能跑SPI之类的同步模式。而TTL、RS232、RS485都是电平标准,说的是UART从芯片出来后怎么接线上。
FPGA开发板上的UART接口通常是TTL电平,也就是3.3V代表逻辑1,0V代表逻辑0。RS232是PC老式串口的电平标准,逻辑1是-3V到-15V,逻辑0是+3V到+15V,和TTL正好相反。如果要把FPGA接到PC的RS232串口,中间必须加电平转换芯片,比如MAX3232。RS485则是差分信号,用两根线的电压差表示逻辑,适合远距离传输,技术上还是在传UART数据帧,只是物理层换了方式。
这块最关键的实操点是:千万别把TTL电平的TX/RX直接连到RS232口上,3.3V的TTL信号怼到RS232口上,轻则通信失败,重则烧掉芯片。我见过一个朋友图省事直接把杜邦线怼上去了,结果FPGA的引脚直接冒烟。电平转换芯片是必须的,没有捷径。
2. FPGA实现前的关键决策:波特率产生、时钟分频和状态机选型
UART协议本身是纯数字逻辑,FPGA实现它的核心工作其实只有三件事:产生准确的波特率时钟、设计发送状态机、设计接收状态机。但动手写代码之前,有几个决策必须先定下来。
2.1 为什么实际项目中很少用9600而偏爱115200
很多教科书例子都用9600波特率,因为它和50MHz系统时钟的分频关系比较好算。50MHz除以9600约等于5208.33,计数5208次产生一个波特率时钟脉冲。这个计算看起来清爽,但实际工程里我更推荐直接用115200。
原因有两个。第一是效率,9600波特率下传1MB数据需要约14.5分钟,115200只需要约72秒,调试时等待上位机数据简直是折磨。第二是分频寄存器位宽的问题,用9600需要13位计数器,用115200只需要9位,逻辑资源更少。而且现在的USB转串口芯片和上位机工具对115200支持得非常成熟,误码率实测下来和9600没有肉眼可见的差别。
50MHz时钟下115200的分频计算是:50000000 / 115200 = 434.02778。注意这个结果不是整数,那么应该取434还是435?取434的话,实际波特率是50000000 / 434 = 115207.37,误差0.006%;取435的话,实际波特率是114942.53,误差0.22%。显然取434更准确。UART接收的容错能力大概在3%以内,所以这点误差完全没问题。
如果你的系统时钟是25MHz、75MHz或者100MHz,同样用这个公式算:分频值 = 系统时钟频率 / 波特率,四舍五入取整数。算完最好验证一下实际误差,超过1%就要考虑换一个能被整除的波特率,或者调整系统时钟设计。
2.2 发送状态机和接收状态机的总体架构选择
整个UART收发模块我推荐用状态机实现,不推荐用计数器死等的方式。收发各自一个独立的有限状态机,发送模块有五个状态:空闲、起始、数据、停止、结束(可选)。接收模块同样五个状态:空闲、检测起始、起始确认、数据采样、停止位验证。
为什么用状态机而不用一堆计数器嵌套?因为状态机把时序逻辑和组合逻辑的边界划得很清楚,代码可读性高,出问题好排查。计数器嵌套写起来爽,但改一个bit宽度可能到处都要跟着改,极容易埋雷。实际工程里我用状态机的经验是:数据位的计数器只在数据状态下累加,起始和停止状态下计数器会清零,这样每个状态的行为都是自包含的,仿真时看波形一目了然。
2.3 分频计数器的使能逻辑:连续计数还是累加到阈值就拉脉冲
这里有个容易踩坑的细节。波特率时钟生成有两种写法:第一种是产生一个连续的时钟信号,波特率时钟作为时钟域使用;第二种是产生一个脉冲信号,作为状态机的节拍使能信号。这两种方式我都试过,强烈建议用第二种。
原因在于跨时钟域。如果你的波特率时钟是连续时钟,那么发送模块的输入数据、控制信号从系统时钟域过来,就要做跨时钟域同步,否则会有亚稳态风险。而脉冲使能的思路就简单了:所有逻辑仍然跑在系统时钟域下,每个系统时钟周期检查一次"波特率脉冲是否到来",来了就推进一步,没来就保持现状。这样整个模块就是一个时钟域,不需要额外的同步器。
具体代码就是分频计数器从0计到分频值-1,当计数值等于分频值-1时,把波特率脉冲信号拉高一个时钟周期,同时计数器归零。以50MHz和115200为例,就是计数0到433共434个周期,在第433个周期拉一个高脉冲。
3. 发送模块的实现:把并行的8位数据变成一根线上的串行波形
发送模块的逻辑相对简单,但做好也要注意几个细节。先看代码,再解释关键点。
module uart_tx #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115200 )( input wire clk, input wire rst_n, input wire tx_start, // 发送使能,拉高一个时钟周期 input wire [7:0] tx_data, // 要发送的8位数据 output reg tx_line, // 串行输出,空闲为高 output reg tx_busy // 忙信号,发送期间为高 ); localparam BAUD_DIV = CLK_FREQ / BAUD_RATE; // 434 localparam BAUD_DIV_HALF = BAUD_DIV / 2; // 217 reg [8:0] baud_cnt; reg baud_pulse; reg [3:0] bit_cnt; reg [7:0] data_shift; reg tx_start_d; wire tx_start_pulse = tx_start && !tx_start_d; // 波特率脉冲发生器 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt <= 9'd0; baud_pulse <= 1'b0; end else if (tx_busy || tx_start_pulse) begin if (baud_cnt == BAUD_DIV - 1) begin baud_cnt <= 9'd0; baud_pulse <= 1'b1; end else begin baud_cnt <= baud_cnt + 1'b1; baud_pulse <= 1'b0; end end else begin baud_cnt <= 9'd0; baud_pulse <= 1'b0; end end // 状态机 localparam S_IDLE = 3'd0; localparam S_START = 3'd1; localparam S_DATA = 3'd2; localparam S_STOP = 3'd3; reg [2:0] state; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; tx_line <= 1'b1; tx_busy <= 1'b0; data_shift <= 8'd0; bit_cnt <= 4'd0; tx_start_d <= 1'b0; end else begin tx_start_d <= tx_start; case (state) S_IDLE: begin tx_line <= 1'b1; tx_busy <= 1'b0; bit_cnt <= 4'd0; if (tx_start_pulse) begin tx_busy <= 1'b1; state <= S_START; data_shift <= tx_data; end end S_START: begin tx_line <= 1'b0; if (baud_pulse) begin state <= S_DATA; bit_cnt <= 4'd0; end end S_DATA: begin tx_line <= data_shift[0]; if (baud_pulse) begin data_shift <= {1'b0, data_shift[7:1]}; if (bit_cnt == 4'd7) begin bit_cnt <= 4'd0; state <= S_STOP; end else begin bit_cnt <= bit_cnt + 1'b1; end end end S_STOP: begin tx_line <= 1'b1; if (baud_pulse) begin state <= S_IDLE; end end default: state <= S_IDLE; endcase end end endmodule3.1 发送时序里最容易错的移位方向和数据位顺序
这里的核心细节是移位方向。UART协议要求先发最低位LSB,所以数据移位必须从低位往高位推。代码里tx_line <= data_shift[0]是把最低位送上输出线,然后data_shift <= {1'b0, data_shift[7:1]}让数据整体右移一位。这样第0位先发,然后原第1位变成新的第0位接着发,循环8次就把整个字节按LSB-first顺序发完了。
有个同学写的时候搞反了方向,从最高位开始移位,结果和PC通信永远收到反过来的字节,比如发0x01收到0x80。排查了半天,最后用逻辑分析仪看波形才发现数据位顺序完全反了。这个坑很隐蔽,因为仿真看着波形是"有序"的,上位机也可能收到"有规律"的错误数据,不熟悉协议的人根本想不到是这里错了。
3.2 发送忙信号tx_busy的时序设计
tx_busy信号非常重要,它告诉外部模块"现在正在发送中,不要再给我新数据"。设计上从检测到tx_start拉高(严格说是tx_start_pulse时的下一个周期)开始置1,到停止位发送完毕回到空闲状态时清零。
为什么用tx_start脉冲触发而不是tx_start高电平直接触发?因为外部模块的发送请求可能是一个持续多个周期的电平信号,如果用电平触发会重复发送,用脉冲触发(上升沿捕捉)就能保证一个字节只发一次。代码里的tx_start_d寄存一个周期的旧值,tx_start且旧值为0就是上升沿脉冲。这是一种非常常用的边沿检测写法,建议养成习惯。
3.3 参数化设计让不同时钟频率和波特率自由切换
模块顶部用parameter定义了CLK_FREQ和BAUD_RATE,实例化时只要改这两个参数就能适配不同开发板。比如Black金开发板是50MHz,有些Xilinx的板子是100MHz,你不需要改内部逻辑,只要在顶层模块里重新定义参数值就行:
uart_tx #( .CLK_FREQ(100_000_000), .BAUD_RATE(9600) ) u_tx_inst ( .clk(clk), .rst_n(rst_n), .tx_start(tx_start), .tx_data(tx_data), .tx_line(tx_line), .tx_busy(tx_busy) );这个参数化的习惯越早养成越好。我见过很多人图省事把分频值直接写死在代码里,换一块板子就得全局搜索替换分频值,一旦漏改一处就是好几个小时的调试噩梦。
4. 接收模块的实现:起始位检测、采样时机和误码处理
接收比发送复杂不少,因为发送方是自己控制节奏的,而接收方要在一个未知的时刻去"猜"线上什么时候有数据来,然后准确地在一个bit的时间里"读懂"电平。
4.1 起始位下降沿检测的不稳定性与确认策略
接收模块空闲时一直盯着RX线。当RX线从高电平变成低电平,理论上这就是起始位来了。但物理世界里信号是有毛刺的,一个几十纳秒的低电平尖刺也可能被当成起始位触发,导致后面收到一堆错误数据。
所以不能看到下降沿就立刻开始采样,要先确认这个低电平是"稳定的低"。常规做法是检测到下降沿后,延迟半个位时间再采样一次。如果这时候还是低电平,就认定这是真正的起始位,开始后续采样;如果已经跳回高电平,说明是毛刺,直接丢弃回空闲态等下一次边沿。
半个位时间正好是BAUD_DIV/2个时钟周期,这就是前面代码里定义BAUD_DIV_HALF的原因。以115200为例,半个位时间是4.34微秒,217个50MHz时钟周期。
4.2 为什么采用"数据位中点采样"而不是波特率时钟直接采样
接收数据位时,有一个广为流传的老工程师经验:采样点要落在每个数据位的正中间,不要踩在边沿附近。原因很直白,数据位边沿是电平翻转的瞬间,信号可能还在抖动,也可能因为线路电容、线长导致边沿变缓,这时采样极可能采到错误的值。而数据位中间是最稳定的区域,电平已经稳定了好几个微秒,采样出错概率最低。
要采数据位中点,就需要在起始位确认之后,以半个位时间为起点开始计数,之后每经过一个完整位时间采样一次。具体做法是这样的:
- 检测到下降沿,启动半个位时间计数
- 半个位时间到,再确认RX为低,锁定起始位
- 然后从0开始计一个完整位时间,到点采样第0位
- 每隔一个位时间采一个点,连续采8个点
- 采完停止位后回到空闲
这个"起始位中点确认 + 数据位中点采样"的策略,是UART接收设计里的经典方案,实测在各种噪声环境下都非常稳。
4.3 稳定的UART接收代码与边沿毛刺消除
代码配合说明来看:
module uart_rx #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115200 )( input wire clk, input wire rst_n, input wire rx_line, // 串行输入,空闲为高 output reg [7:0] rx_data, // 接收到的数据 output reg rx_done // 接收完成脉冲,拉高一个时钟周期 ); localparam BAUD_DIV = CLK_FREQ / BAUD_RATE; reg [8:0] baud_cnt; reg baud_pulse; reg [3:0] bit_cnt; reg [7:0] data_shift; 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_line; rx_sync2 <= rx_sync1; end end wire rx_clean = rx_sync2; reg rx_prev; wire rx_falling = rx_clean && !rx_prev; // 下降沿检测 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_prev <= 1'b1; end else begin rx_prev <= rx_clean; end end reg [2:0] state; localparam S_IDLE = 3'd0; localparam S_HALF = 3'd1; localparam S_DATA = 3'd2; localparam S_STOP = 3'd3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; baud_cnt <= 9'd0; baud_pulse <= 1'b0; bit_cnt <= 4'd0; data_shift <= 8'd0; rx_data <= 8'd0; rx_done <= 1'b0; end else begin rx_done <= 1'b0; case (state) S_IDLE: begin bit_cnt <= 4'd0; baud_cnt <= 9'd0; if (rx_falling) begin state <= S_HALF; baud_cnt <= 9'd0; end end S_HALF: begin if (baud_cnt == BAUD_DIV/2 - 1) begin baud_cnt <= 9'd0; if (rx_clean == 1'b0) begin state <= S_DATA; bit_cnt <= 4'd0; end else begin state <= S_IDLE; end end else begin baud_cnt <= baud_cnt + 1'b1; end end S_DATA: begin if (baud_cnt == BAUD_DIV - 1) begin baud_cnt <= 9'd0; data_shift <= {rx_clean, data_shift[7:1]}; if (bit_cnt == 4'd7) begin bit_cnt <= 4'd0; state <= S_STOP; end else begin bit_cnt <= bit_cnt + 1'b1; end end else begin baud_cnt <= baud_cnt + 1'b1; end end S_STOP: begin if (baud_cnt == BAUD_DIV - 1) begin baud_cnt <= 9'd0; if (rx_clean == 1'b1) begin rx_data <= data_shift; rx_done <= 1'b1; end state <= S_IDLE; end else begin baud_cnt <= baud_cnt + 1'b1; end end default: state <= S_IDLE; endcase end end endmodule4.4 接收数据的移位拼装方向:为什么是 {rx_clean, data_shift[7:1]}
注意接收端的数据移位方向和发送端是反着来的。发送端是从最低位移出,接收端则是把先收到的位放到最高位,后收到的位逐步往低位移。因为先收到的是LSB,第一次采到的值要最终变成rx_data的第0位,所以每次采样后执行data_shift <= {rx_clean, data_shift[7:1]},让新采到的位放在最高位,整个寄存器右移,8次之后,最先采的位正好移到了第0位,最后采的位在第7位。
这里有个小细节:rx_done的输出放在S_STOP状态的确认逻辑里,也就是说只有当停止位也验证通过,才把data_shift里拼好的数据提交给rx_data,同时拉高done脉冲。如果停止位不是高电平,说明帧格式有误,这帧数据直接丢弃,等待下一帧。这种"帧错误检测"机制在噪声较大或者波特率不匹配的场景下非常有用,上位机收不到数据或者收到错误帧时,至少能有个依据判断是物理链路问题还是协议配置问题。
4.5 关于输入打两拍和三重采样过滤的取舍
代码里对rx_line做了两拍同步再使用,这是所有外部信号进入FPGA都必须做的安全处理。外部信号和FPGA内部时钟没有任何相位关系,直接用一个时钟去采样,如果不巧在信号变化中间采样,触发器的输出可能进入亚稳态——既不是0也不是1,而是不确定的中间值,这个值可能沿逻辑链传播出去导致系统崩溃。打两拍是业界通用的亚稳态消除手段,第一拍大概率能稳定,第二拍基本完全稳定。
关于三重采样过滤,也就是每个数据位采三次取多数表决,这个方法在传输线特别长、干扰特别大的RS485总线场景下才有必要,普通板级UART通信中用中点采样就够了。我建议初学者先跑通基本的中点采样,后续做RS485远距离传输时再考虑升级方案,不要一上来就把模块搞复杂。
5. 板级验证的完整流程:仿真、回环测试和串口助手联调
写完代码之后的验证环节,是决定项目成败的临门一脚。很多人仿真过了,上板就是不通,问题往往出在验证方法和工具使用上。
5.1 用testbench模拟上位机发送数据的关键波形构造
仿真首先要验证接收模块,也就是模拟PC端发数据给FPGA。testbench里需要构造一个有起始位、数据位和停止位的串行波形,而且要注意时序单位。如果你的testbench写的是#4340 clk_tb = ~clk_tb;这种以纳秒为单位的写法,50MHz时钟周期就是20纳秒,那么115200波特率的一个位时间约8680纳秒,也就是#8680。千万别把位时间弄错,否则仿真结果全是错的。
模拟发送一个字节0x55(二进制01010101),要从最低位开始发。0x55的LSB是1,所以波形应该是:先拉低8680纳秒作为起始位,然后按顺序:第0位1、第1位0、第2位1、第3位0、第4位1、第5位0、第6位1、第7位0,再拉高8680纳秒作为停止位。把这个序列用fork/join或者简单的阻塞赋值写出来,接到rx_line上,跑仿真就能看到rx_done在停止位之后拉高,rx_data输出0x55。
仿真还有一个必做的用例是"空闲毛刺干扰",也就是在空闲状态下给rx_line一个几十纳秒的低脉冲,验证模块不会误触发。这个用例能帮你确认起始位确认逻辑是否工作正常。
5.2 发个什么数据最有含金量:回环测试与特殊数据选择
上板调试第一步永远是回环测试。把FPGA的TX引脚直接连到RX引脚,把发送模块的tx_data设成一个固定值,比如0xA5,发送一次,观察接收模块rx_data是不是也收到0xA5。通了说明收发模块本身没问题、时钟分频没问题,问题只可能出在外部链路。这一步是性价比最高的排查手段,能瞬间砍掉一半的怀疑对象。
回环通过之后再做PC联调。发送一个数据时,我建议优先发0x55和0xAA这两个值,因为它们的二进制分别是01010101和10101010,数据位里全是密集的0-1跳变,对时序要求最严格。如果这两个值都能正确收发,说明时序裕量足够。很多看似"偶尔乱码"的问题,用0x55测试时立马现形。
另外一定要发0x00和0xFF。0x00发出来是8个连续的0,它的停止位后面紧跟着高电平,如果停止位采错会导致整个帧错位;0xFF发出来是8个连续的1,加上前后空闲全是高电平,容易和空闲态混淆,对起始位检测是个考验。这四个值过一遍,收发模块基本就稳了。
5.3 串口助手连接不上或乱码的第一排查顺序
用串口助手连不上FPGA,大概率不是FPGA代码问题,而是PC端链路问题。我的排查顺序永远是:
- 确认设备管理器里能看到USB转串口设备,比如FT232或者CH340。看不到就重装驱动。
- 确认串口号和波特率设置正确,波特率要和FPGA参数完全一致。
- 用一根杜邦线把串口模块的TX和RX短接,在串口助手里自发自收。能收到说明USB转串口模块本身没问题。
- 再看FPGA和串口模块之间的接线,TX对RX、RX对TX,别忘了共地。
第4条是最容易犯的错。两个设备通信不仅要"你说我听、我说你听"交叉连接,还必须有共同的电压参考点,也就是地线。只接信号线不接地线,信号电平完全没有参考,通信必然失败。很多从单片机转过来的朋友第一次玩FPGA串口时,最容易在这里卡住。
5.4 乱码和丢字节的几种典型原因对应汇总
我把常见现象和原因做成一个对照表,方便你排查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 完全收不到数据 | TX/RX接反、未共地、USB转串口驱动没装好 | 先测回环,再逐段检查接线 |
| 收到乱码 | 波特率不一致、分频器计算错误、时钟频率参数不对 | 核对PC端和FPGA端波特率,重新计算分频值 |
| 偶尔丢字节 | 上位机发送间隔太短、FPGA上层逻辑没有等待busy释放 | 检查tx_busy时序,发送间隔放宽 |
| 首字节丢失 | 上位机打开串口瞬间发送、电平未稳定 | 发送前加100ms延时 |
| 大部分正常但特定数据错 | 数据位顺序接反、校验位设置不一致 | 核对LSB-first移位方向,统一无校验配置 |
丢字节还有一个常见原因:FPGA内部的上层控制逻辑没有检查tx_busy就连续给多个发送请求。比如你写了个状态机每10个时钟周期发送一个字节,但115200波特率下发送一个字节需要约86微秒,也就是4300个时钟周期。如果发送使能间隔小于这个时间,新请求到来时上一帧还没发完,新数据就会丢失或覆盖当前正在发送的数据。解决办法就是用tx_busy信号做握手,等busy拉低再给下一个tx_start。
6. 项目实战中的踩坑记录与工程化改进思路
最后分享几个我在真实项目中踩过的坑,以及从"能跑"到"好用"的工程化改进思路。
6.1 用逻辑分析仪看波形,别用肉眼瞎猜
调试串口最强大的工具不是串口助手,而是逻辑分析仪。几十块钱的USB逻辑分析仪就能看串口波形,接上TX/RX之后,你能直接看到起始位、数据位、停止位的实际波形。波特率对不对、停止位是否完整、数据位顺序对不对,一眼就能看出来。
我之前调一个和传感器通信的项目,PC端发数据给FPGA,FPGA要回数据给PC,但PC收到的回复总是差一个字节。用串口助手看,数据是错的;用逻辑分析仪抓波形才发现,FPGA的发送逻辑里,数据移位寄存器的初值赋错了,导致第一个bit被重复发送了一次。这种问题靠串口助手和肉眼猜,估计能猜一晚上。
6.2 串口调试工具的进阶选择:十六进制显示和虚拟串口
串口助手建议用支持十六进制显示的工具。很多时候你以为收到的是乱码,其实收的是正确数据的十六进制形式,只是ASCII码表里对应的是不可见字符。比如0x00、0x0A、0xFF这类值在文本模式下显示都是"乱码",切换十六进制一看就原形毕露。
如果是调试FPGA和宿主机Linux通信的场景,Windows里可以用虚拟串口软件把物理串口映射到虚拟机。具体做法是:先安装USB转串口芯片的Windows驱动,确认串口号,然后在虚拟机设置里把该串口桥接给Linux虚拟机,Linux里就能看到/dev/ttyS0之类的设备节点。这种场景下特别要注意Windows端串口不能被串口助手之类的程序占用,否则虚拟机打不开。
6.3 调通单个字节之后,如何用FIFO实现高效连续收发
单个字节收发练通之后,90%的项目都会遇到连续收发多字节数据的需求。在FPGA和STM32、上位机之间的数据传输,按字节逐个握手效率太低,实际工程都会加FIFO缓存。
具体做法是在发送端:先把要发的一批数据依次写入发送FIFO,发送状态机一旦检测到FIFO非空,就自动取出一个字节发送,发完再取下一个,直到FIFO空了才回到空闲。接收端同理:接收模块每完成一帧数据就写入接收FIFO,上层控制逻辑随时可以从FIFO里读数据,不需要时刻盯着rx_done。Xilinx FPGA里直接用IP核生成FIFO就行,也可以自己用块RAM写个简单的异步FIFO。
引入FIFO后,tx_busy和写FIFO之间的配合就又是一个新的调试点:写FIFO的请求必须做好FIFO满判断,否则数据会静默丢失。我的建议是先用fifo_full信号挡住写请求,如果实在要尽量利用空间,宁可写满后覆盖最老的数据,也要保证新数据优先。
6.4 从UART到RS485、再到多机通信的可靠扩展路径
文章开头提过RS485是差分信号,适合长距离、多机通信。RS485总线上可以挂多个设备,用地址区分消息目标,物理层用方向控制引脚决定是发送还是接收。FPGA做RS485通信时,只需在UART收发模块外面加一个RS485收发芯片(比如MAX485),收发方向控制引脚连接到FPGA的一个GPIO,发送时拉高方向引脚、接收时拉低。
方向控制的时机很关键:必须在发送的起始位之前拉高方向引脚,给收发器留出切换时间;发送完最后一个停止位也不能立刻拉低,要等停止位在总线上完成传输,否则总线上可能产生一个额外毛刺,影响其他设备接收。通用的做法是在状态机里把停止位阶段延长两个位时间,确保方向切换安全。
如果要做多机通信,还需要在协议层加地址字段和CRC校验。地址字段指明这帧消息发给谁,CRC校验保证数据在总线上传输过程没被篡改。协议栈的实现在逻辑上和UART本身是分离的,UART模块只负责把字节可靠地搬进搬出,组帧、解帧、校验交给上层状态机处理。
6.5 我自己保持的一个收尾习惯:固定发送间隔并加入帧格式头
最后分享一个实际项目里救过我很多次的习惯。和上位机或者STM32联调时,不要把发送节奏做得太极限,尽量在两个字节之间留一点时间间隔。FPGA内部逻辑再快,上位机的串口缓冲和解析程序处理也需要时间,如果FPGA以最大速度连续灌数据,上位机解析程序可能出现处理不及时导致丢包。
另外,项目里的UART通信尽量不要裸发数据,要设计一个简单的帧格式,比如:帧头0xAA + 数据长度 + 有效数据 + 累加和。帧头用来让接收方"对齐",长度字段让接收方知道要收几个字节,累加和验证数据完整性。刚开始觉得麻烦,但一旦系统里出现过一次因为数据错位导致的严重Bug,你就明白帧格式的价值了。UART本身只保证字节传输,不保证字节的含义和完整性,这部分工作需要你自己来补。
我在实际项目中用这套方法调过的串口链路,从最简单的FPGA到PC单字节通信,到FPGA和STM32的百KB级批量数据传输,再到RS485总线上的多机轮询,基本都能在半天内把问题定位到具体模块。希望这篇文章能帮你少走弯路,一次点亮你的UART串口通信链路。