本篇内容带大家动手实践一个 Shell 脚本,实现“文件检查与拷贝”功能。在这个过程中,咱们能顺便摸清不少 Shell 脚本的常用语法。

如果你习惯了手动 ctrl+c、ctrl+v 来备份文件,不妨看看脚本自动化能带来的效率提升。在 云栈社区 上,经常能看到大家分享各种运维提效的实战经验,这种文件检查与同步的脚本就是一个很典型的例子。
1 功能说明
在 Linux 系统中,要实现这样一个 Shell 脚本:把一个源目录中的所有文件(包含子目录下的),同步到另一个指定目录。具体要求如下:
- 拷贝前,先检测两个目录中同名文件的 MD5 值,仅拷贝 MD5 不一致的文件。
- 如果需要拷贝,先汇总并打印待拷贝的文件数量、可能缺失的子目录数量,并询问用户是否执行。
- 用户确认后,再执行拷贝,并输出详细的拷贝操作信息。
2 脚本实现
下面我们分模块来拆解这个脚本。
2.1 源目录与目标目录
SRC_DIR="./curl-8.15.0" # 源目录路径
DEST_DIR="./curl-8.15.0-test2" # 目标目录路径
# 确保目录路径不以斜杠结尾,避免路径处理问题
SRC_DIR=${SRC_DIR%/}
DEST_DIR=${DEST_DIR%/}
# 检查源目录是否存在
if [ ! -d "$SRC_DIR" ]; then
echo "错误:源目录 $SRC_DIR 不存在!"
exit 1
fi
# 检查目标目录,不存在则创建
if [ ! -d "$DEST_DIR" ]; then
echo "目标目录 $DEST_DIR 不存在,正在创建..."
mkdir -p "$DEST_DIR" || { echo "创建目标目录失败!"; exit 1; }
fi
2.2 关键语法点拆解
2.2.1 确保目录路径不以斜杠结尾
移除变量 SRC_DIR 末尾可能存在的斜杠 /,避免后续拼接路径时出现重复的斜杠。
SRC_DIR=${SRC_DIR%/}
其语法为:
${变量%模式}
这会从变量值的末尾开始匹配“模式”,并删除最短的匹配部分(这里用到的模式就是那个斜杠)。
- 如果
SRC_DIR 的值本身不带斜杠(如 src),则该操作不改变原变量。
- 如果
SRC_DIR 的值带斜杠(如 src/ 或 ./src/),则会删除末尾的斜杠,变成 src 或 ./src。
2.2.2 检查目录是否存在
在进行文件检查前,需要先检查对应的目录是否存在。
if [ ! -d "$SRC_DIR" ]; then
#...
fi
在 Shell 脚本中,-d 是一个文件测试运算符,用于检查指定路径是否为一个目录(directory)。类似的常用文件测试运算符还有:
-f:检查是否为普通文件(不是目录)。
-e:检查路径是否存在(无论文件还是目录)。
-r:检查是否有读权限。
-w:检查是否有写权限。
再来看下 if [ ! -d "$SRC_DIR" ] 的语法:
[ ] 是 Shell 的条件判断语法。
! 表示取反。
-d "$SRC_DIR" 检查变量 $SRC_DIR 对应的路径是否为一个存在的目录。
2.3 统计源目录的总文件数和总目录数
这里使用 Shell 脚本中的命令替换(Command Substitution)语法,来计算指定目录下的普通文件总数。
# 统计源目录的总文件数和总目录数
TOTAL_FILES=$(find "$SRC_DIR" -type f | wc -l)
TOTAL_DIRS=$(find "$SRC_DIR" -type d | wc -l)
# 减去源目录本身
TOTAL_DIRS=$((TOTAL_DIRS - 1))
echo "源目录总文件数: $TOTAL_FILES"
echo "源目录总目录数: $TOTAL_DIRS"
具体解释下这句核心命令:
TOTAL_FILES=$(find "$SRC_DIR" -type f | wc -l)
find 是用于查找文件和目录的命令。
"$SRC_DIR" 是查找的起始目录(使用变量引用,双引号处理路径中的空格)。
-type f 是 find 的参数,指定只查找普通文件(排除目录、链接等)。除了 f 参数,还可以有:
d:目录(directory)。
l:符号链接(symbolic link)。
b:块设备文件(block special file),通常指存储设备(如硬盘分区)。
c:字符设备文件(character special file),通常指输入输出设备(如键盘、终端)。
p:管道文件(named pipe/FIFO),用于进程间通信的特殊文件。
s:套接字文件(socket),用于网络或进程间通信的特殊文件。
| 管道符,将前一个命令(find)的输出作为后一个命令(wc -l)的输入。
wc 是用于统计的命令(Word Count),后面的参数可以为:
-l 参数表示统计行数(每行对应一个文件路径)。
-w 单词数。
-c 字节数。
-m 字符数。
$(...) 是命令替换语法,将括号内命令的输出结果作为字符串返回,并赋值给前面的 TOTAL_FILES 变量。
2.4 通过 MD5 对比文件是否一样
# 创建临时文件存储需要处理的项目
TMP_FILE=$(mktemp)
# 递归遍历源目录下所有文件,使用临时文件解决子shell变量问题
find "$SRC_DIR" -type f | while read -r src_file; do
# 计算相对路径
rel_path="${src_file#$SRC_DIR/}"
dest_file="$DEST_DIR/$rel_path"
dest_dir=$(dirname "$dest_file")
# 计算源文件MD5
src_md5=$(md5sum "$src_file" | awk '{print $1}')
# 计算目标文件MD5(如果存在)
if [ -f "$dest_file" ]; then
dest_md5=$(md5sum "$dest_file" | awk '{print $1}')
else
dest_md5="不存在"
fi
# 比较MD5,不同则记录到临时文件
if [ "$src_md5" != "$dest_md5" ]; then
echo "MD5不同: $rel_path"
echo " 源文件: $src_md5"
echo " 目标文件: $dest_md5"
echo "----------------------------------"
# 只记录不存在的目录
if [ ! -d "$dest_dir" ]; then
echo "DIR:$dest_dir" >> "$TMP_FILE"
fi
# 记录需要复制的文件
echo "FILE:$src_file:$dest_file:$rel_path" >> "$TMP_FILE"
fi
done
# 从临时文件读取数据并去重
NEED_CREATE_DIRS=($(grep "^DIR:" "$TMP_FILE" | sort -u | cut -d: -f2-))
NEED_COPY_FILES=($(grep "^FILE:" "$TMP_FILE" | sort -u))
# 删除临时文件
rm -f "$TMP_FILE"
2.4.1 创建临时文件
TMP_FILE=$(mktemp)
mktemp:一个专门用于创建临时文件的命令,它会在系统的临时目录(通常是 /tmp)中生成一个唯一的临时文件,并返回该文件的完整路径。
$(...):命令替换语法,将 mktemp 命令的输出结果,即临时文件的路径,赋值给 TMP_FILE 变量。
2.4.2 遍历每个文件
find "$SRC_DIR" -type f | while read -r src_file; do
#...
done
find "$SRC_DIR" -type f 用于查找 $SRC_DIR 目录下所有的普通文件(-type f),并输出每个文件的完整路径(一行一个路径)。
while 循环:持续读取输入内容,直到没有更多内容为止。
read -r src_file:从输入中读取一行内容,并将其保存到变量 src_file 中。-r 选项用于防止 read 命令对输入中的反斜杠 \ 进行转义,保证路径的完整性。
2.4.3 计算路径
从源目录文件的完整路径中提取出相对路径,然后根据目标位置,组成目标文件的路径。
rel_path="${src_file#$SRC_DIR/}"
dest_file="$DEST_DIR/$rel_path"
dest_dir=$(dirname "$dest_file")
${变量#模式},从变量值的开头开始匹配“模式”,并删除最短的匹配部分。
src_file,存储着文件的完整路径,例如 /home/user/src/docs/readme.txt。
$SRC_DIR/,作为匹配的前缀模式,例如 /home/user/src/。
rel_path,最终得到 docs/readme.txt。
$DEST_DIR/$rel_path,拼接成目标文件的路径,例如 /home/user/src2/docs/readme.txt。
dirname,从文件路径中提取其所在的目录部分,例如 /home/user/src2/docs。
2.4.4 计算 MD5
# 计算源文件MD5
src_md5=$(md5sum "$src_file" | awk '{print $1}')
# 计算目标文件MD5(如果存在)
if [ -f "$dest_file" ]; then
dest_md5=$(md5sum "$dest_file" | awk '{print $1}')
else
dest_md5="不存在"
fi
解释一下这句:
md5sum "$src_file" | awk '{print $1}'
md5sum 是一个计算文件 MD5 哈希值的命令,能生成一个唯一标识文件内容的 32 位十六进制字符串。
"$src_file" 是要计算哈希值的文件路径。
- 该命令的输出格式通常是:
[MD5值] [文件名]。
awk 是文本处理工具,$1 表示取行中的第一个字段,即 MD5 的值。
2.4.5 比较 MD5
if [ "$src_md5" != "$dest_md5" ]; then
echo "MD5不同: $rel_path"
echo " 源文件: $src_md5"
echo " 目标文件: $dest_md5"
echo "----------------------------------"
# 只记录不存在的目录
if [ ! -d "$dest_dir" ]; then
echo "DIR:$dest_dir" >> "$TMP_FILE"
fi
# 记录需要复制的文件
echo "FILE:$src_file:$dest_file:$rel_path" >> "$TMP_FILE"
fi
解释下这句:
echo "DIR:$dest_dir" >> "$TMP_FILE"
echo 用于输出字符串。
"DIR:$dest_dir" 是要输出的内容,$dest_dir 是目标目录路径。
>> 追加重定向运算符,用于将前面命令的输出追加到指定文件的末尾。
"$TMP_FILE" 被写入的文件路径,之前用 mktemp 创建的临时文件。
最终写入 TMP_FILE 中的需要复制的目录的信息如:DIR:./curl-8.15.0-test2/include/curl。
类似的,最终写入 TMP_FILE 中的需要复制的文件的信息如:FILE:./curl-8.15.0/CMakeLists.txt:./curl-8.15.0-test2/CMakeLists.txt:CMakeLists.txt。
2.4.6 从临时文件读取数据
# 从临时文件读取数据并去重
NEED_CREATE_DIRS=($(grep "^DIR:" "$TMP_FILE" | sort -u | cut -d: -f2-))
NEED_COPY_FILES=($(grep "^FILE:" "$TMP_FILE" | sort -u))
# 删除临时文件
rm -f "$TMP_FILE"
解释下第一句管道命令链:
grep "^DIR:" "$TMP_FILE":筛选出类似 DIR:/home/user/output/utils 这样的行。
grep 用于从文件中筛选匹配特定模式的行。
^DIR: 是匹配模式,^ 表示行首,即筛选所有以 DIR: 开头的行。
sort -u:对筛选出的行进行排序,同时去重(unique),确保相同的目录路径只保留一次。
cut -d: -f2-:比如将 DIR:/home/user/output/utils 处理为 /home/user/output/utils。
cut 用于从行中提取指定部分。
-d: 指定分隔符(delimiter)为冒号 :。
-f2- 表示提取从第 2 个字段(field)开始到行尾的内容,从而去掉前面的 DIR: 前缀。
2.5 确认是否复制
# 检查是否有需要处理的内容
if [ ${#NEED_CREATE_DIRS[@]} -eq 0 ] && [ ${#NEED_COPY_FILES[@]} -eq 0 ]; then
echo "=== 所有文件MD5一致,无需复制 ==="
exit 0
fi
# 显示统计信息并确认,附加源目录总数量
echo -e "\n=== 准备操作汇总 ==="
echo "需要创建的目录数: ${#NEED_CREATE_DIRS[@]} (共 $TOTAL_DIRS 个目录)"
echo "需要复制的文件数: ${#NEED_COPY_FILES[@]} (共 $TOTAL_FILES 个文件)"
read -p "是否继续执行操作?(y/n):" confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "操作已取消"
exit 0
fi
if [ ${#NEED_CREATE_DIRS[@]} -eq 0 ]:判断 NEED_CREATE_DIRS 数组的长度是否为 0。${#数组名[@]} 表示返回数组中元素的数量,-eq 是 Shell 中的比较运算符,表示等于(equal)。
read -p "是否继续执行操作?(y/n):" confirm:用于获取用户输入的交互命令。
read:Shell 中用于从标准输入读取用户输入的命令。
-p:指定一个提示信息(prompt)。
confirm:变量名,用于存储用户输入的内容。
2.6 复制有变化的文件
# 创建目录
for dir in "${NEED_CREATE_DIRS[@]}"; do
echo "创建目录: $dir"
mkdir -p "$dir" || echo "警告:创建目录 $dir 失败!"
done
# 复制文件
total=${#NEED_COPY_FILES[@]}
current=0
for item in "${NEED_COPY_FILES[@]}"; do
current=$((current + 1))
# 解析数据
src_file=$(echo "$item" | cut -d: -f2)
dest_file=$(echo "$item" | cut -d: -f3)
rel_path=$(echo "$item" | cut -d: -f4)
# 显示进度和文件名
echo "[$current/$total] 复制: $rel_path"
# 执行复制
cp -pv "$src_file" "$dest_file" || echo "警告:复制 $rel_path 失败!"
done
2.6.1 创建目录
for dir in "${NEED_CREATE_DIRS[@]}"; do
echo "创建目录: $dir"
mkdir -p "$dir" || echo "警告:创建目录 $dir 失败!"
done
for dir in "${NEED_CREATE_DIRS[@]}":for 循环会遍历 NEED_CREATE_DIRS 数组中的每个元素,并赋值给 dir。
mkdir -p "$dir" || echo "警告:创建目录 $dir 失败!" :
mkdir 是创建目录的命令。
-p 选项表示递归创建,如果父目录不存在会自动创建。
|| 是逻辑“或”运算符,左侧命令执行失败时,才会执行右侧命令。
2.6.2 需要复制的数量
total=${#NEED_COPY_FILES[@]}
${#数组名[@]} 是 Shell 中获取数组长度的语法。
2.6.3 复制文件
current=0
for item in "${NEED_COPY_FILES[@]}"; do
current=$((current + 1))
# 解析数据
src_file=$(echo "$item" | cut -d: -f2)
dest_file=$(echo "$item" | cut -d: -f3)
rel_path=$(echo "$item" | cut -d: -f4)
# 显示进度和文件名
echo "[$current/$total] 复制: $rel_path"
# 执行复制
cp -pv "$src_file" "$dest_file" || echo "警告:复制 $rel_path 失败!"
done
src_file=$(echo "$item" | cut -d: -f2) 从字符串中以冒号 : 为分隔符,提取第 2 个字段。
cp -pv "$src_file" "$dest_file":
cp:用于复制文件或目录。
-p 选项,复制文件时保留源文件的元数据信息,包括文件的权限模式、修改时间等。
-v 选项,显示详细的复制过程信息,输出类似 '源文件' -> '目标文件' 的日志。
3 完整的脚本
#!/bin/bash
SRC_DIR="./curl-8.15.0" # 源目录路径
DEST_DIR="./curl-8.15.0-test2" # 目标目录路径
# 确保目录路径不以斜杠结尾,避免路径处理问题
SRC_DIR=${SRC_DIR%/}
DEST_DIR=${DEST_DIR%/}
# 检查源目录是否存在
if [ ! -d "$SRC_DIR" ]; then
echo "错误:源目录 $SRC_DIR 不存在!"
exit 1
fi
# 检查目标目录,不存在则创建
if [ ! -d "$DEST_DIR" ]; then
echo "目标目录 $DEST_DIR 不存在,正在创建..."
mkdir -p "$DEST_DIR" || { echo "创建目标目录失败!"; exit 1; }
fi
echo "源目录: $SRC_DIR"
echo "目标目录: $DEST_DIR"
# 统计源目录的总文件数和总目录数
TOTAL_FILES=$(find "$SRC_DIR" -type f | wc -l)
TOTAL_DIRS=$(find "$SRC_DIR" -type d | wc -l)
# 减去源目录本身
TOTAL_DIRS=$((TOTAL_DIRS - 1))
echo "源目录总文件数: $TOTAL_FILES"
echo "源目录总目录数: $TOTAL_DIRS"
# 创建临时文件存储需要处理的项目
TMP_FILE=$(mktemp)
echo "=== 开始MD5校验(包括子目录) ==="
# 递归遍历源目录下所有文件,使用临时文件解决子shell变量问题
find "$SRC_DIR" -type f | while read -r src_file; do
# 计算相对路径
rel_path="${src_file#$SRC_DIR/}"
dest_file="$DEST_DIR/$rel_path"
dest_dir=$(dirname "$dest_file")
# 计算源文件MD5
src_md5=$(md5sum "$src_file" | awk '{print $1}')
# 计算目标文件MD5(如果存在)
if [ -f "$dest_file" ]; then
dest_md5=$(md5sum "$dest_file" | awk '{print $1}')
else
dest_md5="不存在"
fi
# 比较MD5,不同则记录到临时文件
if [ "$src_md5" != "$dest_md5" ]; then
echo "MD5不同: $rel_path"
echo " 源文件: $src_md5"
echo " 目标文件: $dest_md5"
echo "----------------------------------"
# 只记录不存在的目录
if [ ! -d "$dest_dir" ]; then
echo "DIR:$dest_dir" >> "$TMP_FILE"
fi
# 记录需要复制的文件
echo "FILE:$src_file:$dest_file:$rel_path" >> "$TMP_FILE"
fi
done
# 从临时文件读取数据并去重
NEED_CREATE_DIRS=($(grep "^DIR:" "$TMP_FILE" | sort -u | cut -d: -f2-))
NEED_COPY_FILES=($(grep "^FILE:" "$TMP_FILE" | sort -u))
# 删除临时文件
rm -f "$TMP_FILE"
# 检查是否有需要处理的内容
if [ ${#NEED_CREATE_DIRS[@]} -eq 0 ] && [ ${#NEED_COPY_FILES[@]} -eq 0 ]; then
echo "=== 所有文件MD5一致,无需复制 ==="
exit 0
fi
# 显示统计信息并确认,附加源目录总数量
echo -e "\n=== 准备操作汇总 ==="
echo "需要创建的目录数: ${#NEED_CREATE_DIRS[@]} (共 $TOTAL_DIRS 个目录)"
echo "需要复制的文件数: ${#NEED_COPY_FILES[@]} (共 $TOTAL_FILES 个文件)"
read -p "是否继续执行操作?(y/n):" confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "操作已取消"
exit 0
fi
# 创建目录
echo -e "\n=== 开始创建目录 ==="
for dir in "${NEED_CREATE_DIRS[@]}"; do
echo "创建目录: $dir"
mkdir -p "$dir" || echo "警告:创建目录 $dir 失败!"
done
# 复制文件
echo -e "\n=== 开始复制文件 ==="
total=${#NEED_COPY_FILES[@]}
current=0
for item in "${NEED_COPY_FILES[@]}"; do
current=$((current + 1))
# 解析数据
src_file=$(echo "$item" | cut -d: -f2)
dest_file=$(echo "$item" | cut -d: -f3)
rel_path=$(echo "$item" | cut -d: -f4)
# 显示进度和文件名
echo "[$current/$total] 复制: $rel_path"
# 执行复制
cp -pv "$src_file" "$dest_file" || echo "警告:复制 $rel_path 失败!"
done
echo -e "\n=== 操作完成 ==="
4 测试结果
这里用 curl 的源码目录进行测试。拷贝一份到 curl-8.15.0-test2 目录,然后删除其中一些文件,模拟目录不一致的场景。

可以看到,脚本检测到了两个目录里存在不一致的文件,在用户确认执行后,顺利地完成了文件拷贝。
再次执行相同的脚本,验证同步效果。

再次运行的结果显示,所有文件都已完全一致。
5 总结
本篇通过“文件检查与拷贝”这个实例,不仅串联了 find、md5sum、grep、cut 等多个 Shell 命令,还实践了条件判断、循环、数组以及临时文件处理等核心编程语法。这种将基础命令组合成强大自动化工具的能力,正是 Shell 脚本的精髓所在。