☰
GD32与FPGA高速数据交互:EXMC并行总线实战解析
2026/9/28 1:34:02 网站建设 项目流程

最近在一个项目里把 GD32F407 和 FPGA 放到了同一块板子上,两者之间要跑图像数据,量级大概是每秒几十 MB。当时评估了几种交互方案,最后选了 EXMC 接口,也就是把 FPGA 当作一颗外部 SRAM 挂在 GD32 的并行总线上。这个标题看起来简单,实际把 MCU 和 FPGA 两边都折腾了一圈,期间踩了不少坑。这篇文章就把整个实践过程拆开揉碎讲一遍,从接口选型、硬件连线、GD32 侧驱动、FPGA 侧时序逻辑,到 DMA 提速和调试实录,全部覆盖。

如果你也在做 GD32 加 FPGA 的组合,尤其是图像采集、高速数据采集、工业控制或者软件无线电这类需要大吞吐量交互的场景,这篇文章应该能帮你少走很多弯路。我会尽量用实际操作中总结出来的经验来讲,不搞教科书复读,提到的每个参数、每段代码都来自真实可运行的工程。

1. 接口选型:为什么偏偏用 EXMC 来做 MCU 与 FPGA 的桥梁

1.1 常见 MCU-FPGA 通信方案对比

GD32 和 FPGA 之间通信,方案其实不少。串口太慢,SPI 一般也就几十 Mbps,I2C 更不用提,适合传个配置信息,不适合大规模数据搬运。USB 和以太网吞吐是高,但需要协议栈、外设芯片或者额外的 PHY,对很多板卡来说成本和复杂度都上来了。

真正适合中等速率、高实时性数据交互的,其实就两类:一类是并行总线接口,比如 EXMC/FSMC,另一类是带专用 PHY 的高速串行接口,比如 RGMII、PCIe 这类。后者性能强,但不是所有 MCU 都有,而且 FPGA 侧逻辑复杂度高,调试门槛明显上一截。

我做了一张对比表,方便大家直观感受:

接口类型典型带宽实时性实现复杂度适用场景
UART1 Mbps 左右一般极低调试日志、低速控制
SPI10~50 Mbps较好低传感器采集、小包数据
I2C1~10 Mbps一般低寄存器配置、EEPROM
EXMC/FSMC50~200 MB/s高中图像帧缓存、数据采集、协处理器交互
USB 2.0 HS几十 MB/s中高上位机通信,需要协议栈
RGMII100 MB/s+高很高网络数据,需要 MAC/PHY

EXMC 的优势非常明显:吞吐能力足够,访问方式又极其简单,MCU 这边就是读写内存地址,FPGA 那边只需要实现 SRAM 时序即可。没有协议握手、没有帧格式解析,天然就是为高速数据交互准备的。

1.2 GD32 的 EXMC 模块到底提供了什么

GD32F4 系列内部的 EXMC 控制器支持多路 Bank,可以连接 NOR Flash、PSRAM、SRAM 等外部存储器。用在 FPGA 通信时,我们只需要把 FPGA 配置成“SRAM 从设备”,MCU 对它发起普通的读/写操作,FPGA 侧用逻辑模拟 SRAM 时序,就能完成双向数据交换。

这个方案最漂亮的地方在于,MCU 访问 FPGA 内部的寄存器、缓存区,就像访问自己的数组一样自然。配合 DMA,CPU 几乎不需要参与数据搬移,把大量耗时操作交给硬件完成。

理解 EXMC 的关键是把握“映射”二字。GD32 的 EXMC 把外部设备映射到一段固定的地址空间,CPU 对这段地址发起读写,总线控制器自动产生对应的片选、读使能、写使能、地址和数据信号。你写一个 volatile 指针变量,本质上就是在操作那组物理引脚的电平时序。

在实际项目中,我一般把 FPGA 内部空间分成两块:低地址段放控制寄存器,比如命令字、状态字、中断标志;高地址段放数据缓存区,比如图像帧缓存或者采集缓冲区。GD32 侧通过不同的地址偏移来区分操作对象,这比挂两个独立外设省引脚,也比用自定义协议省逻辑。

