💁♂️个人主页:进击的荆棘
👇作者其它专栏:
目录
1.信号前导
2.产生信号
3.保存信号
4.捕捉信号
5.可重入函数
6.volatile
7.SIGCHLD信号
8.附录
5.可重入函数

●main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先做完第一步之后被打断,现在继续做完第二步。结果是,main函数何sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链接中
●像上例这样,insert函数被不同的控制流调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反正,若一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
若一个函数符号以下条件之一则是不可重入的:
●调用了malloc或free,因为malloc也是用全局链表来管堆的
●调用了标志I/O库函数。标志I/O库的很多实现都以不可重入的方式使用全局数据结构
6.volatile
volatile是C/C++中的一个关键字
cpp
[sjx@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
[sjx@localhost code_test]$ cat Makefile
sig:sig.c
gcc -o sig sig.c #-O2
.PHONY:clean
clean:
rm -f sig
[sjx@localhost code_test]$ ./sig
^Cchage flag 0 to 1
process quit normal
标准情况下,按下Ctrl+c,2号信号被捕捉,执行自定义动作,修改flag=1,while条件不满足,退出循环,进程退出
cpp
[sjx@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
[sjx@localhost code_test]$ cat Makefile
sig:sig.c
gcc -o sig sig.c -O2
.PHONY:clean
clean:
rm -f sig
[sjx@localhost code_test]$ ./sig
^Cchage flag 0 to 1
^Cchage flag 0 to 1
^Cchage flag 0 to 1
优化情况下,按下Ctrl+c,2号信号被捕捉,执行自定义动作,修改flag=1,但while条件依旧满足,进程继续运行。很明显flag肯定被修改了,那为什么循环依旧执行?这是因为,while循环检查的flag,并不是内存中最新的flag,这就存在数据二异性的问题。while检测的flag其实已经因为优化,被放在了CPU寄存器中。此时可以用volatile解决
cpp
[sjx@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
volatile int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
[sjx@localhost code_test]$ cat Makefile
sig:sig.c
gcc -o sig sig.c -O2
.PHONY:clean
clean:
rm -f sig
[sjx@localhost code_test]$ ./sig
^Cchage flag 0 to 1
process quit normal
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
7.SIGCHLD信号
进程时我们用过wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(即轮询)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得不时轮询一下,程序实现复杂。
其实,子进程终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不需关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可
实现一个程序,具有:父进程fork出子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得字节内存的退出状态并打印
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork处理的子进程在终止时会自动清理掉,不会产生僵尸进程,也不是通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对Linux可用,在其它系统则不一定
cpp
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
pid_t id;
while( (id = waitpid(-1, NULL, WNOHANG)) > 0) {
printf("wait child success: %d\n", id);
}
printf("child is quit! %d\n", getpid());
}
int main()
{
signal(SIGCHLD, handler);
pid_t cid;
if((cid = fork()) == 0){//child
printf("child : %d\n", getpid());
sleep(3);
exit(1);
}
while(1){
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}
8.附录
用户态和内核态
●CPU指令集:是CPU实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条CPU指令集,而非常非常多的CPU指令在一起,可以组成一个、甚至多个集合,指令的集合叫CPU指令集
●CPU指令集有权限分级,CPU指令集可以直接操作硬件,要是因为指令操作的不规范,造成的错误会影响整个计算机系统。就像写程序,因为对硬件操作不熟悉,导致操作系统内核、及其它所有正在运行的程序,都可能会因为失误而受到不可挽回的错误,最后只能重启计算机才行
▢对开发任意来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任,所以操作系统内核直接屏蔽开发人员对硬件操作的可能,都不让你碰到这CPU指令集
针对上面的需求,硬件设备商直接提供硬件级别的支持,做法就是对CPU指令集设置了权限,不同级别权限能使用的CPU指令集是有限的,以Inter CPU为例,Inter把CPU指令集操作的权限由高到低划为4级:
●ring 0:权限最高,可以使用所有CPU指令集
●ring 1
●ring 2
●ring 3:权限最低,仅能使用常规CPU指令集,不能使用操作硬件资源的CPU指令集,比如IO读写、网卡访问、申请内存都不行
Linux系统仅采用ring 0和ring 3这两个权限。CPU中有一个标准字段,标志着线程的运行状态,用户态为3,内核态为0
●ring 0被叫做内核态,完全在操作系统内核中运行
▢执行内核空间的代码,具有ring 0保护级别,有对硬件的所有操作权限,可以执行所有CPU指令集,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机
●ring 3被叫做用户态,在应用程序中运行
▢在用户模式下,具有ring 3保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序是通过调用系统接口(System Call APIs)来达到访问硬件和内存,在这种保护模式下,即时程序发送崩溃也是可以恢复的,在电脑上大部分程序都是在,用户模式下运行的
低权限的资源范围较小,高权限的资源范围更大,所以用户态与内核态的概念就是CPU指令集权限的区别。
通过指令集权限区分用户态和内核态,还限制了内存资源的使用,操作系统为用户态与内核态划分了两块内存空间,给它们对应的指令集使用。
在内存资源上的使用,操作系统对用户态与内核态也做了限制,每个进程创建都会分配虚拟空间地址以Linux32位操作系统为例,它的寻址空间范围是4G(2的32次方),而操作系统会把虚拟控制地址划分两部分,一部分为内核空间,另一部分为用户空间,高位的1G(从虚拟地址0xC0000000到0xFFFFFFFF)由内核使用,而低位的3G(从虚拟地址0x00000000到0xBFFFFFFF)由各个进程使用

●用户态:只能操作0-3G范围的低位虚拟空间地址
●内核态:0-4G范围的虚拟空间地址都可以操作,尤其是对3-4G范围的高位寻空间地址必须由内核态去操作
▢3G-4G部分大家是共享的(指所有进程的内核态逻辑地址是共享同一块内存地址),是内核态的地址空间,这里存放在整个内核的代码和所有的内核管理,以及内核所维护的数据
▢在内核运行的过程中,会涉及内核栈的分配,内核的进程管理的代码会将内核栈创建在内核空间中,当然相应的页表也会被创建
用户态与内核态的切换
什么情况会导致用户态到内核态切换?
●系统调用:用户态进程主动切换到内核态的方式,用户态进程通过系统调用向操作系统申请资源完成工作,例如fork()就是一个创建新进程的系统调用
▢操作系统提供了中断指令int 0x80来主动进入内核,这是用户程序发起的调用访问内核代码的唯一方式。调用系统函数时会通过内联汇编代码插入int 0x80的中断指令,内核接收到int 0x80中断后,查询中断处理函数地址,随后进入系统调用
●异常:当CPU在执行用户态的进程时,发生了一些没有预知的异常,这时当前运行进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常
●中断:当CPU在执行用户态的进程时,外围设备完成用户请求的操作后,会像CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令,转到中断信号对应的处理程序去执行,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等
切换到CPU需要做什么?
●当某个进程中要读写IO,必然会用到ring 0级别的CPU指令集。而此时CPU的指令集操作权限只有ring 3,为了可以操作ring 0级别的CPU指令集,CPU切换指令集操作权限级别为ring 0(提权),CPU再执行相应的ring 0级别的CPU指令集(内核代码)
●代码发生提权时,CPU是需要切换栈的。前面提过,内核有自己的内核栈。CPU切换栈是需要栈段描述符(ss寄存器)和栈顶指针(esp寄存器),这两个指哪里来?
▢CPU通过一个段寄存器(tr)确定TSS(任务状态段,struct TSS)的位置。在TSS结构中存在这么一个SS0和ESP0取出来,放到ss和esp寄存器中
切换流程
1.从用户态切换到内核态时,首先用户态可以直接读写寄存器,用户态操作CPU,将寄存器的状态保持到对应的内存中,然后调用对应的系统函数,传入对应的用户栈地址和寄存器信息,方便后续内核方法调用完毕后,恢复用户方法执行的现场
2.从用户态切换到内核态需要提权,CPU切换指令集操作权限级别为ring 0
3.提权后,切换内核栈。然后开始执行内核方法,相应的方法栈帧保存在内核栈中
4.当内核方法执行完毕后,CPU切换指令集操作权限级别为ring 3,然后利用之前写入的信息来恢复用户栈的执行
从上述流程可以看出用户态切换到内核态的时候,会牵扯到用户态现场信息的保持以及恢复,还要进行一系列的安全检查,还是比较耗费资源的
追踪fopen
●源码glibc2.9
●中国科学技术大学镜像站:Directory Listing
cpp
# define _IO_new_fopen fopen //宏
// 转到定义
_IO_FILE *
_IO_new_fopen (filename, mode)
const char *filename;
const char *mode;
{
return __fopen_internal (filename, mode, 1);
}
// 转到定义
_IO_FILE *
__fopen_internal (filename, mode, is32)
const char *filename;
const char *mode;
int is32;
{
if (INTUSE(_IO_file_fopen) ((_IO_FILE *) new_f, filename, mode, is32)
}
// 搜索:_IO_file_fopen
INTDEF2(_IO_new_file_fopen, _IO_file_fopen)
// 为了保证软件兼容性,给_IO_file_fopen 起别名:_IO_new_file_fopen,也就是底层调⽤的
就是_IO_new_file_fopen
// INTDEF2:这个宏⽐较难看,忽略
// 搜索:_IO_new_file_fopen
_IO_FILE *
_IO_new_file_fopen (fp, filename, mode, is32not64)
_IO_FILE *fp;
const char *filename;
const char *mode;
int is32not64;
{
result = _IO_file_open (fp, filename, omode|oflags, oprot, read_write,
is32not64);
}
// 转到定义
_IO_FILE *
_IO_file_open (fp, filename, posix_mode, prot, read_write, is32not64)
_IO_FILE *fp;
const char *filename;
int posix_mode;
int prot;
int read_write;
int is32not64;
{
fdesc = open (filename, posix_mode, prot); // 系统调⽤
}
// open,被__open替换
#define open(Name, Flags, Prot) __open (Name, Flags, Prot)
// _open之后,因为glibc做了很多隐藏机制,我们直接看伪代码就可以了
int __open(const char *file, int oflag, mode_t mode)
{
return INLINE_SYSCALL(open, 3, file, oflag, mode);
}
# define INLINE_SYSCALL(name, nr, args...) \
({ \
unsigned long int resultvar = INTERNAL_SYSCALL (name, , nr, args);\
if (__builtin_expect (INTERNAL_SYSCALL_ERROR_P (resultvar, ), 0)) \
{ \
__set_errno (INTERNAL_SYSCALL_ERRNO (resultvar, )); \
resultvar = (unsigned long int) -1; \
} \
(long int) resultvar; })
# define INTERNAL_SYSCALL(name, err, nr, args...) \
INTERNAL_SYSCALL_NCS (__NR_##name, err, nr, ##args)
// #define __NR_open 2
// 这个前⾯⻅过了
# define INTERNAL_SYSCALL_NCS(name, err, nr, args...) \
({
unsigned long int resultvar; \
LOAD_ARGS_##nr (args) \
LOAD_REGS_##nr \
asm volatile ( \
"syscall\n\t" \
: "=a" (resultvar) \
: "0" (name) ASM_ARGS_##nr : "memory", "cc", "r11", "cx"); \
(long int) resultvar; })
// "0" (__NR_##name):系统调⽤号存⼊ %eax