Skip to content

Latest commit

 

History

History
1626 lines (1294 loc) · 49.9 KB

File metadata and controls

1626 lines (1294 loc) · 49.9 KB

C Language Fundamentals for Embedded Systems

Essential C programming concepts for embedded software development

📋 Table of Contents


🎯 Overview

C is the primary programming language for embedded systems due to its:

  • Direct hardware access - Ability to manipulate memory addresses and registers
  • Efficiency - Minimal runtime overhead and predictable performance
  • Portability - Works across different microcontrollers and architectures
  • Mature ecosystem - Extensive toolchains, libraries, and community support

Key Characteristics for Embedded Development

  • Static typing - Compile-time type checking
  • Manual memory management - Direct control over memory allocation
  • Low-level access - Pointer arithmetic and bit manipulation
  • Procedural programming - Function-based code organization

Interviewer intent (what they’re probing)

  • Can you explain why C remains dominant in embedded work?
  • Do you understand trade‑offs: safety vs control vs performance?
  • Can you connect language features to hardware behavior?

🤔 What is C Programming?

C is a general-purpose programming language developed in the 1970s by Dennis Ritchie at Bell Labs. It was designed to be a simple, efficient language that provides low-level access to memory while maintaining portability across different computer architectures.

Core Philosophy

  1. Simplicity: C provides a minimal set of features that can be easily understood
  2. Efficiency: C code can be compiled to efficient machine code with minimal overhead
  3. Portability: C programs can be compiled for different architectures with minimal changes
  4. Low-level Access: C provides direct access to memory and hardware features

Language Characteristics

Strengths:

  • Performance: Close to assembly language efficiency
  • Control: Direct memory and hardware access
  • Portability: Works across different platforms
  • Maturity: Well-established language with extensive tooling

Limitations:

  • Safety: No built-in memory safety or bounds checking
  • Complexity: Manual memory management can be error-prone
  • Abstraction: Limited high-level abstractions
  • Debugging: Runtime errors can be difficult to debug

C vs. Other Languages

Language Comparison for Embedded Systems:

┌─────────────────┬─────────────┬─────────────┬─────────────────┐
│   Language      │ Performance │ Safety      │ Learning Curve  │
├─────────────────┼─────────────┼─────────────┼─────────────────┤
│   C             │    High     │    Low      │    Medium       │
│   C++           │    High     │   Medium    │     High        │
│   Rust          │    High     │    High     │     High        │
│   Python        │     Low     │    High     │     Low         │
│   Assembly      │   Highest   │    Low      │     High        │
└─────────────────┴─────────────┴─────────────┴─────────────────┘

🎯 Why C for Embedded Systems?

Historical Reasons

C became the dominant language for embedded systems due to several historical factors:

  1. Unix Heritage: C was developed alongside Unix, which influenced early embedded systems
  2. Compiler Technology: C compilers were among the first to generate efficient code
  3. Hardware Access: C's pointer arithmetic provided direct hardware access
  4. Standardization: ANSI C standardization provided stability and portability

Technical Advantages

Performance Benefits:

  • Minimal Runtime: No garbage collection or complex runtime system
  • Predictable Performance: Deterministic execution time
  • Small Code Size: Efficient compilation to machine code
  • Direct Hardware Access: Ability to manipulate registers and memory

Resource Efficiency:

  • Memory Usage: Minimal memory overhead
  • CPU Cycles: Efficient instruction generation
  • Power Consumption: Lower power usage due to efficiency
  • Real-time Capability: Predictable timing characteristics

Embedded-Specific Benefits

Hardware Integration:

  • Register Access: Direct manipulation of hardware registers
  • Memory-Mapped I/O: Access to peripheral registers
  • Interrupt Handling: Low-level interrupt service routines
  • DMA Programming: Direct memory access programming

System Control:

  • Boot Code: System initialization and startup code
  • Device Drivers: Hardware abstraction layer implementation
  • Real-time Systems: Time-critical application development
  • Safety-Critical Systems: Deterministic behavior requirements

When to Use C

Use C When:

  • Resource Constraints: Limited memory or processing power
  • Real-time Requirements: Predictable timing is critical
  • Hardware Access: Direct control over hardware is needed
  • Legacy Systems: Maintaining existing C codebases
  • Performance Critical: Maximum performance is required

Consider Alternatives When:

  • Rapid Prototyping: Quick development is more important than performance
  • Safety Critical: Built-in safety features are required
  • Complex Abstractions: High-level abstractions are needed
  • Team Productivity: Developer productivity is more important than performance

🧠 C Language Concepts

Programming Paradigm

C is primarily a procedural programming language, which means:

  1. Function-Based: Code is organized into functions
  2. Top-Down Design: Programs are designed from high-level to low-level
  3. Data and Code Separation: Data structures are separate from functions
  4. Step-by-Step Execution: Programs execute instructions sequentially

Memory Model

C defines an abstract machine; the actual memory layout is implementation-defined. Embedded targets typically place code in Flash/ROM and data in RAM, and the linker script controls section placement.

Typical Embedded Memory Layout (varies by target/toolchain):
┌─────────────────────────────────────────────────────────────┐
│                    Stack (Local Variables)                 │
│                    ↓ Often grows downward                 │
├─────────────────────────────────────────────────────────────┤
│                    Heap (Dynamic Memory)                  │
│                    ↑ Often grows upward                   │
├─────────────────────────────────────────────────────────────┤
│                    .bss (Zero-initialized Data)            │
├─────────────────────────────────────────────────────────────┤
│                    .data (Initialized Data)                │
├─────────────────────────────────────────────────────────────┤
│                    .text/.rodata (Code/Const)              │
└─────────────────────────────────────────────────────────────┘

Compilation Process

C on embedded targets compiles into multiple object files (translation units) that the linker places into memory regions defined by a linker script. Understanding this flow helps you read the map file and control where data/code lands.

Concept: Translation units, linkage, and the linker script

  • A source + its headers → a translation unit → one object file.
  • The linker merges objects and libraries, resolving external symbols.
  • The linker script maps sections (`.text`, `.rodata`, `.data`, `.bss`) into Flash/RAM.

Try it

  1. Build with -Wl,-Map=out.map and open the map file. Locate a static const table vs a non-const global.
  2. Change a symbol from static to non-static and observe its visibility (external vs internal linkage).

Takeaways

  • The map file is your truth for footprint and placement.
  • static at file scope gives internal linkage; keep symbols local by default.
  • Use sections/attributes only when you must control placement; prefer defaults first.

C programs go through several stages before execution:

Compilation Process:
┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   Source    │ →  │  Preprocess │ →  │   Compile   │ →  │    Link     │
│   Code      │    │   (Macros)  │    │  (Assembly) │    │  (Executable)│
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘

Type System

C uses a static type system with weak typing:

  1. Static Typing: Types are checked at compile time
  2. Weak Typing: Implicit type conversions are allowed
  3. Explicit Casting: Manual type conversion when needed
  4. Type Safety: Limited type safety compared to modern languages

Scope and Lifetime

Scope Rules:

  • File Scope: Identifiers declared outside any function
  • Block Scope: Identifiers declared inside {} blocks (including function bodies)
  • Prototype Scope: Parameter names in function prototypes
  • Function Scope: Labels only (used with goto)

Lifetime Rules:

  • Automatic: Local variables (stack-based)
  • Static: Variables that persist between function calls
  • Dynamic: Manually allocated memory (heap-based)
  • Global: Variables that exist for the entire program

🔢 Variables and Data Types

Concept: Where does the object live and when does it die?

Rather than memorizing types, think in terms of storage duration and lifetime: who owns the object, where is it placed in memory, and when is it initialized/destroyed. On MCUs, these choices affect RAM usage, startup cost, determinism, and safety.

Why it matters in embedded

  • Static objects may be zero‑initialized by the startup code and can live in Flash (if const), reducing RAM.
  • Automatic (stack) objects are fast and deterministic to allocate but are uninitialized by default.
  • Dynamic (heap) objects increase flexibility but can hurt predictability and fragment memory.

Minimal example

int g1;                 // Zero-initialized (\`.bss\`)
static int g2 = 42;     // Pre-initialized (\`.data\`)

void f(void) {
  int a;                // Uninitialized (stack, indeterminate)
  static int b;         // Zero-initialized, retains value across calls
  static const int lut[] = {1,2,3}; // Often placed in Flash/ROM
  (void)a; (void)b; (void)lut;
}

Try it

  1. Print addresses of g1, g2, a local variable, and b. Inspect the linker map to see section placement (`.text`, `.data`, `.bss`, stack).
  2. Make lut large and observe Flash vs RAM usage in the map file when const is present vs removed.

Takeaways

  • Only static storage duration is guaranteed zero‑init. Stack locals are indeterminate until assigned.
  • static inside a function behaves like a global with function scope.
  • const data may reside in non‑volatile memory; don’t cast away const to write to it.

Platform note: On Cortex‑M, large zero‑initialized objects increase `.bss` and extend startup clear time; large initialized objects increase `.data` copy time from Flash to RAM.

What are Variables?

Variables are named storage locations in memory that can hold data. In C, variables must be declared before use, specifying their type and optionally initializing them with a value.

Variable Concepts

Declaration vs. Definition:

  • Declaration: Tells the compiler about a variable's type and name
  • Definition: Actually allocates memory for the variable
  • Initialization: Assigns an initial value to the variable

Variable Attributes:

  • Type: Determines the size and interpretation of data
  • Name: Identifier used to access the variable
  • Value: Data stored in the variable
  • Address: Memory location where variable is stored

Data Type Categories

Integer Types:

  • Signed: Can represent positive and negative values
  • Unsigned: Can only represent positive values
  • Size Variants: Different bit widths for different ranges

Floating Point Types:

  • Single Precision: 32-bit floating point numbers
  • Double Precision: 64-bit floating point numbers
  • IEEE 754: Standard format for floating point representation

Character Types:

  • char: Usually 8-bit character or small integer
  • Character Encoding: ASCII, UTF-8, or other encodings
  • String Representation: Arrays of characters

Basic Data Types

Integer Types

// Signed integers
int8_t   small_int;    // 8-bit signed (-128 to 127)
int16_t  medium_int;   // 16-bit signed (-32768 to 32767)
int32_t  large_int;    // 32-bit signed (-2^31 to 2^31-1)
int64_t  huge_int;     // 64-bit signed

// Unsigned integers
uint8_t  small_uint;   // 8-bit unsigned (0 to 255)
uint16_t medium_uint;  // 16-bit unsigned (0 to 65535)
uint32_t large_uint;   // 32-bit unsigned (0 to 2^32-1)
uint64_t huge_uint;    // 64-bit unsigned

// Traditional C types (avoid in embedded)
int      platform_dependent;  // Size varies by platform
long     also_variable;       // Size varies by platform

Floating Point Types

float    single_precision;    // Typically 32-bit IEEE 754
double   double_precision;    // Implementation-defined (32 or 64-bit)

Character Types

char     character;           // Usually 8-bit
uint8_t  byte_data;          // Explicit 8-bit unsigned

Variable Declaration and Initialization

Best Practices

// ✅ Good: Explicit initialization
uint32_t counter = 0;
uint8_t status = 0xFF;
float temperature = 25.5f;

// ❌ Bad: Uninitialized variables
uint32_t counter;  // Contains garbage data

Constants

// Compile-time constants
#define MAX_BUFFER_SIZE 1024
#define PI 3.14159f

// Runtime constants
const uint32_t TIMEOUT_MS = 5000;
const float VOLTAGE_REFERENCE = 3.3f;

// Enum constants
typedef enum {
    LED_OFF = 0,
    LED_ON = 1,
    LED_BLINK = 2
} led_state_t;

🔧 Functions

Concept: Keep work small, pure when possible, and observable

In embedded, function design drives predictability and testability. Prefer small, single-purpose functions with explicit inputs/outputs. Avoid hidden dependencies (globals) except for well-defined hardware interfaces behind an abstraction.

Why it matters in embedded

  • Smaller functions improve stack usage estimation and inlining opportunities.
  • Pure functions are easier to unit test off-target.
  • Explicit interfaces reduce coupling to hardware and timing.

Minimal example: refactor side effects

// Before: mixes IO, computation, and policy
void control_loop(void) {
  int raw = adc_read();
  float temp = convert_to_celsius(raw);
  if (temp > 30.0f) fan_on(); else fan_off();
}

// After: separate IO from policy
float read_temperature_c(void) { return convert_to_celsius(adc_read()); }
bool fan_required(float temp_c) { return temp_c > 30.0f; }
void apply_fan(bool on) { if (on) fan_on(); else fan_off(); }

Try it

  1. Write a unit test for fan_required off-target (no hardware) to validate thresholds and hysteresis.
  2. Inspect call sites to ensure high-frequency paths remain small enough to inline.

Takeaways

  • Separate policy from mechanism for testability and reuse.
  • Minimize global state; pass data via parameters and return values.
  • Consider static inline for very small helpers on hot paths.

What are Functions?

Functions are reusable blocks of code that perform specific tasks. They are the primary mechanism for code organization and reuse in C programming.

Function Concepts

Function Components:

  • Declaration: Function prototype (signature)
  • Definition: Function implementation (body)
  • Parameters: Input data passed to the function
  • Return Value: Output data returned from the function
  • Scope: Variables and code accessible within the function

Function Types:

  • Library Functions: Built-in functions (printf, malloc, etc.)
  • User-Defined Functions: Functions written by the programmer
  • Main Function: Entry point of the program
  • Callback Functions: Functions passed as parameters

Function Design Principles

Single Responsibility:

  • Each function should do one thing well
  • Functions should be focused and cohesive
  • Avoid functions that do too many things

Parameter Design:

  • Use parameters for input data
  • Return values for output data
  • Avoid global variables for function communication

Error Handling:

  • Return error codes for failure conditions
  • Use consistent error handling patterns
  • Document error conditions and return values

Function Implementation

Basic Function Structure

// Function declaration (prototype)
return_type function_name(parameter_list);

// Function definition
return_type function_name(parameter_list) {
    // Function body
    // Local variables
    // Statements
    return value;  // Optional
}

Function Examples

// Simple function with no parameters
void initialize_system(void) {
    // System initialization code
    configure_clocks();
    setup_peripherals();
    enable_interrupts();
}

// Function with parameters and return value
uint32_t calculate_average(uint32_t* values, size_t count) {
    if (count == 0) return 0;
    
    uint32_t sum = 0;
    for (size_t i = 0; i < count; i++) {
        sum += values[i];
    }
    return sum / count;
}

// Function with multiple return points
bool validate_sensor_data(uint16_t value, uint16_t min, uint16_t max) {
    if (value < min) return false;
    if (value > max) return false;
    return true;
}

🔄 Control Structures

Concept: Prefer early returns and shallow nesting

Deeply nested branches increase cyclomatic complexity and code size on MCUs. Early returns with guard clauses keep critical paths obvious and reduce stack pressure in error paths.

Minimal example

// Nested
bool handle_packet(const pkt_t* p) {
  if (p) {
    if (valid_crc(p)) {
      if (!seq_replay(p)) { process(p); return true; }
    }
  }
  return false;
}

// Guarded
bool handle_packet(const pkt_t* p) {
  if (!p) return false;
  if (!valid_crc(p)) return false;
  if (seq_replay(p)) return false;
  process(p);
  return true;
}

Takeaways

  • Shallow nesting improves readability and timing analysis.
  • Use switch for dense dispatch; avoid fall-through unless deliberate and documented.
  • In ISR-adjacent code, keep branches short and avoid loops without clear bounds.

What are Control Structures?

Control structures determine the flow of program execution. They allow programs to make decisions, repeat operations, and organize code execution.

Control Structure Concepts

Decision Making:

  • Conditional Execution: Execute code based on conditions
  • Boolean Logic: True/false conditions for decisions
  • Nested Conditions: Complex decision trees
  • Default Actions: Fallback behavior when conditions aren't met

Looping:

  • Iteration: Repeating operations multiple times
  • Loop Control: Starting, continuing, and stopping loops
  • Loop Variables: Variables that control loop execution
  • Infinite Loops: Loops that run indefinitely (usually bugs)

Flow Control:

  • Sequential Execution: Code executed in order
  • Branching: Jumping to different code sections
  • Early Exit: Exiting functions or loops early
  • Exception Handling: Managing error conditions

Decision Structures

if-else Statements

// Simple if statement
if (temperature > 30.0f) {
    turn_on_fan();
}

// if-else statement
if (battery_level > 20) {
    normal_operation();
} else {
    low_power_mode();
}

// Nested if-else
if (sensor_status == SENSOR_OK) {
    if (temperature > threshold) {
        activate_cooling();
    } else {
        deactivate_cooling();
    }
} else {
    handle_sensor_error();
}

switch Statements

// Switch statement for multiple conditions
switch (button_pressed) {
    case BUTTON_UP:
        increase_volume();
        break;
    case BUTTON_DOWN:
        decrease_volume();
        break;
    case BUTTON_SELECT:
        select_option();
        break;
    default:
        // Ignore unknown buttons
        break;
}

Loop Structures

for Loops

// Traditional for loop
for (int i = 0; i < 10; i++) {
    process_data(i);
}

// Embedded-style for loop
for (uint32_t i = 0; i < BUFFER_SIZE; i++) {
    buffer[i] = 0;  // Initialize buffer
}

// Infinite loop (common in embedded systems)
for (;;) {
    process_events();
    update_display();
    delay_ms(100);
}

while Loops

// Condition-checked loop
while (data_available()) {
    process_data();
}

// Infinite loop with break
while (1) {
    if (shutdown_requested()) {
        break;
    }
    main_loop();
}

do-while Loops

// Execute at least once
do {
    read_sensor();
} while (sensor_error());

💾 Memory Management

What is Memory Management?

Memory management in C involves allocating, using, and freeing memory resources. Unlike higher-level languages, C requires manual memory management, giving programmers direct control but also responsibility for memory safety.

Memory Management Concepts

Memory Types:

  • Stack Memory: Automatic allocation for local variables
  • Heap Memory: Dynamic allocation with malloc/free (if enabled)
  • Static Storage: Global and static variables
  • Flash/ROM: Read-only code and const data (platform concept)

Memory Lifecycle:

  • Allocation: Requesting memory from the system
  • Usage: Reading and writing to allocated memory
  • Deallocation: Returning memory to the system
  • Reuse: Memory can be reallocated after deallocation

Memory Safety:

  • Bounds Checking: Ensuring memory access is within allocated regions
  • Use-after-free: Accessing memory after it's been freed
  • Memory Leaks: Failing to free allocated memory
  • Double Free: Freeing the same memory twice

Stack vs. Heap

Stack Memory:

  • Automatic Allocation: Variables allocated automatically
  • LIFO Order: Last-in, first-out allocation pattern
  • Fast Access: Direct memory access
  • Limited Size: Stack size is typically small
  • Scope-based: Memory freed when scope ends

Heap Memory:

  • Manual Allocation: Explicit allocation with malloc/calloc
  • Flexible Size: Can allocate large amounts of memory
  • Slower Access: Indirect memory access
  • Manual Deallocation: Must explicitly free memory
  • Fragmentation: Can become fragmented over time

Memory Management Implementation

Stack Memory

void stack_example(void) {
    // Stack-allocated variables
    uint32_t local_var = 42;
    uint8_t buffer[256];
    struct sensor_data data;
    
    // Memory automatically freed when function returns
}

Heap Memory

void heap_example(void) {
    // Allocate memory
    uint8_t* buffer = malloc(1024);
    if (buffer == NULL) {
        // Handle allocation failure
        return;
    }
    
    // Use memory
    memset(buffer, 0, 1024);
    
    // Free memory
    free(buffer);
    buffer = NULL;  // Prevent use-after-free
}

Practical Scenario: Stack vs Heap Comparison

/*
 * Stack vs Heap: When to use each
 * 
 * STACK: small, fixed-size, short-lived data
 * HEAP:  large, variable-size, or data that outlives the function
 */

// ✅ Stack: small fixed buffer for local processing
void process_sensor(void) {
    uint8_t raw[8];           // 8 bytes on stack - fast, automatic
    read_sensor(raw, 8);
    uint16_t value = (raw[0] << 8) | raw[1];
    send_value(value);
}   // raw automatically freed here

// ✅ Heap: large buffer or data returned to caller
uint8_t* allocate_frame_buffer(size_t width, size_t height) {
    size_t size = width * height * 3;  // RGB
    uint8_t* fb = malloc(size);
    if (fb) memset(fb, 0, size);
    return fb;  // caller must free
}

Common Pitfalls with Code Examples

Pitfall 1: Returning Stack Address (Dangling Pointer)

// ❌ BUG: returning address of stack memory
uint8_t* bad_get_buffer(void) {
    uint8_t tmp[64];
    fill_buffer(tmp);
    return tmp;  // UNDEFINED BEHAVIOR - tmp is gone after return
}

// ✅ FIX: use caller-provided buffer or heap
void good_get_buffer(uint8_t* out, size_t len) {
    fill_buffer(out);  // caller owns the memory
}

uint8_t* good_get_buffer_heap(size_t len) {
    uint8_t* buf = malloc(len);
    if (buf) fill_buffer(buf);
    return buf;  // caller must free
}

Pitfall 2: Memory Leak in Error Path

// ❌ BUG: memory leak if second allocation fails
int bad_init(void) {
    ctx->buf1 = malloc(1024);
    if (!ctx->buf1) return -1;
    
    ctx->buf2 = malloc(2048);
    if (!ctx->buf2) return -1;  // LEAK: buf1 not freed!
    
    return 0;
}

// ✅ FIX: clean up on error
int good_init(void) {
    ctx->buf1 = malloc(1024);
    if (!ctx->buf1) return -1;
    
    ctx->buf2 = malloc(2048);
    if (!ctx->buf2) {
        free(ctx->buf1);        // clean up first allocation
        ctx->buf1 = NULL;
        return -1;
    }
    return 0;
}

Pitfall 3: Use-After-Free

// ❌ BUG: accessing freed memory
void bad_cleanup(msg_t* msg) {
    free(msg->payload);
    log("Freed %zu bytes", msg->payload_len);  // OK so far
    
    // ... later in code ...
    if (msg->payload[0] == 0xAA) { }  // UAF! payload is freed
}

