如何优雅地绘制电路图

2023-07-08 ⏳12.2分钟(4.9千字)

最近整理学习笔记1时需要画数字电路图。幸亏之前给博客系统开发了对应的功能2,可以在 Markdown 中使用 LaTeX 的宏包来绘图。简单学习了一把,发现 LaTeX 可以绘制非常精美的电路图,这让我的文章增色不少。今天就给大家简要介绍一下绘图方法,希望能帮大家快速入门。

LaTeX 比较知明的绘图包要属 PGF/TikZ 了。TikZ 不但本身功能强大,还支持定制专用的绘图包。我们今天使用 CircuiTikZ3 宏包来绘图。因为我也是现学现卖,而且程序员使用数字电路比较多,所以本文的例子主要是数字电路,更准确地说是逻辑门电路。

R-S 触发器

我们先展示如何绘制如下 R-S 触发器:

R-S 触发器

CircuiTikZ 提供很多组件,其中就包括各类逻辑门电路。我先给出一段完整代码,画一个或非门,后续只列出 CircuiTikZ 专用代码,不再逻辑完整 LaTeX 代码。

\documentclass[dvisvgm]{standalone}
% 导入 CircuiTikZ 宏包
\usepackage{circuitikz}

% 使用 IEEE 样式
\ctikzset{logic ports=ieee}

\begin{document}
\begin{circuitikz}

% 在 (0,0) 位置画一个或非门
\draw (0,0) node[nor port](){};

\end{circuitikz}
\end{document}

显示效果如下:

一个或非门

上面最核心的指令是\draw (0,0) node[nor port](){};\draw 是 TikZ 的绘图指令。后面的(0,0)表示坐标位置,比较复杂,我们后面细说。然后node表示要画的对象,方括号内指定对象的类型等各类信息,这里的nor port表示或非门。再后面的圆括号里可以填写引用标记,有点类似程序中的变量,后面会细说。最后的大括号里可以填写需要显示的文字,几乎所有 TikZ 对象都支持。

我们可以在门电路上显示自定义内容,比如显示NOR

\draw (0,0) node[nor port](){NOR};
显示自定义描述的或非门

继续说 R-S 触发器。显然,我们需要画两个或非门。第一个的坐标是(0,0),第二个的坐标应该是什么呢?不好说,但我们可以猜一下。第二个或非门应该在第一个的下面,它们的横坐标应该相同,第二个的纵坐标应该比较大才是。我们试试(0,1)

\draw (0,0) node[nor port](){NOR1};
\draw (0,1) node[nor port](){NOR2};

展示效果如下:

两个或非门

两个或非门挤到一起了。大家再仔细看,NOR2 在 NOR1 的上方,也就是说坐标(0,1)(0,0) 的上方。CircuiTikZ 用的就是普通的笛卡尔坐标系。至于坐标中单位长度可以先不管,不合适就调调。现在把 NOR2 调到下方,使用坐标(0,-2)

两个或非门

这样两个图标间距就比较大了。而且大家也要注意,在 TikZ 中,坐标只是用来确定对象之间的相对位置。在最终绘制的图片中,所有对象会根据相对位置居中展示。也就是说,大家在绘图时不用太考虑对象在画布中的绝对位置。

接下来就要画连线并标注输入了输出了。这一步需要根据门电路的引脚绘图,所以城要给它们加上引用编号。这个编号可以随便取,只要不重复就行。比如分别叫它们 NOR1 和 NOR2,写到圆括号中:

\draw (0,0) node[nor port](NOR1){NOR1};
\draw (0,-2) node[nor port](NOR2){NOR2};

有了编号,就能很方便地绘制各类连线。比如我们给 NOR1 的输出端口画一条引线,并加上标签 Q:

\draw (0,0) node[nor port](NOR1){NOR1};
\draw (NOR1.out) -- ++(+0.5,0) node[right]{Q};

引线对应的指令是 (NOR1.out) -- ++(0.5,0) ,它的意思是从 NOR1 的输出端,用 out 表示,水平向右画一条线段,终点是 ++(0.5,0)。这个坐标前面有 ++ 表示坐标增量,意思是以 NOR1.out 为基准,横坐标向右延伸 0.5 个单位,纵坐标不变,以此为终点画线。在其后还有一个 node[right]{Q} 表示在终点的右侧显示标签 Q。

