DEV Community

Raymond Mwaura
Raymond Mwaura

Posted on

Transitioning to Protected Mode in BeaconOS

In the previous post, I explored enabling the A20 line to prepare the system for accessing memory beyond the 1 MB boundary. With that foundation in place, I moved on to something I’ve been eagerly anticipating: switching the CPU from real mode to protected mode.

This post documents that process, explains what protected mode is, and walks through the implementation details and challenges I encountered while bringing BeaconOS into a 32-bit environment.


Understanding Protected Mode

Protected mode was introduced with the Intel 80286 processor. It comes with several features missing in real mode:

  • 4 GB of addressable memory.
  • Memory segmentation using the Global Descriptor Table (GDT).
  • Virtual memory support through paging.
  • Four privilege levels (0-3).
  • Hardware-enforced isolation between user and kernel space.

Protected mode allows software to take full advantage of the CPU’s capabilities.

It’s also worth noting that BIOS interrupts are not available in protected mode. I actually like this for the exact reason many people don’t: it forces me to handle everything manually. That makes protected mode feel much more “low-level”; which is exactly what drew me to operating system development in the first place. I have a feeling this will be both fun and painful.

Before switching modes, two conditions must be met:

  1. The A20 line must be enabled (covered in the previous post).
  2. A proper Global Descriptor Table (GDT) must be defined and loaded.

Implementation

Transitioning to protected mode involves several carefully ordered steps. Each must be executed correctly; a single mistake can trigger a triple fault and reset the system. Below is the process I used in BeaconOS, along with explanations for each stage.

Throughout this process, interrupts were disabled (cli) to prevent any control transfer during the transition.


1. Loading the Global Descriptor Table (GDT)

Protected mode uses descriptors to define memory segments, rather than fixed segment values. These descriptors are stored in the GDT.

For my setup, I created a simple GDT with three entries:

  1. Null Descriptor; required placeholder.
  2. Kernel Code Segment Descriptor; defines executable memory space.
  3. Kernel Data Segment Descriptor; defines readable/writable memory space.
; GDT
gdt_start:
    ; Null descriptor.
    dd 0
    dd 0

; Kernel code segment descriptor.
; Limit=0xFFFFF, Base=0.
; Access byte: present bit set, ring 0, code segment, read allowed.
; Flags: granularity is set, 32-bit segment.
kernel_code:
    dw 0xFFFF       ; Lower limit.
    dw 0            ; Base (0-15).
    db 0            ; Base (16-23).
    db 0b10011010   ; Access byte.
    db 0b11001111   ; Flag + higher limit.
    db 0            ; Base (24-31).

; Kernel data segment descriptor.
; Limit=0xFFFFF, Base=0.
; Access byte: present bit set, ring 0, data segment, write allowed.
; Flags: granularity is set, 32-bit segment.
kernel_data:
    dw 0xFFFF       ; Lower limit.
    dw 0            ; Base (0-15).
    db 0            ; Base (16-23).
    db 0b10010010   ; Access byte.
    db 0b11001111   ; Flag + higher limit.
    db 0            ; Base (24-31).

gdt_end:

gdt_descriptor:
    dw (gdt_end - gdt_start - 1)
    dd gdt_start
Enter fullscreen mode Exit fullscreen mode

Once defined, I loaded the GDT using the lgdt instruction. This instruction loads the base address and limit of the GDT (Global Descriptor Table) from memory into the CPU's GDTR (Global Descriptor Table Register). This tells the CPU where the GDT is and how large it is.

lgdt [gdt_descriptor]
Enter fullscreen mode Exit fullscreen mode

2. Enabling Protected Mode

With the GDT loaded, I set the Protection Enable (PE) bit in the control register CR0. This single bit flips the CPU from real mode to protected mode.

mov eax, cr0
or eax, 1
mov cr0, eax
Enter fullscreen mode Exit fullscreen mode

However, simply setting the bit isn’t enough; the instruction prefetch queue must be flushed. To do that, I performed a far jump to a 32-bit code segment:

jmp 0x08:protected_mode_entry
Enter fullscreen mode Exit fullscreen mode

This jump clears the queue and loads the new code segment selector (0x08, the kernel code segment) from the GDT into the cs register. At this point, the CPU is officially running in protected mode.


3. Initializing and Testing Protected Mode

At the protected_mode_entry label, the CPU executes in 32-bit protected mode. The next step is to reload the segment registers (ds, es, ss, etc.) to point to the data segment defined in the GDT.

bits 32
protected_mode_entry:
    mov ax, 0x10
    mov ds, ax
    mov es, ax
    mov gs, ax
    mov ss, ax
Enter fullscreen mode Exit fullscreen mode

