操作系统总结

一 glibc

API/库函数/系统调用关系

从glibc源码看系统调用原理
在这里插入图片描述
系统调用是操作系统内核为了向外提供服务而规定的一些调用接口(调用规范),它处于内核空间而非用户空间。系统调用的封装按照固定的规则进行。寄存器EAX传递系统调用号。系统调用号用来确定系统调用。寄存器EBX,ECX,EDX,ESI,EDI,EBP依次传递系统调用参数。参数个数决定设置寄存器的个数。int 0x80指令切入内核执行系统调用。系统调用执行完成后返回。寄存器EAX保存系统调用的返回值。
库函数(比如glibc)处于用户空间,它不是系统调用,但是它通常是进行系统调用的入口,它内部封装了进行系统调用的细节(和平台有关,比如arm和mips等),大多数库函数暴露的接口和系统调用接口都是一一对应的。
进行系统调用不一定非得经过库函数,应用代码依然可以使用嵌入式汇编/内联汇编/syscall等方式发起系统调用。
在这里插入图片描述

glibc库函数

在Linux系统中,glibc库中包含许多API,大多数API都对应一个系统调用,比如:应用程序中使用的接口open(),就对应同名的系统调用open()。在glibc库中,通过封装例程(Wrapper Routine)将API和系统调用关联起来。API是头文件中所定义的函数接口,而位于glibc中的封装例程则是对该API对应功能的具体实现。事实上,我们知道接口open()所要完成的功能是通过系统调用open()完成的,因此封装例程要做的工作就是先将接口open()中的参数复制到相应的寄存器中,然后引发一个异常,从而系统进入内核区执行sys_open(),最后当系统调用执行完毕后,封装例程还要将错误码返回到应用程序中。

readahead

在描述了page cache的原理和功能之后,readahead就比较容易理解了,当使用系统调用read读取文件部分数据时,如果数据没有在page cache中,就需要从块设备读取对应数据,对于像磁盘这样的块设备,寻道是最耗时的操作,读一小块数据和读一大块连续数据所花的时间相差不大,但如果这一大块数据分多次读取,就需要多次寻道,这样花费的时间就比较长。

readahead是基于这样的策略:在需要读取一块数据的时候,如果后继的操作是连续读,可以在多读一些数据到page cache中,这样下次访问的连续数据的时候,这些数据已经在page cache中了,就无需I/O操作,这样会大大提高数据访问的效率。

Linux的readahead分为自动模式和用户强制模式,自动预读是指在read系统调用的时候,如果需要从块设备传输数据,系统会自动根据当前的状态设置预读的数据的大小,启用预读过程。每次预读的数据的大小是动态调整的,调整地原则是根据预读后的命中情况适当扩大或缩小预读大小。每次预读的默认大小是可以设置的,而且不同的块设备可以有不同的默认预读大小,察看和设置块设备默认预读大小都可以通过blockdev命令。

这种自动模式的预读机制在每次I/O操作前是都会被启用,所以预读默认大小的设置对性能有一些影响,如果是大量随机读操作,在这种情况下就需要让预读值调小, 但并不是越小越好,一般情况下需要估算一下应用程序平均每次read请求读取的数据量的平均大小,将预读值设成比平均大小稍大一些比较合适;如果是大量顺序读操作,则预读值可以调大一点(对于使用RAID的情况下,预读值的设置还要参考条带大小和条带数)。

在自动预读模式中需要注意的问题还有,如果文件本身有许多很小的碎片,即使是连续读,而且也设置了较大的预读值,其效率也不会太高,因为如果一次被读取的数据在磁盘中不连续的话,仍然不可避免磁盘寻道,所以预读起的作用就不大了。

Linux提供一个readahead的系统调用设置对文件进行强制预读,这个操作是把数据从块设备加载到page cache中,可以提高之后对文件数据访问的速度,用户可以根据自己的需要决定是否使用强制预读。

read

Read函数系统调用
read过程:把需要读取得数据转换成对应的页,对需要读入的每一个页执行如下过程:首先调用page_cache_readahead(如果预读打开),根据当前预读的状态和执行预读策略(预读状态结构根据命中情况和读模式动态调整,预读策略也动态调整),预读过程会进行I/O操作也可能不会,预读过程完毕之后,首先检查page cache中是否已经有所需数据,如果没有,说明预读没有命中,调用handle_ra_miss调整预读策略,进行I/O操作把该页数据读入内存并加入page cache,当该页数据读入page cache之后(或者之前就在page cache中),标记该页mark_page_accessed,然后把该页数据拷贝到应用程序地址空间。

用户空间

Linux 系统调用(SCI,system call interface)的实现机制实际上是一个多路汇聚以及分解的过程,该汇聚点就是 0x80 中断这个入口点(X86 系统结构)。也就是说,所有系统调用都从用户空间中汇聚到 0x80 中断点,同时保存具体的系统调用号。当 0x80 中断处理程序运行时,将根据系统调用号对不同的系统调用分别处理(调用不同的内核函数处理)。
Read 系统调用也不例外,当调用发生时,库函数在保存 read 系统调用号以及参数后,陷入 0x80 中断。这时库函数工作结束。Read 系统调用在用户空间中的处理也就完成了。

read系统调用是glibc库里面的一个函数,是对系统调用函数sys_read()的封装与实现。glic库会将read函数在用户态下进行解析,通过寄存器将参数保存起来,并借助于系统调用名称获得系统调用号,该系统调用号又可以作为系统调用函数在sys_call_table中的索引获取函数入口地址,该表位于linux-4.13.16\arch\x86\entry\syscalls\syscall_64.tbl中

内核空间

