Keyboard Shortcuts

Ctrl+K
Open search
Esc
Close search / dialog
↑
Back to top

SVC #imm16, and x8 Is a Convention and Not an Architecture

25 minadvancedModule 1: The Modes: Getting In, Getting Out, and Being Interrupted
25 min · The AArch64 Machine: Modes, Memory and Faults

Why this matters

Everything in the last two courses happened while your code was running. This is the first instruction in the course that leaves the program, and it is the cheapest possible way to see the difference between the two things a course like this has to keep apart: the machine's encoding, which is architecture and which is in the instruction word, and the operating system's convention, which is a contract between a kernel and a compiler and which is in a register.

The trap is that the two arrive as a single line of assembly. svc #0 is one instruction and it looks like one fact, and a reader who has just been taught that AArch64 bakes a 16-bit constant into the instruction will naturally conclude that the constant is the syscall number. It is not. Both are true, they are different claims, and the page exists to make the difference measurable rather than asserted.

TWO KINDS OF CLAIM ON ONE LINE OF ASSEMBLY svc #0 ENCODING the 16 bits at bits[20:5] are IN the instruction, unsigned, range [0, 65535]. This is architecture. A decoder reads it with no operating system involved at all. CONVENTION x8 holds the same number, and the KERNEL decides what it means. This is ABI. A compiler has never heard of it and will use x8 as a scratch register in a function that never executes a syscall. Reading one without the other is the mistake this page exists to make impossible.

And before anything else, the constraint that decides what this page may claim at all: there is no AArch64 machine on the machine that wrote this course, no AArch64 emulator, and no AArch64 linker. Not one instruction here has been run. Every number below is a bit pattern, a count of bit patterns, an arithmetic identity, or a refusal from a real assembler. The last concept prints the whole measured/quoted boundary, and the neutral course's syscall concept owns the principle.

The model: read the instruction word, not the mnemonic

Here is the whole encoding, measured from five real assembler outputs. Every word below is MEASURED-ON-BYTES: it came out of clang and it was read twice, by the decoder in the artifact and by llvm-objdump-21.

$ python3 a64sys.py --section 4 | sed -n '/the immediate is a FIELD/,/^$/p'
  [MEASURED-ON-BYTES] the immediate is a FIELD, and the assembler says how wide
     svc #0       0xd4000001  imm16 at bits[20:5] = 0x0000   svc      #0
     svc #1       0xd4000021  imm16 at bits[20:5] = 0x0001   svc      #1
     svc #63      0xd40007e1  imm16 at bits[20:5] = 0x003f   svc      #63
     svc #64      0xd4000801  imm16 at bits[20:5] = 0x0040   svc      #64
     svc #65535   0xd41fffe1  imm16 at bits[20:5] = 0xffff   svc      #65535
                

Sixteen bits, at bits[20:5], and the fifth row is the interesting one because it is the boundary the assembler drew for us. Four more probes find it exactly:

$ for t in "svc #65536" "svc #-1" "brk #65536" "hlt #-1"; do ...
  svc #65536   REFUSED  error: immediate must be an integer in range [0, 65535].
  svc #-1      REFUSED  error: immediate must be an integer in range [0, 65535].
  brk #65536   REFUSED  error: immediate must be an integer in range [0, 65535].
  hlt #-1      REFUSED  error: immediate must be an integer in range [0, 65535].
                

The field is UNSIGNED, in an architecture where every other displacement is signed — and the diagnostic names a range rather than a signedness, so it reads like a complaint about a value that was negative to begin with. svc #-1 is the same sixteen bits as svc #65535, and the assembler takes the second and refuses the first. If you have ever written svc #-1 expecting the usual "all ones" idiom, you have met this and the diagnostic did not tell you what was wrong.

Now the field map, which is the part a backend author needs and the part llvm-objdump will not print for you:

  1101 0100 000 0 0000 0000000000000000 00001
   |     |    |  | |   |                        |
   |     |    |  | |   the SIXTEEN-BIT IMMEDIATE, bits[20:5]
   |     |    |  | op0 = 0b00, and op1 = 0b000
   |     |    the L bit: 0 here, 1 for a privileged read
   bits[31:24] = 0xd4, the exception-generating group
                                     bits[4:0] = 0b00001 selects SVC

  The SAME five bits are SVC, HVC and SMC:
      svc 0xd4000001   bits[4:0] = 0b00001
      hvc 0xd4000002   bits[4:0] = 0b00010
      smc 0xd4000003   bits[4:0] = 0b00011
  ...and DCPS1 is 0xd4a00001, which has the SAME bits[4:0]
  and differs only in bits[23:22] and bit 21.
                

So the operation field is not a mnemonic on its own. It is local to a table, and the table is selected by a two-bit field plus one more bit above it. That is the shape of the whole SYSTEM class and it is why a decoder has to be dispatched on a group before it can read a field — the encoding course's field map stops at bits[28:25] and this is what is on the other side of that boundary.

The measurement: the same syscall, both architectures, and the relocations

Here is the cross-architecture comparison this course makes, and it is the only one. The x86-64 course measured 4.92× and 27.65×; nothing on this page has a counterpart for either number, because there is no AArch64 clock on this host to read one with, and a fabricated ratio is worse than an admitted absence.

$ python3 a64sys.py --section 4 | sed -n '/the same syscall, both arch/,/^$/p'
  [MEASURED-ON-BYTES] the same syscall, both architectures, hand-written
     THIS IS A COMPILE-TIME INSTRUCTION COUNT COMPARISON AND NOT
     A TIMING ONE.  NOTHING HAS BEEN EXECUTED ON EITHER SIDE.

     AArch64 (aarch64-linux-gnu)             x86-64 (host, variable length)
     0  0xd2800808 mov x8, #0x40             0  48 c7 c0 3c 00 00 00 movq
     1  0xd2800020 mov x0, #0x1              1  48 c7 c7 01 00 00 00 movq
     2  0x90000001 adrp x1, 0x0 <a64>        2  48 8d 35 00 00 00 00 leaq
     3  0x91000021 add x1, x1, #0x0          3  48 c7 c2 06 00 00 00 movq
     4  0xd28000c2 mov x2, #0x6              4  0f 05              syscall
     5  0xd4000001 svc #0                    5  c3                 retq
     6  0xd65f03c0 ret

     instructions: AArch64 7, x86-64 6
     bytes of CODE: AArch64 28, x86-64 31
     relocations:   AArch64 2, x86-64 1
                

Three things in that block, and the third is the real one.

Now the part that separates the two claims

The corpus has a function in which x8 is not mentioned at all. Not "is not used" — not mentioned in the source. And here is what the compiler does with it, at every optimisation level:

$ python3 a64sys.py --section 4 | sed -n '/the x8 CONVENTION/,/^$/p'
  [MEASURED] the x8 CONVENTION, and what the compiler does with x8
     -O0  sys_const        20 instructions   x8 named: YES
     -O0  sys_global       21 instructions   x8 named: YES
     -O0  sys_no_number    17 instructions   x8 named: YES
     -O1  sys_const         9 instructions   x8 named: YES
     -O2  sys_global       10 instructions   x8 named: YES
     -O2  sys_no_number     8 instructions   x8 named: YES
     -O2  sys_arg6        15 instructions   x6 named: YES
                

sys_no_number never mentions x8 in its C, and the compiler uses it anyway — as the adrp base for the next address it has to form. So x8 is an ordinary caller-saved temporary to the compiler. It is the AAPCS64 Indirect Result Location Register and an ordinary scratch, and the syscall convention reuses it.

THE RETRACTION, and it is R6 DRAFT "x8 is the syscall-number register, so a compiler must treat it as reserved across a `svc`." MEASURED clang uses x8 as a scratch register in every one of the four functions that do not mention it, at every one of the four optimisation levels -- including inside the same function that later issues `svc #0`. The two sentences have different consequences. The first says a compiler has a rule. The second says the compiler has none, and that the rule lives somewhere else entirely. A page that said "x8 is reserved" would produce a wrapper that is correct and a reader who is wrong, and the reader is what carries forward.

