Chisel中篇-Chisel基礎介紹


參考博文:https://blog.csdn.net/qq_34291505/article/details/86744581

第十六章 Chisel入門——搭建開發環境

  用於編寫Chisel的Scala內容已經全部講完了,下面就可以正式進入Chisel的學習之旅了。有興趣的讀者也可以自行深入研究Scala的其它方面,不管是日后學習、工作,或是研究Chisel發布的新版本,都會有不少的幫助。

在學習Chisel之前,自然是要先講解如何搭建開發環境。因為目前還沒有Windows系統的開發環境,所以讀者最好有一個Linux系統的虛擬機,或者Mac OS的電腦。在這里,筆者以使用廣泛的Ubuntu 16.04作為開發平台進行講解。

一、Chisel的安裝步驟
  首先自然是要保證已經安裝好了Scala。對於如何安裝Scala,這里就不再贅述,可以參考第二章。接下來,執行以下安裝步驟:

  ①安裝Sbt。以下所有安裝都只需要默認版本,通過命令安裝即可。如果需要特定的版本,讀者可以自行下載安裝包安裝。打開終端,執行命令:

esperanto@ubuntu:~$ sudo apt-get install sbt

  等待安裝完成后,可以用命令查看sbt的版本:

esperanto@ubuntu:~$ sbt sbtVersion
[info] Loading project definition from /home/esperanto/project
[info] Set current project to esperanto (in build file:/home/esperanto/)
[info] 1.2.6 

  ②安裝Git,系統可能已經自帶了。執行命令:

esperanto@ubuntu:~$ sudo apt-get install git

esperanto@ubuntu:~$ git --version
git version 2.7.4 

  ③安裝Verilator。執行命令:

esperanto@ubuntu:~$ sudo apt-get install verilator 

esperanto@ubuntu:~$ verilator -version
Verilator 3.904 2017-05-30 rev verilator_3_904 

  ④從GitHub上克隆一個chisel-template文件夾。在想要安裝chisel的目錄下執行命令:

esperanto@ubuntu:~$ git clone https://github.com/freechipsproject/chisel-template 

這個文件夾就是一個工程文件,它已經自帶了Chisel 3.1.3。如果讀者不想使用新版本的Chisel,那么在這個工程文件下編寫代碼也已經足夠了。如果想用更新的版本,則繼續下面的步驟。

  ⑤安裝Firrtl。在想要安裝chisel的目錄下執行命令:

esperanto@ubuntu:~$ git clone https://github.com/freechipsproject/firrtl.git && cd firrtl

  克隆完成后,cd命令會把終端路徑切換到firrtl文件夾下,在該路徑下執行編譯命令:

esperanto@ubuntu:~/firrtl$ sbt compile

  編譯完成后,執行測試命令:

esperanto@ubuntu:~/firrtl$ sbt test 

  測試通過后,執行匯編命令:

esperanto@ubuntu:~/firrtl$ sbt assembly 

  匯編完成后,推送到本地緩存:

esperanto@ubuntu:~/firrtl$ sbt publishLocal

  測試步驟容易出錯,多半是虛擬機的網絡不好導致克隆下來的文件有缺失。如果上述步驟都正確完成了,則可以查看firrtl的版本:

esperanto@ubuntu:~/firrtl$ cd ~/.ivy2/local/edu.berkeley.cs/ && ls
chisel3_2.12  firrtl_2.12 

  ⑥安裝chisel3,步驟與firrtl類似。在想要安裝chisel的目錄下執行以下命令:

esperanto@ubuntu:~$ git clone https://github.com/freechipsproject/chisel3.git && cd chisel3

esperanto@ubuntu:~/chisel3$ sbt compile

esperanto@ubuntu:~/chisel3$ sbt test

esperanto@ubuntu:~/chisel3$ sbt publishLocal

esperanto@ubuntu:~/chisel3$ cd ~/.ivy2/local/edu.berkeley.cs/ && ls

  如果所有命令都成功,就能看見chisel3的緩存。

  ⑦仍然把第④步克隆的chisel-template文件夾作為工程文件,但是里面的build.sbt文件改成以下內容:

// build.sbt
def scalacOptionsVersion(scalaVersion: String): Seq[String] = {
Seq() ++ {
// If we're building with Scala > 2.11, enable the compile option
// switch to support our anonymous Bundle definitions:
// https://github.com/scala/bug/issues/10047
CrossVersion.partialVersion(scalaVersion) match {
case Some((2, scalaMajor: Long)) if scalaMajor < 12 => Seq()
case _ => Seq("-Xsource:2.11")
}
}
}

def javacOptionsVersion(scalaVersion: String): Seq[String] = {
Seq() ++ {
// Scala 2.12 requires Java 8. We continue to generate
// Java 7 compatible code for Scala 2.11
// for compatibility with old clients.
CrossVersion.partialVersion(scalaVersion) match {
case Some((2, scalaMajor: Long)) if scalaMajor < 12 =>
Seq("-source", "1.7", "-target", "1.7")
case _ =>
Seq("-source", "1.8", "-target", "1.8")
}
}
}

name := "MyChisel"
version := "3.2-SNAPSHOT"
scalaVersion := "2.12.6"
crossScalaVersions := Seq("2.11.12", "2.12.4")

resolvers += "My Maven" at "https://raw.githubusercontent.com/sequencer/m2_repository/master"
// bug fix from https://github.com/freechipsproject/chisel3/wiki/release-notes-17-09-14
scalacOptions ++= Seq("-Xsource:2.11")

libraryDependencies += "edu.berkeley.cs" %% "chisel3" % "3.2-SNAPSHOT"
libraryDependencies += "edu.berkeley.cs" %% "chisel-iotesters" % "1.2.+"
libraryDependencies += "edu.berkeley.cs" %% "chisel-dot-visualizer" % "0.1-SNAPSHOT"
libraryDependencies += "edu.berkeley.cs" %% "rocketchip" % "1.2"

scalacOptions ++= scalacOptionsVersion(scalaVersion.value)
javacOptions ++= javacOptionsVersion(scalaVersion.value)
 二、Chisel的使用方法
  克隆的chisel-template文件夾可以修改成想要的名字。該文件夾下的src文件夾用於存放工程的源代碼。src文件夾下又有main和test兩個文件夾,其中main用於存放Chisel的設計部分,test用於存放對應的測試文件和生成電路的主函數。main文件夾下又有resources和scala兩個文件夾,其中scala文件夾里可以創建自定義的工程文件進行編寫,也可以繼續創建多個文件夾來按模塊存儲不同功能的設計文件,而resources文件夾用於存放與Chisel互動的Verilog等外部文件。test文件下只有一個scala文件夾,在scala文件夾里創建自定義的測試文件和主函數文件。

  在編寫代碼時,可用的編輯器可以選擇自帶的gedit。筆者習慣使用Visual Studio Code,因為可以一邊寫代碼一邊使用集成終端,而且應用商店里可以下載到Chisel語法的擴展應用。

 

 

   也可以使用IDE工具IntelliJ IDEA。首先需要安裝好Scala插件,然后在開始界面選擇導入工程,並把chisel-template指定為工程文件夾,在下一個頁面選擇“Import project from external model”並從列表里選擇Sbt為外部模型,最后確定即可。

三、編寫一個簡單的電路
  在chisel-template/src/main/scala文件夾里創建一個文件,命名為AND.scala,輸入以下內容並保存:

// AND.scala
package test

import chisel3._
import chisel3.experimental._

class AND extends RawModule {
val io = IO(new Bundle {
val a = Input(UInt(1.W))
val b = Input(UInt(1.W))
val c = Output(UInt(1.W))
})

io.c := io.a & io.b
}
  在chisel-template/src/test/scala文件夾里創建一個文件,命名為ANDtest.scala,輸入以下內容並保存:

// ANDtest.scala
package test

import chisel3._

object testMain extends App {
Driver.execute(args, () => new AND)
}
  在chisel-template文件夾下(與文件build.sbt同一路徑)打開終端,執行命令:

esperanto@ubuntu:~/chisel-template$ sbt "test:runMain test.testMain --target-dir generated/and"

  當最后輸出success時,就會在當前路徑生成一個generated文件夾,里面有一個and文件夾。and文件夾里包含了三個最終輸出的文件,打開其中的AND.v文件,可以看到一個與門的Verilog代碼:

module AND(
     input   io_a,
     input   io_b,
     output  io_c
);
     assign io_c = io_a & io_b;
endmodule 

  對於小規模電路,可以直接用Chisel寫testbench文件,然后聯合Verilator生成C++文件來仿真,輸出波形圖。該方法會在后續章節介紹。對於大規模電路,Verilator仿真很吃力,建議還是用生成的Verilog文件在專業EDA工具里仿真。當前Chisel不支持UVM,也沒有工具支持Chisel,所以盡量用別的工具做測試。

四、總結
  本章介紹了Chisel開發環境的搭建,搭建完畢后就可以用Chisel代碼生成電路了。后續章節將逐步講解Chisel的語法,由於內容較為分散,不能很快就完成模塊級的講解,所以前面的內容無法及時運行驗證,讀者只需要理解文中提供的示例即可。

 

第十七章 Chisel基礎——數據類型

一、Chisel的常見問題
   在學習Chisel前,應該熟悉一些常見問題,這些問題在編寫Chisel的任何時候都應該牢記。

  ①Chisel是寄宿在Scala里的語言,所以它本質還是Scala。為了從Chisel轉變成Verilog,語言開發人員開發了一個中間的標准交換格式——Firrtl,它跟Vrilog是同一級別的,兩者都比Chisel低一級。編寫的Chisel代碼首先會經過Firrtl編譯器,生成Firrtl代碼,也就是一個后綴格式為“.fir”的文件,然后由這個Firrtl文件再去生成對應的Verilog代碼。如果讀者有興趣看一看Firrtl的格式,其實與Verilog很接近,只不過是由機器生成的、很死板的代碼。Firrtl編譯器也並不是只針對Chisel,有興趣和能力的讀者也可以開發針對Java、Python、C++等語言的Firrtl編譯器。因為Firrtl只是一種標准的中間媒介,如何從一端到另一端,完全是自定義的。另外,Firrtl也並不僅僅是生成Verilog,同樣可以開發工具生成VHDL、SystemVerilog等語言。

  ②Scala里的語法,在Chisel里也基本能用,比如Scala的基本值類、內建控制結構、函數抽象、柯里化、模式匹配、隱式參數等等。但是讀者要記住這些代碼不僅要通過Scala編譯器的檢查,還需要通過Firrtl編譯器的檢查。

  ③Verilog的最初目的是用於電路驗證,所以它有很多不可綜合的語法。Firrtl在轉變成Verilog時,只會采用可綜合的語法,因此讀者完全不用擔心用Chisel寫出來的電路不可綜合。只要能正確生成Verilog,那就能被綜合器生成電路。

  ④Chisel目前不支持四態邏輯里的x和z,只支持0和1。由於只有芯片對外的IO處才能出現三態門,所以內部設計幾乎用不到x和z。而且x和z在設計中會帶來危害,忽略掉它們也不影響大多數設計,還簡化了模型。當然,如果確實需要,可以通過黑盒語法與外部的Verilog代碼互動,也可以在下游工具鏈里添加四態邏輯。

  ⑤Chisel會對未被驅動的輸出型端口和線網進行檢測,如果存在,會進行報錯。報錯選項可以關閉和打開,取決於讀者對設計模型的需求。推薦把該選項打開,盡量不要殘留無用的聲明。

  ⑥Chisel的代碼包並不會像Scala的標准庫那樣被編譯器隱式導入,所以每個Chisel文件都應該在開頭至少寫一句“import chisel3._”。這個包包含了基本的語法,對於某些高級語法,則可能需要“import chisel3.util._”、“import chisel3.experimental._”、 “import chisel3.testers._”等等。

  ⑦應該用一個名字有意義的包來打包實現某個功能的文件集。例如,要實現一個自定義的微處理器,則可以把頂層包命名為“mycpu”,進而再划分成“myio”、“mymem”、“mybus”、“myalu”等子包,每個子包里包含相關的源文件。

  ⑧Chisel現在仍在更新中,很可能會添加新功能或刪去老功能。因此,本教程介紹的內容在將來並不一定就正確,讀者應該持續關注Chisel3的GitHub的發展動向。

二、Chisel的數據類型
  Chisel定義了自己的一套數據類型,讀者應該跟Scala的九種基本值類區分開來。而且Chisel也能使用Scala的數據類型,但是Scala的數據類型都是用於參數和內建控制結構,構建硬件電路還是得用Chisel自己的數據類型,在使用時千萬不要混淆。當前Chisel定義的數據類型如下圖所示,其中綠色方塊是class,紅色是object,藍色是trait,箭頭指向的是超類和混入的特質: 

 

 

  

  所有數據類型都繼承自抽象基類Data,它混入了兩個特質HasId和NamedComponent。如果讀者查看Chisel3的源代碼,就會看到很多參數傳遞時都用下界表明了是Data的子類。在實際硬件構成里,並不會用到Data,讀者也不用關心它的具體實現細節。更多的,應該關注Data類的兩大子類:聚合類Aggregate和元素類Element。

  聚合類Aggregate的常用子類是向量類Vec[T]和包裹類Bundle。Vec[T]類用於包含相同的元素,元素類型T可以是任意的Data子類。因為Vec[T]混入了特質IndexedSeq[T],所以向量的元素能從下標0開始索引訪問。Bundle類用於被自定義的類繼承,這樣自定義的類就能包含任意Data的子類對象,常用於協助構造模塊的端口,故而衍生出了一些預定義的端口子類。混合向量類MixedVec[T]是Chisel3.2以上版本添加的語法,它與Vec[T]的不同在於可以包含不同類型的元素。

  Element類衍生出了Analog、Bits和Clock三個子類,單例對象DontCare和特質Reset。Analog用於在黑盒中模擬inout端口,目前在實際Chisel里並無其他用途。Bits類的兩個子類SInt和UInt是最常用的兩個數據類型,它們是用補碼表示的有符號整數和無符號整數。不僅用來協助定義端口位寬,還用來進行賦值。FixedPoint類提供的API帶有試驗性質,而且將來可能會發生改變,所以不常用。Bool類是Chisel自己的布爾類型,區別於Scala的Boolean。Bool類是UInt類的子類,因為它可以看成是1bit的UInt,而且它被混入Reset特質,因為復位信號都是用Bool類型的線網或寄存器使能的。此外,Bits類混入了特質ToBoolable,也就是說FixedPoint、SInt和UInt都能轉換成多bit的Bool類型。Clock類表示時鍾,Chisel里的時鍾是專門的一個類型,並不像Verilog里那樣是1bit的線網。復位類型Reset也是如此。單例對象DontCare用於賦值給未驅動的端口或線網,防止編譯器報錯。

三、數據字面量
  能夠表示具體值的數據類型為UInt、SInt和Bool。實際可綜合的電路都是若干個bit,所以只能表示整數,這與Verilog是一致的。要表示浮點數,本質還是用多個bit來構建,而且要遵循IEEE的浮點標准。對於UInt,可以構成任意位寬的線網或寄存器。對於SInt,在Chisel里會按補碼解讀,轉換成Verilog后會使用系統函數$signed,這是可綜合的。對於Bool,轉換成Verilog后就是1bit的線網或寄存器。

  要表示值,則必須有相應的字面量。Chisel定義了一系列隱式類:fromBigIntToLiteral、fromtIntToLiteral、fromtLongToLiteral、fromStringToLiteral、fromBooleanToLiteral。回顧前面講述的隱式類的內容,也就是會有相應的隱式轉換。以隱式類fromtIntToLiteral為例,存在一個同名的隱式轉換,把相應的Scala的Int對象轉換成一個fromtIntToLiteral的對象。而fromtIntToLiteral類有兩個方法U和S,分別構造一個等值的UInt對象和SInt對象。再加上Scala的基本值類都是用字面量構造對象,所以要表示一個UInt對象,可以寫成“1.U”的格式,這樣編譯器會插入隱式轉換,變成“fromtIntToLiteral(1).U”,進而構造出字面值為“1”的UInt對象。同理,也可以構造SInt。還有相同行為的方法asUInt和asSInt。

  從幾個隱式類的名字就可以看出,可以通過BigInt、Int、Long和String四種類型的Scala字面量來構造UInt和SInt。按Scala的語法,其中BigInt、Int、Long三種類型默認是十進制的,但可以加前綴“0x”或“0X”變成十六進制。對於字符串類型的字面量,Chisel編譯器默認也是十進制的,但是可以加上首字母“h”、“o”、“b”來分別表示十六進制、八進制和二進制。此外,字符串字面量可以用下划線間隔。

  可以通過Boolean類型的字面量——true和false——來構造fromBooleanToLiteral類型的對象,然后調用名為B和asBool的方法進一步構造Bool類型的對象。例如:

  1.U                // 字面值為“1”的UInt對象

  -8.S               // 字面值為“-8”的SInt對象

  "b0101".U     // 字面值為“5”的UInt對象

  true.B            // 字面值為“true”的Bool對象 

四、數據寬度
  默認情況下,數據的寬度按字面值取最小,例如字面值為“8”的UInt對象是4位寬,SInt就是5位寬。但是也可以指定寬度。在Chisel2里,寬度是由Int類型的參數表示的,而Chisel3專門設計了寬度類Width。還有一個隱式類fromIntToWidth,就是把Int對象轉換成fromIntToWidth類型的對象,然后通過方法W返回一個Width對象。方法U、asUInt、S和asSInt都有一個重載的版本,接收一個Width類型的參數,構造指定寬度的SInt和UInt對象。注意,Bool類型固定是1位寬。例如:

  1.U              // 字面值為“1”、寬度為1bit的UInt對象

  1.U(32.W)   // 字面值為“1”、寬度為32bit的UInt對象

  UInt、SInt和Bool都不是抽象類,除了可以通過字面量構造對象以外,也可以直接通過apply工廠方法構造沒有字面量的對象。UInt和SInt的apply方法有兩個版本,一個版本接收Width類型的參數構造指定寬度的對象,另一個則是無參版本構造位寬可自動推斷的對象。有字面量的數據類型用於賦值、初始化寄存器等操作,而無字面量的數據類型則用於聲明端口、構造向量等。

 五、類型轉換
  UInt、SInt和Bool三個類都包含四個方法:asUInt、asSInt、toBool和toBools。其中asUInt和asSInt分別把字面值按無符號數和有符號數解釋,並且位寬不會變化,要注意轉換過程中可能發生符號位和數值的變化。例如,3bit的UInt值“b111”,其字面量是“7”,轉換成SInt后字面量就變成了“-1”。toBool會把1bit的“1”轉換成Bool類型的true,“0”轉換成false。如果位寬超過1bit,則用toBools轉換成Bool類型的序列Seq[Bool]。

  另外,Bool類還有一個方法asClock,把true轉換成電壓常高的時鍾,false轉換成電壓常低的時鍾。Clock類只有一個方法asUInt,轉換成對應的0或1。

六、向量
  如果需要一個集合類型的數據,除了可以使用Scala內建的數組、列表、集等數據結構外,還可以使用Chisel專屬的Vec[T]。T必須是Data的子類,而且每個元素的類型、位寬必須一樣。Vec[T]的伴生對象里有一個apply工廠方法,接收兩個參數,第一個是Int類型,表示元素的個數,第二個是元素。它屬於可索引的序列,下標從0開始。例如:

val myVec = Wire(Vec(3, UInt(32.W)))

val myReg = myVec(0)

  還有一個工廠方法VecInit[T],通過接收一個Seq[T]作為參數來構造向量,或者是多個重復參數。不過,這個工廠方法常把有字面值的數據作為參數,用於初始化寄存器組、ROM、RAM等,或者用來構造多個模塊。

  因為Vec[T]也是一種序列,所以它也定義了諸如map、flatMap、zip、foreach、filter、exists、contains等方法。盡管這些方法應該出現在軟件里,但是它們也可以簡化硬件邏輯的編寫,減少手工代碼量。

