Unix环境高级编程-学习笔记

Unix系统高级编程

程序由业务逻辑系统访问两部分构成的。其中,业务逻辑是根据业务需求,按照设计好的逻辑规则,处理信息,与系统(平台)无关的;而系统访问则是利用操作系统所提供的各种功能,来辅助业务逻辑的实现,是跟系统相关的(平台相关性)。

使用标准库函数(如scanf/printf)实现的程序,可以做到源码级兼容,因为标准库函数的实现虽然基于系统函数,但是直接使用标准库函数时是不需要考虑系统函数的,也就是说标准库函数屏蔽了操作系统/平台之间的差异。与之相对,底层的系统调用函数只能做到接口级兼容。

既然标准库函数这么好,为什么还需要使用系统函数呢?——环境(裸板程序没有标准库)、性能、功能(某些功能标准库没有)

一、Unix系统简介

1、Unix系统的背景

PS:有些内容的描述不准确

Ø 1961-1969年:史前时代

CTSS(Compatible Time-Sharing System,兼容分时系统),以MIT(麻省理工)为首的开发小组(包括AT&T的贝尔实验室),小而简单的实验室原型

分时系统:分时操作系统是指在一台主机上连接多个带有显示器和键盘的终端,同时允许多个用户通过主机的终端,以交互方式使用计算机,共享主机中的资源。分时操作系统是一个多用户交互式操作系统。分时操作系统,主要分为三类:单道分时操作系统,多道分时操作系统,具有前台和后台的分时操作系统。分时操作系统将CPU的时间划分成若干个片段,称为时间片。操作系统以时间片为单位,轮流为每个终端用户服务。

Multics(Multiplexd Information and Computing System,多路信息与计算系统),庞大而复杂,不堪重负,开发和维护变得非常困难,半成品

Unics(Uniplexed Information and Computing System,单路信息与计算系统),返璞归真,走上正道(剔除了Multics中不实用和太过复杂的功能)

Ø 1969-1971年:创世纪

Ken Thompson(肯.汤姆逊),Unix之父,B语言之父,内核用B语言和汇编语言进行开发,在PDP-7(被废弃的)机器上开发的,第一个Unix系统的核心和简单的应用程序。后来被移植到PDP-11平台上,功能更加完善。

目前仍然在世,在谷歌工作,开发了go语言

Ø 1971-1979年:出谷记

Dennis Ritchie(丹尼斯.里奇),C语言之父,用C语言重写了Unix系统内核,极大地提升了Unix系统的可读性、可维护性和可移植性——Unix V7,第一个真正意义上的Unix系统。

Ø 1980-1985年:第一次Unix战争

AT&T贝尔实验室:SVR4

和BSD(加州大学伯克利分校):BSD+TCP/IP

DQRPA,ARPANET(INTERNET),更加支持BSD,导致BSD开发出TCP/IP

IEEE,国际电气电子工程师协会,制定了POSIX标准,为Unix内核和外壳制定了一系列技术标准和规范,消除了系统版本之间的分歧,大一统的操作系统。—>战争结束

Ø 1988-1990年:第二次Unix战争

AT&T+Sun:

IBM+DEC+HP:

比尔.盖茨–>windows突然发布,战争结束

Ø 1992-至今:

1991年,Linus Torvalds创建了Linux系统的内核

1993年,Linux达到了产品级操作系统的水准

1993年,AT&T将Unix系统卖给Novell

1994年,Novell将Unix系统卖给X/Open组织

1995年,X/Open组织将Unix系统无偿捐赠给SCO

2000年,SCO把Unix系统卖给Celdear——Linux发行商

Linux就是现代版本的Unix

2、Linux系统的背景

类Unix操作系统,开源免费。

虽然有不同的发行版本,但是它们都是用相同的内核

支持多种硬件平台:得益于它的免费和开源,手机、路由器、视频游戏控制器、个人PC、大型计算机等等

隶属于GNU功能,GNU = GNU Not Unix

受GPL许可证的限制:如果发布了一个可执行的二进制代码,就必须同时发布可读的源代码,并且在发布任何基于GPL许可证的软件时,不能添加限制性条款。—>后来又有了LGPL

3、Linux系统的版本

早期版本:0.01、 0.02、 …, 1.00

旧计划:1.0.1——2.6.0(A.B.C)

A——主版本号:内核大幅更新

B——次版本号:内核重大修改,奇数表示测试版,偶数是稳定版

C——补丁序号:内核轻微修改

新计划:A.B.C-D.E(格式)

D——构建次数,反应极微小的更新

E——描述信息,rc/r(候选版本)、smp(支持对称多处理器核心)、EL(Rad Hat的企业版)、mm(试验新技术)、…

如何查看Linux系统版本号:cat /proc/version或者使用uname命令

4、Linux系统的特点

遵循GNU/GPL许可证

开放性

多用户

多任务

设备无关性

丰富的网络功能

可靠的系统安全

良好的可移植性

5、Linux发行版本

ubuntu:大众化,简单易用

Linux Mint:新潮前卫,喜欢用一些新技术手段,可能不太稳定

Fedora:red hat的一个桌面版本

OpenSUSE:美观漂亮

Debian:自由开放

Slackware:简杰朴素、简陋

Red Hat:经典、稳定,企业应用,支持全面

CentOS:

Arch:

二、GNU编译器(GCC)

1、GCC的基本特点

(1)支持多种硬件架构:x86_64、alpha、arm、mips、powerpc、SPARC、VAX……

(2)支持多种操作系统(可执行程序的组织形式):Linux、Unix、BSD、Android、MacOS、IOS、windows……

(3)支持多种编程语言:C、C++、Object-C、Java、Fortran、Pascal、Ada……

(4)查看GCC版本:gcc -v

2、程序的构建过程

源代码—>预编译(预处理)—>头文件和宏扩展—>编译—>汇编码—>汇编(.s)—>目标码(.o)—>链接—>可执行代码

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NM2G4Kq6-1578914622438)(./插图/day01/01_编译过程.png)]

nm命令:查看二进制文件符号表

vi hello.c:编辑源代码

gcc -E hello.c -o hello.i:预编译(编译预处理)

gcc -S hello.i -o hello.s:获得汇编代码

gcc -c hello.s -o hello.o:获得目标代码

gcc hello.o -o hello:获得可执行代码

./hello:运行可执行代码

参考代码:day01/code/01_编译过程

3、文件名后缀

​ .h——C语言源代码头文件

​ .c——预处理之前的C语言代码文件

​ .s——汇编语言文件

以上文件都是可读的文本文件,以下文件是不可读的二进制文件(可以使用xxd/hexdump命令查看内容)

​ .o——目标文件

​ .a——静态库文件

​ .so——共享库文件(动态库文件)

​ .out——可执行文件,缺省的

4、GCC编译选项

gcc [选项][参数] 文件1 文件2 …

​ -o:指定输出文件

​ -E:预编译,缺省输出到屏幕,可以用-o指定输出文件

​ -S:编译,将高级语言文件编译成汇编语言文件

​ -c:汇编,将汇编语言文件汇编成机器语言文件

​ -Wall:产生全部警告

​ -std:指定编译器的版本

​ -Werror:将警告当做错误进行处理

​ -x:指定源代码语言

​ -g:产生调试信息,用于gdb调试

​ -O1/-O1/O2/O3:指定优化等级,O0不优化,缺省O1优化

5、头文件

(1)头文件里面写什么?

​ 头文件卫士(#ifndef…#define…#endif):避免在编译阶段产生错误

​ 其他头文件:

​ 宏定义:

​ 自定义类型:

​ 类型别名:

​ 外部变量的声明:

​ 函数声明:

问:为什么不建议将函数定义写在头文件中?

答:一个头文件可能会被多个源文件包含,写在头文件里的函数定义也会因此被预处理器扩展到多个包含该头文件的源文件中,并在编译阶段被编译到多个不同的目标文件中,这将导致链接错误:multipe definition(多重定义)

(2)去哪里找头文件?

gcc -I <头文件附加搜索路径>

#include (尖括号):

​ 先找-I指定的目录,如果没有找到,再找系统指定目录

#include “my.h”(双引号):

​ 先找-I指定的目录,如果没有找到,再找当前目录,如果还没有找到,再找系统指定目录

头文件的系统目录:可以使用gcc <源文件> -v查看

​ usr/include:标准C库

​ /usr/local/include:第三方库

​ /usr/lib/gcc

​ /usr/lib/gcc/x86_64-linux-gnu/7/include:编译器库

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-blmH7CHd-1578914622439)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day01/02_系统指定路径.png)]

代码:calc.h calc.c math.c

6、预处理指令

#include——将指定的文件内容插至此指令处

#define——定义宏

#undef——删除宏定义

#if——如果

#ifdef——如果宏已定义

#ifndef——如果宏未定义

#else——否则,与#if、#ifdef、#ifndef配合使用

#elif——否则如果,与#if、#ifdef、#ifndef配合使用

#endif——结束判定,与#if、#ifdef、#ifndef配合使用

#error——产生错误,结束预处理

#warning 字符串——产生警告,继续预处理

参考代码:error.c

#line 整数n——表示从下一行开始行号变更为第n行

参考代码:line.c

#pragma:设定编译器的状态或者指示编译器的操作

  • #pragma GCC dependency 被依赖文件:表示当前文件依赖于指定的文件名,如果指定的文件最后一次修改时间晚于当前文件,则产生警告信息

  • #pragma GCC poison 语法禁忌:一旦使用该标识符,则产生错误信息

  • #pragma pack(按几字节对齐,1/2/4/8)

  • #pragma pack()——按缺省字节数对齐

参考代码:dep.c pragma.c

7、预定义宏

无需自行定义,预处理器会根据事先设定好的规则将这些宏扩展成其对应的值,这些预定义宏不能被取消定义(#undef)或由编程人员重新定义

__BASE_FILE__:获取正在编译的文件名 %s

_FILE_ :获取当前宏所在的文件名 %s

_LINE_ :获取当前宏所在的行号 %d

_FUNCTION_ :获取当前宏所在的函数名 %s

__func___:同_FUNCTION

_DATE_ :处理日期%s

_TIME_ :处理时间%s

_INCLUDE_LEVEL_:包含层数,从0开始

__cplusplus: C++有定义,C无定义,可以检测环境是C编译器还是C++编译器

代码:print.h predef.h、predef.c

8、GCC相关的环境变量

在进程上下文中保存的一些数据:键(功能,是什么)=值(具体内容)。

env(1):查看环境变量

grep(1):查找字符串

echo $环境变量名:打印该环境变量的值

​ C_INCLUDE_PATH:C头文件附加搜索路径,相当于-I选项

​ CPATH: 同C_INCLUDE_PATH

​ CPLUS__INCLUDE_PATH:C++头文件附加搜索路径,相当于-I选项

​ LIBRARY_PATH:链接库时查找的路径/链接器的默认搜索路径

​ LD_LIBRARY_PATH:加载库的路径/加载器的默认搜索路径

代码:calc.h calc.c math.c

Tips:包含自己的头文件的几种方式

(1) #include “头文件路径”:移植性差

(2) gcc -I指定头文件路径:推荐

(3) 通过C_INCLUDE_PATH或者CPATH指定头文件路径:易冲突

三、库(静态库、共享库)

单一模型:将程序中的所有功能全部实现于一个单一的源文件内。缺点是编译时间长,不易于升级和维护,不易于协作开发

分离模型:将程序的不同功能模块划分到不同的源文件中。优点是缩短了编译时间,易于维护和升级,易于协作开发。不足之处在于,不同的源文件会生成很多的目标文件,使用是需要将每个目标文件依次链接,不方便,所以需要制作成库文件,方便使用和携带。

1、静态库

静态库的本质就是将多个目标文件打包成一个文件。

链接静态库就是将库中被调用的代码(函数**)复制到调用模块**中。

使用静态库的程序通常会占用较大的空间,库中代码一旦修改,所有使用该库的程序必须重新链接(缺点)。

使用静态库的程序运行时无需依赖静态库,执行效率高(优点)。

静态库的形式:lib库名.a

nm命令:查看二进制文件符号表

构建静态库

​ 构建静态库的过程就是将.c源文件编译生成.o目标文件,然后将.o目标文件打包生成.a库文件的过程,使用下面的命令制作静态库和使用静态库:

ar -r -o lib库名.a *.o

使用静态库

  1. gcc … -l库名 -L库的路径
  2. export LIBRARY_PATH=<库路径>:通过LIBRARY_PATH环境变量指定库的路径
  3. gcc … 库文件名:直接链接静态库

参考代码:day02/static_lib/

补充:符号表含义
符号表是一种用于语言翻译器(例如编译器和解释器)中的数据结构。在符号表中,程序源代码中的每个标识符都和它的声明或使用信息绑定在一起,比如其数据类型、作用域以及内存地址。对于符号表组织、构造和管理方法的好坏会直接影响编译系统的运行效率。

2、动态库(共享库)

ldd命令:检查可执行文件依赖的动态库

动态库和静态库最大的不同就是,链接动态库并不需要将库中被调用的代码复制到调用模块中,被嵌入到调用模块中的代码仅仅是被调用代码在动态库中的相对地址。

如果动态库中的代码同时被多个进程所用,动态库的实例(在内存中)在整个内存中仅需一份,因此动态库也叫共享库/共享对象(shared object)。

使用动态库的模块是所占空间较小,即使修改了库中的代码,只要接口保持不变,无需重新链接。

使用动态库的代码,在运行时需要依赖库,因此执行的效率略低。

动态库的形式:lib库名.so

构建动态库:

gcc -c -fpic xxx.c -o xxx.o
#-fpic:生成与位置无关的代码,即在库内的函数调用也用相对地址表示
gcc -shared -o lib库名.so *.o	#将生成的.o打包成动态库

使用动态库(和使用静态库一样)

  1. gcc … -l库名 -L库的路径
  2. export LIBRARY_PATH=<库路径>:通过LIBRARY_PATH环境变量指定库的路径
  3. gcc … 库文件名:直接链接动态库

运行时所调用的动态库必须位于LD_LIBRARY_PATH环境变量所表示的路径(加载器的搜索路径)。

参考代码:day02/dynamic_lib/

gcc缺省链接共享库,可通过-static选项强制链接静态库。

3、动态加载动态库

系统提供的针对动态库的动态加载函数

#include 	//头文件
Link with -ldl	//编译选项

void *dlopen(const char *filename, int flags);
成功返回动态库的句柄,失败返回NULL。

  • filename-动态库的路径,若只给文件名,则根据LD_LIBRARY_PATH环境变量搜索动态库

  • **flags-**加载方式,可取以下值:

    • RTLD_LAZY-延迟加载(懒加载),使用动态库中的符号时才加载

    • RTLD_NOW-立即加载

该函数所返回的动态库句柄唯一的标识了系统内核所维护的动态库对象,将作为后续函数调用的参数

void *dlsym(void *handle, const char *symbol);
成功返回函数地址,失败返回NULL。

  • handle-动态库句柄
  • symbol-符号(函数、全局变量等)名

该函数所返回的函数指针是void*类型,使用时需要做强制类型为实际的函数指针类型才能调用。

int dlclose(void *handle);
关闭共享库,共享库的引用计数减一。如果引用计数为0,系统卸载共享库。成功返回0,失败返回非0

  • handle-动态库句柄

char *dlerror(void);
之前若有错误发生,则返回错误信息字符串,否则返回NULL

参考代码:load.c

#include 
#include 
int main(void)
{
	//动态加载动态库libmath.so
	void *handle = 
        dlopen("../dynamic_lib/libmath.so",RTLD_NOW);
	if(!handle){
		fprintf(stderr,"dlopen:%s\n",dlerror());
		return -1;
	}
	//从动态库中获取add函数的入口地址
	int (*add)(int, int) = 
        (int(*)(int, int))dlsym(handle,"add");
	if(!add){
		fprintf(stderr,"dlsym:%s\n",dlerror());
		return -1;
	}
	//从动态库中获取sub函数的入口地址
	int (*sub)(int, int) = 
        (int(*)(int,int))dlsym(handle,"sub");
	if(!sub){
		fprintf(stderr,"dlsym:%s\n",dlerror());
		return -1;
	}
	//从动态库中获取show函数的入口地址
	void (*show)(int,char,int, int) = 
        (void(*)(int,char,int,int))dlsym(handle,"show");
	if(!show){
		fprintf(stderr,"dlsym:%s\n",dlerror());
		return -1;
	}
	int a=30, b=20;
	show(a,'+',b,add(a,b));
	show(a,'-',b,sub(a,b));
    //卸载/关闭动态库
	if(dlclose(handle)){
		fprintf(stderr,"dlclose:%s\n",dlerror());
		return -1;
	}
	return 0;
}

四、辅助工具

1、查看符号表:nm

列出目标文件(.o)、可执行文件、静态库文件(.a)或动态库文件(.so)中的符号

参考代码:nm.c

int a;//全局变量
static int b; //静态全局变量
void foo(void)
{
    int c; //局部变量
    static int d; //静态局部变量
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ifqH9rfZ-1578914622439)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day02/nm.png)]

2、显示二进制模块反汇编信息:objdump -S

3、删除符号表和调试信息:strip

删除目标文件(.o)、可执行文件、静态库文件(.a)或动态库文件(.so)中的符号表和调试信息,即瘦身

4、查看所依赖的动态库文件:ldd

查看可执行程序文件或动态库文件所依赖的动态库文件

五、错误号和错误信息

1、通过函数的返回值表达错误

返回整数的函数:通过返回合法值域以外的值表示错误

返回指针的函数:通过返回NULL指针表示错误

不需要通过返回值输出信息的函数:返回0表示成功,返回-1表示失败

2、通过错误号和错误信息表示产生错误的具体原因

#include —>全局变量errno(整数),标识最近一次系统调用产生的错误

**char *strerror(int errnum);**根据错误号返回错误信息

**void perror(const char *s);**打印最近错误的错误信息

printf函数的**%m**标记会被替换为最近错误的错误信息

参考代码:./day02/error/errno.c

虽然所有的错误号都不是0,但是因为在函数执行成功的情况下,错误号全局变量errno不会被清0,因此不能用errno是否为0作为函数成功或失败的判断条件,是否出错还是应该根据函数的返回值来决定。

伪代码:

函数调用;
if(返回值表示函数调用失败){
    根据errno判断发生了什么错误;
    针对不同的错误提供不同的处理
};

六、环境变量

每个进程都有一张独立的环境变量表,其中的每个条目都是一个形如“键=值”形式的环境变量。

环境变量是跟进程紧密相关的。

env命令用于显示bash的环境变量。

系统全局变量:erviron,指向了系统的环境向量表,使用时,需要自己在代码中做外部声明:*extern char *environ

