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
# 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 surferVerify each tool after installation:
Verify macOS installations
| 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
| # 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
| 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
| 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)
| # 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
| mkdir -p alu-chisel/src/main/scala | |
| mkdir -p alu-chisel/src/test/scala | |
| cd alu-chisel |
build.sbt for the ALU project
// 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.
op | Operation | Expression |
|---|---|---|
0000 | ADD | |
0001 | SUB | |
0010 | AND | |
0011 | OR | |
0100 | XOR | |
0101 | NOR | |
0110 | SLT (signed) | ? 1 : 0 |
0111 | SLTU (unsigned) | ? 1 : 0 |
1000 | SLL | |
1001 | SRA |
The Chisel source
Complete 32-bit ALU in Chisel
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 ), 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
| 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
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
| 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
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
| 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
| 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 ) and checks that SRA preserves the sign while SRL clears it is the minimal verification.
Carry and borrow flags
Extend the result to 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 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 -bit result, the lower and upper halves are extracted with simple bit slicing:
Multiply opcodes
| 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.