-
Notifications
You must be signed in to change notification settings - Fork 197
Conditional Assignments and Memories
As shown earlier in the tutorial, conditional register updates are performed with the when
block which takes a Bool value or some boolean expression to evaluate.
In this section we more fully explore how to use this when
conditional update structure.
If a when
block is used by itself, Chisel will assume that if the condition for the when
block doesn’t evaluate to true, there is no update to the register value.
However, most of the time we don’t want to limit ourselves to a single conditional.
Thus in Chisel we use .elsewhen
and .otherwise
statements to select between multiple possible register updates as shown in the following sections.
When specifying a conditional update, we may want to check several conditions which we want to check in some order.
To do this for register updates, we use a when
... .elsewhen
structure.
This is analagous to an if... else if control structure in sequential programming.
As with else if clauses, as many .elsewhen
statements can be chained together in a single when
block.
The general structure thus looks like:
when (<condition 1>) {<register update 1>}
.elsewhen (<condition 2>) {<register update 2>}
...
.elsewhen (<condition N>) {<register update N>}
Where <condition 1> through represent the trigger conditions of their respective segments.
An example of this statement in action is shown in the following implementation of a simple stack pointer. Suppose, we need to maintain a pointer that keeps track of the address of the top of a stack. Given a signal pop that decrements the stack pointer address by 1 entry and a signal push that increments the stack pointer address by 1 entry, the implementation of just the pointer would look like the following:
class StackPointer(size:Int) extends Module {
val io = IO(new Bundle {
val push = Input(Bool())
val en = Input(Bool())
val pop = Input(Bool())
})
val sp = RegInit(0.U(log2Ceil(size).W))
when (io.en && io.push && (sp != (size-1).U)) {
sp := sp + 1.U
} .elsewhen(io.en && io.pop && (sp > 0.U)) {
sp := sp - 1.U
}
}
Notice that in this implementation, the push signal has higher priority over the pop signal as it appears earlier in the when
block.
In order to specify a default register update value if all the conditions in the when
block fail to trigger, we use an .otherwise
clause.
The .otherwise
clause is analagous to the else case that completes an if ... else block.
The .otherwise
statement must occur last in the when
block.
The general structure for the complete when
block now looks like:
when (<condition 1>) {<register update 1>}
.elsewhen (<condition 2>) {<register update 2>}
...
.elsewhen (<condition N>) {<register update N>}
.otherwise {<default register update>}
In the previous example, we could add a default statement which just assigns sp to the current value of sp. The block would then look like:
when(io.en && io.push && (sp != (size-1).U)) {
sp := sp + 1.U
} .elsewhen(io.en && io.pop && (sp > 0.U)) {
sp := sp - 1.U
} .otherwise {
sp := sp
}
The explicit assignment to preserve the value of sp is redundant in this case but it captures the point of the .otherwise
statement.
To complement the when
statement, Chisel also supports an unless statement.
The unless statement is a conditional assignment that triggers only if the condition is false.
The general structure for the unless statement is:
unless ( <condition> ) { <assignments> }
For example, suppose we want to do a simple search of the contents of memory and determine the address that contains some number. Since we don’t know how long the search will take, the module will output a done signal when it is finished and until then, we want to continue to search memory. The Chisel code for the module would look like:
class MemorySearch extends Module {
val io = IO(new Bundle {
val target = Input(UInt(4.W))
val address = Output(UInt(3.W))
val en = Input(Bool())
val done = Output(Bool())
})
val index = RegInit(0.U(3.W))
val list = Vec(0.U, 4.U, 15.U, 14.U, 2.U, 5.U, 13.U)
val memVal = list(index)
val done = (memVal === io.target) || (index === 7.U)
unless (done) {
index := index + 1.U
}
io.done := done
io.address := index
}
In this example, we limit the size of the memory to 8 entries and use a vector of literals to create a read only memory. Notice that the unless statement is used to terminate the iteration if it see that the done signal is asserted. Otherwise, it will continue to increment the index in memory until it finds the value in target or reaches the last index in the memory (7).
You can also use the when
.elsewhen
.otherwise
block to define combinational values that may take many values.
For example, the following Chisel code show how to implement a basic arithmetic unit with 4 operations: add, subtract, and pass.
In this example, we check the opcode to determine which operation to perform and conditionally assign the output.
class BasicALU extends Module {
val io = IO(new Bundle {
val a = Input(UInt(4.W))
val b = Input(UInt(4.W))
val opcode = Input(UInt(2.W))
val output = Output(UInt(4.W))
})
io.output := 0.U
when (io.opcode === 0.U) {
io.output := io.a + io.b // ADD
} .elsewhen (io.opcode === 1.U) {
io.output := io.b - io.b // SUB
} .elsewhen (io.opcode === 2.U) {
io.output := io.a // PASS A
} .otherwise {
io.output := io.b // PASS B
}
}
Notice that this can easily be easily expanded to check many different conditions for more complicated arithmetic units or combinational blocks.
To instantiate read only memories in Chisel, we use a vector of constant literals. For example, in order to instantiate an 4 entry read only memory with the values 0 to 3, the definition would look like the following:
val numbers =
Vec(0.U, 1.U, 2.U, 3.U)
The width of the Vec is width of the widest argument. Notice that we need to specify the type of literal in the ... braces following the literals. Accessing the values in the read only memory is the same as accessing an entry in a Vec. For example, to access the 2nd entry of the memory we would use:
val entry2 = numbers(2)
Chisel contains a primitive for memories called Mem. Using the Mem class it is possible to construct multi-ported memory that can be synchronous or asynchronous read.
The Mem construction takes a memory size and a data type which it is composed of. The general declaration structure looks like:
val myMem = Mem(<size>, <type>)
Where corresponds to the number of entries of are in the memory.
For instance, if you wanted to create a 128 entry memory of 32 bit UInt types, you would use the following instantiation:
val myMem = Mem(128, UInt(32.W))
Note that when constructing a memory in Chisel, the initial value of memory contents cannot be specified. Therefore, you should never assume anything about the initial contents of your Mem class.
It is possible to specify either synchronous or asynchronous read behavior.
For instance, if we wanted an asynchronous read 128 entry memory of 32 bit UInt types, we would use the following definition:
val combMem =
Mem(128, UInt(32.W))
Likewise, if we wanted a synchronous read 128 entry memory of 32 bit UInt types, we use a SeqMem object:
val seqMem =
SyncReadMem(128, UInt(32.W))
To add write ports to the Mem, we use a when
block to allow Chisel to infer a write port. Inside the when
block, we specify the location and data for the write transaction. In general, adding a write port requires the following definition:
when (<write condition> ) {
<memory name>( <write address> ) := <write data>
}
Where refers to the entry number in the memory to write to. Also notice that we use the reassignment operator := when writing to the memory.
For example, suppose we have a 128 entry memory of 32 bit UInt types. If we wanted to write a 32 bit value dataIn to the memory at location writeAddr if the write enable signal wen is true, our Chisel code would look like:
...
val myMem = Mem(128, UInt(32.W))
val wen = io.writeEnable
val writeAddr = io.waddr
val dataIn = io.wdata
when (wen) {
myMem(writeAddr) := dataIn
}
...
<what is the behavior of multiple write ports?>
Depending on the type of read behaviour specified, the syntax for adding read ports to Mem in Chisel is slightly different for asynchronous read and synchronous read memories.
Asynchronous Read Ports
For asynchronous read memories, adding read ports to the memory simply amounts to placing an assignment inside a when
block with some trigger condition.
If you want Chisel to infer multiple read ports, simply add more assignments in the when
definition.
The general definition for read ports is thus:
when (<read condition>) {
<read data 1> := <memory name>( <read address 1> )
...
<read data N> := <memory name>( <read address N>)
}
For instance, if you wanted a 128 entry memory of 32 bit UInt values with two asynchronous read ports, with some read enable re
and reads from addresses raddr1
and raddr2
, we would use the following when
block definition:
...
val myMem = Mem(128, UInt(32.W))
val raddr1 = io.raddr
val raddr2 = io.raddr + 4.U
val re = io.readEnable
val read_port1 = UInt(32.W)
val read_port2 = UInt(32.W)
when (re) {
read_port1 := myMem(raddr1)
read_port2 := myMem(raddr2)
}
...
Note that the type and width of the read_port1
and read_port2
should match the type and width of the entries in the Mem.
Synchronous Read Ports
In order to add synchronous read ports to the Chisel Mem class, Chisel requires that the output from the memory be assigned to a Reg type.
Like the asynchronous read port, a synchronous read assignment must occur in a when
block.
The general structure for the definition of a synchronous read port is as follows:
...
val myMem = SyncReadMem(128, UInt(32.W))
val raddr = io.raddr
val read_port = Reg(UInt(32.W))
when (re) {
read_port := myMem(raddr)
}
...
Here we provide a small example of using a memory by implementing a stack.
Suppose we would like to implement a stack that takes two signals push and pop where push tells the stack to push an input dataIn to the top of the stack, and pop tells the stack to pop off the top value from the stack. Furthermore, an enable signal en disables pushing or popping if not asserted. Finally, the stack should always output the top value of the stack.
class Stack(size: Int) extends Module {
val io = IO(new Bundle {
val dataIn = Input(UInt(32.W))
val dataOut = Output(UInt(32.W))
val push = Input(Bool())
val pop = Input(Bool())
val en = Input(Bool())
})
// declare the memory for the stack
val stack_mem = Mem(size, UInt(32.W))
val sp = RegInit(0.U(log2Ceil(size).W))
val dataOut = RegInit(0.U(32.W))
// Push condition - make sure stack isn't full
when(io.en && io.push && (sp != (size-1).U)) {
stack_mem(sp + 1.U) := io.dataIn
sp := sp + 1.U
}
// Pop condition - make sure the stack isn't empty
.elsewhen(io.en && io.pop && (sp > 0.U)) {
sp := sp - 1.U
}
when(io.en) {
dataOut := stack_mem(sp)
}
io.dataOut := dataOut
}
Since the module is parametrized to be have size entries, in order to correctly extract the minimum width of the stack pointer sp we take the log2Ceil(size). This takes the base 2 logarithm of size and rounds up.
In this assignment, write a memory module that supports loading elements and searching based on the following template:
class DynamicMemorySearch(val n: Int, val w: Int) extends Module {
val io = IO(new Bundle {
val isWr = Input(Bool())
val wrAddr = Input(UInt(log2Ceil(n).W))
val data = Input(UInt(w.W))
val en = Input(Bool())
val target = Output(UInt(log2Ceil(n).W))
val done = Output(Bool())
})
val index = RegInit(0.U(log2Ceil(n).W))
val memVal = 0.U
/// fill in here
io.done := false.B
io.target := index
}
and found in $TUT_DIR/src/main/scala/problems/DynamicMemorySearch.scala. Notice how it support size and width parameters n and w and how the address width is computed from the size. Run
./run-problem.sh DynamicMemorySearch
until your circuit passes the tests.
Prev (Creating Your Own Project) Next (Scripting Hardware Generation)