← All parts

Windows Internals · Part 3 of 4

The File-to-Memory Stretch

May 31, 2026

What changes when 39 KB on disk becomes 68 KB in memory. Alignment, page permissions, and the surprising fact that Windows doesn't eagerly copy your executable into memory.

In Part 2 we took apart hello.exe on disk. We can name every byte of it — headers, section table, code, data, relocations, imports. We can translate between the three coordinate systems (file offset, RVA, virtual address). What we have not done is watch the file become a running program.

That transformation is stranger than it looks. A 39 KB file on disk becomes a 68 KB image in memory — bigger, somehow, even though the operating system has not added any code or data. Stranger still, the OS does not really copy the file into memory; it sets up some bookkeeping and lets the bytes come in lazily, only when the program touches them. And then there is a small magic trick that lets twenty processes share the same loaded DLL without stepping on each other's writable globals.

Three facts carry the whole story. On disk, sections pack tightly — at 512-byte boundaries, to save space. In memory, they spread out — to 4 KB boundaries, so the CPU can enforce permissions per page. And the mapping is lazy — bytes don't enter physical RAM until the program touches them.

By the end of this post you'll understand all three, you'll know exactly where the extra 30 KB comes from, and you'll have a mental model of what Windows is actually doing when you double-click a binary.

Two layouts, one binary

The PE format stores two alignment values in the Optional Header, and almost everything about the file-to-memory transformation follows from them.

FileAlignment in hello.exe is 0x200 — 512 bytes. Every section's content starts at a multiple of 512 on disk. Sections pack tightly, with at most 511 bytes of padding between any two.

SectionAlignment is 0x1000 — 4,096 bytes, the size of one memory page on x86-64. Every section's in-memory address is a multiple of 4 KB. Sections don't share pages. If a section's real content is 16 bytes (yes, really, some are), it still occupies a full 4 KB page in memory.

Why the difference? Two reasons, each tied to where the bytes physically live.

On disk, space matters. Our binary has ten sections, and several are tiny. .tls holds 16 bytes of real content. .CRT holds 96. .reloc holds 132. With 4 KB alignment on disk, each of those would balloon to 4,096 bytes. Across ten sections that's a 65% file-size penalty for no functional gain. The format picked the smaller alignment for disk.

