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.
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.
The C runtime calls your main, and clang links it in
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 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
x0tox7, and the result comes back inx0. - A call may change
x0tox17andx30, whilex19tox28are 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. spis a multiple of 16 at every call, and a function that calls another first storesx29andx30as 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 ownbloverwritesx30.
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:
"""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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- _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.
- 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.
- 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.
- _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
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:
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:
"""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
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:
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 """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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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
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:
"""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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
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 """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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
Practise
Three tasks on copies of this lesson’s files. Build and run each one with the commands of its session above.
- Copy
printf_right.sand 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. - Copy
malloc_sort.sand sort ten longs: askmallocfor 80 bytes, list ten values and setCOUNTto 10. Then change a copy ofcmp_longso that the program prints the values in descending order, with no change to the assembly. - Copy
hello_plt.asmandlibc_names.asm, then callputsafterprintf:extern putsin the program, and a secondglobalwith its ownretin the stand-in library. Before you rebuild, predict how manyR_X86_64_JUMP_SLOTrelocations.rela.pltwill list and which numberputs@pltwill 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.
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.
-
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?
-
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.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
clanglinks an assembly object, the C runtime calls_mainas an ordinary C function and passes its result toexit, andldalone 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,
x0tox7on AArch64, withspa multiple of 16 and every value that must survive the call kept in a callee-saved register fromx19tox28. - 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
albounding the vector registers used. malloccan return NULL, so the pointer is tested before its first use, andqsorttakes a comparator’s address inx3and 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
__gotslotdyldbinds at load time, or an ELF PLT entry whose GOT slot the dynamic linker fills at the first call, or at start-up whenLD_BIND_NOWis 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
- Writing ARM64 code for Apple platforms (Apple Inc.)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64): register roles, IP0 and IP1, the stack (Arm Limited)
- ELF for the Arm 64-bit Architecture (AAELF64): PLT sequences and usage models (Arm Limited)
- System V Application Binary Interface, AMD64 Architecture Processor Supplement: variadic calls, the stack at a call, the GOT and the PLT (x86-64 psABI maintainers)
- System V Application Binary Interface, chapter 5: the dynamic section (DT_NEEDED) (SCO Group (gABI mirror))
- Mach-O loader.h: LC_MAIN and entry_point_command (Apple Open Source)
- qsort, in POSIX.1-2024 (The Open Group Base Specifications) (The Open Group)
- malloc, in POSIX.1-2024 (The Open Group Base Specifications) (The Open Group)
- NASM manual, chapter 9: output formats (ELF special symbols and WRT) (The NASM Development Team)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress