Assembly (x86-64, AArch64, RISC-V) Module 6 – Branches, loops and jump tables
Switch statements and jump tables
How a dense switch becomes a bounds check and one indexed jump on x86-64, AArch64 and RISC-V, and why CET, BTI and Zicfilp guard that jump.
What you will learn
- Implement a dense switch as a bounds check plus one indexed indirect jump
- Read a compiler-generated jump table on x86-64, AArch64 and RISC-V
- Explain why an indirect jump needs landing pads under CET, BTI and Zicfilp
- Identify a sparse switch the compiler lowered to a tree of compares
Before you start
What you’ll learn · Before you start
What you will learn
- Implement a dense switch as a bounds check plus one indexed indirect jump
- Read a compiler-generated jump table on x86-64, AArch64 and RISC-V
- Explain why an indirect jump needs landing pads under CET, BTI and Zicfilp
- Identify a sparse switch the compiler lowered to a tree of compares
Before you start
On this page
A switch over sixteen case values that run 0, 1, 2 … 15 does not compare sixteen times. The compiler puts the
sixteen landing addresses in a table, checks the value against the table’s length once, and jumps through the entry it
reads: on x86-64 that table is 64 bytes, four per case, and on AArch64 the same sixteen cases need 16 bytes, one byte
each. A switch this dense lowers this way on all three ISAs.
That makes jump tables worth recognising on sight in a disassembly: a bounds check, a table address, one load, one add, one indirect branch, then a run of short blocks that each end in a return. This lesson compiles one sixteen-case switch for all three ISAs and reads every dispatch, shows what the compiler builds instead when the case values are spread out, names the landing-pad marks that Intel CET, Arm BTI and RISC-V Zicfilp put around the indirect jump, and ends with a four-case table written by hand that assembles and runs.
From a switch to a table of addresses
Here is the switch the whole lesson compiles. Sixteen cases, dense from 0, and each case computes something of its own, so the compiler cannot collapse them into one table of results: every case needs a block of code.
/* classify.c: one dense switch over sixteen kinds.
*
* classify(kind, value) applies the rule of one field kind to a raw value, the way a small
* bytecode interpreter applies one of its opcodes to the accumulator. The case values run
* 0, 1, 2 ... 15 with no gaps, and each case computes something different, so the compiler
* cannot fold the switch into a table of constants: every case needs a block of its own.
* At -O2 that is what makes the lowering a jump table.
*/
int classify(int kind, int value)
{
switch (kind) {
case 0:
return value + 5;
case 1:
return value * 3;
case 2:
return value - 7;
case 3:
return value / 2;
case 4:
return value ^ 9;
case 5:
return value << 2;
case 6:
return value >> 1;
case 7:
return value * value;
case 8:
return value | 16;
case 9:
return value & 31;
case 10:
return value % 7;
case 11:
return value * 11 + 4;
case 12:
return value - value / 3;
case 13:
return value + (value >> 2);
case 14:
return value * 5 - 1;
case 15:
return ~value + 2;
default:
return -1;
}
} The lowering is the same five parts everywhere. First the bounds check: A compare that proves an index is inside the range of the thing it indexes before the index is used. One unsigned compare covers both ends, because a negative value read as unsigned is larger than any valid index.: one unsigned
compare of the switch value against the highest case, which sends anything outside the range to the default block.
An unsigned compare is what makes one instruction enough, because a negative value read as unsigned is a very large
number and fails the same test. Then the value becomes an index, the index reads one entry of the
jump table: A table of addresses, or of 32-bit or smaller offsets, indexed by a value, which turns a dense switch into one indirect jump instead of a chain of compares. Compilers keep it in read-only data., the entry is added to a base address, and one indirect branch goes there.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Check the range once. One unsigned compare against the highest case value decides whether this call belongs in the table at all. A negative value read as unsigned is huge, so it fails the same test and goes to the default block.
- The value is the index. Past the check the value needs no translation, because case 0 is entry 0 and case 15 is entry 15. Nothing is compared again after this point.
- Read one entry. One load reads the entry for that index. AArch64 reads a single byte with ldrb, x86-64 a signed 32-bit value with movslq, RISC-V a word with lw after shifting the index left by two.
- Add the base back. The entry is a distance, so it means nothing alone. x86-64 and RISC-V add the table's own address back; AArch64 adds the address of the first landing, after multiplying the byte by four.
- One branch for any case. The indirect branch goes to the address just computed. Sixteen cases, one branch instruction, and the same work whichever case it was.
| Part | x86-64 | AArch64 | RISC-V |
|---|---|---|---|
| Bounds check | cmpl $0xf, %edi then ja |
cmp w0, #15 then b.hi |
li a2, 15 then bltu |
| Table address | leaq .LJTI0_0(%rip), %rdx |
adrp/add |
auipc/addi |
| Read one entry | movslq (%rdx,%rcx,4), %rcx |
ldrb w11, [x9, x8] |
slli, add, lw |
| Add the base | addq %rdx, %rcx |
add x10, x10, x11, lsl #2 |
add a0, a0, a2 |
| Indirect branch | jmpq *%rcx |
br x10 |
jr a0 |
The table itself lives in read-only data, beside string literals: .rodata on ELF, __TEXT,__const on Mach-O. It is
plain data that the code indexes, which is why a wrong index is so dangerous and why the bounds check is never
optional.
Checkpoint
Check: The five parts of a table dispatch
Reading a compiler-generated table on three ISAs
This session compiles classify.c for AArch64 with Apple’s clang, prints the dispatch, prints the sixteen table
entries, then builds the switch with a driver and runs it for kinds -1 to 16. The two kinds outside the range both
return -1, through the one bounds check.
"""Compile the sixteen-case switch for AArch64, read its jump table, then build and run it.
Apple clang compiles, assembles, links and runs AArch64 Mach-O code on an Apple silicon Mac,
so the dispatch on the page and the eighteen results below come from the same build.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
# Compile classify.c to AArch64 assembly at -O2.
clang -O2 -S classify.c -o classify.s
# The dispatch: one bounds check, the table's address, one entry, one indirect branch.
sed -n '/cmp.*w0, #15/,/br.*x10/p' classify.s
# The table: sixteen one-byte entries, each a landing's distance from the first one, in instructions.
sed -n '/lJTI0_0:/,/End function/p' classify.s
# Build the switch with a driver that calls it for kinds -1 to 16, and run it.
clang -O2 -o classify driver.c classify.c
./classify
"""
) Output
# Compile classify.c to AArch64 assembly at -O2.
$ clang -O2 -S classify.c -o classify.s
# The dispatch: one bounds check, the table's address, one entry, one indirect branch.
$ sed -n '/cmp.*w0, #15/,/br.*x10/p' classify.s
cmp w0, #15
b.hi LBB0_18
; %bb.1:
mov w8, w0
Lloh0:
adrp x9, lJTI0_0@PAGE
Lloh1:
add x9, x9, lJTI0_0@PAGEOFF
adr x10, LBB0_2
ldrb w11, [x9, x8]
add x10, x10, x11, lsl #2
br x10
# The table: sixteen one-byte entries, each a landing's distance from the first one, in instructions.
$ sed -n '/lJTI0_0:/,/End function/p' classify.s
lJTI0_0:
.byte (LBB0_2-LBB0_2)>>2
.byte (LBB0_12-LBB0_2)>>2
.byte (LBB0_8-LBB0_2)>>2
.byte (LBB0_10-LBB0_2)>>2
.byte (LBB0_5-LBB0_2)>>2
.byte (LBB0_13-LBB0_2)>>2
.byte (LBB0_15-LBB0_2)>>2
.byte (LBB0_11-LBB0_2)>>2
.byte (LBB0_17-LBB0_2)>>2
.byte (LBB0_7-LBB0_2)>>2
.byte (LBB0_16-LBB0_2)>>2
.byte (LBB0_4-LBB0_2)>>2
.byte (LBB0_6-LBB0_2)>>2
.byte (LBB0_14-LBB0_2)>>2
.byte (LBB0_3-LBB0_2)>>2
.byte (LBB0_9-LBB0_2)>>2
; -- End function
# Build the switch with a driver that calls it for kinds -1 to 16, and run it.
$ clang -O2 -o classify driver.c classify.c
$ ./classify
value = 12
kind -1 -> -1
kind 0 -> 17
kind 1 -> 36
kind 2 -> 5
kind 3 -> 6
kind 4 -> 5
kind 5 -> 48
kind 6 -> 6
kind 7 -> 144
kind 8 -> 28
kind 9 -> 12
kind 10 -> 5
kind 11 -> 136
kind 12 -> 8
kind 13 -> 15
kind 14 -> 59
kind 15 -> -11
kind 16 -> -1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 classify_session.py
Three details in that recording are worth naming. The entries are written as (LBB0_12-LBB0_2)>>2: a landing’s
distance from the first landing, in bytes, divided by four. Every AArch64 instruction is four bytes, so the division
loses nothing, and sixteen entries fit in sixteen bytes. The ldrb reads one of them, add x10, x10, x11, lsl #2
multiplies by four again and adds the first landing’s address, and br x10 branches. The table’s own address comes
from adrp plus add, the position-independent code: Code that reaches its data and other code relative to the program counter rather than by absolute address, so the loader may place it at any base address. Formed with RIP-relative operands on x86-64, adrp/add on AArch64 and auipc/addi on RISC-V. More in “Static data and PC-relative addresses” pair the previous module
used for static data.
.build_version macos, 26, 0 sdk_version 27, 0
.section __TEXT,__text,regular,pure_instructions
.globl _classify ; -- Begin function classify
.p2align 2
_classify: ; @classify
.cfi_startproc
; %bb.0:
cmp w0, #15
b.hi LBB0_18
; %bb.1:
mov w8, w0
Lloh0:
adrp x9, lJTI0_0@PAGE
Lloh1:
add x9, x9, lJTI0_0@PAGEOFF
adr x10, LBB0_2
ldrb w11, [x9, x8]
add x10, x10, x11, lsl #2
br x10
LBB0_2:
add w0, w1, #5
ret
LBB0_3:
add w8, w1, w1, lsl #2
sub w0, w8, #1
ret
LBB0_4:
mov w8, #11 ; =0xb
mov w9, #4 ; =0x4
madd w0, w1, w8, w9
ret
LBB0_5:
mov w8, #9 ; =0x9
eor w0, w1, w8
ret
LBB0_6:
mov w8, #1431655765 ; =0x55555555
smull x8, w1, w8
lsr x8, x8, #32
sub w8, w8, w1
asr w9, w8, #1
add w8, w9, w8, lsr #31
add w0, w8, w1
ret
LBB0_7:
and w0, w1, #0x1f
ret
LBB0_8:
sub w0, w1, #7
ret
LBB0_9:
mov w8, #1 ; =0x1
sub w0, w8, w1
ret
LBB0_10:
add w8, w1, w1, lsr #31
asr w0, w8, #1
ret
LBB0_11:
mul w0, w1, w1
ret
LBB0_12:
add w0, w1, w1, lsl #1
ret
LBB0_13:
lsl w0, w1, #2
ret
LBB0_14:
add w0, w1, w1, asr #2
ret
LBB0_15:
asr w0, w1, #1
ret
LBB0_16:
mov w8, #9363 ; =0x2493
movk w8, #37449, lsl #16
smull x8, w1, w8
lsr x8, x8, #32
add w8, w8, w1
asr w9, w8, #2
add w8, w9, w8, lsr #31
sub w8, w8, w8, lsl #3
add w0, w1, w8
ret
LBB0_17:
orr w0, w1, #0x10
ret
LBB0_18:
mov w0, #-1 ; =0xffffffff
ret
.loh AdrpAdd Lloh0, Lloh1
.cfi_endproc
.section __TEXT,__const
lJTI0_0:
.byte (LBB0_2-LBB0_2)>>2
.byte (LBB0_12-LBB0_2)>>2
.byte (LBB0_8-LBB0_2)>>2
.byte (LBB0_10-LBB0_2)>>2
.byte (LBB0_5-LBB0_2)>>2
.byte (LBB0_13-LBB0_2)>>2
.byte (LBB0_15-LBB0_2)>>2
.byte (LBB0_11-LBB0_2)>>2
.byte (LBB0_17-LBB0_2)>>2
.byte (LBB0_7-LBB0_2)>>2
.byte (LBB0_16-LBB0_2)>>2
.byte (LBB0_4-LBB0_2)>>2
.byte (LBB0_6-LBB0_2)>>2
.byte (LBB0_14-LBB0_2)>>2
.byte (LBB0_3-LBB0_2)>>2
.byte (LBB0_9-LBB0_2)>>2
; -- End function
.subsections_via_symbols The same source cross-assembled for Linux shows the other two shapes. x86-64 loads a 32-bit signed entry with
movslq and scales the index by four in the addressing mode itself; RISC-V shifts the index left by two, adds the
table base and reads the entry with lw. The section: A named region of an object or executable file, such as .text, .rodata, .data or .bss. The linker merges matching sections across objects and the loader groups them into segments at run time. More in “From source to executable: objects and linking” listing in the recording gives the
sizes: .rodata is 0x40 bytes for sixteen four-byte entries, and .rela.rodata holds one
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” per entry.
"""Cross-assemble the same switch for x86-64 Linux and RISC-V Linux, and read both dispatches.
LLVM's clang compiles the switch for both targets and llvm-objdump disassembles the objects it
writes. The session reads each dispatch and the size the table takes in each object; the ELF
programs themselves are built for Linux.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
# Assemble the switch for x86-64 Linux and disassemble its dispatch, bytes and all.
/opt/homebrew/opt/llvm/bin/clang -O2 -c -target x86_64-linux-gnu classify.c -o classify_x86_64.o
/opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr classify_x86_64.o | sed -n '5,14p'
# The table sits in .rodata: 0x40 bytes, four per entry, with one relocation per entry.
/opt/homebrew/opt/llvm/bin/llvm-objdump -h classify_x86_64.o | sed -n '/Idx/,$p' | sed -n '1,7p'
/opt/homebrew/opt/llvm/bin/llvm-readobj -r classify_x86_64.o | sed -n '/rela.rodata/,/0x10 /p'
# The same source for RISC-V: auipc and addi build the table's address, lw reads one entry.
/opt/homebrew/opt/llvm/bin/clang -O2 -c -target riscv64-linux-gnu classify.c -o classify_riscv64.o
/opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr classify_riscv64.o | sed -n '5,17p'
"""
) Output
# Assemble the switch for x86-64 Linux and disassemble its dispatch, bytes and all.
$ /opt/homebrew/opt/llvm/bin/clang -O2 -c -target x86_64-linux-gnu classify.c -o classify_x86_64.o
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr classify_x86_64.o | sed -n '5,14p'
<classify>:
83 ff 0f cmpl $0xf, %edi
0f 87 aa 00 00 00 ja 0xb3 <classify+0xb3>
89 f0 movl %esi, %eax
89 f9 movl %edi, %ecx
48 8d 15 00 00 00 00 leaq (%rip), %rdx # 0x14 <classify+0x14>
48 63 0c 8a movslq (%rdx,%rcx,4), %rcx
48 01 d1 addq %rdx, %rcx
ff e1 jmpq *%rcx
# The table sits in .rodata: 0x40 bytes, four per entry, with one relocation per entry.
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -h classify_x86_64.o | sed -n '/Idx/,$p' | sed -n '1,7p'
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .strtab 00000073 0000000000000000
2 .text 000000b9 0000000000000000 TEXT
3 .rela.text 00000018 0000000000000000
4 .rodata 00000040 0000000000000000 DATA
5 .rela.rodata 00000180 0000000000000000
$ /opt/homebrew/opt/llvm/bin/llvm-readobj -r classify_x86_64.o | sed -n '/rela.rodata/,/0x10 /p'
Section (5) .rela.rodata {
0x0 R_X86_64_PC32 .text 0x1D
0x4 R_X86_64_PC32 .text 0x76
0x8 R_X86_64_PC32 .text 0x5C
0xC R_X86_64_PC32 .text 0x6E
0x10 R_X86_64_PC32 .text 0x41
# The same source for RISC-V: auipc and addi build the table's address, lw reads one entry.
$ /opt/homebrew/opt/llvm/bin/clang -O2 -c -target riscv64-linux-gnu classify.c -o classify_riscv64.o
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr classify_riscv64.o | sed -n '5,17p'
<classify>:
463d li a2, 0xf
00a66063 bltu a2, a0, 0x2 <classify+0x2>
<.Lpcrel_hi0>:
00000617 auipc a2, 0x0
050a slli a0, a0, 0x2
00060613 mv a2, a2
9532 add a0, a0, a2
4108 lw a0, 0x0(a0)
9532 add a0, a0, a2
8502 jr a0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cross_session.py
.att_syntax
.file "classify.c"
.text
.globl classify # -- Begin function classify
.prefalign 4, .Lfunc_end0, nop
.type classify,@function
classify: # @classify
# %bb.0:
cmpl $15, %edi
ja .LBB0_20
# %bb.1:
movl %esi, %eax
movl %edi, %ecx
leaq .LJTI0_0(%rip), %rdx
movslq (%rdx,%rcx,4), %rcx
addq %rdx, %rcx
jmpq *%rcx
.LBB0_2:
addl $5, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_3:
leal (%rax,%rax,4), %eax
decl %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_4:
leal (%rax,%rax,4), %ecx
leal (%rax,%rcx,2), %eax
addl $4, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_5:
xorl $9, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_6:
movslq %eax, %rcx
imulq $1431655765, %rcx, %rcx # imm = 0x55555555
shrq $32, %rcx
subl %eax, %ecx
movl %ecx, %edx
shrl $31, %edx
sarl %ecx
addl %edx, %ecx
jmp .LBB0_18
.LBB0_7:
andl $31, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_8:
addl $-7, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_9:
movl $1, %ecx
subl %eax, %ecx
movl %ecx, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_11:
movl %eax, %ecx
shrl $31, %ecx
addl %eax, %ecx
sarl %ecx
movl %ecx, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_12:
imull %eax, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_13:
leal (%rax,%rax,2), %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_14:
shll $2, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_15:
movl %eax, %ecx
sarl $2, %ecx
addl %eax, %ecx
movl %ecx, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_16:
sarl %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_17:
movslq %eax, %rcx
imulq $-1840700269, %rcx, %rcx # imm = 0x92492493
shrq $32, %rcx
addl %eax, %ecx
movl %ecx, %edx
shrl $31, %edx
sarl $2, %ecx
addl %edx, %ecx
leal (,%rcx,8), %edx
subl %edx, %ecx
.LBB0_18:
addl %eax, %ecx
movl %ecx, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_19:
orl $16, %eax
# kill: def $eax killed $eax killed $rax
retq
.LBB0_20:
movl $-1, %eax
# kill: def $eax killed $eax killed $rax
retq
.Lfunc_end0:
.size classify, .Lfunc_end0-classify
.section .rodata,"a",@progbits
.p2align 2, 0x0
.LJTI0_0:
.long .LBB0_2-.LJTI0_0
.long .LBB0_13-.LJTI0_0
.long .LBB0_8-.LJTI0_0
.long .LBB0_11-.LJTI0_0
.long .LBB0_5-.LJTI0_0
.long .LBB0_14-.LJTI0_0
.long .LBB0_16-.LJTI0_0
.long .LBB0_12-.LJTI0_0
.long .LBB0_19-.LJTI0_0
.long .LBB0_7-.LJTI0_0
.long .LBB0_17-.LJTI0_0
.long .LBB0_4-.LJTI0_0
.long .LBB0_6-.LJTI0_0
.long .LBB0_15-.LJTI0_0
.long .LBB0_3-.LJTI0_0
.long .LBB0_9-.LJTI0_0
# -- End function
.ident "Homebrew clang version 23.1.2"
.section ".note.GNU-stack","",@progbits
.addrsig .attribute 4, 16
.attribute 5, "rv64i2p1_m2p0_a2p1_f2p2_d2p2_c2p0_zicsr2p0_zmmul1p0_zaamo1p0_zalrsc1p0_zca1p0_zcd1p0"
.file "classify.c"
.text
.globl classify # -- Begin function classify
.p2align 1
.type classify,@function
classify: # @classify
# %bb.0:
li a2, 15
bltu a2, a0, .LBB0_18
# %bb.1:
.Lpcrel_hi0:
auipc a2, %pcrel_hi(.LJTI0_0)
slli a0, a0, 2
addi a2, a2, %pcrel_lo(.Lpcrel_hi0)
add a0, a0, a2
lw a0, 0(a0)
add a0, a0, a2
jr a0
.LBB0_2:
addiw a0, a1, 5
ret
.LBB0_3:
slli a0, a1, 2
add a0, a0, a1
addiw a0, a0, -1
ret
.LBB0_4:
li a0, 11
mul a0, a1, a0
addiw a0, a0, 4
ret
.LBB0_5:
xori a0, a1, 9
ret
.LBB0_6:
lui a0, 349525
addi a0, a0, 1365
mul a0, a1, a0
srli a0, a0, 32
sub a0, a0, a1
srliw a2, a0, 31
sraiw a0, a0, 1
add a0, a0, a2
addw a0, a0, a1
ret
.LBB0_7:
andi a0, a1, 31
ret
.LBB0_8:
addiw a0, a1, -7
ret
.LBB0_9:
li a0, 1
subw a0, a0, a1
ret
.LBB0_10:
srliw a0, a1, 31
add a0, a0, a1
sraiw a0, a0, 1
ret
.LBB0_11:
mulw a0, a1, a1
ret
.LBB0_12:
slli a0, a1, 1
addw a0, a0, a1
ret
.LBB0_13:
slliw a0, a1, 2
ret
.LBB0_14:
srli a0, a1, 2
addw a0, a0, a1
ret
.LBB0_15:
srai a0, a1, 1
ret
.LBB0_16:
lui a0, 599186
addi a0, a0, 1171
mul a0, a1, a0
srli a0, a0, 32
add a0, a0, a1
srliw a2, a0, 31
sraiw a0, a0, 2
add a0, a0, a2
slli a2, a0, 3
add a0, a0, a1
subw a0, a0, a2
ret
.LBB0_17:
ori a0, a1, 16
ret
.LBB0_18:
li a0, -1
ret
.Lfunc_end0:
.size classify, .Lfunc_end0-classify
.section .rodata,"a",@progbits
.p2align 2, 0x0
.LJTI0_0:
.word .LBB0_2-.LJTI0_0
.word .LBB0_12-.LJTI0_0
.word .LBB0_8-.LJTI0_0
.word .LBB0_10-.LJTI0_0
.word .LBB0_5-.LJTI0_0
.word .LBB0_13-.LJTI0_0
.word .LBB0_15-.LJTI0_0
.word .LBB0_11-.LJTI0_0
.word .LBB0_17-.LJTI0_0
.word .LBB0_7-.LJTI0_0
.word .LBB0_16-.LJTI0_0
.word .LBB0_4-.LJTI0_0
.word .LBB0_6-.LJTI0_0
.word .LBB0_14-.LJTI0_0
.word .LBB0_3-.LJTI0_0
.word .LBB0_9-.LJTI0_0
# -- End function
.ident "Homebrew clang version 23.1.2"
.section ".note.GNU-stack","",@progbits
.addrsig Key idea
Checkpoint
Check: Reading a generated table
Why the entries are 32-bit offsets, not addresses
A table of 64-bit addresses would be twice the size and, worse, would hold absolute addresses. An executable that the loader may place anywhere cannot carry absolute addresses in its data without the loader patching every one of them at start-up, so the compiler stores differences instead: each entry is the distance from a fixed point that moves with the code. On x86-64 and RISC-V the fixed point is the table’s own start, which is why the recording shows the base being added back after the load; on AArch64 it is the first landing.
A difference between two places in the same image does not change when the image moves, so the linker computes each
entry once and nothing is left for the loader. That is what the relocations in the recording are: sixteen
R_X86_64_PC32 entries in .rela.rodata, each asking the linker, not the loader, for one 32-bit difference.
| Property | A table of addresses | A table of 32-bit offsets |
|---|---|---|
| Bytes per entry | 8 | 4, or 1 on AArch64 |
| Patched at start-up | yes, one relocation each | no |
| Lives in | data the loader writes | read-only data |
| Reach | anywhere | ±2 GB from the base |
What offsets cost is reach: a 32-bit signed difference spans about 2 GB in each direction, and AArch64’s entries reach
only 255 instructions past the first landing. The compiler knows the sizes of the blocks it just emitted, so it picks
the narrowest entry that fits and widens to ldrh or a word when the cases are longer.
Checkpoint
Check: Entries are offsets, not addresses
Dense or sparse: when the compiler picks a compare tree
A table is indexed by the switch value, so its size is set by the range of the case values, not by how many there are. Sixteen cases from 0 to 15 need sixteen entries. Sixteen cases spread from 1 to 60,000 would need 60,000 entries for the same sixteen landings, which is why the compiler drops the table and compares instead. This session measures both, and then the same six dense cases on two targets.
/* sparse.c: sixteen cases again, but spread out instead of dense.
*
* The case values are the same count as classify.c's, yet they run from 1 to 60,000.
* A table indexed by the switch value would need 60,000 entries for sixteen landings,
* so the compiler drops the table and compares instead.
*/
int sparse(int kind, int value)
{
switch (kind) {
case 1:
return value + 5;
case 7:
return value * 3;
case 19:
return value - 7;
case 42:
return value / 2;
case 97:
return value ^ 9;
case 203:
return value << 2;
case 460:
return value >> 1;
case 911:
return value * value;
case 1700:
return value | 16;
case 3300:
return value & 31;
case 6100:
return value % 7;
case 9800:
return value * 11 + 4;
case 14500:
return value - value / 3;
case 23000:
return value + (value >> 2);
case 38000:
return value * 5 - 1;
case 60000:
return ~value + 2;
default:
return -1;
}
} """Compile a sparse switch and a six-case switch, and count what the compiler chose instead of a table.
Three measurements: sixteen spread-out cases on x86-64 (compares, no table), the same six dense cases
on x86-64 and on AArch64 (a table on one, none on the other), and the AArch64 switch again with the
compiler's table threshold lowered, which brings the table back.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
# Sixteen cases spread from 1 to 60,000: how many compares, and how many table entries?
/opt/homebrew/opt/llvm/bin/clang -O2 -S -target x86_64-linux-gnu sparse.c -o sparse_x86_64.s
printf 'cmpl: '; grep -cE 'cmpl' sparse_x86_64.s; printf 'table entries: '; grep -cE '[.]long[[:space:]]+[.]LBB' sparse_x86_64.s || true
# Six dense cases, x86-64: a six-entry table of four-byte offsets, 24 bytes in all.
/opt/homebrew/opt/llvm/bin/clang -O2 -S -target x86_64-linux-gnu six.c -o six_x86_64.s
printf 'table entries: '; grep -cE '[.]long[[:space:]]+[.]LBB' six_x86_64.s
# The same six cases for AArch64: the compiler picks conditional selects, with no branch at all.
clang -O2 -S six.c -o six_aarch64.s
printf 'csel: '; grep -cE 'csel' six_aarch64.s; printf 'table entries: '; grep -cE '[.]byte[[:space:]]+[(]LBB' six_aarch64.s || true
# AArch64 again with the table threshold lowered to four entries: the table is back.
clang -O2 -mllvm -aarch64-min-jump-table-entries=4 -S six.c -o six_lowered.s
printf 'table entries: '; grep -cE '[.]byte[[:space:]]+[(]LBB' six_lowered.s
"""
) Output
# Sixteen cases spread from 1 to 60,000: how many compares, and how many table entries? $ /opt/homebrew/opt/llvm/bin/clang -O2 -S -target x86_64-linux-gnu sparse.c -o sparse_x86_64.s $ printf 'cmpl: '; grep -cE 'cmpl' sparse_x86_64.s; printf 'table entries: '; grep -cE '[.]long[[:space:]]+[.]LBB' sparse_x86_64.s || true cmpl: 23 table entries: 0 # Six dense cases, x86-64: a six-entry table of four-byte offsets, 24 bytes in all. $ /opt/homebrew/opt/llvm/bin/clang -O2 -S -target x86_64-linux-gnu six.c -o six_x86_64.s $ printf 'table entries: '; grep -cE '[.]long[[:space:]]+[.]LBB' six_x86_64.s table entries: 6 # The same six cases for AArch64: the compiler picks conditional selects, with no branch at all. $ clang -O2 -S six.c -o six_aarch64.s $ printf 'csel: '; grep -cE 'csel' six_aarch64.s; printf 'table entries: '; grep -cE '[.]byte[[:space:]]+[(]LBB' six_aarch64.s || true csel: 7 table entries: 0 # AArch64 again with the table threshold lowered to four entries: the table is back. $ clang -O2 -mllvm -aarch64-min-jump-table-entries=4 -S six.c -o six_lowered.s $ printf 'table entries: '; grep -cE '[.]byte[[:space:]]+[(]LBB' six_lowered.s table entries: 6
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 density_session.py
The sparse switch produced 23 cmpl instructions and no table at all: a tree of compares that splits the range,
roughly four or five tests to reach a landing. The six-case switch is the interesting one. On x86-64 clang built a
six-entry table, 24 bytes. On AArch64 it emitted seven csel instructions and no branch at all, because the compiler
used for this lesson keeps a table only from ten cases up on that target, the default that LLVM’s AArch64 back end
sets for a build that does not optimise for size; lowering that limit to four with
-mllvm -aarch64-min-jump-table-entries=4 brings a six-entry table back from the same source.
GCC exposes the same decision as a tunable parameter, case-values-threshold, documented as the smallest count of
distinct values that makes a table better than a tree of conditional branches, with zero meaning the target’s own
default. So “dense switch statements become jump tables” is a rule about the compiler’s judgement, not about the
language: the same source is a table, a tree or a run of conditional selects depending on the target and the flags.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Lowering | Dense values | Values spread out | Work per call | Under CFI |
|---|---|---|---|---|
| Jump table Reads: one entry of a table | Good sixteen dense cases cost 16 bytes of table on AArch64, 64 on x86-64 | Poor values 1 to 60,000 would need 60,000 entries for sixteen landings | Good one check, one load, one add, one branch, whatever the case | Mixed needs a no-track prefix, marked landings or a guarded register |
| Tree of compares Reads: the value, repeatedly | Mixed works, but 23 compares stood where one indexed jump would do | Good the only shape that ignores the range of the values | Mixed about four or five tests for sixteen cases, each one predicted | Good direct branches only, so no landing pad is involved |
| Conditional selects Reads: the value, branchlessly | Mixed the AArch64 build used seven csel for six dense cases | Poor every case is computed, so spread-out values multiply the work | Mixed every case computed, no branch at all, no misprediction | Good no indirect branch, so nothing to guard |
| Table of results Reads: one entry of a table | Good one load and no branch when every case returns a constant | Poor indexed by the value, so the same holes as a jump table | Good one load; the cheapest shape when it applies | Good plain data load, no indirect branch to guard |
How the answers decide
- “Do your case values run from 0 with no gaps, or are they spread far apart?” Spread out: rules out Jump table and Table of results. Both are indexed by the switch value, so the range of the values sets the table's size.
- “Does each case run its own code, or does each one just produce a value?” A value only: adds a caveat to Jump table. With a value per case the compiler prefers a table of results and skips the indirect branch.
- “Does each case run its own code, or does each one just produce a value?” Its own code: rules out Table of results. A table of results can only hold values, so cases with their own code need a landing each.
- “Do your case values run from 0 with no gaps, or are they spread far apart?” Dense, 0 upwards and “Does each case run its own code, or does each one just produce a value?” Its own code: adds a caveat to Conditional selects. Selects compute every case, so they fit a handful of cheap cases, not sixteen blocks.
Counting cases instead of the range
Reading sixteen cases and expecting a table misses the point twice over: the error codes 2, 404 and 60,000 are sixteen cases with a 60,000-wide range, so the table never appears, and renumbering them 0 to 15 can change a hot dispatch from five compares to one indexed jump. Read the case values, not their count.
Checkpoint
Check: Dense values, sparse values
Control-flow integrity: endbr64, BTI and lpad
An indirect branch is the one place where the processor takes its next address from data. If an attacker can write that data, or can reach the branch with a value of their choosing, the branch goes wherever they like, which is why all three ISAs now have a hardware check for it. control-flow integrity: A defence that limits where a program's control transfers may go: the compiler marks the legal targets of indirect branches, calls and returns, and the processor or the run time faults on a transfer to anything else. marks the legal targets, and the processor faults on a branch to anything else.
The marks are per ISA, and the first session shows the x86-64 one: the same source built with -fcf-protection=none
and with -fcf-protection=full. GCC documents those keywords, branch for indirect branches and return for
returns, with full as the pair.
"""Build the switch for x86-64 Linux twice, with Control-flow Enforcement off and on.
The two disassemblies differ in two bytes' worth of marks: an endbr64 at the function's entry, and a
0x3e prefix on the table's indirect jump. Counting the endbr64 markers shows how many legal landings
for an indirect branch the object declares.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
# Without CET marks: the function starts straight with its bounds check.
/opt/homebrew/opt/llvm/bin/clang -O2 -c -target x86_64-linux-gnu -fcf-protection=none classify.c -o cet_off.o
/opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr cet_off.o | sed -n '6,8p'
# With CET: endbr64 at the entry, and the table's jump carries the 3e no-track prefix.
/opt/homebrew/opt/llvm/bin/clang -O2 -c -target x86_64-linux-gnu -fcf-protection=full classify.c -o cet_on.o
/opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr cet_on.o | sed -n '6,15p'
# How many endbr64 markers each object declares, for sixteen landings plus the entry.
for f in cet_off.o cet_on.o; do printf '%s: ' $f; /opt/homebrew/opt/llvm/bin/llvm-objdump -d $f | grep -c endbr64; done
"""
) Output
# Without CET marks: the function starts straight with its bounds check. $ /opt/homebrew/opt/llvm/bin/clang -O2 -c -target x86_64-linux-gnu -fcf-protection=none classify.c -o cet_off.o $ /opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr cet_off.o | sed -n '6,8p' <classify>: 83 ff 0f cmpl $0xf, %edi 0f 87 aa 00 00 00 ja 0xb3 <classify+0xb3> # With CET: endbr64 at the entry, and the table's jump carries the 3e no-track prefix. $ /opt/homebrew/opt/llvm/bin/clang -O2 -c -target x86_64-linux-gnu -fcf-protection=full classify.c -o cet_on.o $ /opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr cet_on.o | sed -n '6,15p' <classify>: f3 0f 1e fa endbr64 83 ff 0f cmpl $0xf, %edi 0f 87 ab 00 00 00 ja 0xb8 <classify+0xb8> 89 f0 movl %esi, %eax 89 f9 movl %edi, %ecx 48 8d 15 00 00 00 00 leaq (%rip), %rdx # 0x18 <classify+0x18> 48 63 0c 8a movslq (%rdx,%rcx,4), %rcx 48 01 d1 addq %rdx, %rcx 3e ff e1 jmpq *%rcx # How many endbr64 markers each object declares, for sixteen landings plus the entry. $ for f in cet_off.o cet_on.o; do printf '%s: ' $f; /opt/homebrew/opt/llvm/bin/llvm-objdump -d $f | grep -c endbr64; done cet_off.o: 0 cet_on.o: 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 endbr_session.py
Count the marks in the recording: one endbr64, not seventeen. The entry of the function gets the
landing pad: An instruction that marks a legal target of an indirect branch or call, so a processor enforcing control-flow integrity accepts the transfer: endbr64 on x86-64, bti on AArch64, lpad on RISC-V. because the function can be called indirectly, and the sixteen case blocks
get none. What makes that safe is the extra byte on the jump itself, 3e ff e1 instead of ff e1: the 3e prefix
tells the processor not to track this branch, so the branch is allowed to land on an instruction that is not a
landing pad. The table is read-only and the bounds check is right above it, so the compiler takes that trade.
AArch64 does not have the prefix, so it marks every landing instead. The second session compiles the same switch with
-mbranch-protection=bti: bti c guards the function entry and bti j guards all sixteen case blocks, seventeen
marks in all. Arm’s System V ABI states the rule the marks satisfy: an indirect branch into a guarded page has to land
on a bti instruction whose kind matches the branch, and a loader learns that an image is BTI-ready from a GNU
property note.
"""Compile the same switch with AArch64 branch-target identification, then with RISC-V landing pads.
Both are compiled for Linux targets, so nothing here runs; the marks the compiler puts in the
assembly are the evidence. AArch64 marks every landing of the table; RISC-V marks only the function
entry and changes the dispatch register instead.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
# AArch64 with BTI: bti c guards the entry, bti j guards each landing of the table.
/opt/homebrew/opt/llvm/bin/clang -O2 -S -target aarch64-linux-gnu -march=armv8.5-a -mbranch-protection=bti classify.c -o bti.s
sed -n '/^classify:/,/^.LBB0_3:/p' bti.s
# One bti c and one bti j per landing: seventeen marks for sixteen cases.
printf 'bti c: '; grep -c 'bti.*c$' bti.s; printf 'bti j: '; grep -c 'bti.*j$' bti.s
# RISC-V with Zicfilp (still an experimental extension in this compiler): lpad marks the entry.
/opt/homebrew/opt/llvm/bin/clang -O2 -S -target riscv64-linux-gnu -menable-experimental-extensions -march=rv64gc_zicfilp1p0 -fcf-protection=branch -mcf-branch-label-scheme=unlabeled classify.c -o lpad.s
sed -n '/^classify:/,/jr/p' lpad.s
printf 'lpad: '; grep -c 'lpad' lpad.s
"""
) Output
# AArch64 with BTI: bti c guards the entry, bti j guards each landing of the table.
$ /opt/homebrew/opt/llvm/bin/clang -O2 -S -target aarch64-linux-gnu -march=armv8.5-a -mbranch-protection=bti classify.c -o bti.s
$ sed -n '/^classify:/,/^.LBB0_3:/p' bti.s
classify: // @classify
.cfi_startproc
// %bb.0:
bti c
cmp w0, #15
// kill: def $w1 killed $w1 def $x1
b.hi .LBB0_18
// %bb.1:
mov w8, w0
adrp x9, .LJTI0_0
add x9, x9, :lo12:.LJTI0_0
adr x10, .LBB0_2
ldrb w11, [x9, x8]
add x10, x10, x11, lsl #2
br x10
.LBB0_2:
bti j
add w0, w1, #5
ret
.LBB0_3:
# One bti c and one bti j per landing: seventeen marks for sixteen cases.
$ printf 'bti c: '; grep -c 'bti.*c$' bti.s; printf 'bti j: '; grep -c 'bti.*j$' bti.s
bti c: 1
bti j: 16
# RISC-V with Zicfilp (still an experimental extension in this compiler): lpad marks the entry.
$ /opt/homebrew/opt/llvm/bin/clang -O2 -S -target riscv64-linux-gnu -menable-experimental-extensions -march=rv64gc_zicfilp1p0 -fcf-protection=branch -mcf-branch-label-scheme=unlabeled classify.c -o lpad.s
$ sed -n '/^classify:/,/jr/p' lpad.s
classify: # @classify
.cfi_startproc
# %bb.0:
lpad 0
li a2, 15
bltu a2, a0, .LBB0_18
# %bb.1:
.Lpcrel_hi0:
auipc a2, %pcrel_hi(.LJTI0_0)
slli a0, a0, 2
addi a2, a2, %pcrel_lo(.Lpcrel_hi0)
add a0, a0, a2
lw a0, 0(a0)
add t2, a2, a0
jr t2
$ printf 'lpad: '; grep -c 'lpad' lpad.s
lpad: 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cfi_marks_session.py
RISC-V takes the third route. In the same recording the Zicfilp build has exactly one lpad, at the entry, and the
dispatch changed: the address lands in t2 and the branch is jr t2. The RISC-V manual’s control-flow-integrity
chapter calls an indirect branch through x7, which is t2, a software-guarded branch, says such branches need no
landing pad, and names compiled switch-cases as a use for them. The compiler changed one register to stay inside the
rule.
| ISA | Mark | At the function entry | At the table’s landings | How the table’s branch is allowed |
|---|---|---|---|---|
| x86-64 (CET) | endbr64 |
yes | no | 3e no-track prefix on jmpq |
| AArch64 (BTI) | bti c, bti j |
bti c |
bti j on each |
the landings are marked |
| RISC-V (Zicfilp) | lpad |
yes | no | branch through x7 is software-guarded |
Key idea
bti j on every landing satisfies the check on AArch64, and a branch through x7 is exempt on RISC-V.Checkpoint
Check: Landing pads for the indirect jump
Writing a jump table by hand
A hand-written table makes the shape concrete. This one dispatches four operations on AArch64 with the same five parts the compiler uses, and the entries are one byte each, measured from the first landing in instructions.
// handwritten_table_aarch64.s: a four-case dispatcher whose jump table is written by hand.
// AArch64, Mach-O; Apple clang assembles and links it, and the session runs it.
//
// int opcode(int op, int value);
// op 0 -> value + 1 op 1 -> value * 2
// op 2 -> value - 3 op 3 -> 0 - value anything else -> -1
.section __TEXT,__text,regular,pure_instructions
.globl _opcode
.p2align 2
_opcode:
cmp w0, #3 // the bounds check: is op one of 0..3?
b.hi Ldefault // unsigned compare, so a negative op is above 3 too
adrp x9, Ltable@PAGE // x9 = the table of one-byte entries
add x9, x9, Ltable@PAGEOFF
adr x10, Lcase0 // x10 = the first landing, which the entries measure from
ldrb w11, [x9, w0, uxtw] // w11 = Ltable[op], the distance in instructions
add x10, x10, x11, lsl #2 // every instruction is four bytes wide
br x10 // the indirect jump: one branch for any op
Lcase0:
add w0, w1, #1
ret
Lcase1:
lsl w0, w1, #1
ret
Lcase2:
sub w0, w1, #3
ret
Lcase3:
neg w0, w1
ret
Ldefault:
mov w0, #-1
ret
.section __TEXT,__const
.p2align 2
Ltable:
.byte (Lcase0-Lcase0)>>2 // 0 instructions past Lcase0
.byte (Lcase1-Lcase0)>>2 // 2 instructions past it
.byte (Lcase2-Lcase0)>>2 // 4
.byte (Lcase3-Lcase0)>>2 // 6 The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The op arrives. The caller passes op 2 in w0. The bounds check above has already sent anything outside 0 to 3 to the default block, so this value is safe to use as an index.
- Index the table of bytes. Ltable holds four bytes, 00 02 04 06, dumped from the linked binary by the session below. ldrb reads the byte at index 2.
- The entry is a distance. The byte read is 4, meaning four instructions past the first landing. It is not an address, so by itself it points nowhere.
- Add the first landing. The code holds Lcase0 in x10 from an adr instruction. Adding the entry shifted left by two turns four instructions into sixteen bytes.
- Land in the right block. br x10 arrives at Lcase2, which subtracts 3 and returns. The run below shows op 2 with value 20 giving 17.
The session assembles it with a C driver, runs it for ops -1 to 4, disassembles the dispatcher out of the linked
binary and dumps the four table bytes. They are 00 02 04 06, because each case block is two instructions long, and
the two ops outside the range both return -1.
"""Assemble, link and run the hand-written AArch64 dispatcher, then read its table out of the binary.
Apple clang assembles the hand-written source and links it with a C driver into one AArch64
program, so the four results and the four table bytes come from the same binary.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run(
"""
# Assemble the hand-written dispatcher with a C driver and run it for ops -1 to 4.
clang -O2 -o hand hand_driver.c handwritten_table_aarch64.s
./hand
# The dispatcher as the linker left it: bounds check, table address, one byte, indirect branch.
/opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr --disassemble-symbols=_opcode hand | sed -n '5,20p'
# The four table bytes: each landing's distance from the first, in instructions.
/opt/homebrew/opt/llvm/bin/llvm-objdump -s -j __const hand | sed -n '3,5p'
"""
) Output
# Assemble the hand-written dispatcher with a C driver and run it for ops -1 to 4. $ clang -O2 -o hand hand_driver.c handwritten_table_aarch64.s $ ./hand value = 20 op -1 -> -1 op 0 -> 21 op 1 -> 40 op 2 -> 17 op 3 -> -20 op 4 -> -1 # The dispatcher as the linker left it: bounds check, table address, one byte, indirect branch. $ /opt/homebrew/opt/llvm/bin/llvm-objdump -d --no-leading-addr --disassemble-symbols=_opcode hand | sed -n '5,20p' <_opcode>: 71000c1f cmp w0, #0x3 540001e8 b.hi 0x1000005d4 <_opcode+0x40> 90000009 adrp x9, 0x100000000 <_printf+0x100000000> 91181129 add x9, x9, #0x604 1000008a adr x10, 0x1000005b4 <_opcode+0x20> 3860492b ldrb w11, [x9, w0, uxtw] 8b0b094a add x10, x10, x11, lsl #2 d61f0140 br x10 11000420 add w0, w1, #0x1 d65f03c0 ret 531f7820 lsl w0, w1, #1 d65f03c0 ret 51000c20 sub w0, w1, #0x3 d65f03c0 ret # The four table bytes: each landing's distance from the first, in instructions. $ /opt/homebrew/opt/llvm/bin/llvm-objdump -s -j __const hand | sed -n '3,5p' Contents of section __TEXT,__const: 100000604 00020406 ....
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 handwritten_session.py
Two details are easy to get wrong by hand. The index must be zero-extended, not sign-extended, which is what uxtw
in ldrb w11, [x9, w0, uxtw] says; and the table has to be reached PC-relatively, through adrp plus add, or the
object will not link into a position-independent executable. Real code almost always lets the compiler build the
table: it sizes the entries, keeps them in step with the blocks it moves around during optimisation, and adds the
control-flow-integrity marks the target needs. Hand-written jump tables belong in startup code, interpreters with a
measured dispatch loop, and places with no compiler in the way.
Checkpoint
Check: A table written by hand
Practise
Two hands-on changes come first, each built and run with the commands its own session shows. The questions after them use the recordings on this page: the entry widths, the counts from the sparse build and the three control-flow-integrity marks.
- Copy
handwritten_table_aarch64.sand give it a fifth operation, op 4, that returnsvalue >> 2withasr w0, w1, #2. Three places change: the bounds check, a new case block and a fifth table byte. Predict the five bytes of the table dump and the results for op 4 and op 5, then extend the driver’s loop to op 5 and run it. - Copy
sparse.c, renumber its sixteen cases 0 to 15 and leave every case body as it is. Compile it for x86-64 as the density session does and count thecmpllines and the table entries again. Which count moved, and which part of the dispatch is the one compare that is left?
Practice that adapts to you
Practise this lesson
13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: for a dense set of cases, why is a jump table quicker than a tree of compares, and when does the tree win anyway?
A table costs the same work for every case: one bounds check, one load of an entry, one add and one indirect branch, whatever the value. A tree of compares costs about as many tests as the depth of the tree it builds, four or five conditional branches for sixteen cases, and each one is a chance for the branch predictor to be wrong. The table also keeps the code small, because the decision lives in data rather than in instructions. The tree wins in three situations. When the case values are spread out, a table indexed by the value would be mostly holes, so there is nothing to index. When there are only two or three cases, the compares are shorter than the whole dispatch, and a compiler may go further and remove the branch with conditional selects, as the AArch64 build on this page did for six cases. And when one case is far hotter than the rest, a compare for it first is predicted almost perfectly, while the table’s indirect branch has to be predicted from a history of mixed targets.
Work out your own answer to each of the two questions below, then open its first step and follow-ups on the module’s question page.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A service built with control-flow enforcement on x86-64 dies with a control-protection fault, and the faulting address is inside a plugin dispatcher that jumps through a table. What does the hardware's rule tell you about the bug, and how would you narrow it down and fix it?
-
Senior to staff
You have a stripped AArch64 binary of a bytecode interpreter whose main loop switches on 256 opcodes. Walk through how you would find the dispatcher, recover the table, and work out which block belongs to which opcode.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does a dense switch statement compile to?
One bounds check, one load from a table, one add and one indirect branch. The table lives in read-only data, next to the string literals.
-
Why are jump-table entries offsets rather than addresses, and how big are they?
An offset from a fixed point in the same image stays correct wherever the image is loaded, so the linker resolves every entry and the loader patches none. Entries are 4 bytes on x86-64 and RISC-V and can be 1 byte on AArch64, so sixteen cases cost 64, 64 and 16 bytes.
-
What decides whether a compiler builds a jump table or a tree of compares?
The range of the case values, not how many there are. Spread-out values would leave a table mostly empty, so the lesson's sparse switch became 23 compares and no table, and a target's own threshold can rule out a table even for six dense cases.
-
Why is a jump table quicker than a tree of compares for many dense cases?
The table costs the same few instructions for every value, while a tree costs one conditional branch per level, four or five for sixteen cases, and each of them can be mispredicted.
-
Which instructions mark legal targets of indirect branches under control-flow integrity?
endbr64 on x86-64, bti c and bti j on AArch64, and lpad on RISC-V. A processor enforcing the check faults when an indirect branch lands anywhere else.
-
How does each ISA let a compiler's own jump table get through the landing-pad check?
On x86-64 a no-track prefix exempts that indirect jump. On AArch64 every landing gets a bti j, which satisfies the check. On RISC-V a jump through register x7 is exempt.
Key takeaways
- A dense
switchlowers to one bounds check, one table read, one add and one indirect branch, and the table lives in read-only data beside the string literals. - Entries are distances, not addresses: four bytes on x86-64 and RISC-V, one byte on AArch64 when the landings are close, so the sixteen-case table costs 64, 64 and 16 bytes.
- Offsets from a fixed point in the same image survive relocation, so the linker resolves every entry and the loader patches none.
- The range of the case values, not their count, decides whether switch statements get a table: the sparse build in this lesson produced 23 compares and no table, and a target’s own threshold can rule the table out for six dense cases.
- Control-flow integrity guards the indirect branch with
endbr64,bti candbti j, orlpad, and each ISA lets a compiler’s table jump through in its own way: a no-track prefix on x86-64, abti jon every landing on AArch64, a branch throughx7on RISC-V. - A hand-written table works on AArch64 with
adrp,ldrbandbr, but the compiler sizes its entries and keeps them correct while it moves blocks around.
Terms in this lesson
- bounds check
- A compare that proves an index is inside the range of the thing it indexes before the index is used. One unsigned compare covers both ends, because a negative value read as unsigned is larger than any valid index.
- control-flow integrity
- A defence that limits where a program's control transfers may go: the compiler marks the legal targets of indirect branches, calls and returns, and the processor or the run time faults on a transfer to anything else.
- jump table
- A table of addresses, or of 32-bit or smaller offsets, indexed by a value, which turns a dense switch into one indirect jump instead of a chain of compares. Compilers keep it in read-only data.
- landing pad
- An instruction that marks a legal target of an indirect branch or call, so a processor enforcing control-flow integrity accepts the transfer: endbr64 on x86-64, bti on AArch64, lpad on RISC-V.
References
- Intel 64 and IA-32 Architectures Software Developer Manuals (Control-flow Enforcement Technology) (Intel Corporation)
- Arm A-profile A64 Instruction Set Architecture (DDI 0602): BTI (Arm Limited)
- System V ABI for the Arm 64-bit Architecture: BTI and the GNU property note (Arm Limited)
- The RISC-V Instruction Set Manual: Control-flow Integrity (Zicfilp landing pads) (RISC-V International)
- GCC: Program Instrumentation Options (-fcf-protection) (Free Software Foundation)
- GCC: Options That Control Optimization (case-values-threshold) (Free Software Foundation)
- LLVM source: AArch64Subtarget.cpp (MinimumJumpTableEntries, the AArch64 jump-table minimum) (LLVM Project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress