前言

我打算采用 Q & A 的方式编写前言,谁同意,谁反对?

Q:这是一本怎样的书?

A:如你所见,这是一本非官方的北航 6 系《计算机组成》课程通关指南。我希望学弟学妹们能够通过阅读这本书,以更小的负担、更高的效率,更轻松快乐地完成计组实验课程!

Q:为什么要写这本书?

A:这就要从我上大二的时候说起了。在刚进入 6 系时,我对很多计算机相关的知识完全没有任何了解,在学习计组课程时,面对着官方指导书中专业的术语和简洁的内容完全不知所措。最后也是在学习的过程中不断摸索,拜读了各位学长和同学的博客,才连滚带爬地完成了计组实验课程。我相信 6 系有很多同学和我的状态非常类似,于是在今天我选择回过头来,将我一年来艰难摸索出的知识用通俗易懂的语言写出来,帮助学弟学妹们少走弯路,通关计组实验课程!

Q:这本书有什么特点?

A:这本书最直观的特点或许就是篇幅特别长,在写作时我从不吝惜字数,经常是写这个部分的时候突然想到还应该讲一下那个部分,所以字数就像线面一样疯狂繁殖(摊手)。另外一个比较明显的特点就是文风比较诙谐幽默,毕竟又不是官方教程,篇幅还那么长,为了让大家能更开心地读下去,我充分利用了多年网上疯狂冲浪的经验,偶尔会玩一些梗,让大家开心开心,放轻松一点。

Q:创作这本书的历程是怎么样的?

A:这本书从 2025 年 3 月份开始创建文档,一直到开学前一天 9 月 7 号投入试读,历时半年左右。期间因为大二下学期各种压力都很大,鸽了相当长一段时间,所以前后的行文特点上可能有所区别,不过肯定的是全都是我一个人写的,也没有使用大模型代笔。(其实从文中玩的一些梗流行的时间就能大致推断出这一段是什么时候写的,笑)

Q:你的教学理念是什么样的?

A:在本书的写作中,我坚持以问题作为引导,围绕“如何使用”和“为何使用”两大主题进行写作,尽力帮助大家构建起整体的、连贯的认知思维。此外,我认为教学不能是空谈长篇大论的原理,这样虽然做到了严谨,但却陷入了大学教材普遍的“防自学”怪圈。相反,在本书的写作中,我用大量的文字带领大家在例子中学习,对极为晦涩且几乎没用的知识尽量回避,力争用通俗易懂的语言完成教程,希望大家能够一看就懂,一点就通!

Q:对于大量留白式的、鼓励学生探索的教程,你的看法如何?

A:可以预见的是,有人一定会批评我把所有东西都直接讲出来,不给学生探索的空间,是一种扼杀同学积极性的填鸭式教学。然而我并不赞同此种观点。事实上,我以前也是一个宁可自己死磕,也不愿意求助他人的人,我也曾经认为这是一个很好的锻炼思维的过程。然而在 6 系大二一年的毒打中,我逐渐体会到一个道理:我们的时间是短暂和珍贵的,有很多问题自己思考一周,不如别人一句话点透其中的道理,只要对方能够把问题讲清楚,获得的收益不比自己探索更差,还大大提高了解决问题的效率,我认为实在是大有裨益。在写作的过程中,我也经常会引导同学们思考,不过不是以留白的形式,而是在短暂的思考之后继续讲解清楚。有些内容根本不是初学者能够理解的,过度思考毫无意义,浅尝辄止即可。

Q:使用这本书有什么注意事项吗?

A:禁止食用(x)其实没有什么特别的注意事项,你甚至可以当小说来读,轻松愉快就好。如果想亲自动手体验一下书中的电路和代码的话,我将书中的一些电路和代码打包放在了北航云盘里:https://bhpan.buaa.edu.cn/link/AACB2D43EFCB9C438EA2D9539686E34943
(文件夹名:cocheckmate ,有效期限:永久有效,提取码:COCM ),可以下载下来玩一玩。

Q:对于书中可能出现的问题,如何及时反馈?

A:如果你对书中的内容有疑问,包括但不限于讲述错误、没讲清楚、根本没讲等问题,还请及时发邮件到我的北航邮箱:blumaha@buaa.edu.cn ,我将在两天之内给出回复,如果两天没回可能是看完之后忘了,可以再给我发邮件。我将在下册的结尾予以致谢,感谢各位为本书做出的贡献!

Q:最后,有什么想对学弟学妹们说的?

A:同学们,计组实验是你们来到 6 系的第一大关,它看似非常可怕,但我愿与你们一同闯过层层难关,帮助你们取得最终的胜利。希望你们在学期末通过最后一次上机时,能够淡然地回望来时的路,像老朋友一样与这门课程挥手作别,勇敢自信地奔向下一个挑战!

目录

第一章:Logisim 与数字电路
1.1 初识 Logisim
1.2 输入、输出、门电路
1.3 调整位宽:从一位到多位
1.4 复杂的组合逻辑:数学运算与译码器
1.5 时序电路:从锁存器、触发器,到寄存器
1.6 寄存器后日谈
1.7 本章结语:再见,Logisim!

第二章:汇编语言 MIPS
2.1 汇编语言与 MIPS 简介
2.2 MARS 简介
2.3 MIPS 语法初步
2.4 MIPS 编程实战
2.5 MIPS 机器码
2.6 本章结语:再见,MIPS!

第三章:硬件语言 Verilog
3.1 硬件语言简介
3.2 ISE 简介
3.3 Verilog 语法初步:常量、变量与运算
3.4 Verilog 语法初步:组合逻辑与时序逻辑
3.5 Verilog 编程实战
3.6 有限状态机
3.7 本章结语:再见,Verilog!

第一章:Logisim 与数字电路

1.1 初识 Logisim

终于,你将要迈出计组实验的第一步了!不过不要害怕,这本教程会全程一直陪伴着你,帮你走完这一段艰难又收获满满的旅途!首先,相信你已经下载好了我们必要的软件 Logisim (如果还没有,请先参照官方指导书),作为三款软件中最为拟人的一部(大概吧),Logisim 的界面十分简洁,非常便于我们理解和学习。

【打开Logisim】
打开 Logisim ( jar 文件和 exe 文件均可,没有区别),我们可以看到如下界面:

Picture Not Found!

【创建、打开Logisim文件】
我们先不需要学习任务栏中全部的内容,毕竟我们的旅途才刚刚开始嘛!第一步,我们打开 File 任务栏,使用 New 创建一个新的文件,使用 Save / Save As 保存这个文件,别忘了将它放在一个比较正式的文件夹里,这里将会保存你整个学期所有的 Logisim 工程,当你在学期结束时回头看这个文件夹的时候,你一定会收获满满的成就感!

OK ,现在你可以退出 Logisim ,重进 Logisim ,再次打开 File 任务栏,使用 Open 打开你刚才创建的文件(其实在文件夹中直接双击这个文件也可以打开),你已经掌握了最基本的文件操作方法了!

【调整画面比例】
虽然这个时候说有点早,但是还是想提醒你一下:在界面的左下角有一个 100% ,这是当前视图的缩放比例,如果你真正开始上手操作,就会发现这个比例有点太小了,为了避免目害,你可以顺着这个 100% 往右找,看到旁边的上下箭头了吗?点击它们就可以调整视图的比例!不瞒你说,许多人到课程结束都没发现过这个按钮(悲)!

1.2 输入、输出、门电路

我们知道,大多数学校的计算机学院都有一门课程,名字叫作数字电路,这是学习计算机不可省略的一步。然而我们就恰恰没有这门课程,但学习 Logisim 的过程,其实就是在学习数字电路。

【数字电路】
那么,什么是数字电路呢?在我看来,与其说这是一种电学,不如说这是一种数学,是 0 和 1 组成的逻辑运算的具象化。相信各位在去年已经学习过了离散数学的数理逻辑部分,数字电路要做的其实就是将这些表达式以“电路”形式呈现出来,将它们可视化地运行。

如果你是 Minecraft 牢玩家,对红石系统有一定了解的话,你会发现其实红石系统就是比较简单的数字电路,拉杆就是输入,红石灯就是输出,红石火把则是常数 1 和非门,通过不同的组合,我们就可以打造出各种门电路。事实上,和仅供娱乐的游戏相比,Logisim 里提供了更加丰富的数字电路元件,不需要我们自己去用与或非门一点点拼出来,有着更多可能等待我们去探索!

【输入、输出、电路的运行】
接下来我们回到学习中,请关注模块中 Wiring 和 Gates 部分,它们就是我们本节课的主角。点击界面左上方的正方形按钮(或 Wiring 中的 Pin ),再点击工作区的任意位置,我们可以放下一个输入端;同样,点击界面左上方的圆形按钮,我们能够放置一个输出端。使用左键拖动,将输入端的右侧和输出端的左侧连接起来,我们就完成了数字电路中的 “Hello, World!” !点击左上角的小手,切换到调试模式,点击输入端切换其 0 和 1 的状态,感受输出端的变化吧!
(注:图中的“调试模式”和“编辑模式”并非官方翻译方法,本书中将保持这种翻译方法,如有官方翻译,以官方翻译为准)

Picture Not Found!

【Logisim中的快捷键】
如果我们需要解除两个元件之间的连接,只需要将鼠标放在二者连线的一端,沿着这段导线拖动即可。和我们熟悉的操作一样,使用左键在空白区域拖动框选之后,使用 ctrl + X 可以进行剪切操作,使用 ctrl + C 复制,使用 ctrl + V 粘贴,使用 crtl + D 复制并粘贴,使用 ctrl + A 全选,使用 crtl + Z 撤回。( Logisim 貌似不能取消撤回,请谨慎使用 ctrl + Z )另外还有更多的快捷键,可以将鼠标悬停在选项栏上查看。

【逻辑门】
相信读到这里的绝大多数同学都已经学过了离散数学,在 Logisim 中,我们可以将离散数学中学到的逻辑运算具象化。在 Gates 中有着丰富的逻辑门,使用它们可以进行逻辑运算,下面你可以尝试使用它们它们表示出一个逻辑表达式,例如下面这个:

$$ output = (\lnot A \lor B) \land (\lnot B \lor A) $$

这是互蕴含的表达式,你可以再放一个同或门,看看两者输出是否相同!

Picture Not Found!

【元件的属性设置】
相信你已经发现了,我们在摆放逻辑门时,默认摆放的是五输入的逻辑门,为了将其改为二输入,我们进入编辑模式,左键单击元件,即可在左侧对其进行设置。在下面这个网站可以查询所有元件的所有属性,在认识元件之前读一读它的介绍,或许能够掌握它们更多有趣的变化:
https://cburch.com/logisim/docs/2.6.0/en/libs/gates/basic.html
(美中不足的是,这个网站是全英文的,如果在阅读上有困难,可以在 Logisim 上自己修改属性试一下,看看会发生什么变化。我保证,这不会把你自己的 Logisim 玩坏的!)

Picture Not Found!

【奇校验、偶校验】
在 Gates 中有两种我们没有见过的元件,即 Odd Parity(奇校验)和 Even Parity(偶校验),尝试放置一个二输入的奇校验或偶校验,看看它们的输出如何?

或许你已经发现了,二输入的奇校验和异或门完全相同,二输入的偶校验和同或门完全相同。然而我们用十二指肠想,两者也不可能是完全相同的,奇偶校验和异同或门的差别其实体现在多输入上。

【多输入】
在最开始我们就发现了,逻辑门可以有不止两个输入(默认是五个),其实多输入表示的含义很简单,在大多数逻辑门中,多输入表示的都是多个输入进行连续运算,比如一个输入为 A B C D E(均为 1 位)的电路,通过一个默认五输入的与门,输出为 output ,则它们的关系为:

$$ output = A \land B \land C \land D \land E $$

然而在异同或门中,事情似乎发生了一些变化。试着创建一个五输入的异或门电路,观察一下输出如何?

我们惊奇地发现,异或门输出为 1 当且仅当:有且仅有一个输入为 1 ;同或门输出为 0 当且仅当:有且仅有一个输入为 1 。也就是当值为 1 的输入个数是一个大于 1 的奇数时(如 3、5、7 等),异同或门的输出和我们想象中的并不一致!这非常奇怪,完全不符合我们的直觉,然而底层代码是这样写的,我也没办法(摊手)。

输入为 1 的个数 异或门输出 同或门输出
0 0 1
1 1 0
2 0 1
3 0 1
4 0 1
5 0 1

然而,反转又来了,请你再试一下刚才提到了奇偶校验,用奇校验替换上述电路中的异或门,用偶校验替换上述电路中的同或门,看这次的输出如何?

有趣的是,奇偶校验的性质正和我们想象中的“异同或门”完全相同:奇校验输出为 1 当且仅当:有奇数个输入为 1 ;偶校验输出为 1 当且仅当:有偶数个输入为 1 。原来奇偶校验才是真·异同或门!

输入为 1 的个数 奇校验输出 偶校验输出
0 0 1
1 1 0
2 0 1
3 1 0
4 0 1
5 1 0

Picture Not Found!

1.3 调整位宽:从一位到多位

在刚才的学习中,我们介绍了门电路的用法,虽然引进了多输入,但我们的自变量(输入)却依然只有 0 和 1 两种可能,这在实际应用中显然是不够的。于是,我们开始拓宽输入的位宽,进行更加复杂的运算!

【位宽】
在输入的属性中,有一项是 Data Bits ,这就是设置位宽之处。我们先搭建一个最简单的二输入与门电路,紧接着调整其中一个输入的位宽(例如为 4 ),然后……出现错误了。橙色的错误代表着位宽不一致,顺着图中标橙的导线看去,我们发现原来是导线两端的位宽出现了不一致:输入是 4 位,而与门是 1 位。这说明逻辑门也是有位宽的!

Picture Not Found!

事实上,几乎所有的元件都有位宽,有些元件的输入端和输出端甚至有着不同的位宽(例如我们将要讲到的 Bit Extender ),所以在接下来的实验中,我们一定要时刻注意位宽问题。得益于 Logisim 没有隐式的位宽转换,只要位宽不同就会直接报错,我们只需要在报错时及时修改即可,然而之后要学习的 Verilog 语言会让你体会到位宽的背刺的(笑)。

在修改完一串位宽不一致的问题之后,我们的两个输入、与门和输出都已经变成了 4 位,现在我们可以进入调试模式看一下输入与输出之间有着怎样的关系了。

通过观察我们不难发现,对于上节中讲过的可以进行一位逻辑运算的逻辑门,多位运算其实就是将每一位拆开分别运算,最后按原来的位置合在一起,和 C 语言中的位运算 & | ^ 等是完全一致的。什么嘛,原来多位运算也没有那么难嘛~

【Splitter、位数的定义】
接下来,我们来继续认识一些新的元件。就先从 Wiring 这部分开始吧!

Splitter 是一个非常重量级的元件,将会撑起之后实验的半壁江山。见字知意,它的作用就是将一个多位宽的输入分割成多个任意位宽的输出(其实不止如此)。我们放置一个 Splitter ,它的属性参数如下:

Picture Not Found!

通过属性参数的组合,我们可以将任意位宽的输入切割成任意份任意位宽的输出,实在是太方便了!然而,我们千万千万千万千万要注意位数的对应!!!我们对位数的定义和 C 语言的位运算是一致的,对于一个 11 位的数据 abcdefghijk(其中所有的字母均代表 0 或 1),它的最高位是第 10 位a ,最低位是第 0 位k ,这一点请千万牢记,左高右低,最低位为第 0 位,我们在计组课程中提到的所有位数都是如此定义的!

Picture Not Found!

此外,虽然 Logisim绝大多数元件都不支持输入端和输出端倒置,但 Splitter 却是个确确实实的例外!这也就意味着……它既可以将一个大位宽的值分割成多个小位宽,还能将多个小位宽的值合并成一整个大位宽!据说还是有很多人没有发现这个巧妙的用法,看了我的教程,可不许再说你不会这么用了!(骄傲)

Picture Not Found!

(如果你仔细观察就可以发现,上图中有一个很奇葩的问题,Logisim 默认字体中的数字 3 长得和数字 2 几乎一模一样,这不可疑吗?)

【Wiring中的其它元件:Probe】
在一位的电路中,我们能够很容易看出电路中哪部分的值为 0 ,哪部分的值为 1 ,这得益于两种取值对应的导线颜色是不同的:如同人体的动静脉血一样,0 是暗绿色,而 1 是亮绿色。

Picture Not Found!

然而,在多位电路中,无论每位取值如何,导线都只有黑色一种颜色(没连错的情况下),那么我们应该如何知道此时此刻通过导线的数值呢?于是有了 Probe 。将它连接在导线上,甚至不需要设置位宽,即可以实时显示当前通过导线的数据,用于调试非常方便!

Picture Not Found!

【Wiring中的其它元件:Tunnel】
另一个为方便而诞生的工具是 Tunnel ,顾名思义,它承担着隧道的作用,名字相同的两个 Tunnel 之间在我们看不见的地方相互连接,无论相隔多远,不需要受到连线的限制!

Picture Not Found!

【Wiring中的其它元件:Constant】
接下来到场的是两种新的输入方式:Constant 和 Clock !我们在连接电路时,经常会遇到需要输入一个常量的情况,这种时候如果使用 Input 作为输入是不可行的:一方面,它的值是固定不住的,即使我们手动设置好了初始值,关闭文件再次打开之后又会归零;另一方面,它也会使元件的外观发生改变,会导致元件多出一个输入端。所以这时不动如山的 Constant 登场了,和 Input 的用法完全相同,它的值永远固定,除非你手动更改它!

【Wiring中的其它元件:Clock】
如果你在界面上放下一个 Clock ,你会发现它和一位的 Input 在使用上没有任何区别,事实上确实如此。但是请看左上角选项栏中的 Simulation 一项,这里隐藏着 Clock 的全部特殊用法。

在 Simulation 中选择 Ticks Enabled ,你会发现 Clock 开始自己动起来了!是的,现在的 Clock 正以一秒钟为单位不断翻转着自己的输出,如同摆钟一样。在 Tick Frequency 中,我们可以调节 Clock 翻转的周期,让它翻转得更快一些吧!

(PS: 不要碰上面那个 Simulation Enabled,有很多人以为自己的 Logisim 卡死了,实际上是不小心把这个开关关掉了(笑))

事实上,我们一般不拿 Clock 当作数据的输入端,它的存在是服务于时序逻辑电路中的使能端,当然这是很后面的事情了,我们暂时只知道有这么个东西就可以了,千万不要忘记它哦~

【Wiring中的其它元件:Bit Extender】
当你需要改变一个数据的位宽时,目前你肯定会想到用 Splitter 进行拼接,但是这样会产生非常多的常量(Constant),这既不方便也不优雅,于是我们又有了 Bit Extender ,它可以非常方便地拓展位宽,多种模式,任君选择!

在属性参数中,我们可以设置 Extension Type ,即扩展模式,如果选择 Zero 或 One ,则会将高于输入位数的部分全部置 0 或置 1 ;如果选择 Sign ,则会根据输入的最高位决定高位置 0 还是置 1 ;如果选择 Input ,Bit Extender 的上方会出现一个接口,在这里连入一个一位的输入,由它来决定高位置 0 还是置 1 ,灵活性非常强!

Picture Not Found!

1.4 复杂的组合逻辑:数学运算与译码器

其实到这里,我们已经学习了相当多的知识,然而当我告诉你,接下来的几节才是 Logisim 这部分的重量级选手,你会怎么想?

【Arithmetic中的基础元件】
Logisim 非常好心地为我们内置了四则运算的模块,这样我们就不需要自己写全加器和乘法器了!(不过你早晚会在作业的练习题中见到它们的)只需要打开 Arithmetic 文件夹,你就可以轻松使用它们!

Picture Not Found!

值得一提的是,每个模块下面都额外带有一个输出,这些输出意义各不相同,但每个都十分重要(其实上面也有,只是完全没太大用)。

加减法模块下方的输出为溢出检测,当计算发生溢出时,模块本身不会报错,只是会单纯地丢掉溢出的最高位,你也可以理解为溢出的最高位被扔到了下面的输出,事实上,只要用 Splitter 将二者接到一起,你就可以得到一个完整的结果。

为了使输入输出位宽相同,乘法的结果被分为了两部分,较低的一半被分配至正常的输出端,而较高的一半则由下方的额外输出承担;同样,除法的商位于正常输出端,余数位于下方额外输出端。这种设计会在 MIPS 语言中再次出现,回过头来看真的是好经典啊~

【Arithmetic: Negator】
或许你以为 Negator 只是一个简单的取反器,如同 C 语言的 ~ 符号一样将每位取反,你就真的是图样图森破了!实际上,Negator 的运行原理是这样的:

Picture Not Found!

是的,它有一种很奇怪的运行方式:首先找到位于最低位的 1 ,然后将比这个 1 更高位的所有位数取反。这样设计是为什么呢?说实话我也不太清楚。

【Arithmetic: Comparator】
Comparator 真的很纯粹,输入两个值,然后返回一位的比较结果,就这么简单。实际上你甚至不需要在右端连上三个输出,只取一个你想要的即可。

Picture Not Found!

然而醒醒,这不是数学,这是数字电路!二进制让比较变得不纯粹了,在二进制的世界里,正数和负数的界限不再分明,用人话来说,就是你需要确定你所输入的数据究竟是不是有符号的,这在以后所有的比较大小中都尤为重要!!!Logisim 中的 Comparator 默认是有符号比较,也就是会出现下面这种情况:

Picture Not Found!

根据补码的原理,这里的全 1 其实代表的是 -1 ,所以上面大于下面也是显然了。如果想无符号比较怎么办?点一下这个模块,在左边调一下属性参数就好了。

【Arithmetic: Shifter】
Shifter 其实就是移位器,道理很简单,和 C 语言的 << 符号和 >> 符号几乎完全相同,不过因为移位的过程中必然会产生空缺的位置,所以还是要注意置 0 还是置 1 的问题。实际上左移是没什么问题的,在左移的过程中,高位被挤掉,低位补充上 0 即可,然而右移就要分两种情况讨论了:一种情况下,数据右移后低位被挤掉,空缺出的高位上正常补 0 ,这就是逻辑右移(Logical Right);另一种情况下,数据右移后低位被挤掉,空缺出的高位上则要根据原数据的最高位决定补 0 还是补 1 ,这就是算术右移(Arithmetic Right)。真够复杂,不是吗?以后有的是让你抓狂的。

Picture Not Found!

其实在属性参数列表中,你还能看到两种位移:循环左移(Rotate Left)和循环右移(Rotate Right),它们没什么坑点,但也不常用,无非就是原本被挤掉的位数,会从另一端再回来罢了,所谓循环。

【Arithmetic: Bit Adder】
一个会让你觉得“怎么还会有这种元件”的元件,存在感实在很低,考试的时候有很多人都想不起来用它。它的作用就是统计输入端每位上总共有多少个 1 ,没有任何注意事项,真是滑稽。

Picture Not Found!

【Arithmetic: Bit Finder】
Bit Finder 的作用是寻找,至于寻找什么,那要你说了算。它提供四种属性参数,分别是找到 最高位 / 最低位 的 0 / 1 ,并输出它们所在的位数。

Picture Not Found!

发现这其中 bug 了吗?如果你发现了,说明你的神经开始敏锐起来了!如果输入中根本没有我要找的 0 或 1 ,应该输出什么呢?好像无论输出什么都会和某些结果重合,于是它的下方出现了额外的一位输出,表示是否找到了这样的 0 或 1 。

顺带一提,如果没找到的话,右侧的输出固定为 0 。

到此为止,我们已经讲解完 Arithmetic 中所有的元件了!所有的元件在我们的实验中都有用处,真的是一件很不容易的事情。有了 Arithmetic 中的工具,我们就可以进行一些复杂的运算了。记住它们,用好它们,才能在以后的题目中找到更简单的解法!

一刻也没有为 Arithmetic 结束感到悲哀,接下来到场的是:

【Plexers: Multiplexer】
大名鼎鼎的 MUX 本尊,学名 多路选择器 。说起来其实很简单,但是总是把人搞得晕头转向。左侧输入多个数据,并从上往下从 0 开始编号,下侧输入一个编号进行选择,右侧仅输出下侧编号所对应的那个数据。有点像从数组中取一个数据,不是吗?

Picture Not Found!

需要注意的是,MUX 的左侧输入位宽与右侧输入位宽有直接关系(相等),下侧输入位宽与左侧输入个数有直接关系(2的幂次),而前两者和后两者在位宽大小上没有任何关系。

此外,你也许注意到下侧还有另外一个接口,那个是 MUX 的使能端,然而如果不接任何导线(称为悬空态)的话,默认为开启使能,一般我们都不会考虑关闭 MUX ,所以这个接口也是常年空接,你也可以通过调整属性参数直接把这个碍眼的接口消灭掉。

这个元件在以后我们会经常用到,因为它几乎是实现分支判断的不二之选,当它的左侧输入端只有两个数据时,下侧输入端就只有 0 和 1 两种选择,配合万能的 Comparator ,就可以实现 if 一样的效果(当然也可以说更像三目运算符 ? : )。

【Plexers: Demultiplexer】
和 MUX 正好相反,DMX 是一输入多输出,看起来几乎就是镜像的 MUX ,以至于我都懒得在这张图片上 P 图做注解。

Picture Not Found!

然而,这种时候大概率会有个转折,警惕性告诉我们,当输出多于输入的时候我们一定要想一想,多出来的输出是哪来的,竟然是凭空变出来的!也就是说,除了被选中的输出来自输入,其它的输出 0 不来自任何输入,是我们人为规定出来的,这也就导致了它们可能会对我们的电路造成干扰,这在我们搭建 CPU 的 GRF (寄存器堆)部分时会产生致命错误,在每个周期都将其余的所有寄存器覆盖为 0 ,这时候我们需要调整 DMX 的属性参数,将 Three-state? 参数改为 Yes 即可,此时其它输出默认为 x ,不会对后续产生影响。

现在讲这么多感觉还是有点多了,感觉作为初学者你可能也听不太懂,不过请记住这个位置,当你真的在搭 CPU 时翻车了,不要忘了回到这里看看,那时你会有更多的收获!

【Plexers: Decorder】
这位更是声名远扬的 译码器 本尊,计组理论常客,每年期末必考一道大题的东西。作用是将下方的输入转换为一位的“独热码”(One-Hot Encoding),有点像输入端固定为一位 1 的 DMX 。

下图所示的就是我们非常常见的 3-8 译码器,它实现了三位二进制码向八位独热码的转化。Logisim 貌似只支持单向转化,而在理论课中,译码器是可以双向互相转化的,能够实现极为复杂的效果,对于我这种理论不太好的选手还是挺难的。

Picture Not Found!

【Plexers: Priority Encoder】
优先编码器,一种另类的 MUX ,它的特点就是所有输入不再“平等”,而是根据自己的位置有着“优先级”,位置越靠下(下标越大)优先级越大,当多个输入同时亮起时,右侧只输出最靠下的输入的位置。

Picture Not Found!

其实并没有什么存在感,本来不打算讲,结果突然想起以前貌似考过,于是小谈一下。问题在于它并非是不可替代的,有一种很简单的结构也可以快速实现这个功能,是什么呢?

答案是 Splitter 将所有位合在一起后再 Bit Finder ,你猜对了吗

【Plexers: Bit Selector】

Bit Selector 的作用是根据输出位宽将左侧输入分成等宽的部分,再以下侧输入为序号,从低位向高位输出对应的字段输出,实际上相当于 Splitter 加上 MUX 进行分选。

Picture Not Found!

我猜到你一定会问,如右图所示的情况有可能会取到左侧输入的外面,这个时候应该怎么办?事实上,和以往既有高位补 0 又有高位补 1 的情况不同,Bit Selector 的策略是高位一律补 0 ,没有其他选择。

到此为止,我们的本节课也该告一段落了。这节课的主要内容就是认识新的元件,这些元件看似简单,实则已经非常完备,配合我们下节将会讲到的寄存器,已经可以实现极为复杂的系统,甚至是 CPU 。比起理解更加不容易的是学会运用,在以后的 Logisim 上机考试中,我们是否能够根据需求反推出需要哪些元件,这也是需要修炼才能取得的成果。加油,我相信你可以的!

1.5 时序电路:从锁存器、触发器,到寄存器

目前为止,我们学习的所有内容都是“组合逻辑”,也就是将输入和元件进行组合,通过一系列运算得出输出。然而,我们虽然能够进行比较复杂的计算,但是还缺少了非常重要的一环,那就是存储。现在,我们要让我们的电路“动起来”,通过一步一步的构造,逐渐实现最基本的存储元器件 —— 寄存器。

【SR锁存器】

接下来,我将要向你介绍一个颠覆你之前所有认知,为你打开新世界大门的神奇电路:SR 锁存器(SR Latch)。

请像下图一样,在 Logisim 中连出这个电路:

Picture Not Found!

图中出现了红色,它代表的是输出出现了冲突,无论输出 0 还是 1 都有可能。这种报错也经常出现在将两个输入不经过任何元件,同时直接连接到同一个输出上,当两个输入的数值不同的时候(比如一个是 0 ,一个是 1 ),输出就会变为红色。Minecraft 玩家应该格外注意这一点,如果想实现和游戏里一样的效果,必须使用或门。

Picture Not Found!

