Difficulty: Intermediate | Time: 45 Minutes | Cost: ~$45 (assuming Pi is owned)

Connecting a microcontroller to a single-board computer is a rite of passage for embedded builders. When you need to control an Arduino from Raspberry Pi, you are essentially bridging a real-time hardware node with a high-level Linux host. The Pi handles the heavy lifting—networking, databases, and UI—while the Arduino manages strict timing, PWM, and analog reads.

This guide cuts through the forum noise and gives you the exact decision framework, wiring, and code to get USB serial communication running reliably, along with the specific Linux commands to fix the permissions errors that trip up 90% of beginners.

The Verdict: Protocol Decision Matrix

Before wiring anything, you need to pick your physical layer. Do not default to I2C just because it uses fewer wires; USB Serial is vastly superior for 95% of Pi-to-Arduino projects. Use this decision tree to lock in your approach.

If your project requires... Choose this protocol Why?
Sending JSON, text logs, or >32 byte payloads USB Serial (Default Pick) Handles large buffers natively; no pull-up resistors needed; hot-swappable.
Sub-millisecond sensor polling on the same PCB I2C (GPIO Pins) Lower overhead for tiny payloads, but requires level shifting (Pi is 3.3V, Arduino is 5V).
Flashing Arduino firmware without a host PC ISP (SPI Pins) Allows the Pi to act as an AVR programmer via avrdude.
The Concrete Pick: For this build, we are using USB Serial. It avoids the 3.3V vs 5V logic level mismatch that fries Pi GPIO pins when using I2C, and it provides a virtual COM port that Python can interact with using standard file I/O paradigms.

Parts List & Pin Mapping for USB Serial

This build targets the Arduino Uno R3 (ATmega328P) and the Raspberry Pi 4 Model B (or Pi 5) running Raspberry Pi OS (Bookworm or later).

Required Components

  • Host: Raspberry Pi 4 Model B (4GB) or Raspberry Pi 5
  • Node: Arduino Uno R3 (Genuine or ATmega16U2-based clone)
  • Cable: USB-A to USB-B cable (Must be a data cable, not a charge-only cable)
  • Peripherals: 1x 220Ω resistor, 1x 5mm LED (for testing)

Pin Mapping Table

Because we are using USB, the physical data pins (D0/D1) are handled by the onboard ATmega16U2 USB-to-Serial chip. However, if you later pivot to I2C, you must use the correct GPIO mapping.

Function Arduino Uno R3 Pin Raspberry Pi 4/5 Pin (Physical) Notes
USB Data + USB-B Connector USB-A Port Virtual mapping via /dev/ttyACM0
USB Data - USB-B Connector USB-A Port Virtual mapping via /dev/ttyACM0
I2C SDA (Fallback) A4 Pin 3 (GPIO 2) Warning: Requires bi-directional logic level shifter
I2C SCL (Fallback) A5 Pin 5 (GPIO 3) Warning: Requires bi-directional logic level shifter
Test LED D13 (via 220Ω) N/A Or use the onboard SMD LED on pin 13

Step-by-Step: Firmware and Host Code

We will write a C++ sketch for the Arduino that listens for a specific string command, and a Python script on the Pi that sends that command and reads the acknowledgment.

Step 1: Flash the Arduino Firmware

Upload this code to your Uno R3 using the Arduino IDE on your PC (or directly on the Pi if you have the IDE installed). Note the explicit pin definition and the trim() function, which strips hidden carriage returns that often break serial string matching.

// Target Board: Arduino Uno R3 (ATmega328P)
const int LED_PIN = 13;
bool ledState = false;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(9600);
  // Wait for serial port to connect. Needed for native USB boards.
  while (!Serial) { 
    ; 
  }
  Serial.println("ARDUINO_READY");
}

void loop() {
  if (Serial.available() > 0) {
    String command = Serial.readStringUntil('\n');
    command.trim(); // Critical: removes \r and whitespace
    
    if (command == "TOGGLE_LED") {
      ledState = !ledState;
      digitalWrite(LED_PIN, ledState ? HIGH : LOW);
      
      // Send acknowledgment back to Pi
      if (ledState) {
        Serial.println("STATE:ON");
      } else {
        Serial.println("STATE:OFF");
      }
    }
  }
}

Step 2: Configure the Raspberry Pi Environment

Open a terminal on your Pi. You need to install the pyserial library and ensure your user has permission to access the USB serial ports.

  1. Install pyserial: sudo apt update && sudo apt install python3-serial -y
  2. Add your user to the dialout group: sudo usermod -a -G dialout $USER
  3. Reboot the Pi or log out and back in for the group change to take effect.
  4. Plug in the Arduino and verify the port: ls -l /dev/ttyACM* (It should list /dev/ttyACM0).

Step 3: Run the Python Host Script

Create a file named pi_controller.py and paste the following code. This script includes robust error handling for the most common serial connection failures.

import serial
import time
import sys

# Target Board: Raspberry Pi 4/5 running Raspberry Pi OS
PORT = '/dev/ttyACM0'
BAUD_RATE = 9600

