Running MS-DOS on the Intel Galileo
The Intel Galileo was a single-board computer sold by Intel from 2013 to 2017. While it was intended to be used as an Arduino-like platform (it was Arduino certified, i.e. it could be programmed from the Arduino IDE and was compatible with Arduino shields), its 400 MHz CPU and 256 MB of RAM put it much closer to the likes of the Raspberry Pi than to the 'classic' Arduino options.
In my opinion, the CPU is definitely the most interesting part of the package: this is not an ARM SoC like most SBCs, but an Intel SoC called the Quark. Essentially, the Quark is a reimplementation of the original Pentium on a modern process node and with modern features (sleep states, Secure Boot) and interfaces (eMMC, USB, PCIe, SPI, I2C etc.). In other words, it implements the i586 instruction set1. This led me to a question: can the Galileo run MS-DOS natively?
Galileo hardware and boot process #
Out of the box the Galileo runs a 32-bit UEFI, which boots GRUB, which in turn loads a Yocto Linux build running Intel's Arduino layer. This poses a problem: MS-DOS runs in real mode (i.e. a 16-bit environment) and depends heavily on interrupt routines provided by the BIOS. My idea was to build an EFI application that performs the necessary preparation and then returns the CPU to real mode. From there, a small setup program installs custom versions of the required interrupt routines before finally handing control over to DOS.
Approach #
To keep this project manageable I decided to split it into three phases:
- A proof-of-concept in QEMU showing that we can hook interrupts before DOS boots;
- A proof-of-concept that demonstrates we can put the Galileo into real mode;
- The actual port of DOS to the Galileo.
QEMU #
For the QEMU proof-of-concept I decided on the following:
- We'll move the original DOS boot sector to a free location on the floppy image, then install our loader to the boot sector.
- The loader hooks the necessary interrupts from our setup program before chainloading the original boot sector. The following interrupts will be hooked for this demonstration:
- INT 10h function 0Eh (teletype output) - output will be mirrored to the serial port.
- INT 12h (get conventional memory size) - an amount of 624 KiB will be returned, leaving 16 KiB reserved to store our custom interrupt handlers in.
- INT 16h (keyboard input) - input will instead be provided from the serial port.
After a few prompts I had the basic setup implemented. The most notable deviation from the above plan was the need for a second stage for the loader, as the hooking and chainloading required more than the 512 bytes provided by the boot sector.
Real mode #
The next step was seeing whether the Galileo could actually run in real mode. This is not a given2, as the Galileo does not boot in legacy BIOS mode, but instead runs a 32-bit UEFI firmware (based on EDK II). The minimal demonstration of this is the following:
- A minimal EFI application loads a trampoline into memory, exits the UEFI boot services so we have full control of the system, and jumps to the trampoline.
- The trampoline sets up UART output and returns to 16-bit real mode by executing a far jump to 16-bit protected mode, followed by clearing the PE bit from CR0.
- From 16-bit real mode, we write a success message to the UART.
By putting the application on a FAT32-formatted SD card as EFI/BOOT/BOOTIA32.EFI the Galileo will automatically load it:
Port #
For actually running DOS on the Galileo we don't need to modify the floppy image. Instead, any preparation (jumping to real mode, installing interrupt handlers, setting up the UART) can be done from the EFI application, or from the trampoline or setup routine loaded by it, before we hand control over to DOS.
On the Galileo, we need to provide a lot more interrupts than in QEMU. In QEMU, unimplemented functions could fall back on the QEMU-provided BIOS, but on the real hardware no such fallback is available.
Unlike in QEMU, there is also no emulated floppy drive available. The EFI application instead loads the floppy image into RAM. The INT 13h read-sector routine serves reads from there.
The boot flow thus becomes:
UEFI application -> trampoline -> setup code -> MS-DOS
The UEFI application reserves the memory regions needed for the DOS boot sector, trampoline, setup code, interrupt handlers and state information. It loads the floppy image (msdos.img in the root of the SD card) and the trampoline code (embedded in the application) from the root of the SD card. These are not loaded to hardcoded addresses, but to memory regions that the UEFI provides at runtime. By reserving the regions mentioned above, we prevent the UEFI from storing the floppy image or trampoline there.
The application then loads the DOS boot sector, the setup code and the interrupt handlers to low memory, and writes a handoff block containing the address and size of the floppy image and the base address of the UART.
| Flat address | Purpose |
|---|---|
0x00006000 |
Setup code |
0x00007C00 |
DOS boot sector |
0x00008000 |
Interrupt handlers (before being moved) |
0x0009C000 |
Interrupt handlers (after being moved) |
0x0009CF00 |
Handoff block |
somewhere above 0x01000000 |
Trampoline, floppy image |
The UEFI application then hands control over to the trampoline. The trampoline puts the system into real mode (as described in the previous section) and hands control over to the setup code. The setup code moves the interrupt handlers to the reserved memory area, installs them into the interrupt vector table, sets up the PIC/PIT and BIOS Data Area, and jumps to the DOS boot sector at 0000:7C00.
I started by hooking the interrupts normally provided by the BIOS (08h-19h) and the exception handlers (00h-07h) with a small handler that prints the interrupt or exception details to the UART, so we immediately notice if an exception or unimplemented interrupt request occurs.
From there, it was a matter of booting the EFI application on the Galileo, seeing on which interrupt or exception DOS hung, implementing or fixing that, rebuilding the application and booting it again, until DOS succesfully booted to the shell. Note that the interrupt handlers implemented in this way only provide the minimal functionality to allow DOS to continue booting!
The following functionality seems to be required at minimum to boot MS-DOS 6.22 to the shell:
| Interrupt | Function | Description | Hook |
|---|---|---|---|
| 00h-07h | CPU exceptions | Print diagnostic state (CS:IP, nearby bytes and stack words) to the UART, then halt. |
|
| 08h | Timer interrupt | Increment the BIOS tick counter and acknowledge the PIC. | |
| 0Fh | 09h | PIC-generated IRQ7 | Acknowledge the PIC. |
| 10h | 0Eh | Teletype output | Output the character to the UART. |
| 11h | Equipment list | Return a hardcoded configuration: 1 floppy drive, 80x25 display, no serial ports. | |
| 12h | Get conventional memory size | Return 620 KiB instead of the maximum 640 KiB so we have some space to store hooks and state. | |
| 13h | 00h | Reset disk system | Return success for the first floppy drive only. |
| 13h | 01h | Get drive status | Return success for the first floppy drive only. |
| 13h | 02h | Read sectors | Return the requested sectors from the RAM disk. |
| 13h | 03h | Write sectors | Return the 'write protected' error code. |
| 13h | 08h | Get drive parameters | Return geometry derived from the BIOS parameter block on the floppy image. |
| 13h | 15h | Get drive type | Return diskette drive for the first drive, 'not installed' for others. |
| 13h | 16h | Detect media change | Return 'no media change' for the first drive, 'invalid drive number' for others. |
| 14h | Serial port functions | Return an error code as no serial ports are available. | |
| 15h | C0h | Get system parameters | Return a hardcoded configuration: AT-class PC, no support for any optional features. |
| 16h | 00h/10h | Read (expanded) keyboard | Return a single character from the keyboard buffer. We can populate this from the UART input. |
| 16h | 01h/11h | Get (expanded) keyboard status | Return whether a character is waiting to be read (from the UART). |
| 16h | 02h | Get keyboard flags | Return no modifier keys, as these cannot be entered over serial anyway. |
| 17h | 01h | Initialize printer | Return status 'I/O error'. |
| 1Ah | 00h | Get BIOS ticks | Return the BDA tick count maintained by INT 08h. |
| All other IVT entries | Unknown interrupts | Log the vector and AX, then halt instead of jumping through stale firmware vectors. |
The end result: MS-DOS 6.22 running on the Intel Galileo natively:
The source code for all three phases is available on my Forgejo instance.
Limitations and possible next steps #
Of course, while it is cool to have DOS running natively, the current implementation is very limited:
- As there is no VGA hardware or emulation, most applications (e.g. EDIT) will not run.
- Disk writes are not implemented, again blocking the use of many applications.
- Upper and extended memory are not implemented.
- The Galileo's timer hardware runs at a much higher rate, making time- and date-dependent functionality unreliable.
- Time and date services are minimal: BIOS ticks exist, but RTC time/date are fixed placeholder values.
As the Galileo has a mini PCIe slot on the back, it would be interesting to see if a simple graphics card would work using a PCIe riser. If so, it might be possible to port coreboot and SeaBIOS, opening the door to a full DOS port and maybe even allowing the Galileo to run early Windows versions.
Remarks #
1: To be exact, it is derived from a Pentium-class core, so it supports x87 instructions, but not MMX or SSE instructions. 2: I later found out that this is in fact a given: just like regular x86 CPUs the Quark boots in 16-bit real mode, as noted by the Intel® Quark SoC X1000 Core Developer’s Manual. It is actually the UEFI implementation that transitions it to 32-bit protected mode.
Sources #
- Intel® Quark™ SoC X1000 Datasheet
- Intel® Quark SoC X1000 Core Hardware Reference Manual
- Anatomy of the UEFI Boot Sequence on the Intel Galileo
- Previous post: 'Deamericanizing' my homelab