IRIX ELF loader contract
Status: draft
Version: 0.1
Owner: compatibility
Implementation: crates/irix-elf
Related: architecture.md §6, ../plan/milestones.md M5.
1. Scope
This document fixes the ELF parsing and load-plan contract used by the M5 legacy container. The crate is a parser, not a process loader: it validates an image and exposes the segments to map. It does not allocate frames, build page tables, resolve symbols, relocate, or start a process. Syscall decoding and the o32 runtime are separate work.
IRIX 6.5 applications are ELF. Both o32 and n32 use ELF32 (32-bit
pointers); they differ in register width, argument conventions and e_flags.
The container accepts an unchanged executable image and leaves the ABI
personality to the process layer.
2. Accepted image profile
An image is accepted only when all of the following hold:
| Property | Required value |
|---|---|
| Magic | 0x7f 'E' 'L' 'F' |
EI_CLASS | ELFCLASS32 (1) |
EI_DATA | ELFDATA2MSB (2) |
EI_VERSION | EV_CURRENT (1) |
e_type | ET_EXEC (2) or ET_DYN (3) |
e_machine | EM_MIPS (8) |
e_version | EV_CURRENT (1) |
e_ehsize | at least 52 |
e_phentsize | 32..=1024 when e_phnum is non-zero |
e_shentsize | 40..=1024 when a section-header table is present |
Little-endian, ELF64, ET_REL, ET_CORE, ECOFF and non-MIPS images are
rejected rather than reinterpreted. EI_OSABI and EI_ABIVERSION are
exposed but not constrained: IRIX binaries usually declare ELFOSABI_SYSV
(0), and ELFOSABI_IRIX is value 8.
A larger-than-32-byte program-header entry is tolerated up to the 1024-byte
bound; only the first 32 bytes are consumed and the entry stride is honoured.
A zero e_phentsize with e_phnum == 0 is harmless because no header is read.
3. File header
The parser reads the 52-byte ELF32 file header at offset 0. Every offset is read through a bounds-checked big-endian accessor.
| Field | Offset | Contract |
|---|---|---|
e_ident | 0 | Magic, class, data and identification version checked; EI_OSABI and EI_ABIVERSION exposed |
e_type | 16 | ET_EXEC or ET_DYN |
e_machine | 18 | EM_MIPS |
e_version | 20 | EV_CURRENT |
e_entry | 24 | Exposed as the entry virtual address |
e_phoff | 28 | Table start; checked with e_phnum and e_phentsize |
e_shoff | 32 | Section table start, or zero |
e_flags | 36 | Exposed; the MIPS ABI is decoded from it |
e_ehsize | 40 | At least 52 |
e_phentsize | 42 | Program-header entry size |
e_phnum | 44 | Program-header count |
e_shentsize | 46 | Section-header entry size, when present |
e_shnum | 48 | Section-header count, when present |
e_shstrndx | 50 | Ignored; section names are not read |
The program-header table range is e_phoff + e_phnum * e_phentsize, computed
with checked arithmetic and required to lie entirely inside the image.
3.1 Section headers
Sections are not needed to load an image. The table is validated only because the file header names it:
e_shoff == 0requirese_shnum == 0;e_shoff != 0requirese_shnum != 0, a valid entry size and a table that fits in the image;- ELF extended numbering (
e_shnum == 0with a non-zeroe_shoff) is rejected because the real count lives in section header zero; - section contents, names and
e_shstrndxare not parsed.
3.2 MIPS ABI classification
e_flags is decoded, not rejected:
| Condition | ABI |
|---|---|
(e_flags & EF_MIPS_ABI) == EF_MIPS_ABI_O32 | o32 |
(e_flags & EF_MIPS_ABI) == EF_MIPS_ABI_O64 | o64 |
(e_flags & EF_MIPS_ABI) == EF_MIPS_ABI_EABI32 | EABI32 |
(e_flags & EF_MIPS_ABI) == EF_MIPS_ABI_EABI64 | EABI64 |
ABI field zero and EF_MIPS_ABI2 set | n32 |
| Anything else | unknown |
The M5 demo is o32. n32 is represented and tested because the same parser serves both; the o32/n32 personality is chosen later from this value.
4. Program headers
Each 32-byte ELF32 program header is decoded:
| Field | Offset |
|---|---|
p_type | 0 |
p_offset | 4 |
p_vaddr | 8 |
p_paddr | 12 |
p_filesz | 16 |
p_memsz | 20 |
p_flags | 24 |
p_align | 28 |
The loader consumes PT_LOAD (1), PT_INTERP (3) and PT_DYNAMIC (2).
Other types are preserved as a raw value; a loader may ignore them.
Validation applies to every header, not only PT_LOAD:
p_offset + p_fileszmust not overflow 32 bits.- The file range must lie inside the image, so
p_offset + p_fileszis at most the image length. This includes zero-length ranges:p_offsetitself must be at most the image length. p_fileszmust not exceedp_memsz.p_vaddr + p_memszmust not overflow 32 bits.p_alignmust be zero, one, or a power of two.- A
PT_LOADwithp_align > 1must satisfyp_vaddr % p_align == p_offset % p_align.
p_offset, p_vaddr, p_paddr, p_filesz, p_memsz, p_flags and
p_align are all exposed. PF_R (4), PF_W (2) and PF_X (1) are decoded
into a Permissions view; unknown flag bits are preserved in the raw value.
4.1 PT_INTERP
The segment must contain a non-empty path terminated by a NUL byte within
p_filesz. The byte after the first NUL is ignored. The path is returned
without its terminator as bytes, because IRIX paths are not required to be
UTF-8. An image with more than one PT_INTERP is rejected.
Following the interpreter is a later layer. This contract exposes the path.
4.2 PT_DYNAMIC
The segment’s file and memory ranges are validated like any other header, and the header is exposed. Dynamic entries are not decoded, and no relocation or symbol table is read. The M5 demo may run a statically linked binary; the dynamic path is deliberately left unimplemented.
5. Load plan
Elf::load_plan() requires at least one PT_LOAD segment and returns a view
over the loadable segments in program-header order. Segments are not
sorted by address, merged, or rounded to a page size.
A Segment reports:
| Query | Meaning |
|---|---|
load_address() | p_vaddr |
physical_address() | p_paddr, normally equal to p_vaddr |
file_offset() | p_offset |
file_size() | p_filesz |
memory_size() | p_memsz |
zero_fill() | p_memsz - p_filesz |
file_data() | The p_filesz bytes in the image |
file_range() | The same bytes as a range in the image |
permissions() | PF_R, PF_W, PF_X |
alignment() | p_align |
memory_range() | load_address .. load_address + memory_size |
memory_end() | The first address after the memory image |
The mapping contract is: copy file_data() at load_address, then zero
zero_fill() bytes immediately after it. The order in LoadPlan::segments()
is the order to use when segments share a page or overlap.
LoadPlan::extent() returns the lowest load_address and the highest
memory_end. It is useful for sizing an address space, but it is not a
mapping instruction: a caller still maps each segment separately.
The plan is independent of the MMU. kseg0 translation, page rounding, cache
maintenance and the choice of ASID belong to the kernel loader. e_entry is
exposed as-is and is not required to fall inside an executable segment: an
ET_DYN shared object can have entry zero while still having loadable
segments.
6. Trust model
An image is untrusted input. The parser:
- uses checked arithmetic for every offset, size and range;
- bounds every slice against the supplied image;
- never allocates, never copies the image and never uses
unsafe; - returns a typed
Errorrather than panicking on malformed input; - reads only the fields listed above; no field is trusted because another field claims it is consistent.
The image remains owned by the caller. An Elf, its iterators and its
LoadPlan borrow it and cannot outlive it.
7. Deliberate limitations
- No relocation, symbol resolution, dynamic linker or shared-library loading.
- No
PT_NOTE,PT_MIPS_REGINFO,PT_MIPS_OPTIONSorPT_TLSdecoding; these are preserved as headers and ignored. - No section-based loading and no section contents.
- No overlap rejection. Two
PT_LOADsegments may describe overlapping memory; the kernel mapper decides the policy. - No page-size rounding or kseg translation.
- No ECOFF, ELF64 or little-endian images.
- No verification that
e_entrypoints into an executable segment.
These boundaries keep the crate a pure parser and keep MMU policy in the loader that will consume it.
8. Testing and corpus
The crate has fifty-six host tests. Every image is built in the test binary: a valid o32 image (interpreter, two loadable segments, dynamic information and an unnamed type), an n32-like header, BSS-only, overlapping and out-of-order segments, and a series of single-field corruptions covering each rejection above.
No real IRIX binary is committed. The compatibility corpus is private; this contract and the synthetic images are the public oracle.
Run the tests with:
cargo test -p irix-elf
9. First kernel consumer
The kernel’s userelf path is the first consumer of the load plan. It is used
by the automatic post-boot handoff and by the telnet userelf command. It reads
up to 256 KiB from SCSI target 2 LBA 0 in 64 KiB chunks, accepts an n32 or o32
ET_EXEC image, maps each PT_LOAD memory range into kuseg, zeroes BSS and
enters at e_entry with a four-page user stack at 0x7FF0_0000 in real
KSU=user mode. The loader records the image ABI; the syscall path has a
minimal o32 bridge for classic read(3), write(4), open(5), close(6),
exit(1) and getpid(20), with provisional $a3 error signalling.
It maps 4 KiB pages in JTLB even/odd pairs starting at index 4; the 1 MiB heap uses
two 256 KiB page pairs. User pages are cacheable so LL/SC atomics are defined.
The initial stack is the conventional layout: argc at $sp, then the argv
and envp pointer arrays, then NUL-terminated strings. $a0, $a1 and $a2
carry argc, argv and envp on entry. The fixture uses "hello",
"PATH=/" and "HOME=/".
This is deliberately narrow. There is no process table, address-space teardown,
dynamic linker or full POSIX libc. The o32 path is a synthetic loader/syscall
probe (user/o32-hello), not an IRIX 6.5 application; the IRIX libc, rld,
sproc, signals and the full decoder are still absent. The provisional n32
syscall set is
write, read, exit, mmap/munmap/mprotect, open/close/fcntl/dup,
getcwd/chdir, poll, signal, getentropy, clock_gettime, getpid,
fstat/stat, readdir, pipe, tcgetwinsize/tcsetwinsize and
nanosleep. The kernel has a small fd table with
console, /dev/null and a two-file read/write ramfs; stat/fstat and directory
iteration work. Threads/TLS, posix_spawn, real signals and termios are still
absent. The 42-symbol C shim in crates/irix7-libc is the bring-up libc; the
production layer is the relibc fork.