目錄
一、信號處理概述:為什么需要“信號”?
二、用戶空間與內核空間:進程的“雙重人格”
三、內核態與用戶態:權限的“安全鎖”
四、信號捕捉的內核級實現:層層“安檢”
五、sigaction函數:精細控制信號行為
1. 函數原型
2. 關鍵結構體
3. 示例代碼:動態修改信號處理
六、可重入函數
1. 問題場景
2. 問題分析
3. 原因解釋
4. 不可重入函數與可重入函數
5. 如何避免重入問題
七、volatile
1. 問題引入
2. 未使用volatile的情況
3. 使用volatile解決問題
4. volatile的作用總結
一、信號處理概述:為什么需要“信號”?
????????想象你在辦公室工作時,突然有人敲門提醒你快遞到了。這里的“敲門”就像操作系統發給進程的信號。信號是操作系統通知進程某個事件發生的機制,例如:
-
Ctrl+C
?發送?SIGINT?信號終止進程 -
程序崩潰時內核發送?SIGSEGV?信號
-
用戶自定義信號處理邏輯(如保存日志)
????????但進程不會立即處理信號,而是在“合適的時候”——比如從內核態切換回用戶態時。這背后隱藏著操作系統的核心設計邏輯。
二、用戶空間與內核空間:進程的“雙重人格”
每個進程的地址空間分為兩部分:
用戶空間 | 內核空間 |
---|---|
存儲進程私有代碼和數據 | 存儲操作系統全局代碼和數據 |
通過用戶級頁表映射物理內存 | 通過內核級頁表映射物理內存 |
每個進程看到的內容不同 | 所有進程看到的內容相同 |
// 示例:用戶空間的變量
int user_data = 100; // 內核空間的代碼(進程無權直接訪問)
void kernel_code()
{// 管理硬件資源
}
關鍵點:
-
用戶態代碼無法直接訪問內核空間(權限不足)
-
執行系統調用(如
printf
)時,進程會陷入內核,切換到內核態
三、內核態與用戶態:權限的“安全鎖”
用戶態 | 內核態 | |
---|---|---|
權限等級 | 低(普通用戶代碼) | 高(操作系統代碼) |
操作限制 | 無法直接訪問硬件 | 可執行任何指令 |
觸發場景 | 執行普通代碼 | 系統調用、中斷、異常 |
狀態切換示例:
printf("Hello"); // 用戶態 -> 內核態(執行write系統調用) -> 用戶態
具體步驟:
1、用戶態:調用printf
printf
是C標準庫函數,負責格式化字符串(如將"Hello"
轉換為字符流)。- 若輸出到終端(如屏幕),最終會調用**系統調用
write
**將數據寫入文件描述符(如標準輸出stdout
)。
2、觸發系統調用write
系統調用是用戶程序請求操作系統服務的唯一入口。
write
的函數簽名:
ssize_t write(int fd, const void *buf, size_t count);
其中fd=1
表示標準輸出,buf
指向數據緩沖區,count
為數據長度。
3、從用戶態陷入內核態
- CPU執行特殊的陷入指令(如
syscall
或int 0x80
),觸發軟中斷。 - 硬件自動切換特權級:用戶態(ring 3)→ 內核態(ring 0)。
- 跳轉到內核中預定義的系統調用處理函數(如
sys_write
)。
4、內核態:執行sys_write
- 操作系統驗證參數合法性(如
fd
是否有效)。 - 將用戶空間的數據(
"Hello"
)從緩沖區復制到內核空間(防止用戶篡改)。 - 調用設備驅動,將數據發送到終端(如控制臺、SSH會話)。
- 記錄返回結果(成功寫入的字節數或錯誤碼)。
5、返回用戶態
- 內核恢復用戶程序的寄存器狀態和堆棧。
- CPU特權級切換回用戶態(ring 3)。
- 用戶程序繼續執行
printf
之后的代碼。
🌴 為什么需要切換特權態?
用戶態的限制:
用戶程序無法直接訪問硬件(如磁盤、網卡)或修改關鍵數據結構(如進程表)。
例:若允許用戶程序直接寫磁盤,惡意程序可能覆蓋系統文件。內核態的權限:
操作系統代碼擁有最高權限,可安全管理硬件和資源。
通過系統調用“代理”用戶程序的請求,確保所有操作受控。
四、信號捕捉的內核級實現:層層“安檢”
????????在計算機系統里,程序運行時可能會遇到一些特殊情況,比如用戶按下某些按鍵或者系統出現了問題,這時候就需要程序能夠及時做出反應。這種反應機制在Linux系統中是通過“信號”來實現的。信號就像是一個信使,負責把發生的事件告訴程序。
????????現在,假設一個程序正在運行它的主函數(main函數),就好比一個人正在按照計劃做一件大事。突然,某個特定的事件發生了,比如用戶按下了一個特殊的按鍵組合(這會觸發SIGQUIT信號)。這時候,系統會暫時中斷這個人的工作,切換到一個專門處理這種情況的模式,也就是“內核態”,由操作系統來處理這個事件。
????????操作系統在處理完這個事件后,準備回到原來的程序繼續工作之前,會檢查有沒有需要特別處理的信號。如果發現有SIGQUIT信號,而且這個程序之前已經告訴過操作系統,當這個信號出現時要按照它自己定義的方式來處理(也就是注冊了一個信號處理函數sighandler),那么操作系統就會安排一個特殊的操作。
????????這個操作就是:不是直接回到原來的主函數繼續做之前的事情,而是先去執行那個專門定義的處理函數sighandler。這就好比在你做一件大事的時候,突然有緊急情況需要你先去處理一下,處理完了再回來繼續做原來的事。
????????需要注意的是,這個處理函數sighandler和原來的主函數(main函數)是兩個完全獨立的任務,它們就像兩條平行的路,沒有直接的調用關系。sighandler有自己的工作空間(不同的堆棧空間)來完成它的任務。
????????當處理函數sighandler完成自己的任務后,它會觸發一個特殊的指令(sigreturn系統調用),再次回到操作系統那里。操作系統會檢查是否還有其他的緊急情況需要處理。如果沒有,就會回到原來的主函數,恢復之前的狀態,繼續完成未做完的事情。
當進程從內核態返回用戶態時,會檢查未決信號集(pending):
-
檢查信號狀態
-
若信號未被阻塞(block),且處理動作為
默認
或忽略
:
→ 立即處理(如終止進程)并清除pending標志 -
若處理動作為
自定義
:
→ 先返回用戶態執行處理函數,再通過sigreturn
回到內核
-
-
執行自定義處理函數的關鍵步驟
-
內核不信任用戶代碼:必須返回用戶態執行處理函數
-
處理函數與主流程獨立(不同堆棧,無調用關系)
-
五、sigaction函數:精細控制信號行為
1. 函數原型
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
參數說明:
signo?:指定信號的編號(如SIGINT)。
act :新的處理動作
oldact :保存舊的處理動作
2. 關鍵結構體
結構體 sigaction 的定義如下:
struct sigaction
{void (*sa_handler)(int); // 信號處理函數sigset_t sa_mask; // 額外屏蔽的信號int sa_flags; // 控制選項(通常設為0)// 其他字段(如sa_sigaction)暫不討論
};
3. 示例代碼:動態修改信號處理
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>struct sigaction act, oact;// 自定義信號處理函數
void handler(int signo)
{printf("捕獲信號: %d\n", signo);// 恢復為默認處理方式(僅第一次捕獲時自定義)sigaction(SIGINT, &oact, NULL);
}int main()
{memset(&act, 0, sizeof(act));memset(&oact, 0, sizeof(oact));act.sa_handler = handler; // 設置自定義處理函數act.sa_flags = 0; // 無特殊標志sigemptyset(&act.sa_mask); // 不額外屏蔽其他信號// 注冊SIGINT信號(Ctrl+C觸發)sigaction(SIGINT, &act, &oact);while (1){printf("程序運行中...\n");sleep(1);}return 0;
}
運行效果:
-
第一次按下
Ctrl+C
?→ 打印“捕獲信號: 2” -
再次按下
Ctrl+C
?→ 進程終止(已恢復默認行為)
六、可重入函數
1. 問題場景
????????假設我們有一個簡單的鏈表結構,定義了兩個節點 node1 和 node2,以及一個頭指針 head。在 main 函數中,我們調用 insert 函數將 node1 插入到鏈表中。insert 函數的實現分為兩步:首先將新節點的 next 指針指向當前頭節點,然后更新頭指針為新節點。
node_t node1, node2, *head;void insert(node_t *p) {p->next = head; // 第一步:將新節點的 next 指針指向當前頭節點head = p; // 第二步:更新頭指針為新節點
}int main() {// ... 其他代碼 ...insert(&node1); // 在 main 函數中插入 node1// ... 其他代碼 ...
}
????????在插入 node1 的過程中,假設剛執行完第一步(p->next = head),此時發生了硬件中斷,導致進程切換到內核態。在內核態處理完中斷后,檢查到有信號待處理,于是切換到信號處理函數 sighandler。sighandler 同樣調用 insert 函數,試圖將 node2 插入到同一個鏈表中。
void sighandler(int signo) {// ... 其他代碼 ...insert(&node2); // 在信號處理函數中插入 node2// ... 其他代碼 ...
}
????????當 sighandler 完成插入 node2 的操作并返回內核態后,再次回到用戶態,繼續執行 main 函數中被中斷的 insert 函數的第二步(head = p)。
2. 問題分析
????????理想情況下,我們希望 main 函數和 sighandler 分別將 node1 和 node2 插入到鏈表中,最終鏈表包含兩個節點。然而,實際情況卻并非如此。
-
main 函數插入 node1 的第一步 :將 node1 的 next 指針指向當前頭節點(初始時 head 為 NULL),此時 node1->next = NULL。
-
中斷發生,切換到內核態 :main 函數的 insert 操作被中斷,此時 head 還未更新為 node1。
-
sighandler 插入 node2 :在信號處理函數中,執行 insert(&node2)。此時 head 仍為 NULL,所以 node2->next = NULL,然后 head 被更新為 node2。
-
返回 main 函數繼續執行 :執行 insert 函數的第二步,將 head 更新為 node1。
????????最終,鏈表的頭指針 head 指向 node1,而 node1 的 next 指針為 NULL。node2 被插入后又被覆蓋,實際上沒有真正加入鏈表。
3. 原因解釋
????????這個問題的根源在于 insert 函數被不同的控制流程(main 函數和 sighandler)調用,且在第一次調用還未完成時就再次進入該函數。這種現象稱為“重入”(Reentrant)。insert 函數訪問了一個全局鏈表 head,由于全局變量在多個控制流程之間共享,導致數據不一致。
4. 不可重入函數與可重入函數
-
不可重入函數 :如果一個函數在被調用過程中,其內部操作依賴于全局變量或共享資源,并且在函數執行過程中這些資源可能被其他調用者修改,那么這個函數就是不可重入的。像上面的 insert 函數,因為它操作了全局鏈表 head,所以在重入情況下容易出錯。
-
可重入函數 :如果一個函數只訪問自己的局部變量或參數,不依賴于全局變量或共享資源,那么它就是可重入的。可重入函數在不同控制流程中被調用時,不會相互干擾。
5. 如何避免重入問題
-
避免使用全局變量 :盡量使用局部變量,或者通過參數傳遞必要的數據。
-
使用互斥機制 :在多線程或信號處理場景中,使用互斥鎖(如 mutex)來保護共享資源的訪問。
-
設計可重入函數 :確保函數只依賴于參數和局部變量,不依賴于外部環境。
七、volatile
????????在C語言中,volatile
是一個經常被提及但又容易被誤解的關鍵字。今天,我們通過一個具體的信號處理例子,來深入理解 volatile
的作用。
1. 問題引入
考慮以下代碼:
#include <stdio.h>
#include <signal.h>int flag = 0;void handler(int sig) {printf("change flag 0 to 1\n");flag = 1;
}int main() {signal(2, handler);while (!flag);printf("process quit normal\n");return 0;
}
????????該程序的功能是:在接收到 SIGINT 信號(如用戶按下 Ctrl+C)時,執行自定義信號處理函數 handler
,將全局變量 flag
設置為 1,從而退出 while
循環,程序正常結束。
2. 未使用volatile的情況
????????在未使用 volatile
修飾 flag
的情況下,編譯器可能會對代碼進行優化。例如,當使用 -O2(大寫字母O)
優化選項編譯時,編譯器可能會認為 flag
的值在 while
循環中不會被改變(因為從代碼的靜態分析來看,沒有明顯的修改操作),于是將 flag
的值緩存到 CPU 寄存器中,而不是每次都從內存中讀取。
????????這就會導致一個問題:當信號處理函數 handler
修改了 flag
的值時,while
循環中的條件判斷仍然使用寄存器中的舊值,無法及時檢測到 flag
的變化,程序無法正常退出。這種現象被稱為“數據不一致性”或“內存可見性”問題。
3. 使用volatile解決問題
為了解決上述問題,我們需要使用 volatile
關鍵字修飾 flag
變量:
#include <stdio.h>
#include <signal.h>volatile int flag = 0;void handler(int sig) {printf("change flag 0 to 1\n");flag = 1;
}int main() {signal(2, handler);while (!flag);printf("process quit normal\n");return 0;
}
? ?volatile
告訴編譯器,該變量的值可能會被程序之外的其他因素(如信號處理函數、硬件中斷等)改變,因此編譯器在優化時不會假設該變量的值不變。每次訪問 volatile
修飾的變量時,編譯器都會生成代碼從內存中重新讀取該變量的值,而不是使用寄存器中的緩存值。
????????這樣,在信號處理函數修改了 flag
的值后,while
循環中的條件判斷能夠及時檢測到變化,程序可以正常退出。
4. volatile的作用總結
volatile
的主要作用是保持內存的可見性,確保程序能夠正確地讀取和寫入變量的最新值。在以下場景中,使用 volatile
是必要的:
-
信號處理 :當變量可能被信號處理函數修改時,需要使用
volatile
修飾,以確保主程序能夠及時檢測到變量的變化。 -
多線程編程 :在多線程環境中,當變量可能被其他線程修改時,
volatile
可以防止編譯器優化導致的內存可見性問題。不過,需要注意的是,volatile
并不能完全替代互斥鎖等同步機制,因為它不能保證操作的原子性。 -
硬件寄存器訪問 :當程序需要直接訪問硬件寄存器時,這些寄存器的值可能會被硬件異步修改,因此需要使用
volatile
修飾相關的指針或變量。