~ajxs

Introduction to Reverse-Engineering Vintage Synth Firmware

2024.03.18
A gentle introduction to reverse-engineering vintage synthesiser ROMs using the Ghidra disassembler.

In this article we're going to take a look at how to reverse-engineer vintage synthesiser firmware. The synthesiser I've chosen for us to look at is the Yamaha DX7 (See Appendix: Why Choose This Synth?). You don't need a DX7 to follow along at home, but you will need a copy of the DX7 V1.8 firmware (available here) and the Ghidra disassembler.

Who is this article for? This article's intended audience is people from a technical background who are new to reverse-engineering, 8-bit architectures, or embedded development. If you come from an electrical-engineering or embedded-software background, you'll probably find the content here a little basic.

You'll only need to know a little bit about low-level programming: A basic understanding of how binary and pointers work should be enough. You don't need to know assembly language, or understand any specific processor architecture.

A few years ago I decided to give myself a crash course on what goes on inside synthesisers. I ended up writing the article Yamaha DX7 Technical Analysis about what I'd learned. In order to tease out some more details about the DX7's inner-workings, I decided to disassemble the synth's firmware ROM. I didn't have any experience with reverse-engineering binaries, so I had to figure it out as I went. I'm still by no means an expert (if you see any mistakes in this article, please let me know!), but I'd like to share what I've learned.

All I had when I started was a copy of the firmware, a copy of the service manual, and a can-do attitude. I knew nothing about 8-bit systems, and absolutely nothing about electronics, but I was willing to give anything a shot. If this sounds like you, read on, and I hope you find this article helpful!

Reverse-engineering vintage synthesisers is a great introduction to embedded systems, and can be a lot of fun. In a lot of ways reverse-engineering is a bit like putting together a big jigsaw puzzle. Sometimes putting a new piece in place unlocks a lot of new progress, and like a jigsaw puzzle, the best place to start is at the edges.

Address Decoding

The peripheral devices attached to the DX7's CPU, such as its LCD screen and sound chips, are memory-mapped. This means that the device has been allocated a specific address range in the system's memory, and the system communicates with the device by reading and writing data from and to these addresses.

Before we can start disassembling the firmware ROM, we need to know what peripheral device is mapped where. To do that we'll need to look at the DX7's address decoding logic. The first place to start is with the schematics.

The best version of the schematics I've seen is this version, created by the yamahamusicians.com user Miks. While you're at it, grab a copy of the service manual too. We won't be referencing it in this article, but it's a good resource to have. It explains certain details about the synth's architecture that aren't obvious from the schematics.

Service Manuals: Most consumer devices from the 80s and 90s have service manuals available. These are technical documents from the manufacturer to assist in servicing the device. They typically include schematics, diagrams, and lists of electrical components. The first place to start when reverse-engineering a device is looking for its service manual.

If you're new to electronics device schematics can look very intimidating, but once you understand the basics they're not actually as scary as they look! You can find a good introductory guide to schematics here.

Background

But first, what does address decoding actually mean? Address decoding refers to how a specific device is mapped to a specific address. In this section we'll figure out what peripheral is mapped to what address by tracing the address decoding logic in the synth's schematics.

The total amount of memory addresses that a CPU can access is referred to as the CPU's 'address space'. This is limited by the width of its 'address bus'. The CPU's address bus is responsible for selecting addresses in attached memory devices, such as RAM, or peripheral devices with addressable registers. Each line in the address bus represents a single bit, with the total number of lines determining the address range the CPU can access. For example, a 16-bit address bus can address 216 unique memory locations, or 64KiB.

When a CPU's address lines are exposed externally in the form of pins on the chip's package, this is called an external address bus1. These lines can be physically connected to external memory devices. Together with the CPU's data bus, this allow reading and writing binary data back and forth.

When the CPU performs an instruction that reads or writes memory, like LDB 0x2001, several things happen:

But wait... If the CPU only has one set of address and data bus lines, how do you connect multiple memory devices to the CPU? This is where the 'Chip Select' interface comes in: Each device attached to the CPU's data/address buses has a 'Chip Select' pin, controlling whether the device responds to incoming signals.

A schematic showing the address decoding logic of a hypothetical system.
A schematic showing the address decoding logic of a hypothetical system.
Consider the above (incredibly simplified) diagram: Two 8KiB 6264 RAM chips (U3 and U4) are connected to shared address and data buses on a Z80 CPU. U3's CE1 (Chip Enable) pin is connected to the CPU's A13 pin. The bar over the top of the label indicates that this pin is 'Active Low', meaning a low logic level will 'activate' its function. When the CPU selects an address between 0x0 and 0x1FFF, the A13 pin will be low, activating the U3 chip. U4's CE1 pin is attached to the CPU's A13 pin via a NOT gate, which inverts the signal coming from A13. When an address above 0x1FFF is selected, A13 will be set high, selecting the U4 chip. This effectively maps U3 to the first 8KiB of the system's memory, and U4 to the next.

