SpinalWorkshop實驗筆記(二)
概述
本文涉及Function、Apb3Decoder、Timer、BlackBoxAndClock四個實驗。實驗地址
內容
Function
本實驗的電路分兩個階段:
- 識別字符串:用從Flow中獲得的字元匹配引數字串
- 獲得資料:匹配成功後,從字串後面獲得一定量的位元組構成一個整數輸出
難點在於識別字符串。在前面的prime實驗我們知道SpinalHDL的型別不能轉換為scala基礎型別,所以字串索引無法使用。這個時候我們就要借鑑前面的思路:逐個比較,結果合併。對於一個SpinalHDL型別的索引,我們不能將其轉換為scala基礎型別,但我們可以和scala基礎型別比較,比較結果就是SpinalHDL型別了。所以我們可以構造一個scala的int型別的表(整數區間)作為中介,判斷是否存在表中有一個元素和當前索引相等,同時這個元素用來索引引數字串得到的字元和當前Flow中得到的字元相等:
def patternDetector(str : String) = new Area{ val hit = False // TODO val cnt = Counter(str.length) when (io.cmd.valid) { when((0 until str.length).map(x => cnt === x && io.cmd.payload === str(x)).orR) { when (cnt.willOverflowIfInc) { hit := True cnt.clear } otherwise { cnt.increment } } otherwise { cnt.clear } } }
注意這裡不能寫成迴圈形式:
for (i <- 0 until str.length) { when (x => cnt === x && io.cmd.payload === str(x)) { when (cnt.willOverflowIfInc) { hit := True cnt.clear } otherwise { cnt.increment } } otherwise { cnt.clear } }
雖然表面上是等價的,都是把迴圈/區間展開,但是在下面這種情況下cnt在迴圈中會改變,整個意思就變了。而電路中又沒有break語句用,這個地方如果是在軟體中的話應該是非常明顯的bug,但硬體上我就花了很長時間才發現,說明還是經驗太少了。
獲得資料階段就不是很難了:
def valueLoader(start : Bool,that : Data)= new Area{
require(widthOf(that) % widthOf(io.cmd.payload) == 0) //You can make the assumption that the 'that' width is alwas an mulitple of 8
// TODO
val bytecnt = widthOf(that) / widthOf(io.cmd.payload)
val hit = Reg(False) setWhen(start)
val cnt = Counter(bytecnt)
val data = Reg(Bits(widthOf(that) bits))
when (hit && io.cmd.valid) {
data.subdivideIn(bytecnt slices)(cnt) := io.cmd.payload
hit.clearWhen(cnt.willOverflowIfInc)
cnt.increment
}
that := data
}
注意data.subdivideIn(bytecnt slices)(cnt) := io.cmd.payload
切片後索引的變數cnt必須符合切片的塊數,比如切片塊數是8,cnt就必須是3位二進位制數。奇怪的是,當that的位數為8的時候,bytecnt等於1,宣告出的Counter的範圍只有一個數:0。相當於是0位二進位制數,非常反直覺的定義,一開始我很糾結,後來發現並不影響結果,但還是覺得很彆扭。
Apb3Decoder
這個實驗評測需要用到python,注意用來評測的python庫cocotb的版本必須是1.4.0及以下的,不然會有相容性問題。
這個實驗主要是要明白這個譯碼器做什麼的,其實很簡單,就是在一組子裝置中找到對應地址區間的子裝置,這個子裝置的片選訊號等於輸入裝置(父裝置)的片選訊號,同時父裝置的三個接收訊號PRDATA、PREADY和PSLERROR需要接收子裝置的相應訊號;其他裝置就直接連:
//TODO fully asynchronous apb3 decoder
io.input.PRDATA := io.outputs(0).PRDATA
io.input.PREADY := io.outputs(0).PREADY
if (apbConfig.useSlaveError) {
io.input.PSLVERROR := io.outputs(0).PSLVERROR
}
for (i <- 0 until outputsMapping.length) {
when (outputsMapping(i).hit(io.input.PADDR)) {
io.outputs(i).PSEL := io.input.PSEL
io.input.PRDATA := io.outputs(i).PRDATA
io.input.PREADY := io.outputs(i).PREADY
if (apbConfig.useSlaveError) {
io.input.PSLVERROR := io.outputs(i).PSLVERROR
}
} otherwise {
io.outputs(i).PSEL := 0
}
io.outputs(i).PENABLE := io.input.PENABLE
io.outputs(i).PADDR := io.input.PADDR
io.outputs(i).PWRITE := io.input.PWRITE
io.outputs(i).PWDATA := io.input.PWDATA
}
這裡需要注意io.input的三個接收訊號在使用時不能空置,即使是在整個for迴圈中一個子裝置都沒對上,也要強行賦一個值,不然會報latch error。文件裡latch error介紹的是組合邏輯迴路錯誤,實際上出現這種錯誤更多的原因是線路空置。一個類似的錯誤是暫存器沒有初始化,這種錯誤一般不會像上面那種可以在生成電路時檢測出來,而是直接導致邏輯錯誤。在普通評測時只會輸出一個錯誤的值,而用python評測時會輸出高阻的xxx。可能和兩者後端的模擬器有關,前者時verilator,後者是icarus verilog。顯然是後者更容易檢查出錯誤,不過我覺得SpinalHDL應該出一個暫存器沒賦初值就報錯的生成選項,從根本上杜絕這種錯誤。
Timer
這個實驗有兩個重點,一個是BusSlaveFactory物件的使用。我一開始一直不明白這個物件是幹嘛用的,畢竟是fpga初學者。目前看來的作用應該是為地址對映提供便利,像前面pwm實驗中就有根據地址讀寫電路內暫存器的需求,用了這個物件對映一個地址就是一句話的事;另一個是一連串訊號源的處理,父模組可以將一連串訊號都傳入子模組由子模組來進行計算和連線:
def driveFrom(busCtrl : BusSlaveFactory,baseAddress : BigInt)(ticks : Seq[Bool],clears : Seq[Bool]) = new Area {
//TODO phase 2
val clear = False
val ticksEnable = busCtrl.createReadAndWrite(Bits(ticks.length bits), baseAddress + 0, 0) init(0)
val clearsEnable = busCtrl.createReadAndWrite(Bits(clears.length bits), baseAddress + 0, 16) init(0)
busCtrl.driveAndRead(io.limit, baseAddress + 4)
clear.setWhen(busCtrl.isWriting(baseAddress + 4))
busCtrl.read(io.value, baseAddress + 8)
clear.setWhen(busCtrl.isWriting(baseAddress + 8))
io.tick := (ticksEnable & ticks.asBits).orR
io.clear := (clearsEnable & clears.asBits).orR | clear
}
createReadAndWrite建立一個可讀可寫的暫存器並對映;driveAndRead是對映一個已有的埠,並設定為可讀可寫;read也是對映一個已有的埠,但設定為只讀。isWriting用於捕獲對地址的寫請求。
計時器的電路程式碼非常簡單:
//TODO phase 1
val v = Counter(width bits)
when (io.clear) {
v.clear
} elsewhen (io.tick && v =/= io.limit) {
v.increment
}
io.full := v === io.limit
io.value := v
BlackBoxAndClock
本實驗的重點是blackbox的使用,這部分SpinalHDL的文件寫得非常詳細,所以實際上不難:
// TODO define Generics
addGeneric("wordWidth", wordWidth)
addGeneric("addressWidth", addressWidth)
// TODO define IO
val io = new Bundle {
val wr = new Bundle {
val clk = in Bool
val en = in Bool
val addr = in UInt(addressWidth bit)
val data = in Bits(wordWidth bit)
}
val rd = new Bundle {
val clk = in Bool
val en = in Bool
val addr = in UInt(addressWidth bit)
val data = out Bits(wordWidth bit)
}
}
// TODO define ClockDomains mappings
mapClockDomain(writeClock, io.wr.clk)
mapClockDomain(readClock, io.rd.clk)
基本上就是分三步走:
- 定義引數,用addGeneric,指定verilog模組中的parameter
- 定義介面,這裡的層次結構要和verilog模組裡的埠名相符合,比如上面程式碼裡的io.wr.clk對應的就是verilog裡的io_wr_clk,如果要違反命名規範需要特殊設定,文件裡也有寫
- 對映時鐘,將當前的時鐘或者你定義的時鐘對映到verilog的時鐘埠上
然後是電路定義,這個雖然不是重點,但是我卻栽了很大的跟頭,花了很長時間去研究這個時序。之前verilog課的考試也是栽在時序上。這裡我根據波形總結了三條定律:
-
對於when (cond) {xxx}這樣的語句,xxx的觸發在cond變為高電平之後的下次時鐘上升沿。舉例來說,假設第一個上升沿cond變成了高電平,那麼xxx的第一次觸發在第二個上升沿
-
記憶體的讀資料埠會在讀地址變化的下次時鐘上升沿才產生變化
-
在時鐘上升沿時,首先會對記憶體讀資料埠取值,接著讀資料埠更新,最後暫存器產生變化
這樣我們就可以分析下面的程式碼了:
val sumArea = new ClockingArea(sumClock){ // TODO define the memory read + summing logic
val sum = Reg(io.sum.value) init(0)
io.sum.value := sum
val readAddr = Counter(widthOf(io.wr.addr) bits)
var cntEnable = RegInit(False)
val sumEnable = RegNext(cntEnable) init(False)
ram.io.rd.en := cntEnable
ram.io.rd.addr := readAddr
when (io.sum.start) {
cntEnable.set
readAddr.clear
sum.clearAll
}
when (cntEnable) {
readAddr.increment
}
when (sumEnable) {
sum := sum + ram.io.rd.data.asUInt
cntEnable.clearWhen(readAddr.willOverflowIfInc)
}
io.sum.done.clear
io.sum.done.setWhen(sumEnable.fall(False))
}
設io.sum.start被觸發是第0時鐘上升沿,根據定律1,cntEnable被置1和readAddr清零是第1上升沿。則在第2上升沿,readAddr變為1,且sumEnable緊隨cntEnable被置1,又根據定律2,這時ram.io.rd.data才是地址為0的值。在第3上升沿,根據定律3,首先取ram.io.rd.data的值加到sum上,然後ram.io.rd.data更新為地址為1的值,最後readAddr變為2。在第4上升沿,同樣地址為1的值被加到sum上,再更新記憶體讀埠,再更新計數器,以此類推。
可以看出,每次加在sum暫存器上的值是當前計數器減2作為地址得到的值,這也是為什麼要兩個enable的原因。對於結束時的訊號,也得仔細分析,設readAddr被加到0xFF的那個上升沿為第0上升沿,這時sum加上了地址為0xFD的記憶體值,然後埠更新為地址為0xFE的記憶體值。根據定律1,在第1上升沿,cntEnable清零,同時sum加上地址為0xFE的記憶體值,埠更新為地址為0xFF的記憶體值,readAddr更新為0。這時io.sum.done不能置1,因為sum還沒加完。在第2上升沿,sumEnable隨之清零,同時sum加上地址為0xFF的記憶體值,埠更新,readAddr已經不會加了,io.sum.done在sumEnable清零的瞬間置1。這裡只能用“清零的瞬間”這個條件,因為用高電平還是低電平判斷怎麼也不合適。
然後有兩個注意的地方:
-
布林型暫存器設定初始值要用RegInit或者Reg(Bool) init(),我以前以為Reg(False)就是賦初值為False了,結果並不是,只是說明這個暫存器和False是同類型而已
-
關於xxx.fall函式,我看SpinalHDL原始碼裡有兩個過載:
/** * Falling edge detection of this with an initial value * @example{{{ val res = myBool.fall(False) }}} * @param initAt the initial value * @return a Bool */ def fall(initAt: Bool): Bool = ! this && RegNext(this).init(initAt) /** Falling edge detection */ def fall(): Bool = ! this && RegNext(this)
實現方式是定義一個暫存器作為當前訊號的後繼,這樣當前訊號下降的時候後繼暫存器還沒下降。我一開始沒仔細看,選了後面那個沒引數的過載。結果那個後繼暫存器沒初始化!眾所周知,沒有初始化會導致一些匪夷所思的結果,我就遇到了諸如when (cond) {do A} otherwise {do B}和when (!cond) {do B} otherwise {do A}結果不一樣和計數器只能用Reg不能用Counter之類的詭異問題。最後看了波形才明白,原因是後繼暫存器一開始為1,所以只要當前訊號是低電平都會返回1。我只想吐槽一句,寫第二個過載的人是不是SpinalHDL專案組裡的內鬼,這不是純坑爹嗎?事實上,我覺得初始值直接預設為假就可以了,根本就沒有初始值為真能返回正確結果的情況。