C Programming Refresher¶
This refresher covers the C concepts you'll encounter throughout the workshop. ESP-IDF firmware is written in C, so comfort with these fundamentals is essential.
No prior C experience? This page is written for you. We start from the very basics — what a program is, how code runs, and build up step by step. If you've only written "Hello World" in school, you're in the right place.
If you're already comfortable with C, skim this page and move on to the Docker Setup.
What Is a C Program?¶
A C program is a list of instructions that tells the computer what to do, step by step. The computer reads your code from top to bottom and executes each line one at a time.
Think of it like a recipe:
- Get the ingredients → declare variables (your data)
- Follow the steps → write statements (your logic)
- Serve the dish → produce output (your result)
Your First C Program¶
#include <stdio.h> // Include the standard I/O library (for printf)
int main(void) { // Every C program starts here — the "main" function
printf("Hello, World!\n"); // Print a message to the screen
return 0; // Tell the OS: "everything went OK"
}
Let's break this down line by line:
| Line | What It Does | Why It Matters |
|---|---|---|
#include <stdio.h> |
Loads the "standard input/output" library | Without this, printf doesn't exist |
int main(void) |
The entry point — where execution begins | Every C program must have a main function |
printf("Hello, World!\n") |
Prints text to the screen | \n means "new line" — like pressing Enter |
return 0 |
Exits the program with success code | 0 = success, anything else = error |
Semicolons are mandatory
Every statement in C must end with a semicolon ;. Forgetting it is the #1 beginner mistake. The compiler will give you a confusing error if you miss one.
Comments — Writing Notes in Your Code¶
// This is a single-line comment
// The compiler ignores comments — they're for humans
/* This is a multi-line comment
Useful for longer explanations
The compiler ignores everything between /* and */
// USE COMMENTS TO:
// 1. Explain WHY you're doing something (not WHAT — the code shows what)
// 2. Mark sections of your code
// 3. Temporarily disable code while debugging
What Is a Variable?¶
A variable is a named box in memory that holds a value. You must tell C what type of value the box holds before you can use it.
int age = 25; // "age" is a box that holds a whole number (integer)
float temperature = 36.6; // "temperature" holds a decimal number
char grade = 'A'; // "grade" holds a single character
The Analogy¶
Think of variables like labeled containers:
┌─────────────┐ ┌─────────────────┐ ┌───────────┐
│ age: 25 │ │ temperature:36.6│ │ grade: 'A'│
│ (int) │ │ (float) │ │ (char) │
└─────────────┘ └─────────────────┘ └───────────┘
- Type (
int,float,char) = what kind of thing goes in the box - Name (
age,temperature,grade) = the label on the box - Value (
25,36.6,'A') = what's inside the box right now
Rules for Variable Names¶
// ✅ Good names — descriptive, clear
int sensor_count = 0;
float battery_voltage = 3.7;
uint8_t led_brightness = 128;
// ❌ Bad names — unclear, too short
int x = 0; // what is x?
float f = 3.7; // what does f mean?
uint8_t n = 128; // n could be anything
// ❌ Invalid names — C has rules
int 2nd_reading = 0; // can't start with a number
int my-var = 0; // can't use hyphens (use underscores)
int float = 0; // can't use C keywords (float is a type name)
You Can Change a Variable's Value¶
int count = 0; // count is 0
count = 5; // now count is 5 — we overwrote the old value
count = count + 1; // now count is 6 — we added 1 to the old value
// Shorthand versions (very common):
count += 1; // same as count = count + 1
count++; // same as count = count + 1 (add 1)
count--; // same as count = count - 1 (subtract 1)
count *= 2; // same as count = count * 2
What Is a Function?¶
A function is a reusable block of code that does one specific job. Instead of writing the same code over and over, you write it once, give it a name, and call it whenever you need it.
The Analogy¶
Think of a function like a vending machine:
- You put something in → inputs (called parameters)
- The machine does its work → the function body
- You get something out → output (called the return value)
Defining and Calling a Function¶
#include <stdio.h>
// Step 1: DEFINE the function (write the recipe)
// "int" = return type (what comes out)
// "a" and "b" = parameters (what goes in)
int add(int a, int b) {
int result = a + b; // do the calculation
return result; // send the answer back
}
// Step 2: CALL the function (use the recipe)
int main(void) {
int sum = add(3, 4); // call add with 3 and 4 → sum becomes 7
printf("3 + 4 = %d\n", sum); // prints: 3 + 4 = 7
// You can call it multiple times with different inputs
int another = add(10, 20);
printf("10 + 20 = %d\n", another); // prints: 10 + 20 = 30
return 0;
}
Functions That Don't Return a Value¶
Sometimes a function just does something without giving back a result. Use void as the return type:
void greet(const char *name) { // void = "nothing comes out"
printf("Hello, %s!\n", name); // just print, no return
}
// Call it:
greet("Rajath"); // prints: Hello, Rajath!
greet("ESP32"); // prints: Hello, ESP32!
Why Functions Matter in ESP-IDF¶
Every peripheral in ESP-IDF is controlled through functions:
gpio_set_level(2, 1); // Turn on LED on GPIO 2
gpio_set_level(2, 0); // Turn off LED on GPIO 2
vTaskDelay(pdMS_TO_TICKS(500)); // Wait 500ms
You don't need to know how these work internally — you just need to know what to put in and what comes out. That's the power of functions.
How to Read printf¶
printf is how you print text and values to the screen. The f stands for "formatted" — you can mix text with variable values using format specifiers.
int age = 25;
float temp = 36.6f;
char grade = 'A';
char name[] = "ESP32";
// Format specifiers start with % and are replaced by the variable value
printf("I am %d years old\n", age); // %d → integer → "I am 25 years old"
printf("Temperature: %.1f°C\n", temp); // %.1f → float with 1 decimal → "Temperature: 36.6°C"
printf("Grade: %c\n", grade); // %c → single character → "Grade: A"
printf("Device: %s\n", name); // %s → string → "Device: ESP32"
Common Format Specifiers¶
| Specifier | Type | Example Output |
|---|---|---|
%d |
int (signed) |
42 |
%u |
unsigned int |
255 |
%f |
float |
3.140000 |
%.2f |
float (2 decimals) |
3.14 |
%c |
char (single character) |
A |
%s |
char[] (string) |
Hello |
%x |
int (hexadecimal) |
ff |
%#x |
int (hex with 0x prefix) |
0xff |
%% |
literal % sign |
% |
Always match the specifier to the type
Using %d for a float or %f for an int will print garbage. The compiler won't always warn you — this is a common source of confusing bugs.
Basic Types and Variables¶
C is statically typed — every variable must have a type declared at compile time. In embedded C, you use fixed-width types from <stdint.h> to guarantee exact bit widths, because the size of standard types like int varies across platforms.
Normal C vs Embedded C — Type Comparison¶
| Normal C Type | Size (typical) | Embedded C Type | Size (guaranteed) | Signed? | Range | When to Use |
|---|---|---|---|---|---|---|
char |
1 byte (8-bit) | int8_t |
8-bit | Yes | -128 to 127 | Small signed values, ASCII |
unsigned char |
1 byte | uint8_t |
8-bit | No | 0 to 255 | Byte data, register values, flags |
short |
2 bytes (16-bit) | int16_t |
16-bit | Yes | -32768 to 32767 | Small signed counters |
unsigned short |
2 bytes | uint16_t |
16-bit | No | 0 to 65535 | ADC readings, PWM duty |
int |
2 or 4 bytes ⚠️ | int32_t |
32-bit | Yes | -2.1B to 2.1B | General-purpose signed |
unsigned int |
2 or 4 bytes ⚠️ | uint32_t |
32-bit | No | 0 to 4.3B | Timestamps, counters, addresses |
long |
4 or 8 bytes ⚠️ | — | — | — | — | Avoid in embedded — size varies |
long long |
8 bytes | int64_t |
64-bit | Yes | ±9.2 × 10¹⁸ | Rarely needed on MCU |
unsigned long long |
8 bytes | uint64_t |
64-bit | No | 0 to 1.8 × 10¹⁹ | Rarely needed on MCU |
float |
4 bytes | float |
32-bit | — | ±3.4 × 10³⁸ | Sensor values (use sparingly) |
double |
8 bytes | double |
64-bit | — | ±1.7 × 10³⁰⁸ | Avoid on ESP32 — no FPU for double |
| — | — | size_t |
32-bit on ESP32 | No | 0 to 4.3B | Sizes, array indices |
| — | — | bool |
1 byte | — | true/false | Flags, states (from <stdbool.h>) |
int size varies across platforms
On a desktop PC, int is typically 32 bits. On an 8-bit AVR (Arduino Uno), int is 16 bits. On ESP32-S3 (32-bit Xtensa), int is 32 bits. Never assume int is a specific size — always use int32_t / uint32_t when the width matters.
Avoid double on ESP32-S3
The ESP32-S3 has a single-precision FPU that accelerates float operations in hardware. double operations are emulated in software and are 10–20× slower. Always use float (append f to literals: 25.5f not 25.5).
Code Comparison — Normal C vs Embedded C¶
// ── Normal C (desktop application) ──
int count = 0;
unsigned int timestamp = 0;
char initial = 'A';
unsigned char byte_val = 255;
float temperature = 25.5;
// ── Embedded C (ESP-IDF firmware) ──
int32_t count = 0; // guaranteed 32-bit signed
uint32_t timestamp = 0; // guaranteed 32-bit unsigned
char initial = 'A'; // char is fine for ASCII
uint8_t byte_val = 255; // guaranteed 8-bit unsigned
float temperature = 25.5f; // float with 'f' suffix — no double promotion
Why Fixed-Width Types Matter in Embedded¶
// ❌ Dangerous — int size varies
int sensor_value = 0;
if (sensor_value > 32767) { // works on 32-bit, overflows on 16-bit int!
handle_overflow();
}
// ✅ Safe — exact width guaranteed
int32_t sensor_value = 0;
if (sensor_value > 32767) { // always works correctly
handle_overflow();
}
// ❌ Dangerous — unsigned int may be 16-bit on some platforms
unsigned int address = 0x40000000; // overflow if int is 16-bit!
// ✅ Safe — uint32_t is always 32-bit
uint32_t address = 0x40000000; // always correct
ESP-IDF Specific Types¶
ESP-IDF defines additional convenience types:
#include "esp_err.h"
esp_err_t result = ESP_OK; // int32_t — error codes (ESP_OK, ESP_FAIL, etc.)
#include "driver/gpio.h"
gpio_num_t led = GPIO_NUM_2; // enum — GPIO pin number
#include "freertos/FreeRTOS.h"
TickType_t delay = pdMS_TO_TICKS(100); // uint32_t — FreeRTOS tick count
TaskHandle_t task_handle = NULL; // void * — opaque task handle
BaseType_t priority = 1; // long — task priority (use int32_t in your code)
Printf Format Specifiers for Fixed-Width Types¶
A common pitfall — you can't use %d for all types:
| Type | Format Specifier | Example |
|---|---|---|
int8_t |
%d (promoted to int) |
printf("%d", val) |
uint8_t |
%u (promoted to unsigned int) |
printf("%u", val) |
int16_t |
%d |
printf("%d", val) |
uint16_t |
%u |
printf("%u", val) |
int32_t |
%ld or PRId32 |
printf("%" PRId32, val) |
uint32_t |
%lu or PRIu32 |
printf("%" PRIu32, val) |
int64_t |
%lld or PRId64 |
printf("%" PRId64, val) |
uint64_t |
%llu or PRIu64 |
printf("%" PRIu64, val) |
size_t |
%zu |
printf("%zu", val) |
#include <inttypes.h> // provides PRId32, PRIu32, etc.
uint32_t timestamp = 1713427200;
printf("Timestamp: %" PRIu32 "\n", timestamp); // portable across all platforms
Operators¶
// Arithmetic
int sum = a + b;
int diff = a - b;
int product = a * b;
int quotient = a / b; // integer division — 7/2 = 3
int remainder = a % b; // modulo — 7%2 = 1
// Comparison (returns 0 or 1)
if (a == b) { /* equal */ }
if (a != b) { /* not equal */ }
if (a > b) { /* greater than */ }
if (a <= b) { /* less than or equal */ }
// Logical
if (a > 0 && b > 0) { /* both true */ }
if (a > 0 || b > 0) { /* at least one true */ }
if (!flag) { /* flag is false */ }
// Bitwise (very common in embedded — setting/clearing register bits)
uint8_t reg = 0x00;
reg |= (1 << 3); // set bit 3
reg &= ~(1 << 3); // clear bit 3
reg ^= (1 << 3); // toggle bit 3
if (reg & (1 << 3)) { /* bit 3 is set */ }
Control Flow¶
if / else¶
int sensor_value = 512;
if (sensor_value > 800) {
printf("High\n");
} else if (sensor_value > 200) {
printf("Normal\n");
} else {
printf("Low\n");
}
switch¶
typedef enum {
MODE_STA,
MODE_AP,
MODE_STA_AP
} wifi_mode_t;
wifi_mode_t mode = MODE_STA;
switch (mode) {
case MODE_STA:
printf("Station mode\n");
break;
case MODE_AP:
printf("Access Point mode\n");
break;
case MODE_STA_AP:
printf("Combined mode\n");
break;
default:
printf("Unknown mode\n");
break;
}
Loops¶
// for loop — used when you know the iteration count
for (int i = 0; i < 10; i++) {
printf("i = %d\n", i);
}
// while loop — used when condition determines termination
int retries = 0;
while (!wifi_connected() && retries < 5) {
retry_connect();
retries++;
}
// infinite loop — the main pattern in embedded firmware
while (1) {
// Super loop: read sensors, process, act
read_sensors();
process_data();
vTaskDelay(pdMS_TO_TICKS(100));
}
Functions¶
// Function declaration (prototype) — common in header files
int add(int a, int b);
// Function definition
int add(int a, int b) {
return a + b;
}
// void return — function does something but returns nothing
void blink_led(int gpio, int delay_ms) {
gpio_set_level(gpio, 1);
vTaskDelay(pdMS_TO_TICKS(delay_ms));
gpio_set_level(gpio, 0);
}
// static — limits scope to this translation unit (.c file)
static void helper_function(void) {
// only callable from within this file
}
// const — promises not to modify the parameter
void print_label(const char *label) {
printf("%s\n", label);
// label[0] = 'X'; // compiler error — can't modify const
}
Arrays and Strings¶
// Array
int readings[5] = {100, 200, 300, 400, 500};
int first = readings[0]; // 100
// C strings are char arrays terminated by '\0'
char name[] = "ESP32"; // compiler adds '\0' automatically
// name is {'E', 'S', 'P', '3', '2', '\0'} — 6 bytes
// String operations (from string.h)
#include <string.h>
int len = strlen(name); // 5
int cmp = strcmp(name, "ESP32"); // 0 means equal
char *found = strstr(name, "SP"); // pointer to "SP32"
// printf format strings
printf("Device: %s, Value: %d, Temp: %.1f\n", name, readings[0], 25.5);
Pointers¶
Pointers are the single most important C concept for embedded programming. ESP-IDF APIs use pointers everywhere.
Basics¶
int value = 42;
int *ptr = &value; // ptr holds the ADDRESS of value
printf("Value: %d\n", value); // 42
printf("Address: %p\n", (void *)ptr);
printf("Dereference: %d\n", *ptr); // 42 — *ptr reads the value at the address
*ptr = 100; // write through the pointer
printf("Value now: %d\n", value); // 100 — value was changed via pointer
Pointers and Arrays¶
int data[4] = {10, 20, 30, 40};
// Array name decays to a pointer to the first element
int *p = data; // same as &data[0]
printf("%d\n", *p); // 10
printf("%d\n", *(p+1)); // 20 — pointer arithmetic
printf("%d\n", p[2]); // 30 — pointer[index] is equivalent to *(pointer + index)
Pointers to Structs¶
typedef struct {
float temperature;
float humidity;
} sensor_data_t;
sensor_data_t reading = {25.5, 60.0};
sensor_data_t *ptr = &reading;
// Access struct members through pointer using ->
printf("Temp: %.1f\n", ptr->temperature); // 25.5
printf("Humidity: %.1f\n", ptr->humidity); // 60.0
// Equivalent to:
printf("Temp: %.1f\n", (*ptr).temperature); // 25.5
Double Pointers¶
Used in ESP-IDF when a function needs to return a pointer (e.g., allocating memory or finding a handle).
void create_task(TaskHandle_t *out_handle) {
// Function writes a handle back through the caller's pointer
TaskHandle_t handle = xTaskCreate(...);
*out_handle = handle; // write the handle back
}
// Caller:
TaskHandle_t my_task;
create_task(&my_task); // passes address of my_task
// my_task now contains the created handle
Structs¶
Structs group related data together — you'll see them everywhere in ESP-IDF for configuration, events, and data passing.
// Define a struct type
typedef struct {
int gpio_num;
int brightness;
bool is_on;
} led_config_t;
// Initialize
led_config_t led = {
.gpio_num = 2,
.brightness = 128,
.is_on = true
};
// Access members
printf("GPIO: %d, Brightness: %d\n", led.gpio_num, led.brightness);
// Pass to function
void configure_led(const led_config_t *config) {
gpio_set_direction(config->gpio_num, GPIO_MODE_OUTPUT);
if (config->is_on) {
gpio_set_level(config->gpio_num, 1);
}
}
Nested Structs (Common in ESP-IDF)¶
typedef struct {
float temperature;
float humidity;
} dht_reading_t;
typedef struct {
dht_reading_t sensor;
char device_id[16];
uint32_t timestamp;
} telemetry_t;
telemetry_t data = {
.sensor = { .temperature = 25.5, .humidity = 60.0 },
.device_id = "XIAO-S3-01",
.timestamp = 1713427200
};
printf("Temp: %.1f\n", data.sensor.temperature);
Enums¶
Enums define named integer constants — used for states, modes, and error codes.
typedef enum {
ESP_OK = 0, // Success
ESP_FAIL = -1, // Generic failure
ESP_ERR_NO_MEM = 0x101, // Out of memory
ESP_ERR_TIMEOUT = 0x102, // Operation timed out
} esp_err_t;
esp_err_t result = wifi_connect();
if (result != ESP_OK) {
printf("Connection failed: 0x%x\n", result);
}
Preprocessor Macros¶
Macros are heavily used in ESP-IDF for configuration, logging, and conditional compilation.
// Object-like macro — constant replacement
#define LED_GPIO 2
#define MAX_RETRIES 3
// Function-like macro
#define MIN(a, b) ((a) < (b) ? (a) : (b))
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))
// Conditional compilation
#define CONFIG_ENABLE_WIFI 1
#if CONFIG_ENABLE_WIFI
void wifi_init(void) { /* ... */ }
#endif
// ESP-IDF logging macros (you'll use these constantly)
#include "esp_log.h"
#define TAG "MAIN"
ESP_LOGI(TAG, "Temperature: %.1f", temp); // Info
ESP_LOGW(TAG, "Low battery: %d%%", level); // Warning
ESP_LOGE(TAG, "Sensor read failed!"); // Error
const and volatile — Keywords Every Embedded Programmer Must Know¶
These two qualifiers change how the compiler treats a variable. They're not just "nice to know" — getting them wrong in embedded code causes real bugs that are extremely hard to track down.
const — "Don't Change This"¶
const tells the compiler: this value must not be modified. If you try to modify a const variable, the compiler gives an error.
const int max_retries = 3;
// max_retries = 5; // ❌ compiler error — can't modify const
// Common use: function parameters that shouldn't be changed
void print_sensor(const sensor_data_t *data) {
printf("Temp: %.1f\n", data->temperature);
// data->temperature = 0; // ❌ compiler error — data is const
}
// Common use: const pointers — pointer can't change what it points to
const char *device_name = "ESP32-S3"; // can't modify the string
// device_name[0] = 'X'; // ❌ compiler error
volatile — "Don't Optimize This Away"¶
volatile tells the compiler: this variable can change at any time, even when no code in this function modifies it. The compiler must read the variable from memory every time — it cannot cache the value in a register.
This is critical in embedded programming for two situations:
- Memory-mapped hardware registers — the hardware changes the register value behind the CPU's back
- Variables shared between an ISR and main code — the ISR modifies the variable at any time
// ❌ WITHOUT volatile — the compiler optimizes this to an infinite loop
bool flag = false;
void isr_handler(void) {
flag = true; // set by interrupt — happens asynchronously
}
while (!flag) {
// Compiler sees: flag is never modified in this loop
// It optimizes to: while(true) — your program hangs forever!
}
// ✅ WITH volatile — the compiler reads flag from memory every iteration
volatile bool flag = false;
void isr_handler(void) {
flag = true; // interrupt sets it
}
while (!flag) {
// Compiler MUST re-read flag each time — loop exits when ISR fires
}
ESP-IDF Examples of volatile¶
// Memory-mapped register — hardware changes this without CPU involvement
volatile uint32_t *gpio_input = (volatile uint32_t *)0x6000403C;
uint32_t pin_state = *gpio_input; // always reads the real hardware value
// FreeRTOS task notification — another task can modify this at any time
volatile TaskHandle_t x_notify_task = NULL;
// Shared flag between ISR and main task
volatile bool s_wifi_connected = false;
void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *event_data) {
if (id == WIFI_EVENT_STA_CONNECTED) {
s_wifi_connected = true; // ISR / event callback sets flag
}
}
Forgetting volatile is a silent bug
The code compiles without errors or warnings. It just doesn't work correctly — the loop never exits, or the register value is stale. Always use volatile for:
- Variables modified inside an ISR and read in main code
- Memory-mapped peripheral registers
- Variables shared between FreeRTOS tasks without a mutex (though prefer proper synchronization)
const + volatile Together¶
Yes, you can use both! This means: "I can't change it, but something else can."
// Read-only register — you can't write to it, but hardware can change it
const volatile uint32_t *status_reg = (const volatile uint32_t *)0x60004000;
uint32_t val = *status_reg; // read current value (hardware may have changed it)
// *status_reg = 0; // ❌ can't write — it's const
Static Local Variables — Values That Persist Across Calls¶
You've seen static on functions (limits scope to the file). But static on a local variable inside a function does something different: the variable keeps its value between function calls.
// Without static — count resets to 0 every call
void counter_bad(void) {
int count = 0; // created fresh on the stack each call
count++;
printf("Count: %d\n", count); // always prints 1
}
// With static — count persists across calls
void counter_good(void) {
static int count = 0; // initialized ONCE, lives for the entire program
count++; // value carries over from last call
printf("Count: %d\n", count); // 1, 2, 3, 4, ...
}
Why This Matters in ESP-IDF¶
Static locals are used for one-time initialization flags and persistent state in ESP-IDF:
// Pattern 1: One-time initialization flag
void init_wifi(void) {
static bool initialized = false; // persists across calls
if (initialized) {
ESP_LOGI(TAG, "WiFi already initialized");
return; // skip re-initialization
}
// ... do expensive init ...
initialized = true;
}
// Pattern 2: Persistent state in a task
void sensor_task(void *param) {
static uint32_t sample_count = 0; // survives across loop iterations
while (1) {
read_sensor();
sample_count++;
if (sample_count % 100 == 0) {
ESP_LOGI(TAG, "Collected %" PRIu32 " samples", sample_count);
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
// Pattern 3: File-scope static (we already covered this)
// Limits visibility to this .c file — other files can't access it
static const char *TAG = "MAIN"; // only visible in this translation unit
static int s_gpio_num = 0; // "s_" prefix is an ESP-IDF convention for statics
Stack vs static storage
- Normal local: Created on the stack each call, destroyed on return. Fast but temporary.
- Static local: Created in
.bss/.datasection at program start, survives forever. Like a global but scoped to the function.
#define vs const vs enum — Which to Use for Constants?¶
You'll see all three patterns in ESP-IDF code. Here's when to use each:
#define — Preprocessor Macro¶
- How it works: Text replacement before compilation — the compiler never sees
LED_GPIO, only2 - Pros: Works everywhere (including array sizes, switch cases,
#ifconditions) - Cons: No type checking, no symbol in debugger, can cause unexpected behavior with parentheses
const — Typed Constant¶
- How it works: A real variable that the compiler type-checks and can optimize away
- Pros: Type-safe, shows up in debugger, compiler catches misuse
- Cons: Can't use in
switch/caselabels or array sizes in C (C99 restriction — works in C++)
enum — Named Integer Constants¶
- How it works: Integer constants with names — the compiler knows they're related
- Pros: Type-safe (with
typedef), debugger shows names, works inswitch/case - Cons: Only integers — can't represent floats or pointers
Decision Guide¶
| Situation | Use | Why |
|---|---|---|
| Hardware pin numbers, simple constants | #define |
ESP-IDF convention, works everywhere |
| Typed constants that need type safety | const |
Compiler catches type mismatches |
| Related integer constants (states, modes, errors) | enum |
Groups related values, debugger shows names |
Configuration values from menuconfig |
#define |
ESP-IDF's sdkconfig generates #define macros |
Array sizes and switch/case labels |
#define or enum |
const doesn't work here in C |
| Logging tag strings | static const char * |
ESP-IDF convention: static const char *TAG = "MAIN" |
ESP-IDF Conventions You'll See¶
// ESP-IDF uses #define for most constants (generated by menuconfig)
#define CONFIG_ESP_WIFI_SSID "MyNetwork"
#define CONFIG_ESP_WIFI_PASSWORD "MyPassword"
// ESP-IDF uses enum for error codes and modes
typedef enum {
ESP_OK = 0,
ESP_FAIL = -1,
// ...
} esp_err_t;
// ESP-IDF uses static const for strings (not #define)
static const char *TAG = "MAIN"; // NOT: #define TAG "MAIN"
When in doubt, follow ESP-IDF's lead
Use #define for numeric constants and enum for related integer groups. ESP-IDF's own codebase is your best reference for conventions.
Memory: Stack vs Heap¶
// Stack — automatic, fast, limited size (typically 4-8KB per FreeRTOS task)
void stack_example(void) {
int local_var = 42; // on the stack
sensor_data_t data = {0}; // on the stack
// freed automatically when function returns
}
// Heap — manual allocation, larger pool, must free
void heap_example(void) {
sensor_data_t *data = malloc(sizeof(sensor_data_t));
if (data == NULL) {
ESP_LOGE(TAG, "Out of memory!");
return;
}
data->temperature = 25.5;
// ... use data ...
free(data); // MUST free — no garbage collector in C!
}
Memory leaks
In C, every malloc must have a matching free. Forgetting to free memory causes leaks that will eventually crash the ESP32. ESP-IDF also provides heap_caps_malloc for allocating in specific memory regions (e.g., DMA-capable or SPIRAM).
Normal C vs Embedded C — Key Differences¶
This section highlights the practical differences between writing C for a desktop application and writing C for an embedded system like the ESP32-S3.
Program Structure¶
// ── Normal C (desktop) ──
// Program runs once and exits
int main(int argc, char *argv[]) {
printf("Hello!\n");
process_data();
return 0; // OS cleans up everything
}
// ── Embedded C (ESP-IDF) ──
// Program runs forever — there is no OS to return to
void app_main(void) {
init_hardware();
while (1) { // super loop — or use FreeRTOS tasks
read_sensors();
process_data();
actuate_outputs();
vTaskDelay(pdMS_TO_TICKS(100)); // yield CPU — don't busy-wait!
}
// never reaches here
}
Error Handling¶
// ── Normal C (desktop) ──
// Print error and exit — OS handles cleanup
FILE *f = fopen("data.txt", "r");
if (f == NULL) {
perror("Failed to open file");
exit(1); // just quit
}
// ── Embedded C (ESP-IDF) ──
// Must handle error and continue — there is no OS to exit to
esp_err_t ret = i2c_master_init();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "I2C init failed: %s", esp_err_to_name(ret));
// Options: retry, use fallback, or restart the device
esp_restart(); // last resort — reboot the chip
}
Memory Management¶
// ── Normal C (desktop) ──
// Generous heap — allocate freely, OS reclaims on exit
char *buffer = malloc(1024 * 1024); // 1 MB — no problem
if (buffer) { /* use it */ }
// ── Embedded C (ESP-IDF) ──
// Limited heap — ESP32-S3 has ~320KB internal + 8MB PSRAM
// Must be frugal and always check for NULL
char *buffer = malloc(256); // small allocation
if (buffer == NULL) {
ESP_LOGE(TAG, "Out of memory!");
return; // can't proceed — handle gracefully
}
// ... use buffer ...
free(buffer); // ALWAYS free when done
buffer = NULL; // prevent use-after-free
I/O Operations¶
// ── Normal C (desktop) ──
// Standard I/O — keyboard, screen, files
printf("Enter value: ");
scanf("%d", &value);
fprintf(fp, "Result: %d\n", value);
// ── Embedded C (ESP-IDF) ──
// Hardware I/O — GPIO, I2C, SPI, UART
gpio_set_level(LED_GPIO, 1); // digital output
int adc_val = adc_read(ADC_CHANNEL_0); // analog input
i2c_write(I2C_NUM_0, OLED_ADDR, &data, len); // I2C communication
uart_write_bytes(UART_NUM_0, msg, strlen(msg)); // serial output
Timing and Delays¶
// ── Normal C (desktop) ──
// OS-managed sleep — imprecise, minimum ~1ms granularity
#include <unistd.h>
usleep(100000); // sleep 100ms — not precise
// ── Embedded C (ESP-IDF) ──
// FreeRTOS delays — precise, task-aware
vTaskDelay(pdMS_TO_TICKS(100)); // delay 100ms, yields CPU to other tasks
// NEVER use busy-wait loops for timing!
Concurrency¶
// ── Normal C (desktop) ──
// POSIX threads — OS handles scheduling
#include <pthread.h>
pthread_t thread;
pthread_create(&thread, NULL, worker_fn, NULL);
// ── Embedded C (ESP-IDF) ──
// FreeRTOS tasks — deterministic, priority-based
TaskHandle_t task_handle;
xTaskCreatePinnedToCore(
worker_task, // function
"worker", // name
4096, // stack size in bytes
NULL, // parameter
1, // priority
&task_handle, // handle output
1 // core 1 (ESP32-S3 is dual-core!)
);
Summary Table¶
| Aspect | Normal C (Desktop) | Embedded C (ESP-IDF) |
|---|---|---|
| Entry point | main() — runs once |
app_main() — runs forever |
| Exit | return 0 / exit() |
Never exits — esp_restart() to reboot |
| Memory | Abundant (GBs), OS-managed | Limited (~320KB internal + 8MB PSRAM) |
| Types | int, float, double |
int32_t, uint8_t, float only |
| I/O | printf, scanf, fprintf |
GPIO, I2C, SPI, UART drivers |
| Error handling | exit(1) — OS cleans up |
Must handle and continue or reboot |
| Timing | sleep(), usleep() — imprecise |
vTaskDelay() — precise, cooperative |
| Concurrency | pthread — OS scheduled |
FreeRTOS tasks — priority scheduled |
| Debugging | GDB, Valgrind, ASAN | Serial output, ESP_LOGx, JTAG (rare) |
| Compilation | gcc native |
xtensa-esp32s3-elf-gcc cross-compiler |
| Float math | double is fine |
Use float only — no double FPU |
Multi-File Projects and the Preprocessor¶
Real projects don't fit in one file. The C preprocessor (#include, #define, #ifndef) is how you split code across multiple files and control what gets compiled.
How #include Works¶
#include literally copies and pastes the contents of another file at that point before compilation. Think of it as "import this file's declarations so I can use them."
#include <stdio.h> // angle brackets → system/library headers
#include "my_sensor.h" // quotes → your own project headers
| Syntax | Searches | Used For |
|---|---|---|
#include <file.h> |
System include paths only | Standard library, ESP-IDF headers |
#include "file.h" |
Current directory first, then system paths | Your project's own headers |
Splitting Code Across Files — Normal C¶
A typical desktop C project:
my_project/
├── main.c ← entry point, uses sensor and display
├── sensor.h ← sensor declarations (types, function prototypes)
├── sensor.c ← sensor implementation
├── display.h ← display declarations
└── display.c ← display implementation
// ── sensor.h — declare what's available ──
#ifndef SENSOR_H // include guard — prevent double inclusion
#define SENSOR_H
typedef struct {
float temperature;
float humidity;
} sensor_data_t;
sensor_data_t sensor_read(void); // function prototype
void sensor_init(int port); // function prototype
#endif // SENSOR_H
// ── sensor.c — implement the functions ──
#include "sensor.h" // include our own header
#include <stdio.h> // for printf
static int s_port = 0; // file-private variable — only sensor.c can see it
void sensor_init(int port) {
s_port = port;
printf("Sensor on port %d\n", port);
}
sensor_data_t sensor_read(void) {
sensor_data_t data = {25.5, 60.0}; // simulated reading
return data;
}
// ── main.c — use the sensor module ──
#include "sensor.h" // now main.c knows about sensor_data_t and sensor_read
#include <stdio.h>
int main(void) {
sensor_init(1);
sensor_data_t reading = sensor_read();
printf("Temp: %.1f\n", reading.temperature);
return 0;
}
Splitting Code Across Files — ESP-IDF¶
ESP-IDF projects follow a specific structure enforced by the build system:
my_esp_project/
├── main/
│ ├── CMakeLists.txt ← tells the build system about this component
│ ├── main.c ← app_main() entry point
│ ├── sensor.h ← your sensor module header
│ ├── sensor.c ← your sensor module implementation
│ ├── wifi.h ← your WiFi module header
│ └── wifi.c ← your WiFi module implementation
├── components/ ← optional: reusable libraries
│ └── my_driver/
│ ├── CMakeLists.txt
│ ├── include/my_driver.h
│ └── my_driver.c
├── sdkconfig ← generated by menuconfig (all #define configs)
└── CMakeLists.txt ← top-level build file
// ── main/sensor.h ──
#ifndef SENSOR_H
#define SENSOR_H
#include "driver/i2c.h" // ESP-IDF driver header
typedef struct {
float temperature;
float humidity;
} sensor_data_t;
esp_err_t sensor_init(i2c_port_t port); // returns esp_err_t, not int
esp_err_t sensor_read(sensor_data_t *out_data); // output via pointer (ESP-IDF pattern)
#endif // SENSOR_H
// ── main/sensor.c ──
#include "sensor.h"
#include "esp_log.h"
static const char *TAG = "SENSOR"; // ESP-IDF convention: static const for TAG
static i2c_port_t s_i2c_port = 0; // "s_" prefix = static (file-private)
esp_err_t sensor_init(i2c_port_t port) {
s_i2c_port = port;
ESP_LOGI(TAG, "Sensor initialized on I2C port %d", port);
return ESP_OK;
}
esp_err_t sensor_read(sensor_data_t *out_data) {
if (out_data == NULL) return ESP_ERR_INVALID_ARG;
// Read from I2C hardware...
out_data->temperature = 25.5f;
out_data->humidity = 60.0f;
return ESP_OK;
}
// ── main/main.c ──
#include "sensor.h"
#include "esp_log.h"
static const char *TAG = "MAIN";
void app_main(void) {
ESP_ERROR_CHECK(sensor_init(I2C_NUM_0));
while (1) {
sensor_data_t reading = {0};
esp_err_t ret = sensor_read(&reading);
if (ret == ESP_OK) {
ESP_LOGI(TAG, "Temp: %.1f°C, Hum: %.1f%%",
reading.temperature, reading.humidity);
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
Include Guards Explained¶
When a header is included from multiple .c files, it could be processed multiple times. This causes redefinition errors — the compiler sees sensor_data_t defined twice. Include guards prevent this:
#ifndef SENSOR_H // "if SENSOR_H is NOT defined, process this file"
#define SENSOR_H // "now SENSOR_H IS defined"
// ... all your declarations ...
#endif // SENSOR_H // end of guard
- First include:
SENSOR_His not defined → process the file → defineSENSOR_H - Second include:
SENSOR_His already defined → skip everything between#ifndefand#endif
#pragma once vs include guards
Some compilers support #pragma once as a simpler alternative — it does the same thing in one line. However, #ifndef guards are the standard in ESP-IDF and work on all compilers. Use #ifndef guards.
Key Differences: Normal C vs ESP-IDF Multi-File¶
| Aspect | Normal C | ESP-IDF |
|---|---|---|
| Entry point | main() |
app_main() |
| Build system | Makefile or CMake | CMake with idf.py wrapper |
| Error returns | int (0 = success) |
esp_err_t (ESP_OK = success) |
| Logging | printf() |
ESP_LOGI/W/E() with TAG |
| File-private vars | static |
static with s_ prefix convention |
| Headers | #include "my.h" |
Same, plus ESP-IDF component headers |
| Include guards | #ifndef / #define / #endif |
Same pattern |
Advanced Topics¶
The following sections cover more advanced C patterns. You won't need to write these yourself in Modules 0–7, but you'll see them in ESP-IDF source code. Come back here when you're ready.
Function Pointers¶
Used in ESP-IDF for callbacks — event handlers, interrupt service routines, and task entry points.
// Define a function pointer type
typedef void (*event_handler_t)(int event_id, void *event_data);
// A function that matches the signature
void wifi_event_handler(int event_id, void *event_data) {
printf("WiFi event: %d\n", event_id);
}
// Register the callback
void register_handler(event_handler_t handler) {
// Store handler, call it later when event occurs
handler(WIFI_EVENT_CONNECTED, NULL);
}
// Usage
register_handler(wifi_event_handler);
Patterns You'll See in ESP-IDF¶
Error Checking Pattern¶
Almost every ESP-IDF API returns esp_err_t. The standard pattern:
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret); // aborts if ret != ESP_OK
Configuration Struct Pattern¶
ESP-IDF uses "init config" structs extensively:
// 1. Get default configuration
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << LED_GPIO),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
// 2. Apply configuration
gpio_config(&io_conf);
// 3. Use the peripheral
gpio_set_level(LED_GPIO, 1);
Handle Pattern¶
Many ESP-IDF peripherals use opaque handles:
gpio_num_t button_gpio = GPIO_NUM_9;
gpio_install_isr_service(0);
gpio_isr_handler_add(button_gpio, button_isr, NULL);
Further Reading¶
- ESP-IDF Programming Guide
- The C Programming Language (Kernighan & Ritchie)
- Learn C — interactive tutorials