The Mysterious Case of the Duplicated Page

Let's explore how the processes Page Tables are managed and how the Copy-on-Write mechanism works
PUBLISHED: 2026-07-02

PROGRAMMING CONCURRENCY PROCESSES THREADS PAGE TABLE MEMORY MANAGEMENT

This is a short history about ignorance and how correct data can be easily misinterpreted. Be warned, this is a deep dive into the Linux memory management system, but the journey well deserves the effort. Everything started when trying to understand how the Copy-On-Write mechanism worked with forked processes…

I wrote a pretty simple program to get started. Something like this:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#include <sys/types.h>  // Needed for wait
#include <sys/wait.h>


int global_var = 1;

int main ()
{
  pid_t child;

  // Create a new process
  if ((child = fork ()) < 0) {
    perror ("fork:");
    exit (EXIT_FAILURE);
  }
  if (child == 0) // Child process
    {
      global_var ++;
      printf ("CHILD: GLobal variable : %d\n", global_var);
      return 0; // The child process finish
    }
  else // This is the father
    {
      int status;
      wait (&status);
      sleep (1);
      printf ("FATHER: GLobal variable : %d\n", global_var);
    }

  return 0;
}

This is actually part of the introduction to my Concurrent Programming Course that I recommend to read. Anyhow, the program is pretty simple and it’s the classical example where the COW process is fired.

The child process starts with a page table pointing to shared pages with the parent process. Let’s focus on the .data segment/section where the global_variable lives. The first thing the child process does is write into that variable. That should cause a page fault that fires the Copy-On-Write process that, just does that… copy on write. A new page is allocated, the content of the old page is copied into the new page, the page table gets updated and the write happens in a new memory page. Everything looks great.

So I thought. Wouldn’t it be nice to be able to visualize this process?

Accessing Page Tables

In principle, page tables cannot be accessible from user space. But there are a few things that can be done without writing a kernel module to do that. The most direct one is the access to the /proc/pid/pagemap, so let’s try to use it.

This file contains a 64bit value for each virtual page in the system. It’s a huge file and it’s binary so you cannot just cat it. I tried and I had to cancel the process. The content of each of these entries is specified in the kernel documentation page linked above, but let’s reproduce it here for our own convenience.

  • Bits 0-54 page frame number (PFN) if present
  • Bits 0-4 swap type if swapped
  • Bits 5-54 swap offset if swapped
  • Bit 55 pte is soft-dirty (see Soft-Dirty PTEs)
  • Bit 56 page exclusively mapped (since 4.2)
  • Bit 57 pte is uffd-wp write-protected (since 5.13) (see Userfaultfd)
  • Bit 58 pte is a guard region (since 6.15) (see madvise (2) man page)
  • Bits 59-60 zero
  • Bit 61 page is file-page or shared-anon (since 3.5)
  • Bit 62 page swapped
  • Bit 63 page present

For our experiment, we’re only interested in the PFN (Page frame number) that, effectively, is the physical address associated to that page and the bit 63 that indicates if the page is present or not. We’ll come to this bit a bit later :).

So I wrote the following function in my program to be able to access the process page table:

int print_physical_mem (char *title,uint8_t *p) {
  const size_t pagesize = sysconf(_SC_PAGESIZE);
  int          fd;
  uint64_t     vaddr, vpn, entry, pfn, phys;
  off_t        offset;
  
  if ((fd = open ("/proc/self/pagemap", O_RDONLY)) < 0) {
    perror ("open");
    return 1;
  }

  vaddr  = (uint64_t)p;
  vpn    = vaddr / pagesize;
  offset = vpn * sizeof(uint64_t);

  if (lseek (fd, offset, SEEK_SET) == (off_t)-1) {
    perror("lseek");
    return 1;
  }
  if (read (fd, &entry, sizeof(entry)) != sizeof(entry)) {
    perror("read");
    return 1;
  }
  close(fd);
  
  printf ("--[%d:%s]----------------------------------\n",
      getpid(), title);
  printf ("Virtual Address   : %p\n", p); 
  printf ("Raw pagemap entry : 0x%016lx\n", entry);
  printf ("Present           : %d\n", (int)((entry >> 63) & 1));

  pfn = entry & ((1ULL << 55) - 1);
  printf("PFN               : 0x%lx\n", pfn);

  if (pfn != 0 && (int)((entry >> 63) & 1) == 1) {
    printf("Physical addr     : 0x%016lx\n",
       pfn * pagesize + (vaddr % pagesize));
  } else {
    printf("Physical addr     : Not assigned\n");
  }
  
  printf ("------------------------------------\n");
  return 0;
}

The function is pretty straightforward. Takes the virtual address we pass as parameter, calculates the virtual page number (VPN – just dividing by the page size) and then it calculates the offset of that page in the file, that is just the page number times the size of each entry, that is 64 bits. Then we just print the information for that entry as per the description in the kernel documentation (see above).

Let’s try

The mystery arises

Now that we can peek into the page table, let’s modify the main program to show this information. This is how it looked like:

int main ()
{
  printf ("Virtual Address: %p\n", &global_var);
  print_physical_mem ("FATHER", (uint8_t*)&global_var);
  // Create a new process
  if ((child = fork ()) < 0) {
    perror ("fork:");
    exit (EXIT_FAILURE);
  }
  if (child == 0) // Child process
    {
      print_physical_mem ("CHILD - BEFORE WRITE", (uint8_t*)&global_var);
      global_var ++;
      print_physical_mem ("CHILD - AFTER WRITE", (uint8_t*)&global_var);      
      printf ("CHILD: GLobal variable : %d\n", global_var);
      printf ("============================\n");
      return 0; // The child process finish
    }
  else // This is the father
    {
      sleep (5);
      printf ("FATHER: GLobal variable : %d\n", global_var);
      print_physical_mem ("FATHER", (uint8_t*)&global_var);
      printf ("..................................\n");
    }

  return 0;
}

I first call the function at the very beginning to show the initial page table entry (the one from the parent process). Then, in the child process, I print the entry again before and after modifying the variable. In the parent process, I wait 5 seconds to ensure the child has modified the global variable before printing again the page table.

What you would expect is that after the write in the global variable by the child process, a new physical page was assigned to it and the parent process will keep the original physical page. This is how the Copy-On-Write process works, right? Let’s see what do we get.

$ sudo ./process1
Virtual Address: 0x55b10616d070
--[1550034:FATHER]----------------------------------
Virtual Address   : 0x55b10616d070
Raw pagemap entry : 0x8180000000ad83fa
Present           : 1
PFN               : 0xad83fa
Physical addr     : 0x0000000ad83fa070
------------------------------------
--[1550035:CHILD - BEFORE WRITE]----------------------------------
Virtual Address   : 0x55b10616d070
Raw pagemap entry : 0x8180000000ad83fa
Present           : 1
PFN               : 0xad83fa
Physical addr     : 0x0000000ad83fa070
------------------------------------
--[1550035:CHILD - AFTER WRITE]----------------------------------
Virtual Address   : 0x55b10616d070
Raw pagemap entry : 0x8180000000ad83fa
Present           : 1
PFN               : 0xad83fa
Physical addr     : 0x0000000ad83fa070
------------------------------------
CHILD: GLobal variable : 2
============================
FATHER: GLobal variable : 1
--[1550034:FATHER]----------------------------------
Virtual Address   : 0x55b10616d070
Raw pagemap entry : 0x8180000001a97e21
Present           : 1
PFN               : 0x1a97e21
Physical addr     : 0x0000001a97e21070
------------------------------------
..................................

Of course, if you run this in your machine, you’ll get completely different numbers. Actually, you’ll get different numbers each time you run the program. But let’s look at each of these specific blocks one by one.

--[1550034:FATHER]----------------------------------
Virtual Address   : 0x55b10616d070
Raw pagemap entry : 0x8180000000ad83fa
Present           : 1
PFN               : 0xad83fa
Physical addr     : 0x0000000ad83fa070
------------------------------------

This is the initial page entry for the global_var virtual address located at 0x55b10616d070. This virtual address is at physical memory page 0xad83fa. So far, so good. Then we get the child print outs:

--[1550035:CHILD - BEFORE WRITE]----------------------------------
Virtual Address   : 0x55b10616d070
Raw pagemap entry : 0x8180000000ad83fa
Present           : 1
PFN               : 0xad83fa
Physical addr     : 0x0000000ad83fa070
------------------------------------
--[1550035:CHILD - AFTER WRITE]----------------------------------
Virtual Address   : 0x55b10616d070
Raw pagemap entry : 0x8180000000ad83fa
Present           : 1
PFN               : 0xad83fa
Physical addr     : 0x0000000ad83fa070
------------------------------------
CHILD: GLobal variable : 2

This is weird! The global variable is updated correctly, but the child process’s physical address isn’t updated. It still points to 0xad83fa. But the stranger thing comes after:

FATHER: GLobal variable : 1
--[1550034:FATHER]----------------------------------
Virtual Address   : 0x55b10616d070
Raw pagemap entry : 0x8180000001a97e21
Present           : 1
PFN               : 0x1a97e21
Physical addr     : 0x0000001a97e21070
------------------------------------

The parent process is the one that got the new physical page, even when the child process was the one causing the write page fault that fires the Copy-On-Write.

Further Investigation

That result didn’t make much sense; it looks like if, for whatever reason, the kernel was assigning a new page to the parent process even when the child process was the one writing to the page. That doesn’t make much sense but could happen. After all, I don’t really know how the kernel works at that level of details. So I modify once again my example and instead of using a global variable, I allocate my own data page to be sure I have control on who writes to that page.

The program now looks like this:

int main ()
{
  pid_t child;

  // Memory allocated
  int *p = mmap(NULL,
        4096,
        PROT_READ | PROT_WRITE,
        MAP_PRIVATE | MAP_ANONYMOUS,
        -1,
        0);

  print_physical_mem ("FATHER", (uint8_t*)p);
  *p = 42;
  print_physical_mem ("FATHER", (uint8_t*)p);
  // Create a new process
  if ((child = fork ()) < 0) {
    perror ("fork:");
    exit (EXIT_FAILURE);
  }
  if (child == 0) // Child process
    {
      print_physical_mem ("CHILD - BEFORE WRITE", (uint8_t*)p);
      *p = 43;
      printf ("CHILD: GLobal variable : %d\n", *p);
      print_physical_mem ("CHILD - AFTER WRITE", (uint8_t*)p);
      printf ("============================\n");
      return 0; // The child process finish
    }
  else // This is the father
    {
      sleep (5);
      printf ("FATHER: GLobal variable : %d\n", *p);
      print_physical_mem ("FATHER", (uint8_t*)p);
      printf ("..................................\n");
    }

  return 0;
}

Basically, the same program, but this time, the variable to modify is allocated using mmap, ensuring it’s in its own page. The output of this program is the following:

$ sudo ./process2
--[1554732:FATHER]----------------------------------
Virtual Address   : 0x7fef11d71000
Raw pagemap entry : 0x0080000000000000
Present           : 0
PFN               : 0x0
Physical addr     : Not assigned
------------------------------------
--[1554732:FATHER]----------------------------------
Virtual Address   : 0x7fef11d71000
Raw pagemap entry : 0x81800000014ed847
Present           : 1
PFN               : 0x14ed847
Physical addr     : 0x00000014ed847000
------------------------------------
--[1554733:CHILD - BEFORE WRITE]----------------------------------
Virtual Address   : 0x7fef11d71000
Raw pagemap entry : 0x80800000014ed847
Present           : 1
PFN               : 0x14ed847
Physical addr     : 0x00000014ed847000
------------------------------------
CHILD: GLobal variable : 43
--[1554733:CHILD - AFTER WRITE]----------------------------------
Virtual Address   : 0x7fef11d71000
Raw pagemap entry : 0x8180000001720ba6
Present           : 1
PFN               : 0x1720ba6
Physical addr     : 0x0000001720ba6000
------------------------------------
============================
FATHER: GLobal variable : 42
--[1554732:FATHER]----------------------------------
Virtual Address   : 0x7fef11d71000
Raw pagemap entry : 0x81800000014ed847
Present           : 1
PFN               : 0x14ed847
Physical addr     : 0x00000014ed847000
------------------------------------
..................................

The first thing to note is that, just after the mmap, there’s no physical memory assigned to the virtual address yet. Once we access that address (we wrote value 42 in that memory address), a physical page is assigned to it, in this case 0x14ed847. Now everything works as expected. The child process gets a new physical page after writing to the virtual address and the parent process keeps the original page.

So what happened with our initial example?

Unveiling the Mystery

When you hear what happened, you’ll immediately understand, but it took me a while. Actually, I had to go to bed, and the very next day, with a fresh mind, I see the problem very quickly. The issue is lazy binding.

Dynamic binaries in Linux usually do lazy binding. That means that external symbols are resolved only when needed, in other words, when the function is first called in the program. The symbol resolution implies an update of the GOT table of the program. Let’s take a look where that table is:

$ readelf -l process1

Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1100
There are 14 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x0000000000000310 0x0000000000000310  R      0x8
  INTERP         0x0000000000000394 0x0000000000000394 0x0000000000000394
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000890 0x0000000000000890  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x0000000000000571 0x0000000000000571  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x000000000000032c 0x000000000000032c  R      0x1000
  LOAD           0x0000000000002dd0 0x0000000000003dd0 0x0000000000003dd0
                 0x00000000000002a4 0x00000000000002a8  RW     0x1000
  (....)
  
 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .note.gnu.property .note.gnu.build-id .interp .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
   03     .init .plt .plt.got .text .fini
   04     .rodata .eh_frame_hdr .eh_frame .note.ABI-tag
   05     .init_array .fini_array .dynamic .got .got.plt .data .bss
   (...)

As you can see, the .data as well as the .got and .got.plt sections are all at segment 05, all in the same page. So what was happening was that the call to sleep to ensure that the child process had modified the global variable was firing another write (the GOT update) in the same page containing the global variable. In other words, when we call sleep in the parent process, we’re forcing a page fault in the parent and therefore it is the one getting the new physical page.

You can easily check this just by calling sleep once at the very beginning of the program. That way, the symbol is already resolved before creating the child process and won’t be any write later.

SUMMARY

There you go, a mystery case fired by an innocent sleep in our program to force some execution order. In this case, this wasn’t a concurrency-related problem. Actually, there was no problem at all, just something to understand, but it may be an example of the consequences of innocent function calls in a program that may look like innocuous but, unless you know what happens under the hood, will look like bad magic seen from outside. Hope you enjoyed the reading, it was a lot of fun for me to write the programs and this write-up :)

Related Posts

System Programming

The path to fully understand how your computer works under the hood

Read

SYSTEM PROGRAMMING GNU/LINUX WEB SECTION

Concurrency Introduction. Processes and Threads

Getting started with concurrent and real-time programming. Let's explore the foundations of processes and threads

Read

PROGRAMMING CONCURRENCY PROCESSES THREADS

Return to Home Page