Files
sibo-playground/docs/reference/02-system.md

39 KiB
Raw Permalink Blame History

Psion SIBO / EPOC — Operating-System Programming Model

Reference notes for the SIBO family (HC, MC, Series 3/3a/3c, Siena, Workabout / Workabout MX) running the ROM-resident EPOC operating system, programmed through the PLIB library.

Sources, cited inline as:

  • [GEN]SIBO 'C' SDK, General Programming Manual, v2.30
  • [PLIB]PLIB Reference
  • [SYS]EPOC OS System Services (assembly-level service interface)

Everything below is drawn from those manuals. Items derived from prior reverse-engineering, rather than the manuals, are explicitly flagged [RE — reverse-engineered]. Workabout-MX-specific points are flagged [MX]; where the manuals do not distinguish the MX, that is noted as an uncertainty rather than invented.

Terminology. SIBO is the hardware architecture (8086-class CPU, ASICs, SSD storage). EPOC is the operating system that runs on it. This is the 16-bit SIBO-era EPOC, not the later 32-bit EPOC32/Symbian. [GEN §2]


1. The memory model

1.1 Small model, segments, and memory moving

EPOC programs are compiled in the 8086 small model: the code segment and the data segment are each limited to 64 KB. The restriction is deliberate — it lets EPOC move memory segments (including a process's own code and data segments) around physical RAM without any cooperation from the application, which is essential for efficient RAM use in a multitasking system. [GEN §1; PLIB "Introduction"]

Physical memory (address 0 to 0xFFFFF, up to 1 MB addressable; banked above 512 KB) is laid out roughly as: interrupt vectors (1 KB) → screen bitmap (small displays) → OS data space → allocated memory segments → unallocated memory → internal RAM drive (LOC::M:) → environment variables (≤4 KB) → screen bitmap (large displays) → ROM (~256 KB). [PLIB §7 "Overview of system memory usage"]

All unallocated RAM is kept in a single contiguous chunk. Consequently creating, deleting, or resizing one segment forces others to move. Segment addresses and sizes are always expressed in 16-byte paragraphs; segments start on 16-byte boundaries. The segment allocator's maximum segment size is 512 KB. [PLIB §7]

How moving is safe. The supervisor process (SYS$MANG) does the moving. It runs at higher priority than any other process (so it is not interrupted mid-move) and its own data/code segments never move. After a move it walks each process context and adjusts the 8086 segment registers (CS, DS, SS, ES) by the amount the relevant segment moved. A register is adjusted only if it holds a value inside a moved segment's range. [GEN §1; PLIB §7]

Two rules the application must obey so the supervisor's fix-up works:

  • Never store a segment register to memory and later reload it (the memory may move in between). In assembler, protect any unavoidable save/restore by disabling interrupts across it (which blocks the context switch).
  • Never load a non-segment value into a segment register (it might fall inside a moved segment's range and get "adjusted"). [GEN §1]

1.2 Segment kinds and naming

Allocated segments are described by a name, a handle, a size, and an address. [PLIB §7]

  • Device segments — created when an external device is installed, deleted on removal; normally do not resize. Placed at lower addresses than dynamic segments so device drivers (e.g. serial) are not disturbed by volatile dynamic activity. The first two are special: the data segments of SYS$NULL and SYS$MANG, created at startup and never resized. Extensions .LDD / .PDD denote Logical / Physical Device Driver segments.
  • Dynamic segments — code and data segments created/deleted as processes come and go; process data segments grow/shrink to fit heap demand. Extensions: .$SC = shared primary code segment, .DYL = dynamic library code, .$nn = process data segment (nn = process slot number).

The segment handle is the relative address of the segment's 16-byte index-table entry in OS data space; that entry holds the segment address, a usage count, and the name. Usage count = number of processes using the segment; when it drops to zero the segment is (normally) deleted. The index table has fixed capacity — typically 96 total = 32 device + 64 dynamic. [PLIB §7]

Segment API (names follow file-name rules, ≤8 chars + optional .ext): p_sgcreate, p_sgdelete, p_sgopen, p_sgclose, p_sgcopyto, p_sgcopyfr, p_sgsize, p_sgadjust, p_sgfind, p_sglock / p_sgunlock (usage-count inc/dec). Access to data in an external segment is less convenient than to the process data segment, but a single external segment can grow to the 512 KB limit — the standard way to hold data structures larger than 64 KB (e.g. a document). [GEN §1; PLIB §7]

1.3 The process data segment and heap

A process data segment is at most 0xFFE0 bytes (32 bytes short of 64 KB, so a stack underflow always trips an address trap). Layout, low → high:

  1. reserved static variables (0x40 bytes; "magic statics")
  2. floating-point emulator data space (0x200 bytes from offset 0x100)
  3. processor stack
  4. initialised static variables
  5. uninitialised static variables (also zero-initialised)
  6. process heap — at the top so it can grow by growing the data segment

The word at offset 0 is initialised to 0xDEAD; many OS calls check it and panic (PanicDead0, panic 68) if a stray NULL-pointer write has changed it. Stack bytes are pre-filled with 0xFF (the count of 0xFF from 0x40 measures free stack, unless the FP emulator is in use, in which case the stack floor is 0x300). [PLIB §7 "Process data segments"; §12 "Reserved statics"]

1.4 Heap cell allocation

Cells are referenced directly by address and do not move to compact freed space. Each cell is prefixed by a hidden 16-bit length word (skipped by the address returned to you; not counted by p_alen). Writing past a cell's bounds corrupts the length word (or a free-list pointer) — a "time bomb" that a later allocator call detects and turns into a panic.

The heap is not fixed size: EPOC grows it (up to the 0xFFE0 limit) to satisfy requests and shrinks it to hand memory back, but never below the initial / minimum heap value stored in the image (default 2 KB). Allocation is first-fit over the free list; if nothing fits, the data segment is grown by the requested amount plus the heap granularity (default E_GROWBY_DEFAULT 2 KB; max E_MAX_GROWBY 16 KB, set via p_hgran). [PLIB §7 "The heap allocator"]

Function Purpose
void *p_alloc(UINT size) Allocate ≥size bytes; returns address or NULL on failure. Always test for NULL.
void *f_alloc(UINT size) As p_alloc but calls p_leave(E_GEN_NOMEMORY) instead of returning NULL.
void p_free(void *pcell) Free a cell. No-op if pcell is 0 (handy in cleanup). Double-free corrupts the heap.
void *p_realloc(void *pcell, UINT size) Resize; returns new address or NULL (original untouched). pcell==0 ⇒ acts like p_alloc.
void *f_realloc(...) As p_realloc but leaves on failure.
void *p_adjust(void *pcell, UINT offset, INT amount) Open (amount>0) or close (amount<0) a gap inside a cell — insert/delete records in place.
UINT p_alen(void *pcell) Cell length (may be a few bytes > requested).
void p_hgran(UINT nparas) Set heap growth granularity (paragraphs).
void p_allwalk(fn, fpar) Walk every cell (diagnostic); panics on inconsistency.
void p_allchk(INT num) Force a heap-integrity check; p_panic(0xFF) if corrupt.
UINT p_allspc(void **pheap) Potential free space + heap start address.

Design cautions. Alloc heaven — if a multi-cell structure is built and a later allocation fails, free the already-allocated cells or they leak. Internal fragmentation — avoid churning tiny transient cells (use the stack), and "granularise" many-cell variable-length structures. Do not let an unbounded heap structure grow until allocation fails at the 0xFFE0 limit: leave enough heap for the user's expected actions (e.g. saving to file). Note EPOC does not distinguish "no system RAM" from "hit the 64 KB data-segment limit" — both surface as an allocation failure. [PLIB §7]

System-memory queries: p_getram (RAM in paragraphs, capped at 32768 = 512 KB due to banking), p_totalK (total KB ignoring banking; EPOC ≥ 3.50 only), p_sgfree (free segmented memory), p_sgramdisk (RAM-disk usage). [PLIB §7]


2. Processes

2.1 What a process is

A process is a running program, normally created by loading an image (.img / .app) file. EPOC is a single-user, preemptive multitasking OS. Max concurrent processes E_MAX_PROCESSES = 24. A process consists of at least: a process control block (E_proc), a data segment, a (possibly shared) primary code segment, and an I/O semaphore. [PLIB §12]

Process ID — a positive 16-bit value: low 12 bits = PCB offset in OS data space (the "slot"); high 4 bits = a generation counter (07, incremented mod 8 on slot reuse) so a stale PID from a terminated process is rejected. A bad PID panics the caller with panic 7. [PLIB §12]

Process names<name>.$nn where <name> is 18 chars and nn is the 2-digit slot index. For a task (a subsidiary process sharing its owner's data segment — a lightweight thread) the $ becomes a, e.g. SYS$WSRV.a05. Max name E_MAX_NAME = 12. Loading the same image twice yields distinct names (e.g. SORT.$07, SORT.$11); p_pidfind("SORT.*") / p_pfind resolve names→PIDs with wildcards. [PLIB §12]

The standard system processes created at startup:

Name Role Priority
SYS$NULL (.$01) Null process; runs when nothing else is ready; powers the machine off after inactivity 0
SYS$MANG (.$02) Supervisor: memory moving, resource cleanup, critical functions 248
SYS$FSRV (.$03) File server; also loads images to create processes 240
SYS$WSRV (.$04) Window server: screen/keyboard/digitiser
SYS$SHLL (.$05) Shell / launcher

Name/priority/suspend operations fail on SYS$NULL, SYS$MANG, SYS$FSRV. [PLIB §12]

2.2 Creating a process

HANDLE p_execc(TEXT *pName, VOID *pCommand, INT length);   /* usual route */

Loads the image pName (parsed with default extension .IMG) and returns a positive PID — but the new process is created SUSPENDED, so the creator can initialise it (e.g. set priority) before starting it with p_presume. A command cell is allocated in the new process's heap containing the full parsed path, a length byte, and length bytes copied from pCommand (lengthE_MAX_COMMAND_BUFFER = 127); its address lands in the reserved static DatCommandPtr. Loading is done by the file server, so the caller must already be connected to the file server (the standard PLIB startup does this before main; otherwise panic 41). [PLIB §12]

Error returns from p_execc (illustrative of the E_FILE_/E_GEN_ families): E_GEN_NOMEMORY, E_FILE_DEVICE, E_FILE_NOTREADY, E_FILE_DIR, E_FILE_NXIST, E_FILE_EXIST (a different program of the same name — checksum mismatch — already runs), E_GEN_IMAGE (not a valid/corrupt image), E_GEN_NOPROC (no free slots), E_GEN_ARG (data+stack+heap > 0xFFE0, or command too long). [PLIB §12]

  • p_execcasync(pName, pCommand, length, pStatus, pPid) — asynchronous load (completion via status word; see §5).
  • p_pcreate(E_CPB *pBlock) — the primitive creator that does not involve the file server; p_execc eventually calls it. The E_CPB block carries code/ stack/data/heap paragraph counts, initial IP, checksum, minHeap, initial priority, RAM/ROM flag, and name. [PLIB §12]

Shared code. A second instance of the same program shares the existing <name>.$SC code segment (only the initialised statics are reloaded). The load verifies the code segment checksum against the image header — so you cannot run two different programs (or versions) of the same name simultaneously. [PLIB §12]

2.3 Priorities and scheduling

Priority is an unsigned byte, 1255. At each reschedule the highest-priority ready process runs; if several share the top priority they run round-robin, each for a 4-tick slice (the system ticks 32×/second). A lower-priority process is otherwise blocked indefinitely. Preemption is real: a process need not call the OS to be switched out. [PLIB §12; SYS §10]

A process leaves the running state by moving to: SEMAPHORE (via p_iowait / p_wait), DELTA (via p_sleep/p_sleept/p_sleepa), or SUSPENDED (via p_psuspend on itself). Process states: E_PROC_READY, E_PROC_SEMAPHORE, E_PROC_DELTA, E_PROC_SUSPENDED (plus E_PROC_FREE for a vacated slot). The READY queue is priority-ordered; SEMAPHORE queues are FIFO; the DELTA queue stores inter-timer tick deltas. [PLIB §12]

Priority guidance and constants:

  • Applications should stay within E_MIN_PRIORITY (64)E_MAX_PRIORITY (192) inclusive; values outside are OS-reserved. [PLIB §12]
  • Window-server clients are typically created at E_PRIORITY_FORE (128 = 0x80) and then let the window server raise/lower them as they gain/lose focus. A default image priority of 0x80 is the norm. [PLIB §12; GEN §3]
  • Supervisor 248, file server 240; interrupts run at the priority of the process they interrupt. [PLIB §12; SYS §10]

Uncertainty / cross-source discrepancy. [PLIB] states E_MIN_PRIORITY = 64 and E_MAX_PRIORITY = 192. [SYS §10] speaks only of reserved bands cPBMinPriority / cPBMaxPriority without giving numbers, and the [SYS] service-number appendix lists a symbol E_MAX_PRIORITY 154. Treat the PLIB pair (64/192) as the documented application range; the 154 appendix value is unexplained in prose and should not be relied on.

Priority API: INT p_getpri(HANDLE pid), INT p_setpri(HANDLE pid, INT nPriority) (reschedules; E_GEN_RANGE out of range, E_FILE_NXIST no such process, E_GEN_FAIL on null/supervisor/file-server), p_presume, p_psuspend, p_marka/p_unmarka (mark this process (in)active — servers mark themselves non-active), p_getpid, p_pname/p_prename, p_getowner. Cross-process data access: p_pcpyfr, p_pcpyto, p_piscpyfr. [PLIB §12]

2.4 Termination

Self-termination:

  • void p_exit(INT nReason) — normal, graceful. nReason in 127..128; 0 = clean exit; negative = an init-failure error code; positive = an exit status to a parent. Falling off the end of main calls p_exit with main's return value (falling off a void main is poor practice — it exits with garbage).
  • void p_panic(INT nPanic) — abnormal (see §3). Application panic numbers should count down from 254 to avoid the system ranges.

Terminating another process:

  • INT p_pterminate(HANDLE pid, INT nReason)preferred. If the target called p_onterminate(nMessage), it is merely sent that message and gets to run cleanup and exit itself; otherwise it behaves like p_pkill.
  • INT p_pkill(HANDLE pid, INT nReason) — summary kill, no cleanup chance.
  • INT p_ppanic(HANDLE pid, INT nPanic) — panic another process (e.g. a server panicking a client that sent garbage).

All three return E_FILE_NXIST (no such process) or E_GEN_FAIL (target is the null/supervisor/file-server process). [PLIB §6]

Termination notification. p_logona(pid,&status) (async, signals the caller's I/O semaphore), p_logon(pid,mType) (delivers an IPC message — for servers tracking clients), p_watchall (message on any process termination — used by the shell). Each delivers a 16-bit process termination word whose high byte is one of: E_NORMAL_EXIT (low byte = the nReason), E_PANIC_EXIT (low byte = the panic number), or E_TASK_PANIC_EXIT (a task it owned panicked). p_logoffa/p_logoff cancel; after p_logoffa the status word gets E_FILE_CANCEL and you must still p_waitstat. [PLIB §6]

2.5 Inter-process communication (IPC)

Choices, roughly in order of coupling:

  1. Inter-process messaging — the primary client/server mechanism (below).
  2. Shared memory segments — a named external segment opened by several processes, guarded by a semaphore.
  3. Cross-process copyp_pcpyfr / p_pcpyto (a server pulling/pushing a client's data by reference passed in a message).
  4. Termination logon and the notifier service as lightweight signalling.

Messaging model [PLIB §12; SYS §5]. Designed for client/server, one server / many clients. A receiver first calls p_minit(nMess, lMess) to allocate nMess message slots from its heap, each an E_MESSAGE header (next, status, type, pid) + lMess data bytes. The message length is fixed by the server; the file server and supervisor both use lMess = 8. When data exceeds the slot, the message carries an address+length by reference and the server uses p_pcpyfr/p_pcpyto.

Lifecycle: sender p_msend* copies the message into a free slot and enqueues it → server p_mreceivew (or async p_mreceive) dequeues from the front and gets the slot address → server processes it → p_mfree frees the slot and, if the client used a "receive" variant, writes back a completion status and signals the client's I/O semaphore.

  • Client: p_msend (blind), p_msendreceivew (send + wait for reply), p_msendreceivea (send + set up an async reply). p_mcancel cancels a pending p_mreceive.
  • If the server has no free slot, the send blocks on a mutual-exclusion semaphore — even the "async" variant can block indefinitely. Multi-client servers therefore pre-allocate a slot per potential client (≈ E_MAX_PROCESSES minus known processes).
  • Priority queue-jumping: a message from a client of priority ≥ 0x80 is inserted at the front of the server queue (overtaking others), so a foreground task's requests are served first. [PLIB §12; SYS §5]
  • For relative priorities to work, a multi-client server should run at a higher priority than any client; a client waiting on a low-priority server is effectively lowered to the server's priority. [PLIB §12]

The message and I/O systems share status-word + I/O-semaphore mechanics, so async messaging and async I/O can be mixed freely. [SYS §5]


3. Panics

A panic is a fatal exception. When EPOC detects a condition that "could only arise from a bug", it terminates the process immediately with a panic number in 0255. Applications cannot trap or survive a panic (contrast p_leave, which is recoverable). Panicking is cheaper than an error return and enforces discipline by killing the defect at the point of detection. The OS still uses error returns where a condition can arise from legitimate user action — e.g. p_alloc returns NULL on genuine memory exhaustion but panics if it finds the heap corrupted. [GEN §2; PLIB §6]

Panics are raised by p_panic (self) or p_ppanic (another process — used by a server against a misbehaving client). Applications may call p_panic themselves to catch "impossible" cases (e.g. a switch default). [PLIB §6]

3.1 Panic-number ranges — how to interpret one

Range Origin Where documented
080, and 255 PLIB library PLIB Reference (§6)
81129 Window Server library Window Server Reference
130160 OLIB object library OLIB Reference
160254 Non-ROM code (e.g. ISAM and other loadable libraries) the relevant library's manual

[GEN §2; PLIB §6]

Notes on reading a panic:

  • A given number in the 160254 band may be used by more than one non-ROM component, so it is ambiguous — the only definitive way to locate the source is to catch it under the SIBO Debugger and trace back. [GEN §2]
  • The manual overview says PLIB owns 080 & 255; the detailed PLIB list also documents specific panics at 60, 6569, 77, 78 etc., and warns that a panic 79 or anything 81254 may belong to another component. Window-server panics are cited as 81110 in one place and OLIB as 130158 in another (both inside the summary bands above). [PLIB §6]
  • Panics described as "Invalid function number for …" rarely indicate a specific coding bug; they usually mean a trashed return address sent the IP wandering into arbitrary code. [PLIB §6]

3.2 Selected PLIB system panic numbers (080, 255)

# Meaning
0 Test-code failure
15 Semaphore manager: bad function no. / handle / not allocated / negative initial count / negative signal count
68 Process manager: bad function no. / invalid PID / task tried to create a task
9 Time manager: bad function number
1014 Segment manager: bad fn / negative size / bad type / bad handle / copy out of range
1519 Heap manager: bad fn / heap not initialised / cell reduced by more than its size / granularity > E_MAX_GROWBY / cell address outside heap (heap corrupted — try p_allchk)
2023 IPC message manager (bad fn; already init'd (double p_minit); not init'd; zero-length queue)
2426 I/O manager: bad fn / invalid I/O channel (unchecked p_open, closed/overwritten handle) / device requested panic
27 Invalid wait-handler handle (often really a low-address overwrite at addr 2 via an uninitialised pointer)
2829 Key/pointer already hooked / requester not a task
3031 Device manager: bad fn / bad device handle
3234 File manager: bad fn / already connected to file server / reserved
3541 Library manager & file-server connection (bad handle/fn, invalid LIB channel, bad DYL index, invalid message to file server, not connected to file server = 41)
4247 Conversion / general manager fns; unhook-notify-when-not-hooked; invalid revector address
48 p_leave called before p_enter
4956 OOP errors (no method, invalid reclass, unknown category/class, supersend outside method, handle before link, missing external categories, object not a valid class)
57 Invalid link-layer completion code
5859 Invalid fn for window server / hardware manager
60 Write outside process data segment (uninitialised pointer / corrupt structure)
61 Interrupts disabled too long
6364 Divide-by-zero / overflow interrupt
6567 Dbf manager: bad fn / bad DBF I/O channel / bad DBF parameter
68 Address-zero overwrite (the 0xDEAD word changed — uninitialised pointer)
69 Less than 0x100 bytes of stack remain (large automatics — make them static or heap)
70 Environment name > EnvMaxNameSize
71 / 72 Single-step (INT 1) / breakpoint (INT 3)
73 A request was made while an async request of the same type on the same channel was still pending
7476 Serial-I/O bad fn / ASIC1 call on an ASIC9 machine / DYL not in a visible bank
77 Floating-point emulator exception
78 Semaphore count exceeds 0x7FFF
80 Library fatal error (preceded by a specific-error notification)
255 p_allchk detected a corrupted heap

[PLIB §6 "System panic numbers"]


4. Error codes

System functions signal failure by convention:

  • Functions returning an address return NULL (0) on failure.
  • Otherwise: zero or positive = success, 1 = failure (E_GEN_FAIL = 1; you may just test the sign).
  • When the error is elaborated, a negative system error number in 1..128 is returned, allocated in bands: [PLIB §6 "Error returns"]
Band Family Header
1 … 31 General errors E_GEN_xxx p_gen.h
32 … 63 I/O / device / file errors E_FILE_xxx p_file.h
64 … 95 Reserved
96 … 128 OPL run-time errors

p_errs(TEXT *str, INT errno) converts a system error number to a language-dependent string (buffer ≥ E_MAX_ERROR_TEXT_SIZE = 64; unknown numbers give "Unknown error [xx]"). The notifier services p_notify / p_notifyerr present errors to the user; p_setnotify(FALSE) suppresses automatic notification for unattended/server processes. [PLIB §6]

4.1 E_GEN_* (general) family

The named generics referenced across the manuals (all defined in p_gen.h, values in 1..31):

Symbol Value Meaning
E_GEN_NONE 0 No error / success
E_GEN_FAIL 1 General failure (also the generic "just test the sign")
E_GEN_ARG n Bad argument (e.g. invalid double, buffer too small, out-of-range command length)
E_GEN_OVER n Overflow — value too large for the target type/representation
E_GEN_UNDER n Underflow — magnitude too small to represent
E_GEN_RANGE n Value outside a permitted range (e.g. priority)
E_GEN_INUSE 9 Resource in use (e.g. segment usage count > 0)
E_GEN_NOMEMORY n Insufficient (system) memory; f_alloc/f_realloc p_leave with this
E_GEN_NOPROC n No free process slots
E_GEN_NOSEM n No semaphores available (p_semcrt)
E_GEN_IMAGE n File is not a valid image, or is corrupt

Uncertainty. Of the E_GEN_* family only two exact numeric values are fixed by the task's cross-check set and consistent with the manuals: E_GEN_FAIL = 1 and E_GEN_INUSE = 9. The manuals reference the other E_GEN_* symbols by name and behaviour but the two source files do not tabulate their individual numeric values (they live in p_gen.h). Values shown as n above are known only to fall in the 1..31 band; do not assume specific numbers without the header. [PLIB §6, and usage throughout]

4.2 E_FILE_* (I/O / file / device) family

Defined in p_file.h, values in 32..63. Anchor values confirmed against the task's cross-check set:

Symbol Value Meaning
E_FILE_EXIST 32 Already exists (file, or a differently-checksummed process/segment of the same name)
E_FILE_NAME 38 Invalid name (file / memory-segment name)
E_FILE_DEVICE 41 Device does not exist / device driver not found
E_FILE_PENDING n Async request still outstanding (see §5) — the status-word "in progress" marker
E_FILE_CANCEL n Request was cancelled (written to the status word by a cancel)
E_FILE_NXIST n Does not exist (file, directory entry, or process)
E_FILE_DIR n Directory does not exist
E_FILE_NOTREADY n Device present but no medium (e.g. no SSD in the pack)

Uncertainty. Three exact values are pinned by the cross-check and match the manuals' usage: E_FILE_EXIST = 32, E_FILE_NAME = 38, E_FILE_DEVICE = 41. The remaining E_FILE_* symbols appear by name in the manuals (e.g. as p_execc, p_setpri, segment, and async return values) but the two source files do not enumerate their numeric values — those are in p_file.h. Serial-port variants such as E_FILE_PARITY are mentioned in prose without numbers. [PLIB §6/§7/§12]

Observed usage tying symbols to operations: p_execcE_FILE_DEVICE, E_FILE_NOTREADY, E_FILE_DIR, E_FILE_NXIST, E_FILE_EXIST; p_setpri / p_presume / termination calls → E_FILE_NXIST; segment create/open → E_FILE_EXIST, E_FILE_NAME; segment delete → E_GEN_INUSE. [PLIB §7, §12, §6]


5. Asynchronous I/O model

5.1 Two-step services, status words, the I/O semaphore

Most services exist in two halves: make the request, then wait for completion. A synchronous function does both; the internal asynchronous function issues the request and returns at once. Async request functions include p_ioa/p_ioc (on an open I/O channel), p_mreceive (IPC), p_execcasync (image load), and p_logona (termination). [PLIB §8]

Every process gets an I/O semaphore at creation ("asynchronous-request semaphore" would have been the accurate name). Each async request is tied to a caller-supplied status word (a signed 16-bit WORD). The invariant for every async request: [GEN §5; PLIB §8]

  1. On issue, the status word is set to the negative E_FILE_PENDING.
  2. On completion, a value other than E_FILE_PENDING is written — ≥ 0 for success, a negative error number for failure.
  3. The write to the status word happens before the caller's I/O semaphore is signalled. Both steps are integral to the mechanism.

p_iowait decrements the I/O semaphore and blocks until it is non-negative, i.e. until something has been signalled. It does not tell you which request completed — after it returns you poll the status words for the first that is no longer E_FILE_PENDING. The semaphore starts at 0; because p_iowait decrements first, it returns only when a signal has arrived. [GEN §5; PLIB §8]

p_ioc(SerialChannel, P_FWRITE, &SerialStatus, str, &len);   /* async write */
p_ioc(TimerChannel,  P_FRELATIVE, &TimerStatus, &timeout);  /* async 5s timer */
p_iowait();                                                  /* block on one */
if (SerialStatus == E_FILE_PENDING) { /* timed out: cancel the write */
    p_iow(SerialChannel, P_FCANCEL);
    p_waitstat(&SerialStatus);
    p_leave(SERIAL_TIMEOUT);
}
p_iow(TimerChannel, P_FCANCEL);
p_waitstat(&TimerStatus);

[PLIB §8, adapted]

Prioritisation is implicit in poll order: if two requests have completed, whichever status word you poll first is serviced first. A low-priority source can be "locked out" by a rapidly firing higher one. [GEN §5]

Where to declare status words — in static data or in a heap control block, never on a stack frame that will return before the request completes. A stale status word is later written to random memory. [GEN §5]

5.2 The p_io? calls and channels

I/O devices share a uniform interface: p_open (by name, returns a channel handle) → request services → p_close. Synchronous requests use p_iow (convenience wrappers p_iowN); asynchronous use p_ioa / p_ioc (wrappers p_ioaN, p_iocN). The async form always takes one extra parameter — the status-word address. p_ioa vs p_ioc are the async requestors used in the examples ("a" = asynchronous). [GEN §5; PLIB §8/§9]

5.3 The semaphore & I/O-semaphore primitives

EPOC semaphores are counting semaphores (signed value): p_signal increments, p_wait decrements; a negative value means processes are queued (released FIFO). [PLIB §8]

Function Effect
HANDLE p_semcrt(INT nCount) Create a semaphore, initial count nCount (≥0); returns handle or E_GEN_NOSEM. Auto-deleted on process exit.
void p_semdel(HANDLE s) Delete (waiters are released). Rarely needed pre-v3.
void p_wait(HANDLE s) Decrement; block if it goes negative.
void p_signal(HANDLE s) Increment; release the first waiter and reschedule.
void p_signaln(HANDLE s, INT n) p_signal × n.
void p_signalnr(HANDLE s) Signal without rescheduling (reschedule waits for the next tick, or force one with p_sleept(0)).
void p_iosignal(void) Increment this process's I/O semaphore (used by wait handlers, or to raise an "internal event").
void p_iosignalbypid(HANDLE pid) Signal another process's I/O semaphore (after setting its status word via p_pcpyto).
void p_iowait(void) Wait on the I/O semaphore; runs active wait handlers.
void p_waitstat(WORD *pstat) Wait until *pstat != E_FILE_PENDING, correctly re-signalling any other completions seen meanwhile.
void p_ioyield(void) p_iosignal + p_iowait — give installed wait handlers a chance to run.

[PLIB §8]

Discipline. Every p_iosignal must be matched by a p_iowait, and its status word must already be complete when signalled. An unmatched signal is a "stray signal" at Psion — the next p_iowait returns spuriously with nothing ready. Guard against it (e.g. p_panic if the post-p_iowait poll finds no completed word); the Spy app helps spot accumulated stray signals. Making a second request while one of the same type is pending on the same channel → panic 73. [PLIB §8; GEN §5]

Waiting for one specific request: use p_waitstat, not p_iowait — it returns only when that status word completes and re-issues signals for other completions it absorbs. General-purpose synchronous wrappers must use p_waitstat (they cannot assume no other requests are pending). [PLIB §8]

5.4 Cancelling a request

General principles for all cancels (p_iow(P_FCANCEL) for channels, p_mcancel for IPC, p_logoffa for termination): [GEN §5; PLIB §8]

  • A cancel precipitates completion; it does not un-issue the request.
  • It may or may not be effective — the operation may finish naturally first.
  • You must still consume the completion, normally with an immediate p_waitstat. If the request was still outstanding, E_FILE_CANCEL is written to the status word and the process is signalled.

Canonical timer cancel:

p_iow2(timH, P_FCANCEL);   /* synchronous; completes at once */
p_waitstat(&timstat);      /* use up the resulting signal */

Forgetting the p_waitstat after P_FCANCEL is a common bug: the next p_iowait then returns immediately although nothing is actually ready. [GEN §5]

5.5 Wait handlers and interrupt-driven completion

Wait handlers run inside p_iowait/p_waitstat, just before it would return. Install with p_svecadd, remove with p_svecrem, (de)activate with p_sveccall. Each returns P_SIGNAL_DISABLE (completion handled, deactivate), P_SIGNAL_ENABLE (handled, keep active — more pending), or P_SIGNAL_UNUSED (nothing of mine — the signal is external). p_iowait returns to the caller only when all active handlers report P_SIGNAL_UNUSED. The handler queue hangs off a 4-byte header in the reserved static at address 2; corrupting it usually yields panic 27. [PLIB §8]

This matters because a hardware interrupt handler cannot write to the requesting process's data segment (it may be mid-move). Instead the driver writes to a fixed location and signals the I/O semaphore; the driver's wait handler later copies the data into the process data segment when the process next enters p_iowait. Therefore, if you poll a status word (e.g. between chunks of a long computation) instead of blocking, call p_ioyield before each poll so those handlers can run — and still consume the signal with p_iowait once the poll sees completion, or you get a stray signal. Tight-loop polling without yielding is "anti-social" (it hogs the CPU). Server-implemented drivers (e.g. the window server) need no wait handler. [PLIB §8]


6. The SIBO OS executive call (assembly-level interface)

At the C level, p_iow/p_ioa/p_ioc are thin shells over EPOC's ROM services. Those services are reached by an 80C86 software interrupt (INT XXH). Two flavours: single-service (INT XXH — one dedicated interrupt per common service, minimal overhead) and multi-service (MOV AH, ZZH then INT XXH, where ZZ selects the function). [SYS §"System services"]

General calling convention [SYS §"Calling conventions"]:

  • All registers except AX are preserved unless they carry a return value; AX is a scratch/return register.
  • Errorcarry flag set, error number in AL (always negative); other result registers are then indeterminate.
  • Handles are 16-bit, guaranteed positive, non-zero and even; passed in BX where possible; results returned in AX.
  • A programmatically invalid argument ⇒ the process is terminated immediately (a panic), not an error return.

The I/O executive specifically. The manual documents the underlying I/O services at register level:

  • IoAsynchronousAL = I/O function number, BX = I/O handle, DS:CX → arg1, DS:DX → arg2, DS:DI → status word; returns AX = device driver result (carry set ⇒ error). This is exactly the layer beneath p_ioa/ p_ioc: on success the driver later signals completion via IoSignal/IoSignalByPid, and you wait with IoWaitForSignal / IoWaitForStatus (the p_iowait/p_waitstat primitives). Panics PanicIo1 (bad channel), PanicLib1/PanicLib2 (bad library handle/function).
  • IoWithWait — same inputs minus the status word (AL, BX, DS:CX, DS:DX), returns AX = result; the synchronous service beneath p_iow.
  • IoAsynchronousNoError — as IoAsynchronous but start-up errors are reported through the status word + signal rather than the carry flag. [SYS §8 "Input Output Management"]

[RE — reverse-engineered] Prior reverse-engineering of a SIBO ROM identifies the I/O executive concretely as INT 0xCF, with CL = function code, BX = channel handle, DX = argument, result in AX — i.e. the actual interrupt vector and register wiring under p_iow. This is consistent in shape with the documented IoWithWait/IoAsynchronous services above (handle in BX, result in AX, args passed by register), but note two things the manuals do not corroborate and that should be treated as RE findings only:

  • The manuals never name the interrupt number for any service (INT XXH is left abstract throughout [SYS]); 0xCF comes from RE, not the SDK docs.
  • The documented multi-service convention puts the function code in AH, and the I/O services put it in AL with args in CX/DX and the status word in DI. The RE note's CL = function / DX = argument differs from the published register layout. The discrepancy may reflect a particular wrapper, ROM version, or single-service entry point observed during RE; do not treat the INT 0xCF / CL/BX/DX/AX mapping as documented — it is an RE observation to be verified against the specific ROM.

7. Workabout / Workabout MX notes

The manuals cover the SIBO family collectively (HC, MC, Series 3/3a, Workabout); the programming model above — small model, segment moving, panics, error families, async I/O, the executive interrupt — applies to the Workabout as it does to the rest of the range. [MX] The Workabout MX is a later, faster (higher-clock, more-RAM) member of the same SIBO/EPOC-16 line; the SDK source manuals used here (v2.30, 1999) do not carry MX-specific programming differences, so any MX deltas (RAM sizing, ROM version, clock/timer specifics) are not established from these sources and are left as an explicit uncertainty rather than guessed. Version-gated APIs seen above (p_totalK needs EPOC ≥ 3.50; p_semdel recommended only from v3) are the kind of thing to check against the actual MX ROM version via p_getver / the version services. [GEN §7; PLIB §7]


Source map

  • Memory model & allocation: [PLIB §1, §7], [GEN §1]
  • Processes, priorities, IPC, termination: [PLIB §6, §12], [SYS §5, §10]
  • Panics & panic ranges: [GEN §2], [PLIB §6]
  • Error families: [PLIB §6] (+ usage in §7/§12)
  • Async I/O, semaphores, wait handlers, cancel: [GEN §5], [PLIB §8, §9]
  • Executive interrupt / register conventions: [SYS] "System services", "Calling conventions", §8; INT 0xCF mapping = [RE], not in the manuals.