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.

Assembly (x86-64, AArch64, RISC-V)  Module 1 – Start here: what assembly is and how to run it

First program: return an exit status

A first program on three ISAs that exits with 42: w0 returns on a Mac, _start makes the exit system call on Linux, and the shell keeps the low eight bits.

  • Beginner
  • 15 minutes
  • Examples run with Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Write a minimal program on each ISA that ends with a chosen exit status
  • Explain why the AArch64 Mac version returns while the Linux versions make the exit system call themselves
  • Name the three system-call numbers and their register pairs on x86-64, AArch64 and RISC-V
  • Predict what the shell reads back from a status above 255 or below 0
  • Read a link error that names a missing entry symbol and fix it

Before you start

What you’ll learn · Before you start

What you will learn

  • Write a minimal program on each ISA that ends with a chosen exit status
  • Explain why the AArch64 Mac version returns while the Linux versions make the exit system call themselves
  • Name the three system-call numbers and their register pairs on x86-64, AArch64 and RISC-V
  • Predict what the shell reads back from a status above 255 or below 0
  • Read a link error that names a missing entry symbol and fix it

Before you start

On this page

Every program hands back a small whole number when it ends, called its exit status: The small whole number a program hands back when it ends. The parent reads only the low 8 bits, so the value runs from 0 to 255; by convention 0 means success and non-zero means a specific failure., and the shell shows that number through its special parameter $?. The AArch64 program exit42.s on an Apple silicon Mac is six lines of code under a three-line comment, and it makes the shell print exit=42; the same job on x86-64 Linux takes three instructions that write 60 to eax, 42 to edi and run syscall, and on RISC-V it writes 93 to a7, 42 to a0 and runs ecall.

This lesson writes that first program on each of the three ISAs the track teaches, watches the number travel from a register to $?, and shows why 300 reads back as 44 and why linking a program with only _start fails on a Mac.

Where an exit status comes from, and where it goes

Three programs run in a terminal, one after the other, and each sets the shell’s $?:

Every command sets $?; echo overwrites it Python · status_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

# Every command leaves an exit status; $? holds the status of the command that ran last.
session.run("""
true; echo "exit=$?"
false; echo "exit=$?"
ls no-such-file; echo "exit=$?"
sh -c 'exit 7'; echo "exit=$?"
sh -c 'exit 7' && echo "success" || echo "failure, status $?"
sh -c 'exit 7'; echo "first: $?"; echo "second: $?"
""")

Output

$ true; echo "exit=$?"
exit=0
$ false; echo "exit=$?"
exit=1
$ ls no-such-file; echo "exit=$?"
ls: no-such-file: No such file or directory
exit=1
$ sh -c 'exit 7'; echo "exit=$?"
exit=7
$ sh -c 'exit 7' && echo "success" || echo "failure, status $?"
failure, status 7
$ sh -c 'exit 7'; echo "first: $?"; echo "second: $?"
first: 7
second: 0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 status_session.py

true ends with 0, false with 1, and ls no-such-file prints an error and ends with 1 as well. sh -c 'exit 7' ends with 7, and the && || chain after it prints failure, status 7, because && skipped its success branch (the left side failed) and || ran its failure, status $? branch with $? still holding 7. The last two lines of the recording show the hazard: a program that ran before echo leaves 7 in $?, but the second echo reads 0, because the first echo has already overwritten the status with its own.

Read $? on the line straight after the command that matters. The shell gets the number through system call: A request a running program makes to the kernel, through a special instruction (syscall on x86-64, svc on AArch64, ecall on RISC-V) that switches privilege. The program leaves a call number and arguments in agreed registers and the kernel reads them.: the kernel ends the process, keeps its status for the parent, and the shell’s wait returns the number into $? when waitpid or wait hands it over. The POSIX shell specification names ? as “the decimal exit status of the most recent pipeline”.

Interactive · message by message Where the exit status goes after a program ends Step through one run of ./exit42 and follow the number 42 from the program to the shell.
Where the exit status goes after a program ends, step 6 of 6. Messages so far: Shell to Kernel: run ./exit42 and wait; Kernel to ./exit42: start at the entry point; ./exit42 to Kernel: exit, status 42; Kernel to Shell: status 42.Shell$? = 0 after the echo./exit42the new processKernelholds status 42run ./exit42 and waitstart at the entry pointexit, status 42status 42

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

Every step as text
  1. The shell starts it. You type ./exit42. The shell asks the kernel for a new process, has the program loaded into it, and waits for that process to end.
  2. The program runs. The kernel starts the program at its entry point. This program does almost nothing: it puts 42 in the register that carries its status.
  3. It asks to end. The program ends with the exit system call and hands the kernel the number 42. On Linux the program makes that call itself; on macOS the C library makes it once main returns.
  4. The kernel keeps it. The process is gone, but the kernel keeps its status until the parent asks for it. A parent can read only the low 8 bits of that number, so 0 to 255.
  5. The shell collects it. The shell's wait ends. It takes the status, 42, and stores it in its special parameter $?, the status of the last command.
  6. echo reads it. echo "exit=$?" prints exit=42. echo then succeeds, so $? becomes 0: read the status once, straight after the command you care about.

The figure follows the number 42 through the three parts of one run of ./exit42: the shell starts the program and waits, the program leaves 42 in the register that carries its result, the kernel ends the process and keeps the status, and the shell’s wait returns, so $? is 42. One echo "exit=$?" later and $? is 0, the status of echo.

Key idea

An exit status is a small whole number from a program; $? carries the status of the pipeline that ran most recently, so a value is kept only as long as nothing else runs between.

Checkpoint

Check: Where the status goes

  1. Question 1 of 2 The shell has just finished running ./exit42. What does echo "exit=$?" print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: exit=42

    The special parameter $? holds the exit status of the pipeline that ran most recently. In the recorded run, ./exit42; echo "exit=$?" prints exit=42, because 42 is what the kernel kept for the parent when exit42 ended.

  2. Question 2 of 2 A script runs ./build then echo "done" and only then tests the build with [ "$?" -eq 0 ]. What goes wrong?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The test reads the status of echo, which was 0, so the script treats every failed build as a success

    $? holds the exit status of the last command that ran, so you read it on the next line, straight after the command that matters. In the recorded status_session, sh -c 'exit 7'; echo "first: $?"; echo "second: $?" prints first 7, second 0, because the second echo has already overwritten the status.

On a Mac the program returns, and libSystem calls exit

A program linked on macOS always runs under dyld, the dynamic loader, and libSystem. dyld reads the LC_MAIN entry the linker wrote, and the start-up code calls _main like any C function. The whole AArch64 Mac program is six lines of code under a comment:

exit42.s S · exit42.s
// exit42.s: a complete program for AArch64 on macOS (Apple silicon).
// The start-up code calls _main; the number left in w0 when _main
// returns becomes the program's exit status.
        .text
        .globl  _main           // make _main visible to the linker
        .p2align 2              // A64 instructions are 4 bytes, 4-byte aligned
_main:
        mov     w0, #42         // the status to hand back
        ret                     // return to the start-up code, which calls exit

mov w0, #42 puts 42 in w0, the low 32 bits of x0. Arm’s calling standard, AAPCS64, which Apple follows on arm64, returns a 32-bit int result in that register. ret jumps to the address that the start-up code left in x30, the link register, so _main returns like any C function.

Interactive · message by message How exit42 ends on macOS, one step at a time Step through the run of exit42.s and watch who calls whom, and where the 42 travels.
How exit42 ends on macOS, one step at a time, step 6 of 6. Messages so far: Start-up code to _main: call _main; _main to Start-up code: return, w0 = 42; Start-up code to Kernel: exit system call, 42.Start-up codeentryoff 728 = 0x2d8_mainw0 = 42Kernelholds status 42call _mainreturn, w0 = 42exit system call, 42

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

Every step as text
  1. dyld finds _main. The kernel loads exit42 and dyld, the dynamic loader. dyld reads the LC_MAIN load command the linker wrote: entryoff 728, the offset of _main in the file.
  2. A call with a way back. The start-up code calls _main like any C function. The call puts the address to come back to in x30, the link register.
  3. mov w0, #42. mov w0, #42 puts 42 in w0, the low 32 bits of x0. The AArch64 calling standard returns an int result in that register.
  4. ret. ret jumps to the address in x30, back into the start-up code. Nothing has ended yet: 42 is only a return value in w0.
  5. exit(42). The start-up code calls the C library's exit with the value _main returned, which is what C says a return from main means. exit ends the process with a system call.
  6. The process ends. The kernel ends the process and keeps 42 for the parent, so the shell's echo prints exit=42, as in the recorded run.

The figure follows one run: the kernel loads exit42 and dyld, dyld reads LC_MAIN, the startup code calls _main, _main writes 42 to w0 and returns, and the startup code calls exit(42) for the program. Running the program on an Apple silicon Mac prints exit=42, and otool -l shows the LC_MAIN load command the linker wrote:

Assemble, run and read exit42 on an Apple silicon Mac Python · exit42_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

# Assemble and link with Apple clang, run the program, then ask the shell for its exit status.
# "echo" sits on the same line as the program: $? is the status of the command run just before it.
session.run("""
uname -m
clang -o exit42 exit42.s
./exit42; echo "exit=$?"
""")

# Where the program starts: the symbol table, and the entry point the linker wrote into the file.
session.run("""
nm exit42
otool -l exit42 | grep -A3 LC_MAIN
otool -L exit42
""")

Output

$ uname -m
arm64
$ clang -o exit42 exit42.s
$ ./exit42; echo "exit=$?"
exit=42
$ nm exit42
0000000100000000 T __mh_execute_header
00000001000002d8 T _main
$ otool -l exit42 | grep -A3 LC_MAIN
       cmd LC_MAIN
   cmdsize 24
  entryoff 728
 stacksize 0
$ otool -L exit42
exit42:
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1359.0.0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exit42_session.py

Three recorded numbers tie the figure to the file. nm prints _main at address 0x1000002d8. otool -l prints entryoff 728 under LC_MAIN. The two agree: the file is mapped at 0x100000000, so 0x1000002d8 is 728 bytes from the start of the file, and 728 is 0x2d8. Mach-O’s loader.h names entryoff as the file offset of main, so the linker writes the file offset there and dyld turns it into an address at run time. otool -L shows the one library the program needs, libSystem.B.dylib: Apple’s QA1118 note records that Apple does not promise a stable system-call interface, so the Mac program never calls the kernel itself.

Checkpoint

Check: Returning from _main on a Mac

  1. Question 1 of 2 exit42.s ends with mov w0, #42 and ret. What does the start-up code find when it comes back from _main?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 42 in w0, which it passes to the C library's exit as the status

    AAPCS64, Arm's calling standard that Apple follows on arm64, carries an int return value in w0 (the low 32 bits of x0). The start-up code calls _main like a C function, so when _main does ret the number in w0 is the function's return value, which libSystem then passes to exit().

  2. Question 2 of 2 The recorded nm output puts _main at address 0x1000002d8, and the file is mapped at 0x100000000. How many bytes from the start of the file is _main? That number is what LC_MAIN's entryoff records.

    Type a number.

    A hint

    0x1000002d8 − 0x100000000 = 0x2d8, in decimal.

    Show the answer: Question 2 of 2

    Answer: 728 bytes

    LC_MAIN's entryoff is the file offset of main, from Mach-O's loader.h. 0x2d8 is 728 in decimal, and the file is mapped at 0x100000000, so _main at 0x1000002d8 sits 728 bytes from the start of the file, which is what the load command names.

On Linux without a library, the program ends itself

The Linux programs in this lesson link against nothing. Nothing called their entry point: The address where the kernel's loader starts a program: the value the executable's header names (e_entry in an ELF header, LC_MAIN in a Mach-O file). The Linux convention is the symbol _start; a C program's main runs later, called by the startup code., so there is no address for ret to go back to, and the program has to end itself with a system call. The three sources share one shape — write the number of the exit call into one register, the status into another, run the trap instruction — and differ only in the register names and the trap:

exit42-x86-64.asm (NASM syntax) ASM · linux/exit42-x86-64.asm
; exit42-x86-64.asm: x86-64 Linux, no C library (NASM syntax).
; The kernel starts the program at _start. Nothing called it, so it cannot
; return: it asks the kernel to end it with the exit system call.
        global  _start          ; make _start visible to the linker
        section .text
_start:
        mov     eax, 60         ; system call number 60: exit
        mov     edi, 42         ; first argument: the exit status
        syscall                 ; enter the kernel; exit never comes back
exit42-aarch64.s (GNU as) S · linux/exit42-aarch64.s
// exit42-aarch64.s: AArch64 Linux, no C library (GNU as syntax).
        .text
        .globl  _start          // make _start visible to the linker
_start:
        mov     x8, #93         // system call number 93: exit
        mov     x0, #42         // first argument: the exit status
        svc     #0              // supervisor call: enter the kernel
exit42-riscv64.s (GNU as) S · linux/exit42-riscv64.s
# exit42-riscv64.s: RISC-V 64 Linux, no C library (GNU as syntax).
        .text
        .globl  _start          # make _start visible to the linker
_start:
        li      a7, 93          # system call number 93: exit
        li      a0, 42          # first argument: the exit status
        ecall                   # environment call: enter the kernel

syscall(2) names the trap instruction and the register conventions for each ISA. On x86-64, the kernel reads the call number from rax (mov eax, 60 sets it) and the arguments from rdi, rsi, rdx, r10, r8 and r9; the trap is syscall. On AArch64, the call number sits in x8 and the arguments in x0 to x5; the trap is svc #0. On RISC-V, the call number sits in a7 and the arguments in a0 to a5; the trap is ecall. The numbers of exit come from the kernel source: 60 for x86-64 (in arch/x86/entry/syscalls/syscall_64.tbl), 93 for AArch64 and RISC-V (in the generic scripts/syscall.tbl, which arm64/tools/syscall_64.tbl symlinks to and which riscv uses directly).

Interactive · message by message How exit42 ends on Linux, with no C library Step through the x86-64 Linux version and watch the program end itself, because nothing called it.
How exit42 ends on Linux, with no C library, step 6 of 6. Messages so far: Kernel to _start: start at _start; _start to Kernel: syscall: exit, 42.Kernelholds status 42_starteax = 60, edi = 42start at _startsyscall: exit, 42

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

Every step as text
  1. The kernel loads the file. The kernel maps the ELF file into memory and reads its entry point from the file's header: the address of _start.
  2. A jump, not a call. The kernel starts the program at that address. Nothing called _start, so no return address was saved and ret has nowhere to go back to.
  3. mov eax, 60. mov eax, 60 puts the number of the exit system call where the x86-64 kernel looks for it, in eax.
  4. mov edi, 42. mov edi, 42 puts the status in edi, the low half of rdi, the register of the first argument. exit takes an int, so those 32 bits are all it reads.
  5. syscall. syscall switches to the kernel, which reads the call number and its argument and runs exit with status 42.
  6. No way back. exit never returns. The kernel ends the process and keeps 42 for the parent; an instruction written after syscall would never run.

The figure walks through the x86-64 version. The kernel maps the ELF file and reads e_entry from its header; the System V generic ABI defines e_entry as the virtual address to which the system first transfers control. The kernel jumps to that address with no saved return address, so _start cannot return; it writes 60 to eax, 42 to edi and runs syscall, and the kernel ends the process with status 42. exit never comes back, so any instruction after syscall would be unreachable.

The three files cannot run on macOS, but each one can be assembled there as a cross-build and the bytes read back with a disassembler. That is what the recorded session does: LLVM’s assembler encodes the three x86-64 instructions, and the AArch64 and RISC-V sources assemble into Linux ELF object files, which objdump and llvm-objdump then print.

Cross-assembly and disassembly of the three Linux programs Python · linux_objects_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

# macOS cannot run a Linux program, but it can assemble one and show the bytes the processor would decode.
# objdump comes with Apple's tools; llvm-mc and llvm-objdump come from Homebrew's LLVM, which knows RISC-V.

# x86-64: the three instructions of linux/exit42-x86-64.asm, encoded by LLVM's assembler in Intel syntax.
session.run("""
printf 'mov eax, 60\\nmov edi, 42\\nsyscall\\n' | llvm-mc --triple=x86_64 --x86-asm-syntax=intel -show-encoding
""")

# AArch64 and RISC-V: each Linux source file assembled into an ELF object file, then disassembled.
session.run("""
clang --target=aarch64-linux-gnu -c -o exit42-aarch64.o linux/exit42-aarch64.s
file exit42-aarch64.o
objdump -d exit42-aarch64.o
llvm-mc --triple=riscv64 -filetype=obj -o exit42-riscv64.o linux/exit42-riscv64.s
file exit42-riscv64.o
llvm-objdump -d exit42-riscv64.o
""")

Output

$ printf 'mov eax, 60\nmov edi, 42\nsyscall\n' | llvm-mc --triple=x86_64 --x86-asm-syntax=intel -show-encoding
	mov	eax, 60                         # encoding: [0xb8,0x3c,0x00,0x00,0x00]
	mov	edi, 42                         # encoding: [0xbf,0x2a,0x00,0x00,0x00]
	syscall                                 # encoding: [0x0f,0x05]
$ clang --target=aarch64-linux-gnu -c -o exit42-aarch64.o linux/exit42-aarch64.s
$ file exit42-aarch64.o
exit42-aarch64.o: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), not stripped
$ objdump -d exit42-aarch64.o

exit42-aarch64.o:	file format elf64-littleaarch64

Disassembly of section .text:

0000000000000000 <_start>:
       0: d2800ba8     	mov	x8, #0x5d               // =93
       4: d2800540     	mov	x0, #0x2a               // =42
       8: d4000001     	svc	#0
$ llvm-mc --triple=riscv64 -filetype=obj -o exit42-riscv64.o linux/exit42-riscv64.s
$ file exit42-riscv64.o
exit42-riscv64.o: ELF 64-bit LSB relocatable, UCB RISC-V, soft-float ABI, version 1 (SYSV), not stripped
$ llvm-objdump -d exit42-riscv64.o

exit42-riscv64.o:	file format elf64-littleriscv

Disassembly of section .text:

0000000000000000 <_start>:
       0: 05d00893     	li	a7, 0x5d
       4: 02a00513     	li	a0, 0x2a
       8: 00000073     	ecall

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 linux_objects_session.py

The bytes meet the sources. On x86-64, b8 3c 00 00 00 is mov eax, 60 (0x3c = 60), bf 2a 00 00 00 is mov edi, 42 (0x2a = 42), and 0f 05 is syscall: 12 bytes in all. On AArch64, d2800ba8 is mov x8, #0x5d (0x5d = 93), d2800540 is mov x0, #0x2a, and d4000001 is svc #0. On RISC-V, 05d00893 is li a7, 0x5d, 02a00513 is li a0, 0x2a, and 00000073 is ecall. The three programs are byte-for-byte on each ISA what the source said they should be. That session covers the encodings only. Running the three programs needs Linux: the lab of the toolchain lesson builds them with NASM and GNU binutils and runs the programs of the other ISAs under qemu-user. The script below does both steps there and prints each program’s status:

run-in-linux-lab.sh, for the Linux lab Shell · linux/run-in-linux-lab.sh
#!/bin/sh
# Builds and runs the three Linux programs in the Linux lab of the toolchain-setup lesson
# (NASM and GNU binutils for x86-64, AArch64 and RISC-V, with qemu-user registered through binfmt),
# then prints each program's exit status. Run it from this folder: sh run-in-linux-lab.sh
set -u
nasm -f elf64 -o exit42-x86-64.o exit42-x86-64.asm
x86_64-linux-gnu-ld -o exit42-x86-64 exit42-x86-64.o
aarch64-linux-gnu-as -o exit42-aarch64.o exit42-aarch64.s
aarch64-linux-gnu-ld -o exit42-aarch64 exit42-aarch64.o
riscv64-linux-gnu-as -o exit42-riscv64.o exit42-riscv64.s
riscv64-linux-gnu-ld -o exit42-riscv64 exit42-riscv64.o
for program in exit42-x86-64 exit42-aarch64 exit42-riscv64; do
  ./"$program"
  echo "$program: exit=$?"
done
run-in-linux-lab.sh in a Linux lab on an AArch64 machine Python · linux_lab_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import linux_lab

# The Linux lab is an AArch64 machine: the AArch64 program runs natively, and the kernel hands the x86-64 and RISC-V
# programs to qemu-user through their binfmt entries.
linux_lab.run("""
uname -m
cd linux
sh run-in-linux-lab.sh
""")

Output

$ uname -m
aarch64
$ cd linux
$ sh run-in-linux-lab.sh
exit42-x86-64: exit=42
exit42-aarch64: exit=42
exit42-riscv64: exit=42

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 linux_lab_session.py

All three programs end with status 42. The lab runs on an AArch64 processor, so exit42-aarch64 ran directly. For the other two, the kernel matched the machine field of the ELF header to a binfmt entry and started qemu-user, which passed the program’s exit call on to the host kernel with the same status.

Pick the right syscall table

Each ISA has its own table. On x86-64, call number 1 is write (not exit); 42 is connect; 93 is fchown; 231 is exit_group. On the generic table used by AArch64 and RISC-V, 60 is quotactl and 94 is exit_group. In the i386 32-bit table exit is 1, which is why some older examples look wrong on 64-bit builds. Look up a number against the table of the ISA you are building for.

Checkpoint

Check: Linux syscalls without a library

  1. Question 1 of 3 Which register holds the system-call number on x86-64, and which number asks the Linux kernel to exit?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: eax = 60, then the syscall instruction

    The Linux x86-64 kernel reads the call number from rax (mov eax, 60 sets it) and the arguments from rdi, rsi, rdx, r10, r8 and r9 (syscall(2)). The number of exit is 60 in arch/x86/entry/syscalls/syscall_64.tbl, so exit42 writes 60 to eax, 42 to edi and runs syscall.

  2. Question 2 of 3 Which register pairs are right for calling exit from AArch64 Linux (number 93) or RISC-V 64 Linux (number 93)?

    Choose every answer that is right.

    Show the answer: Question 2 of 3

    Answer:

    • RISC-V: a7 holds 93 and a0 holds the status, then ecall
    • AArch64: x8 holds 93 and x0 holds the status, then svc #0

    Each ISA has its own call-number register and its own trap instruction. The recorded cross-assembly in linux_objects_session confirms them: on AArch64, d2800ba8 is mov x8, #0x5d, with 0x5d = 93; on RISC-V, 05d00893 is li a7, 0x5d. The argument registers are x0..x5 on AArch64 and a0..a5 on RISC-V.

  3. Question 3 of 3 Which instruction switches to the kernel on each of the three ISAs this lesson teaches?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: x86-64: syscall. AArch64: svc. RISC-V: ecall.

    Each ISA has its own privilege-switch instruction. In the recorded disassembly the three bytes are 0f 05 for syscall on x86-64, d4000001 for svc #0 on AArch64 and 00000073 for ecall on RISC-V.

The kernel keeps only the low eight bits

The number a program hands back is wider than what the parent gets to see. The Linux kernel’s kernel/exit.c masks it with 0xff before it packs the value for wait: SYSCALL_DEFINE1(exit, int, error_code) { do_exit((error_code & 0xff) << 8); }. The POSIX exit() specification matches that: only status & 0377 is available from wait() and waitpid(), which is what WEXITSTATUS reads. So the shell’s $? carries a number from 0 to 255, however wide the register was.

Three AArch64 programs make the behaviour visible. Each one is exit42.s with a different constant in w0:

exit300.s S · exit300.s
// exit300.s: the same program, handing back 300, a number that needs 9 bits.
        .text
        .globl  _main
        .p2align 2
_main:
        mov     w0, #300        // 300 = 0x12c: bit 8 is set
        ret
exit256.s S · exit256.s
// exit256.s: handing back 256, whose low 8 bits are all 0.
        .text
        .globl  _main
        .p2align 2
_main:
        mov     w0, #256        // 256 = 0x100
        ret
exit_minus1.s S · exit_minus1.s
// exit_minus1.s: handing back -1, whose low 8 bits are all 1.
        .text
        .globl  _main
        .p2align 2
_main:
        mov     w0, #-1         // -1 = 0xffffffff in a 32-bit register
        ret

Running the three programs reads back values the sources did not write:

300, 256 and -1, and what the shell reads back Python · low8_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

# Three statuses that do not fit in 8 bits, or look negative, and what the shell reads back.
session.run("""
clang -o exit300 exit300.s
./exit300; echo "exit=$?"
echo $((300 % 256))
clang -o exit256 exit256.s
./exit256; echo "exit=$?"
clang -o exit_minus1 exit_minus1.s
./exit_minus1; echo "exit=$?"
""")

Output

$ clang -o exit300 exit300.s
$ ./exit300; echo "exit=$?"
exit=44
$ echo $((300 % 256))
44
$ clang -o exit256 exit256.s
$ ./exit256; echo "exit=$?"
exit=0
$ clang -o exit_minus1 exit_minus1.s
$ ./exit_minus1; echo "exit=$?"
exit=255

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 low8_session.py

exit300 reads back as 44, because 300 modulo 256 is 44, as the recording shows on its own line. exit256 reads back as 0, which looks like success although the program meant anything but. exit_minus1 writes 0xffffffff to w0 (−1 in a 32-bit register) and reads back as 255. The three results come from one rule: the kernel keeps the low 8 bits of w0 and throws the rest away.

Key idea

Keep every exit status between 0 and 125. 126, 127 and 128+N are reserved by the shell for “not executable”, “not found” and “killed by signal N”; above 255 the status wraps and silently loses information.

Checkpoint

Check: The low eight bits

  1. Question 1 of 2 A program ends with status 300. What does the shell's echo "$?" print after it?

    Type a number.

    A hint

    Only the low 8 bits survive: divide by 256 and keep the remainder.

    Show the answer: Question 1 of 2

    Answer: 44

    The kernel's exit code is masked with 0xff ((error_code & 0xff) << 8 in kernel/exit.c), so only the low 8 bits survive. 300 = 0x12c, and the low 8 bits are 0x2c = 44, which is what low8_session recorded for exit300.

  2. Question 2 of 2 exit_minus1.s ends with mov w0, #-1 and ret. What does echo "$?" print?

    Type a number.

    A hint

    -1 in a 32-bit register is 0xffffffff. Which number do its low 8 bits make?

    Show the answer: Question 2 of 2

    Answer: 255

    mov w0, #-1 writes 0xffffffff to w0. The kernel keeps the low 8 bits, 0xff = 255, so the shell reads 255 and not a negative number. The low8_session recording shows exactly that. A C programmer who writes return -1 and expects "minus one" meets the same surprise.

Entry names across systems and the slips they cause

The two conventions that start a program come with two symbol names. Mach-O programs name their entry _main and Mach-O carries a leading underscore on every C name; ELF programs name the kernel’s entry _start and name their C main main without an underscore. LLVM’s own LangRef records the rule: “Mach-O mangling … Other symbols get a _ prefix”, while ELF has none.

The slip is to carry one convention into the other. Take exit42.s and change _main to _start:

wrong_entry.s S · wrong_entry.s
// wrong_entry.s: exit42.s with the Linux entry name, _start, tried on macOS.
        .text
        .globl  _start
        .p2align 2
_start:
        mov     w0, #42
        ret

Linking the file with the Mac’s clang fails straight away, because the Mac’s linker drives its default start-up through libSystem, which expects a _main to call:

Linking a program that defines only _start on a Mac Python · wrong_entry_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

# The macOS linker wants _main: a program that only defines _start does not link.
session.run("""
clang -o wrong_entry wrong_entry.s
""")

Output

