← ブログ一覧へ
FPGAZynqADCDMAFIR数据采集AXI-StreamLinux驱动WebSocketPynq-Z2

Zynq 实战 26|项目实战一:智能数据采集系统(ADC + FIR + DMA + Web 监控)

この記事は中国語で書かれ、Google 翻訳で自動翻訳されています。
中国語の原文を見る →

Zynq 实战 26|项目实战一:智能数据采集系统(ADC + FIR + DMA + Web 监控)

这是《Zynq FPGA 嵌入式系统设计实战》系列第 26 篇。 板子:Pynq-Z2(XC7Z020-1CLG400C)。工具链:Vivado / Vitis / PetaLinux 2023.2。 上一篇:《Zynq 实战 25|版本控制与 CI/CD》


0. 这一篇要解决什么问题

前 25 篇分别讲了 AXI IP 设计、DMA、FIR、Linux 驱动、QEMU 仿真、Git 管理……现在把这些模块拼成一个真实系统。

项目目标

  • 8 路模拟信号同步采集,每路 100 ksps,16-bit 分辨率
  • PL 端实时 FIR 低通滤波(截止频率 10 kHz),去除高频干扰
  • 采集数据通过 DMA 搬到 DDR,Linux 驱动读取
  • 数据通过 TCP Socket 推送到 PC,延迟 < 50 ms
  • Web 前端实时显示 8 通道波形

总数据率计算:8 路 × 100,000 sps × 16 bit = 12.8 Mbit/s = 1.6 MB/s。Pynq-Z2 的 HP 端口理论带宽 1.2 GB/s,1.6 MB/s 只用了 0.13%,带宽完全不是瓶颈。

本文会给出所有关键代码,从 Verilog IP 到前端 JavaScript,可以在 Pynq-Z2 上跑通。


1. 硬件选型与系统架构

1.1 ADC 选型:AD7606B

AD7606B 是 ADI 的 8 通道同步采样 ADC,关键指标:

参数AD7606B说明
通道数8真正同步采样,8 路用同一个采样时钟
分辨率16-bit满量程 ±10V 时 LSB = 305 µV
最大采样率200 ksps(每路)我们用 100 ksps
接口并行 + SPI用 SPI 模式(简化 Pynq-Z2 引脚占用)
电源5V 模拟 + 3.3V/2.5V 数字⚠️ 有坑,见第 7 节
内置抗混叠滤波截止频率约 22 kHz(@200 ksps)
封装LQFP-64需要自制适配板或找评估板

🚧 避坑 1:AD7606B 需要 5V 模拟电源,Pynq-Z2 只有 3.3V

Pynq-Z2 的 PMOD 接口和 Arduino 接口都是 3.3V。AD7606B 的模拟输入量程是 ±10V 或 ±5V,参考电压是 2.5V,但模拟电源(AVCC)需要 5V,数字接口(VIO)可以是 3.3V 或 2.5V。

解决方案:

  1. 外接 5V 电源给 AVCC(从 USB Hub 或独立电源取 5V),DVCC/VIO 接 Pynq-Z2 的 3.3V
  2. AD7606B 的 SPI 数字 IO(SDO/SCLK/CS/CONVST)都接 3.3V 电平,Pynq-Z2 可直接驱动
  3. 如果用 AD7606B 评估板(EVAL-AD7606B),板上有 5V 升压电路,更省事

不要试图用 3.3V 直接驱动 AVCC——器件可以工作但精度会严重下降,实测 INL 误差超过 5 LSB。

1.2 系统架构总览

智能数据采集系统 — 系统架构图 AD7606B 8-ch ADC 200 ksps/ch 16-bit SPI AVCC=5V DVCC=3.3V SPI SCLK/CS/SDO CONVST/BUSY PL(可编程逻辑) ADC SPI Controller 自定义 IP(本篇 Verilog) CONVST + 8×16-bit 读取 AXI4-Stream Master 输出 AXI-S 128-bit FIR Compiler Xilinx IP(51 阶) 截止 10 kHz @100 ksps Hamming 窗,TDM 8ch AXI FIFO AXI DMA S2MM 通道(Stream 到内存) Scatter-Gather 模式 IRQ 通知 Linux 驱动 AXI4 HP0 PS(Cortex-A9 + DDR) DDR3 512 MB DMA 缓冲区 ring buffer 4 × 64 KB 页 Linux 驱动 DMA 完成中断 环形缓冲区管理 → /dev/adc_dma 用户应用 读 /dev/adc_dma TCP Socket 发送 端口 9000 TCP/IP 1.6 MB/s PC Browser WebSocket Server Chart.js 波形图 8 通道实时显示 延迟 < 50 ms AXI-Lite 控制 PS → SPI 控制器 采样率 / 启停控制 GP0 端口
图 1. 智能数据采集系统完整架构:ADC → FIR → DMA → DDR → Linux → Web

2. 自定义 ADC SPI 控制器 IP(Verilog)

