USB · Module 29
USB Audio Devices
An audio device runs on its own crystal, so a few parts per million empty the buffer every minute — with nothing lost and nothing to retry. The fix is one 10.14 number per frame, and the steady-state offset is a closed form: crystal error × 2^gain.
The third case study, and the one where nothing is ever lost and nothing ever fails. A flash drive is a decision table with fatal cells. A webcam is a firehose with no error handling. An audio device has a problem that no error mechanism could address even in principle, because nothing is wrong: two clocks disagree, both of them are working correctly, and neither can be adjusted.
1. Two Crystals, And Why You Cannot Retry Your Way Out
A USB audio device plays samples using its own crystal. The host produces samples using its own crystal. The device's idea of 48,000 samples per second and the host's idea of 48,000 samples per second are not the same number of seconds, and they never will be.
Neither is broken. A crystal specified at 48.000 MHz ± 100 ppm and running at 99 ppm fast is a good part, in spec. So is one running 99 ppm slow. Put one of each at the two ends of a USB cable and they disagree by 198 ppm, permanently, with nothing to report and no one at fault.
That is worth stating precisely, because it is the thing that separates this chapter from the two before it:
FLASH DRIVE a command can FAIL -> report it, host retries
WEBCAM a packet can be LOST -> nothing to do, drop the frame
AUDIO DEVICE nothing fails or is lost -> and the buffer still emptiesAn error mechanism has nothing to work with here. The only fix is to stop the two rates from being different in the first place — which means one side has to change its rate to match the other.
2. Who Changes Rate: The Three Synchronization Types
USB audio names three answers to "who adjusts?", and the answer determines the entire architecture of the device.
SYNCHRONOUS the device slaves its sample clock to the USB SOF.
Hardware: a PLL locked to a 1 kHz reference.
Cost: that PLL, and the jitter the bus gives you.
ADAPTIVE the device measures how many samples actually arrive and
retunes its own rate to match.
Hardware: a rate estimator and a tunable clock.
Cost: the device is chasing the host, always behind.
ASYNCHRONOUS the device keeps its own free-running crystal and TELLS THE
HOST what rate it wants. The host adjusts.
Hardware: a counter and a subtractor.
Cost: the host has to be willing to listen.The asynchronous device is the one worth building, and it is what this chapter builds. Not because it is the cheapest — though it is — but because of what it does to the audio quality argument. A synchronous device's sample clock is derived from the USB bus, so bus jitter becomes sample jitter, which is audible. An asynchronous device's sample clock is a crystal in a can, doing nothing but oscillating, and the USB bus never touches it. The rate correction happens entirely on the host side, in software, where it costs nothing and jitters nothing.
3. Where The Feedback Endpoint Lives
An asynchronous audio device is described to the host by a small stack of descriptors, and one endpoint in that stack exists purely to carry the rate.
The descriptor stack of an asynchronous USB audio device
Two details in that stack are worth pausing on, because both are the kind of thing that gets a device rejected in interop testing.
Alternate setting 0 reserves no bandwidth. An audio interface always has an alt 0 whose endpoints request zero bytes per frame. A device that is plugged in but not playing sits in alt 0 and costs the bus nothing. This is not an optimisation; it is required, because isochronous bandwidth is reserved at configuration time and a device that permanently held a reservation it was not using would starve the rest of the bus. Alt 1 and above are the real formats.
The feedback endpoint is isochronous, not interrupt. It carries a number that is only useful if it is current, and a retry would deliver a stale rate late. Losing one feedback packet costs the host one frame of correction, which the next packet supersedes. That is the correct trade, and it is the same argument that makes the data endpoint isochronous.
4. The Number Itself: 10.14 Fixed Point
The device has to report a rate, and the true answer is essentially never an integer. At full speed the format is 10.14: ten integer bits and fourteen fractional bits, in a 24-bit field, in units of samples per frame.
48.000 samples/frame = 48 * 16384 = 786,432
48.250 samples/frame = 790,528 = 786,432 + 4,096
47.750 samples/frame = 782,336 = 786,432 - 4,096
one LSB = 1/16384 sample/frame
= 1/786432 of the nominal rate
= 1.27 ppmOne part in sixteen thousand of a sample per frame. The resolution is not the interesting part — the interesting part is what the host does with it.
5. The Design (Verilog-2005)
So the device's job is: watch the buffer, and report the rate that will bring it back to where it should be. That is a control loop, and it is the smallest interesting control loop in this entire track.
The loop, as the RTL implements it
Here is the whole module. It is proportional only, and section 8 measures exactly what that costs.
// =====================================================================
// uac_feedback -- the control loop that keeps a USB audio device from
// running out of samples, or drowning in them.
//
// CLASSIFICATION: simplified synthesisable teaching RTL.
// This is NOT an audio device. There is no DAC, no volume control, no
// format negotiation and no mixer. It is the feedback endpoint: the one
// number an asynchronous audio device sends back every frame, and the
// arithmetic that decides what that number should be.
//
// WHY THIS MECHANISM IS WORTH RTL
// -------------------------------
// A USB audio device runs on ITS OWN crystal. The host runs on its own.
// Neither is wrong and neither can be adjusted to match the other, so
// the device's idea of 48,000 samples per second and the host's differ
// -- by a few parts per million, forever, in whichever direction the two
// crystals happen to disagree.
//
// A few parts per million sounds harmless. It is not, because the error
// ACCUMULATES into a buffer:
//
// 100 ppm at 48 kHz = 4.8 samples per second
// = 288 samples per minute
// = a buffer of 288 samples emptied, or overflowed,
// every single minute, forever
//
// There is no retry that fixes this and no error to report. The device
// will run out of samples to play, and the listener hears a click. Then
// it happens again a minute later.
//
// SO USB AUDIO PUTS THE DEVICE IN CHARGE OF THE RATE.
//
// An ASYNCHRONOUS audio device reports, once per frame on a dedicated
// feedback endpoint, how many samples per frame it actually wants -- as
// a fixed-point number, because the true answer is virtually never an
// integer. At full speed the format is 10.14: ten integer bits and
// fourteen fractional, so 48.000 samples/frame is 48 << 14.
//
// The host accumulates that fractional value and sends floor() of it,
// carrying the remainder into the next frame. Over many frames the
// average rate the host sends matches the rate the device consumes, to
// a precision of one part in 16384 of a sample.
//
// This module is the device half: watch the buffer, and ask for what
// will bring it back to where it should be.
// =====================================================================
module uac_feedback #(
// Nominal samples per USB frame. 48 at 48 kHz with 1 ms frames.
parameter integer NOM_SAMPLES = 48,
// Buffer depth in samples. Real devices use a few milliseconds' worth;
// the number only has to be big enough that the loop has room to work.
parameter integer BUF_DEPTH = 192
) (
input wire clk,
input wire rst_n,
// One pulse per USB frame. Everything in this module happens once per
// frame, because that is how often the host will look.
input wire sof,
// How many samples the host actually delivered this frame, and how many
// the device's own clock consumed. These differ, and their difference
// is the entire problem.
input wire [15:0] host_samples,
input wire [15:0] dev_consumed,
// How aggressively to correct. The feedback correction is
// (target - level) >> gain_shift, in whole samples per frame, so a
// LARGER shift is a GENTLER loop.
input wire [3:0] gain_shift,
// ---- the feedback value, 10.14 fixed point ----
//
// This is the only thing the host ever sees from this module, and the
// host will do exactly what it says.
output wire [23:0] fb_value,
output wire [15:0] buf_level,
output wire underrun,
output wire overrun,
output wire [31:0] n_frames,
output wire [31:0] n_underrun,
output wire [31:0] n_overrun,
output wire [31:0] n_clamped
);
// 48 samples/frame in 10.14 is 48 * 16384.
localparam [23:0] NOM_Q14 = NOM_SAMPLES * 16384;
// The level the loop steers towards: half full, so it has equal room to
// absorb an error in either direction.
localparam [15:0] TARGET = BUF_DEPTH / 2;
// The specification bounds how far the reported rate may deviate from
// nominal. A device that asked for wildly the wrong rate would be
// indistinguishable from a broken one, and a host is entitled to stop
// believing it. One sample per frame is the limit used here.
localparam [23:0] FB_MAX = NOM_Q14 + 16384;
localparam [23:0] FB_MIN = NOM_Q14 - 16384;
reg [15:0] level;
reg [23:0] fb_r;
reg under_r, over_r;
reg [31:0] frame_c, under_c, over_c, clamp_c;
assign buf_level = level;
assign fb_value = fb_r;
assign underrun = under_r;
assign overrun = over_r;
assign n_frames = frame_c;
assign n_underrun = under_c;
assign n_overrun = over_c;
assign n_clamped = clamp_c;
// ---- the new level, before clamping ----
//
// Signed, because the device can consume more than arrived. Computed
// combinationally so the clamp and the counters see one consistent
// value rather than each recomputing it.
wire signed [17:0] lvl_next = $signed({2'b00, level})
+ $signed({2'b00, host_samples})
- $signed({2'b00, dev_consumed});
wire hit_under = (lvl_next <= 0);
wire hit_over = (lvl_next >= $signed(BUF_DEPTH));
wire [15:0] lvl_clamped = hit_under ? 16'd0 :
hit_over ? BUF_DEPTH[15:0]
: lvl_next[15:0];
// ---- the correction ----
//
// Proportional only. An integral term would drive the steady-state
// error to zero, and it would also wind up during the many frames where
// the level is pinned at a clamp -- which is exactly when the error is
// largest and the loop can do nothing about it. The chapter measures
// what the proportional loop alone achieves.
wire signed [17:0] err = $signed({2'b00, TARGET}) - $signed({2'b00, lvl_clamped});
// err is in samples. Scaling it into 10.14 and then down by gain_shift
// gives a correction of (err >> gain_shift) samples per frame.
//
// WHY A CONCATENATION AND NOT `err <<< 14`.
//
// The obvious way to write this is
//
// wire signed [24:0] corr = ($signed({{7{err[17]}}, err}) <<< 14) >>> gain_shift;
//
// and it works, but not for the reason it appears to. A sign extension
// placed before a LEFT shift is dead code at any width: the shift
// discards exactly the bits the extension supplies. Here the seven
// extension bits sit at bits 24:18 and the shift would move them to
// 38:32, which do not exist. Bits 24:14 of the result come from
// err[10:0] and from nothing else.
//
// The arithmetic is then correct only because eleven bits happens to be
// enough for an error in [-96, +96]. The error spans +/- BUF_DEPTH/2 and
// eleven signed bits hold [-1024, +1023], so the coincidence survives up
// to BUF_DEPTH = 2046 and breaks at 2048: from there the top bits are
// silently lost and the loop starts correcting in the WRONG DIRECTION
// near the rails -- with no warning from any tool, because nothing is
// out of range and nothing overflows.
//
// Written as a concatenation the width is STATED: {err, 14'b0} is
// exactly 32 bits, err[17] lands on bit 31, and $signed makes the
// following shift arithmetic. Nothing is extended and nothing is
// discarded.
wire signed [31:0] err_q14 = $signed({err, 14'b0});
wire signed [31:0] corr = err_q14 >>> gain_shift;
// Also 32 bits, and for the same reason: corr is 32 bits and adding it
// into anything narrower would truncate it silently. The sum cannot
// overflow 32 bits, and now that is true by construction rather than by
// a count of how large the correction happens to get.
wire signed [31:0] fb_raw = $signed({8'b0, NOM_Q14}) + corr;
wire fb_hi = (fb_raw > $signed({8'b0, FB_MAX}));
wire fb_lo = (fb_raw < $signed({8'b0, FB_MIN}));
wire [23:0] fb_next = fb_hi ? FB_MAX :
fb_lo ? FB_MIN : fb_raw[23:0];
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
level <= TARGET; // start where the loop wants to be
fb_r <= NOM_Q14;
under_r <= 1'b0;
over_r <= 1'b0;
frame_c <= 32'd0;
under_c <= 32'd0;
over_c <= 32'd0;
clamp_c <= 32'd0;
end else begin
under_r <= 1'b0;
over_r <= 1'b0;
if (sof) begin
frame_c <= frame_c + 32'd1;
level <= lvl_clamped;
fb_r <= fb_next;
// An underrun is not "the buffer got low". It is "the device
// needed a sample that was not there", which is a click the
// listener hears -- so it is counted separately from the loop
// simply working hard.
if (hit_under) begin
under_r <= 1'b1;
under_c <= under_c + 32'd1;
end
if (hit_over) begin
over_r <= 1'b1;
over_c <= over_c + 32'd1;
end
// How often the loop wanted to ask for more than it is allowed
// to. A loop that is permanently clamped is not controlling
// anything, and this counter is how that becomes visible.
if (fb_hi || fb_lo) clamp_c <= clamp_c + 32'd1;
end
end
end
endmoduleThe sign extension that was not there
The comment block around err_q14 is long because that line is the one thing in
this module that was wrong in a way no tool reports, and it was found by a
mutation that scored zero.
The original code was the obvious thing:
wire signed [17:0] err = $signed({2'b00, TARGET}) - $signed({2'b00, lvl_clamped});
wire signed [24:0] corr = ($signed({{7{err[17]}}, err}) <<< 14) >>> gain_shift;Eighteen-bit signed error, sign-extended to 25 bits, shifted left 14 to scale it into 10.14, shifted right by the gain. It simulated correctly. It passed 365,783 checks. Then a mutation replaced the sign extension with a zero extension — the single most common fixed-point mistake there is — and the suite caught nothing:
MUT V-ALL V-DIR
BASE 0 0
R3 0 0 <-- a sign extension removed, and no failureWork the widths:
corr is 25 bits. err is 18. The extension supplies bits 24:18.
{ ext[6:0] , err[17:0] } <<< 14
^^^^^^^^
bits 24:18 would land at bits 38:32
Bits 24:14 of the result come from err[10:0]. And from nothing else.The seven extension bits are shifted straight off the top of the word. They are never read. A sign extension placed before a left shift is dead code at any width — the shift discards precisely the bits the extension supplies.
So why did it work? Because eleven bits is enough. err lives in
[-96, +96], err[10:0] is err mod 2048, and
err >= 0 : err[10:0] << 14 = err * 16384 correct
err < 0 : err[10:0] << 14 = 2^25 + err * 16384
read back as signed 25-bit = err * 16384 correctThe arithmetic is right by a width coincidence. The error spans
±BUF_DEPTH/2 and eleven signed bits hold [-1024, +1023], so the coincidence
survives up to BUF_DEPTH = 2046 and breaks at 2048. From there the top bits
are silently lost and the loop begins correcting in the wrong direction near the
rails.
Nothing goes out of range, so no simulator warns, no lint fires, and no
assertion in this bench would catch it — the bench sweeps the level
exhaustively, but BUF_DEPTH is a parameter and a parameter is not swept.
The fix is not to add a check. It is to stop inferring the width and state it:
wire signed [31:0] err_q14 = $signed({err, 14'b0});
wire signed [31:0] corr = err_q14 >>> gain_shift;{err, 14'b0} is exactly 32 bits by construction, err[17] lands on bit 31,
and $signed makes the following shift arithmetic. Nothing is extended and
nothing is discarded. The rewritten module produces byte-identical results
— 366,640 checks, zero errors, the same tables — which is what a
behaviour-preserving rewrite should produce, and is the evidence that it is one.
And the property became testable. R3 was retargeted to the mistake the new form
can make — >>> written as >>, which in Verilog is a logical shift whatever
the signedness of its operand — and it now scores 42,811.
6. What The Loop Actually Does
Two scenarios, both dumped cycle by cycle from the simulator rather than drawn. The first is the steady state, and it is the whole chapter in ten cycles.
Settled: a device crystal running a quarter of a sample per frame fast
Three things in that picture are the entire design working:
fb_valueis 790,528, not 786,432. The device is asking for 48.25 samples per frame, because that is what its crystal actually wants. It was never told the offset. It derived it from the buffer.buf_levelis 92, not 96. The loop is proportional, so producing a non-zero correction requires a non-zero error. The four-sample offset is not a failure to converge — it is the price of the correction, and section 8 shows it is exactly4096 × 2⁴ / 16384 = 4.host_samplesis never fractional. 49, 48, 48, 48. The fraction lives in the host's accumulator between frames, and never on the bus.
The second scenario is what happens when the loop cannot win.
The bus goes quiet: the underrun, and the limit on asking for help
7. The Testbench (Verilog)
The bench has an unusual job here. In the two previous chapters it played the host and watched the device. Here it has to play both clocks — because the disagreement between them is the entire phenomenon under test.
THE BENCH AS HOST accumulate the reported 10.14 rate,
send floor() of it, carry the remainder
THE BENCH AS CRYSTAL accumulate its OWN true rate, consume
floor() of it, carry the remainder
THE GAP BETWEEN THEM is what the design has to discoverBoth sides do the fractional-accumulator dance, independently, because that is what both sides really do. If the bench cheated and consumed a fractional number of samples, the design would be solving an easier problem than the real one.
Three deliberate choices in the bench are worth naming before the listing.
The model multiplies where the design shifts. The design computes
{err, 14'b0} >>> gain_shift. The model computes err * (16384 >> gain_shift).
Same number, derived from the other side — so a sign or rounding mistake in one
cannot be reproduced by the other. A model that mirrors the design's expression
is not an oracle; it is a second copy of the same assumption.
Outputs are captured at a defined instant. obs_level, obs_fb,
obs_under and obs_over are sampled once, one nanosecond after the clock
edge, into plain variables, and every check reads those. Earlier in this track a
set of assertions that read the DUT's outputs directly disagreed across three
languages driven by identical stimulus, because eleven of them sampled a
valid-gated output at an instant the three simulators ordered differently. An assertion whose result depends on evaluation order is
not an assertion.
The counters are checked against a tally the bench keeps itself, from the model's own formulation of each condition — never from the DUT's outputs. The four counters do not affect behaviour, which is exactly why nothing else in the bench would notice if they were wrong, and they are what a bring-up engineer will trust when the audio clicks and there is no other evidence.
// =====================================================================
// Testbench for uac_feedback.
//
// THE BENCH IS BOTH CLOCKS.
//
// It plays the host -- accumulating the feedback value in 10.14 and
// sending floor() of it, carrying the remainder -- and it plays the
// device's crystal, consuming at a rate that deliberately is NOT the
// nominal one. The gap between those two is the whole problem the
// feedback endpoint exists to solve.
//
// THE MODEL USES MULTIPLICATION WHERE THE DESIGN USES SHIFTS.
// The design computes its correction as (err << 14) >>> gain_shift. The
// model computes err * (16384 >> gain_shift). Same number, different
// derivation -- so a sign-extension or rounding mistake in one cannot be
// reproduced by the other.
//
// THE HEADLINE is not a pass/fail. It is: how far does the buffer
// actually drift before the loop catches it, as a function of how hard
// the loop pulls?
// =====================================================================
`timescale 1ns/1ps
module tb_fb_v;
localparam integer NOM_SAMPLES = 48;
localparam integer BUF_DEPTH = 192;
localparam integer TARGET = BUF_DEPTH / 2;
localparam integer NOM_Q14 = NOM_SAMPLES * 16384;
localparam integer FB_MAX = NOM_Q14 + 16384;
localparam integer FB_MIN = NOM_Q14 - 16384;
reg clk = 1'b0, rst_n = 1'b0;
always #5 clk = ~clk;
reg sof = 1'b0;
reg [15:0] host_samples = 16'd0, dev_consumed = 16'd0;
reg [3:0] gain_shift = 4'd4;
wire [23:0] fb_value;
wire [15:0] buf_level;
wire underrun, overrun;
wire [31:0] n_frames, n_underrun, n_overrun, n_clamped;
uac_feedback #(.NOM_SAMPLES(NOM_SAMPLES), .BUF_DEPTH(BUF_DEPTH)) dut (
.clk(clk), .rst_n(rst_n), .sof(sof),
.host_samples(host_samples), .dev_consumed(dev_consumed),
.gain_shift(gain_shift),
.fb_value(fb_value), .buf_level(buf_level),
.underrun(underrun), .overrun(overrun),
.n_frames(n_frames), .n_underrun(n_underrun),
.n_overrun(n_overrun), .n_clamped(n_clamped)
);
integer errors = 0, checks = 0, steps = 0;
integer seed;
// ---- cumulative across resets ----
//
// reset_dut runs once per configuration, so the DUT's own counters
// describe only the last one.
integer c_frames = 0, c_under = 0, c_over = 0, c_clamp = 0;
// ---- per-reset tally, maintained from the MODEL ----
//
// The DUT's four counters do not affect its behaviour, which is exactly
// why nothing else in the bench would notice if they were wrong -- and
// they are what a bring-up engineer will trust when the audio clicks and
// no other evidence exists. These are kept from the model's own
// formulation of each condition, never from the DUT's outputs.
integer t_frames, t_under, t_over, t_clamp;
// $random is SIGNED: mask the sign bit before any modulo.
function [31:0] urand;
input dummy;
begin urand = $random(seed) & 32'h3FFF_FFFF; end
endfunction
task ck(input cond, input [255:0] what);
begin
checks = checks + 1;
if (!cond) begin
errors = errors + 1;
if (errors <= 20)
$display(" ERROR @%0t step#%0d: %0s", $time, steps, what);
end
end
endtask
// ---- the model, formulated differently from the design ----
integer m_level;
// The design shifts; the model multiplies. err * (16384 >> shift) is
// the same correction as (err << 14) >>> shift, arrived at from the
// other side.
function integer model_fb(input integer lvl, input integer shift);
integer err, corr, raw;
begin
err = TARGET - lvl;
corr = err * (16384 >> shift);
raw = NOM_Q14 + corr;
if (raw > FB_MAX) model_fb = FB_MAX;
else if (raw < FB_MIN) model_fb = FB_MIN;
else model_fb = raw;
end
endfunction
function integer clamp_level(input integer lvl);
begin
if (lvl <= 0) clamp_level = 0;
else if (lvl >= BUF_DEPTH) clamp_level = BUF_DEPTH;
else clamp_level = lvl;
end
endfunction
// ---- what the design said, sampled at a DEFINED instant ----
integer obs_level, obs_fb, obs_under, obs_over;
// ---- the measurement ----
integer tab_off [0:4];
integer tab_gain [0:3];
integer tab_max [0:4][0:3]; // worst excursion from target
integer tab_bad [0:4][0:3]; // under + overruns
task reset_dut;
begin
rst_n = 1'b0; sof = 1'b0;
host_samples = 16'd0; dev_consumed = 16'd0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
@(posedge clk); #1;
m_level = TARGET;
t_frames = 0; t_under = 0; t_over = 0; t_clamp = 0;
end
endtask
// ---- PROPERTY 8: the four counters agree with an independent tally ----
//
// Called once per frame in the phases where the counted events actually
// happen. Elsewhere no event occurs and the check would be trivially
// true, which is worse than not making it: it would inflate the count
// without testing anything.
task chk_counters;
begin
ck(n_frames == t_frames, "n_frames did not count every frame");
ck(n_underrun == t_under, "n_underrun disagrees with the tally");
ck(n_overrun == t_over, "n_overrun disagrees with the tally");
ck(n_clamped == t_clamp, "n_clamped disagrees with the tally");
end
endtask
// One USB frame: the host delivers, the device consumes, the loop
// reports a new rate.
task one_frame(input integer send, input integer consume);
integer e_level, e_fb;
begin
e_level = clamp_level(m_level + send - consume);
e_fb = model_fb(e_level, gain_shift);
sof = 1'b1; host_samples = send[15:0]; dev_consumed = consume[15:0];
@(posedge clk); #1;
sof = 1'b0;
obs_level = buf_level;
obs_fb = fb_value;
obs_under = underrun;
obs_over = overrun;
// ---- PROPERTY 1: the buffer level is exact ----
//
// Not approximately right. The level is the state the whole loop is
// built on, and a level that drifted from the truth would make every
// feedback value wrong in a way that looked like a tuning problem.
ck(obs_level == e_level, "the buffer level disagrees with the model");
// ---- PROPERTY 2: the feedback value is exact ----
ck(obs_fb == e_fb, "the feedback value disagrees with the model");
// ---- PROPERTY 3: the reported rate stays inside the legal band ----
//
// A device that asked for wildly the wrong rate is indistinguishable
// from a broken one, and a host is entitled to stop believing it.
ck(obs_fb <= FB_MAX && obs_fb >= FB_MIN,
"the feedback value left the legal band");
// ---- PROPERTY 4: underrun means the device ran dry ----
//
// Not "the buffer got low". A click the listener hears, which is why
// it is flagged separately from the loop merely working hard.
ck(obs_under == ((m_level + send - consume) <= 0 ? 1 : 0),
"underrun does not mean the device ran out of samples");
ck(obs_over == ((m_level + send - consume) >= BUF_DEPTH ? 1 : 0),
"overrun does not mean the buffer filled");
// the tally, from the model -- note it uses m_level BEFORE the
// assignment below, which is the level the frame started from
t_frames = t_frames + 1;
if ((m_level + send - consume) <= 0) t_under = t_under + 1;
if ((m_level + send - consume) >= BUF_DEPTH) t_over = t_over + 1;
begin : tally_clamp
integer raw;
raw = NOM_Q14 + (TARGET - e_level) * (16384 >> gain_shift);
if (raw > FB_MAX || raw < FB_MIN) t_clamp = t_clamp + 1;
end
m_level = e_level;
c_frames = c_frames + 1;
if (obs_under) c_under = c_under + 1;
if (obs_over) c_over = c_over + 1;
if (obs_fb == FB_MAX || obs_fb == FB_MIN) c_clamp = c_clamp + 1;
steps = steps + 1;
end
endtask
// -------------------------------------------------------------------
// Run the loop for `nframes` frames with the device's crystal running
// `off_q14` away from nominal, and report the worst excursion.
//
// The host does exactly what a real host does: accumulate the reported
// 10.14 rate, send floor() of it, carry the remainder. The device does
// the same with its own true rate. Neither ever sends a fractional
// sample, and the fractions are what make the average come out right.
// -------------------------------------------------------------------
task run_loop(input integer off_q14, input integer shift,
input integer init_level, input integer nframes,
output integer max_dev, output integer bad,
output integer settled_dev);
integer hacc, dacc, send, consume, f, dev;
begin
reset_dut;
gain_shift = shift[3:0];
// Place the buffer at its starting level: one frame in which the
// host delivers `init_level` and the device consumes exactly the
// reset level, which leaves the level at init_level.
one_frame(init_level, TARGET);
hacc = 0; dacc = 0;
max_dev = 0; bad = 0; settled_dev = 0;
dev = NOM_Q14 + off_q14;
for (f = 0; f < nframes; f = f + 1) begin
// the host: accumulate what the device asked for, send floor()
hacc = hacc + fb_value;
send = hacc >> 14;
hacc = hacc - (send << 14);
// the device's own crystal, doing the same with its true rate
dacc = dacc + dev;
consume = dacc >> 14;
dacc = dacc - (consume << 14);
one_frame(send, consume);
if ((m_level - TARGET) > max_dev) max_dev = m_level - TARGET;
if ((TARGET - m_level) > max_dev) max_dev = TARGET - m_level;
// The SETTLED excursion, over the last quarter of the run only.
// max_dev includes the transient from wherever the buffer
// started, which is an initial condition rather than a property
// of the loop; this is the steady state.
if (f >= (nframes * 3) / 4) begin
if ((m_level - TARGET) > settled_dev) settled_dev = m_level - TARGET;
if ((TARGET - m_level) > settled_dev) settled_dev = TARGET - m_level;
end
if (obs_under || obs_over) bad = bad + 1;
end
end
endtask
// ---- exhaustive reach ----
//
// 5 crystal offsets x 4 loop gains x 3 starting levels = 60. Every
// dimension is an independent input with no forbidden combinations.
reg reach [0:59];
integer nr, ri;
integer oi, gi, ii, k, mx, bd, sd;
integer OFF [0:4];
integer GAIN [0:3];
integer INIT [0:2];
initial begin
for (ri = 0; ri < 60; ri = ri + 1) reach[ri] = 1'b0;
// Crystal error in 10.14 samples per frame. 4096 is a quarter of a
// sample per frame -- about 5200 ppm, far beyond any real crystal, and
// included so the sweep spans the whole transition rather than
// stopping at the first configuration that copes. 256 is about 325 ppm,
// which is the order of a cheap part.
OFF[0] = -4096; OFF[1] = -256; OFF[2] = 0; OFF[3] = 256; OFF[4] = 4096;
// Larger shift is a GENTLER loop.
GAIN[0] = 3; GAIN[1] = 4; GAIN[2] = 5; GAIN[3] = 6;
INIT[0] = 16; INIT[1] = TARGET; INIT[2] = BUF_DEPTH - 16;
seed = 32'd29003;
reset_dut;
// =============================================================
// PHASE 1 (DIRECTED, EXHAUSTIVE) -- the whole configuration space.
// =============================================================
for (oi = 0; oi < 5; oi = oi + 1)
for (gi = 0; gi < 4; gi = gi + 1)
for (ii = 0; ii < 3; ii = ii + 1) begin
// 1200 frames, not 400.
//
// A proportional loop's settling time scales with its gain: a
// shift of 6 has a time constant four times longer than a shift of
// 4, and from 80 samples away it needs several hundred frames just
// for the transient. At 400 frames the last quarter still contained
// one sample of it, and the closed-form check failed by exactly one
// -- six times, all at the gentlest gain.
//
// The measurement window was adequate for an aggressive loop and
// not for a gentle one. That is a property of the loop, and the
// right response is to give every configuration enough time rather
// than to loosen the property.
run_loop(OFF[oi], GAIN[gi], INIT[ii], 1200, mx, bd, sd);
ri = (oi * 4 + gi) * 3 + ii;
reach[ri] = 1'b1;
// ---- PROPERTY 5: the steady-state excursion is a CLOSED FORM ----
//
// excursion = crystal error (samples/frame) x 2^gain_shift
//
// which in 10.14 units is |off| << shift >> 14, floored at one
// sample because the buffer is counted in whole samples and any
// non-zero error eventually moves it by one.
//
// This is the chapter's result, and asserting it is what turns the
// table below from an observation into a measurement. A loop whose
// gain did not do what the arithmetic says would still produce a
// plausible-looking table.
begin : closedform
integer e_exc, a;
a = (OFF[oi] < 0) ? -OFF[oi] : OFF[oi];
e_exc = (a == 0) ? 0 : (((a << GAIN[gi]) >> 14) < 1 ? 1 : ((a << GAIN[gi]) >> 14));
// Asserted against the settled value, not the peak. The peak
// includes the transient from wherever the buffer happened to
// start, which is an initial condition and not a property of the
// loop -- asserting the closed form against the peak failed 40
// times for exactly that reason.
//
// Stating it for EVERY starting level is also the convergence
// property: wherever the buffer began, the loop brings it to the
// same steady state.
ck(sd == e_exc,
"the settled excursion does not match error x 2^gain");
end
// record the middle starting level for the headline table, so the
// table measures the loop rather than the initial condition
if (ii == 1) begin
tab_off[oi] = OFF[oi];
tab_gain[gi] = GAIN[gi];
tab_max[oi][gi] = sd;
tab_bad[oi][gi] = bd;
end
end
// =============================================================
// PHASE 2 (DIRECTED) -- a matched crystal must not move at all.
//
// With zero offset and the buffer starting exactly on target, the
// loop has nothing to correct. If it moves anyway, the loop is
// injecting the very error it exists to remove -- and every other
// measurement in this chapter would be contaminated by it.
// =============================================================
run_loop(0, 4, TARGET, 400, mx, bd, sd);
ck(mx == 0, "the loop moved the buffer with a perfectly matched crystal");
ck(bd == 0, "a matched crystal produced an underrun or overrun");
ck(fb_value == NOM_Q14,
"a matched crystal did not settle at exactly the nominal rate");
// =============================================================
// PHASE 3 (DIRECTED) -- the loop pulls the RIGHT WAY.
//
// Sign errors in a control loop are not subtle in their effect and
// are very subtle in the code. A loop wired backwards drives the
// buffer to a rail and holds it there, and every symptom points at
// the crystal rather than at the sign.
// =============================================================
reset_dut;
gain_shift = 4'd4;
one_frame(16, TARGET); // start well BELOW target
ck(fb_value > NOM_Q14,
"with the buffer low the device did not ask for MORE samples");
reset_dut;
one_frame(BUF_DEPTH - 16, TARGET); // start well ABOVE target
ck(fb_value < NOM_Q14,
"with the buffer high the device did not ask for FEWER samples");
// =============================================================
// PHASE 4 (DIRECTED, EXHAUSTIVE) -- the legal band is respected.
//
// Every starting level from empty to full, at the most aggressive
// gain, so the correction the loop WANTS is as large as it can be.
// The reported rate must still never leave the band.
// =============================================================
for (k = 0; k <= BUF_DEPTH; k = k + 8) begin
reset_dut;
gain_shift = 4'd3;
one_frame(k, TARGET);
ck(fb_value <= FB_MAX && fb_value >= FB_MIN,
"an extreme buffer level pushed the reported rate out of band");
end
// =============================================================
// PHASE 5 (DIRECTED) -- the bus goes quiet, and then it floods.
//
// Every phase above keeps the buffer near the middle, which means
// the two clamps in the design are never actually exercised: the
// sweep touches the rails EXACTLY, never past them, and a clamp
// that fires exactly at its own bound returns the value it was
// given. A wrong clamp would have been inert in every phase so far.
//
// A real device does go past the rails. The host loses its
// bandwidth reservation, or the bus is suspended, and nothing
// arrives for several frames while the device keeps consuming. That
// is the underrun the listener hears, and it is the one condition
// where the loop can do nothing except ask for the most it is
// allowed to and wait.
//
// This phase also checks the DUT's four counters against a tally
// kept here, which is the only place in the bench where they are
// verified rather than merely printed.
// =============================================================
reset_dut;
gain_shift = 4'd4;
begin : rails
integer f2;
// ---- nothing arrives, the device keeps consuming ----
for (f2 = 0; f2 < 20; f2 = f2 + 1) begin
one_frame(0, NOM_SAMPLES);
// ---- PROPERTY 6: the level never leaves the buffer ----
//
// A level below zero or above the depth is not a small error. It
// is a read or write outside the ring buffer.
ck(obs_level <= BUF_DEPTH, "the level rose above the buffer depth");
// Checked every frame, not once at the end: a counter that is
// right at the end of a phase and wrong in the middle of it is
// still a counter that cannot be trusted.
chk_counters;
end
ck(obs_level == 0, "twenty frames of silence did not empty the buffer");
// ---- PROPERTY 7: at the rail the device asks for the most it may --
//
// It cannot ask for enough to refill instantly -- the band forbids
// that -- so the correct behaviour is to sit at the limit until the
// buffer recovers, which is what makes recovery gradual and audible
// as a single click rather than a burst of them.
ck(fb_value == FB_MAX,
"an empty buffer did not make the device ask for the maximum rate");
// ---- the host over-delivers: twice nominal, nothing consumed ----
for (f2 = 0; f2 < 20; f2 = f2 + 1) begin
one_frame(2 * NOM_SAMPLES, 0);
ck(obs_level <= BUF_DEPTH, "the level rose above the buffer depth");
chk_counters;
end
ck(obs_level == BUF_DEPTH, "twenty frames of flood did not fill the buffer");
ck(fb_value == FB_MIN,
"a full buffer did not make the device ask for the minimum rate");
ck(n_frames == 40, "forty frames were played and not forty counted");
end
// =============================================================
// PHASE 6 (DIRECTED, EXHAUSTIVE) -- the two boundaries, reached
// five different ways.
//
// `underrun` is defined on lvl_next <= 0 and `overrun` on
// lvl_next >= BUF_DEPTH. Both bounds are INCLUSIVE, and an inclusive
// bound differs from an exclusive one at exactly one point: the
// bound itself. A sweep cannot widen that domain, because the domain
// is one value.
//
// What can be widened is the number of ways the bench arrives there,
// and the number of consequences it checks once it has. The boundary
// must hold however the level got to it, so the level is placed at
// five different starting points and driven to each of
// {-2,-1,0,+1,+2} around both bounds from each one.
// =============================================================
begin : bounds
integer li, dd;
integer LV [0:4];
LV[0] = 24; LV[1] = 48; LV[2] = TARGET; LV[3] = 144; LV[4] = 168;
for (li = 0; li < 5; li = li + 1)
for (dd = -2; dd <= 2; dd = dd + 1) begin
// ---- the EMPTY bound: drive lvl_next to exactly dd ----
reset_dut;
gain_shift = 4'd4;
one_frame(LV[li], TARGET); // place the level at LV[li]
one_frame(0, LV[li] - dd); // lvl_next = dd
chk_counters;
// ---- the FULL bound: drive lvl_next to exactly BUF_DEPTH+dd ----
reset_dut;
gain_shift = 4'd4;
one_frame(LV[li], TARGET);
one_frame(BUF_DEPTH + dd - LV[li], 0);
chk_counters;
end
end
// =============================================================
// PHASE 7 (RANDOM) -- a jittery host.
//
// A real host does not deliver exactly what it was asked for every
// frame: scheduling, other devices and its own rounding all
// interfere. The loop has to survive that without the buffer
// wandering off.
// =============================================================
`ifndef DIRECTED_ONLY
reset_dut;
gain_shift = 4'd4;
one_frame(TARGET, TARGET);
for (k = 0; k < 600; k = k + 1) begin : jitter
integer s, c;
s = (fb_value >> 14) + (urand(0) % 3) - 1; // host is +/-1 sample out
c = NOM_SAMPLES + (urand(0) % 3) - 1; // device jitters too
if (s < 0) s = 0;
if (c < 0) c = 0;
one_frame(s, c);
end
`endif
nr = 0; for (ri = 0; ri < 60; ri = ri + 1) if (reach[ri]) nr = nr + 1;
$display("steps=%0d checks=%0d reach=%0d/60 errors=%0d",
steps, checks, nr, errors);
$display("[uac] frames=%0d underruns=%0d overruns=%0d rate_clamped=%0d",
c_frames, c_under, c_over, c_clamp);
$display("--- worst buffer excursion from target, in samples ---");
$display(" crystal err gain>>3 gain>>4 gain>>5 gain>>6");
for (oi = 0; oi < 5; oi = oi + 1)
$display(" %11d %9d %9d %9d %9d", tab_off[oi],
tab_max[oi][0], tab_max[oi][1], tab_max[oi][2], tab_max[oi][3]);
$display("--- underruns + overruns in 1200 frames ---");
$display(" crystal err gain>>3 gain>>4 gain>>5 gain>>6");
for (oi = 0; oi < 5; oi = oi + 1)
$display(" %11d %9d %9d %9d %9d", tab_off[oi],
tab_bad[oi][0], tab_bad[oi][1], tab_bad[oi][2], tab_bad[oi][3]);
if (nr != 60) begin
$display("FAIL: exhaustive sweep incomplete"); errors = errors + 1;
end
if (errors == 0) $display("PASS: 0 errors in %0d checks", checks);
else $display("FAIL: %0d errors in %0d checks", errors, checks);
$finish;
end
endmodule8. The Measurement
The headline
Sixty configurations — five crystal offsets × four loop gains × three starting buffer levels — each run for 1,200 frames. The number reported is the worst the buffer deviated from its target in the settled state, in whole samples:
--- worst buffer excursion from target, in samples ---
crystal err gain>>3 gain>>4 gain>>5 gain>>6
-4096 2 4 8 16
-256 1 1 1 1
0 0 0 0 0
256 1 1 1 1
4096 2 4 8 16
--- underruns + overruns in 1200 frames ---
crystal err gain>>3 gain>>4 gain>>5 gain>>6
-4096 0 0 0 0
-256 0 0 0 0
0 0 0 0 0
256 0 0 0 0
4096 0 0 0 0The crystal error is in 10.14 samples per frame, so:
4096 / 786432 = 5,208 ppm fifty times worse than any real part
256 / 786432 = 326 ppm the order of a cheap crystal
0 = perfectly matched, which never happensTwo results, and the second is the one that matters to a product.
The excursion doubles when the gain shift increments. Not approximately.
Exactly. 2, 4, 8, 16 down the -4096 row, and the same up the +4096 row. A
gentler loop tolerates a larger steady-state error, in strict proportion.
Not one underrun or overrun, anywhere in the table. 60 configurations × 1,200 frames = 72,000 frames, at crystal errors up to fifty times worse than any real part, from starting levels 80 samples off target — and the buffer never once ran dry or overflowed. Forty lines of proportional control, with a 192-sample buffer, is sufficient. That is the answer to "do I need a PI controller here", and it is measured rather than asserted.
The closed form is also the convergence property
The assertion is made for every one of the three starting levels: 16 samples (nearly empty), 96 (on target) and 176 (nearly full). Since the expected value does not depend on where the buffer started, asserting it from all three is the statement that the loop converges — wherever the buffer began, it ends up in the same steady state.
That is a stronger property than the table shows, and getting it to hold took two corrections that are worth recording.
The general form of that mistake is worth carrying: a window that is adequate for an aggressive configuration is not adequate for a gentle one, and a suite that sweeps the gain must scale its observation window with it. The symptom — a property that fails only at one end of a swept axis, always by the same small amount — is diagnostic.
Where the numbers come from
Seven phases. The first six are directed and fully deterministic, which is why the directed mutation columns in section 13 must agree exactly across all three languages.
PHASE 1 DIRECTED, EXHAUSTIVE 5 offsets x 4 gains x 3 starts = 60
1,200 frames each; the closed form asserted
on every one
PHASE 2 DIRECTED a perfectly matched crystal must not move
the buffer at all, and must settle at
exactly the nominal rate
PHASE 3 DIRECTED the loop pulls the RIGHT WAY: buffer low
means ask for MORE
PHASE 4 DIRECTED, EXHAUSTIVE every starting level 0..192 in steps of 8,
at the most aggressive gain, must keep the
reported rate inside the legal band
PHASE 5 DIRECTED the bus goes quiet, then floods: the only
phase that drives the level PAST the rails
PHASE 6 DIRECTED, EXHAUSTIVE both inclusive bounds, reached five
different ways each
PHASE 7 RANDOM a jittery host, +/- 1 sample per frame,
600 framesPhases 5 and 6 were added after the first mutation run, and both for the same reason.
Phase 6 is the answer to a different problem: underrun is defined on
lvl_next <= 0 and an inclusive bound differs from an exclusive one at
exactly one point. No amount of sweeping widens that domain, because the
domain is one value. What can be widened is the number of ways the bench
arrives there and the number of consequences it checks once it has — so the
level is placed at five different starting points and driven to each of
{-2,-1,0,+1,+2} around both bounds from each one. The boundary has to hold
however the level got to it.
R6 (the inclusive bound made exclusive) 2 -> 51
R5 (the upper clamp made off by one) 21 -> 36
R8 (the underrun counter frozen) 1 -> 54
R9 (only the upper rate clamp counted) 1 -> 54Run totals
All three languages, same design, same directed stimulus:
steps checks reach errors
Verilog-2005 73,229 366,640 60/60 0
SystemVerilog 73,229 366,640 60/60 0
VHDL-2008 73,229 366,640 60/60 0
DIRECTED ONLY (-D DIRECTED_ONLY / -gDIRECTED_ONLY=true)
Verilog-2005 72,628 363,635 60/60 0
SystemVerilog 72,628 363,635 60/60 0
VHDL-2008 72,628 363,635 60/60 0The full columns agree exactly too, which is not automatic: the random phase uses
$random in the Icarus benches and a VHDL-native LCG in the third, so the
stimulus in phase 7 genuinely differs. It agrees because the check count in
that phase is stimulus-independent — 600 frames, five checks each — and because
all 3,005 of those checks pass in all three. The one place the difference shows
is the rate_clamped tally in the summary line: 2,262 in the Icarus benches,
2,301 in the VHDL bench. Different jitter, different number of frames that
happened to push the request to a limit. That number is reported, not asserted,
for exactly that reason.
9. SystemVerilog
The same hardware contract: same ports, same widths, same reset values, same cycle-by-cycle behaviour. What changes is that the two fixed-point quantities get named types, so the 10.14 format is stated in the declaration instead of living in a comment.
// =====================================================================
// uac_feedback -- SystemVerilog.
//
// Same hardware contract as the Verilog file: same ports, same widths,
// same reset values, same cycle-by-cycle behaviour. What changes is that
// the fixed-point quantities get named types, so the 10.14 format is
// stated in the declaration rather than living in a comment.
//
// EVERY CONTINUOUS ASSIGNMENT IS `logic` + `assign` ON SEPARATE LINES,
// and that is not cosmetic. `logic x = expr;` is a one-shot VARIABLE
// INITIALISER in SystemVerilog -- evaluated once at time zero and never
// again -- while the Verilog `wire x = expr;` it came from is a
// continuous assignment. Earlier in this track the mechanical
// translation of five such lines produced 29,580 phantom failures
// against a design that was entirely correct.
//
// uac_feedback -- the control loop that keeps a USB audio device from
// running out of samples, or drowning in them.
//
// CLASSIFICATION: simplified synthesisable teaching RTL.
// This is NOT an audio device. There is no DAC, no volume control, no
// format negotiation and no mixer. It is the feedback endpoint: the one
// number an asynchronous audio device sends back every frame, and the
// arithmetic that decides what that number should be.
//
// WHY THIS MECHANISM IS WORTH RTL
// -------------------------------
// A USB audio device runs on ITS OWN crystal. The host runs on its own.
// Neither is wrong and neither can be adjusted to match the other, so
// the device's idea of 48,000 samples per second and the host's differ
// -- by a few parts per million, forever, in whichever direction the two
// crystals happen to disagree.
//
// A few parts per million sounds harmless. It is not, because the error
// ACCUMULATES into a buffer:
//
// 100 ppm at 48 kHz = 4.8 samples per second
// = 288 samples per minute
// = a buffer of 288 samples emptied, or overflowed,
// every single minute, forever
//
// There is no retry that fixes this and no error to report. The device
// will run out of samples to play, and the listener hears a click. Then
// it happens again a minute later.
//
// SO USB AUDIO PUTS THE DEVICE IN CHARGE OF THE RATE.
//
// An ASYNCHRONOUS audio device reports, once per frame on a dedicated
// feedback endpoint, how many samples per frame it actually wants -- as
// a fixed-point number, because the true answer is virtually never an
// integer. At full speed the format is 10.14: ten integer bits and
// fourteen fractional, so 48.000 samples/frame is 48 << 14.
//
// The host accumulates that fractional value and sends floor() of it,
// carrying the remainder into the next frame. Over many frames the
// average rate the host sends matches the rate the device consumes, to
// a precision of one part in 16384 of a sample.
//
// This module is the device half: watch the buffer, and ask for what
// will bring it back to where it should be.
// =====================================================================
module uac_feedback #(
// Nominal samples per USB frame. 48 at 48 kHz with 1 ms frames.
parameter integer NOM_SAMPLES = 48,
// Buffer depth in samples. Real devices use a few milliseconds' worth;
// the number only has to be big enough that the loop has room to work.
parameter integer BUF_DEPTH = 192
) (
input logic clk,
input logic rst_n,
// One pulse per USB frame. Everything in this module happens once per
// frame, because that is how often the host will look.
input logic sof,
// How many samples the host actually delivered this frame, and how many
// the device's own clock consumed. These differ, and their difference
// is the entire problem.
input logic[15:0] host_samples,
input logic[15:0] dev_consumed,
// How aggressively to correct. The feedback correction is
// (target - level) >> gain_shift, in whole samples per frame, so a
// LARGER shift is a GENTLER loop.
input logic[3:0] gain_shift,
// ---- the feedback value, 10.14 fixed point ----
//
// This is the only thing the host ever sees from this module, and the
// host will do exactly what it says.
output logic[23:0] fb_value,
output logic[15:0] buf_level,
output logic underrun,
output logic overrun,
output logic[31:0] n_frames,
output logic[31:0] n_underrun,
output logic[31:0] n_overrun,
output logic[31:0] n_clamped
);
// 48 samples/frame in 10.14 is 48 * 16384.
localparam [23:0] NOM_Q14 = NOM_SAMPLES * 16384;
// The level the loop steers towards: half full, so it has equal room to
// absorb an error in either direction.
localparam [15:0] TARGET = BUF_DEPTH / 2;
// The specification bounds how far the reported rate may deviate from
// nominal. A device that asked for wildly the wrong rate would be
// indistinguishable from a broken one, and a host is entitled to stop
// believing it. One sample per frame is the limit used here.
localparam [23:0] FB_MAX = NOM_Q14 + 16384;
localparam [23:0] FB_MIN = NOM_Q14 - 16384;
// ---- named types for the two fixed-point quantities ----
//
// samples_t is a whole number of samples. q10_14_t is the wire format
// the host reads: ten integer bits and fourteen fractional. Giving them
// names means a mistake that mixes the two is visible at the
// declaration instead of being buried inside a shift.
typedef logic [15:0] samples_t;
typedef logic [23:0] q10_14_t;
samples_t level;
q10_14_t fb_r;
logic under_r, over_r;
logic [31:0] frame_c, under_c, over_c, clamp_c;
assign buf_level = level;
assign fb_value = fb_r;
assign underrun = under_r;
assign overrun = over_r;
assign n_frames = frame_c;
assign n_underrun = under_c;
assign n_overrun = over_c;
assign n_clamped = clamp_c;
// ---- the new level, before clamping ----
//
// Signed, because the device can consume more than arrived. Computed
// combinationally so the clamp and the counters see one consistent
// value rather than each recomputing it.
logic signed [17:0] lvl_next;
assign lvl_next = $signed({2'b00, level})
+ $signed({2'b00, host_samples})
- $signed({2'b00, dev_consumed});
logic hit_under;
assign hit_under = (lvl_next <= 0);
logic hit_over;
assign hit_over = (lvl_next >= $signed(BUF_DEPTH));
samples_t lvl_clamped;
assign lvl_clamped = hit_under ? 16'd0 :
hit_over ? samples_t'(BUF_DEPTH)
: lvl_next[15:0];
// ---- the correction ----
//
// Proportional only. An integral term would drive the steady-state
// error to zero, and it would also wind up during the many frames where
// the level is pinned at a clamp -- which is exactly when the error is
// largest and the loop can do nothing about it. The chapter measures
// what the proportional loop alone achieves.
logic signed [17:0] err;
assign err = $signed({2'b00, TARGET}) - $signed({2'b00, lvl_clamped});
// err is in samples. Scaling it into 10.14 and then down by gain_shift
// gives a correction of (err >> gain_shift) samples per frame.
//
// WHY A CONCATENATION AND NOT `err <<< 14`.
//
// The obvious way to write this is
//
// wire signed [24:0] corr = ($signed({{7{err[17]}}, err}) <<< 14) >>> gain_shift;
//
// and it works, but not for the reason it appears to. A sign extension
// placed before a LEFT shift is dead code at any width: the shift
// discards exactly the bits the extension supplies. Here the seven
// extension bits sit at bits 24:18 and the shift would move them to
// 38:32, which do not exist. Bits 24:14 of the result come from
// err[10:0] and from nothing else.
//
// The arithmetic is then correct only because eleven bits happens to be
// enough for an error in [-96, +96]. The error spans +/- BUF_DEPTH/2 and
// eleven signed bits hold [-1024, +1023], so the coincidence survives up
// to BUF_DEPTH = 2046 and breaks at 2048: from there the top bits are
// silently lost and the loop starts correcting in the WRONG DIRECTION
// near the rails -- with no warning from any tool, because nothing is
// out of range and nothing overflows.
//
// Written as a concatenation the width is STATED: {err, 14'b0} is
// exactly 32 bits, err[17] lands on bit 31, and $signed makes the
// following shift arithmetic. Nothing is extended and nothing is
// discarded.
logic signed [31:0] err_q14;
assign err_q14 = $signed({err, 14'b0});
logic signed [31:0] corr;
assign corr = err_q14 >>> gain_shift;
// Also 32 bits, and for the same reason: corr is 32 bits and adding it
// into anything narrower would truncate it silently. The sum cannot
// overflow 32 bits, and now that is true by construction rather than by
// a count of how large the correction happens to get.
logic signed [31:0] fb_raw;
assign fb_raw = $signed({8'b0, NOM_Q14}) + corr;
logic fb_hi;
assign fb_hi = (fb_raw > $signed({8'b0, FB_MAX}));
logic fb_lo;
assign fb_lo = (fb_raw < $signed({8'b0, FB_MIN}));
q10_14_t fb_next;
assign fb_next = fb_hi ? FB_MAX :
fb_lo ? FB_MIN : fb_raw[23:0];
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
level <= TARGET; // start where the loop wants to be
fb_r <= NOM_Q14;
under_r <= 1'b0;
over_r <= 1'b0;
frame_c <= 32'd0;
under_c <= 32'd0;
over_c <= 32'd0;
clamp_c <= 32'd0;
end else begin
under_r <= 1'b0;
over_r <= 1'b0;
if (sof) begin
frame_c <= frame_c + 32'd1;
level <= lvl_clamped;
fb_r <= fb_next;
// An underrun is not "the buffer got low". It is "the device
// needed a sample that was not there", which is a click the
// listener hears -- so it is counted separately from the loop
// simply working hard.
if (hit_under) begin
under_r <= 1'b1;
under_c <= under_c + 32'd1;
end
if (hit_over) begin
over_r <= 1'b1;
over_c <= over_c + 32'd1;
end
// How often the loop wanted to ask for more than it is allowed
// to. A loop that is permanently clamped is not controlling
// anything, and this counter is how that becomes visible.
if (fb_hi || fb_lo) clamp_c <= clamp_c + 32'd1;
end
end
end
endmoduleThe SystemVerilog testbench
Same seed and same phase order as the Verilog bench, deliberately. Icarus seeds
$random identically for both languages, so the two benches drive identical
stimulus — which means any difference between their two mutation columns is a
real difference between the two designs, not between their inputs. The
independent-stimulus role belongs to VHDL.
// =====================================================================
// Testbench for uac_feedback. -- SystemVerilog.
//
// SAME SEED AND SAME PHASE ORDER AS THE VERILOG BENCH, deliberately.
// Icarus seeds $random identically, so both drive identical stimulus and
// any difference between the two mutation columns is a real difference
// between the two DESIGNS. The independent-stimulus role is VHDL's.
//
// THE BENCH IS BOTH CLOCKS.
//
// It plays the host -- accumulating the feedback value in 10.14 and
// sending floor() of it, carrying the remainder -- and it plays the
// device's crystal, consuming at a rate that deliberately is NOT the
// nominal one. The gap between those two is the whole problem the
// feedback endpoint exists to solve.
//
// THE MODEL USES MULTIPLICATION WHERE THE DESIGN USES SHIFTS.
// The design computes its correction as (err << 14) >>> gain_shift. The
// model computes err * (16384 >> gain_shift). Same number, different
// derivation -- so a sign-extension or rounding mistake in one cannot be
// reproduced by the other.
//
// THE HEADLINE is not a pass/fail. It is: how far does the buffer
// actually drift before the loop catches it, as a function of how hard
// the loop pulls?
// =====================================================================
`timescale 1ns/1ps
module tb_fb_sv;
localparam integer NOM_SAMPLES = 48;
localparam integer BUF_DEPTH = 192;
localparam integer TARGET = BUF_DEPTH / 2;
localparam integer NOM_Q14 = NOM_SAMPLES * 16384;
localparam integer FB_MAX = NOM_Q14 + 16384;
localparam integer FB_MIN = NOM_Q14 - 16384;
logic clk = 1'b0, rst_n = 1'b0;
always #5 clk = ~clk;
logic sof = 1'b0;
logic [15:0] host_samples = 16'd0, dev_consumed = 16'd0;
logic [3:0] gain_shift = 4'd4;
logic [23:0] fb_value;
logic [15:0] buf_level;
logic underrun, overrun;
logic [31:0] n_frames, n_underrun, n_overrun, n_clamped;
uac_feedback #(.NOM_SAMPLES(NOM_SAMPLES), .BUF_DEPTH(BUF_DEPTH)) dut (
.clk(clk), .rst_n(rst_n), .sof(sof),
.host_samples(host_samples), .dev_consumed(dev_consumed),
.gain_shift(gain_shift),
.fb_value(fb_value), .buf_level(buf_level),
.underrun(underrun), .overrun(overrun),
.n_frames(n_frames), .n_underrun(n_underrun),
.n_overrun(n_overrun), .n_clamped(n_clamped)
);
int errors = 0, checks = 0, steps = 0;
int seed;
// ---- cumulative across resets ----
//
// reset_dut runs once per configuration, so the DUT's own counters
// describe only the last one.
int c_frames = 0, c_under = 0, c_over = 0, c_clamp = 0;
// ---- per-reset tally, maintained from the MODEL ----
//
// The DUT's four counters do not affect its behaviour, which is exactly
// why nothing else in the bench would notice if they were wrong -- and
// they are what a bring-up engineer will trust when the audio clicks and
// no other evidence exists. These are kept from the model's own
// formulation of each condition, never from the DUT's outputs.
int t_frames, t_under, t_over, t_clamp;
// $random is SIGNED: mask the sign bit before any modulo.
function automatic logic [31:0] urand();
return $random(seed) & 32'h3FFF_FFFF;
endfunction
task automatic ck(input logic cond, input string what);
begin
checks = checks + 1;
if (!cond) begin
errors = errors + 1;
if (errors <= 20)
$display(" ERROR @%0t step#%0d: %s", $time, steps, what);
end
end
endtask
// ---- the model, formulated differently from the design ----
int m_level;
// The design shifts; the model multiplies. err * (16384 >> shift) is
// the same correction as (err << 14) >>> shift, arrived at from the
// other side.
function automatic integer model_fb(input integer lvl, input integer shift);
int err, corr, raw;
begin
err = TARGET - lvl;
corr = err * (16384 >> shift);
raw = NOM_Q14 + corr;
if (raw > FB_MAX) model_fb = FB_MAX;
else if (raw < FB_MIN) model_fb = FB_MIN;
else model_fb = raw;
end
endfunction
function automatic integer clamp_level(input integer lvl);
begin
if (lvl <= 0) clamp_level = 0;
else if (lvl >= BUF_DEPTH) clamp_level = BUF_DEPTH;
else clamp_level = lvl;
end
endfunction
// ---- what the design said, sampled at a DEFINED instant ----
int obs_level, obs_fb, obs_under, obs_over;
// ---- the measurement ----
int tab_off [0:4];
int tab_gain [0:3];
integer tab_max [0:4][0:3]; // worst excursion from target
integer tab_bad [0:4][0:3]; // under + overruns
task automatic reset_dut;
begin
rst_n = 1'b0; sof = 1'b0;
host_samples = 16'd0; dev_consumed = 16'd0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
@(posedge clk); #1;
m_level = TARGET;
t_frames = 0; t_under = 0; t_over = 0; t_clamp = 0;
end
endtask
// ---- PROPERTY 8: the four counters agree with an independent tally ----
//
// Called once per frame in the phases where the counted events actually
// happen. Elsewhere no event occurs and the check would be trivially
// true, which is worse than not making it: it would inflate the count
// without testing anything.
task automatic chk_counters;
begin
ck(n_frames == t_frames, "n_frames did not count every frame");
ck(n_underrun == t_under, "n_underrun disagrees with the tally");
ck(n_overrun == t_over, "n_overrun disagrees with the tally");
ck(n_clamped == t_clamp, "n_clamped disagrees with the tally");
end
endtask
// One USB frame: the host delivers, the device consumes, the loop
// reports a new rate.
task automatic one_frame(input integer send, input integer consume);
int e_level, e_fb;
begin
e_level = clamp_level(m_level + send - consume);
e_fb = model_fb(e_level, gain_shift);
sof = 1'b1; host_samples = send[15:0]; dev_consumed = consume[15:0];
@(posedge clk); #1;
sof = 1'b0;
obs_level = buf_level;
obs_fb = fb_value;
obs_under = underrun;
obs_over = overrun;
// ---- PROPERTY 1: the buffer level is exact ----
//
// Not approximately right. The level is the state the whole loop is
// built on, and a level that drifted from the truth would make every
// feedback value wrong in a way that looked like a tuning problem.
ck(obs_level == e_level, "the buffer level disagrees with the model");
// ---- PROPERTY 2: the feedback value is exact ----
ck(obs_fb == e_fb, "the feedback value disagrees with the model");
// ---- PROPERTY 3: the reported rate stays inside the legal band ----
//
// A device that asked for wildly the wrong rate is indistinguishable
// from a broken one, and a host is entitled to stop believing it.
ck(obs_fb <= FB_MAX && obs_fb >= FB_MIN,
"the feedback value left the legal band");
// ---- PROPERTY 4: underrun means the device ran dry ----
//
// Not "the buffer got low". A click the listener hears, which is why
// it is flagged separately from the loop merely working hard.
ck(obs_under == ((m_level + send - consume) <= 0 ? 1 : 0),
"underrun does not mean the device ran out of samples");
ck(obs_over == ((m_level + send - consume) >= BUF_DEPTH ? 1 : 0),
"overrun does not mean the buffer filled");
// the tally, from the model -- note it uses m_level BEFORE the
// assignment below, which is the level the frame started from
t_frames = t_frames + 1;
if ((m_level + send - consume) <= 0) t_under = t_under + 1;
if ((m_level + send - consume) >= BUF_DEPTH) t_over = t_over + 1;
begin : tally_clamp
integer raw;
raw = NOM_Q14 + (TARGET - e_level) * (16384 >> gain_shift);
if (raw > FB_MAX || raw < FB_MIN) t_clamp = t_clamp + 1;
end
m_level = e_level;
c_frames = c_frames + 1;
if (obs_under) c_under = c_under + 1;
if (obs_over) c_over = c_over + 1;
if (obs_fb == FB_MAX || obs_fb == FB_MIN) c_clamp = c_clamp + 1;
steps = steps + 1;
end
endtask
// -------------------------------------------------------------------
// Run the loop for `nframes` frames with the device's crystal running
// `off_q14` away from nominal, and report the worst excursion.
//
// The host does exactly what a real host does: accumulate the reported
// 10.14 rate, send floor() of it, carry the remainder. The device does
// the same with its own true rate. Neither ever sends a fractional
// sample, and the fractions are what make the average come out right.
// -------------------------------------------------------------------
task automatic run_loop(input integer off_q14, input integer shift,
input integer init_level, input integer nframes,
output integer max_dev, output integer bad,
output integer settled_dev);
int hacc, dacc, send, consume, f, dev;
begin
reset_dut;
gain_shift = shift[3:0];
// Place the buffer at its starting level: one frame in which the
// host delivers `init_level` and the device consumes exactly the
// reset level, which leaves the level at init_level.
one_frame(init_level, TARGET);
hacc = 0; dacc = 0;
max_dev = 0; bad = 0; settled_dev = 0;
dev = NOM_Q14 + off_q14;
for (f = 0; f < nframes; f = f + 1) begin
// the host: accumulate what the device asked for, send floor()
hacc = hacc + fb_value;
send = hacc >> 14;
hacc = hacc - (send << 14);
// the device's own crystal, doing the same with its true rate
dacc = dacc + dev;
consume = dacc >> 14;
dacc = dacc - (consume << 14);
one_frame(send, consume);
if ((m_level - TARGET) > max_dev) max_dev = m_level - TARGET;
if ((TARGET - m_level) > max_dev) max_dev = TARGET - m_level;
// The SETTLED excursion, over the last quarter of the run only.
// max_dev includes the transient from wherever the buffer
// started, which is an initial condition rather than a property
// of the loop; this is the steady state.
if (f >= (nframes * 3) / 4) begin
if ((m_level - TARGET) > settled_dev) settled_dev = m_level - TARGET;
if ((TARGET - m_level) > settled_dev) settled_dev = TARGET - m_level;
end
if (obs_under || obs_over) bad = bad + 1;
end
end
endtask
// ---- exhaustive reach ----
//
// 5 crystal offsets x 4 loop gains x 3 starting levels = 60. Every
// dimension is an independent input with no forbidden combinations.
logic reach [0:59];
int nr, ri;
int oi, gi, ii, k, mx, bd, sd;
int OFF [0:4];
int GAIN [0:3];
int INIT [0:2];
initial begin
for (ri = 0; ri < 60; ri = ri + 1) reach[ri] = 1'b0;
// Crystal error in 10.14 samples per frame. 4096 is a quarter of a
// sample per frame -- about 5200 ppm, far beyond any real crystal, and
// included so the sweep spans the whole transition rather than
// stopping at the first configuration that copes. 256 is about 325 ppm,
// which is the order of a cheap part.
OFF[0] = -4096; OFF[1] = -256; OFF[2] = 0; OFF[3] = 256; OFF[4] = 4096;
// Larger shift is a GENTLER loop.
GAIN[0] = 3; GAIN[1] = 4; GAIN[2] = 5; GAIN[3] = 6;
INIT[0] = 16; INIT[1] = TARGET; INIT[2] = BUF_DEPTH - 16;
seed = 32'd29003;
reset_dut;
// =============================================================
// PHASE 1 (DIRECTED, EXHAUSTIVE) -- the whole configuration space.
// =============================================================
for (oi = 0; oi < 5; oi = oi + 1)
for (gi = 0; gi < 4; gi = gi + 1)
for (ii = 0; ii < 3; ii = ii + 1) begin
// 1200 frames, not 400.
//
// A proportional loop's settling time scales with its gain: a
// shift of 6 has a time constant four times longer than a shift of
// 4, and from 80 samples away it needs several hundred frames just
// for the transient. At 400 frames the last quarter still contained
// one sample of it, and the closed-form check failed by exactly one
// -- six times, all at the gentlest gain.
//
// The measurement window was adequate for an aggressive loop and
// not for a gentle one. That is a property of the loop, and the
// right response is to give every configuration enough time rather
// than to loosen the property.
run_loop(OFF[oi], GAIN[gi], INIT[ii], 1200, mx, bd, sd);
ri = (oi * 4 + gi) * 3 + ii;
reach[ri] = 1'b1;
// ---- PROPERTY 5: the steady-state excursion is a CLOSED FORM ----
//
// excursion = crystal error (samples/frame) x 2^gain_shift
//
// which in 10.14 units is |off| << shift >> 14, floored at one
// sample because the buffer is counted in whole samples and any
// non-zero error eventually moves it by one.
//
// This is the chapter's result, and asserting it is what turns the
// table below from an observation into a measurement. A loop whose
// gain did not do what the arithmetic says would still produce a
// plausible-looking table.
begin : closedform
integer e_exc, a;
a = (OFF[oi] < 0) ? -OFF[oi] : OFF[oi];
e_exc = (a == 0) ? 0 : (((a << GAIN[gi]) >> 14) < 1 ? 1 : ((a << GAIN[gi]) >> 14));
// Asserted against the settled value, not the peak. The peak
// includes the transient from wherever the buffer happened to
// start, which is an initial condition and not a property of the
// loop -- asserting the closed form against the peak failed 40
// times for exactly that reason.
//
// Stating it for EVERY starting level is also the convergence
// property: wherever the buffer began, the loop brings it to the
// same steady state.
ck(sd == e_exc,
"the settled excursion does not match error x 2^gain");
end
// record the middle starting level for the headline table, so the
// table measures the loop rather than the initial condition
if (ii == 1) begin
tab_off[oi] = OFF[oi];
tab_gain[gi] = GAIN[gi];
tab_max[oi][gi] = sd;
tab_bad[oi][gi] = bd;
end
end
// =============================================================
// PHASE 2 (DIRECTED) -- a matched crystal must not move at all.
//
// With zero offset and the buffer starting exactly on target, the
// loop has nothing to correct. If it moves anyway, the loop is
// injecting the very error it exists to remove -- and every other
// measurement in this chapter would be contaminated by it.
// =============================================================
run_loop(0, 4, TARGET, 400, mx, bd, sd);
ck(mx == 0, "the loop moved the buffer with a perfectly matched crystal");
ck(bd == 0, "a matched crystal produced an underrun or overrun");
ck(fb_value == NOM_Q14,
"a matched crystal did not settle at exactly the nominal rate");
// =============================================================
// PHASE 3 (DIRECTED) -- the loop pulls the RIGHT WAY.
//
// Sign errors in a control loop are not subtle in their effect and
// are very subtle in the code. A loop wired backwards drives the
// buffer to a rail and holds it there, and every symptom points at
// the crystal rather than at the sign.
// =============================================================
reset_dut;
gain_shift = 4'd4;
one_frame(16, TARGET); // start well BELOW target
ck(fb_value > NOM_Q14,
"with the buffer low the device did not ask for MORE samples");
reset_dut;
one_frame(BUF_DEPTH - 16, TARGET); // start well ABOVE target
ck(fb_value < NOM_Q14,
"with the buffer high the device did not ask for FEWER samples");
// =============================================================
// PHASE 4 (DIRECTED, EXHAUSTIVE) -- the legal band is respected.
//
// Every starting level from empty to full, at the most aggressive
// gain, so the correction the loop WANTS is as large as it can be.
// The reported rate must still never leave the band.
// =============================================================
for (k = 0; k <= BUF_DEPTH; k = k + 8) begin
reset_dut;
gain_shift = 4'd3;
one_frame(k, TARGET);
ck(fb_value <= FB_MAX && fb_value >= FB_MIN,
"an extreme buffer level pushed the reported rate out of band");
end
// =============================================================
// PHASE 5 (DIRECTED) -- the bus goes quiet, and then it floods.
//
// Every phase above keeps the buffer near the middle, which means
// the two clamps in the design are never actually exercised: the
// sweep touches the rails EXACTLY, never past them, and a clamp
// that fires exactly at its own bound returns the value it was
// given. A wrong clamp would have been inert in every phase so far.
//
// A real device does go past the rails. The host loses its
// bandwidth reservation, or the bus is suspended, and nothing
// arrives for several frames while the device keeps consuming. That
// is the underrun the listener hears, and it is the one condition
// where the loop can do nothing except ask for the most it is
// allowed to and wait.
//
// This phase also checks the DUT's four counters against a tally
// kept here, which is the only place in the bench where they are
// verified rather than merely printed.
// =============================================================
reset_dut;
gain_shift = 4'd4;
begin : rails
int f2;
// ---- nothing arrives, the device keeps consuming ----
for (f2 = 0; f2 < 20; f2 = f2 + 1) begin
one_frame(0, NOM_SAMPLES);
// ---- PROPERTY 6: the level never leaves the buffer ----
//
// A level below zero or above the depth is not a small error. It
// is a read or write outside the ring buffer.
ck(obs_level <= BUF_DEPTH, "the level rose above the buffer depth");
// Checked every frame, not once at the end: a counter that is
// right at the end of a phase and wrong in the middle of it is
// still a counter that cannot be trusted.
chk_counters;
end
ck(obs_level == 0, "twenty frames of silence did not empty the buffer");
// ---- PROPERTY 7: at the rail the device asks for the most it may --
//
// It cannot ask for enough to refill instantly -- the band forbids
// that -- so the correct behaviour is to sit at the limit until the
// buffer recovers, which is what makes recovery gradual and audible
// as a single click rather than a burst of them.
ck(fb_value == FB_MAX,
"an empty buffer did not make the device ask for the maximum rate");
// ---- the host over-delivers: twice nominal, nothing consumed ----
for (f2 = 0; f2 < 20; f2 = f2 + 1) begin
one_frame(2 * NOM_SAMPLES, 0);
ck(obs_level <= BUF_DEPTH, "the level rose above the buffer depth");
chk_counters;
end
ck(obs_level == BUF_DEPTH, "twenty frames of flood did not fill the buffer");
ck(fb_value == FB_MIN,
"a full buffer did not make the device ask for the minimum rate");
ck(n_frames == 40, "forty frames were played and not forty counted");
end
// =============================================================
// PHASE 6 (DIRECTED, EXHAUSTIVE) -- the two boundaries, reached
// five different ways.
//
// `underrun` is defined on lvl_next <= 0 and `overrun` on
// lvl_next >= BUF_DEPTH. Both bounds are INCLUSIVE, and an inclusive
// bound differs from an exclusive one at exactly one point: the
// bound itself. A sweep cannot widen that domain, because the domain
// is one value.
//
// What can be widened is the number of ways the bench arrives there,
// and the number of consequences it checks once it has. The boundary
// must hold however the level got to it, so the level is placed at
// five different starting points and driven to each of
// {-2,-1,0,+1,+2} around both bounds from each one.
// =============================================================
begin : bounds
int li, dd;
integer LV [0:4];
LV[0] = 24; LV[1] = 48; LV[2] = TARGET; LV[3] = 144; LV[4] = 168;
for (li = 0; li < 5; li = li + 1)
for (dd = -2; dd <= 2; dd = dd + 1) begin
// ---- the EMPTY bound: drive lvl_next to exactly dd ----
reset_dut;
gain_shift = 4'd4;
one_frame(LV[li], TARGET); // place the level at LV[li]
one_frame(0, LV[li] - dd); // lvl_next = dd
chk_counters;
// ---- the FULL bound: drive lvl_next to exactly BUF_DEPTH+dd ----
reset_dut;
gain_shift = 4'd4;
one_frame(LV[li], TARGET);
one_frame(BUF_DEPTH + dd - LV[li], 0);
chk_counters;
end
end
// =============================================================
// PHASE 7 (RANDOM) -- a jittery host.
//
// A real host does not deliver exactly what it was asked for every
// frame: scheduling, other devices and its own rounding all
// interfere. The loop has to survive that without the buffer
// wandering off.
// =============================================================
`ifndef DIRECTED_ONLY
reset_dut;
gain_shift = 4'd4;
one_frame(TARGET, TARGET);
for (k = 0; k < 600; k = k + 1) begin : jitter
int s, c;
s = (fb_value >> 14) + (urand() % 3) - 1; // host is +/-1 sample out
c = NOM_SAMPLES + (urand() % 3) - 1; // device jitters too
if (s < 0) s = 0;
if (c < 0) c = 0;
one_frame(s, c);
end
`endif
nr = 0; for (ri = 0; ri < 60; ri = ri + 1) if (reach[ri]) nr = nr + 1;
$display("steps=%0d checks=%0d reach=%0d/60 errors=%0d",
steps, checks, nr, errors);
$display("[uac] frames=%0d underruns=%0d overruns=%0d rate_clamped=%0d",
c_frames, c_under, c_over, c_clamp);
$display("--- worst buffer excursion from target, in samples ---");
$display(" crystal err gain>>3 gain>>4 gain>>5 gain>>6");
for (oi = 0; oi < 5; oi = oi + 1)
$display(" %11d %9d %9d %9d %9d", tab_off[oi],
tab_max[oi][0], tab_max[oi][1], tab_max[oi][2], tab_max[oi][3]);
$display("--- underruns + overruns in 1200 frames ---");
$display(" crystal err gain>>3 gain>>4 gain>>5 gain>>6");
for (oi = 0; oi < 5; oi = oi + 1)
$display(" %11d %9d %9d %9d %9d", tab_off[oi],
tab_bad[oi][0], tab_bad[oi][1], tab_bad[oi][2], tab_bad[oi][3]);
if (nr != 60) begin
$display("FAIL: exhaustive sweep incomplete"); errors = errors + 1;
end
if (errors == 0) $display("PASS: 0 errors in %0d checks", checks);
else $display("FAIL: %0d errors in %0d checks", errors, checks);
$finish;
end
endmodule10. VHDL-2008
The third implementation, and in this track that has repeatedly been the point rather than a formality. The two Icarus benches share their stimulus; the VHDL bench derives its own. A defect that both Icarus benches share cannot be found by comparing them to each other.
VHDL also forces the arithmetic to be explicit in a way Verilog does not, and
that is directly relevant to this module. Verilog will silently truncate a
32-bit value assigned into a narrower signal — which is exactly the mechanism by
which the original sign extension was dead and nobody noticed. numeric_std
requires the width of every intermediate to be stated, which is the discipline
the Verilog file adopted deliberately once the mutation exposed it.
-- =====================================================================
-- uac_feedback -- VHDL-2008.
--
-- Same hardware contract as the Verilog and SystemVerilog files: same
-- ports, same widths, same reset values, same cycle-by-cycle behaviour.
--
-- THIS IS THE INDEPENDENT IMPLEMENTATION, and in this track that has
-- repeatedly been the point rather than a formality. Icarus seeds
-- $random identically for Verilog and SystemVerilog, so those two benches
-- drive the SAME stimulus; the VHDL bench derives its own. A defect that
-- both Icarus benches share cannot be found by comparing them to each
-- other, and twice in this module the VHDL side is what exposed it.
--
-- VHDL also forces the arithmetic to be explicit in a way Verilog does
-- not. Verilog will silently truncate a 32-bit product assigned into a
-- narrower signal; numeric_std requires the width of every intermediate
-- to be stated, which is the same discipline the Verilog file adopted
-- deliberately after a mutation proved its sign extension was dead code.
--
-- uac_feedback -- the control loop that keeps a USB audio device from
-- running out of samples, or drowning in them.
--
-- CLASSIFICATION: simplified synthesisable teaching RTL.
-- This is NOT an audio device. There is no DAC, no volume control, no
-- format negotiation and no mixer. It is the feedback endpoint: the one
-- number an asynchronous audio device sends back every frame, and the
-- arithmetic that decides what that number should be.
--
-- A USB audio device runs on ITS OWN crystal and the host runs on its
-- own. Neither is wrong and neither can be adjusted to match the other,
-- so the device's idea of 48,000 samples per second and the host's differ
-- -- by a few parts per million, forever. A few parts per million sounds
-- harmless; it is not, because the error ACCUMULATES into a buffer:
--
-- 100 ppm at 48 kHz = 4.8 samples per second
-- = 288 samples per minute
-- = a buffer emptied, or overflowed, every minute
--
-- There is no retry that fixes this and no error to report. So USB audio
-- puts the DEVICE in charge of the rate: an asynchronous device reports,
-- once per frame on a dedicated feedback endpoint, how many samples per
-- frame it actually wants, as a 10.14 fixed-point number. The host
-- accumulates that value, sends floor() of it, and carries the remainder.
-- =====================================================================
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity uac_feedback is
generic (
-- Nominal samples per USB frame. 48 at 48 kHz with 1 ms frames.
NOM_SAMPLES : integer := 48;
-- Buffer depth in samples. Real devices use a few milliseconds' worth;
-- the number only has to be big enough that the loop has room to work.
BUF_DEPTH : integer := 192
);
port (
clk : in std_logic;
rst_n : in std_logic;
-- One pulse per USB frame. Everything here happens once per frame,
-- because that is how often the host will look.
sof : in std_logic;
-- How many samples the host actually delivered this frame, and how
-- many the device's own clock consumed. These differ, and their
-- difference is the entire problem.
host_samples : in unsigned(15 downto 0);
dev_consumed : in unsigned(15 downto 0);
-- How aggressively to correct. The correction is
-- (target - level) >> gain_shift, so a LARGER shift is a GENTLER loop.
gain_shift : in unsigned(3 downto 0);
-- The feedback value, 10.14 fixed point. The only thing the host ever
-- sees from this module, and the host will do exactly what it says.
fb_value : out unsigned(23 downto 0);
buf_level : out unsigned(15 downto 0);
underrun : out std_logic;
overrun : out std_logic;
n_frames : out unsigned(31 downto 0);
n_underrun : out unsigned(31 downto 0);
n_overrun : out unsigned(31 downto 0);
n_clamped : out unsigned(31 downto 0)
);
end entity;
architecture rtl of uac_feedback is
-- 48 samples/frame in 10.14 is 48 * 16384.
constant NOM_Q14 : unsigned(23 downto 0) := to_unsigned(NOM_SAMPLES * 16384, 24);
-- The level the loop steers towards: half full, so it has equal room to
-- absorb an error in either direction.
constant TARGET : unsigned(15 downto 0) := to_unsigned(BUF_DEPTH / 2, 16);
-- The specification bounds how far the reported rate may deviate from
-- nominal. A device that asked for wildly the wrong rate would be
-- indistinguishable from a broken one, and a host is entitled to stop
-- believing it. One sample per frame is the limit used here.
constant FB_MAX : unsigned(23 downto 0) := to_unsigned(NOM_SAMPLES * 16384 + 16384, 24);
constant FB_MIN : unsigned(23 downto 0) := to_unsigned(NOM_SAMPLES * 16384 - 16384, 24);
signal level : unsigned(15 downto 0);
signal fb_r : unsigned(23 downto 0);
signal under_r : std_logic;
signal over_r : std_logic;
signal frame_c : unsigned(31 downto 0);
signal under_c : unsigned(31 downto 0);
signal over_c : unsigned(31 downto 0);
signal clamp_c : unsigned(31 downto 0);
signal lvl_next : signed(17 downto 0);
signal hit_under : std_logic;
signal hit_over : std_logic;
signal lvl_clamped : unsigned(15 downto 0);
signal err : signed(17 downto 0);
signal err_q14 : signed(31 downto 0);
signal corr : signed(31 downto 0);
signal fb_raw : signed(31 downto 0);
signal fb_hi : std_logic;
signal fb_lo : std_logic;
signal fb_next : unsigned(23 downto 0);
begin
buf_level <= level;
fb_value <= fb_r;
underrun <= under_r;
overrun <= over_r;
n_frames <= frame_c;
n_underrun <= under_c;
n_overrun <= over_c;
n_clamped <= clamp_c;
-- ---- the new level, before clamping ----
--
-- Signed, because the device can consume more than arrived. Computed
-- concurrently so the clamp and the counters see one consistent value
-- rather than each recomputing it.
lvl_next <= signed(resize(level, 18))
+ signed(resize(host_samples, 18))
- signed(resize(dev_consumed, 18));
hit_under <= '1' when lvl_next <= 0 else '0';
hit_over <= '1' when lvl_next >= to_signed(BUF_DEPTH, 18) else '0';
lvl_clamped <= (others => '0') when hit_under = '1' else
to_unsigned(BUF_DEPTH, 16) when hit_over = '1' else
unsigned(std_logic_vector(lvl_next(15 downto 0)));
-- ---- the correction ----
--
-- Proportional only. An integral term would drive the steady-state
-- error to zero, and it would also wind up during the many frames where
-- the level is pinned at a clamp -- which is exactly when the error is
-- largest and the loop can do nothing about it. The chapter measures
-- what the proportional loop alone achieves.
err <= signed(resize(TARGET, 18)) - signed(resize(lvl_clamped, 18));
-- err is in samples. Scaling it into 10.14 and then down by gain_shift
-- gives a correction of (err >> gain_shift) samples per frame.
--
-- WRITTEN AS A CONCATENATION, exactly as the Verilog is, and for the
-- same reason: a sign extension placed before a LEFT shift is dead code
-- at any width, because the shift discards precisely the bits the
-- extension supplies. Concatenating fourteen zeros onto an 18-bit
-- error gives 32 bits by construction, with err's sign bit landing on
-- bit 31 -- so nothing is extended and nothing is discarded.
--
-- numeric_std would not have let the original mistake pass silently
-- anyway: it requires the width of every intermediate to be stated,
-- where Verilog infers one and truncates without comment.
err_q14 <= signed(std_logic_vector(err) & "00000000000000");
-- shift_right on a SIGNED value is an arithmetic shift. On an unsigned
-- it is logical, which is the VHDL spelling of the same trap that makes
-- Verilog's `>>` and `>>>` different operators.
corr <= shift_right(err_q14, to_integer(gain_shift));
-- Also 32 bits, and for the same reason: corr is 32 bits and adding it
-- into anything narrower would truncate it.
fb_raw <= signed(resize(NOM_Q14, 32)) + corr;
fb_hi <= '1' when fb_raw > signed(resize(FB_MAX, 32)) else '0';
fb_lo <= '1' when fb_raw < signed(resize(FB_MIN, 32)) else '0';
fb_next <= FB_MAX when fb_hi = '1' else
FB_MIN when fb_lo = '1' else
unsigned(std_logic_vector(fb_raw(23 downto 0)));
process (clk, rst_n)
begin
if rst_n = '0' then
level <= TARGET; -- start where the loop wants to be
fb_r <= NOM_Q14;
under_r <= '0';
over_r <= '0';
frame_c <= (others => '0');
under_c <= (others => '0');
over_c <= (others => '0');
clamp_c <= (others => '0');
elsif rising_edge(clk) then
under_r <= '0';
over_r <= '0';
if sof = '1' then
frame_c <= frame_c + 1;
level <= lvl_clamped;
fb_r <= fb_next;
-- An underrun is not "the buffer got low". It is "the device
-- needed a sample that was not there", which is a click the
-- listener hears -- so it is counted separately from the loop
-- simply working hard.
if hit_under = '1' then
under_r <= '1';
under_c <= under_c + 1;
end if;
if hit_over = '1' then
over_r <= '1';
over_c <= over_c + 1;
end if;
-- How often the loop wanted to ask for more than it is allowed
-- to. A loop that is permanently clamped is not controlling
-- anything, and this counter is how that becomes visible.
if fb_hi = '1' or fb_lo = '1' then
clamp_c <= clamp_c + 1;
end if;
end if;
end if;
end process;
end architecture;The VHDL testbench
The directed phases are structurally identical to the Verilog and SystemVerilog benches, so the directed mutation columns must agree exactly. The random phase uses a VHDL-native generator.
-- =====================================================================
-- Testbench for uac_feedback -- VHDL-2008.
--
-- THE BENCH IS BOTH CLOCKS.
--
-- It plays the host -- accumulating the feedback value in 10.14 and
-- sending floor() of it, carrying the remainder -- and it plays the
-- device's crystal, consuming at a rate that deliberately is NOT the
-- nominal one. The gap between those two is the whole problem the
-- feedback endpoint exists to solve.
--
-- THE MODEL USES MULTIPLICATION WHERE THE DESIGN USES SHIFTS. The design
-- computes its correction as (err & 14 zeros) shifted right by
-- gain_shift. The model computes err * (16384 / 2**gain_shift). Same
-- number, different derivation -- so a sign or rounding mistake in one
-- cannot be reproduced by the other.
--
-- THIS IS THE INDEPENDENT BENCH. The directed phases are structurally
-- identical to the Verilog and SystemVerilog benches, so the DIRECTED
-- mutation columns must agree EXACTLY across all three languages. The
-- random phase uses a VHDL-native generator, and takes its value from
-- bits 30 downto 15 of the state rather than the low bits: an LCG's low
-- bits are phase-locked, and earlier in this track reading them produced
-- a perfectly uniform histogram alongside zero of the 400 events the
-- phase existed to create.
--
-- THE HEADLINE is not a pass/fail. It is: how far does the buffer
-- actually drift before the loop catches it, as a function of how hard
-- the loop pulls?
-- =====================================================================
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use std.textio.all;
entity tb_fb_vhdl is
generic (
DIRECTED_ONLY : boolean := false
);
end entity;
architecture sim of tb_fb_vhdl is
constant NOM_SAMPLES : integer := 48;
constant BUF_DEPTH : integer := 192;
constant TARGET : integer := BUF_DEPTH / 2;
constant NOM_Q14 : integer := NOM_SAMPLES * 16384;
constant FB_MAX : integer := NOM_Q14 + 16384;
constant FB_MIN : integer := NOM_Q14 - 16384;
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal done : boolean := false;
signal sof : std_logic := '0';
signal host_samples : unsigned(15 downto 0) := (others => '0');
signal dev_consumed : unsigned(15 downto 0) := (others => '0');
signal gain_shift : unsigned(3 downto 0) := "0100";
signal fb_value : unsigned(23 downto 0);
signal buf_level : unsigned(15 downto 0);
signal underrun : std_logic;
signal overrun : std_logic;
signal n_frames : unsigned(31 downto 0);
signal n_underrun : unsigned(31 downto 0);
signal n_overrun : unsigned(31 downto 0);
signal n_clamped : unsigned(31 downto 0);
begin
dut : entity work.uac_feedback
generic map (NOM_SAMPLES => NOM_SAMPLES, BUF_DEPTH => BUF_DEPTH)
port map (
clk => clk, rst_n => rst_n, sof => sof,
host_samples => host_samples, dev_consumed => dev_consumed,
gain_shift => gain_shift,
fb_value => fb_value, buf_level => buf_level,
underrun => underrun, overrun => overrun,
n_frames => n_frames, n_underrun => n_underrun,
n_overrun => n_overrun, n_clamped => n_clamped
);
clkgen : process
begin
while not done loop
clk <= '0'; wait for 5 ns;
clk <= '1'; wait for 5 ns;
end loop;
wait;
end process;
main : process
variable errors : integer := 0;
variable checks : integer := 0;
variable steps : integer := 0;
variable lo : line;
procedure ck(cond : boolean; what : string) is
begin
checks := checks + 1;
if not cond then
errors := errors + 1;
if errors <= 20 then
write(lo, string'(" ERROR @"));
write(lo, now);
write(lo, string'(" step#"));
write(lo, steps);
write(lo, string'(": "));
write(lo, what);
writeline(output, lo);
end if;
end if;
end procedure;
-- ---- the model, formulated differently from the design ----
variable m_level : integer := TARGET;
-- ---- what the design said, sampled at a DEFINED instant ----
variable obs_level : integer := 0;
variable obs_fb : integer := 0;
variable obs_under : integer := 0;
variable obs_over : integer := 0;
-- ---- per-reset tally, maintained from the MODEL ----
--
-- The DUT's four counters do not affect its behaviour, which is
-- exactly why nothing else in the bench would notice if they were
-- wrong -- and they are what a bring-up engineer will trust when the
-- audio clicks and no other evidence exists.
variable t_frames : integer := 0;
variable t_under : integer := 0;
variable t_over : integer := 0;
variable t_clamp : integer := 0;
-- cumulative across resets, for the summary line
variable c_frames : integer := 0;
variable c_under : integer := 0;
variable c_over : integer := 0;
variable c_clamp : integer := 0;
-- the gain currently driven, as an integer for the model
variable gs : integer := 4;
function clamp_level(lvl : integer) return integer is
begin
if lvl <= 0 then return 0;
elsif lvl >= BUF_DEPTH then return BUF_DEPTH;
else return lvl;
end if;
end function;
-- The design concatenates and shifts right; the model multiplies by
-- 16384 / 2**shift, arrived at from the other side.
function model_fb(lvl : integer; shift : integer) return integer is
variable e, c, raw : integer;
begin
e := TARGET - lvl;
c := e * (16384 / (2 ** shift));
raw := NOM_Q14 + c;
if raw > FB_MAX then return FB_MAX;
elsif raw < FB_MIN then return FB_MIN;
else return raw;
end if;
end function;
-- ---- PROPERTY 8: the four counters agree with an independent tally --
procedure chk_counters is
begin
ck(to_integer(n_frames) = t_frames, "n_frames did not count every frame");
ck(to_integer(n_underrun) = t_under, "n_underrun disagrees with the tally");
ck(to_integer(n_overrun) = t_over, "n_overrun disagrees with the tally");
ck(to_integer(n_clamped) = t_clamp, "n_clamped disagrees with the tally");
end procedure;
procedure reset_dut is
begin
rst_n <= '0'; sof <= '0';
host_samples <= (others => '0');
dev_consumed <= (others => '0');
wait until rising_edge(clk);
wait until rising_edge(clk);
rst_n <= '1';
wait until rising_edge(clk);
wait for 1 ns;
m_level := TARGET;
t_frames := 0; t_under := 0; t_over := 0; t_clamp := 0;
end procedure;
-- One USB frame: the host delivers, the device consumes, the loop
-- reports a new rate.
procedure one_frame(send : integer; consume : integer) is
variable e_level, e_fb, raw : integer;
begin
e_level := clamp_level(m_level + send - consume);
e_fb := model_fb(e_level, gs);
sof <= '1';
host_samples <= to_unsigned(send, 16);
dev_consumed <= to_unsigned(consume, 16);
wait until rising_edge(clk);
wait for 1 ns;
sof <= '0';
obs_level := to_integer(buf_level);
obs_fb := to_integer(fb_value);
if underrun = '1' then obs_under := 1; else obs_under := 0; end if;
if overrun = '1' then obs_over := 1; else obs_over := 0; end if;
-- ---- PROPERTY 1: the buffer level is exact ----
--
-- Not approximately right. The level is the state the whole loop is
-- built on, and a level that drifted from the truth would make
-- every feedback value wrong in a way that looked like a tuning
-- problem.
ck(obs_level = e_level, "the buffer level disagrees with the model");
-- ---- PROPERTY 2: the feedback value is exact ----
ck(obs_fb = e_fb, "the feedback value disagrees with the model");
-- ---- PROPERTY 3: the reported rate stays inside the legal band --
ck(obs_fb <= FB_MAX and obs_fb >= FB_MIN,
"the feedback value left the legal band");
-- ---- PROPERTY 4: underrun means the device ran dry ----
--
-- Not "the buffer got low". A click the listener hears, which is
-- why it is flagged separately from the loop merely working hard.
if (m_level + send - consume) <= 0 then
ck(obs_under = 1, "underrun does not mean the device ran out of samples");
else
ck(obs_under = 0, "underrun does not mean the device ran out of samples");
end if;
if (m_level + send - consume) >= BUF_DEPTH then
ck(obs_over = 1, "overrun does not mean the buffer filled");
else
ck(obs_over = 0, "overrun does not mean the buffer filled");
end if;
-- the tally, from the model -- note it uses m_level BEFORE the
-- assignment below, which is the level the frame started from
t_frames := t_frames + 1;
if (m_level + send - consume) <= 0 then t_under := t_under + 1; end if;
if (m_level + send - consume) >= BUF_DEPTH then t_over := t_over + 1; end if;
raw := NOM_Q14 + (TARGET - e_level) * (16384 / (2 ** gs));
if raw > FB_MAX or raw < FB_MIN then t_clamp := t_clamp + 1; end if;
m_level := e_level;
c_frames := c_frames + 1;
if obs_under = 1 then c_under := c_under + 1; end if;
if obs_over = 1 then c_over := c_over + 1; end if;
if obs_fb = FB_MAX or obs_fb = FB_MIN then c_clamp := c_clamp + 1; end if;
steps := steps + 1;
end procedure;
-- -----------------------------------------------------------------
-- Run the loop for nframes frames with the device's crystal running
-- off_q14 away from nominal, and report the worst excursion.
--
-- The host does exactly what a real host does: accumulate the
-- reported 10.14 rate, send floor() of it, carry the remainder. The
-- device does the same with its own true rate. Neither ever sends a
-- fractional sample, and the fractions are what make the average
-- come out right.
-- -----------------------------------------------------------------
procedure run_loop(off_q14 : integer;
shift : integer;
init_level : integer;
nframes : integer;
max_dev : out integer;
bad : out integer;
settled : out integer) is
variable hacc, dacc, send, consume, dev : integer;
variable mx, bd, sd : integer;
begin
reset_dut;
gs := shift;
gain_shift <= to_unsigned(shift, 4);
-- Place the buffer at its starting level: one frame in which the
-- host delivers init_level and the device consumes exactly the
-- reset level, which leaves the level at init_level.
one_frame(init_level, TARGET);
hacc := 0; dacc := 0;
mx := 0; bd := 0; sd := 0;
dev := NOM_Q14 + off_q14;
for f in 0 to nframes - 1 loop
-- the host: accumulate what the device asked for, send floor()
hacc := hacc + to_integer(fb_value);
send := hacc / 16384;
hacc := hacc - send * 16384;
-- the device's own crystal, doing the same with its true rate
dacc := dacc + dev;
consume := dacc / 16384;
dacc := dacc - consume * 16384;
one_frame(send, consume);
if (m_level - TARGET) > mx then mx := m_level - TARGET; end if;
if (TARGET - m_level) > mx then mx := TARGET - m_level; end if;
-- The SETTLED excursion, over the last quarter of the run only.
-- mx includes the transient from wherever the buffer started,
-- which is an initial condition rather than a property of the
-- loop; this is the steady state.
if f >= (nframes * 3) / 4 then
if (m_level - TARGET) > sd then sd := m_level - TARGET; end if;
if (TARGET - m_level) > sd then sd := TARGET - m_level; end if;
end if;
if obs_under = 1 or obs_over = 1 then bd := bd + 1; end if;
end loop;
max_dev := mx; bad := bd; settled := sd;
end procedure;
-- ---- the measurement ----
type i5_t is array (0 to 4) of integer;
type i4_t is array (0 to 3) of integer;
type i3_t is array (0 to 2) of integer;
type i54_t is array (0 to 4, 0 to 3) of integer;
variable tab_off : i5_t := (others => 0);
variable tab_gain : i4_t := (others => 0);
variable tab_max : i54_t := (others => (others => 0));
variable tab_bad : i54_t := (others => (others => 0));
-- Crystal error in 10.14 samples per frame. 4096 is a quarter of a
-- sample per frame -- about 5200 ppm, far beyond any real crystal,
-- and included so the sweep spans the whole transition rather than
-- stopping at the first configuration that copes. 256 is about
-- 325 ppm, which is the order of a cheap part.
variable OFF : i5_t := (-4096, -256, 0, 256, 4096);
-- Larger shift is a GENTLER loop.
variable GAIN : i4_t := (3, 4, 5, 6);
variable INIT : i3_t := (16, TARGET, BUF_DEPTH - 16);
variable LV : i5_t := (24, 48, TARGET, 144, 168);
-- ---- exhaustive reach ----
--
-- 5 crystal offsets x 4 loop gains x 3 starting levels = 60. Every
-- dimension is an independent input with no forbidden combinations.
type reach_t is array (0 to 59) of boolean;
variable reach : reach_t := (others => false);
-- Only the values that outlive a loop are declared here. Every loop
-- parameter below is implicitly declared by its own loop, and
-- declaring a variable of the same name as well would SHADOW it --
-- which is how a VHDL port shadowed a package constant earlier in this
-- module, case-insensitively and with no error.
variable k, ri, mx, bd, sd, e_exc, a, nr : integer;
-- ---- a VHDL-native generator ----
--
-- Bits 30 downto 15, never the low bits: an LCG's low bits are
-- phase-locked to each other, and reading them once in this track
-- produced a histogram that was exactly uniform alongside zero of the
-- 400 events the phase existed to create.
variable rnd_state : unsigned(31 downto 0) := x"00007123";
impure function urand return integer is
begin
-- resize is not optional: numeric_std's "*" on two 32-bit unsigneds
-- returns SIXTY-FOUR bits, and assigning that back is a fatal length
-- mismatch rather than the silent truncation Verilog would give.
rnd_state := resize(rnd_state * to_unsigned(1103515245, 32), 32)
+ to_unsigned(12345, 32);
return to_integer(rnd_state(30 downto 15));
end function;
variable s_j, c_j : integer;
begin
reset_dut;
-- =============================================================
-- PHASE 1 (DIRECTED, EXHAUSTIVE) -- the whole configuration space.
-- =============================================================
for oi in 0 to 4 loop
for gi in 0 to 3 loop
for ii in 0 to 2 loop
-- 1200 frames, not 400. A proportional loop's settling time
-- scales with its gain: a shift of 6 has a time constant four
-- times longer than a shift of 4, and from 80 samples away it
-- needs several hundred frames just for the transient. At 400
-- frames the last quarter still contained one sample of it, and
-- the closed-form check below failed by exactly one, six times,
-- all at the gentlest gain. The window was adequate for an
-- aggressive loop and not for a gentle one -- so every
-- configuration is given enough time rather than the property
-- being loosened.
run_loop(OFF(oi), GAIN(gi), INIT(ii), 1200, mx, bd, sd);
ri := (oi * 4 + gi) * 3 + ii;
reach(ri) := true;
-- ---- PROPERTY 5: the steady-state excursion is a CLOSED FORM --
--
-- excursion = crystal error (samples/frame) x 2^gain_shift
--
-- which in 10.14 units is |off| * 2**shift / 16384, floored at
-- one sample because the buffer is counted in whole samples and
-- any non-zero error eventually moves it by one.
--
-- This is the chapter's result, and asserting it is what turns
-- the table below from an observation into a measurement. A loop
-- whose gain did not do what the arithmetic says would still
-- produce a plausible-looking table.
--
-- Asserted against the SETTLED value, not the peak: the peak
-- includes the transient from wherever the buffer happened to
-- start, which is an initial condition and not a property of the
-- loop. Stating it for every starting level is also the
-- convergence property -- wherever the buffer began, the loop
-- brings it to the same steady state.
if OFF(oi) < 0 then a := -OFF(oi); else a := OFF(oi); end if;
if a = 0 then
e_exc := 0;
elsif (a * (2 ** GAIN(gi))) / 16384 < 1 then
e_exc := 1;
else
e_exc := (a * (2 ** GAIN(gi))) / 16384;
end if;
ck(sd = e_exc, "the settled excursion does not match error x 2^gain");
-- record the middle starting level for the headline table, so
-- the table measures the loop rather than the initial condition
if ii = 1 then
tab_off(oi) := OFF(oi);
tab_gain(gi) := GAIN(gi);
tab_max(oi, gi) := sd;
tab_bad(oi, gi) := bd;
end if;
end loop;
end loop;
end loop;
-- =============================================================
-- PHASE 2 (DIRECTED) -- a matched crystal must not move at all.
--
-- With zero offset and the buffer starting exactly on target, the
-- loop has nothing to correct. If it moves anyway, the loop is
-- injecting the very error it exists to remove -- and every other
-- measurement in this chapter would be contaminated by it.
-- =============================================================
run_loop(0, 4, TARGET, 400, mx, bd, sd);
ck(mx = 0, "the loop moved the buffer with a perfectly matched crystal");
ck(bd = 0, "a matched crystal produced an underrun or overrun");
ck(to_integer(fb_value) = NOM_Q14,
"a matched crystal did not settle at exactly the nominal rate");
-- =============================================================
-- PHASE 3 (DIRECTED) -- the loop pulls the RIGHT WAY.
--
-- Sign errors in a control loop are not subtle in their effect and
-- are very subtle in the code. A loop wired backwards drives the
-- buffer to a rail and holds it there, and every symptom points at
-- the crystal rather than at the sign.
-- =============================================================
reset_dut;
gs := 4; gain_shift <= "0100";
one_frame(16, TARGET); -- start well BELOW target
ck(to_integer(fb_value) > NOM_Q14,
"with the buffer low the device did not ask for MORE samples");
reset_dut;
one_frame(BUF_DEPTH - 16, TARGET); -- start well ABOVE target
ck(to_integer(fb_value) < NOM_Q14,
"with the buffer high the device did not ask for FEWER samples");
-- =============================================================
-- PHASE 4 (DIRECTED, EXHAUSTIVE) -- the legal band is respected.
--
-- Every starting level from empty to full, at the most aggressive
-- gain, so the correction the loop WANTS is as large as it can be.
-- The reported rate must still never leave the band.
-- =============================================================
k := 0;
while k <= BUF_DEPTH loop
reset_dut;
gs := 3; gain_shift <= "0011";
one_frame(k, TARGET);
ck(to_integer(fb_value) <= FB_MAX and to_integer(fb_value) >= FB_MIN,
"an extreme buffer level pushed the reported rate out of band");
k := k + 8;
end loop;
-- =============================================================
-- PHASE 5 (DIRECTED) -- the bus goes quiet, and then it floods.
--
-- Every phase above keeps the buffer near the middle, which means
-- the two clamps in the design are never actually exercised: the
-- sweep touches the rails EXACTLY, never past them, and a clamp that
-- fires exactly at its own bound returns the value it was given. A
-- wrong clamp would have been inert in every phase so far.
--
-- A real device does go past the rails. The host loses its bandwidth
-- reservation, or the bus is suspended, and nothing arrives for
-- several frames while the device keeps consuming. That is the
-- underrun the listener hears, and it is the one condition where the
-- loop can do nothing except ask for the most it is allowed to and
-- wait.
-- =============================================================
reset_dut;
gs := 4; gain_shift <= "0100";
-- ---- nothing arrives, the device keeps consuming ----
for f2 in 0 to 19 loop
one_frame(0, NOM_SAMPLES);
-- ---- PROPERTY 6: the level never leaves the buffer ----
--
-- A level below zero or above the depth is not a small error. It is
-- a read or write outside the ring buffer.
ck(obs_level <= BUF_DEPTH, "the level rose above the buffer depth");
-- Checked every frame, not once at the end: a counter that is right
-- at the end of a phase and wrong in the middle of it is still a
-- counter that cannot be trusted.
chk_counters;
end loop;
ck(obs_level = 0, "twenty frames of silence did not empty the buffer");
-- ---- PROPERTY 7: at the rail the device asks for the most it may ----
--
-- It cannot ask for enough to refill instantly -- the band forbids
-- that -- so the correct behaviour is to sit at the limit until the
-- buffer recovers, which is what makes recovery gradual and audible as
-- a single click rather than a burst of them.
ck(to_integer(fb_value) = FB_MAX,
"an empty buffer did not make the device ask for the maximum rate");
-- ---- the host over-delivers: twice nominal, nothing consumed ----
for f2 in 0 to 19 loop
one_frame(2 * NOM_SAMPLES, 0);
ck(obs_level <= BUF_DEPTH, "the level rose above the buffer depth");
chk_counters;
end loop;
ck(obs_level = BUF_DEPTH, "twenty frames of flood did not fill the buffer");
ck(to_integer(fb_value) = FB_MIN,
"a full buffer did not make the device ask for the minimum rate");
ck(to_integer(n_frames) = 40,
"forty frames were played and not forty counted");
-- =============================================================
-- PHASE 6 (DIRECTED, EXHAUSTIVE) -- the two boundaries, reached
-- five different ways.
--
-- underrun is defined on lvl_next <= 0 and overrun on
-- lvl_next >= BUF_DEPTH. Both bounds are INCLUSIVE, and an inclusive
-- bound differs from an exclusive one at exactly one point: the bound
-- itself. A sweep cannot widen that domain, because the domain is one
-- value.
--
-- What can be widened is the number of ways the bench arrives there,
-- and the number of consequences it checks once it has. The boundary
-- must hold however the level got to it, so the level is placed at
-- five different starting points and driven to each of
-- {-2,-1,0,+1,+2} around both bounds from each one.
-- =============================================================
for li in 0 to 4 loop
for dd in -2 to 2 loop
-- ---- the EMPTY bound: drive lvl_next to exactly dd ----
reset_dut;
gs := 4; gain_shift <= "0100";
one_frame(LV(li), TARGET); -- place the level at LV(li)
one_frame(0, LV(li) - dd); -- lvl_next = dd
chk_counters;
-- ---- the FULL bound: drive lvl_next to exactly BUF_DEPTH+dd ----
reset_dut;
gs := 4; gain_shift <= "0100";
one_frame(LV(li), TARGET);
one_frame(BUF_DEPTH + dd - LV(li), 0);
chk_counters;
end loop;
end loop;
-- =============================================================
-- PHASE 7 (RANDOM) -- a jittery host.
--
-- A real host does not deliver exactly what it was asked for every
-- frame: scheduling, other devices and its own rounding all
-- interfere. The loop has to survive that without the buffer
-- wandering off.
-- =============================================================
if not DIRECTED_ONLY then
reset_dut;
gs := 4; gain_shift <= "0100";
one_frame(TARGET, TARGET);
for jf in 0 to 599 loop
s_j := to_integer(fb_value) / 16384 + (urand mod 3) - 1;
c_j := NOM_SAMPLES + (urand mod 3) - 1;
if s_j < 0 then s_j := 0; end if;
if c_j < 0 then c_j := 0; end if;
one_frame(s_j, c_j);
end loop;
end if;
nr := 0;
for rj in 0 to 59 loop
if reach(rj) then nr := nr + 1; end if;
end loop;
write(lo, string'("steps=")); write(lo, steps);
write(lo, string'(" checks=")); write(lo, checks);
write(lo, string'(" reach=")); write(lo, nr); write(lo, string'("/60"));
write(lo, string'(" errors=")); write(lo, errors);
writeline(output, lo);
write(lo, string'("[uac] frames=")); write(lo, c_frames);
write(lo, string'(" underruns=")); write(lo, c_under);
write(lo, string'(" overruns=")); write(lo, c_over);
write(lo, string'(" rate_clamped=")); write(lo, c_clamp);
writeline(output, lo);
write(lo, string'("--- worst buffer excursion from target, in samples ---"));
writeline(output, lo);
write(lo, string'(" crystal err gain>>3 gain>>4 gain>>5 gain>>6"));
writeline(output, lo);
for oi in 0 to 4 loop
write(lo, string'(" "));
write(lo, tab_off(oi), right, 11);
write(lo, tab_max(oi, 0), right, 10);
write(lo, tab_max(oi, 1), right, 10);
write(lo, tab_max(oi, 2), right, 10);
write(lo, tab_max(oi, 3), right, 10);
writeline(output, lo);
end loop;
write(lo, string'("--- underruns + overruns in 1200 frames ---"));
writeline(output, lo);
write(lo, string'(" crystal err gain>>3 gain>>4 gain>>5 gain>>6"));
writeline(output, lo);
for oi in 0 to 4 loop
write(lo, string'(" "));
write(lo, tab_off(oi), right, 11);
write(lo, tab_bad(oi, 0), right, 10);
write(lo, tab_bad(oi, 1), right, 10);
write(lo, tab_bad(oi, 2), right, 10);
write(lo, tab_bad(oi, 3), right, 10);
writeline(output, lo);
end loop;
if nr /= 60 then
write(lo, string'("FAIL: exhaustive sweep incomplete"));
writeline(output, lo);
errors := errors + 1;
end if;
if errors = 0 then
write(lo, string'("PASS: 0 errors in ")); write(lo, checks);
write(lo, string'(" checks"));
else
write(lo, string'("FAIL: ")); write(lo, errors);
write(lo, string'(" errors in ")); write(lo, checks);
write(lo, string'(" checks"));
end if;
writeline(output, lo);
done <= true;
wait;
end process;
end architecture;11. Assertions
Four properties in this design are naturally temporal, and are written here as SVA for a tool that supports it. Icarus does not — it rejects concurrent assertions outright — so in this environment each one is enforced by the procedural check named beside it. Both forms are shown because the SVA is what you would write in a commercial simulator and the procedural form is what was actually run.
// ---- P1: the level only changes on a frame ----
//
// Nothing outside a SOF may move the buffer. A level that drifted between
// frames would be a counter with a second, unintended, source.
property p_level_stable_between_frames;
@(posedge clk) disable iff (!rst_n)
!sof |=> $stable(buf_level);
endproperty
// enforced procedurally by: the bench only ever samples after a SOF, and
// PROPERTY 1 compares the level to a model advanced exactly once per frame
// ---- P2: the reported rate never leaves the legal band ----
//
// The one property a host depends on. A device outside the band is
// indistinguishable from a device whose arithmetic is broken.
property p_band;
@(posedge clk) disable iff (!rst_n)
(fb_value <= FB_MAX) && (fb_value >= FB_MIN);
endproperty
// enforced procedurally by: PROPERTY 3, on every one of 73,229 frames
// ---- P3: underrun is a pulse, never a level ----
//
// An underrun is one event -- one sample the device needed and did not
// have. A level would mean "the buffer is low", which is a different
// claim and is not what the counter counts.
property p_underrun_is_a_pulse;
@(posedge clk) disable iff (!rst_n)
underrun |=> !underrun;
endproperty
// enforced procedurally by: the design deasserts both flags unconditionally
// at the top of the clocked block, and PROPERTY 4 checks the flag against
// the model on every frame including the frames it must be low
// ---- P4: the counters are monotonic ----
//
// A counter that can decrease is not a counter. This is the property that
// would catch a counter wired to a combinational term rather than an event.
property p_counters_monotonic;
@(posedge clk) disable iff (!rst_n)
(n_frames >= $past(n_frames)) && (n_underrun >= $past(n_underrun)) &&
(n_overrun >= $past(n_overrun)) && (n_clamped >= $past(n_clamped));
endproperty
// enforced procedurally by: PROPERTY 8 compares all four against a tally
// that is itself monotonic by construction, once per frame in phases 5 and 612. Where UVM Fits
This design is small, and a UVM environment around it would be larger than the design by a factor of ten. That is the wrong trade for forty lines of RTL, and the bench above is the right answer at this scale.
It is the wrong trade for a different reason than usual, though, and the reason is worth understanding, because it is the reason UVM would be right one level up.
WHAT UVM BUYS YOU reuse across configurations, constrained-random
stimulus, functional coverage closure, a
scoreboard independent of the driver
WHAT THIS DUT NEEDS a host model, a crystal model, and one closed-form
prediction checked 60 times
WHERE THE TRADE FLIPS the moment the feedback endpoint is one endpoint
among many inside a real audio functionHere is the shape the environment takes when it does become worthwhile — the audio function, with the feedback endpoint as one agent inside it.
// =====================================================================
// The sequence item is a FRAME, not a packet.
//
// That choice is the whole design of this environment. The feedback
// endpoint's behaviour is defined per frame, the host's accumulator
// advances per frame, and the closed-form property is about the steady
// state of a sequence of frames. An item finer than a frame would force
// every layer above it to reassemble one.
// =====================================================================
class uac_frame_item extends uvm_sequence_item;
`uvm_object_utils(uac_frame_item)
// ---- what the host delivered, and what the device consumed ----
rand int unsigned host_samples;
rand int unsigned dev_consumed;
rand bit [3:0] gain_shift;
// ---- the crystal offset, in 10.14 samples per frame ----
//
// Signed, and deliberately spanning far past any real part: a sweep that
// stopped at the first configuration that copes would not find where the
// loop breaks.
rand int crystal_off_q14;
// ---- what came back ----
bit [23:0] fb_value;
bit [15:0] buf_level;
bit underrun, overrun;
constraint c_gain {
// A larger shift is a GENTLER loop. 3..6 spans a factor of eight in
// loop gain and a factor of eight in settling time, which is the
// constraint that matters for the run length below.
gain_shift inside {[3:6]};
}
constraint c_crystal {
crystal_off_q14 inside {-4096, -256, 0, 256, 4096};
}
constraint c_delivery {
// A real host delivers within a sample or so of what it was asked for.
host_samples inside {[0:96]};
dev_consumed inside {[0:96]};
}
endclass
// =====================================================================
// The driver plays BOTH CLOCKS, because the disagreement between them is
// the phenomenon under test.
//
// Note what it does NOT do: it does not decide how many samples to send.
// It accumulates the rate the DUT asked for and sends floor() of it,
// exactly as a host does. A driver that sent a number of its own choosing
// would be testing a loop that had no authority, which is not this loop.
// =====================================================================
class uac_driver extends uvm_driver #(uac_frame_item);
`uvm_object_utils(uac_driver)
virtual uac_if vif;
// the host's 10.14 accumulator, and the device crystal's own
int host_acc, dev_acc;
task run_phase(uvm_phase phase);
uac_frame_item it;
int send, consume, dev_rate;
forever begin
seq_item_port.get_next_item(it);
dev_rate = 48 * 16384 + it.crystal_off_q14;
// the host: accumulate what the device asked for, send floor()
host_acc += vif.fb_value;
send = host_acc >> 14;
host_acc -= send << 14;
// the device's own crystal, doing the same with its true rate
dev_acc += dev_rate;
consume = dev_acc >> 14;
dev_acc -= consume << 14;
vif.gain_shift <= it.gain_shift;
vif.sof <= 1'b1;
vif.host_samples <= send[15:0];
vif.dev_consumed <= consume[15:0];
@(posedge vif.clk);
vif.sof <= 1'b0;
@(posedge vif.clk);
it.host_samples = send;
it.dev_consumed = consume;
seq_item_port.item_done();
end
endtask
endclass
// =====================================================================
// The scoreboard holds the model, and the model is formulated
// DIFFERENTLY from the design on purpose.
//
// The design scales the error by concatenating fourteen zeros and
// shifting right. The model multiplies by (16384 >> gain_shift). Same
// number, derived from the other side -- so a sign or rounding mistake in
// one cannot be reproduced by the other. A model that mirrors the
// design's expression is a second copy of the same assumption.
// =====================================================================
class uac_scoreboard extends uvm_scoreboard;
`uvm_object_utils(uac_scoreboard)
uvm_analysis_imp #(uac_frame_item, uac_scoreboard) ap;
localparam int TARGET = 96;
localparam int NOM_Q14 = 48 * 16384;
localparam int FB_MAX = NOM_Q14 + 16384;
localparam int FB_MIN = NOM_Q14 - 16384;
int m_level = TARGET;
// ---- the settled-excursion window ----
//
// NOT the peak. The peak includes the transient from wherever the buffer
// started, which is an initial condition and not a property of the loop.
// Asserting the closed form against the peak fails for every run that
// does not begin on target.
int frames_seen, settled_dev;
int window_opens;
function void write(uac_frame_item it);
int lvl, err, corr, raw, e_fb;
lvl = m_level + it.host_samples - it.dev_consumed;
if (lvl < 0) lvl = 0;
if (lvl > 192) lvl = 192;
err = TARGET - lvl;
corr = err * (16384 >> it.gain_shift); // multiply, not shift
raw = NOM_Q14 + corr;
e_fb = (raw > FB_MAX) ? FB_MAX : (raw < FB_MIN) ? FB_MIN : raw;
if (it.buf_level !== lvl[15:0])
`uvm_error("UAC", $sformatf("level %0d expected %0d", it.buf_level, lvl))
if (it.fb_value !== e_fb[23:0])
`uvm_error("UAC", $sformatf("fb %0d expected %0d", it.fb_value, e_fb))
if (it.fb_value > FB_MAX || it.fb_value < FB_MIN)
`uvm_error("UAC", "the reported rate left the legal band")
m_level = lvl;
frames_seen++;
if (frames_seen >= window_opens) begin
if ((lvl - TARGET) > settled_dev) settled_dev = lvl - TARGET;
if ((TARGET - lvl) > settled_dev) settled_dev = TARGET - lvl;
end
endfunction
// ---- the closed form, checked at the end of each configuration ----
function void check_closed_form(int off_q14, int gain_shift);
int a, expect_exc;
a = (off_q14 < 0) ? -off_q14 : off_q14;
expect_exc = (a == 0) ? 0 :
(((a << gain_shift) >> 14) < 1 ? 1 : ((a << gain_shift) >> 14));
if (settled_dev != expect_exc)
`uvm_error("UAC", $sformatf(
"settled excursion %0d, closed form predicts %0d (off=%0d gain=%0d)",
settled_dev, expect_exc, off_q14, gain_shift))
endfunction
endclassAnd the coverage model, which is where UVM genuinely earns its cost on this DUT:
// =====================================================================
// The cross is the point. Any one axis on its own proves nothing: the
// loop copes with a large crystal error at an aggressive gain and copes
// with a gentle gain at a small error. What has to be covered is the
// CORNER -- the largest error at the gentlest gain, from the furthest
// starting level, which is the configuration the closed form predicts
// will sit 16 samples off target forever.
// =====================================================================
covergroup cg_loop @(posedge frame_done);
cp_crystal : coverpoint cfg.crystal_off_q14 {
bins fast_far = {4096};
bins fast_near = {256};
bins matched = {0};
bins slow_near = {-256};
bins slow_far = {-4096};
}
cp_gain : coverpoint cfg.gain_shift {
bins aggressive = {3};
bins mid = {4, 5};
bins gentle = {6};
}
cp_start : coverpoint cfg.init_level {
bins nearly_empty = {[0:32]};
bins on_target = {[88:104]};
bins nearly_full = {[160:192]};
}
// ---- the events that must be REACHED, not merely possible ----
cp_rail : coverpoint dut_railed {
bins never = {0};
bins hit = {1};
}
cp_clamped : coverpoint dut_rate_clamped {
bins no = {0};
bins yes = {1};
}
x_corner : cross cp_crystal, cp_gain, cp_start;
endgroupNote cp_rail. Driving the buffer past a bound is an event none of the four
phases that preceded phase 5 ever produced, and it is why the clamp mutation
scored 21 until that phase existed. In a UVM environment it belongs in the
coverage model rather than in a comment, because a coverage hole is the only
artefact that makes "this state was never reached" visible without running a
mutation campaign — and unlike a mutation campaign, it costs one line.
13. Mutation Testing
Nine mutations, each a plausible single mistake, each generated by a script that asserts its replacement applied — a zero that turns out to be a mutant that was never generated is the most expensive false result in this methodology.
Columns: V Verilog, S SystemVerilog, H VHDL; ALL the full run, DIR with
the random phase compiled out.
MUT V-ALL V-DIR S-ALL S-DIR H-ALL H-DIR
BASE 0 0 0 0 0 0
R1 67783 67191 67783 67191 67791 67191
R2 8918 8492 8918 8492 8924 8492
R3 42811 42211 42811 42211 42811 42211
R4 1040 1030 1040 1030 1068 1030
R5 36 36 36 36 36 36
R6 51 51 51 51 51 51
R7 3588 3172 3588 3172 3567 3172
R8 54 54 54 54 54 54
R9 54 54 54 54 54 54 R1 the error is measured level-to-target instead of target-to-level:
the loop pulls the WRONG WAY and drives the buffer to a rail
R2 the gain shifts left instead of right, so a larger shift is a more
aggressive loop and every tuning decision is backwards
R3 >>> written as >>, which in Verilog is a LOGICAL shift whatever the
signedness of its operand
R4 the upper bound on the reported rate is computed but not applied
R5 the buffer's upper clamp is off by one
R6 running EXACTLY dry is not counted as an underrun
R7 the correction is computed from the PREVIOUS frame's level
R8 the underrun counter never advances
R9 only the upper rate clamp is counted, so a loop permanently pinned
at the MINIMUM looks like a loop that is controlling normallyEvery DIRECTED column is identical across all three languages, and BASE
reads zero in all six. The first fact is the one that carries information: the
directed phases are structurally identical, so a spread there would be a
difference between the three designs rather than between their stimuli. The
second is the precondition for the whole table — a mutation run against a
failing baseline false-kills every mutation, and a stale mutant file produces a
complete, plausible matrix of numbers that means nothing.
The ALL columns differ slightly for R1, R2, R4 and R7 (67783 vs 67791, and so
on). That is the random phase, and the difference is the VHDL bench's independent
generator finding a different number of frames in which a broken loop happens to
be observably broken. It is expected; it is why the directed decomposition exists.
14. What This Does Not Cover
This is simplified synthesisable teaching RTL for one mechanism, and the boundaries matter as much as the content.
NOT MODELLED WHY IT IS OUT OF SCOPE
-------------------------------- ---------------------------------------
the DAC, the mixer, volume, none of it touches the rate mechanism
format negotiation
the actual ring buffer memory the LEVEL is the state the loop needs;
the storage is an ordinary FIFO
clock domain crossing between RTL simulation cannot model
the USB clock and the audio metastability. A real device needs a
clock synchroniser here and simulation will
never tell you whether it is right
high-speed's 16.16 format same arithmetic, different binary point
the host's driver-side resampler the interesting half is the device's
SOF jitter and missing SOFs a frame that never arrives is a
different mechanism (section 5 of the
suspend chapter)
BUF_DEPTH as a swept parameter the level is swept exhaustively; the
DEPTH is a parameter, and this is
exactly how the dead sign extension
survived15. The Interview Answer
"A USB audio device and the host disagree on the sample rate by 50 ppm. Whose problem is it, and what does the fix cost?"
Nobody's fault, and the fix is one number per frame.
Both crystals are in spec. 50 ppm at 48 kHz is 2.4 samples per second, and a 192-sample buffer has 96 samples of margin either side of where it sits — so it runs dry in 40 seconds, and then again, and again, forever, with nothing to report and no packet lost. There is no retry that helps because nothing failed.
The fix is an asynchronous device: keep the free-running crystal, and report
on a dedicated isochronous feedback endpoint how many samples per frame the
device actually wants, as a 10.14 fixed-point number. The host accumulates that
value, sends floor() of it and carries the remainder — so it delivers 49, 48,
48, 48 and the average comes out exactly right without any frame carrying a
fraction.
The device side is a proportional loop: (target - level) >> gain, added to
nominal, clamped to one sample per frame either way. That is about forty lines of
RTL and it needs no PI term — measured, across 60 configurations at crystal
errors up to fifty times worse than any real part: not one underrun in 72,000
frames.
What it costs is a steady-state buffer offset, and that offset is a closed form:
excursion = crystal error (samples/frame) * 2^gain_shiftProportional control produces a correction only in the presence of an error, so the buffer settles wherever the error is large enough to generate exactly the correction needed. A gentler loop tolerates a proportionally larger offset. With a 192-sample buffer and a real crystal that offset is one or two samples, which is why the simplest possible controller is the right one here.
The one thing worth adding unprompted: the reason good external DACs advertise "asynchronous USB" is this mechanism. A synchronous device slaves its sample clock to the USB SOF, so bus jitter becomes sample jitter. An asynchronous device's clock never touches the bus, and the entire rate correction happens on the host in software, where it costs nothing and jitters nothing.
16. What Carries Forward
THE MECHANISM
o isochronous is reserved bandwidth with best-effort delivery, in both
directions -- the feedback endpoint is isochronous for the same reason
the data endpoint is
o 10.14 fixed point, 24 bits, samples per frame; one LSB is 1.27 ppm
o the host accumulates and sends floor(), carrying the fraction: the same
trick as a fractional-N divider
o alternate setting 0 reserves no bandwidth, and that is required
o the legal band is one sample per frame, which bounds recovery to about
96 ms from empty and is what makes the request trustworthy
THE RESULT
o settled excursion = crystal error x 2^gain_shift, asserted on all 60
configurations in three languages, from three different starting levels
o zero underruns and zero overruns in 72,000 frames, up to 5,208 ppm
o a proportional-only loop is sufficient for this problem, measured
THE METHOD
o a mutation score of ZERO has three answers: weak suite, unreachable
state, or DEAD CODE -- and only the first is a testbench problem
o a sign extension placed before a left shift is dead code at any width
o a behaviour-preserving rewrite must produce byte-identical results, and
that identity is the evidence that it is one
o a clamp that only ever fires AT its own bound is untested; reaching a
bound is not exceeding it
o an inclusive/exclusive boundary has a domain of ONE value -- widen the
number of routes to it and the consequences checked, not the sweep
o settling time scales with loop gain, so an observation window adequate
at one end of a swept axis is not adequate at the other; the symptom is
a property failing only at one extreme, always by the same amount
o counters must be checked every event, not once per phase
o the model must be formulated differently from the design: multiply where
it shifts
o unsigned * unsigned is double-width in numeric_std, and fatal on assign
o an LCG's low bits are phase-locked and a uniform histogram proves nothingThe next chapter turns to the device that is simultaneously a mass-storage device, a camera, a serial port and a power sink, and negotiates which of those it is going to be: the phone on the other end of the cable.
Continue learning
Related tutorials
- Related topic
USB Webcams
UVC streams video over isochronous transfers, which have no retries. With only a frame-ID bit and an end-of-frame flag for framing, exactly 1 packet loss in P is detectable — 6% for a 16-packet frame, and under 1% for a real one.
- Related topic
USB vs UART
UART spends zero wires on synchronisation and pays a tolerance budget that shrinks as the frame grows; USB spends a SYNC field, an encoding rule and a PLL to buy that budget away — measured across 5376 exhaustive points, not quoted.
- Related topic
USB vs SPI
SPI selects a peripheral with a wire routed at layout time and USB with an address the host assigned — so a chip-select contention is invisible to every slave (0 of 11) while a duplicate USB address is detected every time (274 of 274).
- Related topic
USB vs Ethernet
USB has one authority that assigns every address; Ethernet has none, so a switch infers the topology from traffic — and an inferred table is wrong 294 times out of 1065 where an assigned one is wrong 0 times out of 130.
Standards & specifications
- Governing standard
- USB-IF (Universal Serial Bus Specification)(opens USB Implementers Forum (USB-IF) in a new tab)
Defines the USB bus — its electrical signalling, connectors, packet and transaction model, device framework and the descriptors a device must expose — together with the device-class specifications layered on it. It does not define host-controller register interfaces (xHCI and EHCI are separate documents) nor any operating system's driver architecture.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the USB curriculum.
