Luke's notes

Writing standalone C programs for i386


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.

Code overview

Overall structure

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

16 vs 32 bit

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.

Calling BIOS services

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.

Memory segmentation

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)

Standard library

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:

Installing the syslinux boot loader

In case the provided boot loader doesn’t work, you may experiment with the one of syslinux:

$ wget
$ 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

Disassembling files

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

Debugging with Bochs

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.

Running in VirtualBox

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

Writing assembly functions

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:

If you’d like to learn more: