SGI EFS v1 and the SGI disk label (IRIS 2000 / 3000)

August 21, 2026 · View on GitHub

The on-disk formats used by Silicon Graphics' first workstations — the IRIS 2000 and 3000 series, 68020 machines running SGI's System V derivative around 1986-1988. Two formats are involved:

  • the SGI disk label at block 0 of the drive, which carves it into slots (src/partition/sgi_dklabel.rs), and
  • EFS v1, the original Extent File System, in each slot (src/fs/efs_v1.rs).

Both are read/write: parse and edit the label, and browse / inspect / extract / edit / create / resize / fsck the filesystems.

Relationship to IRIX EFS

src/fs/efs.rs implements the EFS that shipped with IRIX (magic 0x072959). This is its ancestor, and the two are not interchangeable:

EFS v1 (here)IRIX EFS (efs.rs)
fs_magic0x041755 (EFS2_MAGIC 0x041756)0x072959 / 0x07295A
Superblock162 bytes, 2-byte packed, no fs_bmblock / fs_replsb / fs_lastialloc92 bytes, 4-byte packed
DirectoriesSystem V struct direct: 16-bit inode + 14-byte nameslotted blocks with a 0xBEEF header
Partition tableSGI disk label, D_MAGIC 0x072959SGI volume header, 0x0BE5A941
Inode / extentsidenticalidentical

The number 0x072959 appears in both eras, but not for the same thing: here it is the disk label's magic, and SGI later reused it as the filesystem's magic. Detection keys off position and structure, not the constant alone.

Only the inode and extent layout is shared, which is why this is a separate driver rather than a flag on the existing one.

Provenance

Every structure below is transcribed from SGI's own headers, recovered from the /usr filesystem of a working IRIS 3130 disk image (a 60 MB Priam V170):

HeaderRCS revisionDefines
<sys/efs_sb.h>1.5, 87/06/17struct efs, EFS_MAGIC, EFS2_MAGIC
<sys/efs_ino.h>1.4, 86/10/08struct efs_dinode, struct extent
<sys/efs_fs.h>the block layout and the EFS_ITO* macros
<sys/dir.h>1.1, 86/05/19struct direct
<sys/dklabel.h>1.5, 87/11/20struct disk_label, D_MAGIC, DT_*, DC_*

The reader was cross-checked against that disk file for file: 2,982 entries across its two filesystems, every SHA-256, mode, size, mtime and symlink target matching an independent decoder written from the same headers.

Three further invariants close independently on all three volumes of that disk, which is what makes the field offsets certain rather than merely plausible:

  1. fs_size == fs_firstcg + fs_ncg * fs_cgfsize — exactly.
  2. The blocks reachable from every in-use inode's extents, plus the metadata blocks the geometry implies, equal fs_size - fs_tfree — exactly (11,706 / 3,929 / 49,606 blocks).
  3. fs_checksum reproduces under the same rotate-and-XOR IRIX EFS uses, run over offsets 0x00..0x9E. A field misplaced by even two bytes breaks it.

Byte order

Images taken off these machines' disk controllers are byte-swapped within every 16-bit word — label, superblocks, inodes and file data alike. The sample disk reads as rPai m1V07 where the drive name Priam V170 should be, and 0700 5929 where D_MAGIC should be.

Both the label parser and the filesystem driver probe their magic in both orientations and fix up every block on the way in. Nothing is normalised at the reader level: a backup of one of these disks must stay byte-identical to the source, so only the interpretation is swapped, never the stored bytes.

Because both orientations read identically, the orientation an image happens to be in is invisible unless we say so. rb-cli inspect and rb-cli ls therefore name it (Partition table: SGI-DkLabel (byte-swapped)), as does the GUI's Inspect tab, and rb-cli inspect --format json carries it as byte_order.

To move an image between the two, rb-cli swab16 IN OUT (or --in-place) rewrites every 16-bit word; the GUI's Inspect tab exposes the same thing as a Swap Word Order... button. The transform is an involution, so one command converts in either direction, and running it twice returns the original bytes. It is deliberately format-agnostic — it knows nothing about SGI and will happily fix up any medium captured through a word-swapping controller — but it probes the partition table before and after so the flip is visible rather than blind.

Whether the swap happened in the drive, the controller, or the dumping program is not something the image can tell us. It does not matter for reading: what matters is that the ASCII fields read correctly in exactly one orientation, and that is the one the drivers interpret.

Writing

The driver is read/write. EditableFilesystem covers create / delete / rename for files and directories, symlinks, set_permissions and set_owner; create_blank_efs_v1 formats a fresh volume (rb-cli new volume efs-v1).

Four things the write path has to get right, each verified against the real IRIS 3130 disk rather than assumed:

  • Byte order is symmetric. write_blocks applies the image's word order on the way out exactly as read_blocks applies it on the way in. Writing into a byte-swapped volume therefore leaves it byte-swapped and internally consistent; the alternative is a volume that is half one orientation.
  • The checksum span is 0x9E, not IRIX's 0x58. efs_v1_superblock_checksum runs the same rotate-and-XOR over the longer span. Verified: it reproduces the stored fs_checksum of both EFS volumes on the sample disk exactly, and the IRIX-span routine does not.
  • Inode tables are marked in use in the bitmap. This is the opposite of IRIX EFS, where mkfs leaves those bits set and a bitmap-only first-fit allocator walks into live inodes. On the sample disk every inode-table bit reads as in-use and the free-bit count matches fs_tfree exactly, on both volumes. The allocator still refuses to hand out anything outside a cylinder group's data area, so a damaged or foreign bitmap cannot talk it into overwriting an inode table.
  • fs_tinode is one below the free-inode count. Counting di_mode == 0 slots and comparing with the stored field gives a delta of exactly 1 on both volumes, so mkfs holds one back. create_blank_efs_v1 reproduces that.

Directories are the System V shape throughout: fixed 16-byte records, a removed entry has its inode number zeroed and the directory keeps its size, and a name is capped at DIRSIZ (14 bytes) because a full-length name is not NUL-terminated.

There is no journal, so ordering is the only durability tool available. On create the bitmap reaches disk before the inodes that cite those blocks, and on delete it reaches disk after the inode is cleared. Both orders fail toward leaked free space, which a future fsck can reclaim, rather than toward two inodes sharing a block. File payloads stream one extent at a time (at most EFS_MAXEXTENTLEN blocks), so a large file never lands in RAM whole.

Adding a file to a copy of the real 60 MB disk, in both orientations, leaves every pre-existing file byte-identical (a diff -r of the extracted tree is clean), leaves the untouched /usr volume bit-identical, and leaves a valid superblock checksum with fs_dirty still zero.

Checking and repairing

src/fs/efs_v1_fsck.rs verifies a volume against itself. IRIX's own fsck_efs was never public and none of the era's tooling survives, so there is no oracle to agree with — the checks are the format's own invariants:

geometry closure, the superblock checksum, every live inode's extents (zero ex_magic, inside the volume, and covering only cylinder-group data blocks), the bitmap in both directions, fs_tfree / fs_tinode against the counts just taken, and a breadth-first walk from inode 2 to find anything live that no directory references.

Repair fixes only what a sound structure makes unambiguous: it rebuilds the bitmap from the inode table rather than patching single bits, re-derives the counters, reseals the checksum, and adopts orphans into /lost+found. When any structural finding stands — an extent over an inode table, two inodes sharing a block, broken geometry — repair writes nothing at all and says why. There is no journal and no second opinion available, so guessing would destroy data.

Both filesystems on the sample IRIS 3130 disk verify clean: 448 files / 13 directories on root, 2,580 / 149 on /usr.

Resizing

src/fs/efs_v1_resize.rs grows and shrinks in place. firstcg and cgfsize are fixed at format time, so the only free variable is how many whole cylinder groups fit, and a request that is not a whole number of groups rounds down.

Two constraints are specific to v1. There is no replica superblock to keep in step. And the bitmap sits at a fixed block 2 sized from fs_size, so a grow can need more bitmap blocks than the gap below firstcg — refused rather than worked around, since moving firstcg would relocate every cylinder group. The era's mkfs reserved only the blocks a volume needed, which makes an original disk barely growable; create_blank_efs_v1 reserves room for roughly a fourfold grow instead. The gap is unused space either way and fs_bmsize still declares the true length, so such a volume reads identically on a period system.

A shrink is refused while any live inode sits past the new inode table or any extent past the new end. Both directions rebuild the bitmap from the inode table and then re-run fsck as a gate before the result is accepted.

m68k struct packing

These headers were compiled for the 68020, where the alignment requirement for a long is 2 bytes, not 4. So long fields sit at merely-even offsets and the C compiler inserts no padding to reach a multiple of four.

