CSAPP Chapter 8:Exception Control Flow

  prcesssor在運行時,假設program counter的值為a0, a1, … , an-1,每個ak表示相對應的instruction的地址。從ak到ak+1的變化被稱為control transfer。一系列的control transfers被稱為control flow。

  exceptions是指一些event,這些event表明當前的system、processor或executing program存在某些狀況(詳見1.2)。exceptions會導致control flow的突變,典型的就是將控制從當前運行的程式或任務轉移到exception handler的執行(詳見1.1節)。在電腦程式中,我們設計了jump和branch,call和return;他們通過program state的變化,引起了control flow的突變。exceptions中的control flow的突變是通過system state的變化來引發的,這種control flow的突變被稱為exception control flow。interrupt作為exceptions的一種,當由I/O devices complete引發interrupt後,I/O devices通過pin的變化給processor發送signal,並將exception number放到system bus上,processor接收到signal並從system bus上獲取到exception number,然後將控制轉移到exception handler中[1][2][3]。exception control flow除了應用於在exceptions中,也應用於signal(見第4節)、context switch(見2.3節)和setjump中(見第5節)。

1.exceptions

1.1 exceptions從產生到處理的流程

  exceptions作為exception control flow的一種,它從產生到處理的流程由processor和operate system共同實現的。exceptions的流程中processor和operate system分別執行了哪些工作容易使人困惑。exceptions可以由當前正在執行的instruction而產生,也可以由I/O devices等外部原因而產生,由外部原因產生的異常也被稱為external interrupt。exceptions在產生時,會出現processor state的變化,同時會生成exception  number。對於external interrupt和其它的exception,exception number的產生機制不同。當處理器感知到processor state的變化,就會將控制轉移到exception handler的執行(類似於call procedure)。exception handler是根據exception  number去exception table中匹配到的,exception table的地址存儲在register中。

1.1.1生成exception number

  intel 64和IA-32的架構為每個processor-detectable exception(包括faults,traps和aborts)定義了一個exception number。對於external interrupt,當local APIC(Advanced Programmable Interrupt Controller )啟用時,exception number由I/O APIC決定,並通過LINT[1:0] pin 告訴processor異常發生了。當local APIC關閉時,exception number由external interrupt controller 決定,並通過INTR/NMI pin 告訴processor異常發生了[2]

1.1.2exception handling

  如圖1所示,Interrupt descriptor table(IDT) 將每個exception number(圖1中為Interrupt Vector)和用於定位exception handler(圖1中為interrupt procedure)的gate descriptor關聯起來。圖1適用於IDT為interrupt/trap gate的情況[2]。gate中的segment selector指向GDT/LDT中的segment descriptor,segment descriptor指向Destination code segment。gate中的offset指向了exception handler(圖1中為interrupt procedure)的起始地址。

圖1 interrupt procedure call(也稱為exception handler call)

  IDT可以存在於linear address space的任何位置。如圖2所示,IA32 processor(IA32e與其相似,只是每個gate for interrupt的大小為16bytes)使用IDRT register定位IDT。IDRT register包含32-bit的base address和16-bit的IDT limit[2]

  

圖2 IDTR和IDT的關係

1.1.3 return from exception handler

  當IA32e processor發起對exception handler的調用時,且沒有stack switch的情況下,IA-32e mode 載入一些變數到當前棧中,如圖3所示[2]。IA-32 mode會按順序保存SS和RSP到當前棧中;保存當前的RFLAGS;保存CS和RIP到當前棧中;如果exceptions發生時有保存error code,error code也會保存到當前棧中。CS(code segment selector)和RIP表示return address,指向faulting instruction的位置。SS(stack segment selector)和RSP指向interrupted procedure的位置。當exception handler執行結束時,會執行IRET instruction從當前的異常處理程式返回,IRET instruction與RET intruction的執行類似。當程式返回時,有下面三種情況:1)返回到當前faultiong instruction的位置Icurr(參考圖4);2)返回到faulting instrution的下一條指令Inext;3)退出intrrupted program。

圖3 Interrupted Procedure’s and Handler’s Stack 

 

圖4 Anatomy of an exception 

1.2 exceptions的分類

  exceptions可分為四類:interrupt、trap、fault和abort。圖5中的表格總結了這四類exceptions的一些特性,可以看出它們在cause和return behavior上區別。另外,它們的exception handler也各不相同。

  

圖5 exceptionsd的分類

1.2.1 interrupt

  interrupt由I/O devices引起的,不是由當前指令的執行而導致的,它是非同步的。像network adapters、disk controllers 和timer chips等I/O devices,它們通過pin的變化告知processor異常發生了,並將exception number 放到system bus上。非同步的異常發生時,會先執行完當前instuction,然後才會做出對異常的反應。當processor感知到interrupt pin走高時,他從system bus中獲取exception number,然後將控制轉移到合適的interrupt handler。當從interrupt handler返回時,會返回到interrupt發生時執行指令的下一條指令Inext

1.2.2 trap 和system calls

  User programs在執行read、fork、execve和exit等函數時,需要kernel中的services提供支援。為了能訪問到kernel中的service,processor提供了syscall instruction。當syscall instruction執行時,會將控制轉移到exception handler的執行,exception handler先解析參數,然後依據參數調用對應的kernel routine。當從exception handler返回時,會返回到syscall指令的下一條指令Inext。當Unix的系統級函數遇到error時,他會返回-1並設置全局變數errno。

  圖6列出了幾種常見的system call的函數,每個函數表示一種system call。每一種system call都與唯一的number對應,number表示在kernel中相對於jump table的offset(注意jump table與exception table 不同)。不同的類型的system call在進行syscall(彙編指令)之前,會將number存儲到參數(比如%rax)中。下圖中的write和_exit函數在編譯後的彙編程式碼如圖7所示,write和_exit對應的number分別為1和60,這些number在syscall指令前存儲到%rax中。

