4、Linux系统编程入门:文件I/O、进程与线程、进程间通信(管道、消息队列、共享内存)、信号处理

嵌入式Linux开发与裸机开发最大的区别在于,应用层代码运行在操作系统之上,必须借助系统调用(System Call)与内核交互。本章聚焦于物联网设备中最常用的系统编程基础:文件I/O、进程与线程模型、进程间通信(IPC)以及信号处理。这些知识点是构建稳定、高效、可维护的嵌入式应用(如数据采集网关、协议转换器、远程控制终端)的基石。理解文件描述符的本质、进程与线程的调度差异、IPC机制的性能取舍以及信号的异步处理机制,将直接决定你在实际项目中能否写出不丢数据、不卡死、响应及时的代码。

Linux系统编程核心模型:文件、进程/线程、IPC与信号 文件I/O • 文件描述符 (fd) • open/read/write/close • 阻塞与非阻塞模式 • 标准I/O vs 系统调用 • 设备节点 (/dev/xxx) • 偏移量与O_APPEND 进程与线程 • fork() 与 COW • exec 族函数 • pthread_create/join • 互斥锁与条件变量 • 线程同步与竞态 • 栈空间与资源开销 进程间通信 (IPC) • 管道 (Pipe/FIFO) • 消息队列 (MsgQ) • 共享内存 (Shm) • 信号量 (Semaphore) • 套接字 (Socket) • 性能与适用场景对比 信号处理 • signal() vs sigaction() • 异步事件通知 • 可重入函数 • 屏蔽与未决信号 • SIGCHLD / SIGALRM 嵌入式应用要点 • 文件I/O是设备驱动接口 • 多线程处理并发数据流 • 共享内存+信号量最高效 • 信号用于超时与子进程管理 调试与排障 • strace 跟踪系统调用 • /proc/pid/fd 查看fd • 死锁检测: gdb + pstack • 信号丢失排查

上图展示了本章四大知识模块的关联关系:文件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操作。

关键区别:标准C库的fread()/fwrite()带有用户态缓冲区,减少系统调用次数,适合大块数据;而read()/write()直接进入内核,无用户态缓冲,适合实时性要求高或小数据量场景(如单字节寄存器读写)。在驱动开发或高精度时序控制中,必须使用系统调用。

操作流程与判断方法

  1. 打开文件:int fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY | O_NONBLOCK); 注意O_NONBLOCK用于非阻塞模式,避免read()卡死。
  2. 读写操作:ssize_t n = read(fd, buf, len); 返回值处理:n>0表示实际读取字节数;n==0表示EOF;n==-1且errno==EAGAIN表示非阻塞下无数据。
  3. 定位偏移:off_t lseek(fd, offset, SEEK_SET); 对于设备文件通常无效,但对于普通文件(如配置文件)有用。
  4. 关闭文件: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会泄漏到子进程,导致资源占用。
风险提示:在嵌入式设备上,文件描述符数量有限(默认1024),频繁open/close而不关闭会导致fd耗尽,表现为“Too many open files”。务必使用RAII思想或goto cleanup模式确保close()执行。

4.2 进程与线程:并发执行的选择

核心原理

进程是资源分配的最小单位,拥有独立的地址空间、文件描述符表、信号处理函数。线程是CPU调度的最小单位,同一进程内的线程共享地址空间、全局变量、文件描述符。在嵌入式Linux中,fork()创建子进程时采用写时复制(COW)技术,效率较高,但进程间切换开销大(需要切换页表、刷新TLB)。线程切换开销小,但同步复杂。选择原则:需要强隔离(如某个模块崩溃不影响主程序)用进程;需要高并发I/O且共享数据频繁用线程。

维度进程线程
地址空间独立共享
资源开销较大(页表、PCB)较小(仅栈和寄存器)
同步方式IPC(管道、共享内存等)互斥锁、条件变量
崩溃影响互不影响一个线程崩溃可能导致整个进程退出
适用场景运行独立程序(如看门狗)数据采集+处理+发送流水线

