起因

去年的一天,好朋友的妈妈发微信问我:「macOS 显示磁盘未能推出怎么办?直接拔掉会不会对硬盘不好?」当然不好了,不怕万一就怕一万。她是一个非常专业的牙医,硬盘里存的都是病例照片和相关资料,在经历过一次因为传输过程中意外断开导致丢失了几十张罕见病例照片之后她对硬盘的操作就更小心了。这些照片她说有一些还是可遇不可求的稀有病例,是非常珍贵万万不可出了问题的。
其实这个问题最简单粗暴的方式就是关机再拔出磁盘即可,但是为了推出磁盘而关机这样做就未免有点太小题大做了。所以我当时简单教她了如何使用 lsofkill 这两个命令来解决这个问题。但是追求完美的我心里始终觉得有点不爽,这样的解决方式还是不适合像她那样的电脑小白,我想再做一个更优雅的解决方案,最好是一键就能推出磁盘的那种。于是便有了本文,我们今天来探究个通解方法。

太复杂不看版

下面这条命令是本文的总结,如果你不喜欢看处理过程分析可以直接把这行命令复制之后就可以关了这篇文章。但是我还是希望你能稍微浏览浏览,因为这条看似复杂的命令整体遵循着 unix 的哲学之一:「让程序只做好一件事」,通过管道符连接了多条命令,每一步在做什么都写地非常清楚,每条命令做的事情都非常简单,拆开来看基本上是属于完全没有终端基础也能看得懂的那种。或许通过这篇文章你还能一瞥 unix 哲学「让程序只做好一件事」的魅力,顺便学一点简单的 unix 命令。

find /Volumes -maxdepth 1 -user $(whoami) -print0|xargs -0 lsof|sed '1d'|awk '{print $2}'|uniq|xargs kill -9 && find /Volumes -maxdepth 1 -user $(whoami) -print0|xargs -0 diskutil unmount && echo '磁盘已安全推出' || echo '磁盘推出失败'  

1. 查看磁盘正在调用的程序
通过 lsof 命令查看磁盘调用的程序,可以看到有四个相关的显示,但实际上只有三个进程,它们所对应的 PID 是 14825 14827 15150

➜ lsof /Volumes/Install\ macOS\ Catalina  
COMMAND     PID  USER   FD   TYPE DEVICE SIZE/OFF  NODE NAME  
Finder    14825 james   16r   REG   1,12    11290 21715 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/Resources/arrowbuttonFocus.tiff  
QuickLook 14827 james  txt    REG   1,12    11290 21715 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/Resources/arrowbuttonFocus.tiff  
Preview   15150 james  txt    REG   1,12    11072 21707 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/Resources/arrowbutton.tiff  
Preview   15150 james  txt    REG   1,12    11290 21715 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/Resources/arrowbuttonFocus.tiff  

2. 删掉首行标题
sed 是著名的文本处理三剑客(sed awk perl)之一,我自己将它看作是地表最强文本处理器。在这里我们需要去掉 lsof 输出的标题栏,为下一步提取 PID 做铺垫。'1d'中的 1 是指第一行,d 则是 delete 的缩写。关于 sed 这个地表最强文本处理器,我整理了 70 多个实例可供学习参考,可见:gsed.md,以后有机会我也会专门写一篇文章来讲讲 sed

➜ lsof /Volumes/Install\ macOS\ Catalina|sed '1d'  
Finder    14825 james   16r   REG   1,12    11290 21715 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/Resources/arrowbuttonFocus.tiff  
QuickLook 14827 james  txt    REG   1,12    11290 21715 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/Resources/arrowbuttonFocus.tiff  
Preview   15150 james  txt    REG   1,12    11072 21707 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/Resources/arrowbutton.tiff  
Preview   15150 james  txt    REG   1,12    11290 21715 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/Resources/arrowbuttonFocus.tiff  

3. 提取 PID
awk 命令可以根据间隔符号提取指定的元素,默认的间隔符号是空格。PID 是每行的第二个元素,所以在这里直接使用 awk 内置的 print 功能打印 $2 即可。

