做信号处理的朋友应该遇到过这种场景前端信号一会儿大得削顶一会儿小得被底噪吃掉后面ADC、FFT或者解调模块根本没法干活。AGC自适应增益控制就是专门解决这个问题的模块简单说就是自动把信号的幅度拉到一个合适的水平。最近我用Verilog在FPGA上完整实现了一套AGC逻辑并用Vivado跑完了仿真验证整个过程踩了不少坑也把一些容易忽略的细节理清了。这篇文章把设计思路、核心代码、仿真方法和问题排查都整理出来适合正在做FPGA信号处理、或者想了解AGC怎么落地到硬件的人参考。1. AGC系统整体设计与思路拆解1.1 为什么在FPGA上做AGC先聊方案选型。AGC并不是FPGA独有模拟AGC电路用VGA加检波器就能做DSP里用C语言几十行也能写那为什么还要在FPGA上做模拟AGC的优点是没有采样延迟响应非常快但它的控制电压、检波回路受温漂影响大参数一旦定下来很难现场调整。如果信号带宽很宽模拟AGC的环路设计会变得相当费劲调试需要反复换阻容参数不是咱们这群写RTL的人擅长的活。DSP或ARM做AGC的优点是代码灵活改一个系数就能换一种行为但缺点是采样率越高实时性越难保证。假如ADC输出是100MSPS每个样本都得实时算增益并乘回去普通处理器很难在每个采样周期内完成整套操作。FPGA正好卡在中间它的乘法和加法都是并行流水线几十上百个通道可以同时做AGC延迟也能做到固定且很低。数字AGC和ADC、下变频、滤波、FFT这些模块在同一个芯片里无缝衔接不需要额外的DAC和模拟反馈回路。当然FPGA方案的代价是定点数处理容易踩坑后文的量化误差、溢出、振荡问题都跟这有关。1.2 系统架构与关键指标拆解我设计的数字AGC结构不复杂本质是一个负反馈环路。输入信号先进入滑动窗口平均模块估算当前幅度估算值和目标电平比较后按步进调整增益值增益再经过平滑处理避免突然跳变最后把输入样本和当前增益相乘输出稳定幅度的信号。整个环路可以拆成四个模块幅度检测取绝对值后做滑动窗口平均得到输入信号的平均幅度估计。增益更新将估计幅值与目标电平比较落入死区则不调整否则按固定步进加大或减小增益。增益平滑对增益做一阶IIR滤波控制增益变化速率避免波形跳变。增益相乘输入信号与增益相乘并做定标和饱和处理。在设计之前要先把几个指标定下来。我这里用的是16位signed输入输出也是16位signed。期望输出幅度设为4096左右对应满量程的1/8左右这个值取太小浪费动态范围取太大会给后续增益余量留得过小。需要注意的是AGC目标幅度要考虑输出位宽不能定得离满量程太近否则输入突增的瞬间仍然会削顶。其他关键参数如下参数设计取值说明输入数据位宽16位signed对应ADC输出目标输出幅度4096约满量程的1/8滑动窗口长度256点兼顾响应速度和抗噪增益格式Q4.1216位整数4位小数12位增益步进16/4096 ≈ 0.0039每个控制周期调整量增益范围0 ~ 4.0支持衰减和放大死区±256防止输出在目标附近抖动平滑系数1/16相当于一次控制更新只走1/16的误差这些参数不是拍脑袋定的。比如滑动窗口长度如果采样率是1MHzAGC控制周期大概是256个采样点每秒钟约3900次增益更新用来跟踪话音或中频信号是够的。如果想让响应更快就把窗口裁短如果信号本身噪声大就适当加长。2. 核心算法原理与Verilog实现要点2.1 幅度检测滑动窗口平均的实现技巧幅度检测我选的是“绝对值的滑动窗口平均”而不是峰值检测。峰值检测对单个毛刺太敏感一个尖峰就能让增益骤降RMS检测更平滑但FPGA里做开方要额外资源工程上常用平均绝对值近似效果已经足够好。平均绝对值估计的是输入的“平均整流值”对正弦波来说和RMS只差一个固定系数而AGC本来就不需要精确幅度只需要一个稳定单调的估计量。滑动窗口平均的Verilog实现关键是怎么做到每个时钟都能更新出最新平均值。如果用计数器遍历窗口里的所有数据那每个样本都要做N次加法资源不可接受。正确做法是维护一个累加和每来一个新样本就把最旧的那个样本从累加和里减掉再加上新样本。这样无论窗口多长每个时钟都只要一次加法和一次减法。下面是一个可综合的参考写法module sliding_window_avg #( parameter DATA_WIDTH 16, parameter WIDTH_LOG 8, // 窗口长度 2^WIDTH_LOG parameter ACC_WIDTH DATA_WIDTH WIDTH_LOG )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] data_in, output wire [ACC_WIDTH-1:0] avg_out ); localparam DEPTH 1 WIDTH_LOG; reg signed [DATA_WIDTH-1:0] mem [0:DEPTH-1]; reg [$clog2(DEPTH)-1:0] wptr; reg signed [ACC_WIDTH-1:0] sum_reg; wire signed [ACC_WIDTH-1:0] cur_data data_in; wire signed [ACC_WIDTH-1:0] old_data mem[wptr]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin wptr 0; sum_reg 0; end else begin sum_reg sum_reg cur_data - old_data; mem[wptr] data_in; wptr wptr 1b1; end end assign avg_out sum_reg[ACC_WIDTH-1:WIDTH_LOG]; endmodule这里有几个细节值得注意。mem[wptr]读出来的是当前指针位置上的旧数据正好是被新样本顶掉的数写入和指针递增都放在同一个always块里操作顺序就是“先读旧值再写新值指针加一”这是循环队列的基本用法。avg_out把累加和右移WIDTH_LOG位等于除以窗口长度。ACC_WIDTH留了足够的进位余量防止累加和溢出。另外如果要算绝对值平均在把data_in送入这个模块前先做一次取绝对值也就是判断符号位。取绝对值逻辑很简单但要注意数据位宽扩展不能把最小值映射成正值时溢出通常先把输入扩展成ACC_WIDTH宽的有符号数再处理。2.2 增益更新步进式调整与死区设计幅度估计出来之后下一步是决定增益该怎么变。最直接的思路是“输入幅度除以目标幅度”得到瞬时增益。但这在FPGA里并不好用首先是除法器资源太贵其次瞬时增益随噪声剧烈跳动输出会被调制得很差。更稳妥的做法是步进式调整也就是每个控制周期只让增益朝目标方向移动一个固定步长形成一个数字域的“积分器”环路。增益更新可以用下面这个逻辑描述localparam signed [15:0] GAIN_MIN 16d0; localparam signed [15:0] GAIN_MAX 16d4096; // 1.0 localparam signed [15:0] GAIN_STEP 16d16; // 16/4096 ≈ 0.0039 localparam [15:0] TARGET_ABS 16d4096; localparam [15:0] DEAD_ZONE 16d256; reg [15:0] abs_in; reg [15:0] avg_abs; reg signed [15:0] gain_target; always (posedge clk or negedge rst_n) begin if (!rst_n) begin gain_target GAIN_MAX; end else begin if (avg_abs TARGET_ABS - DEAD_ZONE) begin gain_target gain_target GAIN_STEP; end else if (avg_abs TARGET_ABS DEAD_ZONE) begin gain_target gain_target - GAIN_STEP; end // 死区内部不更新保留原值 end end死区很关键。如果没有死区只要估计幅度和目标有一点点偏差增益就会不停调整最终在目标值附近反复震荡宏观上看输出波形就是一条锯齿轮廓。死区大小要和滑动窗口长度、输入噪声一起权衡。窗口越长平均值的噪声越小死区可以设小一点窗口短死区就要适当加大。还要考虑增益饱和。gain_target是有符号数如果不加限制一直加下去会越过GAIN_MAX变成负值输出相位都会被反转。我试验时发现不加饱和的版本在输入幅度持续很小的时候增益会一路涨到溢出信号相位直接反了。所以最后的代码里加了min/max限制这一步不能省。2.3 增益平滑与attack/release参数步进式更新能让增益不会瞬间跳变但step本身还是有点“硬”。增益直接从一个值跳到另一个值输出波形幅度会跟着台阶跳音频里能听到明显的“嚓嚓”噪音。因此我在增益更新后面又加了一级一阶IIR平滑parameter SMOOTH_SHIFT 4; // 平滑系数 1/16 reg signed [15:0] gain_sm; always (posedge clk or negedge rst_n) begin if (!rst_n) begin gain_sm 16d4096; end else begin gain_sm gain_sm (($signed(gain_target - gain_sm)) SMOOTH_SHIFT); end end这个其实就是数字低通滤波器的递归形式。gain_target是目标增益gain_sm是当前实际增益两者之差除以16以后累加回去。系数越大跟踪越快但输出波动也大系数越小越平滑但收敛变慢。对于音频信号attack通常会希望比release快也就是信号突然变大时要迅速压下来信号变小后可以慢慢恢复避免声音“喘气”。要做到不对称只需要把SMOOTH_SHIFT拆成两个参数一个给正值误差用一个给负值误差用。gain_sm和data_in相乘时要注意位宽。假如gain_sm是16位data_in是16位乘积就是32位。我这里用Q4.12格式表示增益实际放大倍数等于gain_sm/4096所以乘法结果要右移12位再经过饱和截位得到16位输出。如果不做截位直接取高位小信号输出可能出现明显的量化台阶如果右移后直接截断而不饱和大信号又会溢出回绕输出出现毛刺。推荐做法是先做饱和判断再截位。具体可以写成wire signed [31:0] mul_raw data_in * gain_sm; reg signed [15:0] data_out; always (posedge clk or negedge rst_n) begin if (!rst_n) begin data_out 0; end else begin if (mul_raw 16sd32767 12) data_out 16sd32767; else if (mul_raw -16sd32768 12) data_out -16sd32768; else data_out mul_raw[27:12]; end end这里的移位写法是针对Q4.12格式和16位signed输出的实际工程中建议把数据位宽和定标位置都用parameter统一管理不要像我第一次那样把魔数散落得到处都是后面改位数改到怀疑人生。3. Vivado工程实现与仿真验证3.1 工程搭建、时钟复位与基本约束Vivado里建工程这一步很多人已经熟了我重点说几个容易忽略的准备动作。首先是复位策略强烈建议使用同步复位或者使用“异步复位、同步释放”的电路。直接外接按键或者控制器的异步复位释放时刻如果落在时钟边沿附近会触发复位信号亚稳态导致模块内部部分寄存器复位成功、部分没复位成功仿真里看不到上板就很难查。后面第4节会专门说这个问题。顶层接口我按AXI-Stream风格定义方便挂到其他IP旁边module agc_top #( parameter DATA_WIDTH 16 )( input wire clk, input wire rst_n, input wire s_axis_tvalid, input wire signed [DATA_WIDTH-1:0] s_axis_tdata, output reg m_axis_tvalid, output reg signed [DATA_WIDTH-1:0] m_axis_tdata );这样做的好处是后面接FFT、FIR或者DMA都不需要额外转换逻辑。内部例化前面写的滑动窗口模块、增益更新模块、平滑模块和乘法输出模块。如果是在完整的AXI-Stream链路上用还需要把tvalid做同样的流水延迟保证和tdata对齐这个细节在仿真时很容易漏。约束文件只写基础时钟约束就可以让工程顺利跑起来。我通常会在XDC里放两行create_clock -period 10.000 -name sys_clk [get_ports clk] set_input_delay -clock sys_clk 2.5 [get_ports s_axis_tdata]如果输入数据和时钟之间有确定关系set_input_delay可以填实际PCB布线长度折算的延时没有精确数据时可以先用一个估计值按时序报告再调整。IO约束不写或者乱写是后面implement design变红的常见原因。顺便说一句被问很多次的“布局和布线区别”。布局也就是place阶段决定每个逻辑单元落在哪个SLICE、哪个BANK布线也就是route阶段决定不同逻辑单元之间信号走哪些可编程互连资源。Vivado把place和route放在implement流程里自动完成正常场景不需要手动干预但如果资源利用率超过百分之八九十或者约束非常紧这两个阶段都可能因为拥塞而失败直接报错红一片。3.2 Testbench设计与幅度变化激励仿真验证的测试激励核心目标是模拟真实输入信号的动态范围变化。我这里写了一个简易testbench用正弦查找表加幅度控制来生成信号。正弦波可以调用Verilog系统函数$sin代码虽然不能被综合但仿真完全没问题。module tb_agc; reg clk; reg rst_n; reg signed [15:0] stimulus; wire signed [15:0] agc_out; agc_top dut ( .clk(clk), .rst_n(rst_n), .s_axis_tvalid(1b1), .s_axis_tdata(stimulus), .m_axis_tvalid(), .m_axis_tdata(agc_out) ); always #5 clk ~clk; real phase; real amp 0.2; integer k; initial begin clk 0; rst_n 0; stimulus 0; #100; rst_n 1; for (k 0; k 200000; k k 1) begin (posedge clk); if (k 30000) amp 0.8; // 幅度从小变大 if (k 60000) amp 0.05; // 幅度突然变小 if (k 150000) amp 0.4; // 中等幅度恢复 phase 2.0 * 3.14159265 * 0.01 * k; stimulus $rtoi(amp * 32000.0 * $sin(phase)); end $finish; end endmodule这里幅度序列覆盖了三种典型场景小信号需要放大、大信号需要压缩、中等信号稳定保持。窗口长度是256点正常1MHz采样时每个场景跑几万个点足够看到完整收敛过程。如果你想更严苛可以再加一段快速扫频信号或者混入窄带干扰检验AGC抗突变能力。有些朋友习惯用MATLAB生成二进制文件然后用$readmemh灌进去这也是一种更可控的思路但代价是额外的数据生成脚本。如果只是验证AGC逻辑用$sin加上乘法控制幅度已经够用关键是要把幅度切换时刻和切换大小记清楚方便在波形上对号入座。3.3 仿真波形分析与AGC收敛判据在Vivado里跑行为仿真建议把这几个信号放进wave窗口输入stimulus、输出agc_out、估计幅度avg_abs、目标增益gain_target、平滑增益gain_sm。看波形的时候不能只看输出稳定还要关心收敛速度和增益值是否合理。我这次仿真得到比较典型的几个现象输入从0.2倍满量程跳到0.8倍满量程后avg_abs瞬间抬高gain_target开始按步进往下走经过大概6000个时钟周期输出幅度回落到目标附近。这个长度对应约23个控制周期和窗口256点、步进0.0039的预期吻合。若想更快可以把步进加大或窗口减短但代价是稳态波动变大。输入从0.8倍满量程突变到0.05倍满量程输出先掉得很低然后增益缓慢爬升恢复时间明显比压缩时间更长。这是因为我在平滑系数里做了不对称设置release比attack慢。音频场景下这是合理设计。稳态时avg_abs落在TARGET_ABS ± 死区范围内gain_sm没有持续震荡输出幅度波动幅度大约在±5%以内。如果出现明显的锯齿波动说明死区太小或平滑系数太大需要回过去调参。仿真判据可以这样写目标输出幅度为4096允许±10%偏差最终连续N个采样点都落在这个范围内就认为AGC收敛。把收敛时间和最大超调量记录下来作为参数调整的量化依据。只靠眼睛看波形不靠谱特别是面对上百个信号时没有数值判据就是在凭感觉做设计。之后如果要在板级验证可以在Vivado里添加调试ILA核把gain_sm、avg_abs和输出数据引到调试探针上实时抓一段信号观察。ILA没有额外约束时往往会占用额外BRAM仿真阶段一定要先确认逻辑正确再上板。4. 常见问题与工程排查经验4.1 增益振荡量化误差与窗口长度的博弈我第一个版本在输入信号比较小时发现输出幅度在目标附近来回抖动幅度包络看起来像锯齿波。定位下来有三个因素叠加窗口长度只有64点平均绝对值的抖动本身就大死区设成0增益步进1/4096太小导致误差总是累积到超过门限才调整。三者凑一起增益就变成“慢速锯齿振荡”。解决办法分三步先把窗口长度加到256点让估计值平滑下来然后把死区设为±256滤掉小误差最后把步进从8调到16保证一旦开始调整能更快回到目标区域。改完后输出包络明显干净很多。这个案例说明AGC参数不是单独调的窗口长度、死区、步进三个参数必须联调。每次只改一个变量的习惯在这种多变量耦合的问题上会浪费大量时间。4.2 复位信号亚稳态异步复位的隐形雷用外部按键或者控制器给FPGA发复位信号时如果复位释放瞬间正好落在时钟上升沿附近触发器可能出现亚稳态。也就是说有些寄存器认为复位已经结束有些认为还在复位导致状态错乱。这种问题在仿真里几乎不可能复现因为testbench里的rst_n总是跟时钟对齐或错开固定时间真实板卡上磕磕碰碰就会触发。业内标准做法是“异步复位同步释放”。核心代码就几行reg rst_n_r1, rst_n_r2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rst_n_r1 1b0; rst_n_r2 1b0; end else begin rst_n_r1 1b1; rst_n_r2 rst_n_r1; end end wire rst_n_sync rst_n_r2;把外部的异步复位先打两拍得到和时钟对齐的复位信号rst_n_sync然后内部所有模块都用这个同步后的复位。主动学习和产品设计里这已经是基操了但还是经常看到直接把复位接进所有always块的项目出问题再抓头皮就晚了。4.3 Vivado实现阶段报错的定位思路“implement design变红”应该是FPGA开发群里出现频率最高的问题之一。变红本身只是Vivado告诉你implementation没过去但原因千差万别。我总结了一张速查表报错现象可能原因排查思路时序报告显示Setup违反时钟约束不正确或逻辑路径太长打开timing summary找最差路径增加流水级放宽约束Hold违反复位释放异常或跨时钟域未处理检查CDC逻辑用同步器LUT/FF利用率过高代码逻辑重复、参数过大看utilization优化算法或裁剪位宽IO错误XDC引脚分配冲突检查引脚复用、bank电压布局布线拥塞设计资源密集调综合策略、提高优化级别定位这类问题不要一上来就看错误日志末尾。正确顺序是先打开Open Implemented Design看时序报告里的最差路径、资源占用和拥塞报告再决定是改代码还是改约束。很多“变红”的工程其实综合和布局都过了就是某一个跨时钟路径没有约束或者某条路径逻辑级数太长补一拍流水就解决了。5. 扩展应用与进一步优化5.1 多通道并行AGC与动态范围扩展很多场景下AGC不是单个通道而是I/Q两路甚至四通道、八通道并行输入。FPGA做并行AGC很自然只需要把幅度检测、增益更新、平滑模块各复制一份共享同一套目标电平输入数据分别接入即可。因为AGC模块本身不依赖跨通道状态不会出现通道间互相抢资源的问题。有一种做法是多个通道共用同一个增益值适合信号幅度相关性很高的场景比如相控阵各通道幅度一致时。但如果每个通道独立波动共用增益就会导致弱通道没被补偿到位。设计时先想清楚应用场景是被动接收还是主动发射通道之间是否能量相近。多通道独立AGC的代价是多占用一些DSP切片对于K7、A7这些中端片子完全没压力。另一个方向是级联AGC。前级用模拟VGA或低噪声放大器做粗调FPGA做数字细调可以把系统动态范围拉得非常大。FPGA输出的AGC控制字通过SPI接口送给模拟前端形成一个混合环路。这种架构在处理雷达信号时很常见数字部分只需要输出一个缓慢变化的增益字对模拟器件的要求也低很多。5.2 从AGC延伸到其他自适应系统AGC的本质是“测量信号统计量自适应调整增益”。这个思路可以往外扩展很多。比如FPGA图像处理里的自动曝光控制和自动白平衡其实就是对像素亮度做统计再反馈调整曝光时间和RGB增益和AGC的控制结构完全一样只是把时间域换成了图像帧。刚才用的滑动窗口平均、定点数处理和步进更新这些模块稍作修改就能复用到图像处理链路上。再比如热词里提到的去马赛克、色差校正这类ISP算法前端同样需要做黑电平校准和数字增益控制AGC的概率模型可以嵌入进去。处理关键点仍然是最小化定点量化误差、做好边界饱和调试方法论都是通用的。如果你的系统需要更精确的幅度检测可以考虑用近似开方算法替代平均值绝对值比如用牛顿迭代法做定点开方或者在FPGA里例化CORDIC核算幅值。CORDIC同时还能算arctan兼容相位计算这也是FPGA信号处理里的常客。只是在AGC这个场景里平均绝对值性价比最高开方和CORDIC属于锦上添花。最后分享一个调试心得。这个AGC模块的RTL代码写起来其实很快真正花时间的是参数整定和仿真观测。我一开始以为把目标电平、步进、窗口长度按照“看起来合理”的值填进去就能跑结果波形出来不是收敛慢就是输出振荡最后不得不把所有参数做成可配置逐个扫描组合才找到一套稳定参数。做AGC这类闭环系统仿真时一定要把内部中间变量拉出来看光看输入输出会漏掉所有问题。先把固定参数版本跑通再考虑自适应调节这个顺序千万别反了。