操作流程与判断方法

  1. 创建进程:pid_t pid = fork(); 父进程返回子进程PID,子进程返回0,失败返回-1。子进程从fork()下一行开始执行。
  2. 创建线程:pthread_create(&tid, NULL, thread_func, arg); 线程函数返回void*,参数为void*。
  3. 同步机制:pthread_mutex_lock/unlock保护临界区;pthread_cond_wait/signal实现事件通知。
  4. 回收资源:进程用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)。
经验总结:在Cortex-A系列嵌入式Linux中,创建线程比进程快约10倍,但线程间的同步开销不可忽视。对于周期性任务(如10ms控制环),建议使用实时线程(SCHED_FIFO)并设置优先级。

4.3 进程间通信:管道、消息队列与共享内存

核心原理

IPC用于进程间交换数据。管道(Pipe)是半双工字节流,基于内核缓冲区,适合父子进程间简单通信;FIFO(命名管道)可用于无亲缘关系进程。消息队列(SysV或POSIX)以消息为单位,有类型字段,适合小数据量结构化消息。共享内存(Shared Memory)是效率最高的IPC,多个进程直接映射同一物理内存页,但需要信号量同步。在嵌入式场景中,共享内存+信号量常用于大数据量(如视频帧)传输。

IPC方式数据粒度同步机制性能典型应用
管道字节流内核自动阻塞中命令输出重定向
消息队列消息(有类型)阻塞或非阻塞中低控制命令传递
共享内存任意字节需外部信号量最高图像帧、大日志
信号量计数器原子操作高资源互斥与同步

操作流程与判断方法

  1. 管道:int pipefd[2]; pipe(pipefd); 父进程写pipefd[1],子进程读pipefd[0]。注意关闭不需要的端。
  2. 消息队列:int msgid = msgget(IPC_PRIVATE, 0666); msgsnd(msgid, &msg, size, 0); msgrcv(msgid, &msg, size, type, 0);
  3. 共享内存: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:共享内存未加同步,导致数据撕裂(读到的帧一半新一半旧)。必须配合信号量或互斥锁。
风险提示:共享内存的生命周期管理复杂,进程崩溃可能导致信号量未释放,造成死锁。建议使用POSIX命名信号量(sem_open),并设置SEM_UNDO属性(SysV信号量)自动恢复。

4.4 信号处理:异步事件的通知机制

核心原理

信号是软件中断,用于通知进程发生了异步事件。内核向进程发送信号,进程在合适时机(从内核态返回用户态时)处理。常见信号:SIGINT(Ctrl+C)、SIGTERM(终止)、SIGCHLD(子进程退出)、SIGALRM(定时器到期)、SIGUSR1/2(用户自定义)。信号处理函数在用户态执行,但会打断主流程,因此必须使用异步信号安全函数(如write(),不能使用printf())。

操作流程与判断方法

  1. 注册处理函数:使用sigaction()而非signal(),因为sigaction()可移植性更好,且能指定阻塞掩码。
  2. 屏蔽信号:sigprocmask(SIG_BLOCK, &set, NULL); 在临界区屏蔽信号,防止中断。
  3. 等待信号:sigsuspend()原子地解除屏蔽并等待,常用于同步。
  4. 发送信号: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中,推荐使用signalfd()将信号转换为文件描述符,通过select()/poll()统一监听,避免信号处理函数的异步问题。例如:int sfd = signalfd(-1, &set, SFD_NONBLOCK); 然后像读文件一样读取信号信息。

本章内容覆盖了嵌入式Linux应用开发的核心系统编程接口。文件I/O是操作硬件的基础,进程与线程是并发架构的骨架,IPC是模块间协作的桥梁,信号是处理异步事件的利器。在实际项目中,这些技术往往交织使用,例如一个完整的物联网网关程序可能包含:主进程(负责配置管理)、采集线程(使用文件I/O读取传感器)、网络线程(使用socket发送数据)、信号处理(处理SIGTERM优雅退出)。掌握这些基础,你就能在嵌入式Linux平台上构建出稳定、高效、可维护的应用系统。