OS-访问系统对象

lseek

之前在地址空间一文中提及 mmap 可以映射文件到内存地址空间,并通过指针实现随机访问。对于使用 read/write 的情形,系统调用 lseek 同样可以达成这个目的。

1
off_t lseek(int fd,off_t offset,int whence);

whence 用来控制 offset 的工作方式:

  • SEEK_SET:偏移量从文件头开始计算。例如 lseek(fd,10,SEEK_SET) 将偏移量设置为第 10 个字节。
  • SEEK_CUR:在当前偏移位置继续往后偏移。比如已经通过 read 读取了 10 字节,lseek(fd,10,SEEK_CUR) 将偏移20字节。
  • SEEK_END:从文件结束开始偏移(支持负值)。lseek(fd,-10,SEEK_END) 将从结尾向前偏移 10 字节。

fsync

使用 write 写入文件时,修改的内容不一定会立即写入,具体时间由操作系统控制。但对于像事务型数据库这类存在日志、事务概念的程序,它们要求强制写入磁盘来保障持久性。

因此,UNXI 中提供 fsync(fd) 系统调用来将所有未写入的数据写入磁盘。


文件描述符

文件描述符类似指针,但不直接指向具体文件,而是指向一个中间内容。

单独考虑 fork 和文件描述符,我们知道 fork 后两个进程都将指向相同的文件。但我们知道通过 read/write 对文件进行操作时会维护一个偏移量,一个问题是 fork 之后两个进程的文件描述符偏移量也会同步吗?

文件描述符的本质

文件描述符类似指针,但不是指向文件本身,而是指向操作系统维护的一个内核文件对象,这个内核文件对象维护了实际文件的 inode 以及偏移量等信息。当 fork 后文件描述符也被写时复制,但具体指向的内核对象并不会被写时复制,也就是说这是一个“浅拷贝”,所以一个 fork 后进程会共享同一个内核文件对象。

偏移量共享:父子进程操作同一文件时偏移量同步更新。

graph TD
    classDef user fill:#B9D9EB,stroke:#333;
    classDef kernel fill:#F4D03F,stroke:#333;
    classDef physical fill:#ABEBC6,stroke:#333;

    subgraph 用户层
        U1[进程1 文件描述符表 stdin, stdout, stderr, fileA]
        U2[进程2 文件描述符表 stdin, stdout, stderr, fileB]
    end

    subgraph 内核层
        K1[[文件表]]
        K2[[v节点表]]
    end

    subgraph 物理层
        P1[(磁盘文件A)]
        P2[(磁盘文件B)]
        P3[(终端设备)]
        P4[(网络套接字)]
    end

    U1 -->|fd3 指向| K1
    U2 -->|fd3 指向| K1
    K1 -->|关联| K2
    K2 -->|映射| P1
    K2 -->|映射| P2
    K2 -->|映射| P3
    K2 -->|映射| P4

    style U1 fill:#B9D9EB
    style U2 fill:#B9D9EB
    style K1 fill:#F4D03F
    style K2 fill:#F4D03F
    style P1 fill:#ABEBC6
    style P2 fill:#ABEBC6
    style P3 fill:#ABEBC6
    style P4 fill:#ABEBC6

测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main() {
  int fd = open("test.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
  if (fd == -1) {
    perror("open");
    return 1;
  }

  write(fd, "Begin\n", 6);

  pid_t pid = fork();
  if (pid < 0) {
    perror("fork failed");
    return 1;
  } else if (pid == 0) {
    write(fd, "child\n", 6);
  } else if (pid > 0) {
    int status;
    wait(&status);
    write(fd, "parent\n", 7);
    close(fd);
  }
  return 0;
}

输出:

1
2
3
Begin
child
parent

文件描述符带来的风险

文件描述符是内核为进程维护的一个内核文件对象的映射,那么如果 exec 后新的程序会继承文件描述符吗?

测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// a.c
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main() {
  int fd = open("example.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
  if (fd == -1) {
    perror("open");
    return 1;
  }
  printf("fd:%d\n", fd);
  // 写入数据
  write(fd, "h", 1);
  fsync(fd);
  // 执行新程序
  char *args[] = {"./b", NULL};
  execvp(args[0], args);

  // 如果 execvp 成功,以下代码不会执行
  perror("execvp");
  close(fd);
  return 1;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// b.c
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main() {
  // 读取父进程写入的数据
  char buf[2];
  lseek(3, 0, SEEK_SET); // 假设文件描述符 3 是 example.txt
  read(3, buf, 1);
  buf[1] = '\0';
  printf("New program read: %s\n", buf);

  close(3);
  return 0;
}

执行结果:

1
2
fd:3
New program read: h

显然,exec 后的新程序会继承之前打开的文件描述符,比如这里的 b 就成功读取了 a 打开的文件。这毫无疑问是一个风险,毕竟用一个高权限进程拉起其他程序是很常见的操作,而如果不及时关闭文件描述符,新程序将继承一些隐私文件的访问权限。

为此,引入了 close-on-exec 标志(FD_CLOEXEC)确保文件描述符在 exec 时会自动关闭。


管道

管道是 linux 中进程间通讯的基本机制。管道的本质是单向字节流,并且采用先进先出的方式工作。

管道分为两种:

  • 匿名管道:调用 pipe 直接创建,用于具有父子关系的进程之间进行通信。
  • 命名管道:mkfifo 创建一个持续存在于文件系统中的管道。由于像文件一样,因此支持任意进程访问并进行通信。
特性 匿名管道 (Anonymous Pipe) 命名管道 (Named Pipe/FIFO)
创建方式 pipe()系统调用 mkfifo()系统调用或mkfifo命令
生命周期 随进程终止自动销毁 持久存在于文件系统直到显式删除
进程关系要求 必须是有亲缘关系的进程(如父子进程) 支持任意进程间通信
访问方式 通过文件描述符访问 通过文件路径访问

pipe

1
2
int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);

参数说明

  • pipefd[2]:用于返回管道的两个文件描述符:
    • pipefd[0]:管道的读端。
    • pipefd[1]:管道的写端。
  • flags(仅pipe2()):用于设置管道行为的标志位,可以是以下值的按位或:
    • O_CLOEXEC:设置文件描述符的 close-on-exec 标志。
    • O_DIRECT:启用“数据包模式”,每次写操作被视为一个独立的数据包。
    • O_NONBLOCK:设置非阻塞模式。
    • O_NOTIFICATION_PIPE(Linux 5.8+):启用通知机制,内核将事件消息写入管道。

在处理 pipefd 返回的两个文件描述符时,要手动关闭不需要的端口。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if (cpid == 0) { // 子进程
        close(pipefd[1]); // 关闭写端
        while (read(pipefd[0], &buf, 1) > 0)
            write(STDOUT_FILENO, &buf, 1);
        write(STDOUT_FILENO, "\n", 1);
        close(pipefd[0]);
        _exit(EXIT_SUCCESS);
    } else { // 父进程
        close(pipefd[0]); // 关闭读端
        const char* msg = "Hello from parent";
        write(pipefd[1], msg, strlen(msg));
        close(pipefd[1]); // 关闭写端,子进程读到EOF
        wait(NULL); // 等待子进程结束
        exit(EXIT_SUCCESS);
    }
updatedupdated2025-03-312025-03-31