Linux系统编程易错点

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/MOU_IT/article/details/88313689

1、Linux中的内存布局(参考

1)每个进程有独立的虚拟地址空间,进程访问的虚拟地址并不是真正的物理地址
2)虚拟地址可通过每个进程的页表与物理地址进行映射,获得真正物理地址;
3)如果虚拟地址对应的物理地址不在物理内存中,则产生缺页中断,并真正分配物理地址,同时更新进程的页表;如果此时物理内存已耗尽,则根据内存替换算法淘汰部分页面至物理磁盘中。

                                 

    Linux使用虚拟地址空间,大大增加了进程的寻址空间,由低地址到高地址分别为:

    1)只读段:该部分空间只能读,不可写。包括代码段、rodata段(C常量字符串和#define定义的常量)
    2)数据段:保存全局变量、静态变量的空间
    3)堆:就是平时所说的动态内存, malloc/new大部分都来源于此。其中堆顶的位置可通过函数brk和sbrk进行动态调整。
    4)文件映射区域:如动态库、共享内存等映射物理空间的内存,一般是mmap函数所分配的虚拟地址空间。
    5)栈:用于维护函数调用的上下文空间,一般为8M,可通过ulimit –s查看。
    6)内核虚拟内存:用户代码不可见的内存区域,由内核管理。 32位系统有4G的地址空间,其中0x08048000~0xbfffffff 是用户空间,0xc0000000~0xffffffff是内核空间,内核虚拟空间包括内核代码和数据结构,这些区域被所有进程共享。此外,内核虚拟内存为系统的每个进程维护一个单独的任务结构(task_struct),也就是我们常说的进程控制块PCB,任务结构中的元素包含或者指向内核运行该进程时需要的所有信息(例如PID、内核在进程的上下文中执行代码时使用的栈、可执行目标文件的名字、程序计数器、页表以及记录虚拟地址空间当前组织的各种数据结构)。

2、高端内存是什么(参考):

      通常32位Linux内核地址空间划分0~3G为用户空间,3~4G为内核空间。注意这里是32位内核地址空间划分,64位内核地址空间划分是不同的。当内核模块代码或线程访问内存时,代码中的内存地址都为逻辑地址,而对应到真正的物理内存地址,需要地址一对一的映射,如逻辑地址0xc0000003对应的物理地址为0x3,0xc0000004对应的物理地址为0x4,… …。假设按照上述简单的地址映射关系,那么内核逻辑地址空间访问为0xc0000000 ~ 0xffffffff,那么对应的物理内存范围就为0x0 ~ 0x40000000,即只能访问1G物理内存。若机器中安装8G物理内存,那么内核就只能访问前1G物理内存,后面7G物理内存将会无法访问,因为内核的地址空间已经全部映射到物理内存地址范围0x0 ~ 0x40000000。即使安装了8G物理内存,那么物理地址为0x40000001的内存,内核该怎么去访问呢?代码中必须要有内存逻辑地址的,0xc0000000 ~ 0xffffffff的地址空间已经被用完了,所以无法访问物理地址0x40000000以后的内存。
      显然不能将内核地址空间0xc0000000 ~ 0xfffffff全部用来简单的地址映射。因此x86架构中将内核地址空间划分三部分:ZONE_DMA、ZONE_NORMAL和ZONE_HIGHMEM。ZONE_HIGHMEM即为高端内存,这就是内存高端内存概念的由来。
在x86结构中,内核空间的三种类型的区域如下:

ZONE_DMA                 内存开始的16MB
ZONE_NORMAL         16MB~896MB
ZONE_HIGHMEM       896MB~1024MB

                            

      高端内存HIGH_MEM地址空间范围为0xF8000000 ~ 0xFFFFFFFF(896MB~1024MB)。那么如内核是如何借助128MB高端内存地址空间是如何实现访问可以所有物理内存?                           
      当内核想访问高于896MB物理地址内存时,从0xF8000000 ~ 0xFFFFFFFF地址空间范围内找一段相应大小空闲的逻辑地址空间,借用一会。借用这段逻辑地址空间,建立映射到想访问的那段物理内存(即填充内核PTE页面表),临时用一会,用完后归还。这样别人也可以借用这段地址空间访问其他物理内存,实现了使用有限的地址空间,访问所有所有物理内存。如下图。

                       
       例如内核想访问2G开始的一段大小为1MB的物理内存,即物理地址范围为0x80000000 ~ 0x800FFFFF。访问之前先找到一段1MB大小的空闲地址空间,假设找到的空闲地址空间为0xF8700000 ~ 0xF87FFFFF,用这1MB的逻辑地址空间映射到物理地址空间0x80000000 ~ 0x800FFFFF的内存。
      当内核访问完0x80000000 ~ 0x800FFFFF物理内存后,就将0xF8700000 ~ 0xF87FFFFF内核线性空间释放。这样其他进程或代码也可以使用0xF8700000 ~ 0xF87FFFFF这段地址访问其他物理内存。
从上面的描述,我们可以知道高端内存的最基本思想:借一段地址空间,建立临时地址映射,用完后释放,达到这段地址空间可以循环使用,访问所有物理内存。

(1)、用户空间(进程)是否有高端内存概念?
用户进程没有高端内存概念。只有在内核空间才存在高端内存。用户进程最多只可以访问3G物理内存,而内核进程可以访问所有物理内存。
(2)、64位内核中有高端内存吗?
目前现实中,64位Linux内核不存在高端内存,因为64位内核可以支持超过512GB内存。若机器安装的物理内存超过内核地址空间范围,就会存在高端内存。
(3)、用户进程能访问多少物理内存?内核代码能访问多少物理内存?
32位系统用户进程最大可以访问3GB,内核代码可以访问所有物理内存。
64位系统用户进程最大可以访问超过512GB,内核代码可以访问所有物理内存。
(4)、高端内存和物理地址、逻辑地址、线性地址的关系?
高端内存只和物理地址有关系,和线性地址、逻辑地址没有直接关系。
(5)、为什么不把所有的地址空间都分配给内核?
若把所有地址空间都给内存,那么用户进程怎么使用内存?怎么保证内核使用内存和用户进程不起冲突?

3、Linux内存管理算法:伙伴算法和slab

(1)伙伴算法参考

      通常情况下,一个高级操作系统必须要给进程提供基本的、能够在任意时刻申请和释放任意大小内存的功能,就像malloc 函数那样,然而,实现malloc 函数并不简单,由于进程申请内存的大小是任意的,如果操作系统对malloc 函数的实现方法不对,将直接导致一个不可避免的问题,那就是内存碎片。内存碎片就是内存被分割成很小很小的一些块,这些块虽然是空闲的,但是却小到无法使用。随着申请和释放次数的增加,内存将变得越来越不连续。最后,整个内存将只剩下碎片,即使有足够的空闲页框可以满足请求,但要分配一个大块的连续页框就可能无法满足,所以减少内存浪费的核心就是尽量避免产生内存碎片。针对这样的问题,有很多行之有效的解决方法,其中伙伴算法被证明是非常行之有效的一套内存管理方法,因此也被相当多的操作系统所采用。
      伙伴算法,简而言之,就是将内存分成若干块,然后尽可能以最适合的方式满足程序内存需求的一种内存管理算法,伙伴算法的一大优势是它能够完全避免外部碎片的产生。申请时,伙伴算法会给程序分配一个较大的内存空间,即保证所有大块内存都能得到满足。很明显分配比需求还大的内存空间,会产生内部碎片。所以伙伴算法虽然能够完全避免外部碎片的产生,但这恰恰是以产生内部碎片为代价的。Linux 便是采用这著名的伙伴系统算法来解决外部碎片的问题。把所有的空闲页框分组为 11 块链表,每一块链表分别包含大小为1,2,4,8,16,32,64,128,256,512 和 1024 个连续的页框。对1024 个页框的最大请求对应着 4MB 大小的连续RAM 块。每一块的第一个页框的物理地址是该块大小的整数倍。例如,大小为 16个页框的块,其起始地址是 16 * 2^12 (2^12 = 4096,这是一个常规页的大小)的倍数。
      下面通过一个简单的例子来说明该算法的工作原理:假设要请求一个256(129~256)个页框的块。算法先在256个页框的链表中检查是否有一个空闲块。如果没有这样的块,算法会查找下一个更大的页块,也就是,在512个页框的链表中找一个空闲块。如果存在这样的块,内核就把512的页框分成两等分,一般用作满足需求,另一半则插入到256个页框的链表中。如果在512个页框的块链表中也没找到空闲块,就继续找更大的块——1024个页框的块。如果这样的块存在,内核就把1024个页框块的256个页框用作请求,然后剩余的768个页框中拿512个插入到512个页框的链表中,再把最后的256个插入到256个页框的链表中。如果1024个页框的链表还是空的,算法就放弃并发出错误信号。
      简而言之,就是在分配内存时,首先从空闲的内存中搜索比申请的内存大的最小的内存块。如果这样的内存块存在,则将这块内存标记为“已用”,同时将该内存分配给应用程序。如果这样的内存不存在,则操作系统将寻找更大块的空闲内存,然后将这块内存平分成两部分,一部分返回给程序使用,另一部分作为空闲的内存块等待下一次被分配。
      以上过程的逆过程就是页框块的释放过程,也是该算法名字的由来。内核试图把大小为 b 的一对空闲伙伴块合并为一个大小为 2b 的单独块。满足以下条件的两个块称为伙伴:

1)两个快具有相同的大小,记作 b;
2)它们的物理地址是连续的;
3)第一块的第一个页框的物理地址是 2 * b * 2^12 的倍数;

      该算法是迭代的,如果它成功合并所释放的块,它会试图合并 2b 的块,以再次试图形成更大的块。假设要释放一个256个页框的块,算法就把其插入到256个页框的链表中,然后检查与该内存相邻的内存,如果存在同样大小为256个页框的并且空闲的内存,就将这两块内存合并成512个页框,然后插入到512个页框的链表中,如果不存在,就没有后面的合并操作。然后再进一步检查,如果合并后的512个页框的内存存在大小为512个页框的相邻且空闲的内存,则将两者合并,然后插入到1024个页框的链表中。简而言之,就是当程序释放内存时,操作系统首先将该内存回收,然后检查与该内存相邻的内存是否是同样大小并且同样处于空闲的状态,如果是,则将这两块内存合并,然后程序递归进行同样的检查。

