進程控制是操作系統執行多任務的基礎,核心在於管理進程的完整生命週期。本文將深入探討如何通過 fork 創建、wait 等待及 exec 替換,實現對進程的精準掌控。
一.進程創建
進程創建是進程控制的起點核心,是掌握進程管控的必備基礎:
fork
在之前探索進程的奧秘的博客中也提到過,fork()是Linux中創建子進程的系統調用,調用成功後父進程將與子進程併發執行,共享代碼段,數據段(寫時拷貝)。
應用:①一個進程想執行不同代碼(父進程等待客户請求,生成子進程來處理)②一個進程想要執行不同程序(創建子進程後,進程替換)
vfork
vfork專為快速執行exec替換設計(之後會介紹,exec之後子進程地址空間會被新程序覆蓋,無需獨立內存),所以它不會(也不需要)發生寫時拷貝,直接與父進程共享地址空間;
二.進程退出
程退出是進程生命週期的關鍵環節,直接關聯進程控制的核心邏輯:
- 懂退出(正常/異常、exit/_exit/信號終止),能精準管控進程終止時機、清理資源、反饋狀態,避免泄漏或數據丟失;
- 掌握退出機制,才能理解父進程 wait/waitpid 等待子進程退出、回收殭屍進程等核心控制操作;
首先,我們要明確進程退出分為三種情況:
- 運行結束,結果完全正確
- 運行結束,結果不正確
- 運行中斷
得到進程運行情況,才能夠更好的定位程序異常、決定後續執行。
退出碼-正常終止
定義:進程終止時返回的狀態標識(0~255整數,int低8位有效),是進程間傳遞終止狀態的極簡方式。
-常見取值含義:0=正常終止;非0=異常終止(如1=通用錯誤、2=參數錯誤、127=命令未找到、128+n=信號終止,自定義碼可標識程序內部異常)。
當運行結束,我們要了解結果是否正確時,需要退出碼。比如我們熟悉的return 0;這就是一種進程退出碼。
命令行查看錯誤碼
echo $? : 保存的是最近一次程序退出時的錯誤碼
就像上圖,錯誤碼能讓我們得知錯誤信息,讓我們處理異常情況;
c語言查看錯誤信息
但是,錯誤碼有很多,我們很難記住每個數字代表的錯誤信息;所以c語言提供了strerror() 可將錯誤碼轉換成錯誤描述:
錯誤碼和錯誤信息是對應關係
//看一段代碼
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
// 故意打開不存在的文件,觸發 errno 賦值
//errno是全局錯誤變量,當調用失敗後會自動給errno賦值
int fd = open("nonexist.txt", O_RDONLY);
if (fd == -1) {
// strerror 解析 errno 對應的錯誤描述
printf("打開文件失敗:%s(錯誤碼:%d)\n", strerror(errno), errno);
return 1;
}
close(fd);
//直接解析指定錯誤碼
printf("錯誤碼 1:%s\n", strerror(1)); // 通用錯誤
printf("錯誤碼 127:%s\n", strerror(127)); // 命令未找到
printf("錯誤碼 255:%s\n", strerror(255)); // 超出範圍時返回通用描述
return 0;
}
errno
errno是全局變量,需包含頭文件<errno.h>,函數執行失敗時會設置errno對應的錯誤碼;最新一次的錯誤碼將被覆蓋寫入,配合strerror使用(strerror(errno)),查看錯誤信息描述。
退出信號-異常中斷
退出信號是Linux/C語言中觸發程序被動終止的信號類型,屬於信號(Signal[之後博客具體介紹])的子集,由內核、硬件或其他進程發送,程序接收後若未捕獲處理,會直接強制終止,無主動資源清理過程。
程序出錯導致運行中斷,正常來説此時的退出碼應該是沒有意義的,因為程序異常終止了,不存在結果對不對的情況,此時需要通過退出信號查看中斷原因:但是程序異常中斷,內核會自動設置退出碼,規則為[128 + 信號值],我們也可以根據退出碼反推信號值。
比如探索進程的奧秘提到過的kill -9 PID,這屬於人為觸發導致異常終止的一種:
作用
- 標識程序異常終止的具體原因(如bug、硬件故障、人為終止);
- 支持自定義終止邏輯(清理資源、保存數據);
- 實現進程間/系統與進程的終止管控;
- 輔助快速調試排查故障。
終止程序的兩個函數調用
為了主動控制程序終止的時機與狀態,適配不同場景的終止需求,需要我們能夠在程序任意位置主動觸發終止。
exit()
exit()用於終止程序,屬於c語言標準庫函數<stdlib.h>,核心是主動結束程序運行,切清理資源;在main函數中,exit(0)與return 0看似是一樣的,都是結束程序,返回0(運行成功);但如果用在函數中,return只會返回到調用函數的上一層,而exit會直接結束進程。
_exit()
_exit()也用於終止程序,但是它屬於系統調用,結束進程之前不會對資源做清理。與exit()一樣,參數將將被作為錯誤碼設置。
三.進程等待
進程等待是進程控制的銜接核心,承接進程創建與退出,是管控進程生命週期、避免系統資源泄漏的關鍵:
1.概念
有一種無法用kill -9直接殺掉進程 —— 殭屍進程(Zombie Process),我們需要有一種方式(進程等待)結束它,進而解決[內存泄漏]問題;同時,子進程結束,我們需要讓父進程關心它的退出情況,瞭解子進程任務完成情況。
進程等待是父進程通過系統調用( wait() / waitpid() )等待子進程終止,回收子進程資源、獲取退出狀態的機制,是解決殭屍進程的核心方案,也是父子進程同步的關鍵手段。
2.wait/waitpid的使用
父進程通過wait()/waitpid()進行殭屍進程的回收問題,釋放內存空間;如果子進程不退出(或者還未退出),父進程默認在wait()時,子進程返回父進程始終不結束;
由上圖可見:waitpid和wait的區別,在於兩個參數pid_t pid和int options;
pid_t pid決定等待範圍:waitpid可通過傳pid指定等待特定PID/組的子進程; wait只能用於等任意子進程;int options決定阻塞性:wait僅阻塞;waitpid設WNOHANG可非阻塞,不卡父進程——等待子進程的同時時,父進程會執行自己的代碼,只是在間隔時間內不斷檢查waipid等待情況。- 複用性:wait一次只回收1個;waitpid循環+WNOHANG可批量回收所有子進程(等待完多個子進程後結束循環,父進程最後一個退出)。
- 核心場景:簡單回收用wait,精準/非阻塞/批量回收用waitpid。
關於status的具體介紹
status作為輸出型參數,是int類型,低16位存退出狀態,分兩部分解析:
3.阻塞輪詢
對於上文介紹到的int options決定性阻塞,即getpid(id,&status,WNOHANG)第三個參數,這種以非阻塞形式等待子進程的過程也被稱為:阻塞輪詢。
四.進程替換
進程替換是進程控制的核心之一,可讓子進程執行新程序,是Shell等工具實現多任務的關鍵。
1.程序替換
程序替換:指進程通過exec系列函數,用新程序的代碼段、數據段、堆棧(核心執行區)覆蓋自身,丟棄原有代碼數據,轉而執行新程序,進程PID不變。
當使用exec系列函數時,會發生進程替換(頁表重新映射物理地址)的過程,導入新的代碼數據。
2.庫函數 exec系列接口
exec函數僅替換當前進程代碼/數據/堆棧,PID不變,成功無返回,失敗返-1,需配合fork使用避免主進程終止。
列表式
~execl
示例:execl("/bin/ls", "ls", "-l", NULL); ——執行ls -l(全路徑)
(ls可以代選項,因為ls的main函數有自己的命令行參數,進程替換後,完全從執行ls的main函數開始執行新的代碼)
當然,所替換程序也可以是自己的可執行程序,比如:
//生成a.out可執行程序
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
execl("./test","test",NULL);//進程替換
printf("進程替換後,子進程執行新代碼\n");
}
wait(NULL);
return 0;
}
//形成test可執行程序
#include<stdio.h>
int main()
{
printf("進程已被替換\n");
printf("hello execl\n");
}
~execlp
execlp("ls", "ls", "-l", NULL);//第一個參數不需要寫路徑,只需要寫程序名
execlp("a.out", "a.out", NULL);//若a.out在PATH目錄(如/usr/bin)則生效
為什麼execl 稱為列表形式?
因參數需要逐個羅列傳遞,以NULL首尾;而非打包成數組批量傳遞,故被稱為列表式(' l '為後綴(list))。
數組式
~execv與 execvp
execv與execvp與execl系列只改變了傳參的形式(數組式,'v'為後綴(vector)):
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 參數數組:命令名+選項,NULL結尾
char *argv[] = {"ls", "-l", NULL};
// 1.路徑:/bin/ls;2.參數數組首地址
execv("/bin/ls", argv);//替換自己的程序同理
//execvp("ls", argv);//execvp同理
perror("execv fail"); // 替換失敗才執行
}
wait(NULL); // 父進程等待
return 0;
}
execle
環境變量問題
當我們用一個進程去調用另一個進程時,不傳環境變量,main函數也能正常運行,為什麼?
環境變量也是數據,創建子進程時,環境變量就已經被子進程繼承下去了[extern char* environ];所以進程替換中,環境變量信息不會被替換。
所以我們想給子進程傳遞環境變量應該怎麼做?(新增 / 徹底替換)
- 新增:
- 在父進程中用putenv引入新的環境變量,子進程能夠繼承下去。
- execle傳environ(系統默認環境變量),會連同putenv新增的一起傳下去
- 徹底替換:
- 使用execle傳自定義環境變量。
execle同之前的分析後綴含義:
- l=列表傳參
- 無p=需寫全路徑。
- e=自定義環境
核心區別:替換進程同時,用自定義環境變量覆蓋默認環境,參數末尾需跟環境數組+NULL收尾。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
extern char **environ;//聲明
//給調用進程添加環境變量-添加到調用進程的上下文
putenv("PRIVATE_ENV=6666666");
pid_t id = fork();
if(id == 0)
{
// 自定義環境變量數組
char *const myenv[] = { "MYVAL=77777","MYPATH=/bin",NULL}; // 進程替換,指定自定義環境
//完全替換
execle("./otherExe","otherExe","-a","-b","-c",NULL,myenv);//傳自定義環境變量
//新增環境變量- putenv在父進程新增環境變量,會被子進程繼承下去
execle("./otherExe","otherExe","-a","-b","-c",NULL,environ);//可傳環境變量
}
wait(NULL);
return 0;
}
execvpe
接口總結
3.系統調用 execve
有了以上六個庫函數的學習,系統調用接口execve也很好理解:
庫函數在底層都要調用系統調用接口,即:之前六個庫函數底層全部是調用execve實現進程替換。
4.總結
替換原理
Linux形成的可執行程序,存在EFL可執行文件表頭(ELF Header):保存文件起始處的元數據,在內核加載時優先讀取,描述整個可執行文件的核心信息——其中可執行程序的入口地址就被存在表頭中,當進程替換髮生時,表頭會被重新加載,因此CPU能找到找到新程序的入口,從新開始執行程序。
在底層,exec* 系列充當一個加載器的作用,導入磁盤中的數據,讀取指定的新可執行程序的(ELF文件),解析格式,將其加載映射到進程用户地址空間,再跳轉到程序入口執行,完成靜態文件到運行程序的轉換。
跨語言調用
在進程替換中,我們可以替換系統命令,也可以替換我們自己的C/C++程序,但是如果我們嘗試用exec*調用Java,腳本,exe程序呢?發現也能調用成功;
這就不得不提,exec* 用於進程替換,所有語言寫成的程序,運行起來本質都是進程,可以被exec* 替換,從而實現跨語言調用。
五.進程替換總結
- 進程創建:fork()複製父進程生成子進程,共享代碼段,數據段獨立,繼承父進程資源,PID唯一。
- 進程終止:正常終止(exit()/_exit())、異常終止(信號),釋放用户資源,內核資源等待回收。
- 進程等待:wait()/waitpid()回收子進程資源,避免殭屍進程,獲取子進程退出狀態(退出碼、終止信號)。
- 進程替換:exec系列函數加載新ELF程序,覆蓋當前進程用户態核心區,保留PID等內核資源,實現程序切換。
- 核心目的:實現多程序併發執行、資源複用,協調進程生命週期,保障系統穩定高效運行。