回到正题,其实这个电路并没有错误,你可以试着改变一下两个输入的值,你就会发现红色的报错已经消失了!这是为什么呢?或者说,你真的能看懂这个电路吗?尝试一下吧!

Picture Not Found!

如上图所示,上面的输出由两个部分经过或非门(或门后面接非门)构成,其中一个部分是上面的输入,另一个部分是下面的输出;同时下面的输出又由下面的输入和上面的输出经过或非门构成。

于是就形成了一个奇怪的现象:上面的输出控制着下面的输出,而下面的输出又控制着上面的输出,似乎形成了一个“衔尾蛇”,这也是电路最开始出现红色报错的原因。然而,为什么经过赋值之后,电路就能够成功跑起来了呢?我觉得用齿轮来比喻更加合适:我们小学二年级就学过,如果一个循环的齿轮组有偶数个齿轮,那么它就可以旋转,而如果有奇数个齿轮就会卡住,向任何一个方向都无法旋转。这个电路也是一样,只要形成巧妙的平衡,确保不出现“自相矛盾”的情况,就可以自由活动了!

比如观察输入为上 1 下 0 的情况,此时无论另一端输入如何,上面的或门输出一定为 1 ,于是上面的或非门输出为 0 ,上面的输出为 0 ,于是下方或门的两个输入为来自上方输出的 0 和来自下方输入的 0 ,所以下面的或门输出为 0 ,下面的或非门输出为 1 ,下方的输出为 1 。这就形成了一种固定的可能,而不会出现红色的报错!当然,由于这个电路是对称的,所以上 0 下 1 的情况也是同理。

Picture Not Found!

接着,我们再观察输入为上 0 下 0 的情况,此时你的输出是上 0 下 1 还是上 1 下 0 ?如果你是在和你的好盆友一起边做实验遍看这本教程,当你们得出完全不同的输出时,不要着急开始一场大战,因为两种情况都是正确的!这实际上取决于你们是如何操作的。这不可疑吗?

Picture Not Found!

Picture Not Found!

如果你像刚才那样仔细分析两种输出,就会发现这两种情况都是成立的,区别实际上在于你是由上 0 下 1 的输入关闭了下面的 1 ,还是由上 1 下 0 的输入关闭了上面的 1 得到的上 0 下 0 的输入。用这两种情况再试一下?这就是锁存器的魅力时刻。

于是,经过一番长久的研究,设上下两个输入分别为 S 和 R ,上下两个输出分别为 Q 和 Q’ ,我们就可以用真值表总结出 SR 锁存器的所有情况:

S R Q Q’
0 0 和上一状态相同 和上一状态相同
0 1 1 0
1 0 0 1

看到这里,你一定会有疑问,为什么至今为止,我们好像都在刻意回避 S = 1 且 R = 1 这种情况。如果你真的去动手做了这个实验,你就会发现这种情况完全成立,两个输出都为 0 ,而且也不会出现因上一状态不同而输出不同结果的方法。

但是我们接下来就会知道,SR 锁存器的诞生是有它的使命的,创造它的人希望两个输出 Q 和 Q’ 永远输出两个相反的值(从它们的名字就可以看出来),于是输入全为 1 这种“不合群”的情况就被残忍地判决为了“非法”的情况,事实上,它完全不会导致你的电路出现任何 bug 。(悲)

【D锁存器】

D 锁存器(D Latch)其实只是 SR 锁存器的一种特殊形式,不过它才是我们接下来故事的主角。将 SR 锁存器左侧的两个输入合并为一个,并在下面的一端加上一个非门,就形成了 D 锁存器,实际上也就是只有上 0 下 1 和上 1 下 0 两种输入的 SR 锁存器:

Picture Not Found!

D 锁存器的逻辑非常简单,设输入端为 D ,上下两个输出依然为 Q 和 Q’ ,则 D 锁存器的真值表如下:

D Q Q’
0 1 0
1 0 1

【带使能的SR锁存器和D锁存器】

现在,我们在 SR 锁存器和 D 锁存器的输入端都加上一个使能端:

Picture Not Found!

其实非常好理解,当使能端为 1 时,带使能端的锁存器与不带使能端的锁存器相比,二者没有任何区别;而当使能端为 0 时,锁存器的输入被强制固定为两个 0 ,此时根据我们之前的分析,输出端会保持不变,无论输入端如何变化。( D 锁存器也是同样,别忘了它其实就是一种 SR 锁存器!)

看起来还是很好理解?记住这个带使能端的D锁存器(Gated D Latch),接下来我们要起飞了!

【模块化】

等一下,我们现在还不能起飞!工欲善其事,必先利其器。在此之前,我们还需要了解一下 Logisim 的新用法!

在接下来的实验中,我们的电路中经常会出现大量重复的部分,又或者是只需要暴露接口的集成度较高的部分。和写代码的时候需要定义函数的道理完全相同,我们在 Logisim 中也需要这样的“函数”,将一部分电路集成到一个模块中,调用时只需要提供输入,并获取输出就可以了。

在界面的右上角,有一个绿色的加号,点击它我们就可以创建一个新的模块。双击列表中的新模块,我们就可以切换到新模块的编辑模式。在图中,我们创建了 Gated D Latch 模块,在其中画了一个带使能端的 D 触发器。

Picture Not Found!

在编辑完新模块之后,我们就可以进入左上角的模块外观编辑,编辑并检查当前模块的外观。这里就像一个画图软件一样,你可以用各种奇葩的图形随意更改这个模块的外观,方形、梯形、三角形,甚至圆形,这里可以随心所欲!

此外,输入和输出的位置都是可以移动的,建议把它们之间的距离调大一点,这样在使用的时候布线会更加舒服。在调整之前别忘了左键单击一下它们,在右下角看看它们对应的是不是你想象的那个接口!

如果输入和输出比较多的话,我们还可以使用左上角的工具在模块上添加文字,妈妈再也不用担心我找不到对应的的接口了!

Picture Not Found!

现在,我们双击 main 模块,回到 main 模块中,然后单击 Gated D Latch 模块,就可以使用我们刚才创建的模块了!现在我们可以准备起飞了!

【D触发器】

现在,我们搭建这样一个电路:

Picture Not Found!

这就是 D 触发器(D Flip-Flop),由两个 D 锁存器组成,两个使能端的值相反。或许要肉眼看出它的作用还是有点太难了,不过你可以跟着下面的操作去做,在我的引导下逐渐发现它的作用。

现在,让我们保持使能端为 0 ,改变输入 D 的值,观察电路的输出有变化吗?(并没有)

然后,让我们保持使能端为 1 ,改变输入 D 的值,观察电路的输出有变化吗?(还是没有,真让人疑惑)

接下来,按以下步骤进行操作:

  1. 将输入 D 置为 1 ;
  2. 将使能端置为 1 ;(输出变为了 1 !)
  3. 将输入 D 置为 0 ;
  4. 将使能端置为 0 ;(输出并没有发生变化)
  5. 将使能端置为 1 ;(输出变为了 0 !)

到这里,你能发现 D 触发器的作用了吗?实际上,当且仅当使能端由 0 变为 1 的一瞬间,输入端 D 的值才会被输出!我们为这个瞬间起一个名字,就叫作“上升沿”;反之,使能端由 1 变为 0 的一瞬间,就叫作“下降沿”。

然而,求知欲超强的你一定会问了,这是为什么呢?且听我细细为你分析:

在上一节我们讲过,当 D 锁存器的使能端关闭时,它便保持当前的输出不变。在这个电路里,我们根据使能端的值进行分析:

当使能端为 0 时,前一个 D 锁存器的使能开启,将输入端的数据输出至两个锁存器之间;然而后一个 D 锁存器处于关闭状态,不能将数据输出至输出端,于是当前的数据就滞留在了两个锁存器之间。

当使能端为 1 时,前一个 D 锁存器的使能关闭,不再接收输入端的数据,而是保持之前的输出(即滞留在两个锁存器之间的输出)不变;此时后一个 D 锁存器的使能开启,将滞留在两个锁存器之间的值输出出来。

需要注意的是,使能端为 1 时输出的并不是当前输入端的值,而是在切换使能时滞留在两个锁存器之间的值,也就是切换使能时的一瞬间输入端的值,这从上升沿之后无论如何改变输入端的值,输出端的值都不再变化可以看出。

在左侧的 Memory 文件夹中,有 Logisim 内置的 D 触发器(D Flip-Flop)模块,你可以用它试一试,看看和我们自己做的是不是一样~

Picture Not Found!

(注:如果你觉得不一样的话,那你一定是把上下搞反了,Logisim 的 D 触发器,上面的三角才是使能端( Logisim 所有的元件标三角的地方都是用来接 Clock 的使能端),下面的 D 才是输入端,现在知道模块外观的重要性了吧(笑))

刚才我们提到了 Clock ,现在你可以试着用 Clock 替换使能端的输入,接在左上角的三角处。现在你可以用 Simulate 中的 Ticks Enabled 来让使能端自动翻转了!

最后,我们来练习写一下 D 触发器的逻辑表达式。我们接下来还会遇到许多触发器,使用他们的逻辑表达式(这里称为特性方程)可以更直观地理解它们的作用。

D 触发器的特性方程是:

$$ Q_{n+1} = D $$

这里的 n+1 是什么意思呢?事实上,这是表示时序的一种记法,n+1 表示的是下一个周期。在其它触发器中,我们会遇到当前周期的输出会重新参与进运算,影响下个周期输出的情况,这时候为了区分两个周期的输出,我们用 n 表示当前周期,而用 n+1 表示下一个周期。

【T触发器】

除了 D 触发器之外,Logisim 中还有三种触发器,这些触发器虽然在实验中不会遇到(其实 D 触发器也不会),但是却是理论考试的常客,所以还是有必要介绍一下。

T 触发器(T Flip-Flop)相信 Minecraft 牢玩家都有所耳闻。在游戏中,使用发射器装细雪桶,再配合红石比较器就可以形成一个简单的 T 触发器,可以把按钮变成拉杆。用我们课程的语言来说,就是在 Clock 的每个上升沿都翻转一次输出,现实中的 T 触发器也正是这样。和 D 触发器不相同的是,当 Clock 处于上升沿的时候,输出端不再输出上升沿时输入端的值,而是当上升沿时输入端为 1 时,翻转输出端的值。所以,你能写出它的特性方程了吗?(答案如下:)

$$ Q_{n+1} = Q_n ^{\space\space\space} T $$

(这里的尖角表示异或)

使用与或非的形式,也可以写成:

$$ Q_{n+1} = T\overline{Q_n} + \overline{T}Q_n $$

(在数字电路的表达式中,用加号代替或,用乘号(或者直接省略)代替与,用上划线代替非,这种写法在电子电路领域比较常见)

【JK触发器、SR触发器】

JK 触发器(J-K Flip-Flop)和 T 触发器很相似,只不过输入端变成了 J 和 K 两个。当 J 和 K 的值相同时,它和 T 触发器别无二致;而当 J 和 K 的值相反时,输出端的值将会被固定为 J 的值。它的特性方程如下:

$$ Q_{n+1} = J\overline{Q_n} + \overline{K}Q_n $$

SR 触发器(S-R Flip-Flop)又和 JK 触发器很相似,在 S 和 R 的值不全为 1 时,它的表现和 JK 触发器完全相同;而当二者的值全为 1 时,它维持原输出不变,和 SR 锁存器一样,这种情况实际上是“非法”的。它的特性方程是这样的:

$$ Q_{n+1} = S + \overline{R}Q_n $$

(其中 S 和 R 不能同时为 1 )

Picture Not Found!

【寄存器】

在从最基本的或非门一步步构建出各种触发器之后,我们终于要实现最开始的目标了,那就是对输出的存储。实际上,我们已经完成了这个工作,D 触发器其实就是一个非常良好的存储元件。它的输出只有在每个上升沿才会改变一次,在这一个周期内,这个值被完整地保存下来了。而且这个被保存下来的值也可以像在其它触发器中一样,用于计算下个周期输出的值,这一点我们将会在接下来状态机的学习中了解到。

唯一美中不足的是,D 触发器只能保存一位的值,这显然不太够用,于是有了寄存器(Register)。它其实就是一个可以支持多位的 D 触发器,用法和 D 触发器基本是相同的。

Picture Not Found!

这里我们有两个需要注意的点:第一个点就是寄存器显示的数字为 16 进制,第二个点就是下方的复位信号。当我们将复位信号置 1 时,寄存器的值立马被清零,这种复位方法被称为异步复位,这里的异步指的是复位与 Clock 上升沿异步,不要记混了哦。

相对地,同步复位指的是只有当 Clock 上升沿的时候才检测复位信号是否置 1 ,如果为 1 ,则在该上升沿将寄存器清零。

至此,我们一步一步构建出了 Logisim 中最基本的存储元件,在下一节中,我们将会介绍由它诞生的壮观世界。

1.6 寄存器后日谈

寄存器可以说是 Logisim 中最基础的存储元件了,就像编程中的变量一样。有了它,我们的电路才能够成为跑起来的“程序”,而不是一个语句,一个算式。作为构建起时序电路的基石,有了它,我们的 Logisim 世界又将发生什么变化呢?

【其它存储元件:RAM 和 ROM】

寄存器就像程序中的变量,它小巧而方便,可以随时取用。然而,如果想存取极其大量的数据(比如从文件中读写),寄存器的位宽可能就不太够用了,于是我们就需要更大的“寄存器”:RAM 和 ROM 。

我们先讲解更简单的 ROM 。ROM 是只读的存储器,也就是说,在程序运行的过程中,我们不可以对这个存储器进行存入的操作,只能对里面的数据进行读取。如果想写 ROM 中的内容,则需要在程序运行前就做好。

Picture Not Found!

首先我们学习如何向 ROM 导入数据。右键 ROM ,选择 Edit Contents ,我们就可以进入 ROM 的编辑页面,看到 ROM 中的全部数据。左键点击其中任意一个数据,我们就可以用 16 进制编写选中的数据。当然,当需要输入数据的量特别大时,我们肯定不能用手一个一个输入进去,这时就需要使用文件进行导入。

Picture Not Found!

使用文件导入也非常简单,只需要使用最简单的 txt 文件即可。首先,我们需要在文件的第一行写上一句魔法咒语:v2.0 raw ,然后用 16 进制依次写下每个数据就可以了!下面展示了上图中数据的源文件其中的一小段内容:(注:这段内容是有意义的哦,相信以后你一定能够读懂!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
v2.0 raw
341c0000
341d0000
34013456
00210820
8c010004
ac010004
3c027878
00411822
3c051234
34040005
00000000
ac85ffff
......

(这个省略号不是源文件中的内容,它真的就表示省略下文)

点击编辑页面下方的 Open(不是左上角 File 里的那个!),就可以选择数据文件进行导入。导入完成之后直接退出即可,不需要点编辑页面下方的 Save(那个是将当前 ROM 中的数据导出为 txt 文件)。不知道是不是 bug ,在编辑完 ROM 之后需要将工程保存并重新打开,刚才编辑的内容才能显示出来。

ROM 的使用也非常简单,它的左侧有一个输入,表示的是“地址”,注意这里的“地址”和我们以后将要学到并经常使用的内存地址并不是一个意思,这个“地址”你可以完全理解为“数组下标”,表示的就是 ROM 中的第多少个数据(从 0 开始计数,这很计算机)。而右面输出的就是相应位置对应的数据。

RAM 有关读取的部分和 ROM 是相同的,但它多了写入的部分,也就意味着在程序运行的同时,我们可以修改 RAM 中的值。

在使用之前,我们需要左键 RAM ,将 Data Interface 属性参数手动设置为 Seperate load and store ports ,这是我们课程的统一要求,这样输入的数据和输出的数据就分开了,左侧的 D 处用于向 RAM 输入数据,右侧的 D 处用于从 RAM 输出数据。

Picture Not Found!

相比 ROM ,我们可以发现 RAM 多了三个部分,接下来我将一一讲解:

首先是 Clock ,和寄存器一样,只有当 Clock 处于上升沿时,RAM 才会从输入端读取数据。但是一定要注意,这个 Clock 是不控制输出端的,也就是说只要左侧地址发生变化,右侧的输出会直接发生变化。其实这在寄存器、ROM 那里也都是一样的,回忆一下,如果我们不在 Clock 上升沿就手动改变它们的数据(或者 ROM 的地址),它们的输出是不是也会立刻发生变化?答案是肯定的。

此外就是 str 接口连接的一位信号,它是输入端的使能,也叫作“写使能”,它相当于是数据写入的开关,毕竟我们不需要每一周期都向 RAM 写入数据。只有当它置 1 时,RAM 才会在 Clock 上升沿读取输入端的数据,否则直接“一票否决”写入的权利。

最后就是左侧的 Data 了,没什么好说的,单纯是要写入 RAM 中的数据而已。顺带一提,虽然你肯定已经知道了,左侧输入端向 RAM 输入数据的位置就是左侧“地址”的位置。

另外,向 ROM 导入的数据在关闭文件重新开启的时候是不会清空的,其它所有存储元件(寄存器、RAM 等)中的值都不会被保存在文件中,所以不要手动向寄存器赋初值,否则你会体验到在本地运行时全对,在评测机上全错的快感。

那么,我们要如何在程序开始时向寄存器自动赋初值呢?这时候就需要用到一个非常神奇的结构了:

Picture Not Found!

为什么这个结构能够为寄存器自动赋初始值呢?我们来观察它的构造:

在 Logisim 中,寄存器的默认初始值为 0 ,于是当寄存器中的初始值与我们规定的初始值(图中为 0x0f )异或时,就会输出我们规定的初始值作为结果。

但当我们在输入端输入数据时,寄存器会将我们输入的数据与初始值异或的结果存入其中;同时在另一侧输出时,寄存器中的结果会再次与初始值异或。我们都知道,一个数字异或另一个数字两次,相当于没有异或,也就是 (A ^ B ^ B) == A 。所以,异或了初始值两次的输入值从输出端被输出,相当于没有变化。真的是非常巧妙的结构!

不过这个结构还是有点小缺点的,就是寄存器中的值不再等于我们真正要存储的值了,而是这个值和初始值异或一次的结果,所以我们要特别注意一下,在调试的时候不要以为自己写出 bug 了(笑)!

【状态机】

发明状态机的人绝对是个人才,如同左脚踩右脚上天,一个简单的结构就可能演化出无限可能。

在我看来,状态机最特别之处就是它的输出经过一些运算,会反过来成为下一个周期输入的一部分,这样就能够无限地递推下去。

下面是一个最简单的状态机,它甚至不需要输入,只需要自给自足就可以有无限的输出:

Picture Not Found!

(这个图画得比较丑陋,是因为 ? 处的输入在左边,输出在右边,这样更方便理解。你当然可以在自定义模块外观时把它们反过来,那样就会很好看)

当然,你肯定会问 ? 处模块的内容是什么,事实上,这个模块可以是任何内容,正是这个模块赋予了状态机无限的可能。
比如,你想让寄存器中的数字每个周期都增加 1 ,那么你只需要在 ? 处编写一个逻辑,实现 output = input + 1 即可。

这只是最简单的状态机,可以说是状态机的冰山一角了。比如,我们更常见的是有输入的状态机,它与当前周期的输出一同进行运算,得出下一个周期的输入:

Picture Not Found!

Picture Not Found!

上面给出了两种有输入的状态机,它们之间有什么区别?输出的位置是不同的,上面一幅的输出紧跟寄存器,位于 ?(称之为“状态转移”)之前;下面一幅的输出则位于状态转移之后。前者被称为 Moore 型状态机,后者则被称为 Mealy 型状态机。

我最开始学习状态机的时候,完全分不清这两者有什么区别,一是因为指导书的定义太过正式(并不是本书的定义方法,甚至图也不太一样),二是因为真正做题的时候碰到的状态机都奇形怪状,长得一点也不像上面的“标准”状态机。

后来我才发现,其实它们的区别真的非常非常明显。Moore 型状态机的输出在状态转移之前,所以无论输入端是什么,运算后的结果都只能在下个周期 Clock 的上升沿写入寄存器之后才能被输出了,这就是指导书中说的“ Moore 型状态机输出与输入无关”;而 Mealy 型状态机的输出在状态转移之后,所以它的输出会实时更新当前输入参加运算后的结果,而不需要等到下个周期才能给出了,这就是指导书中说的“ Mealy 型状态机输出与输入有关”。

读懂这段话,你也就能明白所谓的“ Mealy 型状态机比 Moore 型状态机早一个周期”是什么意思了。实际上这句话并不严谨,只有当且仅当 Clock 上升沿时改变输入端的数据才是如此,更正常的说法是:输入端输入本周期的数据,Mealy 型状态机的输出端立刻出结果,Moore 型状态机的输出端要到下一个 Clock 上升沿才出结果。

关于状态机的内容,我们暂时先讲到这里。虽然目前为止我们了解得看似不多,但已经足够衍生出巨量的可能了,快去结合实际问题试试吧,你会看到更多奇形怪状的状态机的!(笑)

1.7 本章结语:再见,Logisim!

经过一段漫长的学习,我们对 Logisim 的学习也要告一段落了!能坚持看到这里的你也非常不容易!在这漫长的一章中,我们学习了 Logisim 这款软件,了解了数字电路的基本知识,认识了许多数字电路元件,更是接触到了时序电路、状态机这些复杂高深的知识。虽然我们即将暂时告别 Logisim ,但在 CPU 搭建时,我们会再见的!希望那时的你已经学到了更多计组知识,并将它们与这一章的内容融会贯通,产生更深刻的了解,将 CPU 轻松拿下!

第二章:汇编语言 MIPS

2.1 汇编语言与 MIPS 简介

或许你在过去的学习中已经听说过了汇编语言,在这里,我们还是对它进行一个正式的介绍吧。在学习 C 语言的时候,我们也许会有这样一个问题:对于一台计算机来说,它只能理解 0 和 1 ,那么它是如何能够理解并执行我们编写出来的代码的呢?或者说,我们的代码是如何被一步步转化成 0 和 1 的呢?其中最关键的一步就是将代码“编译”为我们接下来要学习的“汇编语言”。

汇编语言也是一种编程语言,但是相比 C 语言来说,它是一种更“低级”的编程语言。这也就意味着,它能够实现的操作种类更少,相比更高级的语言代码量往往更大,但也更加接近计算机的底层架构。

接下来,我们就来认识一下我们将要学习的汇编语言 —— MIPS 。作为一门古老的汇编语言,MIPS 能够实现运算、对内存进行访存、跳转、条件赋值、条件跳转等多种操作,同时固定 32 位的指令也可以让我们在后续设计 CPU 时能够更加方便。是我喜欢的语言,直接开学!

2.2 MARS 简介

工欲善其事,必先利其器。类比 C 语言,它只是一种编程语言,要想让它能够跑起来,还需要一款合适的 IDE ,比如大家最喜欢的 devcpp 和 vscode 等等。所以,MIPS 语言也需要一款能够真正让它跑起来的 IDE ,那就是 MARS 。课程组已经亲切地为我们提供了这款软件,我们下载后打开即可。在之后,为了方便大家完成后续的实验任务,还会有各路巨佬的魔改版将会流出,大家可以多多关注群聊~

【创建、打开MIPS文件】

在一众软件中,MARS 的界面可以说是最简洁的了。我们进入后,点击左上角的 File ,使用 New 来创建一个新文件,使用 Open 来打开一个新文件,结束编辑后,使用 Save 或者 Save as 保存即可。

Picture Not Found!

【MARS设置】

在正式开始学习之前,我们需要在左上角的 Settings 一栏更改一些设置,将其调整至符合我们课程要求的形状:

Picture Not Found!

首先点击 Editor ,在 Font Family 处调整字体,在 Font Size 处调整文字大小。我个人喜欢用 22 号的 Consolas 字体,这里可以按照个人喜好设置,其它的设置保持默认即可。

Picture Not Found!

接下来点击 Memory Configuration ,将 Configuration 设置为 Compact, Data at Address 0 。这一步至关重要,如果上机考试忘记调这个,那就等着对着怎么也跑不对的代码干瞪眼吧!(笑)

Picture Not Found!

在 Settings 一栏中还有一个选项是 Delayed branching ,即”延迟槽”,目前我们还不需要知道延迟槽是什么,请务必将其保持关闭,等到我们正式开始写 CPU 了之后才需要开启它。

【调试、运行MIPS代码】

下面我们来学习一下如何调试、运行我们的汇编代码,以附件中的 2.3-1.asm 文件举例。

首先就是按照我们之前的教程,使用 File - Open 打开这个文件,接下来,点击上方工具栏中“扳手和螺丝刀”的图标,进入 Execute 界面:

Picture Not Found!

使用最上方工具栏中的几个运行按键,就可以非常轻松地完成对代码的调试和运行,最左侧还提供了打断点的位置供我们使用。

代码界面除了我们自己写的代码和设置断点的区域外,还有很多信息供我们使用:Address 即每条指令的地址,Code 即每条指令的机器码,Basic 即每条指令和伪指令的翻译。现在我们可能一些不懂,但在之后都会系统地学到,并且要经常使用它们。

寄存器界面和内存界面极大地方便了我们的调试。和 C 语言调试时 IDE 能够实时显示每个变量的值一样,这两个界面也是用来实时显示在寄存器和内存中存储的值的。有关寄存器和内存,我们之后还会再学到。

最后是交互界面,如果我们的代码涉及到输入和输出,就需要在这个界面的 Run I/O 界面下完成,在讲到输入输出的时候我们就会用到它(那可能已经是很后面很后面的事了)。

2.3 MIPS 语法初步

接下来,我们就要正式开始我们的 MIPS 学习了。作为一门编程语言,我们首先要学习的肯定就是它的语法:

【MIPS中的寄存器】

在 C 语言中,我们使用自定义的变量来存储值。然而在 MIPS 中,我们不能自己定义变量,而是只能使用规定的寄存器来进行存储。常用的寄存器有 32 个,名字为 $0$31 ,这 32 个寄存器构成了寄存器堆 GRF 。另外还有一些特殊的寄存器 PC HI LO 等,它们的存取方式和普通寄存器有所不同,我们稍后再介绍。

上述的所有寄存器,每个寄存器都可以存储一个 32 位的值,大小和 int 类型的值相同。

刚才我们提到了 $0$31 这些普通的寄存器,它们是相同的吗?我们可以对它们随意赋值吗?其实并非如此。每个寄存器都有着自己的职能,发挥着不同的作用。

根据它们的作用,我们还为它们起了另外的代号。我们在编写汇编代码时,既可以用它们的“本名”(如 $16 ),也可以用它们的“代号”(如 $s0 )使用它们,二者是完全等价的。

Picture Not Found!
(从操作系统指导书里面抠下来的表格)

我们不需要去背诵每个寄存器“本名”和“代号”之间的对应关系,不过还是要清楚这些寄存器的职能。有些寄存器的性质比较特殊,是不能和其它寄存器混用的,在这里列举几点要点,希望能够帮你规避一些以后可能会出现的奇妙 bug :

  1. $0 寄存器不能被赋值,无论如何被赋值,它的值永远为 0 ;
  2. $1 寄存器会被用在伪指令的展开中(伪指令的概念我们稍后会介绍到),尽量不要使用这个寄存器;
  3. $26$31 这几个寄存器都有自己的特殊用途,同样不要使用;
  4. $v0$a0 两个寄存器会用于 syscall 指令的传递参数中,建议不要随意“借用”这两个寄存器,让它们各司其职就好。

那么,说了这么多不能用的寄存器,到底有哪些寄存器是可以放心用的呢?实际上,我们平常会用来作为变量的寄存器一般就只有 $s0$s7 ,和 $t0$t7 两个系列。虽然表格中告诉我们前者一般用于存储定值,后者一般用于存储变量,但其实只是君子协定,即使不遵守也没有问题,只不过美观性上可能会有影响。比如在 t 系列的寄存器全部用光光的时候,我们就可以去 s 系列去“借”一些来用(读书人的事)。其实把 a 系列也加进来,它们三个系列互相借来借去也没什么问题,除了上面提过的 $a0 寄存器不太好惹以外,a 系列的其它寄存器也可以随便拿来用。

(免责声明:我不太建议随便借来借去的这种行为,本来寄存器就没法像变量那样命名,一多起来真的找不到谁是谁,一会儿就乱了)

【MIPS指令:位运算】 and or xor nor andi ori xori

学习了寄存器之后,我们来学习如何使用寄存器进行运算。或许你以为我要从四则运算开始讲起,不过对于计算机来说,四则运算还是有点太难了(笑)还是先从位运算开始讲起吧:

首先是与运算、或运算和异或运算。如果我们想表示 $t2 = $t0 & $t1 ,该怎么编写汇编代码呢?答案是 and $t2, $t0, $t1and 表示我们当前使用的指令,后面紧跟着由英文逗号隔开的三个寄存器的名称,需要格外注意的是,运算的结果是写在最前面的,而不是写在最后面。

同样的,如果想表示 $t2 = $t0 | $t1 ,可以使用 or $t2, $t0, $t1 ;如果想表示 $t2 = $t0 ^ $t1 ,可以使用 xor $t2, $t0, $t1

如果我们想将其中一个寄存器换为某个定值(在以后我们称这个定值为“立即数”),例如 $t2 = $t0 & 15 ,可以使用 andi $t2, $t0, 15 ,这里的 15 也可以写成 0xf ,即十六进制的表示法也是可以的。同理,orixori 指令也是相同的用法。

这里需要注意的是,andi ori xori 这一类使用立即数的指令,立即数必须写在第三个寄存器的位置,不可以和前面的寄存器调换!

接下来就是取反运算了,MIPS 指令集中并没有直接给出取反运算的指令,但是我们可以略施小计将其解决。MIPS 指令集中提供了或非运算指令 nor ,我们使用 nor $t0, $t0, $0 即可将 $t0 寄存器的内容原地取反(还记得 $0 寄存器的值永远为 0 这码事吗)。

MIPS 指令集提供给我们的指令十分有限,大家千万不要脑补出一些不存在的指令来!(比如 nori 指令就不存在,再比如两个立即数直接运算的指令也不存在,你可以将立即数赋值到寄存器中再进行计算)如果不确定指令存在与否的话,还是要先查一查指令集来判断!

【伪指令】 move li la

但是凡事也都不是绝对的,虽然指令集里没有这些指令,但是设计者已经料到了你肯定会这么脑补(笑),所以他们允许你这么写,然后将你写出来的“伪指令”转换成一条或者几条合法的指令,从而实现同样的功能。

在使用汇编语言编程时,我们完全可以使用这些伪指令,甚至说我们完全离不开伪指令。我们只需要记住,在之后设计 CPU 时,我们不需要考虑它们就好了。

现在我们尝试在 MARS 中输入下面的代码:

1
2
3
4
ori $t1, $0, 0xf
ori $t1, $0, 0xfffff
ori $t1, 0xf
ori $t1, 0xfffff

保存后,点击上方“扳手和螺丝刀”的图标,即可进入 Execute 界面。在左上方,我们能够看到刚才的代码被“翻译”成了什么:

Picture Not Found!

可以看到,第一条指令 ori $t1, $0, 0xf 没有发生变化,然而第二条指令 ori $t1, $0, 0xfffff 却被翻译为了 3 条指令。这是因为 ori 指令中的立即数最多只能为 16 位,所以 20 位的立即数 0xfffff 其实是不合法的。不过 MARS 已经帮我们处理好了,就不需要我们考虑太多了。(不过如果期末理论考试考到,可还是要能判断出来它是不合法的哟~)

第三条和第四条指令,明显是缺了一个寄存器,这实际上也是不合法的,然而 MARS 将其解读成了 $t1 寄存器自己或上一个立即数,再赋给自己的过程,相当于是 $t1 |= 0xf

接下来我们来输入下面这段代码:

1
2
3
4
li $t2, 5
move $t3, $t2
label1:
la $t4, label1

同样,我们进到 Execute 界面,观察这些指令的“真身”:

Picture Not Found!

可以发现,它们也不是指令集中的指令,而是伪指令。然而,这些指令的作用却非同寻常。liload immediate)伪指令用于将立即数直接赋值给寄存器,move 指令用于将后一个寄存器的值赋值给前一个寄存器(这里和物理意义上的 move 不相同,原寄存器的值不会被清零,更像是 copy )。

