Back
In print settings: Save as PDF, turn headers and footers off, turn background graphics on.

C Programming Mastery

Master the foundations of systems programming, memory management, and low-level computer science with C.

Official Documentation

July 2026

Contents

Foundations

  • The C Architecture and Abstract Machine

Language Fundamentals

  • Type Systems and Lexical Structure
  • Operators, Expressions, and Side Effects

Program Logic

  • Control Flow and Branching Logic
  • Iteration and Loop Mechanisms

Functions & Scope

  • Functional Decomposition and the Call Stack

Data Structures

  • Contiguous Memory and Array Mechanics
  • Strings and Character Manipulation

Memory Mastery

  • The Address Space: Pointer Fundamentals
  • Advanced Pointers: Indirection and Arithmetic
  • Dynamic Memory: The Heap

Complex Data

  • Compound Data: Structures and Unions

Advanced Features

  • Storage Classes, Linkage, and Mutability
  • The C Preprocessor: Meta-Programming

Working with Data

  • I/O Streams and Persistence

Advanced Features

  • The Standard Library (libc) Utilities

Algorithms

  • Dynamic Data Structures: Linked Lists
  • LIFO and FIFO: Stacks and Queues

Robust Code

  • Error Handling and Defensive Programming

Real-world C

  • Interface with the Machine: Systems Programming

Object-Oriented C & Encapsulation

  • Opaque Pointers and the ADT Pattern
  • Callbacks and Function Pointers

Test-Driven Development (TDD)

  • PlatformIO and TDD Target Isolation
  • Unit Testing and Mocking with FFF

AVR Microcontroller Interfacing

  • AVR Register Programming: GPIO
  • AVR Register Programming: UART
  • AVR Register Programming: Timers & CTC

Real-Time Schedulers

  • Periodic Task Scheduling

Physical Integration

  • Case Study: HC-SR04 Distance Sensor

Foundations

Section Detail

The C Architecture and Abstract Machine

The Philosophy of C

C is often called a “high-level assembly language.” This description, while reductive, captures the essence of the language: it provides the abstractions necessary for structured programming while maintaining a transparent mapping to the underlying hardware. Unlike languages like Java or Python, which run on a Virtual Machine (JVM) or through an interpreter, C is designed around the concept of an Abstract Machine that closely mirrors the von Neumann architecture.

The C Abstract Machine

When you write C, you are not writing for a specific CPU (like an Intel i9 or an Apple M3); you are writing for the C Abstract Machine. The C Standard (ISO/IEC 9899) defines how this machine behaves.

Key characteristics of the C Abstract Machine include:

  1. Linear Memory Model: Memory is treated as a contiguous sequence of bytes, each with a unique address.
  2. Explicit Storage Durations: The programmer, not a garbage collector, manages the lifetime of data (Static, Automatic, and Allocated).
  3. Sequential Execution: Operations happen in a deterministic order, except where the compiler proves that reordering won’t change “observable behavior” (the “As-If” rule).

The Compilation Pipeline

A C program undergoes a rigorous transformation process before the hardware can execute it. Understanding this pipeline is critical for debugging linker errors and optimizing build times.

Code
skinparam componentStyle rectangle

package "Source Code" {
component "main.c" as MAIN
component "header.h" as HDR
}

node "The Pipeline" {
component "Preprocessor" as CPP
component "Compiler" as CC
component "Assembler" as ASM
component "Linker" as LD
}

MAIN --> CPP
HDR --> CPP
CPP --> CC : "Expanded Source (.i)"
CC --> ASM : "Assembly (.s)"
ASM --> LD : "Object File (.o / .obj)"
[Standard Library] --> LD
LD --> [Executable] : "Binary (.exe / .out)"
Source CodeThe Pipelinemain.cheader.hPreprocessorCompilerAssemblerLinkerStandard LibraryExecutableExpanded Source (.i)Assembly (.s)Object File (.o / .obj)Binary (.exe / .out)

1. Preprocessing (cpp)

The preprocessor handles directives starting with #. It performs text substitution, includes header files, and handles conditional compilation. It does not understand C syntax; it is essentially a sophisticated “find-and-replace” engine.

2. Compilation (cc1)

The compiler proper translates the preprocessed C code into assembly language specific to the target architecture (x86, ARM, RISC-V). This is where syntax checking, type checking, and optimization occur.

3. Assembly (as)

The assembler converts the human-readable assembly instructions into machine code (binary). The result is an Object File, which contains machine instructions but may have unresolved references to functions or variables defined in other files.

4. Linking (ld)

The linker resolves those external references. It combines multiple object files and static libraries into a single executable. It also maps the logical addresses in the object files to final memory addresses.

Memory Layout of a C Program

In a modern operating system, every running C process is given a virtual address space. This space is typically organized into several segments:

SegmentDescriptionLifetime
TextThe actual machine instructions (read-only).Program Duration
DataGlobal and static variables initialized by the programmer.Program Duration
BSSGlobal and static variables uninitialized (set to zero).Program Duration
HeapMemory allocated at runtime via malloc or calloc.Manual
StackLocal variables and function call frames.Function Scope

Interactive Exercise: The Entry Point

In the C Abstract Machine, the environment calls a specific function to begin execution. While this usually looks like int main(void), the signature can vary depending on whether you need command-line arguments.

Defining the Entry Point

/* The signature for a program that ignores arguments */
int (void) {
    return 0;
}

A Note on “Undefined Behavior” (UB)

Perhaps the most important concept in C is Undefined Behavior. If your code violates the rules of the Abstract Machine (e.g., dereferencing a null pointer or accessing an array out of bounds), the C standard says anything can happen. The compiler is not required to catch these errors. This is the “double-edged sword” of C: absolute power and absolute responsibility.

c
1#include <stdio.h>
2 
3int main() {
4 // The most basic C program obeying the rules
5 printf("Target: C Abstract Machine Initialized.\n");
6 return 0;
7}

Language Fundamentals

Section Detail

Type Systems and Lexical Structure

Lexical Elements: The Building Blocks

A C program is a sequence of Tokens. The compiler’s lexer breaks your source code into these basic units: Keywords, Identifiers, Constants, String Literals, and Punctuators.

  • Case Sensitivity: C is strictly case-sensitive. int MyVar; and int myvar; refer to two distinct memory locations.
  • Internal vs External Linkage: Identifiers have “linkage” which determines if the same name in a different file refers to the same object. We will explore this in the “Storage Classes” module.

Fundamental Data Types

C’s type system is designed to expose the underlying hardware’s capabilities. Unlike managed languages, C types often have implementation-defined sizes.

1. Integer Types

The size of int is typically the “natural” size of the processor’s word (32-bits on most modern systems). However, the standard only guarantees minimum ranges:

TypeMinimum SizeGuaranteed Range
char8 bits-127 to 127 (or 0 to 255)
short16 bits-32,767 to 32,767
int16 bits-32,767 to 32,767
long32 bits-2,147,483,647 to 2,147,483,647
long long64 bits-(2^63 - 1) to (2^63 - 1)

2. Floating-Point Types

C uses the IEEE 754 Standard for floating-point arithmetic.

  • float: Single precision (usually 32-bit).
  • double: Double precision (usually 64-bit).
  • long double: Extended precision (often 80-bit or 128-bit).

The stdint.h Solution

Because primitive sizes vary by architecture, systems programming requires Fixed-Width Types. Since the C99 standard, <stdint.h> provides types that are guaranteed to be the same size everywhere.

#include <stdint.h>

int32_t fixed_int;   // Exactly 32 bits
uint64_t large_unsig; // Exactly 64 bits, unsigned
intptr_t pointer_int; // Integer large enough to hold a pointer

Implicit Conversions and Promotion

C performs Integer Promotion: any type smaller than an int (like char or short) is converted to an int before arithmetic operations.

The “Usual Arithmetic Conversions” rule: When mixing types (e.g., int + double), C promotes the “smaller” type to the “larger” one to prevent precision loss. However, converting a double to an int causes truncation, where the fractional part is simply discarded.

Type Promotion

int a = 5;
float b = 2.0;
// What is the resulting type of (a / b)?
// Answer: 

Limits and Overflow

What happens when you add 1 to the maximum possible value of a signed integer?

  • Signed Overflow: This is Undefined Behavior. The compiler might assume it never happens and optimize away your checks.
  • Unsigned Overflow: This is well-defined as Wrap-around (modulo arithmetic).
c
1#include <stdio.h>
2#include <limits.h>
3 
4int main() {
5 unsigned int max_u = UINT_MAX;
6 printf("Max Unsigned: %u\n", max_u);
7 printf("Max + 1: %u (Wrap-around!)\n", max_u + 1);
8 return 0;
9}
Section Detail

Operators, Expressions, and Side Effects

The Mechanics of Expressions

In C, an Expression is a sequence of operators and operands that specifies a computation. Understanding the nuances of how these expressions are evaluated is the difference between writing robust code and creating subtle, non-deterministic bugs.

Arithmetic Operators and Pitfalls

Common arithmetic operators (+, -, *, /, %) behave as expected for floating-point and unsigned integers. However, signed integer arithmetic carries risks:

  • Integer Division: Truncates toward zero. (-5) / 2 results in -2.
  • The Modulo Operator (%): Requires integer operands. The identity (a/b)*b + a%b == a always holds in C.

Bitwise Operators: The Hardware Interface

C is the language of choice for drivers and embedded systems because of its direct support for bit-level manipulation.

OperatorDescriptionCommon Use Case
&Bitwise ANDMasking bits (clearing specific bits).
|Bitwise ORSetting bits.
^Bitwise XORToggling bits or simple swaps.
~Bitwise NOTOne’s complement (inverting all bits).
<<Left ShiftMultiply by power of 2.
>>Right ShiftDivide by power of 2 (Behavior for signed is implementation-defined).
c
1#include <stdio.h>
2 
3int main() {
4 unsigned char flag = 0x01; // 0000 0001
5 flag = flag << 3; // 0000 1000 (Value: 8)
6 printf("Flag value: %u\n", flag);
7
8 if (flag & 8) {
9 printf("Bit 3 is set!\n");
10 }
11 return 0;
12}

Logical Operators and Short-Circuiting

Logical AND (&&) and OR (||) are guaranteed to evaluate from left-to-right. They use Short-Circuit Evaluation:

  • In A && B, if A is false, B is never evaluated.
  • In A || B, if A is true, B is never evaluated.

This property is frequently used to guard against null pointer dereferences:

if (ptr != NULL && ptr->value > 10) { ... }

Precedence, Associativity, and Sequence Points

Precedence determines which operator is applied first in an expression like a + b * c. Associativity determines the direction of evaluation for operators of the same precedence (e.g., a - b - c is (a - b) - c).

The Sequence Point Rule

A “Sequence Point” is a point in time where all “side effects” (like variable assignments) from previous evaluations are guaranteed to be complete.

  • The end of a statement (;) is a sequence point.
  • The &&, ||, and , operators are sequence points.

CRITICAL RULE: Between two sequence points, an object’s value shall be modified at most once by the evaluation of an expression. Furthermore, the prior value shall be read only to determine the value to be stored. Failure to follow this results in Undefined Behavior.

Sequence Point Violation

int i = 5;
// Is this code valid or undefined behavior?
i = i++ + 1;
// Answer: 

The Ternary Operator

The ?: operator is C’s only ternary operator. It is an expression, not a statement, meaning it returns a value and can be used on the right-hand side of an assignment.

int max = (a > b) ? a : b;

Program Logic

Section Detail

Control Flow and Branching Logic

The Concept of Selection

Control flow determines the path execution takes through a program. In C, branching is achieved primarily through if-else constructs and switch statements. Unlike high-level languages with dedicated bool types, C treats logic as a numerical property.

Truthiness: The Zero Rule

In C, there is no native boolean type in the core language (prior to C99’s <stdbool.h>). The rules for truth are simple:

  • 0 (Zero) is False. This applies to integers, floating-point numbers, and the NULL pointer.
  • Anything Non-Zero is True. This includes negative numbers.
if (5) { /* This will always execute */ }
if (0) { /* This will never execute */ }
if (-1) { /* This will execute! */ }

The if-else Construct

The if statement evaluates an expression. If it is non-zero, the following block executes.

Dangling Else Problem

When nesting if statements, an else always associates with the nearest preceding if that doesn’t have an else. This can lead to logic errors if braces are omitted.

if (a > 0)
    if (b > 0)
        do_thing();
else // This else belongs to (b > 0), not (a > 0)!
    do_other_thing();

Best Practice: Always use curly braces {} to avoid ambiguity and improve maintainability.

The switch Statement and Jump Tables

The switch statement is used for multi-way branching based on an integer constant.

switch (expression) {
    case CONSTANT_1:
        // statement
        break;
    case CONSTANT_2:
        // statement
        break;
    default:
        // statement
}

Performance: why use Switch?

When a switch has many cases, the compiler often optimizes it using a Jump Table. Instead of checking every condition sequentially (as in an if-else if chain), the CPU can jump directly to the correct code block using an offset in an array of addresses. This makes switch statements O(1)O(1) in terms of time complexity in many scenarios.

Fall-through Behavior

Unlike modern languages (like Swift or Go), C cases “fall through” by default. If you omit the break keyword, execution continues into the next case. This is occasionally useful for mapping multiple inputs to the same output:

Fall-through Logic

switch(input) {
    case 'y':
    case 'Y':
        confirmed = 1;
        ;
    case 'n':
        confirmed = 0;
        break;
}

Branch Prediction and Performance

Modern CPUs use Branch Predictors to guess the outcome of if statements before they are fully evaluated. This allows the CPU to pre-fetch instructions.

  • Predictable Branches: If a branch is almost always true (e.g., checking for errors that rarely occur), the CPU stays fast.
  • Mispredictions: If a branch’s outcome is random (e.g., processing unsorted data), the CPU must flush its pipeline when it guesses wrong, leading to a significant performance hit.
c
1#include <stdio.h>
2 
3int main() {
4 int x = 10;
5 // Using ternary for compact branching
6 const char* result = (x % 2 == 0) ? "Even" : "Odd";
7 printf("Number %d is %s\n", x, result);
8 return 0;
9}
Section Detail

Iteration and Loop Mechanisms

Iteration in C

Iteration allows a program to repeat a block of code while a condition is met. C provides three primary loop constructs: while, do-while, and for.

The while vs. do-while

The fundamental difference lies in when the condition is evaluated.

  • while: Pre-test loop. The body may execute zero times.
  • do-while: Post-test loop. The body is guaranteed to execute at least once. This is often used for input validation or state machines where an action must be performed before its result can be checked.
int i = 10;
while (i < 5) { /* Never runs */ }

do {
    /* Runs exactly once */
} while (i < 5);

The Versatile for Loop

The for loop is syntactically sugar for a while loop but is much more expressive. It consists of three expressions:

  1. Initialization: Executed once before the loop starts.
  2. Condition: Evaluated before each iteration.
  3. Stepper: Evaluated at the end of each iteration.

The Comma Operator in Loops

C’s comma operator allows you to include multiple expressions where only one is expected. This is particularly useful in for loops for managing multiple counters.

for (int i = 0, j = 10; i < j; i++, j--) {
    printf("i: %d, j: %d\n", i, j);
}

Loop Control: break and continue

  • break: Immediately terminates the innermost loop.
  • continue: Skips the remainder of the current iteration and jumps to the condition/stepper evaluation.

Warning: Overusing break and continue can lead to “spaghetti code.” Use them judiciously to handle exceptional cases rather than as primary control logic.

Infinite Loops and Machine Behavior

In systems programming, infinite loops are often intentional (e.g., an OS kernel idle loop or an embedded control loop).

for (;;) { /* The idiomatic C infinite loop */ }
while (1) { /* Also common */ }

Optimization: Loop Unrolling

High-performance C relies on the compiler to optimize loops. Loop Unrolling is a technique where the compiler replicates the loop body multiple times to reduce the overhead of the condition check and the branch.

Manually unrolling:

// Standard
for (int i = 0; i < 4; i++) { process(i); }

// Unrolled (conceptually)
process(0); process(1); process(2); process(3);

Modern compilers like GCC and Clang will do this automatically if they determine it will improve performance without blowing up the code size (cache pressure).

Loop Evaluation

int i = 0;
while (i < 3) {
    printf("%d", i);
    ;
}
c
1#include <stdio.h>
2 
3int main() {
4 // Calculating factorials via iteration
5 int n = 5;
6 long long fact = 1;
7 for(int i = 1; i <= n; i++) {
8 fact *= i;
9 }
10 printf("Factorial of %d is %lld\n", n, fact);
11 return 0;
12}

Functions & Scope

Section Detail

Functional Decomposition and the Call Stack

The Function as an Abstraction

In C, a function is a named block of code that performs a specific task. They are the primary tool for Decomposition—breaking complex problems into manageable, testable units.

A C function consists of:

  1. Return Type: The type of value the function returns to the caller (or void).
  2. Name: A unique identifier.
  3. Parameters: A list of data types and names passed into the function.
  4. Body: The implementation.

Prototypes vs. Definitions

C is a single-pass compiler (conceptually). If you call a function before the compiler has seen its definition, it won’t know the types of the arguments or the return value.

  • Function Prototype (Declaration): Tells the compiler the function’s signature. Usually placed in header files or at the top of a .c file.
  • Function Definition: The actual implementation of the function.
// Prototype
int square(int x);

int main() {
    int res = square(5); // Compiler knows 'square' takes an int and returns an int
}

// Definition
int square(int x) {
    return x * x;
}

The Call Stack and Stack Frames

Every time a function is called, a new Stack Frame (or Activation Record) is pushed onto the Call Stack. This frame contains:

  • The function’s local variables.
  • The parameters passed by the caller.
  • The Return Address (where to jump back to when the function finishes).

When the function returns, its stack frame is “popped,” and its local memory is effectively reclaimed (this is why local variables are called Automatic variables).

Parameter Passing: Pass-by-Value

C follows a strict Pass-by-Value model. When you pass a variable to a function, the function receives a copy of the data.

To modify a variable from within a function, you must pass the address of the variable (using pointers). Even then, C is still passing the value of the address.

c
1#include <stdio.h>
2 
3void increment(int val) {
4 val = val + 1;
5 printf("Inside function: %d\n", val);
6}
7 
8int main() {
9 int count = 5;
10 increment(count);
11 printf("In main: %d (Unchanged!)\n", count);
12 return 0;
13}

Recursion and Stack Overflow

A function that calls itself is Recursive. Each recursive call adds a new frame to the stack. If the recursion is too deep (or infinite), it will exhaust the stack memory, resulting in a Stack Overflow.

int factorial(int n) {
    if (n <= 1) return 1; // Base case
    return n * factorial(n - 1); // Recursive step
}

The Return Type

 print_message(void) {
    printf("Hello\n");
}

Variable Scope and Lifetime

  • Local Scope: Variables declared inside a block {}. They exist only while that block is executing.
  • Global Scope: Variables declared outside any function. They exist for the entire duration of the program.
  • Static Locals: Local variables that retain their value between function calls. They are stored in the Data/BSS segment rather than the stack.

Data Structures

Section Detail

Contiguous Memory and Array Mechanics

The Array Abstraction

In C, an Array is a collection of elements of the same type, stored in a contiguous block of memory. This physical contiguity is what makes arrays extremely fast: calculating the address of an element at index i is a simple arithmetic operation.

Address of A[i]=Base Address+(i×Size of Element)\text{Address of } A[i] = \text{Base Address} + (i \times \text{Size of Element})

Array Decay and Pointers

One of the most confusing aspects of C is the relationship between arrays and pointers. In most expressions, an array name decays into a pointer to its first element.

int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // 'arr' decays to &arr[0]

Because of this, the bracket notation arr[i] is actually syntactic sugar for pointer arithmetic: arr[i] is identical to *(arr + i).

Lack of Bounds Checking

C is a “trust the programmer” language. It does not check if an index is within the bounds of an array at runtime.

  • Accessing arr[10] on an array of size 5 will simply read whatever happens to be in memory at that offset.
  • This results in Undefined Behavior (UB) and is a primary source of security vulnerabilities (Buffer Overflows).

Square Bracket Syntax

int vals[5] = {1, 2, 3};
// Which expression is equivalent to vals[2]?
// Answer: 

Multi-dimensional Arrays

C stores multi-dimensional arrays in Row-Major Order. This means the elements of the first row are stored first, followed by the second row, and so on.

int matrix[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

In memory, this looks like a single flat sequence: 1, 2, 3, 4, 5, 6.

Array Parameters in Functions

When you pass an array to a function, you are actually passing a pointer to the first element. The function has no way of knowing the size of the array unless you pass it as a separate argument.

c
1#include <stdio.h>
2 
3// 'arr' is NOT a copy; it's a pointer to the original
4void partial_sum(int arr[], int size) {
5 int sum = 0;
6 for(int i = 0; i < size; i++) sum += arr[i];
7 printf("Sum: %d\n", sum);
8}
9 
10int main() {
11 int my_data[] = {1, 2, 3, 4, 5};
12 partial_sum(my_data, 5);
13 return 0;
14}

Designated Initializers (C99)

Modern C allows you to initialize specific elements of an array by their index, which is incredibly useful for sparse arrays or configuration tables:

int settings[100] = { [0] = 1, [50] = 5, [99] = -1 };

In this example, all other elements are automatically initialized to zero.

Section Detail

Strings and Character Manipulation

Strings: Just Character Arrays

In C, there is no built-in string type. A string is simply an array of characters (char) where the end of the string is marked by a special character called the Null Terminator ('\0', which has an ASCII value of 0).

Because of this design, a string of length NN always requires N+1N+1 bytes of storage.

String Literals and Memory

When you write "Hello", you are creating a String Literal.

  • Literals are typically stored in the Text Segment (read-only memory) of the executable.
  • Trying to modify a string literal (e.g., char *p = "Hello"; p[0] = 'h';) results in Undefined Behavior, often a segmentation fault.

To have a modifiable string, you must copy it into an array:

char modifiable[] = "Hello"; // Copies "Hello" into stack memory
modifiable[0] = 'h';         // Perfectly valid

Common <string.h> Functions

C provides a standard library for string manipulation. However, these functions are notorious for being unsafe if not used with extreme care.

FunctionDescriptionRisk
strlen()Returns length (excluding \0).O(N)O(N) time complexity; slow for long strings.
strcpy()Copies one string to another.Buffer Overflow: Doesn’t check if destination is large enough.
strcmp()Compares two strings lexically.Returns 0 if equal, not a boolean true/false.
strcat()Appends one string to another.Also prone to buffer overflows.

The Buffer Overflow Vulnerability

If you try to store 10 characters in an array of size 5, C will happily write the extra 5 characters into whatever follows the array in memory. This can overwrite return addresses or other variables, allowing attackers to hijack program execution.

The Buffer Size

char buffer[6];
// How many 'actual' characters can this buffer safely hold?
// Answer: 

Pointer Arithmetic with Strings

Because strings are arrays, we can iterate through them using pointer arithmetic. This is often faster than indexing and is the idiomatic way to write string functions in C.

c
1#include <stdio.h>
2 
3void my_puts(const char *s) {
4 while (*s != '\0') {
5 putchar(*s);
6 s++; // Move to next character
7 }
8 putchar('\n');
9}
10 
11int main() {
12 my_puts("Mastering C Strings!");
13 return 0;
14}

Modern Safety: snprintf

In modern C development, functions like strcpy are often banned in favor of snprintf or strncpy, which allow you to specify the maximum number of bytes to write.

char dest[10];
snprintf(dest, sizeof(dest), "%s", "This is a very long string");
// 'dest' will contain "This is a\0" (safe truncation)

Memory Mastery

Section Detail

The Address Space: Pointer Fundamentals

What is a Pointer?

In C, a Pointer is a variable whose value is a memory address.

Think of RAM as a massive array of bytes. Each byte has a unique numerical index called its address. When you declare int x = 5;, the compiler allocates space in that array to hold the value 5. A pointer to x simply holds that numerical index.

Operators of Power: & and *

  1. The Address-of Operator (&): Returns the memory address of an object.
  2. The Indirection (Dereference) Operator (*): Accesses the value at the address held by a pointer.
int x = 42;
int *p = &x;  // 'p' holds the address of 'x'

printf("%d", *p); // Goes to the address in 'p' and reads the value (42)
*p = 100;         // Goes to the address in 'p' and overwrites it with 100

Pointers and Types

Why do we need different types of pointers (like int*, char*, double*) if every pointer just holds an address?

The type of a pointer tells the compiler:

  1. How many bytes to read/write when dereferencing.
  2. How to interpret those bytes (e.g., as a signed integer or a floating-point number).
  3. Instruction Scaling: How many bytes to jump when performing pointer arithmetic (e.g., p + 1 jumps by 4 bytes for an int* but only 1 byte for a char*).

The NULL Pointer

A NULL pointer is a pointer that points to “nothing” (usually address 0).

  • It is used to signify that a pointer is not yet initialized or that a search/allocation failed.
  • DANGER: Dereferencing a NULL pointer is Undefined Behavior and usually triggers a Segmentation Fault.

Dereferencing

int y = 10;
int *ptr = &y;
// How do we change the value of y to 20 using only ptr?
 = 20;

Why Use Pointers?

If pointers are so dangerous, why does C use them so heavily?

  1. Efficiency: Instead of copying a 10,000-byte structure into a function, we pass an 8-byte pointer (on 64-bit systems).
  2. Dynamic Memory: Pointers are the only way to access memory allocated at runtime (the Heap).
  3. Hardware Access: Pointers allow us to map specific memory addresses to hardware registers (common in embedded systems).
c
1#include <stdio.h>
2 
3int main() {
4 int a = 5, b = 10;
5 int *p1 = &a, *p2 = &b;
6
7 printf("Initially: a=%d, b=%d\n", a, b);
8
9 // Swapping via pointers
10 int temp = *p1;
11 *p1 = *p2;
12 *p2 = temp;
13
14 printf("After swap: a=%d, b=%d\n", a, b);
15 return 0;
16}

Pointer Decay (Recap)

Remember that in C, the name of an array decodes to the address of its first element. Thus, int *p = arr; is shorthand for int *p = &arr[0];.

Section Detail

Advanced Pointers: Indirection and Arithmetic

Pointer Arithmetic: Moving through Memory

As established, adding 1 to a pointer does not add one byte to the address; it adds one unit of the underlying type.

If p is an int* and an int is 4 bytes: p + 1 increases the address by 4.

This allows for incredibly efficient iteration over contiguous structures like arrays.

Double Indirection: Pointers to Pointers

In C, we can have pointers that point to other pointers. This is commonly used for:

  1. Modifying a Pointer in a Function: Since C is pass-by-value, to change which address a pointer points to, you must pass a pointer to that pointer (int **).
  2. Dynamic 2D Arrays: An array of pointers, where each pointer points to a row.
  3. Command Line Arguments: char **argv is an array of strings (where each string is char*).
int x = 10;
int *p = &x;
int **pp = &p; // pp -> p -> x

printf("%d", **pp); // Output: 10

Function Pointers: Executable Logic as Data

A function’s code resides in memory, just like data. A Function Pointer stores the address of the entry point of a function. This enables Callbacks and higher-order programming in C.

Syntax: Return_Type (*Pointer_Name)(Parameter_Types);

int add(int a, int b) { return a + b; }

// declare a pointer to a function taking two ints and returning one int
int (*operation)(int, int) = add;

int result = operation(5, 3); // result is 8

Generic Pointers: void *

The void * type is a Generic Pointer. It can point to any data type, but it cannot be dereferenced directly because the compiler doesn’t know the size or type of the underlying data.

To use the data, you must “cast” it back to a specific type.

int x = 5;
void *vp = &x;
// printf("%d", *vp); // ERROR: Invalid
printf("%d", *(int*)vp); // Correct: Cast to int* then dereference

Generic Pointers

void *ptr;
int x = 10;
ptr = &x;
// How to read x into 'val'?
int val = ;

The Dangers: Aliasing and Dangling Pointers

Pointer Aliasing occurs when two pointers point to the same memory location. This can confuse the compiler’s optimizer unless the restrict keyword is used.

Dangling Pointers are pointers that point to memory that has been freed or is out of scope.

int* get_ptr() {
    int x = 10;
    return &x; // WARNING: x is on the stack and will be destroyed!
}
c
1#include <stdio.h>
2 
3// A function that uses a function pointer as a callback
4void process(int x, int (*callback)(int)) {
5 printf("Processed result: %d\n", callback(x));
6}
7 
8int square(int n) { return n * n; }
9 
10int main() {
11 process(5, square);
12 return 0;
13}
Section Detail

Dynamic Memory: The Heap

The Heap: Memory at Runtime

So far, we have used Automatic Storage (local variables on the stack). However, the stack has limitations:

  1. Fixed Size: You must know the size at compile-time (or use VLAs, which are controversial).
  2. Limited Lifetime: Variables die when the function returns.

The Heap is a large pool of memory that exists independently of function calls. We can request memory from the heap at any time and it stays allocated until we explicitly release it.

The Allocation Quadruplet: <stdlib.h>

C provides four primary functions for managing heap memory:

FunctionPurposeKey Detail
malloc(size)Allocates size bytes.Memory is uninitialized (contains garbage).
calloc(n, size)Allocates n elements of size.Memory is zero-initialized.
realloc(ptr, size)Resizes an existing block.May move the block to a new address.
free(ptr)Releases the block.Using the pointer after free is Undefined Behavior.

The Lifecycle of an Allocation

  1. Request: int *p = malloc(10 * sizeof(int));
  2. Safety Check: Always check if malloc returned NULL (which happens if the system is out of memory).
  3. Usage: Use the pointer just like an array.
  4. Cleanup: free(p);
c
1#include <stdio.h>
2#include <stdlib.h>
3 
4int main() {
5 int n = 5;
6 int *arr = calloc(n, sizeof(int));
7
8 if (arr == NULL) return 1; // Out of memory
9
10 for(int i = 0; i < n; i++) {
11 arr[i] = i * i;
12 printf("%d ", arr[i]);
13 }
14
15 free(arr);
16 printf("\nMemory freed successfully.\n");
17 return 0;
18}

The Dangers of Manual Management

In languages like Python or Java, a Garbage Collector cleans up after you. In C, you are the garbage collector.

1. Memory Leaks

A leak occurs when you lose the pointer to an allocated block without calling free(). The memory remains “reserved” but unusable, eventually crashing the system if it happens in a loop.

2. Use-After-Free

Dereferencing a pointer after it has been passed to free(). The memory might have been re-assigned to something else, causing silent data corruption.

3. Double Free

Calling free() on the same pointer twice. This usually crashes the program immediately as it corrupts the heap’s internal metadata.

The realloc Pattern

int *p = malloc(10 * sizeof(int));
// We need more space!
int *temp = (p, 20 * sizeof(int));
if (temp != NULL) p = temp;

Memory Fragmentation

Over time, frequent allocations and deallocations can leave “holes” in the heap—small blocks of free memory that are too small to satisfy new requests. High-performance systems often use Custom Allocators (like jemalloc or mimalloc) to mitigate this.

Complex Data

Section Detail

Compound Data: Structures and Unions

User-Defined Types

C provides three primary ways to create custom types: Structures, Unions, and Enumerations. These allow you to group related data into logical entities.

Structures: Grouping Heterogeneous Data

A struct is a block of memory that holds multiple variables (members) of different types.

struct Player {
    char name[32];
    int score;
    float health;
};

Accessing Members: . vs ->

  • use the Dot Operator (.) for direct instances.
  • Use the Arrow Operator (->) for pointers to instances. ptr->x is shorthand for (*ptr).x.

Memory Alignment and Padding

This is a critical systems concept. A struct’s size is not always the sum of its parts. Compilers insert “padding” bytes to ensure members are aligned with the CPU’s word boundaries for faster access.

struct Mixed {
    char c;   // 1 byte
    // 3 bytes of padding inserted here!
    int i;    // 4 bytes
};
// sizeof(struct Mixed) is likely 8, not 5.

Optimization Tip: Reorder struct members from largest to smallest to minimize padding.

Unions: Shared Memory

A union is a special type where all members share the same starting memory address. The size of the union is the size of its largest member.

Use unions for:

  1. Type Punning: Interpreting the same bits in different ways.
  2. Mutually Exclusive Data: When an object can be “A” or “B”, but never both at once.
union Data {
    int i;
    float f;
} u;
u.i = 42; 
// Now u.f also contains the bit pattern of 42 interpreted as a float.

Bit-fields: Power at the Bit Level

In embedded programming, we often need to packet data into specific bits to save space or match hardware registers.

struct Flags {
    unsigned int is_active : 1; // 1 bit
    unsigned int error_code : 3; // 3 bits
    unsigned int reserved  : 4; // 4 bits
};
// Total size: 1 byte (plus possible padding to int size)

Memory Layout

union Example {
    char a;
    int b;
};
// If sizeof(char) is 1 and sizeof(int) is 4,
// what is sizeof(union Example)?
// Answer: 

Enumerations (enum)

Enums provide a way to define integer constants with human-readable names, improving code clarity.

enum Status { IDLE, RUNNING, ERROR = -1 };
enum Status current = IDLE; // 'current' is effectively 0
c
1#include <stdio.h>
2 
3struct Vector {
4 float x, y, z;
5};
6 
7int main() {
8 struct Vector v = {1.0f, 2.0f, 3.0f};
9 struct Vector *p = &v;
10
11 p->x += 10.0f;
12
13 printf("Vector: {%.1f, %.1f, %.1f}\n", v.x, v.y, v.z);
14 return 0;
15}

Advanced Features

Section Detail

Storage Classes, Linkage, and Mutability

The Lifecycle of Data

Every variable in C has two properties that define its behavior:

  1. Scope: The region of code where the variable is visible.
  2. Storage Duration: How long the variable stays in memory.

Storage Classes

1. auto

The default for local variables. They are stored on the Stack and have Automatic Storage Duration (destroyed when the block ends).

2. static

The static keyword has two distinct meanings depending on where it is used:

  • Inside a Function: The variable’s lifetime is extended to the entire program duration (stored in the Data/BSS segment). It retains its value between function calls.
  • At File Scope: The variable has Internal Linkage, meaning it is only visible within that specific .c file and cannot be accessed by other files via extern.

3. extern

Used to declare a variable or function that is defined in another Translation Unit (file). It gives the variable External Linkage.

// file1.c
int global_count = 10;

// file2.c
extern int global_count; // Accesses the variable in file1.c

4. register

A hint to the compiler to store the variable in a CPU Register instead of RAM for faster access. Modern compilers are so good at register allocation that this is rarely used today, except in extremely tight loops.

Type Qualifiers

const

Indicates that the variable’s value cannot be changed after initialization. This allows the compiler to perform optimizations and move data to read-only memory.

volatile: The Embedded Essential

The volatile qualifier tells the compiler: “This variable can change at any time without this code doing anything.”

  • Example: A memory-mapped hardware register or a shared variable in a multi-threaded application.
  • Without volatile, the compiler might optimize away “redundant” reads, failing to see the hardware change.
volatile int *sensor = (int*)0x40001234;
while (*sensor == 0) { /* Wait for hardware event */ }
// Without volatile, the compiler might turn this into an infinite loop!

Pointer Qualifiers

Qualifiers can be applied to the pointer itself or the data it points to. This distinction is vital for API design:

DeclarationMeaning
const int *pPointer to a constant integer (Data cannot change).
int * const pConstant pointer to an integer (Address cannot change).
const int * const pConstant pointer to a constant integer (Nothing can change).

Static Lifetime

int get_next_id() {
     int id = 0;
    return ++id;
}
c
1#include <stdio.h>
2 
3void count_calls() {
4 static int calls = 0;
5 printf("Call #%d\n", ++calls);
6}
7 
8int main() {
9 count_calls();
10 count_calls();
11 count_calls();
12 return 0;
13}
Section Detail

The C Preprocessor: Meta-Programming

The Translation Pipeline (Phase 4)

The C Preprocessor (CPP) is a separate program that runs before the actual compilation. It does not understand C syntax; it performs Token-based Text Substitution. Directives for the preprocessor always start with a #.

File Inclusion: #include

  • #include <file>: Searches the system include directories (Standard Library).
  • #include "file": Searches the local project directory first, then fallback to system paths.

The Include Guard Pattern: To prevent a header from being included multiple times (which causes “redefinition” errors), every header file should use an include guard:

#ifndef MY_HEADER_H
#define MY_HEADER_H

// Your declarations here

#endif

Macros: #define

Macros allow you to define symbols that the preprocessor will swap for their replacement text wherever they appear.

1. Simple Constants

#define MAX_BUFFER 1024

2. Function-like Macros

Macros can take arguments. However, because they are just text replacement, they are dangerous.

#define SQUARE(x) x * x
// SQUARE(5 + 1) becomes 5 + 1 * 5 + 1 = 11 (Wrong!)
// Fixed: #define SQUARE(x) ((x) * (x))

Conditional Compilation

The preprocessor can “strip out” code blocks based on specific conditions. This is the primary way C achieves Portability across different Operating Systems.

#ifdef _WIN32
    // Windows-specific code
#elif __linux__
    // Linux-specific code
#endif

Stringification and Token Pasting

The preprocessor provides two special operators:

  • # (Stringification): Converts a macro argument into a string literal.
  • ## (Token Pasting): Merges two tokens into one.
#define DEBUG_PRINT(var) printf(#var " = %d\n", var)
int speed = 60;
DEBUG_PRINT(speed); // Expands to: printf("speed" " = %d\n", speed);

Include Guards

#ifndef HEADER_H
 HEADER_H
// ... code ...
#endif

The Pitfalls: Macros vs. inline

Modern C often prefers inline functions over macros because:

  1. inline functions obey Scope rules.
  2. They are Type-safe.
  3. They avoid Multiple Evaluation side effects.
c
1#include <stdio.h>
2 
3// Macro with potential side effects
4#define MAX(a, b) ((a) > (b) ? (a) : (b))
5 
6int main() {
7 int x = 5, y = 10;
8 printf("Max: %d\n", MAX(x++, y++));
9 // Side effect: y will be incremented TWICE because it is used twice in the macro expansion.
10 printf("x: %d, y: %d\n", x, y);
11 return 0;
12}

Working with Data

Section Detail

I/O Streams and Persistence

The Stream Abstraction

In C, we do not interact directly with files. Instead, we interact with Streams. A stream is a uniform interface for reading and writing data, regardless of whether that data is coming from a hard drive, a keyboard, or a network socket.

The Standard Library provides three streams by default:

  • stdin: Standard Input (Keyboard)
  • stdout: Standard Output (Console)
  • stderr: Standard Error (Console, unbuffered)

Working with FILE *

To work with a custom file, we use a FILE pointer. This pointer manages a buffer and tracks the current position in the file.

1. Opening a File

FILE *fp = fopen("filename.txt", "mode");

Common Modes:

  • "r": Read (fails if file doesn’t exist).
  • "w": Write (overwrites existing file).
  • "a": Append (writes to the end).
  • "rb", "wb": Binary modes (prevents OS from altering line endings).

2. Error Handling

Opening a file can fail (missing file, permission denied). Always check if the pointer is NULL.

Reading and Writing

There are three ways to move data through a stream:

LevelInputOutputUsage
Characterfgetc()fputc()Fine-grained parsing.
Linefgets()fputs()Reading text safely.
Formattedfscanf()fprintf()Structured data.
Blockfread()fwrite()Large binary blocks (Fastest).

Position and Random Access

A file pointer maintains an internal “cursor.” You can move this cursor manually:

  • fseek(fp, offset, origin): Move the cursor.
  • ftell(fp): Get the current cursor position.
  • rewind(fp): Jump back to the start.

File Safety

char buffer[100];
FILE *fp = fopen("data.txt", "r");
if (fp != ) {
    fgets(buffer, 100, fp);
    fclose(fp);
}

Buffered I/O

For performance, C does not write every byte to disk immediately. It stores them in a Buffer and writes them in chunks.

  • fflush(fp): Forces the buffer to be written to disk immediately.
  • fclose(fp): Automatically flushes and closes the stream.
c
1#include <stdio.h>
2 
3int main() {
4 // Note: CodeRunner has limited file access,
5 // but we can demonstrate stdout formatting.
6 FILE *my_out = stdout;
7 fprintf(my_out, "Log level: %s\n", "DEBUG");
8 fprintf(my_out, "Writing formatted data to a stream...\n");
9 return 0;
10}

Binary vs. Text

In Text Mode, some systems automatically convert \n to \r\n and vice-versa. In Binary Mode ("rb", "wb"), the data is moved exactly as-is, which is crucial for images, executables, and custom data structures.

Advanced Features

Section Detail

The Standard Library (libc) Utilities

The Role of libc

The C Standard Library (often referred to as libc) is a collection of headers and binaries that provide essential functionality. It is the bridge between your portable C code and the underlying Operating System.

Sorting and Searching

Instead of writing your own sort, C provides highly optimized generic algorithms.

qsort: The Generic Sorter

qsort can sort any array of any type. It uses a Comparison Callback to determine the order of elements.

void qsort(void *base, size_t nitems, size_t size, 
           int (*compar)(const void *, const void *));

Works on sorted arrays to find an element in O(logN)O(\log N) time.

Process Control and Termination

  1. exit(status): Terminates the program normally. A status of 0 indicates success.
  2. abort(): Terminates the program abnormally (often generates a core dump for debugging).
  3. atexit(callback): Registers a function to be called automatically when the program exits. This is perfect for cleaning up resources or logging.
void cleanup() { printf("Cleaning up...\\n"); }

int main() {
    atexit(cleanup);
    return 0; // cleanup() is called automatically
}

String to Number Conversions

Avoid atoi—it has no error handling. Use the strto... family instead:

  • strtol: String to long.
  • strtod: String to double.

These functions provide a pointer to the “first character that couldn’t be converted,” allowing you to detect malformed input.

Random Number Generation

  • rand(): Returns a pseudo-random integer.
  • srand(seed): Seeds the random number generator. Usually seeded with time(NULL).

Note: rand() is not cryptographically secure. For security applications, use OS-specific APIs like /dev/urandom or BCryptGenRandom.

Comparison Callbacks

int cmp(const void *a, const void *b) {
    return (*(int*)a - *(int*)b);
}
// To sort an array of 5 ints:
qsort(arr, 5, sizeof(int), );
c
1#include <stdio.h>
2#include <stdlib.h>
3#include <time.h>
4 
5int main() {
6 srand(time(NULL));
7 printf("Random Roll (1-6): %d\n", (rand() % 6) + 1);
8 return 0;
9}

Math and Limits

  • <math.h>: sin, cos, pow, sqrt, ceil, floor. Note: You often need to link with -lm.
  • <limits.h>: INT_MAX, CHAR_BIT, LLONG_MIN. Use these to write portable code that doesn’t make assumptions about bit-widths.

Algorithms

Section Detail

Dynamic Data Structures: Linked Lists

Why not just use Arrays?

Arrays are fast for access, but they are rigid:

  1. Fixed Size: You must reallocate and copy the entire array to grow it.
  2. Expensive Insertions: Inserting at the beginning requires shifting every other element.

A Linked List is a sequence of Nodes scattered throughout the heap. Each node knows only two things: its data and where the next node is.

Self-Referential Structures

To build a node, we need a structure that contains a pointer to itself. This is called a self-referential structure.

struct Node {
    int data;           // The payload
    struct Node *next;  // Pointer to the next node
};

The Head Pointer

The “Head” is just a standard pointer that stores the address of the first node. If the list is empty, head is NULL.

Traversing the List

To move through the list, we use a temporary pointer. We never move the head pointer itself during traversal, or we will lose the list!

struct Node *current = head;
while (current != NULL) {
    printf("%d -> ", current->data);
    current = current->next;
}

Insertion at the Head

Inserting at the head is an O(1)O(1) operation regardless of the list size.

  1. Create a new node.
  2. Set new_node->next to the current head.
  3. Update head to point to the new_node.

Memory Cleanup

// To delete a list, we must free every node.
struct Node *tmp;
while (head != NULL) {
    tmp = head;
    head = ;
    free(tmp);
}

The Price of Dynamicity

Linked lists are powerful but come with costs:

  • No Random Access: To get the 100th element, you must visit the previous 99.
  • Cache Inefficiency: Because nodes are scattered in memory, they suffer from frequent cache misses.
  • Memory Overhead: Each node uses extra bytes for the next pointer.
c
1#include <stdio.h>
2#include <stdlib.h>
3 
4struct Node {
5 int data;
6 struct Node *next;
7};
8 
9int main() {
10 // Creating a simple 2-node list: [10] -> [20] -> NULL
11 struct Node *head = malloc(sizeof(struct Node));
12 head->data = 10;
13 head->next = malloc(sizeof(struct Node));
14 head->next->data = 20;
15 head->next->next = NULL;
16 
17 struct Node *curr = head;
18 while(curr) {
19 printf("%d ", curr->data);
20 curr = curr->next;
21 }
22 
23 // Proper cleanup omitted for brevity
24 return 0;
25}
Section Detail

LIFO and FIFO: Stacks and Queues

Understanding Abstract Data Types (ADTs)

An Abstract Data Type defines what a structure does, but not how it is implemented in memory. In C, we can implement the same ADT using either static arrays or dynamic linked lists.

The Stack (LIFO)

A stack follows the Last-In, First-Out principle. Imagine a stack of dinner plates: you can only add or remove the plate at the top.

Operations:

  • Push: Add an item to the top.
  • Pop: Remove the top item.
  • Peek: Look at the top item without removing it.

Array-Based Implementation

An array is the fastest way to build a stack if you know the maximum size in advance.

#define MAX 100
int stack[MAX];
int top = -1;

void push(int val) {
    if (top < MAX - 1) stack[++top] = val;
}

int pop() {
    if (top >= 0) return stack[top--];
    return -1; // Error
}

The Queue (FIFO)

A queue follows the First-In, First-Out principle. Like a line at a supermarket, the person who arrived first is served first.

Operations:

  • Enqueue: Add to the back.
  • Dequeue: Remove from the front.

The Circular Buffer

If you use a simple array for a queue, “dequeuing” from the front leaves wasted space at the beginning. To solve this, we use a Circular Buffer where the tail wraps back to the beginning of the array when it reaches the end.

Choosing an Implementation

ImplementationAdvantageDisadvantage
ArrayO(1)O(1) access, cache-friendly.Fixed size, potential overflow.
Linked ListDynamic size, no overflow.O(1)O(1) but cache-unfriendly (allocations).

The Stack Pointer

// if top initialized to -1
void push(int x) {
    stack[] = x;
}

Practical Application: Function Calls

The CPU itself uses a stack to manage function calls. When main() calls sum(), the current state of main is pushed onto the Hardware Stack. When sum finishes, it is popped, and execution returns to main.

c
1#include <stdio.h>
2 
3// Simple array-based stack simulation
4int stack[5];
5int top = -1;
6 
7void push(int v) { stack[++top] = v; }
8int pop() { return stack[top--]; }
9 
10int main() {
11 push(10);
12 push(20);
13 printf("Popped: %d\n", pop());
14 printf("Top now: %d\n", pop());
15 return 0;
16}

Robust Code

Section Detail

Error Handling and Defensive Programming

The Philosophy of Failure

C has no try-catch blocks or native exception handling. In the C model, errors are ordinary values. They are not exceptional events that interrupt the flow; they are expected outcomes that must be checked and handled explicitly by the programmer.

Patterns of Error Signaling

There are three main ways a C function signals failure:

1. Integer Return Codes

Functions return an int. Typically, 0 means success, and negative values relate to specific error types.

if (calculate_physics() != 0) {
    handle_error();
}

2. Sentinel Values (NULL)

Functions that return pointers return NULL to signify failure (e.g., malloc, fopen).

3. The Global errno

Found in <errno.h>. When a system level function fails, it sets a global integer variable errno. You can translate this number into a human-readable string using strerror().

Warning: errno is only valid immediately after a failed call. Many functions do not clear errno on success, so a successful call won’t overwrite a previous error.

The goto for Cleanup

While generally discouraged, the goto statement is widely considered “the right way” to handle cleanup in complex functions with multiple failure points. This prevents the “Arrow Anti-pattern” of nested if statements.

int process_file() {
    int cleanup_needed = 0;
    if (open_file() != 0) goto fail;
    cleanup_needed = 1;

    if (allocate_buffer() != 0) goto fail;
    
    // ... logic ...
    return 0;

fail:
    if (cleanup_needed) close_file();
    return -1;
}

Advanced: setjmp and longjmp

Found in <setjmp.h>, this is C’s version of a “Non-local Goto.” It allows you to jump directly from a deeply nested function back up to a previous state in the call stack.

  • setjmp(env): Saves the current CPU state (registers, stack pointer).
  • longjmp(env, val): Restores that state, making it look like setjmp just returned val.

Use Case: This is how basic exception handling is implemented in low-level frameworks.

Error Conversion

#include <string.h>
#include <errno.h>
// How to get a string for the current error?
char *msg = (errno);

Defensive Programming

Robust C code assumes inputs are malicious and functions will fail.

  • Asserts: Use assert(ptr != NULL); from <assert.h> to catch logic errors during development.
  • Bounds Checking: Always verify array indices before access.
  • Sanitizers: Use tools like AddressSanitizer (ASan) to find memory errors that don’t immediately crash the program.
c
1#include <stdio.h>
2#include <stdlib.h>
3#include <errno.h>
4 
5int main() {
6 // Intentional error: dividing by zero isn't caught by errno,
7 // but we can catch it with logic.
8 int a = 10, b = 0;
9 if (b == 0) {
10 fprintf(stderr, "Fatal Error: Division by zero avoided.\n");
11 return EXIT_FAILURE;
12 }
13 printf("Result: %d\n", a / b);
14 return EXIT_SUCCESS;
15}

Real-world C

Section Detail

Interface with the Machine: Systems Programming

The Boundary: User Space vs. Kernel Space

When your C program runs, it lives in User Space. It is isolated from other programs and from the hardware itself by the CPU’s memory protection hardware. If you want to perform any action that affects the outside world (like writing to a file or sending a network packet), you must cross the boundary into Kernel Space.

The System Call (Syscall)

A system call is the mechanism for a program to request a service from the operating system kernel.

  1. The program places arguments in specific CPU registers.
  2. It executes a special assembly instruction (like syscall on x86-64 or svc on ARM).
  3. The CPU switches to a higher privilege level (Ring 0) and jumps to the kernel’s entry point.

The Standard Library as a Wrapper

Functions like printf() or fopen() are not system calls themselves. They are part of the C Standard Library, which performs buffering and formatting before eventually calling the true system-level functions (like write() or open() on POSIX systems).

Interfacing with Hardware

In kernel drivers or embedded C, we interact with hardware via Memory-Mapped I/O (MMIO). Specific memory addresses are physically wired to hardware devices.

// Force the compiler to read/write the actual memory address
// every single time, because the hardware could change it.
volatile uint32_t *led_control = (uint32_t *)0x40021018;
*led_control = 0x01; // Turn on a physical LED

The Application Binary Interface (ABI)

The ABI is the low-level contract between the compiler and the machine. It defines:

  1. Calling Conventions: Which registers are used to pass function arguments.
  2. Stack Alignment: How the stack pointer must be positioned.
  3. Data Layout: How struct padding is handled.

Understanding the ABI is essential for writing assembly code that calls C functions, or vice versa.

Hardware Keywords

// used to prevent optimization for MMIO
 uint32_t *reg = (uint32_t*)0x1234;

Interrupts and Callbacks

In systems programming, we often deal with Interrupts. An interrupt is a hardware signal that tells the CPU to stop what it’s doing and jump to a specific function called an Interrupt Service Routine (ISR).

  • ISRs must be incredibly fast.
  • They cannot perform blocking I/O or allocate memory from the standard heap.

Performance: The Inline Advantage

Technically, a function call has overhead (pushing to the stack). For performance-critical system code, we use the inline keyword. This asks the compiler to copy the function body directly into the calling site, eliminating the call overhead while keeping the code modular.

c
1#include <stdio.h>
2#include <stdint.h>
3 
4// Demonstrating bit-masking, a staple of systems C
5#define ENABLE_BIT (1 << 3)
6 
7int main() {
8 uint8_t flags = 0b00000000;
9 flags |= ENABLE_BIT; // Set bit 3
10
11 if (flags & (1 << 3)) {
12 printf("System Flag 3: ENABLED\n");
13 }
14
15 printf("Final Flag State: 0x%02X\n", flags);
16 return 0;
17}

Final Thoughts

Mastering C means mastering the machine. By understanding how the preprocessor, compiler, and linker collaborate with the Operating System, you gain the power to build the tools that empower all other software. From here, the journey continues into OS Development, Compiler Design, and High-Performance Systems.

Object-Oriented C & Encapsulation

Section Detail

Opaque Pointers and the ADT Pattern

Encapsulation in C

C is a procedural language and lacks native object-oriented classes. However, we can enforce encapsulation and modularity using the Abstract Data Type (ADT) pattern. This pattern prevents client code from reading or writing a data structure’s fields directly, shielding implementation details behind a clean public interface.

Example: The Incomplete Type Pattern

To hide a struct’s layout, we declare the struct as an incomplete type in the public header and define it fully in the private implementation file.

1. Public Header (book.h)

The header exposes only a pointer to the incomplete structure type:

#pragma once

// Incomplete type declaration
struct book;

// Opaque pointer type alias
typedef struct book* book_t;

// Public interface functions
book_t book_create(const char* title, int pages);
int book_get_pages(book_t self);
void book_destroy(book_t self);

2. Private Implementation (book.c)

The layout of the structure is defined only in the implementation file. Client code compiling against book.h cannot dereference book_t because the compiler does not know its size or members at compile time.

#include "book.h"
#include <stdlib.h>
#include <string.h>

struct book {
    char title[32];
    int pages;
};

book_t book_create(const char* title, int pages) {
    book_t self = malloc(sizeof(struct book));
    if (self != NULL) {
        strncpy(self->title, title, sizeof(self->title) - 1);
        self->pages = pages;
    }
    return self;
}

int book_get_pages(book_t self) {
    return self->pages;
}

void book_destroy(book_t self) {
    free(self);
}

UML to C Mapping Rules

When translating UML class diagrams into C source code, apply the following visibility and relationship rules:

Visibility Mapping

  • Public Attributes/Methods (+): Declared in the .h file.
  • Private Attributes/Methods (-): Declared inside the struct definition in the .c file. Helper functions are declared as static inside the .c file so they are invisible to the linker.

Association and Multiplicity

  • Multiplicity 0..1 (Optional Association): Mapped to a single pointer member.
  • Multiplicity 0..N (Collection): Mapped to a fixed-size array of pointers along with a count tracking the active size.
#define MAX_AUTHORS 5

struct book {
    char title[32];
    struct author* authors[MAX_AUTHORS]; // Array of pointers (0..N)
    int author_count;
};

Lifecycles: Composition vs. Aggregation

  • Composition (Filled Diamond): Represents strict ownership. The parent struct owns the child objects. The parent’s destructor must destroy all children.
  • Aggregation (Empty Diamond): Represents a temporary relationship. The child objects exist independently of the parent. The parent’s destructor simply frees its own container, leaving the children intact.

In a system where a Library has a composition relationship with Books, what must the library destructor do?

Define Opaque Pointer Type

#pragma once

struct author_st;

// Create the opaque pointer type mapping to struct author_st
typedef struct author_st* ;

References & Further Reading

  • Kernighan, B. W., & Ritchie, D. M. (1988). The C Programming Language (2nd ed.). Prentice Hall. Section 6.7 (Typedef) and Section 6.2 (Structures and Functions).
  • VIA University College: Embedded Software 1 (ESW1) Lecture Notes - Section 5 (Abstract Data Types).
Section Detail

Callbacks and Function Pointers

Decoupling with Function Pointers

In embedded systems, hardware drivers should remain decoupled from application logic. Rather than having a low-level button driver call a specific application function directly, the driver should accept a function pointer (a callback) from the application. This allows the driver to be reused across different projects without modification.

Example: Function Pointer Syntax & Typedefs

A function name is an implicit pointer to its address in the compiled binary. We can store this address in a variable.

Variable Declaration

The declaration syntax requires parenthesis to group the pointer asterisk with the identifier:

// Declares a pointer named 'cb' pointing to a function that takes an int and returns void
void (*cb)(int);

The Typedef Approach

To avoid convoluted signatures, define a function pointer type using typedef:

// Define 'callback_t' as a type for a function taking int and returning void
typedef void (*callback_t)(int);

// Expose a clean registration function in the driver interface
void button_register_callback(callback_t handler);

Callbacks in Interrupt Context

When a hardware event occurs (such as a button press or timer match), it triggers an Interrupt Service Routine (ISR). If the driver needs to invoke a registered callback in response, we must choose between two lifecycle strategies.

1. Synchronous Callbacks (Direct Execution)

The ISR executes the registered callback directly within the interrupt context:

ISR(INT0_vect) {
    if (app_callback != NULL) {
        app_callback(PIND); // Direct synchronous execution
    }
}
  • Pros: Immediate response; low latency.
  • Cons: While the callback executes, all other interrupts of equal or lower priority are blocked. If the callback performs complex logic or blocks, it can cause the system to miss critical real-time deadlines.

2. Asynchronous Callbacks (Event Queue Separation)

The ISR does not execute the callback. Instead, it places an event into a RAM-buffered Event Queue and exits immediately:

  1. Hardware triggers the interrupt.
  2. The ISR logs the event type/data, appends it to a ring buffer (queue) in RAM, and exits.
  3. The main thread’s loop polls the event queue, dequeues the event, and executes the callback in the normal user thread context.
  • Pros: ISRs remain extremely fast, keeping the system responsive.
  • Cons: Higher overhead; latency between the hardware event and callback execution is variable (jitter).

Why is it dangerous to perform floating-point math or print statements inside a synchronous callback called directly by an ISR?

Function Pointer Typedef Syntax

// Declare a type named 'timer_cb_t' pointing to a function that takes no arguments and returns uint8_t
typedef  (*timer_cb_t)();

References & Further Reading

  • Barr, M., & Massa, A. (2006). Programming Embedded Systems: With C and GNU Development Tools (2nd ed.). O’Reilly Media. Chapter 6 (Interrupts).
  • VIA University College: Embedded Software 1 (ESW1) Lecture Notes - Section 9 (Callbacks & Function Pointers).

Test-Driven Development (TDD)

Section Detail

PlatformIO and TDD Target Isolation

The Double Compilation Strategy

A major bottleneck in embedded development is the compile-flash-debug cycle. Flashing code to a physical microcontroller takes time, and debugging directly on hardware can be difficult due to limited hardware breakpoints and slow serial interfaces.

To solve this, we use a double compilation strategy:

  1. Target Environment: The compiler targets the microcontroller architecture (e.g., AVR, ARM) to compile the production binary.
  2. Native PC Environment: The compiler compiles the code for the host development machine (e.g., x86_64, Apple Silicon) to run unit tests in milliseconds.

However, native PC compilers cannot compile code that includes microcontroller hardware register files (like <avr/io.h>) because these registers do not exist on the PC. We must isolate our hardware drivers.

PlatformIO Directory Classification

PlatformIO structures its projects to enable this target isolation out-of-the-box. Code files are divided into three directories:

1. The lib/ Directory (Shared Business Logic)

Contains hardware-independent logic (the Unit Under Test, or UUT) and the public interface (.h files) of low-level drivers.

  • Example: room.c, room.h (checks temperature thresholds and makes decisions), and tempDriver.h (declares the sensor read function).
  • Compilation: Compiled for both the target microcontroller and the native host PC runner.

2. The src/ Directory (Target-Only Implementation)

Contains target-specific driver code that manipulates physical hardware registers, along with main.c (the microcontroller entry point).

  • Example: tempDriver.c (reads the physical microcontroller ADC register like ADMUX) and main.c.
  • Compilation: Compiled only for the target microcontroller.

3. The test/ Directory (PC-Only Test Runner)

Contains unit test suites and mocked driver implementations.

  • Example: test_room.c (Unity assertions) and a mock implementation of tempDriver.h built using a mocking framework.
  • Compilation: Compiled only for the native host PC.
project/
├── lib/               <-- Shared logic (UUT) & interface .h files
│   ├── room/
│   │   ├── room.h
│   │   └── room.c
│   └── tempSensor/
│       └── tempSensor.h
├── src/               <-- Target-only register drivers & main.c
│   ├── tempSensor.c   <-- (Includes <avr/io.h>)
│   └── main.c
└── test/              <-- PC-only unit tests & ffff mocks
    └── test_room/
        └── test_room.c

If you are writing a register-level driver file called 'led_driver.c' that references 'PORTB' directly, where should you place it in a PlatformIO project to support PC unit testing?

Identify Native Test Folder

// Complete the path of the PlatformIO folder compiled only on the native host PC for testing:
project//test_suite.c

References & Further Reading

  • PlatformIO Core Community. PlatformIO Library Dependency Finder (LDF) & Unit Testing Guide. PlatformIO Docs.
  • VIA University College: Embedded Software 1 (ESW1) Lecture Notes - Section 1 & Section 7 (TDD and Build Process).
Section Detail

Unit Testing and Mocking with FFF

Unit Testing in C

Embedded systems rely on physical environments (temperature, voltage, external inputs). To test our business logic in isolation, we must substitute these hardware dependencies with test doubles (fakes, stubs, and mocks).

We compile and execute these tests natively on our PC using Unity (an embedded-focused unit testing runner) and FFF (Fake Function Framework, a header-only mocking utility).

The Unity Test Lifecycle

Unity runs each test function inside a standard lifecycle to prevent state leakage between tests:

  1. setUp(void): Runs automatically before each test case. Used to reset mocks and initialize structures.
  2. test_function(): The actual test containing assertions.
  3. tearDown(void): Runs automatically after each test case. Used to clean up memory or close file streams.
#include "unity.h"
#include "fff.h"
#include "room.h"
#include "mock_tempSensor.h"

DEFINE_FFF_GLOBALS;

void setUp(void) {
    RESET_FAKE(tempSensor_read); // Reset call counters and history
}

void tearDown(void) {}

void test_RoomHeatingOnWhenCold(void) {
    // Set mock sensor return value
    tempSensor_read_fake.return_val = 15; // 15 degrees Celsius
    
    room_tick(); // Tick business logic
    
    TEST_ASSERT_EQUAL(HEATING_ON, room_get_heating_state());
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_RoomHeatingOnWhenCold);
    return UNITY_END();
}

Essential Unity Assertions

  • TEST_ASSERT_EQUAL(expected, actual): Compares integers.
  • TEST_ASSERT_EQUAL_HEX16(expected, actual): Compares 16-bit integers in hex format (highly useful for checking registers).
  • TEST_ASSERT_EQUAL_STRING(expected, actual): Compares strings character-by-character.
  • TEST_ASSERT_NULL(ptr) / TEST_ASSERT_NOT_NULL(ptr): Verifies pointer addresses.

Mocking with Fake Function Framework (FFF)

FFF uses preprocessor macros to generate mock implementations of functions. These mocks track how many times they were called and what arguments they received.

  • FAKE_VOID_FUNC(name, arg_types...): Mocks a function returning void.
  • FAKE_VALUE_FUNC(return_type, name, arg_types...): Mocks a function returning return_type.

[!WARNING] Void Argument List Exception If a function has no parameters (e.g. uint16_t adc_read(void);), do not write void inside the FFF argument macro list.

  • Correct: FAKE_VALUE_FUNC(uint16_t, adc_read);
  • Incorrect: FAKE_VALUE_FUNC(uint16_t, adc_read, void);

Controlling FFF Mocks

FFF automatically generates a tracking struct named [function_name]_fake:

  • Call Count: tempSensor_read_fake.call_count stores the number of times the mock was called.
  • Inspect Arguments: servo_set_angle_fake.arg0_val stores the first argument passed to the function in the latest call.
  • Inspect Argument History: servo_set_angle_fake.arg0_history[i] stores the first argument of the ii-th call.
  • Sequence Returns: Set consecutive return values for multiple calls:
uint16_t temp_sequence[] = {15, 18, 22};
tempSensor_read_fake.return_val_seq = temp_sequence;
tempSensor_read_fake.return_val_seq_len = 3;

What is the correct FFF macro call to mock the function 'void led_toggle(void);'?

Setting Sequence Returns

// Configure the mock 'adc_read' to return elements from array 'seq' of length 3
adc_read_fake. = seq;
adc_read_fake.return_val_seq_len = ;

References & Further Reading

  • ThrowTheSwitch.org. Unity: The lightweight C Unit Testing Framework. ThrowTheSwitch Docs.
  • Fake Function Framework (FFF). FFF: A testing framework for creating fake C functions. GitHub Repository.
  • VIA University College: Embedded Software 1 (ESW1) Lecture Notes - Section 6 (Unit Testing) & Section 7 (Mocking).

AVR Microcontroller Interfacing

Section Detail

AVR Register Programming: GPIO

Microcontroller Registers & MMIO

In embedded systems, we control hardware by writing to specific memory addresses. This is called Memory-Mapped I/O (MMIO). In the C language, compiler headers map these hardware addresses to register names (such as DDRB or PORTB).

The Three GPIO Port Registers

On AVR microcontrollers (like the ATmega2560), each physical port (Port A, Port B, etc.) is controlled by three 8-bit registers. Replacing x with the port letter (e.g., PORTA, DDRA, PINA):

  1. DDRx (Data Direction Register): Configures whether the pin is an Input or Output.
    • Set bit to 0: Input mode (Default).
    • Set bit to 1: Output mode.
  2. PORTx (Data Register): Sets the output state, or toggles internal pull-ups.
    • If pin is Output (DDRx = 1): Write 1 to set pin High (Vcc), or 0 to set pin Low (GND).
    • If pin is Input (DDRx = 0): Write 1 to enable the internal pull-up resistor, or 0 for floating/tri-state.
  3. PINx (Input Pins Address Register): Read-only register containing the physical voltage levels on the pins.
    • Reads 1 if the pin voltage is High, and 0 if the pin voltage is Low.

Active-Low Button with Pull-Up

Floating input pins are highly sensitive to electromagnetic noise, resulting in random reads. To prevent this, we connect input buttons in an active-low configuration:

  • Connect the button between the input pin and Ground (GND).
  • Enable the microcontroller’s internal pull-up resistor, which connects the pin internally to Vcc through a large resistor.

Button States

  • Button Released: The pull-up resistor holds the pin voltage at Vcc. Reading PINx returns 1.
  • Button Pressed: The switch closes, short-circuiting the pin directly to Ground (GND). The pin voltage drops to 0V. Reading PINx returns 0.

Example Driver Implementation

To write a driver for a button connected to Port A, Pin 1 (PA1):

#include <avr/io.h>
#include <stdbool.h>

void button_init(void) {
    // 1. Configure PA1 as Input (Clear DDR bit)
    DDRA &= ~(1 << PA1);
    
    // 2. Enable internal pull-up on PA1 (Set PORT bit)
    PORTA |= (1 << PA1);
}

bool button_is_pressed(void) {
    // Active-low: pressed state reads as 0 (LOW) in PINA
    return (0 == (PINA & (1 << PA1)));
}

Active-High vs. Active-Low LEDs

  • Active-High LED: Pin connects to the LED anode, LED cathode to GND. Write 1 (PORTx |=) to turn ON; write 0 to turn OFF.
  • Active-Low LED: Pin connects to the LED cathode, LED anode to Vcc. Write 0 (PORTx &= ~) to turn ON; write 1 to turn OFF.

Why must we enable the internal pull-up resistor when connecting a button between an input pin and ground?

Configure GPIO Input Pull-up

// Configure PB4 as an input pin with the internal pull-up enabled
DDRB  (1 << PB4); // Set to input
PORTB  (1 << PB4); // Enable pull-up

References & Further Reading

  • Microchip Technology. ATmega2560 8-bit AVR Microcontroller Datasheet. Section 13 (I/O Ports).
  • VIA University College: Embedded Software 1 (ESW1) Lecture Notes - Section 8 (AVR Register Programming).
Section Detail

AVR Register Programming: UART

Serial Communication via UART

The Universal Asynchronous Receiver Transmitter (UART) is a hardware peripheral that converts data between parallel byte streams and asynchronous serial lines. It is widely used to transfer debugging logs and sensor data between microcontrollers and PCs.

UART Frame Formats (8N1)

Because UART is asynchronous (lacks a shared clock line), sender and receiver must agree on a timing rate (baud rate) and frame format. The standard configuration is 8N1:

  • Start Bit: 1 low bit indicating the start of a frame.
  • Data Bits: 8 bits of payload data.
  • Parity Bit: None (no parity check).
  • Stop Bit: 1 high bit indicating the end of a frame.

UART Peripheral Registers

On the ATmega2560, the USART0 peripheral is configured using several memory-mapped registers:

  1. UBRR0H & UBRR0L (Baud Rate Registers): A 16-bit register pair (split into High and Low bytes) that holds the clock divisor factor.
  2. UCSR0A (Control & Status Register A): Tracks transceiver flags:
    • UDRE0 (USART Data Register Empty): Set to 1 by hardware when the write buffer is empty and ready for a new byte.
    • RXC0 (USART Receive Complete): Set to 1 by hardware when there is unread data waiting in the receive buffer.
  3. UCSR0B (Control & Status Register B): Enables transmitter (TXEN0), receiver (RXEN0), and interrupts.
  4. UCSR0C (Control & Status Register C): Configures frame format (data bits size, stop bits, parity).
  5. UDR0 (USART Data Register): The physical buffer. Writing a byte to UDR0 transmits it; reading UDR0 retrieves the received byte.

Baud Rate Divisor Calculation

The divisor UBRR is calculated from the system clock frequency (fCPUf_{CPU}) and the target baud rate:

UBRR=fCPU16×Baud1UBRR = \frac{f_{CPU}}{16 \times Baud} - 1

For a standard ATmega2560 clock frequency of 16 MHz (16,000,000 Hz16,000,000\text{ Hz}) and a target baud rate of 9600:

UBRR=16,000,00016×96001=16,000,000153,6001=104.17104UBRR = \frac{16,000,000}{16 \times 9600} - 1 = \frac{16,000,000}{153,600} - 1 = 104.17 \approx 104

Example Transceiver Driver Implementation

#include <avr/io.h>
#include <stdint.h>

void uart_init(uint32_t baud) {
    // 1. Calculate and set baud rate registers
    uint16_t ubrr_val = F_CPU / 16 / baud - 1;
    UBRR0H = (uint8_t)(ubrr_val >> 8);
    UBRR0L = (uint8_t)ubrr_val;

    // 2. Enable receiver and transmitter
    UCSR0B = (1 << RXEN0) | (1 << TXEN0);

    // 3. Set frame format: 8 data bits, 1 stop bit, no parity (8N1)
    UCSR0C = (1 << UCSZ01) | (1 << UCSZ00);
}

void uart_send_char(char data) {
    // Wait until transmit buffer is empty (UDRE0 bit in UCSR0A is 1)
    while (!(UCSR0A & (1 << UDRE0)));
    
    // Write byte to data register to trigger transmission
    UDR0 = data;
}

char uart_receive_char(void) {
    // Wait until receive buffer has data (RXC0 bit in UCSR0A is 1)
    while (!(UCSR0A & (1 << RXC0)));
    
    // Read and return data register
    return UDR0;
}

Which bit inside the status register UCSR0A must be polled before writing a new byte to UDR0, and why?

Baud Rate Register Math

// Calculate UBRR divisor for a 16MHz clock and 115200 baud
// UBRR = 16000000 / (16 * 115200) - 1 = 8.68 -> rounds to 8
uint16_t ubrr =  / (16UL * ) - 1;

References & Further Reading

  • Microchip Technology. ATmega2560 8-bit AVR Microcontroller Datasheet. Section 22 (USART).
  • VIA University College: Embedded Software 1 (ESW1) Lecture Notes - Section 8 (UART Serial Driver).
Section Detail

AVR Register Programming: Timers & CTC

Hardware Timers

Microcontrollers use physical hardware timers to track time independently of the CPU. A timer is essentially a register (such as TCNT1) that increments automatically on every clock cycle. When the counter reaches its maximum limit, it overflows, resets to 0, and triggers an interrupt.

Normal Mode vs. CTC Mode

  • Normal Mode: The timer counts up to its maximum capacity (e.g., 6553565535 for a 16-bit timer) before resetting. To run a periodic task at a faster frequency, software must manually reset TCNT1 inside the ISR. This manual reset introduces latency (drifts) due to the CPU cycles spent entering the ISR, leading to inaccurate timing.
  • CTC Mode (Clear Timer on Compare match): The timer counts up to a custom limit register (OCR1A). Upon matching, the hardware automatically resets TCNT1 to 0 in the same clock cycle and fires the compare match interrupt. This eliminates software latency drift.

Timer 1 (16-bit) Registers

To configure Timer 1 on the ATmega2560 in CTC mode, we interact with several registers:

  • TCNT1: The raw 16-bit counter value.
  • OCR1A (Output Compare Register A): Holds the target match count.
  • TCCR1B (Timer/Counter Control Register B): Configures the mode (WGM bits) and prescaler (CS bits).
  • TIMSK1 (Timer Interrupt Mask Register): Enables the compare match A interrupt (OCIE1A).
  • TIMER1_COMPA_vect: The interrupt vector name for the compare match A ISR.

Prescaler Selection (TCCR1B CS Bits)

Since the CPU runs at 16 MHz, a 16-bit counter would match OCR1A too quickly at high frequencies. We use a prescaler (clock divisor) to slow down the timer.

PrescalerCS12CS11CS10TCCR1B Register Setting
No Clock (Stopped)000Timer stopped
1 (None)001(1 << CS10)
8010(1 << CS11)
64011(1 << CS11) | (1 << CS10)
256100(1 << CS12)
1024101(1 << CS12) | (1 << CS10)

CTC Threshold Calculation

To trigger a compare match interrupt at frequency fintf_{int}, calculate OCR1A using:

OCR1A=fCPUPrescaler×fint1OCR1A = \frac{f_{CPU}}{Prescaler \times f_{int}} - 1

Rule for LED Toggling: If the ISR toggles a pin to blink a LED at frequency fLEDf_{LED}, the interrupt must trigger twice per cycle (fint=2×fLEDf_{int} = 2 \times f_{LED}):

OCR1A=fCPU2×Prescaler×fLED1OCR1A = \frac{f_{CPU}}{2 \times Prescaler \times f_{LED}} - 1

Example: 100Hz (10ms) Interrupt Setup

We choose a prescaler of 64:

OCR1A=16,000,00064×1001=16,000,00064001=25001=2499OCR1A = \frac{16,000,000}{64 \times 100} - 1 = \frac{16,000,000}{6400} - 1 = 2500 - 1 = 2499

#include <avr/io.h>
#include <avr/interrupt.h>

void timer1_init_100hz(void) {
    TCCR1A = 0; // Clear control registers
    TCCR1B = 0;
    
    // Set CTC mode (WGM12 = 1 in TCCR1B)
    TCCR1B |= (1 << WGM12);
    
    // Set compare match value
    OCR1A = 2499;
    
    // Enable compare match A interrupt
    TIMSK1 |= (1 << OCIE1A);
    
    // Set prescaler to 64 (CS11=1, CS10=1) and start timer
    TCCR1B |= (1 << CS11) | (1 << CS10);
    
    sei(); // Enable global interrupts
}

ISR(TIMER1_COMPA_vect) {
    // Executes automatically every 10ms
    // Must be short and avoid blocking logic
}

Why is CTC mode preferred over Normal mode with manual register reloads for executing periodic scheduler tasks?

Configure Prescaler CS Bits

// Configure Timer 1 for prescaler 256 in TCCR1B
TCCR1B |= (1 << ); // CS12 is set, others clear

References & Further Reading

  • Microchip Technology. ATmega2560 8-bit AVR Microcontroller Datasheet. Section 17 (16-bit Timer/Counter 1).
  • VIA University College: Embedded Software 1 (ESW1) Lecture Notes - Section 8 (Timers & CTC Mode).

Real-Time Schedulers

Section Detail

Periodic Task Scheduling

Scheduling Tasks

An embedded application typically needs to execute multiple tasks at different periods (e.g., reading a temperature sensor every 500ms, updating a display every 100ms, and blinking a status LED every 1000ms). How we organize these tasks determines the predictability and stability of the system.

Super Loop vs. Timer-Controlled Dispatcher

1. The Super Loop (Polled Execution)

A super loop executes tasks sequentially in a continuous loop, using delay functions to manage timing:

int main(void) {
    while (1) {
        read_sensor();
        update_display();
        _delay_ms(100); // Wait between loops
    }
}
  • Pros: Easy to understand; does not require complex timer registers, interrupts, or function pointers.
  • Cons: Timing is fragile. If read_sensor() blocks for 50ms (e.g., waiting for an I/O flag), the display update and all subsequent loops are delayed, causing timing jitter.

2. The Timer-Controlled Dispatcher

A dispatcher splits execution into an asynchronous timer interrupt (counting ticks) and a synchronous main loop (executing tasks). This allows multiple independent periodic tasks to run off a single hardware timer.

  • Pros: Precise periodic execution intervals; timing of one task is insulated from minor variations in others.
  • Cons: Overrun hazard—if a task takes longer to execute than the timer period, it can delay other pending tasks.

Example: Scheduler Implementation in C

The scheduler represents each task using a control structure:

typedef struct {
    uint32_t period;    // Target execution period in milliseconds
    void (*task_p)(void); // Pointer to the task function
    uint32_t ticks;     // Decrementing counter tracking when to run
} task_t;

The Tick Decrement (ISR Context)

A hardware timer is configured to fire at a regular interval (e.g., every 10ms, which is 1 tick1\text{ tick}). The ISR loops through all registered tasks and decrements their tick counters:

#define MS_PER_TICK 10

ISR(TIMER1_COMPA_vect) {
    for (uint8_t i = 0; i < task_count; i++) {
        if (task_list[i].ticks > 0) {
            task_list[i].ticks--;
        }
    }
}

The Dispatcher (Main Thread Context)

The dispatcher runs continuously inside the main loop. It scans for tasks whose ticks have reached 0, executes them, and resets their tick countdown:

void dispatcher(void) {
    for (uint8_t i = 0; i < task_count; i++) {
        if (task_list[i].ticks == 0) {
            // 1. Run the task callback
            task_list[i].task_p();
            
            // 2. Reset the tick counter based on the task's period
            task_list[i].ticks = task_list[i].period / MS_PER_TICK;
        }
    }
}

int main(void) {
    timer1_init_10ms_tick();
    scheduler_add_task(100, update_display);
    scheduler_add_task(500, read_sensor);
    
    while(1) {
        dispatcher(); // Poll and run ready tasks
    }
}

In a 10ms tick dispatcher, what happens if a task registered with a 20ms period takes 35ms to execute?

Implement Tick Decrement Loop

// Inside the timer ISR, decrement ticks if they are greater than zero
if (task_list[i].ticks  0) {
    task_list[i].--;
}

References & Further Reading

  • Pont, M. J. (2001). Patterns for Time-Triggered Embedded Systems. Addison-Wesley. Chapters 1–3 (Schedulers).
  • VIA University College: Embedded Software 1 (ESW1) Lecture Notes - Section 10 (Periodic Task Scheduling).

Physical Integration

Section Detail

Case Study: HC-SR04 Distance Sensor

Ultrasonic Distance Sensing

The HC-SR04 ultrasonic distance sensor measures distance by emitting sound pulses and timing how long they take to bounce back from an obstacle.

Driver Working Principle

  1. Trigger Pulse: The microcontroller initiates a measurement by sending a High (1) pulse of at least 10 µs to the sensor’s Trig pin.
  2. Pulse Transmission: The sensor automatically emits eight 40kHz acoustic pulses.
  3. Echo Measurement: The sensor sets its Echo pin High (1). It keeps it High until the reflected sound wave is received, then drops it Low (0).
  4. Timeout: If no object is detected, the Echo pin automatically drops Low after approximately 38ms to prevent infinite blocking.

Timing & Prescaler Calculations

To measure the Echo pulse duration, we use a 16-bit hardware timer (such as Timer 4 on the ATmega2560) running at fCPU=16 MHzf_{CPU} = 16\text{ MHz}.

1. Timeout Counter Limit

A timeout of 38ms (0.038 seconds0.038\text{ seconds}) corresponds to a CPU cycle count of:

Cycles=0.038 s×16,000,000 cycles/s=608,000 cycles\text{Cycles} = 0.038\text{ s} \times 16,000,000\text{ cycles/s} = 608,000\text{ cycles}

A 16-bit timer can only count up to 6553565535. We must choose a prescaler division factor NN such that 65535×N>608,00065535 \times N > 608,000:

N>608,000655359.27N > \frac{608,000}{65535} \approx 9.27

The closest standard prescaler factor higher than 9.27 is N=64N = 64. The comparison threshold OCR4A for a 38ms timeout using a prescaler of 64 is:

OCR4A=608,00064=9500\text{OCR4A} = \frac{608,000}{64} = 9500

2. Integer Distance Scaling

Floating-point division is extremely slow on 8-bit microcontrollers and is disabled in standard serial formatting libraries. We pre-calculate a static integer scale factor to convert timer counts directly into millimeters.

  • Speed of sound at 20°C: 343 m/s=343,000 mm/s343\text{ m/s} = 343,000\text{ mm/s}.
  • The sound travels to the object and back, so the physical distance is half the total travel time:

Distancemm=12×Propagation Time×Speed of Sound\text{Distance}_{mm} = \frac{1}{2} \times \text{Propagation Time} \times \text{Speed of Sound}

Distancemm=12×Count×6416,000,000×343,000\text{Distance}_{mm} = \frac{1}{2} \times \frac{\text{Count} \times 64}{16,000,000} \times 343,000

Distancemm=Count×32×343,00016,000,000=Count×343500\text{Distance}_{mm} = \text{Count} \times \frac{32 \times 343,000}{16,000,000} = \text{Count} \times \frac{343}{500}

Integer Equation: Distancemm=Count×343500\text{Integer Equation: } \text{Distance}_{mm} = \frac{\text{Count} \times 343}{500}

3. Overflow Protection Check

Before committing to integer math, verify that the numerator does not overflow the variable container type. The maximum expected count is the timeout limit (95009500):

9500×343=3,258,5009500 \times 343 = 3,258,500

Since 3,258,500<23213,258,500 < 2^{32} - 1 (4,294,967,2954,294,967,295), this calculation fits inside a standard 32-bit unsigned integer (uint32_t) without risk of overflow.

Driver Implementation

#include <avr/io.h>
#include <stdint.h>
#include <stdbool.h>

#define TRIG_PIN PORTA2
#define ECHO_PIN PINA3

static volatile bool timeout_flag = false;

void distance_init(void) {
    DDRA |= (1 << TRIG_PIN);   // Set Trig pin as Output
    DDRA &= ~(1 << DDA3);      // Set Echo pin as Input
    
    // Configure Timer 4 in CTC mode (WGM42=1)
    TCCR4A = 0; TCCR4B = 0;
    TCCR4B |= (1 << WGM42);
    OCR4A = 9500;              // Timeout set at 38ms
    TIMSK4 |= (1 << OCIE4A);   // Enable Compare A interrupt
}

ISR(TIMER4_COMPA_vect) {
    timeout_flag = true;
}

uint16_t distance_measure(void) {
    timeout_flag = false;
    TCNT4 = 0;
    
    // 1. Send 10us Trigger Pulse
    PORTA |= (1 << TRIG_PIN);
    for (volatile uint8_t i = 0; i < 40; i++); // Short delay loop (~10us)
    PORTA &= ~(1 << TRIG_PIN);
    
    // 2. Wait for Echo pin to go High (with timeout check)
    while (!(PINA & (1 << ECHO_PIN))) {
        if (timeout_flag) return UINT16_MAX;
    }
    
    // 3. Start Timer 4 with prescaler 64
    TCNT4 = 0;
    TCCR4B |= (1 << CS41) | (1 << CS40); // N = 64
    
    // 4. Wait for Echo pin to go Low (with timeout check)
    while (PINA & (1 << ECHO_PIN)) {
        if (timeout_flag) {
            TCCR4B &= ~((1 << CS41) | (1 << CS40)); // Stop timer
            return UINT16_MAX;
        }
    }
    
    // 5. Stop Timer 4
    TCCR4B &= ~((1 << CS41) | (1 << CS40));
    uint16_t count = TCNT4;
    
    // Convert counts to mm: (Count * 343) / 500
    uint32_t distance = ((uint32_t)count * 343) / 500;
    return (uint16_t)distance;
}

Why do we pre-calculate the integer fraction (343 / 500) rather than writing 'distance = (count * 64.0 / 16000000.0) * 171500.0;'?

Distance Scaling Equation

// Calculate distance in mm from timer count using integer scaling
uint32_t distance = ((uint32_t)count * ) / ;

References & Further Reading

  • Cytron Technologies. HC-SR04 Ultrasonic Sensor Product User’s Manual.
  • VIA University College: Embedded Software 1 (ESW1) Lecture Notes - Section 11 (Case Study: Distance Sensor).