A Formal Verification Framework for Chisel
The instructions can be referenced from the examples in the cases package. For more complex practice cases, you can see in:
https://github.com/Moorvan/RISCV-Formal-Chisel
The main deps are:
- SymbiYosys. You can get it from: https://github.com/YosysHQ/oss-cad-suite-build
- sbt
Or you can use Dockerfile to build the verification environment.
docker build -t chiselfv:v0 .
Using the property description capability provided by ChiselFV, first import the ChiselFv package into your project, and then use the trait Formal in the Chisel module you need to use:
class Counter(max_val: Int) extends Module with Formal {
// ...
}
Then you can use the assertions and timing-related property descriptions provided by ChiselFV in your module.
Memory is an important part of hardware development, and Chisel provides a library to construct Memory directly, making it easy to get a memory module with a parameterized size and width. We can quickly use 15 lines to get a parameterized memory in Chisel.
class Memory(c: Int, w: Int) extends Module {
val nw = log2Ceil(c)
val io = IO(new Bundle {
val wrEna = Input(Bool())
val wrData = Input(UInt(w.W))
val wrAddr = Input(UInt(nw.W))
val rdAddr = Input(UInt(nw.W))
val rdData = Output(UInt(w.W))
})
val mem = Mem(c, UInt(w.W))
when(io.wrEna) {
mem.write(io.wrAddr, io.wrData)
}
io.rdData := mem.read(io.rdAddr)
}
Next, we need to verify the correctness of the memory module. To verify the correctness of the memory module, first, we need to describe its properties of memory module. We can describe the property as at any time, any address
Next, we need to describe the property in Chisel. We first consider a simple version, which is the property description of the Memory module. Note that there are several arbitrary variables:
class Memory(c: Int, w: Int) extend Module with Formal {
...
// the IO and the implementation are omitted.
// Formal verification
val flag_value = WireInit(0.U(1.W))
val addr = anyconst(nw)
val flag = initialReg(1, 0)
val data = Reg(UInt(w.W))
flag.io.in := flag_value
when (io.wrAddr === addr & io.wrEna) {
flag_value := 1.U
data := io.wrData
}
when (io.rdAddr === addr && flag.io.out === 1.U) {
assert(data === io.rdData)
}
}
Finally, we can run the formal verification with the following command in sbt console:
> runMain cases.Memory
The formal verification call is:
Check.kInduction(() => new Memory(1024, 8))
We successfully proved that the property holds in the model.
Design and implementation details can be found at: https://github.com/VerificaticationStudio/MultPortedRAM.
Here, our focus is not on the design itself, so we abstract the multi-ported Memory. We are concerned about whether the multi-ported Memory can meet our requirements, so its internal implementation can be shielded, and we are only concerned about its input and output. The basic IO of a multi-ported memory with m write ports, and n read ports is shown below:
And the definition in Chisel is:
class MultiPortedMemory(m: Int, n: Int, size: Int, w: Int) extends Module {
val addrW: Int = log2Ceil(size)
val io = IO(new Bundle{
val wrAddr = Input(Vec(m, UInt(addrW.W)))
val wrData = Input(Vec(m, UInt(w.W)))
val wrEna = Input(Vec(m, Bool()))
val rdAddr = Input(Vec(n, UInt(addrW.W)))
val rdData = Output(Vec(n, UInt(w.W)))
})
...
// The implementation is omitted.
}
In the multi-ported Memory, we need to verify the hardware property as: at any time, any write port
We have finished the memory module verification code, and now we will consider the verification code for multi-port Memory. It is similar to the memory verification code that we can use the fact that the input determines some variables to remove them, such as
class MultiPortedMemory(m: Int, n: Int, size: Int, w: Int) extend Module with Formal {
...
// the IO and the implementation are omitted.
// Formal verification
val addr = anyconst(addrW)
val flag = initialReg(1, 0)
val data = Reg(UInt(w.W))
val wire = WireInit(0.U(1.W))
flag.io.in := wire
for (i <- 0 until m) {
when(io.wrAddr(i) === addr && io.wrEna(i) === true.B) {
data := io.wrData(i)
wire := 1.U
for(j <- 0 until m) {
if (i != j) {
assume(io.wrAddr(j) =/= addr || io.wrEna(j) === false.B)
}
}
}
}
for (i <- 0 until n) {
when(io.rdAddr(i) === addr && flag.io.out === 1.U) {
assert(io.rdData(i) === data)
}
}
}
After the description of the properties in Chisel, we can directly call the verification function provided by ChiselFV to perform formal verification.
object Main extends App {
Check.kInduction(() => new MultiPortedMemory(m = 2, n = 2, size = 8, w = 8))
Check.kInduction(() => new MultiPortedMemory(m = 3, n = 3, size = 8, w = 8))
Check.kInduction(() => new MultiPortedMemory(m = 4, n = 4, size = 16, w = 8))
}
The log of the verification process is shown below. It can be seen that the transition system of the multi-ported Memory is proved in the third step of the induction proof, and in the BMC verification, it is safe for 20 steps. The explanation is that in the first 20 steps, the property is always safe, and if the transition system is safe in three consecutive steps, then the fourth step is safe so that the property is always satisfied.
We also tried to apply the ChiselFV framework to the formal verification of the processor. We implemented and verified a five-stage pipelined processor in the textbook using Chisel. The implementation and verification code and the introduction are in the repository RISC-V Formal in Chisel.
Our verification solution is mainly inspired by the RISC-V Formal Verification Framework. They provide a framework for verifying RISC-V processors at the SystemVerilog level, using SVA to define properties and then using verification tools to verify them.
We try to migrate the RISC-V Formal verification framework to the Chisel level by ChiselFV to verify the RISC-V processor at the Chisel level. The following figure is our verification framework. Details are explained in RISC-V Formal in Chisel.