文件和系统调用

ZaynPei Lv6

系统调用简介和实现

什么是系统调用(system call)

系统调用,顾名思义,说的是操作系统提供给用户程序调用的一组特殊接口,用户程序可以通过这个特殊接口来获得操作系统内核提供的服务

alt text

系统调用的实现

系统调用是属于操作系统内核的一部分,必须以某种方式提供给进程让它们去调用,相应的操作系统也有不同的运行级别–用户态和内核态,内核态可以毫无限制的访问各种资源,而用户态下的用户进程的各种操作都有限制,显然, 属于内核的系统调用是运行在内核态下,那么如何切换到内核态呢?

答案是软件中断(trap),操作系统一般是通过软件中断从用户态切换到内核态

系统调用和库函数的区别

系统调用是操作系统内核提供的接口,用户程序通过这些接口来请求内核提供的服务,而库函数是编程语言提供的一组预定义函数,这些函数封装了一些常用的操作,简化了编程工作

库函数主要由两类函数组成: 1. 不需要调用系统调用 不需要切换到内核空间即可完成函数全部功能,如strcpy、bzero等字符串操作函数

  1. 需要调用系统调用 需要切换到内核空间,这类函数通过封装系统调用去实现相应的功能,如print、fread等

错误处理函数

errno 是一个在 C 语言及其派生语言(如 C++)的标准库中使用的全局变量或宏,用于存储操作系统或库函数在执行过程中遇到的最近一次错误代码(类型为int), 不同的错误码表示不同的含义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <errno.h> // 也可以#include <cerrno> (C++)

void example_perror() {
FILE *fp;
fp = fopen("non_existent_file.txt", "r");

if (fp == NULL) {
// 仅需传入一个自定义前缀字符串
perror("文件读取失败");

// 预期输出可能类似于: 文件读取失败: No such file or directory
} else {
fclose(fp);
}
}

虚拟地址空间

alt text 如图是一张 Linux 进程地址空间布局图, 核心划分是内核区与用户区: | 区域 | 地址范围 | 作用与特性 | |————————|————|————————————————————————————————————–| | 内核区 (Kernel Space) | 3G 到 4G | 存放 Linux 内核的代码和数据。它是受保护的,用于运行内核程序,如内存管理、进程管理、设备驱动和 VFS(虚拟文件系统)。用户程序不能直接读写这块区域,否则会触发段错误。 | | 用户区 (User Space) | 0 到 3G | 存放用户程序的代码和数据。这是 int main(…) 函数及其代码、数据、堆栈等执行的地方。用户程序的大部分操作都在这个区域内进行。 |

下面我们再介绍用户区内存段(ELF 文件加载区):

用户区从底部(低地址 0)向上依次排列着由 ELF(Executable and Linkable Format,可执行文件格式)文件加载而来的各个数据段:

  1. 代码和数据段 (Code & Data) 这三个段通常是静态分配的,它们的大小在程序编译时就已经确定:
  • .text (代码段):存放程序的机器指令(二进制机器指令)。该区域是只读的,以防止程序意外修改自己的代码。
    • 图中标注:受保护的地址 (0∼4K),这是为了捕获对空指针的解引用操作,防止低地址被使用。
  • .rodata / .data (已初始化全局变量):存放程序中已初始化的全局变量和静态变量。
  • .bss (未初始化全局变量):存放程序中未初始化的全局变量和静态变量。在程序加载时,这些变量会被清零处理。

这几个段共同构成了程序的基本静态结构,其中代码段的只读保护是程序安全的重要保障。

  1. 堆空间 (Heap): 用于程序的动态内存分配(如 C 语言中的 malloc 或 C++ 中的 new)。

它从低地址向高地址(图中的向上箭头)增长,其大小在程序运行时动态变化。并且堆空间通常是用户区中最大的一块区域。

  1. 共享库/动态库 (Shared Libraries/Dynamic Libraries): 存放程序运行时需要链接的共享库文件(如 libc.so - C 标准库)。

