A memory is many words of state behind one address. You could build one from a
Vec of registers, but real hardware offers dedicated structures: constant
tables folded into logic, register banks made of flip-flops, and SRAM macros
supplied by the process. They differ in one property that shapes all the logic
around them: when read data becomes available.
Before you start. You will use Vec and VecInit from
Bundles and Vecs and the
current-value/next-value model from
Registers, RegInit, and stepping the clock.
Memory writes take effect at the clock edge, just like a register's next value.
A ROM's contents are computed by Scala and fixed during elaboration. The contents of a read-write memory change only at clock edges during simulation.
1. Read latency is the whole difference
| Memory | Write | Read | Likely implementation |
|---|---|---|---|
VecInit(...) ROM | none | Combinational: data in the same cycle | Constant logic |
Mem | At the clock edge | Combinational: data in the same cycle | Register bank (flip-flops) |
SyncReadMem | At the clock edge | Synchronous: address captured at the edge, data in the next cycle | Technology SRAM |
Writes behave the same in both read-write memories; reads differ. A Mem read
works like indexing a Vec: the data for this cycle's address is visible in
this cycle. A SyncReadMem read registers the address at the edge, so the
data for the address you present now appears one cycle later.
Note. Most SRAMs on modern FPGAs and ASIC processes support only that
registered read. That is why SyncReadMem is the memory that maps to them,
and Mem usually becomes flip-flops.
2. Read-only memories with VecInit
2.1 A table of literals
A ROM is a Vec of literals. VecInit accepts the literals as arguments or as
a Scala Seq, so the contents can be the result of any Scala computation: a
list, a formula, data read from a file at elaboration time.
Syntax
val name = VecInit(value1, value2, ...)
val name = VecInit(scalaSeq.map(i => expr.U(width.W)))val squares = VecInit((0 until 8).map(i => (i * i).U(6.W)))
io.out := squares(io.idx)| Code | Meaning |
|---|---|
(0 until 8).map(i => (i * i).U(6.W)) | Scala computes 0, 1, 4, …, 49 and makes eight 6-bit literals |
VecInit(...) | Turns the Seq of literals into a constant hardware Vec |
squares(io.idx) | Indexing with a hardware UInt becomes a multiplexer over the constants |
Caution. A Vec has one element type. Literals written without a width
each get their minimal width, so VecInit widens every element to the widest
one. 2.U is 2 bits and 13.U is 4 bits, so a table holding both has 4-bit
elements.
2.2 Counter
chisel3.util.Counter(n) is a ready-made version of the wrap counter you built
in Registers, RegInit, and stepping the clock:
it counts 0 to n - 1 and wraps to 0.
Syntax
val c = Counter(n)
val wrap = c.inc() // adds the logic that advances c.value| Member | Meaning |
|---|---|
c.value | The current count; Scala picks its width from n |
c.inc() | Adds the increment logic and returns a Bool that is true in the cycle where the count is n - 1, about to wrap at the next edge |
Call inc() inside a when and the counter advances only in cycles where
that condition holds.
Note. The form Counter(cond, n) builds a counter that advances only when
cond is true and returns a (value, wrap) pair, as in
val (value, wrap) = Counter(io.en, 5).
2.3 Example: walking a table of primes
PrimeRom maps the Scala list 2, 3, 5, 7, 11, 13 to literals and builds a
VecInit table. An elaboration-time println reports the table's length and
element width. Counter(table.length) generates addresses, and inc() is
called once, unconditionally, so the counter advances every cycle.
import chisel3._
import chisel3.util.Counter
class PrimeRom extends Module {
val io = IO(new Bundle {
val addr = Output(UInt(3.W))
val data = Output(UInt(8.W))
val wrap = Output(Bool())
})
// Scala computes the contents; VecInit turns them into constant hardware.
val primes = Seq(2, 3, 5, 7, 11, 13)
val table = VecInit(primes.map(_.U))
println(s"ELABORATE entries=${table.length} width=${table(0).getWidth}")
// Counter(n) is a register that counts 0 to n-1 and wraps.
val c = Counter(table.length)
val wrap = c.inc()
io.addr := c.value
io.data := table(c.value)
io.wrap := wrap
}The testbench prints address, data, and wrap flag for eight cycles.
import chisel3._
import chiseltest._
import chiseltest.RawTester.test
object Main extends App {
test(new PrimeRom) { dut =>
for (cycle <- 0 until 8) {
val a = dut.io.addr.peek().litValue
val d = dut.io.data.peek().litValue
val w = dut.io.wrap.peek().litValue
println(s"cycle=$cycle addr=$a data=$d wrap=$w")
dut.clock.step()
}
}
}Predict before you run.
- How many entries, and what element width, does the
ELABORATEline report? - In which cycle is
wraphigh? - What address and data does cycle 6 show?
Check
The table has six entries and the counter advances every cycle. In which cycle of the trace is wrap high, and what address does cycle 6 show?
Check the result.
| Output line | What it shows |
|---|---|
ELABORATE | Six entries of width 4, widened to fit the widest literal, 13.U |
| Cycles 0–5 | The primes 2, 3, 5, 7, 11, 13 at addresses 0 to 5 |
| Cycle 5 | wrap is high at the last address |
| Cycles 6–7 | Back to address 0 with data 2, then address 1 |
The wrap flag is combinational on the counter's current value. It marks the cycle before the counter returns to zero, which is when downstream logic must act if it wants to react in time.
Try it yourself. Append 17 to the Scala list and predict one change in each phase.
| Phase | What happens |
|---|---|
| Elaboration | The line reports seven entries of width 5, because 17 needs 5 bits |
| Simulation | The counter now wraps after address 6: wrap is high in cycle 6 with data 17, and address 0 returns in cycle 7 |
You did not touch the counter or the Vec code. Both the table length and the
width came from the Scala list.
3. The combinational-read memory Mem
3.1 Declaring, writing, and reading
Mem takes a depth and an element type. write creates a write port and
read creates a read port.
Syntax
val name = Mem(depth, elementType)
when(writeCondition) {
name.write(address, data)
}
val value = name.read(address)Here is a small register file of four 8-bit registers:
val regs = Mem(4, UInt(8.W))
when(io.wen) {
regs.write(io.waddr, io.wdata)
}
io.rdata := regs.read(io.raddr)| Code | Meaning |
|---|---|
Mem(4, UInt(8.W)) | A memory of four 8-bit words |
when(io.wen) { regs.write(...) } | Writes wdata at the edge ending each cycle where wen is high |
regs.read(io.raddr) | Reads the word at this cycle's raddr in the same cycle |
If you write and read the same address in the same cycle, the read returns the old value. The write lands at the edge, so the new value is visible from the next cycle on.
Note. Indexing with parentheses, regs(io.raddr), also creates a read
port.
4. The synchronous-read memory SyncReadMem
4.1 Declaration and read latency
SyncReadMem is declared and written exactly like Mem. Its read also
accepts an enable; in cycles where enable is low, no read happens.
Syntax
val name = SyncReadMem(depth, elementType)
val value = name.read(address)
val value = name.read(address, enable)The value read returns is the data for the address captured at the previous
edge: an address presented in cycle t produces data in cycle t + 1.
4.2 When read data is undefined
The official documentation leaves SyncReadMem read data undefined in three
cases:
| Condition | Result |
|---|---|
| The same address is written and read at the same clock edge | The read data is undefined |
| The read enable is low | The next cycle's read data is undefined |
| The cycle after read data appeared | The data is not guaranteed to be held; register it yourself if you need it later |
4.3 Example: the same addresses from both memories
MemCompare declares a Mem and a SyncReadMem of the same depth and type,
sends the same write inputs to both, and reads both at the same address
raddr. The SyncReadMem read is enabled only when no write is happening,
which avoids the read-during-write case above.
import chisel3._
class MemCompare extends Module {
val io = IO(new Bundle {
val wen = Input(Bool())
val waddr = Input(UInt(4.W))
val wdata = Input(UInt(8.W))
val raddr = Input(UInt(4.W))
val asyncOut = Output(UInt(8.W))
val syncOut = Output(UInt(8.W))
})
// Same depth, same element type; only the read timing differs.
val comb = Mem(16, UInt(8.W))
val sync = SyncReadMem(16, UInt(8.W))
when(io.wen) {
comb.write(io.waddr, io.wdata)
sync.write(io.waddr, io.wdata)
}
// Mem: data for raddr appears in the same cycle.
io.asyncOut := comb.read(io.raddr)
// SyncReadMem: raddr is captured at the edge; data appears next cycle.
io.syncOut := sync.read(io.raddr, !io.wen)
}The testbench writes 11, 22, 33, 44 to addresses 0 to 3, then presents the
addresses 2, 0, 3, 1, 1, one per cycle, and prints both outputs. In the first
read cycle no enabled read came before, so the syncmem value is undefined
and printed as --.
import chisel3._
import chiseltest._
import chiseltest.RawTester.test
object Main extends App {
test(new MemCompare) { dut =>
// Write 11, 22, 33, 44 into addresses 0 to 3.
dut.io.wen.poke(true.B)
for (a <- 0 until 4) {
dut.io.waddr.poke(a.U)
dut.io.wdata.poke(((a + 1) * 11).U)
dut.clock.step()
}
// Read the same address sequence from both memories.
dut.io.wen.poke(false.B)
for ((a, cycle) <- Seq(2, 0, 3, 1, 1).zipWithIndex) {
dut.io.raddr.poke(a.U)
val m = dut.io.asyncOut.peek().litValue
// Cycle 0 has no earlier enabled read, so its sync value is not shown.
val s = if (cycle == 0) "--" else dut.io.syncOut.peek().litValue.toString
println(s"cycle=$cycle raddr=$a mem=$m syncmem=$s")
dut.clock.step()
}
}
}Practice this concept
Use this concept in a short Chisel problem as your next step.
Predict before you run. The first two rows are filled in; continue for cycles 2 to 4.
| Read cycle | raddr | mem | syncmem |
|---|---|---|---|
| 0 | 2 | 33 | -- (the previous cycle was a write with the read disabled) |
| 1 | 0 | 11 | 33 (data for address 2, captured at the edge ending cycle 0) |
- How is the
syncmemcolumn related to thememcolumn? - Is there a cycle where both columns show the same value?
Check
Read cycle 2 presents raddr = 3. What do the mem and syncmem columns show?
Check the result.
| Column | What it shows |
|---|---|
mem | Follows the address column directly: 33, 11, 44, 22, 22 |
syncmem | The mem column shifted down one line: --, 33, 11, 44, 22 |
| Cycle 4 | The address repeats cycle 3's, so both columns show 22 |
Here the repeated address makes the two columns agree. Different addresses can also return equal data, so equal outputs alone do not prove equal read latency.
Try it yourself. Make the Mem read synchronous by hand: change its output
to io.asyncOut := RegNext(comb.read(io.raddr)), and predict the mem column.
| Cycle | mem | Why |
|---|---|---|
| 0 | 11 | The register's copy of mem[0], because raddr was 0 during the last write cycle |
| 1–4 | 33, 11, 44, 22 | Identical to syncmem |
A register after a combinational read gives the same visible timing as a synchronous-read memory.
Caution. Same timing, different hardware: this is a register bank plus an output register, not an SRAM.
5. Ports, masks, and the SRAM API
5.1 Write masks
Declare the element type as a Vec and write accepts a mask, so a write can
change only some lanes. Byte enables are the typical use.
Syntax
val name = SyncReadMem(depth, Vec(lanes, elementType))
name.write(address, data, mask) // mask: Vec(lanes, Bool())val mem = SyncReadMem(256, Vec(4, UInt(8.W)))
when(io.wen) {
mem.write(io.addr, io.wdata, io.mask)
}
io.rdata := mem.read(io.addr, !io.wen)Only lanes i with mask(i) high take wdata(i); the others keep their old
contents.
5.2 Single-ported memories and SRAM
| Feature | How it is written | What it gives |
|---|---|---|
| Single-ported memory | mem.readWrite(address, writeData, enable, isWrite), or read and write in mutually exclusive branches of one when | One shared read/write port, as in a single-port SRAM |
chisel3.util.SRAM | SRAM(depth, type, nRead, nWrite, nReadWrite) | A SyncReadMem with an explicit number of ports, driven through port bundles |
val mem = SyncReadMem(16, UInt(8.W))
io.rdata := mem.readWrite(io.addr, io.wdata, io.en, io.isWrite)Note. A single-ported memory read inside a when leaves the output
undriven in other cycles, so the official example first assigns
io.dataOut := DontCare to satisfy the unconnected-wire check.
Note. Memories can also be initialized from a binary or hex file. The official documentation marks that feature experimental, and this course does not use it.
6. Choosing a memory
| Memory | Good fit | Cost |
|---|---|---|
VecInit ROM | Small constant tables: lookup tables, coefficients, microcode | Synthesizes into logic, so area grows quickly with size |
Mem | A small memory where a same-cycle read keeps the surrounding logic simple, such as a small register file | Flip-flop area |
SyncReadMem | A memory large enough that an SRAM matters | One cycle of read latency; the address or the operation consuming the data must be lined up one cycle |
A Mem followed by RegNext matches the timing of SyncReadMem, so you can
switch between them while keeping a testbench valid. The choice then comes
down to the implementation the synthesis tool can infer.
7. Common mistakes
7.1 Writing without a when
// Wrong: writes wdata to waddr in every cycle
regs.write(io.waddr, io.wdata)
// Right: write only when wen is high
when(io.wen) {
regs.write(io.waddr, io.wdata)
}write fires in every cycle where its surrounding condition holds. Without a
condition, the wen input is never connected to the write port, and the
memory changes even in cycles where wen is low.
7.2 Reading and writing one SyncReadMem address at the same time
// Wrong: the read stays enabled during writes
when(io.wen) { sync.write(io.addr, io.wdata) }
io.rdata := sync.read(io.addr)
// Right: disable the read while writing
when(io.wen) { sync.write(io.addr, io.wdata) }
io.rdata := sync.read(io.addr, !io.wen)Writing and reading the same address at the same edge leaves SyncReadMem
read data undefined. A simulation may happen to show a plausible value, and an
SRAM may not.
7.3 Sending ROM values through a narrow port
// Wrong: 4-bit elements, 3-bit output
val out = Output(UInt(3.W))
io.out := table(io.idx) // 11 comes out as 3, 13 as 5
// Right: an output as wide as the elements
val out = Output(UInt(4.W))
io.out := table(io.idx)Connecting a wider value to a narrower port with := silently drops the high
bits. When ROM values lose their top bits, check the element width and the
width of every port the data passes through.
7.4 Declaring a large memory as Mem
// Wrong choice: a combinational read cannot map to SRAM
val big = Mem(4096, UInt(32.W))
// Right choice: synchronous read, with the one-cycle latency designed in
val big = SyncReadMem(4096, UInt(32.W))Nothing fails, but synthesis builds over 130,000 flip-flops and a huge read
multiplexer. When a memory is big enough to need an SRAM, use SyncReadMem
and design the surrounding logic around its one-cycle read.
8. Summary
| Topic | Key point |
|---|---|
| ROM | A Vec of literals built with VecInit; contents and element width come from the Scala list during elaboration |
Counter | value is the current count; inc() adds the increment and is true in the cycle before the wrap |
Mem | Writes at the edge, reads in the same cycle; usually becomes flip-flops |
SyncReadMem | Captures the address at the edge and returns data one cycle later; maps to SRAM |
| Undefined reads | Same-address read-during-write, the cycle after a disabled read, and the cycle after data appeared |
| Masks and ports | Vec elements plus a mask give per-lane writes; readWrite and SRAM set the port configuration |
9. Check your understanding
-
Why does the ROM's element width change from 4 to 5 bits when 17 is added, and in which phase is that width decided?
Show answer
Literals without a width get their minimal width, and
VecInitwidens every element to the widest one. 17 needs 5 bits, so the elements become 5 bits wide. That is decided during elaboration. -
In which cycle relative to the wrap does
Counter'sinc()return true?Show answer
In the cycle where the count is
n - 1, just before it wraps to 0 at the next edge. InPrimeRomthat is the cycle with address 5. -
In the
MemComparetrace, why is the firstsyncmemvalue not printed?Show answer
The
SyncReadMemdata visible in the first read cycle is the result of the previous cycle's read. The previous cycle was a write cycle withwenhigh, so the read enable was low and that data is undefined. -
If a
Memis written and read at the same address in the same cycle, what does the read return?Show answer
The old value. The write takes effect at the edge ending the cycle, and the combinational read sees the contents before it. The new value is visible from the next cycle.
-
Which two signals would you compare to show that
RegNext(comb.read(...))andSyncReadMemhave the same timing, and from which cycle are they expected to agree?Show answer
asyncOutandsyncOut, printed as thememandsyncmemcolumns. The first read cycle'ssyncmemvalue is undefined, so the columns should agree from read cycle 1 on.
10. Practice
Two Practice problems sit below this chapter, both linked to the
read-latency example. In the first, you add a valid flag to a scratchpad
built on SyncReadMem, so a client can take the data whenever the flag is
high; right now the flag rises in a different cycle from the data. In the
second, both operand ports of a register file return the previous
instruction's registers, and you find out why and fix it. For both, write the
cycle-by-cycle read table before you press Run.