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() 时,库函数会做几件事:

  1. 刷新缓冲区(如果之前有写入操作)
  2. 丢弃缓冲区中已读取但未使用的数据
  3. 调用底层的 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() 的关系:

lseek() 系统调用知识体系 lseek() 系统调用 fd 文件描述符 offset 偏移量 whence 基准位置 SEEK_SET SEEK_CUR SEEK_END fseek() 库函数 系统调用 vs 库函数 lseek 直接操作内核指针 fseek 有用户空间缓冲区 应用场景 典型用途 • 获取文件大小 • 随机访问记录 • 空洞文件 ⚠ 注意事项 不要混用 lseek 和 fseek 注意空洞文件的风险

12.6 小结

lseek() 是个很基础但很重要的系统调用。它让我们能随意跳转到文件的任何位置,实现随机访问。和 fseek() 相比,lseek() 更底层、更直接,但使用起来也要更小心——没有缓冲区帮你兜底,一切都要自己控制。

我个人建议:在嵌入式开发中,如果文件操作不频繁,用 fseek() 省心。如果对性能有极致要求,或者需要实现自定义的 I/O 策略,那就用 lseek()。关键是——别混用。

好了,这一节就到这里。记住 lseek() 的三个参数、三种定位方式,以及和 fseek() 的本质区别。下一节我们聊聊 dup() 和 dup2(),这两个函数在重定向文件描述符时非常有用。


公众号:蓝海资料掘金营,微信deep3321