2. 硬件连接与 GD32 侧驱动初始化:一份可直接抄的寄存器配置

2.1 引脚映射与硬件设计细节

先看硬件连接。以 GD32F407 为例,EXMC 相关的引脚分布在多个 GPIO 端口上,典型连接是这样:

  • 片选信号 NE1 接到 FPGA 的 cs_n
  • 读使能 NOE 接到 FPGA 的 oe_n
  • 写使能 NWE 接到 FPGA 的 we_n
  • 地址总线 A[15:0] 接到 FPGA 的 addr[15:0]
  • 数据总线 D[15:0] 接到 FPGA 的 data[15:0]

这里有一个非常容易踩坑的细节:如果 EXMC 配置成 16 位数据宽度,MCU 内部的地址总线 HADDR 和外部引脚的地址线是错位的。内部地址的第 1 位对应外部地址线 A0,内部地址的第 2 位对应外部地址线 A1,以此类推。也就是说,你从 MCU 侧看地址偏移是 0x2、0x4、0x6,FPGA 侧看到的地址其实是 0x1、0x2、0x3。如果在 FPGA 里没有处理好这个“地址右移一位”的问题,后面读出来的数据会全部错乱。

硬件设计上还有几个要点。第一,电平标准要一致,GD32 的 GPIO 一般是 3.3V,FPGA 的 Bank 电压如果也是 3.3V,直接相连问题不大,如果 FPGA 侧 Bank 是 2.5V 或者 1.8V,就必须加电平转换芯片。第二,并行总线的数据线、地址线建议做等长处理,尤其是数据线,信号在高速翻转时走线长度差太大会导致建立时间不足。第三,FPGA 侧的三态数据总线端口需要接弱上拉或者内部上拉,防止总线空闲时浮空引入噪声。

我自己的习惯是:FPGA 的数据口在顶层模块里声明成 inout wire,内部用一个三态缓冲器控制,输出使能由“片选有效且读使能有效”的组合逻辑产生。这样总线在写周期或者空闲周期不会和 MCU 的数据输出发生冲突。

2.2 EXMC 寄存器初始化(附代码)

GD32F4 系列初始化 EXMC 的流程大概分三步:使能相关 GPIO 时钟和 EXMC 时钟,配置 GPIO 复用功能,最后配置 EXMC 控制器寄存器。

下面这段代码来自我的实际工程,芯片是 GD32F407VET6,HCLK 跑 120MHz,数据宽度 16 位,异步 SRAM 模式。

#include "gd32f4xx.h" #define EXMC_BANK0_BASE 0x60000000U #define FPGA_BASE (EXMC_BANK0_BASE + 0x00000000U) typedef struct { volatile uint16_t CTRL; /* 0x0000 控制寄存器 */ volatile uint16_t STATUS; /* 0x0002 状态寄存器 */ volatile uint16_t RESERVED[3]; volatile uint16_t BUF[256]; /* 0x0008 数据缓冲区 */ } FPGA_MapTypeDef; #define FPGA ((FPGA_MapTypeDef *)FPGA_BASE) static void gpio_config(void) { rcu_periph_clock_enable(RCU_GPIOE); rcu_periph_clock_enable(RCU_GPIOD); rcu_periph_clock_enable(RCU_GPIOA); rcu_periph_clock_enable(RCU_GPIOC); /* EXMC 数据线 D0-D15,地址线 A0-A15,控制线 NOE/NWE/NE1 等 */ gpio_af_set(GPIOD, GPIO_AF_12, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_14 | GPIO_PIN_15); gpio_af_set(GPIOE, GPIO_AF_12, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15); gpio_mode_set(GPIOD, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_14 | GPIO_PIN_15); gpio_mode_set(GPIOE, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15); } static void exmc_config(void) { rcu_periph_clock_enable(RCU_EXMC); /* 先关闭 Bank0 使能位,再配置时序 */ EXMC_BCR0 = 0x00000000U; /* * BTR0 寄存器时序配置 * ADDSET[3:0] = 0xF:地址建立时间 16 个 HCLK * ADDHLD[7:4] = 0x0:地址保持时间 1 个 HCLK * DATAST[15:8] = 0x7:数据建立时间 8 个 HCLK * BUSTURN[19:16] = 0x7:总线 turnaround 时间 8 个 HCLK */ EXMC_BTR0 = (0xFU << 0) | (0x0U << 4) | (0x7U << 8) | (0x7U << 16); /* * BCR0 寄存器配置 * MBKEN = 1:使能 Bank0 存储区域 * MTYP[1:0] = 00:SRAM 类型 * MWID[1:0] = 01:16 位数据总线 * FACCEN = 0:不复用地址/数据线 * WREN = 1:使能写操作 * EXTMOD = 0:不使用扩展模式寄存器 */ EXMC_BCR0 = (0x1U << 0) | /* MBKEN */ (0x0U << 2) | /* MTYP */ (0x1U << 4) | /* MWID 16位 */ (0x0U << 6) | /* FACCEN */ (0x1U << 12) | /* WREN */ (0x0U << 14); /* EXTMOD */ }

