In the previous post, we mapped the kernel image into the higher half of the virtual address space, giving each section its own permissions while keeping the existing identity mapping in place. However, this only provides a mapping for the kernel image itself. As the kernel’s memory management becomes more sophisticated, we need a convenient way to access physical memory beyond the regions explicitly mapped so far.
This is where the linear mapping comes in. By mapping physical memory into a contiguous region of the kernel’s virtual address space at a fixed offset, we can access physical addresses through a predictable virtual address without having to create a separate mapping for each frame. Before implementing it, we need to decide where this region will live and how it fits alongside the mappings we already have.
In this post, we’ll examine the different mappings our kernel needs, define the virtual address layout for the linear mapping, and implement it using a new PAGE_OFFSET constant. We’ll also look at how to handle page alignment when mapping memory ranges and verify that the resulting mappings behave as expected.
Mappings
So far we have implemented two different mappings to address two different problems:
- How can we continue executing after enabling the MMU?: Identity Map.
- Where does the kernel image live in its final virtual address space, and how can we enforce
W^Xpermissions?: Higher-Half Map.
These mappings get us this far, but two problems remain: how do we access RAM frames through predictable virtual addresses, and how do we access devices using the appropriate Device memory attributes? The linear map addresses the first problem; ioremap addresses the second.
There is also another mapping mechanism worth mentioning: the fixmap. Unlike the linear map or ioremap, which provide access to larger memory regions, the fixmap associates physical pages with predefined virtual addresses. This is useful when we need to access a physical page at a known virtual address, particularly early in the boot process or when a temporary mapping is required.
For example, Linux on AArch64 uses the fixmap for mappings such as the Flattened Device Tree (FDT) and the early console. It also reserves temporary mapping slots for early_ioremap(), which can be used before the regular ioremap() mechanism is available. Our kernel does not implement the fixmap yet, but these examples illustrate why having such a mechanism can be useful.
The following table summarizes each mapping, the problem it solves, and where it lives in our virtual address space. We will use it as a reference throughout the rest of the post.
| Mapping | Problem It Solves | Address-Space Location |
| Identity Mapping | Continue execution after enabling the MMU | TTBR0_EL1 (identity map) |
| Higher-Half Mapping | Run the kernel image at its final virtual address with W^X permissions | TTBR1_EL1 atKIMAGE_VADDR |
| Linear Map | Access mapped RAM through predictable virtual addresses | TTBR1_EL1 atPAGE_OFFSET |
ioremap | Access MMIO with Device memory attributes | TTBR1_EL1 (runtime mappings) |
| Fixmap | Map pages at predefined virtual addresses | Fixed slots in TTBR1_EL1 |
Why a Linear Map?
The linear map gives us a predictable way to access physical RAM through virtual addresses once we enable the MMU. To understand why we need it, let’s look at how we access memory as we transition from physical addressing to virtual memory.
Before enabling the MMU, address translation is disabled. When the CPU accesses an address, it accesses the corresponding physical address directly. At this stage, we can use a physical address as a pointer to access the memory it refers to.
Once we enable the MMU, the CPU interprets addresses as virtual addresses and translates them using the translation tables. The identity map allows us to continue using the same numerical addresses because each virtual address in the identity-mapped range resolves to the corresponding physical address. However, this is only a temporary solution. Once we remove the identity mapping, those addresses can no longer be used to access physical memory this way.
The higher-half mapping does not solve this problem by itself. It maps the kernel image at its final virtual address, but it does not provide a general way to access the rest of physical RAM. For example, the physical frame allocator gives us physical addresses for memory frames, including frames that may contain translation-table pages or nodes in the allocator’s free list. To read or modify these structures, we need virtual addresses that map the corresponding physical memory.
This is the problem the linear map solves: it maps physical RAM into a contiguous region of the virtual address space, giving us a predictable way to translate physical addresses into virtual addresses that the kernel can dereference.
To summarize the different configurations and the access they provide, we can use the following table:
| Configuration | Relevant Mappings | How We Access RAM |
| MMU disabled | Address translation disabled | Addresses directly access physical memory |
| MMU enabled, identity mapping present | Identity map | VA = PA within the mapped range |
| MMU enabled, identity map removed, no linear map | Higher-half image map only | Kernel image accessible; no general mapping for other RAM |
| MMU enabled, linear map installed | Image map and linear map | Mapped RAM accessible atPAGE_OFFSET + PA |
These are conceptual configurations illustrating the capabilities provided by each mapping, rather than a description of the exact sequence in which we construct them.
Layout
Before implementing the linear map, we first need to decide where it will live in the virtual address space. For this purpose, I have introduced PAGE_OFFSET, borrowing the name from Linux. Since we are using 48-bit virtual addresses, I set it to the beginning of the address range managed by TTBR1_EL1. The linear map therefore occupies the lower part of this range, below the kernel image mapping at KIMAGE_VADDR.
pub const PAGE_OFFSET = u64::MAX << VA_BITS; // 0xFFFF_0000_0000_0000
With this layout, the conversion from a physical address to its corresponding linear-map virtual address is straightforward:
$$\text{VA} = \text{PA} + \text{PAGE OFFSET}$$
For example, physical address 0x4000_0000, where RAM begins in the QEMU virt machine, corresponds to virtual address 0xFFFF_0000_4000_0000.
Notice that this formula does not imply that every physical address is mapped. It only tells us how to calculate the virtual address for a physical address covered by the linear map. Physical address zero corresponds to PAGE_OFFSET in the formula, but that does not mean it contains RAM or is actually mapped.
There is another consideration when choosing this offset: alignment. With our 4 KiB translation granule, AArch64 supports 1 GiB and 2 MiB block descriptors, as well as 4 KiB page descriptors. Since PAGE_OFFSET is aligned to 1 GiB (in fact, its lower 48 bits are zero), it preserves the alignment of physical addresses for all three mapping sizes.
This allows the page-table builder to use larger block descriptors whenever the physical address, virtual address, and range size permit it, falling back to 4 KiB pages when necessary.
For comparison, an offset such as 0x0800_0000 would still allow 2 MiB block mappings, but not 1 GiB blocks. A suitable offset alignment is therefore important if we want to take advantage of larger block descriptors.
Comparison with Linux
If we compare this layout with Linux’s AArch64 memory implementation, we can see that it uses a slightly different approach. Its __phys_to_virt() computes the virtual address using (pa - PHYS_OFFSET) | PAGE_OFFSET. Here, PHYS_OFFSET is derived from the runtime memstart_addr, whose placement is constrained by ARM64_MEMSTART_ALIGN during memory initialization. This allows Linux to account for the physical memory base selected for the linear map rather than assuming that the base is physical address zero.
For now, I have deliberately chosen a simpler approach. Our conversion is simplyPA + PAGE_OFFSET, which is sufficient for the current implementation. If we later need to support a configurable physical memory base, we can introduce that functionality without changing every caller, since the conversion is encapsulated in a single helper:
pub fn phys_to_virt(pa: PhysAddr) -> VirtAddr {
VirtAddr::new(pa.as_u64() + PAGE_OFFSET)
}
Address Conversion Helpers
We now have two separate mappings that allow us to translate between physical and virtual addresses. The following table summarizes their conversion formulas and the ranges they apply to.
| Mapping | Physical → Virtual | Virtual → Physical | Valid Range |
| Linear map | PA + PAGE_OFFSET | VA - PAGE_OFFSET | RAM covered by the linear map |
| Kernel image | KIMAGE_VADDR +(PA - __kernel_start) | __kernel_start +(VA - KIMAGE_VADDR) | Kernel image mapping |
These helpers are currently mapping-specific: phys_to_virt() and virt_to_phys() operate on linear-map addresses, while kimage_va() and kimage_pa() handle the kernel image mapping.
Linux takes this a step further. Its __virt_to_phys() checks whether a virtual address belongs to the linear map using __is_lm_address() and selects the corresponding conversion for the linear map or the kernel image. We could introduce a similar dispatch mechanism in the future, but for now, keeping the two mappings separate is sufficient.
Building the Linear Map
Now that we have established the background, it is time to build the linear map. First, we query the starting address and size of RAM by calling meminfo::ram_range(). We can then calculate the page-aligned boundaries for the ranges we are going to map.
// Get the ram range and align it to page boundary
let (ram_start, size) = meminfo::ram_range();
// Rounding it up could map a partial page beyond what the DTB says is RAM.
let ram_end = (ram_start + size as u64).align_down(PAGE_SIZE as u64);
let ram_start = ram_start.align_up(PAGE_SIZE as u64);
let text_start = sym_pa(addr_of_mut!(__text_start)).align_down(PAGE_SIZE as u64);
let rodata_end = sym_pa(addr_of_mut!(__rodata_end)).align_up(PAGE_SIZE as u64);
Here, it is important to distinguish between outward rounding and inward rounding. Each range we map will use one of these strategies, depending on whether we need to cover the entire region or ensure that we stay entirely within it. Before looking at the individual ranges and deciding which strategy to use for each, let’s examine how both approaches work.
Outward Rounding: Start Down, End Up
We use outward rounding when we need to cover the entire region. The start is rounded down to the previous page boundary, while the end is rounded up to the next page boundary.
For example, consider a region with start = 0x50000800 and end = 0x50002900. With 4 KiB pages, outward rounding gives us the range:
[0x50000000, 0x50003000)
This covers three pages and ensures that every byte of the original region is included.
Inward Rounding: Start Up, End Down
We use inward rounding when we need to stay entirely within the original region. The start is rounded up to the next page boundary, while the end is rounded down to the previous page boundary.
Using the same example, inward rounding gives us:
[0x50001000, 0x50002000)
Only one complete page remains. The partial pages at both ends are excluded, ensuring that the resulting range does not extend beyond the original region.
This distinction also matters when choosing the mapping size. It might be tempting to map a full 1 GiB block even when only part of it contains RAM, but doing so would also map the surrounding physical addresses as Normal, write-back memory. Those addresses might belong to devices or to regions that do not contain usable RAM, and the processor may issue speculative accesses under Normal-memory semantics. Instead, we must restrict the mapping to the page-aligned RAM range and select a block size only when the entire block fits within that range. Otherwise, we fall back to a smaller block or a 4 KiB page.
RAM Splits
I split the RAM range into three regions:
- RAM before
.text. - The pages covering
.textand.rodata. - RAM after
.rodata, including.data.
In the higher-half image mapping, .text is read-only and executable, .rodata is read-only and non-executable, and .data is writable and non-executable. In the linear map, all three regions are non-executable: the RAM before .text and after .rodata remains read-write, while the pages covering .text and .rodata are read-only. This prevents the linear map from providing a writable alias of the kernel’s executable code.
The resulting mappings expose the same physical memory through different virtual addresses and permissions:
| Alias | Virtual Address | Permissions |
| Identity map | PA (e.g. 0x5000_0000) | RWX, using a 1 GiB block |
| Image map | KIMAGE_VADDR + offset | .text: RO+X; .rodata: RO+XN;.data: RW+XN |
| Linear map | PAGE_OFFSET + PA(e.g. 0xFFFF_0000_5000_0000) | .text and .rodata: RO+XN;remaining mapped RAM: RW+XN |
Permissions belong to mappings, not to physical frames. The same physical frame can therefore have different permissions through different virtual addresses. At this stage, the identity mapping still exposes the RAM through a writable, executable alias, so these changes do not yet enforce W^X across every alias. The negative test below verifies the read-only permissions of the linear-map alias specifically.
Linux takes a different approach during boot. Its AArch64 alternatives mechanism patches instructions according to the CPU features available, writing through the linear alias using lm_alias(). The alias must therefore remain writable until this patching is complete, after which mark_linear_text_alias_ro() removes its write permission. Our kernel does not patch its code, so making the alias read-only from the beginning is sufficient.
With the three regions defined, we can now apply the rounding rules introduced in the previous subsection.
The writable ranges use inward rounding on their outer edges (ram_start up, ram_end down), so the mapping never extends beyond the RAM the DTB reports. The edges next to the image need no rounding of their own: they are the image range’s outward-rounded boundaries, text_start and rodata_end, so the writable ranges end exactly where the image’s pages begin and resume exactly where they end.
For the image’s .text and .rodata, we use outward rounding. We need to include every page containing these sections, including any partial pages at their boundaries. Otherwise, a page containing read-only data could end up in one of the writable RAM mappings.
The mapping calls below implement this layout:
let mut prot = map::Prot {
ap: leaf::Ap::RwEl1,
mair: MairIdx::NormalWb,
sh: leaf::Shareability::Inner,
attrs: leaf::PXN | leaf::UXN | leaf::AF,
};
// 1. Map from ram_start to __text_start (before kernel image)
map_range(root, phys_to_virt(ram_start), ram_start, text_start - ram_start, prot);
// 2. Map from __rodata_end to ram_end (past kernel image)
map_range(root, phys_to_virt(rodata_end), rodata_end, ram_end - rodata_end, prot);
// 3. Map from __text_start to __rodata_end (kernel image)
prot.ap = leaf::Ap::RoEl1;
map_range(
root,
phys_to_virt(text_start),
text_start,
rodata_end - text_start,
prot,
);
All three linear-map ranges use Normal, write-back memory with Inner Shareable attributes. The access permissions differ, but these memory attributes remain consistent across the ranges and with the other aliases of the same physical RAM. Permissions may differ between aliases; the memory type and shareability attributes should not.
One qualification worth keeping in mind: inward rounding prevents us from extending the mapping beyond the reported RAM boundaries. It does not automatically exclude reserved-memory regions contained within those boundaries. Supporting such exclusions would require handling those regions explicitly.
Extending the Mapping API
The process of building the linear map follows the same approach we used in the previous post: we use map_range() to create the mappings. However, I have made some changes to the mapping API to make it more generic and reusable. This will be useful not only for the linear map, but also for future features such as ioremap.
The first change is to map_range(). Previously, we mapped the range using 4 KiB pages throughout. Now, the function selects the largest block size that fits at each position, falling back from 1 GiB blocks to 2 MiB blocks and finally to 4 KiB pages.
pub fn map_range(root: PhysAddr, va: VirtAddr, pa: PhysAddr, size: u64, prot: Prot) {
debug_assert!(va.is_aligned(PAGE_SIZE as u64) && pa.is_aligned(PAGE_SIZE as u64));
debug_assert!(size % PAGE_SIZE as u64 == 0);
let mut off = 0;
while off < size {
let v = va + off;
let p = pa + off;
let left = size - off;
// Largest block that fits: a block needs both addresses aligned to its size and at
// least that much left. Anything that doesn't fit falls through to the next smaller
// size, so the unaligned edges of the range end up as pages and nothing is rounded
// away.
if v.is_aligned(PUD_SIZE as u64)
&& p.is_aligned(PUD_SIZE as u64)
&& left >= PUD_SIZE as u64
{
map_pud_block(root, v, p, prot);
off += PUD_SIZE as u64;
} else if v.is_aligned(PMD_SIZE as u64)
&& p.is_aligned(PMD_SIZE as u64)
&& left >= PMD_SIZE as u64
{
map_pmd_block(root, v, p, prot);
off += PMD_SIZE as u64;
} else {
map_page(root, v, p, prot);
off += PAGE_SIZE as u64;
}
}
}
The decision is made independently at each position. A block can be used only when both the virtual and physical addresses are aligned to its size and the remaining range is large enough to contain the entire block. Otherwise, the function tries a smaller mapping size. This allows us to use larger blocks wherever possible without forcing the entire range to be aligned to their boundaries.
Depending on the selected size, map_range() calls map_pud_block(), map_pmd_block(), or map_page(). These functions walk the translation-table hierarchy to the appropriate level and delegate the creation of the leaf descriptor to map_leaf(). The latter centralizes the construction of the descriptor, applying the permissions and memory attributes supplied through Prot.
I won't repeat the details of constructing the translation-table hierarchy here, since we covered that in the previous post. The important change is that the API now supports different mapping sizes through a common interface.
Accessing Page-Table Memory
Another change concerns how we access the translation-table entries themselves. The read_entry() and write_entry() functions receive the physical address of a table, but we cannot always dereference that address in the same way. Before the linear map is live, we can still use the physical address as a pointer: with the MMU disabled, addresses directly access physical memory; once the MMU is enabled, the identity mapping allows us to use the same numerical addresses as virtual addresses within the mapped range. After the linear map becomes live, we access the table through its corresponding linear-map virtual address.
To handle this transition, I introduced phys_ptr(), which centralizes the conversion and lets the page-table code remain independent of the current mapping state.
// map.rs
pub fn read_entry<D: Descriptor>(table: PhysAddr, index: usize) -> D {
unsafe {
let slot = phys_ptr::<D>(table).add(index);
slot.read_volatile()
}
}
pub fn write_entry<D: Descriptor>(table: PhysAddr, index: usize, desc: D) {
unsafe {
let slot = phys_ptr::<D>(table).add(index);
core::ptr::write_volatile(slot, desc);
}
}
// pgtable.rs
static LINEAR_MAP_LIVE: AtomicBool = AtomicBool::new(false);
pub fn phys_ptr<T>(pa: PhysAddr) -> *mut T {
if LINEAR_MAP_LIVE.load(Ordering::Relaxed) {
return phys_to_virt(pa).as_mut_ptr();
}
pa.as_mut_ptr()
}
The LINEAR_MAP_LIVE flag determines which address to use. While it is false, phys_ptr() treats the physical address as a directly dereferenceable pointer, relying on the fact that, with the MMU off or the identity map in place, a physical address can be dereferenced as it is. Once the flag becomes true, it converts the physical address through phys_to_virt().
For now, I use Ordering::Relaxed when accessing this flag. That is sufficient for the current single-core execution model, where the flag acts as a mode selector rather than a mechanism for publishing data to other cores. The ordering requirements for the page-table stores themselves are handled separately by the architectural barriers discussed below. If we introduce concurrent access from other cores in the future, the synchronization requirements will need to be revisited.
Ordering the Page-Table Writes
Finally, as in the previous post, we execute DSB ISHST after constructing the mappings. The barrier ensures that the preceding stores to the translation tables have completed within the Inner Shareable domain before subsequent operations proceed.
We have already discussed the distinction between volatile accesses and architectural barriers in the previous post, so I won't repeat the full explanation here. The important point is that read_volatile() and write_volatile() control compiler-level memory accesses; they do not replace the architectural ordering provided by the barrier.
unsafe {
asm!("dsb ishst", options(nostack, preserves_flags));
}
Verifying the Linear Mapping
To verify the linear map, I have extended the mapping checks introduced in the previous post. The boundary checks follow the same approach as check_kimage_mapping(), but this time we compare accesses through the identity mapping and the linear alias. We also allocate a physical frame and verify that writes through either address are visible through the other. Finally, we check that phys_ptr() returns the linear alias once the linear map is marked as live.
fn check_linear_word(pa: PhysAddr, what: &str) {
let linear = phys_to_virt(pa).as_ptr::<u64>();
unsafe {
if pa.as_ptr::<u64>().read_volatile() != linear.read_volatile() {
panic!(
"check_linear_mapping: {what} differs between the physical address and the \
linear alias"
);
}
}
}
pub fn check_linear_mapping() {
let (ram_start, size) = meminfo::ram_range();
let ram_end = (ram_start + size as u64).align_down(PAGE_SIZE as u64);
let ram_start = ram_start.align_up(PAGE_SIZE as u64);
let text_start = sym_pa(addr_of_mut!(__text_start)).align_down(PAGE_SIZE as u64);
let rodata_end = sym_pa(addr_of_mut!(__rodata_end)).align_up(PAGE_SIZE as u64);
check_linear_word(ram_start, "the start of RAM");
check_linear_word(text_start - 8, "the last word below the image");
check_linear_word(text_start, "the start of .text");
check_linear_word(rodata_end - 8, "the last word of the image alias");
check_linear_word(rodata_end, "the first word above the image");
check_linear_word(ram_end - 8, "the end of RAM");
let pa_frame = frame_alloc::alloc();
if pa_frame == PhysAddr::new(0) {
panic!("check_linear_mapping: out of frames");
}
unsafe {
let linear_frame = phys_to_virt(pa_frame).as_mut_ptr::<u64>();
if phys_ptr::<u64>(pa_frame) != linear_frame {
panic!(
"check_linear_mapping: phys_ptr does not return the linear alias after \
set_linear_map_live"
);
}
core::ptr::write_volatile(pa_frame.as_mut_ptr::<u64>(), 0xBEBECAFE);
if linear_frame.read_volatile() != 0xBEBECAFE {
panic!(
"check_linear_mapping: write through the physical address not seen through \
the linear alias"
);
}
if pa_frame.as_ptr::<u64>().read_volatile() != linear_frame.read_volatile() {
panic!(
"check_linear_mapping: frame differs between the physical address and \
linear map addresses"
);
}
core::ptr::write_volatile(linear_frame, 0xCAFEBEBE);
if pa_frame.as_ptr::<u64>().read_volatile() != 0xCAFEBEBE {
panic!(
"check_linear_mapping: write through the linear alias not seen through the \
physical address"
);
}
if pa_frame.as_ptr::<u64>().read_volatile() != linear_frame.read_volatile() {
panic!(
"check_linear_mapping: frame differs between the physical address and \
linear map addresses"
);
}
}
frame_alloc::free(pa_frame);
pr_info!(
"linear",
"read-back OK (range edges and a free frame via the identity and linear aliases)"
);
}
The tests start with read checks similar to those introduced in the previous post. This time, however, we compare reads through the identity mapping and the linear alias at several points:
- The start of the mapped RAM range (
ram_start). - The last word before the read-only range (
text_start - 8). - The first word of that range (
text_start). - The last word within it (
rodata_end - 8). - The first word after it (
rodata_end). - The last word of the mapped RAM range (
ram_end - 8).
Notice that these checks use the page-aligned boundaries of the mappings, rather than necessarily the original RAM or linker-symbol addresses. This lets us verify the transitions between the writable RAM regions and the read-only image range.
After that, I allocate a physical frame and use it to perform a few additional checks:
- Verify that
phys_to_virt()andphys_ptr()return the same virtual address once the linear map is live. - Write a known value through the identity mapping and verify that the linear alias reads the same value.
- Confirm that both aliases return the same value.
- Write a different value through the linear alias and verify that the identity-mapped address reads it.
- Confirm that both aliases return the same value again.
Finally, I free the allocated frame and print a message indicating that the checks have passed.
These tests verify that the linear map provides access to the same physical memory as the identity mapping, including for a frame obtained from the allocator. They do not yet verify the access permissions of the linear map; that is the purpose of the next test.
Negative Test: Write Protection
Finally, I added a temporary negative test that attempts to write to the first word of .text through its linear alias. Since we mapped this region as read-only, the write should trigger a synchronous Data Abort.
use crate::kernel::mm::pgtable::phys_to_virt;
let pa = PhysAddr::new(0x5000_0000); // start of .text
println!("negative test: writing to the linear alias of .text");
unsafe {
phys_to_virt(pa).as_mut_ptr::<u64>().write_volatile(0xDEAD);
let (esr, far): (u64, u64);
core::arch::asm!("mrs {}, esr_el1", "mrs {}, far_el1", out(reg) esr, out(reg) far);
println!(
"negative test: ESR_EL1 {:#x} (EC {:#x}, DFSC {:#x}, WnR {}) FAR_EL1 {:#x}",
esr,
esr >> 26,
esr & 0x3f,
(esr >> 6) & 1,
far
);
println!(
"negative test: .text first word via identity {:#x}",
(0x5000_0000u64 as *const u64).read_volatile()
);
}
I also read ESR_EL1 and FAR_EL1 to inspect the exception syndrome and the address that triggered the fault. This should help us distinguish a permission fault from other reasons why a memory access might fail.

