12、底层文件I/O(二):lseek() 系统调用,与 fseek() 的对比。
好,咱们接着聊底层文件I/O。上一节我们讲了 open()、read()、write() 这些基本功,今天重点说说文件定位——也就是 lseek() 这个系统调用。
文件定位,说白了就是告诉操作系统:“我想从文件的哪个位置开始读写”。你想想看,一个文件几百兆,总不能每次都从头读到尾吧?那效率也太低了。
12.1 lseek() 系统调用:底层定位的核心
lseek() 的原型长这样:
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
三个参数,我一个个说清楚:
- fd:文件描述符,就是 open() 返回的那个整数
- offset:偏移量,单位是字节
- whence:基准位置,有三个可选值
whence 参数决定了 offset 从哪开始算:
| whence 值 | 含义 | 举例 |
|---|---|---|
| SEEK_SET | 从文件开头算起 | lseek(fd, 100, SEEK_SET) → 定位到第100字节 |
| SEEK_CUR | 从当前位置算起 | lseek(fd, 20, SEEK_CUR) → 向前跳20字节 |
| SEEK_END | 从文件末尾算起 | lseek(fd, -10, SEEK_END) → 定位到末尾前10字节 |
返回值是定位后的文件偏移位置(从文件开头算)。如果出错,返回 -1。
重要提醒: lseek() 只是移动了“文件读写位置”这个指针,它本身并不读写任何数据。这个指针是内核维护的,每个打开的文件描述符都有自己独立的指针。
12.2 几个实用场景
我在项目中遇到过不少需要 lseek() 的场景,挑几个典型的说说:
场景一:跳到文件末尾获取文件大小
off_t file_size = lseek(fd, 0, SEEK_END);
// 此时文件位置指针在末尾,记得跳回去
lseek(fd, 0, SEEK_SET);
这个技巧很常用。不过要注意,调用完 lseek(fd, 0, SEEK_END) 后,文件位置指针已经跑到末尾了。如果你紧接着 read(),什么都读不到。所以一般要再跳回开头。
场景二:随机访问固定长度的记录
假设你有一个二进制文件,里面存了1000条记录,每条记录固定100字节。你想读取第50条记录:
int record_no = 50;
int record_size = 100;
off_t offset = record_no * record_size;
lseek(fd, offset, SEEK_SET);
read(fd, buffer, record_size);
这种场景在嵌入式系统里很常见——比如从配置文件中读取某个参数,或者从日志文件中定位到某条记录。
场景三:空洞文件(Sparse File)
lseek() 可以让你跳过一个很大的范围,然后 write() 写入数据。中间跳过的部分,在磁盘上并不实际占用空间。这就是所谓的“空洞文件”。
lseek(fd, 1024*1024*100, SEEK_SET); // 跳过100MB
write(fd, "hello", 5); // 在100MB+1的位置写入5字节
嗯,这里要注意:空洞文件虽然不占磁盘空间,但用 stat() 查看文件大小时,显示的是逻辑大小(包括空洞部分)。如果你用 cp 复制这个文件,可能会变成实际占用空间的文件——这取决于 cp 的实现。
曾经踩过的坑: 有一次我在嵌入式设备上写日志系统,为了预分配空间用了 lseek + write 创建空洞文件。结果设备掉电后,文件系统检查时发现逻辑大小和实际块数不匹配,花了很长时间修复。后来我改用 fallocate() 或者直接写零来预分配,虽然慢一点,但更安全。
12.3 lseek() 与 fseek() 的对比
很多初学者会问:既然有 lseek(),为什么还要有 fseek()?这两个到底有什么区别?
我直接说结论:lseek() 是系统调用,fseek() 是库函数。 就这么简单。
但背后的设计思想,值得好好聊聊。
12.3.1 缓冲机制的不同
fseek() 是 C 标准库提供的,它操作的是 FILE* 流。FILE 结构体内部维护了一个用户空间的缓冲区。当你调用 fseek() 时,库函数会做几件事:
- 刷新缓冲区(如果之前有写入操作)
- 丢弃缓冲区中已读取但未使用的数据
- 调用底层的 lseek() 系统调用
而 lseek() 直接操作内核中的文件偏移指针,没有缓冲区这层。
我的建议: 如果你在做高性能读写,或者需要精确控制文件位置(比如数据库引擎),直接用 lseek() + read()/write()。如果你只是读写文本文件、配置文件,用 fseek() + fread()/fwrite() 更省心——缓冲区能减少系统调用次数,性能反而更好。
12.3.2 功能上的差异
| 特性 | lseek() | fseek() |
|---|---|---|
| 所属层次 | 系统调用(内核) | 库函数(用户空间) |
| 操作对象 | 文件描述符 fd | FILE* 流指针 |
| 返回值 | off_t(定位后的偏移量) | int(成功返回0,失败返回非0) |
| 错误处理 | 返回 -1,errno 设置错误码 | 返回非0,用 ferror() 检查 |
| 获取当前位置 | lseek(fd, 0, SEEK_CUR) | ftell(fp) |
| 可移植性 | POSIX 系统 | 所有 C 标准平台 |
12.3.3 什么时候用哪个?
我个人习惯这样判断:
- 跨平台代码:用 fseek()。Windows 上虽然也有 lseek(),但名字可能叫 _lseek(),麻烦。
- 需要精确控制:用 lseek()。比如你要实现一个自定义的缓存策略,或者做零拷贝 I/O。
- 混合使用:千万别!如果你用 fread() 读了一半,然后突然用 lseek() 跳转,缓冲区里的数据就乱了。要么全用库函数,要么全用系统调用。
曾经踩过的坑: 有一次我在代码里混用了 fread() 和 lseek()。fread() 内部缓冲了 4KB 数据,然后我直接用 lseek() 跳到文件末尾。结果下一次 fread() 读到的还是缓冲区里的旧数据,而不是文件末尾的内容。排查了半天才发现是缓冲区没刷新。从那以后,我再也不敢混用这两套接口了。
12.4 一个完整的例子
写个简单的 demo,展示 lseek() 的三种定位方式:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
int main() {
int fd = open("test.dat", O_RDWR | O_CREAT, 0644);
if (fd == -1) {
perror("open failed");
return 1;
}
// 写入一些数据
const char *data = "Hello, this is a test file.";
write(fd, data, strlen(data));
// 1. SEEK_SET:从开头定位
lseek(fd, 7, SEEK_SET);
char buf[32] = {0};
read(fd, buf, 4);
printf("SEEK_SET: %s\n", buf); // 输出 "this"
// 2. SEEK_CUR:从当前位置定位
lseek(fd, 2, SEEK_CUR);
memset(buf, 0, sizeof(buf));
read(fd, buf, 2);
printf("SEEK_CUR: %s\n", buf); // 输出 "is"
// 3. SEEK_END:从末尾定位
lseek(fd, -5, SEEK_END);
memset(buf, 0, sizeof(buf));
read(fd, buf, 4);
printf("SEEK_END: %s\n", buf); // 输出 "file"
close(fd);
return 0;
}
这个例子很简单,但把三种定位方式都覆盖了。你可以在自己的机器上跑一下,看看输出是不是预期的。
12.5 知识体系图
下面这张图总结了 lseek() 的核心概念和与 fseek() 的关系:
12.6 小结
lseek() 是个很基础但很重要的系统调用。它让我们能随意跳转到文件的任何位置,实现随机访问。和 fseek() 相比,lseek() 更底层、更直接,但使用起来也要更小心——没有缓冲区帮你兜底,一切都要自己控制。
我个人建议:在嵌入式开发中,如果文件操作不频繁,用 fseek() 省心。如果对性能有极致要求,或者需要实现自定义的 I/O 策略,那就用 lseek()。关键是——别混用。
好了,这一节就到这里。记住 lseek() 的三个参数、三种定位方式,以及和 fseek() 的本质区别。下一节我们聊聊 dup() 和 dup2(),这两个函数在重定向文件描述符时非常有用。
公众号:蓝海资料掘金营,微信deep3321