laload address)指令理解起来有些复杂,也没那么常用,不过我记得我还是用过一次,所以还是简单了解一下。在 MIPS 汇编代码中,我们可以使用自定义的标签名 + 冒号为一个位置打上标签(如上述代码中的 label1: ),标签本身不是汇编指令,只不过是一种便捷的写法而已。在 Execute 中我们能够清楚地看到原来标签的位置被替换为了它的地址 0x00003008(至于为什么是这个地址,我们过后再研究)。lali 的作用比较相似,后者将立即数存入寄存器内,前者则是将地址存入寄存器内。

【MIPS指令:四则运算之加减法】 add sub addu subu addi addiu

接下来,我们来了解一下四则运算。四则运算和位运算最大的不同,就是四则运算会面临着两个棘手的问题:正负性和位溢出。

首先来谈正负性的问题,其实这根本就不是个问题。如果你是补码领域大神的话,你就会发现,在不考虑位溢出的情况下(这里“不考虑位溢出”的意思是发生溢出时会将溢出位舍去,相反“考虑溢出”的意思是如果发生溢出直接报错),二进制补码的正负性其实是相同的,这也正是补码的优越之处。不信可以看看下面这些例子:

在 32 位的条件下,0x3 + 0xffffffff 的结果是多少?你的做法或许是这样的:首先,你发现 0xffffffff 的最高位是 1 ,于是断定它是负数,将其一段操作转换成 -1 ,再与 0x3 相加,得到结果 0x2 。但实际上,我直接把它们两个相加,结果就是 0x100000002 ,舍去溢出的最高位,直接就得到了 0x2

再举一个例子,同样是在 32 位的条件下,0xfffffffd - 0xffffffff 的结果是多少?细心的你可能会将这个式子转换成 (-3) - (-1) ,结果是 -2 ,也就是 0xfffffffe 。但是我不管正负,直接进行运算,发现被减数小于减数,我直接在被减数前面补一个溢出位 1 ,于是原式变成 0x1fffffffd - 0xffffffff ,结果等于 0xfffffffe

于是我们可以看出,在不考虑位溢出的情况下,我们不需要先确定一个补码的正负性,再根据其正负性进行运算,而是直接按照加减法规律对其进行运算即可。addusubu 就是这样的指令,它们在进行加减法时,完全不需要考虑溢出位的问题,出现溢出直接舍去最高位,就是这么简单粗暴。

相反,addsub 两条指令就要心思缜密一些,它们在发生溢出时不会计算,而是会发生报错,抛出异常。这样我们刚才“无论正负直接计算”的理论就要站不住脚了。例如上面的两个例子,直接计算时都出现了位溢出的现象,但如果考虑它们的正负性,你就会发现,一个是 3 + (-1) ,一个是 (-3) - (-1) ,都是十以内加减法(当然,是正负十,笑),无论如何也谈不上溢出嘛!这就要提到了,上述的算法固然能保证结果正确,但它的计算过程却是天马行空的,所以在这个过程中就会出现“本来没溢出却溢出了”的情况。所以在判断是否溢出时,还是需要按照老方法规规矩矩来做。

上述四条指令的用法都是 xxx $t2, $t0, $t1 ,依旧是被赋值的寄存器打头,进行运算的两个寄存器殿后。(有 3 个寄存器的指令都是这样的,以后就不单独再提了)

此外,addaddu 两个指令还有它们的 i 版本addiaddiusubisubiu 是伪指令),同样,它们的用法就是 xxx $t2, $t0, imm16imm16 代表 16 位的立即数,超出 16 位的话是伪指令)。

【MIPS指令:四则运算之乘除法】 mult div mfhi mflo mthi mtlo

刚才我们讨论了加减法,到了乘除法这里就不只是溢出一位这么简单的问题了。众所周知,两个 32 位的数字相乘,最多能达到 64 位,如果说结果超出 32 位的乘法都算溢出,舍掉或者报错好像都不太合适。这时候天才的你就想到了,将结果分为高 32 位和低 32 位,分别存入两个寄存器中不就好了嘛!恭喜你,你发明了 HI 寄存器和 LO 寄存器!

在乘法运算中,我们只需指定两个乘数所在的寄存器即可,格式为 mult $t0, $t1 ,运算的结果中,高 32 位自动存储在 HI 寄存器中,低 32 位则自动存储在 LO 寄存器中。

这两个寄存器是特殊寄存器,它们不能直接参与到运算中,如果想使用其中的数据,需要先使用指令将它们取出来,这就需要用到 mfhimflo 指令了。使用 mfxx $t0 ,就可以将 HILO 寄存器中的值取出到 $t0 寄存器中。当然,还有反过来的指令 mthimtlo ,使用 mtxx $t0 就可以将 $t0 寄存器的值写入 HILO 寄存器。

另外,mult 指令也是有符号的运算,所以 0xffffffff * 0xffffffff 的结果不是 HI = 0xfffffffe LO = 0x00000001 ,而是 HI = 0x00000000 LO = 0x00000001 。相当于 (-1) * (-1) = 1 而已。你可能会问为什么加减法可以无所谓有无符号,计算出来的结果是相同的,而乘法却不可以。我会说其实二者是一致的,你之所以会认为计算的结果是相同的,是因为我们把溢出位舍掉了。在讲加法的第一个例子中,我们在计算 0x3 + 0xffffffff 时,得到的结果实际上是 0x100000002 ,我们将 32 位的溢出位舍掉之后得到了结果 0x2 ,乘法也是同理。如果仅保留低 32 位,也就是 LO 的部分,我们会发现上面两个式子的 LO 是一致的,这也就印证了我之前的说法。而 HI 作为“溢出”的 32 位,两种算法结果不同也是正常的。

接下来就是除法了,它的格式为 div $t0, $t1 。我们知道除法当然不涉及到位溢出的问题,但是却有商和余数两个结果,所以我们就可以将 HILO 两个寄存器充分利用起来,规定用 LO 寄存器存储商,HI 寄存器存储余数。这里还是需要注意一下,非常容易记反的。

有同学可能会问,在 MIPS 中,当除数或被除数为负数时,余数的正负性该如何判断。直接说结论,和 C 语言中的规定相同,被除数为正数时,余数为 0 或正数;被除数为负数时,余数为 0 或负数。看下面这个表格就明白了:

被除数 除数 余数
7 3 2 1
7 -3 -2 1
-7 3 -2 -1
-7 -3 2 -1

【位、字节、半字、字】

我们马上就要进入内存的学习了,在接下来的部分,我会大量地使用标题中的这些概念。如果你对这些概念还不是特别清楚,请务必好好看看下面的讲解,打下一个坚实的基础:

在之前的内容中,我们往往都使用“位”(bit)这个单位来形容一个二进制数,很简单,1 “位”就代表着一个二进制值,它可能是 0 ,也可能是 1 。二进制数 0b10101 就是 5 位,十六进制数 0xabc 中的每个数字都可以转换为 4 位二进制数(例如 0xa 等于 0b1010 ),所以它有 12 位。

另外一个很常用的单位是“字节”(Byte),1 字节等于 8 位,即 1 Byte = 8 bits 。对于一个 32 位的寄存器,它的大小就相当于 4 字节。字节是地址的基本单位,我们马上就会讲到。

如果再大一点的话,2 字节(16 位)等于 1 “半字”(halfword),4 字节(32 位)等于 1 “字”(word)。于是一个 32 位的寄存器就是 2 半字或 1 字。

【地址】

在上一节,我们学习了如何使用 32 位的寄存器来存储数据,以及如何使用指令对寄存器进行运算、赋值。然而,对于一个庞大的程序来说,所有的数据肯定不可能全部存储在寄存器中(那我开个 int [32] 的数组你不直接炸了吗),所以我们开辟了一个庞大的、连续的内存空间,用来存储更多的数据。

(当然,在内存空间外还有更大的外存空间,这一部分就是理论课的内容了,在这里我们不过多提及)

以防你忘了 MARS 的内存界面长什么样,在这里再贴一张:

Picture Not Found!

刚才我们提到,这一片内存空间是非常巨大的,所以我们并不会像寄存器那样,给每个 32 位的空间都起一个名字。但正因为它是连续的,所以我们可以像数组下标一样,从 0 开始为每块空间编号,这就是内存空间的地址。

这里要注意,每个地址代表的空间不是 1 位,也不是 32 位,而是 8 位(1 字节),这也就是在说,字节是地址的基本单位。

举个例子来说,一个 32 位的寄存器中存储着数据 0x12345678 ,我将其写入地址为 0x0 的位置,此时从 0x0 开始第一个没有写入数据的地址是哪个?答案是 0x4 。因为 0x12345678 有 32 位(4 字节),占据了 0x0 0x1 0x2 0x3 这 4 个地址的空间,所以答案是 0x4

接下来我们来研究一个更有意思的问题,0x0 0x1 0x2 0x3 这 4 个地址对应的数据分别是什么?先别急着回答,好好想一想。

答案是 0x78 0x56 0x34 0x12 !你答对了吗?或许你会感到很诧异,因为这个答案可能和你想象中的正好相反。但请一定记住,一个数字的最右侧才是最低位,如果按照从低位到高位的顺序依次取 8 位出来,最先取到的 8 位一定是 0x78 ,对应的地址是 0x0 ;紧接着才是 0x56 ,对应 0x1 ;然后是 0x34 ,对应 0x2 ;最后是最高 8 位的 0x12 ,对应 0x3

关于地址的知识点很难,但是也极其重要。如果上面讲的知识点没有大彻大悟的话,就会对后面的内存访存指令的理解产生困惑,然后对 CPU 搭建时地址的变换产生困惑,然后对期末理论考试的许多计算题产生困惑,然后对操作系统课程的内存管理产生困惑,最后就变成系统能力废人了。作为这本书的作者,我可能真得让你清楚清楚,真的。

【MIPS指令:内存访存之按字访存】 sw lw

有了上面的知识做铺垫,我们就可以来讲解一下对内存进行读取和存储的指令了。

首先我们来研究一下最常用的两个指令 swlw ,全称 store wordload word 。顾名思义,它们的作用分别是向内存中一次性写入或读取一个

这两条指令的用法是我们以前没有见过的。sw 指令使用 sw $t1, imm16($t0)sw $t1, label($t0) 两种方法来调用,表示将 $t1 寄存器中的内容写入“ $t0 寄存器中存储的值 + imm16 立即数 或 label 标签表示的地址”这个地址中。

说起来有些复杂,我们举几个例子来理解一下:如果 $t1 = 0x12345678 $t2 = 0x00000007 ,我想使用这两个寄存器将 $t1 中的内容分别存储至 0x80x4 的内存地址中,该如何编写指令呢?答案是 sw $t1, 1($t2)sw $t1, -3($t2) 。是的,这里的立即数可以是负数。这就是使用寄存器 + 立即数构造地址的方法。

再举一个例子,假如我想利用一块连续的内存空间实现一个 int 类型的数组 arr[] ,如果 arr[0] 的地址为标签 arr ,那么我该如何将 $t1 寄存器的内容存储至 arr[3] 呢?

首先我们需要知道,int 类型变量的大小是 32 位,所以数组中的每个数字都应该占据 4 个地址,于是我们能够得到这样的结论:如果 arr[0] 的地址是 x ,那么 arr[3] 的地址就应该是 x + 12

这时候,很多初学者就会犯迷糊了,直接把指令写成了 sw $t1, 12(arr) 。NO!!!!!这里是非常容易犯的错误。我们的惯性思维告诉我们,括号里面的是一个基地址 base ,括号前面的立即数是在基地址上的小范围偏移 offset ,所以自然而然地就写成了上面那样。

然而,我们一定要回归到定义,在之前学习 la 指令的时候我们能够发现,在执行 la $t0, label 指令的时候,label 会被自动替换为一个立即数,而不是某个寄存器。所以在指令 sw $t1, label($t0) 中,标签 label 替换的实际上是立即数 imm16 的位置,而不是括号中的寄存器。

这道题正确的解答方法是,先将 12 存储到某个寄存器中,如使用 li $t0, 12 ,再执行 sw $t1, arr($t0) 。这就有点像 C 语言中 arr[3] 等价于 3[arr] 一样,非常有趣的小知识,一定要记牢!

学会了 sw 指令,lw 指令也就可以直接出师了。它们两个的用法完全相同,只不过 lw 指令是把后面地址中的内容写入前面的寄存器中罢了。

关于 swlw 还有一个要点,当计算出要访问的地址后,若地址不存在或非法(比如地址为负),则会直接发生错误,抛出异常。另外,计算出来的地址必须是 4 的倍数,否则同样会发生错误,抛出异常!

下面是一个小测验时间,本关考验你的内存访存功夫,请听题:在之后的学习中,我们会经常用到这条指令 lw $t0, 0($sp) ,既然偏移值是 0 ,那么它和这条指令 move $t0, $sp 是不是等价的呢?

答案是:两条指令并不等价。因为前一条指令中,$sp 寄存器中的数据代表的是一个地址,我们需要根据这个地址到内存中找到相应的数据,将内存中找到的这个数据赋值给 $t0 ;而后一条指令是直接将 $sp 寄存器中的数据赋给了 $t0 。二者是截然不同的!

另外一个小测验,请听题:和上一道问题很相似,指令 lw $t0, label($0)la $t0, label 又是否是等价的呢?

答案是:两条指令依旧不等价。在前一条指令中,我们根据 label 标签表示的地址在内存中找到相应的数据,将内存中找到的这个数据赋值给 $t0 ;而在后一条指令中,我们直接将 label 标签表示的地址赋给了 $t0

不知道你是否答对了这两道问题。想当年这两个疑问也曾难倒过我,如果你能不假思索地理清这几条指令之间的关系,那么你的内存访存功夫就已经十分到家了!如果你全部都答错了,那就还是结合附件中的代码,好好领悟一下这一部分的精髓吧!

【MIPS指令:内存访存之按半字、字节访存】 sh lh sb lb

学习完了按字访存,我们来研究一下更灵活的按半字、字节访存。

sh lh 指令,全称为 store halfwordload halfword ,用于将一个半字(2 字节,16 位)的数据从寄存器写入内存,或从内存读取到寄存器。

sb lb 指令(并非很优雅的名字),全称为 store byteload byte ,用于将一个字节(8 位)的数据从寄存器写入内存,或从内存读取到寄存器。

这四条指令的用法都和 sw lw 一模一样,在此就不多赘述了,但还有一些小细节是需要我们注意的:

第一就是六条指令对地址的要求并不相同。经过计算得到的地址,除不能是非法地址外,sw lw 指令要求必须是 4 的倍数sh lh 指令则要求必须是 2 的倍数,而 sb lb 指令没有倍数要求

第二就是访存前后寄存器内数据的变化。在学习 swlw 时,我们并没有考虑太多,执行指令时只需要把 32 位的数据整个从寄存器中搬到内存中,或者把 32 位的数据整个放到寄存器中就可以了。然而,当执行后四条指令时,我们要访存的数据不足 32 位,对寄存器中的数据,我们应该如何操作呢?

首先是 shsb 指令,比如当执行指令 sb $t1, imm16($t0) 时,$t1 寄存器内的数据是 0x12345678 ,超过了 8 位,该如何是好呢?答案是抛弃高出来的位数,只向内存的相应位置写入最低 8 位即可,即 0x78

其次是 lhlb 指令,比如当执行指令 lb $t1, imm16($t0) 前,$t1 寄存器已有数据 0x12345678 ,将要被从内存读取到寄存器中的字节是 0x00abcdef 中的 0xcd 字节,那么读取到寄存器中的结果如何呢?是 1234cd78 123456cd 0000cd00 000000cd 中的哪一个?答案是 000000cd 。读取到的字节会被存储在寄存器中的最低位,其它位则直接置 0 。

【MIPS指令:绝对跳转指令】 j jal jr jalr

MIPS 语法中的跳转指令虽然不多,但作用可谓是非常之大。像 C 语言中的 if 条件分支、while 循环和 for 循环,在 MIPS 中都需要用到跳转指令才能实现。下面我们来学习 MIPS 中的 4 条绝对跳转指令 j jal jrjalr

跳转指令其实就类似于 C 语言中的上古禁术 goto ,不知道大家有没有斗胆用过(我是真用过,笑),这下属于是不得不用,可以用个爽了。

j 指令的用法只有一种,就是 j label 。只要把 label 标签写在要跳转的指令前一行,就可以在运行完 j 指令后跳转到该指令。例如下面的例子:

1
2
3
4
j target
li $t0, 1
target:
li $t1, 2

在执行完 j target 之后,程序不会执行下一行的 li $t0, 1 ,而是会跳转到 target 标签的下方,执行下一条指令 li $t1, 2

众所周知,一条指令中的标签 label 在 Execute 时会被翻译为一个立即数,那么 j 指令中的标签会被翻译为什么呢?我们执行一下上述代码,就可以看到 target 被翻译为了 0x00003008 ,而 li $t1, 2 这条指令对应的 Address 正好是这个数字:

Picture Not Found!

其实,不仅我们使用 sw sh sb 指令写入内存的数据会保存在内存中,我们编写的每一条指令都会被转换为二进制码保存在内存中。前者占据了 0x000000000x00002fff 这一段地址,称为 .data 段;后者则占据 0x00003000 之后的地址,称为 .text 段。

我们在 Execute 界面中的内存界面,将下方的地址调整到 0x00003000 开始的 .text 段,就可以发现这一部分的内存已经被写入,而且写入的内容和上方每行代码的 Code 部分都不谋而合。这里的 Code 就是每条指令被转换为的二进制码,称为指令的机器码。在下一节我们将会具体学习每条机器码的含义,在这里我们只需要知道 MIPS 语言中的每条指令,转换为机器码都固定为 32 位,所以每条指令的地址 Address 也会按照顺序每条 +4 递增。

Picture Not Found!

还是要提一下,j 指令不允许使用立即数作为参数,必须强制使用标签,我觉得可能是因为允许用户想跳到哪跳到哪还是太危险了()

接下来出场的是更强力的跳转工具,一对卧龙凤雏 jaljr 。有的时候我们只是想短暂地离开,在执行完一段指令之后又希望能回到刚才离开的位置,这时候就需要用到这两条指令了。

jal 指令的用法和 j 指令一样,使用 jal label 即可跳转到 label 标签对应的位置。和 j 指令不同的是,jal 会把当前指令的下一条指令的地址写入 31 号寄存器 $ra 中。这个地址一般被称为 PC + 4PC 指的当然就是当前指令的地址了,如果你向 MARS 右侧的寄存器界面下方看去,你就会发现真的有一个叫作 PC 的寄存器,在时刻保存着当前指令的地址。

jr 指令则用于返回跳转前的位置。使用 jr $ra 即可跳转至 jal 指令的下一条指令继续运行。注意在 jal 跳转到 jr 返回的这个过程中,千万不要篡改 $ra 寄存器的值,否则会跳转到乱七八糟的地方,不如说在任何过程中都不要随意篡改 $ra 的值,百害而无一利啊~

实际上我们也可以看出,jr 指令后面理论上可以接任何寄存器,它的作用只不过是将寄存器中的数据作为地址,跳转到地址所在的位置罢了,并不一定要与 jal$ra 搭配,所以说也可以变相实现跳转至任意地址了。

其实这也解开了一个谜团,就是为什么 $ra 寄存器中保存的是 jal 指令的下一条指令的地址,而不是 jal 指令本身的地址。后者的话就会导致使用 jr 返回时再次执行 jal 指令跳转,从而就会导致死循环。

最后再介绍一下 jalr 指令,它实际上就是 jal 指令和 jr 指令的结合体。它跳转的方式和 jr 指令是一样的,都是跳转到寄存器所在的位置。另外,我们还需要再指定一个寄存器,用于将 PC + 4 存储在该寄存器中。例如指令 jalr $t1, $t0 ,就是跳转到 $t0 寄存器的值代表的地址,并将 jalr 指令的下一条指令的地址写入 $t1 寄存器中。

需要注意的是,jalr 指令后面的两个寄存器不能相同。如果说是为什么,主要是因为 jalr 指令执行时,是先将 PC + 4 写入寄存器,再执行跳转。如果两个寄存器相同的话,在跳转前寄存器的数据就会被覆盖掉,于是就没有办法执行跳转了。

【MIPS指令:相对跳转指令/条件跳转指令】 beq bne bgtz bgez bltz blez

接下来是条件跳转指令大家族,它们的特点是可以根据一定的条件选择是否进行跳转。相比寥寥数位的 j 型跳转指令,接下来的 b 型跳转指令真的可以算得上一个庞大的家族了。

首先是 beq 指令,它的全称是 branch if equal ,用法是 beq $t0, $t1, label 。当 $t0 的值等于 $t1 时,跳转到 label 标签所在的位置;否则不跳转。

相对的是 bne 指令,全称 branch if not equal ,用法是 bne $t0, $t1, label 。当 $t0 的值不等于 $t1 时,跳转到 label 标签所在的位置;否则不跳转。

有了等于和不等于,自然就有大小比较了,于是就有了 bgtbranch if greater than)、bgebranch if greater or equal)、bltbranch if less than)、blebranch if less or equal)四条指令。以 bge $t0, $t1, label 举例,若 $t0 寄存器中的值大于等于 $t1 寄存器中的值,则跳转到 label 标签所在的位置;否则不跳转。

这时我们的警报又要响了,上一章的经验告诉我们,涉及到比较必须警惕有无符号的问题!上面四条指令都是有符号比较的指令,比如认为 0x1 大于 0xffffffff ,于是又有了这四条指令的无符号变种:bgtu bgeu bltubleu

有时我们经常需要将寄存器中的值和 0 进行比较,这时可以将指令中的第二个寄存器替换为 $0 ,不过我们还有更简单的选择,对于 beq bne bgt bge bltble 这六条指令,在后面加上字母 z ,变成 beqz bnez bgtz bgez bltz blez ,就可以表示和 0 进行比较,相应地,第二个寄存器参数就被取消掉了。例如 bgez $t0, label 指令,就表示如果 $t0 寄存器中的值大于等于 0 ,则跳转到 label 标签所在的位置;否则不跳转。

(你问我为什么 bgtu bgeu bltu bleu 后面不能加字母 z ?再仔细想想,你会为这个问题感到好笑的~)

刚才我们一口气学习了 16 条指令,看起来 b 家族真的是枝繁叶茂的大家族。然而对于精简的 MIPS 指令集来说,一切的繁荣都是虚假的,在它们之中其实只有 6 条指令是真指令,其余的竟然都是伪指令!这 6 条真指令就是 beq bne bgtz bgez bltzblez ,不过其实并不需要太过区分,用起来都一样。

接下来,我们还是来关注一下 b 型跳转指令中的标签 label 被翻译成了什么,我们在 MARS 中输入如下指令:

1
2
3
4
5
6
li $s0, 1
li $s1, 1
beq $s0, $s1, target3
li $t6, 7
target3:
li $t7, 8

分析这一段指令,beq $s0, $s1, target3 语句的含义是比较 $s0 寄存器中的数据和 $s1 寄存器中的数据是否相等,如果相等,则跳转到 li $t7, 8 这条指令;否则继续执行 li $t6, 7 这条指令。

进入 Execute 界面,我们可以看到,标签 target3 并没有被翻译成 li $t7, 8 指令的地址 0x00003040 ,而是被翻译为了 0x00000001

Picture Not Found!

实际上,b 型指令的标签翻译参考的是 跳转的目标指令 与 当前的 beq 指令的下一条指令 的相对条数。上面的例子中,标签对应的指令是当前 beq 指令后面的第二条指令,所以会被翻译为 0x00000001 。相应地,在上述指令中,如果标签 target3 对应的指令是 li $t6, 7 ,那么在 beq 指令中,target3 就会被翻译成 0x00000000 ;如果标签 target3 对应的指令是 beq 指令本身,那么就会被翻译成 0xffffffff(即 -1)。

这里要注意的是,标签的翻译代表的是相对的指令条数,而不是相对的地址,所以上述指令中 target 被翻译成 0x00000001 ,而不是 0x00000004

到这里,我们就已经学完了全部的跳转指令,现在你应该明白了为什么 j 型跳转指令被称为“绝对跳转指令”,而 b 型跳转指令被称为“相对跳转指令”了吧!

目前,我们已经学习了 MIPS 中最重要的三类操作:运算、访存和跳转。接下来要介绍的所有指令都离不开这三类操作,理解起来也会相对简单一些。我知道这一节可能有点太长了,不过前方就是胜利,加油吧!

【MIPS指令:位运算(2)】 sll srl sra sllv srlv srav lui

下面我们来学习 MIPS 中的左移和右移。MIPS 中的左移指令是 sllshift left logical),使用 sll $t1, $t0, imm5 即可将 $t0 寄存器中的数据左移 imm5 位,存入 $t1 寄存器中。这里的 imm5 代表最高 5 位的立即数,也就是说左移的位数只能在 031 这个范围内选取,超出这个范围是没有办法执行的。

接下来就是右移了,通过上一章的学习,你一定知道了右移分为逻辑右移和算术右移两种。为了防止你忘记了,这里再简单介绍一下:逻辑右移时,高位一律补 0 ;算术右移时,需要根据原数据的正负确定高位补 0 还是补 1 。

在 MIPS 中,逻辑右移的指令是 srlshift right logical),算术右移的指令是 srashift right arithmetic),用法和 sll 指令完全相同。

当我们进行乘除法时,如果乘数或除数是 2 的倍数,使用左移或右移就会比较便捷。例如如果要将 $t0 自乘 4 ,使用 mult 指令要用三步:li $t1, 4 mult $t0, $t1 mflo $t0 ,而使用 sll 指令则只需要一步:sll $t0, $t0, 2 ,非常地简单,非常地快速!

除了使用立即数作为移动位数以外,我们还可以使用寄存器中的值作为移动位数,这就需要用到 sllv srlv srav 指令了。在使用指令时,将 imm5 替换为寄存器即可。如 sllv $t1, $t0, $s0 ,就是将 $t0 寄存器中的数据左移 $s0 寄存器中的数据这些位,存入 $t1 寄存器中。

那么就又会出现一个问题,如果上述指令中的 $s0 寄存器中的值大于 31 ,该如何处理呢?或许你认为会报错,或是直接按封顶的 31 位去计算,但其实并非如此。答案是直接舍去 $s0 寄存器中的值的 5 - 31 位,只保留最低 5 位作为移动位数。所以如果 $s0 寄存器中的值为 0xf0 ,实际移动的位数就会是 0x10 ,即 16 位。

位运算部分的最后一条指令,就是 mario 的好兄弟 luiload upper immediate)。其实在很久很久以前你就已经见过它了,只要涉及到向寄存器写入一个超大的立即数时,就会出现它的身影。

