C programming from basic to advanced is best learned in five stages: (1) syntax, data types and operators, (2) control flow and functions, (3) arrays, strings and pointers, (4) structures, dynamic memory and file handling, and (5) advanced topics such as bit manipulation, function pointers, volatile and embedded C. Write code after every topic, and move to hardware projects once pointers and memory feel natural.
If you have ever looked inside an operating system kernel, a car’s control unit or a smart meter, you have almost certainly been reading C. The language is more than fifty years old, yet it is still the one that talks closest to the hardware without getting in your way.
This guide takes you from your first printf to the techniques firmware engineers use every day. Each stage gives you the key ideas as short points, a working example and the mistakes that trip people up most often.
Every C program follows the same skeleton. Learn it once and you will recognise it everywhere.
#include
int main(void)
{
printf("Hello, embedded world!\n");
return 0;
}#include brings in the standard input/output library so printf works.main() is the entry point. On a bare-metal microcontroller, a start-up file runs first and then calls main().return 0; tells the operating system the program finished successfully.gcc -std=c11 -Wall -Wextra hello.c -o hello
./hello-Wall -Wextra switch on helpful warnings. Treat every warning as a bug waiting to happen.
Most embedded projects use C99 or C11, and C23 is the latest published ISO standard.
A variable is a named location in memory. Give it a type, a name and a starting value, for example int count = 10;
Core types: char (1 byte), int (commonly 2 or 4 bytes), float (usually 4 bytes) and double (usually 8 bytes).
Modifiers such as signed, unsigned, short and long change the range and size.
Embedded tip: The size of int depends on the platform. It is often 16 bits on small 8-bit and 16-bit microcontrollers and 32 bits on ARM Cortex-M. When size matters, use the fixed-width types from .
#include
uint8_t sensor_id = 7; // exactly 8 bits, unsigned
int16_t temperature = -25; // exactly 16 bits, signed
uint32_t timer_ticks = 0; // exactly 32 bits, unsignedUse const for typed, read-only values and #define for compile-time values such as array sizes and register addresses.
Mixed expressions convert types automatically, and that can surprise you: 5 / 2 gives 2 because both operands are integers.
Use an explicit cast when you need precision.
int total = 7, count = 2;
float average = (float)total / count; // 3.5, not 3+ - * / %== != > < >= <=&& || !& | ^ ~ << >> (the backbone of register programming)= += -= *= /=, plus ++ and --condition ? a : b for compact decisionsCommon mistake: Writing if (x = 5) instead of if (x == 5). The first assigns, the second compares.
printf() prints formatted output and scanf() reads formatted input.
Common format specifiers: %d (int), %u (unsigned), %f (float), %c (char), %s (string) and %x (hex).
Pass the address of the variable to scanf, for example &age.
int age;
printf("Enter your age: ");
scanf("%d", &age);
printf("You are %d years old.\n", age);Embedded note: Many microcontrollers have no console, so output usually goes through UART. printf is either redirected to the UART or replaced with lightweight custom print functions.
Use if, else if and else for conditions, and switch when choosing between many integer or character values.
End every case with break unless you deliberately want fall-through.
Always add a default case. It is your safety net for unexpected values.
switch (command) {
case 1: led_on(); break;
case 2: led_off(); break;
default: error_handler(); break;
}for suits a known number of repetitions, while checks the condition first, and do-while always runs at least once.
break exits a loop early and continue skips to the next iteration.
Embedded tip: Most firmware lives inside an endless while (1) loop, often called the super-loop.
for (int i = 1; i <= 5; i++) {
printf("%d x 2 = %d\n", i, i * 2);
}A function is a reusable block with a return type, a name, parameters and a body.
Declare a function (write its prototype) before you call it.
Arguments are passed by value, so the function works on a copy. To change the original, pass its address (you will see this in Stage 3).
Keep each function focused on one job. Small functions are easier to test and debug.
int add(int a, int b); // prototype
int main(void)
{
printf("Sum = %d\n", add(4, 6));
return 0;
}
int add(int a, int b)
{
return a + b;
}A recursive function calls itself on a smaller version of the problem and needs a base case that stops the calls.
Classic examples: factorial, Fibonacci and tree traversal.
Embedded caution: Every call uses stack memory. On small microcontrollers recursion is usually avoided, and coding standards such as MISRA C restrict it.
An array stores several values of the same type in consecutive memory locations.
Indexing starts at 0. C does not check bounds, so reading or writing past the end is undefined behaviour.
In firmware, const lookup tables (sine waves, CRC tables) can live in flash, which saves RAM and computation time.
int readings[5] = {12, 18, 25, 30, 22};
int sum = 0;
for (int i = 0; i < 5; i++) {
sum += readings[i];
}A string is a char array that ends with the null character '\0'.
Always reserve one extra byte for it. The word Embedded needs 9 bytes, not 8.
The header gives you strlen, strcmp, strcpy and strcat. Prefer bounded functions such as snprintf to avoid buffer overflows.
char name[] = "Embedded"; // 8 letters + '\0' = 9 bytesPointers are the topic that makes C feel hard at first. Don’t worry, almost everyone struggles here, and the idea becomes simple with the right picture.
A pointer is a variable that stores the memory address of another variable.
Think of a variable as a house and a pointer as its postal address. The address is not the house, but it tells you exactly where to find it.
Two operators do the work: & gives the address of a variable, and * follows an address to reach the value.
int value = 42;
int *ptr = &value;
printf("Value : %d\n", *ptr); // prints 42
printf("Address : %p\n", (void *)ptr);
*ptr = 100; // value is now 100ptr + 1 moves forward by sizeof(*ptr) bytes, not by one byte.arr[i] and *(arr + i) mean the same thing.void swap(int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
// usage: swap(&x, &y);int *p = NULL;A struct groups related data of different types under one name.
A union lets several members share the same memory, so its size equals its largest member.
An enum gives names to integer constants, which makes code self-explanatory.
Embedded tip: Structs map neatly onto sensor packets and peripheral register blocks. Padding and alignment can change a struct’s size, so verify it with sizeof.
typedef enum { LED_OFF, LED_ON, LED_BLINK } LedState_t;
typedef struct {
uint8_t id;
int16_t temperature;
uint32_t timestamp;
LedState_t led;
} SensorNode_t;
SensorNode_t node = { .id = 1, .temperature = 27, .timestamp = 1000, .led = LED_ON };malloc, calloc, realloc and free (from ) manage heap memory at run time.
Every successful malloc needs a matching free. Forgetting it causes a memory leak.
Always check the returned pointer for NULL before using it.
// inside a function that returns int
int *data = malloc(5 * sizeof(*data));
if (data == NULL) {
return -1; // allocation failed
}
data[0] = 10;
free(data);
data = NULL; // avoids a dangling pointerTypical errors: memory leaks, double free and use after free.
Embedded caution: Many firmware projects avoid heap allocation after start-up because of fragmentation and unpredictable timing. Static allocation and memory pools are the usual alternatives.
Use fopen, fclose, fprintf, fscanf, fgets, fread and fwrite to work with files.
Common modes: r (read), w (write), a (append), with b added for binary files.
Always check that fopen succeeded, and always close the file.
FILE *fp = fopen("log.txt", "w");
if (fp == NULL) {
perror("File open failed");
return 1;
}
fprintf(fp, "Temperature: %d C\n", 27);
fclose(fp);File handling also appears in embedded designs that log data to SD cards using libraries such as FatFs.
Local variables live inside a function and disappear when it returns. Global variables live for the whole run, but too many of them make code hard to debug.
static inside a function keeps the value between calls. static at file level hides the name from other files.
extern declares a variable that is defined in another file.
#define creates constants and macros, and #include pulls in headers.
Conditional compilation (#ifdef, #ifndef) switches code on or off for different boards and builds.
Put declarations in .h files and definitions in .c files, and guard every header against double inclusion.
Macro trap: #define SQUARE(x) x * x turns SQUARE(2 + 3) into 2 + 3 * 2 + 3, which is 11 instead of 25. Wrap the parameters and the whole expression in parentheses, or use a static inline function.
#ifndef SENSOR_H
#define SENSOR_H
int sensor_read(void);
#endifSetting, clearing, toggling and testing individual bits is how you control GPIO pins, timers and other peripherals.
Use unsigned types for bitwise work to avoid sign-related surprises.
#define BIT(n) (1U << (n))
uint32_t reg = 0;
reg |= BIT(3); // set bit 3
reg &= ~BIT(3); // clear bit 3
reg ^= BIT(3); // toggle bit 3
if (reg & BIT(3)) { // test bit 3
/* bit 3 is set */
}A function pointer stores the address of a function, so you can pass behaviour around like data.
You will find them in callbacks, state machines, driver tables and interrupt vector tables.
void on_button_press(void)
{
/* handle the event */
}
void (*callback)(void) = on_button_press;
callback(); // calls on_button_pressint **pp stores the address of another pointer. It is used for dynamic 2D arrays and for functions that must change a pointer itself.const int *p means the data is read-only, while int *const p means the pointer cannot be reassigned. Read the declaration from right to left.void * holds any address, which is why malloc returns it. Convert it to a typed pointer before dereferencing.-> accesses struct members through a pointer.int x = 10;
int *p = &x;
int **pp = &p;
**pp = 20; // x is now 20
SensorNode_t *np = &node; // node from the Stage 4 example
np->temperature = 30;volatile tells the compiler that a value can change outside the normal program flow (a hardware register, or a variable modified inside an interrupt), so it must not optimise those reads and writes away.
const marks data as read-only. A read-only status register is often declared const volatile.
In memory-mapped I/O, each peripheral register sits at a fixed address and is accessed through a pointer.
// The address is illustrative. Check your device's reference manual.
#define GPIOA_ODR (*(volatile uint32_t *)0x48000014U)
GPIOA_ODR |= (1U << 5); // drive pin 5 highClassic bug: A flag shared with an interrupt handler but missing volatile often works with optimisation off and fails with optimisation on.
Linked lists, stacks, queues, trees and hash tables are all built from structs and pointers.
A favourite in embedded work is the ring (circular) buffer, used for UART receive data and sensor streams.
One slot stays unused so the code can tell a full buffer from an empty one.
#define BUF_SIZE 8
typedef struct {
uint8_t data[BUF_SIZE];
uint8_t head;
uint8_t tail;
} RingBuffer_t;
int rb_put(RingBuffer_t *rb, uint8_t byte)
{
uint8_t next = (rb->head + 1) % BUF_SIZE;
if (next == rb->tail) {
return -1; // buffer full
}
rb->data[rb->head] = byte;
rb->head = next;
return 0;
}
int rb_get(RingBuffer_t *rb, uint8_t *byte)
{
if (rb->head == rb->tail) {
return -1; // buffer empty
}
*byte = rb->data[rb->tail];
rb->tail = (rb->tail + 1) % BUF_SIZE;
return 0;
}Knowing this layout helps you read a linker map file and see where your RAM is going.
-Wall -Wextra) and fix every one of them.The syntax stays the same. The environment does not.
Starter tools: GCC or Clang with VS Code, GDB for debugging, and an IDE such as STM32CubeIDE or Keil for microcontroller work.
i < size, not i <= size. types when size matters.Mastering C is not about memorising syntax. It is about building a clear mental picture of how your code, memory and hardware fit together. Move stage by stage, type out every example yourself, break things on purpose and then fix them. That habit is what turns a beginner into an engineer.
Your next steps:
Ready to run your C code on real microcontrollers? Explore the embedded systems and IoT training programs at the Indian Institute of Embedded Systems (IIES) in Bangalore and turn these concepts into working hardware projects.
Most learners need about three to six months of regular practice to feel comfortable with advanced topics such as pointers, dynamic memory and bit manipulation. The exact time depends on prior programming experience and daily practice hours, and pointers usually take the longest.
C is a small language, but it gives you direct control over memory and does not catch many mistakes for you, so it can feel strict at first. Practising pointers early with small programs removes most of the difficulty.
Yes. Embedded systems, firmware, operating system kernels, device drivers and performance-critical software still rely heavily on C, and it remains a core requirement in many embedded and IoT roles.
Advanced C usually covers pointers to pointers, function pointers, dynamic memory management, bit manipulation, volatile and memory-mapped I/O, data structures, preprocessor techniques, multi-file builds, memory layout and debugging.
For embedded systems and system programming, learning C first builds a strong base in memory, pointers and hardware access, which makes C++ easier later. If your goal is mainly application development, starting with C++ is also reasonable.
Indian Institute of Embedded Systems – IIES