Perf 笔记

Perf 笔记

环境 Linux Syameimaru-Aya 5.17.0-2-amd64 #1 SMP PREEMPT Debian 5.17.6-1 (2022-05-11) x86_64 GNU/Linux

配置环境

首先安装 linux-perf 软件包,获得 perf(1) 应用程序。

接着运行 perf,发现报了奇怪的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
19:56 Syameimaru-Aya ~/sr/la/hpc/perf
0 perf record -a ./a.out
Error:
Access to performance monitoring and observability operations is limited.
Consider adjusting /proc/sys/kernel/perf_event_paranoid setting to open
access to performance monitoring and observability operations for processes
without CAP_PERFMON, CAP_SYS_PTRACE or CAP_SYS_ADMIN Linux capability.
More information can be found at 'Perf events and tool security' document:
https://www.kernel.org/doc/html/latest/admin-guide/perf-security.html
perf_event_paranoid setting is 3:
-1: Allow use of (almost) all events by all users
Ignore mlock limit after perf_event_mlock_kb without CAP_IPC_LOCK
>= 0: Disallow raw and ftrace function tracepoint access
>= 1: Disallow CPU event access
>= 2: Disallow kernel profiling
To make the adjusted perf_event_paranoid setting permanent preserve it
in /etc/sysctl.conf (e.g. kernel.perf_event_paranoid = <setting>)

跟着报错提示里面提到的文档 Perf events and tool security 看了一圈,大概知道问题出在 perf 的安全措施上。文档里说,随意使用 perf 可能允许人获得其他人正在运行的程序中的数据,不安全。我用的发行版就默认配置成所有人都不能使用 perf 了。

文档给了一种多用户时控制权限,只让特定的人使用 perf 的做法:首先将 /usr/bin/perfsetcap(8) 程序加上 CAP_PERFMON CAP_SYS_PTRACE 两个标签,使 /usr/bin/perf 能够正常使用(没有 CAP_PERFMON 标签的应用程序无法调用 perf_event_open(2) 函数)。接着新建个用户组,仅使在那个组里的用户拥有 /usr/bin/perf 的可执行权限。这样对于一个不允许使用 perf 的人来说,外面偷来的 perf 会因为没有 CAP_PERFMON 而无法使用,自带的 /usr/bin/perf 则没有执行权限。整个设置避免了未经许可的人使用 perf 程序。

因为我的笔记本电脑肯定只有我一个用户,所以我非常暴力地改了一发,在 root 权限下往 /proc/sys/kernel/perf_event_paranoid 文件里写了个 -1。接着在 /etc/sysctl.conf 里加入一行 kernel.perf_event_paranoid = -1

1
2
root@Syameimaru-Aya:~/tmp# echo -1 > /proc/sys/kernel/perf_event_paranoid
root@Syameimaru-Aya:~/tmp#

接着 perf 就可以正常运行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
20:27 Syameimaru-Aya ~/sr/la/hpc/perf
0 cat a.c
#include <stdio.h>

int main(void) {
int i;
for (i = 0; i < 10000000; ++i)
i + i;
return 0;
}
20:27 Syameimaru-Aya ~/sr/la/hpc/perf
0 gcc -O0 a.c && perf record -a ./a.out
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.877 MB perf.data (104 samples) ]

获得炫酷火焰图

中午午睡的时候梦到生成火焰图要用命令 perf script flamegraph。于是试了一下,发现不行。

1
2
3
4
5
6
7
8
9
10
20:29 Syameimaru-Aya ~/sr/la/hpc/perf
0 perf script flamegraph
------------------------------------------------------------
perf_event_attr:
size 128
{ sample_period, sample_freq } 4000

... 超级长的输出 ...

Flame Graph template /usr/share/d3-flame-graph/d3-flamegraph-base.html does not exist. Please install the js-d3-flame-graph (RPM) or libjs-d3-flame-graph (deb) package, specify an existing flame graph template (--template PATH) or another output format (--format FORMAT).