圖6 在x86-64系統中常見的system calls

int main()
{
    write(1,"hello,world\n",13);
    _exit(0);
}
.section .data
string:
  .ascii "hello, world\n"
string_end:
  .equ len, string_end - string
.section .text
.globl main
main:
  First, call write(1, "hello, world\n", 13)
  movq $1, %rax          write is system call 1
  movq $1, %rdi          Arg1:stdout has descriptor 1
  movq $string, %rsi     Arg2: hello world string
  movq $len, %rdx        Arg3: string length
  syscall                Make the system call
  Next, call _exit(
0)   movq $60, %rax _exit is system call 60   movq $0, %rdi Arg1: exit status is 0   syscall Make the system call

圖7 syscall的示例程式碼(文件位置code/ecf/hello-asm64.sa)

1.2.3 fault

  Faults通常是可以糾正的異常,異常糾正後中斷的程式會繼續執行。由於當前instruction執行引發fault時,processor會先將machine state恢復到該instuction執行前的狀態;然後開始執行fault hander。當從fault handler返回時,如果異常得到糾正時則返回到當前執行instruction Icurr;否則返回到abort routine中,使程式終止。

  page fault exception是fault的典型例子,它發生在當virtual address所在virtual page沒有對應的physical page時。fault handler會在physical memory中選擇victim page作為新的對應的physical page,然後重新執行fault insrtuction(詳見Chapter9 3.1)。★★引用鏈接

1.2.4 abort

   abort指不能恢復的fetal errors,比如在DRAM或SRAM崩潰是發生的parity errors等hardware error。abort handler從不會返回到當前執行的程式並繼續執行;它會返回到abort routine中,abort routine會終止程式。

2.process的概念   

  process指當一個正在執行的程式實例。每個程式運行在process的context中。context包括程式運行需要的state,比如code and data、stack、register、program counter、environment variable和open file descriptor等。當一個程式運行時,shell會創建一個新的process,然後載入並運行executable object file。prrocess的context可以抽象為兩個部分:1)An indepent logical control flow;2)A private address space。

  An independent logical control flow是一個抽象概念,它表示在某一特定時刻,一個process中運行的程式對processor是獨享的。一個程式運行時的PC序列被稱為logical control flow。如圖8所示,processor的control flow被分成3個logical flow,它們分別被process A、B和C佔有。這3個logical flow是交叉輪流執行的,如圖Process A先執行,緊接著process B執行,然後processC執行,再然後processA執行,最後process C執行。如果兩個precess的logic flow有交叉(比如A-B-A),我們認為A、B是並發的。如果兩個logic flow在不同的processor上並發的運行,我們稱它們是並行的。如圖8,A和B、A和C是並發執行的。

圖8 logic control flows

  A private address space也是一個抽象概念,它表示每個process在硬碟上的virtual address space是私有的(見第8章★★)。圖9展示了virtual address space的結構。address space的上部分是為kernel保留的,它包含kernel在執行時所需的code、data、heap和stack。address space的下部分是為user program保留的,它包含code、data、heap和stack等部分。code部分的起始地址為0x400000。

圖9 process address space

2.1User and Kernel Modes

  為了給operation system kernel提供封閉的process abstraction,我們將process分為user mode和kernel mode,它們具有不同的許可權。kernel mode可以執行任何指令,訪問記憶體的任何位置;user mode不能執行像halt a processor,change the mode bit或者initiate the I/O operations,也不能訪問address space中的kernel virtual memory區域。mode的切換是通過某些control register中的mode bit進行控制的。當mode bit設置時,process處於kernel mode;當mode bit未設置時,process處於user mode。由user mode切換到kernel mode的唯一方式是通過interrupt、trap、fault和abort等exceptions。當exceptions發生時,控制轉移到exception hander,同時process由user mode切換到kernel mode;當從exception handler返回時,process再由kernel mode切換user mode。在user mode下,可以通過system call的方式間接訪問kernel code and data。

  Linux提供/proc文件系統,可以在user mode下訪問kernel data structures。 比如,通過/proc/cpuinfo可以或取CPU型號,通過/proc/process-id/maps可以獲取某個進程的memeory segments。在2.6版本中提供了/sys filesystem,可以導出關於system buses和system devices的資訊。

2.2 context switch 

  進程間的切換就是一個進程被搶佔,另一個之前被搶佔的進程開始執行,進程調度由kernel中的scheduler來決定。當processor在多個進程間切換時,會發生context switch。context switch基於exception機制實現的,它也是exception control flow的一種。kernel為每個進程維護一個context。context switch包含3個步驟:1)保存當前進程的context;2)還原之前被搶佔進程的context;3)給予新還原的進程式控制制權。

  如圖10所示,剛開始process A在user mode下執行,當執行到read函數時,會發起system call,將控制轉移到kernel mode下的trap handler。trap handler通過DMA將disk的文件讀取到memory中,這個過程比較耗時(大約幾十毫秒)。此時,kernel中scheduler會進行context switch,由processA切換到processB,然後processB在user mode下執行。當disk controller將文件從disk讀取到memeory後,會引發interrupt。此時,kernel中scheduler會進行context switch,由process B切換到processA,然後process A在user mode下執行。★★★是否進入到interrupt handler。

  

圖10 剖析進程context switch

3.Process Control

  C程式中有許多對進程進行的操作的函數,它們是基於Unix為C程式提供的system calls的。比如,我們可以通過getpid獲取當前執行進程的process ID,通過getppid獲取當前執行進程的父進程的process ID。

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);

  進程的狀態可以分為running、stopped和terminated。running指process當前正在執行或者等待被kernel調度執行。stopped指進程暫停,不會被kernel調度,當running進程接收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU signal時,進程變為stopped;當stopped進程接收到SIGCONT,進程變為running。terminated指進程永久停止,主要有3種情況:1)接收終止進程的signal;2)從main任務返回;3)調用exit函數。

