简介:面向数字电路与计算机组成原理课程学习者,这份Logisim 16位自动运算器实验报告围绕16位ALU和时钟驱动的自动累加电路展开,可用于理解运算通路、RAM/寄存器/计数器协作及时序控制。压缩包共1个doc文件,约341KB,按实验目的、实验环境、实验内容、方案设计、过程调试、测试分析、总结与心得、参考文献组织,涵盖加减乘除、逻辑与或非异或、移位、状态字判断,以及RAM 0—15单元累加、结果回存16—31单元、关键点十进制探测显示和运算结果十六进制数码管展示等要点。报告还呈现16位运算器功能表、自动电路封装思路与常见故障排查过程,便于对照复现完整数据流。已有4049人学习,适合课程实验复现、实验报告撰写参考与查漏补缺。
1. 自动运算器:真正难的是“什么时候停”,而不是“算”
把「logisim 自动运算器」这个题目摊开看,多数人的第一反应是把加减乘除、与或非异或、四种移位拼成一个 16 位 ALU——这部分课本上已经讲得很透了,照着连都能连出来。真正容易让人卡住的从来不是怎么算,而是另外两件事:一个没有程序计数器、没有指令译码的纯硬件电路,凭什么自己知道“第 16 个数已经加完,该收工”;以及一片只有一个地址口的 RAM,凭什么能在同一个节拍里既读出 0~15 号单元的旧值、又把中间结果写进 16~31 号单元。这两个问题靠多连几根线是过不去的,它们要求你正面回答:时钟沿到底在哪一刻生效,组合逻辑和时序逻辑的边界划在哪。把这两件事想明白,这个电路上每一根线你都能说出它为什么在那里。
2. 16 位运算器:一份功能表如何压成一条数据通路
2.1 从功能表反推操作码编码
功能表是整个运算器的“合同”,它规定了输入端的 4 位选择信号与输出结果之间的映射。把那 12 种运算按顺序排下来,编码几乎是自然形成的:
| 操作码 | 功能 | 操作码 | 功能 |
|---|---|---|---|
| 0000 | F = A + B | 1000 | F = A 逻辑左移 |
| 0001 | F = A − B | 1001 | F = A 逻辑右移 |
| 0010 | F = A × B | 1010 | F = A 算术左移 |
| 0011 | F = A ÷ B | 1011 | F = A 算术右移 |
| 0100 | F = A AND B | — | 保留 |
| 0101 | F = A OR B | — | 保留 |
| 0110 | F = NOT A | — | 保留 |
| 0111 | F = A XOR B | — | 保留 |
12 种操作刚好塞进 4 位编码,还剩 4 个空位。这个“留白”不是浪费,而是接口设计的常规做法:以后要加带进位加法、比较大小、取反加一,都不用动端口位宽,只占用一个空闲编码。把 A、B、F 三个 16 位端口加一个 4 位 op 端口固定下来,内部怎么搭就变成了纯实现细节,外面看不见——这就是封装的意义,也是后面做自动运算电路时能把它当成一个黑盒直接复用的前提。
从这张表还能读出一层结构:12 种操作里,加减乘除是算术,与或非异或是按位逻辑,四种移位是位序变换。它们的输入输出关系完全独立,没有先后依赖,所以可以全部同时接在 A、B 上一起算。这个“同时”是后面所有设计的起点。
2.2 并行计算、择一输出:ALU 的面积换延迟
最直白的实现是:把加法器、减法器、乘法器、除法器、四个逻辑门组、四条移位通路全部接到 A、B 两端,让它们同时算,12 路结果一起送进一个 12 选 1 的多路选择器,op 决定哪一路能出去。电路看起来“浪费”——任何时刻只有一路结果有用,其余 11 路白算。但用逻辑门数换来的是延迟:从 A、B 稳定到 F 稳定,只经过一级运算部件加一级选择器,没有状态机、没有中间寄存器、没有握手信号。
我一般会这么理解:这是把时间问题换成了空间问题。如果反过来做分时复用,一个加法器先算加、再算减、再算乘,那就要额外的控制器记“现在轮到谁”、要额外的寄存器存中间结果、要额外的节拍让它跑完,控制逻辑的复杂度会远超省下来的那点门电路。在真实处理器里,这个取舍的答案是同一个:执行单元宁可多放几个并排放着,也不愿意把指令拆成微码分时喂给一个单元。硬件天然擅长并行,用并行的方式去解释“选择”,比用串行的方式去模拟“复用”便宜得多。
这里有个必须提醒的实现细节:Logisim 的运算器组件有 Data Bits 属性,默认不是 16 位。搭的时候如果漏改,加法器会按 8 位输出,高 8 位直接被截掉,而这个问题在输入较小的数时不会暴露,等到累加值超过 255 才突然出错,排查起来极其难受。所以每一位宽相关的组件——运算器、寄存器、RAM、计数器、比较器——都要逐一核对,宁可多看一眼。
2.3 移位为什么要拆线重接
Logisim 的运算器组件不提供移位,这一路必须自己搭。做法是借分离器(Splitter):一个 16 位分离器把输入拆成 16 根独立的线,另一个分离器把 16 根线合并回 16 位输出,两个分离器的线序错开一位,空出来的那一位接常量。逻辑左移就是把输入的 0~14 位接到输出的 1~15 位,输出第 0 位接常量 0;逻辑右移方向反过来,输出第 15 位接 0;算术右移的空位不接常量,而是把输入的最高位(符号位)引过来接上。
三种移位的语义差别,用代码对照最清楚:
def shift(kind, x, n=1): x &= 0xFFFF if kind == 'll': # 逻辑左移:低位补 0,最高位被挤出丢弃 return (x << n) & 0xFFFF if kind == 'lr': # 逻辑右移:高位补 0,符号位不保留 return (x >> n) & 0xFFFF if kind == 'ar': # 算术右移:高位复制原符号位 sign = x & 0x8000 r = (x >> n) | ((0xFFFF << (16 - n)) if sign else 0) return r & 0xFFFF # 算术左移与逻辑左移在位级上是同一条表达式 # 区别只在溢出标志怎么置,不在结果参数 n 是移位位数,一次移一位时 n 固定为 1,多位移位要么把一级移位电路级联起来,要么用多路选择器按移位量选出对应的那一路——前者省器件,后者省延迟,实验规模下一次移一位就够用。
真正值得说清楚的是那句容易让人意外的话:算术左移和逻辑左移的结果完全一样。原因是补码的左移本身就是低位补 0、最高位被挤出,而“乘以 2 再对 2¹⁶ 取模”在补码下就是这个位操作,正负都一样。它们的差别不在结果,而在溢出判断——算术左移要关心符号位有没有被改变,逻辑左移只把它当无符号数的位串。而算术右移必须复制符号位,是因为补码的右移要保持“向下取整的除法”语义:−3 算术右移一位得 −2,逻辑右移会得到 32766,符号直接从负翻成正。一条线接错,整个负数的除法路径全废。
3. 状态字:进位、溢出、零为什么不能顺手算出来
3.1 补码加法的溢出:两个进位位的异或
16 位加法器内部是 16 个全加器串成的进位链,每一位都产生一个“本位和”和一个“向上进位”。最高位(第 15 位,即符号位)产生的进位叫 C15,次高位(第 14 位)产生的进位叫 C14。溢出的判断就落在这一位上:
def add_overflow(a, b): s = (a + b) & 0xFFFF carry_into_sign = ((a & 0x7FFF) + (b & 0x7FFF)) >> 15 # C14 carry_out_sign = (a + b) >> 16 # C15 v = carry_into_sign ^ carry_out_sign return s, v & 1逻辑上讲得通:两个同号数相加,如果结果变号,就是溢出。而“符号被翻掉”这件事在进位链上的表现,恰好是进到符号位的进位与符号位自己产生的进位不一致。举例,0x7FFF + 0x0001:低 15 位相加向符号位进了 1(C14 = 1),符号位本身是 0+0+1 没有向外出(C15 = 0),异或得 1,结果为 0x8000,正数加正数变成了负数,判定溢出,正确。
这套判据有个好处:减法不用另写一套。A − B 在硬件里实现为 A + (~B) + 1,把借位吸收进最低位的进位输入,同一个异或公式照样成立。所以一条进位链、一个异或门,就把加减法的溢出全覆盖了。软件里常见的另一种写法(a ^ s) & (b ^ s) & 0x8000(两操作数同号、结果异号)与它是等价的,只是从数据角度而不是从进位角度描述同一件事。
3.2 乘除法的溢出:比较器与“与零比较”
加减法共用一条进位链,乘除法就不行了,因为它们的结果位宽天然超过 16 位。16 位乘 16 位得到 32 位积,硬件乘法器输出低 16 位送 F,高 16 位单独拿出来:只要高 16 位不全为 0,就说明真实结果装不进 16 位,判定溢出。这里的“与零比较”用比较器实现最省事,把高 16 位接到比较器一端,另一端接常量 0,输出直接当溢出标志。
除法同理,但它溢出(更准确说是“结果不精确”)的判据是余数。16 位除法器给出商和余数,商送 F,余数接比较器与 0 比:余数非 0 说明除不尽,整数除法丢了信息,置溢出位。除数等于 0 是另一种情况,在真实处理器里会触发异常,在这个实验的电路里表现为商全 1、余数为被除数,属于需要提前避免的输入。
这里要坦白一个简化:真正的有符号乘法溢出判据不是“高位全零”,而是“高 16 位是否全是低 16 位符号位的复制”。实验按无符号处理,判据才这么干净。如果你后面要把它扩展成有符号版本,这一处必须改,否则负数的合法乘积会被误判成溢出。
3.3 用多路选择器把几套溢出条件拼在一起
到这里,四类运算的溢出条件来源各不相同:加减看 C15 与 C14 的异或,乘法看高 16 位是否为零,除法看余数是否为零,移位看符号位有没有变化。它们不可能用一根线解决,做法是每类各算一路,全部送进一个多路选择器,由 op 的高位决定放哪一路出去。
这个结构和 2.2 节里 F 的产生结构是同构的:多路并行计算、按操作码择一输出。同一套思想在运算器里出现了两次,一次选结果,一次选状态位。进位标志 C 也一样,加减取加法器的进位输出,移位取最后被挤出的那一位,逻辑运算直接置 0;零标志 Z 最简单,把 16 位结果全体或起来再取反,全 0 则 Z = 1,一根 16 输入或门就够了。
值得留意的是这几路状态位必须和 F 同一拍产生,因为它们要一起被打进状态寄存器。如果某一路的路径比 F 长了几个门延迟,压到时钟沿附近就会出现“结果是新的、状态位是旧的”这种时序毛刺,表现是偶尔一次溢出标志丢失,而单步调试永远复现不出来。所以在总装的时候,宁可给状态位这一路多留一级缓冲,也别让它贴着时钟沿走。
4. 自动运算电路:双计数器与半周期时钟的时序账
4.1 两个计数器、一个偏移量
RAM 的布局是这个电路的第一条约定:32 个 16 位单元,0~15 号放 16 个被加数,16~31 号放累加的中间结果。读写地址区间完全不重叠,这一条很关键——正因为读的和写的不是同一个单元,一个节拍内的读和写才有可能并行,不需要先读后写的串行等待。
地址由一个计数器驱动,每拍加 1。读地址从 0 走到 15,写地址相应地从 16 走到 31。写地址有两种生成方式:一是把读计数器的输出加 16;二是加 15。差 1 的来源是计数器的起始状态——如果计数器是 0-based(第一拍输出 0),偏移量是 16;如果计数器要到第一个时钟沿之后才输出有效值(事实上的 1-based),偏移量就是 15。为什么这件事值得写进文章?因为这个 1 会在最不容易发现的地方露头:电路跑得通,数码管上的总和也“看起来对”,但 16 号单元里躺着的是总和,17 号单元是第一次累加,整个结果区错位一格。
用一段断言就能在搭电路之前把这件事钉死:
# 拍级映射:先算清楚,再去连线 read_addrs = [t for t in range(16)] # 0 .. 15 write_addrs = [t + 16 for t in range(16)] # 16 .. 31 assert len(read_addrs) == 16 assert write_addrs == list(range(16, 32)) # 偏移量写错,这行立刻失败 assert set(read_addrs).isdisjoint(write_addrs) # 读写区不得重叠 for t in (0, 15): # 首尾两拍重点核对 print(f"第 {t} 拍: 读 RAM[{read_addrs[t]}] -> 写 RAM[{write_addrs[t]}]")把这段跑一遍,比在 Logisim 里对着地址线发呆十分钟有效。硬件的错误一旦发生就是逐拍累积的,而纸面上的映射表是静态的、可检查的。
4.2 单端口 RAM 的读写冲突与半周期时钟
Logisim 的 RAM 组件有个容易被忽略的性格:读是组合的,地址一变,输出立刻跟着变;写是边沿触发的,只有在时钟的有效沿才把数据输入端的内容打进去。这两件事如果共用同一个时钟沿,就会打架——在同一个沿,地址计数器正在翻到新值,RAM 的输出也正在随之变化,而写操作同时想把数据写进去,结果是写进去的到底是上一拍的累加和还是这一拍的,完全取决于门延迟的先后,表现为两种典型症状:要么只加载不存储,要么存进去的是上一次的结果。
常见的解法是让 RAM 单独吃一路周期只有主时钟一半的时钟。这样一来,一个主节拍内 RAM 会出现额外的有效沿,写操作被安排在读数据已经稳定、而地址还没翻篇的那个窗口里。时间轴摊开是这样的:
| 主时钟相位 | RAM 时钟 | 发生的事情 |
|---|---|---|
| 上升沿 | 第一个沿 | 地址计数器更新,读出新的 RAM 数据 |
| 高电平期间 | — | ALU 算出新的累加和,数据在总线上稳定 |
| 高电平后半段 | 第二个沿 | 把稳定的累加和写回结果区 |
| 下降沿 | — | 所有寄存器打拍,进入下一轮 |
这里的关键不是分频本身,而是“写必须在读之后、下一拍之前”这条约束。半周期时钟之所以能 work,是因为它硬性制造了一个比读稳定更晚、比地址翻转更早的时刻。如果只用主时钟,这个时刻不存在,你只能在“读到脏数据”和“写进旧数据”之间二选一。
另一处和它配套的设计是 RAM 的加载与存储引脚分离。数据输入和数据输出各走各的线,写使能单独一根,不要让双向总线上的三态控制去猜方向。双向总线看起来省线,实际上把“谁在驱动这根线”的问题留给了时序,出问题时探针上显示的是红色冲突值,根本看不出是哪一方在说话。
4.3 用或门把时钟锁住
“自动运算到 16 就停”这件事,最省器件的做法是把时钟本身切断。计数器数到 16,比较器输出 1,这个 1 和主时钟一起送进一个或门,或门的输出当作全电路的时钟:只要比较器输出 1,或门输出恒为 1,不再有跳变沿,所有边沿触发的寄存器、RAM、计数器全部停在原地,电路冻住。
为什么是或门而不是与门?两者都能让输出变成常量,从而消灭边沿。差别在停止后的电平:或门停下来时输出高,与门停下来时输出低。有些部件的使能或复位是低电平有效的,如果时钟停在低电平,这些部件可能被误触发;停在有效电平之外的高位更安全。更实际的理由是逻辑表达式的写法——写成“run = (cnt != 16),时钟与 run 相与”比写成“停止条件或进时钟”更不容易出错,因为或门那条路一旦接反,就是“没到 16 时钟先没了”,而现象和“比较器阈值写错”一模一样,排查时会往完全错误的方向走。
我更推荐用二选一来做门控:run 有效时选时钟,run 无效时选常量,停走状态一目了然,也不用担心两路输入的延迟差。或门是可行的,但它是那种“能跑,但下次改需求时一定让你后悔”的方案。
停时钟之外还要留一条后路:整个电路的所有清零端(计数器、累加寄存器、状态寄存器、显示寄存器)连在同一根线上,接一个按钮。一旦时钟被锁死在错误的时刻,没有这根线就只能重启整个模拟器。这是这个电路里唯一一个“后悔药”性质的部件,值得单独引出来。
5. 避坑与排查:五条实测记录
5.1 时钟启停:门电路拼判断为什么会失败
现象:判断“计数器到 16”的逻辑用一堆与门、非门拼出来,仿真跑几拍后时钟突然不再跳变,或者反过来,计数还没到 16 时钟就没了,清零按钮按了也没用。
原因:组合门拼出来的“到 16”信号在计数器翻转的瞬间会产生毛刺。计数器从 15 变 16 时,几位输出不是同时翻转的,几个门级联后输出上会出现一个极窄的脉冲;如果这个脉冲恰好和时钟沿撞上,或门输出被置 1,时钟当场锁死,而且锁死之后没有任何机制能把它解开——它的输入已经是常量了。
解决:换成比较器,把计数器输出和常量 16 比,比较器的输出是单根、干净的;再把这个信号打一拍(经过一个寄存器)之后才参与门控,让它和时钟沿错开。清零端统一接一个按钮,作为最后手段。
现象:计数明明没到 16,时钟就停了;或者到了 16 还在继续跑。
原因:把“停止条件”直接当成了“使能条件”,或门和与门接反了。clock OR stop和clock AND run在语义上是同一件事的两种写法,但写成clock AND stop就变成了“到 16 才开始跑”。
解决:统一改写成run = (cnt != 16),用 run 去和时钟相与,或者在二选一里选时钟。别在同一张图里混用两种极性写法。
5.2 RAM 时序与地址:那两个最容易翻车的地方
现象:累加跑完,结果区里只有 16 号单元有值,17~31 号全是初始值;或者每个单元都有值,但值全是同一个数。
原因:读写共用一个时钟沿,写操作要么没被触发(写使能在错误的相位有效),要么每次写进去的都是同一个时刻的数据。RAM 的写是边沿敏感的,沿来的时候数据输入端是什么,写进去的就是什么,不会等你后面的组合逻辑算完。
解决:给 RAM 单独一路周期为主时钟一半的时钟,把写沿推到读数据稳定之后;写使能单独一根线,别和数据总线共用。改完之后用探针逐拍观察“地址 / RAM 输出 / ALU 结果 / 写使能”这四个点,确认写使能有效的那个时刻数据已经稳定。
现象:总和是对的,但结果区整体错位一格,16 号单元存的是最终和,而不是第一次累加的结果。
原因:写地址的偏移量用了 15 而不是 16,或者反过来。根源是第二个计数器的起始状态和第一个不一致,一个 0-based 一个 1-based。
解决:先在纸面上列拍级映射表,读地址、写地址、累加和寄存器三行并排,逐拍核对首尾两拍;再用 4.1 节那段断言在代码里钉死。别在仿真里试——错了也能跑,只是错得很隐蔽。
5.3 封装与观测:没有输入的电路怎么看内部
现象:封装成子电路之后,主电路上只剩几个数码管,内部的计数器值、RAM 读出值、累加和、状态位全都看不见,出了问题无从下手。
原因:这个电路没有外部输入,所有状态都在内部,封装把原先能点开看的每一根线都藏起来了。很多人第一次做这个实验会不适应,因为没有输入引脚可以手动改值来试。
解决:用探针加隧道。把计数器输出、RAM 读数据、累加和、PSW 三位各自接一个探针,接上同名的隧道,然后在主电路最上面一行引出来,用十进制显示——十进制是给人看的,一眼能对上心算结果。运算结果本身用十六进制数码管显示,因为十六进制能完整对应 16 位,十进制会截断。
现象:数码管上的数字一直在跳,根本读不出最终结果。
原因:每一拍都在刷新显示,而中间结果本来就是变化的。
解决:用比较器的输出接一个二选一选择器,只在计数到 16 时把最终值送上数码管,其余时刻显示保持。观察中间过程靠探针,不靠数码管。
6. 设计取舍:把时间问题换成空间问题,代价在哪里
回过头看这个电路,几乎所有关键决策都是同一个动作:把“什么时候做”换成“在哪里做”。
运算器里 12 路部件并行摆着,由多路选择器选一路出去,这是把“分时复用”换成了“空间冗余”。代价是门数,收益是延迟只有一级。这个交换在 Logisim 里几乎是免费的——反正器件不限量,多摆几个运算器不花钱。但在真实芯片上,这个交换是有价格的:面积、功耗、布线拥塞。所以真实处理器的做法是折中:算术逻辑单元内部确实并行,但地址计算会复用同一个加法器,因为那一路的频率和时序要求不一样。理解了这个价格,你才知道哪些地方可以无脑并行,哪些地方必须抠。
RAM 那一路的取舍更微妙。用半周期时钟解决读写冲突,本质是用相位换节拍,让两个操作挤进同一个主周期里。代价是时序分析变复杂——现在电路里有两个时钟域,它们之间的相位关系必须靠人为保证,一旦某天你改了主时钟频率而忘了同步调整 RAM 那一路,整个电路就会以一种“看起来能跑、偶尔错一次”的方式坏掉。更保守的方案是同一时钟下用状态机把一拍拆成两拍,读一拍、写一拍,时序干净得没有任何悬念,代价是 16 次累加要跑 32 拍。如果这个电路是某个更大系统的一部分,我会毫不犹豫选后者;如果只是课堂上的验证电路,前者更省器件。这个判断没有标准答案,但你必须知道自己在为哪个目标付钱。
验证方法也一样。我后来养成一个习惯:任何带反馈的时序电路,先写三张表再动手连线——期望值表(每个输入对应的正确输出)、拍级映射表(每一拍各个寄存器和地址的值)、状态位表(什么条件下哪一位应该置起)。三张表在纸上对齐了,连线就只是翻译工作;对不齐,说明设计还没想清楚,连上去也是白连。这个电路里的每一个坑,回头看都是某一张表没写全。
从那以后,我每次搭带反馈的时序电路,都强制先写一遍拍级时序表,把“第几拍、地址是多少、寄存器里是什么”这三列并排填满,再打开软件开始拉线。它比任何调试技巧都管用,因为它把错误挡在了连线之前,而不是等到数码管跳出一个看不懂的数再去猜。希望帮到你。
本文还有配套的精品资源,点击获取