0x80 中断处理程序接管执行后,先检察其系统调用号,然后根据系统调用号查找系统调用表,并从系统调用表中得到处理 read 系统调用的内核函数 sys_read ,最后传递参数并运行 sys_read 函数。至此,内核真正开始处理 read 系统调用(sys_read 是 read 系统调用的内核入口)。
read 系统调用在核心空间中所要经历的层次模型。从图中看出:对于磁盘的一次读请求,首先经过虚拟文件系统层(vfs layer),其次是具体的文件系统层(例如 ext2),接下来是 cache 层(page cache 层)、通用块层(generic block layer)、IO 调度层(I/O scheduler layer)、块设备驱动层(block device driver layer),最后是物理块设备层(block device layer)
在这里插入图片描述

虚拟文件系统层

虚拟文件系统层的作用:屏蔽下层具体文件系统操作的差异,为上层的操作提供一个统一的接口。正是因为有了这个层次,所以可以把设备抽象成文件,使得操作设备就像操作文件一样简单。

cache 层

目的是为了提高 linux 操作系统对磁盘访问的性能。 Cache 层在内存中缓存了磁盘上的部分数据。当数据的请求到达时,如果在 cache 中存在该数据且是最新的,则直接将数据传递给用户程序,免除了对底层磁盘的操作,提高了性能。
并不是每次对磁盘的读写操作都会产生实际的磁盘操作,这是因为系统中还存在有一个磁盘缓冲,就是通常所说的页缓冲。所有的读写操作都会先去查找一下这个缓冲看是否命中,如果命中就不用产生实际的磁盘I/O 这样可以提高系统的性能。如果没有命中就会向下继续传递读写命令。并会把相应的读写数据存入这个缓冲区中,以便下次使用。Linux 内核对这个缓冲的使用策略就是在系统内存允许的情况下使这个缓冲区尽可能大,以提高命中率。

通用块层

接收上层发出的磁盘请求,并最终发出 IO 请求。该层隐藏了底层硬件块设备的特性,为块设备提供了一个通用的抽象视图。

IO 调度层

接收通用块层发出的 IO 请求,缓存请求并试图合并相邻的请求(如果这两个请求的数据在磁盘上是相邻的)。并根据设置好的调度算法,回调驱动层提供的请求处理函数,以处理具体的 IO 请求。

驱动层中

驱动程序对应具体的物理块设备。它从上层中取出 IO 请求,并根据该 IO 请求中指定的信息,通过向具体块设备的设备控制器发送命令的方式,来操纵设备传输数据。

设备层

设备层中都是具体的物理设备。定义了操作具体设备的规范。

read/write

read/write是读写I/O的基本过程,除了mmap之外,其他I/O读写系统调用的基本原理和调用过程都是和read/write一样的。

write过程:和read过程一样,需要把需要写的数据转换成对应页,从应用程序地址空间把数据拷贝到对应页,并标记该页状态为dirty,调用 mark_page_accessed , 如果没有指定为同步写,写操作至此就返回了。如果文件在打开时指定了 O_SYNC,系统会把本次写过程所有涉及到的dirty页回写到块设备中,这个过程是阻塞的。关于dirty页的同步在分析fsync/fdatasync/msync时我们再具体说明。

特殊情况:如果应用程序在打开文件时指定了O_DIRECT,操作系统在读写文件时会完全绕过page cache,读的时候数据直接从块设备传送到应用程序指定的缓存中,写的时候数据也是直接从应用程序指定的缓存中写到块设备中,由于没有经过page cache层,这种方式的写总是同步写。

mmap

mmap的用途很广泛,不仅可以把文件映射到内存地址空间读写文件,也可以用mmap实现共享内存,malloc分配内存是也是用了mmap。
每个进程对虚拟内存都是通过分区域管理的,在虚拟内存分配时,为不同的用途划分不同的虚拟内存区域,这些虚拟内存区域在分配之初并没有为止分配对应的物理内存,而只是分配和设置了管理结构,当进程使用到某个区域的内存,而其又没有对应的物理内存时,系统产生缺页异常,在缺页异常中,系统根据这块内存对应的虚拟内存管理结构为之分配物理内存,在必要情况下(如mmap)加载数据到这块物理内存,建立虚拟内存到物理内存的对应关系,然后进程可以继续访问刚才的虚拟内存。
mmap的实现也是基于上述原理,在使用mmap映射某个文件(或者文件的一部分)到进程的地址空间时,并没有加载文件的数据,而只是在进程的虚拟地址空间划分出一块区域,标记这块区域用于映射到文件的数据区域,mmap的操作就完成了。
当进程试图读或者写文件映射区域时,如果没有对应的物理页面,系统发生缺页异常并进入缺页异常处理程序,缺页异常处理程序根据该区域内存的类型使用不同的策略解决缺页。对于使用mmap映射文件的虚拟内存区域,处理程序首先找到相关的文件的管理数据结构,确定所需页面对应的文件偏移,此时需要从文件中把对应数据加载到page_cache中,与read系统调用流程不同的是,在加载的过程中如果虚拟内存区域管理结构设置了VM_RAND_READ标志,系统只是把所需的页面数据加载,如果设置了VM_SEQ_READ标志,系统会进行和read系统调用相同预读过程,至此应用程序所需的页面已经在page cache中了,系统调整页表把物理页面对应到应用程序的地址空间。mmap对缺页的处理没有读和写的区别,无论是读还是写造成的缺页异常都要执行上述过程。

虚拟内存区域管理结构的VM_RAND_READ标志和VM_SEQ_READ标志可以使用madvise系统调用调整。

使用mmap读写文件需要注意的问题:当读写映射的内存区域的物理页面不存在时,发生缺页异常时系统才能进入内核态,如果物理页面存在,应用程序在用户态直接操作内存,不会进入内核态,大家注意到在调用read/write系统调用时,系统对涉及到的页面都调用了mark_page_accessed函数,mark_page_accessed可以标记物理页面的活动状态,活动的页面就不容易被回收,而是用mmap读文件不产生缺页异常时不能进入内核态,就无法标记页面的活动状态,这样页面就容易被系统回收(进入缺页异常处理时也只是对新分配所缺页面调用了mark_page_accessed)。除此之外,在写该内存区域时,如果不进入内核态也无法标记所写的物理页面为dirty(只用把页表项的dirty位置位),这个问题我们会在后面的msync说明中详细描述。

sendfile

sendfile把文件的从某个位置开始的内容送入另一个文件中(可能会是一个套接字),这种操作节省了数据在内存中的拷贝次数,如果使用read/write实现,会增加两次数据拷贝操作。其内核实现方法和read/write也没有太大区别。

fsync/fdatasync/msync

这三个系统调用都涉及把内存中的dirty page同步到的块设备上的文件中去,它们之间有一些区别。

fsync把文件在page cache中的dirty page写回到磁盘中去,一个文件在page cache中的内容包括文件数据也包括inode数据,当写一个文件时,除了修改文件数据之外,也修改了inode中的数据(比如文件修改时间),所以实际上有这两部分的数据需要同步,fsync把和指定文件相关的这两种dirty page回写到磁盘中。除了使用fsync强行同步文件之外,系统也会定期自动同步,即把dirty page回写到磁盘中。

Fdatasync只回写文件数据的dirty page到磁盘中,不回写文件inode相关的dirty page。

msync与fsync有所不同,在使用mmap映射文件到内存地址,向映射地址写入数据时如果没有缺页,就不会进入内核层,也无法设置写入页的状态为dirty,但cpu会自动把页表的dirty位置位,如果不设置页为dirty,其他的同步程序,如fsync以及内核的同步线程都无法同步这部分数据。msync的主要作用就是检查一个内存区域的页表,把dirty位置位的页表项对应的页的状态设置为dirty,如果msync指定了M_SYNC参数,msync还会和fsync一样同步数据,如果指定为M_ASYNC,则用内核同步线程或其他调用同步数据。

在munmap时,系统会对映射的区域执行类似msync的操作,所以如果不调用msync数据也不一定会丢失(进程在退出时对映射区域也会自动调用munmap),但写大量数据不调用msync会有丢失数据的风险。

malloc

malloc只是一个库函数,在不同的平台对malloc有不同的实现,glibc使用的是ptmalloc的实现。
malloc的使用过程中会使用两个系统调用brk和mmap,brk用于增长(或减小)堆的大小,在进程创建时会指定堆的起始地址(堆空间是向上增长的),而堆的大小为0,当使用malloc分配内存时发现当前堆剩余空间不够时就会调用brk增长堆的大小,实际上brk的作用就是把堆所在的虚拟内存区域的结束地址增长(或减小)到某个位置。当malloc一次分配的空间大于一个阀值大小时(比如128K),malloc不再从堆上分配空间,而是使用mmap重新映射一块虚拟地址区域,在free时调用munmap释放这一区域。这样的策略主要是方便堆管理,避免在一个很大的尺度管理堆,这样做是基于大内存分配并不常使用这个假设。
如果分配的内存过大,在分配和释放时都要通过系统调用,效率会有降低,所以如果应用程序频繁分配大于分配阀值的内存,效率就会很低,这种应用可以通过调整分配阀值使内存分配和释放都在用户态(在堆中)完成。使用mallopt可以调整malloc的参数,M_TRIM_THRESHOLD表示如果堆大小大于该值,就应该在适当的时候收缩堆的大小,M_MMAP_THRESHOLD表示大于此值的内存分配请求要使用 mmap 系统调用。

内存管理

ptmalloc

ptmalloc是隶属于glibc(GNU Libc)的一款内存分配器,现在在Linux环境上,我们使用的运行库的内存分配(malloc/new)和释放(free/delete)就是由其提供。

arena

通过sbrk或mmap系统调用为线程分配的堆区,按线程的类型可以分为2类:
main arena:主线程建立的arena;
thread arena:子线程建立的arena;

chunk

逻辑上划分的一小块内存,根据作用不同分为4类:
Allocated chunk:即分配给用户且未释放的内存块;
Free chunk:即用户已经释放的内存块;
Top chunk
Last Remainder chunk

bin

一个用以保存Free chunk链表的表头信息的指针数组,按所悬挂链表的类型可以分为4类:
Fast bin
Unsorted bin
Small bin
Large bin

1、获取分配区的锁,为了防止多个线程同时访问同一个分配区,在进行分配之前需要取得分配区域的锁。线程先查看线程私有实例中是否已经存在一个分配区,如果存在尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,否则,该线程搜索分配区循环链表试图获得一个空闲(没有加锁)的分配区。如果所有的分配区都已经加锁,那么ptmalloc会开辟一个新的分配区,把该分配区加入到全局分配区循环链表和线程的私有实例中并加锁,然后使用该分配区进行分配操作。开辟出来的新分配区一定为非主分配区,因为主分配区是从父进程那里继承来的。开辟非主分配区时会调用mmap()创建一个sub-heap,并设置好top chunk。

2、将用户的请求大小转换为实际需要分配的chunk空间大小。

3、判断所需分配chunk的大小是否满足chunk_size <= max_fast (max_fast 默认为 64B),如果是的话,则转下一步,否则跳到第5步。

4、首先尝试在fast bins中取一个所需大小的chunk分配给用户。如果可以找到,则分配结束。否则转到下一步。

