Assembly (x86-64, AArch64, RISC-V) Module 7 – Functions, the stack and calling conventions
Stack alignment and the crashes it causes
The 16-byte stack rule of System V, Microsoft x64, AAPCS64 and RISC-V at every call, where a misaligned stack faults, and how to keep it aligned.
What you will learn
- State the stack-alignment rule at a call under System V, Microsoft x64, AAPCS64 and RISC-V
- Diagnose a crash caused by a misaligned stack from the faulting instruction and the value of sp
- Explain why a misaligned x86-64 stack faults only in instructions that need aligned memory
- Count the pushes and stack adjustments that keep a hand-written function aligned at its calls
- Explain how a function realigns the stack for a local that needs 32-byte alignment
Before you start
What you’ll learn · Before you start
What you will learn
- State the stack-alignment rule at a call under System V, Microsoft x64, AAPCS64 and RISC-V
- Diagnose a crash caused by a misaligned stack from the faulting instruction and the value of sp
- Explain why a misaligned x86-64 stack faults only in instructions that need aligned memory
- Count the pushes and stack adjustments that keep a hand-written function aligned at its calls
- Explain how a function realigns the stack for a local that needs 32-byte alignment
Before you start
On this page
call_misaligned lowers sp by 8 bytes before it calls show, where call_aligned lowers it by 16, and no
instruction in call_misaligned objects. On Apple silicon the program then dies inside show, a correct one-line C
function, at its second instruction, with a signal: A notification the operating system delivers to a process, such as SIGFPE after an integer divide fault; unhandled, it ends the process, and the shell reports status 128 plus the signal number. More in “Division: traps, defined results, remainders” whose code means “invalid address
alignment”.
Every application binary interface (ABI): The rules that let separately compiled machine code work together on one platform, from the size of each C type and the calling convention to the format of object files. More in “Calling conventions: arguments and returns” fixes where the stack pointer: The register that addresses the top of the stack: rsp on x86-64, sp on AArch64 and sp (x2) on RISC-V. Push, pop and function prologues move it; losing it loses the stack. More in “Registers on x86-64, AArch64 and RISC-V” must point at a call,
and every processor punishes a break of that rule in its own way: AArch64 checks sp at each memory access through
it, while x86-64 faults only when code uses an instruction that needs aligned memory. Here you state the rule for
System V, Microsoft x64, AAPCS64 and RISC-V, find both the instruction that faulted and the one at fault, see why an
x86-64 program can pass its tests with a misaligned stack, count the bytes that keep a function aligned, and read how
compilers realign the stack for a local that needs 32 bytes.
Every ABI fixes the stack’s alignment at a call, and all four ask for 16 bytes
one_call(x) returns g(x) + 1. No value in it has to survive the call, so on x86-64 it saves no register at all,
and on AArch64 and RISC-V it only has to keep its return address: The address a call records so that the callee can come back: the address of the instruction right after the call. x86-64's call pushes it onto the stack; AArch64's bl and blr and RISC-V's jal and call write it to x30 or ra. More in “Calls, returns and the link register”, which arrives in
x30 or ra and which the call to g will overwrite. Whatever else its code does to the stack pointer is there for
the call alone. listing.sh compiles one file at -O2 for one target with clang -S and prints only the
instructions:
one_call.c
// one_call keeps nothing across its call to g, so whatever its prologue does to the stack pointer is there for the
// return address and for the alignment the call needs, not for saved values.
long g(long);
long one_call(long x) { return g(x) + 1; } listing.sh
#!/bin/sh
# listing.sh TARGET FILE.c [FLAGS...]
# Compiles FILE.c at -O2 for TARGET with clang -S and prints only the instructions and labels:
# assembler directives, comments and blank lines are dropped, and runs of tabs become one space.
target=$1
file=$2
shift 2
case $target in
riscv64-*) set -- -march=rv64gc -mabi=lp64d "$@"; cc=clang-23 ;; # Apple clang has no RISC-V target
x86_64-*) set -- -masm=intel "$@"; cc=clang ;;
*) cc=clang ;;
esac
"$cc" --target="$target" -O2 -S -fno-asynchronous-unwind-tables "$@" -o - "$file" |
grep -v -E '^[[:space:]]*([.#;/]|$)|^@feat' |
sed -E 's/[[:space:]]+(;|\/\/|#) .*$//' |
tr -s '\t' ' ' """Compiles one_call at -O2 for five targets with clang -S and prints the instructions of each."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# x86-64 Linux, System V ABI
sh listing.sh x86_64-linux-gnu one_call.c
# x86-64 Windows, Microsoft x64 convention (compiler output only: nothing is linked or run)
sh listing.sh x86_64-pc-windows-msvc one_call.c
# AArch64 on macOS, Apple's arm64 ABI
sh listing.sh arm64-apple-macos one_call.c
# AArch64 Linux, AAPCS64
sh listing.sh aarch64-linux-gnu one_call.c
# 64-bit RISC-V Linux, LP64D (Homebrew's clang-23: Apple's clang has no RISC-V target)
sh listing.sh riscv64-linux-gnu one_call.c
""") Output
# x86-64 Linux, System V ABI $ sh listing.sh x86_64-linux-gnu one_call.c one_call: push rax call g@PLT inc rax pop rcx ret # x86-64 Windows, Microsoft x64 convention (compiler output only: nothing is linked or run) $ sh listing.sh x86_64-pc-windows-msvc one_call.c one_call: sub rsp, 40 call g inc eax add rsp, 40 ret # AArch64 on macOS, Apple's arm64 ABI $ sh listing.sh arm64-apple-macos one_call.c _one_call: stp x29, x30, [sp, #-16]! mov x29, sp bl _g add x0, x0, #1 ldp x29, x30, [sp], #16 ret # AArch64 Linux, AAPCS64 $ sh listing.sh aarch64-linux-gnu one_call.c one_call: stp x29, x30, [sp, #-16]! mov x29, sp bl g add x0, x0, #1 ldp x29, x30, [sp], #16 ret # 64-bit RISC-V Linux, LP64D (Homebrew's clang-23: Apple's clang has no RISC-V target) $ sh listing.sh riscv64-linux-gnu one_call.c one_call: addi sp, sp, -16 sd ra, 8(sp) call g addi a0, a0, 1 ld ra, 8(sp) addi sp, sp, 16 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_call_session.py
The Linux x86-64 build pushes rax, whose value nobody needs, and pops the same 8 bytes into rcx after the call.
The Windows build subtracts 40 from rsp: 32 bytes of shadow space: The 32 bytes a Microsoft x64 caller reserves on the stack just above the return address on every call, so that the callee can store rcx, rdx, r8 and r9 there if it needs to. More in “Calling conventions: arguments and returns”, which every
Microsoft x64 call needs, and 8 more. Both AArch64 builds store their frame record, 16 bytes, and the RISC-V build
moves sp by 16 to store ra, a single 8-byte register. None of these choices is about one_call itself. Each
convention states its stack alignment: The rule an ABI sets for the address in the stack pointer, such as a multiple of 16 at every call. Compilers rely on it to keep values that need aligned memory at fixed offsets from the stack pointer., the rule its build keeps:
| ABI | The rule for the stack pointer | When it must hold |
|---|---|---|
| System V AMD64 | a multiple of 16 just before call, so rsp + 8 is one at a function’s first instruction; 32 or 64 when an __m256 or __m512 argument goes on the stack |
at every call |
| Microsoft x64 | 16-byte aligned | everywhere except inside a prolog or an epilog, and in leaf functions |
| AAPCS64 | sp mod 16 = 0 |
at every public interface, and at every memory access through sp |
| RISC-V psABI | aligned to 128 bits, which is 16 bytes | on entry, and throughout the procedure |
Apple’s arm64 platforms follow AAPCS64 here: their stack pointer needs 16-byte alignment too. The difference between
the two x86-64 builds and the others comes from the call instruction. x86-64’s call pushes an 8-byte return
address, so every function starts 8 bytes below the boundary its caller kept, and before it calls anything it must
move rsp by another 8, or 24, or any odd multiple of 8. One push of any register does that, which is all
push rax is for: 8 for the return address and 8 for rax make 16. Windows gets there with arithmetic, since 8 and
40 make 48. bl and RISC-V’s call push nothing, so on those two a function starts aligned and keeps every frame a
multiple of 16; the RISC-V build of one_call leaves 8 of its 16 bytes unused.
Why 16 bytes, when no register here is wider than 8? The largest values that commonly live on the stack are
16 bytes wide: an SSE register on x86-64, a q register or a pair of x registers on AArch64, a long double on Linux, or an
__int128. Microsoft gives its reason for the stack’s 16-byte alignment in those terms: 16 bytes is a common
alignment for XMM operations. With every frame starting on such a boundary, a compiler can give those values fixed
offsets and use the instructions that need aligned addresses, without testing anything at run time.
Key idea
At a call the stack pointer holds a multiple of 16 under all four ABIs. On x86-64 the call itself pushes 8 bytes,
so every function starts 8 bytes off and must move rsp by an odd multiple of 8 before it calls again.
Checkpoint
Check: The alignment rule at a call, per ABI
On AArch64 the fault comes at the first access through sp, often in the callee
call_aligned and call_misaligned differ in one immediate: the first makes room for a local with sub sp, sp, #16
and the second with sub sp, sp, #8. The C driver calls one of them and catches SIGBUS and SIGSEGV with a handler
that prints what a crash report would show: the signal, its code, the function and offset of the faulting instruction
found with dladdr(), and sp modulo 16 at that moment. The handler runs on a stack of its own, set up with
sigaltstack(), because the thread’s stack pointer is the one thing in doubt:
calls_aarch64.s
// Two callers of the C function show(long), for AArch64 on Apple platforms. Both build a frame record and then
// make room for a local before the call. call_aligned reserves 16 bytes, so sp stays a multiple of 16;
// call_misaligned reserves 8, which no instruction here objects to.
.text
.p2align 2
.globl _call_aligned
_call_aligned:
stp x29, x30, [sp, #-16]! // sp = S - 16
mov x29, sp
sub sp, sp, #16 // sp = S - 32: a multiple of 16
bl _show // x0 still holds the argument
add sp, sp, #16
ldp x29, x30, [sp], #16
ret
.globl _call_misaligned
_call_misaligned:
stp x29, x30, [sp, #-16]! // sp = S - 16
mov x29, sp
sub sp, sp, #8 // sp = S - 24: 8 bytes off a multiple of 16
bl _show // show starts with sp 8 bytes off
add sp, sp, #8
ldp x29, x30, [sp], #16
ret align_main.c
// Driver for calls_aarch64.s. It calls call_aligned, or call_misaligned when run as "./align bad". A handler for
// SIGBUS and SIGSEGV prints what a crash report would show: the signal, its code, the function and offset of the
// instruction that faulted, and sp modulo 16 at that moment. It runs on its own stack (sigaltstack), because the
// thread's stack pointer is the thing in doubt.
#include <dlfcn.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/ucontext.h>
#include <unistd.h>
void call_aligned(long v);
void call_misaligned(long v);
void show(long v) { printf("show(%ld) printed this line\n", v); }
static void on_fault(int sig, siginfo_t *info, void *context) {
ucontext_t *uc = context;
uint64_t pc = uc->uc_mcontext->__ss.__pc;
uint64_t sp = uc->uc_mcontext->__ss.__sp;
const char *code = (sig == SIGBUS && info->si_code == BUS_ADRALN) ? "BUS_ADRALN" : "other";
Dl_info where;
char line[200];
int n;
if (dladdr((void *)pc, &where) && where.dli_sname)
n = snprintf(line, sizeof line, "%s, code %s, at %s+%llu, sp %% 16 = %llu\n", sig == SIGBUS ? "SIGBUS" : "SIGSEGV",
code, where.dli_sname, (unsigned long long)(pc - (uint64_t)where.dli_saddr),
(unsigned long long)(sp % 16));
else
n = snprintf(line, sizeof line, "signal %d, code %s, sp %% 16 = %llu\n", sig, code, (unsigned long long)(sp % 16));
write(1, line, n);
_exit(1);
}
int main(int argc, char **argv) {
static char spare[65536];
stack_t alt = {.ss_sp = spare, .ss_size = sizeof spare, .ss_flags = 0};
sigaltstack(&alt, NULL);
struct sigaction action;
memset(&action, 0, sizeof action);
action.sa_sigaction = on_fault;
action.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigaction(SIGBUS, &action, NULL);
sigaction(SIGSEGV, &action, NULL);
setvbuf(stdout, NULL, _IONBF, 0);
if (argc > 1 && strcmp(argv[1], "bad") == 0)
call_misaligned(7);
else
call_aligned(7);
printf("back in main\n");
return 0;
} """Builds the two callers of show() with the C driver, runs each once, and disassembles the start of show."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# One program: the C driver, show() and the two assembly callers
clang -O1 -o align align_main.c calls_aarch64.s
# sp a multiple of 16 at the call
./align; echo "exit=$?"
# sp 8 bytes off at the call
./align bad; echo "exit=$?"
# the first three instructions of show, the function that faulted
llvm-objdump -d --no-show-raw-insn --no-leading-addr --disassemble-symbols=_show align | grep -v -E '^$|file format|Disassembly' | head -4 | tr -s ' \t' ' '
""") Output
# One program: the C driver, show() and the two assembly callers $ clang -O1 -o align align_main.c calls_aarch64.s # sp a multiple of 16 at the call $ ./align; echo "exit=$?" show(7) printed this line back in main exit=0 # sp 8 bytes off at the call $ ./align bad; echo "exit=$?" SIGBUS, code BUS_ADRALN, at show+4, sp % 16 = 8 exit=1 # the first three instructions of show, the function that faulted $ llvm-objdump -d --no-show-raw-insn --no-leading-addr --disassemble-symbols=_show align | grep -v -E '^$|file format|Disassembly' | head -4 | tr -s ' ' ' ' <_show>: sub sp, sp, #0x20 stp x29, x30, [sp, #0x10] add x29, sp, #0x10
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 misaligned_session.py
The aligned call printed its line. The misaligned one never reached printf: the fault came at show+4, the
stp x29, x30, [sp, #0x10] with which show stores its own frame record: Two 64-bit values a function stores in its stack frame: the address of its caller's frame record and its own return address. Linked by the frame pointer, the records let a debugger or profiler walk every active call. More in “Callee-saved registers and stack frames”, while sp
was 8 bytes off a multiple of 16. Every instruction before it got through. sub sp, sp, #8 is arithmetic, bl
writes x30 and touches no memory, and show’s own sub sp, sp, #0x20 is arithmetic again. Arm’s architecture
checks sp only when a load or a store uses it as its base register, and only where the operating system has
turned that check on: the SA0 bit of the system register SCTLR_EL1 enables this
SP alignment check: An AArch64 check, turned on by the operating system (the SA0 bit for user code), that makes any load or store whose base register is sp fault when sp is not a multiple of 16. Arithmetic on sp and branches are never checked. for user code, and Linux sets that bit in the value it gives
SCTLR_EL1 at boot. Step through the run, one value of sp at a time:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- On entry. main has called call_misaligned with bl. sp holds S, a multiple of 16, as AAPCS64 requires at a public interface.
- stp x29, x30, [sp, #-16]!. This store uses sp as its base register, so the hardware checks sp first. S passes; the pair goes to S - 16, and sp becomes S - 16, still a multiple of 16.
- sub sp, sp, #8. Plain arithmetic: nothing checks its result. sp is now S - 24, which is 8 bytes off a multiple of 16.
- bl _show. bl writes the return address into x30 and branches. It reads and writes no memory, so nothing is checked, and show starts with sp still 8 bytes off.
- show+0: sub sp, sp, #0x20. show makes its 32-byte frame with arithmetic again. sp becomes S - 56, still 8 bytes off.
- show+4: stp x29, x30. The first store through sp. The SP alignment check fails before anything is written, macOS delivers SIGBUS with code BUS_ADRALN, and the handler prints show+4 and sp % 16 = 8.
- Where the bug is. show did nothing wrong. The 8 in call_misaligned is the bug, one frame up: with 16 there, sp is S - 32 at the call and the same run prints the line from show.
So the report names a function that did nothing wrong. The rule broke one frame up, in the caller, and any function
call_misaligned called would have died at its first store through sp, whether show, memcpy or printf.
Key idea
On AArch64 a misaligned sp is caught at the first load or store that uses it as a base, which is often the
callee’s first frame store; the arithmetic instruction that misaligned it passes unnoticed.
BUS_ADRALN is the POSIX code for an invalid address alignment, and Linux reports the same fault with the same code
from its handler for what it calls the “SP/PC alignment exception”. Only the number differs. SIGBUS is signal 10 on
macOS, so a shell reports a process it ended with status 138, and signal 7 on Linux, which makes the status 135;
the stack shows where 128 plus the signal’s number comes from.
Debugging the function named in the report
The function that faults is usually innocent: its first store through sp checks a stack pointer its caller
handed over. Look at the frames above it instead, for an adjustment of sp that is not a multiple of 16, such as
sub sp, sp, #8 or str x19, [sp, #-8]!, on the path that ran.
Checkpoint
Check: Where AArch64 catches a misaligned sp
On x86-64 only aligned vector accesses fault, so a misaligned stack can pass tests
x86-64 has no check like SA0: push, pop, mov and call work at any address. The rule matters because compiled
code depends on it. clear_and_use zeroes a 32-byte array before it passes the array on, and total is a
variadic function: A function that takes a variable number of arguments after its named parameters, such as printf, whose prototype ends in an ellipsis (...). Each ABI says where the extra arguments travel: on Apple silicon in 8-byte stack slots, on AArch64 and x86-64 Linux in the usual argument registers. More in “Calling C library functions from assembly”, like printf, that adds n doubles:
// Two functions whose x86-64 code depends on rsp being where the System V ABI says it is. clear_and_use zeroes a
// 32-byte local array; total is variadic, like printf, and adds n doubles.
#include <stdarg.h>
void use(char *);
void clear_and_use(void) {
char buf[32] = {0};
use(buf);
}
double total(int n, ...) {
va_list ap;
va_start(ap, n);
double sum = 0;
for (int i = 0; i < n; i++) sum += va_arg(ap, double);
va_end(ap);
return sum;
} """Compiles clear_and_use and total at -O2 for x86-64 Linux and prints their first instructions."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# x86-64 Linux, System V ABI: all of clear_and_use and the first 17 instructions of total
sh listing.sh x86_64-linux-gnu vector_slots.c | head -27
""") Output
# x86-64 Linux, System V ABI: all of clear_and_use and the first 17 instructions of total $ sh listing.sh x86_64-linux-gnu vector_slots.c | head -27 clear_and_use: sub rsp, 40 xorps xmm0, xmm0 movaps xmmword ptr [rsp + 16], xmm0 movaps xmmword ptr [rsp], xmm0 mov rdi, rsp call use@PLT add rsp, 40 ret total: sub rsp, 88 mov qword ptr [rsp - 88], rsi mov qword ptr [rsp - 80], rdx mov qword ptr [rsp - 72], rcx mov qword ptr [rsp - 64], r8 mov qword ptr [rsp - 56], r9 test al, al je .LBB1_19 movapd xmmword ptr [rsp - 48], xmm0 movaps xmmword ptr [rsp - 32], xmm1 movaps xmmword ptr [rsp - 16], xmm2 movaps xmmword ptr [rsp], xmm3 movaps xmmword ptr [rsp + 16], xmm4 movaps xmmword ptr [rsp + 32], xmm5 movaps xmmword ptr [rsp + 48], xmm6 movaps xmmword ptr [rsp + 64], xmm7 lea rax, [rsp - 96]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vector_slots_session.py
clear_and_use subtracts 40 from an rsp that arrived 8 bytes off, which leaves rsp aligned, and clears the array
with two movaps stores of 16 zero bytes each. Intel’s manual requires the memory operand of movaps, movapd and
movdqa to sit on a 16-byte boundary, 32 bytes for their 256-bit forms, and raises a
general-protection exception: The x86 exception #GP, raised for a protection violation such as an aligned vector instruction like movaps whose memory operand is not on a 16-byte boundary. Linux delivers it to a user program as SIGSEGV. otherwise; for unaligned data it points
to the movups form. Linux turns a general-protection fault in user code into SIGSEGV, status 139. Had the caller
left rsp 8 bytes off, both stores would have faulted, a crash in clear_and_use for its caller’s mistake.
total shows how such a mistake can hide. A variadic function cannot know which registers carry its arguments, so
its prologue stores all of them in a register save area: Memory in a variadic function's own frame where its prologue stores the argument registers that may carry variadic arguments, so that va_arg can read them in order: up to rdi to r9 and xmm0 to xmm7 under System V, x0 to x7 and q0 to q7 under AAPCS64 on Linux. More in “Structs, variadic calls and large values”: the five integer
argument registers after rdi with plain mov, and xmm0 to xmm7 with movapd and movaps. Under System V
the caller’s al gives the count of vector registers that carry arguments, never below the true count and never
above 8, and test al, al with je skips all eight vector stores when al is 0. A misaligned call that passes only integers to such a function sets
al to 0, jumps over every aligned store and works; the same call with one double sets al to 1, reaches
movapd xmmword ptr [rsp - 48], xmm0 and faults.
| Instruction on a stack address | Alignment it needs | With rsp 8 bytes off |
|---|---|---|
push, pop, call, mov |
none | works |
movups, movdqu |
none | works |
movaps, movapd, movdqa |
16 bytes | general-protection fault, SIGSEGV |
vmovaps with a 256-bit ymm register |
32 bytes | general-protection fault, SIGSEGV |
GCC’s manual describes the same trap from the other side: code built with 16-byte stack alignment, a standard library
for instance, called with a misaligned stack may meet misaligned-access traps in SSE instructions. A test suite that
never passes a double through the faulty call never runs those instructions.
Trusting a call because it worked once
A misaligned call into printf with "%d" prints correctly, because al is 0 and the vector saves are skipped.
It proves nothing about the same call with "%f". Check rsp modulo 16 at the call instead of the output.
Checkpoint
Check: Why x86-64 faults only in aligned instructions
Counting every push and every sub keeps a hand-written function aligned
Compilers get alignment right by counting bytes, and hand-written code can count the same way. keep_one keeps x
across its call to g, and keep_two keeps x and y, so they need one and two
callee-saved register: A register whose value a called function must hand back unchanged: if it writes the register, it saves the old value first and restores it before it returns. rbx on x86-64, x19 on AArch64 and s0 on RISC-V are callee-saved. More in “Callee-saved registers and stack frames”:
// Two functions that keep values across one call to g: keep_one needs one callee-saved register, keep_two needs
// two. Count what each prologue does to the stack pointer before the call.
long g(long);
long keep_one(long x) { return g(x) + x; }
long keep_two(long x, long y) { return g(x) + x * y; } """Compiles keep_one and keep_two at -O2 for four targets and prints the instructions of each."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# x86-64 Linux, System V ABI
sh listing.sh x86_64-linux-gnu pad.c
# x86-64 Windows, Microsoft x64 convention (compiler output only)
sh listing.sh x86_64-pc-windows-msvc pad.c
# AArch64 on macOS
sh listing.sh arm64-apple-macos pad.c
# 64-bit RISC-V Linux, LP64D
sh listing.sh riscv64-linux-gnu pad.c
""") Output
# x86-64 Linux, System V ABI $ sh listing.sh x86_64-linux-gnu pad.c keep_one: push rbx mov rbx, rdi call g@PLT add rax, rbx pop rbx ret keep_two: push r14 push rbx push rax mov rbx, rsi mov r14, rdi call g@PLT imul rbx, r14 add rax, rbx add rsp, 8 pop rbx pop r14 ret # x86-64 Windows, Microsoft x64 convention (compiler output only) $ sh listing.sh x86_64-pc-windows-msvc pad.c keep_one: push rsi sub rsp, 32 mov esi, ecx call g add eax, esi add rsp, 32 pop rsi ret keep_two: push rsi push rdi sub rsp, 40 mov esi, edx mov edi, ecx call g imul esi, edi add eax, esi add rsp, 40 pop rdi pop rsi ret # AArch64 on macOS $ sh listing.sh arm64-apple-macos pad.c _keep_one: stp x20, x19, [sp, #-32]! stp x29, x30, [sp, #16] add x29, sp, #16 mov x19, x0 bl _g add x0, x0, x19 ldp x29, x30, [sp, #16] ldp x20, x19, [sp], #32 ret _keep_two: stp x20, x19, [sp, #-32]! stp x29, x30, [sp, #16] add x29, sp, #16 mov x19, x1 mov x20, x0 bl _g madd x0, x19, x20, x0 ldp x29, x30, [sp, #16] ldp x20, x19, [sp], #32 ret # 64-bit RISC-V Linux, LP64D $ sh listing.sh riscv64-linux-gnu pad.c keep_one: addi sp, sp, -16 sd ra, 8(sp) sd s0, 0(sp) mv s0, a0 call g add a0, a0, s0 ld ra, 8(sp) ld s0, 0(sp) addi sp, sp, 16 ret keep_two: addi sp, sp, -32 sd ra, 24(sp) sd s0, 16(sp) sd s1, 8(sp) mv s0, a1 mv s1, a0 call g mul a1, s0, s1 add a0, a0, a1 ld ra, 24(sp) ld s0, 16(sp) ld s1, 8(sp) addi sp, sp, 32 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pad_session.py
On x86-64 Linux the count starts at 8, the return address. keep_one pushes rbx to save it, which makes 16, and
calls g with nothing more. keep_two must save r14 and rbx, which makes 24, so it adds push rax, a third
push for the count alone, and gives those 8 bytes back with add rsp, 8 instead of a pop. Under System V the rule is
parity: before a call, a function’s pushes and subtractions together must come to an odd multiple of 8. Windows adds
its 32 bytes of shadow space to the same sum. The bytes moved before each call, counted from the caller’s aligned
rsp:
| Target | keep_one | keep_two |
|---|---|---|
| x86-64 Linux | 8 + push rbx = 16 |
8 + three pushes = 32 |
| x86-64 Windows | 8 + push rsi + 32 = 48 |
8 + two pushes + 40 = 64 |
| AArch64 macOS | a 32-byte frame: x20, x19 and the frame record |
the same 32 bytes |
| RISC-V Linux | a 16-byte frame: ra and s0 |
a 32-byte frame: ra, s0, s1 and 8 spare |
AArch64 and RISC-V have no return address on the stack to count, so they round every frame up to a multiple of 16
instead. Apple’s keep_one saves x20 beside x19 because a pair takes the same 16 bytes as one register, and the
RISC-V keep_two leaves the slot at 0(sp) unused, since 24 bytes of saves need a 32-byte frame. Step through the
count for keep_two on x86-64 Linux:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- On entry. The caller's call pushed the return address, so rsp is 8 bytes below the multiple of 16 the caller kept. keep_two must call g with rsp aligned again.
- push r14. keep_two keeps x in r14 across the call, so it saves the caller's r14 first. 8 + 8 = 16 bytes: rsp is aligned right now.
- push rbx. y lives in rbx across the call, so rbx is saved too. 24 bytes: 8 off again. Two saves alone would make the call misaligned.
- push rax. rax holds nothing keep_two needs; this push exists only for the count. 32 bytes: rsp is a multiple of 16 again.
- call g@PLT. The call pushes keep_two's own return address, so g starts 8 bytes below a multiple of 16, exactly as keep_two did. The rule holds one level down.
- add rsp, 8. Back from g, the padding is given back without loading it: the epilogue adds 8 instead of popping into a register.
- pop rbx, pop r14. The two saved values come back in the reverse order of their pushes, and the count falls to 8, the return address alone.
- ret. ret pops the return address. rsp is the caller's again, a multiple of 16, as it was before the caller's call.
At its call, g starts exactly as keep_two did: 8 bytes below a multiple of 16.
Key idea
Count from 8 on x86-64, because of the return address, and make every call see a multiple of 16: an odd number of 8-byte steps under System V, the same with 32 bytes of shadow space under Windows. AArch64 and RISC-V round every frame up to a multiple of 16.
The count works the same way in a hand-written routine: write down the 8 of the return address, add 8 for each
push and the operand of each sub rsp, and check that the sum is a multiple of 16 at every call, on every path.
A routine that pushes on one path and not on another needs the check on both.
Counting from zero on x86-64
A count that forgets the return address adds a padding push where none is needed and leaves it out where it is: a
function that saves only rbx and pushes rax as well calls g with rsp 8 bytes off. Start every count at 8.
Checkpoint
Check: Counting bytes to keep a function aligned
A local that needs 32-byte alignment makes the function realign the stack
_Alignas(32) asks for more than any of the four ABIs promises. A caller hands over a 16-byte boundary, and nothing
says whether that is also a 32-byte one. No constant subtraction can fix that, because the constant that works
depends on where the stack pointer happens to be, so the function rounds the stack pointer down instead:
// buf must start at a multiple of 32, twice the alignment any of these ABIs promises for the stack pointer, so the
// function cannot simply subtract a constant from it.
void use(char *);
void wide_local(void) {
_Alignas(32) char buf[64] = {0};
use(buf);
} """Compiles wide_local at -O2 for three targets and prints the instructions of each."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session # noqa: E402
session.run("""
# -fno-stack-protector leaves out a canary check that has nothing to do with alignment
# x86-64 Linux, System V ABI
sh listing.sh x86_64-linux-gnu wide.c -fno-stack-protector
# AArch64 on macOS
sh listing.sh arm64-apple-macos wide.c -fno-stack-protector
# 64-bit RISC-V Linux, LP64D
sh listing.sh riscv64-linux-gnu wide.c -fno-stack-protector
""") Output
# -fno-stack-protector leaves out a canary check that has nothing to do with alignment # x86-64 Linux, System V ABI $ sh listing.sh x86_64-linux-gnu wide.c -fno-stack-protector wide_local: push rbp mov rbp, rsp and rsp, -32 sub rsp, 96 xorps xmm0, xmm0 movaps xmmword ptr [rsp + 48], xmm0 movaps xmmword ptr [rsp + 32], xmm0 movaps xmmword ptr [rsp + 16], xmm0 movaps xmmword ptr [rsp], xmm0 mov rdi, rsp call use@PLT mov rsp, rbp pop rbp ret # AArch64 on macOS $ sh listing.sh arm64-apple-macos wide.c -fno-stack-protector _wide_local: stp x29, x30, [sp, #-16]! mov x29, sp sub x9, sp, #80 and sp, x9, #0xffffffffffffffe0 movi.2d v0, #0000000000000000 stp q0, q0, [sp, #32] stp q0, q0, [sp] mov x0, sp bl _use mov sp, x29 ldp x29, x30, [sp], #16 ret # 64-bit RISC-V Linux, LP64D $ sh listing.sh riscv64-linux-gnu wide.c -fno-stack-protector wide_local: addi sp, sp, -96 sd ra, 88(sp) sd s0, 80(sp) addi s0, sp, 96 andi sp, sp, -32 sd zero, 32(sp) sd zero, 40(sp) sd zero, 48(sp) sd zero, 56(sp) sd zero, 0(sp) sd zero, 8(sp) sd zero, 16(sp) sd zero, 24(sp) mv a0, sp call use addi sp, s0, -96 ld ra, 88(sp) ld s0, 80(sp) addi sp, sp, 96 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wide_session.py
After push rbp, the x86-64 build’s rsp is a multiple of 16. and rsp, -32 clears its low five bits, which lowers
it by 0 or 16 bytes to a multiple of 32, and sub rsp, 96 keeps that, since 96 is 3 × 32; four movaps stores then
clear the 64 bytes at [rsp]. The AArch64 build computes x9 = sp - 80 and writes the rounded value straight into
sp with and sp, x9, #0xffffffffffffffe0, and the RISC-V build uses andi sp, sp, -32. This is
stack realignment: Rounding the stack pointer down with an and, such as and rsp, -32, so that a local can get more alignment than the ABI promises. The rounding loses the old value, so the function keeps a copy in a frame pointer and restores it from there., and it has a price. Rounding throws away the old value of the
stack pointer, and only a register can bring it back, so every build first sets a
frame pointer: A register that locates the current function's frame record, so that each frame links to its caller's: x29 on AArch64 and rbp on x86-64 address the record, and s0 on RISC-V points just above it. Compilers may omit it, and Apple requires x29 to address a valid frame record. More in “Callee-saved registers and stack frames” and its epilogue restores the stack pointer from it:
| ISA | Rounds down with | Restores the stack pointer with |
|---|---|---|
| x86-64 | and rsp, -32 |
mov rsp, rbp, then pop rbp |
| AArch64 | and sp, x9, #0xffffffffffffffe0 |
mov sp, x29, then ldp x29, x30 |
| RISC-V | andi sp, sp, -32 |
addi sp, s0, -96, then the loads |
Microsoft’s convention puts the same point in one line: alignment above 16 bytes must be done by hand. The System V
rule of the first section needs 32 or 64 bytes only from a caller that passes an __m256 or __m512 argument on the
stack; a local that needs that much is the callee’s own business, as in wide_local. GCC’s -mstackrealign uses the
same rounding at entry, for another reason: it lets code that keeps 16-byte alignment be called from older code that
keeps only 4.
Checkpoint
Check: Realigning the stack for a 32-byte local
Practise
Three changes to copies of the lesson’s files. Build and list them with the commands the sessions above print.
- In a copy of
calls_aarch64.s, writecall_twice(v), which callsshow(v)and thenshow(v + 1), keepingvinx19across the first call. Decide the frame’s size before you write the prologue, with the record andx19in it. Addcall_twiceto a copy ofalign_main.cand check that both lines print. Then change only the frame size, so that one of your instructions leavessp8 bytes off, and predict which instruction the handler names before you run it. - Write
keep_three(x, y, z), which returnsg(x) + x * y + z, by hand for x86-64 System V, in a GNUasfile in Intel syntax, keeping its three values inrbx,r12andr13across the call. Count from 8 and decide whether it needs padding. Assemble it withclang --target=x86_64-linux-gnu -cand list it withllvm-objdump -d -M intel. Then add a fourth saved register and say what changes. - Change
wide.cto_Alignas(64)and predict the mask and the subtraction in the x86-64 build before you runlisting.shon it. Then run it forarm64-apple-macosand find the new mask.
Practice that adapts to you
Practise this lesson
15 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 must the stack pointer satisfy at a call on x86-64 Linux and on AArch64, and why does a System V function that pushes exactly one register need no padding?
On x86-64 Linux, under System V, rsp must be a multiple of 16 just before a call executes. The call then pushes
an 8-byte return address, so at a function’s first instruction rsp + 8 is the multiple of 16. On AArch64,
AAPCS64 requires sp to be a multiple of 16 at every public interface and at every memory access through sp, and
the hardware checks the second rule when the operating system enables it. A System V function that pushes one
register has moved rsp by 8 bytes after the return address’s 8, which makes 16, so it can call at once. With zero or
two pushes it needs 8 more bytes, and compilers often add push rax or sub rsp, 8 for that. AArch64 has no pushed
return address, so its frames are simply rounded up to a multiple of 16.
Two longer cases in the module’s bank start from the crashes on this page: one is a printf that fails only when it
prints a double, the other a bus error reported in a library function that is not at fault.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
An x86-64 Linux assembly routine pushes rbx and r12 at entry and calls printf to log a count with "%ld". It has worked for months. A colleague adds the mean as a double, "%ld items, mean %.2f", sets al to 1, and the routine now dies with SIGSEGV inside printf. Explain the crash, prove it, and fix it.
-
Mid to senior
After a change to a hand-written AArch64 routine that now keeps a third local, a Linux service dies with SIGBUS, si_code BUS_ADRALN, and the top frame is format_line, a logging-library C function untouched for years. The routine addresses its locals through x29. Find the fault, explain why format_line is named, and fix it.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What alignment must the stack pointer have at a call, and why is an x86-64 function's entry 8 bytes off?
A multiple of 16 under System V, Microsoft x64, AAPCS64 and RISC-V. On x86-64 the call itself pushes an 8-byte return address, so a function must move rsp by an odd multiple of 8 before it calls again.
-
Why can a System V function that pushes exactly one register call another at once?
The return address took 8 bytes and the push another 8, which makes 16, so rsp is a multiple of 16 again. With zero or two pushes the function needs 8 more bytes, often a push rax or a sub rsp, 8.
-
On AArch64, where does a misaligned stack pointer fault?
At the first memory access whose base register is sp, often the callee's first frame store, with SIGBUS and BUS_ADRALN. The arithmetic that misaligned sp is never checked, so the crash points at correct code.
-
Why can a misaligned stack pass tests on x86-64 and then crash later?
x86-64 faults only on instructions that need aligned memory, such as movaps. Integer code runs fine until a callee, typically a variadic one saving its vector registers, uses an aligned vector store.
-
How do you keep hand-written functions aligned?
Count: start at 8 on x86-64 for the return address, add every push and every subtraction, include Windows' 32 bytes of shadow space, and make each call see a multiple of 16. On AArch64 and RISC-V round every frame up to 16.
-
How does a function get 32-byte alignment for a local when the ABI only promises 16?
It rounds the stack pointer down with an and, such as and rsp, -32, and keeps the old value in a frame pointer to restore it, because the rounding loses it.
Key takeaways
- System V, Microsoft x64, AAPCS64 and RISC-V all keep the stack pointer a multiple of 16 at a call; on x86-64 the
return address that
callpushes leaves each function’s entry 8 bytes off, so the function movesrspby an odd multiple of 8 before it calls again. - AArch64 checks
spat every load or store that uses it as a base, so a misaligned stack faults with SIGBUS andBUS_ADRALN, often in a correct callee, while the arithmetic that misaligned it passes. - x86-64 faults only in instructions that need aligned memory, such as
movaps, so a misaligned call can work for integer arguments and fail as soon as a variadic callee saves its vector registers. - Counting keeps hand-written code aligned: start at 8 on x86-64, add every push and subtraction, include Windows’ 32 bytes of shadow space, and round every AArch64 and RISC-V frame up to a multiple of 16.
- A local that needs more alignment than 16 bytes makes the function round the stack pointer down with an
and, and restore it from a frame pointer, because the rounding loses its old value.
Terms in this lesson
- general-protection exception
- The x86 exception #GP, raised for a protection violation such as an aligned vector instruction like movaps whose memory operand is not on a 16-byte boundary. Linux delivers it to a user program as SIGSEGV.
- SP alignment check
- An AArch64 check, turned on by the operating system (the SA0 bit for user code), that makes any load or store whose base register is sp fault when sp is not a multiple of 16. Arithmetic on sp and branches are never checked.
- stack alignment
- The rule an ABI sets for the address in the stack pointer, such as a multiple of 16 at every call. Compilers rely on it to keep values that need aligned memory at fixed offsets from the stack pointer.
- stack realignment
- Rounding the stack pointer down with an and, such as and rsp, -32, so that a local can get more alignment than the ABI promises. The rounding loses the old value, so the function keeps a copy in a frame pointer and restores it from there.
References
- System V Application Binary Interface, AMD64 Architecture Processor Supplement (the stack frame, parameter passing) (x86-64 psABI maintainers)
- x64 stack usage (alignment, leaf functions) (Microsoft)
- x64 calling convention (alignment) (Microsoft)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64): the stack (Arm Limited)
- SCTLR_EL1, System Control Register (EL1): SA0, the SP alignment check for EL0 (Arm Limited)
- Writing ARM64 code for Apple platforms (stack pointer alignment) (Apple Inc.)
- RISC-V ELF psABI: calling convention (the stack pointer) (RISC-V International)
- Intel 64 and IA-32 Architectures Software Developer's Manual (MOVAPS, MOVAPD, MOVDQA) (Intel Corporation)
- GCC manual: x86 Options (-mpreferred-stack-boundary, -mstackrealign) (GCC project)
- Linux kernel: arch/arm64/mm/fault.c (SP/PC alignment exception) (Linux kernel)
- Linux kernel: arch/arm64/include/asm/sysreg.h (SCTLR_EL1 settings) (Linux kernel)
- Linux kernel: arch/x86/kernel/traps.c (general protection faults in user mode) (Linux kernel)
- Linux kernel: include/uapi/asm-generic/signal.h (signal numbers) (Linux kernel)
- POSIX.1-2024: signal.h (si_code values such as BUS_ADRALN) (The Open Group)
Report a problem with this lesson
Kept only in this browser. Your Learn progress