关于时序参数的选择,这里要展开说一下。GD32 的 EXMC 异步访问时序由 HCLK 作为基本单位。比如 ADDSET 配置为 0xF,表示地址建立阶段持续 16 个 HCLK,在 120MHz 主频下大约是 133ns。DATAST 配置为 0x7,表示读/写数据阶段持续 8 个 HCLK,约 66ns。所以单次 16 位访问的周期大约是 200ns 左右,换算下来带宽在 80MB/s 量级,完全够用。

如果 FPGA 侧逻辑处理速度不够快,可以把 DATAST 调大一点,代价是传输速率下降。如果发现 FPGA 很轻松就能响应,也可以把 ADDSET 调小到 0x7,DATAST 调到 0x4,性能会明显提升。这个参数本质上就是在“时序余量”和“吞吐性能”之间做取舍,需要根据实际硬件表现来调,不建议照搬。

2.3 软件封装与访问封装

初始化完成之后,MCU 访问 FPGA 就非常简单了。比如要写一个命令字,只需要:

FPGA->CTRL = 0x5A01; /* 启动 FPGA 数据采集 */

要读状态寄存器,只需要:

uint16_t status = FPGA->STATUS;

这里要注意,对 EXMC 地址空间的访问必须用 volatile 修饰,否则编译器优化后可能把连续两次读操作合并掉。我上面的结构体里已经把成员都声明成 volatile uint16_t,所以直接用没问题。

在实际项目中,我通常还会封装几个简单的函数,用于发送命令帧和读取数据块:

void fpga_write_reg(uint16_t offset, uint16_t value) { *(volatile uint16_t *)(FPGA_BASE + offset) = value; } uint16_t fpga_read_reg(uint16_t offset) { return *(volatile uint16_t *)(FPGA_BASE + offset); } void fpga_read_buf(uint16_t *dst, uint16_t offset, uint32_t len) { volatile uint16_t *src = (volatile uint16_t *)(FPGA_BASE + offset); for (uint32_t i = 0; i < len; i++) { dst[i] = src[i]; } }

这套代码在写驱动阶段非常直观,但在批量数据传输时 CPU 会卡在循环里。所以做到后面一定要上 DMA,后面会专门讲。

3. FPGA 侧总线从机设计:把 EXMC 时序变成可用的 RTL

3.1 EXMC 异步 SRAM 时序模型

FPGA 侧要实现一个能被 EXMC 正常读写的从设备,核心是理解异步 SRAM 的读时序和写时序。不需要特别复杂,抓住几个关键沿就行。

读周期的大致过程是:MCU 先拉低片选 cs_n,同时在地址总线上输出目标地址;地址稳定后,MCU 拉低读使能 oe_n;FPGA 看到 oe_n 有效后,把对应地址的数据放到数据总线上;MCU 在 oe_n 上升沿之前采样数据总线。整个过程中,数据总线由 FPGA 驱动。