5、判断所需大小是否处在small bins中,即判断chunk_size < 512B是否成立。如果chunk大小处在small bins中,则转下一步,否则转到第6步。

6、根据所需分配的chunk的大小,找到具体所在的某个small bin,从该bin的尾部摘取一个恰好满足大小的chunk。若成功,则分配结束,否则,转到下一步。

7、到了这一步,说明需要分配的是一块大的内存,或者small bins中找不到合适的 chunk。于是,ptmalloc首先会遍历fast bins中的chunk,将相邻的chunk进行合并,并链接到unsorted bin中,然后遍历unsorted bin中的chunk,如果unsorted bin只有一个chunk,并且这个chunk在上次分配时被使用过,并且所需分配的chunk大小属于small bins,并且chunk的大小大于等于需要分配的大小,这种情况下就直接将该chunk进行切割,分配结束,否则将根据chunk的空间大小将其放入small bins或是large bins中,遍历完成后,转入下一步。

8、到了这一步,说明需要分配的是一块大的内存,或者small bins和unsorted bin中都找不到合适的 chunk,并且fast bins和unsorted bin中所有的chunk都清除干净了。从large bins中按照“smallest-first,best-fit”原则,找一个合适的 chunk,从中划分一块所需大小的chunk,并将剩下的部分链接回到bins中。若操作成功,则分配结束,否则转到下一步。

9、如果搜索fast bins和bins都没有找到合适的chunk,那么就需要操作top chunk来进行分配了。判断top chunk大小是否满足所需chunk的大小,如果是,则从top chunk中分出一块来。否则转到下一步。

10、到了这一步,说明top chunk也不能满足分配要求,所以,于是就有了两个选择: 如果是主分配区,调用sbrk(),增加top chunk大小;如果是非主分配区,调用mmap来分配一个新的sub-heap,增加top chunk大小;或者使用mmap()来直接分配。在这里,需要依靠chunk的大小来决定到底使用哪种方法。判断所需分配的chunk大小是否大于等于 mmap分配阈值,如果是的话,则转下一步,调用mmap分配,否则跳到第12步,增加top chunk 的大小。

11、使用mmap系统调用为程序的内存空间映射一块chunk_size align 4kB大小的空间。 然后将内存指针返回给用户。

12、判断是否为第一次调用malloc,若是主分配区,则需要进行一次初始化工作,分配一块大小为(chunk_size + 128KB) align 4KB大小的空间作为初始的heap。若已经初始化过了,主分配区则调用sbrk()增加heap空间,分主分配区则在top chunk中切割出一个chunk,使之满足分配需求,并将内存指针返回给用户。

在这里插入图片描述
在这里插入图片描述

对于heap的操作,操作系统提供了brk()函数,c运行时库提供了sbrk()函数。

对于mmap映射区域的操作,操作系统提供了mmap()和munmap()函数。

sbrk(),brk() 或者 mmap() 都可以用来向我们的进程添加额外的虚拟内存。而glibc就是使用这些函数来向操作系统申请虚拟内存,以完成内存分配的。
在这里插入图片描述
进程的内存结构,在内核中,是用mm_struct来表示的,其定义如下:

struct mm_struct { … unsigned long (*get_unmapped_area) (struct file filp, unsigned long addr, unsigned long len, unsigned long pgoff, unsigned long flags); … unsigned long mmap_base; / base of mmap area / unsigned long task_size; / size of task vm space */ … unsigned long start_code, end_code, start_data, end_data; unsigned long start_brk, brk, start_stack; unsigned long arg_start, arg_end, env_start, env_end; … }
在上述mm_struct结构中:

[start_code,end_code)表示代码段的地址空间范围。
[start_data,end_start)表示数据段的地址空间范围。
[start_brk,brk)分别表示heap段的起始空间和当前的heap指针。
[start_stack,end_stack)表示stack段的地址空间范围。
mmap_base表示memory mapping段的起始地址。

C语言的动态内存分配基本函数是 malloc(),在 Linux 上的实现是通过内核的 brk 系统调用。brk()是一个非常简单的系统调用, 只是简单地改变mm_struct结构的成员变量 brk 的值。

Heap操作

在glibc中,malloc就是调用sbrk()函数将数据段的下界移动以来代表内存的分配和释放。sbrk()函数在内核的管理下,将虚拟地址空间映射到内存,供malloc()函数使用。

下面为brk()函数和sbrk()函数的声明。

#include int brk(void *addr); void *sbrk(intptr_t increment);
需要说明的是,当sbrk()的参数increment为0时候,sbrk()返回的是进程当前brk值。increment 为正数时扩展 brk 值,当 increment 为负值时收缩 brk 值。

延迟分配

每一个应用程序有独立的地址空间,当然这个地址是虚拟的,通过应用程序的页表可以把虚拟地址转化为实际的物理地址进行操作,虽然系统可以实现从虚拟地址到物理地址的转换,但并非应用程序的每一块虚拟内存都对应一块物理内存。Linux使用一种按需分配的策略为应用程序分配物理内存,这种按需分配是使用缺页异常实现的。比如一个应用程序动态分配了10MB的内存,这些内存在分配时只是在应用程序的虚拟内存区域管理结构中表示这一区间的地址已经被占用,内核此时并没有为之分配物理内存,而是在应用程序使用(读写)该内存区时,发现该内存地址对应得物理内存并不存在,此时产生缺页异常,促使内核为当前访问的虚拟内存页分配一个物理内存页。

内存的延迟分配,只有在真正访问一个地址的时候才建立这个地址的物理映射,这是 Linux 内存管理的基本思想之一。Linux 内核在用户申请内存的时候,只是给它分配了一个线性区(也就是虚拟内存),并没有分配实际物理内存;只有当用户使用这块内存的时候,内核才会分配具体的物理页面给用户,这时候才占用宝贵的物理内存。内核释放物理页面是通过释放线性区,找到其所对应的物理页面,将其全部释放的过程。

