1. 从“手忙脚乱”到“一键搞定”为什么你需要Shell脚本如果你在Linux或macOS的终端里曾经为了完成一个任务把同一串命令敲了十遍或者你需要在Windows的PowerShell里每天重复执行一系列固定的文件整理操作那么你肯定有过这样的念头有没有办法让电脑记住这些步骤下次点一下就能自动完成这就是Shell脚本要解决的问题。简单来说Shell脚本就是一个包含了多条Shell命令的文本文件。Shell你可以把它理解为你和操作系统内核真正干活的“大脑”之间的“翻译官”和“传令兵”。你输入ls、cd、cp这些命令Shell负责接收、解释然后告诉内核去执行。而脚本就是你把要给“传令兵”的一连串指令事先写在一张“任务清单”上下次直接把清单交给它它就能按顺序逐一执行。看看那些热搜词吧npm、node、claude命令找不到本质是环境路径问题一个脚本就能帮你检查和设置adb shell反复安装APK、重启到bootloader是安卓开发和测试的日常写成脚本能省下大量重复劳动用shell重命名文件、做开机自启脚本更是自动化运维和系统管理的家常便饭。甚至那些看起来复杂的青龙脚本、碧蓝航线脚本其底层逻辑也是通过脚本模拟用户操作实现自动化。脚本的本质是把零散、重复的手工操作固化、序列化最终实现自动化。很多人觉得脚本编程是运维或开发工程师的专利其实不然。无论你是数据分析师需要定期处理日志是设计师需要批量转换图片格式还是普通用户想自动备份重要文档Shell脚本都能成为你的得力助手。它入门门槛低直接使用你已经在敲的命令它威力巨大几行代码就能完成繁琐工作。这篇文章我就以一个十几年老鸟的视角带你从零开始亲手编写你的第一个Shell脚本并理解其背后的每一个细节。我们不求面面俱到但求你能亲手写出一个真正有用、能解决实际问题的脚本并理解每一步“为什么”要这么做。2. 编写你的第一个脚本从“Hello World”到可执行文件让我们暂时忘掉那些复杂的自动化场景就从最经典的“Hello World”开始。这个过程看似简单却包含了编写Shell脚本所有最核心的步骤和概念。2.1 创建脚本文件与Shebang首先打开你的终端Linux/macOS的Terminal或Windows的Git Bash、WSL。找一个你常用的目录比如家目录下的scripts文件夹如果没有可以用mkdir ~/scripts创建一个。进入这个目录cd ~/scripts。现在我们来创建第一个脚本文件。在Shell的世界里脚本文件通常以.sh作为扩展名但这并不是强制要求只是一个良好的约定让人一眼就知道这是个Shell脚本。使用nano、vim或者你喜欢的任何文本编辑器比如gedit、code .创建一个新文件nano hello_world.sh文件的第一行必须是这样一个特殊的注释#!/bin/bash这一行被称为Shebang或 Hashbang。它的作用是告诉系统当直接执行这个脚本文件时应该使用哪个解释器来运行它。#!是固定开头后面的/bin/bash指定了Bash Shell的路径。为什么是/bin/bash因为在绝大多数Linux发行版和macOS上Bash是默认的、功能最丰富的Shell。你也可以写#!/bin/sh但sh通常是dash的符号链接它是Bash的一个精简版某些Bash特有的语法在sh下可能无法运行。为了兼容性和功能的完整性我们通常直接指定bash。注意在Windows的PowerShell中编写脚本.ps1文件其Shebang可能是#!/usr/bin/env pwsh原理是类似的都是指定解释器。但本文聚焦于Unix-like环境下的Bash Shell脚本这是应用最广泛的场景。2.2 编写脚本内容与赋予执行权限在Shebang行之后你就可以开始写命令了。我们的第一个命令是echo Hello, Shell Script World!echo命令的作用就是将后面的字符串输出到终端屏幕。现在你的hello_world.sh文件内容应该是这样的#!/bin/bash echo Hello, Shell Script World!保存并退出编辑器在nano中是CtrlX然后按Y确认再按回车。现在尝试直接运行它./hello_world.sh。你很可能会看到这样的错误信息bash: ./hello_world.sh: Permission denied。这是因为在Unix-like系统中一个新创建的文件默认只有读写权限没有执行x权限。你必须显式地告诉系统“这个文件是可以作为程序来执行的。”这就需要用到chmod命令来修改文件权限chmod x hello_world.shchmod是“change mode”的缩写x表示为文件增加执行x权限。执行完这条命令后再用ls -l hello_world.sh查看一下你会看到文件权限变成了-rwxr-xr-x开头的-代表是普通文件后面三组rwx分别代表文件所有者、所属组和其他用户拥有读r、写w、执行x权限。现在脚本已经具备了被执行的资格。2.3 运行脚本的三种方式及其区别现在再次运行./hello_world.sh。这次你应该能看到终端上输出了“Hello, Shell Script World!”这行字。恭喜你你的第一个Shell脚本成功运行了这里./代表当前目录。系统默认只在PATH环境变量指定的目录里寻找可执行文件当前目录通常不在PATH中这是出于安全考虑所以必须加上./来指明路径。除了这种方式还有两种常见的运行方式使用指定的解释器执行bash hello_world.sh。这种方式直接调用bash解释器并把脚本文件名作为参数传给它。此时脚本文件本身不需要有执行权限x因为bash命令本身是可执行的它只是去读取并解释hello_world.sh这个文本文件的内容。Shebang行在这种情况下会被忽略因为解释器已经由你在命令行显式指定了。通过source或点命令执行source hello_world.sh或. hello_world.sh。这种方式的特点是脚本会在当前Shell进程中执行而不是新建一个子Shell。这意味着脚本中设置的变量、改变的目录cd等在执行完后会影响到你当前的终端环境。而前两种方式./script.sh和bash script.sh都会启动一个子Shell来执行脚本脚本内部的环境变更不会“污染”父Shell。通常我们运行独立任务脚本用前两种而要加载环境配置比如.bashrc时才会用source。理解这三种方式的区别对于后续脚本调试和环境管理非常重要。一个常见的坑是在脚本里用export设置了一个环境变量然后用./script.sh运行结果发现当前终端里这个变量不存在。这就是因为变量被设置在了子Shell中子Shell结束变量就消失了。如果想让它生效就需要用source script.sh来运行。3. 让脚本变得有用变量、参数与条件判断一个只会说“Hello World”的脚本显然没什么用。要让脚本能处理实际任务我们必须让它能接收外部信息并能根据情况做出不同反应。这就涉及到变量、传递参数和条件判断。3.1 使用变量存储信息变量就像脚本里的临时记事本可以存放各种信息比如文件名、路径、计数器等。在Bash中定义变量很简单#!/bin/bash # 定义一个变量等号两边不能有空格 my_nameShell Learner backup_dir/home/user/backups file_count10 # 使用变量时需要在变量名前加上美元符号 $ echo Hello, $my_name echo Backup directory is: $backup_dir echo We have $file_count files to process. # 也可以把命令的执行结果赋值给变量 current_time$(date) # 方法一推荐更清晰 current_timedate # 方法二反引号旧式写法 echo Current time is: $current_time # 系统预定义的特殊变量 echo This script is named: $0 # 脚本本身的文件名 echo My process ID is: $$ # 当前脚本的进程ID这里有几个关键点赋值时等号两边不能有空格varvalue是正确的var value会被解释为运行命令var并带两个参数和value从而报错。引用变量要加$echo $my_name。命令替换$(command)或command可以将命令的输出捕获并赋值给变量。$(...)是更现代、更推荐的方式因为它可以嵌套且引号处理更清晰。变量名规范通常使用小写字母和下划线如backup_dir。全大写的变量名通常用于环境变量或脚本内部常量。3.2 接收命令行参数脚本如何知道用户想处理哪个文件呢通过命令行参数。Bash提供了一系列特殊变量来获取这些参数#!/bin/bash # 假设脚本名为 process_file.sh 这样调用./process_file.sh file1.txt file2.txt /output echo Script name: $0 # 输出./process_file.sh echo First argument: $1 # 输出file1.txt echo Second argument: $2 # 输出file2.txt echo Third argument: $3 # 输出/output echo All arguments: $ # 输出file1.txt file2.txt /output 每个参数作为独立单词 echo All arguments as one string: $* # 输出file1.txt file2.txt /output 所有参数合并成一个字符串 echo Number of arguments: $# # 输出3$1,$2,$3... 分别对应命令行上的第一、第二、第三个参数。$0是脚本名本身。$#表示参数个数。$和$*都代表所有参数但在循环处理时$能正确处理带空格的参数如my file.txt而$*会将所有参数视为一个整体所以在需要遍历参数时总是使用$带双引号。一个实用的例子备份指定文件到指定目录。#!/bin/bash # backup.sh # 检查参数数量是否正确如果不正确则提示用法并退出 if [ $# -ne 2 ]; then echo Usage: $0 source_file backup_directory exit 1 # 非0退出码通常表示错误 fi source_file$1 backup_dir$2 backup_file${backup_dir}/$(date %Y%m%d_%H%M%S)_$(basename $source_file) # 检查源文件是否存在 if [ ! -f $source_file ]; then echo Error: Source file $source_file does not exist. exit 2 fi # 检查备份目录是否存在不存在则创建 if [ ! -d $backup_dir ]; then mkdir -p $backup_dir echo Created backup directory: $backup_dir fi # 执行备份 cp $source_file $backup_file if [ $? -eq 0 ]; then echo Backup successful: $backup_file else echo Backup failed! exit 3 fi这个脚本展示了如何用$1、$2获取参数并用$#检查参数个数。$?是一个特殊的变量它保存了上一条命令的退出状态码。0通常表示成功非0表示失败。cp命令成功后$?就是0所以我们用if [ $? -eq 0 ]来判断备份是否成功。3.3 使用条件判断控制流程上面的脚本中已经用到了if语句。Shell脚本中的条件判断主要依靠test命令或其等价形式[ ... ]、[[ ... ]]。[ ... ]是test命令的另一种写法方括号内部两端必须有空格。功能较基础。[[ ... ]]Bash的增强版测试命令支持更强大的字符串匹配如、~正则且逻辑更清晰是更推荐的方式。常见的测试条件测试类型[ ]写法示例[[ ]]写法示例说明字符串比较[ $str1 $str2 ][[ $str1 $str2 ]]字符串相等[ $str1 ! $str2 ][[ $str1 ! $str2 ]]字符串不等[ -z $str ][[ -z $str ]]字符串长度为0空[ -n $str ][[ -n $str ]]字符串长度非0整数比较[ $a -eq $b ](( a b ))等于推荐用(( ))[ $a -ne $b ](( a ! b ))不等于[ $a -gt $b ](( a b ))大于[ $a -lt $b ](( a b ))小于文件测试[ -f file ][[ -f file ]]存在且是普通文件[ -d dir ][[ -d dir ]]存在且是目录[ -e path ][[ -e path ]]文件/目录存在[ -r file ][[ -r file ]]文件可读[ -w file ][[ -w file ]]文件可写[ -x file ][[ -x file ]]文件可执行逻辑组合[ cond1 -a cond2 ][[ cond1 cond2 ]]逻辑与[ cond1 -o cond2 ][[ cond1 || cond2 ]]逻辑或[ ! cond1 ][[ ! cond1 ]]逻辑非重要提示在使用[ ]时变量引用务必加上双引号如[ -f $filename ]。否则如果$filename包含空格或为空命令会解析错误。[[ ]]在这方面更安全但养成加引号的习惯总是好的。让我们用if-elif-else结构写一个更复杂的例子根据文件类型做不同处理#!/bin/bash # file_handler.sh target$1 if [[ -z $target ]]; then echo Error: Please specify a file or directory. exit 1 fi if [[ -d $target ]]; then echo $target is a directory. echo Listing contents: ls -la $target elif [[ -f $target ]]; then echo $target is a regular file. echo File size: $(du -h $target | cut -f1) echo Last modified: $(date -r $target) # 判断文件是否可执行 if [[ -x $target ]]; then echo This file is executable. fi else echo $target is neither a regular file nor a directory (maybe a device file, symlink, etc.). fi这个脚本首先检查是否提供了参数-z判断是否为空然后依次判断目标是目录、普通文件还是其他类型并执行相应的操作。elif是else if的缩写用于连接多个条件分支。4. 自动化核心循环与函数当我们需要对多个项目执行相同操作或者把一段常用代码封装起来时循环和函数就登场了。它们是实现复杂自动化的基石。4.1 遍历与重复for循环与while循环for循环常用于遍历一个已知的列表比如文件集合、参数列表等。#!/bin/bash # 示例1遍历固定字符串列表 echo Processing fruits: for fruit in apple banana cherry; do echo I like $fruit done # 示例2遍历当前目录下所有 .txt 文件这是处理通配符展开 echo Processing text files: for file in *.txt; do # 防止没有.txt文件时循环会执行一次file的值就是*.txt if [[ -f $file ]]; then echo Found text file: $file wc -l $file # 统计文件行数 fi done # 示例3遍历所有命令行参数这是处理参数最安全的方式 echo Processing all arguments: for arg in $; do echo Argument: $arg done # 示例4C语言风格的for循环用于数字序列 for (( i1; i5; i )); do echo Count: $i donewhile循环则用于当某个条件为真时持续执行循环体。它常用来读取文件内容或者等待某个事件发生。#!/bin/bash # 示例1计数器 count1 while [[ $count -le 5 ]]; do echo Count is $count ((count)) # 使用双括号进行算术运算相当于 count$((count1)) done # 示例2逐行读取文件经典用法 config_fileapp.conf if [[ -f $config_file ]]; then echo Reading config file: line_number1 while IFS read -r line; do # 跳过空行和以#开头的注释行 if [[ -n $line $line ! \#* ]]; then echo Line $line_number: $line fi ((line_number)) done $config_file # 输入重定向将文件内容喂给while循环 fi # 示例3从命令输出中读取 echo Monitoring process list for bash (CtrlC to stop): while true; do # 无限循环 if pgrep bash /dev/null; then echo $(date): Bash process is running. else echo $(date): No bash process found. fi sleep 2 # 等待2秒 donewhile IFS read -r line是一个需要重点理解的模式IFS将内部字段分隔符设为空防止read命令去掉行首行尾的空白字符。-r选项防止反斜杠\被解释为转义字符。done $config_file将文件内容重定向到while循环的标准输入使得read命令能从文件中读取。4.2 封装与复用定义与使用函数当一段代码需要被多次使用或者为了提升脚本的可读性和结构性我们就应该把它封装成函数。#!/bin/bash # function_demo.sh # 定义一个无参数的函数打印日志带时间戳 log_message() { local timestamp$(date %Y-%m-%d %H:%M:%S) echo [$timestamp] $1 # $1 在这里是函数的第一个参数不是脚本的参数 } # 定义一个有返回值的函数检查URL是否可访问 check_url() { local url$1 # 使用curl静默模式只返回HTTP状态码 if curl --output /dev/null --silent --head --fail --max-time 5 $url; then return 0 # 成功返回0 else return 1 # 失败返回非0 fi } # 定义一个计算函数利用命令替换返回值 calculate_sum() { local a$1 local b$2 local sum$((a b)) echo $sum # 通过echo输出结果调用者用$(...)捕获 } # 使用函数 log_message Script started. url_to_checkhttps://www.example.com if check_url $url_to_check; then log_message URL $url_to_check is accessible. else log_message ERROR: URL $url_to_check is not accessible. fi result$(calculate_sum 10 20) log_message The sum of 10 and 20 is: $result log_message Script finished.函数的关键点定义function_name() { ... }或function function_name { ... }。前者是POSIX标准更通用。参数函数内部使用$1,$2...来获取传入的参数。注意这和脚本主体的$1、$2是独立的。局部变量使用local关键字在函数内声明变量可以避免污染全局变量空间。这是一个非常好的习惯。返回值Shell函数通过return语句返回一个退出状态码0-2550表示成功。这通常用于表示函数执行成败。如果需要返回一个字符串或数值结果常用的方法是在函数内使用echo输出然后在调用处用命令替换$(...)捕获如上面的calculate_sum函数。调用直接写函数名参数跟在后面用空格分隔。函数让脚本变得模块化。你可以把常用的功能比如日志记录、错误检查、发送通知等都写成函数库一个单独的.sh文件然后在主脚本中用source functions_lib.sh来引入大大提升代码的复用性和可维护性。5. 实战构建一个实用的日志文件清理脚本现在让我们把前面学到的所有知识——变量、参数、条件判断、循环、函数——组合起来编写一个真正有用的脚本一个自动清理旧日志文件的工具。这是系统管理中的常见需求。5.1 需求分析与脚本设计假设我们有一个应用程序它每天都会在/var/log/myapp/目录下生成日志文件命名格式为myapp-YYYY-MM-DD.log。我们希望写一个脚本能够删除指定目录下超过N天的日志文件。可以指定要匹配的文件名模式如*.log或myapp-*.log。在真正删除前最好能先预览一下哪些文件会被删除“试运行”模式。脚本应该健壮能处理目录不存在、权限不足等情况并给出清晰的日志。我们将设计脚本的调用方式如下./clean_old_logs.sh /var/log/myapp myapp-*.log 30参数1要清理的目录。 参数2文件名匹配模式。 参数3保留最近多少天的文件。加上一个可选的-n或--dry-run参数来启用试运行模式。5.2 逐步实现脚本首先创建脚本文件clean_old_logs.sh并赋予执行权限。#!/bin/bash # clean_old_logs.sh - 清理指定目录下旧文件的实用脚本 # 定义颜色输出让日志更易读可选 RED\033[0;31m GREEN\033[0;32m YELLOW\033[1;33m NC\033[0m # No Color # 日志函数 log_info() { echo -e [INFO] $1 } log_warn() { echo -e ${YELLOW}[WARN]${NC} $1 } log_error() { echo -e ${RED}[ERROR]${NC} $1 } log_success() { echo -e ${GREEN}[SUCCESS]${NC} $1 } # 显示用法函数 show_usage() { echo Usage: $0 [OPTIONS] directory file_pattern days_to_keep echo echo Options: echo -n, --dry-run Preview files to be deleted without actually deleting. echo -h, --help Show this help message. echo echo Example: echo $0 /var/log/myapp \myapp-*.log\ 30 # Delete logs older than 30 days echo $0 -n /tmp \*.tmp\ 7 # Dry-run for temp files older than 7 days } # 初始化变量 DRY_RUNfalse DIRECTORY PATTERN DAYS # 解析命令行参数简单版本更复杂的可以用getopts while [[ $# -gt 0 ]]; do case $1 in -n|--dry-run) DRY_RUNtrue shift # 移除已处理的选项 ;; -h|--help) show_usage exit 0 ;; -*) log_error Unknown option: $1 show_usage exit 1 ;; *) # 第一个非选项参数是目录第二个是模式第三个是天数 if [[ -z $DIRECTORY ]]; then DIRECTORY$1 elif [[ -z $PATTERN ]]; then PATTERN$1 elif [[ -z $DAYS ]]; then DAYS$1 else log_error Too many arguments. show_usage exit 1 fi shift ;; esac done # 检查必需的参数是否提供 if [[ -z $DIRECTORY || -z $PATTERN || -z $DAYS ]]; then log_error Missing required arguments. show_usage exit 1 fi # 验证天数是否为有效数字 if ! [[ $DAYS ~ ^[0-9]$ ]]; then log_error Days to keep must be a positive integer. exit 1 fi # 检查目录是否存在且可访问 if [[ ! -d $DIRECTORY ]]; then log_error Directory $DIRECTORY does not exist or is not accessible. exit 1 fi if [[ ! -r $DIRECTORY ]]; then log_error No read permission for directory $DIRECTORY. exit 1 fi log_info Starting cleanup process... log_info Directory: $DIRECTORY log_info Pattern: $PATTERN log_info Days to keep: $DAYS if [[ $DRY_RUN true ]]; then log_warn DRY-RUN MODE ENABLED. No files will be deleted. fi # 计算截止时间戳当前时间减去DAYS天的秒数 CUTOFF_TIME$(date -d $DAYS days ago %s 2/dev/null || date -v-${DAYS}d %s 2/dev/null) # 上面命令兼容Linux (date -d) 和 macOS (date -v)。如果都失败则退出。 if [[ -z $CUTOFF_TIME ]]; then log_error Failed to calculate cutoff date. Check your date command. exit 1 fi DELETED_COUNT0 ERROR_COUNT0 # 进入目标目录处理文件 cd $DIRECTORY || { log_error Failed to cd to $DIRECTORY; exit 1; } # 使用find命令查找匹配模式且修改时间早于截止时间的文件 # -name 指定模式-mtime $DAYS 表示修改时间在$DAYS天以前-type f 只找普通文件 while IFS read -r -d $\0 file; do # 获取文件的修改时间戳 file_mtime$(stat -c %Y $file 2/dev/null || stat -f %m $file 2/dev/null) if [[ -z $file_mtime ]]; then log_warn Could not get modification time for $file. Skipping. ((ERROR_COUNT)) continue fi if [[ $file_mtime -lt $CUTOFF_TIME ]]; then if [[ $DRY_RUN true ]]; then log_info [DRY-RUN] Would delete: $file (modified: $(date -d $file_mtime)) else log_info Deleting: $file if rm -f $file; then ((DELETED_COUNT)) else log_error Failed to delete: $file ((ERROR_COUNT)) fi fi fi done (find . -maxdepth 1 -type f -name $PATTERN -print0) # 注意这里使用了进程替换 (command) 和 find 的 -print0可以安全处理包含空格或换行符的文件名。 # 返回原目录虽然不是必须但是个好习惯 cd - /dev/null # 输出总结 if [[ $DRY_RUN true ]]; then log_success Dry-run completed. Found $DELETED_COUNT file(s) that would be deleted. $ERROR_COUNT error(s). else log_success Cleanup completed. Successfully deleted $DELETED_COUNT file(s). $ERROR_COUNT error(s) encountered. fi exit $((ERROR_COUNT 0 ? 1 : 0))5.3 脚本关键点解析与避坑指南这个脚本虽然不长但包含了很多Shell脚本编程的实用技巧和容易踩坑的地方参数解析我们实现了一个简单的while/case循环来解析-n、-h这样的选项。对于更复杂的选项如带值的-d 30建议使用Bash内置的getopts命令它更标准、更强大。输入验证这是健壮脚本的生命线。我们检查了参数数量、目录是否存在、是否有读取权限、天数是否为数字。缺少这些检查脚本在异常情况下会行为诡异或直接崩溃。平台兼容性date命令计算过去时间在LinuxGNUdate和macOSBSDdate上的语法不同。我们用了||操作符尝试两种方式date -d $DAYS days ago %sLinux和date -v-${DAYS}d %smacOS。如果都失败脚本会报错退出。stat命令获取文件时间戳也存在类似的平台差异。安全地处理文件名这是最大的坑之一。如果文件名包含空格、换行符等特殊字符简单的for file in $(find ...)会出错。我们使用了find的-print0选项用NULL字符分隔文件名和while IFS read -r -d $\0循环来读取这是处理任意文件名的黄金标准。进程替换 (command)这个语法叫“进程替换”。它把command的输出作为一个临时文件描述符提供给while循环读取。这里用它来安全地传递find命令的结果。清晰的日志与状态返回脚本使用了不同颜色的日志级别INFO, WARN, ERROR, SUCCESS让运行结果一目了然。最后脚本根据是否发生错误ERROR_COUNT返回不同的退出码0成功非0失败这便于其他脚本或工具调用时判断本次清理是否完全成功。“试运行”模式通过一个DRY_RUN标志让脚本在不实际执行删除操作的情况下列出所有将要被删除的文件。这是一个非常实用的功能尤其是在执行破坏性操作前让你有机会做最后确认。你可以把这个脚本保存起来根据你的实际需求修改比如递归查找子目录可以去掉find的-maxdepth 1或者添加邮件通知功能。通过这个实战项目你不仅学会了写脚本更学会了如何思考、设计和实现一个可靠、实用的自动化工具。这才是Shell脚本编程真正的价值所在。