1. 程式人生 > 其它 >SpinalWorkshop實驗筆記(二)

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)

基本上就是分三步走:

  1. 定義引數,用addGeneric,指定verilog模組中的parameter
  2. 定義介面,這裡的層次結構要和verilog模組裡的埠名相符合,比如上面程式碼裡的io.wr.clk對應的就是verilog裡的io_wr_clk,如果要違反命名規範需要特殊設定,文件裡也有寫
  3. 對映時鐘,將當前的時鐘或者你定義的時鐘對映到verilog的時鐘埠上

然後是電路定義,這個雖然不是重點,但是我卻栽了很大的跟頭,花了很長時間去研究這個時序。之前verilog課的考試也是栽在時序上。這裡我根據波形總結了三條定律:

  1. 對於when (cond) {xxx}這樣的語句,xxx的觸發在cond變為高電平之後的下次時鐘上升沿。舉例來說,假設第一個上升沿cond變成了高電平,那麼xxx的第一次觸發在第二個上升沿

  2. 記憶體的讀資料埠會在讀地址變化的下次時鐘上升沿才產生變化

  3. 在時鐘上升沿時,首先會對記憶體讀資料埠取值,接著讀資料埠更新,最後暫存器產生變化

這樣我們就可以分析下面的程式碼了:

  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專案組裡的內鬼,這不是純坑爹嗎?事實上,我覺得初始值直接預設為假就可以了,根本就沒有初始值為真能返回正確結果的情況。