飞利浦 PM5139 / PM5138A / PM5136 函数发生器的固件逆向工程:将 8051 仿真器用作测量仪器、35 个章节的硬件文档,以及修正后的固件 V2.0
一台约 1994 年的 20 MHz 函数发生器,在软件层面被拆解:两份 EPROM 转储、一个被当作测量仪器使用的 8051 模拟器,以及 35 节文档,其中每一条论断都有清单地址、模拟器测量结果或原理图作为依据。
最终成果是一个 固件 V2.0,修复了 Philips 出厂时的一个缺陷, 六个我们自己的任意波形,以及一个逐条指令运行原始 ROM 的浏览器模拟器。

程序 EPROM 中的每一个波形表,直接从二进制中绘制出来。右下角那个 开启了本项目最有趣的部分。
Philips PM5139 是三仪器系列(PM5136 / PM5138A / PM5139)中的 20 MHz 顶级型号。 内部有一块 PCB80C652 —— 带硬件 I²C 的 8051 内核 —— 一片 27512 程序 EPROM, 以及挂在串行总线上的六个模拟组件。
没有 PM5139 的维修手册。自 2010 年以来人们一直在论坛上寻找它。 存在的是其 10 MHz 姊妹型号 PM5138A 的手册,内部几乎完全相同。
所以本项目从另一端开始:转储 EPROM,弄清代码在做什么, 直到对仪器理解得足够透彻,能够修改它。
有两个固件版本可用,V1.3 和 V1.5,都是 64 KiB 的 M27512 转储。
| 反汇编 | 两个版本均完整,约 23 000 行,带交叉引用 |
| 注释清单 | 147 个命名例程,145 条头部注释,3 826 行注释 |
| 文档 | 35 节,4 600 行,每条论断均有出处 |
| 信号路径 | 频率、幅度、偏移、AM、FM、突发、对称性、扫描 —— 全部计算并与原始代码核对 |
| 硬件 | 全部 10 个选通、C 总线、与每个参与者的 I²C、端口、键盘、旋钮、显示位图 |
| 状态位 | 128 个中有 75 个具有已记录的效果 |
| 版本差异 | V1.3 与 V1.5 结构上 91.4 % 相同;每处改动均已命名 |
| 模拟器 | 一个 Python 版,一个 JavaScript 版(约 8 M 指令/秒),外加一个单文件浏览器模拟器 |
| 我们自己的固件 | V2.0 —— 修复了一个出厂缺陷,处理了校验和,在模拟器和真实硬件上验证通过 |
| 位置 | 类型 | 功能 |
|---|---|---|
| D301 | PCB80C652 | 带硬件 I²C 的 8051 内核,12 MHz |
| D306 | 27512 | 程序 EPROM —— V1.3 占用 0000h–AC70h |
| D310 | X28C64 | MOVX 总线上的任意 EEPROM |
| D305 | PCF8570 | I²C 上 256 字节电池后备 NVRAM(A0h) |
| D304-A | PCF8576 | I²C 上的 LCD 驱动器(70h),20 字节缓冲 |
| D302-A | SAA3007 | 键盘编码器,单线上脉冲宽度编码 |
| D307 | 74HCT4514 | 选通解码器 —— 选通编号即地址位 A8…A11 |
模拟侧是一条串行 C 总线:8051 的 UART 运行在移位寄存器模式,
TXD 是时钟,RXD 是数据,一个选通决定十个移位寄存器中哪一个锁存字节。
MOV DPH,#8nh 后跟 MOVX @DPTR,A 触发选通 n。
这一行就是整个模拟部分的关键。
这是值得你为自己的项目借鉴的部分。
用肉眼阅读 44 KB 的 8051 二进制文件大概只能让你走完三分之一。 其余的一切都来自运行原始代码并观察会掉出什么:```python
c = CPU(rom) for w in test_values: set_amplitude(c, w) c.call(0x0AAC) # the original routine, untouched print(w, c.ram[0x1C]) # the byte that goes out on STR9
改变输入,读取输出,对照假设进行验证。这对频率、幅度、偏移、AM 深度、FM 偏差、突发计数、对称性以及两种扫描特性都有效。文档中的每个公式都附带了其验证所用的采样点。
三项改进使其真正富有成效:
**观察总线,而非显示器。** 第 15 节测量了状态位对显示缓冲区的影响,128 位中有 74 位似乎不起作用。但其中许多并不驱动显示器,它们驱动的是*模拟组件*——而这些只能作为 C 总线上的电报可见。记录 `MOV SBUF,…` 和终止的 `MOVX @DPTR` 将已记录位的数量从 54 提升到了 75。
**按键,而非直接写 RAM。** 手动设置 RAM 字节会产生仪器从未进入的状态。这让我们付出了两个错误发现和一次撞入命令表的代价。通过模拟的 SAA3007 注入真实键码,可以得到固件实际到达的状态——正是对所有 256 个键码的暴力扫描揭示了哪个键触发哪个处理程序。
**首先怀疑你自己的模拟器。** 我们核心中的三个 bug 产生了“无法解释的”固件行为:`ACALL` 被执行为 `AJMP`,缺少辅助进位标志(因此 `DA A` 行为异常,固件看起来像是在用二进制计数),以及键盘中断被加倍。那个时期的所有发现之后都重新进行了测量。
---
## 走到这里的路
**先静态。** 一个带有完整操作码表的反汇编器,然后使用跳转表启发式进行递归下降。这产生了 30 508 字节的代码,并留下 13 637 字节无法解释。
**然后动态。** 一次跟踪运行——冷启动、全部 23 个前面板按键、旋钮的两个方向、每种操作模式、8600 万周期——标记每个实际执行的地址。与静态分析对照,它恰好发现了下降分析遗漏的**一个**区域,而 10 686 个无法解释的字节中有 5 个已知表块。
**然后是原理图。** 服务手册的 OCR 对原理图毫无用处,但 400 dpi 的页面图像非常出色。切成重叠的图块后,它们可读至引脚编号。六张图纸就是这样读出的——而在五条平行走线相距 90 像素的地方,目测被一个从位图中提取线段的脚本(`lines.py`)所取代。
**然后是两个被拔出的芯片。** 一个标有“SINUS 1.1”的 27C64 和一个 X28C64 被读出。两者都被放入原理图中,其内容被解码。
**然后是版本差异。** 对两个 ROM 进行标记化(使用相对跳转距离而非绝对目标),并对它们运行 `SequenceMatcher`,得到一个能在代码移动后仍然存活的地址映射——这就是 V1.3 符号被带到 V1.5 上的方式。
---
## 精彩之处
### Philips 交付了一个有噪声的波形
三个内置任意曲线位于 `A047h`、`A447h` 和 `A847h`。第三个的形状与 ROM 中已经以计算形式存在的表相同——但有 **563 次方向变化,而后者只有 13 次**,标准差为 4.1 LSB。
它是从模拟源采样而来,而非计算得出。偏差的均值为零,1024 个点中只有两个偏差超过 10——这不是不同的波形,而是*同一个*波形上带有噪声。
### 那个表是一个 30 dB 电平阶梯
干净版本在早期草稿中被描述为“一个带有十个 AM 深度的正弦波”,这是对图表的目测解读,而不是代码所说的内容。经过计算,1024 个点分成十个正弦弧,其跨度是```
255 171 120 80 56 38 26 17 12 8
一个公比为 0.681 = 10^(−1/6) 的等比数列,即每步 3.33 dB,总共 30.1 dB。减半模型最多偏差 56,3 dB 模型偏差 10。这是一个对数电平阶梯——一种幅度或衰减测试模式。
幅度控制器在同一个选通信号上有两个移位寄存器,但固件每次电报只发送一个字节。原理图解释了这一点:两个 4094 通过 **QS'(引脚 10)**级联,引脚 9 未使用——而且电报成对出现,相隔约 42 000 个周期,成对之间有数百万个周期的静默。先发送的字节会被推入第二个寄存器。
同样的级联模式出现在每一个拥有多个移位寄存器的组件上——包括一个案例,其中链路通过一条名为 E 的线跨越了组件边界。
STR9 电报中的五个位直接驱动继电器:S1 切换直流发生器量程,S2…S5 控制衰减器继电器。20 dB (for 40dB)、20 dB、50/600 ohms——这些都印在原理图上。没有需要计算的阈值。
位于 0301h 的跳转表通过 JMP @A+DPTR 读取。第 15 项落在 0301h + 30 = 031Fh——而那里,不是通常的 AJMP,而是处理程序本身,内联放置,省去了一次跳转。ROM 中没有任何跳转指令指向它,因此静态分析漏掉了它。它是 DIAL LOCK 处理程序,只有动态跟踪才发现了它。
数据手册承诺 24 个波形存储器。EEPROM 中的目录显示为六个。算术给出了结论:``` 1024 points × 10 bit, packed 4 values per 5 bytes -> 1280 bytes per curve 6 × 1280 = 7 680 bytes, 0100h…1EFFh (X28C64, 8 KB) <- what was fitted 24 × 1280 = 30 720 bytes, 0100h…78FFh (X28C256, 32 KB) <- what the schematic says
固件实测读取范围是 `0100h–1EFFh`——六条曲线,精确到字节。该仪器是用小芯片构建的。
### 与不存在的设备通信的死代码
`9AFFh` 处的 186 字节与地址 `5Ah` 进行 I²C 通信——该地址在其他任何地方都未出现。在**两个**固件版本中,都没有跳转指向它。它与 `5Eh` 处的接口卡位于同一设备类型块中,只是 bank 位不同,并且它以两条十字节电报发送接收缓冲区和算术寄存器。这看起来像是针对从未出货的设备的工厂诊断程序。
### 你无法从任意 EEPROM 执行代码
一个显而易见的想法——将代码放入任意波形槽并跳转到它——从一开始就行不通。8051 是哈佛架构:指令通过 `/PSEN` 从程序 EPROM 取指,数据通过 `/RD` 从任意 EEPROM 读取。这不是被阻止了;只是线路根本不存在。
### 最后是频率编码
显示数字行位于发送到 PCF8576 的图像中的 `3Eh–43h` 处,所有位置共享同一种段编码,而字节 `43h` 在第 7 和第 8 个十进制位之间从 kHz 切换到 MHz。由此:```
f = M · 10^(D−8) kHz
在真实仪器上测得的三段频率步进序列被此完全复现——包括那段因尾数 2500 意味着 25 MHz 超出限制而提前停止的序列。

