嵌入式 Linux 用户态程序,如何通过文件 IO 进入 Linux 的系统世界。
一、为什么第二章先讲文件 IO
嵌入式 Linux 和单片机开发最大的区别之一是:
应用程序通常不直接操作硬件寄存器,而是通过 Linux 内核提供的接口访问系统资源。
在单片机里,你可能会这样理解硬件:
但到了 Linux ,你更多时候看到的是这些东西:
/etc/device.conf/dev/ttyS0/dev/input/event0/sys/class/leds/user-led/brightness/proc/cpuinfo
它们看起来都是“文件路径”,但背后可能对应完全不同的东西:
普通配置文件串口设备输入设备LED 设备属性内核导出的 CPU 信息
这就是 Linux 很重要的设计思想之一:
用统一的文件接口,暴露不同类型的系统资源。
所以文件 IO 不是“读写 txt 文件”这么窄。
在嵌入式 Linux 里,文件 IO 往往是应用程序进入系统世界的第一层入口。
Linux 倾向于把很多资源包装成可以被打开、读取、写入、关闭的对象。
这也是为什么你必须先理解 open/read/write/close/ioctl。
二、不要把“文件”只理解成普通文件
很多人刚开始学文件 IO,会下意识认为:
这个理解太片面了,在 Linux 里,“文件”这个概念比普通文件大得多。
常见的文件类型包括:
普通文件目录字符设备块设备管道socketprocfs 节点sysfs 节点
比如:
/etc/profile 普通配置文件/dev/ttyS0 串口设备节点/dev/i2c-1 I2C 控制器设备节点/dev/input/event0 输入设备节点/proc/meminfo 内存信息/proc/cpuinfo CPU 信息/sys/class/leds/... LED 设备属性/sys/class/gpio/... GPIO 设备属性
调用:
open("/dev/ttyS0", O_RDWR);
表面上是在打开一个路径,但这个路径背后不是普通磁盘文件,而是一个串口设备。
调用:
open("/sys/class/leds/user-led/brightness", O_WRONLY);
表面上也是打开一个路径,但你写进去的内容,会变成控制 LED 亮灭的动作。
所以要有如下观念:
在 Linux 用户态,路径不一定只代表磁盘文件,它可能代表一个由内核或驱动暴露出来的系统接口。
这句话很重要。
很多嵌入式 Linux 新手调设备调不通,本质不是 C 语言不会,而是没搞清楚这个路径背后到底是谁在响应。
三、fd 到底是什么
理解文件 IO,第一件事不是背 open() 的参数,而是理解 fd。
fd 是 file descriptor,也就是文件描述符。
它通常是一个整数:
比如:
int fd = open("/etc/device.conf", O_RDONLY);
如果打开成功,open() 会返回一个非负整数,这个整数就是 fd。
很多人会误以为:
不对,更准确地说:
fd 是当前进程里用来引用某个已打开文件对象的句柄。
它不是文件本身,只是一个入口。
程序后续调用:
read(fd, buf, size);write(fd, buf, size);close(fd);
都是通过这个 fd 告诉内核:
可以这样理解:
一个进程内部维护着一张文件描述符表。
当你调用 open() 成功后,内核会在这张表里分配一个位置,然后把对应的编号返回给你。
这个编号可能是:
为什么不是从 0 开始?
因为一个进程启动时,通常已经默认打开了三个文件描述符:
0 标准输入 stdin1 标准输出 stdout2 标准错误 stderr
所以你第一次自己调用 open(),经常会拿到 3。
这也是为什么下面这几个概念本质上都能和文件 IO 扯上关系:
printf() 输出到终端shell 重定向日志写入文件串口读写管道通信socket 通信
它们背后都绕不开“进程通过 fd 操作某个内核对象”这个思想。
四、open:先拿到访问入口
open() 的作用是打开一个文件或资源,并返回一个 fd。
常见函数原型是:
#include<fcntl.h>intopen(constchar *pathname, int flags);intopen(constchar *pathname, int flags, mode_t mode);
你可以先抓住三个点:
pathname 要打开哪个路径flags 以什么方式打开mode 创建文件时的权限
最简单的例子:
int fd = open("/etc/device.conf", O_RDONLY);
意思是:
常见的 flags 有:
O_RDONLY 只读O_WRONLY 只写O_RDWR 可读可写O_CREAT 文件不存在就创建O_APPEND 追加写O_TRUNC 打开时清空原内容O_NONBLOCK 非阻塞方式打开
如果你要创建文件,就需要第三个参数:
int fd = open("app.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
这里的 0644 表示文件权限。
简单理解:
但是新手最应该先记住的不是这些组合,而是:
open() 可能失败,失败时返回 -1。
所以工程代码里不能这样写:
int fd = open("/etc/device.conf", O_RDONLY);read(fd, buf, sizeof(buf));
这就是不合格代码。
因为如果 open() 失败,fd 就是 -1,你后面继续 read() 只会制造更混乱的问题。
正确的姿势应该是:
int fd = open("/etc/device.conf", O_RDONLY);if (fd < 0) { perror("open"); return -1;}
perror() 会根据当前的 errno 打印错误原因。
比如:
No such file or directoryPermission deniedDevice or resource busy
这些信息在板子上调试时非常关键。
别小看这一点,很多人调嵌入式程序,上来就怀疑驱动坏了、内核坏了、板子坏了,结果最后只是路径写错或者权限不够。
五、read:读到多少算多少
read() 用来从 fd 对应的对象里读取数据。
函数原型:
#include<unistd.h>ssize_tread(int fd, void *buf, size_t count);
它的意思是:
从 fd 里最多读取 count 个字节,放到 buf 指向的内存里。
返回值很关键:
> 0 实际读到的字节数= 0 读到文件末尾,或者对端关闭= -1 读取失败
这里有一个非常常见的误区:
read() 不是你让它读多少,它就一定读多少。
比如你写:
并不代表一定读到 1024 字节。
它可能只返回:
对于普通文件,这个可能不明显,但对于串口、管道、socket、设备节点,这个问题非常常见,你必须关注返回值。
如果你需要读取固定长度的数据,就应该循环读,而不是假装一次就能读完。
比如:
staticssize_tread_full(int fd, void *buf, size_t size){ char *p = buf; size_t left = size; while (left > 0) { ssize_t n = read(fd, p, left); if (n < 0) { return -1; } if (n == 0) { break; } p += n; left -= (size_t)n; } return (ssize_t)(size - left);}
这段代码的重点是表达一个事实:
读数据必须以 read() 的返回值为准。
不要脑补,系统编程里,脑补就是 bug 的温床。
六、write:写出去也不代表一次写完
write() 用来向 fd 对应的对象写入数据。
函数原型:
#include<unistd.h>ssize_twrite(int fd, constvoid *buf, size_t count);
返回值含义:
和 read() 一样,write() 也不保证一次写完。
比如:
n = write(fd, buf, 1024);
返回值可能是:
如果你要保证一段数据完整写入,也应该循环写。
比如:
staticintwrite_full(int fd, constvoid *buf, size_t size){ const char *p = buf; size_t left = size; while (left > 0) { ssize_t n = write(fd, p, left); if (n < 0) { return -1; } p += n; left -= (size_t)n; } return 0;}
这在嵌入式项目里非常实用。
比如:
写日志保存配置文件通过串口发送协议帧向设备节点写控制命令向 socket 发送数据
都不能粗暴地假设一次 write() 必然成功写完。
七、close:释放的不只是一个数字
close() 用来关闭文件描述符。
函数原型:
#include<unistd.h>intclose(int fd);
很多人觉得:
如果只是一个几十行的小测试程序,确实通常问题不大。
但在嵌入式 Linux 里,你写的程序经常是长期运行的服务。
比如:
数据采集服务通信网关程序设备管理进程后台守护进程升级服务日志服务
这些程序可能运行几天、几个月,甚至几年。
如果反复 open() 却不 close(),文件描述符会泄漏。
泄漏到一定程度,系统会报:
然后你的程序开始莫名其妙地失败。
所以要养成习惯:
八、ioctl:不是读写数据,而是发控制命令
讲文件 IO,如果完全不讲 ioctl(),是不完整的。
因为很多设备不是只靠 read 和write 就能控制。
比如:
串口要设置波特率、数据位、停止位、校验位摄像头要设置分辨率、帧格式、曝光参数I2C 设备可能要发特定传输命令SPI 设备要设置模式、位宽、速度某些自定义驱动要接收应用层控制命令
这些操作本质上不一定是“读一段数据”或“写一段数据”。
它们更像是在对设备说:
这时就会用到 ioctl()。
常见函数原型是:
#include<sys/ioctl.h>intioctl(int fd, unsignedlong request, ...);
你可以先这样理解:
fd 要控制哪个已打开的对象request 要执行哪种控制命令... 命令需要的参数,可能是整数,也可能是结构体指针
也就是说,ioctl() 仍然是围绕 fd 工作的。
它不是绕过文件 IO 模型,而是在文件 IO 模型上补了一条“控制通道”。
如下代码(伪代码):
int fd = open("/dev/example0", O_RDWR);if (fd < 0) { perror("open"); return -1;}struct example_config cfg = { .mode = 1, .speed = 1000000,};if (ioctl(fd, EXAMPLE_SET_CONFIG, &cfg) < 0) { perror("ioctl"); close(fd); return -1;}close(fd);
真实项目里,这些通常来自:
内核头文件驱动提供的用户态头文件芯片厂商 SDK项目自定义协议头文件
这里要抓住重点:
read/write 更偏向数据通道,ioctl 更偏向控制通道。
串口是一个典型例子,你可以用 read/write收发数据
read(fd, buf, size);write(fd, buf, size);
但设置串口参数时,通常不会直接 write() 一个字符串进去,而是通过 termios 相关接口。底层本质上也会涉及对这个 tty 设备的控制操作。
再比如 V4L2 摄像头:
VIDIOC_QUERYCAP 查询设备能力VIDIOC_S_FMT 设置视频格式VIDIOC_REQBUFS 申请缓冲区VIDIOC_STREAMON 开始采集VIDIOC_STREAMOFF 停止采集
这些都是典型的 ioctl 控制命令。
所以不要把 ioctl() 理解成一个“高级 read/write”。
它解决的是另一类问题:
read/write 负责数据流ioctl 负责设备控制
当然,ioctl() 也有缺点。它不像 read/write 那样统一直观,不同设备的 request 和参数结构完全可能不一样,所以使用 ioctl() 时,必须看对应设备或驱动的文档。
九、一个例子:控制 LED
普通文件只是开始。
嵌入式 Linux 更常见的情况是:通过某个设备接口控制硬件。
比如某些系统会通过 sysfs 暴露 LED:
/sys/class/leds/user-led/brightness
往里面写1:
写0:
示例代码:
#include<errno.h>#include<fcntl.h>#include<stdio.h>#include<string.h>#include<unistd.h>staticintwrite_full(int fd, constvoid *buf, size_t size){ const char *p = buf; size_t left = size; while (left > 0) { ssize_t n = write(fd, p, left); if (n < 0) { return -1; } p += n; left -= (size_t)n; } return 0;}intled_set(constchar *path, int on){ int fd = open(path, O_WRONLY); if (fd < 0) { fprintf(stderr, "open %s failed: %s\n", path, strerror(errno)); return -1; } const char *value = on ? "1\n" : "0\n"; if (write_full(fd, value, strlen(value)) < 0) { fprintf(stderr, "write %s failed: %s\n", path, strerror(errno)); close(fd); return -1; } if (close(fd) < 0) { fprintf(stderr, "close %s failed: %s\n", path, strerror(errno)); return -1; } return 0;}intmain(void){ return led_set("/sys/class/leds/user-led/brightness", 1) == 0 ? 0 : 1;}
这个例子不一定能在你的板子上直接运行。
因为不同板子的 LED 名称不一样,有些系统也不一定启用了这种接口。
它表达的是嵌入式 Linux 里非常典型的访问方式:
找到内核或驱动暴露出来的路径open 打开它write 写控制值close 关闭它
十、文件 IO 背后发生了什么
我们还是拿read 来举例。
你表面上只是调了一个 函数。
但背后大致会经历:
应用程序调用 read() -> C 库封装系统调用 -> CPU 从用户态切换到内核态 -> Linux 内核根据 fd 找到对应的 file 对象 -> VFS 判断这个对象属于哪类资源 -> 普通文件走文件系统 -> 设备节点走对应驱动 -> procfs/sysfs 走内核导出接口 -> 数据复制回用户空间 -> read() 返回实际读取的字节数
这里有几个关键点。
第一,应用程序不能直接拿 fd 去硬件里找东西。fd 是给内核看的。
第二,read() 读到的数据来自哪里,不由 read() 自己决定,它取决于这个 fd 背后到底连着什么对象。
第三,同样是 read(),背后可能完全不同。
比如:
read 普通文件 从文件系统读数据read /dev/ttyS0 从串口驱动读数据read /proc/cpuinfo 从内核生成 CPU 信息read socket 从网络协议栈读数据
上层接口统一,底层实现不同。
十一、阻塞与非阻塞:为什么程序会卡住
文件 IO 里还有一个非常重要的问题:阻塞。
比如你从串口读数据:
read(fd, buf, sizeof(buf));
如果当前没有数据,程序可能会停在那里等,这就叫阻塞。
阻塞不一定是坏事。
有些程序就是希望等到数据来了再继续。
但如果你在主线程里随便阻塞,整个程序可能就像“死了”一样。
比如:
如果你不希望 read() 一直等,可以用 O_NONBLOCK:
int fd = open("/dev/ttyS0", O_RDWR | O_NONBLOCK);
非阻塞模式下,如果暂时没有数据,read() 可能返回 -1,并设置:
或者:
这表示:
你不能看到 read() 返回 -1 就直接认定设备坏了。
要看 errno ,这也是为什么工程代码里必须认真处理返回值。
十二、嵌入式开发里最常见的文件 IO 坑
文件 IO 的 API 不复杂,但坑很多。
1. 不检查返回值
错误写法:
fd = open(path, O_RDONLY);read(fd, buf, sizeof(buf));close(fd);
这类代码在 demo 里很多,在工程里不应该出现。
open() 失败怎么办?
read() 失败怎么办?
read() 只读到一部分怎么办?
完全没处理。
2. 以为路径存在
PC 上有:
不代表板子上也有。
某个开发板上有:
/sys/class/leds/user-led/brightness
不代表另一块板子也有。
嵌入式 Linux 里,路径和根文件系统、内核配置、设备树、驱动加载都有关系。
路径不存在时,先看错误信息。
3. 权限不够
有些设备节点普通用户不能访问。
比如:
/dev/ttyS0/dev/i2c-1/dev/spidev0.0
如果 open() 返回:
那就是权限问题。
你需要检查:
当前运行用户设备节点权限udev/mdev 规则启动脚本systemd service 配置
4. 忘记 close
短命令行工具可能不明显。
长期运行服务一定会出事。
尤其是循环里反复打开文件:
while (1) { fd = open(path, O_RDONLY); read(fd, buf, sizeof(buf));}
这就是典型的 fd 泄漏。
跑一段时间后,程序可能再也打不开新文件。
十三、总结
第一,Linux 用户态通过 fd 操作已打开的资源。第二,普通文件、设备节点、procfs、sysfs 都可以纳入文件 IO 思路。第三,open/read/write/close 是进入 Linux 系统编程的基本入口。第四,read/write 必须看返回值,不能假设一次读写完整。第五,错误处理不是附属逻辑,而是系统编程的主流程。第六,嵌入式程序经常长期运行,fd 泄漏会变成真实事故。
如果你能把这些理解透,后面很多东西会自然很多。
比如:
为什么串口可以用 open/read/write 操作为什么 GPIO 可以通过 /sys 或 /dev 访问为什么驱动要创建设备节点为什么 ioctl 是在 fd 上发控制命令为什么 select/poll 也是围绕 fd 工作为什么 socket 也像文件一样能 read/write
这就是文件 IO 的价值。
它不是一个孤立知识点,而是 Linux 用户态编程的骨架。
你要记住:
open 打开一个资源,拿到 fdread 从 fd 读取数据write 向 fd 写入数据ioctl 向 fd 背后的设备发送控制命令close 释放 fd
但更要记住:
fd 背后可能是普通文件,也可能是设备、驱动、内核接口、管道或网络连接。
上层接口统一,底层实现不同。