ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FPGA实战:将UART封装为Vivado自定义IP核全流程

FPGA实战:将UART封装为Vivado自定义IP核全流程 做FPGA这几年串口通信几乎是我用过最频繁却又最容易忽略的外设接口。调试板子要打印内部状态上位机跟FPGA交互要发控制字和MCU联调还要传数据几乎所有工程都离不开UART。但问题是每次新开项目我都得把之前写的uart_top.v翻出来复制粘贴、改端口、重新仿真进了Block Design还要手动添加模块、手动拉线一折腾就是一下午。后来我把UART模块封装成了Vivado自定义IP核带AXI-Lite接口支持参数化配置波特率。现在新工程里只需要打开IP Catalog找到自己做的串口模块双击配置像Xilinx官方IP一样拖进Block Design点几下鼠标就完成集成。这篇文章把完整流程走一遍——从HDL编写、IP封装、仿真验证到上板实测适合刚接触Vivado、想搞明白自定义IP原理的FPGA开发者也适合那些被重复性UART集成工作折磨的工程师。1. 为什么把UART做成自定义IP核而不是每次复制代码先聊一个很多人会问的问题串口模块代码我都写好了直接加到工程里不是一样能用吗何必再花时间封装成IP核这个说法在单个工程里确实成立但一旦你需要在多个项目里复用、或者要让整个团队都用这个模块区别就出来了。模块文件的方式有一个很麻烦的痛点不同工程的时钟频率可能不一样串口波特率也不同甚至有些工程用8位数据有些用9位。每次复制代码进去都要打开顶层文件逐个改参数改完还得检查端口匹配稍不注意就会漏改。Vivado的Block Design本身也倾向于以IP为核心组织设计把模块当成普通文件添加进来不是不行但扩展性很差工程大了以后查找、维护都很痛苦。封装成自定义IP核本质上就是给这个模块一个标准化的包装重点解决了三个问题接口标准化通过自定义IP向导生成AXI-Lite接口处理器可以直接读写寄存器控制串口无论是Zynq的ARM核还是MicroBlaze软核都能同一套方式访问。参数可视化波特率、数据位宽度、时钟频率全部暴露在IP配置界面上双击就能修改不用再打开源码改常量。复用成本低IP核注册到Vivado的IP Catalog之后跟官方IP一样可以拖拽、连线、保存版本团队其他成员拿到就能用不需要知道内部实现细节。我用一个实际项目做个对比。去年做一个数据采集板卡三个不同功能模块都用到串口通信分别是板卡调试口、传感器数据上报口、上位机固件升级口。如果每个都复制一份uart代码寄存器地址、收发状态定义全靠人肉对齐联调时出了问题很难排查。封装成IP核之后在Block Design里例化三个实例配置成不同波特率和地址映射整个架构一眼就能看清维护起来非常舒服。所以做这个自定义IP核表面上是封装了一个串口模块实际上是建立了一套可复用的外设管理方式。后面任何FPGA项目需要串口接口都不用再碰RTL代码直接在图形化界面里拖一个实例出来。这个投入是值得的。2. 串口协议梳理与HDL核心模块实现2.1 UART数据帧格式快速复习在写代码之前先花一分钟把UART协议重温一下因为后面所有的仿真检查和时序计算都建立在这几个基本概念上。UART全称是Universal Asynchronous Receiver/Transmitter全双工异步串行通信发送和接收各用一根线共用一根地线不需要时钟同步线双方预先约定好波特率。一帧数据由这几部分组成空闲状态发送线保持高电平。起始位发送线拉低1个位时间表示一帧开始。数据位紧跟起始位之后通常是5到8位从最低位LSB开始逐位发送。校验位可选奇偶校验保证数据完整性。停止位数据位结束后拉高长度可以是1位、1.5位或2位最常见的是1位。举个例子发送数据0x55二进制0101_0101如果是LSB first发送线上实际先发最低位1然后是0、1、0、1、0、1、0最后是停止位。为什么强调LSB first因为在写发送状态机和检查时序波形时如果搞反了发送顺序最容易出现的现象就是串口调试助手收到乱码而且这种乱码还有规律——每个字节的高低字节互换。接收端怎么判断一帧开始就是检测线路从高电平跳变到低电平这个下降沿就是起始位的开始。如果线路上有毛刺也可能被误判为起始位。最常见的处理办法是检测到下降沿之后等半个位时间在起始位的中点再采样一次如果仍然是低电平才确认这是真正的起始位否则放弃。这样能滤除大部分短毛刺干扰。2.2 波特率发生器算好分频值再动手波特率决定了每秒钟传输多少个二进制位。系统时钟是高频信号要得到目标波特率的位宽脉冲必须做分频。先算清楚分频值再写代码这是整个模块正确性的基础。分频公式很简单分频计数最大值 系统时钟频率 / 目标波特率我这里以100MHz系统时钟、115200bps波特率为例。100_000_000 / 115200 868.0556取整数868所以用一个计数器从0数到867每个时钟周期加1数满868个时钟周期输出一个高电平脉冲这个脉冲就是一个位时间的节拍。实际波特率 100_000_000 / 868 115207跟目标值115200的误差只有0.006%远低于串口通信允许的误差范围一般不超过2%都没有问题。这个计算过程一定要自己亲手算一遍很多同学直接复制网上的代码把CLK_FREQ参数填错比如系统实际是50MHz却填了100MHz波特率直接偏一倍收发就会乱码。波特率发生器的Verilog代码其实非常简洁localparam integer DIV_CNT (CLK_FREQ / BAUD_RATE) - 1; reg [31:0] baud_cnt; wire baud_pulse (baud_cnt DIV_CNT); always (posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt 32d0; end else if (baud_cnt DIV_CNT) begin baud_cnt 32d0; end else begin baud_cnt baud_cnt 1b1; end end注意一点baud_pulse只在计数器数满的那一个时钟周期为高不是持续一个波特率周期。这个脉冲用于驱动状态机的状态跳转可以理解成每秒踩一下油门。如果系统时钟很高、波特率很低DIV_CNT会变得很大计数器位宽要注意别只定义16位导致溢出。我当时用100MHz时钟配9600波特率分频值超过1000016位虽然够但留着余量总是好的所以我统一用32位计数器省得换参数的时候踩坑。2.3 发送模块状态机别写复杂四个状态够了UART发送模块的核心就是一个状态机从空闲、起始位、数据位到停止位四个状态依次跳转。很多初学者会把发送逻辑写得很啰嗦实际上这个状态机非常简单关键是把节拍控制对。状态跳转使用波特率脉冲而不是直接用计数器。每次baud_pulse拉高表示一个位时间结束状态机跳到下一个状态。数据位部分需要知道自己当前发送到第几个bit了所以用一个bit_cnt计数器每逢baud_pulse就加1从0数到78位数据。发送数据要提前锁存到发送移位寄存器里因为外部输入的数据总线是稳定的但发送过程需要逐位取出不能用同一个寄存器同时又接收新数据。localparam S_IDLE 2d0; localparam S_START 2d1; localparam S_DATA 2d2; localparam S_STOP 2d3; reg [1:0] state; reg [3:0] bit_cnt; reg [7:0] shift_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; txd 1b1; tx_busy 1b0; bit_cnt 4d0; end else begin case (state) S_IDLE: begin txd 1b1; tx_busy 1b0; if (tx_valid) begin tx_busy 1b1; shift_reg tx_data; state S_START; end end S_START: begin txd 1b0; if (baud_pulse) begin bit_cnt 4d0; state S_DATA; end end S_DATA: begin txd shift_reg[0]; if (baud_pulse) begin shift_reg {1b0, shift_reg[7:1]}; if (bit_cnt 4d7) state S_STOP; else bit_cnt bit_cnt 1b1; end end S_STOP: begin txd 1b1; if (baud_pulse) state S_IDLE; end default: state S_IDLE; endcase end end这段代码里有几个细节值得注意。一是在S_DATA状态下数据是移位寄存器的第0位随后右移一位这保证了LSB先发。二是在bit_cnt从7变到停止位这里判断用了4d7因为数据位一共8位从0到7正好8个节拍。三是S_STOP状态也需要等一个波特率脉冲才能回到空闲这样停止位的宽度才是完整的1个位时间。2.4 接收模块中点采样和连续三次抽样接收模块比发送模块要复杂因为它没有时钟同步线不知道对方什么时候发数据只能靠检测电平跳变来猜测。接收的关键点在于采样时机——在数据位的中间点采样这样抗干扰能力最强。如果采样点靠近位边界信号翻转临界处很容易采到不确定值。检测到起始位下降沿后需要先等半个位时间在起始位的中间点确认电平确实是低确认没有误触发。确认之后再等一个完整的位时间到达第一个数据位的中间点依次采样8个数据位。我用一个采样点计数器spo_cnt来实现这个时序具体思路是下降沿触发后spo_cnt从0开始计数。当spo_cnt等于DIV_CNT/2时到达起始位中点再次检查rxd是否为低。之后每当spo_cnt等于DIV_CNT的整数倍加DIV_CNT/2时采样一个数据位。接收代码的采样部分我推荐用连续三次抽样取多数的方式进一步提升抗干扰能力。也就是说在采样点附近连续采样三个时钟周期取两个以上相同值作为最终采样结果。每次采样三个周期的值是并行的用组合逻辑做比较。reg rx_d0, rx_d1; wire rx_negedge; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_d0 1b1; rx_d1 1b1; end else begin rx_d0 rxd; rx_d1 rx_d0; end end assign rx_negedge rx_d1 ~rx_d0;这里先把rxd打了两拍第一拍是为了消除亚稳态风险第二拍才能生成可靠的下降沿标志。如果直接用原始rxd信号做边沿检测现实中很容易出问题尤其是线缆稍长一点、信号边沿不够陡的情况。接收数据位的采样我写成单独的一个模块函数方便复用。采样中枢逻辑是在采样点时刻把rxd三次采样的多数判决结果移位存入移位寄存器。检测到停止位时如果停止位采样为高表示接收成功把数据打入接收缓冲同时拉高一个接收有效标志。如果停止位采样到低电平说明发生了帧错误直接丢弃这个数据并把错误标志置位。2.5 顶层整合一个干净对外的串口核心有了波特率发生器、发送模块、接收模块顶层模块的作用就是把这些子模块拼起来对外暴露一个干净、易于集成的接口。顶层接口应该包含时钟、复位、发送数据请求、发送数据总线、发送忙标志、接收数据有效标志、接收数据总线。我的习惯是所有子模块的时钟复位都是同一个顶层模块只做例化和简单的信号拼接不在顶层写任何逻辑。这样做的原因很直接后续封装IP核的时候顶层模块会作为IP的顶层实体如果里面逻辑太多封装工具和后来的维护者都会很头疼。顶层里几个关键信号的连接逻辑tx_valid作为发送触发信号上升沿有效。tx_busy表示发送忙高电平期间不应该再拉tx_valid否则数据会被覆盖。rx_valid是接收到一帧完整数据的标志持续一个时钟周期高电平外部只要检测上升沿或直接Latch。rx_data在rx_valid拉高时有效其他时间保持不变。接收数据给到上层之后通常有两种使用方式一种是直接拿寄存器读走另一种是加一个小FIFO缓冲防止连续接收时处理器来不及读导致丢数。我在IP核里预留了FIFO接口实际项目中根据需要使能。如果只是简单调试用途直接寄存器读就够了FIFO会增加面积和复杂度。3. Vivado中封装自定义IP核的完整流程3.1 封装前的准备工作代码写完并仿真通过之后就可以进入Vivado的IP封装流程了。在动手之前有两件事必须确认清楚版本兼容性和模块端口规范性。Vivado不同版本的IP打包格式略有区别我用的这个流程在Vivado 2018.3到2022.2上都验证过基本一致。如果你的版本偏老界面文字可能有差异但入口路径基本不变。端口规范性上自定义IP核的端口名尽量不要用莫名其妙的缩写。我在第一次封装的时候把发送有效信号命名为tv接收有效信号命名为rv到了Block Design连线时自己都看晕了。建议用完整清晰的名字比如tx_valid、rx_valid、tx_data、rx_data这样后续维护团队成员看到端口列表就能理解。另外如果模块支持参数化在封装之前就要确定好哪些参数需要暴露给用户。我选择的暴露参数是CLK_FREQ和BAUD_RATE这两个是最常用的。其他参数比如奇偶校验模式、停止位位数通常一个项目里固定不变做成内部常量就行不需要污染配置界面。3.2 入口与接口模板选择打开Vivado工程后菜单栏找到Tools Create and Package New IP这一步就是启动IP定制向导。向导会问你选择打包一个已存在的IP还是创建新的AXI4外设。我们的场景是已经有了串口HDL代码所以选第一个把RTL打包成IP。接下来向导会让你添加模块文件。Vivado支持直接添加多个文件并自动识别顶层模块。把uart_top.v以及它依赖的子模块文件一次性添加上去向导会解析出顶层端口列表。如果模块之间的依赖关系有问题向导会在这一步报错所以封装之前先确认RTL能在普通仿真中通过减少封装时的排错成本。走到接口模板选择这一步最关键的决定出现了选不带接口还是带AXI接口。如果只是想在Block Design里使用IP但不希望被处理器访问可以选择无接口或简单的时钟复位接口如果想用寄存器控制串口或者挂在Zynq的AXI总线上那就必须选AXI4-Lite Slave接口。我选的是AXI4-Lite Slave模板。为什么选它而不是更复杂的AXI4-Full因为串口通信本质上是一个低速控制型外设单次读写、寄存器访问就是它的全部需求AXI4-Lite信号少、时序简单、模板代码容易看懂完全没有必要引入AXI4-Full的Burst特性。这里的原则是够用就好不要过度设计。3.3 模板代码与用户逻辑的对接新版Vivado向导会生成一个完整的AXI-Lite从机模板自动包含写地址通道、写数据通道、写响应通道、读地址通道和读数据通道的处理逻辑。模板中最核心的是那8个可读写寄存器reg_data0到reg_data7以及对应的写使能信号和读使能信号。我打算把串口寄存器映射到低地址地址0x00控制寄存器bit0为发送触发bit1为发送忙标志bit2为接收有效标志。地址0x04发送数据寄存器写入这个寄存器的同时触发一次发送。地址0x08接收数据寄存器处理器读取后清除接收有效标志。模板生成后需要做的动作是在模板的模块体内例化我们的uart_top然后建立寄存器与串口模块的信号连接。具体来说uart_top u_uart ( .clk (S_AXI_ACLK), .rst_n (~S_AXI_ARESETN), .tx_valid (uart_tx_trigger), .tx_data (slv_reg1[7:0]), .tx_busy (uart_tx_busy), .rx_valid (uart_rx_valid), .rx_data (uart_rx_data), .txd (uart_txd), .rxd (uart_rxd) );这里有一个非常容易搞错的地方AXI-Lite模板的复位信号是低电平有效的S_AXI_ARESETN而我的UART模块复位设计也是低电平有效按理说直接接S_AXI_ARESETN就行。但要注意S_AXI_ARESETN在AXI总线没有复位时可能是未定义状态所以见到的很多工程会把它再打一拍保证用户逻辑复位干净。我在第一次调试时就遇到上板后UART模块一直处于复位状态的问题查了半天最后才定位到是复位信号处理不严谨。控制寄存器的bit0是发送触发我写的时候特意设计成写1自动清0的方式也就是模板检测到写控制寄存器时把bit0锁存到uart_tx_trigger然后在下一个时钟周期自动清零。这样上位机写入0x01到控制寄存器就触发一次发送不需要在发送完成后再手动清标志。3.4 编辑IP核属性与参数界面封装向导的最后一步可以对IP核的基本属性进行编辑包括名称、显示名、版本号、厂商信息和描述。显示名最好起一个直观的名字比如UART Controller这样在IP Catalog里搜索的时候一眼就能找到。参数编辑是这一步的重头戏。向导可以把顶层模块的parameter暴露成IP的可配置参数在IP配置界面显示出来。我暴露了CLK_FREQ和BAUD_RATE两个参数并且在IP配置界面里填好默认值CLK_FREQ默认100000000BAUD_RATE默认115200。这样任何人拖入这个IP后双击就能看到这两个参数并修改。参数界面还有一个比较高级的玩法是添加参数关联表达式比如根据CLK_FREQ自动计算最大波特率范围。不过这个配置在实际项目里很少用到因为UART波特率本身可选值就那么几个不需要动态计算。我建议初学者不用过度设计参数界面暴露两三个最核心的参数就够了其余做成内部常量减少出错的概率。打包完成之后Vivado会生成一个名为xx工程.srcs/sources_1/ip的目录里面是完整的IP核定义文件和打包数据。此时在工程里的IP Catalog中已经能看到这个自定义IP了名字会出现在User Repository分类下面图标跟官方IP不一样带一个自定义标识这非常直观。3.5 打包后验证与更新机制IP核第一次打包完成并不代表万事大吉。我每次封装完都会做两个动作一是到IP Catalog里双击打开配置界面确认参数显示、默认值都正确二是新建一个空的Block Design把IP拖进去检查端口列表和波形视图是否正常。发现问题就回到源码修改然后在IP Catalog中右键选择Refresh IP或在Tools菜单里执行Update IP Sources让Vivado重新打包并更新工程引用。IP核更新机制有一个常见问题如果在Block Design里已经例化了这个IP然后修改了IP的端口或参数Block Design里的例化不会自动跟着变需要在IP Catalog里执行Upgrade IP或者重新添加实例。这是我在多版本迭代中踩过的坑每次改完模块接口都要记得同步更新Block Design否则综合的时候会报端口不匹配的错误而且这种错误报出来还特别难定位因为报错信息不会指向你的自定义IP而是指向例化它的顶层模块。4. 仿真验证别让bug留到上板才发现4.1 Testbench设计要点串口模块的仿真分两层一层是直接对uart_top模块做RTL级仿真验证串口协议本身的正确性另一层是对封装后的IP核做AXI总线级仿真验证寄存器读写和模块行为是否一致。两层都不能省。写Testbench的时候我通常是先写一个简单的结构跑通发送路径再扩展接收路径。Testbench的基本构成是生成时钟、释放复位、初始化输入信号、发送任务task和接收任务task。reg clk; reg rst_n; reg tx_valid; reg [7:0] tx_data; wire tx_busy; wire rx_valid; wire [7:0] rx_data; wire txd; wire rxd; // 时钟与复位 initial begin clk 0; rst_n 0; #100 rst_n 1; end always #5 clk ~clk; // 发送任务 task send_byte(input [7:0] data); begin (negedge clk); tx_valid 1b1; tx_data data; (negedge clk); tx_valid 1b0; wait (tx_busy 0); end endtask这个Testbench里的send_byte任务设计成在时钟下降沿拉高valid信号保持一个时钟周期然后等待发送忙结束。为什么选下降沿而不是上升沿因为要确保发送模块在下一个上升沿稳定采样到tx_valid避免valid信号和时钟边沿出现竞争。这是仿真中容易被忽略的细节实际写代码时如果valid信号的建立时间不够上板很容易触发偶发性的乱码。4.2 发送路径仿真与分析波形发送路径仿真要做三件事验证txd波形符合UART协议、验证tx_busy行为正确、验证连续发送多字节时不会互相干扰。跑完仿真之后展开txd波形对照UART协议一帧一帧地检查。重点看这几个点空闲时txd是高电平起始位拉低持续约8.68us100MHz时钟115200波特率868个周期数据位低电平为0、高电平为1并且顺序是LSB first停止位为高。如果波形里有一个位的宽度多了一拍或者少了一拍那多半是状态机跳转条件写错了回头检查baud_pulse和状态切换的时序关系。tx_busy信号的意义在于给上层一个我在忙的提示。我在仿真里故意在发送过程中再次触发tx_valid观察模块会不会把新数据丢掉。正确的行为是当tx_busy为高时新来的tx_valid应该被忽略或者至少缓冲到发送完成后再处理。我采用的是忽略策略也就是空闲状态才会接收新的发送请求。这样做的优点是逻辑简单缺点是需要上位机查询tx_busy状态在IP核寄存器里通过控制寄存器的bit1可以读到这个状态。4.3 接收路径仿真与采样点验证接收路径需要模拟一个外部串口设备向FPGA发送数据。Testbench中定义一个rxd输出端口用行为级代码模拟UART波形。比如用任务task模拟发送一个字节0xA5起始位拉低一个位时间然后依次发送8个数据位最后拉高停止位。接收路径仿真最关键的是检查采样点是否落在每个数据位的中心。Vivado的波形窗口里可以加虚拟总线把rxd信号和rx_data信号按位显示。如果发现采到的字节和发送的不一致大概率是采样点时钟偏移问题。我遇到过一种情况仿真里接收正常上板却偶尔出现错位后来发现是仿真时钟频率和实际硬件时钟频率不同步导致采样点偏移超出容限。所以仿真里除了验证功能还要注意跑几个不同的时钟频率边界情况。还有一种情况是模块的复位时序没有处理好。UART接收模块在复位释放之后会处于等待起始位下降沿的状态但如果在复位期间rxd恰好处于低电平复位释放后模块可能误判一个虚假的起始位。我做的处理是在复位释放后强制接收状态机回到IDLE状态并等待至少一个位时间之后才开始检测下降沿。4.4 仿真常见坑第一个常见坑是仿真一直跑不完波形窗口不动。这通常是因为Testbench里某个wait条件永远不满足比如等待tx_busy下降但模块因为复位没释放一直处于忙状态。排查方法很简单在Tcl控制台暂停仿真检查该死信号的值。第二个坑是波形全是X态。原因几乎都是模块没有正确复位或者输入信号初始化成X。Testbench里一定要把所有输入信号初始化为确定值尤其是rst_n。我在初学的时候经常忘了给rst_n一个初始值结果整个仿真窗口一片红色X还以为是模块代码写错了。第三个坑是对AXI-Lite模板做仿真时读写时序不对。模板自带一个简单的仿真例子封装完成后会在IP目录里生成一个仿真测试文件直接使用它比自己从零写要省事得多。这个例子覆盖了基本的寄存器写和寄存器读流程我们在它基础上增加串口触发逻辑即可。5. Block Design集成与上板实测5.1 在Block Design中调用自定义IP仿真通过后创建一个新的Block Design在画布空白处右键选择Add IP搜索框输入自定义IP的显示名比如UART Controller把它添加进来。这个操作跟添加Zynq PS或者AXI GPIO等官方IP完全一样。添加进来之后你会看到IP的端口分为两类AXI从机接口和普通外部接口。AXI-Lite接口可以连接处理器总线或者如果不需要处理器控制也可以手动Connected到AXI Interconnect。普通外部接口包括clk、rst_n、txd、rxd这四个。其中txd和rxd属于需要引出的物理引脚在Block Design里右键这两个端口选择Make External会生成uart_txd_0和uart_rxd_0两个外部端口。这里有一个Vivado自动连接的小技巧。选中IP之后运行Run Connection AutomationVivado会自动为AXI-Lite接口添加AXI Interconnect把时钟和复位连好。但是这个自动化工具通常不会自动为txd和rxd生成外部引脚因为Vivado不知道这两个信号要连到具体哪个物理引脚上去。我的习惯是手动添加外部端口并命名比如uart_tx和uart_rx这样生成的XDC约束文件可读性更好。5.2 引脚约束与实现Block Design完成后在顶层文件里实例化这个Block Design然后综合、实现之前必须把uart_tx和uart_rx映射到FPGA的实际引脚上。约束文件写法如下set_property PACKAGE_PIN M14 [get_ports uart_tx] set_property IOSTANDARD LVCMOS33 [get_ports uart_tx] set_property PACKAGE_PIN N14 [get_ports uart_rx] set_property IOSTANDARD LVCMOS33 [get_ports uart_rx]注意这里的引脚是举例用的不同开发板型号引脚完全不同一定要查手上的开发板原理图。我见过太多人直接复制网上的引脚约束跑到自己板子上结果编译能过但上板完全没反应最后发现是引脚根本不对应。引脚约束中IOSTANDARD是必选项不能省略。漏掉IOSTANDARD会导致实现阶段报错错误类型是物理约束缺失。如果开发板上有外接电平转换芯片还要根据原理图配置上拉或者串阻等附加约束不过大部分开发板的UART口都是直接连接USB转串口芯片LVCMOS33基本通用。实现完成后生成比特流连接开发板Program Device下载。如果一切正常这一步就应该能通过串口调试助手看到FPGA发出来的数据了。5.3 上板联调验证回环是最好用的测试方法上板验证我推荐的第一个实验是串口回环实验也就是把FPGA收到的数据原样发送回去。这个实验不需要上位机发什么特定的协议只要用串口调试助手发送任意字节如果FPGA能把数据原样返回说明UART通道的接收和发送链路整体是通的。实现回环非常容易在IP核内部把rx_valid拉高时寄存的rx_data直接赋给tx_data并触发tx_valid即可。不需要通过AXI总线也不需要处理器参与纯粹验证硬件链路。我在做回环实验时发现了一个有意思的现象当串口助手每发送一个字节FPGA都原样返回一个字节但发送间隔太短时会发生丢数据。原因是回环逻辑里如果接收模块在发送模块还忙的时候就收到了新数据新数据显示rx_valid会拉高但这个新数据没有地方保存只能被丢掉。这个问题揭示了回环实验的本质真正复杂的不是UART模块本身而是数据流量的控制。所以在实际项目中串口模块后面通常要加FIFO做缓冲。在公开的IP核版本里我使用了一个简单的做法如果发送模块忙就不触发发送而是置位一个溢出标志寄存器上层程序通过查询这个标志可以感知到丢包发生。这样既保证了逻辑简单又保留了流量控制能力。5.4 AXI寄存器读写验证如果用的是Zynq平台或者MicroBlaze平台在回环验证通过后还需要做一个AXI寄存器读写的验证。这个实验是这样的通过SDK或Vitis写一个小程序往自定义IP的发送数据寄存器写入0x55然后读取接收数据寄存器看是否返回0x55。我在Vitis里用的代码非常简单#define UART_CTRL_ADDR 0x43C00000 #define CTRL_REG *(volatile unsigned int *)(UART_CTRL_ADDR 0x00) #define TX_DATA_REG *(volatile unsigned int *)(UART_CTRL_ADDR 0x04) #define RX_DATA_REG *(volatile unsigned int *)(UART_CTRL_ADDR 0x08) void uart_send_byte(unsigned char data) { while (CTRL_REG 0x02); // 等待发送忙信号清零 TX_DATA_REG data; // 写发送数据寄存器并触发发送 }这段代码里的地址0x43C00000是Block Design中AXI Interconnect分配给自定义IP的基地址具体值要根据实际工程查看Address Editor。我在调试时经常有人问为什么基地址是这个其实在Vivado的Block Design地址编辑器里能看到每个IP的地址分配甚至可以手动修改。读操作也需要注意一个细节读接收数据寄存器之后模块会自动清除接收有效标志。这是我在硬件逻辑设计时就写好的行为目的是避免同一帧数据被重复读走。如果上层程序连续读两次第二次读到的值是不确定的正确做法是每次读之前先查询控制寄存器的接收有效位。6. 常见问题与排查技巧实录6.1 串口调试助手收到乱码乱码是串口调试中最常见的故障。我的排查思路是一层层往下拆。第一步检查FPGA侧的波特率配置和上位机串口助手设置是否一致。这里要特别注意自定义IP配置界面的CLK_FREQ参数很多人系统时钟明明是50MHz配置界面却填了100MHz波特率实际就偏了差不多一倍必然乱码。第二个容易踩的坑是上位机串口助手的停止位、校验位设置跟IP核内部不一致比如上位机设置了8位数据无校验1停止位而IP核内部设计的是1位停止位这个配置通常没问题但如果你在IP内部改了停止位为2位上位机没有同步也会出现帧错。第二步检查引脚约束是否正确。有些开发板USB转串口的TX和RX是交叉的也就是FPGA的txd引脚接的是USB转串口的RXDFPGA的rxd引脚接的是USB转串口的TXD。如果约束写反了收发的数据就会悬空或者互相干扰现象也是乱码或者完全收不到。第三步用仿真波形对照实际波形。用Logic Analyzer IP核抓取内部波特率脉冲和数据位的实际宽度跟仿真波形对比看脉冲周期是否一致。如果仿真正常但上板乱码这通常指向时钟频率不对或者引脚约束错误。I实际项目里我还遇到过一种比较隐蔽的情况USB转串口驱动版本太老波特率在115200以上时实际频率偏移超过标准导致FPGA能发但PC收的乱码。换一台电脑或者更新驱动后恢复正常。这提示我们排查乱码时不能只盯着FPGA侧上位机的串口环境和驱动也有责任。6.2 上板后完全收不到数据完全收不到数据比乱码好排查因为大概率是某个信号根本没工作。第一反应先量一下FPGA的发送引脚有没有波形翻转用万用表看不了电平变化最好用示波器或者逻辑分析仪。没有仪器的话可以在IP核里加一个计数器每发送一个字节计数器加1通过LED显示或者通过另一个调试接口读出来判断模块到底有没有进入发送流程。如果模块确实在发但上位机收不到重点检查USB转串口模块接线和驱动。很多开发板上的USB转串口芯片需要安装厂家驱动程序我的习惯是先在设备管理器里确认串口号出现再用串口调试助手打开该串口。打不开串口通常是因为驱动没装好或者串口号被其他软件占用。如果是上板瞬间收不到数据之后一直没有响应大概率是复位问题。检查复位引脚是否正常释放尤其当复位信号来自外部按键时按键抖动可能让UART模块多次复位导致错过上位机发来的起始位。处理办法是在IP核内部对复位信号做消抖处理或者在外部电路上加RC延时。6.3 IP配置界面改了参数没生效这是自定义IP核使用中一个很有迷惑性的问题。你在Block Design里双击IP把波特率从115200改成了9600但综合实现后串口实际还是115200。问题多半出在IP核的升级或者更新流程上。Vivado的IP核有一个已打包参数和例化参数的概念。你在配置界面修改的参数会写入当前例化实例但如果这个IP核是从旧版本打包过来的配置界面的参数可能没有同步到底层RTL。解决方法是确保在IP Catalog中右键执行Refresh IP或Upgrade IP并重新生成例化文件。另外还要检查一个容易被忽略的点自定义IP核的源码文件在打包之后会被复制到IP目录中而不是引用你原始工程里的文件。如果你修改了源码但忘记重新打包新参数和旧源码混在一起就会出现改了半天参数毫无响应的诡异现象。记住这个原则改了源码必须重新打包IP改了参数必须重新生成例化二者缺一不可。6.4 综合实现阶段报错合集自定义IP核在综合实现阶段出的错有些是IP本身的问题有些是工程集成的问题。我整理了几条最高频的报错和对应解法LIT端口不匹配错误IP核的端口和Block Design中例化不一致。几乎都是更新IP源码没有同步更新Block Design导致的在IP Catalog中执行Update IP然后刷新Block Design即可。未使用输入端口警告自定义IP的某些输入端口没有连接到任何信号。在Block Design中确认所有输入端口都有连接尤其是复位信号不能悬空。时钟约束缺失错误IP核内部有多个时钟域没有添加时钟约束。在综合前运行约束向导确认所有时钟输入都有正确的周期约束。IOSTANDARD缺失错误外部引脚约束缺了IOSTANDARD属性添加即可。这些问题里LIT端口不匹配最容易误导人因为报错信息通常会指向顶层模块而不是指向自定义IP。我的经验是遇到这种错误第一时间在Tcl Console里输入report_cell看看哪个IP实例报错再决定是更新IP还是修改Block Design比直接傻看源码要高效得多。写在最后的一点实操体会做这个自定义串口IP核项目最大的感受是Vivado真正的门槛不在图形界面而在对硬件时序的理解。IP封装本身只需要点几轮鼠标但想让它稳定可靠地工作靠的是对UART协议细节的把握、对采样时序的推敲、以及对AXI总线行为的理解。这个项目的每个环节其实都在考验基本功。我自己在这条路上踩过的坑归纳起来就一句话不要想当然每个参数、每段时序都要亲手算一遍、仿真一遍、上板验证一遍。串口通信看似简单实际上从协议到实现、从仿真到上板每个环节都有足够的细节值得深挖。把这套自定义IP核的流程走通一次你会对FPGA开发的整个链条都有更完整的认识。如果你也要做类似的东西建议先从最简单的版本开始先把发送路径跑通再加接收再加AXI接口每加一层都重新仿真。一步一步来整个项目会非常可控。这样一套IP核沉淀下来以后无论换到哪个FPGA平台它都能成为你工具箱里最趁手的武器。
返回列表