Keyboard Shortcuts

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

Sixteen Entries of 0x80, and Four Times No Diagnostic

25 minadvancedModule 1: The Modes: Getting In, Getting Out, and Being InterruptedContinues from a64-exceptions
25 min · The AArch64 Machine: Modes, Memory and Faults

Why this matters

Where does the CPU go when something happens? Not "somewhere in the kernel" — at a computed address, and the arithmetic is two instructions long: the base register plus the vector number times a fixed stride. That is the whole dispatch mechanism, and it is the reason an AArch64 kernel's first four kilobytes are a table of branches rather than a table of function pointers: the entry is a branch, so a vector can be a b to a shared handler and the four exception types are four branches rather than four indirect jumps.

And the reason this concept is worth a page of its own rather than a paragraph is the measurement at the end, which is an absence of a diagnostic. The table has a hard alignment requirement, the requirement is architectural, and nothing in the toolchain checks it. A misaligned vector table is not an error. It is a jump to the wrong address, discovered by the first exception the system takes, which on a machine that boots is very early and on a machine that has been running for a month is very late.

THE SHAPE OF THE MEASUREMENT Measured: the table's size, all sixteen offsets, the encoding of the read and the write, the two relocations of the install, and what four different alignment directives do. Measured by its ABSENCE: that none of the four produces a diagnostic. A course with hardware would measure the fault. This one measures the silence, and prints the silence in the place a reader would expect the fault to be justified.

The model: base plus vector times 0x80

The quoted part first, because everything measured on this page is measured against it. The architecture defines a table of sixteen entries, each 0x80 bytes, indexed by a vector number that is built from the exception type and the exception level pair. Sixteen times 0x80 is 0x800 — two kibibytes — and that is not a coincidence: the low eleven bits of VBAR_ELx are RES0, so the base itself must be 2 KiB aligned or the architecture cannot represent the address at all.

Now the measurement, and the reason to print all sixteen rows rather than state the size: a table of offsets is exactly where a real error would show, and the error this method is most likely to have is one entry out by 0x80.

$ python3 a64sys.py --section 6 | sed -n '/the table the assembler BUILT/,/^$/p'
  [MEASURED] the table the assembler BUILT, from vec.s

     vector #   name              symbol value   value & 0x7f   value / 0x80
     0         el0t_sync         0x800       0             16
     1         el0t_irq          0x880       0             17
     2         el0t_fiq          0x900       0             18
     3         el0t_serror       0x980       0             19
     4         el0t_sync_a64     0xa00       0             20
     5         el0t_irq_a64      0xa80       0             21
     6         el0t_fiq_a64      0xb00       0             22
     7         el0t_serror_a64   0xb80       0             23
     8         el0i_sync         0xc00       0             24
     9         el0i_irq          0xc80       0             25
     10        el0i_fiq          0xd00       0             26
     11        el0i_serror       0xd80       0             27
     12        el1t_sync         0xe00       0             28
     13        el1t_irq          0xe80       0             29
     14        el1t_fiq          0xf00       0             30
     15        el1t_serror       0xf80       0             31

     the table symbol: value 0x0, SIZE 2048 bytes (0x800)

  [RESULT]           16 x 0x80 = 0x800, and the assembler MEASURED that as 2048 bytes
                

All sixteen are multiples of 0x80, all sixteen fall between 0x800 and 0xf80, and the sixteenth is at 0xf80 — so the table spans exactly sixteen slots from 0x800 to 0xfff inclusive. The size came out of the symbol table, not out of a formula I typed in. That distinction is the whole method: .size vectors, . - vectors asked the assembler, and the answer was 2048.

The labels are also the quoted layout, printed by the assembler into a file on this disk: the first four are EL0 arriving with SP0, the next four are EL0 arriving with SP0 in AArch64 state, the next four are EL0 arriving with SPx, and the last four are a lower EL arriving to EL1 in AArch64 state. EL0 always lands in the first eight — the machine chooses, and the choice is a consequence of the rule that EL0 cannot set VBAR_ELx at all, because the register only exists at EL1 and above.

The measurement: the encoding, the install, and four cases of silence

Writing the table's address into the register is a data instruction. That is the first surprise, and it is measured on bytes:

$ python3 a64sys.py --section 6 | sed -n '/reading and writing VBAR/,/^$/p'
  [MEASURED-ON-BYTES] the encoding of reading and writing VBAR
     mrs x0, VBAR_EL1   0xd538c000  L=1  CRn=0xc CRm=0x0 op2=0  mrs      x0, VBAR_EL1
     msr VBAR_EL1, x0   0xd518c000  L=0  CRn=0xc CRm=0x0 op2=0  msr      VBAR_EL1, x0
                

One bit — bit 21 — separates the read from the write, and the five-field register name is identical in both. So installing the vector table looks in a disassembly exactly like reading a variable, and a reader scanning an object file for "where does this program install its exception handlers" is looking for an msr and will find one that looks like a load.

The full install, five instructions, read out of the symbol table:

     install_vbar, at 0xf84, 20 bytes, from the symbol table:
       0x90000000  adrp     x0, +0
       0x91000000  add      x0, x0, #0x0
       0xd518c000  msr      VBAR_EL1, x0
       0xd5033fdf  isb
       0xd65f03c0  ret
                

And the two relocations that adrp + add cost, which is the same pair concept 1 measured for a syscall number loaded from memory:

     the relocations the pair emits:
      0x002010c4  R_AARCH64_ADR_PREL_PG_HI21   vectors + 0
      0x002010c8  R_AARCH64_ADD_ABS_LO12_NC    vectors + 0
                

Two relocations, adjacent, against one symbol, four bytes apart. On AArch64 there is no encoding of "one relocation that means an address", and a 2 KiB-aligned table is not a special case of that — it is the ordinary case. Concept 5 measures why, and the reason is not the one the section plan gave.

And now the four cases of silence

Four alignment directives, four identical object files in shape, four times no diagnostic:

$ python3 a64sys.py --section 6 | sed -n '/what the assembler does about/,/^$/p'
  [MEASURED] what the assembler does about the alignment, and what it does NOT
     source                      section align   label value  diagnostic?
     .align 11 (2 KiB)        2048            0x0         NONE
     .align 8 (256 B)         256             0x0         NONE
     no .align at all         4               0x0         NONE
     .align 12 (4 KiB)        4096            0x0         NONE

     . FOUR CASES, FOUR TIMES NO DIAGNOSTIC. The section alignment is
     . whatever the directive says, the table is where the label is, and
     . the assembler has no idea that a 2 KiB-aligned array of branch
     . instructions is going to be handed to a register whose low eleven
     . bits are RES0.
                
R9, and it is the concept DRAFT "put `.align 11` before the vector table and it is valid." MEASURED `.align 11` is NECESSARY and NOT SUFFICIENT, and nothing checks it. The assembler honours the alignment, the table is 0x800 bytes, and the register that consumes the address is written at RUNTIME by an `msr` whose only check is architectural. A kernel that computes the address with adrp + add :lo12: and writes it to VBAR with the low eleven bits set will fault on the first exception it takes -- and no assembler, linker or compiler will have said anything. This is the difference between a REQUIREMENT and a DIAGNOSTIC, and it is the most useful thing a toolchain course can teach about a requirement.

Note the third row: with no .align at all the section comes back with alignment 4, which is what an x86-64-shaped intuition predicts and which is four hundred times too small. The assembler is not ignoring the requirement — there is no requirement in its input. It is aligning the instruction stream, and four is the alignment of a 32-bit instruction.

Worked: the arithmetic of a vector, and the one thing you cannot check

Given a base and a vector number, the entry address is base + vector * 0x80. That is the whole dispatch, and it means the table can be relocated as a unit: put it anywhere 2 KiB aligned and the arithmetic follows. Which is the property that makes the table relocatable and also the property that makes the :lo12: pair necessary — the linker can move the base and fix the relocation, and if the relocation were a single 21-bit page-relative field there would be nothing left to fix up.

     for a 0x800-aligned table at 0xffff8000_00800000:

       vector  0  Sync   0xffff8000_00800000
       vector  1  IRQ    0xffff8000_00800080
       vector  2  FIQ    0xffff8000_00800100
       vector  3  SError 0xffff8000_00800180
       ...
       vector 15  SError 0xffff8000_00800780

     and the LAST valid byte of the table is
       0xffff8000_008007ff
     which is base + 0x7ff -- 0x800 bytes minus one.
                

Two things to notice in that arithmetic, and the second is the trap.

What this page cannot show

It cannot show an interrupt being taken, a vector being entered, a VBAR write being honoured, or a misaligned table faulting. All four would need an AArch64 machine, an emulator or a linker, and this host has none of the three. The sixteen entries, the four exception types, the (source EL, target EL) pair that selects the first four, the requirement that the low eleven bits of VBAR be zero, and the fact that EL0 cannot install a table — all of that is quoted. What is measured is the table the assembler built, its size, its sixteen offsets, the four different things the assembler does with an alignment directive, the encoding of the read and the write, and the two relocations of the install. Six results, and the largest of them is an absence.