Can you spot the problem with this example? Since any address using A13 will 'select' U4, U4 is now mapped to every 8KiB block of memory above 0x1FFF. In reality, more sophisticated logic is used to map memory devices. Let's examine the real world example of the DX7's address decoding circuitry.

Logic Gates: The DX7's address decoding logic is primarily implemented via the use of logic gates. If you're not familiar with logic gates, a good introduction to the topic can be found here. Don't worry though, we're not going to be going too in-depth. A basic understanding of what AND, OR, and NOT gates do is all you need. One particular type of component you'll encounter a lot inside vintage synthesisers are 7400-series logic chips.

Decoding the DX7's Memory Map

Nearly all of the discrete electrical components that make up a DX7 are commonly available products. They're mass-manufactured and sold by a variety of different manufacturers. The best way to understand these components is to read the datasheets made available by the manufacturer. I'll provide links to these as we go.

Note: The creator of the DX7 schematics used the logic symbols · and + to denote AND and OR gates, rather than the more standard ANSI, or IEC notations [pdf]. Other gates use the ANSI notation.

The Firmware ROM

Let's start by taking a look at the firmware ROM, IC14.

An excerpt from the Yamaha DX7's schematics, with the ROM address mapping logic highlighted
An excerpt from the Yamaha DX7's schematics, with the ROM address mapping logic highlighted.
We can see that IC14's CE1 pin is connected to the CPU's A14/A15 lines via an AND gate, and a NOT gate. What's going on here?

The AND gate ensures that the signal is only high when both address lines are active, and the NOT gate inverts the signal so that it activates the active-low CE1 pin. If A14 and A15 being active on the CPU 'selects' the ROM chip, that means it's mapped to the address range 0xC000 - 0xFFFF2.

Awesome! That wasn't so hard. Now we know where the ROM is mapped in memory. What's next?

RAM

The address decoding logic for the RAM is a little more complicated.

