Connecting an RPI to Arduino via I2C is the most robust method for offloading real-time sensor polling from a Linux host to a bare-metal microcontroller. Unlike USB serial, which incurs OS-level latency and requires FTDI/CH340 driver overhead, I2C operates on a shared hardware bus with deterministic timing. However, because the Raspberry Pi 4 operates at 3.3V logic and the classic Arduino Uno R3 uses 5V logic, a direct connection will destroy the Pi's GPIO pins. You must use a bidirectional logic level shifter. This guide details the exact wiring, Python and C++ code, and debugging steps to establish a stable RPI to Arduino I2C link.

Protocol Selection: I2C vs SPI vs UART for RPI to Arduino

Before wiring, it is critical to choose the right protocol for your specific data throughput and latency requirements. While USB UART is the easiest to physically connect, I2C and SPI offer direct hardware bus integration. Below is a data-dense comparison to help you decide.

Table 1: RPI to Arduino Communication Protocol Comparison
Protocol Max Practical Speed Wiring Complexity Level Shifting Required? Best Use Case
I2C 400 kHz (Fast Mode) Low (2 shared wires + GND) Yes (3.3V to 5V) Polling low-speed sensors (BME280, BH1750), state machines
SPI 20 MHz - 50 MHz Medium (4 wires + GND per device) Yes (3.3V to 5V) High-speed data (TFT displays, external ADCs, SD cards)
USB UART 115,200 baud (typical) Lowest (1 USB cable) No (Handled by USB PHY) Telemetry streaming, debugging, human-readable CLI
RS-485 10 Mbps (over short runs) High (Requires transceivers) Yes (UART to Differential) Noisy industrial environments, long-distance runs (>50ft)

Hardware Requirements and Pin Mapping

This build specifically targets the Raspberry Pi 4 Model B (4GB) running Raspberry Pi OS (Bookworm) as the master, and the Arduino Uno R3 (DIP ATmega328P) as the slave. Because the Uno R3 is a 5V device and the Pi 4 is strictly 3.3V, we are using a BSS138 MOSFET-based bidirectional level shifter.

Parts List

  • Host: Raspberry Pi 4 Model B (or Pi 5)
  • Target: Arduino Uno R3 (ATmega328P, 5V logic)
  • Level Shifter: SparkFun Logic Level Converter - Bi-Directional (Part #BOB-12009, BSS138 MOSFETs)
  • Wiring: 22 AWG stranded silicone jumper wires
  • Resistors: 4.7kΩ pull-up resistors (only needed if bus capacitance exceeds 200pF)

Pin Mapping Table

The BSS138 level shifter has a low-voltage (LV) side and a high-voltage (HV) side. Never swap these power rails.

Table 2: RPI to Arduino I2C Pin Mapping via Level Shifter
Raspberry Pi 4 GPIO Level Shifter (LV Side) Level Shifter (HV Side) Arduino Uno R3
GPIO 2 (SDA1 / Pin 3) LV1 HV1 A4 (SDA)
GPIO 3 (SCL1 / Pin 5) LV2 HV2 A5 (SCL)
Pin 1 (3.3V Power) LV - -
Pin 2 (5V Power) - HV 5V Pin
Pin 6 (GND) GND1 GND2 GND
WARNING: Feeding 5V directly into the Raspberry Pi's GPIO 2 or 3 will instantly fry the BCM2711 SoC's I2C peripheral and likely destroy the entire chip. Always verify your level shifter's HV and LV rails with a multimeter before connecting the I2C data lines.

Step-by-Step Wiring Procedure

  1. Establish Equipotential Grounding: Connect the GND pin of the Raspberry Pi to GND1 on the level shifter, and GND2 to the Arduino GND. I2C requires a shared ground reference to correctly interpret logic thresholds.
  2. Power the Level Shifter: Connect Pi Pin 1 (3.3V) to the LV pin on the shifter. Connect the Arduino 5V pin to the HV pin on the shifter. Do not power the shifter from the Pi's 5V rail for the LV side; it must match the Pi's logic high voltage exactly.
  3. Route the Data Lines: Connect Pi GPIO 2 to LV1, and LV1's corresponding HV1 to Arduino A4. Repeat for GPIO 3 (SCL) through LV2/HV2 to Arduino A5.
  4. Enable I2C on the Pi: SSH into your Raspberry Pi and run sudo raspi-config. Navigate to Interface Options > I2C and enable it. Reboot the Pi.
  5. Verify the Bus: Install the I2C tools on the Pi via sudo apt install i2c-tools. Run i2cdetect -y 1. You should see a device at address 0x08 once the Arduino is flashed and running.

Complete Firmware and Host Code

Below is the complete, compilable code for both the Arduino slave and the Raspberry Pi master. This example implements a simple telemetry request where the Pi asks for sensor data, and the Arduino responds with a formatted byte array.

Arduino Uno R3 Firmware (C++)

This code targets the ATmega328P. It uses the native Arduino Wire library. We implement a non-blocking state machine to prevent the I2C bus from hanging if the master requests data while the Arduino is busy.

#include <Wire.h>

const int I2C_ADDRESS = 0x08;
const int LED_PIN = 13;

// Volatile variables for I2C interrupt service routines
volatile bool dataRequested = false;
volatile int sensorValue = 0;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(115200);
  
  // Initialize I2C as slave
  Wire.begin(I2C_ADDRESS);
  Wire.onReceive(receiveEvent);
  Wire.onRequest(requestEvent);
  
  Serial.println("Arduino I2C Slave Ready.");
}

