深入理解計算機系統:程式

騰訊技術工程發表於2019-09-16
導語:這是篇讀書筆記,每次重讀CSAPP都有新的認知,尤其是在進入了後臺通道之後才感受到每天和程式打交道的感覺是如此深刻。


0x00 What is Process?

深入理解計算機系統:程式

[ system structure ]


  • 程式(Process)
    經典定義是一個執行中的程式的例項作業系統對一個正在執行的程式的一種抽象。併發執行,指的是一個程式的指令和另一個程式的指令交錯執行。作業系統實現這種交錯執行的機制稱為上下文切換。


  • 執行緒(Thread)
    一個程式可以由多個執行緒的執行單元組成,每個執行緒都執行在程式的上下文中,並共享同樣的程式碼和全域性資料。


  • 核心(Kernel)
    一個計算機程式,用來管理軟體發出的資料I/O(輸入與輸出)要求,將這些要求轉譯為資料處理的指令,交由中央處理器(CPU)及計算機中其他電子元件進行處理,是現代作業系統中最基本的部分。


  • 外殼(Shell)
    指“為使用者提供使用者介面”的軟體,通常指的是命令列介面的解析器。一般來說,這個詞是指作業系統中提供存取核心所提供之服務的程式。Shell也用於泛指所有為使用者提供操作介面的程式,也就是程式和使用者互動的層面。核心不提供互動。


  • 搶佔(Preemption)
    分為非搶佔式和搶佔式。根據排程主體分使用者搶佔與核心搶佔。
    非搶佔式(Nonpreemptive)——讓程式執行直到結束或阻塞的排程方式。
    搶佔式(Preemptive)——允許將邏輯上可繼續執行的在執行過程暫停的排程方式。可防止單一程式長時間獨佔CPU。


  • 異常控制流(ECF,Exceptional Control Flow)
    ECF發生在硬體層,作業系統層,應用層。控制轉移(control transfer)是指程式計數器對應的指令序列的跳轉,控制轉移序列的叫做處理器的控制流(control flow)。
    某些如跳轉、呼叫和返回是為了使得程式對內部狀態變化(event)做出反應而設計的機制,系統透過使控制流發生突變對發生各種狀態變化。

Exceptions

任何情況下,處理器檢測到event發生,透過異常表(exception table)跳轉到專門處理這類事件的作業系統子程式(exception handler)。

非同步異常由事件產生,同步異常是執行一條指令的直接產物。


類別包含中斷(非同步)陷阱(同步)故障(同步)終止(同步)

  • 中斷——非同步發生,處理器IO裝置訊號的結果。

  • 陷阱——有意的異常。最重要的用途是在使用者程式和核心之間提供一個像過程一樣的介面,叫做系統呼叫。

  • 故障——潛在可恢復的錯誤造成的結果。如果能被修復,則重新執行引起故障的指令,否則終止。
  • 終止——不可恢復的致命錯誤造成的結果。

有高達256種不同的異常型別,如出發錯誤(0)、一般保護故障(13)、缺頁(14)、機器檢查(18)、作業系統定義的異常(32-127,129-255)、系統呼叫(0x80)。


我們常見的段故障(Segmentation fault),是一般保護故障(異常13),通常是因為一個程式引用了一個未定義的虛擬儲存器區域,或者因為程式試圖寫一個只讀的文字段。

深入理解計算機系統:程式
[ Examples of popular system calls ]


Processes

  • 邏輯控制流(Logical Control Flow)
    程式計數器PC值的序列叫做邏輯控制流(邏輯流)。PC對應於程式的可執行目標檔案中的指令,或者是包含在執行時動態連結到程式的共享物件中的指令。

    邏輯流看起來就像是在獨佔處理器地執行程式,每個程式執行邏輯流的一部分然後就被搶佔,實際上處理器透過上下文保護好程式間的資訊,在不同的程式中切換。


  • 併發流(Concurrent Flows)
    併發流指邏輯流在執行時間上與另一個流重疊,多個就叫併發(concurrent)。

    一個程式和其他程式輪流執行叫多工(multitasking)。

    程式佔有CPU執行控制流的每一個時間段叫時間片(time slice)。

    多工也叫做時間分片(time slicing)。

    如果兩個流併發執行在不同的處理器或者計算機,稱為並行流(parallel flow)。


  • 私有地址空間(Private Address Space)
    一般,程式間地址空間讀防寫。程式地址空間32位程式,程式碼段從0x08048000開始,64位程式從0x00400000開始:

深入理解計算機系統:程式
[ Process address space ]

  • 使用者模式和核心模式(User and Kernel Modes)
    透過控制暫存器中的模式位(mode bit)描述程式當前享有的特權。
    核心模式:(超級使用者)可執行指令集中任何指令,並且可以訪問系統中任何儲存器位置。

    使用者模式:不允許執行特權指令,不允許直接引用地址空間中核心區內的程式碼和資料,任何嘗試都會引發致命保護故障。可以透過系統呼叫介面間接訪問核心程式碼和資料。


  • 上下文切換(Context Switches)
    核心為每個程式維持一個上下文(context),是核心重新啟動一個被搶佔的程式所需的狀態。包括:
    通用目的的暫存器、浮點暫存器、程式計數器、使用者棧、狀態暫存器、核心棧和各種核心資料結構(地址空間的頁表、有關當前程式資訊的程式表、程式已開啟檔案的資訊的檔案表

    核心排程器(scheduler)負責排程程式,搶佔當前程式,重新開始先前被搶佔的程式。


0x01 101 Inside Process

Process Control

如何控制程式?

PID
pid > 0
#include <sys/types.h> // for pid_t#include <unistd.h>
pid_t getpid(void); // 獲取程式IDpid_t getppid(void); // 獲取父程式ID

Creating and Terminating Process

從程式角度來看,程式總處於以下三種狀態:

  • Running——要麼處於CPU執行中,要麼處於等待被執行且最終會被核心排程

  • Stopped——程式被掛起(suspend),且不會被排程。當收到SIGSTOP、SIGTSTP、SIGTTIN或者SIGTTOU訊號時,程式停止,直到收到SIGCONT訊號,程式再次開始執行。

  • Terminated——程式永遠停止了。三種原因導致終止:
    1)收到一個預設行為時終止程式的訊號;
    2)從主程式返回;
    3)呼叫exit。

#include <sys/types.h>#include <unistd.h>/* 建立子程式* 返回:子程式=0,父程式=子程式PID,出錯=-1*/pid_t fork(void);
#include <stdlib.h>void exit(int status);

父程式透過呼叫fork建立一個新的執行子程式,最大的區別在於不同的PID。

  • fork():一次呼叫,返回兩次。
    1)在呼叫程式中(父程式),返回子程式PID;
    2)在新建立的子程式中,在子程式中返回0。


  • 併發執行:父子程式是併發執行的獨立程式。


  • 相同但是獨立的地址空間。子程式與父程式使用者級虛擬地址空間相同的複製,相同的本地變數值、堆、全域性變數、以及程式碼。如程式碼中print出來不一樣的x。


  • 共享檔案:任何開啟檔案描述符相同的複製,如stdout。
int main() {    pid_t pid;    int x = 1;
    pid = fork(); // 在此處分裂出了兩條時間線!    if (pid == 0) {// 子程式        printf("child: x=%d\n", ++x);        exit(0);    }    // 父程式    printf("parent: x=%d\n", --x);    exit(0);
    return 0;}

out:
parent: x=0
child: x=2
child       |————x=2————father  ——————————x=0————          fork          exit

Reap Child Process

程式終止時,保持位已終止狀態,直到被父程式回收(reap)。當父程式回收已終止的子程式,核心將子程式的退出狀態傳遞給父程式,然後拋棄已終止的程式,此刻程式不復存在。

殭屍程式(zombie):一個終止了但還未被回收的程式。但是如果父程式沒有回收就終止了,則核心安排init程式(PID=1)回收殭屍程式。
#include <sys/types.h>#include <sys/wait.h>
/* 程式可以呼叫waitpid等待子程式終止或者結束。* 預設options=0,掛起呼叫程式,直到它等待集合中的一個子程式終止。如果等待集合中的一個程式在剛呼叫的時刻就已經終止了,那麼waitpid立即返回。返回已終止的子程式PID,並去除該子程式。
*輸入引數pid:pid>0,等待集合就是一個單獨的子程式,程式ID等於pid。pid=-1,等待集合是由父程式所有的子程式組成。
*輸入引數options:WNOHANGE:等待集合中任何子程式都還沒有終止,立即返回0;預設行為還是掛起呼叫程式直到子程式終止。WUNTRACED:掛起呼叫程式執行,直到集合中有一個程式終止或停止。返回該程式PID。WNOHANGE|WUNTRACED:立刻返回,0=如果沒有終止或停止的子程式;PID=終止或停止的子程式PID。
*輸入引數status:WIFEXITED:True=子程式是透過return或者exit終止的;WEXITSTATUS:返回exit狀態,只有WIFEXITED=True時被定義;WIFSIGNALED:True=子程式是因為一個未被捕獲的訊號終止的;WTERMSIG:返回導致子程式終止訊號量,只有WIFSIGNALED=True被定義;WIFSTOPPED:True=返回的子程式是停止的;WSTOPSIG:返回引起子程式停止的訊號的數量,只有WIFSTOPPED=True被定義;
返回:成功=子程式PID;if WNOHANG=0;其他錯誤=-1(errno=ECHILD,沒有子程式;errno=EINTR,被一個訊號中斷)*/pid_t waitpid(pid_t pid, int *status, int options);pid_t wait(int *status); //等價於waitpid(-1, &status, 0);

Sleep
#include <unistd.h>
// 返回:seconds left to sleepunsigned int sleep(unsigned int secs);
// 讓呼叫函式休眠,直到收到一個訊號// 返回:-1int pause(void);


Loading and Running Programs

execve函式在當前程式的上下文中載入並執行一個新的程式,覆蓋當前程式的地址空間,但並沒有建立一個新程式,程式PID沒有改變。
#include <unistd.h>// 返回:成功=不返回;出錯=-1int execve(const char *filename, const char *argv[],            const char *envp[]);// 程式主入口:int main(int argc, char **argv, char **envp);int main(int argc, char *argv[], char *envp[]);

Signal

深入理解計算機系統:程式
[ Linux Signal(`man 7 signal`) ]

訊號傳遞到目的程式包括兩個步驟:1)傳送;2)接收。

  • 一個發出卻沒被接收的訊號叫做待處理訊號(Pending Signal)。

  • 一個程式有一個型別為k的待處理訊號,後面傳送到這個程式的k訊號都會被丟棄。

  • 也可以選擇性阻塞接收某個訊號,訊號被阻塞時仍可以傳送,但產生的待處理訊號不會被接收,直到程式取消對這種訊號的阻塞。

  • 一個待處理訊號最多隻能被接收一次,核心為每個程式在pending位向量中維護待處理訊號集合,而在blocked位向量中維護被阻塞的訊號集合。

  • 只有接收了k訊號,核心才會清除pending中的k位。


Sending Signal
  • 每個程式都只屬於一個程式組,程式組ID標識。unix所有傳送訊號的機制都是基於程式組(process group)/
#include <unistd.h>
// 返回:呼叫程式的程式組IDpid_t getpgrp(void);// 返回:成功=1,錯誤=-1int setpgid(pid_t pid, pid_t pgid);

  • 用/bin/kill程式傳送訊號
    傳送訊號9到程式15213
    /bin/kill -9 15213
    傳送訊號9到程式組15213中的每個程式。

    /bin/kill -9 -15213


  • 從鍵盤傳送訊號
    unix使用作業(job)表示對每一個命令列執行而建立的程式,至多一個前臺作業和0個或多個後臺作業。透過|unix管道連線起多個程式。

    shell位每個作業建立一個獨立的程式組。程式組ID是取自job中父程式中的一個。


    Ctrl + C傳送SIGINT訊號到前臺程式組中的每一個程式,終止前臺作業。

深入理解計算機系統:程式
[ 前臺程式子程式和父程式具有相同的程式組ID。]

  • 用KILL函式傳送訊號。
#include <signal.h>// 輸入引數pid:// pid>0:傳送SIGKILL給程式pid// pid<0:傳送SIGKILL給程式組abs(pid)// 返回:成功=0,失敗=-1int kill(pid_t pid, int sig);

  • alarm函式傳送訊號
#include <unistd.h>// 傳送SIGALRM給呼叫程式,如果secs位0,則不會排程alarm。任何情況,對alarm呼叫都將取消任何pending alarm,並返回pending alarm在被髮送前還剩下的秒數。// 返回:前一次alarm剩餘的秒數,0=以前沒有設定alarmunsigned int alarm(unsigned int secs);

/* 定時1s觸發alarm handler,5s結束 */#include <unistd.h>#include <stdio.h>#include <stdlib.h>

void handler(int sig) {   static int beeps = 0;   printf("BEEP\n");   if (++beeps < 5) {       alarm(1);   } else {        printf("BOOM!\n");        exit(0);   }}

int main() {   signal(SIGALRM, handler);   alarm(1);   for(;;);   exit(0);}


Receiving Signals
wtf:當異常處理程式返回時,準備轉移控制權給程式p時,會檢查非被阻塞的待處理訊號的集合(pending&~blocked)if 集合為空:   程式p的邏輯控制流下一跳指令else:   選擇某個最小訊號k,強制p接收訊號k   goto wtf


每個訊號型別預定義的預設行為(檢視Figure8.25):
  • 程式終止
  • 程式終止並轉儲存器(dump core)
  • 程式停止直到被SIGCONT訊號重啟
  • 程式忽略該訊號
#include <signal.h>

// 定義訊號處理函式(signal handler)// 輸入int為訊號量typedef void (*sighandler_t)(int);// 輸入函式sighandler_t:// handler=SIG_IGN,忽略型別為signum的訊號;// handler=SIG_DFL,重置型別為signum訊號的行為。//// 返回:成功=指向前次處理程式指標,出錯=SIG_ERR(不設定errno)sighandler_t signal(int signum, sighandler_t handler); // installing the handler



/* ctrl-c中斷sleep,並列印睡眠時間 */#include <unistd.h>#include <stdlib.h>#include <stdio.h>#include <signal.h>

void handler(int sig) {} // 改變SIGINT處理函式

int snooze(unsigned int sec) {   int left_sleep_sec = sleep(sec);   printf("Slept for %d of %d secs.\tUser hits ctrl-c after %d seconds\n",           left_sleep_sec, sec, sec-left_sleep_sec);}

int main(int argc, char *argv[]) {   if (SIG_ERR == signal(SIGINT, handler)) {       exit(-1);   }

   unsigned int sleep_sec = 0;   if (argc > 1) {       sleep_sec = atoi(argv[1]);   } else {       exit(0);   }   printf("sleep for %d seconds\n", sleep_sec);   snooze(sleep_sec);   exit(0);}

Signal Handling Issues

當程式需要捕獲多個訊號時,問題產生了。

  • 待處理訊號被阻塞。Unix訊號處理程式通常會阻塞當前處理程式正在處理的型別的待處理訊號k。如果另一個訊號k傳遞到該程式,則訊號k將變成待處理,但是不會被接收,直到處理程式返回。再次檢查發現仍有待處理訊號k,則再次呼叫訊號處理函式。


  • 待處理訊號不會排隊等待。任意型別最多隻有一個待處理訊號。當目的程式正在執行訊號k的處理程式時是阻塞的,當傳送兩個訊號k,僅第一個訊號k會變成待處理,第二個則直接被丟棄,不會排隊等待。


  • 系統呼叫可以被中斷。像read、wait和accept呼叫過程會阻塞程式的稱謂慢速系統呼叫,當捕獲到一個訊號時,被中斷的慢速系統呼叫在訊號處理返回時不再繼續,而是立即返回使用者一個錯誤條件,並將errno設定為EINTR。(即使sleep被訊號處理捕獲後仍會返回)



Explicitly Blocking and Unblocking Signals
#include <signal.h>

// how = SIG_BLOCK, blocked=blocked | set// how = SIG_UNBLOCK, blocked=blocked &~ set// how = SIG_SETMASK, blocked = setint sigprocmask(int how, const sigset_t *set, sigset_t *oldset); int sigemptyset(sigset_t *set);// 將每個訊號新增到setint sigfillset(sigset_t *set);// 新增signum到setint sigaddset(sigset_t *set, int signum);// 從set中刪除signumint sigdelset(sigset_t *set, int signum);//Returns: 0 if OK, −1 on error

int sigismember(const sigset_t *set, int signum);//Returns: 1 if member, 0 if not, −1 on error

Nonlocal Jump

作用允許從一個深層巢狀的函式呼叫中立即返回。


另一個作用是使一個訊號處理程式分支到一個特殊的位置sigsetjmp/siglongjmp。

#include <setjmp.h>

int setjmp(jmp_buf env);int sigsetjmp(sigjmp_buf env, int savesigs);// Returns: 0 from setjmp, nonzero from longjmps

void longjmp(jmp_buf env, int retval);void siglongjmp(sigjmp_buf env, int retval);// Never returns

jmp_buf env;rc=setjmp(env); // 儲存當前呼叫環境if(rc == 0) dosomething();else if (rc == 1) dosomething1(); // 如果else if (rc == 2) dosomething2();

int dosomething() {   longjmp(buf,1); // 跳轉到setjmp,返回1   // longjmp(buf,2); // 跳轉到setjmp,返回2}

操作程式工具
STRACE:列印一個正在執行的程式和它的子程式呼叫的每個系統呼叫的軌跡。
PS:列出當前系統中的程式(包括殭屍程式)。
TOP:列印關於當前程式資源使用的資訊。
PMAP:顯示程式的儲存器對映
/proc:一個虛擬檔案系統,以ASCII輸出大量核心資料結構的內容。如cat /proc/loadavg,觀察Linux系統上的當前的平均負載。

相關文章