AmigaDOS Buffered I/O

Besides the unbuffered Read()/Write()/Seek() calls, which hand every request to the handler and cost a task switch each time, dos.library offers a family of buffered I/O functions: FRead(), FWrite(), FGetC(), FPutC(), FGets(), FPuts(), UnGetC(), Flush(), SetVBuf() and the formatted printers VFPrintf() and VFWritef(). They collect data in a buffer attached to the file handle and talk to the handler only in larger chunks. They resemble ANSI-C stdio, but their error reporting is quite different and several of them had version-specific defects. This article collects the rules and the pitfalls from §5.6–5.8 of the 2024 ROM Kernel Reference Manual.

The buffered functions always existed inside AmigaDOS, but before V36 only BCPL programs could reach them through the Global Vector. Since the V36 rewrite they are part of the dos.library interface. One difference from ANSI-C stands out: the file handle is the first argument, not the last (FPuts(fh, str) versus fputs(str, fp)).

The file handle buffer

  • Every file handle can do buffered I/O. A buffer is attached as soon as a buffered function is first used. Its default size is 204 bytes.
  • In V45 and earlier the buffered functions were built on single-byte primitives and had a large overhead (the NDK autodoc notes that FRead()/FWrite() were a series of FGetC()/FPutC() calls). V47 redesigned them and they are now much faster.
  • The default buffer is small. The manual recommends enlarging it with SetVBuf() to 4096 bytes, the block size of modern hard drives.
  • The buffer lives in the FileHandle fields fh_Buf, fh_Pos and fh_End; see DOS String Streams for the structure and for building a handle over a memory buffer.

Note: the NDK 3.2 autodoc for SetVBuf() states that the default and minimum buffer size is 208 bytes.

Function summary

Prototypes as in NDK 3.2 clib/dos_protos.h; all are V36 except SetVBuf(), which only works from V39.

Function Prototype Result on error IoErr()
FRead 2.0+ LONG FRead(BPTR fh, APTR block, ULONG blocklen, ULONG number) fewer complete records, never -1 only set when the handler is called
FWrite 2.0+ LONG FWrite(BPTR fh, CONST_APTR block, ULONG blocklen, ULONG number) fewer complete records, never -1 only set when the handler is called
FGetC 2.0+ LONG FGetC(BPTR fh) ENDSTREAMCH (-1), same as EOF set on refill
FGets 2.0+ STRPTR FGets(BPTR fh, STRPTR buf, ULONG buflen) NULL, same as EOF set on refill
UnGetC 2.0+ LONG UnGetC(BPTR fh, LONG character) 0 not set
FPutC 2.0+ LONG FPutC(BPTR fh, LONG ch) ENDSTREAMCH (-1) only when the buffer is written out
FPuts 2.0+ LONG FPuts(BPTR fh, CONST_STRPTR str) ENDSTREAMCH (-1); 0 = success only when the buffer is written out
PutStr 2.0+ LONG PutStr(CONST_STRPTR str) -1; 0 = success only when the buffer is written out
WriteChars 2.0+ LONG WriteChars(CONST_STRPTR buf, ULONG buflen) fewer characters, never -1 only when the buffer is written out
Flush 2.0+ LONG Flush(BPTR fh) always DOSTRUE set if the handler fails
SetVBuf 2.0+ LONG SetVBuf(BPTR fh, STRPTR buff, LONG type, LONG size) non-zero; 0 = success only on out-of-memory
VFPrintf 2.0+ LONG VFPrintf(BPTR fh, CONST_STRPTR format, CONST_APTR argarray) -1 error code

ENDSTREAMCH (-1) and the BUF_* modes are in dos/stdio.h.

Error detection: the SetIoErr(0) pattern

The buffered functions only touch IoErr() when they actually call the handler, i.e. when the buffer has to be refilled or written out. A request satisfied entirely from the buffer leaves IoErr() as it was, and several functions have no error return value at all. The reliable pattern is therefore:

  1. SetIoErr(0) before the call (or before a loop of calls);
  2. make the calls;
  3. if the result is short, NULL or ENDSTREAMCH, look at IoErr(): 0 means end of file, anything else is an error.
#include <dos/dos.h>
#include <dos/stdio.h>
#include <proto/dos.h>

/* Copy everything from one open file to another through the buffers.
   Returns DOSTRUE on success; IoErr() holds the reason on failure. */
LONG CopyBuffered(BPTR from, BPTR to)
{
    UBYTE buf[512];          /* plain C buffer: no alignment needed */
    LONG  n;

    SetIoErr(0);
    while ((n = FRead(from, buf, 1, sizeof(buf))) > 0) {
        if (FWrite(to, buf, 1, n) != n)
            return DOSFALSE;             /* IoErr() set by the failed write-out */
    }
    if (IoErr() != 0)                    /* n == 0: EOF or error? */
        return DOSFALSE;

    Flush(to);                           /* always returns DOSTRUE ... */
    return (IoErr() == 0) ? DOSTRUE : DOSFALSE;   /* ... so check IoErr() */
}

Record I/O: FRead and FWrite

FRead() reads number records of blocklen bytes each; FWrite() writes them. The record size has no special meaning: they transfer blocklen × number bytes but return only the number of complete records. An incomplete last record is not counted. To read or write a plain block of bytes, set blocklen to 1.

  • Neither returns -1 on error, and neither clears IoErr() first: use the pattern above.
  • V47 "burst" mode. FRead() first takes what it can from the buffer; if more bytes remain than the buffer holds, the handler is called to read directly into the caller's buffer. FWrite() first tops up a partially filled buffer, then writes a large remainder in one handler call, bypassing the buffer; small remainders go into the buffer.
  • Before V47, FRead() was slow; see FGet() for a faster inlinable reader.

Writing characters and strings

  • FPutC(fh, ch) returns the character or ENDSTREAMCH. Pass a LONG in the range 0–255.
  • FPuts(fh, str) writes a NUL-terminated string without the NUL. It returns 0 on success, ENDSTREAMCH on error.
  • PutStr(str) is FPuts(Output(), str); no newline is appended. Returns 0 or -1.
  • WriteChars(buf, len) is FWrite(Output(), buf, 1, len) and returns the number of characters written. It does not return -1 on error, and sets IoErr() only when the buffer is written out; call SetIoErr(0) first.

Note: the NDK 3.2 autodoc for WriteChars() states that a result of -1 (EOF) indicates an error.

SetVBuf: buffer size and mode

LONG SetVBuf(BPTR fh, STRPTR buff, LONG type, LONG size);   /* V39 */
  • size is the buffer size in bytes; smaller values are rounded up to 204.
  • buff == NULL: dos.library allocates the buffer and frees it on Close() or when the buffer is changed again.
  • buff != NULL: your own buffer, longword-aligned. It is never freed by dos.library.
  • type is one of:
Mode Value Writing Reading
BUF_LINE 2.0+ 0 Flush on line feed, carriage return or NUL, only if the file is interactive; otherwise like BUF_FULL Fills the whole buffer
BUF_FULL 2.0+ 1 Flush only when the buffer overflows or on Flush() Fills the whole buffer
BUF_NONE 2.0+ 2 Every character goes straight to the file No buffering

Pitfalls:

  • The result is inverted: 0 means success, non-zero means failure (out of memory, bad mode, bad handle). IoErr() is set only for out-of-memory.
  • Before V39 it did nothing and always returned 0, although it was documented since V36.
  • It crashes on NIL: handles in all versions. Check fh_Type first.
  • It cannot change the buffer of Input() of a Shell command; see below.

Note: the NDK 3.2 autodoc for SetVBuf() states that a size of -1 changes only the buffering mode.

/* Give a freshly opened file a 4 KB buffer. */
static void BigBuffer(BPTR file)
{
    struct FileHandle *fh = BADDR(file);

    if (fh->fh_Type != NULL)                         /* never on NIL: */
        if (SetVBuf(file, NULL, BUF_FULL, 4096) != 0) {
            /* failed: keep the default buffer */
        }
}

Flush

LONG Flush(BPTR fh);   /* V36 */

Flush() synchronizes the file with the buffer. Pending written data is pushed out. If data was read and unread bytes remain in the buffer, they are dropped and dos.library seeks back to the position of the last byte actually consumed.

  • The result is always DOSTRUE; it says nothing about errors. Use SetIoErr(0), Flush(), then IoErr().
  • Call Flush() when switching from writing to reading the same handle, or back. The buffer logic cannot handle this case by itself.
  • Call Flush() when switching from buffered to unbuffered I/O (Read()/Write()) on the same handle. Seek() flushes implicitly and is safe to mix.

Note: the NDK 3.2 autodoc for Flush() states that before V37 it returned a random value, and that V36 and V37 did not properly flush handles on which no buffered I/O had been done (redirected command input, NP_Arguments, SelectInput() before RunCommand()), fixed in V39; the workaround is FGetC(), UnGetC(), then Flush().

