Half the BIOS is zero on disk because it's generated at runtime

5 min read
z80cpmsoftcardreverse-engineeringretrocomputingcpm-videx-series

Detail for Part 5 — The BIOS That Half-Exists.

Update (2026-04-28): The “first 1 KB populated, second 1 KB all-zero” framing is too coarse. A page-by-page byte histogram shows both BIOSes use a 256-byte interleave: alternating code pages and runtime-generated pages, four of each. 2.20 fills the generated pages with $E5 (CP/M’s deleted-file marker, also PUSH HL); 2.23 uses a FF FF 00 00 / F7 F7 00 00 pattern that decodes as RST traps. The runtime-generation observation survives — what changes is the layout (interleaved chunks, not bookend halves) and the implied position of the BOOT vector ($FED1 lives in a runtime-generated page; the actual cold-boot entry isn’t where the populated-half claim assumed). See the BIOS jump-table correction.

Picking up from the LOAD_CPM finding: the BIOS at Z-80 $FAB8 is 2 KB total. The first 1 KB ($FAB8-$FEB7) is loaded by LOAD_CPM and lands at Apple $0C00-$0FFF after PREP_HANDOFF — verified by exact byte-for-byte match against the BIOS extract from earlier. But the second 1 KB ($FEB8-$FFFF) — containing BOOT, HOME, SELDSK, SETTRK, READ, WRITE, and the per-device handlers that the dispatch table at $FB0A references — wasn’t anywhere in the LOAD_CPM staging.

So where does it come from?

The next physical sectors after where LOAD_CPM stops (trk2:$07) are trk2:$08-$0B — exactly 4 sectors = 1 KB. Extracted those from the .DSK and compared against the second half of the BIOS extract. They match — and they’re all zeros. Both the disk-source bytes AND the second half of the BIOS extract are entirely zero-filled.

This isn’t a bug in the extraction. The .DSK genuinely has zero bytes at trk2:$08-$0B. The BIOS area $FEB8-$FFFF in the running CP/M isn’t loaded from disk because there’s nothing on disk to load.

Which means the actual code at those addresses gets generated at runtime. SoftCard CP/M does code generation at boot time.

The pattern is consistent with what’s visible in the populated half:

  • $FAB8-$FAE4: 15-entry jump table (JP BOOT, JP WBOOT, JP CONST, etc.) — fixed targets that point into the second half (e.g., BOOT → $FED1, HOME → $FE6C)
  • $FAE5-$FB09: small inline stubs (LISTST XOR A; RET, SECTRAN LD H,B; LD L,C; RET)
  • $FB0A-$FB39: the per-device dispatch table (4 entries × 16 bytes), with each entry referencing two routine addresses in the second half ($FFAC-$FFD0 for one set, $FF64-$FF9A for the other)
  • $FB3A-$FE6B: directly-disassemblable code — CONOUT, LIST, the $FB45 helper, plus more
  • $FE6C-$FFFF (second half): all zeros at boot

When the Z-80 reaches its first instruction (JP $FA00 from the planted reset vector — see Part 2), it lands in the BIOS cold-boot area $FA00-$FAB7. That code reads the device-code table the slot scanner built at Apple $03B9-$03BF (Z-80 sees these at $03B9+ as well — same address since $0xxx addresses are XOR’d to $1xxx), then writes the appropriate per-device handler bytes into the second half of the BIOS area. After initialization, the second half of the BIOS contains real code; at boot time before initialization, it’s zeros.

This explains a lot:

  1. Why CP/M 2.20 and 2.23 differ structurally. The “BIOS factory” (the cold-boot code generator) is different between versions. 2.23’s factory knows how to generate Pascal-1.1 driver code (because it sees the new device code $06 in the slot table); 2.20’s doesn’t.
  2. Why diffing the static .DSK images shows so many bytes different. The .DSK doesn’t store the full BIOS — it stores the code generator. The actual runtime BIOS is the OUTPUT of the generator, not its source.
  3. Why static analysis can only see half. The runtime second half doesn’t exist statically. Either we run it (Z-80 emulator) or we trace the generator’s logic forward to predict its output.
  4. Why the BIOS jump table targets $FED1, $FE6C, etc. look reasonable but the bytes there are dispatch-table data: because at runtime they’re NOT dispatch-table data — they’re populated routines. The dispatch table at $FB0A is in the first half (correct in our extract); the routines it dispatches to are in the second half (zero in our extract; populated at runtime).

The extracted bytes also explain another puzzle. CONST at $FB10 and CONIN at $FB1A “decode as data” because they ARE data — they’re slots within the dispatch table that other code (the per-device handlers, generated at runtime in the second half) reads from. CONST and CONIN are presumably reached via different code paths (maybe their targets are RE-WRITTEN by the BIOS factory, not at the original $FB10/$FB1A addresses, but to something pointing into $FExx-$FFxx).

Where this leaves us

The 6502 boot loader is now fully understood. The CCP, BDOS, and the first 1 KB of the BIOS are now extracted and disassemble cleanly as Z-80 code. The Z-80 disk callbacks at Apple $0A00 are extracted. We have the boot banner string verifying everything is correct.

What’s needed to see the runtime second half of the BIOS:

  • Either trace the BIOS cold-boot code generator from Z-80 $FA00-$FAB7 (which IS in our extract — the populated half includes everything below the jump table) and predict the generated code
  • Or boot the system in a Z-80 emulator and dump memory after BIOS init completes

The first path is doable from the extracted bytes — the cold-boot logic is sitting in our bios_223.bin at offsets $0000-$0010 (just before the jump table); pointed to by the BOOT entry in the jump table (which points at $FED1, but at boot time $FED1 is zero, so the actual cold-boot must be reached differently — probably via a JMP from the planted Z-80 reset vector or similar).

Actually — the planted Z-80 reset vector is JP $FA00. So the Z-80 cold-boot starts at $FA00, which is below the BIOS jump table base at $FAB8. The bytes at $FA00-$FAB7 are the ~$B8 bytes of “pre-jump-table” BIOS code — the actual cold-boot routine. It runs first, sets up state, generates the per-device handlers in the $FE/$FF area, then presumably falls through to the jump table or a normal WBOOT. That’s the next concrete trace target.

Status: the BIOS isn’t missing — it’s runtime-generated. The cold-boot code generator is in bios_223.bin at $FA00-$FAB7 (~184 bytes, in our extract) and is the next thing to disassemble. Once that’s understood, we’ll know the algorithm that produces the per-device handlers from the device-code table — which is the real Part 4 article.


Update (2026-04-27): The claim above that the cold-boot generator is at $FA00-$FAB7 was wrong. That area is data/zeros, not code (visible in the staging-bytes dump if you re-check the disassembly output). The actual cold-boot entry is at $FB70, mislabeled as the LIST jump-table entry — see the cold-boot finding devlog for the corrected location and what that routine actually does (stack init, BDOS call vector planting, Z-80 reset vector rewrite from JP $FA00 to JP $FA03).