thecodingidiot.com

The VoiceThe Output Primitive

The Output Primitive

tci_printf could call putchar or puts — they are separate functions and there is no circularity. But calling them would delegate to the very layer this chapter is trying to understand. putchar and puts both eventually call write, the POSIX system call that sends bytes to a file descriptor. The point is to call it directly and see what every C library output function is built on.

File descriptors in C

In f01/06 you saw file descriptors as shell numbers: 2>/dev/null, 2>&1, fd 0 for stdin and fd 1 for stdout. In C, they are the same numbers — plain int values passed directly to system calls.

The C standard library wraps them in a higher layer. printf, putchar, and puts all write to FILE *stdout — a struct that holds a file descriptor, a buffer, and buffering state. It is not a direct line to the kernel; it is a managed layer on top of one.

When you call printf("hello"), the characters go into that buffer. The buffer flushes to the kernel via write(1, buf, n) when it fills, when you print a newline, or when the program exits. write is the system call at the bottom — the kernel is what actually moves bytes to the terminal.

tci_printf skips the FILE * layer entirely and calls write(1, ...) directly. No buffer, no struct, no stdio state — one call to the kernel per character or string.

A call to write(1, &c, 1) is an integer, a pointer, and a byte count. Coming from the 6502 breadboard in f03c, that is not a large conceptual leap — writing a value to a memory-mapped register and calling a syscall with a file descriptor are the same kind of operation. The abstraction is thin by design.

The rendering chapters will bring that feeling back more directly. Packing a pixel colour into a 32-bit integer with shifts and bitwise OR — (r << 16) | (g << 8) | b — is the same logic you traced through the gates on the breadboard, now written in C.

write()

write is a POSIX system call defined in <unistd.h>:

ssize_t  write(int fd,           /* file descriptor — 1 is stdout */
               const void *buf,  /* pointer to the bytes to send */
               size_t count);    /* number of bytes to send */

fd is a file descriptor. File descriptor 1 is standard output — the same destination printf writes to by default. buf is a pointer to the data to write. count is the number of bytes to send.

write returns the number of bytes actually written, or −1 on error. For tci_printf, writing to standard output in a normal process, errors are unusual and we do not handle them — the return value of write is not checked. The priority is correctness of the format string logic.

Every C library output function eventually calls write. printf formats its output into a buffer and flushes it through write. puts calls the same path. tci_printf calls write directly — there is nothing between the function and the kernel.

Two static helpers

Add two static functions to tci_printf.c. They are not part of the public libtci API — not declared in libtci.h, not linked by other translation units — so they are static:

#include "libtci.h"
#include <stdarg.h>
#include <unistd.h>
 
static int  tci_putchar_fd(char c, int fd)
{
    write(fd, &c, 1);  /* &c: write() needs a pointer; c is on the stack */
    return (1);        /* always 1 byte — lets the caller accumulate totals */
}
 
static int  tci_putstr_fd(const char *s, int fd)
{
    int  len;
 
    if (!s)
        s = "(null)";          /* NULL pointer: print the string "(null)" */
    len = (int)tci_strlen(s);
    write(fd, s, len);         /* write the whole string in one call */
    return (len);
}

Both helpers return the number of bytes written so the caller can accumulate the total.

tci_putchar_fd takes the address of c with &c because write expects a pointer. Passing c directly to a void * parameter is not valid in C99.

tci_putstr_fd handles a NULL pointer by printing (null) instead of crashing — this is the libc printf behaviour for %s with a NULL argument.

The main function skeleton

Replace the stub in tci_printf.c with the format loop:

static int  dispatch(char spec, va_list *args);  /* forward declaration */
 
int     tci_printf(const char *fmt, ...)
{
    va_list  args;
    int      count;
    int      i;
 
    if (!fmt)           /* NULL format string: nothing to parse */
        return (-1);
    va_start(args, fmt);
    count = 0;
    i = 0;
    while (fmt[i]) {
        if (fmt[i] == '%' && fmt[i + 1]) { /* fmt[i+1]: guard against trailing % */
            i++;
            count += dispatch(fmt[i], &args); /* i now points at the specifier */
        } else
            count += tci_putchar_fd(fmt[i], 1);
        i++;
    }
    va_end(args);       /* must be called before returning */
    return (count);
}

The forward declaration of dispatch above tci_printf is necessary because the compiler reads top to bottom — it must know dispatch's signature before it reaches the call site inside tci_printf. Without it the compiler sees an unknown identifier and stops:

/* without forward declaration: error at the call site */
int     tci_printf(const char *fmt, ...)
{
    count += dispatch(fmt[i], &args);  /* error: implicit declaration of dispatch */
}
 
static int  dispatch(char spec, va_list *args) { /* ... */ }
/* with forward declaration: signature is known before tci_printf is compiled */
static int  dispatch(char spec, va_list *args);  /* declaration: signature only */
 
int     tci_printf(const char *fmt, ...)
{
    count += dispatch(fmt[i], &args);  /* OK: compiler knows the signature */
}
 
static int  dispatch(char spec, va_list *args) { /* ... */ }  /* definition */

The alternative is to define dispatch above tci_printf entirely, which eliminates the need for a declaration. Either layout works.

Add a stub dispatch so the file compiles:

static int  dispatch(char spec, va_list *args)
{
    (void)args;
    return (tci_putchar_fd(spec, 1));    /* unknown: print the character */
}

Run make re. No warnings. Test the literal output path:

tci_printf("hello, world\n");
tci_printf("no specifiers here\n");

These should work correctly — the format loop writes every non-% character directly. The specifier cases are built on the next two pages.