之前提过,ori 等指令中的立即数,位数最多为 16 位,超过 16 位就会变为伪指令。其实这是为了使每条指令的机器码都保持在 32 位的无奈之举,不然一个 32 位的立即数就把整个指令的空间占满了,其它信息实在是没有办法存进去,真的是太可怜了。

于是每当我们试图将一个高于 16 位的立即数写入寄存器时,实际上它都会分成两部分。先使用 lui 指令将高 16 位存入寄存器中,再使用 ori 指令写入低 16 位,真是非常天才的做法。

于是 lui 指令的功能就是将立即数左移 16 位之后写入寄存器,用法为 lui $t0, imm16 。例如 lui $t0, 0x1234 的结果就是向 $t0 寄存器中写入 0x12340000

【MIPS指令:条件赋值】 slt sltu slti sltiu

最后!最后!真的是最后了!我们来简单了解一下条件赋值指令。这几个指令真的就没什么存在感,但是考虑到后续设计 CPU 的时候要实现 slt 指令,所以就简单谈一谈,了解一下即可。

sltset less than)指令的用法是 slt $t2, $t0, $t1 ,表示如果 $t0 寄存器中的值小于 $t1 寄存器中的值,那么就将 $t2 寄存器置为 1 。

当然,slt 的比较是有符号的比较,如果要无符号比较的话,就可以使用 sltu 指令,用法完全相同。

除此之外,这两个指令还有与立即数比较的版本,slti 指令的用法是 slti $t2, $t0, imm16 ,表示如果 $t0 寄存器中的值小于立即数 imm16 的值,那么就将 $t2 寄存器置为 1 。sltiu 指令也是同理。

b 型跳转指令相同,这 4 条指令也衍生出了巨量的伪指令,这里就简单罗列一下好了,相信聪明的你一定能猜出来它们的含义:seq sne sle sleu sgt sgtu sge sgeu 。你猜对了吗?

到这里我们就已经学完了 MIPS 所有的常用指令了!说多不多,其实无非就是我们之前提到的运算、访存、跳转三种类型。在接下来的教程中,我们就要开始实战,将之前学过的指令运用起来,同时也会接触到更多有趣的 MIPS 语法!

2.4 MIPS 编程实战

在这一节中,我们开始学习如何使用 MIPS 语言编写出一个完整的程序,以能够完美复刻出 C 语言的简单程序目标,开始训练吧!

【MIPS中的if分支】

首先我们来学习如何使用条件跳转指令实现 if 结构,以下面这段非常简单的 C 语言代码为例:

1
2
3
4
5
6
7
if (a > b) {
a = 0;
}
else {
b = 0;
}
c = 0;

我们可以用下面这一段 MIPS 代码来实现它:

1
2
3
4
5
6
7
8
9
bgt $s0, $s1, branch1   # $s0 = a, $s1 = b
j branch2
branch1:
li $s0, 0 # a = 0
j end
branch2:
li $s1, 0 # b = 0
end:
li $s2, 0 # $s2 = c = 0

观察这段指令,我们将 if 替换为了 b 型跳转指令,指向第一个分支 branch1 ,将 else 替换为了 j 型跳转指令,指向第二个分支 branch2 ,成功实现了两个分支的分离。

另外我们还需要注意,在满足 a > b 的情况下,我们在分支的末尾加上了 j end 用来跳转到 branch2 分支结束处,否则在执行完 li $s0, 0 之后,就会继续执行 branch2 分支内的内容。

再提一点,由于汇编语言本身就十分抽象,为了在以后上百行的编写过程中不把自己绕晕,我个人建议还是保持要一个良好的代码风格为好。比如在变量定义和赋值时多写一些注释,再比如调整一下代码的缩进,尽可能和 C 语言的缩进相似,都是比较好的书写习惯。希望能够有帮助!

下一步我们再上一点强度,增加一个分支:

1
2
3
4
5
6
7
8
9
10
11
if (a > b) {
a = 0;
}
else if (a < b) {
b = 0;
}
else {
a = 0;
b = 0;
}
c = 0;

和简单分支一样,只需要加上一个 b 型跳转指令即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bgt $s0, $s1, branch1   # $s0 = a, $s1 = b
blt $s0, $s1, branch2
j branch3
branch1:
li $s0, 0 # a = 0
j end
branch2:
li $s1, 0 # b = 0
j end
branch3:
li $s0, 0 # a = 0
li $s1, 0 # b = 0
end:
li $s2, 0 # $s2 = c = 0

接下来,我们来练习一下带有逻辑运算符 &&|| 的条件:

1
2
3
4
5
6
7
if (a > b || b > c) {
a = 0;
}
else {
b = 0;
}
c = 0;

这里出现了逻辑或符号,那么我们应该如何应对呢?看下面这段指令:

1
2
3
4
5
6
7
8
9
10
bgt $s0, $s1, branch1   # $s0 = a, $s1 = b
bgt $s1, $s2, branch1 # $s1 = b, $s2 = c
j branch2
branch1:
li $s0, 0 # a = 0
j end
branch2:
li $s1, 0 # b = 0
end:
li $s2, 0 # c = 0

从上面的代码可以看出,只需要连续两条条件跳转指令,将它们指向同一个分支,就可以表达出 || 的意思。那么如果是逻辑与呢?

1
2
3
4
5
6
7
if (a > b && b > c) {
a = 0;
}
else {
b = 0;
}
c = 0;

这可能稍微有点麻烦了,因为只判断逻辑与一侧的表达式成立,并不能确定一个唯一的结果,不过你可能会想到这么操作一下:

1
2
3
4
5
6
7
if (a <= b || b <= c) {
b = 0;
}
else {
a = 0;
}
c = 0;

这显然是一个相当不错的想法,能够极大地降低难度,如果只有 if - else 两个分支的情况下,我是非常建议你这么去做的!然而当中间再插入几个 else if 分支的话,这种做法就有点力不从心了。不过没关系,我们还可以这样修改我们的 C 代码:

1
2
3
4
5
6
7
8
9
10
11
12
if (a > b) {
if (b > c) {
a = 0;
}
else {
b = 0;
}
}
else {
b = 0;
}
c = 0;

这种嵌套的 if 结构就相当于在 branch 里面再加入一套 if 结构。听起来有点复杂,你可以先自己想想。绕出来了吗?看看下面的指令:

1
2
3
4
5
6
7
8
9
10
11
12
bgt $s0, $s1, branch1   # $s0 = a, $s1 = b
j branch2
branch1:
bgt $s1, $s2, branch3 # $s1 = b, $s2 = c
j branch2
branch3:
li $s0, 0 # a = 0
j end
branch2:
li $s1, 0 # b = 0
end:
li $s2, 0 # c = 0

好了,我相信你已经对 if 结构有所了解了。感受到汇编语言的恐怖之处了吗?抓紧坐稳,我们继续发车:

【MIPS中的while循环、for循环】

要想实现 while 循环,我们依旧要借助条件跳转指令的力量。对于 while 循环,我有两种推荐的写法,你可以根据喜好选择一种,当然也可以根据不同情况灵活使用:

第一种是“进入循环时条件跳转,跳出循环时直接跳转”。例如下面这个 C 语言代码:

1
2
3
4
5
6
a = 0;
while (a < 5) {
b = a;
a++;
}
a = 0;

根据上面的描述,我们可以这样改写:

1
2
3
4
5
6
7
8
9
10
11
li $s0, 0   # $s0 = a = 0
li $s2, 5 # $s2 = 5
while:
blt $s0, $s2, loop
j end
loop:
move $s1, $s0 # $s1 = b = a
addi $s0, $s0, 1 # a++
j while
end:
li $s0, 0 # a = 0

这种写法和刚才我们学过的 if 结构很像,优点是循环条件和 || 逻辑运算可以很好地结合,只需要再增加一个到 loop 的条件跳转即可,然而如果循环条件中出现 && 逻辑运算,写起来就会比较复杂。

第二种写法是“进入循环时不跳转,跳出循环时条件跳转”。以同一个例子为例,用这种写法写起来就会是这样:

1
2
3
4
5
6
7
8
9
li $s0, 0   # $s0 = a = 0
li $s2, 5 # $s2 = 5
while:
bge $s0, $s2, end
move $s1, $s0 # $s1 = b = a
addi $s0, $s0, 1 # a++
j while
end:
li $s0, 0 # a = 0

显然这种方法的代码量比较小,而且也更像 C 语言中 while 循环的结构,然而缺点也显而易见,在跳转到 end 的条件跳转指令中,我们将 C 语言中的判断条件取了个反,变为了“如果 a >= 5 则跳转到 end 标签处”,如果是更为复杂的判断条件的话,有可能在取反这一过程中就出现错误了。

和前一种写法相对,这种写法的另一个优点就是循环条件和 && 逻辑运算可以很好地结合,同样只需要再增加一个到 end 的条件跳转即可。

我们接着再来说 for 循环,其实我们可以看出,刚才我们示例的 C 语言代码就是一个标准的 for 循环,我们可以将其改写成这样:

1
2
3
4
for (a = 0; a < 5; a++) {
b = a;
}
a = 0;

我们知道,for 循环其实就是一种特殊的 while 循环。所以我们完全可以将每个 for 循环都拆解成 while 循环,按照 while 结构的思路完成我们的指令编写。具体改写方法其实就是三步:第一步,将括号中的第一部分提到循环的前面,在进入循环前为循环变量赋初始值;第二步,将括号中的第三部分放到大括号内的最后,在每一次循环结束之后更新循环变量的值;第三步,把 for 改为 while 就可以了。

比如对于下面这两段 C 语言代码,第一段中的 for 循环与第二段中的 while 循环就是等价的:

1
2
3
4
5
6
7
8
9
if (n % 2 == 0) {
return 0;
}
for (int i = 3; i * i <= n; i += 2) {
if (n % i == 0) {
return 0;
}
}
return 1;
1
2
3
4
5
6
7
8
9
10
11
if (n % 2 == 0) {
return 0;
}
int i = 3;
while (i * i <= n) {
if (n % i == 0) {
return 0;
}
i += 2;
}
return 1;

(这是一段简单的判断一个数是否是质数的代码,如果你没看出来的话,那简直是太可怕了)

【MIPS中的数组】

在掌握了基本的语法之后,我们就来学习一下如何存储大量的数据。在编程时,我们难免会遇到需要存储一系列大量数据的情况,这时候我们肯定是不能为每个数据都创建一个变量了。在 C 语言中,我们可以使用数组、链表等各种各样的形式来存储这些数据;在更低级的 MIPS 中,我们就选择将这些数据存入内存中。接下来我们就来学习如何通过内存模拟数组的定义与存取。

还记得我们在上一节讲过的有关“段”的内容吗?.data 段指的是地址为 0x000000000x00002fff 的一部分内存,我们可以随意使用这个段中的内存来存储大量数据;.text 段指的是地址为 0x00003000 之后的内存,这一段用来存储我们的 MIPS 指令的机器码。

于是我们就可以在 .data 段中开辟出一块连续的空间,用来作为数组存储一系列的大量数据。如下面一段指令所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
.data
array1: .space 20
array2: .space 40

.text
li $s0, 1 # $s0 = 1
li $s1, 2 # #s1 = 2
li $t0, 0
li $t1, 4
sw $s0, array1($t0) # array1[0] = $s0
sw $s1, array1($t1) # array1[1] = $s1
sw $s0, array2($t0) # array2[0] = $s0
sw $s1, array2($t1) # array2[1] = $s1

(在编写指令时,写入的段默认为 .text 段,如果要切换写入的段,则需要像上述指令中一样,使用 .data .text 等进行切换)

我们首先输入 .data ,证明我们接下来要在这个段中开辟空间。在接下来的每一行中,我们先写上我们自定义的数组名,在英文冒号后加上 .space ,后面接上我们要开辟的空间的字节数。这里我们为 array1 数组开辟了 20 个字节的空间,但是要注意这个数组的大小不是 20 ,因为我们要存储的每个数据都要占据 4 个字节的空间(哪怕是只占据 1 字节的 char 型数据也建议直接用 4 字节来存储,我们的内存足够多,不需要担心不够用,使用 4 字节存储和寄存器的位数一致,也可以非常方便地使用 swlw 指令直接进行存取,也可以降低出错的概率,这个括号真的好长啊~),所以这个数组实际上只能存储 5 个值,即从 array1[0]array1[4] ;同理,array2 数组开辟了 40 个字节的空间,能够存储 10 个值,从 array2[0]array2[9]

在向数组进行存取时,我们使用内存访存指令 swlw 就可以实现。这里的 array1array2 可以作为标签 label 来使用,我们都知道标签在运行时会被翻译成立即数,实际上这两个标签代表的是两个开辟出的空间的首地址。对于 array1 数组来说,标签 array1 代表的立即数就是 0x00000000 ,因为空间是从 .data 段的首地址 0x00000000 开始分配的;对于 array2 数组,标签 array2 代表的立即数就是 0x00000014 ,也就是十进制的 20 。因为 array1 占据了地址为 0x000000000x00000013 的空间,array2 就顺延下来进行分配了。

如果要我来说的话,这种顺延下来的分配其实是一种特别坏的设定。比如在这个例子中,一旦 array1 申请的字节数不能被 4 整除,array2 的首地址就同样会不能被 4 整除,导致我们用 sw array2($0) 等指令进行数组的读写操作时,程序会因为 sw 要访问的地址不能被 4 整除而直接报错崩掉。所以说,我们最好保证在申请数组时,申请的字节数永远都能够被 4 整除!

接下来我们再谈谈内存访存指令中括号部分的内容。首先要注意的是括号里的内容应该是一个寄存器,而不是立即数,这一点在最开始讲内存访存指令的时候就已经强调过了。

还有一点要注意的就是,寄存器中的值不是数组的下标,而应该是数组下标乘 4 的结果。为了简便计算,我们经常会使用位运算来计算乘 4 的结果,比如使用 sll $t1, $t0, 2 就可以将 $t0 寄存器中的值乘 4 写入 $t1 寄存器中了。

接下来我们来学习一下二维数组。早在学习 C 语言时,你可能就听说过,C 语言中的二维数组其实就是一维数组的一种简单写法,二维数组在存储时其实是按行展平之后再存储的。我们接下来就要用到这种思路来实现二维数组。

例如我打算开辟一个 3 行 5 列的数组,于是我需要申请一个 3 * 5 * 4 = 60 字节的内存(你自己写时可以开大一点,防止自己算错空间导致溢出)。这一步还是比较好算的,关键在于如何正确对二维数组进行读写。

我们知道,二维数组在存储时会按行展平成一维数组,也就是按照第一行、第二行、第三行……的顺序紧密排列进行存储的。例如在一个 m 行 n 列的二维数组 matrix 中,matrix[i][j] 实际上和 matrix[i * n + j] 是相同的,于是 i * n + j 的值再乘 4 ,其实就是读写二维数组时,括号中的寄存器的值。

可想而知,每次对二维数组进行读写时,如果我们拿着两个数组下标,想要求出括号中的寄存器的值,还是比较复杂的,例如在刚才的 3 行 5 列数组 matrix 中,使用两个数组下标写入 matrix[1][2] 这个元素,就需要进行下面一通操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.data
matrix: .space 60

.text
li $s0, 3 # $s0 = m = 3
li $s1, 5 # $s1 = n = 5
li $t0, 1 # $t0 = i = 1
li $t1, 2 # $t1 = j = 2
li $t3, 0x114514 # $t3 = 0x114514
mult $t0, $s1 # LO = i * n
mflo $t2 # $t2 = LO
add $t2, $t2, $t1 # $t2 = i * n + j
sll $t2, $t2, 2 # $t2 *= 4
sw $t3, matrix($t2) # matrix[1][2] = $t3

确实很复杂,但是我们在之后会讲到宏,使用宏可以将计算过程提取出来,避免重复造轮子。除此之外,我们还可以进行一点小优化,那就是在申请空间时,将二维数组的列数扩展到 2 的幂次,这样就可以不使用 mult 指令进行计算,而是使用位运算指令 sll 一步到位了!例如在这个例子里,我们就可以申请一个 matrix[3][8] 的数组,大小为 96 字节。在使用这个数组时,第 0 到 4 列正常使用,第 5 到 7 列直接不理它就好了。

学习了二维数组的用法,相信你也已经能够举一反三,写出更高维数组了!(虽然我估计也不会用到)快去尝试一下吧!

【MIPS中的字符、字符串】

在 MIPS 中如何表示字符?你肯定猜到了,当然是使用字符的 ASCII 码值了。我们知道,ASCII 码值的范围是 0 到 127 ,也就是说,最多使用 7 位二进制数就能够表示出我们常用的所有字符。出于方便,我们使用一个字节(8 位)来表示一个字符,和 C 语言中 char 型变量的大小完全相同。

(如果你好奇 ASCII 码值为 128 到 255 的字符被定义为了什么,可以上网查一下,都是一些键盘上打不出来的特殊字符)

我知道你会觉得很简单,不过这一部分真的很容易搞混,因为十六进制的 ASCII 码和字符本身长得完全一样。还记得我之前想要输出字母 b ,于是在寄存器里写入了 0x0000000b ,结果输出不出来任何东西,我还以为是 MARS 的 bug 。实际上,0x0000000b 代表的是 \v ,一种空白符号,当然没有输出了,而真正表示字符 b 的应该是 0x0x00000062(晕)!

接下来我们来谈谈字符串。如果要存储字符串的话,我们就很难使用寄存器了,毕竟一个寄存器只能存放 4 个字符。所以我们可以将字符串写入内存中。

和上一部分的数组一样,我们也可以通过在 .data 段作出声明的形式来把字符串写入内存中。像下面这样:

1
2
3
4
.data
str1: .ascii "Hello, "
str2: .asciiz "World!"
str3: .asciiz "I'm Kamonto!"

我们执行这段指令,就可以在下方的内存界面处看到我们写入的内容。不过此时的内容还是十六进制的形式,我们可以点击下方的 ASCII 来将每个字节的内容转换为字符形式:

Picture Not Found!

我们可以观察一下这些字符的排列顺序,这些字符的顺序看上去似乎混乱不堪,但再仔细观察一下就可以发现,这些字符其实都是按照地址从低到高排列的。我们之所以觉得混乱,是因为在每个字中,4 个字节的地址是从左往右越来越低;而字与字之间的排列顺序,则是从左往右地址越来越高。假如在第一个字中,l l e H 的地址分别是 0x3 0x2 0x1 0x0 ,按照地址从低到高来读正好是 H e l l ;第二字中的 W (空格) , o 的地址分别是 0x7 0x6 0x5 0x4 ,按照地址从低到高来读是 o , (空格) W 。像这样依次读下去,就能够得到我们写入的字符串了。

你肯定已经发现了,在上面的指令中我使用了两种声明的形式,一种是使用 .ascii 来声明,一种是使用 .asciiz 来声明,那么你发现这两种声明方式的区别了吗?

答案就藏在字符串的尾部。我们可以发现,使用 .ascii 声明的字符串 Hello, 的后面直接接了下一个字符串的首字符 W ,而使用 .asciiz 声明的字符串 World! 的后面接了一个 \0 之后,接着才是下一个字符串的内容。二者的区别就在于结尾的这个 \0

和 C 语言一样,MIPS 中的字符串也是要以 \0 结尾的,如果一个字符串的结尾没有 \0 ,那么它就会和后面的内容连成一个字符串,直到遇见 \0 为止。如果你没太懂,那也不要紧,在下一部分 syscall 的内容中,我们还会再深入讨论这个问题~

最后还想强调一点,在之后我们经常会遇到在 .data 段要同时声明数组和字符串的情况,请记住一定要先声明数组,再声明字符串!道理其实很简单,因为如果先声明字符串的话,如果字符串的字节数不能被 4 整除,数组的首地址就又跑到不能被 4 整除的地方去了(叹气)。

【syscall:输入、输出、结束运行】

讲到这里,你会发现一个非常有趣的问题,那就是我们竟然还没有讲如何输入和输出数据!虽然我们一直在试图模仿当年学习 C 语言的过程来学习 MIPS ,但是想当年我们可是在第一节课就学了 scanf() printf() 这些函数的用法,然而现在我们却一直在回避输入输出的问题,这不可疑吗?

很遗憾,确实是这样的。事实上,对于在 CPU 上运行的汇编语言来说,要想仅通过自身来实现某些功能,是完全不可能的,哪怕是从外部输入和向外部输出这么简单的功能。正因为这两个功能需要通过与外部进行交互来实现,所以我们的汇编语言拿它们完全是束手无策,这时候就需要系统调用 syscall 来进行支援了!

因为在操作系统课程中我们才需要具体学习系统调用的相关知识,在这里就不详细介绍了。简单地来说,系统调用其实就是一些“求救信号”,当我们遇到一些没有办法实现的需求时,就可以喊出万能的 syscall ,召唤神奇的操作系统,来帮助我们解决问题。至于具体是怎么解决的,起码我们现在不需要知道,只需要快乐地 syscall 就 OK 了~
(相信我,操作系统这门课,一学一个不吱声,所有人都内核崩溃了)

众所周知,系统调用 syscall 肯定不止一种,那么我们该用什么方法来区分不同的 syscall 呢?答案是使用当前 $v0 寄存器中的值。也就是说,在我们使用 syscall 指令时,CPU 会根据当前 $v0 寄存器中的值,来判断执行哪种系统调用。

我们在 MARS 中按 F1 查看帮助,在 Syscalls 一栏中即可查看系统调用的种类和它们对应的 $v0 值。(当然,其它栏中的内容也非常有用,有时间的话可以仔细研读一下)

Picture Not Found!

我们在 Syscalls 一栏中往下翻,找到 Table of Available Services ,这个表格中有好几十种系统调用,看得人眼花缭乱,但是我们只需要学其中的几种就可以了(开心)。

Service Code in $v0 Arguments Result
print integer 1 $a0 = integer to print
print string 4 $a0 = address of null-terminated string to print
read integer 5 $v0 contains integer read
exit (terminate execution) 10
print character 11 $a0 = character to print See note below table
read character 12 $v0 contains character read

Service 11 - Prints ASCII character corresponding to contents of low-order byte.

print integer ,也就是输出一个 int 型整数,在系统调用前需要将 $v0 寄存器的值置为 1 。Arguments 一栏指的是系统调用时需要提供的参数,也就是说我们需要把要输出的整数事先写入 $a0 寄存器中。例如下面一段指令:

1
2
3
li $a0, 114514
li $v0, 1
syscall

我们在 MARS 中运行这段指令,在下方的交互界面中点击 Run I/O 选项,就可以看到下方输出了十进制的 114514

Picture Not Found!

read integer ,也就是输入一个 int 型整数,在系统调用前需要将 $v0 寄存器的值置为 5 。Result 一栏指的是系统调用的结果,从描述中可以看出,输入的 int 型整数存储在 $v0 寄存器中。也就是下面这段指令:

1
2
li $v0, 5
syscall

同样,在 MARS 中运行这段指令,我们就会发现程序运行时停在了第一个 syscall 这一行。我们在下方的交互界面中输入一个十进制数字,再按下回车,我们刚才输入的数字就会以十六进制的形式存入 $v0 寄存器中。例如输入 114514$v0 寄存器的值就会变为 0x0001bf52

需要注意的是,此时 $v0 寄存器中的值发生了改变,于是我们在每次输入时都需要为 $v0 寄存器重新赋值。

此外,我们还要注意两个系统调用使用的寄存器是有所不同的,输出时要输出的整数存储在 $a0 寄存器中,输入时被输入的整数存储在 $v0 寄存器中,这一点千万不要忘了!顺带一提,如果考试的时候忘记了,别忘了还可以按 F1 ,找到上面的表格来查看一下哦~

print character 和 read character 的用法和上面两个系统调用就非常相似了,只不过把输入输出的内容从 int 型改为了 char 型,$v0 寄存器的值不同而已,流程其实都是一样的。另外要注意的是,字符只占一个字节,所以在输出字符时 $a0 寄存器的值只有最低 8 位是有效的,剩下的高位会被忽略掉。

然而在输出字符串时,流程就有所不同了。和前两个输出不同的是,输出字符串时 $a0 寄存器内的值并不是我们要输出的内容,而是要输出的字符串的首地址。输出时会从首地址所在的字符开始不停输出,直到遇到第一个 \0 字符为止。

那么我们该如何将 $a0 赋值为字符串首地址呢?这时候就要请出我们的古神 la 指令了!字符串的名字同样也是标签,于是我们就可以使用 la $a0, label 将标签对应的值,即字符串首地址赋给 $a0 寄存器。

我们再拿上一部分中的三条指令作例子,当我们使用下面的指令时,三次 syscall 指令输出的结果是什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
.data
str1: .ascii "Hello, "
str2: .asciiz "World!"
str3: .asciiz "I'm Kamonto!"

.text
li $v0, 4
la $a0, str1
syscall
addi $a0, $a0, 1
syscall
la $a0, str2
syscall

答案分别是:Hello, World! ello, World!World! 。你答对了吗?

在输出 str1 时,因为 str1 使用的是 .ascii ,结尾没有 \0 ,所以会一直输出到 str2 结尾的 \0 才结束。随后,我们将 $a0 寄存器的值自增 1 ,此时 $a0 寄存器指向的地址就从 Hello, 中的 H 转移到了 e ,所以从 e 开始输出。在输出 str2 时,因为 str 使用了 .asciiz ,结尾有 \0 ,所以直接输出了 str2 的内容。

这个故事告诉我们,没事不要乱用 .ascii ,正常用有 \0 结尾的 .asciiz 就好……

实战中我们也经常将单个字符使用字符串的形式来存储,例如空格 " " 和换行符 "\n" ,然后使用输出字符串的方法来输出。把这些符号放进内存里,可以把寄存器节省下来,毕竟一共就那么几个寄存器,真的很宝贵:

1
2
3
4
5
6
7
8
9
10
.data
space: .asciiz " "
enter: .asciiz "\n"

.text
li $v0, 4
la $a0, space
syscall
la $a0, enter
syscall

最后就是结束运行的系统调用了,和 C 语言在 main() 函数中执行 return 0 一样,执行这个系统调用会让程序直接停止运行。调用方法就比较简单了,只需要将 $v0 寄存器的值置为 10 ,再执行 syscall 就可以了。

在我的印象里,官方的指导书里貌似提到过,如果 MIPS 程序结尾不使用这种方法结束运行,在评测机上好像会出现错误,所以还是尽量给每个程序都加上结束吧(摊手)~

【MIPS中的宏定义】

到目前为止,相信你已经充分体验到了使用 MIPS 进行编程的复杂,有时候哪怕非常简单的一个功能,用汇编语言实现起来都可能需要上百行。那么,有没有什么压缩指令条数的方法呢?

有的兄弟,有的!使用宏定义,我们就可以将几条重复或相似的语句合成成一条语句了!

来看这个例子,我们使用 .macro.end_macro 定义了宏 exit ,在 .text 段编写指令时,我们就可以使用 exit 来代替宏定义中的两条指令:

1
2
3
4
5
6
7
8
.macro exit   # or .macro exit()
li $v0, 10
syscall
.end_macro

ori $t0, $t0, 1
exit # or exit()
ori $t1, $t1, 2

可以看到,我们在 .macro 的后面写上了宏的名称 exit ,然后在下方写上结束运行的指令,最后以 .end_macro 结尾,就完成了宏定义的编写。调用时只需要使用宏名 exit 调用即可。(这里因为宏没有参数,所以在定义和调用时,后面加不加括号均可)

刚才我们提到了宏的参数,正是这样,我们还可以使用 % + 自定义参数名 的方式为宏添加参数,比如下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.macro push(%d)
addi $sp, $sp, -4
sw %d, 0($sp)
.end_macro

.macro pop(%d)
lw %d, 0($sp)
addi $sp, $sp, 4
.end_macro

push($t0)
push($t1)
li $t0, 0
li $t1, 0
pop($t1)
pop($t0)

这一段指令就是我们在稍后会讲到的压栈和弹出指令。通过这一段指令,我们就可以实现将 $t0 寄存器和 $t1 寄存器中的值压入栈中,再以先进后出的规则将它们弹出的过程。

实际上,在这段指令被执行前,带有宏的指令会被展开成正常指令。例如 push($t0) 这行指令,在展开时将 $t0 这个参数代入到宏定义 push(%d)%d 的位置,替代其中两行代码中所有的 %d ,于是 push($t0) 就被转换为了下面两行指令:

1
2
addi $sp, $sp, -4
sw $t0, 0($sp)

根据这个规则,上述代码就会被转换成这样的 10 行指令:

1
2
3
4
5
6
7
8
9
10
addi $sp, $sp, -4
sw $t0, 0($sp)
addi $sp, $sp, -4
sw $t1, 0($sp)
li $t0, 0
li $t1, 0
lw $t1, 0($sp)
addi $sp, $sp, 4
lw $t0, 0($sp)
addi $sp, $sp, 4

除了我们经常会用到的压栈和弹出指令以外,我们还可以将输入和输出指令打包成宏来使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.macro scanfint(%addr)
li $v0, 5
syscall
move %addr, $v0
.end_macro

.macro printfint(%addr)
move $a0, %addr
li $v0, 1
syscall
.end_macro

scanfint($s0)
printfint($s0)

此外,宏不仅可以有一个参数,使用逗号分割,我们就可以使用多个参数:

1
2
3
4
5
6
7
8
9
.macro getoffset(%ans, %i, %j)
sll %ans, %i, 3 # %ans = %i * 8
add %ans, %ans, %j # %ans += %j
sll %ans, %ans, 2 # %ans *= 4
.end_macro

li $t5, 2
li $t6, 5
getoffset($t7, $t5, $t6)

这里的 getoffset 宏的作用是,计算每行 8 列的二维数组中第 %i 行第 %j 列元素的地址,相对于该二维数组首地址的偏移值。在二维数组相关的题目中,我们也会经常用到这个宏。

刚才我们提到的这些宏,基本上就是我们在以后的学习中比较常用的所有宏了。接下来我将为你介绍一些更神奇的宏的特性,助你玩转宏定义!(说不定在下一部分中还有妙用哦!)

事实上,宏中带 % 的自定义参数不仅能够表示某个寄存器,它甚至可以表示一条指令中的任何一个部分,比如立即数:

1
2
3
4
5
6
.macro para(%p)
sw $s1, %p($s2)
.end_macro

li $s1, 1
para(4)

还有标签:

1
2
3
4
5
6
7
8
.macro label(%l)
j %l
.end_macro

label(label1)
li $s3, 1
label1:
li $s4, 1

令人不可思议的是,它甚至可以表示指令名:

1
2
3
4
5
6
.macro instr(%i)
%i $s2, $s3, $s4
.end_macro

instr(add)
instr(sub)

(不过如果输入的指令名不满足上述格式就会直接报错了)

不仅如此,宏还支持在自身内部跳转:

1
2
3
4
5
6
7
8
9
10
11
.macro branch(%d)
beqz %d, iszero
j end
iszero:
li %d, 114514
end:
.end_macro

li $s0, 0
branch($s0)
branch($s1)

当然,如果你试图从外部跳转到宏内,或者从宏内跳转到外部的话,就没办法通过编译了……

或许你还没有 get 到宏内部跳转的神奇之处,那么我们试想一下,在上面的指令中,如果我们直接将宏展开,会发生什么问题?答案是 iszeroend 标签就会出现两次,整个程序就没办法运行了!

我们来运行一下这个程序,看看 MARS 展开的宏是什么样的:

Picture Not Found!

我们可以发现,MARS 聪明地将两个标签加上了后缀,用以区分不同标签,真的是太细了!

除此之外,宏甚至还支持嵌套调用:

1
2
3
4
5
6
7
8
9
10
11
12
.macro para(%p)
instr(add)
sw $s1, %p($s2)
.end_macro

.macro instr(%i)
%i $s2, $s3, $s4
.end_macro

li $s1, 1
li $s3, 3
para(4)

当然,如果宏自己调用自己,或者出现了循环调用(比如宏 para 调用宏 instr ,而宏 instr 又调用宏 para ),就没有办法展开了……

真是万能的宏,让我们歌颂宏!

【MIPS中的函数、递归函数、栈】

最后,我们再来学习一下 C 语言转 MIPS 语言中函数的写法。我们来看这一段代码:

1
2
3
4
5
6
7
8
9
void swap(int *a, int *b) {
*a = *a ^ *b;
*b = *a ^ *b;
*a = *a ^ *b;
}

int x = 3;
int y = 5;
swap(&x, &y);

这是一个非常经典的交换函数,根据我们现有的知识,我们该如何在 MIPS 中实现相同的功能呢?

或许你已经想到了一个非常巧妙的办法,我直接把函数展开,不调用函数不就可以了吗?像下面这样:

1
2
3
4
5
int x = 3;
int y = 5;
x = x ^ y;
y = x ^ y;
x = x ^ y;

于是就可以写成下面的 MIPS 指令:

1
2
3
4
5
li $s0, 3   # $s0 = x = 3
li $s1, 5 # $s1 = y = 5
xor $s0, $s0, $s1 # x = x ^ y
xor $s1, $s0, $s1 # y = x ^ y
xor $s0, $s0, $s1 # x = x ^ y

非常聪明的解法!事实上,在我们亲自去进行 MIPS 编程时,当我们遇到这种比较简单的函数时,我也推荐大家使用这种方法,也就是非必要尽量不创造函数,而是直接将其展开到主函数中。

那么,如果这个函数的调用次数特别多呢?例如,我现在要对 10 对不同的寄存器分别执行交换操作,怎么样才能使我们的指令更加简洁呢?这时候就可以使用我们刚才学过的宏定义:

1
2
3
4
5
6
7
8
9
10
11
12
.macro swap(%var1, %var2)
xor %var1, %var1, %var2
xor %var2, %var1, %var2
xor %var1, %var1, %var2
.end_macro

li $s0, 3
li $s1, 5
li $s2, 6
li $s3, 11
swap($s0, $s1)
swap($s2, $s3)

如果函数有返回值呢?我们有两种解决方法。第一种就是将存储返回值的寄存器作为一个参数,写在宏定义中。当函数有返回值,但我们并不需要它时,我们也可以在使用宏时,将存储返回值的寄存器设为 $0(还记得吗?$0 寄存器的值永远为 0 ):

1
2
3
4
5
6
7
8
.macro myadd(%var1, %var2, %res)
add %res, %var1, %var2
.end_macro

li $s0, 3
li $s1, 5
myadd($s0, $s1, $t0) # $t0 = $s0 + $s1
myadd($s0, $s1, $0) # no return

第二种方法就是将所有函数的返回值都固定存储在一个寄存器中(如 $v0 寄存器),在调用结束之后,使用 move 指令将存储返回值的寄存器转移到其它寄存器中:

1
2
3
4
5
6
7
8
.macro myadd(%var1, %var2)
add $v0, %var1, %var2
.end_macro

li $s0, 7
li $s1, 9
myadd($s0, $s1) # $v0 = $s0 + $s1
move $t0, $v0 # $t0 = $v0

对于一般的函数,我们使用宏基本上就完全可以实现翻译了,真是太美妙了!然而,对于一种特殊的函数,我们却束手无策,那就是递归函数!

其实你也应该猜到了,在真实的编译过程中,函数绝对不是通过像宏定义一样直接展开到指令中来实现的,而是跳转到一段指令中,执行结束之后再跳转回原来的位置。就像下图这样:

Picture Not Found!

看到这张图,你可以试着想象一下它对应的指令是什么。这是你或许就会产生一个问题了,我们可以在程序的任意位置调用这个函数(甚至是这个函数自身内部),那么我们该如何实现函数执行结束后跳转回原来的位置呢?这就要请出我们好久不见的老朋友:jaljr 了!

我们知道,jal 指令和 j 指令的不同之处就是,jal 指令在执行跳转的时候,会将当前指令的下一条指令的地址保存在 $ra(即 $31 )寄存器中。而 jr 可以跳转到任意寄存器的值代表的地址处。于是,我们只需要在调用函数时,将 j 指令替换为 jal 指令,在返回时使用 jr $ra ,就可以实现上面图中的过程了。

或许你还会问,那函数的参数和返回值怎么办呢?我们手写的函数没有办法像宏那样直接传参,不过我们可以借鉴一下刚才使用宏实现函数时,对返回值处理的办法。

我们之前提到的两种方法中,有一种方法就是将函数的结果统一写入一个固定的寄存器中(比如 $v0 寄存器),再在调用宏结束之后统一 move 到其它寄存器中,我们在这种函数的写法中也可以使用这种方式来传递返回值。

其实参数也是同理,我们可以在调用函数前,将参数统一装入一些固定的寄存器内,调用时直接取用即可。一般来说,我们约定俗成的参数寄存器是 $a0 $a1 $a2 $a3 这四个寄存器,分别存储函数的第 1、2、3、4 个参数。什么?你问我如果参数超过 4 个该怎么办?如果这是操作系统课程的话,我肯定会教你将多出来的参数压栈,还会告诉你在栈中要留出前 4 个参数的虚空位置等等……但这里是我们堂堂 CO 课程!我会告诉你,从别的寄存器那里偷几个来用不就好了嘛(笑)!

还是这段比较经典的 C 语言代码:

1
2
3
4
5
6
7
int myadd(int a, int b) {
return a + b;
}

int x = 3;
int y = 5;
int sum = myadd(x, y);

于是我们就可以这样翻译:

1
2
3
4
5
6
7
8
9
10
11
12
li $s0, 3   # $s0 = x = 3
li $s1, 5 # $s1 = y = 5
move $a0, $s0 # $a0 = x
move $a1, $s1 # $a1 = y
jal myadd # $v0 = myadd(x, y)
move $t0, $v0 # $t0 = $v0
li $v0, 10
syscall # don't forget to exit!!!

myadd:
add $v0, $a0, $a1
jr $ra

像这样,我们就用非常标准的方法完成了函数的翻译!那么接下来,我们来挑战一下递归函数吧!考虑下面这个 C 语言代码:

1
2
3
4
5
6
7
8
9
10
11
12
int gcd(int a, int b) {
if (b == 0) {
return a;
}
else {
return gcd(b, a % b);
}
}

int x = 14;
int y = 21;
int res = gcd(x, y);

这是一段求两个正整数的最大公因数的代码,如果你直接套用刚才的模板,就会得到这样的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# warning: this code is wrong, don't learn this!!!
li $s0, 14 # $s0 = x = 14
li $s1, 21 # $s1 = y = 21
move $a0, $s0 # $a0 = x
move $a1, $s1 # $a1 = y
jal gcd # $v0 = gcd(x, y)
move $s2, $v0 # $s2 = $v0
li $v0, 10
syscall # don't forget to exit!!!

gcd: # gcd(a, b)
beqz $a1, zero # if b == 0 then jump to zero
div $a0, $a1 # LO = a / b, HI = a % b
move $a0, $a1 # a = b
mfhi $a1 # b = HI = a % b
jal gcd # $v0 = gcd(a, b)
jr $ra # return $v0

zero:
move $v0, $a0 # $v0 = a
jr $ra # return $v0

(再次提醒,这段代码是完全错误的,请不要学习这段代码!!!好吧虽然有点剧透了,但我还是要强调再强调)

你现在已经为自己能够完成这么复杂的一个 MIPS 程序而沾沾自喜了,对吗?很抱歉,我要泼你一盆冷水了。你可以试着运行一下这段代码,就会发现它在 jr $ra 这行指令处出现了死循环。这是为什么呢?

揭晓答案!我们知道,每当我们调用一次 jal 指令时,$ra 中都会被写入当前指令的下一条指令的地址。于是,当我们在 gcd() 函数中再次调用 gcd() 函数时,$ra 中的地址就会被新地址覆盖掉,以此类推。导致最后真正要返回时,我们只能返回到最后一次调用 gcd() 函数的位置,而没有办法实现我们预想中不断跳出的结果。如果你还是不太明白,可以试着在运行上述错误代码的同时,观察 $ra 寄存器中的值的变化,或许你会对这个问题有更直观的理解。

如果类比 C 语言的话,寄存器就像是 C 语言中的全局变量。和作用域为函数内部的局部变量不同,所有的函数都共享着同一个 $ra 寄存器,只要有一个函数修改了它的值,所有函数的返回地址都会随之改变。

那么,为了解决这个问题,我们该如何将函数的返回地址保存下来呢?这里就需要用到栈了。

观察 MARS 界面右下角的寄存器界面,我们能够找到一个名为 $sp 的寄存器,它就是栈寄存器。我们可以发现,和大多数寄存器不一样,它的初始值不是 0 ,而是 0x00002ffc

Picture Not Found!

看到这个初始值,你可能就会联想到 .data 段的范围正是 0x000000000x00002fff 。没错,栈寄存器的初始值确实与此有关,它的值实际上就是 .data 段上最后一个字(32 位)空间的首地址。

在操作系统中,栈空间其实是一个自顶向下的空间。也就是说,当我们将寄存器中的值压入栈中时,栈寄存器 $sp 的值会减少 4 ,随后将寄存器中的值写入当前 $sp 寄存器的值代表的地址中;当我们想将栈顶的数据弹出到寄存器中时,需要先将当前 $sp 寄存器的值代表的地址中的数据读取到寄存器中,再将 $sp 的值增加 4 。听起来非常拗口,实际上就是下面的过程:

$s0 寄存器为例,将其中的数据压入栈时,实际上就是执行这两条指令:

1
2
addi $sp, $sp, -4
sw $s0, 0($sp)

(注意这里的 sw $s0, 0($sp) 指令完全不等于 move $s0, $sp 指令,原因在最开始讲内存访存指令时提到过,如果你还讲不清楚它们为什么不相等的话,赶紧回去恶补一下吧!)

将栈顶的数据弹出到 $s0 寄存器,实际上就是执行这两条指令:

1
2
lw $s0, 0($sp)
addi $sp, $sp, 4

这样,我们就实现了把寄存器中的值暂存下来,防止寄存器中的值丢失的效果。那么,我们应该在什么时候压栈,又应该在什么时候弹出呢?有两种比较经典的做法:

一种做法是在调用函数之前压栈,在函数返回之后弹出,像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.macro push(%d)
addi $sp, $sp, -4
sw %d, 0($sp)
.end_macro

.macro pop(%d)
lw %d, 0($sp)
addi $sp, $sp, 4
.end_macro

loop:
......
push($ra)
jal loop
pop($ra)
......
jr $ra

另一种做法是在刚进入函数时压栈,在函数即将返回时弹出,像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.macro push(%d)
addi $sp, $sp, -4
sw %d, 0($sp)
.end_macro

.macro pop(%d)
lw %d, 0($sp)
addi $sp, $sp, 4
.end_macro

loop:
push($ra)
......
jal loop
......
pop($ra)
jr $ra

其实两种写法的作用是等价的,就像有的人喜欢饭前饭后刷牙,有的人喜欢起床后和上床前刷牙一样。我个人比较喜欢后者的写法,因为一个函数可能被调用很多次,每次调用的时候都需要在两侧压栈和弹出;而函数本体总归是只有一个,所以只需要写一次压栈和弹出即可。(这个道理放在刷牙的例子上也很直观,真是个好例子!不过真的有人饭前刷牙吗?)

不仅是 $ra 寄存器,所有的寄存器都不例外。只要递归函数中使用了某个寄存器,随后调用了自身进行递归,在调用结束返回之后继续使用这个寄存器,同样会出现刚才的数据覆盖问题。另外,在非递归函数中,我们也经常会将一些函数中用不到的数据先压入栈中,腾出寄存器的位置来,防止寄存器用光,等到函数运行结束返回之前,再从栈中弹出寄存器的值。

在向从栈中弹出的过程时,我们一定要注意栈结构先进后出的原则,千万千万不要把弹出的顺序搞反,否则整个程序就混乱不堪了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.macro push(%d)
addi $sp, $sp, -4
sw %d, 0($sp)
.end_macro

.macro pop(%d)
lw %d, 0($sp)
addi $sp, $sp, 4
.end_macro

loop:
push($ra)
push($t0)
push($t1)
......
jal loop
......
pop($t1)
pop($t0)
pop($ra)
jr $ra

所以到这里,你应该已经能驾驭递归函数了。现在试着改一改刚才求最大公因数的程序吧!真的很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
.macro push(%d)
addi $sp, $sp, -4
sw %d, 0($sp)
.end_macro

.macro pop(%d)
lw %d, 0($sp)
addi $sp, $sp, 4
.end_macro

li $s0, 14 # $s0 = x = 14
li $s1, 21 # $s1 = y = 21
move $a0, $s0 # $a0 = x
move $a1, $s1 # $a1 = y
jal gcd # $v0 = gcd(x, y)
move $s2, $v0 # $s2 = $v0
li $v0, 10
syscall # don't forget to exit!!!

gcd: # gcd(a, b)
push($ra)
beqz $a1, zero # if b == 0 then jump to zero
div $a0, $a1 # LO = a / b, HI = a % b
move $a0, $a1 # a = b
mfhi $a1 # b = HI = a % b
jal gcd # $v0 = gcd(a, b)
pop($ra)
jr $ra # return $v0

zero:
move $v0, $a0 # $v0 = a
pop($ra)
jr $ra # return $v0

2.5 MIPS 机器码

在上一节中我们已经了解到,我们学习的 MIPS 中每条指令都会以 32 位机器码的形式存储于 .text 段中,或许你也会好奇它们之间是如何转换的,下面我们就来学习一下 MIPS 的基本原理 ——

【J型指令】

我们首先来认识一种最简单的指令:J 型指令。J 型指令的特点是,指令的参数中没有任何寄存器,只有一个立即数,例如我们之前学过的 jjal 指令。(别忘了,标签和立即数在翻译成机器码时是等价的!)

J 型指令的 32 为机器码结构十分简单,一共只有两个部分:31 到 26 位代表指令的名称(例如 000010 代表 j 指令,000011 代表 jal 指令),称之为 OpCode ;25 到 0 位代表 26 位的立即数。

31..26 25..0
OpCode imm26

下面我们来通过一个例子,看 J 型 MIPS 指令是如何转换为机器码的:假设有一条地址为 0x00003004 的指令 j target ,其中 target 是一个标签,指向地址为 0x0000301c 的指令。

这条指令的机器码为 0x08000c07 。注意这是十六进制的编码,我们在将其拆分的时候一定要保持谨慎,实在不行就把它转换成二进制再拆分,也算是一种比较稳妥的方法。

首先我们来看它的高 6 位,为 0x02 ,也就是 0b000010 ,代表着 j 指令。其次是低 26 位,为 0x0000c07 ,实际上它是 0x3004 右移两位之后的结果。至于这里为什么要右移两位呢,这就涉及到 MIPS 机器码中立即数的扩展了,我们稍后会再讲到。总而言之,将上面的两部分合起来,就能够得到 32 位的机器码了!

【I型指令】

接下来我们来了解一下 I 型指令。绝大多数含有立即数的指令都是 I 型指令,我们常见的有:所有的内存访存指令(如 lb sb 等)、相对跳转指令(如 beq bne bgtz 等)和除移位运算以外的带有立即数的运算指令(如 ori addi sltiu lui 等)。简单来说就是 6 个内存访存指令,以及所有 b 家族和 i 家族的指令都是 I 型指令(当然了,sub mfhi 这种 b 和 i 就别来攀亲戚了,笑)。

I 型指令由四部分组成,31 到 26 位(OpCode)同样代表指令的名称;25 到 21 位为第一个寄存器的编号(0 到 31),称为 rs 寄存器;20 到 16 位为第二个寄存器的编号,称为 rt 寄存器;15 到 0 位代表 16 位的立即数。

31..26 25..21 20..16 15..0
OpCode rs rt imm16

这里要注意的是,虽然机器码中寄存器和立即数的顺序是这样的,但这并不意味着指令中它们的顺序也是这样。b 家族的指令谨遵指示,指令形如 beq rs, rt, labellabel 即是 imm16 ,于是 b 家族的指令和机器码的顺序一模一样;i 家族有些叛逆,指令形如 ori rt, rs, imm16 ,其中的 rtrs 位置正好相反;内存访存家族最为放荡不羁,指令形如 sw rt, imm16(rs)sw rt, label(rs) ,整个顺序都被弄得乱七八糟,真的是搞得人非常头大。

(这里打个补丁,beqbne 指令的结构确实如上述所说,但 bltz blez bgtz bgez 指令中只有一个寄存器,形如 blez rs, imm16 。其中 blez bgtz 指令的 rt 为 00000 ;而 bltz bgez 指令的 OpCode 相同,均为 000001 ,区别在于前者的 rt 为 00000 ,而后者的 rt 为 00001 。我不知道为什么要这样设计,简直太邪门了,不过还好我们以后应该不需要实现这四条指令,也不用把这件事太放在心上~)

其实有一个简单的规律,不知道你有没有发现,在 MIPS 指令的书写中,如果一条指令涉及到给某个寄存器赋值,那么这个寄存器就一定会被写在最前面,例如我们刚才提到的 i 家族指令和内存访存指令。而在 I 型指令中,如果有寄存器被赋值,那么它就必然是 rt 寄存器,rs 寄存器不具有被赋值的功能。(当然,后面的上机考试中,有些助教为了让你改造电路,可能会故意出赋值给 rs 寄存器的题)至于为什么是这样,到了我们搭建 CPU 的时候,你自然就会了解了,在这里就先按下不表吧!

下面我们还是举个例子来研究一下。我们来看 ori $s0, $s1, 19 这条指令,如果我告诉你 ori 指令的 OpCode 是 001101 ,你能写出这条指令的机器码吗?

答案是 0x36300013 ,你答对了吗?下面是转换的思路:OpCode 已经给出,是 0b001101 ;接下来是 rs 和 rt ,这里要注意在 ori 指令中,17 号寄存器 $s1 是 rs ,16 号寄存器 $s0 才是 rt ,于是 rs 就是 0b10001 ,而 rt 则是 0b10000 ;最后加上 16 位的立即数 0x0013(这里二进制太长,直接写十六进制了),将上面的所有部分按顺序拼接在一起,就得到了上面的机器码。

【R型指令】

最后就是最复杂,也是最常见的 R 型指令了。你要问我 R 型指令都是啥,我会告诉你,除了 J 型指令和 I 型指令以外,剩下的全都是 R 型指令!

R 型指令更是由惊人的六部分组成。31 到 26 位依旧是 OpCode ,但和前两种指令不一样,R 型指令并不以 OpCode 作为判断依据,我们常用的所有 R 型指令的 OpCode 值全部都是 000000 !真正决定指令种类的是 5 位到 0 位的 Funct ,它们和前两种指令的 OpCode 一样,指示着指令的种类,例如 add 指令的 Funct 为 100000jr 指令的 Funct 为 001000

R 型指令中最多可以有三个寄存器,它们分别是 25 到 21 位的 rs ,20 到 16 位的 rt ,以及 15 到 11 位的 rd 。如果哪个寄存器没被用到,相应位全部置 0 即可。于是就有了这种说法:R 型指令不一定要有 3 个寄存器,但有 3 个寄存器的指令一定是 R 型指令!

或许你会发现还有一个部分没有介绍到,那就是存在感最低的(最近几年搭 CPU 时都直接不用考虑的)10 到 6 位的 shamt ,它真的只会用在 sll sra srl 三条指令上,是一个 5 位的立即数,用于表示移位的位数,毕竟移位的位数要 0 到 31 就好。在其它指令中,这 5 位永远都是 00000

31..26 25..21 20..16 15..11 10..6 5..0
OpCode rs rt rd shamt Funct

和 I 型指令一样,R 型指令各个参数的位置也是千奇百怪,大概可以分为以下几种:

指令 格式
add addu sub subu and or xor nor slt sltu add rd, rs, rt
sll sra srl sll rd, rt, shamt
sllv srav srlv sllv rd, rt, rs
jalr jalr rd, rs
mult multu div divu mult rs, rt
mfhi mflo mthi mtlo jr mfhi rs
syscall break syscall

在 R 型指令中,只要有 rd 寄存器,那么它一定会被赋值,而 rs 和 rt 寄存器均不能被赋值。所以说,rs 寄存器一定不能被赋值,rt 寄存器不一定能不能被赋值,rd 寄存器一定会被赋值。这一点在我们后续设计 CPU 时有很大用处,我们暂且先记住一下。

下面我们就还是浅举两个例子,体会一下 R 型指令的机器码。首先我们来看 add $t0, $t1, $t2 这条指令。刚才我们起到过,add 指令的 Funct 是 100000 ,那么它的机器码是什么呢?

答案是 0x012a4020 。答对了吗?这条指令的 OpCode 是 0b000000 ,shamt 是 0b00000 ,Funct 是 0b100000 。寄存器部分,我们还是不要忘记了寄存器的顺序,按照 add rd, rs, rt 的顺序,排列好寄存器编号:rs 0b01001 ,rt 0b01010 ,rd 0b01000 ,将 6 个部分拼接起来,就能够得到这条指令的机器码了!

再举一个例子吧,观察 sll $t0, $t1, 23 这条指令,已知 sll 指令的 Funct 是 000000 ,那么它的机器码是什么呢?

答案是 0x000945c0 。这回答对了吗?sll 真的是一个很特殊的指令,它的 OpCode 和 Funct 都是 0b000000 。根据上面的顺序表格,可以知道这条指令并没有 rs 寄存器,所以 25 到 21 位为 0b00000 ;rt 寄存器是 $t1 寄存器,所以 20 到 16 位为 0b01001 ;rd 寄存器是 $t0 寄存器,所以 15 到 11 位为 0b01000 ;shamt 位是 23 ,所以 10 到 6 位为 0b10111 。将所有部分合起来即可。

【立即数的扩展】

这里我们简单探讨一下有关立即数扩展的一些小细节。在前面 J 型指令的示例中我们能够看出,机器码中的立即数并不能够直接参与到指令执行中,而是需要进行一些适当的扩展才可以。下面我们就来探讨一下 J 型指令和 I 型指令中立即数的扩展:

对于 jjal 这两条 J 型指令,它们有 26 位的立即数。那么如何用 26 位的立即数表示出 32 位的地址呢?

首先我们能够想到,一条指令在内存中的地址一定是 4 的倍数,所以为了节省位数,我们可以直接舍去 32 位地址的最低两位,因为它们一定是 0 嘛。

现在地址长度变为 30 位了,但是还是没法塞到 26 位的立即数里,该怎么办呢?只能牺牲一下跳转的范围了!于是我们只能砍掉最高的 4 位,选择用当前地址(PC 寄存器的值)的最高 4 位来代替原来的最高 4 位。

(你可能会问,如果要跳转到的地址最高 4 位和当前地址不同,该怎么跳转呢?那就只能用 jrjalr 指令将就一下了~)

于是我们就能够得到 J 型指令中跳转地址的计算方法:将 26 位立即数左移 2 位,并在高位处拼接当前地址的最高 4 位。

接下来是 I 型指令,I 型指令中的 16 位立即数又是怎么被解释成 32 位的呢?我们来分类讨论一下:

对于 i 家族的指令和内存访存指令,这个问题就比较简单了。我们只需要将 16 位立即数直接扩展到 32 位即可。当然,位扩展就一定要考虑到有无符号扩展的问题。这里就直接说结论了,在 MIPS 中,只有位运算的指令 andi ori xori 是无符号扩展,其余所有的指令对 16 位立即数都是有符号扩展(哪怕是名称里面带 u 也是!)

实际上相对跳转指令也是一样的道理,只不过在将相对条数转换为跳转地址时需要再花一步。对 16 位立即数符号扩展到 32 位,就已经得到了我们之前提到的相对条数。之后我们需要将相对条数左移 2 位,再与当前指令的下一条指令的地址(PC + 4)相加,才能够得到我们要跳转的真实地址。

【使用符号语言描述指令功能】

在本章的结尾,我们来简单了解一下如何使用符号语言来描述一条指令的功能,毕竟如果每条指令都用语言描述的话,可能很难准确地表述出来。有了符号语言之后,我们就可以快速地了解到一条指令的作用,以及它的底层逻辑了!

下面我们来认识一些基本的符号。$GPR[num]$ 表示编号 num 对应的寄存器,$Memory[addr]$ 则表示内存中以 addr 为首地址的一个字(4 字节)。左箭头 $\leftarrow$ 代表赋值,双竖线 $||$ 代表位拼接,上角标 $ 0^{32} $ 代表重复次数,下角标 $ temp_{31} $ 代表取相应位数,当取多个连续位时可以用两个点省略写作 $ temp_{15..0} $ 。$ zero _ extend() $ 表示无符号扩展,$ sign _ extend() $ 表示有符号扩展。结合一些 C 语言中常用的符号,我们就能够使用符号化的语言描述指令的功能了!

下面我们来举几个例子,直观感受一下符号语言描述的强度。首先是 sb 指令,它的符号语言是这样的:

$$ Addr \leftarrow GPR[base] + sign _ extend(offset) $$

$$ byte \leftarrow Addr_{1..0} $$

$$ memory[Addr]{7+8byte..8byte} \leftarrow GPR[rt]{7:0} $$

讲真,看起来真的挺复杂的,这也是我个人不太喜欢直接用符号语言讲指令的原因,实在是太枯燥,搞得大家都不愿意去深入研究了(摊手)。

再来感受一下 beq 指令的符号语言吧:

$$ if (GPR[rs] == GPR[rt]) $$

$$ PC \leftarrow PC + 4 + sign _ extend(offset||0^2) $$

$$ else $$

$$ PC \leftarrow PC + 4 $$

(用代码块打不了左箭头和角标,用 LaTex 打不了缩进,凑合看吧)

jal 指令:

$$ PC \leftarrow PC_{31..28}||instr _ index||0^2 $$

$$ GPR[31] \leftarrow PC + 4 $$

在课程组提供的(由 gxp 老师倾情编写的)MIPS 指令集中,你可以找到每一条指令的符号语言描述,如果学有余力的话,可以细细研究一下,对我们以后设计 CPU 时用硬件语言实现指令还是很有帮助的。在这里就不过多介绍了。

【机器码的导出】

最后,我们来介绍一下如何将指令的机器码导出为 txt 文档,这样就能实现在我们自己的 CPU 上运行指令了!在 MARS 中,我们点击左上角 File 选项中的 Dump Memory 选项,在弹出的窗口中选择要导出的段(.text 段),并选择导出的格式(一般为十六进制),确认后填写文件名和保存路径,即可将所有指令的机器导出到 txt 文件中:

Picture Not Found!

Picture Not Found!

2.6 本章结语:再见,MIPS!

