Skip to content

Instantly share code, notes, and snippets.

@irudoy
Created August 13, 2026 17:17
Show Gist options
  • Select an option

  • Save irudoy/2e6dcbd3ae8f69b8e5dbf69d40233fcf to your computer and use it in GitHub Desktop.

Select an option

Save irudoy/2e6dcbd3ae8f69b8e5dbf69d40233fcf to your computer and use it in GitHub Desktop.
IBM ThinkPad 365ED: booting Windows 98 DOS from CompactFlash via PCMCIA

Booting Windows 98 DOS from a PCMCIA CompactFlash card on an IBM ThinkPad 365ED

Overview

The IBM ThinkPad 365ED can start Windows 98 DOS from a CompactFlash card in a PCMCIA ATA adapter, but its PCMCIA BIOS is not fully compatible with the Windows 98 IO.SYS boot path.

A normally prepared active FAT16 card reaches IO.SYS and then stops on a black screen with a blinking cursor. Two independent firmware compatibility problems cause the failure:

  1. the PCMCIA BIOS hangs when IO.SYS rereads CHS 0/0/1 through INT 13h;
  2. the BIOS leaves the head-count byte in the geometry structure at zero, causing a 32-bit division by zero inside IO.SYS.

The MBR bootstrap included in this Gist works around both problems. It caches sector 0, services the problematic BIOS request from memory, repairs the geometry value at the verified IO.SYS instruction, removes its temporary trace handler, and lets DOS continue at normal speed.

Warning

This is highly version-specific boot code that overwrites the executable part of an MBR. Verify every device name, keep a sector-0 backup, and read the limitations before writing anything.

Confirmed configuration

Component Confirmed value
Computer IBM ThinkPad 365ED
Boot medium 8 GB CF card in a PCMCIA ATA adapter
BIOS disk number DL=80h
Partition Primary, active, FAT16, type 06h
Partition start LBA 63, CHS 0/1/1
Partition length 179,361 sectors, 91,832,832 bytes
BPB sectors per track 63
BPB heads 16
DOS version Windows 98 4.10.2222
IO.SYS size 222,390 bytes
IO.SYS SHA-256 36889c640067e58019b819cc160d9516bde023affe9529ade7b2ddfdaef56ad9
MBR bootstrap size 446 bytes
MBR bootstrap SHA-256 5b85b6d1c26ce2bbbf5455ffe917877b9ad8457620eeb199b12deb4c2965a96f
Assembly source SHA-256 f4e259c396a8bbb95085d97fadd2b6d6caff65ea60e6f9404c30fead1add08c9

The rest of the CF card may remain unallocated. Keeping the first FAT16 partition below 2 GiB avoids additional limitations in old DOS and BIOS implementations.

Symptom

The CF card contains an active FAT16 partition, a valid FAT16 boot sector, and the required system files:

IO.SYS
MSDOS.SYS
COMMAND.COM

The firmware starts the card, but the machine displays only a blinking cursor instead of the Windows 98 banner and C:\> prompt.

At this point:

  • the MBR has executed;
  • the FAT16 partition boot sector has loaded;
  • the partition boot code has found and loaded IO.SYS;
  • execution has failed after control reached IO.SYS.

This is different from IBM errors such as I9990302 and I9990305, which occur at an earlier stage when the partition or boot sector is not usable.

Root cause 1: a hanging INT 13h request

During startup, IO.SYS rereads the physical MBR sector using this register state:

AH=02h       read sectors
AL=01h       one sector
CH=00h
CL=01h       cylinder 0, sector 1
DH=00h       head 0
DL=80h       first BIOS hard disk

AX=0201h
CX=0001h
DX=0080h

The ThinkPad PCMCIA BIOS does not return from this call. An earlier read of the FAT16 partition boot sector at CHS 0/1/1 succeeds, so the failure is specific to this later CHS 0/0/1 request.

The workaround stores a copy of sector 0 in reserved conventional memory and hooks INT 13h. It handles only the exact AX=0201h, CX=0001h, DX=0080h request from the cache. Every other disk request is chained to the original BIOS handler.

The traced return frame after the cached read was:

return CS:IP = 0070:0522
FLAGS        = 0046h
CF           = 0

Root cause 2: zero heads and DIV EBX

After bypassing the hanging read, execution reaches 0070:12A2. The next bytes are:

66 F7 F3 66

In 16-bit real mode, the first three bytes decode as:

div ebx

The real ThinkPad register state immediately before that instruction was:

AX=0001 BX=0000 CX=0001 DX=0000 DS=9EE0 ES=0560
EBX=00000000

IO.SYS obtains this divisor from the head-count byte at ES:[DI+15h]. The ThinkPad PCMCIA BIOS leaves that byte at zero.

Running an exact image of the CF card in QEMU shows the expected values at the same point:

ES:[DI+15h] = 10h
EBX         = 00000010h

The value 16 matches the heads field in the FAT16 BPB. On the real machine, DIV EBX with EBX=0 raises a divide exception before the next disk request, leaving the visible blinking cursor.

How the bootstrap works

The included thinkpad-365ed-pcmcia-mbr.s performs this sequence:

  1. Relocates from 0000:7C00 to 0000:0600.
  2. Reduces the conventional-memory value at BDA 0040:0013 by 2 KiB.
  3. Copies itself into those reserved 2 KiB at the top of conventional memory.
  4. Keeps a sector-0 copy at offset 0200h in its resident segment.
  5. Saves and replaces the INT 13h vector.
  6. Loads the FAT16 boot sector from CHS 0/1/1 and transfers control to it.
  7. Services the exact repeated MBR read from its in-memory copy.
  8. On the second matching request, temporarily installs an INT 1 handler and sets the Trap Flag in the return frame.
  9. When saved IP reaches 12A2h, also verifies the bytes 66 F7 F3 66 at saved CS:IP.
  10. Writes 10h to ES:[DI+15h] and 00000010h to EBX.
  11. Restores the original INT 1 vector, clears the Trap Flag, and continues without tracing.

Checking both the instruction address and opcode prevents the geometry modification from being applied to unrelated code.

Preparing the CF card

Required partition layout

The confirmed layout is:

primary DOS partition
active = yes
type = 06h FAT16
start LBA = 63
sectors per track = 63
heads = 16
size <= 2 GiB

For a card larger than 2 GiB, create a small first FAT16 partition and leave the remaining space unallocated.

Modern LBA-2048 alignment is not compatible with this bootstrap. It loads the partition boot sector from fixed CHS 0/1/1, which corresponds to LBA 63 with 16 heads and 63 sectors per track.

Transferring the DOS system

Format the partition as FAT16, then transfer Windows 98 DOS to the correct drive letter:

SYS X:

Determine X: by inspecting the drive capacity. Do not select a drive letter by assumption.

Verify the root directory:

DIR X:\ /A

It must contain at least IO.SYS, MSDOS.SYS, and COMMAND.COM. If FORMAT /S reports insufficient memory, format the partition separately and run SYS X: afterward.

Building the MBR bootstrap on macOS

The source is included as thinkpad-365ed-pcmcia-mbr.s. The tested build uses Clang and GNU objcopy from Homebrew binutils:

clang -target i386-unknown-linux-gnu \
  -c thinkpad-365ed-pcmcia-mbr.s \
  -o thinkpad-365ed-pcmcia-mbr.o

/opt/homebrew/opt/binutils/bin/objcopy \
  -O binary -j .text \
  thinkpad-365ed-pcmcia-mbr.o \
  thinkpad-365ed-pcmcia-mbr.bin

stat -f '%z' thinkpad-365ed-pcmcia-mbr.bin
shasum -a 256 thinkpad-365ed-pcmcia-mbr.bin

Expected output for the included source:

size:    446
SHA-256: 5b85b6d1c26ce2bbbf5455ffe917877b9ad8457620eeb199b12deb4c2965a96f

Do not write a binary with any other size as the MBR bootstrap area.

Installing the MBR on macOS

1. Identify the CF device

diskutil list
diskutil info /dev/disk5
diskutil info /dev/disk5s1

/dev/disk5 is only an example. Determine the device number again after every connection.

Confirm all of the following before continuing:

  • Whole: Yes for the whole-disk device;
  • Device Location: External;
  • Removable Media: Removable;
  • the expected total CF capacity;
  • a FAT16 first partition;
  • Partition Offset: 32256 Bytes, or 63 sectors.

Caution

Using the wrong /dev/diskN can overwrite the MBR of an unrelated disk.

2. Back up sector 0

