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
2
3
4
5
6
#include<stdio.h>

int main(void) {
printf("Hello World!\n");
return 0;
}
  • 预编译:这个过程处理宏定义和include,去除注释,不会对语法进行检查。可以看到预编译后,源文件的代码从6行扩展了很多行,生成main.i
1
gcc -E main.c > main.i # 或者 gcc -E main.c -o main.i
1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
// 只展示了一部分
extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;


extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 943 "/usr/include/stdio.h" 3 4

# 2 "main.c" 2
int main(void) {
printf("Hello World!\n");
return 0;
}
  • 编译:这个阶段,检查语法,生成汇编代码main.s
1
gcc -S main.c -o main.s

main.s的内容:

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
        .file   "main.c"
.section .rodata
.LC0:
.string "Hello World!"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
.section .note.GNU-stack,"",@progbits
  • 汇编:这个阶段,生成目标代码main.o
1
gcc -c main.s -o main.o
  • 链接:生成可执行代码main
    • 链接分为两种,一种是静态链接,另外一种是动态链接。
    • 使用静态链接的好处是,依赖的动态链接库较少,对动态链接库的版本不会很敏感,具有较好的兼容性;缺点是生成的程序比较大。
    • 使用动态链接的好处是,生成的程序比较小,占用较少的内存。
1
gcc main.o -o main
  • 运行
1
2
[root@HongyiZeng c]# ./main
Hello World!

一步到位:

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定义了一系列的规则来指定哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作。

image-20221121035110927

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.htool1.c
1
2
3
4
#ifndef __TOOL1_H__
#define __TOOL1_H__
void mytool1();
#endif
1
2
3
4
5
#include <stdio.h>
#include "tool1.h"
void mytool1() {
printf("tool1 print...\n");
}
  • tool2.htool2.c
1
2
3
4
#ifndef __TOOL2_H__
#define __TOOL2_H__
void mytool2();
#endif
1
2
3
4
5
6
#include <stdio.h>
#include "tool2.h"

void mytool2() {
printf("tool2 print...\n");
}
  • main.c
1
2
3
4
5
6
7
8
#include "tool1.h"
#include "tool2.h"

int main(void) {
mytool1();
mytool2();
return 0;
}

对所有文件进行编译:

1
gcc *.c

执行a.out

1
2
3
[root@HongyiZeng makefile]# ./a.out 
tool1 print...
tool2 print...

1.4.2 语法规则

1
2
3
4
目标 ... : 依赖 ...
命令1
命令2
. . .
  • 目标即要生成的文件。如果目标文件的更新时间晚于依赖文件更新时间,则说明依赖文件没有改动,目标文件不需要重新编译。否则会进行重新编译并更新目标文件。默认情况下Makefile的第一个目标为终极目标
  • 依赖:即目标文件由哪些文件生成。
  • 命令:即通过执行命令由依赖文件生成目标文件。注意每条命令之前必须有一个tab保持缩进。

代码示例

  • 新建makefile,touch makefile
1
2
3
4
5
6
7
8
mytool:main.o tool1.o tool2.o
gcc main.o tool1.o tool2.o -o mytool
main.o:main.c
gcc -c main.c -o main.o
tool1.o:tool1.c
gcc -c tool1.c -o tool1.o
tool2.o:tool2.c
gcc -c tool2.c -o tool2.o
  • 执行make即可。

makefile会监视哪些源文件发生了变化,一旦发生变化,则会在有这个变化文件的编译链上重新编译。

  • 假设没有变化
1
2
[root@HongyiZeng makefile]# make
make: `mytool' is up to date.
  • tool1.c修改一点,再次make
1
2
3
4
5
6
[root@HongyiZeng makefile]# make
gcc -c tool1.c -o tool1.o
gcc main.o tool1.o tool2.o -o mytool
[root@HongyiZeng makefile]# ./mytool
tool1.c is updated...
tool2 print...

发现只有tool1.c影响到的地方被重新编译了,没有发生变化的地方则不会被重新编译。


  • 代码清理clean:我们可以编译一条属于自己的clean语句,来清理make命令所产生的所有文件,例如:
1
2
3
4
5
6
7
8
9
10
11
mytool:main.o tool1.o tool2.o
gcc main.o tool1.o tool2.o -o mytool
main.o:main.c
gcc -c main.c -o main.o
tool1.o:tool1.c
gcc -c tool1.c -o tool1.o
tool2.o:tool2.c
gcc -c tool2.c -o tool2.o

clean:
rm *.o mytool -rf

使用:

1
make clean

  • 变量:$符号表示取变量的值,当变量名多于一个字符时,使用()

  • 变量赋值:

    • =:最普通的等号,在Makefile中容易搞错赋值等号,使用 =进行赋值,变量的值是整个Makefile中最后被指定的值。

      1
      2
      3
      VIR_A = A
      VIR_B = $(VIR_A) B
      VIR_A = AA

      经过上面的赋值后,最后VIR_B的值是AA B,而不是A B,在make时,会把整个Makefile展开,来决定变量的值

    • :=:表示直接赋值,赋予当前位置的值。

      1
      2
      3
      VIR_A := A
      VIR_B := $(VIR_A) B
      VIR_A := AA

      最后BIR_B的值是A B,即根据当前位置进行赋值。

    • ?=:表示如果该变量没有被赋值,赋值予等号后面的值。

      1
      VIR ?= new_value

      如果VIR在之前没有被赋值,那么VIR的值就为new_value。

      1
      2
      VIR := 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中:

image-20230115162552592

需要在编译和链接的选项中加上-pthread,因此,在编写makefile时需要加上:

1
2
CFLAGS+=-pthread
LDFLAGS+=-pthread

执行make时:

1
cc -pthread -phread test.c -o test

改造后的makefile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
OBJS=main.o tool1.o tool2.o
CFLAGS+=-c -Wall -g # 编译选项

mytool:$(OBJS)
$(CC) $(OBJS) -o mytool
main.o:main.c
$(CC) $(CFLAGS) main.c -o main.o
tool1.o:tool1.c
$(CC) $(CFLAGS) tool1.c -o tool1.o
tool2.o:tool2.c
$(CC) $(CFLAGS) tool2.c -o tool2.o

clean:
rm *.o mytool -rf

$的其他用法:

  • $^ 表示所有的依赖文件
  • $@ 表示生成的目标文件
  • $< 代表第一个依赖文件

再次改造后的makefile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
OBJS=main.o tool1.o tool2.o
CFLAGS+=-c -Wall -g

mytool:$(OBJS)
$(CC) $^ -o $@
main.o:main.c
$(CC) $(CFLAGS) $^ -o $@
tool1.o:tool1.c
$(CC) $(CFLAGS) $^ -o $@
tool2.o:tool2.c
$(CC) $(CFLAGS) $^ -o $@

clean:
rm *.o mytool -rf

实际上上面的三段代码都有固定的模式,如下:

1
2
xxx.o:xxx.c
$(CC) $(CFLAGS) $^ -o $@

相同的部分xxx可以用通配符%代替。可以简化为:

1
2
3
4
5
6
7
8
9
10
OBJS=main.o tool1.o tool2.o
CFLAGS+=-c -Wall -g

mytool:$(OBJS)
$(CC) $^ -o $@
%.o:%.c
$(CC) $(CFLAGS) $^ -o $@

clean:
rm *.o mytool -rf

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。包括:

  1. 美国加州大学伯克利分校的Unix4.xBSD(Berkeley Software Distribution)。
  2. 贝尔实验室发布的自己的版本,称为System V Unix。
  3. 其他厂商的版本,比如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文件中。

库函数调用则是面向应用开发的,库函数可分为两类:

  1. 一类是C语言标准规定的库函数,
  2. 一类是编译器特定的库函数。

(由于版权原因,库函数的源代码一般是不可见的,但在头文件中你可以看到它对外的接口)。

glibc 是 Linux 下使用的开源的标准 C 库,它是 GNU 发布的 libc 库,即运行时库。这些基本函数都是被标准化了的,而且这些函数通常都是用汇编直接实现的。

glibc 为程序员提供丰富的 API(Application Programming Interface),这些API都是遵循POSIX标准的,API的函数名,返回值,参数类型等都必须按照POSIX标准来定义。

POSIX兼容也就指定这些接口函数兼容,但是并不管API具体如何实现。

② 区别

img

如上图所示:

  • 库函数是语言或应用程序的一部分,而系统调用是内核提供给应用程序的接口,属于系统的一部分
  • 库函数在用户地址空间执行,系统调用是在内核地址空间执行,库函数运行时间属于用户时间,系统调用属于系统时间,库函数开销较小,系统调用开销较大
  • 系统调用依赖于平台,库函数并不依赖
函数库调用 系统调用
定义差别 在所有的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等。

img

printf函数执行过程中,程序运行状态切换如下:

1
用户态–>系统调用–>内核态–>返回用户态

printf函数、glibc库和系统调用在系统中关系图如下:

img

2 标准IO

该节对应APUE的第五章——标准IO库

2.1 简介

IO分为标准IO(stdio)和系统调用IO(sysio);

系统调用IO根据操作系统的实现方式而定,对于程序员来说会造成很大困扰(例如打开文件,Linux的系统调用为open,而Windows的系统调用为opendir),于是又有了标准IO,提供了一套标准的IO实现的库函数(例如pringtffopen等),它实际上也是调用了系统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+” 打开一个用于读取和追加的文件。

只有模式rr+要求文件必须存在,其他模式都是有则清空,无则创建;

mode也可以包含字母b,放在最后或者中间,表示二进制流。例如“rb”“r+b”

  • 该函数返回一个 FILE 指针。否则返回 NULL,且设置全局变量 errno 来标识错误。该全局变量在头文件errno.h中声明:(只展示部分)
1
2
3
4
5
6
7
#define EPERM 1 /* Operation not permitted */
#define ENOENT 2 /* No such file or directory */
#define ESRCH 3 /* No such process */
#define EINTR 4 /* Interrupted system call */
#define EIO 5 /* I/O error */
#define ENXIO 6 /* No such device or address */
#define E2BIG 7 /* Argument list too long */

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

int main(void) {
FILE *fp;
fp = fopen("tmp", "r");
if(fp == NULL) {
fprintf(stderr, "fopen() failed! errno = %d.\n", errno);
exit(1);
}
puts("OK!");
exit(0);
}

编译执行后打印结果:

1
fopen() failed! errno = 2.

可知errno为2,为No such file or directory

在C标准中定义了两个函数帮助打印输出errno的对应错误原因,一个是strerror,另一个是perror


perror包含在stdio.h中:

1
2
3
4
5
6
//函数原型
/*
*功能:根据error打印对应的错误信息
*参数:s: 用户自定义信息字符串,一般是出错的函数名
*/
void perror(const char *s);

修改后的程序为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

int main(void) {
FILE *fp;
fp = fopen("tmp", "r");
if(fp == NULL) {
// fprintf(stderr, "fopen() failed! errno = %d.\n", errno);
perror("fopen()");
exit(1);
}
puts("OK!");
exit(0);
}

打印结果:

1
fopen(): No such file or directory

strerror包含在<string.h>头文件中

1
2
3
4
5
//函数原型
/*
*功能:将某个错误代码转换成对应的错误信息
*/
char *strerror(int errnuum);

修改后的程序为:

1
fprintf(stderr, "fopen:%s\n", strerror(errno));

fopen函数解析:

由函数原型可知,fopen函数返回的是一个FILE类型的指针,FILE是一个结构体,由typedef进行了重命名,而指针实际上是指向结构体的指针。

问题:指针指向的内存空间是哪一块(或者说FILE结构体放在内存的哪一块)?是堆,是栈,还是静态区?

1
2
3
4
5
6
7
8
9
10
11
// 简单的fopen源码分析
FILE *fopen(const char *filename, const char *mode) {
FILE tmp;

// 给结构体成员赋值初始化
tmp.xxx = xxx;
tmp.yyy = yyy;
...

return &tmp;
}

分析:tmp变量的存储类别是自动类型(块作用域,自动存储期),当程序退出这个块时,释放刚才为变量tmp匹配的内存,因此,指针指向的地址实际上没有tmp,是一个没有被分配的内存;

  • 静态区
1
2
3
4
5
6
7
8
9
10
11
// 简单的fopen源码分析
FILE *fopen(const char *filename, const char *mode) {
static FILE tmp;

// 给结构体成员赋值初始化
tmp.xxx = xxx;
tmp.yyy = yyy;
...

return &tmp;
}

加上static,将tmp保存在静态区(静态无链接),但是只能存在一个FILE实例(因为只有这一个内存区供指针指向);例如:

1
2
3
fp1 = fopen("a", "r");
fp2 = fopen("b", "r");
// 此时fp1实际指向了b,第二次的结果会把第一次的结果覆盖掉
  • 堆(正解)
1
2
3
4
5
6
7
8
9
10
11
12
// 简单的fopen源码分析
FILE *fopen(const char *filename, const char *mode) {
FILE *tmp = NULL;
tmp = malloc(sizeof(FILE));

// 给结构体成员赋值初始化
tmp->xxx = xxx;
tmp->yyy = yyy;
...

return tmp;
}

此时变量tmp具有动态存储期,从调用malloc分配内存到调用free释放内存为止,而free就在fclose函数中被调用。

2.3 fclose

C 库函数 fclose 关闭流 stream。刷新所有的缓冲区。

1
int fclose(FILE *stream)
  • stream — 这是指向 FILE 对象的指针,该 FILE 对象指定了要被关闭的流。
  • 如果流成功关闭,则该方法返回零。如果失败,则返回 EOF。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

int main(void) {
FILE *fp;
fp = fopen("tmp", "r");
if(fp == NULL) {
perror("fopen()");
exit(1);
}
puts("OK!");
fclose(fp); // 释放内存
exit(0);
}

2.4 fgetc和fputc

  • getcharputchar
1
int getchar(void); // 从标准输入 stdin 获取一个字符(一个无符号字符)。这等同于 getc 带有 stdin 作为参数
1
int putchar(int char); // 把参数 char 指定的字符(一个无符号字符)写入到标准输出 stdout 中。这等同于 putc 带有 stdout 作为参数
  • getcputc
1
int getc(FILE *stream); // 从指定的流 stream 获取下一个字符(一个无符号字符),并把位置标识符往前移动。 
1
int putc(int char, FILE *stream); // 把参数 char 指定的字符(一个无符号字符)写入到指定的流 stream 中,并把位置标识符往前移动。
  • fgetcfputc
1
2
int fgetc(FILE *stream); // 从指定的流 stream 获取下一个字符(一个无符号字符),并把位置标识符往前移动。
// 该函数以无符号 char 强制转换为 int 的形式返回读取的字符,如果到达文件末尾或发生读错误,则返回 EOF。
1
2
int fputc(int char, FILE *stream); // 把参数 char 指定的字符(一个无符号字符)写入到指定的流 stream 中,并把位置标识符往前移动。
// 如果没有发生错误,则返回被写入的字符。如果发生错误,则返回 EOF,并设置错误标识符。

getcputcfgetcfputc的区别

两者的使用完全相同,只是实现不同。这里的f指的是function,而不是file

getcputc是通过宏定义实现,而fgetcfputc是通过函数来实现。

宏只占用编译时间,不占用调用时间,而函数相反,因此内核的实现通常使用宏来定义函数,减少调用时间。

代码示例

需求:拷贝文件

1
./mycpy src dest

实现代码:

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
#include <stdio.h>
#include <stdlib.h>

// 命令行传参
int main(int argc, char **argv) {

FILE *fps, *fpd;
int ch; // 存储读入的字符

if(argc < 3) {
fprintf(stderr, "Usage:%s <src_file> <dest_file>\n", argv[0]);
exit(1);
}

fps = fopen(argv[1], "r");
if(fps == NULL) {
perror("fopen()");
exit(1);
}

fpd = fopen(argv[2], "w");
if(fpd == NULL) {
fclose(fps);
perror("fopen()");
exit(1);
}

while(1) {
ch = fgetc(fps);
if(ch == EOF) { // 读到文件末尾结束循环
break;
}
fputc(ch, fpd);
}

// 释放内存,后开的先关
fclose(fpd);
fclose(fps);

exit(0);
}

使用:

1
./mycpy /usr/local/test /temp/out

2.5 fgets和fputs

  • getsputs
1
2
char *gets(char *str); // 从标准输入 stdin 读取一行,并把它存储在 str 所指向的字符串中。当读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定。
// 如果成功,该函数返回 str。如果发生错误或者到达文件末尾时还未读取任何字符,则返回 NULL。
1
2
int puts(const char *str); // 把一个字符串写入到标准输出 stdout,直到空字符,但不包括空字符。换行符会被追加到输出中。
// 如果成功,该函数返回一个非负值为字符串长度(包括末尾的 \0),如果发生错误则返回 EOF。
  • fgetsfputs
1
2
3
4
// 从指定的流 stream 读取一行,并把它存储在 str 所指向的字符串内。当读取 (n-1) 个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定。
char *fgets(char *str, int n, FILE *stream);
// 如果成功,该函数返回相同的 str 参数。如果到达文件末尾或者没有读取到任何字符,str 的内容保持不变,并返回一个空指针。
// 如果发生错误,返回一个空指针。
1
2
3
//  把字符串写入到指定的流 stream 中,但不包括空字符。
int fputs(const char *str, FILE *stream);
// 该函数返回一个非负值,如果发生错误则返回 EOF。

区别

fgetsgets安全,使用gets编译时会警告。所以不要使用gets

原因:函数 gets 可以无限读取,不会判断上限,所以程序员应该确保 buffer 的空间足够大,以便在执行读操作时不发生溢出。也就是说,gets 函数并不检查缓冲区 buffer 的空间大小,事实上它也无法检查缓冲区的空间。

如果函数的调用者提供了一个指向堆栈的指针,并且 gets 函数读入的字符数量超过了缓冲区的空间(即发生溢出),gets 函数会将多出来的字符继续写入堆栈中,这样就覆盖了堆栈中原来的内容,破坏一个或多个不相关变量的值。


fgets读取结束的条件,满足其一即可:

  • 读到size-1个字符时停止
  • 读到换行符'\n'时停止
  • 读到文件末尾EOF

简单的实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define SIZE 5
char buf[SIZE];
fgets(buf, SIZE, stream);

如果stream = "abcde"
则buf = "abcd\0"(读到size-1),文件指针指向e

如果stream = "ab"
则buf = "ab\n\0"(读到换行符),文件指针指向EOF

极端的情况:
如果stream = "abcd"
则需要fgets读取两次才能读完
第一次读取的为"abcd\0"(读到SIZE-1),指针指向'\n'
第二次读取的为"\n\0"(读到换行符),指针指向EOF

代码示例

fgetsfputs代替fgtecfputc

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
#include <stdio.h>
#include <stdlib.h>
#define SIZE 1024

int main(int argc, char **argv) {

FILE *fps, *fpd;
char buf[SIZE];

if(argc < 3) {
fprintf(stderr, "Usage:%s <src_file> <dest_file>\n", argv[0]);
exit(1);
}

fps = fopen(argv[1], "r");
if(fps == NULL) {
perror("fopen()");
exit(1);
}

fpd = fopen(argv[2], "w");
if(fpd == NULL) {
fclose(fps);
perror("fopen()");
exit(1);
}

while(fgets(buf, SIZE, fps) != NULL)
fputs(buf, fpd);

fclose(fpd);
fclose(fps);

exit(0);
}

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 参数不同,则可能发生了一个错误或者到达了文件末尾。

fwriteptr 所指向的数组中的数据写入到给定流 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fread(buf, size, nmemb, fp);

// 情况1:数据量足够
// 情况2:文件只有5个字节

// 读10个对象,每个对象1个字节
fread(buf, 1, 10, fp);

// 情况1:
// 第一次读:返回10(读到10个对象),读到10个字节
// 情况2:
// 第一次读:返回5(读到5个对象),读到5个字节

//--------------------------------

// 读1个对象,每个对象10个字节
fread(buf, 10, 1, fp);

// 情况1:
// 第一次读:返回1(读到1个对象),也读到10个字节
// 情况2:
// 第一次读:返回0(读不到1个对象,因为1个对象要10字节,而文件只有5个字节)

代码示例

freadfwrite代替fgtecfputc

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
#include <stdio.h>
#include <stdlib.h>
#define SIZE 1024

int main(int argc, char **argv) {

FILE *fps, *fpd;
char buf[SIZE];
int n;

if(argc < 3) {
fprintf(stderr, "Usage:%s <src_file> <dest_file>\n", argv[0]);
exit(1);
}

fps = fopen(argv[1], "r");
if(fps == NULL) {
perror("fopen()");
exit(1);
}

fpd = fopen(argv[2], "w");
if(fpd == NULL) {
fclose(fps);
perror("fopen()");
exit(1);
}

// 如果成功读到n(n>0)个对象,则返回n
// 将这n个对象写入流中
while((n = fread(buf, 1, SIZE, fps)) > 0)
fwrite(buf, 1, n, fpd);

fclose(fpd);
fclose(fps);

exit(0);
}

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
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <stdlib.h>

int main(void) {
char str[] = "123456";

printf("%d\n", atoi(str)); // 123456

exit(0);
}
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <stdlib.h>

int main(void) {
char str[] = "123a456";
// 遇到字符就停止
printf("%d\n", atoi(str)); // 123

exit(0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>

int main(void) {
char buf[1024];
int year = 2022, month = 11, day = 28;

// 将格式化输出重定向为字符串
sprintf(buf, "%d-%d-%d", year, month, day);
puts(buf);

exit(0);
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv){

FILE *fp;
if(argc < 2) {
fprintf(stderr, "Usage...\n");
exit(1);
}

fp = fopen(argv[1], "r");
if(fp == NULL) {
perror("fopen()");
exit(1);
}
// 将指针定位在文件末尾
fseek(fp, 0, SEEK_END);

printf("%d\n", ftell(fp));

exit(0);
}

image-20221130104327899


rewind:设置文件位置为给定流 stream 的文件的开头。

1
void rewind(FILE *stream)

相当于(void) fseek(stream, 0, SEEK_SET);

注意

fseekftell中偏移offset的修饰类型是long,因此只能对2G左右大小的文件进行操作,否则会超出long的范围;

fseekoftello则将偏移的修饰类型使用typedef定义为offset_t,具体类型交由系统决定,因此不存在文件大小的限制。但是这两个函数不是C标准库函数,而是隶属于POSIX标准(POSIX是标准C库的超集,或者说,C库是普通话,而POSIX是方言)。

2.9 fflush

fflush:刷新流 stream 的输出缓冲区。刷新,指的是将缓冲区(内存上的一片区域)的内容写入到磁盘(外存)中,或者输出到终端上显示。

1
int fflush(FILE *stream)
  • 如果参数为NULL,则刷新所有的已打开的流
  • 如果成功,该函数返回零值。如果发生错误,则返回 EOF,且设置错误标识符(即 feof)。

代码示例

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
printf("Before while(1)");
while(1);
printf("After while(1)");
exit(0);
}

打印结果:

1
// 什么都不打印

原因:

  • 对于标准输出,输出缓冲区刷新的时机:
    • 输出缓冲区满
    • 或者遇到换行符\n
    • 强制刷新,或者进程结束

因此,可以修改为:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include <stdlib.h>
int main() {
// 遇到\n刷新
printf("Before while(1)\n");
while(1);
printf("After while(1)\n");
exit(0);
}

或者修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>

int main() {

printf("Before while(1)");
// 强制刷新
fflush(stdout);
// 或者 fflush(NULL);

while(1);

printf("After while(1)");

exit(0);
}

缓冲区的作用:大多数情况下是好事,合并系统调用,增加程序的吞吐量。

缓冲的分类:

  • 行缓冲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
2
3
#define _GNU_SOURCE // 通常将这种宏写在makefile中,现在的编译器没有了该宏,直接使用即可
#include <stdio.h>
ssize_t getline(char **lineptr, size_t *n, FILE *stream);
  • lineptr:指向存放该行字符的指针,如果是NULL,则有系统帮助malloc,请在使用完成后free释放。该参数是一个二级指针,因此传参需要一级指针的地址。即函数会把读取到的字符串的首地址存放在一级指针中。

    1
    2
    3
    4
    5
    // 传参:
    char *ptr;
    // 函数内的实际操作:
    // 假设读取到的字符串Hello的首地址为0x000
    &ptr = 0x000; // 此时ptr就指向了Hello
  • n:如果是由系统malloc的指针填0;

  • stream:函数需要读取的FILE流

  • 返回值:成功返回读取的字节数,失败或读完返回-1。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(int argc, char **argv) {
FILE *fp;
// 一定要初始化,否则指针会指向内存中的随机位置
char *linebuf = NULL;
size_t linesize = 0;
if(argc < 2) {
fprintf(stderr, "Usage...\n");
}
fp = fopen(argv[1], "r");
if(fp == NULL) {
perror("fopen()");
exit(1);
}
while(1) {
// 当返回-1时则读完
if(getline(&linebuf, &linesize, fp) < 0)
break;
printf("%d\n", strlen(linebuf));
}
fclose(fp);
exit(0);
}

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

image-20221201160144166

3.1 简介

UNIX系统的大多数文件IO只需用到5个函数:

  • open
  • close
  • read
  • write
  • lseek

3.2 文件描述符

3.2.1 FILE结构体

查看stdio.h头文件中,有FILE结构体的定义:

1
2
3
4
5
6
7
8
9
10
11
12
//stdio.h
typedef struct _iobuf
{
char* _ptr; //文件输入的下一个位置
int _cnt; //当前缓冲区的相对位置
char* _base; //文件初始位置
int _flag; //文件标志
int _file; //文件有效性
int _charbuf; //缓冲区是否可读取
int _bufsiz; //缓冲区字节数
char* _tmpfname; //临时文件名
} FILE;

其中_file就是文件描述符。

3.2.2 文件描述符

文件描述符(fdfile descriptor)是文件IO(系统IO)中贯穿始终的类型。如下图所示:

image-20221201164844626

  • 当某一个进程执行系统调用open函数,会创建一个结构体,该结构体类似于FILE结构体,其中最基本的成员有一个指针pos,用于指向inode文件的某一个位置;
  • 同时,该进程会维护一个数组(文件描述符表),该数组存储上述结构体的地址,而数组下标就是文件描述符fd,即文件描述符的本质就是一个整型数

    • 该数组默认大小为1024,即可以打开的最大文件数量为1024,但可以设置ulimit来更改数组大小;注意该数组和对应产生的结构体只存在于这个进程空间内,而不是进程间共享;
    • 当调用open函数时,系统会自动打开三个流stdinstdoutstderr,这三个流分别占据该数组的0,1,2号位置;
    • 结构体FILE中的成员_file就是整型数组下标fd,即文件描述符
    • 每打开一个新文件,则占用一个数组空间,而且是空闲的最小的数组下标。即文件描述符优先使用当前可用范围内最小的。同一个文件可以被多次打开,但是每打开一次都需要一个新的文件描述符和新的结构体,例如图中的结构体1和结构体2,指向了同一个inode;
  • 执行系统调用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

image-20221202132740517

或者可以查看单个文件权限:

1
ll atoi

基本的文件属性格式如下:

1
类型 权限 链接数 属主 属组 大小 修改日期 文件名
  • 类型和权限:
    • 第1列表示文件的类型:dcb-lsp
      • d:目录
      • -:普通文件
      • l:软链接(类似Windows的快捷方式)
      • b:块设备文件(例如硬盘、光驱等)
      • p:管道文件
      • c:字符设备文件(例如屏幕等串口设备)
      • s:套接口文件
    • 第2至10列为文件或目录的权限,分为3组:
      • 拥有者权限owner:文件和文件目录的所有者
      • 所属组group:文件和文件目录的所有者所在的组的其他用户
      • 其它用户other:不属于上面的两个的用户
  • 链接数:有多少文件名链接到此节点(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
2
[root@HongyiZeng io]# umask
0002

可以看到umask值为0002

  • 第一个0与特殊权限(特殊权限的内容参见6.6.2节)有关,后三位002则与普通权限rwx有关
  • 002中第一个0与拥有者权限有关,表示从拥有者权限减0,也就是权限不变,所以文件的创建者的权限就是是默认权限rw6 - 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
2
3
[root@HongyiZeng io]# umask 027
[root@HongyiZeng io]# umask
027

即去掉所属组用户的写权限,去掉其他用户的所有权限。

如果需要永久更改umask的话,需要修改文件/etc/bashrc中的umask值:

image-20221202134734807

3.3.2 open

安装man手册:

1
yum install -y man-pages

open用于打开或创建一个文件或者设备。

所在头文件:

1
2
3
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

函数原型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_CREATO_EXCLO_NOCTTYO_TRUNC
  • 文件状态选项:除文件创建选项之外的选项

fopen和open的文件访问模式的联系

1
2
3
4
5
r -> O_RDONLY // 只读存在的文件
r+ -> O_RDWR // 读写存在的文件
w -> O_WRONLY|O_CREAT|O_TRUNC // 只写,并且有则清空,无则创建
w+ -> O_RDWR|O_CREAT|O_TRUNC // 读写,并且有则清空,无则创建
// ...

函数原型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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int printf(const char *fmt, ...){    
int i;
int len;
va_list args; /* va_list 即 char * */

va_start(args, fmt);
/* 内部使用了 va_arg() */
len = vsprintf(g_PCOutBuf,fmt,args);

va_end(args);
for (i = 0; i < strlen(g_PCOutBuf); i++)
{
putc(g_PCOutBuf[i]);
}
return len;
}

3.3.3 close

close:关闭一个文件描述符

1
2
3
#include <unistd.h>

int close(int fd);

返回 0 表示成功,或者 -1 表示有错误发生,并设值errno

3.4 read,write和lseek

read所在头文件和函数原型:

1
2
3
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
  • 从与文件描述符fd相关联的文件中读取前count字节的内容,并且写入到数据区buf中
  • read系统调用返回的是实际读入的字节数,发生错误返回-1

write所在头文件和函数原型:

1
2
3
#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);
  • 把缓存区buf中的前count字节写入到与文件描述符fd有关的文件中
  • write系统调用返回的是实际写入到文件中的字节数,发生错误返回-1,注意返回0不是发生错误,而是写入的字节数为0

lseek所在头文件和函数原型:

1
2
3
4
#include <sys/types.h>
#include <unistd.h>

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

lseek设置文件位置为给定的偏移 offset,参数 offset 意味着从给定的 whence 位置查找的字节数。

whence取值:

字段 含义
SEEK_SET 文件开头
SEEK_END 文件末尾
SEEK_CUR 文件当前位置

代码示例

用系统调用io实现mycpy的功能。

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 <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define BUFSIZE 1024 // 缓冲区大小

int main(int argc, char **argv) {
// 源文件和目标文件的文件描述符
int sfd, dfd;
// 读写缓冲
char buf[BUFSIZE];
// len:读文件的返回字节数
// ret:写文件的返回字节数
// pos:写文件的当前位置
int len, ret, pos;

if(argc < 3) {
fprintf(stderr, "Usage...\n");
exit(1);
}

// 以只读方式打开文件,打开文件失败
if((sfd = open(argv[1], O_RDONLY)) < 0) {
perror("open()");
exit(1);
}

// 以只读方式打开文件,有则清空,无则创建
// 打开文件失败
if((dfd = open(argv[2], O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) {
close(sfd);
perror("open()");
exit(1);
}

while(1) {
if((len = read(sfd, buf, BUFSIZE)) < 0) {
perror("read()");
break;
}

// 读完文件
if(len == 0)
break;

pos = 0;
// 防止读到的字节没有完全写入文件
// 保证读多少,就写多少
while(len > 0) {
if((ret = write(dfd, buf + pos, len)) < 0) {
perror("write()");
exit(1);
}
pos += ret;
len -= ret;
}
}
// 关闭文件描述符
close(dfd);
close(sfd);

exit(0);
}

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
2
3
4
FILE *fp;
// 连续写入两个字符
fputc(fp) -> pos++
fputc(fp) -> pos++

但是,进程维护的结构体中的pos并未加2;只有刷新缓冲区时,该pos才会加2;

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
putchar('a');
write(1, "b", 1);

putchar('a');
write(1, "b", 1);

putchar('a');
write(1, "b", 1);

exit(0);
}

打印结果:

1
bbbaaa

解析:遇到文件IO则立即输出,遇到标准IO,则需要等待缓冲区刷新的时机,这里是进程结束后,进行了强制刷新,将3个a字符输出到终端上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
putchar('a');
fflush(stdout);
write(1, "b", 1);

putchar('a');
fflush(stdout);
write(1, "b", 1);

putchar('a');
fflush(stdout);
write(1, "b", 1);

exit(0);
}

打印结果:

1
ababab

strace命令能够显示所有由用户空间程序发出的系统调用。

以上面第一个程序为例:

1
strace ./ab

打印结果:

image-20221202160702527

BUFSIZE对IO效率的影响

image-20221203120510389

图中用户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)运行时会被加载入内存中,而这个内存一般分为五个分区:栈区、堆区、数据区、常量区、代码区。

image-20221202212432432

image-20221202214105130

3.6.1 动态区

动态区的内容会根据执行情况而动态变化。

① 栈区

栈(stack)是用户存放程序临时创建的局部变量,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且等调用结束后,函数的返回值也会被存放在回栈中。

  • 栈的大小:最大大小由编译时确定,不会太大。
  • 释放和分配:由编译器自动分配释放,由操作系统自动管理,无须手动管理。
  • 栈区地址:由高地址向低地址生长。
  • 若越界访问则会出现段错误(Segmentation Fault)
  • 若多次递归调用增加栈帧导致越界则会出现栈溢出(Stack Overflow)

栈的大小可以通过ulimit命令查看:

1
2
ulimit -s # 只查看stack的大小
ulimit -a # 查看当前所有的资源限制,stack 字段,单位Kbytes

image-20221202212135917

可以看到栈的大小默认为8192KB,即8M;

代码示例

1
2
3
4
5
6
int main() {
int x =10; // 栈存储
int y = 20; // 栈存储

return 0;
}
② 堆区
  • 堆区存放:程序运行中动态存储分配的空间
  • 堆区大小:视内存大小而定,由程序员进行分配。
  • 堆区地址:由低地址向高地址生长

代码示例

1
2
3
4
5
6
7
int main() {
int x =10; // 栈分配
int y = 20; // 栈分配
char *p = (char*)malloc(256); //堆分配

return 0;
}

3.6.2 静态区

静态区的内容在整个程序的生命周期内都存在,由编译器在编译的时候分配。

① 数据区

根据数据是否被初始化又分为:bss段与data段。

  • 未初始化数据段(bss

通常将此段称为bss段,这一名称来源于早期汇编程序的一个操作符,意思是block started by symbol(由符号开始的块)

存放未初始化的全局变量,属于静态内存分配。在程序开始执行之前,内核将此段初始化为0。

代码示例

1
2
3
4
5
6
long sum[1000]; // 此变量存放在非初始化数据段中

int main(void) {
// ...
return 0;
}
  • 已初始化数据段(data

存放已初始化的全局变量和静态变量,属于静态内存分配,其内容由程序初始化。

代码示例

1
2
3
4
5
6
float PI= 3.14f; // 此变量以初值存放在初始化数据段中

int main(void) {
// ...
return 0;
}
② 常量区

常量区存放字符串常量、const修饰的全局变量。程序运行期间,常量区的内容不可以被修改。

③ 代码区

代码区(text),又叫:正文段、代码段。

通常是用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。

3.6.3 栈的地址测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>

int main() {

int x = 10; // 栈分配
int y = 10; // 栈分配

int *p = &x; // 栈分配

printf("&x = %p\n", &x);
printf("&y = %p\n", &y);
printf("&p = %p\n", &p);

exit(0);
}

打印结果:

1
2
3
&x = 0x7ffd1ce3e33c
&y = 0x7ffd1ce3e338
&p = 0x7ffd1ce3e330

可以看到x,y,p的地址从高向低依次排列。

3.6.4 堆的地址测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>

int main() {
int x = 10;
int y = 10;

int *p = &x;
char *q = (char *)malloc(256 * sizeof(char)); // 堆上分配

printf("&x = %p\n", &x);
printf("&y = %p\n", &y);
printf("&p = %p\n", &p);
printf("&q = %p\n", &q);

free(q);
exit(0);
}

打印结果:

1
2
3
4
&x = 0x7ffd4ab330fc
&y = 0x7ffd4ab330f8
&p = 0x7ffd4ab330f0
&q = 0x7ffd4ab330e8

可以看到分配在堆区的q的地址在其他三个的低处,且距离较远。

3.6.5 静态区演示

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
#include <stdio.h>
// 数据区:data段
char m = 'a';
char n = 'a';

// 数据区:bss段
char arr1[10];

// 数据区:data段
char static MAX = 'a';
// 数据区:bss段
char static MIN;

void test() {
// 栈区
int x, y;

// 常量区
const int z = 10;

// 数据区:data段
static char a = 'a';
// 数据区:bss段
static char b;

// "Hello World"在常量区
// p在栈区
const char *p = "Hello World";

// "123456"和arr均在栈区,且地址相同
char arr2[] = "123456";
}

int main(void) {
test();
return 0;
}

3.7 文件共享

如果两个独立进程各自打开了同一文件:

image-20221203121019961

  • 在完成每个write后,在文件表项(即类似于FILE的结构体)中的当前文件偏移量即增加所写入的字节数。如果这导致当前文件偏移量超出了当前文件长度,则将i节点表项中的当前文件长度设置为当前文件偏移量(也就是该文件加长了)。
  • 如果用O_APPEND标志打开一个文件,则相应标志也被设置到文件表项的文件状态标志中。每次对这种具有追加写标志的文件执行写操作时,文件表项中的当前文件偏移量首先会被设置为i节点表项中的文件长度。这就使得每次写入的数据都追加到文件的当前尾端处。
  • 若一个文件用lseek定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置为i节点表项中的当前文件长度。lseek函数只修改文件表项中的当前文件偏移量,不进行任何I/O操作。

可能有多个文件描述符指向同一个文件表项(例如使用dup),对于多个进程读取同一文件都能正确工作。每个进程都有它自己的文件表项,其中也有它自己的当前文件偏移量。但是,当多个进程写同一文件时,则可能产生预想不到的结果。为了说明如何避免这种情况,需要理解原子操作的概念。

原子操作:不可分割的操作;

原子操作的作用:解决竞争和冲突;

3.8 dup和dup2

dup函数用于复制文件描述符,重定向输入输出。

1
2
3
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);

返回值:

  • 成功:
    • dup函数返回当前系统可用的最小整数值,并且该描述符对应的文件与参数描述符oldfd所对应的文件一致,即指向同一个结构体;
    • dup2函数返回第一个不小于newfd的整数值,分两种情况:
      • 如果newfd已经打开,则先将其关闭,再指向文件描述符oldfd的结构体;
      • 如果newfd等于oldfd,则什么也不做;
  • 失败:dup和dup2函数均返回-1,并设置errno。

代码示例

需求:将puts重定向到一个文件中

  • 方法1:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>

#define FNAME "/tmp/out"

int main(void) {
int fd;
close(1); // 关闭stdout,使描述符1空闲
if((fd = open(FNAME, O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) {
perror("open()");
exit(1);
}

puts("Hello World");

exit(0);
}

结果:

1
2
3
[root@HongyiZeng sysio]# ./dup
[root@HongyiZeng sysio]# cat /tmp/out
Hello World
  • 方法2:使用dup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define FNAME "/tmp/out"

int main(void) {
int fd;

if((fd = open(FNAME, O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) {
perror("open()");
exit(1);
}
// 关闭stdout
close(1);
// 复制fd,让其占据1的描述符
dup(fd);
// 关闭fd
close(fd);

puts("Hello World");

exit(0);
}

图示:

image-20221203125442239

注意结构体中有引用计数,当fd=3被关闭时,还有fd=1指向这个结构体,因此结构体不会被销毁掉。存在并发问题。

  • 方法3:使用dup2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define FNAME "/tmp/out"

int main(void) {
int fd;

if((fd = open(FNAME, O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) {
perror("open()");
exit(1);
}
// 如果fd = 1,则什么也不做,返回fd
// 如果fd != 1,则关闭1指向的结构体,再打开1,指向fd的结构体,返回1
dup2(fd, 1);
if(fd != 1) {
close(fd);
}
puts("Hello World");

exit(0);
}

dup2是一个原子操作,相当于:

1
2
3
4
dup2(fd, 1);
// 相当于:
close(1);
dup(fd);

3.9 fcntl和ioctl

fcntl针对文件描述符提供控制。

1
2
3
4
#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );

返回值:若成功,则依赖于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
2
3
#include <sys/ioctl.h>

int ioctl(int d, int request, ...);

ioctl函数一直是IO操作的杂物箱。不能用本章中其他函数表示的I/O操作通常都能用ioctl表示。终端I/O是使用ioctl最多的地方

3.10 /dev/fd目录

对于每个进程,内核都提供有一个特殊的虚拟目录/dev/fd

该目录中包含/dev/fd/n形式的文件名,其中n是与进程中打开文件描述符相对应的编号。也就是说,/dev/fd/0就对应于进程的标志输入。

打开/dev/fd目录中的一个文件等同于复制对应的文件描述符,所以下面两行代码是等价的:

1
2
3
fd = open("/dev/fd/1", O_WRONLY);
// 等价于:
fd = dup(1);

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 概述

image-20221203172157027

树状目录结构:

image-20221203172208754

目录解释:

  • /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
2
3
4
5
[lighthouse@HongyiZeng ~]$ su root
Password:
[root@HongyiZeng lighthouse]# cd ~
[root@HongyiZeng ~]# pwd
/root

如果当前的用户是其他用户,则~代表/home/用户名

1
2
[lighthouse@HongyiZeng ~]$ pwd
/home/lighthouse

4.1.2 /etc/passwd

/etc/passwd为用户信息文件存放路径。

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
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
operator:x:11:0:operator:/root:/sbin/nologin
games:x:12:100:games:/usr/games:/sbin/nologin
ftp:x:14:50:FTP User:/var/ftp:/sbin/nologin
nobody:x:99:99:Nobody:/:/sbin/nologin
systemd-network:x:192:192:systemd Network Management:/:/sbin/nologin
dbus:x:81:81:System message bus:/:/sbin/nologin
polkitd:x:999:998:User for polkitd:/:/sbin/nologin
libstoragemgmt:x:998:997:daemon account for libstoragemgmt:/var/run/lsm:/sbin/nologin
rpc:x:32:32:Rpcbind Daemon:/var/lib/rpcbind:/sbin/nologin
ntp:x:38:38::/etc/ntp:/sbin/nologin
abrt:x:173:173::/etc/abrt:/sbin/nologin
sshd:x:74:74:Privilege-separated SSH:/var/empty/sshd:/sbin/nologin
postfix:x:89:89::/var/spool/postfix:/sbin/nologin
chrony:x:997:995::/var/lib/chrony:/sbin/nologin
tcpdump:x:72:72::/:/sbin/nologin
syslog:x:996:994::/home/syslog:/bin/false
lighthouse:x:1000:1000::/home/lighthouse:/bin/bash

每一行分为7个字段,以冒号:进行分割,含义如下:

字段 含义
用户名 用户登录系统时使用的用户名
密码 密码位,通常将passwd文件中的口令字段使用一个x来代替,将/etc /shadow作为真正的口令文件
UID 用户标识号
GID 缺省组标识号
注释性描述 例如存放用户全名等信息
宿主目录 用户登录系统后的缺省目录
命令解释器 用户使用的shell,默认为bash

4.1.3 /etc/group

/ect/group 文件是用户组配置文件,即用户组的所有信息都存放在此文件中。只列出部分:

1
2
root:x:0:
lighthouse:x:1000:lighthouse

每一行分为4个字段,以冒号:进行分割,含义如下:

字段 含义
组名 用户组名称
组密码 和 /etc/passwd 文件一样,这里的 “x” 仅仅是密码标识
GID 组标识号
组中的用户 此字段列出每个群组包含的所有用户

4.1.4 /usr/include

linux系统编程往往需要引用c头文件,linux下,头文件一般存储到/usr/include

image-20221203190639497

补充 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

image-20221205182107212

每个inode节点(结构体)的大小,一般是128字节或者256字节。inode节点的总数。在格式化时就给定,一般是每1kb或者每2kb就设置一个inode。假定在一块1GB的硬盘中,每个inode节点的大小就会达到128MB。

inode编号是很重要的,当系统在找一个文件时,步骤如下:

  1. 通过文件名先找到文件的inode编号(数组下标)
  2. 通过inode编号找到文件inode信息(结构体)
  3. 通过inode信息中的block指针找到文件内容。
③ 相关命令

stat显示文件的状态信息。stat命令的输出信息比ls命令的输出信息要更详细。

1
2
3
4
5
6
7
8
9
10
11
[root@HongyiZeng sysio]# ls
ab ab.c dup dup.c mycpy mycpy.c test
[root@HongyiZeng sysio]# stat ab.c
文件: ‘ab.c’
大小: 193 块: 8 IO 块: 4096 普通文件
设备: fd01h/64769d Inode: 797269 硬链接: 1
权限: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
最近访问: 2022-12-02 16:05:47.044135336 +0800
最近修改: 2022-12-02 16:05:45.239158807 +0800
最近改动: 2022-12-02 16:05:45.245158729 +0800
创建时间: -

ls -i用于查看某个文件的inode编号。

1
2
[root@HongyiZeng sysio]# ls -i ab.c
797269 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
2
3
4
5
6
7
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

int stat(const char *path, struct stat *buf);
int fstat(int fd, struct stat *buf);
int lstat(const char *path, struct stat *buf);
  • stat:第一个形参:文件路径; 第二个形参:一个指向结构体stat的指针,因此需要传入结构体的地址;
  • fstat:第一个形参是文件描述符;
  • lstat:lstat函数的形参跟stat函数的形参一样。其功能也跟stat函数功能一样,仅有一点不同:stat函数是穿透(追踪)函数,即对软链接文件进行操作时,操作的是链接到的那一个文件,不是软链接文件本身;而lstat函数是不穿透(不追踪)函数,对软链接文件进行操作时,操作的是软链接文件本身。注:软链接严格来说应该叫符号链接
  • 返回值:成功返回0,失败返回-1,并且将详细错误信息赋值给errno全局变量。

struct stat类型的说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct stat {
dev_t st_dev; /* 文件的设备编号 */
ino_t st_ino; /* 索引结点编号 */
mode_t st_mode; /* 文件类型和权限*/
nlink_t st_nlink; /*硬链接数 */
uid_t st_uid; /*用户ID*/
gid_t st_gid; /* 组ID*/
dev_t st_rdev; /* 设备类型(若此文件为设备文件,则为设备编号*/
off_t st_size; /* 文件大小,以字节为单位*/
blksize_t st_blksize; /*文件系统的I/O块大小*/
blkcnt_t st_blocks; /* 块数 */
time_t st_atime; /* 访问时间 */
time_t st_mtime; /* 修改时间 */
time_t st_ctime; /* 更改时间 */
};

代码示例

打印出文件的大小。

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
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

// 注意返回值是off_t类型
static off_t flen(const char *fname) {
struct stat statres; // 声明一个stat类型的结构体statres
if(stat(fname, &statres) < 0) {
perror("stat()");
exit(1);
}
// 返回st_size成员
return statres.st_size;
}

int main(int argc, char **argv) {
if(argc < 2) {
fprintf(stderr, "Usage...\n");
exit(1);
}

printf("total size: %lld\n", flen(argv[1]));

exit(0);
}

off_t类型用于指示文件的偏移量,通常就是long类型,其默认为一个32位的整数,在gcc编译中会被编译为long int类型,在64位的Linux系统中则会被编译为long long int,这是一个64位的整数,其定义在unistd.h头文件中可以查看。

1
2
3
4
5
6
7
8
9
10
11
12
# ifndef __off_t_defined
# ifndef __USE_FILE_OFFSET64
typedef __off_t off_t;
# else
typedef __off64_t off_t;
# endif
# define __off_t_defined
# endif
# if defined __USE_LARGEFILE64 && !defined __off64_t_defined
typedef __off64_t off64_t;
# define __off64_t_defined
# endif

4.2.2 空洞文件

在描述文件属性的结构体stat中,有以下三个描述文件大小的成员:

1
2
3
4
5
struct stat {
off_t st_size; /* 文件大小,以字节为单位*/
blksize_t st_blksize; /*文件系统的I/O块大小*/
blkcnt_t st_blocks; /* 块数 */
};

其中,块大小一般为4096字节,即4KB(一个块为连续8个扇区,每个扇区为512B);块数为该文件的占用的块数;

注意:st_size ≠ st_blksize * st_blocks;或者说,st_size是文件的逻辑大小,而st_blksize * st_blocks是文件的物理大小

代码示例

创建一个5GB大小的文件:

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
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main(int argc, char **argv) {
int fd;

if(argc < 2) {
fprintf(stderr, "Usage...");
exit(1);
}

if((fd = open(argv[1], O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) {
perror("open");
exit(1);
}
// 先让指针从文件开头向后移动5G个字节
lseek(fd, 5LL * 1024LL * 1024LL * 1024LL - 1LL, SEEK_SET);
// 在最后写入一个空字符
write(fd, "", 1);

close(fd);

exit(0);
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
[root@HongyiZeng fs]# make big
cc big.c -o big
[root@HongyiZeng fs]# ./big /tmp/bigfile
[root@HongyiZeng fs]# stat /tmp/bigfile
File: ‘/tmp/bigfile’
Size: 5368709120 Blocks: 8 IO Block: 4096 regular file
Device: fd01h/64769d Inode: 26199 Links: 1
Access: (0600/-rw-------) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-12-05 16:19:40.734216438 +0800
Modify: 2022-12-05 16:19:40.734216438 +0800
Change: 2022-12-05 16:19:40.734216438 +0800
Birth: -

可以看出,大小为5368709120B,但是占用的块数却为8,即实际占用的物理大小为4KB * 8 = 32KB

如果将该文件进行拷贝,再查看拷贝文件的属性:

1
2
3
4
5
6
7
8
9
10
[root@HongyiZeng fs]# cp /tmp/bigfile /tmp/bigfile.bak
[root@HongyiZeng fs]# stat /tmp/bigfile.bak
File: ‘/tmp/bigfile.bak’
Size: 5368709120 Blocks: 0 IO Block: 4096 regular file
Device: fd01h/64769d Inode: 26200 Links: 1
Access: (0600/-rw-------) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-12-05 16:26:39.166784814 +0800
Modify: 2022-12-05 16:26:39.166784814 +0800
Change: 2022-12-05 16:26:39.166784814 +0800
Birth: -

使用 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位。

image-20221205165451681

② 实例分析

假设st_mode表示为八进制的100664

image-20221205165902073

则有:

  • 1000: 这是一个常规文件
  • 000: 执行时设置信息为空,黏着位为 0
  • 110-110-100: 用户权限为 RW-,组员权限为RW-,其他人权限为R--
③ 宏

通过手工分析 st_mode 字段,实际上是很不方便的。实际写程序,可使用 st_mode & 掩码来得到 st_mode 中特定的部分。比如:

1
2
3
4
5
st_mode & 0170000 : 得到文件类型
st_mode & 0007000 : 得到执行文件时设置信息
st_mode & 0000777 : 得到权限位
st_mode & 00100: 判断所有者是否可执行
(以上数字均为八进制)

为了方便使用,用 linux 预定义的一些宏来代替这些生硬的数字。

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
//bit15 ~ bit12 , 文件类型属性区域
#define S_IFMT 0170000 文件类型的位遮罩
#define S_IFSOCK 0140000 socket
#define S_IFLNK 0120000 符号链接(symbolic link)
#define S_IFREG 0100000 一般文件
#define S_IFBLK 0060000 区块装置(block device)
#define S_IFDIR 0040000 目录
#define S_IFCHR 0020000 字符装置(character device)
#define S_IFIFO 0010000 先进先出(fifo)

#define S_ISSOCK(m) (((m) & S_IFMT) == S_IFSOCK) //提供了一些宏函数来帮助用户执行&操作,是则返回1
#define S_ISLNK(m) (((m) & S_IFMT) == S_IFLNK)
#define S_ISREG(m) (((m) & S_IFMT) == S_IFREG)
#define S_ISBLK(m) (((m) & S_IFMT) == S_IFBLK)
#define S_ISDIR(m) (((m) & S_IFMT) == S_IFDIR)
#define S_ISCHR(m) (((m) & S_IFMT) == S_IFCHR)
#define S_ISFIFO(m) (((m) & S_IFMT) == S_IFIFO)

//bit11 ~ bit9,权限的特殊属性区域
#define S_ISUID 0004000 文件的(set user-id on execution)位
#define S_ISGID 0002000 文件的(set group-id on execution)位
#define S_ISVTX 0001000 文件的sticky位

//bit8 ~ bit0,权限属性区域
//文件所有者(owner)
#define S_IRWXU 00700 /* mask for file owner permissions */
#define S_IRUSR 00400 /* owner has read permission */
#define S_IWUSR 00200 /* owner has write permission */
#define S_IXUSR 00100 /* owner has execute permission */
//组用户(group)
#define S_IRWXG 00070 /* mask for group permissions */
#define S_IRGRP 00040 /* group has read permission */
#define S_IWGRP 00020 /* group has write permission */
#define S_IXGRP 00010 /* group has execute permission */
//其他用户(other)
#define S_IRWXO 00007 /* mask for permissions for others (not in group) */
#define S_IROTH 00004 /* others have read permission */
#define S_IWOTH 00002 /* others have write permission */
#define S_IXOTH 00001 /* others have execute permission */
④ 代码示例
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
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

static int ftype(const char *fname) {
struct stat statres;

if(stat(fname, &statres) < 0) {
perror("stat()");
exit(1);
}

if(S_ISREG(statres.st_mode))
return '-';
else if(S_ISDIR(statres.st_mode))
return 'd';
else if(S_ISSOCK(statres.st_mode))
return 's';
else
return '?';
}

static int fper(const char *fname) {
struct stat statres;

if(stat(fname, &statres)) {
perror("fper()");
exit(1);
}

return statres.st_mode & S_IRWXU;
}


int main(int argc, char **argv) {
if(argc < 2) {
fprintf(stderr, "Usage...\n");
exit(1);
}

printf("File type: %c\n", ftype(argv[1]));
printf("Permission of owner: %o\n", fper(argv[1]));
exit(0);
}

执行结果:

1
2
3
4
5
6
[root@HongyiZeng fs]# ./ftype ftype.c 
File type: -
Permission of owner: 600
[root@HongyiZeng fs]# ./ftype ftype
File type: -
Permission of owner: 700

4.2.4 文件权限

① umask

umask函数原型:

1
2
#include <sys/stat.h>
mode_t umask(mode_t mask);

在进程创建一个新的文件或目录时,如调用open函数创建一个新文件,新文件的实际存取权限是mode与umask按照 mode & ~umask运算以后的结果。umask函数用来修改进程的umask,作用是防止出现权限过松的文件。

② chmod

补充:chmod命令

语法:

1
chmod [对谁操作(ugoa)] [操作符 (+-=)] [赋予的权限(rwxs或数字)] 文件名1 文件名2...

image-20221205171940635

例如:

1
2
3
4
5
[root@HongyiZeng fs]# ll big.c
-rw-r--r-- 1 root root 420 Dec 5 16:19 big.c
[root@HongyiZeng fs]# chmod 664 big.c
[root@HongyiZeng fs]# ll big.c
-rw-rw-r-- 1 root root 420 Dec 5 16:19 big.c
1
2
3
4
5
6
7
8
[root@HongyiZeng fs]# ll big.c
-rw-rw-r-- 1 root root 420 Dec 5 16:19 big.c
[root@HongyiZeng fs]# chmod a+x big.c
[root@HongyiZeng fs]# ll big.c
-rwxrwxr-x 1 root root 420 Dec 5 16:19 big.c
[root@HongyiZeng fs]# chmod o-x big.c
[root@HongyiZeng fs]# ll big.c
-rwxrwxr-- 1 root root 420 Dec 5 16:19 big.c

chmod函数原型:

1
2
3
#include <sys/stat.h>
int chmod(const char *pathname, mode_t mode);
int fchmod(int fd, mode_t mode);

chmod是对指定的文件进行操作,而fchmod则是对已经打开的文件进行操作。所以它们的第一个参数不一样。

返回值:如果改变成功返回0,否则返回-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
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
int fd = open("a", O_RDWR);
if (-1 == fd)
{
perror("open fail");
exit(1);
}
// 将fd的权限更改为0777
if(-1 == fchmod(fd, 0777))
{
perror("fchmod fail");
exit(1);
}

// 将b文件的权限更改为0777
if (-1 == chmod("b", 0777))
{
perror("fchmod fail");
exit(1);
}

close(fd);
return 0;
}
③ 粘住位

在UNIX尚未使用分页技术的早期版本中,S_ISVTX位被称为粘住位(sticky bit)。

如果一个可执行程序文件的这一位被设置了,那么在该程序第一次被执行并结束时,其程序正文部分的一个副本仍被保存在交换区,(程序的正文部分是机器指令部分)。这使得下次执行该程序时能较快地将其装入内存中。其原因是:交换区占用连续磁盘空间,可将它视为连续文件,而且一个程序的正文部分在交换区中也是连续存放的,而在一般的UNIX文件系统中,文件的各数据块很可能是随机存放的。

对于常用的应用程序,例如文本编辑器和C编译器,我们常常设置它们所在文件的粘住位。自然,对于在交换区中可以同时存放的设置了粘住位的文件数是有一定限制的,以免过多占用交换区空间,但无论如何这是一个有用的技术。后来的UNIX版本称它为保存正文位(saved-text bit),因此也就有了常量S_ISVTX。现今较新的UNIX系统大多数都配置有虚拟存储系统以及快速文件系统,所以不再需要使用这种技术。

现今的系统扩展了粘住位的使用范围,允许针对目录设置粘住位。如果对一个目录设置了粘住位,则只有对该目录具有写权限的用户在满足下列之一的情况下,才能删除或更名该目录下的文件或目录:

  • 拥有此文件
  • 拥有此目录
  • 是超级用户

目录/tmp是设置粘住位的典型候选者:任何用户都可在这个目录中创建文件。任一用户(用户、组和其他)对这个目录的权限通常都是读、写和执行(rwx)。但是用户不应能删除或更名属于其他人的文件,为此在这个目录的文件模式中都设置了粘住位。

即:假如/tmp下的文件A被用户U1所有,文件A的权限为777,那么所有用户都可以对该文件进行修改、移动、重命名等操作,但无法删除该文件。通常的用途在于用户团队协作的目录,用户可以相互修改文件,却只有用户所有者才能删除

image-20221205173224552

图中最后一位t就是粘住位。

4.2.5 文件系统

① 磁盘的结构

image-20221205180529903

磁盘

一个磁盘(如一个 1T 的机械硬盘)由多个盘片(如下图中的 0 号盘片)叠加而成。

盘片的表面涂有磁性物质,这些磁性物质用来记录二进制数据。因为正反两面都可涂上磁性物质,故一个盘片可能会有两个盘面。

image-20221205180620706

磁道和扇区

每个盘片被划分为一个个磁道(一个一个半径不同的同心圆环),每个磁道又划分为一个个扇区(磁道上的一个弧段)。扇区是磁盘的最小组成单元,通常是512字节。如下图:

image-20221205180731436

其中,最内侧磁道上的扇区面积最小,因此数据密度最大。

柱面

每个盘面对应一个磁头。所有的磁头都是连在同一个磁臂上的,因此所有磁头只能“共进退”。

所有盘面中半径相同的磁道组成柱面。如下图:

image-20221205180822250

磁盘容量计算

存储容量 = 磁头数 × 磁道(柱面)数 × 每道扇区数 × 每扇区字节数

磁盘的物理地址

由上,可用(柱面号,盘面号,扇区号)来定位任意一个“磁盘块”,这里的“磁盘块”,实质上就是一个扇区。

可根据该地址读取一个“块”,操作如下:

  1. 根据“柱面号”前后移动磁臂,让磁头指向指定柱面;
  2. 旋转磁盘,让磁头抵达待读的起始扇区。
  3. 激活指定盘面对应的磁头;
  4. 旋转磁盘,指定的扇区会从磁头下面划过,这样就完成了对指定扇区的读/写。

块和簇

磁盘块/簇(虚拟出来的)。 块是操作系统中最小的逻辑存储单位。操作系统与磁盘打交道的最小单位是磁盘块。

在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

我们可以把一个磁盘分成一个或多个分区。每个分区可以包含一个文件系统

image-20221205180335105

  • i节点即为inode结构体的数组
  • 数据块一般被分成了大小为4KB的块(block)
  • i节点图(i节点位图):用来判断inode的空闲与占用情况
  • 块位图:用来判断数据块的占用与空闲情况

对于普通文件,如下图:

image-20221205181426945

  • i节点结构体中通常包含15个指针来指向数据块,最后三个指针为一二三级指针,用于扩充文件的大小;图中的i节点指向了三个数据块。
  • 在图中有两个目录项(两个不同文件名的文件,但是inode编号相同)指向同一个i节点(此时称为硬链接,即目录项就是硬链接的同义词)。每个i节点中都有一个硬链接计数st_nlink,其值是指向该i节点的目录项数。只有当链接计数减少至0时,才可删除该文件(也就是可以释放该文件占用的数据块)。
  • i节点包含了文件有关的所有信息∶文件类型、文件访问权限位、文件长度和指向文件数据块的指针等。stat 结构中的大多数信息都取自i节点。只有两项重要数据存放在目录项中∶文件名和i节点编号

对于目录,目录也是一种文件,它的属性也需要inode结构体存储,它的物理存储也需要通过inode中的指针来指向的数据块(此时的数据块就是目录块)来存储;

目录块存储的内容非常的简单,由目录项组成,每条目录项有包含的文件名以及该文件名对应的inode编号

如下图所示:

image-20221205194706533

  • 编号为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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[root@HongyiZeng tmp]# ln bigfile bigfile_link
[root@HongyiZeng tmp]# stat bigfile
File: ‘bigfile’
Size: 5368709120 Blocks: 8 IO Block: 4096 regular file
Device: fd01h/64769d Inode: 26199 Links: 2
Access: (0600/-rw-------) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-12-05 16:26:39.166784814 +0800
Modify: 2022-12-05 16:19:40.734216438 +0800
Change: 2022-12-05 20:29:33.176048702 +0800
Birth: -
[root@HongyiZeng tmp]# stat bigfile_link
File: ‘bigfile_link’
Size: 5368709120 Blocks: 8 IO Block: 4096 regular file
Device: fd01h/64769d Inode: 26199 Links: 2
Access: (0600/-rw-------) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-12-05 16:26:39.166784814 +0800
Modify: 2022-12-05 16:19:40.734216438 +0800
Change: 2022-12-05 20:29:33.176048702 +0800
Birth: -

字段Link即为文件的硬链接数,硬链接数为2,说明有两个目录项指向了这个inode,并且注意两个文件的inode编号相同,说明同时指向了这个inode;

将源文件bigfile删除后,bigfile_link仍然存在:

1
2
3
4
5
6
7
8
9
10
[root@HongyiZeng tmp]# rm -rf bigfile
[root@HongyiZeng tmp]# stat bigfile_link
File: ‘bigfile_link’
Size: 5368709120 Blocks: 8 IO Block: 4096 regular file
Device: fd01h/64769d Inode: 26199 Links: 1
Access: (0600/-rw-------) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-12-05 16:26:39.166784814 +0800
Modify: 2022-12-05 16:19:40.734216438 +0800
Change: 2022-12-05 20:33:03.459331364 +0800
Birth: -

此时硬链接数Link变为1;


符号链接对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[root@HongyiZeng tmp]# ln -s bigfile_link bigfile_s
[root@HongyiZeng tmp]# stat bigfile_link
File: ‘bigfile_link’
Size: 5368709120 Blocks: 8 IO Block: 4096 regular file
Device: fd01h/64769d Inode: 26199 Links: 1
Access: (0600/-rw-------) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-12-05 16:26:39.166784814 +0800
Modify: 2022-12-05 16:19:40.734216438 +0800
Change: 2022-12-05 20:33:03.459331364 +0800
Birth: -
[root@HongyiZeng tmp]# stat bigfile_s
File: ‘bigfile_s’ -> ‘bigfile_link’
Size: 12 Blocks: 0 IO Block: 4096 symbolic link
Device: fd01h/64769d Inode: 26201 Links: 1
Access: (0777/lrwxrwxrwx) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2022-12-05 20:34:11.189456100 +0800
Modify: 2022-12-05 20:34:11.189456100 +0800
Change: 2022-12-05 20:34:11.189456100 +0800
Birth: -
[root@HongyiZeng tmp]# ll bigfile_s
lrwxrwxrwx 1 root root 12 Dec 5 20:34 bigfile_s -> bigfile_link

可以看到硬链接数并未改变;此外符号链接文件的大小为12字节,物理占用的块甚至为0;文件类型标识也变为了symbolic linkl

删除原始文件后,发现符号链接文件的链接已成非法链接(红色部分):

image-20221205203655039


相关的系统调用:

link 函数专门用来创建硬链接的,功能和 ln 命令一样。

1
2
#include <unistd.h>
int link(const char *oldpath, const char *newpath);

unlink函数删除一个文件的目录项并减少它的链接数,若成功则返回0,否则返回-1,错误原因存于error。

1
2
#include <unistd.h>
int unlink(const char *pathname)

对比:硬链接不能给分区建立,不能给目录建立,而符号链接可以。

4.2.6 杂项

文件的删除、重命名和移动

  • remove
1
2
#include <stdio.h>
int remove(const char *pathname);
  • rename:是mv命令的系统调用
1
2
#include <stdio.h>
int rename(const char *oldpath, const char *newpath);

更改文件的时间

  • utime:可更改文件的最后读的时间和最后修改的时间
1
2
3
#include <sys/types.h>
#include <utime.h>
int utime(const char *filename, const struct utimbuf *times);

更改当前工作路径

  • chdir:是cd命令的系统调用
1
2
3
#include <unistd.h>
int chdir(const char *path);
int fchdir(int fd);
  • getcwd:是pwd命令的系统调用
1
2
3
4
#include <unistd.h>
char *getcwd(char *buf, size_t size);
char *getwd(char *buf);
char *get_current_dir_name(void);

4.2.7 glob

该节内容为分析目录和读取目录内容。

glob函数原型(模型匹配函数):

1
2
3
4
5
6
#include <glob.h>
int glob(const char *pattern, int flags,
int (*errfunc) (const char *epath, int eerrno),
glob_t *pglob);
// 释放glob函数调用的空间
void globfree(glob_t *pglob);
  • pattern: 通配符,要分析的pattern,如/*表示匹配根文件下的所有文件(不包括隐藏文件)
  • flags:flags参数可以设置特殊要求,如无特殊要求置为0
  • errfunc:函数指针,glob函数执行出错会执行的函数,出错的路径会回填到epath中,出错的原因回填到eerrno中。如不关注错误可设置为NULL
  • pglob:解析出来的结果放在这个参数里,是一个结构体指针
  • 返回值:成功返回0,错误返回非0

其中,glob_t是一个结构体:

1
2
3
4
5
6
typedef struct {
// pathc与pathv类似于main函数参数argc与argv
size_t gl_pathc; //匹配到的数量
char **gl_pathv; //匹配到的元素放在这里
size_t gl_offs;
} glob_t;

程序实例

打印出/etc/*.conf的文件名。

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
#include <stdio.h>
#include <stdlib.h>
#include <glob.h>
// 通配符
#define PAT "//etc//*.conf"

static int errfunc(const char *epath, int eerrno) {
puts(epath);
fprintf(stderr, "ERROR: %d", eerrno);
return 1;
}

int main() {
glob_t globres;
int err;
err = glob(PAT, 0, errfunc, &globres);

if(err) {
printf("ERROR CODE = %d\n", err);
exit(1);
}

int i;

for(i = 0; i < globres.gl_pathc; i++) {
puts(globres.gl_pathv[i]);
}
// 释放空间
globfree(&globres);
exit(0);
}

执行结果:

1
2
3
4
5
6
[root@HongyiZeng fs]# ./glob 
/etc/GeoIP.conf
/etc/asound.conf
/etc/chrony.conf
/etc/dat.conf
其余略

一组对目录操作的函数

和对文件操作的函数基于FILE结构体类似,对目录的操作基于名为DIR的结构体。

下面是常用的对目录进行操作的函数,他们的功能可以被glob替代。

暂略

5 进程环境

本节对应第七章——进程环境;

5.1 main函数

C程序总是从main函数开始执行,从main函数结束执行。即main是程序的入口和出口。

当内核执行C程序时(使用一个exec函数),在调用main前先调用一个特殊的启动例程。可执行程序文件将此启动例程指定为程序的起始地址———这是由连接编辑器设置的,而连接编辑器则由C编译器调用。

启动例程从内核取得命令行参数和环境变量值,然后为按上述方式调用main函数做好安排。

5.2 进程终止

5.2.1 终止方式

共有8种方式让进程终止。其中5种为正常退出:

  1. 从main返回
  2. 调用exit(C库函数)
  3. 调用_exit_Exit(系统调用)
  4. 最后一个线程从其启动例程返回
  5. 从最后一个线程调用pthread_exit

异常终止有3种方式:

  1. 调用abort
  2. 接到一个信号
  3. 最后一个线程对取消请求做出响应

5.2.2 main函数的返回值

main函数的返回值给main函数的父进程

假设一个程序名为main_test,则在终端上执行该程序时,父进程为shell

1
./main_test

可通过$?(表示显示最后命令的退出状态)查看该返回值。

1
2
3
4
5
6
7
#include <stdio.h>
#include <stdlib.h>

int main() {
printf("Hello World!\n");
return 0;
}

执行结果:

1
2
3
4
[root@HongyiZeng fs]# ./main1 
Hello World!
[root@HongyiZeng fs]# echo $?
0

如果将程序更改为:

1
2
3
4
5
6
#include <stdio.h>
#include <stdlib.h>

int main() {
printf("Hello World!\n");
}

则执行结果为:

1
2
3
4
[root@HongyiZeng fs]# ./main1 
Hello World!
[root@HongyiZeng fs]# echo $?
13

此时会将printf的返回值(打印字符的个数)作为main函数的返回值给父进程shell

5.2.3 钩子函数atexit

按照ISO C的规定,一个进程可以登记多至32个函数,这些函数将由exit自动调用。我们称这些函数为终止处理程序exit handler),并调用 atexit 函数来登记这些函数。

1
int atexit(void (*func)(void));

atexit当程序正常终止时,调用指定的函数(终止处理程序) func。可以在任何地方注册终止函数,但它会在程序终止的时候被调用。先注册的后调用。

  • func — 在程序终止时被调用的函数,该函数无参且无返回值,它是一个函数指针,因此传入的参数应该是一个函数的地址,即函数名(函数名就是函数的首地址)。
  • 如果函数成功注册,则该函数返回零,否则返回一个非零值。

程序实例

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
#include <stdio.h>
#include <stdlib.h>

// 终止处理程序
static void f1() {
puts("f1() is working!");
}

// 终止处理程序
static void f2() {
puts("f2() is working!");
}

// 终止处理程序
static void f3() {
puts("f3() is working!");
}

int main() {
puts("Begin");
// 先注册的后被调用
// 钩子函数的书写顺序并不是实际执行顺序,atexit会在程序终止时被调用
// atexit参数用指针来接收,因此需要传入地址,而函数名就是函数的地址
atexit(f1);
atexit(f2);
atexit(f3);

puts("End");
exit(0);
}

执行结果:

1
2
3
4
5
Begin
End
f3() is working!
f2() is working!
f1() is working!

5.2.4 exit和_exit

exit是库函数,而_exit是系统调用,前者使用了后者。

除此之外,_exit()执行后会立即返回给内核,而exit()要先执行一些清除和终止操作,然后将控制权交给内核。

image-20221210113101221

5.3 命令行参数

暂略

5.4 环境变量

5.4.1 简介

环境变量的含义:程序(操作系统命令和应用程序)的执行都需要运行环境,这个环境是由多个环境变量组成的。

按变量的周期划为永久变量和临时性变量2种:

  1. 永久变量:通过修改配置文件,配置之后变量永久生效。
  2. 临时性变量:使用命令如export等命令设置,设置之后马上生效。当关闭shell的时候失效(这种主要用于测试比较多)。

按照影响范围分为用户变量和系统变量2种:

  1. 用户变量(局部变量):修改的设置只对某个用户的路径或执行起作用;
  2. 系统变量(全局变量):影响范围是整个系统;

环境变量本质上是一个kv键值对

5.4.2 查看环境变量

在Shell下,用env命令查看当前用户全部的环境变量。

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
[root@HongyiZeng fs]# env
XDG_SESSION_ID=139874
HOSTNAME=HongyiZeng
TERM=xterm-256color
SHELL=/bin/bash
HISTSIZE=3000
SSH_TTY=/dev/pts/0
USER=root
MAIL=/var/spool/mail/root
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/usr/local/java/jdk1.8.0_161/bin:/root/bin:/root/bin
PWD=/usr/local/linux_c/fs
JAVA_HOME=/usr/local/java/jdk1.8.0_161
LANG=en_US.UTF-8
NEXUS_HOME=/usr/local/nexus/nexus-3.20.1-01
SHLVL=1
HOME=/root
LOGNAME=root
CLASSPATH=/usr/local/java/jdk1.8.0_161/lib/
SSH_CONNECTION=81.69.102.136 35390 10.0.24.5 22
LESSOPEN=||/usr/bin/lesspipe.sh %s
PROMPT_COMMAND=history -a; history -a; printf "\033]0;%s@%s:%s\007" "${USER}" "${HOSTNAME%%.*}" "${PWD/#$HOME/~}"
XDG_RUNTIME_DIR=/run/user/0
HISTTIMEFORMAT=%F %T
_=/usr/bin/env
OLDPWD=/usr/local/linux_c

export命令显示当前系统定义的所有环境变量。

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
[root@HongyiZeng fs]# export
declare -x CLASSPATH="/usr/local/java/jdk1.8.0_161/lib/"
declare -x HISTSIZE="3000"
declare -x HISTTIMEFORMAT="%F %T "
declare -x HOME="/root"
declare -x HOSTNAME="HongyiZeng"
declare -x JAVA_HOME="/usr/local/java/jdk1.8.0_161"
declare -x LANG="en_US.UTF-8"
declare -x LESSOPEN="||/usr/bin/lesspipe.sh %s"
declare -x LOGNAME="root"
declare -x MAIL="/var/spool/mail/root"
declare -x NEXUS_HOME="/usr/local/nexus/nexus-3.20.1-01"
declare -x OLDPWD="/usr/local/linux_c"
declare -x PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/usr/local/java/jdk1.8.0_161/bin:/root/bin:/root/bin"
declare -x PROMPT_COMMAND="history -a; history -a; printf \"\\033]0;%s@%s:%s\\007\" \"\${USER}\" \"\${HOSTNAME%%.*}\" \"\${PWD/#\$HOME/~}\""
declare -x PWD="/usr/local/linux_c/fs"
declare -x SHELL="/bin/bash"
declare -x SHLVL="1"
declare -x SSH_CLIENT="81.69.102.136 35390 22"
declare -x SSH_CONNECTION="81.69.102.136 35390 10.0.24.5 22"
declare -x SSH_TTY="/dev/pts/0"
declare -x TERM="xterm-256color"
declare -x USER="root"
declare -x XDG_RUNTIME_DIR="/run/user/0"
declare -x XDG_SESSION_ID="139874"

查看某个环境变量的值:

1
echo $KEY

例如,查看PATH的值:

1
2
[root@HongyiZeng fs]# echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/usr/local/java/jdk1.8.0_161/bin:/root/bin:/root/bin

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 为环境指针,指针数组为环境表,其中各指针指向的字符串为环境字符串。

image-20221210163517747

代码示例

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

// 引用声明外部的变量
extern char **environ;

int main(void) {
int i;
for(i = 0; environ[i] != NULL; i++) {
puts(environ[i]);
}
exit(0);
}

相关库函数:

1
2
3
4
#include <stdlib.h>

// 根据环境变量的键name来获取环境变量的值
char *getenv(const char *name);
1
2
3
4
5
6
7
8
#include <stdlib.h>

// 键name,值value,overwrite是否覆盖
// 当name存在时,且overwrite为1,表示改变环境变量
int setenv(const char *name, const char *value, int overwrite);

// 根据key删除环境变量
int unsetenv(const char *name);

程序实例

1
2
3
4
5
6
7
#include <stdio.h>
#include <stdlib.h>

int main(void) {
puts(getenv("PATH"));
exit(0);
}

5.5 共享库

共享库使得可执行文件中不再需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存这种库例程的一个副本。

程序第一次执行或者第一次调用某个库函数时,用动态链接方法将程序与共享库函数相链接。这减少了每个可执行文件的长度,但增加了一些运行时间开销。这种时间开销发生在该程序第一次被执行时,或者每个共享库函数第一次被调用时。

共享库的另一个优点是可以用库函数的新版本代替老版本而无需对使用该库的程序重新连接编辑(假定参数的数目和类型都没有发生改变)。

动态库的相关库函数

1
2
3
4
5
6
7
8
9
10
11
12
13
// dladdr,  dlclose,  dlerror,  dlopen,  dlsym, dlvsym - programming interface to dynamic linking loader
#include <dlfcn.h>
// 该函数将打开一个新库,并把它装入内存。该函数主要用来加载库中的符号,这些符号在编译的时候是不知道的。这种机制使得在系统中添加或者删除一个模块时,都不需要重新进行编译
void *dlopen(const char *filename, int flag);

// 返回一个描述最后一次调用dlopen、dlsym,或dlclose的错误信息的字符串
char *dlerror(void);

// 在打开的动态库中查找符号的值
void *dlsym(void *handle, const char *symbol);

// 关闭动态库
int dlclose(void *handle);

5.6 函数间跳转

补充:goto语句

C 语言中的 goto 语句允许把控制无条件转移到同一函数内的被标记的语句。

语法:

1
2
3
4
goto label;
..
.
label: statement;

在这里,label 可以是任何除 C 关键字以外的纯文本,它可以设置在 C 程序中 goto 语句的前面或者后面。

image-20221211193000103

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main() {
/* 局部变量定义 */
int a = 10;

/* do 循环执行 */
LOOP:
do {
if( a == 15) {
/* 跳过迭代 */
a = a + 1;
goto LOOP;
}
printf("a 的值: %d\n", a);
a++;
} while(a < 20);

return 0;
}

执行结果:

1
2
3
4
5
6
7
8
9
a 的值: 10
a 的值: 11
a 的值: 12
a 的值: 13
a 的值: 14
a 的值: 16
a 的值: 17
a 的值: 18
a 的值: 19

setjmplongjmp可以实现非局部控制转移即从一个函数到另外一个函数的跳转。

函数原型:

1
2
3
#include <setjmp.h>
int setjmp(jmp_buf buf);
void longjmp(jmp_buf env, int val);
  • setjmp函数用于记录当前位置,保存调用函数的栈环境在结构体jmp_buf buf(相当于保护现场)。函数输入参数为jmp_buf类型(这个结构体类似于goto的跳转标识),返回整型。当第一次调用时(设置跳转点),它的值为0;当第二次调用时(从别处跳转回来,即调用longjmp时)返回非零值;总之执行一次,返回两次,因此,setjmp函数后常常跟上分支语句。

  • longjmp的作用是使用setjmp保存在buf中的栈环境信息返回到setjmp的位置,也就是当执行longjmp时程序又回到setjmp处(相当于恢复现场)。形参val是调用longjmpsetjmp函数返回的值,为非零值,如果故意设置为0,也会被修改为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
#include <stdio.h>
#include <stdlib.h>

static void d(void) {
printf("%s():Begin.\n", __FUNCTION__);
printf("%s():End.\n", __FUNCTION__);
}

static void c(void) {
printf("%s():Begin.\n", __FUNCTION__);
printf("%s():Call d().\n", __FUNCTION__);
d();
printf("%s():d() returned.\n", __FUNCTION__);
printf("%s():End.\n", __FUNCTION__);
}


static void b(void) {
printf("%s():Begin.\n", __FUNCTION__);
printf("%s():Call c().\n", __FUNCTION__);
c();
printf("%s():c() returned.\n", __FUNCTION__);
printf("%s():End.\n", __FUNCTION__);
}


static void a(void) {
printf("%s():Begin.\n", __FUNCTION__);
printf("%s():Call b().\n", __FUNCTION__);
b();
printf("%s():b() returned.\n", __FUNCTION__);
printf("%s():End.\n", __FUNCTION__);
}

int main(void) {

printf("%s():Begin.\n", __FUNCTION__);
printf("%s():Call a().\n", __FUNCTION__);
a();
printf("%s():a() returned.\n", __FUNCTION__);
printf("%s():End.\n", __FUNCTION__);
exit(0);
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
main():Begin.
main():Call a().
a():Begin.
a():Call b().
b():Begin.
b():Call c().
c():Begin.
c():Call d().
d():Begin.
d():End.
c():d() returned.
c():End.
b():c() returned.
b():End.
a():b() returned.
a():End.
main():a() returned.
main():End.

注:ANSI C 定义了许多宏。在编程中可以使用这些宏,但是不能直接修改这些预定义的宏。

例如:

1
2
3
4
5
__DATE__ 当前日期,一个以 “MMM DD YYYY” 格式表示的字符串常量。
__TIME__ 当前时间,一个以 “HH:MM:SS” 格式表示的字符串常量。
__FILE__ 这会包含当前文件名,一个字符串常量。
__LINE__ 这会包含当前行号,一个十进制常量。
__FUNCTION__ 程序预编译时预编译器将用所在的函数名,返回值是字符串;

现在改写程序,在函数a进行setjmp,函数d进行longjmp

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
#include <stdio.h>
#include <stdlib.h>
#include <setjmp.h>

// 跳转点的现场环境
static jmp_buf save;

static void d(void) {
printf("%s():Begin.\n", __FUNCTION__);
printf("%s():Jump now!.\n", __FUNCTION__);
// 向save跳转,并携带返回值为6
longjmp(save, 6);
printf("%s():End.\n", __FUNCTION__);
}

static void c(void) {
printf("%s():Begin.\n", __FUNCTION__);
printf("%s():Call d().\n", __FUNCTION__);
d();
printf("%s():d() returned.\n", __FUNCTION__);
printf("%s():End.\n", __FUNCTION__);
}


static void b(void) {
printf("%s():Begin.\n", __FUNCTION__);
printf("%s():Call c().\n", __FUNCTION__);
c();
printf("%s():c() returned.\n", __FUNCTION__);
printf("%s():End.\n", __FUNCTION__);
}


static void a(void) {
// 返回值
int ret;
printf("%s():Begin.\n", __FUNCTION__);
// 设置跳转点
// setjmp一次调用,两次返回
ret = setjmp(save);
if(ret == 0) {
printf("%s():Call b().\n", __FUNCTION__);
b();
printf("%s():b() returned.\n", __FUNCTION__);
} else {
printf("%s():Jumped back here with code %d.\n", __FUNCTION__, ret);
}
printf("%s():End.\n", __FUNCTION__);
}

int main(void) {

printf("%s():Begin.\n", __FUNCTION__);
printf("%s():Call a().\n", __FUNCTION__);
a();
printf("%s():a() returned.\n", __FUNCTION__);
printf("%s():End.\n", __FUNCTION__);
exit(0);
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
main():Begin.
main():Call a().
a():Begin.
a():Call b().
b():Begin.
b():Call c().
c():Begin.
c():Call d().
d():Begin.
d():Jump now!.
a():Jumped back here with code 6.
a():End.
main():a() returned.
main():End.

5.7 资源的获取和控制

获取或设置资源使用限制:linux下每种资源都有相关的软硬限制,软限制是内核强加给相应资源的限制值,硬限制是软限制的最大值。

非授权调用的进程只能将其软限制指定为0~硬限制范围中的某个值,同时能不可逆转地降低其硬限制。

授权进程(root用户)可以任意改变其软硬限制。

函数原型:

1
2
3
4
5
#include <sys/time.h>
#include <sys/resource.h>

int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);

rlimit结构体定义如下:

1
2
3
4
struct rlimit {
  rlim_t rlim_cur; // 软限制
  rlim_t rlim_max; // 硬限制
};

resource的选择有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
RLIMIT_AS //进程的最大虚内存空间,字节为单位。
RLIMIT_CORE //内核转存文件的最大长度。
RLIMIT_CPU //最大允许的CPU使用时间,秒为单位。当进程达到软限制,内核将给其发送SIGXCPU信号,这一信号的默认行为是终止进程的执行。然而,可以捕捉信号,处理句柄可将控制返回给主程序。如果进程继续耗费CPU时间,核心会以每秒一次的频率给其发送SIGXCPU信号,直到达到硬限制,那时将给进程发送 SIGKILL信号终止其执行。
RLIMIT_DATA //进程数据段的最大值。
RLIMIT_FSIZE //进程可建立的文件的最大长度。如果进程试图超出这一限制时,核心会给其发送SIGXFSZ信号,默认情况下将终止进程的执行。
RLIMIT_LOCKS //进程可建立的锁和租赁的最大值。
RLIMIT_MEMLOCK //进程可锁定在内存中的最大数据量,字节为单位。
RLIMIT_MSGQUEUE //进程可为POSIX消息队列分配的最大字节数。
RLIMIT_NICE //进程可通过setpriority() 或 nice()调用设置的最大完美值。
RLIMIT_NOFILE //指定比进程可打开的最大文件描述词大一的值,超出此值,将会产生EMFILE错误。
RLIMIT_NPROC //用户可拥有的最大进程数。
RLIMIT_RTPRIO //进程可通过sched_setscheduler 和 sched_setparam设置的最大实时优先级。
RLIMIT_SIGPENDING //用户可拥有的最大挂起信号数。
RLIMIT_STACK //最大的进程堆栈,以字节为单位。

返回值:

  • 成功执行时,返回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
2
3
4
5
6
7
8
#include <sys/types.h>
#include <unistd.h>

// 返回调用该函数进程的进程标识符
pid_t getppid(void);

// 返回父进程的进程标识符
pid_t getpid(void);

补充:ps命令

Linux 中的 ps 命令是 Process Status 的缩写。ps 命令用来列出系统中当前正在运行的那些进程,就是执行 ps 命令的那个时刻的那些进程的快照。

1
2
3
ps axf
ps axm
ps ax -L

6.2 进程产生

6.2.1 fork

init进程:pid为1,是所有进程的祖先进程,注意不是父进程。

一个现有的进程可以调用fork函数创建一个新进程:

1
2
3
#include <unistd.h>

pid_t fork(void);

由fork创建的新进程被称为子进程(child process)。

返回值:fork函数被调用一次,但返回两次。子进程的返回值是0,父进程的返回值则是新建子进程的进程PID。如果失败则返回-1,并设置errno。和setjmp类似,fork语句后常常跟上分支语句进行判断。

子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆、栈和缓冲区的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分,除了读时共享的部分。

  • fork后父子进程的区别

    • fork返回值不同

    • 两个进程的pid不同

    • 两个进程的ppid也不同,父进程的ppid是它的父进程pid,而子进程的ppid是创建它的进程的pid

    • 父进程的未决信号和文件锁不继承

    • 子进程的资源利用量归零

程序实例1——fork的使用

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

int main(void) {
pid_t pid;
printf("[%d]:Begin!\n", getpid());
pid = fork();
// ---------------------------
// 父进程调用fork后,父子进程都从这里开始执行
if(pid < 0) {
perror("fork()");
exit(1);
}

if(pid == 0) { // 如果返回值pid为0,则为子进程
printf("[%d]:Child is working!\n", getpid());
} else { // 如果返回值pid大于0,则为父进程
printf("[%d]:Parent is working!\n", getpid());
}

printf("[%d]:End!\n", getpid());
exit(0);
}

运行结果:(可能形式)

1
2
3
4
5
6
[root@HongyiZeng proc]# ./fork1 
[16023]:Begin!
[16023]:Parent is working!
[16023]:End!
[root@HongyiZeng proc]# [16024]:Child is working!
[16024]:End!

一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法

如果在main程序返回前添加一行:

1
getchar();

使得父子进程都暂停,再使用命令:

1
ps axf

可以看到两个进程与bash的关系如下:

image-20221212095917346

程序实例2——fflush的重要性

对于上述程序的结果,注意到Begin只打印了一次:

1
2
3
4
5
6
[root@HongyiZeng proc]# ./fork1 
[16023]:Begin!
[16023]:Parent is working!
[16023]:End!
[16024]:Child is working!
[16024]:End!

如果将该打印信息重定向至某个文件:

1
./fork1 > /tmp/out

再查看该文件的内容:

1
2
3
4
5
6
7
[root@HongyiZeng proc]# cat /tmp/out
[18060]:Begin!
[18060]:Parent is working!
[18060]:End!
[18060]:Begin!
[18061]:Child is working!
[18061]:End!

注意到Begin打印了两次。

原因:对于重定向至文件,采用的是全缓冲(除标准输出和标准错误输出),只有进程结束或者缓冲满的时候才刷新缓冲区(遇到换行符不刷新),将缓冲区的内容写入到文件。因此,父进程fork时,尚未刷新缓冲区,因此缓冲区的内容[18060]:Begin!(注意进程号已经固定了!)被复制到子进程的缓冲区中,当父子进程执行结束时,强制刷新,输出两次Begin

为防止缓冲区内容被复制,父进程在fork之前需要强制刷新所有已经打开的流:

1
2
3
4
5
6
7
8
9
10
11
12
int main(void) {
pid_t pid;
printf("[%d]:Begin!\n", getpid());

// 强制刷新所有打开的流!!!
fflush(NULL);
// 再调用fork
pid = fork();
// ---------------------------
// 父进程调用fork后,父子进程都从这里开始执行
// ...
}

此时,只打印了一句Begin

1
2
3
4
5
6
7
[root@HongyiZeng proc]# ./fork1 > /tmp/out
[root@HongyiZeng proc]# cat /tmp/out
[19853]:Begin!
[19853]:Parent is working!
[19853]:End!
[19854]:Child is working!
[19854]:End!

程序实例3——找质数

需求:找出30000000~30000200的所有质数。

  • 单进程版:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>
#define LEFT 30000000
#define RIGHT 30000200

int main(void) {
int i, j, mark;
for(i = LEFT; i <= RIGHT; i++) {
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}
if(mark)
printf("%d is a primer.\n", i);
}
exit(0);
}

打印结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[root@HongyiZeng proc]# time ./primer0 
30000001 is a primer.
30000023 is a primer.
30000037 is a primer.
30000041 is a primer.
30000049 is a primer.
30000059 is a primer.
30000071 is a primer.
30000079 is a primer.
30000083 is a primer.
30000109 is a primer.
30000133 is a primer.
30000137 is a primer.
30000149 is a primer.
30000163 is a primer.
30000167 is a primer.
30000169 is a primer.
30000193 is a primer.
30000199 is a primer.

real 0m0.967s
user 0m0.950s
sys 0m0.001s
  • 多进程协同:

一个错误的程序:

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
#include <stdio.h>
#include <stdlib.h>
#define LEFT 30000000
#define RIGHT 30000200

int main(void) {
int i, j, mark;
pid_t pid;
for(i = LEFT; i <= RIGHT; i++) {
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) { // child
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}
if(mark)
printf("%d is a primer.\n", i);
}
}
exit(0);
}

分析:子进程执行完pid==0的分支后,又会执行for循环的部分,此时会再次fork,导致进程数量指数式的增长,超出可用内存。

更正:在执行完pid==0的分支后面(完成了对某个数i的判断的任务),需要正常退出exit(0)

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
#include <stdio.h>
#include <stdlib.h>
#define LEFT 30000000
#define RIGHT 30000200

int main(void) {
int i, j, mark;
pid_t pid;
for(i = LEFT; i <= RIGHT; i++) {
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) { // child
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}

if(mark)
printf("%d is a primer.\n", i);
// 子进程退出
exit(0);
}
}
exit(0);
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[root@HongyiZeng proc]# time ./primer1
30000037 is a primer.
30000071 is a primer.
30000059 is a primer.
30000079 is a primer.
30000083 is a primer.
30000049 is a primer.
30000023 is a primer.
30000137 is a primer.
30000149 is a primer.
30000041 is a primer.
30000167 is a primer.
30000193 is a primer.
30000109 is a primer.
30000001 is a primer.
30000199 is a primer.
30000169 is a primer.
30000163 is a primer.
30000133 is a primer.

real 0m0.048s
user 0m0.001s
sys 0m0.008s

程序实例4——孤儿进程和僵尸进程

修改1:在子进程在退出前,先睡眠1000s,这样父进程会先执行完毕而退出。

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
int main(void) {
int i, j, mark;
pid_t pid;
for(i = LEFT; i <= RIGHT; i++) {
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) {
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}
if(mark)
printf("%d is a primer.\n", i);
// 子进程睡眠1000s
sleep(1000);
exit(0);
}
}
exit(0);
}

再使用命令ps axf查看:

image-20221212105453780

此时201个子进程的状态为S(可中断的睡眠状态),且父进程为init进程(每个进程以顶格形式出现)。这里的子进程在init进程接管之前就是孤儿进程。

孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为1)所收养,并由 init 进程对它们完成状态收集工作,孤儿进程并不会有什么危害。


修改2:在父进程退出之前,先休眠1000s,再查看进程状态。

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
int main(void) {
int i, j, mark;
pid_t pid;
for(i = LEFT; i <= RIGHT; i++) {
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) {
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}
if(mark)
printf("%d is a primer.\n", i);
exit(0);
}
}
// 父进程睡眠1000s再退出
sleep(1000);
exit(0);
}

执行结果:

image-20221212110215722

可以看到子进程状态为Z,即为僵尸状态。

僵尸进程:一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息(收尸),那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵尸进程。

僵尸进程虽然不占有任何内存空间,但如果父进程不调用 wait() / waitpid() 的话,那么保留的信息就不会释放,其进程号就会一直被占用,而系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害。

6.2.2 vfork

考虑这样一个场景,父进程使用了一个占用内存很大的数据,此时它fork了一个子进程,而子进程仅仅打印一个字符串就退出了,此时这块很大的数据复制到子进程的内存空间中,造成了很大的内存浪费。

为了解决这个问题,在fork实现中,增加了读时共享,写时复制(Copy-On-Write,COW)的机制。写时复制可以避免拷贝大量根本就不会使用的数据(地址空间包含的数据多达数十兆)。因此可以看出写时复制极大提升了Linux系统下fork函数运行的性能。

写时复制指的是子进程的页表项指向与父进程相同的物理页,这也只需要拷贝父进程的页表项就可以了,不会复制整个内存地址空间,同时把这些页表项标记为只读

  • 读时共享:如果父子进行都不对页面进行操作或只读,那么便一直共享同一份物理页面。

  • 写时复制:只要父子进程有一个尝试进行修改某一个页面(写时),那么就会发生缺页异常。那么内核便会为该页面创建一个新的物理页面,并将内容复制到新的物理页面中,让父子进程真正地各自拥有自己的物理内存页面,并将页表中相应地页表项标记为可写

写时复制父子进程修改某一个页面前后变化如下图所示:

image-20221212114349322

在fork还没实现copy on write之前。Unix设计者很关心fork之后立刻执行exec所造成的地址空间浪费,所以引入了vfork系统调用。而现在vfork已经不常用了。

  • vfork和fork的区别/联系:vfork函数和 fork函数一样都是在已有的进程中创建一个新的进程,但它们创建的子进程是有区别的。
  • 父子进程的执行顺序
    • fork: 父子进程的执行次序不确定。
    • vfork:保证子进程先运行,在它调用 exec/exit之后,父进程才执行
  • 是否拷贝父进程的地址空间
    • fork: 子进程写时拷贝父进程的地址空间,子进程是父进程的一个复制
    • vfork:子进程共享父进程的地址空间
  • 调用vfork函数,是为了执行exec函数;如果子进程没有调用 exec/exit,程序会出错

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int argc, char *argv[]){
pid_t pid;
pid = vfork(); // 创建进程
if(pid < 0){
perror("vfork");
}
if(0 == pid){
sleep(3); // 延时 3 秒
printf("i am son\n");

_exit(0); // 退出子进程,必须
}
else if(pid > 0){ // 父进程
printf("i am father\n");
}
}

执行结果:已经让子进程延时 3 s,结果还是子进程运行结束后,父进程才执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static int a = 10;

int main(void){
pid_t pid;
int b = 20;
pid = vfork();
if(pid < 0){
perror("vfork()");
exit(1);
}
if(0 == pid){
a = 100, b = 200;
printf("son: a = %d, b = %d\n", a, b);
_exit(0);
}
else if(pid > 0){
printf("father: a = %d, b = %d\n", a, b);
}
exit(0);
}

执行结果:子进程先执行,修改完a,b的值后,由于父子进程共享内存空间,因此会影响父进程

1
2
son: a = 100, b = 200
father: a = 100, b = 200

如果采用fork的话,会有写时复制,此时父子进程的变量无关:

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

static int a = 10;
int main(void) {
pid_t pid;
int b = 20;
fflush(NULL);
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) {
a = 100;
b = 200;
printf("son: a = %d, b = %d.\n", a, b);
_exit(0);
} else {
printf("father: a = %d, b = %d.\n", a, b);
}
exit(0);
}

执行结果:

1
2
3
[root@HongyiZeng proc]# ./vfork 
father: a = 10, b = 20.
son: a = 100, b = 200.

6.3 wait和waitpid

wait系统调用:等待进程改变状态。

进程一旦调用了wait,就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。

wait函数原型如下:

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);
  • status用来保存子进程退出时的一些状态。如果不在意子进程的退出状态,可以设定statusNULL。如果参数status的值不是NULLwait就会把子进程退出时的状态取出,并存入其中。可以使用下列的宏函数来处理status
    • WIFEXITED(status):用来指出子进程是否为正常退出,如果是,则会返回一个非零值。
    • WEXITSTATUS(status):当WIFEXITED返回非零值时,可以用这个宏来提取子进程的返回值。
  • 如果执行成功,wait会返回子进程的PID;如果没有子进程,则wait返回-1

代码示例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

int main (void) {
pid_t pc, pr;
pc = fork();

if(pc < 0) {
printf ("error ocurred!\n");
}else if (pc == 0) { /* 如果是子进程 */
printf("This is child process with pid of %d\n", getpid());
sleep (10); /* 睡眠10秒钟 */
}else { /* 如果是父进程 */
pr = wait (NULL); /* 在这里阻塞,收尸 */
printf ("I catched a child process with pid of %d\n", pr);
}
exit(0);
}

打印结果:

1
2
3
This is child process with pid of 298
等待10
I catched a child process with pid of 298

程序实例2

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
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main(void) {
int status;
pid_t pc, pr;
pc = fork();

if(pc < 0) {
printf( "error ocurred!\n" );
}
if(pc == 0) { /* 子进程 */
printf("This is child process with pid of %d\n", getpid());
exit ( 3 ); /* 子进程返回3 */
if(pc > 0) { /* 父进程 */
pr = wait(&status);
if(WIFEXITED(status)) { /* 如果WIFEXITED返回非零值 */
printf("the child process %d exit normally\n", pr);
printf("the return code is %d\n", WEXITSTATUS(status ));
} else { /* 如果WIFEXITED返回零 */
printf("the child process %d exit abnormally\n", pr);
}
}
exit(0);
}

打印结果:

1
2
3
This is child process with pid of 308
the child process 308 exit normally
the return code is 3

waitpid函数原型如下:

1
2
3
4
5
6
7
8
#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);

// 下面两者等价:
wait(&status);
waitpid(-1, &status, 0);

从本质上讲,waitpidwait的作用是完全相同的,但waitpid多出了两个可以由用户控制的参数pidoptions

  • pid:当pid取不同的值时,在这里有不同的意义:
取值 意义
> 0 只等待进程ID等于pid的子进程
=-1 等待任何一个子进程退出,此时waitpidwait的作用一模一样
= 0 等待同一个进程组process group id中的任何子进程
<-1 等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值
  • options:是一个位图,可以通过按位或来设置,如果不设置则置为0即可。最常用的选项是WNOHANG,作用是即使没有子进程退出,它也会立即返回,此时waitpid不同于wait,它变成了非阻塞的函数。
  • waitpid的返回值有如下几种情况:
    • 当正常返回时,waitpid返回子进程的PID
    • 如果设置了WNOHANG,而waitpid没有发现已经退出的子进程,则返回0
    • 如果waitpid出错,则返回-1。例如参数pid指示的子进程不存在,或此进程存在,但不是调用进程的子进程。

代码示例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
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define LEFT 30000000
#define RIGHT 30000200

int main(void) {
int i, j, mark;
pid_t pid;
pid_t pid_child;
for(i = LEFT; i <= RIGHT; i++) {
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) {
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}

if(mark)
printf("[%d]:%d is a primer.\n", getpid(), i);
exit(0);
}
}
// 循环201次,给201个子进程收尸
for(i = LEFT; i <= RIGHT; i++) {
pid_child = wait(NULL);
printf("Child process with pid: %d.\n", pid_child);
}
exit(0);
}

执行结果:省略了没有打印质数的输出

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
[32444]:30000023 is a primer.
Child process with pid: 32444.
[32462]:30000041 is a primer.
Child process with pid: 32462.
[32458]:30000037 is a primer.
Child process with pid: 32458.
[32422]:30000001 is a primer.
Child process with pid: 32422.
[32470]:30000049 is a primer.
Child process with pid: 32470.
[32480]:30000059 is a primer.
Child process with pid: 32480.
[32530]:30000109 is a primer.
Child process with pid: 32530.
[32504]:30000083 is a primer.
Child process with pid: 32504.
[32614]:30000193 is a primer.
Child process with pid: 32614.
[32590]:30000169 is a primer.
Child process with pid: 32590.
[32492]:30000071 is a primer.
Child process with pid: 32492.
[32620]:30000199 is a primer.
Child process with pid: 32620.
[32500]:30000079 is a primer.
Child process with pid: 32500.
[32588]:30000167 is a primer.
Child process with pid: 32588.
[32554]:30000133 is a primer.
Child process with pid: 32554.
[32558]:30000137 is a primer.
Child process with pid: 32558.
[32584]:30000163 is a primer.
Child process with pid: 32584.
[32570]:30000149 is a primer.
Child process with pid: 32570.

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <unistd.h>

extern char **environ;

// 直达
int execl(const char *path, const char *arg, ...);
// 从PATH里找
int execlp(const char *file, const char *arg, ...);
// 直达
int execle(const char *path, const char *arg, ..., char * const envp[]);

// 直达
int execv(const char *path, char *const argv[]);
// 从PATH里找
int execvp(const char *file, char *const argv[]);
// 从PATH里找
int execvpe(const char *file, char *const argv[], char *const envp[]);
  • 以上函数成功执行时不返回,失败时返回-1并设值errno
  • 后缀含义

    • l:以list形式传入参数
    • v:以vector形式传入参数
    • p:在$PATH中查找可执行程序
    • e:在envp[]中查找可执行程序
  • execlexecv:这两个函数是最基本的exec,都可以用来执行一个程序,区别是传参的格式不同:

    • execl是把参数列表(本质上是多个字符串,必须以NULL结尾)依次排列而成
    • execv是把参数列表事先放入一个字符串数组中(必须以NULL结尾),再把这个字符串数组传给execv函数,类似于char **argv
    • path:完整的文件目录路径
  • execlpexecvp:这两个函数在上面2个基础上加了p

    • file:文件名,系统就会自动从环境变量$PATH所指出的路径中进行查找该文件。如果包含/,则视为路径名path
  • execleexecvpe:这两个函数较基本exec来说加了e

    • envp:自己指定的环境变量。在envp[]中指定当前进程所使用的环境变量替换掉该进程继承的环境变量$PATH

image-20221215120515986

代码示例——环境变量

  • myexec.c
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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {

pid_t pid;
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
} else if(pid == 0) { // 子进程
// 参数
char * const param[] = {"myHello", "-a", "-l", NULL};
// 自己设置的环境变量
char * const envp[] = {"AA=aaa", "BB=bbb", NULL};
// 执行同目录下的hello
execvpe("./hello", param, envp);
perror("execvpe()");
exit(1);
} else { // 父进程
wait(NULL);
}
exit(0);
}
  • hello.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv, char **env) {

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

int i;

for(i = 0; argv[i] != NULL; i++) {
printf("argv[%d]: %s\n", i, argv[i]);
}

for(i = 0; env[i] != NULL; i++) {
printf("env[%d]: %s\n", i, env[i]);
}

exit(0);
}

编译链接为hello

执行结果:

1
2
3
4
5
6
7
[root@HongyiZeng proc]# ./myexec 
argc = 3
argv[0]: myHello
argv[1]: -a
argv[2]: -l
env[0]: AA=aaa
env[1]: BB=bbb

代码示例——程序名称

补充:argv第一个参数为程序名称,后面的参数为命令行参数。程序名称可以任意设置,一般来说,如果一个源码文件的名称为XXX.c,则编译生成的可执行程序为XXX,此时运行,程序名称(argv[0])就是XXX

使用gcc默认编译链接得到的可执行文件名称为a.out,此时程序名称(argv[0])就是a.out


使用exec族函数实现date +%s命令打印时间戳的功能。

1
2
[root@HongyiZeng proc]# date +%s
1670902531

这里的参数依次是程序名+%sNULL,注意第一个参数代表的是程序的名称,可以任意设置,类似于argv[0],之后的参数才是重要的命令行参数

代码实现:

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

int main(void) {
puts("Begin!");
// 注意这里的程序名称给了一个myDate
execl("/bin/date", "myDate", "+%s", NULL);
perror("execl()");
exit(1);

puts("End!");
exit(0);
}

或者使用execv

1
2
3
4
5
6
7
8
9
10
11
int main(void) {
puts("Begin!");

char * const param[] = {"myDate", "+%s", NULL};
execp("/bin/date", param);
perror("execl()");
exit(1);

puts("End!");
exit(0);
}

执行结果:

1
2
3
[root@HongyiZeng proc]# ./ex 
Begin!
1670902607

为什么不打印End!:执行exec后,原进程映像被替换成新的进程映像(即/bin/date程序),从main函数开始执行/bin/date的代码了。

我不再是我,我已成新的我


让子进程睡眠1000s:

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

int main(void) {
pid_t pid;
printf("[%d]Begin!\n", getpid());
fflush(NULL);
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) {
// 注意这里的程序名称给了一个httpd
execl("/bin/sleep", "httpd", "1000", NULL);
perror("execl()");
exit(1);
}

wait(NULL);
printf("[%d]End!\n", getpid());
exit(0);
}

执行后查看:

1
ps axf

image-20221215122921045

这里子进程运行时执行的是sleep程序,但是程序名称却被设置成了httpd,这实际上是一种低级的木马程序隐藏的办法。

代码示例——刷新缓冲区的重要性

在讲fork的时候提到过,在fork之前,最好将强制刷新所有已经打开的流,这里的exec也不例外,例如使用上面的程序,将结果重定向到/tmp/out中:

1
2
3
[root@HongyiZeng proc]# ./ex > /tmp/out
[root@HongyiZeng proc]# cat /tmp/out
1670902720

发现Begin!不见了,原因就在于重定向是全缓冲,当执行完puts("Begin!")后,该进程的缓冲区内容为Begin!\n,并不刷新到文件中,此时执行exec后,进程映像被替换成新的进程映像(即/bin/date程序),除了原进程的进程号外,其他全部(包括缓冲区)被新程序的内容替换了,之后新程序的缓冲区内容为时间戳,程序结束后,强制刷新到文件。

因此需要在执行exec之前强制刷新所有打开的流:

1
2
3
4
5
6
7
8
9
10
11
12
int main(void) {
puts("Begin!");

fflush(NULL);

execl("/bin/date", "date", "+%s", NULL);
perror("execl()");
exit(1);

puts("End!");
exit(0);
}

再次执行:

1
2
3
4
[root@HongyiZeng proc]# ./ex > /tmp/out
[root@HongyiZeng proc]# cat /tmp/out
Begin!
1670903209

代码示例——fork,exec和wait结合使用

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

int main(void) {
pid_t pid;
printf("[%d]Begin!\n", getpid());
fflush(NULL);
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) { // 子进程
execl("/bin/date", "date", "+%s", NULL);
perror("execl()");
exit(1);
}
// 等待子进程结束,收尸
wait(NULL);
printf("[%d]End!\n", getpid());
exit(0);
}

执行结果:

1
2
3
[17301]Begin! // 父进程打印
1670903917 // 子进程打印,子进程和父进程完全是不同的程序了
[17301]End! // 父进程打印

至此,UNIX系统进程控制原语更加完善。用fork可以创建新进程,用exec可以初始执行新的程序。exit函数和wait函数处理终止和等待终止。这些是我们需要的基本的进程控制原语。

6.5 shell外部命令实现

shell外部命令:在磁盘上能够看得到的命令。此外都是内部命令。

之前在终端上执行primer1.c时,出现下列情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[root@HongyiZeng proc]# ./primer1
[root@HongyiZeng proc]# 30000037 is a primer.
30000001 is a primer.
30000041 is a primer.
30000023 is a primer.
30000079 is a primer.
30000133 is a primer.
30000137 is a primer.
30000049 is a primer.
30000109 is a primer.
30000083 is a primer.
30000071 is a primer.
30000059 is a primer.
30000193 is a primer.
30000169 is a primer.
30000167 is a primer.
30000199 is a primer.
30000163 is a primer.
30000149 is a primer.

发现终端先于子程序打印。

原因:在终端上执行primer1时,父进程(终端,即shell)fork了一个子进程,然后exec了primer1程序,并且waitprimer1退出,所以当primer1退出时,就立刻出现了终端,此时primer1fork的子进程还在运行打印结果,所以出现了终端先于子进程的结果出现。

一般的,当shell执行某个程序时,首先fork一个子进程,然后该子进程exec那个执行程序,shell此时wait该程序退出exit

shell伪代码示例

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
int main(void) {
// 死循环,shell不断接收用户命令
while(1) {
// 终端提示符
prompt();

// 获取命令
getline();

// 解析命令
parse();

if(内部命令) {
// ...
} else { // 外部命令
fork();
if(pid < 0) {
// 异常处理...
}
if(pid == 0) { // 子进程
exec(); // 将子进程替换为待执行程序
// 异常处理...
}
if(pid > 0) { // shell父进程
wait(NULL); // 等待子进程结束
}
}

}
exit(0);
}

代码实现

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
67
68
69
70
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <glob.h>

// 分隔符:空 制表符 换行符
#define DELIMS " \t\n"

struct cmd_st {
glob_t globres;
};

static void prompt(void) {
printf("mysh-0.1$ ");
}

static void parse(char *line, struct cmd_st *res) {
char *tok;
int i = 0;
while(1) {
tok = strsep(&line, DELIMS);
// 分割完毕
if(tok == NULL)
break;
if(tok[0] == '\0')
continue;
// 选项解释
// NOCHECK:不对pattern进行解析,直接返回pattern(这里是tok),相当于存储了命令行参数tok在glob_t中
// APPEND:以追加形式将tok存放在glob_t中,第一次时不追加,因为globres尚未初始化,需要系统来自己分配内存,因此乘上i(乘法优先于按位或)
glob(tok, GLOB_NOCHECK|GLOB_APPEND*i, NULL, &res->globres);
// 置为1,使得追加永远成立
i = 1;
}
}


int main(void) {
// getline的参数要初始化
char *linebuf = NULL;
size_t linebuf_size = 0;
struct cmd_st cmd;
pid_t pid;
while(1) {
prompt();
// getline函数参见2.10节
if(getline(&linebuf, &linebuf_size, stdin) < 0) {
break;
}
// 解析命令
parse(linebuf, &cmd);
if(0) { // 内部命令,暂不做实现

} else { // 外部命令
pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}
if(pid == 0) {
execvp(cmd.globres.gl_pathv[0], cmd.globres.gl_pathv);
perror("execvp()");
exit(1);
} else {
wait(NULL);
}
}
}
exit(0);
}

程序分析:

strsep函数原型:

1
2
3
#include <string.h>

char * strsep(char **stringp, const char *delim);

strsep实现字符串的分割,把stringp里面出现的delim替换成'\0',后将 stringp 更新指向到'\0'符号的下一个字符地址,函数的返回值指向原来的 stringp 位置。直到分割完毕返回NULL


代码执行流程分析:

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[root@HongyiZeng proc]# ./mysh 
mysh-0.1$ ls -l
total 144
-rwxr-xr-x 1 root root 8600 Dec 13 11:46 ex
-rw-r--r-- 1 root root 213 Dec 13 11:46 ex.c
-rwxr-xr-x 1 root root 8552 Dec 13 12:52 exv
-rw-r--r-- 1 root root 232 Dec 13 12:52 exv.c
-rwxr-xr-x 1 root root 8752 Dec 13 11:58 few
-rw-r--r-- 1 root root 361 Dec 13 11:58 few.c
-rwxr-xr-x 1 root root 8656 Dec 12 10:19 fork1
-rw-r--r-- 1 root root 402 Dec 12 10:19 fork1.c
-rwxr-xr-x 1 root root 8912 Dec 14 12:06 mysh
-rw-r--r-- 1 root root 953 Dec 14 12:06 mysh.c
-rwxr-xr-x 1 root root 8448 Dec 12 10:28 primer0
-rw-r--r-- 1 root root 313 Dec 12 10:28 primer0.c
-rwxr-xr-x 1 root root 8552 Dec 14 11:05 primer1
-rw-r--r-- 1 root root 437 Dec 14 11:04 primer1.c
-rwxr-xr-x 1 root root 8656 Dec 13 10:02 primer2
-rw-r--r-- 1 root root 652 Dec 13 10:02 primer2.c
-rwxr-xr-x 1 root root 8672 Dec 12 11:56 vfork
-rw-r--r-- 1 root root 372 Dec 12 11:56 vfork.c
  • getline得到字符串ls -l

  • parse解析该字符串,将分割结果存在globres中,其中:

    1
    2
    3
    globres.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
2
3
4
[root@HongyiZeng proc]# id
uid=0(root) gid=0(root) groups=0(root)
[root@HongyiZeng proc]# id lighthouse
uid=1000(lighthouse) gid=1000(lighthouse) groups=1000(lighthouse),991(docker)

要确认自己所属的用户组,可以使用groups命令:

1
2
[root@HongyiZeng proc]# groups
root

系统用来记录用户名、密码的最重要两个文件是/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
2
[root@HongyiZeng proc]# ll /usr/bin/passwd 
-rwsr-xr-x 1 root root 27856 Apr 1 2020 /usr/bin/passwd

上面第4位的s就是特殊权限SUID

下面将由五个问题来说明什么是SUID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1.普通用户可不可以修改密码?
可以,修改自己的密码

# 2./etc/shadow文件的作用?
存储用户密码的文件

# 3./etc/shadow文件的权限?
[root@localhost ~]# ll /etc/shadow
----------1 root root 16404 Apr 8 11:41 /etc/shadow

# 4.普通用户,是否可以修改/etc/shadow文件?
不可以,/etc/shadow文件,对于普通用户没有任何权限,所以不能读取,也不能写入内容。

普通用户的信息保存在 /etc/passwd文件中,与用户的密码在 /etc/shadow 文件中,也就是说,普通用户在更改自己密码时,修改了 /etc/shadow 文件中的加密密码,但是文件权限显示,普通用户对这两个文件都没有写权限。

# 5.那么普通用户,为什么可以修改密码?
1)因为使用了passwd这个命令
2)passwd命令在属主权限位上,原本是x权限,变成了s权限
3)s权限在属主权限位,又叫做SetUID权限,SUID
4)作用:普通用户在使用有SUID权限的文件或命令时,会以该文件的属主身份去执行该命令,换句话说,普通用户在执行passwd命令时,切换成了passwd属主即root的身份去执行passwd命令。

从进程控制的角度来说,当非root用户执行passwd这个可执行文件的时候,产生的进程的EUID,就是root用户的UID。换言之,这种情况下,产生的进程,实际以root用户的ID来运行二进制文件。

相关命令

1
2
chmod u+s 文件名/目录名 # 对文件给予用户s权限,则此用户暂时获得这个文件的属主权限
chmod g+s 文件名/目录名 # 对文件给予用户组s权限,则此用户暂时获得这个文件的属组权限

从进程控制的角度看命令的执行

  • UNIX系统产生的第一个进程是init进程,其三个uid为root的uid,即res0 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的进程控制原语搭建起来的

整个过程的图示如下:

image-20221216121452262

又如执行passwd命令时图如下,变化的只有EUIDSUID

image-20221216121641906

6.6.3 相关系统调用

下面的系统调用是特殊权限实现所需的函数。

  • 获取:
1
2
3
4
5
6
7
8
9
10
11
#include <unistd.h>
#include <sys/types.h>

// 返回当前进程的ruid
uid_t getuid(void);

// 返回当前进程的euid
uid_t geteuid(void);

gid_t getgid(void);
gid_t getegid(void);
  • 设置:
1
2
3
4
5
6
7
8
#include <sys/types.h>
#include <unistd.h>

// 设置当前进程的euid
int setuid(uid_t uid);

// 设置当前进程的egid
int setgid(gid_t gid);
  • 交换:
1
2
3
4
5
6
#include <sys/types.h>
#include <unistd.h>

// 交换当前进程的ruid和euid
int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);

代码示例

实现任意用户用0号用户(即root)的身份查看/etc/shadow文件的功能:

1
./mysu 0 cat /etc/shadow

exec参数:

1
2
cat -> main的argv[2]:所需要执行的程序;
cat /etc/shadow -> main的argv[2]之后:程序名cat 命令行参数/etc/shadow

代码实现:(以普通用户lighthouse编译链接)

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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char **argv) {
pid_t pid;
if(argc < 3) {
fprintf(stderr, "Usage...\n");
exit(1);
}

pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}

if(pid == 0) {
setuid(atoi(argv[1])); // 将字符串转成int
execvp(argv[2], argv + 2);
perror("execvp()");
exit(1);
}
wait(NULL);
exit(0);
}

查看该文件的属性:

1
2
[lighthouse@HongyiZeng proc]$ ll mysu
-rwxr-xr-x 1 lighthouse lighthouse 8800 Dec 16 12:32 mysu

直接运行,权限不够:

1
2
[lighthouse@HongyiZeng proc]$ ./mysu 0 cat /etc/shadow
cat: /etc/shadow: Permission denied

切换到root用户,将mysu属主更改为root,并给予该文件s权限:

1
2
3
4
[root@HongyiZeng proc]# chown root mysu
[root@HongyiZeng proc]# chmod u+s mysu
[root@HongyiZeng proc]# ll mysu
-rwsr-xr-x 1 root lighthouse 8800 Dec 16 12:32 mysu

然后切换到lighthouse,再执行即可:

image-20221216141111786

6.7 解释器文件

解释器文件也叫脚本文件。脚本文件包括:shell脚本,python脚本等;

脚本文件的后缀可自定义,一般来说shell脚本的后缀名为.sh,python脚本的后缀名为.py

解释器文件的执行过程:当在linux系统的shell命令行上执行一个可执行文件时,系统会fork一个子进程,在子进程中内核会首先将该文件当做是二进制机器文件来执行,但是内核发现该文件不是机器文件(看到第一行为#!)后就会返回一个错误信息,收到错误信息后进程会将该文件看做是一个解释器文件,然后扫描该文件的第一行,获取解释器程序(本质上就是可执行文件)的名字,然后执行exec该解释器,并将该解释器文件当做解释器的一个参数,然后开始由解释器程序扫描整个解释器文件,执行每条语句(如果指定解释器为shell,会跳过第一条语句,因为#是注释)。如果其中某条命令执行失败了也不会影响后续命令的执行。

解释器文件的格式:

1
2
3
#!pathname [optional-argument]

内容...
  • pathname:一般是绝对路径(它不会使用$PATH做路径搜索),对这个文件识别是由内核做为exec系统调用处理的。
  • optional-argument:相当于提供给exec的参数

内核exec执行的并不是解释器文件,而是第一行pathname指定的文件。一定要将解释器文件(本质是一个文本文件,以 #!开头)和解释器(由pathname指定)区分开。

代码示例1

以普通用户创建脚本test.sh

1
2
3
4
5
#!/bin/bash
ls
whoami
cat /etc/shadow
ps

这个文件没有执行权限,需要添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
[lighthouse@HongyiZeng proc]$ ll test.sh
-rw-r--r-- 1 lighthouse lighthouse 46 Dec 16 15:09 test.sh
[lighthouse@HongyiZeng proc]$ chmod u+x test.sh
[lighthouse@HongyiZeng proc]$ ./test.sh
ex exv.c fork1 hello.c mysh mysu.c primer1 primer2.c test.sh
ex.c few fork1.c myexec mysh.c primer0 primer1.c sleep vfork
exv few.c hello myexec.c mysu primer0.c primer2 sleep.c vfork.c
lighthouse
cat: /etc/shadow: Permission denied
PID TTY TIME CMD
14857 pts/3 00:00:00 bash
19087 pts/3 00:00:00 test.sh
19091 pts/3 00:00:00 ps

shell执行./test.sh时,fork了一个子进程,该进程看到该文件为解释器文件,于是读取第一行,得到解释器程序的PATH,并exec该解释器程序(/bin/bash),然后重新执行这个解释器文件。

可以看出bash跳过了第一句,因为#在bash程序中被看成了注释,cat命令没有权限,但后面的ps命令仍然继续执行。

代码示例2

1
2
3
4
5
#!/bin/cat
ls
whoami
cat /etc/shadow
ps

执行该脚本:

1
2
3
4
5
6
[root@HongyiZeng proc]# ./test.sh 
#!/bin/cat
ls
whoami
cat /etc/shadow
ps

发现这次是打印了该脚本文件的所有内容。过程同上,只是这次子进程exec的程序为/bin/cat程序。

代码示例3——自定义解释器程序

解释器程序(或解释器)本质上就是一个可执行文件。

  • echoarg.c
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[]) {
int i;

for(i = 0; i < argc; i++) {
printf("argv[%d]: %s \n", i, argv[i]);
}
exit(0);
}

编译为echoarg,并存放在/usr/local/linux_c/proc/下。

  • echoarg.sh
1
#!/usr/local/linux_c/proc/echoarg foo1 foo2 foo3

执行结果:

1
2
3
4
[root@HongyiZeng proc]# ./echoarg.sh
argv[0]: /usr/local/linux_c/proc/echoarg
argv[1]: foo1 foo2 foo3
argv[2]: ./echoarg.sh

6.8 system函数

函数原型:

1
2
3
#include <stdlib.h>

int system(const char *command);

作用:该函数实际上调用的是/bin/sh -c command,实质上是对fork+exec+wait的封装。

程序实例

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include <stdlib.h>

int main(void) {

system("date +%s > /tmp/out");

exit(0);
}

该程序实质上执行的命令为:

1
/bin/sh -c date +%s > /tmp/out

在执行该命令的时候,system函数代码类似于:

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

int main(void) {
pid_t pid;

pid = fork();
if(pid < 0) {
perror("fork()");
exit(1);
}

if(pid == 0) {
// 实际上在 exec /bin/sh程序
execl("/bin/sh", "sh", "-c", "date +%s > /tmp/out", NULL);
perror("execl()");
exit(1);
}

wait(NULL);
exit(0);
}

6.9 守护进程

暂略

6.10 系统日志

暂略

7 信号

该节对应第十章——信号。

7.1 前置概念

并发和并行

  • 并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。
  • 并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。

image-20221217114203844

同步和异步

  • 进程同步:按照一定的顺序协同进行(有序进行),而不是同时。即一组进程为了协调其推进速度,在某些地方需要相互等待或者唤醒,这种进程间的相互制约就被称作是进程同步。这种合作现象在操作系统和并发式编程中属于经常性事件。
    • 例如,在主线程中,开启另一个线程。另一个线程去读取文件,主线程等待该线程读取完毕,那么主线程与该线程就有了同步关系。
  • 进程异步:当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。

异步事件的处理:查询法(发生频率高)和通知法(发生频率低)

7.1 信号的概念

信号是软件层面的中断,是进程之间相互传递消息的一种方法,信号全称为软中断信号,也有人称作软中断,从它的命名可以看出,它的实质和使用很像中断。

进程之间可以通过调用kill库函数发送软中断信号。Linux内核也可能给进程发送信号,通知进程发生了某个事件(例如内存越界)。

每个信号都有一个名字。这些名字都以3个字符SIG开头。头文件<signal.h>中,信号名都被定义为正整数常量(信号编号)。

通过命令kill -l可以列出所有可用信号:

信号值 1 ~ 31 为不可靠信号(标准信号),信号会丢失;信号值 34 ~ 64 为可靠信号(实时信号),信号不会丢失。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[root@HongyiZeng signal]# kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
信号名 信号值 发出信号的原因
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标准库支持的信号是最少的。大部分的信号的默认操作是终止进程。

image-20221217131449456

core是在进程当前工作目录的core文件中复制了该进程的内存映像,core文件记录了进程终止时的错误报告,大多数UNIX系统调试程序都使用core文件检查进程终止时的状态。

7.3 signal函数

补充:typedef的用法总结

  • 定义一种类型的别名:基本用法
1
2
typedef char* PCHAR;
PCHAR pa, pb;
1
2
3
4
5
6
typedef struct tagPOINT {
int x;
int y;
}POINT;

POINT p1, p2;
  • 定义与平台无关的类型:当跨平台时,只要改下 typedef 本身就行,不用对其他源码做任何修改。标准库就广泛使用了这个技巧,比如size_tpid_t。另外,因为typedef是定义了一种类型的新别名,不是简单的字符串替换,所以它比宏更稳健。
1
2
3
typedef long double REAL; // 平台1
typedef double REAL; // 平台2
// ...
  • 复杂声明定义一个新的简单的别名。

例如:

1
2
3
4
5
6
7
// 相当于给返回值为int,形参为int的这一类函数起了一个别名FUNC
typedef int FUNC(int);

// 定义了返回值为int,形参为int的函数f
FUNC f; // 相当于:int f(int);

int a = f(1); // 使用
1
2
3
4
5
6
// 给返回值为int*,形参为int的函数(指针函数:返回指针的函数,本质是函数;函数指针:指向函数的指针,本质是指针)起了一个别名FUNCP,本质是一个函数
typedef int* FUNCP(int);

FUNCP p; // 相当于 int* p(int);

int* a = p(1); // 使用
1
2
3
4
5
6
// 给指向返回值为int*,形参为int的指针函数的指针其一个别名FUNCP,本质是一个指针
typedef int* (*FUNCP)(int);

FUNCP p; // 相当于 int* (*p)(int);

int* a = (*p)(1); // 使用

又如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 原始声明,该声明本质上是一个函数指针
void (* signal(int signum, void (*func)(int)))(int);

// 最外层是一个函数指针,形式为:
void (*)(int);

// 内层是一个返回指针,形参为int,函数指针的函数signal
// 注意到signal的形参也有一个函数指针void (*func)(int),这两个函数指针指向的都是同一种函数,即返回值为void,形参为int
void (*)(int);

// 因此给公共部分起一个别名sighandler_t,sighandler_t本质上是一个指针
typedef void (*sighandler_t)(int);

// 简化后的形式:
sighandler_t signal(int signum, sighandler_t func);

UNIX系统信号机制最简单的接口是signal函数,函数原型如下:

1
2
3
4
5
6
7
8
9
10
// CONFORMING TO C89, C99, POSIX.1-2001.
#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

// 注意typedef没有定义在头文件中,因此必须要写出,否则按照下面的形式给出signal函数,APUE上的就是这种形式:

void (* signal(int signum, void (*func)(int)))(int);
  • signum参数是上图中的信号名,常用宏名来表示,例如SIGINT
  • func参数是下面的一种:
    • SIG_IGN:向内核忽略此信号,除了SIGKILLSIGSTOP
    • SIG_DFL:执行系统默认动作
    • 当接到此信号后要调用的函数的地址:在信号发生时,调用该函数;称这种处理为捕捉该信号,称此函数为信号处理程序或信号捕捉函数
  • 作用:signal函数为signum所代表的信号设置一个信号处理程序func,换句话说,signal就是一个注册函数。

代码示例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
int main(void) {
int i;
// 忽略SIGINT信号
signal(SIGINT, SIG_IGN);
for(i = 0; i < 10; i++) {
// 每1s向终端打印1个*
write(1, "*", 1);
sleep(1);
}
exit(0);
}

信号SIGINT产生的方式就是快捷键CTRL + C

代码示例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

static void int_handler(int s) {
// 向终端打印!
write(1, "!", 1);
}

int main(void) {
int i;
// 函数名就是函数的地址
signal(SIGINT, int_handler);
for(i = 0; i < 10; i++) {
write(1, "*", 1);
sleep(1);
}
exit(0);
}

每按1次CTRL + C,终端就打印1个!

代码示例3——阻塞和非阻塞

上述程序,如果一直按着CTRL + C,程序会小于10S就会结束。

原因在于:信号会打断阻塞的系统调用。这里的阻塞是writesleep函数。

分析:进程运行到sleep(1)的时候,由运行态进入阻塞态,此时如果有信号到来,例如SIGINT,会打断阻塞(唤醒进程),让进程进入就绪态,获得时间片进入运行态,此时进程还没阻塞到1s,就进入了就绪态,即信号会打断阻塞的系统调用。

  • 阻塞:为了完成一个功能,发起一个调用,如果不具备条件的话则一直等待,直到具备条件则完成
  • 非阻塞:为了完成一个功能,发起一个调用,具备条件直接输出,不具备条件直接报错返回

此前学习过的所有IO函数,都是阻塞IO,即阻塞的系统调用。

open为例,进程调用open时,进入阻塞态,等待IO设备打开,如果IO设备打开时间过长,此时有一个信号到来,就会打断open调用,使其打开设备失败。

因此,在设备打开失败的时候,需要判断是因为open自身引发的错误,还是因为信号打断而没有打开,对于前者,以以往的方式处理错误,而对于后者应该尝试再次打开设备,而不是报错后退出程序。

注意:对于所有的阻塞系统调用,都要处理是因为自身调用出现的真错,还是因为信号中断导致的假错。

在宏中,有一个名为EINTRerrno,即为被信号中断而引发的错误。当进程在执行一个阻塞的系统调用时捕捉到一个信号,则被中断不再执行该系统调用,该系统调用返回错误,errno就会被设置为EINTR

image-20221217170001027

以前面的一个程序为例,修改后的代码为:

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
67
68
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define BUFSIZE 1024 // 缓冲区大小

int main(int argc, char **argv) {
int sfd, dfd;
char buf[BUFSIZE];
int len, ret, pos;

if(argc < 3) {
fprintf(stderr, "Usage...\n");
exit(1);
}

do {
if((sfd = open(argv[1], O_RDONLY)) < 0) {
if(errno != EINTR) {
// 不是信号导致的错误,就退出
perror("open()");
exit(1);
}
}
} while(sfd < 0)

do {
if((dfd = open(argv[2], O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) {
if(errno != EINTR) {
close(sfd);
perror("open()");
exit(1);
}
}
}while(dfd < 0)


while(1) {
if((len = read(sfd, buf, BUFSIZE)) < 0) {
if(errno == EINTR)
continue;
perror("read()");
break;
}

if(len == 0)
break;

pos = 0;
while(len > 0) {
if((ret = write(dfd, buf + pos, len)) < 0) {
if(errno == EINTR)
continue;
perror("write()");
exit(1);
}
pos += ret;
len -= ret;
}
}
close(dfd);
close(sfd);

exit(0);
}

7.4 不可靠的信号

信号处理程序由内核调用,在执行该程序时,内核为该处理程序布置现场,此时如果又来一个信号,内核再次调用信号处理程序,可能会冲掉第一次调用布置的现场。

7.5 可重入函数

可重入函数(即可以被中断的函数)可以被一个以上的任务调用,而不担心数据破坏。可重入函数在任何时候都可以被中断,而一段时间之后又可以恢复运行,而相应的数据不会破坏或者丢失。

不可重入的函数由于使用了一些系统资源,比如全局变量区,中断向量表等,所以它如果被中断的话,可能会出现问题,这类函数是不能运行在多任务环境下的。I/O代码通常不是可重入的,因为它们依赖于像磁盘这样共享的,单独的资源。

所有的系统调用都是可重入的,一部分库函数也是可重入的,例如memcpy

函数不可重入的条件

  • 函数内使用了静态的数据。
  • 函数内使用了malloc或者free函数
  • 函数内调用了标准的I/O函数

例如:

1
2
3
4
5
6
int temp;
void swap(int *ex1, int *ex2) {
temp = *ex1; //(1)
*ex1 = *ex2;
*ex2 = 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,此时进程才响应该信号。因此说,信号从收到到响应有一个不可避免的延迟。
    • 响应信号时的操作:内核将该位的maskpending都置为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
2
3
4
5
mask pending
1 0 // 初始化
1 1 // 信号到来
0 0 // 信号响应 内核态->用户态时
1 0 // 信号响应完毕

7.7 常用函数Ⅰ

7.7.1 kill

kill函数用于向进程发送信号,注意不是杀死进程。

1
2
3
4
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

参数:

  • 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
2
3
4
5
6
#include <signal.h>

int raise(int sig);

// 相当于
kill(getpid(), sig);

7.7.3 alarm

alarm函数

1
2
3
#include <unistd.h>

unsigned int alarm(unsigned int seconds);

作用:设置定时器(闹钟)。在指定seconds后,内核会给当前进程发送SIGALRM信号(定时器超时)。进程收到该信号,默认动作终止。每个进程都有且只有唯一的一个定时器,所以多个alarm函数共同调用时,后面设置的时钟会覆盖掉前面的时钟。

返回值:返回0或剩余的秒数,无失败。

代码示例

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

int main(void) {
alarm(5);
while(1);
exit(0);
}

执行结果:

1
2
[root@HongyiZeng signal]# ./alarm 
Alarm clock

7.7.4 pause

pause函数用于等待信号。

1
2
3
#include <unistd.h>

int pause(void);

进程调用pause函数时,会造成进程主动挂起(处于阻塞状态,并主动放弃CPU),并且等待信号将其唤醒。

代码示例

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

int main(void) {
alarm(5);
while(1)
pause();
exit(0);
}

当调用到pause()时,该进程挂起,此时不再占用CPU,5s过后,接收到SIGALRM信号,采取默认动作终止。

信号的处理方式有三种:

  • 默认动作
  • 忽略处理
  • 捕捉

进程收到一个信号后,会先处理响应信号,再唤醒pause函数。于是有下面几种情况:

  • 如果信号的默认处理动作是终止进程,则进程将被终止,也就是说一收到信号进程就终止了,pause函数根本就没有机会返回,例如上面的例子
  • 如果信号的默认处理动作是忽略,则进程将直接忽略该信号,相当于没收到这个信号,进程继续处于挂起状态,pause函数不返回
  • 如果信号的处理动作是捕捉,则进程调用完信号处理函数之后,pause返回-1,errno设置为EINTR,表示“被信号中断”
  • pause收到的信号不能被屏蔽,如果被屏蔽,那么pause就不能被唤醒

sleep = alarm + pause

代码示例

需求:让程序等待5s

  • 使用time
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void) {
time_t end;
int64_t count = 0;

end = time(NULL) + 5;

while(time(NULL) <= end) {
count++;
}

printf("%lld\n", count);

exit(0);
}

执行结果:

1
2
3
4
5
6
7
8
[root@HongyiZeng signal]# time ./pause > /tmp/out
real 0m5.142s
user 0m4.950s
sys 0m0.005s
[root@HongyiZeng signal]# time ./pause > /tmp/out
real 0m5.181s
user 0m5.045s
sys 0m0.008s
  • 使用alarm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

// 循环跳出的标志
static int loop = 1;

static void alrm_handler(int s) {
loop = 0;
}

int main(void) {
int64_t count = 0;
// 对信号SIGALRM注册信号处理函数
signal(SIGALRM, alrm_handler);
alarm(5);
while(loop)
count++;
printf("%lld\n", count);
exit(0);
}

运行结果:

1
2
3
4
5
6
7
8
[root@HongyiZeng signal]# time ./5sec_sig > /tmp/out
real 0m5.029s
user 0m4.821s
sys 0m0.012s
[root@HongyiZeng signal]# time ./5sec_sig > /tmp/out
real 0m5.017s
user 0m4.864s
sys 0m0.006s

当对上述程序进行编译优化时:

1
gcc 5sec_sig.c -O1

再次执行,发现程序一直在运行。

原因:编译优化时,gcc看到下面的循环体中没有出现loop,所以gcc认为loop是不变的,将loop的值存储在高速缓存(CPU的寄存器)中,每次读取loop的值时,从高速缓存中读取,而不是在loop实际存放的内存地址中读取,因此一直在此处循环。

1
2
while(loop)
count++;

解决方法:用volatile修饰loop,此时编译器认为该变量易变,每次读取时从实际内存地址中读取:

1
static volatile int loop = 1;

从汇编的角度来看:

1
gcc -S 5sec_sig.c -O1

未加易变修饰前:(省略不重要的代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
main:
.LFB24:
.cfi_startproc
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $5, %edi
movl $0, %eax
call alarm
movl $alrm_handler, %esi
movl $14, %edi
call signal
cmpl $0, loop(%rip) # 判断loop
jne .L5 # 跳转到循环体中
movl $0, %esi
movl $.LC0, %edi
movl $0, %eax
call printf
movl $0, %edi
call exit
.L5:
jmp .L5 # 死循环
.cfi_endproc

加上易变修饰后:

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
main:
.LFB24:
.cfi_startproc
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $5, %edi
movl $0, %eax
call alarm
movl $alrm_handler, %esi
movl $14, %edi
call signal
movl loop(%rip), %eax
testl %eax, %eax # 判断loop
je .L5 # 跳入循环
movl $0, %esi
.L4:
addq $1, %rsi
movl loop(%rip), %eax
testl %eax, %eax
jne .L4
jmp .L3
.L5:
movl $0, %esi
.L3:
movl $.LC0, %edi
movl $0, %eax
call printf
movl $0, %edi
call exit
.cfi_endproc

7.7.5 漏桶

leaky bucket也叫漏桶,就是将请求先放到一个桶中进行排队,然后按一个固定的速率来处理请求,即所谓的漏出。

桶具有一定的容量,即最大容量的请求数,当排队请求的数量超过桶的容量时,再进来的请求就直接过滤掉,不再被处理。换句话说就是请求先在桶中排队,系统或服务只以一个恒定的速度从桶中将请求取出进行处理。如果排队的请求数量超过桶能够容纳的最大数量,即桶装满了,则直接丢弃。

漏桶算法(Leaky Bucket)是网络世界中流量整形(Traffic Shaping)或速率限制(Rate Limiting)时经常使用的一种算法,它的主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。

需求:实现一个复制文本到标准输出的程序,要求10字符10字符的复制,且不能让CPU空转。

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
67
68
69
70
71
72
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

#define CPS 10
#define BUFSIZE CPS

static volatile int loop = 0;

static void alrm_handler(int s) {
alarm(1);
loop = 1;
}

int main(int argc, char **argv) {
int sfd, dfd = 1;
char buf[BUFSIZE];
int len, ret, pos;

if(argc < 2) {
fprintf(stderr, "Usage...\n");
exit(1);
}

signal(SIGALRM, alrm_handler);
alarm(1);

do {
if((sfd = open(argv[1], O_RDONLY)) < 0) {
if(errno != EINTR) {
perror("open()");
exit(1);
}
}
} while(sfd < 0);

while(1) {

while(!loop)
// 防止CPU空转
pause();
loop = 0;
while((len = read(sfd, buf, BUFSIZE)) < 0) {
if(errno == EINTR)
continue;
perror("read()");
break;
}

if(len == 0)
break;

pos = 0;
while(len > 0) {
if((ret = write(dfd, buf + pos, len)) < 0) {
if(errno == EINTR)
continue;
perror("write()");
exit(1);
}
pos += ret;
len -= ret;
}
}
close(sfd);
exit(0);
}

7.7.6 令牌桶

系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。 当桶满时,新添加的令牌被丢弃或拒绝。

令牌桶算法是一个存放固定容量令牌(token)的桶,按照固定速率往桶里添加令牌。令牌桶算法基本可以用下面的几个概念来描述:

  • 令牌将按照固定的速率被放入令牌桶中。比如每秒放10个。
  • 桶中最多存放b个令牌,当桶满时,新添加的令牌被丢弃或拒绝。
  • 当一个n个字节大小的数据包到达,将从桶中删除n个令牌,接着数据包被发送到网络上。
  • 如果桶中的令牌不足n个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么缓冲区等待)。

令牌桶和漏桶的区别

主要区别在于漏桶算法能够强行限制数据的传输速率,而令牌桶算法在能够限制数据的平均传输速率外,还允许某种程度的突发传输。在令牌桶算法中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的门限,因此它适合于具有突发特性的流量

代码示例

暂略

7.8 信号集

信号集是一个能够表示多个信号的数据类型。

POSIX.1定义数据类型sigset_t来表示一个信号集(本质为整型),并且定义了下列5个处理信号集的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <signal.h>

// 置空一个信号集
int sigemptyset(sigset_t *set);

// 填充满一个信号集
int sigfillset(sigset_t *set);

// 将一个信号加入信号集
int sigaddset(sigset_t *set, int signum);

// 将一个信号从信号集删除
int sigdelset(sigset_t *set, int signum);

// 上述函数成功返回0,失败返回-1

// 检查一个信号集中是否有这个信号,存在返回1,不存在返回0
int sigismember(const sigset_t *set, int signum);

7.9 sigprocmask函数

sigprocmask函数可以检测或更改,或同时进行检测和更改进程的信号屏蔽字(阻塞信号集)。注:对信号来说,阻塞和屏蔽是一个意思。

函数原型:

1
2
3
#include <signal.h>
// 成功返回0,失败返回-1
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • how:用于指定信号修改的方式,有三种选择:
    • SIG_BLOCK:该进程新的信号屏蔽字是其当前信号屏蔽字和set指向信号集的并集。即set包含了希望阻塞的附加信号;
    • SIG_UNBLOCK:该进程新的信号屏蔽字是其当前信号屏蔽字和set所指向信号集补集的交集。即set包含了希望解除阻塞的信号;
    • SIG_SETMASK:该进程新的信号屏蔽是set指向的值;
  • *set:和how结合使用
  • *oldset:进程的当前信号屏蔽字通过oset返回

代码示例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

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

static void int_handler(int s) {
write(1, "!", 1);
}

int main(void) {
int i;
sigset_t set;

signal(SIGINT, int_handler);
// 置空一个信号集set
sigemptyset(&set);
// 将SIGINT加入到信号集set中
sigaddset(&set, SIGINT);
while(1) {
// 设置信号屏蔽字
// 将SIGINT屏蔽
sigprocmask(SIG_BLOCK, &set, NULL);
for(i = 0; i < 5; i++) {
write(1, "*", 1);
sleep(1);
}
write(1, "\n", 1);
// 打印5个*后,将SIGINT解除屏蔽
sigprocmask(SIG_UNBLOCK, &set, NULL);
}
exit(0);
}

执行结果:

1
2
3
4
5
6
[root@HongyiZeng signal]# ./block
*****
*^C****
!*^C^C^C^C*^C^C^C^C^C^C^C^C^C***
!***^C^C^C**
!****^\Quit

解析:第三行发送SIGINT信号时,SIGINT的屏蔽字位为0,pending位置为1,当打印完成后,解除屏蔽,进程响应该信号,在第四行打印了!;第四行多次发送SIGINT信号,也只是对pending位反复置为1,在第五行也只打印一次!

代码示例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main(void) {
int i;
sigset_t set, oset;
signal(SIGINT, int_handler);
sigemptyset(&set);
sigaddset(&set, SIGINT);
while(1) {
// 将SIGINT阻塞,并将原始的屏蔽字存储在oset中,且原始屏蔽字都是1
sigprocmask(SIG_BLOCK, &set, &oset);
for(i = 0; i < 5; i++) {
write(1, "*", 1);
sleep(1);
}
write(1, "\n", 1);
// 将屏蔽字设置为oset信号集的值,即全1,没有屏蔽
sigprocmask(SIG_SETMASK, &oset, NULL);
}
exit(0);
}

功能和代码示例1相同。

代码示例3

为了保证在进入模块和退出模块时进程的信号屏蔽字是一致,需要修改代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main(void) {
int i;
sigset_t set, oset, saveset;
signal(SIGINT, int_handler);
sigemptyset(&set);
sigaddset(&set, SIGINT);
// 将进程起始的信号屏蔽字保存在saveset中
sigprocmask(SIG_UNBLOCK, &set, &saveset);
while(1) {
sigprocmask(SIG_BLOCK, &set, &oset);
for(i = 0; i < 5; i++) {
write(1, "*", 1);
sleep(1);
}
write(1, "\n", 1);
sigprocmask(SIG_SETMASK, &oset, NULL);
}
// 退出时,还原信号屏蔽字
sigprocmask(SIG_SETMASK, &saveset, NULL);
exit(0);
}

7.10 sigpending函数

7.11 常用函数Ⅱ

7.11.1 sigsuspend函数

该函数通过将进程的屏蔽字替换为由参数sigmask给出的信号集,然后将进程挂起(阻塞)。

1
2
3
#include <signal.h>

int sigsuspend(const sigset_t *mask);

功能描述上和pause函数相同,即在等待信号的时候让进程挂起。

区别:sigsuspend实际是将sigprocmask和pause结合起来原子操作。

代码示例

需求:先打印一排*,等待信号,然后打印一排*,以此类推。

  • 使用pause
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void int_handler(int s) {
write(1, "!", 1);
}

int main(void) {
int i;
sigset_t set, oset, saveset;
signal(SIGINT, int_handler);
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_UNBLOCK, &set, &saveset);
while(1) {
sigprocmask(SIG_BLOCK, &set, &oset);
for(i = 0; i < 5; i++) {
write(1, "*", 1);
sleep(1);
}
write(1, "\n", 1);
sigprocmask(SIG_SETMASK, &oset, NULL);
pause();
}
sigprocmask(SIG_SETMASK, &saveset, NULL);
exit(0);
}

执行结果:

1
2
3
4
5
[root@HongyiZeng signal]# ./susp 
*****
^C!*****
^C!**^C^C^C***
!

第二行开始处:当内层循环执行完毕后,到pause处挂起,此时SIGINT到来,首先处理信号,然后唤醒进程,继续执行内层循环打印。

注意第四行,当在内层循环执行时,有多个SIGINT信号到来,由于被屏蔽,所以不打印叹号,打印星号结束后,代码第十九行解除屏蔽(注意,代码第十九行和第二十行之间也会存在多个中断),响应信号(第五行的第一个叹号),再执行到代码第二十行处时,此时没有信号到来,所以一直挂起。

  • 使用sigsuspend
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
static void int_handler(int s) {
write(1, "!", 1);
}

int main(void) {
int i;
sigset_t set, oset, saveset;
signal(SIGINT, int_handler);
sigemptyset(&set);
sigaddset(&set, SIGINT);
// 保存初始屏蔽字
sigprocmask(SIG_UNBLOCK, &set, &saveset);
// 设置阻塞
sigprocmask(SIG_BLOCK, &set, &oset);
while(1) {
for(i = 0; i < 5; i++) {
write(1, "*", 1);
sleep(1);
}
write(1, "\n", 1);
// 挂起
sigsuspend(&oset);
/*
sigset_t tmpset;
sigprocmask(SIG_SETMASK, &oset, &tmpset);
pause();
sigprocmask(SIG_SETMASK, &tmpset, NULL);
*/
}
sigprocmask(SIG_SETMASK, &saveset, NULL);
exit(0);
}

7.11.2 sigaction函数

sigaction函数是升级版的signal函数。

sigaction函数的功能是检查或修改与指定信号相关联的处理动作(可同时两种操作)。

1
2
3
#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • signum参数指出要捕获的信号类型
  • act参数指定新的信号处理方式
  • oldact参数输出先前信号的处理方式(如果不为NULL的话)

struct sigaction结构体定义如下:

1
2
3
4
5
6
7
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
}
  • sa_handler:此参数和signal()的参数handler相同,代表新的信号处理函数
  • sa_mask:用来设置在处理该信号时暂时将 sa_mask 指定的信号集屏蔽
  • sa_flags:用来设置信号处理的其他相关操作,下列的数值可用。
    • SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL
    • SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用
    • SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号
    • SA_SIGINFO:调用信号处理器程序时携带了额外参数,其中提供了关于信号的深入信息
  • sa_sigaction:这是一个三个参数的sa_handler函数版本。如果设置了SA_SIGINFO标志位,则会使用sa_sigaction处理函数,否则使用sa_handler处理函数。其中参数siginfo_t是一个结构体类型

代码示例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
// 需求:三种信号SIGQUIT,SIGTERM,SIGINT共用一个信号处理函数,且该函数中释放了重要的资源,因此不可重入。

static void daemon_exit(int s) {
// 释放相关资源
// ...
exit(0);
}

int main() {
struct sigaction sa;
// 设置信号处理函数
sa.sa_handler = daemon_exit;
sigemptyset(&sa.sa_mask);
// 将SIGQUIT,SIGTERM,SIGINT信号阻塞,防止重入
sigaddset(&sa.sa_mask, SIGQUIT);
sigaddset(&sa.sa_mask, SIGTERM);
sigaddset(&sa.sa_mask, SIGINT);
sa.sa_flags = 0;

// 要捕获SIGQUIT,SIGTERM,SIGINT三种信号
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGQUIT, &sa, NULL);
// ...
exit(0);
}

代码示例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
2
3
4
5
6
7
8
9
struct task_struct {
...
int sigpending; //
...
struct signal_struct *sig; // 信号的处理方法
sigset_t blocked; // 阻塞信号集
struct sigpending pending; // 未决信号集
...
}
  • sigpending:表示进程是否有信号需要处理(1表示有,0表示没有)
  • blocked:表示被屏蔽的信息,每个位代表一个被屏蔽的信号
  • sig:表示信号相应的处理方法,其类型是 struct signal_struct,定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#define  _NSIG  64

struct signal_struct {
atomic_t count;
struct k_sigaction action[_NSIG];
spinlock_t siglock;
};

typedef void (*__sighandler_t)(int);

// sigaction详见7.11.2节
struct sigaction {
__sighandler_t sa_handler;
unsigned long sa_flags;
void (*sa_restorer)(void);
sigset_t sa_mask;
};

struct k_sigaction {
struct sigaction sa;
};
  • pending:其类型为 struct sigpending,存储着进程接收到的信号队列,定义如下:
1
2
3
4
5
6
7
8
9
struct sigqueue {
struct sigqueue *next;
siginfo_t info;
};

struct sigpending {
struct sigqueue *head, **tail;
sigset_t signal;
};

当进程接收到一个信号时,就需要把接收到的信号添加 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
2
CFLAGS+=-pthread # 编译选项
LDFLAGS+=-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 进程剩余的时间片

进程中的定时器如下图所示:

image-20221227214742773

其余略

② 线程共享和私有内容

私有资源:所属线程的栈区、程序计数器、栈指针以及函数运行使用的寄存器是线程私有的。这些资源又称为线程上下文。

除此之外的所有资源均由线程共享。

8.2 线程标识

就像每个进程有一个进程ID一样,每个线程也有一个线程ID。进程ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。

进程ID是用pid_t数据类型来表示的,是一个非负整数。线程ID是用pthread_t 数据类型来表示的,实现的时候可以用一个结构来代表pthread_t数据类型,所以可移植的操作系统实现不能把它作为整数处理。

因此需要一个函数来对两个线程ID进行比较:

1
2
3
4
#include <pthread.h>

// 相等返回非零,不等返回0
int pthread_equal(pthread_t t1, pthread_t t2);

获取自身的线程id:

1
2
3
#include <pthread.h>

pthread_t pthread_self(void);

8.3 线程创建

在传统 UNIX进程模型中,每个进程只有一个控制线程。从概念上讲,这与基于线程的模型中每个进程只包含一个线程是相同的。在POSIX线程(pthread)的情况下,程序开始运行时,它也是以单进程中的单个控制线程启动的。在创建多个控制线程以前,程序的行为与传统的进程并没有什么区别。

新增的线程可以通过调用pthread_create函数创建。

1
2
3
#include <pthread.h>
// 成功返回0,失败返回errno
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
  • thread:事先创建好的pthread_t类型的参数。成功时thread指向的内存单元被设置为新创建线程的线程ID。
  • attr:用于定制各种不同的线程属性。APUE的12.3节讨论了线程属性。通常直接设为NULL。
  • start_routine:新创建线程从此函数开始运行,无参数时arg设为NULL即可。形参是函数指针(该函数返回值和形参均为void*),因此需要传入函数地址。
  • arg:start_rtn函数的参数。无参数时设为NULL即可。有参数时输入参数的地址。当多于一个参数时应当使用结构体传入。

代码示例

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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

// 线程执行的函数
static void* func(void* p) {
puts("Thread is working!");
return NULL;
}

int main(void) {
pthread_t tid;
int err;

puts("Begin!");

err = pthread_create(&tid, NULL, func, NULL);
if(err) {
fprintf(stderr, "pthread_create():%s\n", strerror(err));
exit(1);
}

puts("End!");
exit(0);
}

打印结果:

1
2
3
[root@HongyiZeng pthread]# ./create 
Begin!
End!

分析:线程的调度取决于调度器策略。创建线程后,新的线程还没来得及被调度,main线程就执行了exit(0)使得进程退出,所以新的线程并没有被执行就退出了。

8.4 线程终止

8.4.1 终止方式

如果进程中的任意线程调用了 exit_Exit 或者 _exit,那么整个进程就会终止。与此相类似,如果默认的动作是终止进程,那么,发送到线程的信号就会终止整个进程。

单个线程可以通过3种方式退出,因此可以在不终止整个进程的情况下,停止它的控制流:

  • 线程可以简单地从启动例程中返回,返回值是线程的退出码
  • 线程可以被同一进程中的其他线程取消
  • 线程调用pthread_exit
1
2
3
#include <pthread.h>

void pthread_exit(void *rval_ptr);

例如:

1
2
3
4
5
static void *func(void *p) {
puts("Thread is working!");
pthread_exit(NULL);
// return NULL;
}

函数pthread_join用来等待一个线程的结束。相当于进程控制中的wait

1
2
3
#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);
  • thread:为被等待的线程标识符
  • retval:为用户定义的指针,它可以用来存储被等待线程的返回值,即pthread_exit的参数。这是一个二级指针,因此传入的参数为一级指针的地址,如果不关心返回值则用NULL

代码示例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
 #include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

static void *func(void *p) {
puts("Thread is working!");
return NULL;
}


int main(void) {

pthread_t tid;
int err;

puts("Begin!");

err = pthread_create(&tid, NULL, func, NULL);
if(err) {
fprintf(stderr, "pthread_create():%s\n", strerror(err));
exit(1);
}
// join
pthread_join(tid, NULL);
puts("End!");
exit(0);
}

执行结果:

1
2
3
4
[root@HongyiZeng pthread]# ./create 
Begin!
Thread is working!
End!

代码示例2

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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

static void *thr_fn1(void *arg) {
printf("thread1 is working!\n");
pthread_exit((void *)1);
}

static void *thr_fn2(void *arg) {
printf("thread2 is working!\n");
pthread_exit((void *)2);
}


int main(void) {

printf("main thread is working!\n");

int err;
pthread_t tid1, tid2;
void *tret;

err = pthread_create(&tid1, NULL, thr_fn1, NULL);
if(err) {
fprintf(stderr, "pthread_create():%s\n", strerror(err));
exit(1);
}

err = pthread_create(&tid2, NULL, thr_fn2, NULL);
if(err) {
fprintf(stderr, "pthread_create():%s\n", strerror(err));
exit(1);
}

pthread_join(tid1, &tret);
printf("thread1 exit code %ld\n", (long)tret);

pthread_join(tid2, &tret);
printf("thread2 exit code %ld\n", (long)tret);

printf("main thread exit!\n");

exit(0);
}

执行结果:

1
2
3
4
5
6
7
[root@HongyiZeng pthread]# ./test
main thread is working!
thread1 is working!
thread1 exit code 1
thread2 is working!
thread2 exit code 2
main thread exit!

补充:void*

  • void*可以指向任何类型的地址。
1
2
3
4
float f = 5.5;
float* pf = &f;
void* pv = pf; // pv可以指向float类型的地址
float* pf2 = pv; //编译错误,有类型的指针变量不能指向void*变量
  • void*指针只有强制类型转换以后才可以解引用
1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {

float f = 5.5;
float* pf = &f;
void* pv;

pv = pf; //这句是可以的

cout<<*pv<<endl; //编译错误,这样直接对pv取值是错误的
cout<<*(float*)pv<<endl; //强制类型转换后可以取值

return 0;
}
  • void*指针变量和普通指针一样可以通过等于0或者NULL来初始化,表示一个空指针
1
2
void* pv = 0; 
void* pv2 = NULL; // 指针保存的地址为空或0
  • void*指针作为函数的输入和输出时,表示可以接受任意类型的输入指针和输出任意类型的指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>

void* test(void* a) {
printf("test: %d\n", *(int *)a);
return a;
}

int main() {
int a = 5;
int* pi = &a;

printf("%p\n", pi);
printf("%p\n", test(pi));
printf("main: %d\n", *(int *)test(pi));

exit(0);
}

执行结果:

1
2
3
4
5
0x7ffcd39fa0d4
test: 5
0x7ffcd39fa0d4
test: 5
main: 5

8.4.2 栈的清理

线程可以安排它退出时需要调用的函数,这与进程在退出时可以用atexit函数安排退出是类似的。这样的函数称为线程清理处理程序(thread cleanup handler)。一个线程可以建立多个清理处理程序。处理程序记录在栈中,也就是说,它们的执行顺序与它们注册时相反。

1
2
3
4
#include <pthread.h>

void pthread_cleanup_push(void (*routine)(void *), void *arg);
void pthread_cleanup_pop(int execute);

当线程执行以下动作时,清理函数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
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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

static void cleanup_func(void *p) {
puts(p);
}

static void *func(void *p) {
puts("Thread is working!");
pthread_cleanup_push(cleanup_func, "cleanup:1");
pthread_cleanup_push(cleanup_func, "cleanup:2");
pthread_cleanup_push(cleanup_func, "cleanup:3");
puts("push over!");
// 成对出现
pthread_cleanup_pop(1);
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
pthread_exit(NULL);
}

int main(void) {
pthread_t tid;
int err;

puts("Begin!");

err = pthread_create(&tid, NULL, func, NULL);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
pthread_join(tid, NULL);
puts("End!");
exit(0);
}

执行结果:

1
2
3
4
5
Begin!
Thread is working!
push over!
cleanup:3
End!

8.4.3 线程的取消

多线程程序中,一个线程可以借助 pthread_cancel() 函数向另一个线程发送“终止执行”的信号,从而令目标线程结束执行。

pthread_cancel调用并不等待线程终止,它只提出请求。线程在取消请求发出后会继续运行,直到到达某个取消点(CancellationPoint)。取消点是线程检查是否被取消并按照请求进行动作的一个位置。

与线程取消相关的函数有:

1
2
// 发送终止信号给thread线程,如果成功则返回0,否则为非0值。发送成功并不意味着thread会终止。
int pthread_cancel(pthread_t thread);
1
2
// 设置本线程取消动作的执行时机
int pthread_setcanceltype(int type, int *oldtype)
1
2
// 在不包含取消点,但是又需要取消点的地方创建一个取消点,以便在一个没有包含取消点的执行代码线程中响应取消请求
void pthread_testcancel(void)

代码示例

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
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>

//线程执行的函数
void * thread_Fun(void * arg) {
printf("新建线程开始执行\n");
sleep(10);
}

int main(void) {
pthread_t myThread;
void * mess;
int value;
int res;
//创建 myThread 线程
res = pthread_create(&myThread, NULL, thread_Fun, NULL);
if (res != 0) {
printf("线程创建失败\n");
return 0;
}
sleep(1);
//向 myThread 线程发送 Cancel 信号
res = pthread_cancel(myThread);
if (res != 0) {
printf("终止 myThread 线程失败\n");
return 0;
}
//获取已终止线程的返回值
res = pthread_join(myThread, &mess);
if (res != 0) {
printf("等待线程失败\n");
return 0;
}
//如果线程被强制终止,其返回值为 PTHREAD_CANCELED
if (mess == PTHREAD_CANCELED) {
printf("myThread 线程被强制终止\n");
}
else {
printf("error\n");
}
return 0;
}

8.5 线程同步

8.5.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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define LEFT 30000000
#define RIGHT 30000200
#define THRNUM (RIGHT-LEFT+1) // 线程个数

static void *thr_prime(void *p) {
int i, j, mark;
i = *(int *)p;
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}
if(mark)
printf("%d is a primer.\n", i);
pthread_exit(NULL);
}

int main(void) {
int i, err;
pthread_t tid[THRNUM];
for(i = LEFT; i <= RIGHT; i++) {
err = pthread_create(tid+(i-LEFT), NULL, thr_prime, &i);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
}

for (i = LEFT; i <= RIGHT; i++)
pthread_join(tid[i-LEFT], NULL);

exit(0);
}

执行结果:发现每次都不一样,并且每次的结果都是相同的。

原因:线程发生了竞争。

创建线程时,main线程传递给函数thr_prime的参数&i是同一个地址,但是地址保存的值不相同:

1
err = pthread_create(tid+(i-LEFT), NULL, thr_prime, &i);

后面线程执行时,会对这个地址进行解引用:

1
i = *(int *)p;

注意:main线程和创建的线程的调度是由调度算法决定,因此会出现,在线程解引用之前,main线程就将该地址上的i值进行了修改,所以后面线程得到的i值都是同一个值。

解决竞争

定义一个结构体,成员为要计算判断的数,然后每次动态分配内存,将地址作为线程函数的参数即可。

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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define LEFT 30000000
#define RIGHT 30000200
#define THRNUM (RIGHT-LEFT+1)

struct thr_arg_st {
int n;
};

static void *thr_prime(void *p) {
int i, j, mark;
// 先将void*强转为struct thr_arg_st *
i = ((struct thr_arg_st *)p) -> n;
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}
if(mark)
printf("%d is a primer.\n", i);
// 将p作为返回值
pthread_exit(p);
}

int main(void) {
int i, err;
pthread_t tid[THRNUM];
struct thr_arg_st *p;
void * ret;
for(i = LEFT; i <= RIGHT; i++) {
// 动态分配内存
p = malloc(sizeof(*p));
if(p == NULL) {
perror("malloc()");
exit(1);
}
p -> n = i;
err = pthread_create(tid+(i-LEFT), NULL, thr_prime, p);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
}

for (i = LEFT; i <= RIGHT; i++) {
// 用ret来接收(一级指针的地址)
pthread_join(tid[i-LEFT], &ret);
// 释放动态分配的内存
free(ret);
}
exit(0);
}

当多个控制线程共享相同的内存时,需要确保每个线程看到一致的数据视图。如果每个线程使用的变量都是其他线程不会读取和修改的,那么就不存在一致性问题。同样,如果变量是只读的,多个线程同时读取该变量也不会有一致性问题。但是,当一个线程可以修改的变量,其他线程也可以读取或者修改的时候,我们就需要对这些线程进行同步,确保它们在访问变量的存储内容时不会访问到无效的值。

当一个线程修改变量时,其他线程在读取这个变量时可能会看到一个不一致的值。在变量修改时间多于一个存储器访问周期的处理器结构中,当存储器读与存储器写这两个周期交叉时,这种不一致就会出现。当然,这种行为是与处理器体系结构相关的,但是可移植的程序并不能对使用何种处理器体系结构做出任何假设。

图11-7描述了两个线程读写相同变量的假设例子。在这个例子中,线程A读取变量然后给这个变量赋予一个新的数值,但写操作需要两个存储器周期。当线程B在这两个存储器写周期中间读取这个变量时,它就会得到不一致的值。

image-20221230122215903

为了解决这个问题,线程不得不使用锁,同一时间只允许一个线程访问该变量。图11-8描述了这种同步。如果线程B希望读取变量,它首先要获取。同样,当线程A更新变量时,也需要获取同样的这把锁。这样,线程B在线程A释放锁以前就不能读取变量。

image-20221230122240495

8.5.2 互斥量

可以使用 pthread 的互斥接口来保护数据,确保同一时间只有一个线程访问数据。互斥量(mutex从本质上说是一把锁,在访问共享资源前对互斥量进行设置(加锁),在访问完成后释放(解锁)互斥量。

对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞直到当前线程释放该互斥锁。如果释放互斥量时有一个以上的线程阻塞,那么所有该锁上的阻塞线程都会变成可运行状态,第一个变为运行的线程就可以对互斥量加锁,其他线程就会看到互斥量依然是锁着的,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。

只有将所有线程都设计成遵守相同数据访问规则的,互斥机制才能正常工作。操作系统并不会为我们做数据访问的串行化。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其他的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。

互斥变量是用 pthread_mutex_t 数据类型表示的。在使用互斥变量以前,必须首先对它进行初始化,可以把它设置为常量PTHREAD_MUTEX_INITIALIZER(只适用于静态分配的互斥量),也可以通过调用pthread_mutex_init函数进行初始化。如果动态分配互斥量(例如,通过调用malloc函数),在释放内存前需要调用pthread_mutex_destroy

相关函数:

  • 初始化和销毁:
1
2
3
4
5
6
7
8
#include <pthread.h>
// 销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

// 静态分配互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
  • 加锁和解锁
1
2
3
4
5
6
7
8
#include <pthread.h>

// 阻塞加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
// 非阻塞加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);
// 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);

代码示例——20个线程读写一个文件

先向/tmp/out下入1,然后创建20个线程来读这个文件内容并加1,然后写入。期望内容为21

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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define FNAME "/tmp/out"
#define THRNUM 20
#define LINESIZE 1024

static void *thr_add(void *p) {
FILE *fp;
char linebuf[LINESIZE];

fp = fopen(FNAME, "r+");
if(fp == NULL) {
perror("fopen()");
exit(1);
}

fgets(linebuf, LINESIZE, fp);
// 读完后将文件指针指向文件起始处
fseek(fp, 0, SEEK_SET);
sleep(1);
// 向文件写入内容
fprintf(fp, "%d\n", atoi(linebuf) + 1);
fclose(fp);

pthread_exit(NULL);
}

int main(void) {
pthread_t tid[THRNUM];
int i, err;

for(i = 0; i < THRNUM; i++) {
err = pthread_create(tid + i, NULL, thr_add, NULL);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
}

for(i = 0; i < THRNUM; i++)
pthread_join(tid[i], NULL);

exit(0);
}

执行结果:

1
2
3
4
5
6
[root@HongyiZeng posix]# echo 1 > /tmp/out
[root@HongyiZeng posix]# cat /tmp/out
1
[root@HongyiZeng posix]# ./add
[root@HongyiZeng posix]# cat /tmp/out
2

分析:由于调度和竞争,线程读到文件内容1后,休眠1s,然后一起写入2,所以结果就为2;

代码示例——互斥量的使用

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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define FNAME "/tmp/out"
#define THRNUM 20
#define LINESIZE 1024

// 互斥锁
static pthread_mutex_t mut = PTHREAD_MUTEX_INITIALIZER;

static void *thr_add(void *p) {
FILE *fp;
char linebuf[LINESIZE];

fp = fopen(FNAME, "r+");
if(fp == NULL) {
perror("fopen()");
exit(1);
}

// 在进入临界区前加上互斥锁
pthread_mutex_lock(&mut);
fgets(linebuf, LINESIZE, fp);
// 读完后将文件指针指向文件起始处
fseek(fp, 0, SEEK_SET);
sleep(1);
// 向文件写入内容
fprintf(fp, "%d\n", atoi(linebuf) + 1);
fclose(fp);
// 退出临界区后解锁
pthread_mutex_unlock(&mut);

pthread_exit(NULL);
}

int main(void) {
pthread_t tid[THRNUM];
int i, err;

for(i = 0; i < THRNUM; i++) {
err = pthread_create(tid + i, NULL, thr_add, NULL);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
}

for(i = 0; i < THRNUM; i++)
pthread_join(tid[i], NULL);

// 销毁互斥锁
pthread_mutex_destroy(&mut);

exit(0);
}

执行结果:

1
2
3
4
[root@HongyiZeng posix]# echo 1 > /tmp/out
[root@HongyiZeng posix]# ./add
[root@HongyiZeng posix]# cat /tmp/out
21

代码示例3——使用互斥锁实现线程同步

需求:四个线程依次打印abcd

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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define THRNUM 4

static pthread_mutex_t mut[THRNUM];

// 返回下一个线程的编号0~3,3的下一个为0
static int next(int n) {
return (n + 1) == THRNUM ? 0 : n + 1;
}

static void *thr_func(void *p) {
int n = (int)p; // 线程编号
int c = 'a' + (int)p; // 线程打印的字符

while(1) {
// 加锁
pthread_mutex_lock(mut + n);
// 向终端打印字符
write(1, &c, 1);
// 释放下一个线程的锁
pthread_mutex_unlock(mut + next(n));
}

pthread_exit(NULL);
}

int main(void) {
int i, err;
pthread_t tid[THRNUM];

for(i = 0; i < THRNUM; i++) {
// 初始化锁
pthread_mutex_init(mut + i, NULL);
// 加锁
pthread_mutex_lock(mut + i);
err = pthread_create(tid + i, NULL, thr_func, (void *)i);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
}
// 释放打印a的线程的锁
pthread_mutex_unlock(mut + 0);
// 运行3s终止进程
alarm(3);
for(i = 0; i < THRNUM; i++)
pthread_join(tid[i], NULL);

exit(0);
}

8.5.3 线程池

线程数是有一定限制的,8.5.1节用201个线程来检测质数,本节利用线程池来解决。

假设线程池提供4个线程来检测201个质数。设置临界区资源num,当:

  • num = 0:当前没有任务
  • num = -1:当前任务已经全部完成
  • num = 300000~3000200:当前有一个任务,需要一个线程来接受任务
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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

#define LEFT 30000000
#define RIGHT 30000200
#define THRNUM 4 // 假设线程池中有4个线程

// 临界区资源
static int num = 0;
// 互斥锁
static pthread_mutex_t mut_num = PTHREAD_MUTEX_INITIALIZER;

static void *thr_prime(void *p) {
int i, j, mark;
// 死循环:领取任务
while(1) {
pthread_mutex_lock(&mut_num);
// 循环检测是否有任务
while(num == 0) { // 当前没有任务
pthread_mutex_unlock(&mut_num);
sched_yield();
pthread_mutex_lock(&mut_num);
}
// 拿到任务,判断是否为-1,即任务已经结束
if(num == -1) {
// 要释放锁,防止其他线程死锁
pthread_mutex_unlock(&mut_num);
break;
}
// 拿到任务
i = num;
// 将num设置为0,即没有任务
num = 0;
pthread_mutex_unlock(&mut_num);
//---------线程做任务:检测是否为质数-------
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}
if(mark)
printf("[thread-%d]%d is a primer.\n", (int)p, i);
}
pthread_exit(NULL);
}

int main(void) {
int i, err;
pthread_t tid[THRNUM];
// 启动线程池
for(i = 0; i < THRNUM; i++) {
err = pthread_create(tid+i, NULL, thr_prime, (void *)i);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
}

// 主线程下发任务
for(i = LEFT; i <= RIGHT; i++) {
pthread_mutex_lock(&mut_num);
// 循环检测任务是否被线程领走
while(num != 0) { // 没有领走任务
pthread_mutex_unlock(&mut_num);
// 让出CPU
sched_yield();
pthread_mutex_lock(&mut_num);
}
// 设置num,即下发任务
num = i;
pthread_mutex_unlock(&mut_num);
} // 下发任务完毕

// 设置num为-1,代表任务全部结束
pthread_mutex_lock(&mut_num);
// 循环检测最后一个任务是否被完成
while(num != 0) { // 没有完成
pthread_mutex_unlock(&mut_num);
sched_yield();
pthread_mutex_lock(&mut_num);
}
// 已完成,则设置为-1
num = -1;
pthread_mutex_unlock(&mut_num);
// 收尸
for (i = 0; i < THRNUM; i++) {
pthread_join(tid[i], NULL);
}
// 销毁互斥锁
pthread_mutex_destroy(&mut_num);
exit(0);
}

打印结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[thread-0]30000001 is a primer.
[thread-2]30000041 is a primer.
[thread-3]30000023 is a primer.
[thread-1]30000037 is a primer.
[thread-1]30000079 is a primer.
[thread-0]30000059 is a primer.
[thread-3]30000071 is a primer.
[thread-2]30000049 is a primer.
[thread-0]30000083 is a primer.
[thread-1]30000109 is a primer.
[thread-3]30000137 is a primer.
[thread-2]30000133 is a primer.
[thread-3]30000163 is a primer.
[thread-1]30000149 is a primer.
[thread-0]30000167 is a primer.
[thread-2]30000169 is a primer.
[thread-0]30000199 is a primer.
[thread-3]30000193 is a primer.

不足:该程序存在盲等,即查询法的不足,上游main线程一直在循环查看任务是否被领走,而下游一直在循环查看是否有任务。

通知法:上游将设置任务后,唤醒下游来处理任务。如果没有领走任务,则阻塞自己,等待下游来唤醒。

下游发现有任务,则领走任务,并唤醒上游;没有任务,则阻塞,等待上游来唤醒。

8.5.4 线程令牌桶

暂略

8.5.5 条件变量

条件变量是线程可用的另一种同步机制。条件变量给多个线程提供了一个会合的场所。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。

条件本身是由互斥量保护的。线程在改变条件状态之前必须首先锁住互斥量。其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁定以后才能计算条件。

在使用条件变量之前,必须先对它进行初始化。由pthread_cond_t数据类型表示的条件变量可以用两种方式进行初始化。可以把常量PTHREAD_COND_INITTALIZER赋给静态分配的条件变量但是如果条件变量是动态分配的,则需要使用pthread_cond_init函数对它进行初始化。在释放条件变量底层的内存空间之前,可以使用pthread_cond_destroy函数对条件变量进行反初始化。

相关函数和作用:

初始化条件变量

1
2
3
4
5
6
7
8
9
#include <pthread.h>
// 销毁
int pthread_cond_destroy(pthread_cond_t *cond);

// 动态初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

// 静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

阻塞当前线程,等待条件成立

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

// 有时间的条件等待
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);

// 条件等待
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
  • cond:已初始化好的条件变量
  • mutex:与条件变量配合使用的互斥锁
  • abstime:阻塞线程的时间

调用两个函数之前,我们必须先创建好一个互斥锁并完成加锁操作,然后才能作为实参传递给 mutex 参数。两个函数会完成以下两项工作:

  • 阻塞线程,直至接收到条件成立的信号
  • 当线程被添加到等待队列上时,将互斥锁解锁,即释放mutex

也就是说,函数尚未接收到“条件成立”的信号之前,它将一直阻塞线程执行。注意,当函数接收到“条件成立”的信号后,它并不会立即结束对线程的阻塞,而是先完成对互斥锁的“加锁”操作,然后才解除阻塞。

两个函数都以“原子操作”的方式完成“阻塞线程+解锁”或者“重新加锁+解除阻塞”这两个过程。

解除线程的“阻塞”状态

1
2
3
4
5
6
#include <pthread.h>
// 唤醒所有的阻塞线程
int pthread_cond_broadcast(pthread_cond_t *cond);

// 唤醒所有正在的至少一个线程
int pthread_cond_signal(pthread_cond_t *cond);

对于被上面两个函数阻塞的线程,我们可以借助如上两个函数向它们发送“条件成立”的信号,解除它们的“阻塞”状态。

由于互斥锁的存在,解除阻塞后的线程也不一定能立即执行。当互斥锁处于“加锁”状态时,解除阻塞状态的所有线程会组成等待互斥锁资源的队列,等待互斥锁“解锁”。

代码示例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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

#define LEFT 30000000
#define RIGHT 30000200
#define THRNUM 4

static int num = 0;
static pthread_mutex_t mut_num = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cond_num = PTHREAD_COND_INITIALIZER;

static void *thr_prime(void *p) {
int i, j, mark;
while(1) {
pthread_mutex_lock(&mut_num);
// 查看是否有任务
while(num == 0) { // 没有任务
// 则阻塞自己,释放互斥锁
pthread_cond_wait(&cond_num, &mut_num);
}
if(num == -1) {
pthread_mutex_unlock(&mut_num);
break;
}
// 领走任务
i = num;
num = 0;
// 唤醒所有阻塞线程
pthread_cond_broadcast(&cond_num);
pthread_mutex_unlock(&mut_num);
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}
if(mark)
printf("[thread-%d]%d is a primer.\n", (int)p, i);
}
pthread_exit(NULL);
}

int main(void) {
int i, err;
pthread_t tid[THRNUM];
for(i = 0; i < THRNUM; i++) {
err = pthread_create(tid+i, NULL, thr_prime, (void *)i);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
}

for(i = LEFT; i <= RIGHT; i++) {
pthread_mutex_lock(&mut_num);
// 查看任务是否领走
while(num != 0) { // 任务没有领走
// 阻塞自己,并释放互斥锁
pthread_cond_wait(&cond_num, &mut_num);
}
// 已经被领走,则设置任务
num = i; // 设置任务
// 唤醒任何一个阻塞的线程
pthread_cond_signal(&cond_num);
pthread_mutex_unlock(&mut_num);
}
pthread_mutex_lock(&mut_num);
while(num != 0) {
pthread_mutex_unlock(&mut_num);
sched_yield();
pthread_mutex_lock(&mut_num);
}
num = -1;
// 唤醒所有线程
pthread_cond_broadcast(&cond_num);
pthread_mutex_unlock(&mut_num);

for (i = 0; i < THRNUM; i++) {
pthread_join(tid[i], NULL);
}

pthread_mutex_destroy(&mut_num);
pthread_cond_destroy(&cond_num);
exit(0);
}

代码示例2——打印abcd

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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define THRNUM 4

static int num = 0;
static pthread_mutex_t mut = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

static int next(int n) {
return (n + 1) == THRNUM ? 0 : n + 1;
}

static void *thr_func(void *p) {
int n = (int)p;
int c = 'a' + (int)p;

while(1) {
pthread_mutex_lock(&mut);
// 向终端打印字符
while(num != n) {
// 如果num不是自己的编号,则阻塞并释放锁
pthread_cond_wait(&cond, &mut);
}
write(1, &c, 1);
num = next(num); // 将num修改为下一个线程的编号
// 广播唤醒所有阻塞的线程
pthread_cond_broadcast(&cond);
pthread_mutex_unlock(&mut);
}

pthread_exit(NULL);
}

int main(void) {
int i, err;
pthread_t tid[THRNUM];

for(i = 0; i < THRNUM; i++) {
err = pthread_create(tid + i, NULL, thr_func, (void *)i);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
}
alarm(3);
for(i = 0; i < THRNUM; i++)
pthread_join(tid[i], NULL);
pthread_mutex_destroy(&mut);
pthread_cond_destroy(&cond);
exit(0);
}

8.5.6 信号量

使用互斥量和条件变量可以实现信号量的功能。

需求:产生4个线程(4个资源)来筛选质数,任务完成后线程退出,然后又产生线程。这里的共享资源就是4个线程。

  • mysem.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef __MYSEM_H__
#define __MYSEM_H__

// 为void取别名为mysem_t
typedef void mysem_t;

mysem_t *mysem_init(int initval);

int mysem_add(mysem_t *, int);

int mysem_sub(mysem_t *, int);

int mysem_destroy(mysem_t *);

#endif
  • mysem.c
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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include "mysem.h"

// 信号量的结构体
struct mysem_st {
int value; // 现有资源数(相当于信号量)
pthread_mutex_t mut; // 互斥量
pthread_cond_t cond; // 条件变量
};

// 信号量初始化,initval为资源总数
mysem_t *mysem_init(int initval) {
struct mysem_st *me;
me = malloc(sizeof(*me));
if(me == NULL) {
return NULL;
}
me->value = initval;
pthread_mutex_init(&me->mut, NULL);
pthread_cond_init(&me->cond, NULL);

return me;
}

// 增加信号量
int mysem_add(mysem_t *ptr, int n) {
// 将void*的指针指向mysem_st*
struct mysem_st *me = ptr;
pthread_mutex_lock(&me->mut);
me->value += n;
// 广播唤醒所有等待线程
pthread_cond_broadcast(&me->cond);
pthread_mutex_unlock(&me->mut);
return n;
}

// 减少信号量,索要n个资源
int mysem_sub(mysem_t *ptr, int n) {
struct mysem_st *me = ptr;
pthread_mutex_lock(&me->mut);
while(me->value < n) { // 当资源总数少于需要的n
// 条件等待
pthread_cond_wait(&me->cond, &me->mut);
}
me->value -= n;
pthread_mutex_unlock(&me->mut);
return n;
}

// 销毁信号量
int mysem_destroy(mysem_t *ptr) {
struct mysem_st *me = ptr;
pthread_mutex_destroy(&me->mut);
pthread_cond_destroy(&me->cond);
free(me);
return 0;
}
  • main.c
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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include "mysem.h"

#define LEFT 30000000
#define RIGHT 30000200
#define THRNUM (RIGHT-LEFT+1)
#define N 4 // 资源总量(可创建的线程总数)

static mysem_t *sem;

static void *thr_prime(void *p) {
int i, j, mark;
i = (int)p;
mark = 1;
for(j = 2; j < i/2; j++) {
if(i % j == 0) {
mark = 0;
break;
}
}
if(mark)
printf("%d is a primer.\n", i);
// 假设任务需要5s完成
sleep(5);
// 增加信号量
mysem_add(sem, 1);
pthread_exit(NULL);
}

int main(void) {
int i, err;
pthread_t tid[THRNUM];

sem = mysem_init(N);
if(sem == NULL) {
fprintf(stderr, "mysem_init() failed!\n");
exit(1);
}
for(i = LEFT; i <= RIGHT; i++) {
// 减少信号量
mysem_sub(sem, 1);
err = pthread_create(tid+(i-LEFT), NULL, thr_prime, (void *)i);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", strerror(err));
exit(1);
}
}

for (i = LEFT; i <= RIGHT; i++) {
pthread_join(tid[i-LEFT], NULL);
}
mysem_destroy(sem);
exit(0);
}
  • makefile
1
2
3
4
5
6
7
8
9
10
CFLAGS+=-pthread
LDFLAGS+=-pthread

all: mysem

mysem:main.o mysem.o
gcc $^ -o $@ $(CFLAGS) $(LDFLAGS)

clean:
rm -rf *.o mysem

执行结果

使用命令,查看线程关系:

1
ps ax -L

由下图所示,主线程main[3134]创建了4个线程来筛选质数:

image-20221231093757431

当某个线程完成任务后,增加信号量,并唤醒阻塞线程,下图表示原来的4个线程已经完成任务退出,主线程又创建了4个新的线程来筛选质数:

image-20221231093816207

同理,图表示原来的4个线程已经完成任务退出,主线程又创建了4个新的线程来筛选质数:

image-20221231093834252

8.6 线程属性

pthread 接口允许我们通过设置每个对象关联的不同属性来细调线程和同步对象的行为。通常,管理这些属性的函数都遵循相同的模式。

在所有调用pthread_create函数的实例中,传入的参数都是空指针,而不是指向pthread_attr_t结构的指针。可以使用pthread_attr_t结构修改线程默认属性,并把这些属性与创建的线程联系起来。

可以使用pthread_attr_init函数初始化 pthread_attr_t 结构。在调用 pthread attr_init 以后,pthread_attr_t 结构所包含的就是操作系统实现支持的所有线程属性的默认值

初始化和销毁

1
2
3
4
#include <pthread.h>

int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);

下图总结了 POSIX.1 定义的线程属性。POSIX.1 还为线程执行调度(Thread Execution Scheduling)选项定义了额外的属性,用以支持实时应用。下图同时给出了各个操作系统平台对每个线程属性的支持情况。

image-20221231175621629


线程分离状态属性

线程分离:在我们使用默认属性创建一个线程的时候,线程是 joinable 的。 joinable 状态的线程,必须在另一个线程中使用 pthread_join() 等待其结束, 如果一个 joinable 的线程在结束后,没有使用 pthread_join() 进行操作, 这个线程就会变成”僵尸线程”。可以使用pthread_detach函数让线程分离。

当线程被设置为分离状态后,线程结束时,它的资源会被操作系统自动的回收, 而不再需要在其它线程中对其进行 pthread_join() 操作。

1
2
3
4
5
#include <pthread.h>
// 设置状态
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
// 获取状态
int pthread_attr_getdetachstate(pthread_attr_t *attr, int *detachstate);
  • detachstate:可以设置为以下属性
    • PTHREAD_CREATE_DETACHED:线程分离状态
    • PTHREAD_CREATE_JONINABLE:线程可joinable状态

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static void *fn(void *p) {
// ...
}

int main(void) {
int err;
pthread_t tid;
pthread_attr_t attr;
// 初始化
pthread_attr_init(&attr);
// 设置创建的线程为分离状态
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
// 创建线程
err = pthread_create(&tid, &attr, fn, NULL);
if(err) {
//...
exit(1);
}
// 销毁
pthread_attr_destroy(&attr);
exit(0);
}

线程的栈和栈大小

可以使用下列函数设置线程的栈属性。

1
2
3
4
#include <pthread.h>

int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
int pthread_attr_getstack(pthread_attr_t *attr, void **stackaddr, size_t *stacksize);

对于进程来说,虚地址空间的大小是固定的。因为进程中只有一个栈,所以它的大小通常不是问题。但对于线程来说,同样大小的虚地址空间必须被所有的线程栈共享。如果应用程序使用了许多线程,以致这些线程栈的累计大小超过了可用的虚地址空间,就需要减少默认的线程栈大小。另一方面,如果线程调用的函数分配了大量的自动变量,或者调用的函数涉及许多很深的栈帧(stack frame),那么需要的栈大小可能要比默认的大。

如果线程栈的虚地址空间都用完了,那可以使用malloc或者mmap来为可替代的栈分配空间,并用pthread_attr_setstack函数来改变新建线程的栈位置。由stackaddr 参数指定的地址可以用作线程视的内容范围中的最低可寻找地址,该地址与处理器结构相应的边界应对齐。当然,这要假设malloc和mmap所用的虚地址范围与线程栈当前使用的虚地址范围不同。

stackaddr线程属性被定义为栈的最低内存地址,但这并不一定是栈的开始位置。对于一个给定的处理器结构来说,如果栈是从高地址向低地址方向增长的,那么 stackaddr线程属性将是栈的结尾位置,而不是开始位置。

应用程序也可以通过下列函数读取或设置线程属性stacksize

1
2
3
4
#include <pthread.h>

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
int pthread_attr_getstacksize(pthread_attr_t *attr, size_t *stacksize);

如果希望改变默认的栈大小,但又不想自己处理线程栈的分配问题,这时使用pthread_attr_setstacksize函数就非常有用。设置stacksize属性时,选择的stacksize不能小于PTHREAD_STACK_MIN

代码示例——测试线程数量的上限:

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
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

static void *func(void *p) {
while(1);
pthread_exit(NULL);
}

int main(void) {
int i, err;
pthread_t tid;
pthread_attr_t attr;
pthread_attr_init(&attr);
// 定义线程的栈大小为10M
pthread_attr_setstacksize(&attr, 1024 * 1024);
for(i = 0; i ; i++) {
err = pthread_create(&tid, NULL, func, NULL);
if(err) {
fprintf(stderr, "pthread_create(): %s\n", stderror(err));
break;
}
}
printf("%d\n", i);
pthread_attr_destroy(&attr);
exit(0);
}

8.7 同步属性

线程的同步对象也具有属性。

8.7.1 互斥量属性

互斥量属性是用 pthread_mutexattr_t 结构表示的。在8.5.2节每次对互斥量进行初始化时,都是通过使用PTHREAD_MUTEX_INITTALIZER 常量或者用指向互斥量属性结构的空指针作为参数调用 pthread_mutex_init 函数,得到互斥量的默认属性。

对于非默认属性,可以使用下列函数进行初始化和销毁:

1
2
3
4
#include <pthread.h>

int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
int pthread_mutexattr_init(pthread_mutexattr_t *attr);

互斥量的三个主要属性:

  • 进程共享属性
  • 健壮属性(略)
  • 类型属性

进程共享

在进程中,多个线程可以访问同一个同步对象。正如在8.5.2节看到的,这是默认的行为。在这种情况下,进程共享互斥量属性需设置为PTHREAD_PROCESS_PRIVATE

但也存在这样的机制:允许相互独立的多个进程把同一个内存数据块映射到它们各自独立的地址空间中。就像多个线程访问共享数据一样,多个进程访问共享数据通常也需要同步。如果进程共享互斥量属性设置为PTHREAD_PROCESS_SHARED,从多个进程彼此之间共享的内存数据块中分配的互斥量就可以用于这些进程的同步。

相关函数调用:

1
2
3
4
#include <pthread.h>

int pthread_mutexattr_getpshared(const pthread_mutexattr_t * restrict attr, int *restrict pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t *attr, int pshared);
  • pshared:这里的p指的就是进程process
    • PTHREAD_PROCESS_PRIVATE:进程独占互斥量
    • PTHREAD_PROCESS_SHARED:进程共享互斥量

类型属性

类型互斥量属性控制着互斥量的锁定特性。POSIX.1定义了4种类型。

  • PTHREAD_MUTEX_NORMAL:一种标准互斥量类型,不做任何特殊的错误检查或死锁检测。
  • PTHREAD_MUTEX_ERRORCHECK:此互斥量类型提供错误检查。
  • PTHREAD_MUTEX_RECURSIVE :此互斥量类型允许同一线程在互斥量解锁之前对该互斥量进行多次加锁。递归互斥量维护锁的计数,在解锁次数和加锁次数不相同的情况下,不会释放锁。所以,如果对一个递归互斥量加锁两次,然后解锁一次,那么这个互斥量将依然处于加锁状态,对它再次解锁以前不能释放该锁。
  • PTHREAD_MUTEX_DEFAULT:此互斥量类型可以提供默认特性和行为。操作系统在实现它的时候可以把这种类型自由地映射到其他互斥量类型中的一种。

image-20221231194113676

上图不占用解锁是指解锁不是自己加的锁(解锁别人加的锁),例如打印abcd的程序。

相关函数:

1
2
3
4
#include <pthread.h>

int pthread_mutexattr_gettype(const pthread_mutexattr_t *restrict attr, int *restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);

8.7.2 条件变量属性

相关函数:

1
2
3
4
5
6
7
#include <pthread.h>

int pthread_condattr_destroy(pthread_condattr_t *attr);
int pthread_condattr_init(pthread_condattr_t *attr);

int pthread_condattr_getpshared(const pthread_condattr_t *restrict attr, int *restrict pshared);
int pthread_condattr_setpshared(pthread_condattr_t *attr,int pshared);

8.8 线程安全IO

此前介绍过的IO都是线程安全的IO,即在当多个线程操作IO时,都必须对缓冲区进行加锁和解锁,防止出现对缓冲区进行竞争的现象。

例如三个线程,分别向标准输出终端打印连续字符aaabbbccc,如果使用线程安全的io,例如puts,可能出现的情况有:

1
2
3
4
aaabbbccc
aaacccbbb
cccbbbaaa
// ...

但绝不会出现下面这种情况:

1
2
3
abcabcabc
aabbccabc
// ...

原因就在于,puts是线程安全的,在对缓冲区操作前,需要加锁。

也存在线程不安全的IO调用,当考虑到效率问题(省去加锁和解锁的时间),并且确保只有单线程操作缓冲区时,可以使用下面的这些函数,这些函数在后面都加上了_unlocked,表示不加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
int getc_unlocked(FILE *stream);
int getchar_unlocked(void);
int putc_unlocked(int c, FILE *stream);
int putchar_unlocked(int c);

void clearerr_unlocked(FILE *stream);
int feof_unlocked(FILE *stream);
int ferror_unlocked(FILE *stream);
int fileno_unlocked(FILE *stream);
int fflush_unlocked(FILE *stream);
int fgetc_unlocked(FILE *stream);
int fputc_unlocked(int c, FILE *stream);
size_t fread_unlocked(void *ptr, size_t size, size_t n,
FILE *stream);
size_t fwrite_unlocked(const void *ptr, size_t size, size_t n,
FILE *stream);

char *fgets_unlocked(char *s, int n, FILE *stream);
int fputs_unlocked(const char *s, FILE *stream);

8.9 线程和信号

对多线程的进程而言,只有进程级别的未决信号集pending,没有信号屏蔽字mask,而每个线程都有自己的pendingmask(线程级别)。

进程向进程发送信号,改变的是进程级别的pending,线程向线程发送信号,改变的是线程级别的pending。对于线程级别的信号响应,使用当前线程的pendingmask进行按位与。对于进程级别的信号响应,使用当前工作线程的mask和进程级别的pending进行按位与。

此前讨论了进程如何使用 sigprocmask 函数来阻止信号发送。然而,sigprocmask 的行为在多线程的进程中并没有定义,线程必须使用pthread_sigmask

1
2
3
4
5
6
7
#include <signal.h>

// 修改线程级别的信号屏蔽字
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);

// 向指定线程发送信号
int pthread_kill(pthread_t thread, int sig);

线程可以调用sigwait等待一个或多个信号的出现:

1
2
3
4
#include <signal.h>

// 等待信号集set
int sigwait(const sigset_t *set, int *sig);

8.10 线程与fork

暂略

9 高级IO

本节对应第十四章高级IO

9.1 非阻塞IO

阻塞IO:当资源不可用的时候,应用程序就会挂起。当资源可用的时候,唤醒任务。

阻塞IO图示如下,应用程序调用 read 函数从设备中读取数据,当设备不可用或数据未准备好的时候就会进入到休眠态。等设备可用的时候就会从休眠态唤醒,然后从设备中读取数据返回给应用程序。

image-20230101113458287

非阻塞IO:当资源不可用的时候,应用程序轮询查看,或放弃,会有超时处理机制。

非阻塞IO图示如下,可以看出,应用程序使用非阻塞访问方式从设备读取数据,当设备不可用或数据未准备好的时候会立即向内核返回一个错误码,表示数据读取失败。应用程序会再次重新读取数据,这样一直往复循环,直到数据读取成功。

image-20230101113602025

9.2 有限状态机

需求:有左右两个设备,第一个任务为读左设备,写右设备,第二个任务读右设备,写左设备。

image-20230101115616601

代码实现