ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ZC706+ADRV9009:JESD204B HDL与no-OS裸机链路

ZC706+ADRV9009:JESD204B HDL与no-OS裸机链路

简介:面向FPGA与高速数据采集开发者的ZC706+ADRV9009裸机工程包,围绕Xilinx Virtex-7评估板与ADI双通道射频收发器展开,适合希望打通HDL接口设计到no-os软件驱动链路的中高级学习者。压缩包共约2000个文件、201.1MB,以840个Verilog源文件、Tcl脚本、XDC约束、C/H代码和Makefile为主,并含XCI IP配置、SDK工程与测试平台,覆盖时钟管理、数据同步、JESD204接口、寄存器映射及DMA读写等模块。已有3844人学习,说明其在FPGA与高性能ADC集成方向具有较高参考价值。读者可据此复现从硬件描述、引脚与时序约束到裸机初始化、ADRV9009配置和数据读取的完整流程,并借助测试平台与脚本梳理调试思路,为无线通信、雷达和测试测量等低延迟原型开发提供可操作的工程参照。

1. 一块 ZC706 加一片 ADRV9009:这套 HDL+no-os 工程到底解决了什么

手上同时有 ZC706 和 ADRV9009 评估板的人,最先卡住的往往不是射频指标,而是「这条链路到底怎么搭」。这份 zc706_adrv9009 工程把答案直接摆出来:HDL 侧是能综合出 bit 的 JESD204B 收发链路加 DMA,SDK 侧是 no-os 裸机程序,上电后不依赖任何操作系统,就把 AD9528 时钟、ADRV9009 的 profile、JESD204B 链路状态、收发通道使能一路拉起来,最后从 DDR 里读到原始采样数据。它适合刚拿到板子要做原型验证的射频和 FPGA 开发,也适合手上有算法、想把裸机通路改成自己数据口的嵌入式工程师。工程不大,但把「板级连接 → 链路参数 → 裸机初始化」这条最费时间的路铺平了。

2. 工程骨架:hdl 与 no-OS 两个仓库怎么分工

2.1 从 FMC HPC 往上看的硬件分层

ZC706 是 Zynq-7045 的板子,PS 端是双核 A9 加板载 DDR3,PL 端引出一个 FMC HPC 连接器,上面挂着 4 条 GTH 收发器、一对 GBTCLK 差分参考时钟,以及大量 LA/HA 单端 IO。ADRV9009 评估板就是插在这个 FMC 口上的,供电、SPI 控制线、GPIO、device clock、SYSREF,还有 4 条 SERDES lane 全从这个连接器过去。理解这一点很关键,因为它决定了后面所有代码的分工边界。

分工其实很干脆:PS 只管慢速控制,包括通过 SPI 配 AD9528 和 ADRV9009 的寄存器、读链路状态、启动 DMA、把数据从 DDR 搬去串口或者网口;PL 只管高速数据,包括 GTH 收发、JESD204B 解帧、样本重排、DMA 搬运。这个分法不是设计者偷懒,而是因为 ADRV9009 的采样率动辄几百 MSPS,任何一帧数据走 PS 的软件中断都来不及。工程里 hdl 和 no-OS 分成两个独立仓库,正是这条边界的物理体现。

对刚上手的人来说,最容易犯的错是把这两半当成一个东西调试。实际排查时的顺序应该反过来:先确认 PL 侧的链路能起来(看 JESD204B 的 link state 寄存器),再确认 PS 侧的驱动把芯片配对了(读回 ADRV9009 的版本和通道使能寄存器),最后才看数据内容对不对。跳过前两步直接看波形,基本等于盲猜。

2.2 目录结构:两个仓库各管什么

解压后常见的目录形态大致是这样,不同版本会有出入,但分层逻辑是稳定的:

zc706_adrv9009/ ├── hdl/ # Vivado 侧,产出 bit 和 xsa │ ├── projects/adrv9009_zc706/ │ │ ├── adrv9009_zc706.v # 顶层,例化 system_wrapper 和板级 IO │ │ ├── system_bd.tcl # block design 的纯脚本描述 │ │ ├── system_constr.xdc # FMC 引脚、时钟、时序约束 │ │ └── adi_project_adrv9009_zc706.tcl# 工程入口,调 library 里的 IP │ └── library/ │ ├── axi_adrv9009/ # 收发核心,通道整理和样本对齐在这 │ ├── axi_adxcvr/ # GTH 动态重配,改线速率用得上 │ ├── axi_jesd204_tx/ axi_jesd204_rx/# JESD204B link layer │ └── xilinx/axi_dmac/ # 采集和发送用的 DMA └── no-OS/ # SDK/Vitis 侧,产出 elf ├── projects/adrv9009/src/app/ # app_config.h、时钟和 JESD 的初始化 ├── projects/adrv9009/src/hal/ # 平台抽象:SPI、GPIO、延时、串口 └── src/ # adrv9009、ad9528、axi_* 的驱动

library里那几个 IP 是这套工程真正的价值所在。axi_adrv9009负责把解帧后的数据按通道重新排列、做 16bit 对齐、给出 valid 信号;axi_jesd204_rx处理 CGS、ILA 这些链路层状态机;axi_adxcvr让你能在运行时改 GTH 的线速率而不用重新综合。二次开发时你基本不会去动这几个 IP 的内部逻辑,但一定要知道数据从哪个端口出来。

no-OS/src下的驱动是纯 C 的,没有操作系统依赖,也没有动态内存分配,所有设备结构体都是静态的。这对裸机调试特别友好:想看某个状态,直接在main里打断点,寄存器读写都是同步的,不会出现「中断里改了状态但主循环看不到」这种玄学问题。

2.3 一条接收链路的四个模块串联

把接收方向单独拎出来看,数据流是这样的:GTH 收发器把差分信号解成并行数据,交给axi_jesd204_rx做 8b/10b 解码和帧对齐,再交给axi_adrv9009_rx把交织的样本拆成 CH0、CH1 两路,最后通过 AXI4-Stream 送进axi_dmac,由 DMA 写进 PS 侧 DDR 的指定地址。发送方向完全对称,只是把 DMA 换成读、把 deframer 换成 framer。

这条链路上每一级都有自己的状态寄存器,而且都能从 PS 侧读到。GTH 那级看axi_adxcvr的 CPLL/QPLL lock 位,JESD 那级看 link state 和每条 lane 的 sync 状态,ADRV9009 core 那级看有没有溢出标志,DMA 那级看传输完成计数。调试的顺序就按这个链路一级一级往下推,哪一级断了就在哪一级停住,不要在数据内容上反复折腾。

有一点需要提前说清楚:ADRV9009 有两个接收通道、两个发射通道,外加一个观察通道(ORx)。观察通道在内部环回校准时会用到,工程里默认也会给它分配 lane。如果你只用一路接收,可以把不用的通道在 profile 里关掉,但 HDL 侧的参数必须跟着改,只改软件不改硬件是起不来的。

3. HDL 侧拆解:从 tcl 到 bit,JESD204B 收发链路怎么搭起来

3.1 用 make 生成 Vivado 工程

ADI 这套 hdl 仓库用 Makefile 驱动,不需要手动点 Vivado 的图形界面。进到工程目录以后先设环境变量,再分两步走:

cd hdl/projects/adrv9009_zc706 # 指向你本机装的 Vivado,版本要和工程里 IP 支持的版本对得上 export XILINX_VIVADO=/opt/Xilinx/Vivado/2021.1 export PATH=$XILINX_VIVADO/bin:$PATH # 第一步:只生成工程,不综合,几十秒就能看到 block design make project # 第二步:综合 + 实现 + 导出 xsa,视机器性能大概十几到几十分钟 make