Reading characters and lines

FGetC

Returns the next byte (0–255) or ENDSTREAMCH for both EOF and error; distinguish them with SetIoErr(0) and IoErr().

V36 bug: once FGetC() had returned EOF, it kept returning ENDSTREAMCH even if new data arrived on an interactive stream. Fixed in V37.

FGets

STRPTR FGets(BPTR fh, STRPTR buf, ULONG buflen);   /* V36 */

Reads until a line feed (which is kept in the buffer), EOF, an error, or buflen - 1 characters; the result is always NUL-terminated. Returns buf, or NULL on EOF or error (again: SetIoErr(0) first, then IoErr()).

  • Before V39 it overflowed the buffer by one byte when a line did not fit: the NUL went one byte past buflen. Pass buffer size - 1 as buflen when you must run on V36–V38.
  • buflen is unsigned: a negative value becomes a huge length and lets FGets() overflow the buffer.

Note: the NDK 3.2 autodoc for FGets() states that from V39 a length of 0 was treated as 4294967295 characters, and that dos 47.27 makes buflen == 0 return NULL with IoErr() 0 without touching the buffer.

UBYTE line[256];

SetIoErr(0);
while (FGets(fh, line, sizeof(line) - 1)) {   /* -1: pre-V39 workaround */
    /* process one line (or one chunk of an overlong line) */
}
if (IoErr() != 0) {
    /* read error, not EOF */
}

UnGetC

UnGetC(fh, ch) pushes ch back so the next read returns it. With ch == -1 the last character read is pushed back; if the last read hit EOF or an error, UnGetC(fh, -1) pushes back the EOF condition. Only one character is guaranteed to be pushed back after each read. Returns non-zero on success, 0 on failure.

V36 bug: pushing back an EOF did not work. Fixed in V37.

UnGetC(fh, -1) right after Open() is also the cheapest way to make sure a handle has a read buffer; the helpers below depend on it.

The standard input of Shell commands

Input() of a Shell command is a special case:

  • Its buffer holds the command line arguments, where ReadArgs() picks them up. A buffered read from Input() therefore returns the arguments first, not the stream data.
  • That buffer was installed by hand (the string stream technique), so a plain Flush() is not necessarily enough when the original stream was unbuffered. Convert the stream to buffered mode first, then flush:
FGetC(Input());   /* convert to buffered mode        */
Flush(Input());   /* release the command line buffer */
  • SetVBuf() cannot change the buffer size of Input() in any version, because RunCommand() deposits the arguments in a buffer it cannot replace.
  • Do not use Input() for output. When the Shell has redirected standard input, output written to it is lost (the manual considers this a defect that went unnoticed).

Convenience macros

dos/stdio.h defines:

#define ReadChar()            FGetC(Input())
#define WriteChar(c)          FPutC(Output(),(c))
#define UnReadChar(c)         UnGetC(Input(),(c))
#define ReadChars(buf,num)    FRead(Input(),(buf),1,(num))
#define ReadLn(buf,len)       FGets(Input(),(buf),(len))
#define WriteStr(s)           FPuts(Output(),(s))
#define VWritef(format,argv)  VFWritef(Output(),(format),(argv))

Each call costs an extra Input()/Output() call. In a loop, fetch the handle once and call the functions directly.

FSkip and FGet: buffer-level helpers

Seek() can be mixed with buffered I/O, but it always flushes and loses the buffer. The manual (§5.7.4–5.7.5) gives two helpers that work on the buffer fields directly.

FSkip() skips forwards inside the buffer when possible and falls back to Seek() otherwise:

LONG FSkip(BPTR file, LONG skip)
{
    struct FileHandle *fh = BADDR(file);

    if (fh->fh_Pos >= 0 && fh->fh_End > 0) {          /* buffer present? */
        LONG newpos = fh->fh_Pos + skip;
        if (newpos >= 0 && newpos < fh->fh_End) {
            fh->fh_Pos = newpos;                      /* stay in the buffer */
            return DOSTRUE;
        }
    }
    skip += fh->fh_Pos - fh->fh_End;                  /* account for unread bytes */
    fh->fh_Pos = -1;                                  /* mark buffer absent so */
    fh->fh_End = -1;                                  /* Seek() won't Flush()  */
    if (Seek(file, skip, OFFSET_CURRENT) != -1)
        return DOSTRUE;
    return DOSFALSE;
}