(2)slab算法(参考参考):

      有了伙伴系统(buddy),我们可以以页为单位获取连续的物理内存了,即4K为单位的获取,但如果需要频繁的获取/释放并不大的连续物理内存怎么办,如几十字节几百字节的获取/释放,这样的话用buddy就不太合适了,这就引出了slab。slab 层把不同的对象划分为所谓高速缓存组,其中每个高速缓存组都存放不同类型的对象,每种对象类型对应一个高速缓存。例如,一个高速缓存用于存放进程描述符,而另一个高速缓存存放索引节点对象,然后这些高速缓存又被划分为slab。slab 由一个或多个物理上连续的页组成。一般情况下,slab 也就是仅仅由一页组成,每个高速缓存可以由多个 slab 组成。slab 分配器把每一个请求的内存称之为对象。每个 slab 都包含一些对象成员,这里的对象指的是被缓存的数据结构。每个 slab 处于三种状态之一:满、部分满或空。一个满的 slab 没有空闲的对象(slab 中的对象都已被分配)。一个空的 slab 没有分配出任何对象(slab 中所有对象都是空闲的)。一个部分满的 slab 有一些对象已分配出去,有些对象还空闲着。当内核的某一部分需要一个新的对象时,先从部分满的 slab 中进行分配,如果没有部分满的 slab,就从空的 slab 中进行分配。如果没有空的 slab,就要创建一个 slab 了。

      比如我需要一个100字节的连续物理内存,那么内核slab分配器会给我提供一个相应大小的连续物理内存单元,为128字节大小(不会是整好100字节,而是这个档的一个对齐值,如100字节对应128字节,30字节对应32字节,60字节对应64字节),这个物理内存实际上还是从伙伴系统获取的物理页;当我不再需要这个内存时应该释放它,释放它并非把它归还给伙伴系统,而是归还给slab分配器,这样等再需要获取时无需再从伙伴系统申请,这也就是为什么slab分配器往往会把最近释放的内存(即所谓“热”)分配给申请者,这样效率是比较高的。

                                            

      上显示了 slab、cache 及 object 三者之间的关系。该图显示了 2 个大小为 3KB 的内核对象和 3 个大小为 7KB 的对象,它们位于各自的 cache 中。slab 分配算法采用 cache 来存储内核对象。在创建 cache 时,若干起初标记为 free 的对象被分配到 cache。cache 内的对象数量取决于相关 slab 的大小。例如,12KB slab(由 3 个连续的 4KB 页面组成)可以存储 6 个 2KB 对象。最初,cache 内的所有对象都标记为空闲。当需要内核数据结构的新对象时,分配器可以从 cache 上分配任何空闲对象以便满足请求。从 cache 上分配的对象标记为 used(使用)。
     让我们考虑一个场景,这里内核为表示进程描述符的对象从 slab 分配器请求内存。在 Linux 系统中,进程描述符属于 struct task_struct 类型,它需要大约 1.7KB 的内存。当 Linux 内核创建一个新任务时,它从 cache 中请求 struct task_struct 对象的必要内存。cache 利用已经在 slab 中分配的并且标记为 free (空闲)的 struct task_struct 对象来满足请求。 

    slab 分配器提供两个主要优点:

  1)在解决小的内存碎片上,Linux 采用伙伴算法解决外部碎片的产生,但会因此产生内部碎片,基于此,Linux 内存管理采用了 slab allocation 机制:整理内存以便重复使用来避免常见的内部碎片问题。没有因碎片而引起内存浪费。因为每个内核数据结构都有关联的 cache,每个 cache 都由一个或多个slab 组成,而 slab 按所表示对象的大小来分块。因此,当内核请求对象内存时,slab分配器可以返回刚好表示对象的所需内存。
  2)可以快速满足内存请求。因此,当对象频繁地被分配和释放时,如来自内核请求的情况,slab 分配方案在管理内存时特别有效。分配和释放内存的动作可能是一个耗时过程。然而,由于对象已预先创建,因此可以从 cache 中快速分配。再者,当内核用完对象并释放它时,它被标记为空闲并返回到 cache,从而立即可用于后续的内核请求。

4、fork()和vfork()的区别(参考)

    (1)fork()

    调用fork()时,fork()将父进程所有的资源通过数据结构的"复制"传给子进程:让子进程与父进程使用同一代码段,将父进程的数据段和堆栈段复制一份给子进程,这样父进程的所有数据都可以留给子进程,但父子进程的地址空间已经分开,不再共享任何数据,因此,相互之间不再有影响。子进程的执行独立于父进程,父子进程的数据共享需要通过专门的通信机制。采用“复制”的方法创建子进程控制块把复制这个词加上引号,是因为这个复制并不是完全复制。因为父进程控制块中某些项的内容必须按照子进程的特点来修改,如:进程的标识、状态等。另外,子进程控制块还必须有表示自己父进程的域及私有空间,如数据空间、用户堆栈等。

                              

     在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,linux中引入了“写时复制“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。那么子进程的物理空间没有代码,怎么去取指令执行exec系统调用呢?
    在fork之后exec之前两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,如果不是因为exec,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。而如果是因为exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。

