The Raspberry Pi is a powerhouse for Linux, OpenCV, and MQTT networking, but it fundamentally fails at hard real-time hardware control. Its 3.3V GPIOs are fragile, it lacks a native analog-to-digital converter (ADC), and its PWM output suffers from OS-level scheduling jitter. This is exactly when you deploy an Arduino for Pi projects: as a dedicated real-time I/O co-processor.

In this guide, we are building a bulletproof UART (Universal Asynchronous Receiver-Transmitter) serial bridge between a Raspberry Pi 5 and an Arduino Nano Every. The Pi will act as the high-level brain, sending commands and logging data, while the Arduino handles microsecond-precise sensor polling and motor toggling without dropping a single frame.

The Hardware Spec Sheet & Logic Thresholds

Before we wire a single pin, we have to address the most common way makers fry their Pi: ignoring logic voltage thresholds. The Pi operates at 3.3V logic; the Arduino Nano Every operates at 5V. While the Pi's 3.3V TX signal is high enough to be read by the Arduino's RX pin, the Arduino's 5V TX signal will physically destroy the Pi's 3.3V RX pin over time. We must use a bidirectional logic level converter.

Table 1: Component Specifications & Logic Thresholds
Component Exact Variant / Part Number Logic High (V_IH) Logic Low (V_IL) Approx. Cost (2026)
Raspberry Pi (Host) Raspberry Pi 5 (8GB RAM) 3.3V (Max 3.6V absolute) 0V - 1.0V $80.00
Arduino (Co-processor) Arduino Nano Every (ABX00033) 2.1V (at 5V VCC) 0V - 1.5V $20.50
Level Shifter SparkFun BSS138 (BOB-12009) Passes LV (3.3V) to HV (5V) N/A (Bidirectional) $3.95
Wiring 28 AWG Silicone Jumper Wires N/A N/A $8.00 / spool
⚠️ Critical Safety Callout: Never connect a 5V Arduino TX pin directly to a Raspberry Pi RX pin. The Pi's BCM2712 SoC does not have 5V-tolerant GPIOs. Exceeding 3.6V on the GPIO header will cause irreversible silicon latch-up and permanent damage to the Pi.

Wiring the UART Bridge

We are using hardware UART rather than USB-Serial to free up the Arduino's USB port for live debugging via the Serial Monitor, and to bypass the FTDI/CH340 USB-to-Serial chip latency. The Nano Every features a dedicated hardware serial port (Serial1) on pins 0 (RX) and 1 (TX).

Pin Mapping Table

Table 2: Physical Pin Mapping (Pi ↔ Level Shifter ↔ Arduino)
Raspberry Pi 5 (GPIO) BSS138 Level Shifter (LV Side) BSS138 Level Shifter (HV Side) Arduino Nano Every
Pin 1 (3.3V Power) LV - -
Pin 2 (5V Power) - HV 5V Pin
Pin 6 (GND) GND (LV side) GND (HV side) GND
Pin 8 (GPIO 14 / TXD) LV1 HV1 Pin 0 (RX1)
Pin 10 (GPIO 15 / RXD) LV2 HV2 Pin 1 (TX1)

Wiring Steps:

  1. De-energize both boards. Disconnect the Pi from mains and the Arduino from USB.
  2. Wire Power and Ground. Connect the Pi's 3.3V and 5V pins to the LV and HV rails of the BSS138 board, respectively. Tie all GNDs together. A common ground is mandatory for UART reference.
  3. Cross the Data Lines. Pi TX (Pin 8) goes to LV1. The corresponding HV1 goes to Arduino RX (Pin 0). Pi RX (Pin 10) goes to LV2. HV2 goes to Arduino TX (Pin 1). Notice the TX-to-RX crossover.
  4. Verify with a Multimeter. Before applying power, use your DMM in continuity mode to ensure no shorts exist between the 5V and 3.3V rails on the level shifter.

The Firmware: Real-Time C++ and Python Host

The following code targets the Arduino Nano Every (ATmega4809). It uses a fixed-size character buffer to parse incoming serial commands, avoiding the String class entirely to prevent heap fragmentation—a critical practice for long-running embedded I/O loops.

Arduino Co-Processor Firmware (C++)

// Target Board: Arduino Nano Every (ATmega4809)
// Baud Rate: 115200

#define SENSOR_PIN A0
#define RELAY_PIN 4
#define BUFFER_SIZE 32

char rxBuffer[BUFFER_SIZE];
uint8_t rxIndex = 0;

void setup() {
  // Serial1 is the hardware UART on pins 0 (RX) and 1 (TX)
  Serial1.begin(115200);
  
  // Serial (USB) reserved for local debug monitoring
  Serial.begin(115200);
  
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, LOW);
  
  Serial1.println("SYS:READY");
}

void loop() {
  // 1. Handle Real-Time Sensor Polling (Non-blocking)
  // Read analog sensor every 50ms without using delay()
  static unsigned long lastRead = 0;
  if (millis() - lastRead >= 50) {
    lastRead = millis();
    int sensorVal = analogRead(SENSOR_PIN);
    // Only transmit if value changes by more than 5 to reduce bus spam
    static int lastVal = 0;
    if (abs(sensorVal - lastVal) > 5) {
      Serial1.print("SENS:");
      Serial1.println(sensorVal);
      lastVal = sensorVal;
    }
  }

  // 2. Handle Incoming Pi Commands
  while (Serial1.available() > 0) {
    char c = Serial1.read();
    if (c == '\n') {
      rxBuffer[rxIndex] = '\0'; // Null-terminate
      processCommand(rxBuffer);
      rxIndex = 0;
    } else if (rxIndex < BUFFER_SIZE - 1) {
      rxBuffer[rxIndex++] = c;
    } else {
      rxIndex = 0; // Buffer overflow protection
    }
  }
}

