Linux Page Table Structure Explained-free Linux device drivers tutorial

PREV_LEC  |  NEXT_LEC

Linux Page Table Structure Explained
Free Linux Kernel Development Course · Kernel Memory Management · Lecture 4
Level: Intermediate
Kernel: 6.x
Reading Time: 14 min

free embedded systems course
free linux development course
free linux device drivers course
free linux kernel development course
free embedded linux course
linux page table structure

Understanding the Linux page table structure is one of the most
important skills for anyone taking a serious kernel or device driver course, because
almost every memory bug — a bad pointer dereference, a wrong mmap(), a
broken DMA buffer — eventually comes down to how the kernel walks pages, frames, and
the tables that connect them. In the last lecture of this free Linux kernel development
course we looked at virtual memory areas (VMAs). In this lecture we go one
level deeper and look at the actual hierarchy the CPU and kernel use to translate a
virtual address into a physical one: PGD → P4D → PUD → PMD → PTE.

This lecture is part of the free embedded Linux course on EmbeddedPathashala, and like
every lecture in this free Linux device drivers course, everything here is written and
tested against a modern 6.x kernel — not copied from an old textbook.

What You Will Learn

  • Why a single page table does not scale
  • The five levels of the Linux page table structure
  • PGD, P4D, PUD, PMD, PTE explained one by one
  • Why P4D was added and what “folding” means
  • The generic *_offset() and *_alloc() naming convention
  • A working kernel module that walks the page table structure
  • Common mistakes, best practices, and security notes

Prerequisites

Before this lecture

Why the Linux Page Table Structure Has Multiple Levels

A single flat page table sounds simple: one array, one entry per page, done. The problem
is size. On a 64-bit system with a 48-bit virtual address space and 4 KB pages, a flat
table would need around 64 million entries per process, most of them unused, because a
process only touches a small, scattered slice of its address space. Keeping that much
memory reserved just to describe mappings would be wasteful.

The Linux page table structure solves this with hierarchy. Instead of one giant table,
the address space is split into several small tables chained together, and any branch
of the tree that a process never touches simply does not get allocated. Empty regions
cost nothing.

64-bit Virtual Address Split Across the Page Table Structure
PGD
9 bits
P4D
9 bits
PUD
9 bits
PMD
9 bits
PTE
9 bits
Offset
12 bits

Each 9-bit field indexes 512 entries in its table. The P4D field only exists when the
CPU and kernel use 5-level paging (57-bit addresses); otherwise it is folded away.

PGD, P4D, PUD, PMD, PTE: The Linux Page Table Structure Level by Level

Here is what each level of the Linux page table structure actually represents, from the
top of the tree down to the physical page:

Level Full Name Type Offset Function Present On
PGD Page Global Directory pgd_t pgd_offset() Always present
P4D Page Level 4 Directory p4d_t p4d_offset() 5-level paging only, otherwise folded
PUD Page Upper Directory pud_t pud_offset() 4-level and 5-level paging
PMD Page Middle Directory pmd_t pmd_offset() 3-level, 4-level, 5-level paging
PTE Page Table Entry pte_t pte_offset_kernel() / pte_offset_map() Always present (leaf level)

Only the PGD and the PTE are guaranteed to exist on every architecture. P4D, PUD, and
PMD may or may not be real tables depending on how many levels that architecture’s
MMU actually needs — this is the “folding” idea covered next.

Why P4D Was Added to the Linux Page Table Structure

The original design most textbooks still describe is a four-level scheme: PGD → PUD
→ PMD → PTE, covering a 48-bit virtual address space. That is enough for 128 TB
per process, which sounded huge for decades but modern servers with very large memory
and memory-disaggregation technologies started to outgrow it. Linux 4.14 added a fifth
level, P4D, sitting between PGD and PUD, extending the address space to 57
bits — 128 PB per process. On x86-64 this is enabled by setting the CR4.LA57
bit, and the kernel only turns it on if the CPU actually supports it.

Folding: How the Same Code Runs on 2-Level and 5-Level Hardware

Writing separate driver and core-kernel code for every possible number of page table
levels would be unmanageable. Instead, the Linux page table structure uses a trick called
folding. On architectures that do not need a particular level — for example,
a 32-bit ARM MMU that only implements two levels — the kernel still compiles the generic
p4d_offset(), pud_offset(), and pmd_offset() calls, but
they are defined as no-ops that simply return the pointer they were given. The code path
looks identical for every architecture; only the macro definitions change.

Folding on a 2-Level MMU (e.g. many 32-bit ARM SoCs)
PGD
P4D (folded)
PUD (folded)
PMD (folded)
PTE

Walking the Linux Page Table Structure With the Generic API

The kernel gives every level a consistent naming pattern. Functions that read an entry’s
offset are called *_offset(), and functions that allocate a table at that level
are called *_alloc(), where * is pgd, p4d,
pud, pmd, or pte. A full top-to-bottom walk therefore
always looks the same shape, regardless of how many levels are real on that architecture:

