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 9 – Interfacing with C, C++ and Rust

Calling C library functions from assembly

Call puts, printf, malloc and qsort from AArch64 assembly on macOS, link with clang, and follow each call through its stub and GOT slot on Mach-O and ELF.

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

What you will learn

  • Link an assembly main with the C runtime through clang, and explain what ld alone is missing
  • Predict where printf's variadic argument travels on Apple arm64, AArch64 Linux and x86-64 Linux
  • Call malloc and qsort from AArch64 assembly, with a NULL check and live values in callee-saved registers
  • Trace a call into a shared library through a Mach-O stub or an ELF PLT entry and its GOT slot

Before you start

What you’ll learn · Before you start

What you will learn

  • Link an assembly main with the C runtime through clang, and explain what ld alone is missing
  • Predict where printf's variadic argument travels on Apple arm64, AArch64 Linux and x86-64 Linux
  • Call malloc and qsort from AArch64 assembly, with a NULL check and live values in callee-saved registers
  • Trace a call into a shared library through a Mach-O stub or an ELF PLT entry and its GOT slot

Before you start

On this page

An AArch64 main that hands 42 to printf in x1, the register Linux reads, prints value = 30583 on a Mac: printf takes the number from the stack slot at [sp], where the program had planted 0x7777. Stored at [sp], the same 42 prints as 42. A C library function is an ordinary function under the platform’s C calling convention: The part of an ABI that governs calls: which registers carry the arguments and the result, where arguments that do not fit go on the stack, which registers a callee must preserve and how the stack is aligned at a call. More in “Calling conventions: arguments and returns”: put each argument where that convention says, keep sp 16-byte aligned and your callee-saved registers intact, call it with bl or call, and let clang link the library in.

The macOS programs below run on Apple silicon: puts, printf, malloc with its NULL check, and qsort calling back a comparator written in C. Then one call is followed from your code into the library, through the stub and the slot that macOS and Linux each add to your program, to see what the dynamic loader writes there and when.

hello_puts.s is a whole program without a _start of its own. Its _main prints one line with puts and returns argc, the number of words on the command line counting the program’s name, so the exit status shows that real arguments arrived:

hello_puts.s: _main calls puts and returns argc S · macos/hello_puts.s
// hello_puts.s: _main prints one line with puts, then returns argc, which becomes the exit status.
// AArch64 macOS (Mach-O): every C name carries a leading underscore, so puts is the symbol _puts.
        .section __TEXT,__cstring
line:   .asciz  "Hello from _main, through puts"

        .text
        .globl  _main
        .p2align 2
_main:                                  // int main(int argc, char **argv): argc in w0, argv in x1
        stp     x29, x30, [sp, #-32]!   // bl overwrites x30, so keep main's return address (and x29)
        mov     x29, sp
        str     x19, [sp, #16]          // x19 is callee-saved: main must put it back before ret
        mov     w19, w0                 // keep argc in a register that puts must not change
        adrp    x0, line@PAGE           // 1st argument: the address of the string
        add     x0, x0, line@PAGEOFF
        bl      _puts                   // puts writes the line and a newline
        mov     w0, w19                 // main's result: argc
        ldr     x19, [sp, #16]
        ldp     x29, x30, [sp], #32
        ret

_main runs because the C runtime: The start-up code and C library that a compiler driver links into a program. It prepares the process, calls main with argc and argv as an ordinary function and passes main's result to exit; on macOS dyld and libSystem provide it, on Linux crt1.o and libc. calls it. On macOS, as the first-program lesson showed, dyld reads the LC_MAIN load command and the start-up code: The code a compiler driver links around a C program, such as crt1.o's _start, which reads the initial stack, prepares the C library, calls main and passes its return value to exit. More in “Process startup: argc, argv, envp and auxv” calls _main like any C function, then passes its result to exit. On Linux, crt1.o’s _start calls __libc_start_main, which calls main; the process start-up lesson traces both. Either way main is an ordinary function, bound by the convention twice: as the runtime’s callee and as the caller of puts. AAPCS64 gives the three rules this lesson leans on, and the calling-conventions lesson works through them:

  • The first eight integer or pointer arguments travel in x0 to x7, and the result comes back in x0.
  • A call may change x0 to x17 and x30, while x19 to x28 are 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”: a value that must outlive a call goes there, after the old value is saved.
  • sp is a multiple of 16 at every call, and a function that calls another first stores x29 and x30 as a 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”, because its own bl overwrites x30.

hello_puts.s keeps all three. It saves the frame record and x19, parks argc in w19 while puts runs, and restores both before ret. The session builds it twice, once with clang and once with the linker on its own:

Linking _main with clang, and with ld alone Python · runtime_session.py
"""Build hello_puts.s with clang and with the linker alone, and run both.

clang links the object with libSystem, whose start-up code calls _main and turns its result into the exit status.
ld on its own knows nothing of the C library until it is told where libSystem is.
"""

import pathlib
import sys

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

session.run(
    """
# clang assembles, links and runs it: the program name and two words make argc 3.
clang -o macos/hello_puts macos/hello_puts.s
./macos/hello_puts one two; echo "exit=$?"
# The linker on its own, given only the object, cannot find puts.
clang -c -o macos/hello_puts.o macos/hello_puts.s
ld -o macos/hello_ld macos/hello_puts.o
# Three of the options clang hands to ld when it links for you.
clang -### -o macos/hello_puts macos/hello_puts.o 2>&1 | tr ' ' '\\n' | grep -x -E '"-dynamic"|"-syslibroot"|"-lSystem"'
# Name libSystem and the SDK that holds it, as clang does, and ld links the same program.
ld -o macos/hello_ld macos/hello_puts.o -lSystem -syslibroot "$(xcrun --show-sdk-path)"
./macos/hello_ld; echo "exit=$?"
"""
)

Output

# clang assembles, links and runs it: the program name and two words make argc 3.
$ clang -o macos/hello_puts macos/hello_puts.s
$ ./macos/hello_puts one two; echo "exit=$?"
Hello from _main, through puts
exit=3
# The linker on its own, given only the object, cannot find puts.
$ clang -c -o macos/hello_puts.o macos/hello_puts.s
$ ld -o macos/hello_ld macos/hello_puts.o
Undefined symbols for architecture arm64:
  "_puts", referenced from:
      _main in hello_puts.o
ld: symbol(s) not found for architecture arm64
[exit status 1]
# Three of the options clang hands to ld when it links for you.
$ clang -### -o macos/hello_puts macos/hello_puts.o 2>&1 | tr ' ' '\n' | grep -x -E '"-dynamic"|"-syslibroot"|"-lSystem"'
"-dynamic"
"-syslibroot"
"-lSystem"
# Name libSystem and the SDK that holds it, as clang does, and ld links the same program.
$ ld -o macos/hello_ld macos/hello_puts.o -lSystem -syslibroot "$(xcrun --show-sdk-path)"
$ ./macos/hello_ld; echo "exit=$?"
Hello from _main, through puts
exit=1

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

With clang the line appears and the status is 3: the program’s name and two words. ld, given only the object, stops at "_puts", referenced from: _main, because an object names the functions it needs without saying which library defines them. clang -### prints the link command it would run, and three of its options carry exactly that knowledge: -dynamic asks for a dynamically linked program, -lSystem names libSystem, and -syslibroot points at the SDK in which to look for it. Handed those options, ld links the same program, which exits with 1, since no words followed its name this time. Step through the run with two words:

Interactive · message by message The C runtime calls _main, and _main calls puts Step through the run of hello_puts with the words one and two. Watch which registers carry what at each call and return.
The C runtime calls _main, and _main calls puts, step 6 of 6. Messages so far: C runtime to _main: w0 = 3; _main to puts: x0 = &line; puts to _main: line written; _main to C runtime: w0 = 3.C runtimeexit(3)_mainw19 = 3putslibSystemw0 = 3x0 = &lineline writtenw0 = 3

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

Every step as text
  1. dyld loads the program. The shell runs hello_puts one two. dyld, the dynamic loader, maps the program and libSystem, then reads the LC_MAIN load command, which holds the offset of _main in the file.
  2. An ordinary call. The start-up code calls _main with argc in w0 and argv in x1: 3, for the program name and two words, and the address of the array of pointers to those three strings. The call leaves the way back in x30.
  3. _main saves what it must. _main stores x29 and x30 as a frame record, because its own bl will overwrite x30, and stores x19, because x19 belongs to its caller. Then it copies argc into w19.
  4. One argument for puts. adrp and add put the address of the string in x0, the first argument register, and bl _puts calls into libSystem, by way of a stub that a later section opens up.
  5. puts writes the line. puts writes the string and a newline, then returns. It may change any of x0 to x17 on the way, but never x19, so argc is still there.
  6. _main returns argc. _main moves w19 into w0, restores x19, x29 and x30 and returns. The start-up code passes 3 to exit, and the shell prints exit=3.

Checkpoint

Check: How the C runtime calls main and links the C library

  1. Question 1 of 2 In the run of hello_puts, which code calls _main?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The start-up code of the C runtime, after dyld has read the LC_MAIN load command

    A program linked with clang starts in the C runtime. On macOS dyld reads LC_MAIN and the start-up code calls _main with argc in w0 and argv in x1; on Linux crt1.o's _start reaches main through __libc_start_main. Either way main is an ordinary function under the calling convention.

  2. Question 2 of 2 ld -o hello_ld hello_puts.o stopped because _puts was undefined. What did clang give ld that the object lacks?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The name of the library that defines puts, -lSystem, and the SDK to look in, -syslibroot

    An object lists the names it needs but not where they live. The compiler driver adds the C runtime to the link: on macOS libSystem and the SDK root, on Linux the C library and its start-up files.

On Apple silicon, printf takes its variadic arguments from the stack

printf 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.: its prototype, int printf(const char *format, ...), names one parameter and leaves the rest open. A C compiler reads that prototype at every call. In assembly nothing reads it for you, and the two AArch64 platforms disagree on where the open arguments go. These programs make the same call, printf("value = %ld\n", 42), and differ in one store:

42 in x1, then 42 in the first stack slot

macos/printf_wrong.s

// printf_wrong.s: printf("value = %ld\n", 42) with 42 in x1, where Linux on AArch64 would pass it.
// Apple arm64 passes a variadic argument in a stack slot, so printf reads the 8 bytes at [sp] instead.
// The program plants 0x7777 (30583) in that slot first, so the wrong value is the same on every run.
        .section __TEXT,__cstring
fmt:    .asciz  "value = %ld\n"

        .text
        .globl  _main
        .p2align 2
_main:
        sub     sp, sp, #32             // 16 bytes for argument slots, 16 for the frame record
        stp     x29, x30, [sp, #16]
        add     x29, sp, #16
        mov     x9, #0x7777
        str     x9, [sp]                // a known value in the first variadic slot
        adrp    x0, fmt@PAGE            // the named argument, the format string, goes in x0
        add     x0, x0, fmt@PAGEOFF
        mov     x1, #42                 // WRONG on Apple arm64: printf does not read x1 for a variadic argument
        bl      _printf
        mov     w0, #0
        ldp     x29, x30, [sp, #16]
        add     sp, sp, #32
        ret

macos/printf_right.s

// printf_right.s: printf("value = %ld\n", 42) the Apple arm64 way. The variadic argument goes in the
// 8-byte stack slot at [sp]; the format string is a named argument, so it still goes in x0.
        .section __TEXT,__cstring
fmt:    .asciz  "value = %ld\n"

        .text
        .globl  _main
        .p2align 2
_main:
        sub     sp, sp, #32             // 16 bytes for argument slots, 16 for the frame record
        stp     x29, x30, [sp, #16]
        add     x29, sp, #16
        mov     x9, #0x7777
        str     x9, [sp]                // the same planted value as in printf_wrong.s ...
        mov     x9, #42
        str     x9, [sp]                // ... overwritten by the variadic argument, in its slot
        adrp    x0, fmt@PAGE            // the named argument, the format string, goes in x0
        add     x0, x0, fmt@PAGEOFF
        bl      _printf
        mov     w0, #0
        ldp     x29, x30, [sp, #16]
        add     sp, sp, #32
        ret

The session runs both, then lets clang -S show where a C compiler puts the same 42 for macOS, for Linux on AArch64 and for Linux on x86-64. Those three lines only compile varcall.c to assembly:

Where the variadic 42 goes, on three targets Python · variadic_session.py
"""Call printf with one variadic argument the wrong way and the right way on Apple arm64, then let clang show
where it puts the same argument for macOS, Linux on AArch64 and Linux on x86-64.

The clang -S lines only compile c/varcall.c to assembly for each target. The sed and grep filters keep the body of
main and drop the assembler's bookkeeping lines (.cfi_*, Lloh labels).
"""

import pathlib
import sys

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

session.run(
    """
# 42 in x1, where Linux on AArch64 would pass it: printf prints the 0x7777 planted at [sp].
clang -o macos/printf_wrong macos/printf_wrong.s
./macos/printf_wrong; echo "exit=$?"
# 42 stored at [sp], the first variadic slot on Apple arm64.
clang -o macos/printf_right macos/printf_right.s
./macos/printf_right; echo "exit=$?"
# What clang emits for printf("value = %ld\\n", 42L) on macOS, Apple arm64:
clang --target=arm64-apple-macos -O2 -S -fno-verbose-asm -fno-asynchronous-unwind-tables -o - c/varcall.c | sed -n '/^_main:/,/ret$/p' | grep -v -E 'cfi|^Lloh'
# ... on Linux, AArch64:
clang --target=aarch64-linux-gnu -O2 -S -fno-verbose-asm -fno-asynchronous-unwind-tables -o - c/varcall.c | sed -n '/^main:/,/ret$/p'
# ... and on Linux, x86-64 (Intel syntax):
clang --target=x86_64-linux-gnu -O2 -S -masm=intel -fno-verbose-asm -fno-asynchronous-unwind-tables -o - c/varcall.c | sed -n '/^main:/,/ret$/p'
"""
)

Output

# 42 in x1, where Linux on AArch64 would pass it: printf prints the 0x7777 planted at [sp].
$ clang -o macos/printf_wrong macos/printf_wrong.s
$ ./macos/printf_wrong; echo "exit=$?"
value = 30583
exit=0
# 42 stored at [sp], the first variadic slot on Apple arm64.
$ clang -o macos/printf_right macos/printf_right.s
$ ./macos/printf_right; echo "exit=$?"
value = 42
exit=0
# What clang emits for printf("value = %ld\n", 42L) on macOS, Apple arm64:
$ clang --target=arm64-apple-macos -O2 -S -fno-verbose-asm -fno-asynchronous-unwind-tables -o - c/varcall.c | sed -n '/^_main:/,/ret$/p' | grep -v -E 'cfi|^Lloh'
_main:
	sub	sp, sp, #32
	stp	x29, x30, [sp, #16]
	add	x29, sp, #16
	mov	w8, #42
	str	x8, [sp]
	adrp	x0, l_.str@PAGE
	add	x0, x0, l_.str@PAGEOFF
	bl	_printf
	mov	w0, #0
	ldp	x29, x30, [sp, #16]
	add	sp, sp, #32
	ret
# ... on Linux, AArch64:
$ clang --target=aarch64-linux-gnu -O2 -S -fno-verbose-asm -fno-asynchronous-unwind-tables -o - c/varcall.c | sed -n '/^main:/,/ret$/p'
main:
	stp	x29, x30, [sp, #-16]!
	adrp	x0, .L.str
	add	x0, x0, :lo12:.L.str
	mov	w1, #42
	mov	x29, sp
	bl	printf
	mov	w0, wzr
	ldp	x29, x30, [sp], #16
	ret
# ... and on Linux, x86-64 (Intel syntax):
$ clang --target=x86_64-linux-gnu -O2 -S -masm=intel -fno-verbose-asm -fno-asynchronous-unwind-tables -o - c/varcall.c | sed -n '/^main:/,/ret$/p'
main:
	push	rax
	lea	rdi, [rip + .L.str]
	mov	esi, 42
	xor	eax, eax
	call	printf@PLT
	xor	eax, eax
	pop	rcx
	ret

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

The first program prints value = 30583, which is 0x7777, the value it planted at [sp] before the call; the 42 in x1 was never read. The second prints value = 42. Apple’s page on arm64 code states the rule: each variadic argument goes into 8-byte stack slots, while named arguments keep their registers, so the format string stays in x0. AAPCS64 as Linux uses it passes the same argument in x1, and the second listing shows exactly that, mov w1, #42. The macOS listing reserves 32 bytes, stores the 42 with str x8, [sp], and keeps sp a multiple of 16 throughout.

x86-64 Linux adds a rule of its own. The System V psABI passes the 42 in rsi, the second argument register, and turns al into a hidden argument of every variadic call: it must be at least the count of vector registers that carry arguments, and at most 8. The listing’s xor eax, eax says none do. The psABI also wants rsp to be a multiple of 16 just before the call, and the stack-alignment lesson shows the crashes that follow when it is not. At the entry of main it sits 8 past one, because the call that reached main pushed a return address, so the listing’s push rax, which saves nothing, puts it back in line. The three targets of the session, side by side:

Target Format string The variadic 42 Also needed at the call
macOS, Apple silicon x0 the 8-byte slot at [sp] sp a multiple of 16
Linux, AArch64 x0 x1 sp a multiple of 16
Linux, x86-64 rdi rsi al an upper bound on vector registers; rsp a multiple of 16

Porting a Linux printf call to macOS register for register

Code written for Linux on AArch64 loads the values for printf into x1, x2 and onwards. On a Mac it still assembles, links and runs, and prints whatever the stack slots hold. Store each variadic argument in its own 8-byte slot from [sp] upwards, reserve that space in 16-byte steps, and leave only the named arguments in registers.

The planted 0x7777 makes the wrong output the same on every run. Without it, the wrong program prints whatever bytes the slot held, and a bug that prints believable numbers is the kind that survives a quick test.

Checkpoint

Check: Where printf's variadic arguments travel

  1. Question 1 of 2 printf_wrong.s printed value = 30583. Where did printf read its variadic argument from?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: From the 8-byte stack slot at [sp], where the program had planted 0x7777

    On Apple silicon the named argument, the format string, stays in x0 and each variadic argument takes 8-byte stack slots from [sp] upwards. The planted value makes the wrong reading visible and repeatable.

  2. Question 2 of 2 Before calling printf, both hello_plt.asm and clang's x86-64 listing run xor eax, eax. What does the psABI read al as in a variadic call?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A count of vector registers carrying arguments, which al may overstate but never understate, at most 8; here none

    x86-64 System V passes the variadic 42 in rsi like a named argument and adds one rule for variadic calls: al bounds the vector registers used. It also wants rsp to be a multiple of 16 just before the call.

malloc and qsort hand pointers back and forth with your code

malloc_sort.s uses four library functions in one _main. It asks malloc for 48 bytes, copies six longs into the block, has qsort order them with cmp_long, a comparator written in C, prints each one with printf and hands the block back with free. null_check.s asks for 2^62 bytes, a request no process on a Mac can be granted:

malloc, qsort with a C comparator, printf and free

macos/malloc_sort.s

// malloc_sort.s: copy six longs into a block from malloc, sort them with qsort and the C comparator
// cmp_long (c/cmp.c), print them with printf, then hand the block back with free. AArch64 macOS.
        .section __TEXT,__cstring
fmt:    .asciz  "%ld\n"
nomem:  .asciz  "malloc returned NULL"

        .section __TEXT,__const
        .p2align 3
vals:   .quad   42, -7, 1000, 0, 19, -300
        .equ    COUNT, 6

        .text
        .globl  _main
        .p2align 2
_main:
        sub     sp, sp, #48
        stp     x19, x20, [sp, #16]     // callee-saved: main gives them back unchanged
        stp     x29, x30, [sp, #32]     // the frame record
        add     x29, sp, #32
        // [sp] to [sp + 15] stays free for printf's variadic argument

        mov     x0, #COUNT * 8          // malloc(48)
        bl      _malloc                 // x0 = the address of the block, or 0 (NULL)
        cbz     x0, failed              // test the pointer before the first use
        mov     x19, x0                 // calls may change x0 to x17: keep the pointer in x19

        adrp    x9, vals@PAGE           // copy the six values into the block
        add     x9, x9, vals@PAGEOFF
        mov     x10, #0
copy:   ldr     x11, [x9, x10, lsl #3]
        str     x11, [x19, x10, lsl #3]
        add     x10, x10, #1
        cmp     x10, #COUNT
        b.lo    copy

        mov     x0, x19                 // qsort(base, count, size, compar)
        mov     x1, #COUNT
        mov     x2, #8
        adrp    x3, _cmp_long@PAGE      // the address of a C function, passed like any other pointer
        add     x3, x3, _cmp_long@PAGEOFF
        bl      _qsort

        mov     x20, #0                 // the index must survive printf too: x20
print:  ldr     x9, [x19, x20, lsl #3]
        str     x9, [sp]                // the variadic argument, in its stack slot
        adrp    x0, fmt@PAGE
        add     x0, x0, fmt@PAGEOFF
        bl      _printf
        add     x20, x20, #1
        cmp     x20, #COUNT
        b.lo    print

        mov     x0, x19                 // free(block)
        bl      _free
        mov     w0, #0
        b       done

failed: adrp    x0, nomem@PAGE
        add     x0, x0, nomem@PAGEOFF
        bl      _puts
        mov     w0, #1

done:   ldp     x29, x30, [sp, #32]
        ldp     x19, x20, [sp, #16]
        add     sp, sp, #48
        ret

c/cmp.c

/* cmp.c: the comparator qsort calls with the addresses of two elements. It returns a negative number,
   zero or a positive number when the first long is less than, equal to or greater than the second. */
int cmp_long(const void *a, const void *b) {
    long x = *(const long *)a;
    long y = *(const long *)b;
    return (x > y) - (x < y);
}

macos/null_check.s

// null_check.s: ask malloc for 2^62 bytes, more than any Mac can give. malloc returns NULL (0), and the
// test straight after the call sends main down its failure path instead of using the pointer.
        .section __TEXT,__cstring
nomem:  .asciz  "malloc returned NULL"

        .text
        .globl  _main
        .p2align 2
_main:
        stp     x29, x30, [sp, #-16]!
        mov     x29, sp
        mov     x0, #1
        lsl     x0, x0, #62             // the size: 2^62 bytes
        bl      _malloc
        cbz     x0, failed              // NULL: never touch the pointer
        bl      _free                   // a block that did arrive goes back to the allocator
        mov     w0, #0
        b       done
failed: adrp    x0, nomem@PAGE
        add     x0, x0, nomem@PAGEOFF
        bl      _puts
        mov     w0, #1                  // a nonzero exit status says the program failed
done:   ldp     x29, x30, [sp], #16
        ret
Sorting a block from malloc with qsort, and a malloc that fails Python · malloc_qsort_session.py
"""Build malloc_sort.s with the C comparator in c/cmp.c and run it, then run null_check.s, whose malloc fails.

nm -u lists the names the program takes from libSystem; nm -g -j -U lists the global names it defines itself.
"""

import pathlib
import sys

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

session.run(
    """
# Assemble main, compile the comparator, link both with the C library, and run.
clang -o macos/malloc_sort macos/malloc_sort.s c/cmp.c
./macos/malloc_sort; echo "exit=$?"
# The five functions the program takes from libSystem, and the names it defines itself.
nm -u macos/malloc_sort
nm -g -j -U macos/malloc_sort
# A request no Mac can meet: malloc returns NULL, and main takes its failure path.
clang -o macos/null_check macos/null_check.s
./macos/null_check; echo "exit=$?"
"""
)

Output

# Assemble main, compile the comparator, link both with the C library, and run.
$ clang -o macos/malloc_sort macos/malloc_sort.s c/cmp.c
$ ./macos/malloc_sort; echo "exit=$?"
-300
-7
0
19
42
1000
exit=0
# The five functions the program takes from libSystem, and the names it defines itself.
$ nm -u macos/malloc_sort
_free
_malloc
_printf
_puts
_qsort
$ nm -g -j -U macos/malloc_sort
__mh_execute_header
_cmp_long
_main
# A request no Mac can meet: malloc returns NULL, and main takes its failure path.
$ clang -o macos/null_check macos/null_check.s
$ ./macos/null_check; echo "exit=$?"
malloc returned NULL
exit=1

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

The six values come back in order, from -300 to 1000. nm -u lists the five names the program takes from libSystem, _free, _malloc, _printf, _puts and _qsort, and nm -g -j -U the ones it defines: _cmp_long, _main and the Mach-O header symbol. null_check prints malloc returned NULL and exits with 1. POSIX says malloc returns a null pointer when the space cannot be allocated, so both programs test x0 with cbz straight after the call, before the pointer is used.

Pointers travel in both directions here. malloc returns one in x0. qsort receives one in x3, the address of cmp_long, loaded with adrp and add like the address of any other symbol, and calls that function back with the addresses of two elements whenever it needs their order. POSIX asks the comparator for a negative number, zero or a positive number, and allows qsort to move elements between calls, so a comparator looks only at the two elements it is handed. Step through the call:

Interactive · message by message qsort calls the comparator whose address it was given Step through the qsort call of malloc_sort.s. Watch the address of cmp_long travel in x3 and come back as calls.
qsort calls the comparator whose address it was given, step 5 of 5. Messages so far: _main to qsort: x3 = &cmp_long; qsort to cmp_long: two addresses; cmp_long to qsort: w0 = 1; qsort to _main: sorted._mainx19 keptqsortlibSystemcmp_longcmp.cx3 = &cmp_longtwo addressesw0 = 1sorted

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

Every step as text
  1. Four arguments. _main puts the address of the block in x0, the count 6 in x1, the element size 8 in x2 and the address of cmp_long in x3, then calls qsort.
  2. qsort calls back. Whenever qsort needs the order of two elements, it calls the function whose address arrived in x3, with the addresses of the two elements in x0 and x1.
  3. The comparator answers. cmp_long loads the two longs and returns -1, 0 or 1 in w0. Given the addresses of 42 and -7 it returns 1: the first belongs after the second.
  4. As often as it needs. qsort calls cmp_long as many times as its algorithm needs, in an order of its own, and moves the 8-byte elements between calls. The comparator only ever answers one question.
  5. Back in _main. qsort returns. The block now holds -300, -7, 0, 19, 42 and 1000, and _main still has its address in x19, ready for the printf loop.

The convention, not habit, picks the registers. The block’s address must survive qsort, printf and free, and the loop index must survive printf, so they live in x19 and x20, which _main saves on entry and restores before ret. The copy loop uses x9 to x11 freely, because nothing is called inside it.

Keeping a loop counter in a caller-saved register across a call

A print loop that counts in x9 and calls printf on every pass assumes printf leaves x9 alone, and AAPCS64 makes no such promise: x9 is a caller-saved register: A register that any call may change. A caller that still needs the value after the call saves it first or keeps it elsewhere. rdi on x86-64 System V, x9 on AArch64 and t0 on RISC-V are caller-saved. More in “Callee-saved registers and stack frames”, so any call may change it, and the loop can stop early, repeat itself or run on. Count in a callee-saved register such as x20, saved in the prologue, as malloc_sort.s does.

Checkpoint

Check: malloc, qsort callbacks and registers that survive calls

  1. Question 1 of 2 null_check.s tests x0 with cbz straight after bl _malloc. Why there?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: malloc returns a null pointer when it cannot allocate, and the pointer must be tested before it is first used

    A pointer from malloc may be NULL. Test it at once and take a failure path, as both programs do, instead of storing through address 0.

  2. Question 2 of 2 What does malloc_sort.s put in x3 before bl _qsort?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The address of cmp_long, loaded with adrp and add

    A callback is just an address passed like any other pointer. qsort calls it with the addresses of two elements and reads the sign of the int it returns.

Every call into libSystem goes through a stub in your own program

The bl _puts in hello_puts.s cannot hold the address of puts, neither when it is assembled nor when it is linked. libSystem is a shared library: A library that is mapped into a process when the program is loaded and can be shared by every program that uses it, such as libc.so.6 on Linux or libSystem on macOS, instead of being copied into each program by the linker., and with dynamic linking: Resolving a program's references to functions and data in shared libraries when the program is loaded or first calls them. The dynamic loader, dyld on macOS and ld-linux on Linux, does it from records the linker left in the file. its place in memory is settled only when the program is loaded. The session follows that call from the object file into the linked program:

From a BR26 relocation to a stub and a __got slot Python · stubs_session.py
"""Follow a call into libSystem from the object file to the linked program: the relocation on bl, the stubs the
linker writes into __TEXT,__stubs, and the __got slots dyld fills in when it loads the program.

grep stub keeps the calls of main whose target is a stub; sed keeps the stubs section of the disassembly and drops
the __text section printed before it.
"""

import pathlib
import sys

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

session.run(
    """
# In the object, bl _puts holds no address yet: a BR26 relocation names _puts.
clang -c -o macos/hello_puts.o macos/hello_puts.s
llvm-objdump --macho -d -r --no-show-raw-insn macos/hello_puts.o
# In the linked program, every bl to a library function goes to a stub in the program itself.
clang -o macos/malloc_sort macos/malloc_sort.s c/cmp.c
llvm-objdump --macho -d --no-show-raw-insn --disassemble-symbols=_main macos/malloc_sort | grep stub
# The five stubs: each loads an address from a slot of __got into x16, then branches to it.
llvm-objdump --macho -d --no-show-raw-insn --section=__TEXT,__stubs macos/malloc_sort | sed -n '/__stubs/,$p'
# The slots, and what dyld binds each one to when it loads the program.
dyld_info -fixups macos/malloc_sort
"""
)

Output

# In the object, bl _puts holds no address yet: a BR26 relocation names _puts.
$ clang -c -o macos/hello_puts.o macos/hello_puts.s
$ llvm-objdump --macho -d -r --no-show-raw-insn macos/hello_puts.o
macos/hello_puts.o:
(__TEXT,__text) section
_main:
       0:	stp	x29, x30, [sp, #-0x20]!
       4:	mov	x29, sp
       8:	str	x19, [sp, #0x10]
       c:	mov	w19, w0
      10:	adrp	x0, line@PAGE
      14:	add	x0, x0, line@PAGEOFF
      18:	bl	_puts
      1c:	mov	w0, w19
      20:	ldr	x19, [sp, #0x10]
      24:	ldp	x29, x30, [sp], #0x20
      28:	ret
Relocation information (__TEXT,__text) 3 entries
address  pcrel length extern type    scattered symbolnum/value
00000018 True  long   True   BR26    False     _puts
00000014 False long   True   PAGOF12 False     line
00000010 True  long   True   PAGE21  False     line
# In the linked program, every bl to a library function goes to a stub in the program itself.
$ clang -o macos/malloc_sort macos/malloc_sort.s c/cmp.c
$ llvm-objdump --macho -d --no-show-raw-insn --disassemble-symbols=_main macos/malloc_sort | grep stub
1000004c4:	bl	0x1000005b8 ; symbol stub for: _malloc
100000504:	bl	0x1000005dc ; symbol stub for: _qsort
10000051c:	bl	0x1000005c4 ; symbol stub for: _printf
100000530:	bl	0x1000005ac ; symbol stub for: _free
100000544:	bl	0x1000005d0 ; symbol stub for: _puts
# The five stubs: each loads an address from a slot of __got into x16, then branches to it.
$ llvm-objdump --macho -d --no-show-raw-insn --section=__TEXT,__stubs macos/malloc_sort | sed -n '/__stubs/,$p'
Contents of (__TEXT,__stubs) section
1000005ac:	adrp	x16, 4 ; 0x100004000
1000005b0:	ldr	x16, [x16] ; literal pool symbol address: _free
1000005b4:	br	x16
1000005b8:	adrp	x16, 4 ; 0x100004000
1000005bc:	ldr	x16, [x16, #0x8] ; literal pool symbol address: _malloc
1000005c0:	br	x16
1000005c4:	adrp	x16, 4 ; 0x100004000
1000005c8:	ldr	x16, [x16, #0x10] ; literal pool symbol address: _printf
1000005cc:	br	x16
1000005d0:	adrp	x16, 4 ; 0x100004000
1000005d4:	ldr	x16, [x16, #0x18] ; literal pool symbol address: _puts
1000005d8:	br	x16
1000005dc:	adrp	x16, 4 ; 0x100004000
1000005e0:	ldr	x16, [x16, #0x20] ; literal pool symbol address: _qsort
1000005e4:	br	x16
# The slots, and what dyld binds each one to when it loads the program.
$ dyld_info -fixups macos/malloc_sort
macos/malloc_sort [arm64]:
    -fixups:
        segment         section          address             type   target
        __DATA_CONST    __got            0x100004000           bind  libSystem/_free
        __DATA_CONST    __got            0x100004008           bind  libSystem/_malloc
        __DATA_CONST    __got            0x100004010           bind  libSystem/_printf
        __DATA_CONST    __got            0x100004018           bind  libSystem/_puts
        __DATA_CONST    __got            0x100004020           bind  libSystem/_qsort

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

In the object, bl _puts at offset 0x18 carries a BR26 relocation: An entry beside a section's bytes that says which bytes the linker must patch, with which symbol's address, under which formula. Needed whenever the assembler cannot know a final address yet. More in “From source to executable: objects and linking” that names _puts: a note to the linker that this 26-bit branch still needs a target. In the linked malloc_sort, every call to a library function has a target inside the program itself: bl 0x1000005b8 is marked symbol stub for: _malloc, and the five stubs sit 12 bytes apart in __TEXT,__stubs. Each symbol stub: A few instructions the Mach-O linker writes into the __TEXT,__stubs section for each imported function. On arm64 a stub loads the function's address from its __got slot into x16 and branches there. is three instructions: adrp x16 to the page of the __got section, ldr x16 from one 8-byte slot there, and br x16. dyld_info -fixups shows what fills the slots: five bind entries in __DATA_CONST,__got, from 0x100004000 to 0x100004020, each naming a function in libSystem, which dyld writes when it loads the program, before _main runs.

Those slots form the program’s global offset table: The GOT: a table of addresses in a program's data that the dynamic loader fills in, so that position-independent code can reach functions and data whose addresses are known only at run time. Mach-O programs keep such slots in a __got section., the Mach-O side of an idea ELF shares, as the next section shows. Step through the first call of malloc_sort:

Interactive · step by step The call to malloc, through its stub and its __got slot Step through the first library call of malloc_sort. The left column is code in the program and in libSystem; the right column is the slot and dyld, which fills it.
The call to malloc, through its stub and its __got slot, step 6 of 6. Showing _main, Stub for _malloc (x16 = &malloc), malloc, dyld, __got slot (holds &malloc). In focus: Stub for _malloc.blbindldrbr x16_mainbl 0x1000005b8Stub for _mallocx16 = &mallocmallocin libSystemdyldthe dynamic loader__got slotholds &malloc

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

Every step as text
  1. dyld fills the slot. Before _main runs, dyld reads the fixups of malloc_sort and binds the 8-byte slot at 0x100004008 to _malloc in libSystem: it writes the address of malloc there.
  2. The call goes to the stub. bl 0x1000005b8 in _main does not reach libSystem. It reaches a stub of three instructions that the linker wrote into the program, in its own __stubs section.
  3. adrp finds the page. adrp x16, 4 takes the 4 KB page that holds the stub, 0x100000000, and adds four pages: x16 = 0x100004000, the page where the __got slots begin.
  4. ldr reads the slot. ldr x16, [x16, #0x8] loads the 8 bytes at 0x100004008, the address dyld wrote there. x16 now holds the address of malloc.
  5. br jumps into malloc. br x16 jumps to malloc without touching x30, so x30 still holds the return address bl left: malloc returns straight to _main, after the bl.
  6. Why the stub may use x16. AAPCS64 lets code the linker adds, such as stubs and veneers, use x16 and x17 between a call and the function it reaches. So the stub saves nothing, and no value of yours may wait in x16 or x17 across a call.

The adrp arithmetic is plain page maths. The stub at 0x1000005b8 lies in the 4 KB page 0x100000000, and adrp x16, 4 adds four pages, giving 0x100004000; the programmer calculator confirms it with 0x1000005b8 AND NOT 0xFFF. Using x16 without saving it is allowed: AAPCS64 lets a veneer: A short sequence of instructions that a linker inserts so that a branch can reach a target beyond its range. On AArch64 a veneer may change the flags and the registers x16 and x17. More in “Jumps and conditional branches” or PLT code, which a linker inserts between a call and its target, change x16, x17 and the condition flags, and nothing else. A stub therefore needs no frame, and your own code never keeps a value in x16 or x17 across a bl that might pass through one.

Checkpoint

Check: Mach-O stubs and their __got slots

  1. Question 1 of 2 In the linked malloc_sort, where does bl 0x1000005b8 land first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: On a three-instruction stub for _malloc in the program's own __TEXT,__stubs section

    Every call to a library function goes to a stub of adrp, ldr and br x16 in the program. The slot it reads is filled by dyld before _main runs.

  2. Question 2 of 2 A routine keeps a loop count in x16 and calls puts on every pass. Why is that wrong even if the count survived in a test?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: AAPCS64 lets a veneer or PLT code, such as the stub on the way to puts, change x16 and x17 between a call and its target

    x16 and x17 belong to code the linker inserts between a call and its target. A value kept there across a call can vanish without any visible change to your own code, so keep it in x19 to x28.

On Linux, a PLT entry jumps through a GOT slot the loader fills

ELF programs reach shared libraries in the same way, under other names. hello_plt.asm is the x86-64 Linux form of the printf call, in NASM. extern printf declares the name, call printf wrt ..plt asks for a call through the procedure linkage table: The PLT: small entries an ELF linker adds for calls into shared libraries. Each entry jumps through a slot of the global offset table, which the dynamic linker fills with the function's address at start-up or at the first call., the PLT, and default rel makes [fmt] relative to rip, which a position-independent executable: An executable whose code reaches other code and data through PC-relative addressing instead of fixed addresses, so it runs correctly at any load address. This is what lets the loader randomise its placement under ASLR. More in “Memory and a process's address space” needs. It keeps both of the psABI’s rules for a variadic call from the table above. libc_names.asm stands in for the C library while linking:

A printf call through the PLT, and a stand-in libc.so.6

linux/hello_plt.asm

; hello_plt.asm: main calls printf("value = %ld\n", 42) the x86-64 System V way (NASM, ELF64, Linux).
default rel                             ; [fmt] is RIP-relative, as a position-independent executable needs
global main
extern printf                           ; defined in a shared library: the C library, libc.so.6

section .rodata
fmt:    db "value = %ld", 10, 0

section .text
main:
        sub     rsp, 8                  ; at entry rsp is 8 past a multiple of 16; now it is a multiple of 16
        lea     rdi, [fmt]              ; 1st argument: the format string
        mov     esi, 42                 ; 2nd argument, the variadic long, in rsi (writing esi clears the top half)
        xor     eax, eax                ; al = 0: no vector registers carry arguments
        call    printf wrt ..plt        ; through printf's entry in the procedure linkage table
        xor     eax, eax                ; return 0
        add     rsp, 8
        ret

section .note.GNU-stack noalloc noexec nowrite progbits

linux/libc_names.asm

; libc_names.asm: a stand-in for the C library at link time. To link against a shared library, the linker
; reads which names it defines and the name it is loaded by; this one defines printf as a single ret and is
; linked with the name libc.so.6. On a Linux machine the dynamic loader maps the real libc.so.6 instead.
global printf:function
section .text
printf:
        ret

section .note.GNU-stack noalloc noexec nowrite progbits
Reading printf@plt, its GOT slot and its relocation Python · elf_plt_session.py
"""Assemble and link hello_plt.asm for x86-64 Linux, then read the PLT entry, its GOT slot and the relocation that
tells the dynamic loader which function belongs in the slot.

Linking against a shared library needs one that defines printf: libc_names.asm builds a stand-in named libc.so.6
(only its names matter to the linker). The link names no start-up files, so ld.lld warns that _start is missing:
a compiler driver on Linux adds the C runtime's own, whose _start calls main. The session reads the linked file
back with llvm-readelf and llvm-objdump.
"""

import pathlib
import sys

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

session.run(
    """
# The object: the call to printf is an R_X86_64_PLT32 relocation, the format string a PC32 one.
nasm -f elf64 -o linux/hello_plt.o linux/hello_plt.asm
llvm-readelf -r linux/hello_plt.o
# The stand-in C library, then a position-independent executable that needs it.
nasm -f elf64 -o linux/libc_names.o linux/libc_names.asm
ld.lld -shared -soname libc.so.6 -o linux/libc.so.6 linux/libc_names.o
ld.lld -pie --dynamic-linker /lib64/ld-linux-x86-64.so.2 -o linux/hello_plt linux/hello_plt.o linux/libc.so.6
llvm-readelf -d linux/hello_plt | grep NEEDED
# main calls printf@plt, an entry that jumps through a slot of the GOT.
llvm-objdump -d -M intel --no-show-raw-insn --disassemble-symbols=main,printf@plt linux/hello_plt
# The relocation the dynamic loader applies to that slot, and the slot's first contents.
llvm-readelf -r linux/hello_plt
llvm-readelf -x .got.plt linux/hello_plt
"""
)

Output

# The object: the call to printf is an R_X86_64_PLT32 relocation, the format string a PC32 one.
$ nasm -f elf64 -o linux/hello_plt.o linux/hello_plt.asm
$ llvm-readelf -r linux/hello_plt.o

Relocation section '.rela.text' at offset 0x3b0 contains 2 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000000000007  0000000200000002 R_X86_64_PC32          0000000000000000 .rodata - 4
0000000000000013  0000000600000004 R_X86_64_PLT32         0000000000000000 printf - 4
# The stand-in C library, then a position-independent executable that needs it.
$ nasm -f elf64 -o linux/libc_names.o linux/libc_names.asm
$ ld.lld -shared -soname libc.so.6 -o linux/libc.so.6 linux/libc_names.o
$ ld.lld -pie --dynamic-linker /lib64/ld-linux-x86-64.so.2 -o linux/hello_plt linux/hello_plt.o linux/libc.so.6
ld.lld: warning: cannot find entry symbol _start; not setting start address
$ llvm-readelf -d linux/hello_plt | grep NEEDED
  0x0000000000000001 (NEEDED)   Shared library: [libc.so.6]
# main calls printf@plt, an entry that jumps through a slot of the GOT.
$ llvm-objdump -d -M intel --no-show-raw-insn --disassemble-symbols=main,printf@plt linux/hello_plt

linux/hello_plt:	file format elf64-x86-64

Disassembly of section .text:

0000000000001300 <main>:
    1300:      	sub	rsp, 0x8
    1304:      	lea	rdi, [rip - 0x1023]     # 0x2e8 <fmt>
    130b:      	mov	esi, 0x2a
    1310:      	xor	eax, eax
    1312:      	call	0x1330 <printf@plt>
    1317:      	xor	eax, eax
    1319:      	add	rsp, 0x8
    131d:      	ret

Disassembly of section .plt:

0000000000001330 <printf@plt>:
    1330:      	jmp	qword ptr [rip + 0x2102] # 0x3438 <printf+0x3438>
    1336:      	push	0x0
    133b:      	jmp	0x1320 <.plt>
# The relocation the dynamic loader applies to that slot, and the slot's first contents.
$ llvm-readelf -r linux/hello_plt

Relocation section '.rela.plt' at offset 0x2d0 contains 1 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name + Addend
0000000000003438  0000000100000007 R_X86_64_JUMP_SLOT     0000000000000000 printf + 0
$ llvm-readelf -x .got.plt linux/hello_plt

Hex dump of section '.got.plt':
0x00003420 40230000 00000000 00000000 00000000 @#..............
0x00003430 00000000 00000000 36130000 00000000 ........6.......

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

The object’s call is an R_X86_64_PLT32 relocation against printf. Linked, the program lists libc.so.6 as NEEDED, the gABI’s entry for a library the dynamic loader must map, and main calls 0x1330 <printf@plt>. That entry is three instructions: jmp qword ptr [rip + 0x2102] through the GOT slot at 0x3438, push 0x0, and a jmp to the shared code at the start of .plt. .rela.plt holds one R_X86_64_JUMP_SLOT relocation for that slot, and the hex dump shows what the file stores in the slot: 0x1336, the address of the push, not of printf. The warning about _start is the C runtime again: this link names no start-up files, which gcc or clang add on Linux.

Those bytes are the psABI’s lazy scheme. The first call jumps through the slot onto the push, which names relocation 0, and the shared code hands control to the dynamic linker. It looks printf up, writes its address into the slot and jumps there; every later call goes straight through. When LD_BIND_NOW is set, the psABI says the dynamic linker fills the slots before the program starts instead. Step through the first call and a later one:

Interactive · step by step The first call to printf, through the PLT and the GOT Step through the first and later calls of main in hello_plt, with the addresses of the read-back above. The left column is code; the right column is the slot and the dynamic linker.
The first call to printf, through the PLT and the GOT, step 7 of 7. Showing main, printf@plt, .plt header, GOT slot (holds &printf), Dynamic linker, printf. In focus: printf@plt, printf.calljmp viapush 0, jmpjmpwritesjmpmaincall 0x1330printf@pltat 0x1330.plt headerat 0x1320GOT slotholds &printfDynamic linkerld-linux-x86-64.so.2printfin libc.so.6

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

Every step as text
  1. The call goes to the PLT. call 0x1330 in main goes to printf@plt, an entry the linker wrote into the .plt section of the program itself. The object asked for it with R_X86_64_PLT32.
  2. A jump through the slot. The entry starts with jmp qword ptr [rip + 0x2102]: a jump to whatever address is stored in the GOT slot at 0x3438.
  3. First call, back to the push. The file stores 0x1336 in that slot, the address of the push 0x0 right after the jmp. So the first call lands on the next instruction of the entry.
  4. push 0 names the relocation. push 0x0 pushes the index of printf's R_X86_64_JUMP_SLOT relocation in .rela.plt, and jmp 0x1320 goes to the code at the start of .plt that every entry shares.
  5. The dynamic linker binds. That code jumps into the dynamic linker. It reads relocation 0, finds printf in libc.so.6 and writes its address into the slot at 0x3438. The psABI says it evaluates PLT entries lazily like this, at their first call.
  6. On to printf. The dynamic linker then jumps to printf. When printf returns, it returns to main, to the instruction after the call, because the return address that call pushed is still on the stack.
  7. Every later call. From now on the jmp through the slot goes straight to printf, and the push is never reached again. With LD_BIND_NOW set, the psABI says the slots are filled before the program starts.

GNU as writes the same request as call printf@PLT. On AArch64 Linux a plain bl printf is enough: AAELF64 makes the linker generate a PLT entry for an imported function that a bl names. Position-independent code reaches data in a shared library through the GOT as well: the psABI gives every symbol whose absolute address a program needs a GOT entry, which the dynamic linker fills before any of the program’s code runs. The relocation types themselves, what a PIE changes and how a static program starts instead belong to the linking lesson.

Checkpoint

Check: ELF PLT entries and their GOT slots

  1. Question 1 of 2 On the very first call to printf@plt, where does jmp qword ptr [rip + 0x2102] go?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: To 0x1336, the push 0x0 right after it, because that is what the file stores in the slot at 0x3438

    The slot first points back into its own PLT entry. The push names the JUMP_SLOT relocation, and the shared code at the start of .plt hands control to the dynamic linker, which finds printf and writes the slot.

  2. Question 2 of 2 After the dynamic linker has handled the first call, what does a second call to printf@plt do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Its jmp goes through the slot straight to printf, and the push is never reached again

    The entry's code stays the same and only the slot changes. That lets the program's text stay read-only and shareable while the addresses live in the data the dynamic linker writes.

Practise

Three tasks on copies of this lesson’s files. Build and run each one with the commands of its session above.

  1. Copy printf_right.s and print two values with one call, the format "%ld and %ld\n" with 7 and 42. Store 7 at [sp] and 42 at [sp, #8]: the 32 bytes the program reserves already hold both slots. Predict what it prints when the two stores swap places, then try it.
  2. Copy malloc_sort.s and sort ten longs: ask malloc for 80 bytes, list ten values and set COUNT to 10. Then change a copy of cmp_long so that the program prints the values in descending order, with no change to the assembly.
  3. Copy hello_plt.asm and libc_names.asm, then call puts after printf: extern puts in the program, and a second global with its own ret in the stand-in library. Before you rebuild, predict how many R_X86_64_JUMP_SLOT relocations .rela.plt will list and which number puts@plt will push.

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.

  1. Question 1 of 15 hello_puts returns argc as its exit status. What status does ./macos/hello_puts a b c d give?

    Type a number.

    A hint

    argc counts the program's own name as well as the words after it.

    Show the answer: Question 1 of 15

    Answer: 5

    The program's name and four words make argc 5. The start-up code passed argc in w0, _main kept it in w19 while puts ran and returned it, and the runtime handed it to exit.

  2. Question 2 of 15 Why does hello_puts keep argc in w19 while puts runs, instead of in w9?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: x19 is callee-saved, so puts must give it back unchanged, while any call may change x9

    A value that must outlive a call goes in a callee-saved register, and the function that uses that register saves the caller's value first: hello_puts stores x19 at [sp, #16] and loads it back before ret.

  3. Question 3 of 15 A colleague's assembly main ends with ret and no exit call, and the program exits with the right status on macOS. Will the same design work on Linux when gcc links it?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: Yes, because the C runtime there also calls main as a function and passes its result to exit

    With a compiler driver linking the C runtime, main is an ordinary function on both systems. Only a program that brings its own _start, without the runtime, must end with an exit system call.

  4. Question 4 of 15 On Linux on AArch64, where does printf("%ld\n", 42L) put the 42?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: In x1, the next argument register after the format string

    Two AArch64 platforms, two rules: Linux passes the variadic 42 in x1, Apple silicon in the stack slot at [sp]. The format string is named, so it is in x0 on both.

  5. Question 5 of 15 On Apple silicon, a call printf("%ld %ld\n", a, b) stores a at [sp]. At what offset from sp does b go?

    Type a number.

    A hint

    Each variadic long takes one 8-byte stack slot, in order.

    Show the answer: Question 5 of 15

    Answer: 8 bytes

    The variadic arguments fill 8-byte slots from [sp] upwards, so b goes at [sp, #8]. 16 bytes of slots keep sp a multiple of 16; a third argument would need 32.

  6. Question 6 of 15 An x86-64 Linux main loads rdi and esi and zeroes eax, then calls printf with no sub rsp, 8 before it. Which rule of the psABI does the call break?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: rsp must be a multiple of 16 just before the call, and at the entry of main it is 8 past one

    At function entry rsp + 8 is a multiple of 16, because of the pushed return address. A function that calls another must restore the alignment, as hello_plt.asm does with sub rsp, 8 and clang's listing with push rax.

  7. Question 7 of 15 Why does malloc_sort.s move the block's address from x0 into x19 straight after the NULL test?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: Every later call uses x0 for its first argument and result, and x19 survives qsort, printf and free

    Keep what must outlive a call in x19 to x28, saved in the prologue. malloc_sort.s keeps the block in x19 and the print index in x20 for exactly that reason.

  8. Question 8 of 15 What must a qsort comparator return when the first element it is handed belongs after the second?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: A positive integer

    cmp_long returns (x > y) - (x < y): 1, 0 or -1. qsort reads the sign of the result and decides the order itself, calling the comparator as often as its algorithm needs.

  9. Question 9 of 15 A print loop counts in x9 and calls printf on every pass. It printed correctly in one test. What is the right verdict?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: It relies on printf leaving x9 alone, which AAPCS64 never promises; keep the count in a callee-saved register saved in the prologue

    Choose registers by what the convention promises. malloc_sort.s counts in x20, saved with x19 on entry, so the count survives every printf call.

  10. Question 10 of 15 What writes the address of malloc into the __got slot at 0x100004008 of malloc_sort?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: dyld, when it loads the program, from the bind fixup that names libSystem's _malloc

    The linker writes the stubs and the empty slots and records a fixup for each slot. dyld applies the fixups at load time, so every stub finds its function's address in place.

  11. Question 11 of 15 In the __TEXT,__stubs section of malloc_sort, how many bytes apart are two consecutive stubs?

    Type a number.

    A hint

    Count the instructions of one stub; every AArch64 instruction is 4 bytes.

    Show the answer: Question 11 of 15

    Answer: 12 bytes

    Each stub is adrp, ldr and br, three 4-byte instructions, so the stubs start at 0x1000005ac, 0x1000005b8, 0x1000005c4 and so on, 12 bytes apart.

  12. Question 12 of 15 In hello_puts.o, what does the BR26 relocation at offset 0x18 tell the linker?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: That the bl there still needs its 26-bit target, for the symbol _puts

    A relocation is a note beside the bytes. For a call into a shared library the Mach-O linker resolves BR26 to the address of a stub it writes, not to the function itself.

  13. Question 13 of 15 Which entry in the linked hello_plt tells the dynamic linker which function belongs in the GOT slot at 0x3438?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: The R_X86_64_JUMP_SLOT relocation for printf in .rela.plt

    In the object, PLT32 asks the linker for a PLT entry. In the linked program, JUMP_SLOT asks the dynamic linker to put printf's address into that entry's slot.

  14. Question 14 of 15 What changes for hello_plt on Linux when LD_BIND_NOW is set in its environment?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: The dynamic linker fills the GOT slots before the program starts, so even the first call goes straight to printf

    Lazy binding fills a slot at the first call; with LD_BIND_NOW the dynamic linker fills every slot at start-up. The PLT entry's code is the same in both cases.

  15. Question 15 of 15 The same printf call written for AArch64 Linux uses a plain bl printf, with nothing like wrt ..plt. Where does the PLT entry come from?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: The linker generates it, because AAELF64 requires a PLT entry for an imported function that a bl names

    NASM spells out wrt ..plt and GNU as writes printf@PLT on x86-64, but on AArch64 the linker adds the PLT entry for any imported function a bl names. The mechanism, a jump through a GOT slot, is the same.

Interview questions

Your assembly main calls puts. Why does linking the object with ld alone fail, while clang links it?

Because the object only names puts; it does not say which library defines it. ld resolves every undefined symbol against the files and libraries on its command line, and an assembly object brings none, so it stops with an undefined-symbol error for _puts. clang is a compiler driver: when it links, it runs ld with the C runtime added, which on macOS means -lSystem, the SDK root and -dynamic, as clang -### prints, and on Linux the start-up files and -lc. The linker then finds puts in the C library, gives the call a stub and a slot, and records that the program needs the library at run time. The runtime also brings the start-up code that calls main and passes its result to exit, which is why main can be an ordinary function that returns.

Two questions go further than the warm-up: one about a printf call ported from Linux that prints wrong numbers on a Mac, and one about the first call through a PLT entry, instruction by instruction:

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

  1. Mid to senior

    An AArch64 routine written for Linux calls printf with a format string and two longs, and its output is right there. Built unchanged on a Mac, it prints two numbers that are wrong but look believable. What is wrong, how do you fix it, and how do you test the fix?

    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 staff

    Walk through what happens, instruction by instruction, the first time an x86-64 Linux program calls printf through its PLT entry, and say what is different on the second call.

    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. Your assembly main calls puts. Why does linking the object with ld alone fail while clang links it?

    The object only names puts; ld resolves names against the files and libraries on its command line, and the assembly brings none. clang is a driver: it runs ld with the C runtime added, -lSystem on macOS, or the start-up files and -lc on Linux.

  2. When clang links an assembly object, who calls _main, and what happens to its result?

    The C runtime calls _main as an ordinary C function and hands its return value to exit, so main may simply return.

  3. What must assembly do to call a C function on AArch64?

    Put the arguments in x0 to x7 as the prototype orders them, keep sp a multiple of 16 at the call, and hold every value that must survive the call in a callee-saved register from x19 to x28.

  4. Where do printf's variadic arguments go on Apple arm64, on AArch64 Linux and on x86-64 Linux?

    On Apple arm64 each one in its own 8-byte stack slot. On AArch64 Linux in the next argument register. On x86-64 Linux in the next register too, with al saying how many vector registers carry arguments.

  5. What must assembly do with the pointers that malloc and qsort exchange?

    Test malloc's result for NULL before the first use. For qsort, pass the comparator's address in x3; qsort calls it back with the addresses of two elements to compare.

  6. How does a call from your program reach a function in a shared library?

    Through code in your own program. On macOS a stub loads the function's address from a __got slot that dyld binds at load time. On Linux a PLT entry makes an indirect jump via its GOT slot, which the dynamic linker fills at the first call, or at start-up when LD_BIND_NOW is set.

Key takeaways

  • When clang links an assembly object, the C runtime calls _main as an ordinary C function and passes its result to exit, and ld alone fails because only the driver names libSystem, or on Linux the C library and its start-up files.
  • A call to a C function puts each argument where the platform’s convention says, x0 to x7 on AArch64, with sp a multiple of 16 and every value that must survive the call kept in a callee-saved register from x19 to x28.
  • Variadic arguments follow per-platform rules: Apple arm64 puts each one in an 8-byte stack slot, AArch64 Linux in the next register, and x86-64 Linux in the next register with al bounding the vector registers used.
  • malloc can return NULL, so the pointer is tested before its first use, and qsort takes a comparator’s address in x3 and calls it back with the addresses of two elements.
  • Every call into a shared library goes through code in your own program: a Mach-O stub that loads a __got slot dyld binds at load time, or an ELF PLT entry whose GOT slot the dynamic linker fills at the first call, or at start-up when LD_BIND_NOW is set.

Terms in this lesson

C runtime
The start-up code and C library that a compiler driver links into a program. It prepares the process, calls main with argc and argv as an ordinary function and passes main's result to exit; on macOS dyld and libSystem provide it, on Linux crt1.o and libc.
dynamic linking
Resolving a program's references to functions and data in shared libraries when the program is loaded or first calls them. The dynamic loader, dyld on macOS and ld-linux on Linux, does it from records the linker left in the file.
global offset table
The GOT: a table of addresses in a program's data that the dynamic loader fills in, so that position-independent code can reach functions and data whose addresses are known only at run time. Mach-O programs keep such slots in a __got section.
procedure linkage table
The PLT: small entries an ELF linker adds for calls into shared libraries. Each entry jumps through a slot of the global offset table, which the dynamic linker fills with the function's address at start-up or at the first call.
shared library
A library that is mapped into a process when the program is loaded and can be shared by every program that uses it, such as libc.so.6 on Linux or libSystem on macOS, instead of being copied into each program by the linker.
symbol stub
A few instructions the Mach-O linker writes into the __TEXT,__stubs section for each imported function. On arm64 a stub loads the function's address from its __got slot into x16 and branches there.
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.

References

Related tools

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.