实测环境:MATLAB 25.2.0.2998904(R2025b),Windows 64 位(PCWIN64),2026-10-16。仅使用基础 MATLAB。本文以 double 显式模拟有符号整数码、舍入和饱和,不需要 Fixed-Point Designer,也不使用 fi。将完整代码保存为 quantization_clipping_demo.m,运行:
matlab
results = quantization_clipping_demo;
程序导出 results/02_quantization_clipping.png 和对应 MAT 文件。本次运行通过全部正向检查及两个指定错误对照,日志输出 QUANT PASS; negative_controls=2。本次实测:安全量程内的 8 位正弦量化约为 49.15 dB,增加 4 位后约为 73.15 dB;输入幅值增大到 1.15 并发生饱和后,包含全部误差的功率比降到约 21.98 dB。
做 FPGA 定点化时,只看位宽不够。误差既取决于 LSB,也取决于舍入策略、输入占用量程的程度,以及中间节点有没有饱和。把所有误差都称为"量化噪声",往往会掩盖削顶这种有结构的失真。
本文用可手算的码内扫描和三组确定性正弦,把这些条件逐一拆开。
先把整数码与实际数值分开
采用总位宽 B=8、小数位数为 7 的二进制定点格式。整数码范围为:
q ∈ − 128 , 127 q\in-128,127 q∈−128,127
对应实际数值:
x q = q Δ , Δ = 2 − 7 = 0.0078125 x_q=q\Delta,\qquad \Delta=2^{-7}=0.0078125 xq=qΔ,Δ=2−7=0.0078125
因此可表示范围是 [-1,0.9921875],正负端点并不对称。若把上限也写成 1,就等于允许一个 8 位有符号码无法表示的正数。位宽、小数位数与范围的关系可参照 MathWorks 固定点精度和范围说明。
本文量化器明确执行两步:
matlab
raw = round(x/delta);
y = delta*min(max(raw,-128),127);
先舍入,再把整数码限制在合法范围。round 在恰好半整数时默认远离零,例如 [-1.5,-0.5,0.5,1.5] 变成 [-2,-1,1,2]。代码把这个规则也写进断言,因为 FPGA 舍入可能采用向下取整、朝零截断或最近偶数,规则不一致时边界码会不同。MathWorks round 文档
这里没有把浮点输入直接转换成某个整数类型再猜测其行为,也没有模拟溢出回绕。想与 RTL 位精确比对,必须把同一套舍入、饱和和缩放规则落实到每个量化节点。
半个 LSB 是未过载舍入的误差界限
定义误差为 e=y-x。若输入没有触发饱和且使用最近整数舍入,则:
∣ e ∣ ≤ Δ 2 |e|\leq\frac{\Delta}{2} ∣e∣≤2Δ
这个结论只给出误差幅度的界限,并没有说明误差在时间上是白噪声,也没有保证它与输入不相关。
常见的均方误差近似是:
E e 2 ≈ Δ 2 12 Ee\^2\approx\frac{\Delta^2}{12} Ee2≈12Δ2
它来自把误差视为在一个量化区间内均匀分布的模型。要把均方值进一步解释成噪声功率,还需要留意误差均值和相关性。
为了让公式可检验,本文不直接假定某段正弦满足均匀误差,而是构造一个有限扫描:取 201 个安全整数码,每个码内等间距扫描 256 个单元中点,共 51456 个输入。对这组特定数据,误差均值为零,均方误差可以精确求和:
M S E = Δ 2 12 ( 1 − 1 M 2 ) , M = 256 MSE=\frac{\Delta^2}{12}\left(1-\frac{1}{M^2}\right),\qquad M=256 MSE=12Δ2(1−M21),M=256
它非常接近 Δ²/12,但并不完全相等。脚本按有限点公式验收,避免把近似理论值当成不容误差的精确答案。这个扫描验证的是所构造数据的幅度分布,并不证明任意时序量化误差都是独立白噪声。
同一组输入若改成 floor,误差均值恰好为 -Δ/2=-0.00390625。这能直观看到向下取整的偏置。RTL 中的"截断"也应写清楚语义,特别是负数:丢弃二进制低位与朝零的 fix 不能混为一谈。
位宽增加的收益要在相同输入与量程下比较
第二组数据是 8192 点、173 个整周期、幅值 0.9 的正弦。没有随机噪声,初相位固定为 0.23 rad。
使用下面的统一指标:
R = 10 log 10 mean ( x 2 ) mean ( ( y − x ) 2 ) R=10\log_{10}\frac{\operatorname{mean}(x^2)}{\operatorname{mean}((y-x)^2)} R=10log10mean((y−x)2)mean(x2)
代码用 SQNR 命名这个信号功率与量化总误差功率之比。在未过载的本例中,8 位约为 49.15 dB,12 位约为 73.15 dB,增加约 24.00 dB。
若误差近似符合均匀模型,LSB 减半会让均方误差约降为四分之一,对应约 6.02 dB。增加 4 位小数,约提升 24.08 dB;当前确定性正弦的实际离散误差分布使数值略有差别。
代码把可接受差值设为 23 至 25 dB。这是针对本例固定测试信号的验收窗口,不是面向任意信号的位宽收益保证。
输入只增大一点 削顶误差却会接管结果
保持 8 位格式,把正弦幅值由 0.9 改为 1.15。输出上限仍为 0.9921875,下限仍为 -1,波峰附近会被夹平。
这时实测发生饱和夹位的样本数为 2694,共 8192 点。本文的计数定义是"舍入后整数码超出 [-128,127] 的次数",它不等同于"原始输入超出可表示端点的次数",也不等同于"输出恰好等于上下端点的次数"。刚好舍入到端点的合法输入不会计作饱和。
最大误差约为 0.157812,远大于半个 LSB 的 0.00390625。总误差功率比降到约 21.98 dB。误差集中出现在波峰附近,具有明确结构,继续使用"每个误差都不超过半个 LSB"会直接误判。
这里的功率比把舍入误差、饱和失真和任何误差直流分量都计入分母;它没有按 FFT 分离谐波,也不是 ADC 数据手册中的 SINAD 或 ENOB 测量。不能把这个数直接用于器件性能认证。
很小的输入也会使简单噪声公式失效
第三组正弦的幅值只有 0.2*Δ。所有样本离零都不到半个 LSB,因此量化结果全部为零。
此时 e=-x,信号功率等于误差功率,得到 0 dB。没有削顶,格式也仍然是 8 位,但"8 位就应该有约 50 dB"的推断已经不成立。
在这种输入只覆盖很少量化级的情况下,误差与信号高度相关。对 FPGA 的微弱信号链路,应先检查有效码利用率和内部增益,而不是只把理论位数代进噪声公式。
本次 MATLAB 实测结果与负向对照
| 检查项 | 本次实测值 |
|---|---|
| 8 位格式 LSB | 0.0078125 |
| 可表示范围 | -1 至 0.9921875 |
| 有限均匀扫描样本数 | 51456 |
floor 扫描误差均值 |
-0.00390625 |
| 幅值 0.9 的 8 位正弦功率比 | 约 49.15 dB |
| 同一输入改成 12 位 | 约 73.15 dB |
| 幅值 1.15 的 8 位饱和输出 | 约 21.98 dB |
| 饱和夹位样本数 | 2694 / 8192 |
| 幅值 0.2 LSB 的正弦功率比 | 0 dB |