在这里插入图片描述

mm_struct

struct mm_struct {
struct vm_area_struct *mmap; /* 内存区域链表 */
struct rb_root mm_rb; /* VMA 形成的红黑树 */
...
struct list_head mmlist; /* 所有 mm_struct 形成的链表 */
...
unsigned long total_vm; /* 全部页面数目 */
unsigned long locked_vm; /* 上锁的页面数据 */
unsigned long pinned_vm; /* Refcount permanently increased */
unsigned long shared_vm; /* 共享页面数目 Shared pages (files) */
unsigned long exec_vm; /* 可执行页面数目 VM_EXEC & ~VM_WRITE */
unsigned long stack_vm; /* 栈区页面数目 VM_GROWSUP/DOWN */
unsigned long def_flags;
unsigned long start_code, end_code, start_data, end_data; /* 代码段、数据段 起始地址和结束地址 */
unsigned long start_brk, brk, start_stack; /* 栈区 的起始地址,堆区 起始地址和结束地址 */
unsigned long arg_start, arg_end, env_start, env_end; /* 命令行参数 和 环境变量的 起始地址和结束地址 */
...
/* Architecture-specific MM context */
mm_context_t context; /* 体系结构特殊数据 */

/* Must use atomic bitops to access the bits */
unsigned long flags; /* 状态标志位 */
...
/* Coredumping and NUMA and HugePage 相关结构体 */
};

进程栈

进程栈,就是进程地址空间当中的栈区!

进程栈的初始化大小是由编译器和链接器计算出来的,Linux内核会根据入栈情况对栈区进行动态增长(其实也就是添加新的页表)。进程栈是有最大值的,此值由rlimit的值进行限制,可通过shell命令ulimit -s查看,默认是8Mb。

进程栈的动态增长实现 进程在运行的过程中,通过不断向栈区压入数据,当超出栈区容量时,就会耗尽栈所对应的内存区域,这将触发一个 缺页异常 (page fault)。通过异常陷入内核态后,异常会被内核的 expand_stack() 函数处理,进而调用 acct_stack_growth() 来检查是否还有合适的地方用于栈的增长。

如果栈的大小低于 RLIMIT_STACK(通常为8MB),那么一般情况下栈会被加长,程序继续执行,感觉不到发生了什么事情,这是一种将栈扩展到所需大小的常规机制。然而,如果达到了最大栈空间的大小,就会发生 栈溢出(stack overflow),进程将会收到内核发出的 段错误(segmentation fault) 信号。

动态栈增长是唯一一种访问未映射内存区域而被允许的情形,其他任何对未映射内存区域的访问都会触发页错误,从而导致段错误。一些被映射的区域是只读的,因此企图写这些区域也会导致段错误。

线程栈

线程是任务的调度单位,进程是资源分配单位的理念,在实现上,其实是只有task的概念,并对应了task_struct结构,而进程其实是一个task组,也就是他是一组task的集合,所有的task共享内存资源,即mm_struct,其中task组的第一个task,我们称之为task组的leader,即主线程!

因此在linux中线程对应单个task,每个线程拥有一个task_struct,进程就是一组task,每个进程拥有一个mm_struct,进程中的线程共享mm_struct结构!

我们在创建线程时,会调用clone系统调用,clone系统调用也是fork的底层实现,如果要创建线程,那就需要带上CLONE_VM标志,此时,新创建的task,将会直接共享父亲的mm_struct。

线程栈是需要我们在创建线程时指定的!翻阅pthread_create()的源码,其调用了: allocate_stack (iattr, &pd, &stackaddr, &stacksize); 函数来申请栈空间,而allocate_stack最终又调用了: mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); 因此线程栈,其实是通过mmap在文件映射区申请的匿名页。

此外需要注意的是,线程的栈是不能动态增长的,指定了多大,用完就完了!当然了,默认情况下是和主线程保持一致,都是8Mb

内核栈

中断栈

函数调用与栈

在这里插入图片描述

寄存器与栈

每一个函数独占自己的栈帧空间。当前正在运行的函数的栈帧总是在栈顶。CPU系统提供两个特殊的寄存器用于标识位于系统栈顶端的栈帧。

(1) ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈地上面-个栈帧的栈顶。

(2) EBP:基址指针寄存器(extended base pointer)-其内存放着一个指针,该指针永远指向系统栈展上面一个栈帧的底部。

函数栈帧:ESP和EBP之间的内存空间为当前栈帧.EBP标识了当前栈帧的底部.ESP 标识了当前栈帧的顶部。

在函数栈帧中,一般包含以下几类重要信息。
(1) 局部变量:为函数局部变量开辟的内存空间。
(2) 栈帧状态值:保存前栈帧的顶部和底部(实际上只保存前栈帧的底部,前栈帧的顶部可以通过堆栈平衡计算得到),用于在本帧被弹出后恢复出上一个栈帧。
(3) 函数返回地址:保存当前函数调用前的“断点”信息,也就是函数调用前的指令位置,以便在函数返回时能够恢复到函数被调用前的代码区中继续执行指令。

除了与栈相关的寄存器外,您还需要记住另一个至关重要的寄存器。

EIP:指令寄存器(Extended Instruction Pointer),其内存放着一个指针,该指针永远指向下一条等待执行的指令地址,可以说如果控制了EIP寄存器的内容,就控制了进程——我们让EIP指向哪里,CPU就会去执行哪里的指令。

函数调用步骤

函数调用大致包括以下几个步骤.