写周期的大致过程是:MCU 拉低 cs_n,输出地址;随后数据总线上出现要写入的数据,MCU 拉低写使能 we_n;FPGA 在 we_n 上升沿采样地址和数据,锁存到内部寄存器。整个过程中,数据总线由 MCU 驱动,FPGA 必须让开数据总线,不能输出任何内容。

用一句话概括就是:读操作,FPGA 往总线上放数据;写操作,FPGA 从总线上取数据。这个方向一旦搞反,轻则数据错误,重则烧毁引脚驱动电路。

信号方向读周期角色写周期角色
cs_nMCU 拉低,选中设备MCU 拉低,选中设备
addrMCU 输出地址MCU 输出地址
oe_nMCU 拉低,FPGA 输出数据保持高电平
we_n保持高电平MCU 拉低,FPGA 锁存数据
dataFPGA 驱动数据总线MCU 驱动数据总线

3.2 顶层 RTL 实现与三态总线处理

下面给出一个精简但完整的 Verilog 顶层模块。它实现了 16 位地址、16 位数据的 EXMC 从设备,内部用寄存器数组模拟一块简单的 SRAM。前 4 个地址是控制状态寄存器,其余地址是数据缓冲区。

module exmc_slave_top #( parameter ADDR_WIDTH = 16, parameter DATA_WIDTH = 16 )( input wire rst_n, input wire clk, // EXMC 接口信号 input wire sram_cs_n, input wire sram_oe_n, input wire sram_we_n, input wire [ADDR_WIDTH-1:0] sram_addr, inout wire [DATA_WIDTH-1:0] sram_data ); // 内部寄存器数组 reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1]; // 读/写请求 wire cs_active = ~sram_cs_n; wire write_req = cs_active & ~sram_we_n; wire read_req = cs_active & ~sram_oe_n & sram_we_n; // 三态数据总线:读有效时 FPGA 驱动数据线,否则释放 reg [DATA_WIDTH-1:0] dout_r; assign sram_data = read_req ? dout_r : {DATA_WIDTH{1'bz}}; // 写时序:在 we_n 上升沿锁存数据 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin // 复位所有寄存器(实际工程中建议只复位关键寄存器,大块 RAM 用初始值) end else if (write_req) begin mem[sram_addr] <= sram_data; end end // 读时序:组合逻辑读地址,提前一拍打拍输出 reg [ADDR_WIDTH-1:0] addr_d; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin addr_d <= {ADDR_WIDTH{1'b0}}; end else if (read_req) begin addr_d <= sram_addr; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin dout_r <= {DATA_WIDTH{1'b0}}; end else if (read_req) begin dout_r <= mem[sram_addr]; end end // 控制寄存器示例:写地址 0x0000 为命令,GPIO/状态可从内部逻辑产生 endmodule

这段代码有几个细节需要特别说明。

第一,写数据是在write_req有效的时候锁存,但严格来说应该在we_n上升沿锁存。由于 EXMC 写时序中we_n低电平期间数据已经稳定,FPGA 用内部时钟采样write_req高电平也能采到正确的数据。实际工程中,如果主频足够高,这种采样方式误差很小。如果时序比较紧张,建议把sram_we_n打两拍,用上升沿检测来锁存,可靠性更好。

第二,read_req是组合逻辑,所以数据总线在片选和读使能同时有效时立刻输出数据。为了满足 MCU 的建立时间要求,通常需要让输出路径尽可能短。如果发现读数据不稳定,可以在sram_data输出路径上插入一级底层的 OBUFT 原语,或者把dout_r寄存器放到 IOB 中,缩短片内走线延迟。

第三,这个模块里的clk不是 EXMC 接口给的外部时钟,而是 FPGA 内部的主时钟。EXMC 的异步信号进入 FPGA 内部时钟域后,存在跨时钟域问题,下一节单独讲。

3.3 跨时钟域与复位亚稳态:总线接口稳定性的关键

EXMC 总线信号与 FPGA 内部逻辑通常不是同一个时钟域,甚至不是同一个频率。如果不做处理,直接把sram_cs_n、sram_we_n当成内部信号使用,很容易出现亚稳态,导致 FPGA 状态机偶尔挂死或者数据偶尔错位。

