4、Linux系统编程入门:文件I/O、进程与线程、进程间通信(管道、消息队列、共享内存)、信号处理
嵌入式Linux开发与裸机开发最大的区别在于,应用层代码运行在操作系统之上,必须借助系统调用(System Call)与内核交互。本章聚焦于物联网设备中最常用的系统编程基础:文件I/O、进程与线程模型、进程间通信(IPC)以及信号处理。这些知识点是构建稳定、高效、可维护的嵌入式应用(如数据采集网关、协议转换器、远程控制终端)的基石。理解文件描述符的本质、进程与线程的调度差异、IPC机制的性能取舍以及信号的异步处理机制,将直接决定你在实际项目中能否写出不丢数据、不卡死、响应及时的代码。
上图展示了本章四大知识模块的关联关系:文件I/O是访问硬件设备(串口、GPIO、传感器)的基础通道;进程与线程是程序运行的载体;IPC解决多任务间的数据交换问题;信号则提供了一种异步事件通知机制。在嵌入式场景中,这四个模块往往需要组合使用,例如一个数据采集进程通过文件I/O读取传感器,通过共享内存将数据传递给网络发送线程,并用信号量进行同步,同时用定时器信号(SIGALRM)触发周期采样。
4.1 文件I/O:一切皆文件的基石
核心原理
Linux遵循“一切皆文件”的设计哲学。无论是普通文件、设备节点(/dev/ttyS0)、管道还是套接字,都通过文件描述符(File Descriptor, fd)来访问。fd是一个非负整数,本质上是进程文件描述符表(fd_table)的索引。系统调用open()返回最小的可用fd,read()/write()通过fd定位到内核中的文件对象(struct file),该对象维护了当前读写偏移量、状态标志等。嵌入式开发中,操作GPIO(/sys/class/gpio)、I2C设备(/dev/i2c-1)、SPI设备(/dev/spidev0.0)本质上都是文件I/O操作。
操作流程与判断方法
- 打开文件:int fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY | O_NONBLOCK); 注意O_NONBLOCK用于非阻塞模式,避免read()卡死。
- 读写操作:ssize_t n = read(fd, buf, len); 返回值处理:n>0表示实际读取字节数;n==0表示EOF;n==-1且errno==EAGAIN表示非阻塞下无数据。
- 定位偏移:off_t lseek(fd, offset, SEEK_SET); 对于设备文件通常无效,但对于普通文件(如配置文件)有用。
- 关闭文件:close(fd); 注意检查返回值,忽略EINTR错误。
实战场景:串口读取GPS模块数据
在物联网定位终端中,GPS模块通过UART输出NMEA语句。初始化串口时需设置波特率115200、8N1格式。读取时采用非阻塞模式,配合select()或poll()实现超时机制,避免线程阻塞。以下为关键代码片段:
int fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY | O_NONBLOCK);
struct termios options;
tcgetattr(fd, &options);
cfsetispeed(&options, B115200);
cfsetospeed(&options, B115200);
options.c_cflag |= (CLOCAL | CREAD);
options.c_cflag &= ~PARENB; // 无校验
options.c_cflag &= ~CSTOPB; // 1位停止位
options.c_cflag &= ~CSIZE;
options.c_cflag |= CS8; // 8位数据
tcsetattr(fd, TCSANOW, &options);
char buf[256];
fd_set read_fds;
struct timeval timeout = {1, 0}; // 1秒超时
FD_ZERO(&read_fds);
FD_SET(fd, &read_fds);
int ret = select(fd+1, &read_fds, NULL, NULL, &timeout);
if (ret > 0 && FD_ISSET(fd, &read_fds)) {
int n = read(fd, buf, sizeof(buf)-1);
if (n > 0) { buf[n] = '\0'; process_gps_data(buf); }
}
常见误区与复盘
- 误区1:忽略open()返回的错误。嵌入式设备节点可能不存在或权限不足,必须检查返回值并打印errno。
- 误区2:read()返回值未处理完全。TCP/串口可能出现半包,需要循环读取直到满足长度或超时。
- 误区3:忘记设置O_CLOEXEC。在多线程+exec场景下,fd会泄漏到子进程,导致资源占用。
4.2 进程与线程:并发执行的选择
核心原理
进程是资源分配的最小单位,拥有独立的地址空间、文件描述符表、信号处理函数。线程是CPU调度的最小单位,同一进程内的线程共享地址空间、全局变量、文件描述符。在嵌入式Linux中,fork()创建子进程时采用写时复制(COW)技术,效率较高,但进程间切换开销大(需要切换页表、刷新TLB)。线程切换开销小,但同步复杂。选择原则:需要强隔离(如某个模块崩溃不影响主程序)用进程;需要高并发I/O且共享数据频繁用线程。
| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享 |
| 资源开销 | 较大(页表、PCB) | 较小(仅栈和寄存器) |
| 同步方式 | IPC(管道、共享内存等) | 互斥锁、条件变量 |
| 崩溃影响 | 互不影响 | 一个线程崩溃可能导致整个进程退出 |
| 适用场景 | 运行独立程序(如看门狗) | 数据采集+处理+发送流水线 |
操作流程与判断方法
- 创建进程:pid_t pid = fork(); 父进程返回子进程PID,子进程返回0,失败返回-1。子进程从fork()下一行开始执行。
- 创建线程:pthread_create(&tid, NULL, thread_func, arg); 线程函数返回void*,参数为void*。
- 同步机制:pthread_mutex_lock/unlock保护临界区;pthread_cond_wait/signal实现事件通知。
- 回收资源:进程用waitpid(),线程用pthread_join(),避免僵尸进程/线程。
实战场景:多线程数据采集网关
假设一个环境监测网关需要同时采集温湿度(I2C传感器)和空气质量(UART传感器),并将数据打包通过MQTT发送。采用3个线程:采集线程A(I2C)、采集线程B(UART)、发送线程C。共享一个环形缓冲区,用互斥锁+条件变量实现生产者-消费者模型。关键设计:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
struct sensor_data buffer[32];
int head = 0, tail = 0, count = 0;
void* collector_i2c(void* arg) {
while (1) {
struct sensor_data data = read_i2c_sensor();
pthread_mutex_lock(&lock);
while (count == 32) pthread_cond_wait(&cond, &lock); // 缓冲满则等待
buffer[tail] = data;
tail = (tail + 1) % 32;
count++;
pthread_cond_signal(&cond); // 通知发送线程
pthread_mutex_unlock(&lock);
usleep(500000); // 500ms采样周期
}
}
// 发送线程类似,从buffer取数据并发送
常见误区与复盘
- 误区1:fork()后子进程继承了父进程的锁状态,若父进程持锁时fork,子进程会死锁。应在fork前确保锁已释放。
- 误区2:线程函数中直接修改全局变量未加锁,导致数据竞争。使用-fsanitize=thread工具检测。
- 误区3:线程栈默认8MB,嵌入式系统内存有限,需通过pthread_attr_setstacksize设置较小栈(如256KB)。
4.3 进程间通信:管道、消息队列与共享内存
核心原理
IPC用于进程间交换数据。管道(Pipe)是半双工字节流,基于内核缓冲区,适合父子进程间简单通信;FIFO(命名管道)可用于无亲缘关系进程。消息队列(SysV或POSIX)以消息为单位,有类型字段,适合小数据量结构化消息。共享内存(Shared Memory)是效率最高的IPC,多个进程直接映射同一物理内存页,但需要信号量同步。在嵌入式场景中,共享内存+信号量常用于大数据量(如视频帧)传输。
| IPC方式 | 数据粒度 | 同步机制 | 性能 | 典型应用 |
|---|---|---|---|---|
| 管道 | 字节流 | 内核自动阻塞 | 中 | 命令输出重定向 |
| 消息队列 | 消息(有类型) | 阻塞或非阻塞 | 中低 | 控制命令传递 |
| 共享内存 | 任意字节 | 需外部信号量 | 最高 | 图像帧、大日志 |
| 信号量 | 计数器 | 原子操作 | 高 | 资源互斥与同步 |
操作流程与判断方法
- 管道:int pipefd[2]; pipe(pipefd); 父进程写pipefd[1],子进程读pipefd[0]。注意关闭不需要的端。
- 消息队列:int msgid = msgget(IPC_PRIVATE, 0666); msgsnd(msgid, &msg, size, 0); msgrcv(msgid, &msg, size, type, 0);
- 共享内存:int shmid = shmget(IPC_PRIVATE, 4096, 0666); void *addr = shmat(shmid, NULL, 0); 使用后shmdt(addr)。
实战场景:共享内存传输视频帧
在智能摄像头项目中,采集进程从V4L2设备获取YUV帧(约1MB),需要传递给AI推理进程。使用共享内存+双缓冲+信号量实现零拷贝传输:
// 创建共享内存
int shmid = shmget(1234, 2 * FRAME_SIZE, IPC_CREAT | 0666);
unsigned char *buf[2];
buf[0] = shmat(shmid, NULL, 0);
buf[1] = buf[0] + FRAME_SIZE;
// 信号量:sem[0]表示空位,sem[1]表示满位
struct sembuf p_empty = {0, -1, 0};
struct sembuf v_full = {1, 1, 0};
// 生产者(采集进程)
while (1) {
semop(semid, &p_empty, 1); // 等待空位
v4l2_read_frame(buf[current]);
current ^= 1;
semop(semid, &v_full, 1); // 增加满位
}
// 消费者(推理进程)类似,先等满位,处理完再增加空位
常见误区与复盘
- 误区1:管道读写端未正确关闭,导致read()永远阻塞。父进程必须关闭读端,子进程关闭写端。
- 误区2:消息队列消息大小超过上限(默认8192字节),需修改内核参数或改用共享内存。
- 误区3:共享内存未加同步,导致数据撕裂(读到的帧一半新一半旧)。必须配合信号量或互斥锁。
4.4 信号处理:异步事件的通知机制
核心原理
信号是软件中断,用于通知进程发生了异步事件。内核向进程发送信号,进程在合适时机(从内核态返回用户态时)处理。常见信号:SIGINT(Ctrl+C)、SIGTERM(终止)、SIGCHLD(子进程退出)、SIGALRM(定时器到期)、SIGUSR1/2(用户自定义)。信号处理函数在用户态执行,但会打断主流程,因此必须使用异步信号安全函数(如write(),不能使用printf())。
操作流程与判断方法
- 注册处理函数:使用sigaction()而非signal(),因为sigaction()可移植性更好,且能指定阻塞掩码。
- 屏蔽信号:sigprocmask(SIG_BLOCK, &set, NULL); 在临界区屏蔽信号,防止中断。
- 等待信号:sigsuspend()原子地解除屏蔽并等待,常用于同步。
- 发送信号:kill(pid, sig)或pthread_kill(thread, sig)。
struct sigaction sa;
sa.sa_handler = alarm_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; // 不设置SA_RESTART,让read()返回EINTR
sigaction(SIGALRM, &sa, NULL);
void alarm_handler(int sig) {
// 只做异步安全操作,如write()到管道
write(pipefd[1], "tick", 4);
}
// 主循环中使用pause()或sigsuspend()等待信号
实战场景:看门狗与超时管理
在无人值守的物联网设备中,使用SIGALRM实现软件看门狗。主程序定期(如每5秒)向硬件看门狗设备写入数据,若某个任务卡死超过10秒,则触发SIGALRM,在信号处理函数中执行紧急数据保存并重启进程。注意:信号处理函数中不能调用非异步安全函数(如malloc、printf),应通过管道或自管道(self-pipe trick)通知主循环处理。
常见误区与复盘
- 误区1:在信号处理函数中调用printf()或malloc(),导致死锁或堆损坏。应使用write()或设置全局标志。
- 误区2:未设置SA_RESTART导致read()被信号打断返回EINTR,未处理该错误导致数据丢失。应在循环中检查errno==EINTR并重试。
- 误区3:忽略SIGCHLD,导致僵尸进程。应在父进程中signal(SIGCHLD, SIG_IGN)或调用waitpid()回收。
本章内容覆盖了嵌入式Linux应用开发的核心系统编程接口。文件I/O是操作硬件的基础,进程与线程是并发架构的骨架,IPC是模块间协作的桥梁,信号是处理异步事件的利器。在实际项目中,这些技术往往交织使用,例如一个完整的物联网网关程序可能包含:主进程(负责配置管理)、采集线程(使用文件I/O读取传感器)、网络线程(使用socket发送数据)、信号处理(处理SIGTERM优雅退出)。掌握这些基础,你就能在嵌入式Linux平台上构建出稳定、高效、可维护的应用系统。