5. Embedded System Programming
Learning Objectives
- Explain why C remains the dominant embedded programming language over higher-level alternatives
- Write and trace GPIO digital I/O code, including pin configuration and read/write logic
- Explain how interrupts work and write a basic interrupt service routine (ISR)
- Distinguish polling from interrupt-driven design and identify which is appropriate for a given scenario
- Read sensor data over a digital protocol (e.g., OneWire/I2C) using a real library-based example
- Analyze embedded C code for common bugs: missing volatile, blocking delays, and stack overflow risk
Quick Answer
Embedded system programming is writing software that runs directly on constrained hardware, controlling GPIO pins, peripherals, and timing with a level of precision that application programming rarely requires. It's typically done in C (sometimes C++), because C compiles to compact, predictable machine code with direct access to memory and hardware registers — properties higher-level languages with garbage collectors and runtime environments can't guarantee. The core skills are configuring pins for input/output, reading sensors, driving actuators, handling interrupts for time-critical events, and managing severely limited memory (kilobytes, not gigabytes). Mastering embedded programming means thinking in terms of registers, cycles, and bytes, not just algorithms.
Why C Dominates Embedded Programming
C gives programmers three things that matter more in embedded contexts than almost anywhere else: direct memory/register access, predictable compiled output (no hidden runtime, no garbage collector pauses), and minimal footprint (a "Hello World" in C might be a few hundred bytes; the same in Python needs an entire interpreter). A garbage-collection pause of even a few milliseconds, harmless in a desktop app, can violate a hard real-time deadline in a motor controller. This is why virtually every microcontroller vendor ships a C toolchain first, with C++ and other languages (Rust, MicroPython) as secondary options for specific niches.
GPIO: Digital Input and Output
The most fundamental embedded programming task is controlling a General-Purpose Input/Output (GPIO) pin — reading a button, driving an LED, or toggling a signal line.
// Arduino/AVR-style GPIO — configure and toggle an output pin
#define LED_PIN 13
#define BUTTON_PIN 2
void setup() {
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP); // internal pull-up avoids floating input
}
void loop() {
int pressed = (digitalRead(BUTTON_PIN) == LOW); // active-low with pull-up
digitalWrite(LED_PIN, pressed ? HIGH : LOW);
}
The INPUT_PULLUP detail matters: without a pull-up or pull-down resistor, an unconnected digital input pin "floats" and reads random noise. This is a classic beginner bug — code that "randomly" toggles an LED even when nothing is pressed is almost always a floating input pin.
Polling vs. Interrupt-Driven Design
Polling means the CPU continuously checks a condition in a loop (as the button example above does). It's simple but wastes CPU cycles and can miss brief events if the loop is busy doing something else when the event occurs.
Interrupt-driven design lets hardware notify the CPU the instant an event occurs, pausing whatever code is running to service it immediately — critical for events that must never be missed, like a rotary encoder pulse or an emergency stop signal.
// ARM Cortex-M style (STM32) — interrupt-driven button handling
volatile uint8_t emergency_flag = 0; // volatile: modified inside an ISR
void EXTI0_IRQHandler(void) { // fires the instant PA0 pin changes
if (EXTI->PR & EXTI_PR_PR0) {
emergency_flag = 1; // set flag; keep ISR itself very short
EXTI->PR |= EXTI_PR_PR0; // clear the interrupt pending bit
}
}
int main(void) {
gpio_configure_interrupt(GPIOA, PIN_0, FALLING_EDGE);
while (1) {
if (emergency_flag) {
emergency_flag = 0;
disable_motor(); // real work done in main loop, not ISR
}
run_normal_control_loop();
}
}
Two rules this example enforces that students routinely miss: the shared flag must be volatile (the compiler cannot know an ISR changes it), and ISRs should do the minimum possible work (set a flag, clear the interrupt) and let the main loop handle the actual response — a long-running ISR blocks all lower-priority interrupts and can itself cause missed deadlines elsewhere.
Timers and Delays: Why delay() Is a Trap
Beginners rely on blocking delay() calls, but a call like delay(1000) freezes the entire program for a second — no button reads, no sensor checks, nothing. Real embedded code almost always uses non-blocking timing based on a free-running timer or the built-in millisecond counter:
unsigned long last_toggle = 0;
const unsigned long INTERVAL_MS = 500;
void loop() {
unsigned long now = millis();
if (now - last_toggle >= INTERVAL_MS) {
last_toggle = now;
digitalWrite(LED_PIN, !digitalRead(LED_PIN)); // toggle LED
}
check_button(); // this still runs every loop iteration —
check_sensor(); // unlike with delay(), nothing is blocked
}
This pattern — comparing elapsed time against millis() instead of calling delay() — is the single most important habit that separates "toy Arduino sketches" from real embedded firmware capable of doing more than one thing at a time without an RTOS.
Reading a Real Sensor: DS18B20 Digital Temperature Sensor
Practical embedded code often means talking to a sensor over a digital protocol. Here's reading a DS18B20 (a genuinely common temperature sensor) over the OneWire protocol:
#include <OneWire.h>
#include <DallasTemperature.h>
#define ONE_WIRE_BUS 2
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);
void setup() {
Serial.begin(9600);
sensors.begin();
}
void loop() {
sensors.requestTemperatures(); // triggers conversion (~750ms)
float tempC = sensors.getTempCByIndex(0);
if (tempC == DEVICE_DISCONNECTED_C) {
Serial.println("Sensor error: check wiring/pull-up resistor");
return;
}
Serial.print("Temperature: ");
Serial.print(tempC);
Serial.println(" C");
}
Note the error check: requestTemperatures() takes time to complete a conversion, and a disconnected or miswired sensor (a common real-world failure — the OneWire bus needs a 4.7kΩ pull-up resistor) returns a sentinel error value rather than crashing. Production embedded code always checks for this kind of hardware failure; hobbyist code that skips it will silently log garbage data when a wire comes loose.
Memory Discipline: Why Embedded C Avoids malloc
An 8-bit AVR has as little as 2KB of RAM. Repeated dynamic allocation (malloc/free) fragments this tiny heap over time, and a failed allocation deep into a multi-year deployment is far worse than a slightly larger fixed-size buffer decided at compile time. Idiomatic embedded C prefers static, compile-time-sized arrays:
// Preferred: fixed-size circular buffer, size known at compile time
#define BUFFER_SIZE 16
float sensor_buffer[BUFFER_SIZE];
uint8_t buffer_index = 0;
void store_reading(float value) {
sensor_buffer[buffer_index] = value;
buffer_index = (buffer_index + 1) % BUFFER_SIZE; // wraps automatically
}
Why It Matters
Every habit shown here — non-blocking timing, volatile on shared ISR variables, short ISRs, pull-up resistors on floating inputs, avoiding dynamic allocation — exists because embedded devices run unattended for months or years, often in safety-relevant roles. A desktop app that occasionally freezes for 50ms is annoying; a pacemaker firmware doing the same thing is a medical emergency. Embedded programming discipline is what turns "the code compiles and runs once on my bench" into "the code will still be correctly running in a customer's product three years from now."
Key Terms
| Term | Definition | Related Concept |
|---|---|---|
| GPIO | General-Purpose Input/Output — a pin configurable as digital input or output | pinMode, digitalWrite |
| Polling | Repeatedly checking a condition in a loop rather than waiting for a hardware signal | Interrupt-driven design |
| Interrupt Service Routine (ISR) | A function the CPU jumps to immediately when a specific hardware event occurs | volatile, interrupt latency |
| volatile | C keyword preventing the compiler from optimizing away accesses to a variable that can change unexpectedly (e.g., inside an ISR) | ISR, memory-mapped register |
| Blocking delay | A function like delay() that halts all program execution for a fixed time | Non-blocking timing, millis() |
| Pull-up/pull-down resistor | A resistor ensuring an unconnected digital input reads a defined logic level instead of floating | GPIO, floating input |
| Static allocation | Declaring fixed-size buffers at compile time instead of using malloc/free | Heap fragmentation |
Common Mistakes
Misconception: Using delay() is a fine way to control timing in any embedded program.
Why it's wrong: delay() blocks the entire program, meaning button presses, sensor readings, and other time-sensitive events are completely ignored for its entire duration — fine for a single-purpose toy sketch, disastrous for anything that must do more than one thing.
Correct understanding: Real embedded firmware uses non-blocking timing (comparing elapsed millis() against a stored timestamp) so the main loop remains responsive to all events continuously.
Misconception: A shared variable modified inside an ISR doesn't need any special declaration as long as the logic is correct.
Why it's wrong: Without volatile, the compiler may cache the variable's value in a CPU register during optimization, assuming it can't change "unexpectedly" — but an ISR changes it exactly that way, causing the main loop to read a stale, incorrect value.
Correct understanding: Any variable shared between an ISR and main code must be declared volatile so every access re-reads it from actual memory.
Misconception: An unconnected digital input pin reads as a stable LOW (or HIGH) by default. Why it's wrong: A floating (unconnected) digital input pin picks up electrical noise from its surroundings and can read randomly HIGH or LOW from moment to moment, causing "ghost" button presses or erratic behavior. Correct understanding: Always use an internal or external pull-up/pull-down resistor on digital inputs to guarantee a defined logic level when the input is not actively driven.
Comparison and Connections
| Aspect | Polling | Interrupt-Driven |
|---|---|---|
| CPU usage while idle | Wastes cycles continuously checking | CPU free to do other work until event fires |
| Response latency | Depends on loop speed; can miss brief events | Immediate (hardware-triggered) |
| Complexity | Simple to write and debug | Requires care with volatile, ISR brevity, race conditions |
| Best for | Simple, single-task systems; slow-changing inputs | Time-critical or infrequent events (e-stop, encoder pulses) |
Practice Questions
Recall
-
Why is
volatilerequired on a variable shared between an ISR and the main loop? Answer guidance: Without it, the compiler may optimize away or cache reads/writes to the variable, assuming its value cannot change outside normal program flow — but an ISR changes it asynchronously. -
What problem does an internal pull-up resistor solve for a digital input pin? Answer guidance: It prevents the pin from "floating" (reading unpredictable noise) when no external signal is actively driving it, guaranteeing a defined default logic level.
Understanding
-
Explain why
delay(1000)is considered bad practice in most real embedded firmware. Answer guidance: It blocks all other code execution for the full duration, so the program cannot respond to buttons, sensors, or communication during that time — unacceptable for any system doing more than one task. -
Why should an ISR do minimal work (e.g., just set a flag) rather than performing the full response to the triggering event? Answer guidance: A long ISR blocks lower (and sometimes equal) priority interrupts from being serviced and delays the main loop, risking missed deadlines elsewhere in the system; moving substantial work to the main loop keeps the ISR fast and predictable.
Application
-
Rewrite this blocking LED blink code to be non-blocking using
millis():digitalWrite(LED,HIGH); delay(500); digitalWrite(LED,LOW); delay(500);Answer guidance: Tracklast_toggle = millis(); in the loop, ifmillis() - last_toggle >= 500, toggle the LED state and updatelast_toggle, without blocking other code. -
A student's rotary encoder occasionally misses pulses when using polling in the main loop. Propose a fix and justify it. Answer guidance: Switch to interrupt-driven handling — attach an interrupt on the encoder's signal pin so pulses are captured the instant they occur, rather than depending on how fast the main loop happens to be looping when a pulse arrives.
Analysis
-
A DS18B20 temperature sensor intermittently returns
DEVICE_DISCONNECTED_C. Analyze what hardware and software factors could cause this and how the code should respond. Answer guidance: Likely causes include a missing/loose 4.7kΩ pull-up resistor on the OneWire data line, a loose wire connection, or insufficient power. Software should check for the sentinel value every read and log/handle the error rather than silently recording a garbage temperature. -
Compare the risk of using dynamic memory allocation (
malloc) versus a fixed-size circular buffer in firmware expected to run continuously for five years without a restart. Answer guidance:malloc/freerisks heap fragmentation over years of repeated allocation cycles, eventually causing an allocation failure at an unpredictable time — potentially catastrophic if undetected. A fixed-size circular buffer's memory usage is fully known and verified at compile time, eliminating this failure mode entirely, at the cost of a hard upper bound on buffered data.
FAQ
Why does my LED blink erratically even though my code looks correct?
This is almost always a floating input pin issue if a button or sensor is involved, or a missing volatile if an ISR modifies a variable the main loop reads. Check pull-up/pull-down resistors and volatile declarations before assuming a logic bug in the loop itself.
When should I use an RTOS instead of hand-rolled non-blocking millis() timing?
Non-blocking timing with millis() works well for a handful of independent periodic tasks. Once you need priority-based preemption, blocking waits on shared resources, or more than a few concurrent "tasks" with different timing needs, an RTOS (see Real-Time Operating Systems) manages that complexity far more reliably than hand-written state machines.
Is C++ ever a better choice than C for embedded programming? Yes, for larger projects benefiting from classes and encapsulation (e.g., driver abstraction layers), as long as costly features (exceptions, RTTI, unrestrained dynamic allocation) are avoided or disabled. Many production embedded codebases use a restricted "embedded C++" subset for exactly this reason.
How do I debug a variable that seems to have the wrong value, but only sometimes?
Suspect a race condition between an ISR and main code first — check for a missing volatile and for the possibility that the ISR modifies the variable mid-read. On some architectures, multi-byte variables (like a 32-bit counter on an 8-bit AVR) also need interrupts temporarily disabled during the read to avoid a torn read.
Why do some embedded code examples disable interrupts briefly with cli()/sei()?
This creates a critical section — a short window where an ISR cannot interrupt the current code — necessary when reading or writing a multi-byte value that an ISR also modifies, to prevent reading a "torn" value where only part of the bytes have been updated. It must be kept extremely brief since it delays all interrupt response during that window.
Quick Revision
- C dominates embedded programming due to direct hardware access, predictable compiled size, and no runtime overhead
- GPIO pins must be configured (input/output, pull-up/pull-down) before use; unconnected inputs float and read noise
- Polling wastes CPU and can miss fast events; interrupts respond immediately but must stay short
- Any variable shared between an ISR and main code must be
volatile - Blocking
delay()calls freeze the entire program; use non-blockingmillis()-based timing instead - Real sensor code (e.g., DS18B20) must check for hardware failure sentinel values, not assume success
- Avoid dynamic memory allocation in long-running embedded firmware; prefer fixed-size static buffers
- Critical sections (
cli()/sei()) protect multi-byte shared variables from torn reads by ISRs - ISRs should do minimal work — set a flag, clear the interrupt bit — and defer real processing to the main loop
- Embedded programming discipline exists because firmware runs unattended for years, often in safety-relevant roles
Related Topics
Prerequisites: Introduction to Embedded Systems, Embedded System Architecture, basic C programming
Related Topics: Real-Time Operating Systems, Embedded System Interfaces, Debugging Embedded Systems
Next Topics: Embedded System Interfaces (communication protocols in depth), Debugging Embedded Systems (finding bugs in the patterns shown here)