七、混合向量
  混合向量MixedVec[T]與普通的向量Vec[T]類似,只不過包含的元素可以不全都一樣。它的工廠方法是通過重復參數或者序列作為參數來構造的,並且也有一個叫MixedVecInit[T]的單例對象。

  對於構造Vec[T]和MixedVec[T]的序列,並不一定要逐個手寫,可以通過Scala的函數,比如fill、map、flatMap、to、until等來生成。例如:

val mixVec = Wire(MixedVec((1 to 10) map { i => UInt(i.W) }))

 八、包裹
  抽象類Bundle很像C語言的結構體(struct),用戶可以編寫一個自定義類來繼承自它,然后在自定義的類里包含其它各種Data類型的字段。它可以協助構建線網或寄存器,但是最常見的用途是用於構建一個模塊的端口列表,或者一部分端口。例如:

class MyModule extends Module {
   val io = IO(new Bundle {
       val in = Input(UInt(32.W))
       val out = Output(UInt(32.W))
   })

   Bundle可以和UInt進行相互轉換。Bundle類有一個方法asUInt,可以把所含的字段拼接成一個UInt數據,並且前面的字段在高位。例如:

class MyBundle extends Bundle {
   val foo = UInt(4.W)  // 高位
   val bar = UInt(4.W)  // 低位
}

val bundle = Wire(new MyBundle)
bundle.foo := 0xc.U
bundle.bar := 0x3.U
val uint = bundle.asUInt  // 12*16 + 3 = 195

  有一個隱式類fromBitsable,可以把Data類型的對象轉化成該類型,然后通過方法fromBits來接收一個Bits類型的參數來給該對象賦值。不過,該方法在Chisel3中已經被標注為過時,不推薦使用。例如:

class MyBundle extends Bundle {
   val foo = UInt(4.W)  // 高位
   val bar = UInt(4.W)  // 低位
}

val uint = 0xb4.U
val bundle = Wire(new MyBundle).fromBits(uint)  // foo = 11, bar = 4

九、Chisel的內建操作符
  有了數據類型,還需要預定義一些相關的操作符進行基本的操作。下表是Chisel內建的操作符:

  

 

 

    

  這里要注意的一點是相等性比較的兩個符號是“===”和“=/=”。因為“==”和“!=”已經被Scala占用,所以Chisel另設了這兩個新的操作符。按照優先級的判斷准則,“===”和“=/=”的優先級以首個字符為“=”來判斷,也就是在邏輯操作中,相等性比較的優先級要比與、或、異或都高。

十、位寬推斷
  某些操作符會發生位寬的改變,這些返回的結果會生成一個自動推斷的位寬。如下表所示:

  

  當把一個短位寬的信號值或硬件結構賦值給長位寬的硬件結構時,會自動擴展符號位。但是反過來會報錯,並不是像Verilog那樣把多余的高位截斷,這需要注意(注:最新的chisel3版本已經可以像Verilog一樣自動把高位截斷了)。

十一、總結
  讀者在學習本章后,應該理清Chisel數據類型的關系。常用的類型就五種:UInt、SInt、Bool、Bundle和Vec[T],所以重點學會這五種即可。有關三種值類UInt、SInt和Bool的操作符與Verilog差不多,很快就能理解。

第十八章 Chisel基礎——模塊與硬件類型

  Chisel在構建硬件的思路上類似Verilog。在Verilog中,是以“模塊(module)”為基本單位組成一個完整的獨立功能實體,所以Chisel也是按模塊划分的,只不過不是用關鍵字“module”開頭來定義模塊,而是用一個繼承自Module類的自定義class。

  在Verilog里,模塊內部主要有“線網(wire)”和“四態變量(reg)”兩種硬件類型,它們用於描述數字電路的組合邏輯和時序邏輯。在Chisel里,也按這個思路定義了一些硬件類型,包括基本的線網和寄存器,以及一些常用的其它類型。前一章介紹了Chisel的數據類型,這還不夠,因為這些數據類型是無法獨立工作的。實際的電路應該是由硬件類型的對象構成的,不管是信號的聲明,還是用賦值進行信號傳遞,都是由硬件類型的對象來完成的。數據類型和硬件類型融合在一起,才能構成完整、可運行的組件。比如要聲明一個線網,這部分工作由硬件類型來完成;這個線網的位寬是多少、按無符號數還是有符號數解釋、是不是向量等等,這些則是由作為參數的數據類型對象來定義的。

  本章將介紹Chisel里的常用硬件類型以及如何編寫一個基本的模塊,對於高級類型,讀者可以自行研究。這些類型的語法很簡單,都是由定義在單例對象里的apply工廠方法來完成。字面的名字已經把硬件含義表明得很清楚,至於它們的具體實現是什么,讀者可以不用關心。

一、Chisel是如何賦值的
  有了硬件類型后,就可以用賦值操作來進行信號的傳遞或者電路的連接。只有硬件賦值才有意義,單純的數據對象進行賦值並不會被編譯器轉換成實際的電路,因為在Verilog里也是對wire、reg類型的硬件進行賦值。那么,賦值操作需要什么樣的操作符來完成呢?

  在Chisel里,所有對象都應該由val類型的變量來引用,因為硬件電路的不可變性。因此,一個變量一旦初始化時綁定了一個對象,就不能再發生更改。但是,引用的對象很可能需要被重新賦值。例如,輸出端口在定義時使用了“=”與端口變量名進行了綁定,那等到驅動該端口時,就需要通過變量名來進行賦值操作,更新數據。很顯然,此時“=”已經不可用了,因為變量在聲明的時候不是var類型。即使是var類型,這也只是讓變量引用新的對象,而不是直接更新原來的可變對象。

  為了解決這個問題,幾乎所有的Chisel類都定義了方法“:=”,作為等號賦值的代替。所以首次創建變量時用等號初始化,如果變量引用的對象不能立即確定狀態或本身就是可變對象,則在后續更新狀態時應該用“:=”。從前面講的操作符優先級來判斷,該操作符以等號結尾,而且不是四種邏輯比較符號之一,所以優先級與等號一致,是最低的。例如:

val x = Wire(UInt(4.W))

val y = Wire(UInt(4.W))

x := "b1010".U  // 向4bit的線網x賦予了無符號數10

y := ~x  // 把x按位取反,傳遞給y

二、端口
   Ⅰ、定義端口列表
  定義一個模塊前一定要先定義好端口。整個端口列表是由方法“IO[T <: Data](iodef: T)”來定義的,通常其參數是一個Bundle類型的對象,而且引用的字段名稱必須是“io”。因為端口存在方向,所以還需要方法“Input[T <: Data](source: T)”和“Output[T <: Data](source: T)”來為每個端口表明具體的方向。注意,“Input[T <: Data](source: T)”和“Output[T <: Data](source: T)”僅僅是復制它們的參數,所以不能是已經被硬件類型包裹的數據類型。目前Chisel還不支持雙向端口inout,只能通過黑盒里的Analog端口來模擬外部Verilog的雙向端口。

  一旦端口列表定義完成,就可以通過“io.xxx”來使用。輸入可以驅動內部其它信號,輸出可以被其他信號驅動。可以直接進行賦值操作,布爾類型的端口還能直接作為使能信號。端口不需要再使用其它硬件類型來定義,不過要注意從性質上來說它仍然屬於組合邏輯的線網。例如:

class MyIO extends Bundle {
   val in = Input(Vec(5, UInt(32.W)))
   val out = Output(UInt(32.W))
}

......
   val io = IO(new MyIO)  // 模塊的端口列表
......

   Ⅱ、翻轉端口列表的方向
  對於兩個相連的模塊,可能存在大量同名但方向相反的端口。僅僅為了翻轉方向而不得不重寫一遍端口顯得費時費力,所以Chisel提供了“Flipped[T <: Data](source: T)”方法,可以把參數里所有的輸入轉輸出,輸出轉輸入。如果是黑盒里的Analog端口,則仍是雙向的。例如:

 class MyIO extends Bundle {
   val in = Input(Vec(5, UInt(32.W)))
   val out = Output(UInt(32.W))
}

......
   val io = IO(new MyIO)  // in是輸入,out是輸出
......
   val io = IO(Flipped(new MyIO))  // out是輸入,in是輸出

   Ⅲ、整體連接
  翻轉方向的端口列表通常配合整體連接符號“<>”使用。該操作符會把左右兩邊的端口列表里所有同名的端口進行連接,而且同一級的端口方向必須是輸入連輸出、輸出連輸入,父級和子級的端口方向則是輸入連輸入、輸出連輸出。注意,方向必須按這個規則匹配,而且不能存在端口名字、數量、類型不同的情況。這樣就省去了大量連線的代碼。例如:

class MyIO extends Bundle {
   val in = Input(Vec(5, UInt(32.W)))
   val out = Output(UInt(32.W))
}

......
   val io = IO(new Bundle {
       val x = new MyIO       
       val y = Flipped(new MyIO)
   })

   io.x <> io.y  // 相當於 io.y.in := io.x.in; io.x.out := io.y.out 
......

三、模塊
   Ⅰ、定義模塊
  在Chisel里面是用一個自定義的類來定義模塊的,這個類有以下三個特點:①繼承自Module類。②有一個抽象字段“io”需要實現,該字段必須引用前面所說的端口對象。③在類的主構造器里進行內部電路連線。因為非字段、非方法的內容都屬於主構造方法,所以用操作符“:=”進行的賦值、用“<>”進行的連線或一些控制結構等等,都屬於主構造方法。從Scala的層面來講,這些代碼在實例化時表示如何構造一個對象;從Chisel的層面來講,它們就是在聲明如何進行模塊內部子電路的連接、信號的傳遞,類似於Verilog的assign和always語句。實際上這些用賦值表示的電路連接在轉換成Verilog時,組合邏輯就是大量的assign語句,時序邏輯就是always語句。

  還有一點需要注意,這樣定義的模塊會繼承一個字段“clock”,類型是Clock,它表示全局時鍾,在整個模塊內都可見。對於組合邏輯,是用不上它的,而時序邏輯雖然需要這個時鍾,但也不用顯式聲明。還有一個繼承的字段“reset”,類型是Reset,表示全局復位信號,在整個模塊內可見。對於需要復位的時序元件,也可以不用顯式使用該字段。如果確實需要用到全局時鍾和復位,則可以通過它們的字段名稱來使用,但要注意類型是否匹配,經常需要“reset.toBool”這樣的語句把Reset類型轉換成Bool類型用於控制。隱式的全局時鍾和復位端口只有在生成Verilog代碼時才能看到。

  要編寫一個雙輸入多路選擇器,其代碼如下所示:

// mux2.scala
package test

import chisel3._

class Mux2 extends Module {
  val io = IO(new Bundle{
    val sel = Input(UInt(1.W))
    val in0 = Input(UInt(1.W))
    val in1 = Input(UInt(1.W))
    val out = Output(UInt(1.W))
  })

  io.out := (io.sel & io.in1) | (~io.sel & io.in0)
}
  在這里,“new Bundle { ... }”的寫法是聲明一個匿名類繼承自Bundle,然后實例化匿名類。對於短小、簡單的端口列表,可以使用這種簡便寫法。對於大的公用接口,應該單獨寫成具名的Bundle子類,方便修改。“io.out := ...”其實就是主構造方法的一部分,通過內建操作符和三個輸入端口,實現了輸出端口的邏輯行為。

   Ⅱ、例化模塊
  要例化一個模塊,並不是直接用new生成一個實例對象就完成了,還需要再把實例的對象傳遞給單例對象Module的apply方法。這種別扭的語法是Scala的語法限制造成的,就像端口需要寫成“IO(new Bundle {...})”,無符號數要寫成“UInt(n.W)”等等一樣。例如,下面的代碼通過例化剛才的雙輸入多路選擇器構建四輸入多路選擇器:

// mux4.scala
package test

import chisel3._

class Mux4 extends Module {
  val io = IO(new Bundle {
    val in0 = Input(UInt(1.W))
    val in1 = Input(UInt(1.W))
    val in2 = Input(UInt(1.W))
    val in3 = Input(UInt(1.W))
    val sel = Input(UInt(2.W))
    val out = Output(UInt(1.W))
  })

  val m0 = Module(new Mux2)
  m0.io.sel := io.sel(0)
  m0.io.in0 := io.in0
  m0.io.in1 := io.in1

  val m1 = Module(new Mux2)
  m1.io.sel := io.sel(0)
  m1.io.in0 := io.in2
  m1.io.in1 := io.in3

  val m2 = Module(new Mux2)
  m2.io.sel := io.sel(1)
  m2.io.in0 := m0.io.out
  m2.io.in1 := m1.io.out

  io.out := m2.io.out
}
   Ⅲ、例化多個模塊
  像上個例子中,模塊Mux2例化了三次,實際只需要一次性例化三個模塊就可以了。對於要多次例化的重復模塊,可以利用向量的工廠方法VecInit[T <: Data]。因為該方法接收的參數類型是Data的子類,而模塊的字段io正好是Bundle類型,並且實際的電路連線僅僅只需針對模塊的端口,所以可以把待例化模塊的io字段組成一個序列,或者按重復參數的方式作為參數傳遞。通常使用序列作為參數,這樣更節省代碼。生成序列的一種方法是調用單例對象Seq里的方法fill,該方法的一個重載版本有兩個單參數列表,第一個接收Int類型的對象,表示序列的元素個數,第二個是傳名參數,接收序列的元素。

  因為Vec是一種可索引的序列,所以這種方式例化的多個模塊類似於“模塊數組”,用下標索引第n個模塊。另外,因為Vec的元素已經是模塊的端口字段io,所以要引用例化模塊的某個具體端口時,路徑里不用再出現“io”。例如:

// mux4_2.scala
package test

import chisel3._

class Mux4_2 extends Module {
  val io = IO(new Bundle {
    val in0 = Input(UInt(1.W))
    val in1 = Input(UInt(1.W))
    val in2 = Input(UInt(1.W))
    val in3 = Input(UInt(1.W))
    val sel = Input(UInt(2.W))
    val out = Output(UInt(1.W))
  })

  val m = VecInit(Seq.fill(3)(Module(new Mux2).io))  // 例化了三個Mux2,並且參數是端口字段io
  m(0).sel := io.sel(0)  // 模塊的端口通過下標索引,並且路徑里沒有“io”
  m(0).in0 := io.in0
  m(0).in1 := io.in1

  m(1).sel := io.sel(0)
  m(1).in0 := io.in2
  m(1).in1 := io.in3

  m(2).sel := io.sel(1)
  m(2).in0 := m(0).out
  m(2).in1 := m(1).out

  io.out := m(2).out
}
四、線網
  Chisel把線網作為電路的節點,通過工廠方法“Wire[T <: Data](t: T)”來定義。可以對線網進行賦值,也可以連接到其他電路節點,這是組成組合邏輯的基本硬件類型。例如:

val myNode = Wire(UInt(8.W))

myNode := 0.U 

  因為Scala作為軟件語言是順序執行的,定義具有覆蓋性,所以如果對同一個線網多次賦值,則只有最后一次有效。例如下面的代碼與上面的例子是等效的: 

val myNode = Wire(UInt(8.W))

myNode := 10.U

myNode := 0.U

五、寄存器
  寄存器是時序邏輯的基本硬件類型,它們都是由當前時鍾域的時鍾上升沿觸發的。如果模塊里沒有多時鍾域的語句塊,那么寄存器都是由隱式的全局時鍾來控制。對於有復位信號的寄存器,如果不在多時鍾域語句塊里,則由隱式的全局復位來控制,並且高有效。目前Chisel所有的復位都是同步復位,異步復位功能還在開發中。如果需要異步復位寄存器,則需要通過黑盒引入。

  有五種內建的寄存器,第一種是跟隨寄存器“RegNext[T <: Data](next: T)”,在每個時鍾上升沿,它都會采樣一次傳入的參數,並且沒有復位信號。它的另一個版本的apply工廠方法是“RegNext[T <: Data](next: T, init: T)”,也就是由復位信號控制,當復位信號有效時,復位到指定值,否則就跟隨。

  第二種是復位到指定值的寄存器“RegInit[T <: Data](init: T)”,參數需要聲明位寬,否則就是默認位寬。可以用內建的when語句進行條件賦值。

  第三種是普通的寄存器“Reg[T <: Data](t: T)”,它可以在when語句里用全局reset信號進行同步復位(reset信號是Reset類型,要用toBool進行類型轉換),也可以進行條件賦值或無條件跟隨。參數同樣要指定位寬。

  第四種是util包里的帶一個使能端的寄存器“RegEnable[T <: Data](next: T, init: T, enable: Bool)”,如果不需要復位信號,則第二個參數可以省略給出。

  第五種是util包里的移位寄存器“ShiftRegister[T <: Data](in: T, n: Int, resetData: T, en: Bool)”,其中第一個參數in是帶移位的數據,第二個參數n是需要延遲的周期數,第三個參數resetData是指定的復位值,可以省略,第四個參數en是使能移位的信號,默認為true.B。

  假如有如下代碼:

// reg.scala
package test

import chisel3._
import chisel3.util._

class REG extends Module {
  val io = IO(new Bundle {
    val a = Input(UInt(8.W))
    val en = Input(Bool())
    val c = Output(UInt(1.W))
  })

  val reg0 = RegNext(io.a)
  val reg1 = RegNext(io.a, 0.U)
  val reg2 = RegInit(0.U(8.W))
  val reg3 = Reg(UInt(8.W))
  val reg4 = Reg(UInt(8.W))
  val reg5 = RegEnable(io.a + 1.U, 0.U, io.en)
  val reg6 = RegEnable(io.a - 1.U, io.en)
  val reg7 = ShiftRegister(io.a, 3, 0.U, io.en)
  val reg8 = ShiftRegister(io.a, 3, io.en)
  
  reg2 := io.a.andR
  reg3 := io.a.orR

  when(reset.toBool) {
    reg4 := 0.U
  } .otherwise {
    reg4 := 1.U
  }

  io.c := reg0(0) & reg1(0) & reg2(0) & reg3(0) & reg4(0) & reg5(0) & reg6(0) & reg7(0) & reg8(0)

  對應生成的主要Verilog代碼為:

// REG.v
module REG(
  input        clock,
  input        reset,
  input  [7:0] io_a,
  input        io_en,
  output       io_c
);
  reg [7:0] reg0; 
  reg [7:0] reg1; 
  reg [7:0] reg2; 
  reg [7:0] reg3; 
  reg [7:0] reg4; 
  wire [7:0] _T_1; 
  reg [7:0] reg5; 
  wire [8:0] _T_2; 
  wire [8:0] _T_3; 
  wire [7:0] _T_4; 
  reg [7:0] reg6; 
  reg [7:0] _T_5; 
  reg [7:0] _T_6; 
  reg [7:0] reg7; 
  reg [7:0] _T_7; 
  reg [7:0] _T_8; 
  reg [7:0] reg8; 
  wire [7:0] _T_9; 
  wire  _T_10; 
  wire  _T_11; 
  wire  _GEN_8; 
  wire  _T_13; 
  wire  _T_14; 
  wire  _T_15; 
  wire  _T_16; 
  wire  _T_17; 
  wire  _T_18; 
  wire  _T_19; 
  wire  _T_20; 
  wire  _T_21; 
  wire  _T_22; 
  wire  _T_23; 
  wire  _T_24; 
  wire  _T_25; 
  wire  _T_26; 
  wire  _T_27; 
  wire  _T_28; 
  assign _T_1 = io_a + 8'h1; 
  assign _T_2 = io_a - 8'h1; 
  assign _T_3 = $unsigned(_T_2); 
  assign _T_4 = _T_3[7:0]; 
  assign _T_9 = ~ io_a; 
  assign _T_10 = _T_9 == 8'h0; 
  assign _T_11 = io_a != 8'h0; 
  assign _GEN_8 = reset ? 1'h0 : 1'h1; 
  assign _T_13 = reg0[0]; 
  assign _T_14 = reg1[0]; 
  assign _T_15 = _T_13 & _T_14; 
  assign _T_16 = reg2[0]; 
  assign _T_17 = _T_15 & _T_16; 
  assign _T_18 = reg3[0]; 
  assign _T_19 = _T_17 & _T_18; 
  assign _T_20 = reg4[0]; 
  assign _T_21 = _T_19 & _T_20; 
  assign _T_22 = reg5[0]; 
  assign _T_23 = _T_21 & _T_22; 
  assign _T_24 = reg6[0]; 
  assign _T_25 = _T_23 & _T_24; 
  assign _T_26 = reg7[0]; 
  assign _T_27 = _T_25 & _T_26; 
  assign _T_28 = reg8[0]; 
  assign io_c = _T_27 & _T_28; 