// ✅ FIX: NULL after free, check before use
void good_cleanup(msg_t* msg) {
    free(msg->payload);
    msg->payload = NULL;    // prevent accidental reuse
    msg->payload_len = 0;
}

Pitfall 4: Double Free

// ❌ BUG: freeing the same memory twice
void bad_reset(void) {
    free(global_buf);
    // ... other code ...
    free(global_buf);  // DOUBLE FREE - undefined behavior
}

// ✅ FIX: NULL after free
void good_reset(void) {
    free(global_buf);
    global_buf = NULL;
    // ... other code ...
    free(global_buf);  // safe: free(NULL) is a no-op
}

Pitfall 5: Stack Overflow (Large Local Arrays)

// ❌ RISKY: large array on stack (may overflow small embedded stack)
void bad_process_image(void) {
    uint8_t frame[320 * 240];  // 76KB on stack!
    capture_frame(frame);
}

// ✅ SAFER: use static or heap for large buffers
static uint8_t frame_buffer[320 * 240];  // in .bss, not stack

void good_process_image(void) {
    capture_frame(frame_buffer);
}

🎯 Pointers

What are Pointers?

Pointers are variables that store memory addresses. They provide indirect access to data and are fundamental to C programming, especially in embedded systems where direct memory manipulation is common.

Pointer Concepts

Address and Value:

  • Address: Memory location where data is stored
  • Value: Data stored at the memory location
  • Pointer Variable: Variable that stores an address
  • Dereferencing: Accessing the value at the address

Pointer Types:

  • Data Pointers: Point to variables and data structures
  • Function Pointers: Point to functions
  • Void Pointers: Generic pointers that can point to any type
  • Null Pointers: Special pointer value indicating "no address"

Pointer Arithmetic:

  • Increment/Decrement: Move to next/previous memory location
  • Addition/Subtraction: Move by multiple memory locations
  • Comparison: Compare memory addresses
  • Array Relationship: Arrays and pointers are closely related

Pointer Implementation

Basic Pointer Operations

// Pointer declaration and initialization
uint32_t value = 42;
uint32_t* ptr = &value;  // Address-of operator

// Dereferencing
uint32_t retrieved = *ptr;  // Dereference operator

// Pointer arithmetic
uint32_t array[5] = {1, 2, 3, 4, 5};
uint32_t* array_ptr = array;

// Access elements
uint32_t first = *array_ptr;      // array[0]
uint32_t second = *(array_ptr + 1); // array[1]
uint32_t third = array_ptr[2];    // array[2]

Practical Embedded Examples

Memory-Mapped Register Access

// Direct hardware register manipulation via pointers
#define GPIO_BASE   0x40020000u
#define GPIO_MODER  (*(volatile uint32_t*)(GPIO_BASE + 0x00))
#define GPIO_ODR    (*(volatile uint32_t*)(GPIO_BASE + 0x14))
#define GPIO_IDR    (*(volatile uint32_t*)(GPIO_BASE + 0x10))

void gpio_set_output(uint8_t pin) {
    GPIO_MODER &= ~(3u << (pin * 2));   // clear mode bits
    GPIO_MODER |= (1u << (pin * 2));    // set output mode
}

void gpio_write(uint8_t pin, uint8_t val) {
    if (val) GPIO_ODR |= (1u << pin);
    else     GPIO_ODR &= ~(1u << pin);
}

Pointer Arithmetic: Type Matters

/*
 * Key insight: ptr + 1 advances by sizeof(*ptr) bytes
 * 
 * uint8_t*  + 1 = +1 byte
 * uint16_t* + 1 = +2 bytes
 * uint32_t* + 1 = +4 bytes
 */
void demonstrate_pointer_arithmetic(void) {
    uint8_t  buf[16];
    
    uint8_t*  p8  = buf;
    uint16_t* p16 = (uint16_t*)buf;
    uint32_t* p32 = (uint32_t*)buf;
    
    // All start at same address
    // p8  = 0x2000
    // p16 = 0x2000
    // p32 = 0x2000
    
    p8++;   // p8  = 0x2001 (+1 byte)
    p16++;  // p16 = 0x2002 (+2 bytes)
    p32++;  // p32 = 0x2004 (+4 bytes)
}

Practical: Parsing a Protocol Packet

/*
 * Parse: [SYNC:1][LEN:2][CMD:1][PAYLOAD:n][CRC:2]
 * This is how embedded protocols like UART frames are parsed
 */
typedef struct {
    uint8_t  cmd;
    uint16_t len;
    uint8_t* payload;
    uint16_t crc;
} packet_t;

bool parse_packet(uint8_t* raw, size_t raw_len, packet_t* pkt) {
    uint8_t* p = raw;
    uint8_t* end = raw + raw_len;
    
    // Check minimum size
    if (raw_len < 6) return false;
    
    // Parse SYNC
    if (*p++ != 0xAA) return false;
    
    // Parse LEN (little-endian 16-bit)
    pkt->len = p[0] | (p[1] << 8);
    p += 2;
    
    // Bounds check before accessing payload
    if (p + pkt->len + 3 > end) return false;
    
    // Parse CMD
    pkt->cmd = *p++;
    
    // Payload pointer (no copy, just reference)
    pkt->payload = p;
    p += pkt->len;
    
    // Parse CRC
    pkt->crc = p[0] | (p[1] << 8);
    
    return true;
}

Pointer Comparison and Bounds Checking

/*
 * Safe buffer iteration with boundary checks
 * Common pattern for circular buffers and DMA
 */
void safe_buffer_copy(uint8_t* dst, const uint8_t* src, 
                      size_t len, size_t dst_size) {
    const uint8_t* src_end = src + len;
    uint8_t* dst_end = dst + dst_size;
    
    while (src < src_end && dst < dst_end) {
        *dst++ = *src++;
    }
}

// Ring buffer read with wrap-around
size_t ring_read(ring_t* r, uint8_t* out, size_t max) {
    size_t count = 0;
    uint8_t* end = r->buf + r->size;  // one past last valid
    
    while (count < max && r->head != r->tail) {
        *out++ = *r->tail++;
        if (r->tail >= end) {
            r->tail = r->buf;  // wrap to start
        }
        count++;
    }
    return count;
}

Multi-Byte Access Patterns (Endianness-Aware)

/*
 * Portable multi-byte read/write for protocol buffers
 * Avoids alignment issues and works regardless of CPU endianness
 */

// Read 16-bit little-endian from byte buffer
static inline uint16_t read_le16(const uint8_t* p) {
    return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
}

// Read 32-bit big-endian from byte buffer (network order)
static inline uint32_t read_be32(const uint8_t* p) {
    return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
           ((uint32_t)p[2] << 8)  | (uint32_t)p[3];
}

// Write 16-bit little-endian to byte buffer
static inline void write_le16(uint8_t* p, uint16_t v) {
    p[0] = (uint8_t)(v & 0xFF);
    p[1] = (uint8_t)(v >> 8);
}

// Usage: build a packet
void build_response(uint8_t* buf, uint16_t seq, uint32_t value) {
    buf[0] = 0xAA;                  // sync
    write_le16(buf + 1, seq);       // sequence number
    buf[3] = 0x02;                  // command
    write_le16(buf + 4, (uint16_t)value);  // payload
}

Void Pointers and Type Casting

/*
 * void* is the "generic" pointer - can point to any type
 * Must cast before dereferencing
 * Common in callbacks, memory allocators, and HAL APIs
 */

// Generic compare callback (like qsort)
typedef int (*compare_fn)(const void*, const void*);

int compare_uint16(const void* a, const void* b) {
    uint16_t va = *(const uint16_t*)a;
    uint16_t vb = *(const uint16_t*)b;
    return (va > vb) - (va < vb);
}

// Generic memory pool
void* pool_alloc(pool_t* pool, size_t size) {
    if (pool->free + size > pool->end) return NULL;
    void* ptr = pool->free;
    pool->free += size;
    return ptr;
}

// Usage
sensor_t* s = (sensor_t*)pool_alloc(&pool, sizeof(sensor_t));

Function Pointers

// Function pointer type
typedef void (*callback_t)(uint32_t);

// Function that takes a callback
void process_data(uint32_t data, callback_t callback) {
    // Process data
    if (callback != NULL) {
        callback(data);
    }
}

// Callback function
void data_handler(uint32_t data) {
    printf("Received: %u\n", data);
}

// Usage
process_data(42, data_handler);

📊 Arrays and Strings

Mental model: Arrays are blocks; pointers are addresses with intent

An array name in an expression decays to a pointer to its first element. The array itself has a fixed size and lives where it was defined (stack, `.bss`, `.data`). A pointer is just an address that can point anywhere and can be reseated.

Why it matters in embedded

  • Knowing when decay happens prevents bugs with sizeof and parameter passing.
  • Arrays placed in Flash as static const look like ordinary arrays but are read‑only; use volatile only for memory‑mapped registers or data changed by hardware/ISRs.

Minimal example: decay and sizeof

static uint8_t table[16];

size_t size_in_caller = sizeof table;      // 16

void use_array(uint8_t *p) {
  size_t size_in_callee = sizeof p;        // size of pointer, not array
  (void)size_in_callee;
}

void demo(void) {
  use_array(table);                        // array decays to uint8_t*
}

Try it

  1. Print sizeof table in the defining scope and inside a callee parameter.
  2. Change the parameter to uint8_t a[16] and observe it’s still a pointer in the callee.
  3. Create static const uint16_t lut[] = { ... } and verify via the map file whether it resides in Flash/ROM.

Takeaways

  • Arrays are not pointers; they decay to pointers at most expression boundaries.
  • sizeof(param) inside a function where param is declared as type param[] yields the pointer size.
  • Prefer passing (ptr, length) pairs, or wrap in a struct to preserve size information.

Cross‑links: See Type_Qualifiers.md for const/volatile on memory‑mapped regions, and Structure_Alignment.md for layout implications.

What are Arrays?

Arrays are collections of elements of the same type stored in contiguous memory locations. They provide efficient access to multiple related data items.

Array Concepts

Array Characteristics:

  • Contiguous Memory: Elements stored in adjacent memory locations
  • Indexed Access: Elements accessed by numeric index
  • Fixed Size: Size determined at declaration (in C)
  • Type Homogeneity: All elements must be the same type

Array Operations:

  • Traversal: Accessing all elements in sequence
  • Searching: Finding specific elements
  • Sorting: Arranging elements in order
  • Modification: Changing element values

Array Limitations:

  • Fixed Size: Cannot change size after declaration
  • No Bounds Checking: C doesn't check array bounds
  • Memory Waste: May allocate more memory than needed
  • No Built-in Operations: No built-in search, sort, etc.

String Concepts

String Representation:

  • Null-terminated: Strings end with '\0' character
  • Character Arrays: Strings are arrays of characters
  • Length Calculation: Must count characters to find length
  • Memory Management: Must allocate sufficient space

String Operations:

  • Concatenation: Combining strings
  • Comparison: Comparing string contents
  • Searching: Finding substrings
  • Copying: Duplicating strings

Array and String Implementation

Array Operations

// Array declaration and initialization
uint32_t numbers[5] = {1, 2, 3, 4, 5};

// Array traversal
for (size_t i = 0; i < 5; i++) {
    printf("Element %zu: %u\n", i, numbers[i]);
}

