Logisim自动运算器:16位ALU与双计数器时序设计 简介面向数字电路与计算机组成原理课程学习者这份Logisim 16位自动运算器实验报告围绕16位ALU和时钟驱动的自动累加电路展开可用于理解运算通路、RAM/寄存器/计数器协作及时序控制。压缩包共1个doc文件约341KB按实验目的、实验环境、实验内容、方案设计、过程调试、测试分析、总结与心得、参考文献组织涵盖加减乘除、逻辑与或非异或、移位、状态字判断以及RAM 0—15单元累加、结果回存16—31单元、关键点十进制探测显示和运算结果十六进制数码管展示等要点。报告还呈现16位运算器功能表、自动电路封装思路与常见故障排查过程便于对照复现完整数据流。已有4049人学习适合课程实验复现、实验报告撰写参考与查漏补缺。1. 自动运算器真正难的是“什么时候停”而不是“算”把「logisim 自动运算器」这个题目摊开看多数人的第一反应是把加减乘除、与或非异或、四种移位拼成一个 16 位 ALU——这部分课本上已经讲得很透了照着连都能连出来。真正容易让人卡住的从来不是怎么算而是另外两件事一个没有程序计数器、没有指令译码的纯硬件电路凭什么自己知道“第 16 个数已经加完该收工”以及一片只有一个地址口的 RAM凭什么能在同一个节拍里既读出 015 号单元的旧值、又把中间结果写进 1631 号单元。这两个问题靠多连几根线是过不去的它们要求你正面回答时钟沿到底在哪一刻生效组合逻辑和时序逻辑的边界划在哪。把这两件事想明白这个电路上每一根线你都能说出它为什么在那里。2. 16 位运算器一份功能表如何压成一条数据通路2.1 从功能表反推操作码编码功能表是整个运算器的“合同”它规定了输入端的 4 位选择信号与输出结果之间的映射。把那 12 种运算按顺序排下来编码几乎是自然形成的操作码功能操作码功能0000F A B1000F A 逻辑左移0001F A − B1001F A 逻辑右移0010F A × B1010F A 算术左移0011F A ÷ B1011F A 算术右移0100F A AND B—保留0101F A OR B—保留0110F NOT A—保留0111F 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 位输出两个分离器的线序错开一位空出来的那一位接常量。逻辑左移就是把输入的 014 位接到输出的 115 位输出第 0 位接常量 0逻辑右移方向反过来输出第 15 位接 0算术右移的空位不接常量而是把输入的最高位符号位引过来接上。三种移位的语义差别用代码对照最清楚def shift(kind, x, n1): 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 位相加向符号位进了 1C14 1符号位本身是 001 没有向外出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 位单元015 号放 16 个被加数1631 号放累加的中间结果。读写地址区间完全不重叠这一条很关键——正因为读的和写的不是同一个单元一个节拍内的读和写才有可能并行不需要先读后写的串行等待。地址由一个计数器驱动每拍加 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 号单元有值1731 号全是初始值或者每个单元都有值但值全是同一个数。原因读写共用一个时钟沿写操作要么没被触发写使能在错误的相位有效要么每次写进去的都是同一个时刻的数据。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 拍。如果这个电路是某个更大系统的一部分我会毫不犹豫选后者如果只是课堂上的验证电路前者更省器件。这个判断没有标准答案但你必须知道自己在为哪个目标付钱。验证方法也一样。我后来养成一个习惯任何带反馈的时序电路先写三张表再动手连线——期望值表每个输入对应的正确输出、拍级映射表每一拍各个寄存器和地址的值、状态位表什么条件下哪一位应该置起。三张表在纸上对齐了连线就只是翻译工作对不齐说明设计还没想清楚连上去也是白连。这个电路里的每一个坑回头看都是某一张表没写全。从那以后我每次搭带反馈的时序电路都强制先写一遍拍级时序表把“第几拍、地址是多少、寄存器里是什么”这三列并排填满再打开软件开始拉线。它比任何调试技巧都管用因为它把错误挡在了连线之前而不是等到数码管跳出一个看不懂的数再去猜。希望帮到你。本文还有配套的精品资源点击获取