A primer on x86 assembly with GNU assembler
When it comes to assembly language, it isn't as hard as many people think. Fundamentally, you write short, human-readable commands for the processor to execute (your assembly code) and then use a tool (the actual assembler) to translate your code into machine-readable binary instructions. Unlike high-level languages, assembly language is very simple and doesn't have that many features. The difficulty is to deal with memory and build more complex flows (e.g. loops or I/O) from the simple primitives that the assembly language gives you.
Registers and instructions
CPUs usually have small, very fast storage available for the data that is used in its instructions. This kind of storage is much smaller but also much faster than the RAM and is called
registers. An x86 processor has a bunch of them to store generic data, manage the stack, keep track of the current instruction and other administrative information.
In our bootloader, we will use 16 bit instructions and the registers we have available can store 16 bit each. They are called
They can be split into two groups: general purpose and special purpose registers.
dx are general purpose registers while
si (source index),
di (destination index),
sp (stack pointer) and
bp (base pointer) are special purpose registers.
For general purpose registers (
dx) exist alternative names if we want to work with the individual bytes (these registers are 16 bit, so they can hold two bytes): for the lower byte you can replace the
x with an
l and for the higher byte you replace it with an
In memory it would look like this:
15 0 bit | ----- ax ------ | | ah | al | 15 7 0 bit
That works for all these general purpose registers, e.g.
An x86 CPU has a ton of instructions for all sorts of purposes, e.g. moving data around, calling built-in functions, jumping to a different instruction and so on. We will only need a handful of instructions for this article:
||Moves data between two locations|
||Compares two things (e.g. register values)*|
||Jumps to another position in the code|
||Jumps if the things in the previous
||Increases the value in the given register or at the given memory address|
||Calls an Interrupt (here: a built-in, BIOS function)|
||String helper instruction to load a character ("load byte from si register")|
*) See next section
GNU assembler syntax prefixes & suffixes
Our GNU assembler (
as if run directly or
gcc if invoked as part of the C compiler) has a syntax speciality: It uses different prefixes to denote different kinds of things.
Whenever we reference a register, we need to prefix it with
% and whenever we mean a value, we need to prefix that with a
Here is an example that loads the value
5 into the
ax register in GNU assembler syntax (aka "AT&T syntax"):
mov $5, %ax
Some examples you might find online can also use additional (and often optional) instruction suffixes:
movb $5, %al
The code above means "move a byte with value 5 into the lower byte of the
ax register. Usually the GNU assembler is quite good at figuring it out without the suffixes, but sometimes it can't guess:
mov $0x123, %bx # loads value 0x123 into bx register mov $5, (%bx) # loads value 5 into memory at address 0x123
The previous code causes the following error:
Error: no instruction mnemonic suffix given and no register operands; can't size instruction
This error happens, because the assembler doesn't know what kind of data it should operate on (because it doesn't know the size available at address
0x123), so a suffix is needed:
mov $0x123, %bx # loads value 0x123 into bx register movb $5, (%bx) # loads a single byte (value 5) into memory at address 0x123
Valid suffixes are
b (8 bit, byte),
w (short or word, 16 bit),
l (long, 32 bit) or
q (quad, 64 bit).
Comparisons, conditional jumps and flags
je and a bunch of other operations, an additional special purpose register called "flags" is used.
cmp (or one of many other operations) is run, certain bits in that special register are set based on the data that was compared. The x86 manual notes which flags could be set by each operation.
There are a few flags available on x86, but we only care about the
zero bit that tells us if the compared data was equal.
The x86 architecture has a bunch of "conditional jump" instructions based on these flag bits or combinations of them that allow to jump to a different instruction in the code only if a given bit is or isn't 1. The
je instruction for instance stands for "jump if equal" and only goes to the given instruction if the zero flag is 1. It is equivalent to
jz which stands for "jump if zero flag is set". They actually create the same binary output.
Memory and labels
So far it's all nice and fine but how do we work with larger data? In high-level languages such as C or Ruby we have variables and constants but such concepts do not* really exist in assembly. In assembly all you've got is memory (aka RAM). The memory is basically just a long list of bytes that can be filled with data. Our code is a bunch of bytes and our data (i.e. our variables) is just more bytes amongst** our code.
That brings us to labels. Labels in assembly aren't taking up any actual space in memory. The assembler rewrites labels to actual addresses when creating our executable binary but they help us humans stay organised. A label is a name followed by a colon (":") in assembly.
Let's see an example in some assembly code
|0x0||mov %cx, $3||Loads the value
|0x1||loop: dec %cx||Decreases the value that is stored in
|0x2||cmp $0, %cx||Compares the value in
|0x3||jne loop||Jumps if the previously compared values are not equal|
When the assembler creates the binary, it will replace
jne loop with
When we want our assembler to reserve some space in memory and reference that space later, we also use these labels and a special command (called a preprocessor directive) to the assembler, so the assembler reserves space in the executable binary it creates.
For instance, to reserve a single byte in the GNU assembler, we do this:
my_byte: .byte 0x41
This will store a single byte with the value
0x41 (which is an "A" in ASCII) in memory and whenever I want to get the address of my byte "variable", I can use the name of the label (here
my_byte) in the code:
my_byte: .byte 0x41 mov %al, $my_byte
which will get rewritten so
$my_byte will instead by whatever address the value is stored at.
There are other data types available such as:
|Preprocessor directive||Bytes reserved||Meaning|
||1||Reserves a single byte|
||2||Reserves 16 bit (aka "word")|
||4||Reserves 32 bit (aka a "long word")|
||8||Reserves 64 bit (aka a "quad word")|
||2||Alternative name for
||4||Alternative name for
||*||Reserves as many bytes as needed for a string|
||*||Reserves as many bytes as needed for a string, plus an additional byte with value 0|
*) well, we can hardcode data using the preprocessor to get constants...
**) that depends a bit on how we write our code. We can separate our data from our code or not, depending on what we want. For this example all we have
***) instructions actually take some more space than a single byte, but to keep the example simple, I increased the address by a single value for each line.
As mentioned before, the path of getting to an executable, binary file from our assembly source code is via the actual assembler, a program that translates our human-readable program text into binary instructions and data for the processor to execute. In our case the assembler is GNU's
as or the integrated assembler in
gcc (which probably actually is
as, I dunno).
In addition to translating the assembly code we wrote, we can (and sometimes have to) give the assembler additional instructions how to transform our code into the final binary. These instructions are called preprocessor directives.
We already encountered an example of such an instruction that is not turned into an x86 instruction but influences the output when we defined a variable with
.byte. All preprocessor directives can be spotted by the leading dot and we can often ignore them when we're reading the code.
There are quite a few possible directives for GNU assembler but we will focus on the ones we need for now:
First of all, our bootsector will run when the processor is in its 16 bit real mode where it will use the 16 bit registers (ax etc.) and can only address 64kb of memory. The GNU assembler assumes 32 bit and the 32 bit registers by default, though. To avoid problems, we will tell the assembler to consider 16 bit the standard by starting our source code with the directive
Our next directive
.global has something to do with another step towards our boot sector: Linking.
Without going into too much detail yet, the
.global directive, followed by the name of a label in our code, will make sure that the given label is available in other files and to the linker.
Say our code is built from two source code files
b.s which are combined in the final binary file, then
.global abc in
a.s means we can also refer to the label
b.s. Without this directive, the label would only be available in
Last but not least we will use
.fill to make sure our output binary will be exactly 512 bytes long in total.
That is necessary, because the BIOS will read 512 bytes and check if the last two bytes have a predefined value.
If our code is shorter than 510 bytes (and it will be), the special value would appear on the wrong position and the BIOS won't run our code.
To work around that problem, we will fill the remaining bytes with zeroes. The assembler can be asked to do that by using the
.fill directive like this:
.fill 510-(.-_start), 1, 0
We need 510 bytes in total (because the two special bytes will bring the total size to 512). We get the size of our code by taking the current position in memory (we get this using
. in the GNU assembler) and subtract the address of the label at the beginning of our code (here
_start). Then we subtract that from 510 which tells us how many times we need to put a zero into our binary. Then we say we want a single byte and finally what value that byte should have (zero).
That's it. That's all the assembly and GNU assembler know-how you need for the bootloader article. :o)