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.
| 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.
| 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 |
Step-by-Step Wiring Procedure
- 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.
- 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.
- 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.
- 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. - Verify the Bus: Install the I2C tools on the Pi via
sudo apt install i2c-tools. Runi2cdetect -y 1. You should see a device at address0x08once 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:
- Run
i2cdetect -y 1on the Pi: If the grid is entirely empty, your Arduino is not acknowledging its address. If the grid showsUUor the bus locks up completely, the SDA line is being held low (a classic I2C bus lock). - 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.
- Check for blocking code on the Arduino: The
requestEventISR on the Arduino must execute in microseconds. If you havedelay(),Serial.print(), or slowlcd.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. |
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.






