程序员的自我修养

程序员的自我修养

在看一本很有意思的书《程序员的自我修养》,讲的是链接、装载与库,大概讲了操作系统层面,程序如何加载运行的。记录以下关于操作系统的演化史。

操作系统

CPU

分时系统、多任务系统

硬盘

硬盘:硬盘有多个盘片,每个盘片分两面,每面按照同心圆划分为若干个,每个磁道划分为若干个扇区,盘面外围的磁道密度比内圈稀疏。现代的硬盘普遍采用LBA(logic block address)的方式来区分,所有扇区从0到开始编号,抛弃复杂的磁道、盘面的概念。

内存

传统的内存是程序需要多少内存,就直接分配多少内存,如果内存不够,就先把数据写到磁盘里面,等到要用时再读回来。这样的会导致三个主要问题:

  1. 地址空间不隔离,不同应用程序之间可以直接篡改其他程序的数据
  2. 内存使用效率低,频繁的磁盘内存数据换入换出
  3. 程序运行的地址不确定,程序的重定位问题。(即指令修改完以后,目标的地址需要重新计算)

解决问题的方式就是,增加中间层,使用间接的地址访问方法。将程序给出的地址看作是一种虚拟地址,然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。

内存分段

内存分段把程序所需要的内存空间大小的虚拟空间映射到某个地址空间。对于问题1,如果程序访问的虚拟空间的地址超出范围,硬件就会判断这是个非法请求,拒绝这个请求访问,并报告给操作系统或监控程序。对于问题3,因为不在需要关注物理地址,所以无需考虑。

内存分页

内存分段无法解决内存使用效率低的问题,主要是因为操作数据换入换出以程序为单位。根据程序的局部性原理,当一个程序在运行是,在某个时间段,它只是频繁地用到了一部分数据,为了提高效率,可以有更小粒度的内存分割和映射方法,即分页,将地址空间人为地等分成固定大小的页。虚拟空间的页为虚拟页,物理内存的页为物理页,磁盘中的页为磁盘页。当程序的虚拟页不在内存中,在使用到虚拟页的时候,会发生页错误,需要从磁盘中读出并存入内存,建立映射。这种映射为MMU(Memory ManageMent Unit)实现的,一般集成在CPU内部了。同时,页可以设置权限属性和访问,从而增加保护机制。

线程

多线程解决问题比如单线程的等待、交互的中断、并发操作、多核计算机的计算能力匹配、数据共享等。当线程数量小于等于处理器数量时,线程的并发是真正的并发,不同线程运行在不同的机器上。否则,一个处理器可能以多任务形式运行多个线程。
线程私有:栈、线程局部存储(ThreadLocal)、寄存器
线程共享:堆

线程调度状态,根据线程的running、ready、waitting等状态进行时间片轮转。线程的优先级提升策略一般为:用户指定、根据进入等待状态的频繁程度提升或者降低优先级、长时间不被执行而被提升优先级。

可以使用volatile关键字阻止过度优化(编译器的行为影响)。
例如我们在单例模式中的双重锁检查,c++的new其中的步骤:在内存的位置上调用构造函数和将内存地址赋值给pinst,这两部顺序是不确定的,可能出现pinst的值不是null了,然而对象的构造依然没有构造完毕。
CPU的乱序执行能力使安全保障变得异常困难,只能用barrier,阻止CPU将该指令之前的指令交换到barrier之后。

可重入与线程安全

一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行:

  1. 多个线程同时执行这个函数。
  2. 函数自身调用自身
Linux下的线程

Linux对于多线程的支持颇为贫乏,Linux将所有的执行实体(无论是线程还是进程)都称为任务,每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等,也可以共享内存空间(写时复制----即发生修改的时候才复制内存空间)

编译和链接

编译

