This tutorial shows how to write Intel assembler code for a first-stage boot loader for a toy operating system. We review the structure of the boot sector, and write code for resetting the disk system, rebooting, and writing a string to the screen.

Now that we know the structure of the boot sector’s boot parameter block (BPB) and extended boot parameter block (EBPB), we can start writing our first boot loader code in GNU assembler. (If you need a refresher, please have a look at the first part of this series about the structure of the boot sector).

This article is part of a series on toy operating system development.

View the series index

First code in GNU assembler

We’ll be using the GNU assembler, since it’s free, comes with a boatload of options, supports AT&T and Intel assembly syntax and plays nice with gcc and ld later on. Some of the preprocessor directives used may need some explanation, but all code will be in straightforward Intel syntax.

Here’s some boilerplate code to get started:

.code16
.intel_syntax noprefix
.text
.org 0x0

LOAD_SEGMENT = 0x1000

.global main
main:
  jmp short start
  nop

  # BPB and EBPB here

start:
  jmp 0x0000:real_start

real_start:
  # rest of code

The pile of preprocessor instructions at the top tell the assembler to assemble code for real mode. Since all (intel-based) computers start up in real mode with 16-bit instructions, we won’t be able to write 32-bit code here yet. We also instruct GNU assembler that we’ll be using Intel syntax (e.g. mov ax, 1 instead of movw $1, %ax – some prefer the latter, but most readers of this text will be familiar with Intel). The origin of our code will be 0x0, i.e. all absolute addresses start at 0x0, which will be convenient.

Then there’s the main entry point of our code, which corresponds to the first byte of actual output when assembled. The code under the label main simply jumps over the BPB and EBPB located at offset 0x3, resuming execution at the label start. We’ll flesh out the BPB/EBPB in a bit, since it’ll have to have a very exact size.

The very first thing we do at start is jump again! This is because the BIOS loads the boot sector at physical address 0x7C00, but it is free to express that address however it likes: most BIOSes enter at 0000:7C00, some at 07C0:0000. Those are the same byte of memory, but a very different CS. Since we are about to derive DS, ES and SS from CS, and every label in this file is an absolute address, getting the wrong one would put every string and every variable 0x7C00 bytes away from where we look for it. A far jump sets CS and IP together, so jumping to 0x0000:real_start forces both to values we have chosen, whichever convention the BIOS used.

We’ve also defined a constant LOAD_SEGMENT, which is the segment where we’ll be loading our second stage boot loader (more about that later).

The Boot Parameter Block

The structure of the boot parameter block can be coded like this:

bootsector:
  iOEM:          .ascii "DevOS   "    # OEM String
  iSectSize:     .word  0x200         # bytes per sector
  iClustSize:    .byte  1             # sectors per cluster
  iResSect:      .word  1             # #of reserved sectors
  iFatCnt:       .byte  2             # #of FAT copies
  iRootSize:     .word  224           # size of root directory
  iTotalSect:    .word  2880          # total # of sectors if below 32 MB
  iMedia:        .byte  0xF0          # media Descriptor
  iFatSize:      .word  9             # size of each FAT
  iTrackSect:    .word  18            # sectors per track
  iHeadCnt:      .word  2             # number of read-write heads
  iHiddenSect:   .int   0             # number of hidden sectors
  iSect32:       .int   0             # # sectors for over 32 MB
  iBootDrive:    .byte  0             # holds drive that the boot sector came from
  iReserved:     .byte  0             # reserved, empty
  iBootSign:     .byte  0x29          # extended boot sector signature
  acVolID:       .ascii "seri"        # disk serial
  acVolumeLabel: .ascii "MYVOLUME   " # volume label
  acFSType:      .ascii "FAT12   "    # file system type

The fields in this structure correspond to the specification in the first part of this text about the structure of the boot sector, and since they’re nicely labelled, we’ll be able to refer to them later on.

Real-mode Segments

After the start label, we can write some actual code. Let’s start by defining our real mode data segments:

start:
  jmp  0x0000:real_start  # CS is now 0x0, whatever the BIOS chose

real_start:
  cli                  # Turn off interrupts
  mov  ax, cs          # CS = 0x0, because we just made it so
  mov  ds, ax          # DS = CS = 0x0
  mov  es, ax          # ES = CS = 0x0
  mov  ss, ax          # SS = CS = 0x0
  mov  sp, 0x7C00      # Stack grows down from offset 0x7C00 toward 0x0000.
  sti                  # Enable interrupts
  mov  iBootDrive, dl  # save what drive we booted from (BIOS put it in DL)

Here, we mask interrupts so that interrupt calls don’t mess up our sector declarations. We set ES = DS = SS = CS = 0x0, and make the stack grow down from 0x7C00 (our boot loader was loaded at 0x7C00). When done, we turn the interrupts back on. It’s important to note that the BIOS places the number of the boot drive in the DL register. We store it in our BPB for later use.

You may wonder why the code isn’t simply:

mov ax, 0
mov ds, 0
mov es, 0

