【Linux】Linux进程信号(下)
目录
一、捕捉信号
1.1 信号捕捉的流程
1.2 sigaction
1.3 操作系统是怎么运行的
1.3.1 硬件中断
1.3.2 时钟中断
1.3.3 死循环
1.3.4 软中断
1.3.5 缺页中断?内存碎片处理?除零野指针错误?
1.4 如何理解内核态和用户态
二、可重入函数
三、volatile
四、SIGCHLD信号
五、附录
用户态和内核态
用户态与内核态的切换
一、捕捉信号
1.1 信号捕捉的流程
如果信号的处理动作是用户自定义的函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,具体如下:
- 用户程序注册了 SIGQIUT 信号的处理函数 sighandler。
- 当前正在执行的 main 函数,这时发生中断或异常切换到内核态。
- 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。
- 内核决定返回用户态后不是恢复 main 函数的上下文数据继续执行,而是执行 sighandler 函数,sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
- sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
- 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文数据继续执行。
1.2 sigaction
#include <signal.h>int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
- sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功返回0,出错返回-1。signo是指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若 oact 指针非空,则通过 oact 传出该信号原来的处理动作。act 和 oact 是指向 sigaction 结构体的指针。
- 将 sa_handler 赋值为常数 SIG_ING 传给 sigaction 表示忽略信号,赋值为常数 SIG_DFL 表示执行系统的默认处理动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,返回值为 void,可以带一个 int 参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多个信号。显然,这也是一个回调函数,不是被 main 函数调用,而是被系统调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入到进程的信号屏蔽字中,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,它会被阻塞到当前处理函数结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,该希望自动屏蔽另一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags 字段包含一些选项,本章的代码都把 sa_flags 设为0,sa_sigaction 是实时信号的处理函数,这里不解释这两个信号。
1.3 操作系统是怎么运行的
1.3.1 硬件中断
- 向量中断表就是操作系统的一部分,启动就加载到内存中了。
- 通过外部硬件中断,操作系统就不需要对外设进行任何周期性检测或轮询
- 由外部设备触发的,中断系统运行流程的,叫做硬件中断。
// Linux内核0.11源码
void trap_init(void)
{int i;set_trap_gate(0, ÷_error); // 设置除操作出错的中断向量值。以下雷同。set_trap_gate(1, &debug);set_trap_gate(2, &nmi);set_system_gate(3, &int3); /* int3-5 can be called from all */set_system_gate(4, &overflow);set_system_gate(5, &bounds);set_trap_gate(6, &invalid_op);set_trap_gate(7, &device_not_available);set_trap_gate(8, &double_fault);set_trap_gate(9, &coprocessor_segment_overrun);set_trap_gate(10, &invalid_TSS);set_trap_gate(11, &segment_not_present);set_trap_gate(12, &stack_segment);set_trap_gate(13, &general_protection);set_trap_gate(14, &page_fault);set_trap_gate(15, &reserved);set_trap_gate(16, &coprocessor_error);// 下面将int17-48 的陷阱门先均设置为reserved,以后每个硬件初始化时会重新设置自己的陷阱门。for (i = 17; i < 48; i++)set_trap_gate(i, &reserved);set_trap_gate(45, &irq13); // 设置协处理器的陷阱门。outb_p(inb_p(0x21) & 0xfb, 0x21); // 允许主8259A 芯片的IRQ2 中断请求。outb(inb_p(0xA1) & 0xdf, 0xA1); // 允许从8259A 芯片的IRQ13 中断请求。set_trap_gate(39, ¶llel_interrupt); // 设置并行口的陷阱门。
}void rs_init(void)
{set_intr_gate(0x24, rs1_interrupt); // 设置串行口1 的中断门向量(硬件IRQ4信号)。set_intr_gate(0x23, rs2_interrupt); // 设置串行口2 的中断门向量(硬件IRQ3信号)。init(tty_table[1].read_q.data); // 初始化串行口1(.data 是端口号)。init(tty_table[2].read_q.data); // 初始化串行口2。outb(inb_p(0x21) & 0xE7, 0x21); // 允许主8259A 芯片的IRQ3,IRQ4 中断信号请求。
}
1.3.2 时钟中断
问题:
- 进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?
- 外部设备可以触发硬件中断,但是这个需要用户或者设备自己触发的,有没有自己可以定期触发的设备?
// Linux 内核0.11
// main.c
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (kernel/sched.c)
// 调度程序的初始化子程序。
void sched_init(void)
{
...
set_intr_gate(0x20, &timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断。
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调⽤中断⻔。
set_system_gate(0x80, &system_call);
...
}
// system_call.s
_timer_interrupt:
...
; // do_timer(CPL)执行任务切换、计时等工作,在kernel/shched.c,305 行实现。
call _do_timer; // 'do_timer(long CPL)' does everything from
// 调度入口
void do_timer(long cpl)
{
...
schedule();
}
void schedule(void)
{
...
switch_to(next); // 切换到任务号为next 的任务,并运⾏之。
}
1.3.3 死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。操作系统的本质,就是一个死循环!
void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */.../** 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。*/for (;;)pause();
} // end main
- 这样,操作系统就可以在硬件时钟的推动下,自动调度了。
- 所以,什么是时间片?CPU为什么会有主频?为什么主频越快,CPU越快?主频可以作为OS调度执行速度的参考之一。
1.3.4 软中断
- 上述外部硬件中断,需要硬件设备触发。
- 有没有可能,因为软件原因,也触发上面的逻辑呢?有!
- 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。
所以:
问题:
- 用户层怎么把系统调用号给操作系统?寄存器(比如EAX)
- 操作系统怎么把返回值给用户?寄存器或者用户传入的缓冲区地址
- 系统调用的过程,其实就是先 int 0x80、syscall 陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法
- 系统调用号的本质:数组下标
// sys.h
// 系统调用函数指针表。用于系统调用中断处理程序(int 0x80),作为跳转表。
extern int sys_setup(); // 系统启动初始化设置函数。 (kernel/blk_drv/hd.c,71)
extern int sys_exit(); // 程序退出。 (kernel/exit.c, 137)
extern int sys_fork(); // 创建进程。 (kernel/system_call.s, 208)
extern int sys_read(); // 读文件。 (fs/read_write.c, 55)
extern int sys_write(); // 写文件。 (fs/read_write.c, 83)
extern int sys_open(); // 打开文件。 (fs/open.c, 138)
extern int sys_close(); // 关闭文件。 (fs/open.c, 192)
extern int sys_waitpid(); // 等待进程终止。 (kernel/exit.c, 142)
extern int sys_creat(); // 创建文件。 (fs/open.c, 187)
extern int sys_link(); // 创建一个文件的硬连接。 (fs/namei.c, 721)
extern int sys_unlink(); // 删除一个文件名(或删除文件)。 (fs/namei.c, 663)
extern int sys_execve(); // 执行程序。 (kernel/system_call.s, 200)
extern int sys_chdir(); // 更改当前目录。 (fs/open.c, 75)
extern int sys_time(); // 取当前时间。 (kernel/sys.c, 102)
extern int sys_mknod(); // 建立块/字符特殊立件。 (fs/namei.c, 412)
extern int sys_chmod(); // 修改文件属性。 (fs/open.c, 105)
extern int sys_chown(); // 修改文件宿主和所属组。 (fs/open.c, 121)
extern int sys_break(); // (-kernel/sys.c, 21)
extern int sys_stat(); // 使用路径名取文件的状态信息。 (fs/stat.c, 36)
extern int sys_lseek(); // 重新定位读/写文件偏移。 (fs/read_write.c, 25)
extern int sys_getpid(); // 取进程id。 (kernel/sched.c, 348)
extern int sys_mount(); // 安装文件系统。 (fs/super.c, 200)
extern int sys_umount(); // 卸载文件系统。 (fs/super.c, 167)
extern int sys_setuid(); // 设置进程用户id。 (kernel/sys.c, 143)
extern int sys_getuid(); // 取进程用户id。 (kernel/sched.c, 358)
extern int sys_stime(); // 设置系统时间日期。 (-kernel/sys.c, 148)
extern int sys_ptrace(); // 程序调试。 (-kernel/sys.c, 26)
extern int sys_alarm(); // 设置报警。 (kernel/sched.c, 338)
extern int sys_fstat(); // 使用文件句柄取文件的状态信息。(fs/stat.c, 47)
extern int sys_pause(); // 暂停进程运行。 (kernel/sched.c, 144)
extern int sys_utime(); // 改变文件的访问和修改时间。 (fs/open.c, 24)
extern int sys_stty(); // 修改终端行设置。 (-kernel/sys.c, 31)
extern int sys_gtty(); // 取终端行设置信息。 (-kernel/sys.c, 36)
extern int sys_access(); // 检查用户对一个文件的访问权限。(fs/open.c, 47)
extern int sys_nice(); // 设置进程执行优先权。 (kernel/sched.c, 378)
extern int sys_ftime(); // 取日期和时间。 (-kernel/sys.c,16)
extern int sys_sync(); // 同步高速缓冲与设备中数据。 (fs/buffer.c, 44)
extern int sys_kill(); // 终止一个进程。 (kernel/exit.c, 60)
extern int sys_rename(); // 更改文件名。 (-kernel/sys.c, 41)
extern int sys_mkdir(); // 创建目录。 (fs/namei.c, 463)
extern int sys_rmdir(); // 删除目录。 (fs/namei.c, 587)
extern int sys_dup(); // 复制文件句柄。 (fs/fcntl.c, 42)
extern int sys_pipe(); // 创建管道。 (fs/pipe.c, 71)
extern int sys_times(); // 取运行时间。 (kernel/sys.c, 156)
extern int sys_prof(); // 程序执行时间区域。 (-kernel/sys.c, 46)
extern int sys_brk(); // 修改数据段长度。 (kernel/sys.c, 168)
extern int sys_setgid(); // 设置进程组id。 (kernel/sys.c, 72)
extern int sys_getgid(); // 取进程组id。 (kernel/sched.c, 368)
extern int sys_signal(); // 信号处理。 (kernel/signal.c, 48)
extern int sys_geteuid(); // 取进程有效用户id。 (kenrl/sched.c, 363)
extern int sys_getegid(); // 取进程有效组id。 (kenrl/sched.c, 373)
extern int sys_acct(); // 进程记帐。 (-kernel/sys.c, 77)
extern int sys_phys(); // (-kernel/sys.c, 82)
extern int sys_lock(); // (-kernel/sys.c, 87)
extern int sys_ioctl(); // 设备控制。 (fs/ioctl.c, 30)
extern int sys_fcntl(); // 文件句柄操作。 (fs/fcntl.c, 47)
extern int sys_mpx(); // (-kernel/sys.c, 92)
extern int sys_setpgid(); // 设置进程组id。 (kernel/sys.c, 181)
extern int sys_ulimit(); // (-kernel/sys.c, 97)
extern int sys_uname(); // 显示系统信息。 (kernel/sys.c, 216)
extern int sys_umask(); // 取默认文件创建属性码。 (kernel/sys.c, 230)
extern int sys_chroot(); // 改变根系统。 (fs/open.c, 90)
extern int sys_ustat(); // 取文件系统信息。 (fs/open.c, 19)
extern int sys_dup2(); // 复制文件句柄。 (fs/fcntl.c, 36)
extern int sys_getppid(); // 取父进程id。 (kernel/sched.c, 353)
extern int sys_getpgrp(); // 取进程组id,等于getpgid(0)。(kernel/sys.c, 201)
extern int sys_setsid(); // 在新会话中运行程序。 (kernel/sys.c, 206)
extern int sys_sigaction(); // 改变信号处理过程。 (kernel/signal.c, 63)
extern int sys_sgetmask(); // 取信号屏蔽码。 (kernel/signal.c, 15)
extern int sys_ssetmask(); // 设置信号屏蔽码。 (kernel/signal.c, 20)
extern int sys_setreuid(); // 设置真实与/或有效用户id。 (kernel/sys.c,118)
extern int sys_setregid(); // 设置真实与/或有效组id。 (kernel/sys.c, 51)
// 系统调用函数指针表。用于系统调用中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = {sys_setup, sys_exit, sys_fork, sys_read,sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,sys_setreuid, sys_setregid};// 调度程序的初始化子程序。
void sched_init(void)
{...// 设置系统调用中断门。set_system_gate(0x80, &system_call);
}_system_call : cmp eax, nr_system_calls - 1; // 调用号如果超出范围的话就在eax 中置-1 并退出。ja bad_sys_call;push ds; // 保存原段寄存器值。push es;push fs;push edx; // ebx,ecx,edx 中放着系统调用相应的C 语言函数的调用参数。push ecx; // push %ebx,%ecx,%edx as parameterspush ebx; // to the system callmov edx, 10h; // set up ds,es to kernel spacemov ds, dx; // ds,es 指向内核数据段(全局描述符表中数据段描述符)。mov es, dx mov edx, 17h; // fs points to local data spacemov fs, dx; // fs 指向局部数据段(局部描述符表中数据段描述符)。
; // 下面这句操作数的含义是:调用地址 = _sys_call_table + %eax * 4。参见列表后的说明。
; // 对应的C 程序中的sys_call_table 在include/linux/sys.h 中,其中定义了一个包括72个
; // 系统调用C 处理函数的地址数组表。
call[_sys_call_table + eax * 4] push eax; // 把系统调用号入栈。mov eax, _current; // 取当前任务(进程)数据结构地址??eax。
; // 下⾯97-100 行查看当前任务的运行状态。如果不在就绪状态(state 不等于0)就去执行调度程序。
; // 如果该任务在就绪状态但counter[??]值等于0,则也去执行调度程序。cmp dword ptr[state + eax], 0; // statejne reschedulecmp dword ptr[counter + eax], 0; // counterje reschedule
; // 以下这段代码执行从系统调用C 函数返回后,对信号量进行识别处理。
ret_from_sys_call:
- 可是为什么我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall 呢?都是直接调用上层的函数啊?
- 那是因为Linux的gnu C标准库给我们把几乎所有的系统调用都封装了。
- #define SYS_ify(syscall_name) __NR_##syscall_name:是一个宏定义,用于将系统调用的名称转换成对应的系统调用号,比如:SYS_ify(open) 会被展开为 __NR_open
- 而系统调用号,不是 glibc 提供的,是内核提供的,内核提供系统调用入口函数 man 2 syscall,或者直接提供汇编级别软中断命令 int or syscall,并提供对应的头文件或者开发入口,让上层语言的设计者使用系统调用号,完成系统调用过程。
// 源代码路径:linux-2.6.18\linux-2.6.18\include\asm-x86_64\unistd.h/* at least 8 syscall per cacheline */ #define __NR_read 0 __SYSCALL(__NR_read, sys_read) #define __NR_write 1 __SYSCALL(__NR_write, sys_write) #define __NR_open 2 __SYSCALL(__NR_open, sys_open) #define __NR_close 3 __SYSCALL(__NR_close, sys_close) #define __NR_stat 4 __SYSCALL(__NR_stat, sys_newstat) #define __NR_fstat 5 __SYSCALL(__NR_fstat, sys_newfstat) #define __NR_lstat 6 __SYSCALL(__NR_lstat, sys_newlstat) #define __NR_poll 7 __SYSCALL(__NR_poll, sys_poll) #define __NR_lseek 8 __SYSCALL(__NR_lseek, sys_lseek) #define __NR_mmap 9 __SYSCALL(__NR_mmap, sys_mmap) #define __NR_mprotect 10 __SYSCALL(__NR_mprotect, sys_mprotect) #define __NR_munmap 11 __SYSCALL(__NR_munmap, sys_munmap) #define __NR_brk 12 __SYSCALL(__NR_brk, sys_brk) #define __NR_rt_sigaction 13 __SYSCALL(__NR_rt_sigaction, sys_rt_sigaction) #define __NR_rt_sigprocmask 14 __SYSCALL(__NR_rt_sigprocmask, sys_rt_sigprocmask) #define __NR_rt_sigreturn 15 __SYSCALL(__NR_rt_sigreturn, stub_rt_sigreturn) ...
或者部分版本的glibc中,库函数调用实现方式:
#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; })
1.3.5 缺页中断?内存碎片处理?除零野指针错误?
void trap_init(void)
{int i;set_trap_gate(0, ÷_error); // 设置除操作出错的中断向量值。以下雷同。set_trap_gate(1, &debug);set_trap_gate(2, &nmi);set_system_gate(3, &int3); /* int3-5 can be called from all */set_system_gate(4, &overflow);set_system_gate(5, &bounds);set_trap_gate(6, &invalid_op);set_trap_gate(7, &device_not_available);set_trap_gate(8, &double_fault);set_trap_gate(9, &coprocessor_segment_overrun);set_trap_gate(10, &invalid_TSS);set_trap_gate(11, &segment_not_present);set_trap_gate(12, &stack_segment);set_trap_gate(13, &general_protection);set_trap_gate(14, &page_fault);set_trap_gate(15, &reserved);set_trap_gate(16, &coprocessor_error);// 下面将int17-48 的陷阱门先均设置为reserved,以后每个硬件初始化时会重新设置自己的陷阱门。for (i = 17; i < 48; i++)set_trap_gate(i, &reserved);set_trap_gate(45, &irq13); // 设置协处理器的陷阱门。outb_p(inb_p(0x21) & 0xfb, 0x21); // 允许主8259A 芯片的IRQ2 中断请求。outb(inb_p(0xA1) & 0xdf, 0xA1); // 允许从8259A 芯片的IRQ13 中断请求。set_trap_gate(39, ¶llel_interrupt); // 设置并行口的陷阱门。
}
缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转化成CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射;有的是用来处理内存碎片的,有的是用来给目标发送信号,杀掉进程等等。
所以:
- 操作系统就是躺在中断处理例程上的代码块。
- CPU内部的软处理,比如 int 0x80 或者syscall,我们叫做陷阱。
- CPU内部的软处理,比如除零/野指针等,我们叫做异常。(所以,能理解为什么叫“缺页异常”了吗?)
1.4 如何理解内核态和用户态
结论:
- 操作系统无论怎么切换进程,都能找到同一个操作系统。换句话说操作系统系统调用方法的执行,是在进程的地址空间中执行的!
- 关于特权级别,涉及到段、段描述符、段选择子、DPL、CPL、RPL等概念,而现在芯片为了保证兼容性,已经非常复杂了,进而导致OS也必须得照顾它的复杂性,这里我们不深究。
- 用户态就是执行用户[0, 3]GB时所处的状态。
- 内核态就是执行内核[3, 4]GB时所处的状态。
- 区分就是按照CPU内的CPL决定的,CPL的全称是Current Privilege Level,即当前特权级别。
- 一般执行 int 0x80 或者 syscall 软中断,CPL会在校验之后自动变更。
- 这样会不会不安全呢?
二、可重入函数
- main 函数调用 insert 函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到 sighandler 函数也调用 insert 函数向同一个链表head中插入节点node2,插入操作的两步都做完后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行,先前做第一步被打断,现在继续做完第二步。结果是,main 函数和 sighandler 先后向链表中插入两个节点,而最后只有一个节点真正插入链表中。
- 像上面这样,insert 函数被不同的控制路程调用,有可能在第一次调用还未返回时就再次进入该函数,这称为重入,insert 函数访问一个全局链表,有可能因为重入而导致错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。那么,为什么不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一就是不可重入的:
- 调用了 malloc 或 free,因为 malloc 也是用全局链表来管理堆的。
- 调用了标准 I/O 库函数。标准 I/O 库中的很多实现都以不可重入的方式使用全局数据结构。
三、volatile
该关键字我们在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;
}
标准情况下,我们按下 ctrl + c,2号信号被捕捉,执行自定义动作,修改flag = 1,while 条件不满足退出循环,进程退出。
优化情况下,我们按下 ctrl + c,2号信号被捕捉,执行自定义动作,修改flag = 1,但是while条件依旧满足,进程继续运行!但是很明显 flag 已经被修改了,但是为何循环依旧执行?很明显,while 循环检查的 flag,并不是内存中最新的 flag,这就存在了数据二义性的问题。while 检查的 flag 已经被优化了,被放在了CPU寄存器当中,如何解决呢?很明显需要volatile。
#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;
}
- volatile 作用:保持内存的可见性,告知编译器,被该关键词修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行。
四、SIGCHLD信号
进程一章讲过 wait 和 waitpid 函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞的查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作;采用第二种方式,父进程在处理自己工作的同时还要时不时的轮询一下,程序实现复杂。
其实,子进程终止时会给父进程发送 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需要专心处理自己的工作,不比关心子进程,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 函数清理子进程即可。
我们试着编写一个程序完成以下功能:父进程 fork 出子进程,子进程调用 exit(3) 终止,父进程自定义 SIGCHLD 信号的处理函数,在其中调用 wait 获取子进程的退出状态并输出。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>void handler(int number) {pid_t id;while(id = waitpid(-1, NULL, WNOHANG) > 0) {printf("wait child success: %d\n", id);}printf("child clean success\n");
}int main()
{signal(SIGCHLD, handler);pid_t cid;cid = fork();if(cid == 0) {printf("child: %d", getpid());sleep(3);exit(3);}while(1) {printf("do something\n");sleep(1);}return 0;
}
五、附录
用户态和内核态
- CPU 指令集:是 CPU 实现软件指挥硬件执行的媒介,具体来说每一条汇编语句都对应了一条 CPU 指令,而非常非常多的 CPU 指令在一起,可以组成一个,甚至多个集合,指令的集合叫做 CPU 指令集。
- CPU 指令集有权限分级,大家试想,CPU 指令集可以直接操作硬件的,要是因为指令操作的不规范,造成的错误会影响整个计算机系统的。好比你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运行的程序都可能因为操作失误而受到不可挽回的错误,最后只能重启计算机才行。(对开发人员来说是个艰巨的任务,还会增加负担,同时开发人员在这方面也不被信任,所以操作系统内核直接屏蔽开发人员对硬件操作的可能,都不让你碰到这些 CPU 指令集)
针对上面的需求,硬件设备商直接提供硬件级别的支持,做法就是对 CPU 指令集设置了权限,不同级别权限能使用的 CPU 指令集是有限的,以 Inter CPU 为例,Inter 把 CPU 指令集操作的权限由高到低划为了四级:
- 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 范围的高位虚拟空间地址必须由内核去操作。
- 3 - 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。提权的时候,CPU 就是从这个 TSS 里把 SS0 和 ESP0 取出来,放到 ss 和 esp寄存器中。
切换流程
- 从用户态切换到内核态时,首先用户态可以直接读写寄存器,用户态操作 CPU,将寄存器的状态保存到对应的内存中,然后调用对应的系统函数,传入对应的用户栈地址和寄存器信息,方便后续内核方法调用完毕,恢复用户方法执行的现场。
- 从用户态切换到内核态需要提权,CPU 切换指令集操作权限级别为 ring 0。
- 提权后,切换内核栈。然后开始执行内核方法,相应的方法栈帧时保存在内核栈中。
- 当内核方法执行完毕后,CPU 切换指令集操作权限级别为 ring 3,然后利用之前写入的信息来恢复用户栈的执行。
从上诉流程可以看出用户态切换到内核态的时候,会牵扯到用户态现场信息的保存和恢复,还要进行一系列的安全检查,还是比较耗费资源的。