I then tested the mode switch by printing a string. Since BIOS interrupts no longer work, I wrote directly to the VGA text buffer (0xB8000).

protected_mode_entry:
    ; After reloading the segment registers.

    ; Clear the screen.
    mov edi, 0xB8000    ; VGA text buffer.
    mov ecx, (80 * 25)  ; Counter.
    mov eax, 0x0720     ; 0x20 = ' ', 0x07 = light gray on black.
    rep stosw

    ; Print a message.
    lea esi, [success_msg]
    mov edi, 0xB8000
    mov ah, 0x07        ; Attribute: light gray on black.

    .print_loop:
        lodsb
        cmp al, 0
        jz done

        mov [edi], al       ; Write character.
        mov [edi + 1], ah   ; Write attribute.
        add edi, 2          ; Advance pointer.

        jmp .print_loop

done:
    hlt
    jmp done

;; VARIABLES.
success_msg db "Switched to protected mode successfully.", 0
Enter fullscreen mode Exit fullscreen mode

That message appearing on-screen confirmed that BeaconOS had successfully entered protected mode; easily one of the most satisfying milestones so far.


Full Code

Here’s the full second-stage bootloader responsible for switching to protected mode.
(Note: this assumes the A20 line is already enabled; QEMU does this by default.)

org 0x7E00
bits 16

cli

; Load the GDT.
lgdt [gdt_descriptor]

; Set PE bit.
mov eax, cr0
or eax, 1
mov cr0, eax

jmp 0x08:protected_mode_entry

; GDT
gdt_start:
    ; Null descriptor.
    dd 0
    dd 0

; Kernel code segment descriptor.
; Limit=0xFFFFF, Base=0.
; Access byte: present bit set, ring 0, code segment, read allowed.
; Flags: granularity is set, 32-bit segment.
kernel_code:
    dw 0xFFFF       ; Lower limit.
    dw 0            ; Base (0-15).
    db 0            ; Base (16-23).
    db 0b10011010   ; Access byte.
    db 0b11001111   ; Flag + higher limit.
    db 0            ; Base (24-31).

; Kernel data segment descriptor.
; Limit=0xFFFFF, Base=0.
; Access byte: present bit set, ring 0, data segment, write allowed.
; Flags: granularity is set, 32-bit segment.
kernel_data:
    dw 0xFFFF       ; Lower limit.
    dw 0            ; Base (0-15).
    db 0            ; Base (16-23).
    db 0b10010010   ; Access byte.
    db 0b11001111   ; Flag + higher limit.
    db 0            ; Base (24-31).

gdt_end:

gdt_descriptor:
    dw (gdt_end - gdt_start - 1)
    dd gdt_start

; ========== Protected Mode Code ==========
bits 32
protected_mode_entry:
    ; Setup segment registers.
    mov ax, 0x10
    mov ds, ax
    mov es, ax
    mov gs, ax
    mov ss, ax

    ; Clear the screen.
    mov edi, 0xB8000    ; VGA text buffer.
    mov ecx, (80 * 25)  ; Counter.
    mov eax, 0x0720     ; 0x20 = ' ', 0x07 = light gray on black.
    rep stosw

    ; Print a message.
    lea esi, [success_msg]
    mov edi, 0xB8000
    mov ah, 0x07        ; Attribute: light gray on black.

    .print_loop:
        lodsb
        cmp al, 0
        jz done

        mov [edi], al       ; Write character.
        mov [edi + 1], ah   ; Write attribute.
        add edi, 2          ; Advance pointer.

        jmp .print_loop

done:
    hlt
    jmp done

;; VARIABLES.
success_msg db "Switched to protected mode successfully.", 0

times 512 - ($ - $$) db 0
Enter fullscreen mode Exit fullscreen mode

Reflections

Switching to protected mode felt like putting on the Infinity Gauntlet; moving from the cramped limitations of real mode into a true 32-bit environment with far more power and control. This step deepened my understanding of how an operating system seizes control of the hardware at its most fundamental level.

It also reinforced one key lesson: precision is everything in low-level programming. Every byte in the GDT, every instruction in the bootloader, and every bit in a control register matters. One wrong value can mean hours of painful debugging and questioning of life choices.


What’s Next

Now that BeaconOS runs in protected mode, the next step is learning C.

I’ve decided to pause kernel development until I’ve strengthened my C programming skills. Since the BeaconOS kernel will be written primarily in C (with some assembly), mastering the language will make development cleaner, faster, and less error-prone. It’ll also prevent the kind of subtle bugs that come from half-understanding the fundamentals.

I’m genuinely excited about this next phase. I can't wait to learn C and meet the infamous pointers everyone keeps warning me about.


Resources

The complete source code, along with development notes and comments, is available in the this repository.


Top comments (0)