Saturating Arithmetic
The previous article was about interpreting bits correctly. This one is about what happens when a result does not fit in the bits at all.
Turn the volume up on a digital audio path far enough and the sample values reach the top of their range. Two’s complement addition has an answer for what happens next, and for audio it is the wrong one: the sum wraps. The largest positive value plus one becomes the largest negative value. The loudest positive sample flips instantly to the loudest negative sample, which comes out of the speaker as a click that has nothing to do with the sound. Saturating arithmetic exists to prevent this. Instead of wrapping past the boundary, the result sticks at the maximum or minimum representable value.
Detecting signed overflow correctly
Before you can saturate you have to detect overflow, and for signed addition the obvious check, the carry-out of the top bit, is wrong. Two equivalent rules are correct. The formal one is that signed overflow occurs when the carry into the sign bit differs from the carry out of it. The one that is easier to hold in your head is that signed overflow can only happen when both operands have the same sign and the result comes out with the opposite sign. Adding a positive and a negative number can never overflow; only positive plus positive landing negative, or negative plus negative landing positive, signals overflow.
wire signed [7:0] a, b;
wire signed [7:0] sum = a + b;
wire overflow = (a[7] == b[7]) && (sum[7] != a[7]);
This is the form you will see most often in real RTL: compare the sign of both inputs against the sign of the possibly wrapped result. No extra sign-extended bit is needed.
Building the saturating adder
Once you can detect overflow, saturation is a mux: on overflow, replace the wrapped result with the right boundary value.
`timescale 1ns / 1ps
`default_nettype none
module sat_adder_signed #(parameter WIDTH = 8) (
input wire signed [WIDTH-1:0] a, b,
output reg signed [WIDTH-1:0] sum
);
wire signed [WIDTH-1:0] raw_sum = a + b;
wire overflow = (a[WIDTH-1] == b[WIDTH-1]) && (raw_sum[WIDTH-1] != a[WIDTH-1]);
localparam signed [WIDTH-1:0] MAX_VAL = {1'b0, {(WIDTH-1){1'b1}}}; // 0111...1
localparam signed [WIDTH-1:0] MIN_VAL = {1'b1, {(WIDTH-1){1'b0}}}; // 1000...0
always @(*) begin
if (overflow)
sum = a[WIDTH-1] ? MIN_VAL : MAX_VAL; // both negative -> clamp low, both positive -> clamp high
else
sum = raw_sum;
end
endmodule
Note which boundary is selected. If both operands were negative and the sum wrapped, the true result was more negative than the format can hold, so clamp to MIN_VAL. If both were positive, clamp to MAX_VAL. Either operand’s sign tells you the direction, since they are guaranteed equal when overflow has happened.
The unsigned case is simpler
Unsigned saturating addition has only one boundary, the ceiling:
module sat_adder_unsigned #(parameter WIDTH = 8) (
input wire [WIDTH-1:0] a, b,
output reg [WIDTH-1:0] sum
);
wire [WIDTH:0] raw_sum = a + b; // one extra bit catches the carry-out directly
always @(*) begin
if (raw_sum[WIDTH]) // carry out of the top bit means we overflowed
sum = {WIDTH{1'b1}}; // clamp to all-ones, the max representable value
else
sum = raw_sum[WIDTH-1:0];
end
endmodule
For unsigned addition the plain carry-out check is correct. It is specifically signed addition where it gives the wrong answer, which is why the previous article’s warnings matter here too.
Where this shows up
Saturation is the default assumption in audio and video DSP. A mixer summing several channels saturates rather than wraps, because a wrapped sample is a loud click while a saturated one sounds like ordinary analog clipping, unpleasant but not disruptive. Fixed-point accumulators in audio codecs, pixel blending in video, and the output stage of a PID controller all saturate by default for the same reason: a wrapped value is wrong in the worst possible direction. ARM (QADD, QSUB) and the RISC-V P extension have dedicated saturating instructions because DSP code needs this so often that checking in software one overflow at a time would cost too much. A saturating adder in RTL is the same primitive those instructions expose.
Practice Exercise
Build a saturating subtractor for signed 8-bit operands (diff = a - b, saturating instead of wrapping). Reuse the overflow detection pattern from the adder, adjusted for subtraction. Remember that a - b is a + (-b), so the overflow condition changes.
Solution
`timescale 1ns / 1ps
`default_nettype none
module sat_subtractor_signed #(parameter WIDTH = 8) (
input wire signed [WIDTH-1:0] a, b,
output reg signed [WIDTH-1:0] diff
);
wire signed [WIDTH-1:0] raw_diff = a - b;
// For subtraction, overflow occurs when a and b have DIFFERENT signs
// and the result's sign does not match a's sign.
wire overflow = (a[WIDTH-1] != b[WIDTH-1]) && (raw_diff[WIDTH-1] != a[WIDTH-1]);
localparam signed [WIDTH-1:0] MAX_VAL = {1'b0, {(WIDTH-1){1'b1}}};
localparam signed [WIDTH-1:0] MIN_VAL = {1'b1, {(WIDTH-1){1'b0}}};
always @(*) begin
if (overflow)
diff = a[WIDTH-1] ? MIN_VAL : MAX_VAL;
else
diff = raw_diff;
end
endmodule
The condition changes from “same sign in, opposite sign out” for addition to “different signs in, result sign not matching a” for subtraction, because subtracting b is adding its negation. A positive a minus a negative b can overflow high; a negative a minus a positive b can overflow low.
Addition and subtraction wrap silently past the representable range, which is fine for a counter and a problem for any signal that reaches a speaker, a display or an actuator. Signed overflow needs the same-sign-in, opposite-sign-out rule; once detected, saturation is a mux to the right boundary. In the next article we take the adder we have now made correct at the edges and make it fast enough to meet a clock target, by pipelining it.