<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Chris Roy - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Chris Roy - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 07 Sep 2026 23:53:28 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/thechrisin/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How a System Call Actually Works in Linux ]]>
                </title>
                <description>
                    <![CDATA[ Here's a small C program. It calls clock_gettime() three times, then writes five bytes to standard output. #include <stdio.h> #include <time.h> #include <unistd.h> int main(void) {     struct timespe ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-a-system-call-actually-works-in-linux/</link>
                <guid isPermaLink="false">6a9f35bc726beec2fbecea20</guid>
                
                    <category>
                        <![CDATA[ Kernel ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Linux ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Systems Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ operating system ]]>
                    </category>
                
                    <category>
                        <![CDATA[ linux kernel ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chris Roy ]]>
                </dc:creator>
                <pubDate>Mon, 07 Sep 2026 22:07:56 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9118bd52-fbfe-47e9-9f66-e25578632e91.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Here's a small C program. It calls <code>clock_gettime()</code> three times, then writes five bytes to standard output.</p>
<pre><code class="language-c">#include &lt;stdio.h&gt;
#include &lt;time.h&gt;
#include &lt;unistd.h&gt;

int main(void)
{
    struct timespec ts;

    for (int i = 0; i &lt; 3; i++)
        clock_gettime(CLOCK_MONOTONIC, &amp;ts);

    write(1, "done\n", 5);
    return 0;
}
</code></pre>
<p>Both of those look like system calls. Both of them ask the kernel for something your program can't get on its own: the current time, and access to a file descriptor.</p>
<p>Now run it under <code>strace</code>, which reports every system call a process makes:</p>
<pre><code class="language-bash">gcc -O0 -o mystery mystery.c
strace ./mystery 2&gt;&amp;1 | grep -c clock_gettime
</code></pre>
<p>The answer is <code>0</code>.</p>
<p>The <code>write()</code> shows up immediately. The three <code>clock_gettime()</code> calls don't appear at all. Same program, same libc, same machine, and one of them never reaches the kernel.</p>
<p>By the end of this article you'll know every step between your <code>write()</code> and the code that runs inside the kernel, why the return trip is stranger than the way in, and why <code>clock_gettime()</code> gets to skip the whole thing.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-you-need">What You Need</a></p>
</li>
<li><p><a href="#heading-what-a-system-call-looks-like-from-userspace">What a System Call Looks Like from Userspace</a></p>
</li>
<li><p><a href="#heading-the-crossing">The Crossing</a></p>
</li>
<li><p><a href="#heading-inside-the-kernel-finding-the-handler">Inside the Kernel: Finding the Handler</a></p>
</li>
<li><p><a href="#heading-the-return-trip-and-the-truth-about-errno">The Return Trip, and the Truth Abouterrno</a></p>
</li>
<li><p><a href="#heading-the-system-call-that-never-happens">The System Call That Never Happens</a></p>
</li>
<li><p><a href="#heading-what-the-boundary-costs">What the Boundary Costs</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-epilogue">Epilogue</a></p>
</li>
</ul>
<h2 id="heading-what-you-need">What You Need</h2>
<p>You need an x86-64 machine running Linux, <code>gcc</code>, <code>strace</code>, and <code>objdump</code>. On Debian or Ubuntu that's <code>build-essential</code>, <code>strace</code>, and <code>binutils</code>. You also need to be comfortable reading C. You don't need to have written kernel code, and you won't build or install a kernel here.</p>
<p>Everything below runs on a normal user account, except for one optional tracing step that needs <code>sudo</code>.</p>
<p>Two warnings about scope. First, this article is about <strong>x86-64 only</strong>. ARM64 does the same job with different instructions and different register rules, and hedging every sentence for both would double the length and halve the clarity. Second, kernel internals move. I ran everything here on <strong>Linux 5.15 (Ubuntu 22.04, Intel Core i7-10750H)</strong>, and I'll flag the places where newer kernels differ. Check your own version with <code>uname -r</code>.</p>
<h2 id="heading-what-a-system-call-looks-like-from-userspace">What a System Call Looks Like from Userspace</h2>
<p>Let's start with a correction that matters: <code>write()</code> <strong>isn't a system call.</strong> It's an ordinary C function in your C library. That function makes a system call on your behalf, and the difference between those two things is where most confusion about the kernel begins.</p>
<p>You can prove it by cutting libc out and making the call yourself.</p>
<p>On x86-64, a system call has a fixed convention. You put the number of the call you want in <code>rax</code>, and its arguments in <code>rdi</code>, <code>rsi</code>, <code>rdx</code>, <code>r10</code>, <code>r8</code>, and <code>r9</code>, in that order. Then you execute a single instruction called <code>syscall</code>.</p>
<p>The numbers aren't something you memorise. They live in a header on your machine:</p>
<pre><code class="language-bash">grep -E "__NR_(write|getpid|clock_gettime) " /usr/include/x86_64-linux-gnu/asm/unistd_64.h
</code></pre>
<p>On this machine:</p>
<pre><code class="language-text">#define __NR_write 1
#define __NR_getpid 39
#define __NR_clock_gettime 228
</code></pre>
<p>So <code>write</code> is call number 1. Here's that call written by hand, with no libc wrapper involved:</p>
<pre><code class="language-c">static long raw_write(int fd, const void *buf, unsigned long count)
{
    long ret;

    __asm__ volatile (
        "syscall"
        : "=a" (ret)                   /* the result comes back in rax */
        : "a" (1L),        /* rax = 1, the syscall number for write */
          "D" ((long)fd),  /* rdi = first argument                  */
          "S" (buf),       /* rsi = second argument                 */
          "d" (count)      /* rdx = third argument                  */
        : "rcx", "r11", "memory"
    );

    return ret;
}
</code></pre>
<p>Compile and run it and your bytes turn up on standard output, with nothing from libc anywhere in the path.</p>
<p>Look at that last line, the clobber list. It tells the compiler <code>rcx</code> and <code>r11</code> are going to be destroyed. I didn't add that for safety. It's a fact about the hardware, and it quietly explains something odd about the convention above.</p>
<p>C functions on x86-64 pass their fourth argument in <code>rcx</code>. System calls pass theirs in <code>r10</code> instead. Every explanation that says "because that's the convention" stops one step too early. <strong>The real reason is that the</strong> <code>syscall</code> <strong>instruction overwrites</strong> <code>rcx</code> <strong>as part of doing its job.</strong> The kernel couldn't receive a fourth argument there even if it wanted to, so the ABI routed around its own hardware.</p>
<p>This is the first sign that this boundary isn't a function call wearing a costume. Different mechanism, different rules, and the hardware got there first.</p>
<p>You can see the instruction itself in your compiled binary:</p>
<pre><code class="language-bash">objdump -d --no-show-raw-insn raw_write | grep -B2 -A2 syscall
</code></pre>
<p>The instruction is right there:</p>
<pre><code class="language-text">    118b:	mov    -0x28(%rbp),%rdx
    118f:	syscall
    1191:	mov    %rax,-0x8(%rbp)
</code></pre>
<p>Three lines: load a register, execute one instruction, and store what came back. Everything else in this article happens between line two and line three.</p>
<h2 id="heading-the-crossing">The Crossing</h2>
<p>When the CPU executes <code>syscall</code>, it does something no ordinary jump can do: it changes the privilege level of the processor. Your code runs in what x86 calls ring 3. Kernel code runs in ring 0.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a783a81a29db580b40f1bc8/4c3a9e5f-db34-4bf7-8d41-6dc63fb09285.png" alt="Diagram showing a write system call traveling from userspace through the syscall instruction into the kernel, where the CPU loads the entry point from the LSTAR register, swaps to the kernel stack and builds a pt_regs structure, reaches the write handler, and returns with a value in rax" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The instruction does three things, in order:</p>
<ol>
<li><p>It saves the address of the next instruction in your program into <code>rcx</code>. That's the return address, and it's why <code>rcx</code> gets clobbered.</p>
</li>
<li><p>It saves the CPU flags into <code>r11</code>.</p>
</li>
<li><p>It loads a new instruction pointer, code segment, and stack segment from three special CPU registers.</p>
</li>
</ol>
<p>That third step is the important one. The new instruction pointer doesn't come from your program. It comes from a machine-specific register called <code>LSTAR</code>, and <strong>the kernel wrote that register during boot</strong>.</p>
<p>That's the security property the whole design rests on. Userspace triggers the transition. Userspace doesn't get to pick where it lands. There's one door, the kernel installed it, and it opens on <code>entry_SYSCALL_64</code> in <code>arch/x86/entry/entry_64.S</code>.</p>
<p>Notice what the instruction does <em>not</em> do. It doesn't consult the interrupt descriptor table or push an exception frame. Older systems reached the kernel through <code>int 0x80</code>, a software interrupt with all of that machinery attached, and it was slow. The <code>syscall</code> instruction exists because this path was worth building dedicated hardware for.</p>
<h3 id="heading-becoming-the-kernel">Becoming the Kernel</h3>
<p>Arriving at <code>entry_SYSCALL_64</code> isn't the same as being ready to run kernel code. At the instant of arrival the CPU is in ring 0, but it's still using <strong>your</strong> stack and <strong>your</strong> register state. The kernel has to fix that before it can safely do anything.</p>
<p>Three things happen, and all three are the kernel establishing trust in a machine it is already running on:</p>
<h4 id="heading-1-swapgs">1. <code>swapgs</code></h4>
<p>The kernel keeps a per-CPU pointer in the <code>GS</code> register so it can find its own data structures. While you were running, <code>GS</code> held whatever your program put there. A single instruction, <code>swapgs</code>, exchanges it for the kernel's value. The kernel documentation is unusually blunt about this one, calling it fragile and warning that it must nest perfectly. Get it wrong in either direction and you have a very bad afternoon ahead of you.</p>
<h4 id="heading-2-the-stack-switch">2. The stack switch</h4>
<p>Your stack pointer is a value your program chose, so the kernel can't trust it. It stashes your <code>rsp</code> and switches to a kernel stack it allocated for this thread.</p>
<h4 id="heading-3-building-ptregs">3. Building <code>pt_regs</code></h4>
<p>The kernel then pushes your saved registers onto that new stack in a specific order, forming a C struct called <code>struct pt_regs</code>. <code>pt_regs</code> <strong>is your process, frozen.</strong> Every debugger that inspects a stopped process, every signal handler that modifies the context it returns to, and every system call handler reads its arguments out of that struct.</p>
<p>There may be a fourth step. If your CPU is vulnerable to Meltdown, the kernel also swaps page tables here, because on those chips the kernel's memory can't safely stay mapped while your code runs.</p>
<p>That swap isn't free. It's why system calls got measurably slower in 2018, and why some of the numbers later in this article would look different on a machine three years older.</p>
<p>You can check whether your machine pays that cost:</p>
<pre><code class="language-bash">cat /sys/devices/system/cpu/vulnerabilities/meltdown
</code></pre>
<p>The test machine here reports <code>Not affected</code>, because its generation of silicon has the fix in hardware. An older laptop will report <code>Mitigation: PTI</code>, and every system call it makes is doing extra work at exactly this point.</p>
<p>Look through the other files in that directory while you're there. Each one is a mitigation that this boundary may be paying for.</p>
<h2 id="heading-inside-the-kernel-finding-the-handler">Inside the Kernel: Finding the Handler</h2>
<p>The kernel is now running on its own stack with your registers safely captured. It calls a C function, <code>do_syscall_64</code>, and hands it two things: your <code>pt_regs</code>, and the system call number you left in <code>rax</code>.</p>
<p>Dispatch is short enough to describe completely. The kernel checks that your number is within range, clamps it, and jumps to the matching handler:</p>
<pre><code class="language-c">if (likely(nr &lt; NR_syscalls)) {
    nr = array_index_nospec(nr, NR_syscalls);
    regs-&gt;ax = x64_sys_call(regs, nr);
}
</code></pre>
<p>Two things in there need explaining.</p>
<p><code>array_index_nospec</code> is a Spectre mitigation. A plain bounds check isn't enough on a speculating CPU, because the processor may run ahead and touch memory past the end of the table before the check resolves. This helper forces the index to be clamped in a way speculation can't skip.</p>
<p><code>x64_sys_call</code> is where a lot of older explanations are now wrong, including some still near the top of search results. They'll tell you the kernel indexes an array of function pointers called <code>sys_call_table</code>. <strong>That was true for many years and is no longer how dispatch works.</strong> Since kernel 6.9, <code>x64_sys_call</code> is a generated <code>switch</code> statement of direct calls.</p>
<p>The reason is a chain of consequences. Spectre mitigations made indirect calls through function pointers expensive, because each one has to go through a retpoline.</p>
<p>A <code>switch</code> of direct calls avoids that cost entirely. The table still exists, because tracing tools use it, but the hot path no longer reads it. On my 5.15 kernel the older table-based dispatch is still in place, which is exactly why naming your kernel version in an article like this one matters.</p>
<h3 id="heading-where-the-handler-comes-from">Where the Handler Comes From</h3>
<p>The handler for <code>write</code> is named <code>__x64_sys_write</code>, and you won't find that name written anywhere in the kernel source. It's generated by a macro:</p>
<pre><code class="language-c">SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
</code></pre>
<p><code>SYSCALL_DEFINE3</code> means "a system call taking three arguments". The macro expands into two functions: the real implementation, and a thin wrapper named <code>__x64_sys_write</code> that takes a single <code>struct pt_regs *</code> and pulls the arguments out of it.</p>
<p>That indirection is deliberate. Rather than trusting whatever userspace happened to leave in the argument registers, the kernel unpacks exactly the values it expects from the frozen struct it built itself. It's the same defensive instinct as <code>array_index_nospec</code>, applied to the shape of the function call.</p>
<p>You don't have to take any of this on faith. <code>ftrace</code>, the kernel's built-in tracer, will show you the handler running.</p>
<p>This needs a root shell rather than <code>sudo</code> on each line, because the filter that keeps the output readable refers to the shell's own process ID:</p>
<pre><code class="language-bash">sudo -i
cd /sys/kernel/tracing

echo 0 &gt; tracing_on
echo $$ &gt; set_ftrace_pid              # trace only this shell
echo function_graph &gt; current_tracer
echo __x64_sys_write &gt; set_graph_function

echo 1 &gt; tracing_on
echo "trigger a write" &gt; /dev/null    # the call we want to catch
echo 0 &gt; tracing_on

head -40 trace
</code></pre>
<p>Without that <code>set_ftrace_pid</code> line, you'll trace every write on the machine, which on a running desktop is far too much output to read.</p>
<p>Here's the result on the test system, lightly trimmed:</p>
<pre><code class="language-text"> 9)               |  __x64_sys_write() {
 9)               |    ksys_write() {
 9)               |      __fdget_pos() {
 9)   0.124 us    |        __fget_light();
 9)   0.363 us    |      }
 9)               |      vfs_write() {
 9)               |        rw_verify_area() {
 9)               |          security_file_permission() {
 9)               |            apparmor_file_permission() {
 9)   0.264 us    |              aa_file_perm();
 9)   0.457 us    |            }
 9)   0.644 us    |          }
 9)   0.857 us    |        }
 9)   0.083 us    |        write_null();
 9)               |        __fsnotify_parent() {
 9)   0.107 us    |          fsnotify();
 9)   1.383 us    |        }
 9)   2.813 us    |      }
 9)   3.449 us    |    }
 9)   3.720 us    |  }