$ clang -o wrong_entry wrong_entry.s
Undefined symbols for architecture arm64:
  "_main", referenced from:
      <initial-undefines>
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wrong_entry_session.py

The recorded message is Undefined symbols for architecture arm64: "_main", referenced from: <initial-undefines>, with an exit status of 1. The fix is to use _main, as exit42.s does, or to tell the linker to use _start with -e _start and give the program its own exit path, since nothing would call _start back.

The same slip goes the other way too. The one-line C program ret42.c is the same source on both systems, yet the assembly it compiles to names the function differently:

ret42.c C · ret42.c
// ret42.c: the same program in C, to compare with what the compiler writes.
int main(void) {
    return 42;
}
Compiling the same C to assembly on macOS and on Linux Python · symbols_session.py
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session

# The same C program compiled for macOS and for Linux on the same ISA, AArch64.
# grep keeps the label and the instructions; -O1 drops the stack frame that -O0 would add.
session.run("""
clang -S -O1 -o - ret42.c | grep -E '^_?main:|^[[:space:]]+(mov|ret)'
clang --target=aarch64-linux-gnu -S -O1 -o - ret42.c | grep -E '^_?main:|^[[:space:]]+(mov|ret)'
""")

Output

$ clang -S -O1 -o - ret42.c | grep -E '^_?main:|^[[:space:]]+(mov|ret)'
_main:                                  ; @main
	mov	w0, #42                         ; =0x2a
	ret
$ clang --target=aarch64-linux-gnu -S -O1 -o - ret42.c | grep -E '^_?main:|^[[:space:]]+(mov|ret)'
main:                                   // @main
	mov	w0, #42                         // =0x2a
	ret

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 symbols_session.py

On macOS the label is _main:, with its leading underscore; on Linux it is main:, with none. Both bodies are mov w0, #42 followed by ret, because AAPCS64 returns an int in w0 on both. The caller is what differs: the start-up code in dyld and libSystem on macOS, and __libc_start_main from glibc on Linux. Both callers take the number from w0 and call exit, so the shell reads 42 in each case. In the Linux build, GNU ld’s documentation records that the -e option or a linker script’s ENTRY() command names the entry symbol (_start for the programs here), and the linker writes that symbol’s address into ELF’s e_entry field for the kernel to jump to.

Checkpoint

Check: Entry names and link slips

  1. Question 1 of 2 You take wrong_entry.s, which defines only _start, and link it with the Mac's clang. What does the linker say?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Undefined symbols for architecture arm64: "_main"

    wrong_entry_session recorded the exact message: Undefined symbols for architecture arm64, _main, exit status 1. The symbol name's leading underscore comes from the Mach-O mangling LLVM's LangRef names: Other symbols get a _ prefix. ELF has no such prefix, so a Linux C program defines main without the underscore.

  2. Question 2 of 2 ret42.c is int main(void) { return 42; }. Compiled for Linux and run, the shell prints exit=42. Who calls main, and where does the 42 end up?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The C library's start-up code calls main, takes the int it returns in eax (System V amd64), and calls exit_group with 42; the kernel keeps the low 8 bits.

    The System V ELF start code (crt1.o and __libc_start_main) calls main, reads the int it returns from eax, and calls exit() with it. exit() flushes stdio buffers and runs atexit handlers, then makes exit_group so every thread of the process ends. The kernel keeps the low 8 bits of the number for the parent, which is why return 300 reads back as 44.

Practise: exit with the sum

Write exit_sum on the ISA you can run: it adds the two constants A (17) and B (25) in registers and exits with their sum, so the shell reads back 42. Each starter below exits with 0 from the line marked “replace this line”. Use the constants by name, not the number 42: with A at 200 and B at 100, a correct program exits with 300 cut to its low 8 bits, 44.

On an Apple silicon Mac, put A in w1 and B in w2, add them into w0, and return from _main:

exit_sum_a64.s, the AArch64 macOS starter S · exit-sum/exit_sum_a64.s
// exit_sum_a64.s: AArch64 macOS. Add A and B in registers and exit with the sum.
        .equ    A, 17
        .equ    B, 25
        .text
        .globl  _main
        .p2align 2
_main:
        // Your code: put A in w1 and B in w2, then add them into w0.
        mov     w0, #0          // replace this line
        ret

The sample test assembles the file, runs it and prints PASS or FAIL with the status it saw. Save it next to the starter and run sh test_exit_sum_a64.sh in that folder:

test_exit_sum_a64.sh Shell · exit-sum/test_exit_sum_a64.sh
#!/bin/sh
# Sample test for exit_sum on AArch64 macOS: assembles exit_sum_a64.s, runs it and checks
# that its exit status is 17 + 25 = 42. Run it in this folder: sh test_exit_sum_a64.sh
clang -o exit_sum_a64 exit_sum_a64.s || { echo "FAIL: exit_sum_a64.s did not assemble"; exit 1; }
./exit_sum_a64
status=$?
if [ "$status" -eq 42 ]; then
  echo "PASS: exit status 42"
else
  echo "FAIL: exit status $status, expected 42 (17 + 25)"
  exit 1
fi

The two Linux starters keep the exit call this lesson built and leave the sum to you: on x86-64, put A in edi and add B to it; on RISC-V, put A in t0 and B in t1 and add them into a0. Build and run them in the Linux lab with the same commands as run-in-linux-lab.sh, then read $?.

exit_sum_x64.asm, the x86-64 Linux starter (NASM) ASM · exit-sum/exit_sum_x64.asm
; exit_sum_x64.asm: x86-64 Linux, no C library. Add A and B in registers and exit with the sum.
A       equ     17
B       equ     25
        global  _start
        section .text
_start:
        ; Your code: put A in edi, then add B to edi.
        mov     edi, 0          ; replace this line
        mov     eax, 60         ; system call number 60: exit
        syscall
