
做FPGA的应该都有这种体会串口这玩意看着简单到实际工程里却总在最后一公里捅娄子。前阵子调一块板子串口怎么都不通最后发现不是RTL代码的问题而是波特率分频计数器多写了一个周期导致实际波特率偏了0.2%。这种问题在直接用Vivado自带的UART IP时几乎不会暴露但只要你哪天想加个自定义帧结构、想省点LUT、想把收发逻辑整合成自己的模块就不得不面对“自己撸一个串口核并封装成IP”这件事。这篇内容就是围绕Vivado里自定义串口通信IP核的完整实现来写的适合有一定Verilog基础、知道Block Design是什么但没正经封装过自定义IP的人。我会把UART协议本身的时序细节、RTL实现思路、Vivado IP Packager封装步骤、仿真排错和上板调试讲透整个过程基于我实际做过的一个可上板版本代码和流程都可以直接拿去参考。1. 为什么放着现成的UART IP不用偏要自己写一个1.1 Vivado自带串口IP的几个“别扭”之处Xilinx在Vivado里提供了AXI UART Lite和UART 16550两种常见串口控制器功能上肯定没问题但实际工程里用起来有几点不太顺手。第一UART Lite的寄存器非常固定想调整停止位、校验模式或者加一个“收到指定字节就自动回复”的功能得去查一堆寄存器手册绕来绕去并不直观。第二UART 16550虽然功能强大、兼容老系统但对大多数FPGA片内逻辑来说太“重”了一堆历史负担寄存器占资源不说逻辑也不透明出了问题没有源码可看只能盲猜。第三也是最关键的串口在很多项目里只是排查问题的辅助通道但实际项目的通信协议往往不是标准UART能搞定的可能需要一帧多字节连续收发、可能需要CRC校验、可能需要在收到特定命令后自动切换波特率。这种情况下用现成IP不如自己控制来得直接。1.2 开源串口模块为什么也要谨慎GitHub上确实有一堆现成的UART Verilog模块好多写得也不错但拿来直接投入使用时经常会遇到这几个问题端口风格不一致有的用valid/ready握手有的用简单的en/rdy集成到已有代码里要对改半天复位策略粗糙很多开源模块只处理了同步复位却忽略了异步复位释放时的亚稳态问题对时钟频率和波特率组合没有做参数校验换一颗芯片、换一个时钟树配置分频值直接溢出或精度严重下降只提供了发送或接收单端模块没有封装成完整IP没法拖进Block Design里和AXI总线快速连接。这些不是致命伤但工程化起来要自己补的坑太多。与其花一个小时去适配一个半成品不如花半天自己搞一个熟悉的核后面任何改动都在掌控之内。1.3 自定义IP核的真正价值把串口逻辑封装成IP核不是为了让界面好看而是为了“复用”和“标准接口”。封装之后这个模块就有了自己的名字、版本号、参数定义和端口接口。下次搭新工程直接从IP Catalog拖进去就能用团队其他人调你的Block Design时不需要看你几百行复杂RTL只需要知道你提供的寄存器映射和物理引脚就行。这种标准化带来的好处在工程规模变大之后极其明显。我自己最直观的一个感受是自定义IP核之后从一个空工程到跑通串口收发通常半小时内就能完成。不需要把那段UART逻辑在每个项目里复制粘贴一遍更不需要每次都要重新做一次仿真约束。下面这张表是我个人对不同方案的适用判断方案适用场景劣势AXI UART Lite快速原型只需标准收发寄存器呆板帧格式不可控UART 16550老系统兼容需求资源占用高逻辑不透明开源Verilog模块学习参考、临时验证风格杂工程复用差自封装UART IP长期项目、多工程复用前期一次性工作量较大2. 动手之前先把UART时序吃透波特率、数据帧与采样点2.1 UART帧结构到底是什么串口通信的本质是一根线上的电平随时间变化。标准UART一帧由四个部分组成起始位、数据位、校验位可选和停止位。空闲状态时TX线保持高电平。发送方先拉低电平一个位时间这个下降沿就是起始位告诉接收方“我要开始发数据了”。接着按位发送数据通常低位在前。数据位结束后如果有校验位就根据数据内容决定这一位是0还是1没有校验位就直接进入停止位。停止位必须是高电平持续1位、1.5位或2位时间之后可以回到空闲态也可以紧接着发下一帧。这个结构里最容易犯的错误是把“停止位”当做一个普通位去传输实际上它只是用来保证帧与帧之间至少有一段高电平时间接收方靠它确认一帧的结束。如果停止位的电平不对接收方会判定为帧错误常见表现就是串口工具里看到“溢出错误”或者直接乱码。2.2 波特率发生器的分频计算与误差波特率就是一秒传多少个bit比如115200波特率就是每秒传115200个0/1电平。FPGA内部没有魔法只能用系统时钟去计数分频产生一个接近目标波特率的节拍。分频公式很简单分频计数值 系统时钟频率 / 目标波特率。以我常用的50MHz系统时钟为例目标是11520050000000 / 115200 434.0278取整为434那么实际波特率 50000000 / 434 115207.37 bps偏差 (115207.37 - 115200) / 115200 ≈ 0.0064%这个精度对UART完全够用。UART接收端只要采样的误差不超过半个位周期就不会采错通常1%以内的偏差都没问题。但要注意一点分频值一旦在代码里写死换一块晶振或者换一个系统时钟就得手动重新算。这也是为什么我在设计RTL时坚持把系统时钟频率和波特率定义成模块参数而不是直接写死在状态机里。还有一类隐蔽问题如果你用的是PLL出来的时钟频率可能是48MHz、125MHz甚至156.25MHz这时候分频后的误差会更大。比如48MHz时钟下生成115200波特率分频值416.666取整417实际波特率约115108.51偏差约0.08%仍然在可接受范围内。如果时钟频率太低比如1MHz时钟还想生成115200波特率分频值只有8.68量化误差就会超过5%这种配置本身就不合理。2.3 接收端采样策略中点采样和过采样UART是异步通信发送方和接收方没有独立的时钟同步线接收方怎么知道该在哪一刻采样数据呢答案就是“用本地波特率时钟去找中点然后等间距采样”。最常见的做法有两种一是只做中点采样即检测到起始位下降沿后延时一个半位周期到起始位中心然后再延时一个位周期到达第一个数据位的中心点在这个点采样数据位之后每过一个位周期采样一次直到采完所有数据位和停止位。这种做法的计数器逻辑最简单很适合自定义IP核使用实测也很稳定。另一种是过采样常见的是16倍波特率采样即用16倍于波特率的时钟去采样每一位在每个位周期内采样16次然后通过投票多数决来判断该位是0还是1。这个策略抗干扰能力强一些但计数器、移位逻辑复杂还会消耗更多触发器。对大多数FPGA内部走线质量良好、外部用RS232电平转换芯片的场合中点采样完全足够没必要为了“理论先进”牺牲简洁。2.4 用一个“对暗号”的类比帮助理解如果你第一次接触UART可以把收发双方的比对待成两个约好节奏的人对暗号一个说“我来啦”另一个听到后不能立刻回必须“心里默数半拍”再回这样才能保证两边说出的字正好对得上。起始位就是那声“我来啦”分频计数器就是心里默数的节奏中点采样就是保证你在对方“说到一半”的时候按下录音键而不是在话刚出口或者话尾时录。这样理解之后再去看RTL代码里的状态机就会顺畅很多。3. RTL实现一个能上板的串口收发核到底怎么撸3.1 模块结构与端口规划在设计RTL之前先把端口规划清楚。我习惯把串口拆成两个子模块一个是发送模块uart_tx一个是接收模块uart_rx然后由一个顶层模块把两个子模块组合起来。这样写的好处是调试的时候可以单独仿真发送、单独仿真接收定位问题快很多。顶层端口如下clk系统时钟输入后续封装成IP时可以做成可参数化时钟频率rst_n异步复位输入低有效tx_data[7:0]要发送的8位数据tx_valid发送请求高有效tx_busy发送忙指示高电平表示正在发送发送完成自动拉低tx串行发送输出接外部PHY芯片或直接接RS232电平转换rx串行接收输入同样接外部物理层rx_data[7:0]接收到的8位数据rx_valid接收完成指示一个时钟周期的高脉冲表示本次数据有效。为什么不把发送和接收直接合并成一个模块因为实际工程里发送侧和接收侧往往是不同的节奏。发送端可以靠CPU写寄存器触发接收端则是随时可能有数据进来如果耦合在一起状态机复杂度会上升不少尤其在封装成AXI-Lite IP之后分开写可以避免很多奇怪的跨状态交互问题。3.2 发送模块Verilog代码分频计数和移位输出发送模块的核心是检测到tx_valid后依次把起始位、数据位、停止位按波特率时钟移位输出。参考代码如下module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire tx_valid, output reg tx_busy, output reg tx ); localparam BAUD_CNT_MAX CLK_FREQ / BAUD_RATE - 1; typedef enum reg [1:0] { IDLE 2d0, START 2d1, DATA 2d2, STOP 2d3 } state_t; state_t state; reg [15:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] tx_shift; always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx 1b1; tx_busy 1b0; state IDLE; baud_cnt 16d0; bit_cnt 3d0; tx_shift 8d0; end else begin case (state) IDLE: begin tx 1b1; if (tx_valid !tx_busy) begin tx_busy 1b1; tx_shift tx_data; state START; end end START: begin tx 1b0; if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; state DATA; bit_cnt 3d0; end else begin baud_cnt baud_cnt 1b1; end end DATA: begin tx tx_shift[0]; if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; tx_shift {1b1, tx_shift[7:1]}; 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 tx 1b1; if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; state IDLE; tx_busy 1b0; end else begin baud_cnt baud_cnt 1b1; end end endcase end end endmodule这里要注意几个细节。第一发送模块在IDLE状态下如果tx_valid和tx_busy同时出现要把tx_busy拉高这是为了防止一个长有效的tx_valid信号在发完一帧后又触发发送同一帧数据。第二DATA状态里移位的写法{1b1, tx_shift[7:1]}是因为发送完最低位后高位移进来的值无所谓但为了避免移位后高位出现不定态统一填1更安全。第三baud_cnt从0计数到BAUD_CNT_MAX总共是BAUD_CNT_MAX1个周期刚好等于一个波特率周期。3.3 接收模块Verilog代码下降沿检测和中点采样接收模块的难点在于异步输入信号的处理。需要先把rx信号打两拍同步到本地时钟域然后检测下降沿再按波特率时钟节点采样数据位。module uart_rx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire rx, output reg [7:0] rx_data, output reg rx_valid ); localparam BAUD_CNT_MAX CLK_FREQ / BAUD_RATE - 1; localparam HALF_CNT (CLK_FREQ / BAUD_RATE) / 2 - 1; typedef enum reg [1:0] { IDLE 2d0, START 2d1, DATA 2d2, STOP 2d3 } state_t; state_t state; reg [15:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] rx_shift; reg [1:0] rx_sync; reg rx_sync_prev; reg rx_sample; reg rx_done; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_sync 2b11; rx_sync_prev 1b1; rx_sample 1b1; end else begin rx_sync {rx_sync[0], rx}; rx_sync_prev rx_sync[1]; rx_sample rx_sync[1]; end end wire rx_negedge rx_sync_prev ~rx_sync[1]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; baud_cnt 16d0; bit_cnt 3d0; rx_shift 8d0; rx_data 8d0; rx_valid 1b0; rx_done 1b0; end else begin rx_valid 1b0; case (state) IDLE: begin if (rx_negedge) begin state START; baud_cnt 16d0; end end START: begin if (baud_cnt HALF_CNT) begin // 已到起始位中点继续等一个位周期到bit0中点 baud_cnt 16d0; state DATA; bit_cnt 3d0; end else begin baud_cnt baud_cnt 1b1; end end DATA: begin if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; rx_shift {rx_shift[6:0], rx_sample}; 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 if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; state IDLE; rx_data rx_shift; rx_valid 1b1; end else begin baud_cnt baud_cnt 1b1; end end endcase end end endmodule这段代码的重点在于采样时机。检测到rx下降沿后先从IDLE进入START状态计数到半位周期时说明已经到了起始位中点这时转入DATA状态但注意此刻还没有采样数据位。进入DATA状态的第一个周期开始每数满一个完整位周期才采一次数据位。所以第一次数据采样实际发生在bit0的中点而不是起始位中点。这个顺序如果你写反了会导致所有数据位整体偏移一个位周期接收到的字节永远是错的而且是那种看起来有规律、实际每个bit都错位的错误。3.4 接收数据缓冲要不要加FIFO上面接收模块每接收到一个字节输出一个rx_valid脉冲。但如果你的系统处理不过来比如CPU在某个时刻正好在忙别的这个rx_valid脉冲只持续一个时钟周期数据没来得及读走就会被下一帧数据覆盖。这就是很多初学者在“高速连续发送多字节”时遇到丢包的根本原因。解决方法要么是接收模块内部增加一个深度很小的FIFO要么是在封装IP时用AXI-Lite的控制寄存器配合中断机制。我的建议是如果只做纯逻辑IP不加CPU那就在IP内部加一个最简单的8深或16深FIFO用rx_valid作为写使能用后级读信号作为读使能如果最终要挂到Zynq的PS端更推荐使用AXI-Lite方式由PS端读取数据寄存器配合中断或者轮询方式消费数据。3.5 一个使用注意跨时钟域与复位虽然串口本身速度不高但FPGA内部可能存在多个时钟域。rx信号来自片外和本地系统时钟没有任何相位关系所以打两拍同步是必须要做的。我在接收模块里已经做了两级同步但如果你把这个模块嵌入到更大的系统还要注意其他地方尽量不要直接使用rx原信号。复位方面建议用异步复位、同步释放避免在复位释放过程中采样到亚稳态导致状态机跑飞。上面代码里用了异步复位的写法综合工具一般也能良好处理只要在XDC里适当加上复位相关的约束即可。4. 把RTL变成IP核Vivado里自定义IP封装的完整流程4.1 IP Packager入口和三种封装方式代码写好、仿真通过之后下一步就是把它变成Vivado能直接拖进Block Design的IP核。在Vivado里在菜单栏选择Tools - Create and Package New IP会弹出向导。这里有几种选择Package a custom IP from source file从你已有的RTL源码打包这是最简单的方式Create a new AXI4 peripheralVivado直接生成一个带AXI-Lite接口的模板你可以在这个模板里再挂载自己的串口逻辑Package current project把当前整个工程打包成IP。大多数情况下用第一种就够了。如果你确定要挂Zynq的AXI总线我建议直接用第二种生成带AXI-Lite接口的外设模板然后再把串口逻辑嵌进去这样比硬生生给已有RTL手动加AXI握手信号要省事得多。4.2 面板配置详解名称、版本、参数化端口选择Package a custom IP之后向导会让你指定顶层RTL文件。选定后Vivado会解析所有端口和参数并打开IP Packager界面。在左侧菜单里需要重点关注的项包括Identification设置Display name、Version、Vendor等。Display name最好起一个一眼能看懂的名字比如MyUart版本号建议从1.0开始Compatibility选择支持的器件家族不勾选的话可能在某些板卡上无法使用File Groups确认RTL源文件都包含在内缺文件会导致IP例化失败Ports and Interfaces这里可以看到模块端口。Vivado不总能自动识别时钟和复位端口需要手动给clk选择Clock接口、给rst_n选择Reset接口tx和rx可以选External接口Parameters把CLK_FREQ和BAUD_RATE暴露成IP参数这样使用IP时可以直接在GUI里填不同时钟频率和波特率不用改RTL再重新封装。以我的经验最容易出问题的地方在于时钟接口没有正确指定。如果clk没标记成ClockBlock Design里就不太好自动连接时钟后面所有逻辑都乱套。封装前务必检查一遍端口接口列表宁可多花两分钟逐个确认也不要到最后集成阶段再来回折腾。4.3 集成到Block Design纯FPGA与Zynq PS端的差异封装完成后在任意新工程的IP Catalog里搜索自定义IP名称就能找到它。拖入Block Design后纯FPGA工程和Zynq工程的连接方式不太一样。如果是纯FPGA工程比如Artix-7直接控制串口操作很简单把IP的clk、rst_n连到系统时钟和复位模块tx、rx端口连到Block Design边界上生成输出产品然后在顶层XDC里把对应的物理引脚约束到FPGA管脚就行。如果是Zynq工程你需要一个Processing System的PS端作为主设备自定义串口IP通过AXI-Lite从接口挂到PS的GP主端口上。连接好之后打开Address Editor分配一个基地址然后生成Bitstream在SDK/Vitis里通过寄存器读写来控制串口。这比纯FPGA方式多了“确定基地址”这个步骤但后续PS端发数据只需要向某个地址写数接收数据只需要从某个地址读数非常方便。4.4 在AXI-Lite总线上挂自定义IP寄存器映射设计如果你选择了第二种封装方式Vivado自动生成AXI4-Lite模板默认会有一组寄存器slv_reg0、slv_reg1等你可以把它们重新定义成语义清晰的寄存器。我的设计习惯是这样的寄存器偏移方向功能0x00只读状态寄存器bit0 tx_busybit1 rx_validbit2 rx_overrun0x04写发送数据寄存器PS写入要发送的字节自动触发发送0x08只读接收数据寄存器PS读取收到的字节0x0C写控制寄存器bit0复位FIFO/内部逻辑bit1使能中断这样设计的好处是PS端代码可读性很强。发送一个字节只需三步读状态寄存器确认tx_busy为0往发送数据寄存器写数完成。接收则更简单读状态寄存器判断rx_valid然后读接收数据寄存器拿数。这种寄存器级接口哪怕将来换内核、换总线只要保持寄存器地址和位定义不变上层驱动代码就不用动。4.5 常见封装错误和解决思路我自己封装IP的过程中踩过几个比较典型的坑封装后IP Catalog里找不到多半是IP版本和当前工程版本不兼容或者编译指纹有问题去Tools - Reload IP Catalog刷新一下修改RTL后IP不更新改完RTL必须回到IP Packager里点击Review and Package - Re-Package IP同时工程必须把旧版本IP实例删除重新添加端口类型错误最常见的是clk没有被识别成Clock接口导致在Block Design中无法自动关联时钟网络参数改了但仿真行为不变参数修改后需要重新生成输出产品和综合否则综合工具可能还在用旧的elaborated design。5. 仿真与上板调试一次实测踩坑记录5.1 写一个能“自己测自己”的testbench有了完整的RTL和封装流程仿真测试不能省。testbench的核心思路是模拟PC端串口发出的波形发一个字节给接收模块再把接收模块收到的数据交给发送模块发出去然后检查发送波形是否能被另一个接收逻辑正确还原。这个环回测试虽然没法验证物理链路但能快速确认内部逻辑是否自洽。下面是一个极简testbench框架生成一个115200波特率的字节波形送入DUT的rx端timescale 1ns / 1ps module tb_uart; localparam real BIT_TIME 1_000_000_000.0 / 115200; // ns reg clk 0; reg rst_n 0; reg rx_line 1; reg tx_valid_stim 0; reg [7:0] tx_data_stim 0; wire tx_line_out; wire tx_busy_w; wire [7:0] rx_data_out; wire rx_valid_out; uart_top #( .CLK_FREQ(50_000_000), .BAUD_RATE(115200) ) dut ( .clk(clk), .rst_n(rst_n), .tx_data(tx_data_stim), .tx_valid(tx_valid_stim), .tx_busy(tx_busy_w), .tx(tx_line_out), .rx(rx_line), .rx_data(rx_data_out), .rx_valid(rx_valid_out) ); always #10 clk ~clk; // 50MHz integer i; task uart_send_byte(input [7:0] data); begin rx_line 1; #(BIT_TIME); rx_line 0; #(BIT_TIME); for (i 0; i 8; i i 1) begin rx_line data[i]; #(BIT_TIME); end rx_line 1; #(BIT_TIME); end endtask initial begin rst_n 0; #100; rst_n 1; #100; uart_send_byte(8hA5); wait (rx_valid_out); $display(receive data: %02X, rx_data_out); // 再把收到的数据从tx发出去 tx_data_stim rx_data_out; tx_valid_stim 1; #100; tx_valid_stim 0; #(BIT_TIME * 12); $finish; end endmodule这里有一个实际体会仿真时不建议用#434这种时钟周期整数来代替真实的位时间因为仿真时间单位和位时间的关系容易搞混尤其当分频值不是整数时模拟出来的波形时序会和真实情况完全脱节。直接用#(BIT_TIME)按真实时间生成激励反而更能暴露RTL里的边界问题。5.2 环路回环测试与PC联调的区别仿真过了不代表上板就没问题。环路回环测试在板上有两种常见做法一种是把FPGA的TX引脚直接短接到RX引脚让数据从内部发送后立刻被接收整个过程中数据不经过PC另一种是接上USB转串口线用PC串口工具发数据给FPGAFPGA再把收到的数据回发给PC。第一种方式验证的是FPGA内部收发逻辑和物理引脚的电平翻转是否正常。如果你发现回环接收到的数据和发送数据不一致那问题多半出在内部逻辑和PC端无关。第二种方式验证的是完整链路包括电平转换芯片、USB转串口芯片、PC端驱动和串口工具配置任何一个环节出问题都会导致收发异常。所以我每次都是在板上先做两种回环测试先短路回环验证FPGA内部再走USB线验证物理层。5.3 典型问题与排查链路下面我把实测遇到的几个问题整理成完整的排查链路方便你出现问题时有条理地定位。第一个问题是串口工具完全收不到数据。排查链路先看FPGA有没有正确产生TX波形用示波器或逻辑分析仪抓TX引脚如果TX一直是高电平查tx_valid有没有拉起来查RTL状态机是否卡在IDLE如果TX有波形但PC收不到看电平转换芯片的TXD/RXD方向是不是接反了看USB转串口线的型号是否需要装驱动看串口工具选择的串口号和波特率对不对。第二个问题是收到数据但全部乱码。这个大概率是波特率不匹配。先不要急着怀疑协议用示波器测一下TX引脚上每一位的脉宽用1除以脉宽就能得到实际波特率。如果实际波特率和配置的115200差别超过2%回RTL把分频值重新算一遍。如果脉宽是对的再检查数据位、停止位和校验位的格式设置PC端串口工具里默认8N18个数据位、无校验、1个停止位如果RTL里写的是8E1那肯定乱码。第三个问题是仿真正常、上板只有第一个字节正常。这种问题多出在接收FIFO缓冲和后级读取逻辑上。每收到一个字节产生rx_valid脉冲但如果后级在rx_valid期间没有及时读取rx_data下一帧到来时旧数据会被覆盖。解决方法是要么加FIFO要么在AXI-Lite方式下用中断方式及时读取数据。如果你在串口工具里开启“自动发送”功能连续发数据这个问题会暴露得更快。第四个问题是板子本身能收能发但过几分钟后通信突然卡死。排查思路是看复位逻辑和状态机死锁。常见原因是FIFO被写满后没有清空机制或者状态机在STOP状态遇到异常电平比如接收线长时间为0而卡住。解决方法是增加一段超时处理如果接收一帧时间超时未完成就强制回到IDLE。5.4 ILA在线抓信号触发条件和采样时钟仿真测试跑通后上板调试时我最依赖的工具就是Vivado的ILA集成逻辑分析仪。给ILA添加探针的要点是采样时钟必须和被测逻辑时钟一致一般直接用系统时钟clk探针信号至少包括rx或者同步后的rx_sample、tx、tx_busy、rx_valid、状态机的state信号。触发条件设置上发现不了问题时可以先触发在rx下降沿然后打开窗口看一整帧的波形。如果波形显示起始位、数据位、停止位形状正确说明物理层和接收模块都没问题。再看tx输出对比发送和接收的波形时序。用ILA的好处是能看到FPGA内部信号的精确时序关系比单纯靠示波器判断要高效得多。如果你发现ILA抓出来的发送数据波形中间有一个bit电平不对可以通过观察tx_busy信号判断是否是发送期间的某个时刻被异常拉高或者tx_data在发送过程中被外部修改了。这种情况我遇到过原因是PS端在发送过程中又写了一次发送数据寄存器导致状态机内部移位寄存器被覆盖。解决办法是在RTL里加一个发送数据锁存器发送期间只锁存idle状态下的写入数据发送过程中忽略新写入。这个经验如果没有ILA抓信号只靠串口工具上调参数可能得排查很久才能定位。6. 后续扩展从单字节到完整串口子系统6.1 可变波特率、奇偶校验和数据位选择怎么改前面给出的RTL是固定8N1格式但在实际应用中有的设备要求9位数据、偶校验或者1.5个停止位。扩展思路是给顶层增加参数或寄存器控制位在RTL内部把波特率分频值、数据位数和校验方式变成条件分支。比如接收模块在DATA状态可以增加一个“是否发送校验位”的判断在数据位结束后插入一个校验位状态发送模块同样增加一个校验计算用异或逻辑判断奇偶结果。把波特率改成运行时可变稍微复杂一些。实现方式是把分频值从参数改为内部寄存器PS端通过AXI-Lite写控制寄存器修改波特率。注意修改后要先把状态机复位到IDLE否则分频计数器的中间值会产生半个周期的错误信号。6.2 接收FIFO与超时机制上位机协议的刚需如果你要处理的不是单字节回显而是一帧几十个字节的数据包接收FIFO和超时机制基本是刚需。FIFO深度根据最大帧长来定可以做成1.5倍帧长在UART接收水平方向上并不会一次到位而是靠一个定时器维护“距上次收到字节的时间”超过一定间隔就认为一帧结束然后通知处理器去FIFO取数据。这个超时时间一般设置成3~5个字节的传输时间具体按波特率计算。6.3 工程化版本管理、IP升级与驱动配套IP核封装完成后强烈建议把IP源工程和RTL源码一起纳入版本管理不要只保留打包后的xcix或者zip文件。因为后期改逻辑时如果没有源工程重新封装会很痛苦。版本号建议沿用Vivado的格式每次修改后递增一位修改RTL后必须重新生成输出产品否则旧IP实例在用的时候会自动报“locked”状态整个Block Design都无法更新。配套的驱动代码也建议跟着IP版本一起管理。我在纯逻辑工程里会直接把寄存器操作封装成一组Verilog task在Zynq工程里会封装成C语言函数。这样IP升级时只需要修改底层函数内部实现上层应用代码不用动。6.4 如果再让我设计一次的自选功能如果给我一块新板子做串口通信我会在基础收发核之外优先加两个常规文档里不会写的小功能一个是发送完成的中断触发让CPU不用轮询tx_busy另一个是接收FIFO半满中断避免一帧数据全部积压后一次性中断导致的延迟抖动。这两个功能对实时性要求较高的场合尤其重要实现起来也不难只是很多人一开始设计时没想到后面补需求时再改IP会牵动一堆牵连。我个人的体会是自定义串口IP核真正带来的价值并不是“省了一个现成IP的资源”而是让你对通信链路上的每一个环节都有掌控力。从协议时序到采样精度从中断机制到寄存器映射你实现的每一个细节在后续多项目复用时都在帮你节省时间。这套基础框架搭好之后剩下的扩展就只是在这个骨架上不断加肌肉而已。