In memory, permissions matter. The CPU only enforces memory permissions in 4 KB chunks (we'll see why in the next section). For each section to get its own permission set — .text executable, .data writable, .rdata read-only — each section has to live on its own pages. The format picked the larger alignment for memory.

The same binary, two layouts. Same code, same data — packed tight in one form, spread out in the other.

Here is the section table from hello.exe, straight from the file. Every number we work with in this post comes from this table; I'll keep pointing back to it.

Name     VirtualSize  VirtualAddr  SizeOfRaw   PtrRaw
.text         27,496   0x00001000     27,648   0x000400
.data            192   0x00008000        512   0x007000
.rdata         3,488   0x00009000      3,584   0x007200
.pdata         1,128   0x0000A000      1,536   0x008000
.xdata         1,292   0x0000B000      1,536   0x008600
.bss           2,944   0x0000C000          0   0x000000
.idata         1,744   0x0000D000      2,048   0x008C00
.CRT              96   0x0000E000        512   0x009400
.tls              16   0x0000F000        512   0x009600
.reloc           132   0x00010000        512   0x009800

Four numbers per section. VirtualSize is how much real content the section has. VirtualAddress is where it lives in memory (always page-aligned — every value ends in three hex zeros). SizeOfRawData is how much of the file the section occupies (rounded up to FileAlignment). PointerToRawData is the section's file offset.

One thing to notice immediately: .bss has SizeOfRawData = 0 and PointerToRawData = 0. It exists in memory but takes zero bytes on disk. We'll come back to why.

The picture below shows the two layouts side by side, drawn at the same scale, so you can see what the format is actually trading.

The alignment stretch: hello.exe on disk versus in memory Two vertical bars drawn at the same scale. The left bar represents the on-disk file at 39,424 bytes; sections are packed tightly with at most 511 bytes of file-alignment padding between them, and the .bss section is absent because it has no on-disk content. The right bar represents the in-memory image at 69,632 bytes; every section occupies a full 4096-byte page, and the .bss section is present as a zero-filled region. The image bar is 1.77 times taller than the file bar, with the extra height coming from alignment padding between sections and from the .bss region. The same code, the same data, in two different layouts. THE ALIGNMENT STRETCH hello.exe on disk vs in memory, same scale ON DISK 39,424 bytes FileAlignment = 0x200 (512 B) IN MEMORY 69,632 bytes SectionAlignment = 0x1000 (4 KB) headers · 1,024 B .text · 27,648 B .rdata · 3,584 B .pdata · 1,536 B .xdata · 1,536 B .idata · 2,048 B .CRT · .tls · .reloc .bss not present — no on-disk content MAPPED BY THE LOADER +30,208 bytes image is 1.77× the file size in memory 0x0000 0x1000 0x8000 0x9000 0xA000 0xB000 0xC000 0xD000 0xE000 0xF000 0x10000 0x11000 memory-only zero-filled headers .text .data .rdata .pdata .xdata .bss .idata .CRT .tls .reloc Same code, same data — packed tightly on disk, spread out on page boundaries in memory.
The two layouts drawn at the same scale. On disk, sections pack with at most 511 bytes of file-alignment padding between them; in memory, each section gets a full 4 KB page or more. The file's 39,424 bytes become 69,632 bytes in the running image — a difference of 30,208 bytes, every byte of it alignment gaps or memory-only zero-filled regions.

What's a process, anyway?

Before we go further, the words process and address space are going to do a lot of heavy lifting. Let's pin them down.

A process is a running instance of a program. When you double-click hello.exe, Windows creates a process to run it. If you double-click it again while the first one is still running, you get a second process — same code on disk, two independent runs, each with its own state. Two Notepad windows on your screen, two Chrome tabs in separate processes, two terminal sessions: every one of those is a separate process.

Each process gets its own private virtual address space — a range of memory addresses the program can refer to. The "virtual" matters. The addresses look real to the program (you can write a pointer, print its value, follow it), but they're not RAM addresses. The hardware translates each virtual address to wherever the operating system has decided to put the real bytes, and the OS can put them anywhere — or nowhere, until the program actually touches them. Each process's address space is its own. The address 0x140001000 in Process 1 has nothing to do with the address 0x140001000 in Process 2; they're two unrelated locations in two unrelated worlds.

A thread is a single line of execution inside a process. Every process has at least one — the "main" thread that runs the program's main function. Programs can spawn more threads to do work in parallel. All threads inside the same process share that process's address space; they look at the same memory, the same globals, the same mapped image. We won't lean on threads much in this post, but they matter in Part 4.

Here is the picture for two copies of hello.exe running side by side:

Two processes, each with its own address space A diagram showing two separate processes — labeled "instance 1" and "instance 2" of hello.exe — running at the same time. Each process has its own private virtual address space, shown as a vertical column. Each contains the same regions: the mapped image of hello.exe at the bottom, a stack growing downward from somewhere higher, a heap, and mapped DLLs. The same virtual address means different things in each process; addresses are not portable between processes. Both processes were created from the same hello.exe file on disk, shown beneath them with arrows. TWO PROCESSES, TWO ADDRESS SPACES Same program running twice — separate worlds PROCESS 1 first copy of hello.exe VIRTUAL ADDRESS SPACE high addresses Stack Heap Mapped DLLs Mapped hello.exe low addresses PROCESS 2 second copy of hello.exe VIRTUAL ADDRESS SPACE high addresses Stack Heap Mapped DLLs Mapped hello.exe low addresses SEPARATE WORLDS hello.exe single file on disk
Two processes, each running hello.exe. Each has its own private virtual address space, with the same regions arranged the same way: the mapped image of hello.exe, plus a stack, heap, and any DLLs the program loads. Both came from the single file on disk, but the running processes are independent — what one writes to its .data section doesn't affect the other.

From here on, when the post says "the process's address space" or "Process A and Process B," this is what it means.

Five more words we'll lean on

With process, address space, and thread established, five more terms deserve to be pinned down. They're the words I'll use over and over for the rest of this post, and getting them straight up front saves confusion later.

A page is the smallest chunk of memory the CPU can manage as a unit. On x86-64 a page is 4,096 bytes (= 0x1000 = 4 KB). The CPU sets permissions per page: a page can be read-only, or readable and executable, or readable and writable. It can't be "executable in the first half and read-only in the second half" — the granularity is the whole page.

One Windows-specific quirk worth knowing: Windows enforces permissions at 4 KB page granularity, but it allocates virtual address space at a coarser 64 KB granularity. VirtualAlloc, MapViewOfFile, and the image-mapping path all round their base addresses down to a 64 KB boundary. This is why preferred image bases like 0x140000000 are always 64 KB-aligned, and why ASLR picks new bases that are also 64 KB-aligned — even though the page-level protections inside the mapped image still change every 4 KB. You'll see the 64 KB number appear in tools like VMMap as the "allocation base," distinct from the 4 KB pages inside it.

A page is mapped in a process when the process has a valid virtual address for it. Being mapped is a bookkeeping fact: the OS has reserved that address and recorded what should appear there. It doesn't mean the bytes are in RAM. They might still be on disk; the OS just knows where to find them.

A mapped page is resident when its contents have actually been brought into physical RAM. Resident pages cost real memory; mapped-but-not-resident pages don't. The collection of pages a process has resident at this moment is its working set — usually a small fraction of the total mapped size.

And copy-on-write is the mechanism that lets several processes share the same physical page of memory as long as none of them writes to it. The first time any one process tries to modify the page, the kernel quietly gives that process its own private copy and lets it write to that. The other processes keep sharing the original. We'll cover this in detail in its own section.

Here is the single most important thing to take from this vocabulary list: mapped size is not RAM usage. The "69,632-byte image" we keep mentioning is the size of hello.exe's mapped virtual address space. The actual RAM that a running hello.exe uses for its image is almost always less — sometimes much less — because most of the pages get pulled into RAM only on demand. Internalize this distinction now and the rest of the post will land more cleanly.

Pages and permissions

Now we can answer the question that the alignment section left dangling. Why must sections live on their own pages?

Because the CPU enforces permissions per page, and only per page. A page can be readable, or readable and executable, or readable and writable — but not all three at once for ordinary code (the combination has a name, "RWX," and it's a known security smell). Permissions are encoded in the page table, the hardware data structure the CPU consults on every memory access. If a program tries to write to a read-only page, the CPU traps the write before any byte actually moves. If it tries to execute an instruction from a page that isn't marked executable, the CPU traps that too. This is the foundation of modern memory safety: the hardware refuses to do the wrong thing.

Now apply that constraint to a PE file. .text wants to be executable and read-only. .data wants to be writable. .rdata wants to be plain read-only. If two of those sections shared a page, the OS would have to grant the page both sets of permissions — which is exactly the dangerous combination the format is trying to avoid. So sections don't share pages. The linker pads every section out to the next 4 KB boundary, and the kernel applies one permission set to each page range. Simple, mechanical, and the entire reason for SectionAlignment = 0x1000.

Picture a sequence of pages from a mapped image:

Pages are the unit of permission enforcement A horizontal row of five 4KB pages from a generic mapped image, showing how each page gets one permission. The first page contains the headers and is marked read-only. The next two pages contain .text content and are marked read and execute. The fourth page contains .data content and is marked read and write. The fifth page contains .rdata content and is marked read-only. Below the row, a callout shows what would happen if .text and .data shared a page: the page would have to be marked read, write, and execute, the worst-case permission combination, defeating data-execution prevention. PAGES AND PERMISSIONS One page, one permission — the hardware enforces it FIVE CONSECUTIVE PAGES IN MEMORY PAGE 0 headers R−− RVA 0x0000 PAGE 1 .text R−X RVA 0x1000 PAGE 2 .text (cont.) R−X RVA 0x2000 PAGE 3 .data RW− RVA 0x3000 PAGE 4 .rdata R−− RVA 0x4000 4,096 bytes per page WHY SECTIONS CAN'T SHARE A PAGE If .text content and .data content lived on the same page, the OS would have to mark that page RWX to satisfy both — the worst case for security. An attacker who writes shellcode anywhere on that page could then execute it. DEP relies on each page having exactly one permission set, so each section needs its own pages.
The CPU enforces memory permissions at page granularity. Each page in a mapped image carries one permission set — read-only, read-execute, read-write, etc. — and the OS cannot mark half a page differently from the other half. Sections in a PE file must therefore live in disjoint page-ranges, which is why the linker pads every section up to a page boundary regardless of how little real content it carries.

Where the 30 KB comes from

We can now answer the first puzzle. The file is 39,424 bytes; the in-memory image is 69,632 bytes. The difference is 30,208 bytes. Where does it come from?

The answer has two parts. Most of it is alignment padding: each section's content gets rounded up to fill its 4 KB page (or pages), and the unused tail is zero. A small remainder comes from .bss — that one section with SizeOfRawData = 0 we noticed earlier. .bss exists only in memory.

The picture below makes both ideas concrete. For each section, it shows three things drawn to scale: the real content (in dark color), any file-alignment slack already on disk (medium shade), and the memory-only padding the loader has to add to bring the section up to its page boundary (pale, dashed outline). The number on the right is that section's contribution to the 30,208-byte total. Read row by row.

Where the 30 KB comes from, section by section A per-section breakdown. Each row is one section, drawn at the same total width so all sections are visually comparable. Within each row, the composition (dark for real content, medium for file-alignment slack already on disk, pale dashed for memory-only padding) shows where the section spends its page allocation. The .text section is overwhelmingly dark — it spans seven pages and uses them efficiently, contributing only 1 KB of memory padding. .bss is entirely pale — it has no on-disk presence and the whole 4 KB page is demand-zero. Tiny sections like .tls, .CRT, and .reloc are mostly pale because they hold very little real content but still occupy a full 4 KB page. Contributions sum to 30,208 bytes — the entire file-to-memory delta. WHERE THE 30 KB COMES FROM Section by section, every bar to the same scale LEGEND real content VirtualSize file padding in file, not real content memory-only padding contributes to the 30 KB SECTION PAGE ALLOCATION (all rows shown at the same width — composition tells the story) CONTRIBUTION headers +3,072 B .text 7 pages +1,024 B .data +3,584 B .rdata +512 B .pdata +2,560 B .xdata +2,560 B .bss +4,096 B .idata +2,048 B .CRT +3,584 B .tls +3,584 B .reloc +3,584 B total file: 39,424 bytes → memory: 69,632 bytes +30,208 B HOW TO READ A ROW Every bar represents one section's full page allocation, drawn at the same width. Most sections occupy a single 4 KB page; .text spans seven, and is labeled accordingly. Within each bar: dark is real content, medium is file-alignment slack already on disk, pale dashed is memory-only padding added by the loader. Compare .text (almost all dark — code uses its pages efficiently) with .tls (mostly pale — 16 bytes of real content in a 4 KB page). And .bss is entirely pale: zero on-disk, one 4 KB demand-zero page that materializes on first access.
Each section drawn at the same bar width so composition can be compared across rows. Dark portions are real content, medium portions are file-alignment slack already on disk, pale dashed portions are memory-only padding the loader adds. The contributions on the right add up to the 30,208-byte file-to-memory delta.

Two things land immediately from this picture.

Most of the 30 KB is alignment padding, not memory-only content. Of the 30,208 bytes, only the 4,096-byte .bss segment is a memory-only region. The other 26,112 bytes are gaps between sections.

Tiny sections pay disproportionately. .tls has 16 bytes of real content but occupies a full 4 KB page; same for .CRT with its 96 bytes, and .reloc with its 132. Each of these three sections individually contributes more memory-only bytes than .text, which holds 27,496 bytes of actual code. That's the cost of being able to set permissions per page. Real applications with dozens of sections pay this tax many times over — but on a 64-bit machine with terabytes of virtual address space, nobody cares.

One useful detail: the padding bytes aren't garbage. The Windows loader zero-fills the trailing portion of each section's last page, so the gaps appear as zeros in the running image. Nothing from elsewhere leaks in.

Mapping, not copying

Now the second puzzle. How does the file get into memory?

The intuitive answer — the one most tutorials give — is that the loader reads each section from disk and writes it into memory. That intuition is wrong. Windows doesn't copy the file into memory at load time. It does something stranger and cheaper.

The kernel sets up a mapping. It tells itself, in effect: "the bytes for these virtual addresses are at these file offsets — when somebody asks, fetch them." That's all. No section-body pages are eagerly copied into the process. The loader and memory manager read only the metadata they need to create the image section (headers, the section table, signature checks); executable and data pages get faulted in lazily as the program touches them. The image's virtual address range is reserved, the page permissions are configured, the entry point's address is computable — but the bulk of the actual bytes haven't moved yet.

The bytes come in lazily. The first time the program tries to execute an instruction or read from a memory address, the CPU notices the page isn't in RAM, raises a page fault, the kernel reads 4 KB from the file into a fresh physical page, and the program continues. This is called demand paging. It's the second of our three founding facts.

Two operations, not one

The mapping is actually built from two distinct steps, and it pays to see them separately.

Step 1: create an image section. The kernel opens hello.exe, parses its headers, and builds a record that describes the file as an executable image: where each PE section starts in the virtual layout, how big it is, what page permissions it needs. This record lives in the kernel and describes the file. It's not attached to any particular process yet. Windows calls this record an image section. (In the Windows kernel it's a section object created through NtCreateSection with the SEC_IMAGE flag; the data structures it owns are called a control area and a set of prototype PTEs — one per page. You don't need to remember those names to follow this post, but you'll see them in debuggers and in Windows Internals.)

Step 2: map a view of that section. Now the kernel takes the image section and attaches it to a specific process — installs the page-table bookkeeping in that process's address space so the process can refer to the image by virtual addresses. Windows calls this attachment a map view: a view of the image section, seen from inside one process. (From user-mode code this is what MapViewOfFile does; under the hood it's the syscall NtMapViewOfSection.)

