4、硬件接口编程:GPIO、UART、I2C、SPI的Linux用户空间驱动开发

好,咱们进入正题。这一章聊的是嵌入式Linux开发里最基础、也最绕不开的话题——硬件接口编程。GPIO、UART、I2C、SPI,这四个接口,说白了就是嵌入式系统和外界打交道的“四肢”和“五官”。你想想看,一个物联网网关,如果连个灯都点不亮、连个传感器都读不到数据,那还叫网关吗?

我个人习惯,在开始写任何驱动代码之前,先搞清楚一件事:这个接口在内核里是怎么暴露给用户空间的? 因为Linux的设计哲学是“一切皆文件”,硬件也不例外。你操作一个GPIO,可能就是在读写一个文件;你收发UART数据,也是在读写一个文件。理解了这一点,后面的路就好走了。

4.1 GPIO:从点灯到中断

GPIO,通用输入输出。最简单的应用就是点灯。但你别小看它,我在项目中遇到过因为GPIO驱动电流不够,导致继电器无法吸合的坑。嗯,这里要注意,GPIO的驱动能力通常只有几毫安,驱动大负载必须加三极管或光耦。

在Linux用户空间操作GPIO,主要有两种方式:

  • sysfs接口(老方法,但兼容性好)
  • chardev接口(新方法,性能更好,支持中断)

我个人建议,新项目直接用chardev接口。为什么?因为sysfs操作一个引脚需要echo、cat好几个文件,效率低,而且不支持边沿触发中断。chardev通过ioctl一次性搞定配置和读写,干净利落。

来看一个简单的GPIO输出示例,用chardev接口:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/gpio.h>

int main() {
    int fd = open("/dev/gpiochip0", O_RDONLY);
    if (fd < 0) {
        perror("open gpiochip0 failed");
        return -1;
    }

    struct gpiohandle_request req;
    req.flags = GPIOHANDLE_REQUEST_OUTPUT;
    req.lines = 1;
    req.lineoffsets[0] = 17;  // GPIO17
    req.default_values[0] = 1; // 初始高电平

    int ret = ioctl(fd, GPIO_GET_LINEHANDLE_IOCTL, &req);
    if (ret < 0) {
        perror("ioctl failed");
        close(fd);
        return -1;
    }

    struct gpiohandle_data data;
    data.values[0] = 0; // 输出低电平
    ret = ioctl(req.fd, GPIOHANDLE_SET_LINE_VALUES_IOCTL, &data);
    if (ret < 0) {
        perror("set value failed");
    }

    close(req.fd);
    close(fd);
    return 0;
}
小提示: 操作GPIO前,一定要确认引脚没有被其他驱动占用。可以用 gpioinfo 命令查看当前引脚状态。我曾经在调试时,死活拉不低一个引脚,查了半天才发现是内核里的一个音频驱动把它占用了。

4.2 UART:最朴素的“对话”

UART,串口。这玩意儿太经典了。从调试内核到连接GPS模块、蓝牙模块,都离不开它。Linux下操作UART,就是操作一个tty设备,比如 /dev/ttyS0/dev/ttyAMA0

配置UART,核心是设置波特率、数据位、停止位、校验位。这些参数通过 termios 结构体来配置。我刚开始做的时候,经常忘记设置 CLOCALCREAD 这两个标志,结果数据收不到,还以为是硬件坏了。

给你一个标准配置流程:

  1. 打开设备文件:open("/dev/ttyS0", O_RDWR | O_NOCTTY)
  2. 获取当前属性:tcgetattr(fd, &options)
  3. 设置波特率:cfsetispeed(&options, B115200)
  4. 设置数据格式:8N1(8数据位,无校验,1停止位)
  5. 使能接收:设置 CLOCAL | CREAD
  6. 写入并生效:tcsetattr(fd, TCSANOW, &options)
注意: 串口编程中,read() 默认是阻塞的。如果你希望非阻塞读取,可以在 open() 时加上 O_NONBLOCK 标志。但要注意,非阻塞模式下,如果没有数据,read() 会立即返回 -1,并设置 errno 为 EAGAIN。你需要轮询或者用 select/poll 来处理。

4.3 I2C:多设备共享两根线

I2C,两根线(SCL、SDA)挂一堆设备。温度传感器、EEPROM、RTC时钟,很多都是I2C接口。Linux用户空间操作I2C,通常通过 /dev/i2c-N 设备节点。

操作I2C设备,核心步骤就三步:

  • 打开I2C总线设备
  • 设置从设备地址
  • 进行读写操作

这里有个坑,我必须要说。I2C地址有7位和10位之分,大部分传感器是7位地址。但有些芯片的数据手册上给的地址是8位的(左移了一位),你直接拿去用,肯定通信失败。我曾经被一个温湿度传感器坑了一下午,后来才发现手册上的地址是0xDC,实际7位地址是0x6E。

来看一个读取I2C设备寄存器的示例:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>

int main() {
    int fd = open("/dev/i2c-1", O_RDWR);
    if (fd < 0) {
        perror("open i2c-1 failed");
        return -1;
    }

    int addr = 0x6E; // 7位地址
    if (ioctl(fd, I2C_SLAVE, addr) < 0) {
        perror("set slave address failed");
        close(fd);
        return -1;
    }

    // 写寄存器地址
    unsigned char reg = 0x00;
    write(fd, &reg, 1);

    // 读一个字节
    unsigned char value;
    read(fd, &value, 1);

    printf("Register 0x00 value: 0x%02X\n", value);
    close(fd);
    return 0;
}
核心要点: I2C读写操作是“组合”的。先发设备地址+写位,再发寄存器地址,然后发设备地址+读位,最后读取数据。Linux的i2c-dev驱动帮你封装好了这些细节,你只需要用 write()read() 就行。但要注意,有些设备需要“重复起始条件”,这时候就要用 I2C_RDWR ioctl 来发送组合消息。

4.4 SPI:高速全双工

SPI,四根线(MOSI、MISO、SCLK、CS),全双工,速度比I2C快得多。适合高速ADC、DAC、显示驱动、RF模块等。Linux用户空间操作SPI,通过 /dev/spidevX.Y 设备节点。

SPI编程的核心是配置模式和速率。模式由时钟极性(CPOL)和时钟相位(CPHA)决定,一共四种组合。速率取决于你的主控和从设备,一般几MHz到几十MHz。

我建议,配置SPI时,先用示波器看看时钟和数据线的波形。为什么?因为有些从设备对时序要求很苛刻,比如某些LCD驱动,CPOL和CPHA搞反了,屏幕就是花屏。我在一个项目里就遇到过,折腾了两天才发现是模式配错了。

SPI的读写是同步的,你发一个字节,同时收到一个字节。所以读写操作通常用同一个函数:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/spi/spidev.h>

int main() {
    int fd = open("/dev/spidev0.0", O_RDWR);
    if (fd < 0) {
        perror("open spidev0.0 failed");
        return -1;
    }

    // 配置模式:模式0 (CPOL=0, CPHA=0)
    unsigned char mode = SPI_MODE_0;
    ioctl(fd, SPI_IOC_WR_MODE, &mode);

    // 配置速率:1MHz
    unsigned int speed = 1000000;
    ioctl(fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed);

    // 准备发送和接收缓冲区
    unsigned char tx[] = {0x01, 0x02, 0x03};
    unsigned char rx[3] = {0};

    struct spi_ioc_transfer tr = {
        .tx_buf = (unsigned long)tx,
        .rx_buf = (unsigned long)rx,
        .len = 3,
        .speed_hz = speed,
        .delay_usecs = 0,
        .bits_per_word = 8,
    };

    int ret = ioctl(fd, SPI_IOC_MESSAGE(1), &tr);
    if (ret < 0) {
        perror("SPI transfer failed");
    } else {
        printf("Received: 0x%02X 0x%02X 0x%02X\n", rx[0], rx[1], rx[2]);
    }

    close(fd);
    return 0;
}
经验之谈: SPI的片选信号(CS)通常由内核自动控制,但有些场景下你需要手动控制CS。比如,你要连续发送多个SPI消息,但不想让CS在消息之间拉高。这时候可以用 SPI_CS_HIGH 标志或者通过GPIO手动控制CS。我个人习惯,对于复杂的时序要求,直接GPIO模拟CS,更灵活。

4.5 四种接口对比与选型

好了,四种接口都讲完了。最后给你一张对比表,方便你选型时参考:

接口 线数 速度 通信方式 典型应用
GPIO 1根/引脚 低(软件控制) 并行/位控制 按键、LED、中断信号
UART 2根(TX/RX) 中(最高几Mbps) 异步串行 调试口、GPS、蓝牙
I2C 2根(SCL/SDA) 中(最高3.4Mbps) 同步串行,多设备 传感器、EEPROM、RTC
SPI 4根(MOSI/MISO/SCLK/CS) 高(几十Mbps) 同步串行,全双工 ADC、LCD、RF模块

选型时,我的建议很简单:

  • 控制简单状态(灯、按键)→ GPIO
  • 低速、长距离、设备多 → UART 或 I2C
  • 高速、大数据量 → SPI

嗯,这一章的内容就到这里。四种接口的Linux用户空间驱动开发,说白了就是学会操作对应的设备文件。你只要掌握了 openioctlreadwrite 这四个系统调用,再配合对应的配置结构体,就能搞定大部分硬件通信。下一章,我们会把这些接口组合起来,做一个真正的物联网网关数据采集程序。