Unix 的 history 命令是如何工作的?
 创作,tux 由 [catalyststuff](https://www.freepik.com/vectors/baby) 创作 — freepik.com](https://endler.dev/2021/history/hero.jpg)
一天将尽,我正好有一个小时完全属于自己的时间。正是听点 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 上的 man 手册页帮不上什么忙——而且一开始我也没找到多少资料。
我找到了这篇文章(如今提醒你这是 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 是怎么回事!?
原来它是命令的执行时长,整个格式被称为扩展历史格式(extended history format):
: <开始时间>:<经过的秒数>;<命令>(根据你的设置,你的文件可能看起来不太一样。)
挂钩历史记录
但话说回来,history 究竟是怎么工作的呢。
它一定是在我每次执行命令时运行某些代码——某种钩子(hook)!
💥 嗖———— 💥
来自未来的 Matthias 从一团耀眼的光球中走出:等一等等一等等一下!事情根本不是这样运作的!
事实证明,像 bash 和 zsh 这样的 shell 其实并不会为 history 调用钩子。为什么要调用呢?既然 history 是一个 shell 内建命令(builtin),它们直接在内部跟踪命令就行了。
幸好我的主编、常驻 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这设置了两个钩子:第一个在命令即将被执行之前被调用,第二个紧随其后被调用。(我决定把我这个小巧的 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 的文档:
如果历史记录机制处于活动状态 […],用户输入的字符串会作为第一个参数传入,否则它是一个空字符串。将要执行的实际命令(包括展开后的别名)以两种不同的形式传入:第二个参数是该命令的单行、有长度限制的版本(函数体之类的内容会被省略);第三个参数包含正在执行的完整文本。
不知道你怎么想,反正我觉得第三个参数应该就是我们需要的全部了?🤨
验证一下……
❯❯❯ ls -l
preexec ls -l lsd -l lsd -l(向 lsd 致敬,下一代 ls 命令)
好了,够用了。让我们用一些 Rust 代码解析 $3 并把它写入我们自己的历史文件。
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(())
}现在,只要眯起眼睛看,它差不多就算是以我的历史格式写入我们的命令了。(Unix 时间戳那部分是直接从文档里抄来的。毫不后悔。)
还记得我说过 precmd 什么都拿不到吗?
我撒谎了。
实际上,你可以读取所执行命令的退出码(exit code)(从 $?)。这非常有用,但我们还是假装忽略它,并且永远不要再提这件事。
把这个撇开之后,我们最终的 past.zsh 钩子文件是这样的:
autoload -U add-zsh-hook
_past_preexec(){
past $@
}
add-zsh-hook preexec _past_preexec现在到了危险的部分!退后,我要用我自己的实现替换原来的 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 在它的主运行循环(run loop)中跟踪历史记录。以下是重要的部分。(假设所有类型都在作用域内。)
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);
}
}历史行保存在一个哈希表中,同时也保存在一个环形缓冲区(ring-buffer)中,以防历史记录变得过大。(见这里。)
这很聪明!如果没有环形缓冲区,恶意用户可以用随机命令不断冲击历史记录,直到触发缓冲区溢出(buffer overflow)。我从没想到过这一点。
历史时刻(看出我玩的双关了吗?)
最初的 history 命令于 1978 年被加入 Unix C shell(csh)。这里是 Bill Joy(比尔·乔伊)的论文链接(嘿,又是一个 Bill!)。他的灵感来自 Interlisp 中的 REDO 命令。你可以在原始 Interlisp 手册的第 8.7 节找到它的规范。
学到的经验
- 重建你不理解的东西。
- 历史文件是人类可读的,而且相当直观。
history命令是 shell 内建命令,但我们可以利用钩子编写自己的版本。- 有趣的事实:你知道吗,在 zsh 中,
history实际上只是fc -l的别名?更多信息见这里,或者查看源代码。
“凡我不能创造的,我就不能理解。”——Richard Feynman(理查德·费曼)
随机一篇博客