P04: Cross-Platform Syscall Abstraction Library

P04: Cross-Platform Syscall Abstraction Library

Build a library that wraps platform-specific syscalls into a unified API like libuv or Rustโ€™s std

Attribute Value
Main Language C
Difficulty Level 3: Advanced
Coolness Level Level 2: Practical but Foundational
Knowledge Area Systems Programming, Portability
Key Tools POSIX APIs, Win32 API, CMake
Main Book โ€œAdvanced Programming in the UNIX Environmentโ€ - Stevens & Rago

Learning Objectives

By completing this project, you will:

  1. Master platform abstractions โ€” Understand the fundamental differences between POSIX and Windows
  2. Handle ABI details โ€” Learn about struct layouts, calling conventions, and binary compatibility
  3. Design stable APIs โ€” Create interfaces that hide platform complexity without sacrificing performance
  4. Avoid undefined behavior โ€” Navigate the minefield of type punning, alignment, and strict aliasing
  5. Build portable software โ€” Write code that compiles and runs identically across operating systems
  6. Use CI/CD effectively โ€” Test on multiple platforms automatically

The Core Question

โ€œWhy canโ€™t I just write code that works everywhere? What makes POSIX and Windows fundamentally different?โ€

Most developers treat cross-platform compatibility as annoying #ifdef boilerplate. But understanding why platforms differ reveals deep truths about operating system design:

  • What is a file descriptor vs a HANDLE? Both represent โ€œopen files,โ€ but theyโ€™re completely different concepts
  • Why doesnโ€™t Windows have fork()? Itโ€™s not a missing featureโ€”itโ€™s architecturally incompatible
  • What makes a good abstraction? How do you hide differences without killing performance?

After this project, youโ€™ll understand that portable code isnโ€™t about avoiding platform-specific APIsโ€”itโ€™s about building the right abstraction layer.


Deep Theoretical Foundation

1. POSIX File Descriptors vs Windows HANDLEs

File Descriptors (POSIX)

A file descriptor is just an integerโ€”an index into a per-process table:

Process File Descriptor Table:
โ”Œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ FD  โ”‚ Points to (in kernel)              โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  0  โ”‚ stdin (terminal)                   โ”‚
โ”‚  1  โ”‚ stdout (terminal)                  โ”‚
โ”‚  2  โ”‚ stderr (terminal)                  โ”‚
โ”‚  3  โ”‚ /home/user/data.txt                โ”‚
โ”‚  4  โ”‚ socket to 192.168.1.1:80           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
int fd = open("/path/to/file", O_RDONLY);  // Returns small integer (e.g., 3)
read(fd, buffer, size);                      // fd is just an int
close(fd);

HANDLEs (Windows)

A HANDLE is an opaque pointer to a kernel object:

HANDLE h = CreateFileA("C:\\path\\file.txt",
                       GENERIC_READ,
                       0, NULL, OPEN_EXISTING, 0, NULL);
// h is a pointer-sized value (often looks like 0x0000000000000108)

ReadFile(h, buffer, size, &bytes_read, NULL);
CloseHandle(h);

Key Differences

Aspect File Descriptor HANDLE
Type int (small integer) void* (pointer-sized)
Validity fd >= 0 means valid Compare to INVALID_HANDLE_VALUE
Inheritance Automatic to child processes Explicit via SetHandleInformation
I/O model read()/write() ReadFile()/WriteFile() with overlapped

2. Process Creation Models

POSIX: fork() + exec()

pid_t pid = fork();
if (pid == 0) {
    // Child process - exact copy of parent
    exec("/bin/ls", "ls", "-la", NULL);  // Replace with new program
} else {
    // Parent process
    waitpid(pid, &status, 0);
}

fork() creates an exact duplicate of the parent process:

  • Same memory contents (copy-on-write)
  • Same open files
  • Same state

Windows: CreateProcess()

STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;

CreateProcessA(
    "C:\\Windows\\System32\\cmd.exe",  // Program
    "/c dir",                           // Arguments
    NULL, NULL,                         // Security attributes
    FALSE,                              // Inherit handles
    0,                                  // Flags
    NULL,                               // Environment
    NULL,                               // Working directory
    &si, &pi
);

