RTL & Logic Design · lesson 11

ALUs and Decimal Arithmetic in RTL

By Tabish Iqbal · Last updated:

Design a small ALU from an opcode table with zero/carry/overflow/negative flags, then build BCD addition with +6 correction and 9's-complement BCD subtraction.

12 min read · Free, no login

An ALU is rarely designed by writing Verilog first. The design method that holds up in practice starts from a plain table โ€” which operations does this unit need to support, and which opcode selects each one โ€” and only then becomes a case statement. This lesson works that method end to end, with the status flags a real ALU is expected to expose, and then turns to a kind of arithmetic this path has not touched yet: binary-coded decimal, where "add the two numbers" is not quite as simple as it is for plain binary.

Reading time: about 12 minutes.

11.1 Designing an ALU from an opcode table

The Common RTL Building Blocks lesson in the Verilog path already built a small ALU as one of five verified RTL templates, with a four-operation case statement and a zero flag. This lesson deliberately builds a different one, not to duplicate that block but to show the method that produces it: before any Verilog exists, write down which operations the unit must support and which opcode value selects each one. Every later design decision โ€” how wide the opcode field needs to be, which flags matter for which operations, what the default branch should do โ€” falls out of that table rather than being guessed at the keyboard.

Opcode table for an 8-operation, 4-bit ALU
opOperationResult
000ADDa + b
001SUBa − b
010ANDa & b
011ORa | b
100XORa ^ b
101SLT1 if a < b (signed), else 0
110SLLa shifted left by b[1:0]
111SRLa shifted right by b[1:0]

Four flags are the standard companions to a result, and the table already tells us which operations can produce each one meaningfully. Zero and negative are properties of the result alone, so they apply uniformly. Carry and overflow are specifically arithmetic concepts โ€” lesson 10's rule, "both operands share a sign but the result's sign disagrees" โ€” which only makes sense for ADD and SUB (for SUB the rule applies to a and −b: overflow occurs when a and b differ in sign and the result's sign differs from a's); AND has no notion of a signed result overflowing, so the table also guides which flags to even compute per branch.

11.2 The ALU in RTL

