十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Ubuntu 20.04 Shell 入门:命令、变量、权限与脚本调试

Ubuntu 20.04 Shell 入门:命令、变量、权限与脚本调试 1. 在 Ubuntu 20.04 上先弄清楚你敲的命令交给了谁很多人第一次接触 Linux 是从一台云主机或者虚拟机开始的把 Ubuntu 20.04 LTS 装好SSH 连上去屏幕上出现一行userhost:~$然后光标一闪一闪地等你输入。可真到了这一步大多数人脑子里是空的——不是不知道要装什么软件而是不知道这行提示符背后的东西到底是什么敲下去的命令又是谁在执行。我当年第一次自己搭 Ubuntu 环境时光是“为什么我改了.bashrc但登录后没生效”这件事就折腾了半个晚上最后才发现是我把变量写进了错误的启动文件里。这篇内容就是把这些最基础、但最容易被跳过的环节按我自己的踩坑顺序摊开讲一遍。Ubuntu 20.04 LTS 是长期支持版本默认的登录 shell 是 bash 5.0.x 系列这一点在整个学习过程中会反复用到。你要练的东西基本都在这个 shell 里完成文件操作、文本处理、批量任务、脚本编写。如果你是完全零基础建议先花二十分钟把本节读完再动手敲命令因为这个认知差决定了后面是“复制粘贴到处报错”还是“知道每一条命令为什么这么写”。1.1 终端、shell、控制台三样东西别混为一谈打一个生活里的比方。终端terminal是那部电话机屏幕上显示的窗口、光标、字体都是它的事shell 是电话那头的接线员负责听懂你要什么、把你的请求转达给系统、再把结果念给你听。你在 Windows 上用的 Xshell、PuTTY或者 Ubuntu 桌面上的 GNOME Terminal都属于前者bash、zsh、sh 才是后者。系统里真正的核心叫内核kernel它不认识中文也不认识英文命令只认系统调用中间这层翻译工作全是 shell 在干。为什么要分这么清因为后面你会遇到大量“同一条命令在 A 环境能跑、在 B 环境报错”的情况。原因往往不是命令写错了而是 shell 不一样。举个很常见的例子[[ ]]这种双中括号的判断语法是 bash 的扩展如果你把脚本头写成#!/bin/sh在 Ubuntu 上/bin/sh默认指向 dash跑起来就会直接报语法错误。所以调试脚本前先把“我到底在哪个 shell 里执行”确认清楚能省掉一半无意义的排查。确认方法很直接几条命令就能看明白echo $SHELL # 当前用户的登录 shell来自 /etc/passwd ps -p $$ -o comm # 当前这个进程用的是什么 shell最准确 cat /etc/shells # 系统里安装了哪些合法 shell getent passwd $USER # 看自己这条用户记录最后一个字段就是登录 shell$SHELL这个变量有个容易被忽略的地方它只在登录时设置一次你中途手动敲了bash切换进去$SHELL不会跟着变。而$$表示当前进程的 PIDps -p $$查出来的才是你此刻真正运行着的那个 shell。这两者的区别在排查环境问题时特别关键。1.2su -和su差的是一整套环境变量Ubuntu 默认把 root 账号锁住用sudo提权这是它和很多发行版不一样的地方。但你还是会碰到需要临时切到别的用户、甚至切到 root 的场景这时su和su -的区别就冒出来了。su 用户名只是换了身份环境变量基本沿用了原来那套su - 用户名或者写成su -l 用户名会模拟一次完整登录重新加载目标用户的.profile、.bashrc把PATH、HOME、PWD全部换成目标用户的。这个差别造成的最典型症状就是切过去之后ls、cat这类基础命令还能用但你自己装的工具比如装在/usr/local/bin或用户目录下的nvm、conda全都找不到了因为PATH里没那个路径。记住一条只要涉及“切换用户后环境不对”“命令找不到”先看su后面有没有加那个横杠。对于日常运维我更推荐直接用sudo -i或者sudo -u 某用户 -i它同样会加载目标用户的环境而且不用知道对方密码前提是你的账号在 sudo 组里。想验证自己在不在用id或者groups看一眼输出里有没有sudo就行。1.3 动手之前先给自己划一块练习用地我不太赞成新手一上来就在/etc或者家目录根下乱敲。先在$HOME下面建一个专门的练习目录所有实验都在里面做删错了也不影响系统。这一步只要一分钟mkdir -p ~/lab/{in,out} cd ~/lab touch in/a.txt in/b.txt ls -R这里顺带用到了花括号展开brace expansion{in,out}会被 bash 展开成两个独立参数等价于写两遍mkdir。这个特性在批量建目录、批量改文件名的时候非常好用后面讲 for 循环时还会回头说它。ls -R是递归列出能确认目录结构建对了。练习目录存在的另一个好处是你不用再纠结“我是不是把系统搞坏了”。很多新手卡住不是因为命令难而是心理上不敢按回车。有了一块随时可以rm -rf ~/lab mkdir -p ~/lab/{in,out}重来的沙盒学习速度会明显不一样。2. 每天都要敲的那几个动作cd、ls 和命令行效率技巧如果把 shell 比作开车cd、ls、cat就是方向盘、后视镜和油门的组合用得不熟后面所有的脚本技巧都无从谈起。我见过不少已经能写循环的同学却还在用鼠标一个个找文件名、每次都从/开始输路径这种低效会成倍消耗排查问题时的耐心。这一节只讲日常动作但讲透。2.1 cd 的三种路径写法和那个几乎没人用的横杠路径分绝对路径和相对路径这个概念大家都懂但真正影响效率的是这几个速记写法写法含义典型用途cd回到当前用户家目录迷路时回血cd -回到上一次所在目录两个目录之间来回跳cd ..上一级逐层退出cd ~user进入指定用户的家目录切用户后找文件cd -这个功能我要单独强调一下。它的工作方式和浏览器的“后退”键类似会在最近两个目录之间来回切换。实际排查问题时我经常在/var/log和/etc/nginx之间反复横跳敲cd -比重新输一遍绝对路径快得多而且不容易输错。它之所以冷门大概是因为没人在教程里提但一旦用顺手就很难放下。还有一个新手高频疑问路径里带空格怎么办。文件名里有空格时shell 会把它当成两个参数所以要么用引号包起来cd my dir要么用反斜杠转义cd my\ dir。更稳妥的做法是养成不创建带空格文件名的习惯用下划线或者中划线代替能躲开后面一大堆脚本问题。2.2 ls 的输出到底怎么读ls不带参数只是把文件名平铺出来用处有限。真正有用的是ls -l但它的每一列在讲什么很多人是模糊的$ ls -l -rw-r--r-- 1 dev dev 1024 Feb 10 09:12 notes.txt drwxr-xr-x 2 dev dev 4096 Feb 10 09:10 scripts第一列-rw-r--r--一共十位。第 1 位是文件类型-是普通文件d是目录l是软链接。后面九位分成三组分别是属主、属组、其他人的权限每组rwx对应读、写、执行。数字1是硬链接数然后是属主和属组、字节大小、修改时间和文件名。看懂这一列是后面讲权限和新用户时的地基。目录多的时候直接把输出截断在屏幕顶端会很烦。两种处理方式一是ls -l | less用less分屏查看空格翻页、q退出、/关键词搜索二是ls -lh把字节数转成人能读的 K、M、G。我个人在查磁盘占用时更习惯du -sh * | sort -h这个组合能把当前目录下每个子项的大小按从小到大排好一眼看出谁在占地方。再补一个隐藏文件相关的点ls默认不显示以点开头的文件得加-a。Ubuntu 用户的配置几乎全在这些点文件里.bashrc、.profile、.ssh所以ls -la是排查配置问题时出场率最高的命令之一。2.3 Tab 补全、历史搜索和几个救场快捷键命令行效率的一半来自快捷键而且这些键在 Ubuntu 20.04 的 bash 里默认就能用不需要装任何插件。Tab 补全不用多说路径、命令、甚至sudo后面的参数都能补。它还有个隐藏用法连按两次 Tab会把所有可能的候选全列出来相当于一个即时的模糊匹配。文件名长、路径深的时候这个动作能省下大量敲键盘的时间也顺手避免了拼写错误。历史命令相关的几个组合值得专门记一下Ctrl R反向搜索历史命令输入片段就能翻出以前敲过的长命令再按一次继续往前找。!!重复上一条命令。最经典的用法是apt装东西忘了加sudo直接敲sudo !!就补上了。!$引用上一条命令的最后一个参数。比如刚mkdir project下一步cd !$直接进去。Ctrl A/Ctrl E跳到行首 / 行尾。Ctrl U/Ctrl K删掉光标前 / 光标后的内容。Ctrl L清屏效果等于clear但手不用离开主键区。Ctrl R是我用得最多的一条。写脚本调试时经常要反复跑同一条长命令与其往上翻历史不如直接搜关键词。顺便说一句历史命令默认存在~/.bash_history里只有正常退出 shell 时才会写入如果你开了多个会话又在另一个会话里查不到刚敲的命令就是这个原因——执行history -a可以手动追加。3. 文件与文本处理乱码、批量改名和组合检索基础动作熟练之后接下来要面对的就是真实场景了从别处拷来的压缩包解出来全是乱码、几百个文件要统一改名、在几万行日志里找出关键几行。这三件事几乎每个用过 Linux 的人都会碰到而它们的解法背后都有明确的原理不是靠背命令。3.1 解压出来文件名全是乱码根源在字符编码最常见的一幕从 Windows 上打包的 zip 传到 Ubuntu 20.04 上unzip一解中文文件名全变成了问号或者一堆奇怪的符号。这不是压缩包坏了而是文件名编码不一致。Windows 中文环境下的 zip 通常把文件名按 GBK 编码存进去而 Linux 解压时默认按 UTF-8 去解读这段字节两边对不上就成了乱码。处理思路有几条按推荐程度排# 方案一明确告诉 unzip 用 GBK 解读 unzip -O CP936 乱码包.zip -d out/ # 方案二用 7z指定代码页 7z x -mcp936 乱码包.zip -oout/ # 方案三已经解压了事后批量改名 convmv -f GBK -t UTF-8 -r --notest out/方案一最省事但要注意 Ubuntu 20.04 自带的unzip版本对-O参数的支持情况如果提示选项非法直接换方案二。convmv需要额外安装sudo apt install convmv它能把已经解出来的一堆乱码文件名整体转回正常编码--notest表示真正执行去掉它则只预览不改动——建议第一次先不加--notest看一眼结果确认没误伤再真正跑。至于tar.gz和tar.bz2它们打包时是按字节流存储文件名一般不会出现这种编码错位所以如果你解 tar 包也乱码大概率是终端本身的字符集设置有问题用locale检查一下LANG和LC_ALL是否包含UTF-8就行。顺带一个容易忽略的点unzip -O只影响文件名不影响文件内容。如果是文本文件内容乱码那是另一回事要用iconv转换。3.2 批量重命名for 循环和 rename 各管一段给一批文件统一加前缀、改扩展名、替换其中的年份这类需求手动改是灾难。两种主流做法适用场景不太一样。第一种是for循环灵活度最高能塞进任意逻辑cd ~/lab/in for f in *.txt; do mv -- $f bak_$f done这里有两个细节值得说。--是告诉mv后面即使出现-开头的文件名也当普通文件名处理避免误判成参数。$f外面的双引号必须加不然遇到文件名带空格的文件mv会把它拆成两个参数直接报错或者移错位置。这两点看似琐碎实际就是很多人脚本“在我机器上能跑”但换台机器就翻车的原因。第二种是rename命令适合纯字符串替换一行搞定# Ubuntu 上的 rename 是 Perl 版本语法是 s/旧/新/ rename s/^/bak_/ *.txt rename s/\.txt$/.md/ *.md要注意 Ubuntu 20.04 仓库里的rename有两个变种一个是 Perl 版的支持正则用s///一个是 util-linux 里的简单版只做字面替换。rename --version看一眼输出就能区分。用错版本的表现是提示语法不认识这时候别怀疑自己先确认版本。实操心得批量改名前先跑一次ls *.txt确认匹配到的文件范围。我就吃过一次亏目录里混着几个不该动的文件正则写宽了一点全被改了名事后靠mv一个个对回来浪费的时间远超提前看一眼的成本。3.3 find 和 grep 组合起来才是真正的检索能力grep负责在文件内容里找文本find负责在目录树里找文件这两个分开用都很简单组合起来才是日常排查的主力。按文件名找find /var/log -name *.log -mtime -1 # 近一天修改过的日志 find ~/lab -type f -size 10M # 大于 10M 的普通文件 find . -name *.tmp -delete # 找到并删除先别加 -delete 试跑按内容找grep -rn timeout /etc/nginx/ # 递归搜索并显示行号 grep -i -C 2 error app.log # 忽略大小写前后各带两行上下文 grep -v ^# config.conf | grep -v ^$ # 过滤掉注释行和空行-r递归、-n显示行号、-i忽略大小写、-C带上下文这四个是排查日志时最常用的组合。尤其是-C只看匹配行经常看不出因果前后各带两行问题的来龙去脉一下就清楚了。把两者连起来用可以做到“在特定类型的文件里搜特定内容”find /etc -name *.conf -exec grep -l listen 80 {} \;-exec后面的{}是占位符代表find找到的每个文件名\;是结束符。这个写法每次都会 fork 一个新进程执行grep文件多的时候慢换成-exec ... 会把多个文件名攒起来一次性传过去速度快很多。文件量大的场景下我更推荐直接上grep -r --include*.conf listen 80 /etc可读性和效率都更好。4. 变量、引用与替换${}和$()到底差在哪这一节讲的是我认为 shell 里最容易混淆、也最值得花时间啃透的一块。${}和$()长得很像功能完全不同而且它们背后牵扯到变量作用域、命令替换、引号语义这一整套东西。很多脚本的 bug 不是逻辑写错了而是引用方式选错了。4.1 变量定义、作用域和 exportshell 里定义变量不需要声明类型一个等号就行但等号两边绝对不能有空格有空格会被当成命令执行nameubuntu # 正确 name ubuntu # 报错shell 会去找一个叫 name 的命令这一点和几乎所有编程语言都不一样是新手第一个踩的坑。另外一个细节变量名加不加$取决于你在干什么赋值时用变量名本身name取值时才加$name。作用域是重点。在交互式 shell 里直接定义的变量默认只在当前 shell 进程里有效。你开一个子进程比如跑一个脚本子进程是看不到这个变量的。要让子进程也能读到得用exportexport PATH$PATH:/opt/mytools # 子进程也能用 local_varonly here # 子进程看不到PATH本身就是个被导出过的变量所以你在.bashrc里追加路径后新开的终端里所有命令都能用到。这个机制解释了一个常见现象在终端里手动export的变量脚本里能读但脚本里export的变量脚本一结束就没了因为它只影响脚本自己那个进程和它的子进程。想让变量长期生效写进~/.bashrc交互式登录时加载或者~/.profile。这里有个顺序问题要提一句Ubuntu 上~/.profile会去 source~/.bashrc但只在交互式 bash 里。如果你写的是 SSH 登录后执行的脚本变量放在~/.bashrc里更保险。改完记得source ~/.bashrc让它立即生效或者重开一个终端。4.2${}、$()、$(( ))三种写法的分工把这三个放一起对比最清楚写法名称作用例子${var}参数扩展取变量值可做默认值、截取、替换${port:-8080}$(cmd)命令替换把命令的输出当作值$(date %F)$(( ))算术扩展整数运算并返回结果$((1 2))${var}加花括号的意义在于界定变量边界。比如$name_okshell 会去找一个叫name_ok的变量而不是name加上_ok。写成${name}_ok就清楚了。花括号里还能塞很多有用的操作符echo ${port:-8080} # port 未定义或为空时用 8080 echo ${path#*/} # 从左去掉第一个 / 之前的部分 echo ${file%.txt} # 从右去掉 .txt 扩展名 echo ${#str} # 字符串长度这几个在日常脚本里出场率极高。${file%.txt}这个去掉扩展名的写法在批量处理文件时比调用basename更省事。$(cmd)是把命令的输出拿来用最典型的场景是给变量赋值today$(date %F) files$(ls *.log | wc -l) echo 今天日期 $today日志数 $files它还有一个老写法是反引号cmd功能上等价但反引号不能嵌套嵌一层就得转义可读性极差所以新写脚本一律用$()。只有在维护很老的历史脚本时才会看到反引号。$(( ))专门做整数运算shell 原生不支持浮点这一点要有心理准备需要小数就得借助bc或awk。4.3 单引号、双引号、不加引号三种语义完全不同引号问题是 shell 报错的高频来源三种写法的行为差异必须记牢不加引号shell 会做单词拆分按空格切开和通配符展开。变量值里有空格或*时会出问题。双引号保留内容为一个整体但里面的$变量、$(命令)、反引号仍然会被解释。单引号里面的所有字符原样保留不做任何解释。看一个能说明问题的对比va b c echo $v # 输出三行a、b、c被拆成三个参数 echo $v # 输出一行a b c echo $v # 输出字面量$v所以有一条经验法则值得贴在显示器上凡是变量出现在参数位置默认给它加双引号。这条规则能挡掉绝大多数“文件名带空格就出错”的问题。需要原样传递正则或者$符号时比如awk的脚本体才改用单引号。一个容易疼的细节在双引号里写反斜杠只有$、、、\、换行符前面才有转义效果其他情况反斜杠会被原样保留。这和很多语言的规则不一样写路径时别想当然。5. 流程控制与参数处理for、while 和 shift 的实战场景脚本之所以比手敲命令有用就是因为它能循环、能判断、能接收参数。这一节讲三件事for怎么用才不出空格问题、while read逐行处理的正确姿势、以及shift在处理位置参数时的价值。5.1 for 循环的几种写法以及为什么别用for f in $(ls)for最基本的样子是遍历一个列表for f in a.txt b.txt c.txt; do echo 处理 $f done但真实场景里列表往往来自通配符或者命令输出。这两种来源差别巨大for f in *.txt; do # 推荐shell 自己展开通配符文件名带空格也没事 echo $f done for f in $(ls *.txt); do # 不推荐命令替换的结果会按空格再拆一次 echo $f done第二种写法的问题在于$(ls)的输出是一整串文本shell 拿到之后还要按IFS默认包含空格、Tab、换行切分文件名里只要有一个空格就会被切成两半变成两个不存在的文件。这个坑极其常见也是“shell 中常见坑”里排前三的一条。记住结论能用通配符展开就用通配符别套$(ls)。如果确实需要从命令输出遍历比如从文件里读一列名字用while read更稳下一小节讲。for还有个 C 语言风格的写法适合计数for ((i 1; i 5; i)); do echo 第 $i 次 done批量给文件编号、跑固定次数的重试用这种形式最直观。5.2while read逐行处理两个必踩的坑从文件或命令输出里逐行读内容标准写法是while IFS read -r line; do echo 行内容$line done list.txt这里有三处细节都是有原因的。IFS是把分隔符置空防止行首行尾的空格被吃掉这在处理配置文件时很关键。-r是禁止反斜杠转义否则行尾的\会被当成续行符把两行粘成一行。用重定向而不是管道cat list.txt | while ...是为了避免下面这个更隐蔽的坑。管道里的 while 循环运行在子 shell 中循环里赋值的变量出了循环就丢了。这个现象第一次遇到会非常困惑count0 cat list.txt | while read -r line; do count$((count 1)) done echo $count # 输出 0不是行数 # 正确写法 count0 while read -r line; do count$((count 1)) done list.txt echo $count # 输出实际行数原因就是管道会为每一段创建一个子进程变量修改只发生在子进程里父进程压根不知道。这也是为什么很多人统计完行数发现是 0。规避方式有三种改用重定向推荐、用进程替换done (cat list.txt)、或者把后续逻辑全放进循环里。bash 4.2 之后有个shopt -s lastpipe能让管道最后一段在当前 shell 执行但依赖这个开关会让脚本在不同环境下表现不一致不如直接用重定向来得干净。5.3shift与位置参数写带选项的脚本必备写脚本时经常要处理命令行传进来的参数$1、$2是位置参数$#是参数个数$是全部参数的列表。shift的作用是把这些位置参数整体左移一位原来的$2变成$1$#减一。看一个实际例子就明白了#!/bin/bash while [ $# -gt 0 ]; do case $1 in -n) name$2; shift 2 ;; -v) verbose1; shift ;; *) target$1; shift ;; esac done echo name$name target$target verbose$verbose调用./demo.sh -n test -v /data每一轮循环处理掉一个选项shift 2表示这个选项带了一个值要一次跳过两个位置。这种“边解析边 shift”的模式是 bash 里处理带参脚本的经典写法不依赖任何外部工具。使用shift时要注意边界参数已经移完还继续shift返回值是非零如果脚本开了set -e会直接退出。稳妥的做法是循环条件写成while [ $# -gt 0 ]保证只在有参数时才处理。如果选项比较多、还要支持长短选项和帮助信息手写这套就比较累了可以了解一下getopts它是 bash 内置的用法比手写 shift 更规范。但理解 shift 的机制仍然有必要因为几乎所有解析逻辑底层都是它在工作。6. 在 Ubuntu 20.04 上建用户、给权限几个容易埋雷的地方很多教程讲完命令就结束了但真正让新手翻车的是权限。新建一个用户、让他能执行 sudo、把目录交给他用这几步看着简单细节没处理好后面会遇到“文件能看不能改”“服务起不来”之类莫名其妙的问题。6.1adduser和useradd不是同一个东西Ubuntu 上这两个命令都在但行为差别很大命令来源行为adduserDebian/Ubuntu 脚本自动建家目录、设 shell、提示设密码交互友好useradd底层工具默认不建家目录、shell 可能是 sh需要手动加参数日常用adduser就够了sudo adduser deploy sudo usermod -aG sudo deploy # 加入 sudo 组-a 不能漏usermod -aG里的-a是“追加”如果漏掉写成-G会把用户原有的附加组全部覆盖掉用户会突然失去某些权限。这是个很典型的“少打一个字母出一堆怪问题”的例子操作前最好先用groups 用户名记下原有的组改完再核对一遍。如果非要用useradd比如在脚本里批量建用户参数得写全sudo useradd -m -s /bin/bash -G sudo deploy sudo passwd deploy-m建家目录-s指定登录 shell。这两项漏掉的话新用户登录后可能连ls都提示找不到因为默认 shell 成了/bin/sh而家目录根本没建。检查新用户状态用id 用户名和getent passwd 用户名输出里能看到 UID、GID 和所有附加组。6.2 权限的两层普通权限和 sudo 授权文件权限那套rwx的读法在第 2 节已经说过了这里补充几个实际会遇到的情形。把目录交给另一个用户使用时光改文件权限不够还得注意目录本身和各级父目录的执行位。一个目录如果没有x权限即使里面文件是777对方也进不去。用chown -R 用户:组 目录一次性把属主属组改掉更省事-R是递归。另一个常见需求是让多个用户协作同一个目录。三种做法按推荐度排列把用户都加进同一个组目录设成组可写chmod 2775开头那个 2 是 SGID保证新建文件继承父目录的组。用 ACL 精确授权setfacl -R -m u:deploy:rwx /data粒度细但需要文件系统支持且在拷贝时容易丢失。直接chmod 777最快也最危险除非是一次性临时目录否则不要用。至于sudo想说清楚一点它和su的运行机制不同。sudo执行的是指定命令默认不会加载目标用户的完整登录环境sudo -i才会。所以你会发现sudo echo $PATH打印的还是当前用户的 PATH因为在参数展开阶段$PATH就已经被当前 shell 替换掉了——这是新手最容易误解的一条。要真正看 root 的 PATH得用sudo bash -c echo $PATH或者sudo -i进去再看。关于用户和权限的操作养成一个习惯改之前先用id、groups、ls -l、stat把现状记下来。出问题时你能快速对比出是哪一步改变了什么排查速度完全不一样。7. 脚本调试与那些反复踩的坑前面六节讲的是“怎么写”这一节讲“写错了怎么查”。脚本的调试手段其实不多但用熟了效率差异巨大尤其是当脚本跑了半小时才失败的时候。7.1 三件套bash -n、set -x、echo在脚本里排查空白和引号问题前先做语法检查bash -n script.sh # 只检查语法不执行 bash -x script.sh # 逐条打印实际执行的命令bash -n能快速揪出括号不配对、fi漏写这类结构错误。注意它只查语法查不出逻辑问题变量名拼错它一样放行。bash -x是把每条命令在展开之后的样子打印出来前面带号。这是定位问题最有效的手段因为你能直接看到变量到底被替换成了什么。比如你以为$file是a.txt结果打印出来是*.txt那就说明通配符没展开问题定位到了引号上。如果想只在脚本的某一段开启追踪可以在代码里插入开关set -x # 从这里开始打印 可疑的操作 set x # 关掉长久维护的脚本我习惯在开头加上这几行set -euo pipefail-e遇到错误退出-u使用未定义变量时报错-o pipefail让管道中任意一段失败都算整体失败默认只看最后一段。这三条能挡掉大量静默失败。但-e有它自己的坑比如grep没有匹配到内容时返回非零加上-e会让脚本直接退出。所以涉及“允许失败”的命令时得显式处理if ! grep -q x file; then ... fi或者grep x file || true。7.2 空格、换行符和权限脚本三大隐形杀手文件名带空格前面已经反复说过变量引用加双引号基本能解决。Windows 换行符脚本从 Windows 编辑后传过来每行末尾多一个\rbash 会把\r当成命令名的一部分报错信息里会看到^M或者 No such file or directory 但文件名看起来明明是对的。用cat -A script.sh能看到行尾的^M$。修sed -i s/\r$// script.sh # 或者 dos2unix script.sh脚本没有执行权限./script.sh提示 Permission denied除了chmod x script.sh还要检查文件所在分区是不是挂载成了noexec。用mount | grep 分区名看挂载选项这种情况在把脚本放在某些共享目录时会遇到表现就是怎么改权限都执行不了。还有一个和编辑器相关的现象值得一提用vi编辑脚本时不小心按了CtrlZ或者CtrlS终端看起来像卡住了输入没反应。前者是把进程挂起了输入fg恢复后者是终端流控被冻结按CtrlQ解开。这两个是纯快捷键层面的坑初次遇到很容易以为系统崩了其实什么也没坏。7.3 同一套操作习惯在adb shell里也适用如果你的工作涉及 Android 设备调试会接触到一个叫adb shell的东西。可以把它理解成“通过数据线连到手机上的那个 shell”进去之后ls、cd、cat、ps这些命令照样能用只是底层是 Android 系统的精简环境通常是toybox或mksh不是 bash所以前面讲的 bash 专有语法比如[[ ]]、${var^^}、数组在里面可能不支持。常用的几个动作adb shell # 进入设备的 shell adb shell dumpsys battery # 查看电池状态信息 adb shell dumpsys battery set usb 0 # 让系统认为 USB 未充电便于测试 adb shell ls /sdcard/Download # 直接执行单条命令这种“单条命令直接跟在adb shell后面”的写法比进去之后再敲更常用因为不需要维护一个交互会话适合放进脚本里批量执行。理解了第 4 节讲的引用规则之后你会发现adb shell后的参数怎么加引号、变量在哪一层展开本质上是同一类问题——是本地 shell 先展开还是传给远端再展开。双引号会让本地先替换单引号则原样传给远端。这个判断逻辑和前面讲的find -exec、ssh 远程命令完全一致属于同一种思维模型。8. 把零散命令串成脚本一个能直接抄的练习讲到这里各种零件都齐了。最后给一个完整的小脚本把变量、循环、条件判断、参数处理、错误处理串起来你可以直接复制到~/lab下跑一遍改一改参数感受每个部分的作用。它的功能是扫描指定目录下的日志文件把超过一定大小的归档并输出一份统计。#!/bin/bash set -uo pipefail TARGET_DIR${1:-$HOME/lab/in} # 第一个参数缺省用练习目录 MAX_SIZE_MB${2:-1} # 第二个参数单位 MB MAX_BYTES$((MAX_SIZE_MB * 1024 * 1024)) if [ ! -d $TARGET_DIR ]; then echo 目录不存在$TARGET_DIR 2 exit 1 fi count0 total0 while IFS read -r -d file; do size$(stat -c %s $file) total$((total size)) if [ $size -gt $MAX_BYTES ]; then gzip -c $file ${file}.gz rm -- $file echo 已归档$(basename $file) count$((count 1)) fi done (find $TARGET_DIR -maxdepth 1 -type f -name *.log -print0) echo 扫描完成归档 $count 个文件总计 $((total / 1024)) KB里面有四个地方值得你停下来琢磨。第一${1:-$HOME/lab/in}用了第 4 节讲的默认值语法脚本不传参也能跑。第二find ... -print0配合read -d 是用\0作为分隔符读取专门用来处理文件名里有空格、换行这类极端情况比按行读更安全。第三 (...)是进程替换把find的输出当成一个文件喂给while这样循环体在当前 shell 执行count和total的累加不会丢。第四2把错误信息输出到标准错误这样即使你把正常输出重定向到文件报错依然能在屏幕上看到。把set -uo pipefail里的-e去掉是因为这个脚本里有rm、gzip这类可能失败的命令加上-e会让脚本动不动就中断改用显式的控制流程更可控。这也是我在实际写脚本时的一个习惯对失败容忍度低的脚本用set -euo pipefail涉及大量外部命令的批处理脚本反而要慎用用||和if显式判断更稳。我自己踩了几年坑之后的一个体会是shell 的学习曲线其实不在命令数量上而在“引用规则”和“进程模型”这两块。前者决定了你的脚本在遇到空格、特殊字符时是稳还是崩后者决定了你能不能被“变量为什么丢了”“循环为什么只跑一遍”这类问题卡住。常用命令翻一次手册就能记住但这两块得靠反复写、反复调试才能形成直觉。每次写完一个脚本把它在文件名带空格、含特殊字符的目录里跑一遍是提升最快的方式。
返回列表