多个进程可以共享同一份库的代码和数据,从而节省物理内存。共享库通过系统调用(如 execve)或动态加载器,将 C 标准库、Linux 系统 I/O 函数等加载到这里,供用户代码调用。

而静态库的代码则直接链接到程序的代码段中, 存储在.text 段内。

  1. 栈空间 (Stack): 用于存放函数调用时的局部变量、函数参数、返回地址等。

它从高地址向低地址(图中的向下箭头)增长。栈空间相对较小,一旦耗尽会导致栈溢出(Stack Overflow)。

堆和栈的对向增长(一个向上,一个向下)机制可以有效地利用中间的虚拟地址空间,并在它们相遇之前,为程序提供了最大的灵活性。

  1. 命令行参数和环境变量: 位于用户区最高端,存放着程序启动时传递给 main 函数的参数(如 argc,argv[…])和系统的环境变量(env)。

文件描述符

alt text 如图展示了 Linux 进程如何通过文件描述符 (File Descriptor, FD) 机制来管理和访问文件及 I/O 资源。

文件描述符的本质是一个非负整数(通常是 0 到 1023 之间),它在进程的上下文中,是用来唯一标识一个打开的文件、套接字(socket) 或其他 I/O 资源(本质上它们都是文件)的索引。

右图的文件描述符表存储在PCB中, PCB(process control block)是每个 Linux 进程都有的进程控制块,它位于内核区(Linux kernel)内, 包含了一个指向该进程文件描述符表的指针。

文件描述符 (FD) 名称 对应 I/O 资源 默认用途
0 STDIN_FILENO 标准输入 默认为只读模式,通常指向键盘输入或文件重定向。
1 STDOUT_FILENO 标准输出 默认为只写模式,通常指向终端显示或文件重定向。
2 STDERR_FILENO 标准错误 默认为只写模式,用于输出程序的错误和诊断信息。
3 及以上 正常文件 I/O 文件、套接字、管道等 用于进程通过 open()、socket() 等系统调用打开的资源。

如图所示, FD 0、FD 1、FD 2 是所有进程默认打开的三个标准流. 而当程序调用 open() 或 socket() 等系统调用(进入内核态)打开一个新的 I/O 资源时,内核会在PCB中查找该进程的文件描述符表。它会从 3 开始,寻找最小当前未被占用的整数作为新的文件描述符分配给该资源。

简而言之,用户进程不直接操作文件,而是操作文件描述符,由内核负责将这个数字与实际的 I/O 资源关联起来

常用文件IO函数

open函数

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <sys/types.h>
#include <sys.stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
参数:
pathname:文件的路径及文件名
flags:打开文件的行为标识(只读、只写、可读可写)
mode:这个参数只有在文件不存在时有效,指新建文件时指定文件的权限(数字指定, 如 0644代表rw-r--r--)
返回值:
成功:返回打开的文件描述符
失败:-1

其中flags参数常用的取值有: 必选项 (Access Modes) | 取值 (Flag) | 含义 (Meaning) | |————-|———————–| | O_RDONLY | 以只读的方式打开 | | O_WRONLY | 以只写的方式打开 | | O_RDWR | 以可读、可写的方式打开 |