make project这一步非常值得单独跑。它会根据adi_project_adrv9009_zc706.tcl里的脚本,把 block design、约束、IP 参数全部重建一遍,生成一个可以直接打开的 Vivado 工程。打开以后先看 Address Editor 里各个 AXI 外设的地址分配,再确认 JESD204 那几根线连到了正确的 GTH,比自己从头对着文档猜快得多。

make的产物在build/目录下,会同时给出 bit 文件和 xsa。xsa 就是后面 Vitis/SDK 建平台要用的那一个,别拿错成单纯的 bit。血泪经验是:每次重新跑make之后,Vitis 里的 platform 工程必须重新生成一次 BSP,否则地址映射还是旧的,程序能编过但一跑就飞。

3.2 system_bd.tcl 里 RX 链路怎么拼

block design 不是画出来的,是脚本写出来的。接收方向的核心几段大致长这样,参数名在各版本间略有出入,照着你那份adi_project_*.tcl抄,不要凭记忆写:

# 接收方向:GTH 收发器 -> JESD204B RX -> ADRV9009 RX core ad_ip_instance axi_adxcvr axi_adxcvr_rx ad_ip_parameter axi_adxcvr_rx CONFIG.NUM_OF_LANES 4 ad_ip_parameter axi_adxcvr_rx CONFIG.TX_OR_RX_N 0 ;# 0 表示接收方向 # link layer:lane 数、转换器数、位宽、每帧样本数必须和 profile 一致 ad_ip_instance axi_jesd204_rx axi_jesd204_rx ad_ip_parameter axi_jesd204_rx CONFIG.NUM_OF_LANES 4 ad_ip_parameter axi_jesd204_rx CONFIG.NUM_OF_CONVERTERS 2 ad_ip_parameter axi_jesd204_rx CONFIG.CONVERTER_RESOLUTION 16 ad_ip_parameter axi_jesd204_rx CONFIG.SAMPLES_PER_FRAME 4 # 应用层 core:做通道拆分和样本对齐,输出给 DMA ad_ip_instance axi_adrv9009 axi_adrv9009_rx ad_connect axi_adrv9009_rx/gt_rx_data axi_jesd204_rx/gt_rx_data ad_connect axi_jesd204_rx/adc_data axi_adrv9009_rx/adc_data

NUM_OF_LANES 4对应的是两个接收通道各占 2 条 lane 的常见配置。NUM_OF_CONVERTERS 2表示这条链路上有两个转换器(两个接收通道)。CONVERTER_RESOLUTION 16是 JESD204B 传输时用的容器位宽,ADRV9009 内部 ADC 是 12bit,但链路层按 16bit 对齐,这个值填错会让样本高低位错位,现象是波形看着有信号但全是畸变。

SAMPLES_PER_FRAME对应 profile 里的 F 参数,指的是每条 lane 每一帧承载的样本数。这几个参数不是随便挑的,它们和 ADRV9009 内部的 profile 是一一对应的。改任何一个都要两边同时改,只改一边的后果下面第 5 章会细说。

3.3 profile 数字对不上,链路就是不起来

JESD204B 最难的地方在于参数耦合。下面这张表是我每次改配置都会对着核一遍的,取值以 245.76 MSPS 那一档典型 profile 为例:

参数含义典型取值填错的直接后果
L每条链路的 lane 数每通道 2,合计 4少一根 lane,CGS 阶段就过不去
M每链路的转换器数2通道数据错位,CH0 里混进 CH1
F每帧每 lane 的 octet 数4帧边界算错,数据整体偏移
K每 multiframe 的帧数32SYSREF 对齐失败,link state 反复跳
S每帧每转换器的样本数1实际采样率和你以为的不一样

这几个参数还决定了线速率。按上面的取值算一遍:采样率 245.76 MSPS × M=2 × 16bit × 10/8 ÷ 每条通道 2 条 lane,得到单 lane 4.9152 Gbps。这个数字必须落在 ZC706 上 GTH 的能力范围内,也要和axi_adxcvr里配的参考时钟分频比匹配。算出来的线速率超过 GTH 那一档的上限,综合不报错,但上板以后 CPLL 锁不上,现象就是收发器一直不 lock,看起来像硬件坏了。

