Linux 系统编程入门:文件 IO 深度实操与工程应用
一、引言
很多刚从单片机转型到嵌入式 Linux 的兄弟,第一道坎往往不是 Linux 内核,而是 Linux 系统编程。在裸机开发里,你操作外设就是写寄存器;但在 Linux 下,万物皆文件——打开/dev/i2c-1就能读写 I2C 设备,操作/dev/ttyS0就能收发串口数据。
所以,文件 IO 是嵌入式 Linux 入门的必修课。今天咱们就从最底层的系统调用open/read/write/close讲起,把文件描述符、阻塞与非阻塞、缓冲区这些核心概念彻底捋清楚,最后给出一个工程中真能用的串口日志记录模块。
二、什么是文件描述符?
文件描述符(File Descriptor,FD)就是内核给每个已打开文件分配的整数编号。内核通过它来追踪你打开了哪些文件、读到哪儿了、是什么模式打开的。
| 文件描述符 | 宏定义 | 含义 |
|---|---|---|
| 0 | STDIN_FILENO | 标准输入(键盘) |
| 1 | STDOUT_FILENO | 标准输出(屏幕) |
| 2 | STDERR_FILENO | 标准错误输出 |
用一个比喻:你去图书馆借书,管理员(内核)给你一个号牌——这个号牌就是文件描述符。以后你还书、续借,都靠这个号牌沟通。你不需要知道书在哪个架子上,管理员帮你搞定。
关键点:每个进程的文件描述符是独立的。进程 A 的 FD=3 和进程 B 的 FD=3 可能指向完全不同的文件。内核在每个进程的task_struct中维护一个files_struct表来管理这些映射。
三、核心系统调用详解
3.1 open() —— 打开文件
#include<fcntl.h>intopen(constchar*pathname,intflags);intopen(constchar*pathname,intflags,mode_tmode);flags 常用组合:
| flag | 含义 |
|---|---|
| O_RDONLY | 只读 |
| O_WRONLY | 只写 |
| O_RDWR | 读写 |
| O_CREAT | 文件不存在则创建 |
| O_TRUNC | 打开时清空文件内容 |
| O_APPEND | 追加模式,写入自动到末尾 |
| O_NONBLOCK | 非阻塞模式 |
| O_SYNC | 同步写入,数据必须落盘才返回 |
权限 mode 的值:
// rw-r--r-- = 644open("test.txt",O_WRONLY|O_CREAT|O_TRUNC,0644);注意第三个参数mode需要和当前进程的umask做位与运算后才是最终权限。比如umask是0002,你传0666,最终创建的文件权限是0664。
3.2 read() —— 读取数据
#include<unistd.h>ssize_tread(intfd,void*buf,size_tcount);返回值含义:
> 0:实际读取的字节数= 0:读到文件末尾(EOF)= -1:出错,查看errno- 当
errno == EAGAIN且 fd 是非阻塞模式:表示当前没有数据可读
新手最容易踩的坑:以为一次read就能读完所有数据。实际上read的返回值可能小于你请求的count,这在网络编程和串口通信中尤其常见。
3.3 write() —— 写入数据
ssize_twrite(intfd,constvoid*buf,size_tcount);和read类似,write的返回值也可能小于请求写入的字节数,需要循环写入来确保数据全部发出。
3.4 close() —— 关闭文件
intclose(intfd);看似简单,却有大学问:进程退出时,所有打开的文件描述符都会被内核自动关闭。但显式close是好习惯,避免文件描述符泄漏——尤其在长时间运行的后台服务中,FD 泄漏会导致资源耗尽。
四、阻塞 vs 非阻塞:你必须懂的区别
这俩概念贯穿整个 Linux 编程,必须彻底搞懂。
- 阻塞(Blocking,默认):调用
read时如果没有数据,进程会挂起等待直到有数据到达或出错。 - 非阻塞(Non-blocking):调用
read时如果没有数据,立即返回 -1,同时errno设为EAGAIN或EWOULDBLOCK。
设置非阻塞的方式:
// 方式1:open 时指定intfd=open("/dev/ttyS0",O_RDWR|O_NONBLOCK);// 方式2:用 fcntl 动态修改intflags=fcntl(fd,F_GETFL,0);fcntl(fd,F_SETFL,flags|O_NONBLOCK);在嵌入式传感器采集场景中,非阻塞模式很常用——你不能让整个采集线程卡在一个read上,别的传感器还要不要读了?
五、工程实战:串口日志记录模块
下面给一个相对工程化的例子——一个简单的日志记录器,支持文件打开、追加写入、自动关闭。
#include<stdio.h>#include<stdlib.h>#include<string.h>#include<fcntl.h>#include<unistd.h>#include<stdarg.h>#include<time.h>#include<errno.h>#defineLOG_PATH"/var/log/embedded_sensor.log"#defineMAX_LINE256staticintlog_fd=-1;/* 初始化日志文件 */intlog_init(constchar*path){log_fd=open(path,O_WRONLY|O_CREAT|O_APPEND,0644);if(log_fd<0){fprintf(stderr,"log_init: open failed, %s\n",strerror(errno));return-1;}return0;}/* 写入日志,带时间戳 */intlog_write(constchar*fmt,...){if(log_fd<0)return-1;charline[MAX_LINE];intprefix_len,msg_len,total;time_tnow=time(NULL);structtm*tm_info=localtime(&now);/* 时间戳前缀 */prefix_len=strftime(line,sizeof(line),"[%Y-%m-%d %H:%M:%S] ",tm_info);/* 用户消息 */va_list args;va_start(args,fmt);msg_len=vsnprintf(line+prefix_len,sizeof(line)-prefix_len,fmt,args);va_end(args);total=prefix_len+msg_len;if(write(log_fd,line,total)!=total){fprintf(stderr,"log_write: write failed\n");return-1;}/* 每条日志后换行 */write(log_fd,"\n",1);return0;}/* 关闭日志 */voidlog_close(void){if(log_fd>=0){close(log_fd);log_fd=-1;}}/* 使用示范 */intmain(void){if(log_init(LOG_PATH)<0)return-1;log_write("传感器初始化完成,温度=%.2f°C",25.3);log_write("数据采样周期=1000ms");log_close();return0;}这段代码看起来不复杂,但涵盖了:
- open 创建/追加打开:O_CREAT | O_APPEND 组合确保日志不会丢失已有内容
- write 非原子性处理:虽然这里简化了,实际工程中建议检查
write返回值 - 时间戳格式化:
strftime+localtime是日志模块标配 - 可变参数:
va_list实现类似printf的接口
六、常见踩坑清单
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 文件描述符泄漏 | 打开后忘记 close | 使用 valgrind 或 lsof 排查 |
| read 返回值不对 | 一次 read 不够 | 循环读取直到读完 |
| 写文件没有内容 | 缓冲未刷新 | 使用 fsync 或 O_SYNC |
| lseek 定位混乱 | 多线程共享 FD | 使用 pread/pwrite |
| 权限不对 | umask 影响 | open 时指定完整 mode |
七、总结
文件 IO 是 Linux 系统编程的基石。掌握了open/read/write/close这套系统调用,你就拿到了进入内核世界的门票。在嵌入式开发中,无论是读写传感器数据、记录运行日志,还是操作 /dev 下的设备节点,本质都是文件 IO。
下一课我们把思路从"文件"扩展到"进程",看看fork如何让嵌入式系统并行处理多个任务。