Skip to content

Conditional Assignments and Memories

manili edited this page Dec 31, 2020 · 21 revisions

Conditional Register Updates

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.

The .elsewhen Clause

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.

The .otherwise Clause

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.

The unless Clause

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).

Combinational Conditional Assignment

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.

Read Only Memories

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)

Read-Write Memories

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.

Basic Instantiation

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.

Synchronous vs. Asynchronous Read

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))

Adding Write Ports

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?>

Adding Read 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) 
} 
...

Example of Mem in Action

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.

Load/Search Mem Problem

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)