device clock 也一样要算。它通常取线速率的 1/20,也就是上面这个例子里的 245.76 MHz,由 AD9528 输出、经 FMC 的时钟引脚送进 PL,同时还要送给 ADRV9009 自己。SYSREF 则是 device clock 的整数分频,分频比要能覆盖 K×F 的 multiframe 长度。这三个频率之间的关系一旦断掉,链路就是起不来,而且报错信息极其模糊,只能靠示波器或者 ILA 去抓。

提示:改 profile 时不要只改 ADRV9009 那边的配置数组,HDL 里的NUM_OF_LANES、NUM_OF_CONVERTERS、SAMPLES_PER_FRAME必须同源同改,两边对不上是这套工程最常见的翻车点。

4. no-OS 侧拆解:上电到出数的那几百毫秒里发生了什么

4.1 main 里的初始化顺序

裸机程序没有操作系统帮你排依赖,所有初始化顺序都得自己写死。这套工程的main逻辑大概是这个形状,函数名和你手上那份可能差几个字母,但顺序是一致的:

int main(void) { int32_t status; struct adi_hal *hal = NULL; /* 1. 平台层:串口、中断控制器、定时器、SPI/GPIO 句柄 */ status = adi_hal_init(&hal, &hal_init_param); if (status != SUCCESS) goto error; /* 2. 时钟芯片 AD9528:先出 device clock 和 SYSREF,后面所有事都靠它 */ status = ad9528_init(&ad9528_device, &ad9528_init_param, hal); if (status != SUCCESS) goto error; status = ad9528_setup(&ad9528_device); /* 3. 收发器本体:SPI 灌 profile、开通道、跑内部校准 */ status = adrv9009_init(&adrv9009_device, &adrv9009_init_param, hal); if (status != SUCCESS) goto error; status = adrv9009_setup(&adrv9009_device); /* 4. JESD204B:等链路起来,再启动 DMA 采集 */ status = app_jesd204_setup(); /* 5. 进主循环,周期性读状态、搬数据 */ while (1) { app_monitor(); mdelay(1000); } error: while (1) ; /* 停在这,用调试器看 status 停在哪一步 */ return status; }

顺序不能换。AD9528 必须先于 ADRV9009,因为后者上电后要检测到合法的 device clock 才会响应 SPI;JESD204B 的链路使能必须晚于 ADRV9009 的 profile 加载,否则 framer 输出的数据格式还没定,接收侧会一直卡在 CGS。我见过有人为了「快一点」,把adrv9009_setup放到ad9528_setup前面,结果 SPI 读 ID 一直返回 0,查了半天以为是焊接问题。

adrv9009_setup这一步内部还会跑一段内部校准,包括正交误差校正和直流偏移校正,耗时在几百毫秒量级。这段时间里 CPU 是纯阻塞的,串口不会有任何输出。第一次跑的时候不要以为程序死了,把超时调大一点,或者在这之前先打一行日志。

4.2 链路自检:读 link state 和 lane 状态

数据不对之前,先确认链路是对的。JESD204B 的状态机有明确的分级,CGS 阶段是码组同步,ILA 阶段是初始通道对齐,只有两者都完成,数据才可用:

#define JESD_LINK_CGS_DONE 0x1 #define JESD_LINK_ILA_DONE 0x3 static int32_t wait_link_up(struct axi_jesd204_rx *jesd, uint32_t timeout_ms) { uint32_t link_state = 0; uint32_t lane_state = 0; uint32_t elapsed = 0; while (elapsed < timeout_ms) { /* 链路整体状态,寄存器偏移以 axi_jesd204 驱动头文件为准 */ axi_jesd204_rx_read(jesd, AXI_JESD204_REG_LINK_STATE, &link_state); if ((link_state & JESD_LINK_ILA_DONE) == JESD_LINK_ILA_DONE) return SUCCESS; mdelay(10); elapsed += 10; } /* 没起来就把每条 lane 的状态打出来,看是全部不同步还是个别 lane */ axi_jesd204_rx_read(jesd, AXI_JESD204_REG_LANE_STATUS, &lane_state); printf("link=%lx lanes=%lx\n", link_state, lane_state); return FAILURE; }

