(一)我們編寫的代碼如何在計算機上運行

1、計算機指令

計算機的指令即cpu能理解的操作,也就是我們所說的機器語言。不同的cpu能理解的語言不一樣,如intel的cpu,蘋果使用的ARM的cpu。不同的語言即不同的計算機指令集

高級語言,彙編語言,計算機指令的關係

  • 一條高級語言 可翻譯成 多條彙編指令(一對多)
  • 一條彙編指令 可翻譯成 一條計算機指令 (一對一)
  • 一條條的計算機指令 即 一條條機器碼(由0和1組成)
  • 高級語言也可以直接翻譯成機器碼,也可以先翻譯成彙編語言再由彙編器翻譯成機器碼
  • 通常我們會把高級語言翻譯成彙編,來看計算機執行的每個步驟

2、代碼執行過程

寄存器是cpu內部的組成部分,特殊的有三類:PC寄存器(存儲下一條指令的內存地址,也叫作程序計數器)、指令寄存器(存儲正在執行的指令碼)、條件碼寄存器(用裏面的一個一個標記位(Flag),存放 CPU 進行算術或者邏輯計算的結果)。除了這些特殊的寄存器,CPU裏面還有更多用來存儲數據和內存地址的寄存器。這樣的寄存器通常一類裏面不止一個。我們通常根據存放的數據內容來給它們取名字,比如整數寄存器、浮點數寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放數據,又能存放地址,我們就叫它通用寄存器。

查看高級語言對應的彙編及機器碼,以linux下c語言爲例

int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        a += i;
    }
}

 通過gcc 和 objdump命令查看代碼對應的彙編指令

gcc -g -c test.c
objdump -d -M intel -S test.o 

 下面爲代碼對應的彙編指令,可以看到for循環是通過 邏輯判斷+指令跳轉 來實現

    for (int i = 0; i < 3; i++)
   b:   c7 45 f8 00 00 00 00    mov    DWORD PTR [rbp-0x8],0x0
  12:   eb 0a                   jmp    1e <main+0x1e>
    {
        a += i;
  14:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  17:   01 45 fc                add    DWORD PTR [rbp-0x4],eax
    for (int i = 0; i < 3; i++)
  1a:   83 45 f8 01             add    DWORD PTR [rbp-0x8],0x1
  1e:   83 7d f8 02             cmp    DWORD PTR [rbp-0x8],0x2
  22:   7e f0                   jle    14 <main+0x14>
  24:   b8 00 00 00 00          mov    eax,0x0
    }

對於cpu而言,我們寫好的代碼變成計算機指令後,是一條一條順序執行的。 程序被加載到內存後,cpu根據PC寄存器的值從內存中拿指令,然後存儲到指令寄存器,再執行。對於有些指令,則會修改pc寄存器的裏的值,達到程序跳轉的目的。

3、函數執行的原理

 代碼中會出現調用其他函數,那麼被調用的函數執行完後如何回到調用的地方繼續往下執行呢?方法爲何是線程安全的呢?

// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
    return a+b;
}


int main()
{
    int x = 5;
    int y = 10;
    int u = add(x, y);
}
int static add(int a, int b)
{
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
    return a+b;
   a:   8b 55 fc                mov    edx,DWORD PTR [rbp-0x4]
   d:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  10:   01 d0                   add    eax,edx
}
  12:   5d                      pop    rbp
  13:   c3                      ret    
0000000000000014 <main>:
int main()
{
  14:   55                      push   rbp
  15:   48 89 e5                mov    rbp,rsp
  18:   48 83 ec 10             sub    rsp,0x10
    int x = 5;
  1c:   c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
    int y = 10;
  23:   c7 45 f8 0a 00 00 00    mov    DWORD PTR [rbp-0x8],0xa
    int u = add(x, y);
  2a:   8b 55 f8                mov    edx,DWORD PTR [rbp-0x8]
  2d:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  30:   89 d6                   mov    esi,edx
  32:   89 c7                   mov    edi,eax
  34:   e8 c7 ff ff ff          call   0 <add>
  39:   89 45 f4                mov    DWORD PTR [rbp-0xc],eax
  3c:   b8 00 00 00 00          mov    eax,0x0
}
  41:   c9                      leave  
  42:   c3                      ret    