This is the single easiest way to misread both formats. In struct efs, fs_time follows the short fs_dirty at offset 0x16; assume 4-byte alignment and it lands at 0x18, putting fs_magic two bytes out of place and every field after it with it. The same applies to d_altstart at 0x0E in the disk label.

struct disk_label — block 0

274 bytes (0x112). Big-endian, 2-byte packed.

OffsetTypeFieldNotes
0x00be32d_magicD_MAGIC = 0x00072959
0x04be16d_typedrive type, DT_*
0x06be16d_controllerDC_DSD5217 0, DC_XYLOGICS450 1, DC_INTERPHASE2190 2, DC_STORAGER 3
0x08be16d_cylinders
0x0Abe16d_heads
0x0Cbe16d_sectorssectors per track
0x0Ebe32d_altstartfirst block of the alternates region
0x12be16d_nalternatesblocks reserved there
0x14u8d_bootfsslot the PROM boots from
0x15u8d_swapfsslot used as swap
0x168 × 8 Bd_map[NFS]struct disk_map { be32 d_base; be32 d_size; }
0x56i8d_interleave
0x57i8d_trackskew
0x58i8d_cylskew(one pad byte follows)
0x5Abe16d_badspots
0x5Cchar[50]d_namedrive model, e.g. Priam V170
0x8Echar[50]d_serial
0xC0be32[20]d_miscgap and group sizes
0x110u8d_rootnotbootnonzero when root and boot differ
0x111u8d_rootfsroot slot when the above is set

Blocks 1-4 hold the bad-block map: 64 × struct disk_bbm { long d_bad; long d_good; } per block, 256 entries maximum. No filesystem starts before block 5.

d_cylinders * d_heads * d_sectors is the whole drive; d_altstart + d_nalternates equals it, with d_altstart marking the end of usable space.

The slots carry no type field. Roles come from d_bootfs / d_swapfs / d_rootfs, and the label conventionally repeats the whole usable disk in one or more trailing slots. Those wrapper slots overlap the real ones, so — as with the SGI volume header's VOLHDR / VOLUME and Sun's backup slice — a slot that swallows another, or duplicates an earlier one, is filtered out of the browse list.

Sample: the IRIS 3130 Priam V170

987 cylinders × 7 heads × 17 sectors = 117,453 blocks; d_altstart 115,430 + d_nalternates 2,023 = 117,453. d_bootfs 0, d_swapfs 1.

Slotd_based_sizeEndsHolds
011917,85017,969root filesystem (17,848 blocks)
117,96917,73135,700swap
235,70079,730115,430/usr filesystem (79,704 blocks)
3, 6119115,311115,430whole-disk wrappers
70115,430115,430whole volume

Slot 1 still contains a stale EFS superblock from an earlier layout — the label says it is swap, and the filesystem there is inconsistent (three inodes sharing one block, one more in-use inode than fs_tinode accounts for). That is a property of the disk, not of the decode; it is why the swap slot is presented as swap rather than as a mountable volume.

struct efs — the superblock, block 1 of the slot

The on-volume portion is 162 bytes (0xA2). Big-endian, 2-byte packed.

OffsetTypeFieldNotes
0x00be32fs_sizeblocks, excluding the tail past the last group
0x04be32fs_firstcgblock of the first cylinder group
0x08be32fs_cgfsizeblocks per cylinder group
0x0Cbe16fs_cgisizeinode blocks per cylinder group
0x0Ebe16fs_sectorssectors per track
0x10be16fs_headsheads per cylinder
0x12be16fs_ncgcylinder groups
0x14be16fs_dirtyneeds fsck
0x16be32fs_timelast superblock update
0x1Achar[6]fs_fnamefilesystem name, e.g. root
0x20char[6]fs_fpackpack name, e.g. sgi
0x26be32fs_magicEFS_MAGIC 0x041755, EFS2_MAGIC 0x041756
0x2Abe32fs_preallocpreferred pre-allocation run (16 on the sample)
0x2Ebe32fs_bmsizebitmap length in bytes
0x32be32fs_tfreefree data blocks
0x36be32fs_tinodefree inodes
0x3Achar[100]fs_sparezero
0x9Ebe32fs_checksumrotate-and-XOR over 0x00..0x9E

Note what is absent relative to IRIX EFS: there is no fs_bmblock (the bitmap is fixed at block 2), no fs_replsb (no replicated superblock), and no fs_lastialloc.