左侧为出厂曲线,右侧为修正后的曲线。左下角是与计算表的偏差——那条 ±5 LSB 的带是采样模拟源留下的痕迹。
mkv20.py 从 V1.5(或 V1.3)构建 V2.0。它通过签名而非硬编码来查找每个地址,因此同一脚本可同时适用于两个源版本:
*IDN? 中:PHILIPS,PM5139,0,V2.0/0000。2.0。其余一切均未改动。还发现了另外三处异常,但有意保留——对不存在的 SFR 的写入(无害,两个版本中均有)、已失效的 5Ah 块,以及三个被测试但从未置位的状态位。修补它们不会改变任何行为,只会增加风险。
M27512_PM5139_V20.bin 正是此内容,别无其他。下面的旋律是一个独立的、可选的构建步骤。
已验证: 在模拟器中冷启动产生与 V1.5 相同的显示缓冲区和相同的标志,校验和验证通过,且构建是字节可复现的。它已被烧录并在真实的 PM5139 上运行——显示屏显示 2.0,且全部六个任意槽位均正常工作。
由于 V1.5 中校验和之后有 19 509 个未使用字节,且频率路径将音符频率作为三个 BCD 字节接收,该仪器可以通过自身输出播放音乐。
编码方式令人愉快地直接——第 3 位十进制,然后是以 0.01 Hz 为单位的 BCD 频率,因此 82.41 Hz 为 30 82 41。每个音符四个字节:三个用于音高,一个用于时长。
有趣的部分是触发机制。诊断菜单(开机时按住 LOCAL)有一个包含八个条目的跳转表,但菜单循环仅从 1 计数到 7 的 0Bh——因此第八个条目不可达。它也是冗余的:它跳转到菜单起始处,而该处本来就可以从另外两个地方到达。
因此整个钩子只有两个字节:``` 5B94h table entry 8: LJMP 5B45h -> LJMP 5B62h count limit: 08h -> 09h
没有丢失自检,没有表格被移动,也没有出现失效的菜单项。按住 LOCAL,打开开关,让菜单计数到 8,按一个键。
时序来自 MCS-51 数据手册。两个模拟器现在都在指令旁计数机器周期(`mcyc`,来自 `mcs51.CYCLES`),步进等待循环测得每单位 **1009 µs** —— 在 140 BPM 下每十六分音符 106.95 ms,偏离目标 0.2 %。这个数字以前是手工计算的 1006 µs,漏掉了两条指令。
`mkdoom.py` 也可以转换 MIDI 文件。必须选择一个声部(最高音、最低音或某一个通道),并且短于约 25 ms 的片段会被合并 —— 低于这个长度,低音无法完成一个完整振荡,你只会听到一声咔嗒。
---
## 然后它竟然是复音的
上面的旋律是一个声部。它不必如此,原因在于我们之前读过却忽略的服务手册中的一句话:
> 在信号生成期间,不同的信号幅度采样从 RAM 中读出。如果基本信号波形被改变 [...] 相应的幅度采样由 **CPU 加载到 RAM 中**。
PM5139 是一个 **1024 点波表 DDS**。TWS 在任何朴素意义上都不是三角波发生器 —— 它是一个相位累加器,为单元 4 上的快速 RAM 产生 0…1023 的读取地址,而该 RAM 由 CPU 通过 C 总线填充。正弦、方波、锯齿波和任意波形都是同一机制:一张表。
而这张表恰好保存**输出信号的一个周期**。因此,由*谐波之和*构建的表在其 1024 个点上仍然是周期性的,并且会作为和弦播放。不是琶音,不是调制技巧 —— 而是多个音符同时以完整的 20 Vpp 发声,在它们发声时 CPU 完全不做任何事。由于分音必须是表频率的整数倍,音程以纯律呈现,而对于持续和弦来说,这本来就是更好的调音。