User-Space Buffering and Custom Stream Wrappers in Linux I/O

Standard C library functions operating on files utilize an intermediate memory region known as the user-space buffer before invoking kernel system calls. This mechanism reduces the frequency of context switches between user and kernel modes, significantly improving I/O throughput. The FILE structure defined in <stdio.h> encapsulates not only the underlying file descriptor but also metadata regarding buffer state, current position, and error indicators.

When a process invokes high-level operations such as fprintf() or fwrite(), data initially resides in a user-allocated buffer managed by the C library. In contrast, low-level system calls like write() transfer data direct from the provided user buffer into kernel space without intermediate buffering at the library level.

Consider the following demonstration of buffering behavior across process creation:

#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(void) {
    const char *buf1 = "Output via printf\n";
    const char *buf2 = "Output via fwrite\n";
    const char *buf3 = "Output via write\n";

    fprintf(stdout, "%s", buf1);
    fwrite(buf2, 1, strlen(buf2), stdout);
    write(STDOUT_FILENO, buf3, strlen(buf3));

    fork();
    return 0;
}

When executed interactively in a terminal, this program produces three lines of output. However, when redirected to a regular file (./program > output.txt), the file contains five lines: the write() output appears once, while the printf() and fwrite() outputs appear twice.

This duplication occurs due to the interaction between buffering strategies and copy-on-write semantics. Terminal devices employ line buffering, flushing the buffer when encountering newline characters. Regular files utilize full buffering, accumulating data until the buffer fills or an explicit flush occurs. Upon redirection, the stream transitions from line-buffered to fully-buffered mode, causing the library functions' output to remain in the user-space buffer when fork() executes.

During fork(), the parent and child processes share physical memory pages marked read-only. When either process attempts to modify the buffered data (such as flushing the stream during process termination), the operating system creates a private copy of the page for that process. Consequently, both parent and child possess identical buffered content, wich each process flushes independently upon exit. The write() system call, lacking a user-space buffer in this context, executed immediately before the fork, leaving no pending data to duplicate.

Kernel-level buffering operates independently within the operating system, caching disk blocks to optimize hardware access. User-level buffering, managed by the C standard library, exists within the process address space inside the FILE structure.

Implementing a Minimal Buffered I/O Library

The following implementation illustrates encapsulating system calls (open, read, write, close) within a custom stream type, implementing manual buffer management:

stream.h

#ifndef STREAM_H
#define STREAM_H

#include <sys/types.h>

#define BUF_SIZE 4096

typedef struct {
    int descriptor;
    char cache[BUF_SIZE];
    ssize_t cached_count;
    ssize_t current_pos;
    int flags;
    int eof_indicator;
    int error_indicator;
} Stream;

Stream* stream_open(const char *path, const char *mode);
int stream_close(Stream *sp);
size_t stream_read(void *ptr, size_t size, Stream *sp);
size_t stream_write(const void *ptr, size_t size, Stream *sp);
int stream_flush(Stream *sp);

#endif

stream.c

#include "stream.h"
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

Stream* stream_open(const char *path, const char *mode) {
    int fd;
    int open_flags = 0;
    
    if (strcmp(mode, "r") == 0) open_flags = O_RDONLY;
    else if (strcmp(mode, "w") == 0) open_flags = O_WRONLY | O_CREAT | O_TRUNC;
    else if (strcmp(mode, "a") == 0) open_flags = O_WRONLY | O_CREAT | O_APPEND;
    else return NULL;

    fd = open(path, open_flags, 0644);
    if (fd < 0) return NULL;

    Stream *sp = malloc(sizeof(Stream));
    if (!sp) {
        close(fd);
        return NULL;
    }

    sp->descriptor = fd;
    sp->cached_count = 0;
    sp->current_pos = 0;
    sp->flags = open_flags;
    sp->eof_indicator = 0;
    sp->error_indicator = 0;
    
    return sp;
}

int stream_flush(Stream *sp) {
    if (sp == NULL || sp->cached_count == 0) return 0;
    
    ssize_t written = 0;
    while (written < sp->cached_count) {
        ssize_t result = write(sp->descriptor, 
                              sp->cache + written, 
                              sp->cached_count - written);
        if (result < 0) {
            sp->error_indicator = 1;
            return -1;
        }
        written += result;
    }
    sp->cached_count = 0;
    sp->current_pos = 0;
    return 0;
}

size_t stream_write(const void *ptr, size_t size, Stream *sp) {
    if (!sp || !ptr) return 0;
    
    const char *data = ptr;
    size_t remaining = size;
    
    while (remaining > 0) {
        size_t space = BUF_SIZE - sp->cached_count;
        size_t to_copy = (remaining < space) ? remaining : space;
        
        memcpy(sp->cache + sp->cached_count, data, to_copy);
        sp->cached_count += to_copy;
        data += to_copy;
        remaining -= to_copy;
        
        if (sp->cached_count == BUF_SIZE) {
            if (stream_flush(sp) < 0) break;
        }
    }
    
    return size - remaining;
}

size_t stream_read(void *ptr, size_t size, Stream *sp) {
    if (!sp || !ptr || !(sp->flags & O_RDONLY)) return 0;
    
    size_t total_read = 0;
    char *dest = ptr;
    
    while (total_read < size) {
        if (sp->current_pos >= sp->cached_count) {
            ssize_t bytes = read(sp->descriptor, sp->cache, BUF_SIZE);
            if (bytes == 0) {
                sp->eof_indicator = 1;
                break;
            }
            if (bytes < 0) {
                sp->error_indicator = 1;
                break;
            }
            sp->cached_count = bytes;
            sp->current_pos = 0;
        }
        
        size_t available = sp->cached_count - sp->current_pos;
        size_t to_copy = (size - total_read < available) ? 
                        (size - total_read) : available;
        
        memcpy(dest + total_read, sp->cache + sp->current_pos, to_copy);
        sp->current_pos += to_copy;
        total_read += to_copy;
    }
    
    return total_read;
}

int stream_close(Stream *sp) {
    if (!sp) return -1;
    
    int result = 0;
    if (sp->flags & (O_WRONLY | O_RDWR)) {
        result = stream_flush(sp);
    }
    
    if (close(sp->descriptor) < 0) {
        result = -1;
    }
    free(sp);
    return result;
}

Tags: Linux System Programming File I/O buffering C

Posted on Mon, 31 Aug 2026 16:41:13 +0000 by keziah