  always @(posedge clock) begin
    reg0 <= io_a;
    if (reset) begin
      reg1 <= 8'h0;
    end else begin
      reg1 <= io_a;
    end
    if (reset) begin
      reg2 <= 8'h0;
    end else begin
      reg2 <= {{7'd0}, _T_10};
    end
    reg3 <= {{7'd0}, _T_11};
    reg4 <= {{7'd0}, _GEN_8};
    if (reset) begin
      reg5 <= 8'h0;
    end else begin
      if (io_en) begin
        reg5 <= _T_1;
      end
    end
    if (io_en) begin
      reg6 <= _T_4;
    end
    if (reset) begin
      _T_5 <= 8'h0;
    end else begin
      if (io_en) begin
        _T_5 <= io_a;
      end
    end
    if (reset) begin
      _T_6 <= 8'h0;
    end else begin
      if (io_en) begin
        _T_6 <= _T_5;
      end
    end
    if (reset) begin
      reg7 <= 8'h0;
    end else begin
      if (io_en) begin
        reg7 <= _T_6;
      end
    end
    if (io_en) begin
      _T_7 <= io_a;
    end
    if (io_en) begin
      _T_8 <= _T_7;
    end
    if (io_en) begin
      reg8 <= _T_8;
    end
  end
endmodule
六、寄存器組
  上述構造寄存器的工廠方法,它們的參數可以是任何Data的子類型。如果把子類型Vec[T]作為參數傳遞進去,就會生成多個位寬相同、行為相同、名字前綴相同的寄存器。同樣,寄存器組在Chisel代碼里可以通過下標索引。例如:

// reg2.scala
package test

import chisel3._
import chisel3.util._

class REG2 extends Module {
  val io = IO(new Bundle {
    val a = Input(UInt(8.W))
    val en = Input(Bool())
    val c = Output(UInt(1.W))
  })

  val reg0 = RegNext(VecInit(io.a, io.a))
  val reg1 = RegNext(VecInit(io.a, io.a), VecInit(0.U, 0.U))
  val reg2 = RegInit(VecInit(0.U(8.W), 0.U(8.W)))
  val reg3 = Reg(Vec(2, UInt(8.W)))
  val reg4 = Reg(Vec(2, UInt(8.W)))
  val reg5 = RegEnable(VecInit(io.a + 1.U, io.a + 1.U), VecInit(0.U(8.W), 0.U(8.W)), io.en)
  val reg6 = RegEnable(VecInit(io.a - 1.U, io.a - 1.U), io.en)
  val reg7 = ShiftRegister(VecInit(io.a, io.a), 3, VecInit(0.U(8.W), 0.U(8.W)), io.en)
  val reg8 = ShiftRegister(VecInit(io.a, io.a), 3, io.en)
  
  reg2(0) := io.a.andR
  reg2(1) := io.a.andR
  reg3(0) := io.a.orR
  reg3(1) := io.a.orR

  when(reset.toBool) {
    reg4(0) := 0.U
    reg4(1) := 0.U
  } .otherwise {
    reg4(0) := 1.U
    reg4(1) := 1.U
  }

  io.c := reg0(0)(0) & reg1(0)(0) & reg2(0)(0) & reg3(0)(0) & reg4(0)(0) & reg5(0)(0) & reg6(0)(0) & reg7(0)(0) & reg8(0)(0) &
          reg0(1)(0) & reg1(1)(0) & reg2(1)(0) & reg3(1)(0) & reg4(1)(0) & reg5(1)(0) & reg6(1)(0) & reg7(1)(0) & reg8(1)(0)
}
  對應的主要Verilog代碼為:

// REG2.v
module REG2(
  input        clock,
  input        reset,
  input  [7:0] io_a,
  input        io_en,
  output       io_c
);
  reg [7:0] reg0_0; 
  reg [7:0] reg0_1; 
  reg [7:0] reg1_0; 
  reg [7:0] reg1_1; 
  reg [7:0] reg2_0; 
  reg [7:0] reg2_1; 
  reg [7:0] reg3_0; 
  reg [7:0] reg3_1; 
  reg [7:0] reg4_0; 
  reg [7:0] reg4_1; 
  wire [7:0] _T_5; 
  reg [7:0] reg5_0;   
  reg [7:0] reg5_1; 
  wire [8:0] _T_10; 
  wire [8:0] _T_11; 
  wire [7:0] _T_12; 
  reg [7:0] reg6_0; 
  reg [7:0] reg6_1; 
  reg [7:0] _T_19_0; 
  reg [7:0] _T_19_1; 
  reg [7:0] _T_20_0; 
  reg [7:0] _T_20_1; 
  reg [7:0] reg7_0; 
  reg [7:0] reg7_1; 
  reg [7:0] _T_22_0; 
  reg [7:0] _T_22_1; 
  reg [7:0] _T_23_0; 
  reg [7:0] _T_23_1; 
  reg [7:0] reg8_0; 
  reg [7:0] reg8_1; 
  wire [7:0] _T_24; 
  wire  _T_25; 
  wire  _T_28; 
  wire  _GEN_16; 
  wire  _T_31; 
  wire  _T_32; 
  wire  _T_33; 
  wire  _T_34; 
  wire  _T_35; 
  wire  _T_36; 
  wire  _T_37; 
  wire  _T_38; 
  wire  _T_39; 
  wire  _T_40; 
  wire  _T_41; 
  wire  _T_42; 
  wire  _T_43; 
  wire  _T_44; 
  wire  _T_45; 
  wire  _T_46; 
  wire  _T_47; 
  wire  _T_48; 
  wire  _T_49; 
  wire  _T_50; 
  wire  _T_51; 
  wire  _T_52; 
  wire  _T_53; 
  wire  _T_54; 
  wire  _T_55; 
  wire  _T_56; 
  wire  _T_57; 
  wire  _T_58; 
  wire  _T_59; 
  wire  _T_60; 
  wire  _T_61; 
  wire  _T_62; 
  wire  _T_63; 
  wire  _T_64; 
  assign _T_5 = io_a + 8'h1; 
  assign _T_10 = io_a - 8'h1; 
  assign _T_11 = $unsigned(_T_10); 
  assign _T_12 = _T_11[7:0]; 
  assign _T_24 = ~ io_a; 
  assign _T_25 = _T_24 == 8'h0; 
  assign _T_28 = io_a != 8'h0; 
  assign _GEN_16 = reset ? 1'h0 : 1'h1; 
  assign _T_31 = reg0_0[0]; 
  assign _T_32 = reg1_0[0]; 
  assign _T_33 = _T_31 & _T_32; 
  assign _T_34 = reg2_0[0]; 
  assign _T_35 = _T_33 & _T_34; 
  assign _T_36 = reg3_0[0]; 
  assign _T_37 = _T_35 & _T_36; 
  assign _T_38 = reg4_0[0]; 
  assign _T_39 = _T_37 & _T_38; 
  assign _T_40 = reg5_0[0]; 
  assign _T_41 = _T_39 & _T_40; 
  assign _T_42 = reg6_0[0]; 
  assign _T_43 = _T_41 & _T_42; 
  assign _T_44 = reg7_0[0]; 
  assign _T_45 = _T_43 & _T_44; 
  assign _T_46 = reg8_0[0]; 
  assign _T_47 = _T_45 & _T_46; 
  assign _T_48 = reg0_1[0]; 
  assign _T_49 = _T_47 & _T_48; 
  assign _T_50 = reg1_1[0]; 
  assign _T_51 = _T_49 & _T_50; 
  assign _T_52 = reg2_1[0]; 
  assign _T_53 = _T_51 & _T_52; 
  assign _T_54 = reg3_1[0]; 
  assign _T_55 = _T_53 & _T_54; 
  assign _T_56 = reg4_1[0]; 
  assign _T_57 = _T_55 & _T_56; 
  assign _T_58 = reg5_1[0]; 
  assign _T_59 = _T_57 & _T_58; 
  assign _T_60 = reg6_1[0]; 
  assign _T_61 = _T_59 & _T_60; 
  assign _T_62 = reg7_1[0]; 
  assign _T_63 = _T_61 & _T_62; 
  assign _T_64 = reg8_1[0]; 
  assign io_c = _T_63 & _T_64; 
  
  always @(posedge clock) begin
    reg0_0 <= io_a;
    reg0_1 <= io_a;
    if (reset) begin
      reg1_0 <= 8'h0;
    end else begin
      reg1_0 <= io_a;
    end
    if (reset) begin
      reg1_1 <= 8'h0;
    end else begin
      reg1_1 <= io_a;
    end
    if (reset) begin
      reg2_0 <= 8'h0;
    end else begin
      reg2_0 <= {{7'd0}, _T_25};
    end
    if (reset) begin
      reg2_1 <= 8'h0;
    end else begin
      reg2_1 <= {{7'd0}, _T_25};
    end
    reg3_0 <= {{7'd0}, _T_28};
    reg3_1 <= {{7'd0}, _T_28};
    reg4_0 <= {{7'd0}, _GEN_16};
    reg4_1 <= {{7'd0}, _GEN_16};
    if (reset) begin
      reg5_0 <= 8'h0;
    end else begin
      if (io_en) begin
        reg5_0 <= _T_5;
      end
    end
    if (reset) begin
      reg5_1 <= 8'h0;
    end else begin
      if (io_en) begin
        reg5_1 <= _T_5;
      end
    end
    if (io_en) begin
      reg6_0 <= _T_12;
    end
    if (io_en) begin
      reg6_1 <= _T_12;
    end
    if (reset) begin
      _T_19_0 <= 8'h0;
    end else begin
      if (io_en) begin
        _T_19_0 <= io_a;
      end
    end
    if (reset) begin
      _T_19_1 <= 8'h0;
    end else begin
      if (io_en) begin
        _T_19_1 <= io_a;
      end
    end
    if (reset) begin
      _T_20_0 <= 8'h0;
    end else begin
      if (io_en) begin
        _T_20_0 <= _T_19_0;
      end
    end
    if (reset) begin
      _T_20_1 <= 8'h0;
    end else begin
      if (io_en) begin
        _T_20_1 <= _T_19_1;
      end
    end
    if (reset) begin
      reg7_0 <= 8'h0;
    end else begin
      if (io_en) begin
        reg7_0 <= _T_20_0;
      end
    end
    if (reset) begin
      reg7_1 <= 8'h0;
    end else begin
      if (io_en) begin
        reg7_1 <= _T_20_1;
      end
    end
    if (io_en) begin
      _T_22_0 <= io_a;
    end
    if (io_en) begin
      _T_22_1 <= io_a;
    end
    if (io_en) begin
      _T_23_0 <= _T_22_0;
    end
    if (io_en) begin
      _T_23_1 <= _T_22_1;
    end
    if (io_en) begin
      reg8_0 <= _T_23_0;
    end
    if (io_en) begin
      reg8_1 <= _T_23_1;
    end
  end
endmodule
七、用when給電路賦值
  在Verilog里,可以使用“if...else if...else”這樣的條件選擇語句來方便地構建電路的邏輯。由於Scala已經占用了“if…else if…else”語法,所以相應的Chisel控制結構改成了when語句,其語法如下:

when (condition 1) { definition 1 }
.elsewhen (condition 2) { definition 2 }
...
.elsewhen (condition N) { definition N }
.otherwise { default behavior }

  注意,“.elsewhen”和“.otherwise”的開頭有兩個句點。所有的判斷條件都是返回Bool類型的傳名參數,不要和Scala的Boolean類型混淆,也不存在Boolean和Bool之間的相互轉換。對於UInt、SInt和Reset類型,可以用方法toBool轉換成Bool類型來作為判斷條件。

  when語句不僅可以給線網賦值,還可以給寄存器賦值,但是要注意構建組合邏輯時不能缺失“.otherwise”分支。通常,when用於給帶使能信號的寄存器更新數據,組合邏輯不常用。對於有復位信號的寄存器,推薦使用RegInit來聲明,這樣生成的Verilog會自動根據當前的時鍾域來同步復位,盡量不要在when語句里用“reset.toBool”作為復位條件。

  除了when結構,util包里還有一個與之對偶的結構“unless”,如果unless的判定條件為false.B則一直執行,否則不執行:

import chisel3.util._

unless (condition) { definition }

八、總結:數據類型與硬件類型的區別
  前一章介紹了Chisel的數據類型,其中常用的就五種:UInt、SInt、Bool、Bundle和Vec[T]。本章介紹了硬件類型,最基本的是IO、Wire和Reg三種,還有指明端口方向的Input、Output和Flipped。Module是沿襲了Verilog用模塊構建電路的規則,不僅讓熟悉Verilog/VHDL的工程師方便理解,也便於從Chisel轉化成Verilog代碼。

  數據類型必須配合硬件類型才能使用,它不能獨立存在,因為編譯器只會把硬件類型生成對應的Verilog代碼。從語法規則上來講,這兩種類型也有很大的區別,編譯器會對數據類型和硬件類型加以區分。盡管從Scala的角度來看,硬件類型對應的工廠方法僅僅是“封裝”了一遍作為入參的數據類型,其返回結果沒變,比如Wire的工廠方法定義為:

def apply[T <: Data](t: T)(implicit sourceInfo: SourceInfo, compileOptions: CompileOptions): T

  可以看到,入參t的類型與返回結果的類型是一樣的,但是還有配置編譯器的隱式參數,很可能區別就源自這里。

  但是從Chisel編譯器的角度來看,這兩者就是不一樣。換句話說,硬件類型就好像在數據類型上“包裹了一層外衣(英文原文用單詞binding來形容)”。比如,線網“Wire(UInt(8.W))”就像給數據類型“UInt(8.W)”包上了一個“Wire( )”。所以,在編寫Chisel時,要注意哪些地方是數據類型,哪些地方又是硬件類型。這時,靜態語言的優勢便體現出來了,因為編譯器會幫助程序員檢查類型是否匹配。如果在需要數據類型的地方出現了硬件類型、在需要硬件類型的地方出現了數據類型,那么就會引發錯誤。程序員只需要按照錯誤信息去修改相應的代碼,而不需要人工逐個檢查。

  例如,在前面介紹寄存器組的時候,示例代碼里的一句是這樣的:

val reg0 = RegNext(VecInit(io.a, io.a)) 

  讀者可能會好奇為什么不寫成如下形式:

val reg0 = RegNext(Vec(2, io.a)) 

  如果改成這樣,那么編譯器就會發出如下錯誤:

[error] chisel3.core.Binding$ExpectedChiselTypeException: vec type 'chisel3.core.UInt@6147b2fd' must be a Chisel type, not hardware 

  這是因為方法Vec期望第二個參數是數據類型,這樣它才能推斷出返回的Vec[T]是數據類型。但實際的“io.a”是經過Input封裝過的硬件類型,導致Vec[T]變成了硬件類型,所以發生了類型匹配錯誤。錯誤信息里也明確指示了,“Chisel type”指的就是數據類型,“hardware”指的就是硬件類型,而vec的類型應該是“Chisel type”,不應該變成硬件。

  Chisel提供了一個用戶API——chiselTypeOf[T <: Data](target: T): T,其作用就是把硬件類型的“封皮”去掉,變成純粹的數據類型。因此,讀者可能會期望如下代碼成功:

val reg0 = RegNext(Vec(2, chiselTypeOf(io.a)))  

  但是編譯器仍然發出了錯誤信息:

[error] chisel3.core.Binding$ExpectedHardwareException: reg next 'Vec(chisel3.core.UInt@65b0972a, chisel3.core.UInt@25155aa4)' must be hardware, not a bare Chisel type. Perhaps you forgot to wrap it in Wire(_) or IO(_)? 

  只不過,這次是RegNext出錯了。chiselTypeOf確實把硬件類型變成了數據類型,所以Vec[T]的檢查通過了。但RegNext是實打實的硬件——寄存器,它也需要根據入參來推斷返回結果的類型,所以傳入一個數據類型Vec[T]就引發了錯誤。錯誤信息還額外提示程序員,是否忘記了用Wire(_)或IO(_)來包裹裸露的數據類型。甚至是帶有字面量的數據類型,比如“0.U(8.W)”這樣的對象,也被當作是硬件類型。

  綜合考慮這兩種錯誤,只有寫成“val reg0 = RegNext(VecInit(io.a, io.a))”合適,因為VecInit專門接收硬件類型的參數來構造硬件向量,給VecInit傳入數據類型反而會報錯,盡管它的返回類型也是Vec[T]。另外,Reg(_)的參數是數據類型,不是硬件類型,所以示例代碼中它的參數是Vec,而別的參數都是VecInit。

有了基本的數據類型和硬件類型后,就已經可以編寫絕大多數組合邏輯與時序邏輯電路。下一章將介紹Chisel庫里定義的常用原語,有了這些原語就能更快速地構建電路,而不需要只用這些基本類型來搭積木。

第十九章 Chisel基礎——常用的硬件原語

  前兩章介紹了基本的數據類型和硬件類型,已經足夠編寫基本的小規模電路。至於要如何生成Verilog,會在后續章節講解。如果要編寫大型電路,當然也可以一磚一瓦地搭建,但是費時費力,完全體現不出軟件語言的優勢。Chisel在語言庫里定義了很多常用的硬件原語,讀者可以直接導入相應的包來使用。讓編譯器多干活,讓程序員少費力。

一、多路選擇器
  因為多路選擇器是一個很常用的電路模塊,所以Chisel內建了幾種多路選擇器。第一種形式是二輸入多路選擇器“Mux(sel, in1, in2)”。sel是Bool類型,in1和in2的類型相同,都是Data的任意子類型。當sel為true.B時,返回in1,否則返回in2。

  因為Mux僅僅是把一個輸入返回,所以Mux可以內嵌Mux,構成n輸入多路選擇器,類似於嵌套的三元操作符。其形式為“Mux(c1, a, Mux(c2, b, Mux(..., default)))”。第二種就是針對上述n輸入多路選擇器的簡便寫法,形式為“MuxCase(default, Array(c1 -> a, c2 -> b, ...))”,它的展開與嵌套的Mux是一樣的。第一個參數是默認情況下返回的結果,第二個參數是一個數組,數組的元素是對偶“(成立條件,被選擇的輸入)”。MuxCase在chisel3.util包里。

  第三種是MuxCase的變體,它相當於把MuxCase的成立條件依次換成從0開始的索引值,就好像一個查找表,其形式為“MuxLookup(idx, default, Array(0.U -> a, 1.U -> b, ...))”。它的展開相當於“MuxCase(default, Array((idx === 0.U) -> a, (idx === 1.U) -> b, ...))”。MuxLookup也在chisel3.util包里。

  第四種是chisel3.util包里的獨熱碼多路選擇器,它的選擇信號是一個獨熱碼。如果零個或多個選擇信號有效,則行為未定義。其形式如下:

val hotValue = Mux1H(Seq(
    io.selector(0) -> 2.U,
    io.selector(1) -> 4.U,
    io.selector(2) -> 8.U,
    io.selector(4) -> 11.U
))

  內建的多路選擇器會轉換成Verilog的三元操作符“? :”,這對於構建組合邏輯而言是完全足夠的,而且更推薦這種做法,所以when語句常用於給寄存器賦值,而很少用來給線網賦值。讀者可能習慣用always語句塊來編寫電路,但這存在一些問題:首先,always既可以綜合出時序邏輯又能綜合出組合邏輯,導致reg變量存在二義性,常常使得新手誤解reg就是寄存器;其次,if...else if...else不能傳播控制變量的未知態x(某些EDA工具可以),使得仿真階段無法發現一些錯誤,但是assign語句會在控制變量為x時也輸出x。工業級的Verilog,都是用assign語句來構建電路。時序邏輯也是通過例化觸發器模塊來完成的,相應的端口都是由assign來驅動,而且觸發器會使用  SystemVerilog的斷言來尋找always語句里的x和z。整個設計應該盡量避免使用always語句。 

二、ROM
  可以通過工廠方法“VecInit[T <: Data](elt0: T, elts: T*)”或“VecInit[T <: Data](elts: Seq[T])”來創建一個只讀存儲器,參數就是ROM里的常量數值,對應的Verilog代碼就是給讀取ROM的線網或寄存器賦予常量值。例如:

// rom.scala
package test

import chisel3._

class ROM extends Module {
  val io = IO(new Bundle {
    val sel = Input(UInt(2.W))
    val out = Output(UInt(8.W))  
  })

  val rom = VecInit(1.U, 2.U, 3.U, 4.U)

  io.out := rom(io.sel)
}
  對應的Verilog為:

// ROM.v
module ROM(
  input        clock,
  input        reset,
  input  [1:0] io_sel,
  output [7:0] io_out
);
  wire [2:0] _GEN_1; 
  wire [2:0] _GEN_2; 
  wire [2:0] _GEN_3; 
  assign _GEN_1 = 2'h1 == io_sel ? 3'h2 : 3'h1; 
  assign _GEN_2 = 2'h2 == io_sel ? 3'h3 : _GEN_1; 
  assign _GEN_3 = 2'h3 == io_sel ? 3'h4 : _GEN_2; 
  assign io_out = {{5'd0}, _GEN_3};
endmodule
  在這個例子里需要提的一點是,Vec[T]類的apply方法不僅可以接收Int類型的索引值,另一個重載版本還能接收UInt類型的索引值。所以對於承擔地址、計數器等功能的部件,可以直接作為由Vec[T]構造的元素的索引參數,比如這個例子中根據sel端口的值來選擇相應地址的ROM值。

三、RAM
  Chisel支持兩種類型的RAM。第一種RAM是同步(時序)寫,異步(組合邏輯)讀,通過工廠方法“Mem[T <: Data](size: Int, t: T)”來構建。例如:

val asyncMem = Mem(16, UInt(32.W)) 

  由於現代的FPGA和ASIC技術已經不再支持異步讀RAM,所以這種RAM會被綜合成寄存器陣列。第二種RAM則是同步(時序)讀、寫,通過工廠方法“SyncReadMem[T <: Data](size: Int, t: T)”來構建,這種RAM會被綜合成實際的SRAM。在Verilog代碼上,這兩種RAM都是由reg類型的變量來表示的,區別在於第二種RAM的讀地址會被地址寄存器寄存一次。例如:

val syncMem = SyncReadMem(16, UInt(32.W))

  寫RAM的語法是:

when(wr_en) {
     mem.write(address, dataIn) 
     out := DontCare
}

  其中DontCare告訴Chisel的未連接線網檢測機制,寫入RAM時讀端口的行為無需關心。

  讀RAM的語法是:

out := mem.read(address, rd_en)

  讀、寫使能信號都可以省略。

  要綜合出實際的SRAM,讀者最好了解自己的綜合器是如何推斷的,按照綜合器的推斷規則來編寫模塊的端口定義、時鍾域划分、讀寫使能的行為等等,否則就可能綜合出寄存器陣列而不是SRAM。以Vivado 2018.3為例,下面的單端口SRAM代碼經過綜合后會映射到FPGA上實際的BRAM資源,而不是寄存器:

// ram.scala
package test

import chisel3._

class SinglePortRAM extends Module {
val io = IO(new Bundle {
val addr = Input(UInt(10.W))
val dataIn = Input(UInt(32.W))
val en = Input(Bool())
val we = Input(Bool())
val dataOut = Output(UInt(32.W))
})

val syncRAM = SyncReadMem(1024, UInt(32.W))

when(io.en) {
when(io.we) {
syncRAM.write(io.addr, io.dataIn)
io.dataOut := DontCare
} .otherwise {
io.dataOut := syncRAM.read(io.addr)
}
} .otherwise {
io.dataOut := DontCare
}
}
  下面是Vivado綜合后的部分截圖,可以看到確實變成了實際的BRAM:

  Vivado的BRAM最多支持真·雙端口,按照對應的Verilog模板逆向編寫Chisel,然后用編譯器把Chisel轉換成Verilog。但此時編譯器生成的Verilog代碼並不能被Vivado的綜合器識別出來。原因在於SyncReadMem生成的Verilog代碼是用一級寄存器保存輸入的讀地址,然后用讀地址寄存器去異步讀取RAM的數據,而Vivado的綜合器識別不出這種模式的RAM。讀者必須手動修改成用一級寄存器保存異步讀取的數據而不是讀地址,然后把讀數據寄存器的內容用assign語句賦值給讀數據端口,這樣才能被識別成真·雙端口BRAM。尚不清楚其它綜合器是否有這個問題。經過咨詢SiFive的工作人員,對方答復因為當前轉換的代碼把延遲放在地址一側,所以流水線的節拍設計也是根據這個來的。考慮到貿然修改SyncReadMem的行為,可能會潛在地影響其它用戶對流水線的設計,故而沒有修改計划。如果確實需要自定義的、對綜合器友好的Verilog代碼,可以使用黑盒功能替代,或者給Firrtl編譯器傳入參數,改用自定義腳本來編譯Chisel。

四、帶寫掩模的RAM
  RAM通常都具備按字節寫入的功能,比如數據寫入端口的位寬是32bit,那么就應該有4bit的寫掩模信號,只有當寫掩模比特有效時,對應的字節才會寫入。Chisel也具備構建帶寫掩模的RAM的功能。

  當構建RAM的數據類型為Vec[T]時,就會推斷出該RAM具有寫掩模。此時,需要定義一個Seq[Bool]類型的寫掩模信號,序列的元素個數為數據寫入端口的位寬除以字節寬度。而write方法有一個重載版本,就是第三個參數是接收寫掩模信號的。當下標為0的寫掩模比特是true.B時,最低的那個字節會被寫入,依次類推。下面是一個帶寫掩模的單端口RAM:

// maskram.scala
package test

import chisel3._
import chisel3.util._

class MaskRAM extends Module {
val io = IO(new Bundle {
val addr = Input(UInt(10.W))
val dataIn = Input(UInt(32.W))
val en = Input(Bool())
val we = Input(UInt(4.W))
val dataOut = Output(UInt(32.W))
})

val dataIn_temp = Wire(Vec(4, UInt(8.W)))
val dataOut_temp = Wire(Vec(4, UInt(8.W)))
val mask = Wire(Vec(4, Bool()))

val syncRAM = SyncReadMem(1024, Vec(4, UInt(8.W)))

when(io.en) {
syncRAM.write(io.addr, dataIn_temp, mask)
dataOut_temp := syncRAM.read(io.addr)
} .otherwise {
dataOut_temp := DontCare
}

for(i <- 0 until 4) {
dataIn_temp(i) := io.dataIn(8*i+7, 8*i)
mask(i) := io.we(i).toBool
io.dataOut := Cat(dataOut_temp(3), dataOut_temp(2), dataOut_temp(1), dataOut_temp(0))
}
}
  讀、寫端口和寫掩模可以不用定義成一個UInt,也可以是Vec[UInt],這樣定義只是為了讓模塊對外只有一個讀端口、一個寫端口和一個寫掩模端口。注意,編譯器會把Vec[T]的元素逐個展開,而不是合並成壓縮數組的形式。也正是如此,上述代碼對應的Verilog中,把RAM主體定義成了“reg [7:0] syncRAM_0 [0:1023]”、“reg [7:0]  syncRAM_1 [0:1023]”、“reg [7:0] syncRAM_2 [0:1023]”和“reg [7:0] syncRAM_3 [0:1023]”,而不是一個“reg [31:0] syncRAM [0:1023]”。這樣,Vivado綜合出來的電路是四小塊BRAM,而不是一大塊BRAM。

五、從文件讀取數據到RAM
  在experimental包里有一個單例對象loadMemoryFromFile,它的apply方法可以在Chisel層面上從txt文件讀取數據到RAM里。其定義如下所示:

def apply[T <: Data](memory: MemBase[T], fileName: String, hexOrBinary: FileType = MemoryLoadFileType.Hex): Unit

  第一個參數是MemBase[T]類型的,也就是Mem[T]和SyncReadMem[T]的超類,該參數接收一個自定義的RAM對象。第二個參數是文件的名字及路徑,用字符串表示。第三個參數表示讀取的方式為十六進制或二進制,默認是MemoryLoadFileType.Hex,也可以改成MemoryLoadFileType.Binary。注意,沒有十進制和八進制。

  該方法其實就是調用Verilog的系統函數“$readmemh”和“$readmemb”,所以要注意文件路徑的書寫和數據的格式都要按照Verilog的要求書寫。最好把數據文件放在resources文件夾里。例如:

// loadmem.scala
package test

import chisel3._
import chisel3.util.experimental.loadMemoryFromFile

class LoadMem extends Module {
val io = IO(new Bundle {
val address = Input(UInt(3.W))
val value = Output(UInt(8.W))
})
val memory = Mem(8, UInt(8.W))
io.value := memory.read(io.address)
loadMemoryFromFile(memory, "~/chisel-workspace/chisel-template/mem.txt")
}
  那么就會得到兩個Verilog文件:

// LoadMem.v
module LoadMem(
input clock,
input reset,
input [2:0] io_address,
output [7:0] io_value
);
reg [7:0] memory [0:7];
wire [7:0] memory__T_data;
wire [2:0] memory__T_addr;
assign memory__T_addr = io_address;
assign memory__T_data = memory[memory__T_addr];
assign io_value = memory__T_data;
endmodule

// LoadMem.LoadMem.memory.v
module BindsTo_0_LoadMem(
input clock,
input reset,
input [2:0] io_address,
output [7:0] io_value
);

initial begin
$readmemh("~/chisel-workspace/chisel-template/mem.txt", LoadMem.memory);
end
endmodule
  在用Verilator仿真時,它會識別這個Chisel代碼,從文件讀取數據。 

六、計數器
  計數器也是一個常用的硬件電路。Chisel在util包里定義了一個自增計數器原語Counter,它的工廠方法接收兩個參數:第一個參數是Bool類型的使能信號,為true.B時計數器從0開始每個時鍾上升沿加1自增,為false.B時則計數器保持不變;第二個參數需要一個Int類型的具體正數,當計數到該值時歸零。該方法返回一個二元組,其第一個元素是計數器的計數值,第二個元素是判斷計數值是否等於期望值的結果。工廠方法的另一個重載版本沒有使能信號。

  有如下示例代碼:

// counter.scala
package test

import chisel3._
import chisel3.util._

class MyCounter extends Module {
val io = IO(new Bundle {
val en = Input(Bool())
val out = Output(UInt(8.W))
val valid = Output(Bool())
})

val (a, b) = Counter(io.en, 233)
io.out := a
io.valid := b
}
  它生成的主要Verilog代碼為:

// MyCounter.v
module MyCounter(
input clock,
input reset,
input io_en,
output [7:0] io_out,
output io_valid
);
reg [7:0] value;
wire _T;
wire [7:0] _T_2;
assign _T = value == 8'he8;
assign _T_2 = value + 8'h1;
assign io_out = value;
assign io_valid = io_en & _T;

always @(posedge clock) begin
if (reset) begin
value <= 8'h0;
end else begin
if (io_en) begin
if (_T) begin
value <= 8'h0;
end else begin
value <= _T_2;
end
end
end
end
endmodule
七、16位線性反饋移位寄存器
  如果要產生偽隨機數,可以使用util包里的16位線性反饋移位寄存器原語LFSR16,它接收一個Bool類型的使能信號,用於控制寄存器是否移位,缺省值為true.B。它返回一個UInt(16.W)類型的結果。例如:

// lfsr.scala
package test

import chisel3._
import chisel3.util._

class LFSR extends Module {
val io = IO(new Bundle {
val en = Input(Bool())
val out = Output(UInt(16.W))
})

io.out := LFSR16(io.en)
}
  它生成的主要Verilog代碼為:

// LFSR.v
module LFSR(
input clock,
input reset,
input io_en,
output [15:0] io_out
);
reg [15:0] _T;
wire _T_1;
wire _T_2;
wire _T_3;
wire _T_4;
wire _T_5;
wire _T_6;
wire _T_7;
wire [14:0] _T_8;
wire [15:0] _T_9;
assign _T_1 = _T[0];
assign _T_2 = _T[2];
assign _T_3 = _T_1 ^ _T_2;
assign _T_4 = _T[3];
assign _T_5 = _T_3 ^ _T_4;
assign _T_6 = _T[5];
assign _T_7 = _T_5 ^ _T_6;
assign _T_8 = _T[15:1];
assign _T_9 = {_T_7,_T_8};
assign io_out = _T;

always @(posedge clock) begin
if (reset) begin
_T <= 16'h1;
end else begin
if (io_en) begin
_T <= _T_9;
end
end
end
endmodule
八、狀態機
  狀態機也是常用電路,但是Chisel沒有直接構建狀態機的原語。不過,util包里定義了一個Enum特質及其伴生對象。伴生對象里的apply方法定義如下:

def apply(n: Int): List[UInt]

  它會根據參數n返回對應元素數的List[UInt],每個元素都是不同的,所以可以作為枚舉值來使用。最好把枚舉狀態的變量名也組成一個列表,然后用列表的模式匹配來進行賦值。有了枚舉值后,可以通過“switch…is…is”語句來使用。其中,switch里是相應的狀態寄存器,而每個is分支的后面則是枚舉值及相應的定義。例如檢測持續時間超過兩個時鍾周期的高電平:

// fsm.scala
package test

import chisel3._
import chisel3.util._

class DetectTwoOnes extends Module {
val io = IO(new Bundle {
val in = Input(Bool())
val out = Output(Bool())
})

val sNone :: sOne1 :: sTwo1s :: Nil = Enum(3)
val state = RegInit(sNone)

io.out := (state === sTwo1s)

switch (state) {
is (sNone) {
when (io.in) {
state := sOne1
}
}
is (sOne1) {
when (io.in) {
state := sTwo1s
} .otherwise {
state := sNone
}
}
is (sTwo1s) {
when (!io.in) {
state := sNone
}
}
}
}
  注意,枚舉狀態名的首字母要小寫,這樣Scala的編譯器才能識別成變量模式匹配。它生成的Verilog為:

// DetectTwoOnes.v
module DetectTwoOnes(
input clock,
input reset,
input io_in,
output io_out
);
reg [1:0] state;
wire _T_1;
wire _T_2;
wire _T_3;
wire _T_4;
assign _T_1 = 2'h0 == state;
assign _T_2 = 2'h1 == state;
assign _T_3 = 2'h2 == state;
assign _T_4 = io_in == 1'h0;
assign io_out = state == 2'h2;

always @(posedge clock) begin
if (reset) begin
state <= 2'h0;
end else begin
if (_T_1) begin
if (io_in) begin
state <= 2'h1;
end
end else begin
if (_T_2) begin
if (io_in) begin
state <= 2'h2;
end else begin
state <= 2'h0;
end
end else begin
if (_T_3) begin
if (_T_4) begin
state <= 2'h0;
end
end
end
end
end
end
endmodule
 九、總結
  本章介紹了Chisel內建的常用原語,還有更多原語可以使用,比如Bundle衍生的幾種端口類,讀者可以通過查詢API或源碼來進一步了解。

第二十章 Chisel基礎——生成Verilog與基本測試

  經過前三章的內容,讀者已經了解了如何使用Chisel構建一個基本的模塊。本章的內容就是在此基礎上,把一個Chisel模塊編譯成Verilog代碼,並進一步使用Verilator做一些簡單的測試。

一、生成Verilog
  前面介紹Scala的內容里說過,Scala程序的入口是主函數。所以,生成Verilog的程序自然是在主函數里例化待編譯的模塊,然后運行這個主函數。例化待編譯模塊需要特殊的方法調用。chisel3包里有一個單例對象Driver,它包含一個方法execute,該方法接收兩個參數,第一個參數是命令行傳入的實參即字符串數組args,第二個是返回待編譯模塊的對象的無參函數。運行這個execute方法,就能得到Verilog代碼。

  假設在src/main/scala文件夾下有一個全加器的Chisel設計代碼,如下所示:

// fulladder.scala
package test

import chisel3._

class FullAdder extends Module {
val io = IO(new Bundle {
val a = Input(UInt(1.W))
val b = Input(UInt(1.W))
val cin = Input(UInt(1.W))
val s = Output(UInt(1.W))
val cout = Output(UInt(1.W))
})

io.s := io.a ^ io.b ^ io.cin
io.cout := (io.a & io.b) | ((io.a | io.b) & io.cin)
}
  接着,讀者需要在src/test/scala文件夾下編寫對應的主函數文件,如下所示:

// fullAdderGen.scala
package test

object FullAdderGen extends App {
chisel3.Driver.execute(args, () => new FullAdder)
}
  在這個主函數里,只有一個execute函數的調用,第一個參數固定是“args”,第二個參數則是無參的函數字面量“() => new FullAdder”。因為Chisel的模塊本質上還是Scala的class,所以只需用new構造一個對象作為返回結果即可。主函數里可以包括多個execute函數,也可以包含其它代碼。還有一點要注意的是,建議把設計文件和主函數放在一個包里,比如這里的“package test”,這樣省去了編寫路徑的麻煩。

  要運行這個主函數,需要在build.sbt文件所在的路徑下打開終端,然后執行命令:

esperanto@ubuntu:~/chisel-template$ sbt 'test:runMain test.FullAdderGen'

  注意,sbt后面有空格,再后面的內容都是被單引號對或雙引號對包起來。其中,test:runMain是讓sbt執行主函數的命令,而test.FullAdderGen就是要執行的那個主函數。

  如果設計文件沒有錯誤,那么最后就會看到“[success] Total time: 6 s, completed Feb 22, 2019 4:45:31 PM”這樣的信息。此時,終端的路徑下就會生成三個文件:FullAdder.anno.json、FullAdder.fir和FullAdder.v。

  第一個文件用於記錄傳遞給Firrtl編譯器的Scala注解,讀者可以不用關心。第二個后綴為“.fir”的文件就是對應的Firrtl代碼,第三個自然是對應的Verilog文件。

  首先查看最關心的Verilog文件,內容如下:

// FullAdder.v
module FullAdder(
input clock,
input reset,
input io_a,
input io_b,
input io_cin,
output io_s,
output io_cout
);
wire _T; // @[fulladder.scala 14:16]
wire _T_2; // @[fulladder.scala 15:20]
wire _T_3; // @[fulladder.scala 15:37]
wire _T_4; // @[fulladder.scala 15:45]
assign _T = io_a ^ io_b; // @[fulladder.scala 14:16]
assign _T_2 = io_a & io_b; // @[fulladder.scala 15:20]
assign _T_3 = io_a | io_b; // @[fulladder.scala 15:37]
assign _T_4 = _T_3 & io_cin; // @[fulladder.scala 15:45]
assign io_s = _T ^ io_cin; // @[fulladder.scala 14:8]
assign io_cout = _T_2 | _T_4; // @[fulladder.scala 15:11]
endmodule
  可以看到,代碼邏輯與想要表達的意思完全一致,而且對應的代碼都用注釋標明了來自於Chisel源文件的哪里。但由於這是通過語法分析的腳本代碼得到的,所以看上去顯得很笨拙、僵硬,生成了大量無用的中間變量聲明。對於下游的綜合器而言是一個負擔,可能會影響綜合器的優化。而且在進行仿真時,要理解這些中間變量也很麻煩。對后端人員來說,這也是讓人頭疼的問題。

  接着再看一看Firrtl代碼,內容如下:

// FullAdder.fir
;buildInfoPackage: chisel3, version: 3.2-SNAPSHOT, scalaVersion: 2.12.6, sbtVersion: 1.1.1
circuit FullAdder :
module FullAdder :
input clock : Clock
input reset : UInt<1>
output io : {flip a : UInt<1>, flip b : UInt<1>, flip cin : UInt<1>, s : UInt<1>, cout : UInt<1>}

node _T = xor(io.a, io.b) @[fulladder.scala 14:16]
node _T_1 = xor(_T, io.cin) @[fulladder.scala 14:23]
io.s <= _T_1 @[fulladder.scala 14:8]
node _T_2 = and(io.a, io.b) @[fulladder.scala 15:20]
node _T_3 = or(io.a, io.b) @[fulladder.scala 15:37]
node _T_4 = and(_T_3, io.cin) @[fulladder.scala 15:45]
node _T_5 = or(_T_2, _T_4) @[fulladder.scala 15:28]
io.cout <= _T_5 @[fulladder.scala 15:11]
  可以看到,Firrtl代碼與它生成的Verilog代碼非常接近。這種代碼風格雖然不方便人工閱讀,但是適合語法分析腳本使用。

二、在命令里增加參數
   Ⅰ、給Firrtl傳遞參數
  在運行主函數時,可以在剛才的命令后面繼續增加可選的參數。例如,增加參數“--help”查看幫助菜單,運行命令:

esperanto@ubuntu:~/chisel-template$ sbt 'test:runMain test.FullAdderGen --help'

  可以得到如下幫助信息: 

common options
  -tn, --top-name <top-level-circuit-name>
                           This options defines the top level circuit, defaults to dut when possible
  -td, --target-dir <target-directory>
                           This options defines a work directory for intermediate files, default is .
  -ll, --log-level <Error|Warn|Info|Debug|Trace>
                           This options defines a work directory for intermediate files, default is .
  -cll, --class-log-level <FullClassName:[Error|Warn|Info|Debug|Trace]>[,...]
                           This options defines a work directory for intermediate files, default is .
  -ltf, --log-to-file      default logs to stdout, this flags writes to topName.log or firrtl.log if no topName
  -lcn, --log-class-names  shows class names and log level in logging output, useful for target --class-log-level
  --help                   prints this usage text
  <arg>...                 optional unbounded args
chisel3 options
  -chnrf, --no-run-firrtl  Stop after chisel emits chirrtl file
firrtl options
  -i, --input-file <firrtl-source>
                           use this to override the default input file name , default is empty
  -o, --output-file <output>
                           use this to override the default output file name, default is empty
  -faf, --annotation-file <input-anno-file>
                           Used to specify annotation files (can appear multiple times)
  -foaf, --output-annotation-file <output-anno-file>
                           use this to set the annotation output file
  -X, --compiler <high|middle|low|verilog|sverilog>
                           compiler to use, default is verilog
 --info-mode <ignore|use|gen|append>
                           specifies the source info handling, default is append
  -fct, --custom-transforms <package>.<class>
                           runs these custom transforms during compilation.
  -fil, --inline <circuit>[.<module>[.<instance>]][,..],
                           Inline one or more module (comma separated, no spaces) module looks like "MyModule" or "MyModule.myinstance
  -firw, --infer-rw        Enable readwrite port inference for the target circuit
  -frsq, --repl-seq-mem -c:<circuit>:-i:<filename>:-o:<filename>
                           Replace sequential memories with blackboxes + configuration file
  -clks, --list-clocks -c:<circuit>:-m:<module>:-o:<filename>
                           List which signal drives each clock of every descendent of specified module
  -fsm, --split-modules    Emit each module to its own file in the target directory.
  --no-check-comb-loops    Do NOT check for combinational loops (not recommended)
  --no-dce                 Do NOT run dead code elimination 

  例如,最常用的是參數“-td”,可以在后面指定一個文件夾,這樣之前生成的三個文件就在該文件夾里,而不是在當前路徑下。其格式如下:

esperanto@ubuntu:~/chisel-template$ sbt 'test:runMain test.FullAdderGen -td ./generated/fulladder' 

   Ⅱ、給主函數傳遞參數
  Scala的類可以接收參數,自然Chisel的模塊也可以接收參數。假設要構建一個n位的加法器,具體位寬不確定,根據需要而定。那么,就可以把端口位寬參數化,例化時傳入想要的參數即可。例如:

// adder.scala
package test

import chisel3._

class Adder(n: Int) extends Module {
val io = IO(new Bundle {
val a = Input(UInt(n.W))
val b = Input(UInt(n.W))
val s = Output(UInt(n.W))
val cout = Output(UInt(1.W))
})

io.s := (io.a +& io.b)(n-1, 0)
io.cout := (io.a +& io.b)(n)
}

// adderGen.scala
package test

object AdderGen extends App {
chisel3.Driver.execute(args, () => new Adder(args(0).toInt))
}
  在這里,模塊Adder的主構造方法接收一個Int類型的參數n,然后用n去定義端口位寬。主函數在例化這個模塊時,就要給出相應的參數。前面的幫助菜單里顯示,在運行sbt命令時,可以傳入若干個獨立的參數。和運行Scala的主函數一樣,這些命令行的參數也可以由字符串數組args通過下標來索引。從要運行的主函數后面開始,后面的內容都是按空格划分、從下標0開始的args的元素。比如例子中的主函數期望第一個參數即args(0)是一個數字字符串,這樣就能通過方法toInt轉換成Adder所需的參數。

  執行如下命令:

esperanto@ubuntu:~/chisel-template$  sbt 'test:runMain test.AdderGen 8 -td ./generated/adder'

  可以在相應的文件夾下得到如下Verilog代碼,其中位寬的確是8位的:

// Adder.v
module Adder(
input clock,
input reset,
input [7:0] io_a,
input [7:0] io_b,
output [7:0] io_s,
output io_cout
);
wire [8:0] _T;
assign _T = io_a + io_b;
assign io_s = _T[7:0];
assign io_cout = _T[8];
endmodule
三、編寫簡單的測試
  Chisel的測試有兩種,第一種是利用Scala的測試來驗證Chisel級別的代碼邏輯有沒有錯誤。因為這部分內容比較復雜,而且筆者目前也沒有深入學習有關Scala測試的內容,所以這部分內容可有讀者自行選擇研究。第二種是利用Chisel庫里的peek和poke函數,給模塊的端口加激勵、查看信號值,並交由下游的Verilator來仿真、產生波形。這種方式比較簡單,類似於Verilog的testbench,適合小型電路的驗證。對於超大型的系統級電路,最好還是生成Verilog,交由成熟的EDA工具,用UVM進行驗證。

  要編寫一個簡單的testbench,首先也是定義一個類,這個類的主構造方法接收一個參數,參數類型就是待測模塊的類名。因為模塊也是一個類,從Scala的角度來看,一個類就是定義了一種類型。其次,這個類繼承自PeekPokeTester類,並且把接收的待測模塊也傳遞給此超類。最后,測試類內部有四種方法可用:①“poke(端口,激勵值)”方法給相應的端口添加想要的激勵值,激勵值是Int類型的;②“peek(端口)”方法返回相應的端口的當前值;③“expect(端口,期望值)”方法會對第一個參數(端口)使用peek方法,然后與Int類型的期望值進行對比,如果兩者不相等則出錯;④“step(n)”方法則讓仿真前進n個時鍾周期。

  因為測試模塊只用於仿真,無需轉成Verilog,所以類似for、do…while、to、until、map等Scala高級語法都可以使用,幫助測試代碼更加簡潔有效。

  如下所示是一個對前一例中的8位加法器的testbench:

// addertest.scala
package test

import scala.util._
import chisel3.iotesters._

class AdderTest(c: Adder) extends PeekPokeTester(c) {
val randNum = new Random
for(i <- 0 until 10) {
val a = randNum.nextInt(256)
val b = randNum.nextInt(256)
poke(c.io.a, a)
poke(c.io.b, b)
step(1)
expect(c.io.s, (a + b) & 0xff)
expect(c.io.cout, ((a + b) & 0x100) >> 8)
}
}
  其中,第一個包scala.util里包含了Scala生成偽隨機數的類Random,第二個包chisel3.iotesters包含了測試類PeekPokeTester。

四、運行測試
  要運行測試,自然也是通過主函數,但是這次是使用iotesters包里的execute方法。該方法與前面生成Verilog的方法類似,僅僅是多了一個參數列表,多出的第二個參數列表接收一個返回測試類的對象的函數:

// addertest.scala
object AdderTestGen extends App {
chisel3.iotesters.Driver.execute(args, () => new Adder(8))(c => new AdderTest(c))
}
  運行如下命令:

esperanto@ubuntu:~/chisel-template$  sbt 'test:runMain test.AdderTestGen -td ./generated/addertest --backend-name verilator' 

  執行成功后,就能在相應文件夾里看到一個新生成的文件夾,里面是仿真生成的文件。其中,“Adder.vcd”文件就是波形文件,使用GTKWave軟件打開就能查看,將相應的端口拖拽到右側就能顯示波形。

  如果只想在終端查看仿真運行的信息,則執行命令:

esperanto@ubuntu:~/chisel-template$  sbt 'test:runMain test.AdderTestGen -td ./generated/addertest --is-verbose' 

  那么終端就會顯示如下信息:

[info] [0.002] SEED 1550906002475
[info] [0.005]   POKE io_a <- 184
[info] [0.006]   POKE io_b <- 142
[info] [0.006] STEP 0 -> 1
[info] [0.007] EXPECT AT 1   io_s got 70 expected 70 PASS
[info] [0.008] EXPECT AT 1   io_cout got 1 expected 1 PASS
[info] [0.008]   POKE io_a <- 114
[info] [0.009]   POKE io_b <- 231
[info] [0.009] STEP 1 -> 2
[info] [0.009] EXPECT AT 2   io_s got 89 expected 89 PASS
[info] [0.009] EXPECT AT 2   io_cout got 1 expected 1 PASS
[info] [0.010]   POKE io_a <- 183
[info] [0.010]   POKE io_b <- 168
[info] [0.010] STEP 2 -> 3
[info] [0.011] EXPECT AT 3   io_s got 95 expected 95 PASS
[info] [0.011] EXPECT AT 3   io_cout got 1 expected 1 PASS
[info] [0.012]   POKE io_a <- 223
[info] [0.012]   POKE io_b <- 106
[info] [0.012] STEP 3 -> 4
[info] [0.012] EXPECT AT 4   io_s got 73 expected 73 PASS
[info] [0.013] EXPECT AT 4   io_cout got 1 expected 1 PASS
[info] [0.013]   POKE io_a <- 12
[info] [0.013]   POKE io_b <- 182
[info] [0.013] STEP 4 -> 5
[info] [0.014] EXPECT AT 5   io_s got 194 expected 194 PASS
[info] [0.014] EXPECT AT 5   io_cout got 0 expected 0 PASS
[info] [0.014]   POKE io_a <- 52
[info] [0.014]   POKE io_b <- 41
[info] [0.015] STEP 5 -> 6
[info] [0.015] EXPECT AT 6   io_s got 93 expected 93 PASS
[info] [0.016] EXPECT AT 6   io_cout got 0 expected 0 PASS
[info] [0.016]   POKE io_a <- 187
[info] [0.017]   POKE io_b <- 60
[info] [0.017] STEP 6 -> 7
[info] [0.017] EXPECT AT 7   io_s got 247 expected 247 PASS
[info] [0.018] EXPECT AT 7   io_cout got 0 expected 0 PASS
[info] [0.018]   POKE io_a <- 218
[info] [0.019]   POKE io_b <- 203
[info] [0.019] STEP 7 -> 8
[info] [0.019] EXPECT AT 8   io_s got 165 expected 165 PASS
[info] [0.020] EXPECT AT 8   io_cout got 1 expected 1 PASS
[info] [0.020]   POKE io_a <- 123
[info] [0.021]   POKE io_b <- 115
[info] [0.021] STEP 8 -> 9
[info] [0.021] EXPECT AT 9   io_s got 238 expected 238 PASS
[info] [0.022] EXPECT AT 9   io_cout got 0 expected 0 PASS
[info] [0.022]   POKE io_a <- 17
[info] [0.022]   POKE io_b <- 197
[info] [0.023] STEP 9 -> 10
[info] [0.023] EXPECT AT 10   io_s got 214 expected 214 PASS
[info] [0.024] EXPECT AT 10   io_cout got 0 expected 0 PASS
test Adder Success: 20 tests passed in 15 cycles in 0.047415 seconds 316.36 Hz
[info] [0.025] RAN 10 CYCLES PASSED
[success] Total time: 7 s, completed Feb 23, 2019 3:13:26 PM 

五、總結
  本章介紹了從Chisel轉換成Verilog、測試設計的基本方法。因為Chisel還在更新中,這些方法也是從Chisel2里保留下來的。將來也許會有更便捷的方式,讀者可以留意。

第二十一章 Chisel基礎——黑盒

  因為Chisel的功能相對Verilog來說還不完善,所以設計人員在當前版本下無法實現的功能,就需要用Verilog來實現。在這種情況下,可以使用Chisel的BlackBox功能,它的作用就是向Chisel代碼提供了用Verilog設計的電路的接口,使得Chisel層面的代碼可以通過模塊的端口來進行交互。

一、例化黑盒
  如果讀者嘗試在Chisel的模塊里例化另一個模塊,然后生成Verilog代碼,就會發現端口名字里多了“io_”這樣的字眼。很顯然,這是因為Chisel要求模塊的端口都是由字段“io”來引用的,語法分析腳本在生成Verilog代碼時會保留這個端口名前綴。

  假設有一個外部的Verilog模塊,它的端口列表聲明如下:

module Dut ( input [31: 0] a, input clk, input reset, output [3: 0] b );

  按照Verilog的語法,它的例化代碼應該是這樣的:

Dut u0 ( .a(u0_a), .clk(u0_clk), .reset(u0_reset), .b(u0_b) ); 

  其中,例化時的名字和連接的線網名是可以任意的,但是模塊名“Dut”和端口名“.a”、“.clk”、“.reset”、 “.b”是固定的。

  倘若把這個Verilog模塊聲明成普通的Chisel模塊,然后直接例化使用,那么例化的Verilog代碼就會變成:

Dut u0 ( .io_a(io_u0_a), .io_clk(io_u0_clk), .io_reset(io_u0_reset), .io_b(io_u0_b) );

  也就是說,本來應該是“.a”,變成了“.io_a”。當然,這樣做首先在Chisel層面上就不會成功,因為Chisel的編譯器不允許模塊內部連線為空,不能只有端口聲明而沒有內部連線的模塊。

  如果定義Dut類時,不是繼承自Module,而是繼承自BlackBox,則允許只有端口定義,也只需要端口定義。此外,在別的模塊里例化黑盒時,編譯器不會給黑盒的端口名加上“io_”,連接的線網名變成引用黑盒的變量名與黑盒端口名的組合。例如: 

// blackbox.scala
package test

import chisel3._

class Dut extends BlackBox {
val io = IO(new Bundle {
val a = Input(UInt(32.W))
val clk = Input(Clock())
val reset = Input(Bool())
val b = Output(UInt(4.W))
})
}

class UseDut extends Module {
val io = IO(new Bundle {
val toDut_a = Input(UInt(32.W))
val toDut_b = Output(UInt(4.W))
})

val u0 = Module(new Dut)

u0.io.a := io.toDut_a
u0.io.clk := clock
u0.io.reset := reset
io.toDut_b := u0.io.b
}

object UseDutTest extends App {
chisel3.Driver.execute(args, () => new UseDut)
}
  它對應生成的Verilog代碼為:

// UseDut.v
module UseDut(
input clock,
input reset,
input [31:0] io_toDut_a,
output [3:0] io_toDut_b
);
wire [31:0] u0_a; // @[blackbox.scala 20:18]
wire u0_clk; // @[blackbox.scala 20:18]
wire u0_reset; // @[blackbox.scala 20:18]
wire [3:0] u0_b; // @[blackbox.scala 20:18]
Dut u0 ( // @[blackbox.scala 20:18]
.a(u0_a),
.clk(u0_clk),
.reset(u0_reset),
.b(u0_b)
);
assign io_toDut_b = u0_b; // @[blackbox.scala 25:14]
assign u0_a = io_toDut_a; // @[blackbox.scala 22:11]
assign u0_clk = clock; // @[blackbox.scala 23:13]
assign u0_reset = reset; // @[blackbox.scala 24:15]
endmodule
  可以看到,例化黑盒生成的Verilog代碼,完全符合Verilog例化模塊的語法規則。通過黑盒導入Verilog模塊的端口列表給Chisel模塊使用,然后把Chisel代碼轉換成Verilog,把它與導入的Verilog一同傳遞給EDA工具使用。

  BlackBox的構造方法可以接收一個Map[String, Param]類型的參數,這會使得例化外部的Verilog模塊時具有配置模塊的“#(參數配置)”。映射的鍵固定是字符串類型,它對應Verilog里聲明的參數名;映射的值對應傳入的配置參數,可以是字符串,也可以是整數和浮點數。雖然值的類型是Param,這是一個Chisel的印章類,但是單例對象chisel3.experimental里定義了相應的隱式轉換,可以把BigInt、Int、Long、Double和String轉換成對應的Param類型。例如把上例修改成: 

...
import chisel3.experimental._

class Dut extends BlackBox(Map("DATA_WIDTH" -> 32,
"MODE" -> "Sequential",
"RESET" -> "Asynchronous")) {
val io = IO(new Bundle {
val a = Input(UInt(32.W))
val clk = Input(Clock())
val reset = Input(Bool())
val b = Output(UInt(4.W))
})
}
...
  對應的Verilog就變成了:

...
Dut #(.DATA_WIDTH(32), .MODE("Sequential"), .RESET("Asynchronous")) u0 ( // @[blackbox.scala 23:18]
.a(u0_a),
.clk(u0_clk),
.reset(u0_reset),
.b(u0_b)
);
...
  通過這種方式,借助Verilog把Chisel的功能暫時補齊了。比如UCB發布的Rocket-Chip,就是用黑盒導入異步寄存器,供內部代碼使用。

二、復制Verilog文件
  chisel3.util包里有一個特質HasBlackBoxResource,如果在黑盒類里混入這個特質,並且在src/main/resources文件夾里有對應的Verilog源文件,那么在Chisel轉換成Verilog時,就會把Verilog文件一起復制到目標文件夾。例如:

...
import chisel3.util._

class Dut extends BlackBox with HasBlackBoxResource {
val io = IO(new Bundle {
val a = Input(UInt(32.W))
val clk = Input(Clock())
val reset = Input(Bool())
val b = Output(UInt(4.W))
})

setResource("/dut.v")
}
...
  注意,相比一般的黑盒,除了端口列表的聲明,還多了一個特質里的setResource方法的調用。方法的入參是Verilog文件的相對地址,即相對src/main/resources的地址。

三、內聯Verilog文件
  chisel3.util包里還有有一個特質HasBlackBoxInline,混入該特質的黑盒類可以把Verilog代碼直接內嵌進去。內嵌的方式是調用特質里的方法“setInline(blackBoxName: String, blackBoxInline: String)”,類似於setResource的用法。這樣,目標文件夾里就會生成一個單獨的Verilog文件,復制內嵌的代碼。該方法適合小型Verilog設計。例如:

...
import chisel3.util._

class Dut extends BlackBox with HasBlackBoxInline {
val io = IO(new Bundle {
val a = Input(UInt(32.W))
val clk = Input(Clock())
val reset = Input(Bool())
val b = Output(UInt(4.W))
})

setInline("dut.v",
"""
|module dut(input [31:0] a,
| input clk,
| input reset,
| output [3:0] b);
|
| reg [3:0] b_temp;
|
| always @ (posedge clk, negedge reset)
| if(!reset)
| b_temp <= 'b0;
| else if(a == 'b0)
| b_temp <= b_temp + 1'b1
|
| assign b = b_temp;
|endmodule
""".stripMargin)
}
...
  字符串中的“ | ”表示文件的邊界,比如Scala的解釋器在換行后的開頭就是一根豎線,方法stripMargin用於消除豎線左側的空格。

  調用這個黑盒的模塊在轉換成Verilog后,目標文件夾里會生成一個“dut.v”文件,內容就是內嵌的Verilog代碼。

四、inout端口
  Chisel目前只支持在黑盒中引入Verilog的inout端口。Bundle中使用 “Analog(位寬)”聲明Analog類型的端口,經過編譯后變成Verilog的inout端口。模塊里的端口可以聲明成Analog類型,但只能用於與黑盒連接,不能在Chisel代碼中進行讀寫。因為是雙向端口,所以不需要用Input或Output指明方向,但是可以用Flipped來翻轉,也就不會影響整個Bundle的翻轉。使用前,要先用“chisel3.experimental._”進行導入。

  例如:

// inout.scala
package test

import chisel3._
import chisel3.util._
import chisel3.experimental._

class InoutIO extends Bundle {
val a = Analog(16.W)
val b = Input(UInt(16.W))
val sel = Input(Bool())
val c = Output(UInt(16.W))
}

class InoutPort extends BlackBox with HasBlackBoxInline {
val io = IO(new InoutIO)

setInline("InoutPort.v",
"""
|module InoutPort( inout [15:0] a,
| input [15:0] b,
| input sel,
| output [15:0] c);
| assign a = sel ? 'bz : b;
| assign c = sel ? a : 'bz;
|endmodule
""".stripMargin)
}

class MakeInout extends Module {
val io = IO(new InoutIO)

val m = Module(new InoutPort)

m.io <> io
}

object InoutGen extends App {
chisel3.Driver.execute(args, () => new MakeInout)
}
  對應的Verilog為:

// MakeInout.v
module MakeInout(
input clock,
input reset,
inout [15:0] io_a,
input [15:0] io_b,
input io_sel,
output [15:0] io_c
);
wire [15:0] m_b; // @[inout.scala 32:17]
wire m_sel; // @[inout.scala 32:17]
wire [15:0] m_c; // @[inout.scala 32:17]
InoutPort m ( // @[inout.scala 32:17]
.a(io_a),
.b(m_b),
.sel(m_sel),
.c(m_c)
);
assign io_c = m_c; // @[inout.scala 34:8]
assign m_b = io_b; // @[inout.scala 34:8]
assign m_sel = io_sel; // @[inout.scala 34:8]
endmodule
五、總結
  本章介紹了三種黑盒的用法,其目的在於通過外部的Verilog文件來補充Chisel還沒有的功能。除此之外,由於還沒有EDA工具直接支持Chisel,比如在開發FPGA項目時,要例化Xilinx或Altera的IP,就需要用到黑盒。

第二十二章 Chisel基礎——函數的應用

  函數是編程語言的常用語法,即使是Verilog這樣的硬件描述語言,也會用函數來構建組合邏輯。對於Chisel這樣的高級語言,函數的使用更加方便,還能節省不少代碼量。不管是用戶自己寫的函數、Chisel語言庫里的函數還是Scala標准庫里的函數,都能幫助用戶節省構建電路的時間。

一、用函數抽象組合邏輯
  與Verilog一樣,對於頻繁使用的組合邏輯電路,可以定義成Scala的函數形式,然后通過函數調用的方式來使用它。這些函數既可以定義在某個單例對象里,供多個模塊重復使用,也可以直接定義在電路模塊里。例如:

// function.scala
import chisel3._

class UseFunc extends Module {
val io = IO(new Bundle {
val in = Input(UInt(4.W))
val out1 = Output(Bool())
val out2 = Output(Bool())
})

def clb(a: UInt, b: UInt, c: UInt, d: UInt): UInt =
(a & b) | (~c & d)

io.out1 := clb(io.in(0), io.in(1), io.in(2), io.in(3))
io.out2 := clb(io.in(0), io.in(2), io.in(3), io.in(1))
}
二、用工廠方法簡化模塊的例化
  在Scala里,往往在類的伴生對象里定義一個工廠方法,來簡化類的實例化。同樣,Chisel的模塊也是Scala的類,也可以在其伴生對象里定義工廠方法來簡化例化、連線模塊。例如用雙輸入多路選擇器構建四輸入多路選擇器:

// mux4.scala
import chisel3._

class Mux2 extends Module {
val io = IO(new Bundle {
val sel = Input(UInt(1.W))
val in0 = Input(UInt(1.W))
val in1 = Input(UInt(1.W))
val out = Output(UInt(1.W))
})

io.out := (io.sel & io.in1) | (~io.sel & io.in0)
}

object Mux2 {
def apply(sel: UInt, in0: UInt, in1: UInt) = {
val m = Module(new Mux2)
m.io.in0 := in0
m.io.in1 := in1
m.io.sel := sel
m.io.out
}
}

class Mux4 extends Module {
val io = IO(new Bundle {
val sel = Input(UInt(2.W))
val in0 = Input(UInt(1.W))
val in1 = Input(UInt(1.W))
val in2 = Input(UInt(1.W))
val in3 = Input(UInt(1.W))
val out = Output(UInt(1.W))
})

io.out := Mux2(io.sel(1),
Mux2(io.sel(0), io.in0, io.in1),
Mux2(io.sel(0), io.in2, io.in3))
}
三、用Scala的函數簡化代碼
  Scala的函數也能在Chisel里使用,只要能通過Firrtl編譯器的檢查。比如在生成長的序列上,利用Scala的函數就能減少大量的代碼。假設要構建一個譯碼器,在Verilog里需要寫條case語句,當n很大時就會使代碼顯得冗長而枯燥。利用Scala的for、yield組合可以產生相應的判斷條件與輸出結果的序列,再用zip函數將兩個序列組成一個對偶序列,再把對偶序列作為MuxCase的參數,就能用幾行代碼構造出任意位數的譯碼器。例如:

// decoder.scala
package decoder

import chisel3._
import chisel3.util._
import chisel3.experimental._

class Decoder(n: Int) extends RawModule {
val io = IO(new Bundle {
val sel = Input(UInt(n.W))
val out = Output(UInt((1 << n).W))
})

val x = for(i <- 0 until (1 << n)) yield io.sel === i.U
val y = for(i <- 0 until (1 << n)) yield 1.U << i
io.out := MuxCase(0.U, x zip y)
}

object DecoderGen extends App {
chisel3.Driver.execute(args, () => new Decoder(args(0).toInt))
}
  只需要輸入參數n,就能立即生成對應的n位譯碼器。

四、Chisel的打印函數
  Chisel提供了一個“ printf ”函數來打印信息,用於電路調試。它有Scala和C兩種風格。當用Verilator生成波形時,每個時鍾周期都會在屏幕上顯示一次。如果在when語句塊里,只有條件成立時才運行。隱式的全局復位信號也不會觸發。

  printf函數只能在Chisel的模塊里使用,並且會轉換成Verilog的系統函數“$fwrite”,包含在宏定義塊“ `ifndef SYNTHESIS......`endif ”里。通過Verilog的宏定義,可以取消這部分不可綜合的代碼。因為后導入的chisel3包覆蓋了Scala的標准包,所以Scala里的printf函數要寫成“Predef.printf”的完整路徑形式。

   Ⅰ、Scala風格
  該風格類似於Scala的字符串插值器。Chisel自定義了一個p插值器,該插值器可以對字符串內的一些自定義表達式進行求值、Chiel類型轉化成字符串類型等。

  ①簡單格式

val myUInt = 33.U
// 顯示Chisel自定義的類型的數據
printf(p"myUInt = $myUInt") // myUInt = 33
// 顯示成十六進制
printf(p"myUInt = 0x${Hexadecimal(myUInt)}") // myUInt = 0x21
// 顯示成二進制
printf(p"myUInt = ${Binary(myUInt)}") // myUInt = 100001
// 顯示成字符(ASCⅡ碼)
printf(p"myUInt = ${Character(myUInt)}") // myUInt = !

  ②聚合數據類型

val myVec = Vec(5.U, 10.U, 13.U)
printf(p"myVec = $myVec") // myVec = Vec(5, 10, 13)

val myBundle = Wire(new Bundle {
    val foo = UInt()
    val bar = UInt()
})
myBundle.foo := 3.U
myBundle.bar := 11.U
printf(p"myBundle = $myBundle") // myBundle = Bundle(a -> 3, b -> 11)

  ③自定義打印信息

  對於自定義的Bundle類型,可以重寫toPrintable方法來定制打印內容。當自定義的Bundle配合其他硬件類型例如Wire構成具體的硬件,並且被賦值后,可以用p插值器來求值該硬件,此時就會調用重寫的toPrintable方法。例如:

class Message extends Bundle {
  val valid = Bool()
  val addr = UInt(32.W)
  val length = UInt(4.W)
  val data = UInt(64.W)
  override def toPrintable: Printable = {
      val char = Mux(valid, 'v'.U, '-'.U)
      p"Message:\n" +
      p"  valid  : ${Character(char)}\n" +
      p"  addr   : 0x${Hexadecimal(addr)}\n" +
      p"  length : $length\n" +
      p"  data   : 0x${Hexadecimal(data)}\n"
  }
}

val myMessage = Wire(new Message)
myMessage.valid := true.B
myMessage.addr := "h1234".U
myMessage.length := 10.U
myMessage.data := "hdeadbeef".U

printf(p"$myMessage")

  注意,重寫的toPrintable方法的返回類型固定是Printable,這是因為p插值器的返回類型就是Printable,並且Printable類里定義了一個方法“+”用於將多個字符串拼接起來。在最后一個語句里,p插值器會求值myMessage,這就會調用Message類的toPrintable方法。因此,最終的打印信息如下:

Message:
     valid  : v
     addr   : 0x00001234
     length : 10
     data   : 0x00000000deadbeef

   Ⅱ、C風格
  Chisel的printf也支持C的部分格式控制符和轉義字符。如下所示:

val myUInt = 32.U
printf("myUInt = %d", myUInt) // myUInt = 32

五、Chisel的對數函數
  在二進制運算里,求以2為底的對數也是常用的運算。

  chisel3.util包里有一個單例對象Log2,它的一個apply方法接收一個Bits類型的參數,計算並返回該參數值以2為底的冪次。返回類型是UInt類型,並且是向下截斷的。另一個apply的重載版本可以接受第二個Int類型的參數,用於指定返回結果的位寬。例如:

Log2(8.U)  // 等於3.U

Log2(13.U)  // 等於3.U(向下截斷)

Log2(myUIntWire)  // 動態求值 

  chisel3.util包里還有四個單例對象:log2Ceil、log2Floor、log2Up和log2Down,它們的apply方法的參數都是Int和BigInt類型,返回結果都是Int類型。log2Ceil是把結果向上舍入,log2Floor則向下舍入。log2Up和log2Down不僅分別把結果向上、向下舍入,而且結果最小為1。

  單例對象isPow2的apply方法接收Int和BigInt類型的參數,判斷該整數是不是2的n次冪,返回Boolean類型的結果。

六、與硬件相關的函數
   Ⅰ、位旋轉
  chisel3.util包里還有一些常用的操作硬件的函數,比如單例對象Reverse的apply方法可以把一個UInt類型的對象進行旋轉,返回一個對應的UInt值。在轉換成Verilog時,都是通過拼接完成的組合邏輯。例如:

Reverse("b1101".U)  // 等於"b1011".U

Reverse("b1101".U(8.W))  // 等於"b10110000".U

Reverse(myUIntWire)  // 動態旋轉

   Ⅱ、位拼接
  單例對象Cat有兩個apply方法,分別接收一個Bits類型的序列和Bits類型的重復參數,將它們拼接成一個UInt數。前面的參數在高位。例如:

Cat("b101".U, "b11".U)  // 等於"b10111".U

Cat(myUIntWire0, myUIntWire1)  // 動態拼接

Cat(Seq("b101".U, "b11".U))  // 等於"b10111".U

Cat(mySeqOfBits)  // 動態拼接 

   Ⅲ、1計數器
  單例對象PopCount有兩個apply方法,分別接收一個Bits類型的參數和Bool類型的序列,計算參數里“1”或“true.B”的個數,返回對應的UInt值。例如:

PopCount(Seq(true.B, false.B, true.B, true.B))  // 等於3.U

PopCount(Seq(false.B, false.B, true.B, false.B))  // 等於1.U

PopCount("b1011".U)  // 等於3.U

PopCount("b0010".U)  // 等於1.U

PopCount(myUIntWire)  // 動態計數

   Ⅳ、獨熱碼轉換器
  單例對象OHToUInt的apply方法可以接收一個Bits類型或Bool序列類型的獨熱碼參數,計算獨熱碼里的“1”在第幾位(從0開始),返回對應的UInt值。如果不是獨熱碼,則行為不確定。例如:

OHToUInt("b1000".U)  // 等於3.U

OHToUInt("b1000_0000".U)  // 等於7.U 

  還有一個行為相反的單例對象UIntToOH,它的apply方法是根據輸入的UInt類型參數,返回對應位置的獨熱碼,獨熱碼也是UInt類型。例如:

UIntToOH(3.U)  // 等於"b1000".U

UIntToOH(7.U)  // 等於"b1000_0000".U

   Ⅴ、無關位
  Verilog里可以用問號表示無關位,那么用case語句進行比較時就不會關心這些位。Chisel里有對應的BitPat類,可以指定無關位。在其伴生對象里,一個apply方法可以接收一個字符串來構造BitPat對象,字符串里用問號表示無關位。例如:

"b10101".U === BitPat("b101??") // 等於true.B

"b10111".U === BitPat("b101??") // 等於true.B

"b10001".U === BitPat("b101??") // 等於false.B 

  另一個apply方法則用UInt類型的參數來構造BitPat對象,UInt參數必須是字面量。這允許把UInt類型用在期望BitPat的地方,當用BitPat定義接口又並非所有情況要用到無關位時,該方法就很有用。

  另外,bitPatToUInt方法可以把一個BitPat對象轉換成UInt對象,但是BitPat對象不能包含無關位。

  dontCare方法接收一個Int類型的參數,構造等值位寬的全部無關位。例如:

val myDontCare = BitPat.dontCare(4)  // 等於BitPat("b????") 

   Ⅵ、查找表
  BitPat通常配合兩種查找表使用。一種是單例對象Lookup,其apply方法定義為:

def apply[T <: Bits](addr: UInt, default: T, mapping: Seq[(BitPat, T)]): T 

  參數addr會與每個BitPat進行比較,如果相等,就返回對應的值,否則就返回default。

  第二種是單例對象ListLookup,它的apply方法與上面的類似,區別在於返回結果是一個T類型的列表:

defapply[T <: Data](addr: UInt, default: List[T], mapping: Array[(BitPat, List[T])]): List[T] 

  這兩種查找表的常用場景是構造CPU的控制器,因為CPU指令里有很多無關位,所以根據輸入的指令(即addr)與預先定義好的帶無關位的指令進行匹配,就能得到相應的控制信號。

七、總結
  在編寫代碼時,雖然是構造硬件,但是語言特性和編譯器允許讀者靈活使用高級函數。要做到熟能生巧,就應該多閱讀、多練習。

第二十三章 Chisel基礎——多時鍾域設計

   在數字電路中免不了用到多時鍾域設計,尤其是設計異步FIFO這樣的同步元件。在Verilog里,多時鍾域的設計很簡單,只需聲明多個時鍾端口,然后不同的always語句塊根據需要選擇不同的時鍾作為敏感變量即可。在Chisel里,則相對復雜一些,因為這與Scala的變量作用域相關,而且時序元件在編譯時都是自動地隱式跟隨當前時鍾域。本章將介紹多時鍾域設計的語法,這其實很簡單。

一、沒有隱式端口的模塊
  繼承自Module的模塊類會獲得隱式的全局時鍾與同步復位信號,即使在設計中用不上它們也沒關系。如果讀者確實不喜歡這兩個隱式端口,則可以選擇繼承自RawModule,這樣在轉換成Verilog時就沒有隱式端口。它是單例對象chisel3.experimental里定義的類型,也就是UserModule類的別名。

  這樣的模塊一般用於純組合邏輯。在類內頂層不能出現使用時鍾的相關操作,比如定義寄存器,否則會報錯沒有隱式端口。例如:

// module.scala
package test

import chisel3._
import chisel3.experimental._

class MyModule extends RawModule {
val io = IO(new Bundle {
val a = Input(UInt(4.W))
val b = Input(UInt(4.W))
val c = Output(UInt(4.W))
})

io.c := io.a & io.b
}

object ModuleGen extends App {
chisel3.Driver.execute(args, () => new MyModule)
}
  它生成的Verilog代碼為:

// MyModule.v
module MyModule(
input [3:0] io_a,
input [3:0] io_b,
output [3:0] io_c
);
assign io_c = io_a & io_b; // @[module.scala 13:8]
endmodule
  RawModule也可以包含時序邏輯,但要使用多時鍾域語法。

二、定義一個時鍾域和復位域
  chisel3.core包里有一個單例對象withClockAndReset,其apply方法定義如下:

def apply[T](clock: Clock, reset: Reset)(block: ⇒ T): T

  該方法的作用就是創建一個新的時鍾和復位域,作用范圍僅限於它的傳名參數的內部。新的時鍾和復位信號就是第一個參數列表的兩個參數。注意,在編寫代碼時不能寫成“import chisel3.core._”,這會擾亂“import chisel3._”的導入內容。正確做法是用“import chisel3.experimental._”導入experimental對象,它里面用同名字段引用了單例對象chisel3.core.withClockAndReset,這樣就不需要再導入core包。例如:

class MultiClockModule extends Module {
   val io = IO(new Bundle {
       val clockB = Input(Clock())
       val resetB = Input(Bool())
       val stuff = Input(Bool())
   })
   // 這個寄存器跟隨當前模塊的隱式全局時鍾clock
   val regClock1 = RegNext(io.stuff)

   withClockAndReset(io.clockB, io.resetB) {
       // 在該花括號內,所有時序元件都跟隨時鍾io.clockB
       // 所有寄存器的復位信號都是io.resetB

       // 這個寄存器跟隨io.clockB
       val regClockB = RegNext(io.stuff)
       // 還可以例化其它模塊
       val m = Module(new ChildModule)
    }

   // 這個寄存器跟隨當前模塊的隱式全局時鍾clock
   val regClock2 = RegNext(io.stuff)
}

  因為第二個參數列表只有一個傳名參數,所以可以把圓括號寫成花括號,這樣還有自動的分號推斷。再加上傳名參數的特性,盡管需要一個無參函數,但是可以省略書寫“() =>”。所以,

withClockAndReset(io.clockB, io.resetB) {
    sentence1
    sentence2
    ...
    sentenceN
}

  實際上相當於:

withClockAndReset(io.clockB, io.resetB)( () => (sentence1; sentence2; ...; sentenceN) )

  這結合了Scala的柯里化、傳名參數和單參數列表的語法特性,讓DSL語言的自定義方法看上去就跟內建的while、for、if等結構一樣自然,所以Scala很適合構建DSL語言。

  讀者再仔細看一看apply方法的定義,它的第二個參數是一個函數,同時該函數的返回結果也是整個apply方法的返回結果。也就是說,獨立時鍾域的定義里,最后一個表達式的結果會被當作函數的返回結果。可以用一個變量來引用這個返回結果,這樣在獨立時鍾域的定義外也能使用。例如引用最后返回的模塊:

class MultiClockModule extends Module {
   val io = IO(new Bundle {
       val clockB = Input(Clock())
       val resetB = Input(Bool())
       val stuff = Input(Bool())
   })
   
   val clockB_child = withClockAndReset(io.clockB, io.resetB) {
       Module(new ChildModule)
    }

   clockB_child.io.in := io.stuff  

  如果傳名參數全都是定義,最后沒有表達式用於返回,那么apply的返回結果類型自然就是Unit。此時,外部不能訪問獨立時鍾域里的任何內容。例如把上個例子改成如下代碼:

class MultiClockModule extends Module {
   val io = IO(new Bundle {
       val clockB = Input(Clock())
       val resetB = Input(Bool())
       val stuff = Input(Bool())
   })
   
   val clockB_child = withClockAndReset(io.clockB, io.resetB) {
       val m = Module(new ChildModule)
    }

   clockB_child.m.io.in := io.stuff  

  現在,被例化的模塊不是作為返回結果,而是變成了變量m的引用對象,故而傳名參數是只有定義、沒有有用的返回值的空函數。如果編譯這個模塊,就會得到“沒有相關成員”的錯誤信息: 

[error] /home/esperanto/chisel-template/src/main/scala/module.scala:42:16: value m is not a member of Unit
[error]   clockB_child.m.io.in := io.stuff
[error]                ^ 

  如果獨立時鍾域有多個變量要與外部交互,則應該在模塊內部的頂層定義全局的線網,讓所有時鍾域都能訪問。

  除了單例對象withClockAndReset,還有單例對象withClock和withReset,分別用於構建只有獨立時鍾和只有獨立復位信號的作用域,三者的語法是一樣的。

三、使用時鍾負沿和低有效的復位信號
  默認情況下,聲明的時序元件都是以時鍾的正沿和高有效的復位信號作為敏感變量,但是在多時鍾域的語法里,可以改變其行為。復位信號比較簡單,只需要加上取反符號或邏輯非符號。時鍾信號稍微麻煩一些,需要先用asUInt方法把Clock類型轉換成UInt類型,再用toBool轉換成Bool類型,此時可以加上取反符號或邏輯非符號,最后再用asClock變回Clock類型。例如:

// negclkrst.scala
package test

import chisel3._
import chisel3.experimental._

class NegativeClkRst extends RawModule {
val io = IO(new Bundle {
val in = Input(UInt(4.W))
val myClk = Input(Clock())
val myRst = Input(Bool())
val out = Output(UInt(4.W))
})

withClockAndReset((~io.myClk.asUInt.toBool).asClock, ~io.myRst) {
val temp = RegInit(0.U(4.W))
temp := io.in
io.out := temp
}
}

object NegClkRstGen extends App {
chisel3.Driver.execute(args, () => new NegativeClkRst)
}
  它生成的Verilog主要是:

// NegativeClkRst.v
module NegativeClkRst(
input [3:0] io_in,
input io_myClk,
input io_myRst,
output [3:0] io_out
);
wire _T; // @[negclkrst.scala 14:32]
wire _T_2; // @[negclkrst.scala 14:22]
wire _T_3; // @[negclkrst.scala 14:47]
wire _T_4; // @[negclkrst.scala 14:56]
reg [3:0] _T_5; // @[negclkrst.scala 15:23]
assign _T = $unsigned(io_myClk); // @[negclkrst.scala 14:32]
assign _T_2 = ~ _T; // @[negclkrst.scala 14:22]
assign _T_3 = _T_2; // @[negclkrst.scala 14:47]
assign _T_4 = ~ io_myRst; // @[negclkrst.scala 14:56]
assign io_out = _T_5; // @[negclkrst.scala 17:12]

always @(posedge _T_3) begin
if (_T_4) begin
_T_5 <= 4'h0;
end else begin
_T_5 <= io_in;
end
end
endmodule
四、示例:異步FIFO
  在跨時鍾域設計中,經常需要使用異步FIFO來同步不同時鍾域的數據傳輸。下面是筆者自己編寫的一個異步FIFO例子,數據位寬和深度都是參數化的,讀、寫地址指針的交互采用格雷碼和兩級寄存器采樣,以便改善亞穩態。通過在Vivado 2018.3里綜合后,可以得到以BRAM為存儲器的FIFO。

// FIFO.scala
package fifo

import chisel3._
import chisel3.util._
import chisel3.experimental._

class FIFO(width: Int, depth: Int) extends RawModule {
val io = IO(new Bundle {
// write-domain
val dataIn = Input(UInt(width.W))
val writeEn = Input(Bool())
val writeClk = Input(Clock())
val full = Output(Bool())
// read-domain
val dataOut = Output(UInt(width.W))
val readEn = Input(Bool())
val readClk = Input(Clock())
val empty = Output(Bool())
// reset
val systemRst = Input(Bool())
})

val ram = SyncReadMem(1 << depth, UInt(width.W)) // 2^depth
val writeToReadPtr = Wire(UInt((depth + 1).W)) // to read clock domain
val readToWritePtr = Wire(UInt((depth + 1).W)) // to write clock domain

// write clock domain
withClockAndReset(io.writeClk, io.systemRst) {
val binaryWritePtr = RegInit(0.U((depth + 1).W))
val binaryWritePtrNext = Wire(UInt((depth + 1).W))
val grayWritePtr = RegInit(0.U((depth + 1).W))
val grayWritePtrNext = Wire(UInt((depth + 1).W))
val isFull = RegInit(false.B)
val fullValue = Wire(Bool())
val grayReadPtrDelay0 = RegNext(readToWritePtr)
val grayReadPtrDelay1 = RegNext(grayReadPtrDelay0)

binaryWritePtrNext := binaryWritePtr + (io.writeEn && !isFull).asUInt
binaryWritePtr := binaryWritePtrNext
grayWritePtrNext := (binaryWritePtrNext >> 1) ^ binaryWritePtrNext
grayWritePtr := grayWritePtrNext
writeToReadPtr := grayWritePtr
fullValue := (grayWritePtrNext === Cat(~grayReadPtrDelay1(depth, depth - 1), grayReadPtrDelay1(depth - 2, 0)))
isFull := fullValue

when(io.writeEn && !isFull) {
ram.write(binaryWritePtr(depth - 1, 0), io.dataIn)
}

io.full := isFull
}
// read clock domain
withClockAndReset(io.readClk, io.systemRst) {
val binaryReadPtr = RegInit(0.U((depth + 1).W))
val binaryReadPtrNext = Wire(UInt((depth + 1).W))
val grayReadPtr = RegInit(0.U((depth + 1).W))
val grayReadPtrNext = Wire(UInt((depth + 1).W))
val isEmpty = RegInit(true.B)
val emptyValue = Wire(Bool())
val grayWritePtrDelay0 = RegNext(writeToReadPtr)
val grayWritePtrDelay1 = RegNext(grayWritePtrDelay0)

binaryReadPtrNext := binaryReadPtr + (io.readEn && !isEmpty).asUInt
binaryReadPtr := binaryReadPtrNext
grayReadPtrNext := (binaryReadPtrNext >> 1) ^ binaryReadPtrNext
grayReadPtr := grayReadPtrNext
readToWritePtr := grayReadPtr
emptyValue := (grayReadPtrNext === grayWritePtrDelay1)
isEmpty := emptyValue

io.dataOut := ram.read(binaryReadPtr(depth - 1, 0), io.readEn && !isEmpty)
io.empty := isEmpty
}
}

object FIFOGen extends App {
chisel3.Driver.execute(args, () => new FIFO(args(0).toInt, args(1).toInt))
}
五、總結
  本章介紹了如何用Chisel設計多時鍾域電路,重點是學會apply方法的使用,以及對第二個參數列表的理解。要注意獨立時鍾域里只有最后的表達式能被作為返回值給變量引用,並被外部訪問,其它的定義都是對外不可見的。

第二十四章 Chisel基礎——其它議題

  本章講解的內容比較繁雜,沒有一個統一的中心思想。這些問題與實際編程沒有太大關系,但是讀者需要稍微留意。

一、動態命名模塊
  Chisel可以動態定義模塊的名字,也就是轉成Verilog時的模塊名不使用定義的類名,而是使用重寫的desiredName方法的返回字符串。模塊和黑盒都適用。例如:

class Coffee extends BlackBox {
   val io = IO(new Bundle {
       val I = Input(UInt(32.W))
       val O = Output(UInt(32.W))
   })
   override def desiredName = "Tea"
}

class Salt extends Module {
   val io = IO(new Bundle {})
   val drink = Module(new Coffee)
   override def desiredName = "SodiumMonochloride"
}

  對應的Verilog為:

module SodiumMonochloride(
     input   clock,
     input   reset
);
     wire [31:0] drink_O;
     wire [31:0] drink_I;
     Tea drink (
         .O(drink_O),
         .I(drink_I)
     );
     assign drink_I = 32'h0;
endmodule 

二、動態修改端口
  Chisel通過引入Scala的Boolean參數、可選值以及if語句可以創建出可選的端口,在例化該模塊時可以通過控制Boolean入參來生成不同的端口。例如:

class ModuleWithOptionalIOs(flag: Boolean) extends Module {
   val io = IO(new Bundle {
       val in = Input(UInt(12.W))
       val out = Output(UInt(12.W))
       val out2 = if (flag) Some(Output(UInt(12.W))) else None
  })
  
   io.out := io.in
   if(flag) {
     io.out2.get := io.in
   }

  注意,端口應該包裝成可選值,這樣不需要端口時就能用對象None代替,編譯出來的Verilog就不會生成這個端口。在給可選端口賦值時,應該先用可選值的get方法把端口解放出來。這里也體現了可選值語法的便利性。

三、生成正確的塊內信號名
  一般情況下,在when、withClockAndReset等語句塊里定義的信號(線網和寄存器),轉換成Verilog時不會生成正確的變量名。例如:

// name.scala
package test

import chisel3._

class TestMod extends Module {
val io = IO(new Bundle {
val a = Input(Bool())
val b = Output(UInt(4.W))
})
when (io.a) {
val innerReg = RegInit(5.U(4.W))
innerReg := innerReg + 1.U
io.b := innerReg
} .otherwise {
io.b := 10.U
}
}

object NameGen extends App {
chisel3.Driver.execute(args, () => new TestMod)
}
  它對應生成的Verilog為:

// TestMod.v
module TestMod(
input clock,
input reset,
input io_a,
output [3:0] io_b
);
reg [3:0] _T;
wire [3:0] _T_2;
assign _T_2 = _T + 4'h1;
assign io_b = io_a ? _T : 4'ha;
always @(posedge clock) begin
if (reset) begin
_T <= 4'h5;
end else begin
_T <= _T_2;
end
end
endmodule
  注意看,when語句塊里聲明的寄存器innerReg,被命名成了“_T”。

  如果想讓名字正確,則需要在build.sbt文件里加上:

addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) 

  同時,設計代碼里需要加上傳遞給Firrtl的注解:

...
import chisel3._
import chisel3.experimental.chiselName

@chiselName
class TestMod extends Module {
...

  這樣,對應的Verilog文件就有了正確的寄存器名字:

// TestMod.v
module TestMod(
input clock,
input reset,
input io_a,
output [3:0] io_b
);
reg [3:0] innerReg;
wire [3:0] _T_1;
assign _T_1 = innerReg + 4'h1;
assign io_b = io_a ? innerReg : 4'ha;
always @(posedge clock) begin
if (reset) begin
innerReg <= 4'h5;
end else begin
innerReg <= _T_1;
end
end
endmodule
四、拆包一個值(給拼接變量賦值)
  在Verilog中,左側的賦值對象可以是一個拼接起多個變量的值,例如:

wire [1:0] a;
wire [3:0] b;
wire [2:0] c;
wire [8:0] z = [...];
assign {a, b, c} = z;

  在Chisel里不能直接這么賦值。最簡單的做法是先定義一個a、b、c組成的Bundle,高位定義在前面,然后創建線網z。線網z可以被直接賦值,被賦值后,z再調用方法asTypeOf。該方法接收一個Data類型的參數,可以把調用對象強制轉換成參數的類型並返回,在這里也就是把a、b、c組成的Bundle作為參數。注意,返回結果是一個新對象,並沒有直接修改調用對象z。強制轉換必須保證不會出錯。例如:

class MyBundle extends Bundle {
  val a = UInt(2.W)
  val b = UInt(4.W)
  val c = UInt(3.W)
}

val z = Wire(UInt(9.W))
z := ...
val unpacked = z.asTypeOf(new MyBundle)
unpacked.a
unpacked.b
unpacked.c

五、子字賦值
  在Verilog中,可以直接給向量的某幾位賦值。同樣,Chisel受限於Scala,不支持直接給Bits類型的某幾位賦值。子字賦值的可行辦法是先調用Bits類型的toBools方法。該方法根據調用對象的0、1排列返回一個相應的Seq[Bool]類型的結果,並且低位在序列里的下標更小,比如第0位的下標就是0、第n位的下標就是n。然后用這個Seq[Bool]對象配合VecInit構成一個向量,此時就可以給單個比特賦值。注意,必須都是Bool類型,要注意賦值前是否需要類型轉換。子字賦值完成后,Bool向量再調用asUInt、asSInt方法轉換回來。例如:

class TestModule extends Module {
   val io = IO(new Bundle {
       val in = Input(UInt(10.W))
       val bit = Input(Bool())
       val out = Output(UInt(10.W))
   })
   val bools = VecInit(io.in.toBools)
   bools(0) := io.bit
   io.out := bools.asUInt
}

六、參數化的Bundle
  因為Chisel是基於Scala和JVM的,所以當一個Bundle類的對象用於創建線網、IO等操作時,它並不是把自己作為參數,而是交出自己的一個復制對象,也就是說編譯器需要知道如何來創建當前Bundle對象的復制對象。Chisel提供了一個內部的API函數cloneType,任何繼承自Data的Chisel對象,要復制自身時,都是由cloneType負責返回該對象的復制對象。它對應的用戶API則是chiselTypeOf。

  當自定義的Bundle的主構造方法沒有參數時,Chisel會自動推斷出如何構造Bundle對象的復制,原因很簡單,因為構造一個新的復制對象不需要任何參數,僅僅使用關鍵字new就行了。但是,如果自定義的Bundle帶有參數列表,那么Chisel就無法推斷了,因為傳遞進去的參數可以是任意的,並不一定就是完全地復制。此時需要用戶自己重寫Bundle類的cloneType方法,其形式為:

override def cloneType = (new CustomBundle(arguments)).asInstanceOf[this.type]

例如:

class ExampleBundle(a: Int, b: Int) extends Bundle {
   val foo = UInt(a.W)
   val bar = UInt(b.W)
   override def cloneType = (new ExampleBundle(a, b)).asInstanceOf[this.type]
}

class ExampleBundleModule(btype: ExampleBundle) extends Module {
   val io = IO(new Bundle {
       val out = Output(UInt(32.W))
       val b = Input(chiselTypeOf(btype))
   })
   io.out := io.b.foo + io.b.bar
}

class Top extends Module {
   val io = IO(new Bundle {
       val out = Output(UInt(32.W))
       val in = Input(UInt(17.W))
   })
   val x = Wire(new ExampleBundle(31, 17))
   x := DontCare
   val m = Module(new ExampleBundleModule(x))
   m.io.b.foo := io.in
   m.io.b.bar := io.in
   io.out := m.io.out
}

  例子中的ExampleBundle有兩個參數,編譯器無法在復制它的對象時推斷出這兩個參數是什么,所以重寫的cloneType方法需要用戶手動將兩個參數傳入,而且用asInstanceOf[this.type]保證返回對象的類型與this對象是一樣的。

  如果沒有這個重寫的cloneType的方法,編譯器會提示把ExampleBundle的參數變成固定的和可獲取的,以便cloneType方法能被自動推斷,即非參數化Bundle不需要重寫該方法。此外,變量x必須要用Wire包住ExampleBundle的對象,否則x在傳遞給ExampleBundleModule時,編譯器會提示應該傳入一個硬件而不是裸露的Chisel類型,並詢問是否遺漏了Wire(_)或IO(_)。與之相反,“Input(chiselTypeOf(btype))”中的chiselTypeOf方法也必不可少,因為此時傳入的btype是一個硬件,編譯器會提示Input的參數應該是Chisel類型而不是硬件,需要使用方法chiselTypeOf解除包住ExampleBundle對象的Wire。

  這個例子中,cloneType在構造復制對象時,僅僅是傳遞了對應的參數,這就會構造一個一模一樣的新對象。為了進一步說明cloneType的作用,再來看一個“別扭”的例子:

class TestBundle(a: Int, b: Int) extends Bundle {
  val A = UInt(a.W)
  val B = UInt(b.W)
  override def cloneType = (new TestBundle(5*b, a+1)).asInstanceOf[this.type]
}

class TestModule extends Module {
  val io = IO(new Bundle {
    val x = Input(UInt(10.W))
    val y = Input(UInt(5.W))
    val out = Output(new TestBundle(10, 5))
  })

  io.out.A := io.x
  io.out.B := io.y
}

  這里,cloneType在構造復制對象前,先把形參a、b做了一些算術操作,再傳遞給TestBundle的主構造方法使用。按常規思路,代碼“Output(new TestBundle(10, 5))”應該構造兩個輸出端口:10bit的A和5bit的B。但實際生成的Verilog如下:

module TestModule(
  input         clock,
  input         reset,
  input  [9:0]  io_x,
  input  [4:0]  io_y,
  output [24:0] io_out_A,
  output [10:0] io_out_B
);
  assign io_out_A = {{15'd0}, io_x};
  assign io_out_B = {{6'd0}, io_y};
endmodule 

  也就是說,“Output(new TestBundle(10, 5))”的真正形式應該是“Output((new TestBundle(10, 5)).cloneType)”,即Output的真正參數是對象TestBundle(10, 5)的cloneType方法構造出來的對象。而cloneType方法是用實參“5 * 5(b)”和“10(a) + 1”來分別賦予形參a和b,因此得出A的實際位寬是25bit,B的實際位寬是11bit。 

七、Chisel泛型
  Chisel本質上還是Scala,所以Chisel的泛型就是使用Scala的泛型語法,這使得電路參數化更加方便。無論是Chisel的函數還是模塊,都可以用類型參數和上、下界來泛化。在例化模塊時,傳入不同類型的參數,就可能會產生不同的電路,而無需編寫額外的代碼,當然前提是邏輯、類型必須正確。

  要熟練使用泛型比較麻煩,所需素材很多,這里就不再介紹。讀者可以通過閱讀Chisel的源碼來學習它是如何進行泛型的。

八、未驅動的線網
  Chisel的Invalidate API支持檢測未驅動的輸出型IO以及定義不完整的Wire定義,在編譯成firrtl時會產生“not fully initialized”錯誤。換句話說,就是組合邏輯的真值表不完整,不能綜合出完整的電路。如果確實需要不被驅動的線網,則可以賦給一個DontCare對象,這會告訴Firrtl編譯器,該線網故意不被驅動。轉換成的Verilog會賦予該信號全0值,甚至把邏輯全部優化掉,所以謹慎使用。例如:

val io = IO(new Bundle {
    val outs = Output(Vec(10, Bool()))
})
io.outs <> DontCare 

  檢查機制是由CompileOptions.explicitInvalidate控制的,如果把它設置成true就是嚴格模式(執行檢查),設置成false就是不嚴格模式(不執行檢查)。開關方法有兩種,第一種是定義一個抽象的模塊類,由抽象類設置,其余模塊都繼承自這個抽象類。例如:

// 嚴格
abstract class ExplicitInvalidateModule extends Module()(chisel3.core.ExplicitCompileOptions.NotStrict.copy(explicitInvalidate = true))

// 不嚴格
abstract class ImplicitInvalidateModule extends Module()(chisel3.core.ExplicitCompileOptions.Strict.copy(explicitInvalidate = false)) 

  第二種方法是在每個模塊里重寫compileOptions字段,由該字段設置編譯選項。例如:

// 嚴格
class MyModule extends Module {
  override val compileOptions = chisel3.core.ExplicitCompileOptions.NotStrict.copy(explicitInvalidate = true)
  ...

// 不嚴格
class MyModule extends Module {
  override val compileOptions = chisel3.core.ExplicitCompileOptions.Strict.copy(explicitInvalidate = false)
  ...
}

九、總結
  本章內容是編寫Chisel時的常見問題匯總。最常出現的錯誤就是“not fully initialized”,讀者應該根據提示信息查看設計中是否有情況沒覆蓋全的組合邏輯。

第二十五章 Chisel進階——隱式參數的應用

  用Chisel編寫的CPU,比如Rocket-Chip、RISCV-Mini等,都有一個特點,就是可以用一個配置文件來裁剪電路。這利用了Scala的模式匹配、樣例類、偏函數、可選值、隱式定義等語法。本章內容就是來為讀者詳細解釋它的工作機制。

一、相關定義
  要理解隱式參數是如何配置電路的,應該先了解與配置相關的定義。在閱讀代碼之前,為了能快速讀懂、深入理解,讀者最好復習一下模式匹配和隱式定義兩章的內容。

  下面是來自於開源處理器RISCV-Mini的代碼:

// Config.scala
// See LICENSE.SiFive for license details.

package freechips.rocketchip.config

abstract class Field[T] private (val default: Option[T])
{
def this() = this(None)
def this(default: T) = this(Some(default))
}

abstract class View {
final def apply[T](pname: Field[T]): T = apply(pname, this)
final def apply[T](pname: Field[T], site: View): T = {
val out = find(pname, site)
require (out.isDefined, s"Key ${pname} is not defined in Parameters")
out.get
}

final def lift[T](pname: Field[T]): Option[T] = lift(pname, this)
final def lift[T](pname: Field[T], site: View): Option[T] = find(pname, site).map(_.asInstanceOf[T])

protected[config] def find[T](pname: Field[T], site: View): Option[T]
}

abstract class Parameters extends View {
final def ++ (x: Parameters): Parameters =
new ChainParameters(this, x)

final def alter(f: (View, View, View) => PartialFunction[Any,Any]): Parameters =
Parameters(f) ++ this

final def alterPartial(f: PartialFunction[Any,Any]): Parameters =
Parameters((_,_,_) => f) ++ this

final def alterMap(m: Map[Any,Any]): Parameters =
new MapParameters(m) ++ this

protected[config] def chain[T](site: View, tail: View, pname: Field[T]): Option[T]
protected[config] def find[T](pname: Field[T], site: View) = chain(site, new TerminalView, pname)
}

object Parameters {
def empty: Parameters = new EmptyParameters
def apply(f: (View, View, View) => PartialFunction[Any,Any]): Parameters = new PartialParameters(f)
}

class Config(p: Parameters) extends Parameters {
def this(f: (View, View, View) => PartialFunction[Any,Any]) = this(Parameters(f))

protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = p.chain(site, tail, pname)
override def toString = this.getClass.getSimpleName
def toInstance = this
}

// Internal implementation:

private class TerminalView extends View {
def find[T](pname: Field[T], site: View): Option[T] = pname.default
}

private class ChainView(head: Parameters, tail: View) extends View {
def find[T](pname: Field[T], site: View) = head.chain(site, tail, pname)
}

private class ChainParameters(x: Parameters, y: Parameters) extends Parameters {
def chain[T](site: View, tail: View, pname: Field[T]) = x.chain(site, new ChainView(y, tail), pname)
}

private class EmptyParameters extends Parameters {
def chain[T](site: View, tail: View, pname: Field[T]) = tail.find(pname, site)
}

private class PartialParameters(f: (View, View, View) => PartialFunction[Any,Any]) extends Parameters {
protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = {
val g = f(site, this, tail)
if (g.isDefinedAt(pname)) Some(g.apply(pname).asInstanceOf[T]) else tail.find(pname, site)
}
}

private class MapParameters(map: Map[Any, Any]) extends Parameters {
protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = {
val g = map.get(pname)
if (g.isDefined) Some(g.get.asInstanceOf[T]) else tail.find(pname, site)
}
}
二、Field[T]類
  位置:6-10行
  抽象類Field[T]是一個類型構造器,它需要根據類型參數T來生成不同的類型。而T取決於傳入的參數——可選值default:Option[T]的類型。例如,如果傳入一個Some(10),那么所有的T都可以確定為Int。

  Field[T]只有一個公有val字段,即主構造方法的參數default:Option[T]。此外,主構造方法是私有的,外部只能訪問兩個公有的輔助構造方法“def this()”和“def this(default: T)”。第一個輔助構造方法不接收參數,所以會構造一個可選值字段是None的對象;第二個輔助構造方法接受一個T類型的參數,然后把參數打包成可選值Some(default): Option[T],並把它賦給對象的可選值字段。

  事實上,Field[T]是抽象的,我們並不能通過“new Field(參數)”來構造一個對象,所以它只能用於繼承給子類、子對象或子特質。之所以定義抽象類Field[T],是為了后面構造出它的樣例子對象,並把這些樣例對象用於偏函數。例如,構造一個“case object isInt extends Field[Int]”,然后把樣例對象isInt用於偏函數“case isInt => …”。

  為什么要把isInt構造成Field[Int]類型,而不是直接的Int類型呢?首先,我們想要偏函數的參數是一個常量,這樣才能構成常量模式的模式匹配,一個常量模式控制一條配置選項。所以,要么定義一個樣例對象,要么定義一個普通的Int對象比如1。這里我們選擇定義樣例對象,因為不僅會有Int類型,還可能有其他的自定義類型,它們可能是抽象的,無法直接創建實例對象。而且,用一個普通的Int對象來做模式匹配,會顯得不那么獨一無二。為了方便統一,全部構造成Field[T]類型的樣例對象。例如,“case object isA extends Field[A]”、“case object isB extends Field[B]”等等。

  其次,為什么要引入Field[Int]而不是“case object isInt extends Int”呢?因為Scala的基本類型Int、Float、Double、Boolean等都是final修飾的抽象類,不能被繼承。

三、View類
  位置:12-24行
  我們只需要關心抽象類View的兩個apply方法。其中第一個apply方法只是調用了第二個apply方法,重點在第二個apply方法。第二個apply方法調用了View的find方法,而find方法是抽象的,目前只知道它的返回結果是一個可選值。View的子類應該實現這個find方法,並且find方法會影響apply方法。如果不同的子類實現了不同行為的find方法,那么apply方法可能也會有不同的行為。

  我們可以大致推測一下,參數pname的類型是Field[T],那么很有可能是一個樣例對象。而find方法應該就是在參數site里面找到是否包含pname,如果包含就返回一個可選值,否則就返回None。根據require函數可以印證這一點:如果site里面沒有pname,那么結果out就是None,out.isDefined就是false,require函數產生異常,並輸出字符串“Key ${pname} is not defined in Parameters”,即找不到pname;反之,out.isDefined就是true,require函數通過,不會輸出字符串,並執行后面的out.get,即把可選值解開並返回。

四、Parameters類及伴生對象
  位置:26-46行
  抽象類Parameters是View的子類,它的確實現了find方法,但是又引入了抽象的chain方法,所以我們只需要關心Parameters的子類是如何實現chain方法的。另外四個方法不是重點,但是大致可以推測出來是在把兩個Parameters類的對象拼接起來。

  此外,出現了新的類TerminalView(位置58-60行)。TerminalView類也是View的子類,它也實現了find方法,只不過是直接返回pname的可選值字段。可以做如下推測:Parameters類的find方法給chain方法傳遞了三個參數——site、一個TerminalView實例對象和pname,它既可以在site里尋找是否包含pname,也可以用TerminalView的find方法直接返回pname。

  Parameters類的伴生對象里定義了一個apply工廠方法,該方法構造了一個PartialParameters對象(位置74-79行)。

  首先,PartialParameters類是Parameters的子類,所以工廠方法的返回類型可以是Parameters但實際返回結果是一個子類對象。

  其次,工廠方法的入參f是一個理解難點。f的類型是一個函數,這個函數有三個View類型的入參,然后返回一個偏函數,即f是一個返回偏函數的函數。根據偏函數的介紹內容,我們可以推測出f返回的偏函數應該是一系列的case語句,用於模式匹配。

  接着,前面說過,我們只需要關心Parameters的子類是如何實現chain方法的,而子類PartialParameters則實現了chain方法的一個版本。這個chain方法首先把PartialParameters的構造參數f返回的偏函數用g來引用,也就是說,g現在就是那個偏函數。至於f的三個入參site、this和tail則不是重點。然后,g.isDefinedAt(pname)表示在偏函數的可行域里尋找是否包含pname,如果有的話,則執行相應的case語句;否則,就用參數tail的find方法。結合代碼定義,參數tail其實就是TerminalView的實例對象,它的find方法就是直接返回pname的可選值字段。這與推測內容相吻合。

五、Config類
  位置:48-54行
  首先,Config類也是Parameters的子類。它可以通過主構造方法接收一個Parameters類型的實例對象來構造一個Config類型的實例對象,或者通過輔助構造方法接收一個函數f來間接構造一個Config類型的實例對象。觀察這個輔助構造方法,它其實先調用了Parameters的工廠方法,也就是利用函數f先構造了一個PartialParameters類型的對象(是Parameters的子類型),再用這個PartialParameters類型的對象去運行主構造方法。

  其次,我們仍然需要知道chain方法是如何實現的。這里,Config的chain方法是由構造時的參數p: Parameters決定的。如果一個Config的對象是用輔助構造方法和函數f構造的,那么參數p就是一個PartialParameters的對象,構造出來的Config對象的chain方法實際上運行的是PartialParameters的chain方法。

六、MiniConfig類
  前面講解的內容相當於類庫里預先定義好的內容。要配置自定義的電路,還需要一個自定義的類。比如,處理器RISCV-Mini就定義了下面的MiniConfig類:

// See LICENSE for license details.

package mini

import chisel3.Module
import freechips.rocketchip.config.{Parameters, Config}
import junctions._

class MiniConfig extends Config((site, here, up) => {
// Core
case XLEN => 32
case Trace => true
case BuildALU => (p: Parameters) => Module(new ALUArea()(p))
case BuildImmGen => (p: Parameters) => Module(new ImmGenWire()(p))
case BuildBrCond => (p: Parameters) => Module(new BrCondArea()(p))
// Cache
case NWays => 1 // TODO: set-associative
case NSets => 256
case CacheBlockBytes => 4 * (here(XLEN) >> 3) // 4 x 32 bits = 16B
// NastiIO
case NastiKey => new NastiParameters(
idBits = 5,
dataBits = 64,
addrBits = here(XLEN))
}
)
  MiniConfig類是Config的子類,其實它沒有添加任何定義,只是給超類Config傳遞了所需要的構造參數。第五點講了,Config有兩種構造方法,這里是用了給定函數f的方法。那么函數f是什么呢?函數f的類型是“(View, View, View) => PartialFunction[Any,Any]”,這里給出的三個View類型入參是site、here和up。我們目前只知道site、here和up是View類型的對象,具體是什么,還無法確定,也無需關心。重點在於返回的偏函數是什么。偏函數是用花括號包起來的9個case語句,這呼應了我們前面講過的用case語句組構造偏函數。我們可以推測case后面的XLEN、Trace等,就是一系列的Filed[T]類型的樣例對象,也就是第二點推測的。

  那么如何利用MiniConfig類呢?我們可以推測這個類包含了riscv-mini核全部的配置信息,然后看看處理器RISCV-Mini的頂層文件是如何描述的:

val params = (new MiniConfig).toInstance
val chirrtl = firrtl.Parser.parse(chisel3.Driver.emit(() => new Tile(params)))

  這里,也就是直接構造了一個MiniConfig的實例,並把它傳遞給了需要它的頂層模塊Tile。

七、MiniConfig的運行原理
  我們來看Tile模塊的定義:

class Tile(tileParams: Parameters) extends Module with TileBase {
  implicit val p = tileParams
  val io     = IO(new TileIO)
  val core   = Module(new Core)
  val icache = Module(new Cache)
  val dcache = Module(new Cache)
  val arb    = Module(new MemArbiter)

  io.host <> core.io.host
  core.io.icache <> icache.io.cpu
  core.io.dcache <> dcache.io.cpu
  arb.io.icache <> icache.io.nasti
  arb.io.dcache <> dcache.io.nasti
  io.nasti <> arb.io.nasti
}

  首先,Tile模塊需要一個Parameters類型的參數,我們給了一個MiniConfig的實例,而MiniConfig繼承自Config,Config繼承自Parameters,所以這是合法的。

  然后,Tile模塊把入參賦給了隱式變量p。參考隱式定義的內容,這個隱式變量會被編譯器傳遞給當前層次所有未顯式給出的隱式參數。查看其他代碼的定義,也就是后面實例化的TileIO、Core、Cache和MemArbiter需要隱式參數。由於沒有顯式給出隱式參數,那么它們都會接收這個隱式變量p,即MiniConfig實例。

  以Core模塊為例:

class Core(implicit val p: Parameters) extends Module with CoreParams {
  val io = IO(new CoreIO)
  val dpath = Module(new Datapath) 
  val ctrl  = Module(new Control)

  io.host <> dpath.io.host
  dpath.io.icache <> io.icache
  dpath.io.dcache <> io.dcache
  dpath.io.ctrl <> ctrl.io

  可以看到,Core模塊確實需要接收一個隱式的Parameters類型的參數。

  再來看Core混入的特質CoreParams:

 abstract trait CoreParams {
     implicit val p: Parameters
     val xlen = p(XLEN)
}

  這個特質有未實現的抽象成員,即隱式參數p。抽象成員需要子類給出具體的實現,這里也就是Core模塊接收的MiniConfig實例。

  那么“val xlen = p(XLEN)”意味着什么呢?我們知道,p是一個MiniConfig的實例對象,它繼承了超類View的apply方法。查看apply的定義,也就是調用了:

final def apply[T](pname: Field[T]): T = apply(pname, this)

和 

final def apply[T](pname: Field[T], site: View): T = {
  val out = find(pname, site)
  require (out.isDefined, s"Key ${pname} is not defined in Parameters")
  out.get
}

  而XLEN被定義為:

case object XLEN extends Field[Int] 

  前面推測了XLEN是Field[T]類型的樣例對象。現在看到定義,確實如此。

  即“val xlen = p(XLEN)”相當於“val xlen = p.apply(XLEN, p)”。這里的this也就是把對象p自己傳入。緊接着,apply方法需要調用find方法,即“val out = find(XLEN, p)”。而MiniConfig繼承了Parameters的find和chain方法,也就是:

protected[config] def chain[T](site: View, tail: View, pname: Field[T]): Option[T]
protected[config] def find[T](pname: Field[T], site: View) = chain(site, new TerminalView, pname) 

  而chain方法繼承自Config類:

protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = p.chain(site, tail, pname) 

  注意這里的p是用MiniConfig傳遞給超類的函數f構造的PartialParameters對象,不是MiniConfig對象自己。即:“val out = (new PartialParameters((site, here, up) => {…})).chain(p, new TerminalView, XLEN)”。

  再來看PartialParameters類的chain方法的具體行為:

protected[config] def chain[T](site: View, tail: View, pname: Field[T]) = {
   val g = f(site, this, tail)
   if (g.isDefinedAt(pname)) Some(g.apply(pname).asInstanceOf[T]) else tail.find(pname, site)
}

  注意,這里的f就是PartialParameters的構造參數,也就是MiniConfig傳遞給超類Config的函數:

(site, here, up) => {
    // Core
    case XLEN => 32
    case Trace => true
    case BuildALU    => (p: Parameters) => Module(new ALUArea()(p))
    case BuildImmGen => (p: Parameters) => Module(new ImmGenWire()(p))
    case BuildBrCond => (p: Parameters) => Module(new BrCondArea()(p))
    // Cache
    case NWays => 1 // TODO: set-associative
    case NSets => 256 
    case CacheBlockBytes => 4 * (here(XLEN) >> 3) // 4 x 32 bits = 16B
    // NastiIO
    case NastiKey => new NastiParameters(
        idBits   = 5,
        dataBits = 64,
        addrBits = here(XLEN))
}

  至此,我們就可以確定site = p(MiniConfig對象自己),here = new PartialParameters((site, here, up) => {…})(注意這里的this應該是chain的調用對象),up = new TerminalView。

  而g就是由花括號里的9個case語句組成的偏函數。那么g.isDefinedAt(XLEN)就是true,最終chain返回的結果就是“Some(g.apply(XLEN).asInstanceOf[Int])”即可選值Some(32),注意XLEN是Field[Int]類型的,確定了T是Int。

  得到了“val out = Some(32)”后,apply方法的require就能通過,同時返回結果“out.get”即32。最終,“val xlen = p(XLEN)”相當於“val xlen = 32”。也就是說,在混入特質CoreParams的地方,如果有一個隱式Parameters變量是MiniConfig的對象,就會得到一個名為“xlen”的val字段,它的值是32。

  關於“here(XLEN)”,因為here已經確定是由f構成的PartialParameters對象,那么套用前述過程,其實也是返回32。

  假設偏函數的可行域內沒有XLEN,那么chain就會執行“(new TerminalView).find(XLEN, p)”,也就是返回XLEN.default。因為XLEN在定義時沒給超類Filed[Int]傳遞參數,所以會調用Filed[T]的第一個輔助構造函數:

def this() = this(None) 

  導致XLEN.default = None。這使得“val out = None”,apply方法的require產生異常報錯,並打印信息“Key XLEN is not defined in Parameters”。注意字符串插值會把${pname}求值成XLEN。

  再來看Core模塊里的CoreIO:

abstract class CoreBundle(implicit val p: Parameters) extends Bundle with CoreParams

class HostIO(implicit p: Parameters) extends CoreBundle()(p) {
  val fromhost = Flipped(Valid(UInt(xlen.W)))
  val tohost   = Output(UInt(xlen.W))
}

class CoreIO(implicit p: Parameters) extends CoreBundle()(p) {
  val host = new HostIO
  val icache = Flipped((new CacheIO))
  val dcache = Flipped((new CacheIO))

  抽象類CoreBundle混入了特質CoreParams,並接收HostIO傳來的隱式參數——MiniConfig的對象(HostIO來自於CoreIO ,CoreIO來自於Core,Core來自於Tile),所以HostIO有了字段“val xlen = 32”,它定義的端口位寬也就是32位的了。 

  對於偏函數其他的case語句,原理一樣:

case object Trace extends Field[Boolean]
case object BuildALU extends Field[Parameters => ALU]
case object BuildImmGen extends Field[Parameters => ImmGen]
case object BuildBrCond extends Field[Parameters => BrCond]
case object NWays extends Field[Int]
case object NSets extends Field[Int]
case object CacheBlockBytes extends Field[Int]
case object NastiKey extends Field[NastiParameters]

case class NastiParameters(dataBits: Int, addrBits: Int, idBits: Int)

if (p(Trace)) {
    printf("PC: %x, INST: %x, REG[%d] <- %x\n", ew_pc, ew_inst,
      Mux(regFile.io.wen, wb_rd_addr, 0.U),
      Mux(regFile.io.wen, regFile.io.wdata, 0.U))
  } 

val alu     = p(BuildALU)(p)
val immGen  = p(BuildImmGen)(p)
val brCond  = p(BuildBrCond)(p) 

val nWays  = p(NWays) // Not used...
val nSets  = p(NSets)
val bBytes = p(CacheBlockBytes) 

val nastiExternal = p(NastiKey)
val nastiXDataBits = nastiExternal.dataBits
val nastiWStrobeBits = nastiXDataBits / 8
val nastiXAddrBits = nastiExternal.addrBits
val nastiWIdBits = nastiExternal.idBits
val nastiRIdBits = nastiExternal.idBits
...... 

八、總結:如何自定義參數
  首先要導入第一點給出的文件,其次是像定義MiniConfig那樣定義自己的參數類,然后實例化參數類,並用隱式參數傳遞給相應的模塊。模塊在定義時,記得要留好隱式參數列表。

  如果當前作用域有隱式的參數類對象,那么用“val xxx = p(XXX)”參數化的字段就能根據隱式對象求得具體的值。改變隱式對象的內容,就能動態地定義像位寬這樣的關鍵字段。這樣裁剪設計時,只需要修改自定義參數類的偏函數,而不需要每個地方都去更改。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM