Geir Isene wrote his login shell in assembly and is using it as his actual default shell. That is a commitment. Not a toy, not a blog demo, but the program that runs when he logs in. The project surfaces a set of questions worth sitting with: what does a shell actually need to do at the syscall level, what does the kernel hand you when you become a login shell, and how much do you give up when you strip away libc?
How the Kernel Hands Off to Your Shell
The path from power-on to a shell prompt involves more coordination than most people think about. init or systemd spawns getty on each TTY, getty calls login, and login authenticates the user and reads /etc/passwd to find the shell. The format of that file is seven colon-delimited fields:
username:x:uid:gid:comment:/home/user:/path/to/shell
Once authentication succeeds, login calls execve with the shell path from that last field. The shell binary replaces the login process entirely. The kernel does not distinguish between a “login shell” and any other process; that concept is purely a convention enforced by the shell itself.
The convention works like this: when login invokes your shell, it prepends a hyphen to argv[0]. So instead of receiving "bash" as the process name, your shell receives "-bash". The shell checks whether argv[0][0] == '-' and, if so, sources /etc/profile and ~/.profile before doing anything else. This is the entire mechanism. Any program can be a login shell. An assembly program can be a login shell.
At the point your _start label runs, the stack layout on x86_64 Linux is deterministic:
rsp + 0: argc (8 bytes)
rsp + 8: argv[0] (pointer to process name string)
rsp + 16: argv[1] ...and so on
...
NULL (argv terminator)
envp[0] (environment strings)
...
NULL (envp terminator)
No main() wrapper, no __libc_start_main, just you and the ABI.
The Syscall Surface of a Working Shell
A shell that can run programs interactively and exit cleanly needs surprisingly few syscalls. Here is the core set for x86_64 Linux:
| rax | Name | Purpose |
|---|---|---|
| 0 | read | Read a command line from stdin |
| 1 | write | Print a prompt or error to stdout |
| 57 | fork | Spawn a child process |
| 59 | execve | Replace child image with the command |
| 60 | exit | Terminate the shell |
| 61 | wait4 | Wait for child to finish |
A prompt-read-execute loop in bare assembly looks roughly like this:
section .bss
buf resb 4096
section .data
prompt db "$ "
prompt_ln equ $ - prompt
section .text
global _start
_start:
.loop:
; write prompt
mov rax, 1
mov rdi, 1
lea rsi, [rel prompt]
mov rdx, prompt_ln
syscall
; read line
mov rax, 0
xor rdi, rdi
lea rsi, [rel buf]
mov rdx, 4096
syscall
test rax, rax
jle .exit ; EOF or error
; ... parse buf, build argv array, then:
mov rax, 57 ; fork
syscall
test rax, rax
jnz .parent
; child: execve
mov rax, 59
lea rdi, [rel cmd_path]
lea rsi, [rel argv_arr]
lea rdx, [rel envp_ptr]
syscall
; execve only returns on failure
mov rax, 60
mov rdi, 127
syscall
.parent:
mov rdi, rax ; child pid from fork
mov rax, 61 ; wait4
lea rsi, [rel wstatus]
xor rdx, rdx
xor r10, r10
syscall
jmp .loop
.exit:
mov rax, 60
xor rdi, rdi
syscall
That structure is the whole skeleton. Everything beyond it is complexity management: parsing the input buffer, building the null-terminated argv array that execve expects, handling errors, and deciding how much shell behavior you want to support.
Where the Real Work Lives
The loop above can run absolute paths. /usr/bin/ls would work. But making ls work without the full path requires PATH resolution, and that is where the code starts to grow. You have to read the PATH environment variable from envp, iterate its colon-separated entries, concatenate each one with the command name, and call execve until one succeeds or the list is exhausted. In C you reach for getenv, strtok_r, and snprintf. In assembly you implement all three inline.
The execve syscall itself demands a specific memory layout. It takes three arguments: a null-terminated path string, a pointer to a null-terminated array of pointers to null-terminated argument strings, and the same structure for the environment. Constructing that in assembly means carving up your input buffer, placing null bytes where spaces were, and writing the resulting pointer array somewhere. The stack is a natural place for the pointer array, but you have to be careful not to clobber it before the syscall.
Signal handling is the other unavoidable piece for a login shell. Without registering a SIGINT handler via sigaction (syscall 13), pressing Ctrl-C will kill the shell itself instead of the foreground child. A minimal handler that simply returns is enough to prevent that. The SIGCHLD disposition matters too if you ever want background jobs.
Comparison with Minimal C Shells
The xv6 shell, written for the MIT teaching operating system, is about 150 lines of C and supports pipes, I/O redirection, and sequential commands. It is routinely cited as the cleanest example of how a shell works. Even that small a program would expand significantly in assembly: every string operation, every pointer arithmetic step, every conditional that C expresses in a few tokens becomes explicit register management.
dash, the Debian Almquist Shell and the /bin/sh on most Linux systems, runs around 30,000 lines of C and handles POSIX sh in full. BusyBox’s ash is similarly scoped for embedded use. The point of comparison is not to suggest that an assembly shell should match these, but to illustrate that the parts people take for granted, quote handling, variable expansion, command substitution, represent a substantial amount of logic that C expresses concisely and assembly does not.
The assembly approach is not trying to win that comparison. It is making a different kind of argument: what is the irreducible minimum? What does the kernel actually need from a program before it will cooperate as a login shell? The answer is less than you might expect.
What the Exercise Teaches
Building anything in assembly on Linux forces you to read the syscall table and the calling convention with full attention. You cannot pass the wrong type to a function and have the compiler complain. You cannot accidentally use a higher-level abstraction. Every assumption you carry from working in C or higher gets checked against the ABI directly.
The login shell context adds the layer of the authentication handoff. Understanding that login signals “login shell” via the argv[0] prefix, that the environment your shell receives was constructed by login from the PAM stack and /etc/environment, that your shell’s file descriptors 0, 1, and 2 are connected to a TTY opened by getty, all of this becomes concrete when you are the program receiving it rather than a C runtime already set up by the time your code runs.
There is also something clarifying about having no safety net. libc is not a crutch you are leaning on; it is a tested, portable implementation of exactly the things you will spend time reimplementing. The assembly exercise makes the value of that library legible in a way that using it comfortably does not.
Isene’s project is a real program in daily use, which puts it in a different category from exercises that stop at “prints a prompt and runs /bin/ls”. The commitment to actually live with it means confronting the edge cases, the EOF handling, the signal behavior, the PATH lookup, because those are the things that make a shell actually usable rather than theoretically complete.
The kernel’s side of this bargain is remarkably stable. The x86_64 Linux syscall numbers and calling convention have not changed since the architecture was introduced. An assembly binary written against this interface twenty years ago would still run. That durability is one of the things you appreciate from this close to the metal: the contract is simple and the kernel keeps it.