跳转至

博客

向 Rocket Chip 添加自定义调试信号

背景

最近在尝试把核心作为一个 Tile 加到 Rocket System 中,所以想要把核心之前自定义的调试信号接到顶层上去。Rocket System 自带的支持是 trace,也就是输出每个周期 retire 的指令信息,但和自定义的不大一样,所以研究了一下怎么添加自定义的调试信号,并且连接到顶层。

分析 Trace 信号连接方式

首先,观察 Rocket Chip 自己使用的 Trace 信号是如何连接到顶层的。在顶层上,可以找到使用的是 testchipip.CanHaveTraceIO:

trait CanHaveTraceIO { this: HasTiles =>
  val module: CanHaveTraceIOModuleImp

  // Bind all the trace nodes to a BB; we'll use this to generate the IO in the imp
  val traceNexus = BundleBridgeNexusNode[Vec[TracedInstruction]]()
  val tileTraceNodes = tiles.flatMap {
    case ext_tile: WithExtendedTraceport => None
    case tile => Some(tile)
  }.map { _.traceNode }

  tileTraceNodes.foreach { traceNexus := _ }
}

可以看到,它采用了 diplomacy 的 BundleBridgeNexusNode,把每个 tile 取出来,把它的 traceNode 接到 traceNexus 上。再看一下模块 CanHaveTraceIOModuleImp 是怎么实现的:

trait CanHaveTraceIOModuleImp { this: LazyModuleImpLike =>
  val outer: CanHaveTraceIO with HasTiles
  implicit val p: Parameters

  val traceIO = p(TracePortKey) map ( traceParams => {
    val extTraceSeqVec = (outer.traceNexus.in.map(_._1)).map(ExtendedTracedInstruction.fromVec(_))
    val tio = IO(Output(TraceOutputTop(extTraceSeqVec)))

    val tileInsts = ((outer.traceNexus.in) .map { case (tileTrace, _) => DeclockedTracedInstruction.fromVec(tileTrace) }

    // Since clock & reset are not included with the traced instruction, plumb that out manually
    (tio.traces zip (outer.tile_prci_domains zip tileInsts)).foreach { case (port, (prci, insts)) =>
      port.clock := prci.module.clock
      port.reset := prci.module.reset.asBool
      port.insns := insts
    }

    tio
  })
}

可以看到,它从 traceNexus 上接了若干的 trace 信号,然后通过 IO(TraceOutputTop()) 接到了顶层的输出信号。

再来看看 Rocket 是如何连接的,首先是 traceNode 的定义:

/** Node for the core to drive legacy "raw" instruction trace. */
val traceSourceNode = BundleBridgeSource(() => Vec(traceRetireWidth, new TracedInstruction()))
/** Node for external consumers to source a legacy instruction trace from the core. */
val traceNode: BundleBridgeOutwardNode[Vec[TracedInstruction]] = traceNexus := traceSourceNode

然后 Rocket Tile 实现的时候,把自己的 trace 接到 traceSourceNode 上:

outer.traceSourceNode.bundle <> core.io.trace

添加自定义调试信号

到这里,整个思路已经比较清晰了,我们只需要照猫画虎地做一个就行。比如要把自己的 Custom Debug 接口暴露出去,首先也是在 Tile 里面创建一个 SourceNode:

// expose debug
val customDebugSourceNode =
BundleBridgeSource(() => new CustomDebug())
val customDebugNode: BundleBridgeOutwardNode[CustomDebug] =
customDebugSourceNode

在 BaseTileModuleImp 里,进行信号的连接:

// expose debug
outer.customDebugSourceNode.bundle := core.io.debug

为了暴露到顶层,我们可以类似地做。在 Subsystem 中:

// expose debug
val customDebugNexus = BundleBridgeNexusNode[CustomDebug]()
val tileCustomDebugNodes = tiles
  .flatMap { case tile: MeowV64Tile =>
    Some(tile)
  }
  .map { _.customDebugNode }

tileCustomDebugNodes.foreach { customDebugNexus := _ }

最后在 SubsystemModule Imp 中连接到 IO:

// wire custom debug signals
val customDebugIO = outer.customDebugNexus.in.map(_._1)
val customDebug = IO(
  Output(
    Vec(customDebugIO.length, customDebugIO(0).cloneType)
  )
)
for (i <- 0 until customDebug.length) {
  customDebug(i) := customDebugIO(i)
}

这样就搞定了。

总结

找到这个实现方法,基本是对着自带的 trace 接口做的,比较重要的是理解 diplomacy 里面的两层,第一层是把不同的模块进行一些连接,然后第二层在 ModuleImp 中处理实际的信号和逻辑。

「教学」内存认证算法

背景

之前 @松 给我讲过一些内存认证(Memory Authentication)算法的内容,受益匪浅,刚好今天某硬件群里又讨论到了这个话题,于是趁此机会再学习和整理一下相关的知识。

内存认证计算的背景是可信计算,比如要做一些涉及重要数据的处理,从软件上,希望即使系统被攻击非法进入了,也可以保证重要信息不会泄漏;从硬件上,希望即使系统可以被攻击者进行一些物理的操作(比如导出或者修改内存等等),也可以保证攻击者无法读取或者篡改数据。

下面的内容主要参考了 Hardware Mechanisms for Memory Authentication: A Survey of Existing Techniques and Engines 这篇 2009 年的文章。

威胁模型

作为一个防御机制,首先要确定攻击方的能力。一个常见的威胁模型是认为,攻击者具有物理的控制,可以任意操控内存中的数据,但是无法读取或者修改 CPU 内部的数据。也就是说,只有 CPU 芯片内的数据是可信的,离开了芯片都是攻击者掌控的范围。一个简单的想法是让内存中保存的数据是加密的,那么怎样攻击者可以如何攻击加密的数据?下面是几个典型的攻击方法:

  • Spoofing attack:把内存数据改成任意攻击者控制的数据;这种攻击可以通过签名来解决
  • Splicing or relocation attack:把某一段内存数据挪到另一部分,这样数据的签名依然是正确的;所以计算签名时需要把地址考虑进来,这样地址变了,验证签名就会失败
  • Replay attack:如果同一个地址的内存发生了改变,攻击者可以把旧的内存数据再写进去,这样签名和地址都是正确的;为了防止重放攻击,还需要引入计数器或者随机 nonce

Authentication Primitives

为了防御上面几种攻击方法,上面提到的文章里提到了如下的思路:

一是 Hash Function,把内存分为很多个块,每一块计算一个密码学 Hash 保存在片内,那么读取数据的时候,把整块数据读取进来,计算一次 Hash,和片内保存的结果进行比对;写入数据的时候,重新计算一次修改后数据的 Hash,更新到片内的存储。这个方法的缺点是没有加密,攻击者可以看到内容,只不过一修改就会被 CPU 发现(除非 Hash 冲突),并且存储代价很大:比如 512-bit 的块,每一块计算一个 128-bit 的 Hash,那就浪费了 25% 的空间,而片内空间是十分宝贵的。

二是 MAC Function,也就是密码学的消息验证码,它需要一个 Key,保存在片内;由于攻击者不知道密码,根据 MAC 的性质,攻击者无法篡改数据,也无法伪造 MAC,所以可以直接把计算出来的 MAC 也保存到内存里。为了防御重放攻击,需要引入随机的 nonce,并且把 nonce 保存在片内,比如每 512-bit 的数据,保存 64-bit 的 nonce,这样片内需要保存 12.5% 的空间,依然不少。MAC 本身也不加密,所以如果不希望攻击者看到明文,还需要进行加密。

三是 Block-Level AREA,也就是在把明文和随机的 nonce 拼接起来,采用块加密算法,保存在内存中;解密的时候,验证最后的 nonce 和片内保存的一致。这个方法和 MAC 比较类似,同时做了加密的事情,也需要在片内保存每块数据对应的随机 nonce。

Integrity Tree

但是上面几种方法开销都比较大,比如要保护 1GB 的内存,那么片内就要保存几百 MB 的数据,这对于片内存储来说太大了。这时候,可以采用区块链里常用的 Merkle Tree 或者类似的方法来用时间换空间。

这种方法的主要思路是,首先把内存划分为很多个块,这些块对应一颗树的叶子结点;自底向上构建一颗树,每个结点可以验证它的子结点的完整性,那么经过 log(n) 层的树,最后只会得到一个很小的根结点,只需要把根结点保存在片内。

为了验证某一个块的完整性,就从这一块对应的叶子结点开始,不断计算出一个值,和父亲结点比较;再递归向上,最后计算出根结点的值,和片内保存的值进行对比。这样验证的复杂度是 O(logn),但是片内保存的数据变成了 O(1),所以是以时间换空间。更新数据的时候,也是类似地从叶子结点一步一步计算,最后更新根结点的值。

这个方法浪费的空间,考虑所有非叶子结点保存的数据,如果是二叉树,总的大小就是数据的一半,但是好处是大部分都可以保存在内存里,所以是比较容易实现的。缺点是每次读取和写入都要进行 O(logn) 次的内存访问和计算,开销比较大。

上面提到的父结点的值的计算方法,如果采用密码学 Hash 函数,这棵树就是 Merkle Tree。它的验证过程是只读的,可以并行的,但是更新过程是串行的,因为要从子结点一步一步计算 Hash,父结点依赖子结点的 Hash 结果。

另一种设计是 Parallelizable Authentication Tree(PAT),它采用 MAC 而不是 Hash,每个结点保存了一个随机的 nonce 和计算出来的 MAC 值,最底层的 MAC 输入是实际的数据,其他层的 MAC 输入是子结点的 nonce,最后在片内保存最后一次 MAC 使用的 nonce 值。这样的好处是更新的时候,每一层都可以并行算,因为 MAC 的输入是 nonce 值,不涉及到子结点的 MAC 计算结果。缺点是要保存更多数据,即 MAC 和 nonce。

还有一种设计是 Tamper-Evident Counter Tree(TEC-Tree),计算的方法则是上面提到的 Block-level AREA。类似地,最底层是用数据和随机 nonce 拼起来做加密,而其他层是用子节点的随机 nonce 拼起来,再拼接上这一层的 nonce 做加密。验证的时候,首先对最底层进行解密,然后判断数据是否匹配,然后再解密上一层,判断 nonce 是否匹配,一直递归,最后解密到根的 nonce,和片内保存的进行匹配。更新的时候,也可以类似地一次性生产一系列的 nonce,然后并行地加密每一层的结果。

最后引用文章里的一个对比:

可以看到,后两种算法可以并行地更新树的节点,同时也需要保存更多的数据。

Cached Trees

从上面的 Integrity Tree 算法可以发现,每次读取或者写入都要访问内存 O(logn) 次,这个对性能影响是十分巨大的。一个简单的思路是,我把一些经常访问的树结点保存在片内的缓存,这样就可以减少一些内存访问次数;进一步地,如果认为攻击者无法篡改片内的缓存,那就可以直接认为片内的结点都是可信的,在验证和更新的时候,只需要从叶子结点遍历到缓存在片内的结点即可。

The Bonsai Merkle Tree

为了进一步减少空间的占用,Bonsai Merkle Tree(BMT)的思路是,既然对每个内存块都生成一个比较长的(比如 64 位)的 nonce 比较耗费空间,那是否可以减少一下 nonce 的位数,当 nonce 出现重复的时候,换一个密钥重新加密呢?具体的做法是,每个内存块做一次 MAC 计算,输入是数据,地址和 counter:M=MAC(C, addr, ctr)。此时,地址和 ctr 充当了原来的 nonce 的作用,所以类似地,此时的 Merkle Tree 保护的是这些 counter,由于 counter 位数比较少,就可以进一步地减少空间的开销,而且树的层数也更少了。缺点是既然位数少了,如果 counter 出现了重复,就需要更换密钥,重新进行一次加密,这个比较耗费时间,所以还要尽量减少重新加密的次数。

具体来说,为了避免重放攻击,每次更新数据的时候,就让 counter 加一,这和原来采用一个足够长(比如 64-bit)的随机 nonce 是类似的。重新加密是很耗费时间的,因此为了把重新加密的范围局限到一个小的局部,又设计了一个两级的 counter:7-bit 的 local counter,每次更新数据加一;64-bit 的 global counter,当某一个 local counter 溢出的时候加一。这时候实际传入 MAC 计算的 counter 则是 global counter 拼接上 local counter。这样相当于是做了一个 counter 的共同前缀,在内存访问比较均匀的时候,比如每个 local counter 轮流加一,那么每次 local counter 溢出只需要重新加密一个小范围的内存,减少了开销。

文章后续还提到了一些相关的算法,这里就不继续翻译和总结了。

Mountable Merkle Tree

再来看一下 Scalable Memory Protection in the Penglai Enclave 中提到的 Mountable Merkle Tree 设计。它主要考虑的是动态可变的保护内存区域,比如提到的微服务场景,并且被保护内存区域的访问有时间局部性,因此它的思路是,不去构造一个对应完整内存的 Merkle Tree,而是允许一些子树不存在。具体来说,它设计了一个 Sub-root nodes 的概念,对应了 Merkle Tree 中间的一层。这一层往上是预先分配好的,并且大部分保存在内存中,根结点保存在片内,这一层往下是动态分配的。比如应用创建了一个新的 enclave,需要新的一个被保护的内存区域,再动态分配若干个 Merkle Tree,接到 Sub-root nodes 层,成为新的子树。

由于片内空间是有限的,所以这里采取了缓存的方式,只把一部分常用的树结点保存在片内;如果某一个子树一直没有被访问,就可以换出到内存里。如果删除了一个已有的 enclave,那么相应的子树就可以删掉,减少内存空间的占用。

参考文献

本文的内容已经整合到知识库中。

背景

最近在研究一些支持缓存一致性的缓存的实现,比如 rocket-chip 的实现和 sifive 的实现,因此需要研究一些 TileLink 协议。本文讨论的时候默认读者具有一定的 AXI 知识,因此很多内容会直接参考 AXI。

信号

根据 TileLink Spec 1.8.0,TileLink 分为以下三种:

  • TL-UL: 只支持读写,不支持 burst,类比 AXI-Lite
  • TL-UH:支持读写,原子指令,预取,支持 burst,类比 AXI+ATOP(AXI5 引入的原子操作)
  • TL-C:在 TL-UH 基础上支持缓存一致性协议,类比 AXI+ACE/CHI

TileLink Uncached(TL-UL 和 TL-UH) 包括了两个 channel:

  • A channel: M->S 发送请求,类比 AXI 的 AR/AW/W
  • D channel: S->M 发送响应,类比 AXI 的 R/W

因此 TileLink 每个周期只能发送读或者写的请求,而 AXI 可以同时在 AR 和 AW channel 上发送请求。

一些请求的例子:

  • 读:M->S 在 A channel 上发送 Get,S->M 在 D channel 上发送 AccessAckData
  • 写:M->S 在 A channel 上发送 PutFullData/PutPartialData,S->M 在 D channel 是发送 AccessAck
  • 原子操作:M->S 在 A channel 上发送 ArithmeticData/LogicalData,S->M 在 D channel 上发送 AccessAckData
  • 预取操作:M->S 在 A channel 上发送 Intent,S->M 在 D channel 上发送 AccessAck

AXI4ToTL

针对 AXI4ToTL 模块的例子,来分析一下如何把一个 AXI4 Master 转换为 TileLink。

首先考虑一下 AXI4 和 TileLink 的区别:一个是读写 channel 合并了,所以这里需要一个 Arbiter;其次 AXI4 中 AW 和 W 是分开的,这里也需要进行合并。这个模块并不考虑 Burst 的情况,而是由 AXI4Fragmenter 来进行拆分,即添加若干个 AW beat,和 W 进行配对。

具体到代码实现上,首先把 AR channel 对应到 到 A channel 上:

val r_out = Wire(out.a)
r_out.valid := in.ar.valid
r_out.bits :<= edgeOut.Get(r_id, r_addr, r_size)._2

然后 AW+W channel 也连接 到 A channel,由于不用考虑 burst 的情况,这里在 aw 和 w 同时 valid 的时候才认为有请求。

val w_out = Wire(out.a)
in.aw.ready := w_out.ready && in.w.valid && in.w.bits.last
in.w.ready  := w_out.ready && in.aw.valid
w_out.valid := in.aw.valid && in.w.valid
w_out.bits :<= edgeOut.Put(w_id, w_addr, w_size, in.w.bits.data, in.w.bits.strb)._2

比较有意思的是读写的 id 增加了若干位,最低位 0 表示读,1 表示写,剩下几位是请求编号,这样发出去的是不同 id 的多个请求。

然后,把读和写的 A channel 连接到 Arbiter 上:

TLArbiter(TLArbiter.roundRobin)(out.a, (UInt(0), r_out), (in.aw.bits.len, w_out))

其余的部分则是对 D channel 进行判断,有数据的转给 R channel,没有数据的转给 B channel:

out.d.ready := Mux(d_hasData, ok_r.ready, ok_b.ready)
ok_r.valid := out.d.valid && d_hasData
ok_b.valid := out.d.valid && !d_hasData

最后处理了一下 TileLink 和 AXI4 对写请求返回确认的区别:TileLink 中,可以在第一个 burst beat 就返回确认,而 AXI4 需要在最后一个 burst beat 之后返回确认。

TLToAXI4

再来看一下反过来的转换,从 TileLink Master 到 AXI。由于 TileLink 同时只能进行读或者写,所以它首先做了一个虚构的 arw channel,可以理解为合并了 ar 和 aw channel 的 AXI4,这个设计在 SpinalHDL 的代码中也能看到。然后再根据是否是写入,分别连接到 ar 和 aw channel:

val queue_arw = Queue.irrevocable(out_arw, entries=depth, flow=combinational)
out.ar.bits := queue_arw.bits
out.aw.bits := queue_arw.bits
out.ar.valid := queue_arw.valid && !queue_arw.bits.wen
out.aw.valid := queue_arw.valid &&  queue_arw.bits.wen
queue_arw.ready := Mux(queue_arw.bits.wen, out.aw.ready, out.ar.ready)

这里处理了 aw 和 w 的 valid 信号:

in.a.ready := !stall && Mux(a_isPut, (doneAW || out_arw.ready) && out_w.ready, out_arw.ready)
out_arw.valid := !stall && in.a.valid && Mux(a_isPut, !doneAW && out_w.ready, Bool(true))
out_w.valid := !stall && in.a.valid && a_isPut && (doneAW || out_arw.ready)

这样做的原因是,在 TileLink 中,每个 burst 都是一个 a channel 上的请求,而 AXI4 中,只有第一个 burst 有 aw 请求,所有 burst 都有 w 请求,因此这里用 doneAW 信号来进行区分。

接着,要把 b 和 r channel 上的结果连接到 d channel,根据上面的经验,这里 又是一个 arbitration:

val r_wins = (out.r.valid && b_delay =/= UInt(7)) || r_holds_d
out.r.ready := in.d.ready && r_wins
out.b.ready := in.d.ready && !r_wins
in.d.valid := Mux(r_wins, out.r.valid, out.b.valid)

最后还处理了一下请求和结果顺序的问题。

上面说的两个模块都是 TileLink Uncached,那么它如何支持缓存一致性呢?首先,它引入了三个 channel:B、C 和 E,支持三种操作:

  • Acquire:M->S 在 A channel 上发送 Acquire,S->M 在 D channel 上发送 Grant,然后 M->S 在 E channel 上发送 GrantAck;功能是获取一个 copy,可以看到这个和 Get 是类似的,都是在 A channel 上发送请求,在 D channel 上接受响应,只不过额外需要在 E channel 上发送 GrantAck。
  • Release:M->S 在 C channel 上发送 Release/ReleaseData,S->M 在 D channel 上发送 ReleaseAck;功能是删除自己的 copy,一般是缓存行要被换出的时候,如果要写回 Dirty 数据,就用 ReleaseData,否则用 Release
  • Probe:S->M 在 B channel 上发送 Probe,M->S 在 C channel 上发送 ProbeAck;功能是要求 M 删除自己的 copy,通常是有某一个缓存发送了 Acquire,导致其他缓存需要降低权限

可以看到,A C E 三个 channel 是 M->S,B D 两个 channel 是 S->M。

假如一个缓存(Master A)要写入一块只读数据,或者读取一块 miss 的缓存行,如果是广播式的缓存一致性协议,那么需要经历如下的过程:

  • Master A -> Slave: Acquire
  • Slave -> Master B: Probe
  • Master B -> Slave: ProbeAck
  • Slave -> Master A: Grant
  • Master A -> Slave: GrantAck

首先 Master A 发出 Acquire 请求,然后 Slave 向其他 Master 广播 Probe,等到其他 Master 返回 ProbeAck 后,再向 Master A 返回 Grant,最后 Master A 发送 GrantAck 给 Slave。这样 Master A 就获得了这个缓存行的一份拷贝,并且让 Master B 的缓存行失效或者状态变成只读。

TileLink 的缓存行有三个状态:None,Branch 和 Trunk(Tip)。基本对应 MSI 模型:None->Invalid,Branch->Shared 和 Trunk->Modified。Rocket Chip 代码中 ClientStates 还定义了 Dirty 状态,大致对应 MESI 模型:None->Invalid,Branch->Shared,Trunk->Exclusive,Dirty->Modified。

此外,标准还说可以在 B 和 C channel 上进行 TL-UH 的操作。标准这么设计的意图是可以让 Slave 转发操作到拥有缓存数据的 Master 上。比如 Master A 在 A channel 上发送 Put 请求,那么 Slave 向 Master B 的 B channel 上发送 Put 请求,Master B 在 C channel 上发送 AccessAck 响应,Slave 再把响应转回 Master A 的 D channel。这就像是一个片上的网络,Slave 负责在 Master 之间路由请求。

Broadcast

接下来看看 Rocket Chip 自带的基于广播的缓存一致性协议实现。核心实现是 TLBroadcast,核心的逻辑就是,如果一个 Master A 发送了 Acquire,那么 TLBroadcast 需要发送 Probe 到其他的 Master,当其他的 Master 都响应了 ProbeAck 后,再返回 Grant 到 Master A。

首先来看 B channel 上的 Probe 逻辑。它记录了一个 todo bitmask,表示哪些 Master 需要发送 Probe,这里采用了 Probe Filter 来减少发送 Probe 的次数,因为只需要向拥有这个缓存行的 Master 发送 Probe:

val probe_todo = RegInit(0.U(max(1, caches.size).W))
val probe_line = Reg(UInt())
val probe_perms = Reg(UInt(2.W))
val probe_next = probe_todo & ~(leftOR(probe_todo) << 1)
val probe_busy = probe_todo.orR()
val probe_target = if (caches.size == 0) 0.U else Mux1H(probe_next, cache_targets)

// Probe whatever the FSM wants to do next
in.b.valid := probe_busy
if (caches.size != 0) {
    in.b.bits := edgeIn.Probe(probe_line << lineShift, probe_target, lineShift.U, probe_perms)._2
}
when (in.b.fire()) { probe_todo := probe_todo & ~probe_next }

这里 probe_next 就是被 probe 的那个 Master 对应的 bitmask,probe_target 就是 Master 的 Id。这个 Probe FSM 的输入就是 Probe Filter,它会给出哪些 Cache 拥有当前的缓存行的信息:

val leaveB = !filter.io.response.bits.needT && !filter.io.response.bits.gaveT
val others = filter.io.response.bits.cacheOH & ~filter.io.response.bits.allocOH
val todo = Mux(leaveB, 0.U, others)
filter.io.response.ready := !probe_busy
when (filter.io.response.fire()) {
    probe_todo  := todo
    probe_line  := filter.io.response.bits.address >> lineShift
    probe_perms := Mux(filter.io.response.bits.needT, TLPermissions.toN, TLPermissions.toB)
}

这里又区分两种情况:如果 Acquire 需要进入 Trunk 状态(比如是个写入操作),意味着其他 Master 需要进入 None 状态,所以这里要发送 toN;如果 Acquire 不需要进入 Trunk 状态(比如是个读取操作),那么只需要其他 Master 进入 Branch 状态,所以这里要发送 toB。

在 B channel 发送 Probe 的同时,也要处理 C channel 上的 ProbeAck 和 ProbeAckData:

// Incoming C can be:
// ProbeAck     => decrement tracker, drop 
// ProbeAckData => decrement tracker, send out A as PutFull(DROP)
// ReleaseData  =>                    send out A as PutFull(TRANSFORM)
// Release      => send out D as ReleaseAck

由于这里采用的是 invalidation based,所以如果某个 Master 之前处于 Dirty 状态,那么它会发送 ProbeAckData,此时需要把数据写回,所以需要用 PutFull 把数据写出去。

Serialization

下面来讨论一下 TileLink 对各组信号的一些要求。

Flow Control Rules

首先是 Flow Control Rules,讨论的是 ready 和 valid 信号的关系,目的是防止死锁。首先是两个比较常规的要求:

  • If ready is LOW, the receiver must not process the beat and the sender must not consider the beat processed.
  • If valid is LOW, the receiver must not expect the control or data signals to be a syntactically correct TileLink beat.

第一个说的就是 valid & ready 的时候才认为是一个 beat 处理了,第二个就是如果 valid=LOW,那么信号可能是随机的、不合法的。

  • valid must never depend on ready. If a sender wishes to send a beat, it must assert valid independently of whether the receiver signals that it is ready.
  • As a consequence, there must be no combinational path from ready to valid or any of the control and data signals.

这里是为了防止组合逻辑出现环路,如果 valid 依赖 ready,ready 依赖 valid,就会出现问题,所以这里规定,valid 不能依赖 ready,反过来只能 ready 依赖 valid。类似地,其他的数据和控制信号也不可以依赖 ready。简单理解就是 sender 要主动提供数据,而 receiver 决定了是否接受。

  • A low priority valid may not combinationally depend on a high priority valid. In other words, the decision to send a request may not be based on receiving a response in the same cycle.
  • A high priority ready may not combinationally depend on a low priority ready. In other words, acceptance of a response may not be made contingent upon a request being accepted the same cycle.

这两条的意思是,同一个周期内,我设置发送的请求的 valid,不能依赖于同一个周期内接受到的响应的 valid,比如 A 的 valid 不能组合依赖于 D 的 valid。另一方面,我设置的响应的 ready 不能依赖于同一个周期内的请求,比如 D 的 ready 不能组和依赖于 A 的 ready。

那么,有这么几种用法是可以的:

  • It is acceptable for a receiver to drive ready in response to valid or any of the control and data signals. For example, an arbiter may lower ready if a valid request is made for an address which is busy. However, whenever possible, it is recommended that ready be driven independently so as to reduce the handshaking circuit depth. 接收方可以让 ready 依赖于 valid 或者其他的控制和数据信号,不过这样会让组合逻辑比较长。
  • A channel may change valid and all control and data signals based on the value of ready in the prior cycle. For example, after a request has been accepted (ready HIGH), a new request may be presented. Only a same-cycle dependency of valid on ready is forbidden. 可以让当前周期的 valid 依赖于上一个周期的 ready 信号,只是不能有同周期的 valid 对 ready 的依赖。
  • A device may legally drive valid for a response based on valid of a request in the same cycle. For example, a combinational ROM which answers immediately. In this case, presumably ready for the request will likewise be driven by ready for the response. The converse relationship is forbidden. 设备可以让响应的 valid 依赖请求的 valid,比如一个组合的 ROM,它的 D channel 的 valid 可以组合依赖于 A channel 的 valid,同时 A channel 的 ready 组合依赖于 D channel 的 ready。这样就简化了设备的设计,并且可以无延迟地进行访问。

和 AXI 不同的一点在于,TileLink 不要求 irrevocable,也就是说如果一个周期内 valid=HIGH 但是 ready=LOW,那么下一个周期 Master 可以修改控制和数据信号,也可以让 valid=LOW。

Note that a sender may raise valid and then lower it on the following
cycle, even if the message was not accepted on the previous cycle. For example,
the sender might have some other higher priority task to perform on the
following cycle, instead of trying to send the rejected message again.
Furthermore, the sender may change the contents of the control and data signals
when a message was not accepted.

TileLink 的 burst 请求是通过比 bus 更宽的 size 的多个 beat 组成的。一旦第一个 beat fire 了,后续只能发送同一个 burst 的数据,不可以交错。

Request-Response Message Ordering

这里讨论的是请求和响应的顺序关系。TileLink 规定,响应的第一个 beat 不早于第一个请求的 beat,比如:

  • 对于 Get 请求,如果响应需要多个 beat,那么第一个 beat 不早于请求的那一个周期,这个比较常规,意思是可以组合返回响应。
  • 对于 Put 请求,如果请求需要多个 beat,那么响应可以在第一个请求的 beat 的周期,这个比较特别,意思是对于多个 beat 的请求,可以立即返回响应,不需要等到所有请求的 beat 完成。
  • 对于 ArithmeticData 请求,响应和请求都可能有多个 beat,那么响应的第一个 beat 不早于请求的第一个 beat 即可,其他没有顺序要求。

Deadlock Freedom

那么多规则,一个很重要的目的是要防止死锁。为了防止死锁,有这样三条:

  1. The agent graph (Section 5.3) contains no cycles
  2. Agents must eventually present all beats of a received message
  3. Unless they have a higher priority message in flight or unanswered
    1. Agents must eventually accept a presented beat
    2. Agents must eventually answer a received request message

大概意思是,beat 不能无限推迟,无论是发送方还是接受方。对于每个请求,它的响应不能无限推迟。

TileLink 定义了各个 channel 的优先级,从低到高是 A<B<C<D<E。对于同一个 channel,A C E 上是 master/sender 优先级更高,B D 上是 slave/receiver 优先级更高。

TileLink 的设计里保证了,每个请求的响应都比请求优先级更高。比如 A channel 的请求(Get/Put/AcquireBlock)的响应在 D channel(AccessAckData/AccessAck/Grant),B channel 的请求(Probe)的响应在 C channel(ProbeAck),C channel 的请求(Release)的响应在 D channel(ReleaseAck),D channel 的请求(Grant)的响应在 E channel(GrantAck)。

参考文档

NUC11 ESXi 中 iGPU 直通虚拟机

背景

之前在 NUC11PAKi5 上装了 ESXI 加几个虚拟机系统,但是自带的 iGPU Intel Iris Xe Graphics(Tiger Lake GT-2) 没用上,感觉有些浪费。因此想要给 Windows 直通。在直通到 Windows 后发现会无限重启,最后直通到 Linux 中。

步骤

第一步是到 esxi 的设备设置的地方,把 iGPU 的 Passthrough 打开,这时候会提示需要重启,但是如果重启,会发现还是处于 Needs reboot 状态。网上进行搜索,发现是 ESXi 自己占用了 iGPU 的输出,解决方法如下:

$ esxcli system settings kernel set -s vga -v FALSE

这样设置以后就不会在显卡输出上显示 dcui 了,这是一个比较大的缺点,但是平时也不用自带的显示输出,就无所谓了。

第二步,重启以后,这时候看设备状态就是 Active。回到 Windows 虚拟机,添加 PCI device,然后启动。这时候,我遇到了这样的错误:

Module ‘DevicePowerOn’ power on failed
Failed to register the device pciPassthru0

搜索了一番,解决方法是关掉 IOMMU。在虚拟机设计中关掉 IOMMU,就可以正常启动了。

第三步,进入 Windows,这时候就可以看到有一个新的未知设备了,VID=8086,PID=9a49;等待一段时间,Windows 自动安装好了驱动,就可以正常识别了。GPU-Z 中可以看到效果如下:

不过关机重启的时候会蓝屏,可能还有一些问题,有人在论坛上也说 passthrough 之后会蓝屏。这个问题一直没有解决。

再尝试 Passthrough 到 Linux:

$ sudo dmesg | grep i915
[    2.173500] i915 0000:13:00.0: enabling device (0000 -> 0003)
[    2.180137] i915 0000:13:00.0: [drm] VT-d active for gfx access
[    2.182109] i915 0000:13:00.0: BAR 6: can't assign [??? 0x00000000 flags 0x20000000] (bogus alignment)
[    2.182110] i915 0000:13:00.0: [drm] Failed to find VBIOS tables (VBT)
[    2.182541] i915 0000:13:00.0: vgaarb: changed VGA decodes: olddecodes=io+mem,decodes=none:owns=none
[    2.197374] i915 0000:13:00.0: firmware: direct-loading firmware i915/tgl_dmc_ver2_08.bin
[    2.198037] i915 0000:13:00.0: [drm] Finished loading DMC firmware i915/tgl_dmc_ver2_08.bin (v2.8)
[    3.401676] i915 0000:13:00.0: [drm] failed to retrieve link info, disabling eDP
[    3.515822] [drm] Initialized i915 1.6.0 20200917 for 0000:13:00.0 on minor 0
[    3.516054] i915 0000:13:00.0: [drm] Cannot find any crtc or sizes
[    3.516144] i915 0000:13:00.0: [drm] Cannot find any crtc or sizes

OpenCL 也可以检测到:

$ sudo apt install intel-opencl-icd clinfo
Number of devices                                 1
  Device Name                                     Intel(R) Graphics Gen12LP [0x9a49]
  Device Vendor                                   Intel(R) Corporation
  Device Vendor ID                                0x8086
  Device Version                                  OpenCL 3.0 NEO
  Device Numeric Version                          0xc00000 (3.0.0)
  Driver Version                                  1.0.0

Vulkan:

$ vulkaninfo
Group 1:
        Properties:
                physicalDevices: count = 1
                        Intel(R) Xe Graphics (TGL GT2) (ID: 0)
                subsetAllocation = 0

        Present Capabilities:
                Intel(R) Xe Graphics (TGL GT2) (ID: 0):
                        Can present images from the following devices: count = 1
                                Intel(R) Xe Graphics (TGL GT2) (ID: 0)
                Present modes: count = 1
                        DEVICE_GROUP_PRESENT_MODE_LOCAL_BIT_KHR

不过目前还没找到显示输出的方法,只能用 VMware SVGA 或者远程桌面。

吐槽

需要吐槽的是,11 代的核显不再支持 Intel GVT-g,而是提供了 SR-IOV 的虚拟化。但是,Linux i915 驱动没有做相应的支持。

参考文档:

LoongArch64 工具链构建

最近因为龙芯杯的原因,想自己搞个 LoongArch64 的交叉编译工具链试试,结果遇到了很多坑,最后终于算是搞出来了。

一开始是想搞一个 newlib 的工具链,比较简单,而且之前做过一个仓库:jiegec/riscv-toolchain,就是构建的 riscv64-unknown-elf 工具链,照着 riscv-gnu-toolchain 就可以了。不过研究发现,newlib 还不支持 loongarch,目前只有 glibc 支持,只好硬着头皮上了。

于是我就在 riscv-toolchain 的基础上搞 loongarch64-unknown-linux-gnu,也就是带 glibc 的工具链,结果发现遇到很多坑。首先编译 libgcc 的时候就找不到头文件,于是先要从 glibc 和 linux 安装头文件到 sysroot 里面,对于 sysroot 里面的头文件路径到底是 include 还是 usr/include 也折腾了半天。然后编译 libgcc 又各种出问题,最后折腾了半天,结果是 gcc stage1 和 glibc 都没问题,gcc stage2 会报链接错误,但是不管它也能用,可以编译出正常的程序,毕竟 libc 是好的。

于是转念一想,要不要试试 crosstool-ng。克隆了一份上游的版本,照着 riscv 的部分抄了一份变成了 loongarch,然后把 config 里面的 linux/glibc/gcc/binutils-gdb 都替换为 custom location,这样我就可以用上游的最新版本了。中途还遇到了 crosstool-ng 对 gcc 12/13 不兼容的 bug,还好下面有人提出了解决方法。这些都搞定以后,终于构建出了一个完整的 loongarch64-unknown-linux-gnu 工具链。仓库地址是 jiegec/ct-ng-loongarch64,需要配合添加了 LoongArch 的 jiegec/crosstool-ng loongarch 分支 使用。

最后得到的工具链各组件版本如下:

loongarch64-unknown-linux-gnu-gcc (crosstool-NG 1.25.0_rc2.1_7e21141) 13.0.0 20220502 (experimental)
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

GNU ld (crosstool-NG 1.25.0_rc2.1_7e21141) 2.38.50.20220502
Copyright (C) 2022 Free Software Foundation, Inc.
This program is free software; you may redistribute it under the terms of
the GNU General Public License version 3 or (at your option) a later version.
This program has absolutely no warranty.
GNU gdb (crosstool-NG 1.25.0_rc2.1_7e21141) 13.0.50.20220502-git
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

之后有时间的话,再把 qemu 和系统搞起来跑跑。

UPDATE: GCC 12.1 发布了,试了一下这个正式版本可以正确地编译。目前还需要使用 HEAD 版本的 binutils 和龙芯的 glibc 和 linux。

参考文档:

试用沁恒 CH32V307 评估板

背景

之前有一天看到朋友在捣鼓 CH32V307,因此自己也萌生了试用 CH32V307 评估板的兴趣,于是在沁恒官网申请样品,很快就接到电话了解情况,几天后就顺丰送到了,不过因为疫情原因直到现在才拿到手上,只能说疫情期间说不定货比人还快。

开箱

收到的盒子里有一个 CH32V307 评估板,和一个 WCH-Link,相关资料可以在 官网 或者 openwch/ch32v307 下载。在说明书中有如下的图示:

板子自带的跳线帽不是很多,建议自备一些,或者用杜邦线替代。比较重要的是 WCH-Link 子板上 CH549 和 CH2V307 连接的几个信号,和下面 BOOT0/1 的选择。

可以看到评估板自带了一个 WCH-Link,所以不需要附赠的那一个,直接把 11 号 Type-C 连接到电脑上即可。这里还遇到一个小插曲,用 Type-C to Type-C 的线连电脑上不工作,连 PWR LED 都点不亮,换一根 Type-A to Type-C 的就可以,没有继续研究是什么原因。电脑上可以看到 WCH-Link 的设备:VID=1a86, PID=8010。比较有意思的是,在 RISC-V 模式(CON 灯不亮)的时候 PID 是 8010,ARM 模式(CON 灯亮)的时候 PID 是 8011,从 RISC-V 模式切换到 ARM 模式的方法是连接 TX 和 GND 后上电,反过来要用 MounRiver,详见 WCH-Link 使用说明 V1.0 V1.3 和原理图 V1.1

给沁恒开源 WCH-Link 原理图并开放固件点个赞,在淘宝上也可以看到不少 WCH-Link 的仿真器,挺有意思的。

在 ARM 模式下,它实现了类似 CMSIS-DAP 的协议,可以用 OpenOCD 调试:

source [find interface/cmsis-dap.cfg]
adapter speed 1000
cmsis_dap_vid_pid 0x1a86 0x8011
transport select swd
init
$ openocd -f openocd.cfg
Open On-Chip Debugger 0.11.0
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
Info : CMSIS-DAP: SWD  Supported
Info : CMSIS-DAP: FW Version = 2.0.0
Info : CMSIS-DAP: Interface Initialised (SWD)
Info : SWCLK/TCK = 1 SWDIO/TMS = 1 TDI = 0 TDO = 0 nTRST = 0 nRESET = 1
Info : CMSIS-DAP: Interface ready
Info : clock speed 1000 kHz
Warn : gdb services need one or more targets defined
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections

不过这里我们要用的是 RISC-V 处理器 CH32V307,上面的就当是 WCH-LINK 使用的小贴士。

给评估板插上 USB Type-C 以后,首先上面的 WCH-Link 部分中红色的 PWR 和绿色的 RUN 亮,CON 不亮,说明 WCH-LINK 的 CH549 已经启动,并且处在 RISC-V 模式(CON 不亮)。CH549 是一个 8051 指令集的处理器,上面的跑的 WCH-LINK 固件在网上可以找到,在下面提到的 MounRiver Studio 目录中也有一份。

OpenOCD

目前开源工具上游还不支持 CH32V307 的开发,需要用 MounRiver,支持 Windows 和 Linux,有两部分:

解压缩后,可以看到它的 OpenOCD 配置:

## wch-arm.cfg
adapter driver cmsis-dap
transport select swd
source [find ../share/openocd/scripts/target/ch32f1x.cfg]
## wch-riscv.cfg
#interface wlink
adapter driver wlink
wlink_set
set _CHIPNAME riscv
jtag newtap $_CHIPNAME cpu -irlen 5 -expected-id 0x00001

set _TARGETNAME $_CHIPNAME.cpu

target create $_TARGETNAME.0 riscv -chain-position $_TARGETNAME
$_TARGETNAME.0 configure  -work-area-phys 0x80000000 -work-area-size 10000 -work-area-backup 1
set _FLASHNAME $_CHIPNAME.flash

flash bank $_FLASHNAME wch_riscv 0x00000000 0 0 0 $_TARGETNAME.0

echo "Ready for Remote Connections"

其中 ch32f1x.cfg 就是 stm32f1x.cfg 改了一下名字,可以看到 WCH OpenOCD 把它的 RISC-V 调试协议称为 wlink,估计是取 wch-link 的简称吧。除了 wlink 部分,其他就是正常的 RISC-V CPU 调试的 OpenOCD 配置,比较有意思的就是 IDCODE 设为了 0x00001,比较有个性。

在网上一番搜索,找到了 WCH OpenOCD 的源码 Embedded_Projects/riscv-openocd-wch,是网友向沁恒获取的源代码,毕竟 OpenOCD 是 GPL 软件。简单看了一下代码,是直接把 RISC-V Debug 中的 DMI 操作封装了一下,然后通过 USB Bulk 和 WCH-Link 通信。我从 riscv-openocd 找到了一个比较接近的 commit,然后把 WCH 的代码提交上去,得到了 diff,有兴趣的可以看看具体实现,甚至把这个支持提交到上游。

有源码以后,就可以在 macOS 上编译了(需要修复三处 clang 报告的编译错误,最终代码):

$ ./bootstrap
$ ./configure --prefix=/path/to/prefix/openocd --enable-wlink --disable-werror CAPSTONE_CFLAGS=-I/opt/homebrew/opt/capstone/include/
$ make -j4 install

如果遇到 makeinfo 报错,把 homebrew 的 texinfo 加到 PATH 即可。

编译完成后,就可以用前面提到的 wch-riscv.cfg 进行调试了:

$ /path/to/prefix/openocd -f wch-riscv.cfg
Open On-Chip Debugger 0.11.0+dev-01623-gbfa3bc7f9 (2022-04-20-09:55)
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
Info : only one transport option; autoselect 'jtag'
Ready for Remote Connections
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : WCH-Link version 2.3 
Info : wlink_init ok
Info : This adapter doesn't support configurable speed
Info : JTAG tap: riscv.cpu tap/device found: 0x00000001 (mfg: 0x000 (<invalid>), part: 0x0000, ver: 0x0)
Warn : Bypassing JTAG setup events due to errors
Info : [riscv.cpu.0] datacount=2 progbufsize=8
Info : Examined RISC-V core; found 1 harts
Info :  hart 0: XLEN=32, misa=0x40901125
[riscv.cpu.0] Target successfully examined.
Info : starting gdb server for riscv.cpu.0 on 3333
Info : Listening on port 3333 for gdb connections

这也验证了上面的发现:因为绕过了 jtag,直接发送 dmi,所以 idcode 是假的:

if(wchwlink){
        buf_set_u32(idcode_buffer, 0, 32, 0x00001);  //Default value,for reuse risc-v jtag debug
}

接下来就可以用 GDB 调试了。里面跑了一个样例的程序,就是向串口打印:

$ screen /dev/tty.usbmodem* 115200
SystemClk:72000000
111
   111
      111
         111
            111

之后则是针对各个外设,基于沁恒提供的示例代码进行相应的开发了。

Baremetal 代码

接下来看看沁恒提供的代码是如何配置的。在 EVT/EXAM/SRC/Startup/startup_ch32v30x_D8C.S 可以看到初始化的汇编代码。比较有意思的是,这个核心扩展了 mtvec,支持 ARM 的 vector table 模式,即放一个指针数组,而不是指令:

    .section    .vector,"ax",@progbits
    .align  1
_vector_base:
    .option norvc;
    .word   _start
    .word   0
    .word   NMI_Handler                /* NMI */
    .word   HardFault_Handler          /* Hard Fault */

这些名字如此熟悉,只能说这是 ARVM 了(ARM + RV)。后面的部分比较常规,把 data 段复制到 sram,然后清空 bss:

handle_reset:
.option push 
.option norelax 
        la gp, __global_pointer$
.option pop 
1:
        la sp, _eusrstack 
2:
        /* Load data section from flash to RAM */
        la a0, _data_lma
        la a1, _data_vma
        la a2, _edata
        bgeu a1, a2, 2f
1:
        lw t0, (a0)
        sw t0, (a1)
        addi a0, a0, 4
        addi a1, a1, 4
        bltu a1, a2, 1b
2:
        /* Clear bss section */
        la a0, _sbss
        la a1, _ebss
        bgeu a0, a1, 2f
1:
        sw zero, (a0)
        addi a0, a0, 4
        bltu a0, a1, 1b
2:

最后是进行一些 csr 的配置,然后进入 C 代码:

    li t0, 0x1f
    csrw 0xbc0, t0

    /* Enable nested and hardware stack */
    li t0, 0x1f
    csrw 0x804, t0

    /* Enable floating point and interrupt */
    li t0, 0x6088           
    csrs mstatus, t0

    la t0, _vector_base
    ori t0, t0, 3           
    csrw mtvec, t0

    lui a0, 0x1ffff
    li a1, 0x300
    sh a1, 0x1b0(a0)
1:  lui s2, 0x40022
    lw a0, 0xc(s2)
    andi a0, a0, 1
    bnez a0, 1b

    jal  SystemInit
    la t0, main
    csrw mepc, t0
    mret

这里有一些自定义的 csr,比如 corecfgr(0xbc0),intsyscr(0x804,设置了 HWSTKEN=1, INESTEN=1, PMTCFG=0b11, HWSTKOVEN=1),具体参考 QingKeV4_Processor_Manual。接着代码往 0x1ffff1b0 写入 0x300,然后不断读取 FLASH Interface (0x40022000) 的 STATR 字段,没有找到代码中相关的定义,简单猜测与 Flash 的零等待/非零等待区有关,因为后续代码要提高频率,因此 Flash 控制器需要增加 wait state。

编译

可以用 MounRiver 编译,也可以用 SiFive 的 riscv64-unknown-elf 工具链进行编译,参考 Embedded_Projects/CH32V307_Template 项目中的编译方式,修改 riscv64-elf.cmake 为:

set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_C_COMPILER riscv64-unknown-elf-gcc)
set(CMAKE_CXX_COMPILER riscv64-unknown-elf-g++)
# Make CMake happy about those compilers
set(CMAKE_TRY_COMPILE_TARGET_TYPE "STATIC_LIBRARY")

然后交叉编译就可以了。需要注意的是对 libnosys 的处理,如果没有正确链接,就会出现 syscall,然后在 ecall handler 里面死循环。

如果不想用 CMake,也可以用下面的精简版 Makefile:

USER := User/main.c User/ch32v30x_it.c User/system_ch32v30x.c
LIBRARY := ../../SRC/Peripheral/src/ch32v30x_misc.c \
    ../../SRC/Peripheral/src/ch32v30x_usart.c \
    ../../SRC/Peripheral/src/ch32v30x_gpio.c \
    ../../SRC/Peripheral/src/ch32v30x_rcc.c \
    ../../SRC/Debug/debug.c \
    ../../SRC/Startup/startup_ch32v30x_D8C.S
LDSCRIPT = ../../SRC/Ld/Link.ld
# disable libc first
CFLAGS := -march=rv32imafc -mabi=ilp32f \
    -flto -ffunction-sections -fdata-sections \
    -nostartfiles -nostdlib \
    -T $(LDSCRIPT) \
    -I../../SRC/Debug \
    -I../../SRC/Core \
    -I../../SRC/Peripheral/inc \
    -I./User \
    -O2 \
    -Wl,--print-memory-usage
# link libc & libnosys in the end
CFLAGS_END := \
    -lc -lgcc -lnosys
PREFIX := riscv64-unknown-elf-

all: obj/build.bin

obj/build.bin: obj/build.elf
    $(PREFIX)objcopy -O binary $^ $@

obj/build.elf: $(USER) $(LIBRARY)
    $(PREFIX)gcc $(CFLAGS) $^ $(CFLAGS_END) -o $@

clean:
    rm -rf obj/*

烧写 Flash

编译好以后,根据 WCH OpenOCD 的文档,可以用下面的配置来进行烧写:

#interface wlink
adapter driver wlink
wlink_set
set _CHIPNAME riscv
jtag newtap $_CHIPNAME cpu -irlen 5 -expected-id 0x00001

set _TARGETNAME $_CHIPNAME.cpu

target create $_TARGETNAME.0 riscv -chain-position $_TARGETNAME
$_TARGETNAME.0 configure  -work-area-phys 0x80000000 -work-area-size 10000 -work-area-backup 1
set _FLASHNAME $_CHIPNAME.flash

flash bank $_FLASHNAME wch_riscv 0x00000000 0 0 0 $_TARGETNAME.0

init
halt

flash erase_sector wch_riscv 0 last
program /path/to/firmware
verify_image /path/to/firmware
wlink_reset_resume
exit

输出:

$ openocd -f program.cfg
Open On-Chip Debugger 0.11.0+dev-01623-gbfa3bc7f9 (2022-04-20-09:55)
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
Info : only one transport option; autoselect 'jtag'
Ready for Remote Connections
Info : WCH-Link version 2.3
Info : wlink_init ok
Info : This adapter doesn't support configurable speed
Info : JTAG tap: riscv.cpu tap/device found: 0x00000001 (mfg: 0x000 (<invalid>), part: 0x0000, ver: 0x0)
Warn : Bypassing JTAG setup events due to errors
Info : [riscv.cpu.0] datacount=2 progbufsize=8
Info : Examined RISC-V core; found 1 harts
Info :  hart 0: XLEN=32, misa=0x40901125
[riscv.cpu.0] Target successfully examined.
Info : starting gdb server for riscv.cpu.0 on 3333
Info : Listening on port 3333 for gdb connections
Info : device id = REDACTED
Info : flash size = 256kbytes
Info : JTAG tap: riscv.cpu tap/device found: 0x00000001 (mfg: 0x000 (<invalid>), part: 0x0000, ver: 0x0)
Warn : Bypassing JTAG setup events due to errors
** Programming Started **
** Programming Finished **
Info : Verify Success

访问串口 screen /dev/tty.usbmodem* 115200,可以看到正确地输出了内容。

转发

本文已授权转发到以下的地址:

导出 Vivado 下载 Bitstream 的 SVF 文件

背景

最近在研究如何实现一个远程 JTAG 的功能,目前实现在 jiegec/jtag-remote-server,实现了简单的 XVC 协议,底层用的是 libftdi 的 MPSSE 协议来操作 JTAG。但是,在用 Vivado 尝试的时候,SysMon 可以正常使用,但是下载 Bitstream 会失败,所以要研究一下 Vivado 都做了什么(目前已经修好,是最后一个字节的部分位读取的处理问题)。

SVF

SVF 格式其实是一系列的 JTAG 上的操作。想到这个,也是因为在网上搜到了一个 dcfeb_v45.svf,里面描述的就是一段 JTAG 操作:

// Created using Xilinx Cse Software [ISE - 12.4]
// Date: Mon May 09 11:00:32 2011

TRST OFF;
ENDIR IDLE;
ENDDR IDLE;
STATE RESET;
STATE IDLE;
FREQUENCY 1E6 HZ;
//Operation: Program -p 0 -dataWidth 16 -rs1 NONE -rs0 NONE -bpionly -e -loadfpga 
TIR 0 ;
HIR 0 ;
TDR 0 ;
HDR 0 ;
TIR 0 ;
HIR 0 ;
HDR 0 ;
TDR 0 ;
//Loading device with 'idcode' instruction.
SIR 10 TDI (03c9) SMASK (03ff) ;
SDR 32 TDI (00000000) SMASK (ffffffff) TDO (f424a093) MASK (0fffffff) ;
//Boundary Scan Chain Contents
//Position 1: xc6vlx130t
TIR 0 ;
HIR 0 ;
TDR 0 ;
HDR 0 ;
TIR 0 ;
HIR 0 ;
TDR 0 ;
HDR 0 ;
TIR 0 ;
HIR 0 ;
HDR 0 ;
TDR 0 ;
//Loading device with 'idcode' instruction.
SIR 10 TDI (03c9) ;
SDR 32 TDI (00000000) TDO (f424a093) ;
//Loading device with 'bypass' instruction.
SIR 10 TDI (03ff) ;
//Loading device with 'idcode' instruction.
SIR 10 TDI (03c9) ;
SDR 32 TDI (00000000) TDO (f424a093) ;
// Loading device with a `jprogram` instruction. 
SIR 10 TDI (03cb) ;
// Loading device with a `isc_noop` instruction. 
SIR 10 TDI (03d4) ;
RUNTEST 100000 TCK;
// Check init_complete in ircapture.
//IR Capture using specified instruction.
SIR 10 TDI (03d4) TDO (0010) MASK (0010) ;
// Loading device with a `isc_enable` instruction. 
SIR 10 TDI (03d0) ;
SDR 5 TDI (00) SMASK (1f) ;
RUNTEST 100 TCK;
// Loading device with a `isc_program` instruction. 
SIR 10 TDI (03d1) ;
SDR 32 TDI (ffffffff) SMASK (ffffffff) ;
SDR 32 TDI (ffffffff) ;
SDR 32 TDI (ffffffff) ;

它的语法比较简单,大概就是 SIR 就是向 IR 输入,SDR 就是向 DR 输入,后面跟着的 TDO 和 MASK 表示对输出数据进行判断。比较核心的是下面这几步:

SIR JPROGRAM
SIR ISC_NOOP
RUNTEST 10000 TCK
SIR ISC_NOOP UNTIL ISC_DONE
SIR ISC_ENABLE
SIR ISC_PROGRAM

之后就是 bitstream 的内容了。

OpenOCD 烧写 SVF

有了 SVF 以后,就可以用其他的一些支持 SVF 语法的工具来烧写 FPGA,而不需要 Vivado。比如采用如下的 OpenOCD 配置:

# openocd config
# use ftdi channel 0
adapter speed 100000
adapter driver ftdi
transport select jtag
ftdi_vid_pid 0x0403 0x6011
ftdi_layout_init 0x0008 0x000b
ftdi_tdo_sample_edge falling
ftdi_channel 0
reset_config none

jtag newtap xcvu37p tap -irlen 18 -expected-id 0x14b79093

init

svf -tap xcvu37p.tap /path/to/program.svf progress

exit

从 Vivado 中导出 SVF

从文件头可以推测,这个功能是 Xilinx 官方提供的,一番搜索,果然找到了命令:Creating SVF files using Xilinx Vivado

program_hw_devices -force -svf_file {program.svf} [get_hw_devices xxx]

添加一个 -svf_file 参数后,就会生成一个 svf 文件。下面摘录一段:

// config/idcode
SIR 18 TDI (009249) ;
SDR 32 TDI (00000000) TDO (04b79093) MASK (0fffffff) ;
// config/jprog
STATE RESET;
STATE IDLE;
SIR 18 TDI (00b2cb) ;
SIR 18 TDI (014514) ;
// Modify the below delay for config_init operation (0.100000 sec typical, 0.100000 sec maximum)
RUNTEST 0.100000 SEC;
// config/jprog/poll
RUNTEST 15000 TCK;
SIR 18 TDI (014514) TDO (011000) MASK (031000) ;
// config/slr
SIR 18 TDI (005924) ;
SDR 226633216 TDI (0000000400000004e00000008001000c00000004d00000008001000c0000000466aa99550000000400000004000000040000000400000004000000040000

结合 Xilinx 官网可以下载的 BSDL 文件,可以找到每个 IR 对应的是什么:

// config/idcode
SIR 18 TDI (009249) ;
SDR 32 TDI (00000000) TDO (04b79093) MASK (0fffffff) ;
// BSDL:
"IDCODE           (001001001001001001)," & --   DEVICE_ID reg
attribute IDCODE_REGISTER of XCVU37P_FSVH2892 : entity is
        "XXXX" &        -- version
        "0100101" &     -- family
        "101111001" &   -- array size
        "00001001001" & -- manufacturer
        "1";            -- required by 1149.1

这一段是检查 IDCODE 是否是目标 FPGA 型号。

// config/jprog
STATE RESET;
STATE IDLE;
SIR 18 TDI (00b2cb) ;
SIR 18 TDI (014514) ;
// BSDL
"JPROGRAM         (001011001011001011)," & --   PRIVATE
"ISC_NOOP         (010100010100010100)," & --   PRIVATE, ISC_DEFAULT
// Modify the below delay for config_init operation (0.100000 sec typical, 0.100000 sec maximum)
RUNTEST 0.100000 SEC;

这一段发送了 JPROGRAM 和 ISC_NOOP 的 IR,然后进入 RUNTEST 状态一段时间。

// config/jprog/poll
RUNTEST 15000 TCK;
SIR 18 TDI (014514) TDO (011000) MASK (031000) ;
// BSDL
"ISC_NOOP         (010100010100010100)," & --   PRIVATE, ISC_DEFAULT
这里再次设置 ISC_NOOP,检查了 TDO 中的数据,意义不明。

// config/slr
SIR 18 TDI (005924) ;
SDR 226633216 TDI (0000000400000004e00000008001000c00000004d00000008001000c0000000466aa99550000000400000004000000040000000400000004000000040000
// BSDL
"CFG_IN_SLR0      (000101100100100100)," & --   PRIVATE

从这里就是开始往里面写入 bitstream,可以看到熟悉的 66aa9955 的同步字符,对比 Bitstream 文件内容:

000000c0: ffff ffff ffff ffff aa99 5566 2000 0000  ..........Uf ...
000000d0: 2000 0000 3002 2001 0000 0000 3002 0001   ...0. .....0...
000000e0: 0000 0000 3000 8001 0000 0000 2000 0000  ....0....... ...
000000f0: 3000 8001 0000 0007 2000 0000 2000 0000  0....... ... ...
00000100: 3000 2001 0000 0000 3002 6001 0000 0000  0. .....0.`.....

可以发现是每 32 字节按位颠倒:aa995566(10101010 10011001 01010101 01100110) -> 66aa9955(01100110 10101010 10011001 01010101),后面的 20000000 -> 00000004 也是类似的。

Xilinx UG570 的 Table 6-5 也印证了上面的过程:

  • Start loading the JPROGRAM instruction, LSB first:
  • Load the MSB of the JPROGRAM instruction when exiting SHIFT-IR, as defined in the IEEE standard.
  • Start loading the CFG_IN instruction, LSB first:
  • Load the MSB of the CFG_IN instruction when exiting SHIFT-IR.
  • Shift in the FPGA bitstream. Bit n (MSB) is the first bit in the bitstream.(3)(4)
  • Shift in the last bit of the bitstream. Bit 0 (LSB) shifts on the transition to EXIT1-DR.

完成 CFG_IN 之后,再进行 JSTART:

// config/start
STATE IDLE;
RUNTEST 100000 TCK;
SIR 18 TDI (00c30c) ;
HIR 0 ;
TIR 0 ;
HDR 0 ;
TDR 0 ;
STATE IDLE;
RUNTEST 2000 TCK;
SIR 18 TDI (009249) TDO (031000) MASK (011000) ;
HIR 0 ;
TIR 0 ;
HDR 0 ;
TDR 0 ;
// BSDL
"JSTART           (001100001100001100)," & --   PRIVATE
"IDCODE           (001001001001001001)," & --   DEVICE_ID reg

然后再次进行 CFG_IN_SLR0, CFG_OUT_SLR0,验证是否真的写进去了:

// config/status
STATE RESET;
RUNTEST 5 TCK;
SIR 18 TDI (005924) ;
SDR 160 TDI (0000000400000004800700140000000466aa9955) ;
SIR 18 TDI (004924) ;
SDR 32 TDI (00000000) TDO (3f5e0d40) MASK (08000000) ;
STATE RESET;
RUNTEST 5 TCK;
// BSDL
"CFG_IN_SLR0      (000101100100100100)," & --   PRIVATE
"CFG_OUT_SLR0     (000100100100100100)," & --   PRIVATE
// config/status
STATE RESET;
RUNTEST 5 TCK;
SIR 18 TDI (005924) ;
SDR 160 TDI (0000000400000004800700140000000466aa9955) ;
SIR 18 TDI (004924) ;
SDR 32 TDI (00000000) TDO (3f5e0d40) MASK (08000000) ;
STATE RESET;
RUNTEST 5 TCK;

这段操作是进行 Status Register Readback,见 UG570 的 Table 10-4。MASK 设为 08000000 应该是判断它的第 4 位:END_OF_STARTUP_STATUS(Table 9-25)。

如果是 Quartus 用户,也可以 生成 SVF,具体操作是:在 Programmer 中,点击 File->Create JAM, JBC, SVF or ISC file,然后在弹出的窗口中选择 svf 格式,导出即可。得到的 svf 文件一样可以用 openocd 来下载。

也可以quartus_cpf 在命令行中进行转换:

quartus_cpf -q 18MHz -g 3.3 -c -n p input.sof/pof output.svf

浅谈乱序执行 CPU(二:访存)

本文的内容已经整合到知识库中。

背景

之前写过一个浅谈乱序执行 CPU,随着学习的深入,内容越来越多,页面太长,因此把后面的一部分内容独立出来,变成了这篇博客文章。

本文主要讨论访存的部分。

本系列的所有文章:

用 sv2v+yosys 把 fpnew 转为 verilog 网表

背景

FPnew 是一个比较好用的浮点计算单元,但它是 SystemVerilog 编写的,并且用了很多高级特性,虽然闭源软件是支持的,但是开源拖拉机经常会遇到这样那样的问题。所以一直想用 sv2v 把它翻译成 Verilog,但此时的 Verilog 还有很多复杂的结构,再用 yosys 转换为一个通用可综合的网表。

步骤

经过一系列踩坑,一个很重要的点是要用最新的 sv2v(v0.0.9-24-gf868f06) 和 yosys(0.15+70)。Debian 打包的 yosys 版本比较老,不能满足需求。

首先,用 verilator 进行预处理,把一堆 sv 文件合成一个:

$ cat a.sv b.sv c.sv > test.sv
$ verilator -E test.sv > merged.sv
$ sed -i '/^`line/d' merged.sv

注意这里用 sed 去掉了无用的行号信息。然后,用 sv2v 进行转换:

$ sv2v merged.sv > merge.v
$ sed -i '/\$$fatal/d' merge.v

这里又用 sed 把不支持的 $fatal 去掉。最后,用 yosys 进行处理:

$ yosys -p 'read_verilog -defer merge.v' -p 'hierarchy -p fpnew_top' -p 'proc' -p 'opt' -p 'write_verilog -noattr output.v'

注意这里要用 read_verilog -defer,否则 yosys 会遇到 TAG_WIDTH=0 默认参数就直接例化,然后就出现 [0:-1] 这样的下标。read_verilog 的文档告诉了我们可以分两步做:

-defer
    only read the abstract syntax tree and defer actual compilation
    to a later 'hierarchy' command. Useful in cases where the default
    parameters of modules yield invalid or not synthesizable code.

这样就得到了简化后的 verilog 代码:

module \$paramod$011e4d7ee08c34f246a38322dc9967d23ecc8081\fpnew_opgroup_block_A94B6_B7406 (clk_i, rst_ni, operands_i, is_boxed_i, rnd_mode_i, op_i, op_mod_i, src_fmt_i, dst_fmt_i, int_fmt_i, vectorial_op_i, tag_i, in_valid_i, in_ready_o, flush_i, result_o, status_o, extension_bit_o, tag_o, out_valid_o, out_ready_i
, busy_o);
  wire _0_;
  wire _1_;
  wire [71:0] arbiter_output;
  output busy_o;
  wire busy_o;
  input clk_i;
  wire clk_i;
  input [2:0] dst_fmt_i;
  wire [2:0] dst_fmt_i;
  output extension_bit_o;
  wire extension_bit_o;
  input flush_i;
  // ...
endmodule

这样虽然比较丑陋,但是解决了 SystemVerilog 的问题。诚然,这样也失去了修改参数的能力,因为参数已经在 yosys 综合途中确定下来了。

Synopsys Design Compiler 综合实践

工艺库

综合很重要的一步是把 HDL 的逻辑变成一个个单元,这些单元加上连接方式就成为了网表。那么,基本单元有哪些,怎么决定用哪些基本单元?

这个就需要工艺库了,工艺库定义了一个个单元,单元的引脚、功能,还有各种参数,这样 Design Compiler 就可以按照这些信息去找到一个优化的网表。

Liberty 格式

网上可以找到一些 Liberty 格式的工艺库,比如 Nangate45,它的设定是 25 摄氏度,1.10 伏,属于 TT(Typical/Typical)的 Process Corner。

在里面可以看到一些基本单元的定理,比如 AND2_X1,就是一个 drive strength 是 1 的二输入与门:

cell (AND2_X1) {
    drive_strength : 1;
    pin (A1) {
        direction : input;
    }
    pin (A2) {
        direction : input;
    }
    pin (ZN) {
        direction : output;
        function : "(A1 & A2)";
    }
    /* ... */
}