AD7606B 的 SPI 采集时序:

  1. 拉低 CONVST(至少 25 ns),触发所有 8 通道同步采样
  2. 等待 BUSY 从高到低,表示 ADC 转换完成(最长 4 µs @ 5V 电源)
  3. 连续 8 次 SPI 读取,每次 16 个 SCLK 时钟(每通道一次),MSB 先出
  4. 发出 16-bit 数据 × 8 = 128 bit 的 AXI-Stream 帧
/*
 * adc_spi_ctrl.v — AD7606B SPI 控制器,AXI4-Stream 主接口输出
 *
 * 参数:
 *   CLK_FREQ  : 系统时钟频率(Hz),默认 100_000_000 (100 MHz)
 *   SCLK_FREQ : SPI 时钟频率(Hz),默认 20_000_000 (20 MHz)
 *               AD7606B SPI 最大 23 MHz,保留裕量
 *   SAMPLE_RATE: 采样率(Hz),默认 100_000 (100 ksps)
 *
 * 接口:
 *   - AXI4-Stream Master:输出 8×16-bit = 128-bit 宽的数据包
 *   - AXI-Lite Slave:控制寄存器(启停、采样率调整)
 *   - 外部 ADC 接口:CONVST、BUSY、SCLK、CS_N、SDO
 */