可选项 (Creation/Status Flags): 可以和必选项按位或(使用 | 运算符)起来使用。 | 取值 (Flag) | 含义 (Meaning) | |————-|———————–| | O_CREAT | 文件不存在则创建文件,使用此选项时需使用 mode 说明文件的权限 | | O_EXCL | 如果同时指定了 O_CREAT,且文件已经存在,则出错 | | O_TRUNC | 如果文件存在,则清空文件内容 | | O_APPEND | 写文件时,数据添加到文件末尾 | | O_NONBLOCK | 对于设备文件,以 O_NONBLOCK 方式打开可以做非阻塞 I/O |

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 打开和关闭文件
int main(void)
{
int fd = -1;

// 1. 以只读的方式打开一个文件,如果文件不存在就报错
fd = open("txt", O_RDONLY);

// 2. 以只写的方式打开一个文件,如果文件存在就直接打开,如果文件不存在就新建一个文件
/fd = open("txt", O_WRONLY | O_CREAT, 0644);

// 3. 以只写的方式打开一个文件,如果文件存在就报错,如果文件不存在就新建一个文件
fd = open("txt", O_WRONLY | O_CREAT | O_EXCL, 0644);

// 4. 以读写的方式打开一个文件,如果文件存在就打开,如果文件不存在就新建一个文件
fd = open("txt", O_RDWR | O_CREAT, 0644);

// 5. O_TRUNC 清空文件内容
// 如果文件不存在就新建一个文件,如果文件存在就打开之后清空
fd = open("txt", O_WRONLY | O_TRUNC | O_CREAT, 0644);

// 6. O_APPEND 追加的方式
// 以只写和追加的方式打开一个文件,如果文件不存在会报错
fd = open("txt", O_WRONLY | O_APPEND);

close(fd);
return 0;
}

close函数

1
2
3
4
5
6
7
8
9
10
#include <unistd.h>

int close(int fd);
功能:
关闭已打开的文件
参数:
fd:文件描述符,open()的返回值
返回值:
成功:0
失败:-1,并设置errno

write函数

1
2
3
4
5
6
7
8
9
10
11
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
功能:
把指定数目的数据写到文件(fd), 注意 write() 函数会覆盖从当前文件偏移量开始的现有数据(与 lseek() 函数结合使用时尤为重要)。
参数:
fd:文件描述符
buf:数据首地址
count:写入数据的长度(字节)
返回值:
成功:实际写入数据的字节个数
失败:-1

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 写文件
int main(void)
{
int fd = -1;
int ret = -1; // write的返回值

char *str = "hello itcast";

// 1. 以只写的方式打开一个文件, 如果文件不存在就新建一个文件
fd = open("txt", O_WRONLY | O_CREAT, 0644);
if (-1 == fd)
{
perror("open");
return 1;
}

printf("fd = %d\n", fd);

// 2. 写文件
ret = write(fd, str, strlen(str));
if (-1 == ret)
{
perror("write");
return 1;
}

printf("write len: %d\n", ret);

// 3. 关闭文件
close(fd);

return 0;
}

read函数

1
2
3
4
5
6
7
8
9
10
11
12
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
功能:
把指定数目的数据读到内存(缓冲区)
参数:
fd:文件描述符
buf:内存首地址, 用于存储读取到的数据
count:读取的字节个数
返回值:
成功:实际读取到的字节个数
失败:-1

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // 包含 read, close 函数
#include <fcntl.h> // 包含 O_RDONLY, O_CREAT 等宏
#include <errno.h> // 包含 errno 定义

#define BUF_SIZE 100 // 定义读取缓冲区的大小

int main(void)
{
int fd = -1;
ssize_t ret = -1; // 使用 ssize_t 类型存储 read/write 返回值
char buffer[BUF_SIZE] = {0}; // 用于存储读取到的数据,并初始化为 0

// 1. 以只读的方式打开一个文件
fd = open("data.txt", O_RDONLY);
if (-1 == fd)
{
perror("open:");
return 1; // 退出程序并返回错误码
}
printf("成功打开文件,文件描述符 (FD) 为: %d\n", fd);

// 2. 读取文件
// 从 fd 对应文件中读取最多 BUF_SIZE-1 个字节到 buffer 中
ret = read(fd, buffer, BUF_SIZE - 1); // 留出 1 字节用于添加字符串结束符 '\0'
if (-1 == ret)
{
perror("read:");
// read 失败后,必须关闭文件,否则会造成资源泄露。
close(fd);
return 1;
}

// 3. 处理读取结果

// 步骤说明:将读取到的字节数 ret 对应的位置设为字符串结束符 '\0',
// 以便将读取到的数据作为一个C字符串打印出来,确保打印的准确性。
buffer[ret] = '\0';

printf("读取到的字节长度 (ret): %zd\n", ret);
printf("文件内容:\n%s\n", buffer);

// 4. 关闭文件
close(fd);

return 0;
}

