FPGA实战:利用LPM模块库设计可调波形信号发生器

张开发
2026/4/13 5:33:10 15 分钟阅读

分享文章

FPGA实战:利用LPM模块库设计可调波形信号发生器
1. 从零开始为什么用FPGA和LPM做信号发生器如果你玩过单片机肯定知道用DAC芯片或者PWM也能产生波形那为啥还要折腾FPGA呢我刚开始接触FPGA的时候也有这个疑问直到有一次项目里需要产生一个频率快速跳变、波形还能实时切换的复杂信号用单片机死活搞不定这才体会到FPGA的“硬”实力。FPGA的硬件并行特性让它能像真正的专用电路一样同时干好几件事而且速度极快延迟可预测。这对于信号发生器来说太重要了意味着你输出的波形抖动小、精度高、响应快。那LPM又是什么你可以把它想象成FPGA开发里的“乐高积木库”。Altera现在是Intel官方把一些常用的、复杂的数字电路功能比如存储器RAM/ROM、数学运算乘法器、除法器、时钟管理PLL等做成了参数化模块。你不需要从与非门开始画原理图或者写一堆状态机去实现一个存储器只需要在Quartus II里像填表格一样设置好数据位宽、存储深度、初始化文件它就能给你生成一个优化过的、可靠的内存模块。用LPM一是省事二是性能有保障三是移植性也好。所以用FPGALPM来做可调波形信号发生器核心思路就是把波形的“形状”数据预先算好存进LPM ROM这个“乐高积木”里然后设计一个“读指针”按照你想要的频率和幅度把这些数据读出来。通过按键改变读指针的移动速度频率和读出数据的缩放比例幅度或者干脆换一个存了不同波形数据的ROM波形切换信号就实时变化了。整个过程是纯硬件并行的速度只受限于你FPGA的时钟频率非常灵活和强大。接下来我就手把手带你用Quartus II和一块常见的FPGA开发板思路通用不限于Cyclone V从创建工程到最终用SignalTap看到波形完整走一遍这个实战项目。你会发现看似复杂的系统拆解后每一步都很清晰。2. 核心引擎手把手配置LPM ROM存储波形整个系统的核心就是这个存储波形数据的ROM。你可以把它理解为一个有很多小格子的表格每个格子有一个地址比如从0到63每个格子里存放着一个数字比如0到255。这个数字就代表了在某个时间点波形的高度幅度。我们按顺序快速读出这些格子里的数并转换成电压输出连成的线就是我们要的波形。2.1 第一步准备波形数据文件.mif或.hexQuartus的LPM ROM在初始化时需要知道每个地址里存什么数这个信息来自一个内存初始化文件。最常用的是.mif(Memory Initialization File) 格式。这个文件是文本文件你可以用记事本写但更推荐用脚本生成比如用Python或者MATLAB特别方便。假设我们要生成一个8位精度0-255、64个点的正弦波数据。用Python几行代码就搞定了import numpy as np # 参数设置 num_points 64 # 一个周期的点数 bit_width 8 # 数据位宽最大值 2^8 - 1 255 # 生成正弦波一个周期的数据 x np.linspace(0, 2*np.pi, num_points, endpointFalse) # 0到2π取64个点 sine_wave np.sin(x) # 得到-1到1之间的值 # 将-1到1映射到0到2558位无符号整数 sine_wave_int np.round((sine_wave 1) * (2**bit_width - 1) / 2).astype(int) # 写入.mif文件 with open(sine_wave_64x8.mif, w) as f: f.write(DEPTH 64;\n) # 存储深度即地址数量 f.write(WIDTH 8;\n) # 数据位宽 f.write(ADDRESS_RADIX DEC;\n) f.write(DATA_RADIX DEC;\n) f.write(CONTENT\n) f.write(BEGIN\n) for i, value in enumerate(sine_wave_int): f.write(f {i} : {value};\n) f.write(END;\n) print(sine_wave_64x8.mif 文件生成完毕)运行这段代码你会得到一个sine_wave_64x8.mif文件。用记事本打开内容结构一目了然。同样地你可以修改公式生成方波地址前半部分存255后半部分存0、三角波、锯齿波的数据文件。我建议为每种波形单独生成一个.mif文件比如square_wave.mif,triangle_wave.mif。2.2 第二步在Quartus II中调用并配置LPM ROM打开Quartus II创建好工程后我们通过“MegaWizard Plug-In Manager”来定制我们的ROM积木。启动向导在菜单栏选择Tools-MegaWizard Plug-In Manager。选择“Create a new custom megafunction variation”点击Next。选择模块在左侧目录中展开Storage选择LPM_ROM。注意右边选择你使用的FPGA家族比如Cyclone V和硬件描述语言Verilog HDL。给输出文件起个名比如rom_sine。关键参数配置数据位宽8 bits。这决定了输出数据q的宽度也对应我们.mif文件里的数据范围。存储字数64 words。这就是深度表示我们有64个地址。必须和.mif文件里的DEPTH一致。其他选项如时钟、寄存器输出等可以先按默认。导入初始化文件这是最重要的一步在配置页面找到“指定初始内容”或类似选项。选择“允许内存内容初始化”然后点击“Browse...”找到我们刚才生成的sine_wave_64x8.mif文件。Quartus会读取这个文件把数据“烧录”到ROM的硬件描述中。生成文件一路Next直到Finish。Quartus会生成几个文件其中rom_sine.v或.vhd就是我们可以在顶层模块中直接例化的ROM模块。这个过程就像在芯片内部“雕刻”好了一个装满数据的只读存储器。你只需要关心地址线address输入和数据线q输出至于内部是怎么实现的LPM都帮你优化好了。用同样的方法你可以为方波、三角波各生成一个ROM模块比如rom_square,rom_triangle。3. 搭建系统框架顶层设计与交互逻辑有了存储波形的ROM“仓库”接下来就要设计“搬运工”地址发生器和“控制中心”顶层模块让整个系统动起来。3.1 设计灵活的地址发生器地址发生器的任务很简单在每个时钟上升沿产生一个送给ROM的地址。但这个“产生”的方式决定了波形的频率。最直接的想法是每个时钟地址加1这样读完64个点需要64个时钟周期。如果我的系统时钟是50MHz那么输出波形的频率就是 50MHz / 64 ≈ 781.25kHz。但我们需要频率可调。怎么办一个经典且高效的方法是使用一个累加器。不是每次加1而是加一个可变的步进值step。这个step越大地址累加得越快读完一个波形周期所需的时钟周期数就越少输出频率就越高。reg [31:0] phase_accumulator; // 相位累加器位宽要足够大以保证精度 reg [31:0] frequency_step; // 频率控制字相当于步进值 wire [5:0] rom_address; // 连接到ROM的6位地址线因为2^664 always (posedge clk or negedge rst_n) begin if (!rst_n) begin phase_accumulator 32d0; frequency_step 32h100000; // 初始步进值决定初始频率 end else begin phase_accumulator phase_accumulator frequency_step; end end // 取相位累加器的高6位作为ROM地址 // 这相当于对累加器结果进行了“取模”操作自动在0-63之间循环 assign rom_address phase_accumulator[31:26];这种设计叫做“直接数字频率合成”DDS的核心思想。通过改变frequency_step我们就能线性、高精度地改变输出频率而且切换频率是即时的没有延迟。我在实际项目中常用这种方法频率分辨率非常高。3.2 整合与切换顶层模块的编写顶层模块就像项目的总接线图要把ROM、地址发生器、按键控制、显示驱动全部连起来。这里我给出一个比原始文章更清晰、更易扩展的框架。module waveform_generator_top ( input wire clk_50m, // 50MHz系统时钟 input wire rst_n, // 低电平复位 input wire key_mode, // 波形切换按键 input wire key_freq_up, // 频率增加按键 input wire key_amp_up, // 幅度增加按键 output reg [7:0] dac_data, // 输出给外部DAC的数据或直接当PWM output wire [6:0] seg0, seg1, seg2, // 数码管段选用于显示当前波形、频率、幅度 output wire [5:0] sel // 数码管位选 ); // 内部信号定义 wire [5:0] addr; // ROM地址线 wire [7:0] sine_data, square_data, triangle_data; // 三种波形ROM的输出 reg [1:0] wave_select; // 波形选择寄存器 00:正弦01:方波10:三角波 reg [7:0] amplitude_gain; // 幅度增益例如 8‘d5 表示放大5倍 reg [31:0] freq_step; // 频率控制字 wire key_mode_pulse, key_freq_pulse, key_amp_pulse; // 按键消抖后的脉冲信号 // --- 实例化三个波形ROM --- rom_sine u_rom_sine ( .address(addr), .clock(clk_50m), .q(sine_data) ); rom_square u_rom_square ( .address(addr), .clock(clk_50m), .q(square_data) ); rom_triangle u_rom_triangle ( .address(addr), .clock(clk_50m), .q(triangle_data) ); // --- 实例化地址发生器DDS相位累加器--- phase_accumulator u_phase_acc ( .clk(clk_50m), .rst_n(rst_n), .step(freq_step), // 频率控制字输入 .addr_out(addr) // 6位ROM地址输出 ); // --- 实例化按键消抖模块关键--- // 原始文章里的按键检测是上升沿检测实际必须做消抖处理 key_debounce u_key_mode ( .clk(clk_50m), .rst_n(rst_n), .key_in(key_mode), .key_pulse(key_mode_pulse) // 输出一个时钟周期的脉冲 ); // ... 同样实例化 key_freq_up 和 key_amp_up 的消抖模块 // --- 控制逻辑响应按键脉冲 --- always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin wave_select 2b00; amplitude_gain 8d10; // 默认增益 freq_step 32h200000; // 默认频率控制字 end else begin // 波形切换 if (key_mode_pulse) begin wave_select wave_select 1; if (wave_select 2b10) wave_select 2b00; // 循环 0,1,2 end // 频率增加示例可按需做加减 if (key_freq_pulse) begin freq_step freq_step 32h10000; // 步进增加 // 可以加范围限制防止溢出 end // 幅度增加 if (key_amp_pulse) begin amplitude_gain amplitude_gain 8d1; if (amplitude_gain 8d20) amplitude_gain 8d1; // 限制在1-20倍 end end end // --- 波形数据选择与幅度调整 --- always (*) begin case(wave_select) 2b00: dac_data (sine_data * amplitude_gain) 3; // 相乘后右移相当于除以8防止溢出 2b01: dac_data (square_data * amplitude_gain) 3; 2b10: dac_data (triangle_data * amplitude_gain) 3; default: dac_data sine_data; endcase end // --- 实例化数码管显示驱动模块 --- // 将wave_select, freq_step[31:28], amplitude_gain等转换成BCD码并驱动数码管 seg_display u_seg_disp ( .clk(clk_50m), .rst_n(rst_n), .wave_sel(wave_select), .freq_val(freq_step[31:28]), // 取高位做简易显示 .amp_val(amplitude_gain), .seg0(seg0), .seg1(seg1), .seg2(seg2), .sel(sel) ); endmodule这个顶层模块的结构就非常清晰了按键控制逻辑修改wave_select,freq_step,amplitude_gain这三个核心控制寄存器地址发生器根据freq_step产生循环地址三个ROM根据地址输出原始波形数据数据选择器根据wave_select选通一路数据并与amplitude_gain相乘实现幅度缩放最后显示驱动把状态反馈给用户。4. 让波形动起来按键交互与参数调整细节硬件设计里和人的交互往往是最容易出问题的环节尤其是按键。直接读取按键的电平变化会引入严重的抖动导致一次按键被误认为多次触发。4.1 实现可靠的按键消抖模块我强烈建议使用状态机来实现消抖这是最稳健的方法。下面是一个经过实测的消抖模块代码它能在检测到稳定按下超过20ms后才产生一个单时钟周期的高脉冲。module key_debounce ( input wire clk, // 假设50MHz周期20ns input wire rst_n, input wire key_in, // 按键输入低电平表示按下 output reg key_pulse // 消抖后的脉冲输出 ); localparam IDLE 2b00; localparam DEBOUNCE 2b01; localparam PRESS 2b10; reg [1:0] state, next_state; reg [19:0] cnt; // 20ms计数器 50MHz: 20ms/20ns 1_000_000需要20位计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end always (*) begin case(state) IDLE: if (key_in 1b0) // 按键按下假设低有效 next_state DEBOUNCE; else next_state IDLE; DEBOUNCE: if (key_in 1b1) // 抖动中又弹起了回到空闲 next_state IDLE; else if (cnt 20d999_999) // 稳定按下达到20ms next_state PRESS; else next_state DEBOUNCE; PRESS: if (key_in 1b1) // 按键释放 next_state IDLE; else next_state PRESS; // 保持按下状态 default: next_state IDLE; endcase end // 计数器逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 20d0; else if (state DEBOUNCE) cnt cnt 1b1; else cnt 20d0; end // 输出脉冲在进入PRESS状态的第一个时钟产生脉冲 always (posedge clk or negedge rst_n) begin if (!rst_n) key_pulse 1b0; else if ((state DEBOUNCE) (next_state PRESS)) key_pulse 1b1; else key_pulse 1b0; end endmodule把这个模块例化三次分别给三个按键用你就能得到干净、可靠的控制脉冲了。这是工程稳定性的基础千万别省事。4.2 频率与幅度调节算法优化原始文章里频率和幅度是按2的幂次方跳变的翻倍或减半这在实际使用中可能跳跃太大。我们可以设计更精细的调节。频率调节freq_step的控制字可以设计成按固定步长比如32h1000递增/递减。输出频率f_out (f_step * f_clk) / 2^N其中N是相位累加器的位宽这里用了32位。这样你可以实现非常精细的频率调节。为了显示直观可以做一个简单的换算将freq_step映射到一个易于显示的数值上。幅度调节原始文章用乘法后除以固定数fu/5。我们可以做得更精细。比如幅度增益amplitude_gain从1到20线性可调。在乘法data * gain后为了保持数据在0-255范围内需要进行饱和处理或动态移位。一个更好的办法是将ROM中的数据归一化比如存储为0-127然后乘法后再限制最大值这样动态范围更大。// 改进的幅度调节防止溢出 reg [15:0] multiplied_data; // 临时存储乘法结果位宽扩展 always (*) begin case(wave_select) 2b00: multiplied_data sine_data * amplitude_gain; // ... 其他波形 endcase // 饱和处理如果结果超过255就输出255 dac_data (multiplied_data 255) ? 8d255 : multiplied_data[7:0]; end5. 眼见为实用SignalTap II进行板上实时调试代码写完了仿真也通过了但烧录到FPGA里到底对不对这时候Quartus自带的SignalTap II逻辑分析仪就是你最好的朋友。它相当于一个“数字示波器”可以抓取FPGA内部任何信号的实时变化。用JTAG接口就能看不需要外接仪器。5.1 配置SignalTap抓取波形数据新建SignalTap文件在Quartus II中选择File-New-SignalTap II Logic Analyzer File。添加待观察信号在实例管理器里双击“Double-click to add nodes”。在弹出的节点查找器中过滤出你的顶层模块waveform_generator_top把关键信号加进来clk_50m,rst_n,addr6位总线,dac_data8位总线,wave_select,key_mode_pulse等。特别注意dac_data是我们要观察的最终波形必须添加。设置采样时钟和深度采样时钟就选系统主时钟clk_50m。采样深度决定了你能看到多长时间的波形。对于观察低频波形深度可以设大点比如4096。但深度越大占用FPGA的存储资源M10K就越多。设置触发条件这是抓到你想要波形的关键。比如我们可以设置key_mode_pulse上升沿触发这样一按波形切换键就开始记录数据。或者简单点设置rst_n从低变高后触发即系统启动后开始抓取。编译并编程FPGA像平常一样全编译工程。然后通过Tools-SignalTap II Logic Analyzer打开窗口加载sof文件到FPGA。确保JTAG连接正常。5.2 分析捕获的波形点击“Run Analysis”开始单次采集。触发条件满足后SignalTap就会把这段时间内所有信号的变化记录下来。看dac_data信号将其显示格式改为“Unsigned Line Chart”无符号线图。你就能直接看到模拟的波形曲线切换wave_select你应该能看到波形在正弦、方波、三角波之间变化。看addr信号将其显示为“Unsigned”格式。你会看到它从0递增到63然后循环。当你按下频率增加键后这个循环的速度会明显变快对应freq_step增大。验证按键响应观察key_mode_pulse信号它应该是一个很窄的脉冲一个时钟周期宽并且与wave_select的变化沿对齐。如果看不到预期波形检查几个地方ROM的.mif文件是否正确加载地址addr是否真的在变化dac_data的数据选择逻辑case语句是否写对了SignalTap的强大之处在于它看到的是FPGA内部真实的、实时的信号比仿真更接近实际硬件行为。我调试的时候几乎每个项目都离不开它。6. 进阶与排坑从能用到好用的经验分享做到上面那几步一个基本的信号发生器已经出来了。但想让它更稳定、更实用还有些细节要注意这也是我踩过坑的地方。坑1时序约束与时钟管理。我们的设计目前只有一个50MHz时钟。如果频率控制字freq_step很大地址发生器phase_accumulator的加法器可能会成为关键路径在高时钟频率下出现时序违例。虽然这个设计不复杂但养成好习惯在Quartus里使用TimeQuest Timing Analyzer添加基本的时钟约束create_clock -name clk_50m -period 20.000 [get_ports clk_50m]。更复杂的系统可能会用到PLL来生成更干净、更高频的时钟给DDS部分以提升性能。坑2输出接口与信号质量。dac_data是8位数字信号如何变成真正的模拟电压有两种常用方法外接DAC芯片这是效果最好的。将8位数据总线连接到DAC如AD9708的数字输入DAC会输出平滑的模拟电压。你需要设计一个简单的SPI或并口去配置DAC。PWM模拟如果对波形质量要求不高可以用FPGA的IO口产生PWM再经过一个低通滤波器电阻电容得到模拟电压。把dac_data与一个高速计数器比较输出占空比变化的PWM波。这种方法成本低但带宽和精度有限适合低频信号。坑3资源优化。我们例化了三个独立的ROM来存三种波形。如果波形种类很多会占用大量存储资源。一个优化方案是只用一个ROM但存储空间更大把多种波形数据拼接存储在一起通过偏移地址来切换。比如深度设为256地址0-63存正弦波64-127存方波以此类推。切换波形时只需要给地址发生器输出的addr加上一个基地址偏移base_addr即可actual_addr addr base_addr。坑4扩展性思考。这个项目是一个完美的学习平台你可以在此基础上玩出很多花样增加波形用Python生成任意形状的波形数据心形、噪声、调制信号导入ROM。增加调制功能让幅度增益amplitude_gain不是固定值而是由另一个低频的DDS产生的正弦波控制这样就实现了调幅AM。串口/按键控制用UART接收电脑或上位机的指令来动态设置频率、幅度、波形做成一个可编程信号源。添加显示界面用更复杂的数码管驱动或者OLED屏幕显示更丰富的参数信息。最后想说的是FPGA设计就像搭积木LPM提供了高质量的积木块。这个项目把“存储”、“计算”、“控制”、“交互”这几块最重要的积木都练到了。当你看到SignalTap里那条光滑的正弦曲线随着你的按键跳动变化时那种对硬件直接编程的掌控感是软件编程无法替代的。多动手改参数多观察现象遇到问题先看SignalTap抓到的真实信号大部分疑惑都能迎刃而解。

更多文章