Worked: the argument audit, and the level where the contract is invisible

Every argument in the corpus is a volatile global with its own distinctive constant, so the disassembly names the argument: the load of 0x4444is argument three. A census would say "seven registers were written"; an audit says which argument was in which register, and can therefore disagree with a specification instead of merely agreeing with itself.

$ python3 a64sys.py --section 4 | sed -n '/the ARGUMENT AUDIT/,/^$/p' | tail -34
     function         x0  x1  x2  x3  x4  x5  x6  x7  9th

     --- O2 ---
     sys_arg1         VA0  .  .  .  .  .  .  .   -
     sys_arg2         VA0  VA1  .  .  .  .  .  .   -
     sys_arg5         VA0  VA1  VA2  VA3  VA4  .  .   -
     sys_arg6         VA0  VA1  VA2  VA3  VA4  VA5  .  .   -
     sys_arg7         VA0  VA1  VA2  VA3  VA4  VA5  VA6  .   -
     sys_arg8         VA0  VA1  VA2  VA3  VA4  VA5  VA6  VA7   -
     sys_arg9_stack   VA0  VA1  VA2  VA3  VA4  VA5  VA6  VA7   stack

     --- O0 ---   every argument is SPILLED, so the assignment the ABI names is
     sys_arg1         .  .  .  .  .  .  .  .   -
     sys_arg2         .  .  .  .  .  .  .  .   -
     sys_arg8         .  .  .  .  .  .  .  .   -
     sys_arg9_stack   .  .  .  .  .  .  .  .   stack

  [RESULT]           111 of 111 argument placements land in the register whose
                     NUMBER is the argument's, at -O1, -O2 and -Os, and the
                     ninth is never a register
                

Four results, and the third is the one to remember.

$ python3 a64sys.py --section 4 | sed -n '/the compiler emits x6/,/^$/p'
  [MEASURED] the compiler emits x6 and x7 for a syscall that has six
     -O2  sys_arg6        15 insn   mentions x6 0 times, x7 0 times
     -O2  sys_arg7        16 insn   mentions x6 2 times, x7 0 times
     -O2  sys_arg8        18 insn   mentions x6 2 times, x7 2 times
                

A literal number and a loaded number, for the last measurement on the page — and the reason it matters is that it makes the next concept's subject appear without warning:

     sys_const, -O2:                    sys_global, -O2:
       adrp x0, VA0                          adrp x8, VN
       ldr x0, [x0, :lo12:VA0]              adrp x9, VA0
       adrp x8, VA1                          ldr x8, [x8, :lo12:VN]
       ldr x1, [x8, :lo12:VA1]              adrp x9, VA1
       adrp x8, VRET                         ldr x0, [x9, :lo12:VA0]
       svc #0                                adrp x9, VA1
       str x0, [x8, :lo12:VRET]              ldr x1, [x9, :lo12:VA1]
       ret                                    svc #0
                                            adrp x8, VRET
                                            str x0, [x8, :lo12:VRET]
                                            ret
                

sys_const gets 64 from an immediate and costs no memory access. sys_global gets it from a volatile and costs an adrp and an ldr — and that is the same pair of two relocations the x86-64 comparison in the previous section was about. A syscall number that is a variable is indistinguishable from a page-table address in the instruction stream, and the only thing that tells them apart is a relocation.

What this page cannot show

It cannot show that svc #0 traps, that the kernel reads x8, that 64 means write, that the result comes back in x0, or that a wrong number produces ENOSYS. All five are QUOTED and all five would need an AArch64 machine or an emulator, and this host has neither. The syscall numbers themselves — 64 for write, 63 for read, 222 for mmap — are quoted from include/uapi/asm-generic/unistd.h in the Linux tree, and a number confirmed only by a header file is a weaker kind of claim than one confirmed by a return value. What the page can show is that the compiler puts the number where the convention says, that the argument registers are filled in the order the ABI names, that the ninth argument is not a register, that -O0 hides the contract, and that the encoding carries a sixteen-bit unsigned constant. Five results, and every one of them is checkable in a file on this disk.