lseek函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <sys/types.h>
#include <unistd.h>
typedef long off_t; // 32位系统中off_t是long类型, 64位系统中是long long类型, long是4字节, long long是8字节

off_t lseek(int fd, off_t offset, int whence);
功能:
改变文件的偏移量, 实现文件的随机访问(这很重要, lseek()本身不进行读写操作,它唯一的作用是修改内核记录的文件偏移量)
参数:
fd: 文件描述符
offset: 根据 whence 来移动的位数(偏移量), 可以是正数, 也可以是负数,
如果正数, 则相对于 whence 往右移动, 如果是负数, 则相对于 whence 往左移动。
whence: 其取值如下:
SEEK_SET: 从文件开头移动 offset 个字节
SEEK_CUR: 从当前位置移动 offset 个字节
SEEK_END: 从文件末尾移动 offset 个字节
返回值:
若 lseek 成功执行, 返回新的偏移量(绝对偏移量, 即从文件开头算起的字节数)
如果失败, 返回 -1

如果 lseek() 将文件偏移量设置到当前文件末尾之后(例如文件长 10 字节,你 lseek 到 100),然后执行 write 操作,文件尺寸会增大到 101 字节。中间跳过的 90 个字节是未初始化的,通常被称为“文件空洞”(File Hole)。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>

void lseek_example() {
int fd;
char buffer[20] = {0};
ssize_t ret;

// 我们假设已经有一个名为 sample.txt 的文件,内容为: 0123456789
fd = open("sample.txt", O_RDWR);
if (fd == -1) {
perror("open sample.txt");
return;
}

// 1. 使用 SEEK_SET
// 步骤说明:SEEK_SET 表示从文件起始位置 (0) 开始计算偏移量。
off_t new_offset = lseek(fd, 4, SEEK_SET);
if (new_offset == -1) {
perror("lseek SEEK_SET failed");
} else {
printf("1. SEEK_SET: 成功将偏移量设置为: %ld\n", new_offset); // 输出 4

// 从当前位置 (4) 读取 3 个字节
ret = read(fd, buffer, 3);
buffer[ret] = '\0';
printf(" 读取内容: %s (从 '4' 开始读 3 个)\n", buffer); // 输出 456
}
// 此时文件偏移量位于 4 + 3 = 7 处

// 2. 使用 SEEK_CUR
// 步骤说明:SEEK_CUR 表示从当前偏移量 (7) 开始计算偏移量。
new_offset = lseek(fd, 2, SEEK_CUR);
if (new_offset == -1) {
perror("lseek SEEK_CUR failed");
} else {
printf("2. SEEK_CUR: 成功将偏移量移动到: %ld\n", new_offset); // lseek 成功后,它返回的是新的绝对偏移量, 因此输出 9

// 从当前位置 (9) 写入数据 'A'
ret = write(fd, "A", 1);
printf(" 写入内容: A\n"); // sample.txt 变为 012345678A
}
// 此时文件偏移量位于 9 + 1 = 10 处

// 3. 使用 SEEK_END
// 步骤说明:SEEK_END 表示从文件末尾开始计算偏移量。
new_offset = lseek(fd, 0, SEEK_END); // 0 表示不移动,即只是定位到文件末尾。

if (new_offset == -1) {
perror("lseek SEEK_END failed");
} else {
printf("3. SEEK_END: 成功将偏移量移动到文件末尾: %ld\n", new_offset); // 输出 10

// 从文件末尾写入数据 'Z',这将增大文件尺寸
ret = write(fd, "Z", 1);
printf("在文件末尾追加内容: Z\n"); // sample.txt 变为012345678AZ
}
// 此时文件偏移量位于 10 + 1 = 11 处

close(fd);
printf("--- 文件操作完成 ---\n");
// 实际文件内容现在为 012345678AZ
}

