[apue] 進程控制那些事兒
進程標識
在介紹進程的創建、啟動與終止之前,首先了解一下進程的唯一標識——進程 ID,它是一個非負整數,在系統范圍內唯一,不過這種唯一是相對的,當一個進程消亡后,它的 ID 可能被重用。不過大多數 Unix 系統實現延遲重用算法,防止將新進程誤認為是使用同一 ID 的某個已終止的進程,下面這個例子展示了這一點:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <set>
int main (int argc, char *argv[])
{
std::set<pid_t> pids;
pid_t pid = getpid();
time_t start = time(NULL);
pids.insert(pid);
while (true)
{
if ((pid = fork ()) < 0)
{
printf ("fork error\n");
return 1;
}
else if (pid == 0)
{
printf ("[%u] child running\n", getpid());
break;
}
else
{
printf ("fork and exec child %u\n", pid);
int status = 0;
pid = wait(&status);
printf ("wait child %u return %d\n", pid, status);
if (pids.find (pid) == pids.end())
{
pids.insert(pid);
}
else
{
time_t end = time(NULL);
printf ("duplicated pid find: %u, total %lu, elapse %lu\n", pid, pids.size(), end-start);
break;
}
}
}
exit (0);
}
上面的程序制造了一個進程 ID 復用的場景:父進程不斷創建子進程,將它的進程 ID 保存在 set 容器中,并將每次新創建的 pid 與容器中已有的進行對比,如果發現有重復的 pid,則打印一條消息退出循環,下面是程序輸出日志:
> ./pid
fork and exec child 18687
[18687] child running
wait child 18687 return 0
fork and exec child 18688
[18688] child running
wait child 18688 return 0
fork and exec child 18689
...
wait child 18683 return 0
fork and exec child 18684
[18684] child running
wait child 18684 return 0
fork and exec child 18685
[18685] child running
wait child 18685 return 0
fork and exec child 18687
[18687] child running
wait child 18687 return 0
duplicated pid find: 18687, total 31930, elapse 8
在大約創建了 3W 個進程后,進程 ID 終于復用了,整個耗時大約 8 秒左右,可見在頻繁創建進程的場景中,進程 ID 被復用的間隔還是很快的,如果依賴進程 ID 的唯一性做一些記錄的話,還是要小心,例如使用進程 ID 做為文件名,最好是加上時間戳等其它維度以確保唯一性。
另外一個有趣的現象是,進程 ID 重復時,剛好是第一個子進程的進程 ID,看起來這個進程 ID 分配是個周而復始的過程,在漲到一定數量后會回卷,追蹤中間的日志,發現有以下輸出:
...
[32765] child running
wait child 32765 return 0
fork and exec child 32766
[32766] child running
wait child 32766 return 0
fork and exec child 32767
[32767] child running
wait child 32767 return 0
fork and exec child 300
[300] child running
wait child 300 return 0
fork and exec child 313
[313] child running
wait child 313 return 0
fork and exec child 314
[314] child running
wait child 314 return 0
...
看起來最大達到 32767 (SHORT_MAX) 后就開始回卷了,這比我想象的要早,畢竟 pid_t 類型為 4 字節整型:
sizeof (pid_t) = 4
最大可以達到 2147483647,這也許是出于某種向后兼容考慮吧。在 macOS 上這個過程略長一些:
> ./pid
fork and exec child 12629
[12629] child running
wait child 12629 return 0
fork and exec child 12630
[12630] child running
wait child 12630 return 0
fork and exec child 12631
[12631] child running
wait child 12631 return 0
...
[12626] child running
wait child 12626 return 0
fork and exec child 12627
[12627] child running
wait child 12627 return 0
fork and exec child 12629
[12629] child running
wait child 12629 return 0
duplicated pid find: 12629, total 99420, elapse 69
總共產生了不到 10W 個 pid,歷時一分多鐘,看起來要比 linux 做的好一點。查看中間日志,pid 也發生了回卷:
...
fork and exec child 99996
[99996] child running
wait child 99996 return 0
fork and exec child 99997
[99997] child running
wait child 99997 return 0
fork and exec child 99998
[99998] child running
wait child 99998 return 0
fork and exec child 100
[100] child running
wait child 100 return 0
fork and exec child 102
[102] child running
wait child 102 return 0
fork and exec child 103
[103] child running
wait child 103 return 0
...
回卷點是 99999,emmmm 有意思,會不會是喬布斯定的,哈哈。
雖然進程 ID 的合法范圍是 [0~INT_MAX],但實際上前幾個進程 ID 會被系統占用:
- 0: swapper 進程 (調度)
- 1: init 進程 (用戶態)
- …
其中 ID=0 的通常是調度進程,也稱為交換進程,是內核的一部分,并不執行任何磁盤上的程序,因此也被稱為系統進程;ID=1 的通常是 init 進程,在自舉過程結束時由內核調用,該程序的程序文件在 UNIX 早期版本中是 /sbin/init,不過在我的測試機 CentOS 上是 /usr/lib/systemd/systemd:
> ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 Oct24 ? 00:00:19 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
root 2 0 0 Oct24 ? 00:00:00 [kthreadd]
root 4 2 0 Oct24 ? 00:00:00 [kworker/0:0H]
root 6 2 0 Oct24 ? 00:00:01 [ksoftirqd/0]
root 7 2 0 Oct24 ? 00:00:01 [migration/0]
root 8 2 0 Oct24 ? 00:00:00 [rcu_bh]
...
查看文件系統:
> ls -lh /sbin/init
lrwxrwxrwx. 1 root root 22 Sep 7 2022 /sbin/init -> ../lib/systemd/systemd
就是個軟鏈接,其實是一回事。在 macOS 又略有不同,
> ps -ef
UID PID PPID C STIME TTY TIME CMD
0 1 0 0 3:34PM ?? 0:15.45 /sbin/launchd
0 74 1 0 3:34PM ?? 0:00.89 /usr/sbin/syslogd
0 75 1 0 3:34PM ?? 0:01.42 /usr/libexec/UserEventAgent (System)
...
為 launched。這里將進程 ID=1 的統稱為 init 進程,它通常讀取與系統有關的初始化文件,并將系統引導到一個狀態 (e.g. 多用戶),且不會終止,雖然運行在用戶態,但具有超級用戶權限。在孤兒進程場景下,它負責做缺省的父進程,關于這一點可以參考后面 "進程終止" 一節。正因為進程 ID 0 永遠不可能分配給用戶進程,所以它可以用作接口的臨界值,就如上面例子中 fork 所做的那樣,關于 fork 的詳細說明可以參考后面 "進程創建" 一節。
各種進程 ID 通過下面的接口返回:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); // process ID
pid_t getppid(void); // parent process ID
uid_t getuid(void); // user ID
uid_t geteuid(void); // effect user ID
gid_t getgid(void); // group ID
gid_t getegid(void); // effect group ID
各個接口返回的 ID 已在注釋中說明。進程是動態的程序文件、文件又由進程生成,而它們都受系統中用戶和組的轄制,用戶態進程必然屬于某個用戶和組,就像文件一樣,關于這一點,可以參考這篇《[apue] linux 文件訪問權限那些事兒 》。再說深入一點,用戶 ID、組 ID 標識的是執行進程的用戶;有效用戶 ID、有效組 ID 則標識了進程程序文件通過 set-user-id、set-group-id 標識指定的用戶,一般用作權限"后門";還有 saved-set-uid、saved-set-gid,則用來恢復更改 uid、gid 之前的身份。關于兩組三種 ID 之間的關系、相互如何轉換及這樣做的目的,可以參考后面 "更改用戶 ID 和組 ID" 一節。
進程創建
Unix 系統的進程主要依賴 fork 創建:
#include <unistd.h>
pid_t fork(void);
fork 本意為分叉,像一條路突然分開變成兩條一樣,調用 fork 后會裂變出兩個進程,新進程具有和原進程完全相同的環境,包括執行堆棧。即在調用 fork 處會產生兩次返回,一次是在父進程,一次是在子進程。
但是父、子進程的返回值卻大不相同,父進程返回的是成功創建的子進程 ID;子進程返回的是 0。通過上一節對進程 ID 的說明,0 是一個合法但不會分配給用戶進程的 ID,這里作為區分父子進程的關鍵,從而執行不同的邏輯。父進程 fork 返回子進程的 pid 也是必要的,因為目前沒有一種接口可以返回父進程所有的子進程 ID,通過 fork 返回值父進程就可以得到子進程的 ID;而反過來,子進程可以通過 get_ppid 輕松獲取父進程 ID,所以不需要在 fork 處返回,且為了區別于父進程中的 fork 返回,這里有必要返回 0 來快速標識自己是子進程 (通過記錄父進程 ID 等辦法也可以標識,但是明顯不如這種來得簡潔)。
int pid = fork();
if (pid < 0)
{
// error
exit(1);
}
else if (pid == 0)
{
// child
printf ("%d spawn from %d\n", getpid(), getppid());
}
else
{
// parent
sleep (1);
printf ("%d create %d\n", getpid(), pid);
}
新建的子進程具有和父進程完全相同的數據空間、堆、棧,但這不意味著與父進程共享,除代碼段這種只讀的區域,其他的都可以理解為父進程的副本:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int g_count = 1;
int main()
{
int v_count = 42;
static int s_count = 1024;
int* h_count = (int*)malloc (sizeof (int));
*h_count = 36;
int pid = fork();
if (pid < 0)
{
// error
exit(1);
}
else if (pid == 0)
{
// child
g_count ++;
v_count ++;
s_count ++;
(*h_count) ++;
printf ("%d spawn from %d\n", getpid(), getppid());
}
else
{
// parent
sleep (1);
printf ("%d create %d\n", getpid(), pid);
}
printf ("%d: global %d, local %d, static %d, heap %d\n", getpid(), g_count, v_count, s_count, *h_count);
return 0;
}
這個例子就很說明問題,運行得到下面的輸出:
$ ./forkit
18270 spawn from 18269
18270: global 2, local 43, static 1025, heap 37
18269 create 18270
18269: global 1, local 42, static 1024, heap 36
子進程修改全局、局部、靜態、堆變量對父進程不可見,父、子進程是相互隔離的,子進程一般會在 fork 之后調用函數族來將進程空間替換為新的程序文件。這就是 exec 函數族,它們會把當前進程內容替換為磁盤上的程序文件并執行新程序的代碼段,和 fork 是一對好搭檔。關于 exec 函數族的更多內容,請參考后面 "exec" 一節。
對于習慣在 Windows 上創建進程的用戶來說,CreateProcess 還是更容易理解一些,它直接把 fork + exec 的工作都包攬了,完全不知道還有復制進程這種騷操作。那 Unix 為什么要繞這樣一個大彎呢?這是由于如果想在執行新程序文件之前,對進程屬性做一些設置,則必需在 fork 之后、exec 之前進行處理,例如 I/O 重定向、設置用戶 ID 和組 ID、信號安排等等,而封裝成一整個的 CretaeProcess 對此是無能為力的,只能將這些代碼安排在新程序的開頭才行,而有時新進程的代碼是不受我們控制的,對此就無能為力了。
Unix 有沒有類似 CreateProcess 這樣的東西呢,也有,而且是在 POSIX 標準層面定義的:
#include <spawn.h>
int posix_spawn(pid_t *restrict pid, const char *restrict path,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *restrict attrp,
char *const argv[restrict], char *const envp[restrict]);
int posix_spawnp(pid_t *restrict pid, const char *restrict file,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *restrict attrp,
char *const argv[restrict], char * const envp[restrict]);
這就是 posix_spawn 和 posix_spawnp,兩者的參數完全相同,區別僅在于路徑參數是絕對路徑 (path) 還是帶搜索能力的相對路徑 (file)。不過這個接口無意取代 fork + exec,僅用來支持對存儲管理缺少硬件支持的系統,這種系統通常難以有效的實現 fork。
有的人認為基于 fork+exec 的 posix_spawn 不如 CreateProcess 性能好,畢竟要復制父進程一大堆東西,而大部分對新進程又無用。實際上 Unix 采取了兩個策略,導致 fork+exec 也不是那么低效,通常情況下都能媲美 CreateProcess。這些策略分別是寫時復制 (COW:Copy-On-Write) 與 vfork。
COW
fork 之后并不執行一個父進程數據段、棧、堆的完全復制,作為替代,這些區域由父、子進程共享,并且內核將它們的訪問權限標記為只讀。如果父、子進程中的任一個試圖修改這些區域,則內核只為修改區域的那塊內存制作一個副本,通常是虛擬存儲器系統中的一頁。在更深入的說明這個技術之前,先來看看 Linux 是如何將虛擬地址轉換為物理地址的:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdint.h>
unsigned long virtual2physical(void* ptr)
{
unsigned long vaddr = (unsigned long)ptr;
int pageSize = getpagesize();
unsigned long v_pageIndex = vaddr / pageSize;
unsigned long v_offset = v_pageIndex * sizeof(uint64_t);
unsigned long page_offset = vaddr % pageSize;
uint64_t item = 0;
int fd = open("/proc/self/pagemap", O_RDONLY);
if(fd == -1)
{
printf("open /proc/self/pagemap error\n");
return NULL;
}
if(lseek(fd, v_offset, SEEK_SET) == -1)
{
printf("sleek error\n");
return NULL;
}
if(read(fd, &item, sizeof(uint64_t)) != sizeof(uint64_t))
{
printf("read item error\n");
return NULL;
}
if((((uint64_t)1 << 63) & item) == 0)
{
printf("page present is 0\n");
return NULL;
}
uint64_t phy_pageIndex = (((uint64_t)1 << 55) - 1) & item;
return (unsigned long)((phy_pageIndex * pageSize) + page_offset);
}
這段代碼可以在用戶空間將一個虛擬內存地址轉換為一個物理地址,具體原理就不介紹了,感興趣的請參考附錄 2。用它做個小測試:
void test_ptr(void *ptr, char const* prompt)
{
uint64_t addr = virtual2physical(ptr);
printf ("%s: virtual: 0x%x, physical: 0x%x\n", prompt, ptr, addr);
}
int g_val1=0;
int g_val2=1;
int main(void) {
test_ptr(&g_val1, "global1");
test_ptr(&g_val2, "global2");
int l_val3=2;
int l_val4=3;
test_ptr(&l_val3, "local1");
test_ptr(&l_val4, "local2");
static int s_val5=4;
static int s_val6=5;
test_ptr(&s_val5, "static1");
test_ptr(&s_val6, "static2");
int *h_val7=(int*)malloc(sizeof(int));
int *h_val8=(int*)malloc(sizeof(int));
test_ptr(h_val7, "heap1");
test_ptr(h_val8, "heap2");
free(h_val7);
free(h_val8);
return 0;
};
測試種類還是比較豐富的,有局部變量、靜態變量、全局變量和堆上分配的變量。在 CentOS 上有以下輸出:
> sudo ./memtrans
global1: virtual: 0x60107c, physical: 0x8652f07c
global2: virtual: 0x60106c, physical: 0x8652f06c
local1: virtual: 0x9950ff2c, physical: 0xfb1df2c
local2: virtual: 0x9950ff28, physical: 0xfb1df28
static1: virtual: 0x601070, physical: 0x8652f070
static2: virtual: 0x601074, physical: 0x8652f074
heap1: virtual: 0xc3e010, physical: 0xb7ebe010
heap2: virtual: 0xc3e030, physical: 0xb7ebe030
發現以下幾點:
- 同類型的變量虛擬、物理地址相差不大
- 靜態和全局變量虛擬地址相近、物理地址也相近,很可能是分配在同一個頁上了
- 局部、全局、堆上的變量虛擬地址相差較大、物理地址也相差較大,應該是分配在不同的頁上了
必需使用超級用戶權限執行這段程序,否則看起來不是那么正確:
> ./memtrans
global1: virtual: 0x60107c, physical: 0x7c
global2: virtual: 0x60106c, physical: 0x6c
local1: virtual: 0x6a433e2c, physical: 0xe2c
local2: virtual: 0x6a433e28, physical: 0xe28
static1: virtual: 0x601070, physical: 0x70
static2: virtual: 0x601074, physical: 0x74
heap1: virtual: 0x116b010, physical: 0x10
heap2: virtual: 0x116b030, physical: 0x30
雖然 virtual2physical 沒有報錯,但是一眼看上去這個結果就是有問題的。能將虛擬地址轉化為物理地址后,就可以拿它在 fork 的場景中做個驗證:
int g_count = 1;
int main()
{
int v_count = 42;
static int s_count = 1024;
int* h_count = (int*)malloc (sizeof (int));
*h_count = 36;
printf ("%d: global ptr 0x%x:0x%x, local ptr 0x%x:0x%x, static ptr 0x%x:0x%x, heap ptr 0x%x:0x%x\n", getpid(),
&g_count, virtual2physical(&g_count),
&v_count, virtual2physical(&v_count),
&s_count, virtual2physical(&s_count),
h_count, virtual2physical(h_count));
int pid = fork();
if (pid < 0)
{
// error
exit(1);
}
else if (pid == 0)
{
// child
printf ("%d spawn from %d\n", getpid(), getppid());
#if 0
g_count ++;
v_count ++;
s_count ++;
(*h_count) ++;
#endif
}
else
{
// parent
sleep (1);
printf ("%d create %d\n", getpid(), pid);
}
printf ("%d: global %d, local %d, static %d, heap %d\n", getpid(), g_count, v_count, s_count, *h_count);
printf ("%d: global ptr 0x%x:0x%x, local ptr 0x%x:0x%x, static ptr 0x%x:0x%x, heap ptr 0x%x:0x%x\n", getpid(),
&g_count, virtual2physical(&g_count),
&v_count, virtual2physical(&v_count),
&s_count, virtual2physical(&s_count),
h_count, virtual2physical(h_count));
return 0;
}
增加了對虛擬、物理地址的打印,并屏蔽了子進程對變量的修改,先看看父、子進程是否共享了內存頁:
> sudo ./forkit
19216: global ptr 0x60208c:0x5769308c, local ptr 0x22c50040:0xf4fe2040, static ptr 0x602090:0x57693090, heap ptr 0x1e71010:0x89924010
19217 spawn from 19216
19217: global 1, local 42, static 1024, heap 36
19217: global ptr 0x60208c:0x5769308c, local ptr 0x22c50040:0xf4fe2040, static ptr 0x602090:0x57693090, heap ptr 0x1e71010:0x89924010
19216 create 19217
19216: global 1, local 42, static 1024, heap 36
19216: global ptr 0x60208c:0x412f308c, local ptr 0x22c50040:0xea994040, static ptr 0x602090:0x412f3090, heap ptr 0x1e71010:0x89924010
發現以下現象:
- 所有變量虛擬地址不變
- 僅堆變量的物理地址不變
- 子進程所有變量的物理地址不變,父進程局部、靜態、全局變量的物理地址發生了改變
從現象可以得到以下結論:
- COW 生效,否則堆變量的物理地址不可能不變
- 局部、靜態、全局變量的物理地址發生改變很可能是因為該頁上有其它數據發生了變更需要復制
- 率先復制的那一方物理地址會發生變更
下面再看下子進程修改變量的情況:
> sudo ./forkit
23182: global ptr 0x60208c:0x1037008c, local ptr 0x677e8540:0xe65b6540, static ptr 0x602090:0x10370090, heap ptr 0x252d010:0x9fb3d010
23183 spawn from 23182
23183: global 2, local 43, static 1025, heap 37
23183: global ptr 0x60208c:0x1037008c, local ptr 0x677e8540:0xe65b6540, static ptr 0x602090:0x10370090, heap ptr 0x252d010:0x6dafb010
23182 create 23183
23182: global 1, local 42, static 1024, heap 36
23182: global ptr 0x60208c:0xf045708c, local ptr 0x677e8540:0x5bc6f540, static ptr 0x602090:0xf0457090, heap ptr 0x252d010:0x9fb3d010
這下所有變量的物理地址都改變了,進一步驗證了 COW 的介入,特別是子進程堆變量物理地址改變 (0x6dafb010) 而父進程的沒有改變 (0x9fb3d010),說明系統確實為修改頁的一方分配了新的頁。另一方面,子進程修改了局部、靜態、全局變量而物理地址沒有發生改變,則說明當頁不再標記為共享后,子進程再修改這些頁也不會為它重新分配頁了。最后父進程沒有修改局部、靜態、全局變量而物理地址發生了變化,一定是這些變量所在頁的其它部分被修改導致的,且這些修改發生在用戶修改這些變量之前,即 fork 內部。
vfork
另外一種提高 fork 性能的方法是 vfork:
#include <unistd.h>
pid_t vfork(void);
它的聲明與 fork 完全一致,用法也差不多,但是卻有以下根本不同:
- 父、子進程并不進行任何數據段、棧、堆的復制,連 COW 都沒有,完全是共享同樣的內存空間
- 父進程只有在子進程調用 exec 或 exit 之后才能繼續運行
vfork 是面向 fork+exec 使用場景的優化,所以在 exec (或 exit) 之前,子進程就是在父進程的地址空間運行的。而為了避免父、子進程訪問同一個內存頁導致的競爭問題,父進程在此期間會被短暫掛起,預期子進程會立刻調用 exec,所以這個延遲還是可以接受的。修改上面的 forkit 代碼:
#if 0
int pid = fork();
#else
int pid = vfork();
#endif
使用 vfork 代替 fork,再來觀察結果有何不同:
> sudo ./forkit
15421: global ptr 0x60208c:0x9f6d608c, local ptr 0x91d548c0:0xa98148c0, static ptr 0x602090:0x9f6d6090, heap ptr 0x1cc1010:0xf3a5c010
15422 spawn from 15421
15422: global 2, local 43, static 1025, heap 37
15422: global ptr 0x60208c:0x9f6d608c, local ptr 0x91d548c0:0xa98148c0, static ptr 0x602090:0x9f6d6090, heap ptr 0x1cc1010:0xf3a5c010
15421 create 15422
Segmentation fault
子進程運行正常而父進程在 fork 返回后崩潰了,打開 gdb 掛上 coredmp 文件查看:
> sudo gdb ./forkit --core=core.15421
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-120.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /ext/code/apue/08.chapter/forkit...done.
[New LWP 15421]
Core was generated by `./forkit'.
Program terminated with signal 11, Segmentation fault.
#0 0x0000000000400ace in main () at forkit.c:90
90 printf ("%d: global %d, local %d, static %d, heap %d\n", getpid(), g_count, v_count, s_count, *h_count);
Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.x86_64
(gdb) i lo
v_count = 43
s_count = 1025
h_count = 0x0
pid = 15422
(gdb)
因為生成的 core 文件具有 root 權限,所以這里也使用 sudo 提權。打印本地變量查看,發現 h_count 指針為空了,導致 printf 崩潰。再看 vfork 的使用說明,發現有下面這么一段:
vfork() differs from fork(2) in that the calling thread is suspended until the child terminates (either normally, by calling
_exit(2), or abnormally, after delivery of a fatal signal), or it makes a call to execve(2). Until that point, the child shares all
memory with its parent, including the stack. The child must not return from the current function or call exit(3), but may call
_exit(2).
大意是說因 vfork 后子進程甚至會共享父進程執行堆棧,所以子進程不能通過 return 和 exit 退出,只能通過 _exit。嘖嘖,一不小心就踩了坑,修改代碼如下:
if (pid < 0)
{
// error
exit(1);
}
else if (pid == 0)
{
// child
printf ("%d spawn from %d\n", getpid(), getppid());
#if 1
g_count ++;
v_count ++;
s_count ++;
(*h_count) ++;
#endif
printf ("%d: global %d, local %d, static %d, heap %d\n", getpid(), g_count, v_count, s_count, *h_count);
printf ("%d: global ptr 0x%x:0x%x, local ptr 0x%x:0x%x, static ptr 0x%x:0x%x, heap ptr 0x%x:0x%x\n", getpid(),
&g_count, virtual2physical(&g_count),
&v_count, virtual2physical(&v_count),
&s_count, virtual2physical(&s_count),
h_count, virtual2physical(h_count));
_exit(0);
}
else
{
// parent
// sleep (1);
printf ("%d create %d\n", getpid(), pid);
}
主要修改點如下:
- 打印語句復制一份到子進程
- 子進程通過 _exit 退出
- 父進程去除 sleep 調用
再次編譯運行:
> sudo ./forkit
22831: global ptr 0x60208c:0xde9ee08c, local ptr 0x9c8a3ac0:0x2661dac0, static ptr 0x602090:0xde9ee090, heap ptr 0x1a90010:0x88797010
22832 spawn from 22831
22832: global 2, local 43, static 1025, heap 37
22832: global ptr 0x60208c:0xde9ee08c, local ptr 0x9c8a3ac0:0x2661dac0, static ptr 0x602090:0xde9ee090, heap ptr 0x1a90010:0x88797010
22831 create 22832
22831: global 2, local 43, static 1025, heap 37
22831: global ptr 0x60208c:0xde9ee08c, local ptr 0x9c8a3ac0:0x2661dac0, static ptr 0x602090:0xde9ee090, heap ptr 0x1a90010:0x88797010
這回不崩潰了,而且可以看到以下有趣的現象:
- 父進程的所有變量都被子進程修改了
- 父、子進程的所有變量虛擬、物理地址完全一致
進一步印證了上面的結論。由于 vfork 根本不存在內存空間的復制,所以理論上它是性能最高的,畢竟 COW 在底層還是發生了很多內存頁復制的。
vfork 這個接口是屬于 SUS 標準的,目前流行的 Unix 都支持,只不過它被標識為了廢棄,使用時需要小心,尤其是處理子進程的退出。
fork + fd
子進程會繼承父進程以下屬性:
- 打開文件描述符
- 實際用戶 ID、實際組 ID、有效用戶 ID、有效組 ID
- 附加組 ID
- 進程組 ID
- 會話 ID
- 控制終端
- 設置用戶 ID 標志和設置組 ID 標志
- 當前工作目錄
- 根目錄
- 文件模式創建屏蔽字
- 信號屏蔽和安排
- 打開文件描述符的 close-on-exec 標志
- 環境變量
- 連接的共享存儲段
- 存儲映射
- 資源限制
- ……
以打開文件描述符為例,有如下測試程序:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main()
{
printf ("before fork\n");
int pid = fork();
if (pid < 0)
{
// error
exit(1);
}
else if (pid == 0)
{
// child
printf ("%d spawn from %d\n", getpid(), getppid());
}
else
{
// parent
sleep (1);
printf ("%d create %d\n", getpid(), pid);
}
printf ("after fork\n");
return 0;
}
運行程序輸出如下:
> ./forkfd
before fork
7204 spawn from 7203
after fork
7203 create 7204
after fork
before fork 只在父進程輸出一次,符合預期,如果在 main 函數第一行插入以下代碼:
setvbuf (stdout, NULL, _IOFBF, 0);
將標準輸出設置為全緩沖模式,(關于標準 IO 的緩沖模式,可以參考這篇《[apue] 標準 I/O 庫那些事兒 》),則輸出會發生改變:
> ./forkfd
before fork
6955 spawn from 6954
after fork
before fork
6954 create 6955
after fork
可以看到 before fork 這條語句輸出了兩次,分別在父、子進程各輸出一次,這是由于 stdout 由行緩沖變更為全緩沖后,積累的內容并不隨換行符 flush,從而就會被 fork 復制到子進程,并與子進程生成的信息一起 flush 到控制臺,最終輸出兩次。如果仍保持行緩沖模式,還會導致多次輸出嗎?答案是有可能,只要將上面的換行符去掉就可以:
printf ("before fork ");
新的輸出如下:
> ./forkfd
before fork 17736 spawn from 17735
after fork
before fork 17735 create 17736
after fork
原理是一樣的。其實還存在另外的隱式修改標準輸出緩沖方式的辦法:文件重定向,仍以有換行符的版本為例:
> ./forkfd > output.txt
> cat output.txt
before fork
15505 spawn from 15504
after fork
before fork
15504 create 15505
after fork
通過將標準輸出重定向到 output.txt 文件,實現了行緩沖到全緩沖的變化,從而得到了與調用 setvbuf 相同的結果。使用不帶緩沖的 write、或者在 fork 前主動 flush 緩沖,以避免上面的問題。
除了緩存復制,父、子進程共享打開文件描述符的另外一個問題是讀寫競爭,fork 后父、子進程共享文件句柄的情況如下圖 (參考《[apue] 一圖讀懂 unix 文件句柄及文件共享過程 》):

父、子進程共享文件句柄特別像進程內 dup 的情況,此時對于共享的雙方而言,任一進程更新文件偏移量對另一個進程都是可見的,保證了一個進程添加的數據會在另一個進程之后。但如果不做任何同步,它們的數據會相互混合,從而使輸出變得混亂。一般遵循以下慣例來保證父、子進程不會在共享的文件句柄上產生讀寫競爭:
- 父進程等待子進程完成
- 父、子進程各自執行不同的程序段 (關閉各自不需要使用的文件描述符)
如果必需使用共享的文件句柄,則需要引入進程間同步機制來解決讀寫沖突,關于這一點,可以參考后續 "父子進程同步" 的文章。
在上一節介紹 vfork 時,了解到它是不復制進程空間的,子進程需要保證在退出時使用 _exit 來清理進程,避免 return 語句破壞棧指針。這里有個疑問,如果使用 exit 代替上例中的 _exit 會如何呢?修改上面的程序進行驗證:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main()
{
setvbuf (stdout, NULL, _IOFBF, 0);
printf ("before fork\n");
int pid = vfork();
if (pid < 0)
{
// error
exit(1);
}
else if (pid == 0)
{
// child
printf ("%d spawn from %d\n", getpid(), getppid());
exit(0);
}
else
{
// parent
printf ("%d create %d\n", getpid(), pid);
}
printf ("after fork\n");
return 0;
}
發現父進程可以正常終止:
> ./forkfd
before fork
25923 spawn from 25922
25922 create 25923
after fork
_exit 是不會做任何清理工作的,所以是安全的;exit 至少會 flush 標準 IO,至于是否關閉它們則沒有標準明確的要求這一點,由各個實現自行決定。如果 exit 關閉了標準 IO,那么父進程一定無法輸出 after fork 這句,可見 CentOS 上的exit 沒有關閉標準 IO。目前大多數系統的 exit 實現不在這方面給自己找麻煩,畢竟進程結束時系統會自動關閉進程打開的所有文件句柄,在庫中關閉它們,只是增加了開銷而不會帶來任何益處。
apue 原文講,即使 exit 關閉了標準 IO,STDOUT_FILENO 句柄還是可用的,通過 write 仍可以正常輸出,子進程關閉自己的標準 IO 句柄并不影響父進程的那一份,對此進行驗證:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main()
{
printf ("before fork\n");
char buf[128] = { 0 };
int pid = vfork();
if (pid < 0)
{
// error
exit(1);
}
else if (pid == 0)
{
// child
printf ("%d spawn from %d\n", getpid(), getppid());
fclose (stdin);
fclose (stdout);
fclose (stderr);
exit(0);
}
else
{
// parent
sprintf (buf, "%d create %d\n", getpid(), pid);
write (STDOUT_FILENO, buf, strlen(buf));
}
sprintf (buf, "after fork\n");
write (STDOUT_FILENO, buf, strlen(buf));
return 0;
}
主要修改點有三處:
- 去除標準輸出重定向
- 在 child exit 前主動關閉標準 IO 庫
- 在 parent vfork 返回后,使用 write 代替 printf 打印日志
新的輸出如下:
> ./forkfd
before fork
20910 spawn from 20909
20909 create 20910
after fork
和書上說的一致,看來關閉標準 IO 庫只影響父進程的 printf 調用,不影響 write 調用。再試試直接關閉文件句柄:
close (STDIN_FILENO);
close (STDOUT_FILENO);
close (STDERR_FILENO);
新的輸出如下:
> ./forkfd
before fork
17462 spawn from 17461
17461 create 17462
after fork
仍然沒有影響!看起來 vfork 子進程雖然沒有復制任何父進程空間的內容,但句柄仍是做了 dup 的,所以關閉子進程的任何句柄,對父進程沒有影響。
標準 IO (stdin/stdout/stderr) 還和文件句柄不同,它們帶有一些額外信息例如緩存等是存儲在堆或棧上的,如果 vfork 后子進程的 exit 關閉了它們,父進程是會受到影響的,這進一步反證了 exit 不會關閉標準 IO。
關于子進程繼承父進程的其它屬性,這里就不一一驗證了,有興趣的讀者可以自行構造 demo。最后補充一下 fork 后子進程與父進程不同的屬性:
- fork 返回值
- 進程 ID
- 父進程 ID
- 子進程的 CPU 時間 (tms_utime / tms_stime / tms_cutime / tms_ustime 均置為 0)
- 文件鎖不會繼承
- 未處理的鬧鐘 (alarm) 將被清除
- 未處理的信號集將設置為空
- ……
clone
在 fork 的全復制和 vfork 全不復制之間,有沒有一個接口可以自由定制進程哪些信息需要復制?答案是 clone,不過這個是 Linux 特有的:
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack,int flags, void *arg, .../* pid_t *ptid, void *newtls, pid_t *ctid */ );
與 fork 不同,clone 子進程啟動時將運行用戶提供的 fn(arg) ,并且需要用戶提前開辟好棧空間 (child_stack),而控制各種信息共享就是通過 flags 參數了,下面列一些主要的控制參數:
- CLONE_FILES:是否共享文件句柄
- CLONE_FS:是否共享文件系統相關信息,這些信息由 chroot、chdir、umask 指定
- CLONE_NEWIPC:是否共享 IPC 命名空間
- CLONE_PID:是否共享 PID
- CLONE_SIGHAND:是否共享信號處理
- CLONE_THREAD:是否共享相同的線程組
- CLONE_VFORK:是否在子進程 exit 或 execve 之前掛起父進程
- CLONE_VM:是否共享同一地址空間
- ……
其實 glibc clone 底層依賴的 clone 系統調用 (sys_clone) 接口更接近于 fork 系統調用,glibc 僅僅是在 sys_clone 的子進程返回中調用用戶提供的 fn(arg) 而已。它將 fork 中的各種進程信息是否共享的決定權交給了用戶,所以有更大的靈活性,甚至可以基于 clone 實現用戶態線程庫。上一節中說 vfork 后子進程在退出時可以關閉 STDOUT_FILENO 而不影響父進程,這是因為標準 IO 句柄是經過 vfork dup 的,如果使用 clone 并指定共享父進程的文件句柄 (CLONE_FILES) 會如何?下面寫個例子進行驗證:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
int child_func(void *arg)
{
// child
printf ("%d spawned from %d\n", getpid(), getppid());
return 1;
}
int main()
{
printf ("before fork\n");
size_t stack_size = 1024 * 1024;
char *stack = (char *)malloc (stack_size);
int pid = clone(child_func, stack+stack_size, CLONE_VM | CLONE_VFORK | SIGCHLD, 0);
if (pid < 0)
{
// error
exit(1);
}
// parent
printf ("[1] %d create %d\n", getpid(), pid);
char buf[128] = { 0 };
sprintf (buf, "[2] %d create %d\n", getpid(), pid);
write (STDOUT_FILENO, buf, strlen(buf));
return 0;
}
先演示下不加 CLONE_FILES 的效果:
> ./clonefd
before fork
1271 spawned from 1270
[1] 1270 create 1271
[2] 1270 create 1271
這個和 vfork 效果相同。這里為了驗證標準 IO 庫被關閉的情況,父進程最后一句日志使用兩種方法打印,能輸出兩行就證明標準 IO 和底層句柄都沒有被關閉,不同的方法使用前綴數字進行區別。
clone 在這個場景的使用有幾點需要注意:
- 至少需要為 clone 指定 CLONE_VM 選項,用于父、子進程共享內存地址空間
- 指定的 stack 地址是開辟內存地址的末尾,因為棧是向上增長的,剛開始 child 進程一啟動就掛掉,就是這里沒設置對
- 指定 CLONE_VFORK 標記,這樣父進程會在子進程退出后才繼續運行,避免了多余的 sleep
在子進程關閉標準 IO 庫嘗試:
> ./clonefd
before fork
5433 spawned from 5432
[2] 5432 create 5433
父進程的 printf 不工作但 write 可以工作,符合預期。在子進程關閉 STDOUT_FILENO 嘗試:
> ./clonefd
before fork
11688 spawned from 11687
[1] 11687 create 11688
[2] 11687 create 11688
兩個都能打印,證實了 fd 是經過 dup 的,與之前 vfork 的結果完全一致。下面為 clone 增加一個共享文件描述表的設置:
int pid = clone(child_func, stack+stack_size, CLONE_VM | CLONE_VFORK | CLONE_FILES | SIGCHLD, 0);
再運行上面兩個用例:
> ./clonefd
before fork
8676 spawned from 8675
兩個場景父進程的 printf 與 write 都不輸出了,但是原理稍有差別,前者是因為關閉標準 IO 對象后底層的句柄也被關閉了;后者是雖然標準 IO 對象雖然還打開著,但底層的句柄已經失效了,所以也無法輸出信息。
clone 雖然強大但不具備可移植性,唯一與它類似的是 FreeBSD 上的 rfork。
fork + pthread
fork 并不復制進程的線程信息,請看下例:
#include "../apue.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <pthread.h>
#include <errno.h>
static void* thread_start (void *arg)
{
printf ("thread start %lu\n", pthread_self ());
sleep (2);
printf ("thread exit %lu\n", pthread_self ());
return 0;
}
int main (int argc, char *argv[])
{
int ret = 0;
pthread_t tid = 0;
ret = pthread_create (&tid, NULL, &thread_start, NULL);
if (ret != 0)
err_sys ("pthread_create");
pid_t pid = 0;
if ((pid = fork ()) < 0)
err_sys ("fork error");
else if (pid == 0)
{
printf ("[%u] child running, thread %lu\n", getpid(), pthread_self());
sleep (3);
}
else
{
printf ("fork and exec child %u in thread %lu\n", pid, pthread_self());
sleep (4);
}
exit (0);
}
做個簡單說明:
- 父進程啟動一個線程 (thread_start)
- 線程啟動后休眠 2 秒
- 父進程啟動一個子進程,子進程啟動后休眠 3 秒后退出
- 父進程休眠 4 秒后退出
執行程序有如下輸出:
> ./fork_pthread
fork and exec child 9825 in thread 140542546036544
thread start 140542537676544
[9825] child running, thread 140542546036544
thread exit 140542537676544
> ./fork_pthread
fork and exec child 28362 in thread 139956664842048
[28362] child running, thread 139956664842048
thread start 139956656482048
thread exit 139956656482048
注意這個 threadid,長長的一串首尾相同,容易讓人誤認為是同一個 thread,實際上兩個是不同的,體現在中間的差異,以第二次執行的輸出為例,一個是 6484,另一個是 5648,猛的一眼看上去不容易看出來,坑爹~
兩次運行線程的啟動和子進程的啟動順序有別,但結果都是一樣的,子進程沒有觀察到線程的退出日志,從而可以斷定沒有復制父進程的線程信息。對上面的例子稍加改造,看看在線程中 fork 子進程會如何:
#include "../apue.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <pthread.h>
#include <errno.h>
static void* thread_start (void *arg)
{
printf ("thread start %lu\n", pthread_self ());
pid_t pid = 0;
if ((pid = fork ()) < 0)
err_sys ("fork error");
else if (pid == 0)
{
printf ("[%u] child running, thread %lu\n", getpid(), pthread_self());
sleep (3);
}
else
{
printf ("fork and exec child %u in thread %lu\n", pid, pthread_self());
sleep (2);
}
printf ("thread exit %lu\n", pthread_self ());
return 0;
}
int main (int argc, char *argv[])
{
int ret = 0;
pthread_t tid = 0;
ret = pthread_create (&tid, NULL, &thread_start, NULL);
if (ret != 0)
err_sys ("pthread_create");
sleep (4);
printf ("main thread exit %lu\n", pthread_self());
exit (0);
}
重新執行:
> ./fork_pthread
thread start 139848844396288
fork and exec child 17141 in thread 139848844396288
[17141] child running, thread 139848844396288
thread exit 139848844396288
thread exit 139848844396288
main thread exit 139848852756288
發現這次只復制了新線程 (4439),沒有復制主線程 (5275),仍然是不完整的。不過 POSIX 語義本來如此:只復制 fork 所在的線程,如果想復制進程的所有線程信息,目前僅有 Solaris 系統能做到,而且只對 Solaris 線程有效,POSIX 線程仍保持只復制一個的語義。而為了和 POSIX 語義一致 (即只復制一個 Solaris 線程),它特意推出了 fork1 接口干這件事,看來復制全部線程反而是個小眾需求。
exec
exec 函數族并不創建新的進程,只是用一個全新的程序替換了當前進程的正文、數據、堆和棧段,所以調用前后進程 ID 并不改變。函數族共包含六個原型:
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *file, char *const argv[], char *const envp[]);
不同的后綴有不同的含義:
- l:使用可變參數列表傳遞新程序參數 (list),一般需要配合 va_arg / va_start / va_end 來提取參數
- v:與 l 參數相反,使用參數數組傳遞新程序參數 (vector)
- p:傳遞程序文件名而非路徑,如果 file 參數不包含 / 字符,則在 PATH 環境變量中搜索可執行文件
- e:指定環境變量數組 envp 參數而不是默認的 environ 變量作為新程序的環境變量
書上有個圖很好的解釋了它們的之間的關系:

做個簡單說明:
- 所有 l 后綴的接口,將參數列表提取為數組后調用 v 后綴的接口
- execvp 在 PATH 環境變量中查找可執行文件,確認新程序路徑后調用 execv
- execv 使用 environ 全局變量作為 envp 參數調用 execve
百川入海,execve 就是最終被調用的那個,實際上它是一個系統調用,而其它 5 個都是庫函數。上面就是 exec 函數族的主要關系,還有一些細節需要注意,下面分別說明。
路徑搜索
帶 p 后綴的函數在搜索 PATH 環境變量時,會依據分號(:)分隔多個路徑字段,例如
> echo $PATH
/bin:/usr/bin:/usr/local/bin:.
包含了四個路徑,按順序分別是
- /bin
- /usr/bin
- /usr/local/bin
- 當前目錄
其中當前目錄的表示方式有多種,除了顯示指定點號外,還可以
- 放置在最前
PATH=:/bin:/usr/bin:/usr/local/bin - 放置在最后
PATH=/bin:/usr/bin:/usr/local/bin: - 放置在中間
PATH=/bin::/usr/bin:/usr/local/bin
當然了,不同的位置搜索優先級也不同,并且也不建議將當前路徑放置在 PATH 環境變量中。
參數列表
帶 l 后綴的函數,以空指針作為參數列表的結尾,像下面這個例子
if (execlp("echoall", "echoall", "test", (char *)0) < 0)
err_sys ("execlp error");
如果使用常數 0,必需使用 char* 進行強制轉換,否則它將被解釋為整型參數,在整型長度與指針長度不同的平臺上, exec 函數的實際參數將會出錯。
帶 v 后綴的函數,也需要保證數組以空指針結尾,無論是 argv 還是 envp,最終都會被新程序的 main 函數接收,所以要求與 main 函數參數相同 (參考《[apue] 進程環境那些事兒》),它們的 man 手冊頁中也有明確說明:
The execv(), execvp(), and execvpe() functions provide an array of pointers to null-terminated
strings that represent the argument list available to the new program. The first argument, by con‐
vention, should point to the filename associated with the file being executed. The array of pointers
must be terminated by a NULL pointer.
配合 execve 的 man 內容閱讀:
argv is an array of argument strings passed to the new program. By convention, the first of these
strings should contain the filename associated with the file being executed. envp is an array of
strings, conventionally of the form key=value, which are passed as environment to the new program.
Both argv and envp must be terminated by a NULL pointer. The argument vector and environment can be
accessed by the called program's main function, when it is defined as:
int main(int argc, char *argv[], char *envp[])
像附錄 8 那樣沒有給 argv 參數以空指針結尾帶來的問題就很好理解了。
參數列表中的第一個參數一般指定為程序文件名,但這只是一種慣例,并無任何強制校驗。每個系統對命令行參數和環境變量參數的總長度都有一個限制,通過sysconf(ARG_MAX)可獲取:
> getconf ARG_MAX
2097152
POSIX 規定此值不得小于 4096,當使用 shell 的文件名擴充功能 (*) 產生一個文件列表時,可能會超過這個限制從而被截斷,為避免產生這種問題,可借助 xargs 命令將長參數拆分成幾部分傳遞,書上給了一個查找 man 手冊中所有的 getrlimit 的例子:
查看代碼
> zgrep getrlimit /usr/share/man/*/*.gz
/usr/share/man/man0p/sys_resource.h.0p.gz:for the \fIresource\fP argument of \fIgetrlimit\fP() and \fIsetrlimit\fP():
/usr/share/man/man0p/sys_resource.h.0p.gz:int getrlimit(int, struct rlimit *);
/usr/share/man/man0p/sys_resource.h.0p.gz:\fIgetrlimit\fP()
/usr/share/man/man1/g++.1.gz:\&\s-1RAM \s0>= 1GB. If \f(CW\*(C`getrlimit\*(C'\fR is available, the notion of \*(L"\s-1RAM\*(R"\s0 is
/usr/share/man/man1/gcc.1.gz:\&\s-1RAM \s0>= 1GB. If \f(CW\*(C`getrlimit\*(C'\fR is available, the notion of \*(L"\s-1RAM\*(R"\s0 is
/usr/share/man/man1/perl561delta.1.gz:offers the getrlimit/setrlimit interface that can be used to adjust
/usr/share/man/man1/perl56delta.1.gz:offers the getrlimit/setrlimit interface that can be used to adjust
/usr/share/man/man1/perlhpux.1.gz: truncate, getrlimit, setrlimit
/usr/share/man/man2/brk.2.gz:.BR getrlimit (2),
/usr/share/man/man2/execve.2.gz:.BR getrlimit (2))
/usr/share/man/man2/fcntl.2.gz:.BR getrlimit (2)
/usr/share/man/man2/getpriority.2.gz:.BR getrlimit (2)
/usr/share/man/man2/getrlimit.2.gz:.\" 2004-11-16 -- mtk: the getrlimit.2 page, which formally included
/usr/share/man/man2/getrlimit.2.gz:getrlimit, setrlimit, prlimit \- get/set resource limits
/usr/share/man/man2/getrlimit.2.gz:.BI "int getrlimit(int " resource ", struct rlimit *" rlim );
/usr/share/man/man2/getrlimit.2.gz:.BR getrlimit ()
/usr/share/man/man2/getrlimit.2.gz:.BR getrlimit ()
/usr/share/man/man2/getrlimit.2.gz:.BR getrlimit ().
/usr/share/man/man2/getrlimit.2.gz:.BR getrlimit ().
/usr/share/man/man2/getrlimit.2.gz:.BR getrlimit (),
/usr/share/man/man2/getrlimit.2.gz:.\" getrlimit() and setrlimit() that use prlimit() to work around
/usr/share/man/man2/getrusage.2.gz:.\" 2004-11-16 -- mtk: the getrlimit.2 page, which formerly included
/usr/share/man/man2/getrusage.2.gz:.\" history, etc., see getrlimit.2
/usr/share/man/man2/getrusage.2.gz:.BR getrlimit (2),
/usr/share/man/man2/madvise.2.gz:.BR getrlimit (2),
/usr/share/man/man2/mremap.2.gz:.BR getrlimit (2),
/usr/share/man/man2/prlimit.2.gz:.so man2/getrlimit.2
/usr/share/man/man2/quotactl.2.gz:.BR getrlimit (2),
/usr/share/man/man2/sched_setscheduler.2.gz:.BR getrlimit (2))
/usr/share/man/man2/sched_setscheduler.2.gz:.BR getrlimit (2)).
/usr/share/man/man2/sched_setscheduler.2.gz:.BR getrlimit (2)
/usr/share/man/man2/sched_setscheduler.2.gz:.BR getrlimit (2).
/usr/share/man/man2/setrlimit.2.gz:.so man2/getrlimit.2
/usr/share/man/man2/syscalls.2.gz:\fBgetrlimit\fP(2) 1.0
/usr/share/man/man2/syscalls.2.gz:\fBugetrlimit\fP(2) 2.4
/usr/share/man/man2/syscalls.2.gz:.BR getrlimit (2)
/usr/share/man/man2/syscalls.2.gz:.IR sys_old_getrlimit ()
/usr/share/man/man2/syscalls.2.gz:.IR __NR_getrlimit )
/usr/share/man/man2/syscalls.2.gz:.IR sys_getrlimit ()
/usr/share/man/man2/syscalls.2.gz:.IR __NR_ugetrlimit ).
/usr/share/man/man2/ugetrlimit.2.gz:.so man2/getrlimit.2
/usr/share/man/man3/getdtablesize.3.gz:.BR getrlimit (2);
/usr/share/man/man3/getdtablesize.3.gz:.BR getrlimit (2)
/usr/share/man/man3/getdtablesize.3.gz:.BR getrlimit (2),
/usr/share/man/man3/malloc.3.gz:.BR getrlimit (2)).
/usr/share/man/man3/pcrestack.3.gz: getrlimit(RLIMIT_STACK, &rlim);
/usr/share/man/man3/pcrestack.3.gz:This reads the current limits (soft and hard) using \fBgetrlimit()\fP, then
/usr/share/man/man3p/exec.3p.gz:\fIgetenv\fP(), \fIgetitimer\fP(), \fIgetrlimit\fP(), \fImmap\fP(),
/usr/share/man/man3p/fclose.3p.gz:\fIclose\fP(), \fIfopen\fP(), \fIgetrlimit\fP(), \fIulimit\fP(),
/usr/share/man/man3p/fflush.3p.gz:\fIgetrlimit\fP(), \fIulimit\fP(), the Base Definitions volume of
/usr/share/man/man3p/fputc.3p.gz:\fIferror\fP(), \fIfopen\fP(), \fIgetrlimit\fP(), \fIputc\fP(),
/usr/share/man/man3p/fseek.3p.gz:\fIgetrlimit\fP(), \fIlseek\fP(), \fIrewind\fP(), \fIulimit\fP(),
/usr/share/man/man3p/getrlimit.3p.gz:.\" getrlimit
/usr/share/man/man3p/getrlimit.3p.gz:getrlimit, setrlimit \- control maximum resource consumption
/usr/share/man/man3p/getrlimit.3p.gz:int getrlimit(int\fP \fIresource\fP\fB, struct rlimit *\fP\fIrlp\fP\fB);
/usr/share/man/man3p/getrlimit.3p.gz:The \fIgetrlimit\fP() function shall get, and the \fIsetrlimit\fP()
/usr/share/man/man3p/getrlimit.3p.gz:Each call to either \fIgetrlimit\fP() or \fIsetrlimit\fP() identifies
/usr/share/man/man3p/getrlimit.3p.gz:considered to be larger than any other limit value. If a call to \fIgetrlimit\fP()
/usr/share/man/man3p/getrlimit.3p.gz:When using the \fIgetrlimit\fP() function, if a resource limit can
/usr/share/man/man3p/getrlimit.3p.gz:is unspecified unless a previous call to \fIgetrlimit\fP()
/usr/share/man/man3p/getrlimit.3p.gz:Upon successful completion, \fIgetrlimit\fP() and \fIsetrlimit\fP()
/usr/share/man/man3p/getrlimit.3p.gz:The \fIgetrlimit\fP() and \fIsetrlimit\fP() functions shall fail if:
/usr/share/man/man3p/setrlimit.3p.gz:.so man3p/getrlimit.3p
/usr/share/man/man3/pthread_attr_setstacksize.3.gz:.BR getrlimit (2),
/usr/share/man/man3/pthread_create.3.gz:.BR getrlimit (2),
/usr/share/man/man3/pthread_getattr_np.3.gz:.BR getrlimit (2),
/usr/share/man/man3/pthread_setschedparam.3.gz:.BR getrlimit (2),
/usr/share/man/man3/pthread_setschedprio.3.gz:.BR getrlimit (2),
/usr/share/man/man3p/ulimit.3p.gz:\fIgetrlimit\fP(), \fIsetrlimit\fP(), \fIwrite\fP(), the Base Definitions
/usr/share/man/man3p/write.3p.gz:\fIchmod\fP(), \fIcreat\fP(), \fIdup\fP(), \fIfcntl\fP(), \fIgetrlimit\fP(),
/usr/share/man/man3/ulimit.3.gz:.BR getrlimit (2),
/usr/share/man/man3/ulimit.3.gz:.BR getrlimit (2),
/usr/share/man/man3/vlimit.3.gz:.so man2/getrlimit.2
/usr/share/man/man3/vlimit.3.gz:.\" getrlimit(2) briefly discusses vlimit(3), so point the user there.
/usr/share/man/man5/core.5.gz:.BR getrlimit (2)
/usr/share/man/man5/core.5.gz:.BR getrlimit (2)
/usr/share/man/man5/core.5.gz:.BR getrlimit (2),
/usr/share/man/man5/limits.conf.5.gz:\fBgetrlimit\fR(2)\fBgetrlimit\fR(3p)
/usr/share/man/man5/proc.5.gz:.BR getrlimit (2)).
/usr/share/man/man5/proc.5.gz:.BR getrlimit (2).
/usr/share/man/man5/proc.5.gz:.BR getrlimit (2)).
/usr/share/man/man5/proc.5.gz:.BR getrlimit (2))
/usr/share/man/man7/credentials.7.gz:.BR getrlimit (2);
/usr/share/man/man7/daemon.7.gz:\fBgetrlimit()\fR
/usr/share/man/man7/mq_overview.7.gz:.BR getrlimit (2).
/usr/share/man/man7/mq_overview.7.gz:.BR getrlimit (2),
/usr/share/man/man7/signal.7.gz:.BR getrlimit (2),
/usr/share/man/man7/time.7.gz:.BR getrlimit (2),
我做了兩點改進:
- 使用 zgrep 代替 grep 或 bzgrep 搜索 gz 壓縮文件中的內容
- 使用 /usr/share/man/*/*.gz 代替 */* 過濾子目錄
實測沒有報錯,看起來是因為數據量還不夠大:
$ find /usr/share/man/ -type f -name "*.gz" | wc
9509 9509 361540
總字節大小為 361540 仍小于限制值 2097152。不過還是改成下面的形式更安全:
> find /usr/share/man -type f -name "*.gz" | xargs zgrep getrlimit
xargs 會自動切分參數,確保它們不超過限制,分批“喂”給 zgrep,從而實現參數長度限制的突破,不過這樣做的前提是作業可被切分為多個進程,如果必需由單個進程完成,就不能這樣搞了。
最后,exec 的環境變量與命令行參數有類似的地方:
- 必需以空指針結尾
- 有總長度限制
也有不同之處,那就是不指定 envp 參數時,也可以通過修改當前進程的環境變量,來影響子進程中的環境變量,這主要是通過 setenv、putenv 接口,關于這點請參考《[apue] 進程環境那些事兒》中環境變量一節的說明。
解釋器文件
如果為帶 p 后綴的 exec 指定的文件不是一個由鏈接器產生的可執行文件,則將該文件當作一個腳本文件處理,此時將嘗試調用腳本首行中記錄的解釋器,格式如下:
#! pathname [ optional-argument ]
對這種文件的識別是由內核作為 exec 系統調用處理的一部分來完成的,pathname 通常是路徑名 (絕對 & 相對),并不對它進行路徑搜索。內核使調用 exec 函數的進程實際執行的并不是 file 參數本身,而是腳本第一行中 pathname 所指定的解釋器,例如最常見的:
#!/bin/sh
相當于調用 /bin/sh path/to/script,其中 #! 之后的空格是可選的;如果沒有首行標記,則默認是 shell 腳本;若解釋器需要選項才能支持腳本文件,則需要帶上相應的選項 (optional-argument),例如:
#! /bin/awk -f
最終相當于調用 /bin/awk -f path/to/script。書上有個不錯的例子拿來做個測試:
#! /bin/awk -f
BEGIN {
for (i =0; i<ARGC; i++)
printf "argv[%d]: %s\n", i, ARGV[i]
exit
}
用于打印所有傳遞到 awk 腳本中的命令行參數,執行之:
> ./echoall.awk file1 FILENAME2 f3
argv[0]: awk
argv[1]: file1
argv[2]: FILENAME2
argv[3]: f3
有以下發現:
- 第一個參數是 awk 而不是 echoall.awk
- 沒有參數 -f
和書上講的不同,懷疑是 awk 做了處理 (-f 明顯沒有傳遞到內部的必要),改為自己寫 C 程序版 echoall 驗證:
#include <stdio.h>
int main (int argc, char *argv[])
{
int i;
for (i=0; i<argc; ++ i)
printf ("argv[%d]: %s\n", i, argv[i]);
exit (0);
}
腳本也需要稍微改進一下:
#! ./echoall -f
因為程序已經做了所有工作,這里腳本內容反而只有首行解釋器定義,再次執行:
> ./echoall.sh file1 FILENAME2 f3
argv[0]: ./echoall
argv[1]: -f
argv[2]: ./echoall.sh
argv[3]: file1
argv[4]: FILENAME2
argv[5]: f3
這回有了 -f 選項,并且它會被編排到 exec 函數中 argv 參數列表之前。書上的例子是直接使用 execl 來模擬內核處理解釋器文件的:
#include "../apue.h"
#include <sys/wait.h>
#include <limits.h>
int main (int argc, char *argv[])
{
pid_t pid;
char *exename = "echoall.sh";
char pwd[PATH_MAX] = { 0 };
getcwd(pwd, PATH_MAX);
if (argc > 1)
exename = argv[1];
strcat (pwd, "/");
strcat (pwd, exename);
if ((pid = fork ()) < 0)
err_sys ("fork error");
else if (pid == 0)
{
if (execl (pwd, exename, "file1", "FILENAME2", "f3", (char *)0) < 0)
err_sys ("execl error");
}
if (waitpid (pid, NULL, 0) < 0)
err_sys ("wait error");
exit (0);
}
輸出與上例完全一致:
> ./exec
argv[0]: ./echoall
argv[1]: -f
argv[2]: /ext/code/apue/08.chapter/echoall.sh
argv[3]: file1
argv[4]: FILENAME2
argv[5]: f3
有趣的是 optional-argument (-f) 之后的第一個參數 (argv[2]),execl 使用的是 path 參數 (pwd),而不是參數列表中的第一個參數 (exename):這是因為 path 參數包含了比第一個參數更多的信息,或者說第一個參數是人為指定的,可以傳入任意值,存在較大的隨意性,遠不如 path 參數可靠。
再考查一下多個 optional-argument 的場景:
#! ./echoall -f test foo bar
新的輸出看起來把他們當作了一個:
> ./echoall.sh
argv[0]: ./echoall
argv[1]: -f test foo bar
argv[2]: ./echoall.sh
最多只有一個解釋器參數,這就意味著除了 -f,不能為 awk 指定更多的額外參數,例如 -F 指定分隔符,這一點需要注意。
解釋器首行也有最大長度限制,而且與命令行參數長度限制不是一回事,以上面的腳本為例,設置一個 128 長度的參數:
#! ./echoall aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
實際輸出不到 128:
> ./echoall.sh
argv[0]: ./echoall
argv[1]: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
argv[2]: ./echoall.sh
經查只有 115,算上前面的 #! ./echoall 才剛好 128,可見該限制是施加在整個首行上的,且就是 128 (CentOS)。
最后,解釋器文件只是一種優化而非必需,因為任何系統命令或程序,都可以放在 shell 里執行,使用解釋器文件只是簡化了這一過程、提高了創建進程的效率,如果解釋器因種種原因不可用 (例如一個 optional-argument 不夠用),還可以回退到 shell 腳本的“老路”上來~
close-on-exec
之前說過,exec 后進程 ID 不會改變,除此之外,執行新程序的進程還保持了原進程的以下特征:
- 進程 ID 和父進程 ID
- 實際用戶 ID、實際組 ID
- 附加組 ID
- 進程組 ID
- 會話 ID
- 控制終端
- 鬧鐘剩余時間
- 當前工作目錄
- 根目錄
- 文件模式創建屏蔽字
- 文件鎖
- 信號屏蔽和安排
- 未處理信號
- 資源限制
- tms_utime & time_stime & tms_cutime & tms_cstime (參考進程時間一節)
- ……
一般不會改變的還有打開文件描述符,說一般是因為當設置某些標志位后,描述符將被 exec 關閉,這個標志位就是 close-on-exec (FD_CLOEXEC)。如果設置了該標志,新進程中的 fd 將被關閉,否則保持不變,默認不設置該標志。下面是典型的通過 fcntl 獲取和設置該標志的代碼:
flag = fcntl (fd, F_GETFD);
printf ("fd(%d) flag: 0x%x, CLOSE_ON_EXEC: %d\n", fd, flag, flag & FD_CLOEXEC);
// set CLOSE_ON_EXEC
fcntl (fd, F_SETFD, flag & ~FD_CLOEXEC);
POSIX.1 明確要求在執行 exec 后關閉打開的目錄流,這通常是由 opendir 在內部實現的,它會為對應的描述符設置 close-on-exec 標志。下面這個程序驗證了這一點,并想方設法讓目錄流可以跨 exec 傳遞:
#include "../apue.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <dirent.h>
char *str = 0;
int main (int argc, char *argv[])
{
int fd = 0;
DIR *dir = 0;
int flag = 0;
if (argc > 1)
{
// child mode
// get file descriptor from args
fd = atol(argv[1]);
char *s = argv[2];
flag = fcntl (fd, F_GETFD);
printf ("dir fd(%d) flag: 0x%x, CLOSE_ON_EXEC: %d\n", fd, flag, flag & FD_CLOEXEC);
dir = fdopendir(fd);
printf ("recv dir %d, str %s (total %d)\n", fd, s, argc);
flag = fcntl (fd, F_GETFD);
printf ("dir fd(%d) flag: 0x%x, CLOSE_ON_EXEC: %d\n", fd, flag, flag & FD_CLOEXEC);
rewinddir(dir);
struct dirent *ent = readdir (dir);
printf ("read %p, %s\n", ent, ent ? ent->d_name : 0);
closedir (dir);
}
else
{
str = strdup ("hello world");
dir = opendir (".");
if (dir == NULL)
err_sys ("open .");
else
printf ("open . return %p\n", dir);
fd = dirfd (dir);
flag = fcntl (fd, F_GETFD);
printf ("dir fd(%d) flag: 0x%x, CLOSE_ON_EXEC: %d\n", fd, flag, flag & FD_CLOEXEC);
// restore CLOSE_ON_EXEC
fcntl (fd, F_SETFD, flag & ~FD_CLOEXEC);
flag = fcntl (fd, F_GETFD);
printf ("dir fd(%d) flag: 0x%x, CLOSE_ON_EXEC: %d\n", fd, flag, flag & FD_CLOEXEC);
pid_t pid = 0;
if ((pid = fork ()) < 0)
err_sys ("fork error");
else if (pid == 0)
{
char tmp[32] = { 0 };
sprintf (tmp, "%lu", (long)fd);
execlp ("./exec_open_dir", "./exec_open_dir"/*argv[0]*/, tmp, str, NULL);
err_sys ("execlp error");
}
else
{
printf ("fork and exec child %u\n", pid);
struct dirent *ent = readdir (dir);
printf ("read %p, %s\n", ent, ent ? ent->d_name : 0);
// closedir (dir);
}
}
exit (0);
}
做個簡單說明:
- 程序編譯為 exec_open_dir,它有兩種模式:
- 無參數時打開當前目錄流,展示并清除它的 close-on-exec 標志,啟動子進程,exec 替換進程為自身 (exec_open_dir) 并傳遞這個目錄流的底層 fd 作為參數;父進程遍歷目錄流第一個文件并退出
- 有參數時直接打開傳遞的文件句柄為目錄流,在 fd 轉換為目錄流前后分別打印它的 close-on-exec 標志,rewind 至開始并遍歷第一個文件,關閉目錄流退出
- 不帶任何參數啟動時進入 else 條件啟動子進程,帶參數啟動時進入 if 條件;父進程進入 else 條件,子進程進入 if 條件
下面看下這個程序的運行結果:
> ./exec_open_dir
open . return 0x1c60030
dir fd(3) flag: 0x1, CLOSE_ON_EXEC: 1
dir fd(3) flag: 0x0, CLOSE_ON_EXEC: 0
fork and exec child 13085
read 0x1c60060, exec.c
dir fd(3) flag: 0x0, CLOSE_ON_EXEC: 0
recv dir 3, str hello world (total 3)
dir fd(3) flag: 0x1, CLOSE_ON_EXEC: 1
read 0x1a7c040, exec.c
做個簡單說明:
- 1-5:為父進程輸出,opendir 后自帶 close-on-exec,手動清除了這個標志,能正常遍歷并打印第一個文件名
- 6-9:為子進程輸出,接收到的 fd 不帶 close-on-exec,fdopendir 后設置了這個標志,rewind 后能正常遍歷并打印第一個文件名
這里印證了兩點:
- opendir & fdopendir 自動添加 close-on-exec 標志來保證目錄流跨 exec 的關閉
- 手動清除目錄流底層 fd 上的 close-on-exec 可以保證目錄的跨 exec 傳遞
不過需要注意的是,重新打開的目錄流必需 rewind 才可以遍歷,否則什么也不輸出,即使父進程沒有遍歷到目錄結尾。注意這里不能直接傳遞 DIR 指針,因為 exec 后整個進程的堆、棧會被替換,前程序的指針變量引用的內容會失效。
最后,對比下 exec 和 fork 前后保留的進程信息:
| 進程信息 | fork | exec |
| 進程 ID 和父進程 ID | 變 | 不變 |
| 實際用戶 ID 和實際組 ID | 不變 | 不變 |
| 有效用戶 ID 和有效組 ID | 不變 | 可變 (set-uid/set-gid) |
| 附加組 ID | 不變 | 不變 |
| 進程組 ID | 不變 | 不變 |
| 會話 ID | 不變 | 不變 |
| 控制終端 | 不變 | 不變 |
| 鬧鐘 | 清除未處理的 | 保持剩余時間 |
| 當前工作目錄 | 不變 | 不變 |
| 根目錄 | 不變 | 不變 |
| 文件模式創建屏蔽字 | 不變 | 不變 |
| 文件鎖 | 清除 | 不變 |
| 信號屏蔽和安排 | 不變 | 不變 |
| 未處理信號 | 清除 | 不變 |
| 資源限制 | 不變 | 不變 |
| 新進程時間 (tms_xxtime) | 清除 | 不變 |
| 環境 | 不變 | 可變 |
| 連接的共享存儲段 | 不變 | 清除 |
| 存儲映射 | 不變 | 清除 |
| 文件描述符 | 不變 | 關閉 (close-on-exec) |
| ... |
更改進程用戶 ID 和組 ID
Unix 系統中,特權是基于用戶和用戶組的,如果需要攬權操作,一般是通過切換啟動進程的用戶身份來實現的,例如 su 或 sudo。
然而一些需要訪問特權文件的程序又需要對普通用戶開放使用權限,例如 passwd,它修改的是普通用戶的賬戶密碼,但需要修改的文件 /etc/passwd 卻只有 root 才有寫權限,為此引入了 set-uid、set-gid 權限位標識。當普通啟動具有 root 身份的 passwd 命令時,新進程將借用命令所有者的身份 (root) 而不是啟動用戶的身份 (普通用戶),從而讓 passwd 命令可以寫 /etc/passwd 文件,實現讓普通用戶繞開權限檢查的目的。
能這樣做的前提是 passwd 這個程序功能單一,內部會對用戶身份進行校驗,確保不會修改其它用戶的密碼,也不會做修改密碼以外的事情。Unix 系統對此有專門的保護機制,當 set-uid 或 set-gid 程序文件內容發生變更時,會自動去除其 set-uid 和 set-gid 標志位,確保程序沒有被黑客篡改來做一些非法的事情。除 passwd 外,類似的程序還有 crontab、at 等等,通過下面的命令可以查看系統中所有 set-uid 程序:
> find / -perm -u+s 2>/dev/null | xargs ls -lhd
關于用戶權限更詳細的內容,可參考《[apue] linux 文件訪問權限那些事兒》。
在解釋 set-uid、set-gid 機制之前先了解幾個術語,進程真實的用戶 ID 和用戶組 ID 稱為 RUID 和 RGID (real),這個一般是不變的;而權限檢查針對的是進程的有效用戶 ID 與有效用戶組 ID,稱為 EUID 和 EGID (effect),默認情況下 EUID = RUID、EGID = RGID,當指定 set-uid 或 set-gid 標志位時,exec 會自動將 EUID 或 EGID 設置為文件所屬的用戶 ID 與用戶組 ID,從而實現攬權的目的。這也是將本節安排在 exec 函數族之后的原因。
單有 set-uid、set-gid 標志位還是不夠,考查一種命令的使用場景,它既要訪問特權文件,還要啟動子進程,如果以特權身份啟動子進程,則存在權限濫用的問題。為此,Unix 允許進程自己控制 EUID、EGID 的變更,當訪問特權文件時,使用特權身份訪問;當啟動子進程時,使用普通用戶身份啟動,從而滿足“最小化使用特權”的原則。
當然了,EUID 與 EGID 不能隨意變更,否則會形成更大的安全漏洞,一般也就是在 RUID、RGID 與 set-uid、set-gid 指定的用戶身份之間切換,后面這組 ID 在切換后會丟失,需要將它們保存起來,為此引入了新的術語:saved-set-uid & saved-set-gid,簡稱為 SUID 和 SGID,用來保存特權用戶身份,方便之后從這里恢復。
在早期 POSIX.1 標準中 SUID & SGID 是可選的,到 2001 版中才變為必需,因此一些較老的系統可能不支持,程序中可以下面的代碼做編譯期測試:
#ifdef _POSIX_SAVED_IDS
printf ("support SUID & SGID!\n")
#else
printf ("NOT support SUID & SGID!\n")
#if
或通過下面的代碼在運行期進行驗證:
if (sysconf (_SC_SAVED_IDS) == 1)
printf ("support SUID & SGID!\n");
else
printf ("NOT support SUID & SGID\n");
甚至支持命令行:
> getconf SAVED_IDS
1
> getconf _POSIX_SAVED_IDS
1
目前流行的大多數系統均支持這一特性。

上圖展示了到目前為止進程內部與權限相關的各種 ID,其中 SUID & SGID 沒有接口可以直接獲取,標識為單獨的顏色。Linux 中有額外的擴展接口可以獲取 SUID & SGID,所以可以通過 ps 命令展示它們:
$ ps -efo ruid,euid,suid,rgid,egid,sgid,pid,ppid,cmd
RUID EUID SUID RGID EGID SGID PID PPID CMD
383278 383278 383278 100000 100000 100000 24537 24536 bash -c /usr/bin/baas login
383278 383278 383278 100000 100000 100000 24610 24537 \_ /bin/bash -l XDG_SESSION_ID=393387 TERM=xterm SHELL=/bin/bash
383278 383278 383278 100000 100000 100000 19001 24610 \_ ps -efo ruid,euid,suid,rgid,egid,sgid,pid,ppid,cmd
有了這個基礎,可以將之前所說的復雜權限控制場景通過下圖直觀展示出來:

重點看下進程的各個 ID 是如何變更的:
- 進程 100 以用戶身份 foo 通過 fork + exec 啟動了一個 set-uid 程序,設置的用戶身份是 bar
- 啟動后的進程為 101,它的 EUID 為 bar 所以可以直接訪問具有 bar 權限的文件
- 進程 101 通過 fork 啟動了一個子進程 102,它的用戶身份完全與 101 一致
- 進程 102 在 exec 之前調整自己的用戶身份為 foo
- 進程 102 在 exec 之后,完全丟失了 bar 的身份信息,沒有機會再轉換身份為 bar,從而達成了解除特權身份的目標
一番操作猛如虎,具有特權身份的進程 (101) 創建了一個普通身份的子進程 (102),它是完完全全的普通身份,不像其父進程一樣可以自由地在特權與普通身份之間切換,如同被閹割了一般。能這樣做其實隱藏了一條非常重要的規則:SUID & SGID 在 exec 時,僅從 EUID & EGID 復制,如果 EUID & EGID 是由 exec 從 set-uid & set-gid 設置的,那么復制發生在它們被設置之后。這一點保證了,102 進程在 exec 之前 SUID 為 bar,exec 之后它被同步為 foo;也是進程 101 從 set-uid 程序創建時能記錄特權身份 (SUID 為 bar) 的關鍵。不得不說這里的設計確實巧妙。創建子進程只是一個例子,實際上可以是任意需要普通用戶權限的場景,因此這個圖還可以繼續擴展,進程 102 可以不斷在的特權用戶 (bar) 和啟動用戶 (foo) 身份之間切換。
有了上面的鋪墊,再來看 Unix 提供的接口:
// user ID
uid_t getuid(void);
uid_t geteuid(void);
int setuid(uid_t uid);
int seteuid(uid_t euid);
int setreuid(uid_t ruid, uid_t euid);
// groupd ID
gid_t getgid(void);
gid_t getegid(void);
int setgid(gid_t gid);
int setegid(gid_t egid);
int setregid(gid_t rgid, gid_t egid);
4 個 get 接口就不多解釋了,剩下的 6 個 set 接口中,僅對 3 個設置 uid 的接口做個說明。另外 3 個設置 gid 的接口情況類似,需要注意的是它們對進程附加組 ID 沒有任何影響,關于后者,參考《[apue] linux 文件訪問權限那些事兒》。
setuid
- root 進程:RUID/EUID/SUID = uid
- 普通進程
- uid == RUID:EUID = uid
- uid == SUID:EUID = uid
- 否則出錯返回 -1,errno 設置為 EPERM
注意當進程本身為超級用戶進程時 (root),才可以更改 RUID,在系統中,通常由 login 程序在用戶登錄時調用 setuid 設置新進程為當前登錄用戶,而 login 確實就是一個超級用戶進程。
非 root 進程僅能將 EUID 設置為自己的 RUID 或 SUID,當進程不是 set-uid 進程時 (RUID = EUID = SUID),實際上調用這個接口沒有意義,因為不能將 EUID 更改為其它值。
seteuid
- root 進程:EUID = uid
- 普通進程
- uid == RUID:EUID = uid
- uid == SUID:EUID = uid
- 否則出錯返回 -1,errno 設置為 EPERM
這個接口對于普通進程而言,與 setuid 無異;對于超級用戶進程而言,唯一的區別是只設置 EUID,保持 RUID 與 SUID 不變。
setreuid
- root 進程:RUID = ruid;EUID = euid
- 普通進程
- ruid
- -1:RUID 不變
- ruid == EUID:RUID = ruid
- ruid == SUID:RUID = ruid
- 否則出錯返回 -1,errno 設置為 EPERM
- euid
- -1:EUID 不變
- euid == RUID:EUID = euid
- euid == SUID:EUID = euid
- 否則出錯返回 -1,errno 設置為 EPERM
- ruid
這個接口來源于 SUS 標準,最早是 BSD 4.3 引入的,由于當時沒有 saved-set-uid 機制,只能通過交換 RUID 與 EUID 的方法來實現特權與普通用戶身份的切換。隨著與 saved-set-uid 機制的整合,相應的判斷條件也增加了一個 (item III):可以把 RUID 或 EUID 設置為 SUID。setreuid (-1, uid) 等價于 seteuid,另外 setreuid 還能實現普通進程 RUID 的變更,這是之前接口沒有的能力。
demo
下面的程序用來驗證:
#include "../apue.h"
#include <sys/types.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
void print_ids ()
{
uid_t ruid = 0;
uid_t euid = 0;
uid_t suid = 0;
int ret = getresuid (&ruid, &euid, &suid);
if (ret == 0)
printf ("%d: ruid %d, euid %d, suid %d\n", getpid(), ruid, euid, suid);
else
err_sys ("getresuid");
}
int main (int argc, char *argv[])
{
if (argc == 2)
{
char* uid=argv[1];
int ret = setuid(atol(uid));
if (ret != 0)
err_sys ("setuid");
print_ids();
}
else if (argc == 3)
{
char* ruid=argv[1];
char* euid=argv[2];
int ret = setreuid(atol(ruid), atol(euid));
if (ret != 0)
err_sys ("setreuid");
print_ids();
}
else if (argc > 1)
{
char* uid=argv[1];
int ret = seteuid(atol(uid));
if (ret != 0)
err_sys ("seteuid");
print_ids();
}
else
{
print_ids();
}
return 0;
}
對 demo 的參數做個簡單說明:
- 1 個參數:調用 setuid,argv[1] 為 uid,整型
- 2 個參數:調用 setreuid,argv[1] 為 ruid,argv[2] 為 euid,整型
- >2 個參數:調用 seteuid,argv[1] 為 euid,整型,其它隨意,僅用于占位
- 無參數:打印當前進程 RUID / EUID / SUID
變更后也會打印當前進程 RUID / EUID / SUID。這里為了直觀起見,使用了 Linux 上獨有的 getresuid 接口,缺點是犧牲了可移植性。下面是驅動腳本:
#!/bin/sh
groupadd test
echo "create group ok"
useradd -g test foo
useradd -g test bar
foo_uid=$(id -u foo)
bar_uid=$(id -u bar)
echo "create user ok"
echo " foo: ${foo_uid}"
echo " bar: ${bar_uid}"
cd /tmp
chown bar:test ./setuid
echo "test foo"
su foo -c ./setuid
chmod u+s ./setuid
echo "test set-uid bar"
su foo -c ./setuid
echo "test setuid(foo)"
su foo -c "./setuid ${foo_uid}"
echo "test seteuid(foo)"
su foo -c "./setuid ${foo_uid} noop noop"
echo "test setreuid(bar, foo)"
su foo -c "./setuid ${bar_uid} ${foo_uid}"
echo "test setreuid(-1, foo)"
su foo -c "./setuid -1 ${foo_uid}"
echo "test setreuid(bar, -1)"
su foo -c "./setuid ${bar_uid} -1"
userdel bar
userdel foo
echo "remove user ok"
rm -rf /home/bar
rm -rf /home/foo
echo "remove user home ok"
groupdel test
echo "delete group ok"
這個腳本制造了不同的條件來調用上面的 setuid 程序,前提是將 demo 事先放置在 /tmp 目錄。運行后產生下面的輸出:
> sudo sh setuid.sh
create group ok
create user ok
foo: 1003
bar: 1004
test foo
4549: ruid 1003, euid 1003, suid 1003
test set-uid bar
4562: ruid 1003, euid 1004, suid 1004
test setuid(foo)
4574: ruid 1003, euid 1003, suid 1004
test seteuid(foo)
4586: ruid 1003, euid 1003, suid 1004
test setreuid(bar, foo)
4598: ruid 1004, euid 1003, suid 1003
test setreuid(-1, foo)
4617: ruid 1003, euid 1003, suid 1004
test setreuid(bar, -1)
4629: ruid 1004, euid 1004, suid 1004
remove user ok
remove user home ok
delete group ok
腳本構造了測試所需的所有賬戶,包括一個用戶組 test,兩個測試賬號 foo(1003) 與 bar(1004),測試結束后又自動清理了這些賬戶。分別驗證了以下場景:
- 未 set-uid:RUID (foo),EUID (foo),SUID (foo)
- set-uid bar
- 空參數:RUID (foo),EUID (bar),SUID (bar)
- setuid (foo):RUID (foo),EUID (foo),SUID (bar)
- seteuid (foo):RUID (foo),EUID (foo),SUID (bar)
- setreuid (bar, foo):RUID (bar),EUID (foo),SUID (bar)
- setreuid (-1,foo):RUID (foo),EUID (foo),SUID (bar)
- setreuid (bar, -1):RUID (bar),EUID (bar),SUID (bar)
都是以 foo 身份啟動的,主要看 set-uid 為 bar 的場景:
- setuid、seteuid 與
setreuid(-1,foo)在這個場景等價 - setreuid 可以改變 RUID 的值,setreuid (bar,-1) 甚至允許用戶永久拋棄普通用戶身份,"理直氣壯"的作個特權進程
對于上面最后一個用例,三個 ID 都變更為了 bar,有人可能會問了,此時進程還能恢復 foo 的身份嗎?在 print_ids 中增加一小段代碼做個驗證:
int ret = getresuid (&ruid, &euid, &suid);
if (ret == 0)
{
if (ouid != -1)
{
printf ("%d: ruid %d, euid %d, suid %d, ouid %d\n", getpid(), ruid, euid, suid, ouid);
if (ruid == euid && euid == suid && suid != ouid)
{
printf ("all uid same %d, change back to old %d\n", ruid, ouid);
ret = seteuid (ouid);
if (ret != 0)
err_sys ("seteuid");
else
print_ids (0);
}
}
else
printf ("%d: ruid %d, euid %d, suid %d\n", getpid(), ruid, euid, suid);
}
else
err_sys ("getresuid");
主要邏輯就是:判斷三個 ID 相等后,嘗試 change back 到之前的普通用戶身份,原來的身份由 ouid 記錄并經外層傳入,這里是在調用 setreuid 之前使用 getuid 備份了之前的值:
else if (argc == 3)
{
char* ruid=argv[1];
char* euid=argv[1];
uid_t ouid = getuid();
int ret = setreuid(atol(ruid), atol(euid));
if (ret != 0)
err_sys ("setreuid");
// to test if ruid/euid/suid changed to same
// can we change back again?
print_ids(ouid);
}
其他場景直接傳 -1 即可。重新運行上面的腳本:
> sudo sh setuid.sh
create group ok
create user ok
foo: 1003
bar: 1004
test foo
4549: ruid 1003, euid 1003, suid 1003
test set-uid bar
4562: ruid 1003, euid 1004, suid 1004
test setuid(foo)
4574: ruid 1003, euid 1003, suid 1004
test seteuid(foo)
4586: ruid 1003, euid 1003, suid 1004
test setreuid(bar, foo)
4598: ruid 1004, euid 1003, suid 1003, ouid 1003
test setreuid(-1, foo)
4617: ruid 1003, euid 1003, suid 1004, ouid 1003
test setreuid(bar, -1)
4629: ruid 1004, euid 1004, suid 1004, ouid 1003
all uid same 1004, change back to old 1003
seteuid: Operation not permitted
remove user ok
remove user home ok
delete group ok
果然失敗了 (EPERM),這從另一個角度驗證了之前的約束:seteuid 只能將 EUID 更新為 RUID 或 SUID 之一。在 setreuid(-1,foo) 的場景中,RUID = EUID = foo,僅 SUID = bar,此時切換到 bar 應該可行,感興趣的讀者可以一試。
root demo1
demo 中的 bar 并不是超級用戶,而 set-uid 的大多數場景是超級用戶,將 bar 切換為 root 會有什么不同?將原始腳本中的 bar 都改為 root (且去掉創建、刪除 root 賬戶的代碼) 再試:
> sudo sh setuid-setroot.sh
create group ok
create user ok
foo: 1003
root: 0
test foo
16370: ruid 1003, euid 1003, suid 1003
test set-uid root
16383: ruid 1003, euid 0, suid 0
test setuid(foo)
16395: ruid 1003, euid 1003, suid 1003
test seteuid(foo)
16408: ruid 1003, euid 1003, suid 0
test setreuid(root, foo)
16420: ruid 0, euid 1003, suid 1003
test setreuid(-1, foo)
16432: ruid 1003, euid 1003, suid 0
test setreuid(root, -1)
16445: ruid 0, euid 0, suid 0
remove user ok
remove user home ok
delete group ok
除了以下不同外外,其它沒區別:
- setuid 會將 3 個 ID 設置為一樣
- setreuid 后 SUID 也將會被變更為新的 EUID
后一條在 man 中找到了解釋:
If the real user ID is set or the effective user ID is set to a value not equal to the previous real user ID, the saved set-user-ID will be set to the new effective user ID.
意思是無論 EUID 還是 RUID,只要與之前的 RUID 不同,SUID 都會隨之變更。關于 SUID 的變更,可以參考下一小節的例子,現在接著上一個例子的熱度,再驗證下 ID 一樣的情況下是否還有 change back 的能力:
> sudo sh setuid-setroot.sh
create group ok
create user ok
foo: 1003
root: 0
test foo
5842: ruid 1003, euid 1003, suid 1003
test set-uid root
5855: ruid 1003, euid 0, suid 0
test setuid(foo)
5873: ruid 1003, euid 1003, suid 1003, ouid 0
all uid same 1003, change back to old 0
seteuid: Operation not permitted
test seteuid(foo)
5885: ruid 1003, euid 1003, suid 0
test setreuid(root, foo)
5897: ruid 0, euid 1003, suid 1003, ouid 1003
test setreuid(-1, foo)
5909: ruid 1003, euid 1003, suid 0, ouid 1003
test setreuid(root, -1)
5921: ruid 0, euid 0, suid 0, ouid 1003
all uid same 0, change back to old 1003
5921: ruid 0, euid 1003, suid 0
remove user ok
remove user home ok
delete group ok
如果已變身為普通用戶,不能 change back;如果是超級用戶,可以。
root demo2
上個例子中,超級用戶進程在變更 EUID 時 SUID 會隨之變更,然而 man 中說 RUID 變更時 SUID 才會隨之變更,為了看的更清楚些,寫了一個 setreuid 的測試腳本:
#!/bin/sh
groupadd test
echo "create group ok"
useradd -g test foo
useradd -g test bar
foo_uid=$(id -u foo)
bar_uid=$(id -u bar)
echo "create user ok"
echo " foo: ${foo_uid}"
echo " bar: ${bar_uid}"
cd /tmp
#chown bar:test ./setuid
echo "test foo"
./setuid
#chmod u+s ./setuid
#echo "test set-uid bar"
#su foo -c ./setuid
echo "test setreuid(bar, foo)"
./setuid ${bar_uid} ${foo_uid}
echo "test setreuid(foo, bar)"
./setuid ${foo_uid} ${bar_uid}
echo "test setreuid(-1, foo)"
./setuid -1 ${foo_uid}
echo "test setreuid(bar, -1)"
./setuid ${bar_uid} -1
echo "test setreuid(bar, bar)"
./setuid ${bar_uid} ${bar_uid}
echo "test setreuid(foo, foo)"
./setuid ${foo_uid} ${foo_uid}
userdel bar
userdel foo
echo "remove user ok"
rm -rf /home/bar
rm -rf /home/foo
echo "remove user home ok"
groupdel test
echo "delete group ok"
仍然創建 foo、bar 賬戶,不同的是直接使用超級用戶身份啟動 setuid,并傳遞不同的 foo、bar 參數給 setreuid 進行測試:
> sudo sh setreuid.sh
create group ok
create user ok
foo: 1003
bar: 1004
test foo
27253: ruid 0, euid 0, suid 0
test setreuid(bar, foo)
27254: ruid 1004, euid 1003, suid 1003
test setreuid(foo, bar)
27255: ruid 1003, euid 1004, suid 1004
test setreuid(-1, foo)
27256: ruid 0, euid 1003, suid 1003
test setreuid(bar, -1)
27257: ruid 1004, euid 0, suid 0
test setreuid(bar, bar)
27258: ruid 1004, euid 1004, suid 1004
test setreuid(foo, foo)
27259: ruid 1003, euid 1003, suid 1003
remove user ok
remove user home ok
測試了 6 個場景,這下看清楚了,SUID 變更基本與 EUID 是同步的,而 RUID 的變更對 SUID 反而沒有什么影響。
需要注意的是,與 demo2 的 setreuid(-1,foo) 場景不同,demo1 的 SUID 仍保持 0 而不是變更為 1003,這里有點說不通,兩個例子唯一的區別僅是獲取的超級用戶權限的途徑,demo1 通過 set-uid root;demo2 通過啟動用戶本身是 root。為 demo1 增加 setreuid(foo,bar) 與 setreuid(bar,foo) 兩個場景做對比,新的輸出如下:
> sudo sh setuid-setroot.sh
create group ok
create user ok
foo: 1003
root: 0
test foo
7475: ruid 1003, euid 1003, suid 1003
test set-uid root
7488: ruid 1003, euid 0, suid 0
test setuid(foo)
7500: ruid 1003, euid 1003, suid 1003
test seteuid(foo)
7512: ruid 1003, euid 1003, suid 0
test setreuid(root, foo)
7524: ruid 0, euid 1003, suid 1003
test setreuid(-1, foo)
7536: ruid 1003, euid 1003, suid 0
test setreuid(root, -1)
7548: ruid 0, euid 0, suid 0
test setreuid(foo, bar)
7560: ruid 1003, euid 1003, suid 1003
test setreuid(bar, foo)
7572: ruid 1003, euid 1003, suid 1003
remove user ok
remove user home ok
delete group ok
神奇的事情發生了,雖然理論上現在進程擁有特權,然而卻不能設置 foo & root 之外的用戶身份,這兩個用例最終都變成了 foo,看起來借用的特權和原生的還是有很大差別。
好奇 setuid 和 seteuid 的表現如何,添加下面的用例:
echo "test setuid(foo)"
su foo -c "./setuid ${foo_uid}"
echo "test setuid(bar)"
su foo -c "./setuid ${bar_uid}"
echo "test seteuid(foo)"
su foo -c "./setuid ${foo_uid} noop noop"
echo "test seteuid(bar)"
su foo -c "./setuid ${bar_uid} noop noop"
主要驗證 setuid(bar) & seteuid(bar) 的情況:
test setuid(foo)
27292: ruid 1003, euid 1003, suid 1003
test setuid(bar)
27304: ruid 1003, euid 0, suid 0
test seteuid(foo)
27316: ruid 1003, euid 1003, suid 0
test seteuid(bar)
27328: ruid 0, euid 0, suid 0
更離譜的情況出現了,setuid(bar) 不生效也不報錯;seteuid(bar) 更是直接回退到了 root。感覺 set-uid root 的進程邏輯有點混亂。
雖然不清楚 Linux 底層是如何處理的,但是大膽假設一下,這里的邏輯應該和 RUID 相關:當以 root 身份啟動時,RUID = EUID = 0;而以 set-uid root 身份啟動時,RUID != 0。然而可以人為將 set-uid root 的 RUID 修改為 0 (通過 setreuid(root, -1) 實現),此時它滿足 RUID = EUID = 0 的條件,再執行 setreuid(foo,bar) 還能成功嗎?修改 setuid.c 程序進行驗證:
uid_t ruid = 0;
uid_t euid = 0;
uid_t suid = 0;
int ret = getresuid (&ruid, &euid, &suid);
if (ret == 0)
{
printf ("%d: ruid %d, euid %d, suid %d\n", getpid(), ruid, euid, suid);
if (ruid == 0 && euid == 0)
{
// in root, try setreuid(foo, bar)
int ret = setreuid(1003, 1004);
if (ret != 0)
err_sys ("setreuid");
else
print_ids (-1);
}
}
else
err_sys ("getresuid");
當檢測到全 root ID 時,在 print_ids 中再調用一次 setreuid,這里為方便直接寫死了 foo、bar 的用戶 ID (1003/1004)。重新運行上面的腳本:
> sudo sh setuid-setroot.sh
create group ok
create user ok
foo: 1003
root: 0
test foo
3767: ruid 1003, euid 1003, suid 1003
test set-uid root
3780: ruid 1003, euid 0, suid 0
test setuid(foo)
3792: ruid 1003, euid 1003, suid 1003
test setuid(bar)
3804: ruid 1003, euid 0, suid 0
test seteuid(foo)
3817: ruid 1003, euid 1003, suid 0
test seteuid(bar)
3829: ruid 0, euid 0, suid 0
3829: ruid 1003, euid 1004, suid 1004
test setreuid(root, foo)
3841: ruid 0, euid 1003, suid 1003
test setreuid(-1, foo)
3853: ruid 1003, euid 1003, suid 0
test setreuid(root, -1)
3865: ruid 0, euid 0, suid 0
3865: ruid 1003, euid 1004, suid 1004
test setreuid(foo, bar)
3878: ruid 1003, euid 1003, suid 1003
test setreuid(bar, foo)
3890: ruid 1003, euid 1003, suid 1003
remove user ok
remove user home ok
delete group ok
重點看 test setreuid(root,-1) 之后的輸出,全 root ID 后是可以正確設置 RUID = foo、EUID = bar 身份的,看來就是 RUID 與 EUID 不一致搗的鬼!
總結一下,如果 EUID = 0 為超級用戶權限,那么在是否能隨意設置其它用戶身份這個問題上,還要看 RUID 的值,如果 RUID = 0,可以;否則,有限制。而對于這個進程是通過 root 身份獲取的全 0 ID,還是通過 set-uid root 再 setreuid 獲取的全 0 ID,系統并不 care。至于 Linux 源碼是不是這樣寫的,這個存疑,留待以后查看源碼再做結論。
root demo3
來看一個冷門但存在的場景:set-uid bar 但以超級用戶身份啟動進程。需要將原始腳本中 foo 替換為 root、所有 su foo -c 去掉 (且去掉創建、刪除賬戶的代碼) :
> sudo sh setuid-root.sh
create group ok
create user ok
root: 0
bar: 1003
test root
14996: ruid 0, euid 0, suid 0
test set-uid bar
14998: ruid 0, euid 1003, suid 1003
test setuid(root)
14999: ruid 0, euid 0, suid 1003
test seteuid(root)
15000: ruid 0, euid 0, suid 1003
test setreuid(bar, root)
15001: ruid 1003, euid 0, suid 0
test setreuid(-1, root)
15002: ruid 0, euid 0, suid 1003
test setreuid(bar, -1)
15003: ruid 1003, euid 1003, suid 1003
remove user ok
remove user home ok
delete group ok
這個反向 set-uid 能實現特權"降級"效果,其中較有有趣的是 setuid (root) 的場景,它僅設置 EUID 為 0,進一步驗證了進程的特權是從 EUID 而來 (老 EUID 為 bar 非特權用戶,雖然 RUID 為 0)。
mac demo
好奇 mac 上的表現是否一致?將原始程序移植到 mac 上 (去掉 SUID 的獲取和展示),直接啟動腳本發現創建用戶和組的命令會報錯,在 mac 上缺少 groupadd、useradd 等命令,必需手動創建它們:


將原始腳本中創建、刪除賬戶的代碼移除,直接基于上面創建好的用戶和組進行測試:
> sudo sh setuid.sh
create user ok
foo: 501
bar: 502
test foo
30106: ruid 501, euid 501
test set-uid bar
30109: ruid 501, euid 502
test setuid(foo)
30111: ruid 501, euid 501
test seteuid(foo)
30113: ruid 501, euid 501
test setreuid(bar, foo)
30115: ruid 502, euid 501
test setreuid(-1, foo)
30117: ruid 501, euid 501
test setreuid(bar, -1)
30119: ruid 502, euid 502
雖然無法看到 SUID,表現卻和 Linux 一致。如法炮制,繼續驗證 root demo1:
> sudo sh setuid-setroot.sh
create user ok
foo: 501
root: 0
test foo
2987: ruid 501, euid 501
test set-uid root
2990: ruid 501, euid 0
test setuid(foo)
2992: ruid 501, euid 501
test setuid(bar)
2994: ruid 501, euid 0
test seteuid(foo)
2996: ruid 501, euid 501
test seteuid(bar)
2998: ruid 0, euid 0
test setreuid(root, foo)
3000: ruid 0, euid 501
test setreuid(-1, foo)
3002: ruid 501, euid 501
test setreuid(root, -1)
3004: ruid 0, euid 0
test setreuid(foo, bar)
3006: ruid 501, euid 501
test setreuid(bar, foo)
3008: ruid 501, euid 501
驗證 set-uid 為 root 的場景,并且融合了部分 root demo2 的場景,即在 set-uid root 獲取超級用戶權限的情況下,能否設置其它用戶身份,結果與 Linux 一致:不能。接下來驗證 root demo2:
> sudo sh setreuid.sh
create user ok
foo: 501
bar: 502
test foo
3410: ruid 0, euid 0
test setreuid(bar, foo)
3411: ruid 502, euid 501
test setreuid(foo, bar)
3412: ruid 501, euid 502
test setreuid(-1, foo)
3413: ruid 0, euid 501
test setreuid(bar, -1)
3414: ruid 502, euid 0
test setreuid(bar, bar)
3415: ruid 502, euid 502
test setreuid(foo, foo)
3416: ruid 501, euid 501
以超級用戶啟動進程的情況下 setreuid 設置任意用戶的能力,與 Linux 也是一致的:能。最后驗證 root demo3:
> sudo sh setuid-root.sh
create user ok
root: 0
bar: 502
test root
3679: ruid 0, euid 0
test set-uid bar
3681: ruid 0, euid 502
test setuid(root)
3682: ruid 0, euid 0
test seteuid(root)
3683: ruid 0, euid 0
test setreuid(bar, root)
3684: ruid 502, euid 0
test setreuid(-1, root)
3685: ruid 0, euid 0
test setreuid(bar, -1)
3686: ruid 502, euid 502
以超級用戶身份啟動 set-uid 普通用戶身份的進程,結果也是與 Linux 一致的。
最終結論,mac 上的 setuid 函數族表現與 linux 完全一致,特別是在 set-uid root 獲取的超級用戶權限時的一些表現,可以明確的一點就是這些異常 case 并不是 Linux 獨有的,而是廣泛分布于 Unix 系統。當然由于在 mac 上看不到 SUID,關于 SUID 的部分不在本節討論范圍內。
總結
結合之前對 exec 的說明,setuid 函數族對權限 ID 的影響可以歸納為一個表格:
| 啟動身份 | set-uid 身份 | 接口 | RUID | EUID | SUID |
| foo | n/a | n/a | foo | foo | foo |
| root | n/a | foo | 0 | 0 | |
| setuid (foo) | foo | foo | foo | ||
| setuid (bar) | foo | 0 | 0 | ||
| seteuid (foo) | foo | foo | 0 | ||
| seteuid (bar) | 0 | 0 | 0 | ||
| setreuid (root, foo) | 0 | foo | 0 | ||
| setreuid (root, -1) | 0 | 0 | 0 | ||
| setreuid (-1, foo) | foo | foo | 0 | ||
| bar | n/a | foo | bar | bar | |
| setuid (foo) | foo | foo | bar | ||
| seteuid (foo) | foo | foo | bar | ||
| setreuid (bar, foo) | bar | foo | bar | ||
| root | n/a | n/a | 0 | 0 | 0 |
| setuid (foo) | foo | foo | foo | ||
| seteuid (foo) | 0 | foo | 0 | ||
| setreuid (foo, bar) | foo | bar | bar | ||
| setreuid (bar, foo) | bar | foo | foo | ||
| bar | n/a | 0 | bar | bar | |
| setuid (root) | 0 | 0 | bar | ||
| seteuid (root) | 0 | 0 | bar | ||
| setreuid (bar, root) | bar | 0 | bar |
其中 foo 和 bar 都是普通用戶,表中驗證了之前討論的幾種場景:
- foo/no-set-uid:普通用戶啟動普通進程,只能訪問自己的文件
- foo/set-uid root:普通用戶啟動超級用戶進程 (setuid 主要場景)
- foo/set-uid bar:普通用戶啟動普通進程,能訪問另一個普通用戶的文件 (冷門場景但存在)
- root/no-set-uid:超級用戶啟動超級用戶進程
- root/set-uid bar:超級用戶啟動普通進程 (冷門場景幾乎不存在)
把這個表弄懂,Unix 上進程權限變化就了然于胸了。
回顧
本節開頭那個復雜的進程特權控制的例子:

在切換 EUID 時,理論上使用上面三個接口都可以,但經過實測:
- setuid 在 root 場景下會同時修改 3 個 ID
- setreuid 場景復雜
- 對 SUID 有說不清楚的影響
- set-uid root 場景下可設置其它用戶身份而不報錯,但結果不符合預期
- 僅交換 RUID & EUID 并不能實現子進程的特權回收,因為子進程可以通過繼續調用 setreuid 恢復特權,如果將 ruid 參數設置為 -1,則退化為 seteuid 的場景
seteuid 語義明確、副作用更少,是最合適的接口,實際上它們的歷史的演進也是如此:setuid -> setreuid -> seteuid。下面的程序演示了基于 seteuid 做上圖中復雜的進程特權控制的過程:
#include "../apue.h"
#include <sys/types.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
void print_ids (char const* prompt)
{
uid_t ruid = 0;
uid_t euid = 0;
uid_t suid = 0;
int ret = getresuid (&ruid, &euid, &suid);
if (ret == 0)
printf ("%s %d: ruid %d, euid %d, suid %d\n", prompt, getpid(), ruid, euid, suid);
else
err_sys ("getresuid");
}
int main (int argc, char *argv[])
{
uid_t ruid = getuid();
uid_t euid = geteuid();
if (ruid == euid)
{
printf ("ruid %d != euid %d, please set me set-uid before run this test !\n");
exit (1);
}
print_ids ("init");
int pid = fork ();
if (pid < 0)
err_sys ("fork");
else if (pid == 0)
{
// children
print_ids ("after fork");
int ret = seteuid (ruid);
if (ret == -1)
err_sys ("seteuid");
print_ids ("before exec");
execlp ("./setuid", "setuid", NULL);
err_sys ("execlp");
}
else
printf ("create child %u\n", pid);
wait(NULL);
print_ids("exit");
return 0;
}
做個簡單說明:
- 這個程序本身會被 set-uid,相當于圖中的 101 進程
- 它會 fork 一個子進程,并在其中 exec 程序 (./setuid),相當于圖中的 102 進程
- 將 seteuid 放置于 fork 之后 exec 之前,這樣做的好處是對父進程沒有影響 (考慮父進程多線程的場景)
- 被啟動的 setuid 進程不帶額外參數,只會打印子進程的 3 個ID 值,用于驗證 SUID 值沒有從父進程復制
下面是驅動腳本:
#!/bin/sh
groupadd test
echo "create group ok"
useradd -g test foo
useradd -g test bar
foo_uid=$(id -u foo)
bar_uid=$(id -u bar)
echo "create user ok"
echo " foo: ${foo_uid}"
echo " bar: ${bar_uid}"
cd /tmp
chown bar:test ./fork_setuid
chmod u+s ./fork_setuid
su foo -c ./fork_setuid
userdel bar
userdel foo
echo "remove user ok"
rm -rf /home/bar
rm -rf /home/foo
echo "remove user home ok"
groupdel test
echo "delete group ok"
以 foo 用戶啟動了一個 set-uid 為 bar 的程序 (fork_setuid)。下面是腳本和程序的輸出:
> sudo sh fork_setuid.sh
create group ok
create user ok
foo: 1003
bar: 1004
init 29958: ruid 1003, euid 1004, suid 1004
create child 29959
after fork 29959: ruid 1003, euid 1004, suid 1004
before exec 29959: ruid 1003, euid 1003, suid 1004
29959: ruid 1003, euid 1003, suid 1003
exit 29958: ruid 1003, euid 1004, suid 1004
remove user ok
remove user home ok
delete group ok
做個簡單說明:
- 由于 set-uid,程序啟動后 RUID = foo,EUID = SUID = bar
- fork 后父、子進程以上值均沒有變化
- 子進程 exec 前 seteuid 后,RUID = EUID = foo,SUID = bar
- 子進程 exec 后,RUID = EUID = SUID = foo,徹徹底底失去了變身 bar 的機會
完全符合預期。做為對比,去掉程序中的 seteuid 調用,再次運行:
> sudo sh fork_setuid.sh
create group ok
create user ok
foo: 1003
bar: 1004
init 14567: ruid 1003, euid 1004, suid 1004
create child 14568
after fork 14568: ruid 1003, euid 1004, suid 1004
14568: ruid 1003, euid 1004, suid 1004
exit 14567: ruid 1003, euid 1004, suid 1004
remove user ok
remove user home ok
delete group ok
這次子進程 exec 后保留了 bar 身份。更進一步,在原 demo 的基礎上為 ./setuid 設置 3 個參數來調用內部的 seteuid,看它還能否恢復 bar (1004) 的身份:
char tmp[128] = { 0 };
sprintf (tmp, "%u", euid);
execlp ("./setuid", "setuid", tmp, "noop", "noop", NULL);
新的程序輸出如下:
> sudo sh fork_setuid.sh
create group ok
create user ok
foo: 1003
bar: 1004
init 1646: ruid 1003, euid 1004, suid 1004
create child 1647
after fork 1647: ruid 1003, euid 1004, suid 1004
before exec 1647: ruid 1003, euid 1003, suid 1004
seteuid: Operation not permitted
exit 1646: ruid 1003, euid 1004, suid 1004
remove user ok
remove user home ok
delete group ok
的確不行。
最后需要補充一點的是,set-uid 標志位對腳本文件不生效,原因其實已經在“解釋器文件”一節中有過說明:腳本文件只是解釋器的輸入,真正被啟動的進程是解釋器,只有將 set-uid 標志加在解釋器上才能有效果,不過解釋器一般是一種通用的命令,具體要執行的操作由腳本指定,如果將它指定為 set-uid root 的話,無疑會造成特權濫用。只有在封閉受控的系統中、沒有其它替代方法萬不得已時才可出此下策。關于這方面更多的信息,可參考附錄 10。
進程終止
關于進程的終止,這篇《[apue] 進程環境那些事兒》有過梳理,主要分 5 種正常終止與 3 種異常終止場景:
正常終止:
- 從 main 返回 (無論是否有返回值)
- 調用 exit
- 調用 _exit 或 _Exit
- 最后一個線程從其啟動例程返回
- 最后一個線程調用 pthread_exit
異常終止:
- 調用 abort
- 接到一個信號并終止
- 最后一個線程對取消請求做出響應
首先看正常終止場景下后兩個場景,它們都與線程相關。如果最后一個線程不是 main,那么當 main 返回或調用 exit 后進程就終止了,不存在其它線程還能繼續跑的場景,所以 main 一定是進程的最后一個線程,所謂它"從啟動例程返回或調用 pthread_exit" 這句話沒有任何意義,因為 main 線程不是 pthread 庫創建的,也就是說最后兩個場景在現實中并不存在,反正我是沒有試出來。
這樣進程正常退出只要聚焦前三個場景就可以了,apue 上有一個圖非常經典:

很好的描述了 exit 與 _exit、用戶進程與內核的關系。進程異常終止雖然不走 exit,但在內核有與正常終止相同的清理邏輯,估且稱之為 sys_exit,它的主要工作是關閉進程所有打開文件、釋放使用的存儲器 (memory) 等。
進程終止狀態
進程退出后并不是什么信息也沒有留下,考慮一種場景,父進程需要得知子進程的退出碼 (exit(status)),系統為此保留了一部分進程信息:
- 進程 ID
- 終止狀態
- 使用的 CPU 時間總量
- ……
這里的終止狀態既包含了正常終止時的退出碼,也包含了異常終止時的信號等信息。
當通過 waitxxx 系統調用返回時,終止狀態 (status) 一般作為整型返回,通過下面的宏可以提取退出碼、異常信號等信息:
- WIFEXITED (status):進程正常終止為 true
- WEXITSTATUS (status):進程正常終止的退出碼,為 exit 或 _exit 的低 8 位,關于 main 函數 return 值與進程 exit 參數的關系,參考《[apue] 進程環境那些事兒》
- WIFSIGNALED (status):進程異常終止為 true
- WTERMSIG (status):導致進程異常終止的信號編號
- WCOREDUMP (status):產生 core 文件為 true (非 SUS 標準)
- WIFSTOPPED (status):進程被掛起為 true
- WSTOPSIG (status):導致進程被掛起的信號編號
- WIFCONTINUED (status):進程繼續運行為 true
注意 wait 函數還可以獲取正在運行的子進程的狀態,例如掛起或繼續運行,但此時子進程并未"終止",多用于 shell 這樣帶作業控制的終端程序。關于終止狀態的更多信息將在討論 wait 函數族時進一步介紹。
僵尸進程與孤兒進程
前文說到,進程退出后仍有一部分信息保留在系統中,這些信息雖然尺寸不大,但在未被 wait 之前會一直占據一個進程表項,而系統的進程表項是有限的 (ulimit -u),如果這種僵尸進程 (zombie) 太多,就會導致新進程創建失敗。下面是基于 forkit 自制的一個例子:
$ ps -exjf
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
....
41707 41955 41707 41707 ? -1 S 1000 0:00 sshd: yunhai01@pts/1
41955 41957 41957 41957 pts/1 28035 Ss 1000 0:00 \_ bash -c /usr/bin/baas pass_bils_identity --baas_cred=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
41957 41982 41982 41957 pts/1 28035 S 1000 0:00 \_ /bin/bash -l XDG_SESSION_ID=39466 ANDROID_HOME=/ext/tools/android-sdk-linux TERM=xterm-256color SHELL=/bin/bash HISTSIZE=1000 SSH_CL
41982 28035 28035 41957 pts/1 28035 S+ 1000 0:00 \_ ./forkit XDG_SESSION_ID=39466 HOSTNAME=goodcitizen.bcc-gzhxy.baidu.com ANDROID_HOME=/ext/tools/android-sdk-linux SHELL=/bin/bash
28035 28036 28035 41957 pts/1 28035 Z+ 1000 0:00 \_ [forkit] <defunct>
....
其中 28035 是父進程,28036 是子進程,子進程退出后父進程沒有 wait 前,ps 查看它的狀態就是 Z (defunct)。僵尸進程的產生是子進程先于父進程退出,如果父進程先于子進程退出呢?這就是孤兒進程了 (orphan)。孤兒進程將被過繼給 init 進程 (進程 ID = 1),無論父進程是否為僵尸進程,這是因為僵尸進程無法 wait 回收任何子進程。對上面的 forkit 稍加改造制作了 fork_zombie 程序:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main()
{
int pid = fork();
if (pid < 0)
{
// error
exit(1);
}
else if (pid == 0)
{
// child
printf ("%d spawn from %d\n", getpid(), getppid());
pid = fork();
if (pid < 0)
{
// error
exit(1);
}
else if (pid == 0)
{
// child
printf ("%d spawn from %d\n", getpid(), getppid());
sleep (10);
}
else
{
printf ("%d create %d\n", getpid(), pid);
}
}
else
{
printf ("%d create %d\n", getpid(), pid);
sleep (10);
}
printf ("after fork\n");
return 0;
}
在子進程中繼續 fork 子進程,這個孫子進程會存活比子進程更長的時間 (sleep 10),從而成為孤兒進程:
$ ps -exjf
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
41707 41955 41707 41707 ? -1 S 1000 0:00 sshd: yunhai01@pts/1
41955 41957 41957 41957 pts/1 13744 Ss 1000 0:00 \_ bash -c /usr/bin/baas pass_bils_identity --baas_cred=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
41957 41982 41982 41957 pts/1 13744 S 1000 0:00 \_ /bin/bash -l XDG_SESSION_ID=39466 ANDROID_HOME=/ext/tools/android-sdk-linux TERM=xterm-256color SHELL=/bin/bash HISTSIZE=1000 SSH_CL
41982 13744 13744 41957 pts/1 13744 S+ 1000 0:00 \_ ./fork_zombie XDG_SESSION_ID=39466 HOSTNAME=goodcitizen.bcc-gzhxy.baidu.com ANDROID_HOME=/ext/tools/android-sdk-linux SHELL=/bin
13744 13745 13744 41957 pts/1 13744 Z+ 1000 0:00 \_ [fork_zombie] <defunct>
1 13746 13744 41957 pts/1 13744 S+ 1000 0:00 ./fork_zombie XDG_SESSION_ID=39466 HOSTNAME=goodcitizen.bcc-gzhxy.baidu.com ANDROID_HOME=/ext/tools/android-sdk-linux SHELL=/bin/bash TERM=x
其中 13744 是父進程;13745 是子進程,會成為僵尸進程;13746 是孫子進程,會成為孤兒進程。可以看到孤兒進程直接過繼給 init 進程了。init 進程會保證回收所有退出的子進程,從而避免僵尸進程數量太多的問題。
上面無意間已經揭示了一種避免僵尸進程的方法:double fork,目前有共三種方法:
- wait 函數族
- SIGCHLD 信號 (處理或忽略)
- double fork
前兩種方法在敝人的拙著《[apue] 等待子進程的那些事兒》中都有記錄,歡迎大家翻閱~
需要注意的是第三種方法 double fork 只能避免孫子進程不是僵尸進程,子進程還需要使用前兩種方法來避免成為僵尸進程,這里用 wait 函數族多一些。
wait 函數族
在介紹 wait 函數族之前,先簡單說一下 SIGCHLD 信號,當子進程退出時,父進程會接收到該信號,面對 SIGCHLD 父進程有三種選擇:
- 默認:系統忽略
- 處理:增加信號處理函數,在其中使用 wait 函數族回收子進程 (可期望不被被阻塞)
- 忽略:SIG_IGN 顯示忽略信號,子進程會被自動回收,對 wait 函數族的影響后面介紹
有了這個鋪墊,再看 wait 接口定義:
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
pid_t wait3(int *status, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *status, int options, struct rusage *rusage);
wait
最基礎的獲取子進程終止狀態的接口,顧名思義,當子進程還在運行時,它還有同步等待子進程退出的能力,以下是它在不同場景的表現:
- 無任何子進程:返回 -1,errno 設置為 ECHILD
- 子進程全部在運行:阻塞等待
- 有子進程退出:獲取其中任意一個退出的子進程狀態并返回其進程 ID
為了避免阻塞,可期望在 SIGCHLD 信號處理器中調用 wait。如果 SIGCHLD 已通過 SIG_IGN 顯示忽略,則一直阻塞直到所有子進程退出,此時返回 -1,errno 設置為 ECHILD,同場景 1。
waitpid
相對 wait 有三方面改進:
- 可指定等待的子進程
- 可查詢子進程狀態
- 可不阻塞試探
pid 參數比較靈活,既可指定進程 ID 也可指定進程組 ID:
- -1:任意子進程,等價于 wait
- > 0:指定進程 ID
- < -1:絕對值指定進程組 ID
- 0:進程組 ID 等于調用進程的任意子進程
注意第二個場景,雖然這里的 pid 可以任意指定,但如果不是調用進程的子進程,依然還是會出錯 (ECHILD)。
status 參數返回進程終止狀態,成功返回時,可用之前介紹過的 WIFXXX 進行進程狀態判斷;若不關心,可設置為 NULL。
options 參數可以指定以下值:
- WNOHANG:不阻塞,若無合適條件的子進程則立即返回,返回值為 0
- WCONTINUED:用于作業控制,若已掛起的子進程在繼續運行后未報告狀態,則返回其狀態
- WUNTRACED:用于作業控制,若已掛起的子進程還未報告其狀態,則返回其狀態
- WNOWAIT:不破壞子進程終止狀態,后續仍可以被 wait (僅 Solaris 支持)
中間兩個選項用于作業控制,可用來獲取子進程當前的狀態,成功返回時,可用 WIFSTOPPED & WIFCONTINUED 去判斷進程狀態,一般為終端程序所用。
WNOHANG 在有合適條件的子進程時,會返回子進程的 PID 與終止狀態,它提供了一種試探的能力。
waittid
是 waitpid 的一個變形,允許以更直觀的方式提供進程 ID,它的 idtype 參數指定了 id 參數的含義:
- P_ALL:忽略 id,等待任一進程
- P_PID:id 表示一個特定進程,等待該進程
- P_PGID:id 表示一個特定進程組,等待該進程組中任意一個進程
從組合上講與 waitpid 功能一致,但實用性上不如 waitpid,waitpid 指定 0 就可以等待同進程組的子進程,waittid 還要明確指定一個進程組,不方便。優點是后者代碼看起來會更清晰一些。
options 參數與 waitpid 大同小異:
- WNOHANG:同上
- WNOWAIT:同上,所有平臺都支持
- WCONTINUED:同上
- WSTOPPED: 同 WUNTRACED
- WEXITED:等待正常退出的進程
信號相關的信息存放在 infop 參數中,相比之前只能拿到一個信號編號要豐富多了。
wait3 & wait4
并非標準的一部分,但大部分 Unix 平臺均提供了,主要增加了被等待進程 (及其所有子進程) 使用的資源匯總 (rusage),除去這部分,wait3 等價于:
waitpid(-1, status, options);
wait4 等價于:
waitpid(pid, status, options);
資源匯總主要統計以下內容:
struct rusage {
struct timeval ru_utime; /* user CPU time used */
struct timeval ru_stime; /* system CPU time used */
long ru_maxrss; /* maximum resident set size */
long ru_ixrss; /* integral shared memory size */
long ru_idrss; /* integral unshared data size */
long ru_isrss; /* integral unshared stack size */
long ru_minflt; /* page reclaims (soft page faults) */
long ru_majflt; /* page faults (hard page faults) */
long ru_nswap; /* swaps */
long ru_inblock; /* block input operations */
long ru_oublock; /* block output operations */
long ru_msgsnd; /* IPC messages sent */
long ru_msgrcv; /* IPC messages received */
long ru_nsignals; /* signals received */
long ru_nvcsw; /* voluntary context switches */
long ru_nivcsw; /* involuntary context switches */
};
就是一些 CPU 時間、內存占用、頁錯誤、信號接收次數等信息,有興趣的可以 man getrusage 查看。
進程時間
司空見慣的 time 命令,是基于 wait3 實現時間統計的:
$ time sleep 3
real 0m3.001s
user 0m0.000s
sys 0m0.001s
其中
- real 為經歷時間 (elapse)
- user 為用戶 CPU 時間
- sys 為系統 CPU 時間
與 rusage 結構中字段的對應關系為:
- user:ru_utime
- sys: ru_stime
具體可參考附錄 7。
除了通過 wait3 / wait4 獲取被等待進程 CPU 時間外,通過 times 接口還能獲取任意時間段的進程 CPU 時間:
#include <sys/times.h>
struct tms {
clock_t tms_utime; /* user time */
clock_t tms_stime; /* system time */
clock_t tms_cutime; /* user time of children */
clock_t tms_cstime; /* system time of children */
};
clock_t times(struct tms *buf);
time 命令中 real、user、sys 三種時間與 times 的對應關系如下:
- real:兩次 times 返回值的差值
- user:tms_utime 差值 + tms_cutime 差值
- sys:tms_stime 差值 + tms_cstime 差值
不過上述參數均以 clock_t 為單位,是時鐘滴答數 (tick),還需將它轉換為時間:sec = tick / sysconf(_SC_CLK_TK),后者表示每秒的 tick 數:
> getconf CLK_TCK
100
times 與 wait3 / wait4 另外一個差別是可以區分父、子進程的時間消耗,不過要統計子進程的 CPU 消耗,需要滿足以下兩個條件:
- 子進程在 times 統計的時間段內終止
- 終止時被父進程 wait 到了
apue 中有一個絕好的例子,演示了 times 的使用方法,這里稍作修改驗證上面的說法:
#include "../apue.h"
#include <sys/types.h>
#include <sys/times.h>
#include <sys/wait.h>
static void pr_times (clock_t, struct tms *, struct tms *);
static void do_cmd (char *);
int main (int argc, char *argv[])
{
int i;
int status;
pid_t pid;
struct tms start, stop;
clock_t begin, end;
for (i=1; i<argc; i++)
do_cmd (argv[i]);
if ((begin = times (&start)) == -1)
err_sys ("times error");
while (1)
{
pid = wait (&status);
if (pid < 0)
{
printf ("wait all children\n");
break;
}
printf ("wait child %d\n", pid);
pr_exit (status);
if ((end = times (&stop)) == -1)
err_sys ("times error");
pr_times (end-begin, &start, &stop);
printf ("---------------------------------\n");
}
exit (0);
}
static void do_cmd (char *cmd)
{
int status;
pid_t pid = fork ();
if (pid < 0)
err_sys ("fork error");
else if (pid == 0)
{
// children
fprintf (stderr, "\ncommand: %s\n", cmd);
if ((status = system (cmd)) < 0)
err_sys ("system () error");
pr_exit (status);
exit (status);
}
else
printf ("fork child %d\n", pid);
}
static void pr_times (clock_t real, struct tms *start, struct tms *stop)
{
static long clktck = 0;
if (clktck == 0)
if ((clktck = sysconf (_SC_CLK_TCK)) < 0)
err_sys ("sysconf error");
clock_t diff = 0;
fprintf (stderr, " real: %7.2f\n", real / (double)clktck);
diff = (stop->tms_utime - start->tms_utime);
fprintf (stderr, " user: %7.2f (%ld)\n", diff/(double) clktck, diff);
diff = (stop->tms_stime - start->tms_stime);
fprintf (stderr, " sys: %7.2f (%ld)\n", diff/(double)clktck, diff);
diff = (stop->tms_cutime - start->tms_cutime);
fprintf (stderr, " child user: %7.2f (%ld)\n", diff/(double)clktck, diff);
diff = (stop->tms_cstime - start->tms_cstime);
fprintf (stderr, " child sys: %7.2f (%ld)\n", diff/(double)clktck, diff);
}
主要做了如下改動:
- 將傳入的命令放在子進程中執行 (do_cmd)
- 時間統計放在 while 循環中不斷 wait 子進程后,直接沒有子進程運行后才退出
為了使例子更具真實性,傳遞進來的兩個命令都執行 dd 文件拷貝操作,只不過第二個命令拷貝的數據量是第一個的 2 倍:
> ./childtime "dd if=/dev/urandom of=./out1 bs=1M count=512" "dd if=/dev/urandom of=./out2 bs=1M count=1024"
fork child 3837
command: dd if=/dev/urandom of=./out1 bs=1M count=512
fork child 3838
command: dd if=/dev/urandom of=./out2 bs=1M count=1024
512+0 records in
512+0 records out
536870912 bytes (537 MB) copied, 7.2026 s, 74.5 MB/s
normal termination, exit status = 0
wait child 3837
normal termination, exit status = 0
real: 7.21
user: 0.00 (0)
sys: 0.00 (0)
child user: 0.00 (0)
child sys: 7.03 (703)
---------------------------------
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB) copied, 10.5758 s, 102 MB/s
normal termination, exit status = 0
wait child 3838
normal termination, exit status = 0
real: 10.58
user: 0.00 (0)
sys: 0.00 (0)
child user: 0.00 (0)
child sys: 17.45 (1745)
---------------------------------
wait all children
有以下發現:
- dd1 結束時,real 時間與 dd1 自己統計的接近 (7.21 => 7.2026)
- dd1 的 CPU 主要消耗在 sys 上,且與 real 時間接近 (7.03 => 7.21)
- dd2 結束時,real 時間與 dd2 自己統計的接近 (10.58 => 10.5758)
- dd2 的 CPU 也消耗在 sys 上,但遠遠大于自己的 real 時間 (10.58 => 17.45),這是因為它包含的是兩個子進程的 sys 時間,需要減去 dd1 的 sys 時間 (17.45 - 7.03 = 10.45),此時與自己的 real 時間接近
- 對比兩次 dd 的 sys CPU 消耗,會發現 dd2 小于 2 倍 dd1 的消耗 (7.03 => 10.45),這與 dd2 平均速度大于 dd1 的結果吻合 (74.5 => 102),速度快了,耗時相應的就降了;而速度提升,很可能與 dd1 退出不再搶占系統資源有關
- 總 CPU 時間 17.45,而總耗時只有 10.58,并行執行任務有效的降低了等待時間
對程序稍加修改,每次等待到子進程后更新 start 與 begin:
while (1)
{
pid = wait (&status);
if (pid < 0)
{
printf ("wait all children\n");
break;
}
printf ("wait child %d\n", pid);
pr_exit (status);
if ((end = times (&stop)) == -1)
err_sys ("times error");
pr_times (end-begin, &start, &stop);
begin = end;
start = stop;
printf ("---------------------------------\n");
}
再次運行上面的程序:
> ./childtime "dd if=/dev/urandom of=./out1 bs=1M count=512" "dd if=/dev/urandom of=./out2 bs=1M count=1024"
fork child 7928
command: dd if=/dev/urandom of=./out2 bs=1M count=1024
512+0 records in
512+0 records out
536870912 bytes (537 MB) copied, 9.92325 s, 54.1 MB/s
normal termination, exit status = 0
wait child 7927
normal termination, exit status = 0
real: 9.93
user: 0.00 (0)
sys: 0.00 (0)
child user: 0.00 (0)
child sys: 6.93 (693)
---------------------------------
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB) copied, 13.499 s, 79.5 MB/s
normal termination, exit status = 0
wait child 7928
normal termination, exit status = 0
real: 3.57
user: 0.00 (0)
sys: 0.00 (0)
child user: 0.00 (0)
child sys: 10.34 (1034)
---------------------------------
wait all children
與之前相比,子進程的 CPU 時間能真實的反映自己的情況了,其它的差別不大。通過這兩個例子,驗證了 times 統計子進程時間的第一個特點:子進程不結束時,times 無法獲取它的進程時間數據;關于第二個特點,感興趣的讀者,可以嘗試使用 sleep 代替 wait 子進程,看看是否還能得到正確的統計。
關于子進程時間統計的實現,下面是我的一些個人想法:當有子進程結束且被父進程 wait 后,它的進程時間數據就會被累加到父進程的子進程時間中 (tms_cutime & tms_cstime),從而供 times 獲取;若用戶使用的是 wait3 / wait4,則這份子進程時間數據會和父進程時間數據相加后再返回給用戶;若子進程也 wait 了它的子進程,那么這個數據還有"遞歸"效果,只不過在被累計至父進程時,孫子進程的時間就和子進程區分不開了,統統計入子進程一欄。
當然了,這個機制是"君子協定",如果哪個子進程沒有遵守規定,不給自己的子進程"善后",那么最終統計出的時間就不準確,不過總的來說實際時間是只大不小,可以換個名稱如"至少進程時間",就更貼切了。
system
glibc 基于 fork+exec+wait 玩了很多花樣,典型的如 system、popen & pclose:
#include <stdlib.h>
int system(const char *command);
#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
system 可以接收任何 shell 能接受的命令,典型的如 date > log.txt 這種帶 shell 重定向功能的命令,甚至是一段 shell 腳本:for file in "*.c"; do echo $file; done,當參數為 NULL 時,system 返回值表示 shell 是否可用,目前大多數系統中,都返回 1。真實 system 源碼比較復雜,書上模擬了一個 system 的實現:
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <errno.h>
int my_system (const char *cmdstring)
{
pid_t pid;
int status;
if (cmdstring == NULL)
return 1;
if ((pid = fork ()) < 0)
{
status = -1;
}
else if (pid == 0)
{
printf ("before calling shell: %s\n", cmdstring);
execl ("/bin/sh", "sh", "-c", cmdstring, (char *)0);
_exit (127);
}
else
{
while (waitpid (pid, &status, 0) < 0)
{
if (errno != EINTR)
{
printf ("wait cmd failed, errno = %d\n", errno);
status = -1;
break;
}
}
printf ("wait cmd, status = %d\n", status);
}
return (status);
}
通過 fork 子進程后 execl 調用 sh 進程,并將 cmdstring 通過 shell 的 -c 選項傳入,最后在父進程 waitpid 等待子進程終結,中間考慮了信號中斷的場景。下面這個程序演示了 my_system 的調用:
#include "../apue.h"
extern int my_system (const char *cmdstring);
int main (int argc, char *argv[])
{
int status;
if (argc < 2)
err_quit ("command-line argument required");
if ((status = my_system (argv[1])) < 0)
err_sys ("system () error");
pr_exit (status);
exit (0);
}
通過命令行傳遞任何想要測試的內容:
> ./tsys "date > log.txt"
normal termination, exit status = 0
> cat log.txt
Fri Mar 15 16:44:35 CST 2024
這樣做也有缺點:所有傳遞給 system 接口的參數必需和命令放在一個參數中,假如想要執行如下命令:ps -exjf,不能寫成:./tsys ps -exjf,必需寫成:./tsys "ps -exjf"。不放在一起也可以,需要將調用 execl 改為 execv,并根據 main 的 argv 參數構造新的參數數組 (假設為 argv_new) 傳遞給新命令,而 argv_new 前兩個參數是固定的,分別是 sh 和 -c,最后以 NULL 結尾。
此外也可以不依賴 shell 直接基于 exec 函數族去做,將 cmdstring 中的命令名與參數傳遞給 exec 即可,仍以 ps 命令為例,最終執行的是下面的代碼:
execlp (argv[1], argv[1] /*ps*/, argv[2] /*-exjf*/, (char *)0);
這里需要有兩個改變:
- 使用 execlp 或 execvp 以便在用戶只給命令名時,可在 PATH 環境變量中查找命令所在位置
- 用戶將需要執行的命令和參數單獨羅列而不是寫成一行,否則還需要解析 cmdstring 中的各個字段
結合以上分析,這里最合適的接口還是 execvp,直接傳遞 &argv[1] 就可以了。
不過這種直接調用 exec 的方式也有缺點,就是不能享受 shell 提供的能力了,譬如:重定向、shell 腳本甚至 shell 元字符,所以目前大部分 glibc 的實現還是通過調用 shell 的。
關于 system 接口還有一個需要說明的用例是 set-uid 程序,這主要表現在兩方面:
- 普通用戶使過 system 調用 set-uid 為 root 的程序,新命令將具有特權
- 通過 set-uid 為 root 獲取特權的進程使用 system 調用普通程序,新命令將不具有特權
第一點比較好理解,主要是 exec 的工作;第二點比較依賴 shell,bash 2.0 之后的版本會檢查 RUID 與 EUID,如果不一致,會將 EUID 設置為 RUID,從而避免安全漏洞。如果直接使用 exec 啟動新命令的話,特權是會被保留的。下面基于 setuid 程序和 tsys 做一個測試:
> ls -lh tsys setuid
-rwxr-xr-x 1 yunhai01 DOORGOD 53K Mar 15 15:54 setuid
-rwxr-xr-x 1 yunhai01 DOORGOD 52K Mar 15 15:54 tsys
> ./tsys ./setuid
29059: ruid 383278, euid 383278, suid 383278
normal termination, exit status = 0
> su
Password:
$ ./tsys ./setuid
29358: ruid 0, euid 0, suid 0
normal termination, exit status = 0
$ chown root:root tsys
$ chmod u+s tsys
$ suspend
[4]+ Stopped su
> ls -lh tsys setuid
-rwxr-xr-x 1 yunhai01 DOORGOD 53K Mar 15 15:54 setuid
-rwsr-xr-x 1 root root 52K Mar 15 15:54 tsys
> ./tsys ./setuid
29754: ruid 383278, euid 383278, suid 383278
normal termination, exit status = 0
> fg
su
$ chown yunhai01:DOORGOD tsys
$ exit
exit
> ls -lh tsys setuid
-rwxr-xr-x 1 yunhai01 DOORGOD 53K Mar 15 15:54 setuid
-rwxr-xr-x 1 yunhai01 DOORGOD 52K Mar 15 15:54 tsys
使用的 bash 版本 4.2.46。第三次 tsys 設置為 set-uid root 后,它啟動的 ./setuid 的輸出來看,與預期一致,bash 攔截了不安全的特權繼承。
進程核算
ps 命令可以查看系統當前運行的進程,類似地,lastcomm 可以查看已經終止的進程,不過需要手動開啟進程核算 (accounting,也稱為進程會計)。
accton
不同平臺都是通過 accton 命令開啟進程核算的,但用法略有不同:
| 平臺 | 默認開啟 | 帶文件開啟 | 關閉 | 默認 acct 文件 |
| Linux | sudo accton on | sudo accton /path/to/acctfiles | sudo accton off | /var/account/pacct |
| macOS/FreeBSD | n/a | sudo accton /path/to/acctfiles | sudo accton | /var/account/acct |
| Solaris | n/a | sudo accton /path/to/acctfiles | sudo accton | /var/adm/pacct |
做個簡單說明:
- Linux 上如果不指定 acct 文件路徑,則必需指定 on 或 off 參數,不指定路徑時使用默認 acct 文件
- macOS 上不能指定 on 或 off 參數,要開啟進程核算必需指定 acct 文件路徑,指定的路徑不存在時會出錯,需要手動創建。不帶路徑時表示關閉核算
acct & struct acct
它們底層都是調用 acct 接口完成核算的開啟和關閉:
#include <unistd.h>
int acct(const char *filename);
開啟進程核算后,內核會在進程終止時記錄一條信息到 acct 文件中,它在 C 語言中就是一個結構體 struct acct。由于進程核算并不是 POSIX 標準的一部分,各個平臺在這里的實現差異比較大,在 Linux 上:
#define ACCT_COMM 16
typedef u_int16_t comp_t;
struct acct {
char ac_flag; /* Accounting flags */
u_int16_t ac_uid; /* Accounting user ID */
u_int16_t ac_gid; /* Accounting group ID */
u_int16_t ac_tty; /* Controlling terminal */
u_int32_t ac_btime; /* Process creation time
(seconds since the Epoch) */
comp_t ac_utime; /* User CPU time */
comp_t ac_stime; /* System CPU time */
comp_t ac_etime; /* Elapsed time */
comp_t ac_mem; /* Average memory usage (kB) */
comp_t ac_io; /* Characters transferred (unused) */
comp_t ac_rw; /* Blocks read or written (unused) */
comp_t ac_minflt; /* Minor page faults */
comp_t ac_majflt; /* Major page faults */
comp_t ac_swaps; /* Number of swaps (unused) */
u_int32_t ac_exitcode; /* Process termination status
(see wait(2)) */
char ac_comm[ACCT_COMM+1];
/* Command name (basename of last
executed command; null-terminated) */
char ac_pad[X]; /* padding bytes */
};
enum { /* Bits that may be set in ac_flag field */
AFORK = 0x01, /* Has executed fork, but no exec */
ASU = 0x02, /* Used superuser privileges */
ACORE = 0x08, /* Dumped core */
AXSIG = 0x10 /* Killed by a signal */
};
在 macOS 上:
/*
* Accounting structures; these use a comp_t type which is a 3 bits base 8
* exponent, 13 bit fraction ``floating point'' number. Units are 1/AHZ
* seconds.
*/
typedef u_short comp_t;
struct acct {
char ac_comm[10]; /* name of command */
comp_t ac_utime; /* user time */
comp_t ac_stime; /* system time */
comp_t ac_etime; /* elapsed time */
time_t ac_btime; /* starting time */
uid_t ac_uid; /* user id */
gid_t ac_gid; /* group id */
short ac_mem; /* memory usage average */
comp_t ac_io; /* count of IO blocks */
dev_t ac_tty; /* controlling tty */
#define AFORK 0x01 /* forked but not execed */
#define ASU 0x02 /* used super-user permissions */
#define ACOMPAT 0x04 /* used compatibility mode */
#define ACORE 0x08 /* dumped core */
#define AXSIG 0x10 /* killed by a signal */
char ac_flag; /* accounting flags */
};
在 Solaris 上:
typedef ushort comp_t; /* pseudo "floating point" representation */
/* 3 bit base-8 exponent in the high */
/* order bits, and a 13-bit fraction */
/* in the low order bits. */
struct acct
{
char ac_flag; /* Accounting flag */
char ac_stat; /* Exit status */
uid_t ac_uid; /* Accounting user ID */
gid_t ac_gid; /* Accounting group ID */
dev_t ac_tty; /* control tty */
time_t ac_btime; /* Beginning time */
comp_t ac_utime; /* accounting user time in clock */
/* ticks */
comp_t ac_stime; /* accounting system time in clock */
/* ticks */
comp_t ac_etime; /* accounting total elapsed time in clock */
/* ticks */
comp_t ac_mem; /* memory usage in clicks (pages) */
comp_t ac_io; /* chars transferred by read/write */
comp_t ac_rw; /* number of block reads/writes */
char ac_comm[8]; /* command name */
};
/*
* Accounting Flags
*/
#define AFORK 01 /* has executed fork, but no exec */
#define ASU 02 /* used super-user privileges */
#define ACCTF 0300 /* record type */
#define AEXPND 040 /* Expanded Record Type - default */
下面這個表總結了各個平臺上 struct acct 的差異:
| acct 字段 | Linux | macOS | FreeBSD | Solaris | |
| ac_flag | AFORK (僅 fork 不 exec) | * | * | * | * |
| ASU (超級用戶進程) | * | * | * | ||
| ACOMPAT | * | ||||
| ACORE (發生 coredump) | * | * | * | ||
| AXSIG (被信號殺死) | * | * | * | ||
| AEXPND | * | ||||
| ac_stat (signal & core flag) | * | ||||
| ac_exitcode | * | ||||
| ac_uid / ac_gid / ac_tty | * | * | * | * | |
| ac_btime / ac_utime / ac_stime / ac_etime | * | * | * | * | |
| ac_mem | * (kB/?) | * (?) | * (?) | * (page/click) | |
| ac_io | * (unused) | * (block) | * (block) | * (bytes) | |
| ac_rw | * (unused) | * (block) | |||
| ac_comm | * (17) | * (10) | * (16) | * (8) | |
做個簡單說明:
- 對于異常退出的場景,Linux & macOS & FreeBSD 是通過 ac_flag 來記錄;Solaris 則通過單獨的 ac_stat 來記錄
- 對于正常退出的場景,只有 Linux 的 ac_exitcode 可以記錄進程退出狀態,其它平臺都沒有這個能力
- 對于進程時間
- ac_btime 開始時間為 epoch 時間,單位為秒
- ac_utime 為用戶 CPU 時間,單位為 ticks,含義同 rusage.ru_utime 或 tms.tms_utime
- ac_stime 為系統 CPU 時間,單位為 ticks,含義同 rusage.ru_stime 或 tms.tms_stime
- ac_etime 為實際經歷時間,單位為 ticks,含義同 time 命令中的 real 時間
- 對于進程 IO 統計
- macOS & FreeBSD 傾向于使用塊作為單位,然而當塊大小發生變更后,這個統計實際上會失真
- Solaris 做的好一些,使用的是字節,它也有基于塊為單位的統計,即 ac_rw
- Linux 則根本不具備統計進程 IO 的能力,這兩個字段雖然存在卻總為 0
- 對于進程名,各個平臺均支持,但長度不一致。最長為 Linux 17 字節,最短為 Solaris 8 字節,即使是最長的 Linux,在目前看來也不夠用,不過考慮到記錄數太過龐大,這點可以原諒
進程核算所需的各種數據都由內核保存在進程表中,并在一個新進程被創建時置初值 (通常是 fork 之后的子進程),每次進程終止時都會追加一條結算記錄,這意味著 acct 文件中記錄的順序對應于進程終止順序而不是啟動順序。配合上面的數據結構會導致一個問題——無法確定進程的啟動順序:
- 想推導進程的啟動順序時,通常想到的方法是讀取全部結算記錄,按 ac_btime 進行排序,但因日歷時間的精度是秒,在一秒內可能啟動了多個進程,所以這種排序結果是不準確的
- ac_etime 的單位是時鐘滴答 (ticks),每秒 ticks 通常在 60~128 之間,這個精度是夠了,但由于沒有記錄進程的準確結束時間,所以也無法反推它的準確啟動時間
第二種方案通過記錄進程的準確結束時間來反推進程準確啟動時間,還不如就在一開始記錄準確啟動時間。例如增加一個字段記錄啟動毫秒數,那 Unix 為何不這樣做?我能想到的一個答案——最小化存儲空間占用。增加一個毫秒字段至少需要 2 字節,而一個短整型最大可以表達 65535,對于毫秒而言,數值 1000 以上的空間都無法使用,考慮到龐大記錄數,這是一筆可觀的存儲浪費。再看看 ac_utime / ac_stime / ac_etime 的設計,通過 2 字節類型 (comp_t) 實現了 10ms 的時間精度,反過來看前者的設計,在早期存儲空間寸土寸金的時代,簡直就是一種無法容忍的揮霍行為。
由于進程核算的起始錨定 fork 而非 exec、且只在進程終止時記錄一條信息,這導致另外一個問題——多次 exec 的進程只記錄最后進程的信息,前面 exec 過的進程像“隱身”一樣消失了,就如同它從來沒在系統中存在過一樣,所以 acct 文件也不可全信,即某些進程不在 acct 文件中不代表它不存在。最簡單的做法,一個黑客可以在惡意程序的最后 exec 一個普通命令,來達到抹去 acct 記錄的目的。
exec loop
為了驗證多次 exec 的場景,先寫一個可以循環 exec 的測試程序:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main (int argc, char *argv[])
{
char const* exename=0;
if (argc > 1)
exename = argv[1];
else
exename = "date";
sleep (1);
printf ("%d, start exec: %s\n", getpid(), exename);
if (argc > 1)
execvp (exename, &argv[1]);
else
execlp (exename, exename, (char *)0);
printf ("should not reach here, exec failed?\n");
abort();
}
這個程序會將參數列表作為一個新程序執行,其中 argv[1] 是新程序名,后面是它的參數,如果參數不足 2,則使用默認的 date 命令。如此就可以通過下面的調用執行一個多次 exec 的進程:
> ./exec_loop ./exec_loop ./exec_loop ./exec_loop ps -exjf
32166, start exec: ./exec_loop
32166, start exec: ./exec_loop
32166, start exec: ./exec_loop
32166, start exec: ps
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
11332 11364 11332 11332 ? -1 S 383278 0:00 sshd: yunhai01@pts/0
11364 11370 11370 11370 pts/0 32166 Ss 383278 0:00 \_ bash -c /usr/bin/baas login --baas_user=yunhai01 --baas_role=baas_all_pri
11370 11442 11442 11370 pts/0 32166 S 383278 0:00 \_ /bin/bash -l XDG_SESSION_ID=398726 TERM=xterm SHELL=/bin/bash SSH_CLI
11442 32166 32166 11370 pts/0 32166 R+ 383278 0:00 \_ ps -exjf XDG_SESSION_ID=398726 HOSTNAME=yunhai.bcc-bdbl.baidu.com
上面的過程開啟 accton 錄制,通過書上的解讀 acct 文件的程序 (vacct) 解析后,得到下面的記錄:
> sudo ./vacct
clock tick: 100
sizeof (acct) = 64
...
ps e = 401.00, chars = 0, stat = 0:
...
系統中同時運行的命令太多,通過 grep 過濾,發現沒有任何 exec_loop 記錄;ps 記錄的 elapse 時間為 401 ticks,約 4 秒,和測試程序每 exec 一個命令 sleep 1 秒吻合。看起來確實只記錄了最后的一個進程。
通過給 exec_loop 一個無法執行的命令,看看會有何改變:
> ./exec_loop ./exec_loop ./exec_loop ./exec_loop this_cmd_not_exist
20313, start exec: ./exec_loop
20313, start exec: ./exec_loop
20313, start exec: ./exec_loop
20313, start exec: this_cmd_not_exist
should not reach here, exec failed?
Aborted (core dumped)
...
$ sudo ./vacct | grep exec_loop
exec_loop e = 403.00, chars = 0, stat = 134: D X
最終記錄的是上一個執行成功的 exec_loop (elapse 4 秒),并且它的終止狀態為接受信號后 (X) coredump (D),其中 stat 134 的高位 (第 8 位) 表示 coredump (128),低 7 位為 6 表示接受的信號 (SIGABRT)。
對于 abort,Linux 的表現與書上 Solaris 例子不同,書上不會有 D & X 兩個標志位。關于這一點,通過重跑書上的那個多次 fork 的例子 (oacct) 也可以看清楚:
> ./vacct | grep -E 'oacct|dd|accton'
accton e = 0.00 , chars = 0, stat = 0: S
dd e = 0.00 , chars = 0, stat = 0:
oacct e = 200.00, chars = 0, stat = 0:
oacct e = 402.00, chars = 0, stat = 134: D X F
oacct e = 600.00, chars = 0, stat = 9: X F
oacct e = 800.00, chars = 0, stat = 0: F
accton e = 0.00 , chars = 0, stat = 0: S
accton e = 0.00 , chars = 0, stat = 0:
這個例子通過 fork 生成了 4 個子進程,除其中一個調用 exec 執行 dd 外其它的均沒有 exec,下面對各個 oacct 記錄做個說明:
- 父進程,sleep 2;exit 2
- 第一個子進程,sleep 4;abort
- 第四個子進程,sleep 6;kill
- 第三個子進程,sleep 8;exit 0
- dd 為第二個子進程,未 sleep 所以結束最快
各項 ac_flag 值都能正確展示,其中 F 是首次出現,表示只 fork 未 exec 的進程,后面會看到,作者使用的 ac_flag 助詞符與系統命令完全一致。
lastcomm
如果不需要查看 ac_etime,使用 lastcomm 命令也不錯,以上面三個場景為例,分別輸出如下:
> sudo lastcomm yunhai01 -f /var/accout/pacct.old
ps yunhai01 pts/0 0.01 secs Mon Mar 25 11:55
> sudo lastcomm yunhai01
exec_loop DX yunhai01 pts/0 0.00 secs Mon Mar 25 12:10
$ sudo lastcomm yunhai01 -f /var/account/pacct-20240322
oacct F yunhai01 pts/0 0.00 secs Thu Mar 21 15:02
oacct F X yunhai01 pts/0 0.00 secs Thu Mar 21 15:02
oacct F DX yunhai01 pts/0 0.00 secs Thu Mar 21 15:02
oacct yunhai01 pts/0 0.00 secs Thu Mar 21 15:02
dd yunhai01 pts/0 0.00 secs Thu Mar 21 15:02
因為訪問的 acct 文件具有超級用戶權限,所以這里也需要 sudo 提權。關于 lastcomm 輸出的各個字段,man 手冊頁有一段說明:
For each entry the following information is printed:
+ command name of the process
+ flags, as recorded by the system accounting routines:
S -- command executed by super-user
F -- command executed after a fork but without a following exec
C -- command run in PDP-11 compatibility mode (VAX only)
D -- command terminated with the generation of a core file
X -- command was terminated with the signal SIGTERM
+ the name of the user who ran the process
+ time the process started
分別是:命令名、標志位、用戶、終端、進程啟動時間等,標志位與 ac_flag 有如下對應關系:
- S:ASU
- F:AFORK
- C:ACOMPAT
- D:ACORE
- X:AXSIG
另外除了啟動時間,還打印了 xxx secs 的信息,經過對比應該不是 ac_etime,懷疑是 ac_utime、ac_stime 之一或之和。
通過 -f 可指定不同于默認路徑的 acct 文件;通過參數可以篩選命令、用戶、終端,關鍵字的順序不重要,lastcomm 將用它們在全域進行匹配,只要有任一匹配成功,就會輸出記錄,相當于是 OR 的關系。例如用戶想篩選名為 foo 的用戶,會將名為 foo 的命令也給篩選出來,為了減少這種烏龍事件,lastcomm 也支持指定域匹配:
> lastcomm --strict-match --command ps --user yunhai01 --tty pts/0
這種情況下,各個條件之間是 AND 的關系,只有匹配所有條件的記錄才會輸出。
dump-acct
如果僅在 Linux 上使用,dump-acct 可以輸出比 lastcomm 更豐富的信息:
> dump-acct /var/account/pacct-20240322 | grep 383278
dd |v3| 0.00| 0.00| 0.00|383278|100000|108096.00| 0.00| 25047 25046|Thu Mar 21 15:02:35 2024
oacct |v3| 0.00| 0.00| 200.00|383278|100000| 4236.00| 0.00| 25045 19543|Thu Mar 21 15:02:35 2024
oacct |v3| 0.00| 0.00| 402.00|383278|100000| 4236.00| 0.00| 25046 1|Thu Mar 21 15:02:35 2024
oacct |v3| 0.00| 0.00| 600.00|383278|100000| 4236.00| 0.00| 25049 25048|Thu Mar 21 15:02:35 2024
oacct |v3| 0.00| 0.00| 800.00|383278|100000| 4236.00| 0.00| 25048 1|Thu Mar 21 15:02:35 2024
記錄比較多,通過 grep 過濾了用戶 ID。各列含義如下:
- 命令名:ac_comm
- 版本號,關于 v3 請參考 Linux 擴展:acct_v3
- 用戶 CPU 時間:ac_utime
- 系統 CPU 時間:ac_stime
- 經歷時間:ac_etime
- 用戶 ID:v3 擴展
- 組 ID:v3 擴展
- 內存使用:ac_mem
- IO:ac_io
- 進程 ID:v3 擴展
- 父進程 ID:v3 擴展
- 啟動時間:ac_btime
信息比 lastcomm 豐富,但是缺少對 ac_flag 的展示,這方面還得借助書上的實用工具 (vacct)。
比較有趣的是上例中父進程 ID 的打印,從進程號可以直觀的發現進程啟動順序:
- 25045:oacct 為父進程,sleep 2;exit 2
- 25046:oacct 為第一個子進程,sleep 4;abort。父進程為 1 應當是進程終止時父進程 25045 已結束被過繼給 init 進程
- 25047:dd 為第二個子進程,未 sleep 所以結束最快。父進程為 25046 是第一個子進程,正常
- 25048:oacct 為第三個子進程,sleep 8;exit 0。父進程為 1 應當是進程終止時父進程 25047 已結束被過繼給 init 進程
- 25049:oacct 為第四個子進程,sleep 6,kill 9。父進程 2504 是第三個子進程,正常
acct_v3 版本確實強大,通過父子進程 ID 復習了進程關系中的孤兒進程概念。
對于上節中 lastcomm xxx secs 打印的是 ac_utime 還是 ac_stime,這節可以給出答案:
sudo dump-acct /var/account/pacct.old | grep 383278
ps |v3| 0.00| 1.00| 401.00|383278|100000|153344.00| 0.00| 858 11442|Mon Mar 25 11:55:33 2024
對比之前 lastcomm 的輸出,ps 命令的 secs 字段為 0.1,對應的是 1 ticks,也就是這里的 ac_stime,可見肯定不是 ac_utime,但到底是系統 CPU 時間還是兩者之和,由于這條記錄的 ac_utime 為 0,沒有辦法確認了,需要再找一條記錄看看:
> sudo lastcomm -f /var/account/pacct.old | grep argus_process_s
argus_process_s root __ 0.13 secs Mon Mar 25 11:55
> sudo dump-acct /var/account/pacct.old | grep argus_process_s
argus_process_s |v3| 12.00| 1.00| 36.00| 0| 0| 41160.00| 0.00| 908 20078|Mon Mar 25 11:55:36 2024
這條記錄看得更清楚了,0.13 s = 13 ticks = ac_utime (12) + ac_stime (1),所以最終的結論是:lastcomm 中 xxx secs 輸出的是進程 CPU 總耗時 (ac_utime + ac_stime)。
sa
lastcomm & dump-acct 是針對單個記錄的,如果想統計多個記錄的信息,例如 CPU 總耗時、磁盤 IO 總次數、命令啟動次數等,就需要使用 sa 命令了,針對 lastcomm 中的 oacct 的例子使用 sa 統計,得到如下輸出:
> sa /var/account/pacct-20240322
314 2.77re 0.01cp 0avio 19357k
3 0.02re 0.01cp 0avio 10295k argus_process_s
13 0.04re 0.00cp 0avio 32597k ***other*
3 0.01re 0.00cp 0avio 188160k top
2 0.00re 0.00cp 0avio 234880k sudo
51 2.40re 0.00cp 0avio 1091k sleep
46 0.00re 0.00cp 0avio 3309k killall
28 0.00re 0.00cp 0avio 2454k monitor_webdir_*
21 0.00re 0.00cp 0avio 1093k wc
17 0.00re 0.00cp 0avio 2482k awk
17 0.00re 0.00cp 0avio 1099k date
14 0.00re 0.00cp 0avio 4888k ls
14 0.00re 0.00cp 0avio 2558k monitor_baas_ag*
14 0.00re 0.00cp 0avio 1627k hostname
13 0.00re 0.00cp 0avio 1096k cat
9 0.00re 0.00cp 0avio 2274k grep
8 0.00re 0.00cp 0avio 422528k noah-client*
6 0.00re 0.00cp 0avio 3310k bc
5 0.00re 0.00cp 0avio 2392k sh
4 0.00re 0.00cp 0avio 1091k getconf
3 0.30re 0.00cp 0avio 1059k oacct*
3 0.00re 0.00cp 0avio 181760k bscpserver
3 0.00re 0.00cp 0avio 1097k tr
3 0.00re 0.00cp 0avio 28848k gpu.sh
3 0.00re 0.00cp 0avio 2552k supervise.bscps*
3 0.00re 0.00cp 0avio 703k accton
3 0.00re 0.00cp 0avio 94k migstat
3 0.00re 0.00cp 0avio 91k gpustat
2 0.00re 0.00cp 0avio 1100k tail
各列含義如下:命令出現次數、總經歷時間、總 CPU 時間、平均 IO 次數、CPU 利用率、命令名。
與一般命令不同的是,sa 并不輸出一個標題行,而是將各列的含義追加在數值后面,man 中對此有詳細說明:
The output fields are labeled as follows:
cpu
sum of system and user time in cpu minutes
re
"elapsed time" in minutes
k
cpu-time averaged core usage, in 1k units
avio
average number of I/O operations per execution
tio
total number of I/O operations
k*sec
cpu storage integral (kilo-core seconds)
u
user cpu time in cpu seconds
s
system time in cpu seconds
各個列與 struct acct 的對應關系是:
- u:ac_utime
- s:ac_stime
- cpu:ac_stime + ac_utime
- re:ac_etime,單位為 min
- avio/tio:ac_io
- k/k*sec:?
注意上面的輸出中沒有 dd 命令,原來為了簡潔,小于 2 次的命令都會被折疊到 other 一欄中,需要使用 -a 選項展示它們:
> sa /var/account/pacct-20240322 -a
314 2.77re 0.01cp 0avio 19357k
3 0.02re 0.01cp 0avio 10295k argus_process_s
1 0.00re 0.00cp 0avio 57104k abrt-action-sav
3 0.01re 0.00cp 0avio 188160k top
2 0.00re 0.00cp 0avio 234880k sudo
1 0.00re 0.00cp 0avio 57088k abrt-server
1 0.00re 0.00cp 0avio 31120k abrt-hook-ccpp
1 0.00re 0.00cp 0avio 56576k abrt-handle-eve
51 2.40re 0.00cp 0avio 1091k sleep
46 0.00re 0.00cp 0avio 3309k killall
28 0.00re 0.00cp 0avio 2454k monitor_webdir_*
21 0.00re 0.00cp 0avio 1093k wc
17 0.00re 0.00cp 0avio 2482k awk
17 0.00re 0.00cp 0avio 1099k date
14 0.00re 0.00cp 0avio 4888k ls
14 0.00re 0.00cp 0avio 2558k monitor_baas_ag*
14 0.00re 0.00cp 0avio 1627k hostname
13 0.00re 0.00cp 0avio 1096k cat
9 0.00re 0.00cp 0avio 2274k grep
8 0.00re 0.00cp 0avio 422528k noah-client*
6 0.00re 0.00cp 0avio 3310k bc
5 0.00re 0.00cp 0avio 2392k sh
4 0.00re 0.00cp 0avio 1091k getconf
3 0.30re 0.00cp 0avio 1059k oacct*
3 0.00re 0.00cp 0avio 181760k bscpserver
3 0.00re 0.00cp 0avio 1097k tr
3 0.00re 0.00cp 0avio 28848k gpu.sh
3 0.00re 0.00cp 0avio 2552k supervise.bscps*
3 0.00re 0.00cp 0avio 703k accton
3 0.00re 0.00cp 0avio 94k migstat
3 0.00re 0.00cp 0avio 91k gpustat
2 0.00re 0.00cp 0avio 1100k tail
1 0.03re 0.00cp 0avio 1059k oacct
1 0.00re 0.00cp 0avio 164096k bcm-agent*
1 0.00re 0.00cp 0avio 27024k dd
1 0.00re 0.00cp 0avio 18752k ssh
1 0.00re 0.00cp 0avio 4066k iptables
1 0.00re 0.00cp 0avio 3280k lsmod
1 0.00re 0.00cp 0avio 2394k sh*
1 0.00re 0.00cp 0avio 1115k dmidecode
1 0.00re 0.00cp 0avio 90k gpu-int
這次在倒數第 7 行有 dd 了。默認是按 cpu 倒序排列的,sa 也提供了大量選項來按其它字段排序,感興趣的可自行 man 查看。
命令名后面的星號表示 AFORK 標志,例如上面的輸出中,有兩條 oacct 記錄,1 個不帶星號的是父進程,3 個帶星號的是 fork 未 exec 的子進程,另外 1 個子進程就是 dd。
指定 -m 選項可以按用戶級別查看統計信息:
> sa /var/account/pacct-20240322 -m
314 2.77re 0.01cp 0avio 19357k
root 309 2.44re 0.01cp 0avio 19569k
yunhai01 5 0.33re 0.00cp 0avio 6252k
除了第一列增加了用戶名,最后一列刪除了命令名外,其余各列與之前相同。
總結一下,dump-acct、lastcomm、sa 命令之于 acct 文件的關系,與 w、who、last、lastb、ac 命令之于 utmp、wtmp、btmp 文件的關系類似,關于后者,可以參考《[apue] Unix 系統數據文件那些事兒》。
參考
[1]. Linux/Unix分配進程ID的方法以及源代碼實現
[2]. Linux下如何在進程中獲取虛擬地址對應的物理地址
[4]. Linux Clone函數
[5]. 淺談linux下進程最大數、最大線程數、進程打開的文件數
[6]. 在 Linux 上以樹狀查看文件和進程
[7]. time命令busybox源碼
[8]. 對argv可能的誤解
[9]. 解釋器、解釋器文件
[10]. 如何使腳本的set-user-id位起作用
[11]. Linux setuid使用
本文來自博客園,作者:goodcitizen,轉載請注明原文鏈接:http://www.rzrgm.cn/goodcitizen/p/things_about_process_control.html
浙公網安備 33010602011771號