The reason is that it is not possible to assign an integer directly to DS, ES and SS. These destinations are segments, and you can’t directly assign an immediate value to them; only a register.

Resetting the disk system

Next, we need to prepare the floppy drive for use. This is done through BIOS interrupt 0x13, subfunction 0. We call it with the boot drive number in DL:

mov  dl, iBootDrive   # drive to reset
xor  ax, ax           # subfunction 0
int  0x13             # call interrupt 13h
jc   bootFailure      # display error message if carry set (error)

If the reset fails, the carry flag will be set and we jump to a label where we handle a boot failure by showing a message, waiting for a key press and rebooting. Come to think of it, we’ll need a way to print a string to the screen.

Printing a string

We’ll add a short function that uses BIOS interrupt 0x10, sub-function 0xe to print characters to the screen. The calling code must point DS:SI to the null-terminated string to be printed.

.func WriteString
 WriteString:
  lodsb                   # load byte at ds:si into al (advancing si)
  or     al, al           # test if character is 0 (end)
  jz     WriteString_done # jump to end if 0.

  mov    ah, 0xe          # Subfunction 0xe of int 10h (video teletype output).
  mov    bx, 9            # Set bh (page nr) to 0, and bl (attribute) to light blue (9).
  int    0x10             # call BIOS interrupt.

  jmp    WriteString      # Repeat for next character.

 WriteString_done:
  retw
.endfunc

We can now define the bootFailure label:

diskerror: .asciz "Disk error. "
bootFailure:
  lea si, diskerror
  call WriteString
  call Reboot

You might think at this point: why lea si, diskerror and not mov si, diskerror? This is because in GNU AS, the mov instruction would place the value of the first two bytes of diskerror in si. lea computes its address instead, which is what we want. Alternatively one could write mov si, OFFSET diskerror, which does the same thing and is, when assembled, one byte shorter.

Great. We’ve got code to reset the floppy drive, and if it fails, there’s code that prints failure strings and reboots. Although, we still have to write a Reboot function.

Rebooting

Here is some code that prints a “Press any key to reboot” message, waits for a keystroke, and reboots the machine.

rebootmsg: .asciz "Press any key to reboot\r\n"
.func Reboot
 Reboot:
  lea    si, rebootmsg    # Load address of reboot message into si
  call   WriteString      # print the string
  xor    ax, ax           # subfuction 0
  int    0x16             # call bios to wait for key

  jmp    0xFFFF:0x0000    # far jump to the CPU's power-on entry point (reboot)
.endfunc

Here, we use BIOS interrupt 0x16, sub-function 0 to read a key (any key). We then add a far jump to 0xffff:0000 which causes the machine to reboot.

2026 note: Far jumping to 0xffff:0000 may not always work on modern hardware. While the CPU starts at this address directly after power on (effectively running POST again), this does not reset any hardware (disk controllers, A20 line), and does not reset the CPU state. A better reset is done with:

mov  al, 0xFE           # pulse the CPU reset line via the keyboard controller
out  0x64, al           # a genuine reset on most hardware

Putting it all together

When we combine the functions above into a single source file, we end up with this:

.code16
.intel_syntax noprefix
.text
.org 0x0                                        

LOAD_SEGMENT = 0x1000             # load the boot loader to segment 1000h

.global main

main:
  jmp short start                 # jump to beginning of code
  nop

bootsector:
 iOEM:          .ascii "DevOS   "    # OEM String
 iSectSize:     .word  0x200         # bytes per sector
 iClustSize:    .byte  1             # sectors per cluster
 iResSect:      .word  1             # #of reserved sectors
 iFatCnt:       .byte  2             # #of FAT copies
 iRootSize:     .word  224           # size of root directory
 iTotalSect:    .word  2880          # total # of sectors if below 32 MB
 iMedia:        .byte  0xF0          # media Descriptor
 iFatSize:      .word  9             # size of each FAT
 iTrackSect:    .word  18            # sectors per track
 iHeadCnt:      .word  2             # number of read-write heads
 iHiddenSect:   .int   0             # number of hidden sectors
 iSect32:       .int   0             # # sectors for over 32 MB
 iBootDrive:    .byte  0             # holds drive that the boot sector came from
 iReserved:     .byte  0             # reserved, empty
 iBootSign:     .byte  0x29          # extended boot sector signature
 acVolID:       .ascii "seri"        # disk serial
 acVolumeLabel: .ascii "MYVOLUME   " # volume label
 acFSType:      .ascii "FAT12   "    # file system type

.func WriteString
WriteString:
  lodsb                   # load byte at ds:si into al (advancing si)
  or     al, al           # test if character is 0 (end)
  jz     WriteString_done # jump to end if 0.

  mov    ah, 0xe          # Subfunction 0xe of int 10h (video teletype output)
  mov    bx, 9            # Set bh (page nr) to 0, and bl (attribute) to light blue (9)
  int    0x10             # call BIOS interrupt.

  jmp    WriteString      # Repeat for next character.

WriteString_done:
  retw
.endfunc