</code></pre>
<p>Read that from the outside in and you have the whole descent in twenty lines.</p>
<p><code>__x64_sys_write</code> is the generated wrapper. It calls <code>ksys_write</code>, the real implementation. That looks up your file descriptor with <code>__fdget_pos</code>, then hands off to <code>vfs_write</code>, the virtual filesystem layer, which is where the kernel stops caring what kind of thing you're writing to.</p>
<p>Then <code>security_file_permission</code> calls into AppArmor, because this machine runs Ubuntu. On a SELinux system something else sits there. Either way it's a security module deciding whether you're allowed to do this. On every write. Every one.</p>
<p><code>write_null</code> is the payoff, and it's there by accident: the command above wrote to <code>/dev/null</code>, so that's the actual driver, the one whose whole job is throwing your bytes away. Point the same write at a file on disk and a filesystem function shows up in that slot instead. Nothing above it moves.</p>
<p>The whole thing took 3.7 microseconds, and the timings on the right tell you where it went.</p>
<p>When you're done, put the tracer back:</p>
<pre><code class="language-bash">echo nop &gt; current_tracer
echo &gt; set_graph_function
echo &gt; set_ftrace_pid
</code></pre>
<p>If <code>/sys/kernel/tracing</code> doesn't exist on your system, try <code>/sys/kernel/debug/tracing</code> instead.</p>
<h2 id="heading-the-return-trip-and-the-truth-about-errno">The Return Trip, and the Truth About <code>errno</code></h2>
<p>The handler finishes and returns a number. That number goes into <code>rax</code>, and <code>rax</code> is the only thing your program gets back.</p>
<p>Which raises a question that is rarely asked directly: if the kernel can only return one value, how does it report <em>what went wrong</em> as well as <em>that</em> something went wrong?</p>
<p>The answer is that it doesn't have a separate channel. <strong>The kernel returns errors as small negative numbers in the same register as the result.</strong> A successful <code>write</code> of 24 bytes returns 24. A <code>write</code> to a closed descriptor returns -9, because <code>EBADF</code> is error number 9.</p>
<p>Now put that together with the fact that <code>errno</code> exists, and something doesn't add up. <code>errno</code> is a variable in your process. The kernel doesn't write to your variables.</p>
<p>Here's the test. Set <code>errno</code> to zero, make a raw system call that's guaranteed to fail, and look at both values:</p>
<pre><code class="language-c">#include &lt;stdio.h&gt;         /* fprintf, stderr */
#include &lt;errno.h&gt;         /* errno */