最常见的做法是:对片选、读使能、写使能三个信号各做两级同步。两级触发器可以把亚稳态出现的概率降到非常低,虽然会引入 2 拍左右的延迟,但 EXMC 的总线周期本来就有几十纳秒,这几十纳秒的延迟完全在容忍范围内。

reg [1:0] cs_sync, oe_sync, we_sync; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cs_sync <= 2'b11; oe_sync <= 2'b11; we_sync <= 2'b11; end else begin cs_sync <= {cs_sync[0], sram_cs_n}; oe_sync <= {oe_sync[0], sram_oe_n}; we_sync <= {we_sync[0], sram_we_n}; end end wire cs_active_sync = ~cs_sync[1]; wire read_req_sync = cs_active_sync & ~oe_sync[1] & we_sync[1]; wire write_req_sync = cs_active_sync & ~we_sync[1];

这里的复位信号也值得多说一句。很多初学者直接写always @(posedge clk or negedge rst_n),理论上这是异步复位,复位信号本身如果释放时间和时钟上升沿靠得太近,同样可能造成亚稳态。规范的做法是“异步复位、同步释放”,即复位信号先经过两级同步器,再用同步后的复位信号去复位内部逻辑。实际调试中我遇到过 FPGA 复位释放毛刺导致状态机跑飞的情况,就是靠这个办法解决的。

另外,GD32 侧访问 FPGA 的时候,如果 FPGA 内部状态机还在忙,MCU 直接读数据可能读到中间状态。这时候一个简单的握手机制能救大命。我在 FPGA 里定义了一个状态寄存器,比如地址 0x0002,bit0 为“busy”标志。MCU 写启动命令后,轮询这个 busy 位,等它拉低之后再读数据。虽然多花一点时间,但能把双方节奏完全对齐。

4. 从快变到更快:DMA 搬运与批量传输优化

4.1 为什么 CPU 搬运不够快

驱动写好之后,功能肯定是通的,但直接拿 CPU 循环读数据,性能非常难看。原因有两个:一是循环里每次访存都要经过 EXMC 控制器的建立、保持、总线 turnaround 等阶段,二是 CPU 的取指、缓存、写回等操作也会消耗总线周期。

以一个 800×600 灰度图像为例,单帧数据量 480KB,也就是 240K 个 16 位字。如果单次 EXMC 访问周期是 200ns,CPU 循环读一遍需要 48ms,一秒钟只能读 20 帧左右。这个速度在图像处理场景下基本上不可用。

换成 DMA 之后,数据搬运完全由 DMA 控制器完成,CPU 只需要在传输开始前配置一次,结束后响应一个中断。同样 240K 字,数据搬移时间还是那么多,但 CPU 可以同时在干别的事,比如图像后处理、协议打包、显示刷新。

4.2 GD32 DMA 配置代码与地址模式

GD32F4 的 DMA 控制器功能很丰富,支持外设到内存、内存到外设、内存到内存三种模式。对于 EXMC 读场景,就是外设到内存。外设地址设置为 EXMC Bank 的基地址,内存地址设置为一个内部 SRAM 缓冲区。需要注意,外设地址在这里必须固定不变,内存地址逐次递增。

下面这段初始化代码实现了从 FPGA 缓冲区连续读取 256 个 16 位字到内存数组的功能。

