我到现在还记得第一次打开Linux虚拟机时,屏幕上只有一个孤零零的提示符,等着我输入点什么。周围坐着的同学已经在鼠标点来点去,我却面对着那个黑乎乎的窗口发呆:我该敲什么?它能听懂我说话吗?后来才知道,那个默默等我的程序,就是Shell——一个把人类语言翻译成系统指令的中间层。而bash,正是这个翻译员里最常见、最通用的一位。这篇文章不打算讲什么高深技巧,就像跟朋友面对面聊一样,把bash的基础知识、核心操作和常见坑位都过一遍。适合刚接触Linux、被命令行吓到的同学,也适合写过几行脚本但一直没弄懂变量引用和条件判断细节的人。看完你至少会明白:命令是怎么被执行的,变量和引号为什么那么绕,怎么写一个能复用的脚本,以及脚本报错时怎么定位问题。1. 从“点按钮”到“敲命令”:Shell到底是个什么玩意1.1 一句话先理清概念很多人会把“终端”“命令行”“Shell”这几个词混着用。严格来说,终端是硬件概念,现在一般指那个窗口程序;命令行是你在窗口里看到的输入环境;而Shell,是真正执行命令、解释语法的那个程序。你在终端里敲了ls -l,被bash接收,bash去调用系统里的ls命令,再把输出结果返回给你。这个过程跟你在图形界面双击一个文件夹图标本质是一样的——都是为了系统干活,只是一个用鼠标,一个用字符串。bash的全称是Bourne Again Shell,看名字也知道它是Bourne Shell(sh)的后继者。Linux发行版里绝大多数默认shell就是bash,macOS早期也是,后来才改成zsh。所以说bash是入门的第一选择,理由不只是它用得广,还包括资料多、脚本兼容性好。你在网上搜到的shell教程,十篇里有八篇默认是讲bash,学会了它,再切到zsh、fish这类新兴shell,核心思路完全通用。1.2 为什么入门的首选是bash市面上的shell并不少:bash、zsh、fish、dash、csh、ksh……各有各的拥趸。zsh以丰富的自动补全和主题出名,fish则更激进,直接把语法改得像一门现代语言。但bash的位置非常特殊:它不是功能最炫的,却是兼容性最强的。绝大多数Linux服务器上,你写一个bash脚本,拷到另一台机器上照样能跑;但如果你写了fish专属语法,到目标机器上可能直接报错。对于要登录服务器做运维、部署服务的人来说,bash就是那个“到哪都能用”的通用语言。bash还内置了很多实用的编程特性:变量、数组、条件判断、循环、函数、算术运算、字符串处理,这些叠加起来,让bash不仅能敲命令,还能像一门真正的编程语言一样写逻辑。说得直白点,命令行是bash的日常用法,脚本才是它真正发挥力量的场景。掌握bash脚本,等于给Linux操作加了一个自动化齿轮,批处理文件、定时备份、日志分析、环境搭建,都能用几十行脚本搞定。1.3 我建议的学习路径我见过很多新手一上来就背命令,今天学find,明天学grep,结果一周后全忘了。我的建议是反过来:先理解bash的语法骨架,再在写脚本的过程中把那些命令自然带出来。你可以按这个顺序推进:先熟悉基本的文件操作命令:cd、ls、cp、mv、rm、mkdir、cat、less。理解命令、选项、参数三者的关系,学会看man帮助文档。掌握变量、引号、通配符、管道、重定向这五个基础概念。学会写条件判断和循环,能够拼出有逻辑的脚本。处理参数、写函数,让脚本具备复用和容错能力。这篇文章会顺着这个路径展开,每个部分都尽量说清楚“为什么这么做”,而不只是贴命令。2. 先搞懂bash的日常操作:命令、变量与引用2.1 命令的本质:你说的话,系统怎么听bash每收到一行输入,会先做分词、展开、再执行。举个例子,你敲了:ls -l /home/userbash会把字符串按空白拆成ls、-l、/home/user三部分,然后去PATH环境变量指定的目录列表里查找有没有叫ls的可执行文件。PATH的定义通常是/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin这些目录的合集。找到了就执行,找不到就报command not found。这也是为什么你要带路径执行当前目录的程序时需要敲./script.sh,因为当前目录.默认不在PATH里。bash并不会默认在当前目录找命令,这是刻意的安全设计——防止你在某个目录里被一个恶意的同名程序劫持。命令还能分成几类:内建命令:由bash自己实现,如cd、echo、export、alias,不依赖外部文件。外部命令:独立的可执行文件,如ls、grep、awk。函数:你或系统定义在shell会话里的逻辑。别名:给命令设置的快捷方式,如alias ll='ls -l'。用type -a 命令名可以查看一个命令属于哪一类、位置在哪。比如type -a cd会显示cd是shell内建命令,而type -a grep会显示它的路径。这对后面调试脚本时判断“命令为什么找不到”非常有用。2.2 变量与引号:bash里最容易栽跟头的地方变量是bash里最小的存储单位。定义一个变量很简单:name="zhangsan" age=25注意赋值号两边不能有空格。name = "zhangsan"会被解析成命令name和参数=、"zhangsan",直接报错。引用变量时前面加美元符号:echo $name。我建议写脚本时总是用大括号包裹变量名,写成${name}。看起来多打了两个字符,但能避免很多边界问题,比如变量名后面紧跟着其他字符时容易解析错。bash里有三类引号,各自分工不同:双引号:可以保留空格,里面的变量和命令会展开,比如echo "$name is $age years old"。单引号:完全字面量,里面所有字符原样输出,echo '$name'会打印$name。反引号:命令替换,已不建议使用。现在更推荐$(command)形式。这个区别极度重要。网上能看到无数个新手栽在这里:日志路径没有加引号,路径里有空格,脚本直接炸了。比如:file_path="/home/user/my files/note.txt" cat $file_pathbash会把$file_path展开成两个词“/home/user/my”和“files/note.txt”,然后当作两个文件处理,结果自然是No such file or directory。正确的写法是cat "$file_path",让整个字符串作为一个整体传递。这个习惯建议从第一天就养成:凡是可能包含空格的变量,引用时默认加双引号。bash还支持一些常用的变量默认值操作。${var:-default}表示如果var没定义或为空,使用default;${var:=default}更激进,还会把default赋值给var。再配合${#var}获取字符串长度、${var%.mp4}去掉后缀名、${var// /_}把空格替换成下划线,日常的字符串处理基本不用去翻awk和sed了。2.3 从实操场景看管道与重定向管道和重定向是shell最强大的两个武器,很多人一开始接触时不明所以,我用一个类比解释:每个命令其实有三个默认数据通道——标准输入(stdin)、标准输出(stdout)、标准错误(stderr)。默认情况下,标准输入来自键盘,标准输出和标准错误都打印到屏幕。重定向做的事情就是改变这些通道的流向。>把标准输出写到文件并覆盖原内容,>>则是追加。2>把标准错误重定向。最常见的组合是2>&1,把标准错误合并到标准输出里,比如你执行一个编译命令,想同时把正常日志和报错都存到一个文件,就写:make > build.log 2>&1这个写法的顺序有讲究:2>&1必须跟在> build.log后面,意思是“把标准错误指向标准输出当前指向的地方”。如果反过来写,标准错误会被指到原本的终端上,效果就不同了。管道则是把一个命令的标准输出接到另一个命令的标准输入。比如:ps aux | grep nginx | grep -v grep当前命令的输出顺着管道一直传给后面的命令处理。这里能看出shell设计的巧思:每个命令都是一个个小工具,通过管道拼装成流水线。跟编程语言里把大函数拆成小函数再组合,是同一个思路。有几个容易混淆的符号再强调一下:$(command):执行命令并把输出作为字符串插入。$((expression)):算术运算,如echo $((3 + 4))输出7。command &:让命令在后台运行。command && command2:前一个成功才执行后一个。command ; command2:不管结果如何,顺序执行。配合通配符一起用更顺手。*匹配任意字符串,?匹配单个字符,[abc]匹配方括号内任一字符。通配符是依赖文件名匹配的,跟正则表达式不同,别混了。3. 让简单的语句组成逻辑:条件判断与循环3.1 if判断的“格子”问题bash的条件判断有个让新手抓狂的地方:空格和方括号必须严格对位。一个最基本的if写法:if [ -f "$file" ]; then echo "文件存在" else echo "文件不存在" fi方括号后面、前面都必须有空格;]前面也要有空格。[其实是一个命令,全名叫test,所以[ "$a" = "$b" ]的意思就是调用test命令测试条件是否成立。既然是一个命令,单词之间就必须用空格分隔,这就是为什么[ -f "$file" ]不能写成[-f "$file"]。除了[,bash还提供了[[关键字,功能更强大:支持==模式匹配、&&和||、正则匹配等,而且即使变量为空也不会出错([在这种情况下经常报错)。条件测试优先级上,我推荐新手直接用[[,写法更现代,逻辑更不易踩坑:if [[ -f $file && -r $file ]]; then echo "文件存在且可读" fi判断条件里有两个特别容易混淆的点:数字比较用-eq、-ne、-gt、-lt,字符串比较用=、!=、\<、\>。[ 10 -gt 2 ]正确,[ 10 \> 2 ]也正确但看起来费劲。如果你看到条件结果总是不对,先检查是不是把数字比较和字符串比较混了。文件测试常用参数也列一下:-f普通文件、-d目录、-e路径存在、-s文件非空、-r可读、-w可写、-x可执行。这些配合条件判断能组合出很多实用场景,比如判断参数是否传入、配置文件是否存在、备份目录是否可写。3.2 for循环的多种写法for循环是最常碰到的循环结构,bash里它跟一般编程语言的“遍历数组”更接近,而不是写一个数字范围然后自增。最经典的写法是遍历一个列表:for ip in 192.168.1.1 192.168.1.2 192.168.1.3; do echo "Pinging $ip" ping -c 1 "$ip" done但这种写法有个潜在问题:默认情况下,for会按空格切割列表,所以如果你遍历的是一个包含空格的文件名,就会出错。针对这种情况可以用while read配合输入重定向:while IFS= read -r line; do echo "$line" done < file_list.txtIFS=和-r都是为了安全处理特殊字符:前者防止行首尾空白被吃掉,后者防止反斜杠被当作转义符号。处理含换行符的文件列表时,这个方法比for更可靠。如果你需要写一个类似C语言的数字循环,bash也有办法:for i in $(seq 1 10); do echo "$i" done # 或 for i in {1..10}; do echo "$i" done{1..10}这种花括号展开在bash 4.0以上版本都能支持,简单直观。循环里用break可以提前退出,continue跳过本次,这跟其他语言一样。需要提醒的是,for i in $(seq 1 10)里用到了命令替换,如果seq命令不存在,这个循环就直接废了。{1..10}是纯bash特性,更可靠。3.3 函数:把重复的活收起来当一个脚本里重复出现好几段相似操作时,就该封装成函数了。bash里定义一个函数很简单:log() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" }位置参数在函数内部同样有效,$1表示第一个参数,$2第二个,$@表示所有参数。注意函数里的$1是函数自己的参数,跟脚本全局的$1不冲突。调用时直接写函数名:log "开始部署"函数还能设置返回值,但bash里返回的是0~255的整数,0表示成功,非0表示失败或状态码。用return 1这类写法和脚本退出码exit逻辑基本一致,只是作用域不同。如果你希望函数打印出处理结果而不只是状态,最简单的方式是用echo打印,然后在调用处用$(函数名)捕获输出。get_size() { local file="$1" local size=$(stat -c %s "$file" 2>/dev/null || echo 0) echo "$size" } current_size=$(get_size "/etc/passwd")这里多用一个local关键字非常关键。bash里函数默认共享全局变量,一个函数里随手改的变量,到了另一个函数可能还在,容易产生莫名其妙的状态污染。local把变量限制在函数内部,写脚本时尽量给内部变量都加上local。4. 从命令行到脚本文件:写出第一个可复用的脚本4.1 脚本文件的三件套当你发现一组命令需要反复敲、或者要做一些多层判断时,就该把命令装进脚本文件了。一个bash脚本的基本构成是三件事:shebang行、可执行权限、正确的执行方式。第一行通常是:#!/bin/bash这行以#!开头,用来告诉系统,这个文件应该用哪个解释器执行。虽然在终端里直接执行时,这行未必是必需的——比如你用bash script.sh时系统已经知道用bash——但加上它可以让你直接./script.sh就能运行,并且能减少误判。第二件事是赋可执行权限:chmod +x script.sh+x表示给文件增加执行权限。很多新手写完脚本,直接./script.sh,得到Permission denied,就是忘了这一步。但如果你不想动权限,也可以用bash script.sh执行。不过我强烈建议你养成加执行权限的习惯,因为这才是“脚本”被系统当作独立程序处理的方式,也方便进一步加入定时任务。第三件事是记住脚本文件内部末尾最好有一个换行。别小看这个细节,某些工具会提示“no newline at end of file”。虽然bash通常不报错,但单行命令、其他编辑器可能受到影响。规范的脚本后面跟一个换行,省心很多。一个可运行的脚本大概长这样:#!/bin/bash # 项目部署前检查脚本 set -euo pipefail echo "正在检查依赖..." if ! command -v curl >/dev/null 2>&1; then echo "curl不存在,请先安装" >&2 exit 1 fi if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 | grep -q 200; then echo "服务状态正常" else echo "服务未响应" >&2 exit 1 fi这里set -euo pipefail是我写脚本时几乎必加的一行。其中-e表示任何命令返回非零就立即退出,-u表示变量未定义就报错(避免变量打错字造成静默错误),-o pipefail表示管道中任何一个命令失败,整个管道返回失败。这三件套能帮你第一时间发现脚本里的问题,而不是带着错误继续跑下去。我踩过最典型的坑是草稿里少写一个字符,变量名拼错,结果脚本里那个变量当作空值处理,后面的逻辑整个偏离预期。加了-u之后,这种错误会立刻暴露出来,报错信息直接指出哪一行用了未定义变量。4.2 给脚本加点“参数处理”脚本写出来给别人用,就得处理外部传入的参数。bash里几个位置参数约定:$0:脚本自身的名字。$1、$2:依次是第一个、第二个位置参数。$#:参数个数。$@:所有参数,常用于遍历。$*:所有参数(当作一个整体字符串)。参数少的时候直接判断即可:if [ $# -lt 1 ]; then echo "用法: $0 <目标目录>" exit 1 fi target_dir="$1"参数稍微多起来,或者带选项(比如-s、-v),就该用getopts了。getopts是bash内建命令,专门处理短选项:while getopts "s:v" opt; do case "$opt" in s) source_dir="$OPTARG" ;; v) verbose=1 ;; *) echo "未知选项: -$OPTARG" exit 1 ;; esac done有两点需要指出来:第一个是后面的:表示s选项需要带参数,取到的参数放在$OPTARG里;第二个是v选项不需要参数,所以没有冒号。getopts会自动把选项和位置参数分离,你可以在最后用shift $((OPTIND - 1))把剩余的裸参数拿到手。当然实际写脚本时,完全可以用更现代的argparse系列库,比如bash第三方库或干脆嵌入一段手动解析。但对绝大多数脚本,getopts够用且可靠。至少它不用你去处理那些相同选项重复出现、选项和参数粘连的边界问题。4.3 实例:一个简单的备份脚本纸上谈兵够多了,直接写一个能用的备份脚本,把前面提到的知识点串起来。这个脚本模拟的场景是:备份一个指定目录下的所有.conf文件,打包成带时间戳的压缩包,只保留最近7天的备份。代码本身不算复杂,但结构完整。#!/bin/bash set -euo pipefail config_dir="/etc/myapp" backup_root="/data/backups" today=$(date +%Y%m%d_%H%M%S) backup_file="$backup_root/config_backup_$today.tar.gz" if [[ ! -d $config_dir ]]; then echo "配置目录 $config_dir 不存在,推出备份" >&2 exit 1 fi mkdir -p "$backup_root" find "$config_dir" -name "*.conf" -print0 | tar --null -czf "$backup_file" -T - 2>/dev/null if [[ -s $backup_file ]]; then echo "备份完成: $backup_file" else echo "备份失败: 生成的文件为空" >&2 exit 1 fi find "$backup_root" -name "config_backup_*.tar.gz" -mtime +7 -delete echo "已清理7天前的备份"有几个细节说一下。find的输出不是普通换行文本,而是-print0配合tar --null,核心是为了应对文件名里带换行或空格的情况。如果文件名有空格,用普通find ... | tar会直接拆错;-print0用空字符分隔,才安全。这段逻辑是我后来从生产环境实战中总结出来的,新手写脚本时忽略了它,一旦遇到特殊文件名就会出问题。-s $backup_file判断文件存在且非空。备份程序可能没报错就退出了,但生成的文件是0字节,这种异常要靠这个判断兜底。删除7天前备份的这一行,用了-mtime +7。注意不是“超过7天前的文件”就删除,而是修改时间距今超过7天的文件。如果机器本身时间有问题,这一行的效果也会受影响。5. 排错与经验:我踩过的坑和检查技巧5.1 高频问题速查bash脚本报错信息有时候相当直白,有时候又很隐晦。整理了一张表,列出我见过的高频错误和对应的处理思路,方便你对号入座:错误现象常见原因处理办法command not foundPATH有问题,命令未安装,或脚本没有shebang用type -a查命令位置,echo $PATH查环境变量Permission denied文件没有执行权限chmod +x 文件名[: 缺少]`[和]周围空格不对,或者变量为空改成[[写法,检查方括号两边空格脚本执行一半就停了某个命令返回非零,配合set -e触发了退出定位退出点,检查该命令的退出码文件内容带空格被拆开引用变量时没有加双引号给变量引用统一加双引号\r字符导致命令执行失败脚本在Windows下编辑过,带有CRLF换行dos2unix 文件名,或换行符转成LF变量名拼写没问题但结果是空变量未定义,或者写成了var而不是$var加set -u,检查是否缺少$符号很多时候问题不在于语法多复杂,而在于“你以为你写的”和“bash以为你写的”不是一回事。比如在CRLF换行的情况下,脚本第一行的#!/bin/bash\r会被系统当成一个带特殊字符的解释器路径,直接报错。这种问题不做排查根本想不到是换行符的锅。5.2 排错工具链:bash -x和echo调试bash自带一个调试模式,运行脚本时加-x参数,会打印每一条实际执行的命令及展开结果。这是我最常用的排错手段。bash -x script.sh-x会把命令展开后的结果打印出来。比如脚本里有$filename,它会显示成cat /data/my file.txt这种展开后的样子,你一眼就能看出变量引用、空格处理有没有问题。配合-n可以做语法检查,bash -n script.sh只检查语法错误但不执行,当脚本很长时先跑一遍,能快速发现括号不匹配、语法错漏。也有时候不想整个脚本都开调试,那就只是在重点位置加echo插桩。最常见的方法是打印变量值:echo "DEBUG: config_dir=$config_dir"打印时建议加上标记前缀,比如DEBUG:,方便之后批量删除。如果脚本里有连续多步操作,还可以打印“进入第X步”这类信息,确认执行顺序是否符合预期。我对当前这个备份脚本实际调试的过程印象挺深。第一次写完,测试时发现备份文件总是空的。加了-x运行,发现find "$config_dir" -name "*.conf"没有输出任何内容,再检查才发现目录里的配置文件后缀是.cnf而不是.conf。这种低级错误靠读代码很难发现,但靠bash -x简直是一目了然。5.3 维护bash脚本的几个经验习惯最后整理一些在实操中验证过的小习惯,虽然琐碎,但能极大提升脚本编写的舒适度。第一个习惯是给每个脚本加上“公共头”。我通常在文件开头写这样几行:#!/bin/bash set -euo pipefail IFS=$'\n\t'set那行前面已经说过。IFS改成换行和Tab,而不是默认的包含空格,这样for循环遍历文件列表时,含有空格的文件名不会被拆散。这个习惯是从某次批量处理带空格文件名被坑之后养成的,之后写任何涉及文件名的脚本都默认带上。第二个习惯是日志输出明确区分正常和错误。正常输出走标准输出,错误输出走标准错误:echo "处理完成" echo "处理失败: 目录不存在" >&2这样在使用脚本的时候,如果加了2>error.log重定向,只有错误信息留在日志文件里,排查起来一目了然。第三个习惯是脚本命名用小写字母加下划线,后缀用.sh。虽然不是硬性规定,但业界通识就是小写加下划线,方便辨认。文件名能用backup_config.sh,就不必写成backupConfigScriptV2.sh,有些系统对文件名大小写敏感,别给自己添麻烦。第四个习惯是给脚本写注释。这个可能听起来像废话,但bash脚本有一个特点:写的时候大脑还清晰,三个月后回来看,基本就等同于天书。至少在关键的判断逻辑、参数处理、磁盘操作这几个位置各写一行注释,说明意图,不然后面维护的成本远高于写的时候多敲几行字。最后的经验体会写了这么多年命令行脚本,最大的体会是:bash是一门“故事语言”,它不追求严密的类型系统和抽象能力,追求的是让系统里的工具能灵活地拼装起来。正因为这个特性,学bash不要试图一次性把所有语法都学会,而是随手记下自己在真实操作中需要用到的那些片段。今天部署服务时写一个判断进程是否在运行的脚本,明天日志清理时写一个定时备份任务,用到的知识点就这么一点一点积累起来了。这篇介绍覆盖的也就是最基础的那一层——变量怎么存、引号怎么用、判断循环怎么写、脚本怎么跑起来。把这些搞利索,往后去看那些复杂的bash开源脚本,至少能读懂它们在干什么,而不是望而生畏。
我到现在还记得第一次打开Linux虚拟机时,屏幕上只有一个孤零零的提示符,等着我输入点什么。周围坐着的同学已经在鼠标点来点去,我却面对着那个黑乎乎的窗口发呆:我该敲什么?它能听懂我说话吗?后来才知道,那个默默等我的程序,就是Shell——一个把人类语言翻译成系统指令的中间层。而bash,正是这个翻译员里最常见、最通用的一位。这篇文章不打算讲什么高深技巧,就像跟朋友面对面聊一样,把bash的基础知识、核心操作和常见坑位都过一遍。适合刚接触Linux、被命令行吓到的同学,也适合写过几行脚本但一直没弄懂变量引用和条件判断细节的人。看完你至少会明白:命令是怎么被执行的,变量和引号为什么那么绕,怎么写一个能复用的脚本,以及脚本报错时怎么定位问题。1. 从“点按钮”到“敲命令”:Shell到底是个什么玩意1.1 一句话先理清概念很多人会把“终端”“命令行”“Shell”这几个词混着用。严格来说,终端是硬件概念,现在一般指那个窗口程序;命令行是你在窗口里看到的输入环境;而Shell,是真正执行命令、解释语法的那个程序。你在终端里敲了ls -l,被bash接收,bash去调用系统里的ls命令,再把输出结果返回给你。这个过程跟你在图形界面双击一个文件夹图标本质是一样的——都是为了系统干活,只是一个用鼠标,一个用字符串。bash的全称是Bourne Again Shell,看名字也知道它是Bourne Shell(sh)的后继者。Linux发行版里绝大多数默认shell就是bash,macOS早期也是,后来才改成zsh。所以说bash是入门的第一选择,理由不只是它用得广,还包括资料多、脚本兼容性好。你在网上搜到的shell教程,十篇里有八篇默认是讲bash,学会了它,再切到zsh、fish这类新兴shell,核心思路完全通用。1.2 为什么入门的首选是bash市面上的shell并不少:bash、zsh、fish、dash、csh、ksh……各有各的拥趸。zsh以丰富的自动补全和主题出名,fish则更激进,直接把语法改得像一门现代语言。但bash的位置非常特殊:它不是功能最炫的,却是兼容性最强的。绝大多数Linux服务器上,你写一个bash脚本,拷到另一台机器上照样能跑;但如果你写了fish专属语法,到目标机器上可能直接报错。对于要登录服务器做运维、部署服务的人来说,bash就是那个“到哪都能用”的通用语言。bash还内置了很多实用的编程特性:变量、数组、条件判断、循环、函数、算术运算、字符串处理,这些叠加起来,让bash不仅能敲命令,还能像一门真正的编程语言一样写逻辑。说得直白点,命令行是bash的日常用法,脚本才是它真正发挥力量的场景。掌握bash脚本,等于给Linux操作加了一个自动化齿轮,批处理文件、定时备份、日志分析、环境搭建,都能用几十行脚本搞定。1.3 我建议的学习路径我见过很多新手一上来就背命令,今天学find,明天学grep,结果一周后全忘了。我的建议是反过来:先理解bash的语法骨架,再在写脚本的过程中把那些命令自然带出来。你可以按这个顺序推进:先熟悉基本的文件操作命令:cd、ls、cp、mv、rm、mkdir、cat、less。理解命令、选项、参数三者的关系,学会看man帮助文档。掌握变量、引号、通配符、管道、重定向这五个基础概念。学会写条件判断和循环,能够拼出有逻辑的脚本。处理参数、写函数,让脚本具备复用和容错能力。这篇文章会顺着这个路径展开,每个部分都尽量说清楚“为什么这么做”,而不只是贴命令。2. 先搞懂bash的日常操作:命令、变量与引用2.1 命令的本质:你说的话,系统怎么听bash每收到一行输入,会先做分词、展开、再执行。举个例子,你敲了:ls -l /home/userbash会把字符串按空白拆成ls、-l、/home/user三部分,然后去PATH环境变量指定的目录列表里查找有没有叫ls的可执行文件。PATH的定义通常是/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin这些目录的合集。找到了就执行,找不到就报command not found。这也是为什么你要带路径执行当前目录的程序时需要敲./script.sh,因为当前目录.默认不在PATH里。bash并不会默认在当前目录找命令,这是刻意的安全设计——防止你在某个目录里被一个恶意的同名程序劫持。命令还能分成几类:内建命令:由bash自己实现,如cd、echo、export、alias,不依赖外部文件。外部命令:独立的可执行文件,如ls、grep、awk。函数:你或系统定义在shell会话里的逻辑。别名:给命令设置的快捷方式,如alias ll='ls -l'。用type -a 命令名可以查看一个命令属于哪一类、位置在哪。比如type -a cd会显示cd是shell内建命令,而type -a grep会显示它的路径。这对后面调试脚本时判断“命令为什么找不到”非常有用。2.2 变量与引号:bash里最容易栽跟头的地方变量是bash里最小的存储单位。定义一个变量很简单:name="zhangsan" age=25注意赋值号两边不能有空格。name = "zhangsan"会被解析成命令name和参数=、"zhangsan",直接报错。引用变量时前面加美元符号:echo $name。我建议写脚本时总是用大括号包裹变量名,写成${name}。看起来多打了两个字符,但能避免很多边界问题,比如变量名后面紧跟着其他字符时容易解析错。bash里有三类引号,各自分工不同:双引号:可以保留空格,里面的变量和命令会展开,比如echo "$name is $age years old"。单引号:完全字面量,里面所有字符原样输出,echo '$name'会打印$name。反引号:命令替换,已不建议使用。现在更推荐$(command)形式。这个区别极度重要。网上能看到无数个新手栽在这里:日志路径没有加引号,路径里有空格,脚本直接炸了。比如:file_path="/home/user/my files/note.txt" cat $file_pathbash会把$file_path展开成两个词“/home/user/my”和“files/note.txt”,然后当作两个文件处理,结果自然是No such file or directory。正确的写法是cat "$file_path",让整个字符串作为一个整体传递。这个习惯建议从第一天就养成:凡是可能包含空格的变量,引用时默认加双引号。bash还支持一些常用的变量默认值操作。${var:-default}表示如果var没定义或为空,使用default;${var:=default}更激进,还会把default赋值给var。再配合${#var}获取字符串长度、${var%.mp4}去掉后缀名、${var// /_}把空格替换成下划线,日常的字符串处理基本不用去翻awk和sed了。2.3 从实操场景看管道与重定向管道和重定向是shell最强大的两个武器,很多人一开始接触时不明所以,我用一个类比解释:每个命令其实有三个默认数据通道——标准输入(stdin)、标准输出(stdout)、标准错误(stderr)。默认情况下,标准输入来自键盘,标准输出和标准错误都打印到屏幕。重定向做的事情就是改变这些通道的流向。>把标准输出写到文件并覆盖原内容,>>则是追加。2>把标准错误重定向。最常见的组合是2>&1,把标准错误合并到标准输出里,比如你执行一个编译命令,想同时把正常日志和报错都存到一个文件,就写:make > build.log 2>&1这个写法的顺序有讲究:2>&1必须跟在> build.log后面,意思是“把标准错误指向标准输出当前指向的地方”。如果反过来写,标准错误会被指到原本的终端上,效果就不同了。管道则是把一个命令的标准输出接到另一个命令的标准输入。比如:ps aux | grep nginx | grep -v grep当前命令的输出顺着管道一直传给后面的命令处理。这里能看出shell设计的巧思:每个命令都是一个个小工具,通过管道拼装成流水线。跟编程语言里把大函数拆成小函数再组合,是同一个思路。有几个容易混淆的符号再强调一下:$(command):执行命令并把输出作为字符串插入。$((expression)):算术运算,如echo $((3 + 4))输出7。command &:让命令在后台运行。command && command2:前一个成功才执行后一个。command ; command2:不管结果如何,顺序执行。配合通配符一起用更顺手。*匹配任意字符串,?匹配单个字符,[abc]匹配方括号内任一字符。通配符是依赖文件名匹配的,跟正则表达式不同,别混了。3. 让简单的语句组成逻辑:条件判断与循环3.1 if判断的“格子”问题bash的条件判断有个让新手抓狂的地方:空格和方括号必须严格对位。一个最基本的if写法:if [ -f "$file" ]; then echo "文件存在" else echo "文件不存在" fi方括号后面、前面都必须有空格;]前面也要有空格。[其实是一个命令,全名叫test,所以[ "$a" = "$b" ]的意思就是调用test命令测试条件是否成立。既然是一个命令,单词之间就必须用空格分隔,这就是为什么[ -f "$file" ]不能写成[-f "$file"]。除了[,bash还提供了[[关键字,功能更强大:支持==模式匹配、&&和||、正则匹配等,而且即使变量为空也不会出错([在这种情况下经常报错)。条件测试优先级上,我推荐新手直接用[[,写法更现代,逻辑更不易踩坑:if [[ -f $file && -r $file ]]; then echo "文件存在且可读" fi判断条件里有两个特别容易混淆的点:数字比较用-eq、-ne、-gt、-lt,字符串比较用=、!=、\<、\>。[ 10 -gt 2 ]正确,[ 10 \> 2 ]也正确但看起来费劲。如果你看到条件结果总是不对,先检查是不是把数字比较和字符串比较混了。文件测试常用参数也列一下:-f普通文件、-d目录、-e路径存在、-s文件非空、-r可读、-w可写、-x可执行。这些配合条件判断能组合出很多实用场景,比如判断参数是否传入、配置文件是否存在、备份目录是否可写。3.2 for循环的多种写法for循环是最常碰到的循环结构,bash里它跟一般编程语言的“遍历数组”更接近,而不是写一个数字范围然后自增。最经典的写法是遍历一个列表:for ip in 192.168.1.1 192.168.1.2 192.168.1.3; do echo "Pinging $ip" ping -c 1 "$ip" done但这种写法有个潜在问题:默认情况下,for会按空格切割列表,所以如果你遍历的是一个包含空格的文件名,就会出错。针对这种情况可以用while read配合输入重定向:while IFS= read -r line; do echo "$line" done < file_list.txtIFS=和-r都是为了安全处理特殊字符:前者防止行首尾空白被吃掉,后者防止反斜杠被当作转义符号。处理含换行符的文件列表时,这个方法比for更可靠。如果你需要写一个类似C语言的数字循环,bash也有办法:for i in $(seq 1 10); do echo "$i" done # 或 for i in {1..10}; do echo "$i" done{1..10}这种花括号展开在bash 4.0以上版本都能支持,简单直观。循环里用break可以提前退出,continue跳过本次,这跟其他语言一样。需要提醒的是,for i in $(seq 1 10)里用到了命令替换,如果seq命令不存在,这个循环就直接废了。{1..10}是纯bash特性,更可靠。3.3 函数:把重复的活收起来当一个脚本里重复出现好几段相似操作时,就该封装成函数了。bash里定义一个函数很简单:log() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" }位置参数在函数内部同样有效,$1表示第一个参数,$2第二个,$@表示所有参数。注意函数里的$1是函数自己的参数,跟脚本全局的$1不冲突。调用时直接写函数名:log "开始部署"函数还能设置返回值,但bash里返回的是0~255的整数,0表示成功,非0表示失败或状态码。用return 1这类写法和脚本退出码exit逻辑基本一致,只是作用域不同。如果你希望函数打印出处理结果而不只是状态,最简单的方式是用echo打印,然后在调用处用$(函数名)捕获输出。get_size() { local file="$1" local size=$(stat -c %s "$file" 2>/dev/null || echo 0) echo "$size" } current_size=$(get_size "/etc/passwd")这里多用一个local关键字非常关键。bash里函数默认共享全局变量,一个函数里随手改的变量,到了另一个函数可能还在,容易产生莫名其妙的状态污染。local把变量限制在函数内部,写脚本时尽量给内部变量都加上local。4. 从命令行到脚本文件:写出第一个可复用的脚本4.1 脚本文件的三件套当你发现一组命令需要反复敲、或者要做一些多层判断时,就该把命令装进脚本文件了。一个bash脚本的基本构成是三件事:shebang行、可执行权限、正确的执行方式。第一行通常是:#!/bin/bash这行以#!开头,用来告诉系统,这个文件应该用哪个解释器执行。虽然在终端里直接执行时,这行未必是必需的——比如你用bash script.sh时系统已经知道用bash——但加上它可以让你直接./script.sh就能运行,并且能减少误判。第二件事是赋可执行权限:chmod +x script.sh+x表示给文件增加执行权限。很多新手写完脚本,直接./script.sh,得到Permission denied,就是忘了这一步。但如果你不想动权限,也可以用bash script.sh执行。不过我强烈建议你养成加执行权限的习惯,因为这才是“脚本”被系统当作独立程序处理的方式,也方便进一步加入定时任务。第三件事是记住脚本文件内部末尾最好有一个换行。别小看这个细节,某些工具会提示“no newline at end of file”。虽然bash通常不报错,但单行命令、其他编辑器可能受到影响。规范的脚本后面跟一个换行,省心很多。一个可运行的脚本大概长这样:#!/bin/bash # 项目部署前检查脚本 set -euo pipefail echo "正在检查依赖..." if ! command -v curl >/dev/null 2>&1; then echo "curl不存在,请先安装" >&2 exit 1 fi if curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 | grep -q 200; then echo "服务状态正常" else echo "服务未响应" >&2 exit 1 fi这里set -euo pipefail是我写脚本时几乎必加的一行。其中-e表示任何命令返回非零就立即退出,-u表示变量未定义就报错(避免变量打错字造成静默错误),-o pipefail表示管道中任何一个命令失败,整个管道返回失败。这三件套能帮你第一时间发现脚本里的问题,而不是带着错误继续跑下去。我踩过最典型的坑是草稿里少写一个字符,变量名拼错,结果脚本里那个变量当作空值处理,后面的逻辑整个偏离预期。加了-u之后,这种错误会立刻暴露出来,报错信息直接指出哪一行用了未定义变量。4.2 给脚本加点“参数处理”脚本写出来给别人用,就得处理外部传入的参数。bash里几个位置参数约定:$0:脚本自身的名字。$1、$2:依次是第一个、第二个位置参数。$#:参数个数。$@:所有参数,常用于遍历。$*:所有参数(当作一个整体字符串)。参数少的时候直接判断即可:if [ $# -lt 1 ]; then echo "用法: $0 <目标目录>" exit 1 fi target_dir="$1"参数稍微多起来,或者带选项(比如-s、-v),就该用getopts了。getopts是bash内建命令,专门处理短选项:while getopts "s:v" opt; do case "$opt" in s) source_dir="$OPTARG" ;; v) verbose=1 ;; *) echo "未知选项: -$OPTARG" exit 1 ;; esac done有两点需要指出来:第一个是后面的:表示s选项需要带参数,取到的参数放在$OPTARG里;第二个是v选项不需要参数,所以没有冒号。getopts会自动把选项和位置参数分离,你可以在最后用shift $((OPTIND - 1))把剩余的裸参数拿到手。当然实际写脚本时,完全可以用更现代的argparse系列库,比如bash第三方库或干脆嵌入一段手动解析。但对绝大多数脚本,getopts够用且可靠。至少它不用你去处理那些相同选项重复出现、选项和参数粘连的边界问题。4.3 实例:一个简单的备份脚本纸上谈兵够多了,直接写一个能用的备份脚本,把前面提到的知识点串起来。这个脚本模拟的场景是:备份一个指定目录下的所有.conf文件,打包成带时间戳的压缩包,只保留最近7天的备份。代码本身不算复杂,但结构完整。#!/bin/bash set -euo pipefail config_dir="/etc/myapp" backup_root="/data/backups" today=$(date +%Y%m%d_%H%M%S) backup_file="$backup_root/config_backup_$today.tar.gz" if [[ ! -d $config_dir ]]; then echo "配置目录 $config_dir 不存在,推出备份" >&2 exit 1 fi mkdir -p "$backup_root" find "$config_dir" -name "*.conf" -print0 | tar --null -czf "$backup_file" -T - 2>/dev/null if [[ -s $backup_file ]]; then echo "备份完成: $backup_file" else echo "备份失败: 生成的文件为空" >&2 exit 1 fi find "$backup_root" -name "config_backup_*.tar.gz" -mtime +7 -delete echo "已清理7天前的备份"有几个细节说一下。find的输出不是普通换行文本,而是-print0配合tar --null,核心是为了应对文件名里带换行或空格的情况。如果文件名有空格,用普通find ... | tar会直接拆错;-print0用空字符分隔,才安全。这段逻辑是我后来从生产环境实战中总结出来的,新手写脚本时忽略了它,一旦遇到特殊文件名就会出问题。-s $backup_file判断文件存在且非空。备份程序可能没报错就退出了,但生成的文件是0字节,这种异常要靠这个判断兜底。删除7天前备份的这一行,用了-mtime +7。注意不是“超过7天前的文件”就删除,而是修改时间距今超过7天的文件。如果机器本身时间有问题,这一行的效果也会受影响。5. 排错与经验:我踩过的坑和检查技巧5.1 高频问题速查bash脚本报错信息有时候相当直白,有时候又很隐晦。整理了一张表,列出我见过的高频错误和对应的处理思路,方便你对号入座:错误现象常见原因处理办法command not foundPATH有问题,命令未安装,或脚本没有shebang用type -a查命令位置,echo $PATH查环境变量Permission denied文件没有执行权限chmod +x 文件名[: 缺少]`[和]周围空格不对,或者变量为空改成[[写法,检查方括号两边空格脚本执行一半就停了某个命令返回非零,配合set -e触发了退出定位退出点,检查该命令的退出码文件内容带空格被拆开引用变量时没有加双引号给变量引用统一加双引号\r字符导致命令执行失败脚本在Windows下编辑过,带有CRLF换行dos2unix 文件名,或换行符转成LF变量名拼写没问题但结果是空变量未定义,或者写成了var而不是$var加set -u,检查是否缺少$符号很多时候问题不在于语法多复杂,而在于“你以为你写的”和“bash以为你写的”不是一回事。比如在CRLF换行的情况下,脚本第一行的#!/bin/bash\r会被系统当成一个带特殊字符的解释器路径,直接报错。这种问题不做排查根本想不到是换行符的锅。5.2 排错工具链:bash -x和echo调试bash自带一个调试模式,运行脚本时加-x参数,会打印每一条实际执行的命令及展开结果。这是我最常用的排错手段。bash -x script.sh-x会把命令展开后的结果打印出来。比如脚本里有$filename,它会显示成cat /data/my file.txt这种展开后的样子,你一眼就能看出变量引用、空格处理有没有问题。配合-n可以做语法检查,bash -n script.sh只检查语法错误但不执行,当脚本很长时先跑一遍,能快速发现括号不匹配、语法错漏。也有时候不想整个脚本都开调试,那就只是在重点位置加echo插桩。最常见的方法是打印变量值:echo "DEBUG: config_dir=$config_dir"打印时建议加上标记前缀,比如DEBUG:,方便之后批量删除。如果脚本里有连续多步操作,还可以打印“进入第X步”这类信息,确认执行顺序是否符合预期。我对当前这个备份脚本实际调试的过程印象挺深。第一次写完,测试时发现备份文件总是空的。加了-x运行,发现find "$config_dir" -name "*.conf"没有输出任何内容,再检查才发现目录里的配置文件后缀是.cnf而不是.conf。这种低级错误靠读代码很难发现,但靠bash -x简直是一目了然。5.3 维护bash脚本的几个经验习惯最后整理一些在实操中验证过的小习惯,虽然琐碎,但能极大提升脚本编写的舒适度。第一个习惯是给每个脚本加上“公共头”。我通常在文件开头写这样几行:#!/bin/bash set -euo pipefail IFS=$'\n\t'set那行前面已经说过。IFS改成换行和Tab,而不是默认的包含空格,这样for循环遍历文件列表时,含有空格的文件名不会被拆散。这个习惯是从某次批量处理带空格文件名被坑之后养成的,之后写任何涉及文件名的脚本都默认带上。第二个习惯是日志输出明确区分正常和错误。正常输出走标准输出,错误输出走标准错误:echo "处理完成" echo "处理失败: 目录不存在" >&2这样在使用脚本的时候,如果加了2>error.log重定向,只有错误信息留在日志文件里,排查起来一目了然。第三个习惯是脚本命名用小写字母加下划线,后缀用.sh。虽然不是硬性规定,但业界通识就是小写加下划线,方便辨认。文件名能用backup_config.sh,就不必写成backupConfigScriptV2.sh,有些系统对文件名大小写敏感,别给自己添麻烦。第四个习惯是给脚本写注释。这个可能听起来像废话,但bash脚本有一个特点:写的时候大脑还清晰,三个月后回来看,基本就等同于天书。至少在关键的判断逻辑、参数处理、磁盘操作这几个位置各写一行注释,说明意图,不然后面维护的成本远高于写的时候多敲几行字。最后的经验体会写了这么多年命令行脚本,最大的体会是:bash是一门“故事语言”,它不追求严密的类型系统和抽象能力,追求的是让系统里的工具能灵活地拼装起来。正因为这个特性,学bash不要试图一次性把所有语法都学会,而是随手记下自己在真实操作中需要用到的那些片段。今天部署服务时写一个判断进程是否在运行的脚本,明天日志清理时写一个定时备份任务,用到的知识点就这么一点一点积累起来了。这篇介绍覆盖的也就是最基础的那一层——变量怎么存、引号怎么用、判断循环怎么写、脚本怎么跑起来。把这些搞利索,往后去看那些复杂的bash开源脚本,至少能读懂它们在干什么,而不是望而生畏。