Skip to content

Instantly share code, notes, and snippets.

@AVGP
Last active July 11, 2023 18:14
Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save AVGP/85037b51856dc7ebc0127a63d6a601fa to your computer and use it in GitHub Desktop.
Save AVGP/85037b51856dc7ebc0127a63d6a601fa to your computer and use it in GitHub Desktop.

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 ax, bx, cx, dx, si, di, sp and bp. They can be split into two groups: general purpose and special purpose registers. ax, bx, cx, 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 (ax...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 h.

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. cx = ch and cl.

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:

instruction description
mov Moves data between two locations
cmp Compares two things (e.g. register values)*
jmp Jumps to another position in the code
je Jumps if the things in the previous cmp instruction were equal*
inc Increases the value in the given register or at the given memory address
int Calls an Interrupt (here: a built-in, BIOS function)
lods String helper instruction to load a character ("load byte from si register")

*) See next section

The x86 architecture has a whole lot more instructions than these and you can look them up in the x86 manual or on Wikipedia if you're curious.

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), s or w (short or word, 16 bit), l (long, 32 bit) or q (quad, 64 bit).

Comparisons, conditional jumps and flags

When using cmp, je and a bunch of other operations, an additional special purpose register called "flags" is used. When 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

Memory address*** Instruction Note
0x0 mov $3, %cx Loads the value 3 into the cx register
0x1 loop: dec %cx Decreases the value that is stored in cx by one
0x2 cmp $0, %cx Compares the value in cx with zero
0x3 jne loop Jumps if the previously compared values are not equal

When the assembler creates the binary, it will replace jne loop with jne 0x1.

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
.byte 1 Reserves a single byte
.word 2 Reserves 16 bit (aka "word")
.long 4 Reserves 32 bit (aka a "long word")
.quad 8 Reserves 64 bit (aka a "quad word")
.short 2 Alternative name for .word
.int 4 Alternative name for .long
.ascii * Reserves as many bytes as needed for a string
.asciz * 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.

Preprocessor directives

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: .code16, .global, and .fill.

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 .code16.

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 a.s and b.s which are combined in the final binary file, then .global abc in a.s means we can also refer to the label abc in b.s. Without this directive, the label would only be available in a.s.

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)

@bnjmnjrk
Copy link

Below the Memory and labels section, the first instruction in the table should be mov $3, %cx right?

@AVGP
Copy link
Author

AVGP commented Sep 18, 2022

Below the Memory and labels section, the first instruction in the table should be mov $3, %cx right?

Yes, good catch!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment