Phase 1: C Fundamentals Back to top
This phase builds the foundation: how C stores data, names memory, prints values, and controls compiler behavior. Do not skip const and volatile; they become critical later.
1. Variables
Phase 1
Why it mattersA variable is a named memory location. In embedded systems this matters because RAM is small, and every variable consumes actual memory inside the microcontroller.
Core ideaThink of RAM as numbered boxes. A variable gives one box a name, stores a value in it, and lets your program read or modify that value later.
Example
#include <stdio.h>
int main(void)
{
int led_state = 0; // 0 means OFF, 1 means ON
int blink_count = 5; // number of times to blink
led_state = 1; // LED ON
printf("LED state = %d\n", led_state);
led_state = 0; // LED OFF
printf("Blink count = %d\n", blink_count);
return 0;
}
Explanationled_state and blink_count are variables. The CPU stores their values in RAM. When you assign led_state = 1, you are changing the value stored in that memory location.
Practice problem: Create three variables: temperature, motor_speed, and error_code. Assign values and print them.
Common mistake: Do not create variables without understanding their size. On MCUs, 1000 unnecessary int variables can waste precious RAM.
2. Data types (char, int, float, uint8_t, uint32_t)
Phase 1
Why it mattersData types tell the compiler how much memory to reserve and how to interpret the bits stored in that memory.
Core ideaNormal C types like int can have different sizes on different platforms. Embedded C often uses fixed-width types from stdint.h so the size is guaranteed.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
char letter = 'A'; // usually 1 byte
int count = 100; // size depends on platform
float voltage = 3.3f; // decimal number
uint8_t gpio_pin = 5; // exactly 8 bits, 0 to 255
uint32_t reg = 0x40021000; // exactly 32 bits
printf("letter=%c count=%d voltage=%.2f pin=%u reg=0x%08X\n",
letter, count, voltage, gpio_pin, reg);
return 0;
}
Explanationuint8_t is useful for bytes, pins, flags, and small counters. uint32_t is common for MCU registers because many ARM Cortex-M registers are 32-bit wide.
Practice problem: Declare uint8_t for LED number, uint16_t for ADC reading, and uint32_t for a register address. Print all three.
Common mistake: Avoid using float casually on small MCUs. Floating point may be slower or increase code size if the MCU has no hardware FPU.
3. Operators
Phase 1
Why it mattersOperators let you calculate, compare, assign, and manipulate data. Embedded code uses arithmetic, logical, relational, and bitwise operators heavily.
Core ideaOperators are the small actions of C: + adds, == compares, && checks logic, and & | ^ manipulate bits.
Example
#include <stdio.h>
int main(void)
{
int adc = 750;
int threshold = 600;
if (adc > threshold && adc < 1024)
{
printf("ADC is valid and above threshold\n");
}
int doubled = adc * 2;
int remainder = adc % 10;
printf("doubled=%d remainder=%d\n", doubled, remainder);
return 0;
}
Explanationadc > threshold returns true or false. && means both conditions must be true. % gives the remainder, useful for counters and periodic events.
Practice problem: Given battery_mV = 3700, print LOW if below 3300, OK if 3300 to 4200, and ERROR if above 4200.
Common mistake: Do not confuse = and ==. = assigns a value; == checks equality.
4. Input and output (printf)
Phase 1
Why it mattersprintf is used on PC to learn C. In embedded systems, printf is often redirected to UART/SWO for debugging.
Core ideaprintf formats values into readable text. It is excellent for learning, but in production firmware it can be heavy and slow.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint8_t pin = 5;
uint32_t tick = 123456;
float voltage = 3.294f;
printf("Pin: %u\n", pin);
printf("Tick: %lu\n", (unsigned long)tick);
printf("Voltage: %.2f V\n", voltage);
return 0;
}
Explanation%u prints unsigned integer, %lu prints unsigned long, %.2f prints a float with two decimal places. On MCUs, float printf may need extra linker options.
Practice problem: Print a fake sensor packet: temperature, humidity, device_id, and error flag.
Common mistake: Do not use printf inside a fast interrupt. It can block, delay timing, and break real-time behavior.
5. Type casting
Phase 1
Why it mattersType casting converts a value from one type to another. This is common when scaling ADC values, handling bytes, or using register addresses.
Core ideaA cast tells the compiler: treat this value as another type. Use it carefully because it can lose data.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint16_t adc = 2048;
float voltage;
voltage = ((float)adc / 4095.0f) * 3.3f;
printf("Voltage = %.3f V\n", voltage);
uint16_t big = 300;
uint8_t small = (uint8_t)big; // 300 becomes 44 because uint8_t max is 255
printf("small = %u\n", small);
return 0;
}
ExplanationWithout (float), adc / 4095 would use integer division and lose the decimal part. Casting big to uint8_t demonstrates overflow/truncation.
Practice problem: Convert a 10-bit ADC reading into millivolts using integer math and using float math. Compare results.
Common mistake: Casting does not magically make unsafe data safe. Casting a large value into a smaller type can wrap around.
6. Scope and lifetime
Phase 1
Why it mattersScope decides where a variable can be used. Lifetime decides how long the variable exists in memory.
Core ideaA local variable exists only inside its block. A global variable can be accessed by many functions. A static local variable keeps its value between function calls.
Example
#include <stdio.h>
int global_error = 0; // global scope, lifetime is entire program
void counter_function(void)
{
static int count = 0; // exists for entire program but visible only here
count++;
printf("count=%d\n", count);
}
int main(void)
{
int local_value = 10; // local to main
counter_function();
counter_function();
counter_function();
printf("local=%d global_error=%d\n", local_value, global_error);
return 0;
}
Explanationcount is initialized once and remembers its old value. local_value disappears when main exits. global_error exists for the full program.
Practice problem: Write a function that counts how many times it was called using static.
Common mistake: Avoid too many global variables. They make bugs harder because any function can change them.
7. Storage classes (auto, static, extern, register)
Phase 1
Why it mattersStorage classes control visibility, lifetime, and sometimes placement of variables/functions.
Core ideaauto is the default for local variables. static preserves local values or hides globals/functions inside a file. extern declares a global defined elsewhere. register is mostly obsolete.
Example
// file1.c
#include <stdio.h>
int system_error = 0; // real definition
static int private_count = 0; // visible only inside file1.c
void increment_private(void)
{
private_count++;
printf("private_count=%d\n", private_count);
}
// file2.c
extern int system_error; // declaration: variable exists in another file
void set_error(void)
{
system_error = 1;
}
Explanationextern is important in multi-file embedded projects. static at file scope is important for encapsulation: it prevents other files from accessing internal state.
Practice problem: Create two .c files: one defines a global counter, the other accesses it using extern.
Common mistake: Do not put non-static global variable definitions in header files. That can cause multiple-definition linker errors.
8. const
Phase 1
Why it mattersconst means the program should not modify a value. In embedded systems, const data can often live in Flash instead of RAM.
Core ideaUse const for lookup tables, calibration constants, fixed strings, and read-only configuration.
Example
#include <stdio.h>
#include <stdint.h>
const uint16_t adc_threshold = 3000;
const char device_name[] = "STM32_NODE_01";
int main(void)
{
uint16_t adc = 3100;
if (adc > adc_threshold)
{
printf("%s: threshold crossed\n", device_name);
}
return 0;
}
Explanationadc_threshold should not change during runtime. Marking it const prevents accidental modification and communicates intent to the compiler and reader.
Practice problem: Create a const lookup table for 5 PWM duty values: 0, 25, 50, 75, 100.
Common mistake: const does not mean compile-time constant in every situation. It means read-only through that variable.
9. volatile
Phase 1
Why it mattersvolatile tells the compiler that a variable can change unexpectedly outside normal program flow. This is essential for hardware registers and interrupt-shared variables.
Core ideaWithout volatile, the compiler may optimize repeated reads and assume the value never changes. Hardware and interrupts can change values behind the compiler's back.
Example
#include <stdint.h>
#include <stdio.h>
volatile uint8_t button_pressed = 0;
void EXTI_Button_IRQHandler(void) // imagine this is an interrupt
{
button_pressed = 1;
}
int main(void)
{
while (button_pressed == 0)
{
// wait until interrupt changes button_pressed
}
printf("Button pressed\n");
return 0;
}
Explanationbutton_pressed must be volatile because it is changed by an interrupt. The main loop must actually re-read it from memory every time.
Practice problem: Create a volatile flag called uart_rx_done and simulate setting it from a function that represents an ISR.
Common mistake: volatile is not a lock and does not make multi-byte access atomic. It only prevents certain compiler optimizations.
Phase 2: Program Control Back to top
This phase teaches decision making and repetition. Embedded firmware is full of state checks, loops, retries, and command handling.
10. if, else
Phase 2
Why it mattersif/else lets firmware make decisions: button pressed or not, ADC high or low, error present or not.
Core ideaUse if for conditions that are true/false. In embedded code, conditions often come from register bits, sensor values, or flags.
Example
#include <stdio.h>
int main(void)
{
int temperature = 75;
if (temperature > 80)
{
printf("Fan ON\n");
}
else
{
printf("Fan OFF\n");
}
return 0;
}
ExplanationOnly one block executes. If temperature is greater than 80, fan turns on; otherwise it turns off.
Practice problem: Write logic for LED: ON if button is 1, OFF if button is 0.
Common mistake: Always consider boundary cases: should temperature exactly 80 turn the fan on or off?
11. switch
Phase 2
Why it mattersswitch is useful when one variable can have many fixed command values, states, or modes.
Core ideaEmbedded firmware often uses switch for state machines and command parsers.
Example
#include <stdio.h>
int main(void)
{
char command = 'B';
switch (command)
{
case 'L':
printf("LED ON\n");
break;
case 'O':
printf("LED OFF\n");
break;
case 'B':
printf("LED BLINK\n");
break;
default:
printf("Unknown command\n");
break;
}
return 0;
}
Explanationbreak stops execution from falling into the next case. default handles unexpected values.
Practice problem: Create switch cases for modes: 0=IDLE, 1=RUN, 2=ERROR, 3=SLEEP.
Common mistake: Forgetting break is a common bug unless fall-through is intentionally documented.
12. for
Phase 2
Why it mattersfor loops are useful when you know how many times something should repeat: blink 5 times, scan 16 channels, clear 100 bytes.
Core ideaA for loop has initialization, condition, and update in one line.
Example
#include <stdio.h>
int main(void)
{
for (int i = 0; i < 5; i++)
{
printf("Blink %d\n", i + 1);
}
return 0;
}
Explanationi starts at 0. The loop runs while i < 5. After each loop, i increments by 1.
Practice problem: Print numbers from 10 down to 1 using a for loop.
Common mistake: Watch off-by-one errors. i < 5 runs 5 times; i <= 5 runs 6 times when starting from 0.
13. while
Phase 2
Why it matterswhile loops are useful when you do not know exactly how many times the loop will run. Firmware main loops are commonly while(1).
Core ideaThe loop continues as long as the condition is true.
Example
#include <stdio.h>
int main(void)
{
int sensor_ready = 0;
int attempts = 0;
while (sensor_ready == 0 && attempts < 3)
{
printf("Checking sensor...\n");
attempts++;
if (attempts == 2)
{
sensor_ready = 1;
}
}
printf("Done\n");
return 0;
}
ExplanationThis loop stops either when the sensor becomes ready or when attempts reaches 3.
Practice problem: Keep subtracting 10 from battery percentage until it reaches 0, printing each value.
Common mistake: Always make sure the condition can eventually become false unless you intentionally want an infinite loop.
14. do-while
Phase 2
Why it mattersdo-while is useful when the code must execute at least once before checking the condition.
Core ideaUnlike while, the condition is checked after the loop body.
Example
#include <stdio.h>
int main(void)
{
int retries = 0;
int success = 0;
do
{
printf("Trying communication...\n");
retries++;
if (retries == 2)
{
success = 1;
}
} while (success == 0 && retries < 3);
return 0;
}
ExplanationThe communication attempt happens at least once even if success was already true before the loop.
Practice problem: Use do-while to ask for a command until command is valid. Simulate the command with a variable.
Common mistake: Do not forget the semicolon after the while condition in do-while.
15. break
Phase 2
Why it mattersbreak exits a loop or switch early. This is useful when the required item is found or an error occurs.
Core ideaInstead of continuing unnecessary work, break stops the nearest loop/switch immediately.
Example
#include <stdio.h>
int main(void)
{
int values[] = {10, 20, 30, 40, 50};
int target = 30;
for (int i = 0; i < 5; i++)
{
if (values[i] == target)
{
printf("Found at index %d\n", i);
break;
}
}
return 0;
}
ExplanationOnce target is found, there is no need to check the remaining elements.
Practice problem: Scan an array for the first value greater than 100 and stop when found.
Common mistake: break only exits the nearest loop, not all nested loops.
16. continue
Phase 2
Why it matterscontinue skips the rest of the current loop iteration and moves to the next iteration.
Core ideaUse it when some values should be ignored but the loop should keep running.
Example
#include <stdio.h>
int main(void)
{
int adc_values[] = {100, -1, 250, -1, 500};
for (int i = 0; i < 5; i++)
{
if (adc_values[i] < 0)
{
continue; // invalid reading, skip it
}
printf("Valid ADC: %d\n", adc_values[i]);
}
return 0;
}
ExplanationInvalid values are skipped. Valid values are printed.
Practice problem: Print only even numbers from 1 to 20 using continue.
Common mistake: Too many continue statements can make logic harder to follow. Use clearly.
17. goto (why it is usually avoided)
Phase 2
Why it mattersgoto jumps to a label. It is usually avoided because it can make code difficult to read, but it can be useful for cleanup paths in C.
Core ideaIn embedded C, goto is sometimes used to jump to one error-handling section instead of duplicating cleanup code.
Example
#include <stdio.h>
int init_sensor(void)
{
int clock_ok = 1;
int gpio_ok = 0;
if (!clock_ok)
{
goto error;
}
if (!gpio_ok)
{
goto error;
}
return 0; // success
error:
printf("Sensor init failed\n");
return -1;
}
int main(void)
{
init_sensor();
return 0;
}
ExplanationThe goto sends all failures to one error label. This is acceptable only when it improves cleanup clarity.
Practice problem: Write a function with three initialization checks and one cleanup label.
Common mistake: Do not use goto to jump around normal program logic. It quickly creates unreadable spaghetti code.
Functions turn messy code into reusable modules. Every driver you build later will be a collection of small functions.
18. Function declaration
Phase 3
Why it mattersA function declaration tells the compiler the function name, return type, and parameters before the function is used.
Core ideaDeclarations are usually placed in header files so other .c files can call the function.
Example
#include <stdio.h>
void led_on(void); // declaration/prototype
int main(void)
{
led_on();
return 0;
}
void led_on(void) // definition
{
printf("LED ON\n");
}
Explanationmain can call led_on before its full definition because the declaration already informed the compiler.
Practice problem: Declare a function named motor_start before main and define it after main.
Common mistake: Declaration and definition must match exactly in return type and parameters.
19. Function definition
Phase 3
Why it mattersA function definition contains the actual code that runs when the function is called.
Core ideaFunctions break code into small reusable blocks. In embedded systems, drivers are built from functions like gpio_init, uart_send, adc_read.
Example
#include <stdio.h>
void blink_led(int times)
{
for (int i = 0; i < times; i++)
{
printf("LED ON\n");
printf("LED OFF\n");
}
}
int main(void)
{
blink_led(3);
return 0;
}
Explanationblink_led is the definition. It has a parameter times and repeats LED messages that many times.
Practice problem: Define a function beep(int count) that prints BEEP count times.
Common mistake: Avoid giant functions. A function should usually do one clear job.
20. Parameters
Phase 3
Why it mattersParameters allow you to pass information into a function, making it reusable for different values.
Core ideaInstead of writing led1_on, led2_on, led3_on, write led_on(pin).
Example
#include <stdio.h>
void set_led(int pin, int state)
{
printf("Pin %d = %s\n", pin, state ? "ON" : "OFF");
}
int main(void)
{
set_led(5, 1);
set_led(5, 0);
set_led(13, 1);
return 0;
}
Explanationpin and state are parameters. The same function controls different pins and states.
Practice problem: Write set_motor(speed, direction) and print the selected speed/direction.
Common mistake: Do not pass unclear magic numbers everywhere. Use enums or named constants when possible.
21. Return values
Phase 3
Why it mattersReturn values let a function send a result back to the caller, such as sensor data or error status.
Core ideaIn embedded C, functions often return 0 for success and negative values for errors.
Example
#include <stdio.h>
int read_temperature(void)
{
return 32; // fake sensor value
}
int init_uart(void)
{
return 0; // success
}
int main(void)
{
int status = init_uart();
int temp = read_temperature();
printf("status=%d temp=%d\n", status, temp);
return 0;
}
Explanationread_temperature returns data. init_uart returns status. This pattern is common in drivers.
Practice problem: Write adc_read() that returns a fake ADC value, then check if it is above 500.
Common mistake: Always handle error return values. Ignoring them hides failures.
22. Call by value
Phase 3
Why it mattersC passes normal variables by value, meaning the function receives a copy, not the original variable.
Core ideaChanging the parameter inside the function does not change the caller's variable unless you pass a pointer.
Example
#include <stdio.h>
void change_value(int x)
{
x = 99;
}
int main(void)
{
int a = 10;
change_value(a);
printf("a=%d\n", a); // still 10
return 0;
}
Explanationa was copied into x. Only x changed. The original a remained unchanged.
Practice problem: Write a function that tries to change a variable by value. Observe that it does not change outside.
Common mistake: Many beginners expect parameters to change original variables. Use pointers when you need that.
23. Passing arrays
Phase 3
Why it mattersArrays are commonly passed to functions for buffers, sensor samples, strings, and communication packets.
Core ideaWhen you pass an array to a function, it decays to a pointer to its first element. The function can modify the original array.
Example
#include <stdio.h>
void clear_buffer(int buffer[], int size)
{
for (int i = 0; i < size; i++)
{
buffer[i] = 0;
}
}
int main(void)
{
int data[5] = {1, 2, 3, 4, 5};
clear_buffer(data, 5);
for (int i = 0; i < 5; i++)
{
printf("%d ", data[i]);
}
return 0;
}
Explanationclear_buffer changes the original data array. Always pass the size separately because the function cannot automatically know it.
Practice problem: Write sum_array(int arr[], int size) that returns the sum.
Common mistake: Do not use sizeof(arr) inside a function parameter expecting full array size. It gives pointer size, not array length.
24. Recursive functions
Phase 3
Why it mattersRecursion means a function calls itself. It is useful for some algorithms, but often avoided in embedded systems due to stack limits.
Core ideaEach recursive call consumes stack memory. On small MCUs, uncontrolled recursion can crash firmware.
Example
#include <stdio.h>
int factorial(int n)
{
if (n <= 1)
{
return 1;
}
return n * factorial(n - 1);
}
int main(void)
{
printf("5! = %d\n", factorial(5));
return 0;
}
Explanationfactorial(5) calls factorial(4), then factorial(3), and so on until n <= 1.
Practice problem: Write a recursive function to calculate sum from 1 to n, then rewrite it using a loop.
Common mistake: Avoid recursion in safety-critical embedded code unless stack usage is bounded and reviewed.
Phase 4: Arrays & Strings Back to top
Arrays are buffers. Strings are command/data text. UART, SPI, ADC sampling, and display work all use arrays.
25. 1D arrays
Phase 4
Why it mattersA 1D array stores multiple values of the same type in continuous memory. This is used for samples, buffers, lookup tables, and pin lists.
Core ideaArray indexing starts at 0. An array of 5 elements has valid indexes 0 to 4.
Example
#include <stdio.h>
int main(void)
{
int adc_samples[5] = {100, 200, 300, 400, 500};
int sum = 0;
for (int i = 0; i < 5; i++)
{
sum += adc_samples[i];
}
printf("Average = %d\n", sum / 5);
return 0;
}
Explanationadc_samples[0] is 100 and adc_samples[4] is 500. The loop calculates the average.
Practice problem: Find the maximum value in an array of 10 integers.
Common mistake: Accessing adc_samples[5] is out of bounds and can corrupt memory.
26. 2D arrays
Phase 4
Why it matters2D arrays store table-like data such as keypad maps, display pixels, or calibration tables.
Core ideaA 2D array is an array of arrays. Use row and column indexes.
Example
#include <stdio.h>
int main(void)
{
int keypad[4][3] = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9},
{0, 0, 0}
};
printf("Key at row 1 col 2 = %d\n", keypad[1][2]); // 6
return 0;
}
Explanationkeypad[1][2] means second row, third column because indexing starts from zero.
Practice problem: Create a 3x3 matrix and print all elements using nested loops.
Common mistake: Remember array dimensions when passing 2D arrays to functions. The column size must be known.
27. Character arrays
Phase 4
Why it mattersA character array stores characters. It can store raw characters or a C string if it ends with '\0'.
Core ideachar name[5] = {'K','A','R','A','N'} is not a string unless there is space for the null terminator.
Example
#include <stdio.h>
int main(void)
{
char raw[3] = {'A', 'B', 'C'};
char str[4] = {'A', 'B', 'C', ''};
printf("String = %s\n", str);
printf("Raw chars = %c %c %c\n", raw[0], raw[1], raw[2]);
return 0;
}
Explanationstr is printable with %s because it ends with '\0'. raw is just characters, not a safe C string.
Practice problem: Create a character array to store "LED" safely and print it.
Common mistake: For strings, always allocate one extra byte for the null terminator.
28. Strings
Phase 4
Why it mattersStrings are used for commands, UART messages, logs, device names, and configuration text.
Core ideaIn C, a string is a char array ending with null character '\0'.
Example
#include <stdio.h>
int main(void)
{
char command[] = "blink";
if (command[0] == 'b')
{
printf("Command starts with b\n");
}
printf("Command = %s\n", command);
return 0;
}
Explanation"blink" is stored as b l i n k \0. The null terminator tells printf where the string ends.
Practice problem: Store "ON", "OFF", and "BLINK" in separate strings and compare their first characters.
Common mistake: Never write beyond the array size when receiving strings from UART. Buffer overflow is dangerous.
29. Standard string functions
Phase 4
Why it mattersstring.h provides functions like strlen, strcmp, strcpy, strncpy, and strstr. They help process command text.
Core ideaThese functions assume valid null-terminated strings. In UART code, ensure the buffer is terminated before using them.
Example
#include <stdio.h>
#include <string.h>
int main(void)
{
char cmd[] = "LED_ON";
if (strcmp(cmd, "LED_ON") == 0)
{
printf("Turn LED ON\n");
}
printf("Length = %zu\n", strlen(cmd));
return 0;
}
Explanationstrcmp returns 0 when strings are equal. strlen counts characters before '\0'. %zu is the correct format for size_t.
Practice problem: Use strstr to check if a command contains the word "blink".
Common mistake: strcpy can overflow the destination buffer. Prefer bounded copying and verify sizes.
30. Build your own string library
Phase 4
Why it mattersRewriting basic string functions builds deep understanding of arrays, pointers, and null terminators.
Core ideaImplementing your own strlen/strcmp shows exactly how C strings work internally.
Example
#include <stdio.h>
int my_strlen(const char *s)
{
int count = 0;
while (s[count] != '')
{
count++;
}
return count;
}
int my_strcmp(const char *a, const char *b)
{
int i = 0;
while (a[i] != '' && b[i] != '')
{
if (a[i] != b[i])
{
return a[i] - b[i];
}
i++;
}
return a[i] - b[i];
}
int main(void)
{
printf("len=%d\n", my_strlen("UART"));
printf("cmp=%d\n", my_strcmp("ON", "OFF"));
return 0;
}
Explanationmy_strlen walks character by character until it sees '\0'. my_strcmp compares characters until a difference or string end.
Practice problem: Implement my_strcpy and my_strstr safely.
Common mistake: Always handle the null terminator. Missing it causes functions to read random memory.
Phase 5: Pointers - Most Important Back to top
Pointers are the heart of embedded C. Registers, buffers, arrays, drivers, callbacks, and dynamic memory all require pointer confidence.
31. Memory basics
Phase 5
Why it mattersPointers become easy only when memory is clear. Embedded programming is basically controlling memory and hardware registers.
Core ideaEvery variable has an address. A pointer stores that address. The CPU can read/write memory through the address.
Example
#include <stdio.h>
int main(void)
{
int value = 42;
printf("value=%d\n", value);
printf("address=%p\n", (void *)&value);
return 0;
}
Explanation&value means address of value. The printed address tells where the variable lives in memory during program execution.
Practice problem: Create three variables and print their addresses.
Common mistake: An address is not the value itself. It is the location where the value is stored.
32. Pointer declaration
Phase 5
Why it mattersA pointer variable stores an address. Pointers are needed for arrays, buffers, registers, drivers, and efficient data passing.
Core ideaint *p means p can store the address of an int. uint32_t *reg means reg points to a 32-bit value/register.
Example
#include <stdio.h>
int main(void)
{
int number = 55;
int *ptr = &number;
printf("number=%d\n", number);
printf("ptr stores address=%p\n", (void *)ptr);
return 0;
}
Explanationptr stores the address of number. The type int* tells the compiler how many bytes to read when dereferencing.
Practice problem: Declare a pointer to int, char, and float. Store addresses of matching variables.
Common mistake: Do not use uninitialized pointers. They contain unknown addresses and can crash or corrupt memory.
33. Dereferencing
Phase 5
Why it mattersDereferencing lets you access or modify the value stored at the address inside a pointer.
Core idea*ptr means "the value at the address stored in ptr".
Example
#include <stdio.h>
int main(void)
{
int led = 0;
int *p = &led;
*p = 1; // change led through pointer
printf("led=%d\n", led);
return 0;
}
Explanationp points to led. *p = 1 writes 1 into led's memory location.
Practice problem: Write a function set_to_zero(int *p) that sets the caller's variable to 0.
Common mistake: Never dereference NULL or invalid pointers.
34. Pointer arithmetic
Phase 5
Why it mattersPointer arithmetic is used to walk through arrays and buffers. It moves by element size, not by one byte unless the pointer is char* or uint8_t*.
Core ideap + 1 means next element, not necessarily next byte. For int*, it usually moves 4 bytes on many systems.
Example
#include <stdio.h>
int main(void)
{
int arr[3] = {10, 20, 30};
int *p = arr;
printf("%d\n", *p); // 10
printf("%d\n", *(p + 1)); // 20
printf("%d\n", *(p + 2)); // 30
return 0;
}
Explanationarr gives the address of arr[0]. p+1 points to arr[1].
Practice problem: Use a pointer loop to print all elements of an array.
Common mistake: Pointer arithmetic outside the array bounds is undefined behavior.
35. Array vs pointer
Phase 5
Why it mattersArrays and pointers are closely related, but they are not the same. This is a key C concept for embedded buffers.
Core ideaAn array name often decays to a pointer to the first element, but the array itself owns storage. A pointer only stores an address.
Example
#include <stdio.h>
int main(void)
{
int arr[4] = {1, 2, 3, 4};
int *p = arr;
printf("arr[2]=%d\n", arr[2]);
printf("*(p+2)=%d\n", *(p + 2));
printf("sizeof arr=%zu\n", sizeof(arr));
printf("sizeof p=%zu\n", sizeof(p));
return 0;
}
Explanationsizeof(arr) gives total array size in bytes. sizeof(p) gives pointer size. This proves array and pointer are different.
Practice problem: Print sizeof for uint8_t buffer[10] and uint8_t *p.
Common mistake: Do not use sizeof(pointer) to determine buffer length.
36. Pointer to pointer
Phase 5
Why it mattersPointer-to-pointer stores the address of another pointer. It appears in dynamic memory, arrays of strings, and APIs that need to update a pointer.
Core ideaint **pp means pp points to an int pointer.
Example
#include <stdio.h>
void change_pointer(int **pp)
{
static int new_value = 99;
*pp = &new_value;
}
int main(void)
{
int a = 10;
int *p = &a;
change_pointer(&p);
printf("*p=%d\n", *p);
return 0;
}
Explanationchange_pointer receives the address of p, so it can change where p points.
Practice problem: Create two integers and use a function to make a pointer point to the larger one.
Common mistake: Do not overuse pointer-to-pointer. It can make code hard to read.
37. Function pointers
Phase 5
Why it mattersFunction pointers let you call different functions through a variable. Embedded drivers use them for callbacks and interrupt/event handlers.
Core ideaA function pointer stores the address of a function instead of data.
Example
#include <stdio.h>
void led_on(void) { printf("LED ON\n"); }
void led_off(void) { printf("LED OFF\n"); }
int main(void)
{
void (*action)(void);
action = led_on;
action();
action = led_off;
action();
return 0;
}
Explanationaction can point to any function with signature void function(void). Calling action() calls the selected function.
Practice problem: Create two functions motor_start and motor_stop, then call them using a function pointer.
Common mistake: The function pointer signature must match the function type.
38. Void pointers
Phase 5
Why it mattersvoid* is a generic pointer type. It can hold the address of any data type but must be cast before dereferencing.
Core ideavoid* is useful for generic APIs, but type safety is weaker.
Example
#include <stdio.h>
void print_int(void *data)
{
int *p = (int *)data;
printf("value=%d\n", *p);
}
int main(void)
{
int x = 123;
print_int(&x);
return 0;
}
Explanationdata is generic. The function casts it back to int* before reading the value.
Practice problem: Write print_float(void *data) that prints a float value.
Common mistake: Casting a void* to the wrong type causes incorrect interpretation of memory.
39. Const pointers
Phase 5
Why it mattersconst with pointers controls whether the pointed value, the pointer itself, or both can change.
Core ideaRead the declaration carefully: const int *p means pointer to const int. int * const p means const pointer to int.
Example
#include <stdio.h>
int main(void)
{
int a = 10;
int b = 20;
const int *p1 = &a; // cannot change *p1, can change p1
p1 = &b;
int * const p2 = &a; // can change *p2, cannot change p2
*p2 = 99;
printf("a=%d b=%d *p1=%d\n", a, b, *p1);
return 0;
}
Explanationp1 protects the data through that pointer. p2 protects the pointer address from changing.
Practice problem: Create const uint8_t *rx_buffer and explain what is protected.
Common mistake: const int *p and int * const p are different. Practice reading from right to left.
Structures help you model devices, packets, handles, and register maps in clean professional firmware.
40. Structures
Phase 6
Why it mattersStructures group related variables into one object. Embedded systems use structures for sensor data, driver handles, packets, and register maps.
Core ideaA structure lets you model real things: a sensor has temperature, humidity, and status.
Example
#include <stdio.h>
#include <stdint.h>
struct SensorData
{
uint16_t adc_value;
float voltage;
uint8_t error;
};
int main(void)
{
struct SensorData sensor = {2048, 1.65f, 0};
printf("adc=%u voltage=%.2f error=%u\n",
sensor.adc_value, sensor.voltage, sensor.error);
return 0;
}
Explanationsensor.adc_value accesses one field inside the structure.
Practice problem: Create struct Motor with speed, direction, and fault fields.
Common mistake: Structures can contain padding bytes. Do not assume field sizes always add exactly to sizeof(struct).
41. Nested structures
Phase 6
Why it mattersNested structures allow complex objects, like a device containing configuration and runtime state.
Core ideaOne structure can contain another structure as a member.
Example
#include <stdio.h>
#include <stdint.h>
struct GpioPin
{
char port;
uint8_t pin;
};
struct Led
{
struct GpioPin gpio;
uint8_t active_high;
};
int main(void)
{
struct Led led = {{'A', 5}, 1};
printf("LED on port %c pin %u\n", led.gpio.port, led.gpio.pin);
return 0;
}
Explanationled.gpio.pin accesses pin inside the nested GpioPin structure.
Practice problem: Create struct Device containing struct Sensor and struct Communication.
Common mistake: Deep nesting can become hard to read. Keep structure design clean.
42. Arrays of structures
Phase 6
Why it mattersArrays of structures store many similar objects such as multiple LEDs, sensors, buttons, or communication channels.
Core ideaEach array element is a full structure.
Example
#include <stdio.h>
#include <stdint.h>
struct Led
{
char port;
uint8_t pin;
};
int main(void)
{
struct Led leds[3] = {{'A', 5}, {'B', 3}, {'C', 7}};
for (int i = 0; i < 3; i++)
{
printf("LED %d: port %c pin %u\n", i, leds[i].port, leds[i].pin);
}
return 0;
}
Explanationleds[0] is one LED object, leds[1] is another, and so on.
Practice problem: Create an array of 4 sensors with id and latest_value.
Common mistake: For large arrays of structures, check RAM usage carefully.
43. Pointer to structure
Phase 6
Why it mattersPointers to structures are used to pass large structures efficiently and to access hardware register structures.
Core ideaUse -> to access fields through a structure pointer.
Example
#include <stdio.h>
#include <stdint.h>
struct Motor
{
uint8_t speed;
uint8_t direction;
};
void motor_set_speed(struct Motor *m, uint8_t speed)
{
m->speed = speed;
}
int main(void)
{
struct Motor motor = {0, 1};
motor_set_speed(&motor, 80);
printf("speed=%u\n", motor.speed);
return 0;
}
Explanationm->speed is the same as (*m).speed but easier to read.
Practice problem: Write a function that updates a Sensor structure through a pointer.
Common mistake: Check that the pointer is not NULL before using -> in robust code.
44. typedef
Phase 6
Why it matterstypedef creates a shorter or clearer name for a type. It is very common in embedded drivers.
Core ideaInstead of writing struct Motor every time, you can create Motor_t.
Example
#include <stdio.h>
#include <stdint.h>
typedef struct
{
uint8_t speed;
uint8_t direction;
} Motor_t;
int main(void)
{
Motor_t motor = {100, 1};
printf("speed=%u\n", motor.speed);
return 0;
}
ExplanationMotor_t is now a type name. Many embedded codebases use _t suffix, but avoid conflicting with system-reserved names in strict portable code.
Practice problem: Create typedef enum for SystemState_t with IDLE, RUN, ERROR.
Common mistake: typedef can hide complexity. Use it to improve clarity, not to obscure pointer types.
45. Unions
Phase 6
Why it mattersA union lets different fields share the same memory. It is used for packet parsing, register views, and memory-saving cases.
Core ideaOnly one union member should be considered valid at a time because all members overlap.
Example
#include <stdio.h>
#include <stdint.h>
union Data
{
uint32_t word;
uint8_t bytes[4];
};
int main(void)
{
union Data d;
d.word = 0x12345678;
printf("word=0x%08X\n", d.word);
printf("byte0=0x%02X\n", d.bytes[0]);
return 0;
}
Explanationword and bytes occupy the same memory. bytes[0] depends on endianness.
Practice problem: Create a union that stores either uint16_t value or two uint8_t bytes.
Common mistake: Do not assume byte order across all processors unless you handle endianness.
46. Enumerations
Phase 6
Why it mattersEnums give meaningful names to integer constants, making state machines and modes readable.
Core ideaUse enum for states like IDLE, RUNNING, ERROR instead of raw numbers like 0, 1, 2.
Example
#include <stdio.h>
typedef enum
{
STATE_IDLE,
STATE_RUN,
STATE_ERROR
} SystemState_t;
int main(void)
{
SystemState_t state = STATE_RUN;
if (state == STATE_RUN)
{
printf("System running\n");
}
return 0;
}
ExplanationSTATE_RUN is clearer than writing 1. This reduces mistakes in state-machine code.
Practice problem: Create enum for UART status: UART_OK, UART_BUSY, UART_TIMEOUT, UART_ERROR.
Common mistake: Do not rely on enum size for communication packets unless explicitly controlled by protocol design.
Phase 7: Dynamic Memory Back to top
Dynamic memory is important to understand, but many embedded systems avoid it in final firmware. Learn it so you know why and when to use it.
47. Stack vs Heap
Phase 7
Why it mattersStack and heap are memory regions. Embedded systems often have very small RAM, so knowing where memory goes is critical.
Core ideaLocal variables usually use stack. malloc uses heap. Global/static variables use data or bss sections.
Example
#include <stdio.h>
#include <stdlib.h>
int global_value; // bss/data area
void function(void)
{
int local_value = 10; // stack
int *heap_value = malloc(sizeof(int)); // heap
if (heap_value != NULL)
{
*heap_value = 20;
printf("local=%d heap=%d\n", local_value, *heap_value);
free(heap_value);
}
}
int main(void)
{
function();
return 0;
}
Explanationlocal_value disappears after function returns. heap_value memory stays allocated until free is called.
Practice problem: Create a local array and estimate stack usage from its element count and type size.
Common mistake: Huge local arrays can overflow the stack on MCUs.
48. malloc
Phase 7
Why it mattersmalloc allocates memory at runtime from the heap. It is useful in PC C, but used carefully or avoided in many embedded systems.
Core ideamalloc returns a pointer to memory or NULL if allocation fails.
Example
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *arr = malloc(5 * sizeof(int));
if (arr == NULL)
{
printf("Allocation failed\n");
return 1;
}
for (int i = 0; i < 5; i++)
{
arr[i] = i * 10;
}
free(arr);
return 0;
}
Explanationmalloc does not initialize memory. You must check for NULL and free the memory when done.
Practice problem: Allocate an array of 10 uint16_t values, fill it, print it, and free it.
Common mistake: Avoid frequent malloc/free in real-time firmware because fragmentation can occur.
49. calloc
Phase 7
Why it matterscalloc allocates memory and initializes it to zero. This is useful for buffers and structures that should start cleared.
Core ideacalloc(count, size) allocates count elements of size bytes each.
Example
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *flags = calloc(8, sizeof(int));
if (flags == NULL)
{
return 1;
}
for (int i = 0; i < 8; i++)
{
printf("%d ", flags[i]); // all zero
}
free(flags);
return 0;
}
ExplanationUnlike malloc, calloc gives zeroed memory.
Practice problem: Use calloc to allocate a structure array and verify all fields start at zero.
Common mistake: calloc can still fail. Always check for NULL.
50. realloc
Phase 7
Why it mattersrealloc changes the size of a previously allocated memory block. It is common in dynamic PC programs but less common in predictable embedded firmware.
Core idearealloc may move memory to a new address, so assign it to a temporary pointer first.
Example
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *arr = malloc(3 * sizeof(int));
if (arr == NULL) return 1;
int *temp = realloc(arr, 6 * sizeof(int));
if (temp == NULL)
{
free(arr);
return 1;
}
arr = temp;
free(arr);
return 0;
}
ExplanationIf realloc fails, the original pointer is still valid. That is why temp is used.
Practice problem: Allocate 3 integers, then grow to 6 integers using realloc.
Common mistake: Never directly write arr = realloc(arr, new_size) without considering failure; you may lose the original pointer.
51. free
Phase 7
Why it mattersfree releases heap memory allocated by malloc/calloc/realloc. Without free, memory leaks occur.
Core ideaAfter free, the pointer still contains the old address but the memory no longer belongs to you.
Example
#include <stdlib.h>
int main(void)
{
int *p = malloc(sizeof(int));
if (p == NULL) return 1;
*p = 42;
free(p);
p = NULL; // safer after free
return 0;
}
ExplanationSetting p to NULL after free helps prevent accidental use-after-free bugs.
Practice problem: Allocate memory inside a function and free it before every return path.
Common mistake: Do not free the same pointer twice. Double-free is a serious bug.
52. Memory leaks
Phase 7
Why it mattersA memory leak happens when allocated memory is no longer reachable and cannot be freed. In long-running firmware, leaks eventually crash the system.
Core ideaEvery successful malloc/calloc should have a matching free unless memory is intentionally kept for the entire program lifetime.
Example
#include <stdlib.h>
void bad_function(void)
{
int *data = malloc(100 * sizeof(int));
if (data == NULL) return;
// forgot free(data); -> leak
}
void good_function(void)
{
int *data = malloc(100 * sizeof(int));
if (data == NULL) return;
free(data);
}
Explanationbad_function leaks memory every time it is called.
Practice problem: Review a function with multiple returns and ensure all allocated memory is freed.
Common mistake: On MCUs, prefer static allocation when memory usage must be deterministic.
Phase 8: Bit Manipulation Back to top
This is the most embedded-specific C skill. Registers are controlled bit by bit. Master this phase slowly.
53. Binary numbers
Phase 8
Why it mattersEmbedded registers are made of bits. Binary lets you understand each bit as ON/OFF, enabled/disabled, set/clear.
Core ideaOne byte has 8 bits. Bit 0 is the least significant bit. 1<<5 means a value with only bit 5 set.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint8_t value = 0b00100000; // bit 5 set
printf("value=%u hex=0x%02X\n", value, value);
return 0;
}
Explanation0b00100000 equals 32 decimal and 0x20 hex. This is bit 5 because counting starts from bit 0.
Practice problem: Write decimal and hex values for bit 0, bit 3, and bit 7 set.
Common mistake: Humans count 1st, 2nd, 3rd; bits are normally numbered 0, 1, 2.
54. Bitwise AND
Phase 8
Why it mattersAND is used to test whether a bit is set or to clear selected bits.
Core ideaA bit remains 1 only if both input bits are 1.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint8_t reg = 0b00101000;
uint8_t mask = (1 << 3);
if (reg & mask)
{
printf("Bit 3 is set\n");
}
return 0;
}
Explanationreg & mask keeps only bit 3. If result is non-zero, bit 3 was set.
Practice problem: Check whether bit 7 of a uint8_t value is set.
Common mistake: Use bitwise & for bits, not logical &&. They are different.
55. OR
Phase 8
Why it mattersOR is used to set bits without disturbing other bits.
Core ideaA bit becomes 1 if either input bit is 1.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint8_t reg = 0b00000000;
reg = reg | (1 << 5); // set bit 5
printf("reg=0x%02X\n", reg);
return 0;
}
ExplanationOnly bit 5 becomes 1. Other bits remain as they were.
Practice problem: Set bit 0 and bit 4 in a register variable.
Common mistake: Using reg = (1 << 5) overwrites all other bits. Use reg |= mask to preserve them.
56. XOR
Phase 8
Why it mattersXOR is commonly used to toggle bits, compare differences, and implement simple checksums.
Core ideaXOR outputs 1 when bits are different and 0 when they are same.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint8_t reg = 0b00010000;
reg ^= (1 << 4); // toggle bit 4 off
reg ^= (1 << 4); // toggle bit 4 on again
printf("reg=0x%02X\n", reg);
return 0;
}
ExplanationXOR with 1 flips the bit. XOR with 0 leaves it unchanged.
Practice problem: Toggle bit 2 of a variable five times and print the result after each toggle.
Common mistake: Do not use XOR when you need a known ON/OFF state. Use set or clear instead.
57. NOT
Phase 8
Why it mattersBitwise NOT inverts every bit. It is often used to build masks for clearing bits.
Core idea~mask changes 1s to 0s and 0s to 1s.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint8_t reg = 0xFF;
uint8_t mask = (1 << 3);
reg = reg & (uint8_t)(~mask); // clear bit 3
printf("reg=0x%02X\n", reg);
return 0;
}
Explanation~mask has all bits as 1 except bit 3. AND with it clears bit 3 and keeps others unchanged.
Practice problem: Clear bit 0 from 0xFF using NOT and AND.
Common mistake: ~ operates after integer promotion. Cast to the desired width when needed.
58. Left shift
Phase 8
Why it mattersLeft shift is used to create bit masks and multiply unsigned integers by powers of two.
Core idea1 << n creates a value with bit n set.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint32_t bit0 = (1UL << 0);
uint32_t bit5 = (1UL << 5);
printf("bit0=0x%08lX bit5=0x%08lX\n",
(unsigned long)bit0, (unsigned long)bit5);
return 0;
}
Explanation1UL is used so the shift happens on an unsigned long. This is safer for register masks.
Practice problem: Create masks for bits 0, 8, 15, and 31.
Common mistake: Do not shift by a value equal to or greater than the width of the type.
59. Right shift
Phase 8
Why it mattersRight shift is used to extract fields from registers or divide unsigned values by powers of two.
Core ideavalue >> n moves bits right by n positions.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint8_t reg = 0b10110000;
uint8_t upper_nibble = reg >> 4;
printf("upper=%u\n", upper_nibble);
return 0;
}
ExplanationShifting 10110000 right by 4 gives 00001011, which is 11.
Practice problem: Given 0xAB, extract high nibble and low nibble separately.
Common mistake: Right shifting signed negative values is implementation-defined. Prefer unsigned for bit operations.
60. Bit masks
Phase 8
Why it mattersA bit mask selects, sets, clears, or toggles specific bits. Register programming depends on masks.
Core ideaA mask has 1s in the bit positions you care about and 0s elsewhere.
Example
#include <stdint.h>
#define LED_PIN 5U
#define LED_MASK (1UL << LED_PIN)
int main(void)
{
uint32_t reg = 0;
reg |= LED_MASK; // set LED pin bit
reg &= ~LED_MASK; // clear LED pin bit
return 0;
}
ExplanationUsing named masks makes code readable and reduces mistakes in bit positions.
Practice problem: Create masks for UART_TX_PIN=9 and UART_RX_PIN=10.
Common mistake: Do not write raw numbers like 0x20 everywhere. Use named masks.
61. Setting bits
Phase 8
Why it mattersSetting a bit enables features like clock enable, GPIO output high, interrupt enable, or peripheral enable.
Core ideaUse reg |= mask to set a bit while preserving other bits.
Example
#include <stdint.h>
#define BIT(n) (1UL << (n))
int main(void)
{
uint32_t control = 0;
control |= BIT(0); // enable peripheral
control |= BIT(3); // enable interrupt
return 0;
}
ExplanationOR with 1 sets selected bits. OR with 0 leaves other bits unchanged.
Practice problem: Set bits 2 and 6 in one statement.
Common mistake: Do not use control = BIT(3) if other bits must remain unchanged.
62. Clearing bits
Phase 8
Why it mattersClearing bits disables features or resets flags. This must be done without disturbing unrelated bits.
Core ideaUse reg &= ~mask to clear a bit.
Example
#include <stdint.h>
#define BIT(n) (1UL << (n))
int main(void)
{
uint32_t control = 0xFFFFFFFF;
control &= ~BIT(3); // clear bit 3
return 0;
}
Explanation~BIT(3) has every bit as 1 except bit 3. AND clears only bit 3.
Practice problem: Clear bits 1 and 4 from a variable.
Common mistake: Some hardware flags are cleared by writing 1, not 0. Always read the datasheet.
63. Toggling bits
Phase 8
Why it mattersToggling changes a bit from 0 to 1 or 1 to 0. It is useful for blinking LEDs.
Core ideaUse reg ^= mask.
Example
#include <stdint.h>
#define BIT(n) (1UL << (n))
int main(void)
{
uint32_t output = 0;
output ^= BIT(5); // LED changes state
output ^= BIT(5); // LED changes back
return 0;
}
ExplanationXOR flips selected bits and leaves the rest unchanged.
Practice problem: Toggle bits 0 and 7 in a uint8_t variable.
Common mistake: Toggling is not good when you need guaranteed ON or OFF.
64. Reading bits
Phase 8
Why it mattersReading bits is used to check buttons, flags, status registers, interrupt pending bits, and peripheral states.
Core ideaUse if (reg & mask) to check if the bit is 1.
Example
#include <stdio.h>
#include <stdint.h>
#define BIT(n) (1UL << (n))
int main(void)
{
uint32_t status = BIT(2);
if (status & BIT(2))
{
printf("Data ready\n");
}
return 0;
}
ExplanationThe result is non-zero only if bit 2 is set.
Practice problem: Check bit 5 and print SET or CLEAR.
Common mistake: Do not compare (reg & mask) == 1 unless mask is bit 0. Use != 0.
Now C connects to hardware. You will learn volatile, registers, GPIO, interrupts, polling, and delays.
65. Fixed-width integers (stdint.h)
Phase 9
Why it mattersFixed-width types guarantee exact sizes. Embedded registers, communication packets, and protocols require predictable widths.
Core ideaUse uint8_t for bytes, uint16_t for 16-bit values, uint32_t for 32-bit registers, int32_t for signed 32-bit values.
Example
#include <stdint.h>
#include <stdio.h>
typedef struct
{
uint8_t id;
uint16_t adc;
uint32_t timestamp;
} Packet_t;
int main(void)
{
Packet_t p = {1, 2048, 1000};
printf("id=%u adc=%u time=%lu\n", p.id, p.adc, (unsigned long)p.timestamp);
return 0;
}
ExplanationThe packet layout uses known-size fields. This matters when transmitting bytes over UART/SPI/CAN.
Practice problem: Create a packet with uint8_t command, uint8_t length, and uint32_t checksum.
Common mistake: Do not assume int is 32-bit everywhere. Use stdint.h when size matters.
66. volatile
Phase 9
Why it mattersvolatile tells the compiler that a variable can change unexpectedly outside normal program flow. This is essential for hardware registers and interrupt-shared variables.
Core ideaWithout volatile, the compiler may optimize repeated reads and assume the value never changes. Hardware and interrupts can change values behind the compiler's back.
Example
#include <stdint.h>
#include <stdio.h>
volatile uint8_t button_pressed = 0;
void EXTI_Button_IRQHandler(void) // imagine this is an interrupt
{
button_pressed = 1;
}
int main(void)
{
while (button_pressed == 0)
{
// wait until interrupt changes button_pressed
}
printf("Button pressed\n");
return 0;
}
Explanationbutton_pressed must be volatile because it is changed by an interrupt. The main loop must actually re-read it from memory every time.
Practice problem: Create a volatile flag called uart_rx_done and simulate setting it from a function that represents an ISR.
Common mistake: volatile is not a lock and does not make multi-byte access atomic. It only prevents certain compiler optimizations.
67. const
Phase 9
Why it mattersconst means the program should not modify a value. In embedded systems, const data can often live in Flash instead of RAM.
Core ideaUse const for lookup tables, calibration constants, fixed strings, and read-only configuration.
Example
#include <stdio.h>
#include <stdint.h>
const uint16_t adc_threshold = 3000;
const char device_name[] = "STM32_NODE_01";
int main(void)
{
uint16_t adc = 3100;
if (adc > adc_threshold)
{
printf("%s: threshold crossed\n", device_name);
}
return 0;
}
Explanationadc_threshold should not change during runtime. Marking it const prevents accidental modification and communicates intent to the compiler and reader.
Practice problem: Create a const lookup table for 5 PWM duty values: 0, 25, 50, 75, 100.
Common mistake: const does not mean compile-time constant in every situation. It means read-only through that variable.
68. Memory-mapped registers
Phase 9
Why it mattersMCU peripherals are controlled by registers placed at fixed memory addresses. C can access these addresses using volatile pointers.
Core ideaA register is just a memory location connected to hardware. Writing to it changes hardware behavior.
Example
#include <stdint.h>
#define GPIOA_ODR_ADDR (0x48000014UL)
#define GPIOA_ODR (*(volatile uint32_t *)GPIOA_ODR_ADDR)
#define LED_PIN 5U
int main(void)
{
GPIOA_ODR |= (1UL << LED_PIN); // set pin high
GPIOA_ODR &= ~(1UL << LED_PIN); // set pin low
return 0;
}
ExplanationGPIOA_ODR is treated like a normal variable, but it actually maps to a hardware register. volatile is mandatory because hardware is involved.
Practice problem: Create fake register macros for CTRL_REG and STATUS_REG and manipulate bits.
Common mistake: Never use random real addresses without checking the exact MCU reference manual.
69. Register programming
Phase 9
Why it mattersRegister programming is the foundation of bare-metal firmware. It means configuring peripherals directly by writing to their registers.
Core ideaTypical steps: enable clock, configure mode, configure speed/pull, then write/read data register.
Example
#include <stdint.h>
#define BIT(n) (1UL << (n))
volatile uint32_t RCC_AHB2ENR;
volatile uint32_t GPIOA_MODER;
volatile uint32_t GPIOA_ODR;
void gpioa_pin5_output_init(void)
{
RCC_AHB2ENR |= BIT(0); // enable GPIOA clock
GPIOA_MODER &= ~(3UL << 10); // clear mode bits for pin 5
GPIOA_MODER |= (1UL << 10); // set pin 5 as output
}
void gpioa_pin5_on(void)
{
GPIOA_ODR |= BIT(5);
}
ExplanationThis is a simplified register model. Real register names and bit positions come from the reference manual and device header.
Practice problem: Write pseudo-code to enable a peripheral clock and configure one pin as output.
Common mistake: Register fields are often multi-bit. Clear the field before writing a new value.
70. GPIO programming
Phase 9
Why it mattersGPIO is the simplest embedded peripheral. It teaches clocks, modes, output, input, pull-up, and bit manipulation.
Core ideaTo use a GPIO pin, enable its port clock, set mode, then read input or write output.
Example
#include <stdint.h>
#define BIT(n) (1UL << (n))
volatile uint32_t GPIO_ODR;
volatile uint32_t GPIO_IDR;
void led_on(void) { GPIO_ODR |= BIT(5); }
void led_off(void) { GPIO_ODR &= ~BIT(5); }
void led_toggle(void) { GPIO_ODR ^= BIT(5); }
uint8_t button_read(void)
{
return (GPIO_IDR & BIT(13)) ? 1U : 0U;
}
ExplanationODR is output data register. IDR is input data register. This example is symbolic but matches real MCU concepts.
Practice problem: Write functions led_on, led_off, led_toggle, button_read for fake registers.
Common mistake: If a GPIO write does nothing, often the clock was not enabled or the pin mode was not configured.
71. Macros
Phase 9
Why it mattersMacros help define register addresses, bit masks, constants, and small reusable expressions at compile time.
Core ideaThe preprocessor replaces macros before compilation.
Example
#include <stdint.h>
#define BIT(n) (1UL << (n))
#define LED_PIN 5U
#define LED_MASK BIT(LED_PIN)
#define SET_BITS(r, m) ((r) |= (m))
#define CLR_BITS(r, m) ((r) &= ~(m))
int main(void)
{
uint32_t reg = 0;
SET_BITS(reg, LED_MASK);
CLR_BITS(reg, LED_MASK);
return 0;
}
ExplanationParentheses around macro arguments prevent precedence bugs.
Practice problem: Create READ_BIT(reg, mask) macro that returns non-zero if selected bit is set.
Common mistake: Macros do not check types. For complex logic, prefer inline functions.
73. Static functions
Phase 9
Why it mattersstatic functions at file scope are visible only inside that .c file. This hides internal implementation details.
Core ideaUse static for helper functions that should not be called from other files.
Example
// uart.c
#include <stdint.h>
static void uart_config_baudrate(uint32_t baud)
{
// private helper
}
void uart_init(uint32_t baud)
{
uart_config_baudrate(baud);
}
ExplanationOther files can call uart_init, but they cannot call uart_config_baudrate directly.
Practice problem: Create a public sensor_init and private static sensor_calibrate helper.
Common mistake: If a function is used only in one .c file, make it static to avoid namespace pollution.
74. Interrupts
Phase 9
Why it mattersSTM32 interrupts handle real-time events like external pins, UART RX, timers, ADC complete, and DMA complete.
Core ideaConfigure peripheral interrupt enable, NVIC interrupt enable, and write the IRQ handler.
Example
#include <stdint.h>
volatile uint8_t tick_flag = 0;
void TIM2_IRQHandler(void)
{
// clear timer interrupt flag here in real STM32 code
tick_flag = 1;
}
int main(void)
{
while (1)
{
if (tick_flag)
{
tick_flag = 0;
// periodic task
}
}
}
ExplanationThe ISR sets a flag. Main loop handles the work. Real STM32 code must clear the interrupt pending flag.
Practice problem: Create a button interrupt that sets button_event = 1.
Common mistake: If you do not clear the interrupt flag, the ISR may repeatedly fire.
75. ISR design
Phase 9
Why it mattersGood ISR design keeps firmware responsive and avoids timing bugs.
Core ideaKeep ISR short, clear hardware flags, save minimal data, use volatile shared variables, and process heavy work in main/task context.
Example
#include <stdint.h>
#define RX_BUF_SIZE 64
volatile uint8_t rx_buf[RX_BUF_SIZE];
volatile uint8_t rx_head = 0;
void UART_RX_IRQHandler(uint8_t byte)
{
uint8_t next = (rx_head + 1U) % RX_BUF_SIZE;
rx_buf[rx_head] = byte;
rx_head = next;
}
ExplanationThe ISR quickly stores a received byte in a ring-like buffer and exits. Real code must also handle overflow and hardware flags.
Practice problem: Design an ISR that only sets adc_ready = 1 after conversion complete.
Common mistake: Shared multi-byte data between ISR and main may need critical sections, not only volatile.
76. Polling vs Interrupt
Phase 9
Why it mattersPolling repeatedly checks a condition. Interrupts react automatically when an event happens. Both are used in embedded systems.
Core ideaPolling is simple but can waste CPU. Interrupts are efficient but more complex.
Example
// Polling style
while ((STATUS_REG & RX_READY_MASK) == 0)
{
// wait
}
byte = DATA_REG;
// Interrupt style
void UART_IRQHandler(void)
{
byte = DATA_REG;
rx_ready = 1;
}
ExplanationPolling is fine for simple short waits. Interrupts are better for asynchronous events and multitasking.
Practice problem: List which method you would use for button press, ADC single read, UART stream, and LED blink timer.
Common mistake: Polling forever without timeout can lock the firmware.
77. Busy waiting
Phase 9
Why it mattersBusy waiting means the CPU does nothing useful while waiting. It is simple but inefficient.
Core ideawhile(flag == 0) {} is busy waiting. It may be acceptable for very short hardware waits but bad for long waits.
Example
#include <stdint.h>
volatile uint8_t tx_done = 0;
void wait_tx_done(void)
{
while (tx_done == 0)
{
// CPU stuck here until flag changes
}
}
ExplanationThe CPU cannot do other work during this wait.
Practice problem: Modify busy wait to include a timeout counter.
Common mistake: Never busy-wait forever for hardware without timeout in production code.
78. Delay implementation
Phase 9
Why it mattersDelays are used for blinking, settling time, debouncing, and protocol timing. Bad delays waste CPU or become inaccurate after clock changes.
Core ideaSimple loop delays are rough. Timer-based delays are better. RTOS delays are best when multitasking.
Example
#include <stdint.h>
void delay_loop(volatile uint32_t count)
{
while (count--)
{
// simple crude delay
}
}
int main(void)
{
while (1)
{
// led_toggle();
delay_loop(1000000);
}
}
Explanationvolatile prevents the compiler from removing the empty loop. This delay is not precise and depends on CPU speed and optimization.
Practice problem: Write a delay_ms function using a fake millisecond tick counter.
Common mistake: Do not use long blocking delays in responsive firmware. Prefer timers/state machines.
Phase 10: Preprocessor Back to top
The preprocessor controls constants, configuration, feature flags, and header safety in embedded projects.
79. #define
Phase 10
Why it matters#define creates symbolic constants and macros. Embedded C uses it for pin numbers, register addresses, masks, and compile-time options.
Core idea#define is processed before compilation by the preprocessor.
Example
#include <stdio.h>
#define LED_PIN 5
#define MAX_RETRY 3
int main(void)
{
printf("LED pin=%d retries=%d\n", LED_PIN, MAX_RETRY);
return 0;
}
ExplanationThe compiler sees the numbers after preprocessing. The names make the code easier to understand.
Practice problem: Define ADC_MAX as 4095 and VREF_MV as 3300.
Common mistake: Do not put semicolon in constant macros: #define LED_PIN 5; is wrong style and can cause bugs.
80. Macros with arguments
Phase 10
Why it mattersArgument macros reduce repeated bit operations and register expressions.
Core ideaAlways wrap macro arguments and the full expression in parentheses.
Example
#include <stdint.h>
#define BIT(n) (1UL << (n))
#define SET_BIT(reg, bit) ((reg) |= BIT(bit))
#define CLEAR_BIT(reg, bit) ((reg) &= ~BIT(bit))
int main(void)
{
uint32_t reg = 0;
SET_BIT(reg, 5);
CLEAR_BIT(reg, 5);
return 0;
}
ExplanationSET_BIT(reg, 5) becomes ((reg) |= (1UL << (5))).
Practice problem: Create TOGGLE_BIT(reg, bit) and READ_BIT(reg, bit) macros.
Common mistake: Macros can evaluate arguments more than once. Avoid side effects like SET_BIT(arr[i++], 3).
81. Include guards
Phase 10
Why it mattersInclude guards prevent a header file from being included multiple times in one compilation unit.
Core ideaUse #ifndef, #define, and #endif around the header contents.
Example
// sensor.h
#ifndef SENSOR_H
#define SENSOR_H
#include <stdint.h>
void sensor_init(void);
uint16_t sensor_read(void);
#endif // SENSOR_H
ExplanationIf SENSOR_H is already defined, the header content is skipped on the next include.
Practice problem: Create include guards for motor.h and uart_driver.h.
Common mistake: Using the same guard name in two different headers can hide one header accidentally.
82. Conditional compilation
Phase 10
Why it mattersConditional compilation includes or excludes code at compile time. It is used for debug logs, board selection, and feature flags.
Core ideaThe preprocessor decides what code the compiler sees.
Example
#include <stdio.h>
#define DEBUG 1
int main(void)
{
#if DEBUG
printf("Debug mode enabled\n");
#endif
return 0;
}
ExplanationIf DEBUG is 1, the printf is compiled. If DEBUG is 0, it is removed before compilation.
Practice problem: Create a BOARD_STM32 and BOARD_ESP32 selection block.
Common mistake: Too much conditional compilation can make code hard to test. Keep it organized.
83. #ifdef
Phase 10
Why it matters#ifdef checks whether a macro is defined. It is useful for optional features.
Core idea#ifdef DEBUG includes code only if DEBUG exists, regardless of its value.
Example
#include <stdio.h>
#define ENABLE_LOGS
int main(void)
{
#ifdef ENABLE_LOGS
printf("Logs enabled\n");
#endif
return 0;
}
ExplanationBecause ENABLE_LOGS is defined, the log code is included.
Practice problem: Use #ifdef USE_UART to include uart_init call.
Common mistake: #ifdef FEATURE only checks existence, not whether FEATURE is 0 or 1.
84. #ifndef
Phase 10
Why it matters#ifndef checks whether a macro is not defined. It is used mostly for include guards and default settings.
Core ideaIf a value is not provided, define a default.
Example
#include <stdio.h>
#ifndef BAUD_RATE
#define BAUD_RATE 115200
#endif
int main(void)
{
printf("Baud=%d\n", BAUD_RATE);
return 0;
}
ExplanationIf BAUD_RATE was not defined earlier, it becomes 115200.
Practice problem: Create a default SYSTEM_CLOCK_HZ if not already defined.
Common mistake: Do not hide configuration problems by silently setting defaults everywhere.
Phase 11: Files & Build System Back to top
Professional firmware is multi-file. This phase teaches how .c/.h files become firmware through compilation and linking.
85. .c and .h
Phase 11
Why it mattersReal firmware is split into .c implementation files and .h interface files. This keeps projects organized.
Core ideaHeader files declare what a module provides. C files define how it works.
Example
// button.h
#ifndef BUTTON_H
#define BUTTON_H
#include <stdint.h>
uint8_t button_read(void);
#endif
// button.c
#include "button.h"
uint8_t button_read(void)
{
return 0;
}
// main.c
#include "button.h"
int main(void)
{
return button_read();
}
Explanationmain.c does not need to know how button_read is implemented. It only needs the declaration.
Practice problem: Make led.c/led.h with led_on, led_off, led_toggle.
Common mistake: Do not include .c files from other .c files. Compile them separately and link.
86. Separate compilation
Phase 11
Why it mattersSeparate compilation allows each .c file to be compiled into an object file independently, then linked together.
Core ideaThis speeds builds and enables modular firmware design.
Example
gcc -c main.c -o main.o
gcc -c led.c -o led.o
gcc main.o led.o -o app
Explanation-c compiles without linking. The final command links object files into the executable.
Practice problem: Compile main.c and math_utils.c separately, then link them.
Common mistake: If a function is declared but not defined in any object file, the linker will report undefined reference.
87. Object files
Phase 11
Why it mattersObject files contain compiled machine code and symbols from one source file, but are not complete programs yet.
Core ideaA .o file is an intermediate build product before linking.
Example
gcc -c uart.c -o uart.o
nm uart.o
Explanationuart.o may contain functions like uart_init and uart_send. The linker later connects calls between object files.
Practice problem: Generate an object file and inspect its size.
Common mistake: An object file cannot run by itself because it may depend on other files and startup code.
88. Linking
Phase 11
Why it mattersLinking combines object files and libraries into the final executable or firmware image.
Core ideaThe linker resolves symbols, places code/data into memory sections, and uses a linker script in embedded projects.
Example
arm-none-eabi-gcc startup.o main.o led.o \
-T linker.ld -o firmware.elf
Explanation-T linker.ld tells the linker where Flash and RAM are located on the target MCU.
Practice problem: Draw how main.o calling led_on gets connected to led.o during linking.
Common mistake: Linker errors are different from compiler errors. Undefined reference means the declaration existed but the definition was not linked.
89. Makefile
Phase 11
Why it mattersA Makefile automates build commands so you do not type long compiler commands every time.
Core ideamake checks dependencies and rebuilds only what changed.
Example
CC=gcc
CFLAGS=-Wall -Wextra
OBJS=main.o led.o
app: $(OBJS)
$(CC) $(OBJS) -o app
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) app
Explanation$< means input file and $@ means output target. Tabs before commands are required in Makefiles.
Practice problem: Create a Makefile for main.c, sensor.c, and uart.c.
Common mistake: Makefile commands must start with a tab, not spaces.
90. Static libraries
Phase 11
Why it mattersStatic libraries bundle multiple object files into one .a file. Firmware projects often use libraries for drivers or middleware.
Core ideaThe linker copies needed object code from the library into the final program.
Example
gcc -c led.c -o led.o
ar rcs libdrivers.a led.o
gcc main.o -L. -ldrivers -o app
Explanationlibdrivers.a contains led.o. -ldrivers links against libdrivers.a.
Practice problem: Create a small static library containing math_utils.o and link it.
Common mistake: Library order can matter in link commands. Put libraries after the object files that use them.
Phase 12: Professional Embedded Back to top
This phase introduces real peripherals and production concepts: UART, SPI, I²C, timers, ADC, PWM, watchdog, bootloader, and clean coding.
91. UART driver
Phase 12
Why it mattersUART is used for serial communication, debugging, command interfaces, GPS modules, Bluetooth modules, and PC communication.
Core ideaA driver usually has init, send byte, receive byte, send buffer, and interrupt/callback handling.
Example
#include <stdint.h>
typedef struct
{
volatile uint32_t SR;
volatile uint32_t DR;
volatile uint32_t BRR;
volatile uint32_t CR1;
} UART_Reg_t;
void uart_send_byte(UART_Reg_t *uart, uint8_t byte)
{
while ((uart->SR & (1UL << 7)) == 0)
{
// wait until TX empty
}
uart->DR = byte;
}
ExplanationThis shows the idea of waiting for TX ready then writing the byte. Real STM32 register names differ by family.
Practice problem: Write uart_send_string that sends characters until '\0'.
Common mistake: Always add timeouts in production drivers instead of waiting forever.
92. SPI driver
Phase 12
Why it mattersSPI is used with displays, sensors, flash memories, ADCs, and radio modules. It is fast and simple.
Core ideaSPI transfers data while clock pulses. Every send also receives a byte because SPI is full-duplex.
Example
#include <stdint.h>
typedef struct
{
volatile uint32_t SR;
volatile uint32_t DR;
} SPI_Reg_t;
uint8_t spi_transfer(SPI_Reg_t *spi, uint8_t data)
{
while ((spi->SR & (1UL << 1)) == 0) {}
spi->DR = data;
while ((spi->SR & (1UL << 0)) == 0) {}
return (uint8_t)spi->DR;
}
ExplanationWait for transmit buffer empty, write data, then wait for received data. Actual bits depend on MCU.
Practice problem: Create spi_write_buffer that transfers every byte in a buffer.
Common mistake: Chip select handling is part of the protocol. Pull CS low before transfer and high after.
93. I²C driver
Phase 12
Why it mattersI²C connects multiple chips using only SDA and SCL lines. It is common for sensors, EEPROMs, RTCs, and displays.
Core ideaI²C uses addresses, start condition, read/write bit, ACK/NACK, and stop condition.
Example
#include <stdint.h>
typedef struct
{
volatile uint32_t CR1;
volatile uint32_t CR2;
volatile uint32_t SR1;
volatile uint32_t SR2;
volatile uint32_t DR;
} I2C_Reg_t;
#define I2C_CR1_START (1UL << 8)
#define I2C_CR1_STOP (1UL << 9)
#define I2C_SR1_SB (1UL << 0) /* start bit sent */
#define I2C_SR1_ADDR (1UL << 1) /* address acked */
#define I2C_SR1_TXE (1UL << 7) /* data register empty */
#define I2C_SR1_BTF (1UL << 2) /* byte transfer finished */
static void i2c_wait_flag(I2C_Reg_t *i2c, uint32_t flag)
{
while ((i2c->SR1 & flag) == 0U) { /* wait, add a timeout in production */ }
}
/* Writes one byte to a register on an I2C device, e.g. an IMU or EEPROM. */
int i2c_write_register(I2C_Reg_t *i2c, uint8_t dev_addr, uint8_t reg_addr, uint8_t value)
{
i2c->CR1 |= I2C_CR1_START;
i2c_wait_flag(i2c, I2C_SR1_SB); /* start condition sent */
i2c->DR = (uint8_t)(dev_addr << 1) | 0U; /* 7-bit address, write bit */
i2c_wait_flag(i2c, I2C_SR1_ADDR);
(void)i2c->SR2; /* SR2 read clears ADDR flag */
i2c->DR = reg_addr;
i2c_wait_flag(i2c, I2C_SR1_TXE);
i2c->DR = value;
i2c_wait_flag(i2c, I2C_SR1_BTF); /* value has left the shift register */
i2c->CR1 |= I2C_CR1_STOP;
return 0;
}
ExplanationRegister bit names and offsets differ by MCU family, but the sequence is always: start → send address with the write bit → send the register (sub-address) you want to touch → send the data byte → stop. Reading SR2 right after ADDR goes high is not optional — on real STM32 I2C peripherals that read is what clears the ADDR flag, and skipping it stalls the bus on the next transfer.
Practice problem: Write i2c_read_register() that writes the register address (no stop), sends a repeated start, reads one byte, then NACKs and stops — the standard sequence for reading a sensor's WHO_AM_I register.
Common mistake: I²C needs pull-up resistors on SDA and SCL. Without pull-ups, communication fails.
94. Timer driver
Phase 12
Why it mattersTimers generate delays, periodic interrupts, PWM, input capture, and time measurement.
Core ideaA timer counts clock ticks. Prescaler slows the clock. Auto-reload decides when overflow/update occurs.
Example
#include <stdint.h>
typedef struct
{
volatile uint32_t PSC;
volatile uint32_t ARR;
volatile uint32_t CNT;
volatile uint32_t CR1;
} TIM_Reg_t;
void timer_start_1khz(TIM_Reg_t *tim, uint32_t timer_clk_hz)
{
tim->PSC = (timer_clk_hz / 1000000UL) - 1UL; // 1 MHz timer tick
tim->ARR = 1000UL - 1UL; // 1 ms update
tim->CNT = 0;
tim->CR1 |= 1UL;
}
ExplanationThis config makes the timer count microseconds and overflow every 1000 ticks = 1 ms.
Practice problem: Calculate PSC and ARR for 1 second interrupt using 1 MHz timer tick.
Common mistake: Timer clock may not equal CPU clock. Check clock tree.
95. ADC driver
Phase 12
Why it mattersADC converts analog voltages into digital numbers. It is used for sensors, battery voltage, potentiometers, and current measurement.
Core ideaADC result depends on resolution and reference voltage. For 12-bit ADC, range is usually 0 to 4095.
Example
#include <stdint.h>
uint32_t adc_to_millivolts(uint16_t adc, uint32_t vref_mv)
{
return ((uint32_t)adc * vref_mv) / 4095UL;
}
int main(void)
{
uint32_t mv = adc_to_millivolts(2048, 3300);
(void)mv;
return 0;
}
Explanation2048 from a 12-bit ADC at 3.3V is about 1650 mV.
Practice problem: Convert ADC readings 0, 1024, 2048, 4095 into millivolts.
Common mistake: ADC readings are noisy. Use filtering/averaging when needed.
96. PWM
Phase 12
Why it mattersPWM controls LED brightness, motor speed, servo position, buzzers, and power converters.
Core ideaPWM rapidly switches ON/OFF. Duty cycle controls average power.
Example
#include <stdint.h>
typedef struct
{
volatile uint32_t ARR;
volatile uint32_t CCR1;
} PWM_Timer_t;
void pwm_set_percent(PWM_Timer_t *tim, uint8_t percent)
{
if (percent > 100) percent = 100;
tim->CCR1 = ((tim->ARR + 1UL) * percent) / 100UL;
}
ExplanationARR is the period. CCR is compare value. Higher CCR means higher duty cycle.
Practice problem: For ARR=999, calculate CCR for 0%, 25%, 50%, 75%, 100% duty.
Common mistake: Frequency and duty cycle are separate. Changing ARR changes frequency and duty resolution.
97. Watchdog
Phase 12
Why it mattersA watchdog resets the MCU if firmware gets stuck. It improves reliability in production systems.
Core ideaFirmware must periodically refresh the watchdog. If it stops refreshing, the watchdog assumes a fault and resets the system.
Example
#include <stdint.h>
typedef struct
{
volatile uint32_t KR; /* key register: unlocks / feeds / starts */
volatile uint32_t PR; /* prescaler */
volatile uint32_t RLR; /* reload value (down-counter target) */
volatile uint32_t SR; /* status: shows if PR/RLR writes finished */
} IWDG_Reg_t;
#define IWDG_KEY_ENABLE_WRITE 0x5555U
#define IWDG_KEY_RELOAD 0xAAAAU
#define IWDG_KEY_START 0xCCCCU
/* ~1 second timeout: 32 kHz LSI / prescaler 64 = 500 Hz, reload 500 counts. */
void watchdog_init(IWDG_Reg_t *iwdg)
{
iwdg->KR = IWDG_KEY_ENABLE_WRITE; /* unlock PR/RLR for writing */
iwdg->PR = 4U; /* prescaler /64 */
iwdg->RLR = 500U; /* counts down from 500 */
while (iwdg->SR != 0U) { /* wait until PR/RLR updates are applied */ }
iwdg->KR = IWDG_KEY_START; /* start the watchdog */
}
void watchdog_kick(IWDG_Reg_t *iwdg)
{
iwdg->KR = IWDG_KEY_RELOAD; /* reload the down-counter */
}
int main(void)
{
IWDG_Reg_t * const iwdg = (IWDG_Reg_t *)0x40003000U;
watchdog_init(iwdg);
while (1)
{
/* main application work: read sensors, service comms, etc. */
watchdog_kick(iwdg);
}
}
ExplanationOnce started, the IWDG counts down from RLR at a rate set by PR; if it ever reaches zero the chip resets. watchdog_kick() reloads the counter before that happens. The two magic keys, 0x5555 to unlock configuration and 0xAAAA to feed it, are a hardware safety mechanism: no accidental single-bit write can disable the watchdog once it's running.
Practice problem: Design where watchdog_kick should happen in a sensor reading application.
Common mistake: Kicking the watchdog from an ISR can hide a dead main loop.
98. Bootloader basics
Phase 12
Why it mattersA bootloader can update firmware, verify images, and jump to the main application.
Core ideaOn reset, bootloader runs first. It decides whether to stay in update mode or jump to application.
Example
#include <stdint.h>
typedef void (*AppEntry_t)(void);
void jump_to_application(uint32_t app_address)
{
uint32_t reset_handler_address = *(uint32_t *)(app_address + 4U);
AppEntry_t app_entry = (AppEntry_t)reset_handler_address;
app_entry();
}
ExplanationIn ARM Cortex-M, vector table starts with stack pointer then reset handler. Real jump code must also set MSP and vector table safely.
Practice problem: Draw memory map: bootloader in first Flash region, app in second Flash region.
Common mistake: Bootloaders must validate firmware before jumping. Never jump to corrupted firmware blindly.
99. Error handling
Phase 12
Why it mattersProduction firmware must detect, report, recover, or safely stop when errors occur.
Core ideaUse clear status codes, timeouts, retries, error counters, and safe states.
Example
#include <stdint.h>
typedef enum
{
STATUS_OK = 0,
STATUS_TIMEOUT = -1,
STATUS_INVALID_ARG = -2,
STATUS_HW_ERROR = -3
} Status_t;
Status_t sensor_read(uint16_t *value)
{
if (value == 0)
{
return STATUS_INVALID_ARG;
}
*value = 1234;
return STATUS_OK;
}
ExplanationThe function returns status and writes data through a pointer. This separates data from error reporting.
Practice problem: Create error codes for UART send: OK, BUSY, TIMEOUT, NULL_POINTER.
Common mistake: Do not ignore errors just because the demo works once. Production systems fail in unexpected ways.
100. Coding standards
Phase 12
Why it mattersCoding standards make firmware readable, maintainable, and safer. Teams need consistent style.
Core ideaUse meaningful names, small functions, consistent formatting, clear comments, and avoid undefined behavior.
Example
#include <stdint.h>
#define ADC_MAX_COUNTS 4095UL
#define VREF_MV 3300UL
uint32_t adc_counts_to_mv(uint16_t counts)
{
return ((uint32_t)counts * VREF_MV) / ADC_MAX_COUNTS;
}
ExplanationNames explain intent. Constants avoid magic numbers. The cast prevents 16-bit multiplication overflow on small systems.
Practice problem: Refactor a messy LED blink program into clean functions and constants.
Common mistake: Comments should explain why, not repeat what the code already says.
Phase 13: Interview-Level C Back to top
These are the concepts that separate beginner C from strong embedded C: memory layout, undefined behavior, optimization, alignment, and endianness.
101. Memory layout
Phase 13
Why it mattersUnderstanding memory layout helps debug crashes, stack overflow, linker errors, and RAM/Flash usage.
Core ideaTypical sections: text/code in Flash, rodata const data, data initialized globals, bss zero globals, heap, and stack.
Example
#include <stdint.h>
const uint32_t table[3] = {1, 2, 3}; // rodata/Flash
uint32_t initialized = 5; // data
uint32_t zero_value; // bss
void function(void)
{
uint32_t local = 10; // stack
(void)local;
}
ExplanationMap files show where these sections are placed. Embedded linker scripts define section placement.
Practice problem: Classify variables in a program as Flash, data, bss, stack, or heap.
Common mistake: Large initialized global arrays consume both Flash and RAM at startup.
102. Stack overflow
Phase 13
Why it mattersStack overflow occurs when stack usage exceeds available stack memory. It can cause random crashes and corrupted variables.
Core ideaDeep calls, recursion, large local arrays, and interrupts all consume stack.
Example
void bad_function(void)
{
int huge_array[10000]; // dangerous on small MCU stack
huge_array[0] = 1;
}
void better_function(void)
{
static int buffer[10000]; // not stack, but uses global RAM
buffer[0] = 1;
}
Explanationstatic buffer moves storage out of stack. But it still consumes RAM permanently.
Practice problem: Estimate stack usage of uint8_t buf[512] and uint32_t arr[100].
Common mistake: Moving everything to static is not a magic fix. You still need enough total RAM.
103. Endianness
Phase 13
Why it mattersEndianness defines byte order for multi-byte values. It matters for communication, file formats, and register byte views.
Core ideaLittle-endian stores least significant byte first. Many ARM Cortex-M MCUs are little-endian.
Example
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint32_t value = 0x12345678;
uint8_t *bytes = (uint8_t *)&value;
printf("byte0=0x%02X\n", bytes[0]);
return 0;
}
ExplanationIf byte0 prints 0x78, the system is little-endian.
Practice problem: Convert uint16_t 0xABCD to two bytes in big-endian order manually.
Common mistake: Do not send raw structs over communication and assume another system will interpret byte order the same.
104. Alignment
Phase 13
Why it mattersAlignment means data is stored at addresses suitable for its type. Some MCUs fault or slow down on unaligned access.
Core ideaA uint32_t is often expected at an address divisible by 4.
Example
#include <stdint.h>
int main(void)
{
uint8_t buffer[8];
// Dangerous on some MCUs if address is not 4-byte aligned:
// uint32_t *p = (uint32_t *)&buffer[1];
// *p = 0x12345678;
return 0;
}
Explanationbuffer[1] may not be aligned for uint32_t. Use memcpy for safe unaligned data extraction.
Practice problem: Explain why reading uint32_t from a byte buffer offset 1 can be risky.
Common mistake: Pointer casting byte buffers to larger types can cause alignment and strict-aliasing problems.
105. Padding
Phase 13
Why it mattersCompilers add padding inside structures to satisfy alignment. This affects sizeof and communication/storage layouts.
Core ideaReordering fields can reduce padding.
Example
#include <stdio.h>
#include <stdint.h>
typedef struct
{
uint8_t a;
uint32_t b;
uint8_t c;
} Example_t;
int main(void)
{
printf("sizeof Example_t = %zu\n", sizeof(Example_t));
return 0;
}
ExplanationThe size may be larger than 6 because padding bytes are inserted around b.
Practice problem: Reorder fields from largest to smallest and compare sizeof.
Common mistake: Do not transmit raw structs unless layout, padding, and endianness are controlled.
106. Undefined behavior
Phase 13
Why it mattersUndefined behavior means the C standard gives no guarantee. The compiler can produce unexpected results.
Core ideaExamples include signed overflow, out-of-bounds access, using uninitialized variables, and dereferencing invalid pointers.
Example
#include <stdio.h>
int main(void)
{
int arr[3] = {1, 2, 3};
// printf("%d\n", arr[5]); // undefined behavior
int x;
// printf("%d\n", x); // undefined behavior: uninitialized
return 0;
}
ExplanationUndefined behavior may appear to work during testing and fail after optimization or on another compiler.
Practice problem: Find undefined behavior in small code snippets: out-of-bounds, uninitialized, divide by zero.
Common mistake: Do not trust code just because it works once on your machine.
107. Compiler optimization
Phase 13
Why it mattersOptimization changes how code is generated. It can remove unused code, reorder operations, and keep values in registers.
Core ideaCorrect C code should work at different optimization levels. Hardware/shared variables need volatile where appropriate.
Example
#include <stdint.h>
volatile uint32_t flag;
void wait_flag(void)
{
while (flag == 0)
{
// compiler must re-read flag every time because volatile
}
}
ExplanationWithout volatile, the compiler might assume flag never changes inside the loop and optimize incorrectly for hardware/ISR cases.
Practice problem: Compile a small program with -O0 and -O2 and observe binary size difference.
Common mistake: Do not fix timing by relying on instruction count in optimized C. Use timers.
108. volatile vs const
Phase 13
Why it mattersvolatile and const solve different problems. const means your code should not modify it. volatile means it may change unexpectedly.
Core ideaA hardware status register can be volatile const: your code should not write it, but hardware can change it.
Example
#include <stdint.h>
volatile const uint32_t STATUS_REG = 0; // conceptual read-only changing register
volatile uint32_t CONTROL_REG = 0; // writable hardware register
int main(void)
{
uint32_t status = STATUS_REG;
CONTROL_REG |= (1UL << 0);
(void)status;
return 0;
}
Explanationvolatile const means the value is read-only for software but can change outside software control.
Practice problem: Classify variables: lookup table, button flag from ISR, hardware status register, calibration constant.
Common mistake: volatile does not protect data from race conditions; const does not mean stored only in Flash in every case.
109. Common interview questions
Phase 13
Why it mattersInterview questions test whether you understand memory, pointers, bits, and embedded behavior, not just syntax.
Core ideaPractice explaining concepts clearly and writing small correct code.
Example
// Example interview task: set, clear, toggle, read bit
#define BIT(n) (1UL << (n))
#define SET_BIT(reg, n) ((reg) |= BIT(n))
#define CLEAR_BIT(reg, n) ((reg) &= ~BIT(n))
#define TOGGLE_BIT(reg, n) ((reg) ^= BIT(n))
#define READ_BIT(reg, n) (((reg) & BIT(n)) != 0U)
ExplanationThis small macro set covers many embedded C basics: masks, shifts, side effects, and readability.
Practice problem: Prepare answers for: pointer vs array, static vs global, volatile usage, stack vs heap, ISR rules, endianness, structure padding.
Common mistake: Do not memorize only definitions. Interviewers often ask you to debug or explain behavior line by line.
Phase 14: STM32 Firmware Back to top
This phase connects everything to practical STM32 firmware projects. Start with LED and button, then UART, timers, ADC, DMA, and RTOS.
110. Bare-metal LED
Phase 14
Why it mattersBare-metal LED blink is the embedded equivalent of Hello World. It proves clock, GPIO, linker, startup, and flashing are working.
Core ideaThe usual flow is enable GPIO clock, set pin as output, then write/toggle output bit in a loop.
Example
/* STM32WB55 register example using CMSIS device definitions; no HAL. */
#include "stm32wb55xx.h"
void led_init(void)
{
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOAEN;
(void)RCC->AHB2ENR;
GPIOA->MODER = (GPIOA->MODER & ~GPIO_MODER_MODE5_Msk)
| (1UL << GPIO_MODER_MODE5_Pos);
}
void led_on(void) { GPIOA->BSRR = GPIO_BSRR_BS5; }
void led_off(void) { GPIOA->BSRR = GPIO_BSRR_BR5; }
ExplanationThis is real memory-mapped register access through ST's CMSIS device header, not HAL. The clock gate is enabled before GPIO is touched, the two-bit mode field is updated without disturbing other pins, and BSRR performs atomic set/reset writes.
Practice problem: Choose a pin from your board schematic, then adapt the port, pin number, output type, speed, and pull configuration.
Common mistake: Declaring an ordinary volatile uint32_t GPIO_OUT; only creates a RAM variable; it does not map a hardware register. Use the exact device header or a correctly derived address.
111. Button
Phase 14
Why it mattersA button teaches GPIO input, pull-up/pull-down, debouncing, and event detection.
Core ideaMechanical buttons bounce. A single press may look like many fast transitions unless debounced.
Example
#include <stdint.h>
#define BIT(n) (1UL << (n))
volatile uint32_t GPIO_IDR;
uint8_t button_is_pressed(void)
{
// Example: active-low button on pin 13
return (GPIO_IDR & BIT(13)) ? 0U : 1U;
}
ExplanationMany boards wire buttons active-low, meaning pressed reads 0. Pull-up keeps the pin high when not pressed.
Practice problem: Add simple debounce: require the same reading for 20 ms before accepting change.
Common mistake: Do not assume pressed means 1. Check board schematic.
112. UART
Phase 14
Why it mattersUART on STM32 is essential for debugging, command interface, data logging, and communication with modules.
Core ideaConfigure GPIO alternate function, enable USART clock, set baud rate, enable transmitter/receiver, then send/receive data.
Example
#include <stdint.h>
#include <string.h>
typedef struct
{
volatile uint32_t SR;
volatile uint32_t DR;
} USART_Reg_t;
#define USART_SR_TXE (1UL << 7)
static void uart_send_byte(USART_Reg_t *u, uint8_t byte)
{
while ((u->SR & USART_SR_TXE) == 0U) { /* wait for TX empty */ }
u->DR = byte;
}
void uart_send_string(USART_Reg_t *u, const char *s)
{
while (*s != '\0') /* stop at the null terminator, not a space */
{
uart_send_byte(u, (uint8_t)*s);
s++;
}
}
/* Simple command dispatcher for a line already received over UART. */
void process_command(const char *cmd)
{
if (strcmp(cmd, "ON") == 0)
{
/* led_on(); */
}
else if (strcmp(cmd, "OFF") == 0)
{
/* led_off(); */
}
else if (strncmp(cmd, "BLINK", 5) == 0)
{
/* led_blink(5); */
}
}
ExplanationThe classic beginner bug is exactly what this fixes: checking for a space instead of the null terminator means uart_send_string would stop at the first word and silently drop the rest of the message. process_command shows the next step after receiving — comparing a completed line to known keywords and dispatching to the matching action.
Practice problem: Write a command parser: ON turns LED on, OFF turns LED off, BLINK toggles five times.
Common mistake: UART receive must handle buffer length, null termination, and line endings.
113. Interrupts
Phase 14
Why it mattersChapter 74 covered the general ISR-plus-flag pattern with a timer. On real STM32 hardware, the most common first interrupt is a GPIO pin change — a button press — routed through the EXTI (external interrupt) controller.
Core ideaA GPIO pin's interrupt path is: EXTI line unmasked → edge trigger selected → NVIC interrupt enabled → your handler runs on that edge → you must clear the EXTI pending bit yourself, in software, before returning.
Example
#include <stdint.h>
typedef struct
{
volatile uint32_t IMR; /* interrupt mask register */
volatile uint32_t RTSR; /* rising-trigger selection */
volatile uint32_t FTSR; /* falling-trigger selection */
volatile uint32_t PR; /* pending register (write 1 to clear)*/
} EXTI_Reg_t;
#define BUTTON_LINE (1UL << 13) /* EXTI13, e.g. the user button on many boards */
volatile uint8_t button_event = 0;
void exti_button_init(EXTI_Reg_t *exti)
{
exti->IMR |= BUTTON_LINE; /* unmask the line so it can interrupt */
exti->FTSR |= BUTTON_LINE; /* trigger on falling edge (press) */
/* NVIC_EnableIRQ(EXTI15_10_IRQn); done separately, MCU-specific */
}
/* Testable helper. The real vector-table ISR must be void name(void). */
void exti_button_service(EXTI_Reg_t *exti)
{
if ((exti->PR & BUTTON_LINE) != 0U)
{
exti->PR = BUTTON_LINE; /* writing 1 clears this pending bit */
button_event = 1;
}
}
int main(void)
{
while (1)
{
if (button_event)
{
button_event = 0;
/* handle the press here, outside the ISR */
}
}
}
ExplanationUnlike some peripherals that clear their own flag on a register read, this EXTI revision's pending bit is cleared by writing a 1. A real vector-table function has the exact signature void EXTI15_10_IRQHandler(void) and calls a helper like this with the target peripheral. Newer STM32 families can use differently named rising/falling pending registers, so verify the target manual.
Practice problem: Extend this handler to also serve EXTI9 on the same IRQ line and identify which pin fired using two separate checks against PR.
Common mistake: Several EXTI lines share one NVIC IRQ (e.g. lines 10–15 on many STM32 families) — always check PR for the specific line, don't assume which pin triggered the shared handler.
114. Timers
Phase 14
Why it mattersSTM32 timers are powerful: delays, periodic tasks, PWM, encoder reading, input capture, and output compare.
Core ideaFor a periodic interrupt: set the prescaler, set auto-reload, enable the update interrupt, enable it in the NVIC, then start the counter.
Example
#include <stdint.h>
typedef struct
{
volatile uint32_t CR1;
volatile uint32_t DIER; /* DMA/interrupt enable register */
volatile uint32_t SR; /* status: bit0 = update flag */
volatile uint32_t PSC;
volatile uint32_t ARR;
} TIM_Reg_t;
#define TIM_CR1_CEN (1UL << 0) /* counter enable */
#define TIM_DIER_UIE (1UL << 0) /* update interrupt enable */
#define TIM_SR_UIF (1UL << 0) /* update interrupt flag */
volatile uint32_t ms_ticks = 0;
/* Assumes a 16 MHz timer input clock; produces a 1 ms update event. */
void timer_1ms_init(TIM_Reg_t *tim)
{
tim->PSC = 16000U - 1U; /* 16 MHz / 16000 = 1 kHz counting rate */
tim->ARR = 1U - 1U; /* overflow every 1 count = every 1 ms */
tim->DIER |= TIM_DIER_UIE;
tim->CR1 |= TIM_CR1_CEN;
/* NVIC_EnableIRQ(TIMx_IRQn); done separately, MCU-specific */
}
/* Testable helper; the real TIMx_IRQHandler is void name(void). */
void timer_update_service(TIM_Reg_t *tim)
{
if ((tim->SR & TIM_SR_UIF) != 0U)
{
tim->SR &= ~TIM_SR_UIF; /* clear the update flag */
ms_ticks++;
}
}
ExplanationPSC divides the timer's input clock down to a convenient counting rate; ARR sets how many of those ticks happen before the update event. Here PSC = 15999 turns a 16 MHz clock into 1000 ticks per second, and ARR = 0 requests an update every count. This partial teaching structure is not a complete STM32 timer memory layout and must not be cast onto a real peripheral; production code should use the exact device header's TIM_TypeDef. Also generate/clear any initial update event as required by the target timer chapter.
Practice problem: Recompute PSC and ARR for a 10 ms interrupt using the same 16 MHz input clock, this time using a larger ARR instead of a larger PSC.
Common mistake: APB timer clocks are sometimes doubled relative to the bus clock depending on the prescaler in the clock tree — always confirm the timer's actual input clock in the reference manual before computing PSC/ARR.
115. SPI LCD
Phase 14
Why it mattersAn SPI LCD like ST7735 is a great project for learning SPI, GPIO chip select, data/command pin, reset timing, and graphics buffers.
Core ideaLCD communication usually sends commands and data separately using a D/C pin.
Example
#include <stdint.h>
#define BIT(n) (1UL << (n))
#define DC_PIN 8U /* data/command select pin */
#define CS_PIN 9U /* chip select pin */
volatile uint32_t GPIO_BSRR; /* set/reset register, see Chapter t91/main.c */
extern uint8_t spi_transfer(void *spi, uint8_t data); /* from Chapter 92 */
static void gpio_write(uint32_t pin, uint8_t level)
{
GPIO_BSRR = level ? BIT(pin) : BIT(pin + 16U);
}
void lcd_write_command(void *spi, uint8_t cmd)
{
gpio_write(DC_PIN, 0U); /* DC low = this byte is a command */
gpio_write(CS_PIN, 0U); /* select the display */
(void)spi_transfer(spi, cmd);
gpio_write(CS_PIN, 1U); /* deselect */
}
void lcd_write_data(void *spi, uint8_t data)
{
gpio_write(DC_PIN, 1U); /* DC high = this byte is pixel data */
gpio_write(CS_PIN, 0U);
(void)spi_transfer(spi, data);
gpio_write(CS_PIN, 1U);
}
ExplanationThe D/C pin is what turns one SPI wire into two logical channels: low means "interpret this byte as a display command" (set orientation, turn on, define a drawing window), high means "this byte is pixel data". spi_transfer is the same function from the SPI driver chapter — an LCD driver is mostly bookkeeping (DC, CS, and the display's specific init sequence) wrapped around that one primitive.
Practice problem: Write functions lcd_set_window(x0, y0, x1, y1) and lcd_fill_color(color).
Common mistake: Wrong SPI mode, reset sequence, or D/C pin handling can make the screen stay blank.
116. ADC
Phase 14
Why it mattersSTM32 ADC lets you read analog sensors. It teaches sampling time, channels, calibration, conversion start, and result reading.
Core ideaConfigure GPIO as analog, select ADC channel, configure sample time, start conversion, wait for complete, read data register.
Example
#include <stdint.h>
uint32_t adc_to_mv_12bit(uint16_t raw)
{
return ((uint32_t)raw * 3300UL) / 4095UL;
}
ExplanationRaw ADC counts become meaningful only after conversion to voltage or calibrated physical units.
Practice problem: Read potentiometer value and map it to PWM duty cycle 0 to 100%.
Common mistake: ADC input voltage must not exceed MCU limits. Use proper voltage divider/protection.
117. DMA
Phase 14
Why it mattersDMA moves data between peripheral and memory without CPU copying every byte. It is important for high-speed UART, SPI, ADC, and audio/data streams.
Core ideaConfigure source, destination, length, direction, and start DMA. CPU gets interrupt on half/full completion.
Example
#include <stdint.h>
typedef struct
{
volatile uint32_t CCR; /* channel config: direction, circular, IRQs */
volatile uint32_t CNDTR; /* number of data items to transfer */
volatile uint32_t CPAR; /* peripheral address (e.g. ADC data reg) */
volatile uint32_t CMAR; /* memory address (your buffer) */
} DMA_Channel_t;
#define DMA_CCR_EN (1UL << 0)
#define DMA_CCR_CIRC (1UL << 5) /* circular mode: wraps automatically */
#define DMA_CCR_MINC (1UL << 7) /* increment memory address each item */
#define DMA_CCR_MSIZE_16B (1UL << 10)
#define ADC_BUF_LEN 128
static volatile uint16_t adc_buffer[ADC_BUF_LEN];
void adc_dma_start(DMA_Channel_t *dma, uint32_t adc_data_reg_addr)
{
dma->CPAR = adc_data_reg_addr;
dma->CMAR = (uint32_t)adc_buffer;
dma->CNDTR = ADC_BUF_LEN;
dma->CCR = DMA_CCR_MSIZE_16B | DMA_CCR_MINC | DMA_CCR_CIRC;
dma->CCR |= DMA_CCR_EN;
}
ExplanationOnce enabled, the DMA controller copies each ADC conversion result straight into adc_buffer without the CPU touching it. MINC makes the destination address advance after every sample; CIRC makes CNDTR wrap back to ADC_BUF_LEN once it hits zero, so sampling continues forever until you disable the channel. The CPU only gets involved via the half-transfer and transfer-complete interrupts, to process one half of the buffer while the other half keeps filling.
Practice problem: Design ADC DMA circular buffer with half-complete and complete callbacks.
Common mistake: DMA buffers shared with CPU need careful cache coherency on MCUs with data cache.
118. Low-power modes
Phase 14
Why it mattersLow-power modes reduce battery consumption. Important for IoT, wearables, sensors, and wireless devices.
Core ideaMCUs can sleep while waiting for interrupts. Deeper sleep saves more power but wakes slower and disables more clocks.
Example
void enter_sleep_until_interrupt(void)
{
// configure wake-up source first
// execute CPU instruction such as WFI on ARM Cortex-M
// __WFI();
}
ExplanationWFI means Wait For Interrupt. The CPU sleeps until an enabled interrupt wakes it.
Practice problem: Design a battery sensor that wakes every 1 second, reads ADC, sends UART packet, then sleeps.
Common mistake: Before sleeping, ensure wake-up source is configured. Otherwise the MCU may not wake as expected.
119. RTOS basics
Phase 14
Why it mattersAn RTOS helps manage multiple tasks, timing, queues, semaphores, and events in larger firmware.
Core ideaInstead of one super-loop, you create tasks like sensor_task, comm_task, display_task.
Example
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
static QueueHandle_t sensor_queue;
void sensor_task(void *arg)
{
uint16_t reading;
for (;;)
{
reading = read_adc_channel(0); /* your driver function */
xQueueSend(sensor_queue, &reading, 0); /* non-blocking send */
vTaskDelay(pdMS_TO_TICKS(100)); /* sleep, don't spin */
}
}
void comm_task(void *arg)
{
uint16_t reading;
for (;;)
{
if (xQueueReceive(sensor_queue, &reading, portMAX_DELAY) == pdPASS)
{
uart_send_string(USART2, "reading ready"); /* transmit it */
}
}
}
int main(void)
{
sensor_queue = xQueueCreate(8, sizeof(uint16_t));
xTaskCreate(sensor_task, "sensor", 128, NULL, 2, NULL);
xTaskCreate(comm_task, "comm", 128, NULL, 1, NULL);
vTaskStartScheduler(); /* never returns */
for (;;) { }
}
ExplanationxQueueCreate allocates a thread-safe mailbox; vTaskDelay puts a task to sleep so the CPU can run other tasks instead of busy-waiting; xQueueReceive with portMAX_DELAY blocks comm_task until sensor_task actually has data, so neither task polls the other. This is the RTOS-safe replacement for a shared global variable between the two tasks.
Practice problem: Design three tasks for LCD UI, UART command, and sensor sampling.
Common mistake: RTOS does not automatically fix bad design. Race conditions and priority issues still exist.
120. Build complete firmware projects
Phase 14
Why it mattersProjects combine all concepts: C basics, pointers, drivers, interrupts, timing, debugging, and architecture.
Core ideaStart small and finish fully. A completed simple project is more valuable than ten half-built complex projects.
Example
// Project architecture idea
// main.c -> app loop and initialization
// led.c/h -> LED driver
// button.c/h -> button driver
// uart.c/h -> UART driver
// command.c/h -> command parser
// app.c/h -> application state machine
ExplanationSeparating modules makes your firmware professional and easier to debug.
Practice problem: Project 1: UART command LED. Project 2: button interrupt counter. Project 3: SPI LCD menu. Project 4: ADC + PWM controller.
Common mistake: Do not jump directly into huge firmware. Build reusable drivers one by one.
Phase 15: Any STM32, Register-First Back to top
Important scope: there is no single register program that works unchanged on every STM32. STM32 families differ in clock trees, GPIO details, interrupt names, peripheral revisions, DMA controllers, and flag-clearing rules. The transferable skill is a repeatable method: identify the exact part, read its documents, use its CMSIS device header, and derive each write from the reference manual. CMSIS register definitions are not HAL; they are thin, vendor-supplied names for memory-mapped addresses and bit fields.
121. Choose the right STM32
Phase 15
Start from requirementsList supply voltage, GPIO count, package/size, Flash, RAM, CPU/DSP/FPU needs, interfaces, ADC/DAC performance, timers, wireless/USB/Ethernet, security, temperature, power budget, availability, and cost. Add at least 20–30% memory headroom.
Then selectUse ST's product selector or STM32CubeMX only as a filter and pin-conflict checker. Confirm every deciding feature in the exact orderable part's datasheet; a family name or development-board page is not enough.
- STM32C0/G0: economical general control; good when cost and simplicity dominate.
- STM32L0/L4/L5/U0/U5: low-power applications; compare run and sleep current in your real operating conditions.
- STM32F0/F1/F3/F4/F7 or G4/H5/H7: increasing performance and specialized control/DSP options; verify cache, memory, analog, and timer needs rather than choosing by MHz alone.
- STM32WB/WBA/WL: integrated radio. Radio firmware, dual-core ownership, RF layout, certification, and middleware add constraints absent from ordinary MCUs.
Board-level check: read the board schematic. An MCU pin may be consumed by ST-LINK, a crystal, solder bridge, external memory, USB, or an onboard LED with active-low wiring.
Reality check: this guide gives a strong foundation, not a guarantee of trouble-free work on every STM32. High-speed clocks, RF, USB, Ethernet, external memory, TrustZone, cache coherency, and safety-critical products require their own manuals and validation.
122. Read the document set in the right order
Phase 15
Datasheet = this partPinout, alternate-function tables, electrical limits, package, memory size, peripheral inventory, current consumption, and ordering code.
Reference manual = this familyMemory map, RCC, peripheral registers, reset values, bit meanings, operating sequences, and flag-clearing rules.
- Write down the full marking/order code, core, Flash/RAM, package, and board revision.
- Download the current datasheet, reference manual, errata sheet, and board schematic/user manual. Also keep the Cortex-M programming manual and the matching CMSIS device pack.
- In the datasheet, verify the pin exists in your package, is 5 V tolerant if needed, supports the required alternate function, and meets voltage/current/timing limits.
- In the reference manual, read the peripheral overview, clock/reset section, register map, then the exact programming sequence. Never infer a write-one-to-clear flag from its name.
- Read errata for the MCU revision before blaming your code. Apply stated workarounds.
- Cross-check names and masks in
stm32xxxx.h. The header prevents address transcription errors; the manual explains what the writes mean.
Fast search terms: peripheral clock enable, kernel clock, reset value, alternate function, programming procedure, write 1 to clear, read sequence, interrupt mapping, DMA request, and electrical characteristics.
123. Port register code safely
Phase 15
Use the exact device header and its typed peripheral pointers. That is direct register access without HAL:
#include "stm32wb55xx.h" /* CMSIS header for this exact device */
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOAEN; /* volatile register access */
GPIOA->BSRR = GPIO_BSRR_BS5; /* atomic set: no read-modify-write */
RCC and GPIOA are pointers to CMSIS register structures at addresses defined by ST. No HAL_Init, HAL_GPIO_Init, or HAL source file is involved.
- Do not copy addresses across families. Even similarly named parts may put clock enables or peripherals elsewhere.
- Enable the bus/kernel clock first. If required, reset the peripheral and wait for ready/status bits.
- Use masked writes for multi-bit fields: clear the field, then OR the new encoded value. Preserve reserved bits.
- Prefer atomic set/clear registers such as GPIO
BSRR. A read-modify-write on ODR can lose an ISR's simultaneous update.
- Respect access types: read-only, write-only, write-one-to-clear, read-to-clear, and key-protected registers need different code.
- Use barriers only where architecture/manual requires them: CMSIS supplies
__DSB(), __ISB(), and __DMB(). volatile is not a CPU memory barrier and does not make shared code atomic.
/* Generic two-bit-field update. Position/mask come from the device header. */
uint32_t v = PERIPH->CONFIG;
v = (v & ~FIELD_Msk) | ((new_value << FIELD_Pos) & FIELD_Msk);
PERIPH->CONFIG = v;
124. Worked example: choose STM32WB55 and make the first blink
Phase 15
Assume you selected the NUCLEO-WB55RG board. Before writing code, identify the exact MCU (STM32WB55RG), the board number/revision, and the LED connection. The board user manual identifies the green user LED LD2 on PB0. The MCU datasheet confirms PB0 exists in the VFQFPN68 package, while reference manual RM0434 describes RCC and GPIO registers. This three-document cross-check is the habit to repeat on every board.
Files the project needsmain.c, the exact CMSIS device/core headers, system_stm32wbxx.c, the Cortex-M4 startup assembly, and a linker script matching the STM32WB55RG Flash/RAM layout.
What it does not needNo HAL_Init, HAL driver source, or USE_HAL_DRIVER define is needed. CMSIS names such as GPIOB and RCC_AHB2ENR_GPIOBEN map directly to registers.
After reset the clock configuration is deliberately left simple. The busy delay is approximate; the first objective is to prove startup, linking, flashing, and GPIO. A timer replaces it later.
#include "stm32wb55xx.h"
static void led_init(void)
{
/* 1. GPIOB sits on AHB2. Its clock gate must be opened first. */
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOBEN;
(void)RCC->AHB2ENR; /* ensure clock write completes */
/* 2. PB0 MODER[1:0] = 01: general-purpose output. */
GPIOB->MODER = (GPIOB->MODER & ~GPIO_MODER_MODE0_Msk)
| (1UL << GPIO_MODER_MODE0_Pos);
GPIOB->OTYPER &= ~GPIO_OTYPER_OT0; /* push-pull */
GPIOB->PUPDR &= ~GPIO_PUPDR_PUPD0_Msk; /* no pull */
GPIOB->OSPEEDR &= ~GPIO_OSPEEDR_OSPEED0_Msk; /* low speed */
}
static void delay_cycles(volatile uint32_t n)
{
while (n-- != 0U) { __NOP(); }
}
int main(void)
{
led_init();
for (;;) {
GPIOB->BSRR = GPIO_BSRR_BS0; /* LD2 on */
delay_cycles(500000U);
GPIOB->BSRR = GPIO_BSRR_BR0; /* LD2 off */
delay_cycles(500000U);
}
}
- Build the ELF and inspect its reported Flash/RAM sizes.
- Connect the board through the ST-LINK USB connector and flash the ELF.
- If LD2 does not blink, halt at
main; inspect RCC->AHB2ENR, GPIOB->MODER, and GPIOB->ODR. Confirm the board revision and schematic.
- Replace the delay loop with a SysTick or timer delay only after basic GPIO works.
Do not generalize PB0: PB0 is this board's LD2 connection, not “the STM32 LED pin.” A custom STM32WB55 board may use another pin or no LED. The radio and Cortex-M0+ are separate later topics; blinking from the Cortex-M4 does not require starting the wireless stack.
125. Create, build, and understand the project in VS Code
Phase 15
The least fragile beginner setup is ST's current VS Code integration plus STM32CubeCLT. CubeCLT supplies the Arm compiler, GDB, programmer, CMake/Ninja integration, and device descriptions. The editor is VS Code; the output is still ordinary ELF firmware.
- Install Visual Studio Code, ST's STM32CubeIDE for Visual Studio Code extension, and STM32CubeCLT. Restart VS Code and confirm the extension detects the tools.
- Use the extension's empty CMake project workflow or import a CMake project generated for the exact MCU/board. Select
STM32WB55RG or NUCLEO-WB55RG, Cortex-M4, and the GNU toolchain.
- Confirm that startup assembly, linker script, CMSIS core/device headers, and
system_stm32wbxx.c match the exact part. Keep these infrastructure files even though application code does not use HAL.
- Remove HAL source files and HAL include paths if the project template added them. Do not define
USE_HAL_DRIVER. Keep the device define STM32WB55xx.
- Put the blink program in
Core/Src/main.c (or the source directory created by the wizard), configure the project, then run Build. Fix every compiler/linker error before connecting hardware.
project/
|-- CMakeLists.txt
|-- cmake/ # Arm cross-compiler settings
|-- Core/
| |-- Inc/
| `-- Src/main.c
|-- Drivers/CMSIS/
| |-- Include/ # Cortex-M core headers
| `-- Device/ST/STM32WBxx/ # STM32WB55 register definitions
|-- startup_stm32wb55xx.s # vector table + Reset_Handler
`-- STM32WB55RG_FLASH.ld # Flash/RAM placement
Startup's jobThe vector table supplies the initial stack pointer and reset address. Reset copies initialized data to RAM, zeroes BSS, calls system initialization, and finally enters main.
Linker's jobThe linker script places vectors and code in Flash and variables/stack/heap in the real RAM banks. Always inspect the map file and size report.
For STM32WB55 Cortex-M4 with hardware floating point, typical GNU architecture flags are:
-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard
-DSTM32WB55xx -ffunction-sections -fdata-sections -Wall -Wextra
# Debug build
-Og -g3
# Link essentials (the wizard normally supplies these)
-TSTM32WB55RG_FLASH.ld -Wl,--gc-sections -Wl,-Map=firmware.map
Official tooling: STM32CubeCLT contains the compiler/debug/programming command-line tools. ST's
VS Code documentation covers CMake projects, toolchains, build, and debug.
A project that compiles is not automatically correct: verify the device define, startup filename, linker memory sizes, floating-point ABI, and selected debug target. A mismatched template can build successfully and still fail before main.
126. Universal recipe for a new peripheral
Phase 15
- State the behavior and timing numerically. Draw the signal path: source clock → bus/kernel clock → peripheral → pin/DMA/IRQ.
- Resolve pins from the exact-package alternate-function table and board schematic; detect conflicts before coding.
- Enable/reset clocks and configure GPIO electrical mode and alternate function.
- Configure the peripheral while disabled if the manual requires it. Derive divider values with units and check rounding/error.
- Clear stale flags using the documented method, enable the peripheral, then poll one simple transfer first.
- Add timeouts to polling. Then add interrupts: clear source, enable peripheral interrupt, set NVIC priority, clear pending, enable NVIC, and use the exact vector-table handler name.
- Add DMA only after polling works. Check request mapping, direction, widths, increment modes, count, circular/double-buffer behavior, cache coherency, and completion/error flags.
- Test normal, boundary, timeout, disconnect, overrun, and recovery cases. Record assumptions beside the code.
/* Safe polling shape: never wait forever in production code. */
bool wait_flag(volatile uint32_t *reg, uint32_t mask, uint32_t budget)
{
while (((*reg) & mask) == 0U) {
if (budget-- == 0U) return false;
}
return true;
}
Family traps: USART may use SR/DR or ISR/RDR/TDR/ICR; DMA may be channel-, stream-, or linked-list-based; RCC registers and timer clock doubling vary; EXTI routing and pending-bit clearing vary; ADC calibration/regulator sequences vary. Re-derive these from the target reference manual every time.
127. Next project: STM32WB55 UART from registers
Phase 15
Use UART transmit as the second milestone because it proves the clock tree, alternate-function pin mux, baud calculation, peripheral status flags, and connection to a PC. This example uses LPUART1 TX on PA2, AF8, confirmed by the STM32WB55 datasheet. Connect PA2 to the RX input of a 3.3 V USB-to-UART adapter and connect grounds. Do not connect an adapter's 5 V logic output to the MCU.
#include "stm32wb55xx.h"
#include <stdint.h>
static void lpuart1_init(uint32_t pclk1_hz, uint32_t baud)
{
/* PA2 = LPUART1_TX, alternate function 8. */
RCC->AHB2ENR |= RCC_AHB2ENR_GPIOAEN;
RCC->APB1ENR2 |= RCC_APB1ENR2_LPUART1EN;
(void)RCC->APB1ENR2;
GPIOA->MODER = (GPIOA->MODER & ~GPIO_MODER_MODE2_Msk)
| (2UL << GPIO_MODER_MODE2_Pos); /* 10: alternate function */
GPIOA->AFR[0] = (GPIOA->AFR[0] & ~GPIO_AFRL_AFSEL2_Msk)
| (8UL << GPIO_AFRL_AFSEL2_Pos);
GPIOA->OTYPER &= ~GPIO_OTYPER_OT2;
GPIOA->PUPDR &= ~GPIO_PUPDR_PUPD2_Msk;
/* LPUART1SEL = 00 selects PCLK1 on STM32WB55. */
RCC->CCIPR &= ~RCC_CCIPR_LPUART1SEL_Msk;
LPUART1->CR1 = 0U; /* disable before configuration */
LPUART1->CR2 = 0U; /* 1 stop bit */
LPUART1->CR3 = 0U; /* no flow control */
/* LPUART BRR = round(256 * peripheral_clock / baud). */
LPUART1->BRR = (uint32_t)
((((uint64_t)256U * pclk1_hz) + (baud / 2U)) / baud);
LPUART1->CR1 = USART_CR1_TE | USART_CR1_UE;
while ((LPUART1->ISR & USART_ISR_TEACK) == 0U) { }
}
static void uart_write_char(char c)
{
while ((LPUART1->ISR & USART_ISR_TXE_TXFNF) == 0U) { }
LPUART1->TDR = (uint8_t)c;
}
static void uart_write(const char *s)
{
while (*s != '\0') uart_write_char(*s++);
}
int main(void)
{
/* Supply the REAL PCLK1 frequency produced by your clock configuration. */
lpuart1_init(4000000U, 115200U); /* valid only if PCLK1 is actually 4 MHz */
uart_write("STM32WB55 register UART is alive!\r\n");
for (;;) { }
}
- Determine the real PCLK1 frequency from RCC configuration; never copy
4000000 after changing clocks.
- Open a serial terminal at 115200 baud, 8 data bits, no parity, one stop bit, no flow control.
- If output is garbage, calculate the observed bit period with a logic analyzer. A baud error usually means the assumed peripheral clock is wrong.
- Add PA3 AF8 and
RE for receive; poll RXNE_RXFNE, read RDR, and handle overrun/error flags exactly as RM0434 specifies.
- Only after polling TX/RX works should you add interrupts, ring buffers, DMA, or
printf retargeting.
Board VCP warning: the onboard ST-LINK virtual COM connection and its solder-bridge routing depend on the board configuration. A direct 3.3 V USB-UART adapter removes that ambiguity. If using the onboard VCP, follow the exact board manual/revision rather than assuming PA2/PA3 are connected.
128. Upload and debug from VS Code
Phase 15
- Connect the board's ST-LINK USB connector. In Windows Device Manager, confirm ST-LINK appears; update its firmware with ST tools if connection fails.
- Build in VS Code. The important output is the
.elf, because it contains program data plus symbols for source-level debugging.
- Choose the generated ST-LINK debug configuration and press F5. Stop first at
main, single-step led_init, and inspect RCC/GPIO through peripheral registers or the SVD view.
- For programming without a debug session, use the extension's flash command or STM32CubeProgrammer CLI:
STM32_Programmer_CLI -c port=SWD -w build/firmware.elf -v -rst
The command connects over SWD, writes the ELF to its linked addresses, verifies it, then resets the target. The actual executable name and output path depend on installation and project settings.
- Cannot connect: check USB cable, target power, ST-LINK driver/firmware, SWD jumper position, and try connect-under-reset.
- Stops in Default_Handler: compare the handler spelling with the startup vector table and clear the peripheral interrupt source.
- HardFault: inspect
SCB->CFSR, HFSR, BFAR, MMFAR, stacked PC/LR, map file, and disassembly.
- Runs only in debugger: suspect timing, uninitialized state, reset/boot settings, watchdog, clock readiness, or code accidentally depending on debugger initialization.
Official programmer: STM32CubeProgrammer supports GUI and CLI programming, erase, verification, option bytes, and memory inspection.
129. Progress from blink to production-style firmware
Phase 15
- GPIO output: blink PB0; then use BSRR helpers and confirm electrical limits.
- Clock tree: select a documented oscillator, configure voltage/Flash latency where required, switch SYSCLK, update the software frequency value, and measure MCO.
- Timer: replace the busy loop with a hardware timer or SysTick. Derive prescaler/period from the measured timer clock.
- UART polling: print text, receive one byte, add timeouts, then implement a line buffer and commands such as
LED ON.
- Interrupts: button via EXTI and UART RX. ISRs clear the source and move minimal data; main code handles behavior.
- SPI and I2C: first verify waveforms with a logic analyzer, then communicate with one simple sensor or display. Handle NACK, timeout, overrun, and bus recovery.
- ADC and PWM: sample a potentiometer, convert counts using measured reference voltage, and control PWM duty cycle.
- DMA: move UART/ADC/SPI data after the polling driver works. Test half/full/error events and buffer ownership.
- Low power and watchdog: measure current, identify wake sources, restore clocks after wake, and feed the watchdog only after health checks pass.
- Wireless on STM32WB: treat it as a separate subsystem. Learn CPU1/CPU2 ownership, IPCC/HSEM, wireless stack binaries, clock requirements, and ST's supported middleware before attempting direct radio work.
Driver structureGive each peripheral init, bounded read/write operations, explicit status results, and an IRQ/DMA service layer. Keep application policy outside the driver.
Definition of doneIt builds warning-free, survives disconnects/timeouts, works after power-on without a debugger, has measured timing, and records the exact MCU/board/document revisions tested.
The portable skill: for every new peripheral, follow clock → pin mux → configuration → flags → simple polling transfer → errors/timeouts → interrupt → DMA → power testing. The register names change; this reasoning sequence does not.