Skip to content

System and CP/M services

This page covers low-level file descriptors, directory enumeration, error reporting, assertions, and the CP/M-specific extensions.

unistd.h / fcntl.h — low-level file I/O

These map onto CP/M file operations and operate on small integer file descriptors. Include unistd.h and fcntl.h.

Function Summary
int open(const char *path, int flags, ...) Open/create a file, returns a descriptor.
int read(int fd, void *buf, unsigned n) Read up to n bytes.
int write(int fd, const void *buf, unsigned n) Write up to n bytes.
int close(int fd) Close a descriptor.
long lseek(int fd, long off, int whence) Reposition the descriptor.
int unlink(const char *path) Delete a file.
int fsync(int fd) Flush file data to disk.
int fdatasync(int fd) Flush file data to disk.

fcntl.h also declares open in K&R form (int open();) for compatibility. off_t from unistd.h is long.

Constant Value Meaning
O_RDONLY 0 open for reading
O_WRONLY 1 open for writing
O_RDWR 2 open for reading and writing
O_CREAT 0100 create the file if needed
O_TRUNC 01000 truncate an existing file
int fd = open("OUT.BIN", O_WRONLY | O_CREAT | O_TRUNC);
if (fd >= 0) {
    write(fd, data, len);
    close(fd);
}

Access modes are enforced: read() on an O_WRONLY descriptor and write() on an O_RDONLY descriptor fail with EBADF. O_CREAT alone preserves an existing file and starts at offset zero; O_TRUNC truncates only an existing file unless O_CREAT is also present. O_RDONLY | O_TRUNC is rejected with EINVAL. A zero-length write never changes the file position or tracked length.

Because both int and the low-level return type are 16-bit signed values, one read() or write() reports at most INT_MAX (32,767) bytes. A larger request is performed as a legal short transfer of at most 32,767 bytes; loop until the requested total is complete. This avoids successful counts becoming indistinguishable from -1.

lseek() rejects a negative final position, signed arithmetic overflow, and a nonzero high byte in whence, leaving the old position unchanged. Successful lseek() and close() operations discard any pending ungetc() pushback.

Single file I/O core

open/read/write/close/lseek/unlink/fsync/fdatasync share one FCB/DMA core. The first file call links that core; additional file calls are nearly free. See the appendix.

dirent.h — directory enumeration

Include dirent.h. CP/M has no subdirectories, so this enumerates files on the selected drive.

Function Summary
DIR *opendir(const char *path) Begin a scan. ".", "*.*", and a bare "A:" enumerate every file; a specific pattern ("*.C", "T?.TMP") filters the scan the same way unlink() does.
struct dirent *readdir(DIR *dirp) Next matching entry, or NULL at the end.
int closedir(DIR *dirp) End the scan.

struct dirent has a single member, char d_name[13], holding the 8.3 name. The public API uses POSIX-like names through macros:

Public name Runtime entry name
opendir dopn
readdir drd
closedir dcls

Call the public names. The short runtime names exist to avoid external-symbol collisions on the M80/L80 toolchain.

DIR *d = opendir("*.*");
struct dirent *e;

while ((e = readdir(d)) != NULL)
    puts(e->d_name);
closedir(d);

Standard diagnostics and errors

The standard-library reference now has dedicated pages for error reporting and assertions. The CP/M file runtime uses errno for file-related failures, and assert writes its diagnostic to the console before terminating with abort().

File I/O and CP/M BDOS conventions

CP/M 2.2's BDOS file model is much simpler than POSIX/C89 assume, and DCCRTL's fopen/read/write/etc. are built directly on it rather than emulating a richer filesystem underneath. The differences below aren't DCCRTL bugs — they follow from what BDOS itself can express — but they can surprise code ported from a hosted C library. Every point here has been cross-checked against several independent CP/M emulators (ntvcm, tnylpo, cpmemu, zxcc, iz-cpm, z88dk's cpm, RunCPM, and Takeda Toshiya's cpm.exe) and, where noted, differs between them.

File length is tracked in 128-byte records, not bytes

