<< Home

Chapter 19: Advanced Topics

This chapter gathers a set of advanced techniques that push C closer to the hardware and to the operating system. You will be shaping memory at the bit level, invoking compiler features that produce faster code, coordinating threads with shared state, and interfacing with devices in constrained environments. Each section provides focused guidance, compact examples, and practical cautions for production work.

Bitfields and low-level data structures

Bitfields allow you to map individual bits or small ranges of bits inside a storage unit such as an unsigned int. This can mirror hardware registers or compact binary protocols. The layout is sensitive to implementation choices; you should treat bitfield order and packing as compiler defined unless you control all build variables.

Defining and packing bitfields safely

A bitfield is declared inside a struct with a colon and a width in bits. Choose an explicit underlying type to communicate intent. Avoid mixing signed and unsigned fields inside the same unit unless you truly need signed interpretation.

typedef struct {
  unsigned mode : 3;    /* values 0..7 */
  unsigned ready : 1;   /* boolean flag */
  unsigned reserved : 4;  /* must be zero */
  unsigned payload : 8;   /* small value */
} Control;

Compilers may insert padding between units to satisfy alignment. If you need a fixed binary representation for I/O, prefer manual masking with integers rather than relying on compiler packing.

Reading and writing bits with masks

Masking with shifts is portable and predictable. It is ideal when you exchange bytes with files, sockets, or hardware ports.

/* pack fields into one byte */
unsigned char pack_fields(unsigned mode, unsigned ready) {
  unsigned char b = 0;
  b |= (mode & 0x7u) << 5;  /* top 3 bits */
  b |= (ready & 0x1u) << 4; /* next bit */
  /* remaining 4 bits left as zero */
  return b;
}

/* extract the same fields */
void unpack_fields(unsigned char b, unsigned *mode, unsigned *ready) {
  *mode = (b >> 5) & 0x7u;
  *ready = (b >> 4) & 0x1u;
}
💡 When binary layout must match a specification, write tests that serialize structures to bytes, then compare against known vectors. This guards against silent layout changes when a compiler flag or target changes.

Modeling protocols and registers clearly

For device registers, make the mapping explicit with integral types plus named constants. This improves clarity and cross-compiler stability.

#define CTRL_MODE_MASK   0xE0u
#define CTRL_READY_MASK  0x10u

static inline unsigned ctrl_get_mode(unsigned char b) {
  return (b & CTRL_MODE_MASK) >> 5;
}

static inline int ctrl_is_ready(unsigned char b) {
  return (b & CTRL_READY_MASK) != 0u;
}
⚠️ Endianness affects how multi-byte integers travel over wires or sit in memory. Bit numbering inside a byte is consistent for masks; byte ordering across addresses differs by architecture. Always specify network byte order when serializing.

Inline assembly and compiler intrinsics

Inline assembly and intrinsics allow a program to use specific instructions or memory barriers without leaving C. Intrinsics expose selected instructions as ordinary functions; inline assembly grants full control at the cost of portability. Start with intrinsics; reach for inline assembly when there is no intrinsic and you can constrain the target set.

Using intrinsics for performance and clarity

Modern compilers provide built-ins for bit scans, population counts, byte swaps, and atomic operations. These map to single instructions on many targets and degrade gracefully where support is absent.

#include <stdint.h>

/* examples that many compilers support */
int ones = __builtin_popcount(0xF0F0F0F0u);
int leadz = __builtin_clz(0x00100000u);
uint32_t swapped = __builtin_bswap32(0x11223344u);

/* branch prediction hints */
if (__builtin_expect(ones > 16, 0)) {
  /* unlikely path */
}

Intrinsics are preferable when available because they participate in optimization and register allocation. They also communicate intent to readers.

Writing minimal inline assembly blocks

Inline assembly varies by compiler. The following shows a small example that reads the time-stamp counter on x86 using GNU C syntax. Constraints and clobbers describe how assembly interacts with C variables and registers.

static inline unsigned long long rdtsc(void) {
  unsigned int lo, hi;
  __asm__ volatile ("rdtsc" : "=a"(lo), "=d"(hi) : : "memory");
  return ((unsigned long long)hi << 32) | lo;
}

Mark blocks as volatile when the compiler must not reorder around them. Declare all clobbered registers and memory effects to avoid miscompilation.

⚠️ Inline assembly ties your code to a toolchain and an instruction set. Provide a pure C fallback or an intrinsic alternative for other platforms, and isolate assembly in small translation units to reduce maintenance.

Guarding with feature checks and fallbacks

Use conditional compilation with predefined macros to select intrinsics or assembly according to the target. Provide a readable and correct fallback implementation.

#if defined(__has_builtin)
#  if __has_builtin(__builtin_bswap32)
#  define HAVE_BSWAP 1
#  endif
#endif

static inline uint32_t my_bswap32(uint32_t x) {
#if defined(HAVE_BSWAP) || defined(__GNUC__)
  return __builtin_bswap32(x);
#else
  return ((x & 0x000000FFu) << 24) |
         ((x & 0x0000FF00u) <<  8) |
         ((x & 0x00FF0000u) >>  8) |
         ((x & 0xFF000000u) >> 24);
#endif
}
💡 Keep assembly and intrinsic helpers in a dedicated header and source pair. This separates target concerns from business logic and makes testing simpler.

Threading and concurrency (POSIX threads overview)

Threads allow a program to perform multiple activities within one process. The POSIX threads API, known as pthreads, provides primitives for thread creation, mutual exclusion, condition waiting, and thread local storage. Careful design avoids data races and deadlocks while delivering scalability.

Creating threads and joining them

The basic lifecycle consists of creating a thread with a function pointer, passing a context pointer, and joining to collect completion. Return values travel back through pthread_join or through shared state guarded by a mutex.

#include <pthread.h>
#include <stdio.h>

void *worker(void *arg) {
  int id = *(int *)arg;
  printf("thread %d running\n", id);
  return NULL;
}

int main(void) {
  pthread_t t1, t2;
  int a = 1, b = 2;
  pthread_create(&t1, NULL, worker, &a);
  pthread_create(&t2, NULL, worker, &b);
  pthread_join(t1, NULL);
  pthread_join(t2, NULL);
  return 0;
}

Sharing data with mutexes and condition variables

Use a mutex to guard shared data; use a condition variable to wait until a predicate becomes true. Always check the predicate inside a loop since signals may be delivered spuriously.

#include <pthread.h>

typedef struct {
  pthread_mutex_t mu;
  pthread_cond_t  cv;
  int ready;
} Gate;

void gate_init(Gate *g) {
  pthread_mutex_init(&g->mu, NULL);
  pthread_cond_init(&g->cv, NULL);
  g->ready = 0;
}

void gate_wait(Gate *g) {
  pthread_mutex_lock(&g->mu);
  while (!g->ready) {
  pthread_cond_wait(&g->cv, &g->mu);
  }
  pthread_mutex_unlock(&g->mu);
}

void gate_open(Gate *g) {
  pthread_mutex_lock(&g->mu);
  g->ready = 1;
  pthread_cond_broadcast(&g->cv);
  pthread_mutex_unlock(&g->mu);
}
💡 Keep a clear invariant that describes when shared data is valid. Every function that acquires the mutex should re-establish or rely on the same invariant, which makes proofs and reviews straightforward.

Avoiding deadlocks and data races

Adopt a single global lock ordering for multi-mutex operations. Prefer fine-grained locks only when profiling shows contention. Use thread local storage for immutable per-thread data and atomic variables for small counters that many threads update.

#include <stdatomic.h>

_Atomic unsigned long tasks_done = 0;

void mark_done(void) {
  atomic_fetch_add_explicit(&tasks_done, 1, memory_order_relaxed);
}
⚠️ Busy waiting wastes CPU time. Always prefer a condition variable or a semaphore rather than spinning, unless you are in a very short critical path where sleeping would cost more than spinning.

Working with hardware and embedded systems

Embedded programming constrains memory, timing, and power. You write code that touches memory-mapped registers, controls interrupts, and cooperates with small real-time kernels or bare-metal loops. Determinism, clarity, and measured use of resources guide every design choice.

Accessing memory-mapped I/O registers

Hardware registers appear at fixed addresses. Use volatile to prevent the compiler from optimizing away required reads or writes. Prefer descriptive names and wrap addresses behind small inline helpers.

#include <stdint.h>

#define UART0_BASE     ((uintptr_t)0x40000000u)
#define UART_DR        (*(volatile uint32_t *)(UART0_BASE + 0x00u))
#define UART_SR        (*(volatile uint32_t *)(UART0_BASE + 0x04u))
#define UART_TX_READY  (1u << 5)

static inline void uart_putc(char c) {
  while ((UART_SR & UART_TX_READY) == 0u) { /* wait */ }
  UART_DR = (uint32_t)(unsigned char)c;
}

Only apply volatile to the object representing the device register. Keep regular variables non-volatile so the optimizer can still improve surrounding code.

Structuring interrupt-safe code paths

Interrupt handlers should be small and predictable. Move heavy work into a deferred context such as a task or a work queue. Share data using lock-free ring buffers or flags that the main loop polls, while guarding multi-byte updates with simple critical sections.

/* pseudo-code; platform glue elided … */
volatile unsigned char rx_buf[256];
volatile unsigned int rx_head = 0, rx_tail = 0;

void isr_uart_rx(void) {
  unsigned char b = (unsigned char)UART_DR;
  rx_buf[rx_head++ & 255u] = b;  /* trivial ring buffer */
}
💡 Keep interrupt latency short by avoiding function calls and by touching only registers and small buffers. Measure worst-case paths with a logic analyzer or a cycle counter to confirm timing margins.

Managing resources in constrained builds

Constrained targets reward simple allocators and static storage. Prefer fixed-size pools, compile-time configuration, and careful logging that can be disabled. Validate all error returns from drivers; transient faults are normal in real hardware.

#ifndef LOG_LEVEL
#define LOG_LEVEL 1  /* 0=off, 1=errors, 2=info */
#endif

#if LOG_LEVEL >= 1
#define LOGE(msg) do { uart_putc('!'); /* write msg … */ } while (0)
#else
#define LOGE(msg) do { } while (0)
#endif
⚠️ Undefined behavior is more dangerous on bare metal since there is no process isolation. Treat compiler warnings as errors and keep a small suite of assertions that can remain enabled in shipping builds if cost permits.

These advanced techniques let you shape bits, guide the compiler, coordinate threads, and tame embedded devices. Use them with measured intent and with tests that prove behavior across compilers and targets.