pgd_t *pgd = pgd_offset(mm, address);
p4d_t *p4d = p4d_offset(pgd, address);
pud_t *pud = pud_offset(p4d, address);
pmd_t *pmd = pmd_offset(pud, address);
pte_t *pte = pte_offset_kernel(pmd, address);

At each step you must check for a NULL, “none”, or “bad” entry before moving to the next
level, because the branch may simply not be allocated yet.

Original Example: A Page Table Walk Driver

The following ep_pgtable_walk_demo module is original code written for this
course. It accepts a virtual address written to its device node, walks that address
through the current process’s page table structure, and prints what it finds at every
level to the kernel log.

// ep_pgtable_walk_demo.c
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <linux/uaccess.h>
#include <linux/mm.h>
#include <linux/sched.h>
#include <linux/pgtable.h>

static ssize_t ep_pgwalk_write(struct file *filp, const char __user *buf,
                                size_t len, loff_t *off)
{
    unsigned long addr;
    char kbuf[32];
    struct mm_struct *mm = current->mm;
    pgd_t *pgd;
    p4d_t *p4d;
    pud_t *pud;
    pmd_t *pmd;
    pte_t *pte;

    if (len >= sizeof(kbuf))
        return -EINVAL;
    if (copy_from_user(kbuf, buf, len))
        return -EFAULT;
    kbuf[len] = '\0';

    if (kstrtoul(kbuf, 16, &addr))
        return -EINVAL;

    if (!mm) {
        pr_info("ep_pgwalk: no mm for this task (kernel thread?)\n");
        return len;
    }

    mmap_read_lock(mm);

    pgd = pgd_offset(mm, addr);
    if (pgd_none(*pgd) || pgd_bad(*pgd)) {
        pr_info("ep_pgwalk: 0x%lx -> PGD not present\n", addr);
        goto out;
    }
    pr_info("ep_pgwalk: PGD entry found for 0x%lx\n", addr);

    p4d = p4d_offset(pgd, addr);
    if (p4d_none(*p4d) || p4d_bad(*p4d)) {
        pr_info("ep_pgwalk: 0x%lx -> P4D not present\n", addr);
        goto out;
    }
    pr_info("ep_pgwalk: P4D entry found for 0x%lx\n", addr);

    pud = pud_offset(p4d, addr);
    if (pud_none(*pud) || pud_bad(*pud)) {
        pr_info("ep_pgwalk: 0x%lx -> PUD not present\n", addr);
        goto out;
    }
    pr_info("ep_pgwalk: PUD entry found for 0x%lx\n", addr);

    pmd = pmd_offset(pud, addr);
    if (pmd_none(*pmd) || pmd_bad(*pmd)) {
        pr_info("ep_pgwalk: 0x%lx -> PMD not present\n", addr);
        goto out;
    }
    pr_info("ep_pgwalk: PMD entry found for 0x%lx\n", addr);

    pte = pte_offset_map(pmd, addr);
    if (!pte) {
        pr_info("ep_pgwalk: 0x%lx -> PTE not present\n", addr);
        goto out;
    }
    if (pte_present(*pte))
        pr_info("ep_pgwalk: 0x%lx -> PFN %lu (present, %s)\n",
                addr, pte_pfn(*pte),
                pte_write(*pte) ? "writable" : "read-only");
    else
        pr_info("ep_pgwalk: 0x%lx -> PTE present but page swapped out\n", addr);

    pte_unmap(pte);

out:
    mmap_read_unlock(mm);
    return len;
}

static const struct file_operations ep_pgwalk_fops = {
    .owner = THIS_MODULE,
    .write = ep_pgwalk_write,
};

static struct miscdevice ep_pgwalk_dev = {
    .minor = MISC_DYNAMIC_MINOR,
    .name  = "ep_pgtable_walk_demo",
    .fops  = &ep_pgwalk_fops,
};

static int __init ep_pgwalk_init(void)
{
    return misc_register(&ep_pgwalk_dev);
}

static void __exit ep_pgwalk_exit(void)
{
    misc_deregister(&ep_pgwalk_dev);
}