/* raw_write() is the function from the previous section */

errno = 0;

long ok  = raw_write(1, "written via raw syscall\n", 24);
long bad = raw_write(999, "x", 1);          /* not an open descriptor */

fprintf(stderr, "ok = %ld\n",  ok);
fprintf(stderr, "bad = %ld\n", bad);
fprintf(stderr, "errno = %d\n", errno);
</code></pre>
<p>Running it:</p>
<pre><code class="language-text">written via raw syscall
ok = 24
bad = -9
errno = 0
</code></pre>
<p>There it is. The kernel returned <code>-9</code>, and <code>errno</code> never moved.</p>
<p><code>errno</code> <strong>is a libc invention.</strong> When you call the normal <code>write()</code>, the wrapper checks whether the return value is a small negative number. If it is, it negates it, stores the result in <code>errno</code>, and returns <code>-1</code> to you. The <code>-1</code>-and-check-<code>errno</code> pattern every C programmer learns is a convention built entirely in userspace, on top of a kernel interface that works a completely different way.</p>
<p>Once you've seen this, a familiar bug class makes more sense. <code>errno</code> is only meaningful immediately after a failed call, because it's just a variable that the last wrapper to fail happened to write to.</p>
<h3 id="heading-two-ways-out">Two Ways Out</h3>
<p>Getting back to userspace has a fast path and a slow path.</p>
<p>The fast path is <code>sysret</code>, the mirror of <code>syscall</code>: it restores your instruction pointer from <code>rcx</code> and your flags from <code>r11</code> and drops back to ring 3 in a few cycles.</p>
<p>The slow path is <code>iret</code>, the general-purpose return-from-interrupt instruction. It's significantly slower, and the kernel uses it when <code>sysret</code> can't be trusted. The entry code's own comments explain why: <code>sysret</code> has trouble with non-canonical addresses due to bugs in both AMD and Intel CPUs, so whenever something might have changed your saved state, the kernel forces the safe path. A debugger reaching in through <code>ptrace</code> and changing your registers is the usual culprit.</p>
<p>Before either instruction runs, the kernel does the housekeeping it deferred. It checks for pending signals and delivers them. It checks whether the scheduler wants the CPU back, and if so, your process stops here and something else runs.</p>
<p>Which means a system call isn't only a request for service. It's one of the main places your process can simply stop running. You asked to write five bytes. On the way back the kernel gets to reconsider everything about you, including whether you should continue at all.</p>
<h2 id="heading-the-system-call-that-never-happens">The System Call That Never Happens</h2>
<p>Now back to the mystery from the opening.</p>
<p>Look at your own process's memory map:</p>
<pre><code class="language-bash">cat /proc/self/maps | tail -4
</code></pre>
<p>which ends with:</p>
<pre><code class="language-text">7fff32f97000-7fff32f9b000 r--p  [vvar]
7fff32f9b000-7fff32f9d000 r-xp  [vdso]
</code></pre>
<p>Two regions you never asked for. Neither came from your program or your libraries. The kernel put them there, in every process on the system.</p>
<p><code>[vdso]</code> stands for virtual dynamic shared object. It's a small, complete shared library (real ELF, with a symbol table) that the kernel maps into every address space. And because the kernel tells each process where it put it, you can dump your own copy and take it apart:</p>
<pre><code class="language-c">#include &lt;stdio.h&gt;
#include &lt;sys/auxv.h&gt;      /* getauxval, AT_SYSINFO_EHDR */
#include &lt;unistd.h&gt;        /* getpagesize */