Block layout

Per <sys/efs_fs.h>, in 512-byte basic blocks, relative to the slot:

  0                unused
  1                superblock                       (EFS_SUPERBB)
  2 ..             bitmap, ceil(fs_bmsize/512) blocks  (EFS_BITMAPBB)
  ..  fs_firstcg   unused
  fs_firstcg ..    fs_ncg groups of fs_cgfsize blocks,
                   each opening with fs_cgisize inode blocks
  fs_size ..       trailing blocks, outside the filesystem

fs_size == fs_firstcg + fs_ncg * fs_cgfsize.

The free-block bitmap uses set bit = free, LSB-first within each byte — block N is bit N % 8 of byte N / 8. Same convention as IRIX EFS, and the opposite of most other filesystems. Counting set bits over fs_bmsize bytes reproduces fs_tfree exactly on all three sample volumes; MSB-first does not.

struct efs_dinode — inodes

128 bytes, 4 per block. Identical to IRIX EFS except that offset 0x1E is di_refs (reorganiser bookkeeping) where IRIX put di_version + di_spare.

OffsetTypeField
0x00be16di_mode
0x02be16di_nlink
0x04be16di_uid
0x06be16di_gid
0x08be32di_size
0x0Cbe32di_atime
0x10be32di_mtime
0x14be32di_ctime
0x18be32di_gen
0x1Cbe16di_numextents
0x1Ebe16di_refs
0x20uniondi_extents[12], or di_dev for a character / block special

Inode 2 is the root. Locating inode i, from the EFS_ITO* macros:

  cg     = i / (fs_cgisize * 4)
  block  = fs_firstcg + cg * fs_cgfsize + ((i / 4) % fs_cgisize)
  offset = (i % 4) * 128

di_size is a 32-bit off_t the kernel treats as signed, capping a file at 2 GiB - 1.

Despite the System V lineage, symbolic links exist (S_IFLNK) — SGI carried the 4.2BSD ones across. The target is stored as ordinary file data, bounded by MAXPATHLEN, not by the 14-byte directory-entry limit. The sample disk has three, including /usr/include/machine.

struct extent — 8 bytes

typedef struct extent {
    unsigned int ex_magic:8,    /* MUST BE ZERO */
                 ex_bn:24,      /* first block */
                 ex_length:8,   /* blocks */
                 ex_offset:24;  /* logical block offset into the file */
} extent;

Limits from <sys/efs_ino.h>: EFS_DIRECTEXTENTS 12, EFS_MAXEXTENTS 2048, EFS_MAXEXTENTLEN 248.

An extent is bad when ex_magic is nonzero, when ex_length is 0 or over EFS_MAXEXTENTLEN, when ex_bn is below fs_firstcg or reaches fs_size, or when (ex_offset, ex_length) overlaps another extent.

ex_bn == 0 is not a bad extent — it marks a hole: a range never written, which reads as zeros.

When di_numextents exceeds 12 the inode switches to indirect mode. The inline slots stop describing data and instead point at runs of blocks packed with extent records, 64 to a 512-byte block; di_extents[0].ex_offset holds the number of inline slots used that way. Extents are not necessarily stored in logical order, so the reader sorts by ex_offset.

struct direct — directories

Flat arrays of 16-byte records, no slotting, no 0xBEEF header:

#define DIRSIZ 14
struct direct {
    ino_t d_ino;            /* be16 — 65,535 inodes maximum */
    char  d_name[DIRSIZ];   /* NUL-padded, not NUL-terminated at 14 */
};

d_ino == 0 marks a free slot. The directory's di_size is the number of records times 16. . and .. are real entries and are hidden by the browse layer.

A record pointing past the inode table is treated as damage and dropped with a warning, rather than failing the whole listing — the sample disk's stale swap slot contains exactly that.

What is not implemented

Write, resize, fsck, and volume creation. The format is fully understood and the free-space bitmap decodes correctly, so a writer is tractable; it is simply out of scope for read-only support.

An emulation oracle would also be welcome and does not currently exist: no emulator runs IRIS 3000-series hardware well enough to boot this OS and check our work the way FS-UAE, IRIX-on-MAME and the BasiliskII fixtures do elsewhere. The three self-consistency invariants listed under Provenance, plus the file-for-file comparison against an independent decoder, are what stand in for one.