大西冰城的博客

8051模拟器的设计与实现

Word count: 2.3kReading time: 8 min
2026/01/03
loading

各种开发板已经买了不少,实在不想再买新的了,于是想着干脆自己写一个 8051 模拟器——8051 的指令集一共就 111 条,用软件实现完全可行。最终用 Rust 实现了一个 8051 模拟器,代码已开源在 GitHub,欢迎参阅:
mcs51-emulator

为什么用Rust来实现8051模拟器

在此之前,我曾写过一个”甜品级”解释型编程语言,叫Sparrow(中文名叫灵雀),用C语言实现的仓库链接(已经烂尾)。构建过程中遇到了无数次内存泄漏和野指针,即使借助AI,解释器里还是留着不少这类问题。这次改用Rust,内存安全这块就不用操心了,可以把精力放在模拟器逻辑本身。

CPU设计思路

在实现8051模拟器时,最大的挑战是如何准确地建模硬件行为。我先查阅了8051的相关资料,理解了其核心组件的工作原理,然后开始思考如何在软件中表达这些硬件结构。

寄存器怎么初始化

8051的寄存器系统看似简单,但在软件中模拟时需要考虑很多细节,最直接的一个就是初始值的问题。真实的硬件上电后,寄存器的值是未定义的,但在模拟器中必须给出一个确定的初始状态。按照惯性思维从0开始:大部分寄存器初始化为0,程序计数器PC从0x0000开始(符合8051的复位向量)。

堆栈指针SP则设为7,这是8051硬件的标准复位值。查阅资料后得知,这个值是有特殊原因的:Bank 0是8051使用的默认寄存器库,它占用了地址0-7这8个寄存器(R0-R7)。如果SP从0开始,那么在压栈操作时会覆盖这些工作寄存器,导致未定义的行为。因此,SP初始化为7,确保堆栈从地址8开始向上增长,避免与Bank 0的寄存器冲突。通常在实际开发中,启动代码会将SP设置到更高的位置以留出足够的堆栈空间。

这个方案既保证了可重复性,又不影响正确程序的执行——因为编写良好的程序本来就不应该依赖未定义的初始值。

内存设计

8051的内存架构比较特殊,它有256字节的内部RAM、128字节的特殊功能寄存器(SFR),以及最大64KB的程序存储器。8051采用哈佛架构——程序存储器和数据存储器是完全分离的,你不能用数据指针去读取程序代码,也不能在程序存储器中存放变量。

在设计数据结构时,我选择为RAM、SFR和ROM创建独立的存储空间,而不是用单一的大数组。这样做主要是为了结构清晰和代码可维护性,不同的内存区域有不同的访问规则(比如SFR的某些位有特殊含义,ROM是只读的),分离存储可以让代码更易于理解。

在实际实现过程中,我还在CPU结构体中添加了一些额外的字段,比如debug标志和delay_skip_counter,这些都是在测试和调试过程中发现需要的。

延时循环太慢怎么办

在测试模拟器时,我遇到了一个意料之外的问题。我加载了一个简单的LED闪烁程序,这是8051开发中最经典的”Hello World”。程序逻辑很简单:点亮LED,延时500ms,熄灭LED,再延时500ms,然后循环。但当我运行这个程序时,模拟器似乎卡住了,风扇开始狂转,CPU占用率飙升到100%,而终端里没有任何输出。我以为是代码哪里写错了,导致了死循环,于是加了一些调试输出。结果我发现模拟器确实在正常执行指令,但速度慢得令人发指——500ms的延时竟然让我的电脑跑了好几分钟。

仔细分析延时函数后发现:在真实的8051硬件上,假设时钟频率是12MHz,那么500ms内能执行的机器周期数是 (0.5秒 × 12,000,000Hz) ÷ 12 = 500,000个机器周期。而一个典型的延时函数包含嵌套循环,每次循环都有变量自增、条件判断、跳转等多条指令,实际执行的指令总数可能达到数百万条——每条都要解码、执行、更新状态,软件层面的开销比硬件大得多。

