手绘波形图这件事我干过很多次,真正逼我去找替代方案的,是整理一份 SPI 接口时序文档的那段时间——接口参数来回改了七版,每一版我都要把整张图从头到尾重新拖一遍,连箭头位置都得对着格子数。后来我把 WaveDrom 翻了出来,用文本描述时序图,改参数就是改几个字符,几十秒重渲一次。这篇笔记就是那之后陆续攒下来的使用经验,包含 WaveJSON 的语法骨架、总线与标注的写法、位域图的用法、命令行批量渲染的流程,以及我在实际项目里被坑过的几个地方。如果你也在做数字接口的时序说明、协议文档、芯片手册配图,或者只是想在技术博客里放一张干净准确的波形图,这篇应该能让你跳过大部分摸索阶段。
1. 手绘时序图的成本,以及 WaveDrom 的真实定位
1.1 一次改动的连锁反应
用通用绘图工具画时序图,问题不在画第一版,而在画第五版。第一次画的时候你其实是在"抄"一张已知的图,照着时钟周期数格子、摆高低电平、拉箭头,虽然慢但不需要动脑。真正要命的是需求变化:时钟周期从 20ns 改成 15ns,某个信号多等一个周期就能满足建立时间,或者协议从 Mode 0 换成 Mode 3 导致采样边沿反转。
这时候你会发现,图里的每一个元素都是孤立的对象。时钟少了一个周期,右边所有波形都要整体左移,箭头要从旧的位置拖到新的位置,跨信号的对齐关系全靠你的眼睛保证。改完一版之后,你很难有把握说"这张图在时序上和代码完全一致",因为中间没有任何机械校验,全靠人工核对。
更隐蔽的问题是复现性。半年后有人问你"图上这个 tSU 是按哪个版本算的",你只能去翻历史文件。图本身不包含任何参数,参数全在人脑子里。
1.2 文本描述的图:WaveDrom 的核心机制
WaveDrom 做的事情非常朴素:它定义了一套描述时序图的文本格式,叫 WaveJSON,然后把这段文本确定性地渲染成 SVG。图里有什么,完全由文本决定;同一个文本任何时候渲染出来的图都一样。
这个思路带来的直接好处是,图和参数是同一份东西。你要把某个信号延迟一个周期,就在波形串里多写一个.;你要改时钟极性,就把P换成N。改完之后重新渲染,所有对齐关系由工具重新计算,不需要你手动保证。
另一个容易被忽略的好处是,WaveJSON 是纯文本,可以直接进版本控制。这意味着"这张图在 v1.2 改了什么"可以像代码一样用 diff 看,而不是拿到两个二进制文件比大小。对于要长期维护的协议文档,这一点比省时间重要得多。
它本质上是一种领域专用描述语言:语法很小,词汇量很少,但恰好覆盖了数字时序图需要的所有元素——电平、总线、时钟、跃变、跨信号依赖、位域。学会它不需要编程基础,会写 JSON 就行。
1.3 它不擅长什么
用了这么久,我对 WaveDrom 的边界也有比较清楚的认识,免得你在不合适的场景上浪费时间。
它不擅长画模拟波形。正弦波、指数衰减、带噪声的信号,这些不是它的目标,虽然有部分的模拟绘制能力,但效果有限,画这类图还是得用专门的工具。它也不适合画那种需要大量美术调整的"宣传性"插图——比如要控制每个元素的圆角、阴影、渐变,或者要往图里塞公司配色的插画,WaveDrom 的样式是通过皮肤限定的,能调的空间不大。
还有一个常见误解:有人以为它能自动帮你检查时序违规。不会。它只是把你写的东西画出来,你写错了建立时间,它就忠实地画出一个错误的图。校验逻辑在你自己脑子里。
所以更准确的说法是:WaveDrom 是一个把时序意图精确、可重复地转成图形的工具,负责的是表达和一致性,不负责正确性推导。
2. WaveJSON 的两层结构:config 与 signal
2.1 一个最小可用的例子
先看能跑起来的最小结构。下面这段就是一张合法的 WaveDrom 输入:
{ signal: [ { name: "clk", wave: "P......" }, { name: "req", wave: "0.1..0." }, { name: "ack", wave: "0..1.0." } ] }顶层是一个对象,signal是数组,每个元素代表图里的一行。每一行通过name给出信号名(显示在图的左侧),通过wave给出波形串。
除了signal,顶层还能放config、head、foot、reg这几类东西。config控制整体渲染参数,比如水平缩放:
{ config: { hscale: 2 }, head: { text: "SPI Mode 0 采样时序" }, foot: { text: "CS 拉低后需等待 tSU 后再发第一个时钟沿" }, signal: [ /* ... */ ] }hscale是唯一我几乎每张图都会调的参数。默认值 1 在信号多、周期长的时候会把图压得很挤,文字标签容易互相重叠;调成 2 或 3 之后横向空间立刻宽松。head和foot分别是图上方标题和下方注释,foot还可以写成{ tick: true }来在图底部加一条刻度尺,方便读者数周期数。
2.2 tick 才是对齐的真正单位
整个 WaveDrom 的心智模型里,最关键的一个概念是tick:波形串里的一个字符,就代表时间轴上的一个 tick。
这一点必须刻进脑子里,因为它决定了所有对齐行为。"0.1..0."这个串有 7 个字符,就表示 7 个 tick;第 0 个 tick 是低电平,第 1 个 tick 继续低,第 2 个 tick 变成高,第 3、4 个 tick 保持高,第 5 个 tick 变低,第 6 个 tick 保持低。
多个信号的波形串长度可以不一样,但渲染时它们都从同一个起点开始,按各自的 tick 展开。所以跨信号的对齐,本质上就是你数 tick 的能力——你想让ack比req晚两个 tick 拉高,就保证ack的第 2 个位置是1,而不是靠拖拽对准。
我一般会在写第一版之前先确定一个基本原则:一个 tick 等于时钟的半个周期,还是等于一个完整周期?这个选择直接决定了后面所有数字怎么数。用半周期(也就是时钟一个周期占两个 tick)写起来更细,能表达更多的相位关系;用整周期写起来更短,但遇到需要表达半个周期偏移的场景就卡住了。
2.3 wave 字符串的字符表与含义
下面是实际写图时最常用的字符,建议收藏成一张速查表:
| 字符 | 含义 | 典型场景 |
|---|---|---|
0 | 低电平 | 片选拉低、复位有效 |
1 | 高电平 | 使能拉高 |
x | 不确定值 | 总线在复位后、上电初期的状态 |
z | 高阻 | 三态总线未被驱动 |
. | 延续前一个状态 | 保持电平,最常用 |
= | 数据总线段 | 需要显示标签的并行数据 |
p/P | 正极性时钟,起始为高 | P带刻度标记,一般用它 |
n/N | 负极性时钟,起始为低 | 反相时钟 |
2/3 | 三态中间电平 | 上拉/下拉后的中间状态 |
4/5 | 多态中间电平 | 少见,一般用不到 |
u/d | 上升/下降斜边 | 需要体现跃变斜率时 |
这里有两个字符最容易搞混。P和p的区别只在是否画刻度标记,我在做需要读者数周期的图时一律用大写P,因为刻度线能让人一眼看出每个时钟沿落在哪。另外.不是"空",而是"沿用上一个 tick 的状态",这一点和很多人第一次的直觉相反——它不是占位符,是状态延续。
还有个细节:连续写1和写1后面跟一串.,渲染出来是一样的。区别只在可读性。我倾向于只在状态切换的地方写字,后面一路.,因为.排成一行之后,视觉上很容易看出"这一段是持续状态",而满屏的1和0反而看不出变化点在哪。
2.4 时钟的自动翻转、周期与相位
时钟是唯一不需要你手动写每个 tick 的信号。你写一个P,后面的.会自动补成翻转的时钟波形,WaveDrom 内部维护了翻转状态。
{ name: "clk", wave: "P....." }这就够用了。但如果图上同时有多个时钟域,比如一个wr_clk和一个rd_clk,你大概率需要调整其中一个的相位,让它们错开显示而不是叠在一起。
{ name: "clk_fast", wave: "p.......", phase: 0.5 }phase用于整体平移时钟的相位,单位是 tick。常见的用法是给异步时钟加一个非整数偏移,视觉上明确区分两个时钟域。
另外要提醒一句:WaveDrom 不会因为相位不同就自动帮你标出亚稳态或跨时钟域风险,相位参数纯粹是视觉和表达层面的东西,别把它当成时序分析工具。
3. 总线、节点与依赖箭头:让图能"讲逻辑"
3.1 数据总线与 data 数组
数字接口文档里,光有电平是不够的,读者还要知道"这个时刻总线上的值是什么"。这就是=和data数组的用武之地。
{ signal: [ { name: "clk", wave: "P......" }, { name: "bus", wave: "x.==.=x", data: ["head", "body", "tail"] }, { name: "wire", wave: "0.1..0." } ] }规则很直接:波形串里每出现一个=,就从data数组里按顺序取下一个字符串贴到那一段总线上。上面这段的波形串里有三个=,data里也正好有三个元素,一一对应。
这里就是我踩过的第一个大坑。=的个数必须和data的条目数严格对应,多一个少一个都会导致标签整体错位。比如你写了四个=却只给了三个标签,最后一个位置就会空着或者其他位置错位显示,而渲染器不会给你一个明显的报错,图照样画出来,只是标签紧了。这种错误在快速迭代的时候特别容易漏掉。
想让某个总线的值保持多个 tick,做法是=后面跟.:"x.=.=.x"里的第二个=会让标签保持到下一个=出现为止。如果你希望某段值宽一点,就在那个=后面多补几个.,视觉上标签所占的横向宽度就会变大。
3.2 node 锚点:把边挂到具体时刻
电平画完了,接下来是表达"因果关系"。比如ack是在req拉高之后的第二个周期才起来的,这种跨信号的时序关系,光靠两条波形并排放着,读者得自己数。WaveDrom 提供node来做这件事。
node是一个和波形串等长的字符串,里面的字母就是锚点名字,.表示这个 tick 上没有锚点。看一个完整的官方风格例子:
{ signal: [ { name: "A", wave: "01..0" }, { name: "B", wave: "0.1.." }, { node: ".a..b" }, { name: "C", wave: "0...1" }, { name: "D", wave: "0.1.0" }, { node: ".c.d." }, { edges: ["a~>c", "b-~d"] } ] }node既可以作为一个独立的行元素出现(上面的写法),也可以直接挂在某个信号的属性上:{ name: "A", wave: "01..0", node: ".a..b" }。独立成行的时候,节点会画在信号之间的空白通道里;挂在信号上的时候,节点会贴在信号自己的波形上。两种写法我都用过,独立成行的可读性更好,尤其是在节点密集的图里,因为贴身上去容易和波形线纠缠在一起看不清楚。
锚点要画在哪个 tick 上,这个位置选择其实有讲究。一般我会把锚点放在信号实际发生变化的那一个 tick 的开头,而不是变化之后。这样从锚点引出的箭头指向的是"边沿",符合数字电路里"边沿触发"的直觉。
3.3 edges 与弧线、标签
有了锚点,edges数组就是用锚点名字连线的清单。每条边的基本结构是「起点 + 线型与箭头 + 终点」。
{ edges: ["a~>c", "b-~d"] }这里-表示直线,~表示曲线(贝塞尔),>和<表示箭头朝向。所以a~>c是"从 a 用曲线连到 c,箭头指向 c",b-~d则组合了直线和曲线的画法。
最常用的几种组合:
a->b:直线箭头,用于简单的因果标注a~>b:曲线箭头,信号跨度大、中间隔着其他波形时用它避开遮挡a<-b:箭头反向,从 b 指向 aa<->b:双向箭头,用于表示"互相依赖"或者"两者在这个区间内都是有效状态"
跨多个信号的依赖我强烈建议用曲线~而不是直线-。直线穿过中间几行波形的时候,会在视觉上制造出"这根线和中间信号有关系"的误解,而曲线会绕出一个明显的弧度,读者一眼就知道它是绕过中间信号的。
至于标签,不同版本对边标签的写法略有差异,有把标签直接跟在边字符串后面的写法,也有版本支持更结构化的表达。我的做法是:写完边之后先丢进在线编辑器里跑一遍,确认标签位置和显示效果符合预期,再固化到文档里。因为一旦标签挂错位置,读者理解出的时序关系可能完全反过来,这种错误的代价太高,不值得省那一次验证。
3.4 总线标签错位的自查方法
遇到总线标签显示不对,按这个顺序排查基本都能定位:
- 数
=的个数,数data数组的长度,看两者是否相等 - 检查
data数组里的字符串是不是被逗号后多余的空格影响(JSON5 允许尾逗号,但不允许多余的分隔符) - 检查这一段总线的波形串有没有被前面某个多写的字符打乱偏移
- 把波形串按 tick 拆开写一遍,和预期的时刻表逐条对照
第三步是最容易忽略的。总线上游如果多了一个.,整条总线的所有标签都会右移一格,而渲染器不会告诉你"你写多了"。
4. 寄存器位域图:reg 的另一半能力
4.1 bits 与 name 的基本组合
除了时序图,WaveDrom 还有一个很多人不知道的功能:画寄存器位域图。这个能力对写寄存器手册、驱动开发文档的场景非常实用,因为位域图的"从高到低"的字段划分恰好是纯结构化的信息。
{ reg: [ { bits: 7, name: "opcode" }, { bits: 5, name: "rd" }, { bits: 3, name: "funct3" }, { bits: 5, name: "rs1" }, { bits: 5, name: "rs2" }, { bits: 7, name: "funct7" } ] }reg数组里的每一项就是一个字段,bits是位宽,name是字段名。渲染时会自动按顺序从最高位排到最低位,并且在每个字段上标出它覆盖的位区间。
attr是可选属性,用来放补充说明,比如编码格式或者复位值:{ bits: 3, name: "mode", attr: "复位值 000" }。我一般把复位值放在attr里,因为它在字段名太长的时候会被挤掉,所以字段名尽量用缩写,把详细含义放到attr或者图下的foot注释里。
4.2 type 样式与 lanes 分层
位域图提供了一组type值,用来改变字段的底色和填充方式。默认样式(不写type)在所有字段上使用同一底色,靠边框分隔;不同type会让相邻字段呈现不同的填充纹理,用于强调"这几个字段属于同一组"或者"这是保留位"。
{ reg: [ { bits: 4, name: "reserved", type: 4 }, { bits: 8, name: "addr", type: 2 } ] }当寄存器有 32 位、字段又特别多的时候,横向排布会变得很挤。这时候用config里的lanes把字段拆成多行显示:
{ config: { lanes: 2, hscale: 3 }, reg: [ /* ... */ ] }lanes: 2会把字段分成两条泳道,hscale同时调大保证每个字段有足够的横向空间放标签。这个组合我在写 64 位寄存器的文档时用得最多。
4.3 位域图的排版技巧
位域图看起来简单,但排版上有几个经验值得说。
字段名超过四个字符时,如果位宽又比较小,文字会溢出到相邻字段。这时候的解法不是缩小字号,而是把lanes打开、hscale调大,让横向空间扩出来。缩小字号会让整张图和正文的文字大小不匹配,放在文档里很突兀。
另一个技巧是,当多个连续字段其实是同一个逻辑含义的子字段时,可以用type把它们标成同一组,再在foot里写一句"这几个字段需整体读写"。这样读者一眼就知道不能单独改其中一个。
还有个容易被忽略的点:位域图里不要画保留位。保留位是不需要读者关注的信息,把它画出来只会占据横向空间、稀释重点。如果确实需要标注"这里有保留位",把它合并成一个字段,名字写reserved,在attr里说明长度即可。
5. 从在线编辑器到本地工具链
5.1 在线编辑器适合做什么
学习阶段用在线编辑器是最快的路径。它有两个区域,一边写 WaveJSON,一边实时显示渲染结果,改一个字符图就跟着变。语法写错了它会在下方给出错误提示,比在文档里改半天再去看效果快得多。
我到现在还保留着用在线编辑器的习惯,主要用在两个场景:一是试错,比如data数组和=的配对关系拿不准的时候,直接在编辑器里试;二是验证边的画法,因为边的曲线走向和标签位置很难纯靠脑补判断,必须看渲染结果。
但一旦进入正式项目,在线编辑器就不够用了。它没法进版本控制,没法批量生成,也没法集成到构建流程里。所以从"会用"到"用得住",中间必须跨过命令行这一步。
5.2 wavedrom-cli 与批处理渲染
wavedrom-cli是官方的命令行工具,通过包管理器安装后可以在终端里直接渲染文件。基本用法是这样:
wavedrom-cli -i timing.json5 -s timing.svg wavedrom-cli -i timing.json5 -p timing.png-i指定输入,-s输出 SVG,-p输出 PNG。输入文件建议用.json5后缀,因为 JSON5 允许写注释和尾逗号,这在维护一份几十行的时序描述时非常关键——你可以给每个信号加一行注释说明它的含义。
真正让命令行模式价值放大的是批处理。把文档目录下所有的.json5一次性渲染成 SVG:
for f in docs/timing/*.json5; do wavedrom-cli -i "$f" -s "${f%.json5}.svg" done这段脚本挂到文档构建流程里,就实现了"改文本 → 自动出图"的闭环。文档仓库里只存文本,图片是构建产物,永远和文本保持一致。这是我最推荐的做法,因为它彻底消灭了"图上画的和文档里写的不一样"这类问题。
5.3 网页与文档系统里的集成方式
如果是往网页或博客里嵌,可以直接引入渲染库,然后把 WaveJSON 放在特殊类型的脚本块里:
<script src="https://cdn.jsdelivr.net/npm/wavedrom@3/wavedrom.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/wavedrom@3/skins/default.js"></script> <script type="WaveDrom"> { "signal": [ { "name": "clk", "wave": "P......" } ] } </script> <script>WaveDrom.ProcessAll();</script>ProcessAll()会扫描页面上所有type="WaveDrom"的脚本块,逐个渲染成图。要注意皮肤脚本必须单独引入,早期版本里皮肤是打包在一起的,新版本拆开了,漏引皮肤会出现渲染出来样式不对或者直接报错的情况。
如果是 React、Vue 这类需要动态渲染的场景,就得调用底层的渲染接口把图挂到指定的容器节点上,而不是依赖ProcessAll()的全页扫描。核心思路是:拿到目标 DOM 节点,把 WaveJSON 对象传进去,让渲染器生成 SVG 并插入节点。做组件封装的时候记得在依赖变化时清理旧的 SVG,否则反复渲染会在容器里堆叠多层节点。
常见的文档系统基本都有对应的集成方案,比如静态站点生成工具、文档生成框架、以及各类 Markdown 扩展,原理都是"把代码块里的内容交给 WaveDrom 渲染"。如果找不到现成的插件,用前面那段批处理脚本先生成 SVG、再在文档里引用图片,是投入产出比最高的兜底方案。
5.4 编辑器实时预览
写 WaveJSON 本质上是在写代码,实时预览能极大降低试错成本。主流的代码编辑器都有对应的预览方案:有的是专门的插件,打开.json5文件就自动在侧边渲染;有的是通过 Markdown 预览扩展把代码块渲染出来。
我的配置习惯是:把 WaveJSON 单独放在一个文件里写,编辑器的预览面板打开,改完保存即刷新。等单张图定稿之后再复制到文档正文里。这样做的好处是,试错期间的中间状态不会污染正文文档。
6. 我在实际项目里踩过的那些坑
6.1 波形串长度不一致导致的视觉错位
这是最高频的问题。多个信号的波形串长度不一样的时候,短的那个信号在到达自己末尾之后就没内容了,渲染出来的线会在中途断掉。如果这些信号之间本应存在对齐关系,断掉的那一段就会让读者误判。
我的做法是:画完之后统一把所有波形串补齐到相同长度,短的补.。这样虽然多打几个字符,但视觉上所有信号线都延伸到同一位置,不会有截断的突兀感,而且后期增删 tick 的时候改动范围也更可预测。
6.2 时钟相位错位带来的理解偏差
有一次我在画一个同步 FIFO 的读写时序,写时钟和读时钟的相位没调,渲染出来两个时钟的边沿几乎重合。评审的时候有人指出"这里看起来像是同一个时钟驱动",实际上代码里是两个异步时钟域。虽然我在foot里写了注释,但图的视觉暗示太强了。
从那之后我就养成了习惯:只要涉及两个及以上时钟域,一定给其中一个加phase偏移,让边沿在视觉上明确错开,同时在图下方的注释里写明"两个时钟异步"。图本身应该尽量减少歧义,不能指望读者去读注释才发现问题。
6.3 节点名冲突与未定义
node里的锚点名字在整张图里应该是唯一的。如果你在不同行里都用了a作为锚点,edges引用a的时候就会出现歧义,渲染出来的连线可能连到你没预期的地方。
另一个更隐蔽的问题是引用了不存在的锚点。比如你把节点名字写成了ab而边里写的是a,这种错误有的版本会静默忽略,导致那条边根本不画出来,你一不留神就以为图是对的。
所以每次画完带边的图,我都会从边往回查一遍:edges里出现的每个名字,是否都能在某个node串里找到。名字尽量用有意义的短词,比如req_hi、ack_lo,比单字母更容易看出对应关系,也不容易撞名。
6.4 导出 SVG 的尺寸与字体问题
从命令行导出的 SVG 默认尺寸有时候和文档排版不匹配,直接插进去可能过大或者过小。SVG 是矢量格式,缩放不会失真,所以调整尺寸本身不是问题,问题在于缩放之后字号也会跟着变。如果图缩小到一半,原本就偏小的信号名会变得完全看不清。
我的处理方式是先调hscale把图本身的横向尺寸和文字密度调到合理状态,再通过外层容器的宽度来控制最终显示大小,尽量让缩放比例接近 1。这样文字大小和正文会比较协调。
还有一点:SVG 里的字体是依赖系统字体的。如果你用了默认字体,而阅读者的系统没有这个字体,渲染出来的文字宽度会变化,可能导致文字超出或位置偏移。做对外发布的图时,我会尽量用通用的无衬线字体配置,避免依赖特定系统字体。
6.5 JSON 注释与尾逗号
这一条看起来是小事,但很影响维护体验。纯 JSON 不允许注释和尾逗号,而 WaveJSON 描述动辄几十行,加一行注释说明"这个信号是内部分频出来的"非常有必要。
.json5格式支持这两种写法,所以在命令行流程里一律用.json5保存源文件。注意在线编辑器对语法的容忍度和命令行工具不一定完全一致,有时候编辑器里能跑的内容,命令行报错,或者反过来。遇到这种情况,用最保守的写法(去掉所有注释和尾逗号)先确认结构没问题,再逐步加回注释定位问题。
7. 几个可以直接抄的时序模板
7.1 握手时序:Valid/Ready
这是数字接口里最通用的一个模式,valid拉高之后要等到ready拉高才算完成一次传输。
{ config: { hscale: 2 }, head: { text: "Valid/Ready 握手" }, signal: [ { name: "clk", wave: "P......" }, { name: "valid", wave: "0.1.0.." }, { name: "ready", wave: "0..1.0." }, { name: "data", wave: "x.=.=.x", data: ["D0", "D1"] }, { name: "accept", wave: "0..1.0." } ] }这里accept用一条独立信号表示"这次传输被接受",它的波形是valid和ready同时为高的那段。写图的时候我是手动对齐的,改的时候一定要三个信号一起动,否则accept和另外两条就对不上了。
7.2 同步 FIFO 的读写
{ signal: [ { name: "wr_clk", wave: "P......", phase: 0 }, { name: "wr_en", wave: "01.0..." }, { name: "wdata", wave: "x=.=.x.", data: ["A", "B"] }, {}, { name: "rd_clk", wave: "P......", phase: 0.5 }, { name: "rd_en", wave: "0..1.0." }, { name: "rdata", wave: "x..=.x.", data: ["A"] }, { name: "empty", wave: "0...1.0" } ] }两个时钟域用phase错开,中间的空对象{}是一条分隔线,把写侧和读侧在视觉上分开。这个空行的小技巧很实用,比调颜色或者加标题省事得多。
7.3 复位与状态机切换
{ signal: [ { name: "clk", wave: "P........" }, { name: "rst_n", wave: "01....0.." }, { name: "state", wave: "x=.=.=.=.", data: ["IDLE", "CFG", "RUN", "IDLE"] } ] }状态机的状态用总线来表达是最清楚的,读者能直接看到状态名而不是去对照一组编码。复位的下降沿和state回到IDLE之间的对应关系,通过 tick 位置就能直接看出来。
7.4 三态总线的驱动切换
{ signal: [ { name: "oe", wave: "0..1..0." }, { name: "bus", wave: "z..=.=.z", data: ["0x1", "0x2"] } ] }z表示高阻,渲染出来是中间那条线;被驱动的时候用=显示数据标签。这张图的关键在于oe的拉高拉低和总线上z与数据段的边界要严格对齐,否则会画出"先有数据后使能"这种物理上不可能的情况。
这四个模板覆盖了我日常 80% 的绘图需求。刚开始的时候我每次都是从空白开始写,后来发现改模板比从零写快得多,尤其是握手和 FIFO 这两种,结构基本固定,只是数据条目和周期数变一变。把这些模板存在一个文件里当"起手式",是我目前效率最高的做法。