void loop() {
  // Simulate sensor reading (non-blocking)
  sensorValue = analogRead(A0); 
  
  // Blink LED to show main loop is not blocked
  digitalWrite(LED_PIN, !digitalRead(LED_PIN));
  delay(100); 
}

// Triggered when master sends data
void receiveEvent(int bytes) {
  while(Wire.available()) {
    char c = Wire.read();
    // Process incoming commands if needed
  }
}

// Triggered when master requests data
void requestEvent() {
  // Pack the 10-bit ADC value into two bytes
  byte highByte = (sensorValue >> 8) & 0xFF;
  byte lowByte = sensorValue & 0xFF;
  
  Wire.write(highByte);
  Wire.write(lowByte);
}

Raspberry Pi Master Script (Python)

This script uses the smbus2 library. Install it via pip3 install smbus2. It includes robust error handling for the most common I2C bus faults.

import smbus2
import time
import sys

I2C_BUS = 1
ARDUINO_ADDR = 0x08

def main():
    bus = smbus2.SMBus(I2C_BUS)
    
    while True:
        try:
            # Request 2 bytes from the Arduino
            msg = smbus2.i2c_msg.read(ARDUINO_ADDR, 2)
            bus.i2c_rdwr(msg)
            
            data = list(msg)
            if len(data) == 2:
                # Reconstruct the 10-bit integer
                adc_value = (data[0] << 8) | data[1]
                # Convert to voltage (assuming 5V reference on Uno)
                voltage = (adc_value / 1023.0) * 5.0
                print(f"ADC Raw: {adc_value} | Voltage: {voltage:.2f}V")
            
            time.sleep(1.0)
            
        except OSError as e:
            if e.errno == 121:
                print("Error: Remote I/O error (Errno 121). Check wiring and pull-ups.")
            elif e.errno == 110:
                print("Error: Connection timed out (Errno 110). Bus might be locked.")
            else:
                print(f"Unexpected I2C OS Error: {e}")
            time.sleep(2.0)
        except KeyboardInterrupt:
            print("
Exiting...")
            sys.exit(0)

if __name__ == "__main__":
    main()

Debugging: Fixing "Remote I/O error" and Hanging Buses

When working with I2C across mixed-voltage domains, you will inevitably encounter bus lockups. The most common Python exception thrown by the smbus2 library is:

OSError: [Errno 121] Remote I/O error

If you hit this error, here are the first three things to check:

  1. Run i2cdetect -y 1 on the Pi: If the grid is entirely empty, your Arduino is not acknowledging its address. If the grid shows UU or the bus locks up completely, the SDA line is being held low (a classic I2C bus lock).
  2. Measure the LV and HV rails: Use a multimeter to verify the level shifter LV pin reads exactly 3.2V-3.3V and the HV pin reads 4.8V-5.1V. If HV is reading 3.3V, your Arduino is likely underpowered or the USB cable has severe voltage drop.
  3. Check for blocking code on the Arduino: The requestEvent ISR on the Arduino must execute in microseconds. If you have delay(), Serial.print(), or slow lcd.clear() calls inside your main loop that interrupt the I2C state machine, the Pi will time out and throw Errno 121.

Ranked Causes for Errno 121

Rank Cause Fix / Measurement Threshold
1 Missing or weak pull-up resistors The SparkFun BOB-12009 has 10kΩ onboard pull-ups. If your wires exceed 12 inches, bus capacitance exceeds 200pF, causing slow rise times. Solder 4.7kΩ or 2.2kΩ resistors between SDA/SCL and the 3.3V rail.
2 Arduino resetting during I2C transaction Check the Arduino's power supply. If the Pi's 5V rail sags below 4.6V under load, the Uno's brown-out detector (BOD) will reset the ATmega328P mid-transaction.
3 Incorrect I2C address in Python Verify the address matches. 0x08 in C++ is 0x08 in Python. Do not confuse 7-bit addresses with 8-bit read/write addresses.
4 Ground loop noise Ensure the Pi and Arduino share a single ground point (star grounding). Read < 0.05V AC across the GND pins with a multimeter.
Pro-Tip for Bus Lockups: If the Pi's I2C bus locks up (SDA held low), rebooting the Pi will not fix it because the Arduino is still holding the line low. You must power-cycle the Arduino, or write a script that toggles the SCL pin as a standard GPIO 9 times to force the slave to release the bus, as documented in the NXP I2C Specification (UM10204).

How to Extend or Simplify the Build

Depending on your project constraints, you may want to eliminate the level shifter entirely or scale the architecture for IoT deployment.

Simplify: Drop the Level Shifter

If you want to eliminate the BSS138 level shifter and its associated wiring complexity, swap the Arduino Uno R3 for an Arduino Nano 33 IoT or an Arduino MKR Zero. These boards use SAMD21 ARM Cortex-M0+ microcontrollers that run natively at 3.3V logic. You can wire the Pi's GPIO 2 and 3 directly to the Nano's SDA/SCL pins. Note: Ensure your sensors are also 3.3V tolerant before making this swap.

Extend: MQTT Sensor Bridge

To scale this from a local bench test to a whole-home IoT network, use the Raspberry Pi as an MQTT bridge. Modify the Python script to publish the parsed voltage variable to a local Mosquitto broker using the paho-mqtt library. This allows Node-RED, Home Assistant, or remote ESP32 nodes to subscribe to the Arduino's sensor data without needing direct I2C access to the bus.