所谓环境变量表就是一个以空指针NULL结束的字符指针数组,其中的每个元素都是一个字符指针,指向一个空字符结尾的字符串,该字符串就是形如“键=值”形式的环境变量。

#include 
#include 
void penv(char **env)
{
	while(env && *env){
		printf("%s",*env++);
	}
}
int main(int argc, char *argv[], char *envp[])
{
	extern char **environ;
	penv(environ);
	while(envp && *envp){
		printf("%s",*envp++);
	}
	return 0;
}

(1)根据环境变量名获取该环境变量的值

char *getenv(const char *name)
成功返回变量名匹配的变量值,失败返回NULL。

  • name-环境变量名,即等号左边的部分。

(2)添加或修改环境变量

int putenv(char * string);
成功返回0,失败返回-1

  • string-形如“键=值”形式的环境变量字符串。若其键已存在,则修改其;若其键不存在,则添加该环境变量

(3)添加或修改环境变量

int setenv(const char *name, const char *value, int overwrite);
成功返回0,失败返回-1

  • name-环境变量名,即等号左边的部分
  • value-环境变量值,即等号右边的部分
  • overwrite-当name参数所表示的环境变量名已存在,此参数取0,则保持该变量的原值不变;若此参数取非0,则将该变量的值修改为value

(4)删除环境变量

int unsetenv(const char * name);
成功返回0,失败返回-1

  • name-要删除的环境变量名

(5)清空环境变量

int clearenv(void)
成功返回0,失败返回-1

参考代码:./day02/env/env.c

补充

Linux环境变量的分类:

1.系统级环境变量:每一个登录到系统的用户都能够读取到系统级的环境变量,分为以下几类:

/etc/profile:在系统启动后第一个用户登录时运行,并从/etc/profile.d目录的配置文件中搜集shell的设置,使用该文件配置的环境变量将应用于登录到系统的每一个用户

/etc/bashrc(Ubuntu和Debian中是/etc/bash.bashrc):在 bash shell 打开时运行,修改该文件配置的环境变量将会影响所有用户使用的bash shell

/etc/environment:在系统启动时运行,用于配置与系统运行相关但与用户无关的环境变量,修改该文件配置的环境变量将影响全局

2.用户级环境变量:每一个登录到系统的用户只能够读取属于自己的用户级的环境变量

~/.profile:当用户登录时执行,每个用户都可以使用该文件来配置专属于自己使用的shell信息;

**/.bashrc**:当用户登录时以及每次打开新的shell时该文件都将被读取;注意:通常我们修改bashrc,有些linux的发行版本不一定有profile这个文件etc/profile等中设定的变量(全局)的可以作用于任何用户,而/.bashrc等中设定的变量(局部)只能继承/etc/profile中的变量, 他们是"父子"关系。

七、内存管理

1、虚拟内存、物理内存、半导体内存、换页文件

虚拟内存:地址空间,虚拟的存储区域,应用程序所访问的都是虚拟内存。

物理内存:存储空间,实际的存储区域,只有系统内核可以访问物理内存。物理内存包括半导体内存和换页文件两部分。当半导体内存不够用时,可以把一些长期闲置的代码和数据从半导体内存中缓存到换页文件中,这叫页面换出。一旦需要使用被换出的代码和数据,再把它们从换页文件恢复到半导体内存,这叫做页面换入。因此,系统中的虚拟内存比半导体内存大得多。

虚拟内存和物理内存之间存在对应关系,当应用程序访问虚拟内存时,系统内核会依据这种对应关系找到与之相应的物理内存。上述对应关系存储在内核中的内存映射表中。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-chkS95mb-1578914622439)(./插图/day02/内存映射关系.png)]

Unix/Linux操作系统采用虚拟内存管理技术,即每个进程都有各自互不干涉的4G线性虚拟地址空间(32位系统),用户所看到和接触到的都是该虚拟地址,无法看到实际的物理内存地址。利用这种虚拟地址不但能起到保护操作系统的效果(用户不能直接访问物理内存),而且更重要的是,用户程序可使用比实际物理内存更大的地址空间。

地址分类:物理地址、逻辑地址、线性地址。物理地址是内存单元的实际地址,用于芯片级内存单元寻址;逻辑地址是程序代码经过编译后出现在 汇编程序中地址,每个逻辑地址都由一个段和偏移量组成;线性地址其实就是虚拟地址,在32位CPU架构下,可以表示4GB的地址空间。

逻辑地址经段机制转化成线性地址,线性地址又经过页机制转化为物理地址。物理地址 = 段基址<<4 + 段内偏移(线性地址),但是在Linux系统中,令段的基地址为0,所以段内偏移量=线性地址,也就是说虚拟地址直接映射到了线性地址,Linux把段机制给绕过去了。

2、进程映射(Process Maps)

每个进程都拥有独立的4GB(32位系统)的虚拟内存(虚拟地址空间),分别被映射到不同的物理内存区域。

内存映射和换入换出都是以页为单位,1页=4096B(4K)。

4GB虚拟内存中高地址的1GB被映射到内核的代码和数据区,这1GB虚拟地址空间在各个进程间共享。用户的应用程序只能直接访问低地址的3GB虚拟内存,该区域称为用户空间,而高地址的1GB虚拟内存(虚拟地址空间)被称为内核空间。用户空间中的代码,只能直接访问用户空间的数据;如果要想访问内核中的代码和数据必须借助专门的系统调用完成。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aZt1FAlY-1578914622440)(./插图/day02/进程地址空间布局.png)]

用户空间的3GB虚拟内存可以进一步划分为如下区域:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-InmjUg9G-1578914622440)(./插图/day02/内存分布.png)]

参考代码:./day03/proc_maps/maps.c

size命令可以查看一个可执行程序的代码区、数据区和BSS区的大小。

cat /proc//maps:查看某个进程占用的内存区域

每个进程的用户空间都拥有独立的从虚拟内存到物理内存的映射,称之为进程间的内存壁垒(只能访问自己的用户空间,不能访问其他进程的用户空间)。

参考代码:./day03/proc_maps/vm.c

3、内存的分配与释放

malloc、calloc、realloc、free都是调用的brk/sbrk两个函数,brk/sbrk两个函数调用mmap/munmap这两个函数,mmap/munmap这两个函数调用kmalloc/kree这两个函数

分配:映射+占有

映射:在地址空间(虚拟内存)和存储空间(物理内存)建立映射关系

占有:指定内存空间的归属性

释放:解除映射+放弃占有

放弃占有:解除对内存空间的归属约束

解除映射:消除地址空间(虚拟内存)和存储空间(物理内存)之间的映射关系

(1) 以增量方式分配或者释放虚拟内存

*void sbrk(intptr_t increment);

成功返回调用该函数之前的堆顶指针,失败返回-1

  • increment:可取以下值

    >0—堆顶指针上移,增大堆空间,分配内存

    <0—堆顶指针下移,缩小堆空间,释放内存

    =0—不分配也不释放虚拟内存,仅仅返回当前堆顶指针

系统内核维护一个指针,指向堆内存的顶端,即有效堆内存中最后一个字节的下一个位置。sbrk函数根据增量参数increment调整该指针的位置,同时返回该指针原来的位置。期间若发生内存页耗尽或空闲,则自动追加或取消相应内存页的映射。

参考代码:./day03/sbrk/sbrk.c

(2) 以绝对地址的方式分配或者释放虚拟内存

*int brk(void end_data_segment);

成功返回0,失败返回-1

  • end_data_segment:可取以下值

    >当前堆顶—分配虚拟内存

    <当前堆顶—释放虚拟内存

    =当前堆顶—空操作

系统内核维护一个指针,指向当前堆顶。brk函数会根据指针参数end_data_segment来设置堆顶的新位置。期间若发生内存页耗尽或空闲,则自动追加或取消相应内存页的映射。

参考代码:./day03/sbrk/brk.c

(3) 建立物理内存或文件到虚拟内存的映射

**void mmap(void start, size_t length, int prot, int flags, int fd, off_t offet);

成功返回映射区虚拟内存的起始地址,失败返回MAP_FAILED(void *类型的-1)

  • start—映射区虚拟内存的起始地址,NULL表示由内核自动选择

  • length—映射区的字节数,自动按页(4096)取整

  • port—访问权限,可取以下值

    PROT_READ-可读

    PROT_WRITE-可写

    PROT_EXEC-可执行

    PROT_NONE-不可访问

  • **flags—**可以去以下值
    MAP_ANONYMOUS—匿名映射,将虚拟内存映射到物理内存,函数的最后两个参数fd和offset被忽略

    MAP_PRIVATE—私有映射,将虚拟内存映射到文件的内存缓冲区中,而非磁盘文件

    MAP_SHARED—共享映射,将虚拟内存映射到磁盘文件中

    MAP_DENYWRITE—拒绝写入映射,文件中被映射的区域不能存在其它写入操作

    MAP_FIXED—固定映射,若在start上无法创建映射,则失败;如果没有此标志,系统自动调整

    MAP_LOCKED—锁定映射,禁止被换出到换页文件

  • fd—文件描述符

  • offset—文件偏移量,自动按页对齐

(4) 解除物理内存或文件到的虚拟内存映射,可以一次性解除映射,也可以分次解除映射

int munmaps(void *start,size_t length );
成功返回0,失败返回-1

  • start—映射区起始地址
  • length—映射区字节数

参考代码:mmap.c

PS:段错误的本质——访问了没有物理内存对应的虚拟地址或者该地址有物理内存对应但是没有访问权限

通常brk函数和sbrk函数配合使用:使用sbrk分配内存,使用brk释放内存

4、补充:内存分配原理brk和mmap

——摘自百度文库,仅作了解

从操作系统角度来看,进程分配内存有两种方式,分别由两个系统调用完成:brkmmap(不考虑共享内存):brk是数据段(.data)的最高地址指针_edata往高地址推,mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。

内存分配原理

八、系统调用

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bAGovH4m-1578914622440)(./插图/day03/系统调用.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SQRYEvRK-1578914622440)(./插图/day03/操作系统层次.png)]

(1) Linux系统内核提供了一套用于实现各种系统功能的子程序,谓之系统调用。程序的编写者可以像调用普通C语言函数一样调用这些系统调用函数,以访问系统内核提供的各种服务。

(2) 系统调用函数在形式上与普通C语言函数并无差别,二者的不同之处在于,前者工作在内核态,而后者工作在用户态。

(3) 在intel的CPU上,运行代码分为4个安全级别:Ring0、Ring1、Ring2和Ring3,Linux系统只使用了Ring0和Ring3。用户代码工作在Ring3级,而内核代码工作在Ring0级。一般而言,用户代码无法访问Ring0级的资源,除非借助系统调用,使用户代码得以进入Ring0级,使用系统内核提供的功能。

(4) 在系统内核的内部维护了一张全局表sys_call_table,表中的每个条目记录着每个系统调用在内核代码中的实现的入口地址。

(5) 当用户代码调用了某个系统调用函数时,该函数会先将参数压入堆栈,将系统调用的标识存入eax寄存器,然后通过int 80H指令触发80h中断。

(6) 这时程序便从用户态(Ring3)进入内核态(Ring0).

(7) 工作在系统内核中的中断处理函数被调用,80h中断的处理函数名为system_call,该函数先从堆栈中取出参数,再从eax寄存器中取出系统调用标识,然后再从sys_call_table表中找到与该系统调用标识相对应的实现代码入口地址,携其参数去调用该实现,并将处理结果逐层返回到用户代码中。

九 、文件管理

1、文件系统的物理结构

(1) 机械硬盘的物理结构:驱动臂、盘片、主轴、磁头、控制器等。

(2) 磁表面存储器读写原理

硬盘盘片的表面都覆盖着薄薄的磁性涂层,涂层中含有无数微小的磁性颗粒,谓之磁畴。相邻的若干磁畴组成一个磁性存储元,以其剩磁的极性表示二进制数字0和1。为磁头写线圈施加脉冲电流,可把一位二进制数字转化为磁性存储元的剩磁极性。利用磁电变换,通过磁头的读线圈,可将磁性存储元的剩磁极性转换为相应的电信号,表示成二进制数。

(3) 磁道和扇区

磁盘旋转,磁头固定,每个磁头都会在盘片表面画出一个圆形轨迹。改变磁头的位置,可以形成若干大小不等的同心圆,这些同心圆就叫磁道(Track)。每张盘片的每个表面都有成千上万个磁道。一个磁道按照512Bytes为单位分成若干个区域,其中的每个区域就叫做一个扇区(Sector)。扇区是文件存储的基本单位

(4) 柱面、柱面组、分区和磁盘驱动器

硬盘中不同盘片相同半径的磁道所组成的圆柱称为柱面(Cylinder)。整个硬盘的柱面数与每张盘片的磁道数是相等的。硬盘上的每个字节需要通过以下参数定位:磁头号用来确定哪个盘面,柱面号确定哪个磁道,扇区号确定哪个区域,偏移量确定扇区内的位置。磁头号、柱面号、扇区号和偏移量统称柱面I/O。若干个连续的柱面构成了一个柱面组,若干连续的柱面组构成了一个分区。每个分区都建有独立的文件系统,若干个分区组成磁盘驱动器。

2、文件系统的逻辑结构

磁盘驱动器:| 分区 | 分区 | 分区 |

分区 引导块 | 超级块 | 柱面组 | 柱面组 | 柱面组 | 柱面组 |

柱面组 引导块副本 | 柱面组信息 | i节点映射表 | 块位图 | i节点表 | 数据块集 |

i节点:| 文件元数据 | 数据块的索引表 |

根据目录文件中记录的i节点编号来检索i节点映射表,获得i节点下标,用该下标查i节点表,获得i节点。i节点中包含了数据块索引表,利用数据块索引表从数据块集中读取数据块,即获得文件数据。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ywN2FF8y-1578914622441)(./插图/day05/文件系统结构.png)]

直接块:存储文件的实际数据内容

间接块:存储下级文件数据的索引表[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8ktjmrnG-1578914622441)(./插图/day05/直接块_间接块.png)]

3、文件分类

普通文件(-):可执行程序、文本、图片、音频、视频、网页等

目录文件(d):该目录中每个硬链接名和i节点号的对应表

符号链接文件(l):存放一个目标文件的路径

管道文件§:有名管道,用于进程间通信

套接字文件(s):进程间通信

块设备文件(b):按块寻址,顺序或随机读写

字符设备文件©:按字节寻址,只能以字节为单位顺序读写

4、文件的打开与关闭

打开文件:在系统内核中建立一套数据结构,用于访问文件。

进程表项

文件描述符表

文件描述符标志 | 文件表项指针 | 0
文件描述符标志 | 文件表项指针 | 1
文件描述符标志 | 文件表项指针 | 2

这个结构中文件标志对应的数组的下标就是文件描述符。文件表项指针指向文件表项,V节点指针指向V节点

文件表项

文件状态标志
文件读写位置
V节点指针

V节点

i节点内容

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3k8qah4o-1578914622441)(./插图/day03/进程表项.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UbkONcc0-1578914622441)(./插图/day03/文件的内核数据结构.png)]

关闭文件:释放打开文件过程中建立的数据结构

(1) 打开已有的文件或创建新文件

int open(const char *pathname, int flags, mode_t mode);

成功返回文件描述符,失败返回-1

  • pathname——文件路径

  • flags——状态标志,可取以下值:

    O_RDONLY——只读

    O_WRONLY——只写

    O_RDWR——读写

    O_APPEND——追加

    O_CREAT——创建,不存在即创建,如果已存在立即打开,除非与以下两个标志之一合用,有此标志,mode参数才有效

    O_EXCL——排它,已存在会失败,必须和O_CREAT合用

    O_TRUNC——清空,已存在即清空,必须同时写权限,必须和O_CREAT合用

    O_SYNC——写同步,在数据被写到磁盘之前写操作不会完成,读操作本来就是同步的,此标志对读操作没有意义

    O_ASYNC——异步,在文件可读写时会产生一个SIGIO信号,在对该信号的处理过程中读写I/O就绪的文件,只能用于终端设备或者网络套接字,而不能用于磁盘文件

    O_NONBLOCK——非阻塞,读操作不会因为无数据可读而阻塞,写操作也不会因为缓冲区满而阻塞,相反,会返回失败,并设置特定的errno

  • mode——权限模式,三位八进制数

    所创建文件的实际权限除了跟mode参数有关,还受权限掩码umask的影响,实际权限 = mode & ~umask。比如,mode = 0666,umask = 0002,实际权限 = 0664.

创建新文件
*int creat(const char pathname, mode_t mode)

相当于把openflags设为:O_WRONLY | O_CREAT | O_TRUNC

打开已有的文件
*int open(const char pathname, int flags)

关闭文件
**int close(int fd) **
成功返回0,失败返回-1. fd表示文件描述符。

参考代码:open.c redir.c

作为文件描述符表项在文件描述符表中的下标,合法的文件描述符一定是大于或等于0的整数。每次产生新的文件描述符表项。系统总是会从下标0开始在文件描述符表中寻找最小的未使用项。每关闭一个文件描述符,无论被其索引的文件表项和V节点是否被删除,与之对应的文件描述符表项一定会被标记为未使用,并在后续操作中为新的文件描述符所占用。系统内核缺省会为每个进程打开三个文件描述符:0、 1、 2,分别对应STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO这三个宏,标准错误和标准输出的区别是标准输出有缓冲,标准错误无缓冲。文件描述符是用户程序和系统内核关于文件的唯一联系方式。

标C Uc C++
标准输入 stdin 0/STDIN_FILENO cin
标准输出 stdout 1/STDOUT_FILENO cout
标准错误 stderr 2/STDERR_FILENO cerr
数据类型 FILE * int iostream

5、文件的读取和写入

(1) 向指定的文件写入字节流

ssize_t write(int fd, const void *buf, size_t count)
成功返回实际写入的字节数,返回0表示未写入,失败返回-1

  • fd——文件描述符
  • buf——内存缓冲区
  • count——期望写入的字节数

(2) 向指定的文件写入字节流

ssize_t read(int fd, void *buf, size_t count)
成功返回实际读取的字节数(0表示读到文件尾),失败返回-1

  • fd——文件描述符
  • buf——内存缓冲区
  • count——期望读取的字节数

参考代码:write.c read.c

基于系统调用的文件读写本来就是面向二进制字节流的,因此对二进制读写而言,无需做任何额外的工作。如果要求文件中的内容必须是可阅读的,那么就必须通过格式化和文件解析处理二进制形式的数据和文本字符串之间的转换。

参考代码:binary.c text.c

6、顺序与随机读写

每个打开的文件都有一个与其相关的文件读写位置保存在文件表项中,用以记录从文件头开始计算的字节偏移。文件读写位置通常是一个非负整数,用off_t类型表示,在32位系统上被定义为long int,而在64位系统上则被定义为long long int。打开一个文件时,除非指定了O_APPEND标志,否则文件读写位置一律被设为0,即文件首字节的位置。每一次读写操作,都从当前的文件读写位置开始,并根据所读写的字节数同步增加文件读写位置,为下一次读写做好准备。因为文件读写位置是保存在文件表项而不是V节点中,因此通过多次打开同一个文件得到多个文件描述符,各自会拥有各自的文件读写位置。

(1) 调整文件读写位置

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

成功返回调整后的文件读写位置,失败返回-1

  • fd——文件描述符

  • offset——文件读写位置,相对于whence参数的偏移量

  • whence——可取以下值

    SEEK_SET——从文件头开始

    SEEK_CUR——从当前位置开始

    SEEK_END——从文件尾开始

lseek函数仅仅是修改文件表项中的文件读写位置,并不引发实际的I/O操作,速度很快。

lseek(fd,10,SEEK_SET);
lseek(fd,-10,SEEK_END);
lseek(fd,0,SEEK_CUR);//返回当前读写位置
lseek(fd,0,SEEK_END);//返回文件总字节数
lseek(fd,-10,SEEK_SET);//错误
lseek(fd,10,SEEK_END);//允许,但是会产生文件空洞,空洞部分补0
//文件空洞不占用磁盘空间,但被算在文件大小内

参考代码:seek.c

7、系统I/O和标准I/O

I/O速度比较:stdio.c sysio.c

标准和通过缓冲区优化,可以减少系统调用的次数,降低在用户态和内核态之间来回切换的频率,提高运行速度,缩短运行时间。

8、复制文件描述符(表项)

int dup(int oldfd)
成功返回目标文件描述符,失败返回-1

  • oldfd——源文件描述符

dup函数将oldfd对应的文件描述符表项复制到文件描述符表的第一个空闲项中,同时返回该表项所对应的文件描述符。

int dup2(int oldfd, int newfd)
成功返回目标文件描述符,失败返回-1

  • oldfd——源文件描述符
  • newfd——目标文件描述符

dup2函数在复制oldfd参数所标识的文件描述符表项时,会首先检查有newfd参数所标识的目标文件描述符表项是否空闲,若空闲则直接将前者复制给后者,否则会先将文件描述符newfd关闭,再进行复制。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zHZd9fpQ-1578914622442)(./插图/day04/open和dup区别.png)]

参考代码:dup.c  same.c

9、文件控制

int fcntl(int fd, int cmd, … );

(1) 复制文件描述符(表项)

int fcntl(int oldfd, F_DUPFD, int newfd )

成功返回目标文件描述符,失败返回-1

  • oldfd——源文件描述符
  • newfd——目标文件描述符

类似dup2函数,但略有不同。如果newfd处于打开状态,该函数并不会像dup2函数那样关闭它,而是另外寻找一个比它大的最小的空闲文件描述符作为复制目标。

参考代码:fcntl.c

(2) 获取/设置文件描述符标志

截至目前只有1个文件描述符标志位:FD_CLOEXEC。一个进程可以通过exec函数族启动另一个进程取代其自身。原进程中无FD_CLOEXEC标志位的文件描述符在新进程中依然保持打开状态,这也是文件描述符的默认标志;如果原进程中某个文件描述符带有此标志位,那么在新进程中该文件描述符会被关闭。

int fcntl(int fd, F_GETFD);——获取文件描述符标志

成功返回文件描述符标志,失败返回-1

int fcntl(int fd, F_SETFD, int flags);——设置文件描述符标志

成功返回0,失败返回-1

参考代码:fd.c

(3) 获取/追加文件状态标志

int fcntl(int fd, F_GETFL);——获取文件状态标志

成功返回文件状态标志,失败返回-1. 与文件创建有关的三个状态标志(O_CREAT、O_EXEC、O_TRUNC)无法被获取。

只读标志的值为0,不能用位与检测,其他标志可以用位与检测。

if((flags & O_ACCMODE) == O_RDONLY){
    //只读文件
}
if(flags & O_WRONLY){
	//只写文件
}
if(flags & O_RDWR){
    //可读可写文件
}

int fcntl(int fd, F_SETFL, flags);——追加文件状态标志

成功返回0,失败返回-1

只有O_APPENDO_NONBLOCK两个状态标志可被追加

参考代码:fl.c

10、文件锁

文件的某个区域正在被访问|

读取 写入
无人访问 OK OK
多人在读 OK NO
一人在写 NO NO

为了避免在读写同一文件的同一个区域时发生冲突,进程之间应遵循以下规则(读共享,写独占):

  • 如果一个进程正在写,那么其它进程既不能写也不能读
  • 如果一个进程正在读,那么其它进程不能写但是可以读

参考代码:write.c read.c

为了避免多个进程在读写同一个文件的同一区域时发生冲突,操作系统引入了文件锁机制,并把文件锁分为读锁写锁。它们的区别在于:

  • 读锁——共享锁,对一个文件的特定区域可以同时加多个读锁
  • 写锁——排它锁(互斥锁),对一个文件的特定区域只能加一把写锁

锁模式:加锁—>读写—>解锁

读锁 写锁
无任何锁 OK OK
多把读锁 OK NO
一把写锁 NO NO

int fcntl(int fd, F_SETLKW, struct flock *lock);——阻塞

int fcntl(int fd, F_SETLK, struct flock *lock);——非阻塞

struct flock结构体内容:

struct flock{
    short int l_type; //锁类型,F_RDLCK(加读锁)/F_WRLCK(加写锁)/UNLCK(解锁——不会阻塞)
    short int l_whence;//锁区偏移起点,           	SEEK_SET/SEEK_CUR/SEEK_END
    off_t l_start;//锁区偏移
    off_t l_len;//锁区长度(字节数),0表示到文件尾
    pid_t l_pid;//加锁进程的PID,-1表示自动设置
};
//对相对于文件头10字节开始锁20字节长度加读锁,阻塞方式
struct flock lock;
lock.l_type = F_RDLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 10;
lock.l_len = 20;
lock.l_pid = -1;
fcntl(fd,F_SETLCKW,&lock);//停-等机制
//对相对于当前位置10字节开始到文件尾以非阻塞方式加写锁
struct flock lock;
lock.l_type = F_WRLCK;
lock.l_whence = SEEK_CUR;
lock.l_start = 10;
lock.l_len = 0;
lock.l_pid = -1;
fcntl(fd,F_SETLCK,&lock);
//对整个文件解锁,解锁不会阻塞
struct flock lock;
lock.l_type = F_UNLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
lock.l_pid = -1;
fcntl(fd,F_SETLCK,&lock);

参考代码:wlock.c rlcok.c

int fcntl(int fd, F_GETLK, struct flock *lock);——测试对文件的某个区域是否可以加某种锁;如果不能加锁,是什么原因导致加锁冲突

成功返回0,失败返回-1.

调用该函数时,lock参数表示欲加之锁的细节。成功返回时,通过lock参数输出欲加之锁是否可加以及存在冲突的锁信息。

参考代码:lock1.c lock2.c

V节点指针

i节点内容

锁表指针–>锁节点–>锁节点–>…

​ 锁的类型

​ 锁区偏移

锁区大小

​ 加锁进程PID

每次对给定文件的特定区域加锁,都会通过fcntl函数向系统内核传递flock结构,该结构中包含了有关锁的一些细节,诸如锁的类型、锁区的起始位置和大小,甚至加锁进程的pid(填-1则由系统自动设置)。系统内核会收集所有进程对该文件所加的各种锁,并把这些flock结构中的信息以链表的形式组织成一张锁表,其起始地址保存在该文件的v节点中。任何一个进程通过fcntl函数对该文件加锁,系统内核都要遍历这张锁表,一旦发现有与欲加之锁构成冲突的锁,即阻塞或报错,否则,将欲加之锁插入到锁表;而解锁的过程实际上就是调整或者锁表中的相应节点。

文件锁属于劝谏锁,亦称协议锁。

11、文件元数据

i节点

文件元数据

数据块索引表

…………

文件元数据保存在下面的结构体中:

struct stat {
   dev_t     st_dev;  /* ID of device containing file */
   ino_t     st_ino;  /* inode number */
   mode_t    st_mode; /* file type and mode */
   nlink_t   st_nlink;/* number of hard links */
   uid_t     st_uid;  /* user ID of owner */
   gid_t     st_gid;  /* group ID of owner */
   dev_t     st_rdev; /* device ID (if special file) */
   off_t     st_size;  /* total size, in bytes */
   blksize_t st_blksize;/* blocksize for filesystem I/O */
   blkcnt_t  st_blocks;/* number of 512B blocks allocated */
   time_t 	 st_atim; /* time of last access */
   time_t    st_mtim; /* time of last modification */
   time_t    st_ctim; /* time of last status change */
   #define st_atime st_atim.tv_sec /*Backward ompatibility*/
   #define st_mtime st_mtim.tv_sec
   #define st_ctime st_ctim.tv_sec
};

文件类型和权限的数据类型mode_t其实就是一个整数,其中只有低16位有效。

B15~B12——文件类型,掩码:S_IFMT

  • 1000——S_IFREG——普通文件(-)

  • 0100——S_IFDIR——目录文件(d)

  • 1100——S_IFSOCK——本地套接字文件(s)

  • 0010——S_IFCHR——字符设备文件©

  • 0110——S_IFBLK——块设备文件(b)

  • 1010——S_IFLNK——符号链接文件(l)

  • 0001——S_IFFIFO——有名管道文件§

B11~B9——设置用户ID位、设置组ID位和粘滞位

  • 带有设置用户ID位(即B11位为1)的可执行文件(如passwd):系统中的每个进程其实都有两个用户ID,一个叫实际用户ID,取决于运行该进程的登录用户;另一个叫有效用户ID,一般情况下进程的有效用户ID就取自实际用户ID。但是,如果产生该进程的可执行文件带有设置用户ID位,那么该进程它的有效用户ID就不再取自实际用户ID,而是取自该可执行文件的拥有者用户ID。进程对系统资源的权限判定是根据其有效用户ID做出的,因此通过这种方法就可以提升普通用户执行进程的权限,完成本来只有高权限用户才能完成的任务,即有限权限提升(有限提权)
  • 带有设置组ID位(即B10位为1)的可执行文件:设置组ID位(B10)的情况与上述类似,只是针对进程的有效组ID而已
  • 带有设置用户ID位的不可执行文件,毫无意义
  • 带有设置组ID位的不可执行文件:某些系统用这种毫无意义的状态作为强制锁的标志
  • 带有粘滞位(B9位为1)的目录:除root以外的任何用户,在该目录下都只能删除或者更名那些属于自己的文件或者子目录,而对于其它用户的文件或子目录,既不能删除也不能更名,如:/tmp

B8~B6——拥有者用户,分别对应读、写、可执行

B5~B3——拥有者组,分别对应读、写、可执行

B2~B0——其它用户,分别对应读、写、可执行

设置用户ID位——带有该位,用户无可执行权限,可执行权限位为S;有可执行权限,可执行权限位为s

~$ ls -l /usr/bin/passwd 
-rwsr-xr-x 1 root root 59680 11月 27 06:27 /usr/bin/passwd

设置组ID位——带有该位,用户组无可执行权限,可执行权限位为S;用户组有可执行权限,可执行权限位为s
粘滞位——带有该位的目录文件,其他用户无可执行权限,可执行权限位为T;有可执行权限,可执行权限位为t

~$ ls -l / |grep tmp
drwxrwxrwt  11 root  root   4096 12月 28 09:21 tmp

获取文件元数据

int stat(const char *pathname, struct stat *buf);

int fstat(int fd, struct stst*buf);

int lstat(char const *path, struct stat *buf);//不跟踪符号链接,if pathname is a symbolic link, then it returns information about the link itself, not the file that it refers to.
成功返回0,失败返回-1

  • pathname——文件路径
  • buf——文件元数据结构
  • fd——文件描述符

参考代码:stat.c

12、访问测试

int access(const char *pathname, int mode);

成功返回0,失败返回-1

  • pathname——文件路径

  • mode——访问权限,可取以下值:

    R_OK——可读否

    W_OK——可写否

    X_OK——可执行否

    F_OK——存在否

根据调用该函数的进程的实际用户ID实际组ID,检测其是否可读、可写或可执行,也可以检测该文件是否存在。

参考代码:access.c

13、权限掩码

mode_t umask(mode_t cmask);

永远成功,返回原来的权限掩码

  • cmask——新的权限掩码

权限掩码是进程的属性之一,存储在系统内核中的进程表项中。umask函数所影响的仅仅是调用进程自己,对于其它进程包括其父进程都没有影响。

参考代码:umask.c

14、修改权限

#include

int chmod(const char* pathname, mode_t mode);

int fchmod(int fd, mode_t mode);

成功返回0,失败返回-1

  • pathname——文件路径
  • mode——文件权限
  • fd——文件描述符

调用进程的有效用户ID必须与文件拥有者用户ID匹配或者是root用户,才能修改该文件的权限,且受权限掩码的影响

参考代码:chmod.c

15、修改文件拥有者(或)拥有者组

#include

int chown(const char* path, uid_t owner, gid_t group);

int fchown(int fd, uid_t owner, gid_t group);

int lchown(const char* path, uid_t owner, gid_t group);//不跟踪符号链接

成功返回0,失败返回-1

  • path——文件路径
  • owner——拥有者ID,-1表示不修改
  • group——拥有者组ID,-1表示不修改
  • fd——文件描述符

如果调用进程的有效用户ID为root用户,则它可以任意修改任何文件的拥有者和组;如果调用进程的有效用户ID为普通用户,则它只能把自己名下的文件的拥有者组改成自己隶属的其它组。

参考代码:chown.c

16、修改文件大小、映射文件

#include

int truncate(const char* path, off_t length);

int ftruncate(int fd, off_t length);

成功返回0,失败返回-1

  • path——文件路径
  • length——文件大小
  • fd——文件描述符

由大变小:截掉文件尾的部分

由小变大:在文件尾之后增加0

参考代码:trunc.c

mmap映射磁盘文件
#include

void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset);

  • flags——MAP_SHARED
  • fd——文件描述符
  • offset——文件内偏移量,自动按页对其

参考代码:fmap1.c fmap2.c

17、硬链接

硬链接就是文件路径,即由各级目录、分隔符(/)和文件名共同组成的字符串,与一个特定的i节点号所形成的对应关系。

ln <目标路径(已存在的路径)> <源路径(新建立的路径);——创建硬链接命令

根据一个已有的硬链接来创建一个新的硬链接

**int link(const char * oldpath, const char* newpath ); **

成功返回0,失败返回-1

  • oldpath——已有的硬链接路径
  • newpath——新的硬链接路径
  • oldpath 必须存在,newpath中不能包含不存在的目录

删除硬链接

int unlink(const char pathname);*

  • pathname——文件路径,不能是目录

从pathname所对应的目录文件中删除包含文件的条目,同时将其所对应的i节点中的硬链接数减一。若该硬链接数被减至0,则将该文件所占用的磁盘空间释放出来。

修改硬链接

int rename(const char* oldpath, const char* newpath)
成功返回0,失败返回-1

  • oldpath——原来的路径
  • newpath——新的路径
rename("./a.txt", "./b.txt");//改名
rename("a/1.txt", "b/1.txt");//移动
rename("a/1.txt", "b/2.txt");//移动+改名

另一个版本的unlink,还可以删除空目录

int remove(const char* pathname);

参考代码:link.c unlink.c remove.c rename.c

18、软连接

软链接文件的本质就是保存着另一个文件或者路径的文件

根据一个已有的硬链接创建一个符号链接

int symlink(const char* oldpath, const char* newpath);

成功返回0,失败返回-1

  • oldpath——原有路径,可以是文件,也可以是目录,甚至可以不存在
  • newpath——新建路径,不能包含不存在的目录

读取软链接文件本身的内容

ssize_t readlink(const char* path, char* buf, size_t size);

成功返回拷入buf的符号链接文件内容的字节数,失败返回-1

  • path——软链接文件路径
  • buf——缓冲区
  • size——缓冲区大小

参考代码:slink.c

19、目录

创建空目录

int mkdir(const char* pathname, mode_t mode);

成功返回0,失败返回-1

  • pathname——目录路径
  • mode——访问权限,目录的可执行文件表示可以进入(cd)

删除一个空目录

int rmdir(const char* pathname);

成功返回0,失败返回-1

  • pathname——目录路径

remove = unlink + rmdir

参考代码:dir.c

获取当前工作目录

char* getcwd(char* buf, size_t size);

成功返回工作目录字符串指针,即buf,会自动追加结尾空字符;失败返回NULL

  • buf——缓冲区
  • size——缓冲区大小

当前工作目录作为进程的属性之一,也是系统内核进程表项的一部分。

改变当前工作目录

int chdir(const char* path);
成功返回0,失败返回-1

参考代码:dir.c

打开目录

DIR* opendir(const char* name);

成功返回目录流指针,失败返回NULL

读取目录

struct dirent* readdir(DIR* dirp);

成功返回目录条目指针,读完(不设置errno)或者失败(设置errno)都会返回NULL

struct dirent 结构体内容如下:

struct dirent{
    ino_t 			d_ino;	//节点号
    off_t 			d_off;	//下一条位置(索引)
    unsigned short 	d_reclen;//记录长度
    unsigned char 	d_type;	//文件类型
    char 			d_name[];//文件名
    ……
};

关闭目录流

int closedir(DIR* dirp);

成功返回0,失败返回-1

  • dirp——目录流指针

十、进程

1、进程的基本概念

程序:磁盘上的可执行文件

进程:内存中的可执行指令和数据,CPU执行指令和访问数据

补充:进程控制块
操作系统通过进程控制块(PCB——Process Control Block)来管理进程

进程控制块内容:

  • 标识符:与进程相关的唯一标识符(PID——进程号
  • 状态:描述进程的状态,因为进程有挂起,阻塞,运行等好几个状态,所以都有个标识符来记录进程的执行状态
  • 优先级:如果有好几个进程正在执行,就涉及到进程被执行的先后顺序的问题,这和进程优先级这个标识符有关
  • 程序计数器:程序中即将被执行的下一条指令的地址
  • 内存指针:程序代码和进程相关数据的指针
  • 上下文数据:进程执行时处理器的寄存器中的数据
  • I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表等
  • 记账信息:包括处理器的时间总和、记账号等等。

2、进程的分类

交互式进程:由shell启动,借助标准I/O与用户交互

批处理进程:在无需人工干预的条件下,自动运行一组批量任务

守护(精灵)进程:后台服务,多数时候处于待命状态,一旦由需要,可被激活完成特定的任务

3、进程快照

ps——显示当前用户拥有控制终端的进程信息

  • ps auxw——BSD风格选项
    a——所有用户

    x——既包括有控制终端也包括无控制终端的进程

    u——显示详细信息

    w——更大列宽

  • ps -efFl——SVR4

    e——所有用户的所有进程

    f——完整格式

    F——更完整格式

    l——长格式

进程快照每列信息:

USER/UID——进程的实际用户ID

PID——进程标识

%CPU/C——CPU使用率

%MEM——内存使用率

VSZ——占用虚拟内存的大小(KB)

RSS——占用物理内存(不包括换页文件)的大小(KB)

TTY——终端次设备号,ttyn表示物理终端(硬件设备),pts/n表示虚拟终端(软件窗口),?表示无控制终端如后台进程

STAT/S——进程状态

  • O——就绪态,等待被调度
  • R——运行态,Linux下没有O状态,就绪态也用R表示
  • S——可唤醒睡眠,系统中断、获得资源、收到信号都可被唤醒,唤醒之后转入运行状态
  • D——不可唤醒的睡眠,只能被wake_up系统调用唤醒
  • T——暂停状态,进程收到SIGSTOP(19)信号转入暂停状态,收到SIGCONT(18)信号转入运行状态
  • W——等待内存分页(2.6以后内核被废弃)
  • X——终止且被回收,不可见
  • Z——僵尸进程,已退出,但未被回收
  • <——高优先级
  • N——低优先级
  • L——有被锁定在半导体内存中的内存分页
  • s——会话首进程
  • l——多线程化
  • +——在前台进程组中
  • I——

START——进程的启动时间

TIME——进程的运行时间(占用CPU的时间)

COMMAND/CMD——进程的启动命令

F——进程标志,1代表通过fork产生的子进程,但是并没有exec创建新进程;4表示拥有超级用户(root)的特权

PPID——父进程的PID

NI——进程的nice值,-20~19,优先级的浮动量

PRI——进程的优先级=80+nice,60~99,值越小优先级越高。I/O消耗型进程,奖励,提高优先级,降低nice值;处理机消耗型进程,惩罚,降低优先级,提高nice值

ADDR——内核进程的内存地址,普通进程显示“-”

SZ——占用虚拟内存的页数

WCHAN——进程正在等待的内核函数或者事件

PSR——进程当前正在被哪个处理器执行

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MpMLiI4u-1578914622442)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day07/进程状态图.png)]

top命令:动态显示,实时刷新

pstree:显示进程树

4、父子进程、孤儿进程、僵尸进程

父进程创建子进程,子进程继承父进程。一个父进程可以创建多个子进程,每个子进程有且仅有一个父进程。除非是根进程(PID = 0,调度器实例)没有父进程。

进程树:

调度进程,pid = 0

init,pid = 1

xinetd

in.telnetd <-------远程登录

login <----用户名和口令

bash <-等待用户输入shel命令

例如:ls—>显示目录

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gyaaoZ8x-1578914622443)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day07/父子进程.png)]

创建进程的两种模式:

(1) 父进程在创建完子进程以后依然存在,甚至可以和子进程进程某种形式的交互,如传参、回收、通信等。

(2) 旧进程在创建完新进程以后被其取代,新进程沿用旧进程的PID,继续独立存在。

孤儿进程
父进程创建子进程以后,子进程在操作系统的调度下可以与其父进程同时运行。如果父进程先于子进程的终止而终止,子进程即成为孤儿进程,同时被init进程收养,即成为init进程的子进程,因此,init进程又被成为孤儿院进程。一个进程成为孤儿进程是正常的,系统中大多数的守护进程都是孤儿进程。

僵尸进程
如果子进程先于父进程的终止而终止,但父进程由于某种原因没有回收子进程的尸体(终止状态),子进程即成为僵尸进程。僵尸进程虽然不在活动,但其终止状态和PID会依然保留,也会占用系统资源,直到其被父进程或init进程回收为止。如果父进程直到其终止都没有回收其处于僵尸状态的子进程,init进程会立即回收这些僵尸进程。因此,一个进程不可能同时既是僵尸进程,又是孤儿进程。

5、进程的各种ID

系统内核会为每个进程维护一个进程表项,其中包括以下ID:
进程ID:系统为每个进程分配的唯一标识。内核在分配进程ID时会持续增加,直到无法再增加了,再从头寻找被释放的ID,即延迟重用
父进程ID:父进程的PID,在创建子进程的过程中被初始化到子进程的进程表项中
实际用户ID:启动该进程的用户ID
实际组ID:启动该进程的用户组ID
有效用户ID:通常情况下,取自进程的实际用户ID。如果该进程的可执行文件带有设置用户ID位,那么该进程的有效用户ID就取自其可执行文件的拥有者用户ID
有效组ID:通常情况下,取自进程的实际组ID,如果该进程的可执行文件带有设置组ID位,那么该进程的有效组ID就取自其可执行文件的拥有者组ID

一个进程的能力和权限,由其有效用户ID和有效组ID决定

(1) 获取各种ID
pid_t getpid(void);——返回调用进程的PID
pid_t getppid(void);——返回调用进程的ppid,即其父进程的PID
uid_t getuid(void);——返回调用进程的实际用户ID
uid_t getgid(void);——返回调用进程的实际用户组ID
uid_t geteuid(void);——返回调用进程的有效用户ID
uid_t getegid(void);——返回调用进程的有效用户组ID

参考代码:id.c

6、创建子进程

产生进程分之(fork):
pid_t fork(void);
成功分别在父子进程中返回子进程的pid和0,失败返回-1.

调用一次返回两次:在父进程中返回所创建子进程的pid,而在子进程中返回0.
函数的调用者往往可以根据该函数返回值的不同,分别为父子进程编写不同的处理分之。

pid_t pid = fork();
if(pid == -1){
    perror("fork");
    exit(EXIT_FAILURE);
}
if(pid == 0){
    //子进程的处理分之;
    exit(EXIT_SUCCESS);
}
//父进程的处理分之;
exit(EXIT_SUCCSS);

子进程是父进程的不完全副本,子进程的数据区、BSS区、堆栈区(I/O流缓冲区),甚至命令行参数和环境变量区,都从父进程拷贝,只有代码区与父进程共享。

fork函数成功返回后,父子进程就各自独立地运行,其被调度的先后顺序并不确定,某些实现可以保证子进程先被调度。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JYUA22nb-1578914622443)(./插图/day07/fork1.png)]

fork函数成功返回以后,系统内核为父进程维护的文件描述符表也被复制到子进程的进程表项中,文件表项并不复制。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9HOCQtvc-1578914622443)(./插图/day07/fork2.png)]

系统总线程数达到上限(cat /proc/sys/kernel/threads-max)或用户的总进程数(ulimit -u)达到上限,fork函数将返回失败。

一个进程如果希望创建自己的副本并执行同一份代码,或者是希望与另一个进程并发地运行,都可以使用fork函数。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h1uhAvbe-1578914622443)(./插图/day07/fork3.png)]

参考代码:fork.c mem.c os.c is.c ftab.c orphan.c zombie.c

7、创建轻量级子进程

pid_t vfork(void);
成功分别在父子进程中返回子进程的pid和0,失败返回-1.

vfork函数的功能基本相同,只有两点区别
**(1) vfork函数创建的子进程不复制父进程的物理内存,也不拥有自己独立的内存映射,而是与父进程共享全部地址空间
(2) **vfork函数在创建子进程的同时会挂起父进程,直到子进程终止,或者通过exec函数创建新进程,再恢复父进程的运行

使用了**写时复制(拷贝)优化技术(copy-on-write)**的fork结合exec的使用,其性能并不弱于典型的vfork+exec的用法。

终止vfork函数创建的子进程,不要在main函数中使用return语句,也不要在任何函数中exit函数,而要调用**_exit**函数,以避免对父进程造成不利影响。

参考代码:vfork.c

8、进程的终止

(1)正常终止

  • 从main函数中返回
  • 在任何地方调用exit、_exit、_Exit
  • 在主线程中调用pthread_exit函数

进程一旦终止,被终止进程在用户空间所持有的资源都会被自动释放,如代码区、数据区、堆栈区等,但是在内核空间中与该进程相关的资源,如进程表项、文件描述符等未必会得到释放。

main函数的返回值和exit、_exit、_Exit函数的参数一样,构成了进程的退出码,可以被终止进程的父进程通过wait或waitpid函数获得,其中只有低8位可被获取。

注册退出处理函数(遗言函数):
int atexit(void(*function)(void));
int on_exit(void(*function)(int, void*), void* arg);
main函数的返回值和exit、_exit、_Exit函数的参数会传递给int,void* arg会传递给void*

宏:
EXIT_SUCCESS—— 0
EXIT_FAILURE—— -1

exit函数的执行过程:

  • 调用实现通过atexit和on_exit函数注册的退出处理函数
  • 冲刷并关闭所有仍处于打开状态的标准I/O流
  • 删除所有通过tmpfile函数创建的临时文件
  • 调用_exit函数

_exit函数的执行过程:

  • 关闭所有仍处于打开状态的文件描述符
  • 将调用进程的子进程(无论死活)托付给孤儿院收养
  • 向调用进程的父进程发送**SIGCHLD(17)**信号
  • 令调用进程终止

_Exit和_exit函数的功能完全一致,唯一的区别是前者由标准库提供,声明于stdlib.h,而后者由系统调用提供,被声明于unistd.h

main函数中执行return语句,就相当于调用了exit函数

参考代码:exit.c

(2) 异常终止

  • 在主线程外部通过pthread_cancel将主线程取消
  • 通过信号杀死进程

ctrl+c—>SIGINT(2),终端终端符信号,进程收到该信号执行默认动作——(异常)终止。
ctrl+\–>SIGQUIT(3),终端退出符信号
SIGKILL(9)
SIGTERM(15)
SIGSEGV(11),内存段错误
SIGBUS(7),硬件错误
……

9、回收子进程

通过等待子进程结束实现某种进程间的同步;
获知子进程的退出码,根据子进不同的退出原因采取不同的对策;
避免过多的子进程僵尸拖垮系统。

#include
pid_t wait(int*status);
成功返回所回收子进程的pid,失败返回-1

  • status——输出子进程的终止状态,如果对子进程的退出状态不感兴趣,可以填NULL

父进程在创建若干子进程以后调用wait函数:

  • 若所有子进程都在运行,则阻塞,直到有子进程终止才返回
  • 若至少有一个子进程已经终止,则立即返回该子进程的pid,并通过status参数输出其终止状态
  • 若没有需要等待的活动进程,也没有需要回收的死亡子进程,wait函数则返回**-1**,同时设置errnoECHILD

分析进程终止状态:

  • WIFEXITED(status)——非0表示进程正常终止
    • WEXITSTATUS(status)——进程main函数的返回值或者传递给exit、_exit、_Exit函数的低8位
  • WIFSIGNALED(status)——非0表示进程被信号杀死
    • WTERMSIG(status)——杀死进程的信号编号

参考代码:wait1.c wait2.c

pid_t waipid(pid_t pid, int* status, int options);
成功返回所回收子进程的pid,失败返回-1

  • pid——进程标识,可取以下值:

    < -1——等待并回收由**-pid**所标识的进程组中的任意子进程

    -1——等待并回收任意的子进程,类似于wait函数

    0——等待并回收与调用进程同组的任意子进程

    > 0——等待并回收由pid所标识的特定的子进程

  • status——输出子进程的终止状态,不感兴趣可置NULL

  • options——选项,可取以下值:

    0——阻塞模式,等不来就死等,类似于wait函数

    WNOHANG——非阻塞模式,所等子进程仍在运行,则返回0

参考代码:waitpid1.c waitpid2.c

10、创建新进程

子进程:父子同在——并行(并发)
新进程:以新换旧——取代

为一个函数传递不定数量的字符串参数:
(1)void foo(const char * arg, …); //变长参数表
(2)void bar(const char *arg[ ]); //字符指针数组

exec函数族,包括6个函数,根据参数的形式和是否使用PATH环境变量进行区分。

#include
int execl(const char* path, const char* arg, …);
功能:用**arg, …**作为命令行参数,运行path所表示的可执行文件,创建新进程,并用新进程取代调用进程
返回值:成功不返回,失败返回-1

  • path——可执行文件的路径
  • arg和**…**——表示命令行参数
execl("/usr/bin/gcc","gcc","hello.c","-o", "hello",NULL);
//                  argv[0] argv[1] argv[2] argv[3]

int execlp(const char* file, const char* arg, …);
功能:通过file参数传入可执行文件的名字即可,无需带路径,该函数会遍历PATH环境变量中的所有路径,寻找可执行文件

execl("gcc","gcc","hello.c","-o", "hello",NULL);
//         argv[0] argv[1] argv[2] argv[3]

int execle(const char* path, const char* arg, …, char* const envp[ ]);

  • envp——环境变量,以NULL结尾的字符指针数组

int execv(const char* path, const char* const argv[ ];

char* const a[] = {"gcc","hello.c","-o", "hello",NULL};
//                argv[0] argv[1] argv[2] argv[3]
execl("/usr/bin/gcc", a);

int execvp(const char* file, const char* const argv[ ]);

int execvp(const char* path, const char* const argv[ ], char* const envp[ ]);

规律:

  • l——list,以变长参数表的形式传入命令行参数
  • p——path,使用PATH环境变量寻找可执行文件
  • e——environ,以字符指针数组的形式传入环境变量
  • v——vector,以字符指针数组的形式传入命令行参数

参考代码:argenv.c exec.c

forkvfork函数不同,exec函数并不是创建调用进程的子进程,而是创建一个新的进程取代调用进程自身。新进程会用自己的全部地址空间,覆盖调用进程的地址空间,但是进程的PID保持不变。调用exec函数不仅改变了调用进程的地址空间和进程映像,调用进程的一些属性也会发生变化

  • 任何处于阻塞状态的信号都会丢失
  • 被设置为捕获的信号会还原为默认操作
  • 有关线程属性的设置也会还原为缺省值
  • 有关进程的统计信息会复位
  • 与进程内存有关的任何数据都会丢失,包括内存映射文件
  • 标准库在用户空间维护的一切数据结构,如通过malloc函数族动态分配的堆内存、通过atexit/on_exit函数注册的退出处理函数等,都会丢失

但是有些属性会被新进程继承下来,如PID、PPID、实际用户ID和实际组ID、优先级以及文件描述符(除非该文件描述符带有FD_CLOEXEC标志位)等。

vfork+exec模式
调用exec函数固然可以创建出新的进程,但是新进程会取代原来的进程。如果既想创建新的进程,同时又希望原来的进程继续存在,则可以考虑使用vfork+exec模式,即在由vfork产生的子进程中调用exec函数,新进程取代了子进程,但父进程依然存在。

再次注意:vfork会挂起父进程,直到子进程结束或者子进程调用exec函数,父进程才恢复运行

参考代码:ve1.c ve2.c vfork.c

如果一个进程可以根据用户的输入创建不同的进程,并在所建进程结束以后继续重复这个过程,那么这个进程就是shell
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xLq0wTsr-1578914622444)(./插图/day08/shell.png)]

#include
int system(const char* command);
成功返回command命令行进程的终止状态,失败返回-1
如果调用vfork或waitpid函数出错,返回-1;如果调用exec函数出错,返回127;如果都成功,返回command进程的终止状态,由waitpid函数的status参数输出
如果command参数取NULL指针,该函数返回-1表示失败,返回其它非0值表示当前shell可用,返回0表示shell不可用

  • command——命令行字符串

参考代码:sys.c

system=vfork+exec+waitpid

十一、信号

1、基本概念

(1) 什么是信号?

信号是提供异步事件处理机制的软件中断。这些异步事件可能来自硬件设备,也可能来自系统内核,甚至可能来自用户程序。进程之间可以相互发送信号,这使得信号成为一种进程间通信(Inter-Process Communication, IPC)的基本手段。信号的异步特性不仅表现为它的产生是异步的,对它的处理同样也是异步的。程序的设计者不可能也不需要精确地预见什么时候触发什么信号,也同样无法预见该信号究竟在什么时候会被处理。一切尽在内核操控下异步地发生。

(2) 什么是信号处理?

每一个信号都有其生命周期:
产生——信号被生成,并被发送至系统内核
未决——信号被内核缓存,而后被递送至目标地址
递送——内核已将信号发送至目标进程

目标进程处理信号的方式:

  • 忽略——什么也不做
  • 捕获——暂停当前的执行过程,转而去调用事先写好的信号处理函数,待该函数完成并返回后,再继续之前被中断的过程
  • 默认操作——既不忽略该信号,也不用自己定义处理方式,而是按照系统默认的方式予以响应

(3) 信号的名称和编号

信号名称——字符串,形如SIGXXX的字符串或宏定义,提高可读性
信号编号——整数

通过kill -l命令查看当前系统所支持的全部信号名称和编号。
1~31, 31个不可靠信号,也叫非实时信号;34~64, 31个可靠信号,也叫实时信号:共62个信号,没有32\33信号。
SIGHUP(1),控制终端关闭,默认操作是终止进程
SIGINT(2),用户产生中断符(ctrl+c),默认操作终止进程
SIGQUIT(3),用户产生退出符(ctrl+\),默认操作终止+转储
SIGBUS(7),硬件或内存对其错误,默认操作是终止+转储
SIGKILL(9),不能被捕获和忽略,默认操作是终止
SIGSEGV(11),无效内存访问,默认操作是终止+转储
SIGPIPE(13),向读端已关闭的管道写入,默认操作是终止
SIGALARM(14),alarm函数设置的闹钟到期,默认操作是终止
SIGTERM(15),可被捕获和忽略,默认操作是终止
SIGCHLD(17),子进程终止,默认操作是忽略
SIGIO(29),异步I/O事件,默认操作是终止

参考代码:loop.c

2、捕获信号

#include
typedef void(*sighandler_t)(int);//函数指针,指向一个接收整形参数并且无返回值的信号处理函数

设置针对特定信号的处理方式,即捕获特定的信号:
sighandler_t signal(int signum, sighandler_t handler);
成功返回原信号处理方式,失败返回SIG_ERR(sighandler_t类型的-1)

  • signum——信号编号

  • handler——信号处理函数指针,也可以取以下值:

    SIG_IGN——忽略信号

    SIG_DFL——默认操作

//定义信号处理函数
void sigint(int signum){
    SIGINT(2)信号的处理代码
}

//捕获SIGINT(2)信号
if(signal(SIGINT,sigint) == SIG_ERR){
    perror("signal");
    return -1;    
}
…………………………

​[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1rDUWubW-1578914622444)(./插图/day08/信号处理.png)] SIGINT(2)

SIGINT(2)/PID/sigint->系统内核->调用目标进程中的sigint函数

当一个信号正在被处理的过程中,相同的信号再次产生,该信号会被阻塞,直到前一个信号处理完成,即从信号处理函数中返回,后一个被阻塞的信号才会被递送,进而再次执行信号处理函数。当一个不可靠信号正在被处理的过程中,多个相同的信号再次产生,只有第一个信号会被阻塞,其它信号直接被丢弃。如果是可靠信号,都会被阻塞,并按照产生的顺序依次被递送。

信号处理函数及被其调用的函数都有可能发生重入,由此可能引发不可预知的风险。

所有标准I/O函数都是不可重入函数,在信号处理过程中要慎用

参考代码:signal.c

补充:什么是可重入函数?
所谓可重入是指一个可以被多个任务调用的过程,任务在调用时不必担心数据是否会出错。
一个可重入的函数简单来说就是可以被中断的函数,也就是说,可以在这个函数执行的任何时刻中断它,转入OS调度下去执行另外一段代码,而返回控制时不会出现什么错误;而不可重入的函数由于使用了一些系统资源,比如全局变量区,中断向量表等,所以它如果被中断的话,可能会出现问题,这类函数是不能运行在多任务环境下的。
也可以这样理解,重入即表示重复进入,首先它意味着这个函数可以被中断,其次意味着它除了使用自己栈上的变量以外不依赖于任何环境(包括 static),这样的函数就是purecode(纯代码)可重入,可以允许有该函数的多个副本在运行,由于它们使用的是分离的栈,所以不会互相干扰。如果确实需要访问全局变量(包括 static),一定要注意实施互斥手段。可重入函数在并行运行环境中非常重要,但是一般要为访问全局变量付出一些性能代价。 编写可重入函数时,若使用全局变量,则应通过关中断、信号量(即P、V操作)等手段对其加以保护。 说明:若对所使用的全局变量不加以保护,则此函数就不具有可重入性,即当多个进程调用此函数时,很有可能使有关全局变量变为不可知状态。

strtok函数详解:
char *strtok(char *str, const char *delim);

(1) 当strtok()在参数s的字符串中发现参数delim中包含的分割字符时,则会将该字符改为**\0** 字符。在第一次调用时,strtok()必需给予参数s字符串,往后的调用则将参数s设置成NULL,每次调用成功则返回指向被分割出片段的指针。
(2) 返回值
从s开头开始的一个个被分割的串。当s中的字符查找到末尾时,返回NULL。如果查找不到delim中的字符时,返回当前strtok的字符串的指针。所有delim中包含的字符都会被滤掉,并将被滤掉的地方设为一处分割的节点。
(3) 需要注意的是,使用该函数进行字符串分割时,会破坏被分解字符串的完整,调用前和调用后的s已经不一样了。第一次分割之后,原字符串str是分割完成之后的第一个字符串,剩余的字符串存储在一个静态变量中,因此多线程同时访问该静态变量时,则会出现错误。

3、信号捕获的流程

主控制流程----------------(中断)-------------->

信号处理函数 ------->

内核处理流程------------------------------------->

​ do_signal /system_call/

​ handle_signal/ sys_sigreturn

​ setup_frame /restore_sigcontext

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LHjlpflU-1578914622445)(./插图/day09/信号处理流程.png)]

信号的本质是一个中断的处理过程,而非多线程的并发过程。线程安全的函数未必是可重入函数。线程安全依靠锁机制,可重入依靠局部化。

4、信号捕获的一次性问题

在某些非Linux操作系统上,存在信号捕获的一次性问题:即使设置了对某个信号的捕获,只有设置后的第一个该信号被递送时,信号处理函数会被执行,以后再来相同的信号,均按默认方式处理。如果希望对信号的捕获具有持久性,可以在信号处理函数返回前,再次设置对该信号的捕获。

5、太平间信号

通过**SIGCHLD(17)**信号高效地回收子进程僵尸。
高效:及时性,适时性。

参考代码:sigchld.c

6、信号处理的继承和恢复

(1) fork和vfork函数创建的子进程会继承父进程的信号处理方式,直到子进程调用exec函数创建新进程替代自身为止。

(2) exec函数创建的新进程会将原进程中被设置为捕获的信号还原为默认处理,在原进程中被忽略的信号于新进程中继续被忽略。

参考代码:fork.c exec.c

7、发送信号

(1) 通过键盘向当前拥有控制终端的前台进程发送信号

ctrl + c-------SIGINT(2),默认终止进程
ctrl + \-------SIGQUIT(3),默认终止进程且转储
ctrl + z-------SIGSTOP(20),默认停止(挂起)进程

(2) 来自硬件或者内核的错误和异常引发的信号

SIGILL(4)-----------进程试图执行非法指令
SIGBUS(7)----------硬件或者总线对齐错误
SIGFPE(8)----------浮点异常
SIGSEGV(11)-------无效内存访问
SIGPIPE(13)--------向无读端的管道写入
SIGSTKFLT(16)-----浮点数协处理器栈错误
SIGXFSZ(25)--------文件资源超限
SIGPWR(30)--------断电
SIGSYS(31)---------无效系统调用

(3) 通过kill命令发送信号

kill [-信号] PIDs,不谢信号编号,缺省发送SIGTERM(15)信号。
超级用户可以给任何进程发信号,普通用户只能给自己的进程发信号。

(4) 调用函数发送信号

向特性的进程或进程组发送信号
int kill(pid_t pid, int signum);
成功(至少发出去一个信号)返回0,失败返回-1

  • pid——进程(组)标识,可取以下值:

    < -1——向-pid进程组中的所有进程发送信号

    =-1——向系统中的所有进程发送信号

    0——向调用进程同组的所有进程发送信号

    >0——向进程标识为pid的特定进程发送信号

  • signum——信号编号。取0时用于检查pid进程是否存在,如果不存在,kill函数会返回-1,且置errno为ESRCH

参考代码:kill.c

向调用进程自己发送信号
int raise(int signum);
成功返回0,失败返回-1

  • signum——信号编号

参考代码:raise.c

通过raise或kill向调用进程发送信号,如果该信号被捕获,则要等到信号处理函数返回后,这两个函数才会返回。

8、暂停、睡眠、闹钟

暂停:不受时间限制的睡眠
int pause(void);
成功阻塞,失败返回-1
该函数会让调用进程进入无时限的睡眠状态,即不参与内核调度,直到有信号终止了调用进程或被捕获。如果有信号被调用进程捕获,当信号处理函数返回以后,pause函数才会返回,且返回值为-1,同时设置errno为EINTR,表示阻塞的系统调用被信号中断。pause函数要么不返回,要么返回-1,不会返回0.

参考代码:pause.c

受时间限制的睡眠
unsigned int sleep(unsigned int seconds);
功能:该函数使调用进程睡眠seconds秒,除非有信号终止了进程或被其捕获。如果有信号被调用进程捕获,在信号函数返回以后,sleep函数才会返回,且返回值为剩余秒数,否则该函数返回0,表示睡眠充足
返回0或剩余秒数

  • seconds——以秒为单位的睡眠时限

参考代码:sleep.c

int usleep(useconds_t usec);
以微秒为单位的睡眠
睡够了返回0,睡不够返回-1,同时设置errno为EINTR

  • usec——以微秒为单位的睡眠时限

Intel CPU:时间精度50~55毫秒

unsigned int alarm(unsigned int seconds);
返回0或者先前闹钟的剩余时间,单次的

  • seconds——以秒为单位的闹钟时间

alarm函数使系统内核在该函数被调用以后seconds秒的时候,向调用进程发送SIGALRM(14)信号。若在调用该函数前已设过闹钟但尚未到期,则该函数会重设闹钟,并返回先前所设闹钟的剩余秒数,否则返回0。若seconds参数取0,则取消之前设置过且未到期的闹钟。

参考代码:alarm.c

通过alarm函数所设置的定时只是一次性的,即在定时到期时,发送一次SIGALRM(14)信号,此后不会再发送该信号。如果希望获得周期性的定时效果,可以在SIGALRM(14)信号的处理函数中继续调用alarm函数,完成下一个定时的设置

参考代码:clock.c

9、信号集

信号集类型的定义:

#include
typedef __sigset_t sigset_t

#include
typedef struct{
unsigned long int __val[_SIGSET_NWORDS];//1024bits
} __sigset_t;

#define __SIGSET_NWORDS (1024 / (8*sizeof(unsigned long int)));

填满信号集,即将信号集的全部信号位置1
int sigfillset(sigset_t* sigset);

清空信号集,即将信号集的全部信号位置0
int sigemptyset(sigset_t* sigset);

加入信号,即将信号集中的特定信号位置1
int sigaddset(sigset_t* sigset, int signum);

删除信号,即将信号集中的特定信号位置0
int sigdelset(sigset_t* sigset, int signum);

以上函数成功返回0,失败返回-1

检查信号,即判断信号集中的特定位是否为1
int sigismember(sigset_t* sigset, int signum);

有返回1,无返回0,失败返回-1

参考代码:sigset.c

10、信号屏蔽

(1) 递送、未决和掩码

当信号产生时,系统内核会在目标进程的进程表项中,以信号位置1的方式存储该信号,这个过程就叫递送。信号从产生到完成递送之间存在一定的时间间隔,处于该间隔状态的信号就属于未决信号。每个进程都有一个信号掩码(集),它实际上是一个信号集,位于该信号集中的信号一旦产生,并不会递送给相应的进程,而是被阻塞于未决状态。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b6WbEehq-1578914622445)(./插图/day09/信号产生未决和递送.png)]

当进程正在执行类似更新数据库、设置硬件状态等敏感任务时,可能不希望被某些信号中断。这时可以通过信号掩码暂时屏蔽而非忽略这些信号,使其一旦产生即被阻塞于未决状态,待特定任务完成以后,再恢复对这些信号的处理。

(2)设置信号掩码

int sigprocmask(int how, const sigset_t* sigset, sigset_t* oldset);

  • how——信号掩码的修改方式,可取以下值:

    SIG_BLOCK——将sigset中的信号加入当前掩码

    SIG_UNBLOCK——从当前掩码中删除sigset中的信号

    SIG_SETMASK——将sigset设置为当前掩码

  • sigset——信号集,取NULL则忽略此参数

  • oldset——原信号掩码,取NULL则忽略此参数

(3) 获取未决信号

int sigpending(sigset_t* sigset);

  • sigset——输出未决信号集

(4) 不可靠信号最多被信号掩码屏蔽一次,在屏蔽期间再有更多的相同信号一律被丢弃。可靠信号会全部被保留下来,且按照发送的顺序排成队列。

参考代码:sigmask.c

11、现代版本的信号处理与发送

经典版本的信号处理与发送:signal、kill(raise)
现代版本的信号处理与发送:sigaction、sigqueue

int sigaction(int signum, const struct sigaction* sigact, struct sigaction* oldact);
当signum信号被递送时,按sigact结构所描述的行为响应之。若oldact参数非NULL,则通过该参数输出原来的相应行为
成功返回0,失败返回-1

  • signum——信号编号
  • sigact——信号行为
  • oldact——输出原来的信号行为,可以置NULL

struct sigaction结构内容:

struct sigaction{
    void (*sa_handler)(int);//经典版本的信号处理函数指针
    void (*sa_sigaction)(int,siginfo_t*,void*);//现代版本的信号处理函数指针
    sigset_t sa_mask;	//信号处理期间的附加掩码集
    int sa_flags;  //信号处理标志
    void (*sa_restorer)(void); //预留项,目前置NULL即可
};

现代版本的信号处理函数:
void sigint(int signum, siginfo_t* si, void* reserved);

  • signum——信号编号
  • siginfo——信号信息
  • reserved——保留未用

struct siginfo结构体内容(共18个字段):

typedef struct siginfo{
    pid_t si_pid;//发送信号进程的PID
    sigval_t si_value;//信号附加数据
    …………;
}siginfo_t;

sigval_t的类型定义:

typedef union sigval{
    int sival_int;//用整形作为信号附加数据
    void* sival_ptr;//用任意类型的指针作为信号附加数据
}sigval_t;

(1) 增减信号掩码

缺省情况下,在信号处理函数的执行过程中,会自动屏蔽这个正在被处理的信号,而对其它信号则不会被屏蔽。通过sa_mask字段可以人为指定,在信号处理函数执行期间,除正在被处理的这个信号以外,还想屏蔽哪些信号,并在信号处理函数返回后,自动解除对它们的屏蔽。另一方面,还可以通过为sa_flags字段设置SA_NOMASK/SA_NODEFER标志位来告诉系统内核在信号处理函数执行期间,不要屏蔽这个正在被处理的信号。

(2) 选择信号处理函数的风格

如果sa_flags字段中没有SA_SIGINFO标志位,则sa_handler字段有效,即使用经典版本的信号处理函数。相反,如果sa_flags包含SA_SIGINFO标志位,则sa_sigaction字段有效,即使用现代版本的信号处理函数指针

(3) 一次性信号处理

如果sa_flags字段中包含SA_ONESHOT/SA_RESETHAND标志位,那么对所捕获的信号的处理就是一次性的,即在执行完一次信号处理函数后,就恢复为按默认方式处理

参考代码:sigact.c

(4) 系统调用中断重启

诸如pause/sleep/usleep等系统调用的阻塞过程会被信号打断,即在针对某个信号的处理函数返回后,这些系统调用也会从阻塞中返回。如果这类系统调用被所捕获的信号打断,即在针对该信号的处理函数返回后,能够自动重启阻塞过程而不要返回,可以为sa_flags字段添加SA_RESTART标志位

参考代码:restart.c

int sigqueue(pid_t pid, int signum, const union sigval value);
成功返回0,失败返回-1

  • pid——接收信号的进程的PID
  • signum——信号编号
  • value——信号附加数据—>siginfo::si_value

参考代码:sigque.c

利用信号附加数据实现简单的进程间通信,参考代码:send.c recv.c

12、定时器

执行时间 = 用户时间 + 内核时间 + 睡眠时间

执行时间——直观感受/墙钟时间——真实计时器
用户时间——消耗在用户态的时间——虚拟计时器
内核时间——消耗在内核态的时间
用户时间+内核时间——实用计时器
睡眠时间——消耗在等待I/O、睡眠等不被调度的时间[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZyPmjkUO-1578914622445)(./插图/day10/计时器.png)]

利用真实计时器时,到期信号——SIGALRM(14)
利用虚拟计时器时,到期信号——SIGVTALRM(26)
利用实用计时器时,到期信号——SIGPROF(27)

基于每每种计时器的定时都有两个参数

  • 初始间隔:从开始定时到发出第一个到期信号的时间间隔
  • 重复间隔:每相邻两个到期信号的时间间隔

设置、开始、关闭定时器
int setitimer(int which, const struct itimerval* new_value, strct itimer* old_value);
成功返回0,失败返回-1

  • which——指定设置哪个定时器可取以下值:

    ITIMER_REAL——基于真实计时器的定时

    ITIMER_VIRTUAL——基于虚拟计时器的定时

    ITIMER_PROF——基于实用计时器的定时

  • new_value——新设置值

  • old_value——输出原设置值,可置NULL

struct itimerval结构体内容

struct itimerval{
    struct timeval it_interval;//重复间隔,取0表示只发一个信号,没有周期性,不重复
    struct timeval it_value;//初始间隔,取0表示停止定时器,不再发送信号
};

struct timeval结构体内容

struct timeval{
    long tv_sec;	//秒
    long tv_usec;	//微秒
};

例如,5s以后开始发SIGALRM(14)信号,以后每隔3ms再发一次信号

struct itimerval it;
//设置初始间隔
it.it_value.tv_sec = 5;
it.it_value.tv_usec = 0;
//设置重复间隔
it.it_interval.tv_sec = 0;
it.it_interval.tv_usec = 3000;
//开启定时器
setitemer(ITIMER_REAL,&it,NULL);
………………;
//关闭定时器
it.it_value.tv_sec = 0;
setitemer(ITIMER_REAL,&it,NULL);

如果希望立即启动定时器,初始间隔至少是1微秒

参考代码:timer.c

获取定时器的设置
int getitimer(int which, struct itimerval* curr_value);
成功返回0,失败返回-1

  • which——指定设置哪个定时器可取以下值:

    ITIMER_REAL——基于真实计时器的定时

    ITIMER_VIRTUAL——基于虚拟计时器的定时

    ITIMER_PROF——基于实用计时器的定时

  • curr_value——当前设置的值

十二、进程间通信

1、什么是进程间通信

Unix/Linux系统中的每个进程都拥有独立的4GB大小的虚拟内存空间。其中高地址的1GB被映射到相同的物理内存区域,用于保存内核代码和数据;低地址的3GB作为保存用户代码和数据的用户空间,被映射到彼此不同的物理内存。因此,同一虚拟内存地址,在不同的进程中,会被映射到不同的物理内存区域,在多个进程之间以交换虚拟内存地址的方式交换数据是不可能的。鉴于进程之间的天然的内存壁垒,为了能够在不同的进程之间高效地交换数据,需要有一种专门的机制,这就是所谓的进程间通信(Inter-Process Communication, IPC)。

2、简单的进程间通信

(1) 命令行参数
进程1组织命令行参数—>execl—>进程2处理命令行参数

(2) 环境变量
进程1组织环境变量—>execl—>进程2处理环境变量

(3)wait/waitpid
进程1—>fork/vfork+exec—>进程2
wait/waitpid(…, &status); mian—>return /exit/_exit/_Exit

(4) 内存映射文件
进程1[虚拟内存]<----->文件区域<----->[虚拟内存]进程2

(5) 信号
进程1-----信号+附加数据----->进程2

3、传统的进程间通信

(1) 有名管道(全双工)
进程1<----->管道文件----->进程2
管道文件有i节点,没有数据块,是内存文件

(2) 无名管道(半双工)

4、XSI进程间通信(SVR4)

(1) 消息队列
进程1—消息3|消息2|消息1—进程2

(2) 共享内存(速度最快的一种进程间通信方式)
进程1[虚拟内存]<----->物理内存<----->[虚拟内存]进程2

(3) 信号量集
多个进程竞争有限的资源

5、套接字进程间通信(BSD)

进程1<----->本地套接字文件----->进程2
本地套接字文件:有i节点但没有数据块,是内存文件,类似与有名管道
以一种统一的编程模式和接口库,处理网络和本机通信

6、有名管道

#include
int mkfifo(const char* pathname, mode_t mode);
成功返回0,失败返回-1

  • pathname——文件路径
  • mode——权限模式

打开、关闭、读取和写入有名管道的方法与读写普通文件无异:open/close/read/write

参考代码:wfifo.c rfifo.c

有名管道逻辑模型[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KfQ7M5pi-1578914622445)(./插图/day10/有名管道逻辑模型.png)]

编程模型:

进程A 进程B
创建管道 mkfifo
打开管道 open 打开管道
读写管道 read/write 读写管道
关闭管道 close 关闭管道
删除管道 unlink

7、无名管道

#include
int pipe(int pipefd[2]);
成功返回0,失败返回-1

  • pipefd——输出两个文件描述符,**pipefd[0]**表示管道的读端,**pipefd[1]**表示管道的写端

编程模型
(1) 父进程调用pipe函数在系统内核中创建无名管道对象,同时得到与该对象相关联的两个文件描述符,一个用于读取,另一个用于写入[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v268SELd-1578914622446)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day10/无名管道1.png)](2) 父进程调用fork函数,创建子进程,子进程会复制父进程的文件描述符表,因此子进程也同样拥有可用于读写管道对象的两个文件描述符[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EkYPBRMk-1578914622446)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day10/无名管道2.png)]
(3) 负责写数据的进程关闭管道的读端,即pipefd[0];而负责读数据的进程关闭管道的写端,即pipefd[1][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-61wqZL3G-1578914622446)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day10/无名管道3.png)](4) 父子进程通过各自持有的文件描述符分别向管道写入和读取数据,待完成通信后再关闭各自持有的文件描述符,内核中的无名管道对象即被释放[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JFMc8ZlP-1578914622446)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day10/无名管道4.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Zs8cRHR6-1578914622447)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day10/无名管道5.png)]

参考代码:pipe.c

8、基于管道通信的特殊情况

(1) 从写端已被关闭的管道中读取:只要管道缓冲区还有数据,依然可被正常读取,一直读到缓冲区空,这是read函数返回0(既不是返回-1也不是阻塞),如同读到文件尾。

(2) 向读端已被关闭的管道中写入:会直接触发SIGPIPE(13)信号,该信号的默认操作是终止执行写入动作的进程。但如果执行写入动作的进程已经事先将SIGPIPE(13)信号设置为忽略或者捕获,这时虽然进程不会因为写入无读端的管道而被终止,但是write函数会返回-1,并置errno为EPIPE

(3) 在**/usr/include/linux/limits.h头文件中定义的PIPE_BUF**宏(4096)表示管道写缓冲区的大小。如果写管道时发现缓冲区的空闲空间不足以容纳此次write调用所要写入的字节数,则write函数会阻塞,直到缓冲区中的空闲空间变得足够大为止。如果同时有多个进程向同一个管道写入数据,而每次调用write函数写入的字节数都不大于BUF_SIZE,则这些write操作不会互相穿插(原子化,atomic),反之,单次写入的字节数超过了BUF_SIZE,则它们的write操作可能会发生相互穿插。

读取一个缓冲区为空的管道,只要其写端没有被关闭,读操作就会被阻塞,除非该读文件描述符被设置为非阻塞(O_NONBLOCK),此时则会立即返回失败,并置errno为EAGAIN

9、管道符号"|"的原理

命令1 | 命令2 | 命令3…
输出 > 管道 > 输入
输出 > 管道 > 输入

“|”将前一个命令的输出作为后一个命令的输入

shell进程管道符号实现原理

//shell进程:
int pipefd[2];
pipe(pipefd);//创建无名管道
vfork();//产生一个子进程
	//子进程1:
	close(pipefd[0]);//关闭无名管道的读端
	dup2(pipefd[1],STDOUT_FILENO);//写端=标准输出
	exec(A);//创建A进程,继承了原进程的文件描述符表。在A进程中,写端=			   //标注输出,printf/puts将数据写入无名管道的写端
vfork();//产生一个子进程2
	//子进程2
	close(pipefd[1]);//关闭无名管道的写端
	dup2(pipefd[0],STDIN_FILENO);//读端=标准输入
	exec(B);//创建B进程,继承了原进程的文件描述符表,在B进程中,读端=			   //标准输入,scanf/gets从无名管道的读端读取数据
//A和B就成为协作进程,A写入数据进管道,B从管道中读取A写入的数据

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UgzOh2Kd-1578914622447)(./插图/day11/管道符号.png)]

参考代码:output.c input.c shell.c

ps aux | grep bash | awk '{print $3}'
ps aux | grep bash | grep -v grep | awk ‘{print $4}’

10、XSI的IPC对象

(1) IPC对象的标识符(ID)和键(Key)

IPC对象在系统内核中的唯一名称用**键(Key,IPC对象的外部名)**表示。不同的进程可以通过键引用该IPC对象。一旦进程获得了该IPC对象,即通过其标识(ID)来称谓该对象。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ub485WkI-1578914622447)(./插图/day11/键.png)]

#include
key_t ftok(const char* pathname, int proj_id);
成功返回IPC对象的键,失败返回-1

  • pathname——一个真实存在的路径,使用该路径的i节点号
  • proj_id——项目ID,仅低8位有效,-127128或0255

相同项目使用相同的路径pathnameproj_id,保证key的一致性;不同项目使用不同的pathnameproj_id,避免key发生冲突

(2) IPC对象的编程接口

创建或获取IPC对象
int shmget(key_t key, size_t size, int shmflg);//共享内存
int msgget(key_t key, int msgflg);//消息队列
int semget(key_t, int nsems, int semflg);//信号量集

控制或销毁IPC对象
int shmctl(int shmid, int cmd, struct shmid_ds* buf);//共享内存
int msgctl(int msqid, int cmd, struct msqid_ds* buf);//消息队列
int semctl(int semid, int semnum, int cmd, union semun arg);//信号量集

  • cmd——控制命令,可取以下值:

    IPC_STAT——获取IPC对象的属性

    IPC_SET——设置IPC对象的属性

    IPC_RMID——删除IPC对象

IPC对象的权限结构

struct ipc_perm{
    key_t 			__key;	//键
    uid_t 			uid; 	//拥有者用户
    gid_t 			gid;	//拥有者组
    uid_t 			cuid;	//创建者用户
    gid_t 			cgid;	//创建者组
    unsigned short 	mode;	//权限
    unsigned short 	__seq;	//序号
};

其中,只有uidgidmode三个字段可以在创建完以后被修改

(3) 共享内存

共享内存:两个或者更多进程,共享一块由系统内核负责维护的物理内存,其地址空间通常位于被映射到每个进程虚拟内存堆和栈之间的不同区域。

共享内存属性结构:

struct shmid_ds {
	struct	ipc_perm shm_perm;	/*权限结构*/
	size_t		shm_segsz;   	/*共享内存的大小(字节数)*/
	time_t		shm_atime;   	/*最后加载时间*/
	time_t		shm_dtime;   	/*最后卸载时间*/
	time_t		shm_ctime;   	/*最后改变时间*/
    pid_t   	shm_cpid;		/*创建进程的pid*/
	pid_t   	shm_lpid;    	/*最后加载(卸载)进程的pid*/ 		
    shmatt_t	shm_nattch;  	/*当前加载计数*/
    ...
};

int shmget(key_t key, size_t size, int shmflg);

  • size——共享内存的字节数,按页向上取整。获取已有的共享内存对象时可以置0

  • shmflg——创建标志,可取以下值:

    0——获取,不存在即失败

    IPC_CREAT——创建兼获取,不存在即创建,已存在直接获取

    IPC_EXCL——不存在即创建,已存在直接报错

加载共享内存到虚拟内存,建立虚拟内存和物理内存间的映射
void *shmat(int shmid, const void *shmaddr, int shmflg);
成功返回共享内存的起始地址,失败返回void*类型的-1

  • shmid——共享内存标识

  • shmaddr——共享内存起始地址,置NULL由系统内核选择

  • shmflg——加载标志,可取以下值:

    0——可读可写

    SHM_RDONLY——只读

    SHM_RND——若shmaddr非空且不是页边界,将其自动向下圆整至页(4096)的整数倍

卸载共享内存
int shmdt(const void *shmaddr);
成功返回0,失败返回-1

shmat函数负责将给定共享内存映射到调用进程的虚拟内存空间,返回映射区域的起始地址,同时系统将内核中共享内存对象的加载计数(shm_nattch)。调用进程在获得shmat函数返回的共享内存起始地址以后,就可以像访问普通内存一样访问该共享内存中的数据。
shmdt函数负责从调用进程的虚拟内存中解除shmaddr所指向的映射区域到虚拟内存的映射,同时将系统内核中共享内存对象的加载计数(shm_nattch)减1。因此,加载计数为0的共享内存必定是没有任何进程使用的。
**shmctl(…, IPC_RMID, …)**调用可以用于销毁共享内存,但并非真的销毁,而只是做一个销毁标记,禁止任何进程对该共享内存形成新的加载,但已有的加载依然保留。只有当其使用者们纷纷卸载,直至其加载计数将为0时,共享内存才会真的被销毁。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qCVHTrdW-1578914622447)(./插图/day11/共享内存编程模型.png)]

参考代码:wshm.c rshm.c

通过共享内存实现进程间通信,可以直接访问由系统内核维护的公共内存区域,不需要额外构建用户缓冲区,也不需要在用户缓冲区和内核换成缓冲区之间来回复制数据,因此,共享内存是速度最快的进程间通信机制。但是共享内存因为缺乏必要的同步机制,往往需要借助其它进程间通信策略提供某种形式的停等机制。

(4) 消息队列

消息队列:由单个的类型各异的一系列消息结构组成的链表。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8EFN8z27-1578914622447)(./插图/day11/消息队列.png)]
消息:消息类型、数据长度、消息数据、消息指针

系统对于消息的限制:
最大可发送消息字节数:8192(8KB)
最大全队列消息字节数:16384(16KB)
最大全系统消息队列数:16
最大全系统消息总个数:262144

#include
int msgget(key_t key, int msgflg);

  • key——消息队列键值

  • msgflg——创建标志,可取以下值:

    0——获取,不存在即失败

    IPC_CREAT——创建兼获取,不存在即创建,已存在直接获取

    IPC_EXCL——不存在即创建,已存在直接报错

发送消息
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
成功返回0,失败返回-1

  • msgid——消息队列标识

  • msgp——>|消息类型(长度:sizeof(long)) | 消息数据 |

  • msgsz——消息数据的长度

  • msgflg——标志位,可取以下值:

    0——阻塞方式

    IPC_NOWAIT——非阻塞方式

msgsnd函数的msgp参数所指向的内存包含4个字节大小的消息类型,其值必须大于0,但该函数的msgsz参数所表示的期望发送的字节数却不包含消息类型所占的4字节。
如果系统内核中的消息未达上限,则msgsnd函数会将欲发送消息加入消息队列并立即返回0。否则,该函数会阻塞,直到系统内核允许加入新消息为止(比如:有消息被接收而离开消息队列)。若msgflg参数中包含IPC_NOWAIT位,则msgsnd函数在系统内核中的消息已达上限的情况下不会阻塞,而是返回-1,并置errnoEAGAIN

接收消息
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
成功返回实际接收到的消息数据的长度,失败返回-1

  • msgid——消息队列标识

  • msgp——>|消息类型(长度:sizeof(long)) | 消息数据缓冲区 |

  • msgsz——缓冲区长度

  • msgtyp——消息类型,可取以下值

    0——提取消息队列中的第一条消息而不论其类型

    >0——msgflg不含MSG_EXCEPT位,提取第一条类型为msgtyp的消息

    msgflgMSG_EXCEPT位,提取第一条类型不为msgtyp的消息

    <0——提取消息队列中类型小于或等于msgtyp绝对值的消息,类型越小的越先被提取

  • msgflg——标志位,可取以下值:

    0——阻塞方式

    IPC_NOWAIT——非阻塞方式

注意:msgrcv函数的msgp参数所指向的内存块中饱含4字节的消息类型,其值由该函数输出,但是该函数的msgsz参数所表示的期望接收字节数以及该函数所返回的实际接收字节数都不包含消息类型4个字节

若存在与msgtyp参数所匹配的消息,但是数据长度大于msgsz参数,且msgflg参数包含MSG_NOERROR位,则只截取该消息数据的前msgsz字节返回;但如果msgflg参数不包含MSG_NOERROR位,则不处理该消息,直接返回-1,并置errnoE2BIG

msgrcv函数根据msgtyp参数对消息队列中的消息有选择地接收,只有满足条件的消息才会被复制到应用程序缓冲区并从内核缓冲区删除。如果满足msgtyp条件的消息不只一条,则按照先进先出的规则提取。

若消息队列中有可接受消息,则msgrcv函数会将该消息移出消息队列,并立即返回所接收到的消息数据字节数,表示接收成功;否则此函数会阻塞,直到消息队列中有可接收消息为止。若msgflg参数包含IPC_NOWAIT位,则msgrcv函数在消息队列中没有可接收的消息的情况下不会阻塞,而是返回**-1**,并置errnoENOMSG

参考代码:wmsg.c rmsg.c

(5) 信号量集

资源的需求多余资源本身,如何协调有限资源在多数需求者之间的分配,以使每个资源需求者都有相对均衡的几率获得其所要求的资源。

系统内核中为每一种资源维护一个资源计数器 ,其值为当前空闲资源的数量,每当一个进程试图获取该种资源时,会先尝试减少其计数器的值。如果计数器的值够减(计数器>=0),则说明空闲资源足够,该进程即获得资源;如果该计数器的值不够减,则说明空闲资源不够,该进程即进入等待模式,等候其它拥有该种资源的进程释放资源。任何一个拥有该种资源的进程,一旦决定释放该资源,都必须将其计数器的值予以增加,以表示空闲资源量的增加,为其它等候该资源的进程提供条件。

一个信号量对应一种类型资源,其值表示该种类型资源的空闲数量。用由多个信号量组成的信号量集表示多种类型的资源。

创建或获取信号量集
#include
int semget(key_t key, int nsems, int semflg);
成功返回信号量集标识符,失败返回-1

  • key——信号量集的键

  • nsems——信号量的个数,即资源的种类数

  • semflg——创建标志,可取以下值:

    0——获取,不存在即失败

    IPC_CREAT——创建,不存在即创建,已存在即获取

    IPC_EXCL——排斥,已存在就失败

操作信号量集:减操作->拥有资源…释放资源->加操作
int semop(int semid, struct sembuf* sops, unsigned nops);
成功返回0,失败返回-1

  • semid——信号量集的标识
  • sops——操作结构数组
  • nops——操作结构数组长度

struct sembuf结构体内容:

struct sembuf{
    unsigned short 	sem_num; //信号量编号(集合索引)
    short		sem_op;	//操作数(-获取/+释放)
    short		sem_flg;//操作标志(0-阻塞/IPC_NOWAIT-非阻塞)
};

sops指针指向一个struct sembuf类型的结构体数组,其中每个元素都是一个struct sembuf类型的结构体,该结构体包含三个字段,用于表示针对信号量集中的一个特定信号量的特定操作。
如果sem_op字段的值为负,则从semid信号量集第sem_num个信号量的值中减去|sem_op|,以表示对资源的获取;如果不够减(信号量的值不能负),则此函数会阻塞,直到够减为止,以表示对资源的等待,但如果sem_flg字段包含IPC_NOWAIT位,则即使不够减也不会阻塞,而是返回-1,并值errno为EAGAIN

销毁或控制信号量集
int semctl(int semid, int semnum, int cmd, …);
成功返回0或者其它与cmd有关的值,失败返回-1

  1. 销毁信号量集
    int semctl(int semid, 0, IPC_RMID);0-成功/-1-失败
  2. 获取信号量集中每个信号量的值**
    unsigned short arry[4];
    int semctl(int semid, 0, GETALL, array); --> 0-成功/-1-失败**
  3. 设置信号量集中每个信号量的值**
    unsigned short arry[4] = {5, 5, 5 , 5};
    int semctl(int semid, 0, SETALL, array); --> 0-成功/-1-失败**
  4. 获取信号量集中特定信号量的值
    int semctl(int semid, int semnum, GETVAL);–>成功返回semid信号量集中第semnum信号量的值/失败-1
  5. 设置信号量集中特定信号量的值
    int semctl(int semid, int semnum, SETALL, int val); --> 0-成功/-1-失败

参考代码:csem.c gsem.c

(6) IPC命令

查看IPC对象
ipcs -m,共享内存
ipcs -q,消息队列对象
ipcs -s,信号量集对象
ipcs - a,全部ipc对象

删除IPC对象
ipcrm -m <共享内存对象标识>
ipcrm -q <消息队列对象标识>
ipcrm -s <信号量集对象标识>

11、总结

命令行参数和环境变量(给main函数传参):初始化设置
回收进程退出码(接收main函数的返回值或者exit函数的参数):获得终止信息
内存映射文件:通过内存的方式操作共享文件, 读写磁盘数据,速度慢但持久性好
信号:简单、异步,信息量有限,效率不高,可靠性不佳
有名管道或本地套接字:非近亲进程之间的中等规模数据通信
无名管道:近亲进程之间中等规模数据通信
共享内存:大数据量的快速数据通信,缺乏同步机制,需要依赖其它IPC机制实现同步
消息队列:天然的同步性,根据类型做细分,适用于中等规模数据通信
信号量集:多数进程竞争少数资源

十三、网络通信

1、网络和网络协议

(1) 什么是计算机网络?

计算机网络是指将地理位置不同的具有独立功能的多台计算机及其外部设备,通过有形或无形的通信线路连接起来,在网络操作系统网络管理软件网络通信协议的管理和协调下,实现资源共享和信息传递的计算机系统。

(2) 什么是网络协议?

网络协议是一种特殊的软件,是计算机网络实现其功能的最基本的机制。网络协议的本质就是规则,即各种硬件和软件必须遵守的共同守则。网络协议本身并不是一套单独的软件,它融合于所有涉及网络通信的软件甚至硬件之中,因此可以说网络协议在网络应用中无处不在。

(3) 什么是协议栈?

为了减少网络设计的复杂性,绝大多数网络采用分层设计的方法。所谓分层设计,就是按照信息流动的过程将网络的整体功能分解为一个个的功能层,不同机器上的同等功能层之间采用相同的协议,同一机器上相邻的功能层之间通过接口进行信息传递。各层协议和接口统称为协议栈

ISO(国际标准化组织)/OSI(Open System Interconnection, 开放系统互联)网络协议模型
应用层:业务逻辑
表示层:数据的表现形式
会话层:建立、管理和终止通信过程
传输层:源到目的地的点对点传输
网络层:路径选择、路由、寻址等与网络结构拓扑
数据链路层:物理寻址、数据通道、错误检测等通信路径
物理层:在数据和电平信号之间进行转换
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uJdmHzKW-1578914622448)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day12/网络与网络协议/网络层次.png)]

(4) TCP/IP协议栈

传输层:TCP、UDP
网络层:IP、ICMP、IGMP
数据链路层:ARP、RARP

(5) 消息包和数据流

应用层:HTTP请求=用户数据包
传输层:TCP头+用户数据包=TCP包
网络层:IP头+TCP包=IP包
数据链路层:以太网头+IP包+以太网尾=以太网帧
物理层:以太网帧—>电平信号

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zztnt4XS-1578914622448)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day12/网络与网络协议/以太网数据帧的分用过程.png)]