def connect_arduino():
    try:
        # timeout=1 prevents readline() from blocking forever if Arduino crashes
        ser = serial.Serial(PORT, BAUD_RATE, timeout=1)
        time.sleep(2)  # Wait for Arduino auto-reset cycle to complete
        return ser
    except serial.SerialException as e:
        print(f"[FATAL] Failed to open port: {e}")
        sys.exit(1)

def main():
    ser = connect_arduino()
    print(f"Connected to {PORT} at {BAUD_RATE} baud.")
    
    try:
        while True:
            # Send command to Arduino
            ser.write(b'TOGGLE_LED\n')
            time.sleep(0.5) # Allow Arduino time to process and respond
            
            # Read response
            if ser.in_waiting > 0:
                response = ser.readline().decode('utf-8').strip()
                print(f"Arduino says: {response}")
            
            time.sleep(2) # Wait before next toggle
            
    except KeyboardInterrupt:
        print("\n[INFO] Stopping script...")
    finally:
        if 'ser' in locals() and ser.is_open:
            ser.close()
            print("[INFO] Serial port closed.")

if __name__ == '__main__':
    main()

Run the script with python3 pi_controller.py. You should see the LED toggle every 2 seconds, with the Pi printing Arduino says: STATE:ON and STATE:OFF.

Debugging: Exact Error Strings and the First Three Checks

Serial communication on Linux is unforgiving. If your script fails, do not guess. Look at the exact traceback and follow this ranked troubleshooting path.

The First Three Things to Check

  1. The Cable: Is it a charge-only cable? If ls /dev/ttyACM* returns "No such file or directory", swap the cable. Micro-USB cables from cheap desk fans often lack the D+/D- data wires.
  2. Permissions: Did you forget to reboot after running usermod? The Linux kernel will block the Python script from touching the hardware node if your user isn't in the dialout group.
  3. Baud Rate & Reset: Is the Pi reading garbage text? Ensure both scripts say 9600. Also, opening the serial port on the Pi triggers a hardware reset on the Arduino Uno. The time.sleep(2) in the Python script is mandatory to let the bootloader finish.

Exact Error Strings and Fixes

Error 1: Permission Denied

serial.serialutil.SerialException: [Errno 13] could not open port /dev/ttyACM0: [Errno 13] Permission denied: '/dev/ttyACM0'
  • Cause: Your user lacks read/write access to the TTY device file.
  • Fix: Run sudo usermod -a -G dialout $USER, then reboot. Do not run your Python script with sudo as a shortcut; it masks the problem and creates root-owned log files.

Error 2: No Such File or Directory

serial.serialutil.SerialException: [Errno 2] could not open port /dev/ttyACM0: [Errno 2] No such file or directory: '/dev/ttyACM0'
  • Cause: The kernel does not see a valid USB-Serial device.
  • Fix: Run dmesg | grep tty. If you see ch341-converter, your Arduino is a clone using a CH340 chip. The port will likely be /dev/ttyUSB0 instead of ttyACM0. Update your Python PORT variable accordingly.

Error 3: Unicode Decode Error

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
  • Cause: Baud rate mismatch, or the Arduino sent raw binary data while Python expected UTF-8 text.
  • Fix: Verify Serial.begin(9600) matches BAUD_RATE = 9600. If sending raw bytes, change .decode('utf-8') to handle raw byte arrays in Python.

Extending or Simplifying the Build

Once you have basic USB serial working, you will inevitably want to scale the project. Here is how to pivot based on your end goal.

How to Extend: Add an MQTT Bridge

If you want to control the Arduino from your phone or integrate it with Home Assistant, do not write a custom TCP socket server on the Pi. Instead, install paho-mqtt on the Pi. Modify the Python script to subscribe to an MQTT topic (e.g., home/livingroom/light). When an MQTT message arrives, the Pi translates it and pushes TOGGLE_LED\n over the USB serial port to the Arduino. This keeps the Arduino completely isolated from the network stack.

How to Simplify: Use StandardFirmata

If you hate writing C++ and just want to treat the Arduino as a dumb I/O expander for Python, skip the custom serial parsing entirely.

  1. Open the Arduino IDE and flash the FirmataPlus or StandardFirmata example sketch to the Uno.
  2. On the Pi, install the pyFirmata library: pip3 install pyfirmata.
  3. Control pins directly from Python without writing a single line of Arduino C++:
from pyfirmata import Arduino, util
import time

board = Arduino('/dev/ttyACM0')
board.digital[13].mode = 1 # Set pin 13 to OUTPUT

while True:
    board.digital[13].write(1)
    time.sleep(1)
    board.digital[13].write(0)
    time.sleep(1)

Firmata is the ultimate shortcut for prototyping, though it consumes more SRAM on the ATmega328P and introduces slight latency compared to a tightly optimized custom serial loop. For production deployments, stick to the custom C++ and Python pyserial implementation detailed above.

References: PySerial Short Introduction, Arduino Serial Reference, Raspberry Pi OS Configuration Documentation.