Class: Rubycc::Backend::AArch64

Inherits:
Object
  • Object
show all
Defined in:
lib/rubycc/backend/aarch64.rb

Overview

AArch64 (ARM64) code generator, the second backend behind the same IR::Function -> Result contract the x86_64 one honors. It keeps the spill-everything strategy — every virtual register owns an 8-byte stack slot and each IR instruction loads its operands into scratch registers, computes, and stores the result back — but the machine underneath is entirely different: fixed-length 32-bit instructions, a flat register file, and load/store addressing that shapes the frame layout.

Frame layout (frame-base-relative, positive offsets). Unlike x86_64's rbp-negative displacements, every slot is addressed as [base + off] with a non-negative off, because AArch64's ldr/str unsigned-offset form scales a 12-bit immediate by the access size (reaching 0..32760 for a 64-bit load) while the signed form is only a 9-bit unscaled window (-256..255) that a modest frame overruns at once. From the base upward the frame holds the outgoing argument area, the saved frame record (x29/x30), the vreg slots and the stack objects. A slot whose offset still overflows the scaled immediate is reached by composing its address into a scratch register with add-immediate(s) — the path is built in from the start rather than bolted on for large frames.

The frame base is sp itself in an ordinary function, which costs nothing and leaves x29 free. It is x29 in a function containing :alloca, because there sp moves during the body and a slot named against it would change address under the program's feet: the prologue copies sp into x29 once the frame is set up and every fixed-frame access goes through #frame_base_register from then on (see #emit_alloca).

The outgoing argument area sits at the very bottom because AAPCS64 places a call's stack arguments starting at the caller's sp, and it is reserved once by the prologue — sized for the widest call in the function — rather than pushed per call. That is the whole reason it exists: every value in this backend is named as [sp + off], so moving sp to push arguments would invalidate the offset of every slot at once, including the ones holding the arguments still to be placed. Reserving the area up front leaves sp fixed for the function's whole body, keeps it 16-aligned at the call (the area's size is rounded to 16, and AAPCS64 requires sp 16-aligned at a public interface), and costs nothing at run time. A function that makes no call with stack arguments reserves nothing, so its frame is unchanged.

A function containing :alloca is the exception, and it is the exception in both directions: it reserves no static area (sp no longer names the bottom of the fixed frame once a block has been allocated, so the area would be unreachable) and instead lowers sp around each call, below the allocated blocks. That is affordable precisely because the objection above no longer applies — the slots are named against x29 there, so moving sp disturbs nothing.

Value representation is identical to the x86_64 backend's slot discipline (see backend/x86_64.rb): a slot is always read and written 64 bits at a time (ldr/str of an X register) so pointers survive intact, a value narrower than 8 bytes lives sign/zero-extended in the low 32 bits with the high half indeterminate, and 32-bit arithmetic runs on the W view of a register — a W-register write zeroes the upper 32 bits, giving C's wrap-around for int exactly as x86's eax does.

AAPCS64 calling convention: the first eight integer/pointer arguments arrive in x0..x7 and the result comes back in x0; the first eight floating arguments arrive in v0..v7, allocated from a counter of their own, and a floating result comes back in v0. The two sequences are independent, exactly as System V's integer and xmm sequences are, which is why the IR's :gp/:sse4/:sse8 tags carry over unchanged. The generator classifies against this target's register budget (IR::CallConvention), so a :gp tag really does mean one of the eight and a :mem tag really does mean the stack — no seventh integer argument is spilled here that AAPCS64 would have kept in a register.

A stack argument occupies eight bytes, whatever its type: AAPCS64 6.4.2 rounds each argument's size on the stack up to a multiple of eight and aligns it to at least eight, so the IR's eightbyte view of the overflow area is exactly the ABI's. A stack-passed float therefore travels as a whole eightbyte whose low four bytes carry the value, which is what the slot discipline already promises.