exit_sum_rv64.s, the RISC-V Linux starter S · exit-sum/exit_sum_rv64.s
# exit_sum_rv64.s: RISC-V 64 Linux, no C library. Add A and B in registers and exit with the sum.
        .equ    A, 17
        .equ    B, 25
        .text
        .globl  _start
_start:
        # Your code: put A in t0 and B in t1, then add them into a0.
        li      a0, 0           # replace this line
        li      a7, 93          # system call number 93: exit
        ecall

A first hint

Load each constant into a register of its own first, then add. AArch64’s add names a destination and two source registers; x86-64’s add adds its second operand into its first; RISC-V’s add also takes three registers, and li loads a constant.

Practice that adapts to you

Practise this lesson

11 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 11 The shell has run false; echo done. What does echo "$?" print?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: 0

    Every command sets $?, so by the time echo "$?" runs, echo done has already overwritten false's 1 with its own 0. Read $? on the line right after the command that matters.

  2. Question 2 of 11 In the recorded run, sh -c 'exit 7' && echo "success" || echo "failure, status $?" prints exactly one line. Which?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: failure, status 7

    In a && b || c, b runs only if a succeeds, and c runs only if the one before it failed. Here a (exit 7) failed, so b was skipped and $? kept 7; c (the failure echo) then ran, reading $? as 7. status_session recorded "failure, status 7".

  3. Question 3 of 11 Why does exit42.s on macOS use ret rather than a direct system call, when the Linux version makes the call itself?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: Because the Mac program is linked with libSystem, which carries dyld's start-up code; that code calls _main like a C function and then calls exit() with the result. Apple's technote QA1118 adds that Apple does not support statically linked user binaries, so going through libSystem is the only path.

    macOS always has libSystem under a user program, and its start code calls _main as a C function. Returning from _main leaves the int result in w0, which the start code hands to exit(). The Linux programs in this lesson link against nothing at all, so there is no caller to return to and the program must make the exit system call itself.

  4. Question 4 of 11 A beginner writes an x86-64 Linux exit program but puts 1 in eax instead of 60. The program compiles and runs. What happens?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: The kernel runs system call 1, which is write on x86-64: it tries to write 42 bytes from whatever address sits in rsi to file descriptor 42 (edi), and the program does not exit at all.

    syscall numbers belong to one table at a time. On x86-64, 1 is write, 42 is connect, 93 is fchown and 231 is exit_group; the generic table used by AArch64 and RISC-V has 60 as quotactl and 94 as exit_group. Reading from the wrong table is a common beginner mistake, and the program's behaviour is whatever the number does mean on the real table, not an error.

  5. Question 5 of 11 A program built like exit300.s ends with mov w0, #600 instead. What does echo "$?" print after it?

    Type a number.

    A hint

    Only 8 bits survive, so take 600 modulo 256.

    Show the answer: Question 5 of 11

    Answer: 88

    The kernel keeps only the low 8 bits of the status, so the shell reads 600 modulo 256. 600 = 2 × 256 + 88, so echo prints 88; in hex, 600 is 0x258 and its low 8 bits are 0x58 = 88. low8_session recorded the same rule for 300, which read back as 44 because 300 % 256 = 44.

  6. Question 6 of 11 exit256.s ends with mov w0, #256. What does echo "$?" print after it?

    Type a number.

    A hint

    256 = 0x100. What are the low 8 bits of 0x100?

    Show the answer: Question 6 of 11

    Answer: 0

    The low 8 bits of 0x100 are all zero. The shell reads 0, which looks like success although the program meant to fail: a status of 256 is one of the first ways a tool silently "passes" when it should fail. Keep every status in 1 to 125 and the problem does not come up.

  7. Question 7 of 11 Which Linux interfaces can observe more than the low 8 bits of the status a program ended with?

    Choose every answer that is right.

    Show the answer: Question 7 of 11

    Answer:

    • waitid, which hands the full value through siginfo_t.si_status
    • A SIGCHLD handler, which the kernel delivers to the parent with siginfo_t.si_status set to the full value

    POSIX cuts the value that wait() and waitpid() hand back to 8 bits (through WEXITSTATUS), so most parents, shells included, see only 0 to 255. waitid, and a SIGCHLD handler through siginfo, do get the full value. A tool that cares, such as a job queue, uses waitid.

  8. Question 8 of 11 Why does a Linux program that defines only _start have to make the exit system call itself, when the macOS _main version just does ret?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: Because _start has no caller: the kernel starts the program at it, with no return address on the stack, so ret has nowhere to go.

    _start is where the kernel jumps when it starts the program, so no caller has saved an address for ret to return to. The program must end itself, which it does by writing the exit system call's number and its status into the agreed registers and running the trap instruction.

  9. Question 9 of 11 You run readelf -h hello on an ELF Linux program that defines only _start. The header shows Entry point address: 0x401000, and nm shows _start at 0x401000. Which statement is right?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: The kernel starts the program at 0x401000, the virtual address of _start, because the linker wrote that address into ELF's e_entry field.

    ELF's e_entry is a virtual address, not a symbol: the linker computes the address of the entry symbol and writes it. GNU ld's -e option or a linker script's ENTRY() command names the symbol (_start in this lesson's Linux programs). Mach-O does the same job with LC_MAIN, whose entryoff is the file offset of the symbol the linker was told to use.

  10. Question 10 of 11 In an interview, you are asked to explain in two sentences why the macOS program returns while the Linux one calls exit. Which answer is best?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: The macOS program is linked with libSystem, which runs a C-library start-up that calls _main like a function and then calls exit with its result; so the program returns, as any C function does, and libSystem ends the process on its behalf. The Linux programs in this lesson link against nothing, so there is no caller to return to and no C library to call exit for them, which is why they make the exit system call themselves.

    macOS: libSystem's start code is above _main on the call stack and calls exit after _main returns; AAPCS64 carries the int result in w0 for libSystem to read. Linux without libc: _start is where the kernel jumps, with no return address, so the program has to end itself with the exit system call.

  11. Question 11 of 11 You run llvm-mc on three Intel-syntax lines (mov eax, 60; mov edi, 42; syscall) and get this encoding. Match each byte sequence to its instruction, and read the number of the call and the status it carries.

    Read the code, then choose one answer.

    b8 3c 00 00 00
    bf 2a 00 00 00
    0f 05
    Show the answer: Question 11 of 11

    Answer: b8 3c 00 00 00 → mov eax, 60. bf 2a 00 00 00 → mov edi, 42. 0f 05 → syscall. The call is 60 (exit); the status is 42.

    The three bytes of the exit program on x86-64 Linux are what the LLVM assembler recorded in linux_objects_session: b8 3c 00 00 00 is mov eax, 60; bf 2a 00 00 00 is mov edi, 42; 0f 05 is syscall. 0x3c = 60 is sys_exit on x86-64; 0x2a = 42 is the status in edi.

