程序的一輩子:從源程序到進程的辛苦歷程

摘要:一個程序的一輩子,從源程序到進程的辛苦歷程!本文不深刻研究編譯原理、操做系統原理,主要聚焦於程序的加載和連接。html


1、前言

做爲計算機專業的人,最遺憾的就是在學習編譯原理的那個學期被別的老師拉去幹活了,而對一個程序怎麼就從源代碼變成了一個在內存裏活靈活現的進程,一直也心懷好奇。這種好奇驅使我要找個機會深刻了解一下,因此便有了本文,來督促本身深刻研究程序的一輩子。不過,本文沒有深刻研究編譯原理、操做系統原理,而是主要聚焦於程序的連接和加載。linux

學習的過程當中主要參考了三本書、一個視頻、一個音頻(文末有列出),三本書裏,最主要的仍是《程序員的自我修養 - 連接、裝載與庫》,裏面的代碼放到了個人github上,而且配有shell腳本和說明,運行後能夠實操理解到更多內容。git

南大袁春風老師的計算機原理講解對我幫助最大,視頻是最直接傳達知識的方式。另外,爲了方便本身的實驗,製做了一個ubuntu的環境,而且內置了代碼,方便實驗:阿里docker鏡像程序員

docker pull registry.cn-hangzhou.aliyuncs.com/piginzoo/learn:1.0github

2、概述

天天都有無數的程序被編譯、部署,不停地跑着,它們幹着千奇百怪的事情。如同這個光怪陸離的世界,是由每一個人、每一個個體組成的,若是咱們剖析每一個人,會發現他們其實都是同樣的結構,都是由細胞、組織組成,再深究即是基因了,DNA裏那一個個的「核苷酸基」決定了他們。算法

一樣,經過這個隱喻來認知計算機,咱們能夠知道,計算機的基因和本質就是馮諾依曼體系。啥是馮諾依曼體系呢?通俗地講,就是定義了整個硬件體系(CPU、外存、輸入輸出),以及執行的運行流程等等。但是,一個程序怎麼就與硬件親密無間地運行起來了呢?應該不少人都不瞭解,甚至包括許多計算機專業的同窗們。docker

本質上來講,這個過程其實就是「從代碼編譯,而後不一樣目標文件連接,最終加載到內存中,被操做系統管理起來的一個進程,可能還會動態地再去連接其餘的一些程序(如動態連接庫)的過程」。看起來彷佛很簡單,但其實每一個部分都隱藏着不少細節,好奇心很強的你必定想知道,到底計算機是怎麼作到的。shell

本文不打算討論硬件、進程、網絡等如此龐大的體系,只聚焦於探索程序的連接和加載這兩個主題。編程

3、基礎

探索以前須要交代一些基礎知識,否則沒法理解連接和加載。ubuntu

3.1 硬件基礎

3.1.1 CPU

程序的一輩子:從源程序到進程的辛苦歷程

CPU由一大堆寄存器、算數邏輯單元(就是作運算的)、控制器組成。每次經過PC(程序計數器,存着指令地址)寄存器去內存裏尋址可執行二進制代碼,而後加載到指令寄存器裏,若是涉及到地址的話,再去內存里加載數據,計算完後寫回到內存裏。每條指令都會放到指令寄存器(IR)中,等着CPU去取出來運行。

指令是從硬盤加載到內存裏,又從內存里加載到IR裏面的。指令運行過程當中須要一些數據,這又要求從內存裏取出一些數據放到通用寄存器中,而後交給ALU去運算,結果出來後又會放到寄存器或者內存中,周而復始。

每一步都是一個時鐘週期,如今的CPU一秒鐘能夠作1G次,是1000000000,幾十億次/秒。目前市場上的CPU主頻聽說到4GHz就到極限了,限於工藝,上不去了,因此慢慢轉爲多核,就是把幾個CPU封裝到一塊兒共享內部緩存。

3.1.2 主板

程序的一輩子:從源程序到進程的辛苦歷程

如圖,咱們常常據說的「北橋、南橋」是什麼?

北橋其實就是一個計算機結構,準確地說是一個芯片,它鏈接的都是高速設備,經過PCI總線,把cpu、內存、顯卡串在一塊兒;而南橋就要慢不少了,鏈接的都是鼠標、鍵盤、硬盤等這些「窮慢」親戚,它們之間用ISA總線串在一塊兒。

3.1.3 硬盤

硬盤硬件上是盤片、磁道、扇區這樣的一個結構,太複雜了,因此從頭至尾給這些扇區編個號,就是所謂的「LBA(Logical Block Address)」邏輯扇區的概念,方便尋址。

爲了隔離,每一個進程有一個本身的虛擬地址空間,而後想辦法給它映射到物理內存裏。若是內存不夠怎麼辦?就想到了再細分,就是分頁,分紅4k的一個小頁,經常使用的在內存裏,不經常使用的交換到磁盤上。這就要常常用到地址映射計算(從虛擬地址到物理地址),這個工做就是MMU(Memory Management Unit),爲了快都集成到CPU裏面了。

3.1.4 輸入輸出設備

還有不少外設負責輸入輸出,一旦被外界輸入或要輸出東西,就得去告訴CPU:「我有東西了,來取吧」;「我要輸出啦,來幫我輸出吧」。這些工做就要靠一個叫「中斷」的機制,能夠將「中斷」理解成一種消息機制,用於通知CPU來幫我幹活。不是每一個部分均可以直接騷擾CPU的,它們都要經過中斷控制器來集中騷擾CPU。

這些外設都有本身的buffer,這些buffer也得有地址,這個地址叫端口

程序的一輩子:從源程序到進程的辛苦歷程

還得給每一個設備編個號,這樣系統才能識別誰是誰。每次中斷,CPU一看,噢,原來是05,05是鍵盤啊;06,06是鼠標啊。這個號,叫中斷編號(IRQ)

每次都必需要騷擾CPU嗎?直接把數據從外設的buffer(端口)灌到內存裏,不用CPU參與,多好啊!對,這個作法就是DMA。每一個DMA設備也得編個號,這個編號就是DMA通道,這些號可不能衝突哦。

程序的一輩子:從源程序到進程的辛苦歷程

3.2 彙編基礎

對於彙編,我其實也忘光了,因此得補補彙編知識了,起碼要能讀懂一些基礎的彙編指令。

3.2.1 彙編語法

彙編分門派呢!」AT&T語法」 vs 「Intel語法」:GUN GCC使用傳統的AT&T語法,它在Unix-like操做系統上使用,而不是dos和windows系統上一般使用的Intel語法。

最多見的AT&T語法的指令:movl、%esp、%ebp。movl是一個最多見的彙編指令的名稱,百分號表示esp和ebp是寄存器。在AT&T語法中,有兩個參數的時候,始終先給出源source,而後再給出目標destination

AT&T語法:

<指令> [源] [目標]

3.2.2 寄存器

寄存器是存放各類給cpu計算用的地址、數據用的,能夠認爲是爲CPU計算準備數據用的。通常分爲8類:

種類 功能
累加寄存器 存儲執行運算的數據和運算後的數據。 就是放計算用的數,算以前,算完後的
標誌寄存器 存儲運算處理後的CPU的狀態。 通常溢出啊,或者JMP的時候看條件用的
程序計數器 存儲下一條指令所在內存的地址。 存着指令的地址,讀他才能找到代碼在哪,代碼尋址用的
基址寄存器 存儲數據內存的起始地址。 讀內存用的,不過只放起始地址,尋址用的
變址寄存器 存儲基址寄存器的相對地址。 讀內存用的,不過只放偏移地址,尋址用的
通用寄存器 存儲任意數據。 這個是聽任意數據用的,我怎麼以爲累加寄存器有點雞肋了,用它不就得了
指令寄存器 存儲指令。CPU內部使用,程序員沒法經過程序對該寄存器進行讀寫操做。 存執行指令用的
棧寄存器 存儲棧區域的起始地址。 尋址用的,永遠指着當前棧的棧頂地址(內存的)

命名上,x86通常是指32位;x86-64通常是指64位。32位寄存器,通常都是e開頭,如eax、ebx;64位寄存器約定以r開頭,如rax、rbx。

1)32位寄存器

32位CPU一共有8個寄存器。

程序的一輩子:從源程序到進程的辛苦歷程

詳細的介紹:

程序的一輩子:從源程序到進程的辛苦歷程

2)64位寄存器有:32個

程序的一輩子:從源程序到進程的辛苦歷程

二者的區別:

  • 64位有16個寄存器,32位只有8個。但32位前8個都有不一樣的命名,分別是e _ ,而64位前8個使用了r代替e,也就是r 。e開頭的寄存器命名依然能夠直接運用於相應寄存器的低32位。而剩下的寄存器名則是從r8 - r15,其低位分別用d,w,b指定長度。
  • 32位寄存器使用棧幀做爲傳遞參數的保存位置,而64位寄存器分別用rdi、rsi、rdx、rcx、r八、r9做爲第1-6個參數,rax做爲返回值。
  • 32位寄存器用ebp做爲棧幀指針,64位寄存器取消了這個設定,沒有棧幀的指針,rbp做爲通用寄存器使用。
  • 64位寄存器支持一些形式以PC相關的尋址,而32位只有在jmp的時候纔會用到這種尋址方式。

對了,寄存器可不是L一、L2 cache啊!Cache位於CPU與主內存間,分爲一級Cache (L1Cache)和二級Cache (L2Cache),L1 Cache集成在CPU內部,L2 Cache早期在主板上,如今也都集成在CPU內部了,常見的容量有256KB或512KB。寄存器不多的,拿64位的來講,也就是16個,64x16,也就是1024,1K。

總結:大體來講數據是經過內存-Cache-寄存器,Cache緩存是爲了彌補CPU與內存之間運算速度的差別而設置的部件。

3.2.3 尋址方式

接下來講說尋址,尋址就是告訴CPU去哪裏取指令、數據。好比movl %rax %rbx,這個涉及到尋址,尋址會尋「寄存器」、「內存」,能夠是暴力的直接尋址,也能夠是委婉的間接尋址。下面是各類尋址方式:

程序的一輩子:從源程序到進程的辛苦歷程

你可能會看到這種指令movl,movw,mov後面的l、w是什麼鬼?

程序的一輩子:從源程序到進程的辛苦歷程

就是一次搬運的數據數量。

3.2.4 經常使用的指令

最後說說指令自己,每一個CPU類型都有本身的指令集,就是告訴CPU幹啥,好比加、減、移動、調用函數等。下面是一些很是經常使用的指令:

程序的一輩子:從源程序到進程的辛苦歷程

參考:願意自虐的同窗,能夠下載【Intel官方的指令集手冊】仔細研讀。

3.3 一些工具和玩法

本文還會涉及到一些工具:

  • gcc:超級編譯工具,能夠作預編譯、編譯成彙編代碼、靜態連接、動態連接等,本質上是各類編譯過程工具的一個封裝器。
  • gdb:太強了,命令行的調試工具,簡直是上天入地的利器。
  • readelf:能夠把一個可執行文件、目標文件徹底展現出來,讓你觀瞧。
  • objdump:跟readelf功能差很少,不過貌似它依賴一個叫「bfd庫」的玩意兒,我也沒研究,另外,它有個readelf不具有的功能:反編譯。剩下的二者都差很少了。
  • ldd:這個小工具也很酷,可讓你看一個動態連接庫文件依賴於哪些其它的動態連接庫。
  • cat /proc/<PID>/maps:這個命令頗有趣,可讓你看到進程的內存分佈。

還有各類利器,本身去探索吧。

3.4 其餘

3.4.1 地址編碼

假若有個整形變量1234,16進制是0x000004d2,佔4個字節,起始地址是0x10000,終止地址是0x10003,那麼在外界看來,是它的地址是0x10000仍是0x10003呢?答案是0x10000。

那麼問題來了,這4個字節裏怎麼放這個數?高地址放高位,仍是低地址放高位?答案是,均可以!

大端方式:高位在低地址,如 IBM360/370,MIPS

程序的一輩子:從源程序到進程的辛苦歷程

小端方式:高位在高地址,如 Intel 80x86

程序的一輩子:從源程序到進程的辛苦歷程

4、編譯

因爲我沒學過編譯,對詞法分析、語法分析也不甚瞭解,找機會再深刻吧,這裏只是把大體知識梳理一下。

詞法分析->語法分析->語義分析->中間代碼生成->目標代碼生成

4.1 詞法分析

經過FSM(有限狀態機)模型,就是按照語法定義好的樣子,挨個掃描源代碼,把其中的每一個單詞和符號作個歸類,好比是關鍵字、標識符、字符串仍是數字的值等,而後分門別類地放到各個表中(符號表、文字表)。若是不符合語法規則,在詞法分析過程當中就會給出各種警告,我們在編譯過程當中看到的不少語法錯誤就是它乾的。有個開源的lex的程序,能夠體會這個過程。

4.2 語法分析

由詞法分析的符號表,要造成一個抽象語法樹,方法是「上下文無關語法(CFG)」。這過程就是把程序表示成一棵樹,葉子節點就是符號和數字,自上而下組合成語句,也就是表達式,層層遞歸,從而造成整個程序的語法樹。同上面的詞法分析同樣,也有個開源項目能夠幫你作這個樹的構建,就是yacc(Yet Another Compiler Compiler)。

4.3 語義分析

這個步驟,我理解要比語法分析工做量小一些,主要就是作一些類型匹配、類型轉換的工做,而後把這些信息更新到語法樹上。

4.4. 中間語言生成

把抽象語法樹轉成一條條順序的中間代碼,這種中間代碼每每採用三地址碼或者P-Code的格式,形如x = y op z。長成這個樣子:

t1 = 2 + 6
array[index] = t1

不過這些代碼是和硬件不相關的,仍是「抽象」代碼。

4.5 目標代碼生成

目標代碼生成就是把中間代碼轉換成目標機器代碼,這就須要和真正的硬件以及操做系統打交道了,要按照目標CPU和操做系統把中間代碼翻譯成符合目標硬件和操做系統的彙編指令,並且,還要給變量們分配寄存器、規定長度,最後獲得了一堆彙編指令。

對於整形、浮點、字符串,均可以翻譯成把幾個bytes的數據初始化到某某寄存器中,可是對於數組等其它的大的數據結構,就要涉及到爲它們分配空間了,這樣才能夠肯定數組中某個index的地址。不過,這事兒編譯不作,留給連接去作。

編譯不是本文重點,這裏就不過多討論了,感興趣的同窗,能夠讀讀這篇:《本身動手寫編譯器》

5、連接

編譯一個c源文件代碼,就會對應獲得一個目標文件。一個項目中會有一堆的c源代碼,編譯後會獲得一堆的目標文件。這些目標文件是二進制的,就是一堆0、1的集合,到底這一堆0、1是如何排布的呢?接下來,咱們得說一說,這些0、1組成的目標文件了。

5.1 目標文件

目標文件是沒有連接的文件(一個目標文件可能會依賴其它目標文件,把它們「串」起來的過程,就是連接)。這些目標文件已經和這臺電腦的硬件及操做系統相關了,好比寄存器、數據長度,可是,對應的變量的地址沒有肯定。

目標文件裏有數據、機器指令代碼、符號表(符號表就是源碼裏那些函數名、變量名和代碼的對應關係,後面會細講)和一些調試信息。

目標代碼的結構依據COFF(Common File Format)規範。Windows和Linux的可執行文件(PE和ELF)就是尊崇這種規範。你們用的都是COFF格式,動態連接庫也是。經過linux下的file命令能夠參看目標文件、elf可執行文件、shell文件等。

file /lib/x86_64-linux-gnu/libc-2.27.so
      /lib/x86_64-linux-gnu/libc-2.27.so: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/l, BuildID[sha1]=b417c0ba7cc5cf06d1d1bed6652cedb9253c60d0, for GNU/Linux 3.2.0, stripped

      file run.sh
      run.sh: Bourne-Again shell script, UTF-8 Unicode text executable

      file a.o
      a.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

      file ab
      ab: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

如上能夠看到不一樣文件的區別。

5.2 目標文件的結構

ELF是Executable LinkableFormat的縮寫,是Linux的連接、可執行、共享庫的格式標準,尊從COFF。

Linux下的目標ELF文件(或可執行ELF文件)的結構包括:

  • ELF頭部
  • .text
  • .data
  • .bss
  • 其餘段
  • 段表
  • 符號表

ELF文件的結構包含ELF的頭部說明和各類「段」(section)。段是一個邏輯單元,包含各類各樣的信息,好比代碼(.text)、數據(.data)、符號等。

5.2.1 文件頭(ELF Header)

先說說ELF文件開頭部分的ELF頭,它是一個總的ELF的說明,裏面包含是否可執行、目標硬件、操做系統等信息,還包含一個重要的東西:「段表」,就是用來記錄段(section)的信息。

看個例子:

ELF Header:
        Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
        Class:                             ELF64
        Data:                              2's complement, little endian
        Version:                           1 (current)
        OS/ABI:                            UNIX - System V
        ABI Version:                       0
        Type:                              REL (Relocatable file)
        Machine:                           Advanced Micro Devices X86-64
        Version:                           0x1
        Entry point address:               0x0
        Start of program headers:          0 (bytes into file)
        Start of section headers:          816 (bytes into file)
        Flags:                             0x0
        Size of this header:               64 (bytes)
        Size of program headers:           0 (bytes)
        Number of program headers:         0
        Size of section headers:           64 (bytes)
        Number of section headers:         12
        Section header string table index: 11

說明:

  • 其中,」7f 45 4c 46」是ELF魔法數,就是DEL字符加上「ELF」3個字母,代表它是一個elf目標或者可執行文件關於elf文件頭格式。
  • 還會說明諸如可執行代碼起始的入口地址;段表的位置;程序表的位置;….多種信息。細節就不贅述了。

關於更詳細的elf文件頭的內容,能夠參考:

5.2.2 段表(section table)

除了elf文件頭,就屬段表重要了,各個段的信息都在這裏。先看個例子:

命令readelf -S ab能夠幫助查看ELF文件的段表。

There are 9 section headers, starting at offset 0x1208:

      Section Headers:
        [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
        [ 0]                   NULL            00000000 000000 000000 00      0   0  0
        [ 1] .text             PROGBITS        08048094 000094 000091 00  AX  0   0  1
        [ 2] .eh_frame         PROGBITS        08048128 000128 000080 00   A  0   0  4
        [ 3] .got.plt          PROGBITS        0804a000 001000 00000c 04  WA  0   0  4
        [ 4] .data             PROGBITS        0804a00c 00100c 000008 00  WA  0   0  4
        [ 5] .comment          PROGBITS        00000000 001014 00002b 01  MS  0   0  1
        [ 6] .symtab           SYMTAB          00000000 001040 000120 10      7  10  4
        [ 7] .strtab           STRTAB          00000000 001160 000063 00      0   0  1
        [ 8] .shstrtab         STRTAB          00000000 0011c3 000043 00      0   0  1
      Key to Flags:
        W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
        L (link order), O (extra OS processing required), G (group), T (TLS),
        C (compressed), x (unknown), o (OS specific), E (exclude),
        p (processor specific)

這個可執行文件裏有9個段。常見的3個段:代碼段、數據段、BSS段:

  • 代碼段:.code或.text;
  • 數據段:.data,放全局變量和局部靜態變量;
  • BSS段:.bss,爲未初始化的全局變量和局部靜態變量預留位置,不佔空間。

還有其它段:

  • .strtab : String Table 字符串表,用於存儲 ELF 文件中用到的各類字符串;
  • .symtab : Symbol Table 符號表,從這裏能夠索引文件中的各個符號;
  • .shstrtab : 各個段的名稱表,其實是由各個段的名字組成的一個字符串數組;
  • .hash : 符號哈希表;
  • .line : 調試時的行號表,即源代碼行號與編譯後指令的對應表;
  • .dynamic : 動態連接信息;
  • .debug : 調試信息;
  • .comment : 存放編譯器版本信息,好比 「GCC:GNU4.2.0」;
  • .plt 和 .got : 動態連接的跳轉表和全局入口表;
  • .init 和 .fini : 程序初始化和終結代碼段;
  • .rodata1 : Read Only Data,只讀數據段,存放字符串常量,全局 const 變量,該段和 .rodata 同樣。

段表裏記錄着每一個段開始的位置和位移(offset)、長度,畢竟這些段都是緊密的放在二進制文件中,須要段表的描述信息才能把它們每一個段分割開。

有了段,咱們其實就對可執行文件瞭然於心了,其中.text代碼段裏放着能夠運行的機器指令;而.data數據段裏放着全局變量的初始值;.symtab裏放着當初源代碼中的函數名、變量名的表明的信息。

目標ELF文件和可執行ELF文件雖然規範是一致的,但仍是有不少細微區別。

5.2.3 目標ELF文件的重定位表

在段表中,你會發現這種段:.rel.xxx,這些段就是連接用的!由於你須要把某個目標中出現的函數、變量等的地址,換成其它目標文件中的位置(也就是地址),這樣才能正確地引用、調用這些變量。至於連接細節,後面講連接的時候再說。

通常有text、data兩種重定位表:

  • .rel.text:代碼段重定位表,描述代碼段中出現的函數、變量的引用地址信息等;
  • .rel.data: 數據段重定位表。

5.2.4 字符串表

.strtab、.shstrtab

ELF中不少字符串,好比函數名字、變量名字,都放到一個叫「字符串」表的段中。

5.2.5 符號表

注意:字符串表只是字符串,符號表跟它不同,符號表更重要,它表示了各個函數、變量的名字對應的代碼或者內存地址,在連接的時候,很是有用。由於連接就是要找各個變量和函數的位置,這樣才能夠更新編譯階段空出來的函數、變量的引用地址。

每一個目標文件裏都有這麼一個符號表,用nm和readelf能夠查看:

1)a.o目標文件的符號表

nm a.o

U _GLOBAL_OFFSET_TABLE_
                 U __stack_chk_fail
0000000000000000 T main
                 U shared
                 U swap

2)readelf -s a.o 目標文件的符號表:

Symbol table '.symtab' contains 12 entries:
         Num:    Value  Size Type    Bind   Vis      Ndx Name
           0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND
           1: 00000000     0 FILE    LOCAL  DEFAULT  ABS a.c
           2: 00000000     0 SECTION LOCAL  DEFAULT    1
           3: 00000000     0 SECTION LOCAL  DEFAULT    3
           4: 00000000     0 SECTION LOCAL  DEFAULT    4
           5: 00000000     0 SECTION LOCAL  DEFAULT    6
           6: 00000000     0 SECTION LOCAL  DEFAULT    7
           7: 00000000     0 SECTION LOCAL  DEFAULT    5
           8: 00000000    85 FUNC    GLOBAL DEFAULT    1 main
           9: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND shared
          10: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND swap
          11: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND __stack_chk_fail

