There is one final important step to know about before you start working on your real kernel: protected mode!
The easiest thing to do now is download the kernel.asm now and have it open in a text editor while you read this part. That makes it much easier to follow without having to paste the entire file here.
Global Descriptor Table
Before switching to protected mode we will need to set up various things, the most important of which is the Global Descriptor Table (GDT).
As mentioned earlier, the GDT is a data structure that defines descriptors that represent virtual memory segments. It defines the characteristics of each segment, like its size, base address and access privileges.
It can be loaded using the lgdt instruction:
lgdt [gdt]
The operand should point to a structure of 6 bytes where the first two bytes are the size of the structure itself and the second four bytes contain the 32-bit address of the structure:
gdt:
dw gdt_end - gdt_start
dd gdt_start
This pushes a word containing the number of bytes between gdt_end and gdt_start (effectively the size of the whole thing) and then pushes the double word with the address of gdt_start.
The structure defined at gdt_start contains descriptors of 8 bytes each. The format of a descriptor is as follows:
Limit 0:15 - 16 bits
Base 0:15 - 16 bits
Base 16:23 - 8 bits
Access byte - 8 bits
Limit 16:19 - 4 bits
Flags - 4 bits
Base 24:31 - 8 bits
The size limit is a 20 bit value split up in two parts. The base address is a 32 bit value split up in three parts. The structure of the access byte field is as follows:
bit 7 - present bit
; must be 1
bit 6 and 5 - privilege bits
; ring level (0 through 3)
bit 4 - descriptor type
; 1 for code and data segments only
bit 3 - executable bit
; if 1, code can be executed
bit 2 - direction bit
; 1 if segment grows down (otherwise up)
bit 1 - readable/writable
; for code segment: whether read access is allowed
; (write access is never allowed)
; for data segment: whether write access is allowed
; (read access is always allowed)
bit 0 - accessed bit
; set to 1 by the CPU when the segment is accessed
The flags field bits are laid out as follows:
bit 3 - page granularity
; 0 for byte granularity (1B blocks)
; 1 for page granularity (4KiB blocks)
bit 2 - size bit
; 0 for 16 bit protected mode
; 1 for 32 bit protected mode
bit 1 and 0
; always 0
Let’s see how we can define a full GDT ourselves now:
gdt_start:
gdt_null:
dd 0x0
dd 0x0
gdt_code:
dw 0xffff ; limit (16 bits)
dw 0x0 ; base (16 bits)
db 0x0 ; base (8 bits)
db 10011010b ; access byte (8 bits from 7 to 0)
db 11001111b ; flags (bits 7 to 4) + limit (bits 3 to 0)
db 0x0 ; base (8 bits)
gdt_data:
dw 0xffff
dw 0x0
db 0x0
db 10010010b
db 11001111b
db 0x0
gdt_end:
; Declare selectors for easy reference
CODE_SELECTOR equ gdt_code - gdt_start
DATA_SELECTOR equ gdt_data - gdt_start
As you can see, we define three descriptors: null, code and data. The null descriptor, according to the Intel manual, should be there, but they also claim it is never used by the CPU. So in theory, you can store anything you like there, but it is probably best to just fill it with zeroes.
Both the code and the data segments have a base of 0x0 and a limit of 0xfffff meaning they both span the entirety of the available memory (i.e. “flat memory”). They are also identical with regards to the access and flags values, except of course for the executable bit.
Now that we have our GDT setup, we can almost switch to protected mode, but there is one small thing left to do.
Let’s look at the LGDT instruction again:
lgdt [gdt]
Remember that a segment is assumed if not explicitely specified, so this actually reads:
lgdt [DS:gdt]
At this point in our code, DS could contain anything and the LGDT instruction is likely to fail. Therefore, we need to clear it first:
mov ax, 0
mov ds, ax
This makes the LGDT instruction work, but it is good practice to clear all segment registers to avoid hard to trace crashes:
mov ax, 0
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
The same goes for the instruction pipeline which was omitted here (CS), which currently contains garbage instructions we no longer care about. To clear this, we need to perform a far jump (i.e. to another segment). This will set the CS register properly and also jump to our 32 bit code.
To enable protected mode on the CPU, a control register must be set:
mov eax, cr0
or eax, 0x1
mov cr0, eax
So, the full code to switch to protected mode, including the far jump to our 32 bit entry point as mentioned, looks like this:
kernel_start:
mov ax, 0
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
lgdt [gdt]
mov eax, cr0
or eax, 0x1
mov cr0, eax
jmp CODE_SELECTOR:entry_point
Preparing our 32 bit environment
Now that we are in 32 bit protected mode, we should prepare our segment registers some more, as they should now contain a selector (which is an actual structure) rather than a base memory address like before:
entry_point:
mov ax, DATA_SELECTOR
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
It is also important to set up a stack, which can easily be done by setting a pointer in ESP:
mov esp, 0xffffffff
This points ESP to the top of our memory limit (remember it grows down). In a real situation, you probably want a more sensible number here, but for these illustration purposes it’s fine.
Finally, for now, we force ourselves into an infinite loop (remember $ means current address):
jmp $
This is a very good candidate to replace with more sensible code in your own kernel!
Video memory
While it might be a bit out of scope, it’s always nice to know your code works so let’s touch the subject of printing something in protected mode. We can no longer rely on BIOS services. Instead, we have to access video memory directly (as in, literally write bytes to it). The (color text) video memory starts at 0xb8000 and each character you wish to print consists of two bytes: one for the actual character and a second for its color (foreground in lowest 4 bits, background in highest 4 bits). For this example, we simply set the color to white.
So, let’s translate our “Hello world!” print from before into direct video memory writing:
print_hello:
pusha
mov ebx, hello
mov edx, 0xb8000
.loop:
mov al, [ebx]
mov ah, 0x0f
cmp al, 0
je .done
mov [edx], ax
add ebx, 1
add edx, 2
jmp .loop
.done:
popa
ret
The interesting bits here are the following:
The pusha and popa instructions save the current data registers before doing something to them and restore them to their original values as soon as the procedure is done.
We use EBX to iterate over our string and EDX to iterate over the video memory. AL and AH (the lower and higher 8 bits of AX) are used to store the character to print and its attribute respectively. If the character is 0, it bails out.
Next, the current 16 bit value (character + attribute) that resides in AX is stored in the current video memory pointer and EBX and EDX get incremented (EDX by 2, because each value is 2 bytes) until all characters are printed.
When you run this code in QEMU, you should see “Hello world!” printed at the top left of your screen. A good excercise now might be to clear the screen beforehand?
Wrapping up
We now have a working 16 bit bootloader and a working 32 bit protected mode kernel that we built using NASM while learning about the architecture, the language and the tools. Hopefully, you find it useful for your own project. At the very least, I hope it was fun and educational to you. If not, or if you have any other feedback, please let me know using the feedback link below!