#include <stdlib.h>

void exit(int status);

  可以通過sleep函數讓進程暫停指定的時間。當sleep的時間到達後,函數返回0,進程變為running;當在sleep過程中,如果進程接收到signal,則sleep會提前中斷,函數返回剩餘的時間。也可以通過pause函數來暫停進程,進程只有在接收到signal時才會變為running,函數總是返回-1。

#include <stdlib.h>

unsigned int sleep(unsigned int secs);
#include <unistd.h>

int pause(void);

3.1 fork

  一個父進程可以通過調用fork函數創建一個running的子進程。如圖11所示,通過fork創建子進程,子進程將父進程的user-level virtual address space拷貝了一份,子進程和父進程共享physical memeory(見第9章)。★★鏈接★fork函數在子進程中返回0,在父進程中返回1。圖11的程式運行結果為:parent : x=1 child : x=2或者child : x=2 parent : x=1。fork函數的特點可總結如下:1)Call once,return twice。fork函數調用一次返回兩次,分別給父進程和子進程返回1和0;2)Corruent execution。父進程和子進程是並發執行的;3)seperated virtual address space,共享physical memory。子進程將父進程user-level vitual address space拷貝了一份,子進程和父進程共享physical memory。

#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);
#include "csapp.h" 

int main(){
    pid_t pid;
    int x =1;
    
    pid = fork();
    if(pid==0){ /*child*/
        printf("child : x=%d\n",++x);
        exit(0);
    }
  
  /*parent*/ printf(
"parent : x=%d\n",--x); exit(0); }

圖11 使用fork函數創建子進程

  一個程式中調用多次fork函數的情況容易使人混淆。根據程式程式碼畫出進程執行流程圖是有幫助的,在進程流程圖中可以直觀的看到進程的創建以及各個進程中的重要操作。如圖12所示,調用了兩次fork函數;第一次fork函數調用創建子進程,在新創建的子進程中再次調用fork函數。

圖12 圖解fork函數嵌套執行

  當unix的system-level函數遇到error時,它們通常返回-1並給errno賦值。我們可以對fork進行如下異常檢查,但這使得程式碼可讀性變差。我們可以使用error-handling wrappers來簡化程式碼。Wrapper調用原函數並檢查error。比如,圖13為函數fork的error-handling wrappers。在後面的章節中都將使用error-handling wrappers,這可以保持程式碼簡潔。

if((pid = fork()<0){
    fprintf(stderr,"fork error: %s\n", strerror(errno));
    exit(0);   
}
void unix_errot(char *msg) /*Unix-style error*/
{
    fprintf(stderr,"%s: %s\n",msg,strerror(errno));
    exit(0);
}

pid_t Fork(void)
{
    pid_t pid;
    if((pid = fork())<0)
      unix_error("Fork error");         
   return pid;
}

圖13 error-handling wrappers函數Fork

3.2 wait

  當一個process執行exit後,process相關的記憶體和資源都會被釋放,但是process在process table中的process『s entry仍然保留。狀態為terminated且尚未從process table中移除的進程被稱為zombie。當子進程exit後,會向父進程發送SIGCHLD signal;父進程可以通過wait函數來接收SIGCHLD signal,然後將zombie從process table中移除。如果父進程沒有成功調用wait函數,zombie將會在process table中遺留。當一個子進程變為zombie後,可以通過終止它的父進程來清除zombie,這是因為init process的存在。init process 是所有進程的祖先,他的PID為1,在系統啟動時即創建,且永遠不會terminated。當子進程的父進程終止時,子進程變為orphan。kernel將init process作為所有orphan的父進程。以init process作為父進程的進程終止後,kernel會安排init process去移除zombie。init process會周期地移除父進程為init的zombie。在像shells或servers等長期運行的程式中,應該總是移除zombie;如果zombie不能及時移除,可能會引起process table entries不足。

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *statusp);

  進程在執行wait函數時,如果已經有子進程終止,wait函數會立即返回pid;如果沒有終止的子進程,那麼該進程會暫停執行直到出現子進程終止,然後wait函數返回pid。在wait函數執行過程中,kernel已經將terminated child在系統中的痕迹移除。相比於wait函數,waitpid函數適用於更複雜的情況。當參數pid大於0時,只有該pid的進程在父進程的等待範圍內;當參數等於-1時,所有的子進程都在父進程的等待範圍內。options表示進程的等待策略。當一個進程執行waitpid函數且options的值為默認值0時,如果等待集合中的進程已經有終止的,waitpid函數會立即返回pid;如果等待集合中的進程都沒有終止,那麼該進程會暫停直到等待集合中的進程終止。options的值還可以為WNOHANG、WUNTRACED和WCONTINUED。相比於options為默認值時,當options的值為WNOHANG時,如果等待集合中的進程都沒有終止,waitpid函數也會立即返回,返回值為0。相比於options為默認值時,當options的值為WUNTRACED時,如果等待集合中的進程發生terminated或stopped時,waitpid函數都會返回。相比於options為默認值時,當options的值為WCONTINUED時,如果等待集合中的進程發生terminated或由stopped狀態變為running狀態時,waitpid函數都會返回。

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *statusp, int options);

  如果waitpid的參數statusp不為Null,那麼子進程的資訊會被存儲在statusp指向的位置。可以通過宏命令來解釋statusp,比如WIFEXITED(statusp*)為true表示子進程是通過exit或return正常終止的。其它的宏命令有WEXITSTATUS、WIFSIGNALED、WTERMSIG、WIFSTOPPED、WSTOPSIG和WIFCONTINUED等。

  如果調用waitpid函數的進程沒有子進程,函數會返回-1,並將errno設置為ECHILD;如果waitpid函數被一個signal中斷,函數會返回-1,並將errno設置為EINTR。wait(*statusp)函數可以看成是waitpid(-1,*statusp,0)。

  圖14為waitpid函數的使用示例。它的輸出結果為:

      linux>./waitpid1

      chilld 22966 terminated normally with exit status=100

      chilld 22967 terminated normally with exit status=101。