CP/M has no byte-granular length field; a directory entry only knows how many 128-byte records a file occupies. A file whose true length isn't a multiple of 128 is still stored as a whole number of records, and the untouched tail of the last record — the padding between the real data and the record boundary — is written as Ctrl-Z (0x1A) bytes when that record is first created (there's no "old data" to merge a partial write with). That padding is genuine, readable, on-disk data: a large-enough fread() on a short file can return the trailing 0x1A padding right along with the real bytes. ftell() reports the current byte position, not the file length. Seeking to the end of an ordinary reopened file uses its record-rounded directory length. This is deliberately left as documented behavior rather than "fixed" by trimming the tracked length at the runtime level — doing that breaks the classic CP/M convention (used by real programs, and by this repo's own fileops.c test) of writing a single trailing Ctrl-Z as the real end-of-text-file marker, which is indistinguishable on disk from unwritten padding. See tests/tpadread.c for a worked repro and tests/tctrlz.c for the Ctrl-Z-as-text-EOF convention in fgets/fread.

Practical implications:

  • Don't assume fread(buf, 1, sizeof(buf), f) stops exactly at a text file's logical end; check for the file's own EOF convention (Ctrl-Z) if you rely on padding not leaking into the buffer.
  • Ordinary streams initially use the record-rounded directory length for fseek(f, 0, SEEK_END). An append stream replaces that value with its trimmed logical end so later append writes can restore it after a seek.
  • fopen(path, "a") computes the logical end by scanning the last record backward for a run of trailing Ctrl-Z bytes. Every nonempty write restores that stream's tracked logical end first, so an intervening fseek() cannot redirect an append write into the middle of the file. A pre-existing trailing Ctrl-Z written as real text-EOF data can still shift where "end" is judged to be, as described above.

Record-count overflow at exactly 8 MB

BDOS function 35 (compute file size) writes the record count to the FCB's three-byte random-record field. DCC's ordinary file-length initialization currently reads only its low 16 bits. CP/M 2.2's maximum file size, 8 MB, is exactly 65536 records, so that truncated count is zero and can look like an empty file. This is a runtime accounting limitation, not a 16-bit BDOS return contract. DCCRTL treats this as the one legitimate reason __fdlen can be genuinely larger than what a raw record-count register pair reported; see tests/tbig.c for sequential and random I/O across that boundary.

Only search (fn 17/18) and delete (fn 19) officially support wildcards

Per the documented CP/M 2.2 Interface Guide, ? in an FCB byte means "match any character in this position" — but only for opendir/readdir (BDOS 17/18, search first/next) and unlink (BDOS 19, delete). fopen/open (BDOS 15, open), fopen(path, "w")/fopen(path, "a")-on-a-new-file (BDOS 22, make), and rename (BDOS 23) are silent on the subject — the spec never promises anything for an ambiguous FCB passed to them, and behavior there is either implementation-defined or deliberately blocked at the DCCRTL level (see below).

__mkfcb passes a ? through unchanged, matching real BDOS wildcard semantics: unlink("WA?.TMP") deletes every file the pattern matches, not just one (WA1.TMP, WA2.TMP, and WA3.TMP all at once, for example), and opendir("T?.TMP")/readdir() filters to exactly the files that match. There is no way to single out one file once a wildcard character is present. See tests/twild.c and tests/tdirpat.c.

A literal *, unlike ?, has no meaning to BDOS itself — only ? is a real wildcard at the BDOS level. The familiar shell-glob convention where * matches any run of characters is implemented by the CCP's own command-line parser (and by virtually every historic CP/M C runtime library), which expands * by filling the rest of the current field with ? before the FCB ever reaches BDOS. __mkfcb does the same: a * in the name or extension fills the remainder of that field with ?, so unlink("*.BAK"), opendir("*.C"), etc. behave the way C code typically expects, consistently across every DCCRTL target rather than only on hosts whose emulator happens to do its own glob expansion. See tests/tstar.c.

rename() with an ambiguous FCB (either the old or new name) is rejected outright (-1, errno = EINVAL, nothing renamed) — this is correct, spec-compliant behavior, not a limitation worth working around. Cross-drive renames are also rejected before BDOS (-1, errno = EXDEV); a missing drive prefix is resolved through the current CP/M drive before comparison. (CP/M 2.2's own BDOS source technically loops over ambiguous old-name matches the same way delete does, but that's undocumented, and it would just copy the new name's bytes verbatim — ? and all — into every match, producing garbage entries rather than any kind of sensible template substitution; no tested BDOS implementation actually does this.) See tests/trenwild.c.

Low-level read errors versus EOF

BDOS random-read statuses 1 and 4 mean unwritten data/extent and remain the runtime's EOF-or-hole result. Unambiguous failures such as an invalid FCB, media change, verification failure, hardware error, or out-of-range record set errno and the stream error indicator instead. If earlier records were transferred, read() returns that positive partial count; otherwise it returns -1.

fsync() and fdatasync() on CP/M

Both functions first require a currently open real-file descriptor. They then issue BDOS function 48 with E = 0 and report its documented hardware-error return as EIO. CP/M 2.2 itself has no function 48: some emulators implement or safely ignore it, while others terminate the guest on the unsupported call. On implementations that support it, the call flushes global disk buffers. It cannot force still-pending FCB allocation/extent metadata to the directory without closing the file, so close() remains the operation that finalizes that metadata.

fopen()/open() for reading an ambiguous name is genuinely implementation-defined: real BDOS's open call happens to reuse the same directory-search primitive delete uses internally, with no explicit check against an ambiguous FCB, so on some implementations it silently opens whatever the first matching directory entry happens to be, while others reject it outright. Don't rely on this either way. See tests/tfopenw.c.

fopen()/open() for creating a file ("w", or "a" on a file that doesn't exist yet) is different: DCCRTL explicitly rejects a ? or * in the parsed name/ext before making any BDOS call at all, returning failure (NULL from fopen, -1 from open, errno = EINVAL). BDOS's make call never validates the FCB it's given — it just copies it into an empty directory slot — so letting a wildcard through would silently create a real, permanent file whose name contains that literal character, and such a file can never again be matched by a wildcard-aware unlink()/opendir() scan (which correctly treat ?/* as pattern characters, not literal ones). Rejecting it up front avoids creating a file with no portable way to clean it back up. See tests/tmakewc.c.

rename() onto an existing (unambiguous) destination name

This is a separate case from the ambiguous-FCB one above: both names are ordinary, unambiguous filenames, but the destination already exists as its own file. Real BDOS (confirmed against its own source) doesn't check for this at all — it just overwrites the matched entry's name/extension bytes with the new name, with no awareness that another directory entry already has that name. On real hardware that leaves two directory entries sharing one name, and which one a later open() finds is undefined.

No emulator that maps CP/M files onto real host files can reproduce that: POSIX and Windows filesystems both refuse two directory entries with the same name, so every emulator is forced to collapse this into a single winner, one way or the other — there's no hardware-faithful option available at all, on any host. Emulators split on which winner they pick: ntvcm, cpmemu, zxcc, and z88dk's cpm overwrite the destination (nonzero return means failure — 0 means the destination now holds the source's old content and the source name is gone); tnylpo and cpm.exe reject the rename outright instead (nonzero return, original name and content untouched). Neither is "more correct." If your program cares which way this goes, check rename()'s return value and/or unlink() the destination first rather than relying on either outcome. See tests/trenamex.c.

Drive-letter prefixes

fopen("A:FILE.TXT", ...) and similar are supported: a leading A-P (case-insensitive) followed by : is parsed into the FCB's drive byte, and the rest of the name follows normal 8.3 rules. There is no directory/path concept beyond this single-letter drive prefix — CP/M has no subdirectories at all (see dirent.h above).

8.3 filenames and truncation collisions

Every filename is 8 characters plus a 3-character extension, uppercased, with no further validation. A longer host-supplied or generated name is silently truncated to fit; two different names that happen to truncate to the same 8.3 form collide and refer to the same underlying CP/M file, with no error raised at creation time. See tests/tlongfn.c.

No atomic append, no O_APPEND-style write positioning

BDOS has no equivalent of POSIX's O_APPEND (every write goes to wherever the FCB's current record pointer is, and nothing serializes that against other processes). fopen(path, "a") computes the append position once, at open time; it does not re-seek to end before every subsequent write the way a true O_APPEND descriptor would if another process extended the file in between. Programs that share or alternate writes to the same file across processes must coordinate at a higher level.

CP/M extensions

The runtime exposes the raw CP/M BDOS entry point for things the standard library doesn't cover (console status, direct disk calls, and so on). It is declared in stdlib.h:

int bdos(int fn, int dearg);
int bdoshl(int fn, int dearg);

fn is the BDOS function number and dearg is the value passed in DE; the byte result comes back in the low byte of the returned int. Calls whose useful result is an FCB/DMA region (directory and file operations) return their data through the memory dearg points at, not in the return value. Use bdoshl when the BDOS function returns a full word in HL, such as function 12 (version), instead of the byte result from A.

The BIOS jump table is also available when an application genuinely needs machine-specific services:

int bios(int fn, int arg);                    /* byte result from A */
int bioshl(int fn, int arg);                  /* full result from HL */
int biosreg(int fn, int bcarg, int dearg);    /* independent BC/DE, result HL */

bios() and bioshl() are the source-compatible convenience forms: arg is copied into BC and DE, and its low byte is therefore also in C. That covers the ordinary no-argument, character-in-C, and one-word-in-BC CP/M 2.2 calls. Use biosreg() only when the BIOS contract needs two different register values, notably SECTRAN (logical sector in BC, translation-table address in DE) or CP/M 3 SELDSK (drive in C, login flag in E). biosreg() returns the full HL result used by those calls.

BIOS disk services are hardware- and emulator-specific. In particular, ntvcm provides the standard 17-entry jump table but implements console functions only; its disk BIOS entries diagnose an unhandled call. Prefer BDOS file APIs for portable CP/M programs, and use BIOS disk calls only where the target BIOS is known.

Non-blocking console input

The standard input calls (getchar, getc, fgets, scanf) are blocking C-style input. For games, menus, terminal UIs, and other polling loops, use the runtime's kbhit() and getch() (declared in stdio.h):

  • int kbhit(void) returns nonzero when a key is waiting and 0 otherwise. It never blocks and does not consume the character.
  • int getch(void) reads one key without echo. It blocks until a key is ready, so it is normally guarded by kbhit().
#include <stdio.h>

int main(void)
{
    int ch;

    if (kbhit()) {          /* non-blocking test */
        ch = getch();       /* safe: a key is already waiting */
        if (ch)
            handle_key(ch);
    }
    return 0;
}

Under the hood kbhit() is CP/M BDOS function 11 (console status) and getch() is BDOS function 6 (direct console input, E = 0xff). Raw calls can be made through bdos(), but the named functions are clearer and also flush pending buffered output before blocking:

#include <stdlib.h>

int raw_kbhit(void)        { return bdos(11, 0) != 0; }
int raw_getch_nonblock(void) { return bdos(6, 0xff); }  /* 0 = no key ready */

BDOS function 6 uses 0 as the "no character" sentinel, so it is best for keyboard-style console input rather than protocols where NUL is meaningful. Do not mix raw bdos() console I/O with buffered console functions in the same code path; direct BDOS calls bypass the console output buffer used by printf/puts.

Direct port I/O

For talking to hardware or an emulator's virtual devices, the runtime also provides 8-bit port I/O, declared alongside bdos in stdlib.h:

int  inp(unsigned port);                 /* IN  A,(port) -> 0..255 */
void outp(unsigned port, unsigned val);  /* OUT (port),A           */

inp runs the Z80 IN A,(port) instruction and returns the byte read, zero-extended to int (so the result is always 0..255). outp runs OUT (port),A, sending the low byte of val to the port. Only the low 8 bits of port are significant. Neither is part of C89.