我们通过编译器的构建(build)将编译和链接合并到一起。这个过程被隐藏了,其实它做了预编译(扩展宏定义、处理#inclue预编译指令,把被包含的文件插入到该预编译指令的位置),编译则是把预处理完的文件进行一系列的词法分析(将字符用有限状态机token化分割)、语法分析(将token进行语法分析,形成以表达式为节点的语法树)、语义分析(静态语义:声明、类型转化、匹配,动态语义:运行时出现的语义相关的问题)、优化(源码级优化:合并运算;代码生成器和目标代码优化器:选择合适的寻址方式、用位移代替乘法运算、删除多余好指令)形成汇编代码文件。

链接

编译后,真正的地址还没有指定。通过符号(symbol)来表示一个地址,可以是一段函数的起始地址,也可以是一个变量的起始地址。模块间函数调用和模块间变量访问都是模块间符号的饮用,链接的作用就是把一些指令对其他符号的引用加以引用,链接的过程主要包括了地址和空间分配、符号决议、重定位等步骤。比如一个模块调用另一个模块的foo函数,在编译器编译这个模块的时候,暂时会搁置这个指令的目标地址,等需要引用foo的时候,自动去另外一个模块查找foo的地址,然后修正地址。
通过这个样的链接过程,把每个模块的源代码文件经过编译器编译城目标文件(.o或者.obj),目标文件和库一起链接形成最终可执行文件。最常见的额就是运行时库。

目标文件

目标文件从结构上将,它是已经编译过的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或者地址还没有被调整。

目标文件的格式

目标文件的可执行格式在Linux下是ELF可执行文件,不光可执行文件按照这种格式来存储,动态链接库和静态链接库也是按照这种格式存储。通过file命令可以产看相应的文件格式。
目标文件总体来说,主要分为两种段:程序指令和程序数据。代码段是程序指令,数据段和.bss段属于程序数据。分段的意义是防止程序指令被修改、配合CPU的数据、指令缓存策略、共享指令可以节省大量内存。

ELF文件结构描述

包括文件头、各个指令段、数据段、段表、字符串表、符号表等等。
文件头定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台入口地址、程序头地址和长度、段表的位置和长度、短短额数量。其中魔术的最开始4个字节,第一个直接字节对应DEL控制符,后面3字节代表“E”“L”“F”三个字,第五个字节是表明文件类型,0x01表示32位,0x02表示64位,第六个字节表示字节序,是大端还是小端,第七个表示主版本号,后面使用这九个字节作为扩展标志。
段表描述了ELF各个段的信息,比如段名、段的长度、在文件中的偏移、读写权限等等。
重定位表是在链接器处理目标文件是,对目标文件中某些部位进行重定位,这些信息都会存放在重定位表里。
字符串表存放普通字符串,段表字符串表存放段名。
符号表:链接的接口是符号,对应了一个函数或者变量。符号表结构定义了符号的类型和绑定信息,定义了所在段,符号值等等。为了防止多模块符号冲突问题,需要使用命名空间和符号修饰机制来避免,多态中引入函数签名,包含函数信息,用于识别不同函数。

空间与地址分配

当有两个目标文件,如何链接合并成为一个可执行文件?
如果将各个目标文件的段都放进可执行文件中,那会造成空间的极大浪费以及产生很多内部碎片。
“链接器为目标文件分配地址和空间”,地址和空间指的的是在输出的可执行文件中的空间,第二个是装载后的虚拟地址中的虚拟地址空间。这里谈的是虚拟地址空间的解析。现在链接一般需要两步,第一步是空间和地址分配,这一步中需要整理全局符号表,这一步中,链接器能够获得所有目标文件的段长度,合并输出各个段的长度与位置,建立映射关系。第二步是符号解析和重定位,使用上述的段信息进入重定位过程。

重定位表

比如代码段“.text”有要被重定位的地方,那么会有一个相对应叫“.rel.text”的段保存了代码段的重定位表。每个要被重定位的地方叫一个重定位入口,重定位入口的偏移表示代码段中要被调整的位置(Relocation records for xxx),且符号解析都要在全局符号表中能够找到,否则连接器就报符号为定义错误。

COMMON块

多个符号定义类型不一致的时候,当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准,编译器将未初始化的全局变量定义作为弱符号处理。

C++相关问题
重复代码消除

模版从本质上来讲很想宏,当模版在一个编译单元里被实例化时,它并不知道自己是否在别的编译单元也被实例化了。比如有个模版函数是add(),某个编译单元以int类型和float类型实例了该模版函数,当别的编译单元也以int或float类型实例化该模版函数后,也会生成同样的名字,这样链接器在最终链接的时候可以区分这些相同的模版实例段,然后把他们并入最后的代码段。

全局构造与析构

Linux系统下一般程序的入口是“_start”,这个函数是Linux系统库的一部分,也是程序初始化部分的入口,程序初始化部分完成一系列初始化过程之后,会调用main函数来执行程序的主题。ELF文件定义来来个特殊的段,比如.init在main函数执行前系统就会执行它,.fini会在main函数返回后被执行的。

静态库链接

静态库可以简单地堪称一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。有些目标文件只有一个函数,这是因为运行库有成百上千个函数,数量非常庞大,每个函数独立的放在一个目标文件中可以减少空间的浪费。
ld -e main可以是ENGTRY指定主函数。

可执行文件的装载与进程

可执行文件只有装载到内存以后才能被CPU执行。早期的程序装载十分简陋,装载的基本过程就是把程序从外部存储器中读取到内存中的某个位置。随着硬件MMU的诞生,多进程,多用户,虚拟存储的操作系统出现以后,可执行文件的装载过程变得非常复杂。
程序的是一个静态的概念,进程是一个动态的概念,是程序运行时的一个过程。每个程序在运行起来后都有自己的虚拟地址空间。程序运行时不能随意使用虚拟空间,只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那个操作系统就会捕获这些访问,将这种操作标记为非法操作,强制结束进程。而且操作系统本身也占用了一定的虚拟空间。
但是Intel CPU扩展到了36位的物理内存,使得新的映射方式可以访问到跟多的物理内存。把这个地址扩展方式叫做PAE(Physical Address Extension)

装载的方式

为了尽可能有效地利用内存,可以将程序最常用的部分驻留在内存中,将一些不太常用的数据放在磁盘里面,这就是动态载入的基本原理,即使用到模块的时候才将模块载入。

覆盖载入

模块之间的依赖形成依赖树,同树间共享一个内存空间,不同树间禁止跨树调用。这中调用管理需要程序员来做辅助代码作为覆盖管理器。

页映射

页映射用的先进先出算法或者LRU算法,分页映射到物理内存,内存不够就让出空间。

从操作系统角度看可执行文件的装载
进程的建立
  1. 创建一个独立的虚拟地址空间
  2. 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
  3. 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
进程虚存空间分布

对于相同权限的段,把他们合并到一起当作一个段进行映射。链接器会尽量把相同权限属性的段分配在同一空间。
一个进程基本上可以分为一下集中VMA区域:

  1. 代码VMA,权限只读、可执行
  2. 数据VMA,权限可读写、可执行
  3. 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展
  4. 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展

动态链接

静态链接的方式对于计算机内存和磁盘的空间浪费非常严重,特别是多进程的系统下,每个程序内部除了都保留着printf()函数、scanf()函数、strlen()等这样的公用库函数,以及在更新的时候,需要导致整个程序都要更新。
把链接的过程推迟到了运行时再进行,这就是动态链接的基本思想。
动态链接的一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点后来被用来制作插件。
但是当程序所依赖的某个模块更新后,由于新的模块与旧的模块之间接口不兼容,缺少有效的共享库版本管理机制,可能导致其他程序无法正常工作。在linux中,被成为dso(Dynamic shared objects)共享对象.so文件,在windows系统中,被称为ddl(Dynamic linking library)
动态链接,除了可执行文件本身,还有它所依赖的共享目标文件。共享对象的最终装载地址在编译时是不确定的,而是装载器动态分配一块足够大小的虚拟地址空间。

延迟绑定

动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后再进行间接跳转。
延迟绑定的做法,基本的思想就是当函数第一次被用到时才进行绑定,如果没有用到则不进行绑定。

动态符号表

为了表示动态链接这些模块之间的额符号导入导出关系,ELF专门有一个动态符号表(Dynamic Symbol Table)的段用于保存这些信息,这个段的段名通常叫做“.dynsym”,与“.symtab”不同的是,“.dynsym”只保存来与动态链接相关的符号,对于模块内部的符号,比如模块私有变量则不保存。

动态链接自举

动态链接器必须要有特殊性:首先是,动态链接器本身不可以依赖于其他任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。对于最后一个条件,链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(bootstrap)。

动态链接器入口地址是自举代码的入口。动态链接器本身的函数自己页不能调用,因为地址文馆操作中,使用PIC模式编译的共享对象,模块内部和模块外部函数调用一样的方式,所以在没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。没有人帮它做重定位工作就自己来,美其名曰“自举”。
当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。

Linux共享库的组织

共享库处理版本兼容问题。
libname.so.x,y.z
x是主版本号,表示库的重大升级,y是次版本好,表示库的增量升级,z是发布版本号,表示性能改进,并不添加新的接口。这个版本号规则和其他的版本规则可能略有不同。

库与运行库

malloc是如何分配出内存,程序为什么能够执行,它是如何执行的。程序环境是程序在运行时调用运行库然后调用系统或者直接调用系统

内存

大多数操作系统都会将内存空间的一部分给内核使用,这部分空间叫做内核空间。应用使用的内存空间有如下默认区域:栈、堆、可执行文件映像、保留区。
增长方向上,栈向低地址增长,堆向高地址增长。

堆栈帧

栈保存来一个函数调用所需要的维护信息。
堆栈帧一般包括:

  1. 函数的返回地址和参数
  2. 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
  3. 保存的上下文:包括在函数调用前后需要保持不变的寄存器。

函数的调用方和被调用方堆函数如何调用有着通牒理解,认同函数的参数按照某个固定的方式压入栈内。
C++中,函数参数的传递顺序是从右到左,在函数将参数压栈之后,函数体会被调用,此后需要将被压入栈中的参数全部弹出,以是的栈在函数调用前后保持一致。
返回值是在站上开辟一块空间,并将这块空间的一部分作为传递返回值的临时对象,最后通过拷贝来更新最终的值。

堆与内存管理

栈上的数据在函数返回的时候就会被释放掉,无法将数据传递到函数外部。运行库向操作系统批发来一块较大的堆空间,然后零售给程序用。运行库通过一个堆的分配算法来管理对空间。
linux中mmap指令来申请空间,mmap的前两个参数分别用于指定需要申请的空间的起始地址和长度,glibc的malloc函数的处理方式:对于小于128kb的请求来说,会在现有的堆空间里面,按照堆分配算法分配一块空间并返回,对于大于128kb,使用mmap函数分配匿名空间,在这个匿名空间中为用户分配空间。malloc分配的空间(物理空间)不一定是连续的,因为一块连续的虚拟空间地址空间有可能是若干个不连续的物理页拼凑起来的。

堆分配空间
  1. 空闲链表
    把堆中的各个空闲的块按照链表的方式链接起来。当用户请求k个字节空间的时候,实际上分配来k+4个字节,这4个字节用于存储该分配的大小即k+4.这样释放该内存的时候只要看这4个字节的值。但是如果链表被破坏,或者记录长度的4字节被破话,整个堆就无法正常工作。

  2. 位图
    位图把整个堆划分为大量的块,每个块的大小相同。用户请求内存的时候,总是将分配整数个块的空间给用户,第一个块是分配区域的头,其余的成为分配区域的主体。当用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲的三种状况,因此仅仅需要两位即可表示一个块,因此成为位图。但是也是有问题的,分配内存的额时候容易产生碎片,例如分配300字节,实际上分配来3个块,即384字节,浪费了84字节。

Main函数不是入口函数
  1. 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数
  2. 入口函数堆运行库和程序运行环境进行初始化,包括堆、io、线程、全局变量构造
  3. 入口函数在完成初始化后,调用main函数,正式开始执行程序主体部分。
  4. main函数执行完毕后,返回到入口函数,入口函数进行清理工作,包括全局变量西沟、堆销毁、关闭io,然后进行系统调用结束进程。
字节序

在不同的计算机体系中,数据的存储和传输机制有所不同,目前存储机制主要有两种:大端和小端。
MSB(most significant bit),表示一个bit序列中对整个序列取值影响最大的那个bit,LSB是指影响最小的那个bit,如0x12345678中,0x12就是MSB,0x78就是LSB。
大端和小端的区别是大端规定MSB在存储时放在低地址,在传输时MSb放在流的开始,LSB存储时放在高地址,在传输时放在流的末尾。小端则相反。目前的tcp/ip网络以及java虚拟机的字节序都是大端的。这意味着如果通过网络传输ox12345678这个整形变量,首先被发送的应该是ox12,接着是0x34,然后是0x56,最后是0x78

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值