WaitForSingleObject(pi.hProcess, INFINITE);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);

Why Windows Has No fork()

  1. DLL base addresses: Windows DLLs can be loaded at different addresses in different processes. fork() would need to handle this.

  2. Thread state: Windows processes often have multiple threads. Duplicating mid-execution is undefined.

  3. Kernel object handles: Handles have process-specific meaning. Mass duplication is complex.

  4. Historical design: Windows was designed differently from Unix from the start.

3. Error Handling Conventions

POSIX

int fd = open("/path/to/file", O_RDONLY);
if (fd == -1) {
    // Error! Check errno (thread-local)
    printf("Error: %s\n", strerror(errno));
    // errno might be ENOENT, EACCES, EMFILE, etc.
}

Windows

HANDLE h = CreateFileA(...);
if (h == INVALID_HANDLE_VALUE) {
    // Error! Call GetLastError() (thread-local)
    DWORD err = GetLastError();
    printf("Error code: %lu\n", err);
    // Error might be ERROR_FILE_NOT_FOUND, ERROR_ACCESS_DENIED, etc.
}

Error Code Mapping

POSIX errno Windows Error Meaning
ENOENT ERROR_FILE_NOT_FOUND File not found
EACCES ERROR_ACCESS_DENIED Permission denied
EEXIST ERROR_FILE_EXISTS File exists
EMFILE ERROR_TOO_MANY_OPEN_FILES Too many open files

4. Struct Layout and ABI

The Problem

struct stat {
    dev_t st_dev;
    ino_t st_ino;
    mode_t st_mode;
    // ...
};

This struct has different sizes and layouts on:

  • Linux (glibc)
  • Linux (musl)
  • macOS
  • FreeBSD
  • 32-bit vs 64-bit

Why?

  1. Type sizes differ: ino_t might be 32 or 64 bits
  2. Padding differs: Compilers insert different padding for alignment
  3. Field order might differ: Some add fields, changing offsets

Implications

You cannot:

  • Share binary struct data between platforms
  • Use sizeof(struct stat) in portable code
  • Cast pointers between platform-specific structs

5. Calling Conventions

Different platforms use different rules for passing function arguments:

Convention Used By Args in Registers Stack Cleanup
cdecl Most C code None (x86) Caller
stdcall Win32 API None (x86) Callee
fastcall Some Win32 First 2 in regs Callee
System V AMD64 Linux/macOS x64 rdi, rsi, rdx, rcx, r8, r9 Caller
Microsoft x64 Windows x64 rcx, rdx, r8, r9 Caller

Why It Matters

When calling across boundaries (e.g., from your library into the OS), you must use the correct convention:

// Windows-specific
BOOL __stdcall MyCallback(LPVOID context);

// POSIX
void* my_thread_func(void* arg);

6. Thread-Local Storage

POSIX

pthread_key_t key;
pthread_key_create(&key, destructor);
pthread_setspecific(key, value);
void* value = pthread_getspecific(key);

Or with GCC extension:

__thread int my_tls_var;

Windows

DWORD tls_index = TlsAlloc();
TlsSetValue(tls_index, value);
void* value = TlsGetValue(tls_index);
TlsFree(tls_index);

Or with MSVC:

__declspec(thread) int my_tls_var;

7. Symbol Visibility and Linking

Exporting Symbols

To make functions available from a shared library:

// POSIX (GCC/Clang)
__attribute__((visibility("default")))
void my_public_function(void);

// Windows
__declspec(dllexport)
void my_public_function(void);

// Or when importing:
__declspec(dllimport)
void their_public_function(void);

Common Macro Pattern

#ifdef _WIN32
  #ifdef MYLIB_BUILDING
    #define MYLIB_API __declspec(dllexport)
  #else
    #define MYLIB_API __declspec(dllimport)
  #endif
#else
  #define MYLIB_API __attribute__((visibility("default")))
#endif

MYLIB_API void my_function(void);

Project Specification

What Youโ€™ll Build

A cross-platform abstraction library providing:

// xplat.h - Unified API

