To reliably interface a GPS and Raspberry Pi, bypass USB-to-serial adapters and wire the module’s UART TX/RX pins directly to the Pi’s GPIO header. USB adapters introduce latency, drop packets under heavy bus load, and require messy udev rules to maintain persistent mount points. A direct UART connection to the Pi 5’s RP1 southbridge yields a rock-solid, low-latency NMEA data stream.

This guide targets the Raspberry Pi 5 (8GB variant) running Raspberry Pi OS (Bookworm or later). We will use the u-blox NEO-M9N, a multi-band GNSS receiver that achieves lock faster and holds it longer in urban canyons compared to the older NEO-6M or MTK3339 chips.

Project Overview & Difficulty Rating

Difficulty: Intermediate (3/5)
Time Required: 45 minutes (Hardware) + 30 minutes (Software/Config)
Target Board: Raspberry Pi 5 (8GB) with RP1 Southbridge
Target OS: Raspberry Pi OS (64-bit, Bookworm)

The Pi 5 represents a major architectural shift from the Pi 4. The Broadcom BCM2712 application processor delegates I/O to the RP1 chip. Consequently, the primary UART mapping is cleaner: Bluetooth is routed internally, leaving /dev/ttyAMA0 fully dedicated to the GPIO header pins without the dtoverlay=pi3-miniuart-bt hacks required on older boards.

Hardware BOM & UART Pin Mapping

Before cutting wires, verify your exact module variant. Many cheap clone boards advertise a "NEO-M8N" but ship with a recycled NEO-6M die. Buy from authorized distributors to ensure you get the multi-band silicon.

Bill of Materials (2026 Pricing)
Component Exact Variant / Part # Interface Est. Price Key Specification
SBC Raspberry Pi 5 (8GB) N/A $80.00 BCM2712 + RP1 I/O
GNSS Module SparkFun u-blox NEO-M9N (GPS-17285) 3.3V UART / I2C / SPI $54.95 L1/L2/L5 Multi-band, 25Hz max
Antenna Active GPS/GLONASS Antenna (SMA) SMA Connector $12.50 28dB LNA, 3.3V-5V power
Wiring 28 AWG Silicone Jumper Wires (F-F) Dupont 2.54mm $6.00 Low resistance, flexible
Logic Level Warning: The Raspberry Pi 5 GPIO operates at 3.3V. The SparkFun NEO-M9N breakout includes onboard 3.3V regulation and level shifting. If you are using a raw u-blox module without a breakout board, ensure you do not feed 5V into the TX/RX pins, or you will permanently damage the Pi 5's RP1 I/O bank.

GPIO Pin Mapping

We are using the primary UART (/dev/ttyAMA0). Do not use the secondary mini-UART (/dev/ttyS0), as it lacks a fractional baud rate divider and will drift over temperature changes, causing NMEA checksum failures.

Pi 5 Physical Pin BCM / RP1 GPIO Function NEO-M9N Breakout Pin
Pin 6 GND Ground Reference GND
Pin 8 GPIO 14 (TXD) Pi Transmit RXI (Receive)
Pin 10 GPIO 15 (RXD) Pi Receive TXO (Transmit)
Pin 17 3V3 Power VCC (Max 50mA draw) VCC

Step-by-Step UART Configuration

Out of the box, the Pi 5 routes the Linux serial console to /dev/ttyAMA0. If you plug your GPS in without disabling this, the kernel will blast boot logs and login prompts at your GPS module, which can corrupt its configuration or cause the module to drop into a low-power state.

  1. Wire the hardware: Connect Pi Pin 8 (TX) to GPS RX. Connect Pi Pin 10 (RX) to GPS TX. Connect Ground and 3.3V. Double-check the TX/RX crossover. TX always goes to RX.
  2. Disable the serial console: Open a terminal and run sudo raspi-config.
  3. Navigate to Interface Options > Serial Port.
  4. When asked "Would you like a login shell to be accessible over serial?", select No.
  5. When asked "Would you like the serial port hardware to be enabled?", select Yes.
  6. Reboot the Pi: sudo reboot.
  7. Verify the port: After reboot, check that the port exists and is quiet by running cat /dev/ttyAMA0. You should see raw NMEA sentences scrolling by (e.g., $GNGGA,...). Press Ctrl+C to exit.

For deeper architectural details on the RP1 UART routing, refer to the official Raspberry Pi UART configuration documentation.

Python Implementation: NMEA Parsing

Raw NMEA strings are difficult to parse manually due to variable sentence lengths and hexadecimal checksums. We will use pyserial to read the port and pynmea2 to parse the $GNGGA (Global Positioning System Fix Data) sentences. Install them via pip: pip3 install pyserial pynmea2.

For comprehensive library usage, see the pyserial short introduction.

#!/usr/bin/env python3
"""
Raspberry Pi 5 UART GPS Reader
Target: Raspberry Pi 5 (8GB) + u-blox NEO-M9N
Dependencies: pyserial, pynmea2
"""

import serial
import pynmea2
import sys
import time

# --- PIN & PORT DEFINITIONS ---
# Physical Pin 8  -> GPIO 14 (TXD) -> GPS RX
# Physical Pin 10 -> GPIO 15 (RXD) -> GPS TX
# Port maps to primary UART on Pi 5 RP1 southbridge
UART_PORT = '/dev/ttyAMA0'
BAUD_RATE = 9600  # Default for u-blox NEO-M9N out of the box
TIMEOUT_SEC = 1.0

def init_serial_port(port, baud):
    """Initialize serial port with error handling for permissions and hardware faults."""
    try:
        ser = serial.Serial(
            port=port,
            baudrate=baud,
            timeout=TIMEOUT_SEC,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            bytesize=serial.EIGHTBITS
        )
        return ser
    except serial.SerialException as e:
        print(f"[FATAL] Hardware/Permission Error: {e}")
        sys.exit(1)

def main():
    print(f"Initializing GPS on {UART_PORT} at {BAUD_RATE} baud...")
    ser = init_serial_port(UART_PORT, BAUD_RATE)
    
    try:
        while True:
            # Read a line from the serial buffer
            raw_line = ser.readline()
            
            # Skip empty timeouts
            if not raw_line:
                continue
                
            try:
                # Decode bytes to string and strip whitespace
                line = raw_line.decode('ascii', errors='replace').strip()
                
                # Filter for GGA (Fix Data) sentences only
                if line.startswith('$GNGGA') or line.startswith('$GPGGA'):
                    msg = pynmea2.parse(line)
                    
                    # Check if we have a valid fix (Quality > 0)
                    if msg.gps_qual > 0:
                        print(f"[FIX] Lat: {msg.latitude:2.6f} {msg.lat_dir} | "
                              f"Lon: {msg.longitude:3.6f} {msg.lon_dir} | "
                              f"Sats: {msg.num_sats} | HDOP: {msg.horizontal_dil}")
                    else:
                        print("[SEARCHING] Acquiring satellite lock...")
                        
            except pynmea2.ParseError:
                # Ignore malformed lines or non-GGA sentences
                continue
                
    except KeyboardInterrupt:
        print("\n[INFO] Tracking stopped by user.")
    finally:
        if ser.is_open:
            ser.close()
            print("[INFO] Serial port closed.")

if __name__ == '__main__':
    main()

Debugging: Fixing Permission and Parse Errors

When working with Linux serial ports, you will inevitably hit the following error on your first run:

serial.serialutil.SerialException: [Errno 13] could not open port /dev/ttyAMA0: [Errno 13] Permission denied: '/dev/ttyAMA0'

First Three Things to Check When It Fails

  1. User Group Permissions (The [Errno 13] Fix): By default, the pi user might not have read/write access to the tty devices. Fix this permanently by adding your user to the dialout group:
    sudo usermod -a -G dialout $USER
    You must log out and log back in (or reboot) for group changes to take effect.
  2. Serial Console Interference: If the port opens but pynmea2 throws constant ParseError exceptions, the Linux kernel is likely still outputting boot logs to the UART. Re-run sudo raspi-config and ensure the serial login shell is disabled, or manually edit /boot/firmware/cmdline.txt and remove console=serial0,115200.
  3. TX/RX Crossover & Voltage: If the script hangs on ser.readline() and never prints anything (not even the "SEARCHING" fallback), your RX line is dead. Verify with a multimeter that Pin 10 (RXD) sees 3.3V when idle. Ensure Pi TX goes to GPS RX, and Pi RX goes to GPS TX.

Ranked Causes for "No Fix" (Quality = 0)

If the code runs but msg.gps_qual remains 0 indefinitely:

  • Cause 1 (80%): Indoor testing. The NEO-M9N is sensitive, but L1/L5 bands will not penetrate a modern insulated roof. Move near a window or outside.
  • Cause 2 (15%): Passive antenna used instead of active. If your breakout board expects a 3.3V active antenna and you connected a passive ceramic patch, the LNA (Low Noise Amplifier) is unpowered.
  • Cause 3 (5%): Baud rate mismatch. If the module was previously configured via u-center to 115200 baud, change the BAUD_RATE variable in the Python script to match.

Extending and Simplifying the Build

How to Simplify: Use the gpsd Daemon

If you don't want to manage Python serial buffers and just want a local JSON API for your GPS data, use gpsd. It runs as a background service, handles the serial parsing in C, and exposes a local socket.

sudo apt install gpsd gpsd-clients
sudo systemctl enable gpsd.socket
sudo systemctl start gpsd.socket
# Configure it to listen to ttyAMA0
sudo dpkg-reconfigure gpsd

Once running, you can query your GPS from any language using the gps library, or simply test it via the terminal with cgps -s. This is the preferred method if you are building a web dashboard or feeding data into Node-RED.

How to Extend: SQLite Logging & I2C OLED

To turn this into a standalone datalogger (e.g., for a vehicle or high-altitude balloon):

  1. Add Storage: Import Python's built-in sqlite3 library. Create a table with columns for timestamp, latitude, longitude, altitude, and speed. Insert a row every time a valid GGA sentence is parsed.
  2. Add Visual Output: Wire an SSD1306 128x64 OLED display to the Pi's I2C bus (Pins 3 and 5). Use the adafruit-circuitpython-ssd1306 library to render the current speed and satellite count in real-time without needing a network connection.
  3. Add PPS (Pulse Per Second): The NEO-M9N breakout exposes a PPS pin. Wire this to Pi GPIO 4. Using the pps-tools and chrony packages, you can discipline the Pi's system clock to stratum-1 GPS time accuracy, which is critical for distributed sensor networks and SDR (Software Defined Radio) applications.