使用 Rust 進行系統(tǒng)編程 — 構建一個迷你 Shell
在介紹性的第 1 部分中,我們討論了什么是系統(tǒng)進程,如何生成它們,以及如何傳遞命令和執(zhí)行它們。如果你之前沒看,可以點擊這里查看。
今天是 Rust 系統(tǒng)編程的第二部分。具體包含如下內容:
什么是系統(tǒng)信號及如何處理 什么是 stdout、 stdin 和 stderr,以及如何有效地使用它們 據(jù)寫入標準輸出(stdout)并從標準輸入(stdin)讀取數(shù)據(jù),而不是打印,這樣做的好處是什么 管理父進程和子進程及其執(zhí)行順序
為了在實踐中演示上面列出的主題,我們將構建一個 UNIX mini-shell,它將能夠從終端中的用戶獲取命令并執(zhí)行它們。該程序還將處理一些無效的命令,并優(yōu)雅地處理被卡住的程序。
stdin, stdout 和 stderr
你可能熟悉計算機中的數(shù)據(jù)流,但不僅僅是像水流,它指的是從源到端點的數(shù)據(jù)流。流允許連接命令、進程、文件等。有三個特殊的流:
stdin (標準輸入) :接受文本作為輸入 stdout (標準輸出) :在 stdout 流中存儲文本輸出 stderr (標準錯誤) :當流中發(fā)生錯誤時,錯誤消息將存儲在此流中
Linux 系統(tǒng)是面向文件的。這意味著幾乎所有的流都被視為文件,并且這些流都是基于每個文件類型所具有的唯一標識符代碼來處理的。對于 stdio (標準輸出、輸入和錯誤的集合) ,分配的值為:stdin 為 0,stdout 為 1,stderr 為 2。如果我們想從命令行讀取文本流,在 C 語言中,我們使用 read() 函數(shù),并提供代碼 0 作為 stdin 的參數(shù)之一(圖1-a)。

在 Rust 中,從 stdio 讀取和寫入有點不同,但是基本原理是一樣的。為了更好地演示它們的用途,我們將開始為迷你 Shell 程序編寫代碼。開始我們將創(chuàng)建一個循環(huán),要求用戶輸入系統(tǒng)將執(zhí)行的命令。我們需要創(chuàng)建的前兩個功能是寫入到標準輸出和從標準輸入讀取。
use std::io::{self, Write};
/// flushes text buffer to the stdout
fn write_to_stdout(text: &str) -> io::Result<()> {
io::stdout().write(text.as_ref())?;
io::stdout().flush()?; // flush to the terminal
Ok(())
}
我們將使用一個標準的 io > 模塊寫入終端。函數(shù) write_to_stdout() 不是通過復制傳遞 String,而是將對字符串片段的引用作為參數(shù)。str 不同于 String。它是 Rust 所指的一個切片,是對一個字符串的一部分的引用。如果你想更好地理解這兩者之間的區(qū)別,我建議你閱讀 Rust 的官方書籍的第四章[1]。
函數(shù) write_to_stdout() 的作用是返回 Result 對象,它可以是 Ok,也可以是 Err。正如這些名字所暗示的,如果一切按計劃進行,將返回 Ok 否則 Err 將返回。這種過程在 Rust 中普遍如此,所以,針對要返回 Err,在函數(shù)調用結束時可以寫一個特殊的符號 ? 表示可能會出錯。
在函數(shù)內部,我們調用一個 write() 函數(shù)來填充 stdout 的文本緩沖區(qū),然后在屏幕上刷新文本。在 write() 中,我們使用了 as_ref() 方法,該方法將字符串切片轉換為 ASCII 字節(jié)文本,因為這是上面提到的函數(shù)作為參數(shù)所期望的。
接下來,我們需要構建一個函數(shù)來讀取用戶輸入的命令并處理它。為此,我們將編寫一個自定義函數(shù) get_user_command(),它返回 String。
/// fetch the user inputted command from terminal
fn get_user_command() -> String {
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap(); // not receommended
if input.ends_with('\n') {
input.pop(); // remove last char
}
input
}
該函數(shù)從終端讀取一個完整的行并將值復制到輸入變量中。read_line() 接受輸入 String 變量的可變引用,在函數(shù)調用中取消引用,寫入用戶提供的命令,并返回 Result。當我們從 stdin 讀取一行時,它的 EOL (行尾)被終止,其中包括末尾的 \n 控制字符,我們需要在返回輸入之前刪除它。
最后,我們將輸入和輸出函數(shù)與迷你 shell 程序粘合在一起。
use std::io::{self, Write};
fn main() {
loop { run_shell(); }
}
fn run_shell() {
let shellname = "ghost# ";
match = write_to_stdout(&shellname) {
Ok(v) => v,
Err(e) => {
eprintln!("Unable to write to stdout : {}", e);
process::exit(1);
},
}
let cmnd = get_user_command();
if let Err(_) = process::Command::new(&cmnd).status() {
eprintln!("{}: command not found!", &cmd);
}
}
在 main() 函數(shù)中,我們運行一個循環(huán),將 shell 名稱打印到終端屏幕,并等待用戶輸入命令。run_shell() 使用我們以前定義的函數(shù)寫入 stdout,并在打印過程中處理錯誤。如果出現(xiàn)錯誤,它會通知用戶并退出程序,錯誤代碼為1(編譯失敗)。
接下來,它讀取用戶提供的命令,并將該命令傳遞給新創(chuàng)建的進程。然后我們檢查命令執(zhí)行的狀態(tài),如果命令不成功,我們通知用戶“命令沒有找到”,并且我們不退出,而是返回提示用戶輸入的循環(huán)。
通過 cargo run 運行該程序,我們應該會看到類似下面的輸出:

