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

本文是《程序员的自我修养》的读书笔记之一,主要把相关知识点进行了梳理。

链接、装载等是操作系统中很基础也是很重要的一部分。我们需要掌握的东西很多,书中向我们灌输了理解linux里面的文件的ELF格式的重要性,我们从阅读《程序员的自我修养》前面的章节从学习ELF文件格式开始有助于更加了解日常编程看不到的东西:

  • 理解操作系统是如何让一段代码工作起来
  • 如何让不同的二进制模块协同工作
  • 理解整个系统的一个起点

本书最好是配合 《Linkers and Loaders》 一起深入阅读。

链接、装载、库是本书的三大核心点,看书的时候根据这三点的笔记整理如下:

《程序员的自我修养》的作者在我们阅读前,提出了几个问题让我们带着思考的动机去看的,我觉得比较有意思,我在这里也把这几个问题也列出一下:

我们先看一个简单的hello world程序

#include <stdio.h>

int main()
{
    printf("hello world\n");
    return 0;
}

越是简单的东西,其实背后往往隐藏了即为复杂的机制和步骤,所以作者继续卖关子,列举了一些值得我们思考的问题,以便他后面的知识点能更深刻地佐证,这些问题是:

  • 1、程序为什么要被编译器编译之后才能运行?

  • 2、最后编译器编译出来的可执行文件里面是什么?

  • 3、最后编译出来的可执行文件(ELF文件)里面是什么?除了机器代码还有什么?它们是怎么存放的?怎么组织的?目标文件是什么?链接又是什么?

  • 4、#include 是什么意思?c语言库又是什么?它是怎么实现的?

  • 5、不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、ARM)以及不同的操作系统(windows、linux、unix等)最终编译出来的结果是一样的吗?

  • 6、Hello world程序是这么运行起来的?操作系统是怎么装载它的?它从哪里开始执行到哪里结束?程序是从main开始执行的吗?main函数之前发生了什么?main函数后又发生了什么?

  • 7、如果没有操作系统, hello world程序可以运行吗?如果系统在一台没有操作系统的机器上运行helloworld需要什么?怎么实现?

  • 8、printf是怎么实现的?它问什么可以有不定数量的参数?为什么能够在终端上输出字符串?

  • 9、hello world程序运行时,它在内存中是怎么样子的?alloc分配的空间是连续的吗?

  • 10、为什么这段程序链接时报错?

  • 11、句柄到底是什么?

围绕这些问题,我们脑袋基本能形成一本清晰且连连相扣的“通关”手册,而本书的知识点就是针对上述的不同“关卡”的详细攻略。

第一部分:链接

首先阅读这些章节,我们目的是获取以下的知识点:

  • 编译和链接(基本概念和步骤)

  • 目标文件里面有什么(源代码编译后如何在目标文件中存储)

  • 静态链接(基本概念和步骤)

我们通常将编译和链接的过程称为“构建(build)”,举个例子:

$ gcc hello.c

$ ./a.out

这个命令包含了非常复杂的过程,我们下面就说明下这些隐藏了的过程:

build过程

程序构建的步骤主要包含:

  • 预处理 (prepressing)

  • 编译 (compilation)

  • 汇编 (assembly)

  • 链接 (linking)

预处理

