C Programming for
Microcontrollers
Learn C from Scratch by Building Real-World
Embedded Projects
By
STEM School
This Page Left Intentionally Blank
Contents
Chapter 1
Why Learn C with Microcontrollers?
Chapter 2
Setting Up Your Development Environment
Chapter 3
The C Language
Chapter 4
GPIO Programming – Blinking LEDs and Buttons
Chapter 5
Timers, Delays, and the System Clock
Chapter 6
Interrupts – Responding to Real-World Events
Chapter 7
Working with LCD Displays
Chapter 8
Analog Inputs – Sensors and ADCs
Chapter 9
Pulse Width Modulation (PWM)
Chapter 10
Serial Communication (UART, I2C, SPI)
Chapter 11
Real-Time Clock and Data Logging
Chapter 12
Building a Menu System for Embedded Interfaces
Chapter 13
Power Management Techniques
Chapter 14
Structs, Bitfields, and Unions
Chapter 15
Building a Wireless Temperature Monitor (Project)
Chapter 16
Interrupt-Driven Multitasking
Chapter 17
Embedded Debugging and Testing
Chapter 18
Capstone Project
Chapter 19
Going Beyond
Appendices
Appendix A
C Language Cheat Sheet
Appendix B
Microcontroller Pin Mapping and Registers
Appendix C
Common Embedded Errors and How to Fix Them
Appendix D
Datasheets and Schematic Reading Guide
Appendix E
Glossary of Embedded Terms
Chapter 1
Why Learn C with Microcontrollers?
The journey into microcontroller programming begins with a simple yet
profound question why C, and why microcontrollers? The answer lies in the
synergy between a powerful, minimalist programming language and a
compact, efficient computing device that brings code to life—physically.
This book is crafted not just to teach you C, but to show you how to apply it
in the real world, controlling lights, motors, sensors, and entire systems you
can touch and interact with. It’s a hands-on, project-driven approach that’s
ideal for learners who want to build both their coding knowledge and
engineering intuition.
The Power of C for Low-Level Programming
C has been the gold standard in systems programming for decades. It’s close
to the hardware, giving you control over memory, registers, and timing.
Unlike Python or Java, which abstract many hardware interactions, C places
you directly in the driver’s seat. You’ll learn how to manipulate registers, set
up interrupts, control timers, and write your own delay functions—skills that
are invaluable in embedded systems development.
C teaches you to think critically about performance and memory. Every
variable you declare has a cost; every loop can affect power consumption or
real-time responsiveness. This makes C not only a language but a discipline.
And it’s used widely—whether you're programming an 8-bit AVR chip, a 32bit ARM Cortex-M, or even writing firmware for medical devices and
spacecraft.
Here’s a quick comparison of C versus other common languages for
embedded systems
Feature
C
Python
Execution Speed
Very Fast
Memory Footprint
Real-Time Capable
Low
Yes
Slow
(interpreted)
High
No
Hardware Access
Direct
Indirect
Portability
High
compiler)
(with
Moderate
Java
Moderate
Moderate
Limited
Very
limited
High
Feature
Popularity
Embedded
C
in
Extremely High
Python
Low
Java
Low
In embedded programming, control is everything. Whether you're blinking an
LED with precise timing or managing power consumption in a batterypowered device, C is your best ally.
Bridge Between Code and the Physical World
A microcontroller is a tiny computer on a single integrated circuit (IC). It
has a CPU, RAM, ROM (or flash memory), timers, and various I/O ports—
all tightly packed into a single chip. These chips are designed to interact with
the real world taking input from sensors, processing that data, and producing
output through LEDs, displays, motors, or communication protocols like
UART, SPI, or I2C.
Learning to program microcontrollers means learning to build devices.
You’re not just writing software—you’re creating interactive systems.
Some of the microcontrollers we will work with in this book include
Architectur Popular UseProgrammin
Peripherals
e
Cases
g Language
Arduino UNO,
UART, SPI, C,
C++
ATmega328P
8-bit AVR
DIY
I2C, ADC (Arduino)
electronics
Robotics, IoT, DMA, USB,
STM32F103C8T 32-bit ARM
Real-time
Timers,
C
6
Cortex-M3
systems
CAN
Industrial
PWM,
C (MPLAB
PIC16F877A
8-bit PIC
automation,
ADC,
XC8)
appliances
UART
Microcontroller
These microcontrollers have different architectures and capabilities, and
working with all three will give you a broad understanding of embedded
development.
Theory to Real-World Projects
Throughout this book, we will use hands-on projects to teach you both the C
language and microcontroller programming. Each chapter builds your skills
by tackling real problems. You’ll start with basic input/output, move on to
timers and interrupts, and eventually build full systems involving displays,
sensors, and communication.
Here’s a roadmap of what you’ll build
Project
Number
Project 1
Project 2
Project 3
Project 4
Project 5
Project 6
Project 7
Project 8
Project Title
Blink an
Delays
LED
Microcontroller
with
Skills Gained
GPIO,
delay
loops, toggling
Input
pins,
Button-Controlled LED ATmega328P
debouncing, logic
control
ADC,
display
STM32F103C8T
Digital Thermometer
output,
6
temperature math
Timers,
control
Traffic Light Simulation PIC16F877A
flow, multi-output
logic
STM32F103C8T PWM, duty cycle,
Servo Motor Control
6
control algorithms
Serial interface,
UART-Based
Serial
ATmega328P
terminal
Communication
interaction
I2C,
data
I2C Communication with STM32F103C8T
transmission,
LCD Module
6
screen control
Interrupts,
Build a Digital Stopwatch
PIC16F877A
counters, precise
with Interrupts
timing
ATmega328P
Each of these projects is designed to deepen your understanding of embedded
systems while making learning fun and productive.
Development Tools You’ll Need
To start working on these projects, you’ll need a few essential tools. Below
is a list of hardware and software that we will use throughout the book.
Hardware
Component
Purpose
ATmega328P
(Arduino
Entry-level 8-bit microcontroller board
Uno)
STM32F103C8T6
(Blue
32-bit ARM Cortex-M3 board
Pill)
Programming
and
debugging
PIC16F877A + PICKit3
microcontrollers
Breadboard and Jumper
Prototyping circuits
Wires
PIC
LEDs, Resistors, Buttons
Basic components for control and output
LCD Display (16x2)
For display-based projects
DHT11 Temperature Sensor For sensing environment
Servo Motor
For motor control experiments
Software
Software
MPLAB X IDE + XC8
STM32CubeIDE
Platform
Purpose
Windows/Linu Programming
x
microcontrollers
Windows/Linu STM32
programming
x
debugging
PIC
and
Atmel Studio or Arduino
Cross-platform ATmega328P programming
IDE
PuTTY / TeraTerm
Cross-platform Serial monitor for UART
Windows/Linu Circuit simulation and PCB
Proteus / KiCAD
x
design
Your Learning Journey Starts Now
This book is not about just reading and understanding. It is about doing. As
you wire up components, upload code, and troubleshoot circuits, you’ll gain
real, tactile experience that no simulation or online tutorial can provide.
By the end of this book, not only will you be comfortable writing C code for
various microcontrollers, but you will also have built a small library of
working embedded systems projects. These will serve as the foundation for
more complex developments—home automation, robotics, wearable tech,
IoT, and more. This journey starts with one blinking LED. But don’t be
fooled by its simplicity. That LED is your first step into the world of
embedded systems—a world where code meets hardware, and creativity
knows no bounds.
Chapter 2
Setting Up Your Development Environment
Before diving into the world of embedded systems programming with C, it’s
crucial to set up a solid development environment. This chapter will walk
you step by step through the process of preparing your computer, choosing the
right toolchain, installing compilers and debuggers, and finally building and
flashing your very first project—blinking an LED. This simple yet powerful
project will be your "Hello, World!" in the realm of microcontroller
programming. Along the way, you’ll become familiar with how source code
is transformed into machine instructions and physically deployed onto a
microcontroller chip.
Understanding the Toolchain
In embedded C development, a toolchain is a collection of software tools
that work together to compile and upload code to your microcontroller. It
generally includes a text editor or IDE (Integrated Development
Environment), a compiler, a linker, and a programmer/debugger interface.
The typical workflow in embedded C development involves writing your
code, compiling it into a binary (.hex or .bin) file, flashing this file to the
microcontroller, and then debugging the program either in real time or
through a serial interface.
To make your journey diverse and insightful, this book will guide you through
three different environments one for AVR microcontrollers, one for PIC
microcontrollers, and one for STM32 microcontrollers. Each has a
different setup and workflow, which helps you gain broad, real-world
experience.
Setting Up AVR-GCC with Arduino Uno
The AVR-GCC toolchain is one of the most widely used platforms for
programming AVR-based microcontrollers like the ATmega328P. This chip
is also the heart of the Arduino Uno, which we'll use due to its simplicity and
availability.
Required Software and Tools
Tool
Purpose
Download Link
AVR-GCC To compile C code https //www.microchip.com/en-us/toolsCompiler into machine code resources/develop/avr-gcc-compiler
To upload .hex file Included with AVR toolchains or Arduino
AVRDUDE
to the board
IDE
Arduino as Flash ATmega328P
Arduino IDE (https //arduino.cc)
ISP
via USB
A Simple Writing code (or use
Any platform
Text Editor VS Code)
Circuit Setup
Connect an LED with a 220Ω resistor in series to digital pin 13 (PB5) of
your Arduino Uno. Connect the other end to GND.
Writing the Code
Create a new C file named main.c and write the following code
#define F_CPU 16000000UL
#include <avr/io.h>
#include <util/delay.h>
int main(void) {
DDRB |= (1 << PB5); // Set PB5 (digital pin 13) as output
while (1) {
PORTB |= (1 << PB5); // Turn LED on
delayms(500); // Delay 500 ms
PORTB &= ~(1 << PB5); // Turn LED off
delayms(500); // Delay 500 ms
}
}
Compiling the Code
Use the following terminal commands
avr-gcc -mmcu=atmega328p -Os -o main.elf main.c
avr-objcopy -O ihex -R .eeprom main.elf main.hex
Flashing the Code
Using AVRDUDE
avrdude -c arduino -p m328p -P COM3 -b 115200 -U flash w main.hex
Replace COM3 with the correct port name.
Setting Up MPLAB X for PIC Microcontrollers
MPLAB X is Microchip’s official IDE for programming PIC
microcontrollers. It provides an all-in-one environment that supports
simulation, debugging, and code development using the XC8 compiler.
Required Software
Tool
Purpose
Download Link
Tool
Purpose
Download Link
https
//www.microchip.com/enMPLAB X Main development
us/tools-resources/dev-tools/mplab-xIDE
environment
ide
MPLAB
https
//www.microchip.com/enTo compile C for 8XC8
us/tools-resources/dev-tools/mplab-xcbit PICs
Compiler
compilers
PICKit 3 or Programmer/debugge
Hardware device
4
r for PIC chips
First Project Blinking an LED with PIC16F877A
Wire an LED to RC0 (pin 15) through a 330Ω resistor.
In MPLAB X
1. Create a new project for PIC16F877A.
2. Use MPLAB Code Configurator or manual code entry.
3. Write this code in main.c
#include <xc.h>
#define XTALFREQ 8000000
void main(void) {
TRISC0 = 0; // Set RC0 as output
while(1) {
RC0 = 1;
_delayms(500);
RC0 = 0;
_delayms(500);
}
}
4. Build the project.
5. Flash the hex file using PICKit 3 and MPLAB IPE.
This confirms your toolchain is functional, and you’ve programmed your first
embedded “Hello, World!”
STM32CubeIDE for STM32 Microcontrollers
STM32CubeIDE is a free and comprehensive development environment
from STMicroelectronics for STM32 microcontrollers, based on Eclipse. It
integrates with STM32CubeMX for graphical configuration of peripherals.
Required Tools
Tool
Purpose
Download Link
IDE
+
https //www.st.com/en/developmentSTM32CubeIDE configuration
tools/stm32cubeide.html
utility
Flashing
and
ST-Link Utility
Included in STM32CubeIDE
debugging STM32
STM32F103C8T
Available online (cheap and
Blue Pill board
6 Board
reliable)
Circuit Setup
Connect the onboard LED (usually on pin PC13).
Creating the Project
1. Open STM32CubeIDE and start a new project with
STM32F103C8Tx.
2. Configure PC13 as GPIO_Output using the Pinout & Configuration
tab.
3. Generate code and open main.c .
4. Add the following code inside the main loop
while (1) {
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
HAL_Delay(500);
}
5. Build the project.
6. Connect the board via ST-Link and click "Debug" to flash the
firmware.
Now your onboard LED will blink every half-second, confirming the STM32
environment is ready.
Troubleshooting Common Issues
If the LED doesn’t blink after flashing the code, check the following
Problem
Possible Cause
Suggested Fix
LED not lighting Incorrect GPIO pin or Check schematic
and
up
resistor too large
verify code
Misconfigured MCU or Double-check
project
Compilation error
headers
settings
Programmer
not USB driver missing or Reinstall drivers or change
recognized
wrong port
USB cable
Wrong COM port or wiring Use correct port, check
Upload error
issue
serial setup
Learning by Doing Skill Gained from “Blink”
Though blinking an LED might seem like a trivial task, it is actually
foundational in embedded development. It teaches you about
Setting up toolchains and understanding how source code gets
converted to machine code.
Navigating IDEs and working with microcontroller-specific
settings.
GPIO configuration, timing functions, and logic control.
The process of compiling, flashing, and verifying code on real
hardware.
In later chapters, this workflow will scale up to managing interrupts,
handling serial communication, and controlling motors or sensors. But this
first project lays the groundwork for everything to come.
This chapter guided you through selecting and configuring the right toolchain
for embedded C development, based on the microcontroller platform of your
choice—AVR with avr-gcc, PIC with MPLAB X and XC8, or STM32 with
STM32CubeIDE. You’ve successfully written, compiled, and flashed your
very first embedded C program that physically interacts with hardware.
The LED blink project may be simple in scope, but it represents a
monumental step in understanding embedded systems—bringing together
software and hardware in a single, powerful learning experience. Let this
LED be your beacon as you continue your journey toward building
intelligent, real-world embedded applications.
Chapter 3
The C Language – A Foundation for Embedded
Systems
To truly master embedded systems, one must first become proficient in the C
programming language. C is not just another general-purpose language; it is
the language of hardware, providing a fine balance between high-level
abstraction and low-level control. Unlike languages that isolate you from the
physical world, C brings you close to the metal. It allows you to manipulate
bits and bytes, set registers, configure memory, and communicate with
peripherals—skills that are essential for embedded systems development.
This chapter introduces the C language from the ground up, with a hands-on
focus. Every concept is taught using embedded-friendly examples that run on
real microcontrollers like the ATmega328P (Arduino Uno), STM32F103
(Blue Pill), and PIC16F877A. This practical approach ensures that you not
only understand the syntax but also how it translates into hardware-level
behavior.
Variables and Data Types in Embedded C
Variables in C represent named memory locations. These are used to store
data temporarily while the microcontroller is running a program. In
embedded systems, choosing the correct data type is critical because memory
and performance are limited. Here’s a table of standard C data types and
their common use in embedded systems
Data
Type
Size
(bits)
char
8
long
16
32
32
float
32
uint8_t
8
int16_t
16
int
Range
Use in Embedded Systems
-128 to 127 or 0 Often used for ASCII characters or
to 255
single-byte data
or PlatformGeneral-purpose counters, loops
dependent
Large numbers Time tracking, large arrays
Rare in embedded due to size, unless
Decimal numbers
necessary
Exact-size integer, often preferred in
0 to 255
embedded code
-32,768
to Signed 16-bit values for timers,
32,767
counters, etc.
On microcontrollers, we usually include stdint.h to use fixed-width data types
like uint8_t , int16_t , etc., which improve portability and predictability.
Hands-On Code (ATmega328P – Arduino Uno)
#include <avr/io.h>
#include <util/delay.h>
#include <stdint.h>
int main(void) {
uint8_t ledState = 0;
DDRB |= (1 << PB5); // Set PB5 as output (Arduino pin 13)
while (1) {
ledState = !ledState;
if (ledState) {
PORTB |= (1 << PB5);
} else {
PORTB &= ~(1 << PB5);
}
delayms(500);
}
}
This example shows how a uint8_t variable controls the LED state and
demonstrates basic data types and bit manipulation.
Control Flow if , while , for – Embedded Logic at Work
Control flow allows your embedded application to make decisions, repeat
tasks, or respond to input. In embedded systems, control flow is crucial for
managing sensors, motors, timing events, and more.
if Statement
The if structure is used to make decisions.
if (sensorValue > 100) {
activateMotor();
}
while Loop
A while loop continues as long as a condition is true. It’s often used in the
main execution loop of embedded applications.
while (1) {
readSensors();
controlOutputs();
delayms(100);
}
for Loop
The for loop is used for tasks that need to run a specific number of times,
such as initializing multiple pins.
for (int i = 0; i < 8; i++) {
DDRC |= (1 << i); // Set PORTC pins as output
}
Example Project Blinking Multiple LEDs in a Pattern (ATmega328P)
Wire 8 LEDs to PORTD (digital pins 0–7). The following program will
blink them in a running-light pattern
#include <avr/io.h>
#include <util/delay.h>
int main(void) {
DDRD = 0xFF; // Set all PORTD pins as outputs
while (1) {
for (uint8_t i = 0; i < 8; i++) {
PORTD = (1 << i);
delayms(200);
}
}
}
This demonstrates the use of for loops and bit shifting—a critical concept in
embedded development.
Functions Organizing Embedded Code
Functions help you break down a program into reusable sections. In
embedded systems, using functions improves clarity, debugging, and
modularity. For example, you can create functions to initialize hardware,
control motors, or process sensor data.
void initLED(void) {
DDRB |= (1 << PB5);
}
void toggleLED(void) {
PORTB ^= (1 << PB5);
}
Calling these in main()
int main(void) {
initLED();
while (1) {
toggleLED();
delayms(500);
}
}
By modularizing the code, you make it easier to maintain and reuse. This is
particularly useful when dealing with complex sensor libraries or
communication protocols.
Input/Output with Serial Communication
Input and output go beyond blinking LEDs. One of the most important
interfaces for an embedded device is serial communication, often used to
send messages to and from a computer or another microcontroller.
On an AVR microcontroller like the ATmega328P, serial communication can
be done via the built-in UART (Universal Asynchronous ReceiverTransmitter). Here’s how to set it up manually
Basic Serial Initialization and Transmission (AVR)
#include <avr/io.h>
void USART_init(unsigned int ubrr) {
UBRR0H = (unsigned char)(ubrr >> 8);
UBRR0L = (unsigned char)ubrr;
UCSR0B = (1 << TXEN0); // Enable transmitter
UCSR0C = (1 << UCSZ01) | (1 << UCSZ00); // 8-bit data
}
void USART_transmit(unsigned char data) {
while (!(UCSR0A & (1 << UDRE0))); // Wait for buffer
UDR0 = data;
}
void USART_print(const char *str) {
while (*str) {
USART_transmit(*str++);
}
}
int main(void) {
USART_init(103); // 9600 baud at 16MHz
while (1) {
USART_print("Hello from AVR!\r\n");
delayms(1000);
}
}
Explanation of UBRR Value
The UBRR value sets the baud rate for serial communication. It is calculated
using
UBRR = (F_CPU / (16 * BAUD)) - 1
At 16 MHz and 9600 baud, this yields UBRR = 103 .
Hardware Setup
Connect the ATmega328P’s TX pin (pin 3, PD1) to a USB-toSerial converter.
Open a serial terminal (like PuTTY or the Arduino Serial
Monitor) on your PC.
This program continuously sends the string "Hello from AVR!" every second,
demonstrating real-time communication between your embedded device and
a host computer.
Visualizing Data Flow in Embedded Programs
To help you understand how C syntax translates to embedded behavior, here's
a diagram showing a simple embedded execution loop
You’ve now seen how fundamental C language constructs like variables, data
types, control flow, and functions directly control hardware behavior in
embedded systems. By writing code that interacts with GPIO pins and
transmits serial data, you are not just learning syntax—you are building a
foundation for real-time, real-world embedded applications.
This chapter forms the base for more advanced topics like interrupt handling,
analog-to-digital conversion, and communication protocols. In the next
chapter, we’ll explore how memory and registers work in embedded
microcontrollers and how direct memory access can optimize your system
even further. Your skill has leveled up—from understanding syntax to
controlling silicon. Keep practicing, experimenting, and watching the LEDs
blink to the rhythm of your code.
Chapter 4
GPIO Programming – Blinking LEDs and Buttons
In the realm of embedded systems, the first real bridge between code and the
physical world often comes in the form of GPIO programming. GPIO stands
for General Purpose Input/Output, and it is the fundamental means by
which microcontrollers interact with the outside world. Whether you're
lighting up LEDs, reading button presses, sensing a signal from another
board, or controlling a motor driver—GPIO is your gateway.
This chapter will walk you through everything you need to know to master
GPIO on embedded microcontrollers like the AVR (ATmega328P) and the
STM32 (Blue Pill, STM32F103). We’ll dive deep into understanding how
PORTx, DDRx, and PINx registers work for AVR, and how STM32 uses
HAL libraries and registers. We’ll build a complete project a buttoncontrolled LED system, complete with debounce logic, internal pull-up
resistors, and edge detection.
Understanding GPIO From Concept to Execution
A GPIO pin can be configured either as an input (to read sensors, buttons,
etc.) or an output (to control LEDs, motors, etc.). GPIO pins can have
several states high (logic 1), low (logic 0), floating, or pulled-up/down. In
embedded C, the state and direction of these pins are controlled through
special memory-mapped registers.
For AVR microcontrollers, each GPIO port (like PORTB, PORTC, or
PORTD) has three key registers
Registe
r
DDRx
PORTx
PINx
Function
Data Direction Register. 1 = Output, 0 = Input
Port Data Register. Writes output value or enables pullup
Pin Input Register. Reads the input value of the pin
Let’s take an example using PORTB on an ATmega328P. Suppose you want
to make Pin 5 (PB5) an output to drive an LED.
Here’s a breakdown of what each instruction does
DDRB |= (1 << PB5); // Set PB5 as OUTPUT
PORTB |= (1 << PB5); // Set PB5 HIGH (turn LED ON)
PORTB &= ~(1 << PB5); // Set PB5 LOW (turn LED OFF)
To read an input, such as a push button on Pin 4 (PB4)
DDRB &= ~(1 << PB4); // Set PB4 as INPUT
PORTB |= (1 << PB4); // Enable internal pull-up resistor
uint8_t button = PINB & (1 << PB4); // Read the pin
In STM32 (e.g., STM32F103), GPIOs are accessed via HAL (Hardware
Abstraction Layer) or direct register manipulation. Here’s how you
configure a pin using HAL
PIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOC_CLK_ENABLE(); // Enable clock for GPIOC
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // LED ON
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); // LED OFF
In both architectures, the concept is the same set the pin direction, and either
read or write its state. The syntax varies, but the principles remain universal.
Button-Controlled LED with Debounce
Let us now build a complete embedded project using GPIOs a button that
controls an LED, with proper debouncing, pull-up configuration, and edge
detection.
AVR (ATmega328P - Arduino Uno) Direct Register Approach
Hardware Setup
Componen
Connection
t
LED
PB5 (Pin 13), with 220Ω resistor
PB4 (Pin 12) to GND (with internal pullPush Button
up)
Circuit Diagram
Code
#include <avr/io.h>
#include <util/delay.h>
#include <stdint.h>
#define LED_PIN PB5
#define BUTTON_PIN PB4
void initIO() {
DDRB |= (1 << LED_PIN); // LED as output
DDRB &= ~(1 << BUTTON_PIN); // Button as input
PORTB |= (1 << BUTTON_PIN); // Enable internal pull-up
}
uint8_t debounceButton() {
if (!(PINB & (1 << BUTTON_PIN))) { // Button pressed (active low)
delayms(20); // Debounce delay
if (!(PINB & (1 << BUTTON_PIN))) {
while (!(PINB & (1 << BUTTON_PIN))); // Wait for release
delayms(20);
return 1;
}
}
return 0;
}
int main(void) {
initIO();
while (1) {
if (debounceButton()) {
PORTB ^= (1 << LED_PIN); // Toggle LED
}
}
}
This code implements edge detection (waits for button release) and debounce
logic (to eliminate false triggering due to mechanical contact noise).
STM32 (STM32F103 Blue Pill) Using HAL
Wiring
LED Connect to PC13 (Onboard LED)
Button Connect a push button to PA0 with a 10K pull-down resistor
or use internal pull-up if configured.
Code (STM32CubeIDE)
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
while (1) {
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) {
HAL_Delay(20); // Debounce delay
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) {
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET); // Wait for
release
HAL_Delay(20);
}
}
}
}
Initialization
void MX_GPIO_Init(void) {
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_NOPULL;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}
This project solidifies key embedded systems concepts GPIO input and
output, hardware setup, software debouncing, and real-time responsiveness.
What is Debouncing and Why Does It Matter?
Mechanical switches don’t make or break contact cleanly. When pressed, the
signal can bounce between 0 and 1 for a few milliseconds. Without debounce
logic, your code may misinterpret one press as multiple. There are two
debounce techniques Software Debounce Add a delay (10–50 ms) after
detecting a press and wait for signal stability.
Hardware Debounce Use an RC low-pass filter or Schmitt trigger IC to
smooth the input.
Software debounce is the most accessible and often good enough for most
embedded tasks.
Internal Pull-up Resistors
Many microcontrollers, including AVR and STM32, allow you to activate an
internal pull-up resistor on GPIO input pins. This means you don’t need an
external resistor. It keeps the pin HIGH when the button is unpressed, and it
goes LOW when the button connects it to ground.
On AVR, it’s done using
PORTB |= (1 << PB4); // Pull-up enabled
On STM32 (if using LL or HAL drivers), you configure it using the
GPIO_InitStruct’s Pull = GPIO_PULLUP .
Edge Detection Responding Only on Change
Edge detection means triggering an action only when the signal changes—
from low to high or high to low. In our debounce examples, we used a
software technique for edge detection wait for button press, confirm, and
wait until release before re-triggering. In more advanced chapters, we’ll use
interrupts for real-time, hardware-driven edge detection.
This chapter has introduced GPIO programming as your first hands-on
interface with the real world through microcontrollers. You’ve learned how
to configure input and output pins, how to debounce button signals, and how
to toggle LEDs using user input. These are not merely blinking lights—they
are the foundations of sensor-driven systems, user interfaces, automation
controllers, and interactive devices. By mastering these GPIO basics and
applying them in real projects, you gain more than just programming skill—
you gain confidence in hardware-software integration, which is at the heart
of embedded systems. In the next chapter, we’ll take things further by diving
into timers, PWM (Pulse Width Modulation), and how microcontrollers
perceive and generate time-based signals.
Chapter 5
Timers, Delays, and the System Clock
In the world of embedded systems, time is everything. Whether you're
blinking an LED, sampling data from a sensor, generating a PWM signal for a
motor, or building a multitasking scheduler, all of these tasks require precise
control over time. This chapter focuses on one of the most essential features
in any microcontroller Timers.
Timers are versatile peripherals that help you measure time, generate
accurate delays, and schedule repetitive or one-shot events. They are not
only useful for creating simple delay loops but also critical for building
Pulse Width Modulation (PWM) signals, implementing debouncing routines,
generating tones, creating real-time clocks, and scheduling periodic tasks.
This chapter will take you step by step through both software-based timing
(using loops and delays) and hardware-based timing (using internal timer
peripherals). We'll work hands-on with AVR and STM32 microcontrollers,
using both register-level and HAL-based approaches. We'll also guide you
through building two practical projects a fading LED using PWM and a
software-based stopwatch using a timer interrupt.
1. Understanding the System Clock
Every microcontroller runs on a clock signal. This signal determines how
fast the CPU can process instructions and how peripherals like timers and
UART operate. The system clock is often derived from an internal oscillator
or an external crystal oscillator.
On the ATmega328P (used in Arduino Uno), the typical clock frequency is
16 MHz when using the external crystal. For STM32F103, the clock can be
scaled up to 72 MHz using PLL (Phase Locked Loop) from an 8 MHz crystal.
Understanding the system clock is vital because timer calculations depend
directly on this clock speed.
2. Software Delays The Beginner’s Approach
The simplest way to create a delay is using software loops. This is often
used in beginner projects but lacks precision because it's affected by code
execution speed and compiler optimizations.
void delay_ms(uint16_t ms) {
for(uint16_t i = 0; i < ms; i++) {
delayms(1); // Available from <util/delay.h> in AVR
}
}
Although useful for prototyping, software delays block the CPU, meaning no
other tasks can run while the delay function is executing. This makes them
unsuitable for real-time multitasking applications.
3. Using Hardware Timers for Precise Control
Hardware timers solve the limitations of software delays. Each timer is a
small counter that increments at a specific rate, driven by the system clock
and scaled down using a prescaler.
Timer Basics (AVR)
Timer/Counter0 is an 8-bit timer (counts 0–255)
Timer/Counter1 is a 16-bit timer (counts 0–65535)
Prescaler options 1, 8, 64, 256, 1024
To create a delay using Timer0 on ATmega328P
void init_timer0() {
TCCR0A = 0x00;
TCCR0B = (1 << CS01) | (1 << CS00); // Prescaler = 64
TCNT0 = 0; // Reset counter
while (TCNT0 < 250); // Wait till counter reaches 250
}
This will cause an approximate delay of
Delay = (Prescaler × Count) / Clock Frequency
Delay = (64 × 250) / 16,000,000 = 1 ms (approx)
You can use such loops to build precise delay functions without blocking
other operations.
4. PWM – Pulse Width Modulation
Pulse Width Modulation is a technique where the amount of power delivered
to a device is controlled by varying the on-off duty cycle of a square wave
signal. PWM is widely used in LED dimming, motor speed control, servo
motors, and signal generation.
Table PWM Parameters
Term
Description
Number of PWM cycles per second
Frequency
(Hz)
Duty
Percentage of time the signal is HIGH
Cycle
Resolution Number of steps between 0% and 100%
Let’s set up PWM using Timer1 on AVR
void setup_pwm() {
DDRB |= (1 << PB1); // Set PB1 (OC1A) as output
TCCR1A = (1 << COM1A1) | (1 << WGM10); // Fast PWM 8-bit
TCCR1B = (1 << WGM12) | (1 << CS11); // Prescaler 8
OCR1A = 128; // 50% Duty Cycle
}
To change the brightness of an LED, vary OCR1A between 0 (OFF) and 255
(Full ON). This creates a fading effect when done in a loop.
5. Project Fading LED Using PWM
Let’s create a visually impressive project using PWM a fading LED.
Hardware Connections
Pin
Function
PB Connected to LED through
1 resistor
220Ω
Code
void fade_led() {
setup_pwm();
while (1) {
for (uint8_t i = 0; i < 255; i++) {
OCR1A = i;
delayms(10);
}
for (uint8_t i = 255; i > 0; i--) {
OCR1A = i;
delayms(10);
}
}
}
This loop gradually increases and decreases the brightness of the LED using
PWM, creating a fading effect.
6. Project Software Stopwatch Using Timer
Interrupt
Let’s take timing control one step further by building a software stopwatch
using timer interrupts. This demonstrates how to execute timed code without
blocking the CPU.
Step 1 Configure Timer Interrupt (AVR Timer1)
volatile uint32_t millis = 0;
ISR(TIMER1_COMPA_vect) {
millis++;
}
void setup_timer_interrupt() {
TCCR1A = 0;
TCCR1B = (1 << WGM12) | (1 << CS11); // CTC Mode, Prescaler 8
OCR1A = 1999; // 1 ms interrupt @ 16MHz
TIMSK1 = (1 << OCIE1A); // Enable interrupt
sei(); // Enable global interrupts
}
This interrupt fires every 1 ms. You can now use millis like a software clock
uint32_t start_time = millis;
// wait until 5 seconds have passed
while (millis - start_time < 5000);
This allows multitasking and accurate time measurement, such as for a
stopwatch or real-time event tracker.
7. STM32 Timers and PWM (HAL)
STM32 timers are more advanced. Let’s configure Timer2 for PWM output
TIM_HandleTypeDef htim2;
void MX_TIM2_Init(void) {
htim2.Instance = TIM2;
htim2.Init.Prescaler = 7199;
htim2.Init.Period = 999;
htim2.Init.ClockDivision = 0;
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
HAL_TIM_PWM_Init(&htim2);
TIM_OC_InitTypeDef sConfigOC = {0};
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 500; // 50% Duty Cycle
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}
This setup generates a PWM signal on PA0. You can fade an LED by
gradually changing the pulse value ( sConfigOC.Pulse ) over time.
By the end of this chapter, you’ve gained mastery over both software and
hardware timing mechanisms. You understand how the system clock
influences timer behavior, how to configure timers for delays and interrupts,
how to use PWM for analog-like output, and how to build practical projects
that demonstrate real-world timing applications. These skills are
foundational for building responsive, power-efficient, and accurate
embedded applications. Whether you're making a robot, a digital clock, or an
industrial controller, timing is everything—and now you have the tools to
control it. In the next chapter, we will build on this by exploring interrupts,
giving you the ability to respond to asynchronous events in real time.
Chapter 6
Interrupts – Responding to Real-World Events
In the realm of embedded systems, real-world events do not wait for the
processor to check for their occurrence. Instead, they often require immediate
attention. This is where interrupts become essential. Interrupts allow a
microcontroller to pause what it is doing and execute a piece of code
specifically designed to handle high-priority tasks—then resume its previous
activity as if nothing happened. This chapter will help you master interrupts,
one of the most powerful features of microcontrollers, by explaining how
they work, how to configure them, and how to use them in real-world
applications like button detection, sensors, communication, and more.
We will explore external and internal interrupts, understand the concept of
vector tables, Interrupt Service Routines (ISRs), priority levels, and
power-efficient interrupt handling. You will learn how to use interrupts
through practical examples and hands-on projects that allow you to react to
events like button presses, timer overflows, or communication requests in
real-time.
What Are Interrupts?
An interrupt is a signal that temporarily halts the CPU’s current operations
and diverts it to execute a special piece of code known as an Interrupt
Service Routine (ISR). Once the ISR is executed, the microcontroller
resumes its previous operation. This capability is fundamental for building
responsive systems that interact with the real world, such as reacting
instantly to a button press or receiving data from a sensor without
continuously polling it.
Consider an example where the microcontroller is controlling an LED blink
pattern while waiting for a button press to stop the pattern. Instead of
checking the button status in every iteration (polling), you can set up an
interrupt that triggers instantly when the button is pressed, saving time and
energy.
Interrupt Vector Table and ISRs
Each interrupt source is mapped to a specific address in memory. This is
known as the interrupt vector table. When an interrupt occurs, the
processor looks up the corresponding address in this table and jumps to that
function. For instance, in the ATmega328P (used in Arduino Uno), the vector
table might look like this
Interrupt Source
Reset
External Interrupt 0
Timer/Counter0
Overflow
Vector
Address
0x0000
0x0002
ISR Function
RESET_vect
INT0_vect
TIMER0_OVF_vec
t
0x001A
Each ISR must be defined with a specific function name and syntax
ISR(INT0_vect) {
// Code to execute when external interrupt 0 triggers
}
This function will be executed automatically when the event occurs.
External Interrupts – Reacting to Button Presses
External interrupts are generated by external pins connected to events like
button presses or sensors. On AVR, pins like INT0 (PD2 on Arduino Uno)
can be configured to trigger on a rising edge, falling edge, or logic change.
Let’s walk through how to configure and use an external interrupt on INT0 to
toggle an LED when a button is pressed.
Step 1 Hardware Setup
Pin
Description
PD Connected to Push
2 (INT0)
PB0 Connected to LED
Button
You should use an external pull-down resistor (e.g., 10kΩ) to keep the pin
low when the button is not pressed.
Step 2 Code (AVR-GCC)
#include <avr/io.h>
#include <avr/interrupt.h>
void setup_interrupt() {
DDRD &= ~(1 << PD2); // INT0 as input
PORTD |= (1 << PD2); // Enable pull-up resistor
DDRB |= (1 << PB0); // PB0 as output for LED
EICRA |= (1 << ISC01); // Falling edge on INT0
EIMSK |= (1 << INT0); // Enable INT0
sei(); // Enable global interrupts
}
ISR(INT0_vect) {
PORTB ^= (1 << PB0); // Toggle LED
}
Now when the button is pressed, the LED toggles instantly without the need
to poll the button.
Debouncing in Interrupts
One of the biggest challenges with physical buttons is debouncing. When a
button is pressed, it doesn’t create a clean signal. Instead, it bounces, sending
multiple transitions in milliseconds. In interrupts, you should handle this by
ignoring additional triggers for a brief time.
volatile uint32_t last_interrupt_time = 0;
ISR(INT0_vect) {
uint32_t current_time = millis();
if ((current_time - last_interrupt_time) > 200) { // 200ms debounce
PORTB ^= (1 << PB0);
last_interrupt_time = current_time;
}
}
You’ll need a timer interrupt (like from Chapter 5) to track milliseconds if
millis() is not natively available.
Internal
Interrupts
–
Timers,
ADC,
UARTInterrupts are not limited to external pins.
Internal peripherals like timers, ADC converters,
and serial ports can also generate interrupts.
For example, a timer overflow interrupt can be used to trigger events every
second. Here's how to use Timer0 for 1-second events
ISR(TIMER0_OVF_vect) {
// Called every time Timer0 overflows
}
This technique is perfect for multitasking or periodic tasks like blinking an
LED, checking sensor data, or updating a display.
STM32 Interrupts – Using HAL
On STM32 platforms using HAL libraries, interrupts are configured in
layers. Let’s consider a button on PA0 that triggers an interrupt.
Step 1 Configure GPIO as EXTI
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_RISING;
GPIO_InitStruct.Pull = GPIO_NOPULL;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
Step 2 Enable Interrupt in NVIC
HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
Step 3 Define the ISR
void EXTI0_IRQHandler(void) {
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
if (GPIO_Pin == GPIO_PIN_0) {
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
}
}
This is how interrupts are modularly handled in STM32 with callbacks. This
allows flexibility and readable code architecture.
Power-Efficient Interrupt Handling
One of the core benefits of using interrupts is power efficiency. Instead of
keeping the CPU awake constantly, polling for events, the microcontroller
can enter a low-power sleep mode and wake up only when an interrupt
occurs.
For AVR
set_sleep_mode(SLEEP_MODE_PWR_DOWN);
sleep_enable();
sleep_cpu(); // Sleeps until an interrupt occurs
For STM32, the HAL_PWR_EnterSLEEPMode() API can be used similarly. This is
ideal for battery-powered systems where saving energy is essential, such as
wireless sensor nodes or portable devices.
Project Interrupt-Driven Button and LED
Let’s build a practical project that combines everything learned in this
chapter.
Project Overview A button toggles an LED using an interrupt. The LED also
blinks every 1 second using a timer interrupt. We’ll also enter a powersaving sleep mode between events.
Features
1. Timer interrupt triggers blinking
2. External interrupt toggles LED state
3. System sleeps when idle, saving power
You’ll need
A push-button
A red LED
ATmega328P or STM32F103 board
Diagram – System Overview
This system design mimics real-world products where you want
responsiveness, multitasking, and energy savings. Interrupts transform
embedded systems from passive machines into reactive agents, capable of
instantly responding to the world around them. By mastering interrupts—
external and internal—you unlock the ability to write non-blocking, powerefficient, and responsive code. Whether you're dealing with a push-button
interface, a sensor trigger, or a communication event, interrupts provide the
cleanest and most professional way to handle events. In this chapter, you've
learned how vector tables map interrupt sources to handlers, how to write
and configure ISRs, and how to avoid common pitfalls like debouncing. You
also explored both AVR and STM32 interrupt models, which prepares you to
work across platforms confidently.
Chapter 7
Working with LCD Displays
Visual feedback is one of the most powerful ways for users to interact with
embedded systems. Whether it’s displaying sensor data, showing menus, or
providing alerts, an LCD screen can dramatically improve the user interface
of your device. In this chapter, we will explore how to interface
microcontrollers with character LCDs (like the popular 16x2 displays) and
OLED graphic screens, using both parallel communication and modern
I2C or SPI protocols.
This chapter is hands-on, teaching you not just how to connect displays, but
also how to use them effectively in real projects. You will learn to build a
real-time digital thermometer using a temperature sensor and a voltmeter
using an analog input, both displaying data on an LCD. These projects will
reinforce your understanding of data conversion, real-time updating, and
display formatting.
Understanding the 16x2 Character LCD
The 16x2 LCD is a monochrome alphanumeric display with two rows of 16
characters each. It is based on the Hitachi HD44780 controller, which
supports a 4-bit or 8-bit parallel interface. However, for simplicity and to
save GPIO pins, many developers use an I2C backpack that connects to the
LCD and exposes a simple 2-wire interface.
The LCD has a register-select architecture, where commands (like cursor
position, clearing the display) and data (characters to be displayed) are sent
through separate registers.
Here is the standard pin layout for a parallel 16x2 LCD module
Pin Name
1
VSS
2
VDD
3
VO
4
RS
5
RW
6
E
7D0-D7
14
15 A
16 K
Description
Ground
+5V Supply
Contrast control (via pot)
Register Select (0=Cmd)
Read/Write (set to 0)
Enable pin
Data lines
LED backlight +
LED backlight GND
In 4-bit mode, only D4 to D7 are used, reducing the required I/O pins.
Wiring an LCD with I2C Backpack
Using an I2C backpack converts this pin-heavy LCD into a device that only
needs 2 I/O pins SDA (data) and SCL (clock). This dramatically simplifies
wiring, especially on microcontrollers with limited I/O.
Connection Example (ATmega328P with I2C LCD)
I2C LCD
Pin
GND
VCC
SDA
SCL
Connects To
(AVR)
GND
+5V
PC4 (A4)
PC5 (A5)
I2C Address Most backpacks default to 0x27 or 0x3F . Use an I2C scanner
sketch to detect yours.
Code for I2C LCD Display
To interface an I2C LCD with AVR or Arduino, you can use libraries such as
LiquidCrystal_I2C . Here’s an example to display text #include <Wire.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 16, 2); // Address, columns, rows
void setup() {
lcd.begin();
lcd.backlight();
lcd.print("Hello, World!");
}
void loop() {
lcd.setCursor(0, 1); // Column 0, Row 1
lcd.print(millis() / 1000);
delay(1000);
}
This prints a welcome message on the first row and an incrementing seconds
counter on the second row.
OLED Displays – Going Graphical
OLED screens, such as the SSD1306 128x64, offer greater visual flexibility
than character LCDs. These can display pixels, shapes, icons, and even
graphs. They often use I2C or SPI and are compatible with libraries like
Adafruit_SSD1306 and u8g2.
Wiring (I2C 128x64 OLED)
OLED
Pin
GND
VCC
SCL
SDA
Connects To (AVR or
STM32)
GND
3.3V or 5V
I2C Clock (PC5 or PB6)
I2C Data (PC4 or PB7)
Here is a sample code for initializing an SSD1306 OLED with u8g2 library in
Arduino #include <Wire.h>
#include <U8g2lib.h>
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0);
void setup() {
u8g2.begin();
}
void loop() {
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_ncenB08_tr);
u8g2.drawStr(0, 15, "Temperature ");
u8g2.drawStr(0, 30, "25.4 C");
u8g2.sendBuffer();
delay(1000);
}
This sets up the OLED, writes two lines of text, and refreshes every second.
Hands-On
Project
Thermometer
1
Real-Time
Digital
Objective Build a temperature display system using an LM35 analog
temperature sensor and display the output on a 16x2 LCD or OLED screen.
Components Needed
ATmega328P board or Arduino Uno
LM35 temperature sensor
16x2 LCD with I2C backpack or OLED 128x64
10K resistor
Breadboard and wires
How it Works
The LM35 outputs 10mV per °C. So at 25°C, it gives 250mV. Using an ADC
input, we can read this analog voltage and convert it to Celsius.
Formula
Temperature (°C) = (ADC_Value / 1024.0) * Vref (usually 5V) * 100
Code Snippet
int tempPin = A0;
float voltage, tempC;
void loop() {
int adcVal = analogRead(tempPin);
voltage = adcVal * 5.0 / 1024.0;
tempC = voltage * 100;
lcd.setCursor(0, 0);
lcd.print("Temp ");
lcd.print(tempC);
lcd.print(" C");
delay(1000);
}
This reads the temperature every second and updates the LCD in real time.
Hands-On Project 2 Digital Voltmeter Display
Objective Measure an input voltage (0V to 5V) and display it on an OLED
or LCD.
Components Needed
ATmega328P or STM32 board
Voltage divider (for higher voltages)
OLED display or LCD
2 resistors (e.g., 10K and 10K for divider)
How it Works
The analog pin can only handle 0–5V. If your voltage exceeds this, use a
voltage divider. The formula for ADC conversion is
Voltage = (ADC_value / 1024.0) * Vref
Code Example
int analogPin = A1;
float voltage;
void loop() {
int value = analogRead(analogPin);
voltage = (value * 5.0) / 1024.0;
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_ncenB08_tr);
u8g2.drawStr(0, 15, "Voltage ");
u8g2.setCursor(0, 30);
u8g2.print(voltage);
u8g2.print(" V");
u8g2.sendBuffer();
delay(1000);
}
You now have a basic voltmeter that updates every second and displays
values in volts.
LCD vs. OLED Comparison Table
Feature
Display Type
16x2 LCD
Characteronly
OLED 128x64
Graphic (text + shapes)
Power Usage
Higher
Interface
Resolution
Backlight
Needed
Parallel/I2C
16x2 chars
Lower (especially
SPI)
I2C or SPI
128x64 pixels
Yes
No (self-emitting)
with
Feature
Cost
16x2 LCD
Lower
OLED 128x64
Slightly Higher
Real-Time Updating Techniques
To make displays more professional, consider how often data is refreshed.
Updating too frequently can cause flickering. Using a non-blocking timer,
like one created with interrupts, allows periodic updates without halting the
system.
For OLEDs, always use buffered drawing( clearBuffer() → draw → sendBuffer() ),
which prevents flickering by updating the display all at once.
Mastering display technology empowers you to create professional-grade
embedded projects. Whether it's a thermostat, voltmeter, user interface, or
data logger, visual feedback transforms a microcontroller from a black box
into a smart, user-friendly system.
In this chapter, you've interfaced both character and graphical displays, used
real sensor inputs to show live data, and learned how I2C and SPI buses help
reduce wiring complexity. You’ve also explored formatting, performance
optimization, and power management for visual systems. In the next chapter,
we’ll explore serial communication—another essential interface for
debugging, logging, and device-to-device communication, opening the door
to data exchange and interaction with computers or other microcontrollers.
Chapter 8
Analog Inputs – Sensors and ADCs
Analog inputs are the primary gateway through which microcontrollers
interact with the physical world in a continuous and real-time manner. From
sensing light levels with a photoresistor to tracking temperature using a
sensor like the LM35, analog-to-digital converters (ADCs) allow us to
quantify and work with naturally varying signals in our embedded projects.
This chapter is dedicated to understanding the internal working of ADCs,
applying them practically with different types of sensors, and creating live
monitoring systems using both serial output and LCD displays.
We will also build hands-on projects such as a live analog voltmeter and a
temperature sensor display. These exercises will strengthen your
understanding of how to connect, read, interpret, and display analog sensor
data, while introducing key concepts such as ADC resolution, reference
voltage, signal scaling, and noise filtering.
Understanding Analog Signals and ADCs
An analog signal is a continuous voltage signal that can have any value
between 0V and a defined maximum (typically 5V or 3.3V, depending on
your microcontroller). Since microcontrollers are digital devices, they
cannot directly process these continuous values. This is where analog-todigital converters (ADC) come in.
An ADC converts a continuous analog voltage to a discrete digital value. For
example, if the input voltage is 2.5V and the reference voltage is 5V, the
ADC interprets this as approximately 50% of the full range.
The resolution of an ADC defines how many different values it can produce
for a given input range. It is usually expressed in bits.
Resolution
(Bits)
8-bit
10-bit
12-bit
Number of
Levels
256
1024
4096
Smallest Detectable Voltage (for 5V
Vref)
5V / 256 = 19.53 mV
5V / 1024 = 4.88 mV
5V / 4096 = 1.22 mV
For example, the ATmega328P uses a 10-bit ADC, while STM32
microcontrollers often have 12-bit or even higher ADC resolution.
Practical Example Reading an LM35 Temperature
Sensor
The LM35 is a linear analog temperature sensor. It produces 10 millivolts for
every degree Celsius. So at 25°C, it gives 250mV. It is simple to use and a
great way to get started with ADC.
LM35 Voltage vs. Temperature
Temperature
Output Voltage
(°C)
(V)
0
0.0
25
0.25
50
0.5
75
0.75
100
1.0
To use this sensor, you connect its output pin to an analog input of your
microcontroller, and then read the voltage via the ADC.
Here is the basic formula for converting ADC value to temperature
Voltage = (ADC_value / ADC_resolution) * Vref
Temperature (°C) = Voltage * 100
For an Arduino with 10-bit ADC and 5V Vref
int raw = analogRead(A0);
float voltage = raw * 5.0 / 1024.0;
float temperature = voltage * 100.0;
Building a Live Temperature Display Using an
LCD
Now let’s integrate this with a 16x2 LCD to show the temperature in realtime.
Required Components
LM35 temperature sensor
16x2 LCD with I2C backpack
Arduino Uno or ATmega328P board
Jumper wires
Breadboard
Circuit Diagram
LM35
VCC = +5V
GND = GND
OUT = A0
I2C LCD
VCC = +5V
GND = GND
SDA = A4
SCL = A5
Arduino Code Example
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 16, 2); // Address may vary
void setup() {
lcd.begin();
lcd.backlight();
}
void loop() {
int raw = analogRead(A0);
float voltage = raw * 5.0 / 1024.0;
float tempC = voltage * 100;
lcd.setCursor(0, 0);
lcd.print("Temp ");
lcd.print(tempC);
lcd.print(" C");
delay(1000);
}
This code reads the analog temperature, converts it to Celsius, and displays
it on the LCD every second. The analogRead() function is non-blocking, making
it useful in time-critical applications.
ADC Noise and Filtering
Noise is a common issue with analog signals. Electrical noise can come from
power supplies, switching components, or even nearby digital signals. A few
techniques to filter noise include Averaging Take multiple ADC samples and
average them.
Low-pass filter Use a resistor-capacitor (RC) circuit to smooth out
fluctuations.
Shielding Physically isolate analog lines from digital lines.
Example of averaging
long sum = 0;
for (int i = 0; i < 10; i++) {
sum += analogRead(A0);
delay(5);
}
float average = sum / 10.0;
Building a Live Voltmeter Using Serial Monitor
To visualize analog signals as voltage, let’s build a serial voltmeter using
the ADC.
Hardware
Arduino Uno
Potentiometer (connected between 5V and GND)
Center pin to A1
Code
void setup() {
Serial.begin(9600);
}
void loop() {
int adc = analogRead(A1);
float voltage = adc * 5.0 / 1024.0;
Serial.print("Voltage ");
Serial.print(voltage);
Serial.println(" V");
delay(500);
}
This program prints the analog voltage every 500 ms to the Serial Monitor.
You can also use tools like Serial Plotter in Arduino IDE to visualize it
graphically.
Scaling and Calibration
Sometimes your input range might not match your ADC range. Suppose you're
measuring a 0–12V signal using a 0–5V ADC. You would need a voltage
divider Vout = Vin × (R2 / (R1 + R2))
Choosing R1 = 7kΩ, R2 = 3kΩ gives
Vout = Vin × (3 / 10) = 0.3Vin
So 12V becomes 3.6V — safe for a 5V ADC. Make sure to recalculate and
scale the value in software
float actualVoltage = (adc 5.0 / 1024.0) (10.0 / 3.0);
Hands-On Project Analog Light Meter
Let’s build a light sensor display using a photoresistor (LDR) and LCD.
Materials
LDR
10K resistor
LCD (I2C or direct)
Arduino Uno
Circuit
LDR between 5V and A2
10K resistor between A2 and GND
The junction between the LDR and resistor goes to the analog input. As light
increases, resistance drops and voltage changes.
Code
int rawLDR = analogRead(A2);
float percentage = (rawLDR / 1023.0) * 100.0;
lcd.setCursor(0, 1);
lcd.print("Light ");
lcd.print(percentage);
lcd.print("%");
This gives a rough indication of ambient light in percentage format.
Comparing ADCs in Microcontrollers
Microcontrolle
ADC
r
Resolution
ATmega328P 10-bit
STM32F103
12-bit
ESP32
12-bit
Channel
Internal Reference
s
Options
8
Yes (1.1V, AVcc)
16
Yes (2.5V, Vref+)
18
Yes (configurable)
Higher resolution and internal references allow more precise measurements
and better scaling.
By mastering analog inputs and ADCs, you're unlocking a powerful ability to
measure and interpret the real world through electronics. Whether you are
reading temperature, voltage, or light, the analog interface is often the first
step in building responsive and intelligent systems.
In this chapter, you've not only read analog values but learned to scale,
calibrate, and display them in real time, using LCDs and serial monitors.
You've built practical projects like a digital thermometer, a voltmeter, and a
light meter—all of which are excellent foundations for more advanced sensor
integration in upcoming chapters. Next, we’ll explore serial communication
protocols—essential for debugging, data logging, and interacting with other
devices such as PCs, wireless modules, or other microcontrollers.
Chapter 9
Pulse Width Modulation (PWM) – Motor and
LED Control
In the embedded world, not all outputs are as straightforward as turning a pin
on or off. When you're trying to control something like a motor's speed, the
brightness of an LED, or the position of a servo motor, simply switching the
pin HIGH or LOW isn't enough. What you need is a technique that simulates
analog behavior using digital signals. This is where Pulse Width
Modulation (PWM) comes into play. PWM is a powerful technique that lets
microcontrollers do analog control using digital outputs, without the need for
an actual digital-to-analog converter (DAC).
In this chapter, you will deeply explore the theory and practical
implementation of PWM using embedded C. You will also build hands-on
projects, such as a fan speed controller, an LED dimmer, and a servo
motor driver, to apply the theory to real-world scenarios. You will
understand and experiment with key PWM parameters like duty cycle,
period, and frequency, and learn how tweaking these parameters influences
the behavior of connected hardware.
What is PWM and Why Do We Use It?
PWM, or Pulse Width Modulation, is a method used to simulate an analog
voltage by rapidly switching a digital signal ON and OFF. The core idea is
that by adjusting the duration the signal stays HIGH in each cycle, we can
simulate varying power levels. This ON-time percentage in a given period is
called the duty cycle.
PWM signals are made up of repeating ON and OFF pulses. The percentage
of time the signal is ON in a single period is known as the duty cycle, and
the frequency of the PWM signal is how many times this ON/OFF cycle
happens per second.
Table Effect of Duty Cycle on Output
Duty Cycle
LED
Motor
(%)
Brightness
Speed
0
OFF
Stopped
25
Dim
Slow
50
Medium
Medium
75
Bright
Fast
100
Fully ON
Max Speed
Servo Angle
(approx)
0°
45°
90°
135°
180°
Setting Up PWM in C (AVR Example –
ATmega328P)
PWM generation is hardware-assisted in most microcontrollers. In AVR
microcontrollers like ATmega328P (used in Arduino Uno), PWM is
generated using internal Timer/Counters.
Let’s consider Timer0 in Fast PWM mode.
Here’s how to configure it in embedded C without using Arduino libraries
void pwm_init() {
// Set PWM pin as output (OC0A – usually pin 6 on Arduino)
DDRD |= (1 << PD6);
// Set Fast PWM mode with non-inverting output
TCCR0A |= (1 << COM0A1) | (1 << WGM01) | (1 << WGM00);
// Set prescaler to 64
TCCR0B |= (1 << CS01) | (1 << CS00);
// Start with 0% duty cycle
OCR0A = 0;
}
To change the duty cycle
OCR0A = value; // Value between 0 and 255 (for 8-bit resolution)
With an 8-bit timer and 64 prescaler at 16MHz clock, the PWM frequency is
about 976 Hz, which is suitable for LED dimming and some motor control.
Hands-On Project LED Brightness Control
Objective
Gradually increase and decrease the brightness of an LED using PWM.
Hardware
ATmega328P board or Arduino Uno
220-ohm resistor
LED
Jumper wires
Circuit
Connect the LED in series with the resistor to PD6 (OC0A).
Code
void delay_ms(unsigned int ms);
int main(void) {
pwm_init();
while (1) {
for (int i = 0; i < 255; i++) {
OCR0A = i;
delay_ms(10);
}
for (int i = 255; i > 0; i--) {
OCR0A = i;
delay_ms(10);
}
}
}
This code generates a soft breathing effect. The LED fades in and out
smoothly using PWM.
Understanding Period and Frequency
PWM frequency depends on the clock source, prescaler, and timer
resolution. For instance, using an 8-bit timer and a 64 prescaler at 16 MHz
PWM Frequency = Clock / (Prescaler * 256)
= 16,000,000 / (64 * 256)
= ~976 Hz
Higher frequencies are preferred for servos (50 Hz) and fans (1-20 kHz),
while lower frequencies (below 1 kHz) are noticeable in visible flicker for
LEDs.
Hands-On Project Fan Speed Controller
Objective
Control the speed of a small 5V DC fan using PWM and a potentiometer.
Components
5V DC fan
IRF540N or similar N-channel MOSFET
10K potentiometer
Arduino Uno or ATmega328P
Power source (5V regulated)
Circuit
Fan connected between +5V and drain of MOSFET
Source of MOSFET to GND
Gate of MOSFET to PWM pin (PD6)
Potentiometer center pin to A0, sides to 5V and GND
Code
void setup() {
pinMode(6, OUTPUT);
}
void loop() {
int sensorValue = analogRead(A0);
int pwmValue = map(sensorValue, 0, 1023, 0, 255);
analogWrite(6, pwmValue);
}
The potentiometer sets the duty cycle of the PWM, effectively adjusting the
fan speed.
Hands-On Project Servo Motor Driver Using
PWM
Servo motors use PWM signals to determine the shaft position. Unlike
regular PWM, servos expect signals with a specific period and pulse width.
For typical servos
Pulse width 1 ms → 0°
Pulse width 1.5 ms → 90°
Pulse width 2 ms → 180°
Period 20 ms (50 Hz)
This is not regular PWM but servo-compatible pulse signals.
Components
SG90 servo motor
Arduino Uno
Code (Arduino)
#include <Servo.h>
Servo myServo;
void setup() {
myServo.attach(9); // PWM pin
}
void loop() {
for (int angle = 0; angle <= 180; angle++) {
myServo.write(angle);
delay(20);
}
for (int angle = 180; angle >= 0; angle--) {
myServo.write(angle);
delay(20);
}
}
Internally, the Servo library generates 50 Hz PWM with correct pulse width
for servo positioning.
PWM Resolution and Fine Control
PWM resolution determines how smooth your control is. For example, an 8bit PWM (0–255) gives 256 steps of control. Higher resolution means more
precise control.
Resolutio
n
Step
Count
Application Example
Resolutio
n
Step
Count
8-bit
256
10-bit
16-bit
1024
65536
Application Example
LED dimming, basic
fans
Advanced motor control
Precision servos, audio
For applications like audio waveform generation or precision motor driving,
higher resolution PWM is essential.
PWM is a fundamental technique in embedded systems and electronics. It
allows digital devices to mimic analog output, making it indispensable for
tasks like brightness control, motor speed regulation, and servo motor
actuation. In this chapter, we explored the internal mechanisms of PWM,
configured timers for generating it in embedded C, and built three functional
projects a fading LED, a DC fan controller, and a servo motor
driver.Understanding PWM deeply not only enhances your embedded skills
but also opens the door to more advanced topics like audio signal synthesis,
digital communication modulation, and precise motion control systems. In the
next chapter, we will extend this understanding to communication protocols,
where timing and signal encoding play equally critical roles.
Chapter 10
Serial Communication (UART, I2C, SPI)
In this chapter, we enter one of the most important and versatile aspects of
embedded systems — serial communication. As microcontrollers often
work as the "brain" of a system, they must constantly exchange data with
peripherals like sensors, displays, memory modules, real-time clocks, and
even other microcontrollers. To facilitate this exchange, we use standard
communication protocols such as UART (Universal Asynchronous
Receiver Transmitter), I2C (Inter-Integrated Circuit), and SPI (Serial
Peripheral Interface).
Each of these protocols serves different purposes and offers distinct
advantages in terms of speed, simplicity, wiring complexity, and data
reliability. This chapter will take you through the underlying concepts, realworld configurations, embedded C implementations, and practical
projects using UART, I2C, and SPI. By the end of this chapter, you will have
built multiple working communication-based systems, strengthening both
your understanding and hands-on experience in embedded design.
Understanding Serial Communication
Unlike parallel communication, where multiple bits are sent simultaneously
across several wires, serial communication transmits data one bit at a time,
making it more efficient for long-distance and low-pin-count applications.
Serial communication can be either synchronous (requiring a clock signal)
or asynchronous (no clock required). The protocols we cover here represent
both types
Protoco
l
UART
I2C
SPI
Speed
Use Cases
(Typical)
Asynchronou
9600–1
Debugging, GPS,
2 (TX, RX)
s
Mbps
Bluetooth
100k–
RTCs,
OLEDs,
Synchronous 2 (SCL, SDA)
400kHz
EEPROMs
4 (MOSI, MISO,
SD cards, sensors,
Synchronous
1–10 Mbps
SCK, SS)
displays
Type
Wires Used
UART Communication – Serial Terminal Project
UART is the simplest and most common serial communication protocol. It
uses only two lines TX (transmit) and RX (receive), and does not require a
clock line. It's widely used for sending data to a PC terminal or connecting
Bluetooth/GPS modules.
UART Theory of Operation
UART uses asynchronous communication, which means both sender and
receiver must agree on a baud rate (bits per second). Common baud rates
include 9600, 19200, and 115200. Data is sent frame-by-frame, typically as
1 start bit
8 data bits
1 stop bit
Optional parity bit
Project Serial Terminal Display
Objective Create a UART-based terminal that sends temperature data from
an LM35 sensor to a serial monitor on a PC.
Hardware
ATmega328P or Arduino Uno
LM35 temperature sensor
USB-to-Serial converter (if not using Arduino)
10K resistor (for ADC stabilization)
Connections
LM35 output → A0
TX → USB-Serial RX (or to PC)
RX → USB-Serial TX (optional for bi-directional)
Embedded C Code (ATmega328P)
#define F_CPU 16000000UL
#define BAUD 9600
#define MYUBRR F_CPU/16/BAUD-1
#include <avr/io.h>
#include <util/delay.h>
#include <stdio.h>
void uart_init(unsigned int ubrr) {
UBRR0H = (unsigned char)(ubrr >> 8);
UBRR0L = (unsigned char)ubrr;
UCSR0B = (1 << TXEN0); // Enable transmitter
UCSR0C = (1 << UCSZ01) | (1 << UCSZ00); // 8-bit data
}
void uart_transmit(unsigned char data) {
while (!(UCSR0A & (1 << UDRE0)));
UDR0 = data;
}
void uart_print(const char *str) {
while (*str) {
uart_transmit(*str++);
}
}
int read_temp() {
ADMUX = (1 << REFS0);
ADCSRA = (1 << ADEN) | (1 << ADSC) | (1 << ADPS1) | (1 << ADPS0);
while (ADCSRA & (1 << ADSC));
return ADC;
}
int main(void) {
char buffer[20];
uart_init(MYUBRR);
while (1) {
int value = read_temp(); // 10mV/°C
float tempC = (value 5.0 100.0) / 1024.0;
snprintf(buffer, 20, "Temp %.2fC\n", tempC);
uart_print(buffer);
delayms(1000);
}
}
Run this code and open a serial terminal like PuTTY or CoolTerm on your
PC at 9600 baud. You’ll see temperature values streaming in real-time.
Section 2 I2C Communication – RTC Clock
Display
I2C, or Inter-Integrated Circuit, is a multi-device synchronous protocol
using just two wires SCL (clock) and SDA (data). It is perfect for
connecting low-speed peripherals like real-time clocks (RTC), EEPROMs,
and displays.
Each I2C device has a unique 7-bit or 10-bit address, and the master can
communicate with any slave using this address. The protocol supports
multiple masters but is usually implemented with one master (the
microcontroller).
I2C Packet Format
Start | Slave Address + R/W | ACK | Data | ACK | ... | Stop
Project Display Time Using DS3231 RTC
Objective Interface a DS3231 I2C real-time clock module and display time
on the serial terminal.
Hardware
ATmega328P or Arduino Uno
DS3231 RTC module
Pull-up resistors (4.7kΩ) on SDA/SCL
Optional OLED display for visual clock
Connections
SDA → A4
SCL → A5
VCC, GND accordingly
Code (Using Wire Library on Arduino for simplicity)
#include <Wire.h>
#define DS3231_ADDR 0x68
byte bcdToDec(byte val) {
return (val / 16 * 10) + (val % 16);
}
void setup() {
Serial.begin(9600);
Wire.begin();
}
void loop() {
Wire.beginTransmission(DS3231_ADDR);
Wire.write(0); // Set pointer to seconds register
Wire.endTransmission();
Wire.requestFrom(DS3231_ADDR, 3);
int sec = bcdToDec(Wire.read());
int min = bcdToDec(Wire.read());
int hour = bcdToDec(Wire.read());
Serial.print("Time ");
Serial.print(hour); Serial.print(" ");
Serial.print(min); Serial.print(" ");
Serial.println(sec);
delay(1000);
}
This code reads the current time from the DS3231 module using I2C and
prints it to the serial monitor every second.
Section 3 SPI Communication – SD Card Interface
SPI, or Serial Peripheral Interface, is a high-speed synchronous
communication protocol. It uses four lines MOSI (Master Out, Slave In),
MISO (Master In, Slave Out), SCK (clock), and SS (slave select).
SPI is faster than I2C but requires more pins and has no built-in addressing.
Each slave needs a separate SS line.
Project Read Files from an SD Card
Objective Use SPI to read and display file contents from a microSD card.
Hardware
ATmega328P or Arduino Uno
microSD card module
SD card formatted in FAT32
Connections
MOSI → D11
MISO → D12
SCK → D13
CS → D10
Arduino Code (Using SD Library)
#include <SPI.h>
#include <SD.h>
File myFile;
void setup() {
Serial.begin(9600);
if (!SD.begin(10)) {
Serial.println("SD init failed!");
return;
}
Serial.println("SD init done.");
myFile = SD.open("data.txt");
if (myFile) {
Serial.println("Reading from data.txt ");
while (myFile.available()) {
Serial.write(myFile.read());
}
myFile.close();
} else {
Serial.println("File error!");
}
}
void loop() {}
Place a data.txt file on your SD card. This code will read its contents via SPI
and display it on the serial monitor.
Serial communication is the digital language that allows microcontrollers to
interact with the world around them. Whether you’re transmitting temperature
readings to a PC via UART, pulling current time from a real-time clock over
I2C, or accessing file systems through SPI, these protocols are the backbone
of most embedded applications.
In this chapter, you’ve gone hands-on with each of these communication
methods through real-world applications a serial temperature terminal using
UART, a live time display using an I2C-based RTC, and an SPI SD card
reader. These projects not only solidify your theoretical knowledge but also
prepare you to build full-fledged, connected systems in future projects. Up
next, we will explore power management techniques — critical for
optimizing embedded systems for battery life, thermal efficiency, and field
durability.
Chapter 11
Real-Time Clock and Data Logging
In this chapter, we will delve into the world of real-time clocks (RTC) and
data logging, which are crucial for many embedded systems that require
accurate timekeeping and long-term data storage. We will explore how to
interface external RTC modules to track time and use SD cards to log sensor
data with timestamps in a CSV (Comma Separated Values) format. These
skills are invaluable in creating systems that track environmental changes
over time, monitor sensor data, and generate reports for analysis.
The knowledge gained in this chapter will empower you to develop your
own data logging projects such as temperature recorders, weather stations, or
other systems that require timestamped data storage.
Understanding Real-Time Clocks (RTC)
Real-time clocks are specialized integrated circuits (ICs) designed to keep
track of time continuously, even when the microcontroller or system is
powered off. RTCs typically use a battery (often a coin cell) to maintain
time when the main system power is off. They keep track of seconds,
minutes, hours, days, and often even months and years. RTC modules are
widely used in applications where time precision is critical.
The most common RTC modules used in embedded systems are
DS3231 This is a highly accurate, temperature-compensated RTC.
It’s widely used because of its low cost, high precision, and I2C
interface.
DS1307 Another popular RTC, though not as accurate as the
DS3231, but still widely used and cheaper. It also uses I2C.
Both modules can be interfaced with microcontrollers to maintain accurate
time and date even when the system is powered down.
Interfacing an RTC Module
To begin using an RTC, we need to interface it with our microcontroller,
which will involve communicating via I2C. In this chapter, we will use the
DS3231 RTC module, which communicates over I2C, but the procedure is
similar for other RTC modules such as the DS1307.
Pin Connections for DS3231
VCC Connect to 3.3V or 5V depending on your microcontroller.
GND Connect to the ground pin.
SDA Connect to the data line (typically A4 on Arduino).
SCL Connect to the clock line (typically A5 on Arduino).
Once the connections are made, we will use the Wire library in Arduino,
which facilitates I2C communication.
The RTC Module Setting and Reading Time
First, you need to initialize the RTC, which involves setting the current date
and time if you're using it for the first time. If the RTC already has the time
set, you can directly start reading it.
Here’s how to interface the DS3231 RTC module and read the time on an
Arduino
#include <Wire.h>
#include <DS3231.h>
DS3231 rtc(SDA, SCL); // Create an instance of the DS3231 RTC module
char daysOfTheWeek[7][12] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday"};
void setup() {
Serial.begin(9600);
rtc.begin();
// Set the time once, then comment it out after the initial set
rtc.setTime(12, 0, 0); // Set time to 12 00 00
rtc.setDate(12, 5, 2025); // Set the date to 12th May, 2025
}
void loop() {
// Reading time and date
int hour = rtc.getHour();
int minute = rtc.getMinute();
int second = rtc.getSecond();
int day = rtc.getDay();
int month = rtc.getMonth();
int year = rtc.getYear();
// Display the date and time on the serial monitor
Serial.print("Date ");
Serial.print(daysOfTheWeek[day - 1]);
Serial.print(", ");
Serial.print(year);
Serial.print("/");
Serial.print(month);
Serial.print("/");
Serial.print(day);
Serial.print(" - ");
Serial.print(hour);
Serial.print(" ");
Serial.print(minute);
Serial.print(" ");
Serial.print(second);
Serial.println();
delay(1000); // Wait for one second
}
This simple example sets the time and displays it every second on the serial
monitor. The time can be easily set manually by calling rtc.setTime() and
functions. After you set the time, comment these lines out so that
the RTC keeps track of time independently.
rtc.setDate()
Storing Data on an SD Card
Now, let’s move on to storing the time and sensor data on an SD card. SD
cards are an easy way to store large amounts of data in embedded systems.
An SD card uses the SPI protocol for communication, and we will use the
SD library in Arduino to interact with it.
The microSD card is usually formatted with the FAT16 or FAT32 file
system, which is compatible with most microcontrollers and allows you to
write and read data in a structured format.
Pin Connections for SD Card Module
VCC Connect to 5V or 3.3V (depending on your module).
GND Connect to the ground.
MOSI Connect to pin 11 (for Arduino Uno).
MISO Connect to pin 12.
SCK Connect to pin 13.
CS (Chip Select) Connect to pin 10.
Once connected, we can begin creating a file on the SD card to log our data.
Logging Sensor Data with Timestamps
For our hands-on project, we will log sensor data (like temperature) and
store it with timestamps. This means that every time we record the sensor
data, we will also record the current time from the RTC module to make our
data useful for future analysis.
Project Temperature Data Logger with Timestamps
Objective Build a data logger that reads temperature from an LM35 sensor,
records the temperature, and logs it to an SD card with timestamps.
Components Needed
ATmega328P or Arduino Uno
DS3231 RTC module
LM35 temperature sensor
microSD card module
10kΩ resistor (for stabilizing the LM35)
Breadboard and jumper wires
Circuit Connections
LM35 output connected to A0 pin of Arduino.
SDA and SCL connected to the DS3231 RTC module.
SD card module connected using SPI pins (MOSI, MISO, SCK, and CS).
Arduino Code Data Logging with Timestamps
#include <Wire.h>
#include <SD.h>
#include <DS3231.h>
DS3231 rtc(SDA, SCL); // DS3231 RTC module
File dataFile;
int sensorPin = A0; // LM35 sensor connected to A0 pin
void setup() {
Serial.begin(9600);
Wire.begin();
rtc.begin(); // Initialize RTC
// Initialize SD card
if (!SD.begin(10)) {
Serial.println("SD Card initialization failed!");
return;
}
// Open the file for writing
dataFile = SD.open("datalog.csv", FILE_WRITE);
if (dataFile) {
dataFile.println("Timestamp,Temperature");
dataFile.close();
} else {
Serial.println("Error opening file.");
}
}
void loop() {
int sensorValue = analogRead(sensorPin);
float voltage = sensorValue * (5.0 / 1023.0);
float temperature = voltage * 100; // Convert voltage to temperature in Celsius
int hour = rtc.getHour();
int minute = rtc.getMinute();
int second = rtc.getSecond();
int day = rtc.getDay();
int month = rtc.getMonth();
int year = rtc.getYear();
// Format the timestamp as "YYYY-MM-DD HH MM SS"
String timestamp = String(year) + "-" + String(month) + "-" + String(day) + " " +
String(hour) + " " + String(minute) + " " + String(second);
// Write timestamp and temperature to the SD card
dataFile = SD.open("datalog.csv", FILE_WRITE);
if (dataFile) {
dataFile.print(timestamp);
dataFile.print(",");
dataFile.println(temperature);
dataFile.close();
} else {
Serial.println("Error opening file.");
}
delay(1000); // Log data every second
}
In this project, we read the temperature from an LM35 sensor, retrieve the
current time from the DS3231 RTC, and log both the time and temperature to
a CSV file on the SD card. Each entry will look like this in the CSV file
Timestamp,Temperature
2025-05-12 12 00 01,25.50
2025-05-12 12 00 02,25.55
2025-05-12 12 00 03,25.60
Chapter 12
Building a Menu System for Embedded Interfaces
In this chapter, we will explore how to create a text-based user interface
(UI) for embedded systems. These interfaces are commonly used in a variety
of applications, such as user-facing systems like thermostats, tools, or even
simple control panels. Building a menu system for your embedded system
allows you to create user-friendly interactions with the hardware, giving
users control over different features and settings in an intuitive manner.
We will design a basic menu system using buttons for navigation and a
display (such as an LCD or OLED screen) to show options. The goal is to
demonstrate how you can build and extend such systems to suit specific
needs. By the end of this chapter, you’ll be able to create custom menus and
navigate between them to perform different actions, helping you develop
systems with more sophisticated user interactio
Understanding the Components
Before diving into the code, let’s first familiarize ourselves with the main
components we’ll use in this project
Buttons These act as the primary input devices for the user. A button can be
pressed to select menu items, navigate through the options, or execute
commands. We’ll use push-button switches, connected to the digital pins of
the microcontroller.
Display We will use an LCD (Liquid Crystal Display) to display the menu
options. For simplicity, we'll use a 16x2 LCD, which is a common size that
can display two lines of text with up to 16 characters per line.
Microcontroller This is the brain of the project, where all the logic will
reside. For this example, we will use an Arduino microcontroller, which is a
popular choice due to its ease of use and widespread community support.
Wiring the Components
The setup consists of three main components the LCD, the buttons, and the
Arduino. Here's a general guide to how each component should be
connected.
LCD Connections
VCC Connect to 5V on the Arduino.
GND Connect to ground (GND) on the Arduino.
SDA and SCL If you're using an I2C interface for the LCD, connect SDA to
A4 and SCL to A5 (on an Arduino Uno). For a non-I2C LCD, you will need
to connect the control pins (RS, EN) and data pins (D4 to D7) to the Arduino
accordingly.
Button Connections
Connect each button to a digital pin on the Arduino. For example, you could
connect the Up button to Pin 2, the Down button to Pin 3, and the Select
button to Pin 4. Each button will have one leg connected to the pin and the
other leg to GND (with a pull-down resistor to ensure clean input).
Designing the Menu System
We are now ready to start designing the menu system. A simple menu consists
of multiple options, and the user navigates through these options using
buttons. We will use a finite state machine approach to manage the different
states of the menu and handle button presses.
Let’s first define a few basic concepts for the menu
Menu Items These are the options displayed on the screen, such as
“Temperature Settings”, “Timer Settings”, etc.
Active Menu The menu that is currently displayed.
Current Selection This refers to the currently highlighted menu item that the
user can select by pressing a button.
Our menu will have two main components
Main Menu The first screen the user sees, displaying a list of primary
options.
Submenus Each option in the main menu leads to a submenu with more
detailed settings or actions.
For simplicity, let’s consider a menu with three main options
1. Temperature Settings
2. Timer Settings
3. Exit
Menu Navigation Logic
We will use the buttons for navigation
Up Button Navigates up through the menu options.
Down Button Navigates down through the menu options.
Select Button Selects the highlighted option and enters a submenu or
performs an action.
To manage this navigation, we need to implement a way to track which menu
item is currently selected. We will store the current selection in a variable
(e.g., currentSelection ) and update it based on button presses.
Sample Code Menu System Implementation
Now that we have an understanding of how the system will work, let’s dive
into the code that implements the menu system.
Here’s a simplified version of the menu system, where we will display the
main menu and navigate through it using buttons
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
// Create LCD object (assuming 16x2 display, with I2C address 0x27)
LiquidCrystal_I2C lcd(0x27, 16, 2);
// Define button pins
const int upButtonPin = 2;
const int downButtonPin = 3;
const int selectButtonPin = 4;
// Variables to hold menu state
int currentSelection = 0;
const int totalMenuItems = 3;
String menuItems[3] = {"Temp Settings", "Timer Settings", "Exit"};
void setup() {
// Initialize LCD and buttons
lcd.begin();
lcd.backlight();
pinMode(upButtonPin, INPUT_PULLUP);
pinMode(downButtonPin, INPUT_PULLUP);
pinMode(selectButtonPin, INPUT_PULLUP);
// Display the main menu
displayMenu();
}
void loop() {
// Check button presses and navigate accordingly
if (digitalRead(upButtonPin) == LOW) {
currentSelection--;
if (currentSelection < 0) currentSelection = totalMenuItems - 1;
displayMenu();
delay(200); // Simple debouncing
}
if (digitalRead(downButtonPin) == LOW) {
currentSelection++;
if (currentSelection >= totalMenuItems) currentSelection = 0;
displayMenu();
delay(200); // Simple debouncing
}
if (digitalRead(selectButtonPin) == LOW) {
selectMenuItem();
delay(200); // Simple debouncing
}
}
void displayMenu() {
// Clear the display
lcd.clear();
// Display the menu item at the current selection
for (int i = 0; i < totalMenuItems; i++) {
if (i == currentSelection) {
lcd.setCursor(0, i);
lcd.print("> " + menuItems[i]);
} else {
lcd.setCursor(1, i);
lcd.print(menuItems[i]);
}
}
}
void selectMenuItem() {
switch (currentSelection) {
case 0
// Call the function for Temperature Settings
temperatureSettings();
break;
case 1
// Call the function for Timer Settings
timerSettings();
break;
case 2
// Exit the menu or power off
lcd.clear();
lcd.print("Exiting...");
delay(1000);
break;
}
}
void temperatureSettings() {
// Display temperature settings
lcd.clear();
lcd.print("Temperature Settings");
delay(2000); // Simulate settings page
}
void timerSettings() {
// Display timer settings
lcd.clear();
lcd.print("Timer Settings");
delay(2000); // Simulate settings page
}
How the Code Works
Initialization
The LCD display and buttons are initialized in the setup() function.
The menu is displayed by calling the displayMenu() function, which shows the
main menu with the current selection highlighted.
Button Press Handling
The loop() function continuously checks for button presses.
If the Up Button is pressed, the current selection moves up the menu. If it’s at
the top, it wraps around to the bottom.
If the Down Button is pressed, the current selection moves down the menu. If
it’s at the bottom, it wraps around to the top.
When the Select Button is pressed, the corresponding menu item is selected,
and the appropriate function (like temperatureSettings() or timerSettings() ) is called.
Display Update
The displayMenu() function is responsible for displaying the menu on the LCD.
It highlights the currently selected menu item by prefixing it with “>”.
The selectMenuItem() function handles the actions associated with selecting a
menu item.
Simulating Submenu Pages
When a menu item is selected (e.g., "Temperature Settings"), the
corresponding function simulates a settings page by clearing the screen and
displaying the name of the settings page for a brief time.
Extending the Menu System
This basic menu system can be extended in many ways. For example
You can add more options to the menu or even create nested
submenus for more complex systems.
You can replace the temperatureSettings() and timerSettings() functions with
actual functionality, such as controlling temperature values or setting
timers.
You could also use rotary encoders or other types of input devices
to navigate the menu, instead of just buttons.
Building a menu system for embedded interfaces is an essential skill for
anyone developing user-facing embedded applications. By using simple
buttons and a display, you can create intuitive interfaces for your systems. In
this chapter, you learned how to navigate a menu, select options, and simulate
actions, laying the groundwork for more complex user interfaces. This skill
will be valuable as you move on to more advanced embedded systems that
require user interaction and control.
Chapter 13
Power Management Techniques
Power management is one of the most crucial aspects when designing
embedded systems, especially for battery-operated devices like sensors,
wearables, and Internet of Things (IoT) nodes. In this chapter, we will
introduce power-saving techniques, such as using sleep modes, disabling
unused peripherals, and clock throttling. We will also design a hands-on
project—a battery-powered sensor node that periodically wakes up, collects
data, and transmits it, demonstrating the principles of power-efficient
embedded systems in action.
Why Power Management is Crucial
In embedded systems, power efficiency can make the difference between a
device that lasts for a few hours and one that can run for months or even
years on a single battery charge. For battery-powered applications,
especially those operating remotely or in situations where charging or
changing batteries is not feasible, reducing power consumption is critical.
Efficient power management helps extend the operational lifetime of the
system, reduces maintenance costs, and makes the system more reliable.
A variety of techniques can be used to achieve low power consumption, and
these techniques often work together. The most commonly used approaches
include Sleep Modes – A technique where the system or certain components
enter a low-power state when not in use.
Disabling Unused Peripherals – Turning off components (like sensors,
displays, or communication modules) when they are not needed.
Clock Throttling – Reducing the clock speed of the microcontroller or
processor to lower its power consumption when full processing power is not
necessary.
Power Consumption in Embedded Systems
To understand how to manage power consumption, it’s essential to
understand the key components of power usage in a microcontroller-based
system Microcontroller Power Usage Microcontrollers typically consume
the most power when they are actively running at full speed. Power
consumption varies depending on the microcontroller architecture, clock
frequency, and active peripherals.
Peripherals Power Usage Peripherals like sensors, communication modules
(e.g., Bluetooth, Wi-Fi, or LoRa), and displays can draw significant power
when they are operational. Powering these devices only when necessary
helps save power.
Sleep Modes Microcontrollers are designed with multiple sleep modes that
allow you to reduce power consumption by disabling certain components,
such as the CPU, memory, and communication interfaces, when not in use.
The following table shows the relative power consumption in different
operating states of a typical microcontroller
Power
State
Consumptio
Description
n
Active
High
(mA Microcontroller is actively running at full clock
(Running) range)
speed, all peripherals are active.
Power
State
Consumptio
Description
n
Moderate
Idle (Low
Microcontroller is idle, but certain peripherals
(uA to mA
Power)
(e.g., timers) remain active.
range)
Sleep
CPU is off, most peripherals are powered down,
Mode
Very
Low
only essential components like the Watchdog
(Deep
(uA range)
Timer are active.
Sleep)
Very
Low Microcontroller is completely powered down,
Shutdown (uA to nA with minimal power used for maintaining basic
range)
functions (e.g., RTC).
Techniques for Power Management
Let’s go over each of the key techniques that help reduce power consumption
in embedded systems.
1. Sleep Modes
Microcontrollers offer various sleep modes, which are essential for saving
power when the system does not need to perform active processing. These
modes include Idle Mode In this mode, the CPU is still running, but the clock
frequency is reduced. Some peripherals are turned off, but the
microcontroller can resume full operation quickly when needed.
Sleep Mode The microcontroller’s core is powered down, and only
essential peripherals, like timers or interrupts, remain active. This
significantly reduces power consumption while keeping the system
responsive to external events.
Deep Sleep Mode This mode is often used in low-power applications where
the microcontroller needs to consume minimal power. Only a few
components like the RTC or watchdog timers remain operational. The wakeup time can be longer compared to other sleep modes, but it provides
substantial power savings.
Power-Down Mode In this state, the microcontroller’s core and most
peripherals are turned off, leaving only the essential components, such as the
watchdog timer or the real-time clock (RTC), running. This mode offers the
lowest power consumption but may require more time to wake up.
2. Disabling Unused Peripherals
Modern microcontrollers are often equipped with multiple peripherals,
including communication interfaces, analog-to-digital converters (ADC),
timers, and digital I/O pins. Not all of these peripherals need to be active all
the time, and turning off the ones that are not in use is an effective way to
reduce power consumption.
For example, if your system is using a sensor and transmitting data over a
communication module, you can disable unused modules like PWM timers,
UART interfaces, or ADCs. This will significantly lower the overall power
consumption. When the system is not actively transmitting or receiving data,
these peripherals can be safely powered down.
To disable a peripheral, most microcontrollers offer built-in registers that
allow you to turn off specific peripherals when they are not needed. This can
be done either through software by writing to control registers or
automatically using certain microcontroller sleep modes that disable
peripherals during inactivity.
3. Clock Throttling
The clock speed of a microcontroller directly impacts its power
consumption. A higher clock speed means more processing power is
available, but it also increases the amount of current drawn. Therefore,
lowering the clock speed when the system does not require full processing
power can save energy.
Many microcontrollers have the ability to dynamically adjust their clock
speed. This feature is particularly useful when performing tasks that do not
require high processing power, such as monitoring sensors or waiting for
input. By slowing down the clock during idle periods or during non-critical
operations, power consumption is reduced significantly without affecting the
functionality of the device.
Hands-On Project Battery-Powered Sensor Node
Let’s build a simple battery-powered sensor node that uses the techniques
we’ve learned so far to save power. This sensor node will measure
temperature using an LM35 sensor and transmit the data to a receiver over a
wireless communication module (e.g., an nRF24L01 or LoRa).
Components Needed
Microcontroller Arduino Uno, or any low-power microcontroller (e.g.,
Arduino Pro Mini, ESP8266)
Temperature Sensor LM35 (or any similar analog temperature sensor)
Wireless Module nRF24L01 or LoRa
Battery 3.7V Li-ion battery or any suitable power source
Button For manual activation (optional)
Capacitors/Resistors For smoothing the power supply if necessary
Circuit Design
Sensor Connection The LM35 temperature sensor will be connected to one
of the analog pins on the microcontroller (e.g., A0).
Wireless Module The wireless module will be connected to the digital pins
of the Arduino (e.g., SPI pins for the nRF24L01 or LoRa pins for the LoRa
module).
Power Supply Use a 3.7V Li-ion battery connected through a voltage
regulator to provide a stable voltage to the system.
The key idea here is to use the sleep mode of the microcontroller to save
power between data transmissions. The system will wake up every 10
seconds to take a temperature reading and transmit it to a receiver.
Sample Code Battery-Powered Sensor Node
#include <SPI.h>
#include <Wire.h>
#include <nRF24L01.h>
#include <RF24.h>
// Define sensor and wireless module pins
const int sensorPin = A0;
RF24 radio(9, 10); // CE, CSN pins for nRF24L01 module
// Setup the sleep interval (in milliseconds)
const unsigned long sleepInterval = 10000; // 10 seconds
void setup() {
// Initialize the sensor and radio
Serial.begin(9600);
radio.begin();
radio.openWritingPipe(0xF0F0F0F0E1LL); // Address of the receiving module
radio.setPALevel(RF24_PA_HIGH);
radio.setDataRate(RF24_250KBPS); // Use low data rate for low power consumption
// Setup the sleep mode (set pins to low to disable)
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, LOW);
// Begin with a sensor reading and transmission
readAndTransmit();
}
void loop() {
// Sleep for the defined interval before waking up again
delay(sleepInterval);
readAndTransmit();
enterSleepMode();
}
void readAndTransmit() {
// Read the temperature sensor value
int sensorValue = analogRead(sensorPin);
float temperature = (sensorValue / 1024.0) * 500.0; // Convert to Celsius
// Transmit the data via wireless module
radio.write(&temperature, sizeof(temperature));
// Optional Display the value for debugging
Serial.print("Temperature ");
Serial.println(temperature);
}
void enterSleepMode() {
// Put the microcontroller in sleep mode
digitalWrite(LED_BUILTIN, HIGH); // Optional Indicate sleep mode
delay(100);
sleep_mode(); // Sleep the Arduino (or use low-power sleep modes in other microcontrollers)
digitalWrite(LED_BUILTIN, LOW); // Optional Exit sleep mode
}
Power management techniques such as sleep modes, disabling unused
peripherals, and clock throttling are essential for extending the battery life
of embedded systems. By implementing these techniques, you can
significantly reduce the energy consumption of your devices, making them
more efficient and sustainable for long-term operation.
In this chapter, you learned how to build a battery-powered sensor node
that uses these power management techniques to wake up periodically, take
sensor readings, and transmit data. This project demonstrates the real-world
application of these techniques in a practical embedded system. As you
continue working on embedded systems, these principles will help you
optimize power usage and create more energy-efficient solutions.
Chapter 14
Structs, Bitfields, and Unions – C for Hardware
Control
In embedded system development, managing hardware resources directly
through C code is a common practice. To achieve this, you often need to
manipulate hardware registers, memory locations, and peripherals. C
provides powerful features such as structs, unions, and bitfields to model
hardware interfaces, which allow for efficient control over the hardware
while also ensuring that your code is both readable and maintainable. In this
chapter, we will dive deep into these concepts, explaining their significance
in hardware control and showing you how to implement them through handson projects.
Understanding Structs in Embedded Systems
A struct (short for structure) in C is a composite data type that groups
variables of different types into a single unit. Each element within a struct is
called a member or field. In embedded systems, structs are frequently used to
model device registers and hardware peripherals, as they allow you to
represent multiple pieces of data that belong together logically, such as a
register’s configuration settings or a set of sensor readings.
For instance, imagine you’re working with a microcontroller and want to
access its control register, which consists of multiple bits controlling
different features. By using a struct, you can group these bits into a logical
representation that makes it easier to manage.
Example Using Structs for Peripheral Registers
Consider a microcontroller with a 32-bit control register, where different
fields represent distinct features like enabling or disabling a peripheral,
setting a speed, or configuring modes of operation. You can define this
control register in C using a struct.
#include <stdint.h>
typedef struct {
uint8_t enable 1; // 1 bit for enabling the peripheral
uint8_t mode 2; // 2 bits for mode selection
uint8_t speed 3; // 3 bits for setting speed
uint8_t reserved 2; // 2 bits reserved for future use
} PeripheralControlRegister;
PeripheralControlRegister reg;
In this example
uint8_t is used as the underlying data type for each field.
The colon ( ) syntax after each data type defines the number of bits
to allocate for that member. For instance, enable takes 1 bit, mode
takes 2 bits, and so on.
The reserved field is typically unused but may be needed for future
updates or to align memory.
With this struct, you can now interact with the control register easily
reg.enable = 1; // Enable the peripheral
reg.mode = 2; // Set the mode to 2
reg.speed = 5; // Set the speed to 5
By using structs, you encapsulate the information in a manageable way,
making your code both modular and easy to understand. You can also pass
these structs to functions that deal with hardware-specific tasks, reducing the
need for manual bit manipulation in multiple places within your code.
Using Unions for Memory-Efficient Hardware
Access
A union is another powerful tool in C that can be particularly useful for
embedded systems. A union allows you to store different types of data in the
same memory location, but only one member can hold a value at any given
time. This is helpful when you need to read or write data in different formats
without wasting memory.
In embedded systems, unions are commonly used when dealing with
hardware registers that can be accessed in different ways. For example, a
32-bit register may contain individual byte values, or it might be manipulated
as a whole word.
Example Using Unions for Register Access
Imagine a scenario where a 32-bit register controls various features of a
sensor. You may want to access the register either as a single 32-bit value or
as individual bytes.
#include <stdint.h>
typedef union {
uint32_t reg; // 32-bit register
uint8_t bytes[4]; // Access register as an array of bytes
} SensorControlRegister;
SensorControlRegister sensorReg;
In this case
reg allows access to the full 32-bit register.
bytes provides an array of 4 bytes, allowing you to access each
byte individually.
You can use the union in the following ways
sensorReg.reg = 0xA1B2C3D4; // Set the whole register value
// Access individual bytes
uint8_t byte1 = sensorReg.bytes[0]; // byte1 = 0xA1
uint8_t byte2 = sensorReg.bytes[1]; // byte2 = 0xB2
By using a union, you can access the data in different formats without
duplicating memory. This is especially useful when working with hardware
registers that offer different views of the same underlying memory, such as
reading a register in both byte-wise and word-wise formats.
Controlling Individual Bits for Hardware Control
In embedded systems, fine-grained control over individual bits in registers is
often required. A bitfield is a feature of C that allows you to allocate a
specific number of bits to a variable, which can be extremely useful when
manipulating hardware control registers that are bit-mapped.
A bitfield is typically used within a struct to represent individual bits of a
register. This allows you to control specific bits without affecting others. The
bitfield syntax is similar to that of structs, but the number of bits is explicitly
defined.
Example Using Bitfields for Hardware Control
Consider a situation where you have a 32-bit control register, where each bit
controls a specific feature (e.g., enabling/disabling a feature, setting modes,
or adjusting speeds). You can use bitfields to control each feature with ease
#include <stdint.h>
typedef struct {
uint32_t feature1 1; // 1 bit for feature1
uint32_t feature2 1; // 1 bit for feature2
uint32_t mode 2; // 2 bits for mode selection
uint32_t reserved 28; // 28 bits reserved
} ControlRegister;
Here
Each feature is controlled by a specific bit. For example, feature1 and
feature2 are 1-bit fields, meaning they can only be set to 0 or 1.
The mode field takes up 2 bits, allowing for four possible modes.
You can now modify the individual bits easily
ControlRegister ctrlReg;
ctrlReg.feature1 = 1; // Enable feature1
ctrlReg.feature2 = 0; // Disable feature2
ctrlReg.mode = 3; // Set mode to 3 (binary 11)
In this way, bitfields allow you to handle hardware registers more intuitively,
without needing to manually manipulate individual bits using bitwise
operations.
Structs, Unions and Bitfields in a Device Driver
Let’s combine all of these concepts to create a simple device driver that
manipulates a hardware register using structs, unions, and bitfields. Suppose
you’re working with a peripheral that has the following control register
Bit 0 Enable the peripheral
Bits 1-2 Select the operation mode
Bits 3-5 Set the speed
Bit 6 Reserved (don’t use)
Bits 7-31 Reserved for future use
Here’s how you would model this in C
#include <stdint.h>
// Define the control register using structs and bitfields
typedef struct {
uint32_t enable 1; // 1 bit for enabling the peripheral
uint32_t mode 2; // 2 bits for mode selection
uint32_t speed 3; // 3 bits for setting speed
uint32_t reserved 26; // 26 bits reserved for future use
} __attribute__((packed)) ControlRegister;
// Union to allow both full register and individual access
typedef union {
uint32_t reg; // Full 32-bit register access
ControlRegister fields; // Access individual fields
} PeripheralControl;
PeripheralControl peripheral;
// Function to initialize the peripheral
void initPeripheral() {
peripheral.fields.enable = 1; // Enable the peripheral
peripheral.fields.mode = 2; // Set mode to 2
peripheral.fields.speed = 5; // Set speed to 5
}
In this example
The ControlRegister struct defines the bitfields for the control register.
The PeripheralControl union allows you to either access the whole register ( reg )
or its individual fields ( fields ).
We use the __attribute__((packed)) directive to ensure that the struct is packed
tightly in memory without any padding, which is crucial in embedded systems
where memory layout is important.
This approach gives you flexibility in interacting with the peripheral’s
register. You can access the register as a whole for efficient writes or read
individual bits when you need to modify specific features.
In embedded systems programming, understanding how to use structs, unions,
and bitfields is essential for writing efficient, maintainable code. These
features allow you to directly interact with hardware in a structured way,
making your code more readable and manageable. By using structs and
unions, you can group related data together, while bitfields give you precise
control over individual bits in registers, which is often required when
working with hardware peripherals.
As you continue to work on embedded projects, mastering these techniques
will make it easier to create robust device drivers, manage hardware
resources efficiently, and maintain your code as it evolves. Always keep in
mind that each microcontroller and peripheral may have its own specific
configuration and register layout, so be sure to refer to the datasheet or
reference manual of your hardware for exact details. In the next chapter, we
will explore how to handle interrupts and write interrupt-driven code, which
is another critical skill in embedded system development.
Chapter 15
Building a Wireless Temperature Monitor (Project)
In this chapter, we will build a wireless temperature monitor that can
transmit temperature data to another board or even a PC. This project
combines several key components of embedded systems, including
temperature sensors, wireless communication modules, and the concept of
serial communication. The goal is not only to create a functional device but
also to help you develop the skills needed to work with sensors and wireless
communication in a hands-on project. We will cover the entire process stepby-step, including how to interface with a temperature sensor, implement the
wireless communication, and add reliability checks to ensure robust data
transmission.
Project Overview
The wireless temperature monitor consists of a temperature sensor (e.g.,
LM35 or DHT11), a wireless module (e.g., NRF24L01 or ESP8266), and a
microcontroller (such as an Arduino or STM32). The system will measure
the temperature and transmit the data wirelessly to a receiver (either another
microcontroller or a PC). This project will require serial communication
protocols (UART, SPI) to send and receive data, as well as basic errorchecking mechanisms to ensure that the data is accurately transmitted and
received.
The key components for this project are
Temperature Sensor This will measure the ambient temperature and convert
it into an analog or digital signal.
Microcontroller It will handle data acquisition from the sensor, convert it
into a suitable format, and manage communication with the wireless module.
Wireless Module (NRF24L01 or ESP8266) This module will handle the
wireless transmission of temperature data to a receiver.
Receiver This could be another microcontroller or a PC that receives the
data for display or further processing.
In this example, we will use the ESP8266 Wi-Fi module for wireless
communication, as it’s widely used and can be easily programmed with
Arduino IDE.
Step 1 Connecting the Temperature Sensor
We will use the LM35 temperature sensor, a popular and easy-to-use sensor
that provides an analog voltage proportional to the temperature in Celsius.
The sensor’s output is directly proportional to the temperature, with 10 mV
per degree Celsius.
Wiring the LM35 to the Microcontroller
The LM35 has three pins
VCC (Pin 1) Connect to the 5V power supply from the microcontroller.
GND (Pin 2) Connect to the ground (GND) of the microcontroller.
Output (Pin 3) This is the analog output, which you’ll connect to one of the
analog input pins of the microcontroller.
For example, if you are using an Arduino Uno, you can connect the output pin
of the LM35 to A0 (the analog input pin).
Reading the Temperature from the LM35
To read the temperature, we need to read the analog voltage from the sensor
and convert it into temperature. The Arduino code to achieve this is as
follows int sensorPin = A0; // Pin where the LM35 is connected
float temperature; // Variable to store the temperature
void setup() {
Serial.begin(9600); // Start serial communication at 9600 baud rate
}
void loop() {
int sensorValue = analogRead(sensorPin); // Read the sensor value (0 to 1023)
temperature = (sensorValue 5.0 / 1023.0) 100.0; // Convert the sensor value to temperature
Serial.print("Temperature ");
Serial.println(temperature); // Print the temperature to the serial monitor
delay(1000); // Wait for 1 second
}
In the code above, we read the analog value from pin A0 and convert it to
temperature
using
the
formula
Temperature
(°C)=sensor
value×5.01023.0×100.0\text{Temperature (°C)} = \frac{\text{sensor value}
\times 5.0}{1023.0} \times 100.0Temperature (°C)=1023.0sensor
value×5.0 ×100.0
Step 2 Adding Wireless Communication
Once we can successfully read the temperature, the next step is to transmit it
wirelessly to another device. For this, we will use the ESP8266 Wi-Fi
module. The ESP8266 allows the microcontroller to connect to a Wi-Fi
network, send data over the network, and communicate with other devices,
such as a PC or another microcontroller.
Connecting the ESP8266 to the Microcontroller
The ESP8266 has several pins, but for basic communication, we will need
the following
VCC Connect to 3.3V power supply.
GND Connect to ground.
TX Connect to the RX pin of the microcontroller.
RX Connect to the TX pin of the microcontroller.
For an Arduino Uno, the TX and RX pins of the ESP8266 will be connected
to the digital pins (Pin 2 and Pin 3) on the Arduino, respectively.
Setting Up the Wi-Fi Communication
To establish communication over Wi-Fi, you need to configure the ESP8266
to connect to a Wi-Fi network. The Arduino code to connect to Wi-Fi is as
follows #include <ESP8266WiFi.h>
const char* ssid = "YourWiFiSSID";
const char* password = "YourWiFiPassword";
void setup() {
Serial.begin(9600);
WiFi.begin(ssid, password); // Connect to Wi-Fi network
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.println("Connecting to WiFi...");
}
Serial.println("Connected to WiFi");
}
void loop() {
// Your logic for sending temperature data
}
In this code, we use the ESP8266WiFi.h library to connect the ESP8266 to a
Wi-Fi network using the WiFi.begin(ssid, password) function. It will keep trying to
connect until the status is WL_CONNECTED .
Step 3 Transmitting Data to a Receiver
Once the ESP8266 is connected to the Wi-Fi network, we can send the
temperature data to a remote server or another microcontroller. For this, we
will use HTTP or UDP communication. In this example, let’s use UDP for
simple communication.
The ESP8266 sends the temperature data over UDP to a predefined IP
address and port. Here is the code to send the temperature data #include
<WiFiUdp.h>
WiFiUDP udp;
const char* host = "192.168.1.100"; // IP address of the receiver (e.g., your PC or another board)
const int port = 12345; // Port to send the data to
void setup() {
Serial.begin(9600);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.println("Connecting to WiFi...");
}
udp.begin(8888); // Start listening on port 8888
}
void loop() {
int sensorValue = analogRead(sensorPin);
temperature = (sensorValue 5.0 / 1023.0) 100.0;
String temperatureData = String(temperature);
udp.beginPacket(host, port); // Start UDP packet
udp.write(temperatureData.c_str()); // Send the temperature data
udp.endPacket(); // End the packet
delay(1000); // Wait before sending the next data
}
In the code above, we use the WiFiUdp library to handle UDP
communication. The udp.beginPacket(host, port) function sends the data to the
specified IP address and port. The temperature data is sent as a string, and
each data packet is sent every second.
Step 4 Receiving and Displaying the Data
On the receiver end, you can use another ESP8266 module or a PC with a
simple server to receive the data and display it. If you’re using a PC, you
could create a simple Python server to listen for incoming UDP packets and
display the data.
Here is a simple Python UDP server to receive the temperature data
import socket
UDP_IP = "0.0.0.0" # Listen on all interfaces
UDP_PORT = 12345
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((UDP_IP, UDP_PORT))
while True
data, addr = sock.recvfrom(1024)
print("Received temperature ", data.decode())
This Python script listens on UDP port 12345 for incoming temperature data
and prints the received temperature value.
Step 5 Implementing Signal Reliability Checks
Wireless communication is prone to noise, interference, and data loss. To
ensure reliable communication, we can implement signal reliability checks
by sending acknowledgment packets or using checksums to verify the
integrity of the received data. A simple checksum can be implemented by
adding a sum of the bytes in the data and sending it with the packet. The
receiver can then calculate the checksum again and compare it with the
transmitted value. If the checksums don’t match, the receiver can request a
retransmission. In this chapter, you learned how to build a wireless
temperature monitor using a temperature sensor, a microcontroller, and a
Wi-Fi module. We covered the essential steps of reading data from the
sensor, transmitting it wirelessly, and receiving it on another device. By
completing this project, you not only gain hands-on experience with
temperature sensors and wireless communication but also develop the skills
needed to create more complex embedded systems. You also learned about
error-checking techniques to ensure reliable data transmission, which is
crucial in real-world applications.
Chapter 16
Interrupt-Driven Multitasking and Finite State
Machines
In this chapter, we will dive into interrupt-driven multitasking and the
concept of finite state machines (FSM), two powerful techniques that allow
embedded systems to handle multiple tasks in parallel efficiently. These
techniques are widely used in real-time systems to ensure that the system
responds promptly to external events, such as sensor readings or user input,
while also performing background tasks like updating displays or controlling
actuators. We will discuss how to use these techniques to build a system that
can read sensors, update displays, and control outputs concurrently, making it
a robust solution for many embedded applications.
Understanding Interrupt-Driven Multitasking
At its core, interrupt-driven multitasking allows a microcontroller to
temporarily halt its current task in favor of responding to a higher-priority
task, such as an input from a sensor or a timer event. This system is efficient
because it enables the microcontroller to focus on the most critical tasks
while continuing with other operations when interruptions are not present.
In a typical embedded system, the microcontroller’s processor runs a loop
where it continuously checks for conditions or states and performs tasks
based on those conditions. However, if something important happens, such as
a sensor value exceeding a threshold, the system can use interrupts to
temporarily interrupt the current execution flow, execute a specific function,
and then return to the main program.
Interrupts can be hardware-based (e.g., when an external pin changes state)
or software-based (e.g., based on a timer). This allows a system to perform
actions like updating a display while waiting for sensor readings, or even
managing different sensors at once, without waiting for the main loop to
complete.
How Interrupts Work
When an interrupt occurs, the processor suspends its current operation and
transfers control to an interrupt handler, also known as an interrupt service
routine (ISR). Once the ISR completes its task, control is returned to the
main program at the point where it was interrupted. Interrupts are essential
for handling real-time events without missing critical information.
For example, suppose you are using a sensor to measure temperature and a
separate sensor to measure humidity. Without interrupts, you would need to
continuously poll both sensors in a loop, which could lead to delays or
missed readings. By using interrupts, the microcontroller can instantly
respond to changes in sensor readings, processing the data as soon as it’s
available.
Setting Up an Interrupt
To set up an interrupt in a typical microcontroller, you must
Enable the interrupt for the specific pin or event.
Define the interrupt handler (ISR) that will execute when the interrupt
occurs.
Clear the interrupt flag once the ISR is complete, allowing the system to
return to normal operation.
Consider this simple example using the Arduino platform, where we use an
external pin to trigger an interrupt. The interrupt service routine reads the
state of the pin and updates a variable volatile int interruptCount = 0;
void setup() {
pinMode(2, INPUT); // Pin 2 is configured as an input for interrupt
attachInterrupt(digitalPinToInterrupt(2), handleInterrupt, RISING); // Trigger on rising edge
Serial.begin(9600);
}
void loop() {
Serial.println(interruptCount); // Print interrupt count to monitor activity
delay(1000);
}
void handleInterrupt() {
interruptCount++; // Increment count when interrupt occurs
}
In this code, when the signal on pin 2 rises (i.e., goes from LOW to HIGH),
the ISR handleInterrupt() is triggered. It increments the interruptCount
variable, which can be observed in the main loop.
Finite State Machines (FSM)
A finite state machine (FSM) is a computational model that describes a
system with a limited number of states, transitions between those states, and
actions associated with those states. Each state represents a particular
condition or behavior of the system, and the system transitions from one state
to another based on inputs or events.
FSMs are extremely useful for modeling complex systems that need to
respond to a variety of inputs. Rather than trying to handle all behavior in a
single, continuous program loop, FSMs allow you to break down the
problem into manageable, distinct states. For example, in an embedded
system controlling a fan, the system may have states such as Idle, On, and
Off, each with its own behavior.
Key Components of an FSM
States These represent the different modes or conditions the system can be
in.
Transitions These define the conditions under which the system moves from
one state to another.
Actions These are the tasks that occur when the system enters a new state.
FSMs help keep the design simple and modular by dividing a system’s
behavior into clearly defined states and transitions. They are widely used in
embedded systems, especially for handling tasks like user interfaces, signal
processing, and protocol handling.
Building a Simple FSM
In this example, we’ll create a simple FSM to control the behavior of a
temperature monitoring system. The system will have three states Idle,
Reading, and Displaying.
Idle The system waits for a trigger, like a button press or a timer, to begin
reading data.
Reading The system reads the temperature sensor.
Displaying After reading the temperature, the system displays the data on an
LCD or serial monitor.
We can model the FSM using the following state transition diagram
FSM Code Example (Arduino)
Let’s break down the FSM in code. We will define the states, the transitions,
and the actions associated with each state.
// Define the states
enum State {Idle, Reading, Displaying};
State currentState = Idle;
void setup() {
Serial.begin(9600);
pinMode(7, INPUT); // Example trigger button on pin 7
}
void loop() {
switch(currentState) {
case Idle
if (digitalRead(7) == HIGH) { // If button is pressed, move to Reading state
currentState = Reading;
}
break;
case Reading
// Simulate sensor reading (replace with actual sensor code)
int temperature = analogRead(A0); // Read temperature from sensor
currentState = Displaying;
break;
case Displaying
// Simulate displaying the result (can be replaced with actual display code)
Serial.print("Temperature ");
Serial.println(analogRead(A0));
currentState = Idle; // Return to Idle state after displaying
break;
}
}
In this simple FSM, the system starts in the Idle state. When a button is
pressed, the system moves to the Reading state, where it reads the
temperature from an analog sensor. Once the temperature is read, the system
transitions to the Displaying state, where it outputs the value. After
displaying, it transitions back to the Idle state, awaiting the next trigger.
Combining Interrupts and FSM
One of the key advantages of using interrupts with FSMs is that interrupts
allow us to break free from polling in a main loop. By using interrupts, the
system can handle real-time events (such as sensor data collection or user
input) and still efficiently manage tasks like updating displays or controlling
outputs. Let’s combine interrupts and FSMs in a more advanced example
Use Case Temperature Control System
Imagine you’re building a temperature control system that reads
temperature data from a sensor, updates the display, and controls a fan based
on the temperature. The FSM would handle the states of the system (Idle,
Reading, Displaying, and Controlling), and the interrupts would be used to
trigger readings and control actions in real-time.
In this system, interrupts can be used to trigger the reading of the sensor
periodically using a timer interrupt. At the same time, the FSM can manage
the transitions between the states to read the sensor, update the display, and
control the fan.
The interrupt-driven multitasking system allows your microcontroller to
handle multiple tasks simultaneously by using interrupts to break the main
loop and perform specific tasks. Meanwhile, the finite state machine
approach enables clear, organized management of system behavior, ensuring
that each task is handled in the appropriate order. By combining these two
techniques, you can build responsive, efficient systems capable of managing
complex behaviors and real-time data processing in parallel.
In this chapter, we have covered the powerful concepts of interrupt-driven
multitasking and finite state machines. You learned how interrupts allow
your microcontroller to respond to real-time events promptly, while FSMs
provide a structured approach to managing system behavior. By combining
both techniques, you can design systems that are both responsive and
organized, ideal for applications that require real-time control and efficient
handling of multiple tasks. Through hands-on examples and projects, you
have seen how to implement these concepts in embedded systems, providing
you with the tools to create robust and responsive products.
Chapter 17
Embedded Debugging and Testing
In this chapter, we will explore the fundamental techniques of debugging and
testing for embedded systems development. Debugging is an essential skill
for every embedded systems engineer, as it helps identify and fix errors in the
code and hardware. Without proper debugging, even the smallest issues in
hardware or software can lead to project failure. Testing, on the other hand,
ensures that your system behaves as expected and meets its requirements
before deployment. Together, debugging and testing form the backbone of
building reliable, efficient, and functional embedded systems.
We will cover several important debugging tools and techniques, including
serial prints, logic analyzers, and simulators. Additionally, we will learn
how to create test harnesses in C that help validate code for various
components such as sensors, logic functions, and communication protocols.
By the end of this chapter, you will have gained hands-on experience with
debugging and testing methods that will aid you in building and optimizing
embedded systems.
Introduction to Debugging Techniques
Embedded systems are often constrained by limited resources such as
processing power, memory, and debugging interfaces. Because these systems
usually do not have a traditional graphical debugging environment like
desktop applications, engineers need to rely on a combination of tools and
techniques to troubleshoot and optimize their code. Let's discuss the most
commonly used methods for debugging embedded systems.
1. Serial Prints
One of the simplest and most widely used debugging techniques in embedded
systems is the use of serial prints. This method involves sending debug
messages to the serial monitor or terminal interface, allowing the developer
to monitor the internal state of the program at various points. Serial prints are
invaluable for tracking variable values, function calls, and program flow,
especially when physical or hardware debugging tools are unavailable.
In C and Arduino-based systems, the Serial library can be used to send data
to the serial monitor. The most common use case is printing values during
runtime to track the system's behavior.
For example, let’s say we want to debug the reading of a temperature sensor.
We can use Serial.print() to output the raw sensor value and the calculated
temperature {
Serial.begin(9600); // Start serial communication
pinMode(A0, INPUT); // Set analog pin A0 for sensor input
}
void loop() {
int sensorValue = analogRead(A0); // Read raw sensor data
float voltage = sensorValue * (5.0 1023.0); / Convert to voltage
float temperature = (voltage - 0.5) * 100; // Convert to Celsius
// Output values to serial monitor for debugging
Serial.print("Sensor Value ");
Serial.print(sensorValue);
Serial.print(" Voltage ");
Serial.print(voltage);
Serial.print(" Temperature ");
Serial.println(temperature);
delay(1000); // Wait for 1 second
}
In this example, the sensorValue, voltage, and temperature are printed to
the serial monitor every second. By observing these printed values, you can
verify if the sensor readings are within expected ranges, ensuring the sensor
is working correctly.
However, serial prints can be relatively slow and intrusive, especially in
time-sensitive systems. Therefore, excessive use of serial prints can affect
the performance of your system. For more complex debugging, other tools
and techniques may be more suitable.
2. Logic Analyzers
A logic analyzer is a powerful tool used to analyze digital signals in
embedded systems. It allows you to observe and record the logic levels
(high/low) of multiple signals at once. Logic analyzers are especially useful
for debugging communication protocols like I2C, SPI, and UART, which
involve digital signal exchanges between devices.
When using a logic analyzer, the signals from various parts of your system
are connected to the analyzer’s inputs. The analyzer then captures and
displays the waveforms of these signals over time. This allows you to
visually inspect the timing, sequence, and integrity of communication
between components.
For example, let’s say you're troubleshooting an I2C communication issue
between an Arduino and an LCD display. The logic analyzer can help you
monitor the SCL (clock) and SDA (data) lines to ensure proper signal
timing and data transmission.
Here’s a simple setup for using a logic analyzer to monitor I2C
communication between a microcontroller and a peripheral
1. Connect the analyzer probes to the SCL and SDA lines.
2. Set the I2C bus speed and trigger on the start condition or address
of the communication.
3. Observe the signal integrity and timing in the analyzer’s software.
By inspecting the captured data, you can identify if the communication is
happening correctly, if there’s noise on the lines, or if the timing between
clock and data is incorrect. Logic analyzers are incredibly useful for lowlevel debugging, as they provide a clear visual representation of what’s
happening at the signal level.
3. Simulators
Simulators can be invaluable when debugging complex embedded systems
that involve intricate hardware interactions. A simulator allows you to run
your code in a virtual environment, mimicking the behavior of your hardware
without needing access to the physical components. This approach is often
used in early stages of development or when hardware is unavailable for
testing.
In embedded development, tools like Proteus or Simulink can simulate
embedded systems. These simulators allow you to write and test your code
without having to worry about specific hardware setup. They also help in
detecting logical errors that might not be apparent through simple code
inspection.
For example, using Proteus, you can create a schematic of your embedded
system, including microcontrollers, sensors, and other peripherals. You can
then load your firmware into the simulator and run it to see if the system
behaves as expected. This is extremely helpful for identifying issues in
wiring or sensor initialization without the need for physical components.
While simulators can be powerful, they may not be able to replicate all the
nuances of real-world hardware behavior, especially when it comes to
electrical noise, timing issues, or sensor inaccuracies.
Building Test Harnesses in C
A test harness is a piece of code designed specifically to test parts of your
system in isolation. It is especially helpful in embedded development when
you need to verify that individual components (like sensors, logic functions,
or communication protocols) are working as expected before integrating
them into the full system.
Test Harness for Sensor Code
Let’s assume you are working with a temperature sensor (e.g., LM35) and
you want to verify that the sensor's code is working correctly. A test harness
allows you to isolate the sensor code and validate its functionality
independently.
Here’s how you could set up a basic test harness for a temperature sensor in
C
Initialize the sensor and configure the necessary pins.
Read the sensor’s output (analog value).
Convert the sensor output to a usable value (temperature).
Verify that the temperature value falls within an expected range.
#include <stdio.h>
#include <stdlib.h>
#define TEMP_SENSOR_PIN A0
// Test harness function for the LM35 sensor
void test_sensor() {
int sensorValue = analogRead(TEMP_SENSOR_PIN); // Read the raw sensor value
float temperature = sensorValue (5.0 / 1023.0) 100.0; // Convert to Celsius
printf("Raw Sensor Value %d\n", sensorValue);
printf("Temperature %.2f°C\n", temperature);
// Verify that the temperature is within a reasonable range
if (temperature > 0 && temperature < 100) {
printf("Sensor is working correctly.\n");
} else {
printf("Error Temperature out of range.\n");
}
}
int main() {
test_sensor(); // Run the sensor test harness
return 0;
}
In this example, the test_sensor function reads data from the sensor, converts
it to a temperature, and then checks if the temperature is within a valid range.
If the sensor is working correctly, the test harness will print "Sensor is
working correctly." Otherwise, it will notify you of an error.
By creating test harnesses, you can validate your components in isolation
before integrating them into the complete system. This approach minimizes
the chance of issues arising during system integration and makes debugging
much easier.
In this chapter, we have explored various essential techniques for debugging
and testing embedded systems. Debugging tools like serial prints, logic
analyzers, and simulators provide invaluable insight into the inner workings
of a system, helping to identify issues at different levels of development.
Furthermore, test harnesses in C allow for isolated testing of individual
components, ensuring that each part of your system functions correctly before
it is integrated into a complete solution. By mastering these techniques, you
will be able to develop embedded systems more efficiently, catching and
fixing bugs early in the development cycle and ultimately building more
reliable and robust products. The combination of debugging tools and testing
practices will not only help you validate your designs but also improve your
overall understanding and confidence as an embedded systems engineer.
Chapter 18
Capstone Project – Build a Home Automation
Control Hub
In this chapter, we are going to combine all the concepts we have learned so
far to build a complete Home Automation Control Hub. This project will
serve as a real-world application of embedded systems principles, where
you will integrate various components such as input sensors, real-time
clocks, relay control, wireless feedback, LCD menus, and power
management.
The goal is to simulate a smart home controller system using modular code
and real-time behavior. By the end of this chapter, you will have built a fully
functioning home automation system that can control various appliances and
provide real-time feedback, all while keeping power consumption optimized.
Project Overview Smart Home Control Hub
A Home Automation System (HAS) allows homeowners to remotely
monitor and control their home environment. In this project, we will design a
basic system that can
Monitor environmental variables such as temperature, humidity, and
light levels using input sensors.
Control appliances such as lights, fans, and other devices through
relays.
Use a real-time clock (RTC) to schedule actions like turning on
lights or setting a thermostat.
Provide a wireless interface for remote monitoring or control,
allowing feedback to be sent to a smartphone or PC.
Display information and provide a menu system via an LCD
display.
Use power management techniques to optimize the system's
energy consumption.
This project will integrate various concepts including sensors, wireless
communication, data logging, and user interfaces to provide you with handson experience in building a practical embedded system.
Step 1 Setting Up the System Components
The first step is to set up the physical components that will be integrated into
our home automation system. These include the following
Microcontroller
We will use an Arduino (e.g., Arduino Uno or Arduino Mega) as the central
controller for the system. The microcontroller will process input from
sensors, control relays, interface with the real-time clock, and communicate
wirelessly. This is the core of our system that will handle the logic and
processing.
Input Sensors
Temperature and Humidity Sensor (DHT11/DHT22) These sensors will
help monitor the environmental conditions inside the house. The DHT11 is a
low-cost sensor that can provide both temperature and humidity readings.
Light Sensor (LDR or BH1750) A light-dependent resistor (LDR) or a
digital light sensor (BH1750) will allow us to monitor the lighting conditions
in the room. This can be used to automatically adjust the lighting based on
ambient light levels.
Relays
Relay Module This is the hardware component that will allow us to control
high-voltage devices (such as lights, fans, or other household appliances).
The relay will be activated by the microcontroller to turn the devices on or
off based on the logic programmed into the system.
Real-Time Clock (RTC)
DS3231 RTC Module A real-time clock is critical for this project because
we want to control actions at specific times. The DS3231 is an accurate and
widely used RTC that will allow us to schedule operations, such as turning
lights on at a certain time or triggering the fan based on temperature.
Wireless Communication
ESP8266 Wi-Fi Module For remote control and monitoring, we will use an
ESP8266 Wi-Fi module. This will enable the system to send real-time data
to a smartphone or a PC and receive commands to control appliances.
LCD Display
16x2 LCD Display with I2C Interface This display will show system
status, sensor readings, and provide a simple menu interface for the user. It
will be helpful for users to check the current temperature, humidity, or light
levels and make adjustments.
Power Management
To make the system energy-efficient, we will implement power management
techniques such as using sleep modes for the microcontroller when the
system is idle, turning off unused peripherals, and adjusting the system’s
performance as needed.
Step 2 Designing the System Logic
Now that we have the components, we need to design the logic for the
system. This involves structuring how each part of the system will interact
and define the sequence of operations.
1. Environmental Monitoring and Control
The system will continuously monitor environmental variables such as
temperature, humidity, and light levels using the respective sensors. The
readings will be taken at regular intervals and displayed on the LCD.
If the temperature exceeds a predefined threshold, the system will trigger a
relay to turn on a fan. Similarly, if the light level is too low, it could trigger a
relay to turn on a light. The logic for this might look something like this //
Example logic for controlling a fan based on temperature
if (temperature > 25.0) {
digitalWrite(fanRelayPin, HIGH); // Turn on the fan
} else {
digitalWrite(fanRelayPin, LOW); // Turn off the fan
}
2. Scheduling with the RTC
We will use the real-time clock to schedule actions. For instance, we might
want to turn on the lights at 6 00 PM or turn off the fan at midnight. The
system will check the current time from the RTC and compare it to the
predefined schedule. If it matches, the corresponding relay will be activated
or deactivated.
// Example logic for scheduled actions using the RTC
if (rtc.getHours() == 18 && rtc.getMinutes() == 0) {
digitalWrite(lightRelayPin, HIGH); // Turn on the light at 6 00 PM
}
3. Wireless Feedback and Control
Using the ESP8266 module, we will send sensor data (such as temperature,
humidity, and light levels) to a cloud server or directly to a smartphone
application. This can be achieved through HTTP or MQTT protocols.
The ESP8266 will allow the system to receive commands remotely, so the
user can turn on/off appliances from a smartphone or PC. For example, an
app could send a signal to the Arduino to turn on the light or fan based on the
user’s input.
// Example of sending temperature data to a server
WiFiClient client;
if (client.connect(server, 80)) {
client.print("GET /update?temp=");
client.print(temperature);
client.println(" HTTP/1.1");
}
4. LCD Menu System
A simple menu system will be designed to allow users to interact with the
system through buttons or a rotary encoder. The user can view sensor
readings, change the thresholds for temperature or light levels, and
activate/deactivate appliances. The 16x2 LCD will display the menu options,
and users can navigate through them using buttons or a dial.
For example, the menu system could allow users to adjust the temperature
threshold for fan activation. This might look something like
// LCD menu to adjust fan temperature threshold
lcd.setCursor(0, 0);
lcd.print("Fan Temp ");
lcd.print(fanThreshold);
lcd.setCursor(0, 1);
lcd.print("Press + or -");
Step 3 Power Management
As our system will be running continuously, it’s essential to manage power
efficiently. We can implement sleep modes where the microcontroller and
peripherals (like the ESP8266 and LCD) can be put to sleep when not in use.
This will save battery if the system is running off a battery pack or reduce
power consumption when connected to mains power.
For instance, the Arduino can be set to sleep for a few seconds after taking
sensor readings, only waking up to check for changes in sensor values or
when triggered by the user via wireless communication.
// Example of putting the system to sleep
LowPower.sleep(60000); // Sleep for 60 seconds
Step 4 Building the System
Now that we have all the logic in place, it’s time to build the system
physically. You’ll need to wire up the sensors, relays, and other components
to the microcontroller, ensuring that all connections are secure.
Important Wiring Considerations
The temperature and humidity sensor (DHT11) will be connected
to one of the analog or digital input pins (depending on the sensor
version).
The relay module will be connected to a digital output pin, which
will control the state of the connected appliances.
The LCD will be connected to the I2C bus for easy communication.
The ESP8266 will be connected to the microcontroller using UART
for serial communication.
Step 5 Testing and Debugging
After assembling the system, you should test each component individually
before running the full system. Check the sensor readings to ensure they are
accurate, test the relay control for turning appliances on/off, and verify that
the wireless communication is functioning correctly.
Use debugging tools like serial prints to monitor system behavior and ensure
that everything is working as expected. You can also use logic analyzers to
check the signal integrity of the digital communications.
In this capstone project, we have successfully combined the knowledge and
skills from earlier chapters to build a comprehensive home automation
control hub. The project integrates sensors, real-time clocks, relays, wireless
communication, LCD interfaces, and power management techniques to create
a functional smart home system. By working on this project, you’ve not only
learned how to design and implement embedded systems but also gained
hands-on experience in troubleshooting, testing, and optimizing real-world
applications.
This project will help you solidify your understanding of embedded systems
while providing a practical, hands-on solution that can be further expanded
and customized. You are now ready to take these skills and apply them to
more advanced projects, perhaps even designing your own unique automation
systems tailored to specific needs or environments.
Chapter 19
Going Beyond – Bootloaders, RTOS, and C
Libraries
As we near the completion of the fundamental principles behind embedded
systems, it’s time to delve deeper into more advanced topics that will further
enhance your ability to develop sophisticated embedded systems. In this
chapter, we will introduce three major concepts bootloaders, Real-Time
Operating Systems (RTOS), and C libraries. These concepts will not only
broaden your skillset but will also prepare you for more complex,
professional embedded system development. Along with the technical
details, we will provide guidance on where to go next in your journey to
continue advancing your knowledge and career in embedded systems.
1. Bootloaders The Gateway to Embedded Systems
A bootloader is a small program that runs when an embedded system is
powered on or reset. Its primary role is to load the main application from
memory (typically flash) into RAM and transfer control to it. Without a
bootloader, the embedded system would be unable to start the application
code directly.
Why Bootloaders Matter
In more advanced embedded systems, you may need to update or load
applications dynamically, debug at boot time, or implement custom firmware
functionality. A bootloader allows for flexibility, such as Firmware updates
The bootloader enables updating firmware without needing to remove or
reprogram the microcontroller chip, often through serial communication or
over-the-air (OTA) updates.
Debugging The bootloader can help in diagnosing boot issues, loading logs,
or even running diagnostics before jumping to the main application code.
Multi-application environments Bootloaders can choose from multiple
applications stored in flash memory, deciding which to load based on certain
conditions or user inputs.
Writing Your Own Bootloader
Writing a bootloader involves low-level programming that communicates
directly with the hardware. Below is an outline of how you can go about
writing a simple bootloader for an embedded system Setup the environment
The bootloader must first initialize the microcontroller’s essential
components, like memory, clocks, and basic I/O peripherals.
Check for a valid application
It then checks if a valid application is present in the flash memory. This may
involve verifying a checksum or a specific flag set in memory by the
application.
Handle communication
For updates, the bootloader will communicate with an external interface,
such as UART, SPI, or Ethernet. A common method is to receive a new
firmware image over UART and write it into flash memory.
Jump to the application code
Once the firmware update or application load process is complete, the
bootloader hands control over to the main application code. This is typically
done by modifying the Program Counter (PC) register to the start of the
application.
Here’s a simple example of how a bootloader might look in C for a
microcontroller
#include <stdint.h>
#define APPLICATION_ADDRESS 0x08008000 // Address where application starts
typedef void (*application_entry)(void); // Define application entry point type
void jump_to_application(void) {
application_entry app_start = (application_entry)*(uint32_t*)APPLICATION_ADDRESS;
app_start(); // Jump to the application code
}
int main(void) {
// Initialize peripherals here (if needed)
// Verify if an application exists at the start address
if (is_application_valid(APPLICATION_ADDRESS)) {
jump_to_application(); // Jump to the application if valid
}
// Otherwise, enter some fallback mode or error handling
}
In the code above, the bootloader first checks if the application is valid. If it
is, it jumps to the application code, which is located at a predefined address
in the flash memory.
Where to Go Next with Bootloaders
To dive deeper into bootloaders, you can explore open-source bootloader
projects like AVR Bootloader, STM32 Bootloader, or Teensy Bootloader
to understand different implementation strategies. Additionally, learning how
to implement secure bootloading (e.g., with cryptographic verification of
firmware) will be a valuable skill for high-stakes embedded systems, like
IoT or automotive applications.
2. Real-Time Operating Systems (RTOS) in
Embedded Systems
A Real-Time Operating System (RTOS) is an operating system designed
for embedded systems where timing and predictability are crucial. Unlike
general-purpose operating systems like Linux or Windows, an RTOS
guarantees that specific tasks will be executed within a given time constraint.
This makes RTOS essential for applications such as robotics, automotive,
medical devices, and communications systems.
What Is Multitasking in RTOS?
Multitasking allows multiple tasks to be executed in parallel on an embedded
system. This is essential in real-time systems, where various functionalities
must occur concurrently, such as reading sensor data, updating displays, or
controlling motors, all while ensuring that each task completes within a
certain time frame.
An RTOS provides tools to manage multitasking, including task scheduling,
synchronization mechanisms (e.g., mutexes), and inter-process
communication (IPC). It typically uses a preemptive scheduling model,
which ensures high-priority tasks can interrupt lower-priority tasks to meet
deadlines.
Introducing FreeRTOS
One of the most widely used real-time operating systems in embedded
systems development is FreeRTOS. FreeRTOS is open-source and offers a
rich feature set for multitasking, task scheduling, and inter-task
communication. It runs on a wide variety of microcontrollers and supports
many processor architectures.
Here’s a brief look at how you can use FreeRTOS to create a simple task for
reading a sensor
Initialize FreeRTOS Set up FreeRTOS by initializing the RTOS kernel and
configuring task scheduling.
Create tasks Each task in FreeRTOS is associated with a function that it
will execute. Tasks are created with priorities, which determine their
execution order.
#include <FreeRTOS.h>
#include <task.h>
// Task function to read sensor data
void vTaskReadSensor(void *pvParameters) {
while(1) {
int sensor_value = read_sensor();
printf("Sensor Value %d\n", sensor_value);
vTaskDelay(1000); // Delay for 1 second
}
}
int main(void) {
// Initialize FreeRTOS kernel
xTaskCreate(vTaskReadSensor, "Sensor Task", 128, NULL, 1, NULL);
// Start scheduler
vTaskStartScheduler();
while(1); // Should never reach here
}
In this code, the vTaskReadSensor task reads a sensor value and prints it
every second. The vTaskDelay function ensures that the task waits for a
specific time before executing again. FreeRTOS handles task scheduling and
ensures the system responds to real-time constraints.
Where to Go Next with RTOS
To expand on FreeRTOS, try learning about task synchronization (e.g.,
semaphores and mutexes) and inter-task communication (e.g., queues and
mailboxes). Also, understand priority inversion and how FreeRTOS handles
it. For advanced projects, explore resource management and real-time
performance tuning to meet stringent timing constraints.
3. Creating Reusable C Libraries for Embedded
Systems
When building embedded systems, it's essential to write reusable C libraries
to abstract and encapsulate hardware interactions and common
functionalities. These libraries improve code maintainability, allow for
easier testing, and speed up development by reusing tried-and-tested
modules.
A C library consists of functions and definitions that can be reused across
multiple projects. For instance, if you're working with an LCD screen, you
can create a C library that abstracts the complexities of communication and
control, making it easy to use the display in different projects.
Creating a C Library for LCD Display
Let’s say we want to create a C library to control an LCD with an I2C
interface. The library would provide functions like initialization, printing
text, and clear screen.
Here's an example of how a simple LCD library might look in C
// lcd.h - Header file for LCD library
#ifndef LCD_H
#define LCD_H
void lcd_init(void);
void lcd_clear(void);
void lcd_print(char *str);
#endif
// lcd.c - Implementation of LCD functions
#include "lcd.h"
#include <Wire.h>
#define LCD_ADDR 0x3F // I2C address for the LCD
void lcd_init(void) {
Wire.begin();
// Initialize the LCD (sending commands to set up the display)
}
void lcd_clear(void) {
Wire.beginTransmission(LCD_ADDR);
Wire.write(0x01); // Clear display command
Wire.endTransmission();
}
void lcd_print(char *str) {
Wire.beginTransmission(LCD_ADDR);
while (*str) {
Wire.write(*str++);
}
Wire.endTransmission();
}
With this setup, you can include lcd.h in your main project and use the
functions without worrying about the underlying details of I2C
communication.
Where to Go Next with C Libraries
Explore creating libraries for other peripheral modules like motors, sensors,
and communication interfaces. Learn about static and dynamic libraries
and how to optimize your libraries for different architectures. For a deeper
understanding, explore low-level programming techniques and memory
management.
In this chapter, we have explored some of the more advanced topics in
embedded systems development. By learning about bootloaders, real-time
operating systems, and C libraries, you now have the foundation to work on
more complex and professional embedded systems.
Appendices
As you venture into the world of embedded systems, having easy access to
quick-reference materials can be a game-changer. This section is dedicated
to providing valuable resources that you can keep handy as you continue
building and troubleshooting embedded systems. Each appendix will be a
useful reference tool, containing essential information to help you navigate
the complexities of embedded system design.
Appendix A
C Language Cheat Sheet
The C programming language is the foundation for embedded system
development. It provides the flexibility to write code that can interact with
hardware directly while maintaining portability. This cheat sheet will guide
you through the most common C language concepts, keywords, syntax, and
tips that will help you write clean and efficient embedded code.
Basic Data Types
Understanding the fundamental data types in C is crucial for manipulating
data in embedded systems. In embedded systems, memory is often
constrained, so choosing the correct data type is essential to optimize
storage.
int Represents an integer. The size may vary based on the platform
(e.g., 16-bit or 32-bit).
float Used for floating-point numbers (with decimals), but may not be
available on all microcontrollers due to limited processing power.
Represents a single character or a small integer (usually 1
byte).
char
void Represents an absence of a value, often used in functions that do
not return anything.
long A larger integer type, typically 32-bit on most systems.
Control Structures
Control structures are used to manage the flow of your program. Common
control structures in C include
if , else , and else if These are conditional statements that allow
branching based on the truth or falsity of a condition.
switch Used for multiple branching, where the value of an expression
is compared with a list of cases.
for , while , and do-while These are loop constructs that enable repetitive
execution of a block of code.
Example
for (int i = 0; i < 10; i++) {
printf("Count %d\n", i);
}
Pointers
Pointers are a powerful feature of the C language. A pointer stores the
memory address of a variable, allowing direct memory access and
manipulation. They are essential in embedded systems for efficient resource
management.
Example
int var = 10;
int *ptr = &var; // Pointer to var
printf("Value %d, Address %p\n", *ptr, ptr);
Appendix B
Microcontroller Pin Mapping and Registers
In embedded system design, the microcontroller's pin mapping and registers
are key components that determine how hardware interacts with the software.
Pin mapping tells you what each pin on the microcontroller does, and
registers allow you to control hardware peripherals like timers, GPIO pins,
and communication interfaces.
Pin Mapping
Each microcontroller has a specific configuration for its pins. The
microcontroller’s datasheet will show a table or diagram detailing how each
pin is mapped to various peripherals, such as digital input/output (I/O),
PWM, UART, SPI, I2C, and analog-to-digital conversion (ADC).
For example, the STM32 microcontroller has pins for GPIO, which can be
configured as input or output. Its pin mapping might look something like
Pin
Name
PA0
PB6
PA9
Functionality
Analog Input (ADC)
UART
TX
(Transmit)
PWM Output
Each microcontroller family will have a different pinout diagram, so you
should always refer to the specific datasheet or user manual for your chosen
chip.
Registers
Registers are small, fast memory locations within the microcontroller used to
control peripherals. They are mapped to specific hardware functions and can
be read from or written to by your program. You interact with registers by
writing values to them or reading the values stored in them. Each peripheral
has a set of registers that define its configuration, status, and control. For
example, to configure a GPIO pin for output, you might write a specific value
to a control register. Here is a simplified example for a general-purpose I/O
(GPIO) register GPIOA->MODER |= ( 1 << 10 ); // Set pin PA5 as output This line
of code writes a value to the MODER register of the GPIO port A,
configuring the PA5 pin as an output.
Appendix C
Common Embedded Errors and How to Fix Them
As you work with embedded systems, you are bound to encounter errors.
Some of the most common issues in embedded programming include
hardware misconfigurations, timing problems, and memory-related issues.
Below are some of the most frequent problems and suggested fixes
1. Stack Overflow
Problem If the stack grows too large, it will overwrite memory, causing a
crash or unpredictable behavior.
Solution Increase the stack size if necessary and carefully manage function
calls and local variables. Use the -fstack-limit compiler option if available.
2. Unused Variables or Functions
Problem You may accidentally leave unused variables or functions in your
code, consuming valuable memory and processing resources.
Solution Regularly clean your code and use the __attribute__((unused)) directive
or similar techniques to remove unused elements.
3. Timing Issues
Problem Embedded systems often require precise timing for communication
and control. Incorrect timing can cause peripherals to malfunction.
Solution Ensure that interrupts are configured correctly and make use of
timers to handle time-sensitive operations. Verify that timing-critical code is
placed in the appropriate interrupt service routines (ISRs).
4. Debugging Interrupts
Problem If interrupts aren’t configured properly, they might not trigger as
expected, or the wrong interrupt vector could be executed.
Solution Use debugging tools like JTAG or SWD for step-by-step
inspection. Check the interrupt vector table and ensure the correct priorities
are assigned.
Appendix D
Datasheets and Schematic Reading Guide
In embedded systems, datasheets and schematics are essential for
understanding the hardware components you're working with. These
documents provide the necessary details to interface with sensors, displays,
and microcontrollers. Learning to read these documents efficiently will save
you time and prevent costly mistakes in hardware and software design.
Reading a Datasheet
Datasheets contain a wealth of information about components, but you don’t
need to read them cover-to-cover. Focus on the following key sections
Pinout Diagram Shows how the component pins are assigned and how they
should be connected.
Electrical Characteristics Lists the voltage, current, and power
requirements.
Functional Block Diagram Describes the internal architecture of the
component and its submodules.
Timing Diagrams Helps you understand how signals interact over time,
which is crucial for communication protocols like SPI or I2C.
Reading a Schematic
A schematic is a diagram showing how components are wired together. It is
crucial to understand common symbols such as resistors, capacitors,
transistors, and microcontroller connections. Pay close attention to Power
and Ground Connections These are often represented as a thick line or a set
of horizontal lines and are crucial to proper circuit operation.
Signal Lines Look for connections between components that represent
communication or control signals (e.g., I2C, SPI).
Decoupling Capacitors These are used to smooth out power supply noise
and are typically placed near power pins of sensitive components.
Appendix E
Glossary of Embedded Terms
To facilitate your understanding of embedded systems development, we’ve
compiled a glossary of terms that are frequently used throughout this book
and in the embedded systems community at large. Familiarizing yourself with
these terms will give you a stronger foundation as you advance in embedded
systems design.
Microcontroller (MCU)
A small, integrated computer on a chip that contains a processor, memory,
and input/output peripherals.
GPIO (General Purpose Input/Output)
Pins on a microcontroller that can be configured as either inputs or outputs to
interact with other devices.
UART (Universal Asynchronous Receiver-Transmitter)
A communication protocol used for serial data transfer. It is commonly used
to interface with peripherals like GPS modules or Bluetooth.
PWM (Pulse Width Modulation)
A technique used to simulate an analog output by switching a digital pin on
and off at a specific duty cycle.
I2C (Inter-Integrated Circuit)
A two-wire communication protocol that allows multiple devices to
communicate with a microcontroller using just two lines (SCL for clock and
SDA for data).
SPI (Serial Peripheral Interface)
A communication protocol that allows data transfer between a master device
and one or more peripherals using multiple data lines.
With these appendices, you now have a handy reference to various aspects of
embedded systems development. Whether you are debugging code,
interpreting datasheets, or simply needing quick syntax reminders, these
appendices will serve as valuable resources throughout your embedded
programming journey.
THE END