void processCommand(char* cmd) {
  if (strcmp(cmd, "RELAY_ON") == 0) {
    digitalWrite(RELAY_PIN, HIGH);
    Serial1.println("ACK:RELAY_ON");
  } 
  else if (strcmp(cmd, "RELAY_OFF") == 0) {
    digitalWrite(RELAY_PIN, LOW);
    Serial1.println("ACK:RELAY_OFF");
  } 
  else if (strcmp(cmd, "PING") == 0) {
    Serial1.println("PONG");
  } 
  else {
    Serial1.print("ERR:UNKNOWN_CMD:");
    Serial1.println(cmd);
  }
}

Raspberry Pi Host Script (Python 3)

On the Pi, we use the pyserial library. Install it via pip install pyserial. This script includes robust error handling for disconnected cables and malformed data.

import serial
import time
import sys

# Target Port: /dev/ttyAMA0 (See debugging section for PL011 vs mini-UART)
PORT = '/dev/ttyAMA0'
BAUD = 115200

def init_serial():
    try:
        ser = serial.Serial(
            port=PORT,
            baudrate=BAUD,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            bytesize=serial.EIGHTBITS,
            timeout=1
        )
        print(f"[INFO] Connected to Arduino on {PORT}")
        return ser
    except serial.SerialException as e:
        print(f"[FATAL] SerialException: {e}")
        sys.exit(1)

def send_command(ser, cmd):
    try:
        ser.write((cmd + '\n').encode('utf-8'))
        # Wait for Arduino ACK
        response = ser.readline().decode('utf-8', errors='replace').strip()
        print(f"[TX] {cmd} -> [RX] {response}")
        return response
    except Exception as e:
        print(f"[ERROR] Communication failed: {e}")
        return None

if __name__ == "__main__":
    pi_serial = init_serial()
    time.sleep(2) # Wait for Arduino reset/boot
    
    # Flush initial boot messages
    pi_serial.reset_input_buffer()
    
    send_command(pi_serial, "PING")
    send_command(pi_serial, "RELAY_ON")
    time.sleep(1)
    send_command(pi_serial, "RELAY_OFF")
    
    pi_serial.close()

Debugging: Permission Denied and Sync Failures

When bridging Linux to bare-metal microcontrollers, the OS will fight you for control of the serial port. If your Python script crashes immediately, you are likely hitting the most infamous Pi serial error.

Exact Error String:
serial.serialutil.SerialException: [Errno 13] could not open port /dev/ttyS0: [Errno 13] Permission denied: '/dev/ttyS0'

The First Three Things to Check When It Fails

  1. User Group Permissions: The default pi (or your custom user) is not in the dialout group, which owns serial devices. Fix this by running sudo usermod -a -G dialout $USER in the terminal, then reboot the Pi. Logging out and back in is not enough for group changes to apply to hardware nodes.
  2. The Serial Console is Hogging the Port: By default, the Pi routes the Linux boot console to the UART header. You must disable this. Run sudo raspi-config, navigate to Interface Options -> Serial Port. Select No for "Would you like a login shell to be accessible over serial?" and Yes for "Would you like the serial port hardware to be enabled?".
  3. Mini-UART vs. PL011 Clock Jitter: On the Pi 4 and 5, the GPIO header UART pins are mapped to the "mini-UART" (/dev/ttyS0) by default. The mini-UART baud rate is tied to the Pi's core clock, which scales dynamically. This causes baud rate drift and garbled text. To fix this, force the high-performance PL011 UART (/dev/ttyAMA0) to the GPIO pins by adding dtoverlay=disable-bt to your /boot/firmware/config.txt and rebooting. (This disables onboard Bluetooth, which is a worthwhile tradeoff for stable hardware I/O).

Scaling the Build: Extensions and Simplifications

Depending on your physical layout and project scope, you may need to alter this baseline architecture.

How to Simplify the Build

If you are prototyping on a desk and don't care about USB debugging on the Arduino, ditch the BSS138 level shifter and the GPIO header entirely. Simply connect the Pi and Arduino via a standard USB-C data cable. The Arduino's onboard ATmega16U2 USB-to-Serial chip handles the 5V-to-3.3V logic translation safely. In your Python script, change the port to /dev/ttyACM0. You lose the deterministic latency of hardware UART, but you save 30 minutes of wiring.

How to Extend for Industrial or Long-Distance Runs

TTL-level UART (the 3.3V/5V signals we used here) is only reliable for about 1 to 2 meters. If your Pi is in a control cabinet and the Arduino is out on a factory floor or a large greenhouse, TTL will suffer from capacitive coupling and EMI, resulting in dropped bytes.

To extend this, add a MAX485 RS-485 transceiver module to both the Pi and the Arduino. RS-485 uses differential signaling over a twisted pair, allowing reliable serial communication up to 1,200 meters at 115200 baud. You will need to implement a half-duplex DE/RE (Driver Enable) pin toggle in your C++ code before transmitting, but the hardware robustness is unmatched for remote Arduino sensor nodes.

Pro-Tip for Pi 5 Builders: The Raspberry Pi 5 changed the power delivery and peripheral routing slightly compared to the Pi 4. Ensure your Pi 5 is running the latest Bookworm firmware, as early 2024 kernels had bugs regarding the disable-bt device tree overlay. Always verify your active UART mapping with ls -l /dev/serial* before deploying to production.