➜ lsof /Volumes/Install\ macOS\ Catalina|sed '1d'|awk '{print $2}'  
14825  
14827  
15150  
15150  

4. 去除重复进程 PID
uniq 命令可以用于去除重复的行列。在第 3 步处理之后可以看到最后两个 PID 是相同的,所以我们需要去掉一个,以免因为 kill 第一个 PID 之后找不到第二个相同值的 PID 而报错。

➜ lsof /Volumes/Install\ macOS\ Catalina|sed '1d'|awk '{print $2}'|uniq  
14825  
14827  
15150  

5. PID 合并为单行
tr 命令可以用于转换、删除、压缩重复字符。在第 4 步的处理中我们已经过滤出了所有相关进程的 PID,现在只要将 PID 合并为单行并 kill 这些 PID 即可。所以在这里使用 tr 将换行符转换为空格。

➜ lsof /Volumes/Install\ macOS\ Catalina|sed '1d'|awk '{print $2}'|uniq|tr '\n' ' '  
14825 14827 15150  

6. 结束相关进程
通过 xargs 命令传递 PID 参数 14825 14827 15150kill -9kill 本身就是一个结束进程的命令,给它加上了 -9 之后是强制终止进程,-9 的效果相当于就算天王老子来了也要结束了这个进程的那种。但是你可能会疑惑一下这里出现的 xargs 是用来干什么的,xargs 在这里的作用是将 PID 参数从管道左侧搬到管道右侧作为参数提供给 kill 命令。因为有些命令是无法通过管道符号接受参数的(比如这里的 kill),便有了 xargs 这个参数搬运工。

➜ lsof /Volumes/Install\ macOS\ Catalina|sed '1d'|awk '{print $2}'|uniq|tr '\n' ' '|xargs kill -9  

7. 确定外接磁盘名
我们的最终目标是一键推出磁盘,那么怎么知道 lsof 的外接磁盘名是什么呢?在上面的命令里都是给定了 /Volumes/Install\ macOS\ Catalina这个外接磁盘名字,但是这样就把这条命令写死了,无法做到直接复制粘贴回车就可以用的效果。我们得想个办法解决这个问题,因此可以先看看内置磁盘与外接磁盘有何不同。

➜ ls -ll /Volumes  
lrwxr-xr-x root  wheel   1 B Sun Jan 17 21:03:02 2021 007 ⇒ /  
drwxrwxr-x root  admin 192 B Thu Dec 10 19:02:32 2020 007 - 数据  
drwxr-xr-x james staff 884 B Tue Jan 19 19:25:01 2021 Install macOS Catalina  

很明显地可以看到第二个参数有些不同,内置磁盘的所有者是 root,而外接磁盘所有者则属于用户自己。这时就需要请出 find 这个强大的查找工具区别两者,如果不算第三方工具,find 这个命令是我心目中最强大的查找工具,支持多种查找方式。我们这里就要使用 find 的「根据所有者来查找」功能进行查找。使用 -maxdepth 1 参数指定查找目录只有一级,使用 -user james 参数指定所属用户是 james

➜ find /Volumes -maxdepth 1 -user james  
/Volumes/Install macOS Catalina  

可以看到 find 命令成功找出了属于用户 james 的外接磁盘,但是随之而来的问题是:不是每个人的电脑用户名都是叫 james 啊,我要做的是可以直接复制粘贴通用的推出命令。所以请出隐居已久的 whoami 命令来帮我们解决这个问题。

➜ whoami  
james  

➜ find /Volumes -maxdepth 1 -user $(whoami)  
/Volumes/Install macOS Catalina  

whoami 是内置用于查看当前登陆的用户名的命令,在它的外面加上 $() 之后 whoami 就会先被执行,将执行结果替换回原始语句中,所以 find /Volumes -maxdepth 1 -user james 等价于 find /Volumes -maxdepth 1 -user $(whoami)。现在看起来我们已经顺利地完成了一半的工作量,下一步照葫芦画瓢通过 xargs 参数搬运工将 find 找到的结果传给 lsof 查看磁盘调用的程序。

➜ find /Volumes -maxdepth 1 -user $(whoami)|xargs lsof  
lsof: status error on /Volumes/Install: No such file or directory  
lsof: status error on macOS: No such file or directory  
lsof: status error on Catalina: No such file or directory  
lsof 4.91  
 latest revision: ftp://lsof.itap.purdue.edu/pub/tools/unix/lsof/  
 latest FAQ: ftp://lsof.itap.purdue.edu/pub/tools/unix/lsof/FAQ  
 latest man page: ftp://lsof.itap.purdue.edu/pub/tools/unix/lsof/lsof_man  
 usage: [-?abhlnNoOPRtUvVX] [+|-c c] [+|-d s] [+D D] [+|-f[cgG]]  
 [-F [f]] [-g [s]] [-i [i]] [+|-L [l]] [+|-M] [-o [o]] [-p s]  
 [+|-r [t]] [-s [p:s]] [-S [t]] [-T [t]] [-u s] [+|-w] [-x [fl]] [--] [names]  
Use the ``-h'' option to get more help information.  

诶?怎么回事,怎么会报错呢?通过报错的前三行可以看出 lsof 将磁盘名Install macOS Catalina 按照空格分割处理了,lsof 以为我们要查询三个参数,实际上我们只输入了一个路径。find 的开发者也考虑到了这一点,所以给 find 加了一个 -print0 参数用于消除空格歧义。而与此配对的也需要在 xargs 之后加上 -0 参数告诉后面的命令:不要把空格当作分隔符,要当成整体处理,加上空格消歧义参数之后的效果如下:

➜ find /Volumes -maxdepth 1 -user $(whoami) -print0|xargs -0 lsof  
COMMAND     PID  USER   FD   TYPE DEVICE SIZE/OFF  NODE NAME  
Finder    14825 james   14r   DIR   1,12      102 21516 /Volumes/Install macOS Catalina/Install macOS Catalina.app  
InstallAs 19477 james  txt    REG   1,12    53424 48644 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/MacOS/InstallAssistant  
InstallAs 19477 james  txt    REG   1,12   401824 49636 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/PlugIns/IA.bundle/Contents/MacOS/IA  
InstallAs 19477 james  txt    REG   1,12   120912 48548 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/PlugIns/IA.bundle/Contents/MacOS/libBaseIA.dylib  
InstallAs 19477 james  txt    REG   1,12   133536 49531 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/Frameworks/OSInstallerSetup.framework/Versions/A/OSInstallerSetup  
InstallAs 19477 james  txt    REG   1,12   132752 49872 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/PlugIns/DiskManagement.IABundle/Contents/MacOS/DiskManagement  
InstallAs 19477 james  txt    REG   1,12    95472 49727 /Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/PlugIns/IACoreStorage.IABundle/Contents/MacOS/IACoreStorage  

➜ find /Volumes -maxdepth 1 -user $(whoami) -print0|xargs -0 lsof|sed '1d'|awk '{print $2}'|uniq|tr '\n' ' '  
14825 19477  

8. 推出磁盘
梳理一下,我们现在已经通过一系列的组合命令完成了如下功能:找出用户磁盘 → 找出调用程序 PID → 去除重复 PID → 合并 PID 为单行 → 结束相关进程。那么不如趁热打铁,顺便把磁盘推出。通过系统自带的 diskutil 命令的 unmount 功能即可实现。

➜ diskutil unmount /Volumes/Install\ macOS\ Catalina  
Volume Install macOS Catalina on disk2s2 unmounted  

9. 推出提示语句
那么我要怎么知道 unmount 是否成功?这时候就要请出逻辑判断符号了,&&|| 是 shell 中内置的一对逻辑判断符号。&& 之后的语句执行条件是前一条语句执行成功情况下执行,|| 之后的语句执行条件是前一条语句执行失败情况下执行。我们可以先通过下面这两个小例子来理解它。

新建一个名为 test 的文件

➜ touch test  

删除 test 文件,因为此时 test 文件存在,所以执行成功输出的结果是:「执行成功返回一」

