Skip to content

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:

  1. Get the ingredients → declare variables (your data)
  2. Follow the steps → write statements (your logic)
  3. 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:

  1. You put something in → inputs (called parameters)
  2. The machine does its work → the function body
  3. 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:

  1. Memory-mapped hardware registers — the hardware changes the register value behind the CPU's back
  2. 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/.data section 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

#define LED_GPIO      2
#define MAX_RETRIES   3
#define TIMEOUT_MS    5000
  • How it works: Text replacement before compilation — the compiler never sees LED_GPIO, only 2
  • Pros: Works everywhere (including array sizes, switch cases, #if conditions)
  • Cons: No type checking, no symbol in debugger, can cause unexpected behavior with parentheses

const — Typed Constant

const uint8_t led_gpio = 2;
const int max_retries = 3;
const uint32_t timeout_ms = 5000;
  • 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/case labels or array sizes in C (C99 restriction — works in C++)

enum — Named Integer Constants

typedef enum {
    LED_GPIO    = 2,
    MAX_RETRIES = 3,
    TIMEOUT_MS  = 5000,
} app_config_t;
  • How it works: Integer constants with names — the compiler knows they're related
  • Pros: Type-safe (with typedef), debugger shows names, works in switch/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_H is not defined → process the file → define SENSOR_H
  • Second include: SENSOR_H is already defined → skip everything between #ifndef and #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