代码还要求两个错误方案被识别:省略饱和步骤会让整数码越界;把半 LSB 界限用于过载信号会验收失败。只有捕获指定错误标识,反例才算通过,普通代码报错不能冒充成功检测。
完整代码
matlab
function results = quantization_clipping_demo(outDir)
% Explicit signed quantizer model; rounding then saturation; base MATLAB.
if nargin < 1
outDir = fullfile(fileparts(mfilename('fullpath')),'results');
end
if ~exist(outDir,'dir'), mkdir(outDir); end
B = 8; delta = 2^(-(B-1));
qmin = -2^(B-1); qmax = 2^(B-1)-1;
M = 256;
phase = ((0:M-1)+0.5)/M-0.5;
codes = (-100:100)';
xSweep = delta*(codes+phase); xSweep = xSweep(:);
ySweep = delta*min(max(round(xSweep/delta),qmin),qmax);
eSweep = ySweep-xSweep;
mseSweep = mean(eSweep.^2);
mseExact = delta^2/12*(1-1/M^2);
mseUniformModel = delta^2/12;
assert(abs(mean(eSweep)) < 1e-15,'QUANT:Mean','Sweep mean error is not zero.');
assert(abs(mseSweep-mseExact) < 1e-17,'QUANT:MSE','Finite sweep MSE mismatch.');
assert(max(abs(eSweep)) <= delta/2,'QUANT:HalfLSB','Unsaturated error exceeds half LSB.');
eFloor = delta*floor(xSweep/delta)-xSweep;
floorBias = mean(eFloor);
assert(abs(floorBias+delta/2) < 1e-15,'QUANT:FloorBias','Floor bias mismatch.');
N = 8192; n = (0:N-1)'; k = 173;
unitSine = sin(2*pi*k*n/N+0.23);
xSafe = 0.9*unitSine;
xOver = 1.15*unitSine;
[ySafe,rawSafe,safeCount] = quantize_signed(xSafe,8);
[yOver,rawOver,clipCount] = quantize_signed(xOver,8);
[y12,~,count12] = quantize_signed(xSafe,12);
sqnr8 = 10*log10(mean(xSafe.^2)/mean((ySafe-xSafe).^2));
sqnr12 = 10*log10(mean(xSafe.^2)/mean((y12-xSafe).^2));
sqnrClipped = 10*log10(mean(xOver.^2)/mean((yOver-xOver).^2));
assert(safeCount==0 && count12==0,'QUANT:SafeRange','Safe case clipped.');
assert(all(rawSafe>=qmin & rawSafe<=qmax),'QUANT:SafeCodes','Safe code outside range.');
assert(max(abs(ySafe-xSafe)) <= delta/2+1e-14,'QUANT:SafeError','Safe error bound failed.');
assert(clipCount>0,'QUANT:Clipping','Overload case did not clip.');
assert(all(yOver>=-1 & yOver<=1-delta),'QUANT:OutputRange','Saturated output out of range.');
assert(max(abs(yOver-xOver)) > 10*delta,'QUANT:OverError','Overload error not exposed.');
assert(sqnr12-sqnr8 > 23 && sqnr12-sqnr8 < 25, ...
'QUANT:BitGain','Four extra bits did not yield the expected improvement here.');
assert(sqnrClipped < sqnr8-20,'QUANT:SQNRDrop','Clipping degradation not exposed.');
xTiny = 0.2*delta*unitSine;
[yTiny,~,tinyCount] = quantize_signed(xTiny,8);
sqnrTiny = 10*log10(mean(xTiny.^2)/mean((yTiny-xTiny).^2));
assert(all(yTiny==0) && tinyCount==0,'QUANT:Tiny','Tiny input did not collapse to zero.');
assert(abs(sqnrTiny) < 1e-12,'QUANT:TinySQNR','Tiny-input SQNR should be zero dB.');
assert(isequal(round([-1.5,-0.5,0.5,1.5]),[-2,-1,1,2]), ...
'QUANT:Ties','Rounding tie convention changed.');
expect_failure(@() assert(all(rawOver>=qmin & rawOver<=qmax), ...
'QUANT:MissingSaturation','Unclipped codes exceed signed range.'), ...
'QUANT:MissingSaturation');
expect_failure(@() assert(max(abs(yOver-xOver))<=delta/2+1e-14, ...
'QUANT:BadHalfLSBAssumption','Half-LSB bound does not cover overload.'), ...
'QUANT:BadHalfLSBAssumption');
fig = figure('Visible','off','Color','w','Position',[100 100 1100 760]);
tiledlayout(2,2,'Padding','compact','TileSpacing','compact');
nexttile;
localX = delta*phase;
plot(localX/delta,(delta*round(localX/delta)-localX)/delta,'b-', ...
localX/delta,(delta*floor(localX/delta)-localX)/delta,'r--','LineWidth',1.2);
xlabel('Position inside quantization cell / LSB'); ylabel('Error / LSB'); grid on;
title('Round and floor have different error bias'); legend('Round','Floor');
nexttile;
show = 1:100;
plot(n(show),xOver(show),'k-',n(show),yOver(show),'r.-','LineWidth',1.1);
yline(1-delta,'b--'); yline(-1,'b--');
xlabel('Sample index'); ylabel('Normalized amplitude'); grid on;
title('8-bit saturation distorts the overloaded sine');
legend('Input','Quantized output','Upper rail','Lower rail');
nexttile;
plot(n(show),(ySafe(show)-xSafe(show))/delta,'b.-', ...
n(show),(yOver(show)-xOver(show))/delta,'r-','LineWidth',1.1);
xlabel('Sample index'); ylabel('Error / 8-bit LSB'); grid on;
title('Overload error exceeds half an LSB'); legend('Safe input','Overload input');
nexttile;
bar([sqnr8,sqnr12,sqnrClipped,sqnrTiny]);
xticks(1:4); xticklabels({'8-bit safe','12-bit safe','8-bit clipped','8-bit tiny'});
ylabel('10 log10(signal power / total error power) dB'); grid on;
title('Same word length does not guarantee the same SQNR');
exportgraphics(fig,fullfile(outDir,'02_quantization_clipping.png'),'Resolution',160);
close(fig);
results = struct('bits',B,'LSB',delta,'lowerRail',-1,'upperRail',1-delta, ...
'sweepSamples',numel(xSweep),'sweepMeanError',mean(eSweep), ...
'sweepMSE',mseSweep,'sweepExactMSE',mseExact,'uniformModelMSE',mseUniformModel, ...
'floorBias',floorBias,'sqnr8',sqnr8,'sqnr12',sqnr12, ...
'sqnrClipped',sqnrClipped,'sqnrTiny',sqnrTiny,'clippedSamples',clipCount, ...
'totalSineSamples',N,'maxClippedError',max(abs(yOver-xOver)), ...
'negativeControls',2);
fprintf('QUANT LSB=%.12g; rails=[%.12g %.12g]; sweep_samples=%d\n',delta,-1,1-delta,numel(xSweep));
fprintf('QUANT sweep_MSE=%.16g; exact_MSE=%.16g; uniform_model_MSE=%.16g; floor_bias=%.12g\n', ...
mseSweep,mseExact,mseUniformModel,floorBias);
fprintf('QUANT SQNR_8=%.12g dB; SQNR_12=%.12g dB; SQNR_clipped=%.12g dB; SQNR_tiny=%.12g dB\n', ...
sqnr8,sqnr12,sqnrClipped,sqnrTiny);
fprintf('QUANT clipped_samples=%d/%d; max_clipped_error=%.12g\n',clipCount,N,results.maxClippedError);
fprintf('QUANT PASS; negative_controls=2\n');
save(fullfile(outDir,'02_quantization_clipping.mat'),'results');
end
function [y,raw,count] = quantize_signed(x,B)
delta = 2^(-(B-1)); lo = -2^(B-1); hi = 2^(B-1)-1;
raw = round(x/delta);
count = nnz(raw<lo | raw>hi);
y = delta*min(max(raw,lo),hi);
end
function expect_failure(fn,expectedId)
caught = false;
try
fn();
catch ME
assert(strcmp(ME.identifier,expectedId),'QUANT:UnexpectedError', ...
'Negative control failed for an unexpected reason: %s',ME.identifier);
caught = true;
fprintf('EXPECTED_FAILURE %s\n',ME.identifier);
end
assert(caught,'QUANT:MissedFailure','Negative control did not fail: %s',expectedId);
end
参考与下一步
- MathWorks round:最近整数舍入及半整数处理
- MathWorks Scaling Range and Precision:整数码、二进制缩放与可表示范围
如果下一步要把"夹到上限或下限"落实成可持续反馈的 RTL 状态,可以接着看FPGA 有符号饱和累加器:反馈状态、反向恢复与 clear 优先级。本文分析一次输入输出量化,那篇讨论的是逐拍更新的累加状态,两者的饱和位置需要分别确认。
一个值得在上板前回答的问题:你的定点信号链中,最先触发饱和的是输入、乘法结果还是累加器?如果把该节点的峰值码、饱和次数和舍入方式一起导出,能否解释 MATLAB 与 RTL 之间的误差?