(2)vfork()

    vfork是linux提供的另一个用来生成一个子进程的系统调用。与fork的区别:vfork并不把父进程全部复制到子进程中,而只是用复制指针的方法使子进程和父进程的资源实现共享。因为通常创建新进程的目的是 exec一个新程序,所以vfork不产生父进程的副本,可提高效率。vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec (或exit ),不过在子进程调用 exec或exit之前,它在父进程的空间中运行。vfork保证子进程先于父进程运行,只有当子进程调用exec或exit后,父进程才能被调度运行。用vfork()创建的子进程不能用return返回,只能用exit()或_exit()退出。而用fork()创建的子进程可以用return返回。

                                             

(3) PCB(参考):

    在linux 中每一个进程都由task_struct的数据结构来定义,task_struct就是我们通常所说的PCB。PCB通常包含: 进程标识符、进程当前状态(-1 不可运行,0 可运行(就绪),>0 已停止)、进程相应的程序和数据地址、进程资源清单、进程优先级、CPU现场保护区、进程同步与通信机制、进程所在队列PCB的链接字、与进程有关的其他信息。

5、read/write和mmap的区别(参考

(1)先说一下read/write系统调用,read/write系统调用会有以下的操作:

    1)访问文件,这涉及到用户态到内核态的转换
    2)读取硬盘文件中的对应数据,内核会采用预读的方式,比如我们需要访问100字节,内核实际会将按照4KB(内存页的大小)存储在page cache中
    3)将read中需要的数据,从page cache中拷贝到用户缓冲区中
    整个过程还是比较艰辛的,基本上涉及到用户内核态的切换,还有就是数据拷贝。

(2)继续说mmap:

    mmap系统调用是将硬盘文件映射到用内存中,说的底层一些是将page cache中的页直接映射到用户进程地址空间中,从而进程可以直接访问自身地址空间的虚拟地址来访问page cache中的页,这样会并涉及page cache到用户缓冲区之间的拷贝。

(3)mmap系统调用与read/write调用的区别在于:

  1)mmap只需要一次系统调用,后续操作不需要系统调用;
  2)访问的数据不需要在page cache和用户缓冲区之间拷贝;

    从上所述,当频繁对一个文件进行读取操作时,mmap会比read高效一些。最后再说一下page cache的话题,从上面所说我们从磁盘文件中读取的内容都会存在page cache中,但当我们关闭这个文件时,page cache中内容会立马释放掉吗?答案是否,磁盘的读取速度比内存慢太多,如果能命中page cache可以显著提升性能,万一后续又有对这个文件的操作,系统就可以很快速的响应。当然,这些文件内容也不是一直存在page cache中的,一般只要系统有空闲物理内存,内核都会拿来当缓存使用,但当物理内存不够用,内存会清理出部分page cache应急,这也就是告诉我们程序对于物理内存的使用能省则省,交给内核使用,作用很大。还有就是普通的write调用只是将数据写到page cache中,并将其标记为dirty就返回了,磁盘I/O通常不会立即执行,这样做的好处是减少磁盘的回写次数,提供吞吐率,不足就是机器一旦意外挂掉,page cache中的数据就会丢失。一般安全性比较高的程序会在每次write之后,调用fsync立即将page cache中的内容回写到磁盘中。

6、进程和线程的同步

    进程间有多重通信方式:管道、命名管道(FIFO)、消息队列、信号量和共享存储。信号量其实是同步原语,而不是IPC,常用于共享资源(如共享存储段)的同步访问。此外,使用共享资源时还可以使用记录锁互斥量,共享存储可以通过mmap函数实现同样的功能,但是共享存储仍然有它的用途。

    线程的同步方式有:互斥量、读写锁、条件变量、自旋锁(忙等阻塞而非休眠阻塞)、屏障(每个线程等待,直到所有线程达到某一点)。

