
Firmware reverse engineering of the Philips PM5139 / PM5138A / PM5136 function generators: 8051 emulators used as measuring instruments, 35 sections of documented hardware, and a corrected firmware V2.0
A 20 MHz function generator from about 1994, taken apart in software: two EPROM dumps, an 8051 emulator used as a measuring instrument, and 35 sections of documentation where every single claim is backed by a listing address, an emulator measurement, or the schematic.
At the end of it there is a firmware V2.0 that fixes a defect Philips shipped, six arbitrary waveforms of our own, and a browser simulator that runs the original ROM instruction by instruction.

Every waveform table in the program EPROM, plotted straight out of the binary. Bottom right is the one that started the most interesting part of this project.
The Philips PM5139 is the 20 MHz top model of a three-instrument family (PM5136 / PM5138A / PM5139). Inside sits a PCB80C652 — an 8051 core with hardware I²C — a 27512 program EPROM, and six analogue assemblies hanging off a serial bus.
There is no PM5139 service manual. People have been looking for one in forums since 2010. What exists is the manual for the PM5138A, its 10 MHz sister model, which is internally almost identical.
So this project started from the other end: dump the EPROM, and work out what the code does until the instrument is understood well enough to modify it.
Two firmware versions were available, V1.3 and V1.5, both 64 KiB M27512 dumps.
| Disassembly | complete for both versions, ~23 000 lines, with cross-references |
| Annotated listing | 147 named routines, 145 header comments, 3 826 annotated lines |
| Documentation | 35 sections, 4 600 lines, every claim sourced |
| Signal path | frequency, amplitude, offset, AM, FM, burst, symmetry, sweep — all computed and verified against the original code |
| Hardware | all 10 strobes, the C-bus, I²C with every participant, ports, keyboard, rotary knob, display bitmap |
| State bits | 75 of 128 with a documented effect |
| Version diff | V1.3 vs V1.5 is 91.4 % structurally identical; every change named |
| Emulators | one in Python, one in JavaScript (~8 M instructions/s), plus a single-file browser simulator |
| Our own firmware | V2.0 — a factory defect fixed, checksum handled, verified in the emulator and on real hardware |
| Position | Type | Function |
|---|---|---|
| D301 | PCB80C652 | 8051 core with hardware I²C, 12 MHz |
| D306 | 27512 | program EPROM — V1.3 occupies 0000h–AC70h |
| D310 | X28C64 | arbitrary EEPROM on the MOVX bus |
| D305 | PCF8570 | 256 bytes of battery-backed NVRAM on I²C (A0h) |
| D304-A | PCF8576 | LCD driver on I²C (70h), 20-byte buffer |
| D302-A | SAA3007 | keyboard encoder, pulse-width coded on a single line |
| D307 | 74HCT4514 | strobe decoder — the strobe number is address bits A8…A11 |
The analogue side is a serial C-bus: the 8051's UART runs in shift
register mode, TXD is the clock, RXD the data, and a strobe decides which
of the ten shift registers latches the bytes. MOV DPH,#8nh followed by
MOVX @DPTR,A fires strobe n. That one line is the key to the whole
analogue section.
This is the part worth stealing for your own project.
Reading a 44 KB 8051 binary by eye gets you maybe a third of the way. Everything past that came from running the original code and watching what falls out:
# What formula turns the entered amplitude into the byte on the bus?
# Don't read the routine. Call it.
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
Vary the input, read the output, check it against the hypothesis. That worked for frequency, amplitude, offset, AM depth, FM deviation, burst count, symmetry and both sweep characteristics. Each formula in the documentation comes with the sample points it was verified over.
Three refinements made it actually productive:
Watch the bus, not the display. Section 15 measures what a state bit
does to the display buffer, and 74 of 128 bits appear to do nothing. But
many of them don't drive the display, they drive the analogue
assemblies — and those are only visible as telegrams on the C-bus.
Recording MOV SBUF,… and the terminating MOVX @DPTR lifted the count
of documented bits from 54 to 75.
Press keys, don't poke RAM. Setting a RAM byte by hand produces states the instrument never takes. That cost us two wrong findings and one crash into the command table. Injecting real key codes through the emulated SAA3007 gives states the firmware actually reaches — and it was a brute-force sweep over all 256 key codes that revealed which key triggers which handler.
Suspect your own emulator first. Three bugs in our core produced
"inexplicable" firmware behaviour: ACALL executed as AJMP, a missing
auxiliary-carry flag (so DA A misbehaved and the firmware appeared to
count in binary), and a doubled keyboard interrupt. Every finding from
that period was re-measured afterwards.
Static first. A disassembler with a full opcode table, then recursive descent with jump-table heuristics. That produced 30 508 bytes of code and left 13 637 bytes unaccounted for.
Then dynamic. A trace run — cold start, all 23 front panel keys, both directions of the knob, every operating mode, 86 million cycles — marking every address that actually executed. Held against the static analysis, it found exactly one area the descent had missed, and 10 686 of the unexplained bytes turned out to be five known table blocks.
Then the schematics. The service manual's OCR is useless for
schematics, but the page images at 400 dpi are excellent. Cut into
overlapping tiles, they are readable down to pin numbers. Six sheets
were read off this way — and where five parallel traces run 90 pixels
apart, eyeballing was replaced by a script (lines.py) that extracts
the line segments from the bitmap.
Then the two chips that were pulled. A 27C64 labelled "SINUS 1.1" and an X28C64 were read out. Both were placed in the schematic and their contents decoded.
Then the version diff. Tokenising both ROMs (relative jump distances
instead of absolute targets) and running SequenceMatcher over them
gives an address mapping that survives code motion — that's how the V1.3
symbols get carried onto V1.5.
The three built-in arbitrary curves live at A047h, A447h and A847h.
The third one has the same shape as a table that already sits in the ROM
in computed form — but with 563 direction changes against 13, and a
standard deviation of 4.1 LSB.