Demilade Sonuga's blog

All posts

The Global Descriptor Table I

2023-01-23 · 26 min read

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:

Bits Meaning
0..=15 Lower 16 bits of the limit (the segment size - 1)
16..=39 Lower 24 bits of the base (the segment's beginning address)
40..=47 The access byte
48..=51 The upper 4 bits of the limit (the segment size - 1)
52 Reserved (unused (no meaning))
53 Tells if the segment is a 64-bit code segment
54 0 for 16- or 64-bit segments, 1 for a 32-bit segment
55 Granularity (the size units of the segment size)
56..=63 The 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:

Bits Meaning
0 1 if the CPU has accessed the segment, 0 otherwise
1 Read-Write
2 Direction-Conforming
3 1 if the segment is executable (code segment), 0 otherwise
4 0 if it's a system segment, 1 otherwise
5..=6 The CPU privilege level of the segment
7 1 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:

Number Hexadecimal equivalent Binary equivalent
0 0x0 0b0000
1 0x1 0b0001
2 0x2 0b0010
3 0x3 0b0011
4 0x4 0b0100
5 0x5 0b0101
6 0x6 0b0110
7 0x7 0b0111
8 0x8 0b1000
9 0x9 0b1001
10 0xa 0b1010
11 0xb 0b1011
12 0xc 0b1100
13 0xd 0b1101
14 0xe 0b1110
15 0xf 0b1111

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:

Bits Descriptor Digit
0..=3 0xf
4..=7 0xf
8..=11 0xf
12..=15 0xf
16..=19 0x0
20..=23 0x0
24..=27 0x0
28..=31 0x0
32..=35 0x0
36..=39 0x0
40..=43 0xa
44..=47 0x9
48..=51 0xf
52..=55 0xa
56..=59 0x0
60..=63 0x0

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:

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

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:

Bits Meaning
0..=3 Type 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:

Bits Meaning
0..=1 The privilege level of the selector
2 Which descriptor table should be used?
3..=15 The 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

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