不做优化的话,任何包含延时的程序都会把模拟器拖死。但不能简单地跳过延时函数,因为我不知道哪些循环是延时,哪些是真正的业务逻辑。我需要一种机制来自动检测忙等待循环,并智能地加速它们的执行。

循环检测器

为了解决这个问题,我设计了一个循环检测器(LoopDetector)。它的核心思想是:通过跟踪程序计数器(PC)的历史记录来识别循环模式。在8051的程序中,循环通常表现为后向跳转——PC从一个较大的地址跳回到较小的地址。当检测到这种模式时,就开始计数,如果同一个循环执行了足够多次(比如100次),就认为这可能是一个忙等待循环,触发”快进”机制——直接增加大量的时钟周期,而不实际执行那么多指令。

但这个设计也有很多细节需要权衡。阈值方面,触发快进的循环次数太小可能误判正常逻辑,太大则延时依然明显,最终设置为100次,是多次实验后的平衡值。快进力度方面,采用了动态调整的策略——如果同一个循环反复触发快进,说明这可能是嵌套的延时循环,就加大快进力度,从最初的1000万个周期逐步提升到100亿个周期。

循环检测器还需要处理一些边界情况,比如区分程序正常结束(一个sjmp $的自跳转指令)和真正的死循环,以及避免在I/O操作密集的循环中错误触发快进。我添加了has_io_in_loopio_operation_count等字段来跟踪循环中的I/O操作,还实现了is_deadlock()is_program_end()等辅助方法来区分不同的循环类型。

指令实现

8051有111条指令,涵盖了数据传输、算术运算、逻辑运算、位操作、跳转和调用等各种操作。我的做法是将这些指令按功能分类,每类指令放在一个独立的模块中。

每个指令的实现都遵循相同的模式:读取操作数、执行操作、更新状态、可选的调试输出。比如在算术模块中,inc_acc函数只做一件事——把累加器加1,使用wrapping_add确保溢出行为与硬件一致。

在实现过程中,我发现有些指令的行为并不像表面上看起来那么简单。比如乘法指令MUL AB,它不仅要计算A×B,还要把16位的结果分别存入A(低8位)和B(高8位)。再比如位操作指令,需要能够访问特定字节的特定位,这要求我实现额外的位寻址逻辑。这些细节都是在实际测试中逐步发现和完善的。

外设模拟

当CPU核心基本完成后,我开始考虑外设的模拟。8051的外设包括4个8位并行I/O端口(P0-P3)、两个16位定时器/计数器、一个串口等。但在当前阶段,我只实现了I/O端口的基本框架,定时器和串口还在规划中。

I/O端口是最基础的外设,几乎所有8051程序都会用到,而且实现相对简单,本质上就是读写特殊功能寄存器(SFR)。我为每个端口定义了SFR地址,并实现了read_sfrwrite_sfr方法来处理对这些寄存器的访问。

但即便是看似简单的I/O端口,实现起来也有一些微妙之处。比如P0端口是漏极开路输出,需要外部上拉电阻才能输出高电平;P3端口的某些引脚具有第二功能(如串口的RXD和TXD)。这些特性在当前的实现中还没有完全模拟,因为它们涉及到更复杂的硬件行为。我的策略是先把基本功能做好,确保简单的程序能正常运行,然后再逐步增加这些高级特性。

定时器和串口的实现会更复杂,因为它们涉及到时序和中断。定时器需要在每个机器周期自动递增,达到溢出值时产生中断;串口需要模拟波特率、数据位、停止位等通信参数。这些功能我计划在下一阶段实现。

CATALOG
  1. 1. 为什么用Rust来实现8051模拟器
  2. 2. CPU设计思路
    1. 2.1. 寄存器怎么初始化
    2. 2.2. 内存设计
    3. 2.3. 延时循环太慢怎么办
    4. 2.4. 循环检测器
  3. 3. 指令实现
  4. 4. 外设模拟