(l) 参数入栈:将参数从右向左依次压入系统栈中。
(2)返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行。
(3) 代码区跳转:处理器从当前代码区跳转到被调用函数的入口处。
(4)栈帧调整:具体包括。
保存当前栈帧状态值,已备后面恢复本栈帧时使用(EBP入栈):
将当前栈帧切换到新栈帧(将ESP值装入EBP.更新栈帧底部):
给新栈帧分配空间(把ESP减去所需空间的大小,抬高栈顶):
对于__stdcall调用约定,函数调用时用到的指令序列大致如下。
在这里插入图片描述
类似地,函数返回的步骤如下:
(1) 保存返回值:通常将函数的返回值保存在寄存器EAX中。
(2) 弹出当前栈帧,恢复上一个栈帧。
(3) 跳转:按照函数返回地址跳同母函数中继续执行。

一、文件系统

1. 文件系统的理解(EXT4,XFS,BTRFS)

文件系统主要用于控制所有程序在不使用数据时如何存储数据、如何访问数据以及有什么其它信息(元数据)和数据本身相关。

Linux 操作系统即便使用标准安装过程,在同一块磁盘上仍使用多个分区。拥有不同分区的一个主要目的就是为了在灾难发生时能获得更好的数据安全性。

EXT4:使用广泛,Linux现在默认的是广泛采用的 ext4,易用性以及广泛性。

XFS:XFS 文件系统是扩展文件系统的一个扩展,XFS 文件系统有一些缺陷,例如它不能压缩,删除大量文件时性能低下。

btrfs:有很多好用的功能,例如写复制、扩展校验、快照、清洗、自修复数据、冗余删除以及其它保证数据完整性的功能。

硬连接和软连接的区别

APUE 4.15和4.16

硬链接:

A是B的硬链接(A和B都是文件名),则A的目录项中的inode节点号与B的目录项中的inode节点号相同,即一个inode节点对应两个不同的文件名,两个文件名指向同一个文件,A和B对文件系统来说是完全平等的。如果删除了其中一个,对另外一个没有影响。每增加一个文件名,inode节点上的链接数增加一,每删除一个对应的文件名,inode节点上的链接数减一,直到为0,inode节点和对应的数据块被回收。注:文件和文件名是不同的东西,rm A删除的只是A这个文件名,而A对应的数据块(文件)只有在inode节点链接数减少为0的时候才会被系统回收。

软链接:

A是B的软链接(A和B都是文件名),A的目录项中的inode节点号与B的目录项中的inode节点号不相同,A和B指向的是两个不同的inode,继而指向两块不同的数据块。但是A的数据块中存放的只是B的路径名(可以根据这个找到B的目录项)。A和B之间是“主从”关系,如果B被删除了,A仍然存在(因为两个是不同的文件),但指向的是一个无效的链接。

区别:

硬链接实际上是为文件建一个别名,链接文件和原文件实际上是同一个文件。可以通过ls -i来查看一下,这两个文件的inode号是同一个,说明它们是同一个文件;而软链接建立的是一个指向,即链接文件内的内容是指向原文件的指针,它们是两个文件。

软链接可以跨文件系统,硬链接不可以;

软链接可以对一个不存在的文件名(filename)进行链接(当然此时如果你vi这个软链接文件,linux会自动新建一个文件名为filename的文件),硬链接不可以(其文件必须存在,inode必须存在);

软链接可以对目录进行连接,硬链接不可以。

文件读写使用的系统调用

读文件:

int fd = -1;

char filename[] = “/root/test.txt”;

fd = open(filename,O_RDONLY);

size = read(fd,buf,10);

close(fd);

写文件:

int fd = -1;

char buf[] = “boys and girls\n hi,children!”;

fd = open(filename,O_RDWR|O_APPEND);

size = write(fd,buf,strlen(buf));

close(fd);

二、IO

Linux的I/O模型介绍、过程、区别以及同步异步阻塞非阻塞的区别(超级重要)

阻塞IO:是在两个过程应用都处于阻塞状态。进程或线程调用某个函数,该函数需要满足特定条件才能向下执行。如果条件不满足,则会使调用进程或线程阻塞,让出CPU控制权,并一直持续到条件满足为止。

非阻塞IO:是应用发出IO操作后可以立刻返回,通过轮询盘判断数据是否准备好,在copy数据阶段阻塞应用。

IO多路复用:是阻塞调用select,查找可用的套接字,如果有套接字可用,那么就阻塞调用(recvfrom)完成数据的copy过程。Linux select就是这种模型,缺点是一次select会扫描所有的socket。

信号驱动IO:是应用发出SIGIO后立刻返回,内核中数据准备好后,通知应用,由应用进行阻塞recvfrom调用从内核copy数据。linux epoll就是基于事件的就绪通知方式,省去了所有socket的扫描开销。

异步IO:是应用发出aio-read后马上返回,数据准备好后,由操作系统把数据copy到应用,并通知应用数据copy完成。

阻塞:用户进程访问数据时,如果未完成IO,调用的进程一直处于等待状态,直到IO操作完成。

非阻塞:用户进程访问数据时,会马上返回一个状态值,无论是否完成,此时进程可以操作其他事情。
同步:用户进程发起IO后,进行就绪判断,轮询内核状态。

异步:用户进程发起IO后,可以做其他事情,等待内核通知。

IO多路复用

select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

select

select:是最初解决IO阻塞问题的方法。用结构体fd_set来告诉内核监听多个文件描述符,该结构体被称为描述符集。由数组来维持哪些描述符被置位了。对结构体的操作封装在三个宏定义中。通过轮寻来查找是否有描述符要被处理,如果没有返回。

