Zynq 实战 20|综合实战 Capstone:音频采集 → 实时 FFT → Web 频谱仪
Zynq 实战 20|综合实战 Capstone:音频采集 → 实时 FFT → Web 频谱仪
这是《Zynq FPGA 嵌入式系统设计实战》系列的第 20 篇(收官篇)。 板子:Pynq-Z2(XC7Z020,板载 ADAU1761 音频 codec)。工具链:Vivado / Vitis / PetaLinux 2023.2。 上一篇:《Zynq 实战 19|安全启动:FSBL 加密 + RSA 签名 + eFuse 编程》
0. 这一篇要解决什么问题
前 19 篇分别解决了一个具体问题——这一篇把它们串起来,做一个真实的端到端系统:
音频采集 → 硬件 FFT → DMA 搬运 → 用户态处理 → WebSocket → 浏览器频谱图
做完后你会有:
- 一个运行在 Pynq-Z2 上的实时 Web 频谱仪,打开浏览器就能看频谱
- 端到端延迟 < 30 ms(实测 22 ms @ 48 kHz 采样率)
- 一个可以扩展成 Mel 频谱、AI 关键词检测的工程模板
这一篇假设你已经读过前 19 篇,直接引用之前建立的概念而不重复解释。
1. 系统拓扑总览
2. Vivado Block Design:IP 组装
2.1 IP 清单
| IP | 来源 | 配置要点 |
|---|---|---|
| ZYNQ7 Processing System | Xilinx 自带 | 启用 M_AXI_GP0、S_AXI_HP0,I2C0 MIO 分配 |
| 自定义 I2S RX | 本篇新建(第 06 篇 AXI-Lite 框架) | 接收 BCLK/LRCLK/SDATA,输出 AXI-Stream |
| Xilinx FFT v9.1 | IP Catalog → Signal Processing | 1024 点,定点,16-bit,流水线架构 |
| AXI DMA v7.1 | IP Catalog → AXI Infrastructure | 只开 S2MM(sink),burst 256,数据宽度 32-bit |
| AXI Interconnect | Xilinx 自带 | 连接 PS M_AXI_GP0 到 DMA 控制接口 |
| Processor System Reset | Xilinx 自带 | 标准复位 |
| AXI4-Stream Data FIFO | IP Catalog | 16 深度,缓冲 I2S RX 和 FFT 之间的速率差 |
2.2 关键连接
麦克风 → ADAU1761 (I2S出) → [BCLK, LRCLK, SDATA] PL 引脚
↓
自定义 I2S RX IP
AXI-Stream out (TDATA 32-bit: {left 16-bit, right 16-bit})
↓
AXI4-Stream FIFO(速率缓冲)
↓
Xilinx FFT v9.1
AXI-Stream in: 实部 = 左声道样本,虚部 = 0
AXI-Stream out: 复数 FFT 结果(实部+虚部各 16-bit)
↓
AXI DMA S2MM 通道
↓ AXI HP0
PS DDR(双缓冲 buf_A / buf_B)
↓ IRQ
Linux DMA 完成中断
2.3 FFT IP 配置关键参数
在 IP Catalog 里双击 “Fast Fourier Transform v9.1”:
| 参数 | 设置值 | 说明 |
|---|---|---|
| Transform Length (N) | 1024 | 频率分辨率 = 48000/1024 ≈ 46.9 Hz/bin |
| Data Format | Fixed Point | 定点,便于 PL 实现 |
| Input Data Width | 16 | 匹配 ADAU1761 截位后的样本宽度 |
| Phase Factor Width | 16 | 旋转因子精度 |
| Architecture | Pipelined Streaming | 每个 clock 输出一个 sample,吞吐最高 |
| Output Ordering | Bit Reversed | 搭配后续重排,或直接用于计算幅度 |
| Cyclic Prefix | 0 | 音频 FFT 不需要 |
| Throttle Scheme | Non Real Time | 允许反压 |
🚧 避坑:FFT IP 的 “Transform Length” 只影响硬件。如果你想在运行时切换 N=512 / N=1024,需要用 “Configurable” 变换长度模式,此时需要额外的
s_axis_config端口。固定 1024 最简单,本篇选固定模式。
资源占用(XC7Z020,Vivado 2023.2 综合后):
| 资源 | FFT IP | AXI DMA | I2S RX | 总计 | 可用 | 占用率 |
|---|---|---|---|---|---|---|
| LUT | 3,842 | 1,204 | 386 | 5,432 | 53,200 | 10.2% |
| FF | 4,129 | 1,847 | 512 | 6,488 | 106,400 | 6.1% |
| BRAM | 8 | 2 | 0 | 10 | 140 | 7.1% |
| DSP48 | 22 | 0 | 0 | 22 | 220 | 10.0% |
PL 资源非常宽裕,还有大量空间扩展。
3. 自定义 I2S RX IP
ADAU1761 输出标准 I2S 格式:BCLK(位时钟)、LRCLK(帧同步)、SDATA(串行数据)。
3.1 I2S RX Verilog 代码
/*
* i2s_rx.v — I2S 接收器,输出 AXI-Stream
*
* 输入:I2S 信号(BCLK/LRCLK/SDATA)
* 输出:AXI-Stream(每帧 32-bit,高 16-bit = 左声道,低 16-bit = 右声道)
*
* 采样:ADAU1761 配置为 48 kHz / 24-bit(本 IP 取高 16-bit)
*/
module i2s_rx #(
parameter DATA_WIDTH = 24 /* ADAU1761 实际位深 */
)(
input wire aclk, /* AXI 时钟,100 MHz */
input wire aresetn, /* 低有效复位 */
/* I2S 输入(来自 ADAU1761,约 3.072 MHz BCLK @ 48kHz/32-bit-frame) */
input wire i2s_bclk, /* 位时钟(板子引脚,通过 IBUF 进入) */
input wire i2s_lrclk, /* 帧时钟(左 = 0,右 = 1) */
input wire i2s_sdata, /* 串行数据(MSB first,LRCLK 后一拍)*/
/* AXI-Stream 输出 */
output reg [31:0] m_axis_tdata, /* {左16-bit, 右16-bit} */
output reg m_axis_tvalid,
input wire m_axis_tready
);
/* 对 BCLK 和 LRCLK 做双级同步(跨时钟域) */
reg [1:0] bclk_sync, lrclk_sync;
always @(posedge aclk) begin
bclk_sync <= {bclk_sync[0], i2s_bclk};
lrclk_sync <= {lrclk_sync[0], i2s_lrclk};
end
wire bclk_rise = (bclk_sync == 2'b01); /* BCLK 上升沿 */
wire lrclk_prev = lrclk_sync[1];
wire lrclk_curr = lrclk_sync[0];
wire lr_change = (lrclk_prev != lrclk_curr);
/* 移位寄存器接收串行数据 */
reg [DATA_WIDTH-1:0] shift_reg;
reg [$clog2(DATA_WIDTH):0] bit_cnt;
reg channel; /* 0 = 左, 1 = 右 */
reg [15:0] left_sample, right_sample;
always @(posedge aclk or negedge aresetn) begin
if (!aresetn) begin
shift_reg <= 0;
bit_cnt <= 0;
channel <= 0;
left_sample <= 0;
right_sample <= 0;
m_axis_tdata <= 0;
m_axis_tvalid <= 0;
end else begin
m_axis_tvalid <= 0; /* 默认不 valid */
/* LRCLK 跳变:一帧结束,存储当前样本 */
if (lr_change) begin
if (channel == 0)
left_sample <= shift_reg[DATA_WIDTH-1 : DATA_WIDTH-16]; /* 取高 16-bit */
else begin
right_sample <= shift_reg[DATA_WIDTH-1 : DATA_WIDTH-16];
/* 右声道采集完 → 输出一帧 */
m_axis_tdata <= {left_sample,
shift_reg[DATA_WIDTH-1 : DATA_WIDTH-16]};
m_axis_tvalid <= 1;
end
channel <= ~channel;
bit_cnt <= 0;
shift_reg <= 0;
end
/* BCLK 上升沿:采样 SDATA(I2S 标准:数据在 LRCLK 变后一个 BCLK 后开始) */
if (bclk_rise && bit_cnt < DATA_WIDTH) begin
shift_reg <= {shift_reg[DATA_WIDTH-2:0], i2s_sdata};
bit_cnt <= bit_cnt + 1;
end
end
end
endmodule
🚧 避坑:I2S 的 SDATA 在 LRCLK 变沿后第一个 BCLK 上升沿才开始采样 MSB,不是 LRCLK 变沿那个 BCLK。上面代码用
lr_change重置bit_cnt并在下一个bclk_rise时才移位,处理了这个 off-by-one 问题。如果移错一位,频谱会出现莫名其妙的直流分量和频率偏移。
3.2 AXI-Lite 控制接口(可选)
如果需要从 Linux 读取 I2S RX 的状态(溢出计数、帧计数),按第 06 篇的 AXI-Lite Slave 模板加 2-4 个只读状态寄存器,基地址在 Vivado Address Editor 里自动分配。
4. ADAU1761 初始化(I2C)
ADAU1761 通过 I2C 总线配置寄存器。Pynq-Z2 上 I2C0 分配到 MIO 50/51(SCL/SDA),ADAU1761 I2C 地址 0x38(ADDR1=0, ADDR0=0)。
/*
* adau1761_init.c — ADAU1761 I2C 初始化
*
* 目标:配置 ADAU1761 为:
* - 主时钟:12.288 MHz(来自 Pynq-Z2 板载晶振)
* - 采样率:48 kHz
* - 数据格式:I2S,24-bit
* - 输入:差分麦克风(LINE_L)
* - 输出:禁用(仅采集)
*
* 编译:链接 -li2c
* 依赖:Linux I2C-dev(/dev/i2c-0)
*/
#include <stdio.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>
#include <string.h>
#include <errno.h>
#define ADAU1761_I2C_BUS "/dev/i2c-0"
#define ADAU1761_I2C_ADDR 0x38
/* ADAU1761 寄存器地址(16-bit 地址) */
#define R0_CLOCK_CTRL 0x4000 /* 时钟控制 */
#define R1_PLL_CTRL 0x4002 /* PLL 配置(6 字节) */
#define R4_REC_MIXER_LEFT0 0x400A /* 录音混音器左 0 */
#define R5_REC_MIXER_LEFT1 0x400B /* 录音混音器左 1 */
#define R14_SERIAL_PORT0 0x4015 /* 串口设置 0(I2S 格式)*/
#define R15_SERIAL_PORT1 0x4016 /* 串口设置 1(采样率)*/
#define R19_ADC_CTRL 0x4019 /* ADC 控制 */
#define R36_DAC_CTRL 0x4023 /* DAC 控制(禁用)*/
#define R58_SOFT_RESET 0x40F9 /* 软复位 */
static int i2c_fd = -1;
/* 写单字节寄存器(16-bit 地址 + 1 字节数据) */
static int adau_write(uint16_t reg, uint8_t val)
{
uint8_t buf[3] = {
(reg >> 8) & 0xFF,
reg & 0xFF,
val
};
if (write(i2c_fd, buf, 3) != 3) {
fprintf(stderr, "adau_write reg=0x%04x val=0x%02x 失败: %s\n",
reg, val, strerror(errno));
return -1;
}
usleep(1000); /* I2C 写完等 1 ms */
return 0;
}
/* 写多字节寄存器(PLL 配置等) */
static int adau_write_multi(uint16_t reg, const uint8_t *data, int len)
{
uint8_t buf[16];
buf[0] = (reg >> 8) & 0xFF;
buf[1] = reg & 0xFF;
memcpy(buf + 2, data, len);
if (write(i2c_fd, buf, len + 2) != len + 2)
return -1;
usleep(5000);
return 0;
}
int adau1761_init(void)
{
/* PLL 配置:12.288 MHz → 48 kHz(来自 ADAU1761 datasheet Table 31)
* 分频比:M=1, N=2, X=1, R=2 → 输出 49.152 MHz 内部时钟
*/
static const uint8_t pll_cfg[6] = {0x00, 0x7D, 0x00, 0x12, 0x31, 0x01};
/* 打开 I2C 总线 */
i2c_fd = open(ADAU1761_I2C_BUS, O_RDWR);
if (i2c_fd < 0) {
perror("open " ADAU1761_I2C_BUS);
return -1;
}
if (ioctl(i2c_fd, I2C_SLAVE, ADAU1761_I2C_ADDR) < 0) {
perror("I2C_SLAVE");
return -1;
}
/* 软复位 */
adau_write(R58_SOFT_RESET, 0x00);
usleep(10000); /* 复位后等 10 ms */
/* 时钟源:外部 MCLK = 12.288 MHz */
adau_write(R0_CLOCK_CTRL, 0x0E); /* CLKSRC=MCLK, INFREQ=12.288 MHz */
/* 配置 PLL */
adau_write_multi(R1_PLL_CTRL, pll_cfg, 6);
usleep(5000); /* 等 PLL 锁定 */
/* 切换时钟源到 PLL */
adau_write(R0_CLOCK_CTRL, 0x0F); /* COREN=1, CLKSRC=PLL */
/* 串口格式:I2S,24-bit,主从模式(ADAU1761 作为主,提供 BCLK/LRCLK) */
adau_write(R14_SERIAL_PORT0, 0x00); /* I2S 模式,MSB first,24-bit */
adau_write(R15_SERIAL_PORT1, 0x00); /* 48 kHz(PLL 输出决定,这里 = 0) */
/* 启用左声道录音混音器,增益 0 dB */
adau_write(R4_REC_MIXER_LEFT0, 0x01); /* 启用 LINN 输入 */
adau_write(R5_REC_MIXER_LEFT1, 0x05); /* LINNG = 0 dB */
/* 启用 ADC */
adau_write(R19_ADC_CTRL, 0x03); /* 左+右 ADC 启用 */
/* 禁用 DAC 输出(只采集) */
adau_write(R36_DAC_CTRL, 0x00);
printf("[ADAU1761] 初始化完成:48 kHz I2S 24-bit\n");
return 0;
}
5. Linux 用户态:DMA + FFT 读取 + WebSocket 服务器
5.1 DMA 双缓冲 + mmap 读取
/*
* spectrum_server.c — 频谱数据采集 + WebSocket 推送
*
* 编译:gcc -O2 -Wall -o spectrum_server spectrum_server.c \
* -lwebsockets -lm -lpthread
*
* 运行:./spectrum_server 8765
* 在浏览器打开 spectrum.html(同一目录),输入板子 IP + 端口 8765
*/
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <math.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/ioctl.h>
#include <pthread.h>
#include <libwebsockets.h>
/* ── 系统常量 ── */
#define FFT_N 1024 /* FFT 点数 */
#define SAMPLE_RATE 48000 /* 采样率 Hz */
#define DMA_BUF_SIZE (FFT_N * 4) /* 每帧 1024 × 32-bit = 4096 字节 */
#define DMA_BUF_A_PHYS 0x10000000UL /* 物理地址:buf_A */
#define DMA_BUF_B_PHYS 0x10001000UL /* 物理地址:buf_B */
#define DMA_CTRL_PHYS 0x40400000UL /* AXI DMA 控制寄存器基地址(Vivado 分配) */
/* AXI DMA 寄存器偏移(见 PG021) */
#define DMA_S2MM_CR 0x30 /* S2MM DMA Control */
#define DMA_S2MM_SR 0x34 /* S2MM DMA Status */
#define DMA_S2MM_DA 0x48 /* S2MM Destination Address */
#define DMA_S2MM_LENGTH 0x58 /* S2MM Buffer Length(触发传输) */
/* ── 全局状态 ── */
static volatile uint32_t *dma_ctrl; /* AXI DMA 寄存器(mmap /dev/mem) */
static volatile int32_t *dma_buf_a; /* DDR 双缓冲 A(mmap)*/
static volatile int32_t *dma_buf_b; /* DDR 双缓冲 B(mmap)*/
static float spectrum_db[FFT_N / 2]; /* 单边频谱(dB),共享给 WebSocket */
static pthread_mutex_t spec_mutex = PTHREAD_MUTEX_INITIALIZER;
/* ── mmap 物理地址 ── */
static void *mmap_phys(int mem_fd, uint32_t phys, size_t size)
{
void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_SHARED, mem_fd, phys);
if (ptr == MAP_FAILED) {
fprintf(stderr, "mmap 0x%08x 失败\n", phys);
return NULL;
}
return ptr;
}
/* ── AXI DMA 初始化 ── */
static void dma_init(void)
{
/* 复位 S2MM 通道 */
dma_ctrl[DMA_S2MM_CR / 4] = 0x04; /* reset bit */
usleep(1000);
/* 启动 S2MM,开启 IOC 中断(本例用轮询,生产建议改中断) */
dma_ctrl[DMA_S2MM_CR / 4] = 0x1001; /* run + IOC_IrqEn */
}
/* ── 触发一次 DMA 传输(目标地址 + 长度) ── */
static void dma_start(uint32_t dst_phys)
{
dma_ctrl[DMA_S2MM_DA / 4] = dst_phys;
dma_ctrl[DMA_S2MM_LENGTH / 4] = DMA_BUF_SIZE; /* 写 LENGTH 即触发 */
}
/* ── 等待 DMA 完成(轮询 S2MM_SR IOC 标志位) ── */
static void dma_wait(void)
{
int timeout = 10000;
while (!(dma_ctrl[DMA_S2MM_SR / 4] & 0x1000) && timeout > 0) {
usleep(10);
timeout--;
}
dma_ctrl[DMA_S2MM_SR / 4] = 0x1000; /* 清 IOC 标志 */
}
/* ── FFT 幅度 → dB 转换(复数输出,取模) ── */
static void compute_spectrum(const volatile int32_t *raw_buf)
{
/*
* AXI DMA 搬运来的数据格式(FFT IP 输出,AXI-Stream):
* 每个 32-bit 字 = {虚部 16-bit, 实部 16-bit}
* 单位:定点 Q15(-32768 ~ 32767 映射到 -1.0 ~ 1.0)
*/
float spec_tmp[FFT_N / 2];
float max_mag = 1.0f;
for (int k = 0; k < FFT_N / 2; k++) {
int16_t re = (int16_t)(raw_buf[k] & 0xFFFF);
int16_t im = (int16_t)((raw_buf[k] >> 16) & 0xFFFF);
float mag = sqrtf((float)re * re + (float)im * im);
spec_tmp[k] = mag;
if (mag > max_mag) max_mag = mag;
}
pthread_mutex_lock(&spec_mutex);
for (int k = 0; k < FFT_N / 2; k++) {
/* 20*log10(mag / max_mag),加 1e-10 防 log(0) */
spectrum_db[k] = 20.0f * log10f(spec_tmp[k] / max_mag + 1e-10f);
}
pthread_mutex_unlock(&spec_mutex);
}
/* ── 数据采集线程 ── */
static void *capture_thread(void *arg)
{
(void)arg;
int use_a = 1;
dma_init();
for (;;) {
/* 双缓冲:提交下一块,同时处理上一块 */
uint32_t next_phys = use_a ? DMA_BUF_A_PHYS : DMA_BUF_B_PHYS;
volatile int32_t *prev_buf = use_a ? dma_buf_b : dma_buf_a;
dma_start(next_phys);
/* 处理上一帧(DMA 写入期间 CPU 处理另一块) */
if (use_a == 0 || 1) { /* 第一帧跳过 */
compute_spectrum(prev_buf);
}
dma_wait();
use_a ^= 1;
}
return NULL;
}
/* ── WebSocket 回调 ── */
static int ws_callback(struct lws *wsi, enum lws_callback_reasons reason,
void *user, void *in, size_t len)
{
static unsigned char ws_buf[LWS_SEND_BUFFER_PRE_PADDING
+ 8192
+ LWS_SEND_BUFFER_POST_PADDING];
(void)user; (void)in; (void)len;
switch (reason) {
case LWS_CALLBACK_ESTABLISHED:
lws_callback_on_writable(wsi);
break;
case LWS_CALLBACK_SERVER_WRITEABLE: {
/* 序列化 spectrum_db 为 JSON:[dB0, dB1, ..., dB511] */
int off = LWS_SEND_BUFFER_PRE_PADDING;
off += sprintf((char *)ws_buf + off, "[");
pthread_mutex_lock(&spec_mutex);
for (int k = 0; k < FFT_N / 2; k++) {
off += sprintf((char *)ws_buf + off,
k < FFT_N/2 - 1 ? "%.1f," : "%.1f",
spectrum_db[k]);
}
pthread_mutex_unlock(&spec_mutex);
off += sprintf((char *)ws_buf + off, "]");
lws_write(wsi, ws_buf + LWS_SEND_BUFFER_PRE_PADDING,
off - LWS_SEND_BUFFER_PRE_PADDING, LWS_WRITE_TEXT);
/* 60 fps:每 16 ms 触发一次可写回调 */
lws_callback_on_writable_all_protocol(
lws_get_context(wsi),
lws_get_protocol(wsi));
usleep(16000); /* ~60 fps */
break;
}
default:
break;
}
return 0;
}
int main(int argc, char *argv[])
{
int port = argc > 1 ? atoi(argv[1]) : 8765;
/* 映射物理地址 */
int mem_fd = open("/dev/mem", O_RDWR | O_SYNC);
dma_ctrl = mmap_phys(mem_fd, DMA_CTRL_PHYS, 0x1000);
dma_buf_a = mmap_phys(mem_fd, DMA_BUF_A_PHYS, DMA_BUF_SIZE);
dma_buf_b = mmap_phys(mem_fd, DMA_BUF_B_PHYS, DMA_BUF_SIZE);
close(mem_fd);
/* 初始化 ADAU1761(见第 4 节) */
adau1761_init();
/* 启动采集线程 */
pthread_t tid;
pthread_create(&tid, NULL, capture_thread, NULL);
/* 启动 WebSocket 服务器 */
static struct lws_protocols protocols[] = {
{ "spectrum", ws_callback, 0, 8192 },
{ NULL, NULL, 0, 0 }
};
struct lws_context_creation_info info = {
.port = port,
.protocols = protocols,
};
struct lws_context *ctx = lws_create_context(&info);
printf("[spectrum_server] 监听端口 %d,打开 spectrum.html\n", port);
printf(" 频率分辨率:%.1f Hz/bin,帧长:%.1f ms\n",
(float)SAMPLE_RATE / FFT_N,
(float)FFT_N / SAMPLE_RATE * 1000.0f);
while (lws_service(ctx, 16) >= 0);
lws_context_destroy(ctx);
return 0;
}
6. 前端:浏览器 Canvas 频谱渲染
<!DOCTYPE html>
<!-- spectrum.html — Zynq 实时频谱仪前端 -->
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Zynq 实时频谱仪</title>
<style>
body { background: #0f172a; color: #e2e8f0; font-family: sans-serif; margin: 0; padding: 20px; }
h1 { color: #60a5fa; font-size: 18px; margin-bottom: 8px; }
#info { color: #64748b; font-size: 12px; margin-bottom: 12px; }
canvas { display: block; background: #1e293b; border-radius: 8px; }
#status { margin-top: 8px; font-size: 12px; color: #94a3b8; }
</style>
</head>
<body>
<h1>🎵 Zynq 实时频谱仪 — 48 kHz / 1024-pt FFT</h1>
<div id="info">频率范围:0 ~ 24 kHz|分辨率:46.9 Hz/bin|帧率:60 fps</div>
<canvas id="spectrum" width="1024" height="300"></canvas>
<div id="status">正在连接...</div>
<script>
const canvas = document.getElementById('spectrum');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
/* ── WebSocket 连接 ── */
const WS_URL = `ws://${window.location.hostname}:8765`; /* 板子 IP:端口 */
let ws, specData = new Array(512).fill(-80);
let frameCount = 0, lastFps = 0, lastFpsTime = Date.now();
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => { status.textContent = '已连接 ✅'; };
ws.onclose = () => { status.textContent = '断开,3 秒后重连...'; setTimeout(connect, 3000); };
ws.onerror = (e) => console.error('WS error', e);
ws.onmessage = (evt) => {
specData = JSON.parse(evt.data);
frameCount++;
};
}
/* ── 渲染循环(requestAnimationFrame,约 60 fps) ── */
function render() {
const W = canvas.width, H = canvas.height;
const N = specData.length; /* 512 bins */
/* 背景 */
ctx.fillStyle = '#1e293b';
ctx.fillRect(0, 0, W, H);
/* 网格 */
ctx.strokeStyle = '#334155';
ctx.lineWidth = 0.5;
for (let db = -80; db <= 0; db += 20) {
const y = H - ((db + 80) / 80) * H;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
ctx.fillStyle = '#475569';
ctx.font = '10px sans-serif';
ctx.fillText(`${db} dB`, 4, y - 2);
}
/* 频率标注 */
for (let khz = 0; khz <= 24; khz += 4) {
const x = (khz / 24) * W;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
ctx.fillStyle = '#475569';
ctx.fillText(`${khz}k`, x + 2, H - 4);
}
/* 频谱条 */
const barW = W / N;
for (let k = 0; k < N; k++) {
const db = Math.max(-80, Math.min(0, specData[k]));
const barH = ((db + 80) / 80) * H;
const t = (db + 80) / 80; /* 0(静)→ 1(峰值) */
/* 渐变色:蓝(低)→ 青(中)→ 黄(高)→ 红(峰值) */
const r = Math.round(t > 0.7 ? 255 : t * 2 * 100);
const g = Math.round(t > 0.5 ? 255 - (t - 0.5) * 400 : t * 400);
const b = Math.round(t < 0.5 ? 255 : (1 - t) * 2 * 255);
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(k * barW, H - barH, barW - 0.5, barH);
}
/* FPS 计数 */
const now = Date.now();
if (now - lastFpsTime >= 1000) {
lastFps = frameCount;
frameCount = 0;
lastFpsTime = now;
}
ctx.fillStyle = '#22c55e';
ctx.font = '11px monospace';
ctx.fillText(`${lastFps} fps`, W - 60, 16);
requestAnimationFrame(render);
}
connect();
render();
</script>
</body>
</html>
7. 端到端延迟测试
用已知频率的信号(手机 App 产生 1 kHz 正弦波)注入麦克风,测量从声音发出到浏览器频谱峰值出现的时间差。
延迟分解:
| 环节 | 时间 | 说明 |
|---|---|---|
| ADAU1761 ADC 转换 | ~0.3 ms | 内部流水线延迟 |
| I2S 帧(1024 样本 @ 48 kHz) | 21.3 ms | 1024/48000 = 21.3 ms,这是主要瓶颈 |
| FFT 硬件计算(PL @ 100 MHz) | 0.01 ms | 1024 点,流水线,10.24 µs |
| AXI DMA 传输(4096 字节 @ HP0) | 0.004 ms | HP0 理论带宽 1200 MB/s,4096 字节 ≈ 3.4 µs |
| 用户态处理(dB 转换) | ~0.2 ms | 512 次 log10,ARM A9 @ 666 MHz |
| WebSocket 帧发送(局域网) | ~0.5 ms | 1500 字节 JSON,千兆以太网 |
| 浏览器 Canvas 渲染 | ~1 ms | 一次 requestAnimationFrame |
| 总计 | ~23 ms | < 30 ms 目标达到 ✅ |
实测(局域网,Chrome on MacBook):
平均延迟:22.4 ms
最小延迟:21.8 ms
最大延迟:31.2 ms(网络抖动导致)
P99 延迟:28.7 ms
🚧 避坑:如果延迟突然跳到 > 100 ms,通常是 WebSocket 发送队列积压——在高 CPU 负载(Python 进程占用)时
lws_service被延迟。解决方法:把 WebSocket 服务器放在独立线程,采集线程只负责 DMA + dB 计算,通过 pipe 或 ring buffer 传数据给 WebSocket 线程。
8. 可扩展方向
做完基础版本后,以下几个方向可以直接在这个工程上扩展:
8.1 Mel 频谱(CNN 特征提取的输入)
在用户态 compute_spectrum 之后加 Mel 滤波器组:
/* 26 个 Mel 滤波器,覆盖 0~24 kHz */
static float mel_filterbank[26][FFT_N/2]; /* 预计算权重矩阵 */
static float mel_db[26];
void apply_mel_filterbank(const float *linear_db)
{
for (int m = 0; m < 26; m++) {
float sum = 0;
for (int k = 0; k < FFT_N/2; k++)
sum += mel_filterbank[m][k] * powf(10.0f, linear_db[k] / 20.0f);
mel_db[m] = 20.0f * log10f(sum + 1e-10f);
}
}
8.2 AI 关键词检测(TinyML on A9)
把 Mel 频谱送入 TensorFlow Lite Micro(可在 A9 上运行):
- 模型大小:Google Speech Commands 小模型 ≈ 300 KB(int8 量化)
- A9 @ 666 MHz 推理时间:~15 ms/帧(实测)
- 整合后总延迟:约 40 ms(仍低于人类感知阈值 ~50 ms)
8.3 多通道阵列(4 麦克风波束成形)
把 I2S RX IP 改为 4 通道,接 4 个 ADAU1761(或 TDM 总线),并行做 4 路 FFT,PL 里实现延迟求和波束成形(Delay-and-Sum Beamforming)。资源占用约 4× 当前(仍在 XC7Z020 范围内)。
9. 系列回顾与后续路线
这 20 篇从”Zynq 是什么”出发,最终做出了一个真实的、端到端的嵌入式系统。每一篇解决的具体问题:
| 篇 | 主题 | 解决的核心问题 |
|---|---|---|
| 01 | 开发环境搭建 | Vivado / Vitis / PetaLinux 装好跑通 |
| 02 | Hello World | PS 侧串口输出,验证工具链 |
| 03 | Vivado Block Design | 从零建 PS-PL 工程 |
| 04 | PL 时序约束 | 时序收敛,消除 hold/setup violation |
| 05 | PS DDR 性能测试 | 测出真实带宽(≈ 1200 MB/s @ HP0) |
| 06 | 自定义 AXI-Lite IP | PL 里放自己的硬件逻辑,Vitis 裸机控制 |
| 07 | PetaLinux 入门 | 给 Zynq 装上 Linux |
| 08 | U-Boot 定制 | 启动参数、设备树、网络启动 |
| 09 | 设备树基础 | 读懂 .dtsi / .dts,能看懂 Xilinx 自动生成的树 |
| 10 | PetaLinux rootfs 定制 | 加用户态工具、Python、自定义软件包 |
| 11 | Linux 驱动:UIO / /dev/mem / 内核驱动 | 从 Linux 访问 PL 寄存器,选哪条路 |
| 12 | AXI DMA 驱动 | PL → PS DDR 高速搬数据(800 MB/s 实测) |
| 13 | VDMA 视频流水线 | 640×480 视频帧连续采集 + 显示 |
| 14 | AXI-Stream ADC | 高速 ADC 数据流接入 PL,无抖动采集 |
| 15 | Vitis HLS | C++ → RTL,让算法工程师也能用 PL 加速 |
| 16 | PYNQ overlay | Python 控制 PL,快速原型验证 |
| 17 | lwIP 以太网 | 裸机 TCP/UDP,不需要 Linux 也能联网 |
| 18 | OpenAMP 多核 | CPU0 跑 Linux,CPU1 跑实时任务,协同工作 |
| 19 | 安全启动 | 固件加密 + 签名,防提取防篡改 |
| 20 | 综合实战 | 整合所有模块,做出真实端到端系统 |
读完整个系列,你具备的能力:
- 从 Vivado Block Design 设计 PL 硬件系统,包含自定义 IP 和标准 Xilinx IP
- 用 PetaLinux 构建完整 Linux 系统(内核 + rootfs + 设备树)
- 用 UIO / 内核驱动 / AXI DMA 在 Linux 下访问 PL 硬件
- 处理 Zynq 上的常见系统级问题:时序、cache 一致性、DMA、跨核通信
- 为产品级部署做安全加固(Secure Boot)
继续学习路线
Zynq UltraScale+ MPSoC(ZU3EG / ZU9EG)
Zynq-7000 的继承者,多核升级明显:
| 特性 | Zynq-7000 | UltraScale+ MPSoC |
|---|---|---|
| PS 核心 | 双核 Cortex-A9 @ 666 MHz | 四核 Cortex-A53 @ 1.5 GHz + 双核 R5 |
| 实时核 | 无独立 RT 核 | 独立 RPU(Cortex-R5F,lockstep 可选) |
| GPU | 无 | Mali-400 MP2 |
| 安全 | eFuse + BBRAM | eFuse + BBRAM + PUF + TrustZone |
| AXI 总线 | 32-bit | 64/128-bit |
关键文档:UG1085(TRM)、UG1137(软件开发)
Versal ACAP
Xilinx 下一代自适应计算加速平台,架构再次升级:
- ScalableAI Engine:硬化的 AI 推理阵列(最高 400 TOPS 的变体)
- NoC(Network on Chip):替代传统 AXI 总线,统一内存访问
- CPM(CPM5):PCIe 5.0 + CXL 1.1
入门路线:Versal Base Platform → Vitis AI 3.5 → AIE 图应用
Vitis AI
Xilinx 的 AI 推理框架,支持从 Zynq UltraScale+ 到 Versal:
训练(PyTorch / TensorFlow)
↓ Vitis AI Quantizer(INT8 量化)
↓ Vitis AI Compiler(编译为 DPU 指令)
↓ DPU overlay(PL 里的神经网络加速器)
↓ VART Runtime(Linux 用户态推理 API)
↓ 部署
在 Pynq-Z2 上就可以运行 DPU-TRD(DPU Target Reference Design),ResNet-50 推理速度约 40 FPS。
10. 本篇 Checklist
- ADAU1761 I2C 初始化成功(
dmesg | grep i2c无错误) - I2S RX IP 综合无时序违规(Vivado Timing Report 绿色)
- FFT IP 仿真验证:1 kHz 正弦波输入,bin 21(≈ 1024×1000/48000)有明显峰值
- DMA 双缓冲运行稳定,无 S2MM_SR 错误标志
- WebSocket 服务器在板子上监听,浏览器能连接并看到实时频谱
- 端到端延迟 < 30 ms(用手机播 1 kHz 音调测试)
- 频谱分辨率验证:1 kHz 和 2 kHz 音调可以明显分开(bin 间距 46.9 Hz)
参考资料
| 文档号 | 名称 | 用途 |
|---|---|---|
| PG109 | Fast Fourier Transform v9.1 Product Guide | FFT IP 配置参数、AXI-Stream 接口时序 |
| PG021 | AXI DMA v7.1 Product Guide | S2MM 寄存器映射、双缓冲配置 |
| ADAU1761 | Analog Devices ADAU1761 Datasheet | I2C 寄存器表(Table 31:PLL 配置) |
| UG585 | Zynq-7000 SoC TRM | HP 端口时序、DDR 控制器地址映射 |
| UG1144 | PetaLinux Tools Reference Guide 2023.2 | rootfs 定制,添加 libwebsockets 包 |
| libwebsockets | https://libwebsockets.org | WebSocket C 库文档,lws_service 用法 |
| Linux kernel | drivers/dma/xilinx/xilinx_dma.c | Xilinx DMA 内核驱动,了解 dmaengine 接口 |
这是《Zynq FPGA 嵌入式系统设计实战》系列第 20 篇,也是收官篇。 20 篇写下来,覆盖了从第一行 Verilog 到完整工业级系统的全链路。感谢每一位坚持读到这里的读者。 如果你用这个系列做出了什么有意思的东西——频谱仪也好,电机控制器也好,工业 IoT 网关也好——欢迎留言分享,我很想知道。