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.
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 $?:
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”.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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: 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.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
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
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: 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: 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: 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).
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- mov eax, 60. mov eax, 60 puts the number of the exit system call where the x86-64 kernel looks for it, in eax.
- 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.
- syscall. syscall switches to the kernel, which reads the call number and its argument and runs exit with status 42.
- 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.
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:
#!/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 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
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: 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: 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: 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:
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
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: 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:
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: the same program in C, to compare with what the compiler writes.
int main(void) {
return 42;
} 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
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: 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:
#!/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: 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: 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.
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.
-
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?
-
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?
-
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?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
w0and doesret; the start-up code indyldandlibSystemcalls_mainand then callsexit(42)with the number_mainreturned. - Linux programs built without a library have no caller to return to, so they make the exit system call themselves:
60ineaxon x86-64 withsyscall,93inx8on AArch64 withsvc #0,93ina7on RISC-V withecall. - The kernel keeps only the low 8 bits of the status (
error_code & 0xffinkernel/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
_startfails to link on a Mac withUndefined 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
- syscall(2), the Linux manual page (Linux man-pages project)
- exit(2), _exit and _Exit, the Linux manual page (Linux man-pages project)
- exit_group(2), the Linux manual page (Linux man-pages project)
- arch/x86/entry/syscalls/syscall_64.tbl: the x86-64 Linux syscall table (The Linux kernel project)
- scripts/syscall.tbl: the generic Linux syscall table used by arm64 and riscv (The Linux kernel project)
- kernel/exit.c: SYSCALL_DEFINE1(exit, int, error_code) (The Linux kernel project)
- Shell Command Language, 2.5.2 Special Parameters (POSIX.1-2024) (The Open Group)
- exit, _Exit and _exit: terminate a process (POSIX.1-2024) (The Open Group)
- wait(2) and WEXITSTATUS, the Linux manual page (Linux man-pages project)
- Working Draft, Programming Languages: C (N3220), 5.1.2.3.4 Program termination (ISO/IEC JTC1/SC22/WG14)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64) (Arm Limited)
- Writing ARM64 code for Apple platforms (Apple Developer)
- Mach-O loader.h: LC_MAIN and entry_point_command (Apple Open Source)
- Technical Q&A QA1118, Statically linked user binaries (Apple Developer)
- LLVM Language Reference Manual: data layout and symbol mangling (The LLVM Project)
- GNU ld documentation: Entry Point (GNU Project (sourceware.org))
- System V Application Binary Interface, Chapter 4: Object Files (e_entry) (SCO Group (gABI mirror))
- NASM, the Netwide Assembler documentation (The NASM Development Team)
Report a problem with this lesson
Kept only in this browser. Your Learn progress