Getting started with coding on a Raspberry Pi often means moving beyond simple terminal scripts and interacting with the physical world via GPIO. The I2C (Inter-Integrated Circuit) bus is the most common gateway for this, but it is also the most frequent source of hardware-level frustration. Unlike Arduino, where I2C is handled by a deterministic microcontroller, the Raspberry Pi runs a full Linux OS. This means your Python code must navigate kernel drivers, bus multiplexing, and hardware quirks specific to the Broadcom SoC.

This guide targets the Raspberry Pi 5 (4GB or 8GB variant) running Raspberry Pi OS (64-bit, Bookworm). We will wire a BME280 environmental sensor, write robust Python code using the smbus2 library, and systematically debug the infamous I2C errors that plague embedded Linux projects.

Project Spec Sheet and Hardware BOM

Before writing a single line of code, you need the right hardware. The Pi 5 operates its GPIO logic at 3.3V. Feeding 5V into the I2C pins will permanently damage the BCM2712 SoC. Always use 3.3V breakouts or a logic level converter.

Component Exact Variant / Model Specs & Notes Est. Price (2026)
Microcontroller Raspberry Pi 5 (4GB) BCM2712 SoC, 3.3V logic, I2C Bus 1 default $60.00
Sensor Adafruit BME280 (PID 2652) I2C/SPI, 3.3V-5V tolerant, onboard pull-ups $14.95
Wiring 28 AWG Silicone Jumper Wires Female-to-Female, 20cm length $6.00
OS / Storage SanDisk Extreme 64GB microSD A2 rating required for Pi 5 boot stability $12.00

Pin Mapping Table

The Raspberry Pi 5 maintains the standard 40-pin header layout. We are using the primary hardware I2C bus (Bus 1). Use the following physical pin numbers (not BCM GPIO numbers) for wiring:

Pi 5 Physical Pin BCM GPIO Function BME280 Breakout Pin Standard Wire Color
Pin 1 N/A (Power) 3.3V DC Power VIN / VCC Red
Pin 6 N/A (Ground) Ground GND Black
Pin 3 GPIO 2 I2C1 SDA (Data) SDA Blue
Pin 5 GPIO 3 I2C1 SCL (Clock) SCL Yellow
Bench Tip: The Adafruit BME280 breakout includes 10kΩ pull-up resistors on the SDA and SCL lines. If you are using a generic clone board without pull-ups, the I2C bus will float, resulting in intermittent reads. You will need to add external 4.7kΩ pull-up resistors to the 3.3V rail.

Coding on a Raspberry Pi: Python I2C Implementation

When coding on a Raspberry Pi for hardware interaction, avoid heavy abstraction layers if you want to understand the underlying bus behavior. We will use smbus2, a pure-Python wrapper around the Linux i2c-dev kernel interface. It is lightweight, fast, and exposes the exact OS-level errors you need for debugging.

First, ensure your environment is prepped. Enable I2C via sudo raspi-config (Interface Options > I2C > Enable), reboot, and install the library:

sudo apt update
sudo apt install python3-smbus2 i2c-tools

Below is the complete, compilable Python script. It initializes the bus, verifies the sensor's Chip ID to confirm physical communication, and reads the temperature register with robust error handling.

#!/usr/bin/env python3
"""
Raspberry Pi 5 I2C BME280 Temperature Reader
Target Board: Raspberry Pi 5 (4GB/8GB)
OS: Raspberry Pi OS (64-bit, Bookworm)
Dependencies: smbus2 (pip install smbus2)
"""

import smbus2
import time
import sys

# --- PIN & BUS DEFINITIONS ---
# Raspberry Pi 5 primary hardware I2C bus
I2C_BUS_ID = 1 
# Default I2C address for Adafruit BME280 (0x77 for some generic clones)
BME280_I2C_ADDR = 0x76 

# BME280 Register Map (from Bosch Datasheet)
REG_CHIP_ID = 0xD0
REG_TEMP_DATA = 0xFA
REG_CTRL_MEAS = 0xF4

EXPECTED_CHIP_ID = 0x60

def initialize_sensor(bus):
    """Verifies connection and sets oversampling for temperature."""
    # Read Chip ID to verify I2C communication
    chip_id = bus.read_byte_data(BME280_I2C_ADDR, REG_CHIP_ID)
    if chip_id != EXPECTED_CHIP_ID:
        raise ValueError(f"Unexpected Chip ID: 0x{chip_id:02X}. Check wiring.")
    
    # Configure: Temp oversampling x2 (010), Pressure x1 (001), Mode Normal (11)
    # Binary: 01000111 -> Hex: 0x47
    bus.write_byte_data(BME280_I2C_ADDR, REG_CTRL_MEAS, 0x47)
    time.sleep(0.1) # Allow sensor to settle

def read_temperature_raw(bus):
    """Reads the 3-byte temperature data register."""
    # Read 3 bytes starting from MSB register
    data = bus.read_i2c_block_data(BME280_I2C_ADDR, REG_TEMP_DATA, 3)
    # Combine bytes into a 20-bit integer (shift and mask)
    raw_temp = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4)
    return raw_temp

def main():
    print(f"Initializing I2C Bus {I2C_BUS_ID} at address 0x{BME280_I2C_ADDR:02X}...")
    
    try:
        # Open the I2C bus
        bus = smbus2.SMBus(I2C_BUS_ID)
        initialize_sensor(bus)
        print("Sensor initialized successfully. Reading data...")
        
        while True:
            try:
                raw_temp = read_temperature_raw(bus)
                # Simplified conversion for demonstration (real calc requires calibration registers)
                # Approximation: raw_temp / 5120.0 * 100
                temp_c = raw_temp / 5120.0 
                print(f"Raw ADC: {raw_temp} | Approx Temp: {temp_c:.2f} °C")
                time.sleep(2.0)
                
            except OSError as e:
                # Catch transient I2C bus glitches without crashing the main loop
                print(f"Transient Read Error: {e}. Retrying in 5s...")
                time.sleep(5.0)
                
    except OSError as e:
        # Fatal setup errors (e.g., device not found, permission denied)
        print(f"\n--- FATAL I2C SETUP ERROR ---")
        print(f"Exception: {e}")
        print("Check 'Debugging the Dreaded Remote I/O Error' section below.")
        sys.exit(1)
        
    except KeyboardInterrupt:
        print("\nScript terminated by user.")
        sys.exit(0)

if __name__ == "__main__":
    main()

Debugging the Dreaded Remote I/O Error

If you run the script above and immediately hit a wall, you will likely see this exact error string in your terminal:

OSError: [Errno 121] Remote I/O error

In embedded Linux, Errno 121 (EREMOTEIO) means the kernel's I2C driver attempted to clock data out on the SDA/SCL lines, but the slave device did not acknowledge (ACK) the transaction. The bus timed out. Here are the first three things to check when this failure occurs, ranked from most to least likely:

1. The First Three Checks

  1. Verify Bus Visibility with i2cdetect: Run i2cdetect -y 1 in the terminal. If you see a matrix of dashes (--) instead of 76 or 77, the Pi physically cannot see the sensor. This points to a wiring fault, swapped SDA/SCL lines, or a dead 3.3V rail.
  2. Measure VCC at the Breakout: Do not trust the power supply label. Use your multimeter to measure DC voltage between the GND and VIN pins directly on the sensor breakout. You should read between 3.25V and 3.35V. If it reads 0V, you have a broken jumper wire or a blown Pi polyfuse.
  3. Check for I2C Address Conflicts: Some generic BME280 clones default to 0x77 instead of 0x76. If i2cdetect shows 77, update the BME280_I2C_ADDR variable in the Python script to 0x77.

Pi 5 Specific Edge Case: Clock Stretching

The BCM2712 chip in the Raspberry Pi 5 has a known hardware quirk regarding I2C clock stretching. If your sensor holds the SCL line low to delay a response (common in older ADCs or certain multiplexers), the Pi 5's hardware I2C controller may prematurely time out, throwing Errno 121 even if the wiring is perfect. While the BME280 rarely stretches the clock aggressively, if you add devices like the CCS811 air quality sensor to the same bus, you will hit this bug.

Fixing Pi 5 Clock Stretching: If you suspect clock stretching is causing your Remote I/O errors, lower the I2C baud rate. Add dtparam=i2c_baudrate=30000 to your /boot/firmware/config.txt file and reboot. This gives the slave device more time to respond before the Pi's kernel driver abandons the transaction.

Debugging Decision Matrix

Exact Error String Root Cause Resolution
OSError: [Errno 121] Remote I/O error Slave NACK (No device at address, or clock stretch timeout) Run i2cdetect, verify address, lower baud rate in config.txt
OSError: [Errno 2] No such file or directory I2C kernel module not loaded or disabled in raspi-config Run sudo raspi-config, enable I2C, reboot
PermissionError: [Errno 13] Permission denied Running script without i2c group privileges Add user to group: sudo usermod -aG i2c $USER, log out/in
ValueError: Unexpected Chip ID: 0xFF SDA/SCL swapped, or reading from wrong I2C bus ID Swap Blue/Yellow wires, verify I2C_BUS_ID = 1

Extending and Simplifying the Build

Once you have stable I2C communication and clean temperature logs, you can adapt this project to fit your specific deployment needs.

How to Simplify (Headless Data Logging)

If you are deploying this in an enclosure and don't need real-time terminal output, strip out the print() statements and the time.sleep() loop. Instead, configure a systemd service to run the script once on boot, appending the raw hex data and a Unix timestamp to a local .csv file. This reduces CPU wake-states and is ideal for battery-backed Pi setups using a PiJuice HAT.

How to Extend (MQTT and Home Assistant)

To integrate this sensor into a smart home dashboard, extend the Python script using the paho-mqtt library.

  1. Install the broker client: pip install paho-mqtt.
  2. After calculating temp_c, publish the payload to an MQTT topic: client.publish("homeassistant/sensor/office/temp", temp_c).
  3. In Home Assistant, configure an MQTT sensor entity in your configuration.yaml pointing to that exact topic. This bridges your raw embedded Linux code into a polished UI without needing heavy local frameworks like Node-RED.

Mastering I2C on the Pi requires respecting the boundary between software and silicon. By verifying your physical layer with a multimeter, understanding the kernel's error codes, and writing defensive Python, you transform Errno 121 from a project-killer into a routine debugging step.

References: For deeper register-level details, consult the Adafruit BME280 Learning System and the official Raspberry Pi Hardware Configuration Documentation.