int main(void)
{
    void  *vdso = (void *)getauxval(AT_SYSINFO_EHDR);   /* the kernel tells us where */
    size_t len  = 2 * getpagesize();                    /* the mapping is two pages  */

    FILE *f = fopen("vdso.so", "wb");
    fwrite(vdso, 1, len, f);
    fclose(f);

    printf("vDSO was mapped at %p\n", vdso);
    return 0;
}
</code></pre>
<p><code>AT_SYSINFO_EHDR</code> lives in <code>&lt;sys/auxv.h&gt;</code>. Leave that header out and you don't get a polite warning about it: the build stops with <code>AT_SYSINFO_EHDR undeclared</code>.</p>
<p>Run that, then read its symbol table like any other library:</p>
<pre><code class="language-bash">./dump_vdso &amp;&amp; objdump -T vdso.so | grep __vdso
</code></pre>
<p>and out comes:</p>
<pre><code class="language-text">__vdso_gettimeofday
__vdso_clock_gettime
__vdso_clock_getres
__vdso_time
__vdso_getcpu
</code></pre>
<p>There's the answer. <code>clock_gettime</code> is in that list.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a783a81a29db580b40f1bc8/8f16ecfa-995c-43b1-8257-4033cc485998.png" alt="Diagram comparing two calls: getpid crossing into the kernel through the syscall instruction and taking about 100 nanoseconds, and clock_gettime staying in userspace by calling into the vDSO, reading a shared read-only page, and taking about 16 nanoseconds" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>When you call <code>clock_gettime()</code>, libc calls into the vDSO. That code is written by kernel developers and shipped with the kernel, but it <strong>executes in ring 3, as part of your process</strong>. It reads the current time out of the <code>[vvar]</code> page (a read-only page the kernel keeps updated) and returns.</p>
<p>There's no privilege change, <code>syscall</code> instruction, entry point, stack switch, or <code>pt_regs</code>. And that means there's nothing for <code>strace</code> to see, because <code>strace</code> works by watching the boundary, and this call never goes near it.</p>
<p>That's the whole trick. The kernel took a handful of operations that are called constantly, need no privileges to <em>read</em>, and only ever return information the kernel is willing to publish – and it published them.</p>
<p>That last constraint explains why the list is so short. <code>write()</code> can never work this way, because it has to change state that belongs to the kernel. Reading the clock does not. So the clock, the time of day, and the current CPU number moved out to where the caller already is.</p>
<h2 id="heading-what-the-boundary-costs">What the Boundary Costs</h2>
<p>Everything above is mechanism. Here's the price, measured.</p>
<p>The benchmark compares a call that definitely traps against one that definitely does not. For the first, use <code>syscall(SYS_getpid)</code>. Going through the thin <code>syscall()</code> wrapper guarantees a real crossing:</p>
<pre><code class="language-c">/* Excerpt. Needs &lt;unistd.h&gt;, &lt;sys/syscall.h&gt; and &lt;time.h&gt;, plus a now()
   helper returning seconds as a double, and ITERATIONS defined above. */