diskutil unmountDisk /dev/disk5

sudo dd if=/dev/rdisk5 \
  of=cf-sector0-original.bin \
  bs=512 count=1

stat -f '%z' cf-sector0-original.bin
shasum -a 256 cf-sector0-original.bin

The backup must be exactly 512 bytes. Keep it until the card has passed a real boot test.

3. Construct a complete patched sector

The executable MBR area is bytes 0..445. Bytes 446..509 contain the partition table, and bytes 510..511 contain the 55 AA signature. Preserve the last 66 bytes from the original sector:

cp cf-sector0-original.bin cf-sector0-patched.bin

dd if=thinkpad-365ed-pcmcia-mbr.bin \
  of=cf-sector0-patched.bin \
  bs=446 count=1 conv=notrunc

stat -f '%z' cf-sector0-patched.bin
xxd -g1 -s 446 -l 66 cf-sector0-patched.bin

The patched file must remain exactly 512 bytes. Its last two bytes must be 55 aa, and its partition entry must match the original backup.

Writing only 446 bytes directly to /dev/rdiskN can fail with Invalid argument on macOS because the raw device requires a 512-byte-aligned operation. Construct and write the complete 512-byte sector instead.

4. Write and verify

sudo dd if=cf-sector0-patched.bin \
  of=/dev/rdisk5 \
  bs=512 count=1 conv=notrunc

sync

sudo dd if=/dev/rdisk5 \
  of=cf-sector0-readback.bin \
  bs=512 count=1

cmp cf-sector0-patched.bin cf-sector0-readback.bin
shasum -a 256 cf-sector0-readback.bin
diskutil eject /dev/disk5

An empty cmp result means the readback matches. Remove the CF card only after a successful comparison and diskutil eject.

Testing on the ThinkPad

  1. Insert the CF card and PCMCIA adapter while the ThinkPad is powered off.
  2. Configure the machine to boot from the PCMCIA disk.
  3. Wait for the Windows 98 banner and C:\> prompt.
  4. Run:
VER
DIR C:\ /A
COPY /B C:\COMMAND.COM NUL

The expected results are Windows 98 version 4.10.2222, the system files in the root directory, and a successful one-file copy to NUL.

Restoring the original MBR

If the card does not behave as expected, write the saved 512-byte sector back using the same verified device:

diskutil unmountDisk /dev/disk5

sudo dd if=cf-sector0-original.bin \
  of=/dev/rdisk5 \
  bs=512 count=1 conv=notrunc

sync
diskutil eject /dev/disk5

This restores the original bootstrap, partition table, and MBR signature from the backup.

Limitations

  • The workaround is tied to the confirmed IO.SYS hash and its instruction layout around saved IP 12A2h.
  • The hard-coded head count of 16 must match the FAT16 BPB.
  • The first partition must begin at LBA 63 and map to CHS 0/1/1.
  • The CF card must be exposed by the BIOS as drive 80h.
  • The code has been tested on an IBM ThinkPad 365ED. Other ThinkPad models may have different PCMCIA BIOS behavior.
  • SYS installs the system files and partition boot sector, but it does not install this custom MBR.
  • Installing the MBR does not format the FAT16 partition. Selecting the wrong physical device can still destroy another disk's partition table.

Failure chain at a glance

BIOS loads MBR
  -> MBR loads FAT16 boot sector
  -> boot sector loads IO.SYS
  -> IO.SYS rereads CHS 0/0/1
  -> PCMCIA BIOS hangs

cached sector-0 workaround
  -> IO.SYS continues
  -> BIOS geometry reports heads=0
  -> IO.SYS executes DIV EBX with EBX=0
  -> divide exception and blinking cursor

geometry workaround
  -> heads=16 and EBX=16
  -> IO.SYS continues
  -> COMMAND.COM displays C:\>

Files

  • README.md: diagnosis, workaround design, installation, verification, and recovery procedure.
  • thinkpad-365ed-pcmcia-mbr.s: the 16-bit resident MBR bootstrap source.
