48. Talking to hardware from user space: GPIO, I2C, SPI, and serial
Linux exposes most hardware buses as ordinary files. This is why a huge
amount of "driver" work is really just Part II's file I/O (Section 12)
plus ioctl(), a system call that's read/write's escape hatch for
"do something device-specific that doesn't fit read/write."
48.1 GPIO: turning a pin into a /dev handle
Modern Linux (kernel 4.8+) exposes GPIO through a character device per
chip (/dev/gpiochip0) rather than the older, now-deprecated
/sys/class/gpio sysfs interface. The standard way to drive it from C
is libgpiod, which wraps the ioctl protocol for you:
// requires: apt install libgpiod-dev ; link with -lgpiod
#include <gpiod.h>
#include <stdio.h>
int main(void) {
struct gpiod_chip *chip = gpiod_chip_open("/dev/gpiochip0");
if (!chip) { perror("gpiod_chip_open"); return 1; }
struct gpiod_line *led = gpiod_chip_get_line(chip, 17); // BCM pin 17
if (!led) { perror("gpiod_chip_get_line"); gpiod_chip_close(chip); return 1; }
if (gpiod_line_request_output(led, "blink-example", 0) < 0) {
perror("gpiod_line_request_output");
gpiod_chip_close(chip);
return 1;
}
gpiod_line_set_value(led, 1); // turn the LED on
gpiod_line_release(led);
gpiod_chip_close(chip);
return 0;
}
The 0/1 you pass to gpiod_line_set_value ends up, several layers down,
as exactly the kind of register write Section 49 shows you doing by
hand — libgpiod exists so that in the 90% case (Section 47's table)
you never have to compute a register offset yourself.
48.2 I2C: addressed devices on a shared two-wire bus
I2C devices share one bus, each answering to its own 7-bit address. From
user space, you open the bus, tell the kernel which address you want to
talk to, then read/write like a normal file:
#include <stdio.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <linux/i2c-dev.h> // Linux-only header — see this Part's opening note
#include <sys/ioctl.h>
#define LM75_ADDR 0x48 // a common temperature-sensor address
int read_temperature(const char *bus_path) {
int fd = open(bus_path, O_RDWR);
if (fd < 0) { perror("open"); return -1; }
if (ioctl(fd, I2C_SLAVE, LM75_ADDR) < 0) { // "all reads/writes on this fd target 0x48"
perror("ioctl(I2C_SLAVE)");
close(fd);
return -1;
}
uint8_t reg = 0x00; // the LM75's temperature register
if (write(fd, ®, 1) != 1) { perror("write"); close(fd); return -1; }
uint8_t raw[2];
if (read(fd, raw, 2) != 2) { perror("read"); close(fd); return -1; }
close(fd);
int16_t temp_raw = (int16_t)((raw[0] << 8) | raw[1]) >> 7; // datasheet-specific bit layout
return temp_raw / 2; // degrees Celsius, per this sensor's datasheet
}
int main(void) {
int c = read_temperature("/dev/i2c-1");
if (c != -1) printf("%d C\n", c);
return 0;
}
Many simple Linux userspace I2C tools follow this shape: open the bus
file, ioctl(I2C_SLAVE, address) to select the chip, then write() the
register you want followed by read() of however many bytes the
datasheet says that register holds. Kernel drivers and SMBus helpers wrap
the same idea in different APIs. The bit-shifting to turn two raw bytes
into a signed temperature (temp_raw >> 7, / 2) is datasheet-specific
— Section 8.2's union-based byte inspection and Section 34's sds-style
"read the bytes, interpret per the format" mindset are exactly the skills
this line exercises.
48.3 SPI: full-duplex, chip-select, and one ioctl per transfer
SPI has no addressing — instead, a separate chip-select line picks which device is listening, and every transfer is full-duplex (you always both send and receive the same number of bytes, even if you only care about one direction):
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <linux/spi/spidev.h> // Linux-only header
#include <sys/ioctl.h>
int spi_transfer(int fd, const uint8_t *tx, uint8_t *rx, size_t len) {
struct spi_ioc_transfer xfer = {
.tx_buf = (unsigned long)tx,
.rx_buf = (unsigned long)rx,
.len = (uint32_t)len,
.speed_hz = 1000000, // 1 MHz — must not exceed the chip's datasheet max
.bits_per_word = 8,
};
return ioctl(fd, SPI_IOC_MESSAGE(1), &xfer); // one call = one full-duplex transfer
}
int main(void) {
int fd = open("/dev/spidev0.0", O_RDWR);
if (fd < 0) { perror("open"); return 1; }
uint8_t tx[2] = {0x9F, 0x00}; // e.g. a flash chip's "read JEDEC ID" command
uint8_t rx[2] = {0};
if (spi_transfer(fd, tx, rx, sizeof tx) < 0) { perror("ioctl"); close(fd); return 1; }
printf("received: %02x %02x\n", rx[0], rx[1]);
close(fd);
return 0;
}
48.4 Serial/UART: extending Section 25's termios beyond a terminal
Section 25 used termios to turn your terminal into raw, byte-at-a-time
mode. The exact same API configures a real serial port talking to
external hardware (a GPS module, an Arduino, an RS-232 sensor) — the
only difference is you now also set a baud rate and enable the receiver:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <termios.h>
int open_serial_port(const char *path) {
int fd = open(path, O_RDWR | O_NOCTTY | O_SYNC);
if (fd < 0) { perror("open"); return -1; }
struct termios tty;
if (tcgetattr(fd, &tty) != 0) { perror("tcgetattr"); close(fd); return -1; }
cfsetispeed(&tty, B9600);
cfsetospeed(&tty, B9600);
tty.c_cflag |= (CLOCAL | CREAD); // ignore modem control lines, enable the receiver —
tty.c_cflag &= ~PARENB; // without CREAD the port simply never delivers bytes
tty.c_cflag &= ~CSTOPB;
tty.c_cflag &= ~CSIZE;
tty.c_cflag |= CS8; // 8N1: 8 data bits, no parity, 1 stop bit
tty.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); // raw mode, same as Section 25
tty.c_iflag &= ~(IXON | IXOFF | IXANY);
tty.c_oflag &= ~OPOST;
tty.c_cc[VMIN] = 1; // block until at least 1 byte arrives
tty.c_cc[VTIME] = 0;
if (tcsetattr(fd, TCSANOW, &tty) != 0) { perror("tcsetattr"); close(fd); return -1; }
return fd;
}
War story. Forgetting CREAD (or leaving CLOCAL unset on a port
whose carrier-detect line isn't wired up) is the classic "my serial
code compiles fine, connects fine, and then just never receives a
byte" bug — nothing errors, read() just blocks forever, because the
port's receiver was never actually enabled at the hardware level. It's
the serial-programming equivalent of Section 46.4's silent
compile-but-wrong bugs: syntactically correct, semantically incomplete.