double a = now();
for (long i = 0; i &lt; ITERATIONS; i++)
    sink += syscall(SYS_getpid);

double b = now();
for (long i = 0; i &lt; ITERATIONS; i++)
    clock_gettime(CLOCK_MONOTONIC, &amp;ts);
</code></pre>
<p>On the test machine, two million iterations of each:</p>
<pre><code class="language-text">real system call (getpid):   106.9 ns/call
vDSO call (clock_gettime):    17.2 ns/call
ratio:                          6.2x
</code></pre>
<p>Roughly six times, and <code>getpid</code> is about as cheap as a system call gets. It reads one field and returns. Which means almost none of that 107 nanoseconds is the work. It's the privilege change, <code>swapgs</code>, the stack switch, <code>pt_regs</code> going up and coming back down, plus whatever mitigations your particular CPU insists on along the way.</p>
<p>Now the caveat, because it matters more than the number.</p>
<p><strong>That 107 nanoseconds is close to a best case.</strong> Check what this machine reported earlier: <code>Not affected</code> for Meltdown, so it never does the page-table swap. Its Spectre mitigation is <code>Enhanced IBRS</code>, which is handled in silicon rather than by retpolines in software. This CPU is skipping two of the most expensive things a crossing can involve.</p>
<p>So run the benchmark yourself, and read your own mitigation files alongside it:</p>
<pre><code class="language-bash">grep . /sys/devices/system/cpu/vulnerabilities/*
</code></pre>
<p>If yours says <code>Mitigation: PTI</code>, your crossings are doing strictly more work than the ones measured here, and your number should be higher. Older silicon can be dramatically worse.</p>
<p>Treat the ratio as the durable result and the absolute number as one reading from one machine. The figure moves with your CPU, your kernel, and whichever mitigations you happen to be carrying. Run it a few times while you're there. The spread between runs on this laptop was about fifteen percent, which tells you roughly how much to trust any single number, including mine.</p>
<p>One more result from the same run corrects a widely repeated claim. <code>getpid()</code> through normal libc costs the same as the raw <code>syscall(SYS_getpid)</code>. glibc used to cache the process ID to avoid the trip, and stopped years ago, because keeping the cache correct across <code>fork</code> and namespace changes was worse than paying the hundred nanoseconds.</p>
<p>Six times sounds abstract until you attach it to something. A program making a million small <code>read()</code> calls spends about a tenth of a second on nothing but crossings. This is the pressure behind a lot of modern kernel interface design: <code>io_uring</code> exists so that submitting a thousand operations can cost one crossing instead of a thousand. Batching syscalls, buffering writes, and using <code>sendfile()</code> instead of a read-write loop are all the same optimisation: not doing less work, just crossing the boundary fewer times.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You can now follow a system call the whole way. You've seen the <code>syscall</code> instruction in your own binary, watched a bad file descriptor come back as <code>-9</code> while <code>errno</code> stayed at zero, pulled the vDSO out of your own address space and read its symbol table, and measured what the crossing costs on your own CPU.</p>
<p>More usefully, you have a mental model that keeps paying out. When you read that <code>io_uring</code> reduces syscall overhead, you know exactly what overhead means. When a profile shows time in <code>entry_SYSCALL_64</code>, you know what that function does. When <code>strace</code> shows nothing, you know to check the vDSO before doubting the tool.</p>
<p>There are a few directions to go from here. Run the <code>ftrace</code> recipe and follow <code>__x64_sys_write</code> down into the filesystem layer. Read <code>arch/x86/entry/entry_64.S</code>: it's heavily commented and much more approachable than its reputation suggests. Or check <code>/sys/devices/system/cpu/vulnerabilities/</code> on an older machine and work out what each mitigation is costing you at this boundary.</p>
<h2 id="heading-epilogue">Epilogue</h2>
<p>I'm currently experimenting with an OS design on top of the Linux kernel that would bring an Android-style permissions and capabilities model to a desktop OS while trying to be 100% compatible with the Debian ecosystem. This has led to some really interesting research lately. This article is a product of that research.</p>
<p>I'll be writing more about the Linux kernel before I move on to formal verification, as in the DO-178C and DO-333 world where avionics software has to qualify the tools that check it. Usually that means <a href="https://www.pm.inf.ethz.ch/research/viper.html">Viper</a>, <a href="https://why3.org/">Why3</a>, <a href="https://www.microsoft.com/en-us/research/project/z3-3/">Z3</a> and friends.</p>
<p>In the meantime, I also write about systems that have to survive contact with reality at <a href="https://thechris.in">thechris.in</a>, including a companion piece to this one, on what it means to build on an abstraction whose cost you can measure but never see.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