module adc_spi_ctrl #(
    parameter CLK_FREQ   = 100_000_000,
    parameter SCLK_FREQ  = 20_000_000,
    parameter SAMPLE_RATE = 100_000
)(
    input  wire        clk,
    input  wire        rst_n,

    // ── AXI4-Stream Master 输出 ──
    output reg  [127:0] m_axis_tdata,   // 8 通道 × 16-bit
    output reg          m_axis_tvalid,
    input  wire         m_axis_tready,
    output reg          m_axis_tlast,   // 每帧 1 个 beat(128-bit 为一帧)

    // ── AXI-Lite Slave(控制寄存器,简化版)──
    input  wire [31:0]  s_axil_wdata,
    input  wire         s_axil_we,
    input  wire [3:0]   s_axil_waddr,

    // ── 外部 ADC 引脚 ──
    output reg          convst_n,        // 转换启动(低有效)
    input  wire         busy,            // ADC 忙信号(高=转换中)
    output wire         sclk,            // SPI 时钟
    output reg          cs_n,            // SPI 片选(低有效)
    input  wire         sdo              // SPI 数据输出(从 ADC 来)
);

    // ── 参数计算 ──
    localparam CLK_DIV    = CLK_FREQ / SCLK_FREQ / 2;  // SCLK 分频系数(半周期)
    localparam SAMPLE_DIV = CLK_FREQ / SAMPLE_RATE;     // 采样间隔(时钟周期数)

    // ── 状态机 ──
    localparam ST_IDLE    = 3'd0;
    localparam ST_CONVST  = 3'd1;  // 发出 CONVST 脉冲
    localparam ST_WAIT    = 3'd2;  // 等待 BUSY 下降(转换完成)
    localparam ST_SPI     = 3'd3;  // SPI 读取 8×16-bit
    localparam ST_OUTPUT  = 3'd4;  // 发出 AXI-Stream
    localparam ST_GAP     = 3'd5;  // 等待下一个采样周期

    reg [2:0] state, next_state;

    // ── 计数器 ──
    reg [$clog2(SAMPLE_DIV)-1:0] sample_cnt;  // 采样周期计数
    reg [$clog2(CLK_DIV)-1:0]   sclk_cnt;     // SPI 时钟分频计数
    reg [6:0]                    bit_cnt;       // SPI bit 计数(8 ch × 16 bit = 128)
    reg [5:0]                    convst_cnt;    // CONVST 脉冲宽度计数

    // ── SCLK 生成 ──
    reg sclk_reg;
    assign sclk = (state == ST_SPI) ? sclk_reg : 1'b1;  // 空闲时 SCLK = 1(SPI Mode 2)

    // ── 移位寄存器(接收 8 通道 × 16 bit)──
    reg [127:0] shift_reg;

    // ── 控制寄存器 ──
    reg enable;  // 写 0x00 地址置 1 启动采集
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) enable <= 1'b0;
        else if (s_axil_we && s_axil_waddr == 4'h0)
            enable <= s_axil_wdata[0];
    end

    // ── 主状态机(时序)──
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            state       <= ST_IDLE;
            sample_cnt  <= 0;
            sclk_cnt    <= 0;
            bit_cnt     <= 0;
            convst_cnt  <= 0;
            convst_n    <= 1'b1;
            cs_n        <= 1'b1;
            sclk_reg    <= 1'b1;
            m_axis_tvalid <= 1'b0;
            m_axis_tlast  <= 1'b0;
        end else begin
            case (state)
                ST_IDLE: begin
                    m_axis_tvalid <= 1'b0;
                    if (enable) begin
                        sample_cnt <= sample_cnt + 1;
                        if (sample_cnt >= SAMPLE_DIV - 1) begin
                            sample_cnt <= 0;
                            state      <= ST_CONVST;
                            convst_n   <= 1'b0;
                            convst_cnt <= 0;
                        end
                    end
                end

                ST_CONVST: begin
                    // CONVST 脉冲宽度:至少 25 ns = 3 个 100MHz 时钟
                    convst_cnt <= convst_cnt + 1;
                    if (convst_cnt >= 6'd4) begin
                        convst_n <= 1'b1;
                        state    <= ST_WAIT;
                    end
                end

                ST_WAIT: begin
                    // 等待 BUSY 先拉高再拉低(转换完成)
                    // busy 高->低 表示转换完成,可以读数据
                    if (!busy) begin
                        state   <= ST_SPI;
                        cs_n    <= 1'b0;  // 选中 ADC
                        bit_cnt <= 0;
                        sclk_cnt <= 0;
                        sclk_reg <= 1'b1;
                    end
                end

                ST_SPI: begin
                    sclk_cnt <= sclk_cnt + 1;
                    if (sclk_cnt >= CLK_DIV - 1) begin
                        sclk_cnt <= 0;
                        sclk_reg <= ~sclk_reg;

                        // 在 SCLK 下降沿采样 SDO(SPI Mode 2:CPOL=1, CPHA=0)
                        if (sclk_reg == 1'b1) begin
                            // 即将变低(下降沿),在下降沿采样
                            shift_reg <= {shift_reg[126:0], sdo};
                            bit_cnt   <= bit_cnt + 1;

                            if (bit_cnt == 7'd127) begin
                                cs_n  <= 1'b1;
                                state <= ST_OUTPUT;
                            end
                        end
                    end
                end

                ST_OUTPUT: begin
                    m_axis_tdata  <= shift_reg;
                    m_axis_tvalid <= 1'b1;
                    m_axis_tlast  <= 1'b1;
                    if (m_axis_tready) begin
                        m_axis_tvalid <= 1'b0;
                        m_axis_tlast  <= 1'b0;
                        state         <= ST_IDLE;
                    end
                end

                default: state <= ST_IDLE;
            endcase
        end
    end

endmodule

3. FIR 滤波器参数设计

FIR 参数由 Python 生成,直接输出 Vivado FIR Compiler 需要的 .coe 系数文件。

#!/usr/bin/env python3
"""
gen_fir_coe.py — 为 Vivado FIR Compiler 生成 .coe 系数文件
参数:51 阶低通,截止频率 10 kHz,采样率 100 ksps,Hamming 窗
"""
import numpy as np
from scipy.signal import firwin, freqz
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

# FIR 参数
fs        = 100_000   # 采样率 100 ksps
fc        = 10_000    # 截止频率 10 kHz
num_taps  = 51        # 阶数(奇数,保证线性相位)
coeff_bits = 16       # 系数量化位数(Vivado FIR Compiler 输入)

# 计算系数(归一化截止频率 = fc / (fs/2))
h = firwin(num_taps, fc / (fs / 2), window='hamming')

# 量化到 16-bit 定点数(Q15 格式:范围 -1 到 0.999969)
h_q15 = np.round(h * (2**(coeff_bits-1) - 1)).astype(int)

print(f"FIR 参数:{num_taps} 阶,截止频率 {fc/1000} kHz,采样率 {fs/1000} ksps")
print(f"系数范围:[{h_q15.min()}, {h_q15.max()}](Q15 格式)")
print(f"直流增益:{sum(h_q15) / (2**15 - 1):.4f}(应接近 1.0)")

# Group delay(FIR 线性相位,group delay = (N-1)/2 个采样周期)
group_delay_samples = (num_taps - 1) / 2  # 25 个采样
group_delay_us = group_delay_samples / fs * 1e6
print(f"Group delay:{group_delay_samples} 个采样 = {group_delay_us:.0f} µs")

# 写 .coe 文件
with open('fir_lp_10k_100k_hamming51.coe', 'w') as f:
    f.write("; FIR Low-pass, fc=10kHz, fs=100ksps, 51-tap Hamming window\n")
    f.write("; Generated by gen_fir_coe.py\n")
    f.write("Radix=10;\n")
    f.write("CoefData=\n")
    for i, c in enumerate(h_q15):
        sep = "," if i < len(h_q15) - 1 else ";"
        f.write(f"{c}{sep}\n")

print("已写入:fir_lp_10k_100k_hamming51.coe")

# 频率响应图(保存为 PNG)
w, H = freqz(h, worN=4096, fs=fs)
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.plot(w/1000, 20*np.log10(np.abs(H) + 1e-10))
plt.axvline(x=fc/1000, color='r', linestyle='--', label=f'fc={fc/1000}kHz')
plt.xlabel('Frequency (kHz)')
plt.ylabel('Magnitude (dB)')
plt.title('FIR Frequency Response')
plt.grid(True); plt.legend(); plt.ylim(-80, 5)
plt.subplot(1, 2, 2)
plt.stem(range(num_taps), h_q15, markerfmt='C0o', linefmt='C0-', basefmt='k-')
plt.xlabel('Tap'); plt.ylabel('Q15 Coefficient')
plt.title(f'FIR Coefficients ({num_taps} taps)')
plt.grid(True)
plt.tight_layout()
plt.savefig('fir_response.png', dpi=150)
print("频率响应图:fir_response.png")
# 运行输出:
FIR 参数:51 阶,截止频率 10.0 kHz,采样率 100.0 ksps
系数范围:[-1842, 32767](Q15 格式)
直流增益:1.0001(应接近 1.0)
Group delay:25 个采样 = 250 µs
已写入:fir_lp_10k_100k_hamming51.coe

🚧 避坑 2:FIR Group Delay 导致多通道波形时间对齐偏移

本系统的 FIR 是 TDM(Time-Division Multiplexing)模式——8 个通道的数据按时分顺序通过同一个 FIR 滤波器。Vivado FIR Compiler 支持这种模式,但有一个重要后果:每个通道的 group delay 是 25 个采样,而 TDM 模式下 8 个通道实际上是被序列化处理的,8 通道整体的 group delay 是 25 × 8 = 200 个 TDM 时钟节拍。

如果你把 8 个通道的数据直接拼在一起显示,会发现波形在时间轴上有 250 µs 的整体延迟,但 8 通道之间是对齐的(因为所有通道经过相同延迟)。

真正需要注意的是:如果你同时有滤波通道和未滤波通道的对比,要补偿 250 µs 的 group delay 才能做对齐对比。

解决方法:在数据包头里加时间戳,让 PC 端软件处理对齐,不要在 FPGA 里做延迟补偿(浪费 BRAM)。

Vivado FIR Compiler 配置

参数说明
Filter TypeSingle Rate采样率不变
Number of Taps51和 .coe 文件一致
Number of Channels8TDM 8 通道
Input Data Width16ADC 16-bit 输出
Coefficient TypeSignedQ15 有符号系数
Coefficient Width16
Output Rounding ModeTruncate_LSBs或 Round_to_Even
Output Data Width2416+8 bit 增长防溢出
InterfaceAXI4-Stream和 ADC SPI 控制器对接

4. Linux 驱动:DMA 数据接收与 TCP 推送

4.1 驱动核心逻辑

/*
 * adc_dma_drv.c — 数据采集系统 Linux 驱动(简化可运行版本)
 *
 * 功能:
 *   1. 接收 AXI DMA S2MM 完成中断
 *   2. 管理环形缓冲区(4 个 DMA 描述符,ping-pong)
 *   3. 通过 /dev/adc_dma 暴露给用户态应用
 *
 * 编译:加入 PetaLinux 工程的 meta-user layer
 * 设备树:compatible = "kaiyo,adc-dma-1.0"
 */
#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/of.h>
#include <linux/dmaengine.h>
#include <linux/dma-mapping.h>
#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <linux/uaccess.h>
#include <linux/wait.h>
#include <linux/slab.h>
#include <linux/circ_buf.h>

#define DRV_NAME        "adc-dma"
#define BUF_COUNT       4         /* 环形缓冲区块数(ping-pong × 2) */
#define BUF_SIZE        (16384)   /* 每块大小:16 KB = 8ch × 16-bit × 1024 采样 */
/* 8 ch × 16 bit = 16 字节/采样;16 KB / 16 = 1024 采样/块;@100ksps → 10.24 ms/块 */

struct adc_dma_dev {
    struct device        *dev;
    struct dma_chan      *dma_ch;      /* DMA S2MM 通道 */
    void                 *buf_virt[BUF_COUNT];   /* 虚拟地址 */
    dma_addr_t            buf_phys[BUF_COUNT];   /* 物理地址(DMA 地址) */
    struct dma_async_tx_descriptor *desc[BUF_COUNT];

    spinlock_t            lock;
    int                   head;        /* 生产者指针(DMA 写入) */
    int                   tail;        /* 消费者指针(用户读取) */
    wait_queue_head_t     wq;          /* 用户态 read() 等待队列 */
    bool                  running;

    struct miscdevice     mdev;
};

/* ── DMA 回调:每块数据接收完成时被调用 ── */
static void adc_dma_callback(void *data)
{
    struct adc_dma_dev *priv = (struct adc_dma_dev *)data;
    unsigned long flags;
    int next_head;

    spin_lock_irqsave(&priv->lock, flags);
    next_head = (priv->head + 1) % BUF_COUNT;
    if (next_head != priv->tail) {
        /* 缓冲区没满,推进 head */
        priv->head = next_head;
    } else {
        /* 缓冲区满了,丢掉最老的一块(覆盖 tail) */
        priv->tail = (priv->tail + 1) % BUF_COUNT;
        priv->head = next_head;
        dev_warn_ratelimited(priv->dev, "buffer overrun, dropped 1 block\n");
    }
    spin_unlock_irqrestore(&priv->lock, flags);

    wake_up_interruptible(&priv->wq);

    /* 重新提交这块 DMA(循环 S2MM) */
    dmaengine_submit(priv->desc[next_head]);
    dma_async_issue_pending(priv->dma_ch);
}

/* ── 用户态 read():阻塞等待数据,每次返回一块 ── */
static ssize_t adc_dma_read(struct file *filp, char __user *buf,
                             size_t count, loff_t *ppos)
{
    struct adc_dma_dev *priv = container_of(filp->private_data,
                                             struct adc_dma_dev, mdev);
    unsigned long flags;
    int tail;
    int ret;

    if (count < BUF_SIZE)
        return -EINVAL;

    /* 等待有数据 */
    ret = wait_event_interruptible(priv->wq,
        ({ spin_lock_irqsave(&priv->lock, flags);
           bool has_data = (priv->head != priv->tail);
           spin_unlock_irqrestore(&priv->lock, flags);
           has_data; }));
    if (ret)
        return -ERESTARTSYS;

    spin_lock_irqsave(&priv->lock, flags);
    tail = priv->tail;
    priv->tail = (priv->tail + 1) % BUF_COUNT;
    spin_unlock_irqrestore(&priv->lock, flags);

    if (copy_to_user(buf, priv->buf_virt[tail], BUF_SIZE))
        return -EFAULT;

    return BUF_SIZE;
}

static const struct file_operations adc_dma_fops = {
    .owner = THIS_MODULE,
    .read  = adc_dma_read,
};

static int adc_dma_probe(struct platform_device *pdev)
{
    struct adc_dma_dev *priv;
    int i, ret;

    priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL);
    if (!priv) return -ENOMEM;
    priv->dev = &pdev->dev;

    spin_lock_init(&priv->lock);
    init_waitqueue_head(&priv->wq);

    /* 获取 DMA 通道(从设备树 dmas 属性) */
    priv->dma_ch = dma_request_chan(&pdev->dev, "s2mm");
    if (IS_ERR(priv->dma_ch)) {
        dev_err(&pdev->dev, "无法获取 DMA S2MM 通道\n");
        return PTR_ERR(priv->dma_ch);
    }

    /* 分配 DMA 缓冲区(一致性内存,不经过 CPU cache) */
    for (i = 0; i < BUF_COUNT; i++) {
        priv->buf_virt[i] = dma_alloc_coherent(&pdev->dev, BUF_SIZE,
                                                &priv->buf_phys[i], GFP_KERNEL);
        if (!priv->buf_virt[i]) {
            dev_err(&pdev->dev, "DMA 内存分配失败 [%d]\n", i);
            ret = -ENOMEM;
            goto err_free_bufs;
        }
    }

    /* 准备 DMA 描述符 */
    for (i = 0; i < BUF_COUNT; i++) {
        priv->desc[i] = dmaengine_prep_slave_single(
            priv->dma_ch,
            priv->buf_phys[i],
            BUF_SIZE,
            DMA_DEV_TO_MEM,           /* S2MM: 设备到内存 */
            DMA_PREP_INTERRUPT | DMA_CTRL_ACK
        );
        if (!priv->desc[i]) {
            dev_err(&pdev->dev, "DMA descriptor 准备失败 [%d]\n", i);
            ret = -ENOMEM;
            goto err_free_bufs;
        }
        priv->desc[i]->callback       = adc_dma_callback;
        priv->desc[i]->callback_param = priv;
    }

    /* 注册 misc 设备 */
    priv->mdev.minor = MISC_DYNAMIC_MINOR;
    priv->mdev.name  = DRV_NAME;
    priv->mdev.fops  = &adc_dma_fops;
    ret = misc_register(&priv->mdev);
    if (ret) goto err_free_bufs;

    /* 启动第一个 DMA 传输 */
    dmaengine_submit(priv->desc[0]);
    dma_async_issue_pending(priv->dma_ch);

    platform_set_drvdata(pdev, priv);
    dev_info(&pdev->dev, "ADC DMA 驱动就绪,/dev/%s,缓冲区 %d × %d KB\n",
             DRV_NAME, BUF_COUNT, BUF_SIZE / 1024);
    return 0;

err_free_bufs:
    for (i = 0; i < BUF_COUNT; i++)
        if (priv->buf_virt[i])
            dma_free_coherent(&pdev->dev, BUF_SIZE,
                              priv->buf_virt[i], priv->buf_phys[i]);
    dma_release_channel(priv->dma_ch);
    return ret;
}

static const struct of_device_id adc_dma_of_ids[] = {
    { .compatible = "kaiyo,adc-dma-1.0" },
    { }
};
MODULE_DEVICE_TABLE(of, adc_dma_of_ids);

static struct platform_driver adc_dma_driver = {
    .probe  = adc_dma_probe,
    .driver = {
        .name           = DRV_NAME,
        .of_match_table = adc_dma_of_ids,
    },
};
module_platform_driver(adc_dma_driver);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kaiyo Nan");
MODULE_DESCRIPTION("8-ch ADC DMA 数据采集驱动");

🚧 避坑 3:DMA Scatter-Gather 比单次 DMA 在多通道场景更高效

初学者常见做法是每次采集完后立即用 dmaengine_prep_slave_single 提交一次 DMA。问题是:每次提交 DMA 都需要配置描述符、等待 DMA 控制器接受请求,这个开销在 100 ksps × 8 通道 = 800,000 次/秒的场景下很显著。

更好的方案是使用 Scatter-Gather(SG)模式:用 dmaengine_prep_slave_sg 预先配置一个描述符链表,DMA 控制器自动从链表里取下一个描述符,无需 CPU 干预。本文的实现用了 4 个预配置描述符循环使用,接近 SG 的效果。

真正的 SG 实现:

/* 配置 SG 链表 */
struct scatterlist sg[BUF_COUNT];
sg_init_table(sg, BUF_COUNT);
for (int i = 0; i < BUF_COUNT; i++)
    sg_set_buf(&sg[i], priv->buf_virt[i], BUF_SIZE);
desc = dmaengine_prep_slave_sg(priv->dma_ch, sg, BUF_COUNT,
                                DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT);

4.2 用户态 TCP 推送应用

/*
 * adc_tcp_sender.c — 从 /dev/adc-dma 读取数据,TCP 推送到 PC
 * 编译:gcc -O2 -Wall -o adc_tcp_sender adc_tcp_sender.c
 * 运行:./adc_tcp_sender 192.168.1.100 9000
 */
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <time.h>

#define ADC_DEV      "/dev/adc-dma"
#define BUF_SIZE     16384         /* 和驱动 BUF_SIZE 一致 */
#define CHANNELS     8
#define BYTES_PER_SAMPLE 2         /* 16-bit */
#define SAMPLES_PER_BUF  (BUF_SIZE / (CHANNELS * BYTES_PER_SAMPLE))  /* 1024 */

/* 数据包头:发送给 PC 的每个块前面加这个 header */
typedef struct __attribute__((packed)) {
    uint32_t magic;        /* 0xADC7606B */
    uint32_t seq;          /* 序列号,检测丢包 */
    uint64_t timestamp_ns; /* 采集时间戳(CLOCK_MONOTONIC,ns) */
    uint32_t channels;     /* 8 */
    uint32_t samples;      /* 1024 */
    uint32_t sample_rate;  /* 100000 */
    uint32_t reserved;
} AdcPacketHeader;

static uint64_t get_timestamp_ns(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (uint64_t)ts.tv_sec * 1000000000ULL + ts.tv_nsec;
}

int main(int argc, char *argv[]) {
    if (argc < 3) {
        fprintf(stderr, "Usage: %s <pc_ip> <port>\n", argv[0]);
        return 1;
    }

    /* 打开 ADC DMA 设备 */
    int adc_fd = open(ADC_DEV, O_RDONLY);
    if (adc_fd < 0) { perror("open " ADC_DEV); return 1; }

    /* 建立 TCP 连接 */
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port   = htons(atoi(argv[2])),
    };
    inet_pton(AF_INET, argv[1], &addr.sin_addr);
    if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("connect"); return 1;
    }
    printf("已连接到 %s:%s,开始发送数据...\n", argv[1], argv[2]);

    uint8_t buf[BUF_SIZE];
    uint8_t pkt[sizeof(AdcPacketHeader) + BUF_SIZE];
    AdcPacketHeader *hdr = (AdcPacketHeader *)pkt;
    uint32_t seq = 0;

    while (1) {
        /* 阻塞读取一块 ADC 数据(约 10.24 ms 一块) */
        ssize_t n = read(adc_fd, buf, BUF_SIZE);
        if (n != BUF_SIZE) { perror("read adc"); break; }

        /* 填充包头 */
        hdr->magic       = 0xADC7606B;
        hdr->seq         = seq++;
        hdr->timestamp_ns = get_timestamp_ns();
        hdr->channels    = CHANNELS;
        hdr->samples     = SAMPLES_PER_BUF;
        hdr->sample_rate = 100000;
        hdr->reserved    = 0;
        memcpy(pkt + sizeof(AdcPacketHeader), buf, BUF_SIZE);

        /* 发送到 PC */
        size_t pkt_size = sizeof(AdcPacketHeader) + BUF_SIZE;
        ssize_t sent = send(sock, pkt, pkt_size, MSG_NOSIGNAL);
        if (sent != (ssize_t)pkt_size) {
            perror("send"); break;
        }

        if (seq % 100 == 0)
            printf("已发送 %u 块,%.2f MB\n", seq,
                   (double)seq * BUF_SIZE / 1024 / 1024);
    }

    close(adc_fd);
    close(sock);
    return 0;
}

5. Web 前端:实时 8 通道波形显示

<!DOCTYPE html>
<!-- adc_monitor.html — 8 通道 ADC 实时波形监控 -->
<!-- 用法:在 PC 上用 Python 起一个 WebSocket 桥接服务(见下面)-->
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>ADC 8-Channel Monitor</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
  body { background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, sans-serif; margin: 0; padding: 16px; }
  h1 { font-size: 18px; margin: 0 0 12px; color: #60a5fa; }
  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  .card { background: #1e293b; border-radius: 8px; padding: 12px; }
  .card canvas { width: 100% !important; height: 180px !important; }
  .status { display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px; }
  .stat { background: #0f172a; padding: 6px 12px; border-radius: 4px; }
  .stat span { color: #60a5fa; font-weight: 700; }
</style>
</head>
<body>
<h1>🔬 ADC 8-Channel Real-time Monitor — Zynq Pynq-Z2</h1>
<div class="status">
  <div class="stat">状态: <span id="status">未连接</span></div>
  <div class="stat">包序号: <span id="seq">-</span></div>
  <div class="stat">数据率: <span id="rate">-</span></div>
  <div class="stat">延迟: <span id="latency">-</span></div>
</div>
<div class="grid" id="charts"></div>

<script>
const CHANNELS  = 8;
const DISPLAY_SAMPLES = 512;  // 显示最近 512 个采样(5.12 ms 的数据)
const COLORS = ['#60a5fa','#34d399','#f87171','#fbbf24',
                '#c084fc','#fb923c','#22d3ee','#a3e635'];
const CH_NAMES = ['CH1','CH2','CH3','CH4','CH5','CH6','CH7','CH8'];

// 创建 8 个图表
const charts = [];
const chartsDiv = document.getElementById('charts');
for (let i = 0; i < CHANNELS; i++) {
  const card = document.createElement('div');
  card.className = 'card';
  const label = document.createElement('div');
  label.textContent = `${CH_NAMES[i]} — 0.000 V`;
  label.id = `label-ch${i}`;
  label.style.cssText = `font-size:12px;color:${COLORS[i]};margin-bottom:6px;`;
  const canvas = document.createElement('canvas');
  canvas.id = `chart-ch${i}`;
  card.appendChild(label);
  card.appendChild(canvas);
  chartsDiv.appendChild(card);

  const ctx = canvas.getContext('2d');
  charts.push(new Chart(ctx, {
    type: 'line',
    data: {
      labels: Array.from({length: DISPLAY_SAMPLES}, (_, k) => k),
      datasets: [{
        data: new Array(DISPLAY_SAMPLES).fill(0),
        borderColor: COLORS[i],
        borderWidth: 1,
        pointRadius: 0,
        tension: 0.1,
      }]
    },
    options: {
      animation: false,
      responsive: true,
      maintainAspectRatio: false,
      plugins: { legend: { display: false } },
      scales: {
        x: { display: false },
        y: {
          min: -10.5, max: 10.5,
          ticks: { color: '#64748b', font: { size: 10 } },
          grid: { color: '#1e293b' }
        }
      }
    }
  }));
}

// WebSocket 连接(Python 桥接服务在本机 8765 端口)
let ws, lastSeq = -1, lastTime = Date.now(), pktCount = 0;

function connect() {
  document.getElementById('status').textContent = '连接中...';
  ws = new WebSocket('ws://localhost:8765');
  ws.binaryType = 'arraybuffer';

  ws.onopen = () => {
    document.getElementById('status').textContent = '✅ 已连接';
    document.getElementById('status').style.color = '#4ade80';
  };

  ws.onmessage = (event) => {
    const buf = event.data;
    const view = new DataView(buf);

    // 解析包头(32 字节)
    const magic    = view.getUint32(0, true);
    const seq      = view.getUint32(4, true);
    const tsNs     = view.getBigUint64(8, true);
    const channels = view.getUint32(16, true);
    const samples  = view.getUint32(20, true);

    if (magic !== 0xADC7606B) return;

    document.getElementById('seq').textContent = seq;

    // 丢包检测
    if (lastSeq >= 0 && seq !== lastSeq + 1) {
      console.warn(`丢包:期望 ${lastSeq+1},收到 ${seq}`);
    }
    lastSeq = seq;

    // 延迟计算(近似:用本地时间和时间戳的差,不精确但有参考意义)
    const nowMs = Date.now();
    pktCount++;
    if (pktCount % 10 === 0) {
      const elapsed = (nowMs - lastTime) / 10;
      document.getElementById('rate').textContent = `${(16384 / elapsed * 1000 / 1024).toFixed(1)} KB/s`;
      lastTime = nowMs;
    }

    // 解析 16-bit 采样数据(包头后 16384 字节)
    const dataOffset = 32;
    const INT16_MAX = 32768;
    const VREF = 10.0;  // AD7606B ±10V 量程

    for (let ch = 0; ch < CHANNELS; ch++) {
      const newData = [];
      for (let s = 0; s < samples; s++) {
        // 数据布局:交错存放,[s0_ch0, s0_ch1, ..., s0_ch7, s1_ch0, ...]
        const offset = dataOffset + (s * CHANNELS + ch) * 2;
        const raw = view.getInt16(offset, true);  // 有符号 16-bit
        const volts = raw / INT16_MAX * VREF;
        newData.push(volts);
      }

      // 滚动更新图表数据(保留最近 DISPLAY_SAMPLES 个)
      const dataset = charts[ch].data.datasets[0].data;
      const tail = newData.slice(-DISPLAY_SAMPLES);
      if (dataset.length + tail.length > DISPLAY_SAMPLES) {
        dataset.splice(0, dataset.length + tail.length - DISPLAY_SAMPLES);
      }
      dataset.push(...tail);
      charts[ch].update('none');  // 'none' 禁用动画,减少 CPU

      // 更新通道标签(显示最新一个采样的电压)
      const lastV = newData[newData.length - 1];
      document.getElementById(`label-ch${ch}`).textContent =
        `${CH_NAMES[ch]} — ${lastV.toFixed(3)} V`;
    }
  };

  ws.onclose = () => {
    document.getElementById('status').textContent = '❌ 断开,3s 后重连';
    document.getElementById('status').style.color = '#f87171';
    setTimeout(connect, 3000);
  };
}

connect();
</script>
</body>
</html>

PC 端 Python WebSocket 桥接服务(把 TCP 数据转发给浏览器):

# ws_bridge.py — TCP 数据 → WebSocket 桥接
# 运行:python3 ws_bridge.py
# 需要:pip install websockets
import asyncio
import websockets

TCP_PORT = 9000   # 接收来自 Zynq 的数据
WS_PORT  = 8765   # 供浏览器连接

clients = set()

async def ws_handler(ws):
    clients.add(ws)
    print(f"[WS] 客户端连接:{ws.remote_address}")
    try:
        await ws.wait_closed()
    finally:
        clients.discard(ws)
        print(f"[WS] 客户端断开:{ws.remote_address}")

async def tcp_server():
    server = await asyncio.start_server(tcp_handler, '0.0.0.0', TCP_PORT)
    print(f"[TCP] 监听 :{TCP_PORT}")
    async with server:
        await server.serve_forever()

async def tcp_handler(reader, writer):
    addr = writer.get_extra_info('peername')
    print(f"[TCP] Zynq 已连接:{addr}")
    HEADER_SIZE = 32
    BUF_SIZE    = 16384
    PKT_SIZE    = HEADER_SIZE + BUF_SIZE
    buf = b''
    try:
        while True:
            chunk = await reader.read(65536)
            if not chunk:
                break
            buf += chunk
            while len(buf) >= PKT_SIZE:
                pkt = buf[:PKT_SIZE]
                buf = buf[PKT_SIZE:]
                # 广播给所有 WebSocket 客户端
                if clients:
                    await asyncio.gather(*[c.send(pkt) for c in clients],
                                        return_exceptions=True)
    except Exception as e:
        print(f"[TCP] 连接断开:{e}")

async def main():
    await asyncio.gather(
        tcp_server(),
        websockets.serve(ws_handler, 'localhost', WS_PORT)
    )

asyncio.run(main())

6. 性能指标与资源占用

6.1 数据链路延迟分解

环节延迟说明
AD7606B 采样转换4 µsBUSY 高电平时间
SPI 读取(8×16-bit @ 20 MHz)6.4 µs128 bit / 20 MHz
FIR group delay250 µs25 tap @ 100 ksps
DMA 传输(16 KB)< 14 µs1.6 MB/s / 带宽,HP 端口
驱动唤醒 + 用户态读取~100 µs调度延迟,Linux RT patch 可改善
TCP 发送 + 网络~1 ms100M 以太网
PC WebSocket + 浏览器渲染~10 msChart.js 更新
总计~12 ms远低于 50 ms 目标

6.2 FPGA 资源占用(Pynq-Z2,XC7Z020)

模块LUTFFBRAMDSP
ADC SPI 控制器31224800
FIR Compiler(51 tap, 8ch)4821,247224
AXI DMA2,1563,08840
AXI Interconnect1,8432,10200
小计4,793 (9.0%)6,685 (6.3%)6 (4.3%)24 (10.9%)

XC7Z020 总资源:53,200 LUT,106,400 FF,140 BRAM,220 DSP。本项目资源占用很低,还有大量余量扩展。


7. 本篇 checklist

  • AD7606B 的 AVCC 接 5V,DVCC/VIO 接 3.3V,不要混接
  • ADC SPI 控制器 Verilog 编译通过,Vivado behavioral sim 看到正确的 128-bit AXI-Stream 帧
  • FIR .coe 文件已生成,Vivado FIR Compiler 加载后频率响应正确(10 kHz 截止)
  • AXI DMA 配置为 S2MM,连接 HP0 端口,地址映射到 DDR 空间
  • Linux 驱动 probe 成功,/dev/adc-dma 设备节点出现
  • adc_tcp_sender 能持续发送数据,无报错
  • 浏览器打开 adc_monitor.html,8 通道波形正常滚动显示
  • 延迟测试:从采集触发到浏览器波形更新 < 50 ms

8. 下一篇预告

下一篇 《Zynq 实战 27|项目实战二:机器视觉检测系统(MIPI CSI + Vitis AI + HDMI 输出)》

  • MIPI CSI-2 摄像头接入 Pynq-Z2
  • Vitis AI 部署 YOLOv5 目标检测
  • 检测结果叠加到 HDMI 输出
  • 实时推理延迟优化

参考资料

文档号名称用途
AD7606B DatasheetRev. B (2020)SPI 时序图,CONVST/BUSY 时序参数
PG021AXI DMA Product Guide v7.1S2MM 通道配置,Scatter-Gather 描述符格式
PG149FIR Compiler v7.2TDM 多通道配置,AXI4-Stream 接口
UG585Zynq-7000 SoC TRMHP 端口带宽规格,DDR 地址映射
Linux DMAEnginekernel.org/doc/html/latest/driver-api/dmaenginedmaengine_prep_slave_sg 用法
Chart.js 4.xchartjs.org/docs实时图表 animation: false 优化

这是《Zynq FPGA 嵌入式系统设计实战》系列第 26 篇。 ADC 接线、FIR 参数调整、或 Web 显示延迟有问题,欢迎留言。