從這個目標ELF文件的符號表能夠看到swap函數,Ndx是UND(Undefined的縮寫),代表不知道它到底在哪一個段,須要被重定位,就是寫個1或3之類的數字代表段中的index;對於全局變量shared也是一樣的定義。這些內容都會在靜態連接的時候,被連接器修改。

爲了對比,咱們來看可執行文件ab的符號表的樣子,看看靜態連接後,這些符號的Ndx的變換。

3)可執行文件ab的符號表

nm ab

0804a000 d _GLOBAL_OFFSET_TABLE_
      0804a014 D __bss_start
      080480d7 T __x86.get_pc_thunk.ax
      0804a014 D _edata
      0804a014 D _end
      080480db T main
      0804a00c D shared
      08048094 T swap
      0804a010 D test

readelf -s ab

Symbol table '.symtab' contains 18 entries:
         Num:    Value  Size Type    Bind   Vis      Ndx Name
           0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND
           1: 08048094     0 SECTION LOCAL  DEFAULT    1
           2: 08048128     0 SECTION LOCAL  DEFAULT    2
           3: 0804a000     0 SECTION LOCAL  DEFAULT    3
           4: 0804a00c     0 SECTION LOCAL  DEFAULT    4
           5: 00000000     0 SECTION LOCAL  DEFAULT    5
           6: 00000000     0 FILE    LOCAL  DEFAULT  ABS b.c
           7: 00000000     0 FILE    LOCAL  DEFAULT  ABS a.c
           8: 00000000     0 FILE    LOCAL  DEFAULT  ABS
           9: 0804a000     0 OBJECT  LOCAL  DEFAULT    3 _GLOBAL_OFFSET_TABLE_
          10: 08048094    67 FUNC    GLOBAL DEFAULT    1 swap
          11: 080480d7     0 FUNC    GLOBAL HIDDEN     1 __x86.get_pc_thunk.ax
          12: 0804a010     4 OBJECT  GLOBAL DEFAULT    4 test
          13: 0804a00c     4 OBJECT  GLOBAL DEFAULT    4 shared
          14: 0804a014     0 NOTYPE  GLOBAL DEFAULT    4 __bss_start
          15: 080480db    74 FUNC    GLOBAL DEFAULT    1 main
          16: 0804a014     0 NOTYPE  GLOBAL DEFAULT    4 _edata
          17: 0804a014     0 NOTYPE  GLOBAL DEFAULT    4 _end

能夠看到,如今shared的Ndx是4,而swap的Ndx是1,對應的就是:4-數據段、1-代碼段。

上面曾經顯示過的段的編號
      。。。。
        [ 1] .text             PROGBITS        08048094 000094 000091 00  AX  0   0  1
        [ 2] .eh_frame         PROGBITS        08048128 000128 000080 00   A  0   0  4
        [ 3] .got.plt          PROGBITS        0804a000 001000 00000c 04  WA  0   0  4
        [ 4] .data             PROGBITS        0804a00c 00100c 000008 00  WA  0   0  4
        [ 5] .comment          PROGBITS        00000000 001014 00002b 01  MS  0   0  1
      。。。

如上,對應的第一列的序號就標明瞭代碼段是1,數據段是4。

另外,第二列Type也挺有用的:Object表示數據的符號,而Func是函數符號。

6、靜態連接

目標文件介紹得差很少了,咱們獲得了一大堆零散的目標ELF文件,是時候把它們「合體」了,這就須要連接過程了,就是要把這些目標文件「湊」到一塊兒,也就是把各個段合併到一塊兒。

程序的一輩子:從源程序到進程的辛苦歷程

合併開始!讀每一個目標文件的文件頭,得到各個段的信息,而後作符號重定位。

  • 讀每一個目標文件,收集各個段的信息,而後合併到一塊兒,其實我理解就是壓縮到一塊兒,你的代碼段挨着個人代碼段,合併成一個新的,由於每一個ELF目標文件都有文件頭,是能夠很嚴格合併到一塊兒的;
  • 符號重定位,簡單來講就是把以前調用某個函數的地址給從新調整一下,或者某個變量在data段中的地址從新調整一下。由於合併的時候,各個代碼段都合併了,對應代碼中的地址都變了,因此要調整。這是連接最核心的一步!

ld a.o b.o ab

詳細介紹a.o+b.o=> ab的變化,特別是虛擬地址的變化。

先看連接前的目標ELF文件:a.o,b.o。

a.o的段屬性(objdump -h a.o)
------------------------------------------------------------------------
      Idx Name          Size      VMA               LMA               File off  Algn
        0 .text         00000051  0000000000000000  0000000000000000  00000040  2**0
                        CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
        1 .data         00000000  0000000000000000  0000000000000000  00000091  2**0
                        CONTENTS, ALLOC, LOAD, DATA
        2 .bss          00000000  0000000000000000  0000000000000000  00000091  2**0
                        ALLOC

b.o的段屬性(objdump -h b.o)
------------------------------------------------------------------------
      Idx Name          Size      VMA               LMA               File off  Algn
        0 .text         0000004b  0000000000000000  0000000000000000  00000040  2**0
                        CONTENTS, ALLOC, LOAD, READONLY, CODE
        1 .data         00000008  0000000000000000  0000000000000000  0000008c  2**2
                        CONTENTS, ALLOC, LOAD, DATA
        2 .bss          00000000  0000000000000000  0000000000000000  00000094  2**0
                        ALLOC

接下來是a.o + b.o,連接合體後的可執行ELF文件:ab。

ab的段屬性(objdump -h ab)
------------------------------------------------------------------------
      Idx Name          Size      VMA       LMA       File off  Algn
        0 .text         00000091  08048094  08048094  00000094  2**0
                        CONTENTS, ALLOC, LOAD, READONLY, CODE
        1 .eh_frame     00000080  08048128  08048128  00000128  2**2
                        CONTENTS, ALLOC, LOAD, READONLY, DATA
        2 .got.plt      0000000c  0804a000  0804a000  00001000  2**2
                        CONTENTS, ALLOC, LOAD, DATA
        3 .data         00000008  0804a00c  0804a00c  0000100c  2**2
                        CONTENTS, ALLOC, LOAD, DATA

咱們來玩一玩「找不一樣」!可執行ELF文件ab的VMA填充了。VMA是啥?爲什麼須要調整?看來是時候說一說可執行ELF文件了。

6.1 目標ELF文件和可執行ELF文件

上面一直刻意不區分目標ELF文件和可執行ELF文件,緣由是想先介紹它們共同的ELF規範部分,但其實二者是有區別的,這一小節忍不住想介紹一下,但願不會打斷看官的思路。

目標ELF文件和可執行ELF文件,實際上是兩個目的、兩個視角:

程序的一輩子:從源程序到進程的辛苦歷程

  • 目標文件是爲了進一步連接用的,咱們能夠用「連接視角」來看待它,它有各個sections,用段表section head table(SHT)來記錄、歸檔不一樣的內容,還有重要的重定位表,用於連接;
  • 可執行文件是爲「進程視角」存在的,不須要重定位表,但它多了一個 「program header table(PHT)」,用來告訴操做系統如何把各個section加到進程空間的segment中。進程裏專門有個「segment」的概念,定義出「虛擬內存區域」(VMA,Virtual Memory Area),每一個VMA就是一個segement。這些segment是操做系統爲了裝載須要,專門又對sections們作了一次合併,定義出不一樣用途的VMA(如代碼VMA、數據VMA、堆VMA、棧VMA)。
  • 在目標文件中,你會看到地址都是從0開始的,可是在可執行文件中是0x8048000開始的,由於操做系統進程虛擬地址的開始地址就是這個數。關於虛擬地址空間,這裏不展開了,後面講裝載的部分再詳細討論。

