Skip to content

I2C Bus and Sensor Integration

I2C Bus and Sensor Integration hero image
Modified:
Published:

I2C (also called TWI on Atmel chips) lets you connect dozens of sensors and peripherals with just two wires. In this lesson you will implement the ATmega328P TWI interface from scratch, handling start conditions, addressing, ACK/NACK responses, and multi-byte reads. The project is a mini weather station: a BME280 sensor provides temperature, humidity, and barometric pressure readings, and the firmware displays all three values on the SSD1306 OLED from the previous lesson. Two different peripherals sharing two wires, all driven by code you wrote. #I2C #BME280 #WeatherStation

What We Are Building

Mini Weather Station

A BME280 environmental sensor connected over I2C provides temperature (0.01 C resolution), relative humidity (0.008% resolution), and barometric pressure (0.18 Pa resolution). The firmware reads all three measurements every 2 seconds, applies the BME280 compensation formulas, and renders the results on the SSD1306 OLED display. The OLED is driven over SPI (from Lesson 6), and the BME280 over I2C, showing both buses working together.

Project specifications:

ParameterValue
MCUATmega328P (on Arduino Nano or Uno)
SensorBME280 (I2C address 0x76 or 0x77)
DisplaySSD1306 128x64 OLED (SPI)
I2C clock100 kHz (standard mode)
I2C pinsSDA (PC4), SCL (PC5)
Update rate0.5 Hz (every 2 seconds)
Data shownTemperature (C), Humidity (%), Pressure (hPa)

Parts for This Lesson

RefComponentQuantityNotes
1Arduino Nano or Uno (ATmega328P)1From previous lessons
2Breadboard1From previous lessons
3BME280 breakout module1I2C variant with pull-ups on board
4SSD1306 OLED 128x64 (SPI)1From Lesson 6
5Jumper wires~4For I2C connections

I2C Protocol Fundamentals



I2C is a two-wire synchronous protocol. SDA carries data and SCL carries the clock. Both lines are open-drain with external pull-up resistors (typically 4.7K).

I2C Bus with two devices:
VCC VCC
| |
[4.7K] [4.7K]
| |
SDA --+------+------+------+--
| | |
SCL --+--+---+--+----------+--
| | |
+----+--+ ++---------++
|ATmega | | BME280 || SSD1306
|328P | | addr: || addr:
|(master)| | 0x76 || 0x3C
+--------+ +----------++----------+
Both lines are open-drain:
devices can only pull LOW.
Pull-ups restore HIGH when released.

The master generates the clock and initiates all communication. A transaction starts with a START condition (SDA falls while SCL is high), followed by a 7-bit address plus a read/write bit, then one or more data bytes, and ends with a STOP condition (SDA rises while SCL is high). For a comparison of I2C with SPI and other bus protocols, see Digital Electronics: Bus Architecture and Interfaces.

I2C Transaction Anatomy

StepSDASCLDescription
STARTHigh to LowHighMaster signals start
Address (7 bits) + R/WDataClock pulsesMaster sends slave address
ACKSlave pulls lowClock pulseSlave acknowledges
Data byteDataClock pulses8 bits, MSB first
ACK/NACKReceiver pulls low (or not)Clock pulseAcknowledge each byte
STOPLow to HighHighMaster signals stop

TWI Registers on ATmega328P



An I2C write transaction to the BME280 follows this sequence of bus states. Each step produces a status code in TWSR that your driver must check.

I2C write transaction (e.g., set register):
Master: [S] [0x76<<1|W] [reg addr] [value] [P]
| | | | |
Bus: START address+W data data STOP
| | | | |
Slave: [ACK] [ACK] [ACK]
| | | |
TWSR: 0x08 0x18 0x28 0x28
I2C read transaction (e.g., read register):
Master: [S] [0x76<<1|W] [reg] [Sr] [0x76<<1|R] [NACK] [P]
^
repeated start
Slave: [ACK] [ACK] [ACK] [data]

The ATmega328P calls its I2C hardware TWI (Two-Wire Interface). Five registers control it. TWBR sets the bit rate (clock speed). TWCR is the control register where you trigger actions by writing specific bit combinations. TWSR holds status codes that tell you what happened after each operation. TWDR is the data register for send/receive. TWAR sets the slave address (only used in slave mode).