An excerpt from the Yamaha DX7's schematics, with the RAM address mapping logic highlighted
An excerpt from the Yamaha DX7's schematics, with the RAM address mapping logic highlighted.
The DX7 features three 4KiB 5118P RAM chips (IC19, 20, 21). These are connected to the CPU's address bus via a 74LS138 demultiplexer (IC23). This demultiplexing circuit is used to select one of 8 individual output lines based on a 3-bit input signal. These output lines are labeled as Y0 - Y7, and the input lines as DA, DB, and DC. The OR gates used here are wired to the system clock output pin. Presumably to ensure the timing of read and write operations are valid.
The function table from the 74LS138P's datasheet
The 'Function Table' from the 74LS138P's datasheet, showing what combinations of input lines select which output line.
The first RAM chip (IC19)'s chip select terminal is connected to the demultiplexer's Y2 line. The 'Function Table' from the 74LS138P datasheet shows that Y2 will be set low when input DB (connected to the CPU's A12) is high. Therefore, when the CPU selects address 0x1000, the first RAM chip will be selected.

Y3 (connected to the second RAM chip, IC20) will be set low when inputs DA and DB (A11 and A12) are high, corresponding to an address of 0x1800. Likewise, Y4 (connected to IC21) corresponds to an address of 0x2000.

By tracing this address decoding logic, we've successfully mapped the synth's RAM to 0x1000 - 0x2800.

LCD Screen

The last peripheral we're going to look at right now is the synth's LCD screen. When you take your first peek inside a binary you'll be staring at an intimidating jumble of machine code. One of the few things that will stand out at a glance is ASCII strings. A good way to get a quick overview of the binary is finding out how these strings are printed to the screen, and where.

The best place to start doing that is understanding how the CPU interfaces with the LCD controller, and working your way backwards to the code responsible for sending string data to it. Once you've found how strings are printed to the screen, you can easily see what's printed where to get a better understanding of the code.

An excerpt from the Yamaha DX7's schematics, with the LCD address mapping logic highlighted
An excerpt from the Yamaha DX7's schematics, with the LCD address mapping logic highlighted.
This looks really complicated, but don't worry though. It's just more of the same logic as before.

IC23's Y5 pin is connected to IC24, another 74LS138 demultiplexer. From the 74LS138 function table we know that Y5 goes low when inputs A, and C (A11 and A13) are high. So it looks like IC24 is mapped to 0x2800.

Take a look at IC24: Inputs A, B and C are wired to A1, A2 and A3. That means that IC24 only maps 8 bytes.

IC24's Y0 and Y1 pins are connected to an AND gate connected to the 'chip select' pin of IC12. What's happening here? This might seem a little confusing at first, but since the 74LS138P's outputs are active-low, this makes LCDCS active when either Y0 or Y1 are active. This maps IC12 to the four-byte range 0x2800 - 0x2803. Awesome. But what's IC12 doing?

IC12 is an Intel 8255 Programmable Peripheral Interface (PPI). It provides 24 parallel, bidirectional IO lines3.

An excerpt from the Intel 8255 datasheet showing its basic operations
An excerpt from the Intel 8255 datasheet showing its basic operations.
The schematics show the LCD's parallel interface (DB0 - DB7) is connected to the PPI's port A (PA0 - PA7), and its control pins (E, RW and RS) to the PPI's port B (PB0 - PB2).

The Hitachi LM016 LCD screen used in the DX7 features the ubiquitous Hitachi HD44780 LCD Controller. According to its datasheet (available here) it has two registers. When its RS line (connected to the PPI's PB0) is low, the instruction register is selected. When high, the data register is selected.

Based on the HD44780 datasheet, and the above table from the 8255's datasheet, we can tell that the LCD's data register must be mapped to 0x2800, and its control register to 0x2801. We'll go into more detail about the LCD controller itself later in the article.

Wrapping Up

Now we've got a pretty good idea of what's going on where in the memory map, and how this is discovered. To save you the trouble of going through the whole schematic, here are all the memory-mapped peripheral addresses.

Address Range Peripheral
0x1000 - 0x2800RAM (External)
0x2800LCD Data
0x2801LCD Control
0x2802Sustain/Portamento Pedals, and LCD Busy Line
0x28038255 Peripheral Controller Control Register
0x2804OPS Mode register
0x2805OPS Algorithm/Feedback register
0x280ADAC Volume
0x280ELED1
0x280FLED2
0x3000 - 0x4000EGS
0x4000 - 0x5000Cartridge Interface
0xC000 - 0xFFFFROM

These aren't the only peripherals attached to the system, the Hitachi 6303 CPU also features 'IO Ports'. These are memory-mapped input/output lines with their own dedicated functionality. We'll touch on these later in the article.

Disassembling The Firmware

Now that we know the memory map, we can start disassembling the firmware. To do this we'll use a graphical disassembler called Ghidra4. It's a relatively new player on the scene, but it's free, open source, and very powerful. A great resource to keep handy while working with Ghidra is the Ghidra Cheat Sheet.

Ghidra 6303 Support: As of the time of writing, Ghidra doesn't yet support the HD6303 architecture out of the box, so we'll need to install our own language specification. This repository contains a Ghidra language specification for the 6303 architecture. To install it, download the repository and copy the 6303 directory to the Ghidra/Processors directory inside your Ghidra installation. You'll need to restart Ghidra to see the new processor in the list.

Once you've installed the language definitions, open Ghidra and create a new project.

A screenshot of Ghidra showing the 'New Project' dialog
The first thing we're going to need to do is to import the firmware ROM binary. Select the '6303' language, and click 'OK'.

A screenshot of Ghidra showing the 'Select Language' dialog

Next, open up the Codebrowser. This is where all the action happens.

A screenshot of Ghidra showing the 'Codebrowser' button

Once the initial disassembly loads, the first thing you'll be looking at is row after row of hexadecimal. This is the actual machine code as it would appear to the CPU. Don't bother with analyzing the file.

The first thing we're going to do is set up the memory map. Remember that thing we just did all that hard work figuring out? That's going to come in handy now. Press the 'Display Memory Map' icon in the top button bar, to open up the Memory Map dialog.

A screenshot of Ghidra showing the 'Display Memory Map' button
By default there's only one memory block defined. This block consists of the binary we just imported, so go ahead and rename it to 'ROM'. The next thing we need to do is move this block to the correct offset 0xC000. Because all of the machine code instructions reference memory with absolute addresses, if we didn't map the ROM to the correct location none of the disassembly would work.
A screenshot of Ghidra showing the 'Move Memory Block' button
A screenshot of Ghidra showing the 'Move Memory Block' dialog

Before we finish setting up the memory map, let's take a quick look at the code. When the Hitachi 6303 processor in the DX7 powers up, it knows where to begin executing code by fetching a pointer from a specific location in the interrupt vector table.

An excerpt table from the 6301/6303 Handbook showing the 6303's interrupt vector table
An excerpt table from the 6301/6303 Handbook showing the 6303's interrupt vector table.
In this case the 'Reset vector' is always located at the specific memory address 0xFFFE, right at the end of the address space. Press the Ctrl+End combination on your keyboard to move to the end of the binary, and select the offset 0xFFFE by clicking on it. Press the P key on your keyboard to convert the data at this address to a pointer. You should see something similar to the image below.
A screenshot from Ghidra showing the reset vector pointer

Double-click on this pointer to take you to the associated offset in the binary. Now we've found where the actual code is located, but it doesn't look like much just yet.

A screenshot from Ghidra showing the reset handler function

To begin disassembling the machine code into something we can work with, click on the label and press the D key on your keyboard, or right-click and select 'Disassemble' in the context menu.

The Reset Vector: Different processor architectures will have different methods for locating the reset handler. Typically a pointer to the reset handler will be stored in an interrupt vector table (IVT) at a fixed location in memory. On an ARM Cortex-M processor the IVT is located at address 0x0, and the reset vector at 0x4. On the MCS-51 architecture —another popular 8-bit microcontroller commonly seen in synthesisers— the actual reset handler begins at 0x0.

The disassembly process will follow the flow of code through the binary, disassembling as it goes. An error will pop up here, but don't worry about it for now. This is just the disassembler mistaking a jump table for code.

A screenshot from Ghidra showing the expected 'analyzer error' message

Once the disassembly completes you should see something like the picture below.

A screenshot from Ghidra showing the disassembled reset handler

Now we're looking at real code! No need to panic though. If you don't understand what you're looking at, that's okay. Assembly can look pretty intimidating at first, but with a little bit of practice you'll get the hang of it!

Each of the lines you're seeing here represents a single machine-code instruction translated into assembly code. The three letter mnemonics are the human-readable representation of the instructions. LDA for example, is the mnemonic for the 'Load value into register A' instruction. STA is the mnemonic for the 'Store value in register A' instruction. If you've never encountered assembly language before, that's okay! This video will give a very quick and general introduction to assembly language.

The HD63B03RP CPU used in the DX7 is a member of the 6800 family of processors. Its instruction set (the full set of assembly instructions) is small and easy to understand. A great resource for understanding the 6303 CPU and its instruction set is the HD6301/HD6303 Series Handbook freely available on bitsavers.org.

The FUN_c5e5 text you're seeing here is a label. This is a symbol placed in the disassembler's symbol table, which can be referenced elsewhere in the assembly code, usually as the target for a branching instruction. Ghidra should already have set up the reset vector as a 'function'. Select this label with your cursor and press the F key on your keyboard to edit the function and give it a more meaningful label like reset.

But what are all these red labels we're seeing, like DAT_2575? If you try to double click on it, Ghidra offers a helpful error message: 'Address not found in program memory: 2575'. This is because we're missing our memory map! Let's go back to the 'Memory Map' dialog, and add the missing blocks.

A screenshot showing adding the RAM memory map block in Ghidra
Fill in the memory map that we worked out in the last section. The completed map should look something like the screenshot below. You can choose to consolidate some of these blocks if you like. It's not super important how the blocks are divided. What matters is that the blocks cover all of the needed peripheral addresses.
The completed memory map shown in Ghidra, with all peripheral address space allocated
Note that I added memory blocks for the HD6303 CPU's internal registers, and internal RAM.

Now is a good time to go and fill in the individual peripheral addresses that we know. Press the Ctrl+Home keys on your keyboard to go to address 0x0. The HD6301/HD6303 Series Handbook provides a list of the HD6303RP's internal registers.

An excerpt from the Hitachi HD6301/HD6303 Series Handbook showing the IO port addresses
An excerpt from the Hitachi HD6301/HD6303 Series Handbook showing the IO port addresses.
Press the B key on your keyboard to declare that address 0x0 specifies a byte of data. You'll see the ?? change to db, which is the assembler directive to define a byte of memory. Press the L key on your keyboard to give this address a useful label like io_port_1_dir. Go ahead and fill in the rest of the peripherals. When you go back to the reset handler you'll notice that, even with only a few pieces of the jigsaw puzzle in place, things will start to make a lot more sense.

The Reset Function

The main reset handler in the DX7 is responsible for initialising the firmware. It sets up the CPU's IO ports, ensures the firmware's important variables have valid values, and sets up the CPU's timer interrupt. More on this later.

A great way to visualise the 'control flow' of the program is in the 'Function Graph' view. This view shows a directed graph of the program's branching logic. You can open this view by selecting Window → Function Graph in the top window menu. You should see a view similar to the picture below.

A screenshot of Ghidra's function graph of the reset handler

At offset 0xC605 you'll see the following instructions:

LDA   #0xd
CMPA  DAT_2328
BHI   LAB_c60f
The 6303 has three general-purpose accumulator registers: A, B, and D. A and B are both 8-bits in size, and D provides a 16-bit view of both of them combined: D == (A << 8) | B.

  1. The LDA instruction loads the immediate value 0xD into the A register.
  2. The CMPA instruction then compares the value in the A register with the value at the memory address DAT_2328.
  3. The BHI instruction tells the CPU to branch to the label LAB_c60f if the value in the A register is greater than the value at DAT_2328.

Condition Codes: What actually happens when you execute a CMPA instruction is that the CPU sets a series of fields in the 'condition code' register based on the result of the comparison. These 'condition codes' are used by the conditional branching instructions to determine whether or not to take the branch. In this case, the BHI instruction will take the branch if the C(arry) and Z(ero) condition codes are both clear.

You can see in the function graph that if the memory at DAT_2328 is greater than or equal to '13', it will not branch, and the value will be cleared. The program will then continue to execute the next instruction, which would have been the original branch target. In this case, the program is checking to see that the 'pitch bend range' variable (stored in memory at location 0x2328) is within a valid range of 0-12. If not, it's reset to 0.

If you look down at the bottom of the graph, you'll notice something interesting: The program goes into an infinite loop. This is the firmware's 'main loop'.

The firmware's 'main loop'
Tasks that need to be performed continuously happen here. Such as updating the UI based on user input, and parsing incoming MIDI messages.

When certain tasks not only need to be performed continuously, but also periodically, there's another way to make this happen: interrupts.

Interrupts

Interrupts are signals sent to the processor by hardware or software to interrupt the current code being executed, and handle a specific event. They're commonly used in embedded-software to handle external, time-critical, or asynchronous events.

One of the most common types of interrupt you'll encounter is a 'timer interrupt'. The HD6303's built-in timer interrupt consists of a 16-bit 'counter' register, which is incremented every clock cycle, and a 16-bit 'output compare' register. When the value in the counter register matches the value in the output compare register, a timer interrupt will be raised. This causes the processor to halt what it was doing, push the current state of the CPU onto the stack, and jump to the appropriate interrupt handler specified in the interrupt vector table. In the 6303 a pointer to this handler is located at offset 0xFFF4. Once the firmware is done handling the interrupt, it executes the RTI instruction, which restores the CPU's state from the stack and continues executing the code from where it left off.

The timer interrupt handler is where all the synth's real-time functionality happens. This is any code that needs to be executed in a time-critical manner. The DX7 uses the periodic timer interrupt to process portamento and modulation, update the individual voice frequencies, and send the updated voice data to the sound chips. Feel free to declare the pointer to the timer interrupt handler just like we did for the reset handler, disassemble the handler, and take a look at what's going on.

A screenshot from Ghidra showing the interrupt vector table and the OCF interrupt handler pointer

LCD Interface

One of the best places to start reverse-engineering a synth's firmware is to understand how it prints things to the LCD screen. We already know where the LCD controller is mapped in memory, let's work backwards from there and see if we can find that code.

Press the G key on your keyboard to open the 'Go To...' dialog, and go to address 0x2800.

A screenshot from Ghidra showing the LCD registers
These are the two memory-mapped LCD registers. The list of cross-references on the right shows us where these addresses are referenced in the code. Click on the FUN_fdef label to take us to this function. This is the function called by the reset handler to initialise the LCD screen.

Below the function we can see something that looks like ASCII data.

Screenshot from Ghidra showing ASCII data embedded in the code
In fact, it looks a lot like the welcome message displayed when you boot up the DX7. Hmm. Click on offset 0xFE31, and press the ' key on the keyboard twice. Once to convert the data at this offset to character data, twice to convert it to a NULL-terminated string.

Notice that the welcome message location is referenced in the code at offset 0xFE2B:

JSR        FUN_fe52
LDX        #0xfe31
JMP        FUN_fea4

Select the operand #0xFe31, and press Alt+Ctrl+R on your keyboard to turn this into a memory reference. The default label looks a bit strange, so you might want to give it a better one like str_welcome_message by selecting the reference and pressing the L key.

We can see here that a pointer to the welcome message string is loaded into the X register, and then the ROM jumps to the function FUN_fea4. Could this function have something to do with printing the string? Let's find out.

The Index Register (X): The Motorola 6800 family of processors has an interesting feature called an 'index register'. The index register (X) can be used as a 16-bit base address, to which an 8-bit relative offset can be applied. For example, the LDA 4,x instruction will load the byte into A that is 4 bytes from the address stored in X. This is useful because it allows us to reference 16-bit addresses with only an 8-bit operand.

Let's take a walk through FUN_fea4 together and see if we can figure out what it's doing:

Screenshot from Ghidra showing an unknown function that prints the welcome message to the LCD screen

  1. First, it pushes the address of the welcome message string in X to the stack.
  2. Then it loads a memory address (0x261F) into X, and saves that address to a pointer in memory.
  3. Then it restores the welcome message address from the stack into X.
Not very helpful yet, unfortunately. Something I find that helps make sense of so many unknown labels is to give them names that describe how they're used. Double-click on the label DAT_00fb to go to its location. Since we know this variable stores memory addresses, press the P key to convert it to a pointer. Giving it a name like unknown_lcd_pointer_00fb can help identify it at a glance later.

Use the Alt+← keyboard combination to navigate back to where we were before. Once you're there, click through to FUN_fe8b.

A screenshot from Ghidra showing the function located at 0xfe8b
We can see that lots of cross-references to this function have been found in the code already. Let's go through this function step by step and see what we can figure out:

  1. We already know that the X register contains a pointer to a string. So we can see that this function is loading an ASCII character into register B from the address stored in X.
  2. When the LDB instruction loads a new value into B it sets the condition codes according to its value. If the most-significant bit of this byte is 1, the N(egative) condition code will be set. This will cause the BMI (Branch If MInus) instruction to branch. Valid ASCII values fall within the range 0-127, so this code looks like it's checking for an invalid character, and will branch to the exit if this is the case.
    Note: Different instructions treat integer values as either signed, or unsigned, with the most-significant bit treated as the sign bit.
  3. The value in B is then compared against 0x20 (ASCII space). As I mentioned earlier, the CMP instruction sets condition codes according to the value in the associated accumulator, and the operand. The BCC instruction (Branch If Carry Clear) will branch if the C(arry) condition code is clear. This means that the value in B must be 0x20 or above, otherwise the function exits.
    You can read more about how the carry flag is used in computer arithmetic on Wikipedia.
  4. If the ASCII char is valid, it calls BSR to branch to the subroutine FUN_fe9a. In this subroutine we can immediately see something interesting: Remember that pointer we labeled earlier? This subroutine writes the ASCII character in B to the location in this pointer, increments the pointer, and saves it...
  5. After this, the address in X is incremented, and the function loops back to the start. Now the function repeats, with X pointing to the next character in the string.
Are you thinking what I'm thinking? This is a String Copy function! It copies characters from a string into a buffer, until either a NULL-terminator, or other unprintable ASCII character is encountered.

Go ahead and give this function a label like lcd_strcpy. If you like, you can apply local labels to LAB_fe94, and LAB_fe99 like .copy_character and .exit. Maybe give that buffer address we saw earlier (0x261F) a temporary label too.

Subroutines: You might have noticed the instructions BSR ('Branch to SubRoutine'), or JSR ('Jump to SubRoutine'). These instructions are used to call subroutines. When a subroutine is called, the address of the next instruction is pushed onto the stack, and the 'program counter' jumps to the address of the subroutine. When the subroutine calls the RTS instruction to return to its caller, this address is popped from the stack into the PC (Program Counter) register, and the program continues from where it left off. Just like a function call in a higher-level language.

This is where we're at so far:

A screenshot from Ghidra showing the function located at 0xfea4 partially annotated

Let's move on to that last function FUN_fe52 and see where that leads us. This function is a bit more complicated. Using the Function Graph window I showed you before might help visualise what's going on.

A screenshot from Ghidra showing the function graph of the function located at 0xfe52

Let's go through this function step-by-step like we did before:

  1. A new location in memory (0x263F) is being saved to that pointer we saw before, and the LCD buffer address we saw earlier (0x261F) is being saved to a new pointer. There's something interesting about those addresses. They're 32 bytes apart. That seems a bit conspicuous, doesn't it? Maybe this corresponds to the length of the LCD screen (2 lines of 16 characters)?
  2. A constant value is loaded into B.
  3. Inside the loop, we can see that B is saved to the stack. A byte is then loaded into A from the location in the pointer at 0xF9. We know from seeing the welcome message string loaded into X that this byte is ASCII string data. The pointer is then incremented and saved.
  4. This byte is then compared against the byte pointed to by unknown_lcd_pointer_00fb.
  5. If the character in unknown_lcd_pointer_00f9 and unknown_lcd_pointer_00fb aren't equal, then this character is used as an argument for a function call to FUN_fec7.

The function at FUN_fec7 is a bit more complicated, so I'll walk you through what's happening.

The firmware's 'main loop'

  1. The constant 0x89 is written to the 8255 PPI control register at 0x2803. The PPI datasheet refers to this as 'Control Word #5'. This sets the PPI's Port A, and B to outputs, allowing the CPU to send data to the LCD controller.
  2. A value of zero is written to the LCD control register. This sets the RS line low to select the Instruction Register, and the RW line low to select a Write operation.
  3. The E line of the LCD is then driven high to instruct it to be ready to receive data over the data bus.
  4. The byte in A is then written to the LCD instruction register. After this, the E line is driven low, and the RW line is driven high to signal the end of the data transfer.
  5. The 8255 'Control Word #13' is written to the PPI control register to revert port A and C to being inputs.
  6. Finally, it branches unconditionally to FUN_ff08.
Note about unconditional branches: Sometimes, rather than returning with an RTS instruction, a subroutine will just unconditionally branch to the next subroutine. As long as the stack is managed correctly, this is perfectly valid. The second subroutine's ending RTS instruction will just pop the original return value from the stack and return to the original caller. This technique saves a few bytes of code.

Let's check out the subroutine at FUN_ff08 that our function jumps to.

A screenshot from Ghidra showing the subroutine at 0xFF08.

  1. The E and RW lines of the LCD controller are set high. This sets the LCD controller to read mode.
  2. The PPI's port C is read into the A register, then the E line of the LCD controller is set low to indicate the read operation is complete.
  3. A bitwise AND is performed between value of the A register and 0b1000_0000. This checks the status of the PC7 line. If the PC7 line is high, the function loops back to the start.
An excerpt from the Yamaha DX7's schematics showing the PPI's connection to the LCD controller's 'Busy Flag' line

It's easy to miss, but if you look closely in the schematics you'll see that the PPI's PC7 line is connected to PA7, which is connected to the LCD controller's DB7 pin. The DB7 pin serves as the LCD controller's 'Busy Flag'. This flag indicates whether the LCD controller is busy processing data. When it's clear, the LCD controller is ready to accept new data.

It looks like the purpose of this function is to poll the LCD controller, waiting for it to be ready to accept new data. Awesome! Let's give it a label like lcd_wait_ready. Okay! So putting it all together, the function at 0xFEC7 writes an instruction to the LCD controller, and then waits for it to be ready to receive data again. Go ahead and give it a name like lcd_write_instruction.

Reverse-engineering often involves going down a rabbit hole. Sometimes you need to fill in a few different pieces of the puzzle before you can start to see the whole picture. Let's return to the function at FUN_fe52 and see what happens next.

We now know the loop is writing an instruction to the LCD controller, but what did this instruction do? The original instruction value when the function started was 0x80, and it's incremented by one with each iteration of the loop. The HD44780 datasheet tells us that 0x80 is the instruction to set the DDRAM (Display Data RAM) address in the LCD controller. This is the address in the LCD's memory where the next character will be written. A value of 0x80 indicates the start of the screen's first line.

The firmware's 'main loop'
The next function call (FUN_fee7) looks almost identical to our lcd_write_instruction function. The only difference is that it writes to the LCD controller's data register, rather than the instruction register. This must be where the actual character data is written! You can give this function a label like lcd_write_data. Note that this function 'falls-through' to the LCD controller polling function we saw earlier.

Now we know what's going on here. This is our LCD printing function! Notice that after writing the character data to the LCD, at offset 0xFE77 the function writes it to the buffer at 0x263F? The incoming characters are compared against the contents of this buffer to see if they're identical, if they are then it skips printing the character. Maybe these buffers represent the 'next', and 'current' contents of the LCD screen?

After writing the LCD character data, the function then checks whether the LCD instruction byte is equal to 0xD0. Now we know that this is checking whether the LCD DDRAM position is at the end of the second line. If not, it checks whether we're at the end of the first line (0x90). If so, the instruction byte is set to 0xC0, which sets the DDRAM address to the start of the second line.

Note about HD44780 DDRAM addresses: Each 'line' in the HD44780 LCD controller actually has a length of 64 characters, even if the screen is only 16 characters wide. As a result, 0xC0 (0x80 + 0x40) is the correct DDRAM address for the start of the second line.

Awesome! Now we've discovered the LCD printing function! Go ahead and give it a name like lcd_print.

The firmware's 'main loop'
The completed lcd_print function. Note that I went ahead and labelled all of the important variables.
If you've followed along, give yourself a huge pat on the back. This was no easy feat! You've now got a pretty good understanding of how vintage synth binaries are reverse-engineered. Everything else involved in disassembling a synth's firmware is just a matter of applying these same ideas.

Bonus: The HD44780 LCD controller has been a staple in consumer devices for decades5. LCD controllers compatible with the HD44780 instruction set are still being manufactured, and are commonly used in commercial, and hobby projects alike. Because of this, lots of tutorial material is available online, and the knowledge gained in working with it is applicable elsewhere.

Going Further

The MIDI Handling Routine

After disassembling the LCD printing function, the next best way to figure out what's going on inside a synth ROM is to disassemble the function that parses incoming MIDI data. This function is an entry point to nearly every aspect of a synth's functionality. Disassembling it will allow you to trace the path of a particular MIDI message to its associated functionality. You can trace 'NOTE ON' and 'NOTE OFF' messages to find the code that handles starting and stopping individual voices; Or you can trace 'CONTROL CHANGE' messages to find the code that handles pitch bend or modulation.

I decided not to tackle this function in this article, as the DX7's MIDI parsing code is huge, and requires a lot of explanation. Parsing MIDI messages is always implemented via a straightforward state machine, and the code is nearly identical across different synths. Once you've seen how it works in one synth, you've seen how it works in nearly all of them.

Debugging the Firmware in an Emulator

One of the best ways to understand what's going on inside a synth's firmware is to run it in an emulator. The MAME emulation framework is freely available, and already supports a wide variety of vintage synths. It features a built-in disassembler, and a debugger that can be used to step through the firmware instruction by instruction to see what's happening in detail. When I was working on my Yamaha DX9/7 project, I used MAME as a testing and development platform for the firmware.

Final Words

The DX7, and its 8-bit CPU might be a bit primitive by today's standards, but the same principles apply to reverse-engineering modern devices. Instructions sets and calling-conventions might change, but whether it's a vintage 8-bit architecture like the 6800, or a cutting-edge 32-bit ARM system, the principles of how to disassemble device firmware remain the same.

If you have any questions about this article, please get in touch! If you have any corrections or suggestions, I'd love to hear from you. Thank you for reading!

Appendix: Why Choose This Synth?

It Can Be Disassembled With Free Software

6303 binaries can be disassembled by using free and open source tools, such as Ghidra, F9DASM, and MAME's Universal Disassembler.

It's Well Documented

40 years on, the DX7 continues to captivate people's imaginations. As a result, lots is known about what goes on inside a DX7. Yamaha's service manuals are comprehensive, and freely available online.

Yamaha even released internal documentation on the DX7's architecture and sound chips, which is now available online.

Only One ROM

One advantage of reverse-engineering the DX7 is that there's only one ROM you need worry about. Technically there's also the sub-CPU and its mask ROM, but in this case you don't really need to worry what's going on there.

Some synths have important part of the firmware stored on the CPU's mask ROM, such as the Casio CZ-101. Other synths spread the synth's core functionality across multiple CPUs, each with their own ROMs, such as the Roland JX-8P. The DX7 is much simpler, having (nearly) all of its code in one place.

It Has an LCD Screen

Disassembling code for a system with a text-based user interface has a lot of advantages. I considered some of the early DCO-based Roland polysynths as candidates for this article, but without an LCD screen it's much harder to make headway into a ROM.

No Bank Switching

Unfortunately the various disassembler tools available don't handle bank switching very well. In Ghidra you can use 'Overlay' memory blocks to set up the different banks, however it's still not very intuitive in my experience.

I considered the Ensoniq ESQ-1 as a candidate for this article. It features a Motorola MC6809 processor, which is very well supported by lots of different debuggers. However it uses bank switching, which makes it a bit of a nuisance to disassemble.

What Is Bank Switching?

What happens if you need to squeeze 64KiB of firmware ROM, and 32KiB of RAM into your HD6303 chip's 16-bit address space? One solution to this problem is bank switching. Many vintage synths use bank-switching to fit their firmware into the CPU's address space.

Bank switching breaks a memory device's address space up into multiple 'banks' by latching one or more of its address lines to one of the CPU's I/O port lines. This allows the CPU to select which 'bank' is active by toggling the aforementioned I/O line in the software.

Yamaha TX81Z schematics excerpt showing CPU, ROM, and RAM
An excerpt from the Yamaha TX81Z's schematics, showing the CPU, ROM, and RAM wiring.
The Yamaha TX81Z features a 64KiB 27C512 EPROM chip, mapped into the CPU's address space at 0x8000 - 0xFFFF. The EPROM's A0-A14 pins are wired to the CPU's A0-A14, and the EPROM's CE1 pin is latched to the CPU's A15 pin. The EPROM's A15 pin is wired to the CPU's I/O port 6 (pin P63 in the schematics). If the P63 I/O line is pulled high, the upper half of the EPROM's memory is selected, mapping addresses 0x8000 - 0xFFFF into the CPU's address space. If it's pulled low, the EPROM's 0x0000 to 0x7FFF memory is mapped to 0x8000 - 0xFFFF.

To allow branching from code in one bank to code in another, a common technique is to use a 'trampoline function' located at the same address in both banks.

Appendix: Documentation

Below is a list of all the important documentation referenced in the article.


  1. The Hitachi 6303 microcontroller used in the DX7 includes both an internal, and external memory bus. The first 256 memory addresses in the 6303 point to the CPU's internal registers and on-board RAM. Many modern microcontrollers —such as the Atmel AVR, and Microchip PIC series— don't feature external address buses. It's more common for modern microcontrollers to communicate with peripheral devices over serial buses, using protocols such as SPI, or I2C.
  2. On boot, the 6800 CPU family fetches the reset vector from the fixed address of 0xFFFE. Knowing this, we could have just made an educated guess that the whole ROM was mapped to the high addresses. Still, it's always good to check your assumptions!
  3. If you're curious about why the 8255 PPI chip is used here, it's most likely because the LCD controller, cartridge, and portamento/sustain pedal interface don't feature a chip select interface.
  4. There are a variety of disassemblers available for the HD6303 architecture. The state-of-the-art graphical disassembler is arguably IDA Pro, but it's closed source, and prohibitively expensive for hobbyists. Non-graphical disassemblers also exist, such as F9DASM. If you're new to reverse-engineering, I'd personally recommend starting with Ghidra. It's free, open source, and easy to learn.
  5. I went down a bit of a rabbit-hole trying to find what year the LM016/HD44780 was first manufactured. The earliest reference I can find online is a 'preliminary' user's manual, dated March 1981. It's a shame that there's so little background information available about one of the best-known ICs in history.