雖然二者有區別,但大致的規範是同樣的,都有ELF頭、段表(section table)、節(section)等基本的組成部分。

能夠參考這篇文章《ELF可執行文件的理解》,加深理解。

6.2 合體的ELF可執行文件

回來看合體(連接)後的可執行ELF文件ab。

ab的段屬性(objdump -h ab):

Idx Name          Size      VMA       LMA       File off  Algn
        0 .text         00000091  08048094  08048094  00000094  2**0
                        CONTENTS, ALLOC, LOAD, READONLY, CODE
        1 .eh_frame     00000080  08048128  08048128  00000128  2**2
                        CONTENTS, ALLOC, LOAD, READONLY, DATA
        2 .got.plt      0000000c  0804a000  0804a000  00001000  2**2
                        CONTENTS, ALLOC, LOAD, DATA
        3 .data         00000008  0804a00c  0804a00c  0000100c  2**2
                        CONTENTS, ALLOC, LOAD, DATA

能夠看到,ab的代碼段.text是從0x8048094開始的,長度是0x91,也就是145個字節長度的代碼段。

段的開頭地址肯定了,接下來段裏符號對應的地址就好找了(也就是.text段中的函數和.data段中的變量)。

回過頭去看幾個符號:swap函數、main函數、test變量、shared變量:

Num:    Value     Size Type    Bind   Vis      Ndx Name
          10:   08048094    67 FUNC    GLOBAL DEFAULT    1 swap
          12:   0804a010     4 OBJECT  GLOBAL DEFAULT    4 test
          13:   0804a00c     4 OBJECT  GLOBAL DEFAULT    4 shared
          15:   080480db    74 FUNC    GLOBAL DEFAULT    1 main
  • main函數:地址是080480db,Ndx=1,Type=FUNC,也就是說,main這個符號對應的是一個函數,在代碼段.text,起始地址是080480db;
  • test變量:地址是0804a010,Ndx=4,Type=OBJECT,也就是說,test這個符號對應的是一個變量,在數據段,起始地址是0804a010。

問題來了,這些地址是如何肯定的呢?要知道目標ELF文件a.o、b.o裏的地址還都是0做爲基地址的,到合體後的可執行文件ab怎麼就填充了這些東西呢?這就要引出「符號重定位」了。

6.3 符號重定位

既然連接是把你們的代碼段、數據段都合併到一塊兒,那就須要修改對應的調用的地址,好比a.o要調用b.o中的函數,合併到一塊兒成爲ab的時候,就須要修改以前a.o中的調用的地址爲一個新的ab中的地址,也就是以前b.o中的那個函數swap的地址。

連接器經過「重定位 + 符號解析」完成上述工做。

最開始編譯完的目標文件,變量地址、函數地址的基準地址都是0。一旦連接,就不能從0開始了,而要從操做系統和應用進程規定的虛擬起始地址開始做爲基準地址,這個規定是0x08048094。別問我爲何,真心不知~

另外,還有這幾個目標文件的各個段,它們的函數、變量等的地址本來都是基於0,如今合體了,都要開始逐一調整!以前每一個函數、變量的地址都是相對於0的,也就是說,你知道它們的偏移offset,這樣的話,你只須要告訴它們新的基地址的調整值,就能夠加上以前的offset算出新的地址,把全部涉及到被調用的地方都改一遍,就完成了這個重定位的過程。

具體怎麼作呢?經過重定位表來完成。

6.4 重定位表

就是一個表,記着以前每一個object目標文件中哪些函數、變量須要被重定位。這是一個單獨的段,命名還有規律呢!就是.rel.xxx,好比.rel.data、.rel.text。

看個栗子:

RELOCATION RECORDS FOR [.text]:
      OFFSET           TYPE              VALUE
      0000000000000025 R_X86_64_PC32     shared-0x0000000000000004
      0000000000000032 R_X86_64_PLT32    swap-0x0000000000000004

shared變量和swap函數都在a.o的重定位表中被記錄下來,說明它們的地址後期會被調整。offset中的25,就是shared變量對於數據段的起始位置的位移offset是25個字節;一樣,swap函數相對於代碼段開始的offset是32個字節。另外,VALUE這列的「shared、swap」會對應到符號表裏面的shared、swap符號。

重定位表只記錄哪些符號須要重定位,而關於這個函數、變量更詳細的信息都在符號表中。

接下來精彩的事情發生了,也就是連接中最關鍵的一步:修改連接完成的文件中調用函數和變量引用的地址。

6.5 指令修改

修改函數和數據的應用地址有不少方法,這涉及到各個平臺的尋址指令差別,好比R_X86_64_PC32。但本質來說就須要一種計算方法,計算出連接後的代碼中對函數的調用地址、變量的應用地址、進行連接後的修改地址。

對於32位的程序來講,一共有10種重定位的類型。

舉個例子可能更容易理解:文件a.c,b.c,連接成ab,咱們來看連接過程當中是如何作指令地址修改的。

先看看源代碼:

a.c

extern int shared;

      int main()
      {
          int a = 0;
          swap(&a, &shared);
      }

b.c

int shared = 1;
      int test = 3;

      void swap(int* a, int* b) {
          *a ^= *b ^= *a ^= *b;
      }

a.c的彙編文件

00000000 <main>:
  ....
  31: 89 c3                 mov    %eax,%ebx
  33: e8 fc ff ff ff        call   34 <main+0x34> <------------- 調用swap函數
  38: 83 c4 10              add    $0x10,%esp
  ....
Relocation section '.rel.text' at offset 0x24c contains 4 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
 ....
00000034  00000e04 R_386_PLT32       00000000   swap

能夠看到目標文件a.o中的彙編指令和重定位表中爲R_386_PLT32的重定位方式。而後,連接後獲得ab的代碼。

連接後的 ab ELF可執行文件:

08048094 <swap>:
 8048094: 55                    push   %ebp
 8048095: 89 e5                 mov    %esp,%ebp
 ....

080480db <main>:
 ....
 804810c: 89 c3                 mov    %eax,%ebx
 804810e: e8 81 ff ff ff        call   8048094 <swap>
 8048113: 83 c4 10              add    $0x10,%esp 
 ....

分析

1)修正後的swap地址是:0x08048094

2)修正後的代碼地址是: 0x804810e

3)原來的調用代碼: 33: e8 fc ff ff ff call 34 &lt;main+0x34&gt;,實際上是0xfffffffc,補碼錶示的-4

4)先看修改完成的:ab中,804810e: e8 81 ff ff ff call 8048094 &lt;swap&gt;。e8 fc ff ff ff 修改爲了=> e8 81 ff ff ff,補碼錶示是-127

5)這個值是怎麼算的?

a.o的重定位表中的信息是:00000034 00000e04 R_386_PLT32 00000000 swap

所謂R_386_PLT32,是:L+A-P

  • L:重定項中VALUE成員所指符號@plt的內存地址 => 8048094,就是修正後的swap函數地址;
  • A:被重定位處原值,表示」被重定位處」相對於」下一條指令」的偏移 => fcffffff,就是源代碼上的地址,固定的,補碼錶示的,實際值是-4;
  • P:被重定位處的內存地址 => 804810e,就是修正後的main中調用swap的代碼地址。

按照這個公式計算修正後的調用地址:

L+A-P:8048094 + −4 - 804810e = - 127 = -0x7f,補碼錶示是 ffffff81,因爲是小端表示,因此最終替換完的指令爲:

804810e: e8 81 ff ff ff call 8048094 &lt;swap&gt;

代碼在執行的時候,會用當前地址的下一條指令的地址,加上偏移(-127),正好就是swap修正後的地址0x08048094。

6.6 靜態連接庫

咱們本身寫的程序能夠編譯成目標代碼,而後等着連接。可是,咱們可能會用到別的庫,它們也是一個個的xxx.o文件麼?連接的時候須要挨個都把它們指定連接進來麼?

咱們可能會用到c語言的核心庫、操做系統提供的各類api的庫,以及不少第三方的庫。好比c的核心庫,比較有名的是glibc,原始的glibc源代碼不少,能夠完成各類功能,如輸入輸出、日期、文件等等,它們其實就是一個個的xxx.o,如fread.o,time.o,printf.o,就是你想象的樣子。

但是,它們被壓縮到了一個大的zip文件裏,叫libc.a:./usr/lib/x86_64-linux-gnu/libc.a,就是個大zip包,把各類*.o都壓縮進去了,聽說libc.a包含了1400多個目標文件。

objdump -t ./usr/lib/x86_64-linux-gnu/libc.a|more
      In archive ./usr/lib/x86_64-linux-gnu/libc.a:

      init-first.o:     file format elf64-x86-64

      SYMBOL TABLE:
      0000000000000000 l    d  .text  0000000000000000 .text
      0000000000000000 l    d  .data  0000000000000000 .data
      0000000000000000 l    d  .bss 0000000000000000 .bss
      .......

我好奇地統計了一下,其實不止1400,個人這臺ubuntu18.04上,有1690個!

objdump -t ./usr/lib/x86_64-linux-gnu/libc.a|grep 'file format'|wc -l
      1690

若是以–verbose方式運行編譯命令,你能看到整個細節過程:

gcc -static --verbose -fno-builtin a.c b.c -o ab

....
        /usr/lib/gcc/x86_64-linux-gnu/7/cc1 -quiet -v -imultiarch x86_64-linux-gnu b.c -quiet -dumpbase b.c -mtune=generic -march=x86-64 -auxbase b -version -fno-builtin -fstack-protector-strong -Wformat -Wformat-security -o /tmp/cciXoNcB.s
       ....
       as -v --64 -o /tmp/ccMLSHnt.o /tmp/cciXoNcB.s
       .....
        /usr/lib/gcc/x86_64-linux-gnu/7/collect2 -o ab /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginT.o ...

整個過程分爲3步:

  • cc1作編譯:編譯成臨時的彙編程序/tmp/cciXoNcB.s
  • as彙編器:生成目標二進制代碼;
  • collect2:其實是一個ld的包裝器,完成最後的連接。

還會連接各種的靜態庫,其實它們都在libc.a這類靜態庫中。

7、裝載

終於把一個程序編譯、連接完,變成了一個可執行文件,接下來就要聊聊如何把它加載到內存,這就是「裝載」的過程。

7.1 虛擬地址空間

在談加載到內存以前,先了解進程虛擬地址空間。

進程虛擬地址空間,在我看來是一個很是重要的概念,它的意義在於,讓每一個程序,甚至後面的進程,都變得獨立起來,不須要考慮物理內存、硬盤、在文件中的絕對位置等。它關心的只是本身在一個虛擬空間的地址位置。這樣連接器就好安排每一個代碼、數據的位置,裝載器也好安排指令、數據、棧、堆的位置,與硬件無關。

這個地址編碼也很簡單,就是你總線多大,我就能編碼多大。好比8位總線,地址就256個;到了32位,地址就能夠是4G大小了;64位的話,地址就很大了...這麼大的一個地址空間都給一個程序和進程用了!但是,真實內存可能也就16G、32G,還有那麼多進程怎麼辦?怎麼裝載進來?別急,後面會介紹。

7.2 如何載入內存

一個可執行文件地址空間碩大無比,怎麼把這頭大象裝入只有16G大小的「冰箱」—-內存?!答案是映射。

程序的一輩子:從源程序到進程的辛苦歷程

這樣就能夠把可執行文件中一塊一塊地裝進內存裏面了,前提是進程須要的塊,好比正在或立刻要執行的代碼、數據等。那剩下的怎麼辦?若是內存滿了怎麼辦?這些不用擔憂,操做系統負責調度,會判斷是否用到,用到的就會加載;若是滿了,就按照LRU算法替換舊的。

7.3 進程視角

切換到進程視角,進程也要有一個虛擬空間,叫「進程虛擬空間(Process Virtual Space)」。注意:咱們又提到了虛擬空間,前面聊起過這個話題,連接器須要、進程加載也須要,連接的時候要給每段代碼、數據編個地址,如今進程也須要一個虛擬地址。個人學習認知告訴我這倆不是一回事,但應該差不了多少,都是總線位數編碼出來的空間大小,各個內容存放的位置也不會有太大變換。

但畢竟是不同的,因此它們之間也須要映射。有了這個映射,進程發現本身所須要的可執行代碼缺了,才能知道到可執行文件中的第幾行加載。這個映射關係就存在可執行ELF的PHT(程序映射表 - Program Header Table)中,前面介紹過,就是個映射表。

咱們再將PHT映射表細化一下。

若是能直接把可執行文件原封不動地映射到進程空間多好啊,這樣映射多簡單啊。事實不是這樣的。

爲了空間佈局上的效率,連接器會把不少段(section)合併,規整成可執行的段(segment)、可讀寫的段、只讀段等,合併後,空間利用率就高了。不然,即使是很小的一段,將來物理內存頁浪費太大(物理內存頁分配通常都是整數倍一塊給你,好比4k)。因此連接器趁着連接就把小塊們都合併了,這個合併信息就在可執行文件頭的VMA信息裏。

這裏有2個段:section和segment,中文都叫段,但有很大區別:section是目標文件中的單元;而segement是可執行文件中的概念,是一個section的組合或集合,是爲了未來加載到進程空間裏用的。在我理解,segement和VMA是一個意思。

readelf -l ab 能夠查看程序映射表 - Program Header Table:

Elf file type is EXEC (Executable file)
      Entry point 0x80480db
      There are 3 program headers, starting at offset 52

      Program Headers:
        Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
        LOAD           0x000000 0x08048000 0x08048000 0x001a8 0x001a8 R E 0x1000
        LOAD           0x001000 0x0804a000 0x0804a000 0x00014 0x00014 RW  0x1000
        GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10

       Section to Segment mapping:
        Segment Sections...
         00     .text .eh_frame
         01     .got.plt .data

「Segment Sections」就告訴你如何合併這些sections了。

上述示例有3個段(Segment),其中2個type是LOAD的Segment,一個是可執行的Segment,一個是隻讀的Segment。第一個可執行Segment到底合併哪些Section呢? 答案是:00 .text .eh_frame

這個信息是存在可執行文件的「程序頭表(Program Header Table - PHT)」裏面的,就是用readelf -f看到的內容,告訴你sections如何合併成segments。

總結:

  • 目標文件有本身的sections,可執行文件也同樣;
  • 只不過可執行文件又創造了一個概念:segment,就是把sections作了一個合併;
  • 真正裝載放到內存裏的時候,還要段地址對齊。

7.4 段(Segment)地址對齊

內存都是一個一個4k的小頁,便於分配,這涉及到內存管理,不展開詳述。

操做系統就給你一摞4k小頁,問題是即便將sections們壓縮成了segment,也不正好就4k大小,就算多一點點,操做系統也得額外再分配一頁,多浪費啊。

辦法來了:段地址對齊

程序的一輩子:從源程序到進程的辛苦歷程

一個物理頁(4k)上再也不是放一個segment,而是還放着別的,物理頁和進程中的頁是1:2的映射關係,浪費就浪費了,反正也是虛擬的。物理上就被「壓縮」到了一塊兒,過去須要5個才能放下的內容,如今只須要3個物理頁了。

7.5 堆和棧

可執行文件加載到進程空間裏以後,進程空間還有兩個特殊的VMA區域,分別是堆和棧

程序的一輩子:從源程序到進程的辛苦歷程

經過查看linux中的進程內存映射也能夠看到這個信息:cat /proc/555/maps

55bddb42d000-55bddb4f5000 rw-p 00000000 00:00 0                          [heap]
      ...
      7ffeb1c1a000-7ffeb1c3b000 rw-p 00000000 00:00 0                          [stack]

參考:Anatomy of a Program in Memory Gcc 編譯的背後

8、動態連接

靜態連接大體清楚了,接下來介紹動態連接。

動態連接的好處不少:

  • 代碼段能夠不用重複靜態連接到須要它的可執行文件裏面去了,省了磁盤空間;
  • 運行期還能夠共享動態連接庫的代碼段,也省了內存。

8.1 一個栗子

先舉個例子,看看動態連接庫怎麼寫。

lib.c,動態連接庫代碼:

#include <stdio.h>
void foobar(int i) {
    printf("Printing from lib.so --> %d\n", i);
    sleep(-1);
}

爲了讓其餘程序引用它,須要爲它編寫一個頭文件:lib.h

#ifndef LIB_H_
  #define LIB_H_
    void foobar(int i);
  #endif // LIB_H_

最後是調用代碼:program1.c

#include "lib.h"
int main() {
    foobar(1);
    return 0;
}

編譯這個動態連接庫:gcc -fPIC -shared -o lib.so lib.c能夠獲得lib.so。而後編譯引用它的程序的program1.c: gcc -o program1 program1.c ./lib.so,這樣就能夠順利地引用這個動態連接庫了。

程序的一輩子:從源程序到進程的辛苦歷程

這背後到底發生了什麼?

編譯program1.c時,引用了函數foobar,可這個函數在哪裏呢?要在編譯,也就是連接的時候,告訴這個program1程序,所須要的那個foobar在lib.so裏面,也就是須要在編譯參數中加入./lib.so這個文件的路徑。聽說連接器要拷貝so的符號表信息到可執行文件中。

在過去靜態連接的時候,咱們要在program1中對函數foobar的引用進行重定位,也就是修改program1中對函數foobar引用的地址。動態連接不須要作這件事,由於連接的時候,根本就沒有foobar這個函數的代碼在代碼段中。

那何時再告訴program1 foobar的調用地址究竟是多少呢?答案是運行的時候,也就是運行期,加載lib.so的時候,再告訴program1,你該去調用哪一個地址上的lib.so中的函數。

咱們能夠經過/proc/$id/maps,查看運行期program1的樣子:

cat /proc/690/maps

55d35c6f0000-55d35c6f1000 r-xp 00000000 08:01 3539248                    /root/link/chapter7/program1
      55d35c8f0000-55d35c8f1000 r--p 00000000 08:01 3539248                    /root/link/chapter7/program1
      55d35c8f1000-55d35c8f2000 rw-p 00001000 08:01 3539248                    /root/link/chapter7/program1
      55d35dc53000-55d35dc74000 rw-p 00000000 00:00 0                          [heap]
      7ff68e48e000-7ff68e675000 r-xp 00000000 08:01 3671326                    /lib/x86_64-linux-gnu/libc-2.27.so
      7ff68e675000-7ff68e875000 ---p 001e7000 08:01 3671326                    /lib/x86_64-linux-gnu/libc-2.27.so
      7ff68e875000-7ff68e879000 r--p 001e7000 08:01 3671326                    /lib/x86_64-linux-gnu/libc-2.27.so
      7ff68e879000-7ff68e87b000 rw-p 001eb000 08:01 3671326                    /lib/x86_64-linux-gnu/libc-2.27.so
      7ff68e87f000-7ff68e880000 r-xp 00000000 08:01 3539246                    /root/link/chapter7/lib.so
      7ff68ea81000-7ff68eaa8000 r-xp 00000000 08:01 3671308                    /lib/x86_64-linux-gnu/ld-2.27.so
      7ffc2a646000-7ffc2a667000 rw-p 00000000 00:00 0                          [stack]
      7ffc2a66c000-7ffc2a66e000 r--p 00000000 00:00 0                          [vvar]
      7ffc2a66e000-7ffc2a670000 r-xp 00000000 00:00 0                          [vdso]
      ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

如上能夠看到「ld-2.27.so」,動態鏈接器。系統開始的時候,它先接管控制權,加載完lib.so後,再把控制權返還給program1。凡有動態連接庫的程序,都會把它動態連接到程序的進程中,由它首先加載動態連接庫。

8.2 GOT和PLT

程序的一輩子:從源程序到進程的辛苦歷程

GOT和PLT很複雜,細節不少,不太好理解,我也只是把大體的過程搞明白了,因此這裏只是說一說個人理解,若是感興趣能夠看南大袁春風老師關於PLT的講解。

GOT放在數據段裏,而PLT在代碼段裏,因此GOT是能夠改的,放的跳轉用的函數地址;而PLT裏面放的是告訴怎麼調用動態連接庫裏函數的代碼(不是函數的代碼,是怎麼調用的代碼)。

假如主程序須要調用動態連接庫lib.so裏的1個函數:ext,那麼在GOT表裏和PLT表裏都有1個條目,GOT表裏是將來這個函數加載後的地址;而PLT裏放的是如何調用這個函數的代碼,這些代碼是在連接期連接器生成的。

GOT裏還有3個特殊的條目,PLT裏還有1個特殊的條目。

GOT裏的3個特殊條目:

  • GOT[0]: .dynamic section的首地址,裏面放着動態連接庫的符號表的信息。
  • GOT[1]: 動態連接器的標識信息,link_map的數據結構,這個不是很明白,我理解就是連接庫的so文件的信息,用於加載。
  • GOT[2]: 這個是調用動態庫延遲綁定的代碼的入口地址,延遲綁定的代碼是一個特殊程序的入口,實際是一個叫「_dl_runtime_resolve」的函數的地址。

PLT裏的特殊條目:

  • PLT[0]: 就是去調動「_dl_runtime_resolve」函數的代碼,是連接器自動生成的。

整個過程開始了:由於是延遲綁定,因此動態重定位這個過程就須要在第一次調用函數的時候觸發。什麼是動態重定位?就是要告訴進程加載程序,修改新載入的動態連接庫被調用處的地址,誰知道你把so文件加載到進程空間的哪一個位置了,你得把加載後的地址告訴我,我才能調用啊~這個過程就是動態重定位。

.text的主程序開始調用ext函數,ext函數的調用指令:

804845b: e8 ec fe ff ff call 804834c&lt;ext&gt;

804834c是誰?原來是PLT[1]的地址,就是ext函數對應的PLT表裏的代理函數,每一個函數都會在PLT、GOT裏對應一個條目。

如今跳轉到這個函數(PLT[1])去。

PLT[1]:

804834c: ff 25 90 95 04 08  jmp   *0x8049590 
8048352: 68 00 00 00 00     pushl $0x0 
8048357: e9 e0 ff ff ff     jmp   804833c

這個函數首先跳到0x8049590裏寫的那個地址去了(jmp *xxx,不是跳到xxx,而是跳到xxx裏面寫的地址上去)。

