NELIX Silicon Lab Docs Launch Silicon Lab Launch Silicon Lab

Tutorial: build an ALU

Write a small arithmetic unit and its test from a specification, starting from the Blank starter.

StudentEngineer 5 min read · Updated 2026-10-05

On this page ObjectiveBefore you startThe specification1. Create the project2. Write the design3. Write the test4. Simulate5. Break it on purpose6. Synthesize7. Build the chip8. Update the specificationHow to tell it workedCommon problemsWhere to go from here

Objective#

Design an 8-bit arithmetic and logic unit, write a test that checks it against its specification, and synthesize it. The code on this page was run with the lab's tool versions; the expected results below are what it produced.

Before you start#

The specification#

PortDirectionWidthMeaning
clkinput1Clock, rising edge
rst_ninput1Reset, active low, asynchronous
opinput200 add, 01 subtract, 10 AND, 11 XOR
a, binput8Operands, unsigned
resultoutput8The result
carryoutput1Carry out of an add, borrow out of a subtract, otherwise 0
zerooutput1High when result is 0
  • Latency: one clock. result and carry appear one clock after a, b and op are sampled.
  • Reset: result and carry are 0.
  • Overflow: result wraps to 8 bits. The ninth bit is reported in carry.

1. Create the project#

sh
nelix new alu --template blank
cd ~/project/alu

2. Write the design#

Replace the contents of rtl/top.sv with this.

systemverilog
`default_nettype none
// 8-bit ALU with a registered result.
// op: 00 add, 01 subtract, 10 AND, 11 XOR.
// result, carry and zero appear one clock after a, b and op are sampled.
module top (
    input  logic       clk,
    input  logic       rst_n,   // asynchronous, active low
    input  logic [1:0] op,
    input  logic [7:0] a,
    input  logic [7:0] b,
    output logic [7:0] result,
    output logic       carry,   // carry out of add, borrow out of subtract
    output logic       zero     // result is 0
);
    logic [8:0] next;           // one extra bit holds the carry or borrow

    always_comb begin
        next = 9'd0;            // a default first, so no latch is inferred
        case (op)
            2'b00:   next = {1'b0, a} + {1'b0, b};
            2'b01:   next = {1'b0, a} - {1'b0, b};
            2'b10:   next = {1'b0, a & b};
            2'b11:   next = {1'b0, a ^ b};
            default: next = 9'd0;
        endcase
    end

    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            result <= 8'd0;
            carry  <= 1'b0;
        end else begin
            result <= next[7:0];
            carry  <= next[8];
        end
    end

    assign zero = (result == 8'd0);
endmodule
`default_nettype wire

Three things to notice:

  • next is 9 bits, one more than the operands, so the carry is not lost.
  • The always_comb block gives next a default before the case, so no latch can be inferred.
  • zero is worked out from the registered result, so it always describes what is on the output.

3. Write the test#

Replace the contents of tb/test_top.py with this.

python
import random

import cocotb
from cocotb.clock import Clock
from cocotb.triggers import FallingEdge

ADD, SUB, AND, XOR = 0, 1, 2, 3
NAMES = {ADD: "add", SUB: "subtract", AND: "and", XOR: "xor"}


def expected(op, a, b):
    """The specification, in Python: (result, carry)."""
    if op == ADD:
        total = a + b
        return total & 0xFF, total >> 8
    if op == SUB:
        return (a - b) & 0xFF, 1 if a < b else 0
    if op == AND:
        return a & b, 0
    return a ^ b, 0


@cocotb.test()
async def test_alu(dut):
    """Every operation, at the edges and with random values."""
    cocotb.start_soon(Clock(dut.clk, 10, unit="ns").start())
    dut.op.value, dut.a.value, dut.b.value = 0, 0, 0
    dut.rst_n.value = 0
    await FallingEdge(dut.clk)
    await FallingEdge(dut.clk)
    assert int(dut.result.value) == 0, "result should be 0 in reset"
    assert int(dut.zero.value) == 1, "zero should be 1 when result is 0"
    dut.rst_n.value = 1

    random.seed(7)
    cases = [(op, a, b) for op in (ADD, SUB, AND, XOR)
             for a, b in ((0, 0), (255, 1), (0, 1), (255, 255), (128, 128))]
    cases += [(random.randrange(4), random.randrange(256), random.randrange(256)) for _ in range(40)]

    for op, a, b in cases:
        dut.op.value, dut.a.value, dut.b.value = op, a, b
        await FallingEdge(dut.clk)
        want, want_carry = expected(op, a, b)
        got, got_carry = int(dut.result.value), int(dut.carry.value)
        assert got == want, f"{NAMES[op]} {a}, {b}: expected {want}, saw {got}"
        assert got_carry == want_carry, f"{NAMES[op]} {a}, {b}: expected carry {want_carry}, saw {got_carry}"
        assert int(dut.zero.value) == (1 if want == 0 else 0), f"{NAMES[op]} {a}, {b}: zero flag is wrong"
    dut._log.info("All tests passed")

The function expected is the specification written in Python. It never looks at the design's code. The test tries every operation at the edges, where mistakes hide: zero, the largest value, a subtraction that borrows. Then it tries 40 random cases, with a fixed seed so a failure repeats.

4. Simulate#

sh
nelix test

Expected:

text
test_top.test_alu passed
** TESTS=1 PASS=1 FAIL=0 SKIP=0 **

5. Break it on purpose#

Prove to yourself that the test can catch a bug. In rtl/top.sv, change the subtract line to add:

systemverilog
            2'b01:   next = {1'b0, a} + {1'b0, b};

Run nelix test.

Expected: it fails, and the message says exactly where.

text
AssertionError: subtract 255, 1: expected 254, saw 0
** TESTS=1 PASS=0 FAIL=1 SKIP=0 **

Put the - back and run the test again.

6. Synthesize#

sh
nelix synth

Expected: the log ends with

text
Number of cells:                207
  $_AND_                         80
  $_DFF_PN0_                      9
  $_MUX_                          9
  $_NOT_                         17
  $_OR_                          62
  $_XOR_                         30

Nine flip-flops: eight for result and one for carry. The multiplexers choose between the four operations. The log also contains the line No latch inferred, which is what the default in always_comb bought you.

7. Build the chip#

sh
nelix build

The Blank starter's settings give a 100 by 100 micrometre die and a 10 ns clock. Read the result on the build card. This step was not run for this page, so there is no expected output to compare with; your build's own report is the answer.

8. Update the specification#

Replace docs/DESIGN.md with the specification above, under the headings the starters use. See Project specifications.

How to tell it worked#

  • The test passes, and fails when you break the design.
  • Synthesis reports nine flip-flops and no latch.

Common problems#

ProblemCause
syntax error on the first line of the moduleThe file was pasted with something missing. Check the first and last lines
The test fails on every caseThe design or the test was only partly replaced
Latch inferred in the synthesis logThe default next = 9'd0; at the top of always_comb was removed

Where to go from here#

  • Add a fifth operation, such as OR. You will need a wider op. Change the specification first, then the test, then the design.
  • Make the operands signed and add an overflow flag. See Bit widths, signed arithmetic and overflow.

Verified. Simulation and synthesis run with Icarus Verilog 12.0, cocotb 2.1.0 and Yosys 0.33 on 5 October 2026. The build step was not run.

Related

Something unclear or missing? Send feedbackSign in and use Feedback to tell us.