发送数据流:数据自协议栈顶层向底层流动,逐层打包
接收数据流:数据自协议栈底层向顶层流动,逐层解析

(6) IP地址

IP地址(Internet Protocol Address,互联网协议地址)是一种地址格式,为互联网上的每个网络和主机分配一个逻辑地址,其目的是消除物理地址的差异性。

IP地址在计算机内部用一个网络字节序的32位(4字节)无符号整数表示。通常习惯将其表示为点分十进制整数字符串形式。
例如,点分十进制整数字符串:1.2.3.4
32位(4字节)无符号整数:0x01020304
内存布局:低地址-0x01|0x02|0x03|0x04-高地址
网络字节序就是大端字节序,高位在低地址,低位在高地址

一台计算机的IP地址包括网络地址+主机地址
A类地址:以0为首的8位网络地址 + 24位主机地址
B类地址:以10为首16位网络地址 + 16位主机地址
C类地址:以110为首24位网络地址 + 8位主机地址
D类地址:以1110为首的32位多播(组播)地址
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IyD1GwDY-1578914622448)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day12/网络与网络协议/五类互联网地址.png)]

例如:某台计算机的IP地址为192.168.182.48,其网络地址和主机地址分别如何?11000000 10101000 10110110 00110000,c类地址,网络地址192.168.182.0,主机地址48

主机IP地址 & 子网掩码 = 网络地址
主机IP地址 & (~子网掩码) = 主机地址

2、套接字(socket)

(1) 什么是套接字

套接字是一个由系统内核负责维护,可以通过文件描述符访问的对象,可用于同一台机器或不同机器中的进程之间实现通信。

套接字也可以被视为围绕表示网络的文件描述符的一套函数库,调用其中的函数就可以访问网络上的数据,实现不同主机之间的通信功能

进程表项

文件描述符表

0: 文件描述符标志| * -> 标准输入文件表项–>键盘
1: 文件描述符标志| * -> 标准输出文件表项–>显示器
2: 文件描述符标志| * -> 标准错误文件表项–>显示器
3: 文件描述符标志| * -> 套接字对象–>网卡

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F1Weo7fF-1578914622448)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day12/文件描述符和套接字描述符对比.png)]

(2) 绑定和连接

套接字就是系统内核内存中的一块数据——逻辑对象
借助包含了IP地址和端口号等参数的网络设备——物理对象
把这个逻辑对象和物理对象建立联系的过程就是绑定(bind)

IP地址区分互联网上的不同机器,端口号区分同一机器上的不同进程(应用)

通过IP地址(网络地址+主机地址)和端口号就可以唯一定位互联网上的一个通信应用

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2xtmubnW-1578914622449)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day12/IP地址和端口号作用.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eefmdsxy-1578914622449)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day12/绑定.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yXMMdBLK-1578914622449)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day12/连接.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZNFHdSb4-1578914622449)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day12/绑定和连接.png)]

(3) 常用函数

创建套接字
int socket(int domain, int type, int protocol);
成功返回套接字描述符,失败返回-1

  • domain——通信域,即协议族,可取以下值:

    PF_LOCAL/PF_UNIX——本地通信,即进程间通信

    PF_INET——互联网通信,ipv4

    PF_INET6——互联网通信,ipv6

    PF_PACKET——底层包通信(嗅探器)

  • type——套接字类型,可取以下值:

    SOCK_STREAM——流式套接字,用于TCP协议

    SOCK_DGRAM——数据报式套接字,使用UDP协议

    SOCK_RAW——原始套接字,使用自定义协议

  • protocol——特殊协议

    对于流式套接字和数据报式套接字,取0

套接字描述符和文件描述符在逻辑层面是一致的,所有关于文件描述符的规则对于套接字描述符也同样成立,同样也通过close函数关闭套接字释放内核中的有关资源。

基本地址结构

struct sockaddr{
    sa_family_t sa_samily;//地址族
    char sa_sata[14];//地址值
};

基本地址结构仅用于函数传参时做强制类型转换

本地地址结构:

#include 
struct sockaddr_un{
    sa_family_t sun_family;//地址族(AF_LOCAL/AF_UNIX)
    char sun_path[];//套接字文件路径
};

网络地址结构:

#include 
struct sockaddr_in{
    sa_family_t sin_family;//地址族(AF_INET)
    in_port_t sin_port;//端口号(网络字节序)
    struct in_addr sin_addr;//IP地址
};
………………
struct in_addr{
    in_addr_t s_addr;//网络字节序的32位无符号整数形式的IP地址
};
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;

发送时:主机字节序—>网络字节序(大端字节序)
接收时:网络字节序(大端字节序)—>主机字节序

将套接字对象和自己的地址结构绑定在一起
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
成功返回0,失败返回-1

  • sockfd——套接字自描述符
  • addr——自己的地址结构
  • addrlen——地址结构的字节数

将套接字对象所代表的物理对象和对方的的地址结构连接在一起
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
成功返回0,失败返回-1

  • sockfd——套接字自描述符
  • addr——对方的地址结构
  • addrlen——地址结构的字节数

通过套接字描述符接收和发送数据的过程就完全与通过文件描述符读取和写入数据的过程完全一样。

接收数据
*ssize_t read(int fd, void buf, size_t count);

发送数据
*ssize_t write(int fd, const void buf, size_t count);

字节序转换函数
通过网络传输多字节整数,需要在发送前转换为网络字节序,在接收后转换为主机字节序。
#include
uint32_t htonl(unit32_t hostlong;)
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
说明:

  • h——host,主机(字节序)
  • to——到,把…转换到…
  • n——network,网络(字节序)
  • l——long版本,32位无符号整数
  • s——short版本,16位无符号整数

IP地址转换
(网络字节序32位无符号)整数<—>(点分十进制)字符串
#include
in_addr_t inet_addr(const char* cp); //串->数(网络字节序)
int inet_aton(const char* cp, struct in_addr* inp);//串–>数,成功返回0,失败返回-1
char* inet_ntoa(struct in_addr in);//数->串,成功返回字符串指针,失败返回NULL

补充:
int inet_pton(int af, const char *src, void *dst);//convert IPv4 and IPv6 addresses from text to binary
form

基于本地套接字的进程间通信模型:

服务器 客户机
创建套接字(socket) 创建套接字(socket)
准备地址结构(sockaddr_un) 准备服务器地址结构(sockaddr_un)
绑定地址(bind) 建立连接(connect)
接收请求(read) 发送请求(write)
业务处理(…) 等待处理(…)
发送响应(write) 接收响应(read)
关闭套接字(close) 关闭套接字(close)

参考代码:locsvr.c loccli.c

服务器:提供业务服务的计算机程序
客户端:请求业务服务的计算机程序

基于网络套接字的进程间通信模型:

服务器 客户机
创建套接字(socket) 创建套接字(socket)
准备地址结构(sockaddr_in) 准备服务器地址结构(sockaddr_in)
绑定地址(bind) 建立连接(connect)
接收请求(read) 发送请求(write)
业务处理(…) 等待处理(…)
发送响应(write) 接收响应(read)
关闭套接字(close) 关闭套接字(close)

参考代码:netsvr.c netcli.c

3、基于并发的TCP客户机/服务器模型

(1) TCP协议的基本特征:

  • 面向连接:参与通信的双方在正式通信之前,需要先建立连接,以形成一条虚拟电路,所有的后续通信都在这条虚电路上完成。类似于电话通信业务,正式通话之前要先拨号,拨通了才能讲话,拨号的过程就是建立连接的过程。
    建立连接(三次握手):一旦三次握手完成,客户机和服务器的网络协议栈中就会保存有关链路的信息,此后的通信内容全部基于此连接实现数据传输。通信过程中,任何原因导致连接中断,都无法继续通信,除非重新建立连接[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9RNrBUxN-1578914622450)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/建立连接.png)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7CmhYny2-1578914622450)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/连接建立_三次握手.png)]
  • 可靠传输:超时重传。每次发送一个数据包,对方每次都需要在一个给定的时间窗口内予以应答,如果超过时间没有收到对方的应答,发送方就会重发数据包,只有重试过足够多的次数依然失败才会最终放弃。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TgjYsGAO-1578914622450)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/交换数据.png)]
  • 保证顺序:发送端为每一个数据包编制序列号,接收端会根据序列号对所接收到的数据包进行重排,避免重复和乱序
  • 流量控制:协议栈底层在从另一端接收数据时,会不断告知对方它能够接收多少字节的数据,即所谓的通告窗口。任何时候。这个窗口都反映了接收缓冲区可用空间的大小,从而确保不会因为发送方发送数据过快或过慢导致接收缓冲区上溢出或下溢出[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-As7qroC8-1578914622450)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/流量控制.png)]
  • 流式传输:以字节流形式来传输数据,数据报在传输过程中并没有记录边界,应用程序需要根据自己的规则来划分出数据包的记录边界,常用的方法有以下三种:
    • 定长记录
    • 不定长记录+分隔符
    • 定长长度+不定长记录[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hpdR5Xeo-1578914622450)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/记录边界.png)]
  • 全双工:在给定的连接上,应用程序在任何时候都既可以发送数据,也可以接收数据。因此TCP必须跟踪每个方向上数据流的状态信息,如序列号、通告窗口大小等。

(2) TCP连接的生命周期

  • 被动打开:通过侦听套接字感知其它主机发起的连接请求
  • 三次握手:TCP连接的建立过程[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d5xuQUS7-1578914622451)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/TCP包首部.png)]
  • 数据传输:超时重传、流量控制、面向字节流、全双工
  • 终止连接:四次分手[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s6Oqu54W-1578914622451)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/终止连接.png)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YFv21w8g-1578914622451)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/连接的终止——四次分手.png)]

(3) 常用函数

在指定套接字上启动对连接请求的侦听,即将该套接字置为被动侦听模式,因为套接字都缺省为主动模式。
int listen(int sockfd, int backlog);
成功返回0,失败返回-1

  • sockfd——套接字描述符
  • backlog——未决连接请求队列的最大长度

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0BIEDV70-1578914622451)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/启动侦听.png)]

在指定的侦听套接字上等待并接受连接请求
int accept(int sockfd, struct sockaddr* addr, size_t* addrlen);
成功返回连接套接字描述符用于后续的通信,失败返回-1。
该函数由TCP服务器调用,返回排在已决队列连接队列首部的连接套接字对象的描述符,若已决队列为空,该函数会阻塞

  • sockfd——侦听套接字描述符
  • addr——输出连接请求发起者的地址信息
  • addrlen——输入输出连接请求发起者地址信息的字节数

非并发的TCP服务器模型:迭代服务器是指对于客户端的请求和连接,服务器逐个进行处理,处理完一个连接后再处理下一个连接,属于串行处理方式,结构比较简单,同一时刻只能处理一个客户端的请求

并发的TCP服务器模型:并发服务器可以同时处理多个客户机请求

接收数据
ssize_t recv(int sockfd, void* buf, size_t len, int flags);

  • 返回值、sockfd、buf、len等参数的含义同read函数

  • flags——接收标志,取0等价于read函数,还可以取以下值:

    MSG_DONTWAIT——非阻塞接收。对于阻塞模式,当接收缓冲区为空时,该函数会阻塞,直到接收缓冲区不空为止。如果使用此标志位,当接收缓冲区为空时,该函数会返回-1,并置errno为EAGAIN或者EWOULDBLOCK

    MSG_OOB——接收带外数据

    MSG_PEEK——瞄一眼数据,只将接收缓冲区中的数据复制到buf缓冲区中,但并不将其从接收缓冲区中删除

    MSG_WAITALL——接收到所有期望接收的数据才返回,如果接收缓冲区中的数据不到len字节,该函数会阻塞,直到可接收到len字节为止

发送数据
ssize_t send(int sockfd, const void* buf, size_t len, int flags);

  • flags——发送标志,取0时等价于write函数,还可以取以下值:

    MSG_DONTWAIT——非阻塞发送。对于阻塞模式,当发送缓冲区的空余空间不足以容纳期望的字节数时,该函数会阻塞,直到发送缓冲区的空余空间足以容纳期望发送的字节数为止。如果使用了此标志位,则能发送多少字节就发送多少字节,不会阻塞,甚至可能返回0表示发送缓冲区满,无法发送。

    MSG_OOB——发送带外数据

    MSG_DONTROUT——不查路由表,直接在本地网中寻找目的主机

参考代码:tcpsvr.c tcpcli.c

4、基于迭代的UDP客户机/服务器模型

(1) UDP协议的基本特征

  • 面向无连接:参与通信的主机之间不需要专门建立和维护逻辑的连接通道。一个UDP套接字可以和任意其它UDP套接字通信,而不必受连接的限制
  • 不可靠传输:没有超时重传机制,可能会导致数据丢失。
  • 不保证顺序:没有序列号,也不进行序列重组,可能产生数据报的重复和乱序
  • 无流量控制:没有通告窗口,通信参与者完全不知道对方的接受能力,可能造成数据的溢出。
  • 记录式传输:以消息报文的形式传输数据,数据包在传输过程中有记录边界,应用程序无需划分数据报边界
  • 全双工

通过在可靠性方面的部分牺牲,来换区高速度[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9Uqieyqh-1578914622452)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day13/UDP首部.png)]

(2) 常用函数

ssize_t sendto(int sockfd, const void* buf, size_t len, int flags, const struct sockaddr* dest_addr, socklen_t addrlen);
返回值和前4各参数同send函数

  • dest_addr——数据报接收者地址结构
  • addelen——数据报接受者地址结构字节数

ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr,socklen_t* addrlen);
返回值和前4各参数同recv函数

  • src_addr——输出数据包发送者的地址结构
  • addelen——输出数据包发送者的地址结构字节数

参考代码:udpsvr.c udpcli.c

针对UDP套接字的connect函数,并不像TCP套接字connect一样通过三次握手过程建立所谓的虚电路连接,而仅仅是将传递给该函数的对方地址结构缓存在套接字对象中。此后通过该套接字发送数据时,可以不适用sendto函数,而直接调用send函数,有关接受放的地址信息从套接字对象的地址缓存中提取即可。

参考代码:concli.c

5、域名解析(DNS, Domain Name Service)

字符串形式的域名—>DNS服务器—>整数形式的IP地址—>套接字编程

根据主机域名获取信息
#include
struct hostent* gethostbyname(const char* name);
成功返回主机信息条目指针,失败返回NULL

  • name——主机域名(字符串)

struct hostent结构体内容:

struct hostent{
    char*	h_name;	//主机官方名
    char** 	h_aliases;	//别名表
    int 	h_addrtype;	//AF_INET,地址类型
    int 	h_length;	//地址字节数
    char**	h_addr_list;//地址表,指向struct in_addr地址结构
}

参考代码:dns.c

6、获取HTTP服务器上的页面

HTTP(Hyper Text Transfotm Protocol),超文本传输协议,属于应用层协议
www服务器通过http协议提供页面服务

HTTP请求包头

方法 资源路径(URI ,统一资源定位符) 通信协议和版本
GET /user/project/main.html HTTP/1.1
每行结束
Host www.tmooc.cn \r\n
Accept text/html \r\n
Connection Keep-Alive/Close \r\n
User-Agent Mozalla/5.0 \r\n
Referer www.tmooc.cn \r\n
…… …… 最后一行:\r\n\r\n

参考代码:http.c

十四、多线程

1、什么是线程?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NGOWEg2c-1578914622452)(/home/jiwei/01_ESD/02_unixc/03_ESD1911/插图/day14/线程.png)]

线程就是进程的执行过程,即进程内存的控制序列,或者说是进程中的一个任务。
一个进程可以同时拥有多个线程,即同时被系统调度的多个执行路径,但至少要有一个主线程——main函数及被其调用的其它函数。

一个进程的所有线程共享进程的代码区、数据区、BSS区、堆区、环境变量和命令行参数区、文件描述符表、信号处理函数、当前工作目录、用户和组的各种ID等。但是,栈区不是共享的,一个进程的每个线程都拥有自己独立的栈区

进程是资源分配的基本单位,线程是执行/调度的基本单位

线程调度

  • 系统内核中专门负责线程调度的处理单元被称为调度器;
  • 调度器将所有处于就绪状态(没有阻塞在任何系统调用上)的线程排成一个队列,即去所谓就绪队列
  • 调度器从就绪队列中获取队首线程,为其分配一个时间片,并令处理器执行该线程,过了一段时间:
    • 该线程的时间片耗尽,调度器立即终止该线程,并将其排到就绪队列的尾端,接着从队首获取下一个线程;
    • 该线程的时间片未耗尽,但需要阻塞于某系统调用,比如等待I/O或者睡眠,调度器会终止该线程并将其从就绪队列中移出至等待队列,直到其等待的条件满足后,再被移回就绪队列
  • 在低优先级线程执行期间,有高优先级的线程就绪,后者会抢占前者的时间片
  • 如果就绪队列为空,则系统内核进入空闲状态,直至其非空
  • 像Linux这样的多任务分时系统,基本的调度单位是线程。
  • 为线程分配的时间片不宜过长,因为时间片太长会导致没有获得处理机的线程等候时间过久,降低系统运行的并行性,用户会感到明显的响应延迟;时间片也不宜过短,因为过短的时间片会增加线程之间切换上下文的频率,也会降低系统的运行性能

2、线程的基本特点

  • 线程是进程中的独立实体,可以拥有自己的资源,可以被独立标识——线程ID,同时也被作为基本调度单元,参与时间片的分配
  • 线程有不同的状态,如创建、运行、终止、暂停、恢复、取消等
  • 线程可以使用的大部分资源还是隶属于进程的,因此线程作为进程的一部分不能脱离进程独立存在
  • 一个进程可以同时执行多个线程,这些线程可以执行相同的代码,完成相同的任务,也可以执行不同的代码,完成不同的任务。
  • 创建一个线程所花费的开销远小于创建进程的开销,线程也成为轻量级进程。因此在解决诸如并发的问题时,优先考虑多线程,其次才是多进程
  • 多线程的问题在于,因为太多的资源被共享,极易导致冲突,为了解决冲突可能需要增加额外的开销,因此多进程仍然有它的优势。

进程:内存壁垒,通信
线程:内存共享,同步

3、POSIX线程

IEEE POSIX 1003.1c(1995颁布),定义了统一的线程编程接口,遵循该标准的线程实现被统称为POSIX线程。

头文件:#include
编译和链接时,Compile and link with -pthread.

4、创建线程

线程过程函数:在一个线程中被内核调用的函数,对该函数的调用过程就是线程的执行过程,从该函数中返回意味着该线程的结束。因此,main函数其实一个进程的主线程的线程过程函数。所有自创建的线程都必须有一个线程过程函数(由程序员定义内核调用): void* 线程过程函数(void* 线程参数指针){线程执行过程}