.code16
.section .text
.globl _start
/*
* ThinkPad 365ED PCMCIA CF bootstrap.
*
* The IBM PCMCIA INT 13h implementation hangs when Windows 98 IO.SYS rereads
* CHS 0/0/1. Keep a copy of MBR sector 0 and answer that exact request from
* memory. IO.SYS also receives a zero head count from this BIOS. Single-step
* only until the proven division site, set the head count to 16, then restore
* INT 1 and clear TF before continuing at full speed.
*/
_start:
cli
xorw %ax, %ax
movw %ax, %ss
movw $0x7c00, %sp
movw %ax, %ds
movw %ax, %es
cld
/* First relocate away from 0000:7c00. */
movw $0x7c00, %si
movw $0x0600, %di
movw $0x0100, %cx
rep movsw
/* Reserve 2 KiB below conventional memory and copy code plus sector cache. */
movw 0x0413, %ax
subw $2, %ax
movw %ax, 0x0413
shlw $6, %ax
movw %ax, %es
movw $0x0600, %si
xorw %di, %di
movw $0x0100, %cx
rep movsw
movw $0x0600, %si
movw $0x0200, %di
movw $0x0100, %cx
rep movsw
pushw %ax
pushw $(relocated - _start)
lretw
relocated:
xorw %ax, %ax
movw %ax, %ds
cli
movw 0x004c, %ax
movw %ax, %cs:(old13 - _start)
movw 0x004e, %ax
movw %ax, %cs:(old13 + 2 - _start)
movw $(int13_handler - _start), 0x004c
movw %cs, %ax
movw %ax, 0x004e
sti
/* Load the active partition boot sector at CHS 0/1/1. */
xorw %ax, %ax
movb $0x80, %dl
int $0x13
xorw %ax, %ax
movw %ax, %es
movw $0x7c00, %bx
movw $0x0201, %ax
movw $0x0001, %cx
movw $0x0180, %dx
int $0x13
jc disk_error
xorw %ax, %ax
movw %ax, %ds
movw $0x07be, %si
movb $0x80, %dl
ljmp $0x0000, $0x7c00
disk_error:
cli
1:
hlt
jmp 1b
int13_handler:
cmpw $0x0201, %ax
jne chain13
cmpw $0x0001, %cx
jne chain13
cmpw $0x0080, %dx
jne chain13
incb %cs:(bypass_count - _start)
pushw %bp
movw %sp, %bp
pushw %ds
pushw %si
pushw %di
pushw %cx
pushw %cs
popw %ds
movw $0x0200, %si
movw %bx, %di
movw $0x0100, %cx
cld
rep movsw
popw %cx
popw %di
popw %si
popw %ds
movw $0x0001, %ax
andw $0xfffe, 6(%bp)
/* The second matching read is the one immediately before the bad divide. */
cmpb $2, %cs:(bypass_count - _start)
jne return13
pushw %ax
pushw %ds
xorw %ax, %ax
movw %ax, %ds
movw 0x0004, %ax
movw %ax, %cs:(old1 - _start)
movw 0x0006, %ax
movw %ax, %cs:(old1 + 2 - _start)
movw $(int1_handler - _start), 0x0004
movw %cs, %ax
movw %ax, 0x0006
popw %ds
popw %ax
orw $0x0100, 6(%bp)
return13:
popw %bp
iretw
chain13:
ljmpw *%cs:(old13 - _start)
int1_handler:
pushw %bp
movw %sp, %bp
/* Runtime IO.SYS offset 12a2 contains 66 f7 f3 66: DIV EBX. */
cmpw $0x12a2, 2(%bp)
jne return1
pushw %ax
pushw %ds
pushw %si
movw 4(%bp), %ax
movw %ax, %ds
movw 2(%bp), %si
cmpl $0x66f3f766, (%si)
popw %si
popw %ds
popw %ax
jne return1
/* Repair both the BIOS geometry byte and the divisor already in EBX. */
movb $0x10, %es:0x15(%di)
movl $0x00000010, %ebx
/* Restore the original debugger vector and disable single-step on IRET. */
pushw %ax
pushw %ds
xorw %ax, %ax
movw %ax, %ds
movw %cs:(old1 - _start), %ax
movw %ax, 0x0004
movw %cs:(old1 + 2 - _start), %ax
movw %ax, 0x0006
popw %ds
popw %ax
andw $0xfeff, 6(%bp)
return1:
popw %bp
iretw
old13: .word 0, 0
old1: .word 0, 0
bypass_count: .byte 0
/* Preserve the partition table at byte 446 when this bootstrap is installed. */
.org 446, 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment