I/O port programming: talking to hardware from Linux userspace
The old way to drive hardware from a Linux program was to grab x86 I/O ports with ioperm() and poke registers with outb(). That still works on a PC, but most hardware you wire up today hangs off GPIO, I2C or SPI, and Linux has a clean device interface for each. This page shows which one to use and how, with short C examples.
The classic I/O port programming mini-HOWTO (2000) is kept by the Linux Documentation Project at tldp.org.
Pick the right interface
| Hardware | Interface | Tools |
|---|---|---|
| Legacy x86 I/O ports (ISA-style cards, parallel port, superIO) | ioperm()/iopl(), /dev/port | Your own C code |
| Parallel port | /dev/parport0 (ppdev driver) | ppdev ioctls |
| GPIO pins (single-board computers, USB GPIO chips) | /dev/gpiochipN character device | libgpiod: gpioinfo, gpioget, gpioset, gpiomon |
| I2C/SMBus chips (sensors, EEPROMs) | /dev/i2c-N (i2c-dev) | i2c-tools: i2cdetect, i2cget, i2cset |
| SPI chips (ADCs, displays) | /dev/spidevB.C (spidev) | ioctls from linux/spi/spidev.h |
| Memory-mapped device with interrupts (FPGA, custom PCI card) | /dev/uioN (UIO), or VFIO for PCI with an IOMMU | mmap() and read() |
Check for a kernel driver first. If the chip is a common sensor, LED controller, RTC or ADC, the kernel probably has a driver, and you get clean files under /sys/class/hwmon/, /sys/class/leds/ or the IIO subsystem. The kernel's GPIO documentation says the same: avoid bit-banging from userspace where a driver exists.
x86 I/O ports
On x86, ioperm(from, num, turn_on) grants the calling thread access to a range of ports (any of the 65,536 since Linux 2.6.8), and iopl(3) grants all of them. Both need the CAP_SYS_RAWIO capability, in practice root. The access functions come from <sys/io.h>; note the argument order, value first and port second, the reverse of DOS compilers.
/* lpt.c - write the parallel port data register, read status (x86 only) */
#include <stdio.h>
#include <stdlib.h>
#include <sys/io.h>
#define BASE 0x378 /* first parallel port on most PCs; check /proc/ioports */
int main(void)
{
if (ioperm(BASE, 3, 1) != 0) {
perror("ioperm");
return EXIT_FAILURE;
}
outb(0xAA, BASE); /* value, then port */
printf("status: 0x%02x\n", inb(BASE + 1));
ioperm(BASE, 3, 0);
return EXIT_SUCCESS;
}
gcc -O2 -Wall -o lpt lpt.c
grep -i parport /proc/ioports # who owns the range
sudo ./lpt
Compile with optimization (-O or -O2): inb/outb are inline functions, and without optimization the link fails with unresolved symbols. Permissions are inherited across fork() and kept across execve(), so drop them before starting other programs.
/dev/port (character device 1,4) offers the same access through file I/O: lseek() to the port number, then read() or write() one byte. From the shell:
sudo dd if=/dev/port bs=1 skip=$((0x379)) count=1 status=none | od -An -tx1
Kernel lockdown blocks both. When the kernel runs in lockdown mode (set with the lockdown= boot parameter or by your distribution's kernel configuration), ioperm() and iopl() fail with Operation not permitted and raw port and memory devices are refused. Check with cat /sys/kernel/security/lockdown. A wrong write to the wrong port can also hang the machine or corrupt a disk controller. If a kernel driver (such as parport_pc) owns the range, do not write behind its back: use its interface (here /dev/parport0) or unload it first.
GPIO with libgpiod
GPIO lines are exposed as character devices /dev/gpiochip0, /dev/gpiochip1 and so on (API version 2 since Linux 5.10). The old /sys/class/gpio/export interface is marked obsolete in the kernel documentation and "will be removed in the future"; do not start new code on it. libgpiod is the userspace library and tool set:
sudo apt install gpiod # Debian, Ubuntu; also libgpiod-dev for C
gpiodetect # list chips
gpioinfo # every line: offset, name, in use by whom
gpioget -c gpiochip0 17 # read line 17
gpioset -c gpiochip0 17=1 # drive line 17 high; holds until Ctrl-C
gpiomon -c gpiochip0 -e rising -n 1 27 # wait for one rising edge on line 27
Lines can also be named, for example gpioget GPIO17 on boards whose device tree names the lines. Two things trip people up:
- Tool versions differ. The commands above are libgpiod 2 syntax (Debian 13 ships 2.2.1, as of November 2026). Ubuntu 26.04 also ships 2.2.1, but Ubuntu 24.04 ships 1.6.3, whose tools take the chip as the first argument and exit immediately by default:
gpioget gpiochip0 17,gpioset --mode=signal gpiochip0 17=1. Check withgpioset --version. - A line keeps its value only while it is requested. In libgpiod 2,
gpiosetstays running on purpose; after the process exits, the state is no longer guaranteed. A program should keep its line request open for as long as it needs the output.
Device permissions vary by distribution; ls -l /dev/gpiochip* shows them. To let a group use the pins without root, add a rule such as SUBSYSTEM=="gpio", KERNEL=="gpiochip*", GROUP="gpio", MODE="0660" in /etc/udev/rules.d/ (create the group first with sudo groupadd --system gpio); the Plug-and-Play HOWTO explains udev rules.
I2C and SMBus
sudo apt install i2c-tools libi2c-dev
sudo modprobe i2c-dev # creates /dev/i2c-N
i2cdetect -l # list buses
sudo i2cdetect -y 1 # scan bus 1
sudo i2cget -y 1 0x48 0x00 w # read a 16-bit word from register 0 of chip 0x48
In the scan, -- means no answer, a hex number means a chip answered, and UU means a kernel driver already uses that address (so leave it alone). The i2c-tools manuals warn that scanning and reading can confuse some chips; avoid the -q and -r probe modes, which are known to corrupt certain EEPROMs or lock up write-only chips. On a PC's own SMBus (memory modules, sensors, clock chips), scan and read only when you know what is on it.
i2cset can damage hardware. Writing to the wrong chip or register can confuse the bus, lose data or worse. The i2cset manual warns that writing to the configuration EEPROM of a memory module (addresses 0x50 to 0x57 on a PC's SMBus) can destroy it and leave the system unbootable. Only write to a chip you have identified from its datasheet, on a bus you wired yourself, and never to an address marked UU. The -y option skips the confirmation prompt, so leave it out of i2cset commands while you experiment, and never use -f, which forces access to a chip a kernel driver owns.
The same read from C:
/* i2cread.c - read register 0 of the chip at 0x48 on bus 1 */
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>
#include <i2c/smbus.h>
int main(void)
{
int fd = open("/dev/i2c-1", O_RDWR);
if (fd < 0) { perror("open"); return 1; }
if (ioctl(fd, I2C_SLAVE, 0x48) < 0) { perror("I2C_SLAVE"); return 1; }
int v = i2c_smbus_read_word_data(fd, 0x00);
if (v < 0) { perror("read"); return 1; }
printf("register 0: 0x%04x\n", v); /* byte order depends on the chip */
close(fd);
return 0;
}
gcc -O2 -Wall -o i2cread i2cread.c -li2c
sudo ./i2cread
SPI with spidev
The spidev driver gives /dev/spidevB.C for chip select C on bus B. It binds only to devices described in the device tree or ACPI with a real chip's compatible string; plain "spidev" is no longer accepted. For a device without a match, write spidev to the device's driver_override file in sysfs and bind it. read() and write() are half-duplex; for a full-duplex transfer, use SPI_IOC_MESSAGE:
/* spi.c - one full-duplex 3-byte transfer on /dev/spidev0.0 */
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/spi/spidev.h>
int main(void)
{
uint8_t tx[3] = { 0x01, 0x80, 0x00 }, rx[3] = { 0 };
uint8_t mode = SPI_MODE_0;
int fd = open("/dev/spidev0.0", O_RDWR);
if (fd < 0) { perror("open"); return 1; }
if (ioctl(fd, SPI_IOC_WR_MODE, &mode) < 0) { perror("mode"); return 1; }
struct spi_ioc_transfer tr = {
.tx_buf = (unsigned long)tx,
.rx_buf = (unsigned long)rx,
.len = sizeof tx,
.speed_hz = 1000000,
.bits_per_word = 8,
};
if (ioctl(fd, SPI_IOC_MESSAGE(1), &tr) < 0) { perror("transfer"); return 1; }
printf("%02x %02x %02x\n", rx[0], rx[1], rx[2]);
close(fd);
return 0;
}
Chip select stays active for all transfers in one SPI_IOC_MESSAGE call. The controller may not hit the exact clock rate you ask for, and each transfer is limited to one page by default (a spidev module parameter changes this).
UIO for memory-mapped devices
UIO splits a driver into a tiny kernel module, which handles the interrupt, and a userspace program that does everything else. For a platform device with its own interrupt line, the generic uio_pdrv_genirq module needs no kernel code at all; uio_pci_generic does the same for PCI 2.3 and PCIe devices. Your program maps the registers and waits for interrupts:
cat /sys/class/uio/uio0/name
cat /sys/class/uio/uio0/maps/map0/size
/* fragment: map region 0 of /dev/uio0 and wait for interrupts */
int fd = open("/dev/uio0", O_RDWR);
volatile uint32_t *regs = mmap(NULL, map_size, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0 * getpagesize());
uint32_t on = 1, count;
for (;;) {
write(fd, &on, sizeof on); /* re-enable the IRQ (uio_pdrv_genirq) */
read(fd, &count, sizeof count); /* blocks; returns the total IRQ count */
/* handle the device through regs[...] */
}
Region N is mapped at offset N times the page size. A read must be exactly 4 bytes; if the count jumps by more than one, you missed interrupts. For PCI devices on a machine with an IOMMU, VFIO is the better choice: the kernel describes it as giving device access "in a secure, IOMMU protected environment", which UIO cannot do.
Timing
The old trick of writing to port 0x80 for a short delay belongs to the ISA era. Today use clock_nanosleep() for waits and clock_gettime(CLOCK_MONOTONIC) to measure time, and run time-critical programs with a real-time priority (sudo chrt -f 50 ./prog). Even so, a general-purpose kernel does not guarantee microsecond timing. If a protocol needs exact timing, let hardware do it: an SPI or I2C controller, a PWM driver, or a small microcontroller connected over USB serial.
Sources
- ioperm(2), man7.org
- outb(2) and inb, man7.org
- mem(4): /dev/mem and /dev/port, man7.org
- kernel_lockdown(7), man7.org
- arch/x86/kernel/ioport.c (ioperm, iopl, lockdown check), Linux kernel
- GPIO character device userspace API, Linux kernel documentation
- GPIO sysfs interface (obsolete), Linux kernel documentation
- gpioset(1), Debian 13 (libgpiod 2.2.1)
- gpioget(1), Debian 13
- gpiomon(1), Debian 13
- gpioset(1), Ubuntu 24.04 (libgpiod 1.6.3)
- gpiod package in Ubuntu 24.04
- Implementing I2C device drivers in userspace, Linux kernel documentation
- i2cdetect(8), Debian 13
- i2cget(8) and i2cset(8), Debian 13
- libgpiod in Ubuntu (24.04: 1.6.3, 26.04: 2.2.1), Launchpad
- SPI userspace API (spidev), Linux kernel documentation
- The Userspace I/O HOWTO, Linux kernel documentation
- VFIO, Linux kernel documentation
- Linux I/O port programming mini-HOWTO (classic), Linux Documentation Project