在學習C語言或者其他編程語言的時候,我們編寫的一個程序代碼,基本都是在屏幕上打印出 hello world ,開始步入編程世(深)界(坑)的。C 語言版本的 hello world 代碼:
#include <stdio.h>
int main()
{
printf("hello world\n");
return 0;
}
不用多說,這段程序在運行時,會在顯示終端上打印出 hello world 。
那麼,這段程序背後關聯的内容,你是否真正梳理明白了呢?
閑話少說,讓我們進入正題,扒一扒 hello world 背後的内幕。
注:本文是在 Ubuntu 環境下對程序的編譯和運行進行實驗,相關内容以 Linux 系統為主。
程序編譯在 Linux 系統或者其他環境下,将源碼編程成可執行程序,很簡單。點擊編譯按鈕或者輸入編譯指令即可完成。例如,在 Linux 下,用 gcc 編譯此程序代碼,然後運行:
$ gcc hello.c -o hello
$ ./hello
hello world
但是,你知道編譯器幹了哪些工作嗎?編譯器将源代碼文件編程成可執行程序,經曆了四步:編譯預處理、編譯、彙編、鍊接。
編譯過程
1. 編譯預處理
編譯預處理過程主要是處理源代碼文件中,以 “#” 開頭的預編譯指令。例如,“#inlude”、“#define”等。
預處理器根據以字符 “#” 開頭的指令,修改原始的 C 程序文件,生成一個以 .i 為擴展名的程序文件。
本例中,#include<stdio.h> 命令告訴預處理器,讀取系統頭文件 stdio.h 的内容,并把它插入到源程序文本中。
在 Linux 環境下,可以通過如下指令得到預處理完成後的 .i 文件
$ gcc -E hello.c -o hello.i
這個文件内容比較長,如果有興趣的話可以自己進行實驗,查看一下。
2. 編譯
編譯的過程就是把預處理完的文件,進行一系列的詞法分析、語法分析、語義分析以及優化後,生成相應的彙編代碼文件。這個過程往往是整個程序構建的核心部分。
将 hello.i 文件翻譯成文本文件 hello.s,其内部是一個彙編語言的程序。
通過如下指令可以得到彙編文件
$ gcc -S hello.i -o hello.s
3. 彙編
彙編器将上一步生成的彙編代碼翻譯成機器可以執行的指令,把這些指令打包成可重定位目标程序,保存在目标文件 hello.o 中。
可以通過下邊的指令生成:
$ gcc -c hello.s -o hello.o
文件 hello.o 是一個二進制文件。
4. 鍊接
hello 程序調用了 printf 函數,這是 标準 C 庫中的一個函數。printf 函數存儲在一個預編譯好的目标文件 printf.o 中,鍊接器負責将這個文件以某種方式合并到 hello.o 程序中。
合并處理後,得到一個可執行目标文件 hello,這個可執行文件可以由系統加載運行。
程序運行hello.c 程序已經被編譯可執行的目标文件 hello,且存在磁盤上。那這個程序是如何運行起來的呢?
當然,你可以說,通過如下指令可以運行程序:
$ ./hello hello world
但是,從計算機角度來說,運行這個程序需要做哪些工作呢?
當輸入 “./hello” 後,shell 開始處理這條指令。
首先,shell 加載可執行文件 hello,複制目标文件 hello 中的代碼和數據到内存中。
數據和指令加載完成後,處理器開始執行 hello 程序中 main 函數的機器指令。這些指令将 “hello world” 字符串中的字節複制到寄存器文件,再從寄存器文件中複制顯示設備上,最終在屏幕上顯示出來。
程序執行過程
其實,操作系統在加載程序後,還做了一些工作,用于準備 main 函數執行需要的環境,然後調用 main 函數。
可執行程序文件在 Linux 下,可執行文件的存儲格式為 ELF(Executable Linkable Format)。那麼其内部結構是什麼樣的呢?
典型的 ELF 可執行文件的布局情況如下:
可執行文件布局
ELF 頭部描述了整個文件的屬性,包括,文件是否可執行、目标硬件、目标操作系統、入口點等信息。
.init 定義了一個小函數,叫做 _init,程序的初始化代碼會調用它。
.text 為已編譯程序的機器代碼。 .rodata 為隻讀數據,比如 printf 語句中格式串。.data 為已初始化的全局和靜态 C 變量。
.bss 存放未初始化的全局變量和局部靜态變量,以及所有被初始化為 0 的全局或靜态變量。不占用實際的空間,隻是一個占位符。
.symtab 是一個符号表,存放在程序中定義和引用的函數和全局變量的信息。
.debug 一個調試符号表,内部是程序定義的局部變量和類型定義,程序定義和引用的全局變量,以及原始的 C 源文件。
.line 源程序中的行号和 .text 節中機器指令之間的映射。
.strtab 一個字符串表,内容包括 .symtab 和 .debug 節中的符号表,以及節頭部中的節名字。
總體來說,将程序源碼編譯之後生成的目标文件,主要分成兩種段:程序指令和程序數據。代碼段屬于程序指令,數據段和 .bss 段屬于程序數據。
加載可執行程序可執行程序被加載器加載到内存,即從磁盤内複制可執行文件中的代碼和數據到内存中,然後跳轉到程序的入口點來運行該程序。将程序複制到内存并運行的過程就叫做加載。
在 Linux 系統中,每個程序都有一個運行時的内存映像。
程序加載後内存布局
代碼段後邊是數段,運行時,堆在數據段之後,通過調用 malloc 庫向上增長。
用戶棧總是從最大的合法用戶地址開始,向較小内存地址增長。
用戶棧以上的區域,是為内核中的代碼和數據保留的。
程序加載運行時,會創建類似上圖所示的内存映像,在程序頭部的引導下,加載器将可執行文件複制到代碼段和數據段,然後加載器跳轉到程序的入口點。
入口點的函數調用啟動函數,初始化執行環境,然後調用用戶層的 main 函數,處理 main 函數的返回值,并在需要的時候把控制權返回給内核。
main 函數為作為用戶可執行程序的入口,是由系統啟動函數内部定義的。在環境準備好後,調用 main 函數,開始執行用戶程序。
總結沒想到,這麼簡單的程序背後,涉及到這麼多知識内容。
,
- 源碼文件編譯成可執行文件具體過程。
- 可執行目标程序加載和執行的詳細過程。
- 可執行目标文件内部結構布局。
- 目标文件加載到内存後的布局情況。
更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!