#include "csapp.h"
#define N 2

int main()
{
  int status, i;
  pid_t pid[N], retpid;

/* Parent creates N children */
for (i = 0; i < N; i++)
  if ((pid[i] = Fork()) == 0) /* Child */
    exit(100+i);

/* Parent reaps N children in order */
  i = 0;
  while ((retpid = waitpid(pid[i++], &status, 0)) > 0) {
    if (WIFEXITED(status))
      printf("child %d terminated normally with exit status=%d\n",
        retpid, WEXITSTATUS(status));
    else
      printf("child %d terminated abnormally\n", retpid);
  }

/* The only normal termination is if there are no more children */
  if (errno != ECHILD)
    unix_error("waitpid error");

  exit(0);
}

圖14 按子進程的創建順序移除zombie children

3.4 execve

  execve函數在當前進程的context中載入並運行一個新的程式。當execve正常執行時,沒有返回值;當execve運行error時,返回值為-1。argv變數表示null-terminated的指針數組,每個指針指向一個argument string。按照慣例,argv[0]是filename。envp變數表示null-terminated的指針數組,每個指針指向一個argument string,string的格式是「name=value」。當execve載入filename後,調用start-up code。start-up code會進行stack的設置並進入到程式的main routine。main routine的格式為int main(int argc, char *argv[], char *envp[]),參數argc表示argv[]中的指針個數。

#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);

  當main函數開始執行時,user stack的結構如圖15所示。stack的中間是*envp[]和*argv[]表示的指針數組,每個指針指向一個底端的variable string;stack的頂端是為start-up函數libc_start_main保留的。

圖15 新程式啟動時的user stack

3.5 使用fork和execve運行程式

  在Unix shells和 Web servers中,fork和execve被大量使用。當打開一個control terminal時,就會運行一個shell程式,shell程式會根據用戶輸入載入並開始運行新的程式。圖16展示了一個簡單的shell程式。這個shell列印命令提示符,等待用戶在stdin輸入command line,然後解析command line並運行command line指定的程式。(★★★圖16的程式還有疑問)

 1  1 #include "csapp.h"
 2  2 #define MAXARGS 128
 3  3 
 4  4 /* Function prototypes */
 5  5  void eval(char *cmdline);
 6  6  int parseline(char *buf, char **argv);
 7  7  int builtin_command(char **argv);
 8  8  
 9  9 int main()
10 10  {
11 11    char cmdline[MAXLINE]; /* Command line */
12 12 
13 13    while (1) {
14 14      /* Read */
15 15      printf("> ");
16 16      Fgets(cmdline, MAXLINE, stdin);
17 17      if (feof(stdin))
18 18        exit(0);
19 19 
20 20    /* Evaluate */
21 21    eval(cmdline);
22 22    }
23 23  }
24 24 
25 25 /* eval - Evaluate a command line */
26 26 void eval(char *cmdline)
27 27 {
28 28   char *argv[MAXARGS]; /* Argument list execve() */
29 29   char buf[MAXLINE]; /* Holds modified command line */
30 30   int bg; /* Should the job run in bg or fg? */
31 31   pid_t pid; /* Process id */
32 32 
33 33   strcpy(buf, cmdline);
34 34   bg = parseline(buf, argv);
35 35   if (argv[0] == NULL)
36 36   return; /* Ignore empty lines */
37 37 
38 38   if (!builtin_command(argv)) {
39 39     if ((pid = Fork()) == 0) { /* Child runs user job */
40 40       if (execve(argv[0], argv, environ) < 0) {
41 41         printf("%s: Command not found.\n", argv[0]);
42 42         exit(0);
43 43       }
44 44     }
45 45 
46 46     /* Parent waits for foreground job to terminate */
47 47     if (!bg) {
48 48       int status;
49 49       if (waitpid(pid, &status, 0) < 0)
50 50         unix_error("waitfg: waitpid error");
51 51     }
52 52     else
53 53       printf("%d %s", pid, cmdline);
54 54     }
55 55     return;
56 56   }
57 57 
58 58   /* If first arg is a builtin command, run it and return true */
59 59   int builtin_command(char **argv)
60 60   {
61 61     if (!strcmp(argv[0], "quit")) /* quit command */
62 62       exit(0);
63 63     if (!strcmp(argv[0], "&")) /* Ignore singleton & */
64 64       return 1;
65 65   return 0; /* Not a builtin command */
66 66 }
67 67 
68 68 /* parseline - Parse the command line and build the argv array */
69 69 int parseline(char *buf, char **argv)
70 70 {
71 71   char *delim; /* Points to first space delimiter */
72 72   int argc; /* Number of args */
73 73   int bg; /* Background job? */
74 74 
75 75   buf[strlen(buf)-1] = 』 』; /* Replace trailing 』\n』 with space */
76 76   while (*buf && (*buf == 』 』)) /* Ignore leading spaces */
77 77     buf++;
78 78 
79 79 /* Build the argv list */
80 80   argc = 0;
81 81   while ((delim = strchr(buf, 』 』))) {
82 82     argv[argc++] = buf;
83 83     *delim = 』\0』;
84 84     buf = delim + 1;
85 85     while (*buf && (*buf == 』 』)) /* Ignore spaces */
86 86       buf++;
87 87   }
88 88   argv[argc] = NULL;
89 89 
90 90   if (argc == 0) /* Ignore blank line */
91 91     return 1;
92 92 
93 93 /* Should the job run in the background? */
94 94   if ((bg = (*argv[argc-1] == 』&』)) != 0)
95 95     argv[--argc] = NULL;
96 96 
97 97   return bg;
98 98 }