module_init(ep_pgwalk_init);
module_exit(ep_pgwalk_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("EmbeddedPathashala: walk the Linux page table structure for a given address");

Building and Running the Page Table Structure Demo

# Build against your running kernel headers
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules

# Load it
sudo insmod ep_pgtable_walk_demo.ko

# Pick a valid virtual address from the current shell, e.g. its own stack
grep -m1 "\[stack\]" /proc/self/maps

# Send that address (hex, no 0x prefix needed after kstrtoul base 16)
echo 7ffd8a2b1000 | sudo tee /dev/ep_pgtable_walk_demo

# Read the walk
dmesg | tail -n 6

Expected output (addresses will differ on your machine):

ep_pgwalk: PGD entry found for 0x7ffd8a2b1000
ep_pgwalk: P4D entry found for 0x7ffd8a2b1000
ep_pgwalk: PUD entry found for 0x7ffd8a2b1000
ep_pgwalk: PMD entry found for 0x7ffd8a2b1000
ep_pgwalk: 0x7ffd8a2b1000 -> PFN 184203 (present, writable)

On a machine running classic 4-level paging you will still see a “P4D entry found” line —
that call succeeds because the P4D level is folded onto the PGD, not because a real P4D
table was allocated.

Where the Linux Page Table Structure Shows Up in Real Kernel Work

  • Page fault handling walks exactly this chain to decide if a fault is valid or a real segmentation violation
  • Device drivers that implement a custom mmap() file operation populate PTEs directly through remap_pfn_range()
  • Huge pages skip the PTE level entirely by marking a PMD or PUD entry as a large-page leaf
  • Tools like /proc/pid/pagemap and page-types read this structure to report per-page residency and flags

Common Mistakes When Working With the Page Table Structure

Mistake Why It Breaks
Assuming every system has exactly 4 real levels 5-level paging is common on new server-class x86-64 kernels; hardcoding 4 levels skips P4D
Walking the table without holding mmap_read_lock() Another thread can tear down the mapping mid-walk, leading to a use-after-free
Forgetting pte_unmap() after pte_offset_map() Leaves a temporary kmap slot pinned on architectures where PTE pages are highmem
Treating a “none” entry as an error An unallocated branch simply means that region was never touched — it is normal, not a bug

Best Practices for the Linux Page Table Structure

  • Always use the generic *_offset() family — never assume a fixed number of levels
  • Take mmap_read_lock(mm) (or the write variant when modifying) before any manual walk
  • Check *_none() and *_bad() at every level before dereferencing
  • Prefer existing kernel helpers (follow_pfn(), get_user_pages()) over manual walks in real drivers
  • Use pte_offset_map()/pte_unmap() pairs, not raw pte_offset_kernel(), when the code can run on highmem 32-bit systems

Security Considerations

Page table contents reveal a process’s ASLR layout, which is exactly the kind of
information an attacker wants. Interfaces such as /proc/pid/pagemap are
therefore gated behind CAP_SYS_ADMIN on modern kernels, and any driver you
write that exposes page table details to user space should apply the same restriction —
never trust an unprivileged process with raw PFN or page table information.

Performance Considerations

Every extra level in the Linux page table structure is an extra memory access on a TLB
miss. CPUs cache intermediate levels in paging-structure caches to soften this cost, and
huge pages reduce it further by letting one PMD or PUD entry cover 2 MB or 1 GB instead
of walking all the way to individual 4 KB PTEs. This is why huge-page-aware allocation
matters for memory-heavy workloads such as databases and virtualization hosts.

Summary and Key Takeaways

  • The Linux page table structure is a hierarchy, not a flat table, so unused address ranges cost no memory
  • Five possible levels exist: PGD, P4D, PUD, PMD, PTE — only PGD and PTE are guaranteed on every architecture
  • P4D was added for 5-level paging to extend the virtual address space from 48 to 57 bits
  • Folding lets the same generic code run correctly whether an architecture uses 2, 3, 4, or 5 real levels
  • The *_offset()/*_alloc() naming convention is consistent across every level and every architecture

Conclusion

The Linux page table structure is what turns a simple pointer dereference in your driver
into a real, protected access to physical memory. Once you can read a PGD-to-PTE chain
confidently, concepts like page faults, huge pages, and custom mmap()
implementations stop feeling like magic. This lecture is part of the free Linux kernel
development course on EmbeddedPathashala, and the next lecture builds directly on this
page table structure to explain how address translation and page faults actually work
end to end.

Frequently Asked Questions

What is the Linux page table structure made of?

It is a hierarchy of up to five levels — PGD, P4D, PUD, PMD, and PTE — that together translate a virtual address into a physical page frame.

Why does the Linux page table structure use multiple levels instead of one table?

A flat table for the full virtual address space would waste enormous amounts of memory on unused entries. A multi-level structure only allocates the branches a process actually uses.

What is P4D and why was it added?

P4D (Page Level 4 Directory) sits between PGD and PUD. It was added in Linux 4.14 to support 5-level paging, extending the virtual address space from 48 bits to 57 bits.

Does every CPU architecture use all five levels?

No. Architectures that need fewer levels “fold” the unused ones — the generic function calls still exist in the code, but they become no-ops that pass the pointer straight through.

What does pgd_offset() actually return?

It returns a pointer to the PGD entry that covers a given virtual address for a specific mm_struct. The other *_offset() functions follow the same pattern at their own level.

Is it safe to walk the page table structure manually from a driver?

Only while holding the appropriate mmap_lock, and generally only for debugging or educational purposes. Production drivers should prefer existing kernel APIs such as get_user_pages() instead of a manual walk.

How do huge pages interact with the page table structure?

A huge page lets the walk stop one or two levels early — a PMD or PUD entry points directly at a large physical page instead of at another table, reducing TLB pressure.

Where can I practice this hands-on in this free Linux device drivers course?

Build and load the ep_pgtable_walk_demo module from this lecture, write a virtual address to its device node, and read the resulting walk in dmesg.

Continue the Free Linux Kernel Development Course

Next up: how the MMU and the kernel use this page table structure to resolve a page fault, step by step.

PREV_LEC  |  NEXT_LEC

Leave a Reply

Your email address will not be published. Required fields are marked *