啊报错说缺少包 libjs-d3-flame-graph。太良心了,连缺什么包都给提示好。显得我很笨的样子 :(。

1
2
3
4
5
6
20:33 Syameimaru-Aya ~/sr/la/hpc/perf
0 i libjs-d3-flame-graph
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
E: Unable to locate package libjs-d3-flame-graph

提示说包不存在。用 apt-file 找了下报错信息中提到的关键文件 /usr/share/d3-flame-graph/d3-flamegraph-base.html,发现源里没有这个东西。不过在 pkgs.org 上找了下发现 rpm 的包到是有……怀疑开发都写报错信息的时候只是把红帽系打包的命名习惯改成了 Debian 系的,估计根本就没看有没有这个包吧!

怎么全是 rpm 包

最后用 alien(1p)rpm 转成 deb 装上。成功运行。

超级酷炫的火焰图!

对集群上 df 和 du 命令显示结果不一致的排查记录

对集群上 df 和 du 命令显示结果不一致的排查记录

背景

在集群上跑作业,然后把磁盘空间吃掉了。把占用空间很大的文件删掉后,du /home 命令的结果显示磁盘占用已经回到了正常水平,但是 df -h 显示,/home 所在分区的磁盘占用率还是 100%,也不能新建和修改文件。

猜想

不会是把文件系统弄坏了吧!

仔细想了想好像不可能,因为平时都是用普通用户的身份工作的……不太可能搞出影响文件系统的操作。

有些人的小文件太多,把 inode 给用光了!

看起来有可能,但是回想下很久很久以前自己做赛博仓鼠的时候遇到的问题,就会发现两个问题表现完全不一样。

很久很久以前,赛博鼠鼠 jyi 试图往自己的鼠鼠洞里塞图片,发现没有磁盘空间了!他 df 了下,发现硬盘空间还有很多,但是新建文件就是会出错。他又试了试往已有的文件后面追加写入一些东西,好像可以成功。他觉得非常奇怪,“凭什么磁盘有空间,但是就是不让我放东西呢?”

用一种比较笨蛋的方法来看 ext{2,3,4} 文件系统,就知道文件系统中,一个文件需要 1 个 inode 和许多许多 block。其中,inode 用来存放文件的元数据,block 用来存放文件本身。由于 block 数量一般多于 inode 的数量(block 的数量少于 inode 的数量有啥用啊……),所以可能会出现 inode 耗尽,而 block 有剩余的情况。在这种情况下,无法新建文件,却可以修改文件。

因为赛博鼠鼠 jyi 非常菜,所以他与电脑搏斗了一番后才想起关于 inode 的知识。他 df -i 了一下,发现自己要存放图片的文件系统的 inode 已经用光了。最后他把一些图片打包成 sfs,再挂载到世界树目录树上,从而在原本的文件系统里回收了一些 inode,终于解决了这个问题。

回到集群上来,为什么这个表现和集群上遇到的状况完全不一样呢?因为经过检查发现,集群上显示 df -i 不是 100%,df -h 显示的使用率是 100%;而很久很久以前和自己的电脑搏斗时,df -i 显示的使用率是 100%,df -h 则是比 100 小不少的数字。

这说明集群上很可能不是很多小文件把 inode 用光的问题,更可能是巨大文件很简单地把 block 用光的问题。

有一些邪恶文件藏在了黑暗角落里!

Linux 下面是可以往非空目录上挂载文件系统的,挂载后原目录里有的文件将会被遮盖掉。这些文件显然会被 df 统计,但是不会被 du 统计。

显然,最简单的方法就是把根目录之外的所有目录卸载,然后跑一下 dfdu。然而,现在要操作的是运行中的系统(也许还有同学在上面跑神秘程序,那种中断了会遭遇线下真人快打的),不能这么粗暴地处理……

最后我找了个 tmpfs /run/mnt(因为根目录下没法新建文件夹做挂载点了),然后 mount --bind / /run/mnt。接着进 /run/mnt 一看,发现 dfdu 的结果仍然不同!仍然是 du 很少一点,df 巨大无比的结果。

破案

正在自闭时,突然想起来,好像学文件系统时在懵懵懂懂的时候学到了 Linux 下 inode 结构体里,有关 i_counti_nlink 的知识。其中,i_count 代表当前有多少个文件描述符引用了这个文件,i_nlink 代表这个文件在文件系统里有多少个硬链接。当且仅当 i_counti_nlink 都为零时,这个 inode 和她所持有的 block 才被会释放。有没有这种可能,一个神秘邪恶,吃光了磁盘的巨大文件,它的 i_nlink 是 0 同时 i_count 非零,这样它不会被递归查看文件名的 du 找到,但是能被统计 block 的 df 给检查到呢?

于是使用 lsof | grep deleted 一查,果然有一堆坏比 Perl 程序,打开了巨大文件没关。文件的所有者是我,大小有 780G。考虑到集群上好像只有我写 Perl,所以主谋是谁应该不言自明了……

结局

使用天火圣裁发动了一次牛逼的攻击……其实是 ps -u jyi,把自己所有的进程,不管好比还是坏比都干掉了。然后 df 了几次,看着可用空间逐渐上涨。

问题最终解决了,可喜可贺可喜可贺。另外给好朋友说这个事时,还听说在 “进程打开了文件,但是文件不小心删掉了” 这种情况下,在进程关闭之前,可以去 /proc/X/fd/Y 下面把文件找回来。其中 X 是进程 pid,Y 是软链接,名字就是文件描述符,目标是被打开的文件,用 cat 命令就可以把文件给找回来。利用的也是 inode 释放的机制。

总之,Linux 真神奇啊 :3

shell 初始化

shell 初始化

众所周知,shell 初始化是一坨巨大的不祥之物。但是如果不了解初始化的过程的话,可能会在编写各种 rc、crontab 时被折磨。所以分享让大家试吃一下。

基本概念

login shell

login shell 是个比较古老的概念,指由 logind 验证用户身份后,便提供一个 login shell 供用户工作。这个 shell 的特殊意义在于,它和用户的会话紧紧绑定在一起,在它开始运行前与它结束运行后都会往 /var/log/wtmp 写入用户的登录记录。除了它以外,所有的被用户手动运行的 shell 都被视作普通的应用程序。

因为大家现在都在 tty7 用各种基于 X 的登录管理器,它们验证用户身份后会提供一个桌面环境,所以 login shell 的概念没啥用了。但是它的一些历史遗留问题还是可能给大家带来困惑。

生成一个 login shell 有两种方法:

  1. 在 shell 后面加上 -l 参数,比如 bash -l

比如,这是一个 login shell:

1
2
3
4
03:18 Syameimaru-Aya ~
0 bash -l
03:18 Syameimaru-Aya ~
0 logout

而这不是一个 login shell:

1
2
3
4
5
03:18 Syameimaru-Aya ~
0 bash
03:18 Syameimaru-Aya ~
0 logout
bash: logout: not login shell: use `exit'
  1. 让 shell 的 argv[0]- 开头。

我们在通过 ssh 远程登录,或者从 ttyN 用 logind 登录时都可以获得 login shell。显然 logind 和 ssh 不应当对 shell 的参数做出假设(即不能假设自己即将运行的程序有一个 -l 参数)。所以他们用改 argv[0] 的方式来通知 shell。

sshd 是这么干的的 ssh/session.c

1
2
3
4
5
/*
* If we have no command, execute the shell. In this case, the shell
* name to be passed in argv[0] is preceded by '-' to indicate that
* this is a login shell.
*/

logind 也是这么干的(在 ttyN 里面试试这些东西):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Debian GNU/Linux bookworm/sid Syameimaru-Aya tty2
Syameimaru-Aya login: jyi
Password:
Linux Syameimaru-Aya 5.19.0-2-amd64 #1 SMP PREEMPT_DYNAMIC Debian 5.19.11-1 (2022-09-24) x86_64 GNU/Linux

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Fri Sep 30 03:06:30 CST 2022 on tty2
03:34 Syameimaru-Aya ~/tmp
0 echo $0
-bash

interactive shell

区分 interactive 与 non-interactive 的意义在于,让 shell 在给人类使用时与执行脚本时表现出不同的行为。

要求标准输入和标准输出都指向终端(用 isatty 系统调用确定)。仅在 interactive shell 里面会打印提示符,同时启用行编辑和 job control 特性,对人类十分友好!

这也解释了为啥用 nc -l -p 2333 -e /bin/bash 搞的丐版远程登录非常难用,因为这不是 interactive shell,没有方便的编辑特性。也能解释为啥 echo echo hello | bash 不会输出提示符而是直接输出命令结果,因为这不是 interactive shell,不会输出提示符。

当然,也可以用 -i 选项暴力启动交互模式。

1
2
3
4
5
6
7
8
9
03:42 Syameimaru-Aya ~
0 echo echo hello | bash -i
03:43 Syameimaru-Aya ~
0 echo hello
hello
03:43 Syameimaru-Aya ~
0 exit
03:43 Syameimaru-Aya ~
0

(为了分辨命令的输出,输出部分往右缩进了一些)。

此时 shell 会像正常一样输出提示符,读取输出并且执行。

不同的组合读取配置文件的区别

以 bash 为例:

login:首先是 /etc/profile,接着是 /etc/profile.d/*,最后是 ~/.bash_profile ~/.bash_login ~/.profile 三者按顺序检查,读取第一个可读的文件。(注意没有 ~/.bashrc)在 shell 退出时,还会读取 ~/.bash_logout
non-login:不会读取任何配置。
interactive:依次读取 /etc/bash.bashrc ~/.bashrc
non-interactive:不会读取任何配置。

一般情况下,shell 启动时读取的配置是上列之一,并且 login 优先于 interactive。比如,如果 shell 以 login + interactive 的方式启动,则会读取 /etc/profile/etc/profile.d/*~/.bash_profile~/.bash_login~/.profile,但是并不会考虑 /etc/bash.bashrc~/.bashrc,即使这是一个 interactive shell。

有个仅用于 bash 的例外是,当其以 non-login 且 non-interactive 的方式启动时,它会检查名为 BASH_ENV 的环境变量。如果变量值所表示的文件存在,则会读取该文件作为配置。

这套神秘机制造成的麻烦

~/.bashrc~/.bash_profile 之间的互动

  1. login shell 不会读取 ~/.bashrc,这使得 login shell 不能读取一些配置,很难用。为了解决这个问题,人们决定在 ~/.bash_profile 里引用 ~/.bashrc
  2. 一些人会在 ~/.bashrc 里对命令加入一些保护措施,比如 alias rm='rm -I --preseve-root',使得在同时删除三个以上文件时需要确认才能删除,另外,有些人可能会拿垃圾桶代替 rm
  3. 一些脚本会以 login 的方式执行(通常是运行得非常早的脚本,甚至不能从父进程里继承 PATH),以保证自己能读取 /etc/profile,得到正确的环境变量。

当这三点齐聚时,会发生什么呢?

  1. 安装软件包时,本来应该被彻底删除的临时文件被不明不白地扔进了垃圾箱里,占用不知道多少的空间。
  2. 即使用了 -y 参数来避免安装时的用户输出,仍然有可能因为 rm -I 等命令而需要等待输入。这对一些后台执行的脚本(比如定时自动更新)来说是非常坏的,因为很可能没有用户会来输入一个 y

为了解决这个问题,只好在 ~/.bashrc 前面加上这一句看起来很像魔法咒语的指令:

1
[[ $- == *i* ]] || return

……使得 bash 在读取 ~/.bashrc 当配置文件时,如果是非交互终端则立即停止读取。

crond 找不到命令,但是自己在终端里操作时又有

为了方便描述,把这个命令叫作 lolcat

  1. 有些人喜欢把 lolcat 放在 ~/.local/bin/
  2. 有些人写 crontab 时喜欢用 lolcat(?)
  3. 他在 ~/.bashrc 里面将 ~/.local/bin/ 加入到 PATH
  4. crond 运行 shell 时为 non-interactive + non-login 模式

会发生什么呢?

  1. 当在终端里试图运行 lolcat 时,因为现有的是 interactive + non-login 模式,所以读取了 ~/.bashrc,正确地设置了路径。
  2. 当在 crond 里运行 lolcat 时,因为是 non-interactive + non-login 模式,没有读取 ~/.bashrcPATH 里没有 ~/.local/bin,找不到 lolcat

所以在写 crontab 时,只好写 bash -lc lolcat

不仅仅是 shell 脚本,C 中的 system()、Python 的 os.system() 以及更多类似物都会遇到这个问题。在终端里直接执行时,会从 bash 中继承 PATH,从而表现出正确的行为。而如果在 crond 内执行,则会出现找不到命令的问题。

更多例子

暂时没遇到……

urxvt 跑得比 alacritty 还快,为什么呢?

urxvt 跑得比 alacritty 还快,为什么呢?

答案

答案是 urxvt 并没有老老实实地绘制其内程序输出的每一个字符,而是通过一些非常取巧的方法,减少了屏幕渲染的内容数量。

具体来说,是用了以下两个优化:

  • jump scroll:如果短时间内需要渲染很多行,那么 urxvt 仅会在收到的行能充满一屏时尝试刷新。
  • skip scroll:在 jump scroll 的基础上,限制刷新率为 60 Hz。

开启这两个优化之后,urxvt 收到的很多内容实际上都被直接扔进历史记录里了,根本没在屏幕上出现过。同时,因为人的眼睛是非常低速的设备,所以即使这些内容没有在屏幕上出现,也不会影响使用体验。

如果禁用掉这些小优化,urxvt 的速度大概仅是 alacritty 的 1/2 到 1/3。

alacritty 与 urxvt 的简介

urxvt 本身是个二十多年前的老东西,使用了很多奇怪的 X 特性。配置文件和 xterm 一样非常奇怪,可能是 Xorg 给世界留下的遗产之一……使用 C 和 C++ 编写,用 Perl 扩展。rxvt 的可扩展性很强,对标准支持也很好,各种 corner case 处理相对比较完善。

alacritty 是个很新的项目,号称要成为最快的终端。使用超级炒作语言 rust 开发,并且实现了 GPU 加速。他们一度声称自己是 “Fastest Terminal Emulator in Existence(现存最快终端)”。但是在 2020 年末的 一次提交 中不知道为什么他们换了说法,甚至连大家炒作时最爱的 “Blazing Fast” 也干没了。可能是开发者开发地表最速终端的梦想在现实里撞车了。非常快乐,大家快去围观。总之,相比项目早期的自述,现在的自述温和了很多。

两个都是非常好的终端。我之前是在 Windows 下用 alacritty,在 Linux 下用 urxvt。

为什么需要关注终端速度

……其实意义也不是很大,因为大家在输出内容太长的时候都会 | less 一下,用 pager 分页来看,终端速度对使用体验的影响很小。

但是既然速度是个能比的项目,那总会有人抱着一种宝可梦对决的心态来研究两个终端谁快谁慢,这也促进了这篇水帖的诞生!

urxvt 的小优化相关代码

摘自 urxvt 代码仓库 src/command.C 的第 2267 行。

if (ecb_unlikely (ch == C0_LF || str >= eol))
  {
    if (ch == C0_LF)
      nlines++;

    refresh_count++;

    if (!option (Opt_jumpScroll) || refresh_count >= nrow - 1)
      {
        refresh_count = 0;

        if (!option (Opt_skipScroll) || ev_time () > ev::now () + 1. / 60.)
          {
            refreshnow = true;
            ch = NOCHAR;
            break;
          }
      }

大概就是它用一堆错综复杂的条件变量实现了上面提到的小优化,整段代码唯一的注释的是这样的:

/*
 * If there have been a lot of new lines, then update the screen
 * What the heck we'll cheat and only refresh less than every page-full.
 * if skipScroll is enabled.
 */

摆了。这啥 GNU-style 的神秘老代码看得我头疼……

markdown 中使用图片但是不使用图床

markdown 中使用图片但是不使用图床

起因是写博客要插入图片,但是懒得上传图片到图床。经过一番尝试后发现可以把图片 base64 编码后放进 markdown 语法中本应该放图片 url 的位置,直接将图片插进 markdown 文件里。

显然我们需要找出 markdown 中的图片。为了减少图片大小,还需要缩放和压缩。为了偷懒想找找有没有相关的项目可以实现功能。只找到了 markdownImage。但是这个图片压缩好像是调用一些网站的 api 来完成相关功能的,还有免费次数限制,并且并不提供图片缩放功能。感觉和需求出入有点大……

最后我写了个便利脚本来完成这项任务,需要机器上安装了 imagemagick、base64 和 tr。

(这个脚本问题还是比较多,比如没有区分代码块里格式类似图片链接的部分和真正的图片链接,某些情况,比如 markdown 教程估计会锅掉。但是总之还是能用的嘛)

会从标准输入和命令行文件中读取内容,处理后输出到标准输出。(在后面接一个 clip 剪贴板程序就可以直接准备发布到博客园啦)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env perl

use v5.12;

sub process_image
{
$_ = shift;
if (/\.gif$/) {
"data:image/gif;base64," .
qx {
convert -fuzz 15% -layers Optimize \Q$_\E - | base64 | tr -d '\n'
}
} else {
"data:image/jpeg;base64," .
qx {
convert -resize \Q1280x960>\E -strip -quality 75% \Q$_\E jpeg:- | base64 | tr -d '\n'
}
}
}

while (defined(my $line = <>)) {
for ($line =~ /!\[[^\]]*\]\([^)]*\)/g) {
my ($mark, $desc, $file) = /(!\[([^\]]*)\]\(([^)]*)\))/;
$file = process_image $file;
$line =~ s/\Q$mark\E/![$desc]($file)/;
}
print $line;
}

使用 complete-alias 补全 bash 别名的参数

使用 complete-alias 补全 bash 别名的参数

命令别名

众所周知,bash 中有个很方便的功能,使用 alias 命令创建命令别名。比如:

1
2
3
4
5
6
7
8
9
10
11
12
# Git
alias cg='cd `git rev-parse --show-toplevel || echo .`'
alias gaA='git add -A'
alias gad='git add'
alias gbc='git branch'
alias gcm='git commit'
alias gco='git checkout'
alias gst='git status'
alias gcl='git clone'
alias glg='git log --graph'
alias gmg='git merge'
alias gdf='git diff'

这样,如果我们输入 gcl,bash 就会认为我们输入的是 git clone。极大地减少了输入字母的数量。

命令参数补全

bash 还有另一个强大的功能,命令参数补全。这个命令参数补全不仅仅是补全当前目录下的文件,而是根据当前已经输入的命令和参数,猜测补全下一个参数。一般来说发行版都会提供大量写好的补全脚本,可以直接使用。

以 Debian 为例,安装 bash-completion 软件包后,在 ~/.bashrc 中加上 source /etc/bash_completion。接着输入命令,连续按下两下 tab 键就可以触发补全功能(按下 tab 键的地方在下面用 <TAB> 表示:

1
2
3
4
% 19:50:08 (master) ~/sr/md/bl/note/complete-alias
0 ls --h<TAB><TAB>
--help --hide-control-chars --hyperlink
--hide= --human-readable

虽然说没有 zsh 的好用就是啦。

但是有一个小问题

bash 的命令参数补全是根据命令名来确定的,举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_id()
{
local cur prev words cword
_init_completion || return

if [[ $cur == -* ]]; then
local opts=$(_parse_help "$1")
[[ $opts ]] || opts="-G -g -u" # POSIX fallback
COMPREPLY=($(compgen -W "$opts" -- "$cur"))
else
COMPREPLY=($(compgen -u "$cur"))
fi
} &&
complete -F _id id

这是从 /usr/share/bash-completion/completions/id 里面摘抄的补全相关代码。可以看到,代码里先实现了 shell 函数 _id,再用 complete -F _id id 来把 id 命令相关的补全和 _id 绑定在一起。即需要补全 id 命令的参数时,会用某种方式调用 _id 函数。

这样确实可以处理很多情况,但是对别名无效。比如我们运行 alias gco='git checkout',把 gco 作为 git checkout 的别名。当我们输入 gco 再按 tab 键时,因为没有绑定 gco 相关的补全函数,所以 bash 不知道如何补全,只能在后面接上文件名。

我们期待的行为应该是输入 gco 再按 tab 就和输入 git checkout 再按 tab 一样,可以补全出分支名称:

1
2
3
% 20:03:23 (master) ~/sr/md/bl/note/complete-alias
0 git checkout<TAB><TAB>
HEAD linux-csharp-build master ORIG_HEAD

小问题解决了

之前肯定也有人遇到过一样的问题,并且造了相关的轮子。这儿有一个好用的:complete-alias

我们只要把仓库里面 complete_alias 文件中的内容复制下来,贴到 ~/.bashrc 尾巴上(有 1000 多行,有点野蛮。讲究的人可以把它放到某个目录里然后 .bashrc 里面用 source 命令处理?),再把最后一行 #complete -F _complete_alias "${!BASH_ALIASES[@]}" 前面的井号 # 删掉就算配置完成。重新启动 bash 即可使用。

总而言之挺开箱即用的,配置不费劲。

效果:

1
2
3
% 20:14:27 (master) ~/sr/md/bl/note/complete-alias
0 gco<TAB><TAB>
HEAD linux-csharp-build master ORIG_HEAD

用盲文字符来在终端画黑白图像

用盲文字符来在终端画黑白图像

食用提示

如果这篇文章在您的设备上显示很多方框,或许是字体出了问题。请确保自己使用的字体可以正常显示盲文。

在我的设备上,无论怎么操作都无法使 urxvt (rxvt , xterm ) 表现出我想要的样子。因此在不建议您进行实验时使用 urxvt (rxvt , xterm ) 。

想法来源

有一天发现盲文就是一堆像素点,就想着用盲文文字在终端画图。

实现效果

show1.png
show2.png

分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
⠀⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏
⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟
⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯
⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿
⡀⡁⡂⡃⡄⡅⡆⡇⡈⡉⡊⡋⡌⡍⡎⡏
⡐⡑⡒⡓⡔⡕⡖⡗⡘⡙⡚⡛⡜⡝⡞⡟
⡠⡡⡢⡣⡤⡥⡦⡧⡨⡩⡪⡫⡬⡭⡮⡯
⡰⡱⡲⡳⡴⡵⡶⡷⡸⡹⡺⡻⡼⡽⡾⡿
⢀⢁⢂⢃⢄⢅⢆⢇⢈⢉⢊⢋⢌⢍⢎⢏
⢐⢑⢒⢓⢔⢕⢖⢗⢘⢙⢚⢛⢜⢝⢞⢟
⢠⢡⢢⢣⢤⢥⢦⢧⢨⢩⢪⢫⢬⢭⢮⢯
⢰⢱⢲⢳⢴⢵⢶⢷⢸⢹⢺⢻⢼⢽⢾⢿
⣀⣁⣂⣃⣄⣅⣆⣇⣈⣉⣊⣋⣌⣍⣎⣏
⣐⣑⣒⣓⣔⣕⣖⣗⣘⣙⣚⣛⣜⣝⣞⣟
⣠⣡⣢⣣⣤⣥⣦⣧⣨⣩⣪⣫⣬⣭⣮⣯
⣰⣱⣲⣳⣴⣵⣶⣷⣸⣹⣺⣻⣼⣽⣾⣿

这是 UTF-8 中的盲文字符。共有 256 个。每个盲文字符都由数个点组成。点最多的盲文字符(右下角)有 8 个点,它看起来像个实心黑框框;点最少的盲文字符有 0 个点(左上角),虽然它看上去像个空格,但它真的和空格不是一个东西。

稍微观察可以发现,一个盲文字符可以当成 4x2 的小形位图使用,如果能够良好组织,使盲文字符按某种方式排列,就可以拼出大一些的位图。

不同的 4x2 位图共有 2^8 = 256 个,而不同的盲文字符正好也有 256 个。这意味着盲文字符和 4x2 的位图之间有着一一对应的关系。为了方便盲文与位图的与相转化,我们需要设计一种编码方案。

上面列出的表显然是经过良好组织的,可以发现盲文字符的排布很有规律。找规律的过程略去不提,这里仅说编码方案。经过以下操作后,可以保证盲文字符和其对应的 4x2 位图有相同的编号:

盲文:将上表中的盲文从上到下,从左到右依次编号 0 到 255 。

位图:考虑搞一张权值表:

1
2
3
4
1  8
2 16
4 32
64 128

将表中所有对应位图黑色位置的权值加起来,得到的和即为位图的编号。

例如,字符 “⢫” ,其编号为 171 ,其位图为:

1
2
3
4
1 1
1 0
0 1
0 1

和权值表 py 后得到 1 + 8 + 2 + 32 + 128 = 171 ,和期待结果一致。

使用这个方法,可以将 4x2 的小位图和它所对应的盲文字符的编号对应起来。于是,我们就可以在终端画图了。

实现

首先对盲文字符打表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const char *magic_table[] = {
"⠀", "⠁", "⠂", "⠃", "⠄", "⠅", "⠆", "⠇", "⠈", "⠉", "⠊", "⠋", "⠌", "⠍", "⠎", "⠏",
"⠐", "⠑", "⠒", "⠓", "⠔", "⠕", "⠖", "⠗", "⠘", "⠙", "⠚", "⠛", "⠜", "⠝", "⠞", "⠟",
"⠠", "⠡", "⠢", "⠣", "⠤", "⠥", "⠦", "⠧", "⠨", "⠩", "⠪", "⠫", "⠬", "⠭", "⠮", "⠯",
"⠰", "⠱", "⠲", "⠳", "⠴", "⠵", "⠶", "⠷", "⠸", "⠹", "⠺", "⠻", "⠼", "⠽", "⠾", "⠿",

"⡀", "⡁", "⡂", "⡃", "⡄", "⡅", "⡆", "⡇", "⡈", "⡉", "⡊", "⡋", "⡌", "⡍", "⡎", "⡏",
"⡐", "⡑", "⡒", "⡓", "⡔", "⡕", "⡖", "⡗", "⡘", "⡙", "⡚", "⡛", "⡜", "⡝", "⡞", "⡟",
"⡠", "⡡", "⡢", "⡣", "⡤", "⡥", "⡦", "⡧", "⡨", "⡩", "⡪", "⡫", "⡬", "⡭", "⡮", "⡯",
"⡰", "⡱", "⡲", "⡳", "⡴", "⡵", "⡶", "⡷", "⡸", "⡹", "⡺", "⡻", "⡼", "⡽", "⡾", "⡿",

"⢀", "⢁", "⢂", "⢃", "⢄", "⢅", "⢆", "⢇", "⢈", "⢉", "⢊", "⢋", "⢌", "⢍", "⢎", "⢏",
"⢐", "⢑", "⢒", "⢓", "⢔", "⢕", "⢖", "⢗", "⢘", "⢙", "⢚", "⢛", "⢜", "⢝", "⢞", "⢟",
"⢠", "⢡", "⢢", "⢣", "⢤", "⢥", "⢦", "⢧", "⢨", "⢩", "⢪", "⢫", "⢬", "⢭", "⢮", "⢯",
"⢰", "⢱", "⢲", "⢳", "⢴", "⢵", "⢶", "⢷", "⢸", "⢹", "⢺", "⢻", "⢼", "⢽", "⢾", "⢿",

"⣀", "⣁", "⣂", "⣃", "⣄", "⣅", "⣆", "⣇", "⣈", "⣉", "⣊", "⣋", "⣌", "⣍", "⣎", "⣏",
"⣐", "⣑", "⣒", "⣓", "⣔", "⣕", "⣖", "⣗", "⣘", "⣙", "⣚", "⣛", "⣜", "⣝", "⣞", "⣟",
"⣠", "⣡", "⣢", "⣣", "⣤", "⣥", "⣦", "⣧", "⣨", "⣩", "⣪", "⣫", "⣬", "⣭", "⣮", "⣯",
"⣰", "⣱", "⣲", "⣳", "⣴", "⣵", "⣶", "⣷", "⣸", "⣹", "⣺", "⣻", "⣼", "⣽", "⣾", "⣿"
};

接着实现 canvas 结构体。这里用 unsigned char 数组当成 bool 数组使用。日后优化时,可以用 bitmap 节省空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef struct canvas {
int width;
int height;
void *buf;
} canvas;

int canvas_init(canvas *p, int width, int height)
{
width = ((width - 1) / 2 + 1) * 2;
height = ((height - 1) / 4 + 1) * 4;
p->width = width;
p->height = height;
p->buf = malloc(sizeof(unsigned char) * width * height);
if (p->buf == NULL)
return 1;
return 0;
}

void canvas_clear(canvas p)
{
free(p.buf);
}

实现画像素点和打印功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void canvas_draw(canvas p, int x, int y)
{
((unsigned char (*)[p.width])p.buf)[y][x] = 1;
}

void canvas_erase(canvas p, int x, int y)
{
((unsigned char (*)[p.width])p.buf)[y][x] = 0;
}

int canvas_test(canvas p, int x, int y)
{
return ((unsigned char (*)[p.width])p.buf)[y][x];
}

void canvas_print(canvas p)
{
int i, j, k, l;
for (i = p.height; i > 0; i -= 4) {
for (j = 0; j < p.width; j += 2) {
int id = 0;
for (l = 1; l >= 0; --l)
for (k = 3; k >= 1; --k)
id = (id << 1) | canvas_test(p, j + l, i - k);
if (canvas_test(p, j, i - 4))
id += 64;
if (canvas_test(p, j + 1, i - 4))
id += 128;
printf("%s", magic_table[id]);
}
putchar('\n');
}
}

实现完成。以下是函数功能与参数说明:

1
2
3
4
5
6
int canvas_init(canvas *p, int width, int height); 将 p 初始化为宽 width 高 height 的画布
void canvas_clear(canvas p); 销毁画布 p
void canvas_draw(canvas p, int x, int y); 在 p 的 (x, y) 位置画上一个像素点
void canvas_erase(canvas p, int x, int y); 擦除 p 中 (x, y) 位置上的像素点
int canvas_test(canvas p, int x, int y); 返回 p 中 (x, y) 上是否已经画过
void canvas_print(canvas p); 打印 p

实现示例中的效果

用 ImageMagick 的 convert 命令将图片文件转为只有 2 种颜色的 xpm 文件,写个傻瓜 xpm 解析器,配合上面的代码简单处理即可得到示例中的效果。傻瓜解析器的代码见:doxpm.c

在本机上,实现示例效果的命令为:

1
2
3
4
5
$ convert -colors 2 sample.png a.xpm
$ gcc doxpm.c -o doxpm
$ ./doxpm
$ # 如果需要彩色的话:
$ ./doxpm | lolcat

代码仅被用来说明想法,并没有想写成一个可用的库。所以码风略快糙猛请多包涵。

感谢 zrz_orz 同学教我在洛谷日报上投稿,并提出大量修改意见。

hyperfine 使用指南

hyperfine 使用指南

简介

测量程序运行耗时是一个常见的需求。

我们经常会调整自己编写的程序,来给程序加速。但是自己提出的加速计划,不一定会被
机器认可。比如,你觉得 ++ii++ 更快并且花了两天时间把程序里所有的后缀全
改成了前缀,但是机器不管,她编译的时候直接把你的写法给扬掉了。这个时候再在 git
的提交信息里写 perf: 优化 XX 部分性能 就会显得非常滑稽。所以,我们经常需要对
程序性能测试来保证自己的优化是有效的。对程序性能测试的最常用的方法就是计时。

小时候幼儿园的老师经常教育我们,在 bash 里面用 time 的命令就可以测量程序
运行的时间。这也是大家最常用的方法。但是我们都知道,time 是一个非常粗糙的工
具。用它测量程序性能时,总会遇到这么几个问题:

  • 测量出来的时间真的是准的吗?会不会受到系统波动的影响?
  • 测量出来的时间有多可靠?该怎么知道测量误差?
  • 我能比较轻松地对比两个或多个程序的性能吗?

我们可以通过写一堆土制脚本来解决上述问题,但是与其费心写功能不全、漏洞百出的脚
本,还不如直接使用已有的趁手工具。

hyperfine 就是一个优秀的性能测试工具。

优势

根据 hyperfine 自己的 介绍 ,hyperfine 拥有如下功能:

  • 多次测量并统计均值方差
  • 支持任意 shell 命令
  • 进度条和预估剩余时间
  • 预热:正式测试之前先运行几次
  • 测试之前执行指定命令(可用于清除缓存)
  • 自动发现 cache 影响和系统性能波动影响
  • 多种输出格式,支持 CSV、JSON、Markdown 等等
  • 跨平台

(注:hyperfine 的介绍是有 中文翻译 的,但是我看的时候它略微有些过时了。
希望有好心人来更新一下翻译)

它的使用截图如下:

hyperfine

个人评测:life-changing 的好东西,我现在没有 hyperfine 都不会测程序了。

基本使用

hyperfine 的使用方式非常符合直觉,命令行结构和选项设计得很好。

安装

hyperfine 是用 rust 写的(不打算去学一下?)。如果机器上有 rust 开发环境,
直接运行 cargo install hyperfine 即可完成安装。cargo 是 rust 的编译系统
和依赖管理工具。

如果机器上没有 rust 开发环境,可以求助你的包管理器,或者从
hyperfine 在 Github 上的发布页面 中,下载与自己的机器架构对应的二进制
文件。

测试单个程序

命令:

hyperfine 'hexdump file'

结果:

11:17 jyi-station ~/tmp/bgifile
0 hyperfine 'hexdump test13.c'
Benchmark 1: hexdump test13.c
  Time (mean ± σ):     385.0 ms ±   5.1 ms    [User: 383.0 ms, System: 2.1 ms]
  Range (min … max):   381.6 ms … 398.9 ms    10 runs

从结果可以看出,hyperfine 把程序运行了 10 次。测量出来平均耗时是 385 ms,误差
是 5.1 ms。运行的时候,hyperfine 把程序的所有输出重定向到了 /dev/null 里,所
以终端上没有多余的内容。

你看,我几乎什么都没做,只是把命令提供给 hyperfine,她就自动帮忙把所有东西都测
好了!

我们甚至无需检查误差是否过大,因为 hyperfine 会自动检测误差过大的情况,并且根
据程序运行时间的特征来猜测可能发生了什么问题,并给出一些建议。非常贴心。后面会
详细讨论这些细节。

对比测试多个程序

命令:

hyperfine 'hexdump test13.c' 'xxd test13.c' 'xxd test14.c'

结果:

11:24 jyi-station ~/tmp/bgifile
0 hyperfine 'hexdump test13.c' 'xxd test13.c' 'xxd test14.c'
Benchmark 1: hexdump test13.c
  Time (mean ± σ):     383.6 ms ±   1.9 ms    [User: 381.8 ms, System: 1.6 ms]
  Range (min … max):   381.6 ms … 387.7 ms    10 runs

Benchmark 2: xxd test13.c
  Time (mean ± σ):      90.2 ms ±   1.0 ms    [User: 88.4 ms, System: 1.9 ms]
  Range (min … max):    88.7 ms …  93.4 ms    32 runs

Benchmark 3: xxd test14.c
  Time (mean ± σ):     180.2 ms ±   2.8 ms    [User: 176.8 ms, System: 3.2 ms]
  Range (min … max):   177.1 ms … 186.6 ms    16 runs

Summary
  'xxd test13.c' ran
    2.00 ± 0.04 times faster than 'xxd test14.c'
    4.25 ± 0.05 times faster than 'hexdump test13.c'

在这个例子里,我们给了 hyperfine 三个参数,让她测量三个程序的耗时。hyperfine
首先输出了三个程序各自的运行结果,这部分和测试单个程序时的结果差不多。但是在报
告的最后,hyperfine 还额外给出了一些信息。她指出了跑的最快的程序(港记程序 :P)
,并且显示了其相对其他程序的加速比和误差。

我们一般测试程序时只需要关注最后的 “Summary” 一栏,知道哪个更快、快多少就可以
了。前面几行是和别人吵架时,给他们看测试结果让他们闭嘴时用的。

运行原理

对每个程序,hyperfine 会把它运行 10 次(运行次数有选项可以配置)。hyperfine 会
对运行时间计时,并且求出均值和标准差。

每次运行的时候,hyperfine 会运行一个 shell 来执行这些程序。比如,假定程序
sleep 1,那么 hyperfine 实际运行的是 sh -c 'sleep 1'。这种用 shell 来运
行程序的行为,会导致程序运行时间测量结果偏大;但是如果不用 shell 来运行程序,
大家平时习惯的 ~/*.txt 这些便利缩写就不能用了,非常麻烦。

总之,这种使用 shell 来执行参数的设计,算是便利与准确之间的一种折衷。

为了使测量结果更精确,你可以手动禁止 hyperfine 使用 shell 来执行程序的行为。
hyperfine 本身也会检测 shell 对测量结果的影响,并且在她觉得 shell 对测量结果的
影响已经大到不可忽略时提出警告。判定规则与细节将在之后描述。

使用进阶

现在,你已经基本学会用 hyperfine 了!让我们来看看一些更好玩的东西吧。

测试 IO 密集型程序

假设我们要运行一个大量读写磁盘文件的程序 10 次,我们会发现什么怪现象呢?我们
会发现,第一次或前几次运行所花费的时间会显著大于后面几次。这是由于 Linux 系统
有 Page Cache 的机制,它会尽可能努力地把最近使用过的文件缓存在内存里。

在第一次运行的时候,程序试图读文件。操作系统发现内存里没有相关文件,只好老老实
实地从磁盘上把文件读出来再交给程序。但是紧接着程序运行第二三四次,程序试图读文
件时,操作系统发现文件刚刚才被读过,还被缓存在内存里,于是直接把内存中的内容交
给程序,直接省略掉了读盘的过程。众所周知,内存的读写速度一般远大于硬盘。这导致
了第二三四次运行程序时,程序用时会显著少于第一次。

类似的情况还会出现在很多具有缓存机制的系统(没有特指操作系统!)里。在对于这些
系统打交道的程序计时时,我们需要给 hyperfine 加一些参数。

预热

我们可以使用 --warmup N 的参数让程序被真正计时之前,先运行 N 次,其中 N 是一
个整数。比如,hyperfine --warmup 2 sleep 3 这个命令实际上会运行 sleep 3
个命令 12 次,其中最后 10 次会被计时。

这种方式有利于将程序需要用到的东西提前装到缓存里。可以测量程序在缓存工作良好时
的运行效率。

提前执行指令

我们可以使用 --prepare X 的参数让 hyperfine 每次运行程序之前,先运行一下 X,
其中 X 是一条 shell 命令。比如,
hyperfine --prepare 'echo 3 | sudo tee /proc/sys/vm/drop_caches' sleep 3
这个命令,会运行 sleep 3 10 次,但是每次运行前,会运行
echo 3 | sudo tee /proc/sys/vm/drop_caches 一次,来清除 Linux 的 Page Cache。

这种方式直接让缓存没用了。可以测量程序冷启动的速度。

测试运行时间过短的程序

之前说到,hyperfine 会用一个 shell 来执行待计时的程序。但是如果程序跑得很快,
导致 shell 启动、解析、执行的时间已经占总用时不小的一部分了,那么测量误差就会
变得不可接受。

这个时候我们就可以使用 -N 参数来制止 hyperfine 使用 shell。此时,她会用一个
内置的简陋的解析器来把命令的可执行文件和参数给分开。这个简陋的解析器主要使用
空白字符来分割参数,但是也支持基础的转义字符和引号。

比如:

hyperfine -N 'touch x'

不知道自己的程序属于哪种类型?

有笨比……

如果你不知道你的程序要跑多久,也不知道它是不是要用到某种缓存系统,直接把它当成
纯计算的程序来测就行了。hyperfine 会在发现不对劲时来提醒你。

下面是几个例子:

奇怪的测量结果

20:21 jyi-station ~/tmp/bgifile
0 hyperfine 'cat test18.c'
Benchmark 1: cat test18.c
  Time (mean ± σ):      16.9 ms ±   1.0 ms    [User: 1.1 ms, System: 15.9 ms]
  Range (min … max):    15.7 ms …  22.1 ms    154 runs

  Warning: Statistical outliers were detected. Consider re-running this
  benchmark on a quiet system without any interferences from other programs.
  It might help to use the '--warmup' or '--prepare' options.

hyperfine 发现测试的时候,有些数据与别的明显不在一个等级。所以她警告你并且建议
你在系统闲的时候重跑。

初次测量很慢

20:21 jyi-station ~/tmp/bgifile
0 hyperfine 'cat test19.c'
Benchmark 1: cat test19.c
  Time (mean ± σ):      34.3 ms ±  14.1 ms    [User: 2.2 ms, System: 31.8 ms]
  Range (min … max):    30.4 ms … 105.4 ms    28 runs

  Warning: The first benchmarking run for this command was significantly slower
  than the rest (105.4 ms). This could be caused by (filesystem) caches that
  were not filled until after the first run. You should consider using the
  '--warmup' option to fill those caches before the actual benchmark.
  Alternatively, use the '--prepare' option to clear the caches before each
  timing run.

这次 hyperfine 不仅发现数据异常,还发现是第一次跑的时候数据异常。于是她猜测是
某种神秘的缓存系统起了作用,并且建议你用 --warmup 参数或 --prepare 参数来
消除缓存的影响。

程序跑得很快

20:21 jyi-station ~/tmp/bgifile
0 hyperfine 'cat test1.c'
Benchmark 1: cat test1.c
  Time (mean ± σ):       0.9 ms ±   0.1 ms    [User: 0.7 ms, System: 0.4 ms]
  Range (min … max):     0.7 ms …   1.3 ms    1340 runs

  Warning: Command took less than 5 ms to complete. Note that the results
  might be inaccurate because hyperfine can not calibrate the shell startup
  time much more precise than this limit. You can try to use the
  `-N`/`--shell=none` option to disable the shell completely.

这次,hyperfine 发现程序跑得很快,误差会比较大,并且建议你用 -N 参数来直接运行程序,
绕过启动 shell 的步骤。

额外的功能

除此之外,hyperfine 还有一些别的功能,比如参数化测试之类的东西。不过我感觉要参
数化的话与其用这一坨命令行参数,不如去写一个小小脚本……所以我没用过。如果有人感
兴趣的话可以试试。

改变输出格式以便与其他软件协作

hyperfine 的命令行界面很好看,有进度条还有颜色。在除命令行之外的地方,她也做得
很好。比如,hyperfine 可以直接用 --export-markdown 参数生成 markdown 表格,
接着你就可以直接把结果插进 README 里面。她还可以导出 json 格式的测试结果,方便
之后再用脚本处理,做些可视化什么的(hyperfine 的仓库就附带了许多可视化脚本,很
好玩)。

总结

测量程序性能的方式有很多。相比那些在函数调用上插桩(gprof)或读 PMC 寄存器
(perf)的东西来说,单纯的计时也许太简陋了一些。但是第一次参观 profiler,却并
不觉得震撼。因为我早已遇见,独属于我的 benchmarking tool。初遇你的那天起,齿轮
便开始转动,却无法阻止丧失的预感。尽管已经拥有了很多,但让我们再多加一个吧。
可以给我最后一个加速比吗?我不愿遗忘

一些能够节省按键次数的 bash 配置

一些能够节省按键次数的 bash 配置

众所周知,敲击键盘的同时,人的手指会经历一系列的磨损。长此以往,手指就会变短。为了保护手指,使用下面的 bash 配置,成为和我一样能少按键盘就少按键盘的人吧!

给命令起单个字符的别名

对于一些常用的命令,如果没有重复命令,可以给他们起单个字符的别名。

1
2
3
4
5
6
7
8
9
alias a='ls -A'
alias g='grep'
alias j='jobs -l'
alias l='ls'
alias o='xdg-open'
alias r='rm'
alias t='task' # taskwarrior: 一个 todo-list 小软件
alias v='vi'
alias -- -='cd -' # 这里的意思是将 - 作为 cd - 的别名

但是这些写法在 xargs 这里出了点小问题:

1
2
3
4
5
% 17:51:28 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 alias x='xargs'
% 17:51:32 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 l|x g hello
xargs: g: No such file or directory

我们的本意是想让它运行

1
ls | xargs grep hello

但由于 g 并不是命令,xargs 报了错。要是我们想让 x 被展开为 xargs 后,其后的 g 继续被展开,我们可以这样写:

1
alias x='xargs ' # 注意,xargs 与第二个单引号之间有一个空格

之后再运行

1
2
3
4
5
6
% 17:58:29 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
1 l
a.md
% 17:58:30 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
1 l|x g '`xargs`'
由于 `g` 并不是命令,xargs 报了错。要是我们想让 `x` 被展开为 `xargs` 后,其后的 `g` 继续被展开,我们可以这样写:

就好了。这是 bash 的小特性,结尾的空格可以让下一个标识符展开(如果是别名的话)。同理,我们对 sudo 也做类似的事情:

1
alias s='sudo '

太方便辣!

此外,单个 % 的作用和 fg 相同,都是让后台进程回到前台。

给有歧义的命令们起一样的名字

我日常使用 findfile 比较频繁,正常人在缩写他们时,都会想到用 f 来作为它们的别名。而如果一个用 f 作了别名,另一个就只能用其他奇奇怪怪的缩写。有没有办法让它们共用一个名字呢?

由于脑机接口尚未开发完成,shell 无法通过魔法装置读取我们的思想,知道我们在运行 f 时究竟是想运行 find,还是 file,我们只能手动实现一个 shell 函数,根据上下文猜测输入时究竟想要什么。

(怎么有种 Perl 猜代码块和匿名哈希的感觉)

这是一个简单的示例,可以根据实际使用情况另作调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# find, file
f()
{
local i
local expect_find=

# 如果发现身处管道之中,stdin 里不是终端,有输入,则猜测想要
# 确定 stdin 中文件的类型
if ! [ -t 0 ]; then
file -

# 如果 stdin 是终端,但是没有参数,猜测是想要递归列出当前目录
# 下的文件,调用 find
elif [ -z "$1" ]; then
find
else

# 如果有参数以连字符(-)打头,则猜测是 find 的参数,
# 比如 -name -type 之类的。
# 如果参数没有以连字符打头的,则猜测是 file 的参数,参数
# 都是文件名
for i in "$@"; do
if [ "${i:0:1}" = '-' ]; then
expect_find=y
break
fi
done

if [ -n "$expect_find" ]; then
find "$@"
else
file "$@"
fi
fi
}

实际使用看起来还不错。

1
2
3
4
5
6
7
8
9
10
11
12
13
% 18:56:38 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 f
.
./a.md
% 18:56:40 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 f < a.md
/dev/stdin: UTF-8 Unicode text
% 18:56:42 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 f -type f
./a.md
% 18:56:45 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 f a.md
a.md: UTF-8 Unicode text

这样基本符合日常使用,无法处理的边边角角的情况打全名也不是不能接受啦。

还有一些类似的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
c()
{
# 复制?还是复制到剪贴板?
if [ -t 0 ] && [ "$#" -ge 2 ]; then
cp "$@"
else
clip "$@"
fi
}

p()
{
# 调用分页器(pager)?还是打印当前目录?
if [ -z "$1" ] && [ -t 0 ]; then
pwd
else
less -F "$@"
fi
}

给小工具更多的默认行为

有时一些操作总是连在一起的,比如新建文件夹然后切换进去,我们可以用这样的神奇函数:

1
2
3
4
5
6
7
8
9
md()
{
if [ -z "$2" ]; then
mkdir "$1" || return
cd "$1"
else
mkdir "$@"
fi
}

或者我们经常将别处的文件移到当前文件夹,使用这个函数,这样我们可以省略最后那个 . 参数。因为奇怪的原因,只有在有且仅有一个参数时才会有这个功能。有多个参数时总会有无法解决的歧义问题。(不过这样已经足够好了)

1
2
3
4
5
6
7
8
9
10
m()
{
if [ -z "$1" ]; then
echo too few arguments
elif [ -z "$2" ]; then
mv "$1" .
else
mv "$@"
fi
}

当然,执行 cd 再执行 ls 应该是某种常规的操作,每年因为这项操作没有优化,无数根手指被磨短。当然可以把 cd 变成 cd && ls,但是我们想到了一种更加酷炫的方法来解决这个问题,放在另一个部分说。

开启大量 shell 内置特性

bash 内置了大量方便的扩展特性,这些特性可以使用 shopt -s <特性名称> 打开。比如:shopt -s autocd

autocd

自动切换目录……意思是假设当前目录下有一个名为 my-doc 的子目录,可以用 my-doc 取代 cd my-doc。这有一个小问题,由于补全时 bash 并不知道想输入的是目录还是指令,指令会和目录一起进入补全列表,又慢又难选。使用 ./my-doc 会好很多。

checkwinsize

在终端窗口变化时重新设置 $LINES$COLUMNS

dotglob

匹配隐藏文件,这个按个人需求而定?我是觉得这个选项很酷所以打开了。

extglob

扩展的匹配,完全没用!真的好难用,试图给通配符加上一些正则表达式的扩展,还没有 find sed grep xargs 香。

failglob

没有匹配时报错而不是将模式作为参数传递给程序。非常有用,能避免一堆奇奇怪怪问题。比如:

开启前:

1
2
3
4
5
% 19:21:49 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 touch *.c # 我要摸摸所有 c 文件
% 19:21:49 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 ls
a.md '*.c' # 啊不好了,他给我新建了一个 ./*.c

开启后:

1
2
3
4
5
6
% 19:23:23 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 touch *.c
-bash: no match: *.c # 没有找到!
% [1] 19:23:28 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 ls
a.md

globstar

** 通配符支持递归进子文件夹的匹配,比如 my/**/file 可以匹配 my/magic/powerful/fancy/file ,可以用来部分代替 find

全自动的 ls

有时我们希望当前目录下文件发生改变,或工作目录发生改变时,自动 ls 一下展示目录现状。

我们很容易写出这样的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 第一次运行,保存工作目录和当前目录内容(的哈希值)
LAST_LS=$(command ls | sum)
LAST_PWD="$PWD"

_prompt_smart_ls()
{
local this_ls
this_ls=$(command ls | sum)
if [ "$LAST_LS" != "$this_ls" ] || [ "$LAST_PWD" != "$PWD" ]; then
LAST_LS="$this_ls"
LAST_PWD="$PWD"
ls
return
fi
}

之后,每调用一次 _prompt_smart_ls,它都会检查工作目录和当前目录内容,如果发现有不一样的地方,就 ls 一次。我们只要想办法每执行一次指令,就调用一次这个函数就行了。

(当然也可以用其他的检查方式,比如使用神奇的守护进程监视文件系统变化,再和 shell 通信,但是其他方法好像都没有每执行完一次指令就检查一次简单有效)

怎么做到每执行一次命令,就调用一次函数呢?

1
PROMPT_COMMAND='_prompt_smart_ls'

使用 bash 魔法变量,bash 会在执行每条命令后自动执行 PROMPT_COMMAND 这个变量里所存的命令。

最后效果:

1
2
3
4
5
6
7
8
9
10
% 20:17:09 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 touch test
a.md test
% 20:17:12 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 rm test
a.md
% 20:17:13 jyi@Syameimaru-Aya ~/s/m/b/n/shell-abbr
0 cd /
bin/ dev/ home/ lib/ lost+found/ mnt/ proc/ run/ srv/ tmp/ var/
boot/ etc/ init* lib64/ media/ opt/ root/ sbin/ sys/ usr/

太炫酷了!

更多的 cd

我们知道设置了 autocd 之后,输入 .. 会自动切换到上级目录……我们可以做得更多!

1
2
alias ...='cd ../..'
alias ....='cd ../../../'

使用外部工具!

仔细想了想,发现平时使用 z.sh 按访问频率自动跳转时,有时会跳转到自己不希望的位置,如果能够选择跳转到哪里就好了。

我们还需要可见的界面!这个想法是从 zsh 的补全里偷来的,感觉可以上下左右选择非常厉害。

所以使用 fzf 配合 z.sh,做出非常友好的跳转方式:

1
2
3
4
5
6
fz()
{
local dir
dir="$(z | sed 's/^[0-9. \t]*//' |fzf -1 -0 --no-sort --tac +m)" && \
cd "$dir" || return 1
}

正确的重新加载配置的方法

修改了 .bashrc 文件,想要试用一番!怎么加载配置文件呢?

source ~/.bashrc:不好,前任配置文件中残留的 alias 尸体、环境变量可能会影响使用,尤其是写错了的情况下……

bash:不好,退出的时候也要连按许多 exit 或者 Ctrl-D

bash; exit:比上一个好,但是会影响 $SHLVL 变量,可能会对一些奇特脚本(比如 debian 11 下的 ~/.bash_logout)造成影响。

exec bash:非常好!用了 exec bash,亩产一千八!

所以这是重新加载配置文件的缩写(reload):

1
alias rl='exec bash'

总结

打键盘是不错,但是也别敲过了头。打键盘打得太多,手指可就被磨短了。

博客园上可用的 markdown 目录生成器

为 markdown 写的文章生成目录,使其在博客园上可用。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use v5.12;
use utf8;
use open ':utf8';
use open ':std', ':utf8';

my @subtitle_number;
say "# 目录";
while (<>) {
next if /^```/ ... /^```/;
if (/^(#+)\s*(.*?)\s*$/) {
my ($level, $title) = (length($1), $2);

my $indent = " " x $level;

my $id = $title;
$id =~ s/[^_[:^punct:]]//g;
$id =~ s/[[:space:]]/-/g;
$id = lc $id;

@subtitle_number = splice @subtitle_number, 0, $level;
$subtitle_number[$level - 1] += 1;
my $subtitle_number = join ".", @subtitle_number;

say "$indent+ $subtitle_number [$title](#$id)";
}
}
say "";

这是一个 Perl 脚本,从 stdin 或者参数中读取文章,输出一份 markdown 代码,是文章的目录。可以直接复制粘贴使用,也可以和其他工具集成使用。

使用示例

使用这样的命令:

1
$ perl toc.pl main.md

可以得到这样的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 目录
+ 1 [完整代码](#完整代码)
+ 2 [使用示例](#使用示例)
+ 3 [原理](#原理)
+ 3.1 [HTML 的链接语法](#html-的链接语法)
+ 3.2 [markdown 列表缩进](#markdown-列表缩进)
+ 4 [代码详解](#代码详解)
+ 4.1 [使用「现代」Perl](#使用现代perl)
+ 4.2 [支持 utf8 编码](#支持-utf8-编码)
+ 4.3 [主循环](#主循环)
+ 4.3.1 [跳过 markdown 的代码片段](#跳过-markdown-的代码片段)
+ 4.3.2 [匹配标题](#匹配标题)
+ 4.3.3 [设置缩进](#设置缩进)
+ 4.3.4 [从标题名字中获得其 id](#从标题名字中获得其-id)
+ 4.3.5 [获取标题的编号](#获取标题的编号)

原理

HTML 的链接语法

在大多数网页上,markdown 的链接语法会被编译成 HTML 的 <a> 标签。通常 <a> 标签会有 href 属性,内容是点击标签时跳转的目的地址。

有些页内元素带有 id 属性,比如这个例子:

1
<h3 id="interactive-shell">interactive shell</h3>

在这个例子里 <h3> 标签有 id 属性,值是 interactive-shell。这个值同样可以用作 <a> 标签的目的地址。

当目的地址是页内元素的 id 时,点击 <a> 标签时便会跳转到该元素的位置。博客园给每个标题都自动分配了一个 id,利用这几点,就可以实现「点击目录项目跳转到对应章节」的功能。

markdown 列表缩进

在 markdown 中,列表以 + -* 开头。如果这些符号前面有空白字符,那么这些空白字符会被当成缩进,最终会体现在列表展示结果上,缩进越多的列表项目展示时会越靠右,缩进相同的列表项目会左对齐。利用这一点,可以实现目录的层次结构。

代码详解

使用「现代」Perl

1
use v5.12;

Perl 是个老古董语言,为了保持兼容性,有许多好玩/有用的特性默认没有打开。不过我们可以使用 use vX.YY 的 pragma 来指定自己想使用的 Perl 的版本号,从而开启这些好玩的特性。

支持 utf8 编码

1
2
3
use utf8;
use open ':utf8';
use open ':std', ':utf8';

同上,因为 Perl 是个老古董语言,所以默认全世界都用 ASCII 编码。我们要开启它对 utf8 的支持。

这里第一行是让 Perl 用 utf8 的方式来解释这份源代码(有点像 python2 里面的 # -*- coding: utf-8 -*- 的 pragma)。

第二行是让 Perl 读所有文件时,读后解码 utf8;写所有文件时,写前编码 utf8。Perl 中为了方便数据处理,存在 IO Layer 的概念。layer 可以看做数据的转换器,数据在进行输入/输出时,会经过这些 layer 逐层处理。常用的 layer 有 :crlf(读时将 CR-LF 序列转换成 CR,写时反过来,用来对付 Windows 系统)和 :encoding(用来编解码文件)。还有些邪恶的 layer 可以实现自动压缩解压、base16 编码等功能。所以有时遇到输出到 stdout 和输出到文件中,内容不一致的情况,可以检查一下是不是用的 layer 不同造成的。

第三行是在设置 stdio 的 layer。因为 stdio 在 Perl 程序运行前就已经打开了,所以需要单独设置一下。

主循环

就是那个巨大的 while 循环。它每次会从输入中读取一行数据并放到 $_ 里面,直到读到文件结束。

1
while (<>) {

可以发现我们并没有处理命令行参数,这是因为 <> 这个操作符会替我们完成这项工作。<> 操作符的意思是,如果有命令行参数,那么就把命令行参数当做文件名打开文件,并且将文件内容作为输入;否则就把 stdin 作为输入。每调用一次 <> 操作符会读取一行,返回这一行的内容。如果没有变量来接收 <> 操作符的返回值,那么 <> 操作符会把返回值存在特殊变量 $_ 中。

跳过 markdown 的代码片段

1
next if /^```/ ... /^```/;

这一行用来跳过 markdown 的代码片断。是一种被称为 flip-flop 的语法。上面代码的意思是,「如果在两个代码标记之间,那么执行 next 语句」。大概和下面的东西等价:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 这句在循环外头
my $in_codeblock = 0;

# 这下面的在循环里头
if ($in_codeblock) {
next;
}

if (/^```/ && $in_codeblock == 0) {
$in_codeblock = 1;
}

if (/^```/ && $in_codeblock == 1) {
$in_codeblock = 0;
}

flip-flop 是一种很方便的语法,可以让人少写很多代码。最重要的是不需要对那一堆烦人的标志变量命名了。

匹配标题

用一个正则表达式来匹配标题并且获得需要的信息:

1
2
if (/^(#+)\s*(.*?)\s*$/) {
my ($level, $title) = (length($1), $2);

这个意思是,如果遇到「开头是若干个 #,中间有一堆字符」这种模式,就认为匹配到标题了。$level$title 分别是标题的层级和名称。因为正则表达式在 Perl 中用的特别多,所以直接做进语言里面去了,可以随手写,不需要另外调库。

设置缩进

1
my $indent = "  " x $level;

$level 总是个整数。这里用字符串重复操作符 x,来获得与 $level 成正比的缩进长度。

从标题名字中获得其 id

1
2
3
4
my $id = $title;
$id =~ s/[^_[:^punct:]]//g;
$id =~ s/[[:space:]]/-/g;
$id = lc $id;

博客园会根据标题名称来设置其 HTML 标签的 id。有人托梦告诉我说,id 就是标题去掉所有标点符号但是保留下划线 _,把空白字符换成连字符 -,并且把所有字母变为小写之后的结果。所以用正则表达式写了一个。

获取标题的编号

1
2
3
@subtitle_number = splice @subtitle_number, 0, $level;
$subtitle_number[$level - 1] += 1;
my $subtitle_number = join ".", @subtitle_number;

生成的目录里面会有类似 X.Y.Z.W 这样的标题编号。这一部分代码就用来处理标题编号的生成问题。懒得写了……

You need to set client_id and slot_id to show this AD unit. Please set it in _config.yml.