系统编程
计算机操作系统
◆计算机的硬件组成:CPU + RAM + FLASH
三大总线 数据总线 地址总线 控制总线
图1.2 一个典型系统的硬件组成
1)总线:贯穿整个系统的一组电子管道,携带信息字节并负责在各个部件间传递。通常总线被设计成传送定长的字节块,各种系统的字节数不相同,例如Intel Pentium系统的字节长为4字节。
2)I/O设备:I/O(输入/输出)设备是系统与外界的联系通道,主要包括:键盘和鼠标,显示器以及用于长期存储数据和程序的磁盘驱动器。每一个I/O设备都是通过一个控制器与适配器与I/O设备连接起来的。控制器是主板上的芯片组,而适配器则是一块插在主板插槽上的卡。
3)主存:主存是由一组DRAM(动态随机访问存储器)芯片组成的。在处理器执行程序时,它被用来存放程序和程序处理的数据。
4)处理器中央处理单元(CPU):简称处理器,是解释存储在主存中指令的引擎。处理器的核心是程序计数器(PC)的字长大小的存储设备(或寄存器)。PC指向主存中的某条机器语言指令(内含其地址)。
5)高速缓存:之前但系统在执行hello程序时会有大量的拷贝工作,例如把代码和数据从磁盘拷贝到主存,从主存拷贝到寄存器堆,再从寄存器堆把文件拷贝到显示设备中。这些拷贝工作会减慢程序的实际工作。因此,为了加快程序运行速度,系统设计者采用了更小更快的存储设备,称为高速缓存存储器,它们被用来作为暂时的集结区域,存放处理器在不久的将来可能会需要的信息。
6)形成层次结构的存储设备:存储器分层结构的主要思想是一个层次上的存储器作为下一层次上的存储器的高速缓存。
图1.3 一个存储器层次结构的示例
1、程序的编译
对于一个hello.c程序,从源文件到目标文件的转化是由编译器驱动程序(compiler driver)完成的,翻译过程分为四个阶段完成,执行这四个阶段的程序(预处理器、编译器、汇编器和链接器)一起构成了编译系统。
图1.1 编译系统
预处理阶段:预处理器(cpp)根据以字符#开头的命令修改原始的C程序,结果得到另一个C程序,通常以.i作为文件扩展名。
编译阶段:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。汇编语言程序中的每条语句都以一种标准的文本格式确切地描述了一条低级机器语言指令。
汇编阶段:汇编器(a.s)将hello.s翻译成机器语言指令,并把指令打包成为一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,它的字节编码是机器语言指令而不是字符。
链接阶段:此时hello程序调用了printf函数。printf函数存在于一个名为printf.o的单独的预编译目标文件中。链接器(ld)就负责处理把这个文件并入到hello.o程序中,结果得到hello文件,一个可执行文件。最后可执行文件加载到存储器后由系统负责执行。
◆使用gcc编译C程序的详细过程
在Linux中,gcc更像一个工具大管家,管理很多工具一起来对C程序进行编译。详细过程请看下图,带阴影的箭头表示文件的流程,空白箭头表示控制过程:
下面详细介绍一下这个过程。
1、程序员在Linux终端中输入命令gcc eatc.c –o eatc
2、gcc接管Linux的控制权,然后立即启用一个工具C preprocessor(cpp)。这个工具处理C语言的源代码文件(eatc.c),处理比如引用#include、#define相应的替换之类的东西(预处理)。
3、之后,gcc接管。gcc把预处理后的文件进一步处理(进行语法编译),编译通过则变成和原始的C文件等价的 .s 汇编文件,这是一个人工可以读懂的文件。
4、之后,gcc就省事了。它把 .s 文件交给gas(一种GNU assembler)进行处理,生成 .o 文件(即:二进制文件)。
5、之后,再使用ld(一种GNU linker)进行处理,把文件中使用到的C库程序全部都链接到一起。最终形成一个可执行文件
(.o文件)。
6、gcc把控制权交还给Linux。
详细解析:
C语言的编译链接过程要把我们编写的一个c程序(源代码)转换成可以在硬件上运行的程序(可执行代码),需要进行编译和链接。编译就是把文本形式源代码翻译为机器语言形式的目标文件的过程。链接是把目标文件、操作系统的启动代码和用到的库文件进行组织,形成最终生成可执行代码的过程。过程图解如下:
从图上可以看到,整个代码的编译过程分为编译和链接两个过程,编译对应图中的大括号括起的部分,其余则为链接过程。
1. 编译过程
编译过程又可以分成两个阶段:编译和汇编。
1.1.编译
编译是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码,源文件的编译过程包含两个主要阶段:
1.1.1. 编译预处理
读取c源程序,对其中的伪指令(以# 开头的指令)和特殊符号进行处理。
伪指令主要包括以下四个方面:
1) 宏定义指令,如#define Name TokenString,#undef等。
对于前一个伪指令,预编译所要做的是将程序中的所有Name用TokenString替换,但作为字符串常量的 Name则不被替换。对于后者,则将取消对某个宏的定义,使以后该串的出现不再被替换。
2) 条件编译指令,如#ifdef,#ifndef,#else,#elif,#endif等。
这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉。
3) 头文件包含指令,如#include "FileName" 或者#include < FileName> 等。
在头文件中一般用伪指令# define定义了大量的宏(最常见的是字符常量),同时包含有各种外部符号的声明。
采用头文件的目的主要是为了使某些定义可以供多个不同的C源程序使用。因为在需要用到这些定义的C源程序中,只需加上一条#include语句即可,而不必再在此文件中将这些定义重复一遍。预编译程序将把头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理。
包含到c源程序中的头文件可以是系统提供的,这些头文件一般被放在/ usr/ include目录下。在程序中#include它们要使用尖括号(< >)。另外开发人员也可以定义自己的头文件,这些文件一般与c源程序放在同一目录下,此时在# include中要用双引号("")。
4) 特殊符号,预编译程序可以识别一些特殊的符号。
例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
预编译程序所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。这个文件的含义同没有经过预处理的源文件是相同的,但内容有所不同。下一步,此输出文件将作为编译程序的输入而被翻译成为机器指令。
1.1.2. 编译、优化阶段
经过预编译得到的输出文件中,只有常量;如数字、字符串、变量的定义,以及C语言的关键字,如main, if , else , for , while , { , } , + , - , * , \ 等等。
编译程序所要作的工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。
优化处理是编译系统中一项比较艰深的技术。它涉及到的问题不仅同编译技术本身有关,而且同机器的硬件环境也有很大的关系。优化一部分是对中间代码的优化。这种优化不依赖于具体的计算机。另一种优化则主要针对目标代码的生成而进行的。
对于前一种优化,主要的工作是删除公共表达式、循环优化(代码外提、强度削弱、变换循环控制条件、已知量的合并等)、复写传播,以及无用赋值的删除,等等。
后一种类型的优化同机器的硬件结构密切相关,最主要的是考虑是如何充分利用机器的各个硬件寄存器存放有关变量的值,以减少对于内存的访问次数。另外,如何根据机器硬件执行指令的特点(如流水线、RISC、CISC、VLIW等)而对指令进行一些调整使目标代码比较短,执行的效率比较高,也是一个重要的研究课题。
经过优化得到的汇编代码必须经过汇编程序的汇编转换成相应的机器指令,方可能被机器执行。
1.2.汇编
汇编过程实际上指把汇编语言代码翻译成目标机器指令的过程。对于被翻译系统处理的每一个C语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。
目标文件由段组成。通常一个目标文件中至少有两个段:
1) 代码段:该段中所包含的主要是程序的指令。该段一般是可读和可执行的,但一般却不可写。
2) 数据段:主要存放程序中要用到的各种全局变量或静态的数据。一般数据段都是可读,可写,可执行的。
UNIX环境下主要有三种类型的目标文件:
1) 可重定位文件
其中包含有适合于其它目标文件链接来创建一个可执行的或者共享的目标文件的代码和数据。
2) 共享的目标文件
这种文件存放了适合于在两种上下文里链接的代码和数据。
第一种是链接程序可把它与其它可重定位文件及共享的目标文件一起处理来创建另一个目标文件;
第二种是动态链接程序将它与另一个可执行文件及其它的共享目标文件结合到一起,创建一个进程映象。
3) 可执行文件
它包含了一个可以被操作系统创建一个进程来执行的文件。
汇编程序生成的实际上是第一种类型的目标文件。对于后两种还需要其他的一些处理方能得到,这个就是链接程序的工作了。
2. 链接过程
由汇编程序生成的目标文件并不能立即就被执行,其中可能还有许多没有解决的问题。
例如,某个源文件中的函数可能引用了另一个源文件中定义的某个符号(如变量或者函数调用等);在程序中可能调用了某个库文件中的函数,等等。所有的这些问题,都需要经链接程序的处理方能得以解决。
链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。
根据开发人员指定的同库函数的链接方式的不同,链接处理可分为两种:
1) 静态链接
在这种链接方式下,函数的代码将从其所在的静态链接库中被拷贝到最终的可执行程序中。这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中。静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。
2) 动态链接
在此种方式下,函数的代码被放到称作是动态链接库或共享对象的某个目标文件中。链接程序此时所作的只是在最终的可执行程序中记录下共享对象的名字以及其它少量的登记信息。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。
对于可执行文件中的函数调用,可分别采用动态链接或静态链接的方法。使用动态链接能够使最终的可执行文件比较短小,并且当共享对象被多个进程使用时能节约一些内存,因为在内存中只需要保存一份此共享对象的代码。但并不是使用动态链接就一定比使用静态链接要优越。在某些情况下动态链接可能带来一些性能上损害。
3. GCC的编译链接
我们在linux使用的gcc编译器便是把以上的几个过程进行捆绑,使用户只使用一次命令就把编译工作完成,这的确方便了编译工作,但对于初学者了解编译过程就很不利了,下图便是gcc代理的编译过程:
从上图可以看到:
1) 预编译
将.c 文件转化成 .i文件
使用的gcc命令是:gcc –E
对应于预处理命令cpp
2) 编译
将.c/.h文件转换成.s文件
使用的gcc命令是:gcc –S
对应于编译命令 cc –S
3) 汇编
将.s 文件转化成 .o文件
使用的gcc 命令是:gcc –c
对应于汇编命令是 as
4) 链接
将.o文件转化成可执行程序
使用的gcc 命令是: gcc
对应于链接命令是 ld
总结起来编译过程就上面的四个过程:预编译处理(.c) --> 编译、优化程序(.s、.asm)--> 汇编程序(.obj、.o、.a、.ko) --> 链接程序(.exe、.elf、.axf等)。
4. 总结
C语言编译的整个过程是非常复杂的,里面涉及到的编译器知识、硬件知识、工具链知识都是非常多的,深入了解整个编译过程对工程师理解应用程序的编写是有很大帮助的,希望大家可以多了解一些,在遇到问题时多思考、多实践。
一般情况下,我们只需要知道分成编译和链接两个阶段,编译阶段将源程序(*.c) 转换成为目标代码(一般是obj文件,至于具体过程就是上面说的那些阶段),链接阶段是把源程序转换成的目标代码(obj文件)与你程序里面调用的库函数对应的代码连接起来形成对应的可执行文件(exe文件)就可以了,其他的都需要在实践中多多体会才能有更深的理解。
----------------------------------华丽的分割线--------------------------------
◆Cpu与外设的三种交互方式(I/O通信技术)
1)CPU的轮循
2)中断 数据传输一直向CPU申请中断?
3)DMA 直接内存访问 例如:摄像头
◆一段代码中的量在内存中的地址分配:
(高地址)
栈(<4M) 局部变量 形参 函数的返回值 地址由上到下生长
解析:也就是说定义局部变量时先声明的地址会大一些
堆 调用malloc new来产生 地址由下到上生长
数据段 常量、全局变量、静态变量
解析:也就是说定义全局变量时先声明的地址会小一些
代码段 main函数的入口 代码的处理
(低地址)
◆栈溢出:
int f ( int x )
{
int a[10];
a[11] = x;
}
这个就是栈溢出,x被写到了不应该写的地方。在特定编译模式下,这个x的内容就会覆盖f原来的返回地址。也就是原本应该返回到调用位置的f函数,返回到了x指向的位置。一般情况下程序会就此崩溃。但是如果x被有意指向一段恶意代码,这段恶意代码就会被执行。
◆ 可执行文件加载到内存后形成的进程在内存中的结构
首先要来理解一下可执行文件加载进内存后形成的进程在内存中的结构,如下图:
代码区:存放CPU执行的机器指令,代码区是可共享,并且是只读的。
数据区:存放已初始化的全局变量、静态变量(全局和局部)、常量数据。
BBS区:存放的是未初始化的全局变量和静态变量。
栈区:由编译器自动分配释放,存放函数的参数值、返回值和局部变量,在程序运行过程中实时分配和释放,栈区由操作系统自动管理,无须程序员手动管理。
堆区:堆是由malloc()函数分配的内存块,使用free()函数来释放内存,堆的申请释放工作由程序员控制,容易产生内存泄漏。
◆ C语言中数据类型存储的比较
c语言中的存储类型有auto, extern, register, static 这四种,存储类型说明了该变量要在进程的哪一个段中分配内存空间,可以为变量分配内存存储空间的有数据区、BBS区、栈区、堆区。下面来一一举例看一下这几个存储类型:
1. auto存储类型
auto只能用来标识局部变量的存储类型,对于局部变量,auto是默认的存储类型,不需要显示的指定。因此,auto标识的变量存储在栈区中。示例如下:
#include <stdio.h>
int main(void)
{
auto int i=1; //显示指定变量的存储类型
int j=2;
printf("i=%d\tj=%d\n",i,j);
return 0;
}
2. extern存储类型
extern用来声明在当前文件中引用在当前项目中的其它文件中定义的全局变量。如果全局变量未被初始化,那么将被存在BBS区(非初始化数据区)中,且在编译时,自动将其值赋值为0,如果已经被初始化,那么就被存在数据区中。全局变量,不管是否被初始化,其生命周期都是整个程序运行过程中,为了节省内存空间,在当前文件中使用extern来声明其它文件中定义的全局变量时,就不会再为其分配内存空间。
示例如下:
#include <stdio.h>
int i=5; //定义全局变量,并初始化
void test(void)
{
printf("in subfunction i=%d\n",i);
}
#include <stdio.h>
extern i; //声明引用全局变量i
int main(void)
{
printf("in main i=%d\n",i);
test();
return 0;
}
$ gcc -o test test.c file.c #编译连接
$ ./test #运行
结果:
in main i=5
in subfunction i=5
3. register存储类型
声明为register的变量在由内存调入到CPU寄存器后,则常驻在CPU的寄存器中,因此访问register变量将在很大程度上提高效率,因为省去了变量由内存调入到寄存器过程中的好几个指令周期。如下示例:
#include <stdio.h>
int main(void)
{
register int i,sum=0;
for(i=0;i<10;i++)
sum=sum+1;
printf("%d\n",sum);
return 0;
}
4. static存储类型
被声明为静态类型的变量,无论是全局的还是局部的,都存储在数据区中,其生命周期为 整个程序,如果是静态局部变量,其作用域为一对{}内,如果是静态全局变量,其作用域为当前文件。静态变量如果没有被初始化,则自动初始化为0。静态变量只能够初始化一次。示例如下:
#include <stdio.h>
int sum(int a)
{
auto int c=0;
static int b=5;
c++;
b++;
printf("a=%d,\tc=%d,\tb=%d\t",a,c,b);
return (a+b+c);
}
int main()
{
int i;
int a=2;
for(i=0;i<5;i++)
printf("sum(a)=%d\n",sum(a));
return 0;
}
$ gcc -o test test.c
$ ./test
a=2, c=1, b=6 sum(a)=9
a=2, c=1, b=7 sum(a)=10
a=2, c=1, b=8 sum(a)=11
a=2, c=1, b=9 sum(a)=12
a=2, c=1, b=10 sum(a)=13
5. 字符串常量
字符串常量存储在数据区中,其生存期为整个程序运行时间,但作用域为当前文件,示例如下:
#include <stdio.h>
char *a="hello";
void test()
{
char *c="hello";
if(a==c)
printf("yes,a==c\n");
else
printf("no,a!=c\n");
}
int main()
{
char *b="hello";
char *d="hello2";
if(a==b)
printf("yes,a==b\n");
else
printf("no,a!=b\n");
test();
if(a==d)
printf("yes,a==d\n");
else
printf("no,a!=d\n");
return 0;
}
$ gcc -o test test.c
$ ./test
yes,a==b
yes,a==c
no,a!=d
总结如下表:
6、静态函数
函数的定义和声明默认情况下是extern的,但静态函数只是在声明他的文件当中可见,不能被其他文件所用。
定义静态函数的好处:
<1> 其他文件中可以定义相同名字的函数,不会发生冲突
<2> 静态函数不能被其他文件所用。 存储说明符auto,register,extern,static,对应两种存储期:自动存储期和静态存储期。 auto和register对应自动存储期。具有自动存储期的变量在进入声明该变量的程序块时被建立,它在该程序块活动时存在,退出该程序块时撤销。
关键字extern和static用来说明具有静态存储期的变量和函数。用static声明的局部变量具有静态存储持续期(static storage duration),或静态范围(static extent)。虽然他的值在函数调用之间保持有效,但是其名字的可视性仍限制在其局部域内。静态局部对象在程序执行到该对象的声明处时被首次初始化。
由于static变量的以上特性,可实现一些特定功能。
两大周期:取指周期和执行周期
◆程序代码在CPU中的执行过程:
分析过程:以上指向的代码是y = x + y;
这里存储器就是内存
◆ DMA(direct memory access)
过程分析:主存RAM和IO之间的大规模的数据传递
CPU |
I/O |
RAM |
HRQ |
DREQ |
传输步骤:(对于CPU只关注数据交互的开始和结束状态(等待中断(数据传输完毕后向CPU申请中断,说明传输完毕))
1、 申请定位通道(I/O DMAC CPU )
2、 确定内存读写方向(开始读写的存储单元)
3、 确定内存读写地址(IO设备的地址)
4、 确定数据的大小
5、
让出总线控制权 |
处理中断程序
2、3、4:CPU让出总线控制权CPU DMAC IO
5:DMAC把总线控制权交给CPU,CPU执行一段检查本次DMA传输操作正确性的代码。最后,带着本次操作结果及状态继续执行原来的程序。
◆操作系统分时多任务操作系统的执行过程:
Cpu中只有一个程序 其他就绪程序都在主存储器(main memory)
◆ cache 高速缓冲存储器
Cache(一二级) |
Cache(硬盘缓冲) |
CPU 内存 硬盘
1、存在于主存与CPU之间的一级存储器
2、由静态存储芯片(SRAM)组成
3、容量比较小但速度比主存高得多 接近于CPU的速度。
主要由三大部分组成:
1、Cache存储体:存放由主存调入的指令(I-cache)与数据块(D-cache)。
2、地址转换部件:建立目录表以实现主存地址到缓存地址的转换。
3、替换部件:在缓存已满时按一定策略进行数据块替换,并修改地址转换部件。
大家知道计算机的主要硬件,硬盘,内存和处理器之间的速度是不一样的,其中处理器的速度是非常快的,内存次之,而硬盘的速度是很慢的(相对于处理器来说),一件任务的处理要通过处理器给出的指令,把相关数据从硬盘里调出来,到内存,在内存和处理器之间还会有许多数据的传输,内存本身不能处理数据,要通过处理器来处理,当他们一起工作的时候,由于处理器和内存工作得快,它们常在把事做完了没事做了,要等硬盘,这样就大大降低了系统的整体性能,不能发挥所有硬件的性能。硬盘与处理器之间的关系成了硬盘与内存和内存与处理器之间的双重关系。所以上面提到的瓶颈问题的处理归结为对内存的优化,即怎样处理好硬盘与内存之间的缓存和处理器与内存之间的缓存,这样高速缓冲存储器就应运而生了。
◆中断产生的详细过程:
解析:
1、对于操作系统来说中断结束后不一定执行原来的程序,要看此时有没有调度这个程序。
2、PSW:程序状态寄存器,是计算机系统的核心部件——控制器的一部分,存储两种状态:1、状态信息,如:有无溢出 2、控制信息,如:是否允许中断
3、PC:程序计数器,存放指令的地址。
4、处理器将PSW和PC压入控制栈,就是将中断的信息和程序执行的位置保存下来,然后根据相应信息去加载新的PC值,
◆内核运行的两种状态
中断上下文:中断时,内核不代表任何进程运行,一般不访问当前进程的数据结构,此时的上下文称中断上下文
进程上下文:陷入(或异常)到内核时,此时内核代表某个进程运行,一般要访问进程的数据结构,此时的上下文称进程上下文
◆详细解析进程上下文和中断上下文
进程上下文和中断上下文是操作系统中很重要的两个概念,这两个概念在操作系统课程中不断被提及,是最经常接触、看上去很懂但又说不清楚到底怎么回事。造成这种局面的原因,可能是原来接触到的操作系统课程的教学总停留在一种浅层次的理论层面上,没有深入去研究。
处理器总处于以下状态中的一种:
1、内核态,运行于进程上下文,内核代表进程运行于内核空间;
2、内核态,运行于中断上下文,内核代表硬件运行于内核空间;
3、用户态,运行于用户空间。
用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存器值、变量等。所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。
硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。
关于进程上下文LINUX完全注释中的一段话:
当一个进程在执行时,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够务必得到切换时的状态执行下去。在LINUX中,当前进程上下文均保存在进程的任务数据结构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中断服务结束时能恢复被中断进程的执行。
内核空间和用户空间是操作系统理论的基础之一,即内核功能模块运行在内核空间,而应用程序运行在用户空间。现代的CPU都具有不同的操作模式,代表不同的级别,不同的级别具有不同的功能,在较低的级别中将禁止某些操作。Linux系统设计时利用了这种硬件特性,使用了两个级别,最高级别和最低级别,内核运行在最高级别(内核态),这个级别可以进行所有操作,而应用程序运行在较低级别(用户态),在这个级别,处理器控制着对硬件的直接访问以及对内存的非授权访问。内核态和用户态有自己的内存映射,即自己的地址空间。
正是有了不同运行状态的划分,才有了上下文的概念。用户空间的应用程序,如果想要请求系统服务,比如操作一个物理设备,或者映射一段设备空间的地址到用户空间,就必须通过系统调用来(操作系统提供给用户空间的接口函数)实现。
通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的地址映射,通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行,所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
同理,硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。
Linux内核工作在进程上下文或者中断上下文。提供系统调用服务的内核代码代表发起系统调用的应用程序运行在进程上下文;另一方面,中断处理程序,异步运行在中断上下文。中断上下文和特定进程无关。
运行在进程上下文的内核代码是可以被抢占的(Linux2.6支持抢占)。但是一个中断上下文,通常都会始终占有CPU(当然中断可以嵌套,但我们一般不这样做),不可以被打断。正因为如此,运行在中断上下文的代码就要受一些限制,不能做下面的事情:
1、睡眠或者放弃CPU
这样做的后果是灾难性的,因为内核在进入中断之前会关闭进程调度,一旦睡眠或者放弃CPU,这时内核无法调度别的进程来执行,系统就会死掉
2、尝试获得信号量
如果获得不到信号量,代码就会睡眠,会产生和上面相同的情况
3、执行耗时的任务
中断处理应该尽可能快,因为内核要响应大量服务和请求,中断上下文占用CPU时间太长会严重影响系统功能。
4、访问用户空间的虚拟地址
因为中断上下文是和特定进程无关的,它是内核代表硬件运行在内核空间,所以在中断上下文无法访问用户空间的虚拟地址。
◆linux硬件中断处理
今天看了0.11核的关于硬件中断处理的基本原理,下面作一下总结:
一、I386中断处理原理
I386体系结构CPU中有两种中断,硬中断和软中断,硬中断是外部硬件产生的,软中断是程序中的某条指令或者程序对标志寄存器中某个标志的设置而产生的,与硬件电路无关。无论是硬件中断和软件中断都有各自的中断对应的处理程序,这些处理程序分布在内核中。那么系统是怎么根据不同的中断找到对应的处理过程的呢?当发生一个中断时候,如果是硬件中断,则CPU会再执行两个周期从对应的硬件中取到中断号,如果是软中断,则直接从指令中取得中断号,CPU再根据中断号在中断向量表中找到对应的中断处理程序的入口,并调用处理。在调用这些处理程序之前,会保护一些寄存器的值,这分为两种情况处理:
1)未发生特权级改变。这种情况下,当前进程是内核进程,由于中断处理程序也在内核中,所以程序的转移不会引起特权级变化。这个时候,CPU会将当前进程的EFLAGS、CS、IP压入堆栈,处理程序结束的时候,iret指令会从堆栈中恢复这些寄存器值。
2)特权级发生变化。这种情况下,当前进程是用户进程,由于中断处理程序在内核中,程序需要转移到内核的代码段和数据段,这个时候就会发生特权级的转变:从用户态专项核心态。这个时候要在将当前进程的EFLAGS、CS、IP压入堆栈之前,把原进程的SS、ESP也压入堆栈,因为,特权级的转变会引起堆栈的切换。处理程序结束的时候,iret指令会从堆栈中恢复这些寄存器的值。
二、Linux中断处理原理
CPU只是提供了发生中断时候,具体是怎么找到中断处理程序的而中断向量表的设置和中断处理过程需要操作系统提供。在Linux中,中断处理程序的描述如入口,也就是上面说的中断向量是中断门和陷阱门的形式,对应的中断向量表叫做中断描述符表IDT。
1)IDT的设置
IDT的设置是在main.c中初始化中调用trap_init()函数完成的,trap_init()在trap.c中定义,主要是调用set_trap_gate和set_system_gate填充中断号对应在IDT中的中断门和陷阱门。这两个函数的具体定义和实现在system.c中。
2)中断处理程序
Linux中的中断处理程序主要包括两个文件,asm.s和trap.c,asm.s用于实现大部分硬件异常所引起的中断的汇编处理过程。而trap.c则实现了asm.s的中断处理过程中调用的C函数。还有几个中断处理程序的实现在system_call.s和page.s中。
A、asm.s
该程序的主要处理方式是调用trap.c中对应的C函数,显示出错位置和出错码,然后退出中断。其中的处理分为两种情况,带出错号的中断处理和不带出错号的处理。
对于不带出错号的处理过程是这样的,在各自的不带中断号的处理过程中,将对应的C函数指针入栈,然后跳到no_error_code处,no_error_code做到工作是:
交换eax和栈顶值,目的是保存eax的值同时将函数指针值放入eax;
保护原进程的通用寄存器:将ebx,ecx,edx,edi,esi,ebp,ds,es,fs入栈;
将错误码入栈(均为0);
指向中断返回地址eip的栈指针esp的值入栈;
重新设置ds、es、fs。使其指向内核段。
调用C函数;
退栈,恢复各个寄存器的值,最后iret。
带中断号的处理过程与不带中断号的处理过程的不同有三,一个地方是在各自带中断号的处理层过程中,跳到error_code而不是no_error_code。再一个地方是开始的地方要做两次交换,一次是错误码和eax交换,一次使函数地址和ebx的交换。另一个地方是错误码入栈的是对应实际的错误码,而不是0。
B、trap.c
此程序包括一些asm.s中调用的C函数的定义和实现,并显示错误位置和错误码。还有一个就是IDT的初始化:trap_init()的定义和实现。在0.11核中,很多函数中基本上都是空的。
◆关于linux虚拟内存的管理策略:
关于进程及其内存分配
进程的概念:
一个正在执行的程序
一个正在计算机上执行的程序实例
能分配给处理器并有处理器执行的实体
具有以下特征的活动单元
一组执行的指令序列
一个当前状态
相关的系统资源集合
首先要明白一个概念:进程中使用的所有地址都是虚地址。
linux中进程可运行在用户态和内核态。
用户态:线性地址位于0~3G范围内(从虚拟地址0x000000000xBFFFFFFF)
内核态:线性地址范围为3G~4G (从虚拟地址0xC0000000到0xFFFFFFFF);
◆ 深入理解C程序内存布局
1、堆和栈的区别,堆和栈的最大限制
堆主要用来分配动态内存,操作系统提供了malloc等内存分配机制来供程序员进行堆内存的分配,同时,堆内存的释放需要程序员来进行。malloc分配的是虚拟地址空间,和用到的实实在在的物理内存是两码事,只有真正往空间里写东西了,os内核会触发缺页异常,然后才真正得到物理内存。32位Linux系统总共有4G内存空间,Linux把最高的1G(0xC0000000-0xFFFFFFFF)作为内核空间,把低地址的3G(0x00000000-0xBFFFFFFF)作为用户空间。malloc函数在这3G的用户空间中分配内存,能分配到的大小不超过3G,需除去栈、数据段(初始化及未初始化的)、共享so及代码段等占的内存空间。堆的地址空间是由低向高增长的(先分配低地址)。我用以下程序进行测试:
点击(此处)折叠或打开
1. #include <stdio.h>
2. #include <stdlib.h>
3.
4. int main(int argc, char *argv[])
5. {
6. char *ch = NULL;
7. unsigned int size = 2147 * 1000000;
8. ch = (char *)malloc(size);
9. if(ch == NULL)
10. perror("malloc failed\n");
11. else
12. printf("malloc success\n");
13. free(ch);
14. return 0;
16. }
发现最大能分配的内存约为2.147GB。
为什么这么说: 我们可以看malloc函数的原型void *malloc(size_t size); size_t 在stddef.h里定义的是unsigned int类型,故在ilp32平台上其最大取值是2147483647
栈由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。栈的地址空间是由高向低减少的(先分配高地址)。在Linux中,用ulimit -a命令可以看到栈的最大分配空间(stack size)是8192kB,即8MB多。
2、Linux中进程最大地址空间
Linux的虚拟地址空间也为0~4G。Linux内核将虚拟的4G字节的空间分为两部分。
将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为"内核空间"。将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为"用户空间"。因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。
其中,很重要的一点是虚拟地址空间,并不是实际的地址空间。进程地址空间是用多少分配多少,4G仅仅是最大限额而已。往往,一个进程的地址空间总是小于4G的,你可以通过查看/proc/pid/maps文件来获悉某个具体进程的地址空间。但进程的地址空间并不对应实际的物理页,Linux采用Lazy的机制来分配实际的物理页("Demand paging"和"和写时复制(Copy On Write)的技术"),从而提高实际内存的使用率。即每个虚拟内存页并不一定对应于物理页。虚拟页和物理页的对应是通过映射的机制来实现的,即通过页表进行映射到实际的物理页。因为每个进程都有自己的页表,因此可以保证不同进程从上到下(地址从高到低)依次为栈(函数内部局部变量),堆(动态分配内存),bss段(存未初始化的全局变量),数据段(存初始化的全局变量),文本段(存代码)同虚拟地址可以映射到不同的物理页,从而为不同的进程都可以同时拥有4G的虚拟地址空间提供了可能。
◆键盘敲一下到显示,电脑是怎么工作的
这一切都是由电脑中的CPU控制的,CPU就相当于人脑,所以计算机也称电脑。CPU速度不仅非常快,配合主板等硬件可以在极短的“同时”处理非常多的信息,其中有一个优先级非常高的中断就是实时监测硬盘按键,一旦发现有键被按下,会在第一时间处理这个按键事件,有如人被扎了一下(呵呵比喻有点不当)会有神经立即通知大脑去处理一样,CPU会根据键盘上不同的按键处理,最终反应到屏幕、音响上。
键盘一般由按键、导电塑胶、编码器以及接口电路等组成。在键盘上通常有上百个按键,每个按键负责一个功能,当用户按下其中一个时,键盘中的编码器能够迅速将此按键所对应的编码通过接口电路输送到计算机的键盘缓冲器中,由CPU进行识别处理。通俗地说也就是当用户按下某个按键时,它会通过导电塑胶将线路板上的这个按键排线接通产生信号,产生了的信号会迅速通过键盘接口传送到CPU,之后cpu控制下产生数字信号经过显卡显示到电脑显示器。
不知道你要低到什么程度。按下键盘a字母时,输入设备将键盘按下的坐标(编码)保存在寄存器中,同时向cpu发送一个中断请求。cpu保存目前运行状态后响应中断请求,从寄存器中读取信息,返回保存前状态,根据当前状态作出相应操作。如果系统是等待输入状态,则将信息送往信息处理程序,处理后将信息发送到输出设备--显示器。中间的处理过程、如何显示就不再细说了。
虚拟内存的管理:(三大方面)
给进程分配地址空间
交换
页和段的管理
linux内核对整个系统的物理内存是通过类型为struct page的数组mem_map来管理的。系统中的伙伴系统分配算法最终是通过操作这个数组来记录物理内存的分配、回收等操作。在这里不要被系统的高端内存、低端内存等概念搞混淆了,高、低端内存的分类主要在于区分物理内存地址是否可以直接映射到内核线性地址空间中。
我们知道,linux的内核地址空间大小为1G(用户空间0~3G,内核空间3G~4G,这种分法最常见),因此如果把这1G线性地址空间全部拿来直接一一映射物理内存的话,在内核态的所有进程(线程)能使用的物理内存总共最多只有1G,为了能使在内核态的所有进程能使用更多的物理内存,linux采取了一种变通的形式:它将1G内核线性地址空间分为几部分,第一部分为1G的前896M,这部分内核线性空间与物理内存的0~896M一一映射(相差一个为0xc0000000(3G)的常数),后面128M的线性空间拿来动态映射剩下的所有物理内存,由于动态映射的方法不一样,后面的128M又分成了几个部分,有兴趣的可以查看相关资料。在这里,前面896M线性空间对应的物理内存就是所谓的低端物理内存,剩下的物理内存就是高端物理内存。
从上面高、低端物理内存命名的由来我们可以知道,高、低端物理内存与具体的内存分配算法无关,它们都是被mem_map数组控制起来,再由伙伴分配系统实施管理。
为了把线性地址转化为物理地址,每个进程都有自己私有的页目录和页表。
linux在建立进程页目录时,把用户地址空间的页目录项(0~767项)清空而将内核页目录表(swapper_pg_dir)的第768项到1023项拷贝到进程的页目录表的第768项到1023项中。由于内核在初始化时也只映射了物理内存的前896M,我们可以知道内核目录表也只能保证第768项开始的224项中有有效映射。从这里我们可以知道,所有的进程都共享了其内核线性地址空间。
当一个进程在内核空间发生缺页故障的时候,这主要发生在访问内核空间动态映射区线性地址,在其处理程序中,就要通过0号进程的页目录(swapper_pg_dir)来同步本进程的内核页目录,实际上就是拷贝0号进程的内核页目录到本进程中(内核页表与进程0共享,故不需要复制)。如果进程0的该地址处的内核页目录也不存在,则出错,具体代码可以参考vmalloc的实现源码。
当进程运行于用户态时,若其需要申请内存空间,内核首先会在其用户线性空间中分配需要的线性地址空间,再通过伙伴分配系统分配物理内存并把分配的物理内存跟用户空间线性地址映射起来,最后再修改进程的页目录项及页表项写入这些映射关系。
◆CPU、内存和硬盘之间的关系
CPU是负责运算和处理的,内存是交换数据的。
当程序或者操作者对CPU发出指令,这些指令和数据暂存在内存里,在CPU空闲时传送给CPU,CPU处理后把结果输出到输出设备上,输出设备就是显示器,打印机等。在没有显示完之前,这些数据也保存在内存里,如果内存不足,那么系统自动从硬盘上划分一部分空间作为虚拟内存来用。但写入和读取的速度跟物理内存差的很远很远,所以,在内存不足的时候,会感到机器反应很慢,硬盘一直在响。
512M的物理内存如果增加到2GB,你会感到电脑变得飞快。但内存512,即使你把CPU从单核换成双核,加速感觉也不明显。
如果你本来就有2G内存,再增加2G,使用起来几乎没有多少性能的改变。
在理论上,物理内存太大反而会减慢速度,因为它增加了寻址的时间。
所以家用机器推荐使用2GB-4GB足矣。
电脑是企业,内存是车间,CPU是生产线,硬盘是仓库,主板是地基,
CPU速度快,生产就快,内存大,一次处理的原材料就多,
所以提高机器速度有两条路,一是CPU升级,一是扩大内存,一次处理更多的信息产品,
但CPU与内存又互相制约,车间再大,CPU慢也快不起来,CPU快,但车间小,一次送来的加工材料没多少,也快不了
◆几种地址和虚拟内存之间的关系(要会通过实例圆满解释)
逻辑地址(Logical Address)
是指由程序产生的与段相关的偏移地址部分。
例如,你在进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于你当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,Cpu不进行自动地址转换);逻辑也就是在Intel 保护模式下程序执行代码段限长内的偏移地址(假定代码段、数据段如果完全一样)。应用程序员仅需与逻辑地址打交道,而分段和分页机制对您来说是完全透明的,仅由系统编程人员涉及。应用程序员虽然自己可以直接操作内存,那也只能在操作系统给你分配的内存段操作。
线性地址(Linear Address)(也就是虚拟地址)
是逻辑地址到物理地址变换之间的中间层。
程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386的线性地址空间容量为4G(2的32次方即32根地址总线寻址)。
1、分页单元中,页目录的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。
2、每一个进程,都有其独立的虚拟地址(线性地址)空间,运行一个进程,首先需要将它的页目录地址放到cr3寄存器中,将其他进程的保存下来。
3、每一个32位的线性地址被划分为三部分:页目录索引(10位):页表索引(10位):偏移(12位)
依据以下步骤进行地址转换:
1、装入进程的页目录地址(操作系统在调度进程时,把这个地址
装入CR3)
2、根据线性地址前十位,在页目录中,找到对应的索引项,页目
录中的项是一个页表的地址
3、根据线性地址的中间十位,在页表中找到页的起始地址
4、将页的起始地址与线性地址的最后12位相加,得到物理地址
物理地址(Physical Address)
是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。
如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
虚拟内存(Virtual Memory)
是指计算机呈现出要比实际拥有的内存大得多的内存量。
因此它允许程序员编制并运行比实际系统拥有的内存大得多的程序。这使得许多大型项目也能够在具有有限内存资源的系统上实现。一个很恰当的比喻是:你不需要很长的轨道就可以让一列火车从上海开到北京。你只需要足够长的铁轨(比如说3公里)就可以完成这个任务。采取的方法是把后面的铁轨立刻铺到火车的前面,只要你的操作足够快并能满足要求,列车就能象在一条完整的轨道上运行。这也就是虚拟内存管理需要完成的任务。在Linux 0.11内核中,给每个程序(进程)都划分了总容量为64MB的虚拟内存空间。因此程序的逻辑地址范围是0x0000000到0x4000000(即64 M)。
与物理地址空间类似,线性地址空间也是平坦的4GB地址空间,地址范围从0到0xFFFFFFFF,线性空间中含有为系统定义的所有段和系统表。
有时我们也把逻辑地址称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的。
逻辑地址与物理地址的“差距”是0xC0000000,是由于虚拟地址->线性地址->物理地址映射正好差这个值。这个值是由操作系统指定的。
虚拟地址到物理地址的转化方法是与体系结构相关的。一般来说有分段、分页两种方式。以现在的x86 cpu为例,分段分页都是支持的。Memory Mangement Unit负责从虚拟地址到物理地址的转化。逻辑地址是段标识+段内偏移量的形式,MMU通过查询段表,可以把逻辑地址转化为线性地址。如果cpu没有开启 分页功能,那么线性地址就是物理地址;如果cpu开启了分页功能,MMU还需要查询页表来将线性地址转化为物理地址:
逻辑地址 ----(段表)---> 线性地址 — (页表)—> 物理地址
不同的逻辑地址可以映射到同一个线性地址上;不同的线性地址也可以映射到同一个物理地址上;所以是多对一的关系。另外,同一个线性地址,在发生换页以后,也可能被重新装载到另外一个物理地址上。所以这种多对一的映射关系也会随时间发生变化。
◆linux的内存管理
Linux内核的设计并没有全部采用Intel所提供的段机制,仅仅是有限度地使用了分段机制。这不仅简化了Linux内核的设计,而且为把Linux移植到其他平台创造了条件,因为很多RISC(精简指令集计算机(Reduced Instruction-Set Computer))处理器并不支持段机制。
由此可以得出,每个段的逻辑地址空间范围为0-4GB。因为每个段的基地址为0,因此,逻辑地址与线性地址保持一致(即逻辑地址的偏移量字段的值与线性地址的值总是相同的),在Linux中所提到的逻辑地址和线性地址(虚拟地址),可以认为是一致的。看来,Linux巧妙地把段机制给绕过去了,而完全利用了分页机制。
前面介绍了i386的二级页管理架构,不过有些CPU(Alpha 64位)使用三级,甚至四级架构,Linux 2.6.29内核为每种CPU提供统一的界面,采用 了四级页管理架构,来兼容二级、三级、四级管理架构的CPU。
这四级分别为:
1. 页全局目录 (Page Global Directory):即pgd,是多级页表的抽象最高层。
2. 页上级目录(Page Upper Directory):即pud。
3. 页中间目录(Page Middle Directory):即pmd,是页表的中间层。
4. 页表(Page Table Entry):即 pte。
◆分布式操作系统:
◆单体内核与微内核体系结构
大内核:提供:调度,文件系统,网络,设备驱动,存储管理等。作为一个进程实现,所有的元素共享相同的地址空间
例如:Unix 类系统,Linux
微内核:内核仅仅包含一些基本的功能:地址空间,进程间通信,基本的调度。其他的都由服务程序提供。
例如:Windows系统
◆操作系统的几大功能
1、程序开发
2、程序运行
3、I/O设备访问
4、文件访问控制
5、系统访问
6、错误检测与响应
7、审计
◆操作系统的特点
◆文件I/O操作部分:
C标准I/O库函数 Unbuffered I/O函数
fopen open
fdopen creat
fclose close
fseek lseek
fread read
fwrite write
◆看看C标准I/O库函数是如何用系统调用实现的
fopen( )
调用open( )打开指定的文件,返回一个文件描述符(就是一个int类型的编号),分配一个FILE结构体,其中包含该文件的描述符、I/O缓冲区和当前读写位置等信息,返回这个FILE结构体的地址。
fgetc( )
通过传入的FILE *参数找到该文件的描述符、I/O缓冲区和当前读写位置,判断能否从I/O缓冲区中读到下一个字符,如果能读到就直接返回该字符,否则调用read( ),把文件描述符传进去,让内核读取该文件的数据到I/O缓冲区,然后返回下一个字符。注意,对于C标准I/O库来说,打开的文件由FILE *指针标识,而对于内核来说,打开的文件由文件描述符标识,文件描述符从open系统调用获得,在使用read、write、close系统调用时都需要传文件描述符。
fputc( )
判断该文件的I/O缓冲区是否有空间再存放一个字符,如果有空间则直接保存在I/O缓冲区中并返回,如果I/O缓冲区已满就调用write(2),让内核把I/O缓冲区的内容写回文件。
fclose( )
如果I/O缓冲区中还有数据没写回文件,就调用write( )写回文件,然后调用close( )关闭文件,释放FILE结构体和I/O缓冲区。
以写文件为例,C标准I/O库函数(printf( )、putchar( )、fputs( ))与系统调用write( )的关系如下图所示。
图 28.1. 库函数与系统调用的层次关系
open、read、write、close等系统函数称为无缓冲I/O(Unbuffered I/O)函数,因为它们位于C标准库的I/O缓冲区的底层。用户程序在读写文件时既可以调用C标准I/O库函数,也可以直接调用底层的Unbuffered I/O函数,那么用哪一组函数好呢?
用Unbuffered I/O函数每次读写都要进内核,调一个系统调用比调一个用户空间的函数要慢很多,所以在用户空间开辟I/O缓冲区还是必要的,用C标准I/O库函数就比较方便,省去了自己管理I/O缓冲区的麻烦。
用C标准I/O库函数要时刻注意I/O缓冲区和实际文件有可能不一致,在必要时需调用fflush( )。
我们知道UNIX的传统是Everything is a file,I/O函数不仅用于读写常规文件,也用于读写设备,比如终端或网络设备。在读写设备时通常是不希望有缓冲的,例如向代表网络设备的文件写数据就是希望数据通过网络设备发送出去,而不希望只写到缓冲区里就算完事儿了,当网络设备接收到数据时应用程序也希望第一时间被通知到,所以网络编程通常直接调用Unbuffered I/O函数。
C标准库函数是C标准的一部分,而Unbuffered I/O函数是UNIX标准的一部分,在所有支持C语言的平台上应该都可以用C标准库函数(除了有些平台的C编译器没有完全符合C标准之外),而只有在UNIX平台上才能使用Unbuffered I/O函数,所以C标准I/O库函数在头文件stdio.h中声明,而read、write等函数在头文件unistd.h中声明。
在支持C语言的非UNIX操作系统上,标准I/O库的底层可能由另外一组系统函数支持,例如Windows系统的底层是Win32 API,其中读写文件的系统函数是ReadFile、WriteFile。
现在该说说文件描述符了。每个进程在Linux内核中都有一个task_struct结构体来维护进程相关的信息,称为进程描述符(Process Descriptor),而在操作系统理论中称为进程控制块(PCB,Process Control Block)。task_struct中有一个指针指向files_struct结构体,称为文件描述符表,其中每个表项包含一个指向已打开的文件的指针,如下图所示。
图 28.2. 文件描述符表
至于已打开的文件在内核中用什么结构体表示,我们将在下一章详细介绍,目前我们在画图时用一个圈表示。用户程序不能直接访问内核中的文件描述符表,而只能使用文件描述符表的索引(即0、1、2、3这些数字),这些索引就称为文件描述符(File Descriptor),用int型变量保存。当调用open打开一个文件或创建一个新文件时,内核分配一个文件描述符并返回给用户程序,该文件描述符表项中的指针指向新打开的文件。当读写文件时,用户程序把文件描述符传给read或write,内核根据文件描述符找到相应的表项,再通过表项中的指针找到相应的文件。
我们知道,程序启动时会自动打开三个文件:标准输入、标准输出和标准错误输出。在C标准库中分别用FILE *指针stdin、stdout和stderr表示。这三个文件的描述符分别是0、1、2,保存在相应的FILE结构体中。头文件unistd.h中有如下的宏定义来表示这三个文件描述符:
#define STDIN_FILENO 0
#define STDOUT_FILENO 1
#define STDERR_FILENO 2
事实上Unbuffered I/O这个名词是有些误导的,虽然write系统调用位于C标准库I/O 底层,但在write的底层也可以分配一个内核I/O缓冲区,所以write也不一定是直接写到文件的,也可能写到内核I/O缓冲区中,至于究竟写到了文件中还是内核缓冲区中对于进程来说是没有差别的,如果进程A和进程B打开同一文件,进程A写到内核I/O缓冲区中的数据从进程B也能读到,而C标准库的I/O缓冲区则不具有这一特性(想一想为什么)。
◆知识小结:
1. C标准I/O库函数
1.1 文件的创建,打开与关闭
原型为:
#include <stdio.h>
FILE *fopen(const char *path,const char *mode);
FILE *fdopen(int fd,const char *mode);
int fclose(FILE *stream);
fopen 以 mode 的方式打开或创建文件,如果成功,将返回一个文件指针,失败则返回 NULL.fopen 创建的文件的访问权限将以 0666 与当前的 umask 结合来确定。
mode 的可选模式列表
模式 读 写 位置 截断原内容 创建
rb Y N 文件头 N N
r+b Y Y 文件头 N N
wb N Y 文件头 Y Y
w+b Y Y 文件头 Y Y
ab N Y 文件尾 N Y
a+b Y Y 文件尾 N Y
在 Linux 系统中,mode 里面的’b’(二进制)可以去掉,但是为了保持与其他系统的兼容性,建议不要去掉。
ab 和 a+b 为追加模式,在此两种模式下,无论文件读写点定位到何处,在写数据时都将是在文件末尾添加,所以比较适合于多进程写同一个文件的情况下保证数据的完整性。
fdopen 根据已经打开的文件描述符打开一文件指针,对同一个文件既打开文件描述符又打开文件指针,将容易出现问题,但是在多进程程序中,往往需要传递文件描述符,所以此类混合文件操作必须掌握。(不明白)
1.2 读写文件
基于文件指针的数据读写函数较多,可分为如下几组:
数据块读写:
#include <stdio.h>
size_t fread(void *ptr,size_t size,size_t nmemb,FILE *stream);
size_t fwrite(void *ptr, size_t size, size_t nmemb, FILE *stream);
fread 从文件流 stream 中读取 nmemb 个元素,写到 ptr 指向的内存中,每个元素的大小为 size 个字节.
fwrite 从 ptr 指向的内存中读取 nmemb 个元素,写到文件中,每个元素 size 个字节。
所有的文件读写函数都从文件的当前读写点开始读写,读写完以后,当前读写点自动往后移动 size*nmemb 个字节。
1.3 文件定位:
文件定位指读取或设置文件当前读写点,所有的通过文件指针读写数据的函数,都是从文件的当前读写点读写数据的。
常用的函数有:
#include <stdio.h>
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);
fseek 设置当前读写点到 offset 处,
whence 可以是 SEEK_SET,SEEK_CUR,SEEK_END,这些值决定是
从文件头、当前点和文件尾计算偏移量 offset.
ftell 获取当前的读写点
rewind 将文件当前读写点移动到文件头
1.4 目录操作
获取目录信息:
原型为:
#include <sys/types.h>
#include <dirent.h>
DIR *opendir(const char *name); //打开一个目录
struct dirent *readdir(DIR *dir); //读取目录的一项信息,并返回该项信息的结构体指针
void rewinddir(DIR *dir); //重新定位到目录文件的头部
int closedir(DIR *dir); //关闭目录文件
读取目录信息的步骤为:
1> 用 opendir 函数打开目录;
2> 使用 readdir 函数迭代读取目录的内容,如果已经读取到目录末尾,又想重新开始读,则可以使用rewinddiw 函数将文件指针重新定位到目录文件的起始位置;
3> 用 closedir 函数关闭目录
1.5. 标准输入/输出流
在进程一开始运行,就自动打开了三个对应设备的文件,它们是标准输入、输出、错误流,分别用全局文件指针 stdin、stdout、stderr 表示,stdin 具有可读属性,缺省情况下是指从键盘的读取输入,stdout 和 stderr 具有可写属性,缺省情况下是指向屏幕输入数据。
2、Unbuffered I/O函数
2.1. 打开、创建和关闭文件
open 和 creat 都能打开和创建函数,原型为
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int creat(const char *pathname, mode_t mode);
flags 和 mode 都是一组掩码的合成值,flags 表示打开或创建的方式,mode 表示文件的访问权限。
flags 的可选项有:
掩码 含义
O_RDONLY 以只读的方式打开
O_WRONLY 以只写的方式打开
O_RDWR 以读写的方式打开
O_CREAT 如果文件不存在,则创建文件
O_EXCL 仅与 O_CREAT 连用,如果文件已存在,则强制 open失败
O_TRUNC 如果文件存在,将文件的长度截至 0
O_APPEND 已追加的方式打开文件,每次调用 write 时,文件指针自动先移到文件尾,用于多进程写同一个文件的情况
O_NONBLOCK 非阻塞方式打开
O_NODELAY 非阻塞方式打开
O_SYNC 只有在数据被真正写入物理设备设备后才返回
int creat(const char *pathname,mode_t mode);
等价于
open(pathname,O_CREAT|O_TRUNC|O_WRONLY,mode);
文件使用完毕后,应该调用 close 关闭它,一旦调用 close,则该进程对文件所加的锁全都被释放,并且使文件的打开引用计数减 1,只有文件的打开引用计数变为 0 以后,文件才会被真正的关闭,文件引用计数主要用于多进程之间文件描述符的传递。
2.2. 读写文件
读写文件的函数原型为:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
2.3. 文件定位
函数 lseek 将文件指针设定到相对于 whence,偏移值为 offset 的位置
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fildes, off_t offset, int whence);
whence 可以是下面三个常量的一个
SEEK_SET 从文件头开始计算
SEEK_CUR 从当前指针开始计算
SEEK_END 从文件尾开始计算
2.4. 文件的锁定
在多进程对同一个文件进行读写访问时,为了保证数据的完整性,有时需要对文件进行锁定。可以通过 fcntl 对文件进行锁定和解锁。
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, struct flock *lock);
参数 cmd 置为 F_GETLK 或 F_SETLK 可以获取或设置文件锁定信息。
参数 struct flock 为文件锁信息。
文件锁有两种类型,读取锁(共享锁)和写入锁(互斥锁)。对于已经加读取锁的文件,再加写入锁将会失败,但是允许其它进程继续加读取锁;对于已经加写入锁的文件,再加读取锁和写入锁都将会失败。
注意:文件锁只会对其它试图加锁的进程有效,对直接访问文件的进程无效。
2.5.文件描述符的复制
函数 dup 和 dup2 可以实现文件描述符的复制。原型为:
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
文件描述符的复制是指用另外一个文件描述符指向同一个打开的文件,它完全不同于直接给文件描述符变量赋值,
例如:
描述符变量的直接赋值:
char szBuf[32];
int fd=open(“./a.txt”,O_RDONLY);
int fd2=fd;
close(fd); //导致文件立即关闭
printf(“read:%d\n”,read(fd2),szBuf,sizeof(szBuf)-1);
close(fd2); //读取失败,无意义了
在此情况下,两个文件描述符变量的值相同,指向同一个打开的文件,但是内核的文件打开引用计数还是为 1,所以 close(fd)或者 close(fd2)都会导致文件立即关闭掉。
描述符的复制:
char szBuf[32];
int fd=open(“./a.txt”,O_RDONLY);
int fd2=dup(fd);
close(fd); //当前还不会导致文件被关闭,此时通过 fd2 照样可以访问文件
printf(“read:%d\n”,read(fd2),szBuf,sizeof(szBuf)-1);
close(fd2); //内核的引用计数变为 0,文件正式关闭
dup2(int fdold,int fdnew)也是进行描述符的复制,只不过采用此种复制,新的描述符由用户用参数 fdnew 显示指定,而不是象 dup 一样由内核帮你选定。对于 dup2,如果 fdnew 已经指向一个已经打开的文件,内核会首先关闭掉fdnew 所指向的原来的文件。如果成功 dup2 的返回值于 fdnew 相同,否则为-1.
2.6.标准输入输出文件描述符
与标准的输入输出流对应,在更底层的实现是用标准输入、标准输出、标准错误文件描述符表示的。它们分别用STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO 三个宏表示,值分别是 0、1、2 三个整型数字。
我的小结:
在对两类文件操作的学习之后,常用的文件打开,创建,读写和关闭,
以及文件指针的定位,文件大小的求取等常用的操作都能掌握,在实际应用的过程出错不多,不过也有,曾经犯过fread读操作的错误,例如,在fread(void *ptr, size_t size, size_t nmemb, FILE *stream)时;有时候一次读size(自定义一个较大的值)个字节,导致读失败,特别是在文件读取最后一次不足size个字节时,现在已经能正常应用,读的时候一个字节一个字节的读,写的时候一次写fread返回值那么多个字节,就能保证文件的读写不会出现两文件或文件和数组里面的内容的个数或大小不相同。
◆进程的基本元素(进程控制块)
栈 堆 代码段 数据段 |
数据块 |
1、标志符(进程的PID)
2、状态(执行、就绪、阻塞、挂起)
3、优先级(在消息队列中)
4、程序计数器(配合PC寄存器)
5、内存指针(分配的空间)
6、上下文数据(context)
7、IO的状态信息(打开了哪些IO设备)
8、审计信息()
◆进程的轨迹
解析:A、C都是按时间片正常执行的;
而B遇中断将处于阻塞态,等外部中断结束时,由阻塞态进入就绪态,继续执行,例如:手机在运行MP3和QQ时,忽然打来了一个电话,这是MP3就会进入阻塞态,
◆进程终止的原因(举例6个)
1、越界访问(数组)
2、除0溢出(inf)
3、无效指令(vim错写成vin)
4、数据误用(指针段错误)
5、父进程终止(终止所有子进程)
6、父进程请求(父进程有终止所有子进程的权利)
运行的在处理器中 |
◆未运行状态在队列中的排队
内存中 |
1 处理器中只有一个程序在执行,而所有的就绪队列,阻塞队列都在内存中,而挂起状态在辅存中。
2分派器应该选择未运行,但就绪的队列中等待时间最长的进程。
◆ 五状态模型
◆进程的控制结构
1、进程的位置
数据和程序保存需要内存
跟踪过程调用和过程间参数传递的栈
相关联的操作系统控制进程的很多属性(属性的集合就是进程控制块 Process Control Block:PCB)
2、进程映像:
◆进程的创建
分配进程ID
唯一的
分配空间
初始化进程控制块
设置正确的连接?
创建和扩充其他的数据结构
◆进程切换的时机
时钟中断
进程允许执行的时间片
IO中断
内存失效?
进程的地址是虚拟地址
包含这个虚拟地址的内存块调入主存中
Trap
发生错误或异常
进程被转换到退出状态
系统调用
比如打开文件
通常导致进程为阻塞状态
◆进程切换时,进程的状态变化
保存处理器上下文
PC 和 其他的寄存器
更新当前处于运行态的进程的进程控制块
进程的状态改变
把更新的进程的进程控制块移到相应的队列
选择另一个进程执行
更新所选择的进程控制块
进程的状态为运行态
更新内存管理的数据结构
管理地址转换
恢复新进程的处理器上下文
---------------------------------------华丽的分割线---------------------------------
***********************************************************
以下是linux子系统管理有关的知识
**********************************************************
◆进程的有关指令
查看进程 ps –aux
查看当前进程和其父进程 ps -e -o pid,ppid,command
◆ 进程的四要素(必须掌握)
1. 有一段程序供其执行。这段程序不一定是某个进程所专有,可以与其他进程共用。
2. 有进程专用的内核空间堆栈。
3. 在内核中有一个task_struct数据结构,即通常所说的“进程控制块”(PCB)。有了这个数据结构,进程才能成为内核调度的一个基本单位接受内核的调度(因为处理器处理进程时是需要进程的PID和状态信息等)。
4. 有独立的用户空间。
◆进程的优点和缺点
优点:使多个程序并发执行
缺点:程序并发执行时付出了巨大的时空开销,每个进程在进行切换时身上带了过多的“累赘”导致系统效率降低。
◆linux进程描述
在Linux中,线程、进程都使用struct task_struct来表示,它包含了大量描述进程/线程的信息,其中比较重要的有:
■pid_t pid;
进程号,最大值10亿
■volatile long state /* 进程状态 */(必须掌握)
1. TASK_RUNNING(运行态)
进程正在被CPU执行,或者已经准备就绪,随时可以执行。当一个进程刚被创建时,就处于TASK_RUNNING状态。
2. TASK_INTERRUPTIBLE(可中断)
处于等待中的进程,待等待条件为真时被唤醒,也可以被信号或者中断唤醒。
3. TASK_UNINTERRUPTIBLE(不可中断)
处于等待中的进程,待资源有效时唤醒,但不可以由其它进程通过信号(signal)或中断唤醒。
4. TASK_STOPPED(进程终止执行)
进程中止执行。当接收到SIGSTOP和SIGTSTP等信号时,进程进入该状态,接收到SIGCONT信号后,进程重新回到TASK_RUNNING。
5. TASK_KILLABLE
Linux2.6.25新引入的进程睡眠状态,原理类似于TASK_UNINTERRUPTIBLE,但是可以被致命信号(SIGKILL)唤醒。
6. TASK_TRACED
正处于被调试状态的进程。
7. TASK_DEAD
进程退出时(调用do_exit),state字段被设置为该状态。
■int exit_state /*进程退出时的状态*/
1、EXIT_ZOMBIE(僵死进程)
表示进程的执行被终止,但是父进程还没有发布waitpid()系统调用来收集有关死亡的进程的信息。
2、EXIT_DEAD(僵死撤销状态)
表示进程的最终状态。父进程已经使用wait()或waitpid()系统调用来收集了信息,因此进程将由系统删除。
◆ linux进程的状态图
有上图可知,linux操作系统进程的状态模型和计算机操作系统的基本上是一样的,也是五状态模型。
◆Task_struct位置(必须掌握)
在2.4内核中是用1k的空间在栈的底部存放tast_struct,但随着运行的进程数量增加,task_struct大小增大并不断侵占栈空间,为了解决这个问题,在2.6内核中就改变成了上图的结构,把thread_info structure替换原来的task_struct的位置,利用它的指针指向process Descriptor, 在Linux中用current指针指向当前正在运行的进程的task_struct。
◆linux进程调度
■什么是调度? 从就绪的进程中选出最适合的一个来执行。
■学习调度需要掌握哪些知识点?
1、调度策略
2、调度时机
3、调度步骤
■调度策略
1)SCHED_NORMAL(SCHED_OTHER):普通的分时进程
2)SCHED_FIFO :先入先出的实时进程
3)SCHED_RR:时间片轮转的实时进程
4)SCHED_BATCH:批处理进程
5)SCHED_IDLE: 只在系统空闲时才能够被调度执行的进程
■调度类
调度类的引入增强了内核调度程序的可扩展性,这些类(调度程序模块)封装了调度策略,并将调度策略模块化。
1)CFS 调度类(在 kernel/sched_fair.c 中实现)用于以下调度策略:SCHED_NORMAL、SCHED_BATCH 和 SCHED_IDLE。
2)实时调度类(在 kernel/sched_rt.c 中实现)用于SCHED_RR 和 SCHED_FIFO 策略。
■pick_next_task:选择下一个要运行的进程
◆调度时机
调度什么时候发生?即:schedule()函数什么时候被调用?
◆调度的发生有两种方式:
■主动式
在内核中直接调用schedule()。当进程需要等待资源等而暂时停止运行时,会把状态置于挂起(睡眠),并主动请求调度,让出CPU。
主动放弃cpu例:
1. current->state = TASK_INTERRUPTIBLE;
2. schedule();
■被动式(抢占)
用户抢占(Linux2.4、Linux2.6)
内核抢占(Linux2.6)
1、用户抢占
用户抢占发生在:
1)从系统调用返回用户空间。
2)从中断处理程序返回用户空间。
内核即将返回用户空间的时候,如果need_resched标志被设置,会导致schedule()被调用,此时就会发生用户抢占。
2、内核抢占
1)在不支持内核抢占的系统中,进程/线程一旦运行于内核空间,就可以一直执行,直到它主动放弃或时间片耗尽为止。这样一些非常紧急的进程或线程将长时间得不到运行。
2)在支持内核抢占的系统中,更高优先级的进程/线程可以抢占正在内核空间运行的低优先级进程/线程。
■在支持内核抢占的系统中,某些特例下是不允许内核抢占的:
1)内核正进行中断处理。进程调度函数schedule()会对此作出判断,如果是在中断中调用,会打印出错信息。
2)内核正在进行中断上下文的Bottom Half(中断的底半部)处理。硬件中断返回前会执行软中断,此时仍然处于中断上下文中。
3)进程正持有spinlock自旋锁、writelock/readlock读写锁等,当持有这些锁时,不应该被抢占,否则由于抢占将导致其他CPU长期不能获得锁而死等。
4)内核正在执行调度程序Scheduler。抢占的原因就是为了进行新的调度,没有理由将调度程序抢占掉再运行调度程序。
■为保证Linux内核在以上情况下不会被抢占,抢占式内核使用了一个变量preempt_count,称为内核抢占计数。这一变量被设置在进程的thread_info结构中。每当内核要进入以上几种状态时,变量preempt_count就加1,指示内核不允许抢占。每当内核从以上几种状态退出时,变量preempt_count就减1,同时进行可抢占的判断与调度。比如说:中断嵌套,多层嵌套就会加几次,中断处理函数结束后就会逐个减一。
■内核抢占可能发生在:
1)中断处理程序完成,返回内核空间之前。
2)当内核代码再一次具有可抢占性的时候,如解锁及使能软中断等。
■调度标志 TIF_NEED_RESCHED
作用:内核提供了一个need_resched标志来表明是否需要重新执行一次调度。
设置:
1)当某个进程耗尽它的时间片时,会设置这个标志;
2)当一个优先级更高的进程进入可执行状态的时候,也会设
置这个标志。
◆调度步骤
Schedule函数工作流程如下:
1). 清理当前运行中的进程;
2). 选择下一个要运行的进程;
(pick_next_task 分析)
3). 设置新进程的运行环境;
4). 进程上下文切换
◆ 信号定义
查看系统文件中的信号定义:vim /usr/include/bits/signum.h
◆进程和线程的区别和联系
进程和线程的区别在于:(这四点很重要,要熟练掌握)
1、进程是系统资源分配的最小单位,线程是系统调度的最小单位
2、进程有独立的内存单元,而线程是共享进程的堆栈,数据段,代码段(而进程只是共享代码段,且进程是分写时复制的功能(需要用时才共享堆栈数据))
3、线程必须依存于进程才能执行,应用程序提供多个线程执行控制。
4、进程执行过程中切换会浪费更多的资源,而线程则被称为轻量型进程。
1、线程的划分尺度小于进程,使得多线程程序的并发性高。
2、进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
3、 线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
4、从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。这就是进程和线程的重要区别。
进程和线程都是由操作系统所体会的程序运行的基本单元,系统利用该基本单元实现系统对应用的并发性。
一般你运行一个应用程序,就生成了一个进程, 这个进程拥有自己的内存空间, 这个进程还可以内部生成多个线程, 这些线程之间共用一个进程的内存空间,所以线程之间共享内存是很容易做到的,多线程协作比多进程协作快一些,而且安全。
◆C标准库函数创建进程
system ("ls -l /home");
◆ fork函数创建进程(通过复制当前进程来创建一个子进程)
1.成功:在父进程中返回子进程的PID,在子进程中返回0
2.失败:返回-1,子进程没有创建。
解析:fork()函数通过系统调用创建一个与原来进程(父进程)几乎完全相同的进程(子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。注意,子进程持有的是上述存储空间的“副本”,这意味着父子进程间不共享这些存储空间。linux将复制父进程的地址空间内容给子进程,因此,子进程有了独立的地址空间。),也就是这两个进程做完全相同的事。
◆exec族函数创建进程(负责读取可执行文件并将其载入地址空间)
char *argv[]={"ls","-l","/home/hou",NULL};
execl("/home/jincheng/test","test","aa","bb","cc",NULL,NULL);
exec("/bin/ls","ls","-l","/home/hou",NULL);
execvp("/bin/ls",argv);
execlp("ls","ls","-l","/home/hou",NULL);
解析:在fork后的子进程中使用exec函数族,可以装入和运行其它程序(子进程替换原有进程,和父进程做不同的事)。fork创建一个新的进程就产生了一个新的PID,exec启动一个新程序,替换原有的进程,因此这个新的被 exec 执行的进程的PID不会改变(和调用exec的进程的PID一样)。
◆ 父进程为什么要创建子进程
在程序设计时,某一个具体的功能模块可以通过函数或是线程等不同的形式来实现。对于同一进程而言,这些函数、线程都是存在于同一个地址空间下的,而且在执行时,大多只对与其相关的一些数据进行处理。如果算法存在某种错误,将有可能破坏与其同处一个地址空间的其他一些重要内容,这将造成比较严重的后果。为保护地址空间中的内容可以考虑将那些需要对地址空间中的数据进行访问的操作部分放到另外一个进程的地址空间中运行,并且只允许其访问原进程地址空间中的相关数据。具体的,可在进程中通过CreateProcess()函数去创建一个子进程,子进程在全部处理过程中只对父进程地址空间中的相关数据进行访问,从而可以保护父进程地址空间中与当前子进程执行任务无关的全部数据。
对于这种情况,子进程所体现出来的作用同函数和线程比较相似,可以看成是父进程在运行期间的一个过程,为此,需要由父进程来掌握子进程的启动、执行和退出。创建子进程才能多道程序并发执行,linux初始化的时候会创建swap进程、然后是init进程和一个init进程的兄弟进程,所有的进程(运行的程序)都是从父进程演化出去的,你可以看看proc里的东西,写个程序打印出各个进程的父进程~网上有源代码的,要的话我给你。
咱要先搞明白进程究竟是什么,进程是资源分配的单位,是运行的程序,既然是运行的程序,一个进程自然只能代表一个程序,多道程序设计自然而然就有了多进程的概念。举个例子,多进程(线程)下载,我们可以给一个需要下载的资源分片,多个进程从不同的片分时下载,这样就提高了下载速度,因为对一个程序分配的更多的资源,你试试开迅雷的时候打开个网页,保证你觉得奇卡无比,因为网络带宽(资源)被迅雷的多个进程占用了。其实在本地的多进程程序并不多见,比如word算是个典型的多进程程序,有个进程接受你的键盘输入,有拼写检查进程,有显示进程等等。大多数都用到网络上了,比如服务器。一台服务器要在“同一时间”处理来自很多客户端的请求,这就必须使用多进程
◆ wait()、sleep()、exit()、abort()
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t pid;
pid=fork();
if(pid==0)
{
int i=20000;
sleep(5);
while(i--)
{
printf("%d\n",i);
}
printf("this is child process\n");
}
else if(pid>0)
{
wait();
printf("this is parent process\n");
}
return 0;
}
◆linux进程间为什么要通信?
1) 数据传输:
2)资源共享:
3)通知事件
4)进程控制
◆linux进程间通信(IPC)由以下几部分发展而来
1)UNIX进程间通信
2)基于System V进程间通信
3) POSIX (Portable Operating System Interface) 可移植操作系统接口
◆进程间通信方式概述:Linux系统中的进程通信方式主要以下几种:
同一主机上的进程通信方式
* UNIX进程间通信方式: 包括管道(PIPE), 有名管道(FIFO), 和信号(Signal)
* System V进程通信方式:包括信号量(Semaphore), 消息队列(Message Queue), 和共享内存(Shared Memory)
网络主机间的进程通信方式:
* RPC: Remote Procedure Call 远程过程调用
* Socket: 当前最流行的网络通信方式, 基于TCP/IP协议的通信方式.
◆ 几种进程间的通信方式的概念
# 管道( pipe ) :管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用,进程的亲缘关系通常是指父子进程关系。
# 有名管道 (named pipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信,可以自动同步,如管道写满后可以自动阻塞,管道的缓冲区是有限的(管道存在于内存中,在管道创建时,为缓冲区分配一个页面大小);管道所传送的是无格式字节流,这就要求管道的读出方和写入方必须事先约定好数据的格式,比如多少字节算作一个消息(或命令、或记录)等等;
# 信号量( semophore ) :信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间(如:父子进程)以及同一进程内不同线程之间的同步手段。
# 消息队列( message queue ) : 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点,这种通信方式需要依靠某种同步机制,如互斥锁和信号量等。
# 信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
# 共享内存( shared memory ) :共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC(Internet Process Connection)(进程间通信方式)方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制配合使用,如信号量,来实现进程间的同步和通信。
# 套接字( socket ) : 套接字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同进程间的进程通信。
◆ 互斥和同步的关系
首先同步是一种更为复杂的互斥,而互斥是一种特殊的同步。
也就是说互斥是两个线程之间不可以同时运行,他们会相互排斥,必须等待一个线程运行完毕,另一个才能运行,而同步也是不能同时运行,但他是必须要安照某种次序来运行相应的线程(也是一种互斥)!
互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。
◆ 进程间的数据通信方式和特点
由于不同的进程运行在各自不同的内存空间中.一方对于变量的修改另一方是无法感知的.因此.进程之间的信息传递不可能通过变量或其它数据结构直接进行,只能通过进程间通信来完成。
根据进程通信时信息量大小的不同,可以将进程通信划分为两大类型:控制信息的通信和大批数据信息的通信.前者称为低级通信,后者称为高级通信。
低级通信主要用于进程之间的同步、互斥、终止、挂起等等控制信息的传递。
高级通信主要用于进程间数据块的交换和共享,常见的高级通信有管道(PIPE)、消息队列(MESSAGE)、共享内存(SHARED MEM0RY)等。
这里主要比较一下高级通信的这三种方式的特点。
管道通信(PIPE)
两个进程利用管道进行通信时.发送信息的进程称为写进程.接收信息的进程称为读进程。管道通信方式的中间介质就是文件.通常称这种文件为管道文件.它就像管道一样将一个写进程和一个读进程连接在一起,实现两个进程之间的通信。写进程通过写入端(发送端)往管道文件中写入信息;读进程通过读出端(接收端)从管道文件中读取信息。两个进程协调不断地进行写和读,便会构成双方通过管道传递信息的流水线。
利用系统调用PIPE()可以创建一个无名管道文件,通常称为无名管道或PIPE;利用系统调用MKNOD()可以创建一个有名管道文件.通常称为有名管道或FIFO。无名管道是一种非永久性的管道通信机构.当它访问的进程全部终止时,它也将随之被撤消。无名管道只能用在具有家族联系的进程之间。有名管道可以长期存在于系统之中.而且提供给任意关系的进程使用,但是使用不当容易导致出错.所以操作系统将命名管道的管理权交由系统来加以控制管道文件被创建后,可以通过系统调用WRITE()和READ()来实现对管道的读写操作;通信完后,可用CLOSE()将管道文件关闭。
消息缓冲通信(MESSAGE)
多个独立的进程之间可以通过消息缓冲机制来相互通信.这种通信的实现是以消息缓冲区为中间介质.通信双方的发送和接收操作均以消息为单位。在存储器中,消息缓冲区被组织成队列,通常称之为消息队列。消息队列一旦创建后即可由多进程共享.发送消息的进程可以在任意时刻发送任意个消息到指定的消息队列上,并检查是否有接收进程在等待它所发送的消息。若有则唤醒它:而接收消息的进程可以在需要消息的时候到指定的消息队列上获取消息.如果消息还没有到来.则转入睡眠状态等待。
共享内存通信(SHARED MEMORY)
针对消息缓冲需要占用CPU进行消息复制的缺点.OS提供了一种进程间直接进行数据交换的通信方式一共享内存顾名思义.这种通信方式允许多个进程在外部通信协议或同步,互斥机制的支持下使用同一个内存段(作为中间介质)进行通信.它是一种最有效的数据通信方式,其特点是没有中间环节.直接将共享的内存页面通过附接.映射到相互通信的进程各自的虚拟地址空间中.从而使多个进程可以直接访问同一个物理内存页面.如同访问自己的私有空间一样(但实质上不是私有的而是共享的)。因此这种进程间通信方式是在同一个计算机系统中的诸进程间实现通信的最快捷的方法.而它的局限性也在于此.即共享内存的诸进程必须共处同一个计算机系统.有物理内存可以共享才行。
三种方式的特点(优缺点):
1、无名管道简单方便.但局限于单向通信的工作方式.并且只能在创建它的进程及其子孙进程之间实现管道的共享:有名管道虽然可以提供给任意关系的进程使用.但是由于其长期存在于系统之中,使用不当容易出错。
2、消息缓冲可以不再局限于父子进程.而允许任意进程通过共享消息队列来实现进程间通信.并由系统调用函数来实现消息发送和接收之间的同步.从而使得用户在使用消息缓冲进行通信时不再需要考虑同步问题.使用方便,但是信息的复制需要额外消耗CPU的时间.不适宜于信息量大或操作频繁的场合。
3、共享内存针对消息缓冲的缺点改而利用内存缓冲区直接交换信息,无须复制,快捷、信息量大是其优点。但是共享内存的通信方式是通过将共享的内存缓冲区直接附加到进程的虚拟地址空间中来实现的.因此,这些进程之间的读写操作的同步问题操作系统无法实现。必须由各进程利用其他同步工具解决。另外,由于内存实体存在于计算机系统中.所以只能由处于同一个计算机系统中的诸进程共享,不方便网络通信。
进程间通信方式剖析
◆无名管道(PIPE)
管道是UNIX系统IPC的最古老形式,所有的UNIX系统都支持这种通信机制。有两个局限性:
(1)支持半双工;
(2)只有具有亲缘关系(即父子进程间)的进程之间才能使用这种无名管道;
例程:
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fds[2];
int status;
pid_t pid;
if(pipe(fds)==-1)
printf("create pipe failure!\n");
pid=fork();
if(pid==0)
{
sleep(2);
char str[100];
memset(str ,0, 100);
//printf("%s\n",str);
close(fds[1]);
if((read(fds[0],str,100))!=-1)
printf("read = %s\n",str);
close(fds[0]);
}
else if(pid>0)
{
char pstr[100]="Hello everyone";
close(fds[0]);
if((write(fds[1],pstr,100))!=-1)
printf("write Hello everyone success!\n");
close(fds[1]);
sleep(3);
wait();
}
return 0;
}
函数原型:#include <unistd.h>
int pipe(int filedes[2]);
功能: 创建无名管道
参数:经由参数filedes返回两个文件描述符,filedes[0]为读而打开,filedes[1]为写而打开。
返回值:返回0,创建成功;返回1,创建失败。
使用管道的注意事项:
1. 当读一个写端已经关闭的管道时,在所有数据被读取之后,read函数返回值为0,以指示到了文件结束处;
2. 如果写一个读端关闭的管道,则产生SIGPIPE信号。如果忽略该信号或者捕捉该信号并处理程序返回,则write返回-1,errno设置为EPIPE
◆有名管道(FIFO)
又称命名管道,可用于两个任意进程间的通信,它相当于一个文件。
创建函数原型:
#include<sys/types.h>
#include<sys/stat.h>
int mkfifo(const char *pathname,mode_t mode)
功能:创建有名管道
参数:
1) pathname:普通的路径名,就是创建后FIFO的文件名
2) mode:属性:创建的这个FIFO,一般的文件访问函数(open、close、read、write)都可用于FIFO;比如:O_CREAT|O_EXCL,O_EXCL表示的是:如果使用O_CREAT时文件存在,就返回错误信息,提示你更换名字,防止文件创建时的重复。如:
如果文件事先已经存在,
open(pathname, O_RDWR | O_CREAT,0666); 打开成功,返回一个大于0的fd
open(pathname, O_RDWR | O_CREAT | O_EXCL,0666); 打开失败,返回-1
3) 非阻塞标志O_NONBLOCK:没有时,访问要求无法满足时将阻塞;使用时,访问要求无法满足时不阻塞,立刻出错返回,errno是ENXIO。
在内核中是,检测其标志!如果存在O_NONBLOCK,读写操作将会立即返回,否则内核通过调度其它进程阻塞当前进程!当目的事件发生时内核会唤醒它!
对于一个给定的描述符两种方法对其指定非阻塞I/O:
(1)调用open获得描述符,并指定O_NONBLOCK标志
(2)对已经打开的文件描述符,调用fcntl,打开O_NONBLOCK文件状态标志。
int flags,s为描述符
flags = fcntl( s, F_GETFL, 0 ) )
fcntl( s, F_SETFL, flags | O_NONBLOCK )
- 对于管道,当FIFO被设置为非阻塞模式时,他将按下面的规则执行:
(1)如果请求写入的数据的长度小于等于PIPE_BUF字节,调用失败,数据不能写入。(PIPE_BUF:FIFO中能存放的数据的长度)
(2)如果请求写入的数据的长度大于PIPE_BUF字节,将写入部分数据,返回实际写入的字节数,返回值也可能是0.
4) 返回值:如果路径已存在,返回EEXIST错误;如果确实返回该错
误,直接打开即可。
例程:
1)读程序
#include<stdio.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>
#include<fcntl.h>
#define FIFO_R "/home/xitongbiancheng/jinchengjiantongxin/pipe/myfifo"
int main(int argc, char **argv)
{
int fd,rd;
char buf[100];
if((mkfifo(FIFO_R,O_CREAT|O_EXCL)<0)&&errno!=EEXIST)
printf("cannot creat myfifo!\n");
printf("prepare reading the pipe...\n");
if((fd=open(FIFO_R,O_RDONLY|O_NONBLOCK,0))==-1)
{
printf("open error!\n");
exit(1);
}
while(1)
{
memset(buf,0,sizeof(buf));
if((rd=read(fd,buf,100))==-1)
{
if(errno==EAGAIN)
printf("no data yet\n");
}
printf("the data from reading is :%s\n",buf);
sleep(1);
}
pause();
return 0;
}
2)写程序
#include<stdio.h>
#include<stdlib.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<unistd.h>
#include<string.h>
#include<errno.h>
#include<fcntl.h>
#define FIFO_W "/home/xitongbiancheng/jinchengjiantongxin/pipe/myfifo"
int main(int argc,char **argv)
{
int fd,rd;
char buf[100];
if(argc==1)
{
printf("Please enter the argv!\n");
exit(0);
}
memset(buf,0,sizeof(buf));
strcpy(buf,argv[1]);
if((fd=open(FIFO_W,O_WRONLY|O_NONBLOCK,0))==-1)
{
printf("open error!\n");
exit(0);
}
if((rd=write(fd,buf,sizeof(buf)))==-1)
{
if(errno==EAGAIN)
printf("the FIFO has not ready,please try it again!\n");
}
else
printf("write success,the string is %s\n",buf);
return 0;
}
【注意调试】看看O_NONBLOCK有无的作用是什么?
◆ 信号(signal)
信号是Unix系统中最为古老的进程间通信机制之一,如按键按下,除0溢出,非法访问,kill函数,kill命令都能产生信号。Linux中有30种信号。
信号类型:(常见的信号如下:)
DIGHUP: 从终端发出的结束信号
SIGINT: 来自键盘中断信号
SIGKILL:该信号结束接收信号的进程
SIGTERM:kill命令发出的信号
SIGCHLD:标志子进程停止或结束的标志
SIGSTOP:来自键盘(CTRL-Z)或调试程序的停止执行信号
信号处理:
1) 忽略信号(SIGKILL、SIGSTOP除外)
2) 执行用户希望的动作(通知内核在某种信号发生时,调用一个用户函数,在用户函数中,执行用户希望的处理),signal函数就实现了这个功能
#include<signal.h>
void (*signal(int signo,void (*func)(int)))(int)
怎么理解这个函数:
typedef void(*sighandler_t)(int)
sighandler_t signal(int signum,sighandler_t handler)
fun可能的值:
SIGQUITE:退出程序 值为3
SIGINT: 值为2
SIG_IGN:忽略此信号 值为1
SIG_DFL: 按系统默认方式处理 值为0
信号处理函数名:使用该函数处理
3) 执行系统默认动作(一般是终止进程)
信号发送:kill函数、raise函数、alarm函数 #include<sys/types.h>
#include<signal.h>
int kill(pid_t pid,int signo)
int raise(int signo)
4)wait()和pause()的区别
wait():结束等待的条件是子进程退出
pause():结束等待的条件是收到一个信号
例程:
#include<signal.h>
#include<stdio.h>
#include<stdlib.h>
void my_func(int sign_no)
{
if(sign_no==SIGINT)
printf("I have get SIGINT\n");
if(sign_no==SIGQUIT)
printf("I have get SIGQUIT\n");
}
int main()
{
printf("waiting for signal SIGINT or SIGQUIT\n");
signal(SIGINT,my_func);
signal(SIGQUIT,my_func);
pause();
exit(0);
}
执行结果:waiting for signal SIGINT or SIGQUIT
紧接着,再打开一个终端,kill -s SIGQUIT 6430 ,6430是这个进程的进程号(每一次都需要查找进程号 ps –aux),这样是将一个进程杀死,等待程序结果是:I have get SIGQUIT
◆ 消息队列( message queue )
特点:
1.消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识.
2.消息队列允许一个或多个进程向它写入与读取消息.
3.管道和命名管道通信数据都是先进先出的原则。
4.消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比FIFO更有优势。
5. 消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
类型: 1)POSICX(可移植的操作系统接口)消息队列
2)系统V消息队列
持续性:系统V消息队列是随内核持续的
删除方式: 1)内核重启 2)人工删除
键值:每个消息队列对应一个唯一的键值
解析:
1) 获取键值key
#include<sys/types.h>
#include<sys/ipc.h>
key_t ftok(char *pathname,char proj)
参数:pathname:文件名
proj:项目名(不为0即可)
返回值:返回文件名对应的键值,失败返回-1
第一个参数不用解释都知道是一个文件路径吧,第二个参数的最后8位(只有后八位有效,0-255)与第一个参数一起确定一个key.(常用于进程)。比如:我们在开发一个项目的时候,有可能不同人需要在同一个路径下编写代码,防止大家不小心使用了相同的key,一般项目经理会分配给每个人不同的proj_t ,这个时候就可以用当前路径pathname和proj_t生成所需的key,也就是说:同一个proj_t不可以出现在同一个路径。
A B C D 四个进程,其中 A B 希望访问相同的 shm, C D 希望访问相同的 shm,那么就可以商定 A/B 使用文件名 /path/to/xxx 来计算 shm key ,而C/D使用 /path/to/yyy 来计算,这样就可以达到A/B C/D 各自使用正确的资源,而互不干扰。
ftok 不会操作文件本身的,他只是根据文件名做计算算出一个唯一的key (文件名不同,key不同)而已。
问:这个路径上的文件到底起到什么样的作用?程序执行后这个文件中有什么内容?
2) 创建消息队列
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
int msgget(key_t key,int msgflg)
参数:key:由ftok获得
msgflg:标志位
IPC_CREAT:创建新的消息队列
IPC_EXEC:若消息队列已存在,则返回错误
IPC_NOWAIT:读写消息队列要求无法满足时,不阻塞
返回值:与key值相对应的消息队列描述字,创建失败则返回-1
注意:创建消息队列的两种情况:
1、如果没有与键值key相对应的消息队列,且msgflg中包含了IPC_CREAT标志位。
2、key参数为IPC_PRIVATE
3)发送消息(写成函数)
int msgsnd(int msqid,struct msgbuf *msgp,int msgsz,int msgflg)
参数:msqid:已打开消息队列id,这个id是通过getpid()得到的。
msgp:存放消息队列的结构体
struct msgbuf{
long mtype; //消息类型 用0,1,2等来标记
char mtext[1];
} //消息数据的首地址
msgsz:消息队列的长度
msgflg:发送标志:IPC_NOWAIT,指明在消息队列没有足够空间容纳要发送的消息时,msgsnd是否等待,这里是不阻塞,也就是说有空间就写,没有空间就冲过去,回头再说。
4)接收消息(写成函数)
int msgrcv(int msqid,struct msgbuf *msgp,int msgsz,
long msgtyp ,int msgflg)
参数:msgtyp:消息类型
msgp:消息取出后,存储在msgp指向的msgbuf中,成功的读取后,队列中的这条消息被删除,就像队列一样,取出去的就没有了。
例程:
#include<stdio.h>
#include<sys/msg.h>
#include<stdlib.h>
#include<string.h>
struct msg_buf
{
long mtype;
char mtext[50];
};
main()
{
key_t key;
key=ftok("/home/xitongbiancheng/jinchengjiantongxin/message_queue/1.dat",'a');
if(key==-1)
{
printf("file change fail!\n");
exit(0);
}
printf("key=[%x]\n",key);
int mes_c;
mes_c=msgget(key,IPC_CREAT);
if(mes_c==-1)
{
printf("message queue creat fail!\n");
exit(0);
}
int mes_s,megsz;
struct msg_buf mesbuf;
mesbuf.mtype=getpid();
megsz=sizeof(mesbuf)-sizeof(mesbuf.mtype);
strcpy(mesbuf.mtext,"1234567890");
mes_s=msgsnd(mes_c,&mesbuf,megsz,IPC_NOWAIT);
if(mes_s==-1)
{
printf("send message error!\n");
exit(0);
}
int mes_r;
memset(&mesbuf,0,sizeof(mesbuf));
mes_r=msgrcv(mes_c,&mesbuf,megsz,mesbuf.mtype,IPC_NOWAIT);
strcat(mesbuf.mtext,"houyunliang");
if(mes_r==-1)
{
printf("read message error!\n");
exit(0);
}
printf("the message from reading is:%s\n",mesbuf.mtext);
}
◆ 信号量
概述:
信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
信号量又称为信号灯,它是用来协调不同进程间的数据对象的,而最主要的应用是共享内存方式的进程间通信。本质上,信号量是一个计数器,它用来记录对某个资源(如共享内存)的存取状况。一般说来,为了获得共享资源,进程需要执行下列操作:
(1) 测试控制该资源的信号量。
(2) 若此信号量的值为正,则允许进行使用该资源,进程将信号量减1。
(3) 若此信号量为0,则该资源目前不可用,进程进入睡眠状态,直至信号量值大于0,进程被唤醒,转入步骤(1)。
(4) 当进程不再使用一个信号量控制的资源时,信号量值加1;如果此时有进程正在睡眠等待此信号量,则唤醒此进程。
维护信号量状态的是Linux内核操作系统而不是用户进程。
分类:1)二值信号量(只能两个进程访问)
2)计数信号量(可多个进程访问)
信号量和互斥锁的区别:
信号量:是进程间(线程间)同步用的,一个进程(线程)完成了某一个动作就通过信号量告诉别的进程(线程),别的进程(线程)再进行某些动作。
互斥锁:是线程间互斥用的,一个线程占用了某一个共享资源,那么别的线程就无法访问,直到这个线程离开,其他的线程才开始可以使用这个共享资源。可以把互斥锁看成二值信号量。
上锁时:
信号量: 只要信号量的value大于0,其他线程就可以sem_wait成功,成功后信号量的value减一。若value值不大于0,则sem_wait阻塞,直到sem_post释放后value值加一。一句话,信号量的value>=0。
互斥锁: 只要被锁住,其他任何线程都不可以访问被保护的资源。如果没有锁,获得资源成功,否则进行阻塞,等待资源可用。一句话,线程互斥锁的vlaue可以为负数,互斥体用于保护共享的易变代码,也就是,全局或静态数据,这样的数据必须通过互斥体进行保护,以防止它们在多个线程同时访问时损坏。
使用场所:
信号量主要适用于进程间通信,当然,也可用于线程间通信。而互斥锁只能用于线程间通信。
例程:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include<stdio.h>
#define MAX 3
union semun
{
int val;
struct semid_ds *buf;
unsigned short int *array;
struct seminfo *_buf;
};
int sig_alloc(key_t key, int sem_flags)
{
return semget (key, MAX, sem_flags); //key=0;MAX=3;sem_flags=512
}
int sig_destory (int semid,int numth)
{
union semun ignored_argument;
return semctl (semid, numth, IPC_RMID,ignored_argument);
}
int sig_init (int semid,unsigned short int*parray)
{
union semun argument;
int i=0;
argument.array= parray;
for(i=0;i<MAX;i++)
{
semctl(semid,i,SETALL,argument); //SETALL:17
}
}
int sig_wait(int semid,int numth)
{
struct sembuf operations[MAX];
operations[numth-1].sem_num = numth-1;
operations[numth-1].sem_op = -1;
operations[numth-1].sem_flg = SEM_UNDO; //SEG_UNDO=4096
return semop(semid,&operations[numth-1],1);
}
int sig_post(int semid,int numth)
{
struct sembuf operations[MAX];
operations[numth-1].sem_num = numth-1;
operations[numth-1].sem_op = 1;
operations[numth-1].sem_flg = SEM_UNDO;
return semop(semid,&operations[numth-1],1);
}
int main()
{
pid_t pid;
int sig_id,i=0;
unsigned short int sig_val[MAX]={1,1,1};
sig_id=sig_alloc(0,IPC_CREAT); //IPC_CREAT=512
sig_init(sig_id,sig_val);
pid=fork();
if(pid==0)
{
while(++i<10)
{
sig_wait(sig_id,3);
printf("***************\n");
sig_post(sig_id,3);
}
}
else if(pid)
{
i=0;
while(++i<10)
{
sig_wait(sig_id,1);
printf("+++++++++++++++\n");
sig_post(sig_id,1);
}
}
wait();
return 1;
}
解析:
(1) int semget(key_t key, int num_sems, int sem_flags);
semget函数创建一个新的信号量或获得一个已存在的信号量键值。
例程:
int sig_alloc(key_t key, int sem_flags)
{
return semget (key, MAX, sem_flags); //key=0;MAX=3;sem_flags=512
}
参数:
key:是一个用来允许不相关的进程访问相同信号量的整
数值。所有的信号量是为不同的程序通过提供一个key来间接访问
的,对于每一个信号量系统生成一个信号量标识符。信号量键值只可
以由semget获得,所有其他的信号量函数所用的信号量标识符都是
由semget所返回的。
还有一个特殊的信号量key值,IPC_PRIVATE(通常为0),其作用是
创建一个只有创建进程可以访问的信号量。
num_sems:是所需要的信号量数目。这个值通常总是1。
sem_flags:是一个标记集合,与open函数的标记十分类似。低
九位是信号的权限,其作用与文件权限类似。另外,这些标记可以与
IPC_CREAT进行或操作来创建新的信号量。设置IPC_CREAT标记并且
指定一个已经存在的信号量键值并不是一个错误。如果不需要,
IPC_CREAT标记只是被简单的忽略。我们可以使用IPC_CREAT与
IPC_EXCL的组合来保证我们可以获得一个新的,唯一的信号量。如
果这个信号量已经存在,则会返回一个错误。
如果成功,semget函数会返回一个正数;这是用于其他信号量函数
的标识符。如果失败,则会返回-1。
(2)int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
函数semop用来改变信号量的值
例程:
int sig_wait(int semid,int numth)
{
struct sembuf operations[MAX];
operations[numth-1].sem_num = numth-1;
operations[numth-1].sem_op = -1;
operations[numth-1].sem_flg = SEM_UNDO; //SEG_UNDO=4096
return semop(semid,&operations[numth-1],1);
}
sem_id:是由semget函数所返回的信号量标识符。
sem_ops:是一个指向结构数组的指针,其中的每一个结构至少包含下列成员:
struct sembuf
{
short sem_num;
short sem_op;
short sem_flg;
}
sem_num:是信号量数目,通常为0,除非我们正在使用一个信号量数组。
sem_op:是信号量的变化量值。(我们可以以任何量改变信号量值,而不只是1)通常情况下中使用两个值,-1是我们的P操作,用来等待一个信号量变得可用,而+1是我们的V操作,用来通知一个信号量可用。
sem_flg:通常设置为SEM_UNDO。这会使得操作系统跟踪当前进程对信号量所做的改变,而且如果进程终止而没有释放这个信号量,如果信号量为这个进程所占有,这个标记可以使得操作系统自动释放这个信号量。将sem_flg设置为SEM_UNDO是一个好习惯,除非我们需要不同的行为。如果我们确实我们需要一个不同的值而不是SEM_UNDO,一致性是十分重要的,否则我们就会变得十分迷惑,当我们的进程退出时,内核是否会尝试清理我们的信号量。semop的所用动作会同时作用,从而避免多个信号量的使用所引起的竞争条件。
(3)int semctl(int sem_id, int sem_num, int command, ...);
semctl函数允许信号量信息的直接控制。
例程:
int sig_init (int semid,unsigned short int*parray)
{
union semun argument;
int i=0;
argument.array= parray;
for(i=0;i<MAX;i++)
{
semctl(semid,i,SETALL,argument); //SETALL:17
}
sem_id,是由semget所获得的信号量标识符。sem_num参数是信号量数目。当我们使用信号量数组时会用到这个参数。通常,如果这是第一个且是唯一的一个信号量,这个值为0。
command:参数是要执行的动作,而如果提供了额外的参数,则是union semun,根据X/OPEN规范,这个参数至少包括下列参数:
union semun
{
int val;
struct semid_ds *buf;
unsigned short *array;
}
许多版本的Linux在头文件(通常为sem.h)中定义了semun联合,尽管X/Open确认说我们必须定义我们自己的联合。如果我们发现我们确实需要定义我们自己的联合,我们可以查看semctl手册页了解定义。如果有这样的情况,建议使用手册页中提供的定义,尽管这个定义与上面的有区别。有多个不同的command值可以用于semctl。在这里我们描述两个会经常用到的值。要了解semctl功能的详细信息,我们应该查看手册页。
这两个通常的command值为:
SETVAL:用于初始化信号量为一个已知的值。所需要的值作为联合semun的val成员来传递。在信号量第一次使用之前需要设置信号量。
IPC_RMID:当信号量不再需要时用于删除一个信号量标识。
semctl函数依据command参数会返回不同的值。对于SETVAL与IPC_RMID,如果成功则会返回0,否则会返回-1。
◆ 共享内存
共享内存是运行在同一台机器上的进程间通信最快的方式,因为数据不需要在不同的进程间复制。通常由一个进程创建一块共享内存区,其余进程对这块内存区进行读写。得到共享内存有两种方式:映射/dev/mem设备和内存映像文件。前一种方式不给系统带来额外的开销,但在现实中并不常用,因为它控制存取的将是实际的物理内存,在Linux系统下,这只有通过限制Linux系统存取的内存才可以做到,这当然不太实际。常用的方式是通过shmXXX函数族来实现利 用共享内存进行存储的。
使用共享内存时要掌握的唯一诀窍是多个进程之间对一定存储区的同步访问。若服务器进程正在将数据放入共享内存,则在它做完这一操作之前,客户进程不应当去读取这些数据,也就是说先写后读。
通常,信号量是用来实现对共享内存访问的同步(记录锁也可以用于这种场合)。
例程1:
#include<stdio.h>
#include<stdlib.h>
#include<sys/shm.h>
#include<sys/stat.h>
#include<string.h>
int main()
{ pid_t pid;
int share_id;
share_id=shmget(IPC_PRIVATE,getpagesize(),IPC_CREAT| S_IRUSR | S_IWUSR | IPC_EXCL );
pid=fork();
if(pid==0)
{
char *share1=NULL;
share1=(char *)shmat(share_id,0,0);
memset(share1,0,getpagesize());
strcpy(share1,"hello world\n");
//printf("%s\n",share1);
shmdt(share1);
}
else if(pid>0)
{
char *share2=NULL;
share2=(char *)shmat(share_id,0,0);
printf("readcharactersfromsharedmemory!%p\n",share2);
//printf("%s",share2);
shmctl(share_id,IPC_RMID,0);
}
return 1;
}
例程2:
#include<stdio.h>
#include<stdlib.h>
#include<sys/shm.h>
#include<sys/stat.h>
#include<string.h>
#define FLAG IPC_CREAT|S_IRUSR|S_IWUSR|IPC_EXCL
main(int argc,char **argv)
{
if(argc!=2)
{
printf("the parameter is not enough,please enter again!\n");
exit(0);
}
int share_id;
share_id=shmget(IPC_PRIVATE,1024,FLAG);
if(share_id==-1)
{
printf("sharing RAM is fail\n");
exit(0);
}
pid_t pid;
pid=fork();
if(pid==0)
{
char *share1=NULL;
share1=(char *)shmat(share_id,0,0);
memset((void *)share1,'\0',1024);
strncpy(share1,argv[1],1024);
strcat(share1,"67890");
shmdt(share1);
exit(0);
}
else if(pid>0)
{
sleep(1);
char *share2=NULL;
share2=(char *)shmat(share_id,0,0);
printf("the RAM from sharing is: %s\n",share2);
shmdt(share2);
shmctl(share_id,IPC_RMID,0);
exit(0);
}
}
解析:
(1)内核为每个共享存储段设置了一个shmid_ds结构。
(2)分配阶段:若要获得一个共享存储标识符,调用的第一个函数通常是shmget。
#include <sys/shm.h>
int shmget(key_t key, size_t size, int flag);
例程:
int share_id;
share_id=shmget(IPC_PRIVATE,getpagesize(),IPC_CREAT| S_IRUSR | S_IWUSR | IPC_EXCL );
key_t key:表示共享内存的键值 一般是系统宏定义IPC_PRIVATE (值为0) 即创建一块新的共享内存
参数size:该共享存储段的长度(1024、2048等)(单位:字节) 一般是一页的大小,(4096) 即得到4K的共享存储单元。
返回值:若成功则返回共享内存标志符ID,若出错则返回-1
(3)绑定阶段:一旦创建了共享存储段,进程就可调用shmat函数将其连接到相应进程的地址空间中。
#include <sys/shm.h>
void shmat(int shmid, const void *addr, int flag);
例程:
char *share1=NULL;
share1=(char *)shmat(share_id,0,0);
shmid:为shmget函数返回的共享内存标志符
addr:参数决定了以什么方式来确定连接的地址,这就是共享内存的地址,如果是NULL或0,则系统会自动分配合适的地址空间。
返回值:若成功则返回共享存储的指针,即使该进程数据段所连接的实际地址,进程可对此内存地址进行读写操作;若出错则返回-1
(4)管理阶段
例程:
shmdt(share1);
man数据:shmdt() detaches the shared memory segment located at the address specified by shmaddr from the address space of the calling process. The to-be-detached segment must be currently attached with shmaddr equal to the value returned by the attaching shmat() call.
(5)删除阶段
shmctl函数对共享存储段执行多种操作。你应当在结束使用每个共享内存块的时候都使用shmctl进行释放,以防止超过系统所允许的共享内存块的总数限制
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
例程:
shmctl(share_id,IPC_RMID,0);
int shmid:为shmget函数返回的共享内存标志符
int cmd:这是删除或其他操作时所用的命令,如:
IPC_RMID (值为0)man数据: Mark the segment to be destroyed.
IPC_STAT(值为2) man数据: Copy information from the kernel data structure associated with shmid into the shmid_ds structure pointed to by buf.
struct shmid_ds *buf:buf是个结构体指针 指向一个复杂的结构体。
返回值:若成功则返回0,若出错则返回-1
◆ 内存映射
Linux提供了内存映射函数mmap, 它把文件内容映射到一段内存上(准确说是虚拟内存上), 通过对这段内存的读取和修改, 实现对文件的读取和修改。
程序:
#define FILE_LENGTH 1024
int main(int argc,char *argv[])
{
int fd1,fd2;char *pfile=NULL;
char *load=NULL;
int num;
if(argc<3)
{
printf("please input more file\n");
return 0;
}
fd1=open(argv[1],O_RDONLY);
fd2=open(argv[2],O_RDWR|O_CREAT,S_IRUSR|S_IWUSR);
printf("fd2=%d\n",fd2);
//fd2=open(argv[2],O_WRONLY);
lseek (fd2, FILE_LENGTH+1, SEEK_SET);
write (fd2, "", 1);
lseek (fd2, 0, SEEK_SET);
load=malloc(FILE_LENGTH);
if(load==NULL)
{
printf("malloc failed\n");
return 0;
}
num=read(fd1,load,FILE_LENGTH);
printf("num=%d\n",num);
printf("fd2=%d\n",fd2);
pfile=mmap(0,1024,PROT_WRITE|PROT_READ,MAP_PRIVATE,fd2,0);
close(fd2);
printf("pfile=%d\n",pfile);
memcpy(pfile,load,FILE_LENGTH);
printf("qqqq\n");
munmap(pfile,FILE_LENGTH);
close(fd1);
free(load);
return 1;
}
解析:
(1)头文件:
<unistd.h>
<sys/mman.h>
原型: void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offsize);
例程:pfile=mmap(0,1024,PROT_WRITE|PROT_READ,MAP_PRIVATE,fd2,0);
返回值: 成功则返回映射区起始地址, 失败则返回MAP_FAILED(-1).
参数:
addr: 指定映射的起始地址, 通常设为NULL, 由系统指定.
length: 将文件的多大长度映射到内存.
prot: 映射区的保护方式, 可以是:
PROT_EXEC: 映射区可被执行.
PROT_READ: 映射区可被读取.
PROT_WRITE: 映射区可被写入.
PROT_NONE: 映射区不能存取.
flags: 映射区的特性, 可以是:
MAP_SHARED: 对映射区域的写入数据会复制回文件, 且允许其他映射该文件的进程共享.
MAP_PRIVATE: 对映射区域的写入操作会产生一个映射的复制(copy-on-write), 对此区域所做的修改不会写回原文件.
fd: 由open返回的文件描述符, 代表要映射的文件.
offset: 以文件开始处的偏移量, 必须是分页大小的整数倍, 通常为0, 表示从文件头开始映射.
(2) int open(const char *pathname, int flags);
返回值:文件的文件描述符
参数:const char *pathname:文件名
int flags:读写方式
(3)内存映射的步骤:
- 用open系统调用打开文件, 并返回描述符fd.
- 用mmap建立内存映射, 并返回映射首地址指针start.
- 对映射(文件)进行各种操作, 显示(printf), 修改(sprintf).
- 用munmap(void *start, size_t lenght)关闭内存映射.
- 用close系统调用关闭文件fd.
注意事项:
在修改映射的文件时, 只能在原长度上修改, 不能增加文件长度, 因为内存是已经分配好的.
open和fopen的区别:
1.缓冲文件系统
缓冲文件系统的特点是:在内存开辟一个“缓冲区”,为程序中的每一个文件使用,当执行读文件的操作时,从磁盘文件将数据先读入内存“缓冲区”,装满后再从内存“缓冲区”依此读入接收的变量。执行写文件的操作时,先将数据写入内存“缓冲区”,待内存“缓冲区”装满后再写入文件。由此可以看出,内存 “缓冲区”的大小,影响着实际操作外存的次数,内存“缓冲区”越大,则操作外存的次数就少,执行速度就快、效率高。一般来说,文件“缓冲区”的大小随机器而定。
fopen, fclose, fread, fwrite, fgetc, fgets, fputc, fputs, freopen, fseek, ftell, rewind等
2.非缓冲文件系统
缓冲文件系统是借助文件结构体指针来对文件进行管理,通过文件指针来对文件进行访问,既可以读写字符、字符串、格式化数据,也可以读写二进制数据。非缓冲文件系统依赖于操作系统,通过操作系统的功能对文件进行读写,是系统级的输入输出,它不设文件结构体指针,只能读写二进制文件,但效率高、速度 快,由于ANSI标准不再包括非缓冲文件系统,因此建议大家最好不要选择它。本书只作简单介绍。open, close, read, write, getc, getchar, putc, putchar 等。
open 是系统调用 返回的是文件句柄,文件的句柄是文件在文件描述副表里的索引,fopen是C的库函数,返回的是一个指向文件结构的指针。
fopen是ANSIC标准中的C语言库函数,在不同的系统中应该调用不同的内核api
linux中的系统函数是open,fopen是其封装函数,个人观点。仅供参考。
文件描述符是linux下的一个概念,linux下的一切设备都是以文件的形式操作.如网络套接字、硬件设备等。当然包括操作文件。
fopen是标准c函数。返回文件流而不是linux下文件句柄。
设备文件不可以当成流式文件来用,只能用open
fopen是用来操纵正规文件的,并且设有缓冲的,跟open还是有一些区别
一般用fopen打开普通文件,用open打开设备文件
fopen是标准c里的,而open是linux的系统调用.
他们的层次不同.
fopen可移植,open不能
我认为fopen和open最主要的区别是fopen在用户态下就有了缓存,在进行read和write的时候减少了用户态和内核态的切换,而open则每次都需要进行内核态和用户态的切换;表现为,如果顺序访问文件,fopen系列的函数要比直接调用open系列快;如果随机访问文件open要比fopen快。
来自论坛的经典回答:
前者属于低级IO,后者是高级IO。
前者返回一个文件描述符(用户程序区的),后者返回一个文件指针。
前者无缓冲,后者有缓冲。
前者与 read, write 等配合使用, 后者与 fread, fwrite等配合使用。
后者是在前者的基础上扩充而来的,在大多数情况下,用后者。
内存映射文件(mmap)
内存映射文件是利用虚拟内存把文件映射到进程的地址空间中去,在此之后进程操作文件,就像操作进程空间里的地址一样了,比如使用memcpy等内存操作的函数。这种方法能
够很好的应用在需要频繁处理一个文件或者是一个大文件的场合,这种方式处理IO效率比
普通IO效率要高。另外,UNIX把它做为内存共享来设计的。
UNIX中,头文件<sys/mman.h>中有与此相关的函数定义。mman==super man :)。
1、创建一个内存映射区域
void *mmap(void *addr, size_t len, int prot, int flag, int filedes, off_t off);
addr
映射区首地址,你想自己定义的时候使用。一般使用NULL,然后系统自动分配一个合适地址。
len
映射的长度, 单位byte
prot
说明映射区访问属性:读、写、执行、不可访问
可 PROT_READ,PROT_WRITE,PROT_EXEC,PROT_NONE
不能超越它所映射的文件的权限
flag
MAP_SHARED 这个标志说明文件映射是共享的,也就是说进程改变了内存映射,也就会影响到文件。
MAP_PRIVATE 这个标志说明文件映射不共享,打开文件映射的进程只能改变的是这个文件的一个副本。
filedes
文件描述符号
off
隐射位置的偏移量,设置为0的话,就映射文件的0-len个字节
返回
映射区域的首地址
2、取消文件映射
int munmap(caddr_t addr,size_t len);
addr
内存隐射的地址。mmap返回的地址。
len
隐射的字节数。
返回
成果0,失败负
使用MAP_PRIVATE的映射改变将不被写回文件。
3、内存映射和文件的同步
int msync(void *addr, size_t len,int flags);
addr
内存映射地址
len
长度
flags
MS_ASYNC,MS_SYNC,MS_INVALIDATE。
MS_ASYNC,异步写,调用后就返回不等待写完,MS_SYNC则等待写完才返回。
MS_INVALIDATE,写完之后,内存映射中与文件不同的数据将无效,取而代之的是文件中的数据。
返回
成功0,失败负
4、创建共享内存区
这个信号量比较相同。
int shm_open(const char *name ,into flag, mode_t mode);
对比:
sem_t *sem_open(const char *name,int oflag,/*mode_t mode,unsigned int value*/);
这个地方就可以解释sem_open函数的文件名有什么用了。使用shem_open创建共享文件,
使用mmap使内存这个文件映射,实现共享内存,然后再使用信号量来同步。这个搭配可算是完美!
name
共享区名,需要绝对路径
flag
和open文件一样,O_RDONLY O_RDWR O_CREAT O_TRUNC
mode
权限位置,和文件相同。只能在O_CREAT下使用
返回
成功返回一个文件描述字,失败负
既然它是一种文件,那么我们对文件操作的函数, fstat lstat read wirte ftruncate 这些函数都可以尝试着对他使用一把。不成功便成人嘛!
5、删除共享内存区
int shm_unlink(const char *name);
◆进程控制
linux进程控制包括创建进程,执行进程,退出进程以及改变进程优先级等。
在linux系统中,用于对进程进行控制的系统调用有:
a.fork: 用于创建一个新进程。
b.exit : 用于终止进程
c.exec : 用于执行一个应用程序
d.wait : 将父进程挂起,等待子进程终止
e.getpid : 获取当前进程的进程ID
f.nice : 该变进程的优先级
◆fork 和 vfork 的区别
(1)fork():使用fork()创建一个子进程时,子进程只是完全复制父进程的资源。这样得到的子进程独立于父进程具有良好的并发性,父子进程执行顺序不定。
(2)vfork():使用 vfork创建一个子进程时,操作系统并不将父进程的地址空间完全复制到子进程。而是子进程共享父进程的地址空间,即子进程完全运行在父进程的地址空间上,子进程对该地址空间中任何数据的修改同样为父进程所见。同时保证子进程先运行,在它调用exec或exit后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
◆exit()和_exit()的区别:
(1) exit函数有参数,正常终止进程 ,exit执行完后把控制权交给系统,exit是在_exit函数之上的一个封装,其会调用_exit,并在调用之前先刷新流。
(2) _exit()执行后立即返回给内核,而exit()要先执行一些清除操作,然后将控制权交给内核。调用_exit函数时,其会关闭进程所有的文件描述符,清理内存以及其他一些内核清理函数,但不会刷新流(stdin, stdout, stderr ...)。
◆僵尸进程的避免
(1) 父进程通过wait和waitpid等函数等待子进程结束,这会导致父进程挂起。
(2) 如果父进程很忙,那么可以用signal函数为SIGCHLD安装handler,因为子进程结束后, 父进程会收到该信号,可以在handler中调用wait回收。
(3) 如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCHLD, SIG_IGN)通知内核,自己对子进程的结束不感兴趣,那么子进程结束后, 内核会回收, 并不再给父进程发送信号。
(4) 还有一些技巧,就是fork两次,父进程fork一个子进程,然后继续工作,子进程fork一 个孙进程后退出,那么孙进程被init接管,孙进程结束后,init会回收。不过子进程的回收 还要自己做。
◆死锁的概念
在多道程序系统中,一组进程中的每一个进程均无限期的等待另一组进程所占有的且不会释放的资源,这种现象称为死锁
例如,
进程1,2分别完全占有两种系统资源A和B,它们的进程操作分别如下(从左到右:
1:获得A资源,获得B资源,释放A资源,释放B资源;
2:获得B资源,获得A资源,释放B资源,释放A资源;
从1来看,它要获得B资源才会释放A资源,而获得A资源正是进程2释放B资源的条件,所以两个进程互相等待,进入死锁
◆ 死锁的四个必要条件:
互斥、占有、非抢占、循环等待
解析:
1、互斥使用(资源独占)
一个资源每次只能给一个进程使用
2、不可强占(不可剥夺)
资源申请者不能强行的从资源占有者手中夺取资源,资源只能由占有者自愿释放
3、请求和保持(部分分配,占有申请)
一个进程在申请新的资源的同时保持对原有资源的占有(只有这样才是动态申请,动态分配)
4、循环等待
存在一个进程等待队列
{P1 , P2 , … , Pn},
其中P1等待P2占有的资源,P2等待P3占有的资源,…,Pn等待P1占有的资源,形成一个进程等待环路
◆ 自旋锁
概述:自旋锁它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。
自旋锁一般原理:
跟互斥锁一样,一个执行单元要想访问被自旋锁保护的共享资源,必须先得到锁,在访问完共享资源后,必须释放锁。如果在获取自旋锁时,没有任何执行单元保持该锁,那么将立即得到锁;如果在获取自旋锁时锁已经有保持者,那么获取锁操作将自旋在那里,直到该自旋锁的保持者释放了锁。由此我们可以看出,自旋锁是一种比较低级的保护数据结构或代码片段的原始方式,这种锁可能存在两个问题:死锁和过多占用cpu资源。
自旋锁适用情况:
自旋锁比较适用于锁使用者保持锁时间比较短的情况。正是由于自旋锁使用者一般保持锁时间非常短,因此选择自旋而不是睡眠,是非常必要的,自旋锁的效率远高于互斥锁。信号量和读写信号量适合于保持时间较长的情况,它们会导致调用者睡眠,因此只能在进程上下文使用,而自旋锁适合于保持时间非常短的情况,它可以在任何上下文使用。如果被保护的共享资源只在进程上下文访问,使用信号量保护该共享资源非常合适,如果对共享资源的访问时间非常短,自旋锁也可以。但是如果被保护的共享资源需要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必须使用自旋锁。自旋锁保持期间是抢占失效的,而信号量和读写信号量保持期间是可以被抢占的。自旋锁只有在内核可抢占或SMP(多处理器)的情况下才真正需要,在单CPU且不可抢占的内核下,自旋锁的所有操作都是空操作。另外格外注意一点:自旋锁不能递归使用。
◆线程的引入:
人们为了解决这个缺点,想到让进程在并行时不拥有资源---从而引入了线程的概念:即线程本身不拥有资源或者是很少的资源,进程是拥有资源的基本单位,线程是调度的基本单位.在操作系统中引入线程则是为了减少程序并发执行时所付出的时空开销,使操作系统具有更好的并发性。
◆线程标识
每个进程内部的不同线程都有自己的唯一标识,线程标识( ID )只在它所属的进程环境中有效,线程标识是 pthread_t 数据类型。
◆线程创建
新创建线程从start_rtn 函数的地址开始运行,不能保证新线程和调用线程的执行顺序。
#include <pthread.h>
int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void), void *restrict arg);
返回:成功返回 0 ,否则返回错误编号
◆终止方式
pthread_exit 和 pthread_join的使用:
#include <pthread.h>
void pthread_exit( void *retval )
int pthread_join( pthread_t *th , void **thread_return )
返回值:成功返回 0 ,否则返回错误编号
pthread_exit:retval是pthread_exit 调用者线程的返回值 , 可由其他函数和 pthread_join 来检测获取。线程退出时使用函数 pthread_exit, 是线程的主动行为。由于一个进程中的多个线程共享数据段,因此通常在线程退出后,退出线程所占用的资源并不会随线程结束而释放。所有需要 pthread_join 函数来等待线程结束,类似于 wait 系统调用。
pthread_join:等待线程的结束。
th :等待线程的标识符
thread_return :用户定义指针,用来存储被等待线程的返回值。
◆ pthread_exit 和 pthread_cancel的区别
(1)线程通过调用pthread_exit函数终止执行,就如同进程在结束时调用exit函数一样。这个函数的作用是,终止调用它的线程并返回一个指向某个对象的指针。
(2)pthread_cancel是一种让线程可以结束其他线程的机制,一个线程可以对另一个线程发送一个结束的请求。当一个线程最终尊重了取消的请求,他的行为就像执行了pthread_exit函数。
◆ 线程的创建
#include<stdio.h>
#include<pthread.h>
#include<stdlib.h>
void *print_xs()
{
while(1)
fputc('x',stderr);
printf("hello1\n");
return NULL;
}
int main()
{
pthread_t pthread_id;
pthread_create(&pthread_id,NULL,&print_xs,NULL);
//pthread_exit(NULL);
printf("hello2\n");
while(1)
fputc('o',stderr);
return 0;
}
解析:
1、 这里的hello2会执行一次,在程序的开始打印,但hello1不会执行。
2、 执行结果是:hello2 ooooo……xxxxxxx…….oooo……xxxxxx
这就说明了:linux异步调度这两个程序,程序不能依赖执行的顺序
pthread_create()调用后会立刻返回,原线程会继续执行之后的指令,同时,新线程开始执行线程函数。
3、 pthread_exit() (线程的退出)只会结束主线程,而不会结束创建的线程。
4、 int pthread_create( pthread_t *thread_id, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg)
(1) pthread_t * thread_id:线程的标识符,一个指向pthread_t类型的指针,新线程的线程ID就存放在这里。
(2) const pthread_attr_t *attr:控制着新线程与程序其它部分交互的具体细节,若是NULL,新线程将被赋予一组默认属性。
(3) void *(*start_routine)(void *) :线程执行的代码:线程函数,
这个函数所调用的函数类型是:void * ( * function ) ( void *)传参过后再转化为我们需要的指针类型。
(4) void *arg :传递给线程函数的参数(这个模式不能改变)
◆ joining线程
#include<stdio.h>
#include<pthread.h>
struct char_print
{
char c;
int num;
};
void *char_print(void *p)
{
int i;
struct char_print *pt=(struct char_print *)p;
for(i=0;i<pt->num;i++)
{
fputc(pt->c,stderr);
}
return NULL;
}
int main()
{
pthread_t thread1_id;
pthread_t thread2_id;
struct char_print t1;
struct char_print t2;
//创建一个输出1000个”#”的的线程
t1.num=1000;
t1.c='#';
pthread_create(&thread1_id,NULL,&char_print,&t1);
//创建一个输出1000个”*”的的线程
t2.num=1000;
t2.c='*';
pthread_create(&thread2_id,NULL,&char_print,&t2);
pthread_join(thread1_id,NULL); //等待线程1结束
pthread_join(thread2_id,NULL); //等待线程2结束
return 0;
}
解析:
1、int pthread_join(pthread_t thread_id, void **value_ptr);
参数:线程ID和一个指向void *类型变量的指针,用于存放线性函数的返回值,如果是NULL,
2、参数的传递方式:这里是一个结构体t1和t2,将它们的地址&t1,&t2,传给线性函数void *char_print(void *p)中的p
3、线程函数:void *char_print(void *p):线程执行的代码
这个函数所调用的函数类型是:void * ( * function ) ( void *) 传参过后再转化为我们需要的指针类型, 如果写成这样void * ( * function ) ( struct char_print *),直接传参,将会出现警告。
3、正常情况下: 执行态 阻塞态 结束态
(1)
Thread2 |
Thread1 |
结果:#######.......*******……####### (#和*总数都是1000个)
或者:##############......*****************……
(2)如果把线程1 屏蔽掉pthread_join(thread1_id,NULL); 会出现什么状况
Thread2 |
Thread1 |
结果:######......******* (*总数是1000个,但是#小于或等于1000个)
(3)如果把线程2屏蔽掉pthread_join(thread2_id,NULL); 会出现什么状况
Thread2 |
Thread1 |
结果:#####......******…… #####.....(#总数是1000个,但是*也是1000个)
或者:#############........(#总数是1000个但是*是0个)
4、教训:一旦你将对某个数据变量的引用传递给某个线程,务必确保这个变量不会被释放(甚至在其它线程中也不行!),直到你确定这个线程不会再使用它。这对于局部变量(当生命期结束的时候自动释放)和堆上分配的对象(free释放)也适用。
◆ 线程的取消
#include<pthread.h>
int pthread_cancel(pthread_t thread)
◆线程中的通信方式
互斥锁 信号量 条件变量
概述:线程间的通信目的主要是用于线程同步。所以线程没有像进程通信中的用于数据交换的通信机制。
为什么有了进程还要引入线程呢?
1) 与进程相比,它是一个非常“节俭”的多任务操作方式,在linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护代码段、堆栈段和数据段,这是一种“昂贵”的操作方式。
2) 运行于一个进程中的多个线程,它们使用相同的地址空间,而且线程间彼此切换所需的时间也远远小于进程间切换所需的时间。据统计,一个进程的开销大约是线程开销的30倍左右。
注意:需要头文件 pthread.h 连接时需要连接libpthread.a
◆ 互斥锁
概述:锁机制,可以说是linux整个系统的精髓所在,linux内核都是围绕着同步在运转,互斥锁有:互斥锁、文件锁、读写锁,信号量也算是一种锁;使用锁的目的是达到同步的作用,使共享资源在同一时间内只能有一个线程或进程对其进行操作。
例程:
#include<stdio.h>
#include<pthread.h>
#include<stdlib.h>
#include<semaphore.h>
#define N 20
struct Queue
{
int front;
int rear;
int pQ[N];
};
struct Queue Q1;
pthread_mutex_t job_queue_mutex = PTHREAD_MUTEX_INITIALIZER;
int *produce();
int *consume();
int main()
{
pthread_t pthread_id1,pthread_id2;
Q1.rear=Q1.front=0;
pthread_create(&pthread_id1,NULL,(void *)produce,NULL);
pthread_create(&pthread_id2,NULL,(void *)consume,NULL);
pthread_join(pthread_id1,NULL);
pthread_join(pthread_id2,NULL);
return 0;
}
int *consume()
{
int num_get;
while(1)
{
pthread_mutex_lock(&job_queue_mutex);
if(Q1.rear==Q1.front)
return ;
else
{
num_get=Q1.pQ[Q1.front];
Q1.front=(Q1.front+1)%N;
printf("the consumer num is:%d",num_get);
}
pthread_mutex_unlock(&job_queue_mutex);
}
}
int *produce()
{
int num_creat;
while(1)
{
num_creat=rand()%100;
pthread_mutex_lock(&job_queue_mutex);
if((Q1.rear+1)%N==Q1.front)
return ;
else
{
Q1.pQ[Q1.rear]=num_creat;
Q1.rear=(Q1.rear+1)%N;
printf("the produce num is:%d\n",num_creat);
}
pthread_mutex_unlock(&job_queue_mutex);
}
}
解析:
(1)互斥锁的初始化
方式一:
使用 PTHREAD_MUTEX_INITIALIZER 宏可以将以静态方式定义的互斥锁初始化为其缺省属性。
例程:pthread_mutex_t job_queue_mutex = PTHREAD_MUTEX_INITIALIZER;
方式二:pthread_mutex_init(pthread_mutex_t *mp, const pthread_mutexattr_t *mattr)
可以使用缺省值初始化由 mp所指向的互斥锁,还可以指定已经使用 pthread_mutexattr_init() 设置的互斥锁属性。mattr 的缺省值为 NULL。
如果互斥锁已初始化,则它会处于未锁定状态,互斥锁可以位于进程之间共享的内存中或者某个进程的专用内存中???。
初始化互斥锁之前,必须将其所在的内存清零,将 mattr 设置为 NULL 的效果与传递缺省互斥锁属性对象的地址相同,但是没有内存开销。
(2)使互斥保持一致
int pthread_mutex_consistent_np(pthread_mutex_t*mutex);
仅当定义了 _POSIX_THREAD_PRIO_INHERIT 符号时,pthread_mutex_consistent_np() 才适用,并且仅适用于使用协议属性值 PTHREAD_PRIO_INHERIT 初始化的互斥锁。
调用 pthread_mutex_lock( ) 会获取不一致的互斥锁。EOWNWERDEAD 返回值表示出现不一致的互斥锁。
持有以前通过调用 pthread_mutex_lock() 获取的互斥锁时可调用 pthread_mutex_consistent_np()。
如果互斥锁的属主失败,则该互斥锁保护的临界段可能会处于不一致状态;在这种情况下,仅当互斥锁保护的临界段可保持一致时,才能使该互斥锁保持一致。
(3)锁定互斥锁(注意细节)
int pthread_mutex_lock(pthread_mutex_t *mutex);
当 pthread_mutex_lock() 返回时,该互斥锁已被锁定。调用线程是该互斥锁的属主。如果该互斥锁已被另一个线程锁定和拥有,则调用线程将阻塞,直到该互斥锁变为可用为止。
(4)解除锁定互斥锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
可释放 mutex 引用的互斥锁对象。互斥锁的释放方式取决于互斥锁的类型属性。如果调用 pthread_mutex_unlock() 时有多个线程被 mutex 对象阻塞,则互斥锁变为可用时调度策略可确定获取该互斥锁的线程。 对于 PTHREAD_MUTEX_RECURSIVE 类型的互斥锁,当计数达到零并且调用线程不再对该互斥锁进行任何锁定时,该互斥锁将变为可用。
(5)销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mp);
可以销毁与 mp 所指向的互斥锁相关联的任何状态
◆ 信号量
信号量的数据类型为结构sem_t,它本质上是一个长整型的数。
例程1:
#include<stdio.h>
#include<semaphore.h>
#include<pthread.h>
sem_t sem1,sem2;
void *pthread1(void * arg)
{
int i=0;
sem_wait(&sem1);
setbuf(stdout,NULL);
while(10>i++)
printf("hello\n");
sem_post(&sem2);
}
void *pthread2(void *arg)
{
int i=0;
sem_wait(&sem2);
while(10>i++)
printf("world\n");
}
int main()
{
pthread_t t1,t2;
sem_init(&sem1,0,1);
sem_init(&sem2,0,0);
pthread_create(&t1,NULL,pthread1,NULL);
pthread_create(&t2,NULL,pthread2,NULL);
pthread_join(t1,NULL);
pthread_join(t2,NULL);
sem_destroy(&sem1);
sem_destroy(&sem2);
return 0;
}
解析:
(1)int sem_init(sem_t *sem, int pshared, unsigned int value);
例:sem_init(&sem1,0,1);
功能:用来初始化一个信号量
sem:为指向信号量结构的一个指针
pshared:共享选项,不为0时此信号量在进程间共享,为0时只能为当前进程的所有线程间共享
value:给出了信号量的初始值。
补充:这个函数的作用是对由sem指定的信号量进行初始化,设置好它的共享选项,并指定一个整数类型的初始值。pshared参数控制着信号量的类型。如果 pshared的值是0,就表示它是当前进程的局部信号量;否则,其它进程就能够共享这个信号量。我们现在只对不让进程共享的信号量感兴趣。pshared传递一个非零将会使函数调用失败。
(2)int sem_wait(sem_t *sem);
功能:函数用于接受信号,当sem>0时就能接受到信号,然后将sem--;被用来阻塞当前线程直到信号量sem的值大于0,解除阻塞后将sem的值减一,表明公共资源经使用后减少。
sem:为指向信号量结构的一个指针
(3)sem_post(sem_t *sem)
功能:函数可以增加信号量,
补充:这两个函数都要用一个由sem_init调用初始化的信号量对象的指针做参数。
sem_post函数的作用是给信号量的值加上一个“1”,它是一个“原子操作"即同时对同一个信号量做加“1”操作的两个线程是不会冲突的;而同时对同一个文件进行读和写操作的两个程序就有可能会引起冲突。信号量的值永远会正确地加一个“2”--因为有两个线程试图改变它。
sem_wait函数也是一个原子操作,它的作用是从信号量的值减去一个“1”,但它永远会先等待该信号量为一个非零值才开始做减法。也就是说,如果你对一个值为2的信号量调用sem_wait(),线程将会继续执行,信号量的值将减到1。如果对一个值为0的信号量调用sem_wait(),这个函数就会等待直到有其它线程增加了这个值使它不再是0为止。
如果有两个线程都在sem_wait()中等待同一个信号量变成非零值,那么当它被第三个线程增加一个“1”时,等待线程中只有一个能够对信号量做减法并继续执行,另一个还将处于等待状态。
信号量这种“只用一个函数就能原子化地测试和设置”的能力下正是它的价值所在。还有另外一个信号量函数sem_trywait,它是sem_wait的非阻塞搭档。
什么是原子操作呢?原子操作,就是不能被更高等级中断抢夺优先的操作。你既然提这个问题,我就说深一点。由于操作系统大部分时间处于开中断状态,所以,一个程序在执行的时候可能被优先级更高的线程中断。而有些操作是不能被中断的,不然会出现无法还原的后果,这时候,这些操作就需要原子操作,就是不能被中断的操作。
(4)sem_destroy(sem_t *sem)
功能:解除信号量
补充:这个函数也使用一个信号量指针做参数,归还自己占据的一切资源。在清理信号量的时候如果还有线程在等待它,用户就会收到一个错误。与其它的函数一样,这些函数在成功时都返回“0”。
◆条件变量
使用条件变量可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的,条件变量始终与互斥锁一起使用,条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,它常和互斥锁一起使用。使用时,条件变量被用来阻塞一个线程,当条件不满足时,线程往往解开相应的互斥锁并等待条件发生变化。一旦其它的某个线程改变了条件变量,它将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。这些线程将重新锁定互斥锁并重新测试条件是否满足。一般说来,条件变量被用来进行线程间的同步。
例程:
#include<stdio.h>
#include<pthread.h>
int i=0;
pthread_mutex_t job_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
void *pthread1(void *argc)
{
int l=50,j;
while(l--)
{
j=10;
pthread_mutex_lock(&job_mutex);
printf("hello5");
while(i)
pthread_cond_wait(&cond,&job_mutex);
printf("hello6");
pthread_mutex_unlock(&job_mutex);
printf("hello7");
while(j--)
{
fputc('*',stderr);
}
i=1;
printf("hello8");
pthread_cond_signal(&cond);
}
}
int main()
{
pthread_t pthread_1;
pthread_create(&pthread_1,NULL,pthread1,NULL);
int l=50;
int j;
while(l--)
{
j=100;
pthread_mutex_lock(&job_mutex);
printf("hello1");
while(!i)
pthread_cond_wait(&cond,&job_mutex);
printf("hello2");
pthread_mutex_unlock(&job_mutex);
printf("hello3");
while(j--)
{
fputc('#',stderr);
}
i=0;
}
printf("hello4");
pthread_join(pthread_1,NULL);
return 0;
}
解析:
(1) 条件变量的结构为pthread_cond_t (相当于windows中的事件的作用)
(2)条件变量的初始化
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr); 其中restrict cond是一个指向结构pthread_cond_t的指针,restrict attr是一个指向结构pthread_condattr_t的指针。结构pthread_condattr_t是条件变量的属性结构,和互斥锁一样我们可以用它来设置条件变量是进程内可用还是进程间可用,默认值是PTHREAD_ PROCESS_PRIVATE,即此条件变量被同一进程内的各个线程使用,注意初始化条件变量只有未被使用时才能重新初始化或被释放。
(3)条件变量的释放
pthread_cond_destroy(pthread_cond_t *cond)释放一个条件变量
(4)条件变量的等待
函数pthread_cond_wait()使线程阻塞在一个条件变量上。它的函数原型为:
extern int pthread_cond_wait_P ((pthread_cond_t *__cond,pthread_mutex_t *__mutex));
例如:while(!i)
pthread_cond_wait(&cond,&job_mutex);
线程解开mutex指向的锁并被条件变量cond阻塞。线程可以被函数pthread_cond_signal和函数pthread_cond_broadcast唤醒,但是要注意的是,条件变量只是起阻塞和唤醒线程的作用,具体的判断条件还需用户给出,例如一个变量是否为0等等,这一点我们从后面的例子中可以看到。线程被唤醒后,它将重新检查判断条件是否满足,如果还不满足,一般说来线程应该仍阻塞在这里,被等待被下一次唤醒。这个过程一般用while语句实现。
另一个用来阻塞线程的函数是pthread_cond_timedwait(),它的原型为:
int pthread_cond_timedwait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
它比函数pthread_cond_wait()多了一个时间参数,经历abstime段时间后,即使条件变量不满足,阻塞也被解除。
(5)条件变量的解除改变
函数pthread_cond_signal()的原型为:
int pthread_cond_signal(pthread_cond_t *cond);它用来释放被阻塞在条件变量cond上的一个线程。多个线程阻塞在此条件变量上时,哪一个线程被唤醒是由线程的调度策略所决定的。要注意的是,必须用保护条件变量的互斥锁来保护这个函数,否则条件满足信号又可能在测试条件和调用pthread_cond_wait函数之间被发出,从而造成无限制的等待。
下面是使用函数pthread_cond_wait()和函数pthread_cond_signal()的一个简单的例子。
pthread_mutex_t count_lock;
pthread_cond_t count_nonzero;
unsigned count;
decrement_count ()
{
pthread_mutex_lock (&count_lock);
while(count==0)
pthread_cond_wait( &count_nonzero, &count_lock);
count=count -1;
pthread_mutex_unlock (&count_lock);
}
increment_count(){
pthread_mutex_lock(&count_lock);
if(count==0)
pthread_cond_signal(&count_nonzero);
count=count+1;
pthread_mutex_unlock(&count_lock);
}
count值为0时,decrement函数在pthread_cond_wait处被阻塞,并打开互斥锁count_lock。此时,当调用到函数increment_count时,pthread_cond_signal()函数改变条件变量,告知decrement_count()停止阻塞。
◆ Linux和wondows进程线程间通信机制
Linux进程间通信
linux下进程间通信的几种主要手段简介:
a) 管道(Pipe)及有名管道(named pipe):管道可用于具有亲缘关系进程间的通信,有名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信;
b) 信号(Signal):信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数);
c) Message(消息队列):消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
d)共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
e)信号量(semaphore):主要作为进程间以及同一进程不同线程之间的同步手段。
f)套接口(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,但现在一般可以移植到其它类Unix系统上:Linux和System V的变种都支持套接字。
Linux线程间通信:互斥体,信号量,条件变量
Windows线程间通信:临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)、事件(Event)
Windows 进程间通信:管道、内存共享、消息队列、信号量、socket
Windows 进程和线程共同之处:信号量和消息(事件)
临界区(Critical section)与互斥体(Mutex)的区别
1、临界区只能用于对象在同一进程里线程间的互斥访问;互斥体可以用于对象进程间或线程间的互斥访问。
2、临界区是非内核对象,只在用户态进行锁操作,速度快;互斥体是内核对象,在核心态进行锁操作,速度慢。
3、临界区和互斥体在Windows平台都下可用;Linux下只有互斥体可用
Windows线程间通信的区别:
1.互斥量与临界区的作用非常相似,但互斥量是可以命名的,也就是说它可以跨越进程使用。所以创建互斥量需要的资源更多,所以如果只为了在进程内部是用的话使用临界区会带来速度上的优势并能够减少资源占用量。因为互斥量是跨进程的互斥量一旦被创建,就可以通过名字打开它。互斥体不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享 .互斥量比临界区复杂
2. 互斥量(Mutex),信号灯(Semaphore),事件(Event)都可以被跨越进程使用来进行同步数据操作,而其他的对象与数据同步操作无关,但对于进程和线程来讲,如果进程和线程在运行状态则为无信号状态,在退出后为有信号状态。所以可以使用WaitForSingleObject来等待进程和线程退出。
◆ 建立动态库
(1) 建立动态库
hello.c
#include<stdio.h>
int print(char c,int n)
{
while(n--)
printf("%c",c);
return 1;
}
void show()
{
printf("This is a test!\n");
}
void show2()
{
printf("hahahhahhahahha\n");
}
建立方法:gcc -fpic -shared -o libhello.so hello.c
(2)建立测试文件
A、main.c 直接加载共享库
#include<stdio.h>
int main()
{
print("Q",10);
return 1;
}
测试方法:gcc -o main main.c -L -l hello.so
B、main2.c搜索加载共享库
#include<stdio.h>
#include<dlfcn.h>
int main()
{
int (*show)(void);
void *dlptr=NULL;
dlptr=dlopen("/usr/lib/libhello.so",RTLD_NOW);
show=dlsym(dlptr,"show2");
show();
return 1;
}
测试方法:gcc main2.c -o main2 -l dl
(2) 执行文件
◆ 简单的学习makefile
相信在unix下编程的没有不知道makefile的,刚开始学习unix平台下的东西,了解了下makefile的制作,觉得有点东西可以记录下。
下面是一个极其简单的例子:
现在我要编译一个Hello world,需要如下三个文件:
1. print.h
#include<stdio.h>
void printhello();
2. print.c
#include"print.h"
void printhello()
{
printf("Hello, world\n");
}
3. main.c
#include "print.h"
int main(void)
{
printhello();
return 0;
}
好了,很简单的程序了。如果我们想要编译成功需要哪些步骤呢?
我认为在这里需要理解的就两步:
# 为每一个 *.c文件生成 *.o文件。
# 连接每一个*.o文件,生成可执行文件。
下面的makefile 就是根据这样的原则来写的。
一:makefile 雏形:
#makefile的撰写是基于规则的,当然这个规则也是很简单的,就是:
#target : prerequisites
command //任意的shell 命令
实例如下:
makefile:
helloworld : main.o print.o
# helloword 就是我们要生成的目标
# main.o print.o是生成此目标的先决条件
gcc -o helloworld main.o print.o #shell命令,最前面的一定是一个tab键
main.o : main.c print.h
gcc -c main.c
print.o : print.c print.h
gcc -c print.c
clean :
rm helloworld main.o print.o
OK,一个简单的makefile制作完毕,现成我们输入 make,自动调用Gcc编译了,
输入 make clean就会删除 hellowworld mian.o print.o
二:小步改进:
在上面的例子中我们可以发现 main.o print.o 被定义了多处,
我们是不是可以向C语言中定义一个宏一样定义它呢?当然可以:
makefile:
objects = main.o print.o #应该叫变量的声明更合适
helloworld : $(objects) #声明了变量以后使用就要$()了
gcc -o helloworld$(objects)
mian.o : mian.c print.h
gcc -c main.c
print.o : print.c print.h
gcc -c print.c
clean :
rm helloworld $(objects)
修改完毕,这样使用了变量的话在很多文件的工程中就能体现出方便性了。
三:再进一步:
再看一下,为没一个*.o文件都写一句gcc -c main.c是不是显得多余了,能不能把它干掉?而且 main.c 和print.c都需要print.h,为每一个都写上是不是多余了,能不能再改进?能,当然能了:
makefile:
objects = main.o print.o
helloworld : $(objects)
gcc -o helloworld $(objects)
$(objects) : print.h # 都依赖print.h
main.o : main.c #干掉了gcc -c main.c 让Gun make自动推导了。
print.o : print.c
clean :
rm helloworld $(objects)
好了,一个简单的makefile就这样完毕了,简单吧。
◆ 线程和进程中的并发机制
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
#define LOOP 5
int num = 0;
int parm_0 = 0;
int parm_1 = 1;
pthread_mutex_t mylock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t qready=PTHREAD_COND_INITIALIZER;
void* thread_func(void *arg)
{
int i, j;
for(i = 0; i < LOOP; i++)
{
pthread_mutex_lock(&mylock);
while(parm_0 != num)
pthread_cond_wait(&qready, &mylock);
printf("thread: \n");
for(j = 0; j < 10; j++)
printf(" %d ", j);
printf("\n");
num = (num + 1) % 2;
pthread_mutex_unlock(&mylock);
pthread_cond_signal(&qready);
}
return (void*) 0;
}
int main()
{
int i, k;
pthread_t tid;
void *tret;
pthread_create(&tid, NULL, thread_func, NULL);
for(i = 0; i < LOOP; i++)
{
pthread_mutex_lock(&mylock);
while(parm_1 != num)
pthread_cond_wait(&qready, &mylock);
printf("main: \n");
for(k = 0; k < 10; k++)
printf(" %d", k + 100);
printf("\n");
num = (num + 1) % 2;
pthread_mutex_unlock(&mylock);
pthread_cond_signal(&qready);
}
pthread_join(tid, &tret);
}
-----------------------华丽的分割线------------------------
线程面试题:
1、线程的基本概念,线程的基本状态及状态之间的关系?
线程的基本概念:一个程序中可以有多条执行线索同时执行,一个线程就是程序中的一条执行线索,每个线程上都关联有要执行的代码,即可以有多段程序代码同时运行,每个程序至少都有一个线程,即main方法执行的那个线程。如果只是一个cpu,它怎么能够同时执行多段程序呢?这是从宏观上来看的,cpu一会执行a线索,一会执行b线索,切换时间很快,给人的感觉是a,b在同时执行,好比大家在同一个办公室上网,只有一条链接到外部网线,其实,这条网线一会为a传数据,一会为b传数据,由于切换时间很短暂,所以,大家感觉都在同时上网。
线程的基本状态及状态转换:就绪,运行,synchronize阻塞,wait和sleep挂起,结束。wait必须在synchronized内部调用。
调用线程的start方法后线程进入就绪状态,线程调度系统将就绪状态的线程转为运行状态,遇到synchronized语句时,由运行状态转为阻塞,当synchronized获得锁后,由阻塞转为运行,在这种情况可以调用wait方法转为挂起状态,当线程关联的代码执行完后,线程变为结束状态。
2、多线程有几种实现方法,都是什么?
实现线程有两种 继承Thread类或者实现Runnable接口... 实现同步也有两种,一种是用同步方法,一种是用同步块.. 同步方法就是在方法返回类型后面加上synchronized, 比如:
public void synchronized add(){...}
同步块就是直接写:synchronized (这里写需要同步的对象){...}
3、进程的用户栈和内核栈
进程是程序的一次执行过程。用剧本和演出来类比,程序相当于剧本,而进程则相当于剧本的一次演出,舞台、灯光则相当于进程的运行环境。
进程的堆栈
每个进程都有自己的堆栈,内核在创建一个新的进程时,在创建进程控制块task_struct的同时,也为进程创建自己堆栈。一个进程 有2个堆栈,用户堆栈和系统堆栈;用户堆栈的空间指向用户地址空间,内核堆栈的空间指向内核地址空间。当进程在用户态运行时,CPU堆栈指针寄存器指向的 用户堆栈地址,使用用户堆栈,当进程运行在内核态时,CPU堆栈指针寄存器指向的是内核栈空间地址,使用的是内核栈;
当进程由于中断或系统调用从用户态转换到内核态时,进程所使用的栈也要从用户栈切换到内核栈。系统调用实质就是通过指令产生中断,称为软中断。进程因为中断(软中断或硬件产生中断),使得CPU切换到特权工作模式,此时进程陷入内核态,进程进入内核态后,首先把用户态的堆栈地址保存在内核堆栈中,然后设置堆栈指针寄存器的地址为内核栈地址,这样就完成了用户栈向内核栈的切换。
当进程从内核态切换到用户态时,最后把保存在内核栈中的用户栈地址恢复到CPU栈指针寄存器即可,这样就完成了内核栈向用户栈的切换。
这里要理解一下内核堆栈。前面我们讲到,进程从用户态进入内核态时,需要在内核栈中保存用户栈的地址。那么进入内核态时,从哪里获得内核栈的栈指针呢?
要解决这个问题,先要理解从用户态刚切换到内核态以后,进程的内核栈总是空的。这点很好理解,当进程在用户空间运行时,使用的是用户栈;当进程在内核态运行时,内核栈中保存进程在内核态运行的相关信息,但是当进程完成了内核态的运行,重新回到用户态时,此时内核栈中保存的信息全部恢复,也就是说,进程在内核态中的代码执行完成回到用户态时,内核栈是空的。
理解了从用户态刚切换到内核态以后,进程的内核栈总是空的,那刚才这个问题就很好理解了,因为内核栈是空的,那当进程从用户态切换到内核态后,把内核栈的栈顶地址设置给CPU的栈指针寄存器就可以了。
X86 Linux内核栈定义如下(可能现在的版本有所改变,但不妨碍我们对内核栈的理解):
在/include/linux/sched.h中定义了如下一个联合结构:
union task_union {
struct task_struct task;
unsigned long stack[2048];
};
从这个结构可以看出,内核栈占8kb的内存区。实际上,进程的task_struct结构所占的内存是由内核动态分配的,更确切地说,内核根本不给task_struct分配内存,而仅仅给内核栈分配8K的内存,并把其中的一部分给task_struct使用。
这样内核栈的起始地址就是union task_union变量的地址+8K 字节的长度。例如:我们动态分配一个union task_union类型的变量如下:
unsigned char *gtaskkernelstack
gtaskkernelstack = kmalloc(sizeof(union task_union));
那么该进程每次进入内核态时,内核栈的起始地址均为:(unsigned char *)gtaskkernelstack + 8096
进程上下文
进程切换现场称为进程上下文(context),包含了一个进程所具有的全部信息,一般包括:进程控制块(Process Control Block,PCB)、有关程序段和相应的数据集。
进程控制块PCB(任务控制块)
进程控制块是进程在内存中的静态存在方式,Linux内核中用task_struct表示一个进程(相当于进程的人事档案)。进程的静 态描述必须保证一个进程在获得CPU并重新进入运行态时,能够精确的接着上次运行的位置继续进行,相关的程序段,数据以及CPU现场信息必须保存。处理机 现场信息主要包括处理机内部寄存器和堆栈等基本数据。
进程控制块一般可以分为进程描述信息、进程控制信息,进程相关的资源信息和CPU现场保护机构。
进程的切换
当一个进程的时间片到时,进程需要让出CPU给其他进程运行,内核需要进行进程切换。
Linux 的进程切换是通过调用函数进程切换函数schedule来实现的。进程切换主要分为2个步骤:
1. 调用switch_mm()函数进行进程页表的切换;
2. 调用 switch_to() 函数进行 CPU寄存器切换;
__switch_to定义在/arch/arm/kernel目录下的entry-armv.S 文件中,源码如下:
-----------------------------------------------------------------------------
ENTRY(__switch_to)
UNWIND(.fnstart )
UNWIND(.cantunwind )
add ip, r1, #TI_CPU_SAVE
ldr r3, [r2, #TI_TP_VALUE]
stmia ip!, {r4 - sl, fp, sp, lr} @ Store most regs on stack
#ifdef CONFIG_MMU
ldr r6, [r2, #TI_CPU_DOMAIN]
#endif
#if __LINUX_ARM_ARCH__ >= 6
#ifdef CONFIG_CPU_32v6K
clrex
#else
strex r5, r4, [ip] @ Clear exclusive monitor
#endif
#endif
#if defined(CONFIG_HAS_TLS_REG)
mcr p15, 0, r3, c13, c0, 3 @ set TLS register
#elif !defined(CONFIG_TLS_REG_EMUL)
mov r4, #0xffff0fff
str r3, [r4, #-15] @ TLS val at 0xffff0ff0
#endif
#ifdef CONFIG_MMU
mcr p15, 0, r6, c3, c0, 0 @ Set domain register
#endif
mov r5, r0
add r4, r2, #TI_CPU_SAVE
ldr r0, =thread_notify_head
mov r1, #THREAD_NOTIFY_SWITCH
bl atomic_notifier_call_chain
mov r0, r5
ldmia r4, {r4 - sl, fp, sp, pc} @ Load all regs saved previously
UNWIND(.fnend )
ENDPROC(__switch_to)
----------------------------------------------------------
Switch_to的处理流程如下:
1. 保存本进程的CPU寄存器(PC、R0 ~ R13)到本进程的栈中;
2. 保存SP(本进程的栈基地址)到task->thread.save 中;
3. 从新进程的task->thread.save恢复SP为新进程的栈基地址;
4. 从新进程的栈中恢复新进程的CPU相关寄存器值,
5. 新进程开始运行,完成任务切换。
这里读者可能会问,在进行任务切换的时候,到底是在运行进程1还是运行进程2呢?进程切换的时候,已经进行页表切换,那页表切换之后,切换进程使用的是进程1还是进程2的页表呢?
要回答这个问题,首先我们要明白由谁来完成进程切换?
通过对操作系统的理解,毫无疑问,进程切换是由内核来完成的,也就是说,在进行进程切换时,CPU运行在内核模式,使用的是内核空间的内核代码,它既不属于进程1,也不属于进程2,当进程的时间片到时,内核提供服务来完成进程的切换。既不使用进程1的页表,也不使用进程2的页表,使用的内核映射页表。这样我们就很好理解上面的问题了。
◆ 内核态和用户态
1.内核态与用户态
◆内核态与用户态
intel x86 架构的 CPU 分 Ring0-Ring3 四种级别的运行模式,Ring0级别最高,Ring3 最低。
- 针对不同的 级别,有很多的限制,比如说传统的 in ,out 指令,就是端口的输入输出指令,在 Ring0 级下是可以用的,但在 Ring3 级下就不能用,你用就产生陷阱,告诉你出错了,当然限制还有很多了,不只是这一点。
- 操作系统下是利用这个特点,当操作系统自己的代码运行时, CPU 就切成 Ring0 级,当用户的程序运行是就只让它在 Ring3 级运行,这样如果用户的程序想做什么破坏系统的事情的话,也没办法做到。
- 当然,低级别的程序是没法把自己升到高级别的,也就是说 用户程序运行在 Ring3 级,他想把自己变成 Ring0 级自己是做不到的,除非是操作系统帮忙。
- 利用这个特性,操作系统就可以控制所有的程序的运行,确保系统的安全了. 平时把操作系统运行时的级别就叫内核态(因为是操作系统内核运行时的状态),而且普通用户程序运行时的那个级别叫用户态。
- 当操作系统刚引导时, CPU 处于实模式,这时就相当于是 Ring0 级,于是操作系统就自动得到最高权限,然后切到保护模式时就是 Ring0 级,这时操作系统就占了先机,成为了最高级别的运行者,由于你的程序都是由操作系统来加载的,所以当它把你加载上来后,就把你的运行状态设为 Ring3 级,即最低级,然后才让你运行,所以没办法,你只能在最低级运行了,因为没办法把自己从低级上升到高级。
- Linux使用了Ring3级别运行用户态,Ring0作为内核态,没有使用Ring1和Ring2。
- Ring3状态不能访问 Ring0的地址空间,包括代码和数据。
- Linux进程的4GB地址空间,3G-4G部分大家是共享的,是内核态的地址空间,这里存放在整个内核的代码和所有的内核模块,以及内核所维护的数据。
- 用户运行一个程序,该程序所创建的进程开始是运行在用户态的,如果要执行文件操作,网络数据发送等操作,必须通过 write,send等系统调用,这些系统调用会调用内核中的代码来完成操作,这时,必须切换到Ring0,然后进入3GB-4GB中的内核地址空间去执 行这些代码完成操作,完成后,切换回Ring3,回到用户态。
- 这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。
- 至于说保护模式,是说通过内存页表操作等机制,保证进程间的地址空间不会互相冲突,一个进程的操作不会修改另一个进程的地址空间中的数据。
例如:版主 可以删除你的贴子,你却不能删除 版主 的贴子 ,当然你也没法让自己变成 版主 ,除非 版主 把你升为 版主(或是系统有 bug ).....
例如:这个论坛是由 版主 创立的,当时创立时就他一个用户,所以它就自然取得了最高权限了,即 版主 运行在 Ring0 级(内核态),然后他再让我们注册自己的账号,但是设置的权限是普通用户,所以我们一注册就只能是普通用户,即我们只能运行在 Ring3 级(用户级),我们没有更多的权限,所以我们只能任由 版主 "蹂躏" 了,看你不爽就删除你的贴子,踢你下线,加你进黑名单,永久封你的IP之类的,你除了发发牢骚之外也是无可奈何了, 这就是操作系统在内核态可以管理用户程序,杀死用户程序的原因了。
Linux 环境下的内核态与用户态
◆ linux允许每个线程有多大的线性地址空间?
用户空间占用从0x00000000到0xBFFFFFFF共3GB的线性地址空间,每个进程都有一个独立的3GB用户空间,所以用户空间由每个进程独有,但是内核线程没有用户空间,因为它不产生用户空间地址。另外子进程共享(继承)父进程的用户空间只是使用与父进程相同的用户线性地址到物理内存地址的映射关系,而不是共享父进程用户空间。运行在用户态和内核态的进程都可以访问用户空间。
◆系统调用
■定义:Linux内核中设置了一组用于实现各种系统功能的子程序,称为系统调用。用户可以通过系统调用命令在自己的应用程序中调用它们。
■库函数
我认为fopen(库函数)和open(系统调用)最主要的区别是fopen在用户态下就有了缓存,在进行read和write的时候减少了用户态和内核态的切换,而open则每次都需要进行内核态和用户态的切换;表现为,如果顺序访问文件,fopen系列的函数要比直接调用open系列快;如果随机访问文件open要比fopen快。
fopen是有缓冲机制的,它使用了FILE这个结构才保存缓冲数据。
open没有缓存机制,每次读操作都直接从文件系统中获取数据。
看一下FILE这个结构的定义就知道区别了,FILE包含了一个open返回的handle
■系统调用数
在2.6.32 内核中,共有系统调用360个,可在arch/arm/include/asm/unistd.h中找到它们。
■系统调用的工作原理
一般情况下,用户进程是不能访问内核的。它既不能访问内核所在的内存空间,也不能调用内核中的函数。系统调用是一个例外。其原理是进程先用适当的值填充寄存器,然后调用一个特殊的指令,这个指令会让用户程序跳转到一个事先定义好的内核中的一个位置。
1)在Intel CPU中,这个指令由中断0x80实现。
2)在ARM中,这个指令是SWI。
例如:在进程运行时在用户空间调用open(每一个函数对应一个系统调用号,如open为5),进程可以跳转到的内核位置是ENTRY(vector_swi) <entry-common.S>,这个过程检查系统调用号,这个号码5告诉内核进程请求是open这种服务,然后,它查看系统调用表(sys_call_table)找到所调用的内核函数入口地址,接着,就调用函数,等返回后,做一些系统检查,最后返回到进程。
◆proc文件系统
■什么是proc文件系统?
实例:通过 /proc/meminfo,查询当前内存使用情况。
结论:proc文件系统是一种在用户态检查内核状态的机制。
■proc文件