shellex.c

 圖 16 一個簡單的shell程式(文件路徑為code/ecf/shellex.c)

 4.siginal

  signal是由一些system event引起,由kernel發送給指定進程,然後進程作出反應。kernel 通過更改指定進程上的state以向其發送訊號,通知進程某些system event的發生。進程對不同的signal有默認處理,如圖17;它也可以通過install signal handler(見4.3)來對signal進行處理。siganl handler的實現在user mode下,這與運行於kernel mode下的exception handler不同。signal允許kernel中斷進程,並將控制轉移到signal handler中,它也是exception control flow的一種。

圖17 Linux signals (a)多年前,main memory被core memory的技術實現。「Dumping core」是遺留辭彙,它表示將code和data segments寫入到disk中; (b)表示signal不能被caught或ignored。

4.1 sending signal

  在Unix systems中,有多種方法向進程發送signal,每種方法都基於process group的概念。每個進程都屬於唯一的process group,每個進程組有一個process group ID。可以通過getgprp()獲取當前進程的process group ID。通過setpgid函數將進程pid的進程組改為pgid。如果pgid為0,則將創建pgid為進程pid的進程組,並將進程pid加入到該進程組。

#include <unistd.h>

pid_t getpgrp(void);
int setpgid(pid_t pid, pid_t pgid);

  可以通過control terminal給進程發送訊號,比如通過termianl input和terminal-generated signals。control terminal和進程的關係如圖18所示,一個control terminal可以對應一個foreground group和多個background groups。如果在control termianl中運行linux> proc3 | proc4 | proc5命令,control terminal中運行的login shell進程(參見3.5)會創建一個foreground job,它包含由Unix pipe連接的3個foreground process,分別用來載入運行proc3、proc4和proc5。如果在命令後加上&,則表示在後台進程運行程式,如linux> proc1|proc2 &表示control termial的login shell進程為兩個background process分別創建了background job,用來載入運行proc1和proc2。login shell和前後台進程組的關係如圖19所示。control terminal打開時(沒有顯示的control terminal也會存在★★★),login shell是一直運行的後台進程。我們把foreground process和background歸為同一個session,以login shell作為session leader(詳見 深入了解進程 foreground進程組只有一個,是否可以詳細講解?★★★)。control terminal可以通過terminal input(後台進程好像也可以通過此方法)和terminated-generated signals(比如快捷鍵ctrl+c)向foreground process發送訊號;只能通過modern disconnect或關閉control terminal向background process發送SIGHUP訊號。

圖18 controlling termianl以及對應的session和process group

圖19 shell和前後台進程組的關係

  我們可以通過/bin/kill程式向其他進程發送任意signal。比如,當我們運行命令linux>/bin/kill -9 15213時,將會發送signal 9(SIGKILL)給 進程15213。如果命令中的pid為負數,則會向進程所在process group的所有進程發送signal,比如linux>/bin/kill -9 -15213。也可以通過kill函數發送signal給其它進程。如果pid大於0,向進程pid發送訊號 sig;如果pid等於0,向當前進程所在進程阻的所有進程發送訊號sig;如果pid小於0,向進程組|pid|中的所有進程發送訊號sig。一個進程可以通過調用alarm函數發送SIGALRM訊號給自己。如果secs為0,沒有alarm被調度。在對alarm調用時,如果有pending alarm,則取消pending alarm,並返回其剩餘的seconds;如果沒有pending alarm,則返回0。

#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);            Returns: 0 if OK,-1 on error
#include <unistd.h>

unsigned int alarm(unsigned int secs);               Returns:remaining seconds of previous alarm,or 0 if no previous alarm

4.2 Reciving Signals

   進程接收訊號後,默認情況下會進行如下反應,1)進程terminate;2)進程terminate並且dumps core;3)進程stops直到接收SIGCONT訊號;4)進程忽略signal。一個進程通過signal函數來指定進程對某種signal的反應;SIGSTOP和SIGKILL訊號除外,進程對它們的默認反應不能被修改。如果handler為SIG_IGN,訊號signum被ignored;如果handler為SIG_DFL,進程對訊號signum的反應還原為默認;如果handler是user-defined的函數,進程在接收到訊號signum時,這個函數會被調用。我們稱這個函數為signal handler。通過signal函數把進程對訊號的默認反應修改為handler的過程稱為installing the handler;handler被調用的過程被稱為catching handler;handler的執行被稱為handling the handler。當進程接收到signal k後,將控制轉移到signal k對應的signal handler;當signal handler執行完成後,返回到進程中斷的位置繼續執行。

#include <signal.h>
typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);  Returns:pointer to previous handler if OK,SIG_ERR on error

  進程中還存儲有pending和blocked的bit vectors資訊。pending和blocked分別表示進程pending signal set和blocked signal set。pending signal指已經發送,但還沒被接收的signal;一個進程對於特定類型的signal只能有一個pending signal,也就是說當一個進程對於特定類型的signal已經有了pending signal,那麼發送到該進程的特定類型的signal將被忽略。一個進程可以鎖定某些signal,這些signal可以被發送到該進程,但是不會被接收,除非進程解鎖這些signal。當signal k被發送時,pending中的bit k被設置;當signal被接收時,pending中的bit k被清除,如圖20所示。(圖示是否正確?★★★)可通過sigprocmask函數來設置或清除blocked中的bit k,以實現對blocked signal的添加和刪除。sigprocmask的參數how可以為以下值:1)SIG_BLOCK表示將set中的所有signal添加到blocked中(blocked = blocked | set);2)SIG_UNBLOCK表示將set中的signal從blocked中移除(blocked=set&~blocked);3)SIG_SETMASK表示blocked=set。參數oldset表示之前的狀態為blocked的signal set。可以通過以下函數對signal set進行操作:sigemptyset函數將set初始化為empty set;sigfillset函數將所有的signal添加到set中;sigaddset函數將訊號signum添加到set中;sigdelset函數刪除set中的訊號signum;當signum是set的成員時sigismember返回1,不是set的成員時返回0。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t  *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int 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