预处理主要是处理那些源码文件中以 "#" 开头的预编译指令(#include 和 #define)

它的工作性质相当于命令:

gcc -E hello.c -o hello.i

.i 文件不包含任何的宏定义,因为所有的宏定义已经被展开,并且包含文件也被插入进来

预处理的过程主要是以下几个步骤:

  • 1、将所有的 #define 删除,并且展开所有的宏定义

  • 2、处理所有条件的预编译指令,例如 #if、#ifdef、#elif、#else、#endif等

  • 3、处理 #include 预编译指令 (递归进行)

  • 4、删除所有的 ///* */

  • 5、添加行号和文件标识,以便编译时编译器能产生调试的行号信息以及用于编译时产生编译错误或报告时也能显示行号

  • 6、保留所有的 #pragma编译指令

编译

编译的主要步骤:

  • 词法分析

  • 语法分析

  • 语义分析

  • 中间代码生成

  • 目标代码生成

编译是整个构建的核心,它的工作性质相当于执行命令:

gcc -S hello.i -o hello.s

或者

gcc -S hello.c -o hello.s

汇编

汇编主要将代码变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令

它的工作性质相当于执行命令:

as hello.s -o hello.o 

或者

gcc -c hello.s -o hello.o

链接

这里我们主要介绍的是静态链接

链接是《程序员的自我修养》的重点概念,学习链接,主要有以下的好处:

  • 理解链接器将帮助我们构造大型程序

  • 能避免一些危险的编程错误

  • 帮助我们理解语言的作用域规则是如何实现的

  • 理解其它重要的系统概念

  • 能够利用好共享库

背景:程序设计的模块化是人们一直追求的东西,比如我们程序main.c中使用了另外一个模块的func.c中的foo()函数,我们在main.c模块中每一处调用foo的时候都必须确切知道foo这个函数的地址,但是由于每个模块都是单独编译的,在编译器编译main.c的时候它并不知道foo函数的地址的,所以它暂时把这些调用foo的指令的目标地址搁置,等待最后链接的时候去将这些指令的目标地址修正,如果没有链接器,我们得手工把每个调用foo的指令进行修正,填入正确的函数地址。

于是链接的目的其实就是:人们把每个源代码模块独立地进行编译,然后按照需要将它们“组织(正确地衔接)” 起来。

静态链接的过程:

  • 地址和空间的分配

  • 符合解析(决议)

  • 重定位(在linking过程中,对其它定义在目标文件中的函数的指令重新调整)

链接的手段基本是通过源代码文件(.c)和库(大多是运行时库)和目标文件(.o)一起绑定为可执行文件

目标文件

针对目标文件,我们需要时刻记住几个关注点

  • 1、目标文件格式(或可执行文件格式)

  • 2、目标文件是怎么样的

  • 3、ELF文件结构描述

  • 4、链接的接口(符号)

  • 5、调试信息

文件格式

linux下,可以使用file命令查看相应的文件格式

目标文件格式的主要分为:可重定位文件、可执行文件、共享目标文件、核心转储文件(codedump)

目标文件是怎么样的

目标文件中的内容至少有编译后的机器指令代码、数据、符合表、调试信息、字符串等。目标文件将这些信息按不同的属性以节(section)段(segment)的形式存储。

程序代码编译后的机器指令经常放在代码段(.code或者.text)中,初始化后的全局变量或者局部变量放在数据段(.data)中。

段可以方便地映射到链接器在运行时可以直接载入的对象中!载入器只是提取文件中每个段的映像,并直接将它们放入内存中

elf文件主要格式如下:

elf

header描述了整个ELF文件属性,包括文件是否可以执行,是否静态链接还是动态入口地址,目标硬件,操作系统等信息。

header 还包含了一个段表(section header table)(用于描述文件中各个段(偏移位置和属性)的一个数组)

.bss 段主要存储未初始化的全局变量和局部静态变量

.bss段不占据空间

链接的接口(符号)

在链接中,我们将函数和变量都统称为符号(symbol)。每一个目标文件都会有一个符号表记录相关的符号名和符号值(地址)

弱符号和强符号

我们经常在编程中碰到一种情况叫符号重复定义。多个目标文件中包含相同名字的全局符号的定义,那么这些目标文件链接的时候将会出现符号重复定义的错误。

编译器一般把初始化的全局变量归为强符号,把未初始化的全局变量归为弱符号。

弱引用和强引用

目前我们所看到的对外部目标文件的符号引用在目标文件最终链接成可执行文件时,它们都需要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义的错误,这些情况,我们就说我们引用了强引用;与之不会报错的情况我们就说引用了弱引用。

这种弱符号和弱引用对库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的应用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能就可以正常使用;如果我们去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合。

linux中链接glibc的pthread就是一个典型的例子

静态的链接

静态链接,也叫 两步链接(Two-Pass Linking) 它的工作主要分两个步骤:

  • 空间与地址分配

  • 符号解析和重定位

空间与地址分配

这个步骤主要是扫描所有的输入文件,获得他们的各自的长度、属性和位置,并且将输入文件中的符号表所有的符号定义和符号引用收集起来!统一放到一个全局的符号表。这一步中通过按序叠加相似合并,链接器将能够获得所有输入目标文件的长度,并且将它们合并,计算出输出文件的各个段合并后的长度和位置并建立映射关系。

符号解析和重定位

使用上一步收集的所有信息,通过重定位表(ELF文件的一个段),读取输入文件中的数据、重定位信息并且讲符号进行解析与重定位吗,调整代码中的地址等。

重定位是链接的核心步骤

第二部分:装载与动态链接

可执行文件的装载与进程

程序(狭义上说的可执行文件,本质是ELF文件)只有装载(覆盖或页映射)到内存以后才能被CPU执行。程序是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件;进程则是一个动态概念,它是程序运行时的一个过程。

每个程序被运行后,它将拥有自己的虚拟地址空间,这个虚拟地址空间的大小由计算机的硬件平台决定。具体来说是由CPU的位数决定的。硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,比如32位的硬件平台决定了虚拟地址空间的地址是0~2^32-1。64位的硬件平台具备64位寻址能力,它的虚拟地址空间达到2^64字节。

C语言指针大小的位数与虚拟空间的位数相同

很多情况下,程序需要的内存数量大于物理内存的数量,于是根据程序运行局部性原理,可以把最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面。这些驻留的方法是动态的,一般分为“覆盖装入(overlay)”“页映射(paging)”

  • 覆盖装入:说白了就是程序员在编写程序的时候将程序分割若干块,然后编写一个小的辅助代码来管理模块“何时该驻留在内存”和“何时该被替换”

  • 页映射:跟覆盖装入不同,它利用了虚拟存储机制,不是一下子把程序的所有数据和指令都装入到内存里面,而是将内存和磁盘中的数据和指令按“页”为单位划分为若干页,以后所有装载和操作的单位就是页。

从操作系统的角度来看,一个进程最关键的特征是:“它是拥有独立的虚拟地址空间”。

进程的建立步骤大概分三步:

  • 创建一个虚拟地址空间(分配一个页目录)

  • 读取可执行文件的文件头,并且与虚拟地址空间建立映射关系(重要过程)

  • 将CPU的指令寄存器设置成可执行文件的入口,启动执行

当程序执行过程中发生页错误时,操作系统会从物理内存中分配一个物理页,然后将该“缺页”从磁盘读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。

操作系统并不关心可执行文件的各个段所包含的实际内存,它只关系一些跟装载相关的问题,最主要的是段权限(可读、可写、可执行)

ELF文件的权限往往只有为数不多的几种组合,基本是三种:

  • 以代码段为代表的权限可读可执行的段

  • 以数据段和BSS段为代码的可读可写的段

  • 以只读数据段为代表的只读的段

对应相同的段,操作系统把它们合并到一起当做一个段进行映射

动态链接

基本思路:把链接的过程推迟到了运行时再进行。

动态链接涉及到运行时的链接以及多个文件的装载,必需要有操作系统的支持。因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下会有一些微妙的变化。

下面是一个简单的动态链接例子:

SimpleDynamicLinking

我们分别需要几个源文件:“Program1.c”、 “Program2.c”、 “Lib.c”、 “Lib.h”

/* Program1.c */
#include "Lib.h"

int main()
{
    foobar(1);
    return 0;
}
/* Program2.c */
#include "Lib.h"

int main()
{
    foobar(2);
    return 0;
}
/* Lib.c */
#include "<stdio.h>"

void foobar(int i)
{
    printf("Printing from Lib.so %d\n".i);
}
/* Lib.h */
#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif

程序很简单,两个程序的主要模块 Program1.c 和 Program2.c 分别调用了Lib.c 里面的foobar()函数,传进去一个数字。

然后我们使用GCC将Lib.c编译成一个共享对象文件

$ gcc -fPIC -shared -o Lib.so Lib.c

这样我们得到了一个Lib.so文件,然后我们就可以分别编译Program1.c 和 Program2.c :

$ gcc -o Program1 Program1.c ./Lib.so
$ gcc -o Program1 Program2.c ./Lib.so

地址无关代码

动态共享对象有个典型问题是:共享对象的地址冲突问题

共享对象的地址大部分是绝对地址,而且动态链接的装载地址是从0x00000000开始的

为了能够使对象在任意地址进行装载,一般想到的手段是装载时重定位:动态链接模块被装载映射到虚拟空间后,指令部分是在多个进程间共享的,由于装载时重定位需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令重定位后对于每个进程来说是不同的。当然动态链接库中数据可修改部分对于不同的进程来说可有多个副本,所以它们可以采用装载时重定位的方式来解决。

装载时重定位既然是为了解决指令部分无法在多个进程之间共享的问题,那边它的基本思想其实很简单:把指令中那些需要修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分就可以在每个进程中用于一个副本。这就是“地址无关技术(PIC)”

动态链接的步骤:

  • 动态链接器的自举

  • 装载共享对象

  • 重定位和初始化

共享库系统路径

目前大多数包括Linux在内的开源操作系统都遵守一个叫做FHS(File Hierarchy Standary)的标准。FHS规定,一个系统主要有3个存放共享库的位置,它们分别是:

  • /lib
  • /usr/lib
  • /usr/local/lib

总体来看,/lib和/usr/lib是一些很常用的、成熟的,一般是系统本身所需要的库;而/usr/local/lib是非系统所需的第三方程序的共享库。

linux的动态链接的模块所依赖的模块路径保存在.dynamic段里面,由DT_NEED类型的项表示。动态连接器对于模块的查找有一定的规则:如果DT_NEED里面保存的是绝对路径,那么动态连接器就按照这个路径去查找;如果DT_NEED里面保存的是相对路径,那么动态链接器会在/lib、/usr/lib和由/etc/ld.so.conf里面配置文件指定的目录中查找共享库。

ld.so.conf 是一个文本配置文件,它可能包含其他的配置文件,这些配置文件中存放着目录信息。如果修改这个文件,都应该允许ldconfig这个程序,以便调整SO-NAME和/etc/ld.so.cache。

环境变量

  • LD_LIBRARY_PATH:使用这个变量可以临时改变某个应用程序的共享库查找路径,而不会影响系统中的其它应用程序

  • LD_PRELOAD:可以指定预先装载的一些共享库甚至或是目标文件

  • LD_DEBUG:这个变量可以打开动态链接器的调试功能

共享库的创建

命令格式:

gcc -shared -wl,-soname,my_soname -o library_name source_files libary_files

可以通过给编译器驱动器一个特殊的-W选项(表示传递这个选项到哪个阶段)向各个阶段传递选项信息。“W”后面跟一个字符(提示到哪个阶段)一个逗号,然后就是具体的选项。所以如果要从编译器驱动器向链接器传递给连接器,而不是预处理或编译器或汇编程序或其它编译阶段,下面这条命令

cc -Wl,-m main.c > main.linker.cpp

参考例子:

gcc -shared -fPIC -wl,-soname,libfoo.so.1 -o lib.so.1.0.0 libfoo1.c libfoo2.c -lbar1 -lbar2

共享库的安装

创建共享库以后我们必须将它安装在系统中,以便于各种程序都可以共享它。最简单的办法就是将它共享复制到某个标准的共享目录如:/lib、/usr/lib等,然后运行ldconfig即可。

不过上述的方法往往需要系统的root权限,如果没有无法运行ldconfig程序。,否则就要通过建立相应SO-NAME的软链接的方法,并告诉编译器和程序如何找到该共享库。

建立SO-NAME的办法也是使用ldconfig,只不过需要制定共享库所在目录

$ ldconfig -n shared_library_directory

在编译程序时,也需要制定共享库的位置,GCC提供了两个参数 “-L” 和 “-l”,分别用于制定共享库搜索目录和共享库的路径。

动态链接是一种“JIT(just in time)”链接,这意味着程序在运行时必须能够找到它们所需要的函数库。连接器通过把文件名或路径名植入可执行文件中来做到这一点。这意味着,函数库的路径不能随意移动。如果把程序连接到/usr/lib/libthread.so库,那么就不能把改函数库移动到其它的目录,除非在连接器中进行特别的说明。否则,当程序调用该函数库的函数时,就会在运行时导致失败,给出这样一条错误消息:

ld.so.1: main: fatal: libthread.so: can’t open file: errno = 2

有用的C语言工具:

用于检查源代码的工具

t1

用于检查可执行文件的工具

t2

帮助调试的工具

t3

性能优化辅助工具

t4