Skip to content

06 · Sensors, I2C & SPI

Real devices are built from parts that talk to each other: sensors, displays, memory chips, radios. Rather than dedicate dozens of pins to each part, microcontrollers use shared buses — most commonly I2C and SPI. This module explains both conceptually, then puts them to work with the two parts you'll use in the capstone: a DHT22 temperature/humidity sensor and an SSD1306 OLED display — both available as virtual parts in Wokwi.

I2C in a nutshell

I2C (inter-integrated circuit) uses just two shared wires for any number of devices:

  • SDA — serial data (both directions)
  • SCL — serial clock (the controller ticks it; data is valid on each tick)

Every peripheral has a 7-bit address (e.g. the SSD1306 OLED is usually 0x3C). The controller (your board) starts each exchange by broadcasting an address; only the matching device responds. Both lines need pull-up resistors (usually already on the module boards). Typical speed: 100–400 kHz — slow-ish, but two pins for a whole network of sensors is a great trade.

Default I2C pins: A4 (SDA) / A5 (SCL) on Uno; GPIO 21 (SDA) / GPIO 22 (SCL) on ESP32.

The Wire library is Arduino's I2C interface. You'll rarely call it directly — device libraries wrap it — but a bus scanner shows what's really happening and is the first thing to run when a device "doesn't work":

#include <Wire.h>

void setup() {
  Serial.begin(115200);
  Wire.begin();                              // join the bus as controller
  Serial.println("Scanning I2C bus...");
  for (uint8_t addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);            // try to address each device
    if (Wire.endTransmission() == 0) {       // 0 = someone acknowledged
      Serial.print("  found device at 0x");
      Serial.println(addr, HEX);             // e.g. "found device at 0x3C"
    }
  }
  Serial.println("Done.");
}

void loop() {}

SPI in a nutshell

SPI (serial peripheral interface) trades pins for speed — four wires, tens of MHz, used by SD cards, displays, and flash chips:

Wire Meaning
SCK Clock, driven by the controller
MOSI Controller out, peripheral in
MISO Controller in, peripheral out
CS/SS Chip select — one per device, pulled LOW to talk to it

No addresses: selecting a device is electrical (its CS line). Multiple devices share SCK/MOSI/MISO but each needs its own CS pin.

Rule of thumb: low-speed sensors → I2C (fewer pins); high-bandwidth things like SD cards and big/fast displays → SPI. You'll use SPI hands-on in Level 2; today's parts are I2C and single-wire.

Using libraries

Nobody bit-bangs display protocols by hand — you install a library that speaks the device's protocol and gives you a friendly API.

  • Arduino IDE: Sketch → Include Library → Manage Libraries, search, install.
  • Wokwi: open the Library Manager tab (left of the code editor) → + → search — or just add the part; Wokwi usually prompts for the matching library.

For this module install: DHT sensor library (Adafruit) — its dependency Adafruit Unified Sensor installs alongside — plus Adafruit SSD1306 and Adafruit GFX Library.

Reading a DHT22 (temperature & humidity)

The DHT22 isn't I2C — it uses its own single-wire protocol, which is exactly why it's a good library lesson: the library hides some genuinely fiddly microsecond-level timing.

Wiring (Wokwi part "DHT22"): VCC → 3V3/5V, GND → GND, SDA/DATA → GPIO 15.

#include "DHT.h"

const uint8_t DHT_PIN = 15;
DHT dht(DHT_PIN, DHT22);           // create driver object: pin + sensor type

void setup() {
  Serial.begin(115200);
  dht.begin();
}

void loop() {
  delay(2000);                     // DHT22 max sample rate: once per 2 s

  float h = dht.readHumidity();
  float t = dht.readTemperature();          // Celsius

  if (isnan(h) || isnan(t)) {               // reads can fail — always check!
    Serial.println(F("DHT read failed"));
    return;
  }

  Serial.print(F("Temp: "));
  Serial.print(t, 1);
  Serial.print(F(" C  Humidity: "));
  Serial.print(h, 1);
  Serial.println(F(" %"));
}

The isnan() check is the important habit: sensors are physical devices on wires — reads fail, and robust firmware checks every one. In Wokwi, click the DHT22 part while running and drag the temperature/humidity sliders; your serial output follows.

Driving an SSD1306 OLED over I2C

Wiring (Wokwi part "SSD1306"): VCC → 3V3, GND → GND, SCL → GPIO 22, SDA → GPIO 21 (address 0x3C — your scanner from earlier will find it).

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

Adafruit_SSD1306 display(128, 64, &Wire, -1);   // width, height, bus, no reset pin

void setup() {
  Serial.begin(115200);
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println(F("SSD1306 init failed"));
    while (true) delay(1000);                   // no display — halt visibly
  }
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(2);
  display.setCursor(0, 0);
  display.println(F("Hello,"));
  display.println(F("embedded!"));
  display.display();                            // nothing appears until this!
}

void loop() {}

The library draws into a RAM framebuffer; display.display() ships the whole buffer over I2C to the panel. Forgetting that call — and staring at a blank screen — is a rite of passage.

Sensor + display together

void loop() {
  delay(2000);
  float t = dht.readTemperature();
  float h = dht.readHumidity();
  if (isnan(t) || isnan(h)) return;

  display.clearDisplay();
  display.setTextSize(2);
  display.setCursor(0, 0);
  display.print(t, 1);
  display.println(F(" C"));
  display.print(h, 1);
  display.println(F(" %"));
  display.display();
}

That's a working thermometer with a screen — the heart of the capstone, in which the delay(2000) will be replaced by proper non-blocking scheduling (module 7).

How It Actually Works

What "shared wires" really means electrically: I2C pins are wired as open-drain — a device can only pull SDA/SCL LOW (by switching on an internal transistor to ground) or let go (release it high-impedance); it can never actively drive HIGH. The pull-up resistors are what pull the line back to logic HIGH when nobody is pulling it low. This is precisely what lets many devices coexist on two wires without one damaging another: if two devices tried to actively drive opposite logic levels on a normal push-pull line simultaneously, you'd get a short circuit, but open-drain means the worst case is just "everyone releases and the resistor wins." The bus scanner's Wire.endTransmission() == 0 works because after the controller clocks out the 7-bit address plus a read/write bit, it releases SDA for one more clock and checks whether the addressed device pulled it LOW — that pulse is the ACK bit, and a 0 return means some device recognized its address and asserted it.

Why SPI is faster: it's push-pull, not open-drain — MOSI, MISO, and SCK are each driven HIGH or LOW directly by dedicated output transistors on both ends, so there's no resistor/parasitic-capacitance RC time constant limiting how fast the line can be slewed, unlike I2C's pull-up-charged rise time. Every SCK edge shifts exactly one bit into a hardware shift register on each end simultaneously (SPI is full-duplex — data moves both directions on the same clock edge), which is also why SPI needs no addressing scheme: the transaction is fully deterministic once you've electrically selected exactly one device by pulling its CS line low, so the shift registers on controller and peripheral are talking to nobody else.

The DHT22's one-wire protocol, and why timing is "fiddly": with only one data line for both directions, the DHT22 encodes each bit as a fixed-length LOW pulse followed by a HIGH pulse whose duration the receiver measures — roughly 26-28 µs of HIGH for a 0 bit and ~70 µs for a 1 bit. The library does this by disabling interrupts around a tight polling loop that reads the GPIO's raw input register (bypassing digitalRead()'s overhead) and calls micros() to time each transition — timing this precise is exactly why it can't tolerate an ISR or another peripheral operation interrupting it mid-read, and why a 2000 ms gap is enforced between reads (the sensor's own internal capacitive humidity element needs that long to stabilize a new reading).

Where the OLED's pixels actually live: Adafruit_GFX's drawing calls (setCursor, println, etc.) only flip bits in a plain RAM array you own — the framebuffer, 1 bit per pixel for a monochrome 128×64 panel (1024 bytes). display.display() is the only call that touches the bus: it streams that entire buffer over I2C into the SSD1306's own internal GDDRAM (graphics display data RAM) inside the display controller chip, which is what continuously refreshes the physical pixels regardless of what your MCU does afterward — which is exactly why the screen keeps showing the last image you pushed even if your sketch stalls.

Cheat sheet

Concept Detail
I2C wires SDA + SCL, shared by all devices; pull-ups required
I2C addressing 7-bit address per device (OLED: 0x3C); scanner finds them
I2C pins Uno: A4/A5 — ESP32: GPIO 21 (SDA) / 22 (SCL)
SPI wires SCK, MOSI, MISO + one CS per device; much faster than I2C
Choosing a bus Sensors → I2C; SD cards, fast displays → SPI
DHT22 Single-wire protocol, one read per 2 s, returns floats, check isnan()
SSD1306 pattern draw to buffer → display.display() pushes it to the panel
Library Manager IDE: Sketch menu — Wokwi: Library Manager tab

Exercise

Build the thermometer above in Wokwi (ESP32 + DHT22 + SSD1306), then upgrade it: show temperature and humidity, plus the minimum and maximum temperature seen since boot on a smaller font line (setTextSize(1)), and a boot counter of failed DHT reads. Verify min/max by dragging the DHT22 slider around, and verify the failure counter stays at 0 in normal operation. Bonus: run the I2C scanner sketch first and confirm the OLED's address, then deliberately pass the wrong address to display.begin() and observe how the failure path behaves.