两个或非门

我们可以如法炮制,画出左边的 R 输入线:

\draw (NOR1.in 1) -- ++(-0.5,0) node[left]{R};
两个或非门

这里的引用基准变成了 (NOR1.in 1) 表示 NOR1 的第一个输入端。第二个输入端为 (NOR1.in 2)。 CircuiTikZ 中的门电路默认有两个输入一个输出,下文会展示多个输入的用法。因为是向左画线,所以相对坐标变成了 ++(-0.5,0),最后使用 node[left]{R} 在左边显示 R。

现在把两个门电路放到一起:

两个或非门

基本骨架算是画好了。接下来需要将每个或非门的输入端跟另一个输出端连起来。

以 NOR1 为例。最简单的办法是从 NOR1 的输入端向下画一条线段,然后从 NOR2 的输出端向上画一条线段,最后把两条线段连接起来。但线段要画多长呢?不好说,先用 0.5 试试。 NOR2 如法炮制,但方向相反。

\draw (NOR1.in 2) -- ++(0,-0.5);
\draw (NOR2.in 1) -- ++(0,+0.5);

\draw (NOR1.out) -- ++(0,-0.5);
\draw (NOR2.out) -- ++(0,+0.5);
两个或非门

竖线画好了,但怎么将它们连起来呢?这里还有一个困难,就是各引线的末端坐标都是相对于起始位置自动计算的,我们要怎样引用它们呢?CircuiTikZ 显然得解决这个问题,它提供 coordinate 指令,我们可以用 coordinate 给中间节点添加引用。比如上面的四个末端可以做如下改动:

\draw (NOR1.in 2) -- ++(0,-0.5) coordinate(i1);
\draw (NOR2.in 1) -- ++(0,+0.5) coordinate(i2);

\draw (NOR1.out) -- ++(0,-0.5) coordinate(o1);
\draw (NOR2.out) -- ++(0,+0.5) coordinate(o2);

然后可以直接引用 i1,i2,o1,o2 绘图:

\draw (i1) -- (o2);
\draw (i2) -- (o1);

最终显示效果如下:

两个或非门

最后需要绘制输入端口跟输出端口之间的连接标志。上面的 -- 是一种简化指令,它实际对应 to[short],方括号内可以添加其它设置。比如说我们希望线段的起点需要显示一个黑点来表示连接,则可以将 -- 替换成 *-

\draw (NOR1.out) to[short,*-] ++(0,-0.5) coordinate(o1);
\draw (NOR2.out) to[short,*-] ++(0,+0.5) coordinate(o2);

这里的*-表示在起点多画一个黑点,-*表示在终点画黑点。显示效果如下:

两个或非门

其实到这里 R-S 触发器已经画好了。但还是有点小瑕疵,两条交叉线的交点不在画布中心。纠其原因,是因为从或非门输入端引线末端跟输出端引线末端高度不同所致。我们前面将引线的长度统一设为 0.5,画出的末端的高度肯定不会一样。有没有办法让两者的高度保持一致呢?肯定有了,这得用于一种特殊的坐标引用记法。

CircuiTikZ 提供两种记法 (A -| B)(A |- B)。前者表示取 A 的纵坐标和 B 的横坐标确定一点,后者表示取 A 的横坐标和 B 的纵坐标确定一点。

在 R-S 触发器中,因为输入端引线末端离画布中心较近,我们以它为基准,那么 NOR1 的输出端引线的末端坐标就是 (i1 -| NOR1.out);同样的,NOR2 的输出端引线末端坐标为 (i2 -| NOR2.out)。修改对应的绘图指令:

\draw (NOR1.out) to[short,*-] (NOR1.out |- i1) coordinate(o1);
\draw (NOR2.out) to[short,*-] (NOR2.out |- i2) coordinate(o2);

最终效果如下:

两个或非门

这样无论怎么调整两个或非门的间距,交点始终在画布中心。比如把门电路的垂直间距改成 4:

两个或非门