圖20  當signal被發送時,pending中的bit k被設置

  當kernel將進程p由kernel mode切換到user mode(比如從system call返回或完成一個context switch)時,kernel會檢查進程 p的unblocked pending signals(pending&~blocked)。如果unblocked pending signals為空,進程會繼續執行下一條指令Inext;如果unblocked pending signals不為空,則先從unblocked pending signals中最小的非零bit k開始,進程接收signal k並進入到signal handler(多個signal handler會不會交叉?★★),然後以bit k遞增的順序重複上面的操作。當所有的signal handler執行完畢,返回到進程終止的位置繼續執行Inext

  signal handlers可以被其它handlers中斷,如圖21所示。如圖,main程式catch signal s,中斷main程式並將控制轉移到handler S。當S在運行時,主程式catch signal t≠s,它將中斷S並將控制轉移到handler T。當T返回時,S從中斷位置恢復執行。最後,S返回並將控制轉移到main程式的中斷位置以恢復執行。

圖21 Handlers可以被其它handlers中斷

4.3 signal handler

  signal handling是Linux系統級編程中的一個棘手問題。如圖21所示,Handers和main程式是並發執行的;如果他們共用全局變數,會有並發安全的問題。為了幫助你們寫出並發安全的程式,下面給出了handler函數的幾條書寫指南,它們在大多數時候可以保證並發安全。

  1)保證handlers儘可能簡單。比如,在handler僅設置一個global flag並立即返回,所有進程在signal接收後的操作都由main程式通過周期性檢查(和重置)那個global flag來實現。

  2)在handler中只調用async-signal-safe的函數。圖22展示了Linux中async-signal-safe的函數,它們執行時不會其它的signal handler打斷。我們也可以在函數中只使用局部變數,以保證並發安全。在signal handler中向終端輸出的函數中只有write是安全的,像printf和sprintf是不安全的。我們開發了Sio(Safe I/O)包,用於列印signal handler中的一些資訊。函數sio_putl 和sio_puts分別向終端輸出一個long和string。函數sio_error列印異常資訊並終止。(具體的例子?★★★)

#include "csapp.h"

ssize_t sio_putl(long v);
ssize_t sio_puts(char s[]);
                                Returns:number of bytes transferred if OK,-1 on error
void sio_error(char s[]);

圖22 Async-signal-safe functions

  3) 在handler中保存並恢復errno。許多Linux中的async-signal-safe函數由於error返回時會設置errno。如果在handler調用這些函數,會對main程式中依賴errno的部分造成干擾。為了handler可能引起的干擾,我們handler的入口處將errno保存為局部變數,在返回前還原errno的值。如果handler不返回,而是直接exit,那麼就沒有必要這要做了。

  4)在訪問共用全局變數時block all signals。比如,handler和main程式共用全局變數,那麼在handler和main程式對全局變數訪問時要暫時block all signal。這是因為對某個data structure d的訪問可能包含一個instruction序列,如果main程式在intruction序列中間發生中斷,那麼handler很可能發現d處在不連續的狀態並導致意外的結果。(暫時沒想到好例子★★★)

  5)使用volatile修飾全局變數。比如,handler和main程式共用全局變數g,當handler對g進行修改後,main程式讀取g。由於編譯器的優化,main程式中可能快取了g的複本,導致在handler對g修改後main程式讀取的g仍然不變。用volatile修飾全局變數後,編譯器不會再快取該變數。

  6)聲明sig_atomic_t類型的flags。當像條目1)中一樣只對flag進行讀寫操作時,flag可以使用sig_atomic_t類型以保證讀寫的原子性。當對flag進行諸如flag ++和flag = flag + 10等操作時,使用sig_atomic_t類型也無法保證原子性,這些操作包含多條instruction。

4.4 signal handler相關案例

4.4.1 correct signal handling 

  如圖23所示,main函數中先為訊號SIGCHLD installing handler,然後循環創建子進程。當子進程exit時,會向main程式發送SIGCHLD訊號,main程式跳轉到installing handler執行。在installing handler中,waitpid函數會移除狀態為terminated的子進程殘留記錄。圖23的執行結果為:(結果是否唯一★★★)

  linux> ./signal1

  Hello from child 14073

  Hello from child 14074

  Hello from child 14075

  Handler reaped child

  Handler reaped child

  CR

  Parent processing input

  從執行結果可以看出,共創建了3個子進程,但只清除了2個狀態為terminated的子進程(zombie children)的殘留記錄,這與預想的不一致。這是由於當handler接收到第一個SIGCHLD訊號後,休眠了1s。當第三個SIGCHLD發送給主程式時,正在執行第一個SIGCHLD的handler,第二個SIGCHLD正處於pending狀態,由於pending signal最多只能有一個,第三個SIGCHLD將被忽略。為了解決這個問題,我們要明白pending signal的存在表明進程至少接收到了一個SIGCHLD訊號,因此我們在signal handler中儘可能多的清除zombie children。圖24展示了修改後的程式。

 1 /* WARNING: This code is buggy! */
 2 void handler1(int sig)
 3 {
 4   int olderrno = errno;
 5 
 6   if ((waitpid(-1, NULL, 0)) < 0)
 7     sio_error("waitpid error");
 8   Sio_puts("Handler reaped child\n");
 9   Sleep(1);
10   errno = olderrno;
11 }
12 
13 int main()
14 {
15   int i, n;
16   char buf[MAXBUF];
17 
18   if (signal(SIGCHLD, handler1) == SIG_ERR)
19     unix_error("signal error");
20 
21   /* Parent creates children */
22   for (i = 0; i < 3; i++) {
23     if (Fork() == 0) {
24       printf("Hello from child %d\n", (int)getpid());
25       exit(0);
26     }
27   }
28 
29   /* Parent waits for terminal input and then processes it */
30   if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0)
31     unix_error("read");
32 
33   printf("Parent processing input\n");
34   while (1)
35   ;
36 
37   exit(0);
38 }