RegisterPurpose
TWBRBit Rate Register: SCL frequency = F_CPU / (16 + 2 * TWBR * prescaler)
TWCRControl: TWINT (interrupt flag), TWEA (enable ACK), TWSTA (start), TWSTO (stop), TWEN (enable)
TWSRStatus: 5-bit status code in upper bits, prescaler in lower 2 bits
TWDRData Register: write to send, read after receive
TWARSlave Address Register (not used in master mode)

Setting the I2C Clock Speed

For 100 kHz standard mode with a 16 MHz system clock and prescaler 1:

TWBR = ((F_CPU / SCL_FREQ) - 16) / 2
TWBR = ((16000000 / 100000) - 16) / 2 = 72

TWI Driver Implementation



#define F_CPU 16000000UL
#define TWI_FREQ 100000UL
#include <avr/io.h>
#include <util/delay.h>
static void twi_init(void)
{
TWSR = 0; /* Prescaler = 1 */
TWBR = ((F_CPU / TWI_FREQ) - 16) / 2;
TWCR = (1 << TWEN); /* Enable TWI */
}
static uint8_t twi_start(void)
{
TWCR = (1 << TWINT) | (1 << TWSTA) | (1 << TWEN);
while (!(TWCR & (1 << TWINT)));
return (TWSR & 0xF8); /* Status code */
}
static void twi_stop(void)
{
TWCR = (1 << TWINT) | (1 << TWSTO) | (1 << TWEN);
while (TWCR & (1 << TWSTO)); /* Wait for stop to complete */
}
static uint8_t twi_write(uint8_t data)
{
TWDR = data;
TWCR = (1 << TWINT) | (1 << TWEN);
while (!(TWCR & (1 << TWINT)));
return (TWSR & 0xF8);
}
static uint8_t twi_read_ack(void)
{
TWCR = (1 << TWINT) | (1 << TWEA) | (1 << TWEN);
while (!(TWCR & (1 << TWINT)));
return TWDR;
}
static uint8_t twi_read_nack(void)
{
TWCR = (1 << TWINT) | (1 << TWEN);
while (!(TWCR & (1 << TWINT)));
return TWDR;
}

TWI Status Codes

CodeMeaning
0x08START condition transmitted
0x10Repeated START transmitted
0x18SLA+W transmitted, ACK received
0x20SLA+W transmitted, NACK received
0x28Data transmitted, ACK received
0x40SLA+R transmitted, ACK received
0x50Data received, ACK returned
0x58Data received, NACK returned

Reading the BME280



The BME280 uses a register-based interface. To read a register, you send a write transaction with the register address, then a repeated start followed by a read transaction. The sensor has a block of calibration data in registers 0x88 through 0xA1 and 0xE1 through 0xF0. Raw measurement data lives in registers 0xF7 through 0xFE (8 bytes for pressure, temperature, and humidity). The compensation algorithm uses integer arithmetic defined in the BME280 datasheet.

#define BME280_ADDR 0x76
static void bme280_write_reg(uint8_t reg, uint8_t val)
{
twi_start();
twi_write((BME280_ADDR << 1) | 0); /* Write mode */
twi_write(reg);
twi_write(val);
twi_stop();
}
static uint8_t bme280_read_reg(uint8_t reg)
{
twi_start();
twi_write((BME280_ADDR << 1) | 0);
twi_write(reg);
twi_start(); /* Repeated start */
twi_write((BME280_ADDR << 1) | 1); /* Read mode */
uint8_t val = twi_read_nack();
twi_stop();
return val;
}
static void bme280_read_burst(uint8_t reg, uint8_t *buf, uint8_t len)
{
twi_start();
twi_write((BME280_ADDR << 1) | 0);
twi_write(reg);
twi_start();
twi_write((BME280_ADDR << 1) | 1);
for (uint8_t i = 0; i < len - 1; i++) {
buf[i] = twi_read_ack();
}
buf[len - 1] = twi_read_nack();
twi_stop();
}

BME280 Initialization and Calibration