這裏有2個細節:

  • 0x8049590這個地址就是GOT[3],GOT[3]是ext函數對應的GOT條目;
  • 0x8049590裏寫的那個地址就是PLT[1](ext對應的plt條目)的下一條。

what?PLT[1]代碼繞這麼個圈子(用GOT[3]裏的地址跳)jmp,其實就是跳到了本身的下一條?是,此次是好笑,但將來這個值會改的,改爲真正的動態庫的函數地址,直接去執行函數。

跳回來以後(PLT[1]),接下來是壓棧了一個0,0表示是第一個函數,也就是ext的索引。

繼續跳0x804833c,這是PLT[0],PLT[0]是去調用「_dl_runtime_resolve」函數。在調用以前還要幹一件事:push 0x8049588,0x8049588是GOT[2]。GOT[2]裏放着so的信息(我理解的不必定徹底正確)。

至此,能夠調用「_dl_runtime_resolve」函數去加載整個so了。

參數包括2個:一個是壓棧的那個0,就是ext函數的索引,後續經過這個索引能夠找到GOT表的位置,把真正的函數的地址回填回去;第二個參數是壓棧的GOT[1],就是動態連接器的標識信息,我理解就是告訴加載器so名字叫啥,它好去加載。

加載完成,馬上回調安放到位置的so裏,索引爲0的ext函數的地址,到GOT[3]中,也就是索引0。

下次再調用這個函數的時候,仍是先調用PLT[1](ext的代理代碼),但裏面的jmp \*0x8049590 (jmp *GOT[3])能夠直接跳轉到真正的ext裏去了。

終於捋完了,必須總結一下。

  • 動態連接庫,動態把so加載到虛擬地址空間,由於地址是不定的,因此跟靜態連接的思路同樣,須要作重定位,也就是要修改調用的代碼地址。
  • 由於是動態連接,都已是運行期了,不能修改內存代碼段(.text)(只讀),只能加載完以後,把加載的函數地址寫到GOT表裏。這就是在加載時修改GOT表的方法。
  • 還有一種方法是:在主程序啓動時不加載so,等第一次調用某個動態連接庫的函數時再加載so,再更新GOT表。思路是:主程序調用某個動態連接庫函數時,實際上是先調用了一個代理代碼(PLT[x]),它會記錄本身的序號(肯定是調哪一個函數)和動態連接庫的文件名這2個參數,而後轉去調用「_dl_runtime_resolve」函數,這個函數負責把so加載到進程虛擬空間去,並回填加載後的函數地址到GOT表,之後再調用就能夠直接去調用那個函數了。

8.3參考

這個是一篇很讚的文章講的PLT的內容,引用過來:

動態連接庫中的函數動態解析過程以下:

1)從調用該函數的指令跳轉到該函數對應的PLT處;

2)該函數對應的PLT第一條指令執行它對應的.GOT.PLT裏的指令。第一次調用時,該函數的.GOT.PLT裏保存的是它對應的PLT裏第二條指令的地址;

3)繼續執行PLT第二條、第三條指令,其中第三條指令做用是跳轉到公共的PLT(.PLT[0]);

4)公共的PLT(.PLT[0])執行.GOT.PLT[2]指向的代碼,也就是執行動態連接器的代碼;

5)動態連接器裏的_dl_runtime_resolve_avx函數修改被調函數對應的.GOT.PLT裏保存的地址,使之指向連接後的動態連接庫裏該函數的實際地址;

6)再次調用該函數對應的PLT第一條指令,跳轉到它對應的.GOT.PLT裏的指令(此時已是該函數在動態連接庫中的真正地址),從而實現該函數的調用。

8.4 Linux的共享庫組織

Linux爲了管理動態連接庫的各類版本,定義了一個so的版本共享方案。

libname.so.x.y.z

  • x是主版本號:重大升級纔會變,不向前兼容,以前引用的程序都要從新編譯;
  • y是次版本號:原有的不變,增長了一些東西而已,向前兼容;
  • z是發佈版本號:任何接口都沒變,只是修復了bug,改進了性能而已。

1)SO-NAME

Linux有個命名機制,用來管理so之間的關係,這個機制叫SO-NAME。任何一個so都對應一個SO-NAME,就是libname.so.x

通常系統的so,無論它的次版本號和發佈版本號是多少,都會給它創建一個SO-NAME的軟連接,例如 libfoo.so.2.6.1,系統就會給它創建一個叫libfoo.so.2的軟鏈。

這個軟連接會指向這個so的最新版本,好比我有2個libfoo,一個是libfoo.so.2.6.1,一個是libfoo.so.2.5.5,軟連接默認指向版本最新的libfoo.so.2.6.1。

在編譯的時候,咱們每每須要引入依賴的連接庫,這時依賴的so使用軟連接的SO-NAME,而不使用詳細的版本號。

在編譯的ELF可執行文件中會存在.dynamic段,用來保存本身所依賴的so的SO-NAME。

編譯時有個更簡潔指定lib的方式,就是gcc -lxxx,xxx是libname中的name,好比gcc -lfoo是指連接的時候去連接一個叫libfoo.so的最新的庫,固然這個是動態連接。若是加上-static: gcc -static -lfoo就會去默認靜態連接libfoo.a的靜態連接庫,規則是同樣的。

2)ldconfig

Linux提供了一個工具「ldconfig」,運行它,linux就會遍歷全部的共享庫目錄,而後更新全部的so的軟鏈,指向它們的最新版,因此通常安裝了新的so,都會運行一遍ldconfig。

8.5 系統的共享庫路徑

Linux尊崇FHS(File Hierarchy Standard)標準,來規定系統文件是如何存放的。

  • /lib:存放最關鍵的基礎共享庫,好比動態連接器、C語言運行庫、數學庫,都是/bin,/sbin裏系統程序用到的庫;
  • /usr/lib: 通常都是一些開發用到的 devel庫;
  • /usr/local/lib:通常都是一些第三方庫,GNU標準推薦第三方的庫安裝到這個目錄下。

另外/usr目錄不是user的意思,而是「unix system resources」的縮寫。

/usr:/usr 是系統核心所在,包含了全部的共享文件。它是 unix 系統中最重要的目錄之一,涵蓋了二進制文件、各類文檔、頭文件、庫文件;還有諸多程序,例如 ftp,telnet 等等。

9、後記

研究這個話題,前先後後經歷了一個月,文章只是把過程當中的體會記錄下來,同時在單位給同事們作了一次分享。雖然也只是浮光掠影,但終究是告終了多年的心願,對可執行文件的格式、加載等基礎知識作了一次梳理,仍是收穫滿滿的。這些知識對實際的工做有什麼幫助嗎?可能會有幫助,但可能也很是有限。「行無用之事,作時間的朋友」,作一些有意思的事情,過程自己就充滿了樂趣。

文章可能會有紕漏和錯誤,能看到這裏的同窗,也請留言指出來,一塊兒討論學習,共同進步!

參考

  • 南京大學-袁春風老師-計算機系統基礎
  • 深刻淺出計算機組成原理-極客時間
  • 《程序是怎樣跑起來的》
  • 《程序員的自我修養》
  • 《深刻理解計算機系統》
  • readlf、nm、ld、objdump、ldconfig、gcc命令

文章來源:宜信技術學院 & 宜信支付結算團隊技術分享第14期-支付結算機器學習技術團隊負責人 劉創 分享《程序的一輩子:從源程序到進程的辛苦歷程》

分享者:宜信支付結算機器學習技術團隊負責人 劉創

原文發佈於我的博客:動物園的豬(www.piginzoo.com)

相關文章
相關標籤/搜索