CPU所执行的指令的地址序列称为CPU的控制流,通过下述两种方式得到的控制流为正常控制流。
1.按顺序取下一条指令执行。
2.通过CALL/RET/Jcc/JMP等指令跳转到转移目标地址处执行
异常控制流
硬件层面有两种情况:
1.执行指令的硬件发现指令有异常。eg:除0
2.外部中断 ctrl+c
异常控制流形成原因(1.2硬件层面)
1.内部异常:缺页,越权,越级,整除0,溢出等,都是CPU可以发现的。
2.外部中断(Ctrl-C,打印缺纸,DMA结束等)由外界请求信号通知CPU
3.进程的上下文切换(发生在操作系统层)
4.一个进程直接发送信号给另外一个进程(发生在应用软件层)
程序和进程
vm_area_struct 是一个线性链表
引入进程的好处
独立的逻辑控制流意味着进程不会感觉到其他进程的存在,使得其不容易受其他进程打乱
逻辑控制流
进程p1,A12,打断一次
进程p2,A24,打断一次
进程与上下文切换
什么叫进程的上下文?
用户级上下文地址空间和系统级上下文地址空间一起构成了一个进程的整个存储器映像
进程
以下内容引用
https://wdxtub.com/2016/04/16/thin-csapp-5/
进程才是程序(指令和数据)的真正运行实例。之所以重要,是因为进程给每个应用提供了两个非常关键的抽象:一是逻辑控制流,二是私有地址空间。逻辑控制流通过称为上下文切换(context switching)的内核机制让每个程序都感觉自己在独占处理器。私有地址空间则是通过称为虚拟内存(virtual memory)的机制让每个程序都感觉自己在独占内存。这样的抽象使得具体的进程不需要操心处理器和内存的相关适宜,也保证了在不同情况下运行同样的程序能得到相同的结果。
左边是单进程的模型,内存中保存着进程所需的各种信息,因为该进程独占 CPU,所以并不需要保存寄存器值。而在右边的单核多进程模型中,虚线部分可以认为是当前正在执行的进程,因为我们可能会切换到其他进程,所以内存中需要另一块区域来保存当前的寄存器值,以便下次执行的时候进行恢复(也就是所谓的上下文切换)。整个过程中,CPU 交替执行不同的进程,虚拟内存系统会负责管理地址空间,而没有执行的进程的寄存器值会被保存在内存中。切换到另一个进程的时候,会载入已保存的对应于将要执行的进程的寄存器值。
我们所讲的“双核”
上下文切换是指把运行内核代码的环境调出来,然后把用户代码的环境(PC,寄存器等)保存起来
进程地址空间
虚拟地址空间由内核空间和用户空间两部分组成。用户空间(32位)都从0x08048000组成。
进程控制
在遇到错误的时候,Linux 系统级函数通常会返回 -1 并且设置 errno 这个全局变量来表示错误的原因。使用的时候记住两个规则:
1.对于每个系统调用都应该检查返回值
2.当然有一些系统调用的返回值为 void,在这里就不适用
fork函数
1 | void unix_error(char *msg) /* Unix-style error */ |
获取进程信息
我们可以用下面两个函数获取进程的相关信息:
1.pid_t getpid(void) - 返回当前进程的 PID
2.pid_t getppid(void) - 返回当前进程的父进程的 PID
我们可以认为,进程有三个主要状态:
1.运行 Running
正在被执行、正在等待执行或者最终将会被执行
2.停止 Stopped
执行被挂起,在进一步通知前不会计划执行
3.终止 Terminated
进程被永久停止
用户态和内核态
程序的加载和运行
entry point 是可执行目标文件ELF头 的entry point
所以程序的加载和运行就是一个进程切换到另外一个进程,中间要进行上下文切换。切换新进程的时候先要创建一个进程(fork),然后exec,然后运行main
第一个参数先压栈,最后一个参数最后压栈,注意上图,argv是一个指针,指向一个数组,即图中argv【0】处,每一个元素又本身是一个指针,指向一个字符串,envp也是一个指针数组,每一个元素指向一个环境变量。
然后如果main函数调用了其它函数,就会又长出一个栈帧,这就是程序加载与运行的过程。
信号
可以用kill函数发射信号
子进程陷入无限循环,则父进程发射KILL信号,终结子进程。
1 | void forkandkill() |
接收信号
所有上下文切换都是通过调用某个异常处理器(exception handler)完成的,内核会计算对易于某个进程p的pnb值:pnb=pending&~blocked
1 | void unix_error(char *msg) /* Unix-style error */ |
信号处理器的工作流程可以认为是和当前用户进程“并发”的同一个伪进程。
并行与并发的区别
并行:多个CPU同时执行程序
并发(concurrent):即使只有一个CPU,但操作系统能够把程序的执行单位细化,然后分开执行。是一种伪并行执行
阻塞信号
内核会阻塞与当前在处理的信号同类型的其他正在等待的信号,也就是说一个SIGINT信号处理是不能被另外一个SIGINT信号中断的。
如果要显示阻塞,需要用sigprocmask函数
1 | sigset_t mask, prev_mask; |
并行访问可能会导致数据毁坏问题,以下是一些编写程序的规则。
规则 1:信号处理器越简单越好
例如:设置一个全局的标记,并返回
规则 2:信号处理器中只调用异步且信号安全(async-signal-safe)的函数
诸如 printf, sprintf, malloc 和 exit 都是不安全的!
规则 3:在进入和退出的时候保存和恢复 errno
这样信号处理器就不会覆盖原有的 errno 值
规则 4:临时阻塞所有的信号以保证对于共享数据结构的访问
防止可能出现的数据损坏
规则 5:用 volatile 关键字声明全局变量
这样编译器就不会把它们保存在寄存器中,保证一致性
规则 6:用 volatile sig_atomic_t 来声明全局标识符(flag)
这样可以防止出现访问异常
异步信号安全:指的是如下两类函数:
1.所有的变量都保存在栈帧当中
2.不会被信号中断的函数
非本地跳转 Non local jump
从一个函数跳转到另一个函数中
1 | setjmp 保存当前程序的堆栈上下文环境(stack context),注意,这个保存的堆栈上下文环境仅在调用 setjmp 的函数内有效,如果调用 setjmp 的函数返回了,这个保存的堆栈上下文环境就失效了。调用 setjmp 的直接返回值为 0。 |
1 | jmp_buf env; |
1 | jmp_buf env; |
因为P2在跳转的时候,已经在P3前返回了,内存已经清理了其对应的栈帧,所以P3的longjmp不能实现期望的操作。
CSAPP 家庭作业
waitpid函数的作用:
当指定等待的子进程以及停止运行或者结束了,waitpid函数会立即返回,如果子进程还没有停止运行或者结束,调用waitpid的父进程会被祖塞,暂停运行
8.181
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17void end(){
printf("2");fflush(stdout);
}
int main()
{
if(fork()==0)
atexit(end);
if(fork()==0){
printf("0");fflush(stdout);
}
else{
printf("1");fflush(stdout);
}
exit(0);
}
第一个子进程的atexit函数把end函数添加到函数列表中,那么这个子进程生成的两个子进程的堆栈中也会有end函数,但是另外父进程则独立,不受影响,即使wxit也不会有反应。