這里要問的一個很好的問題是,為什么我們使用讀寫函數(shù),而不是簡單地打印到屏幕上。這背后的原因是,像 read 和 write 這樣的指令是所謂的 Async-Signal Safe 函數(shù),而 C 的 printf 不是??梢栽谛盘柼幚沓绦蛑邪踩卣{用它們(我們將在下面討論)。
保證異步信號安全的函數(shù)在發(fā)送信號時不會被中斷或干擾。例如,如果我們在 println!() 調用和信號發(fā)生的中間,信號的處理程序本身調用 println!() 可能會導致未定義行為。因為在這種情況下,兩個 println!() 的輸出會交織在一起。
系統(tǒng)信號
為了改進我們的迷你 Shell,我們必須處理系統(tǒng)信號。UNIX 環(huán)境中的信號是一種通知,由操作系統(tǒng)發(fā)送給進程以通知某個事件,這通常會中斷進程。每個信號都有一個唯一的名稱和分配給它的整數(shù)值。你可以通過在終端中鍵入 kill -l 來查看系統(tǒng)信號的完整列表。
默認情況下,每個信號都有其定義的處理程序,該處理程序是在某個信號到達時調用的函數(shù)。我們可以修改對這些信號的處理(我們將為迷你 shell 項目這樣做)。然而,一些信號處理程序不能被修改。
對于我們的項目來說,我們將看到以下四個信號:
SIGINT:通過按Ctrl+C使系統(tǒng)向正在運行的進程發(fā)送 INT 信號。默認情況下,這會導致進程立即終止。SIGINT 的信號碼是 2。SIGQUIT:通過按Ctrl+\使系統(tǒng)將向正在運行的進程發(fā)送 QUIT 信號。這也會終止該進程,但更優(yōu)雅。這將執(zhí)行需要清理的絕對必要的資源。SIGQUIT 的信號代碼是 3。SIGALRM就像一個時鐘,以秒為單位倒計時。如果秒計數(shù)為零,則取消任何掛起的報警,并向進程發(fā)送 SIGALRM 信號。SIGALRM 的信號代碼是 14。SIGKILL是系統(tǒng)發(fā)出的強制進程停止的最強信號。此信號不能由用戶手動處理,但系統(tǒng)仍將在進程終止后執(zhí)行清理。SIGKILL 的信號代碼是 9。
現(xiàn)在,是時候確認在 Rust 中,我們將如何處理上面列出的信號了(除了 SIGKILL,因為我們不能更改它的默認行為)。例如,如果在 Linux 終端中運行 cat 命令時沒有文件參數(shù),那么它將陷入無限循環(huán)。當這種情況發(fā)生在我們的 mini-shell 中時,我們將發(fā)出 SIGINT 信號,以便它將中斷信號轉發(fā)給子進程。這將只會終止循環(huán),但將保持我們的 shell 程序運行。
use signal_hook::{iterator, consts::{SIGINT};
use std::{process, thread, error::Error};
use nix::sys::signal::{self, Signal};
/// Registers UNIX system signals
fn register_signal_handlers() -> Result<(), Box<dyn Error>> {
let mut signals = iterator::Signals::new(&[SIGINT])?;
// signal execution is forwarded to the child process
thread::spawn(move || {
for sig in signals.forever() {
match sig {
SIGINT => assert_ne!(0, sig), // assert that the signal is sent
_ => continue,
}
}
});
Ok(())
}
首先,我們創(chuàng)建一個信號迭代器,它存儲信號引用的向量。這里我們指出哪些信號需要被處理。接下來,我們需要將信號轉發(fā)給正在運行的子進程,并在其上執(zhí)行所需的行為。這是通過生成一個返回 JoinHandler 的新線程來實現(xiàn)的。
此處理程序將在刪除后分離子進程。這意味著當 SIGINT 到達子進程時,該進程將與父進程分離,它只會中斷子進程正在執(zhí)行的任何操作,而父進程將繼續(xù)運行。如果執(zhí)行中沒有子進程,那么它將什么也不做。
我們在信號迭代器上使用 forever() 函數(shù),它對到達的信號進行無限循環(huán)。一旦信號到達,它將評估與匹配的情況,如果它匹配 SIGINT,將斷言信號已成功發(fā)送。對于任何其他信號,迭代器將繼續(xù)等待下一個信號。
既然我們重新接管了 SIGINT 信號,只處理子進程,那么如果我們想完全退出程序呢?我們將處理一個不同的信號,并讓它向標準輸出打印 “Goodbye”,然后優(yōu)雅地退出。對于這種情況,我們將使用 SIGQUIT 信號,它可以通過按下 Ctrl + \ 鍵發(fā)送。
use signal_hook::consts::SIGQUIT;
// .. previous function introduction and matching ..
SIGQUIT => {
write_to_stdout("Goodbye!\n").unwrap();
process::exit(0);
},
// .. rest of the function ..
當調用 SIGQUIT 信號時,它在迭代器中進行匹配,這將調用 write_to_stdout() 函數(shù)。然后程序退出,代碼 0,在 Linux 中代表成功。注意,我們正在從 signal_hook 庫導入 SIGNAL 常量,該庫是一個更容易處理 Unix 信號的庫。
最后,我們將為我們的程序添加一個小特性。用戶將在程序啟動時提供一個整數(shù)。這個數(shù)字將用作程序執(zhí)行時間的倒計時。例如,如果用戶提供 5,那么在啟動子進程時將調用 alarm(5)。如果一個函數(shù)在倒計時結束時沒有完成,我們手動定義的 SIGALRM 信號將殺死它并將程序返回到初始狀態(tài)。
use signal_hook::consts::SIGALRM;
use nix::sys::signal::{self, Signal};
use nix::unistd::{alarm, Pid};
/// alarm will be called from `execute_shell(timeout: u32)`
/// after function collects user input it calls `alarm::set(timeout)`
// .. beginning of the register_signal_handlers function ..
SIGALRM => {
write_to_stdout("This's taking too long...\n").unwrap();
// when alarm goes off it kills child process
signal::kill(Pid::from_raw(0), Signal::SIGINT).unwrap()
},
// .. rest of the function ..
當 SIGALRM匹配時,首先,它將寫入到標準輸出,然后,它將執(zhí)行一個非常有趣的操作。它將使用 signal::kill() 函數(shù)在它所操作的進程上發(fā)送 SIGINT 信號。但是,由于同一個函數(shù)通過將其轉發(fā)給子進程來處理 SIGINT,它只會殺死子進程,并返回到運行 mini-shell 的主程序。代碼如下:
use signal_hook::{iterator, consts::{SIGINT, SIGALRM, SIGQUIT}};
use std::{process, thread, error::Error};
use nix::sys::signal::{self, Signal};
use nix::unistd::{alarm, Pid};
/// Register UNIX system signals
fn register_signal_handlers() -> Result<(), Box<dyn Error>> {
let mut signals = iterator::Signals::new(&[SIGINT, SIGALRM, SIGQUIT])?;
// signal execution is forwarded to the child process
thread::spawn(move || {
for sig in signals.forever() {
match sig {
SIGALRM => {
write_to_stdout("This's taking too long...\n").unwrap();
// when alarm goes off it kills child process
signal::kill(Pid::from_raw(0), Signal::SIGINT).unwrap()
},
SIGQUIT => {
write_to_stdout("Good bye!\n").unwrap(); // not safe
process::exit(0);
},
SIGINT => assert_ne!(0, sig), // assert that the signal is sent
_ => continue,
}
}
});
Ok(())
}
如果你通過終端運行我們的 mini-shell,應該得到如下預期的結果:

你可以在這個 GitHub 存儲庫中找到這個 mini-shell 的完整代碼[2],其中包括其他一些特性。
總結
今天我們學習了什么是 stdin、 stdout 和 stderr,以及如何正確使用它們。我們研究了常見的 UNIX 系統(tǒng)信號,并手動處理了其中的三個,以滿足迷你 shell 程序的需要。第 1 部分的知識使我們能夠構建一個程序來安全快速地執(zhí)行系統(tǒng)命令和處理系統(tǒng)信號,這得益于 Rust 語言。
在接下來的部分中,我們將介紹管道之間進程的通信以及并發(fā)性。我們將證明為什么 Rust 可能是這方面的最佳選擇。
原文鏈接:https://www.bexxmodd.com/post/systems-programming-with-rust-2
參考資料
官方書籍的第四章: https://doc.rust-lang.org/nightly/book/ch04-01-what-is-ownership.html
[2]完整代碼: https://github.com/bexxmodd/systems-with-rust/blob/master/src/main.rs
推薦閱讀
覺得不錯,點個贊吧
掃碼關注「Rust編程指北」