圖23 signal1;這個程式有缺陷,它假定了signal可以排隊

 1  void handler2(int sig)
 2 {
 3   int olderrno = errno;
 4 
 5   while (waitpid(-1, NULL, 0) > 0) {
 6     Sio_puts("Handler reaped child\n");
 7   }
 8   if (errno != ECHILD)
 9   Sio_error("waitpid error");
10   Sleep(1);
11   errno = olderrno;
12 }

 圖24 signal2;這是對圖19中signal1的改進,它考慮到了signal不會排隊

4.4.2 Synchronizing Flows to avoid race (★★★訪問全局變數需要鎖定其它訊號,不鎖定其它訊號有什麼後果?)

  並發程式在相同的存儲位置上讀寫的安全問題是幾代電腦科學家的挑戰。程式控制流交錯的數量在指令數量上是指數級的。圖25所示的程式就存在並發安全的問題,程式中main任務和signal-handling控制流之間交錯著,函數deletejob可能在函數addjob之前執行,導致在job list上遺留一個incorrect entry。這種經典的並發錯誤被稱為race。main任務中的addjob和handler中的deletejob進行race,如果addjob贏得race,那麼結果正確;否則結果錯誤。為了解決這個問題,可以通過在調用fork之前blocking SIGCHLD並在調用addjob之後unblocking SIGCHLD,以保證所有子進程在加入job list後被清除,如圖26所示。

 1 void handler(int sig)
 2 {
 3   int olderrno = errno;
 4   sigset_t mask_all, prev_all;
 5   pid_t pid;
 6 
 7   Sigfillset(&mask_all);
 8   while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
 9     Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
10     deletejob(pid); /* Delete the child from the job list */
11     Sigprocmask(SIG_SETMASK, &prev_all, NULL);
12   }
13   if (errno != ECHILD)
14     Sio_error("waitpid error");
15     errno = olderrno;
16}
17 
18 int main(int argc, char **argv)
19 {
20   int pid;
21   sigset_t mask_all, prev_all;
22 
23   Sigfillset(&mask_all);
24   Signal(SIGCHLD, handler);
25   initjobs(); /* Initialize the job list */
26 
27   while (1) {
28     if ((pid = Fork()) == 0) { /* Child process */
29       Execve("/bin/date", argv, NULL);
30     }
31     Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* Parent process */
32     addjob(pid); /* Add the child to the job list */
33     Sigprocmask(SIG_SETMASK, &prev_all, NULL);
34   }
35   exit(0);
36 }

圖25 一個有同步錯誤的shell程式(code/ecf/promask1.c)

 1 void handler(int sig)
 2 {
 3   int olderrno = errno;
 4   sigset_t mask_all, prev_all;
 5   pid_t pid;
 6 
 7   Sigfillset(&mask_all);
 8   while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
 9     Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
10     deletejob(pid); /* Delete the child from the job list */
11     Sigprocmask(SIG_SETMASK, &prev_all, NULL);
12   }
13   if (errno != ECHILD)
14     Sio_error("waitpid error");
15     errno = olderrno;
16 }
17 
18 int main(int argc, char **argv)
19 {
20   int pid;
21   sigset_t mask_all, mask_one, prev_one;
22 
23   Sigfillset(&mask_all);
24   Sigemptyset(&mask_one);
25   Sigaddset(&mask_one, SIGCHLD);
26   Signal(SIGCHLD, handler);
27   initjobs(); /* Initialize the job list */
28 
29   while(1){
30     Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
31     if ((pid = Fork()) == 0) { /* Child process */
32       Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
33       Execve("/bin/date", argv, NULL);
34     }
35     Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent process */
36     addjob(pid); /* Add the child to the job list */
37     Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
38   }
39   exit(0);
40 }

圖26 對圖21中程式的改進;使用Sigprocmask同步進程(code/ecf/promask2.c)

4.4.3 explictly waiting for Signals

  有時main程式需要等待直到特定的signal handler開始執行。比如說,當一個Linux shell創建了一個foreground job,它在執行下一個用戶命令前需要等待job終止並通過SIGCHLD handler移除job殘留。如圖27所示,main程式在創建子進程後,通過while循環等待子進程終止以及SIGCHLD handler將子進程殘留清除。但是圖中沒有循環體的while循環非常浪費處理器的資源,我們可以在while的循環體中添加pause函數。當main程式接收到SIGCHLD訊號後,會從pause中被喚醒並跳轉到signal handler;使用while循環的原因是pause也可能被其它訊號打斷。這種方式的缺陷是當程式在while後pause前接收到SIGCHLD,那麼pause可能會永遠休眠。使用sleep代替pause可以避免程式永遠休眠,但是函數sleep的參數secs(見第3節)不好設定。如果程式在while後sleep前接收到SIGCHLD,且sleep的參數secs設置過大,比如sleep(1),那麼程式將會等待很長時間(相對而言)。如果sleep的參數secs設置過小,則會浪費處理器的資源。

while(!pid)
     pause();
while(!pid) sleep(1);

  更好的方式是使用sigsuspend函數。sigsuspend函數等同於以下程式碼(具有原子性):

sigprocmask(SIG_SETMASK,&prev,NULL);
pause();
sigprocmask(SIG_BLOCK,&mask,&prev);
#include <signal.h>

int sigsuspend(const sigset_t *mask);

sigsuspend函數暫時地將blocked set設置為prev,以解鎖mask,直到接收到訊號,訊號通知進程運行handler或者終止進程。如果訊號通知終止進程,那麼進程將不會從sigsuspend返回。如果訊號通知進程運行handler,那麼sigsuspend會在handler返回後返回,並在返回前將blocked set恢復到sigsuspend剛被調用時的狀態。圖28中展示了在圖27的while循環中填充sigsuspend後的程式,它節約了處理器的資源。sigsuspend相比pause而言,避免了進程一直休眠的情況;相比sleep而言更高效。

