Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Operating Systems  Module 1 – Foundations: what an operating system does

System calls: the kernel's front door

How a system call travels from a C function through a trap and the kernel's table, how failures come back as errno, and what one costs, measured.

  • Beginner
  • 15 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Clang 21.0.0, GCC 16.2.0 and Rust 1.99.0
  • By MySmartCoPilot
Free: 15 minutes

What you will learn

  • Explain how a system call travels from a C library function through a trap and the kernel's table, and back
  • Use return values and errno correctly when a system call fails
  • Classify system calls into process, file, device, information, communication and protection groups
  • Explain where Linux and macOS each keep the stable interface that programs rely on
  • Measure what a system call costs and explain why some calls never enter the kernel

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain how a system call travels from a C library function through a trap and the kernel's table, and back
  • Use return values and errno correctly when a system call fails
  • Classify system calls into process, file, device, information, communication and protection groups
  • Explain where Linux and macOS each keep the stable interface that programs rely on
  • Measure what a system call costs and explain why some calls never enter the kernel

Before you start

On this page

On the machine that ran this lesson’s programs, asking the kernel for a process’s parent took well over 100 nanoseconds, while reading the clock took tens and an ordinary function call about one. Every request a program makes to the kernel, to open a file, start a process or send a packet, pays a cost like the first, because it travels the same narrow path: the system call.

This lesson follows that path from a C library function through the trap and the kernel’s table and back, shows how a failed call reports its error, sorts the calls into six groups, and explains where Linux and macOS each keep the interface that programs can rely on. It ends by measuring the cost and the calls that avoid it.

From a C function to the kernel and back

A program never calls the kernel’s code directly. It calls a function of the C library, such as write, and the library’s wrapper does the rest: it puts the system call’s number and arguments into registers and executes the trap instruction of the processor, which syscall(2) lists for each architecture: svc on Arm, syscall on x86-64, ecall on RISC-V. In the kernel, the number selects a handler from the system-call table: The kernel's table of handlers, indexed by system-call number. A trap with a number the table does not have is refused with ENOSYS.. Follow one call through it:

Interactive · message by message One write call, from the C library to the kernel and back Step through write(1, "hello\n", 6) on Linux. Watch which side does each part of the work.
One write call, from the C library to the kernel and back, step 7 of 7. Messages so far: Program to C library: write(1, buf, 6); C library to Kernel: trap with the call's number; Kernel to C library: result 6; C library to Program: returns 6.Programuser modeC libraryuser modeKernelkernel modewrite(1, buf, 6)trap with the call's numberresult 6returns 6

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The call. The program calls write with descriptor 1, a buffer and a length of 6. So far this is an ordinary function call into the C library, still in user mode.
  2. The wrapper. The library's wrapper puts the system call's number and the three arguments into the registers that syscall(2) lists for the processor, then executes the trap instruction, svc on Arm or syscall on x86-64.
  3. Into the kernel. The trap switches the processor to kernel mode and jumps to the kernel's entry point for system calls. The program cannot choose any other address.
  4. The table. The kernel saves the program's registers and uses the number to pick the write handler from its system-call table. A number with no entry gets the error ENOSYS.
  5. The checks. The handler checks that descriptor 1 is open for writing and that the 6 bytes lie in memory the process may read, then copies them in. A bad descriptor gets EBADF, a bad address EFAULT.
  6. The work and the result. The bytes go to the terminal or file behind descriptor 1. The handler leaves the result in the return register, 6 here, or minus an error number when something failed, and the kernel returns to user mode.
  7. errno. Back in the wrapper, a negative result would become -1, with its absolute value stored in errno. This one is 6, so write returns 6 and leaves errno alone.

The kernel trusts nothing that arrives: the number is checked against the table, the descriptor against the process’s open files, the buffer against its address space. On Linux the result comes back in a register, and intro(2) gives the convention: on error most system calls return a negative error number, and the wrapper copies its absolute value into errno and returns −1. The model below plays the whole path in 14 languages, with five calls that succeed or fail in different ways:

One program · 14 languages The kernel's table, its checks, and the wrapper's errno

Python 3.14 · main.py

EBADF, EFAULT, ENOSYS = 9, 14, 38  # Linux's numbers
screen: list[str] = []  # what standard output received


def sys_write(fd: int, buf: str | None, count: int) -> int:
    """The kernel checks every argument first."""
    if fd != 1:  # only descriptor 1 is open
        return -EBADF
    if buf is None:  # an address the process cannot read
        return -EFAULT
    screen.append(buf[:count])
    return count


def sys_getpid(fd: int, buf: str | None, count: int) -> int:
    return 4242  # the process's ID


TABLE = [sys_write, sys_getpid]  # call 0 is write, 1 is getpid


def trap(number: int, fd: int, buf: str | None, count: int) -> int:
    """Kernel mode: find the handler."""
    if not 0 <= number < len(TABLE):
        return -ENOSYS
    return TABLE[number](fd, buf, count)


def wrapper(call: str, number: int, fd: int, buf: str | None, count: int) -> int:
    """The C library: a negative result means -1 and errno."""
    raw = trap(number, fd, buf, count)
    if raw < 0:
        print(f"{call}: kernel returns {raw}, wrapper returns -1 with errno {-raw}")
        return -1
    print(f"{call}: kernel returns {raw}, wrapper returns {raw}")
    return raw


wrapper('write(1, "hello", 5)', 0, 1, "hello", 5)
wrapper('write(7, "hi", 2)', 0, 7, "hi", 2)
wrapper("write(1, bad address, 4)", 0, 1, None, 4)
wrapper("getpid()", 1, 0, "", 0)
wrapper("call number 99", 99, 0, "", 0)
print(f"standard output received: {''.join(screen)}")

In Python: The table is a list of functions, so TABLE[number] picks the handler; None stands for an address the process cannot read.

Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py

JavaScript Node 24 · main.mjs

// Linux's numbers
const EBADF = 9;
const EFAULT = 14;
const ENOSYS = 38;
let screen = ""; // what standard output received

// The kernel checks every argument first.
function sysWrite(fd, buf, count) {
  if (fd !== 1) return -EBADF; // only descriptor 1 is open
  if (buf === null) return -EFAULT; // an address the process cannot read
  screen += buf.slice(0, count);
  return count;
}

const sysGetpid = () => 4242; // the process's ID