.func Reboot
 Reboot:
  lea    si, rebootmsg # Load address of reboot message into si
  call   WriteString   # print the string
  xor    ax, ax        # subfuction 0
  int    0x16          # call bios to wait for key
  jmp    0xFFFF:0x0000 # far jump to the CPU's power-on entry point (reboot)
.endfunc

start:
  # The BIOS may have entered at 0000:7C00 or at 07C0:0000. Far jump so that
  # CS:IP is what we expect before we derive any other segment from it.
  jmp  0x0000:real_start

real_start:
  # Setup segments:
  cli
  mov  ax, cs          # CS = 0x0, because the far jump above made it so
  mov  ds, ax          # DS = CS = 0x0
  mov  es, ax          # ES = CS = 0x0
  mov  ss, ax          # SS = CS = 0x0
  mov  sp, 0x7C00      # Stack grows down from offset 0x7C00 toward 0x0000.
  sti
  mov  iBootDrive, dl  # save what drive we booted from (BIOS put it in DL)

  # Display "loading" message:
  lea  si, loadmsg
  call WriteString

  # Reset disk system.
  # Jump to bootFailure on error.
  mov  dl, iBootDrive  # drive to reset
  xor  ax, ax          # subfunction 0
  int  0x13            # call interrupt 13h
  jc   bootFailure     # display error message if carry set (error)  

  # End of loader, for now. Reboot.
  call Reboot

bootFailure:
  lea  si, diskerror
  call WriteString
  call Reboot

# PROGRAM DATA
loadmsg:          .asciz "Loading OS...\r\n"
diskerror:        .asciz "Disk error. "
rebootmsg:        .asciz "Press any key to reboot.\r\n"

.org 510, 0                  # Pad with nulls up to offset 510
BootMagic:  .word 0xAA55     # magic word for BIOS

Points of note

  • The WriteString and Reboot sections are functions that we’ll want to call various times in the other boot code that we’ll soon write. They are not part of the main body of code. That is why they are placed before the start label, so that execution will jump over them.
  • The far jump at start targets a label, so its offset is resolved at link time. That means it only lands where you want if you link the boot sector at the address the BIOS loads it to - ld -Ttext 0x7c00, as we do. Link it at 0 and the jump will faithfully send you to 0000:003e.
  • The loader will print a “Loading OS…” message right after it sets the segments.
  • After having reset the disk system successfully, the loader will reboot. We’re doing that only because we haven’t written any more code yet that does interesting things.
  • The source code ends with an .org directive. This causes the assembler to fill up the output file with null bytes all the way to offset 510. The final two bytes contain the magic word required by some BIOSes. Compilation will now yield a file of exactly 512 bytes, which is what we need for our boot sector. Note that .fill (510-(.-main)), 1, 0 is an alternative, but it issues only a warning when the code is over 510 bytes, while .org halts with an error.

Summary

We’ve written assembler code that prepares data and stack segments and resets the floppy drive. We’ve also added functions for writing text to the screen, waiting for a keypress, and rebooting.

I don’t know about you, but I’m just about ready to see this code in action! In the next section on setting up a toolchain and using Bochs, we’ll see how we can actually compile and test this code.

Continue on to the next part of this guide!

12 comments

Archived from the site's previous comment system. New comments are closed.

Utkarsh

Awesome guide dude..!!
When are you gonna post the next section..???

alex

Uh, soon... My toy OS guide got a bit sidelined with other projects. I do have all the code that I wanted to discuss here, so I should be all set to write the rest. Check back later!

Note that I did make some changes and corrections just now, and I think in the next section we should compile all this and see how it runs.

EDIT: section 3 is done now.

Isaac

Can I use this code in the commercial world?

alex

Yes, under two conditions:
* Credit me;
* Let me know what you did with it. I may want to contribute.

Isaac

Will your boot parameter block also work for fat32 or only for fat 16?

alex

No, this is just for FAT16. The BPB for FAT32 is slightly different I think.

gulab

I have changed the comment line for compile
; load the boot loader to segment 1000h
# load the boot loader to segment 1000h

alex

Oops, my bad. I switched to GNU-style comments at some point and left that in there. It's gone in my own code, so I forgot to update the website. Thanks!

dennis

Great stuff! Just a remark on the padding: The BootMagic as .int is 4 bytes long, resulting in a 514 byte boot sector. Using .word as data type fixes this.

tahir

i never found explanation like that on low level programming.So i request if he makes a tutorial on How to setup or install GNU assembler.it just for the sake of beginners

Mohamad-Jaafar

I am not assembly expert. But, why don't you put .short instead of .int for the magic number so that it matches 512 bytes?

Marabiloso

If I'm not mistaken, the field "Total number of sectors in filesystem" (a dword) is missing in the declaration of bootsector at offset 32, which is shifting everything afterwards, starting with the current drive where you save dl. Not sure how important it is, though, but a real floppy drive (does that still even exist?) probably won't like it too much - and VMWare Fusion doesn't care at all about it. I'm just curious to know how an SD card will react to it?

Also, as far I could find, you can use the same structure to build an El-Torito bootable .iso, emulating a floppy boot is one of the known supported modes.