把ZYNQ的UART1从固定的MIO引脚挪到EMIO,再通过PL逻辑自定义引脚引出——这个操作我在一个多路串口项目里折腾了整整一天才完全跑通。很多资料只讲了MIO和EMIO的区别,却没有说清楚在Vivado里到底怎么配、约束怎么下、Linux下怎么变成ttyS。这篇文章就把我这次“基于ZYNQ的EMIO调试UART实验”的完整过程写出来,包括硬件设计、裸机验证、Linux设备树、以及调试中踩过的坑。适合正在做ZYNQ板级调试、打算把PS串口引出到任意PL引脚、或者MIO不够用来扩展串口的同学参考。
1. 为什么UART调试要绕到EMIO上:MIO与EMIO的选型逻辑
1.1 MIO与EMIO到底差在哪
ZYNQ的PS端UART控制器物理上挂在APB总线上,但它的引脚引出有两种方式:通过MIO直接连到PS封装的固定引脚,或者通过EMIO接口送到PL内部,再由PL引脚连接到板卡外部。很多第一次接触ZYNQ的人会误以为EMIO也是一种物理引脚,实际上EMIO只是PS和PL之间的一组内部接口信号,它本身不直接出到芯片外部,必须通过PL的IO资源做二次约束,才能最终到达封装引脚。
MIO和EMIO的差别可以从几个维度看:
| 对比项 | MIO | EMIO |
|---|---|---|
| 物理路径 | PS直接到封装引脚 | PS到PL,再经PL引脚出芯片 |
| 引脚位置 | 固定,不可更改 | 任意PL引脚,可自由布线 |
| 可用数量 | 54个,固定bank电压 | 受PL bank和封装限制,数量充裕 |
| 时序灵活性 | 固定,驱动强度有限 | 可由PL逻辑控制,搭配约束灵活调整 |
| 适用场景 | 标准外设优先复用 | MIO被占用、需要自定义管脚位置 |
UART控制器本身的数据通路、寄存器、FIFO、波特率发生器等逻辑都在PS内部,EMIO改变的不是控制器,只是物理信号的出口。这意味着,软件侧的驱动、中断、寄存器操作完全不用改动,你只需要在硬件设计阶段把信号引到PL引脚上就行了。这也是EMIO能成为调试串口常用方案的根本原因。
1.2 哪些情况下你不得不选EMIO
实际项目中选EMIO,通常不是因为你喜欢绕路,而是因为MIO实在不够用。MIO只有54个,SD卡要占一部分,QSPI Flash要占一部分,GEM网口还要占一部分,分给UART的固定引脚就只剩下那几个,而且位置固定,板级布局经常被逼着绕线。这时候把UART1通过EMIO引到PL侧一个顺手的引脚,就能避开MIO的资源竞争。
还有一种情况是板子要支持多种启动模式或外设可选,MIO引脚被复用开关切换来切换去,为了不互相干扰,直接把调试串口做成EMIO,在PL侧指定一个不参与外设复用的普通IO。这样无论是换启动配置还是改外设,调试串口都不受影响。
另外,如果你在做FPGA原型验证或者需要把UART信号送进PL逻辑内部做协议分析,比如抓包、转发、环路测试,那就必须走EMIO。因为只有EMIO才能让PS的UART信号出现在PL的逻辑资源里,你可以用逻辑分析仪IP、ILA、或者自定义模块监听这个信号。
1.3 EMIO UART实验的目标
我这次实验的目标很直接:在Vivado里把ZYNQ PS的UART1配置成EMIO方式,把tx和rx两个信号引出到PL侧的两个引脚上,然后外接一个USB转串口模块,让开发板通过这个“另类的串口”打印出调试信息,并在Linux系统下让它作为标准串口设备使用。
实验硬件并不复杂,一块ZYNQ-7000系列开发板,一个USB转串口小板,几根杜邦线就够了。重点在于整个链路:Block Design里的EMIO信号怎么设置、顶层端口怎么创建、XDC约束怎么下、裸机程序怎么跑通、Linux设备树怎么改。这一套流程搞明白,以后任何需要把PS外设挪到PL引脚上的设计都能举一反三。
2. 实验硬件设计:从Block Design到FPGA引脚约束的完整链路
2.1 在Vivado里配置PS端UART走EMIO
在Vivado里创建工程以后,第一步是添加ZYNQ7 Processing System IP。双击打开Re-customize IP,进入PS配置界面。
左侧Peripheral I/O标签页里,能直接看到UART0、UART1等外设。UART1默认可能没有勾选,需要打开。关键的一步是右侧的引脚模式选择:不要选择MIO,要把UART1的TX和RX都改成“EMIO”,也就是让这两个信号通过EMIO接口进入PL。
配置完以后,Close并Run Block Automation,生成顶层wrapper。这时候你点开Block Design里的连接图,会发现ZYNQ IP右侧多出来一组名为UART1的接口信号,这就是需要接出去的EMIO信号。图形界面上默认可能显示成uart1_tx、uart1_rx(具体信号名取决于Vivado版本),它们是PS输出/输入的复位、时钟之外的附加接口。
一个容易忽略的细节是,UART1的EMIO接口里除了tx和rx,往往还有cts、rtn、dtr、dsr这些流量控制信号。如果你的设计只用两线串口,不接流控,就不需要把这些信号连出去,但最好在Block Design中把它们在IP内部disable掉,避免vivado综合时报未连接端口的警告。
2.2 理解EMIO接口的信号与位宽
ZYNQ的EMIO接口并不是一个单一的总线,而是分成了很多组,每组对应一类外设。UART1对应的一组,位宽和信号定义在ZYNQ TRM里有明确说明。就本次实验而言,重要的是tx和rx这一对信号。
不过这里有一个比较绕的点:在Vivado的Block Design里,你看到的接口往往是usb0_port_indicator这样的宽信号,或者uart1信号组内部又拆成了规范的接口名。在某些版本里,uart1的tx和rx可能被封装成UART1_rtl这样的接口,需要你手动右键“Make External”,把整个接口引到顶层。如果你点开之后看到的是UART1_rtl_0_tx和UART1_rtl_0_rx这样的独立端口,也没问题,直接在顶层保留即可。
从功能上看,EMIO的UART信号位宽是固定的:tx为PS输出,rx为PS输入。其他的如cts、rts在使能硬件流控时会用到,本次实验不需要,保持不连接即可。
2.3 顶层连接与XDC引脚约束
生成块设计后,右键Block Design,选择Generate Output Products,然后Create HDL Wrapper。这时顶层文件里会看到类似下面的端口:
module zynq_emio_uart_wrapper ( ... inout var_vp, ... output UART1_rtl_0_tx, input UART1_rtl_0_rx, ... );如果看不到tx/rx出现在顶层,回到Block Design,确认是否手动创建了外部端口。Vivado有时会把没有连接的端口自动优化掉。确保这些端口已经在顶层声明后,接下来就是编写XDC约束文件。
我用的两个PL引脚位置和电平标准是这样约束的:
set_property PACKAGE_PIN N16 [get_ports {UART1_rtl_0_tx}] set_property IOSTANDARD LVCMOS33 [get_ports {UART1_rtl_0_tx}] set_property PACKAGE_PIN N17 [get_ports {UART1_rtl_0_rx}] set_property IOSTANDARD LVCMOS33 [get_ports {UART1_rtl_0_rx}]注意端口名必须和顶层文件里的一致,大小写敏感。引脚位置要根据你板卡实际连接USB转串口模块的排针来选,我这里只是示例。IOSTANDARD一样要仔细看,如果所在PL bank的VCCO接了1.8V,就不能直接用LVCMOS33,否则综合阶段可能不报错,但生成比特流之后板级电路无法正常工作,甚至可能损伤引脚。
2.4 生成比特流前要注意的检查点
在点Generate Bitstream之前,我建议做三件检查:
第一,打开Address Editor,确认ZYNQ的UART1控制器地址已经映射到处理器CPU访问的地址空间里。UART1的固定物理地址是0xE0001000,这一般是自动的,但如果你手动编辑过地址空间,容易把它搞乱。裸机工程里使用XPAR_XUARTPS_1_BASEADDR时会依赖这个地址。
第二,确认PS侧的UART参考时钟是否配置正确。在PS配置界面勾选UART1时,会有一个输入时钟选项,通常可以用IOPLL或者某个FCLK_CLK0分频后的时钟。我这里选了50MHz,因为这个频率在常见的波特率分频计算下误差最小。后面Linux设备树里也要保持一致。
第三,也是EMIO特有的检查:确认PL侧有没有被其他约束占用你要用的引脚。同一个引脚不能同时约束给两个端口,Vivado会在DRC阶段直接报错。我当时就是因为某个测试点残留了旧约束,导致UART引脚布不上线,找了半天才在XDC里翻到问题。
3. 裸机回环和Linux串口:两套软件栈下的EMIO UART验证
3.1 裸机工程:一个最简单的字符回环
硬件综合完成后,我选择Export Hardware并启动SDK/Vitis。新建一个Hello World模板工程时,需要指定一个BSP,BSP里会包含串口驱动的支持。
如果你用的是Hello World模板,默认会输出到STDOUT配置的串口。因为ZYNQ平台默认的stdout通常是UART0,而我们把UART1引出去了,所以第一步就是把BSP设置里的stdout和stdin改成uart1。在SDK 2017.4之后的版本,可以在platform.spr文件里的Board Support Package Settings里,找到stdout和stdin下拉框,选择uart1。
当然也可以不依赖stdio,直接在代码里用UART驱动函数发送。我写了一个简单的回环测试代码,让开发板收到什么字符就原样发回去:
#include "xparameters.h" #include "xuartps.h" #include "xil_printf.h" static XUartPs UartInstance; int main(void) { XUartPs_Config *Config; u8 RecvBuf; int Status; Config = XUartPs_LookupConfig(XPAR_XUARTPS_1_DEVICE_ID); Status = XUartPs_CfgInitialize(&UartInstance, Config, Config->BaseAddress); if (Status != XST_SUCCESS) { xil_printf("UART init failed!\r\n"); return -1; } XUartPs_SetBaudRate(&UartInstance, 115200); XUartPs_SetOperMode(&UartInstance, XUARTPS_OPER_MODE_NORMAL); xil_printf("EMIO UART1 Loopback Test\r\n"); while (1) { if (XUartPs_Recv(&UartInstance, &RecvBuf, 1) == 1) { XUartPs_Send(&UartInstance, &RecvBuf, 1); } } }这段代码的思路很简单,先找到UART1的配置信息并初始化,然后设置波特率,最后在循环里收一个字节、发一个字节。如果你在串口助手里发送A,能收到A,那么PS端的UART控制器和EMIO链路基本没问题。
这一步能验证的核心点有两个:一是EMIO信号确实把UART数据通路引到了PL引脚,二是引脚约束和电平标准没有问题。如果回环失败,问题大概率出在硬件连接或XDC上,而不是软件。
3.2 Linux下的设备树配置:让EMIO UART变成标准tty
裸机跑通之后,我继续在Linux下验证。如果你使用的是PetaLinux,需要重新生成设备树和内核镜像;如果手动编译,则需要确认设备树中UART1节点的状态。
ZYNQ Linux的设备树中,UART1节点通常位于axi节点下,地址是e0001000,兼容字符串在新内核里一般是"cdns,uart-r1p8"或者"xlnx,xuartps"。一个典型的节点类似这样:
uart1: serial@e0001000 { compatible = "cdns,uart-r1p8"; reg = <0xe0001000 0x1000>; interrupts = <0 50 4>; clocks = <&clkc 23>, <&clkc 39>; clock-names = "uart_clk", "pclk"; status = "okay"; };对于EMIO方式,UART1的引脚不在MIO上,所以设备树里不需要声明pinctrl。这一点和直接用MIO时的配置有区别。有些参考设计在MIO引脚上挂了pinctrl-0节点,用于引脚功能选择;而EMIO方式下,引脚功能已经在PL里通过XDC确定了,PS软件侧看不到也不需要管MIO pinmux。
设备树里另一个关键字段是clocks。uart_clk对应的是PS给UART外设的时钟,如果你的Vivado工程里设置的是50MHz,设备树里clkc 23也应该是50MHz。如果设备树里的时钟频率和实际配置不一致,Linux内核里的serial驱动会按错误的时钟源计算波特率,表现出来就是串口乱码。
修改完设备树后,编译生成对应的dtb,替换SD卡里的设备树文件。如果你用PetaLinux,可以直接用petalinux-build -c device-tree等命令重新生成。如果只是做实验,也可以把DTS编译成DTB后单独替换启动分区里的文件。ZYNQ启动时还会用到BOOT.BIN、boot.scr、image.ub这些文件,这里不再展开,但设备树是否正确,会直接影响到系统启动后是否出现ttyPS0或ttyS0这个节点。
3.3 通过串口工具实测通信
Linux启动后,我用命令确认UART1被识别成哪个设备节点:
dmesg | grep tty如果看到类似cdns-uart e0001000.serial: ttyPS0 at MMIO 0xe0001000,说明设备树和驱动都正常。在老版本驱动下可能显示ttyS0,名字不同功能一样。
然后用stty设置波特率并读取数据:
stty -F /dev/ttyPS0 115200 raw cat /dev/ttyPS0在另一个终端向串口发送数据:
echo "EMIO UART test" > /dev/ttyPS0如果你想做一个快速的回环测试,可以在设备侧用硬件短接tx和rx,然后这样操作:
stty -F /dev/ttyPS0 115200 raw -echo echo "loopback" > /dev/ttyPS0 head -n1 /dev/ttyPS0这里需要提醒的是,不同USB转串口模块的行为不太一样,有的模块有内部默认回环,有的没有。如果你发现发送之后立即收到数据,先确认是不是硬件上tx和rx被短接了,不要急着判定是软件问题。
4. 调试EMIO UART时最容易翻车的四个细节:接反、约束、波特率与复位
4.1 TX和RX接反:最经典的串口故障
所有串口调试里概率最高的坑,就是TX和RX方向接反。如果你用USB转串口模块连开发板,模块的TX应该接开发板的RX,模块的RX应该接开发板的TX。这是交叉连接,不是直连。很多新手按“同名相连”的直觉把TX接TX、RX接RX,结果什么都收不到。
判断方法其实很简单:用万用表量电平。串口空闲状态下,发送引脚应该是高电平(大约3.3V或更高)。如果一根排针是0V、另一根是3.3V,一般高电平的是TX,但也不能完全依赖这个规律,因为如果板卡上的UART控制器被配置成高位驱动拉低,可能不是这个状态。更可靠的办法是查原理图,确认板上丝印到底是以板卡为主还是以模块为主。
如果回环测试失败,先不要看代码,直接用杜邦线短接板卡上的tx和rx引脚。短接后自发自收如果通了,说明发送和接收路径都正常,问题一定出在外部连接上。如果短接后还是不通,那就要往XDC约束和硬件设计方向查了。
4.2 引脚约束明明写了却布不上线
Vivado编译的时候,如果PL引脚没有约束,通常会报错说IO placement失败。但有一种情况很隐蔽:约束文件里写的端口名和顶层端口名不一致,Vivado会忽略这条约束,然后又因为端口没有位置约束,综合时默认把它放在不可用的虚拟引脚上,导致实现阶段报错。
排查时,我会在综合后打开Report I/O,确认目标端口是不是已经被分配到了想要的bank和引脚位置。如果发现端口没出现或者约束无效,优先检查端口名大小写和get_ports的引号。
另一个常见问题是PI引脚被其他用途占用了。ZYNQ的PL引脚中,一部分是配置专用引脚,比如CCLK、DONE、PROGRAM_B等,这些引脚不能当普通IO。如果你在XDC里约束到某个专用引脚,DRC会直接报冲突。还有的板卡把某些PL引脚接到了LED、按键、DDR、PCIe等固定位置,你在XDC里强行复用,轻则影响原有功能,重则短路。实验前最好对照板卡原理图选一组空闲排针,避免踩雷。
4.3 波特率漂移导致乱码
EMIO方式本身不会引起波特率漂移,真正影响波特率的是UART的时钟源。ZYNQ的UART控制器支持广泛的时钟输入,但具体分频精度取决于实际时钟频率。如果Vivado里配置的参考时钟和设备树里上报的时钟频率不一致,Linux驱动计算分频系数时就会算错,表现出来就是能收到字节但全是乱码。
ZYNQ UART波特率计算可以简化成:
baud = clock / (bdiv * (oversample + 1))其中oversample通常取6,bdiv是可配置的分频值。如果时钟频率是50MHz,115200波特率对应的bdiv大约是49.6,取整后会有少量误差。这个误差在容忍范围内,但如果你用的时钟是全桥总线频率(比如100MHz),分频系数会更大,误差可能影响通信。
解决乱码问题,我一般先降到9600波特率试试。如果9600正常而115200乱码,基本可以断定是波特率分频精度问题,而不是外部时序问题。这时候去检查Vivado里的UART参考时钟和设备树里的clock-frequency是否一致。
4.4 上电后第一帧丢字:复位与状态机问题
有一种现象很难排查:程序跑起来后前面几个字符丢失,后面数据都正常。这个问题在裸机环境中经常被忽略,因为xil_printf会等待FIFO为空才返回,如果复位后PS UART状态机还没有完全准备好,第一次写入可能被吞掉。
我的解决办法是在主程序初始化之后加一个短暂延时,例如把软件延时函数调用几百毫秒,或者循环等待UART控制器的TX FIFO清零。在Linux下,这个问题通常被内核串口框架消化掉了,因为内核会做延时和总线访问约定。但如果你的PL引脚旁边有严重的信号完整性问题,比如线太长、走线阻抗不匹配,也可能出现偶尔第一个字符丢失。这种情况优先改善外部连线质量,比如缩短杜邦线长度、使用带屏蔽的串口线。
如果丢失的是Linux下earlycon的前几个字符,那可能和earlycon使用UART0还是UART1有关,要在内核启动参数里指定串口地址和设备树匹配。这种问题比较深,不展开太多,但至少要知道它存在。
5. 从EMIO UART实验延伸出去:把它变成日常调试工具的小建议
5.1 我建议你在开发板上预置一个EMIO UART
经过这次实验,我后来在所有项目里都养成了一个习惯:只要FPGA引脚资源有富余,就固定预留一组PL引脚作为EMIO调试串口,而不是依赖MIO上的默认UART。
原因是ZYNQ的MIO在外设被移作他用时,重新分配非常痛苦。比如你已经把MIO的某组引脚给了SD卡,想抢回来给UART,就要改硬件、改设备树、改启动配置,牵一发动全身。而EMIO调试串口放在PL侧,不仅引脚位置可以任意调整,而且即使PS端MIO资源再紧张,只要FCLK和UART控制器还在,调试通道就不受影响。哪怕遇到没网络、没JTAG、只剩一个串口的环境,也能借此看到Linux启动信息,定位问题。
5.2 如果要多路UART,EMIO能做什么
PS端只有两个UART控制器,所以哪怕EMIO能引出很多引脚,也不能凭空变出更多串口。不过,EMIO可以让这两个UART控制器的物理引脚分别落在PL的不同位置,比如一路放在扩展排针,一路接到PL内部状态机做调试监控。如果你真的需要三四路UART,通常做法是在PL里例化多个UART IP核,或者使用单路UART转多路UART的可编程逻辑方案。EMIO此时更多是作为PS与PL之间数据桥接的通道,而不是简单的引脚复用。
如果你刚做完本实验,下一步可以试着把EMIO UART的信号接进ILA(集成逻辑分析仪),通过JTAG实时抓串口波形。这比拿示波器对排针方便很多,而且能直接看到PS发出的字节是否完整到达PL引脚。只要在XDC里把uart1_tx信号同时约束到一个探针引脚上,或者直接在Block Design里添加ILA IP并连接uart1_tx接口即可。这个技巧在实际调试中是加分项。
5.3 记录一份调试清果,避免重复踩坑
我把这次实验常踩的问题做成了一份简短的检查清单,后面每次做EMIO相关调试都会先过一遍:
- 检查顶层端口名和XDC里的get_ports是否完全一致。
- 检查PL bank的VCCO是否和IOSTANDARD匹配。
- 检查Block Design里UART参考时钟频率与设备树是否一致。
- 检查TX和RX是否交叉连接,外部模块的GND是否共地。
- 检查UART控制器地址是否映射正确,裸机工程中使用的是UART1的Device ID。
- 回环测试失败时先短接板卡自身TX/RX,区分是软件还是硬件问题。
这些条目每一条都是当时实际踩过的。按照这个顺序排查,大多数EMIO UART问题都能在半小时内定位,而不是耗费一整天。
最后再分享一个小技巧:调试UART时不要只依赖printf输出,试着在代码里加入一个自动回显功能。这样一来,你就能用同一个串口工具同时判断“发送链路”和“接收链路”是否都正常。配合ELOOP测试命令,很多看似玄学的乱码问题,其实都会快速收敛到某一个具体的硬件节点上。