const TABLE = [sysWrite, sysGetpid]; // call 0 is write, 1 is getpid

// Kernel mode: find the handler.
function trap(number, fd, buf, count) {
  if (number < 0 || number >= TABLE.length) return -ENOSYS;
  return TABLE[number](fd, buf, count);
}

// The C library: a negative result means -1 and errno.
function wrapper(call, number, fd, buf, count) {
  const raw = trap(number, fd, buf, count);
  if (raw < 0) {
    console.log(`${call}: kernel returns ${raw}, wrapper returns -1 with errno ${-raw}`);
    return -1;
  }
  console.log(`${call}: kernel returns ${raw}, wrapper returns ${raw}`);
  return raw;
}

wrapper('write(1, "hello", 5)', 0, 1, "hello", 5);
wrapper('write(7, "hi", 2)', 0, 7, "hi", 2);
wrapper("write(1, bad address, 4)", 0, 1, null, 4);
wrapper("getpid()", 1, 0, "", 0);
wrapper("call number 99", 99, 0, "", 0);
console.log(`standard output received: ${screen}`);

In JavaScript: Functions are values, so the table is an array of them; null stands for an address the process cannot read.

Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs

Java 25 LTS · Main.java

import java.util.List;

public class Main {
  static final int EBADF = 9; // Linux's numbers
  static final int EFAULT = 14;
  static final int ENOSYS = 38;
  static final StringBuilder SCREEN = new StringBuilder(); // what standard output received

  interface Handler {
    int handle(int fd, String buf, int count);
  }

  /** The kernel checks every argument first. */
  static int sysWrite(int fd, String buf, int count) {
    if (fd != 1) { // only descriptor 1 is open
      return -EBADF;
    }
    if (buf == null) { // an address the process cannot read
      return -EFAULT;
    }
    SCREEN.append(buf, 0, count);
    return count;
  }

  static int sysGetpid(int fd, String buf, int count) {
    return 4242; // the process's ID
  }

  static final List<Handler> TABLE = List.of(Main::sysWrite, Main::sysGetpid); // 0 and 1

  /** Kernel mode: find the handler. */
  static int trap(int number, int fd, String buf, int count) {
    if (number < 0 || number >= TABLE.size()) {
      return -ENOSYS;
    }
    return TABLE.get(number).handle(fd, buf, count);
  }

  /** The C library: a negative result means -1 and errno. */
  static int wrapper(String call, int number, int fd, String buf, int count) {
    int raw = trap(number, fd, buf, count);
    if (raw < 0) {
      System.out.println(
          call + ": kernel returns " + raw + ", wrapper returns -1 with errno " + -raw);
      return -1;
    }
    System.out.println(call + ": kernel returns " + raw + ", wrapper returns " + raw);
    return raw;
  }

  public static void main(String[] args) {
    wrapper("write(1, \"hello\", 5)", 0, 1, "hello", 5);
    wrapper("write(7, \"hi\", 2)", 0, 7, "hi", 2);
    wrapper("write(1, bad address, 4)", 0, 1, null, 4);
    wrapper("getpid()", 1, 0, "", 0);
    wrapper("call number 99", 99, 0, "", 0);
    System.out.println("standard output received: " + SCREEN);
  }
}

In Java: The Handler interface lets method references such as Main::sysWrite go into one list.

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java

C++23 · main.cpp

#include <array>
#include <optional>
#include <print>
#include <string>

// EBADF, EFAULT and ENOSYS (Linux's numbers); <errno.h> takes their names
namespace err {
constexpr int BADF = 9;
constexpr int FAULT = 14;
constexpr int NOSYS = 38;
} // namespace err
std::string screen; // what standard output received

using Buffer = std::optional<std::string>; // empty: an address it cannot read
using Handler = int (*)(int fd, const Buffer &buf, int count);

// The kernel checks every argument first.
int sys_write(int fd, const Buffer &buf, int count) {
  if (fd != 1) { // only descriptor 1 is open
    return -err::BADF;
  }
  if (!buf) {
    return -err::FAULT;
  }
  screen += buf->substr(0, count);
  return count;
}

int sys_getpid(int, const Buffer &, int) {
  return 4242; // the process's ID
}

constexpr std::array<Handler, 2> TABLE = {sys_write, sys_getpid}; // 0 and 1

// Kernel mode: find the handler.
int trap(int number, int fd, const Buffer &buf, int count) {
  if (number < 0 || number >= static_cast<int>(TABLE.size())) {
    return -err::NOSYS;
  }
  return TABLE[number](fd, buf, count);
}

// The C library: a negative result means -1 and errno.
int wrapper(const std::string &call, int number, int fd, const Buffer &buf,
            int count) {
  const int raw = trap(number, fd, buf, count);
  if (raw < 0) {
    std::println("{}: kernel returns {}, wrapper returns -1 with errno {}",
                 call, raw, -raw);
    return -1;
  }
  std::println("{}: kernel returns {}, wrapper returns {}", call, raw, raw);
  return raw;
}

int main() {
  wrapper("write(1, \"hello\", 5)", 0, 1, "hello", 5);
  wrapper("write(7, \"hi\", 2)", 0, 7, "hi", 2);
  wrapper("write(1, bad address, 4)", 0, 1, std::nullopt, 4);
  wrapper("getpid()", 1, 0, "", 0);
  wrapper("call number 99", 99, 0, "", 0);
  std::println("standard output received: {}", screen);
}

In C++: <errno.h> already defines EBADF, EFAULT and ENOSYS as macros, so the model names its constants err::BADF and so on; an empty std::optional is the bad address.

Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main

C# 14 · .NET 10 · app.cs

using System.Globalization;
using System.Text;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

const int EBADF = 9; // Linux's numbers
const int EFAULT = 14;
const int ENOSYS = 38;
var screen = new StringBuilder(); // what standard output received

// The kernel checks every argument first.
int SysWrite(int fd, string? buf, int count)
{
    if (fd != 1) // only descriptor 1 is open
    {
        return -EBADF;
    }
    if (buf is null) // an address the process cannot read
    {
        return -EFAULT;
    }
    screen.Append(buf, 0, count);
    return count;
}

static int SysGetpid(int fd, string? buf, int count) => 4242; // the process's ID

Func<int, string?, int, int>[] table = [SysWrite, SysGetpid]; // call 0 is write, 1 is getpid

