A nice Indian fellow named Viral Patel wrote this tutorial on how to write a PC boot loader.
I tried it out and ran into some problems. The problem was that the string in the program code ultimately wouldn't be found.
The actual boot loader code worked. While MS-DOS (and CP/M) binaries are loaded at address 0x100, boot-loaded code is loaded, by the IBM PC BIOS presumably, at address 0x7C00. The assembler should know this, hence the ORG directive at the top.
I wrote and assembled the code in a Linux VM and then wrote it to a floppy image. The same floppy image, installed read-only, is then used in another VM as the boot device.
nasm bootloader.asm -f bin -o boot.bin
sudo dd if=boot.bin bs=512 of=/dev/fd0
This program is supposed to write "Hello, world." to the screen via the teletype BIOS call 0x0E.
I'll try to explain (as much as I understand) how the program works.
BITS 16 tells the assembler that this is 16 bit code.
ORG 0x7C00 tells the assembler that this code will be located starting at address 0x7C00.
MOV DS,AX make sure that the code segment and the data segment start at the same address.
This is necessary because the code contains data and loading data apparently uses an address relative to the data segment address.
MOV SI,s_hello writes the address s_hello into the Source Index register.
CALL writes jumps to the writes address and waits for a RET thence.
JMP $ jumps to itself, i.e. makes the program repeat this command forever.
write: code at this address will write a single character to the display in teletype mode.
MOV AH,0x0E activates teletype mode for the BIOS interrupt, character must be in AL.
MOV BL,7 sets display configuration to lightgrey foreground on black background.
MOV BH,0 sets some sort of page. BX and hence BH might have changed.
INT 0x10 calls the BIOS interrupt.
writes: uses write to display a string of characters.
MOV AL,[SI] loads the character at the address stored in SI into AL.
OR AL,AL sets the CPU's Zero Flag if AL contains a zero.
JZ exit_writes jumps to exit_writes if Zero Flag is set (i.e. if AL contains a zero).
CALL write calls write for the character currently in AL.
INC SI moved forward one byte from current position in s_hello.
JMP writes jumps back to the beginning of the function at writes.
s_hello: is the location where the string "Hello, world.",0 is stored.
DB "Hello, world.",0
TIMES 510-($-$$) DB 0 writes 512-2-(length of program so far) zeroes.
DW 0xAA55 writes two bytes 55 and AA as a signature. 510+2 is 512, a boot sector length.
$ is the current program location. $$ is the start of the program. $-$$ is the length of the program from $$ to $. This fills the rest of the sector with zeroes.
A problem came up originally when I didn't synchronise the code and data segments. The program would always read a zero from s_hello. I couldn't figure it out.
I suspended the VM after the code was running. I then looked at the frozen memory of the VM.
I could see the "Hello, world.",0 at the location where I knew it had to be. But the program couldn't find it there.
So I decided to write characters into whatever location the program thought s_hello was pointing to. I wrote "Foo Bar",0. (I also added an "F" on display to see if the program got that far.)
I looked at the frozen memory again. Note that I also changed the filling zeroes into filling "G"s to make them more visible. (I can't even begin to explain how confused I was looking at this screen before I remembered that!)
Turns out s_hello was exactly 1024 B (0x804B-0x7C4B=0x400) above the location where I thought it would be. This was fixed by making the code segment and data segment point to the same address (probably 0x7C00).
And finally, this is the output of the VM running the boot loader from the floppy image. It displays "Hello, world." and then runs JMP $ forever. (VMware Fusion apparently notices that and it doesn't use physical CPU time.)