
我前后做了三四个带串口的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代表逻辑10V代表逻辑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 9d0; baud_pulse 1b0; end else if (tx_busy || tx_start_pulse) begin if (baud_cnt BAUD_DIV - 1) begin baud_cnt 9d0; baud_pulse 1b1; end else begin baud_cnt baud_cnt 1b1; baud_pulse 1b0; end end else begin baud_cnt 9d0; baud_pulse 1b0; end end // 状态机 localparam S_IDLE 3d0; localparam S_START 3d1; localparam S_DATA 3d2; localparam S_STOP 3d3; reg [2:0] state; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; tx_line 1b1; tx_busy 1b0; data_shift 8d0; bit_cnt 4d0; tx_start_d 1b0; end else begin tx_start_d tx_start; case (state) S_IDLE: begin tx_line 1b1; tx_busy 1b0; bit_cnt 4d0; if (tx_start_pulse) begin tx_busy 1b1; state S_START; data_shift tx_data; end end S_START: begin tx_line 1b0; if (baud_pulse) begin state S_DATA; bit_cnt 4d0; end end S_DATA: begin tx_line data_shift[0]; if (baud_pulse) begin data_shift {1b0, data_shift[7:1]}; if (bit_cnt 4d7) begin bit_cnt 4d0; state S_STOP; end else begin bit_cnt bit_cnt 1b1; end end end S_STOP: begin tx_line 1b1; 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 {1b0, 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 1b1; rx_sync2 1b1; 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 1b1; end else begin rx_prev rx_clean; end end reg [2:0] state; localparam S_IDLE 3d0; localparam S_HALF 3d1; localparam S_DATA 3d2; localparam S_STOP 3d3; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; baud_cnt 9d0; baud_pulse 1b0; bit_cnt 4d0; data_shift 8d0; rx_data 8d0; rx_done 1b0; end else begin rx_done 1b0; case (state) S_IDLE: begin bit_cnt 4d0; baud_cnt 9d0; if (rx_falling) begin state S_HALF; baud_cnt 9d0; end end S_HALF: begin if (baud_cnt BAUD_DIV/2 - 1) begin baud_cnt 9d0; if (rx_clean 1b0) begin state S_DATA; bit_cnt 4d0; end else begin state S_IDLE; end end else begin baud_cnt baud_cnt 1b1; end end S_DATA: begin if (baud_cnt BAUD_DIV - 1) begin baud_cnt 9d0; data_shift {rx_clean, data_shift[7:1]}; if (bit_cnt 4d7) begin bit_cnt 4d0; state S_STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin baud_cnt baud_cnt 1b1; end end S_STOP: begin if (baud_cnt BAUD_DIV - 1) begin baud_cnt 9d0; if (rx_clean 1b1) begin rx_data data_shift; rx_done 1b1; end state S_IDLE; end else begin baud_cnt baud_cnt 1b1; 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端发数据给FPGAFPGA要回数据给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串口通信链路。