Apply it

  1. Find the boundary yourself, with the assembler as the oracle. Write a .s file with svc #0, svc #65535, svc #65536 and svc #-1 and assemble it for aarch64-linux-gnu. (Expect the first two to assemble and the last two to be refused with the identical message immediate must be an integer in range [0, 65535]. Then add svc #-1 in a comment and notice that the # on AArch64 is ambiguous: it is a comment marker and it is the immediate prefix, which is a small trap in every assembler language and a large one in this one.)
  2. Prove the two claims are two claims. Write two functions: one that puts 64 in x8 and issues svc #0, and one that issues svc #64 and does not touch x8 at all. Disassemble both. (Expect the same 32-bit shape with the number in a different place, and then ask the question this page exists for: on a real machine, which of those two does a Linux kernel read? The answer is neither alone — the Linux syscall path reads x8 and the immediate is what the exception handler sees in ESR_EL1 bits 24:5. A field and a convention, both present, doing different jobs.)
  3. Audit your own code for the -O0 trap. Compile a function that takes nine arguments at -O0 and at -O2 and diff the disassembly. (Expect at -O0 that the argument registers are spilled and reloaded and that a reader cannot tell which slot is which argument from the code; at -O2 that the placement is direct. Then compile at -O1 and -Os and check you get the same answer — because a claim about an ABI that holds at one optimisation level and not another is a claim about the compiler.)
  4. Count the relocations your own program needs, on both architectures. A function that returns the address of a static 4 KiB-aligned array, compiled for aarch64-linux-gnu and for the host, and then llvm-readelf-21 -r on each. (Expect two relocations against one. Then move the array into .data and try again, and then delete the adrp and write adr by hand, and expect the assembler to refuse it past 1 MiB. That refusal is the boundary concept 4 measures properly, and it is a different boundary from the one that makes the pair necessary.)
  5. Break the poison and watch the number move. Run python3 a64sys.py --run, note that section 9 reports 1516 named, 0 DISAGREEMENTS, then delete m_sysreg from the dispatch in a64sys.py and run it again. (Expect 1459 named and 57 disagreements, and every one of the 57 is an mrs or an msr printed by name. That is the whole argument for section 9B in one command: a cross-check that has never been seen to fail is a check with no reason to be believed.)

Connect

Backwards, three. Concept 2 of the ABI course is the hinge for the argument audit: x0 and w0 are the same register and a 32-bit write zeroes the top half, so a reader who has not read that page will read sys_arg1's x0 as a 64-bit slot when it is a value the kernel will widen. The immediate chapter of the encoding course owns the MOVZ/MOVK pair that puts a literal in x8, and the adrp/:lo12: pair that sys_global needs. The encoding course's cross-check is where the decoder this course imports was poisoned on purpose, and the poison you just reproduced is the same experiment with a different guard.

Sideways, three neighbours that own the edges. The neutral course owns the principle — that a syscall is a controlled transfer to a more privileged level — and this page owes it the idea and owns only the AArch64 reference. The x86-64 syscall concept is the sibling reference for the same subject, and the table at the top of this page is the honest place the two meet: a compile-time instruction count and a relocation count, and no timing anywhere. The neutral course's privilege concept owns what EL0 and EL1 mean; read it first if the names are new, because this page assumes you know that svc traps upward.

Outward, and the general form is worth carrying out of here. A constant in an instruction and a value in a register look identical in the assembly and are different kinds of claim. The same distinction shows up three more times in this course: the syndrome is a field with a documented layout in one register and a convention for reading it in another; TCR_EL1 is a register whose fields are quoted and whose address is not; and a page-table descriptor is an ordinary 64-bit word that means something only under a convention. A learner writing a backend meets this on day one and it does not go away, and the habit of asking "which of the two is this?" is worth more than any of the specific numbers on this page.

And the last honest word: this page is about a machine that does not exist here, and every claim on it says which of the three kinds it is. Concept 6 is the full table, and it is the concept that makes the other five trustworthy.

render_lesson_js(&mut page)