我承认这一章好像有点写多了。第一章字数还控制在 2 万字,结果第二章字数直接飙升到 5 万字。下一章真的要好好控制一下了(瘫倒)。

其实在我个人看来,MIPS 这章前后割裂还是比较大的。在前期预习阶段,我们的重点往往放在使用 MIPS 进行编程,翻译 C 语言程序上。但等到真正进入 CPU 设计部分时,我们就再也用不上这些东西了,取而代之的重点则是对指令集和机器码的理解。同时,MIPS 也是三大神器里唯一一个会在期末理论考试中继续疯狂出现的家伙(到了期末你就会发现自己只会这个哈哈),所以篇幅大一点也是情有可原吧~

第三章:硬件语言 Verilog

3.1 硬件语言简介

经过了千难万险,我们终于来到入门课程的最后一关了(笑哭)!接下来我们要面对的是关底 boss Verilog 大魔王,不幸的是,它的难度超高,并且会作为我们搭建 CPU 的主要工具,我们需要跟它打交道直到实验课结束(悲)。话不多说,接受挑战吧!

这是我们第一次接触硬件语言,我觉得我还是需要给你打个预防针。无论它长得有多么像编程语言(比如 C 语言),但请记住,硬件语言就是硬件语言,不是编程语言。如果用编程语言的思维去理解硬件语言,你一定会感到非常困惑,在接下来的教程中,我也会尽力带你使用电路的思维理解硬件语言,帮助你掌握 Verilog !

说了这么多,我们还是来简单介绍一下硬件语言和 Verilog 吧。在之前的 Logisim 学习中,我们学习了如何画出一幅数字电路,那么硬件语言就是用类似于代码的形式,将数字电路表述出来。当然了,Verilog 就是这样的一门硬件语言。

和我们平时书写的代码不同,Verilog 并不是一行一行来执行的,它像是描绘了一整张电路的蓝图,没有特别严格的顺序之分,这种思想在我们最初学习之前就要构建起来。在学习的时候,我们看着 Verilog 代码,要经常往 Logisim 电路图的方向去联想,而不是往 C 语言的方向联想。还是那句话,不要用编程语言的思维去理解 Verilog ,否则你会很痛苦的(笑)!

理论上来说,在编写完 Verilog 代码,进行初步的仿真模拟之后,我们就可以在保证可综合性的前提下,制作出真实的数字电路元件来。虽然这看起来很酷,但我们毕竟不是电子信息专业的学生,在我们的课程中并没有这部分内容(悲),在接下来的教程中也不会写相关的内容(因为我也不会),感兴趣的同学可以自己了解一下。

好了,话不多说,让我们开始 Verilog 的学习吧!

3.2 ISE 简介

在学习 Verilog 之前,我们还是需要先来了解一下相关的软件。目前可用于课程中的两款软件分别是 ISE 和 VCS ,鉴于我本人一直使用的都是 ISE ,从来没用过 VCS ,接下来的讲解我们就使用 ISE 来全程进行了。

不同于前两章中小巧玲珑的 Logisim 和 MARS ,ISE 的界面极其复杂,工程文件也极其复杂,运行调试更是极其复杂!所以秉承着一切从简的原则,我只会介绍一些在以后会用得上的冰山一角的操作,保证你能够通过本学期的实验课程 ——

【创建、打开ISE工程】

Logisim 只需要一个 .circ 文件即可运行,MARS 也只需要一个 .asm 文件,然而 ISE 并非如此。在 ISE 中,最顶层的单位是工程(Project),一个工程内可能只有一个 Verilog 代码文件(.v 文件),也可能有很多个互相联系的 Verilog 代码文件。除此之外,一个工程内还有着各种各样数不胜数的辅助文件,所以我们在创建代码文件之前,要先创建一个工程。

Picture Not Found!

要创建一个工程,首先我们要打开 ISE(这不废话吗),在下载安装完毕后,桌面上会留有一个 ISE 的快捷方式,我们双击进入即可。

在打开 ISE 后,我们在 File 选项中选择 New Project 选项(不是下面那个 New !),进入创建工程界面:

Picture Not Found!

在弹出的界面中,我们输入整个工程的名字,并更改工程的路径:

Picture Not Found!

接下来,一路 Next 到底,最后点击 Finish ,我们就创建好一个新工程了!

Picture Not Found!

此时在我们刚才设定的路径下,我们就能够找到工程对应的文件夹了,其中的 .xise 文件就是我们整个 ISE 工程的文件。我们双击这个文件,就可以打开整个 ISE 工程。或者在刚才打开 ISE 时界面左上角 File 选项的 Open Project 选项(不是 Open 选项!)中选择 .xise 文件,也可以再次打开整个 ISE 工程:

Picture Not Found!

Picture Not Found!

【创建、导入Verilog文件】

在创建好工程之后,我们就要在工程内部创建 Verilog 文件了!我们首先右键左上角的工程名,选择 New Source :

Picture Not Found!

在弹出的界面中,我们选择 Verilog Module ,输入文件名,点击 Next :

Picture Not Found!

在下一个界面中,我们需要定义 Verilog 文件的输入输出端口,这一步我们在之后再学习,可以先跳过,点击 Next 即可:

Picture Not Found!

再次点击 Finish ,我们就进入了编写代码的界面,可以看到在左上角已经出现了我们当前的 Verilog 文件:

Picture Not Found!

接下来,我们在右侧的 module 和 endmodule 之间编写 Verilog 代码就可以了!

有的时候,我们需要将已经写好的 Verilog 文件导入到我们的工程中,比如 P4 以后的上机考试,或者 P7 的官方包等等。这时就需要我们右键左侧的工程名,选择 Add Source 或 Add Copy Of Source 选项,添加 .v 后缀的文件,在弹出的界面中直接点击 OK 即可:

Picture Not Found!

两者的区别顾名思义,使用 Add Copy Of Source 选项会在当前工程的文件夹下创建一个相同的 .v 文件,而使用 Add Source 选项则并不会。下面我用两种方法分别从其它文件夹中导入 ALU.v 和 CP0.v 两个文件,导入后的结果如下图所示:

Picture Not Found!

Picture Not Found!

可以看到,两个 .v 文件都成功加入到了工程中,我们双击左侧的文件名就可以查看它们,但在工程所在的文件夹中,只有使用 Add Copy Of Source 导入的 ALU.v 文件出现在了文件夹中,而使用 Add Source 导入的 CP0.v 文件并没有出现。我们在编辑界面的左侧右键 .v 文件,在 File/Path Display 中选择 Full Paths 选项,就可以看到每个 .v 文件的位置:

Picture Not Found!

可以看到 ALU.v 文件的位置就在我们当前工程的文件夹下,而 CP0.v 文件的位置还在复制前的文件夹下。我个人还是非常建议使用 Add Copy Of Source 进行导入,这样也便于我们在写完代码后把所有 .v 文件打包成压缩包。

顺带一提,导入时是可以一次性选中很多文件同时导入的,上机考试到了后面一次性可能要导入十好几个文件,不要一个一个导入,把考试时间都浪费掉了!

3.3 Verilog 语法初步:常量、变量与运算

【Verilog中的常量】

在这一节中,我们要学习一下 Verilog 中最基础的语法:常量与变量。回想一下,在 Logisim 中,所有的数据都是以各种位宽的二进制形式,在导线中传输的,在 Verilog 中也是一样。

在定义一个常量的时候,我们需要指定它的位宽和进制,以 位宽 + 单引号 + 进制 + 数字 的方式来表示。在 Verilog 中,我们可以使用四种进制来表示常量:用 bB 表示二进制,用 oO 表示八进制,用 dD 表示十进制,用 hH 表示十六进制。(不过我们往往都是用二进制)

比如 5'b10101 表示 5 位二进制数 0b10101 ,转换成十进制就是 0d2124'habcdef 表示 24 位十六进制数 0xabcdef 。注意这里的位宽指的都是二进制下的位宽!

如果指定的位宽小于实际位宽,常量就会被截断,只保留最低几位,比如 2'b10101 实际上和 2'b01 是相同的;如果指定的位宽大于实际位宽,常量会被无符号扩展,最高位用 0 补齐,比如 8'b10101 实际上和 8'b00010101 是相同的;如果不指定位宽,位宽默认为 32 位。不过我是强烈建议时刻保持位宽对齐,且不要省略位宽的。Veilog 本身就很难 debug ,所以写代码的时候一定是越严谨越好!

关于常量,我们再讲几个特殊的用法。在 Verilog 中,我们可以在常量的数字的中间加上下划线,它本身并没有任何作用,只是为了好看,比如 32'hffff_ffff 。此外,我们还可以在整个常量的前面加上减号来表示负数,比如 -5'b01011 就等于 5'b10101 ,不过实战上一般不太会用到这种写法。

此外,常量不仅可以由 0 和 1 构成,其中还有可能出现字母 x 和 z ,例如 1xx01 或者 zzzzz ,出现这种情况往往就是因为程序写错了。字母 x 表示不定值,也就是此时该位既有可能等于 0 也有可能等于 1 ,这种情况往往出现在多重赋值错误的时候,比如你既令 a = 5'b10101 ,又令 a = 5'b11001 ,那么 a 的值就会显示为 5'b1xx01 。字母 z 表示悬挂,当你没有给一个变量设置输入端的时候就会出现,这种 bug 非常好解决,肯定是因为忘记连线或者连错线了导致的,随便找一找就能找到。

在 Verilog 中还有一种特殊的常量 —— 字符串。我们在上一章也学过,使用二进制表示常见字符,最好的方法就是使用 8 位二进制数表示字符的 ASCII 码,在 Verilog 中也是一样。我们使用双引号夹住字符串的两端,即可表示一个字符串常量,如 "Kamonto" ,这个字符串的位宽就是 7 * 8 = 56 位,等价于 56'h4b_61_6d_6f_6e_74_6f。在使用时,字符串常量和普通常量也是没有任何区别的(因为本质上都是二进制数字嘛)。

【Verilog中的变量】

在这一部分,我们将会简单介绍 Verilog 中最基本的两种变量 —— wire 型变量和 reg 型变量的定义与调用。

作为两种不同的变量,wire 型变量和 reg 型变量当然有本质上的区别,我们在后续组合逻辑和时序逻辑部分会详细讲解。不过我们目前还不需要太在意它们的区别,在这一部分中我们要学习的两种变量的定义和调用,对于两种变量来说都是相同的,大家可以先放心学习~

和常量相同,我们在定义 wire 和 reg 型变量时也需要指定位宽,使用 wire/reg [最左侧位数:最右侧位数] 变量名 的格式来进行定义,这里位宽的大小由 最左侧位数 和 最右侧位数 来决定。

Verilog 中变量位数的定义可谓是非常自由。在正常情况下,我们认为一个 5 位二进制数的最左侧位数为最高位第 4 位,最右侧位数为最低位第 0 位,那么在定义这样的一个 wire 型变量 wire1 时我们就写为 wire [4:0] wire1

在调用 wire 和 reg 型变量时,我们可以直接使用它们的变量名来调用,比如我们在后面组合逻辑中会学到的语句 assign wire1 = wire2 。除此之外,我们还可以使用中括号来调用变量中的部分位,比如单位 assign wire1[4] = wire2[0] 和多位 assign wire1[3:2] = wire2[1:0]

然而,你也可以不按套路出牌,比如你可以非要定义最左侧位数为第 114 位,最右侧位数为第 110 位,这也是可以的,写成 wire [114:110] wire1 即可。不过装 X 是有代价的,比如在后续调用 wire1 的最低位时,就不能使用 wire1[0] 来调用了,而是要使用 wire1[110] 来调用,所以在整个代码编写的全程中都要记得 wire1 变量的定义,一旦忘了回头再改起来真够喝一壶的。

如果还想制造更多的混乱,你甚至还可以调转枪口,让左侧原本是最高位的位数变为第 0 位,右侧原本是最低位的位数变为第 4 位,写成 wire [0:4] wire1 。如果你是和别人合作完成一份 Verilog 代码,写成这样晚上千万别睡太死!(笑)

所以我还是不建议大家整花活的,老老实实写成 wire [4:0] wire1 的格式,对大家都好~

Verilog 中也可以定义 wire 和 reg 型变量的数组,同样是使用中括号,但是这次是在变量名的后面,例如 reg [4:0] reg1 [0:255] ,就是一个含有 256 个 5 位 reg 型变量的数组 reg1 了。

为了让数组更符合从下标 0 开始向后填充的习惯,我们一般在定义数组时将 0 写在中括号的左边,当然这只是个人建议,你也可以根据自己的喜好 DIY 一下,不过当然是最好形成一个统一的个人风格,避免徒增混乱。

我们不仅可以定义一维数组,还可以定义任意 n 维数组,比如 4 维数组 reg [4:0] reg2 [0:3][0:63][0:15][0:255] ,完全没有任何问题!

在调用数组中的元素时,我们还是采用中括号的形式,例如 reg1[127] 代表 reg1 数组中第 128 个元素(毕竟是从 0 开始的嘛),reg2[1][2][3][4][3:2] 代表 reg2 数组中对应元素的第 [3:2] 位。

【Verilog中的运算:位运算(1)】

在学习 Verilog 中的运算之前,我还想强调一下我个人的思想,那就是时刻注意保持位宽的一致!实际上,位宽不一致并不会引发报错,Verilog 会自动进行位宽的调整。但这种隐式的扩展和截断是非常危险的,它有着非常复杂的规则,包括扩展方式和优先级。我们没有办法保证这种隐式的调整永远按照我们自己认为的方式进行,所以如果你愿意听我的,相信我能帮你减少程序中的 bug ,那就不要进行任何位宽不一致的操作!接下来的教程中,我也不会花大量的笔墨去介绍运算时位宽不一致时 Verilog 的处理情况。好了,我们正式开始吧!

首先我们还是从最简单的位运算开始,在 Verilog 中,位运算的符号和 C 语言是相同的。& 代表按位与,| 代表按位或,^ 代表按位异或,~ 代表按位取反。例如 5'b10101 & 5'b11001 的值应该等于 5'b100015'b10101 | 5'b11001 的值应该等于 5'b111015'b10101 ^ 5'b11001 的值应该等于 5'b01100~5'b10101 的值应该等于 5'b01010

此外,& | ^ 也可以作为单目运算符,直接作用于一个常量或变量之前,表示将常量或变量的每一位都 按位与 / 或 / 异或 在一起,最终的结果是一个 1 位的常量,例如 &5'b10101 的值是 1'b0|5'b10101 的值是 1'b1^5'b10101 的值是 1'b1

【Verilog中的运算:四则运算】

接下来,我们来谈谈四则运算。说是四则运算,其实是 6 个运算符:加 + 、减 - 、乘 * 、整除 / 、取余 % 和幂次 ** 。这年头,有幂次运算符的语言真是不多,你说是不是,Python ?

使用方法上来说应该没什么新奇的,就是我们熟得不能再熟的 5'b10101 + 5'b11001 5'b10101 * 5'b11001 这样,然而最大的问题还是那个我们老生常谈的 —— 有无符号问题。例如 5'b11111 + 5'b00001 到底是等于 6'b100000(无符号 31 + 1 = 32 ),还是应该等于 6'b000000(有符号 -1 + 1 = 0 )?或者说 5'b11001 / 5'b00011 到底是等于 5'b01000(无符号 25 / 3 = 8 ),还是应该等于 5'b11110(有符号 -7 / 3 = -2 )?接下来让我们来探讨一下:

首先是无符号计算。在 Verilog 中,所有的常量和变量默认都是无符号的,在运算时也默认遵循无符号的运算规则。所以如果我们想进行无符号运算,那就想怎么写就怎么写,敞开了写,放飞自我地写,结果一定是无符号的。

但是反过来讲,既然无符号运算写得这么爽,有符号运算就要有大麻烦了。没错,在 Verilog 里进行一次有符号的运算,难度不亚于和班尼特·福迪一起攻克难关。不过只要你按照我接下来的步骤来,就可以大大降低难度!

