FPGA串口通信实战:AXI UART 16550 IP核配置与中断处理详解

发布时间:2026/9/24 7:23:15
FPGA串口通信实战:AXI UART 16550 IP核配置与中断处理详解 搞FPGA通信串口永远是逃不掉的那道坎。不管是调试MicroBlaze软核、跟ARM硬核通信还是纯粹拿FPGA做数据采集监测一个稳定好用的UART通道能省下太多事。Xilinx在Vivado里给了两个现成的串口IP核一个叫AXI UART Lite一个就是今天重点要说的AXI UART 16550。后者虽然配置项多一些、上手看起来复杂一点但它能直接兼容PC标准串口模式带较大FIFO、支持硬件流控在很多实际项目里反而是更稳、更省逻辑的方案。这篇博文不聊理论书上的东西全部来自我实际调这块IP核时踩过的坑从Vivado里的图形化配置到SDK/Vitis里寄存器操作和中断流程一步一步带你把16550用到能稳定收发、能中断处理的程度。这篇内容适合正在用Zynq或MicroBlaze做通信接口开发对Vivado IP集成有基本了解但被16550各种寄存器、各种中断标志位绕晕的朋友。如果你是纯逻辑党习惯自己写UART收发模块也可以参考一下这套现成方案至少在时间紧迫的工程里拿IP核比你自己写代码靠谱得多。1. 为什么选AXI UART 16550而不是更简单的AXI UART Lite接触过Vivado的人都知道Xilinx IP目录里串口相关的就那两个一个AXI UART Lite一个AXI UART 16550。很多同学一看“Lite”这名字觉得更轻量更简单上手就用结果项目做到一半发现FIFO不够深、流控没得做然后才回头来换16550费时费力。我带过不少项目这里先把我选型时的思路讲清楚。1.1 AXI UART 16550和Lite的定位区别AXI UART Lite走的是极简路线内部是简单的寄存器接口加上发送/接收FIFO默认只有16字节可以配置但深度有限中断逻辑也比较单薄。它适合那种“我就想打印个调试日志不想关心复杂协议”的场景。AXI UART 16550则是仿照PC上经典的16550 UART芯片设计的核心特点是寄存器接口占空间小功能却非常齐全发送FIFO和接收FIFO深度可选最大到64字节支持五到八位数据位、多种校验方式、可编程波特率、硬件RTS/CTS流控还能工作在FIFO模式和非FIFO模式。它还有接收字符超时中断这个在传输不定长数据时至关重要。我自己的经验是如果是给MicroBlaze做调试串口或者只是想跟PC串口助手通信Lite够用而且代码简单出问题的概率低。但凡是数据量稍大、需要跟PC串口、传感器模块或者老式工业设备做双向交互或者你的CPU还要处理其他任务不能一直轮询收发直接上16550别纠结。1.2 用16550核能帮你省掉哪些麻烦实际使用中最直接的感受有三点。第一接收FIFO大了之后CPU不用在每收到一个字节都触发中断可以攒到一定数量再一次性处理吞吐量明显不一样。第二字符超时功能让“不定长数据帧”的判断变得特别容易设定一个超时时间收完一帧后自动触发中断不用自己再开定时器卡接收间隙。第三它支持自动RTS/CTS流控接PA、接传感器对方发数据太快的时候硬件能自动拉RTS把对方电视住不用软件干预。从资源占用角度看16550也没有想象中那么可怕在Zynq或者Artix-7这种规模的芯片上单实例资源占用完全可以忽略。2. Vivado里的IP核配置每一个选项都别乱填双击IP核打开配置界面时大家往往看着一堆下拉框不知道选什么。其实16550配置项翻来覆去就那几个但每个都直接影响后面软件层的寄存器逻辑我按配置界面从上到下逐个过一遍顺便说说我推荐的配置逻辑。2.1 最容易被忽略的Interface选择配置页面里第一栏是“Interface”默认是AXI4-Lite。这里几乎不用动。AXI4-Lite是Xilinx专门给外设寄存器设计的轻量级总线接口单次读写、无突发正好匹配串口这类字符设备。别选AXI4那是给高性能存储器或者DMA用的选了反而给自己找麻烦。然后在“AXI Interface”的子选项里要注意“Base Address”一般不用在IP核配置界面里手动填Vivado的Address Editor会自动分配。我见过有人在这里手动填了一个地址结果跟其他IP冲突后面SDK里死活访问不到寄存器。这个地址交给工具去分配就好除非你非常清楚自己在做什么。2.2 波特率与分频那些事接着是“Baud Rate”设置比较常见的是9600、115200、921600这几个档位。这里有个重要概念16550内部是用一个可编程分频器从输入时钟里分频出波特率时钟。输入时钟默认是系统总线时钟比如Zynq的CPU时钟Xilinx IP核会自动帮你计算分频系数但你需要在配置界面填一个“Power of 2 Divisor”之类的选项吗不需要按实际波特率选就行。要注意的是16550的波特率理论上允许通过修改DLL/DLM寄存器实时调整但如果只用IP核自带的生成配置你只需要在界面里选好固定波特率。补充一句115200一般用8位数据、无校验、1停止位也就是8N1这是串口助手的默认值也是兼容性最好的配置。2.3 FIFO深度、流控和Legacy Mode怎么选FIFO这个选项一般有16/32/64这几个选择。我的建议是只要逻辑资源允许直接选64。理由很简单FIFO越深响应超时和丢数据的概率越低尤其在CPU被更高优先级中断占用的场景下64字节的缓冲可以撑过很长一段“没人管”的时间。“Hardware Flow Control”这里值得多说两句。如果选上IP核会占用额外的RTS/CTS信号引脚在软件层也要设置MCR寄存器里的AFE位自动流控使能。我之前有个项目接了外部GPS模块数据是连续不断发过来的一开始没用流控偶尔丢字节开启自动流控后模块发得再快也能完整接收。所以在硬件引脚下得来、对端也支持流控的情况下建议开启。“Legacy Mode”默认不勾选。注意勾选后FIFO深度会被强制限制为1字节功能上模拟老式16550UART没有缓冲能力。除非你是在做移植测试否则别选。我见过有人不小心勾上结果配置了64字节FIFO也完全不生效接收数据一直丢。2.4 中断配置没那么玄但每个勾都要检查在“Interrupts”一栏里Xilinx把16550的四种中断源做成了复选框接收数据可用、字符超时、发送FIFO空、接收错误。建议全部勾上。有人觉得“发送FIFO空”这个中断没用其实在需要发送大量数据时可以借用它做底层驱动填满FIFO后就等发送完成中断再继续填下一批能有效避免CPU死等。勾完中断后IP核会生成中断输出信号。如果是用MicroBlaze你需要在Block Design里手动连到MicroBlaze的中断控制器如果是Zynq则要连到PS端的GIC通用中断控制器上。这一步很多人忘记软件里怎么配都白搭因为中断信号根本没上到CPU。2.5 配置参考小结我习惯用的一组配置是接口AXI4-Lite波特率115200数据位8无校验1停止位FIFO深度64开启硬件流控四种中断全勾。这套配置兼顾了通用性、可靠性和效率除非有特殊需求否则基本可以无脑照抄。配置完别急着点OK先点一下“Safety Check”或者重新看一下地址分配。如果Block Design里还有AXI Interconnect确认串口挂在正确的总线上。没有总线互联时MicroBlaze和IP核之间连接不上是常见问题加一个AXI Interconnect并把IP挂上去就好。3. 硬件连线与约束最容易栽跟头的一个环节IP核配置只是第一步真正让串口跑起来还要看连线、管脚约束和仿真。我见过不少人配置界面没问题生成比特流也成功但上板后UART完全没反应查到最后发现是管脚约束没写对或者顶层的信号根本没连出来。3.1 Block Design中的连线细节在Block Design里例化AXI UART 16550后默认会看到类似UART、UART_RX、UART_TX、RTS、CTS这样的信号。如果你的处理器用的是MicroBlaze需要有AXI Interconnect中转一下如果用的是Zynq的PS端则要检查AXI从接口是否挂到了PS端的M_AXI_GP口上。中断信号的处理我前面提过。这里再强调一次在Block Design的Diagram里16550的中断输出引脚名字通常是interrupt你要把它连到MicroBlaze的Interrupt Controller IP核上或者连到Zynq的PL-PS中断端口。连完之后要确认中断号SDK里要用这个号注册handle弄错一个数字中断就永远进不来。我个人习惯把UART的中断优先级设高一点用FIFO_MODE保存数据。3.2 引脚约束的完整写法如果你用的是Zynq的MIO引脚那直接在PS配置里勾选UART1不用写约束。但如果你用的是PL端EMIO或者直接在纯FPGA里调试那必须写XDC约束把UART信号分配到实际物理引脚上。示例约束写法如下set_property PACKAGE_PIN T14 [get_ports {UART_RX}] set_property PACKAGE_PIN U15 [get_ports {UART_TX}] set_property IOSTANDARD LVCMOS33 [get_ports {UART_RX}] set_property IOSTANDARD LVCMOS33 [get_ports {UART_TX}]这里特别提醒Vivado默认端口命名如果带总线后缀比如uart_tx[0]那么约束里的端口名要写成get_ports {uart_tx[0]}中括号在Tcl里是特殊字符要用花括号括起来否则会报错。这个坑很细但特别常见。3.3 上板之前先做仿真很多人不做仿真直接上板一旦不工作就只能干瞪眼。其实16550在Vivado自带仿真库里是有仿真模型的写一个简单的testbench给IP核灌进去几个字节用Vivado自带仿真器或者ModelSim都能看到UART_TX波形。这样能最快确认IP核配置和连线是否正确。仿真时要注意IP核的输出在一开始会呈高电平也就是空闲状态如果一开始没有拉高说明复位有问题。另外仿真时钟要设置正确通常主频如果是100MHz那么波特率分频器得到的分频系数可以参考IP核生成的例化模板或者直接看生成的example design中的代码。4. 软件驱动编写轮询收发和中断处理的区别硬件准备完毕后进入软件阶段。使用ZynqSDK/Vitis或者MicroBlazeSDK第一步是检查BSP里能不能正确识别这个IP核其次是自己写底层驱动。Xilinx官方提供了一套16550的驱动函数叫做XUartNs550一般SDK里会自动生成。但这套驱动比较底层很多人拿到手不知道怎么用所以我建议先理解寄存器再说这样哪怕不用官方函数也能自己搞定。4.1 16550寄存器映射记住这几个就够用了AXI UART 16550的寄存器挂在一块很小的地址空间里通过AXI-Lite接口可以访问。最常用的寄存器有这几个RBR接收缓冲寄存器地址偏移0x0读操作时拿到的是接收FIFO里的一个字节。THR发送保持寄存器地址偏移0x0写操作时把字节送进发送FIFO。IER中断使能寄存器偏移0x4控制哪些中断源可以使能。IIR中断标识寄存器偏移0x8和FCRFIFO控制寄存器偏移0x8IIR用于识别当前中断类型FCR用于复位和设置FIFO触发阈值。LCR线路控制寄存器偏移0xC设置数据格式。MCR调制解调控制寄存器偏移0x10控制流控等。LSR线路状态寄存器偏移0x14读取接收FIFO是否有数据、是否出错等。其中最容易混淆的是读地址0x0和写地址0x0一个RBR一个THR共用地址靠读写方向区分。这个和ARM外设设计习惯一样读和写可以看作两个寄存器并不冲突。4.2 轮询模式的收发代码骨架在调试阶段轮询模式最省心。先初始化LCR配置好8位数据、无校验、1停止位然后等发送FIFO空了就填一个新字节接收端则不断查询LSR的接收数据Ready位。uint32_t baseaddr XPAR_AXI_UART_16550_0_BASEADDR; void uart_init(void) { uint32_t lcr 0x03; // 8N1 u32_write(baseaddr 0x0C, lcr); } void uart_putc(char c) { while ((u32_read(baseaddr 0x14) 0x20) 0); // THR空? u32_write(baseaddr 0x00, c); } char uart_getc(void) { while ((u32_read(baseaddr 0x14) 0x01) 0); // DR就绪? return (char)u32_read(baseaddr 0x00); }这里有个细节LSR的bit5THRE置1表示发送保持寄存器空但如果FIFO使能了它表示发送FIFO为空或至少达到触发级别。所以要在初始化时先把FCR的FIFO使能位打开设置FIFO触发阈值这样发送逻辑才更高效。如果你不设置FCR默认FIFO可能未开启功能退化成单字节缓冲性能差别很大。4.3 中断处理流程定时器、超时与错误都要管到了真正做项目肯定不能用轮询。用中断方式时初始化的步骤大约是配置FCR使能FIFO设置接收FIFO中断触发阈值比如8字节触发一次。配置IER开启接收数据可用中断和字符超时中断。注册中断服务函数把16550的中断号连接上去。在ISR里读IIR判断中断类型再做相应处理。void uart_isr(void *CallbackRef) { uint32_t baseaddr (uint32_t)CallbackRef; uint32_t iir; while (1) { iir u32_read(baseaddr 0x08); if ((iir 0x01) ! 0) break; // 无中断待处理 switch ((iir 1) 0x07) { case 0x04: // 接收数据可用 // 读取RBR直到接收FIFO空 while ((u32_read(baseaddr 0x14) 0x01) ! 0) { char c (char)u32_read(baseaddr 0x00); ring_buf_push(c); } break; case 0x0C: // 字符超时 while ((u32_read(baseaddr 0x14) 0x01) ! 0) { char c (char)u32_read(baseaddr 0x00); ring_buf_push(c); } frame_ready 1; break; case 0x02: // 发送保持寄存器空 // 如果还有数据要发继续填THR break; default: // 读取LSR清除错误标志 u32_read(baseaddr 0x14); break; } } }这段ISR里最关键的就是判断完IIR后一定要读数据或者读LSR来清除中断标志否则中断会反复触发。IIR会自动更新但不清除源标志位会直接造成CPU被中断风暴打满。实际调试时如果发现程序卡死优先检查ISR里有没有及时读RBR或LSR。5. 中断连接与SDK/Vitis里的具体步骤有了中断服务函数还不够还得让CPU真正能收到来自PL的这根中断线。这一步在SDK/Vitis里配置不对中断就永远不触发而这类问题往往是硬件连接和软件中断号对不上或者初始化顺序有误。5.1 MicroBlaze场景接中断控制器在MicroBlaze的Block Design里你需要加入一个AXI Interrupt Controller通常叫axi_intc把16550的interrupt输出接到它的输入通道。然后MicroBlaze的异常向量表里注册这个中断控制器的handle。代码里要用到XIntc或XScuGic请按你实际用的控制器去选择。如果是XIntc示例初始化大致如下XIntc_Config *intc_config; intc_config XIntc_LookupConfig(XPAR_AXI_INTC_0_DEVICE_ID); XIntc_CfgInitialize(intc_instance, intc_config, intc_config-BaseAddress); XIntc_Connect(intc_instance, XPAR_AXI_UART_16550_0_INTERRUPT_INTR, uart_isr, (void*)baseaddr); XIntc_Enable(intc_instance, XPAR_AXI_UART_16550_0_INTERRUPT_INTR); Xil_ExceptionInit(); Xil_ExceptionRegisterHandler(XIL_EXCEPTION_ID_INT, (Xil_ExceptionHandler)XIntc_InterruptHandler, intc_instance); Xil_ExceptionEnable();注意XIntc_Connect里的参数ID不是随便填的它对应Block Design里中断控制器连接端口的一个宏定义一般在xparameters.h里能查到。如果没有这个宏说明中断线没连上或者地址映射有问题。5.2 Zynq场景接到GICZynq平台上更常见的是把16550的中断连到PS端GIC的PL中断线IRQ_F2P[7:0]之类的。比如在Block Design里将interrupt连到处理器系统的IRQ_F2P端口上对应到GIC的中断号是61不同的管脚对应不同编号以你的工程为准通常参考block design自动生成的地址映射。在Vitis里用XScuGic驱动XScuGic_Config *gic_cfg XScuGic_LookupConfig(XPAR_SCUGIC_SINGLE_DEVICE_ID); XScuGic_CfgInitialize(gic_instance, gic_cfg, gic_cfg-CpuBaseAddress); XScuGic_Connect(gic_instance, 61, uart_isr, (void*)baseaddr); XScuGic_Enable(gic_instance, 61);这种情况下还要额外确定PL到PS中断的映射表在Vitis里可以用XScuGic_IntVector或直接看xparameters.h中对应IP核中断ID的宏。如果中断号填错不会报错但中断进不来甚至可能触发别的中断务必仔细对照。5.3 中断初始化的顺序问题别小看初始化顺序。我建议的顺序是先使能UART自身的寄存器FCR、IER再连接中断控制器再使能中断控制器里的对应通道最后才能打开全局中断。如果顺序反了可能在全局中断打开之前中断已经发生但没被服务结果挂在Pending上之后再打开也进不去。另一种非常隐蔽的问题是MicroBlaze中断控制器里有两个寄存器一个是MERMaster Enable包含硬件使能和软件使能另一个是各自中断通道的Enable。你只设了MER没有设通道使能照样进不来。6. 踩坑实录这十个问题我基本都见过最后这部分算是我花最多时间总结出来的因为很多问题不在配置界面也不在代码逻辑而是藏在工具链和数据手册的边角里。我把它们整理成表遇到时可以直接对号入座。症状可能原因排查与解决办法上板后没有任何输出引脚约束没写对检查XDC中封装引脚和IOSTANDARD确认顶层信号名输出全是乱码波特率不匹配核对IP核配置和上位机配置分频系数异常也可能导致只能收到第一个字节FCR里的FIFO没激活初始化时写FCR的FIFO使能位并设置触发阈值接收数据经常丢接收FIFO中断阈值过高或CPU响应太慢调低触发阈值或检查中断嵌套优先级中断一直触发导致CPU卡死IIR读后没有清除源标志务必读RBR或LSR把源标志清掉发送数据死等发送FIFO满时TX位判断错误检查LSR bit5的定义FIFO模式下与单字节模式不同中断进不去中断线没接、中断号错误或未使能用Vivado Address Editor确认地址核对宏定义上板后第一次发数据无效复位后FIFO状态未清初始化时额外写几次FCR触发FIFO清空加大波特率后不稳定系统时钟抖动或PCB走线问题改用内部时钟约束尽量用差分时钟输入降低波特率验证用GIC时中断误触发多个PL中断挤在同一条线上检查PS-PL中断映射寄存器给不同中断分配不同线每个问题背后都有具体的调试场景。比如“上板后没有任何输出”我遇到过一次比较离谱的情况是管脚约束对了但顶层模块里uart_tx和uart_rx信号其实被综合优化掉了因为IP核的配置里Enable接收/发送信号被错误反向了。这种情况下在Block Design里双击IP核检查 “Enable Input” 和 “Enable Output”确保两个选项都勾上不要依赖默认值。另外调试时建议用逻辑分析仪或者用ILA核挂在UART_TX/UART_RX上可以直接看到电平波形比用示波器省事得多。但如果只是开发初期不要过早把ILA加进去ILA会占用大量BRAM和触发资源影响布局布线等基本功能没问题后再加。6.1 编译报错DRC RTSTAT-2的说明Vivado实现时如果报DRC RTSTAT-2大概率是你把某个静态信号接到了输出端口上或者某个信号没有被正确驱动。16550的TX输出在硬件上默认是高电平如果你为了省事在顶层赋了初值或者反了综合后DRC就可能报警。遇到这种错误先在方块图里追踪uart_tx到引脚看是否有其他逻辑干扰。6.2 使用官方驱动XUartNs550的注意事项如果你不想自己写寄存器操作可以用Xilinx封装好的XUartNs550。用法上先XUartNs550_Initialize然后XUartNs550_SetBaud再XUartNs550_SetDataFormat中断部分则通过XUartNs550_SetHandler注册回调。这套官方驱动有个特点它内部接管了IER、IIR等寄存器但你依然要注意片内FIFO的触发阈值和中断回调函数里的处理时长不能太长否则高波特率下载入的数据会溢出。官方驱动的回调机制和裸机写法差别不大但有一点值得注意官方驱动的回调函数在一些SDK版本里由SendHandler和ReceiveHandler组成中断服务里会自动调用你只需要往驱动内部缓冲里塞数据或者从收数据队列里取数据就行。不要在主逻辑里做长时间循环处理数据处理尽量放到主任务或RTOS线程里。这样能避免中断里执行耗时函数导致下一帧数据丢失。7. 用中断触发一帧数据处理的实际例子给一个典型的用法。假设传感器每10ms发过来一帧16字节数据我们想把整帧收下来后再做解析。如果用轮询CPU会被占掉不少时间完全体现不出16550的优势。用中断的话方案可以是这样设定FIFO触发阈值为16字节一帧数据正好一次触发如果某次没到16字节字符超时中断会在约1字节时长后触发也能把残余数据收干净。#define FRAME_LEN 16 uint8_t rx_frame[FRAME_LEN]; uint8_t frame_index 0; volatile uint8_t frame_done 0; void uart_isr(void *callback_ref) { uint32_t baseaddr (uint32_t)callback_ref; uint32_t iir; while (1) { iir u32_read(baseaddr 0x08); if ((iir 0x01) ! 0) break; if (((iir 1) 0x07) 0x04) { while ((u32_read(baseaddr 0x14) 0x01) ! 0) { if (frame_index FRAME_LEN) rx_frame[frame_index] (uint8_t)u32_read(baseaddr 0x00); else u32_read(baseaddr 0x00); } } else if (((iir 1) 0x07) 0x0C) { while ((u32_read(baseaddr 0x14) 0x01) ! 0) { if (frame_index FRAME_LEN) rx_frame[frame_index] (uint8_t)u32_read(baseaddr 0x00); else u32_read(baseaddr 0x00); } if (frame_index FRAME_LEN) frame_done 1; frame_index 0; } else { u32_read(baseaddr 0x14); } } }这个设计在低速接收时非常舒服中断服务也很短把数据搬进RAM就退出。帧解析跑到主循环里做。如果你今后要加更多协议处理这样的分层结构也更容易扩展。8. 最后还想提醒的几个点想来想去还有几个随手记的细节没有归到前面的章节里但对实际开发很有价值。第一是复位。16550 IP核需要外部复位信号在Block Design里通常由Processor System Reset统一提供。上电后别急着发数据等复位释放并延时几十毫秒再初始化UART否则FIFO状态不稳定第一次发送容易丢字节。第二是软件里的地址偏移。AXI UART 16550的寄存器地址偏移是以字节为单位但AXI-Lite总线宽度如果是32位那么C语言里用uint32_t指针访问寄存器时RBR/THR的偏移可能是0x0、IER是0x4这个没有问题。但是如果你用字节指针去访问那就要保证地址按4字节对齐。很多人踩过这个坑移位补零补坏了。第三是多实例场景。如果工程里需要两路或以上串口可以在Block Design里例化多个16550 IP核每个分配不同的基地址。软件层准备两份驱动上下文或者把ISR参数替换成各自基地址基本上没有太大问题。唯一需要注意的是硬件流控信号引脚别因为复用而被占用了导致串口B的RTS信号无法分配。第四是如果想直接上网口转串口模块比如跟W5500或CH9121这类网络芯片对接16550的5V容忍引脚在不同电平下可能不兼容建议加电平转换。FPGA到外部模块的互连电压不一致寄了FPGA引脚得不偿失。如果只是临时对付一下调试输出又不想花太多时间可以把波特率设成9600中断暂时不使能用轮询跑通基本收发然后再逐级上中断、上流控。慢慢来把每一步都验证到位串口这块基本不会再有大坑。