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;
}
gpioinfo 命令查看当前引脚状态。我曾经在调试时,死活拉不低一个引脚,查了半天才发现是内核里的一个音频驱动把它占用了。
4.2 UART:最朴素的“对话”
UART,串口。这玩意儿太经典了。从调试内核到连接GPS模块、蓝牙模块,都离不开它。Linux下操作UART,就是操作一个tty设备,比如 /dev/ttyS0 或 /dev/ttyAMA0。
配置UART,核心是设置波特率、数据位、停止位、校验位。这些参数通过 termios 结构体来配置。我刚开始做的时候,经常忘记设置 CLOCAL 和 CREAD 这两个标志,结果数据收不到,还以为是硬件坏了。
给你一个标准配置流程:
- 打开设备文件:
open("/dev/ttyS0", O_RDWR | O_NOCTTY) - 获取当前属性:
tcgetattr(fd, &options) - 设置波特率:
cfsetispeed(&options, B115200) - 设置数据格式:8N1(8数据位,无校验,1停止位)
- 使能接收:设置
CLOCAL | CREAD - 写入并生效:
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, ®, 1);
// 读一个字节
unsigned char value;
read(fd, &value, 1);
printf("Register 0x00 value: 0x%02X\n", value);
close(fd);
return 0;
}
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_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用户空间驱动开发,说白了就是学会操作对应的设备文件。你只要掌握了 open、ioctl、read、write 这四个系统调用,再配合对应的配置结构体,就能搞定大部分硬件通信。下一章,我们会把这些接口组合起来,做一个真正的物联网网关数据采集程序。