FPGA串口通信IP核wbuart32集成指南:从Wishbone总线到驱动开发
1. 项目概述:一个轻量级的串口通信IP核
最近在搞一个FPGA上的嵌入式小系统,需要和上位机进行简单的数据交互。像UART这种串口通信,可以说是嵌入式开发里最基础、最常用的外设之一了。虽然很多商用或开源的SoC平台都集成了UART控制器,但当你需要在一个资源极其有限、或者架构非常定制化的FPGA项目里,自己动手“攒”一个系统时,一个足够精简、可靠且易于集成的UART IP核就显得尤为重要了。
我这次用到的就是ZipCPU项目下的wbuart32。简单来说,它是一个遵循Wishbone总线协议的32位UART(通用异步收发传输器)控制器IP核。ZipCPU本身是一个开源的、小体积的RISC-V软核处理器,而wbuart32就是为其生态系统配套的串口解决方案。它的最大特点就是“小而美”:代码量小,逻辑资源占用极低,但功能完整,包含了发送(TX)和接收(RX)功能,支持可编程的波特率,并且通过Wishbone总线提供了简洁的寄存器接口供CPU访问。
对于FPGA开发者,尤其是那些在玩Lattice iCE40、ECP5或者Xilinx Spartan-6这类资源比较紧张的入门级FPGA板卡的朋友来说,wbuart32是一个非常理想的选择。它让你无需依赖庞大的商用IP库,就能快速为你的自定义CPU或逻辑系统添加一个可靠的调试串口或数据通道。接下来,我就结合自己的实际集成和调试过程,把这个IP核的核心机制、集成方法、驱动编写以及那些容易踩坑的地方,给大家掰开揉碎了讲清楚。
2. 核心设计思路与接口解析
2.1 Wishbone总线接口:简洁的片上通信标准
wbuart32采用Wishbone总线作为其与主控制器(通常是CPU)通信的接口,这是理解其如何工作的第一步。Wishbone是一种非常轻量级、开放的片上总线规范,在开源硬件和FPGA领域应用广泛。它的接口信号比ARM的AMBA AHB/AXI要简单得多,特别适合资源受限的设计。
对于wbuart32,我们主要关心其作为“从设备”(Slave)的接口。关键信号包括:
i_wb_cyc和i_wb_stb:周期和选通信号,当主设备要发起一次总线操作时,必须同时置高这两个信号。i_wb_we:写使能信号,高电平表示写操作,低电平表示读操作。i_wb_addr:地址线。wbuart32的地址空间非常小,通常只用最低的1到2位来寻址其内部为数不多的几个寄存器。i_wb_data和o_wb_data:32位的数据输入和输出总线。o_wb_ack:应答信号。当从设备完成一次读写操作后,会拉高此信号通知主设备。这是Wishbone总线完成握手的标志。
这种同步、握手的机制虽然比内存映射的简单读写稍微复杂一点,但保证了通信的可靠性。在集成时,你需要确保你的CPU或总线主控能正确产生这些信号序列。一个常见的简化操作是,在逻辑上将i_wb_cyc和i_wb_stb连接在一起,这样只要主设备发起请求,就认为周期开始。
2.2 UART核心功能逻辑:并串转换与波特率生成
抛开总线接口,wbuart32的核心就是一个标准的UART功能模块。它主要完成两件事:
- 发送(TX):当CPU通过总线写入要发送的数据到TX寄存器后,IP核会启动发送过程。它会按照配置的波特率,以一个固定的时钟分频,将并行数据(通常是8位)加上起始位、可选的校验位和停止位,转换成一位位的串行数据流,从
o_uart_tx引脚输出。 - 接收(RX):
i_uart_rx引脚上的串行数据流被持续监测。当检测到起始位(从高电平跳变到低电平)后,接收逻辑会以波特率时钟对数据位进行采样,最终拼装成并行数据,存入RX寄存器,并置位状态标志,等待CPU读取。
这里最关键的是波特率发生器。wbuart32需要一个较高频率的系统时钟(例如clk为 100MHz)。波特率(如 115200)是通过对这个系统时钟进行分频得到的。分频系数存储在波特率分频寄存器中。计算公式通常是:分频系数 = 系统时钟频率 / (波特率 * 采样因子)。采样因子通常是16(即每个比特位时间内采样16次以提高抗干扰能力),但wbuart32的具体实现可能需要查阅其代码或文档来确定。例如,100MHz时钟,目标波特率115200,若采样因子为16,则理论分频系数约为 100e6 / (115200 * 16) ≈ 54.25,取整后写入寄存器。
注意:波特率误差是串口通信稳定的关键。分频系数必须是整数,因此实际生成的波特率与目标值存在误差。通常要求误差小于2%(最好小于1%)。上述计算中,取整54,实际波特率为 100e6 / (54 * 16) ≈ 115740,误差约0.47%,在允许范围内。你需要根据你的系统时钟频率,仔细计算并测试这个值。
2.3 寄存器映射:CPU与UART的对话窗口
CPU通过读写几个简单的寄存器来控制UART和交换数据。wbuart32的寄存器映射通常如下(具体偏移地址需以实际代码为准):
| 地址偏移 | 寄存器名称 | 读写 | 功能描述 |
|---|---|---|---|
| 0x00 | 数据寄存器 (UART_DATA) | 读写 | 写操作:写入要发送的数据(通常低8位有效)。读操作:读取接收到的数据。 |
| 0x04 | 状态/控制寄存器 (UART_STAT) | 读写 | 读操作:获取状态位,如接收数据就绪(RX_READY)、发送缓冲区空(TX_EMPTY)、是否出错等。写操作:可能用于控制中断使能等(取决于IP核版本)。 |
| 0x08 | 波特率分频寄存器 (UART_BAUD) | 写 | 写入波特率时钟分频系数。通常在初始化时设置一次。 |
| 0x0C | 控制寄存器 (UART_CTRL) | 写 | 可能用于软件复位、设置数据位/停止位/校验位等(功能因版本而异)。 |
这是最精简的配置。有些UART IP会将这些功能合并到更少的寄存器中。例如,状态寄存器读出的某些位,在写入时可能对应中断使能控制。因此,在集成前,务必仔细阅读wbuart32.v源文件顶部的注释或相关的文档,这是避免后续驱动编写错误的最重要一步。
3. 集成到FPGA项目:从代码到引脚
3.1 源代码分析与模块例化
wbuart32的核心就是一个Verilog文件(例如wbuart32.v)。第一步是将其添加到你的FPGA项目文件中。接着,在你的顶层设计文件(比如top.v)中,你需要实例化这个UART模块。
一个典型的例化模板如下:
wbuart32 #( // 这里可以传递参数,例如调整FIFO深度(如果支持) // .TX_ADDR_WIDTH(4), // 发送FIFO地址宽度,深度=2**4=16 // .RX_ADDR_WIDTH(4) // 接收FIFO地址宽度 ) u_uart ( // 时钟与复位 .i_clk (sys_clk), // 系统主时钟,如100MHz .i_reset (sys_reset), // 高电平有效的同步复位 // Wishbone从设备接口 .i_wb_cyc (wb_uart_cyc), // Wishbone周期信号 .i_wb_stb (wb_uart_stb), // Wishbone选通信号 .i_wb_we (wb_uart_we), // 写使能 .i_wb_addr (wb_uart_addr[3:0]), // 地址线,低位 .i_wb_data (wb_uart_wdata), // 写入数据 .o_wb_data (wb_uart_rdata), // 读出数据 .o_wb_ack (wb_uart_ack), // 操作应答 // UART物理接口 .i_uart_rx (fpga_rx_pin), // 连接FPGA的RX输入引脚 .o_uart_tx (fpga_tx_pin), // 连接FPGA的TX输出引脚 // 中断输出(如果支持) .o_int (uart_interrupt) // 可选,当接收数据或发送完成时产生中断 );关键连线说明:
- 时钟与复位:
i_clk必须连接到一个稳定的系统时钟。i_reset的连接需要谨慎,确保上电或需要复位UART模块时,能有一个足够宽的高电平脉冲。 - Wishbone接口:这些信号需要连接到你的“总线仲裁器”或“总线主设备”(如ZipCPU)。你需要根据你的系统地址映射,为UART分配一个基地址(例如
0x8000_0000)。当CPU访问这个地址空间时,总线仲裁器应产生对应的wb_uart_cyc和wb_uart_stb信号,并将地址偏移部分传递给i_wb_addr。 - UART物理引脚:这是最容易出错的地方。
i_uart_rx应连接到FPGA上你计划用作接收的引脚,这个引脚将从外部设备(如USB转串口模块)接收数据。o_uart_tx应连接到FPGA上你计划用作发送的引脚,这个引脚将向外部设备发送数据。务必在约束文件(XDC/UCF等)中为这两个引脚指定正确的管脚编号和I/O标准(如LVCMOS33)。
3.2 约束文件配置与硬件连接
约束文件是告诉FPGA工具你的逻辑信号对应到实际芯片哪个引脚的关键。对于UART引脚,约束通常包括位置(LOC)和I/O电平标准(IOSTANDARD)。
例如,在Xilinx的XDC文件中:
# 假设sys_clk 接在E3引脚,3.3V电平 set_property PACKAGE_PIN E3 [get_ports sys_clk] set_property IOSTANDARD LVCMOS33 [get_ports sys_clk] # UART TX 引脚,连接到USB转串口模块的RX set_property PACKAGE_PIN A10 [get_ports fpga_tx_pin] set_property IOSTANDARD LVCMOS33 [get_ports fpga_tx_pin] # UART RX 引脚,连接到USB转串口模块的TX set_property PACKAGE_PIN A9 [get_ports fpga_rx_pin] set_property IOSTANDARD LVCMOS33 [get_ports fpga_rx_pin] # 复位按钮引脚 set_property PACKAGE_PIN C9 [get_ports sys_reset] set_property IOSTANDARD LVCMOS33 [get_ports sys_reset] set_property PULLUP true [get_ports sys_reset] # 建议内部上拉,防止悬空硬件连接的一个大坑:电平与交叉。
- 电平匹配:确保FPGA的I/O Bank电压(如LVCMOS33的3.3V)与你的USB转串口模块的电平兼容。大部分USB转TTL串口模块都是3.3V电平,可以直接连接。如果是RS232电平(±12V),则必须经过MAX3232之类的电平转换芯片,绝对不能直连,否则会烧坏FPGA!
- 交叉连接:记住一个原则:发送端(TX)连接接收端(RX)。FPGA的
fpga_tx_pin(TX) 应连接到USB转串口模块的RX引脚。FPGA的fpga_rx_pin(RX) 应连接到USB转串口模块的TX引脚。这是最常接反的地方,接反了会导致通信完全失败。
3.3 系统地址映射与总线互联
为了让CPU能访问到UART,你需要在系统中为其分配一个地址窗口。例如,你有一个32位地址空间的ZipCPU系统,可以将UART映射到0x8000_0000。
你的总线互联逻辑(可能是一个简单的地址译码器)需要监听CPU发出的地址。当地址落在0x8000_0000到0x8000_00FF(假设分配256字节空间)这个范围内时,就置起UART的wb_uart_cyc和wb_uart_stb信号,并将地址的低位(如addr[7:0])传递给i_wb_addr。
同时,这个互联逻辑还需要将UART返回的o_wb_data和o_wb_ack信号,在对应的事务中传递回CPU。如果系统中有多个从设备(如UART、定时器、GPIO),还需要一个仲裁逻辑来管理多个主设备(如果有多核)或同一主设备对多从设备的访问。
4. 软件驱动开发与数据收发
4.1 寄存器定义与基础读写函数
硬件集成好后,下一步就是让CPU(软件)能够驱动它。我们首先需要根据硬件地址映射和寄存器定义,在C语言头文件中定义好寄存器指针。
// uart.h #define UART_BASE ((volatile uint32_t *)0x80000000) // 假设寄存器偏移定义(需根据 wbuart32.v 实际定义调整) #define UART_REG_DATA (0x00 / 4) // 除以4是因为32位寻址,字节地址转字地址索引 #define UART_REG_STAT (0x04 / 4) #define UART_REG_BAUD (0x08 / 4) #define UART_REG_CTRL (0x0C / 4) // 状态寄存器位定义(示例,必须核对源码!) #define UART_STAT_TX_READY (1 << 0) // 发送缓冲区空,可写入新数据 #define UART_STAT_RX_READY (1 << 1) // 接收数据就绪,可读取 #define UART_STAT_TX_BUSY (1 << 2) // 发送器正忙 #define UART_STAT_RX_ERR (1 << 3) // 接收错误(如帧错误、溢出) // 基础读写函数(内联以提高效率) static inline uint32_t uart_reg_read(int reg_offset) { return UART_BASE[reg_offset]; } static inline void uart_reg_write(int reg_offset, uint32_t value) { UART_BASE[reg_offset] = value; }4.2 初始化流程:波特率设置与模块复位
在系统启动早期,需要对UART进行初始化。主要步骤包括:
- (可选)软件复位:如果控制寄存器有复位位,先将其置位再清除,以确保UART内部状态机处于已知的初始状态。
- 配置波特率:根据系统时钟频率和 desired 波特率,计算分频系数并写入波特率寄存器。这是最关键的一步,计算错误会导致通信乱码。
- (可选)配置数据格式:如果IP核支持,设置数据位(通常8位)、停止位(通常1位)、奇偶校验位(通常无校验)。
wbuart32可能固定为8N1格式,具体需查证。 - (可选)使能中断:如果使用中断模式,在控制寄存器中使能接收中断或发送完成中断。
一个简单的初始化函数示例如下:
void uart_init(uint32_t sys_clk_freq, uint32_t baud_rate) { // 1. 可选:软件复位 // uart_reg_write(UART_REG_CTRL, UART_CTRL_RESET); // delay_us(10); // 短暂延时 // uart_reg_write(UART_REG_CTRL, 0); // 2. 计算并设置波特率 // 假设 wbuart32 使用 oversampling = 16 uint32_t divisor = sys_clk_freq / (baud_rate * 16); // 需要检查 divisor 是否在有效范围内,例如 > 0 if (divisor == 0) divisor = 1; uart_reg_write(UART_REG_BAUD, divisor); // 3. 可选:配置数据格式(如果支持) // uint32_t ctrl_val = UART_CTRL_8BIT | UART_CTRL_1STOP; // uart_reg_write(UART_REG_CTRL, ctrl_val); // 初始化后可以尝试清空可能的残留数据 // while (uart_reg_read(UART_REG_STAT) & UART_STAT_RX_READY) { // (void)uart_reg_read(UART_REG_DATA); // 读取并丢弃 // } }4.3 轮询模式下的字符收发实现
对于简单的应用,轮询(Polling)模式是最直接的。原理就是不断查询状态寄存器,根据标志位来决定是发送数据还是读取数据。
发送一个字符(阻塞式):
void uart_putc(char c) { // 等待发送缓冲区为空(即上一字节已发送完毕,可以写入新数据) // 注意:这里查询的是“可写”状态,可能是 TX_EMPTY 或 !TX_BUSY while (!(uart_reg_read(UART_REG_STAT) & UART_STAT_TX_READY)) { // 空循环,等待。在实际操作系统中,这里可以出让CPU。 } // 将字符写入数据寄存器,触发发送 uart_reg_write(UART_REG_DATA, (uint32_t)c); }接收一个字符(阻塞式):
char uart_getc(void) { // 等待接收数据就绪 while (!(uart_reg_read(UART_REG_STAT) & UART_STAT_RX_READY)) { // 空循环,等待 } // 从数据寄存器读取接收到的字符(通常取低8位) return (char)(uart_reg_read(UART_REG_DATA) & 0xFF); }实现printf支持:有了uart_putc,你就可以实现一个简单的_putchar函数,然后重定向标准库的printf输出到串口。这是嵌入式调试的利器。
int _putchar(char c) { if (c == '\n') { uart_putc('\r'); // 换行时先发送回车(取决于终端需求) } uart_putc(c); return c; } // 在类似Newlib的库中,你可以将 `_write` 系统调用指向这个函数。4.4 中断驱动与缓冲区管理
轮询模式会占用大量CPU时间。在复杂的系统中,更高效的方式是使用中断。wbuart32的o_int引脚在特定条件(如接收FIFO非空、发送FIFO空)下会拉高,可以连接到CPU的中断控制器。
中断服务程序(ISR)设计要点:
- 中断使能:在UART控制寄存器中使能接收中断(可能还有发送完成中断)。
- ISR入口:在CPU的中断向量表中,注册UART的中断服务函数。
- 中断处理:在ISR中,首先读取状态寄存器判断中断源(是接收中断还是发送中断)。如果是接收中断,则循环读取数据寄存器,直到接收FIFO为空,将读出的数据存入一个软件环形缓冲区(RX Buffer)。如果是发送中断,则从发送环形缓冲区(TX Buffer)中取出下一个字符写入数据寄存器;如果TX缓冲区已空,则关闭发送中断使能。
- 缓冲区操作:主程序通过如
uart_write_buf()这样的函数向TX缓冲区写入数据,并检查是否需要打开发送中断。通过uart_read_buf()从RX缓冲区读取数据。
这种方式实现了异步、非阻塞的串口通信,CPU只在有数据需要处理时才被中断唤醒,大大提高了系统效率。对于wbuart32,你需要确认其FIFO深度(如果有的话),以合理设置软件缓冲区大小。如果IP核本身FIFO很浅(甚至没有),那么中断频率会很高,此时软件缓冲区的设计就更为关键。
5. 调试技巧与常见问题排查
5.1 硬件链路检查与信号抓取
当通信完全不工作,或者出现大量乱码时,首先应该进行硬件层面的排查。
连接与电平确认:
- 用万用表测量USB转串口模块的TX/RX引脚电压。无数据时,TX和RX引脚都应为高电平(3.3V左右)。发送数据时,TX引脚会有电压变化。
- 再次确认交叉连接:FPGA_TX -> 模块_RX, FPGA_RX -> 模块_TX。这是我犯过不止一次的错误。
- 确认地线(GND)已可靠连接在两个板子之间。
使用逻辑分析仪:这是调试数字通信的终极利器。将逻辑分析仪的探头连接到FPGA的TX和RX引脚。
- 抓取TX信号:让FPGA程序循环发送一个固定的字节(如
0x55,二进制01010101)。在逻辑分析仪上设置正确的采样率和协议(异步串行,8N1,波特率115200)。你应该能看到清晰的、周期性的波形。测量比特宽度,计算实际波特率是否与设定值相符。检查起始位、数据位、停止位是否完整。 - 抓取RX信号:从PC端串口工具发送数据,抓取FPGA_RX引脚上的信号。检查FPGA是否收到了正确的波形。这可以排除是发送问题还是接收问题。
- 抓取TX信号:让FPGA程序循环发送一个固定的字节(如
5.2 软件初始化与配置验证
如果硬件链路是通的,问题可能出在软件配置。
- 波特率计算验证:这是乱码的罪魁祸首。仔细核对你的系统时钟频率
sys_clk。这个频率是你在约束文件中指定的,还是由PLL生成的?用逻辑分析仪测量一下实际送到wbuart32模块i_clk引脚的频率。然后重新计算分频系数。可以尝试在代码中打印(如果已有其他输出方式)或通过LED闪烁来输出计算出的分频值,看是否符合预期。 - 寄存器访问测试:编写一个简单的内存读写测试程序。向UART的某个寄存器(如波特率寄存器)写入一个特定的值(如
0x12345678),然后再读回来,比较是否一致。如果不一致,说明Wishbone总线连接、地址映射或时序可能有问题。确保CPU的访问位宽(32位)与IP核匹配。 - 状态寄存器轮询:在初始化后,循环读取并打印(通过其他方式,如LED编码显示)状态寄存器的值。即使不发送数据,
TX_READY位通常也应该为1(表示发送缓冲区空)。当你用USB转串口工具向FPGA发送字符时,观察RX_READY位是否会跳变为1。这是一个非常重要的诊断手段。
5.3 典型故障现象与解决方案速查表
| 故障现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 完全无通信 | 1. 物理连接错误或断开。 2. FPGA引脚约束错误。 3. UART IP核未正确复位或时钟未连接。 4. CPU根本未执行到UART初始化代码。 | 1. 检查连线,确认电平。 2. 检查约束文件LOC和IOSTANDARD。 3. 用逻辑分析仪看 i_clk和i_reset信号。复位后是否释放?4. 在代码开头用GPIO点亮一个LED,确认程序已运行。 |
| 接收/发送大量乱码 | 1.波特率不匹配(最常见)。 2. 数据格式(数据位、停止位、校验位)不匹配。 3. 系统时钟频率不准。 | 1.双端确认波特率:PC软件和FPGA程序设置必须完全相同。用逻辑分析仪测量实际比特宽度计算波特率。 2. 确认双方都是8N1格式。 wbuart32通常固定为此格式。3. 检查FPGA主时钟源和PLL配置。 |
| 只能发送不能接收(或反之) | 1. 交叉连接接反。 2. 接收/发送部分的驱动代码有bug。 3. 对应的状态位判断逻辑错误。 | 1.交换TX/RX连接线测试,这是最快的判断方法。 2. 分别测试发送函数和接收函数。发送函数能否被正确调用?接收函数是否在死循环等待? 3. 核对状态寄存器的位定义,读出的值是否与预期相符。 |
| 偶尔丢失数据 | 1. 软件轮询速度跟不上高速数据流。 2. 中断处理函数耗时太长,导致FIFO溢出。 3. 硬件FIFO深度太浅,且软件未及时读取。 | 1. 提高CPU轮询频率,或改用中断模式。 2. 优化ISR,只做最必要的操作(存数据),将处理移出ISR。 3. 如果IP核FIFO浅,考虑降低波特率或优化软件缓冲区管理。 |
| 上电后第一次通信正常,后续失败 | 1. 软件初始化序列不完整或复位逻辑有问题。 2. 中断使能/清除标志处理不当,导致中断状态锁死。 | 1. 确保每次软件复位或重新初始化时,都完整地配置所有寄存器。 2. 在ISR中,读取数据寄存器本身可能会清除接收就绪标志。检查是否需要显式清除中断标志位。 |
5.4 进阶调试:使用内嵌逻辑分析仪(ILA)
对于Xilinx Vivado或Intel Quartus用户,可以利用其内嵌的逻辑分析仪功能(如Vivado的ILA、Quartus的SignalTap)。这相当于在FPGA内部放置一个示波器,可以捕获设计运行时内部信号的波形,无需外部仪器。
你可以将wbuart32的关键信号添加到ILA观察列表中:
- Wishbone接口信号:
i_wb_cyc,i_wb_stb,i_wb_we,i_wb_addr,i_wb_data,o_wb_data,o_wb_ack。用这个来确认CPU的读写操作是否被正确执行,握手是否成功。 - UART内部关键信号:发送状态机、接收状态机、波特率计数器溢出信号等。这需要你稍微阅读一下
wbuart32.v的代码,找到关键节点。
通过触发条件设置(例如,当i_wb_stb上升沿时触发),你可以清晰地看到一次完整的寄存器写入或读取过程,以及UART内部是如何响应这些操作的。这对于排查复杂的时序问题或理解IP核行为非常有帮助。
集成wbuart32的过程,是一个典型的FPGA软硬件协同开发案例。从理解总线协议、硬件描述语言模块,到编写底层寄存器驱动,再到最后的系统调试,每一步都需要耐心和严谨。这个轻量级的IP核就像一块很好的敲门砖,吃透它,你对FPGA系统内如何组织外设、如何进行软硬件交互的理解会上一个大台阶。当你的代码第一次通过这个自己集成的小串口打印出 “Hello, World!” 时,那种成就感绝对是驱动你继续探索下去的强大动力。