// Kernel mode: find the handler.
int Trap(int number, int fd, string? buf, int count) =>
    number >= 0 && number < table.Length ? table[number](fd, buf, count) : -ENOSYS;

// The C library: a negative result means -1 and errno.
int Wrapper(string call, int number, int fd, string? buf, int count)
{
    int raw = Trap(number, fd, buf, count);
    if (raw < 0)
    {
        Console.WriteLine($"{call}: kernel returns {raw}, wrapper returns -1 with errno {-raw}");
        return -1;
    }
    Console.WriteLine($"{call}: kernel returns {raw}, wrapper returns {raw}");
    return raw;
}

Wrapper("write(1, \"hello\", 5)", 0, 1, "hello", 5);
Wrapper("write(7, \"hi\", 2)", 0, 7, "hi", 2);
Wrapper("write(1, bad address, 4)", 0, 1, null, 4);
Wrapper("getpid()", 1, 0, "", 0);
Wrapper("call number 99", 99, 0, "", 0);
Console.WriteLine($"standard output received: {screen}");

In C#: The handlers are local functions held in an array of Func delegates; SysWrite is not static because it appends to screen.

Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs

C23 · main.c

#include <stddef.h>
#include <stdio.h>
#include <string.h>

// EBADF, EFAULT and ENOSYS (Linux's numbers); <errno.h> takes their names
enum { ERR_BADF = 9, ERR_FAULT = 14, ERR_NOSYS = 38 };
static char screen[64]; // what standard output received

// The kernel checks every argument first.
static int sys_write(int fd, const char *buf, int count) {
  if (fd != 1) { // only descriptor 1 is open
    return -ERR_BADF;
  }
  if (buf == NULL) { // an address the process cannot read
    return -ERR_FAULT;
  }
  strncat(screen, buf, (size_t)count);
  return count;
}

static int sys_getpid(int fd, const char *buf, int count) {
  (void)fd, (void)buf, (void)count;
  return 4242; // the process's ID
}

typedef int (*Handler)(int fd, const char *buf, int count);
static const Handler TABLE[] = {sys_write, sys_getpid}; // 0 and 1

// Kernel mode: find the handler.
static int trap(int number, int fd, const char *buf, int count) {
  if (number < 0 || (size_t)number >= sizeof TABLE / sizeof TABLE[0]) {
    return -ERR_NOSYS;
  }
  return TABLE[number](fd, buf, count);
}

// The C library: a negative result means -1 and errno.
static int wrapper(const char *call, int number, int fd, const char *buf,
                   int count) {
  int raw = trap(number, fd, buf, count);
  if (raw < 0) {
    printf("%s: kernel returns %d, wrapper returns -1 with errno %d\n", call,
           raw, -raw);
    return -1;
  }
  printf("%s: kernel returns %d, wrapper returns %d\n", call, raw, raw);
  return raw;
}

int main(void) {
  wrapper("write(1, \"hello\", 5)", 0, 1, "hello", 5);
  wrapper("write(7, \"hi\", 2)", 0, 7, "hi", 2);
  wrapper("write(1, bad address, 4)", 0, 1, NULL, 4);
  wrapper("getpid()", 1, 0, "", 0);
  wrapper("call number 99", 99, 0, "", 0);
  printf("standard output received: %s\n", screen);
  return 0;
}

In C: <errno.h> already defines EBADF, EFAULT and ENOSYS as macros, so the model calls its constants ERR_BADF and so on; the table is an array of function pointers.

Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main

PHP 8.5 · main.php

<?php

declare(strict_types=1);

const EBADF = 9; // Linux's numbers
const EFAULT = 14;
const ENOSYS = 38;

final class Kernel
{
    public static string $screen = ''; // what standard output received

    /** The kernel checks every argument first. */
    public static function write(int $fd, ?string $buf, int $count): int
    {
        if ($fd !== 1) { // only descriptor 1 is open
            return -EBADF;
        }
        if ($buf === null) { // an address the process cannot read
            return -EFAULT;
        }
        self::$screen .= substr($buf, 0, $count);
        return $count;
    }

    public static function getpid(int $fd, ?string $buf, int $count): int
    {
        return 4242; // the process's ID
    }

    /** Kernel mode: find the handler. */
    public static function trap(int $number, int $fd, ?string $buf, int $count): int
    {
        $table = [self::write(...), self::getpid(...)]; // call 0 is write, 1 is getpid
        return isset($table[$number]) ? $table[$number]($fd, $buf, $count) : -ENOSYS;
    }
}

/** The C library: a negative result means -1 and errno. */
function wrapper(string $call, int $number, int $fd, ?string $buf, int $count): int
{
    $raw = Kernel::trap($number, $fd, $buf, $count);
    if ($raw < 0) {
        echo "{$call}: kernel returns {$raw}, wrapper returns -1 with errno " . -$raw . "\n";
        return -1;
    }
    echo "{$call}: kernel returns {$raw}, wrapper returns {$raw}\n";
    return $raw;
}

wrapper('write(1, "hello", 5)', 0, 1, 'hello', 5);
wrapper('write(7, "hi", 2)', 0, 7, 'hi', 2);
wrapper('write(1, bad address, 4)', 0, 1, null, 4);
wrapper('getpid()', 1, 0, '', 0);
wrapper('call number 99', 99, 0, '', 0);
echo 'standard output received: ' . Kernel::$screen . "\n";

In PHP: The model kernel is a final class with static methods, and write(...) makes a first-class callable out of a method.

Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php

Rust 1.99 · main.rs

const EBADF: i32 = 9; // Linux's numbers
const EFAULT: i32 = 14;
const ENOSYS: i32 = 38;

/// None stands for an address the process cannot read; the last argument is standard output.
type Handler = fn(i32, Option<&str>, usize, &mut String) -> i32;

/// The kernel checks every argument first.
fn sys_write(fd: i32, buf: Option<&str>, count: usize, screen: &mut String) -> i32 {
    if fd != 1 {
        return -EBADF; // only descriptor 1 is open
    }
    let Some(buf) = buf else {
        return -EFAULT;
    };
    screen.push_str(&buf[..count]);
    count as i32
}

fn sys_getpid(_: i32, _: Option<&str>, _: usize, _: &mut String) -> i32 {
    4242 // the process's ID
}

const TABLE: [Handler; 2] = [sys_write, sys_getpid]; // call 0 is write, 1 is getpid