Interview questions

What is an exit status, how wide is it, and where does the shell’s $? read it from?

An exit status is the small whole number a program hands back when it ends. The program leaves the number in an agreed register — w0 or x0 on AArch64, a0 on RISC-V, and on x86-64 eax when main returns or rdi for the exit call — and either returns from _main, so the C library calls exit, or asks the kernel directly with the exit system call. The kernel keeps only the low 8 bits of that number, so the value a parent can read runs from 0 to 255. A C programmer who writes return 300 and expects 300 reads back 44, because 300 modulo 256 is 44. The shell takes the status from the kernel through wait, writes it into its special parameter ?, and echo $? prints it. By convention 0 means success, and non-zero means a specific failure the program’s documentation defines.

Three harder questions from the module’s bank go further: a program with two threads that hangs at its exit, the statuses a tool called from scripts should use, and output that never reaches a pipe:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A teammate's x86-64 Linux program keeps hanging after it hits what should be its last line, which calls syscall number 60 with eax. The program has two threads. What is going wrong, and how do you fix it with one change?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    You are designing a command-line tool that encodes a photo and will be called from shell scripts and from a job queue. The tool runs on one of about 50 input files a day. How do you pick its exit statuses, and which ranges or numbers do you avoid?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A C program prints twelve lines with printf and then calls the raw exit system call directly from inline assembly instead of returning from main. Its exit status is correct, but when its output is piped into grep, none of the twelve lines arrive. What has happened, and what are two ways to fix it?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. When must you read $? to get the exit status of a command?

    On the very next line. $? holds the status of the most recent pipeline, so any command in between, even an echo, replaces the value you wanted.

  2. On an Apple silicon Mac, how does an assembly _main that ends with ret produce exit status 42?

    It leaves 42 in w0 and returns. The start-up code in dyld and libSystem called _main, so it receives the 42 and hands it to exit, which ends the process with that status.

  3. A Linux program built without any library must end itself. Which call number and instruction does each ISA use?

    The exit system call: 60 in eax with syscall on x86-64, 93 in x8 with svc 0 on AArch64, and 93 in a7 with ecall on RISC-V. The status goes in the first argument register.

  4. A program exits with 300. What does the shell report, and why?

    44. The kernel stores just the lowest 8 bits, and 300 mod 256 is 44; by the same rule 256 reads back as 0 and -1 as 255.

  5. Why keep exit statuses between 0 and 125?

    The shell reserves 126 for a file it cannot execute, 127 for a command it cannot find and 128 plus N for a process killed by signal N, and values above 255 wrap around. A status from 0 to 125 cannot be confused with any of those.

  6. Why does a program that defines only _start fail to link on a Mac with an undefined _main error?

    The macOS linker looks for main, which Mach-O spells _main because it puts an underscore in front of every C symbol. ELF adds no underscore, and a Linux program without a library starts at _start, so a Linux-style file leaves _main undefined.

Key takeaways

  • $? holds the exit status of the last command the shell ran, so read it on the next line, straight after the command that matters.
  • On an Apple silicon Mac the AArch64 program writes 42 to w0 and does ret; the start-up code in dyld and libSystem calls _main and then calls exit(42) with the number _main returned.
  • Linux programs built without a library have no caller to return to, so they make the exit system call themselves: 60 in eax on x86-64 with syscall, 93 in x8 on AArch64 with svc #0, 93 in a7 on RISC-V with ecall.
  • The kernel keeps only the low 8 bits of the status (error_code & 0xff in kernel/exit.c), so 300 reads back as 44, 256 as 0 and −1 as 255; keep every status between 0 and 125.
  • Mach-O names symbols with a leading underscore and ELF does not: a program with only _start fails to link on a Mac with Undefined symbols for architecture arm64: "_main", as the recorded run shows.

Terms in this lesson

entry point
The address where the kernel's loader starts a program: the value the executable's header names (e_entry in an ELF header, LC_MAIN in a Mach-O file). The Linux convention is the symbol _start; a C program's main runs later, called by the startup code.
exit status
The small whole number a program hands back when it ends. The parent reads only the low 8 bits, so the value runs from 0 to 255; by convention 0 means success and non-zero means a specific failure.
system call
A request a running program makes to the kernel, through a special instruction (syscall on x86-64, svc on AArch64, ecall on RISC-V) that switches privilege. The program leaves a call number and arguments in agreed registers and the kernel reads them.

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.