To connect a Raspberry Pi and Arduino via I2C, you must use a BSS138 bidirectional logic level shifter to bridge the Pi's strict 3.3V GPIO and the Arduino's 5V logic. Assign the Arduino as the I2C peripheral (slave) using the Wire.h library, and use Python's smbus2 on the Pi as the controller (master) to poll sensor data. Never wire them directly, or you will destroy the Pi's SoC.

Project Difficulty: Intermediate | Time Required: 45 minutes | Cost: ~$95

Why Combine a Raspberry Pi and Arduino Over I2C?

When building environmental monitors, CNC telemetry rigs, or home automation nodes, you often hit the limits of a single board. The Raspberry Pi 5 is a powerhouse for running MQTT brokers, Node-RED, and Home Assistant, but its 3.3V GPIO pins are notoriously fragile and lack real-time analog-to-digital conversion. The Arduino Uno R4 Minima, built around the 48MHz Renesas RA4M1 chip, excels at reading 14-bit ADC sensors and handling strict timing interrupts, but lacks native high-level networking.

Bridging a pi and arduino over I2C (Inter-Integrated Circuit) solves this. I2C requires only two shared wires (SDA and SCL) plus a common ground, leaving your UART pins free for console debugging and your SPI bus free for high-speed displays. However, the voltage mismatch between the two boards is the number one cause of failed builds and fried silicon.

Hardware BOM and Logic Level Shifting

The Raspberry Pi 5 uses the BCM2712 SoC, which has zero tolerance for 5V on its I2C pins (GPIO 2 and GPIO 3). The Arduino Uno R4 Minima defaults to 5V logic on its I/O headers. You must use a MOSFET-based bidirectional level shifter. Generic resistor dividers fail on I2C because the bus is open-drain and bidirectional; a BSS138 MOSFET circuit handles the pull-up routing correctly.

Table 1: Component Specification and Bill of Materials
ComponentExact VariantNominal CostRole / Critical Spec
Host ControllerRaspberry Pi 5 (8GB)$80.00I2C Master, runs Python/MQTT. 3.3V logic.
Peripheral NodeArduino Uno R4 Minima$27.50I2C Slave, reads sensors. 5V I/O rail.
Level ShifterSparkFun BOB-12009 (BSS138)$4.95Bidirectional I2C voltage translation.
SensorAdafruit BME280 (STEMMA)$19.95Temp/Hum/Press. Wired to Arduino 3.3V out.
Wiring24 AWG Silicone Stranded$5.00Low resistance, flexible for breadboarding.
Bench Tip: The Raspberry Pi 5 has built-in 1.8kΩ pull-up resistors on its 3.3V I2C lines. The SparkFun BSS138 board includes 10kΩ pull-ups on both the low and high voltage sides. This parallel resistance yields an effective pull-up of ~1.5kΩ on the Pi side, which is perfect for standard 100kHz I2C clock speeds over short wire runs (< 1 meter).

Pin Mapping and Wiring Steps

Correct wiring is critical. A missing common ground will cause the I2C bus to float, resulting in corrupted packets or a locked-up peripheral.

Table 2: I2C Pin Mapping Matrix
Raspberry Pi 5 PinLevel Shifter (LV Side)Level Shifter (HV Side)Arduino Uno R4 Minima
Pin 1 (3V3 Power)LV
Pin 2 (5V Power)HV5V Pin
Pin 3 (GPIO 2 / SDA)TX1 / RX1TX1 / RX1A4 (SDA)
Pin 5 (GPIO 3 / SCL)TX2 / RX2TX2 / RX2A5 (SCL)
Pin 6 (GND)GNDGNDGND

Step-by-Step Wiring Procedure

  1. De-energize both boards. Unplug the Pi and Arduino from their power supplies before making I2C connections.
  2. Establish Common Ground. Run a 24 AWG wire from Pi Pin 6 to the Level Shifter GND, and from the Level Shifter GND to the Arduino GND. Do not skip this.
  3. Route Power Rails. Connect Pi Pin 1 (3.3V) to the LV pin on the shifter. Connect Pi Pin 2 (5V) to the HV pin on the shifter, and jumper the shifter HV pin to the Arduino 5V pin.
  4. Connect Data Lines. Wire Pi Pin 3 (SDA) to LV1, and LV1's corresponding HV1 to Arduino A4. Wire Pi Pin 5 (SCL) to LV2, and HV2 to Arduino A5.
  5. Verify with a Multimeter. Before powering on, use your multimeter's continuity mode to ensure SDA and SCL are not shorted to GND. Power on and verify the HV rail reads 4.9V–5.1V and the LV rail reads 3.2V–3.3V.

Firmware and Host Code

This implementation targets the Arduino Uno R4 Minima as the peripheral and the Raspberry Pi 5 as the controller. The Arduino will simulate a sensor reading and format it into a byte array, while the Pi requests the data block and decodes it.

Arduino Peripheral Firmware (C++)

Upload this via the Arduino IDE (ensure the 'Arduino UNO R4 Boards' package is installed via Boards Manager). Note the use of volatile for the interrupt flag to prevent compiler optimization bugs.

#include <Wire.h>

// Pin Definitions
#define I2C_PERIPHERAL_ADDR 0x08
#define LED_STATUS_PIN 13

// Volatile flag for interrupt-safe state tracking
volatile bool requestDataFlag = false;
volatile char sensorPayload[8] = "23.45C\0";

void setup() {
  pinMode(LED_STATUS_PIN, OUTPUT);
  
  // Initialize I2C as peripheral with specific address
  Wire.begin(I2C_PERIPHERAL_ADDR);
  
  // Register interrupt service routines
  Wire.onRequest(handleDataRequest);
  Wire.onReceive(handleCommandReceive);
  
  Serial.begin(115200);
  Serial.println("I2C Peripheral Ready.");
}

