Linux 系统编程 学习:00-有关概念
Linux 系统编程 学习:00-有关概念
背景
系统编程其实就是利用系统中被支持的调度API进行开发的一个过程。
从这一讲开始,我们来介绍有关Linux 系统编程的学习。
知识
在进行Linux系统编程有关的开发之前,我们需要了解有关的概念。
进程(Process)
当一个进程创建以后,会被分配到一块虚拟内存中。
后面,我们还会知道:描述进程所涉及的所有信息和数据的那条记录叫做 PCB(process control block),每个进程有且仅有一个PCB。
通常,一个程序占用一块资源,不可能为一个函数分配线程,所以,我们也说进程是资源分配的最小单位。
在每一块进程中的资源通常互不干扰,互相不能访问,但是可以通过系统所支持的 进程间通信(IPC) 来通信。(打破进程的独立性)
进程和程序的区别:
进程是一个程序的一次执行的过程。
- 程序是静态的,它是一些保存在磁盘上的指令的有序集合,没有任何执行的概念。
- 进程是一个动态的概念,它包括了程序执行的过程,包括创建、调度和消亡。
当程序运行时,系统创建进程并开始调度。
进程的状态
进程的3种基本状态:
- 就绪(Ready)状态:当进程已分配到除CPU以外的所有必要的资源,只要获得处理机便可立即执行,这时的进程状态称为就绪状态
- 执行(Running)状态 :当进程已获得处理机,其程序正在处理机上执行,此时的进程状态称为执行状态
- 阻塞(Blocked)状态 :正在执行的进程,由于等待某个事件发生而无法执行时,便放弃处理机而处于阻塞状态。引起进程阻塞的事件可有多种,例如,等待I/O完成、申请缓冲区不能满足、等待信件(信号)等
一个进程在运行期间,不断地从一种状态转换到另一种状态,它可以多次处于就绪状态和执行状态,也可以多次处于阻塞状态。
(1) 就绪→执行
处于就绪状态的进程,当进程调度程序为之分配了处理机后,该进程便由就绪状态转变成执行状态。
(2) 执行→就绪
处于执行状态的进程在其执行过程中,因分配给它的一个时间片已用完或更高优先级的进程抢占而不得不让出处理机,于是进程从执行状态转变成就绪状态。
(3) 执行→阻塞
正在执行的进程因等待某种事件发生而无法继续执行时,便从执行状态变成阻塞状态。
(4) 阻塞→就绪
处于阻塞状态的进程,若其等待的事件已经发生,于是进程由阻塞状态转变为就绪状态。
(5) 运行→终止
以上是最经典也是最基本的三种进程状态,但现在的操作系统都根据需要重新设计了一些新的状态。
如Linux:
- 执行态(RUNNING): 进程正在占有CPU。
- 就绪态(RUNNING): 进程处于等待队列中等待调度。
- 浅睡眠(INTERRUPTABLE): 此时进程在等待一个事件的发生或某种系统资源,可响应信号。
- 深睡眠(UNINTERRUPTABLE):此时进程在等待一个事件的发生或某种系统资源,无法响应信号。
- 停止态(STOPPED): 此时进程被暂停。
- 僵尸态(ZOMBIE): 此时进程不能被调度,但是PCB未被释放。
- 死亡态(DEAD): 这是一个已终止的进程,且PCB将会被释放。
线程(Thread)
线程是cpu或操作系统调度的基本单位。线程具有共享性,同一进程内的不同线程的资源是共享的(也有一些资源不是共享的)。
线程是进程内部的一个执行分支,线程量级很小。(所谓的内部就是在进程的地址空间内运行),一切进程至少都有一个线程。
在程序中创建线程,可以提高效率,进程内线程越多,争夺到CPU的概率就越大,执行代码的概率就越大(有一个度)。CPU将线程和进程等同处理。
在内核中,进程(线程)用结构体task_struct{}来表达。这个结构体通常被称为PCB(Process Control Block), 亦即进程控制块, 该结构体内包含了一个进程所涉及的所有信息和数据。
线程也通常称之为轻量级进程(IWP)。
实际上:线程是为了方便理解而存在的概念,因为在内核中,每一个执行实体都是一个task_struct结构(也就是PCB),可以使用查看
ps -eLf
查看LWP号
注意
对于进程来说,相同的地址(同一个虚拟地址)在不同的进程中,反复使用而不冲突。原因是他们虽虚拟址一样,但,页目录、页表、物理页面各不相同。
相同的虚拟址,映射到不同的物理页面内存单元,最终访问不同的物理页面。
而线程却不是:两个线程具有各自独立的PCB,但共享同一个页目录,也就共享同一个页表和物理页面。所以两个PCB共享一个地址空间。
实际上,无论是创建进程的fork(系统调用),还是创建线程的pthread_create(线程所有操作函数 pthread_* 是库函数,而非系统调用),底层实现都是调用同一个内核函数clone。
如果复制对方的地址空间,那么就产出一个“进程”;如果共享对方的地址空间,就产生一个“线程”。
因此:Linux内核是不区分进程和线程的。只在用户层面上进行区分。
系统调用
由操作系统实现提供的所有系统调用所构成的集合即程序接口或应用编程接口(Application Programming Interface,API)。是应用程序同系统之间的接口。
系统调用函数的执行过程:
0)每一个系统调用函数都有其唯一的系统调用号,当用户调用一个系统调用函数时,
1)首先会把该系统调用函数的系统调用号用eax寄存器保存起来,
2)然后保存进程运行的状态(即保存现场)
3)触发0x80中断,由内核态开始接管并且执行中断处理程序
4)在内核中有一个系统调用表,上面记录了每个系统调用号相对应的系统调用函数的位置,找到该位置,开始执行系统调用函数
5)系统调用函数执行完成,返回用户态继续执行进程
特权级
提到系统调用就不得不提到 芯片的权限管理。
Intel x86架构的cpu一共有0~4四个特权级,0级最高,3级最低;ARM架构也有不同的特权级,硬件上在执行每条指令时都会对指令所具有的特权级做相应的检查。
硬件已经提供了一套特权级使用的相关机制,软件自然要好好利用,这属于操作系统要做的事情,对于UNIX/LINUX来说,只使用了0级特权级别和3级特权级,即最高最低特权级。也就是说在UNIX/LINUX系统中,一条工作在0级特权级的指令具有了CPU能提供的最高权力,而一条工作在3级特权的指令具有CPU提供的最低或者说最基本权力。
以上是从cpu执行指令角度理解特权,其实虚拟地址到物理地址映射由mmu硬件实现,即分页机制是硬件对分页的支持,进程中有页表数据结构指向用户空间和内核空间,使用户态和内核态访问内存空间不同。
用户态和内核态
内核栈:Linux中每个进程有两个栈,分别用于用户态和内核态的进程执行,其中的内核栈就是用于内核态的堆栈,它和进程的task_struct结构,更具体的是thread_info结构一起放在两个连续的页框大小的空间内。
从特权级的调度来理解用户态和内核态就比较好理解了,当程序运行在3级特权级上时,就可以称之为运行在用户态,因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;反之,当程序运行在0级特权级上时,就可以称之为运行在内核态。
虽然用户态下和内核态下工作的程序有很多差别,但最重要的差别就在于特权级的不同,即权力的不同。运行在用户态的程序不能访问操作系统内核数据结构合程序。 当我们在系统中执行一个程序时,大部分时间是运行在用户态下的。在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态。
Linux进程的4GB地址空间,3G-4G部分大家是共享的,是内核态的地址空间,这里存放在整个内核的代码和所有的内核模块,以及内核所维护的数据。用户运行一个程序,该程序所创建的进程开始是运行在用户态的,如果要执行文件操作,网络数据发送等操作,必须通过write,send等系统调用,这些系统调用会调用内核中的代码来完成操作,这时,必须切换到Ring0,然后进入3GB-4GB中的内核地址空间去执行这些代码完成操作,完成后,切换回Ring3,回到用户态。这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。
保护模式,通过内存页表操作等机制,保证进程间的地址空间不会互相冲突,一个进程的操作不会修改另一个进程的地址空间中的数据。在内核态下,CPU可执行任何指令,在用户态下CPU只能执行非特权指令。当CPU处于内核态,可以随意进入用户态;而当CPU处于用户态,只能通过中断的方式进入内核态。一般程序一开始都是运行于用户态,当程序需要使用系统资源时,就必须通过调用软中断(系统调用、异常、中断)进入内核态。
处理器总处于以下状态中的一种:
- 内核态,运行于进程上下文,内核代表进程运行于内核空间;
- 内核态,运行于中断上下文,内核代表硬件运行于内核空间;
- 用户态,运行于用户空间。
用户态和内核态的转换
用户态切换到内核态的3种方式
1)系统调用:
这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。而系统调用的机制,其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如lx86的int 80h, powerpc的sc
2)异常:
当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关的程序中,也就是转到了内核态,比如缺页异常。
3)外围设备的中断:
当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作的完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围中断是被动的。
具体的切换操作:
从触发方式上看,可以认为纯在前述3种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一致的,没有任何区别,都相当于执行了一个中断响应的过程。因为系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本上也是一致的。
关于中断处理机制的细节合步骤这里不做过多分析,涉及到有用户态切换到内核态的步骤主要包括:
1)从当前进程的描述符中提取其内核栈的ss0及esp0信息。
2)使用ss0和esp0指向的内核栈将当前进程的cs,eip,eflags,ss,esp信息保存起来,这个过程也完成了由用户栈到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。
3)将先前又中断向量检索得到的中断处理程序的cs,eip信息装入相应的寄存器,开始执行中断处理程序,这时就转到内核态的程序执行了。
进程上下文以及中断上下文
上下文
进程上下文:就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态、堆栈上的内容、进程打开的文件以及内存信息等,是进程运行的环境。
上下文切换(context switch)可以分为三种情况:
-
当内核需要从一个进程内核态切换到另一个进程内核态时,它需要保存当前进程的所有状态然后加载下一个进程的状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
-
进程由用户态切换到内核态,由用户空间转化为内核空间。可能由系统调用或设备中断引起,但不是所有的系统调用或设备中断都会触发上下文切换,那些在内核态中的系统调用和设备中断是不会引起上下文切换的。
-
有时一个硬件中断的产生,也可能导致内核收到中断信号后由进程上下文切换到中断上下文。
无论哪种上下文切换,只要切换次数多都会影响CPU性能,这时线程就有非常大的优势。
这是在内核态和用户态的子概念,简单说来:上下文就是一个运行环境。
- 相对于进程而言,进程上下文就是进程执行时的环境。
- 相对于中断而言,中断上下文就是中断执行时的环境。
内核空间和用户空间是操作系统重要的理论知识,用户程序运行在用户空间,内核功能模块运行在内核空间,二者是空间是不能互相访问的,内核空间和用户空间指其代码和数据存放内存空间。用户态的程序要想访问内核空间,须使用系统调用。当用户空间的应用程序通过系统调用进入内核空间时,就会涉及到上下文的切换。用户空间和内核空间具有不同的地址映射、通用寄存器和专用寄存器组以及堆栈区,而且用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。
所谓的进程上下文,就是一个进程传递给内核的那些参数和CPU的所有寄存器的值、进程的状态以及堆栈中的内容,也就进程在进入内核态之前的运行环境。所以在切换到内核态时需要保存当前进程的所有状态,即保存当前进程的上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。同理,硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境(主要是被中断的进程的环境)。
当一个进程在执行时,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。在LINUX中,当前进程上下文均保存在进程的任务数据结构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中继服务结束时能恢复被中断进程的执行。
一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。
(1)用户级上下文: 正文、数据、用户堆栈以及共享存储区;
(2)寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
(3)系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
进程上下文切换分为进程调度时和系统调用时两种切换,消耗资源不同,当发生进程调度时,进行进程切换就是上下文切换(context switch)。操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。而系统调用进行的模式切换(mode switch)与进程切换比较起来,容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。在进程上下文中,可以用current宏关联当前进程,也可以睡眠,也可以调用调度程序。
中断上下文不支持抢占,运行在进程上下文的内核代码是可以被抢占的(Linux2。6支持抢占),就是支持进程调度。但是一个中断上下文,通常都会始终占有CPU(当然中断可以嵌套,但我们一般不这样做),不可以被打断。正因为如此,运行在中断上下文的代码就要受一些限制,不能做下面的事情:
1)睡眠或者放弃CPU。
这样做的后果是灾难性的,因为内核在进入中断之前会关闭进程调度,一旦睡眠或者放弃CPU,这时内核无法调度别的进程来执行,系统就会死掉
2)尝试获得信号量、执行自旋锁
如果获得不到信号量,代码就会睡眠,会产生和上面相同的情况
3)执行耗时的任务
中断处理应该尽可能快,因为内核要响应大量服务和请求,中断上下文占用CPU时间太长会严重影响系统功能。
4)访问用户空间的虚拟地址
因为中断上下文是和特定进程无关的,它是内核代表硬件运行在内核空间,所以在中断上下文无法访问用户空间的虚拟地址。
内存管理单元(MMU)
内存管理单元 是 实现虚拟地址和物理地址空间以及内核空间、用户空间的基础。
MMU是处理器复杂到一定程度出现的产物。这个东西和操作系统的内存管理如果结合起来学习和理解,效果最好。
MMU是存储器管理单元的缩写,是用来管理虚拟内存系统的器件。MMU通常是CPU的一部分,本身有少量存储空间存放从虚拟地址到物理地址的匹配表,一种转换方法(算法)。此表称作TLB(转换旁置缓冲区)。所有数据请求都送往MMU,由MMU决定数据是在RAM内还是在大容量存储器设备内。如果数据不在存储空间内,MMU将产生页面错误中断,外部存储器地址空间由页、行、列组成。
MMU的两个主要功能是:
1)将虚地址转换成物理地址。
2)控制存储器存取允许。MMU关掉时,虚地址直接输出到物理地址总线:比如uboot前部分。
在实践中,使用MMU解决了如下几个问题:
1)使用DRAM作为大容量存储器时,如果DRAM的物理地址不连续,这将给程序的编写调试造成极大不便,而适当配置MMU可将其转换成虚拟地址连续的空间,将不连续的物理空间变为连续的虚拟地址空间。
2)ARM内核的中断向量表要求放在0地址,对于ROM在0地址的情况,无法调试中断服务程序,所以在调试阶段有必要将可读写的存储器空间映射到0地址。
3)系统的某些地址段是不允许被访问的,否则会产生不可预料的后果,为了避免这类错误,可以通过MMU匹配表的设置将这些地址段设为用户不可存取类型,即内核空间和用户空间区别。
启动程序中生成的匹配表中包含地址映射,存储页大小(1M,64K,或4K)以及是否允许存取等信息,这是实现上述功能基础。
使能MMU后,程序继续运行,但是对于程序员来说程序计数器的指针已经改变,指向了ROM所对应的虚拟地址。
MMU的作用有两个:地址翻译和地址保护
软件的职责是配置页表,硬件的职责是根据页表完成地址翻译和保护工作。 那三个函数是用来访问页表的。如果cpu没有硬件MMU那么这张表将毫无意义。
你必须从cpu的角度去理解内存映射这个概念。内存映射不是调用一个函数,然后读取返回值。而是cpu通过MMU把一条指令中要访问的地址转换为物理地址,然后发送到总线上的过程。
有本书叫做understand linux kernel,耐心看,那本书写的非常好。
嵌入式系统中,存储系统差别很大,可包含多种类型的存储器件,如FLASH,SRAM,SDRAM,ROM等,这些不同类型的存储器件速度和宽度等各不相同;在访问存储单元时,可能采取平板式的地址映射机制对其操作,或需要使用虚拟地址对其进行读写;系统中,需引入存储保护机制,增强系统的安全性。
为适应如此复杂的存储体系要求,ARM处理器中引入了存储管理单元来管理存储系统,这是mmu意义。
在ARM存储系统中,使用MMU实现虚拟地址到实际物理地址的映射。
为何要实现这种映射?
先要从一个嵌入式系统的基本构成和运行方式着手。系统上电时,处理器的程序指针从0x0(或者是由0Xffff_0000处高端启动)处启动,顺序执行程序,在程序指针(PC)启动地址,属于非易失性存储器空间范围,如ROM、FLASH等。然而与上百兆的嵌入式处理器相比,FLASH、ROM等存储器响应速度慢,已成为提高系统性能的一个瓶颈。而SDRAM具有很高的响应速度,为何不使用SDRAM来执行程序呢?
为了提高系统整体速度,可以这样设想,利用FLASH、ROM对系统进行配置,把真正的应用程序下载到SDRAM中运行,这样就可以提高系统的性能。然而这种想法又遇到了另外一个问题,当ARM处理器响应异常事件时,程序指针将要跳转到一个确定的位置,假设发生了IRQ中断,PC将指向0x18(如果为高端启动,则相应指向0vxffff_0018处),而此时0x18处仍为非易失性存储器所占据的位置,则程序的执行还是有一部分要在FLASH或者ROM中来执行的。
那么我们可不可以使程序完全都SDRAM中运行那?答案是肯定的,这就引入了MMU,利用MMU,可把SDRAM的地址完全映射到0x0起始的一片连续地址空间,而把原来占据这片空间的FLASH或者ROM映射到其它不相冲突的存储空间位置。例如,FLASH的地址从0x0000_0000-0x00ff_ffff,而SDRAM的地址范围是0x3000_0000-0x31ff_ffff,则可把SDRAM地址映射为0x0000_0000-0x1fff_ffff而FLASH的地址可以映射到0x9000_0000-0x90ff_ffff(此处地址空间为空闲,未被占用)。映射完成后,如果处理器发生异常,假设依然为IRQ中断,PC指针指向0x18处的地址,而这个时候PC实际上是从位于物理地址的0x3000_0018处读取指令。
通过MMU的映射,则可实现程序完全运行在SDRAM之中,这些物理器件的地址是实际的挂载到总线上的地址。
在实际的应用中,可能会把两片不连续的物理地址空间分配给SDRAM。而在操作系统中,习惯于把SDRAM的空间连续起来,方便内存管理,且应用程序申请大块的内存时,操作系统内核也可方便地分配。通过MMU可实现不连续的物理地址空间映射为连续的虚拟地址空间。
若在页首无特别声明,本篇文章由 Schips 经过整理后发布。
博客地址:https://www.cnblogs.com/schips/