
做FPGA开发的兄弟几乎都绕不开串口调试这个环节。一个UART通信模块加一个串口调试助手基本是调试板上PL逻辑的标配手段。以前我刚开始用Vivado那会总习惯直接从IP Catalog里拖一个Axi Uartlite IP出来用图省事。但用得久了你会发现这个官方IP虽然稳定可定制性比较差而且如果你想自己定义帧格式、加FIFO缓存或者单纯想搞明白UART到底是怎么在FPGA里跑起来的现成的IP核帮不了你太多。于是后来我干脆自己在Vivado里做了一个自定义的串口通信IP核从零开始把整个流程跑通以后收获确实不小。这篇文章就来聊聊完整实现过程从方案设计、AXI4-Lite外设创建、Verilog逻辑编写到IP封装和上板实测适合那些用过Vivado但还没亲手封装过自定义IP的工程师朋友。1. 项目目标与方案设计思路1.1 为什么需要自定义串口IP核我最早用官方Axi Uartlite IP时最不方便的地方在于想改它内部细节特别费劲。比如想加一个简单的帧校验或者自定义一个带地址字段的私有协议在官方IP里根本没法优雅地实现只能在外面包一层逻辑去协调代码写起来绕仿真也不好看。另一个很现实的场景是项目复用。同一个FPGA平台上串口通信功能往往会在多个工程里反复出现。如果每次都在顶层RTL里复制粘贴一遍UART代码代码量一多就容易改乱而且不同工程师的代码风格还不一样。但如果你把它封装成一个标准的自定义IP核在Vivado的IP Catalog里双击就能添加跟用官方IP一样方便内部逻辑还能完全掌控。这一点在团队协作和项目交接时价值特别明显。还有一点是学习价值。自己动手写一个串口IP核远比直接调用现成IP更能帮助你理解UART底层原理包括起始位怎么检测、数据位如何采样、波特率误差怎么控制、异步信号怎么同步。这些知识在之后设计其他通信接口时都是通用的属于一次投入长期受益的技术积累。1.2 UART协议核心要点UART全称是Universal Asynchronous Receiver/Transmitter通用异步收发器。它最核心的特点就是异步收发双方不需要共享时钟只要约定好波特率就行。一个标准UART帧结构是这样的空闲状态TX/RX线是高电平起始位发送方拉低电平1个位时间告诉接收方我要开始发数据了数据位从最低位开始逐bit发送常见的是8位数据校验位可选奇校验或偶校验停止位拉高电平1个或2个位时间表示一帧结束日常最常用的配置是115200-8-N-1也就是波特率115200、8个数据位、无校验、1个停止位。这个配置基本在所有串口调试工具里都是预置好的拿来就用。在FPGA中实现UART说白了就两个任务接收端要正确检测到起始位的下降沿然后在数据位的中间附近采样避免采到信号翻转边沿发送端要按约定的波特率逐bit输出数据。两者对时序要求都不高但波特率误差必须控制在合理范围内串口通信一般容许的误差在2%左右工业级设计通常要求控制在1%以内。1.3 方案对比与选型分析Vivado里实现串口通信至少有三条路可以走。第一条路是直接用官方Axi Uartlite。稳定、省事Block Design里拖进来配置一下就能用但内部实现是黑盒寄存器映射虽然有文档可查一旦项目需要特殊改造这条路就走不通了。第二条路是不封装直接在RTL里写uart_tx和uart_rx模块需要的时候在顶层例化。优点是灵活、容易理解缺点是不好复用工程一换就得重新复制代码而且和处理器联调时还得自己写一套寄存器读写逻辑。第三条路就是本文要讲的写一个自定义IP核给串口模块加上标准的AXI4-Lite接口。这样内部逻辑完全自己掌握想改什么改什么同时因为有AXI接口在Zynq或MicroBlaze系统里可以直接通过地址读写来控制串口使用体验和官方IP基本一样。代价就是封装阶段多一些工作量。我个人的推荐是把第三条路作为长期方案。一次封装到处复用可维护性和团队协作效率都远好于自己复制RTL。2. 开发环境与工程准备2.1 Vivado版本选择与工程创建本次实例基于Vivado 2023.1实际上2019.1之后的版本操作流程基本一致你手上是什么版本就用什么版本不用太纠结。唯一值得提醒的是尽量装完整版不要为了图省事装WebPack版自定义IP核的一些Block Design功能在WebPack下偶尔会有兼容性小毛病。新建工程的步骤很常规打开Vivado点击Create Project输入工程名比如uart_ip_demo工程类型选RTL Project勾选Do not specify sources at this time选择所使用的FPGA器件型号。我用的是XC7Z020CLG484-1型号不同不影响后续操作工程创建好之后建议的目录结构是这样uart_ip_demo/ src/ # 存放临时RTL测试文件 ip/ # 自定义IP核相关文件 bd/ # Block Design文件 xdc/ # 约束文件规范化的目录结构看着好像多此一举但工程迭代几轮之后就会体会到好处。Vivado的IP文件依赖关系特别复杂单独管理能省掉很多麻烦。2.2 自定义IP的两种封装方式Vivado里创建自定义IP有两种方式。一种是Create and Package New IP向导会引导你从一个模板开始创建带AXI接口的IP这是最标准、最推荐的方式。向导自动生成AXI4-Lite从接口的Verilog骨架包括寄存器读写逻辑和地址译码我们只需要在骨架里加入串口逻辑就行。另一种是把已经写好的模块手动包进去不生成AXI接口只做简单端口导出。这种方式适合纯逻辑、不跟处理器打交道的情况。在Tools - Create and Package New IP里选择Package a specified directory把包含RTL文件的目录导入即可。两种方式我都试过。如果是做纯PL的串口透传第二种方式最快但想和Zynq软核或MicroBlaze联动走AXI模板是正路。这篇教程里我选择前一种因为涉及寄存器读写更贴近大多数人的项目场景。3. 创建AXI4-Lite外设从向导到完整IP3.1 使用向导生成IP骨架打开Vivado后点击Tools - Create and Package New IP弹出的对话框里选择Create a new AXI4 peripheral点Next。然后给IP命名这里设为custom_uartDisplay name写成Custom UART IP版本号保持1.0。接着配置接口参数接口名字保持默认的S_AXI协议类型选AXI4LITE数据位宽32bit寄存器数量填4点Next之后会看到界面提示选择编辑方式。这里有几个选项需要注意Edit IP立即用IP Packager打开编辑代码Add to repository / Create new IP location暂时不编辑先加入IP库我推荐选Edit IP因为向导生成的代码需要我们自己修改核心逻辑直接进入编辑最顺手。之后Vivado会打开一个独立的工程窗口这就是IP Packager。在Sources里展开可以看到自动生成的文件结构custom_uart_v1_0.v # IP顶层 custom_uart_v1_0_S00_AXI.v # AXI从接口寄存器读写逻辑3.2 寄存器映射与功能定义AXI模板默认生成4个32位寄存器可以很自然地映射到串口功能上。我当时分配的寄存器如下偏移地址寄存器名方向功能描述0x00UART_CTRL读写bit0发送使能bit1清除接收标志0x04UART_TX写写入待发送的8位数据写入动作触发发送0x08UART_RX读读取接收到的8位数据0x0CUART_STATUS读bit0发送忙bit1接收数据有效这个映射方案的好处是简单直接地址分配也方便记忆。如果项目复杂要加FIFO、帧长度配置完全可以扩展成8个寄存器甚至16个模板生成的AXI逻辑是支持多寄存器的。寄存器读写操作在AXI模板里已经实现了大部分。比如写数据时向导生成的代码里会有case ( axi_awaddr[3:2] ) 2h0 : slv_reg0 axi_wdata[31:0]; 2h1 : slv_reg1 axi_wdata[31:0]; 2h2 : slv_reg2 axi_wdata[31:0]; 2h3 : slv_reg3 axi_wdata[31:0]; endcase我们要做的就是对特定写数据产生动作比如写UART_TX寄存器时把这个值送给发送模块并触发发送。这一步在后面代码部分细讲。3.3 在IP Packager中修改代码在IP Packager里把自定义的uart_tx和uart_rx模块的代码加进来然后在custom_uart_v1_0_S00_AXI.v里例化它们。同时在IP顶层custom_uart_v1_0.v里把与串口相关的端口uart_tx_pin、uart_rx_pin引出来。这一步最容易出现版本混乱。有个经验分享给大家每次在IP Packager里改完代码一定要先保存并关闭当前IP工程再回到主工程重新打包不然Vivado的IP缓存经常更新不及时Block Design里看到的还是旧版本。遇到这种情况在主工程中右键IP核选Refresh IP或Reset IP Source强制刷新即可。4. 串口收发核心逻辑详解4.1 波特率生成模块串口通信的前提是波特率匹配。波特率生成在FPGA里本质就是一个分频计数器把系统时钟数到一定数量就产生一个位时钟脉冲。以100MHz系统时钟为例要产生115200波特率分频值就是100000000 / 115200 ≈ 868.056取整数868实际波特率是100000000 / 868 115207.37误差只有0.0064%远远小于2%的允许范围完全没问题。parameter CLK_FREQ 100_000_000; parameter BAUD_RATE 115_200; localparam BAUD_DIV CLK_FREQ / BAUD_RATE; reg [15:0] baud_cnt; wire baud_tick (baud_cnt BAUD_DIV - 1); always (posedge clk or negedge rst_n) begin if (!rst_n) baud_cnt 16d0; else if (baud_tick) baud_cnt 16d0; else baud_cnt baud_cnt 1b1; end这里用到的baud_tick信号是整个串口逻辑的时间基准后续发送和接收模块都基于它做位同步。需要注意BAUD_DIV的位宽要够。如果系统时钟是200MHz115200波特率的分频值是173616位足够但要注意时钟改到更高频率时记得把baud_cnt的位宽相应加大。4.2 发送通道设计发送通道的状态机相对简单。平时TX引脚输出高电平一旦收到发送请求先输出起始位低电平一个位时间再从LSB到MSB依次输出8个数据位最后输出停止位高电平回到空闲。习惯上用三段式状态机来写可读性好综合出来的电路时序也交代得清楚。localparam IDLE 2b00; localparam START 2b01; localparam DATA 2b10; localparam STOP 2b11; reg [1:0] state; reg [2:0] bit_cnt; reg [7:0] shift_reg; reg [15:0] baud_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; tx_line 1b1; bit_cnt 3d0; end else begin case (state) IDLE: begin tx_line 1b1; bit_cnt 3d0; if (tx_start_i) begin shift_reg tx_data_i; state START; end end START: begin tx_line 1b0; if (baud_tick) begin state DATA; bit_cnt 3d0; end end DATA: begin if (baud_tick) begin tx_line shift_reg[0]; shift_reg {1b0, shift_reg[7:1]}; if (bit_cnt 3d7) state STOP; else bit_cnt bit_cnt 1b1; end end STOP: begin tx_line 1b1; if (baud_tick) state IDLE; end default: state IDLE; endcase end end assign tx_busy (state ! IDLE);这段代码的思路很直观IDLE等发送请求START期间把电平拉低DATA阶段每个baud_tick移出一位数据STOP阶段恢复高电平。tx_busy信号用于告知外部当前是否处于发送状态。实际测试中这个状态机逻辑稳定没出现过发送卡死的情况。4.3 接收通道设计接收比发送稍微复杂一些难点在于异步信号处理和采样点把握。首先需要对rx输入信号同步因为rx信号来自外部设备和系统时钟没有确定的相位关系直接采可能采到亚稳态。标准做法是打两拍同步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; rx_sync2 rx_sync1; end end wire rx_falling rx_sync1 ~rx_sync2;同步之后用下降沿检测起始位。检测到下降沿后还不能立刻认定就是起始位我习惯在起始位的中点再采样一次此时rx仍为低才确认是真正的起始位否则丢弃。这样可以滤掉毛刺。确认起始位之后利用baud_tick对8个数据位依次在每位中点采样。中点采样的实现方式是在进入每个位时间后当计数器计数到BAUD_DIV/2时采样rx这样采到的值最安全。wire sample_en (baud_cnt BAUD_DIV_HALF); always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_data_o 8d0; rx_valid_o 1b0; end else begin if (sample_en (state DATA)) begin shift_reg {rx_sync2, shift_reg[7:1]}; if (bit_cnt 3d7) begin state STOP; rx_data_o {rx_sync2, shift_reg[7:1]}; end else begin bit_cnt bit_cnt 1b1; end end if (sample_en (state STOP)) begin state IDLE; rx_valid_o 1b1; end end end这段代码里sample_en信号就是每个数据位的中点采样使能。数据从低位开始接收移满8位后存到rx_data_o同时拉高rx_valid_o提醒处理器取走数据。4.4 核心代码与IP打包为了方便复用我把发送和接收模块合成一个底层模块uart_pl然后在AXI模板里例化它把寄存器读写和串口信号串起来。底层模块端口如下clk, rst_nuart_tx_pin, uart_rx_pintx_data_i[7:0], tx_start_i, tx_busy_orx_data_o[7:0], rx_valid_o在S00_AXI模块中关键逻辑有写UART_TX寄存器地址0x04时把写入值赋给tx_data_i同时拉高tx_start_i一个时钟周期读UART_STATUS寄存器地址0x0C时把tx_busy_o和rx_valid_o拼接到返回值读UART_RX寄存器地址0x08时返回rx_data_o并在读操作完成时清除rx_valid_o这些写入和读取动作都需要和AXI的握手信号对齐。向导生成的模板里已经有axi_wready、axi_rvalid等信号直接在对应分支里补逻辑即可。改完代码后回到IP Packager窗口点Review and Package选择Re-Package IPIP就算打包完成。重新打开主工程在IP Catalog里刷新一下自己的custom_uart IP就会出现在User Repository分类下。5. 在Block Design中集成与使用5.1 添加IP到工程主工程里新建一个Block Design起名uart_test_bd。在Diagram空白处右键选择Add IP搜索custom_uart就能看到打包好的IP双击添加进来。如果用的是Zynq器件此时可以添加Zynq PS最小系统然后通过自动连接功能把S_AXI接口挂到PS的M_AXI_GP端口上。如果用纯逻辑器件或只想做PL验证可以不添加处理器那就需要把S_AXI接口的各个信号手动引出来再在顶层约束到引脚。这样做麻烦一点但能更清楚地看到自定义IP的工作方式。为了演示这次采用纯PL的接线方式把S_AXI的awaddr、awvalid、wdata等信号引到bd外部然后在顶层文件里手动做一个简单的寄存器读写逻辑来模拟AXI写操作。这纯粹是为了验证串口功能如果读者目标是做完整系统直接接Zynq PS会更舒服。5.2 引脚绑定与外部接口在bd里选中custom_uart实例右键对应端口选择Make External把uart_tx_pin、uart_rx_pin、clk、rst_n以及S_AXI接口手工外引。保存并Validate BD确认没有红色报错。之后写一个顶层RTL把bd例化进去并把S_AXI接口的端口信号映射到实际引脚。约束文件里至少要包含系统时钟引脚位置和时钟约束uart_tx_pin和uart_rx_pin的引脚位置约束关于约束文件有一个踩过的坑要提醒引脚位置约束必须和板子的原理图对应。如果tx、rx接反串口调试助手那头无论如何都收不到正常数据。测试前先参考板卡原理图确认引脚绑定没问题再生成比特流。5.3 生成比特流与导出硬件确认BD无误后在Sources里右键bd文件选择Generate Output Products等待综合实现完成。然后右键bd选择Create HDL Wrapper让Vivado自动生成顶层包装文件。接下来就是标准流程Run Synthesis - Run Implementation - Generate Bitstream。如果中途遇到Implement Design变红先别慌九成是约束问题。打开Implementation Log看具体报错重点排查是不是引脚约束超出器件封装范围或者是时钟约束没写对。实际调试中这步耗时不短但排查思路非常固定。6. 仿真验证与上板实测6.1 仿真激励编写要点在把比特流烧到板子之前强烈建议先做仿真。串口模块逻辑相对简单仿真是发现低级错误的最快途径比反复烧板子调试效率高太多。我写了一个testbench基本测试流程复位结束后通过AXI写操作向UART_TX寄存器写入0xA5观察uart_tx_pin上是否有正确的串行波形模拟外部设备向uart_rx_pin发送一帧数据0x5A观察UART_RX寄存器和状态寄存器的变化做回环测试发送0xA5再把接收通道收到的数据打出来对比这里有个很实用的技巧仿真时可以把时钟频率降低比如用1MHz虚拟时钟这样波特率分频值变小整个收发过程在波形窗口里很快就能跑完。实际实现时保留参数化设计CLK_FREQ和BAUD_RATE都是参数仿真传1MHz和9600上板传100MHz和115200一套代码两处用。6.2 上板实测与串口调试仿真通过后生成比特流并烧录到开发板。打开串口调试助手选择对应COM口波特率设置115200数据位8、停止位1、无校验。先做PC到FPGA的单向测试在串口助手里发送0xA5用ILA在线逻辑分析仪观察custom_uart内的rx_data_o和rx_valid_o信号。如果ILA抓到的数据与发送一致说明接收通道没问题。再做FPGA到PC的回传测试触发一次发送往发送寄存器写入0x5A如果串口助手正确显示0x5A说明发送通道也没问题。如果PC收不到数据先别急着怀疑逻辑代码。我遇到过几次这种情况最后查出来原因各有不同一次是USB转串口模块供电引脚接触不良一次是系统时钟约束没生效导致时钟频率不对。硬件层面排查应该放第一步。6.3 实测遇到的问题与处理这次测试中印象最深的有两个坑。第一个是接收数据偶发乱码。排查了很久最后发现原因在于uart_rx_pin的输入信号没做完整滤波。最初只在接收状态机里做起始位中点确认数据位采样时直接用baud_tick如果外部信号有抖动某些位就容易采错。解决办法是在每个数据位采样时用连续三拍多数表决的方式滤除毛刺。这个改动看似很小改装之后乱码率几乎降为零。第二个是波特率误差问题。最初把系统时钟错误地当成100MHz但实际板载晶振是50MHz结果PC端收到全乱码。这类问题用ILA抓波特率计数器就能立刻发现。所以遇到乱码问题先检查系统时钟频率和约束再检查波特率分频参数这是最高效的排查顺序。7. 常见问题速查与避坑指南7.1 常见问题与排查方法整理了一份快速排查表覆盖自定义串口IP核从开发到上板最常见的几类问题现象可能原因排查与解决办法上板后串口助手收不到数据引脚绑定错误 / TX和RX接反对照板卡原理图检查XDC约束尝试交换TX、RX收到数据全是乱码波特率不匹配 / 时钟频率不对用ILA检查波特率分频值确认系统时钟与参数一致偶发误码亚稳态或信号抖动对rx输入打两拍同步数据位中点增加多数表决滤波发送一直busy状态机卡在发送状态检查baud_tick是否正常产生确认停止位结束后回到IDLEIP在BD中拖不出来IP未重新打包或版本未刷新主工程右键IP选Refresh IP在IP Catalog刷新User RepositoryImplement Design变红引脚约束冲突 / 时钟约束缺失打开Implementation Log逐条看Error先检查XDC语法烧录后板子无响应顶层包装错误 / 复位逻辑问题确认Create HDL Wrapper后文件已更新检查rst_n是否正确释放以上都是亲身踩坑换来的经验写成表格也是希望各位少走点弯路。7.2 提升可靠性的几个设计技巧串口模块虽然简单但把可靠性做到位有几个细节值得关注。第一是异步信号同步。外部引脚进入FPGA内部使用时尤其是接收引脚至少打两拍同步这是保证设计稳定的基本素养。第二是每个数据位的中点采样。异步通信位时间误差积累最怕采样点太靠近数据位边沿。分频计数器数到BAUD_DIV/2时再采样可以给你留出最大时序裕度。第三是参数化设计。把CLK_FREQ、BAUD_RATE全部作为参数暴露出来不同工程之间复用这个IP时不需要改动内部逻辑直接改例化参数就行。这也是自定义IP核比一段写死的RTL模块更优雅的根本原因。最后再分享一个小技巧在IP核内部加一组loopback模式控制位调试时只要软件上置一个寄存器就能让TX和RX在FPGA内部短接快速验证系统通路是否正常。这个设计在通信类IP核里非常实用排查问题时能省下不少烧版本的时间。