引言
在學習C語言或者其他編程語言的時候,我們編寫的一個程序代碼,基本都是在屏幕上打印出 hello world ,開始步入編程世(深)界(坑)的。C 語言版本的 hello world 代碼:
#include <stdio.h>
int main()
{
printf("hello worldn");
return 0;
}
不用多說,這段程序在運行時,會在顯示終端上打印出 hello world 。
那么,這段程序背后關聯的內容,你是否真正梳理明白了呢?
- 源程序代碼是如何編譯成可執行程序的?
- #include<stdio.h> 的作用是什么?
- hello world 程序是怎樣運行起來的?
- printf 是怎樣將字符串 "hello world" 輸出到終端的?
- hello world 程序在運行時,它在內存中是什么樣子的?
- 程序的執行入口為什么是 main 函數?
- 可執行文件的內部結構是怎么樣的?
閑話少說,讓我們進入正題,扒一扒 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 函數,開始執行用戶程序。
總結
沒想到,這么簡單的程序背后,涉及到這么多知識內容。
- 源碼文件編譯成可執行文件具體過程。
- 可執行目標程序加載和執行的詳細過程。
- 可執行目標文件內部結構布局。
- 目標文件加載到內存后的布局情況。