#define DMA_CH0_IRQ DMA0_Channel0_IRQn static volatile uint16_t rx_buf[256]; static volatile uint32_t rx_len = 0; void dma_exmc_read_init(void) { dma_single_data_para_init(DMA0, DMA_CH0, DMA_PERIPHERAL_TO_MEMORY, DMA_MEMORY_INCREASE_ENABLE, DMA_PERIPHERAL_WIDTH_16BIT, DMA_MEMORY_WIDTH_16BIT, DMA_PRIORITY_HIGH); /* 外设地址固定指向 FPGA 数据缓冲区 */ dma_peripheral_address_config(DMA0, DMA_CH0, (uint32_t)&FPGA->BUF[0]); /* 内存地址指向内部缓冲区 */ dma_memory_address_config(DMA0, DMA_CH0, (uint32_t)rx_buf); /* 传输数量 */ dma_transfer_number_config(DMA0, DMA_CH0, 256); /* 使能 DMA 通道 */ dma_channel_enable(DMA0, DMA_CH0); } void dma_exmc_start(void) { dma_transfer_number_config(DMA0, DMA_CH0, 256); dma_channel_enable(DMA0, DMA_CH0); } void DMA0_Channel0_IRQHandler(void) { if (dma_interrupt_flag_get(DMA0, DMA_CH0, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA0, DMA_CH0, DMA_INT_FLAG_FTF); dma_channel_disable(DMA0, DMA_CH0); rx_len = 256; /* 置事件标志,主循环处理 rx_buf */ } }

这段代码有两个关键点。第一,GD32 的 DMA 在传输完成后会自动关闭通道,所以每次启动传输都要重新设置传输数量并再次使能通道。第二,外设地址固定这一点非常重要,如果误配成递增,DMA 会从 FPGA 地址一路读过去,读到最后直接越过 EXMC 地址空间触发 hardfault。

用 DMA 之后,MCU 搬运 480KB 图像数据只需要在中断里处理一次,CPU 占用率可以忽略不计。

4.3 突发模式和长帧传输实测

EXMC 除了异步访问,还支持同步突发访问模式。同步模式下,MCU 会输出一个时钟信号给外部设备,双方按时钟沿传输数据,每一个时钟周期可以传一个字,连续突发模式下几乎没有间隔。这个模式对 FPGA 侧逻辑要求更高,因为 FPGA 必须跟着 EXMC 的时钟做同步时序,不能再用简单异步逻辑。

实测下来,我那个工程用异步模式、16 位宽度、HCLK 120MHz 配置下,DMA 连续读 240K 字的耗时约 24ms,折合速率约 20MB/s。如果把 ADDSET 和 DATAST 调小到 FPGA 能接受的最小时序,可以把单次访问压到 120ns 左右,速率提升到 33MB/s 左右。如果上同步突发模式,理论上可以接近 60~80MB/s,但对 FPGA 代码的要求会高一截。

模式单字访问周期480KB 耗时实现复杂度
CPU 循环 + 异步约 200ns约 48ms低
DMA + 异步(宽松时序)约 200ns约 24ms低
DMA + 异步(紧时序)约 120ns约 15ms中
DMA + 同步突发约 20ns约 5ms高

我个人的建议是:如果不是极限性能要求,先把异步模式调通跑稳,再去折腾同步突发。同步模式调试复杂度高得多,而且一旦时序约束没做对,可能出现“偶尔能跑、偶尔挂死”这种最难排查的随机故障。

5. 调试实录:信号异常、地址错位、GD32 锁死等经典问题

5.1 读回数据错乱或全 F

这个现象在第一次上板时几乎必现,我至少见过三种原因。

第一种是 FPGA 侧三态门没控制好。比如读有效时数据总线没输出,总线浮空被上下拉拉到固定电平,读回来就是 0xFFFF 或者 0x0000。排查方法很简单:在 FPGA 里给读通道写一个固定值,比如地址 0x0000 永远返回 0x5A5A。MCU 上电后先读这个地址,如果读到 0x5A5A,说明数据链路正常,问题出在数据源;如果读不到,就去查三态控制和时序。

第二种是 EXMC 访问时序太紧。FPGA 逻辑综合布线之后有延迟,MCU 给的建立时间不够,导致 FPGA 还没来得及把数据稳定放到总线上,MCU 就已经采样了。解决方法是把 DATAST 调大,观察现象是否改善。如果调大后正常,基本可以断定是建立时间不足。

第三种是地址线位宽不匹配。MCU 地址线 A[15:0] 接了 FPGA 的 addr[15:0],但 FPGA 内部只用了低 10 位,高位没处理。这时候访问 0x0000 和 0x0400 会命中同一个地址,看起来数据没问题,但做连续地址扫描时会发现数据重复。解决方法是 FPGA 侧对未使用的高位地址做掩码处理。

