How computers work, lesson 3, about 20 minutes
System calls
Your program can't open a file, send a packet or even read the clock without asking the kernel. This lesson follows one request through the only door there is, and meets the guard standing at it.
By the end, you can
- Trace every system call a program makes, and read the results.
- Explain what happens between write() in your code and the kernel doing the work.
- Say why containers and AI sandboxes block some system calls, and why io_uring is on the list.
Your program prints hi. Where is the line between your code and the kernel?
Not the function in your language. The exact point where your code stops and someone else's starts.
Lesson 2 showed that a process lives in its own memory and can't touch anything else. That's deliberate, and it means a process can't do anything useful on its own. Printing, opening files, sending packets, starting processes, even asking what time it is: all of it belongs to the kernel. To get any of it, a process makes a system call.
Linux has a few hundred of them, and every program you have ever run is built out of them. strace shows them as they happen.
inside: docker run -it --rm --cap-add SYS_PTRACE alpine shapk add -q stracestrace -e trace=write echo hiwrite(1, "hi\n", 3) = 3
1. Watch echo printOne call: write to file descriptor 1 (stdout) these 3 bytes. The kernel answers 3, the number of bytes it wrote.inside: docker run -it --rm --cap-add SYS_PTRACE alpine shstrace -e trace=openat cat /nopeopenat(AT_FDCWD, "/nope", O_RDONLY|O_LARGEFILE) = -1 ENOENT cat: can't open '/nope': No such file or directory
2. Watch a call failThe kernel returns -1 and an error code, ENOENT. The friendly message is cat translating that code. Every error you've seen from a file or socket started as a code like this.
Even a tiny command makes dozens of them. Ask strace to count instead of print.
inside: docker run -it --rm --cap-add SYS_PTRACE alpine shstrace -c -f ls / > /dev/null% time calls errors syscall 0.00 18 newfstatat 0.00 4 4 ioctl 0.00 2 getdents64 0.00 2 writev 0.00 2 brk 0.00 2 mprotect ... 0.00 1 execve 100.00 41 4 total
1. Summarise every call41 system calls to list one directory. 18 are newfstatat, one per entry, because ls checks what each one is. The 4 errors are ioctl calls asking whether stdout is a terminal. It isn't, because we sent it to /dev/null.
Your language doesn't make the call. A library does.
When you call write() in C, fs.writeFileSync in Node or os.write in Python, you're calling a library function, not the kernel. Somewhere underneath, a small wrapper in the C library (glibc on most Linux servers, musl on Alpine) does three things: it puts the system call's number in a register, puts the arguments in other registers, and runs one special instruction.
Here is that wrapper, disassembled from a real binary on an ARM Mac.
inside: docker run -it --rm --cap-add SYS_PTRACE alpine shapk add -q gcc musl-dev binutils linux-headersgrep -w __NR_write /usr/include/asm-generic/unistd.h#define __NR_write 64
1. Find write's numberOn 64 bit ARM, write is system call 64. On Intel and AMD it's 1. Same call, different numbers, which is why you never hard code them.inside: docker run -it --rm --cap-add SYS_PTRACE alpine shprintf '#include <unistd.h>\nint main(){write(1,"hi\\n",3);return 0;}\n' > /tmp/w.cgcc -static -O1 -o /tmp/w /tmp/w.c && /tmp/wobjdump -d /tmp/w --no-show-raw-insn | grep -A8 '<write>:'<write>: mov x3, x2 mov x2, x1 sxtw x1, w0 mov x0, #0x40 // 64 bl <__syscall_cp>
2. Read the wrappermusl shifts the three arguments along one register and loads 64, write's number. Then it calls an internal helper that exists so a thread can be cancelled safely while it waits in a call. glibc's wrapper looks different, but it ends in the same place.inside: docker run -it --rm --cap-add SYS_PTRACE alpine shobjdump -d /tmp/w --no-show-raw-insn | grep -B2 svc<_Exit>: mov x8, #0x5e // 94, exit_group mov x0, x1 svc #0x0 <__syscall_cp_c>: mov x5, x6 svc #0x0
3. Find the trap instruction_Exit shows the pattern most plainly: the call number in x8 (94 is exit_group), the argument in x0, then svc. Write's helper ends with the same svc after putting its registers in place.
Check 1
Your Node app writes a log line. How many times does it cross into the kernel to do that?
One instruction changes everything
The special instruction is svc #0 on ARM and syscall on Intel and AMD. It's a deliberate trap. The CPU stops running your code, switches from user mode to kernel mode, and jumps to a fixed address in the kernel that your program can't choose. The kernel reads the number from the register, looks it up in its system call table, checks it's allowed, does the work, puts the result back in a register and returns.
Here is one write, taken apart from your source code to the answer.
Your code
A normal function call in your language. Nothing special has happened yet.
cat /tmp/w.c#include <unistd.h> int main(){write(1,"hi\n",3);return 0;}
Every output here is from the same arm64 container. On an Intel machine the instruction is syscall and the registers are named differently, but the stages are the same.
Crossing into the kernel and back costs real time. In lesson 2, two million crossings took 0.21 seconds of kernel time, about 100 nanoseconds each.
The kernel can say no
Since every dangerous thing a program can do goes through a system call, filtering system calls is one of the strongest ways to contain a program. Linux does this with seccomp: a small filter, attached to a process, that the kernel runs on every call before doing the work. Lesson 1's container teardown showed Seccomp: 2. That means a filter is active, and Docker installed it.
In 2026 this matters more than ever. Containers, browser sandboxes and the sandboxes AI coding agents run untrusted code in all depend on limiting which system calls code can reach. Some go further: gVisor, used for sandboxing, intercepts system calls and implements them itself, so untrusted code barely touches the real kernel at all.
Why io_uring is on the blocklist
io_uring is a newer Linux interface for fast I/O. Instead of one system call per operation, the program and the kernel share ring buffers, and the program drops requests into them. It's fast. It's also a large amount of complex kernel code that ordinary programs can reach. In 2023 Google reported that 60% of the kernel exploits submitted to its bug bounty used io_uring, and it disabled io_uring on its production servers. Docker's default profile blocks it too. You can check.
inside: docker run -it --rm --cap-add SYS_PTRACE alpine shcat > /tmp/uring.c <<'EOF'#include <stdio.h>#include <string.h>#include <errno.h>#include <unistd.h>int main(void) { unsigned char params[120] = {0}; long fd = syscall(425 /* io_uring_setup */, 4, params); if (fd < 0) printf("io_uring_setup failed: %s\n", strerror(errno)); else printf("io_uring_setup ok: fd %ld\n", fd);}EOFgcc -O1 -o /tmp/uring /tmp/uring.c && /tmp/uringio_uring_setup failed: Operation not permitted
1. Under Docker's default seccomp profileThe program calls io_uring_setup, system call 425. The seccomp filter refuses it with EPERM before the io_uring code even runs.on your Macdocker run -it --rm --security-opt seccomp=unconfined alpine sh# then install gcc and repeat the step aboveio_uring_setup ok: fd 3
2. With the filter turned offSame program, same kernel, and now it gets an io_uring instance as file descriptor 3. Turning seccomp off is fine for this experiment and a bad idea in production.
Check 2
A library you use tries io_uring and falls back to plain read and write inside Docker. Why is that the right behaviour?
Calls that skip the kernel entirely
Some questions are asked so often that crossing into the kernel each time would be wasteful. The clock is the classic one. So the kernel maps a small piece of its own code and data into every process, called the vDSO. Reading the time becomes a normal function call that reads shared memory. No trap, no mode switch.
inside: docker run -it --rm --cap-add SYS_PTRACE alpine shstrace -e trace=clock_gettime,gettimeofday dateWed Nov 11 16:54:46 UTC 2026 +++ exited with 0 +++
1. Trace the clock callsdate printed the time, but strace saw no clock_gettime call at all. It was answered in user space, through the vDSO.inside: docker run -it --rm --cap-add SYS_PTRACE alpine shgrep vdso /proc/self/mapsffff8f304000-ffff8f306000 r-xp [vdso]
2. Find the shortcut in memoryThere it is: a page of kernel provided code mapped into every process, readable and executable.
That gives you three ways to pay less for system calls: make fewer of them with bigger buffers (lesson 2), skip them for hot read only data like the clock (the vDSO), or batch many operations through shared memory (io_uring, where it's allowed).
Check 3
A profiler shows your service spends 30% of its CPU in sys time, mostly in tiny write calls. What do you try first?
Keep these
- A system call is the only way a process can reach anything outside its own memory.
- Libraries wrap system calls: a number in one register, arguments in others, then one trap instruction (svc on ARM, syscall on x86).
- Every call returns one value: a result, or a negative error code like ENOENT.
- seccomp lets the kernel refuse system calls. Containers and sandboxes rely on it, and io_uring is commonly blocked.
- Make fewer, bigger calls. The vDSO answers some questions, like the time, with no system call at all.
Sources
Primary references only: the documentation the systems are built from.
- syscall(2), including the per architecture calling conventions tableLinux man-pages
- syscalls(2): the list of Linux system callsLinux man-pages
- Operating Systems: Three Easy Pieces, chapter 6: Limited Direct ExecutionArpaci-Dusseau, University of WisconsinHow traps and mode switches work.
- vdso(7)Linux man-pages
- seccomp(2)Linux man-pages
- Seccomp BPF (SECure COMPuting with filters)Linux kernel documentation
- Seccomp security profiles for DockerDocker documentation
- io_uring(7)Linux man-pages
- Learnings from kCTF VRP's 42 Linux kernel exploits submissionsGoogle Security Blog, 2023Source for the 60% io_uring figure.
- gVisor documentationgVisor project
- strace(1)Linux man-pages