资讯动态

WaveDrom 时序图实战:WaveJSON、总线标注与寄存器位域图

发布时间:2026/9/29 10:25:00 来源:尧图企业网站定制
手绘波形图这件事我干过很多次真正逼我去找替代方案的是整理一份 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 与 signal2.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箭头指向 cb-~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一次性渲染成 SVGfor f in docs/timing/*.json5; do wavedrom-cli -i $f -s ${f%.json5}.svg done这段脚本挂到文档构建流程里就实现了改文本 → 自动出图的闭环。文档仓库里只存文本图片是构建产物永远和文本保持一致。这是我最推荐的做法因为它彻底消灭了图上画的和文档里写的不一样这类问题。5.3 网页与文档系统里的集成方式如果是往网页或博客里嵌可以直接引入渲染库然后把 WaveJSON 放在特殊类型的脚本块里script srchttps://cdn.jsdelivr.net/npm/wavedrom3/wavedrom.min.js/script script srchttps://cdn.jsdelivr.net/npm/wavedrom3/skins/default.js/script script typeWaveDrom { signal: [ { name: clk, wave: P...... } ] } /script scriptWaveDrom.ProcessAll();/scriptProcessAll()会扫描页面上所有typeWaveDrom的脚本块逐个渲染成图。要注意皮肤脚本必须单独引入早期版本里皮肤是打包在一起的新版本拆开了漏引皮肤会出现渲染出来样式不对或者直接报错的情况。如果是 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 这两种结构基本固定只是数据条目和周期数变一变。把这些模板存在一个文件里当起手式是我目前效率最高的做法。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