1. 项目概述与核心思路
最近在折腾一个基于Zynq的软件定义无线电(SDR)小项目,核心需求很简单:用硬件逻辑生成一个可调频率的正弦波,并通过DAC输出。这听起来像是数字信号处理的入门练习,但我的目标更具体一点——把它变成一个二进制频移键控(2-FSK)基带发射器。简单说,就是让逻辑电路根据我要发送的二进制数据(0或1),实时切换输出正弦波的频率,一个频率代表“0”,另一个频率代表“1”。
为什么从基带开始?玩过SDR的朋友都知道,一个完整的无线电系统通常有基带、中频和射频好几级。基带是数字比特流和模拟波形最初相遇的地方,也是理解调制原理最直观的起点。在这里,我们只关心如何把一串0101的数字信息,变成对应频率的模拟正弦波信号。用Zynq来实现这个再合适不过了:它的可编程逻辑(PL)部分可以高速、确定性地处理波形生成和切换,而处理系统(PS)部分则方便我们用C语言准备要发送的数据包,并通过DMA高效地喂给PL。
这个项目的价值在于“麻雀虽小,五脏俱全”。它虽然简化了很多商业SDR中的复杂环节(比如成型滤波、自动增益控制等),但完整地展示了从软件数据准备、AXI总线数据传输、到硬件符号映射(Symbol Mapping)和数模转换的整个链路。对于想入门FPGA数字信号处理或SDR设计的朋友来说,这是一个绝佳的、可以亲手实现并看到实际波形的手把手教程。
2. 系统架构与设计考量
2.1 整体数据流设计
整个系统的数据流可以清晰地分为软件(PS)和硬件(PL)两部分,它们通过AXI总线协同工作。
在PS端,我们的C应用程序负责准备要发送的原始数据。比如,我想发送一个字节0xEF(二进制11101111)。这个数据会被写入DDR内存中的一块特定缓冲区(TX Buffer)。然后,我们配置并启动AXI DMA(直接内存访问)控制器,让它以AXI Stream协议的形式,将这个缓冲区里的数据,通过AXI总线“搬运”到PL端的自定义IP核中。DMA的介入至关重要,它把PS从繁重的数据搬运工作中解放出来,让CPU可以去处理其他任务,同时保证了数据传输的高带宽和低延迟。
在PL端,事情变得有趣起来。我们设计了一个自定义的AXI Stream从设备IP核(sin_axis)。它接收来自DMA的数据流(s_axis_tdata)。这个数据是32位宽的,但我们的FSK一次只处理1个比特。所以,IP核内部需要一个并串转换器,把32位数据拆开,一次取出1个比特。这个比特的值(0或1)决定了接下来要输出哪个频率的正弦波。
接下来是核心的符号映射器和波形生成器。对于当前的比特,我们需要生成一个完整周期的、对应频率的正弦波样本。这里我采用了最经典的查表法(LUT)来生成正弦波。我预先计算好了一个正弦函数在一个周期内(0-360度)的离散幅度值,并量化为DAC的输入码值,存储在FPGA的Block RAM中。要生成不同频率的正弦波,本质上就是控制读取这个LUT的速度。频率高,就在每个时钟周期内快速遍历LUT地址;频率低,就慢速遍历。通过两个可配置的周期计数器(T0_period和T1_period),我们就能精确控制代表“0”和“1”的两个正弦波的输出频率。
最后,生成的正弦波样本码值通过AXI Stream主接口(m_axis_tdata)发送给下游的DAC控制器IP(例如Xilinx的AXI4-Stream Digital-to-Analog ConverterIP核),由它转换成真正的模拟电压信号输出。
2.2 关键设计决策与原因
1. 为什么将符号映射器放在AXI Stream FIFO之后?在最初的框图设计中,我曾犹豫符号映射器该放在DMA和FIFO之间,还是FIFO和DAC之间。最终选择了后者。这是因为符号映射器的处理速度取决于当前比特对应的正弦波周期长度。如果DAC输出一个完整正弦波周期需要N个时钟,那么映射器在这N个时钟内只会“消耗”掉1个比特的数据。如果把它放在FIFO前面,当它处理得慢时,会反向“掐住”DMA的AXI Stream通道,可能导致DMA传输停滞。而把它放在FIFO后面,FIFO可以作为一个缓冲池,先让DMA顺畅地把一整包数据(比如32位)快速写入FIFO。然后符号映射器再从容地从FIFO中一点一点读取。这样解耦了数据生产(DMA)和数据消费(符号映射)的速度,系统更稳定。
2. 为什么选择2-FSK和查表法?2-FSK是数字调制中最简单的形式之一,非常适合教学和原理验证。它直观地展示了“频率”这个模拟参量如何承载数字信息。查表法则是FPGA中生成确定性波形(如正弦波、余弦波)最常用、最可靠的方法。它不涉及复杂的实时计算(如CORDIC算法),资源占用可控,且输出信号的相位连续性好——这对于FSK调制减少频谱扩散和接收端解调至关重要。只要LUT的深度(采样点数)足够,输出波形的失真度就可以接受。
3. 关于相位连续性的实现一个优质的FSK信号要求在比特切换时,波形的相位是连续的,不能出现突变,否则会产生不必要的谐波。在我们的设计中,这一点是通过共用同一个相位累加器和正弦LUT来实现的。无论当前发送的是“0”还是“1”,我们都使用同一个角度计数器(degree_cntr)来索引LUT。当比特切换时,我们只是改变了计数器的步进间隔(即period),而计数器的当前值(即相位)是保持连续的。这样就保证了从频率f0切换到f1时,新频率的波形正好从旧频率波形停止的相位点开始,实现了连续相位FSK(CP-FSK)的特性。
3. 硬件逻辑(Verilog)设计与实现细节
3.1 正弦波查找表(LUT)模块
这是整个波形生成的核心。我生成了一个包含360个点的正弦波表,对应0度到359度(一个完整周期)。每个点是一个16位的无符号数,直接对应AD9717 DAC的输入码值。这里假设DAC工作在二进制补码模式,但实际上表中0x0000对应中点电压,0x7FFF对应正满量程,0x8000对应负满量程(虽然表中是16进制,但原理相同)。
`timescale 1ns / 1ps module sin_lut ( input clk, input rst, input [8:0] sel, // 0 to 359 output reg [15:0] DAC_code ); always @(posedge clk) begin if (rst == 1'b0) begin DAC_code <= 16'h0000; end else begin case (sel) 9'd0: DAC_code = 16'h0000; 9'd1: DAC_code = 16'h01C8; // ... 此处省略了中间的358个case语句 9'd359: DAC_code = 16'hFE37; default: DAC_code = 16'h0000; endcase end end endmodule注意:LUT的生成与精度权衡这个LUT有360个点,意味着角度分辨率是1度。对于基带低频信号(比如几KHz到几百KHz),这个精度通常足够了。但如果你需要生成的频率很高,或者对信号质量要求极严,可能需要增加LUT深度(例如512或1024点)来提高波形精度,减少量化噪声。当然,这会消耗更多的Block RAM资源。一个实用的技巧是,如果你只需要90度对称的正弦波,可以只存储0-90度的值,然后通过逻辑映射得到其他象限的值,这样可以节省3/4的存储空间。
3.2 频率控制与相位累加器逻辑
这是实现可变频率输出的关键。我们通过控制读取LUT的“速度”来改变输出频率。
// 频率/周期控制计数器 reg [2:0] period_cntr; // 假设period值很小,所以位宽小 reg incr_degree_cntr; always @ (posedge clk) begin if (rst == 1'b0) begin incr_degree_cntr = 1'b0; period_cntr <= 3'd0; end else begin if (period_cntr == period) begin // period来自当前比特判决 incr_degree_cntr = 1'b1; // 触发角度计数器加1 period_cntr <= 3'd0; end else begin incr_degree_cntr = 1'b0; period_cntr <= period_cntr + 1; end end end // 正弦波角度(相位)累加器 reg [8:0] degree_cntr; reg degree_cntr_done; always @(posedge clk) begin if (rst == 1'b0) begin degree_cntr <= 9'd0; degree_cntr_done <= 1'b0; end else if (incr_degree_cntr == 1'b1) begin if (degree_cntr < 9'd359) begin degree_cntr <= degree_cntr + 1; degree_cntr_done <= 1'b0; end else begin degree_cntr <= 9'd0; // 循环,实现周期性 degree_cntr_done <= 1'b1; // 标志一个完整正弦波周期结束 end end end工作原理:period是一个关键参数。例如,如果系统时钟是100MHz,我们想让正弦波频率为1MHz。一个周期就是100个时钟周期。那么,我们需要每100个时钟周期让degree_cntr从0遍历到359一次。但degree_cntr每次加1,需要360步才能完成一个周期。所以,period应该设置为100 / 360 ≈ 0.278,这显然不是整数。实际上,我们是通过控制incr_degree_cntr的间隔来等效控制频率的。更准确的关系是:输出正弦波频率 = (系统时钟频率) / (period * 360)因此,对于比特“0”和比特“1”,我们只需预设两个不同的period值(T0_period和T1_period),即可得到两个不同的输出频率。
3.3 并串转换与比特判决逻辑
DMA以32位为单位发送数据,但FSK调制需要逐比特处理。
reg [31:0] tdata_slave; // 从AXI Stream接收的32位数据 reg [4:0] tdata_sel_cntr; // 0-31,用于选择当前比特 reg current_tx_bit; reg tx_pkt_done; // 根据计数器选择当前要发送的比特 always @(tdata_sel_cntr) begin case(tdata_sel_cntr) 5'd0: begin current_tx_bit <= tdata_slave[0]; tx_pkt_done <= 1'b0; end 5'd1: begin current_tx_bit <= tdata_slave[1]; tx_pkt_done <= 1'b0; end // ... 5'd2 到 5'd30 5'd31: begin current_tx_bit <= tdata_slave[31]; tx_pkt_done <= 1'b1; end // 32位发完 default: begin current_tx_bit <= 1'b0; tx_pkt_done <= 1'b0; end endcase end // 每完成一个正弦波周期,切换到下一个比特 always @ (posedge clk) begin if (rst == 1'b0) begin tdata_sel_cntr <= 5'd0; end else begin if (degree_cntr_done == 1'b1) begin // 一个正弦波周期结束 tdata_sel_cntr <= tdata_sel_cntr + 1; end end end // 根据当前比特决定输出频率对应的period always @ (posedge clk) begin if (current_tx_bit == 1'b1) period <= T1_period; else period <= T0_period; end这个设计清晰地展示了2-FSK的局限性:数据速率受限于最慢频率的正弦波周期。如果代表“0”的正弦波周期是100个时钟,那么发送1个比特至少需要100个时钟周期,数据速率最高就是系统时钟频率的1/100。你不能在一个正弦波周期还没结束时就发送下一个比特。
3.4 AXI Stream接口状态机
这个模块封装了上述所有逻辑,并实现了标准的AXI Stream从机和主机接口,用于与DMA和DAC控制器对接。
`timescale 1ns / 1ps module sin_axis ( input clk, input reset, // AXI Stream Slave Interface (from DMA) input [31:0] s_axis_tdata, input [3:0] s_axis_tkeep, input s_axis_tlast, output reg s_axis_tready, input s_axis_tvalid, // AXI Stream Master Interface (to DAC Controller) output reg [31:0] m_axis_tdata, output reg [3:0] m_axis_tkeep, output reg m_axis_tlast, input m_axis_tready, output reg m_axis_tvalid, output [2:0] state_reg ); // 实例化核心的符号映射与波形生成模块 sin_sm sin_sm_i ( .clk(clk), .rst(reset), .tdata_slave(tdata_slave), .tdata_master(tdata_master), .tx_pkt_done(tx_pkt_done) ); // 状态机定义 parameter INIT = 3'd0; parameter SET_SLAVE_TREADY = 3'd1; parameter CHECK_SLAVE_TVALID = 3'd2; parameter PROCESS_TDATA = 3'd3; parameter CHECK_TLAST = 3'd4; reg [2:0] state_reg; reg tlast; reg [31:0] tdata_slave; wire tx_pkt_done; wire [31:0] tdata_master; always @(posedge clk) begin if (reset == 1'b0) begin // 复位所有输出和状态 state_reg <= INIT; s_axis_tready <= 1'b0; // ... 其他信号复位 end else begin case(state_reg) INIT: begin // 初始化内部寄存器 state_reg <= SET_SLAVE_TREADY; end SET_SLAVE_TREADY: begin s_axis_tready <= 1'b1; // 告知DMA:我准备好接收数据了 state_reg <= CHECK_SLAVE_TVALID; end CHECK_SLAVE_TVALID: begin if (s_axis_tvalid == 1'b1) begin // DMA有有效数据 s_axis_tready <= 1'b0; // 拉低ready,实现握手机制 tdata_slave <= s_axis_tdata; // 锁存数据 tlast <= s_axis_tlast; state_reg <= PROCESS_TDATA; // 开始处理这32位数据 end end PROCESS_TDATA: begin m_axis_tvalid <= 1'b1; m_axis_tdata <= tdata_master; // 连接波形生成模块的输出 m_axis_tkeep <= 4'hf; // 表示所有字节有效 m_axis_tlast <= tlast; // 传递帧结束标志 // 等待DAC控制器接收,并且当前32位数据处理完毕 if (m_axis_tready == 1'b1 && tx_pkt_done == 1'b1) { state_reg <= CHECK_TLAST; } end CHECK_TLAST: begin if (m_axis_tlast == 1'b1) begin // 如果是数据包的最后一帧 state_reg <= INIT; // 回到初始状态,准备接收下一个包 end else begin state_reg <= SET_SLAVE_TREADY; // 继续接收当前包的下一个32位数据 end end endcase end end endmodule实操心得:AXI Stream握手机制写AXI Stream接口时,最需要小心的是
tready和tvalid的握手机制。tvalid由发送方置高表示数据有效,tready由接收方置高表示可以接收。只有在同一个时钟上升沿,两者同时为高时,数据传输才发生。在我们的状态机里,SET_SLAVE_TREADY状态拉高s_axis_tready,当检测到s_axis_tvalid也为高时,立刻在CHECK_SLAVE_TVALID状态拉低s_axis_tready,并锁存数据。这是一种常见的“每拍握手”模式,确保不会丢失数据。同时,作为主设备向DAC控制器发送时,我们在PROCESS_TDATA状态拉高m_axis_tvalid,并等待对方的m_axis_tready。这个握手保证了下游模块能来得及处理我们发送的每一个波形数据点。
4. 软件(Vitis)应用程序详解
硬件逻辑负责“怎么发”,而软件则负责“发什么”。PS端的C程序非常简单,核心就是配置DMA并启动传输。
#include "xaxidma.h" #include "xil_cache.h" // 定义DMA设备ID和缓冲区 #define DMA_DEV_ID XPAR_AXIDMA_0_DEVICE_ID #define TX_BUFFER_BASE (0x01000000) // DDR中的地址,需与链接脚本匹配 #define MAX_PKT_LEN 4 // 我们先发送4个字节(32位)数据测试 XAxiDma AxiDma; int main() { init_platform(); // 初始化硬件平台 XAxiDma_Config *CfgPtr; int Status, Index; u8 *TxBufferPtr; // 1. 准备发送缓冲区 TxBufferPtr = (u8 *)TX_BUFFER_BASE; for (Index = 0; Index < MAX_PKT_LEN; Index++) { TxBufferPtr[Index] = 0x00; // 先清零 } // 填充测试数据:0xEF (二进制 11101111) TxBufferPtr[0] = 0xEF; // 2. 刷新数据缓存,确保DMA能看到最新数据 // 因为CPU有缓存,而DMA直接访问DDR,不经过缓存。 // 必须将缓存中的数据写回DDR,否则DMA可能读到旧数据。 Xil_DCacheFlushRange((UINTPTR)TxBufferPtr, MAX_PKT_LEN); // 3. 查找并初始化DMA CfgPtr = XAxiDma_LookupConfig(DMA_DEV_ID); if (!CfgPtr) { xil_printf("DMA 配置查找失败! "); return XST_FAILURE; } Status = XAxiDma_CfgInitialize(&AxiDma, CfgPtr); if (Status != XST_SUCCESS) { xil_printf("DMA 初始化失败! "); return XST_FAILURE; } // 4. 禁用中断(本例使用轮询模式) XAxiDma_IntrDisable(&AxiDma, XAXIDMA_IRQ_ALL_MASK, XAXIDMA_DMA_TO_DEVICE); // 5. 复位DMA(可选,确保干净状态) XAxiDma_Reset(&AxiDma); // 6. 启动MM2S(内存到流)传输 Status = XAxiDma_MM2Stransfer(&AxiDma, (UINTPTR)TxBufferPtr, MAX_PKT_LEN); if (Status != XST_SUCCESS) { xil_printf("DMA 传输启动失败! "); return XST_FAILURE; } // 7. (在实际应用中,这里可以轮询DMA状态或等待中断,然后准备下一次传输) // 本例简单,发送完即结束。 cleanup_platform(); return 0; }关键点解析:缓存一致性
Xil_DCacheFlushRange这行代码极其重要,也是新手最容易出错的地方。Zynq的ARM CPU有数据缓存(Cache)。当你写TxBufferPtr[0] = 0xEF;时,这个数据可能只是写到了CPU的缓存里,并没有立刻更新到真正的DDR内存中。而DMA传输是直接从DDR物理内存中取数据的,它绕过了CPU的缓存。如果不调用刷新函数,DMA很可能读到一片全是0的旧内存区域。所以,任何由CPU准备、交由DMA传输的数据,在启动DMA前必须刷新其对应的缓存区间。对应的,如果DMA将数据从外设写入了DDR,CPU在读取前也需要调用Xil_DCacheInvalidateRange来无效化缓存,以便从DDR重新加载。
5. Vivado工程搭建与IP集成
5.1 Block Design 设计
在Vivado中,我们需要创建一个Block Design来连接所有IP核。
- Zynq Processing System:双击添加,根据你的板卡型号配置DDR、时钟等。确保使能一个HP口(High Performance AXI port)用于DMA访问DDR。
- AXI DMA:添加AXI Direct Memory Access IP。配置为“读通道”(MM2S)使能,数据宽度32位,流接口数据宽度同样32位。将
S_AXI_LITE连接到Zynq的M_AXI_GP(通用AXI)用于软件配置,将M_AXI_MM2S和S_AXI_S2MM(本例未用)连接到Zynq的S_AXI_HP口用于高速数据传输。M_AXIS_MM2S(输出流)连接到我们自定义IP的s_axis接口。 - AXI4-Stream Data FIFO:添加FIFO IP,用于缓冲DMA和自定义逻辑之间的数据。配置为AXI Stream接口,异步时钟可选(如果DMA和逻辑时钟同源则同步),设置合适的深度(如1024)。
- 自定义IP
sin_axis:将我们编写的sin_axis.v封装成IP核(Tools -> Create and Package IP)。将其s_axis接口连接到FIFO的M_AXIS,m_axis接口连接到DAC控制器。 - AXI4-Stream Data FIFO(可选,第二个):如果需要缓冲自定义IP到DAC控制器的数据,可以再加一个。
- AXI4-Stream Digital-to-Analog Converter:这是Xilinx的DAC控制器IP(可能需要单独license或使用第三方开源IP)。将其
s_axis接口连接到自定义IP或FIFO的m_axis。配置其并行数据宽度、采样率等与你的DAC芯片(如AD9717)匹配。 - 时钟与复位:将Zynq的
FCLK_CLK0(例如100MHz)连接到所有IP的aclk,将FCLK_RESET0_N连接到所有IP的aresetn(低电平复位)。
5.2 引脚约束与时钟管理
DAC控制器的并行输出端口(dac_data,dac_clk等)需要分配到FPGA的物理引脚上,并与板卡上的AD9717芯片连接。在XDC约束文件中添加类似语句:
set_property PACKAGE_PIN Y11 [get_ports {dac_data[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {dac_data[*]}] create_clock -name dac_clk -period 10.000 [get_ports dac_clk_p] # 假设100MHz DAC时钟特别注意DAC时钟(dac_clk)的生成。它通常需要非常低的抖动(jitter)。如果对信号质量要求高,应该使用Zynq的PL端时钟管理单元(MMCM/PLL)从主时钟(如100MHz)分频或倍频出所需的DAC采样时钟,并确保这个时钟与数据路径的时钟有明确的相位关系。
6. 测试、验证与结果分析
6.1 硬件测试设置
- 编译与下载:在Vivado中综合、实现、生成比特流,并下载到Zynq开发板。
- 连接示波器:将AD9717的模拟输出通道(通常是差分对,需通过巴伦转换为单端)连接到数字示波器的一个通道。
- 软件调试:在Vitis中导入应用程序工程,配置好调试器。在
main函数中调用XAxiDma_MM2Stransfer之前设置一个断点。
6.2 执行与观测
- 在Vitis中启动调试,程序会停在断点处。
- 在示波器上,将触发模式设置为“边沿触发”,触发电平设为略高于零电平(例如100mV)。
- 让程序继续运行(跳过断点),DMA传输启动。
- 观察示波器屏幕。
预期结果:你应该能看到一个模拟波形,其频率在两种状态之间切换。由于我们发送的数据是0xEF(二进制11101111),所以波形会按照“高-高-高-低-高-高-高-高”的模式,在频率f1(代表1)和f0(代表0)之间切换。每个频率持续的时间正好是一个完整正弦波的周期。
6.3 结果分析与优化方向
成功现象:示波器上清晰显示两个不同频率的正弦波片段交替出现,频率切换点波形平滑,没有明显的相位跳变或毛刺。这证明我们的基带FSK发射器基本功能正常。
潜在问题与排查:
没有波形输出:
- 检查电源和时钟:确认FPGA和DAC芯片供电正常,所有时钟(PL主时钟、DAC时钟)都有信号。
- 检查DMA传输:在Vitis中单步调试,确认
XAxiDma_MM2Stransfer函数返回XST_SUCCESS。可以在C代码中printf传输状态。 - 检查AXI Stream链路:使用Vivado的ILA(集成逻辑分析仪)IP核,抓取自定义IP核
s_axis和m_axis接口上的tvalid,tready,tdata信号。确认数据从DMA正确到达IP核,并且IP核有数据输出给DAC控制器。 - 检查DAC配置:确认DAC控制器IP配置正确,并正确驱动了AD9717的SPI配置接口,使DAC芯片处于正常工作模式。
波形频率不对:
- 计算
period参数:根据公式输出频率 = 系统时钟 / (period * LUT深度)反推period值。在Verilog代码中检查T0_period和T1_period的设定值。 - 检查时钟频率:确认Vivado中为设计约束的系统时钟频率与实际板载晶振频率一致。
- 计算
波形失真严重:
- LUT精度不足:360点的LUT对于较高频率可能精度不够。尝试增加LUT深度到512或1024点。
- DAC性能或接口问题:检查DAC的采样时钟是否稳定,数据建立保持时间是否满足DAC芯片的时序要求。可能需要调整DAC控制器IP的输出延迟。
项目扩展与优化:
- 提高数据速率:当前的比特率受限于最慢频率的周期。可以采用更高阶的调制(如4-FSK,用4个频率代表2个比特),在相同符号周期内传输更多信息。
- 添加成型滤波器:直接切换频率产生的信号频谱较宽。可以在DAC之前插入一个数字成型滤波器(如升余弦滚降滤波器),限制信号带宽,使其更符合信道要求。
- 实现完整收发链路:在另一块板卡上实现一个匹配的FSK接收器(包括ADC、解调、位同步等),构建一个点对点的无线通信演示系统。
- 集成更高级的SDR框架:将本设计封装成AXI4-Lite可配置的IP,频率、符号率等参数可由PS动态配置。甚至可以尝试集成到开源的SDR框架(如RFNoC)中。
这个基于Zynq的简单FSK发射器项目,就像一把钥匙,打开了用FPGA实现软件定义无线电的大门。它虽然基础,但涵盖了从软硬件协同、数据流设计、到具体调制算法实现的完整链条。当你看到示波器上那串随着自己编写的代码而跳动的波形时,那种将数字世界和模拟世界连接起来的成就感,正是硬件设计的魅力所在。希望这个详细的拆解能帮助你顺利复现,并以此为起点,探索更广阔的无线通信世界。