存在的问题:内置数组的形式使得select的最大文件数受限与FD_SIZE;每次调用select前都要重新初始化描述符集,将fd从用户态拷贝到内核态,每次调用select后,都需要将fd从内核态拷贝到用户态;轮寻排查当文件描述符个数很多时,效率很低;

pool

poll:通过一个可变长度的数组解决了select文件描述符受限的问题。数组中元素是结构体,该结构体保存描述符的信息,每增加一个文件描述符就向数组中加入一个结构体,结构体只需要拷贝一次到内核态。poll解决了select重复初始化的问题。轮寻排查的问题未解决。

epoll

epoll:轮寻排查所有文件描述符的效率不高,使服务器并发能力受限。因此,epoll采用只返回状态发生变化的文件描述符,便解决了轮寻的瓶颈。

每次调用poll/select系统调用,操作系统都要把current(当前进程)挂到fd对应的所有设备的等待队列上,可以想象,fd多到上千的时候,这样“挂”法很费事;而每次调用epoll_wait则没有这么罗嗦,epoll只在epoll_ctl时把current挂一遍(这第一遍是免不了的)并给每个fd一个命令“好了就调回调函数”,如果设备有事件了,通过回调函数,会把fd放入rdllist,而每次调用epoll_wait就只是收集rdllist里的fd就可以了——epoll巧妙的利用回调函数,实现了更高效的事件驱动模型。

Epoll的ET模式和LT模式(ET的非阻塞)

LT:level trigger, 水平触发模式

ET:edge trigger, 边缘触发模式

相同点:都是通过epoll_wait从EPOLL等待队列读取激活事件
区别:1. LT模式读取激活事件后,如果还有未处理的数据。事件会重新放入EPOLL等待队列。2. ET模式读取激活事件,直接从EPOLL等待队列移除,不管是否有未处理的数据。

LT模式问题:如果可读或可写事件未处理,会频繁激活未处理事件

解决方法:不想处理读写事件时, 从EPOLL中移除句柄。需要处理时再加入EPOLL

ET模式问题:如果可读或可写没有全部处理,会有老数据残留。要等待新数据到来。

解决方法:1. 循环读取或写入数据,一直到EAGAIN或EWOULDBLOCK

  1. 读取或者写入数据后,通过epoll_ctl设置EPOLL_CTL_MOD,激活未处理事件。

ET模式饿死问题处理方法:

应用层维护一个list, 存储epoll_wait返回的就绪文件描述符, 然后循环处理

在list不为空时, 进行循环处理其中事件

每个文件描述符只进行一定限度的 IO 操作, 比如每次限定只读 1KB 数据, 然后继续处理其他的文件描述符

如果该文件描述符读了 1KB 没读完 (没有返回EAGIN / EWOULDBLOCK), 就继续停留在list中, 反之如果其上的 IO 操作执行完了, 就将其移除list

上一题中编程的时候有什么区别,是在边缘触发的时候要把套接字中的数据读干净,那么当有多个套接字时,在读的套接字一直不停的有数据到达,如何保证其他套接字不被饿死(面试网易游戏的时候问的一个问题,答不上来,印象贼深刻)。

三、进程

Linux进程管理

在很多情况下,进程都是动态创建并由一个动态分配的 task_struct 表示。一个例外是 init 进程本身,它总是存在并由一个静态分配的 task_struct 表示。在 ./linux/arch/i386/kernel/init_task.c 内可以找到这样的一个例子。

Linux 内所有进程的分配有两种方式。第一种方式是通过一个哈希表,由 PID 值进行哈希计算得到;第二种方式是通过双链循环表。循环表非常适合于对任务列表进行迭代。由于列表是循环的,没有头或尾;但是由于 init_task 总是存在,所以可以将其用作继续向前迭代的一个锚点

(15)fork返回值是什么?

fork函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。

它可能有三种不同的返回值:

1)在父进程中,fork返回新创建子进程的进程ID;

2)在子进程中,fork返回0;

3)如果出现错误,fork返回一个负值;

守护进程、孤儿进程、僵尸进程

守护进程:

通常在系统后台运行,没有控制终端,不与前台交互,Daemon程序一般作为系统服务使用。Daemon是长时间运行的进程,通常在系统启动后就运行,在系统关闭是才结束。一般说Daemon程序在后台运行,是因为它没有控制终端,无法和前台的用户交互。Daemon程序一般都作为服务程序使用,等待客户端程序与它通信。我们把运行的Daemon程序称作守护进程。

孤儿进程:

一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为 1)所收养,并由 init 进程对它们完成状态收集工作。由于孤儿进程会被 init 进程收养,所以孤儿进程不会对系统造成危害。

僵尸进程:

一个子进程的进程描述符在子进程退出时不会释放,只有当父进程通过 wait() 或 waitpid() 获取了子进程信息后才会释放。如果子进程退出,而父进程并没有调用 wait() 或 waitpid(),那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵尸进程。

僵尸进程通过 ps 命令显示出来的状态为 Z(zombie)。

系统所能使用的进程号是有限的,如果产生大量僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程。

要消灭系统中大量的僵尸进程,只需要将其父进程杀死,此时僵尸进程就会变成孤儿进程,从而被 init 进程所收养,这样 init 进程就会释放所有的僵尸进程所占有的资源,从而结束僵尸进程。

处理僵尸进程的两种经典方法

父进程回收法:

wait函数将使其调用者阻塞,直到其某个子进程终止。故父进程可调用wait函数回收其僵尸子进程。

init进程回收法:

上面的这种解决方案需要父进程去等待子进程,但在很多情况下,这并不合适,因为父进程也许还有其他任务要做,不能阻塞在这里。在讲述下面这种不用父进程等待就能完成回收子进程的方法之前,先请明白以下两个概念:

  1. 如果父进程先于子进程结束,那么子进程的父进程自动改为 init 进程。

  2. 如果 init 的子进程结束,则 init 进程会自动回收其子进程的资源而不是让它变成僵尸进程。

