Learning Goals 5 min
Parallel arrays — one for names, one for values, one for thresholds — drift out of sync the moment your project grows. A struct bundles related values into one named record. By the end of this lesson you will:
- Declare a
structwith named fields and create instances of it. - Access fields with the dot operator and pass structs to functions (by value or by reference).
- Store an array of structs to model a list of sensors, motors, or other repeating entities — the bridge to classes (tomorrow).
Warm-Up 10 min
You've already used a few structs without knowing: Motor in motor.h from L03-08 / L03-09, the LampState in the smart-lamp project, and ShiftReg in shift595.h. Today we formalise what they are and use them more deliberately.
The problem they solve
Imagine you're tracking three sensors: name, pin, last reading, threshold, alarm-fired flag. With parallel arrays:
const char* NAMES[] = { "Light", "Temp", "Moisture" };
const int PINS[] = { A0, A1, A2 };
int readings[] = { 0, 0, 0 };
int thresholds[] = { 300, 500, 200 };
bool firedFlags[] = { false, false, false };Five arrays. Add a new field → add a 6th array. Add a new sensor → add a row to all 5 arrays. Easy to miss one. Easy to swap indices accidentally.
With a struct:
struct Sensor {
const char* name;
int pin;
int reading;
int threshold;
bool fired;
};
Sensor sensors[] = {
{ "Light", A0, 0, 300, false },
{ "Temp", A1, 0, 500, false },
{ "Moisture", A2, 0, 200, false },
};One source of truth per sensor. Add a sensor: one new line. Add a field: one new field in the struct + initialise it in each row. The dispatch / logging code stays the same.
New Concept · Declaring and using structs 25 min
Declaration
struct Point {
int x;
int y;
};Defines a new type called Point with two named fields, both int. The semicolon at the end matters in C++.
Creating an instance
Point a; // uninitialised
Point b = { 10, 20 }; // both fields initialised by position
Point c = { .x = 5 }; // designated initializer (C99 / C++20)
a.x = 3; a.y = 4; // assign after creationAccessing fields
Serial.println(b.x); // 10
Serial.println(b.y); // 20
b.x += 1; // mutate
Serial.println(b.x); // 11Passing to functions
// by value -- the function gets a copy, original is unchanged
void describe(Point p) {
Serial.print("Point at "); Serial.print(p.x); Serial.print(","); Serial.println(p.y);
}
// by reference -- the function can modify the caller's struct
void translate(Point& p, int dx, int dy) {
p.x += dx;
p.y += dy;
}
// const reference -- read-only access, no copy (best for big structs)
void log(const Point& p) {
Serial.println(p.x);
}For tiny structs (2–3 fields) by value is fine. For bigger ones use const Point& to avoid copying.
Arrays of structs
struct LED {
int pin;
int brightness;
};
LED leds[] = {
{ 9, 0 },
{ 10, 50 },
{ 11, 100 },
};
const int N_LEDS = sizeof(leds) / sizeof(leds[0]);
void setup() {
for (int i = 0; i < N_LEDS; i++) {
pinMode(leds[i].pin, OUTPUT);
analogWrite(leds[i].pin, leds[i].brightness);
}
}One loop handles all LEDs, regardless of how many you add. Each struct carries its own data; the iteration code is generic.
Structs containing structs
struct Range {
int lo;
int hi;
};
struct Sensor {
const char* name;
int pin;
Range okBand;
Range warningBand;
};
Sensor s = { "Light", A0, {200, 800}, {100, 900} };
Serial.println(s.okBand.lo); // 200Composition. The Range idea is reusable across sensors.
Returning structs from functions
struct Reading {
int raw;
float volts;
float celsius;
};
Reading readTMP36(int pin) {
Reading r;
r.raw = analogRead(pin);
r.volts = r.raw * (5.0 / 1023.0);
r.celsius = (r.volts - 0.5) * 100.0;
return r;
}
// caller:
Reading r = readTMP36(A0);
Serial.print(r.celsius); Serial.println(" C");A function that "returns three numbers" without going through global variables or out-parameters. The reading + its derived values travel as one record.
Worked Example · Multi-sensor dashboard 25 min
Step 1 — wire
Three analog inputs. If you only have a UNO/ESP8266 with one ADC, use the pot on A0 and pretend it's three different sensors over time. For a richer demo, use an ESP32 with multiple ADC pins.
Step 2 — the struct-driven sketch
// L03-40 · Multi-sensor dashboard with structs
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 display(128, 64, &Wire, -1);
struct Sensor {
const char* name;
int pin;
int raw;
int threshold; // alarm above this
bool alarmActive;
};
Sensor sensors[] = {
{ "Light", A0, 0, 800, false },
{ "Temp", A1, 0, 600, false },
{ "Loud", A2, 0, 900, false },
};
const int N_SENSORS = sizeof(sensors) / sizeof(sensors[0]);
void readAll() {
for (int i = 0; i < N_SENSORS; i++) {
sensors[i].raw = analogRead(sensors[i].pin);
sensors[i].alarmActive = sensors[i].raw > sensors[i].threshold;
}
}
void drawAll() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
for (int i = 0; i < N_SENSORS; i++) {
int y = i * 20;
display.setCursor(0, y);
display.print(sensors[i].name);
display.print(": ");
display.print(sensors[i].raw);
if (sensors[i].alarmActive) display.print(" !");
// small bar
int barW = map(sensors[i].raw, 0, 1023, 0, 100);
display.drawRect(0, y + 10, 100, 6, SSD1306_WHITE);
display.fillRect(1, y + 11, barW, 4, SSD1306_WHITE);
}
display.display();
}
void setup() {
Serial.begin(9600);
for (int i = 0; i < N_SENSORS; i++) {
pinMode(sensors[i].pin, INPUT);
}
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
}
void loop() {
readAll();
drawAll();
delay(100);
}Step 3 — observe
Three rows on the OLED. Each row: name, raw value, alarm indicator, mini bar gauge. The readAll() and drawAll() functions don't know how many sensors there are — they loop through the array. Add a 4th sensor by adding one row to the initialiser and a 4th pin.
Step 4 — add a helper that takes a struct ref
void describe(const Sensor& s) {
Serial.print(s.name);
Serial.print(" = ");
Serial.print(s.raw);
if (s.alarmActive) Serial.print(" [ALARM]");
Serial.println();
}
// in loop:
for (int i = 0; i < N_SENSORS; i++) describe(sensors[i]);The helper receives one sensor at a time and prints it. No global access; works with any Sensor. Composable.
Step 5 — return a struct from a sensor reader
struct Stats { int min; int max; float avg; };
Stats summarise() {
Stats s = { 1023, 0, 0.0 };
long sum = 0;
for (int i = 0; i < N_SENSORS; i++) {
int r = sensors[i].raw;
if (r < s.min) s.min = r;
if (r > s.max) s.max = r;
sum += r;
}
s.avg = (float)sum / N_SENSORS;
return s;
}
// in loop:
Stats st = summarise();
Serial.print("min="); Serial.print(st.min);
Serial.print(" max="); Serial.print(st.max);
Serial.print(" avg="); Serial.println(st.avg);One function, three return values. Clean.
Try It Yourself 15 min
Goal: Add a units field to Sensor (e.g. "lux", "°C", "dB"). Display it on the OLED next to the value.
Hint
struct Sensor { ...; const char* units; };
Sensor sensors[] = {
{ "Light", A0, 0, 800, false, "lux" },
...
};
// in drawAll:
display.print(sensors[i].raw);
display.print(" ");
display.print(sensors[i].units);One field change ripples to every row of the table without touching the drawing code.
Goal: Add an "average over last 10 readings" field. Each sensor maintains its own running window — the running average from L02-08 lives inside the struct.
Hint
struct Sensor {
...
int history[10];
int histIndex;
long histSum;
int smoothed;
};
void readAll() {
for (int i = 0; i < N_SENSORS; i++) {
Sensor& s = sensors[i];
int r = analogRead(s.pin);
s.histSum -= s.history[s.histIndex];
s.history[s.histIndex] = r;
s.histSum += r;
s.histIndex = (s.histIndex + 1) % 10;
s.smoothed = s.histSum / 10;
s.raw = r;
s.alarmActive = s.smoothed > s.threshold;
}
}Each sensor carries its own smoothing buffer. The struct is now mutable state, not just config — a step toward classes.
Goal: Add a void (*onAlarm)(const Sensor&) function pointer field. Each sensor specifies what to do when its alarm fires. Light sensor fires a buzzer; temp sensor sends an HTTP POST; moisture sensor lights a particular LED.
Hint
Function pointers as struct fields = mini polymorphism. The dispatch is data-driven.
void buzzAlarm(const Sensor& s) { tone(BUZZER, 1500, 200); }
void httpAlarm(const Sensor& s) { /* post via L03-31 helper */ }
void ledAlarm(const Sensor& s) { digitalWrite(LED, HIGH); }
Sensor sensors[] = {
{ "Light", A0, 0, 800, false, "lux", buzzAlarm },
{ "Temp", A1, 0, 600, false, "C", httpAlarm },
{ "Moist", A2, 0, 200, true, "%", ledAlarm },
};
// in readAll, on rising edge:
if (s.alarmActive && !wasActive) s.onAlarm(s);You're now within shouting distance of a class with virtual methods. Tomorrow we close the gap.
Mini-Challenge · Find structs in your codebase 10 min
- Open every L3 sketch you've saved so far.
- For each, identify any places where you have parallel arrays or repeated "name + pin + state" patterns.
- Sketch (in notebook) what the struct version would look like.
- Pick one and refactor it. Save as a "v2" file. Compare line count.
This kind of refactor pays for itself the first time you add a new entity — you change one struct definition instead of 5 separate arrays.
Recap 5 min
A struct is a named bundle of fields — the natural shape of "a sensor", "a motor", "a wifi config". Replace parallel arrays with arrays of structs the moment the data has > 2 fields. Pass by const reference for big structs. Function pointer fields give you data-driven dispatch. Tomorrow we add functions inside the struct definition — and a struct with functions is just a class.
- struct
- A C / C++ keyword that bundles related fields into one named type. Same as a C++
classwith all members public. - Field / member
- A named value inside a struct. Accessed with the dot operator.
- Instance
- A specific variable of a struct type.
Sensor s;creates an instance. - Designated initializer
- Initialising fields by name:
{ .x = 5, .y = 10 }. C99 / C++20. - Pass by value / reference
- By value = a copy. By reference (
T&) = caller's storage.const T&= read-only reference (no copy, no mutation). - Array of structs
- The cleanest way to represent "a list of similar entities" each with its own data.
- Function pointer field
- A struct field that holds a function pointer. Each instance can have its own action. The bridge to virtual methods.
- Composition
- One struct containing another. Reusable parts (Range, RGB, Timer) used inside richer structs (Sensor, Motor, Effect).
Homework 5 min
- Refactor one sketch from parallel arrays to a struct-array. Note line count change.
- Read ahead to ARD-L03-41 (Classes in Arduino). Tomorrow we add functions inside the struct and you discover you've been writing OOP all along.
- Keep the multi-sensor dashboard wired — we'll evolve it tomorrow into a sensor class.
Bring back next class:
- Refactored sketch.
- Multi-sensor wiring still up.