// File operations
xplat_file_t* xplat_open(const char* path, int flags);
ssize_t xplat_read(xplat_file_t* file, void* buf, size_t count);
ssize_t xplat_write(xplat_file_t* file, const void* buf, size_t count);
int xplat_close(xplat_file_t* file);

// Directory operations
xplat_dir_t* xplat_opendir(const char* path);
int xplat_readdir(xplat_dir_t* dir, xplat_dirent_t* entry);
int xplat_closedir(xplat_dir_t* dir);

// Process spawning
xplat_process_t* xplat_spawn(const char* cmd, char** args, char** env);
int xplat_wait(xplat_process_t* proc, int* exit_code);
int xplat_kill(xplat_process_t* proc);

// Threading
xplat_thread_t* xplat_thread_create(xplat_thread_fn func, void* arg);
int xplat_thread_join(xplat_thread_t* thread, void** result);

// Error handling
int xplat_errno(void);
const char* xplat_strerror(int err);

Deliverables

  1. Public header: include/xplat.h โ€” Platform-agnostic API
  2. POSIX implementation: src/xplat_posix.c
  3. Windows implementation: src/xplat_win32.c
  4. Build system: CMake supporting all platforms
  5. Test suite: 40+ tests covering all functionality
  6. CI/CD: GitHub Actions building on Linux, macOS, Windows
  7. Documentation: API reference and usage guide

Success Criteria

# Same code works everywhere
$ cat demo.c
#include <xplat.h>
int main() {
    xplat_file_t* f = xplat_open("test.txt", XPLAT_O_RDONLY);
    // ...
}

# Linux
$ gcc demo.c -lxplat -o demo && ./demo
OK

# macOS
$ clang demo.c -lxplat -o demo && ./demo
OK

# Windows
C:\> cl demo.c xplat.lib /Fe:demo.exe && demo.exe
OK

# CI passes on all platforms
$ gh run view
โœ“ ubuntu-latest
โœ“ macos-latest
โœ“ windows-latest

Solution Architecture

Component Overview

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                        Application Code                              โ”‚
โ”‚  xplat_open(), xplat_read(), xplat_spawn(), ...                     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                 โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                       Public API (xplat.h)                           โ”‚
โ”‚  Opaque types, flag definitions, error codes                        โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                 โ”‚
              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
              โ”‚                                     โ”‚
              โ–ผ                                     โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   POSIX Implementation       โ”‚       โ”‚   Windows Implementation     โ”‚
โ”‚   (xplat_posix.c)           โ”‚       โ”‚   (xplat_win32.c)           โ”‚
โ”‚                             โ”‚       โ”‚                             โ”‚
โ”‚   open() โ†’ xplat_open()     โ”‚       โ”‚   CreateFile โ†’ xplat_open() โ”‚
โ”‚   read() โ†’ xplat_read()     โ”‚       โ”‚   ReadFile โ†’ xplat_read()   โ”‚
โ”‚   fork/exec โ†’ xplat_spawn() โ”‚       โ”‚   CreateProcess โ†’ xplat_spawnโ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚                                     โ”‚
               โ–ผ                                     โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚      Linux/macOS/BSD        โ”‚       โ”‚         Windows              โ”‚
โ”‚      Kernel                 โ”‚       โ”‚         Kernel               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Data Structures

Public Opaque Types

// xplat.h
typedef struct xplat_file xplat_file_t;
typedef struct xplat_dir xplat_dir_t;
typedef struct xplat_process xplat_process_t;
typedef struct xplat_thread xplat_thread_t;

Internal Structures (POSIX)

// xplat_posix.c
struct xplat_file {
    int fd;
    int flags;
};

struct xplat_process {
    pid_t pid;
    int status;
    bool waited;
};

Internal Structures (Windows)

// xplat_win32.c
struct xplat_file {
    HANDLE handle;
    DWORD access;
};

struct xplat_process {
    HANDLE process;
    HANDLE thread;
    DWORD exit_code;
};

Flag Translation Layer

// xplat.h - Platform-independent flags
#define XPLAT_O_RDONLY  0x01
#define XPLAT_O_WRONLY  0x02
#define XPLAT_O_RDWR    0x04
#define XPLAT_O_CREAT   0x08
#define XPLAT_O_TRUNC   0x10
#define XPLAT_O_APPEND  0x20

// xplat_posix.c
static int translate_flags_posix(int xplat_flags) {
    int flags = 0;
    if (xplat_flags & XPLAT_O_RDONLY) flags |= O_RDONLY;
    if (xplat_flags & XPLAT_O_WRONLY) flags |= O_WRONLY;
    if (xplat_flags & XPLAT_O_RDWR)   flags |= O_RDWR;
    if (xplat_flags & XPLAT_O_CREAT)  flags |= O_CREAT;
    if (xplat_flags & XPLAT_O_TRUNC)  flags |= O_TRUNC;
    if (xplat_flags & XPLAT_O_APPEND) flags |= O_APPEND;
    return flags;
}

// xplat_win32.c
static DWORD translate_access_win32(int xplat_flags) {
    DWORD access = 0;
    if (xplat_flags & XPLAT_O_RDONLY) access |= GENERIC_READ;
    if (xplat_flags & XPLAT_O_WRONLY) access |= GENERIC_WRITE;
    if (xplat_flags & XPLAT_O_RDWR)   access |= GENERIC_READ | GENERIC_WRITE;
    return access;
}

Error Code Mapping

// Unified error codes
typedef enum {
    XPLAT_OK = 0,
    XPLAT_ERR_NOT_FOUND,
    XPLAT_ERR_PERMISSION,
    XPLAT_ERR_EXISTS,
    XPLAT_ERR_TOO_MANY_FILES,
    XPLAT_ERR_INVALID,
    XPLAT_ERR_IO,
    XPLAT_ERR_UNKNOWN,
} xplat_error_t;

// POSIX translation
static xplat_error_t errno_to_xplat(int err) {
    switch (err) {
        case ENOENT: return XPLAT_ERR_NOT_FOUND;
        case EACCES: return XPLAT_ERR_PERMISSION;
        case EEXIST: return XPLAT_ERR_EXISTS;
        case EMFILE: return XPLAT_ERR_TOO_MANY_FILES;
        case EINVAL: return XPLAT_ERR_INVALID;
        case EIO:    return XPLAT_ERR_IO;
        default:     return XPLAT_ERR_UNKNOWN;
    }
}

// Windows translation
static xplat_error_t win_error_to_xplat(DWORD err) {
    switch (err) {
        case ERROR_FILE_NOT_FOUND: return XPLAT_ERR_NOT_FOUND;
        case ERROR_ACCESS_DENIED:  return XPLAT_ERR_PERMISSION;
        case ERROR_FILE_EXISTS:    return XPLAT_ERR_EXISTS;
        // ...
    }
}

Phased Implementation Guide

Phase 1: Project Setup (Day 1)

Goal: Build system that compiles on all platforms.

Steps:

  1. Create CMakeLists.txt with platform detection
  2. Set up CI with GitHub Actions (matrix: ubuntu, macos, windows)
  3. Create header with platform detection macros
  4. Verify empty library builds everywhere

Test: cmake --build . succeeds on all platforms.

Phase 2: File Operations (Days 2-3)

Goal: xplat_open, xplat_read, xplat_write, xplat_close.

Steps:

  1. Define opaque xplat_file_t
  2. Implement POSIX version with open/read/write/close
  3. Implement Windows version with CreateFile/ReadFile/WriteFile/CloseHandle
  4. Add flag translation
  5. Add error code translation

Test: Can open, read, write, close files on all platforms.

Phase 3: Directory Operations (Days 3-4)

Goal: xplat_opendir, xplat_readdir, xplat_closedir.

Steps:

  1. Define opaque xplat_dir_t and xplat_dirent_t
  2. Implement POSIX with opendir/readdir/closedir
  3. Implement Windows with FindFirstFile/FindNextFile/FindClose
  4. Handle the different iteration patterns

Test: Can list directory contents on all platforms.

Phase 4: Process Spawning (Days 4-5)

Goal: xplat_spawn, xplat_wait, xplat_kill.

Steps:

  1. Define opaque xplat_process_t
  2. Implement POSIX with fork/exec/waitpid
  3. Implement Windows with CreateProcess/WaitForSingleObject/TerminateProcess
  4. Handle argument passing differences
  5. Handle environment variable passing

Test: Can spawn process, wait for exit, get exit code.

Phase 5: Threading (Days 5-6)

Goal: xplat_thread_create, xplat_thread_join, xplat_mutex_*.

Steps:

  1. Define opaque types
  2. Implement POSIX with pthreads
  3. Implement Windows with CreateThread/_beginthreadex
  4. Handle thread return values
  5. Implement mutexes and condition variables

Test: Can create threads, synchronize with mutexes.

Phase 6: Polish and Documentation (Days 7-10)

Goal: Production-ready library.

Steps:

  1. Comprehensive test suite
  2. Error message improvements
  3. API documentation
  4. Usage examples
  5. Performance benchmarks

Testing Strategy

Unit Tests

// test_file.c
void test_open_read_close(void) {
    xplat_file_t* f = xplat_open("testdata/sample.txt", XPLAT_O_RDONLY);
    assert(f != NULL);

    char buf[256];
    ssize_t n = xplat_read(f, buf, sizeof(buf));
    assert(n > 0);

    int err = xplat_close(f);
    assert(err == 0);
}

void test_write_create(void) {
    xplat_file_t* f = xplat_open("test_out.txt",
                                  XPLAT_O_WRONLY | XPLAT_O_CREAT | XPLAT_O_TRUNC);
    assert(f != NULL);

    const char* data = "Hello, World!";
    ssize_t n = xplat_write(f, data, strlen(data));
    assert(n == strlen(data));

    xplat_close(f);
}

void test_error_handling(void) {
    xplat_file_t* f = xplat_open("/nonexistent/path", XPLAT_O_RDONLY);
    assert(f == NULL);
    assert(xplat_errno() == XPLAT_ERR_NOT_FOUND);
}

Cross-Platform Tests

// test_process.c
void test_spawn_and_wait(void) {
    // Platform-specific command that should exist everywhere
    #ifdef _WIN32
    const char* cmd = "cmd.exe";
    char* args[] = {"/c", "echo", "hello", NULL};
    #else
    const char* cmd = "/bin/echo";
    char* args[] = {"echo", "hello", NULL};
    #endif

    xplat_process_t* proc = xplat_spawn(cmd, args, NULL);
    assert(proc != NULL);

    int exit_code;
    int err = xplat_wait(proc, &exit_code);
    assert(err == 0);
    assert(exit_code == 0);
}

CI Matrix Testing

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        compiler: [gcc, clang, msvc]
        exclude:
          - os: windows-latest
            compiler: gcc
          - os: ubuntu-latest
            compiler: msvc
          - os: macos-latest
            compiler: msvc

    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4

      - name: Configure
        run: cmake -B build

      - name: Build
        run: cmake --build build

      - name: Test
        run: ctest --test-dir build --output-on-failure

Common Pitfalls and Debugging

Pitfall 1: Path Separators

Symptom: File operations fail on Windows.

Cause: Using / instead of \.

Fix:

void normalize_path(char* path) {
    #ifdef _WIN32
    for (char* p = path; *p; p++) {
        if (*p == '/') *p = '\\';
    }
    #endif
}

// Or better: accept both and handle internally

Pitfall 2: Text vs Binary Mode

Symptom: Files have extra \r characters on Windows.

Cause: Windows translates \n to \r\n in text mode.

Fix:

// Always use binary mode for raw byte operations
#ifdef _WIN32
_setmode(_fileno(stdin), _O_BINARY);
#endif

// In CreateFile:
// Don't add FILE_FLAG_SEQUENTIAL_SCAN unless needed

Pitfall 3: sizeof(long) Differences

Symptom: Data corruption when serializing.

Cause: long is 4 bytes on Windows (LLP64) but 8 bytes on Linux (LP64).

Fix:

// Use fixed-size types
#include <stdint.h>
int32_t value;   // Always 4 bytes
int64_t large;   // Always 8 bytes

// NOT: long value;  // Platform-dependent!

Pitfall 4: Thread Function Signatures

Symptom: Crashes or undefined behavior with threads.