As shown in the output, the syndrome tells us what happened. ESR_EL1 is 0x9600004F. Its exception class (bits [31:26]) is 0x25, a data abort taken without a change of exception level, and its data fault status code (DFSC, bits [5:0]) is 0xF, a permission fault at translation level 3, the 4 KiB page entry. Bit 6 (WnR) is set, so the faulting access was a write, and FAR_EL1 is 0xFFFF000050000000, the linear alias of the first word of .text. Reading that word through the identity mapping shows it unchanged. The hardware rejected the write because the page is mapped read-only, which is what we wanted. (A missing mapping would have been a translation fault instead, with a DFSC between 0x04 and 0x07.)
The exception handler currently reports the fault as Requested syscall: 57005 (0xDEAD, the value used in the attempted write) rather than the expected Unimplemented synchronous exception. This is a separate bug in the kernel's exception handling, which I will address in the future.
Next Steps
With the linear map in place, we now have a predictable way to access physical RAM through the kernel's virtual address space. However, this mapping is intended for normal memory and uses Normal, write-back memory attributes. Devices accessed through memory-mapped I/O require a different mapping with the appropriate Device memory attributes.
In the next post, we'll implement ioremap, providing a way to map device memory into the kernel's virtual address space. This will allow us to access MMIO regions without treating them as ordinary RAM, bringing us one step closer to a more complete virtual memory layout.
The complete implementation of the concepts discussed in this post can be found in the project's GitHub repository.