/* Calibration data structure */
typedef struct {
uint16_t dig_T1;
int16_t dig_T2, dig_T3;
uint16_t dig_P1;
int16_t dig_P2, dig_P3, dig_P4, dig_P5;
int16_t dig_P6, dig_P7, dig_P8, dig_P9;
uint8_t dig_H1, dig_H3;
int16_t dig_H2, dig_H4, dig_H5;
int8_t dig_H6;
} bme280_cal_t;
static bme280_cal_t cal;
static void bme280_read_calibration(void)
{
uint8_t buf[26];
bme280_read_burst(0x88, buf, 26);
cal.dig_T1 = (uint16_t)(buf[1] << 8) | buf[0];
cal.dig_T2 = (int16_t)(buf[3] << 8) | buf[2];
cal.dig_T3 = (int16_t)(buf[5] << 8) | buf[4];
cal.dig_P1 = (uint16_t)(buf[7] << 8) | buf[6];
cal.dig_P2 = (int16_t)(buf[9] << 8) | buf[8];
cal.dig_P3 = (int16_t)(buf[11] << 8) | buf[10];
cal.dig_P4 = (int16_t)(buf[13] << 8) | buf[12];
cal.dig_P5 = (int16_t)(buf[15] << 8) | buf[14];
cal.dig_P6 = (int16_t)(buf[17] << 8) | buf[16];
cal.dig_P7 = (int16_t)(buf[19] << 8) | buf[18];
cal.dig_P8 = (int16_t)(buf[21] << 8) | buf[20];
cal.dig_P9 = (int16_t)(buf[23] << 8) | buf[22];
cal.dig_H1 = bme280_read_reg(0xA1);
uint8_t hbuf[7];
bme280_read_burst(0xE1, hbuf, 7);
cal.dig_H2 = (int16_t)(hbuf[1] << 8) | hbuf[0];
cal.dig_H3 = hbuf[2];
cal.dig_H4 = (int16_t)(hbuf[3] << 4) | (hbuf[4] & 0x0F);
cal.dig_H5 = (int16_t)(hbuf[5] << 4) | (hbuf[4] >> 4);
cal.dig_H6 = (int8_t)hbuf[6];
}
static void bme280_init(void)
{
/* Soft reset */
bme280_write_reg(0xE0, 0xB6);
_delay_ms(10);
bme280_read_calibration();
/* Humidity oversampling x1 (must be set before ctrl_meas) */
bme280_write_reg(0xF2, 0x01);
/* Temperature x1, Pressure x1, Normal mode */
bme280_write_reg(0xF4, 0x27);
/* Standby 1000ms, filter off */
bme280_write_reg(0xF5, 0xA0);
}

Compensation Formulas



The BME280 datasheet provides integer compensation routines that convert raw ADC values into physical units. These use 32-bit arithmetic and a shared variable (t_fine) that links the temperature compensation to pressure and humidity compensation. The formulas are complex but they come directly from Bosch’s reference implementation.

static int32_t t_fine;
static int32_t compensate_temp(int32_t adc_T)
{
int32_t var1 = ((((adc_T >> 3) - ((int32_t)cal.dig_T1 << 1)))
* ((int32_t)cal.dig_T2)) >> 11;
int32_t var2 = (((((adc_T >> 4) - ((int32_t)cal.dig_T1))
* ((adc_T >> 4) - ((int32_t)cal.dig_T1))) >> 12)
* ((int32_t)cal.dig_T3)) >> 14;
t_fine = var1 + var2;
return (t_fine * 5 + 128) >> 8; /* Result in 0.01 C */
}
static uint32_t compensate_press(int32_t adc_P)
{
int32_t var1 = (t_fine >> 1) - 64000;
int32_t var2 = (((var1 >> 2) * (var1 >> 2)) >> 11) * ((int32_t)cal.dig_P6);
var2 = var2 + ((var1 * ((int32_t)cal.dig_P5)) << 1);
var2 = (var2 >> 2) + (((int32_t)cal.dig_P4) << 16);
var1 = (((cal.dig_P3 * (((var1>>2)*(var1>>2)) >> 13)) >> 3)
+ ((((int32_t)cal.dig_P2) * var1) >> 1)) >> 18;
var1 = ((32768 + var1) * ((int32_t)cal.dig_P1)) >> 15;
if (var1 == 0) return 0;
uint32_t p = (((uint32_t)(((int32_t)1048576) - adc_P) - (var2 >> 12))) * 3125;
if (p < 0x80000000) p = (p << 1) / ((uint32_t)var1);
else p = (p / (uint32_t)var1) * 2;
var1 = (((int32_t)cal.dig_P9) * ((int32_t)(((p>>3)*(p>>3))>>13))) >> 12;
var2 = (((int32_t)(p >> 2)) * ((int32_t)cal.dig_P8)) >> 13;
return (uint32_t)((int32_t)p + ((var1 + var2 + cal.dig_P7) >> 4));
/* Result in Pa with Q24.8 format (divide by 256 for Pa) */
}
static uint32_t compensate_humid(int32_t adc_H)
{
int32_t v = t_fine - 76800;
v = (((((adc_H << 14) - (((int32_t)cal.dig_H4) << 20)
- (((int32_t)cal.dig_H5) * v)) + 16384) >> 15)
* (((((((v * ((int32_t)cal.dig_H6)) >> 10)
* (((v * ((int32_t)cal.dig_H3)) >> 11) + 32768)) >> 10)
+ 2097152) * ((int32_t)cal.dig_H2) + 8192) >> 14));
v = v - (((((v >> 15) * (v >> 15)) >> 7) * ((int32_t)cal.dig_H1)) >> 4);
v = (v < 0) ? 0 : v;
v = (v > 419430400) ? 419430400 : v;
return (uint32_t)(v >> 12); /* Result in Q22.10 format (divide by 1024 for %) */
}

Putting It All Together



The main loop reads all eight data bytes from the BME280 in a single burst, applies the compensation formulas, formats the results as strings, and renders them on the OLED. The OLED driver from Lesson 6 handles the display update. Temperature is shown in degrees Celsius, humidity as a percentage, and pressure in hectopascals (which equals millibars).

int main(void)
{
twi_init();
spi_init();
oled_init();
bme280_init();
while (1) {
/* Read raw data burst (0xF7 to 0xFE, 8 bytes) */
uint8_t data[8];
bme280_read_burst(0xF7, data, 8);
int32_t adc_P = ((int32_t)data[0] << 12) | ((int32_t)data[1] << 4) | (data[2] >> 4);
int32_t adc_T = ((int32_t)data[3] << 12) | ((int32_t)data[4] << 4) | (data[5] >> 4);
int32_t adc_H = ((int32_t)data[6] << 8) | data[7];
/* Compensate (temperature must be first, sets t_fine) */
int32_t temp_100 = compensate_temp(adc_T); /* 0.01 C */
uint32_t press_256 = compensate_press(adc_P); /* Pa * 256 */
uint32_t humid_1024 = compensate_humid(adc_H); /* % * 1024 */
/* Format and display */
fb_clear();
/* Temperature: e.g., "T: 23.45 C" */
/* Humidity: e.g., "H: 55.2 %" */
/* Pressure: e.g., "P: 1013 hPa" */
/* (Use fb_draw_string with formatted buffers) */
oled_flush();
_delay_ms(2000);
}
}

I2C Bus Debugging Tips



The I2C START and STOP conditions are defined by SDA transitions while SCL is high. During normal data transfer, SDA only changes while SCL is low.

I2C START and STOP conditions:
SDA: ----+ +----
| |
+---------+
SCL: ------+ +------
| |
+-----+
START STOP
(SDA falls (SDA rises
while SCL while SCL
is HIGH) is HIGH)

I2C problems are common and can be frustrating. Here are the most frequent issues and how to solve them.

ProblemLikely CauseFix
No ACK from sensorWrong address, wiring errorCheck SDA/SCL connections, try both 0x76 and 0x77
Bus stuck (SDA held low)Sensor confused by interrupted transactionToggle SCL manually 9 times, then send STOP
Corrupted dataMissing pull-upsAdd 4.7K pull-ups on SDA and SCL to VCC
Intermittent failuresLong wires, noiseKeep wires under 30 cm, add decoupling capacitor near sensor
All readings zeroWrong register address or oversampling offVerify ctrl_meas and ctrl_hum register values

Exercises



  1. Add error checking to the TWI driver: verify the status code after each operation and return an error code if something unexpected happens. Implement a retry mechanism.
  2. Display the pressure as altitude using the barometric formula: altitude = 44330 * (1 - (P/P0)^0.1903) where P0 is sea-level pressure (101325 Pa).
  3. Log all three readings over UART (from Lesson 5) in CSV format alongside the OLED display, so you can capture data on a PC.
  4. Add a second I2C device (for example a BH1750 light sensor at address 0x23) and display its readings on the fourth line of the OLED.

Summary



You now understand the I2C protocol at the register level: start/stop conditions, addressing, ACK/NACK handshaking, and burst reads. You implemented a complete TWI driver for the ATmega328P and used it to interface with the BME280, one of the most capable environmental sensors available. The weather station combines I2C input (BME280) with SPI output (OLED), demonstrating how multiple bus protocols coexist in a single project. I2C’s simplicity (two wires, addressable devices) makes it the default choice for most sensor interfacing.

Comments

Loading comments...


© 2021-2026 SiliconWit®. All rights reserved.