Cause: Different calling conventions expected.

Fix:

// POSIX thread function
void* posix_thread_func(void* arg);

// Windows thread function
DWORD WINAPI win_thread_func(LPVOID arg);

// Wrapper approach
typedef void (*xplat_thread_fn)(void* arg);

// Internally convert:
#ifdef _WIN32
static DWORD WINAPI thread_wrapper(LPVOID arg) {
    xplat_thread_internal_t* t = arg;
    t->func(t->arg);
    return 0;
}
#endif

Pitfall 5: Missing NULL Terminators

Symptom: Crashes when spawning processes on some platforms.

Cause: Windows doesnโ€™t require NULL-terminated arg arrays like POSIX exec.

Fix:

// Always NULL-terminate
char* args[] = {"program", "arg1", "arg2", NULL};  // Correct

// Windows needs command line as string anyway
// POSIX needs NULL terminator for execv

Extensions and Challenges

Extension 1: Memory-Mapped Files

xplat_mmap_t* xplat_mmap(xplat_file_t* file, size_t length, int prot, int flags);
int xplat_munmap(xplat_mmap_t* mapping);

Extension 2: Sockets

xplat_socket_t* xplat_socket(int domain, int type, int protocol);
int xplat_connect(xplat_socket_t* sock, const char* host, int port);
ssize_t xplat_send(xplat_socket_t* sock, const void* buf, size_t len);
ssize_t xplat_recv(xplat_socket_t* sock, void* buf, size_t len);

Extension 3: Dynamic Libraries

xplat_lib_t* xplat_dlopen(const char* path);
void* xplat_dlsym(xplat_lib_t* lib, const char* symbol);
int xplat_dlclose(xplat_lib_t* lib);

Extension 4: Filesystem Monitoring

xplat_watch_t* xplat_watch(const char* path);
int xplat_watch_poll(xplat_watch_t* w, xplat_event_t* events, int max);
// Uses inotify (Linux), FSEvents (macOS), ReadDirectoryChangesW (Windows)

Real-World Connections

Libraries Using These Patterns

Library Purpose Key Insight
libuv Powers Node.js Event loop + fs + process abstraction
SDL Game development Window, input, audio abstraction
GLFW OpenGL windowing Minimal window abstraction
Rust std Standard library Comprehensive sys abstraction
Go runtime Goโ€™s syscall package Platform-agnostic interface

Study libuvโ€™s Source

git clone https://github.com/libuv/libuv.git
cd libuv

# Platform-specific implementations
ls src/unix/    # Linux, macOS, BSD
ls src/win/     # Windows

# Public headers
cat include/uv.h

# See how they handle file operations
grep -r "uv_fs_open" src/

Resources

Essential Reading

  1. โ€œAdvanced Programming in the UNIX Environmentโ€ by Stevens & Rago โ€” POSIX fundamentals
  2. โ€œWindows System Programmingโ€ by Johnson Hart โ€” Win32 API
  3. โ€œWindows Via C/C++โ€ by Jeffrey Richter โ€” Deep Windows internals

Online Resources

Code to Study

  • libuv โ€” Best-in-class cross-platform I/O
  • Rust std::sys โ€” Rustโ€™s platform abstractions
  • SDL2 โ€” Mature cross-platform library

Self-Assessment Checklist

Before considering this project complete, verify:

Understanding

  • I can explain the difference between file descriptors and HANDLEs
  • I can explain why Windows doesnโ€™t have fork()
  • I can describe three things that differ in struct layout across platforms
  • I can explain what a calling convention is
  • I can describe how to export symbols from a shared library on both platforms

Implementation

  • My library compiles on Linux, macOS, and Windows
  • File operations work identically across platforms
  • Process spawning works with proper argument passing
  • Error codes are unified and meaningful
  • CI builds and tests on all platforms

Verification

  • Iโ€™ve tested with at least 40 test cases
  • Iโ€™ve tested edge cases (empty files, long paths, Unicode)
  • Someone else can use my library with only the public header
  • Documentation explains any platform-specific limitations

Cross-platform development forces you to truly understand what operating systems do. After this project, youโ€™ll know why โ€œportable Cโ€ is harder than it sounds.