Master the foundations of systems programming, memory management, and low-level computer science with C.
July 2026
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.
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:
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.
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.
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.
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.
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.
In a modern operating system, every running C process is given a virtual address space. This space is typically organized into several segments:
| Segment | Description | Lifetime |
|---|---|---|
| Text | The actual machine instructions (read-only). | Program Duration |
| Data | Global and static variables initialized by the programmer. | Program Duration |
| BSS | Global and static variables uninitialized (set to zero). | Program Duration |
| Heap | Memory allocated at runtime via malloc or calloc. | Manual |
| Stack | Local variables and function call frames. | Function Scope |
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.
/* The signature for a program that ignores arguments */ int (void) { return 0; }
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.
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.
int MyVar; and int myvar; refer to two distinct memory locations.C’s type system is designed to expose the underlying hardware’s capabilities. Unlike managed languages, C types often have implementation-defined sizes.
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:
| Type | Minimum Size | Guaranteed Range |
|---|---|---|
char | 8 bits | -127 to 127 (or 0 to 255) |
short | 16 bits | -32,767 to 32,767 |
int | 16 bits | -32,767 to 32,767 |
long | 32 bits | -2,147,483,647 to 2,147,483,647 |
long long | 64 bits | -(2^63 - 1) to (2^63 - 1) |
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).stdint.h SolutionBecause 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
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.
int a = 5;
float b = 2.0;
// What is the resulting type of (a / b)?
// Answer: What happens when you add 1 to the maximum possible value of a signed integer?
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.
Common arithmetic operators (+, -, *, /, %) behave as expected for floating-point and unsigned integers. However, signed integer arithmetic carries risks:
(-5) / 2 results in -2.%): Requires integer operands. The identity (a/b)*b + a%b == a always holds in C.C is the language of choice for drivers and embedded systems because of its direct support for bit-level manipulation.
| Operator | Description | Common Use Case |
|---|---|---|
& | Bitwise AND | Masking bits (clearing specific bits). |
| | Bitwise OR | Setting bits. |
^ | Bitwise XOR | Toggling bits or simple swaps. |
~ | Bitwise NOT | One’s complement (inverting all bits). |
<< | Left Shift | Multiply by power of 2. |
>> | Right Shift | Divide by power of 2 (Behavior for signed is implementation-defined). |
Logical AND (&&) and OR (||) are guaranteed to evaluate from left-to-right. They use Short-Circuit Evaluation:
A && B, if A is false, B is never evaluated.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 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).
A “Sequence Point” is a point in time where all “side effects” (like variable assignments) from previous evaluations are guaranteed to be complete.
;) is a sequence point.&&, ||, 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.
int i = 5;
// Is this code valid or undefined behavior?
i = i++ + 1;
// Answer: 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;
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.
In C, there is no native boolean type in the core language (prior to C99’s <stdbool.h>). The rules for truth are simple:
NULL pointer.if (5) { /* This will always execute */ }
if (0) { /* This will never execute */ }
if (-1) { /* This will execute! */ }
if-else ConstructThe if statement evaluates an expression. If it is non-zero, the following block executes.
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.
switch Statement and Jump TablesThe 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
}
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 in terms of time complexity in many scenarios.
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:
switch(input) { case 'y': case 'Y': confirmed = 1; ; case 'n': confirmed = 0; break; }
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.
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.
while vs. do-whileThe 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);
for LoopThe for loop is syntactically sugar for a while loop but is much more expressive. It consists of three expressions:
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);
}
break and continuebreak: 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.
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 */ }
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).
int i = 0; while (i < 3) { printf("%d", i); ; }
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:
void).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.
.c file.// 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;
}
Every time a function is called, a new Stack Frame (or Activation Record) is pushed onto the Call Stack. This frame contains:
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).
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.
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
}
print_message(void) {
printf("Hello\n");
}{}. They exist only while that block is executing.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.
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).
C is a “trust the programmer” language. It does not check if an index is within the bounds of an array at runtime.
arr[10] on an array of size 5 will simply read whatever happens to be in memory at that offset.int vals[5] = {1, 2, 3};
// Which expression is equivalent to vals[2]?
// Answer: 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.
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.
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.
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 always requires bytes of storage.
When you write "Hello", you are creating a String Literal.
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
<string.h> FunctionsC provides a standard library for string manipulation. However, these functions are notorious for being unsafe if not used with extreme care.
| Function | Description | Risk |
|---|---|---|
strlen() | Returns length (excluding \0). | 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. |
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.
char buffer[6];
// How many 'actual' characters can this buffer safely hold?
// Answer: 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.
snprintfIn 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)
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.
& and *&): Returns the memory address of an object.*): 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
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:
p + 1 jumps by 4 bytes for an int* but only 1 byte for a char*).NULL PointerA NULL pointer is a pointer that points to “nothing” (usually address 0).
NULL pointer is Undefined Behavior and usually triggers a Segmentation Fault.int y = 10; int *ptr = &y; // How do we change the value of y to 20 using only ptr? = 20;
If pointers are so dangerous, why does C use them so heavily?
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];.
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.
In C, we can have pointers that point to other pointers. This is commonly used for:
int **).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
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
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
void *ptr; int x = 10; ptr = &x; // How to read x into 'val'? int val = ;
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!
}
So far, we have used Automatic Storage (local variables on the stack). However, the stack has limitations:
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.
<stdlib.h>C provides four primary functions for managing heap memory:
| Function | Purpose | Key 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. |
int *p = malloc(10 * sizeof(int));malloc returned NULL (which happens if the system is out of memory).free(p);In languages like Python or Java, a Garbage Collector cleans up after you. In C, you are the garbage collector.
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.
Dereferencing a pointer after it has been passed to free(). The memory might have been re-assigned to something else, causing silent data corruption.
Calling free() on the same pointer twice. This usually crashes the program immediately as it corrupts the heap’s internal metadata.
int *p = malloc(10 * sizeof(int)); // We need more space! int *temp = (p, 20 * sizeof(int)); if (temp != NULL) p = temp;
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.
C provides three primary ways to create custom types: Structures, Unions, and Enumerations. These allow you to group related data into logical entities.
A struct is a block of memory that holds multiple variables (members) of different types.
struct Player {
char name[32];
int score;
float health;
};
. vs ->.) for direct instances.->) for pointers to instances. ptr->x is shorthand for (*ptr).x.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.
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:
union Data {
int i;
float f;
} u;
u.i = 42;
// Now u.f also contains the bit pattern of 42 interpreted as a float.
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)
union Example {
char a;
int b;
};
// If sizeof(char) is 1 and sizeof(int) is 4,
// what is sizeof(union Example)?
// Answer: 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
Every variable in C has two properties that define its behavior:
autoThe default for local variables. They are stored on the Stack and have Automatic Storage Duration (destroyed when the block ends).
staticThe static keyword has two distinct meanings depending on where it is used:
.c file and cannot be accessed by other files via extern.externUsed 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
registerA 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.
constIndicates 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 EssentialThe volatile qualifier tells the compiler: “This variable can change at any time without this code doing anything.”
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!
Qualifiers can be applied to the pointer itself or the data it points to. This distinction is vital for API design:
| Declaration | Meaning |
|---|---|
const int *p | Pointer to a constant integer (Data cannot change). |
int * const p | Constant pointer to an integer (Address cannot change). |
const int * const p | Constant pointer to a constant integer (Nothing can change). |
int get_next_id() { int id = 0; return ++id; }
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 #.
#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
#defineMacros allow you to define symbols that the preprocessor will swap for their replacement text wherever they appear.
#define MAX_BUFFER 1024
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))
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
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);
#ifndef HEADER_H HEADER_H // ... code ... #endif
inlineModern C often prefers inline functions over macros because:
inline functions obey Scope rules.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)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.
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).Opening a file can fail (missing file, permission denied). Always check if the pointer is NULL.
There are three ways to move data through a stream:
| Level | Input | Output | Usage |
|---|---|---|---|
| Character | fgetc() | fputc() | Fine-grained parsing. |
| Line | fgets() | fputs() | Reading text safely. |
| Formatted | fscanf() | fprintf() | Structured data. |
| Block | fread() | fwrite() | Large binary blocks (Fastest). |
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.char buffer[100]; FILE *fp = fopen("data.txt", "r"); if (fp != ) { fgets(buffer, 100, fp); fclose(fp); }
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.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.
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.
Instead of writing your own sort, C provides highly optimized generic algorithms.
qsort: The Generic Sorterqsort 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 *));
bsearch: Binary SearchWorks on sorted arrays to find an element in time.
exit(status): Terminates the program normally. A status of 0 indicates success.abort(): Terminates the program abnormally (often generates a core dump for debugging).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
}
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.
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.
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), );
<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.Arrays are fast for access, but they are rigid:
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.
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” is just a standard pointer that stores the address of the first node. If the list is empty, head is NULL.
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;
}
Inserting at the head is an operation regardless of the list size.
new_node->next to the current head.head to point to the new_node.// To delete a list, we must free every node. struct Node *tmp; while (head != NULL) { tmp = head; head = ; free(tmp); }
Linked lists are powerful but come with costs:
next pointer.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.
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.
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
}
A queue follows the First-In, First-Out principle. Like a line at a supermarket, the person who arrived first is served first.
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.
| Implementation | Advantage | Disadvantage |
|---|---|---|
| Array | access, cache-friendly. | Fixed size, potential overflow. |
| Linked List | Dynamic size, no overflow. | but cache-unfriendly (allocations). |
// if top initialized to -1 void push(int x) { stack[] = x; }
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 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.
There are three main ways a C function signals failure:
Functions return an int. Typically, 0 means success, and negative values relate to specific error types.
if (calculate_physics() != 0) {
handle_error();
}
Functions that return pointers return NULL to signify failure (e.g., malloc, fopen).
errnoFound 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.
goto for CleanupWhile 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;
}
setjmp and longjmpFound 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.
#include <string.h> #include <errno.h> // How to get a string for the current error? char *msg = (errno);
Robust C code assumes inputs are malicious and functions will fail.
assert(ptr != NULL); from <assert.h> to catch logic errors during development.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.
A system call is the mechanism for a program to request a service from the operating system kernel.
syscall on x86-64 or svc on ARM).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).
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 ABI is the low-level contract between the compiler and the machine. It defines:
struct padding is handled.Understanding the ABI is essential for writing assembly code that calls C functions, or vice versa.
// used to prevent optimization for MMIO uint32_t *reg = (uint32_t*)0x1234;
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).
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.
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.
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.
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.
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);
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);
}
When translating UML class diagrams into C source code, apply the following visibility and relationship rules:
+): Declared in the .h file.-): 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.#define MAX_AUTHORS 5
struct book {
char title[32];
struct author* authors[MAX_AUTHORS]; // Array of pointers (0..N)
int author_count;
};
#pragma once struct author_st; // Create the opaque pointer type mapping to struct author_st typedef struct author_st* ;
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.
A function name is an implicit pointer to its address in the compiled binary. We can store this address in a variable.
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);
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);
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.
The ISR executes the registered callback directly within the interrupt context:
ISR(INT0_vect) {
if (app_callback != NULL) {
app_callback(PIND); // Direct synchronous execution
}
}
The ISR does not execute the callback. Instead, it places an event into a RAM-buffered Event Queue and exits immediately:
// Declare a type named 'timer_cb_t' pointing to a function that takes no arguments and returns uint8_t typedef (*timer_cb_t)();
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:
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 structures its projects to enable this target isolation out-of-the-box. Code files are divided into three directories:
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.
room.c, room.h (checks temperature thresholds and makes decisions), and tempDriver.h (declares the sensor read function).src/ Directory (Target-Only Implementation)Contains target-specific driver code that manipulates physical hardware registers, along with main.c (the microcontroller entry point).
tempDriver.c (reads the physical microcontroller ADC register like ADMUX) and main.c.test/ Directory (PC-Only Test Runner)Contains unit test suites and mocked driver implementations.
test_room.c (Unity assertions) and a mock implementation of tempDriver.h built using a mocking framework.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
// Complete the path of the PlatformIO folder compiled only on the native host PC for testing: project//test_suite.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).
Unity runs each test function inside a standard lifecycle to prevent state leakage between tests:
setUp(void): Runs automatically before each test case. Used to reset mocks and initialize structures.test_function(): The actual test containing assertions.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();
}
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.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 writevoidinside the FFF argument macro list.
- Correct:
FAKE_VALUE_FUNC(uint16_t, adc_read);- Incorrect:
FAKE_VALUE_FUNC(uint16_t, adc_read, void);
FFF automatically generates a tracking struct named [function_name]_fake:
tempSensor_read_fake.call_count stores the number of times the mock was called.servo_set_angle_fake.arg0_val stores the first argument passed to the function in the latest call.servo_set_angle_fake.arg0_history[i] stores the first argument of the -th call.uint16_t temp_sequence[] = {15, 18, 22};
tempSensor_read_fake.return_val_seq = temp_sequence;
tempSensor_read_fake.return_val_seq_len = 3;
// 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 = ;
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).
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):
DDRx (Data Direction Register): Configures whether the pin is an Input or Output.
0: Input mode (Default).1: Output mode.PORTx (Data Register): Sets the output state, or toggles internal pull-ups.
DDRx = 1): Write 1 to set pin High (Vcc), or 0 to set pin Low (GND).DDRx = 0): Write 1 to enable the internal pull-up resistor, or 0 for floating/tri-state.PINx (Input Pins Address Register): Read-only register containing the physical voltage levels on the pins.
1 if the pin voltage is High, and 0 if the pin voltage is Low.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:
PINx returns 1.PINx returns 0.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)));
}
1 (PORTx |=) to turn ON; write 0 to turn OFF.0 (PORTx &= ~) to turn ON; write 1 to turn OFF.// Configure PB4 as an input pin with the internal pull-up enabled DDRB (1 << PB4); // Set to input PORTB (1 << PB4); // Enable pull-up
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.
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:
On the ATmega2560, the USART0 peripheral is configured using several memory-mapped registers:
UBRR0H & UBRR0L (Baud Rate Registers): A 16-bit register pair (split into High and Low bytes) that holds the clock divisor factor.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.UCSR0B (Control & Status Register B): Enables transmitter (TXEN0), receiver (RXEN0), and interrupts.UCSR0C (Control & Status Register C): Configures frame format (data bits size, stop bits, parity).UDR0 (USART Data Register): The physical buffer. Writing a byte to UDR0 transmits it; reading UDR0 retrieves the received byte.The divisor UBRR is calculated from the system clock frequency () and the target baud rate:
For a standard ATmega2560 clock frequency of 16 MHz () and a target baud rate of 9600:
#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;
}
// 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;
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.
TCNT1 inside the ISR. This manual reset introduces latency (drifts) due to the CPU cycles spent entering the ISR, leading to inaccurate timing.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.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.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.
| Prescaler | CS12 | CS11 | CS10 | TCCR1B Register Setting |
|---|---|---|---|---|
| No Clock (Stopped) | 0 | 0 | 0 | Timer stopped |
| 1 (None) | 0 | 0 | 1 | (1 << CS10) |
| 8 | 0 | 1 | 0 | (1 << CS11) |
| 64 | 0 | 1 | 1 | (1 << CS11) | (1 << CS10) |
| 256 | 1 | 0 | 0 | (1 << CS12) |
| 1024 | 1 | 0 | 1 | (1 << CS12) | (1 << CS10) |
To trigger a compare match interrupt at frequency , calculate OCR1A using:
Rule for LED Toggling: If the ISR toggles a pin to blink a LED at frequency , the interrupt must trigger twice per cycle ():
We choose a prescaler of 64:
#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
}
// Configure Timer 1 for prescaler 256 in TCCR1B TCCR1B |= (1 << ); // CS12 is set, others clear
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.
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
}
}
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.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.
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;
A hardware timer is configured to fire at a regular interval (e.g., every 10ms, which is ). 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 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
}
}
// Inside the timer ISR, decrement ticks if they are greater than zero if (task_list[i].ticks 0) { task_list[i].--; }
The HC-SR04 ultrasonic distance sensor measures distance by emitting sound pulses and timing how long they take to bounce back from an obstacle.
1) pulse of at least 10 µs to the sensor’s Trig pin.1). It keeps it High until the reflected sound wave is received, then drops it Low (0).To measure the Echo pulse duration, we use a 16-bit hardware timer (such as Timer 4 on the ATmega2560) running at .
A timeout of 38ms () corresponds to a CPU cycle count of:
A 16-bit timer can only count up to . We must choose a prescaler division factor such that :
The closest standard prescaler factor higher than 9.27 is .
The comparison threshold OCR4A for a 38ms timeout using a prescaler of 64 is:
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.
Before committing to integer math, verify that the numerator does not overflow the variable container type. The maximum expected count is the timeout limit ():
Since (), this calculation fits inside a standard 32-bit unsigned integer (uint32_t) without risk of overflow.
#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;
}
// Calculate distance in mm from timer count using integer scaling uint32_t distance = ((uint32_t)count * ) / ;