7、如何实现守护进程?

     守护进程(daemon)是生存期长的一种进程,它们常常在系统引导转入时启动,仅在系统关闭时才终止,因为它们没有控制终端,所以说它们是在后台运行的。大多数守护进程都以超级用户(root)特权运行,所有的守护进程都没有控制终端。在使用ps命令显示进程的状态时,守护进程的终端名设置为问号。父进程ID为0的各进程通常是内核进程,它们作为系统引导装入过程的一部分而启动。但是init除外,init的进程ID为1,父进程ID为0,init进程时一个由内核在引导装入时启动的用户层次的命令。用户层守护进程的父进程是init进程。

    创建守护进程的编程步骤:

  (1)  首先调用umask将文件模式创建屏蔽字设置为一个已知值(通常是0),因为由继承得来的文件模式创建屏蔽字可能会被设置为拒绝某些权限。
  (2)  调用fork,然后使父进程exit。虽然子进程继承了父进程的进程组ID,但是获得了一个新的进程ID,这就保证了子进程不是一个进程组的组长进程(进程组组长进程的进程组ID等于其进程ID),这是setsid调用的先决条件,因为如果一个进程组的组长进程调用setsid将会出错。
  (3)  调用setsid创建一个新会话,使得调用进程:1)成为新回话首进程;2)成为一个新进程组的组长进程;3)没有控制终端。
  (4)  将当前工作目录更改为根目录。因为根目录不会被卸载。
  (5)  关闭不再需要的文件描述符,这使得守护进程不再持有从父进程继承来的任何文件描述符。
  (6)  某些守护进程打开/dev/null,使其具有文件描述符0、1、2,这样任何一个试图读标准输入、写标准输出或者标准错误的库例程都不会产生任何效果。

9、linux的任务调度机制是什么

    在每个进程的task_struct结构中有以下四项:policy、priority、counter、rt_priority。这四项是选择进程的依据。其中,policy是进程的调度策略,用来区分实时进程和普通进程,实时进程优先于普通进程运行;priority是进程(包括实时和普通)的静态优先级;counter是进程剩余的时间片,它的起始值就是priority的值;由于counter在后面计算一个处于可运行状态的进程值得运行的程度goodness时起重要作用,因此,counter 也可以看作是进程的动态优先级。rt_priority是实时进程特有的,用于实时进程间的选择。 Linux用函数goodness()来衡量一个处于可运行状态的进程值得运行的程度。该函数综合了以上提到的四项,还结合了一些其他的因素,给每个处于可运行状态的进程赋予一个权值(weight),调度程序以这个权值作为选择进程的唯一依据。

10、五种I/O模式

(1)阻塞I/O(Linux下的I/O操作默认是阻塞I/O,即open和socket创建的I/O都是阻塞I/O),I/O操作有可能使得进程永远阻塞;
(2)非阻塞 I/O(可以通过fcntl或者open时使用O_NONBLOCK参数,将fd设置为非阻塞的I/O),I/O操作时这些操作永远不会阻塞,如果这种操作完不成,则调用立即出错返回;
(3)I/O 多路复用(I/O多路复用,通常需要非阻塞I/O配合使用),如select、poll、epoll等;
(4)信号驱动 I/O(SIGIO);
(5)异步 I/O。

11、UDP和TCP的connect有什么区别:

  (1)UDP中可以使用connect系统调用。
  (2)UDP中connect操作与TCP中connect操作有着本质区别。
      TCP中调用connect会引起三次握手,client与server建立连结;
      UDP中调用connect内核仅仅把对端ip&port记录下来。
  (3)UDP中可以多次调用connect,TCP只能调用一次connect。
    一般来说,UDP客户端在建立了插口后会直接用sendto()函数发送数据,需要在sendto()函数的参数里指明目的地址/端口。如果一个UDP客户端在建立了插口后首先用connect()函数指明了目的地址/端口,然后也可以用send函数发送数据,因为此时send函数已经知道对方地址/端口 。

猜你喜欢

转载自blog.csdn.net/MOU_IT/article/details/88313689