<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Python on Welcome to Christophe Nasarre's Blog</title><link>https://chrisnas.github.io/tags/python/</link><description>Recent content in Python on Welcome to Christophe Nasarre's Blog</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Thu, 09 Jul 2026 09:00:00 +0000</lastBuildDate><atom:link href="https://chrisnas.github.io/tags/python/index.xml" rel="self" type="application/rss+xml"/><item><title>How to map the address space of a process on Windows and Linux</title><link>https://chrisnas.github.io/posts/2026-07-09_how-to-map-the-address/</link><pubDate>Thu, 09 Jul 2026 09:00:00 +0000</pubDate><guid>https://chrisnas.github.io/posts/2026-07-09_how-to-map-the-address/</guid><description>From a 2007 Windows via C/C++ VMMap demo to a modern Windows 11 analyzer and its Linux /proc/smaps counterpart - and why Working Set, RSS/PSS and &amp;#39;committed&amp;#39; memory do not mean the same thing on the two operating systems.</description><content:encoded><![CDATA[<hr>
<p>During a recent Datadog event, how to get detailed memory usage on both Windows and Linux was at the center of discussions. One of the goals is to be able to figure out how to improve the memory footprint of our profilers. It happens that, back in 2007, when Jeff Richter and I wrote the 5th edition of <em>Windows via C/C++</em>, chapter 14 shipped a tiny sample called <code>14-VMMap</code>: a ~560-line GUI demo that walked a process address space with <code>VirtualQueryEx</code> and dumped every region into a list box. It was a teaching tool - just enough to <em>see</em> how reserve/commit and the different storage types are laid out in memory.</p>
<p>I dusted it off, and one thing led to another: I first modernized it into a console tool I called <strong>ASMap</strong> that gets close to Sysinternals <a href="https://learn.microsoft.com/en-us/sysinternals/downloads/vmmap">VMMap</a> parity on Windows 11, and then I asked Cursor with Opus 4.8 to port the <em>idea</em> to Linux on top of <code>/proc/&lt;pid&gt;/smaps</code>. The surprising part was not the code - it was discovering how differently the two operating systems account for memory, to the point where the &ldquo;same&rdquo; column names (Working Set, Private, Committed) mean genuinely different things.</p>
<p>This post presents the different steps of the journey: the original allocation-base walk, the Windows modernization, and the Linux counterpart with the conceptual gaps that really matter. The C++ tool and the Python script are both available in my <a href="https://github.com/chrisnas/ASMap">ASMap GitHub repository</a>.</p>
<h2 id="a-process-address-space-101">A process address space 101</h2>
<p>Two OSes, two vocabularies, but the same underlying idea: at some point, &ldquo;everything&rdquo; is accessible via a pointer, an address; hence the name <em>address space</em>. On Windows the model is two levels deep:</p>
<ul>
<li>A <strong>region</strong> is everything that shares a single allocation base (one <code>VirtualAlloc</code>, one mapped file, one loaded image).</li>
<li>A region is subdivided into <strong>blocks</strong>, pages with uniform state and protection - exactly one <code>MEMORY_BASIC_INFORMATION</code> entry.</li>
<li>Each block is in one of three <strong>states</strong>: <code>MEM_FREE</code>, <code>MEM_RESERVE</code> (address space claimed, no backing), or <code>MEM_COMMIT</code> (backing promised).</li>
</ul>
<p>That gives the nesting that the rest of this post keeps coming back to:</p>
<pre tabindex="0"><code>Reserved (MEM_RESERVE)  ⊇  Committed (MEM_COMMIT)  ⊇  Working Set (resident in RAM)
</code></pre><p>Keep that chain in mind - the whole Windows-vs-Linux comparison at the end is really a story about how each OS treats those three levels.</p>
<h2 id="the-2007-original-a-region-base-walk-with-virtualqueryex">The 2007 original: a region-base walk with <code>VirtualQueryEx</code></h2>
<p>Two nested loop are at the heart of the original tool. The outer one starts from address 0 and search for regions with <code>VirtualQueryEx</code>. Then for a given region, an inner loop consolidates blocks within that region.</p>
<p>Readers with keen eyes will identify, in the original code, an unneeded call to <code>VirtualQueryEx</code> and I realized it myself just now!</p>
<p>Next, it grabs the <code>AllocationBase</code>, then walks <code>VirtualQueryEx</code> forward as long as the allocation base does not change, summing sizes and counting blocks:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span><span class="lnt">18
</span><span class="lnt">19
</span><span class="lnt">20
</span><span class="lnt">21
</span><span class="lnt">22
</span><span class="lnt">23
</span><span class="lnt">24
</span><span class="lnt">25
</span><span class="lnt">26
</span><span class="lnt">27
</span><span class="lnt">28
</span><span class="lnt">29
</span><span class="lnt">30
</span><span class="lnt">31
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-cpp" data-lang="cpp"><span class="line"><span class="cl"><span class="c1">// VMQueryHelp - the 2007 core (initialization and error handling trimmed)
</span></span></span><span class="line"><span class="cl"><span class="k">static</span> <span class="n">BOOL</span> <span class="nf">VMQueryHelp</span><span class="p">(</span><span class="n">HANDLE</span> <span class="n">hProcess</span><span class="p">,</span> <span class="n">LPCVOID</span> <span class="n">pvAddress</span><span class="p">,</span> <span class="n">VMQUERY_HELP</span> <span class="o">*</span><span class="n">pVMQHelp</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">MEMORY_BASIC_INFORMATION</span> <span class="n">mbi</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">VirtualQueryEx</span><span class="p">(</span><span class="n">hProcess</span><span class="p">,</span> <span class="n">pvAddress</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">mbi</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">mbi</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">PVOID</span> <span class="n">pvRgnBaseAddress</span> <span class="o">=</span> <span class="n">mbi</span><span class="p">.</span><span class="n">AllocationBase</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">PVOID</span> <span class="n">pvAddressBlk</span>     <span class="o">=</span> <span class="n">pvRgnBaseAddress</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="n">pVMQHelp</span><span class="o">-&gt;</span><span class="n">dwRgnStorage</span> <span class="o">=</span> <span class="n">mbi</span><span class="p">.</span><span class="n">Type</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(;;)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">VirtualQueryEx</span><span class="p">(</span><span class="n">hProcess</span><span class="p">,</span> <span class="n">pvAddressBlk</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">mbi</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">mbi</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">mbi</span><span class="p">.</span><span class="n">AllocationBase</span> <span class="o">!=</span> <span class="n">pvRgnBaseAddress</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</span><span class="p">;</span>   <span class="c1">// stepped into the next region; stop
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">pVMQHelp</span><span class="o">-&gt;</span><span class="n">dwRgnBlocks</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">pVMQHelp</span><span class="o">-&gt;</span><span class="n">RgnSize</span> <span class="o">+=</span> <span class="n">mbi</span><span class="p">.</span><span class="n">RegionSize</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">((</span><span class="n">mbi</span><span class="p">.</span><span class="n">Protect</span> <span class="o">&amp;</span> <span class="n">PAGE_GUARD</span><span class="p">)</span> <span class="o">==</span> <span class="n">PAGE_GUARD</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">pVMQHelp</span><span class="o">-&gt;</span><span class="n">dwRgnGuardBlks</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="c1">// &#34;best guess&#34; storage type: MEM_PRIVATE can be overridden by
</span></span></span><span class="line"><span class="cl">        <span class="c1">// MEM_IMAGE or MEM_MAPPED as soon as a committed block reveals it.
</span></span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">pVMQHelp</span><span class="o">-&gt;</span><span class="n">dwRgnStorage</span> <span class="o">==</span> <span class="n">MEM_PRIVATE</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">pVMQHelp</span><span class="o">-&gt;</span><span class="n">dwRgnStorage</span> <span class="o">=</span> <span class="n">mbi</span><span class="p">.</span><span class="n">Type</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="c1">// address of the next block
</span></span></span><span class="line"><span class="cl">        <span class="n">pvAddressBlk</span> <span class="o">=</span> <span class="p">(</span><span class="n">PVOID</span><span class="p">)((</span><span class="n">PBYTE</span><span class="p">)</span><span class="n">pvAddressBlk</span> <span class="o">+</span> <span class="n">mbi</span><span class="p">.</span><span class="n">RegionSize</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">...</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Three design decisions need to be detailed, because the modern tool keeps all of them:</p>
<ul>
<li><strong>Reserved blocks inherit the region&rsquo;s protection.</strong> For an uncommitted block <code>mbi.Protect</code> is meaningless, so the code shows <code>mbi.AllocationProtect</code> instead.</li>
<li><strong>The storage type is a guess.</strong> A region starts as <code>MEM_PRIVATE</code> and gets promoted to <code>MEM_IMAGE</code>/<code>MEM_MAPPED</code> the moment a committed block reveals its real type. That works for committed regions but is unreliable for a <em>reserved-only</em> region, which has no committed block to read a type from.</li>
<li><strong>Stacks are detected by their guard page.</strong> After the walk, a region is declared as a thread stack if it saw at least one <code>PAGE_GUARD</code> block:</li>
</ul>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-cpp" data-lang="cpp"><span class="line"><span class="cl"><span class="c1">// Windows Vista+: assume a stack if the region has &gt;= 1 guard block.
</span></span></span><span class="line"><span class="cl"><span class="n">pVMQHelp</span><span class="o">-&gt;</span><span class="n">bRgnIsAStack</span> <span class="o">=</span> <span class="p">(</span><span class="n">pVMQHelp</span><span class="o">-&gt;</span><span class="n">dwRgnGuardBlks</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">);</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The result was fed into a maximized list box with a few tab stops, plus <em>Refresh</em>, <em>Expand regions</em>, and <em>Copy to clipboard</em> buttons. For each region it printed just the <strong>address, storage type, size, block count, and protection</strong>, and tried to resolve a module or mapped-file path. Perfect simple sample for a book.</p>
<h2 id="what-the-original-code-could-not-tell-you">What the original code could not tell you</h2>
<p>The moment you try to use it as a real diagnostic tool like VMMap, the gaps jump out:</p>
<table>
  <thead>
      <tr>
          <th>Question</th>
          <th><code>14-VMMap</code> (2007)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>How much of this is actually in RAM?</td>
          <td><em>no working-set data at all</em></td>
      </tr>
      <tr>
          <td>How much is committed vs merely reserved?</td>
          <td><em>only a single &ldquo;size&rdquo;</em></td>
      </tr>
      <tr>
          <td>Which thread owns this stack?</td>
          <td><em>guessed from a guard page</em></td>
      </tr>
      <tr>
          <td>Where are the PEB / TEB / heaps?</td>
          <td><em>not labeled</em></td>
      </tr>
      <tr>
          <td>Which PE section is this block?</td>
          <td><em>not labeled</em></td>
      </tr>
      <tr>
          <td>Does it handle a 32-bit (WOW64) target?</td>
          <td><em>no</em></td>
      </tr>
      <tr>
          <td>Scriptable output?</td>
          <td><em>list box + clipboard</em></td>
      </tr>
  </tbody>
</table>
<p>That list was my to-do list for a new Address Space Map tool; a.k.a. ASMap.</p>
<h2 id="modernizing-into-asmap-on-windows-11">Modernizing into ASMap on Windows 11</h2>
<p>ASMap keeps the allocation-base walk almost verbatim - same loop, same <code>protect</code>/<code>type</code> handling - but wraps it in a proper model (<code>Target</code>,<code>Region</code>, <code>Block</code>, a <code>Category</code> enum). The <code>Target</code> class abstracts the process to map with its handle that is used to walk the <strong>entire</strong> address space range (min and max addresses are retrieved with <code>GetNativeSystemInfo</code>) so that free gaps are enumerated too:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span><span class="lnt">18
</span><span class="lnt">19
</span><span class="lnt">20
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-cpp" data-lang="cpp"><span class="line"><span class="cl"><span class="n">AddressSpace</span> <span class="nf">BuildAddressSpace</span><span class="p">(</span><span class="k">const</span> <span class="n">Target</span><span class="o">&amp;</span> <span class="n">t</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">AddressSpace</span> <span class="n">as</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kt">uint64_t</span> <span class="n">addr</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint64_t</span><span class="p">)</span><span class="n">t</span><span class="p">.</span><span class="n">MinAddress</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="kt">uint64_t</span> <span class="n">maxAddr</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint64_t</span><span class="p">)</span><span class="n">t</span><span class="p">.</span><span class="n">MaxAddress</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="p">(</span><span class="n">addr</span> <span class="o">&lt;=</span> <span class="n">maxAddr</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">Region</span> <span class="n">region</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">QueryRegion</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="p">(</span><span class="k">const</span> <span class="kt">void</span><span class="o">*</span><span class="p">)</span><span class="n">addr</span><span class="p">,</span> <span class="n">region</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="n">addr</span> <span class="o">+=</span> <span class="n">t</span><span class="p">.</span><span class="n">PageSize</span><span class="p">();</span>   <span class="c1">// inaccessible page: step and retry
</span></span></span><span class="line"><span class="cl">            <span class="k">continue</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="kt">uint64_t</span> <span class="n">next</span> <span class="o">=</span> <span class="n">region</span><span class="p">.</span><span class="n">End</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">        <span class="n">as</span><span class="p">.</span><span class="n">regions</span><span class="p">.</span><span class="n">push_back</span><span class="p">(</span><span class="n">std</span><span class="o">::</span><span class="n">move</span><span class="p">(</span><span class="n">region</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">next</span> <span class="o">&lt;=</span> <span class="n">addr</span><span class="p">)</span> <span class="k">break</span><span class="p">;</span>    <span class="c1">// overflow guard at top of space
</span></span></span><span class="line"><span class="cl">        <span class="n">addr</span> <span class="o">=</span> <span class="n">next</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">as</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The returned <code>AddressSpace</code> stores the regions in a <code>std::vector&lt;Regions&gt;</code>. From there, the details missing from the original are added in a second pass, region by region.</p>
<h3 id="authoritative-region-typing">Authoritative region typing</h3>
<p>Instead of <em>guessing</em> the storage type from block types, ASMap uses the under-documented <code>NtQueryVirtualMemory(MemoryRegionInformation)</code> function that returns authoritative type flags (and the true commit size), which override the block-walk guess - crucial for reserved-only regions:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span><span class="lnt">8
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-cpp" data-lang="cpp"><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">info</span><span class="p">.</span><span class="n">RegionType</span> <span class="o">&amp;</span> <span class="n">ASMAP_MEM_REGION_MAPPED_IMAGE</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">region</span><span class="p">.</span><span class="n">type</span> <span class="o">=</span> <span class="n">MEM_IMAGE</span><span class="p">;</span>   <span class="n">region</span><span class="p">.</span><span class="n">cat</span> <span class="o">=</span> <span class="n">Category</span><span class="o">::</span><span class="n">Image</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="nf">if</span> <span class="p">(</span><span class="n">info</span><span class="p">.</span><span class="n">RegionType</span> <span class="o">&amp;</span> <span class="n">ASMAP_MEM_REGION_MAPPED_DATAFILE</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">region</span><span class="p">.</span><span class="n">type</span> <span class="o">=</span> <span class="n">MEM_MAPPED</span><span class="p">;</span>  <span class="n">region</span><span class="p">.</span><span class="n">cat</span> <span class="o">=</span> <span class="n">Category</span><span class="o">::</span><span class="n">MappedFile</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="nf">if</span> <span class="p">(</span><span class="n">info</span><span class="p">.</span><span class="n">RegionType</span> <span class="o">&amp;</span> <span class="n">ASMAP_MEM_REGION_PRIVATE</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">region</span><span class="p">.</span><span class="n">type</span> <span class="o">=</span> <span class="n">MEM_PRIVATE</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">region</span><span class="p">.</span><span class="n">cat</span> <span class="o">==</span> <span class="n">Category</span><span class="o">::</span><span class="n">Reserved</span><span class="p">)</span> <span class="n">region</span><span class="p">.</span><span class="n">cat</span> <span class="o">=</span> <span class="n">Category</span><span class="o">::</span><span class="n">Private</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The raw <code>MEM_*</code> constants are replaced by a semantic <code>Category</code> (<code>Free, Reserved, Image, MappedFile, Private, Heap, Stack, Teb, Peb, …</code>), which is what makes the next labeling passes possible.</p>
<h3 id="working-set-accounting-the-biggest-gap-closed">Working-set accounting: the biggest gap closed</h3>
<p>This is the next step to turn a teaching demo into something comparable to VMMap. For every committed block, ASMap batches 4096 pages per call to <code>QueryWorkingSetEx</code> and classifies each <em>resident</em> page:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-cpp" data-lang="cpp"><span class="line"><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="kt">uint64_t</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">count</span><span class="p">;</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">const</span> <span class="k">auto</span><span class="o">&amp;</span> <span class="n">attr</span> <span class="o">=</span> <span class="n">batch</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="n">VirtualAttributes</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">attr</span><span class="p">.</span><span class="n">Valid</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>                 <span class="c1">// not resident: skip
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">block</span><span class="p">.</span><span class="n">counts</span><span class="p">.</span><span class="n">wsTotal</span> <span class="o">+=</span> <span class="n">pageSize</span><span class="p">;</span>          <span class="c1">// resident
</span></span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">attr</span><span class="p">.</span><span class="n">Shared</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">block</span><span class="p">.</span><span class="n">counts</span><span class="p">.</span><span class="n">wsShareable</span> <span class="o">+=</span> <span class="n">pageSize</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="n">attr</span><span class="p">.</span><span class="n">ShareCount</span> <span class="o">&gt;</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">block</span><span class="p">.</span><span class="n">counts</span><span class="p">.</span><span class="n">wsShared</span> <span class="o">+=</span> <span class="n">pageSize</span><span class="p">;</span>  <span class="c1">// mapped by &gt;1 process now
</span></span></span><span class="line"><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">block</span><span class="p">.</span><span class="n">counts</span><span class="p">.</span><span class="n">wsPrivate</span> <span class="o">+=</span> <span class="n">pageSize</span><span class="p">;</span>    <span class="c1">// resident &amp; private
</span></span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Note the four buckets: <code>wsTotal</code>, <code>wsShareable</code>, <code>wsShared</code>, and <code>wsPrivate</code>. The distinction between <em>shareable</em> and <em>shared</em> looks pedantic here - it becomes the single most important gotcha when we compare with Linux.</p>
<h3 id="semantic-labels-peb-teb-heaps-and-pe-sections">Semantic labels: PEB, TEB, heaps, and PE sections</h3>
<p>A generic &ldquo;Private&rdquo; region is not enough: is it part of the process heap or a thread&rsquo;s TEB? ASMap runs four labeling passes that access the target process, from most generic to most specific:</p>
<table>
  <thead>
      <tr>
          <th>Pass</th>
          <th>Mechanism</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>PE image sections</strong></td>
          <td><code>EnumProcessModulesEx(LIST_MODULES_ALL)</code>, then parse each module&rsquo;s PE header in the target and tag each block with its section (<code>.text</code>, <code>.rdata</code>, …)</td>
      </tr>
      <tr>
          <td><strong>PEB / PEB32</strong></td>
          <td><code>NtQueryInformationProcess(ProcessBasicInformation</code> / <code>ProcessWow64Information)</code></td>
      </tr>
      <tr>
          <td><strong>Heaps</strong></td>
          <td>read <code>PEB.NumberOfHeaps</code> + the <code>ProcessHeaps[]</code> array (x64 offsets <code>0xE8</code>/<code>0xF0</code>, x86 <code>0x88</code>/<code>0x90</code>)</td>
      </tr>
      <tr>
          <td><strong>Thread stacks + TEBs</strong></td>
          <td>enumerate threads with ToolHelp, resolve each <code>TEB</code> with <code>NtQueryInformationThread(ThreadBasicInformation)</code>, read <code>NT_TIB.StackBase</code></td>
      </tr>
  </tbody>
</table>
<p>Ordering matters - images first, threads last - so the most specific label wins:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-cpp" data-lang="cpp"><span class="line"><span class="cl"><span class="kt">void</span> <span class="nf">LabelAddressSpace</span><span class="p">(</span><span class="k">const</span> <span class="n">Target</span><span class="o">&amp;</span> <span class="n">t</span><span class="p">,</span> <span class="n">AddressSpace</span><span class="o">&amp;</span> <span class="n">as</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">LabelImages</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">as</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="n">LabelPeb</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">as</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="n">LabelHeaps</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">as</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="n">LabelThreads</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">as</span><span class="p">);</span>  <span class="c1">// stacks/TEBs last: most specific for private regions
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Compared to Linux (later), notice that ASMap labels <strong>every</strong> native heap from <code>PEB.ProcessHeaps</code>, not just one.</p>
<h3 id="thread-stacks-done-right">Thread stacks, done right</h3>
<p>This is my favorite upgrade because it fixes a genuine correctness problem. The 2007 heuristic (&ldquo;a region with a guard page is a stack&rdquo;) both misses stacks whose guard page moved and false-positives on any other guard page. ASMap instead enumerates the real threads, reads each thread&rsquo;s <code>TEB</code>, and takes the exact <code>NT_TIB.StackBase</code>:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span><span class="lnt">8
</span><span class="lnt">9
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-cpp" data-lang="cpp"><span class="line"><span class="cl"><span class="c1">// StackBase points just past the top of the stack; the region owning the
</span></span></span><span class="line"><span class="cl"><span class="c1">// last committed byte is the (reserved) stack allocation.
</span></span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">Region</span><span class="o">*</span> <span class="n">r</span> <span class="o">=</span> <span class="n">as</span><span class="p">.</span><span class="n">Find</span><span class="p">(</span><span class="n">stackBase</span> <span class="o">-</span> <span class="mi">1</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">r</span><span class="o">-&gt;</span><span class="n">cat</span> <span class="o">==</span> <span class="n">Category</span><span class="o">::</span><span class="n">Private</span> <span class="o">||</span> <span class="n">r</span><span class="o">-&gt;</span><span class="n">cat</span> <span class="o">==</span> <span class="n">Category</span><span class="o">::</span><span class="n">Reserved</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">        <span class="n">r</span><span class="o">-&gt;</span><span class="n">cat</span> <span class="o">==</span> <span class="n">Category</span><span class="o">::</span><span class="n">Teb</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">r</span><span class="o">-&gt;</span><span class="n">cat</span> <span class="o">=</span> <span class="n">Category</span><span class="o">::</span><span class="n">Stack</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">        <span class="n">r</span><span class="o">-&gt;</span><span class="n">detail</span> <span class="o">=</span> <span class="sa">L</span><span class="s">&#34;Thread &#34;</span> <span class="o">+</span> <span class="n">std</span><span class="o">::</span><span class="n">to_wstring</span><span class="p">(</span><span class="n">tid</span><span class="p">)</span> <span class="o">+</span> <span class="sa">L</span><span class="s">&#34; Stack&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Now every stack is attributed to a precise thread id. For a WOW64 thread, the 32-bit stack is found through the 32-bit TEB that lives <code>0x2000</code> bytes after the 64-bit one. The old guard-page flag is still computed (<code>region.isStackGuess</code>) - a good fallback but it is no longer the source of truth.</p>
<h3 id="wow64-and-the-full-64-bit-range">WOW64 and the full 64-bit range</h3>
<p>ASMap detects bitness with <code>IsWow64Process2</code> (ARM64-aware), uses <a href="https://learn.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-getnativesysteminfo?WT.mc_id=DT-MVP-5003325"><code>GetNativeSystemInfo</code></a>) so the address-space bounds cover the whole 64-bit user range, and reads target-native pointers (4 bytes under WOW64, 8 otherwise). The 32-bit PEB/TEB/heap offsets are handled explicitly, so a 32-bit target on 64-bit Windows is a first-class citizen.</p>
<h3 id="from-a-list-box-to-console--csv">From a list box to console + CSV</h3>
<p>The output is now a structured console report - a per-category summary, then per-region detail, with optional per-block sub-rows (<code>--blocks</code>) and an optional <code>--csv</code> export:</p>
<p><img alt="ASMap output" loading="lazy" src="/posts/2026-07-09_how-to-map-the-address/ASMapOutputWindows.png"></p>
<p>Everything the original showed is still there plus additional summary and details - it is just no longer trapped in a GUI and this is important in these days of AI. The original goal of building this tool was to better understand the additional memory footprint of our Datadog profiler. After generating an output for a baseline and others with our profiler and tracer, I&rsquo;ve asked Opus 4.8 to analyze the differences and obtained a report that pinpoints the weight of additional loaded dlls:</p>
<p><img alt="Detailed difference between scenarios" loading="lazy" src="/posts/2026-07-09_how-to-map-the-address/LoadedModuleComparison.png"></p>
<p>Thanks to the dll names and path, I realized that some of these numbers were wrong due to a local configuration of my dev machine where the same datadog_profiling_ffi.dll file was loaded statically because used by our native profiler following Windows rules and the other one loaded by the .NET runtime following other rules due to P/Invoke calls:</p>
<p><img alt="Weird duplicated .dll" loading="lazy" src="/posts/2026-07-09_how-to-map-the-address/WeirdDuplicatedDll.png"></p>
<h2 id="same-goal-on-linux-let-the-kernel-do-the-work">Same goal on Linux: let the kernel do the work</h2>
<p>Then came the fun question: what does this look like on Linux? The philosophy is inverted. On Windows the tool does the work - thousands of syscalls, reading the target&rsquo;s own structures. On Linux the <em>kernel</em> does the work: <code>/proc/&lt;pid&gt;/smaps</code> already contains the per-mapping breakdown as text, so the whole probe is a stdlib-only Python script of ~430 lines that mostly parses a file.</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># Header of a smaps entry:  start-end perms offset dev inode pathname</span>
</span></span><span class="line"><span class="cl"><span class="n">_HEADER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="sa">r</span><span class="s2">&#34;^([0-9a-fA-F]+)-([0-9a-fA-F]+)\s+(\S</span><span class="si">{4}</span><span class="s2">)\s+&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="sa">r</span><span class="s2">&#34;([0-9a-fA-F]+)\s+(\S+)\s+(\d+)\s*(.*)$&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Detail lines:  &#34;Rss:   1234 kB&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">_KV_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;^(\w+):\s+(\d+)\s+kB$&#34;</span><span class="p">)</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The residency numbers I fought for on Windows (one <code>QueryWorkingSetEx</code> per page) are simply <em>fields</em> here: <code>Rss</code>, <code>Pss</code>, <code>Private_Clean/Dirty</code>, <code>Shared_Clean/Dirty</code>, <code>Referenced</code>, <code>Swap</code>, <code>SwapPss</code>. One file read and the kernel already walked the page tables for you.</p>
<h3 id="vmas-one-level-not-two">VMAs: one level, not two</h3>
<p>Linux has no region/block hierarchy. The unit is the <strong>VMA</strong> (virtual memory area) - one smaps entry, described by <code>perms offset dev inode path</code>. There is <strong>no reserve/commit sub-structure</strong> and, crucially, <strong>no free-space or gap entries</strong>: smaps lists only VMAs that exist. So the Linux tool simply cannot show you &ldquo;reserved-but-uncommitted&rdquo; or &ldquo;free&rdquo; the way ASMap does on Windows - that information is not exposed.</p>
<h3 id="mapping-the-categories">Mapping the categories</h3>
<p>The classifier mirrors VMMap&rsquo;s grouping using the path column and the permission bits:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">if</span> <span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;[heap]&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">mp</span><span class="o">.</span><span class="n">category</span> <span class="o">=</span> <span class="s2">&#34;Heap&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;[stack]&#34;</span> <span class="ow">or</span> <span class="n">path</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;[stack:&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">mp</span><span class="o">.</span><span class="n">category</span> <span class="o">=</span> <span class="s2">&#34;Stack&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="n">path</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&#34;[vdso]&#34;</span><span class="p">,</span> <span class="s2">&#34;[vvar]&#34;</span><span class="p">,</span> <span class="s2">&#34;[vsyscall]&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">mp</span><span class="o">.</span><span class="n">category</span> <span class="o">=</span> <span class="s2">&#34;Kernel&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="n">path</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;[anon:&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">mp</span><span class="o">.</span><span class="n">category</span> <span class="o">=</span> <span class="s2">&#34;Named Anon&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;&#34;</span> <span class="ow">or</span> <span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;[anon]&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">mp</span><span class="o">.</span><span class="n">category</span> <span class="o">=</span> <span class="s2">&#34;Anon Shared&#34;</span> <span class="k">if</span> <span class="n">is_shared</span> <span class="k">else</span> <span class="s2">&#34;Anon Private&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="n">path</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;/&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">mp</span><span class="o">.</span><span class="n">category</span> <span class="o">=</span> <span class="s2">&#34;Image&#34;</span> <span class="k">if</span> <span class="n">base</span> <span class="ow">in</span> <span class="n">exec_files</span> <span class="k">else</span> <span class="s2">&#34;Mapped File&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Three asymmetries are worth mentioning:</p>
<ul>
<li><strong>Heaps.</strong> Windows labels <em>every</em> heap from <code>PEB.ProcessHeaps</code>. Linux only knows the single <code>brk()</code> heap <code>[heap]</code>. glibc&rsquo;s <code>malloc</code> arenas, and any <code>mmap</code>-based allocator (including those done by the .NET GC), show up as generic <strong><code>Anon Private</code></strong>. So a managed process on Linux shows almost no <code>Heap</code> - its managed heap is anonymous memory.</li>
<li><strong>Image classification is a heuristic.</strong> There is no <code>MEM_IMAGE</code> equivalent. The script collects every file that has at least one executable (<code>x</code>) mapping into <code>exec_files</code>, then tags <em>all</em> VMAs of those files as <code>Image</code>. It is a good guess, not the authoritative answer Windows provides.</li>
<li><strong>PEB/TEB do not exist.</strong> There is no process/thread environment block; thread-local storage lives inside ordinary anonymous/image mappings and is not separately labeled.</li>
</ul>
<p>When I looked at the Linux output, I scratched my head for some <code>/memfd:doublemapper (deleted)</code> mappings:</p>
<p><img alt="ASMap output on Linux" loading="lazy" src="/posts/2026-07-09_how-to-map-the-address/ASMapOutputLinux.png"></p>
<p>Cursor with Opus 4.8 found the answer for me in seconds:</p>
<p><code>memfd:</code> — This is an anonymous in-memory file created via the Linux <code>memfd_create()</code> syscall. It&rsquo;s backed by RAM (tmpfs), has a file descriptor, but no path on any real filesystem. The name after the colon (<code>doublemapper</code>) is just the label passed to <code>memfd_create()</code>.</p>
<p><code>(deleted)</code> — This is normal and expected, <em>not</em> a problem. A memfd has no directory entry anywhere, so the kernel always renders it in <code>smaps</code>/<code>maps</code> with the <code>(deleted)</code> suffix. Don&rsquo;t read it as &ldquo;a file was removed.&rdquo;</p>
<p><code>doublemapper</code> — This is the giveaway that you&rsquo;re looking at a .NET process (CoreCLR / .NET Core / .NET 5+). The runtime&rsquo;s executable-code allocator uses a double-mapping technique to enforce W^X (&ldquo;write xor execute&rdquo;) for JIT-compiled code:</p>
<ul>
<li>It creates one memfd named <code>doublemapper</code>.</li>
<li>It maps regions of that memfd twice: a long-lived RX (execute) view that the CPU runs, and short-lived RW (write) views used only while the JIT emits or patches code.</li>
<li>Because the same physical page is never simultaneously writable <em>and</em> executable at the same address, this hardens the process against certain code-injection exploits.</li>
</ul>
<p>So this row is essentially the JIT&rsquo;d/executable code heap of the .NET runtime.</p>
<p>And I even got the link to the <a href="https://github.com/dotnet/runtime/blob/main/src/coreclr/minipal/Unix/doublemapping.cpp#L61">related file</a> for the same price! I can&rsquo;t imagine how many hours I would have spent if I had searched on my own without AI&hellip;</p>
<h2 id="where-the-two-worlds-diverge">Where the two worlds diverge</h2>
<p>Now the part I actually find interesting. The two tools print similar-looking tables, but they sit on two different memory-management philosophies. Two columns in particular probably do <strong>not</strong> mean what you think.</p>
<h3 id="working-set-vs-rsspss-shareable-is-not-shared">Working Set vs RSS/PSS: &ldquo;shareable&rdquo; is not &ldquo;shared&rdquo;</h3>
<p>Both answer &ldquo;how much is resident in RAM right now?&rdquo;, but they split <em>shared</em> pages by different rules:</p>
<ul>
<li><strong>Windows <code>Shareable</code></strong> means a page <em>can</em> be shared because it is section/image backed - <strong>even if only this one process maps it right now.</strong> A DLL&rsquo;s clean code page mapped by a single process is still <code>Shareable</code>, not <code>Private</code>, on Windows.</li>
<li><strong>Linux <code>Shared_*</code></strong> means the page is <em>actually</em> shared right now, i.e. its physical <code>mapcount &gt; 1</code>. That same lone-mapped library page is counted as <strong><code>Private_Clean</code></strong> on Linux, because its <code>mapcount == 1</code>.</li>
</ul>
<p>So the correspondence is:</p>
<table>
  <thead>
      <tr>
          <th>Windows (ASMap)</th>
          <th>Linux (smaps)</th>
          <th>Caveat</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>WS</code> (Valid)</td>
          <td><code>Rss</code></td>
          <td>closest 1:1 match</td>
      </tr>
      <tr>
          <td><code>WSShared</code> (ShareCount &gt; 1)</td>
          <td><code>Shared_Clean + Shared_Dirty</code></td>
          <td>both mean &ldquo;shared now&rdquo;</td>
      </tr>
      <tr>
          <td><code>WSPriv</code> (not Shared bit)</td>
          <td><code>Private_Clean + Private_Dirty</code></td>
          <td>roughly</td>
      </tr>
      <tr>
          <td><code>WSShareable</code> (Shared bit, any ShareCount)</td>
          <td><strong>no equivalent</strong></td>
          <td>Linux calls a lone-mapped file page <em>Private</em></td>
      </tr>
      <tr>
          <td><em>(none)</em></td>
          <td><code>Pss</code> (proportional set size)</td>
          <td>Linux-only &ldquo;fair share&rdquo;</td>
      </tr>
      <tr>
          <td><em>(none)</em></td>
          <td><code>Referenced</code>, <code>Swap</code>, <code>SwapPss</code></td>
          <td>Linux-only</td>
      </tr>
  </tbody>
</table>
<p>The practical trap: for a library-heavy process, Windows <code>WSPriv</code> and Linux <code>Private</code> will <em>not</em> match, because Windows moves shareable-but-single-mapper pages out of Private while Linux keeps them in. And Linux adds two things Windows never exposes: <strong>PSS</strong> (each resident page counted as <code>1/mapcount</code>, the real &ldquo;what does this process cost&rdquo; number) and <strong>clean vs dirty</strong> (<code>Private_Dirty</code> is what would hit swap under pressure). <code>QueryWorkingSetEx</code> has no clean/dirty bit at all.</p>
<h3 id="committed-memory-the-models-dont-line-up">Committed memory: the models don&rsquo;t line up</h3>
<p>This is the deepest gap, and the reason why the Linux tool has <strong>no &ldquo;Committed&rdquo; column.</strong></p>
<p>On <strong>Windows</strong>, commit is an explicit, enforced, up-front promise. Committing a page charges it against the <strong>commit limit = RAM + pagefile</strong>. Once committed, touching the page is <em>guaranteed</em> not to fail for lack of memory - it may be resident, demand-zero, or paged out, but the resource is reserved. Hence <code>Commit ≥ Working Set</code>, and ASMap reports <code>Commit(K)</code> straight from the <code>MEM_COMMIT</code> block sizes.</p>
<p>On <strong>Linux</strong>, <code>mmap</code> just creates a VMA; physical pages are demand-allocated on first touch, and by default the kernel <strong>overcommits</strong>. There is no per-page commit charge and therefore no per-mapping &ldquo;Committed&rdquo; field in smaps. The available proxies are all imperfect:</p>
<ul>
<li><strong><code>Size</code> (VSS)</strong> - the VMA length. Over-counts wildly: it includes never-touched and <code>PROT_NONE</code> reservation ranges. (In one .NET example the CLR reserved ~260 GiB of <code>Anon Private</code> VSS with under 1 MiB resident - pure address reservation, not &ldquo;commit&rdquo; in any Windows sense.)</li>
<li><strong><code>Rss + Swap</code></strong> - memory actually consumed; closer to <em>Working Set + pagefile</em> than to <em>Committed</em>.</li>
</ul>
<p>The behavioral inversion is the thing to remember:</p>
<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart LR
  subgraph Windows[&#34;Windows: commit = hard promise&#34;]
    W1[&#34;VirtualAlloc(commit) succeeds&#34;] --&gt; W2[&#34;backing store charged against RAM+pagefile&#34;]
    W2 --&gt; W3[&#34;touching the page always works&#34;]
  end
  subgraph Linux[&#34;Linux: default overcommit&#34;]
    L1[&#34;mmap/malloc succeeds&#34;] --&gt; L2[&#34;no charge; pages lazy on first touch&#34;]
    L2 --&gt; L3[&#34;out of RAM+swap? OOM killer picks a victim&#34;]
  end
</code></pre><p>Linux does track a system-wide estimate (<code>Committed_AS</code> in <code>/proc/meminfo</code>) and a <code>CommitLimit</code>, and <code>vm.overcommit_memory=2</code> enforces <code>Committed_AS ≤ CommitLimit</code> - the mode that behaves most like Windows. But it is system-wide and estimate-only, never a per-process, per-page guarantee. The nearest per-mapping analogue to <code>MEM_RESERVE</code> is a <code>PROT_NONE</code> VMA (address reserved, inaccessible, uncharged), which runtimes use for reserve-then-<code>mprotect</code>-on-demand - functionally reserve/commit, but the kernel never accounts the <code>mprotect</code> as &ldquo;commit.&rdquo;</p>
<table>
  <thead>
      <tr>
          <th>Windows term</th>
          <th>Linux nearest</th>
          <th>Match quality</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Reserved (<code>MEM_RESERVE</code>)</td>
          <td><code>PROT_NONE</code> / untouched VMA range</td>
          <td>conceptually close; not labeled by the Linux tool</td>
      </tr>
      <tr>
          <td>Committed (<code>MEM_COMMIT</code>)</td>
          <td><em>(none per-mapping)</em>; <code>Committed_AS</code> system-wide</td>
          <td><strong>poor</strong> - different model</td>
      </tr>
      <tr>
          <td>Commit limit (RAM + pagefile)</td>
          <td><code>CommitLimit</code> (overcommit mode 2 only)</td>
          <td>close only under strict mode</td>
      </tr>
      <tr>
          <td>Working Set</td>
          <td><code>Rss</code></td>
          <td>good</td>
      </tr>
      <tr>
          <td>Private committed</td>
          <td><code>Private_Dirty</code> (+ anon)</td>
          <td>rough</td>
      </tr>
      <tr>
          <td>Pagefile-backed, not in WS</td>
          <td><code>Swap</code> / <code>SwapPss</code></td>
          <td>good</td>
      </tr>
      <tr>
          <td>Free address space</td>
          <td><em>(not represented)</em></td>
          <td>absent on Linux</td>
      </tr>
  </tbody>
</table>
<ul>
<li><strong>Windows:</strong> a default stack is <strong>1 MiB reserved</strong>, a small committed portion, and a <code>PAGE_GUARD</code> page to grow the committed part of the stack when needed. ASMap shows the reserved region, the committed sub-block, and the guard - the full reserve/commit/WS picture - and finds it precisely via <code>TEB.NT_TIB.StackBase</code>.</li>
<li><strong>Linux:</strong> a pthread stack is a default <strong>8 MiB anonymous mapping</strong>, demand-paged, so <code>Size</code> is 8 MiB with <code>Rss</code> a few KiB - the same reserved-vs-resident spread, but with <em>no committed middle layer to report.</em> The kernel only labels the main <code>[stack]</code>; the script recovers worker-thread stacks from <code>/proc/&lt;tid&gt;/syscall</code>, whose second-to-last value is the stack pointer register:</li>
</ul>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># /proc/&lt;tid&gt;/syscall: &#34;&lt;nr&gt; &lt;arg0..arg5&gt; &lt;sp&gt; &lt;pc&gt;&#34;  (needs ptrace-attach)</span>
</span></span><span class="line"><span class="cl"><span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s2">&#34;</span><span class="si">{}</span><span class="s2">/</span><span class="si">{}</span><span class="s2">/syscall&#34;</span><span class="o">.</span><span class="n">format</span><span class="p">(</span><span class="n">taskdir</span><span class="p">,</span> <span class="n">tid</span><span class="p">),</span> <span class="s2">&#34;r&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">toks</span> <span class="o">=</span> <span class="n">f</span><span class="o">.</span><span class="n">read</span><span class="p">()</span><span class="o">.</span><span class="n">split</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">toks</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="mi">2</span> <span class="ow">and</span> <span class="n">toks</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">&#34;running&#34;</span><span class="p">,</span> <span class="s2">&#34;-1&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">int</span><span class="p">(</span><span class="n">toks</span><span class="p">[</span><span class="o">-</span><span class="mi">2</span><span class="p">],</span> <span class="mi">16</span><span class="p">),</span> <span class="kc">False</span>   <span class="c1"># &lt;sp&gt;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>That needs ptrace-attach rights (own/child process, root, or <code>kernel.yama.ptrace_scope=0</code>), which is itself a nice reminder that Linux makes you <em>earn</em> the cross-thread visibility Windows gives you with <code>NtQueryInformationThread</code>.</p>
<h3 id="thread-stacks-both-ways">Thread stacks, both ways</h3>
<p>Stacks illustrate the whole model difference in one object:</p>
<h2 id="trust-but-verify-cross-checking-the-totals">Trust but verify: cross-checking the totals</h2>
<p>Both tools validate their sums against an independent source of truth. The Linux script compares its summed <code>Rss</code>/<code>Pss</code> against the kernel-authoritative <code>/proc/&lt;pid&gt;/smaps_rollup</code>:</p>
<pre tabindex="0"><code>== Cross-check vs smaps_rollup ==
  Rss  computed=  391540  rollup=  391512  diff=   +28 kB
  Pss  computed=  228104  rollup=  228104  diff=    +0 kB
</code></pre><p>On Windows the analogue is <code>GetProcessMemoryInfo</code> / <code>WorkingSet64</code> and <code>PrivateMemorySize64</code>. One subtlety: smaps is a single, mostly-consistent kernel snapshot, whereas ASMap issues thousands of <code>QueryWorkingSetEx</code> calls while the target keeps running, so its totals can drift a little during the walk. Small diffs are expected.</p>
<h2 id="what-each-platform-uniquely-gives-you">What each platform uniquely gives you</h2>
<p><strong>Windows-only</strong></p>
<ul>
<li>Explicit <strong>Reserved vs Committed vs Free</strong> breakdown.</li>
<li><strong>PEB / TEB</strong> and <strong>all</strong> native heaps (via <code>PEB.ProcessHeaps</code>).</li>
<li><strong>Per-block PE section names</strong> (<code>.text</code>, <code>.rdata</code>, …).</li>
<li><strong>Shareable</strong> (potential-sharing) working-set accounting.</li>
</ul>
<p><strong>Linux-only</strong></p>
<ul>
<li><strong>PSS / SwapPss</strong> proportional accounting.</li>
<li><strong>Clean vs dirty</strong> and <strong>Swap</strong> per mapping.</li>
<li><strong>Referenced</strong> (recently-accessed) pages.</li>
<li><strong>Named anonymous</strong> VMAs (<code>[anon:...]</code>) and <code>[vdso]</code>/<code>[vvar]</code> kernel pages.</li>
<li>The whole breakdown for the cost of a single file read.</li>
</ul>
<h2 id="wrapping-up">Wrapping up</h2>
<p>The two tools look like twins because they present the same VMMap-style tables, but they measure two different philosophies:</p>
<ul>
<li><strong>Residency maps cleanly:</strong> Windows <strong>Working Set ≈ Linux RSS</strong>. The gotcha is <em>shared</em> accounting - Windows &ldquo;Shareable&rdquo; (potential) is not Linux &ldquo;Shared&rdquo; (actual <code>mapcount &gt; 1</code>) - and Linux adds PSS and clean/dirty that Windows does not expose.</li>
<li><strong>Commit does not map:</strong> Windows commit is an enforced, per-page, up-front guarantee against a RAM+pagefile limit; Linux allocates lazily and (by default) overcommits, so there is no reliable per-process &ldquo;committed&rdquo; figure - only the system-wide, estimate-only <code>Committed_AS</code>, and the OOM killer instead of a hard promise.</li>
</ul>
<p>So the single most useful number differs by platform: on <strong>Windows</strong> it is <strong>Private (committed) + Working Set</strong>; on <strong>Linux</strong> it is <strong>PSS</strong> (with <strong><code>Private_Dirty</code></strong> for the swap-bound cost).</p>
<p>None of that changes the big picture: the 2007 allocation-base walk is still beating at the center of both tools, nineteen years later. Rebuilding it into ASMap - and then arguing with Linux about what &ldquo;committed&rdquo; even means - was the most fun I have had with <code>VirtualQueryEx</code> in a long time (with a good amount of help from Cursor and Opus 4.8 along the way).</p>
<p>The full source for the Windows tool and the Linux <code>asmap_smaps.py</code> script is in my <a href="https://github.com/chrisnas/ASMap">ASMap GitHub repository</a>.</p>
<p>Happy coding!</p>
<h2 id="references">References</h2>
<ul>
<li>Sysinternals VMMap: <a href="https://learn.microsoft.com/en-us/sysinternals/downloads/vmmap">documentation &amp; download</a></li>
<li><code>VirtualQueryEx</code> / <code>MEMORY_BASIC_INFORMATION</code>: <a href="https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualqueryex">Win32 docs</a></li>
<li><code>QueryWorkingSetEx</code> / <code>PSAPI_WORKING_SET_EX_INFORMATION</code>: <a href="https://learn.microsoft.com/en-us/windows/win32/api/psapi/nf-psapi-queryworkingsetex">Win32 docs</a></li>
<li><code>NtQueryVirtualMemory</code> and <code>MEMORY_REGION_INFORMATION</code>: <a href="https://learn.microsoft.com/en-us/windows/win32/api/winternl/">NT memory information classes</a></li>
<li>Linux <code>proc(5)</code> man page (smaps / smaps_rollup fields): <a href="https://man7.org/linux/man-pages/man5/proc.5.html">man7.org</a></li>
<li>Linux overcommit accounting: <a href="https://www.kernel.org/doc/html/latest/mm/overcommit-accounting.html">Documentation/mm/overcommit-accounting</a></li>
<li>For related target-memory reading and PE parsing, see my earlier posts: <a href="/posts/2026-06-17_reading-clr-internals-the/">Reading CLR internals the cDAC way</a> and <a href="/posts/2025-12-08_how-to-dump-function/">How to dump function symbols from a .pdb file</a></li>
</ul>
]]></content:encoded></item></channel></rss>