如果你经常在 Linux 服务器上做事,一定遇到过这种场景:某个重复性文本操作要点几十次甚至上百次,手都酸了,还是要老老实实一批批改。之前我折腾 vim 宏编程时,一直觉得“录制按键再回放”这个能力非常神奇,于是就有了一个大胆的想法:能不能用 vim 宏来跑一遍经典的康威生命游戏(Conway's Game of Life)?
答案是可以,而且效果很有意思。这篇文章就是一份“手元向”的自编解说,我会从 vim 宏编程的基本概念开始,逐步讲解如何把 vim 寄存器宏与简单的 vimscript 函数结合起来,让生命游戏在一个文本缓冲区里一代一代演化下去。顺便也会聊聊传统 vi 环境下的兼容方案。
如果你是 vim 新手,可以把它当作宏编程入门;如果你已经熟悉 vim,可以直接跳到实战案例部分,看代码、照抄、自己跑一轮。
1. 背景与核心概念
1.1 vim 宏编程到底解决什么问题
vim 宏(macro)的本质,就是把你在普通模式、可视模式或命令行模式下输入的一连串按键记录下来,保存到一个寄存器中,之后用一个键就可以重复播放整套操作。
举个例子:你有一批行需要把行首的TODO改成DONE,还要在行尾加一个分号。手动操作会重复很多次,但录一个宏之后,按一次@a就能处理一行,按100@a就能处理 100 行。
宏编程解决的核心痛点,就是重复性文本编辑。它并不是编程语言,但它是 vim 操作体系里的“自动化脚本”,尤其适合:
- 批量给代码加注释或去注释
- 重复性格式化文本
- 多行数据预处理
- 用 vim 本身实现一些小动画、小游戏
宏编程之所以强大,是因为它不只是记录普通模式按键,还能记录:开头的 ex 命令、可视模式操作、跨文件切换等。录制的时候,vim 会把你的按键原封不动塞进寄存器,回放时相当于把这些按键重新按一遍。
1.2 生命游戏规则回顾
生命游戏是英国数学家约翰·康威(John Horton Conway)在 1970 年提出的元胞自动机,不是传统意义的“游戏”,而是一个按规则自动演化的二维格子世界。
每个格子有两个状态:存活(alive)或死亡(dead)。在本文中,我们用#表示存活细胞,用.表示死亡细胞。每一代演化只依赖当前代的状态,规则总共四条:
- 一个存活细胞,如果周围 8 个格子中活细胞数量少于 2 个,它会因为“孤独”而死。
- 一个存活细胞,如果周围活细胞数量是 2 个或 3 个,它继续存活到下一代。
- 一个存活细胞,如果周围活细胞数量超过 3 个,它会因为“拥挤”而死。
- 一个死亡细胞,如果周围恰好有 3 个活细胞,它会“繁殖”变成存活状态。
网格边界外的区域,我们统一视为死亡细胞。这样处理后,即使网格边缘的细胞也能正常计算周围邻居数。
生命游戏常常会出现几种经典图案:
- 静物(still life):无论演化多少代都不变。
- 振荡器(oscillator):在一个周期后回到原来的状态。
- 滑翔机(glider):图案会沿着对角线方向不断移动。
这些图案非常适合作为 vim 宏演示的初始数据。
1.3 为什么把 vim 宏和生命游戏放在一起
很多人会觉得,vim 只是一个文本编辑器,怎么能做生命游戏这种偏图形化的东西?
关键在于两点:
第一,生命游戏的演化本质上是对二维字符矩阵做逐代变换。vim 缓冲区天然就是一组文本行,可以把它当成一张字符画布。
第二,vimmacro 的一次录制、多次回放,正好对应生命游戏“一代又一代推进”的过程。我把“演化一代”所需的操作录成一个宏,然后连续回放 100 次,就能看到细胞图案在终端里自己动起来。
不需要图形界面,不需要额外的游戏引擎,只要一个终端、一个 vim,就能在服务器上演示一个生命游戏动画。这也是很多 Linux 老手喜欢做的一种“文本编辑器艺术”。
2. 环境准备与版本说明
2.1 环境与版本
本文示例主要基于 Vim 8.x,理论上 Vim 7.4 之后的大部分版本都能运行,前提是编译时包含了 vimscript 支持。多数 Linux 发行版默认安装的vim都满足条件。
可以用以下命令确认版本:
vim --version如果系统只装了极简版vim-minimal,或者只有传统vi,某些 vimscript 功能可能不可用。本文后面的“vi 兼容方案”会单独说明。
版本需要根据你的实际环境调整,建议直接使用发行版自带的 vim,重点演示配置和操作思路。示例环境可以参考:
- 操作系统:Linux 发行版均可
- 编辑器:Vim 8.x 或更高版本
- Shell:bash 或 zsh
- 不需要额外的第三方库
2.2 让 vim 配置更适合演示
为了让生命游戏在终端里显示得更清楚,建议在~/.vimrc中加入以下几行配置:
set number set nowrap set lazyredraw set t_ut=说明一下:
set number:显示行号,方便观察滑翔机移动。set nowrap:关闭自动换行,避免网格被拆成多行导致错位。set lazyredraw:宏执行过程中不频繁重绘屏幕,减少闪烁。set t_ut=:避免清屏时背景色异常,在部分终端下有奇效。
如果你不想改全局 vimrc,也可以把这几行放进项目目录下的_vimrc或.vimrc。演示完再删掉即可。
2.3 示例文件与网格数据约定
生命游戏的网格需要遵守固定约定,否则脚本无法正确处理:
- 每行长度必须一致,不能有空格,也不能有空白行。
.表示死细胞,#表示活细胞。- 文件内容最好只有网格本身,不要混入注释或其他文本。
这看起来像是在操作一个“人为约束的文本文件”,但正是这种约束,让 vim 的文本处理能力可以直接套用到元胞自动机上。
3. vim 宏编程核心知识点
3.1 寄存器:宏的存储基础
在 vim 中,寄存器(register)是存放文本、按键和命令的容器。宏录制时,按键内容会被暂存到指定寄存器里。
常用字母寄存器有a到z共 26 个。录制宏时用小写字母,比如qa表示录制到寄存器a;如果再用大写QA,表示向寄存器a中追加内容而不是覆盖。
查看寄存器内容:
:reg a这个命令会显示寄存器a中保存的按键序列。调试宏的时候非常方便。
注意:寄存器是 vim 的特性,传统 vi 并没有这套机制,至少没有这么灵活的命名寄存器。
3.2 宏的录制与回放
宏的录制格式很简单:
qa:开始录制到寄存器 a- 执行你需要的操作
q:结束录制@a:回放寄存器 a 中的宏50@a:连续回放 50 次@@:重复上一次执行过的宏
来看一个最简单示例。打开文件:
hello world hello world hello world光标放在第一行行首,执行:
qa 0dw j q意思是:录制宏 a,把光标移到行首,删除第一个单词,然后移到下一行,结束录制。执行@a,第二行的hello被删掉;再按@a,第三行也被处理。
宏录制时要注意一个关键点:vim 记录的是按键操作序列,不是“当前这一行的状态”。所以宏里尽量使用相对移动、查找、文本对象,而不要使用绝对行号,否则回放到后面会跑偏。
3.3 宏里调用 ex 命令
宏不仅可以记录普通模式按键,还能记录:开头的 ex 命令。
例如,录一个宏,把当前文件的foo全部替换成bar:
qa :%s/foo/bar/g q录制过程中,: %s/foo/bar/g会被当成一次完整的命令保存。回放时,vim 会重新执行这个替换命令。
这个特性非常重要,因为生命游戏的演化计算,不能只靠普通模式的字符移动来完成,还需要在宏里调用 vimscript 函数或 ex 命令。我们后面会看到这种组合的威力。
3.4 宏的嵌套与递归
宏里可以调用另一个宏。比如:
qb @a q这样宏 b 会执行宏 a。相当于把多个宏组合成更复杂的自动化流程。
宏也可以递归调用自己,也就是在宏 a 里调用@a。递归宏能实现类似循环的效果,但风险也大:如果缺少终止条件,会进入无限循环。更稳妥的做法是前面加次数上限,例如:
100@a或者在宏里面用寄存器计数,当计数达到阈值时不再调用自己。
在生命游戏演示中,我们不需要递归宏,因为回放次数可以通过100@a这种重复次数来控制。
4. 实战:用 vim 宏驱动生命游戏演化
下面进入核心环节。我们要实现的效果是:打开一个life.txt文件,里面是一块字符网格,运行一个宏后,网格自动按照生命游戏规则演化一代,并且屏幕短暂停顿,接着我们连续回放宏,形成动画。
4.1 准备初始网格
先创建一个文件,命名为life.txt。
................. ....###.......... ....#............ ....#..#......... .................这是一个 5 行 17 列的小网格,里面包含了一个滑翔机图案的初始状态。滑翔机会在网格中向右下方移动,适合观察演化效果。
创建文件后,在 vim 中打开:
vim life.txt确认每行等长,行尾不要有多余空格。后面我们会用 vimscript 函数处理整个文件内容,不规范的空白字符会影响邻居判断。
4.2 编写一次演化函数 LifeStep()
虽然在标题里写的是“vim 宏编程”,但一个单纯的按键宏很难独立完成 8 方向邻居计数和状态判断。工程上更稳妥的做法是:用 vimscript 函数处理计算逻辑,用宏负责驱动重复执行。
把下面的函数放进~/.vimrc,或者在 vim 命令行模式下粘贴执行都可以。我建议放进 vimrc,因为宏回放时随时需要调用它。
" 生命游戏演算函数 " 使用范围调用方式,例如 :%call LifeStep() function! LifeStep() range let l:lines = getline(a:firstline, a:lastline) let l:newlines = [] for l:i in range(0, len(l:lines) - 1) let l:row = l:lines[l:i] let l:newrow = '' for l:j in range(0, len(l:row) - 1) let l:cnt = 0 " 统计周围 8 个方向的活细胞数量 for l:di in [-1, 0, 1] for l:dj in [-1, 0, 1] if l:di == 0 && l:dj == 0 continue endif let l:ni = l:i + l:di let l:nj = l:j + l:dj " 边界外的格子视为死细胞 if l:ni < 0 || l:ni >= len(l:lines) continue endif if l:nj < 0 || l:nj >= len(l:row) continue endif if l:lines[l:ni][l:nj] == '#' let l:cnt = l:cnt + 1 endif endfor endfor " 根据生命游戏规则决定下一代状态 let l:is_alive = (l:lines[l:i][l:j] == '#') if l:is_alive if l:cnt == 2 || l:cnt == 3 let l:newrow = l:newrow . '#' else let l:newrow = l:newrow . '.' endif else if l:cnt == 3 let l:newrow = l:newrow . '#' else let l:newrow = l:newrow . '.' endif endif endfor call add(l:newlines, l:newrow) endfor call setline(a:firstline, l:newlines) endfunction函数的核心流程:
- 通过
getline(a:firstline, a:lastline)取出范围内的所有行。 - 遍历每一行、每一个字符。
- 对每个字符统计周围 8 个格子中
#的数量。 - 根据当前状态和活邻居数量,应用生命游戏规则,生成新的一行。
- 通过
setline(a:firstline, l:newlines)一次性写回新网格。
这个函数使用了range属性,所以可以用范围调用。最简单的调用方式是:
:%call LifeStep()也可以先选中一部分区域,再执行:
:'<,'>call LifeStep()这样只处理选中区域,适合在一个大文件里单独观察某个局部图案。
4.3 录制“演化一代”宏
现在,我们在 vim 中把“演化一代并稍作停顿”录制成宏。
推荐这样录制:
qa :%call LifeStep() :echo 'Generation: ' . g:life_gen :redraw :sleep 200m q不过这里有个问题,g:life_gen需要先初始化。如果你不想维护计数器,也可以直接删掉:echo那一行。为了演示更完整,建议初始化并保留计数。
先初始化计数器:
let g:life_gen = 0然后把LifeStep()函数改一版,内部自动递增计数器:
function! LifeStep() range let g:life_gen = g:life_gen + 1 " 其余代码保持不变 endfunction实现方式可以这样改,在函数开头加一行即可。完整代码不需要重复贴,你只需要在刚才的函数第一行后面加入:
let g:life_gen = g:life_gen + 1然后重新执行source ~/.vimrc或重新粘贴函数。
录制宏的按键序列如下:
qa :%call LifeStep() :echo 'Generation: ' . g:life_gen :redraw :sleep 200m q注意:上面每一行后面的回车都要按下去,vim 才会记录完整的 ex 命令。
各步骤的作用:
:%call LifeStep():对整个文件执行演化函数。:echo 'Generation: ' . g:life_gen:在底部显示当前代数。:redraw:强制重绘屏幕,让新网格显示出来。:sleep 200m:等待 200 毫秒,让动画不要太快。
录完之后,可以按一次@a测试。如果看到网格发生了变化,底部显示Generation: 1,说明宏录制成功。
4.4 连续回放宏:100 代动画
测试成功后,把光标移到网格顶部,确保观察起始位置一致:
gg然后连续回放宏:
100@a此时 vim 会像放电影一样,每隔 200 毫秒演化一代,底部显示代数,网格内容不断更新。
如果中间想停下来,按Ctrl+C即可中断宏回放。
以滑翔机初始状态为例,你会看到#组成的图案会逐渐向右下方移动,并在边界处慢慢消失,因为边界外的细胞被当成死细胞处理。
4.5 效果验证与手动步进
宏回放停止后,有时你想确认当前到底演化到了哪一代,可以直接查看g:life_gen:
:echo g:life_gen如果刚才执行了100@a,这里通常会显示一个接近 100 的数字,除非中途被中断。
你还可以用单步命令验证某一步是否正确:
:%call LifeStep()每执行一次,网格就往前走一代,相当于手动步进调试验证。
4.6 传统 vi 兼容方案说明
必须说明:传统 vi 没有 vim 的寄存器宏录制机制,qa、@a这种操作在老式 vi 里是不支持的。如果你想在很老的环境里也实现“生命游戏逐代演化”,可以用 ex 命令配合外部过滤器,例如 awk 脚本。
思路是:把当前缓冲区内容通过管道交给 awk 程序,awk 计算下一代网格后,再把结果写回缓冲区。
在 vi 中执行:
:1,$!awk -f life.awk其中life.awk是独立的 awk 脚本,内容可以参考下面这个版本:
{ rows[n++] = $0 len = length($0) } END { for (i = 0; i < n; i++) { out = "" for (j = 1; j <= length(rows[i]); j++) { alive = (substr(rows[i], j, 1) == "#") cnt = 0 for (di = -1; di <= 1; di++) for (dj = -1; dj <= 1; dj++) { if (di == 0 && dj == 0) continue ni = i + di nj = j + dj if (ni < 0 || ni >= n) continue if (nj < 1 || nj > length(rows[ni])) continue if (substr(rows[ni], nj, 1) == "#") cnt++ } if (alive) out = out ((cnt == 2 || cnt == 3) ? "#" : ".") else out = out (cnt == 3 ? "#" : ".") } print out } }保存为life.awk,执行一次:1,$!awk -f life.awk,可以看到网格演化一代。再按一次就会再演化一代。这个方案不依赖 vim 宏,但符合传统 vi 也能用 ex 过滤器的思路。
不过,awk 脚本对网格格式要求同样严格:文件不能有空行,每行长度要一致,否则输出可能错位。这里只是提供一个兼容老 vi 的可行思路,日常开发中还是建议直接用完整版的 vim。
5. 常见问题与排查思路
实操过程中可能会遇到下面这些情况,整理成了一张排查表,方便你快速定位。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
执行@a时报错E492: Not an editor command: call LifeStep() | LifeStep函数没有定义,或函数定义被覆盖 | 检查函数是否已写入 vimrc 并在当前 vim 会话中重新 source |
| 宏只执行一次就停止 | 宏中使用了绝对行号或依赖可视区域的按键序列 | 修改宏内容,改用相对移动和全局范围调用 |
| 网格越来越乱,行长度不齐 | 初始数据包含空格、空行或不等长行 | 重新规范life.txt,确保每行等长且无空白字符 |
| 动画闪烁严重,看不清画面 | 没有设置lazyredraw | 在 vimrc 中加入set lazyredraw |
| 没有动画效果,只是瞬间执行完 | 宏里缺少:sleep和:redraw | 重新录制宏,补上:sleep 200m和:redraw |
| 细胞代数不是预期增长 | g:life_gen没有初始化或函数未递增 | 在 vimrc 中初始化let g:life_gen = 0,在函数中递增 |
在 vi 里输入q没反应 | 传统 vi 没有 vim 的宏录制寄存器 | 使用 ex 命令行过滤器方案,配合 awk 脚本演化 |
用:'<,'>call LifeStep()时只处理了当前选中的部分 | 这是范围调用的正常行为 | 如果想处理整个文件,改用:%call LifeStep() |
这里要特别提醒一点:宏录制时如果按错了键,尽量不要手动删除,因为删除动作也会被录进宏。最稳妥的方式是重新录制一遍,也就是再执行一次qa到q,用新内容覆盖旧宏。
6. 最佳实践与工程建议
6.1 函数与宏分层
虽然题目叫“vim 宏编程实现”,但实际工程中不要把所有逻辑都塞进宏里。宏擅长的是“重复按键操作”,而生命游戏这种需要 8 方向邻居计算的逻辑,更适合写在 vimscript 函数中。
推荐的划分方式是:
- vimscript 函数:负责网格读取、计算、写回。
- 宏:负责调用函数、显示代数、等待、重绘画面。
- 手动命令:负责初始化全局变量、设置网格文件。
这种分层方式可读性更强,也方便后续扩展,比如改规则、增加随机初始状态等。
6.2 宏的可重放性
宏最重要的特性就是可重放。为了让它不管在什么位置都能正常工作,录制时应遵循以下原则:
- 不要在宏里使用绝对行号,比如
5G。 - 尽量使用相对移动,比如
j、k、w、0。 - 如果宏要对整个文件操作,就用
%范围,而不是依赖当前光标位置。 - 宏结束时尽量让光标回到一个稳定位置,比如
gg,方便下一次回放。
6.3 性能与网格规模
vim 本质上不是为大规模图像计算设计的。当一个网格扩大到几百行、几百列时,vimscript 逐字符遍历的速度会明显下降。
如果只是演示生命游戏,建议:
- 网格控制在 50 行以内。
- 每行宽度控制在 100 字符以内。
- 不要开太多其他插件,避免回放宏时干扰。
如果确实需要大网格,更合理的方式是让外部程序处理计算,vim 只负责显示。这也是运维场景里常用的“编辑器 + 外部命令”协作模式。
6.4 安全与文件保护
虽然LifeStep()函数并不会直接损坏系统,但你要记住:它会对当前缓冲区内容进行批量改写。
因此:
- 第一次实验时,请在一个独立的测试文件或复制副本上操作。
- 不要把
life.txt和真实代码文件混在同一个缓冲区里。 - 如果改写了重要文件,及时
u撤销,或提前cp一份备份。
在生产环境中使用任何批量编辑、批量替换、外部命令过滤时,都要先确认文件内容可恢复。
7. 总结与后续学习方向
通过这次实操,你应该已经掌握了几件事:vim 宏录制与回放的基本用法、@寄存器执行机制、vimscript 函数与宏的组合方式,以及如何用纯文本网格实现生命游戏的演化。这也证明了 vim 不只是一种编辑器,它本身就是一个可以被“编程”的文本处理环境。
如果还想继续深入,可以尝试下面几个方向:
- 在生命游戏函数中加入更多参数,例如支持随机初始化。
- 把
LifeStep()扩展成通用的二维数组处理函数,应用到其他元胞自动机。 - 学习 vim 的
:help recording、:help Q_a、:help function-range等帮助文档,全面理解宏与函数的底层机制。 - 用类似的思路做一个字符版迷宫生成或简单排序可视化。
当然,vim 宏并不是所有自动化的银弹,但它很适合处理“重复按键 + 少量计算”的场景。在服务器上不方便装图形界面的时候,一段宏加上几十行 vimscript,就能把一个生命游戏动画搬到终端里。这才是 Linux 文本操作最让人上瘾的地方。
如果这篇文章对你有帮助,可以收藏备用,也欢迎自己在终端里把滑翔机跑起来试试看。