I was recently wondering what would it take to run a simple C program directly on top of the PC BIOS, without any operating system code. Since BIOS provides an out-of-the-box API for accessing disks, keyboard, video (both text and graphics), system timer, and more, it should be easy enough, right?
Well, there are tons of tutorials for writing boot-loaders and simple operating systems, but usually they're either extremely limited in functionality, or require a substantial time commitment. Meanwhile, I wanted to find out how straightforward it can possibly be to write usable applications with minimal boilerplate.
As a byproduct, after a few days of tinkering, I created this repository to share my approach. It shows how to create C programs, which:
- boot directly from a USB drive / SD card
- don't require any operating system code
- don't require writing any custom drivers
- only require small and fixed amount of assembly code
- can access the BIOS API directly from C
Of course they also have some limitations:
- they don't have access to the standard C library
- they only work in the real-address mode
- the final binary is limited to ~64KB
- available RAM is limited to ~640KB
- the boot-loader is not guaranteed to work on every PC
So why bother doing such a thing in 2019? Although bare BIOS is too limited for any real-world applications, thanks to the backwards compatibility, it's still widely available. It's also well-documented and well-understood. In the end, I think it still makes for a fun and approachable way to tinker with your PC over a few lazy afternoons.
- Clang or GCC (with support for i386 targets)
- GNU binutils
- QEMU / Bochs (for testing and debugging)
- A PC supporting USB boot in BIOS ("legacy") mode
- A spare USB disk / SD card
Note for Mac users: clang in the latest XCode doesn't support i386, and homebrew's binutils doesn't include GNU ld. I recommend installing MacPorts and their i386-elf-gcc & i386-elf-binutils packages, and updating Makefile accordingly
$ make
$ make qemu APP=snake
$ make bochs APP=hexview
Be careful to pick the right device, this will destroy your data!
$ make disk APP=hello
$ sudo dd if=build/disk.img of=/dev/<USB DISK>
Note: this is not a tutorial for writing low-level x86 code. It's a wide topic and there is plenty of high-quality documentation, so it doesn't make sense to duplicate it. I hope the code will be straightforward to follow, and if you'd like to learn more, check out the references section, especially the OSDev Wiki.
The final app consists of two binaries: a boot loader and the actual program. Both are created by compiling the source code with clang/gcc and GNU assembler, linking the program with GNU ld, and converting the resulting ELF files to flat binaries using objcopy.
To ensure that our main()
is always the first code in the program
binary, we move it to a separate .start
section using the
ENTRY_POINT
macro, and emit it at the top of the file
using a custom linker script
It is possible
to use 32-bit instructions in the real-address mode, they just need
to be marked with address-size and operand-size prefixes. The
-m16
option for clang/gcc, and .code16
directive in GNU assembler, do exactly
that. The resulting code is 32-bit, only marked everywhere with those
prefixes. It's not compatible with actual 16-bit CPUs.
Unfortunately the 32-bit addresses still cannot exceed the boundary of the segment (65535), otherwise they'll trigger an exception. QEMU doesn't emulate this behaviour, so it's useful to occasionally test with Bochs.
The boot loader (boot.s) loads the main program from the startup disk to the memory segment at 0x10000, and jumps to the starting point at offset 0. It assumes that BIOS will emulate the USB disk either as a HDD or a floppy. To make it more likely, it includes a basic MBR partition table. Just in case, we install it both to the main boot sector, and the boot sector of the first active partition.
Since USB booting is not a standardized process, it may not work on every PC. I only really tested on mine, and it still behaved in two different ways for a USB stick and an SD card. In case it doesn't work for you, you can try an alternative loader.
The services provided by BIOS are primarily accessed by saving their method number and arguments to CPU registers, and triggering a software interrupt. Some of them store return values back to the registers.
To avoid writing separate assembly code for every service, we define a generic function (intr.h, intr.s), taking the interrupt number and a pointer to a struct holding register values.
Modern compilers don't have a concept of far pointers, so we can't seamlessly access memory outside of the current code / data segments. This is the main factor limiting our binary to 64K.
Fortunately, both
gcc
and clang
have support for "address spaces" relative to FS and GS.
We include functions (util.h, util.c) to set values
of these registers, for example to access the text-mode video memory
at b800:0000
(see bios.h)
The standard C library depends on the operating system, so we can't
use it in standalone programs (hence the -ffreestanding
and -nostdlib
flags). However, for certain operations, like initialising a struct on
the stack, the compiler may still generate implicit calls to standard
functions, like memcpy
. In such cases, we just need to provide
our own versions (see util.h and util.c).
More likely than not, working on standalone programs will require some tinkering. Below are some hints:
In case the provided boot loader doesn't work, you may experiment with the one of syslinux:
$ wget https://mirrors.edge.kernel.org/pub/linux/utils/boot/syslinux/6.xx/syslinux-6.03.tar.gz
$ tar zxf syslinux-6.03.tar.gz
$ make disk APP=snake
$ dd if=syslinux-6.03/bios/mbr/mbr.bin of=build/disk.img conv=notrunc
By default, objdump will disassemble our binaries with no complaints,
showing a completely incorrect output. Since the code is compiled to run
in 16-bit mode, we need to add -m i8086
:
$ objdump -D -m i8086 build/hello.o
$ objdump -D -m i8086 build/hello.elf
$ objdump -D -b binary -m i8086 build/hello.bin
Bochs is one of the slowest emulators, but often more accurate than others.
Its debugger seems to handle 16-bit code slightly better than GDB with QEMU.
The xchg %bx, bx
instruction can be used to set a breakpoint, in C it's
available using BOCHS_BREAKPOINT
macro. The system clock is completely
inaccurate, so it's not that useful for testing games / animations.
The easiest way to test in VirtualBox is by attaching disk.img
as a raw
image of a floppy. However, it imposes a limit on the amount of sectors
that can be read (with a single BIOS call) to 0x48. So you'll need to replace
mov $0x027f, %ax
with mov $0x0248, %ax
in boot.s
In case you want to write any custom assembly function, be sure to use
32-bit ret
(i.e. retl
in GNU as), otherwise it'll leave the stack
shifted by 2 bytes.
Lists of available BIOS services:
- HelpPC Reference Library - A well-organized quick reference of interrupts and other relevant topics
- Ralf Brown's Interrupt List - The ultimate list
If you'd like to learn more:
- OSDev Wiki - Tons of knowledge about low-level development, primarily focused on writing operating systems
- Intel 80386 Programmer's Reference Manual
- 80286 and 80287 Programmer's Reference Manual
- System BIOS for IBM PC/XT/AT computers and compatibles - Another comprehensive reference of BIOS
- IBM 5150 Technical Reference (includes the source code of the original BIOS)
- IBM PC BIOS source code reconstruction