Your old RPi.GPIO script worked on the Pi 4. On the Pi 5, it throws RuntimeError: Cannot determine SOC peripheral base address. The cause is hardware, not your code. The Pi 5 moved GPIO control off the main BCM2712 SoC and onto a separate I/O controller called RP1. Libraries that poke memory-mapped registers directly can no longer find the pins. This guide shows what changed, which library to use, and how to port your projects with tested code.
Quick Takeaways
- RPi.GPIO and pigpio do not work on the Pi 5 because they depend on the old BCM283x register layout. RP1 replaced it.
- gpiozero is the best default for most projects. It runs on top of lgpio on Pi 5 and keeps a simple, high-level API.
- lgpio gives low-level control, hardware PWM timing options, and direct access to the gpiochip character device.
- All GPIO pins still run at 3.3V logic. Never connect 5V signals directly to a pin.
| Library | Pi 5 Support | Level | Best For | Maintained |
|---|---|---|---|---|
| RPi.GPIO | No | Low | Legacy Pi 1-4 scripts | Effectively dormant |
| pigpio | No | Low | Legacy PWM/servo on Pi 1-4 | Dormant |
| gpiozero | Yes | High | Beginners, prototypes, education | Yes |
| lgpio | Yes | Low | Precise control, custom drivers | Yes |
| libgpiod (Python bindings) | Yes | Low | Kernel-native, portable code | Yes |
| rpi-lgpio | Yes | Drop-in shim | Quick RPi.GPIO compatibility | Yes |
Why RPi.GPIO Stopped Working on the Raspberry Pi 5
On earlier boards, the SoC contained the GPIO block. RPi.GPIO mapped /dev/mem and wrote straight to registers at a known peripheral base address. It was fast and simple, and it bypassed the kernel.
The Pi 5 changes this design. Raspberry Pi built RP1, a custom south-bridge chip, and connected it to the BCM2712 over a PCIe 2.0 x4 link. RP1 now handles:
- The 40-pin header GPIO
- I2C, SPI, UART, and PWM
- USB 3.0 and Ethernet MAC
- Camera and display interfaces (MIPI)
Because RP1 sits behind PCIe, its registers live at different addresses and use a different peripheral layout. The old address-detection logic in RPi.GPIO fails. Accessing RP1 also goes through the kernel’s gpiochip driver, so userspace code should use the character device interface (/dev/gpiochip4 on Pi 5) instead of raw memory.
What the Error Looks Like
RuntimeError: Cannot determine SOC peripheral base address
On Pi 5, this error means the library is probing for BCM283x hardware that no longer owns the pins. No patch to your script fixes it. You must switch libraries or use a compatibility shim.
The gpiochip Number Changed
On the Pi 5, the header pins live on gpiochip4. Earlier models used gpiochip0. Check with:
gpiodetect
Expected output on a Pi 5:
gpiochip0 [gpio-brcmstb@107d508500] (32 lines)
gpiochip1 [gpio-brcmstb@107d508520] (4 lines)
gpiochip2 [gpio-brcmstb@107d517c00] (17 lines)
gpiochip3 [gpio-brcmstb@107d517c20] (6 lines)
gpiochip4 [pinctrl-rp1] (54 lines)
The pinctrl-rp1 entry is your 40-pin header. Newer Raspberry Pi OS releases also create a /dev/gpiochip0 alias that points to it, which helps portability. Verify on your image before hardcoding.
Pinout Reference: 40-Pin Header on Pi 5
The physical header layout matches the Pi 4. Your HATs and jumper wiring carry over. Only the software stack changed.
| Function | BCM GPIO | Physical Pin | Notes |
|---|---|---|---|
| 3.3V power | – | 1, 17 | Max ~50mA shared budget guidance |
| 5V power | – | 2, 4 | Direct from USB-C input |
| GND | – | 6, 9, 14, 20, 25, 30, 34, 39 | Common ground |
| I2C1 SDA | GPIO2 | 3 | Onboard 1.8kΩ pull-up |
| I2C1 SCL | GPIO3 | 5 | Onboard 1.8kΩ pull-up |
| UART TX | GPIO14 | 8 | Serial console optional |
| UART RX | GPIO15 | 10 | Serial console optional |
| SPI0 MOSI | GPIO10 | 19 | SPI data out |
| SPI0 MISO | GPIO9 | 21 | SPI data in |
| SPI0 SCLK | GPIO11 | 23 | SPI clock |
| PWM0 | GPIO12 | 32 | Hardware PWM channel |
| PWM1 | GPIO13 | 33 | Hardware PWM channel |
Electrical Limits to Respect
- Logic level: 3.3V. Inputs are not 5V tolerant.
- Recommended per-pin current: 8mA typical, 16mA absolute maximum guidance for drive strength settings.
- Total current across all GPIO: stay under roughly 50mA unless you buffer the load.
- Drive LEDs through a 330Ω resistor. At 3.3V, this limits current to about 4-5mA for a standard red LED.
- Drive motors, relays, and solenoids through a MOSFET or transistor stage with a flyback diode.
Setting Up Your Environment
Use a current Raspberry Pi OS (Bookworm or newer). The required libraries ship in the default image, but confirm them.
sudo apt update
sudo apt install -y python3-gpiozero python3-lgpio gpiod python3-libgpiod
On Bookworm and later, Python enforces externally managed environments. If you need packages from PyPI, use a virtual environment that can see system packages:
python3 -m venv --system-site-packages ~/gpio-env
source ~/gpio-env/bin/activate
pip install gpiozero lgpio
Check the installed versions:
python3 -c "import gpiozero, lgpio; print(gpiozero.__version__)"
Option 1: gpiozero (Recommended for Most Projects)
gpiozero abstracts pins into devices like LED, Button, and Servo. On Pi 5, it selects lgpio as the default pin factory automatically. You write device-level code, and the library handles the pin plumbing.
Blink an LED
Wiring: GPIO17 (pin 11) to a 330Ω resistor, then to the LED anode. LED cathode to GND (pin 9).
from gpiozero import LED
from time import sleep
led = LED(17) # BCM numbering: GPIO17 is physical pin 11
while True:
led.on() # Drive pin HIGH (3.3V)
sleep(0.5)
led.off() # Drive pin LOW (0V)
sleep(0.5)
Even simpler, use the built-in blink method:
from gpiozero import LED
from signal import pause
led = LED(17)
led.blink(on_time=0.5, off_time=0.5) # Runs in a background thread
pause() # Keep the script alive
Read a Button With Interrupts
Wiring: GPIO27 (pin 13) to one side of a momentary switch. Other side to GND. gpiozero enables the internal pull-up when you set pull_up=True.
from gpiozero import Button
from signal import pause
button = Button(27, pull_up=True, bounce_time=0.05) # 50ms software debounce
def pressed():
print("Button pressed")
def released():
print("Button released")
button.when_pressed = pressed # Callback fires on falling edge
button.when_released = released # Callback fires on rising edge
pause()
PWM Brightness Control
from gpiozero import PWMLED
from time import sleep
led = PWMLED(18, frequency=1000) # 1kHz PWM on GPIO18
for duty in range(0, 101, 5):
led.value = duty / 100 # value is 0.0 to 1.0
sleep(0.05)
Servo Control
Standard hobby servos need a 50Hz signal with a 1-2ms pulse. Power the servo from an external 5V supply and share GND with the Pi.
from gpiozero import Servo
from time import sleep
# Pulse widths tuned for common SG90 servos
servo = Servo(12, min_pulse_width=0.5/1000, max_pulse_width=2.4/1000)
servo.min() # Rotate to one extreme
sleep(1)
servo.mid() # Center
sleep(1)
servo.max() # Rotate to the other extreme
sleep(1)
servo.detach() # Stop pulses to prevent jitter
The software PWM used here can jitter under CPU load. For stable servo control, use a hardware PWM channel (see the PWM section below).
Forcing the Pin Factory
Set the pin factory explicitly if you need to debug or test:
export GPIOZERO_PIN_FACTORY=lgpio
python3 blink.py
Or in code:
from gpiozero import Device, LED
from gpiozero.pins.lgpio import LGPIOFactory
Device.pin_factory = LGPIOFactory()
led = LED(17)
Option 2: lgpio (Low-Level Control)
lgpio is the library gpiozero uses underneath. It wraps the Linux gpiochip interface and offers direct, C-style functions. Use it when you need tighter control over timing, bulk reads, or custom protocols.
Basic Output
import lgpio
import time
CHIP = 4 # gpiochip4 is the RP1 header on Pi 5
LED_PIN = 17 # BCM GPIO17
h = lgpio.gpiochip_open(CHIP) # Open the GPIO chip handle
lgpio.gpio_claim_output(h, LED_PIN) # Claim the pin as an output
try:
for _ in range(10):
lgpio.gpio_write(h, LED_PIN, 1) # HIGH
time.sleep(0.5)
lgpio.gpio_write(h, LED_PIN, 0) # LOW
time.sleep(0.5)
finally:
lgpio.gpio_free(h, LED_PIN) # Release the pin
lgpio.gpiochip_close(h) # Close the chip handle
Input With Pull-Up and Edge Alerts
import lgpio
import time
CHIP = 4
BTN_PIN = 27
h = lgpio.gpiochip_open(CHIP)
# Claim as input with internal pull-up, with a 10ms glitch filter
lgpio.gpio_claim_input(h, BTN_PIN, lgpio.SET_PULL_UP)
lgpio.gpio_set_debounce_micros(h, BTN_PIN, 10000)
# Register for falling-edge alerts (button press connects pin to GND)
lgpio.gpio_claim_alert(h, BTN_PIN, lgpio.FALLING_EDGE, lgpio.SET_PULL_UP)
def on_press(chip, gpio, level, timestamp):
print(f"GPIO{gpio} edge at {timestamp} ns, level={level}")
cb = lgpio.callback(h, BTN_PIN, lgpio.FALLING_EDGE, on_press)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
cb.cancel()
lgpio.gpiochip_close(h)
Hardware-Timed PWM With lgpio
import lgpio
import time
CHIP = 4
PIN = 18
h = lgpio.gpiochip_open(CHIP)
lgpio.gpio_claim_output(h, PIN)
# tx_pwm(handle, gpio, frequency_Hz, duty_cycle_percent)
lgpio.tx_pwm(h, PIN, 1000, 25) # 1kHz, 25% duty
time.sleep(2)
lgpio.tx_pwm(h, PIN, 1000, 75) # 1kHz, 75% duty
time.sleep(2)
lgpio.tx_pwm(h, PIN, 0, 0) # Stop PWM
lgpio.gpiochip_close(h)
Note: tx_pwm produces PWM generated by the library’s timing thread, not the RP1 PWM block. Treat it as software-assisted PWM. For jitter-free output, use the kernel PWM overlay below.
Hardware PWM on the Pi 5
For servos, motor controllers, and audio-grade signals, use the kernel PWM driver. Enable it in /boot/firmware/config.txt:
# Enables 2 PWM channels on GPIO12 and GPIO13
dtoverlay=pwm-2chan
Reboot, then control PWM through sysfs:
# Export channel 0 (GPIO12)
echo 0 | sudo tee /sys/class/pwm/pwmchip0/export
# Period in nanoseconds: 20,000,000 ns = 50Hz
echo 20000000 | sudo tee /sys/class/pwm/pwmchip0/pwm0/period
# Duty cycle: 1,500,000 ns = 1.5ms (servo center)
echo 1500000 | sudo tee /sys/class/pwm/pwmchip0/pwm0/duty_cycle
# Start output
echo 1 | sudo tee /sys/class/pwm/pwmchip0/pwm0/enable
The pwmchip index can differ. On some OS versions the Pi 5 PWM controller appears as pwmchip2. Run ls /sys/class/pwm/ and use the chip that exists.
Option 3: rpi-lgpio (Drop-In RPi.GPIO Replacement)
You have a large legacy codebase and cannot rewrite it today. rpi-lgpio provides the RPi.GPIO API on top of lgpio. Remove the old package and install the shim:
sudo apt remove python3-rpi.gpio
sudo apt install python3-rpi-lgpio
Your existing code runs unchanged:
import RPi.GPIO as GPIO
import time
GPIO.setmode(GPIO.BCM)
GPIO.setup(17, GPIO.OUT)
for _ in range(5):
GPIO.output(17, GPIO.HIGH)
time.sleep(0.5)
GPIO.output(17, GPIO.LOW)
time.sleep(0.5)
GPIO.cleanup()
The shim covers most of the API. It does not cover everything. Test edge detection, PWM, and GPIO.wait_for_edge() behaviors before you ship.
Comparing the Approaches
| Criteria | gpiozero | lgpio | rpi-lgpio | libgpiod |
|---|---|---|---|---|
| Code length | Shortest | Medium | Same as legacy | Longest |
| Learning curve | Easy | Moderate | None for old users | Steep |
| Interrupt support | Callbacks | Callbacks | Edge detect | Event loop |
| Timing precision | Good | Better | Good | Best (kernel) |
| Device abstractions | LED, Button, Servo, sensors | None | None | None |
| Migration effort | Rewrite | Rewrite | Near zero | Rewrite |
Migrating an RPi.GPIO Script to gpiozero
Take this typical legacy snippet:
import RPi.GPIO as GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setup(17, GPIO.OUT)
GPIO.setup(27, GPIO.IN, pull_up_down=GPIO.PUD_UP)
try:
while True:
if GPIO.input(27) == GPIO.LOW:
GPIO.output(17, GPIO.HIGH)
else:
GPIO.output(17, GPIO.LOW)
finally:
GPIO.cleanup()
The gpiozero version removes setup and cleanup:
from gpiozero import LED, Button
from signal import pause
led = LED(17)
button = Button(27) # pull_up=True is the default
button.when_pressed = led.on # Press: LED on
button.when_released = led.off # Release: LED off
pause()
Four things changed:
- No
setmode(). gpiozero uses BCM numbering by default. - No
cleanup(). Pins release automatically on exit. Buttoninverts logic so “pressed” isTrue, even with a pull-up.- Callbacks replace polling, which cuts CPU use.
Communication Protocols on the Pi 5
GPIO is only part of the story. Most sensors speak a bus protocol.
I2C
Enable I2C with sudo raspi-config under Interface Options, then scan the bus:
sudo apt install -y i2c-tools
i2cdetect -y 1
Read a BME280 temperature sensor at address 0x76 using smbus2:
from smbus2 import SMBus
I2C_BUS = 1
BME280_ADDR = 0x76
CHIP_ID_REG = 0xD0 # Chip ID register, returns 0x60 for BME280
with SMBus(I2C_BUS) as bus:
chip_id = bus.read_byte_data(BME280_ADDR, CHIP_ID_REG)
print(f"Chip ID: {hex(chip_id)}") # Expect 0x60
SPI
Enable SPI in raspi-config, then use spidev:
import spidev
spi = spidev.SpiDev()
spi.open(0, 0) # Bus 0, chip-select 0
spi.max_speed_hz = 1_000_000 # 1MHz clock
spi.mode = 0 # CPOL=0, CPHA=0
response = spi.xfer2([0x01, 0x80, 0x00]) # Example MCP3008 channel 0 read
value = ((response[1] & 0x03) << 8) | response[2]
print(f"ADC value: {value}")
spi.close()
UART
The primary UART appears at /dev/ttyAMA0 on Pi 5 headers, and the dedicated debug port is a separate 3-pin connector. Open the port with pyserial:
import serial
ser = serial.Serial("/dev/ttyAMA0", baudrate=115200, timeout=1)
ser.write(b"AT\r\n")
print(ser.readline())
ser.close()
Real-World Project: Temperature-Triggered Fan Controller
This build reads a DS18B20 one-wire temperature sensor and switches a fan through an N-channel MOSFET. It shows input, output, and safe load driving together.
Parts List
- Raspberry Pi 5
- DS18B20 waterproof temperature sensor
- 4.7kΩ pull-up resistor
- 2N7000 or IRLZ44N logic-level MOSFET
- 1N4007 flyback diode
- 5V DC fan (under 500mA)
- 10kΩ gate pulldown resistor
Wiring
| Component | Connection |
|---|---|
| DS18B20 VDD (red) | 3.3V (pin 1) |
| DS18B20 GND (black) | GND (pin 6) |
| DS18B20 DATA (yellow) | GPIO4 (pin 7) |
| 4.7kΩ resistor | Between DATA and 3.3V |
| MOSFET gate | GPIO17 (pin 11) |
| 10kΩ resistor | Between gate and GND |
| MOSFET source | GND |
| MOSFET drain | Fan negative terminal |
| Fan positive | 5V (pin 2) |
| 1N4007 diode | Cathode to 5V, anode to MOSFET drain |
Enable the one-wire interface by adding this line to /boot/firmware/config.txt, then reboot:
dtoverlay=w1-gpio,gpiopin=4
Firmware
import glob
import time
from gpiozero import LED
FAN_PIN = 17
ON_TEMP_C = 45.0 # Turn fan on above this temperature
OFF_TEMP_C = 40.0 # Turn fan off below this temperature (hysteresis)
fan = LED(FAN_PIN) # LED class works for any simple digital output
def read_temp_c():
# DS18B20 appears under /sys/bus/w1/devices/28-xxxxxxxxxxxx/
device = glob.glob("/sys/bus/w1/devices/28-*/w1_slave")[0]
with open(device) as f:
lines = f.read().splitlines()
if not lines[0].endswith("YES"): # CRC check failed
raise RuntimeError("Sensor CRC error")
raw = lines[1].split("t=")[1]
return int(raw) / 1000.0 # Millidegrees to degrees C
while True:
try:
temp = read_temp_c()
print(f"Temperature: {temp:.1f} C")
if temp >= ON_TEMP_C:
fan.on()
elif temp <= OFF_TEMP_C:
fan.off() # Hysteresis prevents rapid cycling
except (IndexError, RuntimeError) as e:
print(f"Sensor error: {e}")
fan.on() # Fail safe: run the fan
time.sleep(2)
The 5°C hysteresis band stops the fan from chattering on and off near the threshold.
Troubleshooting Common GPIO Errors on Pi 5
| Symptom | Likely Cause | Fix |
|---|---|---|
Cannot determine SOC peripheral base address |
Using legacy RPi.GPIO | Install rpi-lgpio or migrate to gpiozero |
lgpio.error: 'GPIO busy' |
Another process owns the pin | Stop the other script, or run gpioinfo to find the owner |
PermissionError on /dev/gpiochip* |
User not in gpio group |
sudo usermod -aG gpio $USER, then log out and in |
pigpio daemon fails to start |
pigpio unsupported on RP1 | Switch to lgpio or kernel PWM |
| LED stays dim or off | Wrong pin numbering | Use BCM numbers, not physical pin numbers |
| Button reads random values | Floating input | Enable the internal pull-up or add a 10kΩ resistor |
| Servo jitters | Software PWM under load | Use hardware PWM via dtoverlay=pwm-2chan |
externally-managed-environment |
Bookworm pip restriction | Use apt packages or a venv with --system-site-packages |
Inspect Pin State From the Shell
gpioinfo gpiochip4 | head -30 # List line names, directions, and consumers
pinctrl get 17 # Show the function and level of GPIO17
pinctrl set 17 op dh # Set GPIO17 as output, drive high
pinctrl ships with Raspberry Pi OS and works on RP1. It is the fastest way to check whether the problem is hardware or software.
Best Practices for Reliable GPIO Code
- Protect the pins. Add a 330Ω to 1kΩ series resistor on inputs exposed to the outside world.
- Level shift 5V signals. Use a BSS138 bidirectional level shifter for I2C at 5V, or a 74LVC245 for parallel buses.
- Isolate inductive loads. Use optocouplers such as the PC817 with relays or motors, and add flyback diodes.
- Debounce in software. Use
bounce_timein gpiozero orgpio_set_debounce_microsin lgpio. - Prefer callbacks over polling. Interrupt-style code frees the CPU and reduces latency.
- Clean up on exit. Wrap hardware code in
try/finallyso pins return to a safe state.
FAQ
Does RPi.GPIO work on the Raspberry Pi 5?
No. The original RPi.GPIO package fails on Pi 5 because GPIO moved to the RP1 chip. Install rpi-lgpio for a drop-in replacement of the same API, or migrate to gpiozero for a modern, maintained interface.
Which Python GPIO library is best for the Raspberry Pi 5?
gpiozero is the best choice for most users. It runs on lgpio under the hood and has simple classes for LEDs, buttons, servos, and sensors. Choose lgpio directly when you need low-level control, or libgpiod for kernel-native portability.
Does pigpio work on the Raspberry Pi 5?
No. pigpio relies on the old BCM283x DMA and register layout, which the Pi 5 does not expose. Replace it with lgpio for GPIO and alerts, or kernel PWM (dtoverlay=pwm-2chan) for hardware PWM output.
Why do I get a “GPIO busy” error with lgpio or gpiozero?
Another process or an earlier script still holds the pin. Run gpioinfo to see which line has a consumer, stop that process, and rerun your code. In gpiozero, call device.close() or let the script exit cleanly to release pins.




