计算机系统性错误,《深入理解计算机系统-异常》
現代操作系統通過使控制流發生突變來對某些意外情況(磁盤讀寫數據準備就緒、硬件定時器產生信號等)做出反應。一般而言,我們把這些突變命名為異??刂屏?Exceptional Contral Flow ECF)。異??刂屏靼l生在計算機系統的各個層次。比如,在硬件層,硬件檢測到時間會觸發控制突然轉移到異常處理程序。在操作系統層,內核通過上下文切換將控制從一個用戶進程轉移到另一個用戶進程。在應用層,一個進程可以發送信號到另一個進程,而接受者會將控制轉移到一個信號處理程序。一個程序可以通過回避通常的棧規則,并執行到其他函數中任意位置的非本地跳轉錯誤做出反應。
為什么需要理解ECF?有助于理解重要的系統概念
有助于理解應用程序是如何與操作系統交互
有助于理解并發
有助于理解軟件異常如何工作(如C++/JAVA try-cache-throw軟件異常機制)
異常
異常是異??刂屏鞯囊环N形式,它一部分由硬件實現,一部分由操作系統實現。
異常就是控制流的一種突變,用來響應處理器狀態中的某些變化。上圖中,當處理器狀態發生一個重要的變化時,處理器正在執行某個當前指令Icur。在處理器中,狀態被編碼為不同的位和信號。狀態的變化稱為事件。事件可能和當前執行的執行直接相關,比如虛擬內存缺頁、算術溢出、除以零,也可能和當前指令沒有關系,比如系統定時器產生信號、I/O請求完成等。
在任何情況下,當處理器檢測到有事件發生時,它會通過一張叫做異常表(exception table)的跳轉表,進行一個間接過程調用。到一個專門涉及用來處理這類事件的操作系統子程序(異常處理程序(exception handler))。當異常處理程序完成處理后,根據引起異常的事件類型,會發生以下情況:重新執行Icur(如發生缺頁中斷)
繼續執行I_next(如收到I/O設備信號)
終止程序(如收到kill信號)
異常處理
系統中可能的每種類型的異常都分配了一個唯一的非負整數的異常號(exception number)。其中一些號碼有處理器的設計者分配,其他號碼由操作系統內核(操作系統常駐內存的部分)的設計者分配。前者的示例包括除以零、缺頁、內存訪問違例(如segment fault)、斷點、算術運算溢出等,后者包括系統調用和來自外部I/O設備的信號。在系統啟動時(重啟或加電時),操作系統分配和初始化一張稱為異常的跳轉表。每個條目k包含了異常k的處理程序的跳轉地址。異常表的起始地址放在一個叫做異常表基址地址寄存器的特俗cpu寄存器內。
異常類似于過程調用,但仍舊有重要的不同之處:過程調用時,在跳轉到處理程序之前,處理器會將返回地址壓入棧中。但是對于不同的異常類型,返回地址可能時當前指令,也可能時下一條指令
處理器也會把一些額外的處理器狀態壓入棧里,在處理程序返回時,重新開始執行被中斷的程序需要這些狀態
如果控制從用戶程序轉移到內核,那么所有這些項目都會壓到內核棧中
異常處理程序運行在內核模式下,意味著異常處理程序對所有的系統資源都有完全的訪問權限(問:用戶指定的異常處理程序呢?)
當異常處理程序完成后,它通過執行一條特殊的“從中斷返回”指令,可選地返回被中斷的程序,該指令將適當的狀態彈回處理器的控制和數據寄存器中。如果異常中斷的是一個用戶程序,就將狀態恢復為用戶模式,然后將控制返回給被中斷的程序。
異常的類別
異常分為4類:中斷(interrupt)、陷阱(trap)、故障(fault)、終止(abort):中斷:異步發生,是來自處理器外部的I/O設備的信號的結果(如硬盤數據讀取完成)。一般這種信號是外部硬件設備向處理器上的一個引腳發信號,并將異常號(標識了引起中斷的設備)放到系統總線,來觸發中斷。當前指令完成后,處理器注意到中斷引腳電壓變高,就從系統總線讀取異常號,并調用適當的異常處理程序。異常處理完成后,執行下一條指令I_next。
陷阱和系統調用:是有意的異常,是執行一條指令的結果(如執行malloc、讀、寫文件、fork、execve、exit等),處理器提供一條特殊的“syscall n”(n是系統調用的編號,操作系統有對應的系統調用表,表中條目i標識系統調用i的處理程序地址)來處理器這些系統調用。中斷處理程序執行完成后,將程序切換為用戶態,執行下一條指令I_next。運行在用戶模式的普通函數只能訪問與調用函數相同的棧,但是系統調用運行在內核模式,因此允許一行特權指令,并訪問定義在內核中的棧。
故障:由錯誤引起,通常能夠被故障處理程序修正。當故障發生,處理器將控制轉移給故障處理程序。如果故障處理程序能夠修正這個錯誤,就見控制返回到因此故障的指令,并重新執行它。否處理程序返回到內核中的abort歷程,abort會終止引起故障的應用程序。常見的故障如:缺頁。
終止:不可恢復的致命錯誤造成結果,通常是一些硬件錯誤,如比如DRAM/SRAM為被損壞時發生的奇偶錯誤。終止處理程序從不將控制返回給應用程序,而是直接返回到內核的abort歷程。linux系統調用函數先將系統調用好寫入寄存器%rax,然后將參數(如mallo的字節數量)寫入寄存器%rdi等,然后調用“syscall”指令來調用系統調用。
進程
進程的經典定義就是一個執行中的程序的實例。系統中的每個程序都運行在某個進程的上下文中。上下文由程序正確運行所需的狀態組成的。這個狀態包括存放在內存中的程序的代碼和數據,它的棧、通用目的寄存器的內容、程序計數器、環境變量、已經打開文件描述符的集合等。
邏輯控制流
進程是輪流使用處理器的。每個進程執行它的流的一部分,然后被搶占,然后輪到其他進程。對于一個運行在這些進程之一的上下文的程序,它看上去就像是在獨占地使用處理器。
并發流
一個邏輯流的執行時間上與另一個流重疊,稱為并發流。這個兩個流被稱為并發地運行。多個流并發地執行的一般現象被稱為并發(concurrency)。一個進程和其他進程輪流地運行的概念稱為多任務(multitasking)。一個進程執行它的控制流的一部分的每一個時間段叫做時間片。
私有地址空間
進程也為每個程序提供了一種假象:好像它獨占地使用系統地址空間。進程為每個程序提供它自己的私有地址空間。一般而言,和這個空間(也就是我們所說的虛擬地址空間)中某個地址關聯的那個內存字節是不能被其他進程讀寫的。
盡管和每個私有地址空間相關聯的內存的內容一般是不同的,但是每個這樣的空間都有相同的通用結構。地址空間地步是保留給用戶程序的,包括通常的代碼、數據、堆和棧段。代碼段總是從0x400000開始。地址空間頂部保留給內核(操作系統常駐內存的部分)。地址空間的這個部分包含內核在帶白繼承執行指令時(比如當應用程序執行系統調用時)使用的代碼、數據和棧。
用戶模式和內核模式
為了限制一個應用可以執行的指令以及它可以訪問的地址空間范圍,處理器使用某個控制寄存器中的一個模式位來描述進程當前享有的權限:模式位為1標識進程運行在內核模式中,可以執行指令集中的任何指令,并訪問系統中的任何內存位置。如果沒有設置模式位,則標識處于用戶模式,不允許執行特權指令(如停止處理器、改變位模式、發起I/O操作、引用地址空間中內核區的代碼和數據)。用戶程序必須通過系統調用訪問內核代碼和數據。
進程從用戶模式變為內核模式的唯一方法是通過諸如中斷、故障或者陷入系統調用這樣的異常。當異常發生,控制傳遞到異常處理程序,處理器將模式從用戶模式變為內核模式。當異常處理程序返回到應用程序代碼時,處理器就將模式從內核模式改為用戶模式。
linux中的/proc文件系統允許用戶模式進程訪問內核結構的內容。/proc文件系統將許多內核數據結構的內容輸出為一個用戶程序可以讀的文本文件的層次結構。/proc/cpuinfo
/proc/$pid/maps等思考:/proc是否存儲到磁盤中?如果不是,那它是怎么實現的?
實現一個程序,仿照/proc,將當前程序使用進程id、內存使用情況寫入到某個文件中。
上下文切換
內核為每個進程維持一個上下文(context)。上下文就是內核重新啟動一個被搶占的進程所需的狀態(通用目的寄存器、浮點寄存器、程序計數器、用戶棧、狀態寄存器、內核棧、內核數據結構),比如描述地址空間的頁表、包含有關當前進程信息的進程表、已打開的文件的描述符等。
系統調用可能導致上下文切換,如I/O讀寫。中斷也可能引起上下文切換。比如,所有操作系統都有周期性定時器中斷的機制,通常為1ms或者10ms。每次發生定時器中斷。內核就判定當前進程已經運行了足夠長的時間,并切換到一個新的進程。
系統調用錯誤處理
當unix系統級函數發生錯誤時,它們通常會返回-1,并設置全局變量errno來標識出錯。程序用應該總是檢查錯誤。if ((pid = fork()) < 0){
// strerror 返回一個文本串,描述了和某個error值相關聯的錯誤。
fprintf(stderr, "fork error: %s\n", strerror(errno));
exit(0)
}
進程控制
unix系統提供了大量從C程序操作進程的系統調用。pid_t getpid;
pid_t getppid;
pid_t fork(void);
void exit(int status);
新創建的子進程幾乎但不完全和父進程相同。子進程得到與父進程用戶及虛擬地址空間相同的(且獨立的)一份副本,包括代碼和數據段、堆、共享庫以及用戶棧。子進程還獲得與父進程任何打開文件描述符相同的副本,意味這子進程可以讀寫父進程打開的任何文件。后續父子兩個進程所做的任何改變都是獨立的,都有自己的私有地址空間,不會反映在另一個進程的內存中。
fork函數只被調用一次,但是會返回兩次。一次在父進程,一次在新創建的子進程中。在具有多個fork實例的程序中,這很容易使人迷惑。如下例一共輸出多少次hello?int main(){
fork();
fork();
printf("hello\n");
exit(0);
}
當一個進程由于某種原因終止時,內核并不是立即把它從系統中清除。相反,進程被保護在一種已終止的狀態,知道被它的父進程回收(raped,即子進程退出的信號被父進程處理)。當父進程回收已終止的子進程時,內核將子進程的退出狀態傳給父進程,然后拋棄已終止的進程,從此時開始,該進程就不存在了。一個終止了但還未被回收的進程被稱為僵死進程(zombie)。(僵死進程仍會占用內存,因此我們總應該小心回收自己創建的子進程)。
如果一個父進程終止了,內核會安排init進程稱為它的孤兒進程的養父。init進程的PID為1,是系統啟動時由內核創建的,它不會終止,是所有進程的祖先。如果父進程沒有回收它的僵死子進程就終止了,那么內核會安排init進程去回收它們。// 成功則返回子進程pid,pid=-1,表示等待所有子進程。statusp用來存儲子進程的退出狀態
// 如果繁盛錯誤,則返回-1,并設置errnos(無子進程errno則為ECHILD)
pid_t waitpid(pid_t pid, int *statusp, int options);
// waitpid的簡化版本,等價于waitpid(-1, &status, 0)
pid_t wait(int *statusp)// 將進程刮起一段指定的時間
unsigned int sleep(unsigned int secs);
// 將進程休眠,直到該進程收到一個信號
int pause(void);// 加載并運行可執行目標文件filename,argv為參數列表,envp為環境變量列表
int execve(const char *filename, const char *argv[], const char *envp[]);
execve 在當前進程的上下文中加載并運行一個新的程序。它會覆蓋當前進程的地址空間,但是并沒有創建一個新的進程,并且繼承了調用execve函數時已打開的所有文件描述符??蓞⒖肌舵溄印芬还潯?/p>
信號
Linux是一種更高層次的軟件形式的異常,它允許進程和內核中斷其他進程。
上圖展示了linux系統上支持的信號,前30幾種在實際應用種最為常見。每種信號類型對應某種系統時間。低層的硬件異常由內核異常處理程序完成,正常情況下,對用戶進程不可見。信號提供了一種機制,通知用戶進程發生了這些異常,比如,如果一個進程試圖除以0,那么內核就發送一個SIGFPE信號;Ctrl-C發送的SIGINT信號;Ctrl-Z則表示發送SIGSTP信號;SIGKILL是強制終止(該信號無法被捕獲,處理程序無法被重寫);SIGCHLD是子進程終止。
傳送一個信號到目的程序是由兩個不同步驟組成的。發送信號:內核通過更新目的程序上下文中的某個狀態(進程的信號位表),發送一個信號給目的程序。發送信號可以有以下兩種原因:內核檢測到系統事件,如除零錯誤或子進程終止;一個進程調用kill,顯式地要求內核發送一個信號給目的進程。一個進程可以發送信號給它自己。
接收信號:當目的進程被內核強迫以某種方式對信號的發送做出反應,它就接收了信號。進程可以忽略這個信號、終止、或者通過執行一個稱為信號處理程序的用戶層函數來捕獲這個信號。
一個發出而沒有被接收的信號叫做待處理信號。在任何時刻,一種類型至多只會有一個待處理信號。因此,如果你重復發送多個信號k給某個進程,如果進程沒處理前一個,那么后續的信號k都將被丟棄。
內核為每個進程在pending位響亮種維護著一個待處理的信號的集合。而blocked位響亮種維護著被阻塞的信號集合。因此,所謂的發送,即內核將pending的第k位置為1,接收則置為0。
發送信號// 給進程pid發送信號sig
kill -$sig $pid
int kill(pid_t pid, int sig)當我們在shell種啟動一個job(比如ls|sort),會啟動兩個進程,二者同屬一個進程組。當我們進行Ctrl-C的時候,內核會發送SIGINT給該進程組種的每個進程。
接收信號
當內核把進程p從內核模式切換到用戶模式時(例如,從系統調用返回或是完成了一次上下文切換),他會檢查進程p的未被阻塞的待處理信號的集合。如果集合為空,那么內核將控制傳遞到p的邏輯控制流中的下一條指令I_next。然而,如果集合非空,那么內核選擇集合種的某個信號k(通常先選取值最小的信號),并強制進程p接收信號k。收到信號會觸發進程采取某種行動(信號處理程序)。一旦完成這個行為,進程就將控制傳遞會p的邏輯控制流的下一條指令I_next。每個信號類型都有一個預定義的默認行為(部分信號的行為允許被用戶程序重寫,SIGSTOP、SIGKILL不允許被重寫),是下面的一種:終止:如收到SIGKILL信號
終止并轉儲到內存
停止直到被SIGCONT信號重啟
忽略:如收到SIGCHLD信號處理程序是可以被其他信號處理程序中斷的。
阻塞和接觸阻塞信號// how: SIG_BLOCK表示屏蔽信號,SIG_UNBLOCK表示接收信號
// set:需要操作的信號集合
// oldset:非空,則將blocked位向量的值保存在oldset
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset)
原則
信號處理是很麻煩的工作:處理程序與主程序并發運行,共享同樣的全局變量,因此可能互相干擾;不同的系統有不同的信號處理語義;信號處理程序可能會被其他信號中斷。因此,一般我們在編寫信號處理程序的時候需要遵循以下原則:處理程序盡可能簡單
處理程序中僅調用異步信號安全的函數(即可重入或無法被中斷的函數)。printf、malloc、exit等均不是異步信號安全
保存和恢復errno。許多異步信號安全的函數都會在出錯返回是設置errno,因此可能干擾主程序中其他以來errno的部分
阻塞所有信號,保護對共享全局數據結構的訪問。如果處理程序和主程序會共享一個全局數據結構,那么在訪問在結構前,應阻塞所有信號
用voliatile聲明全局變量
用sig_atomic_t聲明標志
當我們收到一個信號,僅代表該類型事件僅發生過一次(因為重復的待處理信號是會被丟棄的)
下例中,對于job的操作是一個全局操作,而且實際應用中,對于job的操作一般不是原子性的。#include "csapp.h"
void initjobs()
{
}
void addjob(int pid)
{
}
void deletejob(int pid)
{
}
/* $begin procmask2 */
void handler(int sig)
{
int olderrno = errno;
sigset_t mask_all, prev_all;
pid_t pid;
Sigfillset(&mask_all);
while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
// 阻塞其他信號,防止job列表被并發修改
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
deletejob(pid); /* Delete the child from the job list */
Sigprocmask(SIG_SETMASK, &prev_all, NULL);
}
if (errno != ECHILD)
Sio_error("waitpid error");
errno = olderrno;
}
int main(int argc, char **argv)
{
int pid;
sigset_t mask_all, mask_one, prev_one;
Sigfillset(&mask_all);
Sigemptyset(&mask_one);
Sigaddset(&mask_one, SIGCHLD);
Signal(SIGCHLD, handler);
initjobs(); /* Initialize the job list */
while (1) {
Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
if ((pid = Fork()) == 0) { /* Child process */
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
Execve("/bin/date", argv, NULL);
}
Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent process */
addjob(pid); /* Add the child to the job list */
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
}
exit(0);
}
/* $end procmask2 */
非本地跳轉
C提供了一種用戶級異常控制流形式,稱為非本地跳轉(nonlocal jump),它將控制直接從一個函數轉移到另一個正在執行的函數,而不需要經過正常的調用-返回序列。非本地跳轉是通過setjmp和longjmp函數來提供的。int setjmp(jmp_buf env);
int longjmp(jmp_buf env, int retval);
// sigsetjmp和siglongjmp是setjmp和longjmp的可以被信號處理程序使用的版本
int sigsetjmp(sigjmp_buf env, int savesigs);
int siglongjmp(sigjmp_buf, int retval);
setjmp函數在env緩沖區中保存當前調用環境,以供后面的longjmp使用,并返回0。調用環境包括程序計數器、棧指針和通用目的寄存器。注意setjmp返回的值不能賦值給變量(具體原因可自行思考),不過它可以安全地用在switch或條件語句中測試。rc = setjmp(env); // Wrong
longjmp函數從evn緩沖區恢復調用環境,然后觸發一個從最近一次初始化env的setjmp調用的返回。然后setjmp返回,并帶有非零的返回值retval。
setjmp函數只被調用一次,但返回多次:一次是當第一次調用setjmp,將調用環境保存在緩沖區env時;一次是為每個相應的longjmp調用時。另一方面,longjmp函數被調用一次,但從不返回。非本地跳轉的一個重要應用是運行一個深層嵌套的函數調用中立即返回,通常是由檢測到某個錯誤情況引起的。我們可以使用非本地跳轉直接返回到一個普通的本地化的錯誤處理程序,而無需費力地解開調用棧。/* $begin setjmp */
#include "csapp.h"
jmp_buf buf;
int error1 = 0;
int error2 = 1;
void foo(void), bar(void);
int main()
{
switch(setjmp(buf)) {
case 0:
foo();
break;
case 1:
printf("Detected an error1 condition in foo\n");
break;
case 2:
printf("Detected an error2 condition in foo\n");
break;
default:
printf("Unknown error condition in foo\n");
}
exit(0);
}
/* Deeply nested function foo */
void foo(void)
{
if (error1)
longjmp(buf, 1);
bar();
}
void bar(void)
{
if (error2)
longjmp(buf, 2);
}
/* $end setjmp */
longjmp允許它跳過中間調用地特性可能產生嚴重地后果。假如中間函數調用中分配了某些資源(內存、網絡連接等),本來預期在函數結尾釋放它們,那么這些釋放代碼會被跳過,因而產生資源泄漏。非本地跳轉地另一個重要應用是使一個信號處理程序分支到一個特殊的代碼位置,而不是返回到被信號到達中斷了的指令的位置,比如,我們可以使用sigsetjmp和siglongjmp來實現軟重啟。/* $begin restart */
#include "csapp.h"
sigjmp_buf buf;
void handler(int sig)
{
siglongjmp(buf, 1);
}
int main()
{
// 首次調用返回0。當jump 回到這里后,返回非0
if (!sigsetjmp(buf, 1)) {
Signal(SIGINT, handler);
Sio_puts("starting\n");
}
else
Sio_puts("restarting\n");
while(1) {
Sleep(1);
Sio_puts("processing...\n");
}
exit(0); /* Control never reaches here */
}
/* $end restart */C++、JAVA提供的異常機制是較高層次的,是C語言的setjmp、longjmp函數的更加結構化的版本。你可以把try語句中的catch看作類似于setjmp函數。相似得,trhow語句就類似于longjmp函數。
以下是一個try-catch-throw 的樣例。該程序會一直打印“KeyboardInterrupt”。jmp_buf ex_buf__;
#define TRY do{ if(!setjmp(ex_buf__)) {
#define CATCH } else {
#define ETRY } } while(0)
#define THROW longjmp(ex_buf__, 1)
void sigint_handler(int sig) {
THROW;
}
int main(void) {
if (signal(SIGINT, sigint_handler) == SIG_ERR) {
return 0;
}
TRY {
// raise(sig)效果等同kill(getpid(), sig)
raise(SIGINT);
} CATCH {
printf("KeyboardInterrupt");
}
ETRY;
return 0;
}
宏展開后,代碼如下:jmp_buf ex_buf__;
void sigint_handler(int sig) {
longjmp(ex_buf__, 1);
}
int main(void) {
if (signal(SIGINT, sigint_handler) == ((_crt_signal_t)-1)) {
return 0;
}
do{ if(!_setjmp(ex_buf__)) { {
raise(SIGINT);
} } else { {
printf("KeyboardInterrupt");
}
} } while(0);
return 0;
}
操作進程的工具
Linux系統提供了大量的監控和操作進程的有用工具。strace:打印一個正在運行的程序和它的子進程調用的每個系統調用的軌跡。如:strace cat /dev/null
ps:列出當前系統中的進程(包括僵死進程)
top:打印關于當前進程資源使用的信息
pmap:顯示進程的內存映射
/proc:一個虛擬文件系統,以ASCII文本格式輸出大量內核數據結構的內容,用戶程序可以讀取這些內容。如“cat /proc/loadavg”可以看到當前系統的平均負載我們所經歷的每個平凡的異常,也許就是連續發生的奇跡。
總結
以上是生活随笔為你收集整理的计算机系统性错误,《深入理解计算机系统-异常》的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: ui automator viewer
- 下一篇: mapdb java_MapDB使用入门