5.2 地址偏移与 Bank 基地址

前面提到过 16 位模式地址右移的问题,这里再详细说一下。GD32 的 EXMC 在 16 位模式下,内部地址 HADDR 和外部地址线之间有一个固定的右移关系:内部地址的第 1 位到第 n 位,对应外部地址线的第 0 位到第 n-1 位。

所以如果我定义 FPGA_BASE 是 0x60000000,访问 FPGA->CTRL 对应的地址偏移是 0x0000,FPGA 侧看到地址也是 0x0000;但访问 FPGA->BUF[1] 时,MCU 侧地址偏移是 0x000A(因为结构体里 BUF 从 0x0008 开始,第二个元素偏移 0x000A),FPGA 侧看到的地址是 0x0005。

很多初学者没注意这一点,FPGA 里把缓冲区起始地址设成了 0x0000,MCU 也以为第一个数据在 0x0000,结果可能出现首包正常、后面的数据全部错位。最好的办法是在 FPGA 里加一个“寄存器映射表”注释,把每个地址的对应关系写清楚,MCU 和 FPGA 各管各的,最后在联调时用固定数据模式验证。

5.3 工具链和下载调试的几个坎

GD32 的调试工具链和下载方式,在实际开发中也是一道坎。最常见的问题是:GD32 的 SWD 引脚被误配置成普通 GPIO 后,JLink 再也没法连接,俗称“锁死”。

这个锁死的本质,不是芯片真的烧了,而是调试端口被软件禁用或者进入了某种保护模式。常见的解锁思路有两个。一个是通过串口 ISP 模式重新烧写程序,很多 GD32 型号支持 BOOT0 拉高进入系统 bootloader,然后通过串口工具擦除原程序。另一个是用 JLink Commander 连接目标芯片,尝试发送解锁指令,具体命令和型号相关,建议直接参考原厂应用笔记。

还有一个我踩过的小坑:EXMC 初始化如果把数据线配错成普通推挽输出,并且 FPGA 侧恰好也在驱动数据线,两边会形成对拉,轻则数据错误、整机电流异常,重则烧毁引脚。所以上电后第一件事不是跑业务,而是先验证 EXMC 总线上没有异常电流。

工具链方面,现在 GD32 开发已经非常灵活了。Keil 用得最多,MDK 里添加 GD32 设备包之后可以直接编译下载。如果你更喜欢开源工具链,VSCode 配合 EIDE 插件也能搭建完整的 GD32 工程,调试用 JLink 或者 DAP 都可以。我最近几个项目已经把 FPGA 工程和 MCU 工程统一到一个 Workspace 里,VSCode 开两个窗口,左边改 Verilog,右边改 C,集成体验比 Keil 单独用顺手很多。

5.4 FPGA 布局布线对 EXMC 时序的影响

FPGA 侧的布局布线也会影响 EXMC 通信稳定性。同一段 RTL 代码,综合策略不同、布局位置不同,IO 引脚的延迟会有几十纳秒级别的差异。如果 FPGA 内部逻辑复杂,EXMC 接口信号在布局布线后路径太长,很容易让读时序失效。

我遇到过一次很典型的案例:FPGA 工程里加了一段图像处理逻辑之后,EXMC 读数据开始偶发抖动。用逻辑分析仪抓oe_n到数据输出之间的延迟,发现从oe_n拉低到 FPGA 数据总线稳定,耗时比以前多了将近 30ns,刚好把 MCU 的建立时间余量吃掉了。

解决方式有两个方向。一是在工程约束文件里给 EXMC 接口相关的引脚加时序约束,让综合工具尽量把寄存器放在靠近 IO 的位置。二是把 EXMC 从设备的逻辑和其他逻辑做物理分区,避免关键路径被无关逻辑干扰。这就是 FPGA 布局和布线之间最大的区别:布局决定模块在哪,布线决定信号怎么走,两个环节对时序收敛的影响同样关键。

