深入剖析Sgementation fault原理

2022-10-21 06:02:49

深入剖析Sgementation fault原理

前言

我們在日常的程式設計當中,我們很容易遇到的一個程式崩潰的錯誤就是segmentation fault,在本篇文章當中將主要分析段錯誤發生的原因!

Sgementation fault發生的原因

發生Sgementation fault的直接原因是,程式收到一個來自核心的SIGSEGV訊號,如果是你的程式導致的核心給程序傳送這個訊號的話,那麼就是你的程式正在讀或者寫一個沒有分配的頁面或者你沒有讀或者寫的許可權。這個訊號的來源有兩個:

  • 程式的非法存取,自身程式的指令導致的Sgementation fault。
  • 另外一種是由別的程式直接傳送SIGSEGV訊號給這個程序。

在類Linux系統中,核心給程序傳送的訊號為SIGGEV,訊號對應數位為11,在Linux當中訊號對應的數位情況大致如下所示:

 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX

當一個程式發生 segmentation fault 的時候,這個程式的退出碼 exitcode 等於 139!

發生 segmentation fault 的一個主要的原因是我們自己的程式發生非法存取記憶體,同時別的程式給這個程序傳送 SIGSGEV 訊號也會導致我們的程式發生 segmentation fault 錯誤。

比如下面的程式就是自己發生的段錯誤(發生了越界存取):

#include <stdio.h>

int main() {

  int arr[10];
  arr[1 << 20] = 100; // 會導致 segmentation fault
  printf("arr[12] = %d\n", arr[1 << 20]); // 會導致 segmentation fault
  return 0;
}

下面是一個別的程式給其他程式傳送SIGSGEV訊號會導致其他程序出現段錯誤(下面的終端給上面終端的程序號等於504092的程式傳送了一個訊號值等於11(就是SIGGSGEV)訊號,讓他發生段錯誤):

自定義訊號處理常式

作業系統允許我們自己定義函數,當某些訊號被傳送到程序之後,程序就會去執行這些函數,而不是系統預設的程式(比如說SIGSEGV預設函數是退出程式)。下面來看我們重寫SIGINT訊號的處理常式,當一個程式在終端執行的時候我們按下ctrl+c,這個正在執行的程式就會收到一個來自核心的SIGINT訊號:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>

void sig(int n) { // 引數 n 表示代表訊號的數值
  char* str = "signal number = %d\n";
  char* out = malloc(128);
  sprintf(out, str, n);
  write(STDOUT_FILENO, out, strlen(out));
  free(out);
}

int main() {
  signal(SIGINT, sig); // 這行程式碼就是註冊函數 當程序收到 SIGINT 訊號的時候就執行 sig 函數
  printf("pid = %d\n", getpid());
  while (1)
  {
    sleep(1);
  }
  
  return 0;
}

首先我們需要知道,當我們在終端啟動一個程式之後,如果我們在終端按下ctrl+c終端會給當前正在執行的程序以及他的子程序傳送SIGINT訊號,SIGINT訊號的預設處理常式就是退出程式,但是我們可以捕獲這個訊號,重寫處理常式。在上面的程式當中我們就自己重寫了SIGINT的處理常式,當程序接收到 SIGINT 訊號的時候就會觸發函數 sig 。上面程式的輸出印證了我們的結果。

我們在終端當中最常用的就是ctrl+c 和 ctrl + z 去中斷當前終端正在執行的程式,其實這些也是給我們的程式傳送訊號,ctrl+c傳送SIGINT訊號ctrl+z傳送SIGTSTP訊號。因此和上面的機制類似,我們可以使用處理常式重寫的方式,覆蓋對應的訊號的行為,比如下面的程式就是使用處理常式重寫的方式進行訊號處理:

#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

void sig(int no) {
  char out[128];
  switch(no) {
    case SIGINT:
      sprintf(out, "received SIGINT signal\n");
      break;
    case SIGTSTP:
      sprintf(out, "received SIGSTOP signal\n");
      break;
  }
  write(STDOUT_FILENO, out, strlen(out));
}

int main() {
  signal(SIGINT, sig);
  signal(SIGTSTP, sig);
  while(1) {sleep(1);}
  return 0;
}

現在我們執行這個程式然後看看當我們輸入ctrl+z和ctrl+c會出現有什麼輸出。

從上面的輸出我們可以看到實現了我們想要的輸出結果,說明我們的函數重寫生效了。

段錯誤的魔幻

這裡有另外一個會產生SIGSEGV訊號的程式,我們看看這個程式的輸出是什麼:

#include <stdio.h>
#include <unistd.h> 
#include <signal.h>

void sig(int n) {
  write(STDOUT_FILENO, "a", 1); // 這個函數就是向標準輸出輸出一個字元 a 
}

int main() {
  signal(SIGSEGV, sig); // 這個是註冊一個 SIGSEGV 錯誤的處理常式 當作業系統給程序傳送一個 SIGSEGV 訊號之後這個函數就會被執行
  int* p; 
  printf("%d\n", *p); // 解除參照一個沒有定義的指標 造成 segementation fault
  return 0;
}

我們知道上面的程式肯定會產生 segmentation fault 錯誤,會收到 SIGSGEV 訊號,肯定會執行到函數sig。但是上面的程式會不斷的輸出a產生死迴圈。

上面程式的結果是不是有點難以理解,如果想要了解這個程式的行為,我們就需要了解作業系統是如何處理 segmentation fault 的,瞭解這個處理過程之後對上面程式的輸出就很容易理解了。

訊號處理常式的執行過程

當我們的程序接收到訊號會去執行我們重寫的訊號處理常式,如果在我們的訊號處理常式當中沒有退出程式或者轉移程式的執行流(可以使用setjmp和longjmp實現),即呼叫函數正常返回。訊號處理常式返回之後會重新執行訊號發生位置的指令,也就是說哪條指令導致作業系統給程序傳送訊號,那條條指令在訊號處理常式返回的時候仍然會被執行,因此我們才看到了上面的輸出結果,因為系統會不斷的執行那條發生了 segmentation fault 的指令。

那麼我們如何修正我們的程式碼,讓程式不進入死迴圈,讓程式能夠得到我們的接管呢。有兩種辦法:

  • 一種是在訊號處理常式當中進行一些邏輯處理之後然後,使用系統呼叫_exit直接退出。
  • 另外一種使用setjmp和longjmp進行執行流的跳轉。

直接使用_exit退出

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void sig(int n) {

  printf("直接在這裡退出\n");
  _exit(1); // 使用系統呼叫直接退出
}

int main() {
  signal(SIGSEGV, sig);
  *(int*) NULL = 0;
  printf("結束\n"); // 這個列印不會輸出
  return 0;
}

使用控制流跳轉

#include <stdio.h>
#include <signal.h>
#include <setjmp.h>

jmp_buf env;

void sig(int n) {
  printf("準備回到主函數\n");
  longjmp(env, 1);
}

int main() {
  signal(SIGSEGV, sig);
  if(!setjmp(env)) {
    printf("產生段錯誤\n");
    *(int*) NULL = 0;
  }else {
    printf("回到了主函數\n");
  }
  return 0;
}

總結

在本篇文章當中主要給大家介紹了Sgementation fault 的原理,並且自己動手寫了他的訊號處理常式,在訊號處理常式當中發現如果訊號處理常式正常退出的話,那麼程式會進入一個死迴圈,永遠不會停止,會不斷的產生Sgementation fault,因此我們使用了兩種方式讓程式結束,一種是在訊號處理常式當中不進行返回直接退出,但是這種情況會有一個弊端,如果我們原來的程式在後面還有一些操作的話就不能夠執行了,如果有些程式很重要的,這就可能會造成很多錯誤。第二種方式是我們可以使用setjmp和longjmp轉移控制流,再次回到主函數執行。

以上就是本篇文章的所有內容了,我是LeHung,我們下期再見!!!更多精彩內容合集可存取專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演演算法與資料結構)知識。