1 進程替換
??進程替換是為了讓程序能在不創建新進程的情況下,讓父進程和子進程執行不同的代碼,以實現控制清晰、執行高效的程序調度機制。
1.1 先看效果
#include <stdio.h>
#include <unistd.h> int main()
{printf("before:I am a process, pid:%d,ppid:%d\n",getpid(),getppid()); // 標準寫法 execl("/usr/bin/ls", "ls", "-a", "-l", NULL);// 想要執行程序的路徑 怎么執行這個命令 最后必須NULL結尾printf("after:I am a process, pid:%d,ppid:%d\n",getpid(),getppid());return 0;
}
??我們會發現這里并沒有if else但是子進程在execl后卻沒有執行父進程的代碼,這說明子進程所執行的代碼被替換了!! 這就是發生了進程替換!
1.2 進程替換的原理
??在使用 fork() 創建進程時,我們經常看到父進程和子進程執行的是不同的邏輯,但卻沒有使用 if/else 來區分函數入口。這是如何做到的?要理解這一點,就得先回答下面五個問題。
問題1:子進程執行了 ls 這個程序,是不是創建了一個新的子進程?
- 答:不是的,子進程在執行 ls 的過程中,并不會再創建一個新的進程。相反,它會調用 exec 系列函數(如 execl、execvp 等),這類函數的作用是:將當前進程的代碼和數據空間完全替換為另一個可執行程序的內容(比如 ls 的代碼和數據)。這一過程由操作系統完成,原來的用戶態執行內容被新程序接管。雖然進程內容變了,但進程的 PID 不變,內核中的 PCB(進程控制塊)結構仍然保留,只是部分字段(如指令入口地址、頁表等)發生了更新。(就是寫時拷貝)
問題 2:既然子進程的內容被替換了,為什么父進程沒有受到影響?
- 答:這是因為 Linux 在 fork() 創建進程時采用了寫時拷貝技術。簡單來說:起初父子進程共享相同的內存頁面(包括代碼段和數據段),但在其中一個進程嘗試修改內存時,操作系統才會為它單獨分配新頁面,實現真正的物理內存拷貝。當子進程執行 exec 系列函數時,它會重新加載目標程序的代碼和數據,觸發寫時拷貝,從而不影響父進程的內存空間。這樣,父進程可以繼續執行自己原來的代碼,而子進程則運行被替換的新程序。(就有點像你的第二人格出現,但是你已經不記得自己的第一人格做過什么或者說過什么)
問題 3:我們常說寫時拷貝作用于數據段,那代碼段也可以寫時拷貝嗎?
- 答:可以,代碼段同樣可以觸發寫時拷貝,盡管它通常是只讀的。這是因為:操作系統不相信任何人,通常情況下,用戶程序無法修改代碼段,但 exec 是一種特殊情況,它是由操作系統內核完成的程序替換操作;因此操作系統有權限回收原有代碼段并重新加載新的代碼段(如 ls),實現了用一份全新的代碼段替換舊的的效果。這并不意味著代碼段可以隨意修改,而是說在 exec 的語義中,代碼段可以被替換。
問題 4:如果進程替換失敗了會怎樣?
- 答:如果替換失敗了,就只能執行自己原先的代碼了!!所以exec系列的函數只有失敗的返回值而沒有成功的返回值,因為一但成功后跑的就是新的代碼和數據了,返回就沒有意義了。
問題 5:我們說 main 是程序入口,但它不一定寫在文件開頭,Linux 是如何找到它的?
- 答: Linux中的可執行程序,是有自己的組織形式的,也就是有自己的格式的(有一張表),我們把這個格式叫做ELF ,ELF 文件中包含一個頭部結構體,里面記錄了程序的各個段的起始地址,其中就有一個字段叫做 e_entry,它指向程序的真實入口地址,操作系統加載可執行文件時,根據 e_entry 所指的地址啟動執行,從而精確找到程序入口,無需掃描整個文件內容。
1.3 探究各個程序替換的接口
函數名 | 參數類型 | 是否搜索 PATH | 可否傳 envp | 使用方式 |
---|---|---|---|---|
execl | path + 可變參數 | ? 否 | ? 否 | 寫死路徑 + 手動寫參數 |
execlp | file + 可變參數 | ? 是 | ? 否 | 像終端命令一樣寫 |
execle | path + 可變參數 | ? 否 | ? 是 | 自定義環境變量 |
execv | path + argv[] | ? 否 | ? 否 | 參數數組(變量較多時) |
execvp | file + argv[] | ? 是 | ? 否 | 動態命令調用 |
execve | path + argv[] + envp[] | ? 否 | ? 是 | 最底層、最靈活的調用 |
1.3.1 execl:路徑+變長參數(手動列出參數)
#include <unistd.h>
#include <stdio.h>// int execl(const char *path, const char *arg, ...);int main()
{// 什么路徑下,執行什么程序// 使用完整路徑,執行 ls -l -a,最后一個一定是NULL結尾execl("/bin/ls", "ls", "-l", "-a", NULL);perror("execl failed");return 1;
}
1.3.2 execlp:文件名+變長參數(自動按 PATH 搜索)
#include <unistd.h>
#include <stdio.h>// int execlp(const char *file, const char *arg, ...);int main()
{// 自動在 PATH 中找 ls,等同于直接在終端輸入 ls -l -aexeclp("ls", "ls", "-l", "-a", NULL);perror("execlp failed");return 1;
}
1.3.3 execv:路徑+參數數組
#include <unistd.h>
#include <stdio.h>// int execv(const char *path, char *const argv[]);int main()
{char *args[] = {"ls", "-l", "-a", NULL};execv("/bin/ls", args);perror("execv failed");return 1;
}
1.3.4 execvp:文件名+參數數組(PATH 搜索)
#include <unistd.h>
#include <stdio.h>// int execvp(const char *file, char *const argv[]);int main()
{char *args[] = {"ls", "-l", "-a", NULL};execvp("ls", args);perror("execvp failed");return 1;
}
execle/execvpe:多個一個envp[ ] 意思就是我們可以自己用一套自己的環境變量,而不是用從父進程繼承下來的。 (后面補充!!!)
1.4 接口總結和加載器理解
??在 Linux 中,exec 系列函數雖然有多個變種,但它們的本質區別只在于參數的形式和功能的側重點不同。這些不同的接口設計,實際上是圍繞以下幾個核心問題展開的:
(1)程序在哪里?——執行目標的位置問題
- 進程替換的第一步是定位目標程序的位置。為此,exec 系列提供了兩種方式:
- 明確路徑:如 execl、execv 等函數要求你傳入程序的完整路徑(如 /bin/ls)。
- 自動搜索:如 execlp、execvp 則只需傳入程序名,系統會自動從 PATH 環境變量中查找對應的可執行文件。
(2)參數如何傳?——執行參數的組織方式問題
- 找到程序之后,下一個問題是:我們需要為它傳遞哪些參數?如何傳遞?為此,exec 系列函數支持兩種參數傳遞形式:
- 可變參數列表(如 execl, execlp, execle):適合參數數量固定、較少的情況,手動列出每個參數,最后以 NULL 結尾。
- 參數數組形式(如 execv, execvp, execve):適合動態構造參數列表,將參數統一組織在 char *argv[] 中傳入。
(3)是否使用自定義環境變量?——環境隔離問題
- 最后一個核心問題是:新執行的程序是否必須繼承當前進程的環境變量?
- 默認繼承:大部分 exec 函數(如 execl, execvp, execv 等)會沿用當前進程的環境變量。
- 自定義傳入:而以 e 結尾的函數(如 execle, execve)允許你顯式傳入一組新的環境變量數組 envp[],實現更高的控制力和隔離性
??總結:exec 系列函數的多樣設計,其實是從“程序在哪、參數怎么傳、環境用誰的”這三個角度出發,對進程替換這一行為進行了細粒度的接口劃分,滿足了不同場景下的執行需求。
1.5 makefile一次生成兩個可執行文件
補充知識:.cc .cpp .cxx 都是C++中的文件后綴
test1.c文件
#include <stdio.h>int main()
{printf("hello HYQ\n");printf("hello HYQ\n");printf("hello HYQ\n");printf("hello HYQ\n");return 0;
}
test2.cpp文件
#include <iostream>using namespace std;int main()
{printf("Hello C++ Linux\n");printf("Hello C++ Linux\n");printf("Hello C++ Linux\n");printf("Hello C++ Linux\n");return 0;
}
makefile文件
test1:test1.cgcc -o $@ $^
test2:test2.cppg++ -o $@ $^
.PHONY:clean
clean:rm -f test1 test2
??上面的makefile文件只能生成一個可執行文件,因為它一旦掃描到一個推導鏈,就會立馬執行,執行一個推導鏈就結束了,不會去執行第二個。
??因此,如果我想生成兩個可執行文件,就必須讓這兩個程序有一個關系,才可能一次執行兩個文件。
??唯一的解決方法就是,誰都不要放前面,而是提前建立一個偽目標all放在前面,多一層推導關系,這樣兩個文件就會根據推導鏈的執行而被編譯了。
.PHONY:all
all:test1 test2 // 不能有縮進test1:test1.cgcc -o $@ $^ // 一定是tab縮進,空格會出問題
test2:test2.cppg++ -o $@ $^
.PHONY:clean
clean:rm -f test1 test2
一個可執行程序能不能調另一個可執行程序了???答案是:可以的!!!
int main()
{printf("hello HYQ\n");printf("hello HYQ\n");printf("hello HYQ\n");printf("hello HYQ\n");// 第一次參數:什么路徑執行什么東西// 第二個參數:怎么執行// 第三個參數:一定是NULL結尾execl("./test2","test2",NULL);printf("HYQ\n");return 0;
}
??所以語言和語言之間是可以相互調用的!!任何語言都有像exec這類的接口,語言可以互相調用的原因是無論什么語言寫的程序在操作系統看來都是進程。
??補充關于環境變量:環境變量是在子進程創建的時候就默認繼承了,即使沒有傳環境變變量參數,也可以在地址空間找到。所以進程替換中,環境變量信息不會被替換!
1.6 總結進程替換系列的函數
- 這些函數如果調用成功則加載新的程序從啟動代碼開始執行,不再返回。
- 如果調用出錯則返回-1
- 所以exec函數只有出錯的返回值而沒有成功的返回值
- l(list) : 表示參數采用列表
- v(vector) : 參數用數組
- p(path) : 有p自動搜索環境變量PATH
- e(env) : 表示自己維護環境變量