The Amix Device-Driver Model¶
In Amix, a device is just a file in /dev carrying two numbers — a major (which driver) and a minor (which sub-device). The kernel never looks at the name; it indexes a per-class switch table by the major number to find the driver's entry points. Everything else in this page is the elaboration of that one idea ✅. The authoritative source is Michael Ditto's Writing Amix Device Drivers (1990 European Amiga Developer's Conference), §5 of the research brief, and it is the conceptual core for the rest of the drivers pillar.
This page is uniformly ✅ except where a tag says otherwise. The core model's primary source is the Ditto paper; the later HBA/DMA driver contract and in-kernel filesystem sections are first-party, real-hardware-verified findings (amix-z3660scsi / amix-cdfs) cited inline.
The user-level view: /dev nodes, major + minor¶
To user space a device is a special file. Two pieces of metadata matter:
- The major number selects the driver.
- The minor number selects the sub-device within that driver (a SCSI address, a partition, a port, a mode).
The kernel resolves an I/O on a /dev node purely through these numbers — it does not care what the file is called. You can mknod the same major/minor under any name and it behaves identically ✅. Extract the parts in the kernel with major()/getmajor() and minor()/getminor() (see Key kernel APIs).
Devices come in two visible classes (plus a third, STREAMS, described below):
| Class | ls -l flag |
Purpose | Driver core |
|---|---|---|---|
| Block | b |
Filesystem / random-access storage; buffered through the block cache | strategy() |
| Character | c |
General byte I/O — terminals, printers, raw devices | read() / write() |
Example /dev listing¶
The Ditto paper's worked ls -l /dev excerpt establishes the canonical numbers ✅:
crw--w--w- 1 root ... 0, 0 /dev/console # char major 0 minor 0
brw------- 1 root ... 16, ? /dev/fd0 # block major 16 (floppy)
brw------- 1 root ... 18, 1 /dev/dsk/c0d0s1 # block major 18 (SCSI disk)
crw-rw-rw- 1 root ... 21, 0 /dev/par # char major 21 (parallel port)
Reading those rows:
/dev/console— character device, major 0, minor 0. The system console./dev/fd0— block major 16, the floppy driver./dev/dsk/c0d0s1— block major 18, the SCSI hard-disk driver. The minor1encodes SCSI address 0, LUN 0, partition 1. (Recall the Amix SCSI layout: the tape is fixed at ID 4, and the boot disk is ID 6 by convention — see Quirks.) ✅/dev/par— character major 21, the Amiga parallel port (output-only Centronics). This is the paper's teaching driver; see Writing a character driver. ✅
For a fuller table of stock and community device numbers, see the device list.
The kernel-level view: switch tables in kernel.c¶
The kernel side is table-driven. The configuration file master.d/kernel.c — shipped in source even though most of the kernel is object-only ✅ — declares arrays of driver entry points. The major number is literally the index into the matching array.
A driver is added by editing these tables and relinking the kernel; stock Amix has no loadable modules (one third-party exception) — every driver is statically linked into /unix ✅. The full build cycle lives in Building and installing a kernel.
The switch structs (from conf.h)¶
The shapes below are reproduced from the Ditto paper (§5 of the brief) ✅. They are the SVR4 cdevsw/bdevsw structures as Amix uses them.
/* Character device switch — struct cdevsw, from conf.h */
struct cdevsw {
int (*d_open)(); int (*d_close)(); int (*d_read)(); int (*d_write)();
int (*d_ioctl)(); int (*d_mmap)(); int (*d_segmap)(); int (*d_poll)();
int (*d_xpoll)(); int (*d_xhalt)();
struct tty *d_ttys; /* tty struct array, or notty */
struct streamtab *d_str; /* STREAMS table, or nostr */
int *d_flag; /* driver flags, or nullflag*/
};
/* Block device switch — struct bdevsw */
struct bdevsw {
int (*d_open)(); int (*d_close)(); int (*d_strategy)(); int (*d_print)();
int (*d_size)(); int (*d_xpoll)(); int (*d_xhalt)();
int *d_flag;
};
Note what distinguishes the two: the character switch has the byte-stream and memory-mapping methods (d_read, d_write, d_mmap, d_segmap, d_poll) plus the tty/STREAMS hooks; the block switch instead has d_strategy (the queue-and-return I/O engine), d_size, and d_print.
The *_tbl arrays¶
kernel.c wires drivers into the kernel through several parallel arrays ✅:
| Array | Indexed by | Holds | Example entries |
|---|---|---|---|
cdevsw[] |
char major | one struct cdevsw per char driver |
console (0), par (21), hya (47) |
bdevsw[] |
block major | one struct bdevsw per block driver |
fd (16), SCSI disk (18) |
int2_tbl[] |
— | level-2 (INT2) autovector interrupt handlers | parintr, a2090intr, a2091intr, … |
int6 table |
— | level-6 autovector interrupt handlers | (exists; entries version-specific) |
init_tbl[] |
— | one-shot boot-time init functions | parinit, coinit, … |
So a typical char driver touches three of these: a cdevsw[] slot at its major, an int2_tbl[] entry if it takes a level-2 interrupt, and an init_tbl[] entry if it needs boot-time setup. The parallel driver, for instance, contributes cdevsw[21], parintr to int2_tbl[], and parinit to init_tbl[] ✅.
Note: the exact array name for boot-time init varies between sources — the paper uses
init_tbl[]; the modern VA2000 case study patches anio_init[]array in itskernel.c✅. Treat them as the same concept (a list of init functions called once at boot). The full originalmaster.d/kernel.cis not publicly archived, so the precise schema is inferred from the paper plus the community repos 🔴.
Placeholder entry points: nodev, notty, nostr, nullflag¶
A driver rarely implements every method in its switch struct. Instead of leaving a function pointer null (which would crash on a call), Amix fills unused slots with standard stubs ✅:
| Stub | Goes in | Behaviour |
|---|---|---|
nodev |
any unimplemented entry-point pointer (d_open, d_ioctl, …) |
returns ENODEV ("No such device") |
notty |
d_ttys of a char driver that is not a terminal |
no tty array |
nostr |
d_str of a char driver that is not a STREAMS driver |
no streamtab |
nullflag |
d_flag when the driver declares no flags |
empty flag value |
A purely output-only printer, for example, sets d_read = nodev, d_ttys = notty, d_str = nostr and leaves only d_open/d_close/d_write/d_ioctl/d_poll real ✅.
Naming convention: prefix every entry point¶
Amix drivers follow a strict naming convention so that the switch-table entries read self-documentingly: prefix every entry point with a short driver tag ✅.
par→paropen,parclose,parread,parwrite,parioctl,parpoll, plusparintr(interrupt) andparinit(boot init).dd(a disk driver) →ddopen,ddclose,ddstrategy,ddprint,ddsize.hya(the Hydra STREAMS net driver) →hydraopen,hydrawput,hydraintr. ✅ (see the Hydra case study)
A cdevsw[] slot for par therefore looks roughly like:
/* cdevsw[21] — the parallel port */
{ paropen, parclose, nodev /*read*/, parwrite,
parioctl, nodev /*mmap*/, nodev /*segmap*/, parpoll,
nodev, nodev, notty, nostr, nullflag },
Block vs character semantics¶
The two driver classes differ in how they move data ✅:
- Block driver. Its core is
strategy(): it accepts a buffer request, queues the I/O, and returns immediately; the actual transfer typically uses DMA and completes later via an interrupt. It also suppliesprint()(error reporting) andsize(). The block layer sits under the buffer cache and the filesystems, so block devices are what youmount. See Filesystems and disks. - Character driver. Its core is
read()/write(), optionallyioctl(),mmap()/segmap(), andpoll(). Character I/O is synchronous from the caller's perspective and is the general-purpose path for terminals, printers, raw disks, and framebuffers (e.g. the VA2000 RTG framebuffer is char major 68 ✅).
STREAMS: the third kind¶
STREAMS drivers are a distinct third category — technically a special character driver whose cdevsw[] entry points at a streamtab (the d_str field) instead of nostr ✅. STREAMS is the SVR4 mechanism for layered, message-based I/O and is how Amix does networking (TCP/IP, DLPI). A STREAMS network driver implements a put routine (e.g. hydrawput) rather than read/write, and is brought up with slink rather than opened directly (Amix is SVR4.0 and has no ifconfig … plumb). The full treatment is in Writing a STREAMS driver; the worked example is the Hydra DLPI driver, which registers at cdevsw slot 47 (hya) ✅. For the networking stack itself see Networking.
Key kernel APIs (the driver side)¶
The Ditto paper's par.c exercises the standard SVR4 DDI/DKI surface that an Amix driver draws on ✅:
| API | Purpose |
|---|---|
major() / minor() / getmajor() / getminor() |
extract device-number fields |
copyin() / copyout() |
move data across the user/kernel boundary |
uiomove() / uwritec() |
move data via a uio struct |
getc() / putc() |
clist (character-list) queue operations |
sleep(chan, pri[\|PCATCH]) / wakeup(chan) |
block and wake a process |
timeout() / untimeout() |
schedule / cancel a deferred callback |
spl2() / splx() |
raise / restore interrupt priority level (splpar() == spl2()) |
pollwakeup() |
notify poll() waiters of an event |
autocon(product_id, dev, &board, &dummy) |
Amix-specific Zorro II board discovery 🟡 (repo-confirmed) |
autocon() ties into the Amiga AUTOCONFIG mechanism that assigns Zorro II board addresses at reset; see Zorro II autoconfig for drivers. Note that the stock drivers are Zorro II only: AutoConfig puts Zorro III boards at 0x40000000 and above, outside the kernel's identity-mapped low 1 GB, so dereferencing the autocon() base directly only works for Zorro II boards; a driver that maps its board with sptalloc() reaches Zorro III too ✅ — see the Zorro III correction.
The Amix HBA/DMA driver contract (real hardware)¶
Everything above is the 1990 Ditto-paper model. This section adds the parts of the contract that the paper never spells out but that bite hard on real hardware — recovered by bringing up the native Z3660 piscsi SCSI driver and the cdfs in-kernel filesystem on a physical A4000 + Z3660, and by disassembling the stock Amix kernel. Getting any of them wrong causes silent corruption or a wild-jump panic. They are all ✅ (fix commit + real-hardware reproduction, or stock-kernel disassembly) and they generalize to any HBA/DMA driver on this platform, the a4091 included.
Completion runs in the caller's context, iteratively — never from a callout ✅¶
The SVR4 block layer hands your queue routine a struct sdcom (command + buffer + the cp->intr completion callback). Two facts about its lifetime and context are non-negotiable:
- The
sdcommay live on the caller's stack. ✅ Callers are allowed to allocate it as a local — the cdfs media backend does exactly this (struct sdcominside a stack-local request struct). So a driver that defers completion to a clock callout (timeout(done, cp)) readscp->intrfrom a dead stack frame once the caller returns, and jumps through reclaimed memory — kernel corruption and a wild-jump panic (seen the momentmount -F cdfstouched the CD). Rule: complete the command before your queue routine returns, or the caller must explicitly guarantee thesdcomoutlives the completion — and nothing in the interface says it does. The a4091 driver was immune only because it already completes inline. ✅ - …but a bare inline
(*cp->intr)(cp)blows the kernel stack, because disk completion re-issues the next I/O (ihandle → startio → sdqueue → your queue routine), so a naive recursive completion burns one stack frame per chunk and dies on the first big multi-chunk burst (~100 I/Os into boot on real hardware). ✅ The working shape is synchronous but iterative: a driver-owned completion FIFO plus acompletingguard, so the first caller drains the queue in a loop and re-entrant completions are enqueued, not recursed. A silent, kmem-readable FIFO-overflow counter is cheap insurance and must stay 0. ✅
(See the Z3660 piscsi driver for an implementation of this pattern.)
sdcom.addr is already a physical (bus) address ✅¶
The buffer address in sdcom.addr is physical by contract — the caller has already run it through vtop() before handing it over (both dd.c and the cdfs media backend do). A driver that "helpfully" adds its own vtop() double-translates and corrupts the transfer. ✅ It is an attractive wrong turn because the field is named addr and the rest of the kernel is virtual-addressed. (Related: raw/physio I/O never hands a driver a user VA either — dd.c → breakup() → amiga_dma_pageio() stages through a 2 KB kernel buffer — so a raw readback is trustworthy for verifying a write.) ✅
The spl / callout-IPL contract: spl2 sections are sound; an unguarded mailbox is not ✅¶
Amix callouts dispatch at IPL4 (SR 0x2400), which at first suggests every sdspl-guarded (spl2) critical section in the SCSI path is unsound against a callout-driven completion. It is not: the only thing that triggers a callout is the level-2 CIA-A clock, so masking at spl2 genuinely blocks it, and the stock sdspl (spl2) sections are correct. ✅ The real exposure is a driver's own hardware mailbox transaction carrying no spl masking — bracket the whole transaction (the Z3660 driver uses spl6, with permanent silent nest counters that must read 0). Platform quirk from the same disassembly: spl5/spl6/spl7 all emit the same _spl4 (SR 0x2400) on this kernel. ✅
The DMA-alignment contract: a vtop()'d buffer must NOT cross a page boundary ✅¶
On Amix, a DMA target handed through vtop() is valid for exactly ONE page (NBPP = 2048, sys/immu.h) — because large kmem allocations are virtually contiguous but physically scattered. ✅ A kmem_alloc above 4096 bytes goes through sptalloc → segkmem_alloc, which pops arbitrary frames off page_freelist and maps them at consecutive virtual addresses with no physical adjacency. So vtop() translates only the first page; the moment a single DMA transfer crosses the page boundary, its tail bytes land in the physically-next frame — owned by someone else.
This is a wild-write bug, and its two traps are what make it nearly invisible ✅:
- An allocator header silently destroys alignment. A portable allocator wrapper that prepends an 8-byte length header (SVR4
kmem_freeneeds the original size) shifts every "page-sized" buffer 8 bytes off page alignment. For example, 2048-byte CD sector buffers behind such a header sit 8 bytes into a page, so every sector DMA spills its last 8 bytes into the next frame. When the spilled bytes are a CD sector's zero padding, the signature is "a short run of zeros at the start of an innocent page": a binary's 16-byte ELF ident (such as/usr/sbin/umount's) can be zeroed on disk (buffer-cache page →fsflush), and libc's resident pages can be scribbled. (It also rounds a 2056-byte request into the 4096 buddy class — a size no stock consumer uses.) - Byte-correct reads prove nothing about wild writes. Bulk file reads verified byte-identical against the source ISO throughout, because they happened to use a different, accidentally page-aligned buffer; only misaligned metadata/mount-time reads did the damage, and the bytes they lost were padding nobody compares. The reliable detector was a canary soak:
sum -ra set of resident binaries (libc.so.1,sh,init,mount,umount,ls,sum,df) before and after N mount/umount cycles and diff. That check catches it in a single cycle (15/15 clean after the fix vs corruption by cycle 2–10 before). ✅
Fix pattern: page-align every DMA target (sector == page ⇒ one buffer = exactly one frame), bounce unaligned targets, never DMA into a stack buffer, and add a permanent kernel-side guard that refuses any request with (va & (NBPP-1)) + nbyte > NBPP via cmn_err instead of corrupting memory. ✅ For the allocator internals behind this (why >4096 is scattered, the buddy pools, the freelist-link corruption signature) see Kernel architecture → the kmem allocator. (This is the deeper root cause of the "in-kernel SCSI DMA corrupts multi-sector transfers" gotcha noted under kernel-build: a transfer that stays inside one aligned page is one physical frame and is safe.)
Writing an in-kernel filesystem: the SVR4 VFS_MOUNT contract ✅¶
A new in-kernel filesystem (cdfs was the first non-stock one) must honor a mount(2) contract that hands it a struct vfs full of recycled garbage, with several "obvious" moves being wrong ✅ (all from disassembling mount(2)/dounmount/vfs_add/s5mount/prmount/fdmount/getudev and diffing cdfs against the stock filesystems):
mount(2)does not zero thevfs, and it takes the lock before dispatching. Itkmem_allocs the 48-bytestruct vfs(not zeroed), inlines its ownVFS_INIT, then callsvfs_lock()(settingVFS_MLOCKinvfs_flag) before dispatching yourVFS_MOUNT. A filesystem that callsVFS_INIT()inside its own mount op therefore wipes the kernel's own lock bit — only ever OR intovfs_flag, never assign it. ✅vfs_dev,vfs_fsid,vfs_bcountarrive as heap garbage and must be set (stock s5 sets all three). A garbagevfs_devleaks to userland viastatvfsand can falsely match invfs_devsearch()(used byumount(2),bdevbsize, NFSgetvfs). ✅- The device-less convention is not
vfs_dev = 0.prinit/fdinitlatch a unique major fromgetudev()once at init and stampmakedevice(udev, 0)per mount. ✅ vfs_addclearsVFS_RDONLYunless the caller passedMS_RDONLY. A read-only filesystem that merely sets the flag itself will be believed writable — gate the mount onMS_RDONLY(returnEROFS) or the kernel's state lies. ✅
And two userland-side halves of the same contract:
mount(2)silently stacks mounts on an already-mounted directory — its only busy check isv_vfsmountedhereon the vnodelookupnamereturns, but for a mounted-on dirlookupnametraverses into the mounted filesystem whose root has that flag clear. So a second identicalmountreturns 0 and stacks, andumountthen pops one layer and returns 0 while the media "is still there" — a perfect imitation of a broken umount. Guard it in your own mount op (v_count > 1orVROOT→EBUSY), exactly asprmount/fdmountdo. ✅ This one is on the quirks checklist too./etc/mnttabis userland-maintained, andumount(1M)unmounts by the SPECIAL field. The kernel never writesmnttab; the per-fstype helper/usr/lib/fs/<fstype>/mountdoes (and without that binary the fstype cannot be mounted at all — SVR4mount(1M)execs it). Becauseumount(1M)locates the mount by the entry's special field, a device-less filesystem must record its mount point as the special (/proc//dev/fdset the precedent, e.g./proc /proc proc rw …); recording a<card>,<unit>spec instead makesumountsilently fail while deleting the entry. Trade-off to document:mnttabthen no longer records which unit is mounted. Append the line with a singleO_APPENDwrite, and never fail the mount because bookkeeping failed (warn, exit 0). ✅
Adding a driver: the table workflow¶
The paper's end-to-end procedure ties the whole model together ✅. Full mechanics (file edits, relink, boot-partition write) are in Building and installing a kernel; the conceptual steps are:
- Place the driver
.o(ideally with source) in a subdirectory under/usr/sysand add it to that directory's makefile. - Edit
master.d/kernel.c: add thecdevsw[]/bdevsw[]slot at your chosen major, plus anyint2_tbl[]/init_tbl[]entries. makein/usr/systo link a new kernel (calledrdbunixin the 1990 paper,relocunixon modern 2.1 systems — a historical rename 🟡).- Install it (copy to
/unix, or write it to a boot partition / floppy) and reboot. Keep the old/unixas a fallback ✅. mknod /dev/<name> c|b <major> <minor>to create the device node.
See also¶
- Building and installing a kernel — the relink/boot-partition cycle behind step 3–4 above.
- Writing a character driver — the
pardriver as a full worked example. - Writing a STREAMS driver — the third driver kind, for networking.
- Zorro II autoconfig for drivers —
autocon()and AUTOCONFIG board discovery. - Device list reference — known major/minor numbers.
- Case study: VA2000 framebuffer (char major 68) and Hydra STREAMS net driver (cdevsw 47).
Sources¶
- Ditto, Writing Amix Device Drivers, 1990 European Amiga Developer's Conference (the project's authoritative driver paper) — §5;
cdevsw/bdevswfromconf.h; thels -l /devexample;par.cworked driver andpar(7A)man page (p.22). sources/research-brief.md§5 (device-driver model), §4 (kernel architecture: monolithic, no loadable modules,kernel.cin source), §2 (SCSI ID hard-coding, Zorro II only).asokero/va2000-amixrepo (io_init[]/cdevsw[]slot 68 patches): https://github.com/asokero/va2000-amixisoriano1968/hydra-amixrepo (hyaatcdevswslot 47,hydraopen/hydrawput/hydraintr): https://github.com/isoriano1968/hydra-amix- The HBA/DMA driver contract (the "real hardware" section) — amix-z3660scsi @
2a463b8:a5af58a(sdcomstack-lifetime → synchronous in-context iterative completion via a driver-owned FIFO +completingguard; a callout deferral read a dead-stackcp->intr),908f40a(spl6 mailbox bracket - nest counters), the
sdcom.addr-is-physical / no-double-vtop()rule (source-verified acrossdd.c:240and the cdfs media backend), and the callout-IPL verdict (callouts dispatch at IPL4 but only the level-2 CIA-A clock triggers them, so spl2 sections are sound;spl5/6/7all emit_spl4) from stock-kernel disassembly; repoNOTES.md§"two load-bearing contracts". Validated on a real A4000 + Z3660 (2026-07-10 → 07-12). ✅ - The DMA-alignment contract + the VFS_MOUNT / mnttab contract — amix-cdfs @
31e8c3b:fa9db1a+31e8c3b(page-align DMA targets + a permanent(va & (NBPP-1)) + nbyte > NBPPguard;NBPP = 2048fromsys/immu.h; >4096 kmem is physically scattered sovtop()is valid for one page — the 8-byte allocator header shifted sector buffers off-page and wild-wrote the next frame),0fcf308(VFS_MOUNT contract: don'tVFS_INITover the kernel's lock bit; setvfs_dev/fsid/bcount;getudev()for device-less;vfs_addclearsVFS_RDONLY),844c2ed(anti-stacking +getudev),6f727b1+d3beca7(platform/amix/mount.cuserland helper +mnttabspecial-field semantics);tests/unit/test_remount.c. Stock-kernel disassembly (symbol-bearing relocatable kernel, period m68k binutils). Live on a real A4000 + Z3660 (2026-07-12 → 07-13): canary soak 15/15 byte-identical post-fix vs corruption by cycle 2–10 pre-fix; mount-stacking reproduction;mnttabspecial-field A/B test. ✅ - amigaunix.com — historical and end-user reference: https://www.amigaunix.com/doku.php/home