Demilade Sonuga's blog

All posts

The Global Descriptor Table I

2023-01-23

Memory management is an integral part of OS development that we've managed to escape. This is mainly because of the UEFI firmware which handled a lot of memory stuff for us. This is still one aspect of development we're just going to skim because we don't need to handle it in this project but now that we've encountered the GDT, we need to step back and take a quick look at this subject.

In the olden days, Intel introduced an implementation of memory segmentation on x86 to solve some of its memory-addressing problems. Memory segmentation is a way of managing memory that involves splitting the memory into chunks called segments. Each of these segments is described by segment descriptors in either the Global Descriptor Table or a Local Descriptor Table.

There you have it, that's what the GDT was used for, to provide descriptions of memory segments including information like where in memory the segment starts and where it ends and what is allowed to be done to it. Can it be written to? Can it be read? To realize why this is important, imagine some running program with a bug tries to overwrite memory where its code is stored. That would definitely lead to chaos unless there is some mechanism telling the CPU that that running program is not allowed to write into the code memory. This is one thing segmentation was used for.

As for the LDT, this is just another structure like the GDT but we don't need it.

So, the GDT is just an array of structures called segment descriptors. An index in the GDT is called a segment selector.

There are two types of segment descriptors: system segment descriptors and non-system segment descriptors.

Non-system segment descriptors are used to describe segments of memory used for program code and data. System segment descriptors, on the other hand, are grouped into a few types. The only one we need to look at is the Task State Segment descriptor. This descriptor in the table describes a structure called, unsurprisingly, the Task State Segment. What exactly the Task State Segment is is something we'll get to later.

Understanding memory segmentation is something we don't need to do because x86-64 does not use it but we still need to set up a GDT because it is required for us to get on with event handling.

A non-system segment descriptor is a 64-bit value:

BitsMeaning
0..=15Lower 16 bits of the limit (the segment size - 1)
16..=39Lower 24 bits of the base (the segment's beginning address)
40..=47The access byte
48..=51The upper 4 bits of the limit (the segment size - 1)
52Reserved (unused (no meaning))
53Tells if the segment is a 64-bit code segment
540 for 16- or 64-bit segments, 1 for a 32-bit segment
55Granularity (the size units of the segment size)
56..=63The upper 8 bits of the segment's base (beginning address)

The base is the starting address of the segment while the limit is the segment size - 1. For example, if the limit is 500, then the actual size of the segment is 500 + 1 == 501. The limit size is in units determined by the granularity which can either be 1-byte units (when the bit 55 is 0) or 4-kilobyte units (when bit 55 is 1).

The access byte is a byte (8-bit value) whose bits give some information about what is allowed to be done with the segment:

BitsMeaning
01 if the CPU has accessed the segment, 0 otherwise
1Read-Write
2Direction-Conforming
31 if the segment is executable (code segment), 0 otherwise
40 if it's a system segment, 1 otherwise
5..=6The CPU privilege level of the segment
71 if the segment is valid

Bits 5..=6, the privilege level of the segment, can only take on 2 values: 0b00 (0) for ring 0, the highest privilege level used by OS kernels and 0b11 (the binary equivalent of 3) for ring 3, the lowest privilege level used by user applications. In this project, there are no user applications. There's just our game, which is its own OS, so we can be sure that this field will always be 0b00.

For code segments, the read-write bit determines whether or not reading is allowed. So, if a code segment's read-write bit is 1, then reading is allowed, otherwise, it's not. For data segments, the read-write bit determines whether or not writing is allowed. So, if a data segment's read-write bit is 1, then writing is allowed, otherwise, it's not.

Like the read-write bit, the meaning of the direction-conforming depends on whether or not the segment is a code or data segment. If it's a code segment, the direction-conforming bit tells whether or not the segment's CPU privilege level affects where the code can be executed. A 0 indicates that the code can be executed only from the privilege level specified in bits 5..=6. A 1 indicates that code in this segment can be executed from a lower privilege level. This doesn't matter to us because we're only going to be executing code in the highest privilege level (level 0). If it's a data segment, the bit tells which direction the segment grows. If 0, it grows upwards and if 1 it grows down.

According to the OS dev wiki

In 64-bit mode, the Base and Limit values are ignored, each descriptor covers the entire linear address space regardless of what they are set to.

Meaning that the segments describing either our code or data segments span all memory, no matter what.

A concrete example of a non-system segment descriptor: 0x00_e_f_9a_000000_ffff. This is in hexadecimal, so each digit represents 4 binary digits. We don't have to use hexadecimal but they're a much more compact way of representing bits than using solely binary digits.

This is a brief translation of hex digits to binary digits:

NumberHexadecimal equivalentBinary equivalent
00x00b0000
10x10b0001
20x20b0010
30x30b0011
40x40b0100
50x50b0101
60x60b0110
70x70b0111
80x80b1000
90x90b1001
100xa0b1010
110xb0b1011
120xc0b1100
130xd0b1101
140xe0b1110
150xf0b1111

These are for single hex digits. When multiple hex digits are lined up with each other, the translation is still merely a matter of substituting each hex digit for its binary equivalent. For example, the hex number 0x4ea is 0b0100_1110_1010, since 0x4 == 0b0100, 0xe == 0b1110 and 0xa == 0b1010.

From this table, we see that all hex digits are 4-bit binary numbers. Going back to our non-system segment descriptor, 0x00_a_f_9a_000000_ffff, we can determine which bit numbers correspond to which hex digits:

BitsDescriptor Digit
0..=30xf
4..=70xf
8..=110xf
12..=150xf
16..=190x0
20..=230x0
24..=270x0
28..=310x0
32..=350x0
36..=390x0
40..=430xa
44..=470x9
48..=510xf
52..=550xa
56..=590x0
60..=630x0

Before reading on, determine the meaning of the segment descriptor 0x00_e_f_9a_000000_ffff's bits yourself from the tables.

The lower 0xffff is the first 16 bits of the limit. The upper 4 bits of the limit are bits 48..=51. Bits 48..=51 is the hex digit 0xf. Putting it together, the limit for this segment is 0xfffff. In decimals, this is 1_048_575.

Bits 16..=39, the lower 24 bits of the base, is 0x000000. The upper 8 bits of the base, bits 56..=63 is 0x00. Putting these together, we have the 32-bit value 0x00000000 which is 0. This means that the segment starts from the very first address in memory.

Bits 40..=47 is the access byte. From the table, this is the number 0x9a == 0b1001_1010. Bit 0 is 0, indicating the CPU has not accessed the segment. The meaning of bit 1 depends on whether or not the segment is a code or data segment. Bit 3 is 1, so the segment is executable (meaning it is a code segment). Bit 1 is 1 and the segment is a code segment, so reading is allowed. Bit 2 is 0, meaning the code can only be executed from the current CPU privilege level. Bit 4 is 1, meaning it's a non-system segment. Bits 5..=6 is 0b00, so the privilege level is the highest (level 0). Bit 7 is 1, meaning the segment is valid.

Bits 52..=55 is 0xa == 0b1010. So, bit 52 == bit 54 == 0 and bit 53 == bit 55 == 1. Bit 53 is 1, meaning the segment is a 64-bit code segment. Bit 54 is 0, indicating a 64-bit segment. Bit 55 is 1, indicating that the segment size is in units of 4Kib chunks. This means that the (limit + 1) * 4Kib == (1_048_575 + 1) * 4Kib is the size of the segment.

From the interpretation above, the descriptor describes a 64-bit code segment that starts from address 0 and has a size of 1_048_576 * 4Kib. This is 4Gib (gigabytes) of memory. How exactly this was calculated is not something that matters at the moment and we'll learn about it later if we need to.

A system segment descriptor is a 128-bit value:

BitsMeaning
0..=15The lower 16 bits of the limit
16..=39The lower 24 bits of the base
40..=47The access byte
48..=51The upper 4 bits of the limit
52Reserved (unused (no meaning))
53Tells if the segment is a 64-bit code segment
540 for 16- or 64-bit segments, 1 for a 32-bit segment
55Granularity (the size units of the segment size)
56..=95The upper 40 bits of the base
96..=127Reserved

The first four bits of the access byte have a different meaning from the non-system descriptors' access byte. The bits, instead, tell what type of system descriptor it is:

BitsMeaning
0..=3Type of system descriptor

In our case, these bits will always be the binary equivalent of the number 9, because that is the type for a Task State Segment and those are the only types of system segments we'll be dealing with.

The segment selector, which is used as an index in the GDT, is not just an index. It is a 16-bit value that contains a little bit of extra information:

BitsMeaning
0..=1The privilege level of the selector
2Which descriptor table should be used?
3..=15The index into the GDT

Bit 2 tells whether the GDT or an LDT should be used. Since we're not going to be using any LDTs, we can be sure this should always be 0 (which signifies "use GDT").

The index specified in bits 3..=15 is actually used to compute a byte offset into the table. If there are 2 non-system segments in a GDT and we want a segment selector that selects the second segment, then following normal array logic, the index for that second segment should be 0b1, so we put a 1 in bits 3..=15. This value will be multiplied by 8 (by the processor), giving a byte offset of 0b1000 == 8. This works because the first segment descriptor's size is 64 bits == 8 bytes. So, the second segment descriptor is 8 bytes from the start of the GDT.

The necessity of this byte offset thing becomes clear when, let's say, a system segment descriptor is the first descriptor in the table and a non-system segment descriptor is second. System segment descriptors are 16 bytes (128 bits) in size, so the byte offset ought to be 16, not 8. In this case, the value 16 / 8 == 2 == 0b10 is what ought to be in bits 3..=15. Following normal array logic, this value should be 1, since 1 should be the index for the second descriptor. But that's wrong, it's 2.

So, the GDT is just something of an array that holds 64- and 128-bit values called segment descriptors. An index in the GDT is a segment selector.

To check your understanding, come up with a 128-bit value that describes a 64-bit Task State Segment starting at the address 0x123fc and is 13Kib in size.

Take Away

  • The GDT is an array of 64- and 128-bit values called segment descriptors.
  • The GDT can be indexed with a 16-bit value called a segment selector.

In The Next Post

We'll get to modeling the GDT.

References

  • https://en.wikipedia.org/wiki/X86_memory_segmentation
  • https://wiki.osdev.org/GDT
  • https://wiki.osdev.org/Segment_Selector
  • System segment descriptors: Intel SDM, volume 3, section 3.5

You can download the Intel Architecture Software Developer Manual (SDM) here