void loop() {
  // Simulate sensor polling without blocking I2C interrupts
  static unsigned long lastRead = 0;
  if (millis() - lastRead > 2000) {
    lastRead = millis();
    // In a real build, read your BME280 here and update sensorPayload
    float temp = 23.45 + (random(-10, 10) / 100.0);
    dtostrf(temp, 5, 2, sensorPayload);
    sensorPayload[6] = 'C';
    sensorPayload[7] = '\0';
  }

  if (requestDataFlag) {
    digitalWrite(LED_STATUS_PIN, HIGH);
    delay(50);
    digitalWrite(LED_STATUS_PIN, LOW);
    requestDataFlag = false;
  }
}

// ISR: Triggered when Pi requests data
void handleDataRequest() {
  Wire.write((uint8_t*)sensorPayload, sizeof(sensorPayload));
}

// ISR: Triggered when Pi sends a command
void handleCommandReceive(int byteCount) {
  while (Wire.available()) {
    Wire.read(); // Flush buffer
  }
  requestDataFlag = true;
}

Raspberry Pi Controller Script (Python)

On the Pi, install the modern I2C library: sudo apt install python3-smbus2. Save the following as i2c_bridge.py.

import smbus2
import time
import sys

# Configuration
I2C_BUS = 1
PERIPHERAL_ADDR = 0x08
READ_LENGTH = 8

# Initialize the I2C bus
bus = smbus2.SMBus(I2C_BUS)

def read_sensor_data():
    try:
        # Send a dummy byte to trigger the Arduino's onReceive event
        bus.write_byte(PERIPHERAL_ADDR, 0x00)
        time.sleep(0.05) # Brief pause for Arduino to prepare payload
        
        # Read the 8-byte block from the Arduino
        raw_data = bus.read_i2c_block_data(PERIPHERAL_ADDR, 0x00, READ_LENGTH)
        
        # Decode bytes to string, stripping null terminators
        decoded_string = bytes(raw_data).decode('utf-8').rstrip('\x00')
        return decoded_string
        
    except OSError as e:
        handle_i2c_error(e)
        return None

def handle_i2c_error(error):
    if error.errno == 121:
        print(f'[FATAL] OSError: [Errno 121] Remote I/O error. Check wiring and pull-ups.')
    elif error.errno == 121:
        print(f'[WARN] Device not responding. Is the Arduino powered?')
    else:
        print(f'[ERROR] Unexpected I2C fault: {error}')

if __name__ == '__main__':
    print('Starting Pi-to-Arduino I2C Polling...')
    while True:
        temp_reading = read_sensor_data()
        if temp_reading:
            print(f'Received from Arduino: {temp_reading}')
        time.sleep(1.0)

Debugging 'OSError: [Errno 121] Remote I/O error'

If your Python script crashes or loops with OSError: [Errno 121] Remote I/O error, the Linux kernel's I2C driver is failing to receive an ACKnowledge (ACK) bit from the Arduino. This is the most common failure mode when bridging a pi and arduino.

The first three things to check when it fails:

  1. Verify Common Ground: Measure the voltage between the Pi's GND pin and the Arduino's GND pin with a multimeter. It must read < 0.05V. If it reads higher, your ground wire is broken or undersized.
  2. Check I2C Address Alignment: Run sudo i2cdetect -y 1 on the Pi. You should see 08 in the grid. If the grid is empty, the Pi cannot see the Arduino. If you see 08 but Python still throws Errno 121, your Arduino code is locking up inside the Wire.onRequest ISR (Interrupt Service Routine).
  3. Inspect the Level Shifter Orientation: The BSS138 board has a specific LV (Low Voltage) and HV (High Voltage) side. If you wire the Pi's 3.3V to the HV side, the MOSFETs will not trigger correctly, leaving the bus floating.
Advanced Debugging: If i2cdetect shows the device, but read_i2c_block_data fails, your Arduino might be suffering from I2C clock stretching timeouts. The Raspberry Pi's BCM2712 hardware I2C controller has a strict timeout for clock stretching. If your Arduino loop() uses delay() or heavy floating-point math inside the ISR, it will hold the SCL line low too long, causing the Pi to drop the connection. Keep ISRs under 50 microseconds.

For deeper hardware specifications, refer to the official Arduino Wire library documentation and the Raspberry Pi hardware configuration guides regarding I2C bus speeds.

Extending and Simplifying the Build

Once the basic bridge is stable, you have two distinct paths depending on your project constraints.

How to Extend the Build

  • Add Multiple Peripherals: I2C is a bus. You can wire up to 127 devices. Add an Arduino Nano 33 IoT (address 0x09) and an ESP32 (address 0x0A) to the same level-shifted SDA/SCL lines to create a multi-node sensor array.
  • Implement DMA on the Pi: For high-frequency telemetry, switch from Python's smbus2 to a C++ daemon using the Linux i2c-dev interface with Direct Memory Access (DMA) to offload the CPU.
  • Bi-Directional Control: Expand the Python script to send configuration bytes (e.g., changing sensor sample rates) using bus.write_i2c_block_data(), and parse them in the Arduino's Wire.onReceive ISR.

How to Simplify the Build

If the BSS138 level shifter and I2C interrupt handling feel like overkill for a simple one-way data push, bypass I2C entirely and use USB Serial. Plug the Arduino directly into the Pi's USB port. The Arduino will appear as /dev/ttyACM0. You can then use standard Serial.println() on the Arduino and read it in Python using the pyserial library. This eliminates the need for level shifters, pull-up resistors, and I2C addressing, though it sacrifices the multi-drop bus capability and uses more CPU overhead on the Pi.