Sitemap
Dev Sharma

Dev Sharma

Building a 64-Bit OS — Chapter 2: Setting Up the GDT

9 min readJul 27, 2026

--

When you’re working on a project like building a custom 64-bit OS, you’re bound to keep stumbling upon concepts that you’ve probably never heard of ever in your CS journey before. On top of that, you’ll also be implementing something that you don’t understand at all and you’ll figure it out either while you build it or after you’ve built it. And, after that once you’ve finally understood it, you’ll find out that this “thing” that you just implemented is a baggage of architectural debt and backwards compatibility that is mostly obsolete now. The Global Descriptor Table (GDT) showed all three characteristics when I implemented it (although it must be noted that it is mostly obsolete not completely, you’ll find about this as well in this blog)

What is a Global Descriptor Table?

Originally, A Global Descriptor Table was used to enforce and control hardware level access rights on the memory by dividing memory into different segments and setting different access rights/flags/privileges for different segments.

Let me break it down using the example of Debit/Credit cards. If a person holds a card, that card has pre-defined rules for its use. Like- How much you can spend using it monthly, which airport lounges you can access with this, maybe some discount offers that come with it, tax benefits, cashbacks etc. Now, suppose you go to an airport lounge where your card is not accepted. It is probable that some other card of the same bank as yours might give access to that lounge but yours doesn’t. So what did the bank do here? It enforced rules on how different cards they provide can be used. Now, Just put the GDT in place of banks and memory segments in place of cards. This becomes — GDT enforces rules on how different segments of memory can be used.

Structure of GDT

A GDT is of exactly 64 bytes in size containing 8 GDT entries of 8 bytes each. Here’s the structure as described in my OS:

typedef struct {
unsigned short int limit_0;
unsigned short int base_0; //bits 0-15 of base address
unsigned char base_1; //bits 16-23 of base address
unsigned char access_byte;
unsigned char limit_1_flags; // upper 4 bits are flags, lower 4 are upper 4 bits of limit value
unsigned char base_2; //bits 24-31 of base address
}__attribute__((packed)) gdt_entry_t;

typedef struct {
gdt_entry_t null;
gdt_entry_t kernel_code;
gdt_entry_t kernel_data;
gdt_entry_t user_null;
gdt_entry_t user_code;
gdt_entry_t user_data;
gdt_entry_t tss_1;
gdt_entry_t tss_2;
} gdt_t;

The address at which a segment starts was represented by the value base_2 | base_1 | base_0 and believe me I was not the one to have a single value distribute like this throughout the struct. GDT is a relic from the past and be prepared to encounter more of these if you intend to build an OS from scratch.

Similarly, the limit at which this segment ends is represented by limit_1_flags(only top 4 bits) | limit_0 (again, I’m not responsible for this either).

‘access_byte’ represents the actual privileges given to a particular memory segment. It’s a 1 byte field where each of its bit has some meaning associated with.

7   6   5   4   3   2   1   0 
+---+---+---+---+---+---+---+---+
| P | DPL | S | E |DC |RW | A |
+---+---+---+---+---+---+---+---+
  • P (Present): Must be 1 for the segment to be valid.
  • DPL (Descriptor Privilege Level): 2 bits. Sets the Ring level (0 to 3) required to access this segment.
  • S (Segment Descriptor Type): 0 for system segments (like TSS), 1 for code or data segments.
  • E (Executable): 1 for data segments, 1 for code segments.
  • DC (Direction/Conforming):
    -
    For Data: 0 means segment grows up, 1 means it grows down (stack).
    - For Code: 1 means code can be executed from lower privilege levels.
  • RW (Read/Write):
    -
    For Data: 1 allows write access (read is always allowed).
    - For Code: 1 allows read access (write is never allowed).
  • A (Accessed): Set to 1 by the CPU hardware when the segment is accessed.

The upper 4-bits of the limit_1_flags field are flag bits that are a little different in concept to access_byte. ‘access_byte’ is software-centric where the bits are defined for the use of software. Flag bits are defined to tell the hardware — how to interpret the segments limits and sizes.

7   6   5   4
+---+---+---+---+
| G |D/B| L |AVL|
+---+---+---+---+
  • G (Granularity):
    -
    0 : Segment limit is in byte steps (maximum size of 1 MiB).
    - 1 : Segment limit is in 4 KiB page steps (maximum size of 4 GiB)
  • D/B (Default Operation Size / Big):
    -
    For Code: 0 means 16-bit execution, 1 means 32-bit execution.
    - For Data/Stack: 0 means 16-bit stack pointer (SP), 1 means 32-bit stack pointer.
  • L (Long Mode): 1 sets the segment to 64-bit code mode. If this is set, the D/B bit must be 0.
  • AVL (Available): Free bit for software/OS use; completely ignored by the CPU hardware.

You can read about what values bits have to be setup in different GDT entries from here and then you can try to logically deduce the reason behind it. If you want to build an OS from scratch or any other project that sits directly over the hardware, you have to know the reasoning behind why a certain bit is the way it is. Otherwise, You will be able to start the project but I fear you’ll quit before finishing it.

Get Dev Sharma’s stories in your inbox

Join Medium for free to get updates from this writer.

Ignore the tss_1 and tss_2 fields for now. I’ll come to them later.

Why was it needed?

As I said, GDT enforced rules on how different segments of memory could be used. It was needed to add security. To put that into — What do you think would happen if each person on the face of earth had the keys to Prison cells. Ask yourself this and then ask yourself that what would happen that every program that is running on your computer could access any part of memory. Let me answer that for you. That would mean any “malicious” program could delete any part of your OS (which is essentially a program running and sitting inside your memory) probably corrupting it, it could inject malicious code into it that could end up stealing all of your personal data or in the worst case it could completely takeover your computer. GDT stopped this by enforcing rules saying that — “Memory region from 0x0000 to 0x00FF is for user level program and that means both the user program and kernel program can use it. Memory region from 0xFF00 to 0xFFFF is for kernel level program and no user program can use it, trying to use it will result in a segmentation fault.” These addresses that I’ve used are completely random that I used just for the sake of explanation but I hope it delivers the point.

