资讯动态

用Python写一个91行可视化虚拟机:从取指译码到跳转执行

发布时间:2026/10/15 23:15:22 来源:尧图企业网站定制
大概是被计算机原理折腾过几次之后大部分人都动过同一个念头亲手做一台能跑的机器。真去买一堆 74 系列芯片搭面包板钱包和时间都不太允许上手写一个完整的指令集模拟器又容易陷入几千行代码的泥潭。于是就有了第三条路——用几十行代码做一个玩具虚拟机把取指、译码、执行、跳转这些概念全部塞进一个能亲手拨弄的小盒子里。我手上的这个项目叫 Mini Virtual Machine是一个用 Python 写的可视化虚拟机模拟器整个项目压缩在 91 行内。它能做的事情很纯粹加载一段“机器码”到一块 100 字节的假内存中然后一屏一屏地把程序计数器 PC、指令寄存器 IR、累加器 A 和内存网格的实时状态显示出来支持单步与自动播放。这篇文章就把这个 91 行的小东西从设计目标、指令集、执行引擎、可视化到验证过程完整拆一遍适合刚接触寄存器、内存映射、指令周期的人也适合想快速起一个教学 Demo 的开发者。1. 先想清楚这台 91 行的机器要模拟到什么程度1.1 计算机的最小骨架五个部件一个循环一台计算机哪怕是最原始的也要回答四个问题程序存在哪里、现在执行到哪一条、刚取出来的指令是什么、计算结果放哪里。对应到硬件上一套典型的最小骨架是内存存放指令和数据、程序计数器 PC指向下一条指令的地址、指令寄存器 IR暂存刚取出来的指令、累加器 A参与运算的工作寄存器再加上一个能完成加减的运算单元。这五个东西连起来就是冯·诺依曼结构里最核心的“取指-译码-执行”循环。我用一个生活化的类比来解释PC 是书签内存是一叠写满菜谱的卡片IR 是你刚从卡片上抄下来的那道菜名A 是灶台上的临时工作台。每做一道菜你按书签位置抽出一张卡片抄下菜名然后去工作台配料、下锅最后把书签往后移一页。如果菜谱上写着“下一道菜请翻到第 40 页”你就手工把书签插到那一页——这就是跳转指令。用这个思路去审视需求很快就能知道哪些东西是必须留下的一段内存、三个寄存器级别的变量、一个执行器、一个输出口。不需要中断、不需要特权级、不需要多任务、不需要浮点。这个项目的最小可行产品到此为止就缩得很小了。1.2 为什么不拿现成模拟器非要自己写我调研过很多现成的仿真器和调试工具功能都强大得没话说断点、单步、内存查看、反汇编一应俱全。但这些工具最大的问题是它们太完善了。对初学者来说在断点面板里看到 PC 发生了变化很难想清楚这个变化是哪一步代码造成的因为框架替你完成了太多隐式操作。自己写的 91 行模拟器正好相反。每一行代码的推进都由你自己的逻辑驱动fetch 阶段做了什么、decode 阶段做了什么全都摊在眼前。你可以随时在某条指令执行前停住问自己“现在 IR 里到底装的是什么”然后去代码里找到答案。这种“裸奔”的状态恰恰是学习计算机组织最好的状态。1.3 91 行的“行数预算”怎么定做这种小项目行数限制不是自虐而是一种产品决策。我的分配大概是数据结构 8 行指令加载 3 行核心执行循环 16 行可视化渲染 22 行交互控制 8 行入口和参数处理 7 行剩下的留给必要的注释和空行。这个预算表最实际的价值是逼着你在“加功能”和“保简洁”之间做减法。我一开始想给每个内存地址加一个写入次数统计用热力图的方式标出哪些地址被频繁更新——那至少要多 20 行而且会让核心逻辑淹没在统计代码里。最后砍掉了。因为 91 行这个约束本身就在提醒你一个教学项目最重要的使命是让人看懂而不是让人震撼。行数限制也改变了代码风格。整个项目里几乎没有任何面向对象的抽象全是全局变量和函数。在真实工程里这可能不够整洁但在教学场景下一眼能看懂的代码就是最好的代码。1.4 先从运行效果倒推可视化画什么写任何代码之前我先在纸上画了运行画面。屏幕顶行显示三个寄存器PC、IR、A中间是 10×10 的内存网格每个格子占 3 位宽度左侧标地址底部是输出区。执行一步后整个画面重画一次当前 PC 指向的格子反白累加器 A 的值出现在顶行。把画面定下来有一个好处实现目标变得很具体。“PC 高亮”听起来很玄落到实处只是在打印内存网格时对当前地址的格子使用 ANSI 反白“A 寄存器显示”只是打印字典里的一个值。边学边做的人最怕任务不清不楚而一个可视化的目标能把很多模糊概念立刻钉死在屏幕上。运行那个 1 加到 10 的求和程序时你会看到顶行的 A 从 1 变成 3、6、10、15一直累加到 55内存地址 72 那个格子里循环变量 i 一节一节地跳底部输出区终于打出 55。这种直观反馈比任何 PPT 都让人有踏实感。2. 指令集定型9 条指令刚好覆盖计算、跳转和输出2.1 为什么用三位十进制数当机器码真正的机器码是二进制如果在这台教学虚拟机里塞满二进制文件读者的第一道坎就变成了进制转换而不是计算机组织。所以我刻意选择了三位十进制数作为指令编码最高一位是操作码0-9低两位是地址或立即数0-99。比如201表示“把立即数 1 装进累加器 A”171表示“从内存地址 71 取出数放入 A”。用 Python 解析时只有一行op, arg divmod(ir, 100)。这个设计保留了“机器码本身很反人类”的真实感又让译码逻辑透明得像教科书。我一度想用十六进制比如0xC8这样的写法后来放弃了。十进制低两位天然对应 0-99 的地址范围打印内存网格时可以直接对齐省掉一层心智负担。这个取舍在 91 行的约束下尤其重要。2.2 一条命令就是一张表9 条指令的语义编码助记符动作说明0xxHLT停机只要操作码为 0 即停机低位忽略1xxLDA addrA M[addr]从内存地址 addr 取数到 A2xxLDI immA imm指令自带的立即数直接装进 A3xxSTA addrM[addr] A把 A 的值写回内存地址 addr4xxADD addrA M[addr]把内存地址 addr 的值加到 A5xxSUB addrA - M[addr]用 A 减去内存地址 addr 的值6xxJMP addrPC addr无条件跳转到 addr7xxJZ addr如果 A 0 则 PC addr条件跳转这是控制流的灵魂8xxOUT addr输出 M[addr]把内存地址 addr 的值送到输出区这张表里有几组指令特别值得讲。LDA和LDI是最容易混淆的一对。LDA是“从抽屉里把东西拿出来”LDI是“指令纸片上直接写死一个数”。同一个累加器 A 接收数据但数据来源完全不一样初学者一旦绕晕后面的程序全都会错。JZ则是教学重点。条件跳转实现的是“比较”的高级语义但 CPU 层面并没有一条单独的“比较指令”。这里用减法代替拿上限减去循环变量如果结果为 0说明两者相等就跳转。理解了这一点你就明白为什么汇编程序里到处是“先减一次、再判断零”的套路。为什么没有乘法、除法、函数调用因为 91 行要的是“最小的完整循环”。乘除可以用 ADD 和 SUB 的循环拼出来函数调用需要额外的栈结构它们都可以作为扩展章节的作业。保留最小的指令集才能让每一步执行都清晰可追踪。2.3 第一段真实程序求 1 到 10 的和有了指令表我写了这段演示程序它最终被加载进内存运行结果应该是 55。PRG [200, 372, 373, 171, 472, 372, 473, 373, 170, 572, 713, 603, 0, 873, 0] M[70] 10 # 上限 M[71] 1 # 步长 M[72] 0 # 循环变量 i M[73] 0 # 累加和 sum逐条看200LDI 0把 0 装进 A。372STA 72把 0 存到循环变量 i。373STA 73把 0 存到累加和 sum。171LDA 71从地址 71 取出步长 1。472ADD 72A 1 i也就是 i 1。372STA 72把 i 1 存回 i。473ADD 73A (i 1) sum相当于 sum i。373STA 73把新的和写回 sum。170LDA 70取出上限 10。572SUB 72计算 10 - i。713JZ 13如果结果为 0说明 i 已经到 10跳到地址 13。603JMP 3否则跳回循环头。873OUT 73输出 sum。000HLT停机。这段程序真正暴露了汇编语言的笨拙之处一个高级语言里再普通不过的sum i在这里要经过“取步长、加 i、存回 i、再加 sum、存回 sum”整整五条指令。但正是这种笨拙才能让人理解编译器替我们省下了多少事。3. 执行引擎fetch-decode-execute 压缩进十几行3.1 数据结构内存是列表寄存器是字典执行引擎的数据结构总共三行M [0] * 100 R {A: 0, PC: 0, IR: 0} O []M是内存一个 100 个元素的列表下标就是地址。考虑到指令编码里低位只有两位地址范围 0-99 刚刚好。R是寄存器用字典存而不是单独的变量是因为可视化打印时可以直接写fPC{R[PC]}语义一清二楚以后要扩展 SP、FLAG 这类寄存器也不用改数据结构。O是输出列表OUT指令产生的数据都先存到这儿最后统一显示。为什么用 Python 列表而不是更接近硬件的字节数组因为列表元素可以是任意整数SUB 产生负值时不需要处理无符号回绕。真正的 CPU 要靠补码和溢出标志来应对这种情况但这里刻意把硬件复杂度剥掉了让你把注意力放在执行流程上。3.2 主循环先取指、再自增跳转就是覆盖 PC每执行一条指令本质上是做一次 fetch、decode、execute。核心函数是这样写的def step(): ir M[R[PC]] R[IR] ir R[PC] 1 op, arg divmod(ir, 100) # 接下来按 op 分派……这里有一个特别容易被忽视的顺序问题先取指再自增 PC。默认情况下每条指令执行完PC 自然指向下一条指令如果遇到 JMP直接把 PC 覆盖成目标地址刚做的1就被覆盖掉了。这个顺序是教科书里最经典的 CPU 约定也是后面第 5 章会讲的常见 bug 来源。divmod(ir, 100)一行就完成了译码。比如713会被拆成op7, arg13立刻知道这是一条 JZ目标地址是 13。没有复杂的按位与运算没有位掩码一切都摊在桌面上。3.3 九路译码执行没有魔法的一长串 elif完整的指令分派如下if op 0: return False elif op 1: R[A] M[arg] elif op 2: R[A] arg elif op 3: M[arg] R[A] elif op 4: R[A] M[arg] elif op 5: R[A] - M[arg] elif op 6: R[PC] arg elif op 7 and R[A] 0: R[PC] arg elif op 8: O.append(M[arg]) else: return False return True一长串elif看起来完全没有魔法。这恰恰是最佳教学形态你可以把这段代码和指令表并排放在一起一个格子对应一个分支。如果用一个字典做 table dispatch代码会更短更优雅但指令语义会散落到一堆小函数里失去“一张展开的指令表”的视觉效果。op 0的停机判定有一个细节只要操作码是 0就返回 False低位是什么都会被忽略。这意味着000、001、099都会停机。这比“整条指令等于 0”的判断更可控后面验证章节还会再提。3.4 故意不做的内存保护抛异常也是一种反馈我第一版对非法地址做了保护如果arg len(M)就停机。这个保护让问题反而更难发现——一个跳转到数据区的程序如果数据值恰好是 0虚拟机会静默停止看起来像“程序正常结束”其实是非法行为。第二版干脆不做显式保护让 Python 的IndexError直接抛出来。虽然不优雅但在教学里反而是好消息你能立刻看到“PC 指向了地址 100而内存只有 100 个格子”提示比什么都不说强得多。真要交作业的人可以自己补上错误处理也就是多写 4 行的事。4. 可视化让看不见的时钟周期变成看得见的动画4.1 终端动画、HTML 页面还是 GUI为什么选了终端可视化方案当时有三个候选GUI 库、HTML 页面、终端清屏重绘。GUI 库能做出漂亮的按钮和滚动条但行数预算直接爆炸。HTML 页面可以把内存网格渲染成表格还能用 CSS 做高亮动画效果确实好但需要额外维护网页交互逻辑对 91 行项目来说太重了。终端方案的优势是零依赖任意一台有 Python 的机器都能跑而且清屏重绘的动画效果已经足够传递“指令在流动”的感觉。所以我选了终端。这条经验对教学类工具尤其适用可视化不是为了炫酷而是为了把抽象概念变成可观察的变化。终端虽然朴素但它把“每执行一步就看一眼状态”这个闭环做到了最轻。4.2 清屏重绘只有三行的动画引擎终端动画的技术其实特别简单每次执行完一条指令清屏重画停顿一下再执行下一条。原理和翻页动画一模一样旧画面被清除新画面在零点几秒内出现人眼就产生了变化的感觉。清屏要兼容不同系统os.system(cls if os.name nt else clear)自动播放模式下每两步之间加一个time.sleep(0.3)。这个节奏是我反复试出来的太快看不清楚太慢又让人着急。为什么不选择让控制台一直滚动打印因为滚动刷屏之后旧状态全都堆在远端你很难对比“上一步 PC 在哪、这一步在哪”。清屏重绘虽然简单粗暴但它让每一步的状态都稳稳占据同一个物理位置视觉上形成一个稳定的观察窗口。4.3 ANSI 反白高亮把 PC 变成屏幕上的一道光让 PC 指向的内存格子在网格里反白是可视化里最出效果的一步。ANSI 转义序列\033[7m开启反白\033[0m恢复默认。配合 f-string 可以这样打印for off in range(10): v M[base off] if base off R[PC]: cells.append(f\033[7m{v:3d}\033[0m) else: cells.append(f{v:3d})自动播放模式下反白格会沿着程序流一跳一跳地移动那种“亲眼见到 PC 在跑”的感觉比任何动画库都直接。我第一次跑通求和程序时盯着那个反白格在循环头跳来跳去突然理解了“程序计数器”到底是个什么存在。如果你的终端对 ANSI 支持不全把\033[7m和\033[0m临时替换成星号效果打折一点但逻辑不变。4.4 三种控制模式单步、自动播放和退出我配了三种控制模式默认单步执行每走一条指令都要按回车传-a参数进入自动播放单步模式下输q直接退出。核心控制逻辑非常短auto len(sys.argv) 1 and sys.argv[1] -a while True: dump() if not auto: key input(回车 next, q 退出: ) if key q: break if not step(): break if auto: time.sleep(0.3) print(停机)单步模式的价值在于因果观察。你能明确看到“A 刚变成 5是因为这条 ADD 执行了”而不是动画一闪而过、根本不知道哪一跳对应哪一行。这就像读代码时用 debugger 逐行跑每一步都看得明明白白。4.5 屏幕布局三块信息对应三层抽象整屏布局刻意模仿了计算机组成的教学模型顶部寄存器、中部内存、底部 IO。三块信息分别对应 CPU、内存、外设三层抽象代码也按这个分区组织。def dump(): os.system(cls if os.name nt else clear) print(fPC{R[PC]:2d} IR{R[IR]:3d} A{R[A]:3d}) for base in range(0, 100, 10): # 打印 10 个内存单元 ... if O: print(OUT: .join(str(x) for x in O))打印内存网格时每行 10 个单元行首标出地址。100 字节正好排成一个 10×10 的方阵一屏看完又和指令低两位的地址范围对齐。这种布局上的呼应是 91 行项目里少有的“设计感”。5. 验证正确性一台虚拟机怎么证明自己没写错5.1 纸面追踪几乎免费的第一道测试最有效的验证不是写测试框架而是对一个只有十几条指令的小程序做纸面推演。拿最简单的示例从地址 10 取一个数加上地址 11 的数存到地址 12 并输出。程序是[110, 211, 412, 312, 812, 0] M[10] 5 M[11] 3手工推演时逐条记录 PC、IR、A、M[12] 的变化。走完之后在虚拟机里用单步模式跑一遍对比每一步的状态快照。哪怕只花十分钟这套纸面追踪加对比也能找出大部分执行 bug。为什么纸面追踪有效因为虚拟机的正确性定义非常明确最终内存状态和输出结果符合预期。只要顺序执行、跳转、停机这三类控制流各自验证过整个机器几乎不可能出错。5.2 最容易踩的三个坑先取指还是先自增、停机判定与条件跳转这三个坑我在这类玩具项目上反复踩过值得单独说。第一个坑是“取指后到底先不先自增”。如果先自增再取指JMP 的目标地址会整体偏移一条指令整个程序跑得乱七八糟。记法很简单默认 PC 指向“下一条”跳转指令只是把这个“下一条”改写掉。第二个坑是“把停机指令判断写成整条指令等于 0”。如果你写while M[R[PC]] ! 0作为主循环一旦 PC 跳到一个恰好内容为 0 的数据区机器会静默停机排查起来非常崩溃。正确做法是检查op 0也就是只关注操作码不关心低位。第三个坑是 JZ 与 JMP 写反。条件跳转没有写条件或者无条件跳转用了条件跳转的操作码程序就会在循环里疯狂乱跳。我的经验是每条汇编语句旁边写上机器码和语义写代码之前先对一遍指令表。5.3 91 行的边界哪些功能被砍掉哪些可以加回来这个项目砍掉了很多东西但正因如此才干净。如果要按扩展优先级排第一该加的是栈增加 SP 寄存器配上 PUSH、POP、CALL、RET 四条指令大约多 30 行就能支持子程序调用和递归。第二是乘法或者更宽的数据类型。第三是做一个真正的汇编器让你直接写STA 72而不是手数372。可视化方向的扩展同样有趣。可以给修改过的内存单元加一个“热度”效果刚写过的格子闪一下颜色可以加一个输出曲线图把 OUT 序列画成散点也可以做成 Web 前端用 HTML 表格渲染内存网格用按钮实现单步控制那就变成了一个可以嵌入课件和博客的交互式 Demo。我后来还试过一种玩法把 PRG 数组改成从文本文件读取每行一条指令相当于拥有一个最简陋的“汇编器”加“加载器”。用记事本写求和程序存成 txt再交给 MiniVM 加载运行。虽然简陋但从文本到执行的链路被打通之后那种成就感非常具体。这也是 91 行小玩意最让人上瘾的地方——它什么都还没做但它已经把你拉进了“自己造计算机”的深坑。

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

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

免费获取报价 →
↑