IVAN LEE | QUANT DATA TERMINAL
BLOG ENTRY READ MODE
← BACK TO INDEX
────────────────────────────────────────────────────────────

ls Under the Hood

We type ls -lrt, glance at the output, and move on.

total 0
drwxr-xr-x@ 11 ivan  staff  352 Jun 17 21:10 quant_se_v1
drwxr-xr-x@ 10 ivan  staff  320 Jun 17 21:10 quant_se
drwxr-xr-x@ 12 ivan  staff  384 Jun 18 20:13 quant_leet
drwxr-xr-x@ 17 ivan  staff  544 Jun 19 10:42 old
drwxr-xr-x@  9 ivan  staff  288 Jun 20 23:17 quant_system_design

What happens underneath — from the moment you press Enter to the moment the output appears on your terminal — is so fast it’s easy to ignore. But the layers of abstraction that come together to make this work are worth understanding.

Everything in this post involves two things: system calls and signals. They travel in opposite directions.

  ┌──────────────────────────────┐
  │        User Space            │
  │     (your programs)          │
  │                              │
  │   syscalls ↓        ↑ signals│
  │            ↓        ↑        │
  ├──────────────────────────────┤
  │       Kernel Space           │
  │  (OS — the only code that    │
  │   can talk to hardware)      │
  └──────────────────────────────┘

System calls go downward — your process asks the kernel to do something (open a file, read a directory, terminate another process).

Signals go upward — the kernel tells your process something happened (you’ve been asked to terminate, a child process exited, the user pressed Ctrl+C).

What Happens When You Run ls

Your shell (bash, zsh, whatever) doesn’t execute ls itself. It delegates to the kernel through a well-defined sequence:

  Shell (parent process)

    ├── 1. fork()        → creates a child process (copy of the shell)

    ├── 2. wait()        → parent blocks, waiting for child to finish

    └── Child process

          ├── 3. execvp("ls", args)  → replaces child's memory with the ls binary

          ├── 4. ls runs:
          │     ├── opendir()    → open the directory
          │     ├── readdir()    → read each directory entry
          │     ├── stat()       → get metadata (permissions, size, timestamps)
          │     └── write()      → output formatted results to stdout

          └── 5. exit()  → child terminates, parent's wait() returns

Step by step:

  1. fork() — the shell creates a child process. This is a near-exact copy of the shell process itself, with its own PID. The child inherits the shell’s environment, file descriptors, and memory layout (copy-on-write).

  2. wait() — the parent shell calls wait() and blocks. It won’t accept new input until the child finishes. This is why your prompt doesn’t reappear until ls completes.

  3. execvp() — the child process replaces itself with the ls binary. After this call, the child is no longer running shell code — it’s running /bin/ls. The previous memory image is discarded entirely.

  4. Syscallsls does its actual work through system calls that cross from user space into kernel space:

    • opendir() opens the target directory.
    • readdir() iterates over directory entries (filenames, inode numbers).
    • stat() retrieves metadata for each entry — permissions, owner, group, size, timestamps. This is what makes -l (long format) work.
    • write() sends the formatted output to file descriptor 1 (stdout), which the terminal emulator renders on screen.
  5. exit() — the child process terminates. The kernel notifies the parent, wait() returns with the child’s exit status, and the shell prints your prompt again.

The entire sequence — fork, exec, syscalls, write, exit — completes in milliseconds. Five layers of abstraction, invisible unless you look for them.

What Happens When You Run kill

Killing a process follows a different path entirely. The shell doesn’t need to fork a child — it resolves the kill command (which is often a shell builtin) and makes a single system call.

kill (SIGTERM — signal 15)

When you type kill <pid>, the shell invokes the kill() syscall with signal 15 (SIGTERM). This is a request, not an order. The kernel delivers the signal to the target process, and the process gets a chance to handle it:

  • Flush buffers and close file handles
  • Terminate child processes gracefully
  • Run cleanup handlers or shutdown hooks
  • Write final log entries

The process can even choose to ignore SIGTERM entirely (though that’s generally a bad idea). The key point is that the process remains in control of its own shutdown.

kill -9 (SIGKILL — signal 9)

kill -9 <pid> sends SIGKILL. This is fundamentally different — it’s handled entirely by the kernel. The target process never receives the signal; the kernel simply removes it from the process table. No cleanup handlers run, no buffers are flushed, no child processes are notified.

  SIGTERM (kill / kill -15)          SIGKILL (kill -9)
  ─────────────────────────          ─────────────────
  Delivered to the process           Handled by the kernel
  Process can catch it               Process cannot catch it
  Process can ignore it              Process cannot ignore it
  Cleanup handlers run               No cleanup — immediate removal
  Graceful shutdown                  Forceful termination
  Default signal for kill            Last resort

This distinction matters in practice. SIGTERM is what you should reach for first — it lets the process shut down cleanly, release locks, close database connections, and reap child processes. SIGKILL is the fallback when a process is hung and won’t respond to SIGTERM. Using kill -9 as a first instinct risks orphaned child processes, corrupted files, and unreleased resources.

The reason SIGKILL can’t be caught or ignored is by design: the system needs a guaranteed way to terminate any process. If every signal could be intercepted, a misbehaving process could make itself unkillable.

Ctrl+C (SIGINT — signal 2)

Worth mentioning since it’s related: pressing Ctrl+C doesn’t invoke a system call from the shell. The terminal driver (in kernel space) intercepts the key combination and sends SIGINT directly to the foreground process group. Like SIGTERM, the process can catch or ignore it — but by convention, SIGINT means “the user wants you to stop what you’re doing,” and most programs treat it as an immediate but graceful exit.

cd — The Command That Isn’t

After looking at how ls uses fork and exec, it’s natural to assume every command works the same way. cd is the counterexample — and understanding why reveals something fundamental about the fork+exec model.

cd is a shell builtin. There is no /bin/cd binary. It can’t exist as an external command, and the reason is structural:

If cd were an external binary, the shell would have to fork() a child, exec("cd", "/some/path") in the child, and the child would call chdir() to change its working directory. Then the child exits. The problem: chdir() only affects the calling process. The child’s directory changes, the child terminates, and the parent shell’s working directory remains exactly where it was.

  Shell (cwd: /home/ivan)

    ├── fork() → Child process (cwd: /home/ivan)
    │                │
    │                ├── exec("cd", "/tmp")
    │                ├── chdir("/tmp")     ← only affects this process
    │                └── exit()            ← process gone, change lost

    └── still in /home/ivan               ← nothing happened

This is why cd must run inside the shell process itself — no fork, no exec, no signals. The shell makes a single syscall: chdir(), which tells the kernel to change the calling process’s working directory. That’s it — one syscall, applied directly to the shell’s own process, and the directory change persists. The same applies to other builtins like export, alias, and exit — they all need to modify the shell’s own state, which a child process can’t do.

It’s a simple constraint, but it exposes the boundary of the fork+exec model: any command that needs to change the parent’s state cannot be an external program.

Zombies and Orphans

Step 5 in the ls flow — the parent calling wait() and the child calling exit() — is the clean path. But what happens when that handshake breaks down?

Zombie Processes

When a child process terminates, it doesn’t fully disappear. The kernel keeps a minimal entry in the process table — the child’s PID, exit status, and resource usage statistics — until the parent calls wait() to collect it. Until that happens, the terminated child is a zombie process.

  Shell (parent)

    ├── fork() → Child process
    │                │
    │                └── exit()     ← child terminates

    └── never calls wait()
                                   ← child is now a zombie (defunct)
                                   ← still in process table, PID occupied

Zombies consume no CPU or memory — they’re just a row in the process table. But each one occupies a PID, and PIDs are a finite resource. A parent that repeatedly forks children without reaping them will eventually exhaust the PID space, and the system won’t be able to create new processes. You can spot them in ps output as processes in state Z (defunct).

In well-written programs, this doesn’t happen. The parent either calls wait() synchronously or installs a SIGCHLD handler to reap children asynchronously. The ls example does it correctly: the shell calls wait(), collects the exit status, and the child’s process table entry is freed.

Orphan Processes

The opposite scenario: the parent dies before the child. When a parent process terminates, any of its still-running children become orphan processes. The kernel doesn’t leave them parentless — it reassigns them to PID 1 (init or systemd), which becomes their adoptive parent.

  Shell (parent)

    ├── fork() → Child process (still running)

    └── exit()              ← parent terminates first

  PID 1 (init/systemd)

    └── adopts child        ← child continues running under PID 1

         └── exit()         ← PID 1 calls wait() and reaps it

PID 1 is specifically designed to call wait() on any adopted children, so orphans are always reaped properly — they won’t become permanent zombies. This is a deliberate safety net in the process model.

Orphans aren’t inherently a problem. Daemons and background services often intentionally orphan themselves — a technique called double-forking — to detach from the controlling terminal and run independently under PID 1. It’s a feature, not a bug, when done on purpose.

The failure case is accidental orphaning: a parent crashes, leaves children running, and those children hold resources (file locks, network connections, shared memory) that were supposed to be cleaned up by the parent. The children keep running, the resources stay held, and nobody notices until something else fails trying to acquire them.


Everything you type crosses the boundary between user space and kernel space. The only question is whether you notice.

CONNECTED IVAN LEE — CRYPTO.COM — SINGAPORE --:--:--