读书笔记:《程序员的自我修养》

2021/11/28 1:11:43

本文主要是介绍读书笔记:《程序员的自我修养》,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

介绍

这是一本关于程序编译、链接、装载和库的书,涉及操作系统和编译原理。

花了一天的时间草草的翻了一遍,没怎么深入思考,稍微增加了一点见识吧。不过进入到动态链接库之后的章节,就越翻越快了,进入脑子里的东西越来越少。这本书比较好的地方在于可以将以前学习的很多知识点串起来,从一个程序的编译到链接,再到程序的装载运行。

作者推荐的几本书

《Linkers and Loaders》

《Intel 64 and IA-32 Architectures Software Developers Manual》,这个手册挺牛逼的,记得以前写操作系统的时候,需要查阅相关的内容。主要是一些处理器相关的内容。

第一章

没有什么问题不能通过增加一层抽象解决

System Call 的位置

上面是运行时,下面是硬件。中间是 kernel,通过 “中断机制” 进行调用。

虚拟地址

  • 地址空间的作用之一:隔离不同应用的地址空间。
  • 程序运行地址的重定位问题。直接使用硬件地址会遇到这个问题。
  • 分段:以程序为单位分段,直接将程序的整个空间映射到某个范围,映射由软件完成,地址转换由硬件完成。
  • 分页:以内存的页为单位,将程序分成页进行映射,需要硬件支持(MMU),MMU 在 CPU 内部,发出的虚拟地址在 MMU 转成硬件地址

操作系统概念复习

  • fork 函数,调用产生一个完全一样的进程,并且和当前的进程一样从 fork 返回,新进程返回的是 0。两个进程使用了 COW 机制,既然内容都一样就没必要复制了,需要修改内容、产生差异了,那时候再修改就好了。
  • 可重入,表示一个函数可以重复进入执行。如果使用互斥锁,那么这个函数递归调用会进入等待。“可重入锁指的是在一个线程中可以多次获取同一把锁”,获取了锁之后还可以再获取。
  • 指令乱序执行带来的困扰,某些指令即使乱序执行,也不影响单线程的运行结果;但影响多线程的运行结果。即使用了 volatile 来保证可见性。

第二章 静态链接

  • 预处理:头文件会被包含进去,处理宏指令进行展开,但保留编译器优化用的宏。
  • 编译:编译的结果是汇编代码文件。词法分析,语法分析,语义分析,源代码优化(IR 优化),代码生成。原则上优化可以发生在任何阶段,只不过选取一个中间代码优化可以更好的抽象。
  • 汇编:将汇编指令和机器指令一一对应。最终得到目标文件 Object file。

链接

为什么需要链接?因为编译文件的时候,存在一些无法确定的符号,需要将多个文件凑到一起,才可以找到这些符号,确定下来。比如一个 extern 变量,比如 include 其他头文件,那么在当前源文件可以使用里面的变量和函数名,但是却无法找到那个符号的具体地址。

链接过程:地址和空间分配,符号解析,重定位。

一个 hello world 程序,很重要的是链接运行时(runtime)文件。这样才可以运行。程序的启动过程其实还是要看这个 “运行时(runtime)”

第三章 目标文件

一个目标文件分成几个 section 节,有时候也叫 segment 段。最重要的就是代码段和数据段,代码段里面可能存在一些符号还没有链接。此时需要有一个段叫做 .rel.text 来记录还没有链接的符号。.data 段和 .bss 段存放数据,.bss 存放未初始化的、初始化为 0 的全局变量和静态变量,还有 .rodata 只读数据段,存放字符串常量等。数据段和代码段分开,可以从性能角度考虑原因,可以充分利用局部性原理。为了调试,需要使用某些段保存调试信息,比如对应的代码行号。

几个重要的命令

file:查看文件信息
objdump:查看二进制
nm:打印符号表
readelf:读取 elf
c++filt: 解析符号
ar:压缩程序,压缩多个 .o 为一个 .a

符号修饰

符号修饰可以用于区分不同语言的目标文件,区分重载的函数。比如 c 语言的目标文件中的变量函数以一个下滑线开头,Fortran 语言的目标文件以下滑线开头和结尾。再比如 C++ 中重载的函数,可以按照一定规则将签名变成一个字符串以进行区分。

使用 c++filt 来将修饰过的符号变成签名。使用 extern "C" 导出 C 命名规则的修饰。

第四章 静态链接

这样编译出来的两个目标文件应该怎样链接呢?

两步链接

多个目标文件,每个目标文件都是分段的,为了生成一个可执行文件,需要将这些段进行合并,比如将所有的目标文件代码段合到一起。

第一步,空间和地址分配。链接之后的目标文件代码段会指向一个虚拟地址 VMA(Virtual Memory Address),这个 VMA 指向虚拟空间地址。空间的分配,“空间” 二字有两个含义,一个是输出的目标文件中的空间,占存储大小的;一个是虚拟地址的空间,占运行时内存大小。地址应该怎么确定?确定谁的地址?“符号” 的地址。空间分配完成之后,每个段的地址确定了之后,那么每个段上面的符号,比如函数符号,我们就可以通过段的偏移量加上符号在段上的偏移量来算出这个符号的偏移量。从而确定符号的地址。这里应该需要一个全局的符号表,方便后面查找一个符号对应的地址。

第二步,符号解析与重定位。对象是谁?是 .text 上的指令。因为这些指令在编译的时候,无法确却的知道操作数的地址,所以可以设置一个假的地址,然后把这个信息存放到 .rel.text 符号表上面。后面可以通过 .rel.text 来对这些符号进行重定位。