int pthread_create(pthread_t* tid, const pthread_attr_t* attr, void* (*start_routine)(void*), void* arg);
成功返回0,失败返回错误码

  • tid——输出线程标识
  • attr——线程属性,NULL表示缺省属性
  • start_routine——线程过程函数指针
  • arg——线程参数指针

pthread_create----->创建一个新线程----->调用线程过程函数(start_routine)并传入线程参数指针(arg)

被创建的子线程和创建该子线程的父线程是并行的关系,其调度顺序无法预知,因此当pthread_create函数返回时,子线程执行的位置无从确定,其线程过程函数可能尚未被调用,也可能正在执行,甚至可能已经返回。传递给线程的参数对象,一定要在线程过程函数不再使用它的情况下才能被释放。

参考代码:create.c

主线程和通过pthread_create函数创建的多个子进程,在时间上“同时”运行,如果不去附加任何同步条件,则它们每一个执行步骤的先后顺序无法预知,这种叫做自由并发

参考代码:concur.c

为了让贤臣过程函数的实现更加灵活,可以通过线程参数来传递特定的信息,帮助线程过程函数执行不同的任务。

参考代码:arg.c

5、汇合线程

int pthread_join(pthread_t tid, void** retval);
成功返回0,失败返回错误码

  • tid——线程标识
  • retval——线程退出码

当调用pthread_join函数时,以下几种情况:

  • tid线程已经终止,立刻返回,并且输出线程退出码
  • tid线程尚未终止,阻塞等待直到被汇合线程终止

pthread_join函数的作用:等待子线程终止,清理线程的资源,获得线程过程函数的返回值

参考代码:join.c

6、分离线程

在有些时候,作为子线程的创建者,父线程并不关心子线程何时终止,同时父线程也不需要获得子线程的返回值。在这种情况下,就可以将子线程设置为分离线程,这样的线程一旦终止,他们的资源会被系统自动回收,而无需在其父线程中调用pthread_join函数

int pthread_detach(pthread_t tid);
成功返回0,失败返回错误码

  • tid——线程标识

参考代码:detach.c

//使用线程属性创建分离线程
pthread_attr_t  attr;
pthread_attr_init(&attr);//用缺省值来初始化线程属性
pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED);//设置成分离属性
pthread_create(&tid,&attr,thread_proc,NULL);
pthread_attr_destroy(&attr);

7、线程ID

pthread_create函数通过tid参数输出线程ID,pthread_self()函数可以返回线程ID
if(tid1 == tid2) //兼容性不好

比较线程tid是否相等
int pthread_equal(pthread_t tid1, pthread_t tid1);
两个TID相等返回非0,否则返回0

if(pthread_equal(tid1, tid2)){…}//兼容性好

补充
pthread_self()返回的tid是POSIX线程库维护的主线程虚拟的tid,真正的由内核维护的真实的tid可以通过下面的函数获得:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include
#include /* For SYS_xxx definitions 定义了SYS_xxx宏*/
long syscall(SYS_gettid);
成功返回调用线程的tid,失败返回-1

通过pthread_self函数返回的线程ID和pthread_create函数输出的线程ID一样,都是由POSIX库内部维护的虚拟(伪)线程ID,可用于其它需要提供线程ID的PTHREAD函数。系统内核维护的真实线程ID可通过**syscall(SYS_gettid)**获得。
在Linux系统中,一个进程的PID实际上就是它的主线程的TID

参考代码:equal.c

8、终止线程(自己)

(1) 从线程过程函数中返回,执行该线程过程函数的线程即终止。其返回值可通过pthread_join函数的第二个参数输出给调用函数。

(2) 在线程过程函数及其被其调用的任何函数中都可以调用pthread_exit函数终止当前线程
void pthread_exit(void* retval);
该函数的参数retval就相当于线程过程函数的返回值,同样可以被pthread_join函数的第二个参数输出给调用者

  • retval——线程过程函数的返回值、

参考代码:exit.c

注意:在子线程中调用pthread_exit函数,只会终止调用线程自己,对其它兄弟线程和主线程没有影响。但是如果在主线程中调用pthread_exit函数,被终止的将是整个进程及其所包含的全部线程

9、取消(其它)线程

int pthread_cancel(pthread_t tid);
成功返回0,失败返回错误码

  • tid——被取消线程的tid

该函数只是向特定线程发出取消请求,并不等待其终止运行。缺省情况下,线程在收到取消请求以后,并不会立即终止,而是仍继续运行,直到达到某个取消点。在取消点出,线程会检查其自身是否已被取消,若是则立即终止。取消点通常出现在特定的系统调用中。

设置调用调用线程的取消状态为接收或者忽略取消请求
int pthread_setcancelstate(int state, int* oldstate);

  • state——取消状态,可取以下值:
    • PTHREAD_CANCEL_ENABLE——接收取消请求(缺省)
    • PTHREAD_CANCEL_DISABLE——忽略取消请求
  • oldstate——输出原取消状态,可取NULL

设置调用调用线程的取消类型为延迟或者立即取消
int pthread_setcanceltype(int type, int* oldtype);

  • type——取消类型,可取以下值:
    • PTHREAD_CANCEL_DEFERRED——延迟取消(缺省),收到取消请求,如果不是忽略的话,继续运行一段时间,直到执行到取消点时再终止
    • PTHREAD_CANCEL_ASYNCHRONOUS——立即取消,收到取消请求,如果不是忽略的话,立即终止运行
  • oldtype——输出原取消类型,可取NULL

参考代码:cancel.c

10、补充:线程属性pthread_attr_t

(1) 线程属性结构pthread_attr_t

在Linux上线程属性pthread_attr_t是一个结构体,包括:

  • int detachstate; // 分离状态

    • PTHREAD_CREATE_JOINABLE - 可汇合线程(缺省)
    • PTHREAD_CREATE_DETACHED - 分离线程
  • int scope; // 竞争范围

    • PTHREAD_SCOPE_SYSTEM - 系统范围竞争
    • PTHREAD_SCOPE_PROCESS - 进程范围竞争(Linux不支持)
  • int inheritsched; // 继承调度

    • PTHREAD_INHERIT_SCHED - 继承父线程的调度属性(缺省)
    • PTHREAD_EXPLICIT_SCHED - 用后面的两个属性人为设定
  • int schedpolicy; // 调度策略

    • SCHED_FIFO - 先进先出策略。没有时间片,一个FIFO线程会持续运行,直到阻塞或者有高优先级线程就绪。当FIFO线程阻塞时,系统会将其移出就绪队列,待其恢复时再加到同优先级就绪队列的末尾。当FIFO线程被高优先级线程抢占时,它在就绪队列中的位置不变。因此一旦高优先级线程终止或者阻塞,被抢占的FIFO线程会立即执行。
    • SCHED_RR - 轮转策略,给每个RR线程分配一个时间片,一旦RR线程的时间片耗尽,即将其移至就绪队列末尾。
    • SCHED_OTHER - 普通策略(缺省),静态优先级为0。也是以轮转方式调度,但任何就绪的FIFO线程和RR线程,都会抢占此类线程。
  • struct sched_param schedparam; // 调度参数

    struct sched_param {
        int sched_priority; // 静态优先级,
    };
    

    该静态优先级仅对实施调度SCHED_FIFO/SCHED_RR有意义。此值越大优先级越高。可以通过**sched_get_priority_max()sched_get_priority_min()**函数,获得当前系统所支持的最大和最小静态优先级。

  • size_t guardsize; // 栈尾警戒区字节数,缺省4096

  • size_t stacksize; // 栈区字节数

  • void* stackaddr; // 栈区起始地址

(2) 常用函数

初始化线程属性结构,分配内部资源,设为缺省值

int pthread_attr_init(pthread_attr_t* attr);

设置具体线程属性

int pthread_attr_setdetachstate(pthread_attr_t* attr, int detachstate);

int pthread_attr_setscope(pthread_attr_t* attr, int scopy);

销毁线程结构的内部动态资源

int pthread_attr_destroy(pthread_attr_t* attr);

获取特定线程的属性结构

int pthread_getattr_np(pthread_t thread, pthread_attr_t* attr);

获取具体的线程属性

int pthread_attr_getdetachstate(pthread_attr_t* attr, int* detachstate);

int pthread_attr_getscope(pthread_attr_t* attr, int* scope);


参考代码:attr.c

11、线程冲突

实际的g++执行过程(非原子化的):把内存中的值(g)读入CPU寄存器(eax),把寄存器(eax)中的值加1,把寄存器(eax)中的值存入内存(g)

当两个或者两个以上的线程同时以非原子化的方式访问同一个对象时,如果不能相互协调配合,极有可能导致对象的最终状态不稳定(不一致或者不完整),这就是所谓的并发冲突。解决冲突的基本原则就是敏感操作的原子化,即保证一个线程完成这组敏感操作以后,再允许另一个线程执行类似的操作,位于与共享资源有关的执行代码,在任何时候都只允许一个线程执行。

现象参见代码:vie.c

12、互斥锁

静态初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

动态初始化
pthread_mutex_t mutex; //一般在全局域定义
int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* attr);
成功返回0,失败返回错误码。

  • mutex——互斥锁
  • attr——属性,缺省填NULL

动态销毁互斥锁

int pthread_mutex_destroy(pthread_mutex_t* mutex);

锁定互斥锁

int pthread_mutex_lock(pthread_mutex_t* mutex);

成功返回0,失败返回错误码。
任何时候只会有一个线程对特定的互斥锁加锁成功,其它试图对其加锁的线程会在此函数的阻塞中等待,直到该互斥锁的持有者将其解锁。

解锁互斥锁

int pthread_mutex_unlock(pthread_mutex_t* mutex);

成功返回0,失败返回错误码。

对特定互斥锁加锁成功的线程通过此函数将其解锁,那些阻塞于对该互斥锁对象试图加锁的线程中的一个会被唤醒,得到该互斥锁,并从pthread_mutex_lock函数中返回。

编程模型:

pthread_mutex_init(...); //一般在全局域定义,因为每个线程都要使用该互斥所
...
void* thread_proc(void* arg) {
    ...
    pthread_mutex_lock(...); //加锁   \
    xxx                               |
    xxx //执行操作,访问共享对象           > 锁区
    xxx                               |
    pthread_mutex_unlock(...);//解锁  /
    ...
}
...
pthread_mutex_destroy(...);

参考代码:mutex.c mutex2.c

互斥锁在保证数据一致性和防止并发冲突的同时,也牺牲了多线程应用的并发性,因此在设计上,应尽量少地使用互斥锁,或者说只有在需要需要被互斥锁保护的操作过程中,使用它;而对于保护需求不明显的场合尽量不用。

13、信号量

功能和用法与XSI/IPC对象中的信号量集非常类似,但是XSI/IPC对象中的信号量集只能用于进程,而这里的信号量既能用于进程,也可以用于线程。

(1) 创建信号量

#include

int sem_init(sem_t* sem, int pshared, unsigned int value);

成功返回0,失败返回-1,同时置errno。

  • sem——信号量

  • pshared——0表示用于线程,非0表示用于进程。

    当pshared信号量取0,信号量仅用于一个进程中的多个线程,其本质就是一个普通的全局变量。但如果该参数的值为非0,则信号量可用于不同的进程,其存储位置在可为这些进程所访问的共享内存中。

  • value ——信号量初值,即初始空闲资源数

(2) 销毁信号量

int sem_destroy(sem_t* sem);

成功返回0,失败返回-1,同时置errno。

(3) 等待信号量

int sem_wait(sem_t* sem);

int sem_trywait(sem_t* sem);

int sem_timedwait(sem_t* sem, const struct timespec abs_timeout);*

成功返回0,失败返回-1,同时置errno。

struct timespec结构体内容

struct timespec {
    time_t tv_sec; // 秒
    long tv_nsec; // 纳秒
};

超时时间的计算起点:UTC19700101T000000

调用以上函数时,若信号量计数器的值大于等于1,则将其减1并立即返回0,表示获得资源;如果该信号量的当前值等于0,这就意味着已经没有空闲资源可供分配,调用以上函数的进程或线程,会有以下三种情况::

  • sem_wait函数会阻塞,直到信号量计数器的值够减时,将其减1并返回;
  • sem_trywait函数立即返回-1,并置errno为EAGAIN;
  • sem_timedwait函数会阻塞,直到信号量计数器的值够减时,将其减1并返回,但最多阻塞到abs_timeout时间,一旦超时立即返回-1,并置errno为ETIMEOUT

(4) 释放信号量

int sem_post(sem_t* sem);
成功返回0,失败返回-1,同时置errno。

调用sem_post函数将直接导致sem信号量计数器的值加1。那些此刻正阻塞于针对该信号量的sem_wait函数调用的线程或进程中的一个将被唤醒,并sem信号量计数器的值减1并从sem_wait函数中返回。

(5) 获取信号量的当前值

int sem_getvalue(sem_t* sem, int* sval);

成功返回0,失败返回-1,同时置errno

  • sval ——输出sem信号量计数器的当前值,即当前可分配资源数

代码:sem.c

14、死锁问题

现象参见代码:dl.c

死锁的四个必要条件:

  • 1、独占排它:线程以独占的方式去使用其所获得的资源,即在一段时间内不允许其它线程使用该资源。这段时间内,任何试图请求该资源的线程只能在阻塞中等待,直到资源被其拥有者主动释放
  • 2、请求保持:线程已经拥有了至少一份资源,但又试图获取已被其它线程拥有的资源,因此只能在阻塞中等待,同时对自己已经获取的资源又坚守不放
  • 3、不可剥夺:线程已经获得的资源,在其未被使用完之前,不可被强制剥夺,而只能由其拥有者自己释放
  • 4、循环等待:线程集合{T0,T1,…,Tn}中,T0等待T1占有的资源,T1等待T2占有的资源,…,Tn等待T0占有的资源,形成环路

死锁问题的解决方案

  • 事前预防:通过设置某些限制条件,破坏产生死锁的四个必要条件中的一个或几个,以避免死锁的发生。但如果所设置的限制条件过于严苛,则极有可能导致系统资源利用率和吞吐量的下降
  • 事中规避:无需事先通过各种限制措施破坏产生死锁的四个必要条件,而是在资源的动态分配过程中,通过某种方法防治系统进入不安全状态,从而避免死锁的发生
  • 事后补救:允许系统发生死锁,但是可以通过预设的检测的机制,及时发现死锁的发生,并精确定位与死锁有关的线程和资源。而后取消或挂起一些线程,回收其资源,再将这部分资源分配给那些于阻塞中等待资源的线程,使之进入就绪状态,继续运行

15、条件变量

//线程1
	if(依赖的条件不满足)
    	睡眠于条件变量;
	后续操作;
	...;
//线程2
	...;
	创造出后续操作锁依赖的条件;
	唤醒在条件变量中睡眠的线程;
	......;

条件变量可以使一个线程在对某个条件的等待中发生阻塞,直到其它线程满足其所等待的条件后再被唤醒。

静态初始化条件变量

pthread_cond_t g_cond =PTHREAD_COND_INITIALIZER;

动态初始化条件变量

int pthread_cond_init(pthread_cond_t* cond, const pthread_condattr_t* attr);

成功返回0,失败返回-1。

销毁动态初始化的条件变量

int pthread_cond_destroy(pthread_cond_t* cond);

成功返回0,失败返回-1。

等候条件变量,即在天剑变量中睡眠

int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);

int pthread_cond_timedwait(pthread_cond_t* cond, pthread_mutex_t* mutex, const struct timespec* abs_timeout);

成功返回0,失败返回-1。

  • cond - 条件变量
  • mutex - 互斥锁
  • abs_timeout - 等候时限(始自UTC19700101T000000)

当调用线程在cond条件变量睡眠期间,mutex互斥锁会被解锁,直到该线程从条件变量中苏醒,即从该函数中返回再重新拥有该互斥锁。
以上函数会令调用线程进入阻塞状态,直到条件变量cond收到信号为止,阻塞期间互斥锁mutex被解锁。有时需要将条件变量与互斥锁配合使用,以防止多个线程同时进入条件等候队列时发生竞争。线程在调用pthread_cond_wait函数前必须通过pthread_mutex_lock函数锁定mutex互斥锁。在调用线程进入条件变量等候队列之前,mutex互斥锁一直处于锁定状态,直到调用线程进入条件等候队列才被解锁。当调用线程即将从pthread_cond_wait函数返回时,mutex互斥锁会被重新锁定,回到调用该函数之前的状态。

向指定的条件变量发送信号
int pthread_cond_signal(pthread_cond_t cond);* //唤醒在条件变量cond中睡眠的第一个线程(如果),即条件等待队列的队首线程,待其重新获得互斥锁以后,从pthread_cond_wait函数中返回。

具体唤醒哪个线程呢?:假如有多个线程正在阻塞等待着这个条件变量的话,那么是根据各等待线程优先级的高低确定哪个线程接收到信号开始继续执行。如果各线程优先级相同,则根据等待时间的长短来确定哪个线程获得信号。但无论如何一个pthread_cond_signal调用最多发信一次。

int pthread_cond_broadcast(pthread_cond_t cond);* //唤醒在条件变量cond中睡眠的所有线程,只有重新获得锁的线程会从pthread_cond_wait函数中返回

成功返回0,失败返回-1。

通过pthread_cond_signal函数向条件变量cond发送信号,该条件变量的条件等候队列中的一个线程将离开等候队列,并重新锁定mutex互斥锁后,从pthread_cond_wait函数中返回。

通过pthread_cond_broadcast函数向条件变量cond发送信号,该条件变量的条件等候队列中的所有线程将同时离开等候队列,但其中只有一个线程在重新锁定mutex互斥锁后,从pthread_cond_wait函数中返回,其余线程会继续为等待mutex互斥锁而阻塞。

生产者-消费者模型
硬件/网络—数据—>生产者线程-----数据----->消费者线程:生产者直接把数据交给消费者——刚性耦合
硬件/网络—数据—>生产者线程–数据–>缓冲区–数据–>消费者线程:经过缓冲区给消费者——柔性耦合

理想缓冲区:永远不满也不空
实际缓冲区:满不能放入(撑死),空不能提取(饿死)

//生产者线程
if(缓冲区满)
    睡入非满条件变量,释放缓冲区锁;
	被唤醒,离开条件等待队列,重新获得缓冲区锁;
生产--->非空;
唤醒在非空条件变量中睡眠的消费者线程;

//消费者线程
if(缓冲区空)
    睡入非空条件变量,释放缓冲区锁;
	被唤醒,离开条件等待队列,重新获得缓冲区锁;
消费--->非满;
唤醒在非满条件变量中睡眠的生产者线程;

参考代码:cond.c

哲学家就餐问题,参见代码:day15/dinning.c

你可能感兴趣的:(Unix-C)