blog - git - desktop - images - contact
2025-12-21
Disclaimer: This is to the best of my knowledge at the time of writing and I’m not an authority on this. If you spot mistakes, please tell me.
Disclaimer: Do not try this on real hardware, unless you know what you’re doing.
- Introduction
- The test machines
- BIOS boot and 16-bit Real Mode
- Switching from 16-bit Real Mode to 32-bit Protected Mode
- [Switching from 32-bit Protected Mo…
blog - git - desktop - images - contact
2025-12-21
Disclaimer: This is to the best of my knowledge at the time of writing and I’m not an authority on this. If you spot mistakes, please tell me.
Disclaimer: Do not try this on real hardware, unless you know what you’re doing.
- Introduction
- The test machines
- BIOS boot and 16-bit Real Mode
- Switching from 16-bit Real Mode to 32-bit Protected Mode
- Switching from 32-bit Protected Mode to 64-bit Long Mode
- Finally in 64-bit Long Mode
- Caveats
Introduction
Current x86 CPUs can still boot in 16-bit Real Mode, as far as I know, at least when doing BIOS boot. From there, you can switch to Protected Mode, which gives you access to the 32-bit operation mode. And from Protected Mode (or directly from Real Mode, allegedly), you can switch to Long Mode: Welcome to 64 bits.
I’ve dabbled with Real Mode and now I wanted to try to access the other two modes as well.
Here’s the code example for this blog post:
(Reading this code is definitely easier if you have Vim and can make use of the folds.)
It’s written for NASM. There is extensive commentary in the code. This blog post is meant as an additional explanation and won’t cover all the details. You’ll have to read the code for that.
Actually, what you really should read is this:
The test machines
- Intel Core Duo T2300 (Dell Inspiron 6400 laptop, 32 bits only)
- AMD Athlon 64 X2 4200+ (old gaming PC)
- Intel Atom N455 (Samsung NC10 Plus netbook)
- QEMU 10.1.2 on Linux
BIOS boot and 16-bit Real Mode
The BIOS does us a favor after powering on the computer: It loads the bootsector (from whatever source you configured in the BIOS – floppy, hard disk, USB stick, ...) at 0x0000:0x7C00 and jumps to it. Now it’s up to you what you want to do.
446 bytes of that sector are available for your code.
My goal was: Without loading anything else from disk (because that complicates things really fast), I wanted to switch from Real Mode to Protected Mode, print a little message, then go to Long Mode, print another message, done. That would have to fit on those 446 bytes.
Okay, after power-on, you’re in 16-bit Real Mode.
You can address 1 MiB of memory. There is no memory protection of any kind: Every "process" can read and write all memory. There are no privilege levels.
Memory is organized via segmentation. This is something that the CPU does for you: You put a segment selector in some special register and then, when you tell the CPU to load some data from memory, it’s relative to that selector.
This memory organization is pretty simple. There are no "tables" of any kind.
Switching from 16-bit Real Mode to 32-bit Protected Mode
First of all, you should activate the A20 line. I’ve opted for the simplest way to do this: Ask the BIOS to do it for me. This is not supported on all machines, though. Activating the A20 line can get pretty involved – I’ve only done the bare minimum to get it working on (most of) my test machines.
And then, in Protected Mode, there are tables. That’s the point of it: You want to declare some memory accessible by the kernel and/or processes, and processes shall be isolated from each other. So you need a way to tell the CPU/MMU about that.
The second thing that we have to do before we can switch to Protected Mode, is to set up these tables. Luckily, all this fits inside the bootsector itself: We can hardcode a simple mapping, namely "give me access to everything", put that in the bootsector and the BIOS will load it for us along with the rest of the bootcode. All we then have to do is give the CPU a pointer: "Here’s your tables."
This is the Global Descriptor Table.
Loading this table should be as easy as:
cli
lgdt [gdt_descriptor]
Next up: Enabling bit 0 in the CR0 control register:
mov eax, cr0
or al, 1
mov cr0, eax
This activates Protected Mode, but your current code segment still indicates 16-bit Real Mode. So you would now have to change the contents of the code segment register, CS. This is done by issuing a jump:
jmp dword 0x08:protected_mode
0x08 selects one of the GDT entries and protected_mode is a label further down in the code.
You’ll notice that the source code contains a
bits 32
line at that point. This tells the assembler to use 32-bit opcodes from now on.
(This was a nice surprise. I didn’t know this was supported, as I’ve never done these kinds of mixed things before. I expected to have several files and compilation steps, some 16-bit, some 32-bit, some 64-bit. But that’s not the case: You can put it all in one single file and tell NASM to switch the instruction set.)
(Oh, and unlike earlier examples, I’m generating the entire bootsector with NASM this time. No more dd or fdisk.)
So, after this jump, we’re in 32-bit land. We can use 32-bit instructions and 32-bit addresses. No more Real Mode segmentation!
As a little example, I didn’t write to VGA memory directly, but first to some "high" address (aroud 50 MiB) and then copied that back to a 32-bit register and then to VGA memory. I wanted to see if these 32-bit things actually work at this point.
My old Dell Inspiron 6400 laptop only has a 32-bit CPU, an Intel Core Duo T2300. It can only execute the code until after the switch to Protected Mode has been done. It doesn’t look fancy, but it works:
The "16" is printed while in Real Mode, then the "32" is printed from Protected Mode.
I was already pretty happy when I saw that. :-)
Switching from 32-bit Protected Mode to 64-bit Long Mode
Now it’s getting more complicated.
In Long Mode, Paging is mandatory. And with that come virtual memory addresses.
These page tables are several KiB in size and certainly don’t fit into the bootsector anymore. We have to generate them at runtime.
When you have a 64-bit ("virtual") address, it’ll get mapped to a physical address by looking up various parts in various page tables:
63 55 47 38 29 20 11 0
| | | | | | | |
00000000 00000000 00000000 00000000 | 00000000 00000000 01111100 00000000
└────┬───┘└─────┬────┘└────┬───┘└────┬───┘└─────┬─────┘
│ │ │ │ │
│ │ │ │ └ Offset
│ │ │ │
│ │ │ └ Index in "Page Table"
│ │ │
│ │ └ Index in "Page Directory"
│ │
│ └ Index in "Page-Directory-Pointer Table"
│
└ Index in "PML4 Table"
You start by populating one single table at the top-most level. This is called the "PML4 Table". It is just a series of pointers (and flags) to the next level of page tables. Think of it as an array:
pml4 = [
pointer_and_flags0,
pointer_and_flags1,
pointer_and_flags2,
...
pointer_and_flags511,
]
Each of those point to another page table, if it is present. If it’s not present, the entry will just be 0. This whole thing will be very sparsely populated, which is one of the features of this multi-level approach: There is no need to store a full mapping of everything, because in reality you’re only going to use a fraction of it.
So bits 39 to 47 of your address (these are 9 bits, i.e. values 0 to 511) are interpreted as an index into the PML4 table. At that index, we find a pointer to another page table.
Bits 30 to 38 are the index into that second page table: This is one of the "Page-Directory-Pointer Tables". (There can and will be many.) Again, we’ll find pointers to other sets of page tables here.
Bits 21 to 29 are the index into the third level: One of the "Page Directories". And once again, these are pointers to page tables.
Bits 12 to 20 are the index into the final level, which is just called "Page Table". This table also contains pointers (and slightly different flags), but this time, they point to page frames, i.e. physical addresses. The length of each page frame will be 4 KiB in my example.
The final 12 bits of your address are the offset inside that 4 KiB page frame.
Wikipedia has a helpful diagram of this process, which I’m copying here in full, because the license allows this:
My code generates exactly one of each of these tables. That’s enough to map the lowest 2 MiB to itself. You will be able to use the address 0xB8000 (or 0x00000000000B8000, if you like spelling out all the zeroes), for example, but not 0x00000001000B8000: It’s not mapped and will generate a general protection fault.
After finally being done with setting up the page tables, we can ...
... enable the mandatory PAE mode, enable Long Mode (with an asterisk), set control register CR3 to the address of our PML4 table, and enable paging itself.
But similar to the switch to Protected Mode, we’re not done yet. For the moment, we’re only in "Compatibility Mode". This is indicated by the code by printing a dark green "64".
We need to do another jump to change the code segment register CS, because the current code segment determines if you’re in "true" 64-bit mode or not.
And in order to do that, we need ...
... another Global Descriptor Table. This is basically the same process as before. It is important to do three things:
- The "struct" that we’re loading with
lgdtwill be larger, because the pointer to this GDT will be 64 bits wide. - All the segments defined in this GDT will need to have the "Long Mode flag" set.
- The "DB" flag or "size" flag will have to be cleared now. My Athlon actually refused to enter Long Mode if this wasn’t the case.
And after loading this new GDT, we can finally do the jump like before:
jmp 0x08:long_mode
Finally in 64-bit Long Mode
Now we’ve finally arrived at our destination. We can now write and run 64-bit code:
bits 64
long_mode:
; Bright "64LM" as welcome message.
mov rax, '6' | (0x0A << 8) | ('4' << 16) | (0x0A << 24) | \
('L' << 32) | (0x0A << 40) | ('M' << 48) | (0x0A << 56)
mov [abs 0xB800C], rax
.halt:
hlt
jmp .halt
So, recap the pictures from the beginning of the blog post, which I haven’t explained yet at all:
I’ve run the code on my old Windows XP gaming PC. It’s an AMD Athlon 64 X2 4200+, as you can see on the photo. I’ve dumped my compiled code onto a USB stick and booted from it. And there you have it, all the numbers:
- Brown "16" from when we were in Real Mode.
- Cyan "32" from Protected Mode.
- Dark green "64" from Long Mode in Compatibility Mode.
- Bright green "64LM" in "true" Long Mode.
Whoohoo!
Caveats
The code also works in QEMU:
It does not work on my netbook with its Intel Atom N455, though. The interesting thing is that it already fails to enter Protected Mode. It seems it just doesn’t get past the far jump, instead the system reboots. I suspect that this is either because of the A20 line not getting enabled or it’s because I’ve not set up an Interrupt Descriptor Table. However, the Intel docs say that the IDT can also be set up after the switch and before enabling interrupts again (we don’t enable interrupts precisely because of this):
Maybe I’ve made a mistake in setting up the GDT?
I also tried this, but to no avail:
- Setting up a dummy Task State Segment, because the SYSLINUX source code suggests that this helps with Intel VT-x. (The CPU doesn’t support this anyway, but maybe it does something similar?)
- Clearing the MCE flag in CR4.
A next step could be to try to switch to Protected Mode from Learning-OS/8086 instead of from the bootsector. This means that a) I can write the code in C, b) I have way more room and could try to do a proper A20 initialization. Should help to narrow things down.