Boundaries and Interfaces in C: Real-World Projects
Goal: Build the mental model and hands-on skill to design stable, safe, and maintainable C interfaces. You will learn to treat every function signature as a contract, every header as a legal document, and every module boundary as a bug-prone fault line. By the end, you will be able to design APIs that are hard to misuse, preserve ABI stability across versions, communicate ownership unambiguously, and evolve modules independently without breaking downstream users. You will also learn to validate inputs, design error models, and build interfaces that can scale from small utilities to shared libraries used by thousands of applications.
Introduction
Boundaries and interfaces in C are the explicit seams between modules, libraries, and layers of a system. They include headers, function signatures, data structures, and the implicit runtime contracts between caller and callee. In C, these boundaries are mostly social contracts enforced by discipline, not the compiler. That makes interface design one of the highest leverage skills in systems programming.
What you will build (by the end of this guide):
- A dynamic plugin system for a shell (runtime interface boundaries)
- A key-value client library with strict ownership rules (API contract boundaries)
- A JSON parser library with a robust error model (data exchange boundaries)
- A logging library with pluggable sinks (observability boundaries)
- A small HTTP client/server library that integrates the above (full interface integration)
Scope (what is included):
- C headers as contracts, translation units, and linkage
- Ownership, const-correctness, opaque types, and handles
- ABI stability, symbol visibility, and versioning
- Dynamic loading, plugins, and runtime dispatch
- Validation at data boundaries (parsing, logging, and error handling)
Out of scope (for this guide):
- Writing a full OS kernel or full HTTP stack
- GUI frameworks or language-level module systems
- Large-scale distributed systems APIs
The Big Picture (Mental Model)
Caller Code Public API (Headers) Private Core (C) OS / Runtime
+---------------+ +---------------------+ +------------------+ +--------------+
| app.c | ---> | lib.h (contracts) | ---> | lib.c (impl) | -> | syscalls |
| plugin.c | | opaque handles | | invariants | | dlopen |
| tests.c | | error model | | memory ownership | | sockets |
+---------------+ +---------------------+ +------------------+ +--------------+
^ \ ^ ^
| \ | |
| +-- ABI/Versioning rules ---------------+
+-- Caller expectations (contract, ownership, const)
Key Terms You Will See Everywhere
- API: The source-level contract (headers and documented semantics)
- ABI: The binary-level contract (layout, calling conventions, symbol names)
- Opaque type: A type declared but not defined in headers to enforce encapsulation
- Ownership: Who allocates and who frees a resource
- Borrowed pointer: A pointer you do not own and must not free
- Reentrancy: A function that can be safely called again before it returns
How to Use This Guide
- Read the Theory Primer first. It is your mini-book.
- For each project, read the Core Question and Concepts sections before coding.
- Use the Hints in Layers only after you attempt a design on your own.
- Validate your work against the Definition of Done checklist.
- Use the Books That Will Help tables when you hit a wall or want deeper context.
This guide is designed for iterative learning:
- Pass 1: Build something that works, even if ugly.
- Pass 2: Refine the interface and error model.
- Pass 3: Prove correctness with tests, tooling, and stress.
Prerequisites & Background Knowledge
Essential Prerequisites (Must Have)
Programming Skills:
- Solid C fundamentals (pointers, structs, function pointers)
- Dynamic memory allocation (
malloc,calloc,realloc,free) - Basic build tooling (
make, compiler flags)
Systems Fundamentals:
- Translation units and headers
- Static vs shared libraries
- Basic debugging with
gdborlldb
Recommended reading:
- “C Programming: A Modern Approach” by K. N. King, Ch. 15-18
- “The C Programming Language” by Kernighan and Ritchie, Ch. 5-6
Helpful But Not Required
- Dynamic loading (
dlopen,dlsym) - Basic socket programming
- Threading and synchronization primitives
Self-Assessment Questions
- Can you explain the difference between
const char*andchar* const? - Can you describe what happens when the linker resolves symbols across
.ofiles? - Can you detect a memory ownership bug with
valgrindor AddressSanitizer? - Can you design a function that makes ownership explicit in its name and signature?
- Can you explain how ABI compatibility differs from API compatibility?
Development Environment Setup
Required tools:
- GCC or Clang
- Make or CMake
gdborlldb
Recommended tools:
valgrind-fsanitize=address,undefinedclang-tidyorcppcheck
Verify your setup:
$ cc --version
$ make --version
$ gdb --version
Time Investment
- Project 1 (Plugin system): 1-2 weeks
- Project 2 (KV client library): 1-2 weeks
- Project 3 (JSON parser library): 2 weeks
- Project 4 (Logging library): 1 week
- Project 5 (libhttp-lite): 3-4 weeks
Important Reality Check
Boundary design is one of the hardest skills in C. Expect to redesign your API multiple times. The goal is not to be perfect on the first attempt. The goal is to learn to detect and prevent misuse through interface design.
Big Picture / Mental Model
The core idea is that C boundaries exist at multiple levels and each level has its own failure modes:
Build-time boundary (headers) -> errors: incompatible types, missing prototypes
Link-time boundary (symbols) -> errors: undefined references, duplicate symbols
Load-time boundary (shared lib)-> errors: ABI mismatch, symbol version conflicts
Run-time boundary (calls) -> errors: ownership bugs, invalid inputs, races
Data boundary (parsing) -> errors: invalid syntax, injection, truncation
If you can correctly reason about all five boundary types, you can design stable C libraries used safely by others.
Theory Primer (Read This Before Coding)
This primer is your mini-book. Each chapter is a concept cluster that you must internalize before you can design safe interfaces.
Chapter 1: Translation Units, Linkage, and Symbol Visibility
Fundamentals
A translation unit is the compiler’s unit of work: one .c file after all #include directives have been expanded. The compiler does not see across translation units; only the linker does. That means your header files are the bridge between compilation boundaries. Linkage defines whether symbols are visible outside a translation unit (extern) or restricted (static). Symbol visibility and linkage are the foundations of how APIs are exposed, how private helpers are hidden, and how ABI stability is maintained. If you do not control what symbols are visible and how they are linked, your public API leaks internal details, which makes future changes break existing users.
Deep Dive into the Concept
Translation units are the smallest boundary that influences interface design. In C, the preprocessor performs literal text substitution. When you #include "lib.h", the compiler inserts that text directly into your .c file, and then compiles the result. This means a header is not a module; it is a contract that is copied into every translation unit that includes it. If you put definitions in a header (instead of declarations), you risk multiple definition errors at link time or, worse, ODR-like violations where multiple translation units define the same global object with internal linkage.
The linker operates on object files. Each .o file contains symbol tables and relocation records. A function declared in one translation unit and defined in another becomes a symbol that the linker resolves by name. If two object files export the same symbol with external linkage, the linker fails or picks one, depending on the build model. This is why you use static for internal helpers: it removes the symbol from the external namespace and prevents collisions. If you design a library, you should treat exported symbols as part of your public API/ABI surface. Once a symbol is exported, downstream users can bind to it and rely on it, even if you did not intend them to.
Visibility controls whether a symbol is exported from a shared object. On ELF systems, you can use -fvisibility=hidden and attribute specific functions with __attribute__((visibility("default"))). The goal is to keep the ABI surface minimal, making it easier to preserve compatibility and reduce accidental coupling. Windows uses __declspec(dllexport) and __declspec(dllimport), which has similar intent. If you skip visibility control, your entire internal function set can become part of the public ABI, which is a long-term maintenance hazard.
Headers should be designed to minimize coupling. If your header includes another header, you have created a transitive dependency that every user inherits. This can increase compile times and reduce portability. Prefer forward declarations in headers and include heavy headers only in the .c file. The more you keep headers clean, the smaller the public surface and the easier it is to evolve internals without breaking users.
Finally, note that symbol names are part of the ABI. A simple rename of a function, even if the signature is unchanged, breaks binary compatibility. This is why stable libraries keep old symbols around or provide versioned symbols. If you are creating a library expected to be reused, you must think about how symbol names and linkage form a stable contract.
Another subtle point is the difference between declarations and definitions for global variables. If you put int counter; in a header, you are defining storage in every translation unit, which causes multiple definition errors. The correct pattern is extern int counter; in the header and int counter = 0; in exactly one .c file. This rule applies to function pointers, arrays, and any global symbol. Because headers are copied, you must treat them as declarations-only surfaces, with definitions in a single compilation unit.
Headers also interact with inline and static inline functions. A static inline function in a header generates a private copy in every translation unit. This is fine for small helpers but can bloat code size and create subtle ABI differences if different translation units are compiled with different flags. An inline function with external linkage is trickier and depends on compiler rules. The safe rule is: use static inline for tiny, header-only utilities; use normal functions for anything with logic. If you need performance, consider LTO or -O flags instead of forcing inline in headers.
Namespacing is another boundary discipline. C has a flat global symbol namespace. If your library exports init(), it can collide with another library that exports the same name. The best practice is to prefix all public symbols with a short library identifier (for example, kv_ or http_). This not only prevents collisions but also makes it obvious which module a function belongs to. The same applies to macros and typedefs. Prefix everything that is public.
Tooling can help you understand boundaries. nm -D libfoo.so shows exported symbols; objdump -t shows symbol tables; readelf -Ws shows visibility. In practice, you should inspect your built libraries and confirm that only intended symbols are exported. If you see internal helpers in the dynamic symbol table, you are leaking API surface and increasing your ABI maintenance cost.
Finally, be aware of build flags. -fPIC changes how code is generated for shared libraries, which affects relocation and can influence performance. -fvisibility=hidden combined with explicit visibility attributes is a strong defense against accidental ABI leakage. A clean boundary is not just a code decision; it is also a build system decision.
How this Fits in Projects
- Project 1 uses shared objects and symbol lookup to load plugins at runtime.
- Project 2 and Project 3 depend on clean header boundaries and minimal exports.
- Project 5 is about keeping public symbols stable across versions.
Definitions and Key Terms
- Translation unit: A
.cfile after preprocessing. - External linkage: Symbols visible to the linker across object files.
- Internal linkage: Symbols restricted to one translation unit (
static). - Symbol visibility: Whether a symbol is exported from a shared object.
Mental Model Diagram
.c + includes -> translation unit -> object file (.o) -> linker -> binary
^ |
| +-- symbol table
+-- header contract copied everywhere
How It Works (Step-by-Step)
- Preprocessor expands headers into a translation unit.
- Compiler generates object file with symbol references and definitions.
- Linker resolves symbols between object files.
- Visibility rules decide what becomes part of the shared library ABI.
Minimal Concrete Example
// lib.h
#ifndef LIB_H
#define LIB_H
int lib_add(int a, int b); // declaration
#endif
// lib.c
#include "lib.h"
static int helper(int x) { return x * 2; }
int lib_add(int a, int b) { return helper(a) + helper(b); }
Common Misconceptions
- “Headers define modules” -> Headers are text, not modules.
- “Static just means faster” -> Static means internal linkage.
- “Symbol names do not matter” -> Names are ABI.
Check-Your-Understanding Questions
- What happens if you put a global variable definition in a header?
- Why does
staticreduce ABI surface? - What is the difference between declaration and definition?
Check-Your-Understanding Answers
- Every translation unit gets its own definition, causing duplicate symbols.
statichides symbols from the linker, preventing external linkage.- A declaration announces a symbol; a definition allocates storage or code.
Real-World Applications
- Shared libraries that export only a minimal set of symbols.
- Plugin systems that depend on stable symbol names.
Where You Will Apply It
- Project 1 (plugin system)
- Project 2 (library headers)
- Project 5 (HTTP library API)
References
- “Expert C Programming” by Peter van der Linden, Ch. 5
- “C Programming: A Modern Approach” by K. N. King, Ch. 15
Key Insight
If you do not control your symbols, your ABI controls you.
Summary
Translation units and linkage are the foundation of boundaries. Headers are not modules; they are contracts copied everywhere. The linker is where boundaries become real, and symbol visibility defines what your users can rely on.
Homework/Exercises
- Create two
.cfiles that each define the same global variable and observe the linker error. - Export a function from a shared library and hide an internal helper with
static.
Solutions
- The linker fails with a “multiple definition” error.
- Use
staticor visibility attributes to keep helpers private.
Chapter 2: Interface Contracts and Error Models
Fundamentals
An interface contract in C is the explicit set of guarantees between caller and callee: valid inputs, ownership rules, thread safety, error behavior, and side effects. Unlike languages with enforced module systems, C relies on headers, documentation, naming conventions, and defensive checks. Error models are the backbone of the contract because they specify how failures are represented and handled. If your error model is ambiguous, users will misuse your API. A good error model answers: How do I detect failure? How do I get details? Does failure leave partial state? Can I retry?
Deep Dive into the Concept
C gives you multiple error reporting strategies: return codes, errno, out-parameters, and callbacks. Each has tradeoffs. A return code is explicit and easy to test, but it must be consistent across the API. errno is global and thread-local, but it is implicitly set and easily misused when callers forget to check. Out-parameters allow returning multiple values (like data plus status), but only if the caller provides valid storage and the contract specifies when output is written. For boundary design, consistency matters more than theoretical purity. Choose a single error strategy and use it consistently.
A robust contract must specify preconditions (what the caller must pass), postconditions (what the callee guarantees after returning), and invariants (what always remains true). For example, an API might say that if a function returns KV_OK, the returned pointer is valid until the caller frees it, but if it returns KV_ERR_NOT_FOUND, the output pointer is set to NULL and must not be freed. This explicitness prevents ambiguity.
Error handling also intersects with ownership. Suppose a function allocates a resource and then fails halfway through. The contract must say whether the resource remains valid, is cleaned up automatically, or must be freed by the caller. If this is unclear, callers will leak or double free. Good APIs often provide helper functions like lib_last_error() or lib_error_string() that return a borrowed pointer, keeping ownership clear.
Another critical part of the contract is whether functions are reentrant or thread-safe. If a function uses static internal buffers, it is not reentrant. If it updates global state without locking, it is not thread-safe. You must document this in the header and, if possible, encode it in the type system (for example, by requiring a handle that owns a mutex). Contracts are not just about correctness; they are about reducing the number of ways a user can guess wrong.
The error model also influences testing. If you use return codes, your tests should cover each code and verify that output parameters are set correctly. If you use errno, tests must verify that errno is set only on failure and not overwritten on success. A clean error model makes verification simpler.
Finally, error models must be future proof. If you plan to add new error cases later, use an enum with a reserved range or a general error code plus a string. Avoid encoding too much meaning into -1 or NULL without context. Stable APIs do not add new return values that change the meaning of old ones.
When you choose an error model, consider how callers will compose your API with other APIs. For example, many POSIX functions return -1 and set errno, but that pattern does not compose well if your API also returns -1 for valid data. If you must return a signed size and also signal error, prefer ssize_t and document that -1 indicates error. If you return a pointer, reserve NULL for failure and ensure that NULL is never a valid success value. If NULL can be valid, you must add a separate status code or an out-parameter for errors. The goal is for the caller to detect failure in one obvious way, every time.
Out-parameters deserve special care. If a function signature includes an output pointer, the contract should specify whether the output is written on failure or only on success. A common pattern is to zero the output on failure so callers do not accidentally use stale data. Another pattern is a two-call approach: the caller passes NULL to query required size, then allocates and calls again. This is used by many system APIs and avoids hidden allocations. However, the two-call pattern must be documented carefully because it doubles the chance of caller error.
Consider layering your error model. At the top level, you might return an enum with broad classes (OK, INVALID, IO, OOM). For detailed diagnostics, provide an optional error struct or a function like lib_get_last_error() that returns a string or code. This keeps the main API clean but allows debugging. The key is to make the lifetime of error details explicit: if the error string is stored in a handle, it is only valid while the handle exists. If it is thread-local, document that it is per-thread.
Also pay attention to partial failures. Suppose you have a function that returns an array of results. What happens if allocation fails halfway through? A robust contract might return the partially built array and a warning code, or it might free all intermediate data and return a failure code. Either choice is acceptable as long as it is consistent and documented. Inconsistent partial failure handling is a common source of leaks and corruption.
Finally, think about how your error model interacts with logging. If a function logs an error and also returns an error code, callers might log again, producing duplicate messages. A clean design lets the caller decide whether to log. This is especially important in libraries where you do not control how the host application handles errors. The contract should state whether the library logs internally or whether logging is the caller’s responsibility.
How this Fits in Projects
- Project 2 defines explicit status codes for network operations.
- Project 3 uses detailed parse error reporting.
- Project 4 defines log sink errors and partial write handling.
- Project 5 integrates errors across multiple layers.
Definitions and Key Terms
- Precondition: What must be true before the call.
- Postcondition: What is guaranteed after the call.
- Invariant: A property that always holds.
- Error model: The method used to represent failures.
Mental Model Diagram
Caller -> [validate inputs] -> callee
| |
|-- failure? --------- |
| return code |
+-- success? --------- |
output data valid
How It Works (Step-by-Step)
- Define error codes and meaning in the header.
- Validate inputs at the boundary.
- On failure, set error state and return a clear code.
- On success, guarantee output invariants.
Minimal Concrete Example
typedef enum {
LIB_OK = 0,
LIB_ERR_INVALID = -1,
LIB_ERR_IO = -2
} lib_status;
lib_status lib_read(const char *path, char **out_data);
Common Misconceptions
- “Returning NULL is enough” -> Not if NULL can also mean valid data.
- “errno is automatic” -> It is global state and must be explicitly managed.
Check-Your-Understanding Questions
- What is the difference between
errnoand return codes? - Why should you avoid ambiguous NULL returns?
Check-Your-Understanding Answers
errnois implicit global state; return codes are explicit.- Because callers cannot distinguish failure from valid NULL data.
Real-World Applications
- POSIX APIs that return
-1and seterrno. - Libraries that return enums for precise error handling.
Where You Will Apply It
- Projects 2, 3, 4, 5
References
- “Fluent C” by Christopher Preschern, Ch. 5
- “Code Complete” by Steve McConnell, Ch. 8
Key Insight
A good error model is as important as the core functionality itself.
Summary
Interface contracts define how callers interact with your library. Error models must be explicit, consistent, and future proof. If the error model is ambiguous, the API will be misused.
Homework/Exercises
- Design an error enum for a small file parser.
- Add a
lib_last_error()function that returns a borrowed string.
Solutions
- Use an enum and map each failure mode to a distinct value.
- Return a
const char*to internal storage; document lifetime.
Chapter 3: Ownership, Lifetime, and Const Correctness
Fundamentals
Ownership defines who is responsible for freeing a resource. Lifetime defines how long that resource stays valid. Const correctness communicates mutation intent. Together, these rules prevent leaks, use-after-free, and API misuse. In C, nothing enforces these rules except the interface design itself. A robust API makes ownership and lifetime obvious from the function signature and naming conventions.
Deep Dive into the Concept
C exposes raw pointers with no automatic lifetime tracking. As a result, you must explicitly encode ownership semantics into the API. A common pattern is that functions returning a newly allocated object return a non-const pointer and provide a matching free function, such as foo_new() and foo_free(). Another pattern is to return borrowed data as const pointers, signaling that the caller must not free or modify. This is not just style; it is a contract. A function returning const char* must guarantee that the data remains valid for a documented duration, often until the next call or until the owning handle is freed.
Const correctness is more than a compiler warning tool. It is your only language-level signal for mutability. When you accept const char*, you are promising that you will not modify the data. When you return const char*, you are telling the caller that the data is borrowed and should not be freed or changed. You should also consider the difference between const T* and T* const. The first means the data is immutable; the second means the pointer is immutable but the data can change. At boundaries, callers typically care about data immutability, not pointer immutability.
Ownership also affects error handling. Suppose a function returns a pointer and a status code. On failure, do you return NULL, or a partially allocated object? If you allocate before detecting an error, you must decide whether to free internally or transfer ownership anyway. A consistent rule is to free on failure and return NULL. If you must return partial results, document that the caller owns them even in failure cases.
Lifetime rules can be subtle. A borrowed pointer can be valid only as long as the owning object exists. For example, const char* kv_get_error(kv_handle* h) returns a pointer valid only while h remains alive. If the handle is freed or reused, the pointer becomes invalid. APIs should explicitly state these conditions. Some libraries avoid this complexity by copying data out for the caller, but this adds allocation overhead. The correct choice depends on performance goals and expected usage.
Finally, ownership can be encoded into naming conventions. Prefixes like *_create, *_destroy, *_dup, *_borrow, or *_view communicate intent. Another technique is to use paired allocator functions (for example, lib_malloc and lib_free) so that the API can manage memory even if a custom allocator is used. This is important when library code and application code are built with different CRTs or allocators.
Ownership rules also apply to buffers provided by the caller. A common pattern is for the caller to pass a buffer and its length, and the callee fills it. This avoids allocations and makes ownership explicit. However, it requires a careful contract about what happens when the buffer is too small. One option is to return the required size so the caller can retry. Another option is to truncate and return a warning code. Choose one and document it clearly. Inconsistent buffer handling leads to silent truncation bugs.
Another powerful pattern is reference counting. If multiple parts of a program need to share ownership of an object, a refcount allows safe sharing. But refcounts are themselves a boundary decision: who increments and decrements, and are they thread-safe? If you provide obj_ref() and obj_unref() functions, you must document whether they are safe to call from multiple threads. You should also define what happens at refcount zero (free immediately or defer). Refcounting can prevent double-free errors but adds complexity, so use it only when shared ownership is required.
Const correctness can be extended with restrict and volatile. restrict indicates that pointers do not alias, allowing optimization. volatile indicates that memory may change outside the program’s control. These qualifiers are part of the contract too. If you mark an input pointer restrict, you are promising the caller will not pass overlapping memory. Violating this is undefined behavior, so only use restrict when you can guarantee the contract and want the performance benefit.
Consider ownership in error paths. Suppose a function returns a pointer and also takes an output parameter for error details. If allocation succeeds but validation fails, you must decide whether to free and return NULL or return a partially built object plus an error. The safest approach is to free on error and return NULL, but in some APIs, partial results are useful. If you choose partial results, you must provide a way to inspect their validity and a clear cleanup function.
Finally, cross-module allocation is a real risk on some platforms. If one module uses a custom allocator or is linked against a different C runtime, freeing memory allocated by another module can be unsafe. This is why many libraries provide their own *_free functions. Even on Unix where free() is usually compatible, having explicit free functions is still a best practice because it makes ownership explicit and allows future allocator changes without breaking users.
How this Fits in Projects
- Project 2 requires explicit ownership rules for returned strings.
- Project 3 requires lifetime rules for parsed JSON nodes.
- Project 4 requires const correctness for log message buffers.
- Project 5 relies on clean ownership across the HTTP boundary.
Definitions and Key Terms
- Ownership: Responsibility to release a resource.
- Borrowed pointer: A pointer without ownership.
- Transfer of ownership: When responsibility moves from callee to caller.
Mental Model Diagram
Callee allocates -> returns pointer -> Caller owns -> Caller frees
Borrowed pointer -> valid while owner lives -> never free
How It Works (Step-by-Step)
- Decide which side allocates and frees.
- Encode ownership in function names and signatures.
- Use
constto distinguish borrowed data. - Document lifetime and invalidation rules.
Minimal Concrete Example
char* user_alloc_name(int id); // caller frees
const char* user_get_name(int id); // borrowed, do not free
void user_free_name(char *p);
Common Misconceptions
- “Returning const means memory is static” -> Not necessarily; it can be borrowed.
- “Freeing with free() is always safe” -> Not if the library uses a custom allocator.
Check-Your-Understanding Questions
- Why return
const char*instead ofchar*for borrowed data? - When should you provide a custom free function?
Check-Your-Understanding Answers
- It signals borrowed data and prevents mutation.
- When allocation may not match the caller’s allocator.
Real-World Applications
strdupvsgetenvownership semantics.- Library handles that own buffers and return borrowed views.
Where You Will Apply It
- Projects 2, 3, 4, 5
References
- “Effective C” by Robert C. Seacord, Ch. 6
- “Understanding and Using C Pointers” by Richard Reese, Ch. 4
Key Insight
Ownership must be obvious from the API, not hidden in comments.
Summary
Ownership and const correctness are the most frequent sources of boundary bugs in C. Make allocation and lifetime rules explicit through naming, types, and documentation.
Homework/Exercises
- Rewrite a function that returns a pointer so that ownership is explicit.
- Add
constqualifiers to a header and see which compiler warnings appear.
Solutions
- Add a
*_freefunction or rename the function to include ownership in its name. - Warnings often highlight hidden mutability assumptions.
Chapter 4: Opaque Types and Handle-Based Encapsulation
Fundamentals
Opaque types hide internal structure definitions from users, enforcing encapsulation and ABI stability. A handle-based API exposes only an incomplete type in the header and keeps the full struct definition private in the .c file. This prevents users from depending on internal layout and allows the library to change internals without breaking callers.
Deep Dive into the Concept
In C, the simplest way to create an opaque type is to forward declare a struct in the header: typedef struct foo foo;. This gives the caller a pointer type (foo*) without revealing the struct’s fields. The caller can pass the pointer back to API functions, but cannot dereference it because the compiler does not know the layout. This creates a strict boundary: the only way to interact with the object is through the API. This is a powerful form of encapsulation in a language that otherwise exposes everything.
Opaque types are critical for ABI stability. If you expose a struct definition in a header, any change to its field order or size can break binary compatibility. A program compiled against the old struct layout will access the wrong offsets when linked against a newer library. By hiding the struct, you ensure that users cannot embed it or allocate it on the stack, and your ABI is protected from internal changes.
Real-world libraries use opaque types aggressively. OpenSSL, for example, made many structures opaque in its major version transitions to prevent direct field access and improve ABI stability. This forced users to migrate to getter and setter functions but allowed the library to evolve without breaking binary compatibility. The OpenSSL migration guide explicitly notes that structures were moved to internal headers and must be allocated via _new functions, not on the stack.
Opaque types also help enforce ownership. If the caller cannot see the fields, you can prevent them from freeing internal buffers or mutating state incorrectly. The API becomes the single source of truth. This is particularly useful in multi-threaded contexts because the library can enforce locking internally without the caller corrupting state.
The handle pattern also supports version negotiation and runtime validation. A handle can include a version number or magic value to detect misuse. For example, you can embed a uint32_t magic field that is checked by every public function. If a handle has been freed, the magic value can be overwritten, and attempts to use it can fail fast. This turns silent memory corruption into explicit error handling.
One tradeoff is that opaque types require dynamic allocation. Users cannot allocate the struct on the stack because they do not know its size. This means you must provide creation and destruction functions and be explicit about ownership. This is good for interface stability but adds some complexity for users. The improvement in safety and evolution is worth the cost.
Opaque types also enable a clean separation between public and private headers. In large projects, it is common to have an internal header that defines the struct for the library itself, while the public header only forward declares it. This allows the library to unit test internal fields without exposing them to users. You can also provide a limited set of accessor functions that expose only necessary state, which keeps the surface small and stable.
Handle validation is another critical practice. Because an opaque handle is just a pointer, users can still pass random values or use-after-free pointers. Embedding a magic number and a version field inside the struct allows you to detect invalid handles early. This is a form of defensive boundary checking. If you add a refcount, you can also detect double-free by checking the count or by poisoning memory after free. These practices turn silent memory corruption into explicit errors.
Opaque types also interact with FFI. If your C library is used from other languages, an opaque handle is the safest representation because foreign runtimes can treat it as an opaque pointer without relying on layout. This improves portability and makes bindings simpler. It also means that your library can evolve without forcing changes in every binding.
Finally, opaque types are a key tool for ABI evolution. You can add fields, change internal data structures, or even replace the entire implementation without changing the public header. As long as the function signatures and semantics remain stable, the ABI holds. This makes it possible to refactor aggressively without breaking downstream users. In practice, this is one of the few techniques that gives C a module-like boundary, and it should be your default choice for any non-trivial library.
How this Fits in Projects
- Project 1 uses opaque plugin handles to hide internal data.
- Project 2 uses opaque
kv_handlefor connection state. - Project 4 uses opaque logger handles.
- Project 5 uses opaque HTTP connection handles.
Definitions and Key Terms
- Opaque type: A forward-declared struct with hidden definition.
- Handle: A pointer to an opaque type that represents an object.
Mental Model Diagram
Public header: typedef struct foo foo; (no fields visible)
Private .c: struct foo { ... }; (fields hidden)
Caller: foo* handle -> API only
How It Works (Step-by-Step)
- Forward declare the struct in the header.
- Define the full struct only in the
.cfile. - Provide
createanddestroyfunctions. - Validate handles in each public API call.
Minimal Concrete Example
// header
typedef struct logger logger;
logger* logger_create(void);
void logger_destroy(logger* l);
// implementation
struct logger { int fd; int level; };
Common Misconceptions
- “Opaque types are only for C++” -> They are standard C technique.
- “Users will hack around it” -> They can only by breaking the contract.
Check-Your-Understanding Questions
- Why does an opaque type protect ABI stability?
- Why do opaque types require heap allocation?
Check-Your-Understanding Answers
- The struct layout is hidden, so users cannot depend on it.
- The caller does not know the size, so it cannot allocate on the stack.
Real-World Applications
- OpenSSL opaque structs after 1.1.0
- FILE* handles in the C standard library
Where You Will Apply It
- Projects 1, 2, 4, 5
References
- OpenSSL migration guide (structures made opaque): https://docs.openssl.org/3.5/man7/ossl-guide-migration/
- “C Interfaces and Implementations” by David R. Hanson, Ch. 2
Key Insight
Opaque types are the cheapest way to get encapsulation and ABI stability in C.
Summary
Opaque types restrict how users interact with your objects, enforce access through the API, and prevent ABI breakage due to struct layout changes.
Homework/Exercises
- Convert a public struct API to an opaque handle API.
- Add a magic value to a handle and verify detection of invalid handles.
Solutions
- Move the struct definition into the
.cfile and forward declare it in the header. - Add
uint32_t magicand check it in every public function.
Chapter 5: ABI Stability and Versioning
Fundamentals
ABI stability means that binary code compiled against an old version of a library can run against a newer version without recompilation. In C, ABI includes symbol names, calling conventions, struct layouts, enum sizes, and alignment rules. If you break ABI, every downstream binary must be recompiled or upgraded. Stable ABI is essential for shared libraries that are widely distributed.
Deep Dive into the Concept
ABI stability is often misunderstood as just “do not change function signatures.” In reality, ABI includes every detail that compiled code assumes about the library. If you change a struct layout that is exposed in a header, the offsets used by compiled code become wrong. If you change the size of a type or re-order fields, you break ABI. If you change the calling convention or symbol visibility, you break ABI. Even renaming a function breaks ABI because the symbol name changes.
Libraries that care about ABI stability use several strategies. First, they hide struct definitions behind opaque types, which removes layout from the ABI surface. Second, they avoid changing existing function signatures; new functionality is added via new functions instead of modifying existing ones. Third, they manage versioning at the dynamic linker level, using SONAMEs on ELF systems or versioned DLLs on Windows. When an ABI break is unavoidable, they bump the SONAME so that old binaries continue to link against the old library while new binaries use the new one. This is the core reason why SONAME exists.
Real-world examples show why this matters. libcurl explicitly documents a policy of ABI stability, stating that upgrades do not break ABI and that SONAME bumps are used only when unavoidable. This allows users to upgrade to newer versions without recompiling their applications. SQLite categorizes interfaces as stable, experimental, and deprecated, and explicitly guarantees that stable interfaces remain backwards compatible. This is how SQLite can be embedded everywhere without breaking user applications.
Versioning is not just about the library. You must also consider how your API communicates version compatibility to the caller. A simple approach is a lib_version() function returning a semantic version string. A more robust approach is to embed a version field in an interface struct or vtable. When a plugin or application calls into the library, it can check the version before using features. This is common in plugin systems where the host loads third-party code.
Symbol versioning adds another layer of safety. On ELF systems, you can annotate symbols with version tags so that the dynamic linker can resolve the correct version even if multiple versions exist. This is advanced but important for large ecosystems.
The takeaway is that ABI stability is a long-term commitment. If you want your library to be used broadly, you must plan for ABI compatibility from day one. Opaque types, careful versioning, and clear stability policies are the only way to avoid breaking downstream users.
There are also many subtle ABI breakers that are easy to overlook. Changing the underlying type of an enum can break ABI if callers assume a specific size. On most systems, enum is int, but it is not guaranteed. If you need a fixed ABI, use an explicit integer type in the public API. Similarly, changing struct packing or alignment (#pragma pack) can break ABI by altering field offsets. Avoid packing in public structs unless you absolutely need to match an external binary format, and even then, prefer to parse into internal structs rather than exposing packed layouts to callers.
Calling conventions are part of the ABI as well. Most C compilers on a platform agree on the default calling convention, but if you introduce __stdcall or other attributes in public headers, you are defining ABI details that must not change. This matters for FFI and for plugins compiled with different compilers. The safest approach is to stick to the platform default calling convention and avoid compiler-specific attributes in public headers unless required.
Another area is inline functions in headers. If you change the behavior of an inline function, any code that was already compiled will continue using the old behavior until it is recompiled. This is an API-level change that can create confusing version mismatches. The safest rule is to avoid putting non-trivial logic in inline functions in public headers unless you are willing to treat them as part of your API contract.
Deprecation policies are part of ABI planning. You should provide a path for users to migrate before removing or changing symbols. One common technique is to keep old functions but mark them as deprecated and implement them as wrappers around the new API. This preserves ABI while guiding users to the new interface. Over time, you can remove deprecated symbols only in a major ABI-breaking release, signaled by a SONAME bump.
In practice, stable ABI is a business decision. It reduces friction for users but increases the maintenance burden on library authors. The earlier you commit to ABI stability, the more careful you must be with public structures and function signatures. The payoff is that your library becomes trusted infrastructure, which is the goal for any widely used systems component.
How this Fits in Projects
- Project 1 uses version negotiation in plugin loading.
- Project 2 and 3 benefit from stable APIs for embedding.
- Project 5 requires careful API evolution planning.
Definitions and Key Terms
- ABI: Binary compatibility contract.
- SONAME: Shared object name used by the dynamic linker.
- Symbol versioning: Attaching version tags to exported symbols.
Mental Model Diagram
App (compiled) -> expects ABI v1 symbols
Library v2 -> must preserve v1 ABI or bump SONAME
How It Works (Step-by-Step)
- Define a minimal public API.
- Hide internal structures.
- Add new functions instead of modifying old ones.
- Bump SONAME only when you must break ABI.
Minimal Concrete Example
// v1
int lib_do(int x);
// v2 - add new function, do not change old one
int lib_do_ex(int x, int flags);
Common Misconceptions
- “ABI is the same as API” -> ABI is stricter; it is binary level.
- “Adding fields to a struct is safe” -> Not if the struct is public.
Check-Your-Understanding Questions
- Why does changing a struct layout break ABI?
- What is the purpose of a SONAME bump?
Check-Your-Understanding Answers
- Compiled code uses fixed offsets into struct fields.
- It allows old binaries to keep using the old ABI.
Real-World Applications
- libcurl ABI stability policy: https://curl.se/libcurl/abi.html
- SQLite stable interfaces policy: https://www.sqlite.org/capi3ref.html
Where You Will Apply It
- Project 1 (plugin interface versioning)
- Project 5 (HTTP library evolution)
References
- “The Linux Programming Interface” by Michael Kerrisk, Ch. 41-42
- libcurl ABI policy: https://curl.se/libcurl/abi.html
- SQLite C API stability: https://www.sqlite.org/capi3ref.html
Key Insight
ABI stability is a promise you must engineer for, not a property you get for free.
Summary
If you want your library to survive upgrades without breaking users, you must hide internals, avoid changing exported signatures, and manage versioning carefully.
Homework/Exercises
- Design an API version function and document its use.
- Convert a public struct to an opaque type and consider the ABI impact.
Solutions
- Add
const char* lib_version(void);returning a semantic version string. - Move the struct to the
.cfile and provide accessors.
Chapter 6: Dynamic Loading and Plugin Interfaces
Fundamentals
Dynamic loading allows a program to load shared libraries at runtime and discover symbols dynamically. This is the foundation of plugin systems, where third-party code can be loaded without recompilation. The core functions on POSIX systems are dlopen, dlsym, and dlclose. These functions create a runtime boundary that requires explicit versioning, symbol naming conventions, and validation.
Deep Dive into the Concept
Dynamic loading moves the boundary from compile time to runtime. Instead of linking directly to a library, the program opens a shared object and looks up function symbols by name. This means that the library must expose a known entry point and that the host must validate that entry point before calling into it. If the symbol name changes, the plugin will not load. If the signature changes, the host might call a function with the wrong calling convention, leading to crashes.
A robust plugin interface uses a versioned function table. The plugin exports a function like plugin_get_api() that returns a struct of function pointers plus a version number. The host checks the version and only uses supported fields. This allows you to extend the interface by adding fields at the end while keeping older plugins compatible. This is a common ABI strategy. It mirrors how the Linux kernel and many system libraries evolve their interfaces.
The dynamic loader itself has subtle behavior. dlopen may return the same handle for repeated loads, and dlclose only unloads when the reference count reaches zero. The POSIX spec explicitly states that dlclose is a hint and does not guarantee unloading. Therefore, you must not rely on library destructors running immediately. You must also treat the handle as opaque and only use it with dlsym or dlclose.
Thread safety is also important. If multiple threads load or unload plugins, you need synchronization around global plugin registries. Additionally, you must define whether plugin callbacks are allowed to call back into the host (reentrancy). If a plugin can call host APIs that in turn call plugin APIs, you can create reentrancy hazards. Documenting this is part of the interface contract.
Finally, dynamic loading and plugins create new security boundaries. You are executing third-party code in your process. You must validate inputs, version compatibility, and if possible, restrict plugin capabilities. In production systems, plugins are often sandboxed or loaded in separate processes. In this guide, you will focus on the interface design, but keep in mind that plugin boundaries are also trust boundaries.
The dlopen API itself has important flags that change behavior. RTLD_LAZY resolves symbols on first use, which can hide missing symbol errors until runtime. RTLD_NOW resolves all symbols immediately, which makes failure explicit and easier to debug. RTLD_LOCAL keeps symbols private to the plugin, while RTLD_GLOBAL makes them visible to subsequently loaded plugins. A host that loads multiple plugins should generally use RTLD_LOCAL to avoid accidental symbol interposition between plugins unless you explicitly want shared symbols.
Plugin discovery is also part of the boundary. How does the host decide which files are plugins? A robust design uses a dedicated plugin directory, a strict naming convention, and optional metadata files. It should also validate file permissions and refuse to load world-writable plugins in security-sensitive contexts. Even in a learning environment, practicing these checks builds good habits.
Function table versioning can be strengthened by including a size field. The plugin can fill in api->size = sizeof(plugin_api) and the host can reject any plugin whose struct size is smaller than required. This allows backward compatibility: older plugins have smaller structs, but the host can still use the fields that exist. This pattern is common in OS APIs and plugin ecosystems because it avoids fragile assumptions about struct layout across versions.
Another design detail is plugin lifecycle ordering. A typical lifecycle is: load -> init -> register commands -> run -> shutdown -> unload. The host must define what happens if init fails. Should it call shutdown? Should it unload immediately? The contract should be explicit. The plugin should also document whether init can be called more than once and whether it is thread-safe.
Finally, dynamic loading complicates testing because failures happen at runtime. Add tests that intentionally load a plugin with the wrong version, missing symbols, or incorrect signatures. The host should fail gracefully with clear error messages. This is part of the boundary contract: callers should never have to guess why a plugin failed to load.
How this Fits in Projects
- Project 1 is a full plugin system.
- Project 5 can integrate middleware or extensions.
Definitions and Key Terms
- dlopen: Open a shared object at runtime.
- dlsym: Lookup a symbol by name.
- Function table: A struct of function pointers.
Mental Model Diagram
Host app -> dlopen("plugin.so") -> dlsym("plugin_get_api") -> function table
How It Works (Step-by-Step)
- Host calls
dlopento load the plugin. - Host calls
dlsymto find the entry point. - Plugin returns an API table with a version number.
- Host validates version and calls functions.
- Host calls
dlclosewhen done.
Minimal Concrete Example
typedef struct {
int version;
int (*init)(void);
int (*run)(const char *cmd);
void (*shutdown)(void);
} plugin_api;
plugin_api* plugin_get_api(void);
Common Misconceptions
- “dlclose always unloads” -> It only decrements a reference count.
- “Function pointers are safe across versions” -> Not if the layout changes.
Check-Your-Understanding Questions
- Why is a versioned function table safer than raw symbol lookup?
- What happens if
dlopenfails?
Check-Your-Understanding Answers
- It allows compatibility checks and extensibility.
- It returns NULL and
dlerror()provides details.
Real-World Applications
- Plugin architectures in shells, editors, and databases.
- Runtime extensions in SQLite (
load_extension).
Where You Will Apply It
- Project 1
- Project 5
References
- POSIX
dlopenanddlclosespecification: https://pubs.opengroup.org/onlinepubs/9699919799/functions/dlopen.html - POSIX
dlclosespecification: https://pubs.opengroup.org/onlinepubs/9699919799.orig/functions/dlclose.html
Key Insight
Plugins extend a program only when their interfaces are stable and versioned.
Summary
Dynamic loading moves interface validation to runtime. A safe plugin system requires explicit version negotiation, careful symbol naming, and clear ownership rules.
Homework/Exercises
- Write a tiny shared library that exports a function table.
- Write a host program that loads it and calls its functions.
Solutions
- Use
-shared -fPICto build the plugin. - Use
dlopen,dlsym, anddlclosein the host.
Chapter 7: Data Exchange and Validation (Parsing and Serialization)
Fundamentals
Data boundaries are where untrusted input enters your system. Parsing and serialization are common sources of bugs because they combine complexity with low-level memory management. A good interface for parsing separates input validation from data access, and it uses a clear error model. In this guide, JSON is the canonical example, but the patterns apply to any protocol or data format.
Deep Dive into the Concept
JSON is a lightweight, text-based data interchange format defined by RFC 8259 and ECMA-404. It supports a small set of primitives (string, number, boolean, null) and two composite types (array and object). The simplicity of JSON hides complexity in parsing: Unicode handling, number formats, recursion depth, and error recovery are all potential pitfalls. In C, these pitfalls are amplified by manual memory management.
A safe parsing boundary begins with strict input validation. Decide whether your parser accepts only UTF-8, whether it allows trailing data, and whether it tolerates duplicate keys. These are contract decisions that must be documented. The parser API should take a const char* input and return either a parsed object tree or an error with location details. A simple pattern is:
json_parse(const char* text, json_error* err)returns a handle to a JSON value or NULL.- The error struct includes line, column, and a descriptive message.
The interface should also encode ownership: who owns the returned parse tree and how it is freed. A good design returns an opaque json_value* and provides json_free() to release it. For accessors, return borrowed pointers for internal strings unless you explicitly duplicate them. If you return borrowed data, document that the value is only valid while the parent JSON tree exists.
Input validation also includes size and depth limits. Malicious input can be designed to create deeply nested arrays or objects that blow the stack or consume excessive memory. Your parser should have configurable limits for maximum depth, maximum string length, and maximum total allocation. The API can expose these as parser settings in a config struct. This allows callers to tailor safety limits for their environment.
Serialization boundaries have similar issues. When converting data structures to JSON, you must ensure proper escaping, correct Unicode handling, and deterministic output. An API that accepts a callback for output (streaming) is often safer than returning a giant string, because it avoids large allocations and allows the caller to control output buffering. However, streaming outputs require a precise error model because writing can fail mid-stream.
Finally, parsing errors are user-facing. A good error model includes error codes and a human-readable message. It should also specify whether the error is recoverable. For a parser library, most errors are fatal, but you may want to allow partial parsing or “best effort” modes for debugging tools. This should be a separate API so that normal parsing remains strict.
Memory allocation strategy is another interface decision. You can allocate each JSON node individually with malloc, which is simple but can be slow and fragment memory. Alternatively, you can allocate from an arena and free everything at once, which is fast and makes cleanup trivial. If you choose arenas, document that individual nodes must not be freed and that the entire tree is freed at once. This changes the ownership model and can be a huge usability win. You can also offer both modes: a default allocator and an optional custom allocator passed in via a config struct. This is common in systems libraries where the caller wants to control allocation behavior.
String handling is a classic pitfall. JSON strings include escape sequences and Unicode. If your parser does not fully decode Unicode escapes, you must document the behavior. Some parsers return the raw escaped string, while others return a decoded UTF-8 string. Both choices are valid, but the API must be explicit. If you return decoded strings, you must allocate new buffers and document ownership. If you return slices into the input buffer, you must document that the input must remain alive as long as the JSON tree is used. The slice approach is faster but more restrictive.
Numbers are another boundary edge case. JSON allows integers and floating-point numbers in decimal notation, but not NaN or Infinity. If you parse numbers into double, you may lose precision for large integers. Some parsers store numbers as strings and provide conversion helpers. Decide whether your API returns a numeric type or a string representation, and document the tradeoffs. An advanced design provides both: a raw string and a parsed numeric value when possible.
Error reporting can be enhanced with context. A parse error that includes the token, line, column, and a small excerpt around the error is far more actionable than a generic “invalid JSON”. Consider returning a json_error struct that includes line, column, code, and message. This is valuable in real systems where JSON is used for configuration and API payloads, because it reduces debugging time.
Finally, think about streaming. For large inputs, reading the entire JSON document into memory may be too expensive. A streaming API that reads from a callback and emits events (SAX-style) avoids huge allocations but changes the interface entirely. For this guide, you can implement a tree-based parser, but the design should leave room for a future streaming API without breaking existing users.
How this Fits in Projects
- Project 3 is entirely about parsing and validation boundaries.
- Project 5 uses JSON for request/response bodies.
Definitions and Key Terms
- Parser: Converts text input into structured data.
- Serializer: Converts structured data into text output.
- Validation: Checking that input matches the format and rules.
Mental Model Diagram
Input text -> lexer -> parser -> JSON tree
| | | |
+-- errors+---------+--------+-> error report
How It Works (Step-by-Step)
- Tokenize input (lexer).
- Parse tokens into a tree (recursive descent).
- Validate constraints (depth, size, type rules).
- Return tree or detailed error.
Minimal Concrete Example
typedef struct json_value json_value;
json_value* json_parse(const char *text, json_error *err);
void json_free(json_value *v);
const char* json_string_value(const json_value *v); // borrowed
Common Misconceptions
- “JSON is simple so parser is trivial” -> Edge cases are numerous.
- “Return NULL means error” -> You still need error location details.
Check-Your-Understanding Questions
- Why should a parser provide line and column on errors?
- Why is an opaque JSON tree safer than exposing structs?
Check-Your-Understanding Answers
- It enables debugging and precise error handling for callers.
- It protects ABI stability and prevents misuse of internals.
Real-World Applications
- Config file parsing in CLI tools.
- JSON APIs in HTTP services.
Where You Will Apply It
- Project 3
- Project 5
References
- RFC 8259 (JSON): https://www.rfc-editor.org/rfc/rfc8259
- ECMA-404 (JSON syntax): https://ecma-international.org/publications-and-standards/standards/ecma-404/
Key Insight
Parsing is a boundary with hostile inputs. Design the interface as if attackers will test it.
Summary
Validation, error reporting, and ownership rules are essential in data exchange boundaries. JSON is a perfect case study because it is simple at the surface but complex in practice.
Homework/Exercises
- Write a lexer that tokenizes JSON numbers and strings.
- Add a maximum depth limit to a recursive parser.
Solutions
- Implement token types and a state machine for escapes.
- Track recursion depth and return an error when limit is exceeded.
Chapter 8: Concurrency, Thread Safety, and Reentrancy
Fundamentals
Thread safety defines whether an API can be safely used by multiple threads at once. Reentrancy defines whether a function can be called again before it returns (for example, from a signal handler or callback). These properties are part of the interface contract. If the API is not thread-safe, it must document that clearly. If it is thread-safe, it must implement internal synchronization and avoid global mutable state.
Deep Dive into the Concept
In C, thread safety is not automatic. Any global or static mutable state is a potential race condition. For example, functions that use a static buffer to return data are not reentrant. If a second call happens before the first call’s data is consumed, the buffer is overwritten. This is a classic boundary bug because it appears only under concurrency.
There are several strategies to make an API thread-safe. The simplest is to document that it is not thread-safe and require that each thread uses its own handle. This is common in C libraries for performance reasons. A more robust strategy is to embed a mutex inside each handle and lock it at the start of every public function. This makes the API thread-safe by design, but it can reduce performance and risks deadlocks if callbacks call back into the API.
Reentrancy is even stricter. A reentrant function must not rely on any shared mutable state at all, even if protected by a mutex, because a reentrant call might occur in the same thread in a context where locking is unsafe (such as a signal handler). Reentrancy is necessary for async signal-safe functions, but most user-facing APIs are not reentrant and should document that explicitly.
Thread safety also affects error reporting. If you store the last error in a global variable, concurrent calls will overwrite each other’s errors. A solution is to store error state in the handle (per-connection errors) or to use thread-local storage. The contract must specify which is used so that callers know whether errors are per-handle or global.
Designing thread-safe APIs requires careful decisions about which operations are allowed concurrently. For example, can a handle be used simultaneously for read and write? Can it be closed while another thread is using it? If not, you must enforce this with locks or reference counts. A well-designed API provides explicit functions such as handle_ref() and handle_unref() to manage concurrent lifetimes.
Finally, testing thread safety is crucial. Use stress tests with multiple threads, and use tools like ThreadSanitizer to catch races. Without explicit tests, thread safety bugs will appear only in production under load.
Thread safety is not binary; it has levels. An API may be thread-compatible (safe if different handles are used by different threads) but not thread-safe on a single handle. Another API may be fully thread-safe but not async-signal-safe. These distinctions should appear in the documentation. For example, POSIX documents which functions are async-signal-safe and which are not. If you design a library that may be used in signal handlers, you must avoid any non-reentrant functions, which is a very strict requirement.
Initialization is another boundary issue. Global initialization must be thread-safe to avoid double initialization. POSIX provides pthread_once, which ensures that initialization runs only once, even across threads. If your API requires global initialization, you should either perform it lazily with pthread_once or require the caller to call an explicit init function before any other use. The latter is simpler but increases the chance of misuse; the former is safer but requires careful design.
Callbacks introduce reentrancy hazards. Suppose a logging API calls a user-provided sink callback. If that callback calls back into the logger, you can deadlock or corrupt state. The contract should specify whether callbacks may reenter. A common defensive pattern is to avoid holding locks while calling user callbacks. This prevents deadlocks but requires careful design to avoid data races. Another pattern is to document that callbacks must not call back into the API, and enforce it with a thread-local reentrancy guard.
Thread-local error storage is another boundary decision. If you store the last error globally, concurrent calls will overwrite each other. Thread-local storage can fix this, but you must document that errors are per-thread. If you store errors per-handle, then you must ensure that handle access is synchronized or that the handle is not shared between threads without external locks. Again, the contract must be explicit.
Finally, thread safety affects performance. Locking every API call can become a bottleneck. Some libraries expose "_locked" and "_unlocked" variants or allow callers to provide their own locking. This increases complexity but gives performance-sensitive callers more control. For this guide, a simple per-handle mutex is sufficient, but understanding these tradeoffs is essential for real-world systems work.
How this Fits in Projects
- Project 2 can define per-handle thread safety.
- Project 4 logging can be thread-safe by design.
- Project 5 requires concurrency across network connections.
Definitions and Key Terms
- Thread-safe: Safe for concurrent use by multiple threads.
- Reentrant: Safe to re-enter before returning.
- Thread-local storage: Per-thread state storage.
Mental Model Diagram
Thread A -> API call -> lock handle -> work -> unlock
Thread B -> API call -> waits -> lock handle -> work
How It Works (Step-by-Step)
- Decide thread-safety policy (per-handle or global).
- Add synchronization primitives if needed.
- Document whether callbacks can call back into the API.
- Test with stress and sanitizers.
Minimal Concrete Example
struct kv_handle {
int fd;
pthread_mutex_t lock;
};
kv_status kv_set(kv_handle *h, const char *k, const char *v) {
pthread_mutex_lock(&h->lock);
// do work
pthread_mutex_unlock(&h->lock);
return KV_OK;
}
Common Misconceptions
- “If it compiles, it is thread-safe” -> Thread safety requires explicit design.
- “Mutexes fix everything” -> They can introduce deadlocks and reentrancy issues.
Check-Your-Understanding Questions
- Why is a static buffer not reentrant?
- How do you avoid error overwriting in multithreaded code?
Check-Your-Understanding Answers
- Concurrent calls overwrite the same buffer.
- Use per-handle error state or thread-local storage.
Real-World Applications
- Thread-safe logging libraries.
- Network clients with multiple parallel connections.
Where You Will Apply It
- Projects 2, 4, 5
References
- “The Linux Programming Interface” by Michael Kerrisk, Ch. 31
- “Rust Atomics and Locks” by Mara Bos (conceptual reference)
Key Insight
Thread safety is part of the API contract, not an optional feature.
Summary
Concurrency adds a new boundary: the boundary between threads. You must either enforce safety with locks or document that each thread needs its own handle.
Homework/Exercises
- Add a mutex to a handle-based API and test with two threads.
- Write a test that intentionally violates thread-safety guarantees and document the failure.
Solutions
- Use
pthread_mutex_tin the handle struct and lock around public calls. - Use ThreadSanitizer to detect the race condition.
Chapter 9: Observability Interfaces (Logging and Diagnostics)
Fundamentals
Logging is an interface boundary between your code and operators. It is part of the API contract because it defines what information can be observed, at what severity levels, and in what format. A good logging interface is configurable, thread-safe, and does not leak internal implementation details. It should allow integration with multiple sinks (stderr, files, syslog) without forcing callers to recompile.
Deep Dive into the Concept
In C, logging often starts as printf statements, but a real interface needs structure. The API should separate the act of formatting from the act of writing. A common pattern is a logger handle that owns configuration and one or more sinks. A sink might be a file, stderr, or a user-provided callback. If the logging interface uses varargs, you must document format string ownership and safety. You should also consider using vsnprintf to avoid buffer overflows.
Log levels are part of the contract. A simple enum such as DEBUG, INFO, WARN, ERROR is sufficient, but you should document the semantic meaning. For example, DEBUG is for developer diagnostics, INFO for normal operations, WARN for recoverable anomalies, and ERROR for failures. If you allow a caller to set a minimum log level, you should define whether that setting is global or per-handle.
Thread safety is crucial for logging. If multiple threads write to the same log file, you need synchronization or atomic append operations. A logger can either lock internally or require the caller to provide synchronization. In most cases, internal locking is safer because logging is an API boundary for all modules. But you must avoid deadlocks if logging is called inside a lock held by the caller. One mitigation is to design logging as “best effort”: if the logger detects a potential deadlock or fails to acquire a lock, it can drop the message and increment a counter.
Another design decision is whether logging is synchronous or asynchronous. Synchronous logging is simple but can slow down performance. Asynchronous logging requires a queue and background thread, which introduces complexity and shutdown ordering issues. If you choose async logging, you must document what happens on shutdown, whether logs can be lost, and how to flush the queue.
Diagnostics interfaces also include error retrieval functions. A common pattern is lib_get_last_error() returning a borrowed string. This is effectively a log sink for the most recent error. Ensure that the lifetime of the error string is clear (often bound to the handle). This interface allows callers to integrate errors into their own logging systems.
Finally, logging interfaces must avoid exposing internal pointers or confidential data. The contract should specify what is safe to log and what is not. In system libraries, logging is a side-channel. You should treat it as part of your security surface.
Structured logging is a modern best practice. Instead of free-form text, you log key-value pairs (for example, event=conn_timeout host=...). This makes logs machine-parseable and easier to search. A logging interface can support structured logging by offering a logger_log_kv() function or by accepting pre-formatted strings. The interface decision depends on whether you want the logging library to handle formatting or delegate it to the caller. For a small C library, a simple format string plus a few helper macros can provide enough structure without heavy dependencies.
Time handling is another boundary issue. Logging typically includes timestamps. Should you use UTC or local time? Should you include milliseconds? The contract should specify the format and time zone. ISO 8601 in UTC is a common choice because it is sortable and unambiguous. If you include timestamps in the logging library, you must handle time retrieval errors and be aware of performance costs.
Log rotation and retention matter in real systems. A logging library can support rotation based on size or time. This requires atomic file replacement and careful handling of open file descriptors. The API should define whether rotation happens automatically or whether the caller triggers it. If rotation is built in, you must document what happens to logs when rotation fails (for example, disk full). These are boundary decisions that determine whether the logging system fails open or fails closed.
Another aspect is backpressure. If the log sink is slow (for example, a networked sink), synchronous logging can block application threads. An asynchronous logging option can solve this but introduces queue management, drop policies, and shutdown semantics. A good interface lets the caller choose between sync and async behavior, and documents what happens when the queue is full (drop oldest, drop newest, or block).
Finally, privacy and security are part of the logging contract. Logging sensitive data can be a vulnerability. A robust API can support redaction or tagging of sensitive fields. Even if you do not implement redaction, the documentation should remind callers to avoid logging secrets. This is part of the boundary between code and operators, and it has real security implications.
How this Fits in Projects
- Project 4 builds a logging library with pluggable sinks.
- Project 5 integrates logging across network boundaries.
Definitions and Key Terms
- Log sink: A destination for log messages.
- Log level: Severity classification.
- Structured logging: Logs with machine-readable fields.
Mental Model Diagram
Caller -> log(level, msg) -> format -> sink(s)
| |
+-- file +-- stderr +-- callback
How It Works (Step-by-Step)
- Create a logger handle with configuration.
- Add one or more sinks.
- Format messages with timestamps and levels.
- Write to sinks with thread-safe locking.
Minimal Concrete Example
typedef struct logger logger;
logger* logger_create(void);
void logger_set_level(logger *l, int level);
void logger_add_sink(logger *l, logger_sink_fn fn, void *ctx);
void logger_log(logger *l, int level, const char *fmt, ...);
Common Misconceptions
- “printf is enough” -> Not for reusable libraries.
- “Logs are free” -> Logging can be a performance bottleneck.
Check-Your-Understanding Questions
- Why should logging be a separate module boundary?
- What risks exist if logging is not thread-safe?
Check-Your-Understanding Answers
- It allows consistent diagnostics and integration with external systems.
- Log output can interleave or corrupt, and races can crash the program.
Real-World Applications
- Syslog integration in system daemons.
- Structured logs for observability platforms.
Where You Will Apply It
- Project 4
- Project 5
References
- “Clean Code” by Robert C. Martin, Ch. 3 (principles applied to logging)
- “The Pragmatic Programmer” by Thomas and Hunt, Topic 28
Key Insight
Logging is not a side detail; it is part of the public contract of your system.
Summary
A logging interface must balance usability, performance, and safety. If it is poorly designed, it becomes a source of hidden failures and performance regressions.
Homework/Exercises
- Add a file sink and a stderr sink to a logger.
- Implement log rotation based on file size.
Solutions
- Use function pointers for sinks and write to multiple outputs.
- Check file size and rename old logs before continuing.
Glossary (High-Signal)
- API: Source-level interface (headers and documented semantics).
- ABI: Binary-level interface (symbols, calling conventions, layouts).
- Opaque type: Forward-declared struct that hides layout details.
- Handle: Pointer to an opaque type representing an object instance.
- Ownership: Responsibility for freeing a resource.
- Borrowed pointer: A pointer you do not own and must not free.
- Reentrancy: Ability to safely call a function again before it returns.
- Thread safety: Safe concurrent use by multiple threads.
- SONAME: Shared object name used by dynamic linker.
- Symbol visibility: Whether a symbol is exported from a shared object.
Why Boundaries and Interfaces Matter
The Modern Problem It Solves
Modern systems are assembled from many C libraries and modules. When interfaces are stable and clear, independent teams can evolve code without breaking each other. When interfaces are vague or unstable, changes cascade into outages and security bugs.
Real-world impact:
- Microsoft reports that around 70% of the vulnerabilities it assigns CVEs to are memory safety issues (2019), which often stem from boundary misuse: https://www.microsoft.com/en-us/msrc/blog/2019/07/a-proactive-approach-to-more-secure-code
- The Chromium project reports that around 70% of serious security bugs are memory safety problems (2023 update), highlighting how boundary contracts fail at scale: https://www.chromium.org/Home/chromium-security/memory-safety
- CISA’s Secure by Design guidance notes that memory safety issues remain a dominant class of vulnerabilities in memory-unsafe languages (2023): https://www.cisa.gov/case-memory-safe-roadmaps
Interface stability matters for real systems:
- libcurl has a published ABI stability policy to allow upgrades without breaking applications: https://curl.se/libcurl/abi.html
- SQLite marks interfaces as stable, experimental, or deprecated to preserve compatibility: https://www.sqlite.org/capi3ref.html
Weak boundary design Strong boundary design
+----------------------+ +----------------------+
| unclear ownership | | explicit ownership |
| exposed structs | | opaque types |
| ad-hoc errors | | consistent error |
| global state | | handle-based state |
+----------------------+ +----------------------+
Context and Evolution (Optional)
Early C libraries exposed structs and global state because simplicity mattered more than long-term stability. As systems grew and libraries became shared across many applications, ABI stability and encapsulation became critical. The evolution toward opaque types, versioning, and stable interfaces reflects that shift.
Concept Summary Table
| Concept Cluster | What You Need to Internalize |
|---|---|
| Translation Units and Linkage | Headers are text contracts; linkage defines boundary visibility. |
| Interface Contracts and Error Models | A clear error model is part of the API contract. |
| Ownership and Const Correctness | Ownership must be explicit and enforced by types. |
| Opaque Types and Encapsulation | Hiding layout protects ABI and prevents misuse. |
| ABI Stability and Versioning | Binary compatibility requires long-term planning. |
| Dynamic Loading and Plugins | Runtime boundaries demand versioned interfaces. |
| Data Exchange and Validation | Parsers must treat inputs as hostile. |
| Concurrency and Reentrancy | Thread safety is part of the API contract. |
| Observability Interfaces | Logging is a boundary between code and operators. |
Project-to-Concept Map
| Project | What It Builds | Primer Chapters It Uses |
|---|---|---|
| Project 1: Plugin System for a Simple Shell | Runtime plugin interface | 1, 4, 5, 6 |
| Project 2: Key-Value Store Client Library | Ownership-safe C client | 1, 2, 3, 5, 8 |
| Project 3: JSON Parser Library | Safe parsing boundary | 2, 3, 4, 7 |
| Project 4: Logging Library | Observability boundary | 2, 3, 8, 9 |
| Project 5: libhttp-lite | Integrated C interface | 1-9 |
Deep Dive Reading by Concept
Interface Design and Encapsulation
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Headers as contracts | “C Programming: A Modern Approach” by K. N. King, Ch. 15 | Large program structure and interfaces |
| Opaque types | “C Interfaces and Implementations” by David R. Hanson, Ch. 2 | Encapsulation in C |
| Linking and symbols | “Expert C Programming” by Peter van der Linden, Ch. 5 | Symbol visibility and linkage |
Ownership and Memory
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Memory ownership | “Effective C” by Robert C. Seacord, Ch. 6 | Avoid leaks and UAF |
| Pointer semantics | “Understanding and Using C Pointers” by Richard Reese, Ch. 4 | Ownership contracts |
Systems and ABI
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Shared libraries | “The Linux Programming Interface” by Michael Kerrisk, Ch. 41-42 | ABI and dynamic loading |
| Thread safety | “The Linux Programming Interface” by Michael Kerrisk, Ch. 31 | Concurrency contracts |
API Design and Error Models
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Defensive programming | “Code Complete” by Steve McConnell, Ch. 8 | Contract enforcement |
| Clean interfaces | “Clean Code” by Robert C. Martin, Ch. 3 | API clarity |
Parsing and Data Validation
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Parsing boundaries | “C Interfaces and Implementations” by David R. Hanson, Ch. 3 | Safe parsing patterns |
| Data structures | “Algorithms in C” by Robert Sedgewick, Parts 1-4 | Trees and recursion |
Concurrency and Reentrancy
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Thread safety | “The Linux Programming Interface” by Michael Kerrisk, Ch. 31 | Concurrency contracts |
Observability and Logging
| Concept | Book & Chapter | Why This Matters |
|---|---|---|
| Logging interfaces | “Clean Code” by Robert C. Martin, Ch. 3 | API design for diagnostics |
| Pragmatic boundaries | “The Pragmatic Programmer” by Thomas and Hunt, Topic 28 | Decoupling modules |
Quick Start: Your First 48 Hours
Day 1 (4 hours):
- Read Chapters 1-3 of the primer.
- Skim the public headers of libcurl or SQLite and note opaque types and naming conventions.
- Write a tiny C library with a
create/destroypair and a single function.
Day 2 (4 hours):
- Implement Project 1 minimal plugin loading (just load a single symbol).
- Add error reporting to the loader with
dlerror(). - Write a short reflection: what ownership rules are implicit in your API?
Recommended Learning Paths
Path 1: The Pragmatist (Fastest to Useful Skills)
- Project 2 -> Project 4 -> Project 5
Path 2: The Systems Explorer
- Project 1 -> Project 2 -> Project 3 -> Project 4 -> Project 5
Path 3: The Security-Minded Developer
- Project 3 -> Project 2 -> Project 4 -> Project 5
Path 4: The Completionist
Complete all projects in order and write a short postmortem on each interface redesign.
Success Metrics
- I can design a header that clearly encodes ownership rules.
- I can explain the difference between API and ABI with examples.
- I can load a plugin dynamically and validate its interface version.
- I can parse JSON safely with clear error reporting and no leaks.
- I can build a logging system that is thread-safe and configurable.
- I can evolve a library interface without breaking old users.
Project Overview Table
| Project | Focus | Difficulty | Time | Deliverable |
|---|---|---|---|---|
| Project 1 | Dynamic plugins | Intermediate | 1-2 weeks | Shell with loadable plugins |
| Project 2 | Ownership-safe API | Intermediate | 1-2 weeks | KV client library |
| Project 3 | Parsing boundary | Intermediate | 2 weeks | JSON parser library |
| Project 4 | Observability | Intermediate | 1 week | Logging library |
| Project 5 | Integration | Advanced | 3-4 weeks | libhttp-lite C library |
Project List
Project 1: Plugin System for a Simple Shell
- Main Programming Language: C
- Alternative Programming Languages: Rust (FFI), C++
- Coolness Level: Level 7 - extensible runtime system
- Business Potential: 6 - plugin architecture foundation
- Difficulty: Intermediate
- Knowledge Area: Dynamic loading, ABI, plugin interfaces
- Software or Tool: POSIX
dlopen/dlsym - Main Book: “The Linux Programming Interface” by Michael Kerrisk
What you’ll build: A mini shell that can load and unload external plugins at runtime. Each plugin registers commands via a versioned function table. The shell can list installed plugins, run plugin commands, and unload plugins cleanly.
Why it teaches boundaries and interfaces: Plugins are pure boundary work. You must define a stable ABI, version negotiation, symbol naming conventions, and lifecycle rules without compiler enforcement. Missteps cause crashes or silent bugs.
Core challenges you’ll face:
- Designing a stable plugin ABI that can evolve
- Validating plugin compatibility at runtime
- Managing plugin ownership and lifecycle safely
Real World Outcome
You will have a shell that can load plugins from a directory and expose new commands. You will also have a clear compatibility check and meaningful error messages when a plugin fails to load.
What you will see:
- A shell prompt that can list, load, and unload plugins.
- Plugin metadata (name, version, supported API version).
- Clear failure messages for incompatible or missing symbols.
Command Line Outcome Example:
$ ./mini-shell
mini> plugins
[0] builtin (core)
mini> load ./plugins/echo.so
Loaded plugin: echo (api v1)
mini> load ./plugins/bad.so
Error: plugin_get_api not found (missing symbol)
mini> load ./plugins/old.so
Error: incompatible plugin API (plugin=0, host=1)
mini> plugins
[0] builtin (core)
[1] echo (api v1)
mini> echo hello world
hello world
mini> unload echo
Unloaded plugin: echo
mini> plugins
[0] builtin (core)
The Core Question You’re Answering
“How do you design a runtime interface that is safe even when you do not control the code on the other side?”
Concepts You Must Understand First
- Dynamic loading (
dlopen,dlsym,dlclose)- How does
dlopenload symbols into a process? - What does
dlerrorreturn and when? - What is the difference between
RTLD_NOWandRTLD_LAZY? - Book Reference: “The Linux Programming Interface” Ch. 41-42
- How does
- Function tables and ABI evolution
- Why is a struct of function pointers more stable than raw symbol lookup?
- How do
versionandsizefields enable backward compatibility? - Book Reference: “C Interfaces and Implementations” Ch. 2
- Symbol visibility and namespacing
- Why should plugin entry points be uniquely named?
- How do visibility flags reduce accidental ABI exposure?
- Book Reference: “Expert C Programming” Ch. 5
- Ownership and lifecycle
- Who allocates plugin state and who frees it?
- What happens if
init()fails partway? - Book Reference: “Effective C” Ch. 6
Questions to Guide Your Design
- Compatibility checks
- Will you compare a version number, a struct size, or both?
- What happens if the plugin is older than the host?
- Lifecycle boundaries
- When is
init()called and what must it return on failure? - Can
shutdown()be called ifinit()failed?
- When is
- Threading and reentrancy
- Can plugins be called concurrently?
- Are plugin callbacks allowed to call host APIs?
- Error reporting
- Do you expose
dlerror()directly or wrap it? - Will the host store plugin-specific error messages?
- Do you expose
Thinking Exercise
Trace a plugin lifecycle
load -> init -> register -> run -> unregister -> shutdown
Questions while tracing:
- When is plugin state allocated, and who owns it?
- When is it safe to unload the plugin if commands are still registered?
- What happens if
init()succeeds butregister()fails?
The Interview Questions They’ll Ask
- Why use a function table instead of calling symbols directly?
- How do you prevent ABI breakage in plugins?
- What happens if a plugin crashes during a callback?
- How would you version and extend the API without breaking old plugins?
- What does
RTLD_LOCALvsRTLD_GLOBALchange? - How do you handle plugin unloading when commands are still active?
Hints in Layers
Hint 1: Start with a single entry point
plugin_api* plugin_get_api(void);
Hint 2: Add version and size for forward compatibility
typedef struct {
int version;
size_t size;
int (*init)(void);
int (*run)(const char *cmd);
void (*shutdown)(void);
} plugin_api;
Hint 3: Validate before calling
if (api->version != PLUGIN_API_VERSION || api->size < sizeof(plugin_api)) {
// reject
}
Hint 4: Debug with symbols
nm -D ./plugins/echo.so | grep plugin_get_api
ldd ./plugins/echo.so
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Dynamic loading | “The Linux Programming Interface” | Ch. 41-42 |
| Encapsulation | “C Interfaces and Implementations” | Ch. 2 |
| ABI design | “Expert C Programming” | Ch. 5 |
| Defensive programming | “Code Complete” | Ch. 8 |
| C interface patterns | “C Interfaces and Implementations” | Ch. 1 |
Common Pitfalls & Debugging
Problem: “Plugin loads but crashes on first call”
- Why: Function table layout mismatch or wrong calling convention.
- Fix: Add version checks and compile both host and plugin with same headers.
- Quick test: Print the version and size of the struct from both sides.
Problem: “dlopen fails”
- Why: Wrong path or missing dependencies.
- Fix: Use
lddon the plugin and checkdlerror()output.
Problem: “Plugin command persists after unload”
- Why: Host did not unregister commands or kept stale function pointers.
- Fix: Ensure
unregisterremoves all command references beforedlclose. - Quick test: Unload and run the command again; it should fail cleanly.
Problem: “Two plugins conflict”
- Why: Global symbol collision due to
RTLD_GLOBAL. - Fix: Use
RTLD_LOCALand namespace plugin symbols.
Definition of Done
- Plugin ABI has a version field and compatibility check
- Host can load and unload plugins without crashes
- Plugin API functions are accessed only through the function table
- Errors are reported clearly with
dlerror() - Incompatible plugins fail with a descriptive error
- Unload removes all commands and frees plugin state
Project 2: Key-Value Store Client Library
- Main Programming Language: C
- Alternative Programming Languages: Rust, Go (FFI)
- Coolness Level: Level 6 - clean network API
- Business Potential: 7 - reusable client library
- Difficulty: Intermediate
- Knowledge Area: Ownership, error models, network boundaries
- Software or Tool: Sockets, Redis or custom TCP server
- Main Book: “The Linux Programming Interface” by Michael Kerrisk
What you’ll build: A C client library for a key-value server with explicit ownership semantics and a stable API.
Why it teaches boundaries and interfaces: Networking APIs are boundary-rich: ownership, error handling, and concurrency must be explicit and safe.
Real World Outcome
What you will see:
- A clean API with explicit ownership rules.
- Clear error messages when the server is unreachable.
- No leaks under
valgrind.
Command Line Outcome Example:
$ ./demo
Connected to 127.0.0.1:6379
SET user:1 -> OK
GET user:1 -> "Alice"
GET missing -> (not found)
Failure case:
$ ./demo --port 9999
Error: Connection refused (127.0.0.1:9999)
Memory safety check:
$ valgrind --leak-check=full ./demo
== HEAP SUMMARY:
== All heap blocks were freed -- no leaks are possible
Minimal API surface (header excerpt):
typedef struct kv_handle kv_handle;
typedef enum { KV_OK=0, KV_ERR_CONN=-1, KV_ERR_PROTO=-2, KV_ERR_MEM=-3 } kv_status;
kv_handle* kv_connect(const char *host, int port);
void kv_disconnect(kv_handle *h);
kv_status kv_set(kv_handle *h, const char *key, const char *value);
char* kv_get(kv_handle *h, const char *key); // caller owns, free with kv_free_string()
void kv_free_string(char *s);
const char* kv_last_error(kv_handle *h); // borrowed, do not free
The Core Question You’re Answering
“When a function returns a pointer, how do you make ownership unambiguous?”
Concepts You Must Understand First
- Ownership and const correctness
- Why is
const char*a borrowed pointer? - How do you signal ownership in a function name?
- Book Reference: “Effective C” Ch. 6
- Why is
- Socket lifecycle
- What is the sequence:
socket->connect->send->recv->close? - How do you handle partial reads and writes?
- Book Reference: “The Linux Programming Interface” Ch. 56-59
- What is the sequence:
- Error models
- Should
kv_getreturn NULL for "not found" or only for errors? - How do you represent error details for callers?
- Book Reference: “Fluent C” Ch. 5
- Should
- Thread safety
- Is the handle safe across threads?
- If not, how do you document and enforce it?
- Book Reference: “The Linux Programming Interface” Ch. 31
Questions to Guide Your Design
- Ownership
- Will
kv_getallocate new memory or return a borrowed buffer? - If allocated, will you provide
kv_free_stringor requirefree?
- Will
- Error handling
- How will you distinguish "key not found" from network failure?
- Will you expose
kv_last_error()or return error strings directly?
- Concurrency
- Are handles thread-safe or thread-compatible only?
- Do you allow concurrent
getandseton the same handle?
Thinking Exercise
Trace ownership across this call sequence and draw the heap state:
kv_handle *db = kv_connect("localhost", 6379);
char *v1 = kv_get(db, "a");
char *v2 = kv_get(db, "b");
kv_free_string(v1);
kv_disconnect(db);
printf("%s\n", v2);
Questions:
- What happens to
v2afterkv_disconnect? - Should
kv_disconnectinvalidate outstanding strings?
The Interview Questions They’ll Ask
- Why use an opaque handle for the connection?
- How do you avoid use-after-free in results?
- How would you make the client thread-safe?
- How do you distinguish errors from missing keys?
- Why return a
char*instead of a struct result?
Hints in Layers
Hint 1: Use an opaque handle
typedef struct kv_handle kv_handle;
Hint 2: Provide explicit free
char* kv_get(kv_handle *h, const char *key);
void kv_free_string(char *s);
Hint 3: Add error accessors
const char* kv_last_error(kv_handle *h); // borrowed
Hint 4: Use timeouts
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, ...);
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Ownership | “Effective C” | Ch. 6 |
| Sockets | “The Linux Programming Interface” | Ch. 56-59 |
| Error handling | “Fluent C” | Ch. 5 |
| Interface design | “C Interfaces and Implementations” | Ch. 1-2 |
| Defensive programming | “Code Complete” | Ch. 8 |
Common Pitfalls & Debugging
Problem: “Memory leaks in kv_get”
- Why: Caller forgot to free returned data.
- Fix: Document ownership and provide
kv_free_string.
Problem: “GET returns NULL but no error”
- Why: NULL used for both "not found" and error.
- Fix: Return a status code or provide
kv_last_error.
Problem: “Hangs on recv()”
- Why: No timeout set and server does not respond.
- Fix: Set
SO_RCVTIMEOor useselect/poll.
Problem: “Corrupted results after second GET”
- Why: Reused internal buffer instead of allocating per call.
- Fix: Allocate a new buffer for each result.
Definition of Done
- API clearly documents ownership of returned data
- Error model is consistent and tested
- Valgrind shows no leaks in the demo
- Missing keys are distinguished from connection errors
- Thread-safety policy is documented
- Timeouts prevent blocking forever
Project 3: JSON Parser Library
- Main Programming Language: C
- Alternative Programming Languages: Rust, Zig
- Coolness Level: Level 7 - real parsing boundary
- Business Potential: 5 - embedded parser
- Difficulty: Intermediate
- Knowledge Area: Parsing, error models, ownership
- Software or Tool: RFC 8259 JSON
- Main Book: “C Interfaces and Implementations” by David Hanson
What you’ll build: A JSON parser library that returns an opaque JSON tree with precise error reporting and explicit ownership rules.
Real World Outcome
What you will see:
- Clear parse success with node counts and types.
- Actionable error messages with line/column.
- No leaks after freeing the JSON tree.
$ ./json-parse ./sample.json
OK: parsed 12 nodes
root.type = object
root["user"].name = "Alice"
$ ./json-parse ./broken.json
ERROR line 3 col 18: expected '}' but found ']'
Library usage example:
json_error err;
json_value *root = json_parse(text, &err);
if (!root) {
fprintf(stderr, "Parse error at %d:%d: %s\n", err.line, err.col, err.message);
return 1;
}
const char *name = json_get_string(root, "/user/name"); // borrowed
printf("user.name=%s\n", name);
json_free(root);
The Core Question You’re Answering
“How do you design a parser interface that is safe for hostile input?”
Concepts You Must Understand First
- Parsing boundaries
- What makes JSON parsing tricky in C?
- How do you handle escape sequences safely?
- Book Reference: “C Interfaces and Implementations” Ch. 3
- Ownership and const correctness
- Should
json_get_string()return owned or borrowed memory? - Book Reference: “Effective C” Ch. 6
- Should
- Error models
- What does a good parse error include (line, column, code)?
- Book Reference: “Fluent C” Ch. 5
- Depth and size limits
- How do you prevent stack overflow and OOM?
- Book Reference: “Code Complete” Ch. 8
Questions to Guide Your Design
- Who owns the returned JSON tree and its strings?
- Will you store strings as slices into the input or copies?
- How will parse errors be reported and recovered?
- What are your default depth and size limits?
Thinking Exercise
Write a JSON string that breaks naive parsers (deep nesting, invalid escapes, huge numbers). What should your parser do?
Example:
{"a":[[[[[[[[[["x"]]]]]]]]]}
Questions:
- Should this be accepted? If not, why?
- How do you report the exact location of failure?
The Interview Questions They’ll Ask
- Why use an opaque JSON value handle?
- How do you prevent stack overflow with deep nesting?
- What error details are most useful to callers?
- How do you represent numbers to avoid precision loss?
- What is the difference between returning slices vs copies?
Hints in Layers
Hint 1: Start with a lexer
typedef enum { TOK_LBRACE, TOK_STRING, TOK_NUMBER, TOK_TRUE, TOK_FALSE, TOK_NULL } tok_t;
Hint 2: Use recursive descent with depth tracking
json_value* parse_value(lexer *lx, int depth);
if (depth > MAX_DEPTH) { set_error(err, "depth limit"); return NULL; }
Hint 3: Return a parse error struct
typedef struct { int line, col; int code; char message[128]; } json_error;
Hint 4: Debug with test vectors
./json-parse tests/valid.json
./json-parse tests/invalid.json
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Parsing design | “C Interfaces and Implementations” | Ch. 3 |
| Memory management | “Effective C” | Ch. 6 |
| Error handling | “Fluent C” | Ch. 5 |
| Defensive programming | “Code Complete” | Ch. 8 |
| Data structures | “Algorithms in C” | Parts 1-4 (trees) |
Common Pitfalls & Debugging
Problem: “Parser crashes on deep input”
- Why: Recursion depth unbounded.
- Fix: Add a maximum depth limit.
Problem: “Strings are corrupted”
- Why: Returned slices into a freed input buffer.
- Fix: Copy strings or require the caller to keep the input alive.
Problem: “Numbers lose precision”
- Why: All numbers parsed as
double. - Fix: Store numbers as strings or use 64-bit integers when possible.
Problem: “Error line/column incorrect”
- Why: Lexer does not track newline positions correctly.
- Fix: Increment line/col in the lexer on every character read.
Definition of Done
- Parser handles valid JSON from RFC 8259
- Errors include line and column
- Ownership rules are explicit
- Depth and size limits are enforced
- No leaks after parsing and freeing
- Invalid JSON returns a clear error code
Project 4: Logging Library
- Main Programming Language: C
- Alternative Programming Languages: Rust, Go
- Coolness Level: Level 6 - production-grade diagnostics
- Business Potential: 6 - reusable logging module
- Difficulty: Intermediate
- Knowledge Area: Observability, thread safety, interfaces
- Software or Tool: File I/O, time formatting
- Main Book: “Clean Code” by Robert C. Martin
What you’ll build: A logging library with configurable log levels and pluggable sinks (stderr, file, callback).
Real World Outcome
What you will see:
- Logs written to stderr and a log file.
- Configurable log level filtering.
- Optional JSON or logfmt output.
$ ./log-demo
2026-01-01T12:00:00Z INFO server started port=8080
2026-01-01T12:00:01Z WARN cache miss key=user:1
2026-01-01T12:00:02Z ERROR db timeout=3000ms
File output example:
$ tail -n 2 ./logs/app.log
2026-01-01T12:00:01Z WARN cache miss key=user:1
2026-01-01T12:00:02Z ERROR db timeout=3000ms
JSON output example (optional):
{"ts":"2026-01-01T12:00:02Z","level":"ERROR","msg":"db timeout","timeout_ms":3000}
The Core Question You’re Answering
“How do you design a logging interface that is safe, fast, and reusable?”
Concepts You Must Understand First
- Observability interfaces
- What is a log sink and how do you add one?
- What makes a log line machine-parseable?
- Book Reference: “Clean Code” Ch. 3
- Thread safety
- Should logging be safe from multiple threads?
- Book Reference: “The Linux Programming Interface” Ch. 31
- Ownership of buffers
- Who owns formatted log strings?
- Book Reference: “Effective C” Ch. 6
- Error models
- What happens when a sink fails to write?
- Book Reference: “Fluent C” Ch. 5
Questions to Guide Your Design
- Will logging be synchronous or async? If async, how will you flush on shutdown?
- How will you format safely (fixed buffer, dynamic buffer, or callback)?
- How will you handle multiple sinks (array of sinks, linked list, or vtable)?
- Will log levels be global or per-logger handle?
Thinking Exercise
Design a log line format that is both human-readable and machine-parseable.
Example:
2026-01-01T12:00:02Z ERROR msg="db timeout" timeout_ms=3000
Questions:
- How would you escape quotes in the message?
- How do you add new fields without breaking parsers?
The Interview Questions They’ll Ask
- Why use a logger handle instead of global state?
- How do you make logging thread-safe?
- How do you avoid performance bottlenecks?
- How would you implement log rotation safely?
- How do you avoid deadlocks if a sink callback logs again?
Hints in Layers
Hint 1: Define log levels as an enum
typedef enum { LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR } log_level;
Hint 2: Use vsnprintf for formatting
char buf[1024];
vsnprintf(buf, sizeof(buf), fmt, ap);
Hint 3: Add a sink callback interface
typedef void (*log_sink_fn)(void *ctx, const char *msg);
Hint 4: Guard with a mutex
pthread_mutex_lock(&logger->lock);
// write to sinks
pthread_mutex_unlock(&logger->lock);
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Defensive programming | “Code Complete” | Ch. 8 |
| API design | “Clean Code” | Ch. 3 |
| Concurrency | “The Linux Programming Interface” | Ch. 31 |
| Memory management | “Effective C” | Ch. 6 |
| Pragmatic interfaces | “The Pragmatic Programmer” | Topic 28 |
Common Pitfalls & Debugging
Problem: “Log output is interleaved”
- Why: No synchronization across threads.
- Fix: Add a mutex in the logger handle.
Problem: “Log file grows without limit”
- Why: No rotation or retention policy.
- Fix: Implement size-based rotation and keep N files.
Problem: “Logging deadlocks”
- Why: Callback calls logger while lock is held.
- Fix: Release lock before calling user callbacks or add reentrancy guard.
Problem: “Slow logging stalls app”
- Why: Synchronous logging to slow sink.
- Fix: Add async queue or allow dropping logs at high load.
Definition of Done
- Supports at least two sinks (stderr + file)
- Log levels are configurable
- Thread-safe logging works under stress
- Format is consistent and machine-parseable
- Log rotation works without data loss
- Errors in sinks are reported clearly
Project 5: libhttp-lite (Integrated Boundary System)
- Main Programming Language: C
- Alternative Programming Languages: Rust, Zig
- Coolness Level: Level 8 - end-to-end integration
- Business Potential: 7 - embedded HTTP library
- Difficulty: Advanced
- Knowledge Area: ABI stability, APIs, parsing, logging
- Software or Tool: Sockets, HTTP, JSON
- Main Book: “The Linux Programming Interface” by Michael Kerrisk
What you’ll build: A minimal HTTP server/client library with a stable C API. It integrates the logging system, uses JSON parsing for request bodies, and supports a plugin-style middleware interface.
Real World Outcome
$ ./httpd --port 8080
[INFO] listening on 0.0.0.0:8080
$ curl -X POST http://localhost:8080/echo -d '{"msg":"hello"}'
{"msg":"hello"}
Failure case:
$ curl http://localhost:8080/unknown
{"error":"not found","code":404}
Library usage example:
http_server *srv = http_server_create(8080);
http_server_set_logger(srv, logger);
http_server_add_route(srv, "/echo", echo_handler);
http_server_run(srv);
The Core Question You’re Answering
“How do you design an API that integrates multiple boundaries without breaking compatibility?”
Concepts You Must Understand First
- ABI stability and versioning
- How do you add features without changing existing function signatures?
- Why are opaque request/response handles safer than public structs?
- Book Reference: “Expert C Programming” Ch. 5
- Ownership and error models
- Who owns request bodies and response buffers?
- How do you report parse errors without returning ambiguous NULLs?
- Book Reference: “Effective C” Ch. 6 and “Fluent C” Ch. 5
- Thread safety
- Can one server handle be used from multiple threads?
- What happens if a connection is closed while in use?
- Book Reference: “The Linux Programming Interface” Ch. 31
- Parsing boundaries
- How do you handle partial reads of headers and bodies?
- How do you enforce request size limits?
- Book Reference: “The Linux Programming Interface” Ch. 56-59
- Observability interfaces
- How do you inject logging without global state?
- Book Reference: “Clean Code” Ch. 3
Questions to Guide Your Design
- How will you keep the API stable over time?
- How will you represent HTTP requests and responses safely?
- How will you expose logging without leaking internals?
- How will you enforce size limits on request bodies?
- How will you handle concurrent connections?
Thinking Exercise
Design a http_request struct that can be safely extended without ABI breakage.
Questions:
- Should
http_requestbe opaque? If so, how do you expose fields? - If it is not opaque, how do you prevent ABI breakage when you add fields?
The Interview Questions They’ll Ask
- How do you preserve ABI stability across releases?
- Why should HTTP request structs be opaque?
- How would you add TLS later without breaking users?
- How do you handle partial reads of HTTP headers?
- How do you keep memory ownership clear for request bodies?
Hints in Layers
Hint 1: Use opaque request and response handles
Hint 2: Provide versioned API functions
Hint 3: Keep public structs minimal and extend via accessors
Hint 4: Enforce limits
http_server_set_max_body(srv, 1 * 1024 * 1024);
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Sockets and networking | “The Linux Programming Interface” | Ch. 56-59 |
| API design | “C Interfaces and Implementations” | Ch. 1-2 |
| ABI stability | “Expert C Programming” | Ch. 5 |
| Error handling | “Fluent C” | Ch. 5 |
| Defensive programming | “Code Complete” | Ch. 8 |
Common Pitfalls & Debugging
Problem: “Old clients crash after a library update”
- Why: ABI break due to struct layout change or removed symbol.
- Fix: Use opaque types and add new functions instead of changing old ones.
Problem: “HTTP parser accepts invalid requests”
- Why: No strict validation of headers and line endings.
- Fix: Implement strict RFC-compliant parsing and reject invalid lines.
Problem: “Memory leaks on connection close”
- Why: Request buffers not freed on error paths.
- Fix: Centralize cleanup in a single
cleanuppath.
Problem: “Logging causes performance regressions”
- Why: Synchronous logging in hot path.
- Fix: Use async logging or allow logging to be disabled per route.
Definition of Done
- API uses opaque handles and stable function signatures
- HTTP parsing returns clear error codes
- Logging is integrated without global state
- Simple client and server demos run without leaks
- Request size limits and timeouts are enforced
- ABI stability policy is documented