(18)进程终止的几种方式

1、main函数的自然返回,return

2、调用exit函数

3、调用_exit函数

4、调用abort函数

5、接受能导致进程终止的信号:

exit和_exit函数都是用来终止进程的。当程序执行到exit和_exit时,进程会无条件的停止剩下的所有操作,清除包括PCB在内的各种数据结构,并终止本程序的运行。

exit函数和_exit函数的最大区别在于exit函数在退出之前会检查文件的打开情况,把文件缓冲区中的内容写回文件,也就是清理I/O缓冲。

abort()是使异常程序终止,同时发送SIGABRT信号给调用进程。

四、线程

五、内存管理

虚拟内存

物理内存就是系统硬件提供的内存大小,是真正的内存。相对于物理内存,在 Linux 下还有一个虚拟内存的概念,虚拟内存是为了满足物理内存的不足而提出的策略,它是利用磁盘空间虚拟出的一块逻辑内存。用作虚拟内存的磁盘空间被称为交换空间(又称 swap 空间)。

作为物理内存的扩展,Linux 会在物理内存不足时,使用交换分区的虚拟内存,更详细地说,就是内核会将暂时不用的内存块信息写到交换空间,这样一来,物理内存得到了释放,这块内存就可以用于其他目的,当需要用到原始的内容时,这些信息会被重新从交换空间读入物理内存。

Linux 的内存管理采取的是分页存取机制,为了保证物理内存能得到充分的利用,内核会在适当的时候将物理内存中不经常使用的数据块自动交换到虚拟内存中,而将经常使用的信息保留到物理内存。

要深入了解 Linux 内存运行机制,需要知道下面提到的几个方面:

首先,Linux 系统会不时地进行页面交换操作,以保持尽可能多的空闲物理内存,即使并没有什么事情需要内存,Linux 也会交换出暂时不用的内存页面,因为这样可以大大节省等待交换所需的时间。
其次,Linux 进行页面交换是有条件的,不是所有页面在不用时都交换到虚拟内存,Linux 内核根据“最近最经常使用”算法,仅仅将一些不经常使用的页面文件交换到虚拟内存。

在Linux内核,我们使用vm_area_struct结构来表示一个虚拟内存区域,一个具体的vm_area_struct包含以下字段:

vm_start:指向这个区域的起始处。

vm_end:指向这个区域的结束处。

vm_port:描述这个区域包含的所有页的读写权限。

vm_flags:描述这个区域是否是私有的还是共享的。

vm_next:指向链表中下一个区域结构。

为了解释清楚这里说一下上图中与vm_area_struct有关联的task_strcut 和 mm_strcut。

内核系统为每个进程维护一个单独的任务结构在内核源码中就是task_strcut ,该结构中的元素包含内核运行该进程所需要的所有信息,(如PID、执行用户栈的指针、程序计数器)。

任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态。其中有两个字段是我们感兴趣的,pgd 和 mmap。pgd指向第一级页表的基址,mmap指向vm_area_struct的链表。

mmap内存映射原理

(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
1、进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

2、在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址

3、为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化

4、将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中

(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
5、为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。

6、通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。

7、内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。

8、通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。

(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

9、进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
10、缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
11、调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
12、之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。

(16)什么是虚拟内存?

  1. 每个进程都有自己独立的4G内存空间,各个进程的内存空间具有类似的结构

  2. 一个新进程建立的时候,将会建立起自己的内存空间,此进程的数据,代码等从磁盘拷贝到自己的进程空间,哪些数据在哪里,都由进程控制表中的task_struct记录,task_struct中记录中一条链表,记录中内存空间的分配情况,哪些地址有数据,哪些地址无数据,哪些可读,哪些可写,都可以通过这个链表记录

  3. 每个进程已经分配的内存空间,都与对应的磁盘空间映射

  4. 每个进程的4G内存空间只是虚拟内存空间,每次访问内存空间的某个地址,都需要把地址翻译为实际物理内存地址

  5. 所有进程共享同一物理内存,每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上。

  6. 进程要知道哪些内存地址上的数据在物理内存上,哪些不在,还有在物理内存上的哪里,需要用页表来记录

4.页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)

  1. 当进程访问某个虚拟地址,去看页表,如果发现对应的数据不在物理内存中,则缺页异常

6.缺页异常的处理过程,就是把进程需要的数据从磁盘上拷贝到物理内存中,如果内存已经满了,没有空地方了,那就找一个页覆盖,当然如果被覆盖的页曾经被修改过,需要将此页写回磁盘

(17)Linux是如何避免内存碎片的

伙伴算法,用于管理物理内存,避免内存碎片;

高速缓存Slab层用于管理内核分配内存,避免碎片。

六、常用命令

(6) 查询进程占用CPU的命令(注意要了解到used,buf,***代表意义)

(7) linux的其他常见命令(kill,find,cp等等)

(8) shell脚本用法

(12) Linux监控网络带宽的命令,查看特定进程的占用网络资源情况命令

(18)Linux基本命令?

命令 作用

pwd 显示当前目录

rm 删除

touch 生成文件

cat 读取指定文件的内容并打印到终端输出

mkdir 新建目录make directory

file 查看文件类型

whereis,which,find 和 locate 查找

chown 改变文件所有者

df 查看磁盘容量

wc 计数工具

tr 删除一段文本信息中的某些文字。或者将其进行转换

join 连接两个文件

paste 它是在不对比数据的情况下,简单地将多个文件合并一起,以Tab隔开

Linux内核

(1)Linux内核的进程调度

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值