FGet() copies directly from a partially filled buffer and calls FRead() (which also refills the buffer) only when needed. It is mainly useful before V47, where FRead() is slow, and can be inlined:

#include <string.h>

LONG FGet(BPTR f, void *buf, LONG size)
{
    struct FileHandle *cis = BADDR(f);

    if (cis->fh_Pos > 0) {
        LONG end = cis->fh_Pos + size;
        if (end < cis->fh_End) {
            memcpy(buf, (UBYTE *)BADDR(cis->fh_Buf) + cis->fh_Pos, (size_t)size);
            cis->fh_Pos = end;
            return size;
        }
    }
    return FRead(f, buf, 1, size);
}

Both require the buffer to exist. Initialize it immediately after opening; any buffered read works, UnGetC() is the cheapest:

BPTR file = Open(filename, MODE_OLDFILE);
if (file)
    UnGetC(file, -1);   /* set up the read buffer */

As with all buffered functions, IoErr() is only updated when the handler has to supply data.

Formatted output

VFPrintf, FPrintf, VPrintf, Printf

LONG VFPrintf(BPTR fh, CONST_STRPTR format, CONST_APTR argarray);   /* V36 */
LONG FPrintf(BPTR fh, CONST_STRPTR format, ...);
LONG VPrintf(CONST_STRPTR format, CONST_APTR argarray);           /* to Output() */
LONG Printf(CONST_STRPTR format, ...);

They format like exec's RawDoFmt() and write through the file handle buffer. They return the number of characters written, or -1 with an error code in IoErr().

  • 16-bit integer model. %d, %u, %x and %c take 16-bit arguments regardless of the compiler. With a 32-bit int compiler use %ld, %lu, %lx and %lc, or the output is garbage. Drop the l only with a 16-bit int compiler.
  • %s takes a C string (limit it with a precision, e.g. %.30s); %b (V36) takes a BPTR to a BSTR; %lu exists from V37.
  • RawDoFmt() is patched by locale.library, so the FormatString() extensions of locale become available to these functions too.
LONG args[2];
args[0] = (LONG)"RAM:";
args[1] = 42;
VFPrintf(Output(), "%s holds %ld files\n", args);

Printf("%s holds %ld files\n", "RAM:", 42L);   /* varargs form */

VFWritef and FWritef: BCPL style

VOID VFWritef(BPTR fh, CONST_STRPTR format, CONST LONG *argarray);   /* V36 */
VOID FWritef(BPTR fh, CONST_STRPTR format, ...);

These exist for legacy BCPL programs (they are also a Global Vector entry, where they take BSTRs). They are not patched by locale.library; new code should use VFPrintf(). Width characters x are a single character: 0–9 are widths 0–9, A–Z are widths 10 onwards.

Directive Output
%S NUL-terminated string
%Tx string left-justified in a field of width x; not truncated
%C single character (32-bit code point)
%Ox octal, at most x digits, zero-padded, truncated if too long
%Xx hexadecimal, at most x digits, zero-padded, truncated if too long
%Ix signed decimal, space-padded to x, not truncated
%N signed decimal, no width
%Ux unsigned decimal, space-padded to x, not truncated
%$ skip the next argument
%% a literal %

Note: the NDK 3.2 clib/dos_protos.h declares VFWritef() and FWritef() as returning VOID; the manual documents a LONG count.

Version bugs at a glance

Function Versions Defect Workaround
FGetC() 2.0+ V36 Returns EOF forever after the first EOF Require V37
FGets() 2.0+ < V39 Writes the NUL one byte past the buffer Pass size - 1
UnGetC() 2.0+ V36 Cannot push back EOF Require V37
SetVBuf() 2.0+ < V39 Not implemented, always returns 0 Live with 204-byte buffers
SetVBuf(), ExamineFH() all Crash on NIL: handles Check fh_Type != NULL
SetVBuf() 2.0+ all Cannot resize Input() of a Shell command None
Flush() 2.0+ all Always returns DOSTRUE SetIoErr(0) + IoErr()
FRead()/FWrite() ≤ V45 Byte-by-byte, slow FGet() helper, large buffer

See Also


Sources: Thomas Richter, Amiga ROM Kernel Reference Manual: AmigaDOS (2024, v1.27), §2.6.2, §5.4.3, §5.6–5.8 (pp. 10–11, 35, 40–53); NDK 3.2 R4 dos.doc (FRead, FWrite, FGets, Flush, SetVBuf, WriteChars, VFPrintf), dos/stdio.h, dos/dosextens.h, clib/dos_protos.h Updated: 2026-11-13