➜ rm test && echo 执行成功返回一 || echo 执行失败返回零  
执行成功返回一  

再次运行上面的命令这次输出的结果是:「执行失败返回零」。因为 test 已经不存在了,rm 执行失败则执行 || 之后的语句。

➜ rm test && echo 执行成功返回一 || echo 执行失败返回零  
rm: cannot remove 'test': No such file or directory  
执行失败返回零  

用这个方法,我们给 diskutil unmount 加上提示语句试试看,效果看起来符合预期结果。

➜ diskutil unmount /Volumes/Install\ macOS\ Catalina && echo 磁盘已安全推出 || echo 磁盘推出失败  
Volume Install macOS Catalina on disk2s2 unmounted  
磁盘已安全推出  

➜ diskutil unmount /Volumes/Install\ macOS\ Catalina && echo 磁盘已安全推出 || echo 磁盘推出失败  
Volume Install macOS Catalina on disk2s2 failed to unmount: dissented by PID 18458 (/Volumes/Install macOS Catalina/Install macOS Catalina.app/Contents/MacOS/InstallAssistant)  
Dissenter parent PPID 1 (/sbin/launchd)  
磁盘推出失败  

10. 整合结束语句和推出语句
根据上面提到的 && 我们可以再次使用这个逻辑判断符号来连接两条语句。只有当相关进程都被结束之后才执行推出磁盘的命令,这在逻辑上也是比较合理的。所以我们的命令形式就是:结束进程语句 && 推出磁盘语句 && 推出成功提示 || 推出失败提示 。使用 && 连接结束语句和推出语句的优点是前后逻辑比较合理,但是如果磁盘此时没有调用任何程序,则会输出「磁盘推出失败」,所以具体要使用 && 还是 | 连接请根据自己实际的需求来选择。个人建议使用 && 连接,毕竟谁会没事就单纯把磁盘插上去再拔掉,除非真的是太无聊了😂。

➜ find /Volumes -maxdepth 1 -user $(whoami) -print0|xargs -0 lsof|sed '1d'|awk '{print $2}'|uniq|xargs kill -9 && find /Volumes -maxdepth 1 -user $(whoami) -print0|xargs -0 diskutil unmount && echo '磁盘已安全推出' || echo '磁盘推出失败'  

11. 制作脚本文件并赋权
现在整体命令已经构造完成,为了照顾到不喜欢或者是不熟悉终端的用户考虑,我们可以将它打包成一个名为「推出磁盘」的 shell 脚本文件,只要双击执行即可实现一键推出磁盘。

➜ vim 推出磁盘  

粘贴最终拼接的命令

find /Volumes -maxdepth 1 -user $(whoami) -print0|xargs -0 lsof|sed '1d'|awk '{print $2}'|uniq|xargs kill -9 && find /Volumes -maxdepth 1 -user $(whoami) -print0|xargs -0 diskutil unmount && echo '磁盘已安全推出' || echo '磁盘推出失败'  

在英文输入法的情况下依次按下:[esc] → : → wq → [return],脚本文件就保存好了。但是此时这个脚本文件是没有可执行权限的,我们需要通过下面这条命令给它添加权限。

➜ chmod 777 推出磁盘  

12. 替换推出磁盘脚本的图标
默认的可执行文件是黑黑的可执行文件图标,并不怎么好看。我们可以试着为它换一个图标让它看起来好入眼一点。打开 OmniGraffle 选择文字工具,输入 ⏏️,设置文字大小为 100,导出图片,设置透明背景,输出分辨率200。

使用我在一日一技|如何使用预览抠图这篇文章中介绍的方法,扣除透明背景后保存,得到一张清晰的 ⏏️ 并且没有任何背景的图片。

打开 ⏏️ 图片,⌘+A 全选,⌘+C 复制,右键获取「推出磁盘」程序的详细信息,点击左上角的图标,⌘+V 粘贴,即可将原来的可执行文件图标替换成 ⏏️ 图标。

图标修改完成后的效果图:

最终双击执行效果

如果你有心看到了这里也有兴趣使用这个脚本,可以直接点击下载已经修改好图标的成品。

7
9