首先我们要认识一个神奇的标识符 $signed() ,被它包括的变量和常量会被理解为有符号,例如 $signed(5'b11111) 就会被理解为 -1 ,而不是 31 。注意,我们尽量不要用它包括一整个表达式,例如 $signed(5'b11111 + 5'b00001) ,不是说这样做不可以,不过要想提高正确率,听我的,不要这么做!

其次,我们要保证参与运算的两个常量或变量全部都被 $signed() 包括,即便其中一个是正数。例如想表达 -1 + 1 ,那么 $signed(5'b11111) + $signed(5'b00001) 是正确的,$signed(5'b11111) + 5'b00001 是错误的!

说起这件事,这全都要归功于 Verilog 中有符号运算奇葩的“传染原则”。但凡整个表达式中有任何一个部分是无符号的,整个表达式都会被传染为无符号运算。整个“传染规则”非常复杂,助教写的指导书上有,我实在不记得了,我也没权限看指导书,这里就不讲了。总之,只要稍有不慎,你的整个表达式中的所有运算一下子就会全都变成无符号运算(笑哭)!

于是,为了避免我们所有的努力功亏一篑,我们在进行有符号计算时,也尽量不要写一长串表达式,而是分步进行运算,保证每一步都只有 z = x + y z = x * y 这种形式。这样既能防止整个表达式中所有有符号运算都变为无符号运算,也能在出现问题的时候快速 debug 出问题所在。

按照上面的三步进行运算,我们就能用最稳妥的方式拿捏有符号运算了!希望能对你有帮助!

【Verilog中的运算:位运算(2)】

移位运算日常被开除位运算籍,每次都不能跟着大部队,还要在后面打补丁再讲,关键是它也要涉及到有无符号的问题,这就很烦。

这么长时间以来的经验告诉我们,左移是只有一种的,右移是有逻辑和算术两种的。在 Verilog 中,左移符号就是我们熟悉的 << ,逻辑右移是两个大于号 >> ,算术右移则是三个大于号 >>>

左移和逻辑右移两个操作都没什么问题,毕竟无符号运算,像 32'h0000ffff << 5'b10000 32'hffff0000 >> 5'b10000 这么一写就完事了。然而算术右移就麻烦了,你不会以为像 32'hffff0000 >>> 5'b10000 这样一写结果就等于 32'hffffffff 了吧?那你实在是小瞧 Verilog 了!它的结果仍然等于 32'h0000ffff

究其原因,在 Verilog 中使用算术右移 >>> 时,被移位的数字即使最高位是 1 ,也不能说明是负数,依然会被认为是无符号数,在右移时前面补 0 。正确的做法是要将被移位的数字用 $signed() 包括起来!不过在算术右移中,>>> 后面的数字即使不加 $signed() 也可以,像 $signed(32'hffff0000) >>> 5'b10000 这样,结果就等于 32'hffffffff 了!

(当然了,如果你用的是逻辑右移 >> ,无论加多少个 $signed() 都是高位补 0 ,没用的~)

【Verilog中的运算:比较运算、逻辑运算】

在 Verilog 中,我们可以使用 < > <= >= 符号来比较两个常量或变量的大小。当然,这肯定也会涉及到有无符号的问题。按照我们前面提到的规则,如果想进行有符号比较的话,就需要将比较符号两侧的常量或变量全部加上 $signed() ,例如 $signed(5'b11111) > $signed(5'b00001) ,认为是有符号比较的 -1 > 1 ,结果应该是表示否定的 1'b0 ;而 $signed(5'b11111) > 5'b00001 ,认为是无符号比较的 31 > 1 ,结果应该是表示肯定的 1'b1

如果要比较两个常量或变量是否相等,我们一般会用到 ==!= 。然而,除了它们两个之外,Verilog 中还有另外两个比较是否相等的符号 ===!== 。那么这两组比较符号到底有什么区别呢?

它们的区别在于,当 ==!= 的两端中出现 xz 时,若没有办法判断两端的值是否相等,结果就为不定值 1'bx 。为什么这个时候有可能判断不出两端的值是否相等呢?因为 xz 代表的是不定值,既有可能是 0 ,也有可能是 1 。例如当我们比较 3'b01x3'b011 的时候,3'b01x 既有可能是不相等的 3'b010 ,也有可能是相等的 3'b011 ,所以判断不出两者是否相等。或者比较 3'bzzz3'bzzz ,两者看似一模一样,但它们代表的值可能是不同的,例如前一个 3'bzzz 可能表示 3'b000 ,后一个 3'bzzz 可能表示 3'b111 ,所以依然不能判断出两者是否相同。不过也还是有能够判断出的情况,例如 3'b01x3'b111 这种情况,无论 x 代表的是 0 还是 1 ,两者不可能是相等的,这是就可以直接返回不相等了。

===!=== 就比较简单粗暴了,这两个比较符号会直接将 xz 也进行比较,只要两侧的 xz 也能够对应,也认为左右两边是相等的,反之则直接认为不相等。也就是说,对于这两个运算符号来说,就只有相等和不相等两种情况,绝不存在判断不出的中立地带。例如刚才我们提到的 3'b01x3'b011 ,在它们看来直接就是不相等;3'bzzz3'bzzz ,在它们这里就是完全相等的。真是相当爽快!

你可能会觉得,相等比较就肯定不会出现有无符号比较的问题了吧?非常可惜,并非如此。当等号或不等号左右两侧的位宽相同时,确实不会出现有无符号的问题,不过当两侧的位宽不同时,纷争就出现了。

在 Verilog 中,无论是双等号、双不等号还是三等号、三不等号,都只会比较两侧值的大小,而不会比较它们的位宽。例如 3'b0015'b00001 ,无论用哪种比较方法,结果都是相等。这在正数这里还好,到了负数就出问题了:3'b1115'b11111 到底相不相等?在无符号比较下,两者一个是 7 ,一个是 31 ,显然不相等;但在有符号比较下,两者都是 -1 ,又相等了。

所以在这种情况下,要想实现有符号的比较,让这两个值相等,我们还需要在两侧都加上 $signed() ,写成 $signed(3'b111)$signed(5'b11111) ,这样就可以了。

在 Verilog 中,我们还可以用 && || ! 三个逻辑运算符号来将多个比较的结果组合在一起,使用的方法和 C 语言是一样的,在这里就不多介绍了。

【Verilog中的运算:位拼接】

终于,我们要遇到一个不同于 C 语言的符号了!使用大括号 {} ,我们可以将几个常量或变量直接拼接在一起,像 {5'b10101, 3'b111, 1'b0} 这样,结果就是 9'b101011110

当然,位拼接符号还有进阶玩法,在大括号的左边写上数字,外面再套一层大括号,就可以重复大括号中的内容相应的次数,例如 {8{1'b1}} 就是 8'b11111111{4{2'b10}} 就是 8'b10101010{2{2'b10, 2'b11}} 就是 8'b10111011{{4{1'b1}}, 4'b0101} 就是 8'11110101 。想嵌套多少层都可以!不过千万不要忘了,如果大括号前面加了重复次数,那就需要再套一层大括号,否则会直接报错哦~

对了,还要强调一点,在位拼接中,我们一定要写清楚每个常量的位宽,因为如果不写位宽的话,会被默认为 32 位处理。你以为 {2{1}}2'b11 ,实际上却是 64'h0000000100000001 ,真是太离谱了!

【Verilog中的运算:三目运算符】

最后,我们来讲一下我们在 C 语言中经常被忽视掉的三目运算符。我猜你肯定听过这个运算符,但可能基本就没用过,因为它和 if - else 的用法真的是别无二致。但是不要急,你马上就会(不得不)爱上它的!

三目运算符 ?: 的作用是,当问号前面的条件成立时,整个表达式的值为冒号前面的值,否则为冒号后面的值。例如 (5'b11111 == 5'b11111) ? 3'b111 : 3'b000 ,它的值就是 3'b111 ;或者 (5'b11111 == 5'b00000) ? 3'b111 : 3'b000 ,它的值就是 3'b000

值得注意的是,三目运算符冒号前后的选项,同样不会将无符号传染给问号前面的判断,所以如果想实现有符号比较作为判断依据,直接写成 ($signed(5'b11111) > $signed(5'b00001)) ? 3'b111 : 3'b000 这样就可以了!

3.4 Verilog 语法初步:组合逻辑与时序逻辑

【Verilog中的组合逻辑】

在 Verilog 中,最眉清目秀的变量就是 wire 类型了。有些同学一直不理解 wire 型变量,我一直不理解为什么有些同学会一直不理解 wire 型变量(笑)。

无论别的教程怎么教你,但我告诉你,wire 型变量就是导线!(不然它为什么叫 wire 型)wire 型变量的值就是导线内部的值!例如下图中就是一根 5 位的导线 wire1 ,它的值是 0x15 。用 Verilog 语言书写就是 wire [4:0] wire1 ,它的值是 5'b10101

Picture Not Found!

在 Logisim 一章中,我们已经学习过了组合逻辑和时序逻辑。在组合逻辑中,我们的电路就是由许多导线和元件组合而成的。在 Verilog 中,我们使用 wire 型变量来代替导线,使用运算符号来代替逻辑门等电子元件。接下来我们就将它们组合起来,一步一步来学习如何描述出一个完整的电路!

首先我们来学习如何建立起两条导线(wire 型变量)之间的关系。我们来观察下图中的电路,导线 wire2wire3 接在导线 wire1 的尾端,我们应该如何表示它们三者之间的关系呢?

Picture Not Found!

针对上述情况,我们可以使用阻塞赋值来完成。首先我们肯定要定义一下 wire1 wire2 wire3 这三条导线,接下来我们就要使用 assign 语句来描述导线之间的关系:assign wire2 = wire1; assign wire3 = wire1; 。总的来说就像下面这样:

1
2
3
4
5
6
wire [4:0] wire1;
wire [4:0] wire2;
wire [4:0] wire3;

assign wire2 = wire1;
assign wire3 = wire1;

assign 语句表示导线的连接关系,等号前的导线(wire2 wire3)连接在等号后的导线(wire1)之后,等号后的导线(wire1)将自身内部的数据传输给等号前的导线(wire2 wire3)。

注意这里的等号两侧是不能交换的,因为这张图中 wire1 将自己内部的数据 0x1f 传递给 wire2wire3 ,它们之间是有输入输出的关系的,如果反过来写成 wire2wire3 将自己内部的数据传给 wire1 ,这显然就是不对的。

当然了,像图中的这种写法并没有什么意义,实际写 Verilog 程序时,这三条导线都可以是 wire1 ,不需要赋值来赋值去的。我们真正需要用到 assign 语句时,往往几条导线之间是有电子元件的,比如下面这种情况:

Picture Not Found!

这种情况中,我们该如何描述三条导线之间的关系呢?答案如下:

1
2
3
4
5
wire [4:0] wire1;
wire [4:0] wire2;
wire [4:0] wire3;

assign wire3 = wire1 & wire2;

看起来非常简单,我们再练一个:

Picture Not Found!
(怕你看不清,特意帮你标注一下分割出来的位数)

还记得我们之前学过的中括号吗?把它用起来吧!答案如下:

1
2
3
4
5
6
wire [4:0] wire1;
wire [2:0] wire2;
wire [1:0] wire3; // 如果喜欢的话,定义成 wire [4:3] wire3 也不是不行

assign wire2 = wire1[2:0];
assign wire3 = wire1[4:3];

这里要注意的是,我们在定义时就规定位数是左高右低,在赋值的时候千万不要写反了!

接下来我们再来玩一点有意思的,下面这个如何:

Picture Not Found!

这个看起来好像要用 if 来实现了,但是 assign 语句中是不允许使用 if 的!那么有没有什么平替呢?这时候就要请出你可能在 C 语言编程中从来都没有用过的三目运算符了:(没有平替的话就算了);

1
2
3
4
5
6
wire [4:0] wire1;
wire [4:0] wire2;
wire sel;
wire [4:0] wire3;

assign wire3 = (sel == 1'b0) ? wire1 : wire2;

(我好像有点太幽默了,有人能 get 到我的笑点吗)

你可能不知道的是,我们还可以通过多个三目运算符叠罗汉的方式来模拟 if - else if - else 语句:

1
2
3
4
5
6
7
8
9
10
wire [4:0] wire1;
wire [4:0] wire2;
wire [4:0] wire3;
wire [4:0] wire4;
wire sel;
wire [4:0] res;

assign wire3 = (sel == 2'b00) ? wire1 :
(sel == 2'b01) ? wire2 :
(sel == 2'b10) ? wire3 : wire4;

(这种写法最后一个冒号后面是不能为空的,也就是说必须要有 else )

这种写法虽然还挺美观,但是在其它语言中一般会被归类为神人写法,鉴定为学 Verilog 学的。

再来试试位扩展,下面这幅图片是无符号扩展,将 5 位数据扩展为 16 位数据:

Picture Not Found!

当然,你可以使用位宽的隐式转换:

1
2
3
4
wire [4:0] wire1;
wire [15:0] wire2;

assign wire2 = wire1;

不过我还是建议你使用更保险的方式:

1
2
3
4
wire [4:0] wire1;
wire [15:0] wire2;

assign wire2 = {{11{1'b0}}, wire1};

怕你不记得,{{11{1'b0}}, wire1} 的意思是在 wire1 的前面拼接 11 个 1'b0

无符号扩展还是比较简单的,那么有符号的呢?看看这个:

1
2
3
4
wire [4:0] wire1;
wire [15:0] wire2;

assign wire2 = (wire1[4] == 1'b0) ? {{11{1'b0}}, wire1} : {{11{1'b1}}, wire1};

或者直接:

1
2
3
4
wire [4:0] wire1;
wire [15:0] wire2;

assign wire2 = {{11{wire[4]}}, wire1};

两种方法都是可以的。怎么样,是不是感觉自己的水平又上升了一个层次?

在之后你可以尝试一下 Logisim 中其它的电子元件,理论上来说所有的电子元件都可以像上面这样,能够表示出它们两端的导线之间的关系。多试一试,总没有坏处的!

相信你已经掌握了如何用 Verilog 描述出最基本的导线关系了,下面来挑战下这个吧:

Picture Not Found!
(这个图是 P0 往年题里面一道压轴题的图,不过我把其中的一些细节改掉了,大家千万不要照这个抄,抄了也做不对,而且会被助教的大手制裁)
(如果你照着这个图画了然后交了,哪怕后来交了别的上去,只要有提交记录就会记录在案,然后就会和我当年交过的图进行比对,然后就会喜提 5 号实验室小黑屋,哈哈)

包括 wire1wire2 ,这幅图里有 19 条严格意义上完全不同的导线,如果要是一条一条去定义的话,显然就有点复杂了。不过要记住,我们的 Verilog 是支持连续运算的,所以我们可以省略掉其中的一部分导线,只定义过程中的几条重要的、会反复用到的导线即可,像下图中这样:

Picture Not Found!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
wire [15:0] wire1;
wire [31:0] wire2;
wire [31:0] wire3;
wire [4:0] wire4;
wire [4:0] wire5;
wire [31:0] wire6;
wire wire7;

assign wire2 = (wire7 == 1'b0) ? 32'h00000000 :
(wire1[15] == 1'b0) ? wire6 : (32'h00000000 + wire6);
assign wire3 = {{21{1'b0}}, 1'b1, wire1[9:0]};
assign wire4 = wire5 - 5'b01111;
assign wire5 = wire1[14:10];
assign wire6 = (wire4 < 5'b01010) ? (wire3 >> (5'b01011 - wire4)) : (wire3 << (wire4 - 5'b01011));
assign wire7 = (5'b01111 > wire5) & ~(5'b11111 > wire5);

这里也可以看出我们的 assign 赋值是没有先后顺序的。在没有给 wire6 wire7 赋值之前,我们就用到了它们来给 wire2 赋值,这在编程语言中是万万不可能的。

到这里我们已经学完组合逻辑的全部内容了,是不是还挺简单的?相信我,跟着我来学,下一节的时序逻辑也不会太难的!加油吧!

【Verilog中的时序逻辑:initial块、always块】

如果说组合逻辑的核心是 wire 型变量,那么时序逻辑的核心就是 reg 型变量了。在上一部分中我们提到,wire 型变量其实对应的就是数字电路中的导线,那么 reg 型变量对应着数字电路中的什么呢?或许你已经猜到了,就是寄存器(register)!

首先我们来学习为 reg 型变量赋初始值。不同于 Logisim 中为寄存器赋初始值时需要用到一些神奇的妙妙方法,Verilog 中本身就自带这样的语法,使用 initial 块即可轻松做到:

1
2
3
4
5
reg [4:0] reg1;

initial begin
reg1 = 5'b10101;
end

你肯定发现了,Verilog 中并不是用一对大括号来表示代码块,而是在原来左大括号的位置使用 begin ,在原来有大括号的位置使用 end 。我猜或许是为了与拼接符的大括号区别开,但我觉得实在是特别难受的设计(悲)。

接下来我们来学习一下 reg 型变量的基础用法。下面这幅图中展示了一个最基本的寄存器电路:寄存器 reg1 在每个 clk 的上升沿读取 wire1 的值,并将自身的值实时传递给 wire2

Picture Not Found!

如果你对这个电路还有疑惑的话,那就要回到 Logisim 部分好好补补课了!你可以在 Logisim 中搭出这样一个电路,好好观察一下它的行为。

回到 Verilog ,我们应该怎么用 Verilog 语言描述出这个电路呢?我们看下面一段代码:

1
2
3
4
5
6
7
8
9
10
wire clk;
wire [4:0] wire1;
wire [4:0] wire2;
reg [4:0] reg1;

always @(posedge clk) begin
reg1 <= wire1;
end

assign wire2 = reg1;

看起来还挺复杂,我们一步一步来进行分析:

首先是定义部分,不用多说了,除了寄存器 reg1 以外,三条导线都是 wire 类型。

接下来是中间的 always 块。always @(...) 是一种固定写法,括号中间的内容是 always 块的触发条件,也就是一旦出现了括号中的内容,always 块中的代码就会执行一次。

posedge clk 表示的就是 clk 处于上升沿,反之 negedge clk 表示的就是 clk 处于下降沿。所以 always @(posedge clk) 的意思就是每当 clk 处于上升沿,就执行一次 always 块中的内容,也就是 reg1 <= wire1

这里的赋值也比较奇怪,不是等号,而是小于等于号。在 always 块中,我们往往都使用非阻塞赋值符号 <= 来进行赋值。它与阻塞赋值符号 = 的区别我们也不需要太了解,毕竟我们也不是专业学硬件的选手。我们只需要让它们各司其职,在 always 块里注意用 <= ,在其它时候用 = 就完全没问题!

最后的 assign wire2 = reg1 使用的是 assign 语句,而不是和前面一样在 always 块中,这是为什么呢?我们仔细来想一想,将 reg1 中的值赋值给 wire2 这个动作是只有在 clk 上升沿才会发生的吗?其实并非如此。无论何时,只要 reg1 的值发生了变化,wire2 的值都会随之改变,不信你可以在 Logisim 中搭出上面图中的电路,尝试手动改变寄存器 reg1 的值,看看 wire2 的值如何变化。

接下来我们尝试一下简单的自动机。我们应当如何将下面这幅图片转换为 Verilog 语言呢?

Picture Not Found!

答案是这样的,你答对了吗:

1
2
3
4
5
6
7
8
9
10
11
wire clk;
wire [4:0] wire1;
wire [4:0] wire2;
reg [4:0] reg1;

always @(posedge clk) begin
reg1 <= reg1 + 5'b00001; // 或者 reg1 <= wire1
end

assign wire1 = wire2 + 5'b00001;
assign wire2 = reg1;

和 Logisim 中寄存器可以将自己的输出端接回自己的输入端一样,Verilog 中的 reg 型变量也可以自己赋值给自己,这样是完全没问题的。不过 wire 型变量肯定是不行的,如果给一个 wire 型变量 wire1 写成 assign wire1 = wire1 + 5'b00001 ,那代码绝对过不了编译的!

Verilog 支持在同一个 always 块中给多个 reg 型变量赋值,也接受多个寄存器串联在一起的形式,就像这样:

Picture Not Found!

写成 Verilog 代码就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
wire clk;
wire [4:0] wire1;
wire [4:0] wire2;
wire [4:0] wire3;
wire [4:0] wire4;
reg [4:0] reg1;
reg [4:0] reg2;
reg [4:0] reg3;

always @(posedge clk) begin
reg1 <= wire1;
reg2 <= reg1;
reg3 <= wire3;
end

assign wire2 = reg2;
assign wire4 = reg3;

从这里我们也可以发现一些非阻塞赋值符号的特点,那就是非阻塞赋值符号 <= 的赋值规则和寄存器是相同的。在上面的例子中,当 clk 处于上升沿时,执行了 reg1 <= wire1reg2 <= reg1 这样两个赋值。如果从编程语言的角度来看,我们先将 wire1 的值赋给了 reg1 ,又将 reg1 的值赋给了 reg2 ,那么此时三者的值应该是相等的。

然而事实并非是这样,我们在 Logisim 中模拟一下上面的电路就可以很容易知道,此时 wire1reg1 的值是相等的,但 reg2 的值却不一定与它们相等,而是等于上一个周期 reg1 的值。这是因为上面的两次赋值并不是先后进行的,而是同时进行的。也就是说,在 wire1 将值赋给 reg1 的同时,还没有接收到新值的 reg1 就已经将自身的值赋给了 reg2

当然,这也就呼应了非阻塞赋值的名字,上面代码中 always 块中的 3 条赋值语句之间并没有谁先谁后的阻塞关系,它们都是在 clk 到达上升沿的一瞬间同时进行的。

【Verilog中的时序逻辑:if-else语句、case语句】

有的时候,当时钟到达上升沿时,寄存器作出的数据更新并不一定总是相同,而是受到其它变量的控制。比如同步复位,假如有一个 wire 型 1 位变量 reset 。当时钟 clk 到达上升沿时,若 reset 为 0 ,则寄存器正常更新(比如自增 1);若 reset 为 1 ,则寄存器的值归零。这时就可以使用 if 语句了!是的,你没有听错,在 always 块里,if 语句又复活了!

1
2
3
4
5
6
7
8
9
10
11
12
wire clk;
wire reset;
reg [4:0] reg1;

always @(posedge clk) begin
if (reset) begin
reg1 <= 5'b00000;
end
else begin
reg1 <= reg1 + 5'b00001;
end
end

除了使用 if - else 语句外,在 always 块中,我们还可以使用 case 语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
wire clk;
wire reset;
reg [4:0] reg1;

always @(posedge clk) begin
case (reset)
1'b0: begin
reg1 <= 5'b00000;
end
1'b1: begin
reg1 <= reg1 + 5'b00001;
end
endcase
end

case 语句的长相就有点奇葩了,和 C 语言中的 switch - case 语句不同,大家一定要看清细节之后再用,尤其不要忘记了结尾的 endcase !另外,Verilog 中的 case 语句是不需要 break 的,执行完一个分支直接就会跳出。

当然,上述这些用法都是可以混用的,所以你可以在 if - else 语句里面套 case 语句,然后再在里面套 if - else 语句,这种用法还是很常见的,在我们之后写有限状态机会经常用到。比如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
always@(posedge clk) begin
if (reset) begin
status <= 4'h1;
value <= 32'h00000001;
end
else begin
case(status)
4'h0: begin
if (in == " ") begin
status <= 4'h1;
end
else begin
status <= 4'h0;
end
end
4'h1: begin
if (in == "b") begin
status <= 4'h2;
end
else if (in == "e") begin
status <= 4'h3;
end
else if (in == " ") begin
status <= 4'h1;
end
else begin
status <= 4'h0;
end
end
4'h2: begin
if (in == "e") begin
status <= 4'h3;
end
else begin
status <= 4'h0;
end
end
4'h3: begin
if (in == "g") begin
status <= 4'h4;
end
else begin
status <= 4'h0;
end
end
4'h4: begin
if (in == "d") begin
if (value != 32'h00000000) begin
value <= value - 32'h00000001;
status <= 4'h0;
end
else begin
status <= 4'h0;
end
end
else begin
status <= 4'h0;
end
end
endcase
end
end

(这一段也是从往年题 P1 里面摘下来的,因为我实在是懒得自己写一个非常复杂的状态机了。代码依然是做了很大改动,大家千万不要抄袭,还是那句话,抄了也抄不对,还会被助教查水表!)

不过,always 块看似庞大,却没有大家想象中的自由。观察上面的一大段代码,我们会发现一个问题:无论 always 块有多大,在每个时钟上升沿,我们最终都只能进入其中的一个分支,代码中的 statusvalue 最多只能被赋值一次。在 Logisim 中试想一下,一个时钟上升沿内,寄存器可以被赋值两次吗?当然是不可以的。我们根本画不出这样的电路,因为这完全就是一个双输入错误。

(在 always 块中,当出现一个寄存器被非阻塞赋值符号 <= 赋值两次的时候,写在前面的一次赋值会被自动忽略,不会被执行)

【Verilog中的时序逻辑:always块的触发条件】

刚才我们提到了同步复位,那异步复位该怎么实现呢?这回不是在时钟 clk 上升沿才进行赋值,而是只要复位信号 reset 为 1,寄存器 reg1 的值就立刻归零,我们该怎么实现呢?

或许你可能想到了 assign 语句,写成 assign reg1 = reset ? 5'b00000 : reg1; ,然而这样写是大错特错的!虽然槽点有很多,但是一句话就能把这种想法彻底否决了:assign 语句不能对 reg 型变量赋值。真是太悲伤了,不是吗?

(题外话,有些时候 Verilog 中的组合逻辑和时序逻辑是可以混用的,但前提要建立在对语法的充分了解之上。我个人非常不建议初学者去趟这个雷区,只要能清晰地做到用 assign 语句为 wire 型变量赋值,表达组合逻辑;用 always 块为 reg 型变量赋值,表达时序逻辑,就已经能够打败绝大多数的选手了!)

其实只需要做一个小小的改动,就可以解决这个问题。还记得我们最开始讲的 always 块的触发条件吗?对了!只需要在 reset 从 0 转变为 1 时(也就是 reset 的上升沿)额外触发一次,就可以实现异步的复位了!

1
2
3
4
5
6
7
8
9
10
11
12
wire clk;
wire reset;
reg [4:0] reg1;

always @(posedge clk, posedge reset) begin // 两个触发条件之间用逗号隔开
if (reset) begin
reg1 <= 5'b00000;
end
else begin
reg1 <= reg1 + 5'b00001;
end
end

【时序逻辑和组合逻辑中的for循环】

观前提示:本部分并非必须掌握的内容,即使不掌握对通过计组课程的影响也不大。但本部分可能会对你的认知造成冲击,如果你担心自己对 Verilog 的认知被摧毁,请跳过本部分内容!

有的时候,我们需要一次性为一个数组中的许多变量赋值,比如使用 reset 信号将 reg [31:0] ROM [0:4095] 中的所有寄存器全部同步复位为 0 。或者我们要对某个变量的位数进行批量操作,例如查找一个二进制数中有多少个 1 。这时我们都需要用到 for 循环进行操作。

下面是一个经典的 for 循环例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
wire clk;
wire reset;
reg [31:0] ROM [0:4095];

integer i;

always @(posedge clk) begin
if (reset) begin
for (i = 0; i < 4096; i = i + 1) begin
ROM[i] <= 32'h00000000;
end
end
else begin
......
end
end

从上面的代码中我们就能看出很多细节,种种细节都暗示着这绝对不是一个简单的语法。

首先是循环变量 i ,我们发现它的变量类型不是我们常见的 wire 型和 reg 型,也不是 C 语言中的 int 型,而是 integer 型。还记得我当年 P0 上机的时候连 i 的类型都写不出来(悲)~

其次是括号中的语句,看似一片祥和,实际暗藏杀机。如果你写成了 for (i = 0; i < 4096; i++) ,恭喜你,又通不过编译了!因为 Verilog 中并没有自增运算符 ++ ,所以只能用 i = i + 1 这种写法!(又 call 到 Python 了)

接下来就是对 ROM[i] 正常赋值了。ROM 是一个数组,ROM[i] 指的就是数组中下标为 i 的 reg 型变量。这种写法对单个变量来说也是没有问题,例如对于 wire [31:0] num 变量来说,num[i] 就是变量 num 的第 i 位。

或许你会觉得自己已经学会 for 循环了,这也没什么难度嘛!那么你可以试着解决一下上面我们提到的“查找二进制数有几个 1 ”这个问题,下面我给出这道题的具体描述:

我们定义时钟变量 wire clk 和二进制数 wire [31:0] num ,每当 clk 处于上升沿时,计算 num 的二进制中共有多少个 1 ,存储至寄存器 reg [5:0] sum 中。你可以先不看答案,自己动手写一写这个代码,看你写出来代码的和我下面的答案有什么区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
wire clk;
wire [31:0] num;
reg [5:0] sum;

integer i;

always @(posedge clk) begin
sum = 6'b000000;
for (i = 0; i < 32; i = i + 1) begin
if (num[i] == 1'b1) begin
sum = sum + 6'b000001;
end
end
end

发现区别在哪里了吗?always 块里所有对 sum 的赋值全部都是阻塞赋值 = !事实上,这道题如果使用非阻塞赋值 <= 的话,结果全部都会出错!在我们后续学习了 ISE 中的运行调试以后,你可以自己编写一些测试数据,看看结果到底如何。

到现在你可能已经晕了,明明我们之前说过,在 always 块中无脑使用非阻塞赋值 <= 就一定正确,为什么现在又行不通了呢?

答案其实也很简单,因为我们违背了“一个变量只能赋值一次”的规则。在最开始的复位和 for 循环中,我们多次使用了非阻塞赋值为 sum 赋值,这就会导致前面所有的赋值都被忽略掉,只剩下最后一次赋值。所以会出现当 num 为 0 时 sum 归零,当 num 不为 0 时 sum 自增 1 的奇怪现象。

那么如果想正确实现题目中的要求,我们该怎么办呢?答案是使用阻塞赋值 = 符号。在 always 块中,阻塞赋值的特点就是按从上到下的顺序执行,和我们在编程语言中写出的代码效果几乎一样。使用这种赋值方法,我们就能够实现在一个时钟上升沿内对同一个寄存器多次赋值了!

(顺带一提,前面的 ROM 数组为什么可以使用非阻塞赋值,因为数组中的每个寄存器 ROM[i] 并不被认为是同一个变量,所以每个变量还是只赋值了一次)

那么你肯定又会想,既然阻塞赋值这么好,为什么我们不干脆在 always 块里全用阻塞赋值呢?如果你真的这么想的话,可以回忆一下我们刚才讲到的两个寄存器串联的电路,如果用阻塞赋值的话,还能得到正确结果吗?(笑)

从实际上来讲,我们在使用 for 循环时的思维,已经是编程语言的思维了,而没有从硬件的角度去思考问题。不然你试想一下,如果用 Logisim 解决这道题,该怎么解决?当然是这样解决了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
wire clk;
wire [31:0] num;
reg [5:0] sum;

always @(posedge clk) begin
sum <= num[0] + num[1] + num[2] + num[3] +
num[4] + num[5] + num[6] + num[7] +
num[8] + num[9] + num[10] + num[11] +
num[12] + num[13] + num[14] + num[15] +
num[16] + num[17] + num[18] + num[19] +
num[20] + num[21] + num[22] + num[23] +
num[24] + num[25] + num[26] + num[27] +
num[28] + num[29] + num[30] + num[31];
end

我知道这很搞笑,但你先别笑。等到上机考试写不出来的时候,你也会像这样写的(笑)。没什么好丢人的,毕竟是限时考试,稳妥才是王道!

接下来我们稍微改变一下刚才的题目,如果我们不是在每个 clk 上升沿才去计算,并将结果存入寄存器中,而是一个组合逻辑,实时计算结果,存入一个 wire 型变量 sum 中,该怎么办呢?

聪明的你说这题我会,咱们不是刚讲过嘛:

1
2
3
4
5
6
7
8
9
10
11
12
wire clk;
wire [31:0] num;
wire [5:0] sum;

assign sum = num[0] + num[1] + num[2] + num[3] +
num[4] + num[5] + num[6] + num[7] +
num[8] + num[9] + num[10] + num[11] +
num[12] + num[13] + num[14] + num[15] +
num[16] + num[17] + num[18] + num[19] +
num[20] + num[21] + num[22] + num[23] +
num[24] + num[25] + num[26] + num[27] +
num[28] + num[29] + num[30] + num[31];

好吧你赢了!确实是非常有道理的,考试的时候我也强烈建议你这么写一下,花不了太多时间的。

不过我想说的是,在组合逻辑中,我们能不能也用上 for 循环这么省事的工具呢?毕竟 assign 语句肯定是不支持这种写法了,不过我们可以借用一下 always 块来表示组合逻辑,像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
wire clk;
wire [31:0] num;
wire [5:0] sum;

reg [5:0] temp;
integer i;

always @(*) begin
temp = 6'b000000;
for (i = 0; i < 32; i = i + 1) begin
if (num[i] == 1'b1) begin
temp = temp + 6'b000001;
end
end
end

assign sum = temp;

可以看到,我们为了模拟时序逻辑中为寄存器赋值的过程,自定义了一个新寄存器 temp ,并让 sum 的值时刻等于 temp 的值。不过,我们如何能够做到让 temp 随时更新呢?答案就藏在触发条件 * 中。

这个特殊的触发条件代表什么意思呢?答案是,当 always 块内任何一个变量的值发生变化时,都会触发 always 块,将整个块内的代码执行一遍。于是,只要 num 发生变化,always 块就会立即执行,将结果计算到 temp 中。这样,我们就实现用 always 块模拟组合逻辑了!

到了这里,你可能又又又会想说,我已经弄清楚 for 循环的全部知识了!但我说还是未必!再来看看下面这道题:

我们定义时钟变量 wire clk 和二进制数 wire [31:0] num ,每当 clk 处于上升沿时,计算 num 的二进制中共有多少个连续 16 个 1 ,存储至寄存器 reg [5:0] sum 中。

和刚才的问题很像,只不过是将 1 的个数换成了连续 16 个 1 的个数,看起来问题不大,是吗?

我猜你一定是将判断条件从 if (num[i] == 1'b1) 改为了 if (num[(i + 15) : i] == 16'hffff) ,而且还没有忘记将 i 的范围从 0 到 31 改为了 0 到 15 。然而,当你真正运行的时候就会发生报错,ISE 提示我们 i is not a constant ,这是为什么呢?

实际上,在 Verilog 中,当我们使用中括号截取变量中的 1 位时,我们可以在中括号里使用 i 这种变量;但当我们使用中括号截取变量中的多位时,中括号里冒号两侧的值却必须是常量,也就是说 num[(i + 15) : i](i + 15)i 的位置处必须是常量。

不过不要紧,我们还有一种新的替代方案,那就是使用 num[i +: 16] 来代替 num[(i + 15) : i] 。这里 num[i +: 16] 的意思是从第 i 位开始向高位出发,连续取出 num16 位。这种写法允许 +: 符号的左边是一个变量或有变量的表达式,但 +: 符号的右侧依然只能是一个常量。这就保证了无论 i 如何变化,取出的值的位宽是相同的。当然了,如果题目中要求每次取出的位宽不同,我们就只能另辟蹊径,或者干脆暴力展开了(悲):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
wire clk;
wire [31:0] num;
reg [5:0] sum;

integer i;

always @(posedge clk) begin
sum = 6'b000000;
for (i = 0; i < 15; i = i + 1) begin
if (num[i +: 16] == 16'hffff) begin
sum = sum + 6'b000001;
end
end
end

好了,关于 for 循环的知识到这里就告一段落了。到这里再问一遍,你真的学会 for 循环了吗?没关系,不要灰心,起码我们还有暴力展开!

3.5 Verilog 编程实战

在这一节中,我们开始学习如何完成一个我们自己的 Verilog 程序,在这个过程中,我们会遇到许多实现上的细节,让我们一起探索吧!

【Verilog中的输入和输出】

在我看来,如果将 Verilog 与 Logisim 对照的话,其实一整个 ISE 工程更像是一个 .circ 文件,而 ISE 工程中的一个个 .v 文件则更像是 .circ 文件中的一个个模块,不知道你是否也有这样的同感。

在 Logisim 中,每个模块往往都需要有输入和输出端口,像下图中这样。有些是一位的输入输出端口,有些则是多位的输入输出端口:

Picture Not Found!

在 Verilog 中,我们该如何复刻出这些端口呢?在 ISE 工程中,我们创建 Verilog 文件时,就可以指定这些端口:

Picture Not Found!

可以看到,我们在 Port Name 一栏里写上了端口的名称,在 Direction 一栏中选择了端口的方向(选 input 或者 output 就可以了,inout 用不到)。如果端口是多位的话,就要勾选 Bus 选项,然后在后面的 MSB 和 LSB 中填入端口的位数,相当于 [MSB:LSB]

填好上面这些信息后,点击 Next ,再点击 Finish ,进入模块界面,我们就能看到刚才定义的端口了!

Picture Not Found!

1
2
3
4
5
6
7
8
module HelloVerilog(
input a1,
input [4:0] a2,
output b1,
output [4:0] b2
);

endmodule

当然,如果后续想再添加端口的话,直接在代码中按照上面的格式加入即可:(不过要注意格式的细节,括号中最后一个端口的末尾是没有逗号的)

Picture Not Found!

1
2
3
4
5
6
7
8
9
10
module HelloVerilog(
input a1,
input [4:0] a2,
input [31:0] a3,
output b1,
output [4:0] b2,
output [31:0] b3
);

endmodule

看到输入输出端口的定义,你或许会觉得我们遇到了新的变量类型 input 和 output ,我说实则不然。因为这里的 input 和 output 并不是变量类型的名称,这些端口的变量类型其实就是 wire 型,只不过是省略没写而已,实际上它们的全称应该是这样:

1
2
3
4
5
6
7
8
9
10
module HelloVerilog(
input wire a1,
input wire [4:0] a2,
input wire [31:0] a3,
output wire b1,
output wire [4:0] b2,
output wire [31:0] b3
);

endmodule

实际上,input 端口的类型只能是 wire 型,而 output 端口的类型既可以是 wire 型,又可以是 reg 型,写成 output reg [31:0] b3 这种格式就可以。不过我个人不太喜欢这种写法,我们后续也不再多提了。

有些同学可能没有绕过这个弯,既然 Verilog 中的 wire 型变量等价于 Logisim 中的导线,那输入输出端口怎么解释?其实它们和与自己相连的那条导线是等价的,例如下面这幅图:

Picture Not Found!

我们就可以写做:

1
2
3
4
5
6
7
8
9
module HelloVerilog(
input [4:0] a1,
input [4:0] a2,
output [4:0] b1
);

assign b1 = a1 & a2;

endmodule

在 Verilog 中,输出端口是一定会被赋值的,毕竟如果不赋值就相当于没有与整个电路相连,于是就变成悬挂值一串 z 了。但是输入端口肯定是不能被赋值的,输入端口的值是需要我们在调试时自己指定的,不能写死在代码中。

【Verilog中的仿真调试】

在写完整个 Verilog 代码之后,我们肯定想要验证一下我们的代码到底正不正确,这时候就需要我们进行仿真调试了。和我们以前学过的运行调试方式不同,在 ISE 中,我们不能通过实时交互的方式进行运行调试,而是要通过 Verilog 代码事先写好在哪个时刻,哪个输入端口的值是多少,之后再对已经写好的测试数据进行运行调试。

听起来真的很麻烦,实际上也不简单。我们就用上一个部分中的最后一个代码作为例子:

1
2
3
4
5
6
7
8
9
module HelloVerilog(
input [4:0] a1,
input [4:0] a2,
output [4:0] b1
);

assign b1 = a1 & a2;

endmodule

接下来我们为它生成和编写测试数据,进行运行调试。首先我们要创建一个测试文件 —— testbench 文件。我们在左侧右键工程名,选择 New Source :

Picture Not Found!

接下来,在弹出的页面中,我们选择 Verilog Test Fixture 选项,输入 testbench 的文件名:

Picture Not Found!

接下来,我们一路点击 Next 和 Finish ,就可以看到我们进入了一个新的页面。在左侧点击 Simulation ,我们就能看到新生成的 testbench 文件了!

Picture Not Found!

在上图中我们可以看到 testbench 文件的结构。testbench 文件也是 .v 类型的文件,所以也是完全遵循 Verilog 的语法规则的。不过我们在编写测试程序时,也不需要太去了解它的结构,只要在相应的位置写上相应的内容就可以了!

接下来,我们根据注释中的提示,在文件中的 initial 块中编写测试数据。首先我们在注释 Initialize Inputs 的部分编写输入端口的初始值:

1
2
3
// Initialize Inputs
a1 = 5'b00000;
a2 = 5'b00000;

下一个部分是已经写好的一行 #100 。在 Verilog 中,我们使用 # 加数字的方法表示延迟一段时间。在这里 #100 的意思就是在执行完上述代码之后延迟 100 ns 再执行后续内容。

当然了,我们只测试一组数据肯定是不够的。接下来,我们可以在注释 Add stimulus here 下继续编写其它测试数据:

1
2
3
4
5
6
7
8
9
// Add stimulus here
a1 = 5'b10101;
a2 = 5'b11001;
#100;
a1 = 5'b11011;
a2 = 5'b00001;
#100;
a1 = 5'b11111;
a2 = 5'b01010;

这里要注意的是,我们在每两次赋值之间都要加上时间延迟,表示每组数据测试的时间。

接下来,我们单击左侧的 testbench 文件名,点击下方 ISim Simulator 左侧的加号进行展开,再双击展开后出现的 Simulate behavioral Model ,进入仿真界面:

Picture Not Found!

进入仿真界面,我们可以看到所有的输入输出端口的值都显示在了界面之上。不过目前波形图的比例十分离谱,我们可以看到一大格代表 1 ps ,而我们的代码是 ns 量级的,于是可以使用下图中框里面的三个按钮调整波形图的尺度:

Picture Not Found!

前两个按钮就不多介绍了,最神奇的是第三个按钮,使用第三个按钮可以直接将波形调整至合适的比例。如果我们还需要再调整的话,可以使用前两个按钮来微调。这样,我们就能够看到所有的测试数据了:

Picture Not Found!

我们检查输出端的内容是否正确,发现完全正确!这样我们就成功完成了一次仿真测试!

接下来,我们再来尝试一下时序逻辑。我们接下来对这段代码进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module HelloVerilog(
input clk,
input reset,
input [4:0] a1,
output [4:0] b1
);

reg [4:0] sum;

always @(posedge clk) begin
if (reset) begin
sum <= 5'b00000;
end
else begin
sum <= sum + a1;
end
end

assign b1 = sum;

endmodule

我们重新生成 testbench 文件(或者修改 testbench 文件,使输入输出端口和源文件一致),像下图这样:

Picture Not Found!

我们重新编写测试数据:

Picture Not Found!

然而,当我们对这段测试数据进行仿真的时候,我们就会发现输出一直都是 X :

Picture Not Found!

这是为什么呢?原来我们忘记让 clk 动起来了!clk 从来都没有到达过上升沿,所以寄存器也一直没有被赋过值!

于是我们退出仿真(一定要记得退出,否则再对同一个工程进行仿真时会报错!),打算把 clk 也加进去。然而,clk 每个周期就要变化两次,如果一点一点手写也太麻烦了,于是我们用一种新的方法,在 initial 块的外面写上 always #50 clk = ~clk;(注意一定要在 initial 块的外面!)。这行代码的含义是,每隔 50 ns 对 clk 的值取反一次。这里注意,因为我们是在 clk 上升沿更新寄存器的值,所以这里每个周期的长度不是 50 ns ,而是 100 ns !

于是我们的测试代码变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
initial begin
// Initialize Inputs
clk = 1'b0;
reset = 1'b0;
a1 = 5'b00000;

// Wait 100 ns for global reset to finish
#100;

// Add stimulus here
#25;
reset = 1'b1;
#100;
reset = 1'b0;
a1 = 5'b00001;
#100;
a1 = 5'b00010;
#100;
a1 = 5'b00100;
#100;
a1 = 5'b01000;
#100;
a1 = 5'b10000;

end

always #50 clk = ~clk;

再次运行仿真,我们就能够看到正确的波形了:

Picture Not Found!
(没有使用 initail 块赋初值的寄存器,在仿真最开始时出现 X 是正常的,在第一次赋值之后就没问题了)

然而,波形图只显示输入输出端口的值,还是太少了。有的时候我们还需要查看代码中 wire 型变量和 reg 型变量的值,该怎么做呢?

我们展开左侧写有测试数据名的部分,点击 uut ,就能在右侧的 Objects 处看到源代码中所有的变量了。我们直接把按住想要观察的变量,把变量拖到右侧的波形图区域,就能看到它已经出现在 Name 一栏中了!

Picture Not Found!

不过我们新加入的变量还是没有显示出波形,所以我们需要让波形图重新运行一遍。使用下图所示的三个按钮,我们就可以完成这个操作:

Picture Not Found!

我们先点击第一个按钮,使用这个按钮会清除所有的波形。接下来按第三个按钮,使用这个按钮会进行 1 us 的仿真,我们也可以在它右侧的位置更改一次仿真的时长。不过我们一般不使用第二个按钮,因为 clk 的值不断变化会导致仿真永远停不下来,所以在时序逻辑的电路中一般不使用。

此外,我们还可以使用改变显示数据的进制,让结果更加直观。我们右键 Name 中的变量名,在 Radix 选项中就可以选择对应的进制了:

Picture Not Found!

【Verilog中的多模块】

我们在 Logisim 中学过,为了使我们的电路更加简洁,我们可以将电路中的一部分内容整合成一部分模块,在 Verilog 中也是一样。之前我们也提到过,Verilog 中的每个 .v 文件就相当于 Logisim 中的一个模块,当我们的工程中有多个 .v 文件的时候,我们就需要使用一些手段将它们连接到一起。

实际上,在上一部分编写测试数据文件 testbench 时,我们就已经见识过了这种连接手段。如果你不记得了,我再帮你回忆一下:

1
2
3
4
5
6
7
// Instantiate the Unit Under Test (UUT)
HelloVerilog uut (
.clk(clk),
.reset(reset),
.a1(a1),
.b1(b1)
);

是的,就是这段代码实现了在 testbench 文件中引用了源文件作为模块。下面我们来仔细分析一下这段代码中各个部分的含义:

在这段代码块的开头,我们首先写上了被引用模块的名称 HelloVerilog ;在它的后面,是这个模块在当前文件下的别名 uut 。不过在我们自己引用模块时,一般都只使用模块的本名作为别名,不会再另取一个。

接下来在括号中,我们会列出被引用模块中我们需要使用到的输入输出接口(也就是说,我们不需要列出全部的接口,只列出我们需要用的就可以了),在前面加上点,这里就是 .clk .reset .a1 .b1 。在接口的后面,我们跟上一个括号,里面的内容是与该接口连接的变量(当然这个变量需要提前定义出来,只不过上面代码中没写),其实就相当于一个 assign 语句。

最后我们来简单应用一下,例如下面这张图,我们应该怎样在 Verilog 中写出来呢:

Picture Not Found!

像下面这样写就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
module HelloVerilog(
input [4:0] a1,
input [4:0] a2,
input [4:0] a3,
input [4:0] a4,
output [4:0] b1,
output [4:0] b2,
output [4:0] b3,
output [4:0] b4
);

wire [4:0] wire1;
wire [4:0] wire2;

SWAP SWAP1 (
.in1(a1),
.in2(a2),
.out1(b1),
.out2(wire1)
);

SWAP SWAP2 (
.in1(a3),
.in2(a4),
.out1(wire2),
.out2(b4)
);

SWAP SWAP3 (
.in1(wire1),
.in2(wire2),
.out1(b2),
.out2(b3)
);

endmodule

【Verilog中的宏定义和parameter类型】

在 Verilog 中,我们也会经常用到宏定义。例如在后续状态机的编写中,我们会使用到各种各样的魔数来表示状态;在搭建我们自己的 CPU 时,我们也会需要用到数字来代表指令名。如果我们不使用宏定义,强行使用这些毫不相关的数字编写代码的话,可读性肯定非常差,所以我们真的很有必要了解一下 Verilog 中的宏定义。

在 Verilog 中,我们使用 `define 来进行宏定义(注意前面有一个反引号),例如 `define ORI 5'b00001 ,在之后我们就可以用 ORI 代表 5'b00001 了!

美中不足的是,在调用宏的时候,我们依然需要在宏的名称前面加上反引号,例如 assgin op = `ORI ,才能够正确调用宏。下面是一段示例代码:

1
2
3
4
5
6
7
8
9
module HelloVerilog(
output [4:0] op;
);

`define ORI 5'b00001

assign op = `ORI;

endmodule

不过我还是觉得每次调用宏的时候都要写反引号太麻烦,所以当宏代表的内容是常量的时候,我更经常使用的是 parameter 类型。

parameter 是一种特殊的变量类型,专门用于存储不变的常数。使用 parameter 变量名 = 值 即可定义一个常量。在调用时,直接使用变量名代替常数即可。例如下面的例子:

1
2
3
4
5
6
7
8
9
module HelloVerilog(
output [4:0] op;
);

parameter ORI = 5'b00001;

assign op = ORI;

endmodule

【default_nettype】

Verilog 确实是一门很有个性的语言。有的时候它管得太严,我们不喜欢;有的时候它又很宽松,但是我们依然不喜欢。比如它竟然允许使用从来没有定义过的变量,而没有任何报错!

对于一个从来没有定义过的变量,Verilog 会将其理解为一个 1 位的 wire 型变量。所以在写代码时,你有可能手滑输错了一个字母,结果就变成别的变量了……如果你原本打算写的变量是一个多位宽的还好,这样在运行时,ISE 就会因为位宽不一致而发出警告;但如果你本来就打算写一个 1 位的 wire 型变量,那真是恨不得要 debug 到地老天荒。

所以我们有了 default_nettype 。只要我们在代码中加入一句 `default_nettype none(注意开头的反引号),就可以在我们使用从没定义过的变量时直接报错。

不过这么好用的功能,当然是有代价的。我们之前讲过,我们所有输入输出端口的定义都默认省略了 wire 型,比如 input clk 实际上是 input wire clk 。那么不好意思,只要你使用了尊贵的 default_nettype ,你所有的输入输出端口都要加上一个 wire ,否则小心给你来一个满屏报错,把你裤衩子崩飞,炸得五马分尸,万多桃花开!

【readmemb、readmemh、display】

接下来我们来学习三个特殊的系统任务,说实话会与不会对通关课程来说几乎没有任何影响,不过在后续搭建 CPU 的时候可能会用到,所以说就简单介绍一下。

readmembredmemh 的作用是读取二进制或十六进制的文件,并将文件内容写入寄存器或寄存器数组中。例如我们定义寄存器数组 reg [31:0] ROM [0:4095] ,准备将下面的十六进制文件 code.txt 装入 ROM 中:

1
2
3
4
5
6
7
8
4b616d6f
6e746f20
6c6f7665
7320434f
20616e64
20414c4c
204f4620
594f5521

我们使用如下代码:$readmemh("code.txt", ROM, 0, 4095); 即可将文件中的内容成功装入,快去试试吧!

display 的作用是像控制台输出当前变量的值,它的用法和 C 语言中的 printf() 函数几乎一模一样,唯一区别在于十六进制的格式符不是 %x ,而是 %h 。例如下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module HelloVerilog(
input clk;
input [31:0] in;
output [31:0] out;
);

reg [31:0] reg1;

initial begin
reg1 = 32'h00000000;
end

always @(posedge clk) begin
reg1 <= reg1 + in;
$display("%d: now in %h, reg1 %h", $time, in, reg1);
end

assign out = reg1;

endmodule

上述代码中,每当执行到 display 语句时,都会在下方的控制台中输出相关内容,$time 是一个特殊的系统任务,返回值为当前仿真时刻的时间值。

3.6 有限状态机

最后的最后,我们来聊一聊 Verilog 这一章的集大成者 —— 有限状态机。

在 Logisim 一章中,我们曾经短暂地提到过状态机的概念,也介绍了 Moore 型状态机和 Mealy 型状态机的区别。在本书的最后,我们将再一次和这位老友重逢,了解一下如何用 Verilog 实现有限状态机。

你可能发现了,有限状态机的前面多了一个“有限”,这就说明它肯定不是一般的状态机。在 Logisim 一章中我们学过,一个首尾相连的寄存器就可以形成状态机,寄存器中不同的值就代表着不同的状态,例如一个每次递增 1 的寄存器电路就是状态机。然而这样无限递增下去的寄存器,它的状态数是无限的,这貌似并不符合有限状态机的概念。我们接下来要研究的有限状态机,是只能在有限个状态之间不断转换的状态机。

【有限状态机实战:6系同学最爱的电梯】

我们来思考下面一个问题:假如有一台 6 系学生都最喜欢的单轿厢电梯,穿行在一座 31 层的大楼中,将每位乘客送到自己想去的楼层。我们假设初始时刻电梯处在 1 楼,状态为 WAIT 。此时电梯收到一个乘客的请求,于是出发去接乘客,此时电梯转为 UPDOWN 状态;在到达乘客所在的楼层后,电梯转为 OPEN 状态 3 个周期,等待乘客进入电梯,随后再次转为 UPDOWN 状态将乘客送到想去楼层;电梯到达乘客想去的楼层之后,再次转为 OPEN 状态 3 个周期,等待乘客离开电梯,随后转为 WAIT 状态进行休眠。其它附加条件如下:

  1. 在电梯前去接乘客时,乘客有可能本来就与电梯处在同一楼层,此时电梯不动,直接开门;
  2. 在乘客的请求中,乘客所在的楼层和想去的楼层不会是同一个楼层;
  3. 在电梯转为 WAIT 状态的第一个周期内,一定会有一个新请求发出;在电梯运行期间,一定不会有新请求发出。

题目中有两个输入端,分别为 input [4:0] person_frominput [4:0] person_to ,在新的请求到来时,输入端的值会更新,其它时候输入端的值不变。要求我们定义输出端口 output [31:0] status_str ,以字符串的形式实时输出当前周期内电梯的状态。

下面我们来分析一下这道题。显然,这道题中电梯一共有 4 个状态,分别是 WAIT UP DOWN OPEN 状态。我们要做的其实就是判断在每个周期到来时,电梯的状态如何在它们 4 个之间转换。

我们沿着电梯的整个运动过程进行分析,从初始的 WAIT 状态开始,直到电梯将乘客送到楼层后回到 WAIT 状态。

在最开始,电梯处于 WAIT 状态,那么电梯的下一个周期有没有可能还是 WAIT 状态呢?根据题目要求来看应该是不太可能了,因为电梯在转为 WAIT 状态的第一个周期内就一定会收到新请求(真是一点都不让电梯闲着啊~)。

在收到请求后,电梯该如何判断自己应该转为 UP DOWN OPEN 中的哪个状态呢?那就需要将电梯所在的楼层与乘客所在的楼层进行比较了!乘客所在的楼层我们都清楚,是输入端的 person_from ,但电梯所在的楼层是什么呢?这就需要我们自己记录了!于是我们就需要定义一个寄存器,就叫它 reg [4:0] elevator_at 好了!当然了,定义寄存器是有代价的,我们需要时刻记得在每次状态转换的时候更新它们,比如在这一步,如果状态转为 UP 的时候,就要记得将 elevator_at 的值自增 1 。

为了方便观看,我们可以画一个状态转换图出来,像下面这样:

Picture Not Found!
(这里中括号的内容表示转移条件,没有中括号的内容表示转移时的操作)

接下来我们继续来分析,如果在前往乘客所在楼层的过程中,电梯处在 UP 状态,该如何进行状态转移呢?此时转移的结果无非就是两种,一种是到达乘客所在的楼层之后转为 OPEN 状态,另一种则是没有到达乘客所在的楼层,继续 UP 状态。看起来我们只需要判断 elevator_atperson_from 的大小关系就好了。当 elevator_at == person_from 时,就转为 OPEN 状态;当 elevator_at < person_from 时,就保持 UP 状态。

这里稍微插一句话,在每个状态中,所有的转移条件肯定是没有交集的,毕竟我们当然不能允许出现一个条件同时对应两个转移方向的情况;但一般来说,我们也会希望所有的转移条件的并集是全集,这样能够防止我们漏掉一些转移情况,看起来也更赏心悦目。不过在我们上面这种情况下,我们非常清楚在 UP 状态下,根本不可能出现 elevator_at > person_from 这种情况,那我们也可以不写这种情况,其实也并没有什么问题。

好了我们回到这道题上来吧,经过这一步,我们的状态转换图更加丰富了:( DOWN 状态那边是完全对称的,这里也一并加上来了,之后就不单独分析了)

Picture Not Found!

接下来,我们来分析电梯到达乘客所在楼层之后,也就是位于 OPEN 状态时,该怎么转移状态。首先是停留在同一状态,题目中要求我们在 OPEN 状态停留 3 个周期,那么如何实现 3 个周期的计数呢?我们还是可以定义一个寄存器 reg [1:0] count ,当电梯转移到 OPEN 状态时将 count 的值置为 3 ,随后在每次状态转移时都判断 count 是否为 0 。如果 count 的值为 0 ,就转移到其它状态;否则就停留在 OPEN 状态,并将 count 的值自减 1 。

接下来我们来分析此时 OPEN 状态向其它状态转移的条件。因为题目中规定不存在乘客所在楼层和要去的楼层相同的情况,所以电梯结束 OPEN 状态后,不是转为 UP 状态,就是转为 DOWN 状态。两个转移方式的区别就在于 elevator_at(或者 person_from )是大于 person_to 还是小于 person_to 。搞清楚这个关系之后,我们就能够画出这一步的状态转移图了:

Picture Not Found!

有点复杂了,是不是?这才哪到哪啊(笑),我们继续吧!

接下来,我们的电梯又回到了 UP 状态,依旧有两种转移方式:一种是已经到达乘客想去的楼层,转为 OPEN 状态;另一种是还没有到达乘客想去的楼层,保持 UP 状态。只不过,这一次的转移条件是 elevator_atperson_to 进行比较了!这种情况下,我们应该怎样修改转移条件呢?

这里我想到两种思路。一种思路是开一个 1 位的寄存器 reg choice ,记录当前应该和 person_from 还是 person_to 比较。当电梯处于 WAIT 状态时,接下来就要和 person_from 进行比较;而当电梯处于 OPEN 状态时,接下来就要和 person_to 比较了。这一步还是比较考验逻辑思维的,仔细想一想,是不是这样?

另外一种思路也比较相似,只不过不是开一个 1 位的寄存器,而是开一个 5 位的寄存器 reg [4:0] elevator_target ,记录当前电梯要去的楼层。当电梯去接乘客时,将寄存器的值设置为 person_from ;而当电梯送乘客时,将寄存器的值设置为 person_to ,也是一种非常直观的方法。

我个人比较喜欢后一种方法,这里就按这种方法改造一下我们的状态转移图:

Picture Not Found!

接下来,我们的电梯又回到了 OPEN 状态,进行 3 个周期的循环,随后回到一切的起点 WAIT 状态。这时候你可能会疑惑,我们应该如何确定电梯在 OPEN 状态时,是刚接到乘客,即将出发,还是已经将乘客送到,准备休息呢?其实这个问题已经解决了,我们仔细看从 OPEN 状态转移到 UP 状态和 DOWN 状态的转移条件,里面分别写着 elevator_at < person_toelevator_at > person_to ;而当我们送完乘客即将收工时,此时一定有 elevator_at == person_to ,非常巧妙!我们补全状态转移图的最后一块拼图,收工!

Picture Not Found!

至此,我们已经攻克了有限状态机最难的部分,搞清楚了全部状态转移的条件,转换成 Verilog 语言想必也是并不复杂了,在这里就直接贴一段代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
module coelevator(
input clk,
input [4:0] person_from,
input [4:0] person_to,
output [31:0] status_str
);

reg [1:0] elevator_status;
// 2'b00: WAIT; 2'b01: UP; 2'b10: DOWN; 2'b11: OPEN
reg [4:0] elevator_at;
reg [4:0] elevator_target;
reg [1:0] count;

initial begin
elevator_status = 2'b00;
elevator_at = 5'b00001;
elevator_target = 5'b00000; // whatever
count = 2'b00; // whatever
end

always @(posedge clk) begin
case(elevator_status)
2'b00: begin
if (elevator_at < person_from) begin
elevator_status <= 2'b01;
elevator_at <= elevator_at + 5'b00001;
elevator_target <= person_from;
end
else if (elevator_at > person_from) begin
elevator_status <= 2'b10;
elevator_at <= elevator_at - 5'b00001;
elevator_target <= person_from;
end
else begin
elevator_status <= 2'b11;
count <= 2'b11;
end
end
2'b01: begin
if (elevator_at < elevator_target) begin
elevator_status <= 2'b01; // actually no use
elevator_at <= elevator_at + 5'b00001;
end
else begin
elevator_status <= 2'b11;
count <= 2'b11;
end
end
2'b10: begin
if (elevator_at > elevator_target) begin
elevator_status <= 2'b10; // actually no use
elevator_at <= elevator_at - 5'b00001;
end
else begin
elevator_status <= 2'b11;
count <= 2'b11;
end
end
2'b11: begin
if (count > 2'b00) begin
elevator_status <= 2'b11; // actually no use
count <= count - 2'b01;
elevator_target <= person_to;
end
else if (elevator_at < person_to) begin
elevator_status <= 2'b01;
elevator_at <= elevator_at + 5'b00001;
end
else if (elevator_at > person_to) begin
elevator_status <= 2'b10;
elevator_at <= elevator_at - 5'b00001;
end
else begin
elevator_status <= 2'b00;
end
end
endcase
end

assign status_str = (elevator_status == 2'b00) ? "WAIT" :
(elevator_status == 2'b01) ? "UP" :
(elevator_status == 2'b10) ? "DOWN" : "OPEN";

endmodule

【有限状态机实战:Kamonto最爱的摇骰子】

刚才我们练的电梯难度如何?或许你会觉得有些小难度?但在 6 系,日子还长着呢……

有的时候,我们不仅需要自己研究状态转移条件,连状态是什么都需要我们仔细思考才能得到。接下来我们再来看看这道题:

Kamonto 上高中的时候看过一部动漫,印象非常深刻,名字叫做《狂赌之渊》,抽象的画风和刺激的剧情令我爱不释手,一次性刷穿了全两季。后来这部作品出了一部很短的外传动漫,叫做《狂赌之渊·双》,我也是第一时间拜读了这部大作。在这部大作中,我对第一话中的一场游戏记忆犹新,简化一下跟大家分享一下:

在一场游戏中,裁判会不断投掷一枚骰子。在每次投掷中,若骰子的点数为 1 、2 、3 ,则记为 D ;若骰子的点数为 4 、5 、6 ,则记为 U 。随着裁判不断投掷,场上会形成一条结果序列,例如 UUDUUDDUDU……。现在 SpadeZ 和 Kamonto 需要给出一串 3 个字母的猜测,结果序列中最先出现连续的 3 个字母等于自己猜测的玩家获胜。目前 SpadeZ 的猜测是 DDU ,Kamonto 的猜测是 DUU 。

现在要求我们分别使用 Moore 型和 Mealy 型有限状态机来模拟游戏过程。过程中输入端口 input in 会不断输入当前的投掷结果,以 1'b0 代表 D ,以 1'b1 代表 U 。我们需要使用 output [1:0] winner 输出端口来实时输出获胜者,以 1'b01 代表 SpadeZ 获胜,以 1'b10 代表 Kamonto 获胜,以 1'b00 代表暂时没有人获胜。其它附加条件如下:

  1. 只能使用 1 个寄存器,并且状态机的状态数量要尽可能少;
  2. 当任何一位玩家获胜时,下个周期结果序列将会重置,两人以相同的猜测重新开始游戏;
  3. 珍爱生命,远离赌博()

这道题模拟起来比较简单,解法实在有点多,所以加了一点小限制。我们不能使用 3 个寄存器来分别存储最近 3 次投掷的结果。并且由于题目中说状态数量要尽可能少,我们使用穷举法为 8 种猜测分别建立一个状态的做法也似乎不太可行了……不过,我们确实不需要这么多状态,让我们从结果序列的角度来看,说不定能够发现更好的思路!

在最开始,我们的结果序列为空,我们可以将它定义为初始状态,我们就称这个状态为 N 好了。接下来,如果开局结果是 D 的话,那么两个人的进度都会前进一步,这是不是一个新状态呢?当然是,因为此时两人的获胜进度发生了变化。

总的来说,只有当达到最终状态的进度完全相同时(在这道题中就是两人的获胜进度均相同),我们才认为两个状态是同一个状态。例如如果开局结果是 U ,这就不是一个新状态。因为开局掷出 U 对两人的进度都没有推进作用,所以依然是在原地打转。于是我们就能画出这一部分的状态转移图:

Picture Not Found!

接下来,我们在已经掷出一个 D 的基础上继续探讨。如果再掷出一个 D 呢?我们发现,虽然对于 Kamonto 来说,进度没有任何变化,然而对于 SpadeZ 来说却又前进了一步。所以我们断定 DD 是一个新的状态。

如果掷出的结果是 U 怎样呢?此时就形成了 DU ,同样,对于 SpadeZ 来说没有进度变化,但对于 Kamonto 来说却前进了一步。所以 DU 同样是一个新状态。

这一步还是非常爽快的,无论是哪边都是一个新的状态,于是我们继续补充我们的状态转移图:

Picture Not Found!

接下来我们兵分两路,首先从 DD 这一边继续。假如掷出的结果是 U ,那自不必说了,SpadeZ 取得了胜利,这当然是一个新的状态。但如果掷出的结果还是 D 呢?

我们仔细来考虑,从图中我们可以看出,在 DD 状态下,SpadeZ 的进度是 DD ,而 Kamonto 的进度是 D 。当我们再掷出一个 D 时,由于两个人猜测的下一位都是 U ,所以 SpadeZ 的进度仍然是 DD ,Kamonto 的进度仍然是 D 。此时两人的进度都没有变化,所以这并不是一个新的状态,当前的状态仍然是 DD !

我们再来看另一边,如果当前状态是 DU ,那么如果掷出的是 U ,同样是毫无疑问的新状态,此时 Kamonto 取得了胜利。接下来我们来考虑一下整个问题中最为复杂的一部分,如果掷出的是 D 呢?

或许你会想到,掷出 D 时 Kamonto 的进度从 DU 变回了 D ,这肯定是一个新状态没错了!但如果我们放眼全图就会发现,此时 SpadeZ 的进度仍然是 D ,Kamonto 的进度则变回了 D ,这竟然和 D 这个状态下两人的获胜进度是不谋而合的!所以此时我们依然不需要设置一个新状态,让状态直接转移到 D 状态就好了!

Picture Not Found!

最后,我们来到了已经有人获得胜利的 DDU 和 DUU 状态。根据规则,接下来整个结果序列将会重置,也就是说在下一个周期,无论掷出的结果是 D 还是 U ,整个结果序列中都只会有这一次的结果,所以效果和开局第一次掷骰子的结果是一样的。对于这两个状态来说,如果掷出 D ,就会转到 D 状态;如果掷出 U ,就会如同开局掷出一个 U 一样,回到起始的 N 状态。

于是,我们就画出了整个过程的状态转移图:

Picture Not Found!

我们从上图中不难看出,当我们处于 DDU 状态时,就输出 SpadeZ 获胜;当我们处于 DUU 状态时,就输出 Kamonto 获胜。输出的结果完全受当前状态影响,显然,这是一个 Moore 型状态机。我们来写一下整个过程的 Verilog 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
module codice1(
input clk,
input in,
output [1:0] winner
);

reg [2:0] dice_status;

parameter N = 3'b000;
parameter D = 3'b001;
parameter DD = 3'b010;
parameter DU = 3'b011;
parameter DDU = 3'b100;
parameter DUU = 3'b101;

initial begin
dice_status = N;
end

always @(posedge clk) begin
case(dice_status)
N: begin
if (in == 1'b0) begin
dice_status <= D;
end
else begin
dice_status <= N;
end
end
D: begin
if (in == 1'b0) begin
dice_status <= DD;
end
else begin
dice_status <= DU;
end
end
DD: begin
if (in == 1'b0) begin
dice_status <= DD;
end
else begin
dice_status <= DDU;
end
end
DU: begin
if (in == 1'b0) begin
dice_status <= D;
end
else begin
dice_status <= DUU;
end
end
DDU: begin
if (in == 1'b0) begin
dice_status <= D;
end
else begin
dice_status <= N;
end
end
DUU: begin
if (in == 1'b0) begin
dice_status <= D;
end
else begin
dice_status <= N;
end
end
endcase
end

assign winner = (dice_status == DDU) ? 2'b01 :
(dice_status == DUU) ? 2'b10 : 2'b00;

endmodule

我们已经搞定了 Moore 型自动机,接下来我们该如何将它改成 Mealy 型自动机呢?

我们在 Logisim 一章中已经学过,Mealy 型自动机的特点是不需要等到下个周期转移到新状态时,才输出状态对应的结果,而是通过当前状态以及输入值直接预测出下个状态,并输出下个状态对应的结果。所以理论上我们只需要将 winner 的赋值改成下面这样就可以了:

1
2
assign winner = (dice_status == DD && in == 1'b1) ? 2'b01 :
(dice_status == DU && in == 1'b1) ? 2'b10 : 2'b00;

但是实际上,我们还可以再做一些精简。仔细观察 DUUDDU 两个状态,我们虽然可以认为在这两个状态下,SpadeZ 和 Kamonto 的进度是独一无二的,例如在 DUU 状态下,Kamonto 的进度是 DUU ,而 SpadeZ 的进度是 D 。但是从另外一个角度考虑,当一方获胜时,整个结果序列会被清空,所以此时相对于下一局游戏来说,两人又都没有进度,其实和初始状态 N 是完全一样的。

在 Moore 型状态机中,我们要根据当前状态来判断两人是否胜利,所以必须设置这样两个“胜利状态”来供 winner 使用;但在 Mealy 型状态机中,我们根据两人胜利前的最后一个状态,再加上当前的输入值,就可以判断出两人的胜利情况,于是“胜利状态”也就失去了它们最后的作用,完全可以和 N 状态合并为一个状态了。

于是我们可以将代码精简成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
module codice2(
input clk,
input in,
output [1:0] winner
);

reg [1:0] dice_status;

parameter N = 2'b00;
parameter D = 2'b01;
parameter DD = 2'b10;
parameter DU = 2'b11;

initial begin
dice_status = N;
end

always @(posedge clk) begin
case(dice_status)
N: begin
if (in == 1'b0) begin
dice_status <= D;
end
else begin
dice_status <= N;
end
end
D: begin
if (in == 1'b0) begin
dice_status <= DD;
end
else begin
dice_status <= DU;
end
end
DD: begin
if (in == 1'b0) begin
dice_status <= DD;
end
else begin
dice_status <= N;
end
end
DU: begin
if (in == 1'b0) begin
dice_status <= D;
end
else begin
dice_status <= N;
end
end
endcase
end

assign winner = (dice_status == DDU) ? 2'b01 :
(dice_status == DUU) ? 2'b10 : 2'b00;

endmodule

这样,我们就完成了 Mealy 型状态机的编写,最后附上一张 Mealy 型状态机的状态转移图:

Picture Not Found!

3.7 本章结语:再见,Verilog!

恭喜你成功看到这里,相信这一段旅途真的是非常不容易,不过你成功用努力和坚持战胜了种种困难,完成了如此艰巨的挑战,实在是太了不起了!

我们的学习即将告一段落,接下来的很长一段时间,我们都会是在与这三大神器打交道,巩固使用它们的技巧。

但这还远远不是计组实验课程的全貌,我们学习到现在的全部内容,都还只是我们接下来要面见 CPU 的钥匙,不过它们并非毫无作用,而是化作一个个得心应手的道具,在我们接下来的冒险中助力我们打下 CPU 这只巨龙!

加油,6 系新来的冒险者!