You might have noticed, I used “was” in the heading for this section. That is because, while GDT is still used for setting up privilege levels, it now simply does that to represent the state of whole machine and not just a memory segment.

The current role of GDT

The story goes like this — When 64-bit CPUs were invented, engineers realized that Paging (dividing memory into fixed size pages) was vastly superior then Segmentation (dividing memory into different chunks of same or different size) for memory management. Why? There are multiple reasons. First was obviously, that Paging prevented external fragmentation since memory was always divided into pages of fixed size. Also, with paging you could now apply privileges to individual pages instead of a whole chunk. So, if a Kernel level program needed memory, it could grab a memory page, set up its access level to Kernel only (Ring 0) and when it freed it, a user level program could use the same page but with access level set to User Level (Ring 3). So basically, segmentation was replaced with paging.

Since now, Paging was used for enforcing access rules to memory, defining memory segments in the GDT became meaningless. But GDT still remained meaningful — It evolved. Now, computers use a “flat” memory model where every segment technically spans to the entire address space. Meaning that the base value for each segment is 0x0 and the limit value for each segment is maximum possible address space value. The CPU hardware literally ignores these fields because there are no segments now in the memory — It is flat. While the base and limit field of GDT entries are obsolete now, the access byte and flags still hold meaning. They, now tell in which mode the whole machine is. The GDT entries kernel_code and kernel_data represent Kernel level privilege (Ring 0). When they’re loaded into the registers, that means the system is currently in Kernel Mode or Ring 0. Whereas, user_code and user_data represent User level privilege (Ring 3). When they’re loaded into the registers, that means the system is currently in User Mode or Ring 3.

Loading the GDT

Once you’ve filled up your GDT with correct values for different fields. The only part left is loading it. Here, loading in a way means telling the CPU about your GDT. The x86_64 assembly has an instruction — “LGDT” to load the Global descriptor table and make CPU aware about it.

Now, when the CPU knows about your GDT, you also need to load a segment into various registers to tell it in which mode it currently is. Here’s the my code for it. I’ve developed my OS on Linux so it uses System V ABI and hence this code works on the assumption that when _LoadGDT was called, the address of GDT was placed in the RDI register as dictated by System V ABI. If you’re using Windows, you’ll have change it to RSI.

[bits 64]
global _LoadGDT ; "Make this function visible to C files."
_LoadGDT:
; --- STEP 1: Tell CPU where the table is ---
; In C, you called _LoadGDT(&gdt_descriptor).
; The 'GDTDesc' pointer is passed in the register RDI.
lgdt [rdi] ; "Load Global Descriptor Table from the address in RDI."
; Now the CPU knows WHERE the table is.
; --- STEP 2: Update Data Bookmarks ---
; We want DS, ES, FS, GS, SS to point to offset 0x10 (Kernel Data).
; We can't move numbers directly into segment registers,
; so we put 0x10 into a normal register (AX) first.
mov ax, 0x10

mov ds, ax ; "Set Data Segment to 0x10"
mov es, ax ; "Set Extra Segment to 0x10"
mov fs, ax ; "Set FS Segment to 0x10"
mov gs, ax ; "Set GS Segment to 0x10"
mov ss, ax ; "Set Stack Segment to 0x10"
; --- STEP 3: Update Code Bookmark (The Tricky Part) ---
; We want CS to point to 0x08 (Kernel Code).
; PROBLEM: You cannot simply type 'mov cs, 0x08'. The CPU forbids it.
; WHY? Changing CS changes how the CPU reads the *next* instruction.
; If you mess it up, the CPU doesn't know what to execute next.

; TRICK: We fake a "Return".
; When you return from a function, the CPU pops an Address and a Segment
; from the stack to know where to go back to.
; We will manually push a fake "return address" and our DESIRED Segment (0x08)
; onto the stack, then tell the CPU to "Return".
pop rdi ; Grab the "real" return address (back to C) and save it in RDI.

mov rax, 0x08 ; Prepare the value 0x08.
push rax ; Push 0x08 onto the stack (This will become the new CS).

push rdi ; Push the return address back onto the stack.

; Stack now looks like: [ 0x08 (New CS) ] [ Return Address ]

retfq ; "Return Far Quad"
; This instruction does two things atomically:
; 1. Pops the top value into Instruction Pointer (RIP).
; 2. Pops the NEXT value into Code Segment (CS).
; RESULT: We are now running at 'Return Address' with CS = 0x08!

The tss_1 and tss_2 fields

I do not want to cover them in detail here because they are more of a multitasking concept and if I start explaining them here I will have to go into details of how task switching works and how privilege levels change during that. For now, You can think of them as something like a memory buffer zone that is used when a user level process makes a syscall and drops into ring 0. This memory buffer zone as described by the tss segment is used by the kernel then as stack memory to process the syscall. I hope it doesn’t intimidate you but this is what it is. If you didn’t understand it, you can leave it completely for now. My original GDT didn’t have these and my OS still worked. Only, when I’d my multitasking implemented and I wanted to add Ring 3 isolation, I then added these fields to the GDT. Thus, they’re not at all essential for getting your basic system running.

That pretty much sums up this blog about GDT. I hope it was worth your time. If you would like to see my source code, here is the GitHub link to it. Also, this is the link to my X profile, You can follow me there. I would also appreciate your feedback on this blog.

--

--