Linux系统编程学习笔记
Linux系统编程学习笔记
学习时间:2022年11月20日
学习来源:Linux系统编程(李慧琴)
参考资料:Unix环境高级编程,Linux系统编程
1 前言
1.1 环境
平台:Centos7.6
工具:vim,gcc,make
1.2 GCC
Linux系统下的GCC(GNU Compiler Collection
)是GNU推出的功能强大、性能优越的多平台编译器,是GNU的代表作品之一。gcc是可以在多种硬体平台上编译出可执行程序的超级编译器,其执行效率与一般的编译器相比平均效率要高20%~30%。
GCC编译器能将C、C++语言源程序、汇程式化序和目标程序编译、链接成可执行文件,如果没有给出可执行文件的名字,gcc将生成一个名为a.out
的文件。
GCC编译器编译C源文件为可执行文件的步骤:
- C源文件—->预处理
.i
—->编译.s
(生成汇编文件)—->汇编.o/.obj
(生成目标文件)—->链接.out
(生成可执行文件)—->可执行文件
gcc命令参数(选项)
参数 | 含义 | 示例 |
---|---|---|
-c |
对文件进行预处理、编译和汇编,生成obj文件 | gcc -c hello.c |
-S |
只进行预处理和编译,生成汇编代码 | gcc -S hello.c |
-E |
只进行预处理,这个选项不生成文件,可以使用重定向或者-o 选项使其生成一个文件 |
gcc -E hello.c > hello.i 或者gcc -E hello.c -o hello.i |
-o |
指定目标的名称,默认为a.out | gcc -o hello hello.c |
过程演示
例如源代码main.c
:
1 |
|
- 预编译:这个过程处理宏定义和include,去除注释,不会对语法进行检查。可以看到预编译后,源文件的代码从6行扩展了很多行,生成
main.i
1 | gcc -E main.c > main.i # 或者 gcc -E main.c -o main.i |
1 | // ... |
- 编译:这个阶段,检查语法,生成汇编代码
main.s
1 | gcc -S main.c -o main.s |
main.s
的内容:
1 | .file "main.c" |
- 汇编:这个阶段,生成目标代码
main.o
1 | gcc -c main.s -o main.o |
- 链接:生成可执行代码
main
。- 链接分为两种,一种是静态链接,另外一种是动态链接。
- 使用静态链接的好处是,依赖的动态链接库较少,对动态链接库的版本不会很敏感,具有较好的兼容性;缺点是生成的程序比较大。
- 使用动态链接的好处是,生成的程序比较小,占用较少的内存。
1 | gcc main.o -o main |
- 运行
1 | [root@HongyiZeng c]# ./main |
一步到位:
1 | gcc main.c -o |
此时会默认生成一个名为a.out
的可执行文件。
1 | gcc main.c -o myHello |
此时会生成一个名为myHello
的可执行文件
1.3 make
make命令是GNU工程化中的一个编译工具。make是依赖于Makefile来编译多个源文件的工具。在Makefile里同样是用gcc(或者别的编译器)来编译程序。
可以使用以下命令直接生成可执行文件:
1 | make main |
1.4 makefile
一个工程中的源文件不计其数,其按类型、功能、模块分别放在若干个目录中,makefile
定义了一系列的规则来指定哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作。
1.4.1 使用gcc的例子
- 创建文件
1 | touch main.c tool1.c tool1.h tool2.c tool2.h |
可以使用vim * -p
打开当前目录下的所有文件。-p
是打开多个文件的选项。
在命令模式下,gt
切换到下一个标签页,gT
切换到上一个标签页。
退出多个标签时,可加上a
,例如:qa
或者:wqa
等
tool1.h
和tool1.c
1 |
|
1 |
|
tool2.h
和tool2.c
1 |
|
1 |
|
main.c
1 |
|
对所有文件进行编译:
1 | gcc *.c |
执行a.out
1 | [root@HongyiZeng makefile]# ./a.out |
1.4.2 语法规则
1 | 目标 ... : 依赖 ... |
- 目标即要生成的文件。如果目标文件的更新时间晚于依赖文件更新时间,则说明依赖文件没有改动,目标文件不需要重新编译。否则会进行重新编译并更新目标文件。默认情况下Makefile的第一个目标为终极目标。
- 依赖:即目标文件由哪些文件生成。
- 命令:即通过执行命令由依赖文件生成目标文件。注意每条命令之前必须有一个tab保持缩进。
代码示例
- 新建makefile,
touch makefile
1 | mytool:main.o tool1.o tool2.o |
- 执行
make
即可。
makefile会监视哪些源文件发生了变化,一旦发生变化,则会在有这个变化文件的编译链上重新编译。
- 假设没有变化
1 | [root@HongyiZeng makefile]# make |
- 将
tool1.c
修改一点,再次make
1 | [root@HongyiZeng makefile]# make |
发现只有tool1.c
影响到的地方被重新编译了,没有发生变化的地方则不会被重新编译。
- 代码清理
clean
:我们可以编译一条属于自己的clean语句,来清理make命令所产生的所有文件,例如:
1 | mytool:main.o tool1.o tool2.o |
使用:
1 | make clean |
变量:
$
符号表示取变量的值,当变量名多于一个字符时,使用()
变量赋值:
=
:最普通的等号,在Makefile中容易搞错赋值等号,使用=
进行赋值,变量的值是整个Makefile中最后被指定的值。1
2
3VIR_A = A
VIR_B = $(VIR_A) B
VIR_A = AA经过上面的赋值后,最后VIR_B的值是
AA B
,而不是A B
,在make时,会把整个Makefile展开,来决定变量的值:=
:表示直接赋值,赋予当前位置的值。1
2
3VIR_A := A
VIR_B := $(VIR_A) B
VIR_A := AA最后BIR_B的值是
A B
,即根据当前位置进行赋值。?=
:表示如果该变量没有被赋值,赋值予等号后面的值。1
VIR ?= new_value
如果VIR在之前没有被赋值,那么VIR的值就为new_value。
1
2VIR := old_value
VIR ?= new_value这种情况下,VIR的值就是old_value
+=
:和平时写代码的理解是一样的,表示将符号后面的值添加到前面的变量上
预定义变量
CC
:c编译器的名称,默认值为gcc
GCC编译选项
CFLAGS
参数
选项 | 说明 |
---|---|
-c |
用于把源码文件编译成 .o 对象文件,不进行链接过程 |
-o |
用于连接生成可执行文件,在其后可以指定输出文件的名称 |
-g |
用于在生成的目标可执行文件中,添加调试信息,可以使用GDB进行调试 |
-Wall |
生成常见的所有告警信息,且停止编译,具体是哪些告警信息,请参见GCC手册,一般用这个足矣! |
-w |
关闭所有告警信息 |
-O |
表示编译优化选项,其后可跟优化等级0\1\2\3,默认是0,不优化 |
GCC链接选项
LDFLAGS
参数
选项 | 说明 |
---|---|
-llibrary |
链接时在标准搜索目录中寻找库文件,搜索名为liblibrary.a 或 liblibrary.so |
-Ldir |
用于把新目录添加到库搜索路径上,可以使用相对和绝对路径,“-L.”、“-L./include”、“-L/opt/include” |
-static |
使用静态库链接生成目标文件,避免使用共享库,生成目标文件会比使用动态链接库大 |
使用示例:例如posix线程中,查看创建线程的介绍pthread_create
中:
需要在编译和链接的选项中加上-pthread
,因此,在编写makefile时需要加上:
1 | CFLAGS+=-pthread |
执行make时:
1 | cc -pthread -phread test.c -o test |
改造后的makefile
1 | OBJS=main.o tool1.o tool2.o |
$
的其他用法:
$^
表示所有的依赖文件$@
表示生成的目标文件$<
代表第一个依赖文件
再次改造后的makefile
1 | OBJS=main.o tool1.o tool2.o |
实际上上面的三段代码都有固定的模式,如下:
1 | xxx.o:xxx.c |
相同的部分xxx
可以用通配符%
代替。可以简化为:
1 | OBJS=main.o tool1.o tool2.o |
1.5 POSIX
来源:https://zhuanlan.zhihu.com/p/392588996 侵权必删
1.5.1 简介
POSIX:可移植操作系统接口(Portable Operating System Interface of UNIX
,缩写为 POSIX );
发布者为电气与电子工程师协会(Institute of Electrical and Electronics Engineers),简称IEEE。
POSIX是IEEE为要在各种UNIX操作系统上运行的软件而定义的一系列API标准的总称,其正式称呼为IEEE 1003,而国际标准名称为ISO/IEC 9945。
1.5.2 历史
POSIX是Unix的标准。
1974年,贝尔实验室正式对外发布Unix。因为涉及到反垄断等各种原因,加上早期的Unix不够完善,于是贝尔实验室以慷慨的条件向学校提供源代码,所以Unix在大专院校里获得了很多支持并得以持续发展。
于是出现了好些独立开发的与Unix基本兼容但又不完全兼容的OS,通称Unix-like OS。包括:
- 美国加州大学伯克利分校的Unix4.xBSD(Berkeley Software Distribution)。
- 贝尔实验室发布的自己的版本,称为System V Unix。
- 其他厂商的版本,比如Sun Microsystems的Solaris系统,则是从这些原始的BSD和System V版本中衍生而来。
20世纪80年代中期,Unix厂商试图通过加入新的、往往不兼容的特性来使它们的程序与众不同。
为了提高兼容性和应用程序的可移植性,阻止这种趋势, IEEE开始努力标准化Unix的开发,后来由 Richard Stallman命名为Posix
。
这套标准涵盖了很多方面,比如Unix系统调用的C语言接口、shell程序和工具、线程及网络编程。
1.5.3 可移植性
① 系统调用和库函数
Linux下对文件操作有两种方式:系统调用(system call)和库函数调用(Library functions)。
- 系统调用:系统调用是通向操作系统本身的接口,是面向底层硬件的。通过系统调用,可以使得用户态运行的进程与硬件设备(如CPU、磁盘、打印机等)进行交互,是操作系统留给应用程序的一个接口。
- 库函数调用:库函数(Library function)是把函数放到库里,供别人使用的一种方式。
方法是把一些常用到的函数编完放到一个文件里,供不同的人进行调用。一般放在.lib
文件中。
库函数调用则是面向应用开发的,库函数可分为两类:
- 一类是C语言标准规定的库函数,
- 一类是编译器特定的库函数。
(由于版权原因,库函数的源代码一般是不可见的,但在头文件中你可以看到它对外的接口)。
glibc
是 Linux 下使用的开源的标准 C 库,它是 GNU 发布的 libc 库,即运行时库。这些基本函数都是被标准化了的,而且这些函数通常都是用汇编直接实现的。
glibc 为程序员提供丰富的 API(Application Programming Interface),这些API都是遵循POSIX标准的,API的函数名,返回值,参数类型等都必须按照POSIX标准来定义。
POSIX兼容也就指定这些接口函数兼容,但是并不管API具体如何实现。
② 区别
如上图所示:
- 库函数是语言或应用程序的一部分,而系统调用是内核提供给应用程序的接口,属于系统的一部分
- 库函数在用户地址空间执行,系统调用是在内核地址空间执行,库函数运行时间属于用户时间,系统调用属于系统时间,库函数开销较小,系统调用开销较大
- 系统调用依赖于平台,库函数并不依赖
函数库调用 | 系统调用 | |
---|---|---|
定义差别 | 在所有的ANSI C编译器版本中,C函数库是相同的 | 各个操作系统的系统调用是不同的 |
调用差别 | 它调用函数库中的一段程序或函数 | 调用系统内核的服务 |
运行空间 | 用户地址空间 | 内核地址空间 |
开销 | 属于过程调用,开销小 | 需要在用户空间和内核空间上下文切换,开销较大 |
个数 | 在C函数库libc中大约有300个函数 | 在Linux中大约有100多个系统调用 |
典型调用 | pringf,fread,malloc | chdir,fork,write,brk |
系统调用是为了方便使用操作系统的接口,而库函数则是为了人们编程的方便。
库函数调用与系统无关,不同的系统,调用库函数,库函数会调用不同的底层函数实现,因此可移植性好。
1.5.4 实例
当应用程序调用printf()函数时,printf函数会调用C库中的printf,继而调用C库中的write,C库最后调用内核的write()。
而另一些则不会使用系统调用,比如strlen, strcat, memcpy等。
printf函数执行过程中,程序运行状态切换如下:
1 | 用户态–>系统调用–>内核态–>返回用户态 |
printf函数、glibc库和系统调用在系统中关系图如下:
2 标准IO
该节对应APUE
的第五章——标准IO库
2.1 简介
IO分为标准IO(stdio
)和系统调用IO(sysio
);
系统调用IO根据操作系统的实现方式而定,对于程序员来说会造成很大困扰(例如打开文件,Linux的系统调用为open
,而Windows的系统调用为opendir
),于是又有了标准IO,提供了一套标准的IO实现的库函数(例如pringtf
,fopen
等),它实际上也是调用了系统IO进行操作,但是屏蔽了系统调用IO,方便程序员调用。
常用的标准IO库函数如下:
打开关闭文件 | 输入输出流 | 文件指针操作 | 缓存相关 |
---|---|---|---|
fopen |
fgetc ,fputc |
fseek |
fflush |
fclose |
fgets ,fputs |
ftell |
|
fread ,fwrite |
rewind |
||
printf 族,scanf 族 |
注意FILE
类型贯穿始终;
2.2 fopen
C 库函数 fopen
使用给定的模式 mode 打开 filename 所指向的文件。
1 | FILE *fopen(const char *filename, const char *mode) |
- filename — 字符串,表示要打开的文件名称。
- mode — 字符串,表示文件的访问模式,该指针指向以下面字符开头的字符串:
模式 | 描述 |
---|---|
“r” | 打开一个用于读取的文件。该文件必须存在。 |
“w” | 创建一个用于写入的空文件。如果文件名称与已存在的文件相同,则会删除已有文件的内容,文件被视为一个新的空文件。有则清空,无则创建。 |
“a” | 追加到一个文件。写操作向文件末尾追加数据。如果文件不存在,则创建文件。 |
“r+” | 打开一个用于更新的文件,可读取也可写入。该文件必须存在。 |
“w+” | 创建一个用于读写的空文件。 |
“a+” | 打开一个用于读取和追加的文件。 |
只有模式r
和r+
要求文件必须存在,其他模式都是有则清空,无则创建;
mode也可以包含字母b
,放在最后或者中间,表示二进制流。例如“rb”
,“r+b”
;
- 该函数返回一个 FILE 指针。否则返回 NULL,且设置全局变量
errno
来标识错误。该全局变量在头文件errno.h
中声明:(只展示部分)
1 |
代码示例
1 |
|
编译执行后打印结果:
1 | fopen() failed! errno = 2. |
可知errno
为2,为No such file or directory
;
在C标准中定义了两个函数帮助打印输出errno的对应错误原因,一个是strerror
,另一个是perror
;
perror包含在stdio.h
中:
1 | //函数原型 |
修改后的程序为:
1 |
|
打印结果:
1 | fopen(): No such file or directory |
strerror
包含在<string.h>
头文件中
1 | //函数原型 |
修改后的程序为:
1 | fprintf(stderr, "fopen:%s\n", strerror(errno)); |
fopen函数解析:
由函数原型可知,fopen函数返回的是一个FILE类型的指针,FILE是一个结构体,由typedef
进行了重命名,而指针实际上是指向结构体的指针。
问题:指针指向的内存空间是哪一块(或者说FILE结构体放在内存的哪一块)?是堆,是栈,还是静态区?
- 栈
1 | // 简单的fopen源码分析 |
分析:tmp
变量的存储类别是自动类型(块作用域,自动存储期),当程序退出这个块时,释放刚才为变量tmp匹配的内存,因此,指针指向的地址实际上没有tmp,是一个没有被分配的内存;
- 静态区
1 | // 简单的fopen源码分析 |
加上static
,将tmp保存在静态区(静态无链接),但是只能存在一个FILE实例(因为只有这一个内存区供指针指向);例如:
1 | fp1 = fopen("a", "r"); |
- 堆(正解)
1 | // 简单的fopen源码分析 |
此时变量tmp
具有动态存储期,从调用malloc
分配内存到调用free
释放内存为止,而free
就在fclose
函数中被调用。
2.3 fclose
C 库函数 fclose
关闭流 stream。刷新所有的缓冲区。
1 | int fclose(FILE *stream) |
- stream — 这是指向 FILE 对象的指针,该 FILE 对象指定了要被关闭的流。
- 如果流成功关闭,则该方法返回零。如果失败,则返回 EOF。
代码示例
1 |
|
2.4 fgetc和fputc
getchar
和putchar
1 | int getchar(void); // 从标准输入 stdin 获取一个字符(一个无符号字符)。这等同于 getc 带有 stdin 作为参数 |
1 | int putchar(int char); // 把参数 char 指定的字符(一个无符号字符)写入到标准输出 stdout 中。这等同于 putc 带有 stdout 作为参数 |
getc
和putc
1 | int getc(FILE *stream); // 从指定的流 stream 获取下一个字符(一个无符号字符),并把位置标识符往前移动。 |
1 | int putc(int char, FILE *stream); // 把参数 char 指定的字符(一个无符号字符)写入到指定的流 stream 中,并把位置标识符往前移动。 |
fgetc
和fputc
1 | int fgetc(FILE *stream); // 从指定的流 stream 获取下一个字符(一个无符号字符),并把位置标识符往前移动。 |
1 | int fputc(int char, FILE *stream); // 把参数 char 指定的字符(一个无符号字符)写入到指定的流 stream 中,并把位置标识符往前移动。 |
getc
,putc
和fgetc
,fputc
的区别
两者的使用完全相同,只是实现不同。这里的f
指的是function
,而不是file
。
getc
,putc
是通过宏定义实现,而fgetc
,fputc
是通过函数来实现。
宏只占用编译时间,不占用调用时间,而函数相反,因此内核的实现通常使用宏来定义函数,减少调用时间。
代码示例
需求:拷贝文件
1 | ./mycpy src dest |
实现代码:
1 |
|
使用:
1 | ./mycpy /usr/local/test /temp/out |
2.5 fgets和fputs
gets
和puts
1 | char *gets(char *str); // 从标准输入 stdin 读取一行,并把它存储在 str 所指向的字符串中。当读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定。 |
1 | int puts(const char *str); // 把一个字符串写入到标准输出 stdout,直到空字符,但不包括空字符。换行符会被追加到输出中。 |
fgets
和fputs
1 | // 从指定的流 stream 读取一行,并把它存储在 str 所指向的字符串内。当读取 (n-1) 个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定。 |
1 | // 把字符串写入到指定的流 stream 中,但不包括空字符。 |
区别
fgets
比gets
安全,使用gets
编译时会警告。所以不要使用gets
!
原因:函数 gets 可以无限读取,不会判断上限,所以程序员应该确保 buffer 的空间足够大,以便在执行读操作时不发生溢出。也就是说,gets 函数并不检查缓冲区 buffer 的空间大小,事实上它也无法检查缓冲区的空间。
如果函数的调用者提供了一个指向堆栈的指针,并且 gets 函数读入的字符数量超过了缓冲区的空间(即发生溢出),gets 函数会将多出来的字符继续写入堆栈中,这样就覆盖了堆栈中原来的内容,破坏一个或多个不相关变量的值。
fgets
读取结束的条件,满足其一即可:
- 读到
size-1
个字符时停止 - 读到换行符
'\n'
时停止 - 读到文件末尾
EOF
简单的实例
1 |
|
代码示例
用fgets
和fputs
代替fgtec
和fputc
:
1 |
|
2.6 fread和fwrite
fread
从给定流 stream 读取数据到 ptr 所指向的数组中。
1 | size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream) |
- ptr — 这是指向带有最小尺寸
size*nmemb
字节的内存块的指针。 - size — 这是要读取的每个元素的大小,以字节为单位。
- nmemb — 这是元素的个数,每个元素的大小为 size 字节。
- stream — 这是指向 FILE 对象的指针,该 FILE 对象指定了一个输入流。
- 成功读取的元素总数会以 size_t 对象返回,size_t 对象是一个整型数据类型。如果总数与 nmemb 参数不同,则可能发生了一个错误或者到达了文件末尾。
fwrite
把 ptr 所指向的数组中的数据写入到给定流 stream 中。
1 | size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream) |
- ptr — 这是指向要被写入的元素数组的指针。
- size — 这是要被写入的每个元素的大小,以字节为单位。
- nmemb — 这是元素的个数,每个元素的大小为 size 字节。
- stream — 这是指向 FILE 对象的指针,该 FILE 对象指定了一个输出流。
- 如果成功,该函数返回一个 size_t 对象,表示元素的总数,该对象是一个整型数据类型。如果该数字与 nmemb 参数不同,则会显示一个错误。
简单的实例
1 | fread(buf, size, nmemb, fp); |
代码示例
用fread
和fwrite
代替fgtec
和fputc
:
1 |
|
2.7 printf和scanf
printf
一族函数
printf
:发送格式化输出到标准输出 stdout。
1 | int printf(const char *format, ...); |
fprintf
:发送格式化输出到流 stream 中。可以实现格式化输出的重定向,例如重定向至文件中。
1 | int fprintf(FILE *stream, const char *format, ...); |
sprintf
:发送格式化输出到 str 所指向的字符串。它能够将多种数据类型(整型、字符型)的数据综合为字符串类型。有溢出风险,可以使用snprintf
来防止。
1 | int sprintf(char *str, const char *format, ...) |
atoi
:把参数 str 所指向的字符串转换为一个整数(类型为 int 型)。包含在stdlib.h
中。
1 | int atoi(const char *str) |
代码示例:
1 |
|
1 |
|
1 |
|
scanf
一族函数
scanf
fscanf
sscanf
2.8 fseek和ftell
fseek
:设置流 stream 的文件位置为给定的偏移 offset,参数 offset 意味着从给定的 whence 位置查找的字节数。
1 | int fseek(FILE *stream, long int offset, int whence) |
- stream — 这是指向 FILE 对象的指针,该 FILE 对象标识了流。
- offset — 这是相对 whence 的偏移量,以字节为单位。
- whence — 这是表示开始添加偏移 offset 的位置。它一般指定为下列常量之一:
常量 | 描述 |
---|---|
SEEK_SET |
文件的开头 |
SEEK_CUR |
文件指针的当前位置 |
SEEK_END |
文件的末尾 |
- 如果成功,则该函数返回零,否则返回非零值。
ftell
:返回给定流 stream 的当前文件位置。
1 | long int ftell(FILE *stream) |
- stream — 这是指向 FILE 对象的指针,该 FILE 对象标识了流。
- 该函数返回位置标识符的当前值。如果发生错误,则返回 -1L,全局变量 errno 被设置为一个正值。
程序实例——求程序的有效字节
1 |
|
rewind
:设置文件位置为给定流 stream 的文件的开头。
1 | void rewind(FILE *stream) |
相当于(void) fseek(stream, 0, SEEK_SET);
注意
fseek
和ftell
中偏移offset
的修饰类型是long
,因此只能对2G左右大小的文件进行操作,否则会超出long
的范围;
fseeko
和ftello
则将偏移的修饰类型使用typedef
定义为offset_t
,具体类型交由系统决定,因此不存在文件大小的限制。但是这两个函数不是C标准库函数,而是隶属于POSIX标准(POSIX是标准C库的超集,或者说,C库是普通话,而POSIX是方言)。
2.9 fflush
fflush
:刷新流 stream 的输出缓冲区。刷新,指的是将缓冲区(内存上的一片区域)的内容写入到磁盘(外存)中,或者输出到终端上显示。
1 | int fflush(FILE *stream) |
- 如果参数为
NULL
,则刷新所有的已打开的流 - 如果成功,该函数返回零值。如果发生错误,则返回 EOF,且设置错误标识符(即 feof)。
代码示例
1 |
|
打印结果:
1 | // 什么都不打印 |
原因:
- 对于标准输出,输出缓冲区刷新的时机:
- 输出缓冲区满
- 或者遇到换行符
\n
- 强制刷新,或者进程结束
因此,可以修改为:
1 |
|
或者修改为:
1 |
|
缓冲区的作用:大多数情况下是好事,合并系统调用,增加程序的吞吐量。
缓冲的分类:
- 行缓冲
line buffered
:针对标准输出(终端设备),有换行刷新,缓冲满刷新,强制刷新三种,后两个和全缓冲一致; - 全缓冲
fully buffered
:默认缓冲机制(除标准输出【终端设备】,例如重定向到文件),有缓冲满刷新,强制刷新两种,强制刷新例如调用fflush
函数,或者进程结束时也会强制刷新;此时换行符仅仅只是个换行符,没有刷新功能; - 无缓冲
unbuffered
:例如stderr
,需要立即输出,数据会立即读入内存或者输出到外存文件和设备上;
setvbuf
:定义流 stream 应如何缓冲。理解即可。
1 | int setvbuf(FILE *stream, char *buffer, int mode, size_t size) |
- stream — 这是指向 FILE 对象的指针,该 FILE 对象标识了一个打开的流。
- buffer — 这是分配给用户的缓冲。如果设置为 NULL,该函数会自动分配一个指定大小的缓冲。
- mode — 这指定了文件缓冲的模式:
模式 | 描述 |
---|---|
_IOFBF | 全缓冲:对于输出,数据在缓冲填满时被一次性写入。对于输入,缓冲会在请求输入且缓冲为空时被填充。 |
_IOLBF | 行缓冲:对于输出,数据在遇到换行符或者在缓冲填满时被写入,具体视情况而定。对于输入,缓冲会在请求输入且缓冲为空时被填充,直到遇到下一个换行符。 |
_IONBF | 无缓冲:不使用缓冲。每个 I/O 操作都被即时写入。buffer 和 size 参数被忽略。 |
2.10 getline
之前介绍的函数,都不能获得完整的一整行(有缓冲区大小的限制),而下面介绍的getline
函数则可以动态分配内存,当装不下完整一行时,又会申请额外的内存来存储。
getline
是C++标准库函数,但不是C标准库函数,而是POSIX所定义的标准库函数(在POSIX IEEE Std 1003.1-2008标准出来之前,则只是GNU扩展库里的函数)。在gcc编译器中,对标准库stdio
进行了扩展,加入了一个getline函数。
getline
会生成一个包含一串从输入流读入的字符的字符串,直到以下情况发生会导致生成的此字符串结束:
- 到文件结束
- 遇到函数的定界符
- 输入达到最大限度
函数原型:
1 |
|
lineptr
:指向存放该行字符的指针,如果是NULL,则有系统帮助malloc,请在使用完成后free释放。该参数是一个二级指针,因此传参需要一级指针的地址。即函数会把读取到的字符串的首地址存放在一级指针中。1
2
3
4
5// 传参:
char *ptr;
// 函数内的实际操作:
// 假设读取到的字符串Hello的首地址为0x000
&ptr = 0x000; // 此时ptr就指向了Hellon
:如果是由系统malloc的指针填0;stream
:函数需要读取的FILE流返回值:成功返回读取的字节数,失败或读完返回-1。
代码示例
1 | int main(int argc, char **argv) { |
2.11 临时文件
临时文件产生的问题:
- 如何命名不冲突
- 如何保证及时销毁
tmpnam
:生成并返回一个有效的临时文件名,该文件名之前是不存在的。如果 str 为空,则只会返回临时文件名。
存在并发问题,可能会产生两个或多个名字相同的临时文件。
1 | char *tmpnam(char *str) |
- str — 这是一个指向字符数组的指针,其中,临时文件名将被存储为 C 字符串。
- 返回一个指向 C 字符串的指针,该字符串存储了临时文件名。如果 str 是一个空指针,则该指针指向一个内部缓冲区,缓冲区在下一次调用函数时被覆盖。
- 如果 str 不是一个空指针,则返回 str。如果函数未能成功创建可用的文件名,则返回一个空指针。
tmpfile
:以二进制更新模式(wb+
)创建临时文件。被创建的临时文件会在流关闭的时候或者在程序终止的时候自动删除。
该文件没有名字(匿名文件)只返回指向FILE的指针,因此不存在命名冲突的问题,同时会自动删除,因此可以及时销毁。
1 | FILE *tmpfile(void) |
- 如果成功,该函数返回一个指向被创建的临时文件的流指针。如果文件未被创建,则返回 NULL。
3 系统调用IO
该节对应APUE
的第三章——文件IO
3.1 简介
UNIX系统的大多数文件IO只需用到5个函数:
open
close
read
write
lseek
3.2 文件描述符
3.2.1 FILE结构体
查看stdio.h
头文件中,有FILE
结构体的定义:
1 | //stdio.h |
其中_file
就是文件描述符。
3.2.2 文件描述符
文件描述符(fd
,file descriptor
)是文件IO(系统IO)中贯穿始终的类型。如下图所示:
- 当某一个进程执行系统调用
open
函数,会创建一个结构体,该结构体类似于FILE结构体,其中最基本的成员有一个指针pos
,用于指向inode
文件的某一个位置; 同时,该进程会维护一个数组(文件描述符表),该数组存储上述结构体的地址,而数组下标就是文件描述符
fd
,即文件描述符的本质就是一个整型数;- 该数组默认大小为1024,即可以打开的最大文件数量为1024,但可以设置
ulimit
来更改数组大小;注意该数组和对应产生的结构体只存在于这个进程空间内,而不是进程间共享; - 当调用
open
函数时,系统会自动打开三个流stdin
,stdout
和stderr
,这三个流分别占据该数组的0,1,2
号位置; - 结构体FILE中的成员
_file
就是整型数组下标fd
,即文件描述符 - 每打开一个新文件,则占用一个数组空间,而且是空闲的最小的数组下标。即文件描述符优先使用当前可用范围内最小的。同一个文件可以被多次打开,但是每打开一次都需要一个新的文件描述符和新的结构体,例如图中的结构体1和结构体2,指向了同一个inode;
- 该数组默认大小为1024,即可以打开的最大文件数量为1024,但可以设置
执行系统调用
close
时,就将对应fd下标的数组空间清除掉,并清除该地址指向的结构体;- 结构体中有一个成员用于记录引用计数,例如图中,将5号位置的
0x006
地址复制一份存储在6号位置,此时有两个指针指向了同一个结构体3,此时结构体3的引用计数为2,当5号指针free
时,结构体3的引用计数减1为1,不为0,则不会释放掉,否则6号位置的指针将成为野指针;
3.3 open和close
3.3.1 文件权限
① rwx
Linux下一切皆文件,不同的用户对文件拥有不同的权限。
文件具有三种权限:
1 | rwx 可读可写可执行,转换为数字就是421 |
针对文件 | 针对目录 | |
---|---|---|
r | 是否可以查看文件内容 | 是否能够列出ls 目录内容 |
w | 是否可以编辑文件内容 | 是否能够创建、删除、复制、移动文档 |
x | 是否能够执行该文件 | 是否可以进入目录和获得目录下文件的列表,要对目录下存在的文件进行读取和修改,必须要能进入目录,所以必须要目录有执行权限 |
② 文件属性
查看当前目录下的所有文件的属性:
1 | ll |
或者可以查看单个文件权限:
1 | ll atoi |
基本的文件属性格式如下:
1 | 类型 权限 链接数 属主 属组 大小 修改日期 文件名 |
- 类型和权限:
- 第1列表示文件的类型:
dcb-lsp
d
:目录-
:普通文件l
:软链接(类似Windows的快捷方式)b
:块设备文件(例如硬盘、光驱等)p
:管道文件c
:字符设备文件(例如屏幕等串口设备)s
:套接口文件
- 第2至10列为文件或目录的权限,分为3组:
- 拥有者权限
owner
:文件和文件目录的所有者 - 所属组
group
:文件和文件目录的所有者所在的组的其他用户 - 其它用户
other
:不属于上面的两个的用户
- 拥有者权限
- 第1列表示文件的类型:
- 链接数:有多少文件名链接到此节点(i-node);每个文件都会将它的权限与属性记录到文件系统的i-node中,不过我们使用的目录树却是使用文件名来记录,因此每个文件名就会连接到一个i-node,这个属性记录的就是有多少不同的文件名链接到相同的一个i-node号码。
例如第一行的文件属性为:
1 | -rwxr-xr-x 1 root root 8496 Nov 28 14:54 atoi |
则为普通文件,拥有者有读写和执行权限,而所属组用户和其他用户只有读取和执行权限,没有写权限;属主和属组均为root
;
③ umask
Linux具有默认权限:
一个目录被创建,默认权限是
drwxrwxrwx
,即777一个普通文件被创建,默认权限是
-rw-rw-rw-
,即666
但实际上所创建的文件和目录,看到的权限往往不是上面这个值。原因就是创建文件或目录的时候还要受到 umask
的影响。umask值表明了需要从默认权限中去掉哪些权限来成为最终的默认权限值。
查看umask的值:
1 | [root@HongyiZeng io]# umask |
可以看到umask值为0002
- 第一个0与特殊权限(特殊权限的内容参见
6.6.2节
)有关,后三位002
则与普通权限rwx
有关 002
中第一个0与拥有者权限有关,表示从拥有者权限减0,也就是权限不变,所以文件的创建者的权限就是是默认权限rw
(6 - 0 = 0
)002
中第二个0与组权限有关,表示从组的权限减0,所以组的权限也保持默认权限(rw)002
最后一位2则与系统中其他用户的权限有关,由于w=2,所以需要从其他用户默认权限rw
减去2,也就是去掉写(w)权限,则其他人的权限为rw - w = r(6 - 2 = 4
,对应r权限)- 最终,创建文件的最终默认权限为
-rw-rw-r--
同理目录权限的计算也是如此。
也可以临时更改umask的值:
1 | [root@HongyiZeng io]# umask 027 |
即去掉所属组用户的写权限,去掉其他用户的所有权限。
如果需要永久更改umask的话,需要修改文件/etc/bashrc
中的umask值:
3.3.2 open
安装man
手册:
1 | yum install -y man-pages |
open
用于打开或创建一个文件或者设备。
所在头文件:
1 |
函数原型1:
1 | int open(const char *pathname, int flags); |
- 将准备打开的文件或是设备的名字作为参数path传给函数,flags用来指定文件访问模式。
- open系统调用成功返回一个新的文件描述符,失败返回
-1
。
其中,flags是由必需文件访问模式和可选模式一起构成的(通过按位或|
):
必需部分 | 可选部分(只列出常用的) |
---|---|
O_RDONLY :以只读方式打开 |
O_CREAT :按照参数mode 给出的访问模式创建文件 |
O_WRONLY :以只写方式打开 |
O_EXCL :与O_CREAT 一起使用,确保创建出文件,避免两个程序同时创建同一个文件,如文件存在则open调用失败 |
O_RDWR :以读写方式打开 |
O_APPEND :把写入数据追加在文件的末尾 |
O_TRUNC :把文件长度设置为0,丢弃原有内容 |
|
O_NONBLOCK :以非阻塞模式打开文件 |
其中,对于可选部分,又分为文件创建选项和文件状态选项:
- 文件创建选项:
O_CREAT
,O_EXCL
,O_NOCTTY
,O_TRUNC
- 文件状态选项:除文件创建选项之外的选项
fopen和open的文件访问模式的联系
1 | r -> O_RDONLY // 只读存在的文件 |
函数原型2:
1 | int open(const *path, int flags, mode_t mode); |
在第一种调用方式上,加上了第三个参数mode
,主要是搭配O_CREAT
使用,这个参数规定了属主、同组和其他人对文件的文件操作权限。只列出部分:
字段 | 含义 |
---|---|
S_IRUSR |
读权限 |
S_IWUSR |
写权限 ——文件属主 |
S_IXUSR |
执行权限 |
可以用数字设定法:
数字 | 含义 |
---|---|
0 | 无权限 |
1 | x |
2 | w |
4 | r |
注意mode还要和umask计算才能得出最终的权限;
例如:
1 | int fd = open("./file.txt",O_WRONLY | O_CREAT, 0600); |
创建一个普通文件,权限为0600
,拥有者有读写权限,组用户和其他用户无权限。
补充:变参函数
变参数函数的原型声明为:
1 | type VAFunction(type arg1, type arg2, ...); |
变参函数可以接受不同类型的参数,也可以接受不同个数的参数。
参数可以分为两部分:个数确定的固定参数和个数可变的可选参数。函数至少需要一个固定参数,固定参数的声明和普通函数一样;可选参数由于个数不确定,声明时用 ...
表示。固定参数和可选参数共同构成一个函数的参数列表。
以printf
为例,它就是一个变参函数:
1 | int printf(const char *fmt, ...){ |
3.3.3 close
close
:关闭一个文件描述符
1 |
|
返回 0 表示成功,或者 -1 表示有错误发生,并设值errno
;
3.4 read,write和lseek
read
所在头文件和函数原型:
1 |
|
- 从与文件描述符fd相关联的文件中读取前count字节的内容,并且写入到数据区buf中
- read系统调用返回的是实际读入的字节数,发生错误返回
-1
write
所在头文件和函数原型:
1 |
|
- 把缓存区buf中的前count字节写入到与文件描述符fd有关的文件中
- write系统调用返回的是实际写入到文件中的字节数,发生错误返回
-1
,注意返回0不是发生错误,而是写入的字节数为0
lseek
所在头文件和函数原型:
1 |
|
lseek
设置文件位置为给定的偏移 offset,参数 offset 意味着从给定的 whence 位置查找的字节数。
whence
取值:
字段 | 含义 |
---|---|
SEEK_SET |
文件开头 |
SEEK_END |
文件末尾 |
SEEK_CUR |
文件当前位置 |
代码示例
用系统调用io实现mycpy
的功能。
1 |
|
3.5 IO效率
- 文件I/O:文件I/O又称为无缓冲IO,低级磁盘I/O,遵循POSIX相关标准。任何兼容POSIX标准的操作系统上都支持文件I/O。
- 标准I/O:标准I/O是ANSI C建立的一个标准I/O模型,又称为高级磁盘I/O,是一个标准函数包和stdio.h头文件中的定义,具有一定的可移植性。标准I/O库处理很多细节。例如缓存分配,以优化长度执行I/O等。标准的I/O提供了三种类型的缓存(行缓存、全缓存和无缓存)。
Linux 中使用的是GLIBC
,它是标准C库的超集。不仅包含ANSI C中定义的函数,还包括POSIX标准中定义的函数。因此,Linux 下既可以使用标准I/O,也可以使用文件I/O。
缓存是内存上的某一块区域。缓存的一个作用是合并系统调用,即将多次的标准IO操作合并为一个系统调用操作。
文件IO不使用缓存,每次调用读写函数时,从用户态切换到内核态,对磁盘上的实际文件进行读写操作,因此响应速度快,坏处是频繁的系统调用会增加系统开销(用户态和内核态来回切换),例如调用write
写入一个字符时,磁盘上的文件中就多了一个字符。
标准IO使用缓存,未刷新缓冲前的多次读写时,实际上操作的是内存上的缓冲区,与磁盘上的实际文件无关,直到刷新缓冲时,才调用一次文件IO,从用户态切换到内核态,对磁盘上的实际文件进行操作。因此标准IO吞吐量大,相应的响应时间比文件IO长。但是差别不大,建议使用标准IO来操作文件。
两种IO可以相互转化:
fileno
:返回结构体FILE的成员_file
,即文件描述符。标准IO->文件IO
1 | int fileno(FILE *stream); |
fdopen
:通过文件描述符fd,返回FILE结构体。文件IO->标准IO
1 | FILE *fdopen(int fd, const char *mode); |
注意:即使对同一个文件,也不要混用两种IO,否则容易发生错误。
原因:FILE结构体的pos
和进程中的结构体的pos
基本上不一样。
1 | FILE *fp; |
但是,进程维护的结构体中的pos
并未加2;只有刷新缓冲区时,该pos
才会加2;
代码示例
1 |
|
打印结果:
1 | bbbaaa |
解析:遇到文件IO则立即输出,遇到标准IO,则需要等待缓冲区刷新的时机,这里是进程结束后,进行了强制刷新,将3个a字符输出到终端上。
1 |
|
打印结果:
1 | ababab |
strace
命令能够显示所有由用户空间程序发出的系统调用。
以上面第一个程序为例:
1 | strace ./ab |
打印结果:
BUFSIZE对IO效率的影响
图中用户CPU时间是程序在用户态下的执行时间;系统CPU时间是程序在内核态下的执行时间;时钟时间是两个时间的总和;
BUFSIZE受栈大小的影响;此测试所用的文件系统是Linux ext4文件系统,其磁盘块长度为4096
字节。这也证明了图中系统 CPU 时间的几个最小值差不多出现在BUFFSIZE 为4096 及以后的位置,继续增加缓冲区长度对此时间几乎没有影响。
3.6 C程序的内存空间布局
补充:内存地址和内存空间
内存地址是一个编号,通常由16进制表示,它代表一个内存空间。在计算机中存储器的容量是以字节为基本单位的,也就是说一个内存地址代表一个字节(8bit)的存储空间,即按字节寻址。
假设一个int类型的变量x占用4个字节,则会占用4个连续的内存空间,x的内存地址则为第一个内存空间的地址。
对于32位操作系统,内存地址长度为32位,则可以表示2的32次方个内存空间(可寻址空间),即4GB;
计算:2^32 * 1B = 2^32B = 2^22 KB = 2^12 MB = 2^2 GB = 4GB
对于64位操作系统,内存地址长度为64位,则可以表示2的64次方个内存空间(16777216TB
);但实际上,主板和CPU的限制导致一般的电脑所支持的内存最大只有16GB而已。
C程序(例如a.out
)运行时会被加载入内存中,而这个内存一般分为五个分区:栈区、堆区、数据区、常量区、代码区。
3.6.1 动态区
动态区的内容会根据执行情况而动态变化。
① 栈区
栈(stack)是用户存放程序临时创建的局部变量,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且等调用结束后,函数的返回值也会被存放在回栈中。
- 栈的大小:最大大小由编译时确定,不会太大。
- 释放和分配:由编译器自动分配释放,由操作系统自动管理,无须手动管理。
- 栈区地址:由高地址向低地址生长。
- 若越界访问则会出现段错误(Segmentation Fault)
- 若多次递归调用增加栈帧导致越界则会出现栈溢出(Stack Overflow)
栈的大小可以通过ulimit
命令查看:
1 | ulimit -s # 只查看stack的大小 |
可以看到栈的大小默认为8192KB,即8M;
代码示例
1 | int main() { |
② 堆区
- 堆区存放:程序运行中动态存储分配的空间
- 堆区大小:视内存大小而定,由程序员进行分配。
- 堆区地址:由低地址向高地址生长
代码示例
1 | int main() { |
3.6.2 静态区
静态区的内容在整个程序的生命周期内都存在,由编译器在编译的时候分配。
① 数据区
根据数据是否被初始化又分为:bss
段与data
段。
- 未初始化数据段(
bss
)
通常将此段称为bss
段,这一名称来源于早期汇编程序的一个操作符,意思是block started by symbol(由符号开始的块)
。
存放未初始化的全局变量,属于静态内存分配。在程序开始执行之前,内核将此段初始化为0。
代码示例
1 | long sum[1000]; // 此变量存放在非初始化数据段中 |
- 已初始化数据段(
data
)
存放已初始化的全局变量和静态变量,属于静态内存分配,其内容由程序初始化。
代码示例
1 | float PI= 3.14f; // 此变量以初值存放在初始化数据段中 |
② 常量区
常量区存放字符串常量、const修饰的全局变量。程序运行期间,常量区的内容不可以被修改。
③ 代码区
代码区(text
),又叫:正文段、代码段。
通常是用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。
3.6.3 栈的地址测试
1 |
|
打印结果:
1 | &x = 0x7ffd1ce3e33c |
可以看到x,y,p的地址从高向低依次排列。
3.6.4 堆的地址测试
1 |
|
打印结果:
1 | &x = 0x7ffd4ab330fc |
可以看到分配在堆区的q的地址在其他三个的低处,且距离较远。
3.6.5 静态区演示
1 |
|
3.7 文件共享
如果两个独立进程各自打开了同一文件:
- 在完成每个write后,在文件表项(即类似于FILE的结构体)中的当前文件偏移量即增加所写入的字节数。如果这导致当前文件偏移量超出了当前文件长度,则将i节点表项中的当前文件长度设置为当前文件偏移量(也就是该文件加长了)。
- 如果用
O_APPEND
标志打开一个文件,则相应标志也被设置到文件表项的文件状态标志中。每次对这种具有追加写标志的文件执行写操作时,文件表项中的当前文件偏移量首先会被设置为i节点表项中的文件长度。这就使得每次写入的数据都追加到文件的当前尾端处。 - 若一个文件用
lseek
定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置为i节点表项中的当前文件长度。lseek
函数只修改文件表项中的当前文件偏移量,不进行任何I/O操作。
可能有多个文件描述符指向同一个文件表项(例如使用dup),对于多个进程读取同一文件都能正确工作。每个进程都有它自己的文件表项,其中也有它自己的当前文件偏移量。但是,当多个进程写同一文件时,则可能产生预想不到的结果。为了说明如何避免这种情况,需要理解原子操作的概念。
原子操作:不可分割的操作;
原子操作的作用:解决竞争和冲突;
3.8 dup和dup2
dup
函数用于复制文件描述符,重定向输入输出。
1 |
|
返回值:
- 成功:
- dup函数返回当前系统可用的最小整数值,并且该描述符对应的文件与参数描述符
oldfd
所对应的文件一致,即指向同一个结构体; - dup2函数返回第一个
不小于newfd
的整数值,分两种情况:- 如果newfd已经打开,则先将其关闭,再指向文件描述符oldfd的结构体;
- 如果newfd等于oldfd,则什么也不做;
- dup函数返回当前系统可用的最小整数值,并且该描述符对应的文件与参数描述符
- 失败:dup和dup2函数均返回
-1
,并设置errno。
代码示例
需求:将puts
重定向到一个文件中
- 方法1:
1 |
|
结果:
1 | [root@HongyiZeng sysio]# ./dup |
- 方法2:使用
dup
1 |
|
图示:
注意结构体中有引用计数,当fd=3
被关闭时,还有fd=1
指向这个结构体,因此结构体不会被销毁掉。存在并发问题。
- 方法3:使用
dup2
1 |
|
dup2
是一个原子操作,相当于:
1 | dup2(fd, 1); |
3.9 fcntl和ioctl
fcntl
针对文件描述符提供控制。
1 |
|
返回值:若成功,则依赖于cmd
,若失败,则返回-1
函数功能:
- 复制一个已有的描述符(
cmd=F_DUPFD或F_DUPFD_CLOEXEC
) - 获取/设置文件描述符标志(
cmd=F_GETFD或F_SETFD
) - 获取/设置文件状态标志(
cmd=F_GETFL或F_SETFL
) - 获取/设置异步I/O所有权(
cmd=F_GETOWN或F_SETOWN
) - 获取/设置记录锁(
cmd=F_GETLK、F_SETLK或F_SETLKW
)
ioctl
:用于控制设备
1 |
|
ioctl
函数一直是IO操作的杂物箱。不能用本章中其他函数表示的I/O操作通常都能用ioctl表示。终端I/O是使用ioctl最多的地方。
3.10 /dev/fd目录
对于每个进程,内核都提供有一个特殊的虚拟目录/dev/fd
。
该目录中包含/dev/fd/n
形式的文件名,其中n是与进程中打开文件描述符相对应的编号。也就是说,/dev/fd/0
就对应于进程的标志输入。
打开/dev/fd目录中的一个文件等同于复制对应的文件描述符,所以下面两行代码是等价的:
1 | fd = open("/dev/fd/1", O_WRONLY); |
3.11 补充:几个文件的区别
3.11.1 用户变量
~/.bashrc
和~/.bash_file
这两个看到~
,应该明白,这是用户目录下的,即里面的环境变量也叫shell变量,是局部的,只对特定的shell有效,用vim在用户目录下的.bash_profile
文件中增加变量,变量仅会对当前用户有效,并且是“永久的”。
要让刚才的修改马上生效,需要执行以下代码:
1 | source ~/.bash_profile |
两个的区别:.bash_profile
只在会话开始时被读取一次,而.bashrc
则每次打开新的终端时,都会被读取。
- 当shell是交互式登录shell时,读取
.bash_profile
文件,如在系统启动、远程登录或使用su -切换用户时; - 当shell是交互式登录和非登录shell时都会读取
.bashrc
文件,如:在图形界面中打开新终端或使用su切换用户时,均属于非登录shell的情况。
3.11.2 全局变量
/etc/profile
和/etc/profile.d
,前面的是文件,后面一看也就明白.d
表示目录, /etc/profile
里面的变量是全局的,对所有用户的shell有效。
用vim在文件/etc/profile
文件中增加变量,该变量将会对Linux下所有用户有效,并且是“永久的”。
要让刚才的修改马上生效,需要执行以下代码
1 | source /etc/profile |
4 文件系统
该节对应APUE
的第四章——文件和目录,第六章——系统数据文件和信息
4.1 Linux文件目录构成
4.1.1 概述
树状目录结构:
目录解释:
- /bin:bin 是 Binaries (二进制文件) 的缩写,这个目录存放着最经常使用的命令。
- /boot:这里存放的是启动 Linux 时使用的一些核心文件,包括一些连接文件以及镜像文件。
- /dev :dev 是 Device(设备) 的缩写,该目录下存放的是 Linux 的外部设备,在 Linux 中访问设备的方式和访问文件的方式是相同的(一切皆文件)。
- /etc:etc 是 Etcetera(等等) 的缩写,这个目录用来存放所有的系统管理所需要的配置文件和子目录。
- /home:用户的主目录(家目录),在 Linux 中,每个用户都有一个自己的目录,一般该目录名是以用户的账号命名的,如上图中的 alice、bob 和 eve。
- /lib:lib 是 Library(库) 的缩写这个目录里存放着系统最基本的动态连接共享库,其作用类似于 Windows 里的 DLL 文件。几乎所有的应用程序都需要用到这些共享库。
- /lost+found:这个目录一般情况下是空的,当系统非法关机后,这里就存放了一些文件。
- /media:linux 系统会自动识别一些设备,例如U盘、光驱等等,当识别后,Linux 会把识别的设备挂载到这个目录下。
- /mnt:系统提供该目录是为了让用户临时挂载别的文件系统的,我们可以将光驱挂载在 /mnt/ 上,然后进入该目录就可以查看光驱里的内容了。
- /opt:opt 是 optional(可选) 的缩写,这是给主机额外安装软件所摆放的目录。比如你安装一个ORACLE数据库则就可以放到这个目录下。默认是空的。
- /proc:proc 是 Processes(进程) 的缩写,/proc 是一种伪文件系统(也即虚拟文件系统),存储的是当前内核运行状态的一系列特殊文件,这个目录是一个虚拟的目录,它是系统内存的映射,我们可以通过直接访问这个目录来获取系统信息。
- /root:该目录为系统管理员,也称作超级权限者的用户主目录。
- /sbin:s 就是 Super User 的意思,是 Superuser Binaries (超级用户的二进制文件) 的缩写,这里存放的是系统管理员使用的系统管理程序。
- /tmp:tmp 是 temporary(临时) 的缩写这个目录是用来存放一些临时文件的。
- /usr:usr 是
unix shared resources
(共享资源) 的缩写,这是一个非常重要的目录,用户的很多应用程序和文件都放在这个目录下,类似于 windows 下的C:/Windows/
目录。/usr/lib
理解为C:/Windows/System32
/usr/local
:用户级的程序目录,可以理解为C:/Progrem Files/
。用户自己编译的软件默认会安装到这个目录下。/opt
:用户级的程序目录,可以理解为D:/Software
,opt有可选的意思,这里可以用于放置第三方大型软件(或游戏),当你不需要时,直接rm -rf
掉即可。
- /var:var 是 variable(变量) 的缩写,这个目录中存放着在不断扩充着的东西,我们习惯将那些经常被修改的目录放在这个目录下。包括各种日志文件。
- /run:是一个临时文件系统,存储系统启动以来的信息。当系统重启时,这个目录下的文件应该被删掉或清除。如果你的系统上有 /var/run 目录,应该让它指向 run。
切换用户
1 | su 用户名 |
~
代表当前登录用户的用户目录(家目录)。
如果当前的用户是root,则~
代表/root
:
1 | [lighthouse@HongyiZeng ~]$ su root |
如果当前的用户是其他用户,则~
代表/home/用户名
:
1 | [lighthouse@HongyiZeng ~]$ pwd |
4.1.2 /etc/passwd
/etc/passwd
为用户信息文件存放路径。
1 | root:x:0:0:root:/root:/bin/bash |
每一行分为7个字段,以冒号:
进行分割,含义如下:
字段 | 含义 |
---|---|
用户名 | 用户登录系统时使用的用户名 |
密码 | 密码位,通常将passwd 文件中的口令字段使用一个x 来代替,将/etc /shadow 作为真正的口令文件 |
UID | 用户标识号 |
GID | 缺省组标识号 |
注释性描述 | 例如存放用户全名等信息 |
宿主目录 | 用户登录系统后的缺省目录 |
命令解释器 | 用户使用的shell,默认为bash |
4.1.3 /etc/group
/ect/group
文件是用户组配置文件,即用户组的所有信息都存放在此文件中。只列出部分:
1 | root:x:0: |
每一行分为4个字段,以冒号:
进行分割,含义如下:
字段 | 含义 |
---|---|
组名 | 用户组名称 |
组密码 | 和 /etc/passwd 文件一样,这里的 “x” 仅仅是密码标识 |
GID | 组标识号 |
组中的用户 | 此字段列出每个群组包含的所有用户 |
4.1.4 /usr/include
linux系统编程往往需要引用c头文件,linux下,头文件一般存储到/usr/include
:
补充 inode
① 简介
文件数据是储存在硬盘上的,硬盘的最小存储单位叫做扇区。每个扇区存储512字节,而连续的8个扇区组成了一个块(block),大小为4kB
。文件数据都存储在块中,为了能够方便找到存储数据的位置我们还必须找到一个地方存储文件的属性,这种存储文具属性信息的区域叫做inode
(索引节点)。
② inode的信息
inode(本质上是一个结构体)包含文件的属性信息有以下内容:注意,没有文件名和inode编号
- 文件的字节数
- 文件拥有者的id
- 文件所属组id
- 文件的读写执行权限
- 文件的时间戳,共有三个:
- ctime指inode上一次变动的时间
- mtime指文件内容上一次变动时间
- atime指文件上一次打开时间。
- 硬链接数,即有多少个文件指向这个inode
文件数据块(block)的位置,即指向数据块的指针,包括一级指针,二级指针和三级指针,一般为15个指针
inode也会消耗硬盘空间,所以硬盘格式化的时候,操作系统自动将硬盘划分为两个区域。一个是数据区,存放文件数据,另一个是inode区(inode table),存放inode所包含的信息,inode区本质上是一个结构体数组,数组下标就是inode编号
inode_num
。
每个inode节点(结构体)的大小,一般是128字节或者256字节。inode节点的总数。在格式化时就给定,一般是每1kb或者每2kb就设置一个inode。假定在一块1GB的硬盘中,每个inode节点的大小就会达到128MB。
inode编号是很重要的,当系统在找一个文件时,步骤如下:
- 通过文件名先找到文件的inode编号(数组下标)
- 通过inode编号找到文件inode信息(结构体)
- 通过inode信息中的block指针找到文件内容。
③ 相关命令
stat
显示文件的状态信息。stat命令的输出信息比ls命令的输出信息要更详细。
1 | [root@HongyiZeng sysio]# ls |
ls -i
用于查看某个文件的inode编号。
1 | [root@HongyiZeng sysio]# ls -i ab.c |
④ 硬链接
一般情况下,文件名和inode号码是一一对应关系,每个inode号码对应一个文件名,但是Linux系统允许多个文件名指向同一个inode号码,这就意味着,可以用不同的文件名访问同样的内容;对文件内容进行修改,会影响到所有文件名;但是删除一个文件名,不影响另一个文件名访问,相当于源文件的副本,这种情况叫做硬链接。
inode信息中有一项叫做链接数,记录指向该inode的文件总数,这时会加1,反过来,删除一个文件名,会减一,当链接数变为0时,表明没有文件指向这个inode号码,系统就会回收这个inode号码与文件数据块区。
⑤ 符号链接
文件A与文件B的号码虽然不一样,但是文件A的内容是文件B的路径,A就是B的软链接文件。读取文件A时,系统会自动访问导向文件B的文件名,然后再根据B的inode去访问存储在块中的数据。
而这意味着,文件A依赖于文件B而存在,若删除了文件B,打开文件A就会报错。这就是软连接与硬链接最大的不同:文件A指向文件B的文件名,而不是inode号码,文件B的inode链接数不会发生变化。
4.2 文件和目录
4.2.1 stat
系统调用stat
用于获取文件的属性。
1 |
|
stat
:第一个形参:文件路径; 第二个形参:一个指向结构体stat
的指针,因此需要传入结构体的地址;fstat
:第一个形参是文件描述符;lstat
:lstat函数的形参跟stat函数的形参一样。其功能也跟stat函数功能一样,仅有一点不同:stat函数是穿透(追踪)函数,即对软链接文件进行操作时,操作的是链接到的那一个文件,不是软链接文件本身;而lstat函数是不穿透(不追踪)函数,对软链接文件进行操作时,操作的是软链接文件本身。注:软链接严格来说应该叫符号链接。- 返回值:成功返回0,失败返回-1,并且将详细错误信息赋值给errno全局变量。
struct stat
类型的说明:
1 | struct stat { |
代码示例
打印出文件的大小。
1 |
|
off_t
类型用于指示文件的偏移量,通常就是long
类型,其默认为一个32位的整数,在gcc编译中会被编译为long int
类型,在64位的Linux系统中则会被编译为long long int
,这是一个64位的整数,其定义在unistd.h
头文件中可以查看。
1 |
|
4.2.2 空洞文件
在描述文件属性的结构体stat
中,有以下三个描述文件大小的成员:
1 | struct stat { |
其中,块大小一般为4096字节,即4KB
(一个块为连续8个扇区,每个扇区为512B);块数为该文件的占用的块数;
注意:st_size ≠ st_blksize * st_blocks
;或者说,st_size
是文件的逻辑大小,而st_blksize * st_blocks
是文件的物理大小;
代码示例
创建一个5GB
大小的文件:
1 |
|
执行结果:
1 | [root@HongyiZeng fs]# make big |
可以看出,大小为5368709120B,但是占用的块数却为8,即实际占用的物理大小为4KB * 8 = 32KB
。
如果将该文件进行拷贝,再查看拷贝文件的属性:
1 | [root@HongyiZeng fs]# cp /tmp/bigfile /tmp/bigfile.bak |
使用 lseek 可以修改文件的当前读写位置偏移量,此函数不但可以改变位置偏移量,并且还允许文件偏移量超出文件长度,譬如有一个 test_file
,该文件的大小是 4K(也就是 4096 个字节),通过 lseek 系统调用可以将该文件的读写偏移量移动到偏移文件头部 6000 个字节处。
接下来使用 write 函数对文件进行写入操作,也就是说此时将是从偏移文件头部 6000 个字节处开始写入数据,也就意味着 4096~6000 字节之间出现了一个空洞,因为这部分空间并没有写入任何数据,所以形成了空洞,这部分区域就被称为文件空洞,那么相应的该文件也被称为空洞文件。文件空洞部分实际上并不会占用任何物理空间,直到在某个时刻对空洞部分进行写入数据时才会为它分配对应的空间,但是空洞文件形成时,逻辑上该文件的大小是包含了空洞部分的大小的,这点需要注意。
空洞文件的作用
空洞文件的好处是:空洞文件对多线程共同操作文件是很有用的。
因为我们在创建一个很大文件的时候,我们就把一个文件分成很多的段,然后采用多线程的方式,让每个线程负责写入其中的某一段的数据。这样的话比我们用单个线程写入是快很多的。
例如:
- 在使用迅雷下载文件时,还未下载完成,就发现该文件已经占据了全部文件大小的空间,这也是空洞文件;下载时如果没有空洞文件,多线程下载时文件就只能从一个地方写入,这就不能发挥多线程的作用了;如果有了空洞文件,可以从不同的地址同时写入,就达到了多线程的优势;
- 在创建虚拟机时,你给虚拟机分配了 100G 的磁盘空间,但其实系统安装完成之后,开始也不过只用了 3、4G 的磁盘空间,如果一开始就把 100G 分配出去,资源是很大的浪费。
4.2.3 st_mode
① 简介
st_mode
是一个16位的位图,用于表示文件类型,文件访问权限以及特殊权限位。
它的类型为mode_t
,其实就是普通的unsigned int
,但是只是用了低16位。
② 实例分析
假设st_mode
表示为八进制的100664
:
则有:
- 1000: 这是一个常规文件
- 000: 执行时设置信息为空,黏着位为 0
- 110-110-100: 用户权限为
RW-
,组员权限为RW-
,其他人权限为R--
③ 宏
通过手工分析 st_mode 字段,实际上是很不方便的。实际写程序,可使用 st_mode & 掩码
来得到 st_mode 中特定的部分。比如:
1 | st_mode & 0170000 : 得到文件类型 |
为了方便使用,用 linux 预定义的一些宏来代替这些生硬的数字。
1 | //bit15 ~ bit12 , 文件类型属性区域 |
④ 代码示例
1 |
|
执行结果:
1 | [root@HongyiZeng fs]# ./ftype ftype.c |
4.2.4 文件权限
① umask
umask函数原型:
1 |
|
在进程创建一个新的文件或目录时,如调用open函数创建一个新文件,新文件的实际存取权限是mode与umask按照 mode & ~umask
运算以后的结果。umask函数用来修改进程的umask,作用是防止出现权限过松的文件。
② chmod
补充:chmod命令
语法:
1 | chmod [对谁操作(ugoa)] [操作符 (+-=)] [赋予的权限(rwxs或数字)] 文件名1 文件名2... |
例如:
1 | [root@HongyiZeng fs]# ll big.c |
1 | [root@HongyiZeng fs]# ll big.c |
chmod函数原型:
1 |
|
chmod是对指定的文件进行操作,而fchmod则是对已经打开的文件进行操作。所以它们的第一个参数不一样。
返回值:如果改变成功返回0,否则返回-1
代码示例
1 |
|
③ 粘住位
在UNIX尚未使用分页技术的早期版本中,S_ISVTX
位被称为粘住位(sticky bit
)。
如果一个可执行程序文件的这一位被设置了,那么在该程序第一次被执行并结束时,其程序正文部分的一个副本仍被保存在交换区,(程序的正文部分是机器指令部分)。这使得下次执行该程序时能较快地将其装入内存中。其原因是:交换区占用连续磁盘空间,可将它视为连续文件,而且一个程序的正文部分在交换区中也是连续存放的,而在一般的UNIX文件系统中,文件的各数据块很可能是随机存放的。
对于常用的应用程序,例如文本编辑器和C编译器,我们常常设置它们所在文件的粘住位。自然,对于在交换区中可以同时存放的设置了粘住位的文件数是有一定限制的,以免过多占用交换区空间,但无论如何这是一个有用的技术。后来的UNIX版本称它为保存正文位(saved-text bit),因此也就有了常量S_ISVTX
。现今较新的UNIX系统大多数都配置有虚拟存储系统以及快速文件系统,所以不再需要使用这种技术。
现今的系统扩展了粘住位的使用范围,允许针对目录设置粘住位。如果对一个目录设置了粘住位,则只有对该目录具有写权限的用户在满足下列之一的情况下,才能删除或更名该目录下的文件或目录:
- 拥有此文件
- 拥有此目录
- 是超级用户
目录/tmp
是设置粘住位的典型候选者:任何用户都可在这个目录中创建文件。任一用户(用户、组和其他)对这个目录的权限通常都是读、写和执行(rwx
)。但是用户不应能删除或更名属于其他人的文件,为此在这个目录的文件模式中都设置了粘住位。
即:假如/tmp
下的文件A被用户U1所有,文件A的权限为777,那么所有用户都可以对该文件进行修改、移动、重命名等操作,但无法删除该文件。通常的用途在于用户团队协作的目录,用户可以相互修改文件,却只有用户所有者才能删除。
图中最后一位t
就是粘住位。
4.2.5 文件系统
① 磁盘的结构
磁盘
一个磁盘(如一个 1T 的机械硬盘)由多个盘片(如下图中的 0 号盘片)叠加而成。
盘片的表面涂有磁性物质,这些磁性物质用来记录二进制数据。因为正反两面都可涂上磁性物质,故一个盘片可能会有两个盘面。
磁道和扇区
每个盘片被划分为一个个磁道(一个一个半径不同的同心圆环),每个磁道又划分为一个个扇区(磁道上的一个弧段)。扇区是磁盘的最小组成单元,通常是512字节
。如下图:
其中,最内侧磁道上的扇区面积最小,因此数据密度最大。
柱面
每个盘面对应一个磁头。所有的磁头都是连在同一个磁臂上的,因此所有磁头只能“共进退”。
所有盘面中半径相同的磁道组成柱面。如下图:
磁盘容量计算
存储容量 = 磁头数 × 磁道(柱面)数 × 每道扇区数 × 每扇区字节数
磁盘的物理地址
由上,可用(柱面号,盘面号,扇区号)
来定位任意一个“磁盘块”,这里的“磁盘块”,实质上就是一个扇区。
可根据该地址读取一个“块”,操作如下:
- 根据“柱面号”前后移动磁臂,让磁头指向指定柱面;
- 旋转磁盘,让磁头抵达待读的起始扇区。
- 激活指定盘面对应的磁头;
- 旋转磁盘,指定的扇区会从磁头下面划过,这样就完成了对指定扇区的读/写。
块和簇
磁盘块/簇(虚拟出来的)。 块是操作系统中最小的逻辑存储单位。操作系统与磁盘打交道的最小单位是磁盘块。
在Windows下如NTFS
等文件系统中叫做簇;
在Linux下如Ext4
等文件系统中叫做块(block)。一般来说,一个块(block
)包含连续的8个扇区,每个扇区512B
,因此一个块大小为4096KB
。
每个簇或者块可以包括2、4、8、16、32、64…2的n次方个扇区。
② 文件系统简介
文件系统:文件或数据的存储和管理。目前,正在使用的UNIX文件系统有多种实现。
- 传统的基于BSD的UNIX文件系统(称为
UFS
)。UFS是以Berkeley快速文件系统为基础的。本节讨论该文件系统。 - 读、写DOS格式软盘的文件系统(称为
PCFS
) - 读CD的文件系统(称为
HSFS
)
我们可以把一个磁盘分成一个或多个分区。每个分区可以包含一个文件系统。
- i节点即为inode结构体的数组
- 数据块一般被分成了大小为4KB的块(block)
- i节点图(i节点位图):用来判断inode的空闲与占用情况
- 块位图:用来判断数据块的占用与空闲情况
对于普通文件,如下图:
- i节点结构体中通常包含15个指针来指向数据块,最后三个指针为一二三级指针,用于扩充文件的大小;图中的i节点指向了三个数据块。
- 在图中有两个目录项(两个不同文件名的文件,但是inode编号相同)指向同一个i节点(此时称为硬链接,即目录项就是硬链接的同义词)。每个i节点中都有一个硬链接计数
st_nlink
,其值是指向该i节点的目录项数。只有当链接计数减少至0时,才可删除该文件(也就是可以释放该文件占用的数据块)。 - i节点包含了文件有关的所有信息∶文件类型、文件访问权限位、文件长度和指向文件数据块的指针等。stat 结构中的大多数信息都取自i节点。只有两项重要数据存放在目录项中∶文件名和i节点编号。
对于目录,目录也是一种文件,它的属性也需要inode结构体存储,它的物理存储也需要通过inode中的指针来指向的数据块(此时的数据块就是目录块)来存储;
目录块存储的内容非常的简单,由目录项组成,每条目录项有包含的文件名以及该文件名对应的inode编号
。
如下图所示:
- 编号为2549的i节点(
testdir
),其类型字段st_mode
表示它是一个目录(因此它指向一个特殊的数据块——目录块),链接计数为2。任何一个叶目录(不包含任何其他目录的目录)的链接计数总是2,数值2来自于命名该目录(testdir)的目录项以及在该目录中的.
项。 - 编号为1267的i节点,其类型字段
st_mode
表示它是一个目录,链接计数大于或等于3。它大于或等于3的原因是,至少有3个目录项指向它∶一个是命名它的目录项,第二个是在该目录中的.
项,第三个是在其子目录 testdir 中的..
项。注意,在父目录中的每一个子目录都使该父目录的链接计数增加1。
③ 链接
链接分为硬链接和符号链接(注意不是软链接)。
创建链接的命令:
1 | ln src dest # 创建src的硬链接为dest |
1 | ln -s src dest # 创建src的符号链接为dest |
硬链接对比:
1 | [root@HongyiZeng tmp]# ln bigfile bigfile_link |
字段Link
即为文件的硬链接数,硬链接数为2,说明有两个目录项指向了这个inode,并且注意两个文件的inode编号相同,说明同时指向了这个inode;
将源文件bigfile
删除后,bigfile_link
仍然存在:
1 | [root@HongyiZeng tmp]# rm -rf bigfile |
此时硬链接数Link
变为1;
符号链接对比:
1 | [root@HongyiZeng tmp]# ln -s bigfile_link bigfile_s |
可以看到硬链接数并未改变;此外符号链接文件的大小为12字节,物理占用的块甚至为0;文件类型标识也变为了symbolic link
和l
;
删除原始文件后,发现符号链接文件的链接已成非法链接(红色部分):
相关的系统调用:
link
函数专门用来创建硬链接的,功能和 ln 命令一样。
1 |
|
unlink
函数删除一个文件的目录项并减少它的链接数,若成功则返回0,否则返回-1,错误原因存于error。
1 |
|
对比:硬链接不能给分区建立,不能给目录建立,而符号链接可以。
4.2.6 杂项
文件的删除、重命名和移动
remove
1 |
|
rename
:是mv
命令的系统调用
1 |
|
更改文件的时间
utime
:可更改文件的最后读的时间和最后修改的时间
1 |
|
更改当前工作路径
chdir
:是cd
命令的系统调用
1 |
|
getcwd
:是pwd
命令的系统调用
1 |
|
4.2.7 glob
该节内容为分析目录和读取目录内容。
glob函数原型(模型匹配函数):
1 |
|
pattern
: 通配符,要分析的pattern
,如/*
表示匹配根文件下的所有文件(不包括隐藏文件)flags
:flags参数可以设置特殊要求,如无特殊要求置为0errfunc
:函数指针,glob函数执行出错会执行的函数,出错的路径会回填到epath中,出错的原因回填到eerrno中。如不关注错误可设置为NULL
pglob
:解析出来的结果放在这个参数里,是一个结构体指针- 返回值:成功返回0,错误返回非0
其中,glob_t
是一个结构体:
1 | typedef struct { |
程序实例
打印出/etc/*.conf
的文件名。
1 |
|
执行结果:
1 | [root@HongyiZeng fs]# ./glob |
一组对目录操作的函数
和对文件操作的函数基于FILE
结构体类似,对目录的操作基于名为DIR
的结构体。
下面是常用的对目录进行操作的函数,他们的功能可以被glob
替代。
暂略
5 进程环境
本节对应第七章——进程环境;
5.1 main函数
C程序总是从main函数开始执行,从main函数结束执行。即main是程序的入口和出口。
当内核执行C程序时(使用一个exec函数),在调用main前先调用一个特殊的启动例程。可执行程序文件将此启动例程指定为程序的起始地址———这是由连接编辑器设置的,而连接编辑器则由C编译器调用。
启动例程从内核取得命令行参数和环境变量值,然后为按上述方式调用main函数做好安排。
5.2 进程终止
5.2.1 终止方式
共有8种方式让进程终止。其中5种为正常退出:
- 从main返回
- 调用
exit
(C库函数) - 调用
_exit
或_Exit
(系统调用) - 最后一个线程从其启动例程返回
- 从最后一个线程调用
pthread_exit
异常终止有3种方式:
- 调用
abort
- 接到一个信号
- 最后一个线程对取消请求做出响应
5.2.2 main
函数的返回值
main函数的返回值给main函数的父进程。
假设一个程序名为main_test
,则在终端上执行该程序时,父进程为shell
:
1 | ./main_test |
可通过$?
(表示显示最后命令的退出状态)查看该返回值。
1 |
|
执行结果:
1 | [root@HongyiZeng fs]# ./main1 |
如果将程序更改为:
1 |
|
则执行结果为:
1 | [root@HongyiZeng fs]# ./main1 |
此时会将printf
的返回值(打印字符的个数)作为main函数的返回值给父进程shell
。
5.2.3 钩子函数atexit
按照ISO C的规定,一个进程可以登记多至32个函数,这些函数将由exit
自动调用。我们称这些函数为终止处理程序(exit handler
),并调用 atexit
函数来登记这些函数。
1 | int atexit(void (*func)(void)); |
atexit
当程序正常终止时,调用指定的函数(终止处理程序) func。可以在任何地方注册终止函数,但它会在程序终止的时候被调用。先注册的后调用。
- func — 在程序终止时被调用的函数,该函数无参且无返回值,它是一个函数指针,因此传入的参数应该是一个函数的地址,即函数名(函数名就是函数的首地址)。
- 如果函数成功注册,则该函数返回零,否则返回一个非零值。
程序实例
1 |
|
执行结果:
1 | Begin |
5.2.4 exit和_exit
exit
是库函数,而_exit
是系统调用,前者使用了后者。
除此之外,_exit()
执行后会立即返回给内核,而exit()
要先执行一些清除和终止操作,然后将控制权交给内核。
5.3 命令行参数
暂略
5.4 环境变量
5.4.1 简介
环境变量的含义:程序(操作系统命令和应用程序)的执行都需要运行环境,这个环境是由多个环境变量组成的。
按变量的周期划为永久变量和临时性变量2种:
- 永久变量:通过修改配置文件,配置之后变量永久生效。
- 临时性变量:使用命令如export等命令设置,设置之后马上生效。当关闭shell的时候失效(这种主要用于测试比较多)。
按照影响范围分为用户变量和系统变量2种:
- 用户变量(局部变量):修改的设置只对某个用户的路径或执行起作用;
- 系统变量(全局变量):影响范围是整个系统;
环境变量本质上是一个kv键值对
。
5.4.2 查看环境变量
在Shell下,用env
命令查看当前用户全部的环境变量。
1 | [root@HongyiZeng fs]# env |
export
命令显示当前系统定义的所有环境变量。
1 | [root@HongyiZeng fs]# export |
查看某个环境变量的值:
1 | echo $KEY |
例如,查看PATH
的值:
1 | [root@HongyiZeng fs]# echo $PATH |
5.4.3 设置环境变量
在用户的家目录/home/用户名
下,有几个特别的文件:
.bash_profile
(推荐首选):当用户登录时执行,每个用户都可以使用该文件来配置专属于自己的环境变量。.bashrc
:当用户登录时以及每次打开新的Shell时该文件都将被读取,不推荐在里面配置用户专用的环境变量,因为每开一个Shell,该文件都会被读取一次,效率肯定受影响。.bash_logout
:当每次退出系统(退出bash shell)时执行该文件。.bash_history
:保存了当前用户使用过的历史命令
5.4.4 环境表
每个程序都接收到一张环境表。与参数表一样,环境表也是一个字符指针数组,其中每个指针包含一个以null
结束的C字符串的地址。
全局变量environ
则包含了该指针数组的地址∶
1 | extern char **environ; |
例如,如果该环境包含5个字符串,那么它看起来如图中所示。其中,每个字符串的结尾处都显式地有一个null字节。我们称 environ
为环境指针,指针数组为环境表,其中各指针指向的字符串为环境字符串。
代码示例
1 |
|
相关库函数:
1 |
|
1 |
|
程序实例
1 |
|
5.5 共享库
共享库使得可执行文件中不再需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存这种库例程的一个副本。
程序第一次执行或者第一次调用某个库函数时,用动态链接方法将程序与共享库函数相链接。这减少了每个可执行文件的长度,但增加了一些运行时间开销。这种时间开销发生在该程序第一次被执行时,或者每个共享库函数第一次被调用时。
共享库的另一个优点是可以用库函数的新版本代替老版本而无需对使用该库的程序重新连接编辑(假定参数的数目和类型都没有发生改变)。
动态库的相关库函数
1 | // dladdr, dlclose, dlerror, dlopen, dlsym, dlvsym - programming interface to dynamic linking loader |
5.6 函数间跳转
补充:goto语句
C 语言中的 goto 语句允许把控制无条件转移到同一函数内的被标记的语句。
语法:
1 | goto label; |
在这里,label 可以是任何除 C 关键字以外的纯文本,它可以设置在 C 程序中 goto 语句的前面或者后面。
代码示例:
1 | int main() { |
执行结果:
1 | a 的值: 10 |
setjmp
和longjmp
可以实现非局部控制转移即从一个函数到另外一个函数的跳转。
函数原型:
1 |
|
setjmp
函数用于记录当前位置,保存调用函数的栈环境在结构体jmp_buf buf
(相当于保护现场)。函数输入参数为jmp_buf
类型(这个结构体类似于goto的跳转标识),返回整型。当第一次调用时(设置跳转点),它的值为0;当第二次调用时(从别处跳转回来,即调用longjmp
时)返回非零值;总之执行一次,返回两次,因此,setjmp
函数后常常跟上分支语句。longjmp
的作用是使用setjmp
保存在buf
中的栈环境信息返回到setjmp
的位置,也就是当执行longjmp
时程序又回到setjmp
处(相当于恢复现场)。形参val
是调用longjmp
时setjmp
函数返回的值,为非零值,如果故意设置为0,也会被修改为1;
程序实例
1 |
|
执行结果:
1 | main():Begin. |
注:ANSI C 定义了许多宏。在编程中可以使用这些宏,但是不能直接修改这些预定义的宏。
例如:
1 | __DATE__ 当前日期,一个以 “MMM DD YYYY” 格式表示的字符串常量。 |
现在改写程序,在函数a进行setjmp
,函数d进行longjmp
:
1 |
|
执行结果:
1 | main():Begin. |
5.7 资源的获取和控制
获取或设置资源使用限制:linux下每种资源都有相关的软硬限制,软限制是内核强加给相应资源的限制值,硬限制是软限制的最大值。
非授权调用的进程只能将其软限制指定为0~硬限制范围中的某个值
,同时能不可逆转地降低其硬限制。
授权进程(root用户)可以任意改变其软硬限制。
函数原型:
1 |
|
rlimit
结构体定义如下:
1 | struct rlimit { |
resource
的选择有:
1 | RLIMIT_AS //进程的最大虚内存空间,字节为单位。 |
返回值:
- 成功执行时,返回0。失败返回-1,errno被设为以下的某个值
EFAULT
:rlim指针指向的空间不可访问EINVAL
:参数无效EPERM
:增加资源限制值时,权能不允许
6 进程控制
该节对应第八章——进程控制。
6.1 进程标识
每个进程都有一个非负整型表示的唯一进程ID。因为进程ID标识符总是唯一的,常将其用作其他标识符的一部分以保证其唯一性。例如,应用程序有时就把进程 ID 作为名字的一部分来创建一个唯一的文件名。
进程标识符的类型为pid_t
,其本质上是一个无符号整型(unsigned int)的类型别名。
进程ID是可复用的。当一个进程终止后,其进程ID就成为复用的候选者。大多数 UNIX 系统实现延迟复用算法,使得赋予新建进程的 ID不同于最近终止进程所使用的ID。这防止了将新进程误认为是使用同一ID的某个已终止的先前进程。
系统中有一些专用进程,但具体细节随实现而不同。
- ID为0的进程通常是调度进程,常常被称为交换进程(swapper)。该进程是内核的一部分,它并不执行任何硬盘上的程序,因此也被称为系统进程。
- 进程ID1通常是
init
进程,在自举过程结束时由内核调用。该进程的程序文件在UNIX的早期版本中是/etc/init,在较新版本中是/sbin/init。此进程负责在自举内核后启动一个UNIX系统。init
进程决不会终止。它是一个普通的用户进程(与交换进程不同,它不是内核中的系统进程),但是它以超级用户特权运行。
常用系统调用
1 |
|
补充:
ps
命令
Linux 中的 ps 命令是 Process Status
的缩写。ps 命令用来列出系统中当前正在运行的那些进程,就是执行 ps 命令的那个时刻的那些进程的快照。
1 | ps axf |
6.2 进程产生
6.2.1 fork
init
进程:pid为1,是所有进程的祖先进程,注意不是父进程。
一个现有的进程可以调用fork
函数创建一个新进程:
1 |
|
由fork创建的新进程被称为子进程(child process)。
返回值:fork函数被调用一次,但返回两次。子进程的返回值是0,父进程的返回值则是新建子进程的进程PID。如果失败则返回-1
,并设置errno
。和setjmp
类似,fork语句后常常跟上分支语句进行判断。
子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆、栈和缓冲区的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分,除了读时共享的部分。
fork后父子进程的区别
fork返回值不同
两个进程的
pid
不同两个进程的
ppid
也不同,父进程的ppid是它的父进程pid,而子进程的ppid是创建它的进程的pid父进程的未决信号和文件锁不继承
子进程的资源利用量归零
程序实例1——
fork
的使用
1 |
|
运行结果:(可能形式)
1 | [root@HongyiZeng proc]# ./fork1 |
一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。
如果在main程序返回前添加一行:
1 | getchar(); |
使得父子进程都暂停,再使用命令:
1 | ps axf |
可以看到两个进程与bash的关系如下:
程序实例2——
fflush
的重要性
对于上述程序的结果,注意到Begin
只打印了一次:
1 | [root@HongyiZeng proc]# ./fork1 |
如果将该打印信息重定向至某个文件:
1 | ./fork1 > /tmp/out |
再查看该文件的内容:
1 | [root@HongyiZeng proc]# cat /tmp/out |
注意到Begin
打印了两次。
原因:对于重定向至文件,采用的是全缓冲(除标准输出和标准错误输出),只有进程结束或者缓冲满的时候才刷新缓冲区(遇到换行符不刷新),将缓冲区的内容写入到文件。因此,父进程fork
时,尚未刷新缓冲区,因此缓冲区的内容[18060]:Begin!
(注意进程号已经固定了!)被复制到子进程的缓冲区中,当父子进程执行结束时,强制刷新,输出两次Begin
。
为防止缓冲区内容被复制,父进程在fork
之前需要强制刷新所有已经打开的流:
1 | int main(void) { |
此时,只打印了一句Begin
:
1 | [root@HongyiZeng proc]# ./fork1 > /tmp/out |
程序实例3——找质数
需求:找出30000000~30000200
的所有质数。
- 单进程版:
1 |
|
打印结果:
1 | [root@HongyiZeng proc]# time ./primer0 |
- 多进程协同:
一个错误的程序:
1 |
|
分析:子进程执行完pid==0
的分支后,又会执行for
循环的部分,此时会再次fork
,导致进程数量指数式的增长,超出可用内存。
更正:在执行完pid==0
的分支后面(完成了对某个数i
的判断的任务),需要正常退出exit(0)
:
1 |
|
执行结果:
1 | [root@HongyiZeng proc]# time ./primer1 |
程序实例4——孤儿进程和僵尸进程
修改1:在子进程在退出前,先睡眠1000s,这样父进程会先执行完毕而退出。
1 | int main(void) { |
再使用命令ps axf
查看:
此时201个子进程的状态为S
(可中断的睡眠状态),且父进程为init
进程(每个进程以顶格形式出现)。这里的子进程在init
进程接管之前就是孤儿进程。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init
进程(进程号为1)所收养,并由 init 进程对它们完成状态收集工作,孤儿进程并不会有什么危害。
修改2:在父进程退出之前,先休眠1000s,再查看进程状态。
1 | int main(void) { |
执行结果:
可以看到子进程状态为Z
,即为僵尸状态。
僵尸进程:一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息(收尸),那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵尸进程。
僵尸进程虽然不占有任何内存空间,但如果父进程不调用 wait() / waitpid() 的话,那么保留的信息就不会释放,其进程号就会一直被占用,而系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害。
6.2.2 vfork
考虑这样一个场景,父进程使用了一个占用内存很大的数据,此时它fork了一个子进程,而子进程仅仅打印一个字符串就退出了,此时这块很大的数据复制到子进程的内存空间中,造成了很大的内存浪费。
为了解决这个问题,在fork
实现中,增加了读时共享,写时复制(Copy-On-Write,COW)的机制。写时复制可以避免拷贝大量根本就不会使用的数据(地址空间包含的数据多达数十兆)。因此可以看出写时复制极大提升了Linux系统下fork函数运行的性能。
写时复制指的是子进程的页表项指向与父进程相同的物理页,这也只需要拷贝父进程的页表项就可以了,不会复制整个内存地址空间,同时把这些页表项标记为只读。
读时共享:如果父子进行都不对页面进行操作或只读,那么便一直共享同一份物理页面。
写时复制:只要父子进程有一个尝试进行修改某一个页面(写时),那么就会发生缺页异常。那么内核便会为该页面创建一个新的物理页面,并将内容复制到新的物理页面中,让父子进程真正地各自拥有自己的物理内存页面,并将页表中相应地页表项标记为可写。
写时复制父子进程修改某一个页面前后变化如下图所示:
在fork还没实现copy on write
之前。Unix设计者很关心fork之后立刻执行exec所造成的地址空间浪费,所以引入了vfork
系统调用。而现在vfork
已经不常用了。
- vfork和fork的区别/联系:vfork函数和 fork函数一样都是在已有的进程中创建一个新的进程,但它们创建的子进程是有区别的。
- 父子进程的执行顺序
- fork: 父子进程的执行次序不确定。
- vfork:保证子进程先运行,在它调用
exec/exit
之后,父进程才执行
- 是否拷贝父进程的地址空间
- fork: 子进程写时拷贝父进程的地址空间,子进程是父进程的一个复制
- vfork:子进程共享父进程的地址空间
- 调用vfork函数,是为了执行exec函数;如果子进程没有调用 exec/exit,程序会出错
代码示例
1 | int main(int argc, char *argv[]){ |
执行结果:已经让子进程延时 3 s,结果还是子进程运行结束后,父进程才执行
1 | static int a = 10; |
执行结果:子进程先执行,修改完a,b的值后,由于父子进程共享内存空间,因此会影响父进程
1 | son: a = 100, b = 200 |
如果采用fork
的话,会有写时复制,此时父子进程的变量无关:
1 |
|
执行结果:
1 | [root@HongyiZeng proc]# ./vfork |
6.3 wait和waitpid
wait
系统调用:等待进程改变状态。
进程一旦调用了wait,就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。
wait
函数原型如下:
1 |
|
status
用来保存子进程退出时的一些状态。如果不在意子进程的退出状态,可以设定status
为NULL
。如果参数status
的值不是NULL
,wait
就会把子进程退出时的状态取出,并存入其中。可以使用下列的宏函数来处理status
:WIFEXITED(status)
:用来指出子进程是否为正常退出,如果是,则会返回一个非零值。WEXITSTATUS(status)
:当WIFEXITED
返回非零值时,可以用这个宏来提取子进程的返回值。
- 如果执行成功,
wait
会返回子进程的PID
;如果没有子进程,则wait
返回-1
。
代码示例1
1 |
|
打印结果:
1 | This is child process with pid of 298 |
程序实例2
1 |
|
打印结果:
1 | This is child process with pid of 308 |
waitpid
函数原型如下:
1 |
|
从本质上讲,waitpid
和wait
的作用是完全相同的,但waitpid
多出了两个可以由用户控制的参数pid
和options
:
pid
:当pid
取不同的值时,在这里有不同的意义:
取值 | 意义 |
---|---|
> 0 |
只等待进程ID 等于pid 的子进程 |
=-1 |
等待任何一个子进程退出,此时waitpid 和wait 的作用一模一样 |
= 0 |
等待同一个进程组process group id 中的任何子进程 |
<-1 |
等待一个指定进程组中的任何子进程,这个进程组的ID 等于pid 的绝对值 |
options
:是一个位图,可以通过按位或来设置,如果不设置则置为0即可。最常用的选项是WNOHANG
,作用是即使没有子进程退出,它也会立即返回,此时waitpid
不同于wait
,它变成了非阻塞的函数。waitpid
的返回值有如下几种情况:- 当正常返回时,
waitpid
返回子进程的PID
。 - 如果设置了
WNOHANG
,而waitpid
没有发现已经退出的子进程,则返回0
。 - 如果
waitpid
出错,则返回-1
。例如参数pid
指示的子进程不存在,或此进程存在,但不是调用进程的子进程。
- 当正常返回时,
代码示例1
1 |
|
执行结果:省略了没有打印质数的输出
1 | [32444]:30000023 is a primer. |
6.4 exec函数族
6.4.1 简介
fork函数是用于创建一个子进程,该子进程几乎是父进程的副本,而有时我们希望子进程去执行另外的程序,exec函数族就提供了一个在进程中启动另一个程序执行的方法。它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段,在执行完之后,原调用进程的内容除了进程号外,其他全部被新程序的内容替换了。这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行脚本文件。
当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。
为什么需要
exec
函数
- fork子进程是为了执行新程序(fork创建了子进程后,子进程和父进程同时被OS调度执行,因此子进程可以单独的执行一个程序,这个程序宏观上将会和父进程程序同时进行);
- 可以直接在子进程的
if
中写入新程序的代码(参见6.2.1节
的做法)。这样可以,但是不够灵活,因为我们只能把子进程程序的源代码贴过来执行(必须知道源代码,而且源代码太长了也不好控制),譬如说我们希望子进程来执行ls -la
命令就不行了(没有源代码,只有编译好的可执行程序/usr/bin/ls
); - 使用exec族运行新的可执行程序(exec族函数可以直接把一个编译好的可执行程序直接加载运行);
- 我们有了exec族函数后,典型的父子进程程序是这样的:子进程需要运行的程序被单独编写、单独编译连接成一个可执行程序(叫
hello
),(项目是一个多进程项目)主程序为父进程,fork创建了子进程后在子进程中exec
来执行hello,达到父子进程分别做不同程序同时(宏观上)运行的效果;
6.4.2 使用
有多种不同的exec函数可供使用,它们常常被统称为exec函数。
1 |
|
- 以上函数成功执行时不返回,失败时返回
-1
并设值errno
后缀含义
l
:以list
形式传入参数v
:以vector
形式传入参数p
:在$PATH
中查找可执行程序e
:在envp[]
中查找可执行程序
execl
和execv
:这两个函数是最基本的exec,都可以用来执行一个程序,区别是传参的格式不同:- execl是把参数列表(本质上是多个字符串,必须以
NULL
结尾)依次排列而成 - execv是把参数列表事先放入一个字符串数组中(必须以
NULL
结尾),再把这个字符串数组传给execv函数,类似于char **argv
path
:完整的文件目录路径
- execl是把参数列表(本质上是多个字符串,必须以
execlp
和execvp
:这两个函数在上面2个基础上加了p
file
:文件名,系统就会自动从环境变量$PATH
所指出的路径中进行查找该文件。如果包含/
,则视为路径名path
。
execle
和execvpe
:这两个函数较基本exec来说加了e
envp
:自己指定的环境变量。在envp[]
中指定当前进程所使用的环境变量替换掉该进程继承的环境变量$PATH
。
代码示例——环境变量
myexec.c
1 |
|
hello.c
1 |
|
编译链接为hello
执行结果:
1 | [root@HongyiZeng proc]# ./myexec |
代码示例——程序名称
补充:argv
第一个参数为程序名称,后面的参数为命令行参数。程序名称可以任意设置,一般来说,如果一个源码文件的名称为XXX.c
,则编译生成的可执行程序为XXX
,此时运行,程序名称(argv[0]
)就是XXX
使用gcc默认编译链接得到的可执行文件名称为a.out
,此时程序名称(argv[0]
)就是a.out
。
使用exec
族函数实现date +%s
命令打印时间戳的功能。
1 | [root@HongyiZeng proc]# date +%s |
这里的参数依次是程序名
,+%s
,NULL
,注意第一个参数代表的是程序的名称,可以任意设置,类似于argv[0]
,之后的参数才是重要的命令行参数。
代码实现:
1 |
|
或者使用execv
:
1 | int main(void) { |
执行结果:
1 | [root@HongyiZeng proc]# ./ex |
为什么不打印End!
:执行exec
后,原进程映像被替换成新的进程映像(即/bin/date
程序),从main函数开始执行/bin/date
的代码了。
我不再是我,我已成新的我。
让子进程睡眠1000s:
1 |
|
执行后查看:
1 | ps axf |
这里子进程运行时执行的是sleep
程序,但是程序名称却被设置成了httpd
,这实际上是一种低级的木马程序隐藏的办法。
代码示例——刷新缓冲区的重要性
在讲fork
的时候提到过,在fork
之前,最好将强制刷新所有已经打开的流,这里的exec
也不例外,例如使用上面的程序,将结果重定向到/tmp/out
中:
1 | [root@HongyiZeng proc]# ./ex > /tmp/out |
发现Begin!
不见了,原因就在于重定向是全缓冲,当执行完puts("Begin!")
后,该进程的缓冲区内容为Begin!\n
,并不刷新到文件中,此时执行exec
后,进程映像被替换成新的进程映像(即/bin/date
程序),除了原进程的进程号外,其他全部(包括缓冲区)被新程序的内容替换了,之后新程序的缓冲区内容为时间戳,程序结束后,强制刷新到文件。
因此需要在执行exec
之前强制刷新所有打开的流:
1 | int main(void) { |
再次执行:
1 | [root@HongyiZeng proc]# ./ex > /tmp/out |
代码示例——fork,exec和wait结合使用
1 |
|
执行结果:
1 | [17301]Begin! // 父进程打印 |
至此,UNIX系统进程控制原语更加完善。用fork可以创建新进程,用exec可以初始执行新的程序。exit函数和wait函数处理终止和等待终止。这些是我们需要的基本的进程控制原语。
6.5 shell外部命令实现
shell外部命令:在磁盘上能够看得到的命令。此外都是内部命令。
之前在终端上执行primer1.c
时,出现下列情况:
1 | [root@HongyiZeng proc]# ./primer1 |
发现终端先于子程序打印。
原因:在终端上执行primer1
时,父进程(终端,即shell)fork了一个子进程,然后exec了primer1
程序,并且wait
到primer1
退出,所以当primer1
退出时,就立刻出现了终端,此时primer1
fork的子进程还在运行打印结果,所以出现了终端先于子进程的结果出现。
一般的,当shell执行某个程序时,首先fork
一个子进程,然后该子进程exec
那个执行程序,shell此时wait
该程序退出exit
。
shell伪代码示例
1 | int main(void) { |
代码实现
1 |
|
程序分析:
strsep
函数原型:
1 |
|
strsep
实现字符串的分割,把stringp
里面出现的delim
替换成'\0'
,后将 stringp
更新指向到'\0'
符号的下一个字符地址,函数的返回值指向原来的 stringp
位置。直到分割完毕返回NULL
。
代码执行流程分析:
例如:
1 | [root@HongyiZeng proc]# ./mysh |
getline
得到字符串ls -l
parse
解析该字符串,将分割结果存在globres
中,其中:1
2
3globres.gl_pathv[0] = "ls";
globres.gl_pathv[1] = "-l";
globres.gl_pathv[2] = NULL;子进程
execvp(cmd.globres.gl_pathv[0], cmd.globres.gl_pathv);
- 第一个参数为要执行的可执行程序的名字,为
ls
,从环境变量PATH
中找到/usr/bin/
路径下的ls
程序 - 第二个参数为指针数组,为
ls
和-l
,第一个为程序名,任意,第二个和后面的为命令的参数,重要,这里的参数为-l
- 第一个参数为要执行的可执行程序的名字,为
6.6 用户权限和组权限
在UNIX系统中,特权(如能改变当前日期的表示法)以及访问控制(如能否读、写一个特定文件),是基于用户ID和组ID的。当程序需要增加特权,或需要访问当前并不允许访问的资源时,我们需要更换自己的用户ID或组ID,使得新ID具有合适的特权或访问权限。与此类似,当程序需要降低其特权或阻止对某些资源的访问时,也需要更换用户ID或组ID,新ID不具有相应特权或访问这些资源的能力。
6.6.1 UID和GID
Linux采用一个32位的整数记录和区分不同的用户。这个区分不同用户的数字被称为User ID,简称UID
。Linux系统中用户分为3类,即普通用户、根用户root、系统用户。
- 普通用户是指所有使用Linux系统的真实用户,通常
UID>500
; - 根用户即root用户,ID为0。
- 系统用户是指系统运行必须有的用户,但并不是真实使用者。UID为
1~499
。对于系统用户,可能还不能理解是什么。比如,在Redhat或CentOS下运行网站服务时,需要使用系统用户Apache来运行httpd,而运行MySQL数据库服务时,需要使用系统用户mysql来运行mysqld进程。这就是系统用户。
可以使用id [用户名]
命令查看uid,gid和组名:
1 | [root@HongyiZeng proc]# id |
要确认自己所属的用户组,可以使用groups
命令:
1 | [root@HongyiZeng proc]# groups |
系统用来记录用户名、密码的最重要两个文件是/etc/passwd
和/etc/shadow
,详见4.1.2节
。
6.6.2 SUID和SGID
内核为每个进程维护的三个UID值(和三个GID值,是对应关系,略),这三个UID分别是:
RUID
:(Real UID,实际用户ID),我们当前以哪个用户登录,我们运行程序产生进程的RUID就是这个用户的UID。EUID
:(Effective UID,有效用户ID),指当前进程实际以哪个UID来运行。一般情况下EUID等于RUID;但如果进程对应的可执行文件具有SUID权限(也就是rws的s
),那么进程的EUID是可执行文件的所有者的UID,鉴权看的就是这个ID。SUID
:(Saved Set-user-ID,保存的设置用户ID),EUID的一个副本,与SUID权限有关。
特殊权限
文件和目录权限除了普通权限rwx
外,还有三个特殊权限:
SUID
:在属主的x
位以s
标识,全称SetUID
SGID
:在属组的x
位以s
标识,全称SetGID
STIKCY
:黏着位,详见4.2.4.③节
1 | [root@HongyiZeng proc]# ll /usr/bin/passwd |
上面第4位的s
就是特殊权限SUID
。
下面将由五个问题来说明什么是SUID
:
1 | # 1.普通用户可不可以修改密码? |
从进程控制的角度来说,当非root用户执行passwd这个可执行文件的时候,产生的进程的EUID,就是root用户的UID。换言之,这种情况下,产生的进程,实际以root用户的ID来运行二进制文件。
相关命令
1 | chmod u+s 文件名/目录名 # 对文件给予用户s权限,则此用户暂时获得这个文件的属主权限 |
从进程控制的角度看命令的执行
- UNIX系统产生的第一个进程是
init
进程,其三个uid为root的uid,即res
为0 0 0
,以init(0 0 0)
表示; init
进程fork和exec产生getty(0 0 0)
进程,此进程等待用户输入用户名;- 用户回车输入了用户名后,
getty
进程存储用户名,exec产生login(0 0 0)
进程,等待用户输入密码并验证口令(查找用户名和密码/etc/passwd
);- 如果验证成功,
login
进程则fork并exec产生shell(r e s)
进程,即终端,此时的res
就是登录用户的UID,即固定了用户产生的进程的身份; - 如果验证失败,则返回继续验证;
- 如果验证成功,
- 当用户执行某个命令时,
shell
进程fork并exec该命令对应的程序,例如ls(r e s)
,并wait该程序,ls
进程退出时,又返回到shell(r e s)
终端(因为shell是一个死循环,参见6.5节
); - 可以看出,整个UNIX的世界就是由fork,exec,wait和exit的进程控制原语搭建起来的
整个过程的图示如下:
又如执行passwd
命令时图如下,变化的只有EUID
和SUID
:
6.6.3 相关系统调用
下面的系统调用是特殊权限实现所需的函数。
- 获取:
1 |
|
- 设置:
1 |
|
- 交换:
1 |
|
代码示例
实现任意用户用0号用户
(即root)的身份查看/etc/shadow
文件的功能:
1 | ./mysu 0 cat /etc/shadow |
exec参数:
1 | cat -> main的argv[2]:所需要执行的程序; |
代码实现:(以普通用户lighthouse
编译链接)
1 |
|
查看该文件的属性:
1 | [lighthouse@HongyiZeng proc]$ ll mysu |
直接运行,权限不够:
1 | [lighthouse@HongyiZeng proc]$ ./mysu 0 cat /etc/shadow |
切换到root
用户,将mysu
属主更改为root
,并给予该文件s
权限:
1 | [root@HongyiZeng proc]# chown root mysu |
然后切换到lighthouse
,再执行即可:
6.7 解释器文件
解释器文件也叫脚本文件。脚本文件包括:shell脚本,python脚本等;
脚本文件的后缀可自定义,一般来说shell脚本的后缀名为.sh
,python脚本的后缀名为.py
。
解释器文件的执行过程:当在linux系统的shell命令行上执行一个可执行文件时,系统会fork
一个子进程,在子进程中内核会首先将该文件当做是二进制机器文件来执行,但是内核发现该文件不是机器文件(看到第一行为#!
)后就会返回一个错误信息,收到错误信息后进程会将该文件看做是一个解释器文件,然后扫描该文件的第一行,获取解释器程序(本质上就是可执行文件)的名字,然后执行exec
该解释器,并将该解释器文件当做解释器的一个参数,然后开始由解释器程序扫描整个解释器文件,执行每条语句(如果指定解释器为shell
,会跳过第一条语句,因为#
是注释)。如果其中某条命令执行失败了也不会影响后续命令的执行。
解释器文件的格式:
1 | !pathname [optional-argument] |
pathname
:一般是绝对路径(它不会使用$PATH
做路径搜索),对这个文件识别是由内核做为exec
系统调用处理的。optional-argument
:相当于提供给exec
的参数
内核exec执行的并不是解释器文件,而是第一行pathname指定的文件。一定要将解释器文件(本质是一个文本文件,以 #!
开头)和解释器(由pathname指定)区分开。
代码示例1
以普通用户创建脚本test.sh
:
1 | !/bin/bash |
这个文件没有执行权限,需要添加:
1 | [lighthouse@HongyiZeng proc]$ ll test.sh |
shell执行./test.sh
时,fork了一个子进程,该进程看到该文件为解释器文件,于是读取第一行,得到解释器程序的PATH,并exec
该解释器程序(/bin/bash
),然后重新执行这个解释器文件。
可以看出bash
跳过了第一句,因为#
在bash程序中被看成了注释,cat
命令没有权限,但后面的ps
命令仍然继续执行。
代码示例2
1 | !/bin/cat |
执行该脚本:
1 | [root@HongyiZeng proc]# ./test.sh |
发现这次是打印了该脚本文件的所有内容。过程同上,只是这次子进程exec
的程序为/bin/cat
程序。
代码示例3——自定义解释器程序
解释器程序(或解释器)本质上就是一个可执行文件。
echoarg.c
1 |
|
编译为echoarg
,并存放在/usr/local/linux_c/proc/
下。
echoarg.sh
1 | !/usr/local/linux_c/proc/echoarg foo1 foo2 foo3 |
执行结果:
1 | [root@HongyiZeng proc]# ./echoarg.sh |
6.8 system函数
函数原型:
1 |
|
作用:该函数实际上调用的是/bin/sh -c command
,实质上是对fork+exec+wait
的封装。
程序实例
1 |
|
该程序实质上执行的命令为:
1 | /bin/sh -c date +%s > /tmp/out |
在执行该命令的时候,system函数代码类似于:
1 |
|
6.9 守护进程
暂略
6.10 系统日志
暂略
7 信号
该节对应第十章——信号。
7.1 前置概念
并发和并行
- 并发(
concurrency
):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。 - 并行(
parallelism
):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。
同步和异步
- 进程同步:按照一定的顺序协同进行(有序进行),而不是同时。即一组进程为了协调其推进速度,在某些地方需要相互等待或者唤醒,这种进程间的相互制约就被称作是进程同步。这种合作现象在操作系统和并发式编程中属于经常性事件。
- 例如,在主线程中,开启另一个线程。另一个线程去读取文件,主线程等待该线程读取完毕,那么主线程与该线程就有了同步关系。
- 进程异步:当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。
异步事件的处理:查询法(发生频率高)和通知法(发生频率低)
7.1 信号的概念
信号是软件层面的中断,是进程之间相互传递消息的一种方法,信号全称为软中断信号,也有人称作软中断,从它的命名可以看出,它的实质和使用很像中断。
进程之间可以通过调用kill
库函数发送软中断信号。Linux内核也可能给进程发送信号,通知进程发生了某个事件(例如内存越界)。
每个信号都有一个名字。这些名字都以3个字符SIG
开头。头文件<signal.h>
中,信号名都被定义为正整数常量(信号编号)。
通过命令kill -l
可以列出所有可用信号:
信号值 1 ~ 31 为不可靠信号(标准信号),信号会丢失;信号值 34 ~ 64 为可靠信号(实时信号),信号不会丢失。
1 | [root@HongyiZeng signal]# kill -l |
信号名 | 信号值 | 发出信号的原因 |
---|---|---|
SIGHUP | 1 | 终端挂起或者控制进程终止 |
SIGINT | 2 | 键盘中断Ctrl+c |
SIGQUIT | 3 | 键盘的退出键被按下 |
SIGILL | 4 | 非法指令 |
SIGABRT | 6 | 由abort(3)发出的退出指令 |
SIGFPE | 8 | 浮点异常 |
SIGKILL | 9 | 采用kill -9 进程编号 强制杀死程序。 |
SIGSEGV | 11 | 无效的内存引用 |
SIGPIPE | 13 | 管道破裂:写一个没有读端口的管道 |
SIGALRM | 14 | 由alarm(2)发出的信号 |
SIGTERM | 15 | 采用“kill 进程编号”或“killall 程序名”通知程序。 |
SIGUSR1 | 30,10,16 | 用户自定义信号1 |
SIGUSR2 | 31,12,17 | 用户自定义信号2 |
SIGCHLD | 20,17,18 | 子进程结束信号 |
SIGCONT | 19,18,25 | 进程继续(曾被停止的进程) |
SIGSTOP | 17,19,23 | 终止进程 |
SIGTSTP | 18,20,24 | 控制终端(tty)上按下停止键 |
SIGTTIN | 21,21,26 | 后台进程企图从控制终端读 |
SIGTTOU | 22,22,27 | 后台进程企图从控制终端写 |
信号是异步事件的经典实例。产生信号的事件对进程而言是随机出现的。进程不能简单地测试一个变量(如 errno)来判断是否发生了一个信号,而是必须告诉内核”在此信号发生时,请执行下列操作”。
当某个信号出现时,可以告诉内核按下列三种方式之一进行处理,称之为信号的处理或与信号相关的工作:
- 忽略此信号
- 捕捉信号
- 执行系统默认工作
下图列出了所有信号的名字,说明了哪些系统支持此信号和信号对应的系统默认工作。可以看出C标准库支持的信号是最少的。大部分的信号的默认操作是终止进程。
core
是在进程当前工作目录的core文件中复制了该进程的内存映像,core文件记录了进程终止时的错误报告,大多数UNIX系统调试程序都使用core文件检查进程终止时的状态。
7.3 signal函数
补充:
typedef
的用法总结
- 定义一种类型的别名:基本用法
1 | typedef char* PCHAR; |
1 | typedef struct tagPOINT { |
- 定义与平台无关的类型:当跨平台时,只要改下 typedef 本身就行,不用对其他源码做任何修改。标准库就广泛使用了这个技巧,比如
size_t
,pid_t
。另外,因为typedef是定义了一种类型的新别名,不是简单的字符串替换,所以它比宏更稳健。
1 | typedef long double REAL; // 平台1 |
- 为复杂声明定义一个新的简单的别名。
例如:
1 | // 相当于给返回值为int,形参为int的这一类函数起了一个别名FUNC |
1 | // 给返回值为int*,形参为int的函数(指针函数:返回指针的函数,本质是函数;函数指针:指向函数的指针,本质是指针)起了一个别名FUNCP,本质是一个函数 |
1 | // 给指向返回值为int*,形参为int的指针函数的指针其一个别名FUNCP,本质是一个指针 |
又如:
1 | // 原始声明,该声明本质上是一个函数指针 |
UNIX系统信号机制最简单的接口是signal
函数,函数原型如下:
1 | // CONFORMING TO C89, C99, POSIX.1-2001. |
signum
参数是上图中的信号名,常用宏名来表示,例如SIGINT
func
参数是下面的一种:SIG_IGN
:向内核忽略此信号,除了SIGKILL
和SIGSTOP
SIG_DFL
:执行系统默认动作- 当接到此信号后要调用的函数的地址:在信号发生时,调用该函数;称这种处理为捕捉该信号,称此函数为信号处理程序或信号捕捉函数
- 作用:
signal
函数为signum
所代表的信号设置一个信号处理程序func
,换句话说,signal
就是一个注册函数。
代码示例1
1 |
|
信号SIGINT
产生的方式就是快捷键CTRL + C
。
代码示例2
1 |
|
每按1次CTRL + C
,终端就打印1个!
代码示例3——阻塞和非阻塞
上述程序,如果一直按着CTRL + C
,程序会小于10S就会结束。
原因在于:信号会打断阻塞的系统调用。这里的阻塞是write
和sleep
函数。
分析:进程运行到sleep(1)
的时候,由运行态进入阻塞态,此时如果有信号到来,例如SIGINT
,会打断阻塞(唤醒进程),让进程进入就绪态,获得时间片进入运行态,此时进程还没阻塞到1s,就进入了就绪态,即信号会打断阻塞的系统调用。
- 阻塞:为了完成一个功能,发起一个调用,如果不具备条件的话则一直等待,直到具备条件则完成
- 非阻塞:为了完成一个功能,发起一个调用,具备条件直接输出,不具备条件直接报错返回
此前学习过的所有IO函数,都是阻塞IO,即阻塞的系统调用。
以open
为例,进程调用open
时,进入阻塞态,等待IO设备打开,如果IO设备打开时间过长,此时有一个信号到来,就会打断open
调用,使其打开设备失败。
因此,在设备打开失败的时候,需要判断是因为open
自身引发的错误,还是因为信号打断而没有打开,对于前者,以以往的方式处理错误,而对于后者应该尝试再次打开设备,而不是报错后退出程序。
注意:对于所有的阻塞系统调用,都要处理是因为自身调用出现的真错,还是因为信号中断导致的假错。
在宏中,有一个名为EINTR
的errno
,即为被信号中断而引发的错误。当进程在执行一个阻塞的系统调用时捕捉到一个信号,则被中断不再执行该系统调用,该系统调用返回错误,errno
就会被设置为EINTR
。
以前面的一个程序为例,修改后的代码为:
1 |
|
7.4 不可靠的信号
信号处理程序由内核调用,在执行该程序时,内核为该处理程序布置现场,此时如果又来一个信号,内核再次调用信号处理程序,可能会冲掉第一次调用布置的现场。
7.5 可重入函数
可重入函数(即可以被中断的函数)可以被一个以上的任务调用,而不担心数据破坏。可重入函数在任何时候都可以被中断,而一段时间之后又可以恢复运行,而相应的数据不会破坏或者丢失。
不可重入的函数由于使用了一些系统资源,比如全局变量区,中断向量表等,所以它如果被中断的话,可能会出现问题,这类函数是不能运行在多任务环境下的。I/O代码通常不是可重入的,因为它们依赖于像磁盘这样共享的,单独的资源。
所有的系统调用都是可重入的,一部分库函数也是可重入的,例如memcpy
。
函数不可重入的条件
- 函数内使用了静态的数据。
- 函数内使用了malloc或者free函数
- 函数内调用了标准的I/O函数
例如:
1 | int temp; |
分析:该函数中的全局变量temp是的函数变成了一个不可重入的函数,因为在多任务系统中,假如在任务1中调用swap函数,而程序执行到(1)处时被中断,进而执行其它的任务2,而刚好任务2也调用了swap函数,则temp里存的值则会被任务2改变。从而回到任务1被中断处继续执行的时候,temp里存的值已经不再是原来存的temp值了,进而产生了错误。
使得函数可重入的方法
- 不要使用全局变量,防止别的代码覆盖这些变量的值。
- 调用这类函数之前先关掉中断,调用完之后马上打开中断。防止函数执行期间被中断进入别的任务执行。
- 使用信号量(互斥条件)
7.6 信号的响应过程
补充:用户态和内核态的切换
Linux系统中的内核态本质是内核,是一种特殊的软件程序,用于控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。0-4G范围的虚拟空间地址都可以操作,尤其是对3-4G范围的高位虚拟空间地址必须由内核态去操作。
用户态提供应用程序运行的空间,为了使应用程序访问到内核管理的资源,例如CPU,内存,I/O等。用户态只能受限的访问内存,且不允许访问外设(硬盘、网卡等);内核态CPU可以访问内存所有数据,包括外设,且可以将自己从一个程序切换到另一个程序。
用户态切换到内核态的三种方式:
- 发生系统调用时:(主动)这是处于用户态的进程主动请求切换到内核态的一种方式。用户态的进程通过系统调用申请使用操作系统提供的系统调用服务例程来处理任务。而系统调用的机制,其核心仍是使用了操作系统为用户特别开发的一个中断机制来实现的,即软中断。
- 产生异常时:(被动)当CPU执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行的进程切换到处理此异常的内核相关的程序中,也就是转到了内核态,如缺页异常。
- 外设产生中断时:(被动)当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作的完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
信号在内核中的表示
- 信号递达(
Delivery
):实际执行信号处理的动作。 - 信号未决(
Pending
):信号从产生到递达之间的状态。 - 信号阻塞(
Block
):被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
有如下结论:
- 信号从收到到响应有一个不可避免的延迟。
- 标准信号的响应没有严格顺序。
内核为每个进程都维护了两个位图,一般为32位,两个位图进行与操作:
- 信号屏蔽字 mask :用来屏蔽信号,mask初始值一般全部都是1,表示不屏蔽全部信号
- 位图 pending: 用来记录当前进程收到哪些信号,一般初始值全部都是0,表示没有收到信号,未决信号集。
信号响应过程
以打印*
和!
的程序为例。
- 在没有其他中断的情况下,当时间片耗尽时,进程被计时器中断,使得该进程被切换至内核态,在内核态中运行计时器中断的处理函数(
handler
),进程调度算法就发生在计时器中断的处理函数中(在这个处理函数中,系统会根据任务调度算法,从就绪队列里选择下一个运行的进程)。该进程等待调度。- 进程当前的状态和信息被压入到内核栈中,其中包括程序的返回地址。
- 进程被内核调度时,进程从内核态切换至用户态,从内核栈中保存的返回地址继续运行
- 进程从内核态切换至用户态时(也只有在这个时刻),会执行
mask & pending
,确认自己是否收到信号。对于这种情况,按位与结果为每位都为0,表示进程没有收到信号。
- 当进程在某一刻收到
SIGINT
信号,此时将pending
第2位置为1,表示收到SIGINT
信号,但此时进程并不会被该信号中断(即响应该信号)。当时间片耗尽,一直到被调度,进程从内核态切换至用户态,执行mask & pending
时,发现只有第2位结果为1,此时进程才响应该信号。因此说,信号从收到到响应有一个不可避免的延迟。- 响应信号时的操作:内核将该位的
mask
和pending
都置为0,并将程序返回地址修改为int_handler
即信号响应程序的地址,此时在用户态执行信号响应程序。换句话说,信号的收到的时刻是随机的,而信号的响应总是在从内核态切换到用户态时进行的; - 信号响应程序执行完毕后,从用户态切换至内核态,内核将返回地址修改为程序的返回地址,并将
mask
的该位置为1; - 再次被调度到时,切换至用户态,执行
mask & pending
,发现均为0,说明刚才的信号已经被响应了,则继续向下执行程序;
- 响应信号时的操作:内核将该位的
如何忽略一个信号?
注意到func
(信号处理程序)可以为宏SIG_ING
,实质上是将mask
的位置为0,表示屏蔽该信号。
注意:我们能做的是不响应信号,即不对信号做处理(屏蔽),而不是阻止信号产生和到来。信号的随时都会到来,将pending
置为1。
标准信号为什么不可靠,或者说标准信号为什么会丢失?
信号响应时,位图为(m0 p0)
,此时又来多个信号,则p
被反复置为1,即结果总是为(m0 p1)
;信号响应完毕时,将m
置为1,此时为(m1 p1)
;返回用户态时,发现该位仍然为1,说明又来一个信号(注意不是多个),则继续响应…所以多个信号到来时,只有最后一个信号能够被响应,前面的信号都被丢失了。
总结:mask和pending的变化情况
1 | mask pending |
7.7 常用函数Ⅰ
7.7.1 kill
kill函数用于向进程发送信号,注意不是杀死进程。
1 |
|
参数:
- pid:向哪个进程发送信号
pid > 0
:发送信号给指定进程pid = 0
:发送信号给跟调用kill函数的那个进程处于同一进程组的进程,相当于组内广播。pid < -1
:发送信号给该绝对值所对应的进程组的所有组员,相当于组内广播。pid = -1
:发送信号给所有权限发送的所有进程。
sig
:待发送的信号sig = 0
:没有信号发送,但会返回-1并设置errno
,用来检测某个进程id或进程组id是否存在。注意返回-1时并不能表明该id不存在,而是要根据errno
来判断,详见下面的返回值。
返回值:
- 成功返回0
- 失败返回-1,并设值errno
EINVAL
:无效的信号sig
EPERM
:调用进程没有权限给pid
的进程发送信号ESRCH
:进程或进程组不存在
7.7.2 raise
raise函数用于向调用进程发送信号,即自己给自己发送信号。
1 |
|
7.7.3 alarm
alarm函数
1 |
|
作用:设置定时器(闹钟)。在指定seconds后,内核会给当前进程发送SIGALRM
信号(定时器超时)。进程收到该信号,默认动作终止。每个进程都有且只有唯一的一个定时器,所以多个alarm函数共同调用时,后面设置的时钟会覆盖掉前面的时钟。
返回值:返回0或剩余的秒数,无失败。
代码示例
1 |
|
执行结果:
1 | [root@HongyiZeng signal]# ./alarm |
7.7.4 pause
pause函数用于等待信号。
1 |
|
进程调用pause函数时,会造成进程主动挂起(处于阻塞状态,并主动放弃CPU),并且等待信号将其唤醒。
代码示例
1 |
|
当调用到pause()
时,该进程挂起,此时不再占用CPU,5s过后,接收到SIGALRM
信号,采取默认动作终止。
信号的处理方式有三种:
- 默认动作
- 忽略处理
- 捕捉
进程收到一个信号后,会先处理响应信号,再唤醒pause函数。于是有下面几种情况:
- 如果信号的默认处理动作是终止进程,则进程将被终止,也就是说一收到信号进程就终止了,pause函数根本就没有机会返回,例如上面的例子
- 如果信号的默认处理动作是忽略,则进程将直接忽略该信号,相当于没收到这个信号,进程继续处于挂起状态,pause函数不返回
- 如果信号的处理动作是捕捉,则进程调用完信号处理函数之后,pause返回-1,errno设置为EINTR,表示“被信号中断”
- pause收到的信号不能被屏蔽,如果被屏蔽,那么pause就不能被唤醒
sleep = alarm + pause
代码示例
需求:让程序等待5s
- 使用
time
1 |
|
执行结果:
1 | [root@HongyiZeng signal]# time ./pause > /tmp/out |
- 使用
alarm
1 |
|
运行结果:
1 | [root@HongyiZeng signal]# time ./5sec_sig > /tmp/out |
当对上述程序进行编译优化时:
1 | gcc 5sec_sig.c -O1 |
再次执行,发现程序一直在运行。
原因:编译优化时,gcc看到下面的循环体中没有出现loop
,所以gcc认为loop是不变的,将loop的值存储在高速缓存(CPU的寄存器)中,每次读取loop的值时,从高速缓存中读取,而不是在loop实际存放的内存地址中读取,因此一直在此处循环。
1 | while(loop) |
解决方法:用volatile
修饰loop
,此时编译器认为该变量易变,每次读取时从实际内存地址中读取:
1 | static volatile int loop = 1; |
从汇编的角度来看:
1 | gcc -S 5sec_sig.c -O1 |
未加易变修饰前:(省略不重要的代码)
1 | main: |
加上易变修饰后:
1 | main: |
7.7.5 漏桶
leaky bucket
也叫漏桶,就是将请求先放到一个桶中进行排队,然后按一个固定的速率来处理请求,即所谓的漏出。
桶具有一定的容量,即最大容量的请求数,当排队请求的数量超过桶的容量时,再进来的请求就直接过滤掉,不再被处理。换句话说就是请求先在桶中排队,系统或服务只以一个恒定的速度从桶中将请求取出进行处理。如果排队的请求数量超过桶能够容纳的最大数量,即桶装满了,则直接丢弃。
漏桶算法(Leaky Bucket)是网络世界中流量整形(Traffic Shaping)或速率限制(Rate Limiting)时经常使用的一种算法,它的主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。
需求:实现一个复制文本到标准输出的程序,要求10字符10字符的复制,且不能让CPU空转。
1 |
|
7.7.6 令牌桶
系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。 当桶满时,新添加的令牌被丢弃或拒绝。
令牌桶算法是一个存放固定容量令牌(token)的桶,按照固定速率往桶里添加令牌。令牌桶算法基本可以用下面的几个概念来描述:
- 令牌将按照固定的速率被放入令牌桶中。比如每秒放10个。
- 桶中最多存放b个令牌,当桶满时,新添加的令牌被丢弃或拒绝。
- 当一个n个字节大小的数据包到达,将从桶中删除n个令牌,接着数据包被发送到网络上。
- 如果桶中的令牌不足n个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么缓冲区等待)。
令牌桶和漏桶的区别
主要区别在于漏桶算法能够强行限制数据的传输速率,而令牌桶算法在能够限制数据的平均传输速率外,还允许某种程度的突发传输。在令牌桶算法中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的门限,因此它适合于具有突发特性的流量。
代码示例
暂略
7.8 信号集
信号集是一个能够表示多个信号的数据类型。
POSIX.1
定义数据类型sigset_t
来表示一个信号集(本质为整型),并且定义了下列5个处理信号集的函数:
1 |
|
7.9 sigprocmask函数
sigprocmask
函数可以检测或更改,或同时进行检测和更改进程的信号屏蔽字(阻塞信号集)。注:对信号来说,阻塞和屏蔽是一个意思。
函数原型:
1 |
|
how
:用于指定信号修改的方式,有三种选择:SIG_BLOCK
:该进程新的信号屏蔽字是其当前信号屏蔽字和set指向信号集的并集。即set包含了希望阻塞的附加信号;SIG_UNBLOCK
:该进程新的信号屏蔽字是其当前信号屏蔽字和set所指向信号集补集的交集。即set包含了希望解除阻塞的信号;SIG_SETMASK
:该进程新的信号屏蔽是set指向的值;
*set
:和how结合使用*oldset
:进程的当前信号屏蔽字通过oset
返回
代码示例1
1 |
|
执行结果:
1 | [root@HongyiZeng signal]# ./block |
解析:第三行发送SIGINT信号时,SIGINT的屏蔽字位为0,pending位置为1,当打印完成后,解除屏蔽,进程响应该信号,在第四行打印了!
;第四行多次发送SIGINT信号,也只是对pending位反复置为1,在第五行也只打印一次!
;
代码示例2
1 | int main(void) { |
功能和代码示例1相同。
代码示例3
为了保证在进入模块和退出模块时进程的信号屏蔽字是一致,需要修改代码为:
1 | int main(void) { |
7.10 sigpending函数
略
7.11 常用函数Ⅱ
7.11.1 sigsuspend函数
该函数通过将进程的屏蔽字替换为由参数sigmask
给出的信号集,然后将进程挂起(阻塞)。
1 |
|
功能描述上和pause
函数相同,即在等待信号的时候让进程挂起。
区别:sigsuspend实际是将sigprocmask和pause结合起来原子操作。
代码示例
需求:先打印一排*
,等待信号,然后打印一排*
,以此类推。
- 使用
pause
1 | static void int_handler(int s) { |
执行结果:
1 | [root@HongyiZeng signal]# ./susp |
第二行开始处:当内层循环执行完毕后,到pause处挂起,此时SIGINT到来,首先处理信号,然后唤醒进程,继续执行内层循环打印。
注意第四行,当在内层循环执行时,有多个SIGINT信号到来,由于被屏蔽,所以不打印叹号,打印星号结束后,代码第十九行解除屏蔽(注意,代码第十九行和第二十行之间也会存在多个中断),响应信号(第五行的第一个叹号),再执行到代码第二十行处时,此时没有信号到来,所以一直挂起。
- 使用
sigsuspend
1 | static void int_handler(int s) { |
7.11.2 sigaction函数
sigaction函数是升级版的signal函数。
sigaction函数的功能是检查或修改与指定信号相关联的处理动作(可同时两种操作)。
1 |
|
- signum参数指出要捕获的信号类型
- act参数指定新的信号处理方式
- oldact参数输出先前信号的处理方式(如果不为NULL的话)
struct sigaction
结构体定义如下:
1 | struct sigaction { |
sa_handler
:此参数和signal()的参数handler相同,代表新的信号处理函数sa_mask
:用来设置在处理该信号时暂时将 sa_mask 指定的信号集屏蔽sa_flags
:用来设置信号处理的其他相关操作,下列的数值可用。SA_RESETHAND
:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFLSA_RESTART
:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用SA_NODEFER
:一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号SA_SIGINFO
:调用信号处理器程序时携带了额外参数,其中提供了关于信号的深入信息
sa_sigaction
:这是一个三个参数的sa_handler
函数版本。如果设置了SA_SIGINFO
标志位,则会使用sa_sigaction
处理函数,否则使用sa_handler
处理函数。其中参数siginfo_t
是一个结构体类型
代码示例1
1 | // 需求:三种信号SIGQUIT,SIGTERM,SIGINT共用一个信号处理函数,且该函数中释放了重要的资源,因此不可重入。 |
代码示例2
暂略
7.12 实时信号
暂略
7.13 信号实现原理
该节是对7.6节信号的响应过程
的补充扩展。
7.13.1 未决和阻塞信号集
在 PCB 中有两个非常重要的信号集。一个称之为 阻塞信号集 mask
,另一个称之为 未决信号集 pending
。这两个信号集体现在内核中就是两张表。
但是操作系统不允许我们直接对这两个信号集进行任何操作,而是需要自定义另外一个集合,借助信号集操作函数(详见7.8-7.10节
)来对 PCB 中的这两个信号集进行修改。
- 信号的 “未决(
pending
)” 是一种状态,指的是从信号的产生到信号被处理前的这一段时间。 - 信号的 “阻塞(
mask
)” 是一个开关动作,指的是阻止信号被处理,但不是阻止信号产生。
阻塞信号集和未决信号集在内核中的结构是相同的,它们都是一个整形数组 (被封装过的),一共 128
字节 (int [32] == 1024 bit
),1024 个标志位,其中前 31 个标志位,每一个都对应一个 Linux 中的标准信号,通过标志位的值来标记当前信号在信号集中的状态。
7.13.2 原理介绍
① 信号处理相关的数据结构
在进程管理结构 task_struct
(详见8.1.2.①节
) 中有几个与信号处理相关的字段,如下:
1 | struct task_struct { |
sigpending
:表示进程是否有信号需要处理(1表示有,0表示没有)blocked
:表示被屏蔽的信息,每个位代表一个被屏蔽的信号sig
:表示信号相应的处理方法,其类型是struct signal_struct
,定义如下:
1 |
|
pending
:其类型为struct sigpending
,存储着进程接收到的信号队列,定义如下:
1 | struct sigqueue { |
当进程接收到一个信号时,就需要把接收到的信号添加 pending
这个队列中。
② 发送信号
暂略
8 线程
本节对应APUE第十一、十二章内容
8.1 线程概念
线程本质:一个正在运行的函数。
进程本质:加载到内存的程序。
进程是操作系统分配资源的单位,线程是调度的基本单位,线程之间共享进程资源。
典型的UNIX进程可以看成只有一个控制线程:一个进程在某一时刻只能做一件事情。有了多个控制线程以后,在程序设计时就可以把进程设计成在某一时刻能够做不止一件事,每个线程处理各自独立的任务。
每个线程都包含有表示执行环境所必需的信息,其中包括进程中标识线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据。
一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程序的全局内存和堆内存、栈以及文件描述符。
8.1.1 POSIX线程接口
POSIX线程(英语:POSIX Threads,常被缩写为Pthreads
)是POSIX的线程标准,定义了创建和操纵线程的一套API。
Pthreads定义了一套C语言的类型、函数与常量,它以pthread.h
头文件和一个线程库实现。Pthreads API中大致共有100个函数调用,全都以pthread_
开头,并可以分为四类:
- 线程管理,例如创建线程,等待(join)线程,查询线程状态等。
- 互斥锁(Mutex):创建、摧毁、锁定、解锁、设置属性等操作
- 条件变量(Condition Variable):创建、摧毁、等待、通知、设置与查询属性等操作
- 使用了互斥锁的线程间的同步管理
因此在编译时需要makefile的编译和链接选项:
1 | CFLAGS+=-pthread # 编译选项 |
1 | cc -pthread -pthread create.c -o create |
8.1.2 进程和线程
① 进程控制块
进程控制块是用于保存一个进程信息的结构体,称之为PCB(process control block
),相当于进程的身份证;PCB是进程存在的唯一标识。
在Linux中PCB的信息存放在task_struct
结构体中。结构体的主要成员如下:
- 进程状态
进程执行时,它会根据具体情况改变状态。进程状态是调度和对换的依据。Linux中的进程主要有如下状态:
字段名 | 含义 |
---|---|
TASK_RUNNING |
可运行 |
TASK_INTERRUPTIBLE |
可中断的等待状态 |
TASK_UNINTERRUPTIBLE |
不可中断的等待状态 |
TASK_ZOMBIE |
僵死 |
TASK_STOPPED |
暂停 |
TASK_SWAPPING |
换入/换出 |
- 进程调度信息
调度程序利用这部分信息决定系统中哪个进程最应该运行,并结合进程的状态信息保证系统运转的公平和高效。这一部分信息通常包括进程的类别(普通进程还是实时进程)、进程的优先级等等。
字段名 | 含义 |
---|---|
need_resched | 调度标志 |
Nice | 静态优先级 |
Counter | 动态优先级 |
Policy | 调度策略 |
rt_priority | 实时优先级 |
- 标识符
每个进程有进程标识符、用户标识符、组标识符。
域名 | 含义 |
---|---|
Pid | 进程标识符 |
Uid、gid | 用户标识符、组标识符 |
Euid、egid | 有效用户标识符、有效组标识符 |
Suid、sgid | 备份用户标识符、备份组标识符 |
Fsuid、fsgid | 文件系统用户标识符、文件系统组标识符 |
- 进程通信有关信息
为了使进程能在同一项任务上协调工作,进程之间必须能进行通信即交流数据。
Linux支持多种不同形式的通信机制。它支持典型的Unix通信机制(IPC Mechanisms):信号(Signals)、管道(Pipes),也支持System V通信机制:共享内存(Shared Memory)、信号量和消息队列(Message Queues)
- 进程链接信息
程序创建的进程具有父/子关系。因为一个进程能创建几个子进程,而子进程之间有兄弟关系,在task_struct结构中有几个成员来表示这种关系。
- 时间和定时器信息
一个进程从创建到终止叫做该进程的生存期(lifetime)。进程在其生存期内使用CPU的时间,内核都要进行记录,以便进行统计、计费等有关操作。
进程耗费CPU的时间由两部分组成:一是在用户模式(或称为用户态)下耗费的时间,一是在系统模式(或称为系统态)下耗费的时间。每个时钟滴答,也就是每个时钟中断,内核都要更新当前进程耗费CPU的时间信息,例如将进程的时间片减一。
域名 | 含义 |
---|---|
Start_time | 进程创建时间 |
Per_cpu_utime | 进程在某个CPU上运行时在用户态下耗费的时间 |
Per_cpu_stime | 进程在某个CPU上运行时在系统态下耗费的时间 |
Counter | 进程剩余的时间片 |
进程中的定时器如下图所示:
其余略
② 线程共享和私有内容
私有资源:所属线程的栈区、程序计数器、栈指针以及函数运行使用的寄存器是线程私有的。这些资源又称为线程上下文。
除此之外的所有资源均由线程共享。
8.2 线程标识
就像每个进程有一个进程ID一样,每个线程也有一个线程ID。进程ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。
进程ID是用pid_t
数据类型来表示的,是一个非负整数。线程ID是用pthread_t
数据类型来表示的,实现的时候可以用一个结构来代表pthread_t数据类型,所以可移植的操作系统实现不能把它作为整数处理。
因此需要一个函数来对两个线程ID进行比较:
1 |
|
获取自身的线程id:
1 |
|
8.3 线程创建
在传统 UNIX进程模型中,每个进程只有一个控制线程。从概念上讲,这与基于线程的模型中每个进程只包含一个线程是相同的。在POSIX线程(pthread)的情况下,程序开始运行时,它也是以单进程中的单个控制线程启动的。在创建多个控制线程以前,程序的行为与传统的进程并没有什么区别。
新增的线程可以通过调用pthread_create
函数创建。
1 |
|
thread
:事先创建好的pthread_t
类型的参数。成功时thread指向的内存单元被设置为新创建线程的线程ID。attr
:用于定制各种不同的线程属性。APUE的12.3节讨论了线程属性。通常直接设为NULL。start_routine
:新创建线程从此函数开始运行,无参数时arg
设为NULL即可。形参是函数指针(该函数返回值和形参均为void*
),因此需要传入函数地址。arg
:start_rtn函数的参数。无参数时设为NULL即可。有参数时输入参数的地址。当多于一个参数时应当使用结构体传入。
代码示例
1 |
|
打印结果:
1 | [root@HongyiZeng pthread]# ./create |
分析:线程的调度取决于调度器策略。创建线程后,新的线程还没来得及被调度,main线程就执行了exit(0)
使得进程退出,所以新的线程并没有被执行就退出了。
8.4 线程终止
8.4.1 终止方式
如果进程中的任意线程调用了 exit
、_Exit
或者 _exit
,那么整个进程就会终止。与此相类似,如果默认的动作是终止进程,那么,发送到线程的信号就会终止整个进程。
单个线程可以通过3种方式退出,因此可以在不终止整个进程的情况下,停止它的控制流:
- 线程可以简单地从启动例程中返回,返回值是线程的退出码
- 线程可以被同一进程中的其他线程取消
- 线程调用
pthread_exit
1 |
|
例如:
1 | static void *func(void *p) { |
函数pthread_join
用来等待一个线程的结束。相当于进程控制中的wait
。
1 |
|
thread
:为被等待的线程标识符retval
:为用户定义的指针,它可以用来存储被等待线程的返回值,即pthread_exit
的参数。这是一个二级指针,因此传入的参数为一级指针的地址,如果不关心返回值则用NULL
代码示例1
1 |
|
执行结果:
1 | [root@HongyiZeng pthread]# ./create |
代码示例2
1 |
|
执行结果:
1 | [root@HongyiZeng pthread]# ./test |
补充:void*
void*
可以指向任何类型的地址。
1 | float f = 5.5; |
void*
指针只有强制类型转换以后才可以解引用
1 | int main() { |
void*
指针变量和普通指针一样可以通过等于0或者NULL来初始化,表示一个空指针
1 | void* pv = 0; |
- 当
void*
指针作为函数的输入和输出时,表示可以接受任意类型的输入指针和输出任意类型的指针
1 |
|
执行结果:
1 | 0x7ffcd39fa0d4 |
8.4.2 栈的清理
线程可以安排它退出时需要调用的函数,这与进程在退出时可以用atexit函数安排退出是类似的。这样的函数称为线程清理处理程序(thread cleanup handler)。一个线程可以建立多个清理处理程序。处理程序记录在栈中,也就是说,它们的执行顺序与它们注册时相反。
1 |
|
当线程执行以下动作时,清理函数routine
是由pthread_cleanup_push
函数调度的,调用时只有一个参数arg
:
- 调用pthread_exit时;
- 响应取消请求时;
- 用非零execute参数调用pthread_cleanup_pop 时。如果 execute 参数设置为0,清理函数将不被调用。不管发生上述哪种情况,pthread_cleanup_pop都将删除上次 pthread_cleanup_push调用建立的清理处理程序。
注意:这些函数有一个限制,由于它们可以实现为宏,所以必须在与线程相同的作用域中以匹配对的形式使用。pthread_cleanup_push 的宏定义可以包含字符{
,这种情况下,在 pthread cleanup_pop 的定义中要有对应的匹配字符}
。
代码示例
1 |
|
执行结果:
1 | Begin! |
8.4.3 线程的取消
多线程程序中,一个线程可以借助 pthread_cancel()
函数向另一个线程发送“终止执行”的信号,从而令目标线程结束执行。
pthread_cancel
调用并不等待线程终止,它只提出请求。线程在取消请求发出后会继续运行,直到到达某个取消点(CancellationPoint)
。取消点是线程检查是否被取消并按照请求进行动作的一个位置。
与线程取消相关的函数有:
1 | // 发送终止信号给thread线程,如果成功则返回0,否则为非0值。发送成功并不意味着thread会终止。 |
1 | // 设置本线程取消动作的执行时机 |
1 | // 在不包含取消点,但是又需要取消点的地方创建一个取消点,以便在一个没有包含取消点的执行代码线程中响应取消请求 |
代码示例
1 |
|
8.5 线程同步
8.5.1 概念和例子
线程竞争的实例
1 |
|
执行结果:发现每次都不一样,并且每次的结果都是相同的。
原因:线程发生了竞争。
创建线程时,main线程传递给函数thr_prime
的参数&i
是同一个地址,但是地址保存的值不相同:
1 | err = pthread_create(tid+(i-LEFT), NULL, thr_prime, &i); |
后面线程执行时,会对这个地址进行解引用:
1 | i = *(int *)p; |
注意:main线程和创建的线程的调度是由调度算法决定,因此会出现,在线程解引用之前,main线程就将该地址上的i值进行了修改,所以后面线程得到的i值都是同一个值。
解决竞争
定义一个结构体,成员为要计算判断的数,然后每次动态分配内存,将地址作为线程函数的参数即可。
1 |
|
当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。如果每个线程使用的变量都是其他线程不会读取和修改的,那么就不存在一致性问题。同样,如果变量是只读的,多个线程同时读取该变量也不会有一致性问题。但是,当一个线程可以修改的变量,其他线程也可以读取或者修改的时候,我们就需要对这些线程进行同步,确保它们在访问变量的存储内容时不会访问到无效的值。
当一个线程修改变量时,其他线程在读取这个变量时可能会看到一个不一致的值。在变量修改时间多于一个存储器访问周期的处理器结构中,当存储器读与存储器写这两个周期交叉时,这种不一致就会出现。当然,这种行为是与处理器体系结构相关的,但是可移植的程序并不能对使用何种处理器体系结构做出任何假设。
图11-7描述了两个线程读写相同变量的假设例子。在这个例子中,线程A读取变量然后给这个变量赋予一个新的数值,但写操作需要两个存储器周期。当线程B在这两个存储器写周期中间读取这个变量时,它就会得到不一致的值。
为了解决这个问题,线程不得不使用锁,同一时间只允许一个线程访问该变量。图11-8描述了这种同步。如果线程B希望读取变量,它首先要获取锁。同样,当线程A更新变量时,也需要获取同样的这把锁。这样,线程B在线程A释放锁以前就不能读取变量。
8.5.2 互斥量
可以使用 pthread 的互斥接口来保护数据,确保同一时间只有一个线程访问数据。互斥量(mutex
)从本质上说是一把锁,在访问共享资源前对互斥量进行设置(加锁),在访问完成后释放(解锁)互斥量。
对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞直到当前线程释放该互斥锁。如果释放互斥量时有一个以上的线程阻塞,那么所有该锁上的阻塞线程都会变成可运行状态,第一个变为运行的线程就可以对互斥量加锁,其他线程就会看到互斥量依然是锁着的,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。
只有将所有线程都设计成遵守相同数据访问规则的,互斥机制才能正常工作。操作系统并不会为我们做数据访问的串行化。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其他的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。
互斥变量是用 pthread_mutex_t
数据类型表示的。在使用互斥变量以前,必须首先对它进行初始化,可以把它设置为常量PTHREAD_MUTEX_INITIALIZER
(只适用于静态分配的互斥量),也可以通过调用pthread_mutex_init
函数进行初始化。如果动态分配互斥量(例如,通过调用malloc函数),在释放内存前需要调用pthread_mutex_destroy
。
相关函数:
- 初始化和销毁:
1 |
|
- 加锁和解锁
1 |
|
代码示例——20个线程读写一个文件
先向/tmp/out
下入1,然后创建20个线程来读这个文件内容并加1,然后写入。期望内容为21
1 |
|
执行结果:
1 | [root@HongyiZeng posix]# echo 1 > /tmp/out |
分析:由于调度和竞争,线程读到文件内容1后,休眠1s,然后一起写入2,所以结果就为2;
代码示例——互斥量的使用
1 |
|
执行结果:
1 | [root@HongyiZeng posix] |
代码示例3——使用互斥锁实现线程同步
需求:四个线程依次打印abcd
1 |
|
8.5.3 线程池
线程数是有一定限制的,8.5.1节
用201个线程来检测质数,本节利用线程池来解决。
假设线程池提供4个线程来检测201个质数。设置临界区资源num
,当:
num = 0
:当前没有任务num = -1
:当前任务已经全部完成num = 300000~3000200
:当前有一个任务,需要一个线程来接受任务
1 |
|
打印结果:
1 | [thread-0]30000001 is a primer. |
不足:该程序存在盲等,即查询法的不足,上游main线程一直在循环查看任务是否被领走,而下游一直在循环查看是否有任务。
通知法:上游将设置任务后,唤醒下游来处理任务。如果没有领走任务,则阻塞自己,等待下游来唤醒。
下游发现有任务,则领走任务,并唤醒上游;没有任务,则阻塞,等待上游来唤醒。
8.5.4 线程令牌桶
暂略
8.5.5 条件变量
条件变量是线程可用的另一种同步机制。条件变量给多个线程提供了一个会合的场所。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。
条件本身是由互斥量保护的。线程在改变条件状态之前必须首先锁住互斥量。其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁定以后才能计算条件。
在使用条件变量之前,必须先对它进行初始化。由pthread_cond_t
数据类型表示的条件变量可以用两种方式进行初始化。可以把常量PTHREAD_COND_INITTALIZER
赋给静态分配的条件变量但是如果条件变量是动态分配的,则需要使用pthread_cond_init
函数对它进行初始化。在释放条件变量底层的内存空间之前,可以使用pthread_cond_destroy
函数对条件变量进行反初始化。
相关函数和作用:
初始化条件变量
1 |
|
阻塞当前线程,等待条件成立
1 |
|
cond
:已初始化好的条件变量mutex
:与条件变量配合使用的互斥锁abstime
:阻塞线程的时间
调用两个函数之前,我们必须先创建好一个互斥锁并完成加锁操作,然后才能作为实参传递给 mutex
参数。两个函数会完成以下两项工作:
- 阻塞线程,直至接收到条件成立的信号
- 当线程被添加到等待队列上时,将互斥锁解锁,即释放mutex
也就是说,函数尚未接收到“条件成立”的信号之前,它将一直阻塞线程执行。注意,当函数接收到“条件成立”的信号后,它并不会立即结束对线程的阻塞,而是先完成对互斥锁的“加锁”操作,然后才解除阻塞。
两个函数都以“原子操作”的方式完成“阻塞线程+解锁”或者“重新加锁+解除阻塞”这两个过程。
解除线程的“阻塞”状态
1 |
|
对于被上面两个函数阻塞的线程,我们可以借助如上两个函数向它们发送“条件成立”的信号,解除它们的“阻塞”状态。
由于互斥锁的存在,解除阻塞后的线程也不一定能立即执行。当互斥锁处于“加锁”状态时,解除阻塞状态的所有线程会组成等待互斥锁资源的队列,等待互斥锁“解锁”。
代码示例1——查询法转通知法
1 |
|
代码示例2——打印abcd
1 |
|
8.5.6 信号量
使用互斥量和条件变量可以实现信号量的功能。
需求:产生4个线程(4个资源)来筛选质数,任务完成后线程退出,然后又产生线程。这里的共享资源就是4个线程。
mysem.h
1 |
|
mysem.c
1 |
|
main.c
1 |
|
makefile
1 | CFLAGS+=-pthread |
执行结果
使用命令,查看线程关系:
1 | ps ax -L |
由下图所示,主线程main[3134]
创建了4个线程来筛选质数:
当某个线程完成任务后,增加信号量,并唤醒阻塞线程,下图表示原来的4个线程已经完成任务退出,主线程又创建了4个新的线程来筛选质数:
同理,图表示原来的4个线程已经完成任务退出,主线程又创建了4个新的线程来筛选质数:
8.6 线程属性
pthread 接口允许我们通过设置每个对象关联的不同属性来细调线程和同步对象的行为。通常,管理这些属性的函数都遵循相同的模式。
在所有调用pthread_create
函数的实例中,传入的参数都是空指针,而不是指向pthread_attr_t
结构的指针。可以使用pthread_attr_t
结构修改线程默认属性,并把这些属性与创建的线程联系起来。
可以使用pthread_attr_init
函数初始化 pthread_attr_t
结构。在调用 pthread attr_init
以后,pthread_attr_t
结构所包含的就是操作系统实现支持的所有线程属性的默认值。
初始化和销毁
1 |
|
下图总结了 POSIX.1 定义的线程属性。POSIX.1 还为线程执行调度(Thread Execution Scheduling)选项定义了额外的属性,用以支持实时应用。下图同时给出了各个操作系统平台对每个线程属性的支持情况。
线程分离状态属性
线程分离:在我们使用默认属性创建一个线程的时候,线程是 joinable 的。 joinable 状态的线程,必须在另一个线程中使用 pthread_join() 等待其结束, 如果一个 joinable 的线程在结束后,没有使用 pthread_join() 进行操作, 这个线程就会变成”僵尸线程”。可以使用pthread_detach
函数让线程分离。
当线程被设置为分离状态后,线程结束时,它的资源会被操作系统自动的回收, 而不再需要在其它线程中对其进行 pthread_join() 操作。
1 |
|
detachstate
:可以设置为以下属性PTHREAD_CREATE_DETACHED
:线程分离状态PTHREAD_CREATE_JONINABLE
:线程可joinable状态
代码示例:
1 | static void *fn(void *p) { |
线程的栈和栈大小
可以使用下列函数设置线程的栈属性。
1 |
|
对于进程来说,虚地址空间的大小是固定的。因为进程中只有一个栈,所以它的大小通常不是问题。但对于线程来说,同样大小的虚地址空间必须被所有的线程栈共享。如果应用程序使用了许多线程,以致这些线程栈的累计大小超过了可用的虚地址空间,就需要减少默认的线程栈大小。另一方面,如果线程调用的函数分配了大量的自动变量,或者调用的函数涉及许多很深的栈帧(stack frame),那么需要的栈大小可能要比默认的大。
如果线程栈的虚地址空间都用完了,那可以使用malloc或者mmap来为可替代的栈分配空间,并用pthread_attr_setstack
函数来改变新建线程的栈位置。由stackaddr
参数指定的地址可以用作线程视的内容范围中的最低可寻找地址,该地址与处理器结构相应的边界应对齐。当然,这要假设malloc和mmap所用的虚地址范围与线程栈当前使用的虚地址范围不同。
stackaddr
线程属性被定义为栈的最低内存地址,但这并不一定是栈的开始位置。对于一个给定的处理器结构来说,如果栈是从高地址向低地址方向增长的,那么 stackaddr
线程属性将是栈的结尾位置,而不是开始位置。
应用程序也可以通过下列函数读取或设置线程属性stacksize
。
1 |
|
如果希望改变默认的栈大小,但又不想自己处理线程栈的分配问题,这时使用pthread_attr_setstacksize
函数就非常有用。设置stacksize属性时,选择的stacksize不能小于PTHREAD_STACK_MIN
。
代码示例——测试线程数量的上限:
1 |
|
8.7 同步属性
线程的同步对象也具有属性。
8.7.1 互斥量属性
互斥量属性是用 pthread_mutexattr_t
结构表示的。在8.5.2节
每次对互斥量进行初始化时,都是通过使用PTHREAD_MUTEX_INITTALIZER
常量或者用指向互斥量属性结构的空指针作为参数调用 pthread_mutex_init
函数,得到互斥量的默认属性。
对于非默认属性,可以使用下列函数进行初始化和销毁:
1 |
|
互斥量的三个主要属性:
- 进程共享属性
- 健壮属性(略)
- 类型属性
进程共享
在进程中,多个线程可以访问同一个同步对象。正如在8.5.2节
看到的,这是默认的行为。在这种情况下,进程共享互斥量属性需设置为PTHREAD_PROCESS_PRIVATE
。
但也存在这样的机制:允许相互独立的多个进程把同一个内存数据块映射到它们各自独立的地址空间中。就像多个线程访问共享数据一样,多个进程访问共享数据通常也需要同步。如果进程共享互斥量属性设置为PTHREAD_PROCESS_SHARED
,从多个进程彼此之间共享的内存数据块中分配的互斥量就可以用于这些进程的同步。
相关函数调用:
1 |
|
pshared
:这里的p指的就是进程processPTHREAD_PROCESS_PRIVATE
:进程独占互斥量PTHREAD_PROCESS_SHARED
:进程共享互斥量
类型属性
类型互斥量属性控制着互斥量的锁定特性。POSIX.1定义了4种类型。
PTHREAD_MUTEX_NORMAL
:一种标准互斥量类型,不做任何特殊的错误检查或死锁检测。PTHREAD_MUTEX_ERRORCHECK
:此互斥量类型提供错误检查。PTHREAD_MUTEX_RECURSIVE
:此互斥量类型允许同一线程在互斥量解锁之前对该互斥量进行多次加锁。递归互斥量维护锁的计数,在解锁次数和加锁次数不相同的情况下,不会释放锁。所以,如果对一个递归互斥量加锁两次,然后解锁一次,那么这个互斥量将依然处于加锁状态,对它再次解锁以前不能释放该锁。PTHREAD_MUTEX_DEFAULT
:此互斥量类型可以提供默认特性和行为。操作系统在实现它的时候可以把这种类型自由地映射到其他互斥量类型中的一种。
上图不占用解锁是指解锁不是自己加的锁(解锁别人加的锁),例如打印abcd的程序。
相关函数:
1 |
|
8.7.2 条件变量属性
相关函数:
1 |
|
8.8 线程安全IO
此前介绍过的IO都是线程安全的IO,即在当多个线程操作IO时,都必须对缓冲区进行加锁和解锁,防止出现对缓冲区进行竞争的现象。
例如三个线程,分别向标准输出终端打印连续字符aaa
,bbb
和ccc
,如果使用线程安全的io,例如puts
,可能出现的情况有:
1 | aaabbbccc |
但绝不会出现下面这种情况:
1 | abcabcabc |
原因就在于,puts
是线程安全的,在对缓冲区操作前,需要加锁。
也存在线程不安全的IO调用,当考虑到效率问题(省去加锁和解锁的时间),并且确保只有单线程操作缓冲区时,可以使用下面的这些函数,这些函数在后面都加上了_unlocked
,表示不加锁。
1 |
|
8.9 线程和信号
对多线程的进程而言,只有进程级别的未决信号集pending
,没有信号屏蔽字mask
,而每个线程都有自己的pending
和mask
(线程级别)。
进程向进程发送信号,改变的是进程级别的pending
,线程向线程发送信号,改变的是线程级别的pending
。对于线程级别的信号响应,使用当前线程的pending
和mask
进行按位与。对于进程级别的信号响应,使用当前工作线程的mask
和进程级别的pending
进行按位与。
此前讨论了进程如何使用 sigprocmask
函数来阻止信号发送。然而,sigprocmask
的行为在多线程的进程中并没有定义,线程必须使用pthread_sigmask
。
1 |
|
线程可以调用sigwait
等待一个或多个信号的出现:
1 |
|
8.10 线程与fork
暂略
9 高级IO
本节对应第十四章高级IO
9.1 非阻塞IO
阻塞IO:当资源不可用的时候,应用程序就会挂起。当资源可用的时候,唤醒任务。
阻塞IO图示如下,应用程序调用 read 函数从设备中读取数据,当设备不可用或数据未准备好的时候就会进入到休眠态。等设备可用的时候就会从休眠态唤醒,然后从设备中读取数据返回给应用程序。
非阻塞IO:当资源不可用的时候,应用程序轮询查看,或放弃,会有超时处理机制。
非阻塞IO图示如下,可以看出,应用程序使用非阻塞访问方式从设备读取数据,当设备不可用或数据未准备好的时候会立即向内核返回一个错误码,表示数据读取失败。应用程序会再次重新读取数据,这样一直往复循环,直到数据读取成功。
9.2 有限状态机
需求:有左右两个设备,第一个任务为读左设备,写右设备,第二个任务读右设备,写左设备。
代码实现