link_state为 1 说明只有 CGS 完成,为 3 才是真正可用。停在 1 通常是 lane 极性反了或者线速率不匹配;一直是 0 则要往前查 GTH 有没有 lock。lane_state是按位分布的,每一位对应一条 lane,如果只有某一位不亮,问题大概率出在那一条 lane 的物理连接或者该 lane 的 K 码配置上,而不是全局参数。

这段自检建议加在adrv9009_setup之后、DMA 启动之前,作为一道硬门槛。链路没起来就继续往下跑,拿到的数据是垃圾,反而会让你以为是算法写错了。

4.3 DMA 把数据搬进 DDR

链路通了以后,数据是持续不断的 AXI4-Stream,需要一个搬运工把它落到内存里。工程里用的是axi_dmac,配置成 PL 到 PS DDR 的方向,循环模式:

/* 采集缓冲区地址:别和 .bss/.data 撞,一般放在预留区或者 DDR 高段 */ #define ADC_CAPTURE_ADDR (0x20000000UL) #define ADC_CAPTURE_SIZE (65536 * 4) /* 两通道交织,16bit 对齐后 4 字节一组 */ static struct axi_dmac rx_dmac; static struct axi_dmac_init rx_dmac_init = { .name = "rx_dmac", .base = RX_DMA_BASEADDR, .irq_option = IRQ_DISABLED, /* 裸机先用轮询,别一上来就开中断 */ }; static int32_t start_capture(void) { struct axi_dmac_transfer transfer = { .src_addr = RX_CORE_DATA_ADDR, /* RX core 的数据口地址 */ .dest_addr = ADC_CAPTURE_ADDR, .size = ADC_CAPTURE_SIZE, .flags = DMA_TRANSFER_CYCLIC, /* 循环搬,采满一圈覆盖一轮 */ }; axi_dmac_init(&rx_dmac, &rx_dmac_init); axi_dmac_transfer_start(&rx_dmac, &transfer); /* 等第一轮搬完,之后就可以直接读 DDR 里的缓冲区 */ return axi_dmac_transfer_wait_completion(&rx_dmac, 2000); }

ADC_CAPTURE_SIZE是一次搬运的字节数,必须是 DMA 位宽的整数倍,也要和 DDR 里那块区域的边界对齐。开循环模式的好处是采集不中断,代价是缓冲区会被覆盖,所以读数据的时候要么关掉 DMA,要么用双缓冲。这一点在只做原型验证时无所谓,一上量产逻辑就得改。

irq_option先设成IRQ_DISABLED是刻意的。裸机环境下的中断向量表、GIC 初始化都要自己搭,一开始就开中断,很容易出现「断点停不下来」「程序在某处随机跳」这类问题。先轮询把通路跑通,确认数据正确,再换成中断。

5. 排查清单:链路起不来、数据全是零时先看这五处

5.1 link state 一直停在 CGS

现象:串口反复打印link=1,lane_state显示部分 lane 同步上了,就是到不了 ILA 完成。原因:最常见的是 SYSREF 没送到 FPGA,或者送的是连续模式但相位和 LMFC 没对齐;其次是 AD9528 配好了时钟但没开 SYSREF 输出使能。解决:先读 AD9528 的回读寄存器确认 SYSREF 通道真的打开了,再确认 FMC 上那根 SYSREF 差分对有没有连到正确的引脚、约束里有没有写对应的 IO 标准,最后把 SYSREF 改成 N-shot 单次输出试一次。这一步用 ILA 抓一下 sysref 上升沿最直接。

5.2 改了 profile 但没重跑综合

