数字IC笔试高频考点:一阶Σ-Δ调制器的Verilog实现与噪声整形原理 1. 项目缘起从一道笔试真题说起最近在帮几个准备跳槽的朋友复盘数字IC设计面试发现一个挺有意思的现象很多公司的笔试题目尤其是那些头部大厂特别喜欢考一些“小而精”的算法或接口实现。它们不像复杂的SoC架构那样宏大但恰恰是这些题目最能考察一个工程师对数字电路本质的理解、代码风格和临场解决问题的能力。其中脉冲密度调制相关的题目出镜率相当高我印象里至少有三家不同公司的朋友都遇到过变种题。PDM这玩意儿说新不新在音频DAC、Class-D功放、电机控制里都是老面孔了。但很多朋友一看到题目描述里“用数字电路实现”、“输出1bit流”、“密度代表幅度”这些词第一反应往往是去搜现成的IP或者想套用PWM的思路结果要么代码冗长要么面积和时序不理想。其实它的核心就是一个非常经典的一阶噪声整形结构理解了这一点代码写起来会清晰很多。今天我就以一道典型的公司笔试真题为引子把手撕PDM代码的完整思路、背后的数学原理、Verilog实现细节以及那些容易栽跟头的坑给大家彻底拆解清楚。无论你是正在备战秋招春招的同学还是想巩固基础的在职工程师相信这篇都能给你带来一些直接的参考价值。我们不止要“撕”出代码更要明白为什么这么“撕”。2. 脉冲密度调制它到底是什么又为什么重要在深入代码之前我们得先搞明白PDM到底在干什么。很多人会把它和PWM搞混虽然名字里都有“脉冲”和“调制”但内核逻辑完全不同。PWM是“脉冲宽度调制”。它输出的是一个固定频率的方波通过改变每个周期内高电平的持续时间占空比来传递模拟信息。比如50%占空比代表中间值。它的频谱里除了基波还有大量高频谐波需要后续的低通滤波器来平滑。PWM的实现通常需要一个计数器和比较器。而PDM是“脉冲密度调制”。它输出的是一连串单比特0或1的数据流其平均密度代表了模拟信号的幅度。举个例子如果一个时间段内输出10个比特里有7个是1那么其平均密度就是0.7对应0.7倍的满量程电压。关键在于PDM的比特流频率远高于信号频率这就是过采样并且它通过一种叫做噪声整形的技术将量化噪声由于用1bit表示幅度必然引入的误差推到了高频区域使得在我们关心的音频或信号频带内信噪比非常高。为什么数字IC面试爱考它我认为有几点考察对数据流和精度的理解如何用1bit的输出来高精度地表示一个多比特的输入这涉及到过采样、噪声整形、积分器等核心概念。考察对硬件描述语言HDL的掌握需要熟练使用寄存器、加法器、比较器并处理好位宽和溢出。考察面积和时序意识一个高效的PDM调制器应该非常精简因为它在音频通路中可能被实例化很多次。它是更复杂模块的基础理解了PDM对后续学习Sigma-Delta ADC/DAC的工作原理有极大帮助。最常见的PDM实现结构是一阶Σ-Δ调制器。它的结构图在教材上很常见一个减法器、一个积分器累加器、一个1比特量化器本质上就是一个比较器以及一个将量化输出反馈回来的减法器。下面我们就从这个数学模型出发推导出它的硬件实现。3. 从数学模型到硬件架构的推导很多资料直接给出了电路图但知其然更要知其所以然。我们从一个最简化的离散时间模型开始推演。假设输入X[n]是一个有符号的二进制补码数位宽为N。输出Y[n]是1比特0或1在电路中常用0表示负满幅1表示正满幅但中间值需要映射。一阶Σ-Δ调制器的差分方程可以表示为E[n] X[n] - Y[n-1] * Fs S[n] S[n-1] E[n] Y[n] 1 if (S[n] 0) else 0这里稍微解释一下E[n]是误差信号即当前输入与上一个输出映射回满幅值的差值。S[n]是积分器累加器的状态它累积了历史的误差。Y[n]是当前输出由积分器状态的正负决定。Fs是一个缩放因子通常为2^(N-1)用于将1比特的Y映射回输入X的量程。这个模型很直观但在硬件实现时Y[n-1] * Fs这个乘法器是可以优化掉的。我们可以把方程改写一下S[n] S[n-1] X[n] - Y[n-1] * Fs如果我们定义一个新的累加器变量Acc[n]让它直接等于S[n] Y[n-1] * Fs这只是一个数学上的变量替换那么判决条件会发生变化。经过变换具体推导过程略核心是消除乘法器我们可以得到一个在硬件上更友好的算法Acc[n] Acc[n-1] X[n] if (Acc[n] 0) { Y[n] 1; Acc[n] Acc[n] - Fs; // 减去满幅值 } else { Y[n] 0; // Acc[n] 保持不变因为减去的是0 }看乘法器消失了这就是硬件实现常用的误差反馈结构。Acc这个累加器同时扮演了积分和误差补偿的角色。它的位宽需要比输入X的位宽更宽以容纳累加过程中的数值防止溢出。通常Acc的位宽是N M其中M是防止溢出的保护位一般取log2(过采样率)的量级。有了这个算法我们的硬件架构就非常清晰了一个位宽足够的累加器寄存器(Acc)。一个加法器每个时钟周期将输入X加到Acc上。一个比较器判断累加后的Acc是否大于等于0。一个选择器根据比较结果输出1或0同时决定是否从Acc中减去满幅值Fs。这个架构极其精简只有一个加法器、一个比较器和一个寄存器非常适合高速、低面积的数字电路实现。4. 手撕Verilog代码核心实现与关键细节理解了算法和架构现在我们来写代码。我会按照一个可综合的、稳健的模块风格来写并穿插讲解每一个设计抉择背后的原因。首先定义模块接口。假设输入是16位有符号二进制补码输出是1比特的PDM流。module pdm_modulator #( parameter INPUT_WIDTH 16, // 输入位宽 parameter ACC_WIDTH 20 // 累加器位宽INPUT_WIDTH 4 )( input wire clk, input wire rst_n, input wire signed [INPUT_WIDTH-1:0] data_in, // 有符号输入 output reg pdm_out // 1比特PDM输出 );为什么累加器位宽是20164这是一个经验值。4个保护位大约能容纳16倍的累加值对于大多数过采样应用足够了。保护位不够会导致累加器溢出引起严重的非线性失真保护位过多则浪费面积。ACC_WIDTH参数化便于调整。接下来定义内部信号和常量。// 将输出1比特映射回输入量程的满幅值。 // 对于二进制补码正满幅是 2^(INPUT_WIDTH-1)-1负满幅是 -2^(INPUT_WIDTH-1) // 我们通常用正满幅值作为减法器。当输出为1时意味着我们“消耗”掉一个正满幅值。 localparam signed FULL_SCALE (1 (INPUT_WIDTH-1)) - 1; // 例如16位输入FULL_SCALE32767 reg signed [ACC_WIDTH-1:0] acc_reg; // 累加器寄存器 wire signed [ACC_WIDTH-1:0] acc_next; // 累加器下一拍的值 wire subtract; // 控制是否减去的信号核心逻辑都在一个时序always块里always (posedge clk or negedge rst_n) begin if (!rst_n) begin acc_reg {ACC_WIDTH{1b0}}; pdm_out 1b0; end else begin // 更新累加器 acc_reg acc_next; // 根据累加器当前值注意是更新前的acc_reg还是更新后的acc_next // 这里有一个关键细节判决应该基于累加“之后”的值即acc_next。 // 但为了时序清晰我们常用一个组合逻辑块来计算acc_next和pdm_out。 end end把判决和减法逻辑放到组合逻辑里更清晰但要注意防止毛刺。我们采用时序逻辑描述但思维上按组合逻辑推导// 这些计算本质上是组合逻辑我们写在always块内 wire signed [ACC_WIDTH-1:0] acc_after_add; assign acc_after_add acc_reg data_in; // 先做加法 // 判决如果累加后值 0输出1否则输出0 always (*) begin if (acc_after_add 0) begin pdm_out 1b1; acc_next acc_after_add - FULL_SCALE; end else begin pdm_out 1b0; acc_next acc_after_add; // 输出0不减 end end // 注意上面的always (*)块描述了组合逻辑。 // 在时序always块中我们直接赋值 always (posedge clk or negedge rst_n) begin if (!rst_n) begin acc_reg {ACC_WIDTH{1b0}}; // pdm_out 在组合逻辑中生成这里不需要复位 end else begin acc_reg acc_next; // pdm_out 已经由组合逻辑实时生成 end end // 但更常见的写法是将判决也放入时序always块避免组合输出利于时序分析。更推荐纯时序逻辑的写法将加法、比较、减法在一个周期内完成reg signed [ACC_WIDTH-1:0] acc_after_add; always (posedge clk or negedge rst_n) begin if (!rst_n) begin acc_reg {ACC_WIDTH{1b0}}; pdm_out 1b0; end else begin // 1. 累加输入 acc_after_add acc_reg data_in; // 注意这里用阻塞赋值因为用于本周期计算 // 2. 判决并更新累加器 if (acc_after_add 0) begin pdm_out 1b1; acc_reg acc_after_add - FULL_SCALE; end else begin pdm_out 1b0; acc_reg acc_after_add; end end end注意上面的代码中acc_after_add acc_reg data_in;使用了阻塞赋值。在时序逻辑的always (posedge clk)块中使用阻塞赋值通常是不推荐的因为它可能导致仿真与综合不一致依赖于工具。更严谨的做法是要么全部使用非阻塞赋值并将中间计算拆分成多个周期或使用临时变量要么将比较和加法的组合逻辑单独写到一个always (*)块中生成pdm_out和acc_next信号然后在时钟沿用非阻塞赋值更新acc_reg。这里为了流程清晰展示算法采用了混合写法。实际工程中请务必统一赋值风格。让我们给出一个更严谨、可综合的版本module pdm_modulator #( parameter INPUT_WIDTH 16, parameter ACC_WIDTH 20 )( input wire clk, input wire rst_n, input wire signed [INPUT_WIDTH-1:0] data_in, output reg pdm_out ); localparam signed FULL_SCALE (1 (INPUT_WIDTH-1)) - 1; reg signed [ACC_WIDTH-1:0] acc_reg; // 组合逻辑计算下一状态和输出 wire signed [ACC_WIDTH-1:0] acc_plus_input; wire pdm_out_comb; wire signed [ACC_WIDTH-1:0] acc_next; assign acc_plus_input acc_reg data_in; assign pdm_out_comb (acc_plus_input 0); assign acc_next pdm_out_comb ? (acc_plus_input - FULL_SCALE) : acc_plus_input; // 时序逻辑更新寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin acc_reg {ACC_WIDTH{1b0}}; pdm_out 1b0; end else begin acc_reg acc_next; pdm_out pdm_out_comb; end end endmodule这个版本将组合逻辑和时序逻辑清晰分离使用连续赋值语句描述算法是更优的实践。5. 仿真验证如何证明你的代码是对的代码写完了但没经过仿真的代码就像没上考场的复习。对于PDM仿真验证至关重要因为我们要看时域波形和频域特性。我会搭建一个简单的测试平台。首先我们需要一个模拟的输入信号。最简单的是用常数输入看输出密度是否匹配。例如输入为0理论上输出1和0应该各占一半长期平均值为0.5。输入为正满幅输出应全为1输入为负满幅输出应全为0。timescale 1ns/1ps module tb_pdm(); reg clk; reg rst_n; reg signed [15:0] data_in; wire pdm_out; pdm_modulator #(.INPUT_WIDTH(16), .ACC_WIDTH(20)) uut ( .clk(clk), .rst_n(rst_n), .data_in(data_in), .pdm_out(pdm_out) ); // 时钟生成 initial begin clk 0; forever #5 clk ~clk; // 100MHz时钟 end // 测试序列 initial begin // 初始化 rst_n 0; data_in 0; #100; rst_n 1; #20; // 测试1输入为0 $display(Test 1: Input 0); data_in 16d0; #1000; // 观察一段时间 // 可以在这里统计pdm_out中1的个数比例应接近50% // 测试2输入为正小值 $display(Test 2: Input 1000); data_in 16d1000; // 约满幅的3% #1000; // 测试3输入为负小值 $display(Test 3: Input -1000); data_in -16d1000; #1000; // 测试4输入正弦波更实际的测试 $display(Test 4: Sine wave input); begin integer i; for (i0; i1024; ii1) begin // 生成一个1kHz的正弦波样本采样率100MHz幅度为满幅的30% data_in $rtoi(0.3 * 32767 * $sin(2.0 * 3.1415926 * 1000 * i / 100_000_000.0)); (posedge clk); end end #100; $finish; end // 将输出写入文件便于用MATLAB/Python分析频谱 integer f_out; initial begin f_out $fopen(pdm_output.txt, w); forever begin (posedge clk); if (rst_n) begin $fwrite(f_out, %b\n, pdm_out); end end end initial begin #50000; $fclose(f_out); end endmodule仿真波形只能看个大概。要定量分析性能必须做频谱分析。我们把输出的1比特流保存到文件pdm_output.txt然后用脚本分析。这里给出一个Python的思路import numpy as np import matplotlib.pyplot as plt # 读取仿真输出的比特流 with open(pdm_output.txt, r) as f: lines f.readlines() y np.array([int(line.strip()) for line in lines], dtypenp.float64) # 将0/1映射为-1/1方便计算直流分量和频谱 y y * 2 - 1 # 参数 fs 100e6 # 采样率与仿真时钟一致 N len(y) t np.arange(N) / fs # 计算FFT Y np.fft.fft(y) freq np.fft.fftfreq(N, 1/fs) # 绘制频谱 (单边谱) Pxx np.abs(Y[:N//2])**2 / (N * fs) # 功率谱密度估计 freq_oneside freq[:N//2] plt.figure(figsize(10,6)) plt.semilogx(freq_oneside, 10*np.log10(Pxx)) plt.xlabel(Frequency (Hz)) plt.ylabel(Power Spectral Density (dB/Hz)) plt.title(PSD of 1st-order PDM Output) plt.grid(True, whichboth) plt.axvline(x20000, colorr, linestyle--, label20kHz Audio Band) plt.legend() plt.show()在一个理想的一阶PDM频谱中你应该能看到在低频段比如20kHz以内信号分量清晰可见并且本底噪声很低。从低频开始量化噪声的功率谱密度以大约9dB/倍频程的斜率上升这是一阶噪声整形的特征。大部分噪声能量被“整形”到了高频区域奈奎斯特频率附近。如果频谱显示低频段噪声很大或者没有明显的噪声整形斜坡那说明你的代码可能有问题比如累加器位宽不够导致溢出或者判决逻辑有误。6. 笔试实战中的陷阱与进阶思考如果这真的是一道笔试真题面试官除了看代码是否工作更会关注你是否考虑到了下面这些实际工程问题陷阱一累加器初始值与极限环振荡如果累加器acc_reg复位为0输入也为0那么第一个时钟周期acc_plus_input0判决为0输出pdm_out1同时acc_next 0 - FULL_SCALE -32767。下一个周期输入为0acc_plus_input -32767 0 -32767判决输出0acc_next保持-32767。如此循环输出变成了固定的1, 0, 1, 0,...方波而不是随机的01序列。这种现象称为极限环振荡在输入为0或很小值时输出会出现周期性的模式这在音频中可能听作固定的音调Tonal Noise。如何缓解一个常见技巧是在累加器复位时给它一个非零的初始值或者引入一个非常小的随机扰动例如利用一个伪随机数生成器的LSB。在笔试中你可以提出这个观点并说明在高端音频应用中会采用高频抖动注入技术来打散这种周期性。陷阱二输入数据的同步与跨时钟域题目通常假设输入data_in与clk同时钟域。但在真实系统中输入数据可能来自另一个时钟域如音频I2S总线。这时必须考虑跨时钟域处理通常使用同步器两级触发器来处理。如果笔试要求设计一个完整的音频PDM发射器这部分必须提及。陷阱三输出信号的毛刺与驱动能力pdm_out是1比特信号但它的翻转频率最高可达时钟频率的一半。如果直接驱动片外负载如Class-D功放输入需要考虑输出缓冲、毛刺过滤和ESD保护。在芯片内部也要注意该信号线可能较长需要插入缓冲器或使用更好的布局来保证信号完整性。进阶思考如何从一阶扩展到二阶或更高阶一阶PDM结构简单但噪声整形能力有限只有9dB/倍频程的滚降。更高阶的Σ-Δ调制器能提供更陡的噪声整形斜率将更多噪声推向高频从而在相同过采样率下获得更高的信噪比。二阶的结构包含两个积分器和更复杂的反馈网络。在笔试中如果时间允许你可以简要画出二阶调制器的框图并指出其实现复杂度更多的加法器、寄存器以及系数量化问题和稳定性考量需要设计合理的系数防止振荡这绝对是加分项。进阶思考位宽与系数量化在我们的一阶模型中FULL_SCALE是一个精确的2的幂次方减一。但在高阶调制器或更复杂的噪声整形器中反馈系数可能不是整数。这时就需要对系数进行定点数量化。量化会引入额外的误差影响调制器的稳定性和性能。你需要权衡系数的精度和硬件成本。7. 面积、功耗与性能的折衷考虑作为数字IC工程师写出能工作的代码只是第一步写出面积小、功耗低、性能高的代码才是终极目标。对于这个PDM模块面积优化核心面积是累加器 (ACC_WIDTH位寄存器)、一个ACC_WIDTH位加法器和一个比较器。优化点位宽裁剪在保证不溢出的前提下尽可能减少ACC_WIDTH。可以通过数学仿真确定所需的最小位宽。加法器优化如果输入位宽不大如16位一个简单的行波进位加法器即可。如果速度要求极高可以考虑超前进位加法器但面积会增加。比较器优化判断acc_plus_input 0实际上就是检查最高位符号位是否为0。这是一个非常简单的逻辑几乎不占面积。功耗优化门控时钟如果PDM模块并非一直工作可以对acc_reg的时钟进行门控当输入数据无效时关闭时钟以节省动态功耗。操作数隔离在加法器输入稳定为0时可以尝试隔离其输入变化减少不必要的翻转。但在我们这个每个周期都工作的模块中优化空间有限。性能时序优化关键路径是从acc_reg读出经过加法器再经过比较器最后回到acc_reg的输入。在高速时钟下如用于高清音频的100MHz以上这条路径可能成为瓶颈。流水线可以将“加法-比较-选择减法”这个组合逻辑拆分成两个时钟周期完成。但这样会引入一个周期的延迟在闭环系统中需要谨慎处理。预计算对于固定的FULL_SCALEacc_plus_input - FULL_SCALE这个操作可以优化。因为FULL_SCALE是常数减法器可以简化。在实际笔试或项目中面试官可能会追问“如果时钟频率需要提高到200MHz你的设计会遇到什么问题如何修改” 这时候流水线化和关键路径分析的能力就体现出来了。8. 从仿真到板级验证的鸿沟代码仿真通过频谱看起来也很完美是不是就万事大吉了远非如此。在真实的FPGA或ASIC上你会遇到仿真中看不到的问题。问题一 metastability 亚稳态如果data_in来自异步时钟域即使你用了两级同步器在极端情况下仍可能发生亚稳态导致某个周期的data_in值错误。对于PDM这种累积系统一个错误的输入样本可能会影响后续多个输出听起来就是“啪”的一声爆音。解决方案是使用更可靠的同步电路如握手协议或者在前端增加一个FIFO进行数据缓冲和时钟域隔离。问题二 电源噪声PDM输出是高速切换的1比特信号。当它驱动一个大的片外负载时会在电源网络上产生高频电流尖峰从而耦合到模拟电源或同一芯片上的其他敏感模块如PLL、精密ADC。这就是所谓的“开关噪声”。解决方法包括使用独立的电源轨和地平面给PDM输出驱动器供电。在输出引脚附近放置去耦电容。采用差分输出如PDM_CLK, PDM_DATA来抵消共模噪声。在数字侧可以采用“返回至零”或“双沿输出”等编码方式来降低开关频率。问题三 EMI电磁干扰高速的01切换序列相当于一个射频发射源。如果PCB布局不当很容易导致EMI测试失败。除了做好电源滤波和信号完整性布局外还可以在数字域对PDM流进行随机化或散射处理将集中的开关能量扩散到更宽的频带上降低峰值辐射。这些板级问题在笔试中可能不会深究但如果你能在介绍设计时提到“需要考虑跨时钟域同步、电源噪声隔离和EMI抑制等措施”无疑会展现出你具备系统级的思维和丰富的实战经验这绝对是脱颖而出的关键。最后我想说的是这道“手撕代码”题的价值远不止于写对一个模块。它考察的是你是否具备将算法映射到硬件的思维是否了解数字信号处理的基础是否对电路的实际行为有预见性以及是否具备解决工程问题的完整方法论。下次再遇到类似的题目不妨从系统模型开始推导仔细考虑位宽、时序和边界条件最后再思考如何优化和加固。这个过程本身就是一名优秀数字IC工程师的日常。