FPGA一阶PDM脉冲密度调制:满量程端点、余量守恒与停顿语义
当一个数字目标值要变成单比特高低电平流时,除了固定周期PWM,还可以使用累加器进位构造脉冲密度调制PDM。它适合用来理解数字平均值如何映射成高电平所占比例,但单有数字逻辑并不等于已经得到满足精度指标的模拟DAC。
本文实现一个一阶整数累加器。重点放在三个经常被忽略的约定:如何真正得到100%高电平、禁用周期是否计入密度、输入动态变化时还能检查什么。
1. 先把量程和时钟定义清楚
令M=2^W。输入level_i使用W+1位,合法目标范围为0~M,表示0~100%的高电平比例。大于M的值饱和到M,并通过clipped_o报告该有效周期发生了限幅。
例如W=8时,合法输入为0~256,不是0~255。若只给8位输入,最大255对应255/256,不能声称能达到100%。
rst为同步高有效复位。enable_i=1时采样level_i并推进一步;enable_i=0时,内部余量保持,而pulse_o和clipped_o都输出0。因此“目标密度”按有效步骤统计;如果enable有空洞,按全部物理时钟统计的平均高电平比例会降低。需要连续时间输出时,应持续使能,低速控制值保持稳定送入,而不是仅在控制值更新时打一拍enable。
本文完成了Linux云端Python 3.12.14整数模型测试,以及2026年10月30日Windows 11上的Vivado/XSim 2022.1(SW Build 3526262)RTL行为仿真。RTL编译、展开和自检仿真均退出0。未运行综合、布局布线、时序分析,未上板,也没有测量纹波、频谱或模拟输出精度。
2. 一条守恒式比“看起来像脉冲”更可靠
设第k步之前余量为a[k],输入限幅后为u[k],两者范围分别为0~M-1和0~M。计算s=a[k]+u[k]:若s达到M,则输出1并减去M;否则输出0并保留s。
对应关系为:
a[k+1] = a[k] + u[k] – M*y[k]
把连续N个有效步骤相加,就得到:
M * sum(y) = sum(u) + a[0] – a[N]
从复位余量0开始,累计高电平个数就是floor(sum(u)/M),余量是sum(u)对M取模。对任意动态输入序列,这个整数关系仍成立;但是它不等于任意短窗口内的模拟电压已经稳定,也不证明频谱或噪声指标。
输入固定为整数c,连续运行M个有效步骤时,输出恰好有c个高电平,包括0与M两个端点。以下是从余量0开始、W=8时的部分预期序列:128对应交替0/1;256每步输出1;0每步输出0。相邻两个1会形成连续高电平,并不是每个1都产生新的上升沿,因此不要把该输出当成需要固定脉宽和边沿间隔的步进电机脉冲接口。
Microchip的官方PDM DAC资料介绍了累加器产生密度相关脉冲流,再经RC滤波得到模拟量的架构。本文只讨论一个自包含数字示例,不复制该器件内部结构,也不把该器件的性能或电压范围套用到FPGA引脚。Microchip官方PDM说明
3. 完整RTL:pdm_accumulator.sv
sum_c最大为(M-1)+M=2M-1,因此W+1位足够;最低W位作为新余量,最高位就是本步输出。所有信号采用无符号数,避免把最高输入位误认为符号位。AMD UG901有符号与无符号表达式说明
module pdm_accumulator #(
parameter integer W = 8 // W >= 1
) (
input wire clk,
input wire rst,
input wire enable_i,
input wire [W:0] level_i,
output reg pulse_o,
output reg clipped_o
);
localparam [W:0] FULL = {1'b1,{W{1'b0}}};
reg [W-1:0] acc_q;
wire [W:0] level_c = (level_i > FULL) ? FULL : level_i;
wire [W:0] sum_c = {1'b0,acc_q} + level_c;
always @(posedge clk) begin
if (rst) begin
acc_q <= {W{1'b0}};
pulse_o <= 1'b0;
clipped_o <= 1'b0;
end else if (enable_i) begin
acc_q <= sum_c[W-1:0];
pulse_o <= sum_c[W];
clipped_o <= (level_i > FULL);
end else begin
pulse_o <= 1'b0;
clipped_o <= 1'b0;
end
end
endmodule
参数W必须大于等于1。本示例默认W=8,数字输入在clk上升沿采样,输出寄存器在该边沿之后更新。clipped_o与对应pulse_o同拍;复位优先于使能。余量保持的禁用行为是有意定义,不要在接入后自行把禁用改成清零而仍沿用原测试结论。
4. 完整自检Testbench:tb_pdm_accumulator.sv
恒值测试遍历9位输入全部512种编码,其中257~511检验饱和处理;每组重置后运行256个有效步骤,检查高电平总数。之后覆盖输入变化、使能空洞与处理中复位。Testbench在下降沿驱动,在上升沿后1ns检查输出与内部余量。
`timescale 1ns/1ps
module tb_pdm_accumulator;
localparam W=8, M=256;
reg clk=0;
always #5 clk=~clk;
reg rst=1, enable_i=0;
reg [W:0] level_i=0;
wire pulse_o, clipped_o;
pdm_accumulator #(.W(W)) dut(.*);
integer residue=0, ref_pulse=0, ref_clip=0;
integer total, applied, checks=0, ones=0, code, n;
reg [31:0] rng=32'h20261028;
task automatic step(input reg r, input reg en, input integer x);
begin
@(negedge clk); rst=r; enable_i=en; level_i=x;
@(posedge clk);
if(r) begin
residue=0; ref_pulse=0; ref_clip=0;
end else if(en) begin
applied=(x>M)?M:x;
total=residue+applied;
ref_pulse=total/M;
residue=total%M;
ref_clip=(x>M);
end else begin ref_pulse=0; ref_clip=0; end
#1;
if(pulse_o !== ref_pulse[0] || clipped_o !== ref_clip[0] || dut.acc_q !== residue[W-1:0])
$fatal(1,"PDM mismatch cycle %0d code %0d",checks,x);
checks=checks+1;
end
endtask
initial begin
for(code=0;code<512;code=code+1) begin
step(1,0,0); ones=0;
for(n=0;n<256;n=n+1) begin
step(0,1,code); ones=ones+pulse_o;
end
if(ones != ((code>M)?M:code)) $fatal(1,"constant density mismatch");
end
for(n=0;n<20000;n=n+1) begin
rng=1664525*rng+1013904223;
step((n%997)==0,rng[12:10]!=0,rng[8:0]);
end
$display("PASS pdm checks=%0d",checks);
$finish;
end
initial begin #10000000; $fatal(1,"watchdog timeout"); end
endmodule
5. 实际执行的数学参考测试
保存为model.py:
class Pdm:
def __init__(self,width,initial=0):
self.width=width; self.acc=initial
def tick(self,code,enable,reset):
if reset:
self.acc=0
return 0,0,0
if not enable:
return 0,0,self.acc
m=1<<self.width
val=min(code,m)
total=self.acc+val
self.acc=total&(m–1)
return (total>>self.width)&1,int(code>m),self.acc
保存为test_model.py:
import json, random, sys
from pathlib import Path
from model import Pdm
rng = random.Random(20261028)
checks = enabled_steps = clipped_steps = 0
def run(width, values, initial=0):
global checks, enabled_steps, clipped_steps
m=1<<width
dut=Pdm(width, initial)
residual=initial
for code, enable, reset in values:
if reset:
expected_bit=expected_clip=0
residual=0
elif enable:
clamped=min(code,m)
total=residual+clamped
expected_bit=int(total>=m)
expected_clip=int(code>m)
residual=total–expected_bit*m
enabled_steps+=1
clipped_steps+=expected_clip
else:
expected_bit=expected_clip=0
bit,clip,acc=dut.tick(code,enable,reset)
assert (bit,clip,acc)==(expected_bit,expected_clip,residual), (width,code,enable,reset,(bit,clip,acc),(expected_bit,expected_clip,residual))
checks+=1
return dut
for w in range(1,6):
for a in range(1<<w):
for x in range(1<<(w+1)):
run(w,[(x,1,0)],a)
# Every constant code, including invalid codes, over 256 enabled slots.
constant_cases=0
for x in range(512):
dut=run(8,[(x,1,0)]*256)
assert dut.acc==0
constant_cases+=1
# Separate aggregate oracle, independent of conditional carry step.
dut=Pdm(8)
sum_codes=sum_bits=0
for _ in range(100000):
x=rng.randrange(512)
bit,clip,acc=dut.tick(x,1,0)
sum_codes+=min(x,256); sum_bits+=bit
assert sum_bits==sum_codes//256 and acc==sum_codes%256
checks+=1; enabled_steps+=1; clipped_steps+=clip
# Reset and disabled-slot behavior.
run(8,[(rng.randrange(512),rng.randrange(4)!=0,rng.randrange(1000)==0) for _ in range(20000)])
r=dict(environment=sys.version,checks=checks,enabled_steps=enabled_steps,clipped_steps=clipped_steps,constant_cases=constant_cases,seed=20261028,scope='Python mathematical model only; no RTL execution')
Path(__file__).with_name('python_results.json').write_text(json.dumps(r,indent=2)+'\\n'); print(json.dumps(r))
运行命令:
Python参考模型仍可独立运行 python3 test_model.py。RTL复现需要已安装Vivado 2022.1并将其bin目录加入PATH,在对应源码目录执行:
xvlog —sv pdm_accumulator.sv tb_pdm_accumulator.sv
xelab tb_pdm_accumulator –s pdm_sim —debug typical —timescale 1ns/1ps
xsim pdm_sim –tclbatch run.tcl –wdb pdm.wdb
同目录的 run.tcl 内容为:
log_wave -recursive *
run all
quit
本次XSim初次展开曾因DUT未声明时间单位而报告XSIM 43-4099;增加 –timescale 1ns/1ps 后通过,未修改RTL或Testbench。该参数给没有声明时间单位的设计单元提供默认值。波形记录出现Simtcl 6-197警告,部分对象因类型或大小限制不能记录;WDB文件已生成,但不能宣称全部内部对象都被记录。此警告不影响本次自检断言运行。
本次Python真实结果:253800次检查通过;其中248896个有效步骤、123710个限幅步骤;512个恒值编码用例通过。测试还枚举W=1~5时所有余量与输入编码的单步组合,并用100000步动态序列核对累计输入除以M的独立参考式。随机种子为20261028。
这些数字只属于Python模型测试,不是RTL日志。脚本先以未实现的占位模型运行并触发断言,随后实现模型再运行通过;这只能说明测试能发现该缺失行为,不能替代RTL工具执行。
6. 从数字验证走向实际输出
第一步,保存已运行的RTL自检日志:本次XSim输出 PASS pdm checks=151584,没有触发 $fatal,编译、展开及仿真各退出0。SV实例为W=8,接下来仍需补不同W参数的RTL回归;不能把Python的小位宽枚举当成所有RTL参数都验证过。
第二步,明确enable语义。如果需要持续输出目标比例,则保持enable_i=1。控制器偶尔更新level_i即可,不要让“数据更新脉冲”误当作PDM采样时钟使能。
第三步,给控制值同步传输。若来自另一时钟域,多位level_i不能分别串两级寄存器后就认为数值一致,应使用握手或合适CDC桥接。
第四步,再选择输出电气接口和模拟滤波方案。FPGA引脚电平、负载、边沿、滤波器与纹波指标必须单独设计和实测;本文没有给出一组未经验证的RC参数,也不保证比PWM更适合你的负载。
第五步,避免把pulse_o直接作为新的逻辑时钟。后级数字逻辑应继续使用系统时钟,并把它当普通数据或使能条件;是否适合特定外设的输入时序,需要另行核对。
7. 相关阅读与工程问题
- DAC限速器:目标变化与输出更新
- DAC影子寄存器与原子更新
如果你的控制值每1000拍更新一次,PDM应该每拍运行,还是只在更新拍运行?先回答目标是连续输出平均值还是离散事件分配,再决定enable和滤波器,不能仅因为代码有enable就把更新脉冲直接接过去。