· 6 min read ·

The Login Shell Contract, Read Through Assembly

Source: lobsters

The Linux login process is something most developers interact with every day and almost never think about. When a shell prompt appears, a chain of processes has already run, established a session, applied resource limits, set up file descriptors, and handed control off to your shell binary. Writing that shell in assembly, as Geir Isene does in this recent post, strips out every layer of abstraction and forces you to confront what the contract actually says.

The login chain before your shell runs

Before a shell sees a single byte, a substantial amount of work has already happened. On a traditional Linux console login, the sequence runs roughly:

  1. init (or systemd) starts getty on the TTY
  2. getty opens the terminal device, configures the line discipline, and execs login
  3. login handles authentication via PAM, drops privileges with setuid() and setgid(), changes the working directory to the user’s home, and calls execve() on the shell binary

By the time the shell’s _start runs, file descriptors 0, 1, and 2 are already wired to the terminal. The session is established via setsid(). PAM has run pam_open_session(), potentially setting environment variables from /etc/environment, applying ulimits from /etc/security/limits.conf, and configuring any namespace or cgroup assignments the system administrator has specified.

The shell arrives in a clean process image, but into an environment that has already been shaped by several other programs.

The argv[0] convention

One piece of the login contract requires the shell to detect its own role. The convention traces back to Unix Version 7: when login calls execve(), it passes argv[0] with a leading hyphen.

execve("/bin/bash", ["-bash", NULL], envp);

The executable name becomes -bash or -zsh or -sh. On seeing that leading hyphen, a POSIX-compliant shell knows to behave as a login shell: source /etc/profile, then ~/.profile (or shell-specific equivalents like ~/.bash_profile for Bash or ~/.zprofile for Zsh).

The kernel neither enforces nor sets this. It is a convention, documented in the POSIX specification for shell startup and honored by every login program and POSIX shell since the late 1970s. An assembly login shell must check argv[0][0] == '-' explicitly, because there is no shell framework to do it on the program’s behalf.

What the kernel hands to _start

In C, main(int argc, char **argv, char **envp) receives its arguments through the C runtime startup code in __libc_start_main. In assembly there is no C runtime. The entry point _start receives the process stack as-is from the kernel’s exec handler.

On x86-64 Linux, the stack layout at process entry is:

[RSP+0]              argc           (8 bytes)
[RSP+8]              argv[0]        (pointer)
...
[RSP+8*(argc+1)]     NULL           (argv terminator)
[RSP+8*(argc+2)]     envp[0]        (pointer)
...
NULL                                (envp terminator)
<auxiliary vector>                  (AT_* entries)

The auxiliary vector that follows envp carries kernel-supplied metadata: AT_PAGESZ gives the system page size, AT_UID and AT_EUID carry real and effective user IDs without a syscall, AT_RANDOM provides 16 bytes of entropy, and AT_EXECFN points to the executable’s path. The Linux kernel documentation covers the full set.

Libc parses all of this in __libc_start_main before calling main. In assembly, you walk the stack yourself at _start. There is no shortcut.

The syscall surface of a minimal shell

What system calls does a login shell actually need? The answer depends on how minimal the implementation aims to be.

For a shell that acts purely as a login wrapper, sourcing profile files and then execing the user’s interactive shell, the list is short:

SyscallNumber (x86-64)Purpose
read0Read from stdin or opened files
write1Write errors or prompts
open2Open /etc/profile, ~/.profile
close3Release file descriptors
execve59Exec /bin/sh for scripts, then the target shell
exit60Exit on failure

For a shell that also functions as an interactive REPL, the list grows:

SyscallNumber (x86-64)Purpose
fork57Create child processes
wait461Wait for child completion
rt_sigaction13Handle SIGCHLD, SIGINT
getcwd79Build the prompt
chdir80Implement cd
getpid39For $$ expansion

A shell that skips signal handling will break Ctrl-C and leave zombie processes. A shell that skips chdir cannot implement cd. Each omission is a deliberate position on what “minimal” means, and in assembly every syscall that gets included requires explicit registration code and error handling.

What disappears when you remove libc

Libc does an enormous amount of work that programmers rarely account for. Beyond __libc_start_main, the C library provides malloc and the heap allocator, stdio buffering for efficient file I/O, glob() for pathname expansion, wordexp() for word splitting and tilde expansion, regcomp() for pattern matching, and getenv()/setenv() for environment variable lookup and modification.

Shell builtins that seem trivial depend on this infrastructure. Processing echo $HOME requires parsing $HOME, walking the envp array to find a matching string, extracting the value, and writing it. In C, getenv("HOME") is a one-liner backed by libc’s internal environment copy. In assembly, you compare strings byte by byte against the raw envp pointers from the stack.

The sash project (Stand-Alone Shell) takes the same no-external-dependencies philosophy in C: a shell that survives a broken shared library environment. Assembly takes that approach to its logical end, removing even the C standard library from the dependency list.

Historical reference points

The Thompson shell, the first Unix shell from 1971, ran to roughly 900 lines of C. It had no variables, no scripting beyond basic command sequences, and no job control. It was enough to be useful on the hardware of the time.

The Bourne shell added scripting in 1979 and sits at around 5,000 lines. Modern minimal shells like dash (Debian Almquist Shell) implement the full POSIX sh specification in roughly 12,000 lines of C and produce a binary under 100KB. BusyBox includes a shell as one slice of a broader embedded toolkit.

None of these is trying to compete with an assembly implementation on simplicity. The assembly approach is not positioned as a practical alternative to dash; it is an investigation into what the shell startup contract actually looks like when no tooling abstracts it.

Why the exercise has value

Writing a login shell in assembly forces you to answer questions that higher-level implementations do not surface. You cannot hand-wave environment setup; you have to know what the kernel puts on the stack and in what order. You cannot ignore the argv[0] convention; there is no framework to check it for you. You cannot assume malloc exists or that buffered I/O is available.

You also have to decide exactly where your shell’s responsibilities end. The session was established before your code ran. The file descriptors were inherited. The resource limits were applied. What does your shell actually owe to the user sitting at the terminal, and what was already done for it?

Isene’s post is worth reading for anyone curious about where that minimum sits. The assembly forces an unusually clear accounting of the login process because every abstraction that gets used has to be written explicitly, and every abstraction that gets skipped shows up as a missing capability.

Most working shell code runs on top of C, libc, and decades of accumulated POSIX infrastructure. Reading code that deliberately discards all of that gives you a more accurate model of the process lifecycle than documentation alone, because the documentation can be skimmed and the assembly cannot.

Was this interesting?