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 7 – Functions, the stack and calling conventions

Calls, returns and the link register

How call and ret, bl and ret, and jal and jalr keep the return address on x86-64, AArch64 and RISC-V, when a function must save it, and how far calls reach.

  • 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

  • Call and return on each ISA with call and ret, bl and ret, and jal and jalr
  • Save the link register in a function before it calls another
  • Distinguish leaf, non-leaf and tail-calling functions in compiler output
  • Explain how far a call reaches and what the linker does with a target beyond it

Before you start

What you’ll learn · Before you start

What you will learn

  • Call and return on each ISA with call and ret, bl and ret, and jal and jalr
  • Save the link register in a function before it calls another
  • Distinguish leaf, non-leaf and tail-calling functions in compiler output
  • Explain how far a call reaches and what the linker does with a target beyond it

Before you start

On this page

greet_broken calls puts twice in seven AArch64 instructions, prints both of its lines, and then never comes back: the test driver’s alarm has to end the program after 1 second, with exit status 3. The working version stores x30, the register that holds the address it has to return to, before its first call, and loads it back before it returns.

A call does two jobs. It records where the program must come back to, and then it jumps. x86-64 records that address on the stack, AArch64 and RISC-V in a register, and that difference decides which functions must save something before they call. You will follow the return address through a call on each ISA, save it where it has to be saved, pick out leaf, non-leaf and tail calls in compiler output, and see what the linker does with a call whose target lies too far away.

A call writes down where to come back, then jumps

The leaf here below is two instructions long, and it returns the one thing it knows that its caller does not: the address it is about to return to. gap_bl notes the address of its own bl, calls here, and subtracts. The difference says how far past the call the return address points.

On AArch64, bl writes the address of the instruction after itself into x30, the link register: The register that holds the return address a call left behind: x30 on AArch64 and ra (x1) on RISC-V. A leaf function can return through it without using the stack; x86-64 instead keeps the return address on the stack. More in “Registers on x86-64, AArch64 and RISC-V”, and then branches. That address is the return address: The address a call records so that the callee can come back: the address of the instruction right after the call. x86-64's call pushes it onto the stack; AArch64's bl and blr and RISC-V's jal and call write it to x30 or ra., and ret branches to whatever x30 holds. Arm’s instruction set reference puts it in two lines: BL branches and sets X30 to PC+4, and RET branches to a register that defaults to X30. blr sets X30 the same way but takes its target from a register, which is the shape of a call through a function pointer. gap_bl calls here itself, so it parks its own return address on the stack first, for the reason the next section shows.

here and the gap functions on three ISAs

AArch64 · gap_aarch64.s

// Where does a call leave its return address, and what is it? Each gap function notes the address of
// its own call, calls the leaf here, and subtracts: the answer is how far past the call the return goes.
    .text
    .p2align 2
_here:                          // long here(void): a leaf, it calls nothing
    mov x0, x30                 // bl and blr left the return address in x30, the link register
    ret                         // branch to the address in x30

    .globl _gap_bl
    .p2align 2