Apply it

  1. Build the table and let the assembler tell you the size. Write a .s file with .align 11, sixteen b label entries, and .size vectors, . - vectors; assemble it and read the size back with llvm-readelf-21 -s. (Expect 2048. Then delete the .align 11, reassemble, and check the size is still 2048 — the size is the same and the alignment is what changed, which is the whole distinction. Now check the section header's alignment in both cases: 2048 and 4.)
  2. Get the missing diagnostic yourself. Take the .align 8 version — 256-byte alignment, a table 0x800 bytes long — and ask what the low eleven bits of the adrp-computed address are. (Expect that they are not zero, and that the assembler did not object, and that msr vbar_el1, x0 assembles just as happily over a misaligned x0 as over a good one. The architectural check happens when the write is interpreted, which is the moment you cannot reach from here. That sentence is the concept.)
  3. Count the relocations, and then break the pairing by hand. Assemble the adrp + add :lo12: pair and read llvm-readelf-21 -r. (Expect two, four bytes apart, with the names R_AARCH64_ADR_PREL_PG_HI21 and R_AARCH64_ADD_ABS_LO12_NC. Then write adrp x0, vectors with no partner and look at the object: one relocation, and an address that is wrong by whatever the low twelve bits happen to be. There is no diagnostic for the missing partner either — the assembler does not know you needed one, because adrp on its own is a legal instruction with a legal meaning.)
  4. Write a trampoline table and measure it. Sixteen b instructions, then 0x80 bytes of handler after each, with the .balign 0x80 that the layout requires. (Expect the section to grow to 0x4000 and the symbol table to show every handler at a multiple of 0x80. Now delete one .balign and watch the offsets go irregular — and note that the assembler again says nothing, because .balign between handlers is a layout convention the assembler honours and a convention it does not enforce. Three silences on one page, and they are all the same silence: a linker-time and run-time requirement that no compiler checks.)
  5. Compare with x86-64's IDT and find the asymmetry.The instruction-set course's interrupt concept gives you the x86-64 picture: an IDT of 256 eight-byte entries, a gate, and a privileged LIDT. Build the AArch64 equivalent from this page and put the two side by side. (Expect x86-64 to have one gate format covering all 256 vectors, and AArch64 to have 16 fixed-size slots with the type packed into the index. The x86-64 design scales to 256 vectors because the gate is a fixed 8 bytes; the AArch64 design is cheaper per vector and tops out at 16 because the stride is a constant. Neither is wrong and they optimise for different things, and the reason a reader should care is that the AArch64 one is the design a compiler backend has to emit a table for and the x86-64 one is the design it has to emit a pointer to.)

Connect

Backwards, and the link is one register. Concept 2 measured mrs x6, ELR_EL1 — the address the CPU will return to. This page is the address it goes to first, and the two are written in the same six-instruction prologue of every handler. Concept 1 measured the other side of the same register file: the adrp + add pair that computes the table's address is identical whether the thing being named is a string, a syscall number or a vector table, and the two relocations are identical too. The encoding course's condition-code chapter is not needed here, but its RET model is: the ABI course is where x30 and eret meet, and eret is one bit away from drps — both 0xd6?f03e0, both with 32 bits and no operands.

Sideways, the neighbours that own the edges. The neutral course owns the vector model, and the reason this page can be short is that page: what a vector is, why there are several, and what a dispatcher is. The SMP course's interrupt concept owns what happens when two CPUs take an interrupt at once, and this page owns the per-CPU part of that: VBAR_EL1 is per exception level, so a four-core machine has four tables, and the address a given core uses is the one its own register holds. The dynamic-linking course is where a frame gets walked for the first time in a real program, and a vector table is the one place in a program where that walk begins before main.

Outward, and this is the concept the whole course is built around. A requirement that no tool checks is a requirement you have to check yourself, and the tools will not even tell you that you have not checked it. Four alignment directives and four times no diagnostic is the same shape as the HINT guard bug in the encoding course's cross-check — a model that is right about what it is asked and is never asked about the rest — and it is the same shape as the ADRP pair in concept 5: a thing that is correct in isolation and undefined in combination. A reader who has finished this course should have a reflex for the pattern: where is the requirement stated, who checks it, and what happens if it is violated?

And the honest accounting. Everything quoted on this page is about a machine that does not exist here. Everything measured is about an assembler that does. Concept 6 prints the boundary for the whole course, and it is the concept that makes the other five worth reading.

render_lesson_js(&mut page)