// Array as function parameter
void process_array(uint32_t* array, size_t size) {
    for (size_t i = 0; i < size; i++) {
        array[i] *= 2;  // Double each element
    }
}

String Operations

// String declaration
char message[] = "Hello, World!";

// String length calculation
size_t length = 0;
while (message[length] != '\0') {
    length++;
}

// String copying
char destination[20];
size_t i = 0;
while (message[i] != '\0') {
    destination[i] = message[i];
    i++;
}
destination[i] = '\0';  // Null-terminate

🏗️ Structures and Unions

What are Structures?

Structures are user-defined data types that group related data items of different types into a single unit. They provide a way to organize complex data in embedded systems.

Structure Concepts

Structure Components:

  • Members: Individual data items within the structure
  • Layout: How members are arranged in memory
  • Alignment: Memory alignment requirements
  • Size: Total memory occupied by the structure

Structure Usage:

  • Data Organization: Group related data together
  • Function Parameters: Pass multiple related values
  • Return Values: Return multiple values from functions
  • Memory Mapping: Map structures to hardware registers

Structure Design:

  • Cohesion: Members should be logically related
  • Size Optimization: Minimize memory usage
  • Alignment: Consider memory alignment requirements
  • Access Patterns: Design for efficient access

Union Concepts

Union Characteristics:

  • Shared Memory: All members share the same memory location
  • Single Value: Only one member can be active at a time
  • Memory Efficiency: Uses only the size of the largest member
  • Type Flexibility: Can represent different data types

Union Applications:

  • Type Conversion: Convert between different data types
  • Memory Efficiency: Save memory when only one type is needed
  • Hardware Access: Access hardware registers in different ways
  • Protocol Implementation: Handle different message types

Structure and Union Implementation

Structure Examples

// Basic structure
typedef struct {
    uint32_t id;
    float temperature;
    uint8_t status;
} sensor_data_t;

// Structure with bit fields
typedef struct {
    uint8_t red : 3;    // 3 bits for red
    uint8_t green : 3;  // 3 bits for green
    uint8_t blue : 2;   // 2 bits for blue
} rgb_color_t;

// Structure with function pointer
typedef struct {
    uint32_t (*read)(void);
    void (*write)(uint32_t value);
    uint32_t address;
} hardware_register_t;

Union Examples

// Union for type conversion
typedef union {
    uint32_t as_uint32;
    uint8_t as_bytes[4];
    float as_float;
} data_converter_t;

// Union for protocol messages
typedef union {
    struct {
        uint8_t type;
        uint8_t length;
        uint8_t data[32];
    } message;
    uint8_t raw[34];
} protocol_message_t;

Note: Type-punning through unions is implementation-defined in C. For strict portability, use memcpy to move between object representations.

🔧 Preprocessor Directives

Guideline: Keep macros minimal and local. Prefer static inline functions for type safety, debuggability, and better compiler analysis unless you truly need token pasting/stringification or compile‑time branching.

What are Preprocessor Directives?

Preprocessor directives are instructions to the C preprocessor that are processed before compilation. They provide text substitution, conditional compilation, and file inclusion capabilities.

Preprocessor Concepts

Text Substitution:

  • Macros: Text replacement before compilation
  • Constants: Define compile-time constants
  • Function-like Macros: Macros that take parameters
  • Stringification: Convert parameters to strings

Conditional Compilation:

  • Platform-specific Code: Different code for different platforms
  • Debug Code: Include debug code only in debug builds
  • Feature Flags: Enable/disable features at compile time
  • Header Guards: Prevent multiple inclusion of headers

File Management:

  • Header Inclusion: Include declarations from other files
  • File Organization: Separate interface from implementation
  • Dependency Management: Manage file dependencies
  • Modular Design: Organize code into modules

Preprocessor Implementation

Macro Definitions

// Simple macro
#define MAX_BUFFER_SIZE 1024
#define PI 3.14159f

// Function-like macro
#define MIN(a, b) ((a) < (b) ? (a) : (b))
#define ABS(x) ((x) < 0 ? -(x) : (x))

// Multi-line macro
#define INIT_SENSOR(sensor, id, threshold) \
    do { \
        sensor.id = id; \
        sensor.threshold = threshold; \
        sensor.status = SENSOR_INACTIVE; \
    } while(0)

Conditional Compilation

// Platform-specific code
#ifdef ARM_CORTEX_M4
    #define CPU_FREQUENCY 168000000
#elif defined(ARM_CORTEX_M3)
    #define CPU_FREQUENCY 72000000
#else
    #define CPU_FREQUENCY 16000000
#endif

// Debug code
#ifdef DEBUG
    #define DEBUG_PRINT(msg) printf("DEBUG: %s\n", msg)
#else
    #define DEBUG_PRINT(msg) ((void)0)
#endif

🔧 Implementation

Complete Program Example

#include <stdint.h>
#include <stdbool.h>

// Constants
#define MAX_SENSORS 8
#define TEMPERATURE_THRESHOLD 30.0f

// Data structures
typedef struct {
    uint32_t id;
    float temperature;
    bool active;
} sensor_t;

typedef struct {
    sensor_t sensors[MAX_SENSORS];
    uint8_t sensor_count;
    bool system_active;
} system_state_t;

// Function prototypes
void initialize_system(system_state_t* state);
void read_sensors(system_state_t* state);
void process_data(system_state_t* state);
void update_outputs(system_state_t* state);

// Main function
int main(void) {
    system_state_t system;
    
    // Initialize system
    initialize_system(&system);
    
    // Main loop
    while (system.system_active) {
        read_sensors(&system);
        process_data(&system);
        update_outputs(&system);
    }
    
    return 0;
}