这应该算相对引用的威力吧。完整的绘图指令如下:

%%| fig-cap: 两个或非门
\usepackage{circuitikz}

\ctikzset{logic ports=ieee}

\begin{document}
\begin{circuitikz}

\draw (0,0) node[nor port](NOR1){NOR1};
\draw (NOR1.out) -- ++(+0.5,0) node[right]{Q};
\draw (NOR1.in 1) -- ++(-0.5,0) node[left]{R};

\draw (0,-2) node[nor port](NOR2){NOR2};
\draw (NOR2.out) -- ++(+0.5,0) node[right]{\ctikztextnot{Q}};
\draw (NOR2.in 2) -- ++(-0.5,0) node[left]{S};

\draw (NOR1.in 2) -- ++(0,-0.5) coordinate(i1);
\draw (NOR2.in 1) -- ++(0,+0.5) coordinate(i2);

\draw (NOR1.out) to[short,*-] (NOR1.out |- i1) coordinate(o1);
\draw (NOR2.out) to[short,*-] (NOR2.out |- i2) coordinate(o2);

\draw (i1) -- (o2);
\draw (i2) -- (o1);

\end{circuitikz}
\end{document}

TikZ 支持合并\draw指令,也支持连续绘画。上面的代码可以进一步简化为:

%%| fig-cap: 两个或非门
\usepackage{circuitikz}

\ctikzset{logic ports=ieee}

\begin{document}
\begin{circuitikz}

\draw (0,0) node[nor port](NOR1){NOR1}
      (NOR1.out) -- ++(+0.5,0) node[right]{Q}
      (NOR1.in 1) -- ++(-0.5,0) node[left]{R}
      (NOR1.in 2) -- ++(0,-0.5) coordinate(i1);

\draw (0,-2) node[nor port](NOR2){NOR2} 
      (NOR2.out) -- ++(+0.5,0) node[right]{\ctikztextnot{Q}} 
      (NOR2.in 2) -- ++(-0.5,0) node[left]{S}
      (NOR2.in 1) -- ++(0,+0.5) coordinate(i2);

\draw (NOR1.out) to[short,*-] (NOR1.out |- i1) -- (i2);
\draw (NOR2.out) to[short,*-] (NOR2.out |- i2) -- (i1);

\end{circuitikz}
\end{document}

3-8 选择器

本文到这里也应该结束了。但读者可能会认为 R-S 触发器太简单,觉筛 TikZ 也没什么大不了的。为此我就再分享画过 3-8 译码器电路,非常经典。先看效果图:

完整的 3-8 选择器

这算是比较复杂的电路图了。但大家不要被它唬住。我们来分析它的结构。首先可以分成上下两个部分。上面是一系列的横线,下面是八个三输入与门。横线总共有六条,两两分组,下面一条通过一个非门与上面一条相连。画出三组横线后再依次画出八个与门并与之相应的横线相连就可以了。

我们从上到下依次画起。上面有六条线,两两一组,我们先画两条。假设长度为 9,线间距为 0.5。我们以 (0,0) 为参考点。因为下边还要画非门,我们让上边的线反向延长一个单位,也就是从 (-1,0) 开始;下边的线从 (0,-0.5) 开始。绘图指令为:

\draw (-1,0) -- ++(10,0)
      (0,-0.5) -- ++(9,0)
      ;

绘图结果如下:

一长一短两条线

这与我们的想要的结果相距甚远。但不要紧,聚沙成塔。我们在上面线的左端打上标签并在下面线左端画一个非门:

\draw (-1,0) node[left]{$s_0$} -- ++(10,0)
      (0,-0.5) node[not port](){} -- ++(9,0)
      ;
绘制非门

这里出现两个问题。首先,跟线间距相比,非门有点大了。这个好解决,我们可以设置 scale 来缩小非门尺寸。另一个问题是第二条横线左端延伸到了非门内部。这是为什么呢?

我们回过头来看画非门的指令 (0,-0.5) node[not port](){},它的意思是在左边的坐标点绘制非门。但非门的位置怎么确定呢?在默认情况下,该坐标点就是非门的中心。同时,它也是第二线的起点,所以会出现横线延伸到非门内部的问题。