A 4-bit ALU built directly from the opcode table above
module alu_op (
    input  wire [3:0] a,
    input  wire [3:0] b,
    input  wire [2:0] op,
    output reg  [3:0] result,
    output reg        carry,
    output reg        overflow,
    output wire        zero,
    output wire        negative
);
    localparam ADD=3'b000, SUB=3'b001, AND_OP=3'b010, OR_OP=3'b011,
               XOR_OP=3'b100, SLT=3'b101, SLL=3'b110, SRL=3'b111;
    reg [4:0] wide;

    always @* begin
        carry = 1'b0; overflow = 1'b0; wide = 5'b0;
        case (op)
            ADD: begin
                wide     = {1'b0, a} + {1'b0, b};
                result   = wide[3:0];
                carry    = wide[4];
                overflow = (a[3] == b[3]) && (result[3] != a[3]);
            end
            SUB: begin
                wide     = {1'b0, a} - {1'b0, b};
                result   = wide[3:0];
                carry    = wide[4];             // 1 means a < b
                overflow = (a[3] != b[3]) && (result[3] != a[3]);
            end
            AND_OP:  result = a & b;
            OR_OP:   result = a | b;
            XOR_OP:  result = a ^ b;
            SLT:     result = ($signed(a) < $signed(b)) ? 4'd1 : 4'd0;
            SLL:     result = a << b[1:0];
            SRL:     result = a >> b[1:0];
            default: result = 4'b0;
        endcase
    end
    assign zero     = (result == 4'b0);
    assign negative = result[3];
endmodule

Three things here are direct consequences of the opcode table. carry and overflow are initialized to 0 before the case, so every non-arithmetic branch gets a defined value for free instead of repeating "set overflow to 0" in five separate branches. ADD and SUB each compute into a 5-bit wide temporary specifically so wide[4] can serve as the carry bit โ€” the same guard-bit pattern lesson 10's division datapath uses. For ADD it is the unsigned carry-out; for SUB it is the borrow, the sign bit of the 5-bit difference {0,a} − {0,b}, so carry = 1 on SUB means a < b. That is the inverse of lesson 10's addsub8, whose SUB carry-out of 1 means no borrow; both conventions exist in real processors, and a design must document which one it uses. zero and negative are plain continuous assignments reading result, outside the case entirely, because the table already said those two apply uniformly.

PASS: op=0 a=5 b=3 -> result=8 z=0 n=1 ovf=1 c=0
PASS: op=1 a=5 b=5 -> result=0 z=1 n=0 ovf=0 c=0
PASS: op=1 a=3 b=5 -> result=14 z=0 n=1 ovf=0 c=1
PASS: op=5 a=13 b=2 -> result=1 z=0 n=0 ovf=0 c=0
ALL ALU_OP TESTS PASSED

The first line is worth reading carefully: ADD with a=5, b=3 gives a binary result of 8, but this is a 4-bit ALU, whose signed range is only −8 to 7 โ€” so 5 + 3 genuinely overflows that range, and the flags correctly report negative=1, overflow=1 for a result that reads as unsigned 8 but is actually the bit pattern for signed −8. This is the same sign-bit overflow rule from lesson 10, now exercised on a width narrow enough that an entirely ordinary-looking addition trips it. Yosys's check reports zero problems and no inferred latches. Because the 3-bit opcode's eight values are all listed, the case is already full without its default โ€” removing that line and re-running Yosys still infers no latch. The default: result = 4'b0; branch is defensive: it gives a defined result when simulation drives op with X or Z bits, and it keeps the block latch-free if the opcode table later changes.

11.3 BCD addition and the +6 correction

Binary-coded decimal stores each decimal digit in its own 4-bit field, using only the values 0000 through 1001 (0–9) โ€” the six bit patterns 1010 through 1111 are simply never used. That gap is exactly what makes ordinary binary addition wrong for BCD: adding two BCD digits with a plain 4-bit adder treats the result as if all sixteen 4-bit patterns were valid digits, so a sum that lands in the unused range needs to be pushed past it and into the next decimal digit's territory.

0111 (7) + 0101 (5) 01100 (binary 12) not a valid single BCD digit + 0110 (add 6 to correct) 1 0010 = BCD "1 2" cout=1 (tens digit), sum=0010 (2)
Plain binary addition of 7 and 5 gives 01100 (12), which is not one of the ten valid BCD patterns; adding 6 pushes it past the six unused codes and produces the correct two-digit BCD result, carry 1 and digit 2.

The rule that makes this general, not just true for 7+5, is: compute the ordinary 4-bit binary sum (plus any carry-in) first; add 6 if that sum exceeds 9 โ€” that is, if the 4-bit adder produced a carry-out C4 = 1, or its 4-bit result is above 9. Both conditions matter: sums of 10–15 land in the six-code gap, while sums of 16–19 carry out of the 4-bit field and leave a valid-looking 0000–0011 behind (9 + 9 + 1 leaves 0011 with a carry), yet still need the +6. Comparing the full 5-bit sum against 9, as the RTL below does, covers both at once. The correction also produces the carry-out into the next decimal digit.

A single-digit BCD adder with the +6 correction
module bcd_adder (
    input  wire [3:0] a,    // one BCD digit, 0-9
    input  wire [3:0] b,
    input  wire       cin,
    output wire [3:0] sum,
    output wire        cout
);
    wire [4:0] bin_sum   = {1'b0, a} + {1'b0, b} + cin;
    wire       needs_fix = (bin_sum > 5'd9);
    wire [4:0] fixed_sum = needs_fix ? (bin_sum + 5'd6) : bin_sum;

    assign sum  = fixed_sum[3:0];
    assign cout = needs_fix;
endmodule
PASS ADD: 7+5+cin0 = cout1 sum2
PASS ADD: 9+9+cin1 = cout1 sum9
PASS ADD: 3+4+cin0 = cout0 sum7
ALL BCD TESTS PASSED

The second line confirms the correction rule at its most demanding case: 9 + 9 + a carry-in of 1 is 19, whose low 4 bits alone read 0011 โ€” a valid-looking digit that only the carry-out exposes โ€” and the +6 correction turns the raw binary sum 10011 into the correct BCD pair cout=1, sum=9 โ€” "1 9" read as two decimal digits, exactly matching ordinary decimal addition. The third line shows the correction doing nothing at all when it is not needed: 3 + 4 = 7 never exceeds 9, so needs_fix stays 0 and the binary and BCD answers coincide, which is the "just right" behavior this circuit needs โ€” correcting only the cases that actually land outside the valid digit range, and leaving everything else as plain binary addition.

11.4 BCD subtraction by 9's complement

Complement arithmetic lets an adder subtract, by letting "subtract" mean "add the complemented operand." The 9's complement is the decimal analogue of binary one's complement (invert every bit), not of two's complement; the decimal analogue of two's complement is the 10's complement (9's complement plus 1). Like one's complement, the 9's complement needs an end-around carry and has a negative zero, both of which appear below. For a single BCD digit d in 0–9, its 9's complement is simply 9 − d. Subtracting B from A becomes: complement B to (9−B), add that to A, and look at whether the sum reached double digits.

If the raw sum A + (9−B) is 10 or more, an end-around carry has occurred โ€” add 1 back to the sum, past the carry, and the result is the correct positive difference. If the raw sum stays below 10, the true difference is negative, and its magnitude is the 9's complement of that raw sum (9 minus the raw sum) rather than the raw sum itself.

A single-digit BCD subtractor by 9's complement and end-around carry
module bcd_sub9 (
    input  wire [3:0] a,
    input  wire [3:0] b,
    output wire [3:0] diff,  // magnitude of a - b
    output wire        neg   // 1 if a - b is negative
);
    wire [3:0] comp_b   = 4'd9 - b;
    wire [4:0] raw      = {1'b0, a} + {1'b0, comp_b};
    wire       carry    = (raw >= 5'd10);
    wire [3:0] diff_neg = 4'd9 - raw[3:0];

    // raw == 9 (no carry) is this scheme's own "negative zero" -- a == b --
    // which is still zero, not negative, so the sign check looks at the
    // recovered magnitude, not at the carry alone.
    assign diff = carry ? (raw - 5'd9) : diff_neg;
    assign neg  = (~carry) && (diff_neg != 4'd0);
endmodule
PASS SUB: 7-3 =  4
PASS SUB: 3-7 = -4
PASS SUB: 5-5 =  0
ALL BCD TESTS PASSED

The middle case is the one that justifies the whole scheme: 3 − 7 = 9 − 7 = 2, raw = 3 + 2 = 5, which is below 10, so no end-around carry occurs and the hardware reports a negative result with magnitude 9 − 5 = 4 โ€” correctly −4. The comment inside the module flags a genuine, easy-to-miss edge case: when A equals B, the 9's complement scheme produces a raw sum of exactly 9, which looks like "no carry, therefore negative" by the general rule, but is actually zero, since a number and its own 9's complement add to 9 by construction. The diff_neg != 4'd0 check exists specifically to keep this one case from being reported as "−0," which is correct but potentially confusing output for a circuit meant to represent an ordinary signed decimal digit. The 5−5 test above exercises exactly this edge case and confirms the fix.

11.5 Interview questions

Why does an ALU's opcode table matter before any Verilog is written? It fixes how wide the opcode needs to be, which operation each value selects, and โ€” just as importantly โ€” which flags are even meaningful for which operations, so the case statement and the flag logic both become direct transcriptions of a decision already made, rather than guesses made while typing.

Why does plain binary addition give the wrong answer for two BCD digits? BCD only uses ten of a 4-bit field's sixteen possible patterns. A binary sum that lands on one of the six unused patterns (10–15) is not a valid single decimal digit, and a sum of 16–19 carries out of the 4-bit field and leaves a valid-looking 0000–0011 behind. Both cases need +6, so the correction fires when the carry-out is 1 or the 4-bit sum exceeds 9, pushing the result into the correct two-digit BCD representation.

What does the end-around carry mean in 9's-complement BCD subtraction? A raw sum of A plus the 9's complement of B that reaches 10 or more means the true difference is non-negative; adding 1 back (past the carry) to that raw sum recovers the correct positive result. No such carry means the true difference is negative, and its magnitude is the 9's complement of the raw sum.

Why does the BCD subtractor need a special check for the case A equals B? The 9's-complement scheme represents zero two ways โ€” an actual 0 and a "negative zero" that arises whenever a digit is subtracted from itself, since a digit and its own 9's complement always sum to exactly 9. Without an explicit check, that case would be reported as negative, which is numerically harmless (both represent zero) but is not the output a digit comparison should return.

Key takeaways

  • An ALU's design starts from an opcode table, not from Verilog โ€” the table fixes the opcode width, the operation-to-code mapping, and which flags apply to which operations.
  • Carry and overflow are meaningful only for arithmetic operations (ADD/SUB); zero and negative are properties of the result and apply uniformly, confirmed here on a 4-bit ALU narrow enough that an ordinary-looking 5+3 genuinely overflows.
  • BCD addition needs a +6 correction exactly when the 4-bit adder carries out or its 4-bit sum exceeds 9 (equivalently, when the 5-bit sum exceeds 9), skipping the six unused 4-bit codes and producing the correct carry into the next decimal digit.
  • BCD subtraction by 9's complement mirrors one's-complement binary subtraction (10's complement is the two's-complement analogue): complement, add, and either absorb an end-around carry (positive result) or 9's-complement the raw sum again (negative result).
  • The 9's-complement scheme has two representations of zero โ€” a case this lesson's subtractor explicitly detects, rather than silently reporting an ordinary digit-equal comparison as negative.