每個線程都有一個自己的棧(內存中開闢的空間),線程執行中,所有方法公用這個棧。棧底到棧頂內存地址不變變小,這樣只只用判斷地址是否小於0即可。調用到某個方法時都會先爲該方法先創建一個幀,併入棧。每個幀裏保存着各自方法裏的變量,返回地址,執行狀態等信息。當方法執行完後,則出棧,釋放內存。

此處使用call指令調用函數,當調用call指令時,會默認把當前pc寄存器裏的數據壓棧(下一條指令的地址),在add函數中調用ret指令則會把該地址出棧並寫入pc寄存器,此時函數的控制權就回到了調用的地方。

4、我們寫的代碼如何編譯鏈接,被加載到內存並執行

1、以c語言爲例,看鏈接過程

針對每個源文件編譯得到對應的目標文件,只有通過鏈接器把多個目標文件以及調用的各種函數庫鏈接起來,我們才能得到一個可執行文件。這個過程也叫做靜態鏈接。

可執行文件在linux下是ELF格式,在windows下是PE格式。Linux 下的裝載器只能解析 ELF 格式而不能解析 PE 格式。所以在windows下生成的可執行文件在linux下無法執行。

linux下可執行文件和目標文件都是用的ELF格式,該格式的文件重要結構大致如下:

  • 文件頭,表示這個文件的基本屬性,比如是否是可執行文件,對應的 CPU、操作系統等等。
  • 代碼段,用來保存程序的代碼和指令。
  • 數據段,保存程序裏面設置好的初始化數據信息。
  • 重定位表,保留的是當前的文件裏面,哪些跳轉地址其實是我們不知道的。比如調用函數 printf 在鏈接發生之前,我們並不知道該跳轉到哪裏,這些信息就會存儲在重定位表裏。
  • 符號表,保留了當前文件裏面定義的函數名稱和對應地址的地址簿。

鏈接的過程:鏈接器會掃描所有輸入的目標文件,然後把所有符號表裏的信息收集起來,構成一個全局的符號表。然後再根據重定位表,把所有不確定要跳轉地址的代碼,根據符號表裏面存儲的地址,進行一次修正。最後,把所有的目標文件的對應段進行一次合併,變成了最終的可執行代碼。這也是爲什麼,可執行文件裏面的函數調用的地址都是正確的

2、程序如何加載到內存

虛擬內存:指令裏用到的內存地址叫虛擬地址(比如可執行文件中地址)。

物理內存:指令加載到內存中,實際的硬件的地址。

虛擬內存與物理內存映射表:指令還是按照虛擬內存中的地址來進行跳轉及運行,所以需要一個映射表,通過虛擬地址找到物理地址。

內存交換:有多個程序需要加載到內存並運行,若內存不夠時,則需要把其他程序佔用的內存先轉移到硬盤,等需要使用時再從硬盤恢復到內存。

內存分頁:由於程序執行的時候,程序計數器是順序往下讀的,所以需要程序在內存中的地址是連續的。但是若整個程序都要求連續,這樣多個程序運行內存不足時,需要把整個程序都進行一次內存交換,延遲太高。若把程序拆分成多個固定大小(一般爲4kb),這樣我們運行程序時可以只加載用到的頁進入內存,進行內存交換的時候也可以只針對某些頁,不會產生很大的延遲。

程序運行到某頁,若該頁未加載到內存,操作系統捕捉到異常則把該頁加載到內存,再由cpu讀取執行。

3、 動態鏈接

靜態鏈接:針對某一個程序,程序在鏈接的時候,把不同文件的代碼段,合併到一起,成爲最後的可執行文件。這樣我們寫一個方法,在這個程序每個地方都只需要鏈接就可以使用,達到複用的目的。

動態鏈接:針對很多程序,當他門之間很多功能代碼都是重複時。把他們都生成可執行文件並都裝載到內存裏,會浪費很多的磁盤和內存空間。這時我們可以把這部分功能代碼提煉出來,加載到內存,讓所有程序在運行時可以通過動態鏈接來使用。這樣可以充分節省磁盤和內存空間。(動態鏈接庫,例如dll文件)既可以在開發階段複用,也可以在運行階段複用。

疑問:怎麼實現動態鏈接庫加載到內存後,其他程序進行動態鏈接。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章