聊聊 SpinalHDL 里特别关键的两个接口:Flow
和 Stream
。这玩意儿看上去有点抽象,但其实掌握了它们,你会发现写硬件流水线变得清爽又高效,完全不像折腾 Verilog 那么痛苦。本文尝试掰开揉碎讲清楚它们的本质和区别和用法。
为什么要有 Flow
和 Stream
想象一下,硬件里数据在模块间来回传递,如果每个模块都自己设计一套“数据有效信号”,自己判断啥时可以接收,啥时没准能来,接口就乱成一团,接线也容易出错。SpinalHDL 里的 Flow
和 Stream
就是为了统一这种“数据传输协定”而生,类似大家耳熟能详的 AXI-Stream,但更简洁、可编程,还是 Scala 代码写出来的。
它们就是数据“传输协议”的具体实现,让设计模块就像拼积木一样简单,信号级别的细节全交给这套接口去管,模块间连接的时候只管对口,少踩坑。
Flow
-- 简单直接的“推送”
Flow
是两者中简单粗暴的。它只有两个信号:
valid
:数据有效信号,主模块一拉高就代表有数据
payload
:真正的数据内容
它没有“ready”信号,也就是说,只要你发了 valid
,下游模块必须立刻收,不许阻塞。
这意味着什么?
- 下游模块没得选,数据来了就得接受,否则就会丢失或出错。
- 不支持背压(backpressure),也就是说数据的流动是单向“推”的,没有“拉”的反馈。
代码里怎么定义?
class Flow[T <: Data](val payloadType: HardType[T]) extends Bundle with IMasterSlave {
val valid = Bool()
val payload = payloadType()
override def asMaster() = out(this)
override def asSlave() = in(this)
def fire: Bool = valid
}
什么时候用它?
- 事件通知,比如中断信号
- 配置数据推送,不需要确认,接收端会记下来
- 简单数据发送,接收端保证能随时接收(或者自己有缓冲)
- 如果你想快速把这个简单接口拔插进更复杂的数据路径,也可以转成
Stream
举个例子
case class MyData() extends Bundle {
val x = UInt(8 bits)
val y = Bool()
}
class FlowProducer extends Component {
val io = new Bundle {
val output = master(Flow(MyData()))
}
io.output.valid := True
io.output.payload.x := 42
io.output.payload.y := False
}
class FlowConsumer extends Component {
val io = new Bundle {
val input = slave(Flow(MyData()))
val storedX = out(UInt(8 bits))
}
val xReg = Reg(UInt(8 bits)) init 0
when(io.input.valid) {
xReg := io.input.payload.x
}
io.storedX := xReg
}
这段代码里,FlowProducer
一直把数据“丢”给 FlowConsumer
,后者只要 valid
是高的,就立刻接收。
总之非常轻量级,适合从传统 IO Bundle 重构过来用。
2. Stream
-- 加了握手的“推拉”机制
Stream
就比 Flow
更“成熟”一点,增加了个 ready
信号。具体信号是:
valid
:主模块声明数据有效
payload
:数据内容
ready
:从模块告诉主模块“我准备好了,可以收数据”
一个数据周期真正传输(fire)的条件是:valid && ready
同时为真。
这才叫“握手协议”。
这意味着什么?
- 主模块发数据时,必须等下游
ready
变高才能把数据算“传送成功”
- 下游模块可以根据自身状态控制
ready
,实现背压,控制数据流速
- 可以优雅处理速度不匹配的情况,比如流水线里一级慢了,前级能停止推数据
SpinalHDL 里定义很直观:
class Stream[T <: Data](val payloadType: HardType[T]) extends Bundle with IMasterSlave {
val valid = Bool()
val ready = Bool()
val payload = payloadType()
override def asMaster() = {
out(valid, payload)
in(ready)
}
override def asSlave() = {
in(valid, payload)
out(ready)
}
def fire: Bool = valid && ready
}
什么时候用它?
- 需要流控的场景,传输路径上有可能堵塞,比如 FIFO,后端处理单元忙
- 高度通用和可靠的数据通路
- 跟传统 AXI-Stream 类型的 IP 交互
代码示例
class StreamProducer extends Component {
val io = new Bundle {
val output = master(Stream(MyData()))
}
val regData = Reg(MyData()) init MyData(0, false)
val sending = RegInit(False)
when(io.output.fire || !sending) {
sending := True
regData.x := regData.x + 1
regData.y := !regData.y
}
io.output.valid := sending
io.output.payload := regData
}
class StreamConsumer extends Component {
val io = new Bundle {
val input = slave(Stream(MyData()))
val outputX = out(UInt(8 bits))
}
val buffer = Reg(MyData())
val validBuffer = RegInit(False)
io.input.ready := !validBuffer
when(io.input.fire) {
buffer := io.input.payload
validBuffer := True
}
val done = False
when(done && validBuffer) {
validBuffer := False
}
io.outputX := buffer.x
}
这里,生产者会根据消费者是否“ready”决定是否继续发,也能优雅应对消费者忙不接的情况。
3. Flow
和 Stream
的本质区别速览
方面 | Flow | Stream |
信号数 | valid , payload | valid , payload , ready |
传输方向 | 主动“推”数据 | 握手式“推+拉”数据 |
是否支持背压 | 不支持,slave必须接收 | 支持,slave可以暂时不ready |
接收端角色 | 被动,一旦valid就必须接收 | 主动,可控制 ready 来控制流量 |
适合场景 | 配置、事件通知、简单数据推送 | 复杂数据通路、pipeline、FIFO |
fire 条件 | 只要 valid | valid && ready |
4. 它们还有什么操作和转换?
Flow
可以转成 Stream
,变成有握手的接口,方便接入复杂流水线。
Stream
可以转成 Flow
,强制认为消费端永远 ready,简化接口。
- 都有方法方便插入寄存器:
Flow.stage()
就是给valid和payload都加一级寄存器,减少组合逻辑延迟
Stream
有多种 pipeline 操作(m2sPipe()
, s2mPipe()
, fullPipe()
),可以分别对valid/payload和ready路径插寄存器,优化时序
Stream
有丰富工具支持,比如 FIFO、仲裁器、多路复用/解复用、时钟域跨越等,直接调用API就可以配置好。
总结
SpinalHDL 的 Flow
和 Stream
让我们实现跨组件通信时省了很多事。
Flow
简单直接,没背压,适合下游一定能实时接收的场景
Stream
复杂一点,有握手带背压,适合速度不匹配、流水线传输、IP兼容
反正非常适合快速搭建模块间标准化的数据流通路。如果你之前用 Verilog 写过类似接口,会发现用 SpinalHDL 直接写 Flow
和 Stream
爽多了,完全不用死盯着valid、ready和具体寄存器跑,Scala里链式调用一写,流水线立马搭起来。