Why split it in two? Because a single image section can be mapped into many processes at once. The kernel creates one image-section record per loaded file, and every process that loads that file gets its own map view onto the same record. That's the trick that makes DLL sharing cheap. The first process to load kernel32.dll causes the kernel to build the image section. Every subsequent process just gets a map view onto the existing record — no re-parsing, no duplicate state, and (because the file is the source of truth for all of them) no duplicate RAM either. We'll see in the next section what happens when one of those processes wants to write.

Three states of one mapping

To make this concrete, let's watch the same image at three moments in time. The left column shows what the process's address space contains; the right column shows the on-disk file. The columns stay fixed across all three states — what changes is what's inside them. Click the buttons to step forward.

State 1 is the boring initial condition: the file exists, the process exists, but nothing connects them yet. State 2 is the result of the mapping work — the kernel has built the bookkeeping that says "this virtual address comes from this file offset," but no actual content has been pulled into RAM. State 3 is the first time the program touches a mapped address; one page (and only one) gets brought into RAM by a page fault, while the rest stay on disk.

MAPPING IS BOOKKEEPING
From file on disk to running image — three states
Process address space
(empty)

no pages reserved
no permissions set
hello.exe on disk
headers
.text .data .rdata
.pdata .xdata .idata
.bss (no on-disk data)
.CRT .tls .reloc
file is just bytes on disk
Before mapping. The file exists on disk. The process has been created but nothing has been set up yet — no virtual addresses reserved, no permissions configured. The file is just bytes sitting in storage.
Three states of the same memory mapping. Click the buttons above to step through them; the columns stay put, only what's inside changes.

The consequences are striking once you let them land.

Code paths the program never executes may never become resident in this process's working set. The error-handling code that only runs when a system call fails sits on disk, indexed but unloaded, until the failure happens. If the failure never happens during this process's life, those bytes may never be paged in for this process. The same goes for DLLs that get loaded but never called, for string constants the program never reads, for TLS initialization data for threads that never spawn. The image is fully mapped — the virtual address space is fully reserved — but the actual RAM footprint is often a small fraction of the mapped size.

"May never" rather than "never" because real systems blur the lines. Windows' SuperFetch / SysMain service watches launch patterns and prefetches pages of frequently-launched applications speculatively, the cache manager pulls in pages around the ones the program touches (read-ahead), an EDR or AV scan can fault entire executables in for inspection at load time, and memory compression can keep "evicted" pages around in a compressed form. The architectural truth — pages don't enter the working set until something causes them to — holds. The real-system behaviour is just messier than the bare model suggests.

This is the difference between mapped and resident that we pinned down in the vocabulary section, in concrete terms. A process can have a 68 KB mapped image but only 12 KB of it resident in RAM at any moment.

It's also why "warm" launches feel snappier than "cold" launches. First launch after boot: every page has to be faulted in from disk. Second launch: most of those pages are still cached in RAM from last time, so the faults are satisfied without disk I/O. Same program, same code path, very different perceived speed.

Sharing without conflict

The mapping story has a puzzle hidden in it. If every process that loads kernel32.dll shares its physical pages, and kernel32.dll has a writable .data section with global variables, what stops Process A from overwriting Process B's globals? They're literally looking at the same memory.

The mechanism that resolves this is called copy-on-write, and it's elegant.

When the kernel maps a writable image-page, it doesn't actually mark the page writable. It marks the page shared and read-only, with a special note: "if anyone tries to write here, don't just refuse — call me." Process A and Process B can read the page freely. Both see the same bytes, both share the same physical RAM. So far, no problem.

The interesting moment is when one of them writes. Process A executes a write instruction, but the page is marked read-only, so the CPU traps before any byte hits memory. The kernel's page-fault handler runs. It looks at the trap, sees the page is supposed to be writable (just shared until now), and does something quiet: it allocates a fresh physical page, copies the contents of the shared page into it, points Process A's bookkeeping at the new page, and marks the new page genuinely read-write. Then the kernel returns and lets the original write instruction complete — to the new page, not the shared one.

From the program's perspective this is completely invisible. The program writes to its global variable. The value sticks. The variable is private to this process. Whether the page was shared with thirty other processes a moment ago or already private — that's the kernel's business, not the program's.

Three states tell the whole story. The middle state — the moment of the write — is where the trick actually happens:

COPY-ON-WRITE
Shared until someone writes — then private to that someone
PROCESS A
hello.exe · pid 4520
reading the shared pageshared, read-only
PROCESS B
hello.exe · pid 7831
reading the shared pageshared, read-only
no private copies
(yet)
PHYSICAL PAGE X
PFN 0x1A4F2 · msvcrt.dll .data
shared by both pids
Shared, no writes yet. Both processes load the same DLL. The kernel maps the writable section as copy-on-write — shared in the meantime, but the kernel will do something special the moment anyone tries to write. Both processes see the same physical bytes; they're literally looking at the same RAM. No private copies exist yet.
Three states of copy-on-write. The middle state — the moment of the write — is where the kernel quietly switches one process to a private copy.

Copy-on-write is one of the cleverest mechanisms in modern operating systems, and almost every reader has been quietly using it for years without thinking about it. Every time you run two copies of a program, every time the same DLL gets loaded into a dozen processes, the same trick is doing the same work. The OS lies a little about sharing, then quietly stops lying the moment the lie becomes a problem.

The scale of what this saves is worth pausing on. A 100 MB DLL loaded by fifty processes does not consume 5 GB of RAM. The physical pages backing the DLL's .text are loaded once and referenced fifty times; each process pays only the cost of its own per-process page-table entries — a few bytes per virtual page. Two processes calling the same function in ntdll.dll are literally executing the same physical bytes; the MMU just translates their different virtual addresses to the same physical location. The writable sections start out shared the same way and only diverge under copy-on-write. Without this mechanism, the modern Windows desktop — with dozens of processes simultaneously mapping ntdll, kernel32, user32, gdi32, and the rest — would not fit in RAM.

(There is one subtle exception worth knowing in passing: the PE format has a flag that asks for a section to be actually shared across processes — writes from one process become visible to all the others. Modern toolchains don't produce these, because they're a cross-process write surface useful to attackers. If you see one in a binary you're analyzing, look closer.)

Page permissions, section by section

We've covered the mechanism. Now the result. After the kernel finishes mapping hello.exe, here's what each section's pages look like:

Section   Initial permission        Notes
─────────────────────────────────────────────────────────────────────
headers   read-only                 the PE headers, readable at runtime
.text     read + execute            seven pages of x86-64 code
.data     copy-on-write             becomes plain read-write after first write
.rdata    read-only                 string constants, const data
.pdata    read-only                 exception tables
.xdata    read-only                 unwind metadata
.bss      read-write (demand-zero)  no file backing; materializes as zeroes
.idata    copy-on-write             loader patches the IAT here at startup
.CRT      copy-on-write             C runtime init data
.tls      copy-on-write             TLS template
.reloc    read-only (discardable)   freed after relocations applied

These protections aren't arbitrary or kernel-determined — each one is derived from the section's Characteristics field, which we walked through in Part 2. The header's IMAGE_SCN_MEM_EXECUTE, IMAGE_SCN_MEM_READ, and IMAGE_SCN_MEM_WRITE bits map almost mechanically onto the runtime page flags the kernel installs. The PE file already declares what protection each section deserves; the kernel just honors that declaration when it sets up the page tables.

A few things to notice.

Read-only is the default for non-code. Strings, exception tables, unwind metadata — none of these should ever be modified. Marking them read-only means an accidental write fails loudly with an access violation instead of silently corrupting the program.

Code is read + execute, never write. Writable code is a security smell. Modern compilers don't produce it. If you see .text with write permission, the binary is doing something unusual (a self-modifying loader, an obfuscator, a JIT).

Writable sections start as copy-on-write. They become genuinely writable for a process only after that process actually writes to them. This is what lets two processes share the same loaded DLL without trampling each other.

And .bss is the special case we've been promising. It has no file backing — SizeOfRawData = 0, remember? When the kernel sets up the mapping for the .bss page range, it marks those pages "no file; materialize as zeros on first access." On first read, the kernel allocates a fresh physical page, zero-fills it, and hands it over. This is the mechanism that fulfills C's old promise: uninitialized globals start as zero. No code zeroes them; the page just arrives that way.

.reloc has a quieter special case worth knowing. Its Characteristics field has the IMAGE_SCN_MEM_DISCARDABLE bit set, which tells the loader: once you've finished using me, you can throw me away. After the loader walks the Base Relocation Table and patches the absolute addresses scattered through the image (Part 4), the original relocation entries are no longer needed for anything. Windows is free to reclaim that page and reuse it for something else. A nice cleanup detail: the work the section exists to support happens once, at load time, and then the bytes are gone.

Here is the full page-by-page map of hello.exe in memory:

The seventeen pages of hello.exe in memory A vertical strip showing all 17 pages of the mapped image, from RVA 0x0000 at the top to RVA 0x10000 at the bottom. Each page is one row, labeled with its RVA, its source section, and its initial page protection. Page 0 holds the headers and is read-only. Pages 1 through 7 hold .text and are read-execute. Page 8 is .data, marked PAGE_WRITECOPY so it shares with other processes until written. Page 9 is .rdata, page 10 is .pdata, page 11 is .xdata — all read-only. Page 12 is .bss, demand-zero and not backed by the file. Page 13 is .idata, marked WRITECOPY because the loader will patch the IAT into it. Pages 14 and 15 are .CRT and .tls, also WRITECOPY. Page 16 is .reloc, mapped read-only and discarded after the loader applies relocations. EVERY PAGE, EVERY PERMISSION hello.exe in memory: 17 pages, page by page RVA SECTION PROTECTION NOTES 0x0000 headers PAGE_READONLY PE headers, readable at runtime 0x1000 .text PAGE_EXECUTE_READ entry point lives here 0x2000 .text PAGE_EXECUTE_READ 0x3000 .text PAGE_EXECUTE_READ 0x4000 .text PAGE_EXECUTE_READ seven pages of x86-64 code 0x5000 .text PAGE_EXECUTE_READ 0x6000 .text PAGE_EXECUTE_READ 0x7000 .text PAGE_EXECUTE_READ last 0x400 bytes zero-padded 0x8000 .data PAGE_WRITECOPY becomes RW on first write 0x9000 .rdata PAGE_READONLY string constants, TLS template 0xA000 .pdata PAGE_READONLY exception tables 0xB000 .xdata PAGE_READONLY unwind metadata for .pdata 0xC000 .bss PAGE_READWRITE demand-zero, not file-backed 0xD000 .idata PAGE_WRITECOPY loader patches IAT here at startup 0xE000 .CRT PAGE_WRITECOPY C runtime initializers 0xF000 .tls PAGE_WRITECOPY TLS directory 0x10000 .reloc PAGE_READONLY discardable after relocations applied 0x11000 end of mapped image 17 pages, 68 KB of virtual address space, varying amounts of physical memory depending on what runs.
Every page of hello.exe as it lives in memory, with its initial protection. .text is seven pages of executable code; everything else is one page each. Writable sections start as PAGE_WRITECOPY and become private on first write. The .bss page is demand-zero and has no file backing. .reloc is released once relocations are applied.

From double-click to mapped image

One last picture to tie everything together: the sequence of events when you double-click hello.exe, up to the moment the image is mapped and the user-mode loader takes over (which is Part 4's job).

From double-click to mapped image: the kernel-side timeline A vertical timeline of eight steps. Step 1: user double-clicks the executable. Step 2: CreateProcess runs in user mode. Step 3: NtCreateUserProcess transitions into the kernel. Step 4: the kernel opens the file. Step 5: the kernel creates an image section, parsing the PE headers and building a control area. Step 6: the kernel reserves SizeOfImage bytes of virtual address space at a base. Step 7: the kernel maps a view by installing per-process page table entries and applying per-section protections. Step 8: the kernel hands off to ntdll for the user-mode loader to finish the job in Part 4. Each step is colored to indicate whether it executes in user mode or kernel mode. FROM DOUBLE-CLICK TO MAPPED IMAGE Eight steps inside the kernel-side mapping phase user mode user→kernel transition kernel mode kernel → user handoff 1 Double-click You click the icon. Explorer.exe asks Windows to launch the binary. 2 CreateProcess The user-mode CreateProcess API in kernel32.dll prepares the launch and traps into the kernel. 3 NtCreateUserProcess The kernel takes over. Everything from here through step 7 happens in kernel mode. 4 Open the file The kernel opens hello.exe and gets a handle that will outlive this call — the running image will be backed by this file for the process's lifetime. 5 Create the image section Parse the PE headers (validate machine type, signatures, alignments). Build a control area with one subsection per PE section and one prototype PTE per page. This is the SEC_IMAGE path. 6 Reserve virtual address space Allocate a new process object. Reserve SizeOfImage bytes of virtual address space at the preferred ImageBase (or at a randomized address if ASLR is in effect). 7 Map the view Install per-process PTEs that reference the control area's prototype PTEs. Apply per-section page protections — PAGE_EXECUTE_READ for .text, PAGE_WRITECOPY for .data, and so on. 8 Hand off to ntdll Kernel returns. The image is mapped — virtual addresses valid, permissions set — but not yet runnable. Only the PE metadata has been read so far; section bodies fault in lazily. Part 4 picks up from here.
The kernel-side mapping phase, end to end. Steps 1 and 2 are in user mode (Explorer asking for a launch; kernel32.dll preparing the request). Step 3 transitions into the kernel. Steps 4 through 7 are the kernel doing the work we have described in this post: opening the file, creating the image section, reserving virtual address space, and mapping a view of the section into the new process. Step 8 hands control back to user mode, where ntdll's loader takes over for Part 4.

Steps 1 and 2 are in user mode, where Explorer asks Windows for a launch and kernel32.dll prepares the request. Step 3 is the transition into the kernel — everything from there through step 7 is kernel work. Step 8 hands control back to user mode, where the user-mode loader takes over to do all the work that's still missing (which is a lot — see the wrap-up).

The single sentence to remember: mapping is two operations — set up the bookkeeping that says "this file becomes this image," then attach that bookkeeping to a process. Everything else in steps 4 through 7 is the kernel doing the homework around those two ideas.

Try it yourself, on Windows

Inspect the mapping of a running process

The things we've described — page protections, working sets, copy-on-write state — only exist on a running Windows process, so the experiments here need an actual Windows machine. Two free Sysinternals-class tools are worth installing.

VMMap shows the virtual address space of a running process in a hierarchical view. Attach it to Notepad and expand any "Image" row to see the per-section protections that match what we tabulated for hello.exe. The Details pane indicates which pages are resident versus just reserved.

Process Hacker (now System Informer) has a Memory tab on every process's properties dialog. Its "Shared" column reflects copy-on-write status — Shared means still on the shared backing, Private means already copy-on-written.

The single experiment worth running: launch the same program twice, open the Memory tab for both instances, and check the Shared column on each writable section. Most pages will still be shared between the two — copy-on-write in action.

For a closer look at the kernel side, WinDbg in kernel-debugging mode lets you inspect the data structures behind everything we've described. !ca dumps a control area; !pte walks the page tables for a virtual address; !vad lists the Virtual Address Descriptors that describe each mapped region. This is more setup work — you need a kernel-debugger connection to a target VM — but it shows the section objects, prototype PTEs, and per-process page tables as actual bytes in kernel memory. If "image section" and "prototype PTE" still feel like abstractions after this post, looking at them in the debugger turns them concrete fast.

What we have at the end of Part 3

Recall the three facts we opened with. Tight on disk. Spread to pages in memory. Mapping is lazy. Now you know all three concretely.

You also know exactly where the 30 KB difference between file and image comes from. The bulk of it — 23,040 bytes — is per-section page-padding: every section gets rounded up to fill its 4 KB page allocation, and small sections pay a disproportionate tax. .tls's 16 bytes of content takes a full 4 KB page; .reloc's 132 bytes takes another. Add up that rounding across all ten sections and you reach 23 KB. Another 4,096 bytes come from .bss, which has no on-disk presence at all and materializes as a zero-filled page on first access. The remaining 3,072 bytes are the headers region rounding up — the PE headers occupy 1,024 bytes on disk but get a full 4 KB page in memory. 23,040 + 4,096 + 3,072 = 30,208. The whole difference accounted for. None of the extra bytes is new content; they're the consequence of a layout optimized for page-level permission enforcement.

You know that mapping is not copying. The kernel manufactures a record describing how each page of the image relates to bytes in the file, attaches that record to a new process, and lets the memory manager pull pages into RAM only when the program touches them. DLLs loaded by many processes share their pages. Writable sections start out shared and become private through copy-on-write on first write.

But the mapped image still can't run. Several things are still missing.

If the binary didn't load at its preferred ImageBase — and on modern Windows with ASLR enabled, it usually does not — the absolute addresses baked into the code by the linker are wrong, and the Base Relocation Table has to drive a sweep through the image patching every one of them. The Import Address Table is still full of placeholder thunk entries; the loader has to walk the import descriptors, load each required DLL (recursively, because they have their own imports), resolve each imported function, and overwrite the IAT entries with real function pointers. TLS callbacks need to run before the entry point. The DLL search order has to be followed correctly so dependencies resolve to the intended modules, not attacker-controlled lookalikes earlier in the path. None of that has happened yet.

That is Part 4.