要想解决这个问题,就得用到 anchor 这个工具。CircuiTikZ 给每个对象都定义了 anchor,分别对应八个方向(参照地图,上北下南)。我们在绘图时可以指定以哪个方向的 anchor 为中心。在上面的例子中,我们可以指定以非门的右边,也就是东边为中心绘图。调整后的绘图指令如下:

\draw (-1,0) node[left]{$s_0$} coordinate(L0) -- ++(10,0)
      (0,-0.5) node[not port,scale=0.5,anchor=east](N0){} -- ++(9,0)
非门调整

最后我们需要把非门的输入端连到上一条电路上。

\draw (-1,0) node[left]{$s_0$} coordinate(L0) -- ++(10,0)
      (0,-0.5) node[not port,scale=0.5,anchor=east](N0){} -- ++(9,0)
      (N0.in) to[short,-*] (N0.in|-L0)
      ;

为此用 L0 引用上面直线的左端点坐标,用 N0 引用非门。从非门的输出端 N0.in 出发,向 (N0.in|-L0) 点画一条线段,且末端附带黑点。这里的 (N0.in|-L0) 表示用 N0.in 的横坐标和 L0 的纵坐标确定一点,这一点一定落到上面的直线上。

双线相连

这样我们就画好了第一组直线。虽然另外两组也可以如法炮制。但作为入门学习材料,我想向读者展示 LaTex 的另一特色,那就是自定义命令。这个自定义命令可以简单理解为 C 语言的宏定义。我们可以使用 \newcommand 将一组复杂的绘图指令定义成一个命令,还能指定需要的参数。通过它,不但可以简化绘图代码,还能让绘图逻辑变得更加清晰,也更容易维护。

回到上面的这组直线,它包含一长一短两条直线,然后用一个非门将二者相连。两直线在垂直方向上的距离为 0.5 单位,在水平方向上,下面的直线比上面的直线向右缩进 1 单位长度。两条直线长度相差 1 个单位。

只要确定了上面直线左端点的坐标和长度,那么其他图元的位置也就确定了。如果你们要定义新的命令来一次性画出一组直线,我希望它能接受以下参数:

我们的新命令大概长这个样子:

\newcommand*{\myline}[4] {
    node[left]{$s_0$} coordinate(L0) -- ++(10,0)
    (0,-0.5) node[not port,scale=0.5,anchor=east](N0){} -- ++(9,0)
    (N0.in) to[short,-*] (N0.in|-L0)
}

大括号里的 \myline 就是命令的名字,后面的 4 表示参数的数量。在接下来的大括号内,可以使用 #1, #2 之类的特殊变量来引用对应的参数。我们使用的时候需要配合 \draw 来指定左上角端点位置:

\draw (-1,0) \myline{L0}{s_0}{N0}{9};

接下来的问题就是要把上面写死的参数改写成对应命令参数。首先,所有的 L0 都得替换成 #1s_0 需要替换成 #2,所有 N0 都得替换成 #3。这些都比较简单。可是命令里的 ++(9,0) 坐标增量和 (0,-0.5) 该怎么处理呢?这些值需要根所左上端点的位置来动态确定。为此,我们需要做一点数学运算,这就得用于 calc 宏包。使用该包需要添加如下导言:

\usetikzlibrary{calc}

