Frontend Labs
⋯

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, &reg, 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.


Generated from real-life-c-guide.md · 8 Oct 2026 16:36