_gap_bl:                        // long gap_bl(void)
    str x30, [sp, #-16]!        // park our own return address: the bl below overwrites x30
    adr x1, Lcall_bl            // x1 = the address of the bl
Lcall_bl:
    bl _here                    // x30 = the address of the next instruction, then jump
    sub x0, x0, x1              // here's answer minus the address of the bl
    ldr x30, [sp], #16          // take our return address back
    ret

    .globl _gap_blr
    .p2align 2
_gap_blr:                       // long gap_blr(void): the same call through a register
    str x30, [sp, #-16]!
    adr x8, _here               // x8 = a pointer to here
    adr x1, Lcall_blr
Lcall_blr:
    blr x8                      // call the address in x8: x30 = the next instruction
    sub x0, x0, x1
    ldr x30, [sp], #16
    ret

x86-64 · gap_x86_64.s

# The same two gap functions for x86-64 Linux (System V), in Intel operand order. call pushes the
# return address on the stack, so a caller's own return address is never overwritten by a call it makes.
.intel_syntax noprefix
.text
here:                           # long here(void): a leaf
    mov rax, [rsp]              # the call pushed the return address: it is the top word of the stack
    ret                         # pop that word into rip

.globl gap_call
gap_call:                       # long gap_call(void)
    lea rcx, [rip + .Lcall]     # rcx = the address of the call
.Lcall:
    call here                   # push the address of the next instruction, then jump
    sub rax, rcx                # here's answer minus the address of the call
    ret

.globl gap_call_reg
gap_call_reg:                   # long gap_call_reg(void): the same call through a register
    lea rdx, [rip + here]       # rdx = a pointer to here
    lea rcx, [rip + .Lcall_reg]
.Lcall_reg:
    call rdx                    # call the address in rdx
    sub rax, rcx
    ret

.section .note.GNU-stack,"",@progbits

RISC-V · gap_riscv64.s

# The same gap functions for 64-bit RISC-V Linux (LP64D). jal, jalr and call write the return address
# to ra (x1), as bl does to x30, so a function that calls must park its own ra first.
.text
here:                           # long here(void): a leaf
    mv a0, ra                   # the call left the return address in ra
    ret                         # jalr zero, 0(ra): jump to ra and keep no new link

.globl gap_jal
gap_jal:                        # long gap_jal(void)
    addi sp, sp, -16
    sd ra, 8(sp)                # park our own return address
    lla a1, .Lcall_jal          # a1 = the address of the jal
.Lcall_jal:
    jal ra, here                # ra = the address of the next instruction, then jump
    sub a0, a0, a1              # here's answer minus the address of the jal
    ld ra, 8(sp)
    addi sp, sp, 16
    ret

.globl gap_call
gap_call:                       # long gap_call(void): the call pseudo-instruction
    addi sp, sp, -16
    sd ra, 8(sp)
    lla a1, .Lcall
.Lcall:
    call here                   # auipc and jalr: two instructions
    sub a0, a0, a1
    ld ra, 8(sp)
    addi sp, sp, 16
    ret

.globl gap_jalr
gap_jalr:                       # long gap_jalr(void): the same call through a register
    addi sp, sp, -16
    sd ra, 8(sp)
    lla a5, here                # a5 = a pointer to here
    lla a1, .Lcall_jalr
.Lcall_jalr:
    jalr ra, 0(a5)              # call the address in a5: ra = the next instruction
    sub a0, a0, a1
    ld ra, 8(sp)
    addi sp, sp, 16
    ret

.section .note.GNU-stack,"",@progbits

A C driver calls both AArch64 gap functions and prints what they return:

gap_main.c: prints both gaps C · gap_main.c
/* Calls the two AArch64 gap functions and prints how far past each call its return address points. */
#include <stdio.h>

long gap_bl(void);
long gap_blr(void);

int main(void) {
    printf("bl:  the return address is the address of the call + %ld bytes\n", gap_bl());
    printf("blr: the return address is the address of the call + %ld bytes\n", gap_blr());
    return 0;
}

The session runs the AArch64 version and lists the bytes of the other two:

How far past each call the return address points Python · gap_session.py
"""Runs the AArch64 gap functions, then assembles the x86-64 and RISC-V versions and lists their bytes:
how long each call instruction is, and so how far past it the return address points."""

import pathlib
import sys

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

session.run("""
# AArch64: build the C driver with gap_aarch64.s and run it
clang -o gap gap_main.c gap_aarch64.s
./gap
# x86-64: assemble gap_x86_64.s into an ELF object and list it, bytes and instructions
clang --target=x86_64-linux-gnu -c -o gap_x86_64.o gap_x86_64.s
llvm-objdump -d -M intel gap_x86_64.o
# RISC-V: assemble gap_riscv64.s and list it with the real instruction names
llvm-mc --triple=riscv64-linux-gnu -filetype=obj -o gap_riscv64.o gap_riscv64.s
llvm-objdump -d -M no-aliases gap_riscv64.o
""", max_lines=60)

Output

# AArch64: build the C driver with gap_aarch64.s and run it
$ clang -o gap gap_main.c gap_aarch64.s
$ ./gap
bl:  the return address is the address of the call + 4 bytes
blr: the return address is the address of the call + 4 bytes
# x86-64: assemble gap_x86_64.s into an ELF object and list it, bytes and instructions
$ clang --target=x86_64-linux-gnu -c -o gap_x86_64.o gap_x86_64.s
$ llvm-objdump -d -M intel gap_x86_64.o

gap_x86_64.o:	file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <here>:
       0: 48 8b 04 24                  	mov	rax, qword ptr [rsp]
       4: c3                           	ret

0000000000000005 <gap_call>:
       5: 48 8d 0d 00 00 00 00         	lea	rcx, [rip]              # 0xc <gap_call+0x7>
       c: e8 ef ff ff ff               	call	0x0 <here>
      11: 48 29 c8                     	sub	rax, rcx
      14: c3                           	ret

0000000000000015 <gap_call_reg>:
      15: 48 8d 15 e4 ff ff ff         	lea	rdx, [rip - 0x1c]       # 0x0 <here>
      1c: 48 8d 0d 00 00 00 00         	lea	rcx, [rip]              # 0x23 <gap_call_reg+0xe>
      23: ff d2                        	call	rdx
      25: 48 29 c8                     	sub	rax, rcx
      28: c3                           	ret
# RISC-V: assemble gap_riscv64.s and list it with the real instruction names
$ llvm-mc --triple=riscv64-linux-gnu -filetype=obj -o gap_riscv64.o gap_riscv64.s
$ llvm-objdump -d -M no-aliases gap_riscv64.o

gap_riscv64.o:	file format elf64-littleriscv

Disassembly of section .text:

0000000000000000 <here>:
       0: 00008513     	addi	a0, ra, 0x0
       4: 00008067     	jalr	zero, 0x0(ra)

0000000000000008 <gap_jal>:
       8: ff010113     	addi	sp, sp, -0x10
       c: 00113423     	sd	ra, 0x8(sp)
      10: 00000597     	auipc	a1, 0x0
      14: 00858593     	addi	a1, a1, 0x8
      18: fe9ff0ef     	jal	ra, 0x0 <here>
      1c: 40b50533     	sub	a0, a0, a1
      20: 00813083     	ld	ra, 0x8(sp)
      24: 01010113     	addi	sp, sp, 0x10
      28: 00008067     	jalr	zero, 0x0(ra)

000000000000002c <gap_call>:
      2c: ff010113     	addi	sp, sp, -0x10
      30: 00113423     	sd	ra, 0x8(sp)
      34: 00000597     	auipc	a1, 0x0
      38: 00858593     	addi	a1, a1, 0x8
      3c: 00000097     	auipc	ra, 0x0
      40: fc4080e7     	jalr	ra, -0x3c(ra) <here>
      44: 40b50533     	sub	a0, a0, a1
      48: 00813083     	ld	ra, 0x8(sp)
      4c: 01010113     	addi	sp, sp, 0x10
      50: 00008067     	jalr	zero, 0x0(ra)

0000000000000054 <gap_jalr>:
      54: ff010113     	addi	sp, sp, -0x10
      58: 00113423     	sd	ra, 0x8(sp)
      5c: 00000797     	auipc	a5, 0x0
      60: fa478793     	addi	a5, a5, -0x5c
      64: 00000597     	auipc	a1, 0x0
      68: 00858593     	addi	a1, a1, 0x8
      6c: 000780e7     	jalr	ra, 0x0(a5)
      70: 40b50533     	sub	a0, a0, a1
      74: 00813083     	ld	ra, 0x8(sp)
      78: 01010113     	addi	sp, sp, 0x10
      7c: 00008067     	jalr	zero, 0x0(ra)

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

Both AArch64 calls printed 4, the length of a bl and of a blr. The x86-64 listing shows a different rule behind the same idea. call here is e8 and a 4-byte offset, 5 bytes in all, and call rdx is ff d2, 2 bytes. Intel’s manual says a near call pushes the address of the instruction that follows it onto the stack, and ret, c3, pops that address into rip. x86-64 has no link register, so its here finds the return address with mov rax, [rsp]: the System V ABI says that at a function’s first instruction rsp points to the return address.

RISC-V puts the address in ra, which is x1, as AArch64 puts it in x30. jal ra, here is 4 bytes, and call here is a pseudo-instruction: A name an assembler accepts where an instruction goes although the instruction set has no instruction of that name: RISC-V li and la become one or more real instructions. The NASM manual also uses the word for its data lines, such as db, equ and times. More in “Assembler syntax: NASM, GNU as, Intel and AT&T” for two, auipc and jalr, so ra ends up 8 bytes past the start of the call. Its ret is jalr zero, 0(ra): a jump to ra whose own link goes to the zero register, which drops it. Side by side, the three ISAs keep and use the return address like this:

ISA Call instructions Where the return address goes Return
x86-64 call here (5 bytes), call rdx (2) pushed onto the stack ret pops it into rip
AArch64 bl, blr x8 (4 bytes each) x30, the link register ret branches to x30
RISC-V jal ra, here (4), call here (8), jalr ra, 0(a5) (4) ra, which is x1 ret, which is jalr zero, 0(ra)

Step through the AArch64 call once, with x30 as the third column:

Interactive · message by message One bl and one ret, and what x30 holds Step through gap_bl calling here in gap_aarch64.s. A is the address of the bl; watch x30 between the two functions.
One bl and one ret, and what x30 holds, step 7 of 7. Messages so far: gap_bl to x30: x30 = A + 4; gap_bl to here: bl _here; here to gap_bl: ret: to A + 4.gap_blx0 = 4herex0 = A + 4x30ra, or [rsp]x30 = A + 4bl _hereret: to A + 4

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

Every step as text
  1. Note the call's address. gap_bl has already parked its own return address on the stack. Now adr x1, Lcall_bl puts the address of the next instruction, the bl, into x1. Call that address A.
  2. bl writes x30. bl does two things in one 4-byte instruction. First it writes A + 4, the address of the instruction after itself, into x30, the link register.
  3. Then it jumps. Then bl branches to here. Nothing was pushed and the stack pointer did not move: the only record of where to come back is the value in x30.
  4. The leaf reads x30. here copies x30 into x0, the register a function returns its result in. here calls nothing, so nothing has written x30 since the bl.
  5. ret jumps to x30. ret branches to the address in x30, A + 4: the sub instruction right behind the bl in gap_bl. The call is over.
  6. The answer is 4. sub x0, x0, x1 works out (A + 4) - A = 4, the length of the bl: the number the run printed. blr x8 gave 4 too, because it writes x30 the same way.
  7. The other two ISAs. RISC-V does the same with ra: jal writes the address of the next instruction into ra. x86-64 has no link register: call pushes that address onto the stack, and ret pops it.

The result came back in x0 on AArch64 and would come back in rax and a0 on the other two; which registers carry arguments and results is the subject of calling conventions.

Checkpoint

Check: Where a call leaves its return address

  1. Question 1 of 2 In the x86-64 listing, call rdx starts at offset 0x23 and is 2 bytes long. What does it push?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0x25, the offset of the instruction after it

    call pushes the address of the instruction that follows it, then jumps. call rdx is 2 bytes, ff d2, so from offset 0x23 it pushes 0x25, and the ret of here pops 0x25 back into rip.

  2. Question 2 of 2 Where does AArch64's ret find the address it branches to?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In x30, the link register

    On AArch64 a call leaves the return address in x30 and ret reads it from there. The gap run showed it: here handed x30 back, and it was the address of the bl plus 4.

greet_broken and greet_fixed both call puts twice. The broken one never saves x30; the fixed one stores it before its first call and loads it back before ret. The other two tabs hold a working greet for x86-64 and RISC-V, to compare with later:

greet on three ISAs, with the AArch64 version broken and fixed

AArch64 · greet_aarch64.s

// greet calls puts twice. greet_broken never saves x30; greet_fixed saves it before its first call.
    .text
    .globl _greet_broken
    .p2align 2
_greet_broken:
    adrp x0, Lhello@PAGE           // x0 = the address of "hello"
    add x0, x0, Lhello@PAGEOFF
    bl _puts                       // x30 = the address of the next instruction: main's is gone
    adrp x0, Lagain@PAGE
    add x0, x0, Lagain@PAGEOFF
    bl _puts                       // x30 = the address of the ret below
    ret                            // branch to x30: to this ret, for ever

    .globl _greet_fixed
    .p2align 2
_greet_fixed:
    stp x29, x30, [sp, #-16]!      // save x30 (and x29) on the stack before any call
    mov x29, sp
    adrp x0, Lhello@PAGE
    add x0, x0, Lhello@PAGEOFF
    bl _puts
    adrp x0, Lagain@PAGE
    add x0, x0, Lagain@PAGEOFF
    bl _puts
    ldp x29, x30, [sp], #16        // x30 = main's return address again
    ret

    .section __TEXT,__cstring
Lhello: .asciz "hello"
Lagain: .asciz "again"

x86-64 · greet_x86_64.s

# greet for x86-64 Linux (System V): two calls of puts. Each call pushes its own return address below
# ours, so nothing has to be saved for ret; every byte taken from the stack must be given back first.
.intel_syntax noprefix
.text
.globl greet
greet:
    sub rsp, 8                  # puts expects rsp 16-byte aligned at the call (alignment has rules of its own)
    lea rdi, [rip + .Lhello]    # the string's address, for puts
    call puts@PLT               # push the address of the next instruction, jump to puts
    lea rdi, [rip + .Lagain]
    call puts@PLT
    add rsp, 8                  # give back the 8 bytes: our return address is on top again
    ret                         # pop it into rip

.section .rodata
.Lhello: .asciz "hello"
.Lagain: .asciz "again"
.section .note.GNU-stack,"",@progbits

RISC-V · greet_riscv64.s

# greet for 64-bit RISC-V Linux: call writes ra, as bl writes x30, so ra is saved before the first call.
.text
.globl greet
greet:
    addi sp, sp, -16            # room on the stack, in a 16-byte step
    sd ra, 8(sp)                # save our return address before any call overwrites ra
    lla a0, .Lhello             # the string's address, for puts
    call puts                   # ra = the address of the next instruction, then jump
    lla a0, .Lagain
    call puts
    ld ra, 8(sp)                # our own return address again
    addi sp, sp, 16
    ret                         # jalr zero, 0(ra)

.section .rodata
.Lhello: .asciz "hello"
.Lagain: .asciz "again"
.section .note.GNU-stack,"",@progbits

The C driver arms a 1-second alarm, then calls the version named on its command line. The session builds one program with both versions, runs each, and lists the broken one:

greet_main.c: a 1-second alarm, then one version of greet C · greet_main.c
/* Calls greet_fixed, or greet_broken when the first argument is "broken". A function that never returns
   would hang the program, so an alarm ends it after 1 second with exit status 3. */
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

void greet_broken(void);
void greet_fixed(void);

static void too_long(int sig) {
    (void)sig;
    static const char msg[] = "still inside after 1 s: the function never returned\n";
    write(1, msg, sizeof msg - 1);
    _exit(3);
}

int main(int argc, char **argv) {
    signal(SIGALRM, too_long);
    alarm(1);
    if (argc > 1 && strcmp(argv[1], "broken") == 0) greet_broken(); else greet_fixed();
    puts("back in main");
    return 0;
}
greet_fixed returns; greet_broken never does Python · greet_session.py
"""Builds greet_fixed and greet_broken into one AArch64 program and runs each, lists the broken function's
offsets, then assembles the x86-64 and RISC-V versions of greet."""

import pathlib
import sys

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

session.run("""
# AArch64: one program, both versions of greet
clang -o greet greet_main.c greet_aarch64.s
# greet_fixed saves x30 before its first call
./greet fixed; echo "exit=$?"
# greet_broken never saves x30
./greet broken; echo "exit=$?"
# Where each instruction of greet_broken sits, counted from its first byte, and what the linker fills in (-r)
clang -c -o greet_aarch64.o greet_aarch64.s
llvm-objdump -d -r --disassemble-symbols=_greet_broken greet_aarch64.o
# x86-64 and RISC-V: the working greet, assembled into ELF objects
clang --target=x86_64-linux-gnu -c -o greet_x86_64.o greet_x86_64.s
llvm-mc --triple=riscv64-linux-gnu -filetype=obj -o greet_riscv64.o greet_riscv64.s
""")

Output

# AArch64: one program, both versions of greet
$ clang -o greet greet_main.c greet_aarch64.s
# greet_fixed saves x30 before its first call
$ ./greet fixed; echo "exit=$?"
hello
again
back in main
exit=0
# greet_broken never saves x30
$ ./greet broken; echo "exit=$?"
hello
again
still inside after 1 s: the function never returned
exit=3
# Where each instruction of greet_broken sits, counted from its first byte, and what the linker fills in (-r)
$ clang -c -o greet_aarch64.o greet_aarch64.s
$ llvm-objdump -d -r --disassemble-symbols=_greet_broken greet_aarch64.o

greet_aarch64.o:	file format mach-o arm64

Disassembly of section __TEXT,__text:

0000000000000000 <_greet_broken>:
       0: 90000000     	adrp	x0, 0x0 <ltmp0>
		0000000000000000:  ARM64_RELOC_PAGE21	Lhello
       4: 91000000     	add	x0, x0, #0x0
		0000000000000004:  ARM64_RELOC_PAGEOFF12	Lhello
       8: 94000000     	bl	0x8 <ltmp0+0x8>
		0000000000000008:  ARM64_RELOC_BRANCH26	_puts
       c: 90000000     	adrp	x0, 0x0 <ltmp0>
		000000000000000c:  ARM64_RELOC_PAGE21	Lagain
      10: 91000000     	add	x0, x0, #0x0
		0000000000000010:  ARM64_RELOC_PAGEOFF12	Lagain
      14: 94000000     	bl	0x14 <ltmp0+0x14>
		0000000000000014:  ARM64_RELOC_BRANCH26	_puts
      18: d65f03c0     	ret
# x86-64 and RISC-V: the working greet, assembled into ELF objects
$ clang --target=x86_64-linux-gnu -c -o greet_x86_64.o greet_x86_64.s
$ llvm-mc --triple=riscv64-linux-gnu -filetype=obj -o greet_riscv64.o greet_riscv64.s

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

Both versions printed hello and again, so both calls of puts worked. Only greet_fixed got back to main. The listing explains the broken run with three offsets: the second bl sits at 0x14, so it writes greet_broken + 0x18 into x30, and 0x18 is the function’s own ret. When that ret runs, it branches to the address in x30, which is its own address, so it runs again, and again. The loop moves no stack and prints nothing; only the alarm stops it.

The first bl did the damage. Before it, x30 held the only copy of main’s return address; the bl replaced it with an address inside greet_broken, and nothing had kept the old value. Step through the run:

Interactive · message by message How greet_broken loses its way back to main Step through the broken run of greet_broken. The offsets come from the listing; watch the x30 value under greet.
How greet_broken loses its way back to main, step 7 of 7. Messages so far: main to greet: bl _greet_broken; greet to puts: bl _puts (at +0x8); puts to greet: ret: to +0xc; greet to puts: bl _puts (at +0x14); puts to greet: ret: to +0x18.mainstatus 3greetx30: +0x18putslibcbl _greet_brokenbl _puts (at +0x8)ret: to +0xcbl _puts (at +0x14)ret: to +0x18

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

Every step as text
  1. main calls greet_broken. main runs bl _greet_broken. x30 now holds the address in main right after that bl: the one way back to main that exists anywhere.
  2. The first bl overwrites x30. The bl at offset 0x8 writes the address after itself, greet_broken + 0xc, into x30. The way back to main is gone, and nothing kept a copy.
  3. puts prints hello. puts prints hello and returns through x30, to offset 0xc in greet_broken. So far everything looks right.
  4. The second bl. The bl at offset 0x14 writes greet_broken + 0x18 into x30. Offset 0x18 holds the function's own ret.
  5. puts prints again. puts prints again and returns to offset 0x18, the ret.
  6. ret jumps to itself. ret branches to the address in x30, which is the address of this ret. It runs again and jumps to itself again, for ever, using no stack and printing nothing.
  7. The alarm ends it. After 1 second the driver's alarm handler prints that the function never returned and exits with status 3. greet_fixed avoids all of this with one stp before its first bl and one ldp before its ret.

greet_fixed adds three instructions. stp x29, x30, [sp, #-16]! stores x29 and x30 in a 16-byte slot below the old stack pointer before the first call, mov x29, sp points x29 at that slot, and ldp x29, x30, [sp], #16 loads both back just before ret. The pair and writeback forms are those of the stack lesson; the slot, with x29 pointing at it, is a frame record, which callee-saved registers and frames explains. RISC-V follows the same rule with ra: its greet stores ra with sd ra, 8(sp) before the first call and reloads it with ld ra, 8(sp).

Keeping x30 in a spare register

mov x9, x30 before the first call looks like a save, and it may even pass a test. But a callee may change any of x0 to x17 without restoring it, so the copy survives only until puts, or whatever is called, happens to use x9. Store x30 in the function’s own stack slot, which no callee touches, and load it back from there.

x86-64 cannot lose the return address this way. Each call pushes its own return address below the caller’s, so greet’s return address is still in its stack slot, untouched, when greet’s own ret runs. What x86-64 code must get right instead is the stack pointer: ret pops whatever word is on top, so the sub rsp, 8 has to be undone first. Leave a pushed register on the stack and ret pops that value as an address.

Key idea

A call overwrites the link register, so a function that makes calls stores x30 or ra in its own stack slot before the first one and loads it back before ret. On x86-64 the return address is already on the stack, and the rule becomes: give back every byte you took from the stack before ret.

Checkpoint

Check: Saving the link register before a call

  1. Question 1 of 2 In greet_broken the second bl is at offset 0x14 and ret at 0x18. What is in x30 when that ret runs?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: greet_broken + 0x18, the address of the ret itself

    Each bl overwrites x30 with the address after itself. The last one before the ret is at 0x14, so x30 holds 0x18, the ret, and ret jumps to itself for ever.

  2. Question 2 of 2 Why does the x86-64 greet need no instruction that saves its own return address?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each call pushes its own return address below the caller's, so greet's stays in its stack slot.

    On x86-64 the return address lives on the stack, and nested calls push theirs further down. What greet must do instead is give back the 8 bytes it took, so that ret pops its own return address and not some other word.

Compilers skip the save in a leaf and turn a last call into a jump

tail.c holds three small functions. leaf calls nothing, not_tail calls g and then adds 1 to its result, and tail adds 1 first and ends with the call. The session prints what clang writes for each at -O2 on the three ISAs, then tail once more at -O0:

tail.c: a leaf, a call with work after it, and a call at the end C · tail.c
/* Three small functions for compiler output: one calls nothing, one does work after its call,
   and one ends with its call. g is defined elsewhere, so the compiler cannot inline it. */
long g(long x);

long leaf(long x) { return x * 3 + 1; }
long not_tail(long x) { return g(x) + 1; }
long tail(long x) { return g(x + 1); }
Compiler output for the three functions Python · tail_session.py
"""Compiles tail.c at -O2 for AArch64 macOS, x86-64 Linux and RISC-V Linux and prints the assembly the
compiler writes, then the tail function once more at -O0."""

import pathlib
import sys

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

session.run("""
# Each command prints the compiler's assembly; grep drops the directive and comment lines
clang --target=arm64-apple-macos -O2 -S -fno-asynchronous-unwind-tables -o - tail.c | grep -v -E '^[[:space:]]*([.#;/]|$)'
clang --target=x86_64-linux-gnu -O2 -S -masm=intel -fno-asynchronous-unwind-tables -o - tail.c | grep -v -E '^[[:space:]]*([.#;/]|$)'
clang-23 --target=riscv64-linux-gnu -march=rv64gc -mabi=lp64d -O2 -S -fno-asynchronous-unwind-tables -o - tail.c | grep -v -E '^[[:space:]]*([.#;/]|$)'
# AArch64 at -O0: only the tail function, from its label to its ret
clang --target=arm64-apple-macos -O0 -S -fno-asynchronous-unwind-tables -o - tail.c | grep -v -E '^[[:space:]]*([.#;/]|$)' | sed -n '/^_tail:/,/ret$/p'
""")

Output

# Each command prints the compiler's assembly; grep drops the directive and comment lines
$ clang --target=arm64-apple-macos -O2 -S -fno-asynchronous-unwind-tables -o - tail.c | grep -v -E '^[[:space:]]*([.#;/]|$)'
_leaf:                                  ; @leaf
	add	x8, x0, x0, lsl #1
	add	x0, x8, #1
	ret
_not_tail:                              ; @not_tail
	stp	x29, x30, [sp, #-16]!           ; 16-byte Folded Spill
	mov	x29, sp
	bl	_g
	add	x0, x0, #1
	ldp	x29, x30, [sp], #16             ; 16-byte Folded Reload
	ret
_tail:                                  ; @tail
	add	x0, x0, #1
	b	_g
$ clang --target=x86_64-linux-gnu -O2 -S -masm=intel -fno-asynchronous-unwind-tables -o - tail.c | grep -v -E '^[[:space:]]*([.#;/]|$)'
leaf:                                   # @leaf
	lea	rax, [rdi + 2*rdi]
	inc	rax
	ret
not_tail:                               # @not_tail
	push	rax
	call	g@PLT
	inc	rax
	pop	rcx
	ret
tail:                                   # @tail
	inc	rdi
	jmp	g@PLT                           # TAILCALL
$ clang-23 --target=riscv64-linux-gnu -march=rv64gc -mabi=lp64d -O2 -S -fno-asynchronous-unwind-tables -o - tail.c | grep -v -E '^[[:space:]]*([.#;/]|$)'
leaf:                                   # @leaf
	slli	a1, a0, 1
	add	a0, a0, a1
	addi	a0, a0, 1
	ret
not_tail:                               # @not_tail
	addi	sp, sp, -16
	sd	ra, 8(sp)                       # 8-byte Folded Spill
	call	g
	addi	a0, a0, 1
	ld	ra, 8(sp)                       # 8-byte Folded Reload
	addi	sp, sp, 16
	ret
tail:                                   # @tail
	addi	a0, a0, 1
	tail	g
# AArch64 at -O0: only the tail function, from its label to its ret
$ clang --target=arm64-apple-macos -O0 -S -fno-asynchronous-unwind-tables -o - tail.c | grep -v -E '^[[:space:]]*([.#;/]|$)' | sed -n '/^_tail:/,/ret$/p'
_tail:                                  ; @tail
	sub	sp, sp, #32
	stp	x29, x30, [sp, #16]             ; 16-byte Folded Spill
	add	x29, sp, #16
	str	x0, [sp, #8]
	ldr	x8, [sp, #8]
	add	x0, x8, #1
	bl	_g
	ldp	x29, x30, [sp, #16]             ; 16-byte Folded Reload
	add	sp, sp, #32
	ret

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

The -O2 output, function by function:

Function AArch64 (macOS) x86-64 (Linux) RISC-V (Linux)
leaf two adds, ret: no stack lea, inc, ret slli, add, addi, ret
not_tail stp x29, x30, bl _g, ldp, ret push rax, call g@PLT, pop rcx, ret sd ra, call g, ld ra, ret
tail add, then b _g inc rdi, then jmp g@PLT addi, then tail g

leaf is a leaf function: A function that makes no calls of its own. Nothing overwrites its link register while it runs, so it needs no save of x30 or ra, and at -O2 compilers often give it no stack space at all.. It makes no calls, so nothing overwrites x30 or ra while it runs, and the compiler gave it no stack at all on any ISA. not_tail makes a call and has work left after it, so on AArch64 and RISC-V it saves the link register exactly as greet_fixed did. On x86-64 its return address is already on the stack, and the one push rax saves nothing that matters: it moves rsp by 8 so that rsp is 16-byte aligned at the call, which the System V ABI requires (alignment has rules of its own). pop rcx takes the 8 bytes back without touching the result in rax.

tail ends with its call, so the compiler replaced the call and the return with a plain jump: b, jmp or RISC-V’s tail pseudo-instruction, which the assembly manual defines as auipc with jalr zero, a jump that writes no link. clang even marks the x86-64 one # TAILCALL. The link register, or the top word of the x86-64 stack, still holds the return address of whoever called tail, so g returns straight there. This is a tail call: A call that is the last thing a function does, compiled as a plain jump (b, jmp or RISC-V's tail) instead of a call and a return. The caller's return address stays in place, so the callee returns straight to the caller's caller.. Step through it with main as the caller:

Interactive · message by message A tail call leaves the return address where it is Step through tail from the -O2 AArch64 output, called by main. Watch x30 under tail.
A tail call leaves the return address where it is, step 5 of 5. Messages so far: main to tail: bl _tail; tail to g: b _g; g to main: ret: to main.mainthe callertailx0 = x + 1gthe calleebl _tailb _gret: to main

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

Every step as text
  1. main calls tail. main runs bl _tail, so x30 holds the address in main right after that bl.
  2. The work before the call. tail adds 1 to its argument in x0. The call to g is the last thing it does, so it has no work left for after g returns.
  3. b instead of bl. The compiler wrote b _g, a plain branch. A b writes nothing to x30, so x30 still holds the way back to main, and tail never had to save it.
  4. g returns to main. g does its work and ends with ret, which branches to x30: straight back to main, with g's result in x0 as tail's result. tail runs no instruction after the jump.
  5. What it saved. not_tail, which adds 1 after its call, needed stp, mov, bl, ldp and ret around the same call. tail needed one b, and no stack at all.

At -O0 the same tail keeps bl _g inside a 32-byte frame that saves x29 and x30, so the jump is an optimisation, not a rule of the language. GCC documents its switch for it, -foptimize-sibling-calls, as enabled at -O2, -O3 and -Os, and clang accepts the same flag.

Reading a function's last instruction

The end of a function in a listing tells you its kind. A ret with no call before it is a leaf; a call followed later by ret is a non-leaf; a b, jmp or tail to another function where the ret would be is a tail call.

Checkpoint

Check: Leaf, non-leaf and tail calls in compiler output

  1. Question 1 of 2 Which of these endings, from -O2 output, is a tail call?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: inc rdi then jmp g@PLT

    A tail call is a call in the last position compiled as a jump: b on AArch64, jmp on x86-64, tail on RISC-V. The return address of the caller is still in place, so the callee returns straight to it.

  2. Question 2 of 2 Why does not_tail on x86-64 start with push rax?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It moves rsp by 8 so that rsp is 16-byte aligned at the call.

    On x86-64 the one push in not_tail is about alignment, not about saving. pop rcx at the end gives the 8 bytes back without touching the result in rax.

A call reaches only so far, and the linker bridges the rest

A direct call does not hold its target’s address. It holds how far away the target is, counted from the call, and the number of bits it has for that count sets its branch range: How far from itself a PC-relative branch can reach, set by the width of its offset field: 4 KiB either way for a RISC-V branch, 32 KiB for AArch64 tbz, about 2 GiB for an x86-64 near jump. More in “Jumps and conditional branches”. The fields, from each ISA’s documents:

Call Offset field Reach
x86-64 call with rel32 32 bits, counting bytes about 2 GiB either way
AArch64 bl 26 bits, counting 4-byte instructions 128 MiB either way
RISC-V jal 20 bits, counting 2-byte units 1 MiB either way
RISC-V call: auipc and jalr 32 bits, split over two instructions about 2 GiB either way

Conditional branches reach less, which jumps and conditional branches works through. For calls, the two ISAs with short reaches take opposite routes, and both run through the linker: The tool that merges one or more object files into one executable or shared library. It concatenates sections, picks final addresses, resolves symbols and patches the bytes the assembler marked with relocations. More in “From source to executable: objects and linking”, which is the first tool that knows the final addresses.

RISC-V starts long and lets the linker shorten. The assembler cannot know whether a call’s target will end up within 1 MiB, so call begins as the 8-byte pair. Assembled with relaxation allowed, each auipc carries two 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”: R_RISCV_CALL_PLT asks the linker to fill in the distance, and R_RISCV_RELAX allows it to rewrite the sequence. The next session links three such calls with LLD, LLVM’s linker, and lists the program it wrote; the program is linked and listed, not run:

relax_riscv64.s: three calls to shorten S · relax_riscv64.s
# Three calls for the linker to shorten. Assembled with relaxation allowed, each call stays an
# auipc and a jalr with two relocations; the linker may turn the pair into one jal when it reaches.
# The program is linked and listed.
.text
.globl _start
_start:
    call twice                  # 8 bytes in the object file
    li a7, 93                   # the Linux exit system call
    ecall

.globl twice
twice:                          # long twice(long x): helper(helper(x))
    addi sp, sp, -16
    sd ra, 8(sp)
    call helper
    call helper
    ld ra, 8(sp)
    addi sp, sp, 16
    ret

helper:                         # long helper(long x): x + 1
    addi a0, a0, 1
    ret

.section .note.GNU-stack,"",@progbits
Three RISC-V calls before and after the link Python · relax_session.py
"""Assembles three RISC-V calls with relaxation allowed, links them with LLD, LLVM's linker, and lists the
object file and the linked program: each call before and after the linker shortened it."""

import pathlib
import sys

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

session.run("""
# Assemble with relaxation allowed (-mattr=+relax), then list the object with its relocations (-r)
llvm-mc --triple=riscv64-linux-gnu -mattr=+relax -filetype=obj -o relax.o relax_riscv64.s
llvm-objdump -dr -M no-aliases relax.o
# Link it into a RISC-V Linux program and list that
ld.lld -o relax relax.o
llvm-objdump -d -M no-aliases relax
# Bytes of code before and after the link
llvm-size relax.o relax
""")

Output

# Assemble with relaxation allowed (-mattr=+relax), then list the object with its relocations (-r)
$ llvm-mc --triple=riscv64-linux-gnu -mattr=+relax -filetype=obj -o relax.o relax_riscv64.s
$ llvm-objdump -dr -M no-aliases relax.o

relax.o:	file format elf64-littleriscv

Disassembly of section .text:

0000000000000000 <_start>:
       0: 00000097     	auipc	ra, 0x0
		0000000000000000:  R_RISCV_CALL_PLT	twice
		0000000000000000:  R_RISCV_RELAX	*ABS*
       4: 000080e7     	jalr	ra, 0x0(ra) <_start>
       8: 05d00893     	addi	a7, zero, 0x5d
       c: 00000073     	ecall

0000000000000010 <twice>:
      10: ff010113     	addi	sp, sp, -0x10
      14: 00113423     	sd	ra, 0x8(sp)
      18: 00000097     	auipc	ra, 0x0
		0000000000000018:  R_RISCV_CALL_PLT	helper
		0000000000000018:  R_RISCV_RELAX	*ABS*
      1c: 000080e7     	jalr	ra, 0x0(ra) <twice+0x8>
      20: 00000097     	auipc	ra, 0x0
		0000000000000020:  R_RISCV_CALL_PLT	helper
		0000000000000020:  R_RISCV_RELAX	*ABS*
      24: 000080e7     	jalr	ra, 0x0(ra) <twice+0x10>
      28: 00813083     	ld	ra, 0x8(sp)
      2c: 01010113     	addi	sp, sp, 0x10
      30: 00008067     	jalr	zero, 0x0(ra)

0000000000000034 <helper>:
      34: 00150513     	addi	a0, a0, 0x1
      38: 00008067     	jalr	zero, 0x0(ra)
# Link it into a RISC-V Linux program and list that
$ ld.lld -o relax relax.o
$ llvm-objdump -d -M no-aliases relax

relax:	file format elf64-littleriscv

Disassembly of section .text:

0000000000011120 <_start>:
   11120: 00c000ef     	jal	ra, 0x1112c <twice>
   11124: 05d00893     	addi	a7, zero, 0x5d
   11128: 00000073     	ecall

000000000001112c <twice>:
   1112c: ff010113     	addi	sp, sp, -0x10
   11130: 00113423     	sd	ra, 0x8(sp)
   11134: 014000ef     	jal	ra, 0x11148 <helper>
   11138: 010000ef     	jal	ra, 0x11148 <helper>
   1113c: 00813083     	ld	ra, 0x8(sp)
   11140: 01010113     	addi	sp, sp, 0x10
   11144: 00008067     	jalr	zero, 0x0(ra)

0000000000011148 <helper>:
   11148: 00150513     	addi	a0, a0, 0x1
   1114c: 00008067     	jalr	zero, 0x0(ra)
# Bytes of code before and after the link
$ llvm-size relax.o relax
   text	   data	    bss	    dec	    hex	filename
     60	      0	      0	     60	     3c	relax.o
     48	      0	      0	     48	     30	relax

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

Each of the three calls became one jal ra, and the code shrank from 60 bytes to 48. This is linker relaxation: A link-time rewrite that replaces a code sequence with a shorter one once the final addresses are known, such as a RISC-V auipc and jalr pair turned into one jal when the target is within about 1 MiB. An R_RISCV_RELAX relocation marks each place where it is allowed.: the RISC-V ELF psABI lets a linker replace an auipc and jalr pair with one jal when the target is within about 1 MiB. Removing bytes moves every instruction after them, so the psABI asks compilers to keep a relocation for each address the move could change.

AArch64 starts short and lets the linker lengthen. far_aarch64.s makes one call to a function placed just behind it and one to far_fn, which the linker script far.ld puts at 0x20000000:

A near and a far call, and the script that places them

far_aarch64.s

// Two calls: near_fn sits right behind _start, far_fn in a section the linker script far.ld places
// about 510 MiB away, beyond the 128 MiB a bl can reach. The program is linked and listed.
    .text
    .globl _start
_start:
    bl near_fn
    bl far_fn
    mov x8, #93                 // Linux exit
    svc #0

    .type near_fn, %function
near_fn:
    ret

    .section .farcode, "ax"
    .globl far_fn
    .type far_fn, %function
far_fn:
    ret

    .section .note.GNU-stack,"",%progbits

far.ld

/* Puts .text at 0x210000 and .farcode at 0x20000000 (512 MiB), each in its own loadable segment,
   so the file holds the two pieces of code and not the half a gigabyte between them. */
PHDRS { near PT_LOAD; far PT_LOAD; }
SECTIONS {
  . = 0x210000;
  .text : { *(.text) } :near
  . = 0x20000000;
  .farcode : { *(.farcode) } :far
}

Linked with that script by the same LLD, and listed:

A bl beyond 128 MiB after the link Python · thunk_session.py
"""Assembles two AArch64 calls, one near and one about 510 MiB away, links them with LLD and the linker
script far.ld, and lists the program: what the linker put between the far bl and its target."""

import pathlib
import sys

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

session.run("""
# Assemble for AArch64 Linux (ELF), then link with far.ld, which places far_fn at 0x20000000
llvm-mc --triple=aarch64-linux-gnu -filetype=obj -o far.o far_aarch64.s
ld.lld -T far.ld -o far far.o
# List the linked program
llvm-objdump -d far
""")

Output

# Assemble for AArch64 Linux (ELF), then link with far.ld, which places far_fn at 0x20000000
$ llvm-mc --triple=aarch64-linux-gnu -filetype=obj -o far.o far_aarch64.s
$ ld.lld -T far.ld -o far far.o
# List the linked program
$ llvm-objdump -d far

far:	file format elf64-littleaarch64

Disassembly of section .text:

0000000000210000 <_start>:
  210000: 94000004     	bl	0x210010 <near_fn>
  210004: 94000005     	bl	0x210018 <__AArch64AbsLongThunk_far_fn>
  210008: d2800ba8     	mov	x8, #0x5d               // =93
  21000c: d4000001     	svc	#0

0000000000210010 <near_fn>:
  210010: d65f03c0     	ret
  210014: 00000000     	udf	#0x0

0000000000210018 <__AArch64AbsLongThunk_far_fn>:
  210018: 58000050     	ldr	x16, 0x210020 <__AArch64AbsLongThunk_far_fn+0x8>
  21001c: d61f0200     	br	x16
  210020: 00 00 00 20  	.word	0x20000000
  210024: 00 00 00 00  	.word	0x00000000

Disassembly of section .farcode:

0000000020000000 <far_fn>:
20000000: d65f03c0     	ret

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

The second bl sits at 0x210004, so far_fn is 0x20000000 - 0x210004 = 534,708,220 bytes away, about 510 MiB and far past what a bl can encode. LLD did not refuse the call. It aimed the bl at a 16-byte 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” of its own, __AArch64AbsLongThunk_far_fn, which loads the full 64-bit address of far_fn from the literal behind it into x16 and jumps there with br x16. The near call needed nothing.

That is why AAPCS64 hands x16 and x17, named IP0 and IP1, to the linker. A veneer may change those two registers and the flags and must keep every other register, so code may never expect a value in x16 or x17 to survive a bl or b that has a long-branch relocation. Arm’s ELF specification allows veneers only for those relocations, so a far conditional branch remains a link error until it is rewritten.

Key idea

A call reaches as far as its offset field allows: 1 MiB for jal, 128 MiB for bl, about 2 GiB for an x86-64 call or a RISC-V auipc and jalr pair. A RISC-V linker shortens a pair whose target turned out near, and an AArch64 linker sends a bl whose target turned out far through a veneer that may change x16 and x17.

Checkpoint

Check: Call reach, relaxation and veneers

  1. Question 1 of 2 The RISC-V object file held 60 bytes of code with three calls. How many bytes did the linked program hold?

    Type a number.

    A hint

    Each call started as auipc and jalr, two 4-byte instructions. What did the linker leave in place of each?

    Show the answer: Question 1 of 2

    Answer: 48 bytes

    The linker relaxed each auipc and jalr pair, 8 bytes, into one 4-byte jal, saving 4 bytes per call: 60 - 3 x 4 = 48, which is what llvm-size printed for the linked program.

  2. Question 2 of 2 A bl in your AArch64 code may end up more than 128 MiB from its target after linking. Which registers must your code not expect to survive that bl?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: x16 and x17, the registers a veneer may use, besides those the callee may change anyway

    The thunk in the run was ldr x16 and br x16. Code may therefore never keep a value in x16 or x17 across a bl or b that has a long-branch relocation, because a veneer may change them on the way.

Practise

Three changes to this lesson’s files, each built with the commands of its session above; tasks 1 and 2 need macOS on Apple silicon, as the runs did. Make the prediction before each run, then let the run tell you whether it held.

  1. Write apply(fn, x) in AArch64 assembly in a file apply_aarch64.s. It returns fn(fn(x)), and the driver below tests it with an increment and a doubling callback. apply makes calls, so its first instruction saves x29 and x30 in a 32-byte slot, which leaves 16 bytes for a value of your own. fn arrives in x0 and x in x1. Store fn in your slot, since fn itself may change any of x0 to x17, move x into x0, and call through the pointer with blr. The first result is already in x0, so load fn back from the slot and blr again. Restore x29 and x30 and ret. The driver should print 42 and 20.
  2. Copy greet_aarch64.s, delete the stp, mov x29, sp and ldp lines of greet_fixed, and run ./greet fixed. Predict the output first, and the offset x30 holds when the copy’s ret runs.
  3. Rebuild tail.c for x86-64 and RISC-V at -O0, then for AArch64 at -O2 with -fno-optimize-sibling-calls. Before you look, say which of the three functions change and what the tail call turns into.
apply_main.c: the test driver for task 1 C · apply_main.c
/* A test driver for apply(fn, x), which returns fn(fn(x)). Write apply in AArch64 assembly in
   apply_aarch64.s, then build and run: clang -o apply apply_main.c apply_aarch64.s && ./apply */
#include <stdio.h>

long apply(long (*fn)(long), long x);

static long inc(long x) { return x + 1; }
static long twice(long x) { return 2 * x; }

int main(void) {
    printf("apply(inc, 40) = %ld (expected 42)\n", apply(inc, 40));
    printf("apply(twice, 5) = %ld (expected 20)\n", apply(twice, 5));
    return 0;
}

Practice that adapts to you

Practise this lesson

12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 Where is the return address when an x86-64 function begins to run?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: At [rsp], the top word of the stack

    The x86-64 call pushes the return address and jumps, so at the first instruction rsp points at it. That is why the x86-64 here could read it with mov rax, [rsp].

  2. Question 2 of 12 In gap_riscv64.s, call here assembles to auipc and jalr. How many bytes past the start of the call does ra point?

    Type a number.

    A hint

    Count the instructions the listing shows for the call, and their size.

    Show the answer: Question 2 of 12

    Answer: 8 bytes

    jalr writes the address of the instruction after itself, and it is the second of two 4-byte instructions, so ra points 8 bytes past the auipc. A single jal ra would give 4.

  3. Question 3 of 12 RISC-V's j label is the instruction jal zero, label. Why is that a plain jump rather than a call?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: jal writes its link to the zero register, which drops every write, so no return address is kept.

    jal always writes the address of the next instruction to its destination register. With zero as that register the write is discarded, which turns a call into a jump; ret uses the same idea with jalr zero, 0(ra).

  4. Question 4 of 12 Which AArch64 function must save x30 before its body runs?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: One that calls another function

    Only a call writes x30, so only a function that makes calls loses its return address. It saves x30 before the first call and loads it back before ret.

  5. Question 5 of 12 In greet_fixed, someone moves ldp x29, x30, [sp], #16 to just before the second bl _puts. What happens?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: It prints hello and again, then hangs like greet_broken.

    The restore has to come after the last call. Any bl between the ldp and the ret overwrites x30 with an address inside greet, and the function cannot return.

  6. Question 6 of 12 A RISC-V function calls a helper once and returns its result plus 1. Which way of keeping ra across the call is safe?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Make room on the stack, sd ra there before the call and ld ra after it.

    ra must survive the call in a place the callee leaves alone. A temporary register is not such a place; the function's own stack slot is.

  7. Question 7 of 12 What makes leaf() a leaf function?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: It calls no other function.

    A leaf is a function that makes no calls. Its return address stays in x30 or ra, or on the x86-64 stack, the whole time it runs.

  8. Question 8 of 12 An AArch64 function ends with add x0, x0, #1 then b _parse, and nothing follows. What kind is it?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: It makes a tail call to parse.

    b writes no link, so x30 still holds the return address of whoever called this function, and parse's ret goes back there. That is the tail call clang wrote for tail().

  9. Question 9 of 12 Which change to long tail(long x) { return g(x + 1); } stops clang from making the call a jump?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: return g(x + 1) * 2;

    A call can become a jump only when nothing is left to do after it. Any use of its result before returning, such as the + 1 in not_tail, keeps it a real call with a saved link register.

  10. Question 10 of 12 How far from itself can an AArch64 bl reach?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: 128 MiB either way

    bl holds a 26-bit offset in units of 4 bytes, so it reaches 128 MiB in each direction; Arm's ELF document names the same bound for its R_AARCH64_CALL26 relocation.

  11. Question 11 of 12 A RISC-V call's target ends up 3 MiB away after linking. What does the linker leave in the program?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: The auipc and jalr pair, with the distance filled in

    The assembler writes the long form, which reaches about 2 GiB, and lets the linker shorten it to jal when the target is within 1 MiB. At 3 MiB nothing is shortened.

  12. Question 12 of 12 An AArch64 program's code grows to 300 MiB. Which of its calls might the linker route through a veneer?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Calls whose target is more than 128 MiB away

    A veneer is needed per call, when that call's target lies outside the 128 MiB a bl reaches. Such calls may change x16 and x17 on the way.

Interview questions

Where do x86-64 and AArch64 keep the return address after a call, and what does that change for a leaf function?

x86-64’s call pushes the return address onto the stack, so at a function’s first instruction it is the word at [rsp], and ret pops it into rip. AArch64’s bl writes it into x30, the link register, and ret branches to x30; RISC-V’s jal does the same with ra. For a leaf, which calls nothing, the register version means no memory traffic for the return at all: leaf() at -O2 on AArch64 and RISC-V had no stack instructions, only its arithmetic and ret. An x86-64 leaf always has its return address in memory, because the call put it there, yet it needs no save either, since nothing else will overwrite that slot while the leaf runs. The difference shows once a function makes calls: then AArch64 and RISC-V code must save x30 or ra first, while x86-64 code must leave rsp as it found it.

The next two take the same mechanics into a debugging session: a routine whose new exit path never comes back, and a crash report that seems to skip a function.

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

  1. Mid to senior

    Your AArch64 routine saves x29 and x30 at entry and restores them before its last ret. A colleague adds an early exit just after the routine's first bl: cbz x0, Lout, where Lout: holds a bare ret. Calls that take that exit now never return. Explain exactly where they go, and fix the routine.

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

  2. Mid to senior

    A crash report shows parse_header called directly by handle_request, yet in the source handle_request calls validate, which ends with return parse_header(buf);. validate lives in another file and is not inlined. What did the compiler do, how would you prove it from the binary, and how would you get validate into such reports?

    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. Where does a call put the return address on each ISA?

    x86-64 call pushes it onto the stack. AArch64 bl and blr write it to x30, and RISC-V jal and call write it to ra. ret returns through the same place.

  2. Why must an AArch64 or RISC-V function that calls another save its link register first?

    Its own call overwrites x30 or ra with a new return address. Without a saved copy, the function's final ret goes back to the wrong place; the lesson's broken greet returned to its own ret and never came back.

  3. x86-64 calls never overwrite the caller's return address. What can still break the return?

    ret pops whatever word is on top of the stack. Every push and every sub from rsp must be undone before ret, or it jumps to whatever value was left there.

  4. What does a leaf function at -O2 need on the stack?

    Often nothing at all. Nothing overwrites its link register while it runs, so AArch64 and RISC-V leaves need no save and no stack instructions; an x86-64 leaf already has its return address on the stack and leaves it there.

  5. What is a tail call, and how is it compiled?

    A call that is the last thing a function does. It becomes a plain jump (b, jmp or RISC-V tail), so the callee returns straight to the original caller and no frame is kept for the middle function.

  6. How far can a call reach, and what do linkers do about distant targets?

    About 1 MiB for jal, 128 MiB for bl and about 2 GiB for an x86-64 call or a RISC-V auipc and jalr pair. A RISC-V linker shrinks a pair whose target is near into one jal; an AArch64 linker sends a bl that cannot reach through a veneer that may change x16 and x17.

Key takeaways

  • A call writes the address of the instruction after it and jumps: x86-64’s call pushes it onto the stack, AArch64’s bl and blr write it to x30, and RISC-V’s jal and call write it to ra, and ret returns through the same place.
  • A function that calls another must store x30 or ra in its own stack slot before the first call and load it back before ret; greet_broken skipped that, so its second bl left x30 pointing at its own ret and it never returned.
  • x86-64 calls cannot overwrite a caller’s return address, because each one pushes its own, but ret pops whatever word is on top, so every push and every sub rsp has to be undone before it.
  • At -O2 the compilers gave the leaf no stack, saved the link register only in the function with work after its call, and turned the call that ended tail into a plain jump: b, jmp or tail.
  • A call reaches as far as its offset field allows; LLD relaxed three near RISC-V call pairs into single jal instructions and sent a bl to a target about 510 MiB away through a veneer that uses x16.

Terms in this lesson

leaf function
A function that makes no calls of its own. Nothing overwrites its link register while it runs, so it needs no save of x30 or ra, and at -O2 compilers often give it no stack space at all.
linker relaxation
A link-time rewrite that replaces a code sequence with a shorter one once the final addresses are known, such as a RISC-V auipc and jalr pair turned into one jal when the target is within about 1 MiB. An R_RISCV_RELAX relocation marks each place where it is allowed.
return address
The address a call records so that the callee can come back: the address of the instruction right after the call. x86-64's call pushes it onto the stack; AArch64's bl and blr and RISC-V's jal and call write it to x30 or ra.
tail call
A call that is the last thing a function does, compiled as a plain jump (b, jmp or RISC-V's tail) instead of a call and a return. The caller's return address stays in place, so the callee returns straight to the caller's caller.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.