我对它也不太了解。目前就知道它只能做坐标计算。比如左下端点其实是相地左上端点加了偏稳量 (1,-0.5)。用 calc 包可以表示为 ($(#1)+(1,-0.5)$)。注意,两边都有一个$,里是两个坐标相加,其中 (#1) 表示左上端点的位置。这样就能动态确定左下端点的位置。

经过有限的试验,我发现 calc 不能直接计算数字相加,也就说 ++(10,0) 不能写成 ++($#4+1$,0)。为此,我们可以把 ++(10,0) 改成绝对坐标,然后基于左上端点计算。于是可以改写为 ($(#1)+(1,0)+(#4,0)$)。这里中间的 (1,0) 是为了让上面的直线长度加一,后面的 (#4,0) 是为了指定直线主体长度。下面直线的 ++(9,0) 可以改写成 ($(#1)+(1,-0.5)+(#4,0)$),也就是以左下端点为基准向右扩展 #4 个单位。

最终的命令长这个样子:

\newcommand*{\myline}[4] {
    node[left]{$#2$} coordinate(#1) -- ($(#1)+(1,0)+(#4,0)$)
    ($(#1)+(1,-0.5)$) node[not port,scale=0.5,anchor=east](#3){} -- ($(#1)+(1,-0.5)+(#4,0)$)
    (#3.in) to[short,-*] (#3.in|-#1)
}

有了 \myline,我们可以立即画出三组直线对:

\draw (-1,0) \myline{L0}{s_0}{N0}{9};
\draw (-1,1) \myline{L1}{s_1}{N1}{9};
\draw (-1,2) \myline{L2}{s_2}{N2}{9};
三组完整的连接线

L0, N0, L1, N1, L2, N2 分别是每一条直线上的点。后续可以通过它们获取对应直线的纵坐标。只要我们愿意,相画多少组都行。

最后我们开始画下面的与门电路。 先画一个三输入与门,而且是头朝下的。 CircuiTikZ 可以通过 number inputs 控制门电路输入端数量,然后通过 rotate 指定逆时针旋转的角度。默认朝右,如果想朝下,需要逆时针转 -90 度。而且我们还得在输出端打上文字标识。所以我们需要的与门指令如下:

\draw (0,0) node[and port, number inputs=3,rotate=-90](A0){}
      (A0.out) node[begin]{$d_0$};
头朝下三输入与门

有了与门,我们还得将各引脚同上面的三组线连起来。以 A0 为例,它的三个输入端要依次连接 N0, N1 和 N2,对应的指令如下:

\draw (0,0) node[and port, number inputs=3,rotate=-90](A0){}
      (A0.out) node[begin]{$d_0$};
      (A0.in 1) to[short,-*] (A0.in 1 |- N0)
      (A0.in 2) to[short,-*] (A0.in 2 |- N1)
      (A0.in 3) to[short,-*] (A0.in 3 |- N2)
      ;

以第一个输入为例,它对应 (A0.in 1),它要向上画,连接到 N0 所在的直线,也就是说连结点的横坐标与 (A0.in 1) 相同,纵坐标与 (N0) 相同。

非门与信号线相连

接下来我们要画八个与门,肯定不能直接复制粘贴,也需要定义新命令。我们再看一下与门的绘图指令:

\draw (0,0) node[and port, number inputs=3,rotate=-90](A0){}
      (A0.out) node[begin]{$d_0$};
      (A0.in 1) to[short,-*] (A0.in 1 |- N0)
      (A0.in 2) to[short,-*] (A0.in 2 |- N1)
      (A0.in 3) to[short,-*] (A0.in 3 |- N2)
      ;

这里也有五个变量,分别是 A0, d_0, N0, N1, N2,但它们相互之间没有关系,比前面的 \myline 相简单多了。所以直接定义如下命令:

\newcommand*{\myand}[5] {
    node[and port, number inputs=3,rotate=-90](#1){}
    (#1.out) node[below]{$#2$}
    (#1.in 1) to[short,-*] (#1.in 1 |- #3)
    (#1.in 2) to[short,-*] (#1.in 2 |- #4)
    (#1.in 3) to[short,-*] (#1.in 3 |- #5)
}

依次用 #1-#5 替换对应参数即可。有了 \myand,立马可以画出八个与门:

\draw (1,-2) \myand{A7}{d_7}{L0}{L1}{L2};
\draw (2,-2) \myand{A6}{d_6}{N0}{L1}{L2};
\draw (3,-2) \myand{A5}{d_5}{L0}{N1}{L2};
\draw (4,-2) \myand{A4}{d_4}{N0}{N1}{L2};
\draw (5,-2) \myand{A3}{d_3}{L0}{L1}{N2};
\draw (6,-2) \myand{A2}{d_2}{N0}{L1}{N2};
\draw (7,-2) \myand{A1}{d_1}{L0}{N1}{N2};
\draw (8,-2) \myand{A0}{d_0}{N0}{N1}{N2};

同样需要指定每个与门的相对坐标以及需要连接的直线。使用 \myand 我们可以从绘图细节中摆脱出来,只关心各图元之间的罗辑关系。这是使用 TikZ 绘图最大优势。

完整的 3-8 选择器

使用 TikZ 说到底是在描述图元之间的关系,然后让计算机帮忙绘图。从这个意义上讲,它实际上是一种数据可视化,所以呈现出的结果会更加精确。

4-16 选择器

使用这种数图分离的思想,我们可以快速绘制复杂的图案。为了进一步展示 TikZ 的魔力,我在上面指令的基础上再定义一个四输入与门命令,为大家画一个 4-16 选择器。

\newcommand*{\myandx}[6] {
    node[and port, number inputs=4,rotate=-90](#1){}
    (#1.out) node[below]{$#2$}
    (#1.in 1) to[short,-*] (#1.in 1 |- #3)
    (#1.in 2) to[short,-*] (#1.in 2 |- #4)
    (#1.in 3) to[short,-*] (#1.in 3 |- #5)
    (#1.in 4) to[short,-*] (#1.in 4 |- #6)
}

跟原来的 \myand 区别不大,只多了一个参数,用来控制与第四组线连接。

先画四组直线,长度需要翻倍,不然容不下十六个与门:

\draw (-1,0) \myline{L0}{s_0}{N0}{18};
\draw (-1,1) \myline{L1}{s_1}{N1}{18};
\draw (-1,2) \myline{L2}{s_2}{N2}{18};
\draw (-1,3) \myline{L3}{s_3}{N3}{18};

再画 16 个四输入与门。大家观察每条指令最后四个参数的变化,L 表示 1,N 表示 0,它们与输入的值一一对应。

\draw (1,-2) \myandx{A7}{d_{15}}{L0}{L1}{L2}{L3};
\draw (2,-2) \myandx{A6}{d_{14}}{N0}{L1}{L2}{L3};
\draw (3,-2) \myandx{A5}{d_{13}}{L0}{N1}{L2}{L3};
\draw (4,-2) \myandx{A4}{d_{12}}{N0}{N1}{L2}{L3};
\draw (5,-2) \myandx{A3}{d_{11}}{L0}{L1}{N2}{L3};
\draw (6,-2) \myandx{A2}{d_{10}}{N0}{L1}{N2}{L3};
\draw (7,-2) \myandx{A1}{d_9}{L0}{N1}{N2}{L3};
\draw (8,-2) \myandx{A0}{d_8}{N0}{N1}{N2}{L3};

\draw ( 9,-2) \myandx{B7}{i_7}{L0}{L1}{L2}{N3};
\draw (10,-2) \myandx{B6}{i_6}{N0}{L1}{L2}{N3};
\draw (11,-2) \myandx{B5}{i_5}{L0}{N1}{L2}{N3};
\draw (12,-2) \myandx{B4}{i_4}{N0}{N1}{L2}{N3};
\draw (13,-2) \myandx{B3}{i_3}{L0}{L1}{N2}{N3};
\draw (14,-2) \myandx{B2}{i_2}{N0}{L1}{N2}{N3};
\draw (15,-2) \myandx{B1}{i_1}{L0}{N1}{N2}{N3};
\draw (16,-2) \myandx{B0}{i_0}{N0}{N1}{N2}{N3};
完整的 4-16 选择器

通过 LaTeX 自定义命令,画 4-16 选择器跟 3-8 选择器区别不大。读者有兴趣可以搞个 5-32 选择器。

总结

终于写完了,希望对大家有所帮助。我用过好多绘图软件,大都是拖拖拽拽就能画出相要的图形,对新手比较友好。但如果想画 4-16 选择器这样的复杂图形,用鼠标真要死人的。而且后续改动也会异常困难。TikZ 则相反,上手比较难。一旦入了门,后面的天地可就太广阔了。很开心之前为博客添加原生 LaTeX 支持,后续也会输出更多图文并茂的内容。敬请期待。


  1. ../cs/boot.html↩︎

  2. ../unix/markdown-drawing.html↩︎

  3. https://ctan.org/pkg/circuitikz↩︎