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 上的手册页没什么帮助——而且一开始我就没找到多少资料。
我找到了这篇文章(按现在的礼节得先提醒你这是个 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 究竟是怎么工作的。
它一定是在我每次执行命令时都会运行一段代码——某种钩子!
💥 Swoooooosh 💥
来自未来的 Matthias 从一道刺眼的光球中走出来:等等!事实根本不是这样的!
事实证明,像 bash 和 zsh 这样的 shell 根本不会为 history 去真正调用什么钩子。它们为什么要这么做呢?既然 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这设置了两个钩子:第一个在命令执行前被调用,第二个在命令执行后立即被调用。(我决定把我这个小小的 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”难道还不够吗?
嗯……来看看 关于 preexec 的 zsh 文档是怎么说的:
如果历史机制处于活动状态 […],用户输入的字符串会作为第一个参数传入,否则传入空字符串。将要执行的实际命令(包括展开后的别名)会以两种不同形式传入:第二个参数是命令的单行、长度受限版本(会省略函数体等内容);第三个参数则包含正在执行的完整文本。
不知道你怎么想,反正我觉得第三个参数就足够我们用的了?🤨
验证一下……
❯❯❯ 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 什么也拿不到吗?
我撒谎了。
实际上,你是可以读到已执行命令的退出码的(通过 $?)。这很有用,但我们就当没这回事,再也不提了。
把这个问题抛开不谈,我们最终的 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 在它的主运行循环中跟踪历史记录。下面是关键部分。(假设所有类型都已在作用域内。)
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);
}
}历史记录行被保存在一个哈希表中,同时也保存在一个环形缓冲区里,以防止历史记录变得过大。(见此处。)
这很聪明!如果没有环形缓冲区,恶意用户就可以用随机命令不断刷爆历史记录,直到触发缓冲区溢出。我可从没想过这一点。
历史时刻(明白我的梗了吗?)
最初的 history 命令是在 1978 年被加入到 Unix C shell(csh)中的。这里有一篇 论文,作者是 Bill Joy(嘿,又是一个 Bill!)。他从 Interlisp 中的 REDO 命令获得了灵感。你可以在 Interlisp 原始手册的第 8.7 节中找到它的规范。
收获
- 不懂的东西,就亲手重做一遍。
- 历史文件是人类可读的,而且相当直观。
history命令是 shell 的内置命令,但我们可以用钩子来自己实现一个。- 冷知识:你知道在 zsh 中,
history其实只是fc -l的一个别名吗?更多信息见此或查看源代码。
“我无法创造的东西,我就无法理解”——理查德·费曼
随机一篇博客
评论
登录后参与讨论