#include "csapp.h"
volatile sig_atomic_t pid;
void sigchld_handler(int s)
{
  int olderrno = errno;
  pid = waitpid(-1, NULL, 0);
  errno = olderrno;
}

void sigint_handler(int s)
{
}

int main(int argc, char **argv)
{
  sigset_t mask, prev;

  Signal(SIGCHLD, sigchld_handler);
  Signal(SIGINT, sigint_handler);
  Sigemptyset(&mask);
  Sigaddset(&mask, SIGCHLD);

  while (1) {
    Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */
    if (Fork() == 0) /* Child */
      exit(0);

    /* Parent */
    pid = 0;
    Sigprocmask(SIG_SETMASK, &prev, NULL); /* Unblock SIGCHLD */
  
    /* Wait for SIGCHLD to be received (wasteful) */
    while (!pid)
    ;

    /* Do some work after receiving SIGCHLD */
    printf(".");
  }
  exit(0);
}

圖27 使用spin loop等待訊號;程式是正確的,但是浪費處理器資源

 1 while (1) {
 2   Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */
 3   if (Fork() == 0) /* Child */
 4     exit(0);
 5 
 6   /* Wait for SIGCHLD to be received */
 7   pid = 0;
 8   while (!pid)
 9     sigsuspend(&prev);
10 
11   /* Optionally unblock SIGCHLD */
12   Sigprocmask(SIG_SETMASK, &prev, NULL);
13 
14   /* Do some work after receiving SIGCHLD */
15   printf(".");
16 }

圖28 使用sigsuspend函數等待訊號(請結合圖22)

5.nonlocal jump

  nonlocal jump將控制直接從一個函數轉移到另一個當前正在執行的函數,它是user-level exception control flow。nonlocal jump通過函數setjmp和longjmp來實現。函數setjmp保存current calling environment在env buffer中,並返回0;env buffer在後面的函數longjmp會使用。current calling environment包含the program counter,stack pointer和general-purpose registers。由於某些超出我們知識範圍的原因,函數setjmp的返回值不能被變數接收,比如rc = setjmp(env)是錯誤的。但是,函數setjmp卻可以在switch或條件語句的條件判斷中使用。函數longjmp從env buffer中恢復the calling environment並將longjump的參數retval作為setjmp的返回值。

#include <setjmp.h>

int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
void longjmp(jmp_buf env,int retval);
void siglongjmp(sigjmp_buf env, int retval);

  longjmp的一個應用是從函數調用時深度嵌套的程式碼中直接返回,通常是由於檢測到error condition。從深度嵌套的程式碼中直接返回相比於常規的call-and-return,不用先彈出調用棧。如圖29所示,main函數首先調用setjmp保存current calling environment,然後調用函數foo,函數foo中又調用了函數bar。當函數foo或函數bar中遇到error時,會通過longjmp直接將控制轉移到the calling environment,也就是轉移到函數setjmp。setjmp將有一個不為0的返回值,它表示error的類型;接著error將得到處理。longjmp相比於常規的call-and-return,避開了直接函數調用的意想不到的後果。比如,在函數調用時,我們allocate了一些data structure,在還沒有deallocate這些data structure時出現了error,那麼deallocation將不會進行,可能會引起數據泄露。

 1 #include "csapp.h"
 2 
 3 jmp_buf buf;
 4 
 5 int error1 = 0;
 6 int error2 = 1;
 7 
 8 void foo(void), bar(void);
 9 
10 int main()
11 {
12   switch(setjmp(buf)) {
13   case 0:
14     foo();
15     break;
16   case 1:
17     printf("Detected an error1 condition in foo\n");
18     break;
19   case 2:
20     printf("Detected an error2 condition in foo\n");
21     break;
22     default:
23   printf("Unknown error condition in foo\n");
24   }
25   exit(0);
26 }
27 
28 /* Deeply nested function foo */
29 void foo(void)
30 {
31   if (error1)
32     longjmp(buf, 1);
33   bar();
34 }
35 
36 void bar(void)
37 {
38   if (error2)
39     longjmp(buf, 2);
40 }

圖29  nonlocal jump exception(文件位置code/ecf/setjmp.c)

  nonlocal jumps的另一個應用是在signal handler中直接將控制轉移到特定的程式碼位置,而不是之前由於接受到訊號而中斷的指令。圖30所示為使用sigsetjmp和siglongjmp的程式。程式在終端運行時,先後多次按下ctrl+C時,輸入出的結果如下:

  linux> ./restart

  starting

  processing…

  processing…

  ctrl+C

  restarting

  processing…

  ctrl+C

  restarting

  processing…

 1 #include "csapp.h"
 2 
 3 sigjmp_buf buf;
 4 
 5 void handler(int sig)
 6 {
 7   siglongjmp(buf, 1);
 8 }
 9 
10 int main()
11 {
12   if (!sigsetjmp(buf, 1)) {
13     Signal(SIGINT, handler);
14     Sio_puts("starting\n");
15   }
16   else
17     Sio_puts("restarting\n");
18 
19   while(1) {
20     Sleep(1);
21     Sio_puts("processing...\n");
22     }
23   exit(0); /* Control never reaches here */
24 }

圖30 一個nolocal jump程式,當用戶按下ctrl+C後會重啟(文件位置code/ecf/restart.c)

  注意圖30中的main程式中的exit(0)是不會執行到的,這保證了調用longjmp進行控制轉移時main程式是在執行中的。

參考資料:

[1] Randal E.Bryant,David R. O’Hallaron.Computer Systems:A Programmer’s Perspective,3/E(CS:APP3e).

[2] Chapter 6 Interrupt and exception handling  ★★

[3] I/Odevice 引起pin變化的資料; ★★