6. 扩展思路:从数据交互到图像采集、协议加速

6.1 在 FPGA 里做帧缓存和行场同步

EXMC 通路稳定之后,很多应用场景可以往上叠加。我这边最常用的是图像采集:FPGA 通过 MIPI 或者 LVDS 接口从摄像头接收图像数据,经过简单的 ISP 预处理后写入片内帧缓存,GD32 通过 EXMC 把整帧数据搬走。

这个架构下,FPGA 负责的是实时性要求极高的前端任务,比如去马赛克、自动曝光统计、坏点校正,而 GD32 负责的是控制逻辑和后台处理。为了提升吞吐量,帧缓存通常会做成乒乓结构:FPGA 写 A 缓存时,GD32 读 B 缓存;下一帧交换。这样读写互不干扰,GD32 读帧的时间可以从 24ms 缩短到接近 DMA 搬运的原始时间,CPU 只需要在切换帧时发一个命令。

6.2 用寄存器组做软硬件协同控制

FPGA 内部的寄存器组是软硬件协同的关键接口。我通常把寄存器空间划分成这样:

地址偏移名称方向说明
0x0000CTRLW启动、停止、复位命令
0x0002STATUSRbusy、frame_done、error 标志
0x0004FRAME_ADDRW当前帧缓冲区地址
0x0006FRAME_LENW当前帧长度
0x0008BUF[0..255]R/W数据缓冲区

MCU 每次只需要写几个寄存器,FPGA 就能自动完成后续操作。例如写 CTRL 寄存器 bit0 为 1,FPGA 内部状态机开始缓存图像;采集完成后,STATUS 寄存器 bit1 自动拉高,MCU 轮询到该位后启动 DMA 读走数据。这套机制非常像配置一个硬件外设,逻辑清晰,调试也容易。

6.3 换用同步 NOR 模式或 PSRAM 模式

如果对吞吐量的要求进一步提升,可以仔细研究 EXMC 的同步模式。同步 NOR Flash 模式支持连续突发读,MCU 发一个起始地址,后续数据按时钟连续输出,非常适合整块数据搬运。FPGA 需要实现对应的同步时序逻辑,也就是根据 EXMC 输出的时钟信号来驱动内部读状态机。

这个方案的复杂度主要在 FPGA 侧,但只要把跨时钟域处理和时钟生成搞定,性能提升非常可观。我有一次把同步突发模式调通后,读 480KB 图像帧的 DMA 时间从 15ms 压到不到 6ms,效果立竿见影。

6.4 工程化建议:GD32 标准工程模板与版本管理

最后给准备长期维护项目的朋友一个建议:GD32 工程一定要做标准模板,FPGA 工程和 MCU 工程的接口头文件最好共享一份。比如把寄存器映射表的地址偏移宏定义放在一个fpga_reg_map.h文件里,GD32 工程直接 include,FPGA 工程里的 Verilog 代码注释引用同一份文档。这样两边同时改接口时不容易出现“MCU 改了地址,FPGA 忘了更新”的情况。

无论是用 Keil、GD32 Embedded Builder,还是 VSCode 加 EIDE 插件,模板里至少要把启动文件、系统时钟配置、串口打印、LED 指示这几个基础模块打好。以后每个新项目基于模板改,能省下大量重复造轮子的时间。


这套 EXMC 加 FPGA 的方案,我在项目里验证了很长时间,最大的体会是:并行总线调试一定不要上来就硬跑业务逻辑,先把链路打通,在 FPGA 里放一个固定 ID 寄存器,GD32 上电先读固定值确认总路线拉通,再逐步加功能。这样任何一步出了问题,都能精确缩小到某一侧,不会两边互相甩锅。

最后再分享一个小技巧:调时序的时候,把 ADDSET 和 DATAST 的参数做成宏定义,上位机或者串口命令可以直接修改值,这样不用每次都重新编译下载。实测下来,这种“在线调时序”的方式,比传统的“改代码—烧录—看波形”速度快了好几倍,尤其是在排查建立时间紧张的问题时特别有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询