FPGA中AXI UART 16550 IP核配置与中断处理实战指南
在FPGA里做串口通信只要你是用MicroBlaze软核或者Zynq的ARM核波特率、收发状态、中断这些最后基本都会落到Xilinx的AXI UART 16550 IP核上。这个IP核本质上是把经典的PC级16550 UART控制器搬到了AXI总线上内核逻辑一模一样但挂在总线之后配置方式、中断处理还有调试套路跟传统裸寄存器玩法差别不小。我最初接触这个IP的时候是在Zynq-7020上给PS端串口之外又扩了一个PL端串口。刚开始以为就是配置个波特率、开个中断的事结果一路踩坑波特率算出来的分频值不对导致上板全是乱码FIFO触发等级设太高导致CPU中断里一次要搬一堆数据还有那个电平触发的中断信号ISR里没读干净IIR直接进死循环。折腾下来我才发现这IP核本身不难难的是把IP配置、寄存器计算、中断时序这几层东西串起来。这篇文章就把我从Vivado配置到SDK/Vitis中断处理的全过程连同调试中踩过的坑一起写出来。适合两类人看一类是第一次在Block Design里用AXI UART 16550想知道各项参数怎么填、中断怎么接的另一类是已经跑通收发但偶尔出现乱码、丢字节、中断异常想系统排查问题的。从头到尾没有玄学全是能直接抄的配置和代码。1. 先把话说清楚AXI UART 16550是什么什么时候用得到它1.1 从UART到AXI总线这个IP是怎么长出来的要理解这个IP核先拆一下名字。AXI说明它挂在AMBA AXI总线上具体是AXI4-Lite接口UART说明它是个串口控制器16550则是来自PC时代的经典UART芯片型号。16550芯片当年就是为了解决早期8250/16450在高速率下容易丢数据的问题加入了16字节收发FIFO而闻名。Xilinx把这个架构做成了软核IP挂在FPGA内部总线上寄存器级兼容标准16550。换句话说你在Vivado里拉出来的这个IP核打开寄存器手册看到的DLL、DLM、IER、IIR、LCR、LSR这些名字和上世纪PC主板上那颗16550芯片是一模一样的。这意味着两件事第一网上能找到大量关于标准16550的中文和英文资料基本都能参考第二如果你以前搞过单片机或者老PC串口开发看到寄存器会非常亲切。但它和传统16550芯片有个根本区别数据不是通过IO端口读写而是通过AXI4-Lite总线访问。在Block Design里处理器的AXI互联主端口通过AXI SmartConnect或者直接连接挂到IP的s_axi接口上读写就变成了对内存映射地址的访问。硬件上多了一层总线转换但软件上其实更简单直接拿指针操作地址就行。1.2 和AXI UART Lite、标准UART 16550怎么选Xilinx在Vivado里其实提供了两个常被混淆的串口IPAXI UART Lite和AXI UART 16550。很多人第一次选型时容易纠结我把两者的核心区别列一下特性AXI UART 16550AXI UART Lite寄存器集完整16550寄存器DLL、DLM、IER、IIR等精简寄存器只有少量控制寄存器FIFO可配置深度常见16/32/64等固定16字节中断源接收数据可用、接收超时、发送空、线路状态错误、调制解调器状态仅发送空、接收有数据波特率运行时可通过DLL/DLM寄存器修改仅在IP配置时固定运行期一般不变自动波特率部分版本支持不支持典型用途需要兼容PC软件、需要丰富错误状态的场景简单透传不需要复杂状态管理如果只是把数据从A点搬到B点UART Lite确实够用代码也更简单。但一旦牵扯到和PC端调试工具对接、需要处理帧错误和溢出错误、或者希望把FIFO深度调大来减少中断频率16550的优势就出来了。我自己做PL端扩展串口基本都用16550因为它的寄存器行为和成熟驱动兼容性更好调试时拿PC上现成的16550工具和经验都能对上。1.3 什么场景下必须用这个IP什么场景不适合AXI UART 16550最适用的场景是你有一个软核MicroBlaze或者硬核Zynq/Zynq UltraScale需要通过AXI总线挂一个PL逻辑里的串口并且这个串口的行为要像标准PC串口一样健壮。典型例子是上位机通过UART和FPGA交互FPGA里跑着处理器运行协议栈串口负责传输日志或者控制命令。如果只是纯逻辑收发、不经过CPU那根本不用这个IP直接写个简单的UART收发器模块就行资源还更省。如果是要做极高波特率或者特殊协议比如RS485自动方向控制、LIN总线等16550 IP也能做但可能需要外部加控制逻辑不如自己写灵活。所以选型之前先想清楚有没有CPU、要不要寄存器接口、中断要怎么处理这三个问题答案定了IP也就定了。2. Vivado里的添加、配置和连线每一步都别想当然2.1 在Block Design里添加IP并完成基础连线打开Vivado工程创建一个Block Design或者打开已有的在Diagram窗口空白处点加号搜索框里输入“axi_uart16550”注意拼写是axi后面直接跟uart数字是16550不要搜成“uart_16550”或者“axi_uart_16550”。搜索结果里会出现“AXI UART 16550”这个IP双击即可添加到画布。添加之后IP默认带这些接口s_axiAXI4-Lite从接口、s_axi_aclk、s_axi_aresetn、uart_clk、uart_rxd_in、uart_txd_out、int_o。如果你的版本里还看到modem相关引脚那是因为勾选了Use Modem Ports一般用不到。连线有两种方式。一种是手动连把s_axi接到处理器的AXI互联主端口上把s_axi_aclk和uart_clk接到同一个时钟比如100MHzs_axi_aresetn接到复位网络int_o接到中断控制器。另一种是懒人方式右键IP选择Connection Automation让Vivado自动连接总线和时钟我建议新手先用自动连接再手动补一下中断引脚。关于时钟这里有个特别容易踩的坑s_axi_aclk和uart_clk可以是同一个时钟也可以是不同时钟但在IP配置页面里填的UART Clock频率必须和uart_clk实际接入的时钟频率严格一致。后面实操部分我会详细说为什么。如果两个时钟频率不同s_axi_aclk管AXI总线访问时序uart_clk管波特率生成两者可以异步但IP内部没有做跨时钟域的FIFO同步建议尽量用同一个时钟域避免奇奇怪怪的问题。2.2 配置页面逐项拆解时钟、波特率、帧格式、FIFO双击IP进入配置界面你会看到这么几个关键项Component Name保持默认也可以改成uart_pl这种好认的名字注意一旦生成IP后名字就不能乱改了否则Block Design里所有的引用都会断。UART Clock这一栏填的是uart_clk引脚的实际频率单位是MHz。比如你在Block Design里把uart_clk接到了100MHz时钟上这里就填100。IP内部要根据这个频率计算波特率分频值。如果填错后面寄存器里算出来的分频值全是错的表现就是上板后乱码或者干脆收发不了。Desired Baud Rate填目标波特率常用115200或者9600。IP会根据UART Clock和Desired Baud Rate自动计算一个分频值并Preview在下面。如果计算出来的分频值超过16位寄存器范围65535IP会报错或者红色提示这时候要么降低UART Clock要么降低目标波特率要么换用外部波特率时钟模式。Data Bits、Parity、Stop Bits一般选8、None、1也就是常说的8N1。这些决定了LCR寄存器的初始值。如果和PC调试助手的设置不一致表现为收到数据但内容对不上偶尔还会触发帧错误。FIFO Depth这个和中断行为有直接关系。默认16字节如果你希望CPU少被打断可以选更大的深度。注意FIFO深度变了FCR寄存器里RX触发等级对应的字节数也会变这块到中断部分再说。配置完这些IP引脚一般会自动更新比如有些版本在配置里勾选了“Use Modem Ports”就会多出几个引脚。2.3 中断信号怎么接接法直接影响后面调试AXI UART 16550的中断输出是int_o这是关键。它和很多其他IP的中断不一样不是脉冲而是电平信号只要IP内部还有未处理的中断源int_o就会一直保持高电平直到你把产生中断的原因清掉。这个特性在Block Design里接中断控制器时就要注意。如果你用MicroBlaze通常会有一个AXI Interrupt Controller把int_o接到它的某一路中断输入上然后中断控制器再把汇总后的中断信号接到MicroBlaze的interrupt引脚。如果是Zynq可以把int_o直接连到PS端的IRQ_F2P引脚或者也先经过PL侧的中断控制器再进GIC。连接方式本身不复杂但之后写ISR时一定要记住电平触发这个特性。处理完一个中断源必须重新读取IIR寄存器确认是否还有挂起的中断直到IIR的bit0变为1才能返回。否则中断控制器会认为中断没有处理完不停地触发CPU就卡死在中断里了。后面我会给完整的ISR代码。2.4 地址分配、综合、导出硬件一个都不能漏Block Design连线完成后切换到Address Editor给IP分配一个地址区间。手动连线时Vivado可能不会自动分配需要你点一下自动分配或者手动填一个基地址比如0x40000000。这个基地址后面在SDK/Vitis里会用到xparameters.h里会生成XPAR_AXI_UART16550_0_BASEADDR宏但如果你自己写寄存器访问代码最好确认一下实际值。之后就是常规流程Validate Design、Generate Output Products、Create HDL Wrapper、Run Synthesis、Run Implementation、Generate Bitstream。导出硬件时Vitis/SDK用户记得勾选Include bitstream这样生成应用工程后可以边调试边下载PL逻辑省得单独烧Flash。导出完硬件在Vitis/SDK里新建platform工程时BSP里会自动带上xuartns550驱动默认是轮询模式的。这里有个小坑BSP默认配置不一定会初始化FIFO和中断很多驱动API要你自己设置调用所以新手最好从寄存器操作入手先把整个链路走通再考虑用官方驱动简化代码。3. 寄存器操作与波特率分频计算这是整个IP的核心3.1 寄存器映射速查表建议收藏AXI UART 16550的寄存器偏移和标准16550一致但由于挂在AXI总线上同一个地址在不同条件下会映射到不同寄存器最关键的就是DLABDivisor Latch Access Bit这个开关。我整理了一张常用表地址偏移DLAB0 时DLAB1 时说明0x00RBR读/ THR写DLL接收缓冲/发送保持/波特率分频低8位0x04IERDLM中断使能/波特率分频高8位0x08IIR读/ FCR写IIR/FCR中断标志/ FIFO控制0x0CLCRLCR线路控制bit7是DLAB0x10MCRMCR调制解调器控制0x14LSRLSR线路状态0x18MSRMSR调制解调器状态0x1CSCRSCR暂存寄存器在Vitis/SDK里做寄存器访问时基地址加上表里的偏移就是绝对地址然后用Xil_In32/Xil_Out32读写。注意IP核的寄存器数据只在低8位有效读出来要0xFF写的时候也只改低8位。3.2 DLAB锁存原理为什么0x00一会儿是数据一会儿是分频值刚开始的人很容易在这个地方卡住。地址0x00你在DLAB0时读它得到的是接收数据在DLAB1时读它得到的是波特率分频值的低8位。同一个地址含义完全不一样。控制这个开关的是LCR寄存器的bit7。LCR本身在0x0C地址你要设置波特率分频值时流程是先把LCR写成0x80也就是bit7置1其他位随意一般写0x80这时候0x00和0x04被解释为DLL和DLM然后写分频值进去写完还要把LCR恢复成正常的帧格式配置比如0x03代表8N1把DLAB关掉。如果不恢复后续收发数据时寄存器解释就会错位读到的全是分频值数据肯定不对。这个锁存机制是16550的祖传设计早期是为了在有限的寄存器地址空间里塞下更多功能。理解它之后初始化代码的顺序就变得非常清晰了。3.3 波特率分频的数学公式、实例、误差判断波特率分频值的官方公式在PG143里写得明确divisor UART_Clock_Frequency / (Baud_Rate * 16)分子是uart_clk的赫兹数分母是目标波特率乘16结果向下取整。这个divisor就是写入DLM:DLL的16位值。之所以有乘16是因为UART标准规定每个bit再采样16次保证在噪声环境下也能正确恢复数据。举个例子。uart_clk是100MHz目标波特率115200divisor 100000000 / (115200 * 16) 100000000 / 1843200 54.253取54。那么实际波特率 100000000 / (54 * 16) 115740.74误差约0.47%完全在串口通信容忍范围内。再算一个9600常用的divisor 100000000 / (9600 * 16) 651.04取651实际波特率 100000000 / (651 * 16) 9600.61误差只有0.006%非常理想。但如果你把uart_clk改成50MHz还想跑到115200divisor 50000000 / (115200 * 16) 27.126取27实际波特率 50000000 / (27 * 16) 115740.74误差还是0.47%。看起来没问题但如果目标是1836000这种高频波特率分频值可能很小一个整数的误差就会带来几个百分点的偏差直接导致通信失败。所以有两个实用建议第一尽量用接近整数分频的时钟和波特率组合第二误差超过2%就慎重超过3%基本必挂。实际调板子时如果碰到“偶尔能收到但内容不对”的怪现象先怀疑波特率误差用示波器或者逻辑分析仪抓TXD引脚量一下单个bit的宽度换算成实际波特率一量便知。4. SDK/Vitis里的驱动与中断处理代码直接抄4.1 初始化流程关中断、设分频、配帧格式、使能FIFO、开中断在SDK或Vitis里写代码我习惯直接操作寄存器不依赖xuartns550官方驱动这样对IP理解最深以后换平台也通用。先定义寄存器偏移#include xil_io.h #include xparameters.h #define UART_BASE XPAR_AXI_UART16550_0_BASEADDR #define RBR_OFFSET 0x00 #define THR_OFFSET 0x00 #define IER_OFFSET 0x04 #define IIR_OFFSET 0x08 #define FCR_OFFSET 0x08 #define LCR_OFFSET 0x0C #define MCR_OFFSET 0x10 #define LSR_OFFSET 0x14然后写初始化函数。注意顺序不能乱void uart16550_init(uint32_t base, uint32_t clk_hz, uint32_t baud) { uint32_t divisor clk_hz / (baud * 16); // 1. 先关中断防止配置过程中产生异常中断 Xil_Out32(base IER_OFFSET, 0x00); // 2. DLAB1准备写分频寄存器 Xil_Out32(base LCR_OFFSET, 0x80); // 3. 写DLL和DLM注意先低后高 Xil_Out32(base RBR_OFFSET, divisor 0xFF); Xil_Out32(base IER_OFFSET, (divisor 8) 0xFF); // 4. 恢复LCR为8N1即0x03同时关闭DLAB Xil_Out32(base LCR_OFFSET, 0x03); // 5. 写FCR使能FIFO并设置RX触发等级 // 0x01表示FIFO使能(0x016)表示16字节FIFO时触发等级为4字节 Xil_Out32(base FCR_OFFSET, 0x01 | (0x01 6)); // 6. 使能中断接收数据可用(bit0) 接收线路状态(bit2) // 注意没使能发送中断发送中断在需要发数据时再打开 Xil_Out32(base IER_OFFSET, 0x01 | 0x04); }这里面有细节要展开。第二步和第四步一开一关DLAB是波特率配置的标准流程写完DLL/DLM之后LCR必须恢复成帧格式否则后面收发全乱。第三步里往RBR_OFFSET和IER_OFFSET写数据看起来像是往接收缓冲和中断使能寄存器写东西实际上因为DLAB1这两个地址被解释成DLL和DLM这正好呼应了前面讲的寄存器映射复用规则。FCR配置里0x01是FIFO使能一旦使能IIR的bit6和bit7会变成11用来表示FIFO已经工作。RX触发等级通过bit7:6选择对于16字节FIFO00表示1字节触发、01表示4字节触发、10表示8字节触发、11表示14字节触发。选4字节是比较平衡的选择既不会因为CPU响应慢丢数据也不会中断太频繁。4.2 中断服务程序的标准写法清中断是灵魂ISR写不好最容易出现的就是中断风暴。AXI UART 16550是电平触发处理完一个中断类型后必须检查是否还有别的挂起中断。标准写法如下#define TX_BUF_LEN 256 static uint8_t tx_buf[TX_BUF_LEN]; static volatile uint32_t tx_len; static volatile uint32_t tx_index; void uart16550_isr(void *param) { uint32_t iir; uint8_t ch; iir Xil_In32(UART_BASE IIR_OFFSET); // bit0为0表示有中断挂起循环直到处理完所有挂起中断 while ((iir 0x01) 0) { switch (iir 0x06) // 取bit2:1判断中断类型 { case 0x06: // 接收线路状态错误读LSR即可清中断源 // 实际项目中要逐个bit判断是溢出、帧错误还是校验错误 Xil_In32(UART_BASE LSR_OFFSET); break; case 0x04: // 接收数据超时FIFO里有数据但一直没凑满触发等级 // 需要把FIFO里所有数据读完 while (Xil_In32(UART_BASE LSR_OFFSET) 0x01) { ch Xil_In32(UART_BASE RBR_OFFSET) 0xFF; // 放入你的接收队列这里略 } break; case 0x02: // 接收数据可用FIFO达到触发等级 // 循环读取直到LSR的bit0为0 while (Xil_In32(UART_BASE LSR_OFFSET) 0x01) { ch Xil_In32(UART_BASE RBR_OFFSET) 0xFF; // 放入你的接收队列 } break; case 0x00: // 发送保持寄存器空可以发送下一字节 if (tx_index tx_len) { Xil_Out32(UART_BASE THR_OFFSET, tx_buf[tx_index]); } else { // 如果发送完成关闭发送中断避免不断触发 Xil_Out32(UART_BASE IER_OFFSET, 0x01 | 0x04); } break; default: break; } // 重新读IIR确认还有没有挂起的中断 iir Xil_In32(UART_BASE IIR_OFFSET); } }这个循环是关键。很多人的ISR只在入口读一次IIR处理完一种类型就return结果另一个中断源还挂着电平触发逻辑让int_o始终保持高CPU刚出ISR又被拉进去表现出来就是程序卡死在中断里。接收超时中断值得单独讲一下。当FIFO里的数据没达到触发等级但已经有数据时16550会产生接收超时中断保证你不漏掉零散到达的字节。所以ISR案例0x04和0x02做的事情本质一样把FIFO里的残留数据全部读走。4.3 发送函数、接收队列和中断控制器的注册发送时先往THR写第一个字节然后使能发送保持寄存器空中断后续字节在ISR的case 0x00里继续填。这样做的好处是发送一个长字符串时CPU只需要启动一下剩下的由中断逐字节搬运不需要阻塞等待。代码大致是void uart16550_send(uint8_t *data, uint32_t len) { // 拷贝到发送缓冲区设置索引 // 为了简单这里省略对tx_buf的拷贝过程 tx_len len; tx_index 0; // 写第一个字节 Xil_Out32(UART_BASE THR_OFFSET, tx_buf[tx_index]); // 使能发送保持寄存器空中断IER bit1 uint32_t ier Xil_In32(UART_BASE IER_OFFSET); Xil_Out32(UART_BASE IER_OFFSET, ier | 0x02); }注意IERP使能发送中断之前要确认第一字节已经写进THR否则THRE中断会在你写THR之前就触发逻辑上虽然问题不大但会多一次空转中断干扰你的状态判断。中断控制器注册这一步要根据平台区分。Zynq的话用XScuGic把PL端中断号注册到异常表MicroBlaze则用XIntc或AXI Interrupt Controller的API。这里给一个Zynq的简化示例#include xscugic.h static XScuGic intc; #define UART_INT_ID XPAR_XSCUGIC_0_AXI_UART16550_0_INT_O_INTR void uart16550_intc_init(void) { XScuGic_Config *cfg XScuGic_LookupConfig(XPAR_SCUGIC_0_DEVICE_ID); XScuGic_CfgInitialize(intc, cfg, cfg-CpuBaseAddress); Xil_ExceptionInit(); Xil_ExceptionRegisterHandler(XIL_EXCEPTION_ID_INT, (Xil_ExceptionHandler)XScuGic_InterruptHandler, intc); XScuGic_Connect(intc, UART_INT_ID, (Xil_InterruptHandler)uart16550_isr, NULL); XScuGic_Enable(intc, UART_INT_ID); Xil_ExceptionEnable(); }这里的UART_INT_ID宏要和你实际的硬件连接对应不同工程的xparameters.h生成的宏名可能有差别务必去头文件里确认不要照搬。5. 避坑实录从仿真到上板的经验总结5.1 问题速查表先对号入座现象可能原因解决办法上板后接收全是乱码波特率分频值不对uart_clk频率填错两侧帧格式不一致核对IP配置里UART Clock与真实时钟频率重新计算divisor确认都是8N1中断一直触发CPU像卡死ISR没读完所有挂起中断没清中断源IER误开了不需要的中断按示例在ISR里循环直到IIR bit0为1正确清除RBR/LSR/THR对应状态发送不出去或只发第一个字节THRE中断没使能发送中断被误关tx_index计数错误按流程先写THR再开IER bit1检查ISR里发送分支逻辑接收偶尔丢字节FIFO触发等级太高ISR处理不及时中断优先级不够降低FCR里的RX触发等级ISR里循环读完FIFO检查中断控制器优先级AXI读回数据固定为0或0xFF地址映射错误s_axi_aclk没跑aresetn无效核对xparameters.h地址检查Block Design时钟和复位连接用ILA抓总线和PC调试助手连不上流控被使能TX/RX接反引脚约束没做关闭PC端流控确认FPGA引脚约束正确在板级回环验证第一次收发正常热复位后异常复位时序问题FIFO没被复位初始化时写FCR先清零FIFO检查复位信号是否满足IP要求5.2 波特率误差带来的“假死”现象我曾经遇到过一个很典型的场景uart_clk用了33.333MHz这是从某个PLL分出来的目标波特率设成57600。算一下分频值33333300 / (57600 * 16)结果不是整数取整之后误差超过3%。现象很诡异PC端发送数据FPGA有时候能收到几个正确字节有时候全是错的甚至完全收不到。用示波器抓TXD发现每个bit的宽度和标准57600差了明显的一截。这类问题排查思路很简单就三步第一步用示波器或逻辑分析仪量FPGA的TXD引脚数一个bit的时间换算成实际波特率第二步反向计算当前DLL/DLM里写进去的分频值看看IP实际是打算跑多少波特率第三步对比两者是否一致不一致找链路中哪个环节填错了。还有一个很容易忽略的点IP配置界面的UART Clock和Block Design里实际连接的时钟频率可能不一致。比如Block Design里时钟原来100MHz后来因为某个模块改了PLL配置变成75MHz但是IP里UART Clock还停留在100MHzIP会按照100MHz去算分频值实际跑75MHz波特率自然偏掉。每次改过时钟树都要回头核对IP参数。5.3 中断风暴电平触发带来的麻烦电平触发VS脉冲触发在FPGA中断处理里是天壤之别。脉冲触发时哪怕ISR没清干净中断源下一拍脉冲不来就没事电平触发不同只要中断源还挂着中断线永远是有效电平中断控制器就会反复上报。16550就是电平触发ISR里如果只处理了接收而发送中断也挂起了或者接收FIFO没读完CPU就会疯狂进ISR。我踩过的一个具体坑是ISR里处理完接收数据后直接return没重新读IIR结果因为IER同时开了接收和线路状态中断ISR只清了接收线路状态那个中断源还挂着CPU就死锁在中断里。打出来的现象是程序跑一会儿就卡住用调试器暂停一看PC落在中断服务函数里而且每次暂停位置都不一样。解决办法就是我前面代码里写的那个while循环处理完一个类型后重新读IIR再看bit0如果仍然为0说明还有挂起中断继续处理直到IIR显示没有挂起为止。这个习惯一旦养成基本能杜绝中断风暴。5.4 一些不为人注意的小细节第一LSR的bit0表示接收数据就绪但FIFO模式下它表示FIFO里有至少一个数据。在ISR的接收分支里我习惯用while (Xil_In32(base LSR_OFFSET) 0x01)循环读取保证把FIFO里所有数据都取走而不是只取一个就停。这样即使FIFO在读取期间又收到新数据也能一并处理避免残留数据触发无谓的中断。第二FCR的bit1和bit2分别是RX FIFO和TX FIFO的复位位。初始化时把这两个位置1给一个短暂的脉冲清零能确保上电瞬间FIFO状态干净。Xilinx的IP有时会出现复位后FIFO里残留数据的情况特别是热复位时主动复位一次FIFO能省很多麻烦。第三用Zynq时注意把中断引脚正确的连到IRQ_F2P。很多人Block Design里忘了连int_o或者连到了IRQ_F2P[1]但SDK里查的是IRQ_F2P[0]的中断号结果中断完全进不去。建议在Block Design里专门打开连接图检查一下这条路径再在xparameters.h里搜UART_INT_O_INTR确保宏存在且值和你预期一致。第四如果板子上的UART芯片带硬件流控而IP又没有启用Modem端口PC端调试助手的RTS/CTS相关选项一定要关掉。否则上位机可能因为CTS无效而拒绝发送表现是FPGA收不到任何数据但寄存器看都正常。最后再说几句实在话踩过这么多坑之后我个人最深的体会是AXI UART 16550这个IP核本身不复杂寄存器就是老16550那一套关键是要把“寄存器复用规则”“波特率分频计算”“电平触发中断”这三个点彻底想明白。只要这三个点通了网上的资料加上这篇笔记基本能解决百分之九十的问题。我自己的习惯是新板子首次上电跑串口时不急着写业务逻辑先写一个回环自测程序PC发一串数据给FPGAFPGA通过16550中断接收再原样发回PCPC收到后比对确认无误再往上面叠功能。这个回环测试看似简单但它能把FPGA引脚约束、AXI总线访问、波特率、帧格式、中断处理这整条链路一次性验证完后面再出问题至少知道不是最底层串口的问题。如果还要提一个最容易被忽略的坑我选uart_clk的时钟频率。每次改完PLL或者时钟向导再回来调串口第一件事就是去IP配置里确认UART Clock是不是最新值。就这一条好几次帮我避开了整夜的调板子时间。希望这篇笔记也能帮你少走点弯路。