Unix 的 `history` 指令是如何運作的?
原文由 Matthias Endler 于 發布,訂閱此部落格

一天即將結束,我有整整一個小時完全屬於自己。正是聽點 Billie Joel(最近不是聽 Billie Joel 就是 Billie Eilish)的完美時機,順便來研究一下 Unix 的 history 指令是怎麼運作的。人生真美好。
探究 Unix 的運作原理算是我的一個小嗜好。
之前我已經寫過 yes、ls 和 cat 了。別笑我。
history 到底是怎麼運作的?
每一個指令都會被記錄下來,所以當我在自己的電腦上執行 history 時,就能看到最近的幾筆指令。
❯❯❯ history
8680 cd endler.dev
8682 cd content/2021
8683 mkdir history
8684 cd history
8685 vim index.md對,但它是怎麼辦到的?
我 Mac 上的 manpage 其實沒什麼幫助——老實說一開始也找不到什麼資料。
我找到了這篇文章(現在先提醒你這是 Medium 連結,也算是種禮貌),裡面稍微說明了背後的運作方式。
每一個指令都會被儲存在 $HISTFILE 裡,對我來說它指向 ~/.zsh_history。
❯❯❯ tail $HISTFILE
: 1586007759:0;cd endler.dev
: 1586007763:0;cd content/2021
: 1586007771:0;mkdir history
: 1586007772:0;cd history
: 1586007777:0;vim index.md
...來看看吧。開頭是一個 :,接著是時間戳記,再來是 :0,然後是一個分隔符號(;),最後才是真正的指令。每個新指令都會被附加到檔案的最後面。要重現這個格式也不難。
等等,那個 0 是什麼意思!?
原來它是指令執行時間,而整個格式被稱為擴充歷史格式:
: <beginning time>:<elapsed seconds>;<command>(視你的設定而定,你的檔案看起來可能會不太一樣。)
接入 history
但話說回來,history 到底是怎麼真正運作的。
它一定是在我每次執行指令時都會跑一段程式碼——某種 hook(鉤子)吧!
💥 Swoooooosh 💥
來自未來的 Matthias 從一道刺眼的光球中現身:等等!才不是那樣運作的!
事實上,像 bash 和 zsh 這類的 shell 並不會真的為了 history 去呼叫什麼 hook。何必呢?既然 history 本身就是 shell 內建指令,它們大可以直接在內部追蹤指令就好。
幸好我的總編輯兼駐站 Unix 老屁股 Simon Brüggen 跟我解釋了這件事——不過是在我把這篇文章的初稿寄給他之後才說的。😓
因此,接下來的這一節有點像《魔戒》:一個討喜但天真的小夥子,踏上了一場前途未卜的任務,完全不知道自己被捲進了什麼。
幫自己辯解一下,《魔戒》主要也是因為娛樂價值而受到喜愛,而不是因為它的歷史正確性⋯⋯就像這部史詩故事一樣,我保證最後我們一定會把事情搞清楚。
我在 add-zsh-hook 和 atuin 的原始碼中找到了一個使用範例。
我也許沒辦法完全搞懂上面寫的所有東西,但我是個行動派,我可以把一個紮實的作品拆開來研究。
東西不多,但這就是我整理出來的成果:
# Source this in your ~/.zshrc
autoload -U add-zsh-hook
_past_preexec(){
echo "preexec"
}
_past_precmd(){
echo "precmd"
}
add-zsh-hook preexec _past_preexec
add-zsh-hook precmd _past_precmd這樣就設定了兩個 hook:第一個會在指令即將執行前被呼叫,第二個則在執行完後立刻被呼叫。(我決定把我的小 history 替代品叫做 past。我喜歡簡短的名字。)
好,來告訴 zsh,每當我們執行指令時就執行這個檔案:
source src/shell/past.zsh⋯⋯然後
❯❯❯ date
preexec
Fri May 28 18:53:55 CEST 2021
precmd成功了!✨ 太令人興奮了! ✨
其實我現在才想起來,兩年多前我就為了我的小工具——環境設定管理工具 envy 做過一模一樣的事,不過算了啦!
那麼,該拿我們新獲得的超能力做些什麼呢?
來跑些 Rust 程式碼吧
重點是:只有 preexec 能拿到「真正的」指令。precmd 什麼都拿不到:
_past_preexec(){
echo "preexec $@"
}
_past_precmd(){
echo "precmd $@"
}$@ 的意思是「把你有的都給我看看」,而它拿到的就是這些:
❯❯❯ date
preexec date date date
Fri May 28 19:02:11 CEST 2021
precmd一個「date」難道還不夠嗎?
嗯⋯⋯來看看 zsh 關於 preexec 的文件說明:
如果 history 機制處於啟用狀態 […],使用者輸入的字串會作為第一個參數傳入,否則會是空字串。實際將被執行的指令(包含展開後的別名)會以兩種不同形式傳入:第二個參數是單行、長度受限的指令版本(例如函式本體等內容會被省略);第三個參數則包含即將被執行的完整文字。
不知道你怎麼想,但第三個參數應該就是我們唯一需要的吧?🤨
來驗證一下⋯⋯
❯❯❯ ls -l
preexec ls -l lsd -l lsd -l(在此致敬 lsd,也就是次世代的 ls 指令)
好,夠用了。來用 Rust 程式碼解析 $3,然後把它寫進我們自己的 history 檔案吧。
use std::env;
use std::error::Error;
use std::fs::OpenOptions;
use std::io::Write;
const HISTORY_FILE: &str = "lol";
fn main() -> Result<(), Box<dyn Error>> {
let mut history = OpenOptions::new()
.create(true)
.append(true)
.open(HISTORY_FILE)?;
if let Some(command) = env::args().nth(3) {
writeln!(history, "{}", command)?;
};
Ok(())
}❯❯❯ cargo run -- dummy dummy hello
❯❯❯ cargo run -- dummy dummy world
❯❯❯ cat lol
hello
world我們快完成了——至少如果你願意稍微作弊一下的話。😏 來把那個格式字串寫死吧:
use std::env;
use std::error::Error;
use std::fs::OpenOptions;
use std::io::Write;
use std::time::SystemTime;
const HISTORY_FILE: &str = "lol";
fn timestamp() -> Result<u64, Box<dyn Error>> {
let n = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
Ok(n.as_secs())
}
fn main() -> Result<(), Box<dyn Error>> {
let mut history = OpenOptions::new()
.create(true)
.append(true)
.open(HISTORY_FILE)?;
if let Some(command) = env::args().nth(3) {
writeln!(history, ": {}:0;{}", timestamp()?, command)?;
};
Ok(())
}現在,如果瞇著眼睛看一下,它也算是把我們的指令寫成我的 history 格式了。(那段關於 Unix 時間戳記的寫法是直接從文件抄來的。毫無罪惡感。)
還記得我說 precmd 什麼都拿不到嗎?
我騙你的。
實際上,你可以從 $? 讀到剛執行完的指令的結束狀態碼(exit code)。這其實很有用,但我們就當作沒這回事,再也不提它了。
把這件事拋開不談,我們最終的 past.zsh hook 檔案長這樣:
autoload -U add-zsh-hook
_past_preexec(){
past $@
}
add-zsh-hook preexec _past_preexec現在要進入危險環節了!先退後一點,看我用自己的版本取代原本的 history 指令。千萬別在家嘗試。(其實我有點誇大了。儘管試試看吧。最糟的情況就是你會遺失一點歷史紀錄,但別來告我。)
首先,把 history 檔案的路徑改成我真正的路徑:
// You should read the ${HISTFILE} env var instead ;)
const HISTORY_FILE: &str = "/Users/mendler/.zhistory";接著來安裝 past:
❯❯❯ cargo install --path .
# bleep bloop...這樣就準備好可以使用了。來把這個小傢伙加到我的 ~/.zshrc 裡:
source "/Users/mendler/Code/private/past/src/shell/past.zsh"終於可以來測試了。
我們開啟一個新的 shell,執行幾個指令,接著再跑 history:
❯❯❯ date
...
❯❯❯ ls
...
❯❯❯ it works
...
❯❯❯ history
1011 date
1012 ls
1013 it works✨ 成功了。 ✨ past 的原始碼就在 Github 上。
它真正真正的運作原理
我們的實驗大獲成功,但我後來才知道,現實其實有點不一樣。
「在早期的 Unix 版本中,history 指令是一個獨立的程式」,但多數現代的 shell 都把 history 做成了內建指令。
zsh 是在它的主執行迴圈中追蹤歷史紀錄的。以下是關鍵的部分。(假設所有型別都已在作用域內。)
Eprog prog;
/* Main zsh run loop */
for (;;)
{
/* Init history */
hbegin(1);
if (!(prog = parse_event(ENDINPUT)))
{
/* Couldn't parse command. Stop history */
hend(NULL);
continue;
}
/* Store command in history */
if (hend(prog))
{
LinkList args;
args = newlinklist();
addlinknode(args, hist_ring->node.nam);
addlinknode(args, dupstring(getjobtext(prog, NULL)));
addlinknode(args, cmdstr = getpermtext(prog, NULL, 0));
/* Here's the preexec hook that we used.
* It gets passed all the args we saw earlier.
*/
callhookfunc("preexec", args, 1, NULL);
/* Main routine for executing a command */
execode(prog);
}
}歷史紀錄的每一行會被保存在一個 hash 中,同時也會放在一個環形緩衝區裡,以防止歷史紀錄變得過大。(請看這裡。)
這招真聰明!如果沒有環形緩衝區,惡意使用者只要用隨機指令不斷灌爆歷史紀錄,就可能觸發緩衝區溢位。這點我從來沒想到過。
歷史小時間(有看出我在玩諧音梗嗎?)
最初的 history 指令是在 1978 年被加入到 Unix C shell(csh)中的。這裡有一篇 Bill Joy(嘿,又一個 Bill!)的論文連結。他的靈感來自 Interlisp 中的 REDO 指令。你可以在原始的 Interlisp 手冊第 8.7 節中找到它的規格說明。
學到的教訓
- 不懂的東西,就自己重做一遍。
- history 檔案是人類可讀的,而且相當直觀。
history指令是 shell 的內建指令,但我們可以用 hook 來寫出自己的版本。- 冷知識:你知道在 zsh 中,
history其實只是fc -l的別名嗎?更多資訊請看這裡,或直接查看原始碼。
「我無法創造的東西,我就無法理解」—— Richard Feynman
隨機一篇部落格
留言
登入後參與討論