文件描述符复制

在 Linux/Unix 系统编程中,dup() 和 dup2() 是两个重要的系统调用,用于复制(或称为重定向)文件描述符。

dup函数

1
2
3
4
5
6
7
8
9
10
#include <unistd.h>

int dup(int oldfd);
功能:
用于根据旧的文件描述符复制出一个新的文件描述符, 原始的文件描述符 oldfd 和新的文件描述符都将指向同一个文件
参数:
oldfd: 需要复制的文件描述符
返回值:
成功: 返回新的文件描述符
失败: -1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int main(void)
{
int fd = -1;
int newfd = -1;

// 1. 打开文件
fd = open("txt", O_RDWR | O_CREAT, 0644);
if (-1 == fd)
{
perror("open");
return 1;
}
printf("fd = %d\n", fd);

// 文件描述符复制
newfd = dup(fd);
if (-1 == newfd)
{
perror("dup");
return 1;
}
printf("newfd = %d\n", newfd);

// 2. 操作
write(fd, "ABCDEFG", 7);
write(newfd, "1234567", 7);

// 3. 关闭文件描述符
close(fd);
close(newfd);
return 0;
}

由于 newfd 和 fd 指向同一个文件, 共享同一文件偏移量,因此这次写入操作将从上一次写入结束的位置开始。也就是说,1234567 会紧接着 ABCDEFG 写入文件,文件内容最终是 ABCDEFG1234567

dup2函数

1
2
3
4
5
6
7
8
9
10
#include <unistd.h>
int dup2(int oldfd, int newfd);
功能:
将 oldfd 复制到 newfd, 并且newfd 的值可以人为指定. 如果 newfd 已经被打开, 则先调用 close() 关闭此描述符,断开它与原文件的关联,然后再使用这个合法的数字作为新的文件描述符。
参数:
oldfd: 需要复制的文件描述符
newfd: 目标文件描述符, 这个描述符的值可以指定。
返回值:
成功: 返回新的文件描述符
失败: -1

dup2() 最常见的用途是将标准 I/O 流(FD 0、1、2)重定向到文件。

1
2
3
4
5
6
7
8
9
10
11
int file_fd = open("output.log", O_WRONLY | O_CREAT | O_TRUNC, 0644); 
if (file_fd == -1) { /* 错误处理 */ }

// 步骤说明:使用 dup2 将 标准输出 (FD 1) 替换为 file_fd 指向的文件。
// 1. 如果 FD 1 已经打开(通常是终端),dup2 会先关闭它, 意味着无法通过FD 1 连接到终端。
// 2. 然后,它使 FD 1 指向和 file_fd 相同的资源。
dup2(file_fd, 1);

// 现在,所有原本写入标准输出 (原先的FD 1) 的数据,都会被写入 output.log 文件中。
printf("这条信息现在写入了 output.log 文件中。\n");
close(file_fd); // 关闭原始的文件描述符,但 FD 1 作为新的log文件描述符仍然有效
### fcntl函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */);
功能:
改变已打开的文件的性质,fcntl 针对文件描述符提供控制功能
参数:
fd: 文件描述符
cmd: 控制命令
F_DUPFD: 复制文件描述符,类似于 dup()
F_GETFD: 获取文件描述符标志
F_SETFD: 设置文件描述符标志
F_GETFL: 获取文件状态标志和访问模式
F_SETFL: 设置文件状态标志
返回值:
成功: 返回值取决于具体的 cmd
失败: -1

目录相关操作