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.