其他

  • 有一个叫做 BFD 的库,给这么多的目标文件格式提供了统一的操作接口。
  • 静态链接库,实际上就是很多 .o 文件利用 ar 打包而成
  • COMMON 块机制,处理弱符号、强符号。可以暂且认为将所有的弱符号(可以重复)存储到 COMMON 块当中,如果有强符号,那么可以抛弃弱符号;如果有多个弱符号,那么选择最大的那个;未初始化的全局变量是一个弱符号,据说是因为经常忘记写 extern,于是会导致多个目标文件有同一个全局变量符号,如果这些符号都是强的,那么无法连接不了;于是让 “未初始化的全局变量” 变成弱符号吧,可以使用 -fno-common 来禁止。
  • 这一章通过控制链接过程,来尽可能降低可执行文件的大小。

第六章 可执行文件的装载和进程

装载是什么?最简单的装载就是将整个文件放到一块内存,然后就可以执行那里的代码了。

装载过程

装载实际上是由操作系统来完成的。

现代操作系统中,装载通过页映射来完成。分为三个步骤:进程的建立,虚拟地址空间的创建,其实就是建数据结构;读取可执行文件头,建立虚拟地址空间和文件的映射;将 CPU 指令寄存器设置到入口。之后开始执行,会发生页错误,然后利用中断机制将执行交给操作系统,操作系统帮你加载对应的页之后,返回到当前进程。

以 Linux 为例子:

地址空间划分

  • 地址空间的划分。从 0 到某个位置是进程的空间,某个位置到最后面是内核空间。

其他

  • PAE。物理地址扩展。可以使用多余 4G 的物理地址空间。不过一个进程还是无法使用多余 4G 虚拟空间,通过地址映射和使用更多的地址线,可以间接使用高于 4G 的物理地址。
  • 随机地址空间分布的技术,可以防止程序受到恶意攻击。
  • 6.4.4 中有讲到如何充分利用物理空间,没看懂。
  • 程序如何获取环境变量?环境变量在装载的时候分配了空间,所以本质上环境变量是存在了当前进程中的。仔细想想,这样做才是最正确的,不如它存储在哪里,存储了之后应该怎么拿呢?

第七章 动态链接

运行时链接成一个完整的程序。

希望共享指令可以在装载的时候不会因为装载地址的改变而改变,这样可以做到多个进程共享动态链接库。为此需要引入 PIC,地址无关代码。那么地址可以分为四种情况进行讨论:代码或数据,模块内部或外部。

  • 模块内部代码:相对地址寻址,然后跳转。
  • 模块内部数据:增加多一个函数调用,获取动态链接库的加载地址,然后加上偏移量。
  • 模块外部数据:使用 GOT 全局偏移表,存储每个变量的地址。这个 GOT 在模块的数据段。编译的时候可以确定 GOT 的偏移,于是我们可以计算一个使用了某个变量在 GOT 上的偏移量。
  • 模块外部代码:可以使用 GOT。

延迟绑定

延迟绑定用于减少启动时候链接的开销,只有用到了才绑定。当执行 bar@plt 这个函数时,第一次 jmp 是跳转到下一条指令 push,执行一个子程序来解析 bar 符号;之后的跳转都是直接跳到对应的地址。在 PLT 子函数中,会将 GOT 对应的地址 push 进去,所以后面执行都是直接跳转。

其他

  • .interp 段,保存了动态链接器的地址。
  • .dynsym 段,动态符号表
  • .symtab 段,符号表
  • 可以使用一些 API 加载动态链接库,实现类似插件的功能。dlopen, dlsym, dlerror, dlclose.

第八章 Linux 共享库的组织

SO-NAME:库命名规则,用来记录共享库的依赖关系,每个共享库都有一个对应的 SO-NAME。比如有 liba.so.2.6.2 和 liba.so.2.7.1 两个版本,那么我们可以将其命名为 liba.so.2,在使用这两个动态链接库的时候,我们可以直接使用 liba.so.2,从而避免依赖一个具体版本的库,可以使用某个大版本且 ABI 兼容的库。

基本版本的机制:编译的时候,可以用 ld --version-script 来设置版本。它也可以用来隐藏符号。对 SO-NAME 机制的补充。

第十章 内存

一个进程的内存布局。

  • 栈。讲到了如何传参、如何获取返回值、如何调用函数、如何记录返回地址。还分析了函数返回一个对象的问题,临时对象的产生和销毁,以及调用了复制构造函数。当然这个问题在 C++11 通过移动构造函数解决了。
  • 堆。这里主要是 malloc 的实现。

malloc

涉及的系统调用:brk,mmap。

brk:设置数据段的结束地址。
mmap:向操作系统申请一段虚拟地址空间。
malloc:初始的时候,先申请一块大内存。当用户申请小于 128k 内存,按照堆分配算法为它分配一块空间;申请大于 128k 内存,使用 mmap 申请一块空间。堆分配算法,就是空闲块管理算法,使用空闲链表或者位图的方式,对象池(按照固定的大小分配内存)。

第十一章 运行库

终于到了运行库了。一个 hello world 程序的真正入口,并不是 main,而是 “运行时” runtime 的入口。

第十二章 系统调用

原理

  • 特权级和中断。特权级分为用户态和内核态。中断分为硬件中断(比如键盘被按下,设备发出中断信号)和软件中断(一个汇编指令)。

  • Linux 使用 0x80 号中断作为系统调用的入口,同时使用 eax 存放要系统调用号。

  • 从用户态切换到内核态,需要切换堆栈。



这篇关于读书笔记:《程序员的自我修养》的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程