From 6f1b0babbb8306c0bebbb9d083a9566128e8531d Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Wed, 9 Sep 2026 00:52:41 +0800 Subject: [PATCH 1/6] Flush every stream before the compiler exits A dynamically linked build resolves fflush through the PLT to the host libc, but lib/c.h defines stdout as the plain file descriptor 1, which is not a FILE pointer. Handing that to glibc dereferences address 1. That build is also the only one whose stdio buffers, so it is the one that most needs the flush: without it, a diagnostic written to a pipe is lost when the compiler dies. NULL means every stream to the host libc and is ignored by the unbuffered embedded one, so it suits both. Only fatal, usage_error and error_at change here. The eleven other sites in this file share the hazard and deserve their own look. --- src/globals.c | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/globals.c b/src/globals.c index c035b47c..956f0a92 100644 --- a/src/globals.c +++ b/src/globals.c @@ -1770,8 +1770,15 @@ __noreturn void fatal(const char *msg) /* abort() does not flush, so a diagnostic written to a pipe -- a build log, * or any invocation whose output is captured -- is discarded and the * compiler appears to die silently. + * + * The stream is NULL rather than stdout because a dynamically linked build + * resolves fflush through the PLT to the host libc, for which lib/c.h's + * 'stdout' -- the plain file descriptor 1 -- is not a FILE *. NULL means + * "every stream" there and is ignored by the unbuffered embedded libc, so + * it is right for both. That build needs the flush most, being the only one + * whose stdio actually buffers. */ - fflush(stdout); + fflush(NULL); abort(); } @@ -1782,7 +1789,7 @@ __noreturn void fatal(const char *msg) __noreturn void usage_error(const char *msg) { printf("[Error]: %s\n", msg); - fflush(stdout); + fflush(NULL); exit(1); } @@ -1801,7 +1808,7 @@ __noreturn void error_at(char *msg, source_location_t *loc) if (!loc) { printf("[Error]: %s\n", msg); - fflush(stdout); + fflush(NULL); exit(1); } @@ -1816,7 +1823,7 @@ __noreturn void error_at(char *msg, source_location_t *loc) */ if (!src) { printf("[Error]: %s\n", msg); - fflush(stdout); + fflush(NULL); exit(1); } @@ -1862,7 +1869,7 @@ __noreturn void error_at(char *msg, source_location_t *loc) strcpy(diagnostic + i, note); printf("%s\n", diagnostic); - fflush(stdout); /* exit() flushes, but say so once rather than rely on it */ + fflush(NULL); /* exit() flushes, but say so once rather than rely on it */ exit(1); } From f1213c004fa2c210100b9fa583c4d7ffd67acb6a Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Wed, 9 Sep 2026 00:52:54 +0800 Subject: [PATCH 2/6] Add a self-hosting AArch64 backend Generate ELF64 images for AArch64 Linux under AAPCS64, static and dynamically linked, with the stage-1 and stage-2 compilers byte identical. The backend maps the allocator's registers onto x0-x7 and x20-x22, keeps the synthetic global frame in x19, and reaches libc through an eager-bound PLT of ADRP/ADD/LDR/BR entries. Two shared assumptions did not survive a 64-bit pointer. The register allocator now records which operands of an instruction are addresses, because pointer arithmetic has to widen the int index beside the address and a comparison has to read the address whole; an array counts, since what reaches the instruction is its decayed base. The global-frame slot reserves a full pointer rather than the historic 32-bit word. Both are inert on the 32-bit targets. The output mode is set explicitly once the file is closed, since it depends on which libc opened it rather than on how the output links: the embedded lib/c.c passes 0775 to openat, while glibc's fopen yields 0666. Setting it also needs fchmodat on the targets whose asm-generic table has no chmod at all, where 90 is capget. Images separate their load segments by 64 KiB so they stay loadable under any of the granules AArch64 Linux may use. QEMU-user on an x86-64 host always presents 4 KiB pages and cannot observe this. --- Makefile | 2 +- lib/c.c | 48 +-- lib/c.h | 29 +- mk/arm64.mk | 31 ++ src/arm64-codegen.c | 731 ++++++++++++++++++++++++++++++++++++++++++++ src/defs.h | 16 + src/elf.c | 81 ++++- src/globals.c | 8 +- src/parser.c | 12 +- src/reg-alloc.c | 54 +++- tests/arm64-abi.sh | 174 +++++++++++ 11 files changed, 1130 insertions(+), 56 deletions(-) create mode 100644 mk/arm64.mk create mode 100644 src/arm64-codegen.c create mode 100755 tests/arm64-abi.sh diff --git a/Makefile b/Makefile index ed56d7bd..6661e623 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ USE_QEMU ?= 1 OUT ?= out # Every architecture that can be selected as a build target. The first is the # default when ARCH is not given. -ARCHS = arm riscv x64 +ARCHS = arm arm64 riscv x64 ARCH ?= $(firstword $(ARCHS)) SRCDIR := $(shell find src -type d) LIBDIR := $(shell find lib -type d) diff --git a/lib/c.c b/lib/c.c index 37081966..6b03909d 100644 --- a/lib/c.c +++ b/lib/c.c @@ -241,13 +241,11 @@ void __str_base10(char *pb, int val) int q, r, t; int i = INT_BUF_LEN - 1; - /* On a 32-bit target, negating INT_MIN overflows and the digit loop below - * cannot make progress, so the value is spelled out directly. On LP64 the - * negation happens in a 64-bit register and the normal path is exact. This - * is an ordinary constant expression rather than a preprocessor conditional - * so that shecc can compile this file for either target. + /* val is an int on every target: negating INT_MIN overflows even when + * pointers and registers are 64-bit. Spell it directly so the digit loop + * never walks its stack buffer backwards indefinitely. */ - if (__ptr_width == 4 && val == -2147483648) { + if (val == -2147483648) { strncpy(pb + INT_BUF_LEN - 11, "-2147483648", 11); return; } @@ -662,14 +660,14 @@ FILE *fopen(char *filename, char *mode) if (!strcmp(mode, "wb")) perm = 0x1fd; -#if defined(__riscv) +#if defined(__riscv) || defined(__aarch64__) /* FIXME: mode not work currently in RISC-V */ fd = __syscall(__syscall_openat, -100, filename, 577, perm); #else fd = __syscall(__syscall_open, filename, 577, perm); #endif } else if (!strcmp(mode, "r") || !strcmp(mode, "rb")) { -#if defined(__riscv) +#if defined(__riscv) || defined(__aarch64__) fd = __syscall(__syscall_openat, -100, filename, 0, 0); #else fd = __syscall(__syscall_open, filename, 0, 0); @@ -692,6 +690,16 @@ int fclose(FILE *stream) return 0; } +int chmod(char *filename, int mode) +{ +#if defined(__riscv) || defined(__aarch64__) + /* sys_fchmodat takes (dirfd, filename, mode); AT_FDCWD is -100. */ + return __syscall(__syscall_fchmodat, -100, filename, mode); +#else + return __syscall(__syscall_chmod, filename, mode); +#endif +} + /* Read a byte from file descriptor. So the return value is either in the range * of 0 to 127 for the character, or -1 on the end of file. */ @@ -738,32 +746,28 @@ int fputc(int c, FILE *stream) int fseek(FILE *stream, int offset, int whence) { int result; -#if defined(__arm__) - result = __syscall(__syscall_lseek, stream, offset, whence); -#elif defined(__riscv) - /* No need to offset */ + + /* RV32 has only _llseek, which splits the offset and returns the result + * through a pointer. Every other target takes (fd, offset, whence) + * directly. lib/c.h rejects an architecture that is neither. + */ +#if defined(__riscv) result = __syscall(__syscall_lseek, stream, 0, offset, NULL, whence); -#elif defined(__x86_64__) - /* x86-64 lseek(2) takes (fd, offset, whence) directly. */ - result = __syscall(__syscall_lseek, stream, offset, whence); #else -#error "Unsupported fseek support for current platform" + result = __syscall(__syscall_lseek, stream, offset, whence); #endif return result == -1; } int ftell(FILE *stream) { -#if defined(__arm__) - return __syscall(__syscall_lseek, stream, 0, SEEK_CUR); -#elif defined(__riscv) + /* See fseek(): only RV32 needs the split-offset _llseek form. */ +#if defined(__riscv) int result; __syscall(__syscall_lseek, stream, 0, 0, &result, SEEK_CUR); return result; -#elif defined(__x86_64__) - return __syscall(__syscall_lseek, stream, 0, SEEK_CUR); #else -#error "Unsupported ftell support for current platform" + return __syscall(__syscall_lseek, stream, 0, SEEK_CUR); #endif } diff --git a/lib/c.h b/lib/c.h index d9be4b15..d097edcf 100644 --- a/lib/c.h +++ b/lib/c.h @@ -21,19 +21,32 @@ #define SEEK_CUR 1 #define SEEK_END 2 -#if defined(__arm__) +/* Pointer width and syscall table are independent axes: RV32 and AArch64 share + * the asm-generic table but not the width, while Arm32 and x86-64 each carry a + * legacy table of their own. Selecting on each separately keeps one copy of the + * numbers. + */ +#if defined(__arm__) || defined(__riscv) #define __SIZEOF_POINTER__ 4 +#elif defined(__aarch64__) || defined(__x86_64__) +#define __SIZEOF_POINTER__ 8 +#else +#error "Unsupported architecture" +#endif + +#if defined(__arm__) #define __syscall_exit 1 #define __syscall_read 3 #define __syscall_write 4 #define __syscall_close 6 #define __syscall_open 5 #define __syscall_lseek 19 +#define __syscall_chmod 15 #define __syscall_mmap2 192 #define __syscall_munmap 91 -#elif defined(__riscv) -#define __SIZEOF_POINTER__ 4 +/* RV32 and AArch64 both use the asm-generic table. */ +#elif defined(__riscv) || defined(__aarch64__) #define __syscall_exit 93 #define __syscall_read 63 #define __syscall_write 64 @@ -41,11 +54,15 @@ #define __syscall_open 1024 #define __syscall_openat 56 #define __syscall_lseek 62 + +/* That table has no chmod at all -- 90 is capget there -- so path-based mode + * changes go through fchmodat(2). + */ +#define __syscall_fchmodat 53 #define __syscall_mmap2 222 #define __syscall_munmap 215 #elif defined(__x86_64__) -#define __SIZEOF_POINTER__ 8 #define __syscall_exit 60 #define __syscall_read 0 #define __syscall_write 1 @@ -53,6 +70,7 @@ #define __syscall_open 2 #define __syscall_openat 257 #define __syscall_lseek 8 +#define __syscall_chmod 90 #define __syscall_mmap 9 #define __syscall_munmap 11 @@ -61,7 +79,7 @@ */ #define __syscall_mmap2 9 -#else /* Only Arm32, RV32, and x86-64 are supported */ +#else #error "Unsupported architecture" #endif @@ -98,6 +116,7 @@ typedef int FILE; FILE *fopen(char *filename, char *mode); int fclose(FILE *stream); +int chmod(char *filename, int mode); int fgetc(FILE *stream); char *fgets(char *str, int n, FILE *stream); int fputc(int c, FILE *stream); diff --git a/mk/arm64.mk b/mk/arm64.mk new file mode 100644 index 00000000..a67120be --- /dev/null +++ b/mk/arm64.mk @@ -0,0 +1,31 @@ +# AArch64 Linux / AAPCS64 target. +ARCH_RUNNER = qemu-aarch64 +ARCH_DEFS = \ + "/* target: AArch64 */\n$\ + \#pragma once\n$\ + \#define ARCH_PREDEFINED \"__aarch64__\"\n$\ + \#define ELF_MACHINE 0xb7 /* EM_AARCH64 */\n$\ + \#define ELF_FLAGS 0\n$\ + \#define PTR_SIZE 8\n$\ + \#define MAX_ARGS_IN_REG 8\n$\ + /* Eight register arguments leave no outgoing stack area at the default\n$\ + * limit of eight, so a ninth argument would land on the caller's own\n$\ + * locals. */\n$\ + \#define MAX_PARAMS 16\n$\ + /* AArch64 Linux uses a 4K, 16K or 64K translation granule. Separating the\n$\ + * load segments by the largest keeps the image loadable on all three; a 4K\n$\ + * gap leaves both inside one 64K page, where the writable mapping replaces\n$\ + * the executable one. */\n$\ + \#define PAGESIZE 65536\n$\ + \#define REG_CNT 11\n$\ + \#define CALLEE_SAVED_REGS 3\n$\ + \#define DYN_LINKER \"/lib/ld-linux-aarch64.so.1\"\n$\ + \#define LIBC_SO \"libc.so.6\"\n$\ + \#define PLT_FIXUP_SIZE 0\n$\ + \#define PLT_ENT_SIZE 16\n$\ + \#define RESERVED_GOT_NUM 3\n$\ + \#define R_ARCH_JUMP_SLOT 1026 /* R_AARCH64_JUMP_SLOT */\n$\ + \#define DYN_BIND_NOW 1\n$\ + " + +TOOLCHAIN_CANDIDATES := aarch64-linux-gnu- aarch64-none-linux-gnu- diff --git a/src/arm64-codegen.c b/src/arm64-codegen.c new file mode 100644 index 00000000..e1589a54 --- /dev/null +++ b/src/arm64-codegen.c @@ -0,0 +1,731 @@ +/* + * AArch64 Linux code generator. The allocator's virtual registers map to + * x0..x7, x20..x22; x16/x17 are reserved scratch and x19 is the global base. + */ +#include "defs.h" +#include "globals.c" + +#define A64_SP 31 +#define A64_ZR 31 + +/* IP0, the linker's own scratch register: free for a code generator to use + * between instructions, and never allocated to a value. + */ +#define A64_IP0 16 +/* Base of the synthetic global frame, held for the life of the program. */ +#define A64_GP 19 + +/* AAPCS64 keeps SP 16-byte aligned, and the prologue saves five registers in + * three pairs. Every frame calculation derives from these two. + */ +#define A64_STACK_ALIGN 16 +#define A64_SAVE_BYTES 48 + +int a64_reg(int r) +{ + return r < 8 ? r : r + 12; +} +void emit(int insn) +{ + elf_write_int(elf_code, insn); +} +void a64_mov(int d, int n) +{ + emit(0xaa0003e0 | (n << 16) | d); +} + +/* SP is not a general register in logical instructions: ORR would read ZR. ADD + * #0 is the architectural move spelling when either operand is SP. + */ +void a64_mov_sp(int d) +{ + emit(0x910003e0 | (A64_SP << 5) | d); +} +void a64_mov_imm(int d, int v) +{ + /* Integer constants participate in pointer arithmetic throughout the + * compiler. A negative C int must therefore become a sign-extended + * X-register value, not 0x00000000ffffffff-style zero extension. MOVN + * supplies the upper one bits while MOVK fills the second halfword. + */ + if (v < 0) + emit(0x92800000 | (((~v) & 0xffff) << 5) | d); + else + emit(0xd2800000 | ((v & 0xffff) << 5) | d); + emit(0xf2800000 | (((v >> 16) & 0xffff) << 5) | d | (1 << 21)); +} +void a64_add(int d, int n, int m) +{ + int op = (d == A64_SP || n == A64_SP) ? 0x8b206000 : 0x8b000000; + emit(op | (m << 16) | (n << 5) | d); +} +void a64_sub(int d, int n, int m) +{ + int op = (d == A64_SP || n == A64_SP) ? 0xcb206000 : 0xcb000000; + emit(op | (m << 16) | (n << 5) | d); +} +void a64_sxtw(int d, int n) +{ + emit(0x93407c00 | (n << 5) | d); +} + +/* Xd = Xn +/- sign_extend(Wm). The extended-register forms fold the widening of + * an int index into the address arithmetic that consumes it, so pointer + * arithmetic costs the same one instruction as any other add. + */ +void a64_add_sxtw(int d, int n, int m) +{ + emit(0x8b20c000 | (m << 16) | (n << 5) | d); +} +void a64_sub_sxtw(int d, int n, int m) +{ + emit(0xcb20c000 | (m << 16) | (n << 5) | d); +} + +/* Sign-extend the low @size bytes of Xn into Xd. Always one instruction, so + * update_elf_offset()'s default estimate stays correct. + */ +void a64_extend(int d, int n, int size) +{ + if (size == 1) + emit(0x93401c00 | (n << 5) | d); + else if (size == 2) + emit(0x93403c00 | (n << 5) | d); + else if (size == 4) + a64_sxtw(d, n); + else + a64_mov(d, n); +} + +/* AArch64's ordinary LDR/STR immediate is scaled by the access width. The + * compiler deliberately supports packed C layouts, so structure members are + * often not naturally aligned (func_t.bbs, for example). Such offsets must use + * the byte-addressed LDUR/STUR form; rounding them down corrupts adjacent + * fields during self-hosting. + */ +int a64_mem_count(int size, int ofs) +{ + if (ofs >= 0 && ofs <= 4095 * size && !(ofs % size)) + return 1; + if (ofs >= -256 && ofs <= 255) + return 1; + return 4; /* MOVZ/MOVK + ADD + [base] access */ +} + +/* Base opcode of the unscaled load/store of @size bytes. Size lives in bits + * 31:30 and the operation in 23:22, where a load picks the sign-extending form + * for every width narrower than a doubleword -- values sit sign-extended in + * their X register, so a narrow load must widen the same way whichever + * addressing form carries it. The scaled form is this plus bit 24, which is why + * one table serves both and they can no longer disagree. + */ +int a64_mem_op(int load, int size) +{ + int sf; + if (size == 8) + sf = 3; + else if (size == 4) + sf = 2; + else if (size == 2) + sf = 1; + else if (size == 1) + sf = 0; + else { + fatal("unsupported arm64 access width"); + return 0; + } + if (!load) + return 0x38000000 | (sf << 30); + return 0x38000000 | (sf << 30) | ((size == 8 ? 1 : 2) << 22); +} +void a64_mem(int load, int size, int rt, int rn, int ofs) +{ + int op = a64_mem_op(load, size); + if (ofs >= -256 && ofs <= 255 && (ofs < 0 || ofs % size)) { + emit(op | ((ofs & 0x1ff) << 12) | (rn << 5) | rt); /* LDUR/STUR */ + return; + } + if (ofs < 0 || ofs > 4095 * size || ofs % size) { + a64_mov_imm(A64_IP0, ofs); + a64_add(A64_IP0, rn, A64_IP0); + rn = A64_IP0; + ofs = 0; + } + emit(op | (1 << 24) | ((ofs / size) << 10) | (rn << 5) | rt); +} + +/* Masking an out-of-range displacement silently branches somewhere else. The + * generated image is small enough that these never fire today, so say so rather + * than let a larger input fail as a wild jump. + */ +void a64_check_disp(int disp, int bits, char *what) +{ + int lim = 1 << (bits - 1); + if (disp < -lim || disp >= lim) + fatal(what); +} + +/* Branch to @target when @rt is non-zero. CBNZ carries the same 19-bit + * displacement a B.cond would, so it replaces a compare-and-branch pair + * outright. @wide picks the X form, which a pointer needs: testing only Wn + * reads an address whose low word happens to be zero as null. + */ +void a64_cbnz(bool wide, int rt, int target) +{ + int disp = (target - elf_code->size) / 4; + a64_check_disp(disp, 19, "arm64 conditional branch out of range"); + emit((wide ? 0xb5000000 : 0x35000000) | ((disp & 0x7ffff) << 5) | rt); +} +void a64_b(int target) +{ + int d = (target - elf_code->size) / 4; + a64_check_disp(d, 26, "arm64 branch out of range"); + emit(0x14000000 | (d & 0x3ffffff)); +} +void a64_bl_addr(int target) +{ + int d = (target - (elf_code_start + elf_code->size)) / 4; + a64_check_disp(d, 26, "arm64 call out of range"); + emit(0x94000000 | (d & 0x3ffffff)); +} +int a64_adrp_insn(int d, int pc, int target) +{ + int pages = (target >> 12) - (pc >> 12); + a64_check_disp(pages, 21, "arm64 ADRP target out of range"); + return 0x90000000 | ((pages & 3) << 29) | (((pages >> 2) & 0x7ffff) << 5) | + d; +} + +/* Which operand of an address expression is the int index that must widen: 0 + * for neither, 1 for src0, 2 for src1. Exactly one source being an address is + * what makes the other an index, so the two source flags decide alone. + */ +int a64_ptr_index(ph2_ir_t *p) +{ + if (p->src0_is_pointer == p->src1_is_pointer) + return 0; + return p->src0_is_pointer ? 2 : 1; +} + +/* A comparison is as wide as the values it compares. An address has to be + * compared whole, an array included, since what reaches the instruction is its + * decayed base. The source flags say so and is_pointer does not: it counts + * pointer-like operands alone, for the sake of the 32-bit targets that read it. + */ +bool a64_cmp_wide(ph2_ir_t *p) +{ + return p->src0_is_pointer || p->src1_is_pointer; +} + +int a64_cond(opcode_t op) +{ + switch (op) { + case OP_eq: + return 0; + case OP_neq: + return 1; + case OP_geq: + return 10; + case OP_lt: + return 11; + case OP_gt: + return 12; + default: + return 13; + } +} + +/* All estimates below exactly match emit_ph2_ir(). Fixed-width AArch64 makes + * this deliberately simpler and more reliable than a relocation pass. + */ +void update_elf_offset(ph2_ir_t *ir) +{ + int n = 1; + switch (ir->op) { + case OP_allocat: + n = 0; + break; + case OP_assign: + n = ir->dest == ir->src0 ? 0 : 1; + break; + case OP_load_constant: + case OP_load_data_address: + case OP_load_rodata_address: + n = 2; + break; + case OP_address_of: + case OP_global_address_of: + n = 3; + break; + case OP_load: + case OP_global_load: + n = a64_mem_count(ir->size_bytes, ir->src0); + break; + case OP_store: + case OP_global_store: + n = a64_mem_count(ir->size_bytes, ir->src1); + break; + case OP_address_of_func: + n = 3; + break; + case OP_return: + n = (ir->src0 >= 0 && a64_reg(ir->src0) != 0) ? 8 : 7; + break; + case OP_branch: + n = 2; + break; + case OP_mod: + n = 2; + break; + case OP_eq: + case OP_neq: + case OP_gt: + case OP_lt: + case OP_geq: + case OP_leq: + case OP_log_not: + n = 2; + break; + default: + break; + } + elf_offset += n * 4; +} + +void cfg_flatten(void) +{ + func_t *f; + + /* Entry sequence lengths, which the block offsets below start after. + * Static: 12 instructions of setup, then the 9-instruction __syscall + * helper, so 21 in all. Dynamic: 23, having no __syscall helper but saving + * the original stack pointer for __libc_start_main's stack_end argument, + * spilling argc/argv, and calling memset to clear the frame. + */ + f = find_func("__syscall"); + if (f && f->bbs) { + if (dynlink) + f->bbs->elf_offset = 0; + else + f->bbs->elf_offset = 12 * 4; + } + if (dynlink) + elf_offset = 23 * 4; + else + elf_offset = 21 * 4; + GLOBAL_FUNC->bbs->elf_offset = elf_offset; + for (ph2_ir_t *p = GLOBAL_FUNC->bbs->ph2_ir_list.head; p; p = p->next) + update_elf_offset(p); + + /* code_generate() emits the call to main only when there is one, so a + * translation unit without main must not be charged for it either. + */ + if (MAIN_BB) { + int global_frame = ALIGN_UP(GLOBAL_FUNC->stack_size, A64_STACK_ALIGN); + if (dynlink) + elf_offset += (10 + a64_mem_count(8, global_frame) + + a64_mem_count(8, global_frame + 8)) * + 4; + else + elf_offset += 6 * 4; + } + for (f = FUNC_LIST.head; f; f = f->next) { + if (!f->bbs) + continue; + ph2_ir_t *d = add_ph2_ir(OP_define); + d->src0 = f->stack_size; + d->func_name = intern_string(f->return_def.var_name); + + /* Where the caller's stack arguments sit, seen from the callee's own + * SP. This must round the frame exactly as the prologue does: rounding + * to MIN_ALIGNMENT instead left it eight bytes short whenever + * stack_size % 16 == 8, and every stack-passed argument was then read + * one slot low. + */ + int stack_top_ofs = + ALIGN_UP(f->stack_size, A64_STACK_ALIGN) + A64_SAVE_BYTES; + for (basic_block_t *b = f->bbs; b; b = b->rpo_next) { + b->elf_offset = elf_offset; + + /* The entry block's offset deliberately names the prologue so calls + * enter at a valid function entry. Later block labels must however + * account for that prologue before their first IR word. + */ + if (b == f->bbs) { + elf_offset += 7 * 4; + if (dynlink && !strcmp(f->return_def.var_name, "main")) + elf_offset += 3 * 4; + } + for (ph2_ir_t *p = b->ph2_ir_list.head; p; p = p->next) { + if (p->ofs_based_on_stack_top) { + if (p->op == OP_load || p->op == OP_address_of) + p->src0 += stack_top_ofs; + else if (p->op == OP_store) + p->src1 += stack_top_ofs; + } + ph2_ir_t *q = add_existed_ph2_ir(p); + if (q->op == OP_return) + q->src1 = f->stack_size; + update_elf_offset(q); + } + } + } +} + +void emit_ph2_ir(ph2_ir_t *p) +{ + int d = a64_reg(p->dest), n = a64_reg(p->src0), m = a64_reg(p->src1); + switch (p->op) { + case OP_define: { + bool reload_global_base = dynlink && !strcmp(p->func_name, "main"); + emit(0xa9bf7bfd); /* stp x29, x30, [sp, #-16]! */ + emit(0x910003fd); /* mov x29, sp */ + emit(0xa9bf57f4); /* stp x20, x21, [sp, #-16]! */ + /* x19 holds the synthetic global-frame base, but AAPCS64 makes it + * callee-saved and glibc calls into this code at main. Saving it + * alongside x22 costs nothing: the slot was half empty anyway. + */ + emit(0xa9bf4ff6); /* stp x22, x19, [sp, #-16]! */ + a64_mov_imm(A64_IP0, ALIGN_UP(p->src0, A64_STACK_ALIGN)); + a64_sub(A64_SP, A64_SP, A64_IP0); + + /* Reload x19 rather than trust what called us: glibc enters at main, + * and AAPCS64 lets everything in between clobber a callee-saved + * register it has saved. The leading 1 is a64_mem()'s load selector, so + * this reads the base back from the word parked at elf_data_start. The + * frame itself is allocated once in code_generate(), which is also + * where the question of clearing it is settled. + */ + if (reload_global_base) { + a64_mov_imm(A64_IP0, elf_data_start); + a64_mem(1, 8, A64_GP, A64_IP0, 0); + } + return; + } + case OP_load_constant: + a64_mov_imm(d, p->src0); + return; + case OP_assign: + if (d != n) + a64_mov(d, n); + return; + case OP_address_of: + a64_mov_imm(A64_IP0, p->src0); + a64_add(d, A64_SP, A64_IP0); + return; + case OP_global_address_of: + a64_mov_imm(A64_IP0, p->src0); + a64_add(d, A64_GP, A64_IP0); + return; + case OP_load: + a64_mem(1, p->size_bytes, d, A64_SP, p->src0); + return; + case OP_global_load: + a64_mem(1, p->size_bytes, d, A64_GP, p->src0); + return; + case OP_store: + a64_mem(0, p->size_bytes, n, A64_SP, p->src1); + return; + case OP_global_store: + a64_mem(0, p->size_bytes, n, A64_GP, p->src1); + return; + case OP_read: + a64_mem(1, p->src1, d, n, 0); + return; + case OP_write: + a64_mem(0, p->dest, m, n, 0); + return; + case OP_add: + /* Index expressions are int-valued, so the index has to widen before it + * joins a 64-bit address; otherwise -1 becomes +4294967295 on LP64. + * Addition is commutative, so either operand may be the index. + * + * The fall-through stays the X form for both cases a64_ptr_index() + * reports as 0: two addresses, which is pointer minus pointer and + * genuinely 64-bit, and two ints, whose upper half no reader looks at. + * See the width note at the comparisons below. + */ + if (a64_ptr_index(p) == 2) + a64_add_sxtw(d, n, m); + else if (a64_ptr_index(p) == 1) + a64_add_sxtw(d, m, n); + else + a64_add(d, n, m); + return; + case OP_sub: + /* Only pointer-minus-int widens: int-minus-pointer is not an address + * expression, and pointer-minus-pointer is already 64-bit on both + * sides. + */ + if (a64_ptr_index(p) == 2) + a64_sub_sxtw(d, n, m); + else + a64_sub(d, n, m); + return; + case OP_mul: + emit(0x1b007c00 | (m << 16) | (n << 5) | d); + return; + case OP_div: + emit(0x1ac00c00 | (m << 16) | (n << 5) | d); + return; + case OP_mod: + /* d = n % m. Do not put the quotient in d: register coalescing may make + * d alias n, losing the minuend before MSUB reads it. + */ + emit(0x1ac00c00 | (m << 16) | (n << 5) | A64_IP0); + emit(0x1b008000 | (m << 16) | (n << 10) | (A64_IP0 << 5) | d); + return; + case OP_lshift: + emit(0x1ac02000 | (m << 16) | (n << 5) | d); + return; + case OP_rshift: + emit(0x1ac02800 | (m << 16) | (n << 5) | d); + return; + case OP_bit_and: + emit(0x0a000000 | (m << 16) | (n << 5) | d); + return; + case OP_bit_or: + emit(0x2a000000 | (m << 16) | (n << 5) | d); + return; + case OP_bit_xor: + emit(0x4a000000 | (m << 16) | (n << 5) | d); + return; + case OP_negate: + emit(0x4b0003e0 | (n << 16) | d); + return; + case OP_bit_not: + emit(0x2a2003e0 | (n << 16) | d); + return; + case OP_eq: + case OP_neq: + case OP_gt: + case OP_lt: + case OP_geq: + case OP_leq: + emit((a64_cmp_wide(p) ? 0xeb00001f : 0x6b00001f) | (m << 16) | + (n << 5)); + emit((a64_cmp_wide(p) ? 0x9a9f07e0 : 0x1a9f07e0) | + ((a64_cond(p->op) ^ 1) << 12) | d); + return; + + /* Width follows the operand, exactly as the comparisons above do. A pointer + * must be tested whole, or one whose low word happens to be zero reads as + * null. An int must not be: multiply, divide, shift and the bitwise + * operations all use the W forms, which leave the upper half zeroed rather + * than sign-extended, so only the low word is the value. + */ + case OP_log_not: + emit((p->src0_is_pointer ? 0xf100001f : 0x7100001f) | (n << 5)); + emit(0x1a9f07e0 | (1 << 12) | d); + return; + + /* OP_trunc's src1 is the target width; OP_sign_ext's packs the source width + * in its upper half (see promote_unchecked()). Decoding both the same way + * made every promotion a plain move. + */ + case OP_trunc: + a64_extend(d, n, p->src1); + return; + case OP_sign_ext: { + int src_size = (p->src1 >> 16) & 0xffff, dst_size = p->src1 & 0xffff; + + /* Widening to a pointer: the register already holds a full address, so + * extending it from 32 bits would discard the upper half. + */ + if (dst_size == PTR_SIZE) + a64_mov(d, n); + else + a64_extend(d, n, src_size); + return; + } + case OP_cast: + a64_mov(d, n); + return; + case OP_branch: + a64_cbnz(p->src0_is_pointer, n, p->then_bb->elf_offset); + a64_b(p->else_bb->elf_offset); + return; + case OP_jump: + a64_b(p->next_bb->elf_offset); + return; + case OP_call: { + func_t *f = find_func(p->func_name); + if (!f) + fatal("arm64 call to unknown function"); + if (!f->bbs) { + if (!dynlink) + fatal("arm64 external call requires --dynlink"); + a64_bl_addr(dynamic_sections.elf_plt_start + f->plt_offset); + } else + emit(0x94000000 | + (((f->bbs->elf_offset - elf_code->size) / 4) & 0x3ffffff)); + return; + } + case OP_load_data_address: + a64_mov_imm(d, p->src0 + elf_data_start); + return; + case OP_load_rodata_address: + a64_mov_imm(d, p->src0 + elf_rodata_start); + return; + case OP_address_of_func: { + func_t *f = find_func(p->func_name); + int target; + if (!f) + fatal("arm64 address of unknown function"); + if (!f->bbs) { + if (!dynlink) + fatal("arm64 external function address requires --dynlink"); + + /* A PLT entry is a stable callable address. With eager binding, its + * GOT slot already holds the resolved external target. + */ + target = dynamic_sections.elf_plt_start + f->plt_offset; + } else + target = elf_code_start + f->bbs->elf_offset; + a64_mov_imm(A64_IP0, target); + a64_mem(0, 8, A64_IP0, n, 0); + return; + } + case OP_load_func: + a64_mov(A64_IP0, n); + return; + case OP_indirect: + emit(0xd63f0200); + return; + case OP_return: + if (p->src0 >= 0 && n != 0) + a64_mov(0, n); + a64_mov_imm(A64_IP0, ALIGN_UP(p->src1, A64_STACK_ALIGN)); + a64_add(A64_SP, A64_SP, A64_IP0); + emit(0xa8c14ff6); /* ldp x22, x19, [sp], #16 */ + emit(0xa8c157f4); /* ldp x20, x21, [sp], #16 */ + emit(0xa8c17bfd); /* ldp x29, x30, [sp], #16 */ + emit(0xd65f03c0); /* ret */ + return; + default: + fatal("unknown arm64 opcode"); + } +} + +/* Each PLT entry is ADRP/ADD to form the GOT slot address, then an indirect + * branch through it. ADD carries the byte offset rather than folding it into a + * scaled LDR, because .got follows the byte-aligned interpreter string and so + * is not guaranteed to be eight-byte aligned. + */ +void plt_generate(void) +{ + int entries = dynamic_sections.plt_size / PLT_ENT_SIZE; + for (int i = 0; i < entries; i++) { + int ent = dynamic_sections.elf_plt_start + i * PLT_ENT_SIZE; + int got = + dynamic_sections.elf_got_start + PTR_SIZE * (RESERVED_GOT_NUM + i); + elf_write_int(dynamic_sections.elf_plt, + a64_adrp_insn(A64_IP0, ent, got)); + elf_write_int(dynamic_sections.elf_plt, + 0x91000210 | ((got & 0xfff) << 10)); /* add x16, x16, # */ + elf_write_int(dynamic_sections.elf_plt, + 0xf9400211); /* ldr x17, [x16] */ + elf_write_int(dynamic_sections.elf_plt, 0xd61f0220); /* br x17 */ + } +} + +void code_generate(void) +{ + int global_frame = ALIGN_UP(GLOBAL_FUNC->stack_size, A64_STACK_ALIGN); + + /* At Linux process entry argc is at [sp] and argv begins at sp + 8; they + * are loaded into x20 and x21. Those two are allocatable by the global + * initialiser, so the values are parked in x23/x24 -- callee-saved, and + * outside this backend's allocation set -- to survive it. + */ + if (dynlink) + a64_mov_sp(25); + a64_mem(1, 8, 20, A64_SP, 0); + emit(0x910023f5); + a64_mov(23, 20); + a64_mov(24, 21); + if (dynlink) { + a64_mov_imm(A64_IP0, A64_STACK_ALIGN); + a64_sub(A64_SP, A64_SP, A64_IP0); + a64_mem(0, 8, 23, A64_SP, 0); + a64_mem(0, 8, 24, A64_SP, 8); + } + + /* The synthetic global frame is carved out of the runtime stack, and x19 + * keeps its base; only that base is parked in the data segment, where a + * prologue entered from glibc can read it back. + * + * Clearing it is the dynamic build's job alone. A static image is the first + * thing to run, so the stack below the entry SP is untouched anonymous + * memory and already reads as zero. A dynamic one has had the loader and + * glibc's startup run over that same memory first, so a global with no + * initializer would otherwise begin life holding their leftovers. + */ + a64_mov_imm(A64_IP0, global_frame); + a64_sub(A64_SP, A64_SP, A64_IP0); + a64_mov_sp(A64_GP); + a64_mov_imm(A64_IP0, elf_data_start); + a64_mem(0, 8, A64_GP, A64_IP0, 0); + if (dynlink) { + func_t *memset_func = find_func("memset"); + if (!memset_func) + fatal("arm64 dynamic startup needs memset"); + a64_mov(0, A64_GP); + a64_mov(1, A64_ZR); + a64_mov_imm(2, global_frame); + a64_bl_addr(dynamic_sections.elf_plt_start + memset_func->plt_offset); + } + a64_b(GLOBAL_FUNC->bbs->elf_offset); + /* __syscall(number,arg1,...): AArch64 Linux wants x8,x0..x5. */ + if (!dynlink) { + a64_mov(8, 0); + a64_mov(0, 1); + a64_mov(1, 2); + a64_mov(2, 3); + a64_mov(3, 4); + a64_mov(4, 5); + a64_mov(5, 6); + emit(0xd4000001); + emit(0xd65f03c0); + } + for (ph2_ir_t *p = GLOBAL_FUNC->bbs->ph2_ir_list.head; p; p = p->next) + emit_ph2_ir(p); + if (MAIN_BB) { + if (dynlink) { + a64_mem(1, 8, 23, A64_SP, global_frame); + a64_mem(1, 8, 24, A64_SP, global_frame + 8); + a64_mov_imm(0, elf_code_start + MAIN_BB->elf_offset); + a64_mov(1, 23); + a64_mov(2, 24); + a64_mov(3, A64_ZR); + a64_mov(4, A64_ZR); + a64_mov(5, A64_ZR); + a64_mov(6, 25); + a64_bl_addr(dynamic_sections.elf_plt_start + PLT_FIXUP_SIZE); + emit(0xd4200000); /* __libc_start_main does not return */ + } else { + a64_mov(0, 23); + a64_mov(1, 24); + emit(0x94000000 | + (((MAIN_BB->elf_offset - elf_code->size) / 4) & 0x3ffffff)); + a64_mov_imm(8, 93); + emit(0xd4000001); + } + } + for (int i = 0; i < ph2_ir_idx; i++) + emit_ph2_ir(PH2_IR_FLATTEN[i]); + if (elf_code->size != elf_offset) + fatal("arm64 code-size accounting mismatch"); + if (dynlink) { + /* Dynamic addresses depend on the final text extent. The ARM64 stream + * is fixed-width, but .rodata may still have gained alignment padding + * since elf_preprocess(); rebuild the address-bearing tables + * immediately before writing PLT bytes. + */ + elf_rodata_start = elf_code_start + elf_code->size; + elf_layout_dynamic(); + elf_reset_dynamic_sections(); + elf_generate_dynamic_sections(); + plt_generate(); + } +} diff --git a/src/defs.h b/src/defs.h index c99fcc3e..3534f2fe 100644 --- a/src/defs.h +++ b/src/defs.h @@ -45,7 +45,17 @@ */ #define DUMP_INSN_LEN 512 #define MAX_TYPE_LEN 32 + +/* Declaration limit, and with MAX_ARGS_IN_REG it also sizes the outgoing + * stack-argument area every frame reserves (see add_func() in globals.c). A + * target passing many arguments in registers must raise it or that area is + * empty and a call with more arguments overwrites the caller's first locals. + * Raising it for everyone would widen func_t.param_defs and the variadic spill + * on targets that gain nothing, so each mk file states its own. + */ +#ifndef MAX_PARAMS #define MAX_PARAMS 8 +#endif #define MAX_LOCALS 3200 #define MAX_FIELDS 64 #define MAX_TYPES 256 @@ -177,6 +187,7 @@ #define ELF_MACHINE_ARM32 0x28 #define ELF_MACHINE_RV32 0xf3 #define ELF_MACHINE_X86_64 0x3e +#define ELF_MACHINE_AARCH64 0xb7 /* ELF class of the active target: a 64-bit pointer means ELF64, and every * 32-bit target means ELF32. Used to select the header/segment writers in @@ -690,6 +701,11 @@ struct ph2_ir { */ bool ofs_based_on_stack_top; bool is_pointer; /* True if this operation involves a pointer type */ + /* Operand provenance is required by LP64 backends: pointer arithmetic keeps + * the address operand wide but sign-extends an int index. + */ + bool src0_is_pointer; + bool src1_is_pointer; }; typedef struct ph2_ir ph2_ir_t; diff --git a/src/elf.c b/src/elf.c index 44043dc4..66ec0e34 100644 --- a/src/elf.c +++ b/src/elf.c @@ -155,8 +155,10 @@ void elf_write_jmprel(strbuf_t *buf, int offset, int sym_idx) #endif } -/* One GOT slot. */ -void elf_write_got_slot(strbuf_t *buf, int val) +/* One pointer-sized value: eight bytes on an ELF64 target, four on ELF32. Used + * for GOT slots and for any data-section object holding an address. + */ +void elf_write_ptr(strbuf_t *buf, int val) { #if ELF_IS_64 == 1 elf_write_quad(buf, val); @@ -171,6 +173,16 @@ void elf_write_got_slot(strbuf_t *buf, int val) */ void elf_layout_dynamic(void) { + /* Everything below is placed from the end of .rodata, and .got holds + * pointers, so that end has to be pointer-aligned. Padding here rather than + * in the caller keeps the two runs of this function in agreement -- the pad + * is already present the second time, so it is idempotent -- which a + * backend that bakes the PLT address into its calls, as AArch64's BL does, + * depends on. + */ + while ((elf_rodata_start + elf_rodata->size) % PTR_SIZE) + elf_write_byte(elf_rodata, 0); + int relplt_bytes = dynamic_sections.use_relaplt ? dynamic_sections.relaplt_size : dynamic_sections.relplt_size; @@ -1004,20 +1016,21 @@ void elf_generate_dynamic_sections(void) switch (ELF_MACHINE) { case ELF_MACHINE_ARM32: case ELF_MACHINE_X86_64: + case ELF_MACHINE_AARCH64: /* GOT[0] holds the address of .dynamic. The GOT is still being built, * so its final size comes from got_size rather than the buffer. */ - elf_write_got_slot(dynamic_sections.elf_got, - dynamic_sections.elf_got_start + - dynamic_sections.got_size + - dynamic_sections.elf_dynstr->size + - dynamic_sections.elf_dynsym->size); - elf_write_got_slot(dynamic_sections.elf_got, 0); - elf_write_got_slot(dynamic_sections.elf_got, 0); + elf_write_ptr(dynamic_sections.elf_got, + dynamic_sections.elf_got_start + + dynamic_sections.got_size + + dynamic_sections.elf_dynstr->size + + dynamic_sections.elf_dynsym->size); + elf_write_ptr(dynamic_sections.elf_got, 0); + elf_write_ptr(dynamic_sections.elf_got, 0); break; case ELF_MACHINE_RV32: - elf_write_got_slot(dynamic_sections.elf_got, 0); - elf_write_got_slot(dynamic_sections.elf_got, 0); + elf_write_ptr(dynamic_sections.elf_got, 0); + elf_write_ptr(dynamic_sections.elf_got, 0); break; } int got_idx = 0; @@ -1032,7 +1045,7 @@ void elf_generate_dynamic_sections(void) */ slot = dynamic_sections.elf_plt_start + PLT_FIXUP_SIZE + got_idx * PLT_ENT_SIZE + 6; - elf_write_got_slot(dynamic_sections.elf_got, slot); + elf_write_ptr(dynamic_sections.elf_got, slot); got_idx++; } @@ -1187,6 +1200,13 @@ void elf_preprocess(void) elf_code_start = ELF_START + elf_header_len; elf_rodata_start = elf_code_start + elf_offset; if (dynlink) { + /* Dynamic startup clears the synthetic global frame with memset. + * Reserve its PLT slot even when user code does not otherwise call it. + */ + func_t *memset_func = find_func("memset"); + if (memset_func) + memset_func->is_used = true; + /* Precalculate the sizes of .rel.plt (.rela.plt), .plt and .got * sections. * @@ -1242,9 +1262,19 @@ void elf_preprocess(void) } else { /* To prevent two load segments from sharing a common page, add PAGESIZE * to elf_data_start, since the first section of the second load segment - * is .data in static linking mode. + * is .data in static linking mode. ELF requires p_offset and p_vaddr to + * agree modulo p_align. ELF64 output pads the file to the next page + * before .data, so derive its virtual address from that same aligned + * file offset rather than merely adding a page to the preceding virtual + * end. */ +#if ELF_IS_64 == 1 + elf_data_start = + ELF_START + + ALIGN_UP(elf_header_len + elf_offset + elf_rodata->size, PAGESIZE); +#else elf_data_start = elf_rodata_start + elf_rodata->size + PAGESIZE; +#endif } elf_bss_start = elf_data_start + elf_data->size; elf_align(elf_symtab); @@ -1340,11 +1370,21 @@ void elf_generate(const char *outfile) if (!dynlink) { int ro_written = elf_header_len + elf_code->size + elf_rodata->size; int data_ofs = ALIGN_UP(ro_written, PAGESIZE); - char pad[PAGESIZE]; - for (int i = 0; i < PAGESIZE; i++) + /* Written in chunks rather than from one PAGESIZE-sized buffer: a + * target with a 64 KiB granule would otherwise put 64 KiB on the stack, + * and zero all of it to emit the few bytes actually needed. + */ + char pad[256]; + int left = data_ofs - ro_written; + + for (int i = 0; i < 256; i++) pad[i] = 0; - elf_write_all(fp, pad, data_ofs - ro_written); + while (left > 0) { + int n = left < 256 ? left : 256; + elf_write_all(fp, pad, n); + left -= n; + } } #endif /* Readable and writable sections */ @@ -1364,4 +1404,13 @@ void elf_generate(const char *outfile) elf_write_all(fp, elf_section_header->elements, elf_section_header->size); #endif fclose(fp); + + /* A generated ELF is meant to be runnable directly, but the mode it gets + * depends on which libc opened it, not on how the output is linked: the + * embedded lib/c.c passes 0775 to openat(2) while glibc's fopen("wb") + * yields 0666. Every compiler that reaches here -- host-built, static + * self-hosted, or dynamic -- therefore sets the bits explicitly. + */ + if (chmod((char *) outfile, 0x1ed) < 0) /* 0755 */ + usage_error("Unable to mark output executable"); } diff --git a/src/globals.c b/src/globals.c index 956f0a92..dd89767b 100644 --- a/src/globals.c +++ b/src/globals.c @@ -11,6 +11,7 @@ #include #include #include +#include #include "defs.h" @@ -636,6 +637,8 @@ ph2_ir_t *add_ph2_ir(opcode_t op) */ ph2_ir->size_bytes = PTR_SIZE; ph2_ir->is_pointer = false; + ph2_ir->src0_is_pointer = false; + ph2_ir->src1_is_pointer = false; return add_existed_ph2_ir(ph2_ir); } @@ -1552,10 +1555,9 @@ void global_init(void) dynamic_sections.use_relaplt = false; break; case ELF_MACHINE_RV32: - dynamic_sections.use_relaplt = true; - break; case ELF_MACHINE_X86_64: - /* x86-64 uses RELA throughout. */ + case ELF_MACHINE_AARCH64: + /* Every target but Arm32 uses RELA throughout. */ dynamic_sections.use_relaplt = true; break; } diff --git a/src/parser.c b/src/parser.c index 8a2062ee..1d710e1f 100644 --- a/src/parser.c +++ b/src/parser.c @@ -5612,7 +5612,11 @@ void parse_internal(void) { /* set starting point of global stack manually */ GLOBAL_FUNC = add_func("", true); - GLOBAL_FUNC->stack_size = 4; + + /* The first global slot retains the synthetic global-frame pointer. It must + * occupy a full target pointer, not the historic 32-bit word. + */ + GLOBAL_FUNC->stack_size = PTR_SIZE; GLOBAL_FUNC->bbs = arena_calloc(BB_ARENA, 1, sizeof(basic_block_t)); GLOBAL_FUNC->bbs->belong_to = GLOBAL_FUNC; /* Prevent nullptr deref in RA */ GLOBAL_FUNC->bbs->elf_offset = -1; /* not yet emitted */ @@ -5676,9 +5680,11 @@ void parse_internal(void) /* Add a global object to the .data section. * - * This object is used to save the global stack pointer. + * This object saves the global stack pointer, so it is written back as a + * pointer and must reserve a full one: on an LP64 target the historic + * 32-bit word left four bytes belonging to the next global. */ - elf_write_int(elf_data, 0); + elf_write_ptr(elf_data, 0); /* lexer initialization */ do { diff --git a/src/reg-alloc.c b/src/reg-alloc.c index 913646c6..3e38c15c 100644 --- a/src/reg-alloc.c +++ b/src/reg-alloc.c @@ -22,6 +22,36 @@ bool is_pointer_like(var_t *v) return v && (v->ptr_level > 0 || (v->type && v->type->ptr_level > 0)); } +/* An operand holds an address if it is pointer-like or is an array, which + * decays to one wherever it is used as a value. A subscript reaches OP_add with + * the array itself as the base, so leaving arrays out here would let an LP64 + * backend read a[i] as ordinary arithmetic and skip widening the index. + */ +bool is_address_like(var_t *v) +{ + return is_pointer_like(v) || (v && v->array_size > 0); +} + +/* Record which operands of a three-address instruction are addresses. + * + * An LP64 backend needs the operands apart, not just the instruction: pointer + * arithmetic keeps the address 64 bits wide while sign-extending the int index + * beside it. + * + * The two source flags and is_pointer deliberately ask different questions. + * Only the AArch64 backend reads the source flags, and it wants every address, + * arrays included. is_pointer is older and the x86-64 backend decides store + * widths and int narrowing by it, so it keeps counting pointer-like operands + * alone rather than acquiring arrays and changing a settled target. + */ +void set_ptr_flags(ph2_ir_t *ir, insn_t *insn) +{ + ir->src0_is_pointer = is_address_like(insn->rs1); + ir->src1_is_pointer = is_address_like(insn->rs2); + ir->is_pointer = is_pointer_like(insn->rd) || is_pointer_like(insn->rs1) || + is_pointer_like(insn->rs2); +} + /* Width of the value a local's frame slot actually holds. * * Slots are pointer-sized, but a scalar occupies only its low bytes. Reading @@ -186,6 +216,8 @@ ph2_ir_t *bb_add_ph2_ir(basic_block_t *bb, opcode_t op) n->ofs_based_on_stack_top = false; n->size_bytes = PTR_SIZE; /* default to the full slot; see add_ph2_ir */ n->is_pointer = false; + n->src0_is_pointer = false; + n->src1_is_pointer = false; if (!bb->ph2_ir_list.head) bb->ph2_ir_list.head = n; @@ -2103,6 +2135,7 @@ void reg_alloc_global(insn_t *global_insn) ir->src0 = src0; ir->src1 = src1; ir->dest = dest; + set_ptr_flags(ir, global_insn); break; } case OP_write: { @@ -2500,6 +2533,11 @@ void reg_alloc_bb(func_t *func, basic_block_t *bb) ir = bb_add_ph2_ir(bb, OP_branch); ir->src0 = src0; + + /* An LP64 backend tests an address over its full width and an int + * over its low word only. + */ + ir->src0_is_pointer = is_address_like(insn->rs1); ir->then_bb = bb->then_; ir->else_bb = bb->else_; break; @@ -2601,13 +2639,12 @@ void reg_alloc_bb(func_t *func, basic_block_t *bb) ir->src1 = src1; ir->dest = dest; - /* Record whether the result is an address. On LP64 an int-typed - * result has to wrap at 32 bits, while a pointer must keep all - * 64. The backend cannot tell the two apart without this. + /* Record whether the result is an address, and which operand it + * came from. On LP64 an int-typed result has to wrap at 32 bits, + * while a pointer must keep all 64, and pointer arithmetic has to + * widen the int index beside the address. */ - ir->is_pointer = is_pointer_like(insn->rd) || - is_pointer_like(insn->rs1) || - is_pointer_like(insn->rs2); + set_ptr_flags(ir, insn); break; case OP_negate: case OP_bit_not: @@ -2617,6 +2654,11 @@ void reg_alloc_bb(func_t *func, basic_block_t *bb) ir = bb_add_ph2_ir(bb, insn->opcode); ir->src0 = src0; ir->dest = dest; + + /* As for OP_branch: the width of the test follows the operand, not + * the result. + */ + ir->src0_is_pointer = is_address_like(insn->rs1); break; case OP_trunc: case OP_sign_ext: diff --git a/tests/arm64-abi.sh b/tests/arm64-abi.sh new file mode 100755 index 00000000..02a552fb --- /dev/null +++ b/tests/arm64-abi.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# Focused AAPCS64 integer ABI checks. These deliberately avoid host headers: +# shecc supplies its inlined libc and the same file can run for stage 0 or 2. +set -eu + +if [ "$#" -lt 1 ]; then + echo "Usage: $0 []" >&2 + exit 2 +fi + +# TARGET_EXEC is a command with its own arguments, so both it and the compiler +# invocation are kept as argv arrays. A string passed through eval would lose a +# checkout path containing a space. +read -r -a runner <<< "${TARGET_EXEC:-}" + +case "$1" in + 0) shecc=("$PWD/out/shecc") ;; + 2) shecc=("${runner[@]}" "$PWD/out/shecc-stage2.elf") ;; + *) + echo "arm64 ABI tests support stage 0 or 2" >&2 + exit 2 + ;; +esac + +if [ "${2:-0}" = 1 ]; then + shecc+=(--dynlink) + link_mode=dynamic +else + link_mode=static +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +count=0 + +run_case() +{ + name=$1 expected=$2 source=$3 + src="$tmpdir/test.c" exe="$tmpdir/test.elf" + printf '%s\n' "$source" > "$src" + if ! output=$("${shecc[@]}" -o "$exe" "$src" 2>&1); then + echo "FAIL: $name (compile)" >&2 + printf '%s\n' "$output" >&2 + exit 1 + fi + # A generated ELF must be runnable as produced, whichever libc opened it. + if [ ! -x "$exe" ]; then + echo "FAIL: $name (output is not executable)" >&2 + exit 1 + fi + set +e + "${runner[@]}" "$exe" > /dev/null 2>&1 + got=$? + set -e + if [ "$got" -ne "$expected" ]; then + echo "FAIL: $name (expected $expected, got $got)" >&2 + exit 1 + fi + count=$((count + 1)) +} + +# x0-x7 are the eight AAPCS64 integer/pointer argument registers. +run_case 'eight register arguments' 36 ' +int sum8(int a,int b,int c,int d,int e,int f,int g,int h) { + return a+b+c+d+e+f+g+h; +} +int main(void) { return sum8(1,2,3,4,5,6,7,8); }' + +# Arguments nine and ten must be read from the caller stack with 16-byte SP. +run_case 'overflow stack arguments' 55 ' +int sum10(int a,int b,int c,int d,int e,int f,int g,int h,int i,int j) { + return a+b+c+d+e+f+g+h+i+j; +} +int main(void) { return sum10(1,2,3,4,5,6,7,8,9,10); }' + +# The callee reads stack arguments at a fixed offset above its own frame, so +# that offset has to be rounded exactly as the prologue rounds the frame. A +# frame whose size is 8 modulo 16 is what catches a disagreement: an +# address-taken local forces the rounding that flips the parity. +run_case 'stack arguments with an odd-parity frame' 42 ' +int pick(int a,int b,int c,int d,int e,int f,int g,int h,int i) { + int v; int *p = &v; *p = 0; + return i; +} +int main(void) { return pick(1,2,3,4,5,6,7,8,42); }' + +run_case 'stack arguments read past live locals' 38 ' +int sum10(int a,int b,int c,int d,int e,int f,int g,int h,int i,int j) { + int t[3]; t[0]=a; t[1]=i; t[2]=j; + return t[0]+t[1]+t[2]+i+j; +} +int main(void) { + int keep = 5; int *q = &keep; + return sum10(1,2,3,4,5,6,7,8,9,10) - *q + 4; +}' + +# SP must stay 16-byte aligned: AArch64 Linux enables SP alignment checking, so +# a frame rounded to 8 faults the moment the next prologue touches [sp]. +run_case 'stack stays 16-byte aligned through nesting' 42 ' +int d3(int x) { int a; int *p = &a; *p = x; return *p + 1; } +int d2(int x) { int a,b,c; a=x; b=a+1; c=b+1; return d3(c) + 1; } +int d1(int x) { int a; int *p = &a; *p = x; return d2(*p) + 1; } +int main(void) { return d1(37); }' + +# Narrow array elements must load sign-extended. Kept here rather than in +# tests/driver.sh because the Arm backend zero-extends them (see TODO.md). +run_case 'narrow array load and store' 42 ' +int main(void) { + char b[4]; short h[4]; int i; + for (i = 0; i < 4; i++) { b[i] = -1 - i; h[i] = -1000 - i; } + for (i = 0; i < 4; i++) { + if (b[i] != -1 - i) return 1; + if (h[i] != -1000 - i) return 2; + } + return 42; +}' + +# x19-x28 are callee-saved; the backend currently allocates x20-x22. +run_case 'callee-saved allocation survives call' 42 ' +int leaf(int x) { int a=7,b=8,c=9; return x+a+b+c; } +int caller(int x) { int keep=18; return leaf(x)+keep; } +int main(void) { return caller(0); }' + +run_case 'struct member offset layout' 12 ' +struct P { char tag; int number; }; +int main(void) { struct P p; p.tag=1; p.number=11; return p.tag+p.number; }' + +run_case 'defined function pointer' 42 ' +int add1(int n) { return n + 1; } +int main(void) { + int (*p)(int); + p = add1; + return p(41); +}' + +run_case 'negative pointer index' 7 ' +int main(void) { + int a[3]; + int *p = &a[1]; + a[0] = 7; + return p[-1]; +}' + +run_case 'negative runtime pointer subtraction' 42 ' +int get(int i) { + int a[3]; + int *p = &a[1]; + a[2] = 42; + return *(p - i); +} +int main(void) { return get(-1); }' + +if [ "$link_mode" = static ]; then + + # Linux AArch64 getpid is syscall 172. This verifies the backend moves the + # syscall number to x8 and six arguments to x0..x5. + run_case 'syscall register ABI' 0 ' +int main(void) { + return __syscall(172, 0, 0, 0, 0, 0, 0) > 0 ? 0 : 1; +}' +fi + +if [ "$link_mode" = dynamic ]; then + run_case 'external function pointer through PLT' 0 ' +int puts(char *s); +int main(void) { + int (*p)(char *); + p = puts; + p("external function pointer"); + return 0; +}' +fi + +echo "AAPCS64 ABI: $count/$count passed (stage $1, $link_mode)" From b168ce6e6af158904eb5a7a6da4b9cb4133c765f Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Wed, 9 Sep 2026 00:53:03 +0800 Subject: [PATCH 3/6] Cover narrow signed values and pointer truthiness Neither had a case in the shared suite. A char or short must stay negative through promotion and through a store and reload, which an LP64 backend holding it in a 64-bit register can get wrong in either direction. A pointer must be tested for truth over its whole value, and that starts to matter once a target holds one in a register wider than an int. The case which separates a full-width test from a low-word one cannot be forced, since pinning a pointer whose low word is zero would need a 64-bit literal, so the test covers agreement between the paths instead: the backend picks a different width for a branch, for a logical negation and for a comparison, and each is reached with a null and a non-null pointer. The array form of the first belongs here too, but it fails on the Arm backend, whose char elements load zero-extended, so it stays in the AArch64 gate until that is fixed. --- tests/driver.sh | 96 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/driver.sh b/tests/driver.sh index 08a9dd45..0f3afd66 100755 --- a/tests/driver.sh +++ b/tests/driver.sh @@ -648,6 +648,102 @@ declare -a variable_tests=( run_items_tests variable_tests +# Narrow signed values must stay negative through promotion and through a store +# and reload. An LP64 backend holds them in a 64-bit register, so a load that +# zero-extends or a promotion that forgets to extend turns a small negative +# number into a large positive one. The array-element form belongs here too, but +# it fails on the Arm backend, whose char elements load zero-extended, so it +# stays in tests/arm64-abi.sh until that is fixed. +try_ 42 << EOF +int main() { + char c = -5; + short s = -1000; + int ci = c; + int si = s; + if (ci != -5) + return 1; + if (si != -1000) + return 2; + if (c >= 0) + return 3; + if (s >= 0) + return 4; + return 42; +} +EOF + +# A pointer is wider than an int on an LP64 target, so testing one for truth has +# to consider the whole value, not just its low word. No fixture can force the +# case that separates the two, since pinning a pointer whose low word is zero +# needs a 64-bit literal and shecc has no integer constant that wide. What is +# testable is that every path which tests an address agrees: the backend emits a +# different width for a branch, for a logical negation and for a comparison, so +# each is reached here with a null and a non-null pointer. +try_ 42 << EOF +struct holder { + int *ptr; +}; + +int *pick(int *p, int take) +{ + if (take) + return p; + return 0; +} + +int main() { + int v = 42; + int *p = &v; + int *n = 0; + struct holder h; + int seen = 0; + + if (!p) + return 1; + if (n) + return 2; + if (p == 0) + return 3; + if (n != 0) + return 4; + + while (n) + return 5; + + seen = p ? 1 : 0; + if (!seen) + return 6; + seen = n ? 1 : 0; + if (seen) + return 7; + + if (p && !n) + seen = 2; + if (seen != 2) + return 8; + if (n || !p) + return 9; + + /* A pointer that reaches the test through a return value or a struct + * field has been through a store and a reload on the way. + */ + if (!pick(p, 1)) + return 10; + if (pick(p, 0)) + return 11; + + h.ptr = n; + if (h.ptr) + return 12; + h.ptr = p; + if (!h.ptr) + return 13; + + int *q = h.ptr; + return *q; +} +EOF + # Category: Compound Literals begin_category "Compound Literals" "Testing C99 compound literal features" From 2ca687cae7a1b84041e7a2cf0662d55a58ef0a76 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Wed, 9 Sep 2026 00:53:04 +0800 Subject: [PATCH 4/6] Describe AArch64 build and runtime support Name the new target where the other three are listed, and say what its images require of a loader: they separate the load segments by 64 KiB so they run under any AArch64 page granule. Both examples also lose their chmod, since the compiler now marks its own output executable, and the dynamic one gains the Arm invocation it had lost alongside the AArch64 one. --- README.md | 44 +++++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4d0786e2..bb7147a4 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ ## Introduction -`shecc` is built from scratch, targeting 32-bit Arm, 32-bit RISC-V, and x86-64, -as a self-compiling compiler for a subset of the C language. +`shecc` is built from scratch, targeting 32-bit Arm, AArch64, 32-bit RISC-V, +and x86-64, as a self-compiling compiler for a subset of the C language. Despite its simplistic nature, it is capable of performing basic optimization strategies as a standalone optimizing compiler. ### Features -* Generate executable Linux ELF binaries for ARMv7-A, RV32IM, and x86-64. +* Generate executable Linux ELF binaries for ARMv7-A, AArch64, RV32IM, and x86-64. * Provide a minimal C standard library for basic I/O on GNU/Linux. * The cross-compiler is written in ANSI C, making it compatible with most platforms. * Include a self-contained C front-end with an integrated machine code generator; no external assembler or linker needed. @@ -19,7 +19,7 @@ Despite its simplistic nature, it is capable of performing basic optimization st * Develop a register allocation system that is compatible with RISC-style architectures. * Implement an architecture-independent, [static single assignment](https://en.wikipedia.org/wiki/Static_single-assignment_form) (SSA)-based middle-end for enhanced optimizations. * Support dynamic linking to allow generated executables to run with glibc. -* Emit both ELF32 (Arm, RISC-V) and ELF64 (x86-64) images; the ELF class follows the target pointer width. +* Emit both ELF32 (Arm, RISC-V) and ELF64 (AArch64, x86-64) images; the ELF class follows the target pointer width. ## Compatibility @@ -41,6 +41,10 @@ syntax: * function-like macros with parameters, `__VA_ARGS__`, stringification (`#`), and token pasting (`##`) The Arm backend targets armv7hf with the Linux ABI, verified on Raspberry Pi 3. +The AArch64 backend follows AAPCS64 and supports static and eager-bound dynamic +linking, verified with QEMU AArch64 on eMag. Its images separate the load +segments by 64 KiB so they load under any of the 4 KiB, 16 KiB and 64 KiB +translation granules AArch64 Linux may be configured with. The RISC-V backend targets RV32IM, verified with QEMU. The x86-64 backend follows the System V AMD64 ABI and runs natively on an x86-64 GNU/Linux host, so no emulator is involved. @@ -86,7 +90,7 @@ the second stage bootstrapping would fail due to `qemu-arm` absence, and the The dynamic linking mode needs an ELF interpreter and the matching glibc for the target. The `x64` target resolves both from the host system, so it needs nothing -beyond an x86-64 GNU/Linux installation. The Arm and RISC-V targets need a +beyond an x86-64 GNU/Linux installation. The Arm, AArch64, and RISC-V targets need a cross-compile GNU toolchain to obtain them. For the Arm architecture, you can install the ARM GNU toolchain using `apt-get`: @@ -98,14 +102,20 @@ Another approach is to manually download and install the toolchain from [ARM Dev Select "x86_64 Linux hosted cross toolchains" - "AArch32 GNU/Linux target with hard float (arm-none-linux-gnueabihf)" to download the toolchain. +For AArch64 dynamic binaries, install the matching toolchain and user emulator: + +```shell +$ sudo apt-get install gcc-aarch64-linux-gnu qemu-user +``` + Since `apt-get` does not provide the necessary RISC-V GNU toolchain, it must be downloaded manually if you want to run a dynamically linked `shecc` targeting the RISC-V architecture. For instance, you can download and extract the `riscv32-glibc-ubuntu-22.04-gcc.tar.xz` package from the [riscv-gnu-gcc](https://github.com/riscv-collab/riscv-gnu-toolchain) repository. ## Build and Verify -Configure which backend you want. `shecc` supports the ARMv7-A, RV32IM, and -x86-64 backends, with Arm as the default: +Configure which backend you want. `shecc` supports the ARMv7-A, AArch64, +RV32IM, and x86-64 backends, with Arm as the default: ```shell $ make config ARCH=arm # Target machine code switch to arm @@ -113,6 +123,9 @@ $ make config ARCH=arm $ make config ARCH=riscv # Target machine code switch to riscv +$ make config ARCH=arm64 +# Target machine code switch to arm64 + $ make config ARCH=x64 # Target machine code switch to x64 ``` @@ -145,7 +158,10 @@ $ make DYNLINK=1 SHECC out/shecc-stage2.elf $ file out/shecc-stage2.elf +# ARCH=arm: out/shecc-stage2.elf: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, not stripped +# ARCH=arm64: +out/shecc-stage2.elf: ELF 64-bit LSB executable, ARM aarch64, dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, no section header ``` For development builds with memory safety checks: @@ -171,20 +187,26 @@ Compiler options: Example 1: static linking mode ```shell $ out/shecc -o fib tests/fib.c -$ chmod +x fib $ qemu-arm fib ``` +The compiler marks its own output executable, so neither example needs a +`chmod +x` in front of the run. Earlier revisions did: the mode used to be +whatever the libc that opened the file chose, which was 0666 for a +glibc-linked build. + An `x64` build produces a native binary, so `./fib` runs it directly with no emulator in front. Example 2: dynamic linking mode -Notice that `/usr/arm-linux-gnueabihf` is the ELF interpreter prefix. Since the path may be different if you manually install the ARM/RISC-V GNU toolchain instead of using `apt-get`, you should set the prefix to the actual path. +For AArch64, `/usr/aarch64-linux-gnu` is a typical ELF interpreter prefix. +The path may differ if you manually install a GNU toolchain, so set it to the +actual sysroot. ```shell $ out/shecc --dynlink -o fib tests/fib.c -$ chmod +x fib -$ qemu-arm -L /usr/arm-linux-gnueabihf fib +$ qemu-arm -L /usr/arm-linux-gnueabihf fib # ARCH=arm +$ qemu-aarch64 -L /usr/aarch64-linux-gnu fib # ARCH=arm64 ``` ### Unit Tests From aa8efd7f5988cafbc07fb799a6035c250b6e403c Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Wed, 9 Sep 2026 00:53:15 +0800 Subject: [PATCH 5/6] Exercise AArch64 static and dynamic builds in CI Run both link modes for the new target, and install the AArch64 sysroot the dynamic runs need under QEMU. This cannot observe everything the target needs: QEMU-user on an x86-64 host always presents 4 KiB pages, so a load-segment separation too small for a larger granule still passes here. --- .github/actions/setup-build-env/action.yml | 4 ++++ .github/workflows/main.yml | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-build-env/action.yml b/.github/actions/setup-build-env/action.yml index a52bcf3f..a27d1fae 100644 --- a/.github/actions/setup-build-env/action.yml +++ b/.github/actions/setup-build-env/action.yml @@ -59,6 +59,10 @@ runs: [ "$(uname -m)" != aarch64 ]; then packages+=(gcc-arm-linux-gnueabihf) fi + if [ "$LINK_MODE" = dynamic ] && [ "$ARCHITECTURE" = arm64 ] && \ + [ "$(uname -m)" != aarch64 ]; then + packages+=(gcc-aarch64-linux-gnu) + fi sudo apt-get update -q -y sudo apt-get install -q -y --no-install-recommends "${packages[@]}" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 75a29cfb..b3fd69d8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,7 +30,7 @@ jobs: fail-fast: false matrix: compiler: [gcc, clang] - architecture: [arm, riscv, x64] + architecture: [arm, arm64, riscv, x64] link_mode: [static, dynamic] env: CC: ${{ matrix.compiler }} @@ -80,7 +80,7 @@ jobs: # clang-sanitized one, which puts a single cell of this matrix at # roughly fifteen hours. Restoring the dimension means finding out # why first. - architecture: [arm, riscv, x64] + architecture: [arm, arm64, riscv, x64] link_mode: [static, dynamic] env: ARCH: ${{ matrix.architecture }} @@ -110,7 +110,7 @@ jobs: fail-fast: false matrix: compiler: [gcc, clang] - architecture: [arm, riscv, x64] + architecture: [arm, arm64, riscv, x64] env: CC: ${{ matrix.compiler }} ARCH: ${{ matrix.architecture }} From 83672e1e7e327d75647aad117dd0488eeda0e6cf Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Wed, 9 Sep 2026 00:53:15 +0800 Subject: [PATCH 6/6] Run the AArch64 target natively on Arm64 runners An Arm64 Linux host runs this target's output itself, so asking QEMU to stand in for it hides the one thing the emulator cannot show: QEMU-user maps the image on its own and, on an x86-64 host, always presents 4 KiB pages, leaving the 64 KiB separation between the load segments unexercised. The host answers this alone, without the fastfetch probe the Arm target needs to tell apart boards that can run its 32-bit output. It has to be the right kernel as well as the right architecture, since what comes out is an AArch64 Linux ELF. The runner job grows an architecture dimension, and the assertion that the output really did run natively now covers both targets rather than letting an emulated job pass as a native one. With the emulator gone the dynamic build resolves its interpreter and libc from the running system, so no cross toolchain is wanted on that host. --- .github/actions/setup-build-env/action.yml | 24 ++++++++++++++-------- .github/workflows/main.yml | 18 +++++++++------- mk/arm64.mk | 11 ++++++++++ 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/.github/actions/setup-build-env/action.yml b/.github/actions/setup-build-env/action.yml index a27d1fae..39f0b82e 100644 --- a/.github/actions/setup-build-env/action.yml +++ b/.github/actions/setup-build-env/action.yml @@ -59,6 +59,9 @@ runs: [ "$(uname -m)" != aarch64 ]; then packages+=(gcc-arm-linux-gnueabihf) fi + # An Arm64 host runs the AArch64 output natively, so mk/arm64.mk + # turns USE_QEMU off and the build resolves the interpreter and libc + # from the running system instead of from a cross sysroot. if [ "$LINK_MODE" = dynamic ] && [ "$ARCHITECTURE" = arm64 ] && \ [ "$(uname -m)" != aarch64 ]; then packages+=(gcc-aarch64-linux-gnu) @@ -164,18 +167,21 @@ runs: echo "$sha256 $file" | sha256sum -c - sudo apt-get install -q -y --no-install-recommends "$file" - # An Arm64 runner is asked for so that the Arm output runs through the - # kernel rather than an emulator, and the step above is what lets - # mk/arm.mk see that. Read the decision back while it is fresh: a runner - # the probe stops matching would otherwise turn those jobs into slower - # copies of the x86 ones without saying so. An empty TARGET_EXEC is the - # Makefile saying nothing stands in front of the binary. - - name: Confirm the Arm output runs natively - if: runner.arch == 'ARM64' && inputs.architecture == 'arm' + # An Arm64 runner is asked for so that the Arm and AArch64 output runs + # through the kernel rather than an emulator: mk/arm.mk decides that from + # the step above, mk/arm64.mk from the host architecture alone. Read the + # decision back while it is fresh: a runner the probe stops matching would + # otherwise turn those jobs into slower copies of the x86 ones without + # saying so. An empty TARGET_EXEC is the Makefile saying nothing stands in + # front of the binary. + - name: Confirm the output runs natively + if: runner.arch == 'ARM64' && (inputs.architecture == 'arm' || inputs.architecture == 'arm64') shell: bash + env: + ARCHITECTURE: ${{ inputs.architecture }} run: | set -euo pipefail - runner=$(make -np ARCH=arm 2>/dev/null | \ + runner=$(make -np ARCH="$ARCHITECTURE" 2>/dev/null | \ sed -n 's/^TARGET_EXEC = *//p' | head -1) if [ -n "$runner" ]; then echo "Expected native execution, but the build selected: $runner" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b3fd69d8..81a7c242 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -130,17 +130,21 @@ jobs: - name: Build stage 1 artifact run: ./out/shecc --no-libc -o out/shecc-stage1.elf ./out/out.c - # An Arm64 runner executes the Arm output natively, so this covers the paths - # that the emulator would otherwise stand in for. + # An Arm64 runner executes both the Arm and the AArch64 output natively, so + # this covers the paths that the emulator would otherwise stand in for. For + # AArch64 that is the only place the real loader is exercised: QEMU-user maps + # the image itself, and on an x86-64 host it always presents 4 KiB pages. host-arm: - name: arm/${{ matrix.link_mode }} on Arm64 + name: ${{ matrix.architecture }}/${{ matrix.link_mode }} on Arm64 runs-on: ubuntu-24.04-arm timeout-minutes: 30 strategy: fail-fast: false matrix: + architecture: [arm, arm64] link_mode: [static, dynamic] env: + ARCH: ${{ matrix.architecture }} DYNLINK: ${{ matrix.link_mode == 'dynamic' && '1' || '0' }} steps: - name: Checkout code @@ -148,18 +152,18 @@ jobs: - name: Set up the build environment uses: ./.github/actions/setup-build-env with: - architecture: arm + architecture: ${{ matrix.architecture }} link-mode: ${{ matrix.link_mode }} github-token: ${{ github.token }} - name: Build artifacts - run: make ARCH=arm DYNLINK="$DYNLINK" + run: make ARCH="$ARCH" DYNLINK="$DYNLINK" - name: Unit tests - run: make check ARCH=arm DYNLINK="$DYNLINK" + run: make check ARCH="$ARCH" DYNLINK="$DYNLINK" - name: Upload the test logs if: failure() uses: actions/upload-artifact@v7 with: - name: logs-arm64-host-${{ matrix.link_mode }} + name: logs-arm64-host-${{ matrix.architecture }}-${{ matrix.link_mode }} path: | out/*.log out/tests/*.log diff --git a/mk/arm64.mk b/mk/arm64.mk index a67120be..c6b402b2 100644 --- a/mk/arm64.mk +++ b/mk/arm64.mk @@ -28,4 +28,15 @@ ARCH_DEFS = \ \#define DYN_BIND_NOW 1\n$\ " +# An Arm64 Linux host runs this target's output itself, so nothing has to stand +# in for it. The Arm target needs fastfetch to tell a board that can run its +# 32-bit output from one that cannot; here the host answers on its own. The +# check is for the kernel as well as the architecture, since what comes out is +# an AArch64 Linux ELF and no other system will execute it. With the emulator +# out of the way the dynamic build resolves its interpreter and libc from the +# running system rather than from a sysroot. +ifeq ($(shell uname -s -m),Linux aarch64) + USE_QEMU = 0 +endif + TOOLCHAIN_CANDIDATES := aarch64-linux-gnu- aarch64-none-linux-gnu-