Aggregates arrive here already cut into the pieces AAPCS64 6.4.2 moves them in, the generator having classified them against this target's rules rather than System V's (IR::CallConvention). What that leaves for this backend is placing the pieces: an HFA's members each take a vector register of their own, so struct { float a, b; } really does travel in s0 and s1 rather than packed into one — the one shape whose System V reading would have been silently wrong rather than merely unsupported. A smaller non-HFA aggregate's eightbytes take consecutive x registers, and one too large for either is passed by reference, which the generator has already reduced to an ordinary pointer argument.

The one aggregate mechanism that needs a register no other kind names is the indirect result: a result too large for registers is written through a buffer address the caller puts in x8, tagged :indirect_result so it can be kept clear of both argument sequences (see INDIRECT_RESULT_REGISTER).

Under AAPCS64 a variadic call on this platform places its arguments in the same registers a fixed call would, so the fixed half of a call's size pair needs no action here — unlike System V, where it drives the count of vector registers written to al.

This backend covers the A2 core — control flow, integer arithmetic, local variables, pointers to locals and direct calls — plus the A3 memory-access layer (the addresses of global variables, string literals and functions, formed with an adrp/add pair, and their -fPIC counterpart read from the GOT with an adrp/ldr pair) and most of A4: indirect calls through a function pointer, floating-point arithmetic, comparison, conversion, argument passing and return, whole-object copies, aggregates passed and returned by value, and variadic function definitions (a register-save-area prologue and the AAPCS64 :va_start seed __builtin_va_arg walks), plus the unsigned 64x64->128 multiply's high half (a single umulh) that a synthesized 128-bit multiply needs, and the bit-scan builtins (clz, and rbit before it for the trailing-zero direction). The atomic ops are covered too, built from the armv8-a baseline load-acquire / store-release exclusive pair (see the atomics section below), and dynamic stack allocation, which moves sp below the fixed frame while x29 keeps that frame addressable (see #emit_alloca). Every IR op the generator can hand this backend is now lowered; there is nothing left it refuses.

Defined Under Namespace

Classes: Result

Constant Summary collapse

ARG_REGISTERS =

Integer argument / result registers, in AAPCS64 order. Every argument the IR classifies :gp lands here in order; an argument past the eighth register, or one already classified onto the stack, is refused (see the class comment).

[0, 1, 2, 3, 4, 5, 6, 7].freeze
FP_ARG_REGISTERS =

Floating argument / result registers, v0..v7, allocated from a counter independent of the integer one. They share the numbering of the integer argument registers but not the register file: v0 and x0 are different registers, so a call passing both an int and a double writes each once.

[0, 1, 2, 3, 4, 5, 6, 7].freeze
INDIRECT_RESULT_REGISTER =

x8, the indirect result register. AAPCS64 6.4.1 reserves it for the address of the buffer a result too large for registers is written into, which is what makes an aggregate return differ from System V's: there the same pointer is an ordinary leading integer argument that eats x0's equivalent, here it rides a register of its own and every real argument keeps the place it would have had.

8
RESULT_GP_REGISTERS =

The registers an aggregate result comes back in, in piece order. An integer piece fills x0 then x1 (an aggregate reaching here is 16 bytes or less, so two is all it can need) and a floating one v0..v3 (an HFA has at most four members). Which file a piece draws on follows its kind, so a struct of two floats returns in s0/s1 while a struct of two longs returns in x0/x1.

[0, 1].freeze
RESULT_FP_REGISTERS =
[0, 1, 2, 3].freeze
A =

Scratch (temporary, caller-saved) registers used to evaluate one instruction. A/B hold the two operands, C an extra working value (the quotient a remainder needs), and ADDR composes a slot address when the offset overruns the scaled load/store immediate. None is an argument register, so spilling arguments never disturbs an address computation.

9
B =
10
C =
11
ADDR =
12
D =

Three further scratch registers, used only by the atomic sequences (see #emit_atomic_cas), which are the one place a single IR instruction has more values in flight than A/B/C hold: a compare-exchange juggles the object address, the expected pointer, the expected value, the desired value, the value actually read and the store-exclusive status at once. x13..x15 are caller-saved temporaries like x9..x12 and are clear of ADDR, so an address composed for a distant slot never collides with one of them. (x16/x17 are deliberately skipped: the linker may insert a veneer that clobbers them at any call site.)

13
E =
14
F =
15
FA =

The floating counterparts of A/B, holding the operands of one floating instruction. v16..v31 are caller-saved like x9..x15 (v8..v15 are the callee-saved vector registers, so they are avoided), and being clear of v0..v7 means evaluating a floating value never disturbs an argument already placed.

16
FB =
17
SP =

Special register numbers. In the load/store, add/sub-immediate and stp/ldp encodings a register field of 31 denotes the stack pointer; in the data-processing (arithmetic/logical shifted-register) encodings the same 31 denotes the zero register, which is how neg (sub from xzr) and cmp (subs into xzr) are formed.

31
XZR =
31
FP =
29
LR =
30
SAVE_AREA_SIZE =

The saved frame record (x29, x30) occupies the lowest 16 bytes of the frame; the first vreg slot sits just above it.

16
MAX_SCALED_OFFSET =

The largest byte offset the 64-bit scaled ldr/str immediate can name (a 12-bit field scaled by 8). A slot beyond this is reached through a composed address instead.

4095 * 8
MAX_PAIR_OFFSET =

The largest byte offset the stp/ldp immediate can name (a 7-bit signed field scaled by 8). The saved record is reached through a composed address past this.

63 * 8
CONDITIONS =

IR comparison op -> AArch64 condition code applied to the flags left by cmp a, b (a - b). The signed forms use the N/V-based conditions (lt/le/gt/ge), the unsigned ones the carry-based conditions (lo/ls/hi/hs), which is what an unsigned or pointer comparison needs.

{
  eq: 0,  ne: 1,
  lt: 11, le: 13, gt: 12, ge: 10,
  ult: 3, ule: 9, ugt: 8, uge: 2
}.freeze
FLOAT_CONDITIONS =

IR floating comparison -> the condition code applied to the flags left by fcmp a, b. FCMP reports an unordered compare (either operand NaN) as N=0 Z=0 C=1 V=1, a combination no ordered result produces, and the four conditions below are chosen so every one of them reads false there — which is what C requires of <, <=, > and >= against a NaN:

:flt -> MI (N set)       only a strictly-less compare sets N
:fle -> LS (C clear or Z) less clears C, equal sets Z; unordered sets C
                        and clears Z
:fgt -> GT (Z clear, N=V) unordered has N=0, V=1, so N != V
:fge -> GE (N=V)          likewise false when unordered

Equality needs no combining pair the way x86's ucomis does: FCMP leaves Z clear for an unordered compare (where x86 sets ZF), so plain EQ is already false on NaN and plain NE already true.

{
  feq: 0, fne: 1,
  flt: 4, fle: 9, fgt: 12, fge: 10
}.freeze

Instance Method Summary collapse

Instance Method Details

#compile(ir_func) ⇒ Object



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/rubycc/backend/aarch64.rb', line 258

def compile(ir_func)
  @code = +"".b
  # @labels maps a label id to its resolved byte offset; @fixups collects
  # [patch_offset, label_id, kind] for each forward/backward branch whose
  # immediate is written once every label offset is known.
  @labels = {}
  @fixups = []
  @relocations = []

  # Kept for :va_start, which reads the named parameters' register classes
  # to seed __gr_offs / __vr_offs past the registers the fixed arguments
  # consumed.
  @param_kinds = ir_func.param_kinds
  layout_frame(ir_func.vreg_count, ir_func.stack_objects, ir_func.insts, ir_func.variadic)
  emit_prologue(ir_func.param_kinds, ir_func.variadic)
  ir_func.insts.each { |inst| emit_instruction(inst) }
  resolve_fixups

  Result.new(
    bytes: @code,
    symbols: [{ name: ir_func.name, offset: 0, size: @code.bytesize }],
    relocations: @relocations
  )
end