Build your own operating system
Part 07
Hello everyone!
This is the seventh installment in the series “Build Your Own Operating System.” Before reading this, I recommend that you go back and read the preceding articles. Before I wrap up this essay, I’d want to point out some of my prior work. Before reading this, please look through them and refer to them. It will assist you in gaining a better grasp of the content of this article.
Let’s move to today’s topic.
Introduction to Virtual Memory
Simply virtual memory is nothing but, using our secondary storage to extend our RAM. Which is a feature of operating systems that enable computers to be able to use shortages of physical memory by transferring data from random access memory (RAM) to disc storage (HDD).
There are two ways to accomplish this task in x86 architecture. Which are: using segmentation and paging. But, paging is by far the most common and versatile technique. And also we will be using that method to implement virtual memory to our operating system. But remember, some uses of segmentation are still required to allow the code to execute under different privilege levels.
Paging in x86
To understand paging you need to know what is a Page Directory, a Page Table, and a Page Frame.
Page Directory — A table in memory which the MMU uses to find the page tables.
Each index in the Page Directory is a pointer to a Page table. It can contain references to 1024 page tables.
So the Page Directory Entity structure would look something like this,
Page Table — A table in memory that describes how the MMU should translate a certain range of addresses.
Each index in a Page Table contains the physical memory address to which a certain page should be mapped. It can point to 1024 sections of physical memory called page frames (PF).
So the Page Entity structure would look something like this,
Page Frame — A fixed-length contiguous block of physical memory into which memory pages are mapped. Each page frame is 4096 byte large.
The 32-bit linear address derived from the selector and offset is divided into three fields. The first 10 bits are used as the index of the page directory. The page directory entry points to a page table. The next 10 bits of the linear address provide the index of the table. The page table entry provides the base address of a 4 KB page in physical memory called a page frame. The last 12 bits of the original linear address provide the page frame offset. Each task has its own page directory, which is pointed to by the CR3 processor control register.
The changing of linear addresses to physical addresses is shown in the figure below.
Loading Page Directory
The first step is to tell the processor where to find our page directory by putting it’s address into the CR3 register. Because C code cannot directly access the computer’s registers, we will need to use assembly code to access CR3. The following assembly is written for NASM. ( If you use a different assembler then you will need to translate between this assembly format and the format supported by your assembler.)
Enabling Paging
Paging is enabled by first writing the address of a page directory to cr3 and then setting bit 31 (the PG “paging-enable” bit) of cr0 to 1. To use 4 MB pages, set the PSE bit (Page Size Extensions, bit 4) of cr4.
To do this add paging_enable.s file to your working directory with the given code.
Paging and the kernel
In this part, we will discuss how paging affects the OS Kernel. We encourage you to run your OS using identity paging before trying to implement a more advanced paging setup since it can be hard to debug a malfunctioning page table that is set up via assembly code.
Reasons to Not Identity Map the Kernel
There will be a problem when connecting user-mode process code with a kernel located at the beginning of the virtual address space. which is a virtual address space of 0x00000000
, “size of the kernel.” Standard and the best practice is to load code into memory location 0x00000000
while linking. 0x00000000
will be the base address for resolving absolute references. This means the user-mode process can’t be loaded at this virtual address because the kernel is mapped to the virtual address space.
Using a linker script that instructs it to assume a different beginning address is not an easy solution for the users of the operating system to deal with.
We’re also assuming that we want the kernel’s address space to be part of the user-mode process’s address space in this scenario. The fact that we don’t have to alter any paging structures in order to access the kernel’s code and data is a good feature, as we’ll see later. A user process cannot read or write kernel memory unless they have privilege level 0.
The Virtual Address for the Kernel
Ideally, the Kernel ought to be set at an extremely high virtual memory address, for instance, 0xC0000000
(3 GB). The client mode measure isn’t probably going to be 3 GB enormous, which is presently the solitary way that it can struggle with the piece. At the point when the bit utilizes virtual addresses at 3 GB or more, it is known as a higher-half portion. 0xC0000000
is only a model, the bit can be set at any location higher than 0 to get similar advantages. Picking the right location relies upon how much virtual memory ought to be accessible for the portion (it is most straightforward if all memory over the bit virtual location ought to have a place with the piece) and how much virtual memory ought to be accessible for the cycle.
In the event that the client mode measure is bigger than 3 GB, a few pages should be traded out by the bit. Trading pages isn’t essential for this book.
Placing the Kernel at 0xC0000000
The kernel ought to be set at 0xC0100000
, instead of at 0xC0000000
, in light of the fact that this permits (0x00000000
, 0x00100000
) to be meant (0xC0000000
, 0xC0100000
). Memory (0x00000000
, “size of the Kernel”) is planned to the reach (0xC0000000
, + 0xC0000000
+ “size of the Kernel”) thusly.
The position of the kernel is easy, however, it needs a tad bit of reasoning. By and by, this is a linkage issue. For instance, in the event that we use migration in the linker script (see “Connecting the Kernel”), the linker will feel that our kernel is stacked at the actual memory address, 0x00100000
; not 0x00000000
. On the off chance that the leaps are settled utilizing 0xC100000 as the base location, a kernel hop will jump straightforwardly into the client mode measure code (recall that the client mode measure is stacked in virtual memory 0x00000000
).
In any case, we can’t simply advise the linker to assume that 0xC01000000
is the place where the kernel begins (is stacked), in light of the fact that we need it to be stacked at 0x00100000
. To keep the kernel from being stacked at 0x00000000
since there is BIOS and GRUB code stacked beneath 1MB, it is stacked at 1 MB. Since the PC might have under 3 GB of actual memory, we can’t believe that we’ll have the option to stack the kernel at 0xC0100000
by the same token.
As a workaround, the linker content can utilize both the migration guidance (.=0xC0100000
) and the AT guidance. Address computations for non-relative memory-references ought to use the migration address as a beginning stage for working out addresses for movement species. The kernel ought to be stacked into memory at the predetermined species. At connect time, GNU ld plays out the migration, and GRUB handles the heap address indicated by AT when stacking the kernel.
Higher-half Linker Script
Modify your linker script to this:
NTRY(loader) /* the name of the entry symbol */
. = 0xC0100000 /* the code should be relocated to 3GB + 1MB */
/* align at 4 KB and load at 1 MB */
.text ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
{
*(.text) /* all text sections from all files */
}
/* align at 4 KB and load at 1 MB + . */
.rodata ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
{
*(.rodata*) /* all read-only data sections from all files */
}
/* align at 4 KB and load at 1 MB + . */
.data ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
{
*(.data) /* all data sections from all files */
}
/* align at 4 KB and load at 1 MB + . */
.bss ALIGN (0x1000) : AT(ADDR(.text)-0xC0000000)
{
*(COMMON) /* all COMMON sections from all files */
*(.bss) /* all bss sections from all files */
}
kernel_end = .;
Entering the Higher Half
When GRUB jumps to the kernel code, there will be no paging table. Therefore, all references to 0xC0100000 + x won’t be mapped to the correct physical address, and will therefore cause a general protection exception or the computer will just crash.
If we skip the above second option, the CPU would generate a page fault immediately after paging was enabled when trying to fetch the next instruction from memory. After the table has been created, a jump can be done to a label to make eip
point to a virtual address in the higher half:
; assembly code executing at around 0x00100000
; enable paging for both actual location of kernel
; and its higher-half virtual location
lea ebx, [higher_half] ; load the address of the label in ebx
jmp ebx ; jump to the label
higher_half:
; code here executes in the higher half kernel
; eip is larger than 0xC0000000
; can continue kernel initialisation, calling C code, etc.
Running in the Higher Half
There are a few more details we must deal with when using a higher-half kernel. We must be careful when using memory-mapped I/O that uses specific memory locations. For example, the frame buffer is located at 0x000B8000
, but since there is no entry in the page table for the address 0x000B8000
any longer, the address 0xC00B8000
must be used, since the virtual address 0xC0000000
maps to the physical address 0x00000000
.
Any explicit references to addresses within the multiboot structure needs to be changed to reflect the new virtual addresses as well.
Mapping 4 MB pages for the kernel is simple, but wastes memory (unless you have a really big kernel). Creating a higher-half kernel mapped in as 4 KB pages saves memory but is harder to set up. Memory for the page directory and one page table can be reserved in the .data
section, but one needs to configure the mappings from virtual to physical addresses at run-time. The size of the kernel can be determined by exporting labels from the linker script , which we’ll need to do later anyway when writing the page frame allocator.
Virtual Memory Through Paging
Paging enables two things that are good for virtual memory. First, it allows for fine-grained access control to memory. You can mark pages as read-only, read-write, only for PL0 etc. Second, it creates the illusion of contiguous memory. User mode processes, and the kernel, can access memory as if it were contiguous, and the contiguous memory can be extended without the need to move data around in memory. We can also allow the user mode programs access to all memory below 3 GB, but unless they actually use it, we don’t have to assign page frames to the pages. This allows processes to have code located near 0x00000000
and the stack at just below 0xC0000000
, and still not require more than two actual pages.
It’s quite simple to configure paging into your OS. You can get the complete code for paging from my github below.
Hope to see you in the next article as well!
Thank you!
Reference: Helin, E., & Renberg, A. (2015). The little book about OS development