// Function implementations
void initialize_system(system_state_t* state) {
    state->sensor_count = 0;
    state->system_active = true;
    
    // Initialize sensors
    for (uint8_t i = 0; i < MAX_SENSORS; i++) {
        state->sensors[i].id = i;
        state->sensors[i].temperature = 0.0f;
        state->sensors[i].active = false;
    }
}

void read_sensors(system_state_t* state) {
    for (uint8_t i = 0; i < state->sensor_count; i++) {
        if (state->sensors[i].active) {
            // Simulate sensor reading
            state->sensors[i].temperature = 25.0f + (i * 2.0f);
        }
    }
}

void process_data(system_state_t* state) {
    for (uint8_t i = 0; i < state->sensor_count; i++) {
        if (state->sensors[i].active && 
            state->sensors[i].temperature > TEMPERATURE_THRESHOLD) {
            // Handle high temperature
            activate_cooling();
        }
    }
}

void update_outputs(system_state_t* state) {
    // Update system outputs based on processed data
    update_display();
    send_status_report();
}

⚠️ Common Pitfalls

1. Uninitialized Variables

Problem: Using variables before they're initialized Solution: Always initialize variables

// ❌ Bad: Uninitialized variable
uint32_t counter;
printf("Counter: %u\n", counter);  // Undefined behavior

// ✅ Good: Initialized variable
uint32_t counter = 0;
printf("Counter: %u\n", counter);

2. Buffer Overflows

Problem: Writing beyond array boundaries Solution: Always check array bounds

// ❌ Bad: Buffer overflow
uint8_t buffer[10];
for (int i = 0; i < 20; i++) {
    buffer[i] = 0;  // Buffer overflow!
}

// ✅ Good: Bounds checking
uint8_t buffer[10];
for (int i = 0; i < 10; i++) {
    buffer[i] = 0;
}

3. Memory Leaks

Problem: Not freeing allocated memory Solution: Always free allocated memory

// ❌ Bad: Memory leak
void bad_function(void) {
    uint8_t* buffer = malloc(1024);
    // Use buffer...
    // Forgot to free!
}

// ✅ Good: Proper cleanup
void good_function(void) {
    uint8_t* buffer = malloc(1024);
    if (buffer != NULL) {
        // Use buffer...
        free(buffer);
    }
}

4. Dangling Pointers

Problem: Using pointers after memory is freed Solution: Set pointers to NULL after freeing

// ❌ Bad: Dangling pointer
uint8_t* ptr = malloc(100);
free(ptr);
*ptr = 42;  // Use-after-free!

// ✅ Good: Null pointer after free
uint8_t* ptr = malloc(100);
free(ptr);
ptr = NULL;  // Prevent use-after-free

Best Practices

1. Code Organization

  • Modular Design: Break code into logical modules
  • Function Size: Keep functions small and focused
  • Naming Conventions: Use consistent naming
  • Documentation: Document complex code sections

2. Memory Management

  • Initialization: Always initialize variables
  • Bounds Checking: Check array bounds
  • Memory Cleanup: Free allocated memory
  • Null Pointers: Check for NULL before dereferencing

3. Error Handling

  • Return Values: Use return values for error indication
  • Error Codes: Define consistent error codes
  • Graceful Degradation: Handle errors gracefully
  • Logging: Log errors for debugging

4. Performance

  • Efficient Algorithms: Choose appropriate algorithms
  • Memory Usage: Minimize memory usage
  • Loop Optimization: Optimize critical loops
  • Compiler Flags: Use appropriate compiler flags

5. Safety

  • Bounds Checking: Always check array bounds
  • Type Safety: Use appropriate data types
  • Null Checks: Check pointers before use
  • Initialization: Initialize all variables

🎯 Interview Questions

Basic Questions

  1. What are the key characteristics of C programming?

    • Static typing, manual memory management, low-level access
    • Procedural programming, direct hardware access
    • Efficiency, portability, mature ecosystem
  2. What is the difference between stack and heap memory?

    • Stack: Automatic allocation, LIFO, limited size, scope-based
    • Heap: Manual allocation, flexible size, slower access, manual deallocation
  3. What are pointers and why are they important in C?

    • Pointers store memory addresses
    • Provide indirect access to data
    • Essential for dynamic memory allocation
    • Enable efficient array and function operations

Advanced Questions

  1. How would you implement a memory pool in C?

    • Pre-allocate memory in fixed-size blocks
    • Maintain a free list of available blocks
    • Implement O(1) allocation and deallocation
    • Handle pool exhaustion gracefully
  2. How would you design a function pointer system for callbacks?

    • Define function pointer types
    • Pass function pointers as parameters
    • Implement callback registration
    • Handle NULL function pointers
  3. How would you optimize a C program for embedded systems?

    • Use appropriate data types
    • Minimize memory usage
    • Optimize critical loops
    • Use compiler optimizations

Implementation Questions

  1. Write a function to reverse a string in place
  2. Implement a simple memory allocator
  3. Write a function to find the nth Fibonacci number
  4. Design a structure for a linked list node

📚 Additional Resources

Books

  • "The C Programming Language" by Brian W. Kernighan and Dennis M. Ritchie
  • "C Programming: A Modern Approach" by K.N. King
  • "Embedded C Coding Standard" by Michael Barr

Online Resources

Tools

  • GCC: GNU Compiler Collection
  • Clang: LLVM-based compiler
  • Valgrind: Memory analysis tool
  • GDB: GNU Debugger

Standards

  • C11: Widely used in embedded toolchains
  • C17/C18: Bug-fix revision of C11
  • C23: Latest ISO C standard (toolchain support varies)
  • MISRA C: Safety-critical coding standard

Next Steps: Explore Memory Management to understand memory allocation strategies, or dive into Pointers and Memory Addresses for low-level memory manipulation.