/// Kernel mode: find the handler.
fn trap(number: usize, fd: i32, buf: Option<&str>, count: usize, screen: &mut String) -> i32 {
    match TABLE.get(number) {
        Some(handler) => handler(fd, buf, count, screen),
        None => -ENOSYS,
    }
}

/// The C library: a negative result means -1 and errno.
fn wrapper(call: &str, number: usize, args: (i32, Option<&str>, usize), screen: &mut String) {
    let raw = trap(number, args.0, args.1, args.2, screen);
    if raw < 0 {
        println!(
            "{call}: kernel returns {raw}, wrapper returns -1 with errno {}",
            -raw
        );
    } else {
        println!("{call}: kernel returns {raw}, wrapper returns {raw}");
    }
}

fn main() {
    let mut screen = String::new(); // what standard output received
    wrapper(
        r#"write(1, "hello", 5)"#,
        0,
        (1, Some("hello"), 5),
        &mut screen,
    );
    wrapper(r#"write(7, "hi", 2)"#, 0, (7, Some("hi"), 2), &mut screen);
    wrapper("write(1, bad address, 4)", 0, (1, None, 4), &mut screen);
    wrapper("getpid()", 1, (0, Some(""), 0), &mut screen);
    wrapper("call number 99", 99, (0, Some(""), 0), &mut screen);
    println!("standard output received: {screen}");
}

In Rust: Standard output is a String passed down to the handlers rather than a global, as Rust prefers; TABLE.get returns None for a number past the end.

Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main

TypeScript 6.0 · main.ts

type Handler = (fd: number, buf: string | null, count: number) => number;

// Linux's numbers
const EBADF = 9;
const EFAULT = 14;
const ENOSYS = 38;
let screen = ""; // what standard output received

// The kernel checks every argument first.
const sysWrite: Handler = (fd, buf, count) => {
  if (fd !== 1) return -EBADF; // only descriptor 1 is open
  if (buf === null) return -EFAULT; // an address the process cannot read
  screen += buf.slice(0, count);
  return count;
};

const sysGetpid: Handler = () => 4242; // the process's ID

const TABLE: Handler[] = [sysWrite, sysGetpid]; // call 0 is write, 1 is getpid

// Kernel mode: find the handler.
function trap(number: number, fd: number, buf: string | null, count: number): number {
  const handler = TABLE[number];
  return handler === undefined ? -ENOSYS : handler(fd, buf, count);
}

// The C library: a negative result means -1 and errno.
function wrapper(call: string, number: number, fd: number, buf: string | null, count: number) {
  const raw = trap(number, fd, buf, count);
  if (raw < 0) {
    console.log(`${call}: kernel returns ${raw}, wrapper returns -1 with errno ${-raw}`);
    return -1;
  }
  console.log(`${call}: kernel returns ${raw}, wrapper returns ${raw}`);
  return raw;
}

wrapper('write(1, "hello", 5)', 0, 1, "hello", 5);
wrapper('write(7, "hi", 2)', 0, 7, "hi", 2);
wrapper("write(1, bad address, 4)", 0, 1, null, 4);
wrapper("getpid()", 1, 0, "", 0);
wrapper("call number 99", 99, 0, "", 0);
console.log(`standard output received: ${screen}`);

In TypeScript: Indexing past the end of the array gives undefined, which trap turns into ENOSYS.

Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts

Go 1.27 · main.go

package main

import (
	"fmt"
	"strings"
)

// Linux's numbers
const (
	ebadf  = 9
	efault = 14
	enosys = 38
)

var screen strings.Builder // what standard output received

// sysWrite checks every argument first. A nil buf is an address the process cannot read.
func sysWrite(fd int, buf *string, count int) int {
	if fd != 1 { // only descriptor 1 is open
		return -ebadf
	}
	if buf == nil {
		return -efault
	}
	screen.WriteString((*buf)[:count])
	return count
}

func sysGetpid(int, *string, int) int {
	return 4242 // the process's ID
}

var table = []func(int, *string, int) int{sysWrite, sysGetpid} // call 0 is write, 1 is getpid

// trap is kernel mode: find the handler.
func trap(number, fd int, buf *string, count int) int {
	if number < 0 || number >= len(table) {
		return -enosys
	}
	return table[number](fd, buf, count)
}

// wrapper is the C library: a negative result means -1 and errno.
func wrapper(call string, number, fd int, buf *string, count int) int {
	raw := trap(number, fd, buf, count)
	if raw < 0 {
		fmt.Printf("%s: kernel returns %d, wrapper returns -1 with errno %d\n", call, raw, -raw)
		return -1
	}
	fmt.Printf("%s: kernel returns %d, wrapper returns %d\n", call, raw, raw)
	return raw
}

func main() {
	hello, hi, empty := "hello", "hi", ""
	wrapper(`write(1, "hello", 5)`, 0, 1, &hello, 5)
	wrapper(`write(7, "hi", 2)`, 0, 7, &hi, 2)
	wrapper("write(1, bad address, 4)", 0, 1, nil, 4)
	wrapper("getpid()", 1, 0, &empty, 0)
	wrapper("call number 99", 99, 0, &empty, 0)
	fmt.Println("standard output received: " + screen.String())
}

In Go: A nil *string stands for the bad address, and the table is a slice of function values.

Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go

Swift 6.4 · main.swift

let ebadf = 9  // Linux's numbers
let efault = 14
let enosys = 38
var screen = ""  // what standard output received

/// The kernel checks every argument first.
func sysWrite(_ fd: Int, _ buf: String?, _ count: Int) -> Int {
  guard fd == 1 else { return -ebadf }  // only descriptor 1 is open
  guard let buf else { return -efault }  // an address the process cannot read
  screen += buf.prefix(count)
  return count
}

func sysGetpid(_ fd: Int, _ buf: String?, _ count: Int) -> Int {
  4242  // the process's ID
}

let table: [(Int, String?, Int) -> Int] = [sysWrite, sysGetpid]  // call 0 is write, 1 is getpid

/// Kernel mode: find the handler.
func trap(_ number: Int, _ fd: Int, _ buf: String?, _ count: Int) -> Int {
  table.indices.contains(number) ? table[number](fd, buf, count) : -enosys
}

/// The C library: a negative result means -1 and errno.
@discardableResult
func wrapper(_ call: String, _ number: Int, _ fd: Int, _ buf: String?, _ count: Int) -> Int {
  let raw = trap(number, fd, buf, count)
  guard raw >= 0 else {
    print("\(call): kernel returns \(raw), wrapper returns -1 with errno \(-raw)")
    return -1
  }
  print("\(call): kernel returns \(raw), wrapper returns \(raw)")
  return raw
}

wrapper("write(1, \"hello\", 5)", 0, 1, "hello", 5)
wrapper("write(7, \"hi\", 2)", 0, 7, "hi", 2)
wrapper("write(1, bad address, 4)", 0, 1, nil, 4)
wrapper("getpid()", 1, 0, "", 0)
wrapper("call number 99", 99, 0, "", 0)
print("standard output received: \(screen)")

In Swift: guard let unwraps the buffer, and table.indices.contains checks the number before it is used.

Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main

Ruby 4.0 · main.rb

# frozen_string_literal: true

EBADF = 9 # Linux's numbers
EFAULT = 14
ENOSYS = 38
SCREEN = +'' # what standard output received

# The kernel checks every argument first.
def sys_write(descriptor, buffer, count)
  return -EBADF unless descriptor == 1 # only descriptor 1 is open
  return -EFAULT if buffer.nil? # an address the process cannot read

  SCREEN << buffer[0, count]
  count
end

def sys_getpid(_descriptor, _buffer, _count)
  4242 # the process's ID
end

TABLE = [method(:sys_write), method(:sys_getpid)].freeze # call 0 is write, 1 is getpid

# Kernel mode: find the handler.
def trap(number, descriptor, buffer, count)
  return -ENOSYS unless number.between?(0, TABLE.size - 1)

  TABLE[number].call(descriptor, buffer, count)
end

# The C library: a negative result means -1 and errno.
def wrapper(call, number, descriptor, buffer, count)
  raw = trap(number, descriptor, buffer, count)
  if raw.negative?
    puts "#{call}: kernel returns #{raw}, wrapper returns -1 with errno #{-raw}"
    return -1
  end
  puts "#{call}: kernel returns #{raw}, wrapper returns #{raw}"
  raw
end

wrapper('write(1, "hello", 5)', 0, 1, 'hello', 5)
wrapper('write(7, "hi", 2)', 0, 7, 'hi', 2)
wrapper('write(1, bad address, 4)', 0, 1, nil, 4)
wrapper('getpid()', 1, 0, '', 0)
wrapper('call number 99', 99, 0, '', 0)
puts "standard output received: #{SCREEN}"

In Ruby: method(:sys_write) turns each handler into a Method object, so the table can call it.

Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb

Kotlin 2.4 · Main.kt

const val EBADF = 9 // Linux's numbers
const val EFAULT = 14
const val ENOSYS = 38
val screen = StringBuilder() // what standard output received

// The kernel checks every argument first.
fun sysWrite(
    fd: Int,
    buf: String?,
    count: Int,
): Int {
    if (fd != 1) return -EBADF // only descriptor 1 is open
    if (buf == null) return -EFAULT // an address the process cannot read
    screen.append(buf.take(count))
    return count
}

@Suppress("UNUSED_PARAMETER")
fun sysGetpid(
    fd: Int,
    buf: String?,
    count: Int,
): Int = 4242 // the process's ID

val table: List<(Int, String?, Int) -> Int> = listOf(::sysWrite, ::sysGetpid) // 0 and 1

// Kernel mode: find the handler.
fun trap(
    number: Int,
    fd: Int,
    buf: String?,
    count: Int,
): Int = table.getOrNull(number)?.invoke(fd, buf, count) ?: -ENOSYS

// The C library: a negative result means -1 and errno.
fun wrapper(
    call: String,
    number: Int,
    fd: Int,
    buf: String?,
    count: Int,
): Int {
    val raw = trap(number, fd, buf, count)
    if (raw < 0) {
        println("$call: kernel returns $raw, wrapper returns -1 with errno ${-raw}")
        return -1
    }
    println("$call: kernel returns $raw, wrapper returns $raw")
    return raw
}

fun main() {
    wrapper("write(1, \"hello\", 5)", 0, 1, "hello", 5)
    wrapper("write(7, \"hi\", 2)", 0, 7, "hi", 2)
    wrapper("write(1, bad address, 4)", 0, 1, null, 4)
    wrapper("getpid()", 1, 0, "", 0)
    wrapper("call number 99", 99, 0, "", 0)
    println("standard output received: $screen")
}

In Kotlin: Function references such as ::sysWrite fill the table, and getOrNull gives null for a number past the end.

Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar

Dart 3.13 · main.dart

const ebadf = 9; // Linux's numbers
const efault = 14;
const enosys = 38;
final screen = StringBuffer(); // what standard output received

/// The kernel checks every argument first.
int sysWrite(int fd, String? buf, int count) {
  if (fd != 1) return -ebadf; // only descriptor 1 is open
  if (buf == null) return -efault; // an address the process cannot read
  screen.write(buf.substring(0, count));
  return count;
}

int sysGetpid(int fd, String? buf, int count) => 4242; // the process's ID

// call 0 is write, 1 is getpid
final table = <int Function(int, String?, int)>[sysWrite, sysGetpid];

/// Kernel mode: find the handler.
int trap(int number, int fd, String? buf, int count) =>
    number >= 0 && number < table.length
    ? table[number](fd, buf, count)
    : -enosys;

/// The C library: a negative result means -1 and errno.
int wrapper(String call, int number, int fd, String? buf, int count) {
  final raw = trap(number, fd, buf, count);
  if (raw < 0) {
    print('$call: kernel returns $raw, wrapper returns -1 with errno ${-raw}');
    return -1;
  }
  print('$call: kernel returns $raw, wrapper returns $raw');
  return raw;
}

void main() {
  wrapper('write(1, "hello", 5)', 0, 1, 'hello', 5);
  wrapper('write(7, "hi", 2)', 0, 7, 'hi', 2);
  wrapper('write(1, bad address, 4)', 0, 1, null, 4);
  wrapper('getpid()', 1, 0, '', 0);
  wrapper('call number 99', 99, 0, '', 0);
  print('standard output received: $screen');
}

In Dart: The table's type, int Function(int, String?, int), is a function type, so the handlers go into a typed list.

Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart

What every language printed

write(1, "hello", 5): kernel returns 5, wrapper returns 5
write(7, "hi", 2): kernel returns -9, wrapper returns -1 with errno 9
write(1, bad address, 4): kernel returns -14, wrapper returns -1 with errno 14
getpid(): kernel returns 4242, wrapper returns 4242
call number 99: kernel returns -38, wrapper returns -1 with errno 38
standard output received: hello

The kernel served the first write and getpid. It refused the second write with −9, because descriptor 7 is not open; the third with −14, because the buffer’s address cannot be read; and call number 99 with −38, because the table has no such entry. These are the numbers Linux’s own error headers give EBADF, EFAULT and ENOSYS. In each case the wrapper returned −1 and left the number in errno.

Key idea

A system call is a function call into the C library, a trap into the kernel, a lookup in the kernel’s table and a handler that checks every argument; the result comes back in a register, and the wrapper turns a failure into −1 and errno.

Checkpoint

Check: The path of a system call

  1. Question 1 of 2 In a write call, which side checks that the buffer lies in memory the process may read?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The kernel's write handler, before it copies the bytes in

    The kernel treats every argument as untrusted: descriptors, addresses, lengths. In the model, a bad address got -14 back, EFAULT on Linux, and the process went on.

  2. Question 2 of 2 In the model, the program traps with call number 99, which is not in the kernel's table. What does the program see?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The wrapper returns -1 with errno 38, ENOSYS on Linux

    The table lookup is checked like every other argument: the model returned -38, and the wrapper turned it into -1 with errno 38, which Linux's errno.h names ENOSYS, an invalid system call number.

Checking for failure: return values and errno

A failed call announces itself in its return value, usually −1, and leaves the reason in errno: The number of the last error a system call or library function reported, such as ENOENT or EBADF. It is meaningful only right after a call whose return value said it failed, and each thread has its own.. errno(3) adds two rules that are easy to miss: the value means something only when the return value indicated an error, and no system call or library function ever sets it to zero. This program makes calls that fail in three ways, then shows what happens when errno is read after a success:

Three failures, two successes, and errno left over C · errno_demo.c
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static void report(const char *call, int result) {
  if (result == -1) {
    printf("%-26s -> -1, errno %d: %s\n", call, errno, strerror(errno));
  } else {
    printf("%-26s -> %d\n", call, result);
  }
}

int main(void) {
  report("open(\"missing.txt\", ...)", open("missing.txt", O_RDONLY));
  report("open(\".\", O_WRONLY)", open(".", O_WRONLY));
  report("close(42)", close(42));
  int fd = open("notes.txt", O_WRONLY | O_CREAT | O_TRUNC, 0600);
  if (fd == -1) {
    perror("open notes.txt");
    return 1;
  }
  report("write(fd, \"hello\\n\", 6)", (int)write(fd, "hello\n", 6));
  report("close(fd)", close(fd));
  unlink("notes.txt");

  // A successful call does not reset errno: only the return value says
  // whether a call failed.
  if (open("missing.txt", O_RDONLY) == -1) {
    (void)getpid(); // getpid always succeeds
    printf("after a failed open and a successful getpid, errno is %d (%s)\n",
           errno, errno == ENOENT ? "still ENOENT" : "something else");
  }
  return 0;
}

Output

open("missing.txt", ...)   -> -1, errno 2: No such file or directory
open(".", O_WRONLY)        -> -1, errno 21: Is a directory
close(42)                  -> -1, errno 9: Bad file descriptor
write(fd, "hello\n", 6)    -> 6
close(fd)                  -> 0
after a failed open and a successful getpid, errno is 2 (still ENOENT)

Recorded with Apple clang version 21.0.0 (clang-2100.3.34.2) on macOS 26 arm64. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined errno_demo.c -o errno_demo && ./errno_demo

The three failures each left a different number: 2 (ENOENT) for a missing file, 21 (EISDIR) for opening a directory for writing, 9 (EBADF) for closing a descriptor that was never opened. The write returned 6, the number of bytes it wrote. The last line is the trap: after a failed open, a successful getpid left errno at 2, so a program that read errno without looking at the return value would report an error that never happened. errno is also thread-local, errno(3) says, so one thread’s failure never shows up in another thread’s errno.

Reading errno first

if (errno != 0) … after a call reports old errors as new. Test the return value first, then read errno at once, before any other call: int fd = open(path, O_RDONLY); if (fd == -1) perror("open");.

Checkpoint

Check: Return values and errno

  1. Question 1 of 2 errno_demo.c made an open fail, then called getpid, which succeeded. What did errno hold after getpid?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 2, ENOENT, still from the failed open

    errno is meaningful only right after a call that reported failure through its return value. Reading it after a success reads a leftover, as the run's last line shows.

  2. Question 2 of 2 What is the right way to tell whether open failed, and why it failed?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Check whether it returned -1, and only then read errno

    Every POSIX system call wrapper reports failure in its return value, usually -1. Read errno right after such a return, before any other call can change it.

Six groups of system calls

POSIX defines the portable calls and Linux’s syscalls(2) lists hundreds of its own, but sorting them by purpose, a skill CS2023 lists among its learning outcomes, leaves six groups:

Group What the calls do Examples
Process control Create, run, wait for and end processes fork, execve, waitpid, _exit, kill
File management Create, open, read, write, rename and remove files open, read, write, close, rename, unlink
Device management Control devices beyond reading and writing them ioctl on Linux, tcsetattr for terminals
Information maintenance Read or set the time, IDs and system data getpid, getppid, clock_gettime, uname
Communication Move data between processes pipe, socket, send, recv, shared mmap
Protection Change who may do what chmod, chown, umask, setuid

Some calls sit on a border: kill is process control, though it also carries a small message, and read and write serve files, pipes, sockets and devices alike, because the kernel makes all of them look like files. The groups help most when reading a trace of an unfamiliar program: its calls of each group show what it was asking the operating system to do.

Checkpoint

Check: The groups of system calls

  1. One question Which group does chmod belong to?

    Choose one answer.

    Show the answer: One question

    Answer: Protection

    The groups follow purpose: process control, file management, device management, information maintenance, communication and protection. A call can touch a file and still belong to protection.

Where each system keeps its stable interface

A program built today should still run after the next operating-system update, so each system promises some interface to stay the same. They put the promise in different places:

System The stable interface What that means for programs
Linux The system calls themselves A statically linked program, which makes its system calls without a shared C library, keeps working across kernel updates
macOS The system libraries, libSystem and the frameworks Programs should make system calls through libSystem; the numbers of the kernel’s calls may change

CS2023 names the contrast directly, Linux’s “perma-stable” system-call ABI against kernels whose ABI changes with every release. Apple’s technical note QA1118 says that a statically linked binary assumes compatibility at the system-call interface, where Apple makes no guarantees, while it strives for compatibility in each dynamically linked system library. Go’s runtime followed that rule from Go 1.12 on: its release notes say libSystem is now used for system calls on Darwin, to stay forward-compatible with future versions of macOS and iOS.

Checkpoint

Check: Where the stable interface lives

  1. One question Why does Apple advise against statically linked programs, which make system calls into the kernel directly?

    Choose one answer.

    Show the answer: One question

    Answer: Apple guarantees compatibility only for its dynamically linked system libraries, not for the kernel's system-call interface

    On macOS the stable boundary is libSystem; Go's runtime moved to it in Go 1.12 for forward compatibility. On Linux the system-call interface itself is kept stable, which is why statically linked programs keep working there.

What a system call costs, and calls that skip the kernel

Each trip into the kernel pays for the trap, the kernel’s checks and the return. The program below times three things one million times each, keeping the best of three runs: an ordinary function call, a clock read, and getppid, which Rust’s standard library offers as parent_id and which enters the kernel on every call:

A function call, a clock read and a system call, timed Rust · syscall_cost.rs
use std::hint::black_box;
use std::os::unix::process::parent_id;
use std::time::Instant;

const CALLS: u32 = 1_000_000; // calls timed in each run
const RUNS: usize = 3; // the best of 3 runs: other programs can only slow a run

fn ns_per_call(mut call: impl FnMut()) -> f64 {
    let mut best = f64::MAX;
    for _ in 0..RUNS {
        let start = Instant::now();
        for _ in 0..CALLS {
            call();
        }
        best = best.min(start.elapsed().as_nanos() as f64 / f64::from(CALLS));
    }
    best
}

#[inline(never)]
fn plain_function(x: u32) -> u32 {
    x.wrapping_mul(3)
}

fn main() {
    let function = ns_per_call(|| {
        black_box(plain_function(black_box(7)));
    });
    let clock = ns_per_call(|| {
        black_box(Instant::now());
    });
    let parent = ns_per_call(|| {
        black_box(parent_id()); // getppid: a real system call every time
    });
    println!("a plain function call:  {function:6.1} ns");
    println!("reading the clock:      {clock:6.1} ns");
    println!("getppid, a system call: {parent:6.1} ns");
    println!(
        "getppid took {:.0} times as long as a clock read",
        parent / clock
    );
}

Output

a plain function call:     1.1 ns
reading the clock:        22.6 ns
getppid, a system call:  174.5 ns
getppid took 8 times as long as a clock read

This output changes from run to run: the times depend on the machine and on what else it is running

Recorded with rustc 1.99.0 (b940084d7) on macOS 26 arm64. To run it yourself: rustc --edition 2024 -O syscall_cost.rs && ./syscall_cost

The function call cost about a nanosecond, the clock read tens of nanoseconds, and getppid well over a hundred, several times the clock read. The clock read is so much cheaper because it does not make the round trip. vdso(7) describes how Linux does it: the kernel maps a small library, the vDSO: A small library that the Linux kernel maps into every process, so that calls such as clock_gettime run as ordinary function calls without entering the kernel., into every process, so that a call to gettimeofday changes from a system call to “a normal function call and a few memory accesses”. The recorded run on macOS shows the same pattern, a clock read far too cheap to include a trip through the kernel.

That cost is why the C library buffers output. A program that prints a thousand short lines to a file makes one write per buffer-full instead of one per line, and the exercise below counts the difference.

Checkpoint

Check: What a system call costs

  1. One question In the recorded run, reading the clock cost tens of nanoseconds and getppid well over a hundred. What explains the difference?

    Choose one answer.

    Show the answer: One question

    Answer: getppid traps into the kernel on every call, while the clock is read without that round trip

    On Linux, vdso(7) describes the mechanism: the kernel maps code and data into every process, so a clock read becomes a normal function call and a few memory accesses. getppid has no such shortcut and pays for the full trap.

Practise: count the write calls that buffering saves

Write a function that counts the write system calls a program makes when it prints through an unbuffered, a line-buffered or a fully buffered stream, and run the sample tests:

Exercise · Easy · Python

Count the write calls that buffering saves

A program prints its output in pieces, through the C library's buffer, and every time the library hands bytes to the kernel it makes one write system call. Write count_writes(lines, buffer_size, mode) in writes.py, which returns how many write calls the program makes.

  • lines lists the program's lines; each line is a list of the lengths, in bytes, of the pieces printed for it. The last piece of a line ends with its newline.
  • mode is "unbuffered", "line" or "full":
    • unbuffered: every piece is written at once, one write per piece;
    • line: the pieces of a line collect in the buffer, which is written out when the line ends and also whenever it fills up (buffer_size bytes);
    • full: pieces collect across lines, the buffer is written whenever it fills up, and once more at the end if anything is left in it.

For example, two lines printed as [[5, 3, 1], [10, 1]] with a buffer of 8 bytes take 5 writes unbuffered, 4 with line buffering (2 for each line) and 3 with full buffering (20 bytes in total).

Starter code · writes.py

def count_writes(lines, buffer_size, mode):
    """The number of write system calls for the pieces in lines, buffered as mode says."""
    # Replace this line with your code.
    return 0
The sample tests · test_writes.py
from writes import count_writes

EXAMPLE = [[5, 3, 1], [10, 1]]


def test_the_example():
    """gives 5 writes unbuffered, 4 line buffered and 3 fully buffered"""
    assert count_writes(EXAMPLE, 8, "unbuffered") == 5
    assert count_writes(EXAMPLE, 8, "line") == 4
    assert count_writes(EXAMPLE, 8, "full") == 3


def test_a_thousand_short_lines():
    """turns a thousand one-piece lines of 50 bytes into 13 writes with a 4096-byte buffer"""
    lines = [[50]] * 1000
    assert count_writes(lines, 4096, "unbuffered") == 1000
    assert count_writes(lines, 4096, "line") == 1000
    assert count_writes(lines, 4096, "full") == 13


def test_exact_fits():
    """writes a buffer that fills exactly once, with nothing left over"""
    assert count_writes([[8]], 8, "line") == 1
    assert count_writes([[8]], 8, "full") == 1
    assert count_writes([[4, 4], [8]], 8, "full") == 2
    assert count_writes([[4, 4], [8]], 8, "line") == 2


def test_long_lines():
    """splits a line longer than the buffer into several writes"""
    assert count_writes([[20]], 8, "line") == 3
    assert count_writes([[3, 3, 3]], 4, "line") == 3
    assert count_writes([[3, 3, 3]], 4, "unbuffered") == 3


def test_nothing_printed():
    """makes no write at all when nothing is printed"""
    for mode in ["unbuffered", "line", "full"]:
        assert count_writes([], 8, mode) == 0
A hint

With full buffering only the total matters: total bytes fill the buffer total // buffer_size times, plus one last write if there is a remainder, which is math.ceil(total / buffer_size). Line buffering applies the same rule to each line on its own.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Finish with the lesson’s questions, asked one by one, starting where your answers were weakest:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Which instruction does the C library's wrapper use to enter the Linux kernel on 64-bit Arm?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: svc

    Each architecture has its own trap instruction and register convention, which syscall(2) tabulates: svc on Arm, syscall on x86-64, ecall on RISC-V. The C library hides the difference behind the same write function.

  2. Question 2 of 10 Why does the kernel use a number and a table instead of letting a program jump to the handler it wants?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: So that the program can enter the kernel only at one checked point, and reach only the handlers the table lists

    The trap enters at one place; the number picks a row of the table; the handler checks its arguments. Every system call takes this path, so every request meets the same checks.

  3. Question 3 of 10 In errno_demo.c, write(fd, "hello\n", 6) succeeded. What did it return?

    Type a number.

    Show the answer: Question 3 of 10

    Answer: 6 bytes

    write returns the number of bytes written on success, here all 6. A program must still compare the result with the length it asked for, because write may write fewer bytes than requested.

  4. Question 4 of 10 Two threads call open at the same time; one fails with ENOENT and the other succeeds. Can the second thread's errno show the first thread's error?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: No; errno is thread-local, so each thread has its own

    errno is a per-thread value behind a macro, so a thread's errno always describes that thread's last failed call. Within one thread, the rule of checking the return value first still applies.

  5. Question 5 of 10 Which of these are process-control system calls?

    Choose every answer that is right.

    Show the answer: Question 5 of 10

    Answer:

    • fork
    • _exit
    • waitpid

    Process control covers creating, running, waiting for and ending processes; files, devices, information, communication and protection make up the other groups.

  6. Question 6 of 10 A program reads the clock with clock_gettime, sends a signal with kill and maps shared memory with mmap. In which three groups are these, in that order?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Information maintenance, process control, communication

    Grouping by purpose helps when reading an unfamiliar trace: the calls of each group tell you what a program was doing with the operating system at that moment.

  7. Question 7 of 10 On which system is the kernel's system-call interface itself the stable boundary that programs may rely on?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: Linux

    That difference is why a statically linked Linux program keeps running across kernel updates, and why runtimes such as Go call libSystem on macOS but can make system calls themselves on Linux.

  8. Question 8 of 10 Go's runtime changed, in Go 1.12, to use libSystem for its system calls on macOS. What problem did that solve?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Programs built with Go keep working on later macOS releases, because libSystem is the interface Apple keeps compatible

    The C library on macOS is part of the operating system's contract with programs, while the kernel's call numbers are an internal detail between the two.

  9. Question 9 of 10 The recorded run timed a plain function call, a clock read and getppid. Which was cheapest?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: The plain function call, at about a nanosecond

    The three show the layers of cost: a function call, library work without the kernel, and a full system call, each many times the one before.

  10. Question 10 of 10 A program prints 1,000 lines of 50 bytes each to a file, through a fully buffered stream with a 4,096-byte buffer. How many write system calls does it make?

    Type a number.

    A hint

    Only the total matters; count how many times 4,096 bytes fill up, plus one for what is left.

    Show the answer: Question 10 of 10

    Answer: 13 write calls

    50,000 bytes fill the buffer 12 times (49,152 bytes), and the last 848 bytes go out in a 13th write at the end. Unbuffered, the same program would make 1,000 writes, at well over a hundred nanoseconds each.

Interview questions

Here is a warm-up, answered completely. Harder interview questions from this lesson sit in the module’s bank: a logger that writes one byte at a time, and a statically linked program that behaves differently on Linux and macOS.

  1. Fresher · Warm-up

    What does errno hold, and when may a program read it?

    errno holds the number of the last error that a system call or library function reported, such as ENOENT for a missing file or EBADF for a bad descriptor. It is meaningful only right after a call whose return value said it failed, usually -1, so a program checks the return value first and reads errno at once, before any other call can change it. A successful call never sets errno to zero, so reading it after a success can show an old error. On Linux the kernel returns a negative error number and the C library's wrapper stores its absolute value in errno. errno is thread-local, so each thread sees only its own errors. strerror and perror turn the number into a message.

Key takeaways

  • A system call goes from a C library wrapper, through a trap instruction, to a handler the kernel picks from its table by number; the handler checks every argument before it does anything.
  • On Linux the kernel returns a negative error number, and the wrapper turns it into −1 and errno; the model returned −9, −14 and −38 for a closed descriptor, a bad address and an unknown call.
  • Test the return value first and read errno only after a failure: a successful getpid left errno at 2 from an earlier failed open.
  • System calls fall into six groups by purpose: process control, file management, device management, information maintenance, communication and protection.
  • Linux keeps its system calls stable, while macOS keeps its system libraries stable, which is why Go calls libSystem on macOS.
  • A function call cost about a nanosecond, a clock read tens of nanoseconds and getppid well over a hundred, so libraries buffer output and some calls, such as the clock, avoid the kernel altogether.

Terms in this lesson

errno
The number of the last error a system call or library function reported, such as ENOENT or EBADF. It is meaningful only right after a call whose return value said it failed, and each thread has its own.
system call
A request from a program to the kernel, made with a trap instruction that switches the processor into kernel mode at an entry point the kernel chose.
system-call table
The kernel's table of handlers, indexed by system-call number. A trap with a number the table does not have is refused with ENOSYS.
vDSO
A small library that the Linux kernel maps into every process, so that calls such as clock_gettime run as ordinary function calls without entering the kernel.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.