现象:软件侧读回来的通道使能是对的,SPI 通信正常,但采集到的数据两路混在一起,或者整体偏移了半个样本。原因:只改了 no-OS 里的配置数组和 profile 文件,HDL 里的NUM_OF_CONVERTERS、SAMPLES_PER_FRAME还是旧值,两边对不上。解决:把 profile 里的 L/M/F/K/S 抄到system_bd.tcl对应的ad_ip_parameter上,重新make出 bit 和 xsa,再重建 Vitis 的 platform 和 BSP。这套流程千万别想着省。

5.3 程序能下载但跑飞

现象:Vitis 里点调试,程序停在adi_hal_init里出不来,或者串口一个字符都没有。原因:换了新的 xsa 但 BSP 还是旧的,外设基地址对不上;或者 PS 侧的串口、DDR 参数和板子实际配置不一致。解决:删掉 platform 工程重新建,BSP 重新生成,然后确认xparameters.h里XPAR_AXI_UART之类的宏地址和 Vivado 里 Address Editor 显示的一致。这一步没有捷径。

5.4 采到的数据全是零或者常数

现象:DMA 传输完成标志置位了,缓冲区里读出来全是0x0000或者一个重复值。原因:三种可能,一是 ADRV9009 的接收通道根本没使能,二是 RX core 的 AXI-Stream 上没有 valid 信号,三是 DMA 的源地址指错了地方。解决:先读 ADRV9009 的通道使能寄存器确认,再用 ILA 抓一下 RX core 输出口的valid和data,最后对着 Address Editor 核对 DMA 源地址。按这个顺序查,基本三步之内能定位。

5.5 综合能过但时序不收敛

现象:实现阶段报 setup 违例,或者上板以后数据偶发错误、温度一高就出错。原因:FMC 的 GTH 参考时钟约束没写,或者 SYSREF 那根线走了普通 IO 而没有用 IDDR/ODDR 处理跨时钟域。解决:检查system_constr.xdc里create_clock有没有覆盖 GBTCLK,检查 SYSREF 相关的set_input_delay是不是照着传输延迟填的。时序余量很小的路径优先看 JESD204 那几级的跨时钟域握手,那里最容易出问题。

6. 二次开发:改采样率、接自己的算法模块,以及怎么验证没改坏

想动采样率的话,改动面比想象中大。完整动作是:用配置工具重新生成 profile,把里面的 L/M/F/K/S 抄回system_bd.tcl,把新的 device clock 和线速率写进 AD9528 的分频参数,重跑make出 bit 和 xsa,最后在 no-OS 里同步 profile 配置数组。改完先别急着采数据,跑一遍第 4.2 节的自检,确认 link state 到 3,再采一段单音做 FFT 看 SNR 和 SFDR 有没有掉下来。掉太多说明时钟或者 JESD 参数有问题,而不是射频电路的问题。

要把数据接进自己的算法模块,最省事的做法不是改axi_adrv9009,而是在它和 DMA 之间插一个旁路 tap,只监听不参与握手:

// 旁路监听:不占用 DMA 带宽,也不改变原有 valid/ready 时序 tap_adc_stream u_tap ( .clk (rx_clk), .rst (rx_rst), .s_axis_tvalid (rx_valid), .s_axis_tdata (rx_data[31:0]), // CH0/CH1 各 16bit 交织 .peak_flag (peak_flag) // 给 PS 当软中断用 );

这个模块里可以做简单的门限检测、累加或者抽取,结果通过 AXI-Lite 寄存器回给 PS。改完之后的验证方法固定成三步:先读 JESD 链路状态确认没被拖垮,再用 ILA 抓peak_flag确认触发时机对,最后回 PS 里对比 tap 的统计值和 DMA 缓冲区里的数据是否自洽。三步都过了,才算改完。

改 HDL 最怕的是「看起来没问题」。我踩过最贵的一次是加了个跨时钟域的计数器,综合过了、仿真过了、上板跑了半小时也正常,温度一上来就开始丢数。从那以后我每次动 PL 逻辑,都强制先跑一遍 link state 自检加一段单音 FFT,两条都过才允许往下走。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进