reenigne's decoding of the 8088 microcode in 2020 opened the doors for extremely accurate emulation of the 8088 CPU.
Although I had added support for the NEC V20 in MartyPC, the V20 core was not cycle-accurate in terms of the V20's actual timings. It was an 8088 in a V20's clothing - a copy-paste of my 8088 core with V20 instructions tacked on.
This was not an ideal case, but the prospect of making my V20 core cycle-exact without the microcode seemed like it might be a discouraging slog of trial and error.
So why not get the microcode, then?
I recently commissioned InfoSecDJ to take die photography of an NEC V20 CPU (actually a second-source V20 fabricated by Sharp, but a V20 nonetheless). He did an excellent job.
This photomosaic is extremely high resolution - 5.6 Gigapixels to be exact, an astonishing 70478x80672 resolution - too large to even fit in the JPEG image format!
You can see the entire thing at full resolution here.
The rectangular region just below the center of the die is the main microcode ROM.
The ROM array is 258x116, containing 29,928 bits. It's evenly divisible by 29, which we know is the microcode word length of the V20, so that's a good sign. But it implies 1032 microcode words, when we were only expecting 1024. That's a bit odd, isn't it? We'll figure out the reason for that a bit later on.
Here's a close-in crop of the ROM array:
| Microcode bits, zoomed in |
These gaps form a transistor - with the presence of a transistor indicating a 1 bit. I'll highlight the 1 bits to make that a bit easier to see.
Seeing this got me very excited - if we can visually identify the bits in the ROM, then we can extract the ROM contents. Just one slight problem - there are 29,928 bits in the array. That would be a tad bit tedious to extract by hand.
| Defining bit locations with MaskRomTool |
Fortunately, we could use MaskRomTool to export our defined bit positions to JSON format. I used this exported JSON file to write a Python script that extracted a square bitmap centered on each bit position, and saved it with the bit's logical column and row number in the filename.
Before we can train our CNN, we have to have a training data set. So I created a quick and dirty Python/tkinter script so I could quickly sort bits by eye into buckets as either 0s or 1s.
| The quick and dirty classification tool |
Shown here is a '1' bit. Can you spot it by the transistor behind the metal layer? The buttons ended up being extraneous - you just need to hit either 1 or 0 on your keyboard to classify the bit. In theory, you could do this 29,928 times and you'd have the job done in a few hours. I had originally intended for this to be my backup method in case the CNN training didn't work out - I had a few friends willing to volunteer to help, and the JSON logs that the "Bit Voter" produces can be merged to support distributed work with consensus. Fortunately, this was not needed.
Ultimately, I classified a little over 1,000 bits manually this way. Once they were sorted into two directories, we could now attempt to train a CNN model using the sorted images as input.
This is what a training run looks like.
[Epoch 01] train: loss=0.6945 acc=0.7273 f1=0.0164 | val: loss=0.6924 acc=0.7876 f1=0.0000
val precision=0.0000 recall=0.0000 cm=[[178, 0], [48, 0]]
[Epoch 02] train: loss=0.6860 acc=0.7151 f1=0.3826 | val: loss=0.6257 acc=0.7965 f1=0.0729
val precision=0.5000 recall=0.0394 cm=[[178, 0], [46, 2]]
[Epoch 03] train: loss=0.3751 acc=0.8914 f1=0.7213 | val: loss=0.3900 acc=0.7655 f1=0.6327
val precision=0.4661 recall=1.0000 cm=[[125, 53], [0, 48]]
[Epoch 04] train: loss=0.1159 acc=0.9523 f1=0.9139 | val: loss=0.0495 acc=0.9912 f1=0.9773
val precision=0.9773 recall=0.9773 cm=[[177, 1], [1, 47]]
[Epoch 05] train: loss=0.0251 acc=0.9945 f1=0.9888 | val: loss=0.0460 acc=0.9867 f1=0.9744
val precision=0.9514 recall=1.0000 cm=[[175, 3], [0, 48]]
[Epoch 06] train: loss=0.0319 acc=0.9933 f1=0.9802 | val: loss=0.0438 acc=0.9823 f1=0.9659
val precision=0.9350 recall=1.0000 cm=[[174, 4], [0, 48]]
[Epoch 07] train: loss=0.0185 acc=0.9945 f1=0.9212 | val: loss=0.0274 acc=0.9956 f1=0.9891
val precision=0.9792 recall=1.0000 cm=[[177, 1], [0, 48]]
[Epoch 08] train: loss=0.0141 acc=0.9956 f1=0.9913 | val: loss=0.0271 acc=0.9956 f1=0.9891
val precision=0.9792 recall=1.0000 cm=[[177, 1], [0, 48]]
[Epoch 09] train: loss=0.0101 acc=0.9978 f1=0.9940 | val: loss=0.0447 acc=0.9867 f1=0.9735
val precision=0.9488 recall=1.0000 cm=[[175, 3], [0, 48]]
[Epoch 10] train: loss=0.0110 acc=0.9967 f1=0.9907 | val: loss=0.0437 acc=0.9912 f1=0.9773
val precision=0.9773 recall=0.9773 cm=[[177, 1], [1, 47]]
Early stopping: no val F1 improvement >= 0.0 for 3 epoch(s).
Best val F1: 0.9891
If you have a CUDA-capable GPU, training is rather quick - this only took a few minutes.
The idea is that we want to maximize our accuracy - but reaching 1.0 may not be feasible, and maybe not even desirable (there's a thing called overfitting). Sometimes going on for longer just makes things worse, so we end training if we're not seeing a steady improvement.
The output of the training is a neural network model - we can then use this model to run inference on an entire input data set. Inference is just a fancy word for applying our model to actually do what we trained it to do - predict whether a given image contains a 0 bit or 1 bit.
Before we move on - a quick note to head off any potential controversies. CNNs loosely fall in the broader scope of AI from a computer science perspective, but we are not using "AI" in the modern, controversial sense that typically refers to a large language model (LLM).
When we run an inference pass, we get a confidence score for each pixel. We can use this confidence score to mark bits the model is less confident about, under some specific threshold (I used < 99% here). Here's the result of the first run, with ambiguous bits colored red:
I took all the ambiguous bits and manually sorted them back into the training folders, then re-ran the training, repeating until I got this result:
This was pretty good - only 4 bits remain ambiguous, and it was faster just to manually verify them than to train another model.
Great, we have our 29k microcode bits and we saved hours of tedious manual labor (in exchange for hours of writing a training script in Python, but at least that is reusable!).
We still have to turn this rectangular blob of bits into a list of 29-bit microcode words. In other words, we need to reorganize the bitmap until it is 29x1032 instead of 258x116. How exactly to go about doing that is not obvious, but we can put it aside for the moment until we've decoded the matching decoder PLA.
The decode or "activation" PLA sits above the main microcode ROM block, with some intermediate circuitry sandwiched in between.
The job of this PLA is to take 13 logical inputs from the left side (each input has a twin inverted signal, for a total of 26 input lines), and activate one column of the microcode array beneath it if the input signals match that column of the PLA.
What do we mean by matching?
A closer zoom might be informative:
We have a similar arrangement of vertical metal wires, punctuated by interconnects, and gaps in the substrate forming transistors. In this case, these transistors form logical AND gates. Unlike the microcode ROM, only one transistor is ever attached to an interconnect, facing either the normal or inverted signal of an input pair. This allows the PLA to test for a 0, a 1, or to not care about that input signal at all (the case where no interconnect is present). This creates a sort of maskable Boolean logic. Since all the gates are tied together, the corresponding column of microcode will only be activated if all the gates match.
This matching logic is very clever - almost certainly 8 of the 13 input lines are the opcode byte itself for a given instruction. Setting "don't care" bits in the PLA allows entire ranges of instructions to share the same microcode implementation, which is hugely important for efficiency's sake so that the microcode ROM doesn't take up the entire CPU die.
What's being stored here is not exactly a set of bits, but instead logic - the simple AND logic can be represented as pairs of bits, and we can extract those bits the same way we extracted the microcode ROM - drawing rows and columns in MaskRomTool, exporting the bits as images, manually sorting a thousand of them, then feeding that to our hotdog CNN.
This is our result:
Manually decoding the first few columns of the decode PLA is promising. We essentially have:
00?00???0??00
00?00???10?00
00?000??11100
where a ? means we "don't care" about the value of the bit in that position, allowing for a range of opcodes to match.
Luckily, it appears that the 8 bits of the instruction opcode are represented in the 8 inputs starting at the fourth input.
The first row will match 64 different opcodes, starting with 00,01,02,03,08,09,0a,0b,10,11,12,13, and so on. This happens to correspond with the 8088 ISA's general ALU opcodes, which all share the same microcode on the 8088. That's a good sign.
The second line will match 04,05,0c,0d,14,15,1c,1d, and so on, corresponding with general ALU opcodes that take an immediate operand. This is surely more than coincidence. As it turns out, the matching logic for the most part is laid out in a reassuringly numerical order.
One thing to note is that there are only 257 activation lines for 1032 microcode words. This means that the "entry points" into the microcode ROM for a given instruction have to be at addresses evenly divisible by 4 - this was the case on the 8088 as well, so it's not weird to see.
Each column of the microcode contains 4 words.
![]() |
| The 4:1 microcode column multiplexers |
Given a rectangular block of microcode ROM bits, the question becomes how that block is addressed to produce a linear arrangement of microcode words. There are a number of possible permutations - from the ROM array itself, we could read from the top or bottom, and within each multiplexed row, we have a similar choice. It's also possible that lines are swapped or interleaved, so some trial and error may be necessary to produce something that looks reasonable. As it turns out, we read the microcode words in order from each column from the bottom-up.
Here you can see me musing about the reasonableness of a certain word extraction on Discord.
Thankfully, finding a correct word extraction did not take long at all.
Here's what all the microcode words look like once extracted, split into five columns (each column continues from the bottom of the column to its left). I've colorized the various sections of the microcode for visual interest. I attempted to use a colorblind-friendly palette (Okabe & Ito), but apologies if this information is not visible to everyone.
Approximately two-thirds of the V20 microcode is dedicated to implementing the Intel 80186 ISA. The remainder is dedicated to either implementing NEC's own extended instruction set in the 0Fh-prefixed opcode space, or implementing the 8080 instruction set used by NEC's 8080 emulation mode. The latter accounts for nearly 1/5 of the total ROM area.
Given that we know where instructions are, and we have a rough picture of the form of microcode words, we can start determining by deduction what the various values of the source and destination operands mean. This is very similar to solving a crossword. You start at certain logical anchors - such as instructions that work with specific registers - note down guesses and assumptions as you work, and either validate or reject them.
The first four values of the first source field turn out to be segment registers - ES, CS, SS and DS. The last eight values turn out to be AX, CX, DX, BX, SP, BP, SI and DI.
I started tracking my work using an Excel spreadsheet to decode the fields and perform lookups of various field values which I kept on additional sheets.
The Main Decoding Effort
Unlocking the Group Decode ROM
Just staring at the microcode itself can leave one puzzled as to how certain instructions work, since the microcode itself in many cases doesn't have enough context to govern how the instructions must actually behave.
Being intimately familiar with the 8088, I was sure that the V20 must also have a GDR. It wasn't difficult to spot, being a large block of PLA circuitry. Many of the signals the V20's GDR emits are identical in purpose to the 8088's signals, which made decoding it more or less straightforward.
Decoding the first few lines of the GDR show us some familiar patterns.
01 111100?? 00100000001000 PREFIXES f0,f1,f2,f3
01 1111010? 00100000001000 HLT,CMC f4,f5
01 1111?0?? 00100000000100 f0,f1,f2,f3,f8,f9,fa,fb
01 1111??0? 00100000000010 f0,f1,f4,f5,f8,f9,fc,fd
01 1111?0?0 00100000000001 f0,f2,f8,fa
01 1111?100 00100000000001 HLT,STD f4,fc
01 00001111 00100000000001 EXT PFX 0f
01 001??110 00100000000000 26,2e,36,3e
01 01100100 00100000000001 REPNC 64
01 0110010? 00100000001000 REPX 64,65
01 0100???? 00000000011100 INC/DEC 40,41,42,43,44,45,46,47,48,49,4a,4b,4c,4d,4e,4f
01 1111?11? 01000100011011 GRP f6,f7,fe,ffThe first mask column match matches opcodes F0, F1, F2 and F3, all of which are instruction prefixes - later on we can see the 0F opcode extension explicitly matched as well.
The True V20 Microcode Word Format
One interesting development occurred during the VCF forum collaboration - dreNorteR discovered microcode word formats that were not mentioned in the famous court documents.
As it turns out, the frequently reproduced diagram was incomplete. The left side of the microcode word can take two forms, one of which encodes an inline constant value. The right-hand side of the microcode word has four total forms, not three.
This division can be seen in the die photography quite clearly - 17 outputs of the microcode ROM exit the ROM in one direction, with the remaining lines exiting in the opposite direction, so clearly they had different functional divisions.
Fields like 'F', 'W', and 'E', which were left unexplained in the old diagram now have known meanings. It might have been reasonable to assume 'F' was "Update Flags" in correlation with the 8088's F field, but it actually has an entirely different meaning - Fetch.
V30
NEC had an advantage over Intel that allowed them to make a rather clever optimization. The NEC V20, like the 8088, has an 8-bit bus. The corresponding chip to the fully 16-bit 8086 is NEC's V30 CPU.
In the image below, within the indicated circle, one side or arm of the metal structure was cut depending on the CPU type being fabricated. This meant both CPUs could share the same microcode mask. This also explains the discrepancy in microcode word count originally noticed!
If we zoom in, you can clearly see that a trace on the left side is cut on this V20 die where it connects to the thick post at top center. On a V30, the opposite side would be cut instead.