这样就定义了两个输入 pin,一个输出 pin,还有它实现的功能。还有很重要的一点是保存了时序信息,比如:

lu_table_template (Timing_7_7) {
    variable_1 : input_net_transition;
    variable_2 : total_output_net_capacitance;
    index_1 ("0.0010,0.0020,0.0030,0.0040,0.0050,0.0060,0.0070");
    index_2 ("0.0010,0.0020,0.0030,0.0040,0.0050,0.0060,0.0070");
}
cell (AND2_X1) {
    pin (ZN) {
        timing () {
            related_pin : "A1";
            timing_sense : positive_unate;
            cell_fall(Timing_7_7) {
                index_1 ("0.00117378,0.00472397,0.0171859,0.0409838,0.0780596,0.130081,0.198535");
                index_2 ("0.365616,1.893040,3.786090,7.572170,15.144300,30.288700,60.577400");
                values ("0.0217822,0.0253224,0.0288237,0.0346827,0.0448323,0.0636086,0.100366", \
                        "0.0233179,0.0268545,0.0303556,0.0362159,0.0463659,0.0651426,0.101902", \
                        "0.0296429,0.0331470,0.0366371,0.0425000,0.0526603,0.0714467,0.108208", \
                        "0.0402311,0.0440292,0.0477457,0.0538394,0.0641187,0.0829203,0.119654", \
                        "0.0511250,0.0554077,0.0595859,0.0662932,0.0771901,0.0963434,0.133061", \
                        "0.0625876,0.0673198,0.0719785,0.0794046,0.0910973,0.110757,0.147656", \
                        "0.0748282,0.0800098,0.0851434,0.0933663,0.106111,0.126669,0.163872");
            }
    }
}

首先要看 cell_fall 后面的 template 是 Timing_7_7,可以看到 variable_1 和 variable_2 对应的是 input_net_transition 和 total_output_net_capacitance。这里 cell_fall 指的是输出 pin ZN 从 1 变成 0 的时候,这个变化从 A1 的变化传播到 ZN 的时间,这个时间和输入的 transition 时间(大概是从 0 到 1、从 1 到 0 的时间,具体从多少百分比到多少百分比见设置)和输出的 capacitance 有关,所以是一个查找表,查找的时候找最近的点进行插值。输出的 capacitance 取决于 wire load 和连接了这个输出的其他单元的输入。

除了 cell_fall/cell_rise 两种类型,还有 fall_transition 和 rise_transition,这就是输出引脚的变化时间,又作为后继单元的输入 transition 时间。

接下来,还能看到功耗的数据:

power_lut_template (Power_7_7) {
    variable_1 : input_transition_time;
    variable_2 : total_output_net_capacitance;
    index_1 ("0.0010,0.0020,0.0030,0.0040,0.0050,0.0060,0.0070");
    index_2 ("0.0010,0.0020,0.0030,0.0040,0.0050,0.0060,0.0070");
}
internal_power () {
    related_pin          : "A1";
    fall_power(Power_7_7) {
        index_1 ("0.00117378,0.00472397,0.0171859,0.0409838,0.0780596,0.130081,0.198535");
        index_2 ("0.365616,1.893040,3.786090,7.572170,15.144300,30.288700,60.577400");
        values ("2.707163,2.939134,3.111270,3.271119,3.366153,3.407657,3.420511", \
                "2.676697,2.905713,3.073189,3.236823,3.334156,3.373344,3.387400", \
                "2.680855,2.891263,3.047784,3.212948,3.315296,3.360694,3.377614", \
                "2.821141,3.032707,3.182020,3.338567,3.444608,3.488752,3.508229", \
                "3.129641,3.235525,3.357993,3.567372,3.743682,3.792092,3.808289", \
                "3.724304,3.738737,3.808381,3.980825,4.147999,4.278043,4.311323", \
                "4.526175,4.492292,4.510220,4.634217,4.814899,4.934862,5.047389");
    }
    rise_power(Power_7_7) {
        index_1 ("0.00117378,0.00472397,0.0171859,0.0409838,0.0780596,0.130081,0.198535");
        index_2 ("0.365616,1.893040,3.786090,7.572170,15.144300,30.288700,60.577400");
        values ("1.823439,1.926997,1.963153,2.028865,1.957837,2.123314,2.075262", \
                "1.796317,1.896145,1.960625,2.014112,2.050786,2.046472,1.972327", \
                "1.811604,1.886741,1.955658,1.978263,1.965671,1.963736,2.071227", \
                "1.997387,2.045930,2.092357,2.063643,2.099127,1.932089,2.131341", \
                "2.367285,2.439718,2.440043,2.403446,2.305848,2.351146,2.195145", \
                "2.916140,2.994325,3.044451,2.962881,2.836259,2.781564,2.633645", \
                "3.687718,3.756085,3.789394,3.792984,3.773583,3.593022,3.405552");
    }
}

可以看到,这也是一个查找表,也是按照输出的 rise/fall 有不同的功耗。巧合的是,功耗的查找表的 index_1/index_2 和上面的时序查找表是一样的。除了 internal power,还有 leakage power,定义如下:

leakage_power_unit : "1nW";
/* ... */
cell_leakage_power  : 50.353160;

leakage_power () {
    when           : "!A1 & !A2";
    value          : 40.690980;
}
leakage_power () {
    when           : "!A1 & A2";
    value          : 62.007550;
}
leakage_power () {
    when           : "A1 & !A2";
    value          : 41.294331;
}
leakage_power () {
    when           : "A1 & A2";
    value          : 57.419780;
}

可以看到,它的 leakage power 取决于输入的状态,单位是 1nW。

再来看 Flip Flop 的定义:

cell (DFFRS_X1) {
    ff ("IQ" , "IQN") {
        next_state          : "D";
        clocked_on          : "CK";
        preset              : "!SN";
        clear               : "!RN";
        clear_preset_var1   : L;
        clear_preset_var2   : L;
    }
    pin (D) {
        direction       : input;
        capacitance     : 1.148034;
        fall_capacitance    : 1.081549;
        rise_capacitance    : 1.148034;

        timing () {

            related_pin    : "CK";
            timing_type    : hold_rising;
            when               : "RN & SN";
            sdf_cond       : "RN_AND_SN === 1'b1";
            fall_constraint(Hold_3_3) {
                index_1 ("0.00117378,0.0449324,0.198535");
                index_2 ("0.00117378,0.0449324,0.198535");
                values ("0.002921,0.012421,0.011913", \
                        "0.002707,0.008886,0.005388", \
                        "0.139993,0.148595,0.137370");
            }
            rise_constraint(Hold_3_3) {
                index_1 ("0.00117378,0.0449324,0.198535");
                index_2 ("0.00117378,0.0449324,0.198535");
                values ("0.004193,0.015978,0.019836", \
                        "0.020266,0.031864,0.035343", \
                        "0.099118,0.113075,0.120979");
            }
        }
    }
}

可以看到,这里的属性变成了 setup/hold 时间。

SRAM 也有类似的定义,通常是写在单独的 lib 文件中,根据 width 和 depth 生成,比如 fakeram45_32x64.lib

cell(fakeram45_32x64) {
    area : 1754.536;
    interface_timing : true;
    memory() {
        type : ram;
        address_width : 5;
        word_width : 64;
    }
    pin(clk)   {
        direction : input;
        min_period : 0.174 ;
        internal_power(){
            rise_power(scalar) {
                values ("1498.650")
            }
            fall_power(scalar) {
                values ("1498.650")
            }
        }
    }
    bus(wd_in)   {
        bus_type : fakeram45_32x64_DATA;
        direction : input;
        timing() {
            related_pin     : clk;
            timing_type     : setup_rising ;
            rise_constraint(scalar) {
                values ("0.050");
            }
            fall_constraint(scalar) {
                values ("0.050");
            }
        } 
        internal_power(){
            when : "(we_in)";
            rise_power(scalar) {
                values ("14.987");
            }
            fall_power(scalar) {
                values ("14.987");
            }
        }
    }
}

也可以类似地看到,它的输入 setup/hold,功耗,面积等等信息。

DB 格式

在给 Design Compiler 配置工艺库前,需要用 Library Compiler 先把 lib 格式转换为更紧凑的二进制 db 格式:

read_lib xxx.lib
write_lib -format db xxx

实测部分 Liberty 文件会报错,不知道有没有修复的办法。另外,不同版本的 Library Compiler 生成的格式也不大一样,但都是兼容的。

在 Design Compiler 中,设置当前工艺库命令:

set_app_var target_library xxx.db
set_link_var target_library xxx.db
# or
set_app_var target_library {xxx.db yyy.db}
set_link_var target_library {xxx.db yyy.db}

综合脚本

准备好工艺库以后,就可以开始编写综合脚本了,通常有这么些步骤:

# step 1: read source code & set top level module name
read_file -format verilog xxx.v
read_file -format vhdl yyy.vhdl
current_design xxx

# step 2: setup timing constraints
create_clock clock -period 1.0000 # 1GHz for example
# other timing constraints:
# e.g. set_input_delay/set_output_delay

# step 3: synthesis
link
uniquify
# use this if you want to ungroup all hierarchy
# ungroup -flatten -all
# use this to retime design
# set_optimize_registers
compile_ultra

# step 4: check & report
check_timing
check_design
report_design
report_area -hierarchy
report_power -hierarchy
report_cell
report_timing -delay_type max
report_timing -delay_type min
report_constraint -all_violators
report_qor

# step 5: export
write -format ddc -hierarchy -output xxx.ddc
write_sdc -version 1.0 xxx.sdf
write -format verilog -hierarchy -output xxx.syn.v
write_sdc xxx.sdc

根据需求,进行自定义的修改。综合完成后,可以看到生成的 xxx.syn.v 文件里都是一个个的 cell,比如:

AND2X2 U3912 ( .A(n4416), .B(n2168), .Y(n3469) );
OAI21X1 U3913 ( .A(n2872), .B(n4589), .C(n2461), .Y(n3471) );
DFFPOSX1 clock_r_REG147_S1 ( .D(n7634), .CLK(clock), .Q(n7773) );

还有一些比较特殊的 cell,比如 TIEHI/TIELO 就是恒定输出 1/0,用于门控时钟的 CLKGATE/ICG 等,还有一些综合阶段不会出现的 cell,在后续阶段会使用。

参考文档

OpenROAD Flow 初尝试

背景

最近在尝试接触一些芯片前后端的知识。正好有现成的开源工具链 OpenROAD 来做这个事情,借此机会来学习一下整个流程。

尝试过程

首先 clone 仓库 OpenROAD-flow-scripts,然后运行:./build_openroad.sh,脚本会克隆一些仓库,自动进行编译。

编译中会找不到一些库,比如可能需要安装这些依赖:liblemon-dev libeigen3-dev libreadline-dev swig,此外运行的时候还需要 klayout 依赖。

如果遇到解决 cmake 找不到 LEMON 的问题,这是一个 BUG,可以运行下面的命令解决:

cd /usr/lib/x86_64-linux-gnu/cmake/lemon
cp lemonConfig.cmake LEMONConfig.cmake

编译后整个目录大概有 4.8G,输出的二进制目录是 133M。

如果要跑一下样例里的 nangate45 工艺的 gcd 例子,运行:

cd flow
make DESIGN_CONFIG=./designs/nangate45/gcd/config.mk

分析 GCD 测例

这个测例的代码提供了这样一个接口:

module gcd
(
  input  wire clk,
  input  wire [  31:0] req_msg,
  output wire req_rdy,
  input  wire req_val,
  input  wire reset,
  output wire [  15:0] resp_msg,
  input  wire resp_rdy,
  output wire resp_val
);
endmodule

从名字可以推断出,外部通过 req 发送请求到 GCD 模块,然后模块计算出 GCD 后再返回结果。

根据日志可以看到,从 verilog 到最终的 gds 文件,经过了这些步骤:

  1. 第一步用 yosys 综合(1_1_yosys),把 verilog 代码转化为网表,网表中的单元就是形如 NAND2_X1 DFF_X1 等这样由工艺库定义的一些单元。
  2. 第二步进行 floorplan(2_1_floorplan),规划出芯片的大小,逻辑放在哪个位置,输入输出引脚放在什么位置(2_2_floorplan_io),还要考虑 SRAM 等宏或者 IP(2_4_mplace),电源网络 PDN(2_6_floorplan_pdn)
  3. 第三步是 Placement,就是把前面得到的一些 cell 放到芯片上的 (x,y) 坐标上
  4. 第四步是 Clock Tree Synthesis(4_1_cts),简称 CTS,生成时钟树
  5. 第五步是进行路由连线,OpenROAD 有两个路由:FastRoute(5_1_fastroute)和 TritonRoute(5_2_TritonRoute)
  6. 第六步输出结果到 gds 文件(6_1_merge)。

这些步骤可以在仓库的 flow/Makefile 里面看得比较清晰,英文版摘抄如下:

  1. SYNTHESIS
    1. Run Synthesis using yosys
  2. FLOORPLAN
    1. Translate verilog to def
    2. IO Placement (random)
    3. Timing Driven Mixed Size Placement (tdms)
    4. Macro Placement
    5. Tapcell and Welltie insertion
    6. PDN generation
  3. PLACE
    1. Global placement without placed IOs, timing-driven, and routability-driven
    2. IO placement (non-random)
    3. Global placement with placed IOs, timing-driven, and routability-driven
    4. Resizing & Buffering
    5. Detail placement
  4. CTS(Clock Tree Synthesis)
    1. Run TritonCTS
    2. Filler cell insertion
  5. ROUTING
    1. Run global route (FastRoute)
    2. Run detailed route (TritonRoute)

最后生成的 gds,用 KLayout 打开,可以看到这个样子:

日志里可以看到,预测的总功耗是 1.71 mW,面积占用是 703 um^2。

还跑了一下其他样例设计的 gds,比如 ibex:

日志里可以看到,预测的总功耗是 10.1 mW,面积占用是 32176 um^2。

还有 tiny rocket:

日志里可以看到,预测的总功耗是 36.8 mW,面积占用是 52786 um^2。

工艺库常见术语

  • slvt/lvt/rvt/hvt: super-low/low/regular/high V threshold 前者速度快:阈值电压低,同时漏电流大
  • ss/tt/ff: slow-slow/typical-typical/fast-fast 后者速度快:电压高,温度低,比如 SS(0.99V 125C)TT(1.10V 25C)FF(1.21V -40C);有时候还会看到 ssg,可以理解为 ss 的比较精确的版本,因此没有那么悲观,延迟比 SS 低一些,详见 STA | ssg 跟 ss corner 的区别——谬误更正版
  • c+数字:表示的是 channel length,c40 表示 40nm,数字越大速度越慢,能耗越低
  • 数字+track:表示的是 track height,sc12 表示 12-track,数字越大速度越快

ARM 的文档 Choosing the physical IP libraries 描述了 Channel length, Track height, Voltage threshold 等不同的选择。

综合来说,如果要更低的延迟,选择低 vt,小 c 和大 track,反之如果要更低的能耗,选择高 vt,大 c 和 小 track。

CCS v.s. NLDM

由于物理的特性比较复杂,工艺库里描述的也只是一个大致的模型,刻画了这些 cell 的特性,那么自然可以选取不同的模型。NLDM(上面举的例子就是 NLDM),CCS 就是常见的两个模型,相比之下,CCS 更精确,同时参数更多。更精确的还有直接用 SPICE 描述的电路。详细的对比可以看下面的参考文档。

参考文档

通过 JTAG 对 VCU128 上的 Rocket Chip 进行调试

前言

两年前,我尝试过用 BSCAN JTAG 来配置 Rocket Chip 的调试,但是这个方法不是很好用,具体来说,如果有独立的一组 JTAG 信号,配置起来会更方便,而且不用和 Vivado 去抢,OpenOCD 可以和 Vivado hw_server 同时运行和工作。但是,苦于 VCU128 上没有 PMOD 接口,之前一直没考虑过在 VCU128 上配置独立的 JTAG。然后最近研究了一下,终于解决了这个问题。

寻找 JTAG 接口

前几天在研究别的问题的时候,看到 VCU128 文档中的这段话:

The FT4232HL U8 multi-function USB-UART on the VCU128 board provides three level-shifted
UART connections through the single micro-AB USB connector J2.
• Channel A is configured in JTAG mode to support the JTAG chain
• Channel B implements 4-wire UART0 (level-shifted) FPGA U1 bank 67 connections
• Channel C implements 4-wire UART1 (level-shifted) FPGA U1 bank 67 connections
• Channel D implements 2-wire (level-shifted) SYSCTLR U42 bank 501 connections

其中 Channel A 是到 FPGA 本身的 JTAG 接口,是给 Vivado 用的,如果是通过 BSCAN 的方式,也是在这个 Channel 上,但是需要经过 FPGA 自己的 TAP 再隧道到 BSCAN 上,比较麻烦。Channel B 和 C 是串口,Channel D 是连接 VCU128 上的 System Controller 的。之前的时候,都是直接用 Channel B 做串口,然后突发奇想:注意到这里是 4-wire UART,说明连接到 FPGA 是四条线,那是不是也可以拿来当 JTAG 用?

查询了一下 FT4232H 的文档,发现它的 Channel A 和 Channel B 是支持 MPSSE 模式的,在 MPSSE 模式下,可以当成 JTAG 使用:

Signal Channel A Channel B
TCK 12 22
TDI 13 23
TDO 14 24
TMS 15 25

对照 VCU128 的 Schematic 看,虽然引脚的编号不大一样,可以发现,Channel A 和 B 分别对应了 ADBUS0-4 和 BDBUS 0-4,对应到 schematic 上的名字是:

  • ADBUS0 - FT4232_TCK
  • ADBUS1 - FT4232_TDI
  • ADBUS2 - FMCP_HSPC_TDO
  • ADBUS3 - FT4232_TMS

这一组是直接连到 FPGA 上专用的 JTAG 引脚,其中 TDO 是连接了额外的逻辑,可以把 FMC 接口上的 JTAG 连接成 daisy chain。

  • ADBUS0 - FTDI_UART0_TXD_LS - UART0_RXD - BP26 -> TCK
  • ADBUS1 - FTDI_UART0_RXD_LS - UART0_TXD - BN26 -> TDI
  • ADBUS2 - FTDI_UART0_RTS_B_LS - UART0_RTS_B - BP22 -> TDO
  • ADBUS3 - FTDI_UART0_CTS_B_LS - UART0_CTS_B - BP23 -> TMS

这里的 RXD/TXD 名字交换也是很容易看错,要小心,只要记住 FT4232H 要求的顺序一定是 TCK-TDI-TDO-TMS 即可。对应到 vivado 内的 xdc 就是这么写:

set_property -dict {PACKAGE_PIN BP26 IOSTANDARD LVCMOS18} [get_ports jtag_TCK]
set_property -dict {PACKAGE_PIN BN26 IOSTANDARD LVCMOS18} [get_ports jtag_TDI]
set_property -dict {PACKAGE_PIN BP22 IOSTANDARD LVCMOS18} [get_ports jtag_TDO]
set_property -dict {PACKAGE_PIN BP23 IOSTANDARD LVCMOS18} [get_ports jtag_TMS]

接下来,我们要把 Rocket Chip 的 JTAG 信号接出来。

配置 Rocket Chip 的 JTAG

配置 Rocket Chip 的 JTAG,大概需要如下几步:

  1. 给 Config 加上 WithJtagDTM,以 JTAG 作为 DTM 模块
  2. 给 Subsystem 加上 HasPeripheryDebug
  3. 给 SubsystemModuleImp 加上 HasPeripheryDebugModuleImp
  4. 把 JTAG 信号连到自己的顶层模块上

最后一步的相关代码,首先,按照 spec 要求,把 DM 输出的 ndreset 信号连到整个 Rocket 的 reset 上:

// ndreset can reset all harts
val childReset = reset.asBool | target.debug.map(_.ndreset).getOrElse(false.B)
target.reset := childReset

接着,把 JTAG 的信号连到顶层:

val systemJtag = target.debug.get.systemjtag.get
systemJtag.jtag.TCK := io.jtag.TCK
systemJtag.jtag.TMS := io.jtag.TMS
systemJtag.jtag.TDI := io.jtag.TDI
io.jtag.TDO := systemJtag.jtag.TDO

除了 JTAG 信号以外,还需要配置 IDCODE 相关的变量:

systemJtag.mfr_id := p(JtagDTMKey).idcodeManufId.U(11.W)
systemJtag.part_number := p(JtagDTMKey).idcodePartNum.U(16.W)
systemJtag.version := p(JtagDTMKey).idcodeVersion.U(4.W)

最后这一部分比较关键:首先,JTAG 部分的 reset 是独立于其余部分的,这里简单期间就连到了外部的 reset,其实可以改成 FPGA program 的时候进行 reset,然后等时钟来了就释放,实现方法可以参考文末的链接。resetctrl 是给 DM 知道哪些核心被 reset 了,最后是调用 rocket chip 自带的函数。这里踩的一个坑是,传给 systemJtag.reset 一定得是异步的,因为这个时钟域的时钟都是 jtag 的 TCK 信号,所以很可能错过一开始的 reset 信号,所以这里要用异步的 reset。

// MUST use async reset here
// otherwise the internal logic(e.g. TLXbar) might not function
// if reset deasserted before TCK rises
systemJtag.reset := reset.asAsyncReset
target.resetctrl.foreach { rc =>
  rc.hartIsInReset.foreach { _ := childReset }
}
Debug.connectDebugClockAndReset(target.debug, clock)

配置 JTAG 相关的约束

这部分是参考了 pulp 的 VCU118 中 jtag 信号的约束文件。照着抄就行:

create_clock -period 100.000 -name jtag_TCK [get_ports jtag_TCK]
set_input_jitter jtag_TCK 1.000
set_property CLOCK_DEDICATED_ROUTE FALSE [get_nets jtag_TCK_IBUF_inst/O]
set_input_delay -clock jtag_TCK -clock_fall 5.000 [get_ports jtag_TDI]
set_input_delay -clock jtag_TCK -clock_fall 5.000 [get_ports jtag_TMS]
set_output_delay -clock jtag_TCK 5.000 [get_ports jtag_TDO]
set_max_delay -to [get_ports jtag_TDO] 20.000
set_max_delay -from [get_ports jtag_TMS] 20.000
set_max_delay -from [get_ports jtag_TDI] 20.000
set_clock_groups -asynchronous -group [get_clocks jtag_TCK] -group [get_clocks -of_objects [get_pins system_i/clk_wiz_0/inst/mmcme4_adv_inst/CLKOUT1]]
set_property ASYNC_REG TRUE [get_cells -hier -regexp "system_i/rocketchip_wrapper_0/.*/cdc_reg_reg.*"]

和原版本稍微改了一下,一个区别是 set_clock_groups 的时候,第二个时钟参数用的是 Clocking Wizard 的输出,同时也是 Rocket Chip 自己的时钟输入;另一个区别是用的 ASYNC_REG 查询语句不大一样。我没有具体分析过这些约束为什么这么写,不确定这些约束是否都合理,是否都是需要的,没有测试过不带这些约束会不会出问题。

运行 OpenOCD 和 GDB

最后,采用如下的 OpenOCD 配置来连接:

# openocd config
# use ftdi channel 1
# vcu128 uart0 as jtag
adapter speed 10000
adapter driver ftdi
ftdi_vid_pid 0x0403 0x6011 # FT4232H
ftdi_layout_init 0x0008 0x000b # Output: TCK TDI TMS
ftdi_tdo_sample_edge falling
ftdi_channel 1 # channel B
reset_config none

set _CHIPNAME riscv
jtag newtap $_CHIPNAME cpu -irlen 5

set _TARGETNAME $_CHIPNAME.cpu

target create $_TARGETNAME.0 riscv -chain-position $_TARGETNAME
$_TARGETNAME.0 configure -work-area-phys 0x80000000 -work-area-size 10000 -work-area-backup 1

然后就可以连接到 Rocket Chip 上:

> openocd -f openocd.cfg
Open On-Chip Debugger 0.11.0-rc2
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "jtag". To override use 'transport select <transport>'.
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : clock speed 10000 kHz
Info : JTAG tap: riscv.cpu tap/device found: 0x10000913 (mfg: 0x489 (SiFive Inc), part: 0x0000, ver: 0x1)
Info : datacount=2 progbufsize=16
Info : Disabling abstract command reads from CSRs.
Info : Examined RISC-V core; found 1 harts
Info :  hart 0: XLEN=64, misa=0x800000000094112d
Info : starting gdb server for riscv.cpu.0 on 3333
Info : Listening on port 3333 for gdb connections
> riscv64-unknown-elf-gdb
(gdb) target remote localhost:3333
Remote debugging using localhost:3333
0x00000000800001a4 in ?? ()

可以看到调试功能都正常了。

总结

调试这个功能大概花了一天的时间,主要遇到了下面这些问题:

  1. 调试模块的 reset 信号需要是异步的,这个是通过仿真(Remote Bitbang 连接 OpenOCD)调试出来的
  2. 看 schematic 的时候 rxd/txd 搞反了,后来仔细对比才找到了正确的对应关系
  3. OpenOCD 配置的 irlen 一开始写的不对,dmcontrol 读出来是 0,一直以为是有别的问题,结果改了 irlen 后立马就成功了,这个问题可以让 OpenOCD 自动推断 irlen 来发现

参考

解决 k3s 中 traefik 不会转发 X-Forwarded-For 等头部的问题

背景

把应用迁移到 k3s 中,然后用了 traefik 作为 Ingress Controller,发现无法获得真实的用户 IP 地址,而是 cni 内部的地址。搜索了一番,找到了靠谱的解决方案:

Traefik Kubernetes Ingress and X-Forwarded-Headers

具体来说,需要给 traefik 传额外的参数,方法是在 k3s 的配置目录下,添加一个 HelmChartConfig:

# edit /var/lib/rancher/k3s/server/manifests/traefik-config.yaml
# content:
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    additionalArguments:
      - "--entryPoints.web.proxyProtocol.insecure"
      - "--entryPoints.web.forwardedHeaders.insecure"

这样相当于让 traefik 信任前一级代理传过来的这些头部。更精细的话,还可以设置信任的 IP 地址范围,不过如果 traefik 不会直接暴露出去,就不用考虑这个问题了。

在 M1 上运行 Windows ARM 虚拟机

目前 Windows ARM 出了预览版,可以从 Windows Insider Preview Downloads 下载,得到一个 9.5GB 的 vhdx 文件。

接着,用 qemu-img 转换为 vmdk 格式:

$ qemu-img convert Windows11_InsiderPreview_Client_ARM64_en-us_22533.vhdx -O vmdk -o adapter_type=lsilogic Windows11_InsiderPreview_Client_ARM64_en-us_22533.vmdk

转换后,在 VMWare Fusion for Apple Silicon Tech Preview 中,选择从已有的 vmdk 中创建虚拟机,启动前修改一些设置,特别是内存,默认 256MB 肯定不够,默认单核 CPU 也太少了一些。内存不足可能导致安装失败,记住要第一次启动前设置。

启动以后会无法访问网络,按照下面网页里的方法设置网络:

https://www.gerjon.com/vmware/vmware-fusion-on-apple-silicion-m1/

需要注意的是,bcdedit 选项填的 IP 地址一般是 bridge 上的地址,比如 bridge101 的地址。

然后就可以正常工作了!

VMWare 论坛里,还谈到了下面几个问题的解决方法:

为了让声音工作,可以修改 vmx 文件,设置 guestOS:

guestOS = "arm-windows11-64"

这样声音就可以正常播放了。

分辨率的问题,可以用 RDP 来解决:首先在虚拟机里打开 Remote Desktop,然后用 macOS 的 Microsoft Remote Desktop Beta 访问即可。

系统里没有 Microsoft Store,要安装的话,用命令行的 Powershell 执行下面的命令:

wsreset.exe -i

这个方法见 Parallels Desktop KB128520

UPDATE:

VMware Fusion 发布了新版本 22H2,有官方的 Windows 11 on ARM 支持了:

  • Windows 11 on Intel and Apple Silicon with 2D GFX and Networking
  • VMtools installation for Windows 11 GOS on M1
  • Improved Linux support on M1
  • 3D Graphics HW Acceleration and OpenGL 4.3 in Linux VMs* (Requires Linux 5.19+ & Mesa 22.1.3+)
  • Virtual TPM Device
  • Fast Encryption
  • Universal Binary

并且不需要上面写的网卡的 workaround 了:

vmxnet3 Networking Drivers for Windows on ARM

While Windows does not yet ship with our vmxnet3 networking driver for
Windows on ARM as it now does for Intel, the VMware Tools ISO on ARM
contains the 2 currently supported drivers for graphics and networking.

实测安装 VMware Tools 以后,就可以成功用 vmxnet3 网卡上网了,不需要之前的 bcdedit 方案。

但是目前测试 Linux 虚拟机有一些问题,一些内核版本在启动的时候 vmwgfx 驱动会报错,不能正常显示,但是系统是正常启动的,可以通过 SSH 访问。我测试的情况见下:

Linux 5.19.6(5.19.0-1-arm64): 可以正常启动和显示,但是没有 3D 加速(按照 VMware 的说法是需要 5.19 内核 + Mesa 22.1.1 以上,但是我的环境版本已经符合了这个要求,可能是 Debian 打包缺了什么东西);

UPDATE(2022-09-27):更新了一下系统,现在 glxinfo 可以看到 SVGA 了:

Device: SVGA3D; build: RELEASE; LLVM; (0x406)
Version: 22.2.0
Accelerated: no

但是显示有一些 BUG,刷新不正常。

Linux 5.18(5.18.0-0.bpo.1-arm64): VMware Fusion has encountered an error and has shut down the virtual machine

Linux 5.16(5.16.0-0.bpo.4-arm64): SSH 也没启动,看不到内核日志

Linux 5.15.15(5.15.0-0.bpo.3-arm64):

图形界面起不来,可以通过 SSH 访问。

[   10.765945] kernel BUG at drivers/gpu/drm/vmwgfx/vmwgfx_drv.h:1627!
[   10.766206] Call trace:
[   10.766207]  vmw_event_fence_action_queue+0x328/0x330 [vmwgfx]
[   10.766210]  vmw_stdu_primary_plane_atomic_update+0xd8/0x220 [vmwgfx]
[   10.766214]  drm_atomic_helper_commit_planes+0xf8/0x21c [drm_kms_helper]
[   10.766222]  drm_atomic_helper_commit_tail+0x5c/0xb0 [drm_kms_helper]
[   10.766225]  commit_tail+0x160/0x190 [drm_kms_helper]
[   10.766227]  drm_atomic_helper_commit+0x16c/0x400 [drm_kms_helper]
[   10.766230]  drm_atomic_commit+0x58/0x6c [drm]
[   10.766242]  drm_atomic_helper_set_config+0xe0/0x120 [drm_kms_helper]
[   10.766245]  drm_mode_setcrtc+0x1ac/0x680 [drm]
[   10.766249]  drm_ioctl_kernel+0xd0/0x120 [drm]
[   10.766253]  drm_ioctl+0x250/0x460 [drm]
[   10.766257]  vmw_generic_ioctl+0xbc/0x160 [vmwgfx]
[   10.766261]  vmw_unlocked_ioctl+0x24/0x30 [vmwgfx]
[   10.766264]  __arm64_sys_ioctl+0xb4/0x100
[   10.766287]  invoke_syscall+0x50/0x120
[   10.766300]  el0_svc_common.constprop.0+0x4c/0xf4
[   10.766302]  do_el0_svc+0x30/0x9c
[   10.766303]  el0_svc+0x28/0xb0
[   10.766327]  el0t_64_sync_handler+0x1a4/0x1b0
[   10.766328]  el0t_64_sync+0x1a0/0x1a4

回收硬盘空间:

sudo vmware-toolbox-cmd disk shrink /

RISC-V Vector 1.0 工具链构建

不久前 RVV 1.0 标准终于是出来了,但是工具链的支持目前基本还处于刚 upstream 还没有 release 的状态。而目前 RVV 1.0 的支持主要在 LLVM 上比较活跃,因此也是采用 LLVM Clang + GCC Newlib Toolchain 的方式进行配合,前者做 RVV 1.0 的编译,后者提供 libc 等基础库。

UPDATE: LLVM 14 已经发布,这个版本已经支持 RVV 1.0,直接从 https://apt.llvm.org 等地安装 LLVM 14 即可。

LLVM Clang 直接采用 upstream 即可。编译选项:

$ cmake -G Ninja ../llvm -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_INSTALL_PREFIX=/prefix/llvm -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS="clang" -DLLVM_TARGETS_TO_BUILD="RISCV"
$ ninja
$ ninja install
$ /prefix/llvm/bin/clang --version
clang version 14.0.0 (https://github.com/llvm/llvm-project.git 8d298355ca3778a47fd6b3110aeee03ea5e8e02b)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /data/llvm/bin

还需要配合一个 GCC 工具链才可以完整地工作。可以直接采用 riscv-gnu-toolchain nightly 版本,比如 riscv64-elf-ubuntu-20.04-nightly-2022.01.17-nightly.tar.gz。下载以后解压,得到 riscv 目录,GCC 版本是比较新的:

$ ~/riscv/bin/riscv64-unknown-elf-gcc --version
riscv64-unknown-elf-gcc (GCC) 11.1.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

但是如果编译 C++ 程序,链接的时候就会报错:prefixed ISA extension must separate with _,这是因为 riscv-gnu-toolchain 仓库的 binutils 版本不够新,在 upstream 的 binutils 里面已经修复了这个问题。所以 clone 下来,然后编译,覆盖掉 riscv-gnu-toolchain 里面的 binutils:

UPDATE: binutils 2.38 已经发布,用这个版本即可。

$ ../configure --target=riscv64-unknown-elf --prefix=$HOME/riscv --disable-gdb --disable-sim --disable-libdecnumber --disable-readline
$ make
$ make install
$ ~/riscv/bin/riscv64-unknown-elf-ld --version
GNU ld (GNU Binutils) 2.38.50.20220125
Copyright (C) 2022 Free Software Foundation, Inc.
This program is free software; you may redistribute it under the terms of
the GNU General Public License version 3 or (at your option) a later version.
This program has absolutely no warranty.

然后编译程序的时候,使用 clang,配置参数 --gcc-toolchain=~/riscv 即可让 clang 找到 GNU 工具链。这样就可以编译出来 RVV 1.0 的程序了:

$ /llvm/bin/clang --target=riscv64-unknown-elf -O2 -march=rv64gcv1p0 -menable-experimental-extensions -mllvm --riscv-v-vector-bits-min=256 --gcc-toolchain=$HOME/riscv add.cpp -o add
$ /data/llvm/bin/llvm-objdump --mattr=+v -S add
   1020e: 57 70 04 c5   vsetivli        zero, 8, e32, m1, ta, mu
   10212: 07 64 03 02   vle32.v v8, (t1)
   10216: 87 e4 03 02   vle32.v v9, (t2)
   1021a: 93 07 07 fe   addi    a5, a4, -32
   1021e: 07 e5 07 02   vle32.v v10, (a5)
   10222: 87 65 07 02   vle32.v v11, (a4)
   10226: 57 14 85 02   vfadd.vv        v8, v8, v10
   1022a: d7 94 95 02   vfadd.vv        v9, v9, v11
   1022e: 93 07 05 fe   addi    a5, a0, -32
   10232: 27 e4 07 02   vse32.v v8, (a5)
   10236: a7 64 05 02   vse32.v v9, (a0)

可以看到 llvm 的自动向量化是工作的。此外,也可以编写 rvv intrinsic。

jiege.ch 停用

jiege.ch 域名不再续费,之后一直继续用 jia.je 这个域名。

分析 Rocket Chip 中 Diplomacy 系统

概念

Diplomacy 主要实现了两个功能:

  1. 把整个总线结构在代码中表现出来
  2. 自动配置总线中各个端口的参数

具体来说,第一点实现了类似 Vivado Board Design 中连线的功能,第二点则是保证总线两端的参数一致,可以连接起来。

Diplomacy 为了表示总线的结构,每个模块可以对应一个 Node,Node 和 Node 之间连接形成一个图。Node 的类型主要有以下几个:

  1. Client:对应 AXI 里面的 Master,发起请求
  2. Manager:对应 AXI 里面的 Slave,处理请求
  3. Adapter:对应 AXI Width Converter/Clock Converter/AXI4 to AXI3/AXI4 to AHB bridge 等,会修改 AXI 的参数,然后每个输入对应一个输出
  4. Nexus:对应 AXI Crossbar,多个输入和多个输出

每个 Node 可能作为 Manager 连接上游的 Client,这个叫做入边(Inward Edge);同样地,也可以作为 Client 连接下游的 Manager,这个是出边(Outward Edge)。想象成一个 DAG,从若干个 Client 流向 Manager。

连接方式采用的是 := :=* :*= :*=* 操作符,左侧是 Client,右侧是 Manager。

Rocket Chip 总线结构

Rocket Chip 主要有以下几个总线:

  1. sbus: System Bus
  2. mbus: Memory Bus
  3. cbus: Control Bus
  4. pbus: Periphery Bus
  5. fbus: Frontend Bus

图示可以见参考文档中的链接,不过链接中的结构和实际的有一些区别。目前的 Rocket Chip 内存结构大致是这样:

fbus -> sbus -> mbus
tile --/    \-> cbus -> pbus

主要是 pbus 的位置从连接 sbus 移动到了连接 cbus。

相关代码:

/** Parameterization of a topology containing three additional, optional buses for attaching MMIO devices. */
case class HierarchicalBusTopologyParams(
  pbus: PeripheryBusParams,
  fbus: FrontBusParams,
  cbus: PeripheryBusParams,
  xTypes: SubsystemCrossingParams,
  driveClocksFromSBus: Boolean = true
) extends TLBusWrapperTopology(
  instantiations = List(
    (PBUS, pbus),
    (FBUS, fbus),
    (CBUS, cbus)),
  connections = List(
    (SBUS, CBUS, TLBusWrapperConnection  .crossTo(xTypes.sbusToCbusXType, if (driveClocksFromSBus) Some(true) else None)),
    (CBUS, PBUS, TLBusWrapperConnection  .crossTo(xTypes.cbusToPbusXType, if (driveClocksFromSBus) Some(true) else None)),
    (FBUS, SBUS, TLBusWrapperConnection.crossFrom(xTypes.fbusToSbusXType, if (driveClocksFromSBus) Some(false) else None)))
)

当然了,也有简化版的 JustOneBusTopology,那就只有 SystemBus 了。如果再配置了 CoherentBusTopology,那么 SBUS 和 MBUS 之间还有一层 L2:

/** Parameterization of a topology containing a banked coherence manager and a bus for attaching memory devices. */
case class CoherentBusTopologyParams(
  sbus: SystemBusParams, // TODO remove this after better width propagation
  mbus: MemoryBusParams,
  l2: BankedL2Params,
  sbusToMbusXType: ClockCrossingType = NoCrossing,
  driveMBusClockFromSBus: Boolean = true
) extends TLBusWrapperTopology(
  instantiations = (if (l2.nBanks == 0) Nil else List(
    (MBUS, mbus),
    (L2, CoherenceManagerWrapperParams(mbus.blockBytes, mbus.beatBytes, l2.nBanks, L2.name, sbus.dtsFrequency)(l2.coherenceManager)))),
  connections = if (l2.nBanks == 0) Nil else List(
    (SBUS, L2,   TLBusWrapperConnection(driveClockFromMaster = Some(true), nodeBinding = BIND_STAR)()),
    (L2,  MBUS,  TLBusWrapperConnection.crossTo(
      xType = sbusToMbusXType,
      driveClockFromMaster = if (driveMBusClockFromSBus) Some(true) else None,
      nodeBinding = BIND_QUERY))
  )
)

参考文档

Chisel3 Cookbook

Chisel 版本选择

尽量选择较新版本的 Chisel。Chisel v3.5 完善了编译器插件,使得生成的代码中会包括更多变量名信息。

去掉输出 Verilog 文件中的寄存器随机初始化

版本:FIRRTL >= 1.5.0-RC2

代码:

new ChiselStage().execute(
  Array("-X", "verilog", "-o", s"${name}.v"),
  Seq(
    ChiselGeneratorAnnotation(genModule),
    CustomDefaultRegisterEmission(
      useInitAsPreset = false,
      disableRandomization = true
    )
  )
)

设置 disableRandomization=true 即可。useInitAsPreset 不建议开启。

关闭 FIRRTL 优化,输出尽可能与源代码一致的 Verilog

设置 Chisel 生成 MinimumVerilog:

new ChiselStage().execute(
  Array("-X", "mverilog", "-o", s"${name}.v"),
  Seq(
    ChiselGeneratorAnnotation(genModule)
  )
)

此时代码中会保留更多原始 Chisel 代码的元素。

重命名 AXI4 为标准命名

Rocket Chip 中 AXI4Bundle 直接生成的名字和标准写法不同,可以利用 Chisel3 3.5.0 的 DataView 功能进行重命名:

// https://www.chisel-lang.org/chisel3/docs/explanations/dataview.html
// use standard names
class StandardAXI4BundleBundle(val addrBits: Int, val dataBits: Int, val idBits: Int)
    extends Bundle {
  val AWREADY = Input(Bool())
  val AWVALID = Output(Bool())
  val AWID = Output(UInt(idBits.W))
  val AWADDR = Output(UInt(addrBits.W))
  val AWLEN = Output(UInt(8.W))
  val AWSIZE = Output(UInt(3.W))
  val AWBURST = Output(UInt(2.W))
  val AWLOCK = Output(UInt(1.W))
  val AWCACHE = Output(UInt(4.W))
  val AWPROT = Output(UInt(3.W))
  val AWQOS = Output(UInt(4.W))

  val WREADY = Input(Bool())
  val WVALID = Output(Bool())
  val WDATA = Output(UInt(dataBits.W))
  val WSTRB = Output(UInt((dataBits / 8).W))
  val WLAST = Output(Bool())

  val BREADY = Output(Bool())
  val BVALID = Input(Bool())
  val BID = Input(UInt(idBits.W))
  val BRESP = Input(UInt(2.W))

  val ARREADY = Input(Bool())
  val ARVALID = Output(Bool())
  val ARID = Output(UInt(idBits.W))
  val ARADDR = Output(UInt(addrBits.W))
  val ARLEN = Output(UInt(8.W))
  val ARSIZE = Output(UInt(3.W))
  val ARBURST = Output(UInt(2.W))
  val ARLOCK = Output(UInt(1.W))
  val ARCACHE = Output(UInt(4.W))
  val ARPROT = Output(UInt(3.W))
  val ARQOS = Output(UInt(4.W))

  val RREADY = Output(Bool())
  val RVALID = Input(Bool())
  val RID = Input(UInt(idBits.W))
  val RDATA = Input(UInt(dataBits.W))
  val RRESP = Input(UInt(2.W))
  val RLAST = Input(Bool())
}

object StandardAXI4BundleBundle {
  implicit val axiView = DataView[StandardAXI4BundleBundle, AXI4Bundle](
    vab =>
      new AXI4Bundle(
        AXI4BundleParameters(vab.addrBits, vab.dataBits, vab.idBits)
      ),
    // AW
    _.AWREADY -> _.aw.ready,
    _.AWVALID -> _.aw.valid,
    _.AWID -> _.aw.bits.id,
    _.AWADDR -> _.aw.bits.addr,
    _.AWLEN -> _.aw.bits.len,
    _.AWSIZE -> _.aw.bits.size,
    _.AWBURST -> _.aw.bits.burst,
    _.AWLOCK -> _.aw.bits.lock,
    _.AWCACHE -> _.aw.bits.cache,
    _.AWPROT -> _.aw.bits.prot,
    _.AWQOS -> _.aw.bits.qos,
    // W
    _.WREADY -> _.w.ready,
    _.WVALID -> _.w.valid,
    _.WDATA -> _.w.bits.data,
    _.WSTRB -> _.w.bits.strb,
    _.WLAST -> _.w.bits.last,
    // B
    _.BREADY -> _.b.ready,
    _.BVALID -> _.b.valid,
    _.BID -> _.b.bits.id,
    _.BRESP -> _.b.bits.resp,
    // AR
    _.ARREADY -> _.ar.ready,
    _.ARVALID -> _.ar.valid,
    _.ARID -> _.ar.bits.id,
    _.ARADDR -> _.ar.bits.addr,
    _.ARLEN -> _.ar.bits.len,
    _.ARSIZE -> _.ar.bits.size,
    _.ARBURST -> _.ar.bits.burst,
    _.ARLOCK -> _.ar.bits.lock,
    _.ARCACHE -> _.ar.bits.cache,
    _.ARPROT -> _.ar.bits.prot,
    _.ARQOS -> _.ar.bits.qos,
    // R
    _.RREADY -> _.r.ready,
    _.RVALID -> _.r.valid,
    _.RID -> _.r.bits.id,
    _.RDATA -> _.r.bits.data,
    _.RRESP -> _.r.bits.resp,
    _.RLAST -> _.r.bits.last
  )
  implicit val axiView2 = StandardAXI4BundleBundle.axiView.invert(ab =>
    new StandardAXI4BundleBundle(
      ab.params.addrBits,
      ab.params.dataBits,
      ab.params.idBits
    )
  )
}

// usage
val MEM = IO(new StandardAXI4BundleBundle(32, 64, 4))
MEM <> target.mem_axi4.head.viewAs[StandardAXI4BundleBundle]

给所有模块添加名称前缀

有些时候,我们希望给所有模块添加一个名称前缀,防止可能出现的冲突。

在 Chisel 3 中,可以使用自定义 FIRRTL Transform 来实现这个功能。这一部分的实现参考了 chisel issue #1059

import firrtl._
import firrtl.annotations.NoTargetAnnotation
import firrtl.options.Dependency
import firrtl.passes.PassException
import firrtl.transforms.DedupModules

// adapted from https://github.com/chipsalliance/chisel3/issues/1059#issuecomment-814353578

/** Specifies a global prefix for all module names. */
case class ModulePrefix(prefix: String) extends NoTargetAnnotation

/** FIRRTL pass to add prefix to module names
  */
object PrefixModulesPass extends Transform with DependencyAPIMigration {
  // we run after deduplication to save some work
  override def prerequisites = Seq(Dependency[DedupModules])

  // we do not invalidate the results of any prior passes
  override def invalidates(a: Transform) = false

  override protected def execute(state: CircuitState): CircuitState = {
    val prefixes = state.annotations.collect { case a: ModulePrefix =>
      a.prefix
    }.distinct
    prefixes match {
      case Seq() =>
        logger.info("[PrefixModulesPass] No ModulePrefix annotation found.")
        state
      case Seq("") => state
      case Seq(prefix) =>
        val c = state.circuit.mapModule(onModule(_, prefix))
        state.copy(circuit = c.copy(main = prefix + c.main))
      case other =>
        throw new PassException(
          s"[PrefixModulesPass] found more than one prefix annotation: $other"
        )
    }
  }

  private def onModule(m: ir.DefModule, prefix: String): ir.DefModule =
    m match {
      case e: ir.ExtModule => e.copy(name = prefix + e.name)
      case mod: ir.Module =>
        val name = prefix + mod.name
        val body = onStmt(mod.body, prefix)
        mod.copy(name = name, body = body)
    }

  private def onStmt(s: ir.Statement, prefix: String): ir.Statement = s match {
    case i: ir.DefInstance => i.copy(module = prefix + i.module)
    case other             => other.mapStmt(onStmt(_, prefix))
  }
}

实现思路就是遍历 IR,找到所有的 Module 并改名,再把所有模块例化也做一次替换。最后在生成 Verilog 的时候添加 Annotation 即可:

new ChiselStage().execute(
  Array("-o", s"${name}.v"),
  Seq(
    ChiselGeneratorAnnotation(genModule),
    RunFirrtlTransformAnnotation(Dependency(PrefixModulesPass)),
    ModulePrefix(prefix)
  )

如果使用新的 MLIR FIRRTL Compiler,则可以利用 sifive.enterprise.firrtl.NestedPrefixModulesAnnotation annotation,让 firtool 来进行 prefix 操作

package sifive {
  package enterprise {
    package firrtl {
      import _root_.firrtl.annotations._

      case class NestedPrefixModulesAnnotation(
          val target: Target,
          prefix: String,
          inclusive: Boolean
      ) extends SingleTargetAnnotation[Target] {

        def duplicate(n: Target): Annotation =
          NestedPrefixModulesAnnotation(target, prefix, inclusive)
      }
    }
  }
}

object AddPrefix {
  def apply(module: Module, prefix: String, inclusive: Boolean = true) = {
    annotate(new ChiselAnnotation {
      def toFirrtl =
        new NestedPrefixModulesAnnotation(module.toTarget, prefix, inclusive)
    })
  }
}

这个方法的灵感来自 @sequencer。唯一的缺点就是比较 Hack,建议 SiFive 把相关的类也开源出来用。

关闭 RTL 级别的优化

Chisel3 生成 Verilog/System Verilog 的时候会进行一些优化。如果想要关闭这些优化,可以使用:

  1. dontTouch annotation
  2. 添加命令行参数:--preserve-values=[none/named/all],见 FIRRTL Dialect Rationale

升级 Linksys E8450 的 OpenWRT 系统到 UBI

背景

OpenWRT Linksys E8450 页面 中,如果要用新版的固件,需要转换到 UBI 格式的文件系统。之前用的是 non-UBI 格式的文件系统,直接在官方的分区下,覆盖掉其中一个启动分区。但是经常会报告 flash 出错,然后系统也不稳定,决定要按照文档更新到 UBI。

步骤

请注意:更换文件系统操作比较危险,请先备份好数据,并做好变砖的心理准备。本文仅记录了作者编写时可行的更新操作,不代表读者在阅读时,依然可以按照这个顺序进行,请按照 https://github.com/dangowrt/owrt-ubi-installer 的文档进行操作。

基本按照文档一步一步走。初始状态是一个 non-UBI 版本的 OpenWRT 固件:

  1. 下载官方的 1.0 固件:https://downloads.linksys.com/support/assets/firmware/FW_E8450_1.0.01.101415_prod.img
  2. 在 luci 中,刷入官方 1.0 固件,这时候进入了官方固件的系统
  3. 登录官方固件网页,恢复出厂设置
  4. 下载 openwrt ubi recovery 固件 然后在官方固件里刷入
  5. 这时候进入了 recovery 固件,下载 ubi 固件,继续在网页里刷入
  6. 这时候固件就更新完成了。ssh root@192.168.1.1,然后进去安装 luci 等软件,恢复配置即可。

XRDP 和 NVIDIA 显卡兼容性问题

背景

最近在尝试配置 XRDP,发现它在有 NVIDIA 的机器上启动远程桌面后会黑屏,查看错误信息可以看到:

xf86OpenConsole: Cannot open virtual console 1 (Permission denied)

2024 年注:一些比较新的发行版上携带的 xrdp 已经没有这个问题,此外不要忘记安装 xorgxrdp。

解决方法

XRDP 作者在 issue #2010 中提到了解决方法:

修改 /etc/xrdp/sesman.ini,在 [Xorg] 部分里加上下面的配置:

param=-configdir
param=/

实际上就是不让 Xorg 加载 nvidia xorg 驱动,这样就绕过了问题。

NVIDIA 驱动和 CUDA 版本信息速查

背景

之前和 NVIDIA 驱动和 CUDA 搏斗比较多,因此记录一下一些常用信息,方便查询。

常用地址

CUDA 版本与 NVIDIA 驱动兼容性

可以通过 apt show cuda-runtime-x-x 找到:

  • cuda 12.6 >= 560 (Release Notes: 525)
  • cuda 12.5 >= 555 (Release Notes: 525)
  • cuda 12.4 >= 550 (Release Notes: 525)
  • cuda 12.3 >= 545 (Release Notes: 525)
  • cuda 12.2 >= 535 (Release Notes: 525)
  • cuda 12.1 >= 530 (Release Notes: 525)
  • cuda 12.0 >= 525 (Release Notes: 525)
  • cuda 11.8 >= 520 (Release Notes: 450)
  • cuda 11.7 >= 515 (Release Notes: 450)
  • cuda 11.6 >= 510 (Release Notes: 450)
  • cuda 11.5 >= 495 (Release Notes: 450)
  • cuda 11.4 >= 470 (Release Notes: 450)
  • cuda 11.3 >= 465 (Release Notes: 450)
  • cuda 11.2 >= 460 (Release Notes: 450)
  • cuda 11.1 >= 455 (Release Notes: 450)
  • cuda 11.0 >= 450 (Release Notes: 450)
  • cuda 10.2 >= 440
  • cuda 10.1 >= 418
  • cuda 10.0 >= 410
  • cuda 9.2 >= 396
  • cuda 9.1 >= 390
  • cuda 9.0 >= 384

使用 nvidia-smi 看到的 CUDA 版本,通常就是这个驱动在上表里对应的 CUDA 版本,例如内核驱动版本是 470 的话,看到的 CUDA 版本就是 11.4。

实际上兼容的驱动版本会比 APT 宣称的更多一些:官方文档 里面写了 CUDA 11.x 可以兼容 NVIDIA >= 450,CUDA 12.x 可以兼容 NVIDIA >= 525。

CUDA 版本和 GCC/Clang 版本兼容性

可以在 cuda/include/crt/host_config.h 文件里找到:

  • cuda 12.6: gcc <= 13, 3.2 < clang < 19
  • cuda 12.1: gcc <= 12, 3.2 < clang < 16
  • cuda 12.0: gcc <= 12, 3.2 < clang < 15
  • cuda 11.8: gcc <= 11, 3.2 < clang < 15
  • cuda 11.5: gcc <= 11
  • cuda 11.4: gcc <= 10
  • cuda 11.3: gcc <= 10, 3.2 < clang < 12
  • cuda 11.1: gcc <= 10, 3.2 < clang < 11
  • cuda 11.0: gcc <= 9, 3.2 < clang < 10
  • cuda 10.2: gcc <= 8, 3.2 < clang < 9
  • cuda 10.1: gcc <= 8, 3.2 < clang < 9
  • cuda 10.0: gcc <= 7
  • cuda 9.1: gcc <= 6

CUDA 版本与显卡兼容性

编译选项与显卡对应关系 https://arnon.dk/matching-sm-architectures-arch-and-gencode-for-various-nvidia-cards/

可以在 nvcc --help 搜索 gpu-architecture 找到:

  • cuda 12.1 sm_50 to sm_90a
  • cuda 12.0 sm_50 to sm_90a
  • cuda 11.8 sm_35 to sm_90
  • cuda 11.4 sm_35 to sm_87
  • cuda 11.3 sm_35 to sm_86
  • cuda 11.1 sm_35 to sm_86
  • cuda 11.0 sm_35 to sm_80
  • cuda 10.2 sm_30 to sm_75
  • cuda 10.0 sm_30 to sm_75
  • cuda 9.1 sm_30 to sm_72
  • cuda 9.0 sm_30 to sm_70

显卡的 Compute Capability 可以在 https://developer.nvidia.com/cuda-gpus 找到:

  • H100: 90
  • A100: 80
  • V100: 70
  • P100: 60

升级 NVIDIA 驱动

升级后,需要 rmmod 已有的,再 modprobe 新的:

sudo rmmod nvidia_uvm nvidia_drm nvidia_modeset nvidia && sudo modprobe nvidia

如果发现 rmmod 失败,可以 lsof /dev/nvidiactl 查看谁在占用。DGX OS 上需要停止:

sudo systemctl stop nvsm.service
sudo systemctl stop nvidia-dcgm.service 

除了 /dev/nvidia* 可能被占用以外,还需要用 lsof 检查 /dev/dri/render*

「教学」缓存一致性协议分析

本文的内容已经整合到知识库中。

背景

最近在《高等计算机系统结构》课程中学习缓存一致性协议算法,这里用自己的语言来组织一下相关知识的讲解。

Write-invalidate 和 Write-update

最基础的缓存一致性思想有两种:

  1. Write-invalidate:写入数据的时候,将其他 Cache 中这条 Cache Line 设为 Invalid
  2. Write-update:写入数据的时候,把新的结果写入到有这条 Cache Line 的其他 Cache

Write-once 协议

Write-once 协议定义了四个状态:

  1. Invalid:表示这个块不合法
  2. Valid:表示这个块合法,并可能是共享的,同时数据没有修改
  3. Reserved:表示这个块合法,不是共享的,同时数据没有更改
  4. Dirty:表示这个块合法,不是共享的,数据做了修改,和内存不同。

可见,当一个缓存状态在 R 或者 D,其他缓存只能是 I;而缓存状态是 V 的时候,可以有多个缓存在 V 状态。

Write-once 协议的特点是,第一次写的时候,会写入到内存(类似 Write-through),连续写入则只写到缓存中,类似 Write-back。

当 Read hit 的时候,状态不变。

Read hit: The information is supplied by the current cache. No state change.

当 Read miss 的时候,会查看所有缓存,如果有其他缓存处于 Valid/Reserved/Dirty 状态,就从其他缓存处读取数据,然后设为 Valid,其他缓存也设为 Valid。如果其他缓存处于 Dirty 状态,还要把数据写入内存。

Read miss: The data is read from main memory. The read is snooped by other caches; if any of them have the line in the Dirty state, the read is interrupted long enough to write the data back to memory before it is allowed to continue. Any copies in the Dirty or Reserved states are set to the Valid state.

当 Write hit 的时候,如果是 Valid 状态,首先写入内存,把其他 Cache 都设为 Invalid,进入 Reserved 状态,这意味着第一次写是 Write-through。如果是 Reserved/Dirty 状态,则不修改内存,进入 Dirty 状态,这表示后续的写入都是 Write-back。

Write hit: If the information in the cache is in Dirty or Reserved state, the cache line is updated in place and its state is set to Dirty without updating memory. If the information is in Valid state, a write-through operation is executed updating the block and the memory and the block state is changed to Reserved. Other caches snoop the write and set their copies to Invalid.

当 Write miss 的时候,这个行为 Wikipedia 上和上课讲的不一样。按照 Wikipedia 的说法,首先按照 Read miss 处理,再按照 Write hit 处理,类似于 Write Allocate 的思路。如果是这样的话,那么首先从其他缓存或者内存读取数据,然后把其他缓存都设为 Invalid,把更新后的数据写入内存,进入 Reserved 状态。相当于 Write miss 的时候,也是按照 Write-through 实现。

Write miss: A partial cache line write is handled as a read miss (if necessary to fetch the unwritten portion of the cache line) followed by a write hit. This leaves all other caches in the Invalid state, and the current cache in the Reserved state.

教材上则是 Write miss 的时候按照 Write-back 处理。如果其他缓存都是 Invalid 时,从内存里读取数据,然后写入到缓存中,进入 Dirty 状态。如果其他缓存是 Valid/Reserved/Dirty 状态,就从其他缓存里读取数据,让其他缓存都进入 Invalid 状态,然后更新自己的数据,进入 Dirty 状态。

MSI 协议

MSI 协议比较简单,它定义了三个状态:

  1. Modified:表示数据已经修改,和内存里不一致
  2. Shared:数据和内存一致,可以有一到多个缓存同时处在 Shared 状态
  3. Invalid:不在缓存中

当 Read hit 的时候,状态不变。

当 Read miss 的时候,检查其他缓存的状态,如果都是 Invalid,就从内存里读取,然后进入 Shared 状态。如果有 Shared,就从其他缓存处读取。如果有 Dirty,那就要把其他缓存的数据写入内存和本地缓存,然后进入 Shared 状态。

当 Write hit 的时候,如果现在是 Shared 状态,则要让其他的 Shared 缓存进入 Invalid 状态,然后更新数据,进入 Modified 状态。如果是 Modified 状态,那就修改数据,状态保持不变。

当 Write miss 的时候,如果有其他缓存处于 Modified/Shared 状态,那就从其他缓存处读取数据,并让其他缓存进入 Invalid 状态,然后修改本地数据,进入 Modified 状态。如果所有缓存都是 Invalid 状态,那就从内存读入,然后修改缓存数据,进入 Modified 状态。

MESI 协议

MESI 协议定义了四种状态:

  1. Modified:数据与内存不一致,并且只有一个缓存有数据
  2. Exclusive:数据与内存一致,并且只有一个缓存有数据
  3. Shared:数据与内存一致,可以有多个缓存同时有数据
  4. Invalid:不在缓存中

当 Read hit 的时候,状态不变。

当 Read miss 的时候,首先会检查其他缓存的状态,如果有数据,就从其他缓存读取数据,并且都进入 Shared 状态,如果其他缓存处于 Modified 状态,还需要把数据写入内存;如果其他缓存都没有数据,就从内存里读取,然后进入 Exclusive 状态。

当 Write hit 的时候,进入 Modified 状态,同时让其他缓存进入 Invalid 状态。

当 Write miss 的时候,检查其他缓存的状态,如果有数据,就从其他缓存读取,否则从内存读取。然后,其他缓存都进入 Invalid 状态,本地缓存更新数据,进入 Modified 状态。

值得一提的是,Shared 状态不一定表示只有一个缓存有数据:比如本来有两个缓存都是 Shared 状态,然后其中一个因为缓存替换变成了 Invalid,那么另一个是不会受到通知变成 Exclusive 的。Exclusive 的设置是为了减少一些总线请求,比如当数据只有一个核心访问的时候,只有第一次 Read miss 会发送总线请求,之后一直在 Exclusive/Modified 状态中,不需要发送总线请求。

MOESI 协议

MOESI 定义了五个状态:

  1. Modified:数据经过修改,并且只有一个缓存有这个数据
  2. Owned:同时有多个缓存有这个数据,但是只有这个缓存可以修改数据
  3. Exclusive:数据没有修改,并且只有一个缓存有这个数据
  4. Shared:同时有多个缓存有这个数据,但是不能修改数据
  5. Invalid:不在缓存中

状态中,M 和 E 是独占的,所有缓存里只能有一个。此外,可以同时有多个 S,或者多个 S 加一个 O,但是不能同时有多个 O。

它的状态转移与 MESI 类似,区别在于:当核心写入 Owned 状态的缓存时,有两种方式:1)通知其他 Shared 的缓存更新数据;2)把其他 Shared 缓存设为 Invalid,然后本地缓存进入 Modified 状态。在 Read miss 的时候,则可以从 Owned 缓存读取数据,进入 Shared 状态,而不用写入内存。它相比 MESI 的好处是,减少了写回内存的次数。

AMD64 文档里采用的就是 MOESI 协议。AMBA ACE 协议其实也是 MOESI 协议,只不过换了一些名称,表示可以兼容 MEI/MESI/MOESI 中的一个协议。ACE 对应关系如下:

  1. UniqueDirty: Modified
  2. SharedDirty: Owned
  3. UniqueClean: Exclusive
  4. SharedClean: Shared
  5. Invalid: Invalid

需要注意的是,SharedClean 并不代表它的数据和内存一致,比如说和 SharedDirty 缓存一致,它只是说缓存替换的时候,不需要写回内存。

Dragon 协议

Dragon 协议是一个基于更新的协议,意味着写入缓存的时候,会把更新的数据同步到拥有这个缓存行的其他核心。它定义了四个状态:

  1. Exclusive clean(E):独占,并且数据和内存一致
  2. Shared clean(Sc):数据同时存在多个缓存中,并且自己不是最后一个写入该缓存数据的
  3. Shared modified(Sm):数据同时存在多个缓存中,并且自己设最后一个写入该缓存数据的,类似于前面 MOESI 协议的 Owner 状态
  4. Modify(M):独占,并且数据和内存不一致

可以看到,E 和 M 都是独占的,如果出现了多个缓存有同一个缓存行,那就是若干个 Sc 和一个 Sm。

当 Read miss 的时候,在总线上检查是否有缓存已经有这个缓存行的数据,如果没有,则从内存读取并转到 Exclusive clean 状态;如果已经在其他缓存中,则从其他缓存读取,将其他缓存转移到 Shared clean/Shared modified 状态,然后该缓存转移到 Shared clean 状态。

当 Write miss 的时候,同样检查其他缓存的状态,如果是第一个访问的,就从内存读取,更新数据,然后转到 Modify 状态;如果不是第一个访问的,就进入 Shared modified 状态,并且让原来 Shared modified 的缓存进入 Shared clean 状态。

当 Write hit 的时候,如果状态是 Shared modified,这时候需要通知其他缓存更新数据;如果状态是 Shared clean,则要通知其他缓存更新数据的同时,让原来 Shared modified 的缓存进入 Shared clean 状态;如果状态是 Exclusive clean,则进入 Modify 状态。

在这里,Shared modified 的缓存负责在换出的时候,写入数据到内存中。

ACE 协议

ACE 协议在 AXI 的基础上,添加了三个 channel:

  1. AC: Coherent address channel, Input to master: ACADDR, ACSNOOP, ACPROT
  2. CR: Coherent response channel, Output from master: CRRESP
  3. CD: Coherent data channel, Output from master: CDDATA, CDLAST

此外,已有的 Channel 也添加了信号:

  1. ARSNOOP[3:0]/ARBAR[1:0]/ARDOMAIN[1:0]
  2. AWSNOOP[3:0]/AWBAR[1:0]/AWDOMAIN[1:0]/AWUNIQUE
  3. RRESP[3:2]
  4. RACK/WACK

ACE-lite 只在已有 Channel 上添加了新信号,没有添加新的 Channel。因此它内部不能有 Cache,但是可以访问一致的缓存内容。

当 Read miss 的时候,首先 AXI master 发送 read transaction 给 Interconnect,Interconnect 向保存了这个缓存行的缓存发送 AC 请求,如果有其他 master 提供了数据,就向请求的 master 返回数据;如果没有其他 master 提供数据,则向内存发起读请求,并把结果返回给 master,最后 master 提供 RACK 信号。

当 Write miss 的时候,也是类似地,AXI master 发送 MakeUnique 请求给 Interconnect,Interconnect 向保存了该缓存行的缓存发送请求,要求其他 master 状态改为 Invalid;当所有 master 都已经 invalidate 成功,就向原 AXI master 返回结果。

基于目录的缓存一致性

上面的缓存一致性协议中,经常有这么一个操作:向所有有这个缓存行的缓存发送/接受消息。简单的方法是直接广播,然后接受端自己判断是否处理。但是这个方法在核心很多的时候会导致广播流量太大,因此需要先保存下来哪些缓存会有这个缓存的信息,然后对这些缓存点对点地发送。这样就可以节省一些网络流量。

那么,怎么记录这个信息呢?一个简单的办法(Full bit vector format)是,有一个全局的表,对每个缓存行,都记录一个大小为 N(N 为核心数)的位向量,1 表示对应的核心中有这个缓存行。但这个方法保存数据量太大:缓存行数正比于 N,还要再乘以一次 N,总容量是 O(N^2) 的。

一个稍微好一些的方法(Coarse bit vector format)是,我把核心分组,比如按照 NUMA 节点进行划分,此时每个缓存行都保存一个大小为 M(M 为 NUMA 数量)的位向量,只要这个 NUMA 节点里有这个缓存行,对应位就取 1。这样相当于是以牺牲一部分流量为代价(NUMA 节点内部广播),来节省一些目录的存储空间。

但实际上,通常情况下,一个缓存行通常只会在很少的核心中保存,所以这里有很大的优化空间。比如说,可以设置一个缓存行同时出现的缓存数量上限 (Limited pointer format),然后保存核心的下标而不是位向量,这样的存储空间就是 O(Nlog2N)。但是呢,这样限制了缓存行同时出现的次数,如果超过了上限,需要替换掉已有的缓存,可能在一些场景下性能会降低。

还有一种方式,就是链表 (Chained directory format)。目录中保存最后一次访问的核心编号,然后每个核心的缓存里,保存了下一个保存了这个缓存行的核心编号,或者表示链表终止。这样存储空间也是 O(Nlog2N),不过发送消息的延迟更长,因为要串行遍历一遍,而不能同时发送。类似地,可以用二叉树 (Number-balanced binary tree format) 来组织:每个缓存保存两个指针,指向左子树和右子树,然后分别遍历,目的还是加快遍历的速度,可以同时发送消息给多个核心。

参考文档

DRAM 在 Kintex 7 FPGA 上内部 Vref 的性能问题

背景

最近我们设计的 Kintex 7 FPGA 开发板在测试 DDR SDRAM 的时候遇到了一个问题,因为采用了 Internel VREF,MIG 在配置的时候限制了频率只能是 400 MHz,对应 800 MT/s,这样无法达到 DDR 的最好性能。

原理

首先,VREF 在 DDR 中是用来区分低电平和高电平的。在 JESD79-4B 标准中,可以看到,对于直流信号,电压不小于 VREF+0.075V 时表示高电平,而电压不高于 VREF-0.075V 时表示低电平。VREF 本身应该介于 VDD 的 0.49 倍到 0.51 倍之间。

在连接 FPGA 的时候,有两种选择:

  • Internal VREF: 从 FPGA 输出 VREF 信号到 DRAM
  • External VREF:接入 FPGA 以外的 VREF

对于 7 Series 的 FPGA,Xilinx 要求如下:

For DDR3 SDRAM interfaces running at or below 800 Mb/s (400 MHz),
users have the option of selecting Internal VREF to save two I/O
pins or using external VREF. VREF is required for banks containing
DDR3 interface input pins (DQ/DQS).

进一步,Xilinx 在 UltraScale 文档下解释了背后的原因:

The UltraScale internal VREF circuit includes enhancements compared
to the 7 Series internal VREF circuit. Whereas 7 Series MIG had datarate
limitations on internal VREF usage (see (Xilinx Answer 42036)), internal
VREF is recommended in UltraScale. The VREF for 7 Series had coarse steps
of VREF value that were based on VCCAUX. This saved pins but limited the
performance because VCCAUX did not track with VCCO as voltage went up and
down. Not being able to track with VCCO enforced the performance
limitations of internal VREF in MIG 7 Series. UltraScale includes several
changes to internal VREF including a much finer resolution of VREF for DDR4
read VREF training. Additionally, internal VREF is based on the VCCO supply
enabling it to track with VCCO. Internal VREF is not subject to PCB and
Package inductance and capacitance. These changes in design now give internal
VREF the highest performance.

用中文简单来说:

  1. 7 Series FPGA 中,Internal VREF 可以节省引脚,代价是 VREF 不会随着 VCCO 变化而变化(而是随着 VCCAUX 变化而变化),当 DRAM 频率提高的时候,可能无法满足 VREF 约等于 VDD 一半的要求
  2. UltraScale FPGA 中,Internal VREF 是随着 VCCO 变化而变化的,并且会比 External VREF 性能更好;因此 UltraScale FPGA 的 DDR4 只支持 Internal VREF。

以 MA703FA-35T 开发板为例,它使用的 FPGA 是 Artix7 35T,内存是 DDR3,采用的是 External VREF。它采用了 TPS51200 Sink and Source DDR Termination Regulator 芯片,将芯片的 REFOUT 芯片接到 DRAM 的 VREFDQ 和 VREFCA 引脚上。

参考文档

「教学」DRAM 结构和特性

本文的内容已经整合到知识库中。

DRAM 是如何组织的

DRAM 分成很多层次:Bank Group,Bank,Row,Column,从大到小,容量也是各级别的乘积。

举例子:

  • 4 Bank Group
  • 4 Bank per Bank Group
  • 32,768 Row per Bank
  • 1024 Column per Row
  • 4 Bits per Column

那么总大小就是 4*4*32768*1024*4=2 Gb

访问模式

DRAM 的访问模式决定了访问内存的实际带宽。对于每次访问,需要这样的操作:

  1. 用 ACT(Bank Activate) 命令打开某个 Bank Group 下面的某个 Bank 的某个 Row,此时整个 Row 的数据都会复制到 Sense Amplifier 中。这一步叫做 RAS(Row Address Strobe)
  2. 用 RD(Read)/WR(Write) 命令按照 Column 访问数据。这一步叫做 CAS(Column Address Strobe)。
  3. 在访问其他 Row 之前,需要用 PRE(Single Bank Precharge) 命令将 Sense Amplifier 中整个 Row 的数据写回 Row 中。

可以看到,如果访问连续的地址,就可以省下 ACT 命令的时间,可以连续的进行 RD/WR 命令操作。

除了显式 PRE 以外,还可以在某次读写之后自动进行 PRE:WRA(Write with Auto-Precharge) 和 RDA(Read with Auto-Precharge)。

总结一下上面提到的六种命令:

  1. ACT: Bank Activate,激活一个 Row,用于接下来的访问
  2. PRE: Single Bank Precharge,与 ACT 是逆操作,解除 Row 的激活状态
  3. RD: Read,读取当前 Row 的某个 Column 数据
  4. RDA: Read with Auto-Precharge,读取后执行 Precharge
  5. WR: Write,写入当前 Row 的某个 Column 数据
  6. WRA: Write with Auto-Precharge,写入后执行 Precharge

除此之外,还有一些常用命令:

  1. REF: Refresh,需要定期执行,保证 DRAM 数据不会丢失。

参数

DRAM 有很多参数,以服务器上的内存 MTA36ASF2G72PZ-2G3A3 为例子:

  • 16GB 容量,DDR4 SDRAM RDIMM
  • PC4-2400
  • Row address: 64K A[15:0]
  • Column address: 1K A[9:0]
  • Device bank group address: 4 BG[1:0]
  • Device bank address per group: 4 BA[1:0]
  • Device configuration: 4Gb (1Gig x 4), 16 banks
  • Module rank address: 2 CS_n[1:0]
  • Configuration: 2Gig x 72
  • Module Bandwidth: 19.2 GB/s=2400 MT/s * 8B/T
  • Memory Clock: 0.83ns(1200 MHz)
  • Data Rate: 2400 MT/s
  • Clock Cycles: CL-nRCD-nRP = 17-17-17

容量:每个 DRAM 颗粒 64K*1K*4*4*4=4Gb,不考虑 ECC,一共有 16*2=32 个这样的颗粒,实际容量是 16 GB。32 个颗粒分为两组,每组 16 个颗粒,两组之间通过 CS_n 片选信号区分。每组 16 个颗粒,每个颗粒 4 位 DQ 数据信号,合并起来就是 64 位,如果考虑 ECC 就是 72 位。

再举一个 FPGA 开发板上内存的例子:MT40A512M16LY-075E,参数如下:

  1. Data Rate: 2666 MT/s, Clock Frequency: 1333 MHz, tCK=0.750ns=750ps
  2. Target CL-nRCD-nRP: 18-18-18
  3. tAA(Internal READ command to first data)=13.50ns(=18*0.750)
  4. tRCD(ACTIVATE to internal READ or WRITE delay time)=13.50ns(=18*0.750)
  5. tRP(PRECHARGE command period)=13.50ns(=18*0.750)
  6. tRAS(ACTIVATE-to-PRECHARGE command period)=32ns
  7. 512 Meg x 16
  8. Number of bank groups: 2
  9. Bank count per group: 4
  10. Row addressing: 64K
  11. Column addressing: 1K
  12. Page size: 2KB=2K*16b

总大小:2*4*64K*1K*16=1GB。这个开发板用了 5 个 DRAM 芯片,只采用了其中的 4.5 个芯片:最后一个芯片只用了 8 位数据,这样就是 4.5*16=72 位的数据线,对应 64 位+ECC。

时序

可以看到,上面的 DRAM Datasheet 里提到了三个时序参数:

  1. CL=17: CAS Latency,从发送读请求到第一个数据的延迟周期数
  2. RCD=17: ACT to internal read or write delay time,表示从 ACT 到读/写需要的延迟周期数
  3. RP=17: Row Precharge Time,表示 Precharge 后需要延迟周期数

如果第一次访问一个 Row 中的数据,并且之前没有已经打开的 Row,那么要执行 ACT 和 RD 命令,需要的周期数是 RCD+CL;如果之前已经有打开了的 Row,那么要执行 PRE,ACT 和 RD 命令,需要的周期数是 RP+RCD+CL。但如果是连续访问,虽然还需要 CL 的延迟,但是可以流水线起来,充分利用 DDR 的带宽。

如果把这个换算到 CPU 角度的内存访问延迟的话,如果每次访问都是最坏情况,那么需要 17+17+17=51 个 DRAM 时钟周期,考虑 DRAM 时钟是 1200MHz,那就是 42.5ns,这个相当于是 DRAM 内部的延迟,实际上测得的是 100ns 左右。

更严格来说,读延迟 READ Latency = AL + CL + PL,其中 AL 和 PL 是可以配置的,CL 是固有的,所以简单可以认为 READ Latency = CL。同理 WRITE Latency = AL + CWL + PL,可以简单认为 WRITE Latency = CWL。CWL 也是可以配置的,不同的 DDR 速率对应不同的 CWL,范围从 1600 MT/s 的 CWL=9 到 3200 MT/s 的 CWL=20,具体见 JESD79-4B 标准的 Table 7 CWL (CAS Write Latency)。

波形

用 Micron 提供的 Verilog Model 进行仿真,可以看到如下的波形图:

首先看第一个命令,ACT_n=0, ADDR=0x009C, CAS_n_A15=0, CKE=1->1, CS_n=0, RAS_n_A16=0, WE_n_A14=1,查阅标准可知这是 ACT(Bank Activate) 命令。接着第二个命令,ACT_n=1, ADDR=0x0400, CAS_n_A15=0, CKE=1->1, CS_n=0, RAS_n_A16=1, WE_n_A14=1, A10=1, 这是 RDA(Read with Auto Precharge) 命令。若干个周期后,读取的数据从 DQ 上输出,一共 8 个字节的数据。

刷新

DRAM 的一个特点是需要定期刷新。有一个参数 tREFI,表示刷新的时间周期,这个值通常是 7.8us,在温度大于 85 摄氏度时是 3.9 us(见 JESD79-4B Table 131)。在刷新之前,所有的 bank 都需要 Precharge 完成并等待 RP 的时间,这时候所有的 Bank 都是空闲的,再执行 REF(Refresh) 命令。等待 tRFC(Refresh Cycle) 时间后,可以继续正常使用。

为了更好的性能,DDR4 标准允许推迟一定次数的刷新,但是要在之后补充,保证平均下来依然满足每过 tREFI 时间至少一次刷新。

地址映射

如果研究 DRAM 内存控制器,比如 FPGA 上的 MIG,可以发现它可以配置不同的地址映射方式,例如:

  • ROW_COLUMN_BANK
  • ROW_BANK_COLUMN
  • BANK_ROW_COLUMN
  • ROW_COLUMN_LRANK_BANK
  • ROW_LRANK_COLUMN_BANK
  • ROW_COLUMN_BANK_INTLV

就是将地址的不同部分映射到 DRAM 的几个地址:Row,Column,Bank。可以想象,不同的地址映射方式针对不同的访存模式会有不同的性能。对于连续的内存访问,ROW_COLUMN_BANK 方式是比较适合的,因为连续的访问会分布到不同的 Bank 上,这样性能就会更好。

此外,如果访问会连续命中同一个 Page,那么直接读写即可;反之如果每次读写几乎都不会命中同一个 Page,那么可以设置 Auto Precharge,即读写以后自动 Precharge,减少了下一次访问前因为 Row 不同导致的 PRE 命令。一个思路是在对每个 Page 的最后一次访问采用 Auto Precharge。

传输速率

DDR SDRAM 的传输速率计算方式如下:

Memory Speed (MT/s) * 64 (bits/transfer)

例如一个 DDR4-3200 的内存,带宽就是 3200 * 64 = 204.8 Gb/s = 25.6 GB/s。但前面已经看到,除了传输数据,还需要进行很多命令,实际上很难达到 100% 的带宽。然后 CPU 可以连接多个 channel 的 DRAM,再考虑多个 CPU Socket,系统的总带宽就是

Memory Speed (MT/s) * 64 (bits/transfer) * Channels * Sockets

使用 MLC 等工具进行测试,计算实际与理论的比值,我测试得到的大概在 70%-90% 之间。

HBM

HBM 相比前面的 DDR SDRAM,它堆叠了多个 DRAM,提供多个 channel 并且提高了位宽。例如 Micron HBM with ECC,堆叠了 4/8 层 DRAM,提供 8 个 channel,每个 channel 的数据宽度是 128 位,以 3200 MT/s 计算,一个 HBM 芯片的传输速率最大是:

3200 (MT/s) * 128 (bits/transfer) * 8 (Channels) = 3276.8 Gb/s = 409.6 GB/s

所以一片 HBM 的传输速率就相当于 16 个传统的 DDR SDRAM:8 个 Channel 加双倍的位宽。128 位实际上就是把两片 64-bit DDR SDRAM 并起来了,可以当成一个 128 位的用,也可以在 Pseudo Channel 模式下,当成共享地址和命令信号的两个 DDR SDRAM 用。

Xilinx 的 Virtex Ultrascale Plus HBM FPGA 提供了 1800 (MT/s) * 128 (bits/transfer) * 8 (Channels) = 230.4 GB/s 的带宽,如果用了两片 HBM 就是 460.8 GB/s。暴露给 FPGA 逻辑的是 16 个 256 位的 AXI3 端口,AXI 频率 450 MHz,内存频率 900 MHz。可以看到,每个 AXI3 就对应了一个 HBM 的 pseudo channel。每个 pseudo channel 是 64 位,但是 AXI 端口是 256 位:在速率不变的情况下,从 450MHz 到 900MHz,再加上 DDR,相当于频率翻了四倍,所以位宽要从 64 位翻四倍到 256 位。

当然了,HBM 的高带宽的代价就是引脚数量很多。根据 HBM3 JESD238A,每个 Channel 要 120 个 pin,一共 16 个 channel(HBM2 是 8 channel,每个 channel 128 位;HBM3 是 16 channel,每个 channel 64 位),然后还有其他的 52 个 pin,这些加起来就 1972 个 pin 了。所以一般在 Silicon Interposer 上连接,而不是传统的在 PCB 上走线(图源 A 1.2V 20nm 307GB/s HBM DRAM with At-Speed Wafer-Level I/O Test Scheme and Adaptive Refresh Considering Temperature Distribution):

所以在 HBM3 标准里,用 Microbump 来描述 HBM 的 pin。

可以理解为把原来插在主板上的内存条,通过堆叠,变成一个 HBM Die,然后紧密地连接到 CPU 中。但是另一方面,密度上去了,价格也更贵了。

A100 显卡 40GB PCIe 版本提供了 1555 GB/s 的内存带宽。根据倍数关系,可以猜测是 5 个 8GB 的 HBM,每个提供 1555 / 5 = 311 GB/s 的带宽,那么时钟频率就是 311 (GB/s) * 8 (bits/byte) / 128 (bits/transfer) / 8 (channels) / 2 (DDR) = 1215 MHz,这与 nvidia-smi -q 看到的结果是一致的。

进一步,A100 80GB PCIe 版本提供了 1935 GB/s 的带宽,按照同样的方法计算,可得时钟频率是 1935 (GB/s) / 5 * 8 (bits/byte) / 128 (bits/transfer) / 8 (channels) / 2(DDR) = 1512 MHz,与 Product Brief 一致。频率的提高是因为从 HBM2 升级到了 HBM2e。

A100 文档中的 Memory bus width 5120 的计算方式也就清楚了:128 (bits/transfer) * 8 (channels) * 5 (stacks) = 5120 (bits)

H100 SXM5 升级到了 HBM3,内存容量依然是 80GB,但是时钟频率提高,内存带宽是 2619 (MHz) * 2 (DDR) * 128 (bits/transfer) * 8 (channels) * 5 (stacks) / 8 (bits/byte) = 3352 GB/s

参考文档

「教学」RISC-V Debug 协议

背景

之前用过一些 RISC-V 核心,但是遇到调试相关的内容的时候就两眼一抹黑,不知道原理,出了问题也不知道如何排查,趁此机会研究一下工作原理。

架构

为了调试 RISC-V 核心,需要很多部件一起工作。按 RISC-V Debug Spec 所述,有这么几部分:

  1. Debugger: GDB,连接到 OpenOCD 启动的 GDB Server
  2. Debug Translator: OpenOCD,向 GDB 提供 Server 实现,同时会通过 FTDI 等芯片控制 JTAG
  3. Debug Transport Hardware: 比如 FTDI 的芯片,可以提供 USB 接口,让 OpenOCD 控制 JTAG 信号 TMS/TDI/TCK 的变化,并读取 TDO
  4. Debug Transport Module: 在芯片内部的 JTAG 控制器(TAP),符合 JTAG 标准
  5. Debug Module Interface:RISC-V 自定义的一系列寄存器,通过这些寄存器来控制 Debug Module 的行为
  6. Debug Module:调试器,控制 RISC-V 核心,同时也支持直接访问总线,也有内部的 Program Buffer

可以看到,DMI 是实际的调试接口,而 JTAG 可以认为是一个传输协议。

JTAG

首先什么是 JTAG?简单来说,它工作流程是这样的:

  1. JTAG TAP 维护了一个状态机,由 TMS 信号控制
  2. 当状态机进入 CaptureDR/CaptureIR 状态的时候,加载数据到 DR/IR 中
  3. 在 ShiftDR/ShiftIR 状态下,寄存器从 TDI 移入,从 TDO 移出
  4. 当进入 UpdateDR/UpdateIR 状态的时候,把 DR/IR 的结果输出到其他单元

具体来说,JTAG 定义了两类寄存器:IR 和 DR。可以把 JTAG 理解成一个小的总线,我通过 IR 选择总线上的设备,通过 DR 向指定的设备上进行数据传输。比如在 RISC-V Debug Spec 里面,规定了以下的 5 位 IR 地址定义:

  1. 0x00/0x1f: BYPASS
  2. 0x01: IDCODE
  3. 0x10: dtmcs
  4. 0x11: dmi

可以类比为有四个设备:BYPASS,IDCODE,dtmcs,dmi,对应了一些地址。如果要选择 dtmcs 这个设备,就在 ShiftIR 阶段向 TDI 输入二进制的 00001 即可。选择地址以后,再向 DR 写入时,操作的就是 dtmcs 设备。

那么,每个设备是怎么操作的呢?假如我已经通过 IR 设置了当前设备是 dtmcs,然后进入 ShiftDR 模式时,JTAG 会同时输入和输出。输入的就是当前要输入的数据,输出的就是原来寄存器里的结果,这个结果可能是固定的,也可能是表示上一次输入对应的结果。

举个例子:IDCODE 设备,在 CaptureDR 阶段的时候,DR 总会被设为一个固定的 IDCODE,表示设备的 ID;在 Shift 的时候,这个 IDCODE 就会一位一位从 TDO 中输出,而 TDI 输入的数据都会被忽略掉。BYPASS 设备则是一个 1 位的寄存器,直接从 TDI 到寄存器,寄存器到 TDO,数据就这么流过去了。

那么,在 RISC-V Debug 里面,JTAG 是怎么用的呢?我们可以这么类比一下:CaptureDR 相当于读取寄存器到缓冲区,然后 ShiftDR 在读取缓冲区的同时写入缓冲区,最后 UpdateDR 则是把缓冲区中的数据写入到寄存器中。这和 MMIO 有点类似,只不过每次操作不是单独的写和读,而是一次操作等于先读后写。

还是来看例子。dtmcs 这个设备表示的是 DTM 当前的状态,它有 32 位,读取的时候可以得到 DMI 的状态和配置,写入的时候可以 reset DMI。以 OpenOCD 代码 dtmcontrol_scan 为例子,它做了这么几个事情:

  1. 首先设置 IR 为 0x10,对应 dtmcs。
  2. 向 DR 中写入数据,同时读取数据。
  3. 设置 IR 为 0x11,对应 dmi,因为 dmi 操作是比较多的,所以它默认恢复到 dmi。

如果我只想读取 dtmcs 寄存器,那么只要设置写入数据为 0 即可,因为寄存器的设计里考虑到,如果写入全 0 是没有副作用的。同理,如果只想写入 dtmcs 寄存器,直接写入即可,因为设计的时候也保证读入寄存器的值是没有副作用的。这样,就在一个一读一写的操作中,实现了读或者写的功能。

那么,dmi 寄存器的用途是什么呢?我们前面提到过,JTAG 其实是一个传输层,而 DMI 又定义了一系列的寄存器,这会让人有点混乱,为啥到处都是寄存器?又是 JTAG 的 IR/DR,又是 dmi,dmi 又有一堆寄存器,这是什么关系?

首先我们来看 dmi 寄存器的定义。它由三部分组成:地址、数据和操作。由于 JTAG 每次操作是一读一写,虽然寄存器定义差不多,但是读和写的含义是不同的。

比如读的时候,它表示的是上一次 dmi 请求的结果。地址还是上一次请求的地址,数据则是上一次请求的结果,操作字段 0 表示成功,2 表示失败,3 表示还没执行完。而写的时候,地址和数据表示了对哪个寄存器写入什么数据,操作字段 0 表示无操作,1 表示读,2 表示写。

可以看到,如果想操作 dmi 定义的寄存器,需要如下几个步骤,这也是 OpenOCD dmi_op_timeout 要做的事情:

  1. 设置 IR 为 0x11,对应 DMI。
  2. 向 DR 写入请求的地址 + 数据 + 操作,丢弃读取的结果。
  3. 等待若干个周期。
  4. 向 DR 写入全 0,对应无操作,同时读取结果,这个结果就对应上面的请求。

可以预期,如果首先写入了一个写操作,那么第二次 DR scan 得到的结果就是是否成功写入;如果首先写入了一个读操作,那么第二次 DR scan 得到的结果就是目标寄存器的值。

可能看起来还是很绕,确实很绕,因为这里有一个封装的过程。首先,DMI 本身定义了一些寄存器,这些寄存器读/写都有一定的含义,比如控制某一个 RISC-V 核心暂停等等。接着,JTAG 需要传输 DMI 的读取和写入操作,同时还要考虑读写尚未完成的情况,怎么办?结论就是通过 DR 来实现,写入 DR 时,按照 DR 中的操作数,对应到 DMI 的写入/读取;然后读取 DR 的时候,按照 DMI 的状态,告诉 OpenOCD 目前是否已经完成了上一次 DMI 操作,和操作的结果。

DMI

讲完 JTAG 以后,终于来到了 DMI。其实 DMI 就是一系列的寄存器,类似于 MMIO 设备,只不过访问方式不是我们通常的内存读写,而是通过 JTAG 的方式进行。它有很多个寄存器,摘录如下:

  1. dmcontrol 0x10: Debug Module Control
  2. dmstatus 0x11: Debug Module Status
  3. hartinfo 0x12: Hart Info
  4. hartsum 0x13: Hart Summary
  5. command 0x16: Abstract Control and Status
  6. data0 0x04: Abstract Data 0
  7. progbuf0 0x20: Program Buffer 0
  8. sbcs 0x38: System Bus Access Control and Status
  9. sbaddress0 0x39: System Bus Address 31:0
  10. sbdata0 0x3c: System Bus Data 31:0

OpenOCD 的 examine 函数对 DMI 初始化并进行一些参数的获取。它的操作如下:

  1. 调用 dtmcontrol_scan,读取 JTAG 里的 dtmcs,可以得到 JTAG-DMI 的配置信息
  2. 向 dmcontrol 写入,进行复位
  3. 向 dmcontrol 写入,启用调试模块
  4. 从 hartinfo 读取 hart 信息
  5. 检查各个 hart 的状态

类似地,其他各种调试操作都是对这些 DMI 寄存器的读和写进行。RISC-V Debug Spec 附录里还提到了如何实现调试器的一些功能。

比如要读取 CPU 的寄存器(比如通用寄存器,CSR 等等)的话,有如下的方式:

第一种是 Abstract Command,直接向 DMI 写入要寄存器编号,就可以实现读/写。

第二种是 Program Buffer。它是一块小的代码存储,可以通过 DMI 向其中写入指令,比如 csrw s0, mstatus; ebreak,然后设置 s0 寄存器的值,再执行 Program Buffer 里的代码。

以 OpenOCD 代码为例,register_read_abstract 做了以下操作:

  1. 找到要读取的寄存器对应的 Abstract Register Number
  2. 进行 transfer 命令,DM 会读取对应寄存器到 data0 中
  3. 从 data0 中读取寄存器内容

如果要读取内存的话,也有两种方法。一种是直接向 DMI 写入要读取的总线地址,然后再向指定的寄存器中读取数据。第二种还是利用 Program Buffer,写入一条 lw s0, 0(s0) 指令,然后先向 s0 写入地址,执行 Program Buffer 后,再把 s0 寄存器的值读出来。

Abstract Command 实现

那么,如何实现上面提到的 Abstract Command(比如读写寄存器,读写内存等)呢?Debug Spec 里面提到一种 Execution-Based 的方式,即在 Debug mode 下,核心依然在执行代码,只不过执行的是调试用的特殊代码。它做的就是轮询 Debug Module 等待命令,接受到命令以后,就去读写寄存器/内存,然后通过 data0-12 来传输数据。

这里还有一个比较特别的点,就是读取寄存器的时候,寄存器的编号是直接记录在指令中的,所以可以让 Debug Module 动态生成指令,然后让核心刷新 ICache 然后跳转过去。另外,还可以利用 dscratch0/dscratch1 寄存器来保存 gpr,然后用 dret 退出的时候再恢复,这样就有两个 gpr 可以用来实现功能了,实际上这已经够用了(一个技巧是,把地址设为 0 附近,然后直接用 zero 寄存器加偏移来寻址)。

单步调试实现

在 dcsr 中,有一个值 step 表示是否在单步调试状态。设 step 为 1 的时候,如果不在 debug mode 中,只需要记录以及执行的指令数,当执行了一条指令后,视为下一个指令发生了进入 debug mode 的异常,这样就实现了单步调试。

软件断点实现

调试器为了打断点,一种简单的方式是,往断点处写入 ebreak 指令,然后设置 dcsr 的 ebreakm/s/u,表示在这些特权集里,ebreak 是进入 debug mode,而不是原来的处理过程。然后,程序运行到 ebreak 指令的时候,进入 debug mode,openocd 发现核心进入 halted 状态后,让 gdb 继续进行调试。

硬件方面的实现方法就是,在遇到 ebreak 的时候,判断一下当前的特权集,结合 ebreakm/s/u 判断跳转到什么状态。此外,由于它会写入指令到内存,所以还需要执行 fence.i 指令,而 OpenOCD 需要依赖 progbuf 来执行 fence.i 指令,所以为了让这个方案工作,还得实现 Program Buffer。

当然了,软件断点也有局限性,比如内存不可写,比如 ROM,不能覆盖里面的指令,这样就有可能出问题。而且硬件断点性能也更好,不需要来回这样写指令。

Semihosting

ARM 有一种 semihosting 机制,就是处理器执行一种特定的指令序列,然后调试器看到整个序列的时候,不是进入 GDB 调试状态,而是去进行一些操作,比如输出信息,读写文件等等,然后结果通过 JTAG 写回去。OpenOCD 给 RISC-V 也做了类似的 semihosting 机制,只不过触发的指令序列不大一样,但是机制是类似的。

如果用过 Rocket Chip 仿真的或者以前的 ucb-bar/fpga-zynq 项目的话,会知道还有一个目的有些类似的东西:HTIF + fesvr,它是通过 fromhost/tohost 两组地址来进行通信,但是这个方法缺点是需要 poll tohost/fromhost 地址的内容,相对来说比较麻烦。

Program Buffer

此外,debug spec 还有一个可选的功能,就是 Program Buffer,调试器可以往里面插入自定义的指令,然后结合 abstract command 进行操作。这样就可以做一些比较高效的操作,比如 OpenOCD 实现的批量写入内存:

sw s1, 0(a0)
addi a0, a0, 4
ebreak

并且设置 abstractauto,然后重复的操作是往 s1 里面写入新的数据,然后跳转到 program buffer,进行上面的 sw 操作,这样就可以一次 dmi 请求完成一次内存的写入,比较高效。

参考文档

  1. RISC-V Debug Spec 0.13
  2. IEEE Standard for JTAG 1149.1-2013
  3. OpenOCD 相关代码