Part IArchitectural Foundations

Project --- Building a 32-Bit ALU in Chisel

August 3, 2026·23 min read·beginner

The previous two chapters developed the ideas behind CMOS implementation (Chapter 9) and hardware description languages (Chapter 10). This project chapter puts both together. The reader will install the Chisel…

The previous two chapters developed the ideas behind CMOS implementation (Chapter 9) and hardware description languages (Chapter 10). This project chapter puts both together. The reader will install the Chisel toolchain, write a parameterizable 32-bit ALU in Chisel, simulate it with Verilator, view the resulting waveforms in Surfer, and gain first-hand experience with the write-simulate-debug cycle that every digital designer follows daily.

The ALU designed here is the direct descendant of the combinational ALU sketched at the end of Chapter 5. It implements ten operations: addition, subtraction, bitwise AND, OR, XOR, NOR, set-on-less-than (signed and unsigned), shift left logical, and shift right arithmetic. That list follows the earlier eight-function sketch rather than the RV32I opcode list. NOR is inherited from the sketch even though RV32I has no NOR instruction, and shift right logical arrives later in this chapter, in the section on extending the ALU. What carries forward to the single-cycle RISC-V datapath of Chapter 25 is the structure rather than the exact operation list. That structure computes every candidate result in parallel and selects one with a multiplexer driven by the function code.

01.Setup and Installation

The project requires four tools: a Java Development Kit (JDK), sbt (the Scala build tool), Verilator (for simulation), and Surfer (for waveform viewing). All four are free and open-source. The instructions below cover macOS, Linux (Arch Linux as the canonical distribution, with Debian/Ubuntu and Fedora alternatives), and Windows.

macOS (Homebrew)

macOS setup via Homebrew

Bash
# Install the JDK (Temurin 21 is the current LTS) brew install --cask temurin@21 # Install sbt brew install sbt # Install Verilator brew install verilator # Install Surfer brew install --cask surfer

Verify each tool after installation:

Verify macOS installations

Bash
java -version # should print Temurin 21.x
sbt --version # should print sbt 1.x
verilator --version # should print Verilator 5.x
surfer --version # should print the installed version

Linux (Arch as canonical)

Arch Linux setup

Bash
# JDK (OpenJDK 21)
sudo pacman -S jdk21-openjdk
# sbt (from the AUR — use your preferred AUR helper)
yay -S sbt
# Verilator
sudo pacman -S verilator
# Surfer (from the AUR)
yay -S surfer

For Debian/Ubuntu, replace pacman with apt:

Debian/Ubuntu alternative

Bash
sudo apt install default-jdk verilator
# sbt: follow https://www.scala-sbt.org/download/
# Surfer: download AppImage from the Surfer GitHub releases

For Fedora:

Fedora alternative

Bash
sudo dnf install java-21-openjdk verilator
# sbt and Surfer: same as Debian instructions above

Windows

The recommended approach on Windows is to run the HDL tools inside WSL2 with ArchWSL. Native Windows builds of Verilator exist but are less tested than the Linux versions. Surfer has a native Windows build.

Windows setup via WSL2 (ArchWSL)

Bash
# Inside the ArchWSL terminal:
sudo pacman -S jdk21-openjdk verilator
# sbt (from the AUR)
yay -S sbt
# Surfer (from the AUR, or download the Windows
# native binary from the Surfer GitHub releases
# and run it on the Windows side)
yay -S surfer

If you prefer to avoid WSL2, install the JDK and sbt natively on Windows (the Temurin MSI installer and the sbt Windows installer both work), and install Verilator via MSYS2 or the pre-built Windows binary from the Verilator releases page. Surfer’s native Windows binary works without WSL2.

02.Project Skeleton

Create a directory for the project and initialize an sbt project with the Chisel dependency:

Create the project directory

Bash
mkdir -p alu-chisel/src/main/scala
mkdir -p alu-chisel/src/test/scala
cd alu-chisel

build.sbt for the ALU project

Scala
// build.sbt val chiselVersion = "6.7.0" lazy val root = (project in file(".")) .settings( name := "alu-chisel", scalaVersion := "2.13.14", libraryDependencies ++= Seq( "org.chipsalliance" %% "chisel" % chiselVersion, "edu.berkeley.cs" %% "chiseltest" % "6.0.0" % "test" ), addCompilerPlugin( "org.chipsalliance" % "chisel-plugin" % chiselVersion cross CrossVersion.full ) )

Run sbt compile once to download dependencies. The first run takes a few minutes as sbt fetches Scala, Chisel, and the FIRRTL compiler.

03.Designing the ALU

The ALU accepts two 32-bit operands (a and b), a 4-bit function code (op), and a 5-bit shift amount (shamt). It produces a 32-bit result (result) and a 1-bit zero flag (zero) that is asserted when the result is all zeros.

Table 1. ALU operations and their function codes.

opOperationExpression
0000ADDa+ba + b
0001SUBaba - b
0010ANDa&ba \mathbin{\&} b
0011ORaba \mid b
0100XORaba \oplus b
0101NORab\overline{a \mid b}
0110SLT (signed)a<sba <_s b ? 1 : 0
0111SLTU (unsigned)a<uba <_u b ? 1 : 0
1000SLLashamta \ll \texttt{shamt}
1001SRAaashamta \gg_a \texttt{shamt}

The Chisel source

Complete 32-bit ALU in Chisel

Scala
package alu import chisel3._ import chisel3.util._ class ALU(val width: Int = 32) extends Module { val io = IO(new Bundle { val a = Input(UInt(width.W)) val b = Input(UInt(width.W)) val op = Input(UInt(4.W)) val shamt = Input(UInt(5.W)) val result = Output(UInt(width.W)) val zero = Output(Bool()) }) // Default output val result = WireDefault(0.U(width.W)) switch (io.op) { is ("b0000".U) { result := io.a + io.b } is ("b0001".U) { result := io.a - io.b } is ("b0010".U) { result := io.a & io.b } is ("b0011".U) { result := io.a | io.b } is ("b0100".U) { result := io.a ^ io.b } is ("b0101".U) { result := ~(io.a | io.b) } is ("b0110".U) { // Signed less-than: reinterpret as SInt result := Mux(io.a.asSInt < io.b.asSInt, 1.U, 0.U) } is ("b0111".U) { // Unsigned less-than result := Mux(io.a < io.b, 1.U, 0.U) } is ("b1000".U) { result := io.a << io.shamt } is ("b1001".U) { // Arithmetic right shift result := (io.a.asSInt >> io.shamt).asUInt } } io.result := result io.zero := result === 0.U }

Several points deserve attention.

The WireDefault(0.U) ensures that every code path assigns result, avoiding the latch-inference problem discussed in Chapter 10. If a future revision adds more opcodes and forgets to cover all cases, the default kicks in and the synthesis tool sees only combinational logic.

The signed operations (SLT, SRA) use .asSInt to reinterpret the unsigned bit pattern as a two’s-complement signed integer, perform the comparison or shift, and convert back with .asUInt. This matches the hardware behavior of the RISC-V SLT and SRA instructions.

The ALU is parameterizable via the width constructor argument. Instantiating new ALU(64) produces a 64-bit ALU from the same source.

Walking through the code

Several points in the ALU source deserve careful attention.

The WireDefault(0.U(width.W)) declaration creates a combinational wire with a default value of zero. This default ensures that every path through the switch statement assigns result, even if the opcode does not match any is clause. Without the default, the Chisel compiler would report an “uninitialized” error, and a Verilog equivalent would infer a latch (the problem discussed in Chapter 10).

The signed operations (SLT and SRA) use .asSInt to reinterpret the unsigned bit pattern as a two’s-complement signed integer, perform the comparison or shift, and convert back with .asUInt. This matches the hardware behavior of the RISC-V SLT and SRA instructions. The .asSInt call does not generate any hardware. It changes the type annotation in the Chisel IR so that the < operator compiles to a signed comparator instead of an unsigned one.

The io.zero output is derived from the result wire using the Chisel === (hardware equality) operator, not the Scala == operator. Recall from the elaboration model discussion: === produces a hardware comparator in the generated Verilog, while == would attempt a Scala-level comparison on a Chisel type and produce a compilation error.

The ALU is parameterizable via the width constructor argument. Instantiating new ALU(64) produces a 64-bit ALU from the same source. The shift amount width would need to grow from 5 to 6 bits for a 64-bit ALU (since log2(64)=6\log_{2}(64) = 6), a refinement left as an exercise.

Generating Verilog

To emit the Verilog netlist from the Chisel source, add a small driver object:

Verilog emission driver

Scala
package alu
import chisel3._
import circt.stage.ChiselStage
object ALUMain extends App {
ChiselStage.emitSystemVerilogFile(
new ALU,
firtoolOpts = Array("-disable-all-randomization",
"-strip-debug-info")
)
}

Run sbt "runMain alu.ALUMain" from the project root. The generated SystemVerilog file appears in the working directory. Open it to see the mux tree and adder/subtractor that the Chisel compiler produced from the switch statement.

04.Testing with ChiselTest

The test harness uses ChiselTest’s peek/poke API to drive the ALU inputs and check the outputs. Verilator serves as the simulation backend.

ALU test suite

Scala
package alu import chisel3._ import chiseltest._ import org.scalatest.flatspec.AnyFlatSpec class ALUTest extends AnyFlatSpec with ChiselScalatestTester { behavior of "ALU" it should "add two numbers" in { test(new ALU) { dut => dut.io.a.poke(10.U) dut.io.b.poke(20.U) dut.io.op.poke("b0000".U) dut.io.shamt.poke(0.U) dut.clock.step() dut.io.result.expect(30.U) dut.io.zero.expect(false.B) } } it should "subtract and detect zero" in { test(new ALU) { dut => dut.io.a.poke(42.U) dut.io.b.poke(42.U) dut.io.op.poke("b0001".U) dut.io.shamt.poke(0.U) dut.clock.step() dut.io.result.expect(0.U) dut.io.zero.expect(true.B) } } it should "perform bitwise AND" in { test(new ALU) { dut => dut.io.a.poke("hFF00FF00".U) dut.io.b.poke("h0F0F0F0F".U) dut.io.op.poke("b0010".U) dut.io.shamt.poke(0.U) dut.clock.step() dut.io.result.expect("h0F000F00".U) } } it should "perform signed less-than" in { test(new ALU) { dut => // -1 (0xFFFFFFFF) < 1 signed? Yes. dut.io.a.poke("hFFFFFFFF".U) dut.io.b.poke(1.U) dut.io.op.poke("b0110".U) dut.io.shamt.poke(0.U) dut.clock.step() dut.io.result.expect(1.U) } } it should "shift left logical" in { test(new ALU) { dut => dut.io.a.poke(1.U) dut.io.b.poke(0.U) dut.io.op.poke("b1000".U) dut.io.shamt.poke(4.U) dut.clock.step() dut.io.result.expect(16.U) } } }

Run the tests from the project root:

Run the test suite

Bash
sbt test

All tests should pass. If any test fails, ChiselTest reports the expected and actual values with the signal name and simulation time, making it straightforward to locate the bug.

05.Waveform Debugging with Surfer

To generate a VCD trace for visual inspection, modify the test to enable waveform dumping:

Test with VCD trace enabled

Scala
it should "produce a VCD trace" in { test(new ALU) .withAnnotations(Seq(WriteVcdAnnotation)) { dut => // Drive a sequence of operations val ops = Seq( (10, 20, 0), // ADD (42, 42, 1), // SUB (0xFF, 0x0F, 2) // AND ) for ((a, b, op) <- ops) { dut.io.a.poke(a.U) dut.io.b.poke(b.U) dut.io.op.poke(op.U) dut.io.shamt.poke(0.U) dut.clock.step() } } }

After the test completes, a .vcd file appears in the test_run_dir/ folder. Open it in Surfer:

View the waveform in Surfer

Bash
surfer test_run_dir/*/ALU.vcd

In the Surfer window, add io_a, io_b, io_op, io_result, and io_zero to the signal list. The waveform shows each operation’s inputs and the corresponding result, one clock cycle at a time. This is the same debugging workflow used for processors with millions of gates: drive stimuli, capture traces, and inspect the waveforms to find where the actual behavior diverges from the expected behavior.

06.Understanding the Generated Verilog

Opening the Verilog file produced by the Chisel compiler is instructive. The file is machine-generated and not meant to be edited by hand, but reading it reveals how the high-level Chisel constructs map to the standard hardware primitives that a synthesis tool expects.

The switch statement over io.op compiles into a cascade of multiplexers. Each is clause becomes a mux input selected by a comparator on the op field. The Chisel compiler often flattens the cascade into a single priority mux or a tree of 2-to-1 muxes, depending on the optimization passes that FIRRTL runs before lowering.

The signed operations (SLT and SRA) generate explicit sign-extension and comparison logic. The .asSInt < .asSInt comparison becomes a subtraction followed by a sign-bit extract, the same structure a hardware designer would build by hand.

The +& (widening add) operator from the overflow extension below generates an adder whose output is one bit wider than its inputs. The extra bit carries the unsigned carry-out. This is the same technique used in the ripple-carry adder of Chapter 5, now expressed at the RTL level rather than the gate level.

07.The Chisel Elaboration Model

A common source of confusion for students learning Chisel is the two-phase execution model. When the user runs sbt "runMain alu.ALUMain" or sbt test, two distinct things happen in sequence.

Phase 1: Scala execution (the generator runs). The JVM executes the Scala code. Every val, every when block, every switch clause, every for loop in the Chisel source runs as ordinary Scala code. But instead of computing data values, the Scala code builds a graph of hardware nodes (wires, registers, muxes, operators) inside the Chisel elaboration engine.

Phase 2: FIRRTL lowering and Verilog emission (the hardware is compiled). After the Scala constructor finishes, the Chisel compiler takes the hardware graph, runs optimization passes (constant propagation, dead-node removal, width inference), and emits Verilog (or SystemVerilog) for the target simulator or synthesis tool.

The practical consequence is that Scala if-else and Chisel when-otherwise are different things. A Scala if evaluates at elaboration time and selects which hardware to build. A Chisel when evaluates at simulation time (it becomes a mux in the generated hardware) and selects which value to drive. Confusing the two is the most common Chisel bug after forgetting to connect an output.

08.Extending the ALU

The 10-operation ALU above covers most of the basic RISC-V integer operations. Several extensions are natural, and each one reinforces the Chisel workflow: modify the source, add a test, run the suite, inspect the waveform if anything fails.

Overflow detection

Add an overflow output that is asserted when the addition or subtraction result overflows the signed 32-bit range. The overflow condition for addition is: the two operands have the same sign, but the result has the opposite sign. In Chisel:

Overflow detection for ADD

Scala
val sum = io.a +& io.b // +& extends by one bit
val overflow = (io.a(width-1) === io.b(width-1)) &&
(sum(width-1) =/= io.a(width-1))

For subtraction the condition mirrors: the two operands have different signs, and the result’s sign matches the subtrahend rather than the minuend. A single Chisel when block conditioned on the opcode selects the correct overflow formula.

Shift right logical

Add opcode 1010 for logical (unsigned) right shift: result := io.a >> io.shamt. The distinction between SRA (arithmetic, sign-extending) and SRL (logical, zero-filling) matters for the RISC-V SRL and SRLW instructions. A test case that feeds a negative number (MSB =1= 1) and checks that SRA preserves the sign while SRL clears it is the minimal verification.

Carry and borrow flags

Extend the result to width+1\texttt{width} + 1 bits for addition and subtraction, and expose the extra bit as a carry/borrow output. This is useful for multi-precision arithmetic, where the carry-out of the low 32 bits becomes the carry-in of the high 32 bits. In Chisel, the +& operator already produces the wider result. The carry is simply the MSB of the extended sum.

Multiplication

Add opcodes for the lower 32 bits of a 32×3232 \times 32 multiply (the RISC-V MUL instruction) and the upper 32 bits (the MULH family). This extends the ALU toward the integer execution unit of a real processor datapath. Because Chisel’s * operator on UInt produces a 2n2n-bit result, the lower and upper halves are extracted with simple bit slicing:

Multiply opcodes

Scala
val product = (io.a * io.b)
is ("b1011".U) { result := product(width-1, 0) }
is ("b1100".U) { result := product(2*width-1, width) }

Each extension follows the same workflow: add the new opcode to the switch statement, write a test case, run sbt test, and inspect the waveform if anything fails.

09.What This Project Teaches

The project chapter is intentionally small. Its purpose is not to build a production ALU but to establish three skills.

First, the Chisel write-compile-simulate-debug cycle. Every hardware project in the rest of the book follows this pattern, increasing in complexity from the ALU to a full pipelined CPU in Chapter 34.

Second, the parameterized-generator mindset. The ALU’s width parameter demonstrates that a single source can produce different hardware. The same principle scales to caches parameterized by associativity, pipelines parameterized by stage count, and interconnects parameterized by port count.

Third, visual waveform debugging. The ability to read a VCD trace in Surfer and correlate signal transitions with the design’s expected behavior is the fundamental debugging skill of hardware engineering.

Chapter 12 broadens the toolchain from Chisel-only to the full open-source HDL workflow: Yosys for synthesis, Icarus Verilog for event-driven simulation, and Verilator for cycle-accurate simulation. Together, Chapters 11 and 12 give the reader a complete, cross-platform, zero-cost environment for digital design.

10.Common Pitfalls

Students encounter a predictable set of errors when working through this project for the first time. Knowing them in advance saves debugging time.

Unconnected output. Chisel enforces that every declared output in the IO bundle must be driven. If you add an output port (say, overflow) but forget to wire it, the Chisel compiler raises a “not fully initialized” error during elaboration. The fix is to provide a default value with WireDefault or to ensure every code path assigns the signal.

Width mismatch. Chisel infers signal widths automatically, but explicit widths on IO ports are mandatory. If you write Input(UInt()) without a .W width specifier, the compiler cannot determine the port width and raises an error. Always specify widths on all ports and on RegInit initial values.

Using Scala if where Chisel when is needed. A Scala if cannot take a Chisel signal as its condition. Writing if (io.sel) {...} raises a Scala type error (found chisel3.Bool, required Boolean), because a Chisel signal carries no value at elaboration time. The correct construct is when (io.sel) {...}, which builds a mux. The quiet version of this mistake happens when the condition really is a Scala value, such as the useBarrel parameter earlier in this chapter. That if compiles, one branch is built, and the other never exists in the hardware. That behavior is exactly what an elaboration-time conditional is for when the choice is meant to be a compile-time parameter, and a bug when a run-time selection was wanted.

Forgetting the .U literal suffix. A bare Scala integer 0 is not a Chisel hardware literal. Writing io.a := 0 raises a type error. The correct form is io.a := 0.U. Similarly, true and false are Scala booleans, not Chisel Bool values. Use true.B and false.B.

sbt version conflicts. The Chisel ecosystem moves quickly. If the build fails with mysterious Scala compiler errors, the most likely cause is a mismatch between the Chisel library version, the Chisel plugin version, and the Scala compiler version in build.sbt. Always copy the version triple from the official Chisel template repository.

11.The Project in Context

The ALU built in this chapter is the compute engine at the heart of every CPU datapath in the book. In Chapter 25, the single-cycle RISC-V datapath instantiates an ALU built on the same parallel-compute-then-select pattern, with a 3-bit function code selecting among ADD, SUB, AND, OR, XOR, SLL, SRL, and SLT, and wires that function-code input to the output of a control decoder. In Chapter 29, the 5-stage pipeline wraps an ALU carrying the full RV32I operation list into the execute stage, and Chapter 30 adds the forwarding muxes that feed it results before those results reach the register file. In Chapter 34, the project chapter for Part III, the reader builds a full pipelined RV32IM CPU whose execute stage is a superset of the ALU designed here plus the multiply-divide unit from Chapter 7.

The parameterization strategy also scales. The Chisel width parameter in this chapter’s ALU is a simple integer. In later chapters, parameterization grows to include cache associativity, pipeline depth, branch-predictor table size, and interconnect topology. The generator pattern (a Scala function that produces different hardware for different parameters) is the organizing principle of the entire Chisel-based hardware stack, from the Rocket Chip generator at UC Berkeley to commercial RISC-V IP cores.

12.Worked Examples

13.Exercises

Book mode
computer-architecturearchitectural-foundations
Was this helpful?