In the previous post, we built the physical frame allocator required to construct page tables dynamically. Until now, the kernel has relied on an identity mapping, with virtual addresses equal to physical addresses, which was enough to enable the MMU and continue execution with address translation enabled.
The next step is to map the kernel image into the higher half of the virtual address space. This is an important step towards separating kernel and userspace mappings, with the eventual goal of leaving the lower half available for user processes while keeping the kernel in the higher half. It also allows us to map each kernel section with its own permissions: executable code will be read-only, read-only data will remain non-writable, and writable sections will be mapped as non-executable.
We are not moving execution to the higher half just yet. Before making that transition, we still need to implement a few more pieces, including ioremap. For now, we will build the higher-half mapping alongside the existing identity mapping and verify that it works by reading and writing through both virtual addresses. This gives us a way to validate the new mappings before relying on them for kernel execution.
Defining the Higher-Half Address Space
Before choosing the virtual address at which to map the kernel, let’s look at the translation setup we currently have. The kernel still executes through an identity mapping, backed by two 1 GiB block descriptors in TTBR0_EL1. We will keep this mapping in place for now while building a separate translation-table hierarchy for the higher half, rooted at TTBR1_EL1. The physical frame allocator we implemented in the previous post gives us the means to allocate the frames required for this new hierarchy.
As established in the first post of the series, we will use a 48-bit virtual address space with 4 KiB granules. This is a deliberate choice that keeps the implementation aligned with a widely used AArch64 configuration. With 48-bit virtual addresses, the 12-bit page offset leaves 36 bits for the translation-table indices. These are divided into four 9-bit indices, one for each level of the translation hierarchy.
Another decision we have to make is whether we want a fixed virtual address where the kernel will be mapped or a randomized one for each boot using KASLR. While I would like to implement KASLR, I believe that at this point it doesn’t make sense. For now, the idea is to keep it simple and add it in the future. Therefore, we will use a fixed virtual address for the kernel image, which we will define as KIMAGE_VADDR.
Those familiar with Linux development may recognize the name KIMAGE_VADDR, which Linux also uses for the base virtual address of the kernel image. I’m borrowing the naming convention, not reproducing Linux’s layout. Linux reserves separate regions for modules, vmalloc, vmemmap, and other purposes. Our kernel does not need that complexity yet.
I set KIMAGE_VADDR to 0xFFFF_8000_0000_0000. This places the kernel image within the virtual-address range managed by TTBR1_EL1, while leaving the lower virtual-address region available for mappings managed through TTBR0_EL1.
With the base address defined, we can derive a higher-half virtual address for a physical address in the kernel’s memory layout. The kimage_va(pa) helper preserves the physical address’s offset from __kernel_start and adds it to KIMAGE_VADDR, maintaining the same relative offset in the higher-half virtual layout. This is used not only for the kernel image sections, but also for the kernel stack, which is placed after __kernel_end in the current linker layout.
pub fn kimage_va(pa: PhysAddr) -> u64 {
KIMAGE_VADDR + (pa.as_u64() - addr_of_mut!(__kernel_start) as u64)
}
Translating Kernel Permissions into AArch64 Attributes
Now that we know where the kernel is going to live, what permissions should each part of it have? Does it make sense to set the whole kernel with full RWX permissions? The answer is no. This is where it makes sense to split the regions and assign each region a specific set of permissions. For example, kernel code pages should not be writable because the kernel does not need to modify its own instructions.
To allow this separation, we need a finer mapping granularity than the 1 GiB blocks we used for the identity map. With our 4 KiB translation granule, we can use block descriptors to map 1 GiB or 2 MiB regions, or page descriptors to map individual 4 KiB pages. Since a block descriptor assigns the same attributes to the entire region it maps, we cannot use a single 1 GiB block to give the different kernel sections their own permissions. For this reason, we will map the kernel in the higher half using 4 KiB pages.
The resulting permission policy is as follows:
| Region | EL1 data access | EL1 execution | EL0 access |
.text | Read-only | Allowed | Denied |
.rodata | Read-only | Denied | Denied |
.data/.bss | Read/write | Denied | Denied |
Kernel stack | Read/write | Denied | Denied |
Guard page | Translation fault | Translation fault | Translation fault |
These permissions apply to the higher-half mappings. While the identity mapping remains in place, the same physical pages may still be accessible through their identity-mapped aliases, subject to the permissions of those mappings.
There is one special case: the guard page. Unlike the other regions, we don’t want to map this page at all. The leaf descriptor’s AP field does not provide an encoding that denies all data access at EL1, so we create the guard page by leaving its higher-half virtual address unmapped. Any access through that virtual address will then generate a translation fault. However, while the identity mapping is still present, the physical memory corresponding to that address may remain accessible through its identity-mapped address. The guard page therefore does not yet protect that physical memory from accesses through the identity alias; that limitation will disappear once we remove the identity mapping.
The permissions in the table above are the policy we want. Now we need to translate that policy into the attributes of the AArch64 translation descriptors. In AArch64, these attributes are encoded in the translation descriptors, with different fields controlling how the translation tables and the mapped memory can be accessed. As we saw in the first post, translation descriptors can either point to another translation table or describe the final mapping of a block or page. For the higher-half mapping, we are mainly interested in the attributes of these descriptors that allow us to enforce the permissions defined above.
Concretely, we are interested in the UXN, PXN, and AP[2:1] fields of the leaf descriptors:
- UXN ([54]): Prevents
EL0from fetching instructions from the mapped region. We set this for the kernel mappings so that they cannot be executed atEL0. This field controls instruction execution; data access permissions are handled separately byAP. - PXN ([53]): Prevents
EL1from fetching instructions from the mapped region. We will set this for.rodata,.data,.bss, and the kernel stack so that these regions cannot be executed atEL1. - AP ([7:6]): Controls data read/write permissions and whether data accesses are permitted at
EL0. This allows us to make.textand.rodataread-only atEL1, while keeping.data,.bss, and the stack writable, and denyingEL0data access to these kernel mappings.
Preparing the Linker Script for Section-Level Mapping
The changes to the linker script are relatively small, but they address a few issues I encountered while implementing the higher-half mapping. Rather than reproducing the entire script, the diff below highlights the relevant changes.
The linker script already defines symbols such as __text_start and __text_end, which mark the boundaries of the kernel’s text section. Until now, we haven’t needed to use these symbols to build the mappings. With the higher-half mapping, they become important because they allow us to map each kernel region with its own permissions.
However, this also means that the symbols must be placed correctly, which was not always the case in the original script. For example, __data_start was defined before .rodata, even though it is intended to mark the beginning of the writable data region. Using that symbol as the start of the writable mapping could make the mapping overlap pages already assigned to .text or .rodata. Since map_page() rejects an already-mapped page, this can cause the mapping process to panic rather than produce the intended permission layout.
The placement of __data_end, on the other hand, is intentional. Although its name might suggest that it marks the end of the .data output section, I use it as the end of the combined writable region. Since .data, .bss, and the reserved identity-map page tables all require the same permissions, this lets us map the entire range in a single operation.
Another bug I found while implementing this feature was that I had not reserved a page for __idmap_l1. The symbol marks the start of the L1 table, but assigning the symbol does not reserve any space for it. Unlike the L0 table, I had forgotten to advance the location counter by PAGE_SIZE after the symbol. As a result, __kernel_end was placed at the beginning of the L1 table page, rather than after it, so the page was not properly included in the kernel’s reserved image layout. Adding . += PAGE_SIZE fixes this by reserving the entire table page.
Another issue was that some input-section patterns matched only suffixed names, without explicitly matching the exact .text and .rodata section names. For example, *(.text.*) matches sections such as .text.boot, but does not match an input section named exactly .text.
This does not necessarily cause the section to disappear from the final ELF. In my case, the linker reported:
orphan section
.textfrombuild/src/asm/vectors.S.obeing placed in section.text
The linker placed that input section in the output .text section, so the build still completed. However, relying on orphan placement is risky: the linker decides where to place sections that the script has not explicitly assigned. In particular, an orphan placed after a linker-defined end symbol could leave that symbol out of sync with the actual contents that need to be mapped. The fix is to match both the exact section name and its suffixed variants explicitly.
Finally, I decided to discard .eh_frame and .debug_gdb_scripts. Since the kernel currently uses panic=abort, it does not need runtime unwinding to recover from a panic. However, unwind information can also be useful to debugging tools, so discarding .eh_frame is a deliberate trade-off rather than a general rule for kernels. I can revisit this when improving debugging support.
The .debug_gdb_scripts section allows GDB to load scripts associated with the ELF, including scripts that register pretty-printers. Since I am not using this auto-loading mechanism at the moment, I have chosen to discard the section as well.
@@ .text
- *(.text.*)
+ *(.text .text.*)
__text_end = .;
} > RAM
- __data_start = .;
.rodata : ALIGN(PAGE_SIZE)
{
__rodata_start = .;
- *(.rodata.*)
- __rodata_end = .;
+ *(.rodata .rodata.*)
+ __rodata_end = .;
} > RAM
- .data : ALIGN(4K)
+ .data : ALIGN(PAGE_SIZE)
{
+ __data_start = .;
*(.data .data.*)
} > RAM
@@ .bss
. += PAGE_SIZE;
__idmap_l1 = .;
+ . += PAGE_SIZE;
_bss_end = .;
} > RAM
+ /DISCARD/ : { *(.eh_frame) *(.debug_gdb_scripts) }
Constructing the Higher-Half Page Tables
Now it is time to describe how we build the translation-table hierarchy for the higher half. This is a software-side walk: we traverse the tables we have already allocated, creating intermediate tables when necessary, until we can install a leaf descriptor for the physical page we want to map.
Physical frame allocator
│
└── Allocates frames for translation tables
│
└── L0 table
└── L0 entry → L1 table
└── L1 entry → L2 table
└── L2 entry → L3 table
Kernel image already present in physical memory
│
└── Physical kernel page ← L3 page descriptor
↑
Higher-half VA
Initializing the L0 Root Table
The first step is to allocate and zero-fill a page for the L0 table. Zero-initialization is important because it leaves every descriptor invalid. Our physical frame allocator fills newly allocated frames with a pattern to make memory bugs easier to detect, so we must explicitly clear this frame before using it as a translation table. This table is the starting point of the walk.
let pgdir = frame_alloc::alloc();
if pgdir == PhysAddr::new(0) {
panic!("map_kimage: out of frames for the TTBR1 root table");
}
unsafe {
utilities::mem::memset(pgdir.as_mut_ptr(), 0x0, PAGE_SIZE);
}
Allocating Intermediate Tables at L1 and L2
With the root table initialized, we can start walking the hierarchy. Each level uses a nine-bit index extracted from the virtual address to select its descriptor.
The operation for intermediate levels is the same, so I encapsulated it in get_or_alloc_table<D>(table, va). The generic parameter identifies the descriptor type for the current level, allowing D::index(va) to select the appropriate entry.
Once we have calculated the descriptor’s address, we read it using read_volatile(). Here, volatile access ensures that the compiler performs the memory access rather than eliminating it or reusing a previously read value. It does not guarantee atomicity or hardware visibility, however; architectural ordering and translation maintenance are separate concerns.
The identity-map implementation still uses ordinary reads and writes. I plan to make the two implementations consistent in a later cleanup, once the higher-half transition is working.
let slot = table.as_mut_ptr::<D>().add(D::index(va));
// Extract the descriptor from the pointed addr. Read it as volatile
let descriptor = slot.read_volatile();
One important technical qualification: A valid descriptor at L1 or L2 is not necessarily a table descriptor. AArch64 also permits block mappings at these levels, as we saw in the first post. This helper checks only whether the descriptor is valid and then follows its output address, assuming that the descriptor points to another table. That assumption holds for the hierarchy we construct here, since all intermediate entries are created as table descriptors. A more general page-table walker would also need to check the descriptor type.
If the descriptor is valid, we follow its output address to the next-level table. Otherwise, we allocate a new table and install a descriptor pointing to it. We use write_volatile() for that store for the compiler-level reasons explained above.
unsafe {
if !descriptor.is_valid() {
let page = frame_alloc::alloc();
if page == PhysAddr::new(0) {
panic!("get_or_alloc_table: out of frames for page tables");
}
utilities::mem::memset(page.as_mut_ptr(), 0x0, PAGE_SIZE);
let mut next_lvl_desc = D::invalid();
next_lvl_desc.set_type(DescriptorType::Table);
next_lvl_desc.set_attrs(table::UXNTABLE | table::APTABLE0);
next_lvl_desc.set_output_address(page);
core::ptr::write_volatile(slot, next_lvl_desc);
return page;
}
descriptor.output_address()
}
We also set two hierarchical permission attributes in every intermediate table descriptor, regardless of the table level:
- APTABLE0 ([61]): When set, denies
EL0data access to the mappings below this table descriptor. - UXNTABLE ([60]): Prevents instruction execution at
EL0for mappings below this table descriptor. It does not prevent the kernel from executing code atEL1.
These attributes constrain descendant mappings; they do not describe the physical frame containing the table itself. The restrictions are hierarchical, so the effective permissions of a mapping depend on the applicable table-level restrictions as well as its leaf descriptor. For the page permissions we need to wait for the L3 descriptor, where we will set the permissions we saw in the previous section.
Mapping Pages at L3
Once we reach the L3 table, the descriptor directly describes the 4 KiB physical page we want to map. We no longer need to allocate another translation table. Instead, we construct a page descriptor containing the output physical address, the region’s access permissions, and the memory attributes.
L3 descriptors contain several attributes that control how the mapped memory can be accessed. Some of these attributes are common to every mapping we create, while others depend on the region being mapped. The common attributes describe the mapping’s shareability, access-flag state, and memory type. The access permissions and execution restrictions are then selected according to the region’s intended use.
- nG ([11]) = 0x0: The mapping is global, so its TLB entry is not restricted to the current Address Space Identifier (ASID). This is appropriate for kernel mappings shared across address spaces.
- AF ([10]) = 0x1: Marks the access flag as set, allowing the mapping to be used without relying on hardware access-flag updates or implementing an access-flag fault handler. With the current configuration, leaving it clear would cause an access-flag fault on access.
- SH ([9:8]) = 0b11: Selects Inner Shareable. This is the intended shareability domain for the normal, cacheable RAM in this configuration. The field identifies the shareability domain; it does not by itself create coherency or replace memory-ordering requirements.
- AttrIndx ([4:2]) = 0b10: Selects entry 2 (
MAIR_EL1.Attr2). That MAIR entry was configured as Normal, write-back cacheable memory in the first post.
In the code, we first verify that the destination PTE is invalid, since this function does not support replacing an existing mapping. We then build the page descriptor by setting its type, shareability, access permissions, memory-attribute index, and region-specific attributes before supplying the output physical address. Finally, we store it in the L3 table.
// Perform the page walk and create intermediate pages if not exist
let pud_table = get_or_alloc_table::<Pgd>(root, va);
let pmd_table = get_or_alloc_table::<Pud>(pud_table, va);
let pte_table = get_or_alloc_table::<Pmd>(pmd_table, va);
unsafe {
let slot = pte_table.as_mut_ptr::<Pte>().add(Pte::index(va));
let descriptor = slot.read_volatile();
// If the pte returned is not invalid that means there is something mapped there, just
// panic
if descriptor.is_valid() {
panic!("map_page: page already mapped");
}
// Build the pte entry and write it in the table
let mut pte = Pte::invalid();
pte.set_type(DescriptorType::Page);
pte.set_shareability(leaf::Shareability::Inner);
pte.set_ap(ap);
pte.set_mair_range(MairIdx::NormalWb);
pte.set_attrs(attrs);
pte.set_output_address(pa);
core::ptr::write_volatile(slot, pte);
}
map_page() maps one physical page at a specified virtual address. To map an entire kernel region, we use map_range(), which receives a physical start address, an exclusive end address, and the permissions and attributes to apply.
The function aligns the start address down to the containing 4 KiB page, then iterates through the range one page at a time. For each physical page, it derives the corresponding higher-half virtual address with kimage_va() and calls map_page() to create the mapping. The linker layout ensures that region boundaries do not cause adjacent sections with different permissions to share a page.
fn map_range(root: PhysAddr, start: PhysAddr, end: PhysAddr, ap: leaf::Ap, attrs: u64) {
let mut page = start.align_down(PAGE_SIZE as u64);
while page < end {
let vaddr = kimage_va(page);
map_page(root, vaddr, page, ap, attrs);
page = page + PAGE_SIZE as u64;
}
}
We can now apply the permissions defined earlier.. As we have defined previously, each region will hold a different set of permissions. These permissions are controlled with the AP, UXN, and PXN fields.
| Region | AP | UXN | PXN | Intended result |
.text | 0b10 | 0x1 | 0x0 | EL1 RX.EL0 cannot access the mapping |
.rodata | 0b10 | 0x1 | 0x1 | EL1 R.EL0 cannot access the mapping |
.data / .bss | 0b00 | 0x1 | 0x1 | EL1 RW.EL0 cannot access the mapping |
Kernel stack | 0b00 | 0x1 | 0x1 | EL1 RW.EL0 cannot access the mapping |
We can now apply the permission policy defined earlier. The common memory attributes and access flag are identical for every mapping; what changes between regions is the access-permission setting and whether execution is permitted at EL1 or EL0.
// 1. Build the walk for text pages
map_range(
pgdir,
sym_pa(addr_of_mut!(__text_start)),
sym_pa(addr_of_mut!(__text_end)),
leaf::Ap::RoEl1,
leaf::UXN | leaf::AF,
);
// 2. Build the walk for rodata pages
map_range(
pgdir,
sym_pa(addr_of_mut!(__rodata_start)),
sym_pa(addr_of_mut!(__rodata_end)),
leaf::Ap::RoEl1,
leaf::PXN | leaf::UXN | leaf::AF,
);
// 3. Build the walk for data pages
map_range(
pgdir,
sym_pa(addr_of_mut!(__data_start)),
sym_pa(addr_of_mut!(__data_end)),
leaf::Ap::RwEl1,
leaf::PXN | leaf::UXN | leaf::AF,
);
// 4. Build the walk for stack pages. We start a page after __kernel_end so we leave a guard
// page in between data and stack sections
map_range(
pgdir,
sym_pa(addr_of_mut!(__kernel_end)) + PAGE_SIZE as u64,
sym_pa(addr_of_mut!(__stack_top)),
leaf::Ap::RwEl1,
leaf::PXN | leaf::UXN | leaf::AF,
);
Finally, we execute DSB ISHST after constructing the page tables. This barrier ensures that the preceding stores to the translation tables have completed within the Inner Shareable domain before subsequent operations proceed. The barrier provides architectural ordering; the volatile accesses above serve a different purpose at the compiler level.
unsafe {
asm!("dsb ishst", options(nostack, preserves_flags));
}
Enabling the Higher-Half Translation Tables
Once the higher-half page tables have been built, we need to make the hierarchy available to the CPU. The first step is to load the physical address of the L0 root table into TTBR1_EL1. This provides the starting point for translations in the upper virtual-address region.
Until now, the initialization code only loaded the root table for the identity mapping into TTBR0_EL1. We now also build the higher-half mapping and load its root table into TTBR1_EL1. The map_kimage() function constructs the new hierarchy and returns the physical address of its L0 root table, which we pass to load_ttbr1().
diff --git a/src/kernel/mm/identity.rs b/src/kernel/mm/identity.rs
--- a/src/kernel/mm/identity.rs
+++ b/src/kernel/mm/identity.rs
@@ -325,7 +325,8 @@ pub fn setup_identity_mapping() {
*off = pud_nwb;
load_ttbr0(idmap_pgd_ptr);
+ load_ttbr1(map_kimage());
}
// We can now safely enable MMU
#[inline(always)]
fn load_ttbr1(base: PhysAddr) {
unsafe {
asm!(
"msr ttbr1_el1, {tmp}",
"isb sy",
"tlbi vmalle1",
"dsb nsh",
"isb sy",
tmp = in(reg) base.as_u64(),
options(nostack, preserves_flags)
);
}
}
The instruction sequence follows the same pattern as the TTBR0_EL1 setup we covered in the first post. After writing the new table base, we synchronize the instruction stream, invalidate the local stage-1 TLB entries, wait for the invalidation to complete, and synchronize again before continuing.
The next step is to update TCR_EL1, whose configuration we introduced in the first post (OK AS IS). Until now, we have set EPD1 to disable translation-table walks through TTBR1_EL1, since the higher-half mapping was not yet available. Now that the root table is installed, we can enable these walks by clearing EPD1.
The configure_tcr() call below sets up both translation regions for 48-bit virtual addresses, the 4 KiB granule, and the 44-bit intermediate physical address size. Since EPD1 is omitted from the value, it is cleared, allowing the processor to walk the hierarchy through TTBR1_EL1 when required.
configure_tcr(
tcr::T0SZ_48
| tcr::IRGN0_WBWA
| tcr::ORGN0_WBWA
| tcr::SH0_INNER
| tcr::TG0_4K
| tcr::T1SZ_48
| tcr::IRGN1_WBWA
| tcr::ORGN1_WBWA
| tcr::SH1_INNER
| tcr::TG1_4K
| tcr::IPS_44,
);
At this point, the higher-half mapping is enabled, but we have not yet moved kernel execution there. The kernel continues running through the existing identity mapping. This lets us test the new higher-half addresses while keeping execution on the mapping we already know works.
Verifying the Higher-Half Mapping
Since we are still not jumping to the higher half, I wanted to verify that what we have built in this post actually works as expected. To do so, I created check_kimage_mapping(), which accesses the same physical memory through both its identity-mapped and higher-half virtual addresses.
The test is intentionally simple. It is not intended to exhaustively verify the permission configuration; it is just a quick sanity check that the new mappings point to the expected physical memory before we rely on them for kernel execution.
The function performs three checks:
- Read from
.textthrough both aliases and compare the values. - Read from
.rodatathrough both aliases and compare the values. - Read from
.data, then modify it through the higher-half alias and verify the change through the identity alias. The test then performs the same operation in the opposite direction and restores the original value.
pub fn check_kimage_mapping() {
let id_text = PhysAddr::new(addr_of_mut!(__text_start) as u64);
let hi_text = kimage_va(id_text) as *mut u64;
unsafe {
if id_text.as_ptr::<u64>().read_volatile()
!= hi_text.read_volatile()
{
panic!("check_kimage_mapping: .text differs between the identity and high addresses");
}
}
let id_rodata = PhysAddr::new(addr_of_mut!(__rodata_start) as u64);
let hi_rodata = kimage_va(id_rodata) as *mut u64;
unsafe {
if id_rodata.as_ptr::<u64>().read_volatile()
!= hi_rodata.read_volatile()
{
panic!("check_kimage_mapping: .rodata differs between the identity and high addresses");
}
}
let id_data = PhysAddr::new(addr_of_mut!(__data_start) as u64);
let hi_data = kimage_va(id_data) as *mut u64;
unsafe {
if id_data.as_ptr::<u64>().read_volatile()
!= hi_data.read_volatile()
{
panic!("check_kimage_mapping: .data differs between the identity and high addresses");
}
let hi_val = hi_data.read_volatile();
core::ptr::write_volatile(hi_data, 0xBEBECAFE);
if id_data.as_ptr::<u64>().read_volatile() != 0xBEBECAFE {
panic!(
"check_kimage_mapping: write through the high alias not seen at the identity address"
);
}
core::ptr::write_volatile(hi_data, hi_val);
let id_val = id_data.as_ptr::<u64>().read_volatile();
core::ptr::write_volatile(id_data.as_mut_ptr::<u64>(), 0xBEBECAFE);
if hi_data.read_volatile() != 0xBEBECAFE {
panic!(
"check_kimage_mapping: write through the identity alias not seen at the high address"
);
}
core::ptr::write_volatile(id_data.as_mut_ptr::<u64>(), id_val);
}
pr_info!(
"kimage",
"read-back OK (text, rodata, data via the high and identity aliases)"
);
}
If the higher-half translation is missing, an access will trigger a synchronous exception, such as a data abort caused by a translation fault. If both accesses complete but return different values, the corresponding comparison will fail. If the test passes, we have verified the basic property we need at this stage: the identity-mapped and higher-half addresses can access the same underlying physical memory.
There is a small detail worth pointing out in the original version of this test. I initially used __kernel_start as the address from which to test the .text mapping, even though __text_start explicitly marks the beginning of the .text section. In the current linker layout, both addresses coincide, so the original test worked. However, using __text_start makes the intention clearer and avoids relying on that layout detail. I have since corrected this in the repository.
Next Steps
With the higher-half mapping in place, we now have the basic address-space layout required to eventually move the kernel away from its identity mapping. The kernel is still executing through the identity mapping, however, so there is still some work to do before we can make that transition safely.
The next step is to prepare the remaining pieces of the kernel's memory layout and make the code that performs the transition independent of the identity mapping. This includes dealing with memory-mapped I/O, establishing the mappings needed by the kernel, and adjusting the kernel's link and load addresses to its final higher-half location.
Once these pieces are in place, we will be able to switch kernel execution to the higher half and, after verifying that everything continues to work, remove the identity mapping and reclaim the page-table frames it uses.
The complete implementation of the concepts discussed in this post can be found in the project's GitHub repository.