Deep Dive: What actually happens when you type 'ls'?

A journey from the shell prompt to the kernel and back. Understanding fork(), execve(), and how Linux directories actually work.

linux kernel systems c

Classic interview question: "What happens when you type ls -l and hit Enter?"

"The shell finds ls and runs it" is technically true and tells you nothing. Here's what's actually going on, which I got curious about while hacking on ls-btw, a tiny ls clone I wrote in C.

1. The shell parses your input

Your shell is blocked on read() from stdin, waiting. You type ls -l, it tokenizes into ["ls", "-l"], expands your ls alias if you have one (--color=auto is common), and checks if ls is a builtin. It's not, so it moves on.

2. Finding the binary

The shell walks $PATH (e.g. /usr/local/bin:/usr/bin:/bin) until it finds an executable named ls — usually /usr/bin/ls. Bash caches this in a hash table (hash command), which is why moving a binary sometimes gives you a stale "command not found."

3. fork() then exec()

The shell can't turn itself into ls without losing your session, so it clones itself with fork(). The parent calls wait() and sits there. The child calls execve("/usr/bin/ls", argv, envp), and the kernel wipes the child's memory, loads ls off disk, and jumps to its entry point. Same PID, totally different program:

# strace -f -e trace=execve bash -c "ls -l"
execve("/usr/bin/bash", ["bash", "-c", "ls -l"], ...) = 0
[pid 1234] execve("/usr/bin/ls", ["ls", "-l"], ...) = 0

4. The dynamic linker

ls isn't statically linked, so before main() runs, the kernel loads ld-linux.so, which maps in libc (and friends) and resolves symbols. Only then does ls's main() actually start.

5. Reading the directory

This is the part ls-btw actually does. ls can't touch disk directly — it goes through the kernel:

  • openat() opens the directory
  • getdents64() lists entries (filenames + inode numbers)
  • stat()/lstat() per file for -l (permissions, owner, size, mtime)

In ls-btw I used opendir()/readdir(), which is libc's wrapper around getdents64 — same syscall underneath, just less code. Run strace -f on either one and the shape looks the same:

openat(AT_FDCWD, ".", O_RDONLY|O_DIRECTORY) = 3
getdents64(3, /* 10 entries */, 32768) = 312
newfstatat(AT_FDCWD, "file1.txt", {...}, 0) = 0

6. Writing the output

ls formats the output and calls write() on fd 1. It also checks isatty() — a real terminal gets columns and color, a pipe (ls | cat) gets plain one-per-line output instead. When ls exits, the parent shell wakes up from wait() and prints your next prompt.


Why bother knowing this

  • "not found" vs "permission denied" vs a crash point to different steps above, useful for debugging.
  • Every stat() is a context switch, which is why ls -l on a huge directory is way slower than plain ls.
  • fork-exec is why children inherit file descriptors, env vars, and permissions the way they do.

If you want to see step 5 for yourself, build ls-btw and run it under strace -f next to real ls. Same syscalls, way less code.