Shell 脚本编程

本文合并自:05-Shell脚本编程.md、1-6-Shell编程与文本处理.md(仅 Shell 编程部分)、02-服务管理与Shell环境.md(仅 Shell 环境部分)

适用环境:CentOS 6 / CentOS 7,解释器为 bash(/bin/bash)。文中凡涉及版本差异处均单独注明。

本章要点

  • 理解编程语言分类与 Shell 解析器 /bin/bash,掌握 Shebang、特殊符号、命令历史、别名与环境变量。
  • 掌握 bash 环境的构成:环境变量与自定义变量、export/env/set/unset、配置文件加载顺序、登录式与非登录式 Shell 的区别、PS1 提示符定制、命令历史与别名持久化。
  • 掌握脚本编写规范、执行方式(新开子进程执行与 source/. 本进程执行)与调试手段(bash -x、set -x、set -euo pipefail)。
  • 掌握变量的定义、更新、获取、删除,${} 的截取/替换/默认值/长度等特殊访问,以及 $$、$0、$1、$#、$*、$@、$?、$! 等内置变量。
  • 掌握 echo/printf/read 输入输出,以及引号、命令替换(` ` 与 $())、一行多条命令与逻辑运算。
  • 掌握 let、expr、(( ))、$(( ))、bc 五种运算方式与随机数 RANDOM。
  • 掌握 test/[ ]/[[ ]]/(( )) 条件判断,整型、字符串、逻辑与文件测试操作符,以及退出状态码与 exit。
  • 掌握 if/elif/else/fi、case 多路分支,for/while/until/select 循环及 break/continue,配合 seq 生成序列。
  • 掌握函数定义、调用、参数、return/exit 返回值、函数库与递归。
  • 掌握索引数组与关联数组的定义、访问、更新、删除,以及 declare 的常用选项。
  • 理解变量作用域(全局变量与函数内 local 局部变量)。
  • 掌握进阶技巧:trap 信号捕获、getopts 选项解析、here document、子 Shell 与进程替换、后台与并发、脚本加锁、日志函数、颜色输出。
  • 能综合运用以上知识编写系统巡检、批量建号、备份、日志切割、服务监控、批量探测等实战脚本。

一、Shell 概述

1.1 编程语言分类

类型 说明 代表语言
机器语言 由 0 和 1 组成的二进制指令(01010101) —
低级语言(汇编) 使用助记符,由汇编器编译成机器语言 汇编
高级语言 具备跨平台能力 C、C++、Java、Python、Shell 等

高级语言可进一步细分:

类型 说明 代表
编译型(编译器) 源码经编译器转换为对应平台的汇编或机器码 C、C++
解释型(解析器) 源码由解释器实时解析成平台指令执行 Shell、PHP、Python、JavaScript、Go
编译解释型(编译器 + 虚拟机) 先编译为中间字节码,再由虚拟机解释执行 Java、C#

Shell 属于解释型语言:不需要编译,写完即可运行,非常适合把一串运维命令固化成可复用的流程。

1.2 编程入口

Shell 脚本的编程入口就是一个文本文件:从文件第一行开始逐行解析执行。文件第一行通常用 Shebang 指定解析器:

#!/bin/bash

建议所有 Shell 脚本首行都显式指定解释器为 /bin/bash。

1.3 学习编程语言的一般流程

了解语言概述 → 编程入口 → 变量使用(数据类型)→ 操作符(操作变量)形成语句 → 流程控制(分支、循环)→ 函数(代码打包)→ 数组 → 字符串处理 → I/O 文件操作 → 系统编程(进程/线程/进程间通信)→ 网络编程(套接字)。

1.4 Shell 的作用与常见种类

Shell 是用户与内核之间的命令解释器:接收用户输入的命令,翻译成系统调用交给内核执行,再把结果返回给用户。它既是交互式的命令行环境,也是一门脚本语言。

Shell 说明
sh(Bourne Shell) 最早的 UNIX Shell,语法简单,兼容性最好;Linux 上 /bin/sh 通常是指向 bash 的软链接
bash(Bourne Again Shell) GNU 对 sh 的增强实现,Linux 默认 Shell,功能最全,本文全部以 bash 为准
csh / tcsh(C Shell) 语法接近 C 语言,交互特性丰富,脚本能力较弱
ksh(Korn Shell) 商业 UNIX 常见,兼容 sh 并吸收了 csh 的交互特性
zsh 交互体验最强(补全、主题、插件),常配合 oh-my-zsh 使用
dash 轻量 POSIX Shell,Debian/Ubuntu 的 /bin/sh 指向它,启动快但不支持 bash 扩展语法

查看系统中所有可用的合法 Shell:

cat /etc/shells

查看当前使用的 Shell 与 bash 版本:

echo $SHELL          # 当前用户登录 Shell(来自 /etc/passwd 最后一个字段)
echo $0              # 当前正在运行的 Shell 名
echo $BASH_VERSION   # bash 版本,CentOS 7 为 4.2.x

切换默认 Shell(写入 /etc/passwd 第 7 字段,永久生效):

chsh -s /bin/bash          # 修改当前用户
chsh -s /bin/bash user1    # root 修改指定用户
usermod -s /bin/bash user1 # 等价写法
chsh -l                    # 列出可用 Shell(等价于 cat /etc/shells)

临时切换只需直接输入 Shell 名(如 sh、zsh),exit 即返回原 Shell。

/sbin/nologin 也是一种"Shell",用于禁止用户登录系统,常见于服务账号。

1.5 bash 的主要特性

  • 命令补全与路径补全(Tab)。
  • 命令历史(history、!!、Ctrl+r 反向搜索)。
  • 命令别名(alias)。
  • 命令行编辑(emacs / vi 两种模式)。
  • 通配符(globbing)与花括号展开。
  • 管道、重定向与作业控制(&、jobs、fg、bg)。
  • 变量、数组、函数、流程控制等完整脚本能力。
  • 内置命令丰富(echo、cd、read、test、let、declare、source 等),执行效率高于外部命令。

1.6 bash 快捷键

bash 支持两种命令行编辑模式:emacs 模式(默认)与 vi 模式。

快捷键 作用
Ctrl + a 光标移到行首
Ctrl + e 光标移到行尾
Ctrl + d 删除光标所在字符;空行时退出终端
Ctrl + u 删除光标前的全部内容
Ctrl + k 删除光标后的全部内容
Ctrl + w 删除光标前的一个单词
Ctrl + l 清屏(等价 clear)
Ctrl + c 终止当前前台命令
Ctrl + z 把当前前台命令挂起到后台
Ctrl + r 反向搜索命令历史
Tab 命令或路径补全

临时切换为 vi 模式:

set -o vi
set -o emacs    # 切回默认

1.7 特殊符号

符号 含义与用法
* 通配符,匹配任意长度任意字符
? 通配符,匹配任意单个字符
\ 转义符,去除其后单个字符的特殊含义
" 双引号(弱引用),批量转义大部分特殊字符,但 $、`、\、! 等仍保留特殊含义
' 单引号(强引用),转义内部所有特殊字符,所见即所得
` ` | 反引号,把内容当作命令执行并以标准输出替换;建议改用 $(),$() 可嵌套、反引号不能嵌套;二者都会开新进程执行
{} 展开,代表多个;也用于字符串截取、数组增删改查、变量默认值、正则数量符、awk 动作块
() 子 Shell(子程序);$( ) 命令替换
& 将程序放入后台执行(多进程)
! 命令历史特殊符号,如 !!、!109;[[ ]]/test 中表示逻辑非
~ 当前登录用户的家目录
/ 根目录 / 路径分隔符
# 注释符
- 选项前缀;某些命令中表示标准输入
$ 与变量名组合访问变量,如 $var、${var}
\| 管道,把前一个命令的标准输出交给后一个命令的标准输入
< > >> 输入 / 输出 / 追加重定向
. 当前目录;也是 source 的等价命令
.. 上一级目录
[ ] 条件判断表达式
: 永真命令(空命令),常用于 if/while
; 一条命令的结束符,可在同一行分隔多条命令
&& 逻辑与,前一条命令执行成功才执行后一条
\|\| 逻辑或,前一条命令执行失败才执行后一条

二、Bash 环境

2.1 环境变量的概念

环境变量是操作系统中具有特定名字的对象,保存一个或多个应用程序要用到的信息。把系统环境相关信息存入变量,可以减少与内核的交互次数从而提升效率;并且环境变量会被复制给子进程,从而影响子进程的运行环境。

最典型的用途是 PATH——执行命令时若没给出完整路径,系统就会到 PATH 指定的各个目录中依次查找。

2.2 自定义变量与环境变量

类型 特点 示例
自定义变量(局部变量) 仅当前 Shell 有效,不影响子进程 a=123
环境变量(全局变量) 会复制给子进程,影响子进程 export PATH=/usr/local/bin:$PATH
# 自定义变量提升为环境变量
export a
export a=123        # 定义并直接导出
declare -x a=123    # 等价写法

2.3 查看、定义与删除

操作 命令
输出当前 Shell 所有环境变量 env(或 printenv)
输出环境变量 + Shell 自定义变量 + 函数 set
访问某个变量的值 echo $变量名
定义 / 修改环境变量 export 变量名=值
删除变量(自定义变量与环境变量通用) unset 变量名

env 与 set 的区别示例:

abc="hello world"
env | grep abc      # 无输出(abc 只是自定义变量)
set | grep abc      # 有输出:abc='hello world'
export abc
env | grep abc      # 此时有输出

OLDPWD 实验,直观体会环境变量如何影响命令行为:

cd ~
env | grep OLDPWD
cd /
env | grep OLDPWD
export OLDPWD=/tmp
cd -        # cd - 回到 OLDPWD 指向的目录,因此会进入 /tmp

2.4 常见环境变量

变量 含义 示例 / 说明
PATH 命令搜索路径,冒号分隔 /usr/local/sbin:/usr/sbin:/usr/bin:/bin
HOME 当前用户家目录 /root
USER / LOGNAME 当前用户名 root
UID 当前用户 UID echo $UID,0 即 root
SHELL 当前用户的登录 Shell /bin/bash
PWD / OLDPWD 当前目录 / 上一个目录 影响 cd -
HOSTNAME 主机名 —
PS1 一级命令提示符格式 [\u@\h \W]\$
PS2 二级提示符(命令未结束时的续行提示) 默认 >
LANG / LANGUAGE / LC_ALL 语言与本地化环境 脚本中常设 export LANG=en_US.UTF-8 保证输出格式稳定
RANDOM 0–32767 之间的随机整数 echo $RANDOM
HISTFILE 命令历史保存文件 默认 ~/.bash_history
HISTSIZE 内存中保留的历史条目数 默认 1000
HISTFILESIZE 历史文件最大条目数 默认 1000
HISTTIMEFORMAT 历史记录时间显示格式 export HISTTIMEFORMAT="%F %T "
HISTCONTROL 控制哪些命令不记入历史 见 2.9
TMOUT 空闲超时自动注销秒数 TMOUT=30
IFS 内部字段分隔符 默认空格、制表符、换行
BASH_VERSION bash 版本号 4.2.46(2)-release
TERM 终端类型 xterm
echo $PATH
echo $HISTFILE
echo $HISTFILESIZE

追加自定义命令目录到 PATH(注意保留原值):

export PATH=$PATH:/opt/myscripts

2.5 用户级配置文件

位于每个用户家目录下,均为以 . 开头的隐藏文件:

文件 作用 执行时机
~/.bash_profile 用户个人环境变量、启动程序 登录时执行一次
~/.bash_login / ~/.profile ~/.bash_profile 不存在时的替补(按此顺序取第一个存在的) 登录时执行一次
~/.bashrc 用户个人别名、Shell 选项、函数 每次打开新的交互式 Shell 时执行
~/.bash_logout 退出登录时的清理动作 注销时执行
~/.bash_history 历史命令记录文件(不是配置文件) 退出时由内存写入

~/.bash_history 不是配置文件,bash 不会执行其中的内容。它由 history 内建命令读取和写入,用于保存命令历史(路径由 HISTFILE 决定)。

root 用户的 ~/.bashrc 默认定义了三个安全别名:

alias rm='rm -i'
alias cp='cp -i'
alias mv='mv -i'

2.6 全局配置文件

文件 / 目录 作用
/etc/profile 全局环境变量与登录时的初始化,对所有用户生效
/etc/profile.d/*.sh 各软件自定义的全局环境变量脚本目录,由 /etc/profile 循环 source(推荐把自定义全局配置放这里,避免直接改 /etc/profile)
/etc/bashrc(Debian 系为 /etc/bash.bashrc) 全局别名、Shell 选项、PS1 默认值、umask,对所有用户生效

2.7 配置文件加载顺序与登录式 / 非登录式 Shell

登录式 Shell(login shell):通过 tty 本地登录、ssh 远程登录、su - user、bash --login 启动的 Shell。加载顺序为:

/etc/profile
  └─> /etc/profile.d/*.sh
        └─> ~/.bash_profile
              └─> ~/.bashrc
                    └─> /etc/bashrc

注意后两级是因为 ~/.bash_profile 中通常有这样一段"套娃"调用:

# ~/.bash_profile 片段
if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi

而 ~/.bashrc 中又有:

# ~/.bashrc 片段
if [ -f /etc/bashrc ]; then
    . /etc/bashrc
fi

非登录式 Shell(non-login shell):在图形界面里打开终端、在已登录的 Shell 中执行 bash、su user(不带 -)、执行脚本时启动的子 Shell。它不读 /etc/profile 与 ~/.bash_profile,只读:

~/.bashrc
  └─> /etc/bashrc
Shell 类型 读取的文件
交互式登录 Shell /etc/profile → /etc/profile.d/*.sh → ~/.bash_profile → ~/.bashrc → /etc/bashrc
交互式非登录 Shell ~/.bashrc → /etc/bashrc
非交互式 Shell(执行脚本) 只读 $BASH_ENV 指向的文件(默认未设置,即什么都不读)
退出登录 Shell 时 ~/.bash_logout

由此得到两条实用结论:

  • 环境变量(export)写进 ~/.bash_profile 或 /etc/profile.d/xx.sh;因为环境变量会自动继承给子 Shell,登录时设一次即可。
  • 别名、函数、Shell 选项写进 ~/.bashrc;因为别名不会被子 Shell 继承,每开一个 Shell 都要重新定义。

验证当前 Shell 是否为登录式:

shopt login_shell        # on 为登录式,off 为非登录式
echo $0                  # 登录式 Shell 显示为 -bash(前面有减号)

修改配置文件后让其立即生效的三种方式:

source ~/.bashrc      # 方式一
. ~/.bashrc           # 方式二(点号 + 空格 + 文件名)
# 方式三:退出重新登录

2.8 提示符 PS1 自定义

PS1 决定命令提示符的样式,CentOS 默认为:

PS1='[\u@\h \W]\$ '

常用转义符:

转义符 含义
\u 当前用户名
\h 主机名(第一个点之前)
\H 完整主机名
\w 当前完整工作路径
\W 当前目录名(basename)
\$ 提示符,root 显示 #,普通用户显示 $
\t / \T 24 小时制 / 12 小时制时间 HH:MM:SS
\d 日期,如 Mon Aug 22
\A 24 小时制 HH:MM
\n 换行
\! 当前命令的历史编号
\# 当前命令的序号

带颜色的提示符(颜色序列需用 \[ \] 包裹,否则会导致行宽计算错乱):

# 绿色用户@主机,蓝色路径
PS1='\[\e[32m\]\u@\h\[\e[0m\]:\[\e[34m\]\w\[\e[0m\]\$ '

# 显示时间与完整路径,并在出错时另起一行
PS1='[\t \u@\h \w]\n\$ '

要永久生效,写入 ~/.bashrc(对单个用户)或 /etc/profile.d/ps1.sh(对所有用户)。

PS2 是命令未输入完整时的续行提示符(默认 >):

export PS2="continue-> "

2.9 命令历史管理

history                        # 查看全部命令历史
history 10                     # 查看最近 10 条
history -w /tmp/history.txt    # 将内存中的历史写入指定文件
history -r /tmp/history.txt    # 从指定文件读取历史到内存
history -c                     # 清空当前会话内存中的历史
history -w                     # 将内存中的历史立即写入 ~/.bash_history
history -d 10                  # 删除第 10 条历史

~/.bash_history 默认在退出登录时才把本次会话的命令写入;bash 结束时会自动完成这一动作。

快速执行历史命令:

!!          # 执行最后一条命令
!109        # 执行历史编号为 109 的命令
!root       # 执行最近一条以 root 开头的命令
!$          # 取上一条命令的最后一个参数
!^          # 取上一条命令的第一个参数
^old^new    # 把上一条命令中的 old 换成 new 后重新执行

命令历史相关环境变量:

变量 作用
HISTFILE 命令历史默认写入的文件(~/.bash_history)
HISTFILESIZE 写入文件的历史最大条目数
HISTSIZE 内存中命令历史最大条目数
HISTTIMEFORMAT 历史记录时间显示格式,如 export HISTTIMEFORMAT="%F %T "
HISTCONTROL 控制哪些命令不记入历史

HISTCONTROL 的取值:

取值 含义
ignoredups 忽略与上一条完全相同的连续重复命令(默认值)
ignorespace 忽略以空格开头的命令(输入敏感命令时前置一个空格即可不留痕)
ignoreboth 同时启用以上两者
erasedups 删除历史中所有与本条重复的旧记录
export HISTCONTROL=ignoreboth
export HISTSIZE=5000
export HISTFILESIZE=5000
export HISTTIMEFORMAT="%F %T "
# 以上四行写入 ~/.bashrc 可永久生效

2.10 命令别名与持久化

别名用于简化命令输入,只影响当前终端会话。

# 创建别名
alias 别名='执行命令及参数'
alias i='ifconfig enp0s25'
alias p='ping -c 3 192.168.2.1'
alias pb='ping -c 3 www.baidu.com'
alias ll='ls -lh --color=auto'
alias grep='grep --color=auto'

# 查看别名
alias                # 查看全部
alias ll             # 查看单个

# 删除别名
unalias 别名
unalias -a           # 删除所有别名

持久化:别名不会被子 Shell 继承,也不会跨会话保留,必须写入配置文件。

# 只对当前用户生效
echo "alias ll='ls -lh'" >> ~/.bashrc
source ~/.bashrc

# 对所有用户生效
vim /etc/profile.d/alias.sh     # 内容为若干 alias 定义

临时绕过别名执行原命令(例如 root 下 rm 被定义为 rm -i):

\rm -rf dir          # 反斜杠转义,绕过别名
command rm -rf dir   # 使用 command 内建命令
/bin/rm -rf dir      # 直接给出绝对路径

2.11 Shell 环境实战案例

案例 1:用户退出时自动清除历史记录

为防止他人查看操作记录,在对应用户的 ~/.bash_logout 中加入:

history -c              # 清空当前会话历史
rm -rf ~/.bash_history  # 删除历史文件

案例 2:记录所有用户的登录时间

实现"任意用户登录都记录到文件",格式:用户名 2014-07-17 18:00:00。

touch /tmp/login
chmod 666 /tmp/login

在 /etc/profile 末尾追加:

echo "$USER $(date +'%F %T')" >> /tmp/login

执行 source /etc/profile 立即生效(RHEL 6 及以上一般会在下次登录时自动加载)。

案例 3:root 登录即自动更改密码

需求:root 每次登录密码都变化,使下一次登录密码不同(建议在虚拟机或普通用户上测试)。

在 /root/.bash_profile 中追加:

# 以当天日期作为新密码
echo "$(date +%Y%m%d)" | passwd --stdin root &> /dev/null

# 或者使用随机数
echo "$RANDOM" | passwd --stdin root &> /dev/null

可将新密码通过重定向保存到文件,或通过邮件通知,避免锁死自己。

案例 4:普通用户 30 秒无操作自动退出

在该用户的 ~/.bash_profile 中加入:

TMOUT=30        # 单位:秒,超时自动注销

若要对所有用户生效,可写入 /etc/profile 或 /etc/profile.d/ 下的脚本。


三、第一个脚本

3.1 脚本的组成

一个规范的 Shell 脚本通常包含四部分:

#!/bin/bash
#
# Name:         check_disk.sh
# Description:  检查磁盘使用率并在超过阈值时告警
# Author:       ops
# Date:         2026-08-22
# Version:      1.0
#

# ===== 全局变量定义 =====
THRESHOLD=80
LOG_FILE=/var/log/check_disk.log

# ===== 函数定义 =====
log() {
    echo "[$(date +'%F %T')] $*" >> "$LOG_FILE"
}

# ===== 主流程 =====
main() {
    log "start check"
    # ...
}

main "$@"
  1. Shebang:#!/bin/bash,必须在第一行第一列,指定解析器。
  2. 注释块:脚本名、功能、作者、日期、版本,便于交接维护。
  3. 变量与函数定义。
  4. 主流程代码。

3.2 Shebang 说明

#!/bin/bash            # 最常用
#!/usr/bin/env bash    # 从 PATH 中查找 bash,可移植性更好
#!/bin/sh              # POSIX 模式,不能使用数组、[[ ]] 等 bash 扩展
#!/bin/bash -x         # 直接以调试模式运行
#!/usr/bin/awk -f      # 也可指定其他解释器
  • Shebang 只有在以可执行文件方式运行(./script.sh)时才起作用;用 bash script.sh 执行时,命令行指定的解释器优先。
  • Shebang 之后的 # 都是注释,注释不会被执行。

3.3 注释规范

# 单行注释

#####################################
# 分隔性注释,用于区分脚本的功能段落
#####################################

: <<'COMMENT'
多行注释的一种实现:利用永真命令 : 配合 here document,
中间的内容不会被执行。
COMMENT

3.4 代码规范(可读性)

规范 说明
加空行 使代码具有段落感:Shebang 与第一行代码之间加空行;连续定义多行变量后加空行;if/while/for/until/case 之间加空行;函数之间加空行;按功能分段加空行
缩进对齐 缩进量为 4 个空格;if/while/for/until/case 内部代码缩进;函数内部缩进
加空格 双目操作符两边加空格,使代码更清晰
命名规则 见名知意。首字母小写驼峰 gameStatus、首字母大写驼峰 GameStatus、下划线连接 game_status(推荐小写下划线风格)。强烈不建议使用中文和拼音
折行 一行不超过 80 个字符(含空白),使用 \ 连接行,\ 后不能跟任何字符
常量 全大写加下划线,如 LOG_FILE、MAX_RETRY

3.5 三种执行方式与子 Shell

# 方式一:赋予可执行权限后直接执行 —— 开子进程,由 Shebang 决定解释器
chmod +x script.sh
./script.sh
/root/script.sh          # 绝对路径同理

# 方式二:显式调用解释器 —— 开子进程,不需要执行权限,Shebang 被忽略
bash script.sh
sh script.sh
bash -x script.sh        # 调试执行

# 方式三:source / . —— 在【当前 Shell】执行,不开子进程
source script.sh
. script.sh
方式 是否需执行权限 是否开子 Shell 脚本里的变量能否留在当前 Shell
./script.sh 需要 是 否
bash script.sh 不需要 是 否
source script.sh / . script.sh 不需要 否 能

默认执行任何程序都会开新进程,source(内部命令)是例外。这带来两个重要影响:

  • 脚本中的 cd、变量赋值只有用 source 执行时才会影响当前 Shell。因此修改完 ~/.bashrc 要用 source ~/.bashrc 而不是 ./~/.bashrc。
  • 脚本中的 exit 在 source 方式下会退出当前 Shell(终端会关闭),调试时要注意。

验证子 Shell 的存在:

# test.sh
#!/bin/bash
echo "脚本内 PID: $$"
name="inner"

# 终端执行
echo $$              # 终端 PID,与脚本内 PID 不同
./test.sh
echo $name           # 空,子 Shell 的变量不会回传
source test.sh       # 此时脚本内 PID 与终端 PID 相同
echo $name           # inner

3.6 脚本调试

bash -x script.sh        # 逐行显示执行过程(展开变量后的真实命令)
bash -n script.sh        # 只做语法检查,不执行
bash -v script.sh        # 执行前打印脚本原始内容

在脚本内部局部开启 / 关闭调试:

set -x        # 开启调试,之后每条命令都会带 + 前缀打印
# ...需要排查的代码...
set +x        # 关闭调试

调试输出前缀由 PS4 控制,加上行号与函数名会更清晰:

export PS4='+[${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}] '

3.7 健壮性开关:set -e / -u / -o pipefail

选项 作用
set -e(set -o errexit) 任何命令返回非 0 立即退出脚本,避免"错误后继续执行造成更大破坏"
set -u(set -o nounset) 引用未定义变量时报错退出,防止 rm -rf $DIR/ 中 DIR 为空导致删根
set -o pipefail 管道中任意一环失败,整个管道的返回码即为失败(默认只取最后一个命令的返回码)
set -E 使 ERR 陷阱在函数、子 Shell 中同样生效
set -o noclobber(set -C) 禁止 > 覆盖已存在文件
set -f 禁用通配符展开

推荐的脚本头:

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'

注意事项:

  • set -e 对 if、while 条件位置、&&/|| 左侧的命令不生效(这是设计如此)。
  • 明知某条命令可能失败且允许失败时,用 cmd || true 显式豁免。
  • set -u 下访问可能不存在的变量要写默认值:${var:-}。
set -euo pipefail

# 允许失败的命令
grep -q root /etc/passwd || true

# 可能未定义的变量
echo "参数是 ${1:-未提供}"

四、变量

4.1 变量概念与分类

变量是以一组文字或符号替换复杂或易变的数据。Shell 中变量默认没有数据类型之分,一切皆为字符串(需要按整数运算时由运算上下文临时转换)。

变量的用途:方便搜索与引用;编程中对常用值使用变量,改一处即可全局生效。

分类 说明
自定义变量 仅当前 Shell 有效,不影响子进程
环境变量 会影响子进程(父进程将环境变量复制给子进程)
位置参数变量 $1、$2... 脚本或函数的入参
预定义(内置)变量 $$、$?、$# 等由 bash 维护

自定义变量转为环境变量:

export 自定义变量名

4.2 命名规则

  • 严格区分大小写,同名但大小写不同是两个不同变量。
  • 可由数字、字母、下划线组合,但不能以数字和特殊字符开头;最好不要以下划线开头,更不能是纯数字。
  • 定义变量时 = 两边不能有空格;含空格的字符串要用引号引起来。
  • 单引号会转义所有特殊字符,双引号保留部分特殊字符。
  • 不要与系统环境变量(PATH、HOME、UID 等)同名。
  • 尽可能见名知意。

4.3 定义、更新、获取、删除

定义变量(= 两边不能有空格):

a=123
a="hello world"
a=34.6778

特殊定义方式——将命令输出赋值给变量(命令替换):

var=`ls -l`     # 反引号方式
a=$(ls -l)      # 推荐:$() 方式,可嵌套

更新变量值:

num1=2333
num2=$(($num1 + 1000))     # 3333,算术扩展
num2=$((num1 + 1000))      # 双括号内可省略 $
num2=$[num1 + 1000]        # 3333,旧写法
let num2=($num1 + 1000)    # 3333
let "num2=(num1 + 1000)"   # 含空格或括号时必须整体加引号,否则会被词分割导致语法错误

str1="hello world "
str2=$str1"hello shell"    # hello world hello shell
str2="${str1}hello shell"  # 推荐写法,边界清晰

追加值:

变量名+=追加值

str1="hello world "
str1+="hello shell"        # hello world hello shell

获取变量值:

$变量名
${变量名}      # 推荐,可明确边界

删除变量:

unset 变量名

4.4 只读变量 readonly

readonly PI=3.14159
declare -r PI=3.14159      # 等价写法
readonly -p                # 列出所有只读变量

只读变量不能被重新赋值,也不能被 unset,只有当前 Shell 退出后才释放。常量建议全大写命名。

readonly LOG_DIR=/var/log/myapp
LOG_DIR=/tmp        # 报错:LOG_DIR: readonly variable

4.5 单引号、双引号、反引号与 $()

  • 双引号 "..."(弱引用):批量转义大部分特殊字符,但 $、`、\、! 等仍保留特殊含义(变量会被展开)。
  • 单引号 '...'(强引用):转义所有特殊字符,所见即所得(变量不会被展开)。
  • 反引号 `...` 与 $(...):命令替换,把命令的标准输出替换到原位置。
  • 对变量取值时,建议用双引号包裹(如 "$a"),以避免变量为空或含空格时产生语法错误、词分割。
name="world"

echo "hello $name"      # hello world
echo 'hello $name'      # hello $name
echo "今天是 `date +%F`"   # 今天是 2026-08-22
echo "今天是 $(date +%F)"  # 推荐写法

# $() 可以嵌套,反引号不能
echo "本机磁盘:$(df -h $(pwd) | tail -1)"
写法 变量展开 命令替换 用途
"..." 是 是 绝大多数场景
'...' 否 否 需要原样输出 $、反斜杠时(如 awk/sed 表达式)
`cmd` — 是 老式命令替换,不可嵌套
$(cmd) — 是 推荐,可嵌套、可读性好

反引号与 $() 都会开新进程执行命令,并以标准输出结果替换。

4.6 位置参数与内置变量

变量 含义
$0 本脚本名(包含调用时写的路径)
$1、$2、$3... 脚本的第 1、2、3... 个参数
${10} 脚本的第 10 个参数(两位及以上必须用 {},否则 $10 会被解析为 $1 后跟字符 0)
$n 脚本的第 n 个参数
$# 位置参数的总个数
$* 脚本的所有参数,加双引号时等价于 "$1 $2 $3"(一个整体)
$@ 脚本的所有参数,加双引号时等价于 "$1" "$2" "$3"(各自独立)
$? 上一条命令的返回码,0 表示成功,非 0 表示错误
$$ 本脚本(当前 Shell)的 PID
$! 最后一个放入后台运行的进程的 PID
$_ 上一条命令的最后一个参数
$- 当前 Shell 的选项标志(如 himBH)

关于 $? 的补充:赋值语句本身没有返回码;变量=$(命令) 这种命令结果赋值时,$? 取该命令的返回码。

关于 $$ 的补充:终端的 PID 是该终端所运行脚本的 PPID;脚本中的 sleep 等会开子进程;脚本退出后其中的 sleep 可能成为孤儿进程继续运行。

#!/bin/bash
echo "脚本名: $0"
echo "第一个参数: $1"
echo "第十个参数: ${10}"
echo "所有参数: $@"
echo "参数总数: $#"
echo "脚本 PID: $$"
echo "上一条命令返回码: $?"

$* 与 $@ 的差异

未加双引号时二者行为相同;加双引号后:"$*" 把所有参数当作一个字符串(用 IFS 的第一个字符连接),"$@" 把每个参数当作独立字符串。

#!/bin/bash
# 以 ./demo.sh a "b c" d 执行

echo '--- "$*" ---'
for i in "$*"; do echo "[$i]"; done
# [a b c d]        只循环一次

echo '--- "$@" ---'
for i in "$@"; do echo "[$i]"; done
# [a]
# [b c]            正确保留了含空格的参数
# [d]

结论:转发参数一律用 "$@",例如 main "$@"、ssh host "$@"。

shift 参数左移

shift [n] 把位置参数左移 n 位(默认 1 位),$2 变成 $1,$# 相应减小。常用于循环处理不定个数的参数。

#!/bin/bash
# 逐个处理所有参数
while [ $# -gt 0 ]; do
    echo "正在处理: $1"
    shift
done
#!/bin/bash
# 第一个参数作为动作,其余作为目标列表
action=$1
shift                  # 移除动作参数
for target in "$@"; do
    echo "$action -> $target"
done

4.7 ${} 的特殊访问

解决变量与其他字符串连接混乱问题:使用 ${变量名} 明确边界。

name="log"
echo "$name_file"      # 空!被当成变量 name_file
echo "${name}_file"    # log_file

按位置截取:

写法 含义
${变量名:起始位置:截取个数} 从起始位置(从 0 开始)截取指定个数
${变量名:起始位置} 从起始位置截取到末尾
${a:7} 从第 8 个字符开始到结束
${var:0-7:3} 从倒数第 7 个开始,截取 3 个
${var:0-7} 从倒数第 7 个开始,截取到最后
${var: -7} 同上(冒号后必须有空格,否则与默认值语法冲突)
${var:起始:-n} 从末尾定位结束位置截取(bash 4.2+)
a="hello shell world"
echo ${a:6:5}      # shell
echo ${a:6}        # shell world
echo ${a:0-5:3}    # wor

按关键词截取(模式删除):

写法 含义
${变量名#*关键词} 删除首次匹配关键词及其左侧内容(最短匹配),即截取第一个关键词后面的内容
${变量名##*关键词} 删除最后一次匹配关键词及其左侧内容(最长匹配),即截取最后一个关键词后面的内容
${变量名%关键词*} 删除最后一次匹配关键词及其右侧内容(最短匹配),即截取最后一个关键词前面的内容
${变量名%%关键词*} 删除首次匹配关键词及其右侧内容(最长匹配),即截取第一个关键词前面的内容

记忆技巧:# 在键盘上位于 $ 左边,所以从左边删;% 在 $ 右边,所以从右边删;单个符号为最短匹配,双符号为最长匹配。

url="http://www.baidu.com/index.html"

echo ${url#*//}          # www.baidu.com/index.html
echo ${url##*/}          # index.html
echo ${url%/*}           # http://www.baidu.com
echo ${url%%/*}          # http:
echo ${url##*.}          # html        取扩展名
file="/var/log/messages.1.gz"
echo ${file##*/}         # messages.1.gz   取文件名
echo ${file%/*}          # /var/log        取目录名

获取字符串长度:

${#变量名}

a="hello shell world"
echo ${#a}         # 17

替换:

${变量名/查找关键词/替换关键词}     # 替换第一个匹配
${变量名//查找关键词/替换关键词}    # 替换所有匹配
${变量名/#前缀/替换}               # 只替换开头匹配的
${变量名/%后缀/替换}               # 只替换结尾匹配的
${变量名/查找关键词/}              # 替换为空,即删除

a="hello shell world"
echo ${a/l/L}      # heLlo shell world
echo ${a//l/L}     # heLLo sheLL worLd
echo ${a//l/}      # heo she word

大小写转换(bash 4.0+):

str="hello Shell"
echo ${str^}       # Hello Shell     首字母转大写
echo ${str^^}      # HELLO SHELL     全部转大写
echo ${str,}       # hello Shell     首字母转小写
echo ${str,,}      # hello shell     全部转小写

declare -u up="abc"    # 赋值自动转大写
declare -l low="ABC"   # 赋值自动转小写

变量默认值与存在性判断:

写法 含义
${var-默认值} 变量未定义时返回默认值(已定义为空则返回空)
${var:-默认值} 变量未定义或为空时返回默认值,不修改 var 本身
${var:=默认值} 变量未定义或为空时返回默认值,并把默认值赋给 var
${var:?提示信息} 变量未定义或为空时,把提示信息输出到标准错误并退出脚本(非交互式)
${var:+替代值} 变量已定义且非空时返回替代值,否则返回空
echo ${变量名-默认值}      # 变量为空或不存在时使用默认值
echo ${name:-"default"}

unset dir
echo ${dir:-/tmp}        # /tmp ,dir 仍未定义
echo $dir                # 空

echo ${dir:=/tmp}        # /tmp
echo $dir                # /tmp ,已被赋值

# 强制要求必须传参,是脚本参数校验最简洁的写法
file=${1:?"用法: $0 <文件名>"}

# 拼接可选参数
opts=${DEBUG:+--verbose}

间接引用与变量名遍历:

name="hello"
var="name"
echo ${!var}         # hello ,取变量名为 name 的变量的值
echo ${!HIST@}       # 列出所有以 HIST 开头的变量名

4.8 关于 $$ 与进程关系(实验)

  1. 在终端执行 echo $$ 查看终端 PID。
  2. 编写脚本 process:
echo $$
sleep 999
  1. 运行脚本,用 ps -el | grep process 查看进程 PID 是否为脚本打印的 PID,并查看脚本进程的 PPID 是否为终端 PID。
  2. 执行 kill -9 pid 终止脚本。
  3. 脚本终止后,sleep 成为孤儿进程(其 PPID 变为 1)。

4.9 变量作用域与 local

  • Shell 中变量默认都是全局变量:函数可直接访问和修改函数外的变量;函数内定义的变量在函数调用后仍可在函数外访问。
  • 函数内使用 local 关键字可定义局部变量,仅在函数内部(及其调用的子函数)有效;local 只能在函数中使用。
  • 环境变量(export)的作用域可以延伸到子进程,但子进程的修改不会影响父进程。
var="global"

func() {
    local var="local value"    # 局部变量,屏蔽同名全局变量
    inner="函数内定义但是全局的"
    echo "$var"
}

func            # local value
echo "$var"     # global      未被污染
echo "$inner"   # 函数内定义但是全局的

func2() {
    var="modified"            # 未加 local,直接修改全局变量
}
func2
echo "$var"     # modified

编写函数时应养成"函数内所有临时变量都加 local"的习惯,避免函数之间互相污染,这是大型脚本最常见的隐蔽 bug 来源。


五、输入与输出

5.1 echo

echo [选项] 字符串
选项 / 转义 作用
-n 不换行输出
-e 启用反斜杠转义解释
-E 不解释转义(默认)
\n 换行
\t 制表符
\r 回车(常用于原地刷新进度)
\\ 反斜杠本身
\c 最后不换行(需配合 -e)
\e / \033 ESC,用于颜色控制序列
echo -n "请输入: "
echo -e "第一行\n第二行"
echo -e "姓名\t年龄"
echo -e "no newline\c"

5.2 printf

printf 支持格式化输出,不会自动换行,需要手写 \n:

printf "格式字符串" 参数1 参数2 ...
printf "%d%s\n" 34 "hello"

常用格式:

格式 含义
%s 字符串
%d / %i 十进制整数
%f 浮点数(默认 6 位小数)
%c 单个字符
%x / %X 十六进制
%o 八进制
%% 百分号本身
%10d 占 10 位右对齐,不足补空格
%-10d 占 10 位左对齐,不足补空格
%010d 占 10 位右对齐,不足补 0
%10.2f 占 10 位右对齐,保留 2 位小数
%-20s 占 20 位左对齐字符串,常用于对齐表格
# 格式化表格输出
printf "%-10s %-8s %-6s\n" 用户名 UID Shell
printf "%-10s %-8d %-6s\n" root 0 /bin/bash

# 格式字符串会被循环复用,直到参数用完
printf "%s\n" a b c        # 分三行输出 a b c

# 百分比与保留小数
printf "使用率: %.1f%%\n" 85.66      # 使用率: 85.7%

5.3 read 交互输入

read [选项] [变量名]...
选项 含义
-p "提示" 指定输入提示信息
-s 关闭回显(用于输入密码)
-n N 指定输入最大字符个数,达到即返回(不用回车)
-N N 严格读取 N 个字符
-t N 指定超时时间(秒),超时返回非 0
-d 字符 用指定字符代替换行作为结束符
-a 数组名 把输入按 IFS 拆分后存入数组
-r 不把反斜杠当转义符(读文件时必加)
-e 启用 readline 编辑(可用方向键、Tab 补全)

若 read 不指定变量,接收到的内容会放入特殊环境变量 REPLY。

read var                                          # 等待给 var 赋值
read -p "input your name:" var                    # 带提示
read -p "input your name:" -t 10 var              # 10 秒超时
read -s -p "input your password:" passwd          # 隐藏回显输入密码
echo                                              # -s 后补一个换行
read -n 1 -p "确认删除?(y/n) " answer             # 按一个键即返回
read -p "请输入三个数字: " n1 n2 n3               # 按空格拆分给多个变量
read -a arr -p "请输入若干值: "                    # 读入数组

从文件或管道读取(-r 防止反斜杠被吃掉,IFS= 防止首尾空格被删):

while IFS= read -r line; do
    echo "读到: $line"
done < /etc/passwd

5.4 一行执行多条命令

写法 含义
; 顺序执行,前一条成功与否都执行下一条
&& 前一条执行成功(返回 0)才执行后一条
\|\| 前一条执行失败(返回非 0)才执行后一条
& 前一条放入后台,立即执行后一条
cd /tmp; ls
cd /tmp && ls
make && make install
mkdir -p /data || echo "mkdir failed"
ping -c 1 192.168.1.1 || echo "network error"

# 三元表达式的常见写法
[ -f /etc/passwd ] && echo "存在" || echo "不存在"

注意:A && B || C 并不完全等价于 if-else——如果 B 自身失败,C 也会被执行。逻辑复杂时请老老实实用 if。

5.5 旁支:管道与重定向

详见 阶段01 Linux 基础与文件管理

管道 |

管道 | 将前一个命令的标准输出作为后一个命令的标准输入,形成多级处理链。

命令1 | 命令2 | 命令3 ...
# 查看文件元数据,只保留最后 3 行
stat 1 | tail -3

# 取 /etc/passwd 最后 5 行中的第 1 行
tail -5 /etc/passwd | head -1

# 统计用户数(行数)
cat /etc/passwd | wc -l

# 统计包含 root 的行数
cat /etc/passwd | grep root | wc -l

# 多级管道:取第一行 → 按 : 分隔 → 输出第 1 列
cat /etc/passwd | head -1 | cut -d ":" -f1

管道只传递标准输出,错误输出需要先重定向(2>&1)。管道每一段都在子 Shell 中执行,因此 cmd | while read; do count=$((count+1)); done 结束后 count 不会保留(见"常见坑"一章)。

三个标准流

流 设备文件 文件描述符 默认设备
标准输入 stdin /dev/stdin 0 键盘
标准输出 stdout /dev/stdout 1 屏幕
错误输出 stderr /dev/stderr 2 屏幕

输出重定向

写法 含义
> 文件 或 1> 文件 标准输出覆盖写入文件
>> 文件 或 1>> 文件 标准输出追加到文件
2> 文件 错误输出覆盖写入
2>> 文件 错误输出追加
&> 文件 标准输出 + 错误输出一并覆盖写入
&>> 文件 标准输出 + 错误输出一并追加
> 文件 2>&1 等价于 &> 文件(顺序不能颠倒)
ls > 123.txt                          # 正确结果写入文件
ls abc 2> 123.txt                     # 错误信息写入文件
ls &> 123.txt                         # 正确、错误都写入文件
ls abc &>> 123.txt                    # 都追加到文件
ls install.log fdsaf 1> ok.txt 2> err.txt  # 分流到两个文件
echo 123 > 1.txt                      # 覆盖
echo 456 >> 1.txt                     # 追加

/dev/null 黑洞

/dev/null 是一个只写丢弃设备,写入其中的所有内容都会被丢弃,常用于屏蔽输出:

cat 1.txt > /dev/null      # 不显示任何输出
make > /dev/null           # 屏蔽编译输出
命令 &> /dev/null          # 同时屏蔽正确和错误输出
命令 > /dev/null 2>&1      # 同上,兼容性更好

输入重定向

< 将文件内容作为命令的标准输入:

wc -l < 1.txt              # 等价于 wc -l 1.txt,但来源是 stdin

六、数值运算

Shell 默认把一切当字符串,做算术运算必须借助专门的语法。共有五种常用方式。

6.1 五种运算方式对比

方式 语法 特点
$(( )) num=$((a + b)) 最推荐。bash 内置,效率高,括号内可省略 $,支持 C 语言风格运算符
(( )) ((a++))、((a = b + 1)) 内置算术求值,本身不返回结果只返回状态码,常用于自增与条件判断
let let a=b+1 内置命令,等号两边与运算符间不能有空格,除非整体加引号
$[ ] num=$[a + b] 旧写法,已不推荐,功能等同 $(( ))
expr num=$(expr $a + $b) 外部命令,每次运算都开进程,效率最低;运算符两侧必须有空格,* 必须转义
bc echo "3.5*2" \| bc 外部计算器,唯一支持浮点运算的常用方式
a=10
b=3

# $(( ))
echo $((a + b))       # 13
echo $((a - b))       # 7
echo $((a * b))       # 30
echo $((a / b))       # 3    整除,直接舍去小数
echo $((a % b))       # 1    取余
echo $((a ** b))      # 1000 幂运算

# (( ))
((sum = a + b))
echo $sum             # 13
((a > b)) && echo "a 大于 b"

# let
let c=a+b
let "c = a + b"       # 有空格必须加引号
echo $c

# $[ ]
echo $[a + b]

# expr(注意空格与转义)
expr $a + $b
expr $a \* $b
expr 10 % 3
expr length "hello"   # 5,expr 还能做字符串操作

6.2 算术运算符全表

运算符 含义 示例
+ 加 $((3 + 2)) → 5
- 减 $((3 - 2)) → 1
* 乘 $((3 * 2)) → 6
/ 整除(舍去小数) $((7 / 2)) → 3
% 取余(取模) $((7 % 2)) → 1
** 幂 $((2 ** 10)) → 1024
++ / -- 自增 / 自减 ((i++))、((++i))
! 逻辑非 $((!0)) → 1
&& / \|\| 逻辑与 / 或 $((1 && 0)) → 0
& / \| / ^ / ~ 按位与 / 或 / 异或 / 取反 $((6 & 3)) → 2
<< / >> 左移 / 右移 $((1 << 3)) → 8
> >= < <= == != 比较,返回 1(真)或 0(假) $((3 > 2)) → 1
? : 三元条件 $((a > b ? a : b))

6.3 赋值运算符全表

运算符 等价写法
= 直接赋值
+= a += 2 → a = a + 2
-= a -= 2 → a = a - 2
*= a *= 2 → a = a * 2
/= a /= 2 → a = a / 2
%= a %= 2 → a = a % 2
<<= >>= &= \|= ^= 位运算复合赋值
i=5
((i += 3)); echo $i     # 8
let i-=2;   echo $i      # 6
((i *= 10)); echo $i     # 60

6.4 自增与自减

i=5
echo $((i++))    # 5   先取值后自增(后置)
echo $i          # 6
echo $((++i))    # 7   先自增后取值(前置)
echo $i          # 7

# 循环计数的三种等价写法
((i++))
let i++
i=$((i + 1))

注意:在 set -e 脚本中,当 i 为 0 时 ((i++)) 的返回值是 1(假),会导致脚本意外退出。安全写法是 ((i++)) || true 或 i=$((i+1))。

6.5 bc 做浮点运算

bash 本身不支持浮点运算,需借助外部命令 bc(可能需 yum install -y bc)。

# 基本用法:通过管道把表达式喂给 bc
echo "3.5 * 2" | bc                  # 7.0
echo "10 / 3" | bc                   # 3        默认标度为 0
echo "scale=4; 10 / 3" | bc          # 3.3333   scale 指定小数位数
echo "scale=2; 5 / 2" | bc           # 2.50

# 变量参与运算
a=3.5
b=2.1
echo "$a + $b" | bc                  # 5.6
result=$(echo "scale=2; $a * $b" | bc)
echo "$result"                       # 7.35

# 数学函数需加 -l 选项(自动设 scale=20)
echo "sqrt(2)" | bc -l               # 1.41421356237309504880
echo "s(0)" | bc -l                  # 正弦

# 比较:bc 返回 1 为真、0 为假
if [ "$(echo "$a > $b" | bc)" -eq 1 ]; then
    echo "a 大于 b"
fi

# here string 写法
bc -l <<< "scale=3; 22/7"            # 3.142

也可以用 awk 做浮点运算与格式化:

awk 'BEGIN{printf "%.2f\n", 10/3}'          # 3.33
awk -v a=3.5 -v b=2.1 'BEGIN{print a*b}'    # 7.35

保留小数与四舍五入还可以直接用 printf:

printf "%.2f\n" $(echo "10/3" | bc -l)      # 3.33

6.6 随机数 RANDOM

RANDOM 是 bash 内置变量,每次引用返回一个 0–32767 之间的随机整数。

echo $RANDOM                 # 0 ~ 32767

# 通用公式:$((RANDOM % 范围长度 + 起始值))
echo $((RANDOM % 100))       # 0 ~ 99
echo $((RANDOM % 100 + 1))   # 1 ~ 100
echo $((RANDOM % 45 + 55))   # 55 ~ 99
echo $((RANDOM % 4 + 4))     # 4 ~ 7

# 从列表中随机取一个元素
arr=(apple banana orange)
echo ${arr[$((RANDOM % ${#arr[@]}))]}

# 更高强度的随机来源
head -c 8 /dev/urandom | od -An -tu4        # 内核随机数
openssl rand -base64 12                     # 随机字符串
cat /proc/sys/kernel/random/uuid            # 随机 UUID

RANDOM 是伪随机,可通过 RANDOM=种子 复现序列;生成密码等安全场景应使用 /dev/urandom 或 openssl rand。


七、条件测试

7.1 test、[ ]、[[ ]]、(( )) 的区别

test 用于判断条件表达式,有多种等价写法:

test $a = $b
[ $a = $b ]
(($a == $b))
[[ $a == $b ]]

完整帮助可查看 man test 或 help test。建议对变量加双引号:

if [ "$a" -eq "$b" ]; then ...
形式 本质 特点与适用场景
test 表达式 内建命令 最古老的写法,POSIX 通用
[ 表达式 ] 内建命令([ 就是命令名) 与 test 完全等价;[ 后与 ] 前必须有空格;变量必须加引号;逻辑用 -a/-o
[[ 表达式 ]] bash 关键字 bash 扩展:变量无需加引号也不会因空值报错;支持 &&/\|\|、通配符匹配、正则 =~;不做词分割与通配展开;不支持 -a/-o
(( 表达式 )) 算术求值 只用于整数运算与比较,可直接写 >、<、==、&&;变量前不用加 $
a=10; b=20

[ "$a" -lt "$b" ] && echo yes          # 整数比较用 -lt
[[ $a -lt $b ]] && echo yes            # [[ ]] 同样支持
(( a < b )) && echo yes                # (( )) 里可直接用 <

# [[ ]] 对空值安全
unset x
[ $x = "abc" ]      # 报错:unary operator expected
[[ $x = "abc" ]]    # 正常返回假

# [[ ]] 支持通配符(右侧不加引号才是模式)
name="test.log"
[[ $name == *.log ]] && echo "是日志文件"

# [[ ]] 支持正则匹配
[[ "abc123" =~ ^[a-z]+[0-9]+$ ]] && echo "格式正确"

结论:写 bash 脚本时优先用 [[ ]] 做字符串/文件判断,用 (( )) 做整数运算判断;只有需要兼容 sh/dash 时才用 [ ]。

7.2 文件测试

操作符 含义
-a / -e 文件存在(exist)
-r 文件存在且当前进程可读
-w 文件存在且当前进程可写
-x 文件存在且当前进程可执行
-d 存在且是目录(directory)
-f 存在且是常规文件(file)
-s 存在且大小大于 0(size,非空)
-b 存在且是块特殊文件(block)
-c 存在且是字符特殊文件(char)
-L / -h 存在且是符号链接(link)
-p 存在且是 FIFO(命名管道 pipe)
-S 存在且是套接字(socket)
-G 存在且组 ID 与当前进程相同
-O 存在且属主为当前进程用户 ID
-u / -g / -k 存在且设置了 SUID / SGID / Sticky 位
-N 自上次读取后文件被修改过
f1 -nt f2 f1 比 f2 新(newer than)
f1 -ot f2 f1 比 f2 旧(older than)
f1 -ef f2 f1 与 f2 是同一个文件(硬链接/同 inode)

示例:

if test -d "/tmp"; then
    echo "判断 /tmp 是否存在且是个目录"
fi

if [ -d "/tmp" ]; then
    echo "判断 /tmp 是否存在且是个目录"
fi

# 常用组合
[ -e /etc/passwd ] && echo "文件存在"
[ -f /etc/passwd ] || echo "不是普通文件"
[ -s /var/log/messages ] && echo "日志非空"
[ -x /usr/bin/nginx ] && echo "可执行"
[ ! -d /data ] && mkdir -p /data          # 目录不存在则创建
[ -L /bin ] && echo "/bin 是软链接"
[ /tmp/a -nt /tmp/b ] && echo "a 比 b 新"

注意 -e 与 -f 的区别:目录、设备文件对 -e 为真但对 -f 为假。判断"日志文件存在且有内容"要用 -s。

7.3 字符串测试

操作符 含义 示例
= 等于 [ "$a" = "$b" ]
== 等于,与 = 等价([[ ]] 中右侧是模式) [ "$a" == "$b" ]
!= 不等于 [ "$a" != "$b" ]
-z 字符串长度为 0(zero,即为空) [ -z "$a" ]
-n 字符串长度不为 0(non-zero,非空) [ -n "$a" ]
< 按字典序小于([[ ]] 中直接用,[ ] 中需转义 \<) [[ "$a" < "$b" ]]
> 按字典序大于 [[ "$a" > "$b" ]]
=~ 正则匹配(仅 [[ ]] 支持) [[ "$a" =~ ^[0-9]+$ ]]
str="hello"

[ "$str" = "hello" ] && echo "相等"
[ -z "$str" ] && echo "空" || echo "非空"
[ -n "$str" ] && echo "非空"

# 判断变量是否为空的标准写法
if [ -z "$1" ]; then
    echo "参数不能为空"
    exit 1
fi

[[ ]] 中的正则匹配 =~:右侧的正则不要加引号(加引号会退化为普通字符串匹配),分组结果保存在数组 BASH_REMATCH 中。

# 校验纯数字
[[ "12345" =~ ^[0-9]+$ ]] && echo "是纯数字"

# 校验 IP 地址格式
ip="192.168.1.10"
if [[ $ip =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
    echo "IP 格式合法"
fi

# 校验邮箱
[[ "a@b.com" =~ ^[a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] && echo "邮箱合法"

# 提取分组
date_str="2026-08-22"
if [[ $date_str =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then
    echo "年: ${BASH_REMATCH[1]}"
    echo "月: ${BASH_REMATCH[2]}"
    echo "日: ${BASH_REMATCH[3]}"
fi

# 通配符匹配(== 右侧不加引号)
f="access.log"
[[ $f == *.log ]] && echo "日志文件"
[[ $f != *.txt ]] && echo "不是文本文件"

7.4 整数测试

操作符 含义 示例
-eq 等于 equal [ "$a" -eq "$b" ]
-ne 不等于 not equal [ "$a" -ne "$b" ]
-gt 大于 greater than [ "$a" -gt "$b" ]
-ge 大于等于 greater equal [ "$a" -ge "$b" ]
-lt 小于 less than [ "$a" -lt "$b" ]
-le 小于等于 less equal [ "$a" -le "$b" ]
< 小于(需双括号) (("$a" < "$b"))
<= 小于等于(需双括号) (("$a" <= "$b"))
> 大于(需双括号) (("$a" > "$b"))
>= 大于等于(需双括号) (("$a" >= "$b"))
== 等于(需双括号) ((a == b))
!= 不等于(需双括号) ((a != b))
num=15
[ "$num" -gt 10 ] && echo "大于 10"
(( num >= 10 && num <= 20 )) && echo "在 10 到 20 之间"

切记:[ ] 中比较整数用 -eq/-gt 等字母操作符,比较字符串用 =/!=。用反了不会立刻报错,但结果往往是错的(例如 [ "10" > "9" ] 里的 > 会被当成输出重定向!)。

7.5 逻辑组合

操作符 含义 适用范围
! 非,后面要跟空格 [ ] 与 [[ ]] 均可
-a 与 and,全部满足才为真 仅 [ ] / test
-o 或 or,满足一个即为真 仅 [ ] / test
&& 与 [[ ]]、(( )),或连接两个独立的 [ ]
\|\| 或 同上
\( \) 分组([ ] 中括号需转义) [ ]
a=10; b=20; file=/etc/passwd

# [ ] 中用 -a / -o
[ "$a" -gt 5 -a "$b" -lt 30 ] && echo "都满足"
[ "$a" -lt 5 -o "$b" -lt 30 ] && echo "至少一个满足"
[ ! -d /data ] && echo "不是目录"

# [[ ]] 中用 && / ||,可读性更好
[[ $a -gt 5 && $b -lt 30 ]] && echo "都满足"
[[ -f $file && -r $file ]] && echo "是可读的普通文件"

# 连接两个独立测试
[ "$a" -gt 5 ] && [ "$b" -lt 30 ] && echo "都满足"

# 分组
[ \( "$a" -gt 5 -a "$b" -lt 30 \) -o "$a" -eq 0 ] && echo "满足"

7.6 退出状态码与 exit

每条命令执行完都会返回一个 0–255 的退出状态码,保存在 $? 中:0 表示成功(真),非 0 表示失败(假)。if、while、&&、|| 判断的依据都是这个状态码。

ls /etc > /dev/null
echo $?          # 0

ls /notexist 2> /dev/null
echo $?          # 2

exit [n] 用于退出脚本并指定状态码;不带参数时使用上一条命令的状态码。

exit 0           # 正常退出
exit 1           # 一般性错误
exit 2           # 用法错误(参数不对)

常见状态码约定:

状态码 含义
0 成功
1 一般性未知错误
2 误用 Shell 命令 / 参数错误
126 命令找到但不可执行(权限不足)
127 命令未找到(command not found)
128 + n 被信号 n 终止,如 Ctrl+C 为 130(128+2),kill -9 为 137(128+9)
255 状态码越界

自定义退出码便于外部调用者判断失败原因:

#!/bin/bash
[ $# -lt 1 ] && { echo "用法: $0 <文件>"; exit 2; }
[ -f "$1" ] || { echo "文件不存在"; exit 3; }
grep -q "ERROR" "$1" || exit 0
echo "发现错误日志"
exit 1

true 与 false 是专门用来产生状态码的内建命令;:(冒号)是永真空命令。

true;  echo $?     # 0
false; echo $?     # 1
: ;    echo $?     # 0

八、流程控制——分支

8.1 if 语句

if 判断的依据是命令的返回码:0 为真,非 0 为假。

# 单分支
if 命令; then
    命令成功要执行的代码块
fi
# 双分支
if 命令; then
    命令成功要执行的代码块
else
    命令失败要执行的代码块
fi
# 多分支
if 命令1; then
    命令1成功要执行的代码块
elif 命令2; then
    命令2成功要执行的代码块
elif 命令3; then
    命令3成功要执行的代码块
else
    全部失败时执行的代码块
fi

then 也可以另起一行(此时 if 行末不写分号):

if [ -f /etc/passwd ]
then
    echo "存在"
fi

因为 if 判断的是命令返回码,所以可以直接判断命令是否成功,不必非要写 [ ]:

if grep -q "^root:" /etc/passwd; then
    echo "root 用户存在"
fi

if ping -c 1 -W 1 8.8.8.8 &> /dev/null; then
    echo "网络正常"
else
    echo "网络不通"
fi

if ! id user1 &> /dev/null; then
    useradd user1
fi

嵌套 if:

#!/bin/bash
read -p "请输入分数: " score

if [[ $score =~ ^[0-9]+$ ]]; then
    if [ "$score" -gt 100 ]; then
        echo "分数不能大于 100"
    elif [ "$score" -ge 60 ]; then
        echo "及格"
        if [ "$score" -ge 90 ]; then
            echo "而且是优秀"
        fi
    else
        echo "不及格"
    fi
else
    echo "请输入数字"
fi

成绩等级判断(完整脚本):

#!/bin/bash

read -p "请输入成绩: " score

if [ "$score" -ge 90 ]; then
    echo "A"
elif [ "$score" -ge 80 ]; then
    echo "B"
elif [ "$score" -ge 70 ]; then
    echo "C"
else
    echo "D"
fi

8.2 case 多路分支

case 用于把一个值与多个模式比较,常用于菜单处理与多路匹配,比一长串 elif 更清晰。

简略版:

case "$num" in
    A|a) echo "A";;
    B|b) echo "B";;
    C|c) echo "C";;
    *) echo "Other";;
esac

完整版:

case "$num" in
    A|a)
        echo "A"
        ;;
    B|b)
        echo "B"
        ;;
    C|c)
        echo "C"
        ;;
    *)
        echo "Other"
        ;;
esac

语法要点:

  • 每个分支模式后跟 ),语句块以 ;; 结束(;; 表示跳出 case)。
  • | 表示"或",可以匹配多个值。
  • * 匹配所有其他情况,相当于 else,通常放在最后。
  • 结束关键字是 esac(case 反写)。
  • 模式支持通配符(glob),而不是正则:*、?、[0-9]、[a-z]。
  • bash 4 起可用 ;;& 代替 ;; 继续测试后续分支,;& 表示无条件贯穿到下一分支。

通配模式示例:

#!/bin/bash
read -p "请输入内容: " input

case "$input" in
    [0-9])
        echo "一位数字"
        ;;
    [0-9][0-9])
        echo "两位数字"
        ;;
    [a-z]*)
        echo "以小写字母开头"
        ;;
    *.txt)
        echo "文本文件名"
        ;;
    "")
        echo "什么都没输入"
        ;;
    *)
        echo "其他内容"
        ;;
esac

服务控制脚本的经典 case 结构:

#!/bin/bash
case "$1" in
    start)
        echo "starting service..."
        ;;
    stop)
        echo "stopping service..."
        ;;
    restart|reload)
        echo "restarting service..."
        ;;
    status)
        echo "service status..."
        ;;
    *)
        echo "用法: $0 {start|stop|restart|status}"
        exit 2
        ;;
esac

case 随机数菜单(完整脚本):

#!/bin/bash

read -p "是否想得到一个 50-100 的随机数 (yes/no): " choice

case "$choice" in
    yes|YES|Y|y|"")
        echo $((RANDOM % 45 + 55))    # 55~99
        ;;
    no|NO|N|No)
        echo "欢迎下次来玩"
        ;;
    *)
        echo "输入无效"
        ;;
esac

九、流程控制——循环

9.1 for 循环

写法一:列表遍历(迭代对象以 IFS,默认空格/制表符/换行为分隔符):

for 迭代变量 in 迭代对象; do
    循环代码块
done
for i in 1 2 3 4 5 6 7 8 9 10; do
    echo $i
done

for i in 1 2.334 "hello" "1.txt" 5 678.45 7 8 9 10; do
    echo $i
done

# 遍历命令输出
for user in $(cut -d: -f1 /etc/passwd); do
    echo "用户: $user"
done

# 遍历文件(通配符展开)
for f in /var/log/*.log; do
    echo "日志: $f"
done

# 遍历脚本参数(推荐 "$@")
for arg in "$@"; do
    echo "参数: $arg"
done

# 省略 in 列表时默认遍历 "$@"
for arg; do
    echo "参数: $arg"
done

写法二:seq 生成序列

seq 产生一个数字序列用于迭代:

count=18
for i in $(seq 0 $count); do
    echo $i
done
seq 5              # 1 2 3 4 5
seq 2 6            # 2 3 4 5 6
seq 1 2 10         # 1 3 5 7 9   (起始 步长 结束)
seq 10 -2 1        # 10 8 6 4 2
seq -w 1 10        # 01 02 ... 10  等宽补零
seq -s, 1 5        # 1,2,3,4,5     指定分隔符

for i in $(seq -w 1 10); do
    echo "192.168.1.$i"
done

写法三:花括号扩展(bash 内置,效率高于 seq,但不支持变量作为边界):

for i in {1..10}; do echo $i; done
for i in {1..10..2}; do echo $i; done      # 步长 2,bash 4.0+
for i in {01..10}; do echo $i; done        # 补零
for c in {a..z}; do echo -n "$c"; done     # 字母序列
for ip in 192.168.1.{1..254}; do echo $ip; done
echo file{1,2,3}.txt                       # file1.txt file2.txt file3.txt

注意:花括号扩展在变量展开之前完成,所以 {1..$n} 无效,需要变量边界时用 seq 或 C 风格 for。

写法四:C 语言风格双括号

for ((i = 0; i < 5; i++)); do
    echo $i
done

for ((i = 1; i <= 9; i++)); do
    echo $i
done

# 多变量、倒序
for ((i = 10; i > 0; i--)); do echo $i; done
for ((i = 0, j = 10; i < j; i++, j--)); do echo "$i $j"; done

# 无限循环
for ((;;)); do echo "loop"; sleep 1; done

9.2 while 循环

命令成功或条件为真时执行循环体:

while 命令(条件表达式); do
    循环代码块
done
# 计数循环示例
i=0
while [ "$i" -lt 10 ]; do
    echo $i
    ((i++))
done
# 等待条件满足
while ! ping -c 1 -W 1 192.168.1.1 &> /dev/null; do
    echo "等待网络恢复..."
    sleep 3
done
echo "网络已恢复"

9.3 until 循环

命令失败或条件为假时执行循环体(与 while 相反):

until 命令(条件表达式); do
    循环代码块
done
# 计数循环示例
i=0
until [ "$i" -ge 10 ]; do
    echo $i
    ((i++))
done
# 等待端口起来
until ss -tnl | grep -q ':3306'; do
    echo "等待 MySQL 启动..."
    sleep 2
done
echo "MySQL 已就绪"

until 在条件为假时执行循环体,与 while 相反。二者可互相改写:until 条件 等价于 while ! 条件。

9.4 while read line 逐行读文件

这是运维脚本中最常用的读文件套路,正确写法有以下几种:

# 推荐写法:IFS= 保留行首尾空格,-r 不转义反斜杠
while IFS= read -r line; do
    echo "读到: $line"
done < /etc/passwd
# 按分隔符直接拆列(比 awk 更省进程)
while IFS=: read -r user pass uid gid desc home shell; do
    echo "$user 的 UID 是 $uid,Shell 是 $shell"
done < /etc/passwd
# 处理管道输出(注意:管道中 while 在子 Shell 中执行)
cat /etc/passwd | while IFS= read -r line; do
    echo "$line"
done
# 用进程替换避免子 Shell 问题,使循环内的变量在循环外仍然有效
count=0
while IFS= read -r line; do
    ((count++))
done < <(grep bash /etc/passwd)
echo "共 $count 行"        # 正确输出
# 最后一行没有换行符时也不丢数据的写法
while IFS= read -r line || [ -n "$line" ]; do
    echo "$line"
done < data.txt

陷阱:循环体内如果还有读标准输入的命令(如 ssh、mysql、read),会把待处理的行"吃掉"。解决办法是给这些命令加 < /dev/null,或对 ssh 使用 -n 选项。

while read -r host; do
    ssh -n "$host" "uptime"          # 加 -n 防止 ssh 吞掉输入
done < hosts.txt

9.5 无限循环

# 写法一
while true; do
    echo "running"
    sleep 5
done

# 写法二(: 是永真命令,效率略高)
while :; do
    echo "running"
    sleep 5
done

# 写法三
until false; do
    echo "running"
    sleep 5
done

# 写法四
for ((;;)); do
    echo "running"
    sleep 5
done

守护型脚本里的无限循环必须带 sleep,否则会把 CPU 跑满。

9.6 break 与 continue

  • break:退出整个循环。
  • continue:跳过本次循环剩余语句,进入下一次循环。
  • 二者都可带层级参数 break n / continue n,用于操作多层嵌套循环的第 n 层(由内向外计数,break 2 表示跳出两层)。
# break
for i in {1..10}; do
    [ "$i" -eq 5 ] && break
    echo $i             # 输出 1 2 3 4
done

# continue
for i in {1..10}; do
    [ $((i % 2)) -eq 0 ] && continue
    echo $i             # 输出 1 3 5 7 9
done

# break 2:跳出两层循环
for i in {1..3}; do
    for j in {1..3}; do
        if [ "$i" -eq 2 ] && [ "$j" -eq 2 ]; then
            echo "在 i=$i j=$j 处跳出全部循环"
            break 2
        fi
        echo "i=$i j=$j"
    done
done

# continue 2:直接进入外层循环的下一次
for i in {1..3}; do
    for j in {1..3}; do
        [ "$j" -eq 2 ] && continue 2
        echo "i=$i j=$j"
    done
done

exit 与 break 的区别:break 只跳出循环,脚本继续往下执行;exit 直接结束整个脚本。

9.7 select 菜单

select 是 bash 提供的交互式菜单结构,自动打印带序号的选项列表并等待用户输入序号,选项列表存入变量后循环执行,直到 break 或 Ctrl+D。提示符由 PS3 控制,用户输入的原始内容保存在 REPLY。

select 变量 in 列表; do
    循环代码块
done
#!/bin/bash
PS3="请选择要执行的操作(输入序号): "

select action in "查看磁盘" "查看内存" "查看负载" "退出"; do
    case "$action" in
        "查看磁盘") df -h ;;
        "查看内存") free -m ;;
        "查看负载") uptime ;;
        "退出")     break ;;
        *)          echo "无效选项 $REPLY,请重新选择" ;;
    esac
done
#!/bin/bash
# 让用户选择一个日志文件进行查看
PS3="选择日志文件: "
select f in /var/log/*.log 退出; do
    [ "$f" = "退出" ] && break
    [ -n "$f" ] && tail -20 "$f"
done

select 会自动循环显示菜单,不需要自己写 while true;每次循环重新显示菜单可通过按回车触发。


十、位置参数与参数处理

脚本要成为一个可复用的工具,必须能够接收外部传入的参数。Shell 通过位置参数(positional parameters)把命令行上的参数依次传给脚本。

10.1 位置参数与特殊变量总览

变量 含义
$0 脚本自身的名称(含调用时所用的路径)
$1 ~ $9 第 1 ~ 9 个位置参数
${10} 及以后 第 10 个及之后的位置参数,必须加花括号
$n 第 n 个位置参数
$# 位置参数的总个数(不含 $0)
$* 所有位置参数,不加引号时等价于 $1 $2 $3 ...
$@ 所有位置参数,不加引号时等价于 $1 $2 $3 ...
$? 上一条命令的退出状态码,0 为成功,非 0 为失败
$$ 当前脚本(当前 Shell)进程的 PID
$! 最后一个放入后台运行的进程的 PID
$_ 上一条命令的最后一个参数;脚本启动时为脚本的绝对路径
$- 当前 Shell 生效的选项标志字符串,如 himBH

$$ 的补充说明:终端的 PID 是该终端所运行脚本的 PPID;脚本中的 sleep 等命令会开子进程;脚本被杀死后,其中的 sleep 可能成为孤儿进程继续运行。

$? 的补充说明:单纯的赋值语句没有返回码(沿用上一条命令的);把命令结果赋值给变量时,$? 取的是该命令的返回码。

${10} 这个坑

#!/bin/bash
# 保存为 args.sh,然后执行:bash args.sh a b c d e f g h i j k
echo "第 10 个参数(错误写法): $10"      # 被解析成 ${1} 后面跟一个字符 0,输出 a0
echo "第 10 个参数(正确写法): ${10}"    # 输出 j
echo "第 11 个参数: ${11}"               # 输出 k

结论:两位数及以上的位置参数必须写成 ${10}、${11},否则 $10 会被解析为 $1 与字面字符 0 的拼接。

$- 与 $! 示例

#!/bin/bash
echo "当前 shell 选项: $-"        # 例如 hB(非交互脚本);交互式 shell 通常是 himBH

sleep 100 &
echo "后台任务 PID: $!"
wait "$!"                          # 等待该后台任务结束
echo "脚本自身 PID: $$"

ls /etc/passwd
echo "上一条命令的最后一个参数: $_"   # /etc/passwd

10.2 $* 与 $@ 的区别(重点,用 for 循环实测)

  • 不加双引号时,$* 与 $@ 行为完全相同:都按 IFS 拆分成多个词。
  • 加双引号后二者产生本质差异:
    • "$*" 把所有参数用 IFS 的第一个字符(默认空格)连接成一个整体字符串。
    • "$@" 把每个参数保留为各自独立的字符串,是遍历参数的唯一正确写法。

实测脚本:

#!/bin/bash
# 保存为 star_at.sh,执行:bash star_at.sh "hello world" "foo bar" baz

echo "===== 参数个数: $# ====="

echo '--- for i in "$*" ---'
for i in "$*"; do
    echo "[$i]"
done
# 输出只有一行:
# [hello world foo bar baz]

echo '--- for i in "$@" ---'
for i in "$@"; do
    echo "[$i]"
done
# 输出三行,含空格的参数没有被拆开:
# [hello world]
# [foo bar]
# [baz]

echo '--- for i in $* (无引号)---'
for i in $*; do
    echo "[$i]"
done
# 输出五行,含空格的参数被空格拆开了:
# [hello] [world] [foo] [bar] [baz]

echo '--- for i in $@ (无引号)---'
for i in $@; do
    echo "[$i]"
done
# 与上一组完全相同,也是五行

对照表:

写法 结果 建议
$* 拆成 5 个词,含空格的参数被破坏 不要用
$@ 拆成 5 个词,含空格的参数被破坏 不要用
"$*" 合并成 1 个字符串 仅在需要拼接成一句话时用(如写日志)
"$@" 保留 3 个独立参数 遍历参数、转发参数一律用这个

转发参数给另一个命令时也必须用 "$@":

# 正确:原样把本脚本收到的参数转发给 rsync
rsync -av "$@"

# 错误:含空格的路径会被拆散
rsync -av $*

10.3 shift 移动位置参数

shift 把位置参数整体左移一位:$2 变成 $1,$3 变成 $2……$# 相应减 1,$0 不受影响。

shift        # 左移 1 位,等价于 shift 1
shift 3      # 左移 3 位

配合 while 遍历全部参数:

#!/bin/bash
while [ $# -gt 0 ]; do
    echo "当前第一个参数: $1(还剩 $# 个)"
    shift
done

注意:shift n 时若 n 大于 $#,shift 返回非 0 且不做任何移动(在 set -e 下会导致脚本退出)。

10.4 set -- 重设位置参数

set -- 可以在脚本运行过程中重新设定位置参数,常用于把命令输出重新组织成参数列表。

#!/bin/bash
set -- alpha beta gamma
echo "$#"        # 3
echo "$2"        # beta

# 把命令输出变成位置参数(按 IFS 分词)
set -- $(date +"%Y %m %d")
echo "年=$1 月=$2 日=$3"

# 清空所有位置参数
set --
echo "$#"        # 0

-- 的作用是告诉 set 后面的内容都是参数而不是选项,否则以 - 开头的值会被当成 set 的选项。

10.5 参数校验与 usage 函数标准写法

任何接收参数的脚本都应该在开头做参数校验,并提供统一的用法提示。

#!/bin/bash
# 标准 usage 函数写法:用法信息写到标准错误,退出码非 0

usage() {
    cat >&2 <<EOF
用法: $(basename "$0") [选项] <源目录> <目标目录>

选项:
  -h, --help        显示本帮助信息
  -v, --verbose     输出详细过程
  -n, --dry-run     只打印将要执行的操作,不实际执行

示例:
  $(basename "$0") -v /data /backup
EOF
    exit 1
}

# 参数个数校验
if [ $# -lt 2 ]; then
    echo "错误: 参数不足,至少需要 2 个参数" >&2
    usage
fi

参数为空的多种判断写法(等价):

[ $# -eq 0 ] && usage
[ -z "$1" ] && usage
[ "$#" -lt 1 ] && usage
if [ ! -n "$1" ]; then usage; fi

10.6 默认值 ${1:-默认值}

#!/bin/bash
# 第 1 个参数不给就用默认值,避免脚本因空参数出错
dir=${1:-/tmp}                 # $1 为空或未设置时取 /tmp
days=${2:-7}                   # 默认保留 7 天
logfile=${3:-/var/log/my.log}

echo "目录=$dir 天数=$days 日志=$logfile"

# 也可直接对位置参数做非空断言,为空则报错退出
target=${1:?"错误: 必须指定目标目录"}

10.7 手工解析选项:while + case 完整模板

当需要同时支持短选项(-v)和长选项(--verbose)时,getopts 不支持长选项,此时用 while + case 手工解析最灵活。这是生产脚本中最常用的模板:

#!/bin/bash
# 手工参数解析完整模板:支持短选项、长选项、带值选项、-- 终止符

verbose=0
dry_run=0
output=""
declare -a targets=()

usage() {
    echo "用法: $0 [-v] [-n] [-o 文件] [--] <目标>..." >&2
    exit 1
}

while [ $# -gt 0 ]; do
    case "$1" in
        -h|--help)
            usage
            ;;
        -v|--verbose)
            verbose=1
            shift                      # 布尔选项只消耗 1 个参数
            ;;
        -n|--dry-run)
            dry_run=1
            shift
            ;;
        -o|--output)
            # 带值选项:先校验后面还有值
            if [ -z "$2" ]; then
                echo "错误: $1 需要一个参数值" >&2
                exit 2
            fi
            output="$2"
            shift 2                    # 带值选项消耗 2 个参数
            ;;
        --output=*)
            # 支持 --output=xxx 这种等号写法
            output="${1#*=}"
            shift
            ;;
        --)
            shift                      # 遇到 -- 后面全部当普通参数
            break
            ;;
        -*)
            echo "错误: 未知选项 $1" >&2
            usage
            ;;
        *)
            targets+=("$1")            # 收集普通参数
            shift
            ;;
    esac
done

# -- 之后剩余的参数也并入目标列表
while [ $# -gt 0 ]; do
    targets+=("$1")
    shift
done

echo "verbose=$verbose dry_run=$dry_run output=$output"
echo "目标个数=${#targets[@]} 目标列表=${targets[*]}"

10.8 getopts 内置命令

getopts 是 bash 内置命令,专门解析短选项,语法:

getopts 选项字符串 变量名

规则:

写法 含义
getopts "abc" opt 支持 -a、-b、-c 三个不带值的选项
getopts "a:b" opt 选项字母后跟 : 表示该选项必须带一个值,值存入 OPTARG
getopts ":ab:" opt 选项字符串以 : 开头表示静默错误模式,由脚本自己处理错误

相关变量:

变量 含义
OPTARG 当前带值选项的参数值
OPTIND 下一个待处理参数的索引(初始为 1),循环结束后用 shift $((OPTIND - 1)) 丢弃已解析的选项
opt 的值 ? 遇到未定义的选项
opt 的值 : 静默模式下带值选项缺少值,缺值的选项字母存于 OPTARG

完整示例:

#!/bin/bash
# getopts 完整示例:bash demo.sh -v -u alice -p 8080 file1 file2

verbose=0
user=""
port=80

usage() {
    echo "用法: $0 [-v] [-u 用户名] [-p 端口] 文件..." >&2
    exit 1
}

# 选项字符串开头的 : 启用静默错误处理,u 和 p 后的 : 表示需要带值
while getopts ":vhu:p:" opt; do
    case "$opt" in
        v)
            verbose=1
            ;;
        u)
            user="$OPTARG"
            ;;
        p)
            port="$OPTARG"
            ;;
        h)
            usage
            ;;
        :)
            echo "错误: 选项 -$OPTARG 缺少参数值" >&2
            exit 2
            ;;
        \?)
            echo "错误: 未知选项 -$OPTARG" >&2
            usage
            ;;
    esac
done

# 关键一步:丢弃已被 getopts 处理掉的选项,让 $1 指向第一个普通参数
shift $((OPTIND - 1))

echo "verbose=$verbose user=$user port=$port"
echo "剩余参数个数=$# 列表=$*"

for f in "$@"; do
    echo "处理文件: $f"
done

注意:OPTIND 是全局变量。若在函数内使用 getopts,应先 local OPTIND=1,否则第二次调用该函数时解析会从上次的位置继续,导致选项丢失。

10.9 getopt 与 getopts 的区别

对比项 getopts(bash 内置) getopt(外部命令,util-linux 提供)
类型 Shell 内置命令 独立的外部可执行程序 /usr/bin/getopt
长选项 不支持 --verbose 增强版支持 -l/--longoptions
参数重排 不重排,选项必须写在普通参数之前 可重排,选项和参数可交错书写
空格/特殊字符 处理安全 必须配合 eval set -- "$(getopt ...)" 与 -o 才安全;老版本会破坏含空格的参数
可移植性 POSIX 标准,各 Shell 通用 依赖具体实现,GNU 增强版与 BSD 版差异大
用法复杂度 简单 较复杂,需要 eval

getopt 支持长选项的写法:

#!/bin/bash
# 用 GNU getopt 解析长选项
ARGS=$(getopt -o vhu:p: -l verbose,help,user:,port: -n "$0" -- "$@") || exit 1
eval set -- "$ARGS"

while true; do
    case "$1" in
        -v|--verbose) verbose=1; shift ;;
        -u|--user)    user="$2"; shift 2 ;;
        -p|--port)    port="$2"; shift 2 ;;
        -h|--help)    usage ;;
        --)           shift; break ;;
        *)            echo "内部错误"; exit 1 ;;
    esac
done

echo "剩余参数: $*"

选型建议:

  • 只需短选项 → 用 getopts,最简单可靠。
  • 需要长选项 → 优先用 10.7 的 while + case 手工模板(无外部依赖),其次用 GNU getopt。

十一、数组

数组用于批量存储同类型数据。Shell 只支持一维数组,且数组不能直接作为函数参数传递。

11.1 普通(索引)数组的定义与赋值

多种赋值方式:

# 1) 先声明再赋值(declare -a 可省略,直接赋值即自动成为索引数组)
declare -a arr

# 2) 整体赋值,元素以空格分隔,下标自动从 0 开始
arr=(1 2 3 4 5)
arr=(1 2.334 "hello" "1.txt" 678.45)          # 元素类型可以混杂

# 3) 按索引逐个赋值(可以跳号,形成稀疏数组)
arr[0]=tom
arr[1]=jerry
arr[5]=spike                                   # 中间 2~4 为空

# 4) 定义时指定下标
arr=([0]=tom [1]=jerry [5]=spike)

# 5) 从命令输出赋值(按 IFS 分词)
files=($(ls /etc/*.conf))
lines=($(cat /etc/passwd))                     # 每个单词一个元素,慎用

# 6) 从命令输出按行赋值(bash 4+,推荐,能保留含空格的行)
mapfile -t users < /etc/passwd                 # mapfile 即 readarray
readarray -t users < /etc/passwd

# 7) 大括号展开与 seq 赋值
nums=({1..10})
nums=($(seq 1 10))

# 8) 字符串按分隔符切分成数组
str="a:b:c:d"
IFS=':' read -r -a parts <<< "$str"
echo "${parts[2]}"                             # c

11.2 取值与遍历

arr=(tom jerry spike tyke)

echo "${arr[0]}"          # tom,访问指定下标,下标从 0 开始
echo "${arr[2]}"          # spike
echo "${arr}"             # tom,省略下标等价于 ${arr[0]}
echo "${arr[@]}"          # tom jerry spike tyke,所有元素
echo "${arr[*]}"          # tom jerry spike tyke,所有元素

${arr[@]} 与 ${arr[*]} 的区别与 $@/$* 完全一致,加双引号后才有差别:

arr=("hello world" "foo bar" baz)

echo '--- for i in "${arr[@]}" (唯一正确的遍历写法)---'
for i in "${arr[@]}"; do
    echo "[$i]"
done
# [hello world]
# [foo bar]
# [baz]

echo '--- for i in "${arr[*]}" ---'
for i in "${arr[*]}"; do
    echo "[$i]"
done
# [hello world foo bar baz]    只有一个整体元素

echo '--- for i in ${arr[@]} (无引号,元素被空格拆散)---'
for i in ${arr[@]}; do
    echo "[$i]"
done
# [hello] [world] [foo] [bar] [baz]

规范:遍历数组一律写 for i in "${arr[@]}",任何省略双引号的写法都会在元素含空格时出错。

11.3 长度、索引列表与切片

arr=(tom jerry spike tyke)

# 数组元素个数
echo "${#arr[@]}"         # 4
echo "${#arr[*]}"         # 4,等价

# 单个元素的字符串长度
echo "${#arr[1]}"         # 5(jerry 有 5 个字符)
echo "${#arr[0]}"         # 3

# 所有下标(键名)列表;索引数组的键名形如 0 1 2 3
echo "${!arr[@]}"         # 0 1 2 3
echo "${!arr[*]}"         # 0 1 2 3

# 取最后一个元素(CentOS 7 自带 bash 4.2 不支持负下标)
echo "${arr[${#arr[@]}-1]}"      # tyke,通用写法
echo "${arr[-1]}"                # tyke,需要 bash 4.3+

# 切片:${arr[@]:起始下标:个数}
echo "${arr[@]:1:2}"      # jerry spike
echo "${arr[@]:2}"        # spike tyke,从下标 2 到末尾
echo "${arr[@]: -2}"      # spike tyke,负偏移前面必须有空格
echo "${arr[@]:0:1}"      # tom

# 对单个元素做字符串切片
echo "${arr[1]:0:3}"      # jer

版本差异:负数下标(${arr[-1]})需要 bash 4.3 及以上。CentOS 7 自带 bash 4.2,使用负数下标会报 bad array subscript;CentOS 6 自带 bash 4.1,同样不支持。通用写法是 ${arr[${#arr[@]}-1]}。

11.4 追加、修改与删除元素

arr=(1 2 3 4)

# 修改指定下标的值
arr[1]=twenty

# 追加元素(推荐写法)
arr+=(5 6 7)

# 用「当前长度」作为下标追加一个变量的值
num=99
arr[${#arr[@]}]=$num

# 删除指定下标的元素
unset arr[3]

# 删除整个数组
unset arr

unset 只清空对应下标的元素值,不会重排其他元素的下标,会产生稀疏数组:

a=(1 2 3 4 5)
len1=${#a[*]}        # 5
unset a[3]           # 删除下标 3
len2=${#a[*]}        # 4,但剩余下标是 0 1 2 4,不是 0 1 2 3

echo "${!a[@]}"      # 0 1 2 4,下标 3 已不存在

# 直接遍历值时无法感知原下标
for i in "${a[@]}"; do
    echo "$i"
done

# 配合下标遍历才能正确对应(推荐用 ${!a[@]} 而非自增计数器)
for idx in "${!a[@]}"; do
    echo "下标为 $idx 的值是: ${a[$idx]}"
done

# 讲义中的自增计数器写法(在稀疏数组上会错位,仅作对照)
x=0
for i in "${a[*]}"; do
    echo "下标为 $x 的值是: ${a[$x]}"
    let x+=1
done

# 想让下标重新连续,需要重建数组
a=("${a[@]}")
echo "${!a[@]}"      # 0 1 2 3

11.5 关联数组(键值对数组)

关联数组用字符串作键,必须先用 declare -A 声明,否则赋值会被当成索引数组处理。

版本差异(重要):关联数组是 bash 4.0 引入的特性。

  • CentOS 7 自带 bash 4.2,支持关联数组。
  • CentOS 6 自带 bash 4.1,支持关联数组(但 bash 3.x 的系统,如 RHEL 5 / macOS 自带 bash 3.2,不支持,会报 declare: -A: invalid option)。
  • 在需要兼容 bash 3 的场景,只能用「索引数组 + 字符串拼接」或临时文件模拟键值对。 检查版本:echo $BASH_VERSION 或 bash --version。
# 声明(必须)
declare -A map

# 逐个赋值
map["name1"]=soul
map["name2"]=shell
map[key]=value

# 整体赋值
declare -A map=(["name1"]=soul ["name2"]=shell ["name3"]=bash)

# 取值(键名建议加引号,含空格或特殊字符时必须加)
echo "${map["name1"]}"     # soul
echo "${map[name2]}"       # shell

# 所有值 / 所有键
echo "${map[@]}"           # 所有值
echo "${!map[@]}"          # 所有键名
echo "${#map[@]}"          # 键值对个数

# 判断键是否存在(-v 需要 bash 4.2+)
if [ -v map["name1"] ]; then echo "键存在"; fi
if [ -n "${map[name1]+x}" ]; then echo "键存在(通用写法)"; fi

# 删除某个键
unset map["name1"]

# 删除整个关联数组
unset map

遍历关联数组(顺序无保证,需有序输出时对键排序):

declare -A svc=([httpd]=80 [sshd]=22 [mysqld]=3306)

# 遍历键
for k in "${!svc[@]}"; do
    echo "$k -> ${svc[$k]}"
done

# 按键名排序遍历
for k in $(echo "${!svc[@]}" | tr ' ' '\n' | sort); do
    echo "$k -> ${svc[$k]}"
done

场景一:用关联数组计数(统计)

#!/bin/bash
# 统计 /etc/passwd 中每种 shell 的使用次数
declare -A count

while IFS=: read -r user _ _ _ _ _ shell; do
    ((count["$shell"]++))          # 键不存在时自动从 0 开始
done < /etc/passwd

for sh in "${!count[@]}"; do
    printf "%-20s %d\n" "$sh" "${count[$sh]}"
done

场景二:用关联数组做查表(映射)

#!/bin/bash
# 端口 -> 服务名 的查表
declare -A port2svc=(
    [22]="SSH"
    [25]="SMTP"
    [80]="HTTP"
    [443]="HTTPS"
    [3306]="MySQL"
)

read -p "请输入端口号: " p
if [ -n "${port2svc[$p]+x}" ]; then
    echo "端口 $p 对应服务: ${port2svc[$p]}"
else
    echo "未知端口: $p"
fi

场景三:索引/键值混合数组

语法上允许,但强烈不建议使用,因为可读性极差且行为容易混淆:

数组名[键]=值
数组名=(值1 值2 值3 ...)

11.6 数组作为函数参数传递的注意点

Shell 的数组不能直接作为一个参数整体传给函数,因为传参时数组会被展开成多个独立参数。

#!/bin/bash
arr=("hello world" foo bar)

# 错误认知:以下写法传进去的是 3 个参数,不是 1 个数组
show() {
    echo "收到参数个数: $#"
    echo "第一个参数: $1"
}
show "${arr[@]}"          # 收到 3 个参数

# 正确做法一:在函数内用 "$@" 重建数组(最常用)
process() {
    local -a items=("$@")             # 用参数重建局部数组
    echo "元素个数: ${#items[@]}"
    for i in "${items[@]}"; do
        echo "  -> $i"
    done
}
process "${arr[@]}"

# 正确做法二:传数组名,用间接引用取值(bash 4.3+ 用 local -n 名称引用)
by_name() {
    local name="$1"
    local -n ref="$name"              # nameref,需要 bash 4.3+
    echo "个数: ${#ref[@]} 内容: ${ref[*]}"
}
by_name arr

# 正确做法三:兼容老 bash 的间接引用(eval,注意安全)
by_eval() {
    local name="$1"
    eval "local -a copy=(\"\${${name}[@]}\")"
    echo "个数: ${#copy[@]}"
}
by_eval arr

# 正确做法四:混合传参时把数组放在最后
# func 固定参数1 固定参数2 "${arr[@]}"
mixed() {
    local prefix="$1"
    shift                              # 丢掉固定参数
    local -a items=("$@")
    for i in "${items[@]}"; do
        echo "$prefix$i"
    done
}
mixed ">> " "${arr[@]}"

11.7 遍历数组的完整示例

#!/bin/bash
# 数组遍历四种典型场景

servers=("web01 192.168.1.11" "web02 192.168.1.12" "db01 192.168.1.20")

echo "===== 1. 按值遍历 ====="
for s in "${servers[@]}"; do
    echo "记录: $s"
done

echo "===== 2. 按下标遍历(可拿到序号,支持稀疏数组) ====="
for i in "${!servers[@]}"; do
    echo "第 $((i + 1)) 条: ${servers[$i]}"
done

echo "===== 3. C 风格 for 遍历(仅适用于连续下标) ====="
for ((i = 0; i < ${#servers[@]}; i++)); do
    echo "[$i] ${servers[$i]}"
done

echo "===== 4. while + 下标遍历 ====="
i=0
while [ $i -lt ${#servers[@]} ]; do
    # 把一条记录再拆成主机名和 IP
    set -- ${servers[$i]}
    printf "主机名=%-8s IP=%s\n" "$1" "$2"
    ((i++))
done

十二、字符串处理

Shell 变量默认无数据类型,一切皆字符串。bash 内置的变量扩展(parameter expansion)可以在不调用外部命令的情况下完成绝大部分字符串操作,性能远高于 expr、sed、awk。

12.1 字符串长度 ${#str}

a="hello shell world"
echo "${#a}"          # 17
echo "${#HOME}"       # 家目录路径的字符数

# 判断字符串是否为空
[ ${#a} -eq 0 ] && echo "空串"
[ -z "$a" ] && echo "空串(推荐写法)"

12.2 子串提取 ${str:offset} 与 ${str:offset:len}

str="hello shell world"

echo "${str:6}"          # shell world      从下标 6(第 7 个字符)到末尾
echo "${str:6:5}"        # shell            从下标 6 开始取 5 个字符
echo "${str:0:5}"        # hello
echo "${str:0-5}"        # world            从倒数第 5 个开始到末尾
echo "${str:0-5:3}"      # wor              从倒数第 5 个开始取 3 个

负偏移的坑:直接写 ${str:-5} 会被解析成「变量为空时取默认值 5」,必须用空格或括号消除歧义:

str="hello shell world"

echo "${str:-5}"         # 错!输出整个 hello shell world(被当成默认值语法)
echo "${str: -5}"        # 正确:world,冒号后加一个空格
echo "${str:(-5)}"       # 正确:world,用小括号包裹
echo "${str:0-5}"        # 正确:world,用 0-5 表达式(讲义写法)
n=-5; echo "${str:$n}"   # 正确:world,用变量
echo "${str: -11:5}"     # shell,负偏移 + 长度

对照表(讲义原表):

写法 含义
${变量名:起始位置:截取个数} 从起始位置(从 0 开始)截取指定个数
${a:7} 从第 8 个字符开始到结束
${var:0-7:3} 从倒数第 7 个开始,截取 3 个
${var:0-7} 从倒数第 7 个开始,截取到最后

12.3 掐头去尾 # ## % %%

记忆口诀:# 在键盘上 $ 的左边,所以从左边(头部)删;% 在 $ 的右边,所以从右边(尾部)删。单个符号是最短匹配(贪婪度最低),双个符号是最长匹配。

写法 含义
${str#pattern} 从左边删除最短匹配 pattern 的部分
${str##pattern} 从左边删除最长匹配 pattern 的部分
${str%pattern} 从右边删除最短匹配 pattern 的部分
${str%%pattern} 从右边删除最长匹配 pattern 的部分

讲义原表(等价说法):

写法 含义
${变量名#*关键词} 截取第一个关键词后面的内容(最短匹配)
${变量名##*关键词} 截取最后一个关键词后面的内容(最长匹配)
${变量名%关键词*} 截取最后一个关键词前面的内容
${变量名%%关键词*} 截取第一个关键词前面的内容

对照示例:

str="a.b.c.d.txt"

echo "${str#*.}"        # b.c.d.txt     从左删到第一个 . (最短)
echo "${str##*.}"       # txt           从左删到最后一个 . (最长)
echo "${str%.*}"        # a.b.c.d       从右删到最后一个 . (最短)
echo "${str%%.*}"       # a             从右删到第一个 . (最长)

pattern 支持通配符(*、?、[...]),不是正则:

path="/var/log/nginx/access.log.2024-05-01.gz"

echo "${path##*/}"                 # access.log.2024-05-01.gz   取文件名(等价 basename)
echo "${path%/*}"                  # /var/log/nginx             取目录名(等价 dirname)
echo "${path##*.}"                 # gz                         取最后的扩展名
echo "${path%.*}"                  # ...access.log.2024-05-01   去掉最后的扩展名
echo "${path%%.*}"                 # /var/log/nginx/access      去掉全部扩展名

实用例子:

# 取文件名与扩展名
f="/opt/backup/db_full.sql.gz"
name="${f##*/}"                    # db_full.sql.gz
dir="${f%/*}"                      # /opt/backup
ext="${name##*.}"                  # gz
base="${name%%.*}"                 # db_full
base2="${name%.*}"                 # db_full.sql(只去掉一层扩展名)

echo "目录=$dir 文件名=$name 主名=$base 扩展名=$ext"

# 批量把 .txt 改名为 .bak(纯内置扩展,不调用外部命令)
for f in *.txt; do
    mv -- "$f" "${f%.txt}.bak"
done

# 去掉前缀
url="http://www.baidu.com/index.html"
echo "${url#*://}"                 # www.baidu.com/index.html   去掉协议
echo "${url%%://*}"                # http                       只取协议
echo "${url#*://}" | cut -d/ -f1   # www.baidu.com              取域名

# 去掉固定前缀 / 后缀
s="prefix_value_suffix"
echo "${s#prefix_}"                # value_suffix
echo "${s%_suffix}"                # prefix_value

12.4 替换 / // /# /%

写法 含义
${str/old/new} 替换第一个匹配
${str//old/new} 替换全部匹配
${str/#old/new} 仅当字符串以 old 开头时才替换(锚定行首)
${str/%old/new} 仅当字符串以 old 结尾时才替换(锚定行尾)
${str/old/} 把 old 删除(new 留空即为删除)
${str//old/} 删除全部 old
a="hello shell world"

echo "${a/l/L}"          # heLlo shell world      只替换第 1 个 l
echo "${a//l/L}"         # heLLo sheLL worLd      替换所有 l
echo "${a/#hello/HI}"    # HI shell world         以 hello 开头才替换
echo "${a/#world/HI}"    # hello shell world      不以 world 开头,原样返回
echo "${a/%world/EARTH}" # hello shell EARTH      以 world 结尾才替换
echo "${a// /_}"         # hello_shell_world      空格全换成下划线
echo "${a//l/}"          # heo she word           删除所有 l

# old 部分支持通配符
p="/usr/local/bin"
echo "${p//\//-}"        # -usr-local-bin         / 需要转义
echo "${p/usr*bin/X}"    # /X

# 用 for 循环替换「第 n 个」某字符(讲义 PPP5 思路)
str="hello world hello uplooking hello YW201809"
tmp="$str"
for i in 1 2 3; do
    tmp="${tmp/o/O}"     # 每循环一次替换掉最靠左的一个 o,共替换前 3 个
done
echo "$tmp"

12.5 大小写转换(bash 4.0+)

写法 含义
${str^} 首字母转大写
${str^^} 全部转大写
${str,} 首字母转小写
${str,,} 全部转小写
${str~} / ${str~~} 首字母 / 全部大小写反转
s="hello World"

echo "${s^}"        # Hello World
echo "${s^^}"       # HELLO WORLD
echo "${s,,}"       # hello world
S="HELLO"
echo "${S,}"        # hELLO

# 大小写不敏感的输入判断
read -p "继续吗 (y/n)? " ans
if [ "${ans,,}" = "y" ]; then
    echo "继续"
fi

版本差异:^ ^^ , ,, 需要 bash 4.0+,CentOS 6/7 均支持。bash 3.x 需改用 tr '[:lower:]' '[:upper:]' 或 declare -u / declare -l。

12.6 默认值与断言(差异对照表)

关键区别在于有没有冒号:带冒号的把「变量未定义」和「变量为空串」同等对待;不带冒号的只判断「变量是否未定义」。

写法 变量未定义 变量为空串 "" 变量有值 是否修改原变量
${var:-word} 返回 word 返回 word 返回 $var 否
${var-word} 返回 word 返回空串 返回 $var 否
${var:=word} 返回 word 返回 word 返回 $var 是,同时把 word 赋给 var
${var=word} 返回 word 返回空串 返回 $var 未定义时赋值
${var:?msg} 打印 msg 到 stderr 并退出 打印 msg 并退出 返回 $var 否
${var?msg} 打印 msg 并退出 返回空串 返回 $var 否
${var:+word} 返回空串 返回空串 返回 word 否
${var+word} 返回空串 返回 word 返回 word 否

实测:

#!/bin/bash
unset a                 # a 未定义
b=""                    # b 为空串
c="value"               # c 有值

echo "1: [${a:-默认}]"    # [默认]
echo "2: [${b:-默认}]"    # [默认]     空串也用默认值
echo "3: [${b-默认}]"     # []         空串保持空串
echo "4: [${c:-默认}]"    # [value]

# := 会真正给变量赋值
echo "5: [${d:=写入}]"    # [写入]
echo "6: [$d]"           # [写入]     d 已被赋值

# :+ 与 :- 相反:有值时才输出 word,常用于拼接可选参数
opts=""
[ -n "$LOGFILE" ] && opts="--log $LOGFILE"
opts="${LOGFILE:+--log $LOGFILE}"        # 等价的一行写法
echo "7: [$opts]"

# :? 用于必填参数断言,缺失直接终止脚本
target="${1:?"错误: 必须指定目标目录"}"

讲义写法 echo ${变量名-默认值}(PPP6 题)实测:

name1=zhangsan; name2=lisi; name3=wangwu; name4=zhaoliu
echo "${name1-unknow} ${name2-unknow} ${name3-unknow} ${name4-unknow}"
# zhangsan lisi wangwu zhaoliu

unset name2 name4
echo "${name1-unknow} ${name2-unknow} ${name3-unknow} ${name4-unknow}"
# zhangsan unknow wangwu unknow

12.7 间接引用 ${!var} 与变量名匹配 ${!prefix*}

# 间接引用:用变量的值作为变量名去取值
name="hostname"
hostname="web01"
echo "${!name}"          # web01

# 典型用途:批量按规律取变量
web1="192.168.1.11"
web2="192.168.1.12"
web3="192.168.1.13"
for i in 1 2 3; do
    var="web$i"
    echo "web$i 的 IP 是 ${!var}"
done

# 变量名前缀匹配:列出所有以 web 开头的变量名
echo "${!web*}"          # web1 web2 web3
echo "${!web@}"          # web1 web2 web3

for v in ${!web*}; do
    echo "$v = ${!v}"
done

# 列出所有 HIST 开头的环境变量名
echo "${!HIST*}"         # HISTCONTROL HISTFILE HISTFILESIZE HISTSIZE ...

bash 4.3+ 还提供 local -n ref=varname 名称引用(nameref),比 ${!var} 更强大,可直接对引用赋值。

12.8 字符串比较、模式匹配与正则匹配

基础比较([ ] 与 [[ ]] 通用):

a="hello"; b="world"

[ "$a" = "$b" ]           # 相等(POSIX 标准写法)
[ "$a" == "$b" ]          # 相等(bash 扩展,与 = 等价)
[ "$a" != "$b" ]          # 不相等
[ -z "$a" ]               # 长度为 0
[ -n "$a" ]               # 长度不为 0

# 字典序比较:在 [[ ]] 中可直接用 < >;在 [ ] 中必须转义为 \< \>
[[ "$a" < "$b" ]] && echo "a 在字典序上小于 b"
[ "$a" \< "$b" ] && echo "同上,[ ] 里必须转义"

[[ ]] 中 == 右侧是通配符模式(glob),不是正则;右侧不能加引号,加引号就变成字面比较:

f="access.log"

[[ "$f" == *.log ]]       && echo "是 .log 文件"        # 模式匹配,成立
[[ "$f" == "*.log" ]]     && echo "不会执行"            # 加引号变字面比较,不成立
[[ "$f" == access* ]]     && echo "以 access 开头"
[[ "$f" == ?????.log ]]   && echo "主名恰好 6 个字符"
[[ "$f" == [ab]* ]]       && echo "以 a 或 b 开头"
[[ "$f" != *.txt ]]       && echo "不是 .txt 文件"

=~ 正则匹配(bash 3.0+,仅 [[ ]] 内可用,使用 ERE 扩展正则):

num="12345"
[[ "$num" =~ ^[0-9]+$ ]] && echo "是纯数字"

ip="192.168.1.100"
if [[ "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then
    echo "IP 格式合法"
fi

# 正则里的模式建议放到变量中(避免引号导致模式退化为字面串)
re='^1[3-9][0-9]{9}$'
phone="13812345678"
[[ "$phone" =~ $re ]] && echo "手机号合法"      # $re 不要加引号

BASH_REMATCH 数组保存匹配结果:${BASH_REMATCH[0]} 是整个匹配,${BASH_REMATCH[1]} 起是各个分组:

#!/bin/bash
line="2024-05-17 08:30:45 ERROR disk full"
re='^([0-9]{4})-([0-9]{2})-([0-9]{2}) ([0-9:]+) ([A-Z]+) (.*)$'

if [[ "$line" =~ $re ]]; then
    echo "整体匹配: ${BASH_REMATCH[0]}"
    echo "年:   ${BASH_REMATCH[1]}"
    echo "月:   ${BASH_REMATCH[2]}"
    echo "日:   ${BASH_REMATCH[3]}"
    echo "时间: ${BASH_REMATCH[4]}"
    echo "级别: ${BASH_REMATCH[5]}"
    echo "内容: ${BASH_REMATCH[6]}"
    echo "分组个数: $((${#BASH_REMATCH[@]} - 1))"
fi

# 提取版本号中的主次版本
ver="nginx/1.20.1"
if [[ "$ver" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
    echo "major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]}"
fi

BASH_REMATCH 在每次 =~ 匹配后被重写,需要复用时应先拷贝到自己的变量里。

12.9 拼接、去空格与判空的常用写法

# ---------- 拼接 ----------
a="hello"; b="world"
c="$a$b"                     # helloworld
c="$a $b"                    # hello world
c="${a}_${b}"                # hello_world,用 {} 明确变量边界
c="$a"'-固定文本-'"$b"        # 混合引号拼接
a+="!!!"                     # 追加,结果 hello!!!
str1="hello world "
str2=$str1"hello shell"      # 讲义写法,结果 hello world hello shell

# 循环拼接(构造逗号分隔列表)
list=""
for x in a b c; do
    list="${list:+$list,}$x" # 利用 :+ 避免开头多一个逗号
done
echo "$list"                 # a,b,c

# ---------- 去空格 ----------
s="   hello   world   "

echo "[${s#"${s%%[![:space:]]*}"}]"          # 去左侧空白
echo "[${s%"${s##*[![:space:]]}"}]"          # 去右侧空白

# 更易读的写法(bash 内置,需 extglob)
shopt -s extglob
t="$s"
t="${t##+([[:space:]])}"                     # 去左
t="${t%%+([[:space:]])}"                     # 去右
echo "[$t]"

# 用 xargs / tr / sed 去空格(外部命令)
echo "$s" | xargs                            # hello world,同时压缩中间空格
echo "$s" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
echo "$s" | tr -d ' '                        # 删掉所有空格:helloworld
echo "$s" | tr -s ' '                        # 压缩连续空格为一个

# 用 read 去首尾空白(利用 IFS 分词特性)
read -r t <<< "$s"; echo "[$t]"

# ---------- 判空 ----------
[ -z "$var" ]           && echo "空或未定义"
[ -n "$var" ]           && echo "非空"
[ "x$var" = "x" ]       && echo "空(老式兼容写法,无需引号也安全)"
[ ${#var} -eq 0 ]       && echo "空"
[[ -z "${var// /}" ]]   && echo "空或只含空格"
[ -z "${var:-}" ]       && echo "空(在 set -u 下也安全)"

12.10 expr / awk / tr / sed 处理字符串的替代方案

在 bash 内置扩展无法胜任时才使用外部命令。选择依据:

需求 首选方案 原因
取长度、截取、掐头去尾、替换、大小写 bash 内置 ${} 不 fork 进程,最快;循环中差异可达几十倍
字符级删除 / 压缩 / 转换(如删所有数字、压缩空格) tr 语法最简,专职做字符集映射
按分隔符取字段 cut -d -f 或 awk -F cut 更快,awk 更灵活(支持正则分隔符、负向取字段)
复杂正则替换、多行处理、按行号处理 sed 流编辑器,正则能力强
需要计算、格式化、多字段联动 awk 是完整编程语言
老式子串 / 长度 / 正则(POSIX 兼容脚本) expr 可移植性好,但语法晦涩、已过时
# ---------- expr(已过时,仅作了解) ----------
str="hello world"
expr length "$str"                       # 11        长度
expr substr "$str" 1 5                   # hello     截取(下标从 1 开始)
expr index "$str" wo                     # 7         首次出现位置
expr "$str" : '.*'                       # 11        匹配长度
expr "$str" : 'hello \(.*\)'             # world     分组提取
expr 3 + 4                               # 7         算术(注意 * 需转义为 \*)

# ---------- tr ----------
echo "$str" | tr 'a-z' 'A-Z'             # HELLO WORLD    大小写转换
echo "$str" | tr -d 'lo'                 # he wrd         删除指定字符
echo "aabbcc" | tr -s 'a'                # abbcc          压缩重复字符
echo "$str" | tr ' ' '\n'                # 按空格拆行
echo "abc123" | tr -dc '0-9'             # 123            只保留数字(-c 取反)

# ---------- awk ----------
echo "$str" | awk '{print length($0)}'           # 11
echo "$str" | awk '{print toupper($0)}'          # HELLO WORLD
echo "$str" | awk '{print substr($0, 1, 5)}'     # hello
echo "a:b:c" | awk -F: '{print $2}'              # b
echo "a:b:c" | awk -F: '{print $NF}'             # c   最后一个字段
echo "$str" | awk '{gsub(/o/, "0"); print}'      # hell0 w0rld   全局替换
echo "$str" | awk '{print NF}'                   # 2   单词数

# ---------- sed ----------
echo "$str" | sed 's/world/shell/'               # hello shell
echo "$str" | sed 's/o/0/g'                      # hell0 w0rld
echo "$str" | sed -r 's/(\w+) (\w+)/\2 \1/'      # world hello   交换两个单词
echo "$str" | sed 's/.*/\U&/'                    # HELLO WORLD   GNU sed 扩展

grep / sed / awk 的完整用法与正则表达式体系,详见 06-02-文本处理与文件查找.md。


十三、函数

函数用于把一段代码打包命名,定义后就像一条命令一样调用,是消除重复代码、控制脚本复杂度的核心手段。

13.1 定义的两种语法

# 语法一:function 关键字(function 可省略,括号可省略)
function 函数名 {
    函数代码块
}

function 函数名() {
    函数代码块
}

# 语法二:POSIX 标准写法(推荐,兼容性最好)
函数名() {
    函数代码块
}
# 实例
greet() {
    echo "Hello, Shell!"
}

function check_root {
    [ "$(id -u)" -ne 0 ] && { echo "请用 root 运行" >&2; exit 1; }
}

函数必须先定义后调用。Shell 是面向过程、自上而下逐行解析执行的,调用一个还未被解析到的函数会报 command not found。因此规范做法是:脚本头部注释 → 全局变量 → 全部函数定义 → 主流程(main)。

#!/bin/bash
# 错误:调用在定义之前
say_hi                  # 报错 say_hi: command not found
say_hi() { echo hi; }

#!/bin/bash
# 正确
say_hi() { echo hi; }
say_hi

13.2 调用与传参

函数就是命令,调用方式与普通命令完全一致,参数用空格分隔,不需要括号和逗号:

函数名 参数1 参数2 ...

函数内部用 $1、$2、$#、$@、$* 接收参数,这些位置参数是函数私有的,与脚本的位置参数互不影响:

#!/bin/bash
# 执行:bash func_args.sh scriptA scriptB

show() {
    echo "  函数内 \$0 = $0"        # 仍是脚本名!$0 不会变成函数名
    echo "  函数内 \$# = $#"        # 函数收到的参数个数
    echo "  函数内 \$1 = $1"
    echo "  函数内 \$@ = $@"
}

echo "脚本级 \$# = $#  \$1 = $1"     # 2  scriptA
echo "调用 show funcX funcY:"
show funcX funcY
echo "调用之后脚本级 \$1 = $1"        # 仍是 scriptA,未被函数改变

echo "把脚本参数原样转发给函数:"
show "$@"                            # 用 "$@" 转发才安全

要点:

  • 函数内 $1 是函数的第一个参数,脚本的 $1 在函数外不受影响。
  • $0 是例外:它始终是脚本名,不会变成函数名。想拿函数名用 ${FUNCNAME[0]}。
  • 转发参数一定用 "$@",不要用 $*。
whoami_func() {
    echo "当前函数名: ${FUNCNAME[0]}"
    echo "调用者函数名: ${FUNCNAME[1]:-主流程}"
    echo "所在文件: ${BASH_SOURCE[0]}  行号: ${LINENO}"
}

13.3 返回值:return 与 echo 的分工

这是 Shell 函数最容易被误解的地方:

目的 正确做法 说明
返回成功/失败状态 return 0 / return 1,调用后用 $? 接收 只能是 0~255 的整数,是「退出状态码」而不是数据
返回数据(字符串、数字) 函数内 echo/printf 输出,调用处用 $(函数名) 命令替换接收 这是 Shell 里返回数据的唯一正确方式
直接终止整个脚本 exit n 注意 exit 会退出脚本,不是退出函数
#!/bin/bash

# ---------- 用 return 返回状态 ----------
is_number() {
    [[ "$1" =~ ^-?[0-9]+$ ]] && return 0 || return 1
}

if is_number "123"; then
    echo "123 是数字"
fi

is_number "abc"
echo "上次判断的返回码 \$? = $?"      # 1

# ---------- 用 echo 返回数据 ----------
add() {
    local a=$1
    local b=$2
    echo $((a + b))                   # 把结果写到标准输出
}
result=$(add 3 5)                     # 用命令替换接收
echo "3 + 5 = $result"                # 8

# ---------- 两者结合:既返回数据又返回状态 ----------
get_disk_usage() {
    local mount="$1"
    local usage
    usage=$(df -P "$mount" 2>/dev/null | awk 'NR==2{print $5}' | tr -d '%')
    if [ -z "$usage" ]; then
        echo "0"
        return 1                      # 挂载点不存在,返回失败
    fi
    echo "$usage"
    return 0
}

if u=$(get_disk_usage /); then
    echo "根分区使用率: ${u}%"
else
    echo "获取失败"
fi

return 的取值限制(讲义要点):

  • return n / exit n 的 n 必须是 0–255 的整数。
  • 小于 0 会报错,最终状态码为 1–255 的随机值,多数是 2。
  • 大于 255 会取模 256,最终状态码为 1–255 的随机值,多数是 1。
  • 返回非数字会报错,状态码为 1–255 的随机值。
  • 函数执行到 return 立即结束,其后代码不再执行。
  • 脚本遇到 exit 直接退出整个脚本,其后代码不再执行。
#!/bin/bash
f1() { return 300; }
f1; echo "$?"        # 44(300 % 256)

f2() { return -1; }
f2; echo "$?"        # 报错,状态码通常为 2

f3() { return abc; }
f3; echo "$?"        # 报错 numeric argument required

13.4 局部变量 local 的重要性

Shell 中变量默认全是全局变量,函数内定义的变量在函数返回后依然存在,会污染全局命名空间,这是大型脚本中最隐蔽的 bug 来源。

不加 local 的污染实例:

#!/bin/bash
# ===== 反例:不加 local,函数把外部同名变量改掉了 =====
i=100
name="全局名字"

bad_func() {
    for i in 1 2 3; do                # i 是全局的,循环结束后 i=3
        echo "循环中 i=$i"
    done
    name="被函数改掉了"                # 直接覆盖全局 name
    tmp="函数内产生的临时变量"          # 泄漏到全局
}

echo "调用前:  i=$i  name=$name"
bad_func
echo "调用后:  i=$i  name=$name"      # i=3  name=被函数改掉了
echo "泄漏变量 tmp=$tmp"               # 函数内的临时变量在外面也能访问

# ===== 更危险的场景:嵌套循环互相踩踏 =====
process() {
    for i in a b c; do                # 与外层循环共用同一个 i
        echo "  内层 $i"
    done
}
for i in 1 2 3; do
    echo "外层 $i"
    process
done
# 结果:外层循环只跑一轮就结束了,因为 i 被内层改成了 c

# ===== 正例:加 local 隔离 =====
good_func() {
    local i                            # 声明为局部
    local name="局部名字"
    local tmp="局部临时变量"
    for i in 1 2 3; do
        echo "循环中 i=$i"
    done
    echo "函数内 name=$name"
}

i=100; name="全局名字"; unset tmp
echo "调用前:  i=$i  name=$name"
good_func
echo "调用后:  i=$i  name=$name"      # i=100  name=全局名字,完全没被影响
echo "tmp=[${tmp:-未定义}]"            # 未定义

规范:函数内所有中间变量(含循环变量 i、j)都必须用 local 声明。 这是编写可维护 Shell 脚本的第一条铁律。

local 的注意点:

f() {
    local a=1 b=2 c            # 一行可声明多个
    local -i n=10              # 局部整型变量
    local -a arr=(x y z)       # 局部数组
    local -A map               # 局部关联数组
    local -r CONST="不可改"     # 局部只读

    # 坑:local 与命令替换连写会吞掉退出码
    local out=$(false)         # $? 是 local 的返回码 0,不是 false 的 1
    echo "错误示范 \$?=$?"      # 0

    # 正确:先声明,再赋值
    local out2
    out2=$(false)
    echo "正确写法 \$?=$?"      # 1
}
f

# local 只能在函数中使用,在函数外使用会报错
# local x=1        # bash: local: can only be used in a function

13.5 变量作用域与 declare

Shell 的作用域是动态作用域:函数 A 内的 local 变量,对被 A 调用的函数 B 也是可见的(这与 C/Python 的静态作用域不同)。

#!/bin/bash
inner() {
    echo "inner 看到的 v = $v"      # 能看到 outer 的局部变量
    v="被 inner 修改"                # 修改的是 outer 的那个 v
}

outer() {
    local v="outer 的局部变量"
    inner
    echo "inner 返回后 v = $v"      # 被 inner 修改
}

v="全局 v"
outer
echo "全局 v = $v"                  # 全局 v 未受影响

declare 在函数内使用时默认就是局部的(等价于 local),加 -g 才是全局:

选项 作用
declare -r name=value 只读变量(常量),不可修改也不可 unset
declare -i n 整型变量,赋值时自动做算术求值
declare -a arr 索引数组
declare -A map 关联数组(bash 4+)
declare -u name 赋值时自动转大写
declare -l name 赋值时自动转小写
declare -x name 导出为环境变量(等价 export)
declare -g name 在函数内声明全局变量
declare -p name 打印变量的声明与属性
declare -f name 打印函数定义
declare -F 只打印函数名
declare -r PI=3.14159
declare -i count=0
count=count+1                # 整型变量可直接写算术表达式
echo "$count"                # 1

declare -u upper_var
upper_var="hello"
echo "$upper_var"            # HELLO

declare -l lower_var
lower_var="WORLD"
echo "$lower_var"            # world

declare -x MY_ENV=value      # 等价 export MY_ENV=value

f() {
    declare local_by_default="只在函数内可见"
    declare -g GLOBAL_VAR="函数内声明的全局变量"
}
f
echo "${GLOBAL_VAR}"                       # 可访问
echo "${local_by_default:-已销毁}"          # 已销毁

declare -p PI count                        # 查看变量属性

13.6 递归函数

函数可以调用自己。Shell 递归深度受 FUNCNEST(bash 4.2+)限制,默认无限制但受栈空间约束,实际使用中应控制深度。

#!/bin/bash
# 阶乘
factorial() {
    local n=$1
    if [ "$n" -le 1 ]; then
        echo 1
    else
        local sub
        sub=$(factorial $((n - 1)))
        echo $((n * sub))
    fi
}
echo "5! = $(factorial 5)"        # 120

# 斐波那契数列(演示用,指数级复杂度,n 不要超过 20)
fib() {
    local n=$1
    if [ "$n" -lt 2 ]; then
        echo "$n"
    else
        echo $(( $(fib $((n - 1))) + $(fib $((n - 2))) ))
    fi
}
for i in $(seq 0 10); do printf "%s " "$(fib $i)"; done; echo

# 递归遍历目录树(打印带缩进的目录结构)
tree_walk() {
    local dir="$1"
    local prefix="${2:-}"
    local item
    for item in "$dir"/*; do
        [ -e "$item" ] || continue                 # 目录为空时跳过
        echo "${prefix}|-- $(basename "$item")"
        if [ -d "$item" ]; then
            tree_walk "$item" "${prefix}|   "      # 递归进入子目录
        fi
    done
}
tree_walk /etc/sysconfig

# 递归计算目录总大小(字节)
dir_size() {
    local dir="$1" total=0 item sz
    for item in "$dir"/*; do
        [ -e "$item" ] || continue
        if [ -d "$item" ]; then
            sz=$(dir_size "$item")
        else
            sz=$(stat -c %s "$item" 2>/dev/null || echo 0)
        fi
        total=$((total + sz))
    done
    echo "$total"
}

# 限制递归深度,防止无限递归把系统拖死(bash 4.2+)
FUNCNEST=100

13.7 函数库的抽取与引入

把通用函数抽取到独立文件形成「函数库」,用 source 引入,是组织中大型脚本的标准做法。

推荐的目录组织:

/opt/ops/
├── bin/                    # 可执行入口脚本
│   ├── backup.sh
│   └── check.sh
├── lib/                    # 函数库(只定义函数,不执行动作)
│   ├── common.sh           # 通用:日志、错误处理、颜色
│   ├── log.sh
│   ├── net.sh              # 网络相关函数
│   └── db.sh               # 数据库相关函数
├── conf/                   # 配置文件
│   └── backup.conf
└── logs/                   # 运行日志

函数库文件 /opt/ops/lib/common.sh:

#!/bin/bash
# ============================================================
# 文件: common.sh
# 用途: 通用函数库,供其他脚本 source 引入,不可直接执行
# ============================================================

# 防止被重复 source(引入卫兵)
[ -n "${__COMMON_SH_LOADED:-}" ] && return 0
__COMMON_SH_LOADED=1

# 颜色定义
readonly C_RED='\033[31m'
readonly C_GREEN='\033[32m'
readonly C_YELLOW='\033[33m'
readonly C_RESET='\033[0m'

LOG_FILE="${LOG_FILE:-/var/log/ops.log}"

log_info() {
    printf "${C_GREEN}[INFO ]${C_RESET} %s %s\n" "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"
}

log_warn() {
    printf "${C_YELLOW}[WARN ]${C_RESET} %s %s\n" "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE" >&2
}

log_error() {
    printf "${C_RED}[ERROR]${C_RESET} %s %s\n" "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE" >&2
}

die() {
    log_error "$*"
    exit 1
}

require_root() {
    [ "$(id -u)" -eq 0 ] || die "本脚本必须以 root 身份运行"
}

require_cmd() {
    local c
    for c in "$@"; do
        command -v "$c" >/dev/null 2>&1 || die "缺少必需命令: $c"
    done
}

调用方脚本 /opt/ops/bin/backup.sh:

#!/bin/bash
set -euo pipefail

# 计算脚本自身所在目录,保证从任何位置执行都能找到函数库
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="${SCRIPT_DIR}/../lib"

# 两种等价的引入写法
source "${LIB_DIR}/common.sh"          # source 是完整写法
. "${LIB_DIR}/common.sh"               # . 是 source 的等价简写(POSIX 标准)

# 引入前先判断文件是否存在,避免报错难以排查
[ -f "${LIB_DIR}/db.sh" ] && source "${LIB_DIR}/db.sh"

# 批量引入 lib 下所有函数库
for f in "${LIB_DIR}"/*.sh; do
    [ -r "$f" ] && source "$f"
done

require_root
require_cmd tar gzip
log_info "备份开始"

要点:

  • source file 与 . file 完全等价,在当前进程执行文件内容,因此函数、变量都会留在当前 Shell 中。
  • 函数库文件本身不应执行任何实际动作,只做定义,且不要写 exit(会把调用方脚本一起退出)。
  • 函数库不需要执行权限(因为不是被执行而是被读取),但建议保留 shebang 以便编辑器识别语法。
  • 用「引入卫兵」变量防止重复 source 导致的重复定义或计数器错乱。

13.8 查看与删除函数:declare -f / unset -f

declare -f                 # 列出当前 Shell 所有已定义函数(含函数体)
declare -F                 # 只列出函数名
declare -f log_info        # 查看指定函数的定义
declare -F log_info        # 只确认该函数是否存在

type log_info              # 也能查看函数定义
type -t log_info           # 输出 function,只判断类型

set                        # 也会列出所有函数(连同变量)

unset -f log_info          # 删除函数定义
unset -v log_info          # 只删除同名变量,不删函数(-v 明确指定变量)
unset log_info             # 不加选项时优先删除变量,变量不存在才删函数

判断函数是否存在的标准写法:

if declare -F my_func >/dev/null; then
    echo "函数 my_func 已定义"
fi

if [ "$(type -t my_func)" = "function" ]; then
    echo "函数 my_func 已定义"
fi

13.9 函数、别名与外部命令的查找优先级

执行一个名字时,bash 的查找顺序是:

1. 别名 alias
2. 关键字 keyword(if / for / function 等)
3. 函数 function
4. 内建命令 builtin(cd / echo / test / read ...)
5. 外部命令(按 $PATH 顺序查找,命中即止)

讲义要点:函数名与 Linux 命令冲突时,优先执行自定义函数(因为函数在第 3 位,早于内建命令和外部命令)。

# 演示优先级
ls() { echo "这是自定义 ls 函数"; }
ls                    # 输出:这是自定义 ls 函数(函数覆盖了外部命令)

command ls            # 绕过函数与别名,执行外部命令/内建命令
/bin/ls               # 用绝对路径,绕过一切
\ls                   # 反斜杠只能绕过别名,不能绕过函数
builtin echo hi       # 强制执行内建命令,绕过同名函数

unset -f ls           # 删除函数,恢复正常
type -a ls            # 列出该名字的所有匹配(函数、别名、外部命令)

# 用 command 包装原命令是「增强命令」的标准做法
cd() {
    command cd "$@" && ls        # 必须用 command cd,否则无限递归
}

常见坑:在函数中调用与函数同名的命令,必须用 command(外部/内建)或绝对路径,否则会无限递归直至栈溢出。

13.10 export -f 导出函数给子进程

变量用 export,函数用 export -f:

#!/bin/bash
my_func() { echo "我来自父 Shell,参数=$1"; }

export -f my_func              # 把函数导出到环境中

bash -c 'my_func 测试'          # 子 bash 能调用到该函数

# 最常见用途:让 xargs / find -exec 调用自定义函数
process_one() {
    echo "处理文件: $1 大小 $(stat -c %s "$1" 2>/dev/null) 字节"
}
export -f process_one

find /etc -maxdepth 1 -type f -name '*.conf' -print0 \
    | xargs -0 -I{} bash -c 'process_one "$@"' _ {}

# 配合 GNU parallel 并发处理
# find . -name '*.log' | parallel process_one {}

export -pf                     # 查看所有已导出的函数

注意:export -f 通过环境变量传递函数体,只在同为 bash 的子进程中有效;sh(dash)不支持。

13.11 函数使用注意事项(讲义要点汇总)

序号 说明
1 Shell 是面向过程语言,从上到下依次执行(类似 C)
2 变量要先定义再使用,否则值为空
3 函数要先定义再调用,否则报错
4 函数内外通信可通过传参,或修改共同使用的变量
5 函数可通过标准输出(echo/printf)和 return/exit 与外部通信
6 函数名遵循变量命名规则,见名知意
7 函数不调用不执行
8 函数名与 Linux 命令冲突时,优先执行自定义函数

十四、信号与 trap

信号(signal)是内核或进程向另一个进程发送的异步通知。脚本要做到「被中断时也能清理现场」,就必须捕获信号。

14.1 常用信号对照表

编号 名称 默认动作 说明 可否捕获
1 SIGHUP 终止 挂起。终端断开时发给前台进程组;守护进程常用它做「重读配置」 可
2 SIGINT 终止 中断,由 Ctrl + C 产生 可
3 SIGQUIT 终止并转储 退出,由 Ctrl + \ 产生 可
9 SIGKILL 终止 强制杀死,不可被捕获、阻塞或忽略 不可
15 SIGTERM 终止 终止请求,kill 命令的默认信号,允许进程做清理后退出 可
17/19 SIGSTOP 暂停 暂停进程,不可被捕获、阻塞或忽略 不可
18/20 SIGCONT 继续 让暂停的进程继续运行 可
20/18 SIGTSTP 暂停 终端暂停,由 Ctrl + Z 产生(可捕获,与 SIGSTOP 不同) 可
10 SIGUSR1 终止 用户自定义信号 1,供程序自行约定用途 可
12 SIGUSR2 终止 用户自定义信号 2 可
13 SIGPIPE 终止 向已关闭读端的管道写数据(如 cmd \| head 时产生) 可
14 SIGALRM 终止 定时器超时(alarm) 可
0 EXIT — bash 伪信号,脚本正常或异常退出时触发(不是真实信号) 可

编号与名称的对应关系在不同架构上有差异(SIGSTOP/SIGCONT/SIGTSTP 在 x86 上分别是 17/18/20,在其他架构上可能不同),脚本中一律使用信号名而不要用数字。查看本机完整列表:kill -l 或 trap -l。

信号的产生、kill / killall / pkill 的用法、前后台作业控制与进程状态,详见 04-01-进程与任务管理.md。

14.2 trap 的语法

trap '命令或函数' 信号列表

四种基本用法:

# 1) 捕获信号并执行指定动作
trap 'echo "收到 Ctrl+C"' SIGINT
trap 'cleanup' SIGINT SIGTERM        # 一次捕获多个信号
trap 'echo bye' INT TERM             # 信号名可省略 SIG 前缀

# 2) 忽略信号(动作为空字符串)——按 Ctrl+C 完全无反应
trap '' SIGINT
trap '' SIGINT SIGTERM SIGHUP        # 关键操作期间屏蔽中断

# 3) 恢复信号的默认行为(动作为一个减号)
trap - SIGINT
trap SIGINT                          # 省略动作也表示恢复默认

# 4) 查看当前已设置的捕获
trap -p                              # 列出所有已设置的 trap
trap -p SIGINT                       # 只看 SIGINT 的
trap -l                              # 列出系统所有信号名与编号

区别要记牢:trap '' SIGINT 是忽略(信号被丢弃),trap - SIGINT 是恢复默认(按 Ctrl+C 会正常终止脚本)。

一个完整对比演示:

#!/bin/bash
echo "阶段 1:默认行为,Ctrl+C 可以杀掉脚本"
sleep 5

echo "阶段 2:忽略 Ctrl+C,按了没反应"
trap '' SIGINT
sleep 5

echo "阶段 3:捕获 Ctrl+C,只打印提示不退出"
trap 'echo "  >> 想退出请输入 kill -9 $$"' SIGINT
sleep 10

echo "阶段 4:恢复默认,Ctrl+C 又能杀掉脚本了"
trap - SIGINT
sleep 5

echo "结束"

14.3 EXIT 伪信号做清理(经典模板)

EXIT(也可写作 0)是 bash 的伪信号,在脚本以任何方式退出时都会触发:正常跑完、exit、被 SIGTERM/SIGINT 终止、set -e 触发退出。因此它是「保证清理一定执行」的最佳位置。

模板一:临时文件自动清理

#!/bin/bash
set -euo pipefail

# 用 mktemp 创建临时文件/目录,绝不自己拼 /tmp/xxx.$$ 这种可预测的名字
TMPFILE=$(mktemp /tmp/myscript.XXXXXX)
TMPDIR=$(mktemp -d /tmp/myscript.XXXXXX)

# 关键:注册 EXIT 清理,写在创建临时资源之后立刻注册
cleanup() {
    local rc=$?                       # 先保存退出码,后面的命令会改写 $?
    rm -f -- "$TMPFILE"
    rm -rf -- "$TMPDIR"
    echo "[cleanup] 已清理临时文件,退出码 $rc"
    return $rc
}
trap cleanup EXIT

# ------- 正常业务逻辑 -------
echo "临时文件: $TMPFILE"
df -h > "$TMPFILE"
grep -c . "$TMPFILE"
# 无论下面是正常结束、报错退出还是被 Ctrl+C,cleanup 都会执行

模板二:锁文件(防止脚本重复运行)

#!/bin/bash
# 用锁文件保证同一时刻只有一个实例在跑(适合 cron 定时任务)
set -euo pipefail

LOCKFILE="/var/run/$(basename "$0").lock"

acquire_lock() {
    # noclobber 方式:文件已存在时重定向失败,原子性有保证
    if ! (set -o noclobber; echo "$$" > "$LOCKFILE") 2>/dev/null; then
        local pid
        pid=$(cat "$LOCKFILE" 2>/dev/null || echo "?")
        # 检查持锁进程是否还活着,处理僵死锁
        if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
            echo "脚本已在运行中(PID $pid),本次退出" >&2
            exit 1
        else
            echo "发现僵死锁文件(PID $pid 已不存在),清理后重新加锁" >&2
            rm -f -- "$LOCKFILE"
            echo "$$" > "$LOCKFILE"
        fi
    fi
}

release_lock() {
    rm -f -- "$LOCKFILE"
    echo "[cleanup] 锁已释放"
}

acquire_lock
trap release_lock EXIT INT TERM        # EXIT 保证任何退出路径都释放锁

echo "获得锁,开始工作(PID $$)"
sleep 30
echo "工作完成"

更简洁的做法是用 flock(util-linux 提供,CentOS 6/7 都自带):

#!/bin/bash
# 用 flock 加文件锁,内核级互斥,不用担心僵死锁
exec 200>/var/run/myjob.lock
flock -n 200 || { echo "已有实例在运行"; exit 1; }
echo "$$" >&200

echo "开始工作"
sleep 30
# 进程退出时文件描述符自动关闭,锁自动释放,无需手工清理

# 或者用 flock 直接包裹命令(cron 中最常用)
# * * * * * /usr/bin/flock -n /var/run/myjob.lock /opt/ops/bin/myjob.sh

14.4 ERR / DEBUG / RETURN 伪信号

除 EXIT 外,bash 还提供三个调试用伪信号:

伪信号 触发时机 典型用途
ERR 任意命令返回非 0 时(受 set -e 的例外规则约束) 统一错误处理、打印出错行号
DEBUG 每一条命令执行之前 单步跟踪、打印变量变化
RETURN 函数或 source 的脚本返回时 统计函数耗时、审计
#!/bin/bash
set -eE                      # -E 让 ERR trap 能被函数继承,否则函数内的错误捕获不到

# ---------- ERR:统一错误处理,打印出错的行号与命令 ----------
on_error() {
    local rc=$?
    echo "==== 出错了 ====" >&2
    echo "退出码: $rc" >&2
    echo "行号:   ${BASH_LINENO[0]}" >&2
    echo "命令:   ${BASH_COMMAND}" >&2
    echo "函数栈: ${FUNCNAME[*]}" >&2
    exit "$rc"
}
trap on_error ERR

# ---------- DEBUG:单步跟踪每条命令 ----------
trap 'echo "[DEBUG] 第 ${LINENO} 行即将执行: ${BASH_COMMAND}"' DEBUG

# ---------- RETURN:函数返回时触发 ----------
trap 'echo "[RETURN] ${FUNCNAME[0]:-脚本} 返回"' RETURN

work() {
    local x=1
    x=$((x + 1))
    echo "x=$x"
}

work
ls /不存在的目录          # 触发 ERR trap
echo "这一行不会执行"

set -eE 中的 -E(即 set -o errtrace)很关键:默认情况下 ERR trap 不会被 Shell 函数、命令替换、子 Shell 继承,加 -E 后才继承。同理 set -T(functrace)让 DEBUG 和 RETURN trap 被函数继承。

14.5 SIGKILL 与 SIGSTOP 不可捕获

#!/bin/bash
# 试图捕获 SIGKILL 和 SIGSTOP
trap 'echo "捕获到 KILL"' SIGKILL     # bash 不报错但完全无效
trap 'echo "捕获到 STOP"' SIGSTOP     # 同样无效

echo "我的 PID 是 $$,试试 kill -9 $$"
sleep 300

要点:

  • SIGKILL(9)与 SIGSTOP(17/19)由内核直接处理,无法被捕获、阻塞或忽略,这是操作系统的硬性设计,确保管理员永远有手段终止/暂停任何进程。
  • 因此 kill -9 之后脚本的 trap ... EXIT 清理逻辑不会执行,临时文件和锁文件会残留。
  • 运维实践:优先用 kill(默认 SIGTERM)让脚本优雅退出,只在进程无响应时才用 kill -9;同时脚本启动时应主动检查并清理上次残留的僵死锁(见 14.3 模板二)。

14.6 捕获 Ctrl+C 做优雅退出的完整示例

#!/bin/bash
# ============================================================
# 脚本名称: graceful.sh
# 功能描述: 演示信号捕获与优雅退出——收到 Ctrl+C 后不立即中断,
#           而是完成当前正在处理的任务、写完统计、清理临时文件再退出
# 用法:     bash graceful.sh
# ============================================================
set -uo pipefail

TMPDIR=$(mktemp -d /tmp/graceful.XXXXXX)
STATFILE="${TMPDIR}/stat.txt"
STOP_FLAG=0            # 退出标志位,由信号处理函数置位
PROCESSED=0

# ---------- 清理函数:EXIT 时一定执行 ----------
cleanup() {
    local rc=$?
    echo ""
    echo "[cleanup] 共处理 $PROCESSED 个任务"
    [ -f "$STATFILE" ] && cat "$STATFILE"
    rm -rf -- "$TMPDIR"
    echo "[cleanup] 临时目录已删除,退出码 $rc"
}
trap cleanup EXIT

# ---------- 信号处理:只置标志位,不直接 exit ----------
on_interrupt() {
    STOP_FLAG=1
    echo ""
    echo "[signal] 收到中断信号,将在当前任务完成后退出(再按一次强制退出)"
    trap - SIGINT SIGTERM      # 恢复默认,第二次 Ctrl+C 直接杀掉
}
trap on_interrupt SIGINT SIGTERM

# ---------- 收到 SIGHUP 重读配置(守护进程常见约定) ----------
reload_conf() {
    echo "[signal] 收到 SIGHUP,重新加载配置"
}
trap reload_conf SIGHUP

# ---------- 用 SIGUSR1 输出当前进度 ----------
show_progress() {
    echo "[signal] 当前进度: 已处理 $PROCESSED 个(kill -USR1 $$ 可随时查询)"
}
trap show_progress SIGUSR1

echo "开始运行,PID=$$"
echo "  Ctrl+C  -> 优雅退出"
echo "  kill -HUP  $$ -> 重载配置"
echo "  kill -USR1 $$ -> 查看进度"

for i in $(seq 1 20); do
    # 每轮循环开头检查标志位,这是优雅退出的关键
    if [ "$STOP_FLAG" -eq 1 ]; then
        echo "[main] 检测到退出标志,正常结束循环"
        break
    fi

    echo "  处理任务 $i ..."
    sleep 2                                  # 模拟耗时操作
    PROCESSED=$((PROCESSED + 1))
    echo "任务 $i 完成于 $(date '+%F %T')" >> "$STATFILE"
done

echo "[main] 主流程结束"
exit 0

优雅退出的核心思路:信号处理函数里只设置标志位、不做重活也不直接 exit,由主循环在安全点检查标志位后退出;清理动作统一放在 EXIT trap 里,保证任何退出路径都会执行。


十五、here document 与输入重定向

15.1 三个标准流与文件描述符

流 设备文件 文件描述符 默认设备
标准输入 stdin /dev/stdin 0 键盘
标准输出 stdout /dev/stdout 1 屏幕
错误输出 stderr /dev/stderr 2 屏幕

15.2 here document 的三种形式

here document(此处文档)把脚本中的一段多行文本直接作为命令的标准输入,语法是 <<标记,直到再次遇到该标记结束。

写法 前导 tab 变量/命令替换 说明
<<EOF 保留 会展开 $var、`cmd`、$(cmd) 最常用,用于生成含变量的配置
<<-EOF 去除每行的前导 tab 会展开 便于把 here doc 缩进对齐在代码块内
<<'EOF' 或 <<"EOF" 或 <<\EOF 保留 完全不展开,所见即所得 生成含 $ 的脚本、awk 程序、密码等
<<-'EOF' 去除前导 tab 不展开 两种特性组合

对照示例:

#!/bin/bash
name="张三"
today=$(date +%F)

echo "===== 1. <<EOF:变量会被展开 ====="
cat <<EOF
用户名: $name
日期:   $today
命令替换: $(hostname)
路径: $HOME
EOF

echo "===== 2. <<'EOF':变量原样输出 ====="
cat <<'EOF'
用户名: $name
日期:   $today
命令替换: $(hostname)
这里的 $ 和 ` 都是普通字符
EOF

echo "===== 3. <<-EOF:去掉每行前导 tab(注意必须是 tab 不是空格) ====="
if true; then
    cat <<-EOF
    这一行在源码中有一个 tab 缩进
    输出时 tab 被去掉,左侧对齐
    变量仍然展开: $name
    EOF
fi

echo "===== 4. 不加 - 时缩进会原样保留 ====="
if true; then
    cat <<EOF
    这一行前面的 4 个空格会原样输出
EOF
fi

重要坑点:<<- 只去除 tab 字符,不去除空格。如果编辑器把 tab 自动转成了空格,缩进就不会被去掉。结束标记 EOF 前面也只能有 tab(用 <<- 时)或完全没有缩进(用 << 时),否则 Shell 找不到结束标记会一直报 unexpected end of file。

15.3 批量生成配置文件

#!/bin/bash
# 用 here doc 生成 Nginx 虚拟主机配置
DOMAIN="www.example.com"
ROOT="/var/www/example"
PORT=80

# 变量要展开,用 <<EOF
cat > "/etc/nginx/conf.d/${DOMAIN}.conf" <<EOF
server {
    listen       ${PORT};
    server_name  ${DOMAIN};
    root         ${ROOT};
    index        index.html index.htm;

    access_log  /var/log/nginx/${DOMAIN}_access.log  main;
    error_log   /var/log/nginx/${DOMAIN}_error.log;

    location / {
        try_files \$uri \$uri/ =404;      # Nginx 自己的 \$uri 必须转义
    }
}
EOF

# 追加内容用 >>
cat >> /etc/hosts <<EOF
192.168.1.10  web01
192.168.1.11  web02
192.168.1.20  db01
EOF

# 生成一个不需要变量展开的脚本,用 <<'EOF' 最安全
cat > /usr/local/bin/diskcheck.sh <<'EOF'
#!/bin/bash
df -h | awk 'NR>1 && $5+0 > 80 {print $6" 使用率 "$5}'
EOF
chmod +x /usr/local/bin/diskcheck.sh

# 生成 yum 仓库配置(CentOS 7)
cat > /etc/yum.repos.d/local.repo <<'EOF'
[local]
name=Local Repository
baseurl=file:///mnt/cdrom
enabled=1
gpgcheck=0
EOF

# 讲义写法:标记名不要求必须是 EOF,只要首尾一致即可
cat > 123.txt << EOF
111
222
EOF

cat >> 123.txt << HAHA
333
444
HAHA

# 交互式写入(不用 here doc,Ctrl+D 结束)
cat > 1.txt

15.4 用 here doc 给交互式命令喂输入

# ---------- fdisk 非交互分区 ----------
# n 新建 / p 主分区 / 1 分区号 / 两个空行用默认起止扇区 / w 保存
fdisk /dev/sdb <<EOF
n
p
1


w
EOF
partprobe /dev/sdb            # 通知内核重读分区表
mkfs.ext4 /dev/sdb1

# ---------- mysql 批量执行 SQL ----------
mysql -uroot -p"$MYSQL_PASS" <<'EOF'
CREATE DATABASE IF NOT EXISTS appdb DEFAULT CHARSET utf8mb4;
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'App@2024';
GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'localhost';
FLUSH PRIVILEGES;
SHOW DATABASES;
EOF

# SQL 中需要用到 Shell 变量时改用 <<EOF(不加引号)
DB="appdb"
mysql -uroot -p"$MYSQL_PASS" <<EOF
USE ${DB};
SELECT COUNT(*) FROM users;
EOF

# ---------- ftp 自动上传 ----------
ftp -n 192.168.1.100 <<EOF
user ftpuser ftppass
binary
cd /upload
lcd /data/backup
put db_$(date +%F).tar.gz
bye
EOF

# ---------- passwd 修改密码(CentOS 6/7 均可用 --stdin) ----------
echo "NewPass@123" | passwd --stdin username

# ---------- 交互式菜单程序喂输入 ----------
./install.sh <<EOF
y
/opt/myapp
y
EOF

15.5 here string <<<

<<< 把一个字符串直接作为命令的标准输入,比 echo xxx | cmd 少 fork 一个进程,也不会引入子 Shell。

# 基本用法
wc -c <<< "hello"                       # 6(含末尾换行)
grep -o 'ab' <<< "xxabxxabxx"
bc <<< "3 * 4"                          # 12
tr 'a-z' 'A-Z' <<< "hello world"        # HELLO WORLD

# 与管道对比(<<< 不产生子 Shell,变量赋值能保留)
echo "a b c" | read x y z; echo "管道: x=[$x]"      # 空!read 在子 Shell 中
read x y z <<< "a b c";     echo "here string: x=[$x] y=[$y] z=[$z]"   # a b c

# 把字符串按分隔符切成数组
IFS=':' read -r -a arr <<< "root:x:0:0:root:/root:/bin/bash"
echo "${arr[0]} ${arr[6]}"              # root /bin/bash

# 逐行处理变量中的多行文本
text="line1
line2
line3"
while IFS= read -r line; do
    echo "读到: $line"
done <<< "$text"

# 用 <<< 做正则匹配与字段提取
[[ $(uname -r) =~ ^([0-9]+)\.([0-9]+) ]] && echo "内核 ${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
awk '{print $2}' <<< "$(uptime)"

15.6 exec 重定向与自定义文件描述符

exec 不带命令、只带重定向时,会永久改变当前 Shell 的文件描述符,直到脚本结束或再次修改。

#!/bin/bash
# ---------- 把整个脚本的输出重定向到日志文件 ----------
LOG="/var/log/myscript.log"

exec >> "$LOG"          # 之后所有标准输出都追加到日志
exec 2>&1               # 错误输出也并入同一个日志

echo "===== 脚本启动 $(date '+%F %T') ====="
echo "这一行不会出现在屏幕上,只写进日志"
ls /不存在的路径          # 错误信息也进日志
echo "===== 脚本结束 ====="

# ---------- 既写日志又在屏幕显示:用进程替换配合 tee ----------
exec > >(tee -a "$LOG") 2>&1
echo "这一行同时出现在屏幕和日志中"

# ---------- 先保存原始 fd,用完再恢复 ----------
exec 3>&1               # 把当前 stdout 备份到 fd 3
exec 1> /tmp/quiet.log  # stdout 改向文件
echo "这句写进文件"
exec 1>&3               # 从 fd 3 恢复 stdout
exec 3>&-               # 关闭 fd 3
echo "这句回到屏幕"

自定义文件描述符 3~9(0/1/2 已被占用,10 以上由 bash 内部使用):

#!/bin/bash
# ---------- 只读打开 ----------
exec 3< /etc/passwd            # 用 fd 3 只读打开文件
read -r line <&3               # 从 fd 3 读一行
echo "第一行: $line"
read -r line <&3               # 继续读第二行(文件偏移被保持)
echo "第二行: $line"
exec 3<&-                      # 关闭 fd 3

# ---------- 只写打开 ----------
exec 4> /tmp/out.txt
echo "写入内容 1" >&4
echo "写入内容 2" >&4
exec 4>&-                      # 关闭后文件才刷新完整

# ---------- 读写同时打开(<> 双向) ----------
exec 5<> /tmp/rw.txt           # 文件不存在会被创建,存在则不截断
echo "hello" >&5               # 从当前偏移写入
exec 5>&-

# ---------- 同时处理两个文件(fd 的经典用途) ----------
exec 3< /tmp/a.txt
exec 4< /tmp/b.txt
while read -r la <&3 && read -r lb <&4; do
    echo "A: $la  |  B: $lb"
done
exec 3<&- 4<&-

# ---------- 关闭描述符的写法 ----------
exec 3>&-                      # 关闭输出型 fd 3
exec 3<&-                      # 关闭输入型 fd 3
exec 3>&- 4<&-                 # 一次关闭多个

15.7 重定向操作符汇总

写法 含义
> 文件 / 1> 文件 标准输出覆盖写入文件
>> 文件 / 1>> 文件 标准输出追加写入文件
2> 文件 错误输出覆盖写入
2>> 文件 错误输出追加写入
2>&1 把错误输出合并到标准输出当前指向的地方(顺序敏感)
1>&2 把标准输出合并到错误输出(常用于打印错误提示:echo "err" >&2)
&> 文件 / >& 文件 stdout + stderr 一并覆盖写入(bash 专有)
&>> 文件 stdout + stderr 一并追加(bash 4+)
< 文件 标准输入来自文件
<> 文件 以读写方式打开
\|& 管道同时传递 stdout 和 stderr(等价 2>&1 \|,bash 4+)
n>&- / n<&- 关闭文件描述符 n

2>&1 的顺序陷阱:

# 正确:先把 stdout 指向文件,再让 stderr 复制 stdout(都进文件)
command > file 2>&1

# 错误:stderr 先复制了当时仍指向屏幕的 stdout,之后 stdout 才改向文件
# 结果:stdout 进文件,stderr 仍在屏幕
command 2>&1 > file

# 分流到两个不同文件
command > ok.txt 2> err.txt
ls install.log fdsaf 1> ok.txt 2> err.txt

/dev/null 与 /dev/zero:

设备 特性 典型用途
/dev/null 「黑洞」,写入即丢弃,读出立即 EOF 屏蔽输出:cmd &> /dev/null
/dev/zero 无限提供 \0 空字节 生成指定大小的文件、内存/磁盘测试
/dev/random 真随机数,熵不足时会阻塞 生成高强度密钥
/dev/urandom 伪随机数,不阻塞 生成随机密码(脚本首选)
# /dev/null 常见用法
cat 1.txt > /dev/null                 # 丢弃标准输出
make > /dev/null                      # 屏蔽编译输出
command 2> /dev/null                  # 只屏蔽错误
command &> /dev/null                  # 全部屏蔽
ping -c1 -W1 8.8.8.8 &>/dev/null && echo 通 || echo 不通
find / -name "*.conf" 2>/dev/null     # 屏蔽 Permission denied

# /dev/zero 生成 100MB 测试文件
dd if=/dev/zero of=/tmp/test.img bs=1M count=100
dd if=/dev/zero of=/swapfile bs=1M count=2048     # 创建 swap 文件
dd if=/dev/zero of=/tmp/t bs=1M count=1024 conv=fdatasync  # 测磁盘写速度

# /dev/urandom 生成随机密码
head -c 12 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 12; echo
tr -dc 'A-Za-z0-9!@#' < /dev/urandom | head -c 16; echo
openssl rand -base64 12

tee 配合重定向(一份输出同时到屏幕和文件):

command | tee file.log                 # 覆盖写入并显示在屏幕
command | tee -a file.log              # 追加写入并显示
command 2>&1 | tee -a file.log         # 含错误输出一起
command | tee file1 file2 file3        # 同时写多个文件
command | tee /dev/null                # 只丢弃(等价重定向到 null)
echo "text" | sudo tee /etc/config     # 用 tee 突破 sudo 无法重定向的限制
command | tee >(grep ERROR > err.log)  # 配合进程替换分流

十六、子 shell、进程替换与执行方式

16.1 五种执行方式的区别对照表

执行方式 是否新开子进程 变量/函数是否影响当前 Shell 是否需要执行权限 shebang 是否生效 脚本结束后
bash script.sh 是(新开 bash 子进程) 否 不需要 不生效(由命令行的 bash 解释) 返回当前 Shell
./script.sh 是(fork + exec) 否 需要 chmod +x 生效(内核按首行选解释器) 返回当前 Shell
source script.sh 否(当前进程执行) 是,全部保留 不需要 不生效 停留在当前 Shell
. script.sh 否,与 source 完全等价 是 不需要 不生效 停留在当前 Shell
exec script.sh 否(替换当前进程映像) 无意义(当前 Shell 已被替换) 需要 生效 当前 Shell 一并结束,终端会退出

实测验证:

# 准备测试脚本 t.sh
cat > /tmp/t.sh <<'EOF'
#!/bin/bash
echo "脚本内 PID = $$  PPID = $PPID"
myvar="脚本里定义的值"
myfunc() { echo "脚本里定义的函数"; }
cd /etc
EOF
chmod +x /tmp/t.sh

echo "当前 Shell PID = $$"

# ---------- 1) bash 执行:PID 不同,变量不保留,cd 不影响当前目录 ----------
bash /tmp/t.sh
echo "myvar = [${myvar:-未定义}]"      # 未定义
pwd                                    # 目录未变

# ---------- 2) ./ 执行:与 bash 执行类似,但需要执行权限、shebang 生效 ----------
/tmp/t.sh
echo "myvar = [${myvar:-未定义}]"      # 未定义

# ---------- 3) source 执行:PID 相同,变量与函数都保留,cd 生效 ----------
source /tmp/t.sh
echo "myvar = [${myvar:-未定义}]"      # 脚本里定义的值
myfunc                                 # 可以调用
pwd                                    # /etc,当前目录被改变了!

# ---------- 4) . 执行:与 source 完全相同 ----------
. /tmp/t.sh

# ---------- 5) exec 执行:当前 Shell 被替换,脚本结束终端也关闭 ----------
# exec /tmp/t.sh      # 谨慎测试!在子 shell 里试:bash -c 'exec /tmp/t.sh'

选用原则:

  • 需要脚本修改当前 Shell 环境(如加载环境变量、定义函数库、cd 到指定目录)→ 必须用 source / .,这就是 source ~/.bashrc 让配置生效的原理。
  • 普通脚本执行 → 用 ./script.sh(配合 shebang,最规范)或 bash script.sh(调试时可加选项如 bash -x)。
  • 脚本调试时想临时覆盖 shebang → 用 bash script.sh。
  • exec 用于「启动器」场景:包装脚本设置好环境后用 exec 换成真正的程序,避免多留一层 Shell 进程(容器 entrypoint 中非常常见)。
# exec 作为启动器的典型用法(容器 entrypoint)
#!/bin/bash
export APP_ENV=production
ulimit -n 65535
exec /usr/local/bin/myapp --config /etc/myapp.conf "$@"
# 用 exec 后 myapp 直接继承 PID 1,能正确收到 docker stop 发来的 SIGTERM

16.2 子 shell ( ) 与代码块 { }

对比项 子 Shell ( 命令 ) 代码块 { 命令; }
是否新开子进程 是 否,在当前 Shell 执行
内部变量修改 不影响外部 影响外部
内部 cd 不影响外部当前目录 影响外部
内部 exit 只退出子 Shell 退出整个脚本
语法要求 ( 后不必有空格 { 后必须有空格,最后一条命令后必须有 ; 或换行
性能 有 fork 开销 无开销
典型用途 隔离环境(临时 cd、临时改 IFS) 分组重定向、逻辑与或的组合动作
#!/bin/bash
var="外部值"
cd /tmp

# ---------- 子 Shell:完全隔离 ----------
(
    var="子 Shell 里改的"
    cd /etc
    echo "子 Shell 内: var=$var pwd=$(pwd) PID=$BASHPID"
)
echo "子 Shell 后: var=$var pwd=$(pwd)"        # 外部值  /tmp(都没变)

# ---------- 代码块:不隔离 ----------
{
    var="代码块里改的"
    cd /etc
    echo "代码块内: var=$var pwd=$(pwd)"
}
echo "代码块后: var=$var pwd=$(pwd)"           # 代码块里改的  /etc(都变了)

# ---------- 语法差异演示 ----------
(echo a; echo b)          # 正确
{ echo a; echo b; }       # 正确:{ 后有空格,b 后有分号
# {echo a; echo b; }      # 错误:{ 后缺空格,会被当成命令名 {echo
# { echo a; echo b }      # 错误:最后缺分号

# ---------- 分组重定向 ----------
{
    echo "标题行"
    date
    df -h
} > report.txt            # 三条命令的输出统一写入一个文件

(
    echo "子 Shell 的分组"
    uptime
) | tee out.log

# ---------- 逻辑运算中的组合动作(代码块最常见用途) ----------
[ -f /etc/passwd ] || { echo "文件不存在" >&2; exit 1; }
grep -q root /etc/passwd && { echo "找到 root"; echo "继续处理"; }

# ---------- exit 的差异(重要) ----------
( exit 1 ); echo "子 Shell exit 后脚本继续运行,\$?=$?"
# { exit 1; }             # 这会直接终止整个脚本!

# ---------- 用子 Shell 隔离临时环境 ----------
(
    IFS=':'                              # 临时改 IFS,不污染外部
    set -- $PATH
    echo "PATH 有 $# 个目录"
)
echo "外部 IFS 未被改变"

# 查看当前是否在子 Shell 中:$BASHPID 是真实 PID,$$ 在子 Shell 中不变
echo "\$\$=$$  \$BASHPID=$BASHPID"
(echo "子 Shell 中 \$\$=$$  \$BASHPID=$BASHPID")     # $$ 相同,BASHPID 不同
echo "SHLVL(Shell 嵌套层级)= $SHLVL"

16.3 命令替换 $( ) 与反引号

对比项 `命令` $(命令)
可嵌套 不能(内层需层层转义 \``) 可以,任意层嵌套
转义规则 反斜杠处理复杂且反直觉 直观,内部按正常规则解析
可读性 反引号与单引号易混淆 边界清晰
兼容性 所有 sh 都支持 POSIX 标准,现代 Shell 均支持
结论 不推荐 推荐

两者共同点:都会开新进程执行命令,并用命令的标准输出替换掉整个表达式,同时删除末尾的所有换行符。

# 基本用法
now=`date +%F`
now=$(date +%F)              # 推荐

# 嵌套:$() 天然支持
echo "$(basename "$(dirname "$(pwd)")")"

# 反引号嵌套必须层层转义,极易出错
echo "`basename \`dirname \\\`pwd\\\`\``"

# 命令替换会吃掉末尾换行,需要保留时的技巧
content=$(cat /etc/hostname)            # 末尾换行被删除
content=$(cat /etc/hostname; echo x); content=${content%x}   # 保留末尾换行

# 多行输出赋值给变量后,用双引号才能保留换行
out=$(df -h)
echo "$out"                  # 正确:保留原有换行和对齐
echo $out                    # 错误:所有换行变空格,格式全乱

# 命令替换在算术中
files=$(ls -1 | wc -l)
echo "共 $files 个文件"
echo "文件数的两倍是 $((files * 2))"

# 讲义写法
var=`ls -l`
a=$(ls -l)

16.4 进程替换 <( ) 与 >( )

进程替换把一个命令的输出(或输入)伪装成一个文件名(实际是 /dev/fd/63 这样的匿名管道),从而让只接受文件参数的命令能直接消费命令的输出。

写法 方向 含义
<(命令) 读 命令的输出变成一个可读文件
>(命令) 写 命令的输入变成一个可写文件

进程替换是 bash 扩展(sh 不支持),需要系统支持 /dev/fd。

# ---------- 经典例子:diff 两个命令的输出 ----------
# 传统做法:要先生成两个临时文件,还要记得删
ls /dir1 > /tmp/a.txt
ls /dir2 > /tmp/b.txt
diff /tmp/a.txt /tmp/b.txt
rm -f /tmp/a.txt /tmp/b.txt

# 进程替换:一行搞定,无需临时文件
diff <(ls /dir1) <(ls /dir2)

# 更多实战对比
diff <(sort file1) <(sort file2)                       # 排序后再比对
diff <(rpm -qa | sort) <(ssh web02 'rpm -qa' | sort)   # 比对两台主机的软件包
diff <(cat /etc/passwd) <(ssh web02 cat /etc/passwd)   # 比对配置差异
comm -12 <(sort a.txt) <(sort b.txt)                   # 取两个文件的交集
diff <(mysql -Ne 'show databases' -h db1) <(mysql -Ne 'show databases' -h db2)

# ---------- <() 供只接受文件名的命令使用 ----------
while read -r line; do echo "$line"; done < <(grep root /etc/passwd)
paste <(cut -d: -f1 /etc/passwd) <(cut -d: -f7 /etc/passwd)
grep -f <(echo -e "root\ndaemon") /etc/passwd          # 模式列表来自命令输出
tar -czf backup.tar.gz -T <(find /data -name '*.log')  # 文件列表来自 find

# ---------- >() 把输出分流给另一个命令 ----------
# 把日志同时写入文件并筛出错误行
./app.sh > >(tee app.log) 2> >(tee app.err >&2)

# 一份输出分流给多个处理程序
cat access.log | tee >(grep 404 > 404.log) >(grep 500 > 500.log) > /dev/null

# 边压缩边校验
tar -cf - /data | tee >(sha256sum > backup.sha256) | gzip > backup.tar.gz

# 查看进程替换实际生成的文件名
echo <(true)                   # /dev/fd/63

16.5 管道导致变量赋值丢失的经典坑

管道的每一段都在各自的子 Shell 中执行,因此在管道右侧修改的变量,在管道结束后全部丢失。

#!/bin/bash
# ===== 反例:统计结果永远是 0 =====
count=0
cat /etc/passwd | while read -r line; do
    count=$((count + 1))          # 这个 count 是子 Shell 里的副本
done
echo "count = $count"             # 输出 0!不是行数

# ===== 同类反例 =====
total=0
find /var/log -name '*.log' | while read -r f; do
    total=$((total + $(stat -c %s "$f")))
done
echo "总大小 = $total"            # 永远是 0

echo "a b c" | read x y z
echo "x=[$x]"                     # 空

解法一:用输入重定向代替管道(最推荐)

count=0
while read -r line; do
    count=$((count + 1))
done < /etc/passwd                # 重定向:while 在当前 Shell 执行
echo "count = $count"             # 正确

# 数据来自命令时,配合进程替换
count=0
while read -r line; do
    count=$((count + 1))
done < <(grep bash /etc/passwd)    # 注意 < 和 <( 之间有空格
echo "count = $count"              # 正确

解法二:shopt -s lastpipe

让管道的最后一段在当前 Shell 执行(bash 4.2+,且必须关闭作业控制,因此在交互式 Shell 中需先 set +m):

#!/bin/bash
shopt -s lastpipe                 # 开启 lastpipe
# set +m                          # 交互式 Shell 中还需关闭 job control

count=0
cat /etc/passwd | while read -r line; do
    count=$((count + 1))
done
echo "count = $count"             # 正确!最后一段在当前 Shell 执行

shopt -p lastpipe                 # 查看当前设置

解法三:用进程替换(等价于解法一的变体)

count=0
while read -r line; do
    ((count++))
done < <(cat /etc/passwd)
echo "count = $count"             # 正确

其他变通做法

# 把整个管道结果通过标准输出带回来
count=$(cat /etc/passwd | wc -l)                # 最简单:让工具自己算
count=$(grep -c . /etc/passwd)

# 让子 Shell 把结果 echo 出来,外面用命令替换接
count=$(cat /etc/passwd | { c=0; while read -r l; do c=$((c+1)); done; echo "$c"; })
echo "count = $count"

# 用数组把数据先读进来,再在当前 Shell 中处理
mapfile -t lines < /etc/passwd
echo "count = ${#lines[@]}"

# 用临时文件传递(笨但通用)
cat /etc/passwd | while read -r l; do echo x; done | wc -l > /tmp/c
count=$(cat /tmp/c)

排查口诀:看到「循环里累加的变量在循环外变回 0」,第一反应就是检查数据源是不是用管道喂进 while 的。

16.6 后台执行、nohup、disown 与 wait

# ---------- & 放入后台 ----------
sleep 100 &                 # 后台运行,返回作业号和 PID
echo "刚放入后台的 PID = $!"

jobs                        # 查看当前 Shell 的后台作业
jobs -l                     # 带 PID
fg %1                       # 把作业 1 调到前台
bg %1                       # 让暂停的作业 1 在后台继续

# ---------- nohup:忽略 SIGHUP,终端关闭后进程继续 ----------
nohup ./long_task.sh &                              # 输出默认写入 ./nohup.out
nohup ./long_task.sh > /var/log/task.log 2>&1 &     # 显式指定日志(推荐)
nohup ./long_task.sh &> /dev/null &                 # 丢弃输出

# ---------- disown:已经在后台的作业,事后剥离 ----------
./long_task.sh &
disown %1                   # 从当前 Shell 的作业表中移除,不再收到 SIGHUP
disown -h %1                # 保留在作业表中但不接收 SIGHUP
disown -a                   # 剥离所有作业

# ---------- setsid:彻底脱离终端会话(最干净) ----------
setsid ./long_task.sh > /var/log/task.log 2>&1 < /dev/null &

# ---------- wait:等待后台任务 ----------
sleep 5 &
pid1=$!
sleep 3 &
pid2=$!

wait "$pid1"                # 等待指定 PID
echo "任务 1 退出码 = $?"
wait "$pid2"
wait                        # 不带参数:等待所有后台子进程结束
echo "全部后台任务已完成"

# ---------- 并发执行 + 控制并发数 + 收集退出码 ----------
#!/bin/bash
hosts=(web01 web02 web03 web04 web05)
MAX=3                       # 最大并发数
declare -A rc

for h in "${hosts[@]}"; do
    # 并发数达到上限时等一个结束再继续(bash 4.3+ 可用 wait -n)
    while [ "$(jobs -rp | wc -l)" -ge "$MAX" ]; do
        sleep 0.2
    done
    (
        echo "开始处理 $h"
        ssh -o ConnectTimeout=5 "$h" 'uptime' > "/tmp/${h}.out" 2>&1
    ) &
    rc["$h"]=$!
done

wait                        # 等待全部完成
for h in "${hosts[@]}"; do
    echo "===== $h ====="
    cat "/tmp/${h}.out"
done

后台作业、前后台切换、jobs / fg / bg / nohup / screen / tmux、进程状态与优先级的完整内容,详见 04-01-进程与任务管理.md。


十七、脚本调试与规范

17.1 调试手段

语法检查与跟踪执行

命令/选项 作用
bash -n script.sh 只做语法检查,不实际执行,最安全的第一步
bash -x script.sh 跟踪执行,把每条实际执行的命令(变量已展开)打印到 stderr,前缀为 +
bash -v script.sh 逐行打印源代码原文(未展开变量),常与 -x 合用
bash -xv script.sh 同时显示原文与展开后的命令
bash -u script.sh 引用未定义变量时报错
bash -e script.sh 任何命令失败立即退出
# 先查语法,再跟踪执行
bash -n deploy.sh && echo "语法 OK"
bash -x deploy.sh
bash -xv deploy.sh 2> /tmp/trace.log      # 跟踪信息写入文件方便查阅

# 也可以写进 shebang(调试期临时用,上线前务必去掉)
#!/bin/bash -x
#!/bin/bash -xe

脚本内局部开启跟踪

跟踪整个大脚本输出量太大,用 set -x / set +x 只跟踪可疑代码段:

#!/bin/bash
echo "这段不跟踪"

set -x                        # 开启跟踪
result=$(complicated_function "$arg1" "$arg2")
if [ "$result" -gt 10 ]; then
    do_something
fi
set +x                        # 关闭跟踪

echo "这段也不跟踪"

# 用变量控制是否调试,无需改代码
DEBUG=${DEBUG:-0}
[ "$DEBUG" -eq 1 ] && set -x
# 运行时:DEBUG=1 ./script.sh

# 查看当前生效的选项
echo "$-"                     # 含 x 表示跟踪已开启
set -o | grep xtrace

PS4 定制跟踪输出(含行号与函数名)

默认 -x 的前缀只有一个 +,看不出行号,大脚本中难以定位。用 PS4 定制:

# 加上文件名、行号、函数名(强烈推荐)
export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '

# 更详细:带时间戳与嵌套层级(PS4 首字符会按嵌套深度重复)
export PS4='+ $(date "+%T") ${BASH_SOURCE##*/}:${LINENO} ${FUNCNAME[0]:-main}() > '

# 带颜色,跟踪信息与正常输出一眼分清
export PS4='\033[33m+${BASH_SOURCE}:${LINENO}:\033[0m '

bash -x script.sh

效果示例:

+/opt/ops/bin/check.sh:15:main: disk_usage=85
+/opt/ops/bin/check.sh:22:check_disk: [ 85 -gt 80 ]
+/opt/ops/bin/check.sh:23:check_disk: echo 磁盘告警

PS4 必须 export 才能对 bash -x script.sh 这种子进程生效;只在脚本内 set -x 时,脚本内直接赋值 PS4= 即可。

set -e:遇错即退出,及其陷阱

set -e                # 等价 set -o errexit

作用:任何命令返回非 0 时立即终止脚本。它能避免「前一步失败了后面还继续跑」的灾难(例如 cd /data 失败后接着 rm -rf *)。

但 set -e 有大量例外,必须了解,否则会误以为有保护实际没有:

#!/bin/bash
set -e

# 陷阱 1:出现在 if / while / until 条件中的命令失败不会触发退出
if grep notexist /etc/passwd; then echo found; fi    # 不会退出
while false; do :; done                              # 不会退出

# 陷阱 2:&& || 左侧的命令失败不会触发退出
false && echo never
false || echo "这样写 false 的失败被吞掉了"

# 陷阱 3:只有管道的最后一条命令的返回码算数
false | true; echo "管道退出码 = $?"     # 0,脚本不会退出(需配合 pipefail)

# 陷阱 4:! 取反后失败不触发退出
! false                                  # 不会退出

# 陷阱 5:函数内的失败会向上传播,但函数出现在条件位置时整体豁免
check() { false; echo "这行不会执行"; }
if check; then :; fi                     # 整个 check 内部都被豁免了 set -e

# 陷阱 6:local/declare/export 与命令替换连写会吞掉退出码
local x=$(false)                         # $? 是 local 的 0
export Y=$(false)                        # 同样问题
# 正确写法:先声明后赋值
local x; x=$(false)

# 陷阱 7:算术表达式结果为 0 时返回码是 1
count=0
((count++))                              # count++ 返回旧值 0 -> 返回码 1 -> 脚本退出!
((count++)) || true                      # 正确写法
count=$((count + 1))                     # 或者用这种写法,永不出错

# 需要允许某条命令失败时,显式声明
some_command || true
some_command || :
if ! some_command; then echo "失败但继续"; fi

set -u:未定义变量报错

#!/bin/bash
set -u                # 等价 set -o nounset

echo "$undefined_var"     # 报错 unbound variable 并退出(默认只是输出空串)

# 在 -u 下访问可能未定义的变量必须给默认值
echo "${MAYBE_UNSET:-默认值}"
echo "${1:-}"                       # 位置参数也一样
[ "${#@}" -eq 0 ] && echo "无参数"

# 空数组在 bash 4.2 下的坑(bash 4.4 已修复)
declare -a arr=()
echo "${arr[@]}"                    # bash 4.2 + set -u 会报 unbound variable
echo "${arr[@]:-}"                  # 兼容写法

set -o pipefail:管道任一环失败即失败

#!/bin/bash
# 默认行为:管道退出码 = 最后一条命令的退出码
false | true; echo "无 pipefail: $?"          # 0,掩盖了 false 的失败

set -o pipefail
false | true; echo "有 pipefail: $?"          # 1,正确反映失败

# 实际危害示例:备份看似成功实则源命令失败
mysqldump -uroot nonexistdb | gzip > backup.sql.gz
echo "无 pipefail 时 \$? = $?"     # 0!得到一个只有 gzip 头的空备份

set -o pipefail
mysqldump -uroot nonexistdb | gzip > backup.sql.gz || echo "备份失败,已检出"

# pipefail 的副作用:head 提前关闭管道会让上游收到 SIGPIPE 而"失败"
set -o pipefail
yes | head -3          # 退出码 141(128+13 SIGPIPE),在 set -e 下会退出脚本
yes | head -3 || true  # 需要显式豁免

set -euo pipefail 组合

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'           # 常与之搭配:把 IFS 限定为换行和 tab,避免空格分词意外

含义:

选项 全称 含义
-e errexit 任一命令失败立即退出
-u nounset 引用未定义变量即报错退出
-o pipefail — 管道中任一环失败则整个管道失败

被称为「bash 严格模式」(unofficial strict mode)。适用性评估:

  • 适合:自己新写的、有明确成败判定的自动化脚本(部署、备份、CI),能把「静默失败」变成「显式报错」。
  • 不适合直接套用:
    • 交互式脚本、菜单脚本(用户输错就退出,体验很差)。
    • 大量依赖「命令失败是正常情况」的巡检类脚本(如 grep 找不到返回 1、ping 不通返回 1)。
    • 维护旧脚本时贸然加上,会因为上面列出的各种陷阱产生更难查的问题。
  • 实践建议:新脚本一律用 set -euo pipefail 起步,配合 trap ... ERR 打印出错行号;对允许失败的命令显式写 || true;旧脚本改造时逐段验证,不要一次性全开。
#!/bin/bash
# 严格模式 + 错误定位的标准头部
set -euo pipefail
trap 'echo "错误: 第 ${LINENO} 行命令 [${BASH_COMMAND}] 失败,退出码 $?" >&2' ERR

trap DEBUG 单步调试

#!/bin/bash
# 每执行一条命令前暂停,回车继续(简易单步调试器)
trap 'read -p "第 $LINENO 行将执行: $BASH_COMMAND  [回车继续]"' DEBUG

x=1
y=2
echo $((x + y))

# 只打印不暂停:监控某个变量的变化
trap 'echo "第 $LINENO 行后: count=$count"' DEBUG

echo / printf 埋点与日志函数

#!/bin/bash
# 最朴素但最有效的调试:关键位置打印变量
echo "DEBUG: var=[$var] count=[$count] file=[$file]" >&2

# 用 printf 显示不可见字符,排查空格、换行、\r 问题
printf 'DEBUG: [%q]\n' "$var"          # %q 会把空格、换行显式转义
printf 'DEBUG: 长度=%d 内容=[%s]\n' "${#var}" "$var"
echo "$var" | od -c | head             # 看到每个字节,排查 Windows 换行 \r

# 生产脚本的标准日志函数(带级别控制)
LOG_LEVEL=${LOG_LEVEL:-INFO}            # DEBUG < INFO < WARN < ERROR
LOG_FILE=${LOG_FILE:-/var/log/myscript.log}

_log() {
    local level="$1"; shift
    local -A prio=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
    [ "${prio[$level]}" -lt "${prio[$LOG_LEVEL]}" ] && return 0
    printf '%s [%-5s] [%s:%s] %s\n' \
        "$(date '+%F %T')" "$level" "${FUNCNAME[2]:-main}" "${BASH_LINENO[1]}" "$*" \
        | tee -a "$LOG_FILE" >&2
}

log_debug() { _log DEBUG "$@"; }
log_info()  { _log INFO  "$@"; }
log_warn()  { _log WARN  "$@"; }
log_error() { _log ERROR "$@"; }

# 用法:LOG_LEVEL=DEBUG ./script.sh 即可打开调试输出
log_debug "变量 var=$var"
log_info  "任务开始"
log_error "连接数据库失败"

shellcheck 静态检查工具

shellcheck 能在运行前发现绝大多数常见错误(未加引号的变量、拼写错误的变量名、无效的 test 写法等),是编写 Shell 脚本的必备工具。

安装:

# CentOS 7 / RHEL 7:需要 EPEL 源
yum install -y epel-release
yum install -y ShellCheck

# CentOS 6:EPEL 中可能没有,下载官方静态二进制
wget https://github.com/koalaman/shellcheck/releases/download/stable/shellcheck-stable.linux.x86_64.tar.xz
tar -xJf shellcheck-stable.linux.x86_64.tar.xz
cp shellcheck-stable/shellcheck /usr/local/bin/

# Ubuntu / Debian
apt-get install -y shellcheck

# 通过 docker 使用(无需安装)
docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:stable myscript.sh

使用:

shellcheck myscript.sh                    # 检查单个脚本
shellcheck *.sh                           # 批量检查
shellcheck -s bash myscript.sh            # 指定方言为 bash
shellcheck -S warning myscript.sh         # 只报 warning 及以上级别
shellcheck -f gcc myscript.sh             # gcc 格式输出,便于编辑器跳转
shellcheck -x myscript.sh                 # 跟进 source 引入的文件一起检查
shellcheck -e SC2086,SC2034 myscript.sh   # 排除指定检查项

# 在脚本内针对某一行禁用某项检查
# shellcheck disable=SC2086
echo $unquoted_on_purpose

# 在脚本头部全局声明
#!/bin/bash
# shellcheck shell=bash
# shellcheck disable=SC2034

常见告警编号:

编号 含义
SC2086 变量未加双引号,可能被分词或通配展开(最常见、最重要)
SC2034 变量被赋值但从未使用(可能拼写错误)
SC2154 引用了从未赋值的变量(大概率拼写错误)
SC2006 使用了反引号,建议改为 $( )
SC2046 命令替换未加引号,会被分词
SC2164 cd 未检查返回值,建议 cd x \|\| exit
SC2181 用 $? 判断上一条命令,建议直接 if cmd; then
SC2126 grep \| wc -l 建议改用 grep -c

17.2 编码规范

shebang 必须写

脚本第一行必须写解释器声明,否则用 ./script.sh 执行时会由当前 Shell(可能是 dash、zsh、csh)解释,行为不可预期。

写法 优点 缺点 适用场景
#!/bin/bash 路径确定;可在 shebang 上加选项(#!/bin/bash -e) 若 bash 不在 /bin(如部分 BSD、NixOS)则失效 Linux 运维脚本首选,CentOS 6/7 上 bash 恒在 /bin/bash
#!/usr/bin/env bash 通过 PATH 查找,跨平台(macOS/BSD/容器)通用;能用到用户自编译的新版 bash 不能可靠地传第二个参数;受 PATH 影响,安全敏感场景(setuid)下不推荐 需要跨发行版分发的脚本
#!/bin/sh POSIX 最大兼容 CentOS 上 /bin/sh 是 bash 的软链但会进入 POSIX 模式,数组、[[ ]]、local 等 bash 特性全部不可用 只写纯 POSIX 脚本时
#!/bin/bash                    # 运维脚本推荐
#!/usr/bin/env bash            # 跨平台工具推荐
#!/bin/bash -e                 # 可以带选项(env 写法不行)

# 不要写这些
#!/bin/sh                      # 会丢失 bash 特性,却仍写 bash 语法 → 隐蔽的报错
# 完全不写                      # 由调用者的 Shell 解释,不可预期

脚本头部注释块

#!/bin/bash
# ============================================================================
# 脚本名称: db_backup.sh
# 功能描述: 全量备份 MySQL 数据库并保留最近 7 份,失败时发送告警邮件
# 适用系统: CentOS 6 / CentOS 7 (bash 4.1+)
# 依赖命令: mysqldump, gzip, mail, find
#
# 用法:     ./db_backup.sh [-d 数据库名] [-o 输出目录] [-k 保留份数]
# 示例:     ./db_backup.sh -d appdb -o /data/backup -k 7
#
# 退出码:   0  成功
#           1  参数错误
#           2  依赖命令缺失
#           3  数据库连接失败
#           4  备份文件校验失败
#
# 作者:     张三 <zhangsan@example.com>
# 创建日期: 2024-05-17
# 版本:     v1.2.0
# 变更记录:
#   v1.0.0  2024-03-01  张三  初始版本
#   v1.1.0  2024-04-10  李四  增加备份文件完整性校验
#   v1.2.0  2024-05-17  张三  增加保留份数可配置、失败邮件告警
# ============================================================================

变量命名

# 全大写 + 下划线:常量、环境变量、全局配置
readonly MAX_RETRY=3
readonly BACKUP_DIR="/data/backup"
declare -r LOG_FILE="/var/log/backup.log"
export APP_ENV="production"

# 小写 + 下划线:局部变量、临时变量
local file_count=0
local current_user
db_name="appdb"

# 函数名:小写 + 下划线,动词开头,见名知意
check_disk_usage() { :; }
send_alert_mail() { :; }
get_service_status() { :; }

# 避免的写法
a=1                            # 无意义名字
TMP=1                          # 局部变量用全大写,容易与环境变量冲突
PATH="/my/dir"                 # 覆盖系统关键变量!应改名 MY_PATH
UID=1000                       # UID 是只读内置变量,赋值会报错
中文变量名=1                    # 强烈不建议中文和拼音命名
gameStatus / GameStatus        # 驼峰在 Shell 中不是惯例(讲义中有列出,但推荐下划线)

# 常量用 readonly 保护
readonly CONFIG="/etc/app.conf"
CONFIG="/tmp/x"                # 报错:readonly variable

双引号包裹变量(最重要的一条规范)

除极少数刻意需要分词/通配的场景外,所有变量引用都必须用双引号包裹。 不加引号时,bash 会对变量值做「单词拆分(word splitting)」和「路径通配展开(globbing)」,这是 Shell 脚本事故的第一大来源。

#!/bin/bash
# ===================== 反例 =====================
f="my file.txt"                 # 文件名含空格
rm $f                           # 展开成 rm my file.txt → 删了 my 和 file.txt 两个文件!
rm "$f"                         # 正确

dir="/tmp/my dir"
cd $dir                         # cd: /tmp/my: 没有该文件或目录
cd "$dir"                       # 正确

# 空值导致语法错误
empty=""
[ $empty = "x" ]                # 展开成 [ = "x" ] → bash: [: =: 期待一元表达式
[ "$empty" = "x" ]              # 正确

# 通配符被意外展开
pattern="*"
echo $pattern                   # 列出当前目录所有文件!
echo "$pattern"                 # 正确输出 *

files="*.txt"
[ -f $files ]                   # 参数过多错误
[ -f "$files" ]                 # 正确

# 多行输出格式被破坏
out=$(df -h)
echo $out                       # 所有换行变空格,表格全乱
echo "$out"                     # 正确保留格式

# 数组遍历
arr=("a b" "c d")
for i in ${arr[@]}; do echo "[$i]"; done      # 4 个元素:a b c d
for i in "${arr[@]}"; do echo "[$i]"; done    # 正确,2 个元素

# 最危险的组合
rm -rf $DIR/*                   # DIR 为空时变成 rm -rf /* !
rm -rf "${DIR:?DIR 未设置}"/*    # 正确:DIR 为空直接报错退出

# ===================== 少数不加引号的场合 =====================
# 1. 确实需要把字符串拆成多个参数时(更好的做法是用数组)
opts="-l -a -h"
ls $opts                        # 有意分词
opts_arr=(-l -a -h); ls "${opts_arr[@]}"      # 推荐写法

# 2. [[ ]] 内部右侧的模式(加引号就变字面匹配了)
[[ "$f" == *.txt ]]             # 右侧模式不加引号
[[ "$s" =~ $re ]]               # 正则变量不加引号

# 3. (( )) 与 $(( )) 算术上下文内部(不做分词,无需引号)
if (( count > 10 )); then :; fi

# 4. 变量赋值的右侧(赋值不做分词,但加引号是好习惯)
a=$b                            # 安全,但仍建议写 a="$b"

[[ ]] 优于 [ ] 的原因

能力 [ ](test,外部/内建命令) [[ ]](bash 关键字)
变量为空/含空格 会因分词而语法错误,必须加引号 内部不做分词,不加引号也安全
逻辑运算 用 -a / -o(已废弃,优先级混乱) 用 && / \|\|,语义清晰
模式匹配 不支持 支持 == 通配模式
正则匹配 不支持 支持 =~ + BASH_REMATCH
字典序比较 \< \> 必须转义 直接写 < >
短路求值 两侧都会求值 支持短路,右侧可能不执行
可移植性 POSIX 标准,所有 sh 通用 bash/ksh/zsh 专有,dash 不支持
# [[ ]] 的优势演示
unset v
[ $v = "x" ]                    # 语法错误
[[ $v = "x" ]]                  # 正常返回假,不报错

[ "$a" -gt 5 -a "$b" -lt 10 ]   # 老式写法
[[ $a -gt 5 && $b -lt 10 ]]     # 清晰

[[ $file == *.log ]]            # 模式匹配
[[ $ip =~ ^[0-9.]+$ ]]          # 正则匹配

# 结论:写 bash 脚本(已声明 #!/bin/bash)一律用 [[ ]];
#      只有需要兼容 POSIX sh 时才用 [ ],且必须给所有变量加引号。
# 算术判断优先用 (( )):
(( count > 10 ))                # 比 [ "$count" -gt 10 ] 更直观

$( ) 优于反引号

now=`date`                      # 不推荐:不能嵌套,转义规则反直觉
now=$(date)                     # 推荐:可嵌套,边界清晰
echo "$(basename "$(dirname "$PWD")")"   # 嵌套毫无障碍

退出码的规划

退出码 约定含义
0 成功
1 通用错误
2 用法错误 / 参数错误(与 shell 内建约定一致)
3~99 脚本自定义的业务错误,应在头部注释中列明
126 命令找到但不可执行(权限不足)
127 命令未找到
128+N 被信号 N 终止(如 130 = 128+2 = Ctrl+C,137 = 128+9 = kill -9)
255 退出码越界
#!/bin/bash
# 用常量定义退出码,可读性远好于裸数字
readonly E_OK=0
readonly E_USAGE=1
readonly E_NODEP=2
readonly E_CONN=3
readonly E_VERIFY=4

command -v mysqldump >/dev/null || { echo "缺少 mysqldump" >&2; exit "$E_NODEP"; }
[ $# -ge 1 ] || { usage; exit "$E_USAGE"; }
exit "$E_OK"

幂等性

脚本应能重复执行多次而结果一致,这对配置管理和自动化重试至关重要。

# ---------- 反例:重复执行会出问题 ----------
mkdir /data/backup                                  # 第二次执行报「已存在」
useradd appuser                                     # 第二次执行报「已存在」
echo "export PATH=/opt/bin:\$PATH" >> ~/.bashrc     # 每次执行都追加一遍
iptables -A INPUT -p tcp --dport 80 -j ACCEPT       # 规则被重复添加

# ---------- 正例:幂等写法 ----------
mkdir -p /data/backup                               # -p 已存在也不报错

id appuser &>/dev/null || useradd appuser           # 先判断再创建
getent group appgrp >/dev/null || groupadd appgrp

# 先检查是否已存在再追加
grep -qF '/opt/bin' ~/.bashrc || echo 'export PATH=/opt/bin:$PATH' >> ~/.bashrc

# 防火墙规则:检查后添加
iptables -C INPUT -p tcp --dport 80 -j ACCEPT 2>/dev/null \
    || iptables -A INPUT -p tcp --dport 80 -j ACCEPT

# 配置修改:用 sed 替换而非追加,或先删后加
sed -i 's/^PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i '/^MY_SETTING=/d' /etc/app.conf; echo 'MY_SETTING=1' >> /etc/app.conf

# 软链接:-f 强制覆盖
ln -sfn /opt/app-v2 /opt/app-current

# 挂载:先判断
mountpoint -q /data || mount /dev/sdb1 /data

# 服务启动(CentOS 7 systemd)
systemctl is-active --quiet httpd || systemctl start httpd
# 服务启动(CentOS 6 SysV init)
service httpd status &>/dev/null || service httpd start

危险写法与防护

# ================= 头号杀手:rm -rf 变量未判空 =================
# 反例:DIR 为空或含空格时后果灾难性
rm -rf $DIR/*                       # DIR 空 → rm -rf /*
rm -rf "$DIR"/                      # DIR 空 → rm -rf /
cd $DIR; rm -rf *                   # cd 失败仍在原目录,删错地方

# 防护写法(多重保险)
# 1) 用 :? 断言非空
rm -rf "${DIR:?错误: DIR 变量未设置或为空}"/*

# 2) 显式判空 + 判断目录存在
if [ -n "$DIR" ] && [ -d "$DIR" ]; then
    rm -rf -- "${DIR:?}"/*
fi

# 3) cd 成功才继续(&& 串联,或用 || exit)
cd "$DIR" || exit 1
rm -rf ./*                          # 用 ./ 前缀,避免文件名以 - 开头被当选项

# 4) 白名单校验,禁止危险路径
case "$DIR" in
    /|/bin|/boot|/dev|/etc|/lib|/lib64|/proc|/root|/sbin|/sys|/usr|/var)
        echo "拒绝操作系统关键目录: $DIR" >&2; exit 1 ;;
    /data/*|/tmp/*)
        : ;;                        # 只允许这些前缀
    *)
        echo "路径不在允许范围: $DIR" >&2; exit 1 ;;
esac
rm -rf -- "$DIR"

# 5) 危险操作先 dry-run 打印
[ "$DRY_RUN" = 1 ] && { echo "将执行: rm -rf $DIR"; exit 0; }

# 6) 用 find -delete 替代,可精确限定条件
find "$DIR" -mindepth 1 -maxdepth 1 -mtime +7 -delete

# ================= 其他危险写法 =================
# 未判断 cd 是否成功
cd /notexist; rm -rf *              # 危险
cd /notexist || exit 1              # 正确

# 用 $? 判断(易被中间命令改写)
grep root /etc/passwd > /dev/null
if [ $? -eq 0 ]; then :; fi         # 不推荐
if grep -q root /etc/passwd; then :; fi     # 推荐

# eval 执行外部输入(命令注入风险)
eval "$user_input"                  # 极度危险,绝对禁止
# 若必须用,先严格白名单校验

# 处理文件名用 ls 而不是通配或 find
for f in $(ls); do rm "$f"; done    # 含空格/换行的文件名会出错
for f in *; do [ -e "$f" ] && rm -- "$f"; done          # 正确
find . -maxdepth 1 -type f -print0 | xargs -0 rm --     # 正确,处理特殊字符

# 明文密码写在脚本里
mysql -uroot -p'Passw0rd!'          # 会出现在 ps 输出和 history 中
# 正确:用 ~/.my.cnf(权限 600)或环境变量文件
mysql --defaults-extra-file=/root/.my.cnf

# 用 kill -9 作为常规手段
kill -9 "$pid"                      # 跳过清理逻辑,可能留下脏数据
kill "$pid"; sleep 3; kill -0 "$pid" 2>/dev/null && kill -9 "$pid"   # 先温柔后强硬

# 临时文件名可预测(符号链接攻击)
tmp="/tmp/mydata.$$"                # 可被预测和抢占
tmp=$(mktemp /tmp/mydata.XXXXXX)    # 正确

缩进、函数化与脚本长度

规范 说明
缩进 统一 4 个空格(不用 tab,除 <<- here doc 场景),if/for/while/case/函数内部都缩进
空行 shebang 与首行代码之间、连续变量定义之后、各控制结构之间、各函数之间加空行分段
空格 双目操作符两侧加空格:a = b(比较)、i + 1;但赋值 a=1 两侧不能有空格
折行 单行不超过 80 字符,用 \ 续行,\ 后不能跟任何字符(含空格)
函数化 单个函数不超过 50 行、只做一件事;重复出现 2 次以上的代码就抽成函数
脚本长度 单个脚本超过 300~500 行就应拆分为「入口脚本 + 函数库」(见 13.7)
主流程 把主流程收拢到 main 函数,末尾用 main "$@" 调用,便于阅读与测试
#!/bin/bash
# ============================================================
# 一个符合规范的脚本骨架
# ============================================================
set -euo pipefail

# ---------- 全局常量 ----------
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"
readonly MAX_RETRY=3

# ---------- 全局变量 ----------
verbose=0
target=""

# ---------- 函数定义 ----------
log() {
    printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" | tee -a "$LOG_FILE"
}

usage() {
    cat >&2 <<EOF
用法: $SCRIPT_NAME [-v] -t <目标>
EOF
    exit 1
}

parse_args() {
    while getopts ":vt:h" opt; do
        case "$opt" in
            v) verbose=1 ;;
            t) target="$OPTARG" ;;
            h) usage ;;
            *) usage ;;
        esac
    done
    [ -n "$target" ] || usage
}

# 折行示例:\ 后面不能有任何字符
do_work() {
    rsync -avz --delete --exclude='*.tmp' \
          --log-file="$LOG_FILE" \
          "$target/" "/backup/$(basename "$target")/"
}

cleanup() {
    log INFO "清理完成"
}

main() {
    trap cleanup EXIT
    parse_args "$@"
    log INFO "开始处理 $target"
    do_work
    log INFO "处理完成"
}

# ---------- 入口 ----------
main "$@"

十八、综合实战案例

18.1 讲义基础脚本实例

18.1.1 九九乘法表

#!/bin/bash

for ((i = 1; i <= 9; i++)); do
    for ((j = 1; j <= i; j++)); do
        printf "%d*%d=%-2d  " $j $i $((i * j))
    done
    echo
done

18.1.2 猜数字游戏

生成 1–100 的随机数,给用户 4–7 次机会,提示剩余次数,猜对提示成功,次数耗尽退出,并允许用户主动退出:

#!/bin/bash

answer=$((RANDOM % 100 + 1))
maxtimes=$((RANDOM % 4 + 4))    # 4~7 次
times=0

while [ $times -lt $maxtimes ]; do
    left=$((maxtimes - times))
    read -p "请输入 1-100 的数字(还剩 $left 次机会,输入 q 退出): " guess
    if [ "$guess" = "q" ] || [ "$guess" = "Q" ]; then
        echo "已退出,答案是 $answer"
        exit 0
    fi
    if [ "$guess" -eq "$answer" ]; then
        echo "猜对了!"
        exit 0
    elif [ "$guess" -lt "$answer" ]; then
        echo "猜小了"
    else
        echo "猜大了"
    fi
    let times+=1
done

echo "机会用完了,答案是 $answer"

简化版(讲义另一版本,$RANDOM 是 bash 内置的 0 ~ 32767 随机数变量):

#!/bin/bash
target=$((RANDOM % 100 + 1))   # 1 ~ 100 的随机数
max=$((RANDOM % 4 + 4))        # 4 ~ 7 次随机猜测机会
count=0
while [ $count -lt $max ]; do
    read -p "guess: " guess
    if [ "$guess" -eq "$target" ]; then
        echo "right!"
        exit 0
    elif [ "$guess" -gt "$target" ]; then
        echo "too big"
    else
        echo "too small"
    fi
    ((count++))
done
echo "game over"

该脚本缺少输入校验:一旦输入非数字,[ "$guess" -eq "$target" ] 会直接报 integer expression expected 并继续消耗次数。生产脚本中应先用 [[ "$guess" =~ ^[0-9]+$ ]] 之类的判断过滤非法输入。

带输入校验的健壮版本:

#!/bin/bash
target=$((RANDOM % 100 + 1))
max=$((RANDOM % 4 + 4))
count=0

while [ "$count" -lt "$max" ]; do
    read -p "请输入 1-100 的整数(剩余 $((max - count)) 次,q 退出): " guess

    [[ "$guess" == [qQ] ]] && { echo "已退出,答案是 $target"; exit 0; }

    if ! [[ "$guess" =~ ^[0-9]+$ ]]; then
        echo "输入非法,请输入纯数字(本次不计入次数)"
        continue                                   # 非法输入不消耗机会
    fi
    if [ "$guess" -lt 1 ] || [ "$guess" -gt 100 ]; then
        echo "请输入 1-100 之间的数(本次不计入次数)"
        continue
    fi

    if [ "$guess" -eq "$target" ]; then
        echo "猜对了!用了 $((count + 1)) 次"
        exit 0
    elif [ "$guess" -lt "$target" ]; then
        echo "猜小了"
    else
        echo "猜大了"
    fi
    ((count++))
done

echo "机会用完,答案是 $target"
exit 1

18.1.3 成绩等级判断

#!/bin/bash

read -p "请输入成绩: " score

if [ "$score" -ge 90 ]; then
    echo "A"
elif [ "$score" -ge 80 ]; then
    echo "B"
elif [ "$score" -ge 70 ]; then
    echo "C"
else
    echo "D"
fi

18.1.4 case 随机数菜单

#!/bin/bash

read -p "是否想得到一个 50-100 的随机数 (yes/no): " choice

case "$choice" in
    yes|YES|Y|y|"")
        echo $((RANDOM % 45 + 55))    # 55~99
        ;;
    no|NO|N|No)
        echo "欢迎下次来玩"
        ;;
    *)
        echo "输入无效"
        ;;
esac

18.1.5 闰年判断

#!/bin/bash

read -p "请输入年份: " year

if (( year % 4 == 0 && year % 100 != 0 )) || (( year % 400 == 0 )); then
    echo "$year 是闰年"
else
    echo "$year 不是闰年"
fi

18.1.6 百钱买百鸡

公鸡 5 元一只,母鸡 3 元一只,小鸡 1 元三只,100 元买 100 只鸡:

#!/bin/bash

for ((x = 0; x <= 20; x++)); do
    for ((y = 0; y <= 33; y++)); do
        z=$((100 - x - y))
        if [ $((5*x + 3*y + z/3)) -eq 100 ] && [ $((z % 3)) -eq 0 ]; then
            echo "公鸡:$x 母鸡:$y 小鸡:$z"
        fi
    done
done

18.1.7 网段存活 IP 探测

测试 192.168.2.0 网段哪些 IP 在使用,输出所有存活 IP:

#!/bin/bash

for i in $(seq 1 254); do
    ping -c 1 -W 1 192.168.2.$i > /dev/null 2>&1
    if [ $? -eq 0 ]; then
        echo "192.168.2.$i 在用"
    fi
done

并发加速版(254 个串行 ping 太慢,改为并发 + wait):

#!/bin/bash
NET="192.168.2"

for i in $(seq 1 254); do
    (
        if ping -c 1 -W 1 "${NET}.${i}" &>/dev/null; then
            echo "${NET}.${i} 在用"
        fi
    ) &
    # 控制并发数,避免一次 fork 254 个进程
    while [ "$(jobs -rp | wc -l)" -ge 50 ]; do sleep 0.1; done
done

wait
echo "扫描完成"

18.1.8 找最小可用普通用户 UID

#!/bin/bash

uid=1000
while grep -q ":x:$uid:" /etc/passwd; do
    let uid+=1
done
echo "最小可用 UID: $uid"

18.1.9 截取本机网卡名与 IP 地址

#!/bin/bash

# 在脚本运行的进程里把语言环境改成英文,避免受语言环境影响
export LANG=en_US.UTF-8
export LANGUAGE=en_US:en

# 截取出正在上网的网卡名称
link=$(route -n | sed -n '/UG/p' | sed -re 's/(.*)(\b[a-Z0-9]+$)/\2/')
# 或
link=$(route -n | sed -n '/UG/p' | awk '{print $NF}')

# 截取出 ip 和子网掩码,用 / 分开
ip=$(ifconfig $link | sed -n '/Mask/p' \
    | sed -r 's/^([^0-9]+)([0-9.]+)(.*)([^0-9.]+)([0-9.]+$)/\2\/\5/')
echo $ip

ifconfig 属于已废弃的 net-tools 工具集,CentOS 7 及以上的最小化安装通常并不自带,推荐使用其现代替代品 ip 命令取 IP:

link=$(route -n | sed -n '/UG/p' | awk '{print $NF}')
ip=$(ip a s $link | sed -n '/inet /p' | awk '{print $2}')
echo $ip

# 更简洁的现代写法(CentOS 7+)
link=$(ip route show default | awk '/default/{print $5}')
addr=$(ip -o -4 addr show "$link" | awk '{print $4}')     # 形如 192.168.1.10/24
echo "网卡=$link 地址=$addr"

ip a s 是 ip address show 的缩写,输出中 inet 行的第 2 个字段即「IP/掩码位数」形式的地址。上述脚本中 sed / awk 的正则与字段处理语法,详见 06-02-文本处理与文件查找.md。

18.1.10 观察 $$ 与进程关系的实验脚本

#!/bin/bash
# 保存为 process,用于观察脚本 PID / PPID 与孤儿进程
echo $$
sleep 999

实验步骤:

  1. 在终端执行 echo $$ 查看终端 PID。
  2. 运行脚本 bash process。
  3. 用 ps -el | grep process 查看进程 PID 是否为脚本打印的 PID,并查看脚本进程的 PPID 是否为终端 PID。
  4. 执行 kill -9 pid 终止脚本。
  5. 脚本终止后,sleep 失去父进程,成为孤儿进程(被 init/systemd 收养)继续运行。

18.2 案例:批量创建用户并设置随机初始密码

需求说明:从用户名列表文件批量创建系统用户,为每人生成 12 位随机密码,要求首次登录必须改密码,把「用户名 密码」记录到权限 600 的文件中交付;已存在的用户跳过(幂等)。

#!/bin/bash
# ============================================================================
# 脚本名称: batch_adduser.sh
# 功能描述: 批量创建用户并设置随机初始密码,输出账号密码清单
# 适用系统: CentOS 6 / CentOS 7
# 用法:     ./batch_adduser.sh -f 用户名列表文件 [-g 附加组] [-s shell]
# 示例:     ./batch_adduser.sh -f users.txt -g devops -s /bin/bash
# 退出码:   0 成功 1 参数错误 2 需要 root 3 列表文件不存在
# ============================================================================
set -uo pipefail

readonly PASS_LEN=12
readonly OUT_FILE="/root/new_users_$(date +%Y%m%d_%H%M%S).txt"
user_file=""
extra_group=""
user_shell="/bin/bash"

usage() {
    cat >&2 <<EOF
用法: $(basename "$0") -f 用户名列表文件 [-g 附加组] [-s 登录shell]
  -f  每行一个用户名的文本文件(# 开头的行和空行会被忽略)
  -g  同时把用户加入的附加组(不存在会自动创建)
  -s  登录 shell,默认 /bin/bash
EOF
    exit 1
}

# ---------- 生成随机密码:字母 + 数字 + 特殊字符 ----------
gen_password() {
    # 用 /dev/urandom 而不是 $RANDOM,强度更高且不可预测
    tr -dc 'A-Za-z0-9@#%+=' < /dev/urandom | head -c "$PASS_LEN"
    echo
}

# ---------- 解析参数 ----------
while getopts ":f:g:s:h" opt; do
    case "$opt" in
        f) user_file="$OPTARG" ;;
        g) extra_group="$OPTARG" ;;
        s) user_shell="$OPTARG" ;;
        h) usage ;;
        :) echo "选项 -$OPTARG 缺少参数" >&2; exit 1 ;;
        \?) echo "未知选项 -$OPTARG" >&2; usage ;;
    esac
done

[ -n "$user_file" ] || usage
[ "$(id -u)" -eq 0 ] || { echo "必须以 root 运行" >&2; exit 2; }
[ -f "$user_file" ] || { echo "列表文件不存在: $user_file" >&2; exit 3; }

# ---------- 附加组不存在则创建(幂等) ----------
if [ -n "$extra_group" ]; then
    getent group "$extra_group" >/dev/null || groupadd "$extra_group"
fi

# ---------- 准备输出文件,先把权限收紧再写内容 ----------
touch "$OUT_FILE"
chmod 600 "$OUT_FILE"
printf '%-20s %-16s %s\n' "用户名" "初始密码" "创建时间" >> "$OUT_FILE"

created=0; skipped=0; failed=0

# 注意:用重定向而不是管道,保证计数器在当前 Shell 中累加
while read -r username; do
    # 跳过空行与注释行
    [ -z "$username" ] && continue
    case "$username" in \#*) continue ;; esac

    # 用户名合法性校验
    if ! [[ "$username" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
        echo "[跳过] 非法用户名: $username" >&2
        ((failed++)) || true
        continue
    fi

    # 已存在则跳过(幂等)
    if id "$username" &>/dev/null; then
        echo "[跳过] 用户已存在: $username"
        ((skipped++)) || true
        continue
    fi

    password=$(gen_password)

    # 创建用户:-m 创建家目录 -s 指定 shell
    if [ -n "$extra_group" ]; then
        useradd -m -s "$user_shell" -G "$extra_group" "$username"
    else
        useradd -m -s "$user_shell" "$username"
    fi

    if [ $? -ne 0 ]; then
        echo "[失败] 创建用户失败: $username" >&2
        ((failed++)) || true
        continue
    fi

    # 设置密码:--stdin 在 CentOS 6/7 都可用
    echo "$password" | passwd --stdin "$username" &>/dev/null
    # 通用写法(无 --stdin 的系统,如 Debian/Ubuntu):
    # echo "${username}:${password}" | chpasswd

    # 强制首次登录修改密码
    chage -d 0 "$username"
    # 设置密码 90 天过期、到期前 7 天警告
    chage -M 90 -W 7 "$username"

    printf '%-20s %-16s %s\n' "$username" "$password" "$(date '+%F %T')" >> "$OUT_FILE"
    echo "[成功] 已创建: $username"
    ((created++)) || true
done < "$user_file"

echo "===================================="
echo "创建成功: $created  已存在跳过: $skipped  失败: $failed"
echo "账号密码清单: $OUT_FILE (权限 600,请及时分发并删除)"
exit 0

用法示例:

# 准备用户名列表
cat > users.txt <<'EOF'
# 开发组成员
zhangsan
lisi
wangwu
EOF

./batch_adduser.sh -f users.txt -g devops
cat /root/new_users_*.txt

# 批量删除(回滚)
while read -r u; do
    [ -z "$u" ] && continue
    case "$u" in \#*) continue ;; esac
    userdel -r "$u" 2>/dev/null && echo "已删除 $u"
done < users.txt

18.3 案例:系统巡检脚本

需求说明:一键输出主机基础信息、CPU、内存、磁盘、负载、网络与关键服务状态,超阈值项标红,同时兼容 CentOS 6(SysV init)和 CentOS 7(systemd),结果可写入日志供定时巡检使用。

#!/bin/bash
# ============================================================================
# 脚本名称: sys_check.sh
# 功能描述: 系统巡检——CPU / 内存 / 磁盘 / 负载 / 网络 / 关键服务状态
# 适用系统: CentOS 6 (SysV init) / CentOS 7 (systemd),自动识别
# 用法:     ./sys_check.sh [-s "服务1 服务2"] [-o 输出文件]
# ============================================================================
set -uo pipefail

readonly WARN_CPU=80         # CPU 使用率告警阈值(%)
readonly WARN_MEM=85         # 内存使用率告警阈值(%)
readonly WARN_DISK=85        # 磁盘使用率告警阈值(%)
readonly WARN_INODE=85       # inode 使用率告警阈值(%)

SERVICES="sshd crond"
OUT=""

C_RED='\033[31m'; C_GREEN='\033[32m'; C_YELLOW='\033[33m'; C_RESET='\033[0m'
# 输出到文件时禁用颜色
[ -t 1 ] || { C_RED=''; C_GREEN=''; C_YELLOW=''; C_RESET=''; }

while getopts ":s:o:h" opt; do
    case "$opt" in
        s) SERVICES="$OPTARG" ;;
        o) OUT="$OPTARG" ;;
        h) echo "用法: $0 [-s \"服务列表\"] [-o 输出文件]"; exit 0 ;;
        *) echo "参数错误" >&2; exit 1 ;;
    esac
done

[ -n "$OUT" ] && exec > >(tee -a "$OUT") 2>&1

title() { echo -e "\n${C_GREEN}===== $* =====${C_RESET}"; }
ok()    { echo -e "  ${C_GREEN}[正常]${C_RESET} $*"; }
warn()  { echo -e "  ${C_YELLOW}[注意]${C_RESET} $*"; }
bad()   { echo -e "  ${C_RED}[告警]${C_RESET} $*"; }

# ---------- 判断 init 体系,兼容 CentOS 6 / 7 ----------
detect_init() {
    if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
        echo "systemd"          # CentOS 7 及以上
    else
        echo "sysv"             # CentOS 6
    fi
}
readonly INIT_SYS=$(detect_init)

# ---------- 1. 主机基础信息 ----------
check_base() {
    title "主机基础信息"
    echo "  主机名   : $(hostname)"
    echo "  系统版本 : $(cat /etc/redhat-release 2>/dev/null || uname -o)"
    echo "  内核版本 : $(uname -r)"
    echo "  系统架构 : $(uname -m)"
    echo "  init 体系: $INIT_SYS"
    echo "  当前时间 : $(date '+%F %T %Z')"
    echo "  运行时长 : $(uptime | sed 's/.*up \([^,]*\),.*/\1/')"
    echo "  登录用户 : $(who | wc -l) 个"
    echo "  bash版本 : ${BASH_VERSION}"
}

# ---------- 2. CPU ----------
check_cpu() {
    title "CPU 状态"
    local cores model idle used
    cores=$(grep -c '^processor' /proc/cpuinfo)
    model=$(awk -F: '/model name/{print $2; exit}' /proc/cpuinfo | sed 's/^ *//')
    echo "  型号     : $model"
    echo "  逻辑核数 : $cores"

    # 用 top 取一次瞬时使用率(-bn1 批处理模式取一次采样)
    idle=$(top -bn1 | awk -F'[,%]+' '/Cpu\(s\)|%Cpu/{for(i=1;i<=NF;i++) if($i ~ /id/) print $(i-1)}' \
           | tr -d ' ' | head -1)
    idle=${idle:-100}
    used=$(awk -v i="$idle" 'BEGIN{printf "%.1f", 100 - i}')
    echo "  使用率   : ${used}%  (空闲 ${idle}%)"

    if awk -v u="$used" -v w="$WARN_CPU" 'BEGIN{exit !(u > w)}'; then
        bad "CPU 使用率 ${used}% 超过阈值 ${WARN_CPU}%"
        echo "  占用最高的 5 个进程:"
        ps -eo pid,ppid,user,pcpu,pmem,comm --sort=-pcpu | head -6 | sed 's/^/    /'
    else
        ok "CPU 使用率 ${used}% 正常"
    fi
}

# ---------- 3. 内存 ----------
check_mem() {
    title "内存状态"
    local total used avail pct swap_total swap_used
    total=$(awk '/MemTotal/{print $2}' /proc/meminfo)
    avail=$(awk '/MemAvailable/{print $2}' /proc/meminfo)
    # CentOS 6 的 /proc/meminfo 没有 MemAvailable,退化为 free+buffers+cached
    if [ -z "$avail" ]; then
        avail=$(awk '/MemFree|Buffers|^Cached/{s+=$2}END{print s}' /proc/meminfo)
    fi
    used=$((total - avail))
    pct=$(awk -v u="$used" -v t="$total" 'BEGIN{printf "%.1f", u*100/t}')

    printf "  总内存   : %.2f GB\n" "$(awk -v t="$total" 'BEGIN{print t/1048576}')"
    printf "  已使用   : %.2f GB (%s%%)\n" "$(awk -v u="$used" 'BEGIN{print u/1048576}')" "$pct"
    printf "  可用     : %.2f GB\n" "$(awk -v a="$avail" 'BEGIN{print a/1048576}')"

    swap_total=$(awk '/SwapTotal/{print $2}' /proc/meminfo)
    swap_used=$((swap_total - $(awk '/SwapFree/{print $2}' /proc/meminfo)))
    if [ "$swap_total" -gt 0 ]; then
        printf "  Swap     : %.2f / %.2f GB\n" \
            "$(awk -v s="$swap_used" 'BEGIN{print s/1048576}')" \
            "$(awk -v s="$swap_total" 'BEGIN{print s/1048576}')"
    fi

    if awk -v p="$pct" -v w="$WARN_MEM" 'BEGIN{exit !(p > w)}'; then
        bad "内存使用率 ${pct}% 超过阈值 ${WARN_MEM}%"
        echo "  占用最高的 5 个进程:"
        ps -eo pid,user,pmem,rss,comm --sort=-pmem | head -6 | sed 's/^/    /'
    else
        ok "内存使用率 ${pct}% 正常"
    fi
}

# ---------- 4. 磁盘 ----------
check_disk() {
    title "磁盘空间"
    local has_warn=0
    # 用 df -P 保证单行输出(POSIX 格式),避免长设备名换行导致字段错位
    while read -r fs size used avail pct mount; do
        pct=${pct%\%}                       # 去掉百分号
        printf "  %-24s %-6s 已用 %-6s 可用 %-6s %3s%%  %s\n" \
            "$fs" "$size" "$used" "$avail" "$pct" "$mount"
        if [ "$pct" -ge "$WARN_DISK" ]; then
            bad "$mount 使用率 ${pct}% 超过阈值 ${WARN_DISK}%"
            has_warn=1
            # 列出该挂载点下最大的 5 个目录,辅助定位
            du -xh --max-depth=1 "$mount" 2>/dev/null | sort -rh | head -6 | sed 's/^/      /'
        fi
    done < <(df -hP | awk 'NR>1 && $1 ~ /^\/dev/')

    [ "$has_warn" -eq 0 ] && ok "所有分区空间正常"

    echo "  --- inode 使用情况 ---"
    while read -r fs inodes iused ifree pct mount; do
        pct=${pct%\%}
        [ "$pct" = "-" ] && continue
        if [ "$pct" -ge "$WARN_INODE" ]; then
            bad "$mount inode 使用率 ${pct}%(空间可能还有但已无法创建文件)"
        fi
    done < <(df -iP | awk 'NR>1 && $1 ~ /^\/dev/')

    echo "  --- 只读挂载检查 ---"
    if grep -q ' ro,' /proc/mounts; then
        bad "存在只读挂载的文件系统:"
        grep ' ro,' /proc/mounts | sed 's/^/    /'
    else
        ok "无异常只读挂载"
    fi
}

# ---------- 5. 系统负载 ----------
check_load() {
    title "系统负载"
    local cores l1 l5 l15 ratio
    cores=$(grep -c '^processor' /proc/cpuinfo)
    read -r l1 l5 l15 _ < /proc/loadavg
    echo "  1 分钟   : $l1"
    echo "  5 分钟   : $l5"
    echo "  15 分钟  : $l15"
    echo "  CPU 核数 : $cores"

    ratio=$(awk -v l="$l1" -v c="$cores" 'BEGIN{printf "%.2f", l/c}')
    echo "  单核负载 : $ratio"

    if awk -v r="$ratio" 'BEGIN{exit !(r > 1)}'; then
        bad "单核负载 $ratio 大于 1,系统处于过载状态"
    elif awk -v r="$ratio" 'BEGIN{exit !(r > 0.7)}'; then
        warn "单核负载 $ratio 偏高,需持续关注"
    else
        ok "负载正常"
    fi

    # 处于 D 状态(不可中断睡眠)的进程往往意味着 IO 阻塞
    local dstat
    dstat=$(ps -eo stat,pid,comm | awk '$1 ~ /^D/{print}' | wc -l)
    [ "$dstat" -gt 0 ] && warn "有 $dstat 个进程处于 D(IO 阻塞)状态"

    # 僵尸进程
    local zombie
    zombie=$(ps -eo stat | grep -c '^Z' || true)
    [ "$zombie" -gt 0 ] && warn "有 $zombie 个僵尸进程"
}

# ---------- 6. 网络 ----------
check_network() {
    title "网络状态"
    local dev
    dev=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
    echo "  默认网卡 : ${dev:-未识别}"
    [ -n "$dev" ] && echo "  IP 地址  : $(ip -o -4 addr show "$dev" | awk '{print $4}')"
    echo "  默认网关 : $(ip route show default 2>/dev/null | awk '/default/{print $3; exit}')"
    echo "  DNS      : $(awk '/^nameserver/{printf "%s ", $2}' /etc/resolv.conf)"

    echo "  --- 连通性 ---"
    if ping -c 2 -W 2 223.5.5.5 &>/dev/null; then
        ok "外网可达"
    else
        bad "外网不可达"
    fi

    echo "  --- TCP 连接统计 ---"
    if command -v ss >/dev/null 2>&1; then
        ss -ant | awk 'NR>1{c[$1]++}END{for(s in c) printf "    %-14s %d\n", s, c[s]}'
        echo "  监听端口 : $(ss -lntu | awk 'NR>1{print $5}' | sed 's/.*://' | sort -n | uniq | tr '\n' ' ')"
    else
        # CentOS 6 可能没有 ss,退回 netstat
        netstat -ant | awk 'NR>2{c[$6]++}END{for(s in c) printf "    %-14s %d\n", s, c[s]}'
    fi
}

# ---------- 7. 关键服务状态(兼容 CentOS 6 / 7) ----------
check_services() {
    title "关键服务状态"
    local svc
    for svc in $SERVICES; do
        if [ "$INIT_SYS" = "systemd" ]; then
            # ===== CentOS 7 及以上:systemctl =====
            if systemctl is-active --quiet "$svc" 2>/dev/null; then
                if systemctl is-enabled --quiet "$svc" 2>/dev/null; then
                    ok "$svc 运行中,且已设置开机自启"
                else
                    warn "$svc 运行中,但未设置开机自启(systemctl enable $svc)"
                fi
            else
                bad "$svc 未运行(systemctl start $svc)"
            fi
        else
            # ===== CentOS 6:service + chkconfig =====
            if service "$svc" status &>/dev/null; then
                if chkconfig --list "$svc" 2>/dev/null | grep -q '3:on'; then
                    ok "$svc 运行中,且 3 级别已设置开机自启"
                else
                    warn "$svc 运行中,但未设置开机自启(chkconfig $svc on)"
                fi
            else
                bad "$svc 未运行(service $svc start)"
            fi
        fi
    done
}

# ---------- 8. 其他安全项 ----------
check_misc() {
    title "其他检查项"
    # 登录失败记录
    local fails
    fails=$(lastb 2>/dev/null | grep -c . || echo 0)
    [ "$fails" -gt 50 ] && warn "登录失败记录 $fails 条,可能存在暴力破解"

    # SELinux
    command -v getenforce >/dev/null && echo "  SELinux  : $(getenforce)"

    # 防火墙
    if [ "$INIT_SYS" = "systemd" ]; then
        systemctl is-active --quiet firewalld && echo "  防火墙   : firewalld 运行中" \
            || echo "  防火墙   : firewalld 未运行"
    else
        service iptables status &>/dev/null && echo "  防火墙   : iptables 运行中" \
            || echo "  防火墙   : iptables 未运行"
    fi

    # 时间同步
    if [ "$INIT_SYS" = "systemd" ]; then
        systemctl is-active --quiet chronyd && echo "  时间同步 : chronyd 运行中"
    else
        service ntpd status &>/dev/null && echo "  时间同步 : ntpd 运行中"
    fi

    # 大文件(可能是失控的日志)
    echo "  --- /var/log 下最大的 5 个文件 ---"
    find /var/log -type f -size +100M 2>/dev/null -exec ls -lh {} \; \
        | awk '{print "    "$5" "$9}' | head -5
}

# ---------- 主流程 ----------
main() {
    echo "############################################################"
    echo "#  系统巡检报告  $(date '+%F %T')"
    echo "############################################################"
    check_base
    check_cpu
    check_mem
    check_disk
    check_load
    check_network
    check_services
    check_misc
    echo -e "\n############### 巡检结束 ###############"
}

main "$@"

用法示例:

chmod +x sys_check.sh
./sys_check.sh                                       # 屏幕输出(带颜色)
./sys_check.sh -s "sshd crond httpd mysqld nginx"    # 指定要检查的服务
./sys_check.sh -o /var/log/syscheck.log              # 同时写入日志(自动去色)

# 加入定时任务,每天 8 点巡检并邮件发送
# 0 8 * * * /opt/ops/bin/sys_check.sh 2>&1 | mail -s "$(hostname) 巡检报告" ops@example.com

18.4 案例:日志按日期归档并清理 N 天前的旧日志

需求说明:把指定目录下的日志按日期打包压缩到归档目录,归档后清空原文件(保持进程写入的 fd 有效),并删除超过 N 天的归档;全过程记录日志,支持 dry-run 预演。

#!/bin/bash
# ============================================================================
# 脚本名称: log_archive.sh
# 功能描述: 日志按日期归档压缩 + 清理 N 天前的旧归档
# 适用系统: CentOS 6 / CentOS 7
# 用法:     ./log_archive.sh -d 日志目录 [-a 归档目录] [-k 保留天数] [-p 匹配模式] [-n]
# 示例:     ./log_archive.sh -d /var/log/nginx -a /data/logarch -k 30 -p '*.log'
# 退出码:   0 成功 1 参数错误 2 目录不存在
# ============================================================================
set -uo pipefail

LOG_DIR=""
ARCH_DIR="/data/logarchive"
KEEP_DAYS=30
PATTERN="*.log"
DRY_RUN=0
readonly DATE_TAG=$(date +%Y%m%d)
readonly SELF_LOG="/var/log/log_archive.log"

log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" | tee -a "$SELF_LOG"; }

usage() {
    cat >&2 <<EOF
用法: $(basename "$0") -d 日志目录 [选项]
  -d DIR   要归档的日志目录(必填)
  -a DIR   归档存放目录,默认 $ARCH_DIR
  -k N     归档保留天数,默认 $KEEP_DAYS
  -p GLOB  日志文件匹配模式,默认 $PATTERN
  -n       dry-run,只打印将要执行的动作
EOF
    exit 1
}

while getopts ":d:a:k:p:nh" opt; do
    case "$opt" in
        d) LOG_DIR="$OPTARG" ;;
        a) ARCH_DIR="$OPTARG" ;;
        k) KEEP_DAYS="$OPTARG" ;;
        p) PATTERN="$OPTARG" ;;
        n) DRY_RUN=1 ;;
        h) usage ;;
        *) usage ;;
    esac
done

# ---------- 参数校验 ----------
[ -n "$LOG_DIR" ] || usage
[ -d "$LOG_DIR" ] || { log ERROR "日志目录不存在: $LOG_DIR"; exit 2; }
[[ "$KEEP_DAYS" =~ ^[0-9]+$ ]] || { log ERROR "保留天数必须是数字"; exit 1; }

# 安全防护:拒绝在系统关键目录上操作
case "$LOG_DIR" in
    /|/bin|/boot|/dev|/etc|/lib|/lib64|/proc|/sbin|/sys|/usr) 
        log ERROR "拒绝操作系统关键目录: $LOG_DIR"; exit 1 ;;
esac

mkdir -p "$ARCH_DIR" || { log ERROR "无法创建归档目录 $ARCH_DIR"; exit 2; }

log INFO "===== 开始归档: 源=$LOG_DIR 归档=$ARCH_DIR 保留=${KEEP_DAYS}天 dry_run=$DRY_RUN ====="

# ---------- 归档 ----------
archived=0
# 用 find -print0 + read -d '' 正确处理含空格的文件名
while IFS= read -r -d '' file; do
    base=$(basename "$file")
    # 跳过空文件
    [ -s "$file" ] || { log INFO "跳过空文件: $base"; continue; }

    target="${ARCH_DIR}/${base}.${DATE_TAG}.gz"
    # 同名归档已存在时加上时间戳,避免覆盖
    [ -e "$target" ] && target="${ARCH_DIR}/${base}.$(date +%Y%m%d_%H%M%S).gz"

    if [ "$DRY_RUN" -eq 1 ]; then
        log INFO "[dry-run] 将归档 $file -> $target 并清空源文件"
        continue
    fi

    # 先压缩到归档目录(cat + gzip 而非 mv,避免影响正在写入的进程)
    if gzip -c "$file" > "$target"; then
        # 校验归档完整性
        if gzip -t "$target" 2>/dev/null; then
            # 用重定向清空而不是 rm,保证已打开该文件的进程 fd 依然有效
            : > "$file"
            log INFO "已归档并清空: $base -> $(basename "$target") ($(du -h "$target" | cut -f1))"
            ((archived++)) || true
        else
            log ERROR "归档校验失败,保留源文件: $base"
            rm -f -- "$target"
        fi
    else
        log ERROR "压缩失败: $base"
        rm -f -- "$target"
    fi
done < <(find "$LOG_DIR" -maxdepth 1 -type f -name "$PATTERN" -print0)

log INFO "本次归档 $archived 个文件"

# ---------- 清理旧归档 ----------
deleted=0
while IFS= read -r -d '' old; do
    if [ "$DRY_RUN" -eq 1 ]; then
        log INFO "[dry-run] 将删除过期归档: $(basename "$old")"
    else
        rm -f -- "$old" && { log INFO "已删除过期归档: $(basename "$old")"; ((deleted++)) || true; }
    fi
done < <(find "$ARCH_DIR" -maxdepth 1 -type f -name '*.gz' -mtime +"$KEEP_DAYS" -print0)

log INFO "本次清理 $deleted 个过期归档"

# ---------- 汇总 ----------
log INFO "归档目录当前占用: $(du -sh "$ARCH_DIR" | cut -f1),共 $(find "$ARCH_DIR" -type f -name '*.gz' | wc -l) 个归档"
log INFO "===== 归档任务结束 ====="

# 归档后通知服务重新打开日志文件(若使用 mv 方式归档则必须做)
# CentOS 7: systemctl reload nginx
# CentOS 6: service nginx reload   或  kill -USR1 $(cat /var/run/nginx.pid)
exit 0

用法示例:

# 先预演,确认动作无误
./log_archive.sh -d /var/log/nginx -k 30 -n

# 实际执行
./log_archive.sh -d /var/log/nginx -a /data/logarch -k 30 -p '*.log'

# 只清理不归档:直接用 find
find /data/logarch -name '*.gz' -mtime +30 -delete
find /var/log -name '*.log.*' -mtime +7 -exec rm -f {} \;

# 定时任务:每天凌晨 1 点归档
# 0 1 * * * /opt/ops/bin/log_archive.sh -d /var/log/nginx -k 30 >> /var/log/cron_arch.log 2>&1

生产环境更推荐用系统自带的 logrotate(配置在 /etc/logrotate.d/)做日志轮转,本脚本适用于 logrotate 无法覆盖的自定义归档需求。

18.5 案例:MySQL 定时备份并保留最近 7 份

需求说明:每天全量备份指定数据库,压缩存储,校验备份完整性,只保留最近 7 份(按份数而非天数),备份失败发送告警邮件,密码不出现在命令行中。

#!/bin/bash
# ============================================================================
# 脚本名称: mysql_backup.sh
# 功能描述: MySQL 全量备份 + 压缩 + 完整性校验 + 只保留最近 N 份 + 失败告警
# 适用系统: CentOS 6 / CentOS 7
# 依赖:     mysqldump gzip mail(可选)
# 用法:     ./mysql_backup.sh [-d 数据库名|all] [-o 备份目录] [-k 保留份数]
# 退出码:   0 成功 1 参数错误 2 依赖缺失 3 连接失败 4 备份/校验失败
# 前置:     在 /root/.my.cnf 中配置好账号密码并 chmod 600,避免密码出现在 ps 中
#           [client]
#           user=root
#           password=YourPassword
# ============================================================================
set -uo pipefail

readonly MY_CNF="/root/.my.cnf"
readonly MAIL_TO="ops@example.com"
readonly LOG="/var/log/mysql_backup.log"
DB_NAME="all"
BACKUP_DIR="/data/backup/mysql"
KEEP=7

log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" | tee -a "$LOG"; }

alert() {
    log ERROR "$*"
    if command -v mail >/dev/null 2>&1; then
        echo "$(hostname) MySQL 备份失败: $*" | mail -s "[告警] MySQL 备份失败 $(hostname)" "$MAIL_TO"
    fi
}

while getopts ":d:o:k:h" opt; do
    case "$opt" in
        d) DB_NAME="$OPTARG" ;;
        o) BACKUP_DIR="$OPTARG" ;;
        k) KEEP="$OPTARG" ;;
        h) echo "用法: $0 [-d 数据库名|all] [-o 备份目录] [-k 保留份数]"; exit 0 ;;
        *) echo "参数错误" >&2; exit 1 ;;
    esac
done

# ---------- 依赖与前置检查 ----------
command -v mysqldump >/dev/null 2>&1 || { alert "缺少 mysqldump 命令"; exit 2; }
[ -f "$MY_CNF" ] || { alert "缺少认证文件 $MY_CNF"; exit 2; }
[ "$(stat -c %a "$MY_CNF")" = "600" ] || log WARN "$MY_CNF 权限不是 600,存在泄露风险"

mkdir -p "$BACKUP_DIR" || { alert "无法创建备份目录 $BACKUP_DIR"; exit 2; }

# 连接测试
if ! mysql --defaults-extra-file="$MY_CNF" -e "SELECT 1" &>/dev/null; then
    alert "数据库连接失败,请检查 $MY_CNF"
    exit 3
fi

# ---------- 组装备份文件名与 mysqldump 参数 ----------
ts=$(date +%Y%m%d_%H%M%S)
if [ "$DB_NAME" = "all" ]; then
    dump_args="--all-databases"
    outfile="${BACKUP_DIR}/all_${ts}.sql.gz"
    prefix="all_"
else
    dump_args="--databases $DB_NAME"
    outfile="${BACKUP_DIR}/${DB_NAME}_${ts}.sql.gz"
    prefix="${DB_NAME}_"
fi

log INFO "===== 开始备份 $DB_NAME -> $outfile ====="
start=$(date +%s)

# ---------- 执行备份 ----------
# --single-transaction : InnoDB 一致性快照,不锁表
# --routines --triggers --events : 一并备份存储过程、触发器、事件
# --master-data=2      : 记录 binlog 位置(做主从/时间点恢复时需要)
# 必须开 pipefail,否则 mysqldump 失败会被 gzip 的成功掩盖
set -o pipefail
if mysqldump --defaults-extra-file="$MY_CNF" \
        --single-transaction --quick --routines --triggers --events \
        --default-character-set=utf8mb4 \
        $dump_args 2>>"$LOG" | gzip -9 > "$outfile"; then
    log INFO "mysqldump 执行成功"
else
    alert "mysqldump 执行失败"
    rm -f -- "$outfile"
    exit 4
fi

# ---------- 完整性校验 ----------
if ! gzip -t "$outfile" 2>/dev/null; then
    alert "备份文件 gzip 校验失败: $outfile"
    rm -f -- "$outfile"
    exit 4
fi

# 检查文件大小是否过小(可能是空备份)
size=$(stat -c %s "$outfile")
if [ "$size" -lt 1024 ]; then
    alert "备份文件过小(${size} 字节),疑似失败: $outfile"
    exit 4
fi

# 检查备份内容是否包含结束标记
if ! zcat "$outfile" | tail -5 | grep -q "Dump completed"; then
    alert "备份文件缺少 'Dump completed' 结束标记,可能被截断"
    exit 4
fi

# 生成校验和,便于异地传输后核对
sha256sum "$outfile" > "${outfile}.sha256"

cost=$(( $(date +%s) - start ))
log INFO "备份完成: $(basename "$outfile") 大小 $(du -h "$outfile" | cut -f1) 耗时 ${cost}s"

# ---------- 只保留最近 N 份(按份数) ----------
# 按修改时间倒序排列,跳过前 KEEP 个,其余删除
total=$(find "$BACKUP_DIR" -maxdepth 1 -name "${prefix}*.sql.gz" | wc -l)
if [ "$total" -gt "$KEEP" ]; then
    log INFO "当前有 $total 份备份,超出保留数 $KEEP,开始清理"
    ls -1t "${BACKUP_DIR}/${prefix}"*.sql.gz | tail -n +$((KEEP + 1)) | while read -r old; do
        rm -f -- "$old" "${old}.sha256"
        log INFO "已删除旧备份: $(basename "$old")"
    done
else
    log INFO "当前有 $total 份备份,未超出保留数 $KEEP"
fi

log INFO "备份目录占用: $(du -sh "$BACKUP_DIR" | cut -f1)"
log INFO "===== 备份任务结束 ====="
exit 0

用法示例与恢复:

# 准备认证文件
cat > /root/.my.cnf <<'EOF'
[client]
user=root
password=YourPassword
EOF
chmod 600 /root/.my.cnf

./mysql_backup.sh -d appdb -o /data/backup/mysql -k 7    # 备份单库,保留 7 份
./mysql_backup.sh -d all -k 7                            # 备份全部数据库

# 定时任务:每天凌晨 2:30 备份
# 30 2 * * * /opt/ops/bin/mysql_backup.sh -d all -k 7 >/dev/null 2>&1

# 恢复
zcat /data/backup/mysql/appdb_20240517_023000.sql.gz | mysql --defaults-extra-file=/root/.my.cnf
# 只恢复单个库到新库名
zcat backup.sql.gz | mysql --defaults-extra-file=/root/.my.cnf newdb

# 校验异地传输后的完整性
sha256sum -c /data/backup/mysql/appdb_20240517_023000.sql.gz.sha256

18.6 案例:服务存活监控、自动重启与告警

需求说明:定期检查关键服务的进程与端口是否正常,异常时自动尝试重启(最多 N 次),重启成功/失败都发送告警;同一服务在冷却期内不重复告警,避免告警风暴;兼容 CentOS 6 与 7 两套服务管理体系。

#!/bin/bash
# ============================================================================
# 脚本名称: service_monitor.sh
# 功能描述: 服务存活监控 -> 自动重启 -> 邮件/webhook 告警,带告警抑制
# 适用系统: CentOS 6 (service/chkconfig) / CentOS 7 (systemctl),自动识别
# 用法:     ./service_monitor.sh                       # 使用内置服务清单
#           ./service_monitor.sh -s nginx:80 -s mysqld:3306
# ============================================================================
set -uo pipefail

readonly MAX_RETRY=3               # 最多重启尝试次数
readonly ALERT_COOLDOWN=1800       # 告警冷却时间(秒),30 分钟内同一服务只告警一次
readonly STATE_DIR="/var/tmp/svcmon"
readonly LOG="/var/log/service_monitor.log"
readonly MAIL_TO="ops@example.com"
# 服务清单:格式 服务名:监听端口(端口为 0 表示不检查端口)
declare -a SERVICES=("sshd:22" "crond:0" "nginx:80" "mysqld:3306")

mkdir -p "$STATE_DIR"

log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" | tee -a "$LOG"; }

# ---------- 识别 init 体系 ----------
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
    readonly INIT_SYS="systemd"       # CentOS 7 及以上
else
    readonly INIT_SYS="sysv"          # CentOS 6
fi

# ---------- 统一封装服务操作,屏蔽 CentOS 6/7 差异 ----------
svc_is_active() {
    local svc="$1"
    if [ "$INIT_SYS" = "systemd" ]; then
        systemctl is-active --quiet "$svc"          # CentOS 7
    else
        service "$svc" status &>/dev/null           # CentOS 6
    fi
}

svc_start() {
    local svc="$1"
    if [ "$INIT_SYS" = "systemd" ]; then
        systemctl start "$svc" &>>"$LOG"            # CentOS 7
    else
        service "$svc" start &>>"$LOG"              # CentOS 6
    fi
}

svc_restart() {
    local svc="$1"
    if [ "$INIT_SYS" = "systemd" ]; then
        systemctl restart "$svc" &>>"$LOG"          # CentOS 7
    else
        service "$svc" restart &>>"$LOG"            # CentOS 6
    fi
}

svc_is_enabled() {
    local svc="$1"
    if [ "$INIT_SYS" = "systemd" ]; then
        systemctl is-enabled --quiet "$svc" 2>/dev/null                 # CentOS 7
    else
        chkconfig --list "$svc" 2>/dev/null | grep -q '3:on'            # CentOS 6
    fi
}

# ---------- 端口检查 ----------
port_is_listening() {
    local port="$1"
    [ "$port" = "0" ] && return 0
    if command -v ss >/dev/null 2>&1; then
        ss -lntu | awk '{print $5}' | grep -qE "[:.]${port}\$"
    else
        netstat -lntu 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${port}\$"
    fi
}

# ---------- 进程检查(有些服务没有 init 脚本,只能查进程) ----------
proc_is_running() {
    pgrep -x "$1" >/dev/null 2>&1
}

# ---------- 告警(带冷却抑制) ----------
send_alert() {
    local svc="$1" level="$2" msg="$3"
    local stamp_file="${STATE_DIR}/${svc}.alert"
    local now last

    now=$(date +%s)
    last=$(cat "$stamp_file" 2>/dev/null || echo 0)

    if [ $((now - last)) -lt "$ALERT_COOLDOWN" ]; then
        log INFO "$svc 处于告警冷却期(剩余 $((ALERT_COOLDOWN - now + last))s),本次不重复发送"
        return 0
    fi
    echo "$now" > "$stamp_file"

    local subject="[$level] $(hostname) 服务异常: $svc"
    local body
    body=$(cat <<EOF
主机:     $(hostname) ($(ip route get 1 2>/dev/null | awk '{print $NF; exit}'))
时间:     $(date '+%F %T')
服务:     $svc
级别:     $level
详情:     $msg
系统:     $(cat /etc/redhat-release 2>/dev/null) / init=$INIT_SYS
负载:     $(cat /proc/loadavg)
内存:     $(free -m | awk '/Mem/{printf "%s/%s MB", $3, $2}')
磁盘:     $(df -hP / | awk 'NR==2{print $5" used"}')
EOF
)
    # 邮件告警
    if command -v mail >/dev/null 2>&1; then
        echo "$body" | mail -s "$subject" "$MAIL_TO"
    fi

    # webhook 告警(钉钉/企业微信/飞书,按需替换 URL)
    # curl -s -X POST -H 'Content-Type: application/json' \
    #     -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"$subject\n$msg\"}}" \
    #     "$WEBHOOK_URL" >/dev/null

    log WARN "已发送告警: $subject"
}

# ---------- 单个服务的检查与自愈 ----------
check_and_heal() {
    local entry="$1"
    local svc="${entry%%:*}"          # 冒号前是服务名
    local port="${entry##*:}"         # 冒号后是端口
    local i

    # 服务未安装则跳过,不算异常
    if [ "$INIT_SYS" = "systemd" ]; then
        systemctl list-unit-files 2>/dev/null | grep -q "^${svc}\.service" \
            || { log INFO "$svc 未安装,跳过"; return 0; }
    else
        [ -x "/etc/init.d/$svc" ] || { log INFO "$svc 未安装,跳过"; return 0; }
    fi

    # 检查开机自启(不影响存活判断,只提醒)
    svc_is_enabled "$svc" || log WARN "$svc 未设置开机自启"

    # 双重判断:服务状态 + 端口监听
    if svc_is_active "$svc" && port_is_listening "$port"; then
        log INFO "$svc 正常(端口 ${port})"
        rm -f -- "${STATE_DIR}/${svc}.alert"        # 恢复后清除冷却标记
        return 0
    fi

    log ERROR "$svc 异常:状态=$(svc_is_active "$svc" && echo active || echo inactive) 端口${port}=$(port_is_listening "$port" && echo listening || echo down)"

    # 自动重启,最多 MAX_RETRY 次
    for ((i = 1; i <= MAX_RETRY; i++)); do
        log WARN "$svc 第 $i/$MAX_RETRY 次尝试重启"
        svc_restart "$svc"
        sleep 5                                     # 给服务留出启动时间

        if svc_is_active "$svc" && port_is_listening "$port"; then
            log INFO "$svc 第 $i 次重启成功"
            send_alert "$svc" "RECOVERED" "服务曾异常,已在第 $i 次重启后自动恢复"
            return 0
        fi
    done

    # 重启全部失败,收集现场信息后告警
    local detail
    detail="重启 $MAX_RETRY 次均失败。最近日志:
$(if [ "$INIT_SYS" = "systemd" ]; then
      journalctl -u "$svc" -n 20 --no-pager 2>/dev/null
  else
      tail -20 "/var/log/messages" 2>/dev/null | grep -i "$svc"
  fi)"
    send_alert "$svc" "CRITICAL" "$detail"
    return 1
}

# ---------- 主流程 ----------
main() {
    # 支持用 -s 覆盖服务清单
    local -a custom=()
    while getopts ":s:h" opt; do
        case "$opt" in
            s) custom+=("$OPTARG") ;;
            h) echo "用法: $0 [-s 服务名:端口]..."; exit 0 ;;
            *) echo "参数错误" >&2; exit 1 ;;
        esac
    done
    [ "${#custom[@]}" -gt 0 ] && SERVICES=("${custom[@]}")

    log INFO "===== 服务监控开始(init=$INIT_SYS,共 ${#SERVICES[@]} 项)====="
    local fail=0 entry
    for entry in "${SERVICES[@]}"; do
        check_and_heal "$entry" || ((fail++)) || true
    done
    log INFO "===== 服务监控结束,异常未恢复 $fail 项 ====="
    [ "$fail" -eq 0 ]
}

main "$@"

用法示例:

chmod +x service_monitor.sh
./service_monitor.sh                                  # 检查内置清单
./service_monitor.sh -s nginx:80 -s mysqld:3306       # 只检查指定服务

# 定时任务:每 2 分钟检查一次(用 flock 防止上一轮未结束就重复启动)
# */2 * * * * /usr/bin/flock -n /var/run/svcmon.lock /opt/ops/bin/service_monitor.sh >/dev/null 2>&1

CentOS 7 的 systemd 本身支持自动重启,生产环境更推荐直接配置 unit: [Service] 段中加 Restart=always、RestartSec=5、StartLimitBurst=5,然后 systemctl daemon-reload。 本脚本适用于 CentOS 6,或需要「端口级别健康检查 + 自定义告警」的场景。

18.7 案例:批量 SSH 到多台主机执行命令

需求说明:从主机列表文件读取主机,批量执行同一条命令或分发脚本,支持并发、超时控制、结果分类汇总(成功/失败),失败主机单独列出便于重试。

#!/bin/bash
# ============================================================================
# 脚本名称: batch_ssh.sh
# 功能描述: 批量 SSH 执行命令 / 分发文件,支持并发与结果汇总
# 适用系统: CentOS 6 / CentOS 7
# 前置条件: 已配置 SSH 免密(ssh-keygen + ssh-copy-id),否则需安装 sshpass
# 用法:     ./batch_ssh.sh -f hosts.txt -c "uptime" [-p 10] [-t 15] [-u root]
#           ./batch_ssh.sh -f hosts.txt -S local.sh          # 分发并执行本地脚本
#           ./batch_ssh.sh -f hosts.txt -P /etc/hosts:/tmp/  # 分发文件
# ============================================================================
set -uo pipefail

HOST_FILE=""
CMD=""
SCRIPT=""
PUSH=""
PARALLEL=10
TIMEOUT=15
SSH_USER="root"
SSH_PORT=22
readonly RESULT_DIR="/tmp/batch_ssh_$(date +%Y%m%d_%H%M%S)"
readonly SSH_OPTS="-o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=ERROR"

usage() {
    cat >&2 <<EOF
用法: $(basename "$0") -f 主机列表 (-c 命令 | -S 本地脚本 | -P 本地文件:远程目录) [选项]
  -f FILE   主机列表文件,每行一个主机名或 IP(# 开头为注释)
  -c CMD    要在远程执行的命令
  -S FILE   把本地脚本上传到远程 /tmp 并执行,执行后删除
  -P SPEC   分发文件,格式 本地路径:远程目录
  -u USER   SSH 用户,默认 root
  -p N      并发数,默认 10
  -t N      单主机命令超时秒数,默认 15
  -o PORT   SSH 端口,默认 22
EOF
    exit 1
}

while getopts ":f:c:S:P:u:p:t:o:h" opt; do
    case "$opt" in
        f) HOST_FILE="$OPTARG" ;;
        c) CMD="$OPTARG" ;;
        S) SCRIPT="$OPTARG" ;;
        P) PUSH="$OPTARG" ;;
        u) SSH_USER="$OPTARG" ;;
        p) PARALLEL="$OPTARG" ;;
        t) TIMEOUT="$OPTARG" ;;
        o) SSH_PORT="$OPTARG" ;;
        h) usage ;;
        *) usage ;;
    esac
done

[ -n "$HOST_FILE" ] && [ -f "$HOST_FILE" ] || { echo "主机列表文件无效" >&2; usage; }
[ -n "$CMD$SCRIPT$PUSH" ] || { echo "必须指定 -c、-S 或 -P 之一" >&2; usage; }
[ -n "$SCRIPT" ] && [ ! -f "$SCRIPT" ] && { echo "脚本不存在: $SCRIPT" >&2; exit 1; }

mkdir -p "$RESULT_DIR"/{ok,fail}

# ---------- 读取主机列表到数组(过滤注释与空行) ----------
declare -a HOSTS=()
while read -r h; do
    [ -z "$h" ] && continue
    case "$h" in \#*) continue ;; esac
    HOSTS+=("$h")
done < "$HOST_FILE"

echo "共 ${#HOSTS[@]} 台主机,并发 $PARALLEL,超时 ${TIMEOUT}s,结果目录 $RESULT_DIR"

# ---------- 单主机处理函数 ----------
do_host() {
    local host="$1"
    local out="${RESULT_DIR}/${host}.out"
    local rc=0

    {
        if [ -n "$PUSH" ]; then
            # 分发文件:本地路径:远程目录
            local src="${PUSH%%:*}" dst="${PUSH##*:}"
            scp $SSH_OPTS -P "$SSH_PORT" -r "$src" "${SSH_USER}@${host}:${dst}" 2>&1
            rc=$?
        elif [ -n "$SCRIPT" ]; then
            # 上传脚本 -> 执行 -> 删除
            local remote="/tmp/.bssh_$(basename "$SCRIPT").$$"
            scp $SSH_OPTS -P "$SSH_PORT" "$SCRIPT" "${SSH_USER}@${host}:${remote}" 2>&1 \
                && ssh $SSH_OPTS -p "$SSH_PORT" "${SSH_USER}@${host}" \
                       "chmod +x '$remote'; '$remote'; rc=\$?; rm -f '$remote'; exit \$rc" 2>&1
            rc=$?
        else
            # 执行命令,用 timeout 防止远程命令卡死(CentOS 6/7 都自带 coreutils timeout)
            timeout "$TIMEOUT" ssh $SSH_OPTS -p "$SSH_PORT" "${SSH_USER}@${host}" "$CMD" 2>&1
            rc=$?
        fi
    } > "$out" 2>&1

    if [ "$rc" -eq 0 ]; then
        mv "$out" "${RESULT_DIR}/ok/${host}.out"
        echo "[  OK  ] $host"
    else
        echo "退出码: $rc" >> "$out"
        [ "$rc" -eq 124 ] && echo "原因: 命令执行超时(${TIMEOUT}s)" >> "$out"
        [ "$rc" -eq 255 ] && echo "原因: SSH 连接失败" >> "$out"
        mv "$out" "${RESULT_DIR}/fail/${host}.out"
        echo "[ FAIL ] $host (rc=$rc)"
    fi
    return "$rc"
}

# ---------- 并发执行 ----------
for host in "${HOSTS[@]}"; do
    # 控制并发数
    while [ "$(jobs -rp | wc -l)" -ge "$PARALLEL" ]; do
        sleep 0.2
    done
    do_host "$host" &
done

wait        # 等待全部完成

# ---------- 结果汇总 ----------
ok_cnt=$(find "${RESULT_DIR}/ok" -type f | wc -l)
fail_cnt=$(find "${RESULT_DIR}/fail" -type f | wc -l)

echo ""
echo "=========== 执行汇总 ==========="
echo "成功: $ok_cnt    失败: $fail_cnt    总计: ${#HOSTS[@]}"

if [ "$fail_cnt" -gt 0 ]; then
    echo ""
    echo "--- 失败主机列表(可保存为新列表重试)---"
    find "${RESULT_DIR}/fail" -type f -name '*.out' -exec basename {} .out \; | tee "${RESULT_DIR}/failed_hosts.txt"
    echo ""
    echo "--- 失败详情 ---"
    for f in "${RESULT_DIR}/fail"/*.out; do
        [ -e "$f" ] || continue
        echo "### $(basename "$f" .out)"
        sed 's/^/    /' "$f"
    done
fi

echo ""
echo "--- 成功输出 ---"
for f in "${RESULT_DIR}/ok"/*.out; do
    [ -e "$f" ] || continue
    echo "### $(basename "$f" .out)"
    sed 's/^/    /' "$f"
done

echo ""
echo "完整结果保存在: $RESULT_DIR"
[ "$fail_cnt" -eq 0 ]

用法示例:

# 准备主机列表
cat > hosts.txt <<'EOF'
# web 集群
192.168.1.11
192.168.1.12
web03.example.com
EOF

# 配置免密(一次性)
ssh-keygen -t rsa -b 2048 -N '' -f ~/.ssh/id_rsa
while read -r h; do
    case "$h" in ''|\#*) continue ;; esac
    ssh-copy-id -o StrictHostKeyChecking=no "root@$h"
done < hosts.txt

./batch_ssh.sh -f hosts.txt -c "uptime; df -h /"          # 批量执行命令
./batch_ssh.sh -f hosts.txt -c "yum -y update" -t 600     # 长任务加大超时
./batch_ssh.sh -f hosts.txt -S ./sys_check.sh             # 分发并执行巡检脚本
./batch_ssh.sh -f hosts.txt -P /etc/hosts:/etc/           # 分发配置文件
./batch_ssh.sh -f /tmp/batch_ssh_*/failed_hosts.txt -c "uptime"   # 只重试失败主机

# 没有免密时用 sshpass(不推荐,密码会进 history)
# sshpass -p 'password' ssh root@host 'uptime'

生产环境批量运维推荐使用 Ansible / SaltStack 等成熟工具,本脚本适用于无法安装第三方工具的临时场景,或作为理解并发控制与 SSH 批处理原理的练习。

18.8 案例:磁盘使用率超阈值告警

需求说明:检查所有本地分区的空间与 inode 使用率,超过阈值时告警并自动定位占用最大的目录与文件;支持为不同挂载点设置不同阈值;带告警抑制避免重复轰炸。

#!/bin/bash
# ============================================================================
# 脚本名称: disk_alert.sh
# 功能描述: 磁盘空间/inode 使用率超阈值告警 + 自动定位大文件大目录
# 适用系统: CentOS 6 / CentOS 7
# 用法:     ./disk_alert.sh [-w 警告阈值] [-c 严重阈值] [-m 邮箱]
# 退出码:   0 全部正常  1 有 WARNING  2 有 CRITICAL
# ============================================================================
set -uo pipefail

WARN_PCT=80                    # 警告阈值
CRIT_PCT=90                    # 严重阈值
MAIL_TO="ops@example.com"
readonly STATE_DIR="/var/tmp/diskalert"
readonly COOLDOWN=3600         # 同一挂载点 1 小时内只告警一次
readonly LOG="/var/log/disk_alert.log"

# 特定挂载点的自定义阈值(关联数组,bash 4+)
declare -A CUSTOM_WARN=(
    ["/boot"]=90               # /boot 本来就小,放宽到 90
    ["/data"]=85
    ["/var/lib/mysql"]=75      # 数据库分区收紧到 75
)

mkdir -p "$STATE_DIR"
log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" | tee -a "$LOG"; }

while getopts ":w:c:m:h" opt; do
    case "$opt" in
        w) WARN_PCT="$OPTARG" ;;
        c) CRIT_PCT="$OPTARG" ;;
        m) MAIL_TO="$OPTARG" ;;
        h) echo "用法: $0 [-w 警告阈值] [-c 严重阈值] [-m 邮箱]"; exit 0 ;;
        *) echo "参数错误" >&2; exit 1 ;;
    esac
done

# ---------- 告警函数(带冷却) ----------
notify() {
    local mount="$1" level="$2" body="$3"
    local key="${mount//\//_}"                  # 挂载点里的 / 换成 _ 作为文件名
    local stamp="${STATE_DIR}/${key}.${level}"
    local now last
    now=$(date +%s)
    last=$(cat "$stamp" 2>/dev/null || echo 0)

    if [ $((now - last)) -lt "$COOLDOWN" ]; then
        log INFO "$mount 的 $level 告警处于冷却期,跳过发送"
        return 0
    fi
    echo "$now" > "$stamp"

    log WARN "发送 $level 告警: $mount"
    if command -v mail >/dev/null 2>&1; then
        echo "$body" | mail -s "[$level] $(hostname) 磁盘告警 $mount" "$MAIL_TO"
    fi
}

# ---------- 定位空间占用 ----------
diagnose() {
    local mount="$1"
    echo "--- $mount 下占用最大的 10 个目录 ---"
    # -x 不跨文件系统,避免统计到其他挂载点
    du -xh --max-depth=2 "$mount" 2>/dev/null | sort -rh | head -10

    echo "--- $mount 下最大的 10 个文件 ---"
    find "$mount" -xdev -type f -size +50M 2>/dev/null -printf '%s\t%p\n' \
        | sort -rn | head -10 | awk '{printf "%.1f MB\t%s\n", $1/1048576, $2}'

    echo "--- 已删除但仍被进程占用的文件(空间不释放的常见原因) ---"
    lsof -nP 2>/dev/null | awk '/deleted/ {printf "%s %s %.1f MB %s\n", $1, $2, $7/1048576, $9}' | head -10

    echo "--- 7 天内新增的大文件 ---"
    find "$mount" -xdev -type f -size +100M -mtime -7 2>/dev/null -exec ls -lh {} \; \
        | awk '{print $5, $6, $7, $9}' | head -10
}

# ---------- 主检查 ----------
exit_code=0

log INFO "===== 磁盘检查开始(warn=${WARN_PCT}% crit=${CRIT_PCT}%)====="

# 空间使用率检查
while read -r fs size used avail pct mount; do
    pct=${pct%\%}
    # 取该挂载点的自定义阈值,没有则用全局默认
    local_warn="${CUSTOM_WARN[$mount]:-$WARN_PCT}"

    printf '  %-28s %5s used %5s avail %3s%%  (阈值 %s%%)\n' \
        "$mount" "$used" "$avail" "$pct" "$local_warn"

    if [ "$pct" -ge "$CRIT_PCT" ]; then
        body=$(cat <<EOF
主机:   $(hostname)
时间:   $(date '+%F %T')
挂载点: $mount ($fs)
容量:   总 $size / 已用 $used / 可用 $avail
使用率: ${pct}%  已超过严重阈值 ${CRIT_PCT}%

$(diagnose "$mount")
EOF
)
        notify "$mount" "CRITICAL" "$body"
        log ERROR "$mount 使用率 ${pct}% 达到严重级别"
        exit_code=2
    elif [ "$pct" -ge "$local_warn" ]; then
        body=$(cat <<EOF
主机:   $(hostname)
挂载点: $mount
使用率: ${pct}%  已超过警告阈值 ${local_warn}%
容量:   总 $size / 已用 $used / 可用 $avail

$(diagnose "$mount")
EOF
)
        notify "$mount" "WARNING" "$body"
        log WARN "$mount 使用率 ${pct}% 达到警告级别"
        [ "$exit_code" -eq 0 ] && exit_code=1
    else
        # 恢复正常后清除冷却标记,下次异常能立即告警
        key="${mount//\//_}"
        rm -f -- "${STATE_DIR}/${key}".{WARNING,CRITICAL}
    fi
done < <(df -hPT 2>/dev/null | awk 'NR>1 && $2 !~ /tmpfs|devtmpfs|iso9660|squashfs/ && $1 ~ /^\/dev/ {print $1,$3,$4,$5,$6,$7}')

# inode 使用率检查(空间还有但 inode 耗尽会导致"磁盘已满"的假象)
echo "  --- inode 检查 ---"
while read -r fs inodes iused ifree pct mount; do
    pct=${pct%\%}
    [ "$pct" = "-" ] && continue
    if [ "$pct" -ge "$WARN_PCT" ]; then
        log WARN "$mount inode 使用率 ${pct}%(共 $inodes,已用 $iused)"
        body="主机 $(hostname) 的 $mount inode 使用率 ${pct}%,可能无法创建新文件。
小文件最多的目录:
$(find "$mount" -xdev -type d 2>/dev/null -exec sh -c 'echo "$(ls -A "$1" 2>/dev/null | wc -l) $1"' _ {} \; | sort -rn | head -10)"
        notify "${mount}_inode" "WARNING" "$body"
        [ "$exit_code" -eq 0 ] && exit_code=1
    fi
done < <(df -iP 2>/dev/null | awk 'NR>1 && $1 ~ /^\/dev/')

log INFO "===== 磁盘检查结束,退出码 $exit_code ====="
exit "$exit_code"

用法示例:

./disk_alert.sh                          # 用默认阈值 80/90
./disk_alert.sh -w 70 -c 85 -m me@x.com  # 自定义阈值和收件人

# 定时任务:每 10 分钟检查一次
# */10 * * * * /opt/ops/bin/disk_alert.sh >/dev/null 2>&1

# 一行版极简检查(讲义思路,适合临时排查)
df -hP | awk 'NR>1 && $5+0 > 80 {print $6" 使用率 "$5}'
df -hP | awk -v w=80 'NR>1 {gsub(/%/,"",$5); if ($5+0 >= w) print $6, $5"%"}'

18.9 案例:文件同步与校验

需求说明:把本地目录同步到远端(或备份盘),同步后逐文件比对 SHA256 校验和,确认无损;支持排除规则、限速、断点续传、dry-run 与同步报告。

#!/bin/bash
# ============================================================================
# 脚本名称: sync_verify.sh
# 功能描述: rsync 目录同步 + SHA256 逐文件校验 + 差异报告
# 适用系统: CentOS 6 / CentOS 7
# 依赖:     rsync sha256sum ssh
# 用法:     ./sync_verify.sh -s 源目录 -d 目标 [-e 排除模式] [-b 限速KB/s] [-n]
# 示例:     本地到本地: ./sync_verify.sh -s /data/app -d /backup/app
#           本地到远端: ./sync_verify.sh -s /data/app -d root@192.168.1.20:/backup/app
# 退出码:   0 同步且校验通过 1 参数错误 2 同步失败 3 校验不一致
# ============================================================================
set -uo pipefail

SRC=""
DST=""
BWLIMIT=0
DRY_RUN=0
declare -a EXCLUDES=()
readonly LOG="/var/log/sync_verify.log"
readonly WORK=$(mktemp -d /tmp/syncverify.XXXXXX)

# 无论如何都清理临时目录
cleanup() { local rc=$?; rm -rf -- "$WORK"; exit "$rc"; }
trap cleanup EXIT INT TERM

log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" | tee -a "$LOG"; }

usage() {
    cat >&2 <<EOF
用法: $(basename "$0") -s 源目录 -d 目标 [选项]
  -s DIR   源目录(必填,末尾不要加 /)
  -d DEST  目标,本地路径或 user@host:/path
  -e GLOB  排除模式,可重复指定,如 -e '*.tmp' -e 'cache/'
  -b KBPS  限速(KB/s),0 表示不限速
  -n       dry-run,只显示将要同步的内容
EOF
    exit 1
}

while getopts ":s:d:e:b:nh" opt; do
    case "$opt" in
        s) SRC="${OPTARG%/}" ;;                  # 去掉末尾斜杠,统一行为
        d) DST="${OPTARG%/}" ;;
        e) EXCLUDES+=("$OPTARG") ;;
        b) BWLIMIT="$OPTARG" ;;
        n) DRY_RUN=1 ;;
        h) usage ;;
        *) usage ;;
    esac
done

[ -n "$SRC" ] && [ -n "$DST" ] || usage
[ -d "$SRC" ] || { log ERROR "源目录不存在: $SRC"; exit 1; }
command -v rsync >/dev/null 2>&1 || { log ERROR "缺少 rsync 命令"; exit 1; }

# 判断目标是远端还是本地
if [[ "$DST" == *:* ]]; then
    IS_REMOTE=1
    REMOTE_HOST="${DST%%:*}"
    REMOTE_PATH="${DST#*:}"
else
    IS_REMOTE=0
fi

# ---------- 组装 rsync 参数 ----------
# -a 归档模式(保留权限/属主/时间/软链接)  -H 保留硬链接  -A ACL  -X 扩展属性
# --partial 断点续传   --delete 删除目标端多余文件   --stats 输出统计
declare -a RSYNC_OPTS=(-aHAX --partial --delete --stats --human-readable)
[ "$DRY_RUN" -eq 1 ] && RSYNC_OPTS+=(--dry-run --itemize-changes)
[ "$BWLIMIT" -gt 0 ] && RSYNC_OPTS+=("--bwlimit=$BWLIMIT")
[ "$IS_REMOTE" -eq 1 ] && RSYNC_OPTS+=(-e "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10")

for ex in "${EXCLUDES[@]}"; do
    RSYNC_OPTS+=("--exclude=$ex")
done

log INFO "===== 开始同步 $SRC/ -> $DST/ (dry_run=$DRY_RUN 限速=${BWLIMIT}KB/s)====="

# ---------- 执行同步 ----------
# 源路径末尾加 / 表示同步目录内容而非目录本身
if rsync "${RSYNC_OPTS[@]}" "$SRC/" "$DST/" 2>&1 | tee -a "$LOG"; then
    log INFO "rsync 同步完成"
else
    log ERROR "rsync 同步失败(退出码 ${PIPESTATUS[0]})"
    exit 2
fi

if [ "$DRY_RUN" -eq 1 ]; then
    log INFO "dry-run 模式,跳过校验"
    exit 0
fi

# ---------- 生成源端校验和 ----------
log INFO "生成源端 SHA256 校验和..."
( cd "$SRC" && find . -type f -print0 | xargs -0 sha256sum 2>/dev/null ) \
    | sort -k2 > "${WORK}/src.sha256"
src_cnt=$(wc -l < "${WORK}/src.sha256")
log INFO "源端文件数: $src_cnt"

# ---------- 生成目标端校验和 ----------
log INFO "生成目标端 SHA256 校验和..."
if [ "$IS_REMOTE" -eq 1 ]; then
    ssh -o StrictHostKeyChecking=no "$REMOTE_HOST" \
        "cd '$REMOTE_PATH' && find . -type f -print0 | xargs -0 sha256sum 2>/dev/null" \
        | sort -k2 > "${WORK}/dst.sha256"
else
    ( cd "$DST" && find . -type f -print0 | xargs -0 sha256sum 2>/dev/null ) \
        | sort -k2 > "${WORK}/dst.sha256"
fi
dst_cnt=$(wc -l < "${WORK}/dst.sha256")
log INFO "目标端文件数: $dst_cnt"

# ---------- 比对(用进程替换做 diff,无需额外临时文件) ----------
log INFO "开始比对校验和..."
if diff -q "${WORK}/src.sha256" "${WORK}/dst.sha256" >/dev/null; then
    log INFO "校验通过:$src_cnt 个文件的 SHA256 完全一致"
    echo ""
    echo "=========== 同步报告 ==========="
    echo "源:       $SRC"
    echo "目标:     $DST"
    echo "文件数:   $src_cnt"
    echo "总大小:   $(du -sh "$SRC" | cut -f1)"
    echo "结果:     校验通过"
    exit 0
fi

# ---------- 有差异:分类输出 ----------
log ERROR "校验不一致!开始分析差异"
echo ""
echo "--- 仅存在于源端的文件(同步遗漏) ---"
comm -23 <(awk '{print $2}' "${WORK}/src.sha256") \
         <(awk '{print $2}' "${WORK}/dst.sha256") | tee "${WORK}/only_src.txt"

echo "--- 仅存在于目标端的文件(多余残留) ---"
comm -13 <(awk '{print $2}' "${WORK}/src.sha256") \
         <(awk '{print $2}' "${WORK}/dst.sha256") | tee "${WORK}/only_dst.txt"

echo "--- 两端都有但内容不同的文件(损坏或未同步完) ---"
join -j 2 "${WORK}/src.sha256" "${WORK}/dst.sha256" 2>/dev/null \
    | awk '$2 != $3 {print $1}' | tee "${WORK}/mismatch.txt"

echo ""
echo "=========== 差异汇总 ==========="
printf "源端独有: %d 个\n"   "$(wc -l < "${WORK}/only_src.txt")"
printf "目标独有: %d 个\n"   "$(wc -l < "${WORK}/only_dst.txt")"
printf "内容不同: %d 个\n"   "$(wc -l < "${WORK}/mismatch.txt")"
echo "建议:对差异文件重新执行同步,或加 --checksum 让 rsync 按内容而非时间戳比对"

exit 3

用法示例:

chmod +x sync_verify.sh

# 本地到本地(备份盘)
./sync_verify.sh -s /data/app -d /backup/app

# 本地到远端,排除临时文件,限速 5MB/s
./sync_verify.sh -s /data/app -d root@192.168.1.20:/backup/app \
                 -e '*.tmp' -e '*.log' -e 'cache/' -b 5120

# 先 dry-run 看会同步/删除哪些文件
./sync_verify.sh -s /data/app -d /backup/app -n

# 单独用 rsync 做按内容校验的同步(慢但最可靠)
rsync -aHAX --checksum --delete --progress /data/app/ /backup/app/

# 单独校验两个目录是否一致(不同步)
diff <(cd /data/app && find . -type f | sort) <(cd /backup/app && find . -type f | sort)
diff <(cd /data/app && find . -type f -exec sha256sum {} \; | sort -k2) \
     <(cd /backup/app && find . -type f -exec sha256sum {} \; | sort -k2)

# 定时任务:每天凌晨 3 点同步并校验
# 0 3 * * * /opt/ops/bin/sync_verify.sh -s /data/app -d /backup/app >> /var/log/sync.log 2>&1

练习

以下练习完整保留讲义原有的 PPP 系列题目与综合练习,每题给出题目原文与参考答案;最后一节补充针对本章新增知识点的动手练习。

一、变量与参数(PPPz、PPP0–PPP7)

PPPz 进程关系:编写脚本,在脚本中打印自身 PID 与父进程 PID,并在后台启动一个 sleep;脚本退出后观察 sleep 的父进程变化。

答案:

#!/bin/bash
echo "脚本自身 PID(\$\$) = $$"
echo "父进程 PPID       = $PPID"
echo "父进程命令行:"
ps -o pid,ppid,cmd -p $PPID

sleep 300 &
echo "后台 sleep 的 PID(\$!) = $!"
ps -o pid,ppid,cmd -p $!
echo "脚本即将退出,请在另一个终端执行 ps -o pid,ppid,cmd -p $! 观察"
# 执行与观察
./pppz.sh
# 脚本退出后再查看那个 sleep:
ps -ef | grep "[s]leep 300"
# 结论:脚本进程消失后,sleep 成为孤儿进程,PPID 被改为 1
#       CentOS 6 的 1 号进程是 init,CentOS 7 是 systemd

要点:$$ 是当前 Shell 的 PID,$PPID 是父进程 PID,$! 是最近一个后台进程的 PID;用 bash script.sh 执行会新开子 Shell,脚本里的 $$ 与终端的 $$ 不同;用 source script.sh 执行则 $$ 与终端相同。

PPP0 环境变量 OLDPWD:cd ~ → env | grep OLDPWD → cd / → 观察 → export OLDPWD=/tmp → cd -,解释现象。

答案:

cd ~
env | grep OLDPWD          # 显示上一次所在目录
cd /
env | grep OLDPWD          # 变成了家目录,因为刚从家目录切过来
export OLDPWD=/tmp         # 手工改写 OLDPWD
cd -                       # 切到 /tmp,而不是原来的家目录
pwd                        # /tmp

要点:cd - 的行为完全依赖环境变量 OLDPWD,cd 每次执行都会把切换前的目录写入 OLDPWD;手工修改 OLDPWD 即可"欺骗" cd -。同理 cd 不带参数依赖 HOME,把 HOME 改掉后 cd 就会去新位置。

PPP1 变量定义:定义变量 a 值为 c;b 的值等于变量 a 的值;c 的值为 a 的值再加上 hello;d 的值为 /tmp 下的文件列表;e 的值为 2333;f 的值为 e 加 1000(不允许直接写 f=3333)。

答案:

a=c                          # 普通赋值,等号两边不能有空格
b=$a                         # 变量引用赋值,b 的值是 c
c=$a" hello"                 # 拼接:值含空格必须加引号,写成 c="$a hello" 亦可
d=$(ls /tmp)                 # 命令替换,推荐 $() 而不是反引号
e=2333
f=$((e+1000))                # 算术扩展,$(( )) 内部引用变量可以不加 $
# 其他等价写法
f=$[e+1000]                  # 旧式写法,已不推荐
let f=e+1000                 # let 命令
declare -i f; f=e+1000       # 声明为整型后可直接做算术
f=$(expr $e + 1000)          # expr 外部命令,运算符两侧必须有空格
echo "a=$a b=$b c=$c e=$e f=$f"
echo "d=$d"

PPP2 字符串截取:name="hello world hello uplooking hello YW201811",分别完成:① 从第 3 个字符开始截取 16 个字符;② 从第 7 个字符截取到倒数第 3 个字符;③ 从右边第 12 个字符截取到最后;④ 从右边第 12 个字符截取到倒数第 3 个字符。

答案:

name="hello world hello uplooking hello YW201811"
echo "字符串长度: ${#name}"

# ① 第 3 个字符 = 偏移量 2(偏移从 0 开始),取 16 个
echo "${name:2:16}"

# ② 第 7 个字符 = 偏移 6;到倒数第 3 个字符,长度 = 总长 - 6 - 2
echo "${name:6:$((${#name}-8))}"
# bash 4.2 及以上还可以用负长度,表示"距末尾还留 2 个字符"
echo "${name:6:-2}"

# ③ 从右边第 12 个到最后:负偏移,冒号后必须有空格或用括号,否则被当成 ${var:-默认值}
echo "${name: -12}"
echo "${name:(-12)}"
echo "${name:0-12}"

# ④ 从右边第 12 个到倒数第 3 个:长度 = 12 - 3 + 1 = 10
echo "${name: -12:10}"
echo "${name: -12:-2}"       # bash 4.2+

PPP3 URL 分解:分别用位置截取和关键字截取的方式,把 http://www.baidu.com/index.html 分解出协议、分隔符 ://、域名的各段、文件名与后缀。

答案:

url="http://www.baidu.com/index.html"

# ---------- 方式一:关键字截取(推荐,与长度无关) ----------
echo "协议:     ${url%%://*}"          # http
echo "分隔符:   ://"
rest=${url#*://}                        # www.baidu.com/index.html
echo "域名全称: ${rest%%/*}"            # www.baidu.com
domain=${rest%%/*}
echo "域名第1段: ${domain%%.*}"         # www
tmp=${domain#*.}                        # baidu.com
echo "域名第2段: ${tmp%%.*}"            # baidu
echo "域名第3段: ${domain##*.}"         # com
file=${url##*/}                         # index.html
echo "文件全名: $file"
echo "主文件名: ${file%%.*}"            # index
echo "后缀:     ${file##*.}"            # html

# ---------- 方式二:位置截取(依赖固定长度,换个网址就失效) ----------
echo "${url:0:4}"        # http
echo "${url:4:3}"        # ://
echo "${url:7:3}"        # www
echo "${url:11:5}"       # baidu
echo "${url:17:3}"       # com
echo "${url:21}"         # index.html

# ---------- 方式三:借助 IFS 与数组一次拆完 ----------
proto=${url%%://*}
rest=${url#*://}
IFS='/' read -r -a parts <<< "$rest"
IFS='.' read -r -a dm <<< "${parts[0]}"
echo "协议=$proto 域名段=${dm[*]} 文件=${parts[1]}"

PPP4 截取本机 IP:编写脚本,先设置 export LANG=en_US.UTF-8、export LANGUAGE=en_US:en 保证命令输出为英文,再从 route -n 输出中取出带 UG 标志(默认路由)的网卡名,然后从 ip a 输出中截取该网卡的 IP 地址。

答案:

#!/bin/bash
# 强制英文输出,避免中文环境下字段错位导致截取失败
export LANG=en_US.UTF-8
export LANGUAGE=en_US:en

# 取默认路由(Flags 含 UG)对应的网卡名,最后一列是 Iface
eth=$(route -n | awk '/^0.0.0.0/ && $4 ~ /UG/ {print $NF}' | head -1)
# CentOS 7 也可以用 ip 命令,无需安装 net-tools
[ -z "$eth" ] && eth=$(ip route show default | awk '/default/ {print $5}' | head -1)
echo "默认路由网卡: $eth"

# 方法一:纯 Shell 字符串截取(不借助 awk/sed)
line=$(ip a show "$eth" | grep -w inet)      # 形如 "    inet 192.168.1.10/24 brd ..."
line=${line#*inet }                          # 去掉 inet 及之前内容
ip1=${line%%/*}                              # 去掉 /24 及之后内容
echo "IP(纯 Shell): $ip1"

# 方法二:awk + cut
ip2=$(ip a show "$eth" | awk '/inet /{print $2}' | cut -d/ -f1)
echo "IP(awk):      $ip2"

# 方法三:CentOS 6 的 ifconfig 输出格式(addr: 前缀)
# ifconfig eth0 | grep 'inet addr' | awk -F'[: ]+' '{print $4}'
# CentOS 7 的 ifconfig 输出格式已变为 "inet 192.168.1.10  netmask ..."
# ifconfig eth0 | awk '/inet /{print $2}'

# 顺便取掩码与广播地址
mask=$(ip a show "$eth" | awk '/inet /{print $2}' | cut -d/ -f2)
echo "前缀长度: /$mask"

注意 route、ifconfig、netstat 属于 net-tools 包,CentOS 7 最小化安装默认不带,需要 yum install -y net-tools,或改用 ip / ss 命令。

PPP5 字符串替换:str="hello world hello uplooking hello YW201809",分别完成:① 把前 3 个 o 替换成 O;② 只把第 4 个 o 替换成 O;③ 把所有 e 替换成 E。

答案:

str="hello world hello uplooking hello YW201809"

# ③ 最简单:全部替换用 //
echo "${str//e/E}"

# ① 前 3 个 o 换成 O:${var/o/O} 只换第一个,循环 3 次即可
tmp="$str"
for i in 1 2 3; do
    tmp="${tmp/o/O}"        # 每次都把当前第一个小写 o 换掉
done
echo "前3个o: $tmp"

# ① 另一种思路:用 sed 的 s///3g 之类不易表达,改用 awk 逐字符处理
echo "$str" | awk '{n=0; out=""
    for (i=1; i<=length($0); i++) {
        c=substr($0,i,1)
        if (c=="o" && n<3) { c="O"; n++ }
        out=out c
    }
    print out}'

# ② 只换第 4 个 o:先把前 3 个换成临时占位符,再换第一个 o,最后把占位符还原
tmp="$str"
for i in 1 2 3; do
    tmp="${tmp/o/@@}"       # 占位符要选原串中不存在的字符
done
tmp="${tmp/o/O}"            # 此时第一个 o 就是原来的第 4 个 o
tmp="${tmp//@@/o}"          # 还原前 3 个
echo "第4个o: $tmp"

# ② 纯截取思路:定位第 4 个 o 的偏移量再拼接
count=0
for ((i=0; i<${#str}; i++)); do
    if [ "${str:i:1}" = "o" ]; then
        count=$((count+1))
        if [ "$count" -eq 4 ]; then
            echo "第4个o: ${str:0:i}O${str:i+1}"
            break
        fi
    fi
done

要点:${var/old/new} 替换第一个匹配,${var//old/new} 替换全部,${var/#old/new} 只在开头匹配,${var/%old/new} 只在结尾匹配;替换第 n 个匹配 Bash 没有原生语法,只能靠"占位符法"或逐字符循环。

PPP6 变量默认值:定义 name1=zhangsan、name2=lisi、name3=wangwu、name4=zhaoliu,用 ${name-unknow} 的形式打印四个变量;然后 unset name2 name4,再次打印,观察差异。

答案:

name1=zhangsan
name2=lisi
name3=wangwu
name4=zhaoliu

for i in 1 2 3 4; do
    var="name$i"
    echo "name$i = ${!var-unknow}"       # ${!var} 间接引用,取变量名为 name$i 的值
done
# 输出:zhangsan lisi wangwu zhaoliu

unset name2 name4
for i in 1 2 3 4; do
    var="name$i"
    echo "name$i = ${!var-unknow}"
done
# 输出:zhangsan unknow wangwu unknow —— unset 之后变量"不存在",触发默认值

# 对比:未定义 与 定义为空串 的区别
name5=""
echo "${name5-unknow}"     # 输出空行:变量存在(只是空),- 不触发
echo "${name5:-unknow}"    # 输出 unknow:加冒号后,空串也算"未设置"
unset name5
echo "${name5-unknow}"     # 输出 unknow
语法 变量未定义 变量为空串 变量有值
${var-默认} 返回默认值 返回空串 返回原值
${var:-默认} 返回默认值 返回默认值 返回原值
${var=默认} 赋默认值并返回 返回空串 返回原值
${var:=默认} 赋默认值并返回 赋默认值并返回 返回原值
${var?提示} 报错退出 返回空串 返回原值
${var:?提示} 报错退出 报错退出 返回原值
${var+替代} 返回空串 返回替代值 返回替代值
${var:+替代} 返回空串 返回空串 返回替代值

PPP7 输入与输出格式化:提示输入用户名(5 秒超时)、4 位数字 id(5 秒超时,多余字符自动截断)、6 位密码(密文输入、多余字符自动截断);输出时:用户名前后各加 5 个 *;id 前面 5 个空格、后面 10 个空格再接 uid;密码明文显示。

答案:

#!/bin/bash
# read 常用选项:
#   -p 提示语   -t 超时秒数   -n 达到指定字符数自动结束(截断)
#   -s 静默(不回显,用于密码)   -r 不转义反斜杠(推荐总是加)

read -t 5 -p "请输入用户名: " -r username
echo                                  # 换行,避免超时后光标停在提示行

read -t 5 -n 4 -p "请输入 4 位数字 id: " -r uid
echo

read -s -n 6 -p "请输入 6 位密码: " -r passwd
echo                                  # -s 不回显也不换行,必须手工补 echo

# 超时或未输入时给默认值
username=${username:-nobody}
uid=${uid:-0000}

# ---------- 输出格式化 ----------
echo "用户名: *****${username}*****"
printf '用户名: %s%s%s\n' "*****" "$username" "*****"

echo "id:          ${uid}          uid"     # 手工敲空格,可读性差
printf 'id: %5s%s%10suid\n' "" "$uid" ""     # 用 printf 补空格,更可控
printf 'id: %5s%-10s%s\n' "" "$uid" "uid"    # %-10s 左对齐并补足 10 宽

echo "密码明文: $passwd"

# 校验(讲义未要求,但生产脚本必须有)
[[ "$uid" =~ ^[0-9]{4}$ ]] || echo "警告: id 不是 4 位数字"
[ ${#passwd} -eq 6 ] || echo "警告: 密码长度不足 6 位"

要点:-n 4 表示读满 4 个字符立即返回(起到"截断"效果),-N 4 表示必须精确读 4 个字符且忽略分隔符;-t 超时后 read 返回非 0 状态码(Bash 中大于 128),可用 if ! read -t 5 ...; then 判断超时。

二、分支与循环(PPP8–PPP11)

PPP8-1 if/test 成绩分级:输入成绩,90 分及以上输出 A,80–89 输出 B,70–79 输出 C,70 以下输出 D。

答案:

#!/bin/bash
read -p "请输入成绩: " -r score
# 先做输入校验
[[ "$score" =~ ^[0-9]+$ ]] || { echo "请输入非负整数"; exit 1; }
[ "$score" -le 100 ] || { echo "成绩不能超过 100"; exit 1; }

if [ "$score" -ge 90 ]; then
    echo A
elif [ "$score" -ge 80 ]; then
    echo B
elif [ "$score" -ge 70 ]; then
    echo C
else
    echo D
fi
# 另一种写法:用 case 配合区间(Bash 的 case 不支持数值区间,只能用通配)
case "$score" in
    100|9[0-9]) echo A ;;
    8[0-9])     echo B ;;
    7[0-9])     echo C ;;
    *)          echo D ;;
esac

PPP8-2 参数个数判断:参数为空时输出"参数不足,应该多于 1 个",要求用 4 种方法实现。

答案:

#!/bin/bash
msg="参数不足,应该多于 1 个"

# 方法一:判断参数个数 $#
[ $# -lt 1 ] && { echo "$msg"; exit 1; }

# 方法二:判断第一个参数是否为空字符串(必须加引号,否则参数为空时 test 语法出错)
[ -z "$1" ] && { echo "$msg"; exit 1; }

# 方法三:用 [[ ]],内部不做单词拆分,可以不加引号
[[ -z $1 ]] && { echo "$msg"; exit 1; }

# 方法四:用 ${var:?} 让 Shell 自己报错退出
: ${1:?"$msg"}

# 方法五:用 if 完整写法 + 字符串比较
if [ "x$1" = "x" ]; then       # 老式写法,前面加 x 防止参数以 - 开头被当成选项
    echo "$msg"; exit 1
fi

# 方法六:用 test 命令的等价形式
test $# -eq 0 && { echo "$msg"; exit 1; }

echo "参数检查通过,共 $# 个参数: $*"

PPP8-3 变量取值:变量 num,若 $1 为空则取 $0 的值,否则取 $1 的值,要求 3 种方法。

答案:

#!/bin/bash
# 方法一:参数默认值扩展(最简洁)
num=${1:-$0}

# 方法二:if 判断
if [ -z "$1" ]; then
    num=$0
else
    num=$1
fi

# 方法三:短路运算符
[ -n "$1" ] && num=$1 || num=$0

# 方法四:三元式模拟(Bash 无三元运算符,用 [[ ]] 加 && ||)
num=$([ -n "$1" ] && echo "$1" || echo "$0")

echo "num=$num"

PPP8-4 区间判断:输入一个数字,若在 10 到 20 之间(含 10 和 20)输出 yes,否则输出 no,要求 3 种方法。

答案:

#!/bin/bash
read -p "请输入数字: " -r n

# 方法一:test 的 -a(与)
[ "$n" -ge 10 -a "$n" -le 20 ] && echo yes || echo no

# 方法二:两个 test 用 && 连接(推荐,-a/-o 已不建议使用)
[ "$n" -ge 10 ] && [ "$n" -le 20 ] && echo yes || echo no

# 方法三:[[ ]] 内部用 &&
[[ "$n" -ge 10 && "$n" -le 20 ]] && echo yes || echo no

# 方法四:算术表达式,可以直接写数学比较符
(( n >= 10 && n <= 20 )) && echo yes || echo no

# 方法五:case 通配(仅整数)
case "$n" in
    1[0-9]|20) echo yes ;;
    *)         echo no  ;;
esac

# 方法六:正则
[[ "$n" =~ ^(1[0-9]|20)$ ]] && echo yes || echo no

注意 cmd1 && cmd2 || cmd3 不完全等价于 if-else:如果 cmd2 本身失败,cmd3 也会执行。要求严谨时仍应写 if。

PPP9 case 随机数菜单:询问用户是否要一个 50 到 100 之间的随机数;输入 yes/YES/Y/y、直接回车、或乱输入都视为参加;输入 no/NO/N/No 视为不参加;参加则打印一个 55 到 99 之间的随机数,不参加则打印欢迎语。

答案:

#!/bin/bash
read -p "是否需要一个 50-100 的随机数? [Y/n] " -r answer

case "$answer" in
    no|NO|N|n|No|nO)
        echo "欢迎下次再来!"
        ;;
    yes|YES|Y|y|Yes|"")            # "" 匹配直接回车的情况
        # $RANDOM 是 Bash 内置变量,范围 0-32767
        # 生成 [55,99] 区间:取模 45 得 0-44,再加 55
        echo "随机数: $((RANDOM % 45 + 55))"
        ;;
    *)                             # 乱输入也算参加
        echo "无法识别的输入,默认参加"
        echo "随机数: $((RANDOM % 45 + 55))"
        ;;
esac
# 用忽略大小写的方式简化(bash 4.0+ 支持 shopt -s nocasematch)
shopt -s nocasematch
case "$answer" in
    n|no)  echo "欢迎下次再来!" ;;
    *)     echo "随机数: $((RANDOM % 45 + 55))" ;;
esac
shopt -u nocasematch

# 生成任意区间 [min,max] 随机数的通用公式
min=50; max=100
echo $((RANDOM % (max - min + 1) + min))

PPP10 猜数字游戏:完善猜数字游戏,随机生成目标数,限制猜测次数,提示大了还是小了。

答案见 §18.1 讲义脚本与下文"综合编程题第 2 题",此处给出带完整输入校验的版本:

#!/bin/bash
target=$((RANDOM % 100 + 1))       # 1-100
max=$((RANDOM % 4 + 4))            # 4-7 次机会
count=0

echo "我想了一个 1-100 的数字,你有 $max 次机会"
while [ "$count" -lt "$max" ]; do
    read -p "第 $((count+1)) 次猜测: " -r guess
    # 非法输入不消耗次数
    if ! [[ "$guess" =~ ^[0-9]+$ ]]; then
        echo "请输入正整数"
        continue
    fi
    count=$((count+1))
    if [ "$guess" -eq "$target" ]; then
        echo "恭喜,猜对了!共用了 $count 次"
        exit 0
    elif [ "$guess" -gt "$target" ]; then
        echo "太大了"
    else
        echo "太小了"
    fi
    echo "剩余机会: $((max - count))"
done
echo "机会用完,正确答案是 $target"
exit 1

PPP11 直角三角形:接收一个边长参数,用 4 种方式打印直角三角形。

答案:

#!/bin/bash
n=${1:-5}
[[ "$n" =~ ^[1-9][0-9]*$ ]] || { echo "用法: $0 正整数"; exit 1; }

echo "=== 方式一:双层 for 循环 ==="
for ((i=1; i<=n; i++)); do
    for ((j=1; j<=i; j++)); do
        echo -n "*"
    done
    echo
done

echo "=== 方式二:while 循环 ==="
i=1
while [ "$i" -le "$n" ]; do
    j=1
    line=""
    while [ "$j" -le "$i" ]; do
        line="${line}*"
        j=$((j+1))
    done
    echo "$line"
    i=$((i+1))
done

echo "=== 方式三:printf 补位 + tr 替换(无内层循环) ==="
for ((i=1; i<=n; i++)); do
    printf "%${i}s\n" | tr ' ' '*'
done

echo "=== 方式四:字符串累加 ==="
line=""
for ((i=1; i<=n; i++)); do
    line="${line}*"
    echo "$line"
done

echo "=== 方式五:seq + eval 生成 ==="
for i in $(seq 1 "$n"); do
    eval printf '"*"' "{1..$i}" ; echo
done

三、数组(PPP12)

PPP12 数组综合操作:① 用下标赋值的方式创建一个索引数组;② 在其基础上拼接一个数组;③ 删除下标为 3 的元素;④ 循环打印数组,观察下标 3 的元素;⑤ 用下标的形式删除所有元素;⑥ 再拼接一个数组;⑦ 循环打印键名和值;⑧ 用下标访问元素,观察下标情况;⑨ 在已存在的数组中追加一个变量的值。

答案:

#!/bin/bash

# ① 用下标赋值创建索引数组
arr[0]=aa
arr[1]=bb
arr[2]=cc
arr[3]=dd
arr[4]=ee
echo "① 数组内容: ${arr[@]}"          # aa bb cc dd ee
echo "   元素个数: ${#arr[@]}"        # 5
echo "   下标列表: ${!arr[@]}"        # 0 1 2 3 4

# ② 在其基础上拼接一个数组
arr+=(ff gg)                           # += 追加到末尾,等价于 arr[${#arr[@]}]=...
echo "② 拼接后:   ${arr[@]}"          # aa bb cc dd ee ff gg
echo "   下标列表: ${!arr[@]}"        # 0 1 2 3 4 5 6

# ③ 删除下标为 3 的元素
unset arr[3]
# 注意:unset arr[3] 在开启了通配符匹配的场景下可能被当成文件名,稳妥写法:
# unset 'arr[3]'
echo "③ 删除下标3后: ${arr[@]}"       # aa bb cc ee ff gg(dd 消失)
echo "   元素个数:   ${#arr[@]}"      # 6(少了一个)
echo "   下标列表:   ${!arr[@]}"      # 0 1 2 4 5 6(下标 3 空缺,成为稀疏数组)

# ④ 循环打印数组,观察下标 3 的元素
echo "④ 按下标 0 到 6 遍历(错误示范,会漏元素或打印空值):"
for ((i=0; i<=6; i++)); do
    echo "   arr[$i] = '${arr[$i]}'"   # arr[3] 为空字符串
done
echo "   按下标列表遍历(正确):"
for i in "${!arr[@]}"; do
    echo "   arr[$i] = ${arr[$i]}"
done
echo "   结论:unset 只删值不重排下标,数组变稀疏;用 \${#arr[@]} 当上界遍历会漏掉尾部元素"

# 重建为连续下标
arr=("${arr[@]}")
echo "   重建后下标: ${!arr[@]}"       # 0 1 2 3 4 5

# ⑤ 用下标的形式删除所有元素
for i in "${!arr[@]}"; do
    unset "arr[$i]"
done
echo "⑤ 全删后个数: ${#arr[@]}"       # 0
echo "   数组是否还存在: $(declare -p arr 2>/dev/null || echo '不存在')"
# 说明:把所有元素 unset 掉之后,数组变量本身仍然存在(declare -a arr='()')
# 彻底删除整个数组要用:unset arr

# ⑥ 再拼接一个数组
arr+=(11 22 33)
echo "⑥ 重新拼接: ${arr[@]}"          # 11 22 33
echo "   下标列表: ${!arr[@]}"        # 0 1 2(从 0 重新开始)

# ⑦ 循环打印键名和值
echo "⑦ 键值对:"
for k in "${!arr[@]}"; do
    echo "   键=$k 值=${arr[$k]}"
done

# ⑧ 用下标访问元素,观察下标情况
echo "⑧ arr[0]=${arr[0]} arr[2]=${arr[2]} arr[9]='${arr[9]}'"
echo "   不存在的下标返回空字符串,不报错(除非开启 set -u)"
echo "   不加下标时 \$arr 等价于 \${arr[0]}: $arr"
# 取最后一个元素
echo "   最后一个元素: ${arr[${#arr[@]}-1]}"     # 通用写法,CentOS 6/7 都支持
echo "   最后一个元素: ${arr[-1]}"               # bash 4.3+ 才支持负下标
# CentOS 7 自带 bash 4.2,写 ${arr[-1]} 会报 bad array subscript

# ⑨ 在已存在数组中追加一个变量的值
name="hello"
arr+=("$name")                         # 推荐写法,变量含空格也安全
echo "⑨ 追加变量后: ${arr[@]}"
arr[${#arr[@]}]="$USER"                # 等价写法:用当前长度作为新下标
echo "   再追加一个: ${arr[@]}"
declare -p arr                          # 打印数组的完整定义,调试利器

四、find(PPP13)

PPP13 find 多条件查找:找出根目录下属主为 root、大于 1M(讲义示例用 5M)、权限为 644 的普通文件,屏蔽错误输出,并用 ls -hl 格式化显示。

答案(讲义原命令):

find / -size +5M -perm 644 -user root -type f 2> /dev/null -exec ls -hl "{}" \;
# 题面要求的 1M 版本
find / -size +1M -perm 644 -user root -type f 2>/dev/null -exec ls -hl {} \;

# 大量结果时用 xargs 更高效(-exec 是每个文件起一个进程,xargs 是批量)
find / -size +1M -perm 644 -user root -type f -print0 2>/dev/null | xargs -0 ls -hl

# -exec 的 + 形式也能批量执行,等效于 xargs
find / -size +1M -perm 644 -user root -type f 2>/dev/null -exec ls -hl {} +

按各类条件单独练习:

# 按大小:+ 大于  - 小于  不带符号表示"等于"(会向上取整,慎用)
find /var -size +100M                  # 大于 100M
find /var -size -10k                   # 小于 10k
find /var -size +10M -size -100M       # 10M 到 100M 之间(条件默认是与)

# 按属主/属组
find /home -user zhangsan              # 属主是 zhangsan
find /home -group developer            # 属组是 developer
find / -nouser -o -nogroup             # 属主或属组不存在(删用户后的遗留文件)
find /home -uid 1000                   # 按 UID 查
find /home ! -user root                # 取反:属主不是 root

# 按文件类型
find /dev -type b                      # b 块设备  c 字符设备  d 目录
find /etc -type l                      # l 符号链接  f 普通文件  s 套接字  p 管道

# 按名字(模糊搜索)
find /etc -name "*.conf"               # 通配符要加引号,否则被 Shell 先展开
find /etc -iname "*.CONF"              # -iname 忽略大小写
find /etc -regex '.*\.\(conf\|cfg\)$'  # -regex 匹配整个路径

# 按权限
find /tmp -perm 777                    # 权限精确等于 777
find /tmp -perm -644                   # 权限"至少包含" 644(所有指定位都置位)
find /tmp -perm /222                    # 权限"任一包含"(任意一个写位置位)
find / -perm -4000 2>/dev/null          # 查找所有 SUID 程序,安全检查常用

# 按时间:atime 访问 mtime 修改 ctime 属性变更;单位是天,min 结尾的是分钟
find /var/log -mtime +7                # 修改时间超过 7 天
find /var/log -mtime -1                # 最近 1 天内修改过
find /var/log -mmin -30                # 最近 30 分钟内修改过
find /var/log -newer /etc/passwd       # 比参照文件更新

# 组合与删除
find /var/log -name "*.log" -mtime +30 -delete            # 直接删除
find /var/log -name "*.log" -mtime +30 -exec rm -f {} \;  # 等效但更慢
find /data -type d -empty -delete                          # 删除空目录

# 限制深度、不跨文件系统
find / -maxdepth 2 -name "*.conf"      # 只找 2 层
find / -xdev -name core                # 不进入其他挂载点

find 的完整选项体系、-printf 格式化输出、与 locate / which / whereis 的区别,详见 06-02-文本处理与文件查找.md。

五、正则与输入检测(PPP14)

PPP14 输入合法性检测菜单:编写脚本,用菜单选择检测类型,输入字符串后给出检测结果;输入 q 或 Q 退出;输入非法编号提示重新输入。需支持的检测项:① 纯数字;② 纯字母;③ 纯小写字母;④ 纯大写字母;⑤ 电子邮箱;⑥ 手机号码;⑦ 非负整数;⑧ 正整数;⑨ 负整数;⑩ 整数;⑪ 小数;⑫ 只包含字母数字下划线且不以数字开头。

答案(讲义参考脚本):

while true; do
    cat <<-EOF
    1. 纯数字
    2. 纯字母
    3. 纯小写
    4. 纯大写
    5. 电子邮箱
    6. 手机号码
    7. 非负整数
    8. 正整数
    9. 负整数
    10. 整数
    11. 小数
    12. 字母数字下划线且不以数字开头
    q. 退出
    EOF
    read -p "请选择: " op
    [ "$op" = "q" -o "$op" = "Q" ] && exit 0
    read -p "请输入要检测的字符串: " str
    case "$op" in
        1)  echo "$str" | grep -Eq '^[0-9]+$' && echo yes || echo no;;
        2)  echo "$str" | grep -Eq '^[a-zA-Z]+$' && echo yes || echo no;;
        3)  echo "$str" | grep -Eq '^[a-z]+$' && echo yes || echo no;;
        4)  echo "$str" | grep -Eq '^[A-Z]+$' && echo yes || echo no;;
        5)  echo "$str" | grep -Eq '^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$' && echo yes || echo no;;
        6)  echo "$str" | grep -Eq '^1[3-9][0-9]{9}$' && echo yes || echo no;;
        7)  echo "$str" | grep -Eq '^[0-9]+$' && echo yes || echo no;;
        8)  echo "$str" | grep -Eq '^[1-9][0-9]*$' && echo yes || echo no;;
        9)  echo "$str" | grep -Eq '^-[1-9][0-9]*$' && echo yes || echo no;;
        10) echo "$str" | grep -Eq '^-?[0-9]+$' && echo yes || echo no;;
        11) echo "$str" | grep -Eq '^-?[0-9]+\.[0-9]+$' && echo yes || echo no;;
        12) echo "$str" | grep -Eq '^[a-zA-Z_][a-zA-Z0-9_]*$' && echo yes || echo no;;
        *)  echo "输入有误,请重输";;
    esac
done

cat <<-EOF 中的 - 允许缩进,但只忽略制表符不忽略空格,上面菜单每行开头必须是真实的 Tab;结束标记 EOF 前也只能有 Tab。

用纯 Bash 内置正则重写(不调用外部 grep,效率更高):

#!/bin/bash
# 用关联数组把"编号 -> 正则"和"编号 -> 描述"集中管理,便于扩展
declare -A RE=(
    [1]='^[0-9]+$'
    [2]='^[a-zA-Z]+$'
    [3]='^[a-z]+$'
    [4]='^[A-Z]+$'
    [5]='^[a-zA-Z0-9_.-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$'
    [6]='^1[3-9][0-9]{9}$'
    [7]='^[0-9]+$'
    [8]='^[1-9][0-9]*$'
    [9]='^-[1-9][0-9]*$'
    [10]='^-?[0-9]+$'
    [11]='^-?[0-9]+\.[0-9]+$'
    [12]='^[a-zA-Z_][a-zA-Z0-9_]*$'
)
declare -A DESC=(
    [1]="纯数字"   [2]="纯字母"   [3]="纯小写"   [4]="纯大写"
    [5]="电子邮箱" [6]="手机号码" [7]="非负整数" [8]="正整数"
    [9]="负整数"   [10]="整数"    [11]="小数"    [12]="标识符"
)

while true; do
    echo "===== 输入合法性检测 ====="
    # 关联数组遍历顺序不确定,用 sort -n 保证按编号显示
    for k in $(echo "${!DESC[@]}" | tr ' ' '\n' | sort -n); do
        printf '%3s. %s\n' "$k" "${DESC[$k]}"
    done
    echo "  q. 退出"
    read -p "请选择: " -r op
    case "$op" in
        q|Q) echo "再见"; exit 0 ;;
    esac
    if [ -z "${RE[$op]:-}" ]; then
        echo "输入有误,请重输"; continue
    fi
    read -p "请输入要检测的字符串: " -r str
    # [[ =~ ]] 右侧的正则不要加引号,否则会被当成普通字符串
    if [[ "$str" =~ ${RE[$op]} ]]; then
        echo "结果: yes(是合法的${DESC[$op]})"
        echo "匹配内容: ${BASH_REMATCH[0]}"
    else
        echo "结果: no(不是合法的${DESC[$op]})"
    fi
    echo
done

常见校验正则补充(生产脚本常用):

IP='^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$'
PORT='^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$'
DATE='^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'
MAC='^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
USERNAME='^[a-z_][a-z0-9_-]{0,31}$'          # Linux 用户名规范
[[ "192.168.1.1" =~ $IP ]] && echo "合法 IP"

正则表达式的元字符体系、BRE 与 ERE 的差异、贪婪匹配与反向引用的完整说明,详见 06-02-文本处理与文件查找.md。

六、sed(PPP15–PPP21)

以下题目均出自讲义,题面完整保留。sed 的完整命令体系与选项说明详见 06-02-文本处理与文件查找.md,此处只给出各题参考命令。

PPP15 输出 p:① 只打印 /etc/passwd 中以 s、g、a 开头的行;② 输出含空格的行;③ 打印以字母 c 结尾的行。

sed -n '/^[sga]/p' /etc/passwd
sed -n '/ /p' /etc/passwd
sed -n '/c$/p' /etc/passwd

PPP16 增加 i/a:cat /etc/passwd > passwd.txt,在不知道文件行数的情况下,在倒数第一行的上面插入一行数据。

cat /etc/passwd > passwd.txt
sed '$i hello world' passwd.txt          # $ 表示最后一行,i 在其之前插入
sed -i '$i hello world' passwd.txt       # -i 直接修改文件
sed '$a hello world' passwd.txt          # 对比:a 是在最后一行之后追加

PPP17 删除 d:cat -n /etc/passwd > 1_passwd.txt,① 删除每行的前导空格;② 删除第 10 行的第 10 到 20 个字符;③ 删除倒数第 10 行的倒数第 3 到第 10 个字符;④ 删除第 20 行的第 13 个和第 18 个字符。

cat -n /etc/passwd > 1_passwd.txt
sed -r 's/^[[:space:]]+//' 1_passwd.txt                    # ①
sed -r '10s/^(.{9}).{11}/\1/' 1_passwd.txt                 # ②
total=$(wc -l < 1_passwd.txt); line=$((total-9))
sed -r "${line}s/.{8}(.{2})$/\1/" 1_passwd.txt             # ③ 倒数第3~10个共8个字符
sed -r '20s/^(.{12})./\1/; 20s/^(.{16})./\1/' 1_passwd.txt # ④ 先删13个,原18变17

PPP18 替换 s:cat -n /etc/passwd > passwd.txt,① 删除行号与有效内容之间的空格;② 把每行第 3 个 : 替换成 #。

cat -n /etc/passwd > passwd.txt
sed -r 's/^([[:space:]]*[0-9]+)[[:space:]]+/\1/' passwd.txt   # ①
sed 's/:/#/3' passwd.txt                                      # ② 数字标志替换第 n 个

PPP19 综合:cat /etc/passwd > 1_passwd.txt,① 交换每行第二个单词与倒数第二个单词;② 从网卡信息中截取 IP 与掩码,输出形如 192.168.0.183/255.255.255.0。

# ① 以 : 为分隔符,交换第 2 与倒数第 2 字段
sed -r 's/^([^:]*:)([^:]*)(:.*:)([^:]*)(:[^:]*)$/\1\4\3\2\5/' 1_passwd.txt

# ② CentOS 7 的 ip 命令输出
ip a show eth0 | sed -rn 's#.*inet ([0-9.]+)/([0-9]+).*#\1/\2#p'
# CentOS 6 的 ifconfig 输出(inet addr:x  Mask:y)
ifconfig eth0 | sed -rn 's/.*addr:([0-9.]+).*Mask:([0-9.]+).*/\1\/\2/p'
# CentOS 7 的 ifconfig 输出(inet x netmask y)
ifconfig eth0 | sed -rn 's/.*inet ([0-9.]+).*netmask ([0-9.]+).*/\1\/\2/p'

PPP20:① 删除每行倒数第二个单词(单词定义为 [a-zA-Z]+);② 交换每行第一个单词与最后一个单词。

sed -r 's/[a-zA-Z]+([^a-zA-Z]+[a-zA-Z]+[^a-zA-Z]*)$/\1/' 1_passwd.txt   # ①
sed -r 's/^([a-zA-Z]+)(.*[^a-zA-Z])([a-zA-Z]+)$/\3\2\1/' 1_passwd.txt   # ②

PPP21 日期与字符替换:① 把 2018/9/1、2011/10/23 这类日期替换为 2018 09 01 形式(月日补零、斜杠换空格);② 打印倒数第 5 行和倒数第 10 行的倒数第 5 个字符;③ 把倒数第 20 行的倒数第 12 个字符替换成倒数第 25 行的倒数第 10 个字符。

# ① 分两步:先补零再换分隔符
echo "2018/9/1" | sed -r 's#/([0-9])(/|$)#/0\1\2#g; s#/# #g'
sed -r 's#\b([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})\b#\1 0\2 0\3#g; s# 0([0-9]{2})# \1#g' date.txt

# ② 倒数第 5 行、倒数第 10 行的倒数第 5 个字符
total=$(wc -l < 1_passwd.txt)
sed -rn "$((total-4))s/.*(.).{4}$/\1/p" 1_passwd.txt
sed -rn "$((total-9))s/.*(.).{4}$/\1/p" 1_passwd.txt

# ③ 先取出源字符,再用变量替换目标位置
ch=$(sed -rn "$((total-24))s/.*(.).{9}$/\1/p" 1_passwd.txt)
sed -r "$((total-19))s/(.{11})$/${ch}\1/" 1_passwd.txt   # 需先删原字符再插入,见下
sed -r "$((total-19))s/.(.{11})$/${ch}\1/" 1_passwd.txt  # 正确:. 吃掉原字符

以上各题涉及的 sed 定址方式、-n/-r/-i 选项、p/d/s/i/a/c/y 命令、后向引用 \1~\9 与 &,详见 06-02-文本处理与文件查找.md。

七、awk(PPP22)

PPP22 以下题目均以 : 为分隔符:① 打印 uid 在 800 到 1000 之间的用户名;② 打印第 6 行到第 9 行;③ 打印第 5 行和第 9 行的累积字段数与当前行号;④ 打印倒数第 3 列的数据;⑤ 打印倒数第 10 行的倒数第 3 个字段;⑥ cat -n /etc/passwd > 1_passwd.txt,替换倒数第 3 行倒数第 2 个字段与倒数第 10 行倒数第 3 个字段;⑦ 删除 1_passwd.txt 倒数第 3 行的第 3 列数据。

第 ③ 题要求的输出形如:

5   20
9   60

参考答案:

# ① uid 在 800~1000 之间的用户名
awk -F: '$3 >= 800 && $3 <= 1000 {print $1}' /etc/passwd

# ② 第 6 到第 9 行
awk -F: 'NR >= 6 && NR <= 9 {print $0}' /etc/passwd
awk -F: 'NR==6,NR==9' /etc/passwd          # 范围模式写法
awk -F: 'NR>9 {exit} NR>=6' /etc/passwd    # 大文件时提前退出,效率更高

# ③ 累积字段数与行号:用变量累加 NF
awk -F: '{sum += NF} NR==5 || NR==9 {print NR"\t"sum}' /etc/passwd

# ④ 倒数第 3 列:$(NF-2)
awk -F: '{print $(NF-2)}' /etc/passwd

# ⑤ 倒数第 10 行的倒数第 3 个字段:awk 无法回退,需两次遍历或先算总行数
total=$(wc -l < /etc/passwd)
awk -F: -v n=$((total-9)) 'NR==n {print $(NF-2)}' /etc/passwd
# 单条命令实现:把所有行存进数组,END 中再取
awk -F: '{a[NR]=$(NF-2)} END{print a[NR-9]}' /etc/passwd

# ⑥ 交换倒数第 3 行倒数第 2 字段 与 倒数第 10 行倒数第 3 字段
cat -n /etc/passwd > 1_passwd.txt
awk -F: '{line[NR]=$0}
END{
    n=NR
    split(line[n-2], a, ":"); split(line[n-9], b, ":")
    tmp = a[length(a)-1]; a[length(a)-1] = b[length(b)-2]; b[length(b)-2] = tmp
    for (i=1; i<=n; i++) {
        if (i == n-2)      { s=""; for (j=1; j<=length(a); j++) s = s (j>1?":":"") a[j]; print s }
        else if (i == n-9) { s=""; for (j=1; j<=length(b); j++) s = s (j>1?":":"") b[j]; print s }
        else print line[i]
    }
}' 1_passwd.txt

# ⑦ 删除倒数第 3 行的第 3 列
awk -F: -v OFS=: '{line[NR]=$0}
END{
    for (i=1; i<=NR; i++) {
        if (i == NR-2) {
            n = split(line[i], f, ":"); s=""
            for (j=1; j<=n; j++) if (j != 3) s = s (s==""?"":":") f[j]
            print s
        } else print line[i]
    }
}' 1_passwd.txt

# ⑦ 另一种思路:用 sed 处理指定行
total=$(wc -l < 1_passwd.txt)
sed -r "$((total-2))s/^(([^:]*:){2})[^:]*:/\1/" 1_passwd.txt

awk 的模式-动作结构、BEGIN/END 块、内置变量(NR NF FS OFS RS ORS FILENAME $0~$NF)、数组与流程控制、printf 格式化、getline、自定义函数等完整内容,详见 06-02-文本处理与文件查找.md。

八、函数(PPP23、PPP24)

PPP23 函数运算:① 加法函数,输入 2 个参数,返回两数之和的 36 倍;② 减法函数,输入 4 个参数,返回 4 个参数之差的 42 倍;③ 乘法函数,输入 3 个参数,返回 3 个参数乘积的 42 倍再除以 3500;④ 取模函数,输入 3 个参数,返回第一个参数模第二个参数、结果再模第三个参数的值。

答案:

#!/bin/bash
# 注意:return 只能返回 0-255 的退出状态码,运算结果必须用 echo 输出,
#       由调用方用 $( ) 捕获,否则大于 255 的结果会被取模成错误值。

# ① 加法:(a+b)*36
add() {
    [ $# -eq 2 ] || { echo "add 需要 2 个参数" >&2; return 1; }
    echo $((($1 + $2) * 36))
}

# ② 减法:(a-b-c-d)*42
sub() {
    [ $# -eq 4 ] || { echo "sub 需要 4 个参数" >&2; return 1; }
    echo $((($1 - $2 - $3 - $4) * 42))
}

# ③ 乘法:a*b*c*42/3500(整数除法会截断小数,需要小数用 bc)
mul() {
    [ $# -eq 3 ] || { echo "mul 需要 3 个参数" >&2; return 1; }
    echo $(($1 * $2 * $3 * 42 / 3500))
}
mul_float() {                      # 需要小数结果时用 bc,scale 控制小数位
    echo "scale=4; $1 * $2 * $3 * 42 / 3500" | bc
}

# ④ 取模:a % b % c
mod() {
    [ $# -eq 3 ] || { echo "mod 需要 3 个参数" >&2; return 1; }
    [ "$2" -ne 0 ] && [ "$3" -ne 0 ] || { echo "除数不能为 0" >&2; return 1; }
    echo $(( $1 % $2 % $3 ))
}

# 调用与验证
echo "add 3 5      = $(add 3 5)"            # (3+5)*36 = 288
echo "sub 100 10 20 30 = $(sub 100 10 20 30)" # (100-10-20-30)*42 = 1680
echo "mul 10 20 30 = $(mul 10 20 30)"        # 10*20*30*42/3500 = 72
echo "mul_float     = $(mul_float 10 20 30)"
echo "mod 100 7 3   = $(mod 100 7 3)"        # 100%7=2, 2%3=2

# 错误示范:用 return 返回运算结果
bad_add() { return $(( $1 + $2 )); }
bad_add 200 100; echo "return 方式: $?"      # 300 % 256 = 44,结果错误

PPP24 多级菜单:用函数组织一个多级菜单脚本。

答案:

#!/bin/bash
# ============================================================================
# 多级菜单示例:每一级菜单一个函数,用 return 返回上一级,用 exit 整体退出
# ============================================================================

pause() { read -n 1 -s -p "按任意键继续..." -r; echo; }

# ---------- 三级菜单:网络信息 ----------
menu_net() {
    while true; do
        clear
        cat <<-EOF
        =========== 网络信息 ===========
        1) 查看 IP 地址
        2) 查看路由表
        3) 查看监听端口
        4) 测试外网连通性
        b) 返回上一级
        q) 退出
        ================================
        EOF
        read -p "请选择: " -r op
        case "$op" in
            1) ip a 2>/dev/null || ifconfig -a; pause ;;
            2) ip route 2>/dev/null || route -n; pause ;;
            3) ss -tulnp 2>/dev/null || netstat -tulnp; pause ;;
            4) ping -c 3 223.5.5.5; pause ;;
            b|B) return 0 ;;
            q|Q) exit 0 ;;
            *) echo "无效选择"; sleep 1 ;;
        esac
    done
}

# ---------- 二级菜单:系统信息 ----------
menu_sys() {
    while true; do
        clear
        cat <<-EOF
        =========== 系统信息 ===========
        1) 主机名与内核版本
        2) CPU 与内存
        3) 磁盘使用率
        4) 系统负载与运行时长
        5) 进入网络信息子菜单
        b) 返回主菜单
        q) 退出
        ================================
        EOF
        read -p "请选择: " -r op
        case "$op" in
            1) hostname; uname -a; cat /etc/redhat-release; pause ;;
            2) lscpu | head -20; free -m; pause ;;
            3) df -hT; pause ;;
            4) uptime; cat /proc/loadavg; pause ;;
            5) menu_net ;;                  # 调用三级菜单函数
            b|B) return 0 ;;
            q|Q) exit 0 ;;
            *) echo "无效选择"; sleep 1 ;;
        esac
    done
}

# ---------- 二级菜单:用户管理 ----------
menu_user() {
    while true; do
        clear
        cat <<-EOF
        =========== 用户管理 ===========
        1) 列出所有普通用户
        2) 新建用户
        3) 删除用户
        4) 修改用户密码
        b) 返回主菜单
        q) 退出
        ================================
        EOF
        read -p "请选择: " -r op
        case "$op" in
            1) awk -F: '$3>=500 && $3<60000 {print $1, $3}' /etc/passwd; pause ;;
            2) read -p "用户名: " -r u
               id "$u" &>/dev/null && echo "用户已存在" || {
                   useradd "$u" && echo "创建成功"
               }; pause ;;
            3) read -p "用户名: " -r u
               read -p "确认删除 $u 及其家目录? [y/N] " -r yn
               [ "$yn" = "y" ] && userdel -r "$u" && echo "已删除"; pause ;;
            4) read -p "用户名: " -r u
               read -s -p "新密码: " -r p; echo
               echo "$p" | passwd --stdin "$u" &>/dev/null && echo "修改成功"; pause ;;
            b|B) return 0 ;;
            q|Q) exit 0 ;;
            *) echo "无效选择"; sleep 1 ;;
        esac
    done
}

# ---------- 一级主菜单 ----------
main_menu() {
    while true; do
        clear
        cat <<-EOF
        ========= 运维工具箱 v1.0 =========
        1) 系统信息
        2) 用户管理
        3) 服务管理
        q) 退出
        ==================================
        EOF
        read -p "请选择: " -r op
        case "$op" in
            1) menu_sys ;;
            2) menu_user ;;
            3) if command -v systemctl &>/dev/null; then
                   systemctl list-units --type=service --state=running | head -20   # CentOS 7
               else
                   service --status-all 2>/dev/null | head -20                      # CentOS 6
               fi; pause ;;
            q|Q) echo "退出"; exit 0 ;;
            *) echo "无效选择"; sleep 1 ;;
        esac
    done
}

# 必须先定义函数再调用
[ "$(id -u)" -eq 0 ] || echo "提示: 部分功能需要 root 权限"
main_menu

要点:每一级菜单封装成一个函数,用 while true 保持循环,return 返回上一级,exit 直接终止整个脚本;三级以上菜单只需继续嵌套调用即可,无需嵌套 case;用 select 语句可以自动生成带编号的菜单(见 §九流程控制)。

九、综合编程题

综合题 1 打印九九乘法表。

#!/bin/bash
# 方式一:双层 for,右下三角
for ((i=1; i<=9; i++)); do
    for ((j=1; j<=i; j++)); do
        printf '%d*%d=%-4d' "$j" "$i" $((i*j))
    done
    echo
done

# 方式二:while 循环
i=1
while [ "$i" -le 9 ]; do
    j=1
    while [ "$j" -le "$i" ]; do
        echo -ne "$j*$i=$((i*j))\t"
        j=$((j+1))
    done
    echo
    i=$((i+1))
done

# 方式三:完整 9x9 矩阵
for i in {1..9}; do
    for j in {1..9}; do
        printf '%2d*%d=%-3d ' "$j" "$i" $((i*j))
    done
    echo
done

# 方式四:用 seq(可移植到不支持 {1..9} 的老 sh)
for i in $(seq 1 9); do
    for j in $(seq 1 "$i"); do
        echo -n "$j*$i=$((i*j))  "
    done
    echo
done

综合题 2 完善猜数字游戏(见 PPP10 答案,此处给出带排行榜与难度选择的完整版)。

#!/bin/bash
readonly SCORE_FILE="$HOME/.guess_score"

choose_level() {
    cat <<-EOF
    请选择难度:
    1) 简单   范围 1-50    机会 8 次
    2) 普通   范围 1-100   机会 7 次
    3) 困难   范围 1-1000  机会 10 次
    EOF
    read -p "选择 [1-3]: " -r lv
    case "$lv" in
        1) RANGE=50;   CHANCE=8  ;;
        3) RANGE=1000; CHANCE=10 ;;
        *) RANGE=100;  CHANCE=7  ;;
    esac
}

play() {
    local target=$((RANDOM % RANGE + 1))
    local count=0 guess
    echo "我想了一个 1-$RANGE 的数字,你有 $CHANCE 次机会"
    while [ "$count" -lt "$CHANCE" ]; do
        read -p "第 $((count+1))/$CHANCE 次猜测: " -r guess
        if ! [[ "$guess" =~ ^[0-9]+$ ]]; then
            echo "  请输入正整数(本次不计入次数)"; continue
        fi
        if [ "$guess" -lt 1 ] || [ "$guess" -gt "$RANGE" ]; then
            echo "  超出范围 1-$RANGE(本次不计入次数)"; continue
        fi
        count=$((count+1))
        if [ "$guess" -eq "$target" ]; then
            echo "  恭喜!答案就是 $target,你用了 $count 次"
            echo "$(date '+%F %T') 范围1-$RANGE 用了${count}次" >> "$SCORE_FILE"
            return 0
        elif [ "$guess" -gt "$target" ]; then
            echo "  太大了"
        else
            echo "  太小了"
        fi
    done
    echo "机会用完,正确答案是 $target"
    return 1
}

while true; do
    choose_level
    play
    [ -s "$SCORE_FILE" ] && { echo "--- 最近 5 局记录 ---"; tail -5 "$SCORE_FILE"; }
    read -p "再来一局? [Y/n] " -r again
    case "$again" in n|N|no|NO) echo "再见"; exit 0 ;; esac
done

综合题 3 输入年份判断是否闰年。

#!/bin/bash
# 闰年规则:能被 4 整除且不能被 100 整除,或者能被 400 整除
read -p "请输入年份: " -r year
[[ "$year" =~ ^[0-9]{1,4}$ ]] || { echo "请输入 1-4 位数字年份"; exit 1; }

if [ $((year % 4)) -eq 0 ] && [ $((year % 100)) -ne 0 ] || [ $((year % 400)) -eq 0 ]; then
    echo "$year 是闰年,2 月有 29 天"
else
    echo "$year 不是闰年,2 月有 28 天"
fi

# 写成函数,供其他脚本复用
is_leap() {
    local y=$1
    (( (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 ))
}
is_leap 2000 && echo "2000 闰年"
is_leap 1900 || echo "1900 平年"

# 借助 date 命令验证(最可靠:直接问系统 2 月 29 日是否存在)
check_by_date() {
    date -d "$1-02-29" +%F &>/dev/null && echo "$1 是闰年" || echo "$1 不是闰年"
}
check_by_date 2024
# 或者看该年的天数
echo "2024 年共 $(date -d "2024-12-31" +%j) 天"

# 批量列出某区间内的闰年
for y in {2000..2030}; do
    is_leap "$y" && echo -n "$y "
done; echo

综合题 4 百钱买百鸡:公鸡 5 元一只,母鸡 3 元一只,小鸡 1 元三只,用 100 元买 100 只鸡,列出所有可能的组合。

#!/bin/bash
# 设公鸡 x 只、母鸡 y 只、小鸡 z 只
#   x + y + z = 100
#   5x + 3y + z/3 = 100  →  15x + 9y + z = 300
echo "公鸡  母鸡  小鸡"
for ((x=0; x<=20; x++)); do            # 100/5 = 20,公鸡最多 20 只
    for ((y=0; y<=33; y++)); do        # 100/3 = 33,母鸡最多 33 只
        z=$((100 - x - y))
        # 小鸡数必须是 3 的倍数(三只一元),且总价正好 100 元
        if [ $((z % 3)) -eq 0 ] && [ $((15*x + 9*y + z)) -eq 300 ]; then
            printf '%4d %5d %5d\n' "$x" "$y" "$z"
        fi
    done
done

# 优化:由方程消元,只需一层循环
echo "--- 单层循环版 ---"
for ((x=0; x<=20; x++)); do
    # 由 x+y+z=100 与 15x+9y+z=300 相减得 14x+8y=200 → y=(200-14x)/8
    (( (200 - 14*x) % 8 == 0 )) || continue
    y=$(( (200 - 14*x) / 8 ))
    z=$((100 - x - y))
    if [ "$y" -ge 0 ] && [ "$z" -ge 0 ]; then
        printf '公鸡=%d 母鸡=%d 小鸡=%d 总价=%s元\n' "$x" "$y" "$z" \
            "$(echo "scale=0; 5*$x + 3*$y + $z/3" | bc)"
    fi
done

输出结果:

公鸡  母鸡  小鸡
   0    25    75
   4    18    78
   8    11    81
  12     4    84

综合题 5 黑色星期五:已知 1900 年 1 月 1 日是星期一,求今年有多少个黑色星期五(每月 13 号恰好是星期五)。

#!/bin/bash
year=${1:-$(date +%Y)}

echo "===== 方式一:用 date 命令直接算(推荐) ====="
count=0
for m in {1..12}; do
    # %w 返回星期几(0=周日 ... 5=周五 6=周六)
    w=$(date -d "${year}-${m}-13" +%w)
    if [ "$w" -eq 5 ]; then
        echo "  ${year}-$(printf '%02d' "$m")-13 是黑色星期五"
        count=$((count+1))
    fi
done
echo "$year 年共有 $count 个黑色星期五"

echo "===== 方式二:从 1900-01-01 是星期一手工推算 ====="
is_leap() { local y=$1; (( (y%4==0 && y%100!=0) || y%400==0 )); }

# 计算从 1900-01-01 到目标日期一共过了多少天
days_from_1900() {
    local y=$1 m=$2 d=$3 total=0 i
    local -a md=(0 31 28 31 30 31 30 31 31 30 31 30 31)
    for ((i=1900; i<y; i++)); do
        if is_leap "$i"; then total=$((total+366)); else total=$((total+365)); fi
    done
    for ((i=1; i<m; i++)); do
        total=$((total + md[i]))
        [ "$i" -eq 2 ] && is_leap "$y" && total=$((total+1))
    done
    total=$((total + d - 1))       # 1900-01-01 当天算第 0 天
    echo "$total"
}

count2=0
for m in {1..12}; do
    n=$(days_from_1900 "$year" "$m" 13)
    # 1900-01-01 是星期一,(n + 1) % 7 == 5 即为星期五(周一=1 ... 周五=5)
    w=$(( (n + 1) % 7 ))
    if [ "$w" -eq 5 ]; then
        echo "  ${year}-$(printf '%02d' "$m")-13 是黑色星期五(距 1900-01-01 第 $n 天)"
        count2=$((count2+1))
    fi
done
echo "手工推算结果: $count2 个"

echo "===== 方式三:用 cal 命令验证 ====="
for m in {1..12}; do
    # cal 输出中 13 号所在的列即代表星期几
    cal "$m" "$year" | awk -v m="$m" '
        NR>2 { for (i=1; i<=NF; i++) if ($i==13) print m"月13日在第"i"列" }' 2>/dev/null | head -1
done

综合题 6 右对齐直角三角形:输入边长,循环打印右对齐的直角三角形。

题目示例:

输入: 5
        *
      * *
    * * *
  * * * *
* * * * *
#!/bin/bash
n=${1:-5}
[[ "$n" =~ ^[1-9][0-9]*$ ]] || { echo "用法: $0 正整数"; exit 1; }

echo "=== 方式一:先打印空格再打印星号 ==="
for ((i=1; i<=n; i++)); do
    # 第 i 行有 (n-i) 组 "  " 前导空格,i 个 "* "
    for ((j=1; j<=n-i; j++)); do echo -n "  "; done
    for ((j=1; j<=i; j++));   do echo -n "* "; done
    echo
done

echo "=== 方式二:用 printf 一次补齐宽度 ==="
for ((i=1; i<=n; i++)); do
    stars=$(printf '* %.0s' $(seq 1 "$i"))    # 生成 i 个 "* "
    printf '%*s\n' $((n*2)) "$stars"          # %*s 用变量指定宽度,右对齐
done

echo "=== 方式三:字符串累加 + 空格计算 ==="
line=""
for ((i=1; i<=n; i++)); do
    line="$line* "
    space=""
    for ((j=1; j<=n-i; j++)); do space="$space  "; done
    echo "${space}${line}"
done

echo "=== 方式四:seq + tr 生成,无内层循环 ==="
for ((i=1; i<=n; i++)); do
    pad=$(printf "%$(( (n-i)*2 ))s" "")
    row=$(printf "%${i}s" | sed 's/ /* /g')
    echo "${pad}${row}"
done

综合题 7 三天打鱼两天晒网:老王从 2000 年 1 月 1 日起"三天打鱼两天晒网",输入年月日,判断这一天他在打鱼还是晒网。

#!/bin/bash
# 周期为 5 天:第 1、2、3 天打鱼,第 4、5 天晒网
read -p "请输入日期(格式 YYYY-MM-DD): " -r input

# 校验日期合法性:交给 date 判断,非法日期会报错
if ! date -d "$input" +%F &>/dev/null; then
    echo "日期格式不正确或日期不存在"; exit 1
fi

start="2000-01-01"
# date +%s 得到 Unix 时间戳(秒),相差的秒数除以 86400 得到天数
s1=$(date -d "$start" +%s)
s2=$(date -d "$input" +%s)
if [ "$s2" -lt "$s1" ]; then
    echo "日期早于 2000-01-01,不在统计范围内"; exit 1
fi
days=$(( (s2 - s1) / 86400 ))          # 2000-01-01 当天为第 0 天
day_in_cycle=$(( days % 5 + 1 ))       # 转换为周期内第几天(1-5)

echo "距 2000-01-01 共 $days 天,处于 5 天周期的第 $day_in_cycle 天"
if [ "$day_in_cycle" -le 3 ]; then
    echo "$input 老王在打鱼"
else
    echo "$input 老王在晒网"
fi

# 不依赖 date -d 的纯计算版本(老系统 date 可能不支持 -d)
days_from() {                          # 计算某日期距 2000-01-01 的天数
    local y=$1 m=$2 d=$3 total=0 i
    local -a md=(0 31 28 31 30 31 30 31 31 30 31 30 31)
    is_leap() { local yy=$1; (( (yy%4==0 && yy%100!=0) || yy%400==0 )); }
    for ((i=2000; i<y; i++)); do
        if is_leap "$i"; then total=$((total+366)); else total=$((total+365)); fi
    done
    for ((i=1; i<m; i++)); do
        total=$((total + md[i]))
        [ "$i" -eq 2 ] && is_leap "$y" && total=$((total+1))
    done
    echo $((total + d - 1))
}
IFS='-' read -r Y M D <<< "$input"
M=$((10#$M)); D=$((10#$D))             # 10# 强制十进制,避免 08 被当成八进制
n=$(days_from "$Y" "$M" "$D")
[ $(( n % 5 )) -lt 3 ] && echo "纯计算结果: 打鱼" || echo "纯计算结果: 晒网"

# 批量打印某个月的作息表
for d in {1..31}; do
    date_str=$(printf '2024-03-%02d' "$d")
    date -d "$date_str" &>/dev/null || continue
    n=$(( ($(date -d "$date_str" +%s) - s1) / 86400 ))
    [ $(( n % 5 )) -lt 3 ] && act="打鱼" || act="晒网"
    echo "$date_str $act"
done

综合题 8 实现 cal 命令功能:编写脚本模拟 cal 命令,打印指定年月的日历。

#!/bin/bash
# ============================================================================
# 功能: 模拟 cal 命令,打印指定年月的日历
# 用法: ./mycal.sh [月] [年]     不带参数则打印当月
# ============================================================================
is_leap() { local y=$1; (( (y%4==0 && y%100!=0) || y%400==0 )); }

# 每月天数
days_in_month() {
    local y=$1 m=$2
    local -a md=(0 31 28 31 30 31 30 31 31 30 31 30 31)
    if [ "$m" -eq 2 ] && is_leap "$y"; then echo 29; else echo "${md[$m]}"; fi
}

# 蔡勒公式:计算某日期是星期几(0=周日 ... 6=周六)
zeller() {
    local y=$1 m=$2 d=$3
    if [ "$m" -lt 3 ]; then m=$((m+12)); y=$((y-1)); fi
    local c=$((y / 100)) yy=$((y % 100))
    local w=$(( yy + yy/4 + c/4 - 2*c + (26*(m+1))/10 + d - 1 ))
    echo $(( (w % 7 + 7) % 7 ))       # 两次取模保证结果非负
}

month=${1:-$(date +%-m)}              # %-m 去掉前导零,避免八进制问题
year=${2:-$(date +%Y)}
month=$((10#$month)); year=$((10#$year))

[ "$month" -ge 1 ] && [ "$month" -le 12 ] || { echo "月份必须是 1-12"; exit 1; }

declare -a MONTH_NAME=("" 一月 二月 三月 四月 五月 六月 七月 八月 九月 十月 十一月 十二月)
total=$(days_in_month "$year" "$month")
first_w=$(zeller "$year" "$month" 1)  # 该月 1 号是星期几

# ---------- 打印表头(居中) ----------
title="${year}年${MONTH_NAME[$month]}"
printf '%*s\n' $(( (20 + ${#title}) / 2 )) "$title"
echo "日 一 二 三 四 五 六"

# ---------- 打印 1 号之前的空白 ----------
for ((i=0; i<first_w; i++)); do printf '   '; done

# ---------- 打印日期 ----------
today=$(date +%-d); this_m=$(date +%-m); this_y=$(date +%Y)
for ((d=1; d<=total; d++)); do
    if [ "$d" -eq "$today" ] && [ "$month" -eq "$this_m" ] && [ "$year" -eq "$this_y" ]; then
        printf '\e[7m%2d\e[0m ' "$d"   # 反显当天,与 cal 行为一致
    else
        printf '%2d ' "$d"
    fi
    # 每周六(第 7 列)换行
    if [ $(( (first_w + d) % 7 )) -eq 0 ]; then echo; fi
done
echo

# 与系统 cal 对比验证
echo "--- 系统 cal 输出 ---"
cal "$month" "$year"
# 打印整年日历(每行 3 个月,模拟 cal -y)
for m in {1..12}; do ./mycal.sh "$m" 2024; echo; done
cal -y 2024                # 系统命令
cal -3                     # 打印上月、本月、下月(CentOS 7 支持)

综合题 9 找出最小可用的普通用户 UID。

#!/bin/bash
# CentOS 6 普通用户 UID 从 500 开始,CentOS 7 从 1000 开始
# 读取 /etc/login.defs 中的 UID_MIN / UID_MAX,避免硬编码
UID_MIN=$(awk '/^UID_MIN/{print $2}' /etc/login.defs)
UID_MAX=$(awk '/^UID_MAX/{print $2}' /etc/login.defs)
UID_MIN=${UID_MIN:-500}
UID_MAX=${UID_MAX:-60000}
echo "本机普通用户 UID 范围: $UID_MIN - $UID_MAX"

echo "=== 方式一:从 UID_MIN 逐个试,第一个查不到的就是最小可用值 ==="
for ((u=UID_MIN; u<=UID_MAX; u++)); do
    if ! id -u "$u" &>/dev/null; then           # id 查不到该 UID 说明未被占用
        echo "最小可用 UID: $u"
        break
    fi
done

echo "=== 方式二:把已用 UID 读进数组,再找空缺 ==="
mapfile -t used < <(awk -F: -v min="$UID_MIN" -v max="$UID_MAX" \
    '$3>=min && $3<=max {print $3}' /etc/passwd | sort -n)
echo "已使用的普通 UID: ${used[*]:-无}"
expect=$UID_MIN
for u in "${used[@]}"; do
    if [ "$u" -ne "$expect" ]; then break; fi   # 出现断档,expect 即为可用值
    expect=$((expect+1))
done
echo "最小可用 UID: $expect"

echo "=== 方式三:用关联数组做 O(1) 查表 ==="
declare -A occupied
while IFS=: read -r _ _ uid _; do
    occupied[$uid]=1
done < /etc/passwd
for ((u=UID_MIN; u<=UID_MAX; u++)); do
    [ -z "${occupied[$u]:-}" ] && { echo "最小可用 UID: $u"; break; }
done

echo "=== 方式四:一行 awk ==="
awk -F: -v min="$UID_MIN" '$3>=min {a[$3]=1}
    END{ for (i=min; i<65535; i++) if (!(i in a)) { print "最小可用 UID:", i; exit } }' /etc/passwd

echo "=== 方式五:用 seq 与 comm 求差集 ==="
comm -23 <(seq "$UID_MIN" $((UID_MIN+100))) \
         <(awk -F: '{print $3}' /etc/passwd | sort -n | uniq) | head -1

# 实际建用户时直接指定
# useradd -u $(最小可用UID) newuser

综合题 10 批量重命名:自动给指定目录下所有文件或目录重命名,加上不重复的单字符前缀,字符集为 a-zA-Z0-9。

#!/bin/bash
# ============================================================================
# 功能: 给目录下的文件/目录加不重复的单字符前缀,如 a_file1 b_file2
# 用法: ./prefix_rename.sh 目录 [-n]     -n 为 dry-run 只显示不执行
# 注意: 字符集共 62 个(a-z A-Z 0-9),因此最多支持 62 个条目
# ============================================================================
dir=${1:-.}
dry_run=0
[ "$2" = "-n" ] && dry_run=1

[ -d "$dir" ] || { echo "目录不存在: $dir"; exit 1; }

# 构造字符集数组:a-z A-Z 0-9
chars=({a..z} {A..Z} {0..9})
echo "可用前缀字符共 ${#chars[@]} 个"

# 收集条目:用 find + while read 而不是 ls,正确处理含空格的文件名
declare -a items=()
while IFS= read -r -d '' f; do
    items+=("$(basename "$f")")
done < <(find "$dir" -mindepth 1 -maxdepth 1 -print0 | sort -z)

count=${#items[@]}
echo "目录 $dir 下共 $count 个条目"
if [ "$count" -gt "${#chars[@]}" ]; then
    echo "条目数超过 ${#chars[@]},单字符前缀不够用,请改用双字符前缀"
    exit 1
fi

i=0
for name in "${items[@]}"; do
    prefix="${chars[$i]}"
    newname="${prefix}_${name}"
    # 避免覆盖已存在的目标文件
    if [ -e "${dir}/${newname}" ]; then
        echo "跳过(目标已存在): $newname"
        i=$((i+1)); continue
    fi
    if [ "$dry_run" -eq 1 ]; then
        echo "[dry-run] mv '${name}' -> '${newname}'"
    else
        mv -- "${dir}/${name}" "${dir}/${newname}" && echo "已改名: $name -> $newname"
    fi
    i=$((i+1))
done

# 随机分配前缀(不重复)的版本:先打乱字符集
echo "--- 随机不重复前缀 ---"
mapfile -t shuffled < <(printf '%s\n' "${chars[@]}" | shuf)
i=0
for name in "${items[@]}"; do
    echo "[dry-run] mv '${name}' -> '${shuffled[$i]}_${name}'"
    i=$((i+1))
done

# 撤销:去掉已加的单字符前缀
undo() {
    local d=${1:-.}
    for f in "$d"/*; do
        base=$(basename "$f")
        # 匹配"单个字母数字 + 下划线"开头的文件名
        if [[ "$base" =~ ^[a-zA-Z0-9]_(.+)$ ]]; then
            mv -- "$f" "$d/${BASH_REMATCH[1]}" && echo "还原: $base -> ${BASH_REMATCH[1]}"
        fi
    done
}
# undo /tmp/testdir

# 其他常用批量改名手法
# 1) 用 rename 命令(perl 版,CentOS 自带的是 util-linux 版,语法不同)
#    CentOS 的 rename:rename 旧串 新串 文件...
#    rename .txt .bak *.txt
# 2) 用参数扩展批量换扩展名
#    for f in *.txt; do mv -- "$f" "${f%.txt}.bak"; done
# 3) 批量加日期后缀
#    for f in *.log; do mv -- "$f" "${f}.$(date +%F)"; done
# 4) 批量转小写(bash 4.0+)
#    for f in *; do mv -- "$f" "${f,,}"; done

综合题 11 网段存活探测:测试 192.168.2.0 网段中哪些 IP 正在使用,输出所有在用 IP。

#!/bin/bash
# ============================================================================
# 功能: 探测指定网段存活主机
# 用法: ./ping_scan.sh [网段前缀] [并发数]
# 示例: ./ping_scan.sh 192.168.2 50
# ============================================================================
net=${1:-192.168.2}
parallel=${2:-50}
result=$(mktemp /tmp/pingscan.XXXXXX)
trap 'rm -f "$result"' EXIT

echo "=== 方式一:串行探测(最慢,254 个地址约需 4 分钟) ==="
# for i in {1..254}; do
#     ping -c 1 -W 1 "${net}.${i}" &>/dev/null && echo "${net}.${i} is up"
# done

echo "=== 方式二:并发探测(推荐) ==="
# -c 1 只发一个包  -W 1 等待 1 秒超时  -w 1 总超时(CentOS 的 ping 支持)
for i in $(seq 1 254); do
    {
        if ping -c 1 -W 1 "${net}.${i}" &>/dev/null; then
            echo "${net}.${i}" >> "$result"
        fi
    } &
    # 控制并发数,避免同时起 254 个进程
    while [ "$(jobs -rp | wc -l)" -ge "$parallel" ]; do
        sleep 0.1
    done
done
wait                                    # 等所有后台任务结束

echo "--- 存活主机列表(共 $(wc -l < "$result") 台)---"
sort -t. -k4 -n "$result"

echo "=== 方式三:用 fping 批量探测(需 yum install fping,速度最快) ==="
# fping -a -g "${net}.1" "${net}.254" 2>/dev/null

echo "=== 方式四:用 nmap 探测(更准确,能穿透部分防 ping 主机) ==="
# nmap -sn "${net}.0/24" | awk '/Nmap scan report/{print $NF}' | tr -d '()'

echo "=== 方式五:用 arping 探测同网段(不依赖 ICMP,防火墙挡不住) ==="
# for i in {1..254}; do arping -c 1 -w 1 "${net}.${i}" &>/dev/null && echo "${net}.${i} up"; done

echo "=== 方式六:查看 ARP 缓存(探测后立即查,能发现刚通信过的主机) ==="
arp -n | awk '/ether/{print $1, $3}' 2>/dev/null || ip neigh | awk '/lladdr/{print $1, $5}'

echo "=== 顺便:对存活主机做端口探测 ==="
while read -r ip; do
    for port in 22 80 443 3306; do
        # /dev/tcp 是 Bash 内置的伪设备,无需 nc 也能测端口
        (echo >"/dev/tcp/${ip}/${port}") &>/dev/null \
            && echo "  $ip:$port 开放"
    done
done < "$result"

while [ "$(jobs -rp | wc -l)" -ge "$parallel" ] 是纯 Bash 的并发闸门写法;也可用 xargs -P 实现:seq 1 254 | xargs -P 50 -I{} sh -c 'ping -c1 -W1 192.168.2.{} &>/dev/null && echo 192.168.2.{}'。

十、综合练习(另一份讲义)

练习 A(if 成绩判断):read 取值后用 if / elif / else 构成数值判断阶梯。

read -p "score: " score
if [ "$score" -ge 90 ]; then
    echo "A"
elif [ "$score" -ge 80 ]; then
    echo "B"
elif [ "$score" -ge 70 ]; then
    echo "C"
else
    echo "D"
fi

练习 B(猜数字游戏):综合运用 $RANDOM(Bash 内置的 0 ~ 32767 随机数变量)、while 循环、read 交互与 exit。

#!/bin/bash
target=$((RANDOM % 100 + 1))   # 1 ~ 100 的随机数
max=$((RANDOM % 4 + 4))        # 4 ~ 7 次随机猜测机会
count=0
while [ $count -lt $max ]; do
    read -p "guess: " guess
    if [ "$guess" -eq "$target" ]; then
        echo "right!"
        exit 0
    elif [ "$guess" -gt "$target" ]; then
        echo "too big"
    else
        echo "too small"
    fi
    ((count++))
done
echo "game over"

该脚本缺少输入校验:一旦输入非数字,[ "$guess" -eq "$target" ] 会直接报 integer expression expected 并继续消耗次数。生产脚本中应先用 [[ "$guess" =~ ^[0-9]+$ ]] 之类的判断过滤非法输入。

练习 C(find 多条件综合):

find / -size +5M -perm 644 -user root -type f 2>/dev/null -exec ls -hl {} \;

find 的多个条件默认是「与」关系,可任意叠加。全盘查找时普通用户必然遇到无权访问的目录,用 2>/dev/null 把标准错误丢弃,即可屏蔽大量 Permission denied 干扰信息。

练习 D(sed 字段换位):用反向引用把 /etc/passwd 每行的首末字段互换。

cat /etc/passwd > 1_passwd.txt
sed -r 's/^([^:]+)([^a-zA-Z]+)([^:]+)(.*)\2([^:]+)$/\1\2\5\4\2\3/' 1_passwd.txt

五个子表达式依次对应「首字段、分隔符、第 2 字段、中间内容、末字段」,替换部分把 \3 与 \5 调换位置即完成换位;\2 在匹配模式中被复用,保证前后分隔符一致。详见 06-02-文本处理与文件查找.md。

练习 E(awk 三题):

awk -F: '$3 >= 800 && $3 <= 1000 {print $1}' /etc/passwd   # UID 在 800~1000 的用户
awk -F: 'NR >= 6 && NR <= 9 {print $0}' /etc/passwd        # 打印第 6~9 行
awk -F: 'END{print $NF}' /etc/passwd                       # 最后一行的最后一个字段

END 块中 $0 及各字段仍保留最后一条记录的内容,因此 END{print $NF} 取到的是末行末字段。详见 06-02-文本处理与文件查找.md。

十一、本章新增知识点动手练习

练习 1 位置参数与 getopts:编写脚本 backup.sh,要求支持 -s 源目录、-d 目标目录、-k 保留天数、-v 详细输出、-h 帮助 五个选项;缺少必填选项时打印用法并以状态码 1 退出;打印出所有未被 getopts 处理的剩余参数。

答案:

#!/bin/bash
SRC=""; DST=""; KEEP=7; VERBOSE=0

usage() {
    cat >&2 <<-EOF
    用法: $(basename "$0") -s 源目录 -d 目标目录 [-k 保留天数] [-v] [-h]
      -s DIR   源目录(必填)
      -d DIR   目标目录(必填)
      -k NUM   保留天数,默认 7
      -v       详细输出
      -h       显示本帮助
    EOF
    exit 1
}

# 开头的冒号表示静默模式:错误由脚本自己处理,不打印 bash 的默认提示
# 选项后跟冒号表示该选项需要参数,参数值放在 $OPTARG 中
while getopts ":s:d:k:vh" opt; do
    case "$opt" in
        s) SRC="$OPTARG" ;;
        d) DST="$OPTARG" ;;
        k) KEEP="$OPTARG" ;;
        v) VERBOSE=1 ;;
        h) usage ;;
        :) echo "错误: -$OPTARG 选项缺少参数" >&2; usage ;;   # 缺参数时 opt 为 :
        \?) echo "错误: 未知选项 -$OPTARG" >&2; usage ;;      # 未知选项时 opt 为 ?
    esac
done
shift $((OPTIND - 1))            # 丢弃已处理的选项,让 $1 指向第一个非选项参数

[ -n "$SRC" ] && [ -n "$DST" ] || { echo "错误: -s 与 -d 为必填项" >&2; usage; }
[[ "$KEEP" =~ ^[0-9]+$ ]] || { echo "错误: -k 必须是数字" >&2; exit 1; }

[ "$VERBOSE" -eq 1 ] && set -x
echo "源目录=$SRC 目标=$DST 保留=${KEEP}天 详细=$VERBOSE"
echo "剩余参数共 $# 个: $*"
for arg in "$@"; do echo "  剩余: $arg"; done
# 验证
./backup.sh -s /data -d /backup -k 14 -v extra1 extra2
./backup.sh -s /data                  # 报错:-d 为必填
./backup.sh -s                        # 报错:-s 缺少参数
./backup.sh -x                        # 报错:未知选项
./backup.sh -s /data -d /backup -- -notanoption   # -- 之后不再解析选项

练习 2 数组与关联数组统计:统计 /etc/passwd 中每种登录 Shell 各有多少用户,按数量从多到少输出;再用索引数组保存所有 UID 大于等于 1000 的用户名,输出其个数、第一个和最后一个元素。

答案:

#!/bin/bash
declare -A shell_count                 # 关联数组必须先用 declare -A 声明

# 用 while read 按行读取,IFS=: 直接完成字段切分
while IFS=: read -r user _ uid _ _ _ shell; do
    # 关联数组当计数器:键不存在时 ${var:-0} 提供初值
    shell_count["$shell"]=$(( ${shell_count["$shell"]:-0} + 1 ))
done < /etc/passwd

echo "===== 各 Shell 用户数(降序) ====="
# 关联数组遍历顺序由哈希决定,要排序必须借助外部 sort
for sh in "${!shell_count[@]}"; do
    printf '%s %s\n' "${shell_count[$sh]}" "$sh"
done | sort -rn | while read -r cnt sh; do
    printf '%-30s %3d 个用户\n' "$sh" "$cnt"
done

echo "键的个数: ${#shell_count[@]}"
echo "所有键:   ${!shell_count[@]}"
echo "所有值:   ${shell_count[@]}"
echo "bash 用户数: ${shell_count[/bin/bash]:-0}"
# 判断某个键是否存在
[ -v 'shell_count[/bin/false]' ] && echo "存在 /bin/false 用户" || echo "无 /bin/false 用户"

echo "===== UID >= 1000 的用户(索引数组) ====="
declare -a normal=()
while IFS=: read -r user _ uid _; do
    [ "$uid" -ge 1000 ] && [ "$uid" -lt 60000 ] && normal+=("$user")
done < /etc/passwd

echo "个数:       ${#normal[@]}"
echo "全部:       ${normal[*]}"
echo "第一个:     ${normal[0]}"
echo "最后一个:   ${normal[${#normal[@]}-1]}"        # 通用写法
echo "最后一个:   ${normal[-1]}"                     # bash 4.3+ 才支持
echo "下标列表:   ${!normal[@]}"
echo "第 2-4 个:  ${normal[@]:1:3}"                  # 切片

# 用 mapfile 一步读入数组(bash 4.0+,比循环快很多)
mapfile -t normal2 < <(awk -F: '$3>=1000 && $3<60000 {print $1}' /etc/passwd)
echo "mapfile 读入 ${#normal2[@]} 个用户"
declare -p normal2

练习 3 字符串处理取文件名与扩展名:给定路径 /var/log/nginx/access.log.2024-03-15.gz,用参数扩展分别取出:目录名、完整文件名、去掉最后一个扩展名的部分、最后一个扩展名、第一个点之前的主名、所有扩展名;再把文件名中的 - 全部换成 _,并把主名转为大写。

答案:

#!/bin/bash
path="/var/log/nginx/access.log.2024-03-15.gz"

echo "原始路径:       $path"
echo "长度:           ${#path}"
echo "目录名:         ${path%/*}"          # 去掉最后一个 / 及其后内容
echo "文件名:         ${path##*/}"         # 去掉最后一个 / 及其前内容
file="${path##*/}"
echo "去掉末扩展名:   ${file%.*}"          # % 最短匹配,从右删
echo "最后扩展名:     ${file##*.}"         # ## 最长匹配,从左删
echo "第一个点前主名: ${file%%.*}"         # %% 最长匹配,从右删
echo "所有扩展名:     ${file#*.}"          # # 最短匹配,从左删
echo "- 换成 _:       ${file//-/_}"        # // 全部替换
echo "主名转大写:     ${file%%.*}"" -> ${file%%.*}"
name="${file%%.*}"
echo "主名转大写:     ${name^^}"           # bash 4.0+,^^ 全大写
echo "主名首字母大写: ${name^}"            # ^ 只转首字母
echo "全部转小写:     ${file,,}"           # ,, 全小写

echo "--- 用外部命令的等价做法 ---"
echo "dirname:  $(dirname "$path")"
echo "basename: $(basename "$path")"
echo "去扩展名: $(basename "$path" .gz)"   # basename 可指定要去掉的后缀
echo "转大写:   $(echo "$name" | tr 'a-z' 'A-Z')"

echo "--- 循环处理一批文件 ---"
for f in /var/log/*.log; do
    [ -e "$f" ] || continue                # 无匹配时通配符原样保留,需判断
    base="${f##*/}"
    echo "  $base  ->  ${base%.log}.bak"
done

练习 4 函数与 local 作用域:编写函数 count_lines,接收若干文件名,返回总行数(用 echo 输出);在函数内部用 local 声明所有变量,并在函数外验证全局同名变量未被污染。

答案:

#!/bin/bash
total=0                                    # 全局变量,故意与函数内同名
file="全局的 file 变量"

count_lines() {
    # local 声明的变量只在函数及其调用的子函数中可见(动态作用域)
    local total=0                          # 遮蔽全局 total
    local file n
    for file in "$@"; do
        if [ ! -f "$file" ]; then
            echo "跳过不存在的文件: $file" >&2   # 提示信息必须走 stderr,
            continue                             # 否则会混进 $( ) 捕获的结果
        fi
        n=$(wc -l < "$file")
        total=$((total + n))
        echo "  $file: $n 行" >&2
    done
    echo "$total"                          # 唯一走 stdout 的就是返回值
    return 0                               # return 只表示成功/失败
}

result=$(count_lines /etc/passwd /etc/group /etc/nosuchfile)
echo "函数返回的总行数: $result"
echo "函数退出状态: $?"
echo "全局 total 仍是: $total"             # 0,未被污染
echo "全局 file 仍是: $file"               # 保持原值

# 反面示范:不加 local 会污染全局
bad_func() { total=99999; file="被改掉了"; }
bad_func
echo "调用未加 local 的函数后 total=$total file=$file"

# local 的坑:local x=$(cmd) 时,$? 是 local 的状态而非 cmd 的状态
trap_demo() {
    local out
    out=$(false)                           # 分两行写,$? 才是 false 的状态
    echo "分开写: \$?=$?"                   # 1,正确
    local out2=$(false)
    echo "合并写: \$?=$?"                   # 0,被 local 的返回值覆盖,错误
}
trap_demo

练习 5 trap 清理临时文件:编写脚本,创建两个临时文件和一个锁文件,无论正常结束、被 Ctrl+C 中断、还是被 kill 终止,都要保证临时文件与锁文件被清理;验证 kill -9 时无法清理。

答案:

#!/bin/bash
# mktemp 创建带随机后缀的临时文件,避免被预测和符号链接攻击
TMP1=$(mktemp /tmp/demo.XXXXXX)
TMP2=$(mktemp /tmp/demo.XXXXXX)
LOCK="/var/tmp/demo.lock"

cleanup() {
    local rc=$?                            # 必须第一行保存原始退出码
    echo ""
    echo "[cleanup] 触发清理,退出码 $rc"
    rm -f -- "$TMP1" "$TMP2"
    rm -f -- "$LOCK"
    echo "[cleanup] 已删除 $TMP1 $TMP2 $LOCK"
    exit "$rc"
}

# EXIT 是伪信号,脚本以任何方式正常退出都会触发
# 同时捕获 INT(Ctrl+C) TERM(kill) HUP(终端关闭) QUIT
trap cleanup EXIT INT TERM HUP QUIT

# 用 noclobber 实现原子性加锁:文件已存在时重定向失败
set -o noclobber
if ! echo $$ > "$LOCK" 2>/dev/null; then
    old_pid=$(cat "$LOCK" 2>/dev/null)
    # kill -0 只检测进程是否存在,不真正发信号
    if kill -0 "$old_pid" 2>/dev/null; then
        echo "已有实例在运行(PID $old_pid),退出"
        trap - EXIT                        # 取消 EXIT 陷阱,避免删掉别人的锁
        exit 1
    fi
    echo "发现残留锁文件(PID $old_pid 已不存在),清理后继续"
    rm -f "$LOCK"; echo $$ > "$LOCK"
fi
set +o noclobber

echo "锁文件: $LOCK(内容 $(cat "$LOCK"))"
echo "临时文件: $TMP1 $TMP2"
echo "当前已注册的陷阱:"
trap -p

echo "开始工作,请尝试 Ctrl+C 或在另一个终端执行 kill $$"
for i in {1..30}; do
    echo "data $i" >> "$TMP1"
    echo "  工作中 $i/30 ..."
    sleep 1
done
echo "正常完成"
# 验证
./trap_demo.sh                # 正常跑完,EXIT 触发清理
./trap_demo.sh                # 运行中按 Ctrl+C,INT 触发清理
kill $(pgrep -f trap_demo)    # TERM 触发清理
kill -9 $(pgrep -f trap_demo) # SIGKILL 无法被捕获,临时文件残留!
ls /tmp/demo.*                # 能看到残留文件,说明 -9 绕过了 trap

要点:SIGKILL(9) 与 SIGSTOP(19) 是唯一两个不能被捕获、阻塞或忽略的信号,因此不能依赖 trap 处理 kill -9;对应做法是启动时先清理上次残留(用 mktemp -d 配合固定前缀 + 开机清理任务)。用 flock 加锁可以避免锁文件残留问题,因为文件锁随进程结束由内核自动释放:

exec 200>/var/lock/demo.lock
flock -n 200 || { echo "已有实例在运行"; exit 1; }
echo $$ >&200
# 后续代码,进程退出后锁自动释放,即使 kill -9 也不会留下"死锁"

练习 6 here document 生成配置:用 here document 生成一个 Nginx 虚拟主机配置文件,其中域名、端口、根目录来自变量;再用带引号的定界符生成一个内含 $ 的 Shell 脚本模板,验证变量不被展开。

答案:

#!/bin/bash
DOMAIN="www.example.com"
PORT=8080
ROOT="/data/www/example"
LOG_DIR="/var/log/nginx"

mkdir -p "$ROOT" "$LOG_DIR"

# ---------- 不加引号:变量、命令替换都会被展开 ----------
cat > /etc/nginx/conf.d/${DOMAIN}.conf <<EOF
# 由脚本自动生成于 $(date '+%F %T'),请勿手工修改
server {
    listen       ${PORT};
    server_name  ${DOMAIN};
    root         ${ROOT};
    index        index.html index.htm;

    access_log   ${LOG_DIR}/${DOMAIN}.access.log main;
    error_log    ${LOG_DIR}/${DOMAIN}.error.log warn;

    location / {
        try_files \$uri \$uri/ =404;
    }
    location ~* \.(gif|jpg|png|css|js)\$ {
        expires 30d;
    }
}
EOF
echo "已生成配置,内容如下:"
cat /etc/nginx/conf.d/${DOMAIN}.conf

# ---------- 加引号:完全按字面输出,$ 不需要转义 ----------
cat > /tmp/template.sh <<'EOF'
#!/bin/bash
# 这是一个模板脚本,其中的变量在生成时不展开
echo "脚本名: $0"
echo "参数个数: $#"
echo "当前用户: $(whoami)"
echo "PID: $$"
for f in "$@"; do
    echo "处理 $f"
done
EOF
chmod +x /tmp/template.sh
echo "--- 模板内容($ 保持原样) ---"
cat /tmp/template.sh

# ---------- <<- 允许用 Tab 缩进(注意只忽略 Tab,不忽略空格) ----------
generate_hosts() {
    cat <<-EOF
        127.0.0.1   localhost
        192.168.1.10 web01
        192.168.1.11 web02
        192.168.1.20 db01
    EOF
}
generate_hosts >> /etc/hosts

# ---------- here string:单行输入更简洁 ----------
read -r a b c <<< "one two three"
echo "a=$a b=$b c=$c"
grep -c bash <<< "$(cat /etc/passwd)"

# ---------- 常见用法:给交互式命令喂输入 ----------
# 修改密码(CentOS 6/7 通用)
# passwd user01 <<EOF
# newpass123
# newpass123
# EOF
# CentOS 更推荐:echo "newpass123" | passwd --stdin user01

# 批量执行 SQL
# mysql -uroot -p"$PASS" <<'SQL'
# CREATE DATABASE IF NOT EXISTS testdb DEFAULT CHARSET utf8mb4;
# GRANT ALL ON testdb.* TO 'app'@'192.168.1.%' IDENTIFIED BY 'apppass';
# FLUSH PRIVILEGES;
# SQL

# 非交互分区
# fdisk /dev/sdb <<EOF
# n
# p
# 1
#
# +10G
# w
# EOF

练习 7 管道中变量丢失的排查与修复:以下脚本统计 /etc/passwd 中使用 /sbin/nologin 的用户数,但结果始终为 0,请解释原因并给出三种修复方案。

#!/bin/bash
count=0
cat /etc/passwd | while IFS=: read -r user _ _ _ _ _ shell; do
    [ "$shell" = "/sbin/nologin" ] && count=$((count+1))
done
echo "统计结果: $count"       # 永远输出 0

答案:

# 原因:管道的每一段都在独立的子 Shell 中运行,while 循环里对 count 的修改
#       只作用于子 Shell 的变量副本;子 Shell 结束后修改随之丢失,
#       父 Shell 的 count 仍是 0。这是 Shell 编程最经典的陷阱之一。

# ---------- 修复方案一:用输入重定向代替管道(最简单,推荐) ----------
count=0
while IFS=: read -r user _ _ _ _ _ shell; do
    [ "$shell" = "/sbin/nologin" ] && count=$((count+1))
done < /etc/passwd            # 重定向不产生子 Shell
echo "方案一: $count"

# ---------- 修复方案二:进程替换(命令输出需要预处理时用这个) ----------
count=0
while IFS=: read -r user _ _ _ _ _ shell; do
    [ "$shell" = "/sbin/nologin" ] && count=$((count+1))
done < <(cat /etc/passwd)     # < <() 中间必须有空格
echo "方案二: $count"

# ---------- 修复方案三:开启 lastpipe(bash 4.2+,让管道最后一段在当前 Shell 执行) ----------
shopt -s lastpipe
set +m                        # lastpipe 在交互式 Shell 中需先关闭作业控制
count=0
cat /etc/passwd | while IFS=: read -r user _ _ _ _ _ shell; do
    [ "$shell" = "/sbin/nologin" ] && count=$((count+1))
done
echo "方案三: $count"

# ---------- 修复方案四:把结果通过 stdout 传出来 ----------
count=$(cat /etc/passwd | while IFS=: read -r _ _ _ _ _ _ shell; do
    [ "$shell" = "/sbin/nologin" ] && echo x
done | wc -l)
echo "方案四: $count"

# ---------- 修复方案五:干脆不用循环 ----------
echo "方案五: $(awk -F: '$7=="/sbin/nologin"' /etc/passwd | wc -l)"
echo "方案五: $(grep -c ':/sbin/nologin$' /etc/passwd)"

# 验证子 Shell 现象
echo "--- 子 Shell 验证 ---"
x=1; echo hi | { x=2; echo "管道内 x=$x  BASH_SUBSHELL=$BASH_SUBSHELL"; }
echo "管道外 x=$x  BASH_SUBSHELL=$BASH_SUBSHELL"

练习 8 验证 set -euo pipefail 的效果:分别验证 set -e、set -u、set -o pipefail 三个选项的作用,并至少举出两个 set -e 不生效的场景。

答案:

#!/bin/bash
echo "===== 验证 set -e:命令失败立即退出 ====="
(
    set -e
    echo "第 1 步"
    false                                  # 这里失败,脚本立即终止
    echo "第 2 步(不会执行)"
)
echo "子 Shell 退出码: $?"

echo "===== 验证 set -u:引用未定义变量报错退出 ====="
(
    set -u
    echo "已定义变量: $HOME"
    echo "未定义变量: $UNDEFINED_VAR"      # 报 unbound variable 并退出
    echo "不会执行"
) 2>&1 | tail -2

echo "===== 验证 set -o pipefail:管道任一环失败即视为失败 ====="
(
    false | true
    echo "无 pipefail 时管道退出码: $?"     # 0,只看最后一个命令
    set -o pipefail
    false | true
    echo "有 pipefail 时管道退出码: $?"     # 1,取第一个失败命令的码
    echo "PIPESTATUS 数组: ${PIPESTATUS[*]}"  # 各环节退出码
)

echo "===== set -e 不生效的场景 ====="
set -e
# 场景 1:命令出现在 && || 的左侧(属于条件上下文)
false && echo "never"
echo "场景1:false 在 && 左侧,脚本继续"

# 场景 2:命令出现在 if / while / until 的条件里
if false; then echo never; fi
echo "场景2:false 在 if 条件里,脚本继续"

# 场景 3:命令前加了 ! 取反
! false
echo "场景3:! false,脚本继续"

# 场景 4:管道中间环节失败(不加 pipefail 时)
false | true
echo "场景4:管道中间失败,脚本继续"

# 场景 5:函数内失败但函数结果被用于条件判断
myfunc() { false; echo "函数内继续执行了"; }
myfunc || true
echo "场景5:函数被 || 保护,脚本继续"

# 场景 6:子 Shell 中的失败(set -e 不自动继承到某些子 Shell 上下文)
res=$(false) || true
echo "场景6:命令替换失败被 || 吞掉,脚本继续"

# 场景 7:算术表达式结果为 0 会被当成"失败"(真正的坑)
count=0
# (( count++ ))    # count++ 返回 0,(( )) 视为假 → set -e 下直接退出!
(( count++ )) || true      # 必须加 || true 保护
echo "场景7:(( count++ )) 返回 0 会触发退出,已用 || true 保护,count=$count"

echo "===== 严格模式最佳实践 ====="
cat <<'EOF'
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'              # 收窄 IFS,避免空格导致的意外单词拆分
trap 'echo "第 $LINENO 行出错,退出码 $?" >&2' ERR
# 注意:
#  1) set -u 下引用可能不存在的变量要写成 ${VAR:-} 或 ${1:-}
#  2) set -u 下空数组 "${arr[@]}" 在 bash 4.2 及以前会报错,需写 ${arr[@]+"${arr[@]}"}
#  3) set -e 不适合"允许部分失败"的批量任务脚本,那种场景应手工检查 $?
#  4) 交互式 Shell 里不要 set -e,否则一个命令打错就退出登录
EOF

echo "===== 用 ERR 陷阱定位出错行 ====="
(
    set -eE                                # -E 让 ERR 陷阱在函数内也生效
    trap 'echo "出错: 第 $LINENO 行, 命令 [$BASH_COMMAND], 退出码 $?" >&2' ERR
    echo "正常"
    ls /nonexistent_path_xyz
) 2>&1 | tail -3

练习 9 编写一个完整的巡检脚本:综合运用本章知识,编写一个系统巡检脚本,要求:使用严格模式与 ERR 陷阱、用 getopts 接收选项、用函数拆分各检查项、用数组管理检查清单、用关联数组记录检查结果、用 trap EXIT 清理临时文件、用 here document 输出报告、兼容 CentOS 6 与 CentOS 7。

答案:

#!/bin/bash
# ============================================================================
# 脚本名称: inspect.sh
# 功能描述: 系统巡检,输出文本/HTML 报告
# 适用系统: CentOS 6 / CentOS 7
# 用法:     ./inspect.sh [-o 输出文件] [-f text|html] [-q] [-h]
# 退出码:   0 全部正常  1 有 WARN  2 有 CRIT  3 参数错误
# ============================================================================
set -uo pipefail                   # 巡检脚本刻意不用 -e:单项失败不应中断全部检查

readonly VERSION="1.0"
OUTPUT=""
FORMAT="text"
QUIET=0

readonly TMPD=$(mktemp -d /tmp/inspect.XXXXXX)
trap 'rm -rf -- "$TMPD"' EXIT INT TERM
trap 'echo "[ERR] 第 $LINENO 行: $BASH_COMMAND" >&2' ERR

# 检查项清单(索引数组,保证执行顺序)
readonly CHECKS=(os cpu memory disk inode load network service security)
# 检查结果(关联数组:检查项 -> OK/WARN/CRIT)
declare -A STATUS
declare -A DETAIL

# ---------- 基础函数 ----------
say()  { [ "$QUIET" -eq 1 ] || echo "$@"; }
mark() { STATUS["$1"]="$2"; DETAIL["$1"]="$3"; }

# 识别系统版本,后续按版本分支
detect_os() {
    if [ -f /etc/redhat-release ]; then
        OS_VER=$(grep -oE '[0-9]+' /etc/redhat-release | head -1)
    else
        OS_VER=0
    fi
    if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
        INIT_SYS="systemd"         # CentOS 7 及以上
    else
        INIT_SYS="sysv"            # CentOS 6
    fi
}

usage() {
    cat >&2 <<-EOF
    系统巡检脚本 v${VERSION}
    用法: $(basename "$0") [选项]
      -o FILE   报告输出到文件,默认输出到屏幕
      -f FMT    报告格式 text 或 html,默认 text
      -q        安静模式,只输出报告不输出过程
      -h        显示帮助
    EOF
    exit 3
}

各检查函数(接上):

# ---------- 各检查项 ----------
check_os() {
    local rel kernel up
    rel=$(cat /etc/redhat-release 2>/dev/null || echo "未知发行版")
    kernel=$(uname -r)
    up=$(uptime | sed 's/.*up \([^,]*\),.*/\1/')
    mark os OK "$rel | 内核 $kernel | 已运行 $up | init=$INIT_SYS"
}

check_cpu() {
    local cores idle used
    cores=$(grep -c '^processor' /proc/cpuinfo)
    # top -bn1 取一次快照;CentOS 6/7 的 %Cpu 行格式略有差异,用 awk 取 id 字段
    idle=$(top -bn1 | awk -F'[,%]+' '/Cpu/ {for(i=1;i<=NF;i++) if($i ~ /id/) print $(i-1)}' | head -1 | tr -d ' ')
    idle=${idle:-100}
    used=$(awk -v i="$idle" 'BEGIN{printf "%.1f", 100-i}')
    if awk -v u="$used" 'BEGIN{exit !(u>90)}'; then
        mark cpu CRIT "核心数 $cores | 使用率 ${used}%(超过 90%)"
    elif awk -v u="$used" 'BEGIN{exit !(u>75)}'; then
        mark cpu WARN "核心数 $cores | 使用率 ${used}%(超过 75%)"
    else
        mark cpu OK "核心数 $cores | 使用率 ${used}%"
    fi
}

check_memory() {
    local total used avail pct
    total=$(awk '/^MemTotal/{printf "%d", $2/1024}' /proc/meminfo)
    # MemAvailable 是内核 3.14+ 才有的字段,CentOS 6 需回退到 free+buffers+cached
    if grep -q '^MemAvailable' /proc/meminfo; then
        avail=$(awk '/^MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)   # CentOS 7
    else
        avail=$(awk '/^MemFree|^Buffers|^Cached/{s+=$2} END{printf "%d", s/1024}' /proc/meminfo)  # CentOS 6
    fi
    used=$((total - avail))
    pct=$((used * 100 / total))
    local swap_pct=0 st sf
    st=$(awk '/^SwapTotal/{print $2}' /proc/meminfo)
    sf=$(awk '/^SwapFree/{print $2}' /proc/meminfo)
    [ "$st" -gt 0 ] && swap_pct=$(( (st - sf) * 100 / st ))
    local msg="内存 ${used}M/${total}M (${pct}%) | 可用 ${avail}M | Swap 使用 ${swap_pct}%"
    if [ "$pct" -ge 90 ] || [ "$swap_pct" -ge 50 ]; then
        mark memory CRIT "$msg"
    elif [ "$pct" -ge 80 ] || [ "$swap_pct" -ge 20 ]; then
        mark memory WARN "$msg"
    else
        mark memory OK "$msg"
    fi
}

check_disk() {
    local worst=0 worst_mp="" level=OK msg=""
    while read -r pct mp; do
        pct=${pct%\%}
        [ "$pct" -gt "$worst" ] && { worst=$pct; worst_mp=$mp; }
        [ "$pct" -ge 80 ] && msg="${msg}${mp}=${pct}% "
    done < <(df -hP | awk 'NR>1 && $1 ~ /^\/dev/ {print $5, $6}')
    if   [ "$worst" -ge 90 ]; then level=CRIT
    elif [ "$worst" -ge 80 ]; then level=WARN
    fi
    mark disk "$level" "最高使用率 ${worst_mp}=${worst}% ${msg:+| 超阈值分区: $msg}"
}

check_inode() {
    local worst=0 worst_mp="" level=OK
    while read -r pct mp; do
        pct=${pct%\%}
        [ "$pct" = "-" ] && continue
        [ "$pct" -gt "$worst" ] && { worst=$pct; worst_mp=$mp; }
    done < <(df -iP | awk 'NR>1 && $1 ~ /^\/dev/ {print $5, $6}')
    [ "$worst" -ge 90 ] && level=CRIT
    [ "$worst" -ge 80 ] && [ "$level" = OK ] && level=WARN
    mark inode "$level" "最高 inode 使用率 ${worst_mp}=${worst}%"
}

check_load() {
    local cores l1 l5 l15 ratio level=OK
    cores=$(grep -c '^processor' /proc/cpuinfo)
    read -r l1 l5 l15 _ < /proc/loadavg
    # 判断标准:1 分钟负载 / 核心数
    ratio=$(awk -v l="$l1" -v c="$cores" 'BEGIN{printf "%.2f", l/c}')
    awk -v r="$ratio" 'BEGIN{exit !(r>2)}'   && level=CRIT
    awk -v r="$ratio" 'BEGIN{exit !(r>1)}'   && [ "$level" = OK ] && level=WARN
    mark load "$level" "负载 $l1/$l5/$l15 | 核心 $cores | 单核负载 $ratio"
}

check_network() {
    local level=OK msg="" ip gw conns
    ip=$(ip -4 a 2>/dev/null | awk '/inet /&&!/127.0.0.1/{print $2}' | paste -sd, -)
    gw=$(ip route 2>/dev/null | awk '/^default/{print $3}' | head -1)
    # ss 是 CentOS 7 推荐,netstat 属 net-tools 可能未装
    if command -v ss >/dev/null 2>&1; then
        conns=$(ss -ant 2>/dev/null | grep -c ESTAB)
    else
        conns=$(netstat -ant 2>/dev/null | grep -c ESTABLISHED)
    fi
    msg="IP: ${ip:-无} | 网关: ${gw:-无} | ESTAB 连接 $conns"
    if [ -z "$gw" ]; then
        level=WARN; msg="$msg | 无默认路由"
    elif ! ping -c 1 -W 2 "$gw" &>/dev/null; then
        level=WARN; msg="$msg | 网关不可达"
    fi
    mark network "$level" "$msg"
}

check_service() {
    local level=OK down=""
    local -a svcs=(sshd crond rsyslog network)
    for s in "${svcs[@]}"; do
        if [ "$INIT_SYS" = "systemd" ]; then
            systemctl is-active "$s" &>/dev/null || down="$down $s"       # CentOS 7
        else
            service "$s" status &>/dev/null || down="$down $s"            # CentOS 6
        fi
    done
    if [ -n "$down" ]; then
        level=CRIT
        mark service "$level" "以下服务未运行:$down"
    else
        mark service OK "关键服务全部正常: ${svcs[*]}"
    fi
}

check_security() {
    local level=OK msg="" empty_pass suid_cnt fails selinux
    # 空密码账号
    empty_pass=$(awk -F: '$2==""{print $1}' /etc/shadow 2>/dev/null | paste -sd, -)
    # UID 为 0 的非 root 账号
    local root_dup
    root_dup=$(awk -F: '$3==0 && $1!="root"{print $1}' /etc/passwd | paste -sd, -)
    suid_cnt=$(find /usr/bin /usr/sbin -perm -4000 2>/dev/null | wc -l)
    fails=$(grep -c 'Failed password' /var/log/secure 2>/dev/null || echo 0)
    selinux=$(getenforce 2>/dev/null || echo "未安装")
    msg="SUID 程序 $suid_cnt 个 | 登录失败 $fails 次 | SELinux $selinux"
    [ -n "$empty_pass" ] && { level=CRIT; msg="$msg | 空密码账号: $empty_pass"; }
    [ -n "$root_dup" ]   && { level=CRIT; msg="$msg | 非 root 的 UID0 账号: $root_dup"; }
    [ "$fails" -gt 100 ] && [ "$level" = OK ] && { level=WARN; msg="$msg | 疑似暴力破解"; }
    mark security "$level" "$msg"
}

报告输出与主流程(接上):

# ---------- 报告输出 ----------
report_text() {
    local ok=0 warn=0 crit=0
    cat <<-EOF
    ================================================================
                        系统巡检报告 v${VERSION}
    ----------------------------------------------------------------
    主机名  : $(hostname)
    IP 地址 : $(hostname -I 2>/dev/null || hostname -i)
    巡检时间: $(date '+%F %T')
    执行用户: $(whoami)
    ================================================================
    EOF
    for c in "${CHECKS[@]}"; do
        printf '[%-4s] %-10s %s\n' "${STATUS[$c]}" "$c" "${DETAIL[$c]}"
        case "${STATUS[$c]}" in
            OK)   ok=$((ok+1))   ;;
            WARN) warn=$((warn+1)) ;;
            CRIT) crit=$((crit+1)) ;;
        esac
    done
    cat <<-EOF
    ----------------------------------------------------------------
    汇总: 共 ${#CHECKS[@]} 项,正常 $ok 项,警告 $warn 项,严重 $crit 项
    ================================================================
    EOF
    # 用退出码反映最严重级别
    [ "$crit" -gt 0 ] && return 2
    [ "$warn" -gt 0 ] && return 1
    return 0
}

report_html() {
    cat <<-'EOF'
    <!DOCTYPE html><html><head><meta charset="utf-8">
    <title>系统巡检报告</title><style>
    body{font-family:monospace;margin:20px}
    table{border-collapse:collapse;width:100%}
    th,td{border:1px solid #ccc;padding:6px;text-align:left}
    th{background:#f0f0f0}
    .OK{background:#dfd}.WARN{background:#ffd}.CRIT{background:#fdd}
    </style></head><body>
    EOF
    echo "<h2>系统巡检报告 - $(hostname) - $(date '+%F %T')</h2>"
    echo "<table><tr><th>状态</th><th>检查项</th><th>详情</th></tr>"
    for c in "${CHECKS[@]}"; do
        printf '<tr class="%s"><td>%s</td><td>%s</td><td>%s</td></tr>\n' \
            "${STATUS[$c]}" "${STATUS[$c]}" "$c" "${DETAIL[$c]}"
    done
    echo "</table></body></html>"
}

# ---------- 主流程 ----------
main() {
    while getopts ":o:f:qh" opt; do
        case "$opt" in
            o) OUTPUT="$OPTARG" ;;
            f) FORMAT="$OPTARG" ;;
            q) QUIET=1 ;;
            h) usage ;;
            :) echo "选项 -$OPTARG 缺少参数" >&2; usage ;;
            \?) echo "未知选项 -$OPTARG" >&2; usage ;;
        esac
    done
    shift $((OPTIND - 1))

    case "$FORMAT" in
        text|html) ;;
        *) echo "格式只能是 text 或 html" >&2; usage ;;
    esac

    detect_os
    say "开始巡检(系统 CentOS ${OS_VER}, init=${INIT_SYS})..."

    # 遍历检查清单,动态调用 check_xxx 函数
    for c in "${CHECKS[@]}"; do
        say "  检查 $c ..."
        # declare -F 判断函数是否存在,避免清单里写了没实现的项
        if declare -F "check_$c" >/dev/null; then
            "check_$c" || mark "$c" WARN "检查过程出错"
        else
            mark "$c" WARN "未实现的检查项"
        fi
    done

    local rc=0
    if [ -n "$OUTPUT" ]; then
        if [ "$FORMAT" = html ]; then report_html > "$OUTPUT"; else report_text > "$OUTPUT"; fi
        rc=$?
        say "报告已写入 $OUTPUT"
    else
        if [ "$FORMAT" = html ]; then report_html; else report_text; fi
        rc=$?
    fi
    return "$rc"
}

# 只有被直接执行时才运行 main;被 source 时只加载函数
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
    main "$@"
    exit $?
fi

用法示例:

chmod +x inspect.sh
./inspect.sh                                  # 屏幕输出文本报告
./inspect.sh -q -o /tmp/report.txt            # 安静模式写入文件
./inspect.sh -f html -o /var/www/html/inspect.html   # 生成 HTML 报告
./inspect.sh; echo "退出码=$?"                 # 0 全正常 1 有警告 2 有严重项

# 语法检查与静态分析
bash -n inspect.sh
shellcheck inspect.sh

# 调试执行
bash -x inspect.sh 2>&1 | head -50
PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): ' bash -x inspect.sh

# 加入定时任务,每天 8 点巡检并邮件通知
# 0 8 * * * /opt/ops/bin/inspect.sh -q -o /tmp/r.txt; mail -s "巡检 $(hostname)" ops@x.com < /tmp/r.txt

# 被其他脚本 source 后单独调用某个检查函数
source ./inspect.sh
detect_os; check_disk; echo "${STATUS[disk]} ${DETAIL[disk]}"

本章小结

变量与扩展速查

语法 作用
var=value 赋值,等号两侧不能有空格
$var / ${var} 引用;紧跟其他字符时必须加花括号
${#var} 字符串长度
${var:off} / ${var:off:len} 从 off 偏移取 len 个字符;负偏移前需空格或括号
${var#pat} / ${var##pat} 从左删除最短 / 最长匹配
${var%pat} / ${var%%pat} 从右删除最短 / 最长匹配
${var/old/new} / ${var//old/new} 替换第一个 / 全部匹配
${var/#old/new} / ${var/%old/new} 只替换开头 / 结尾的匹配
${var^} / ${var^^} / ${var,} / ${var,,} 首字母大写 / 全大写 / 首字母小写 / 全小写(bash 4.0+)
${var:-默认} / ${var:=默认} 为空取默认值 / 为空则赋默认值
${var:?提示} / ${var:+替代} 为空则报错退出 / 非空则用替代值
${!var} 间接引用(变量名存在另一个变量里)
${!prefix*} / ${!prefix@} 列出所有以 prefix 开头的变量名
$((表达式)) / $[表达式] 算术运算(后者已废弃)
$(命令) / `命令` 命令替换(推荐前者,可嵌套)
declare -i/-r/-a/-A/-x/-l/-u/-p 整型 / 只读 / 索引数组 / 关联数组 / 导出 / 小写 / 大写 / 打印定义
local / readonly / unset / export 局部 / 只读 / 删除 / 导出为环境变量

特殊变量速查

变量 含义
$0 脚本名(函数内不变,仍是脚本名)
$1 ~ $9、${10} 位置参数,两位及以上必须加花括号
$# 位置参数个数
$* 全部参数,"$*" 合成一个字符串
$@ 全部参数,"$@" 展开为独立的多个参数(推荐)
$? 上一条命令的退出状态码(0 成功)
$$ 当前 Shell 的 PID
$! 最近一个后台进程的 PID
$_ 上一条命令的最后一个参数
$- 当前 Shell 的选项标志
$PPID 父进程 PID
$RANDOM 0 ~ 32767 随机数
$SECONDS 脚本已运行的秒数
$LINENO 当前行号
$FUNCNAME 函数名数组,${FUNCNAME[0]} 为当前函数
$BASH_SOURCE 脚本路径数组,配合 [ "${BASH_SOURCE[0]}" = "$0" ] 判断是否被 source
$BASH_COMMAND 正在执行的命令(DEBUG / ERR 陷阱中使用)
$PIPESTATUS 管道各环节退出码数组
$IFS 内部字段分隔符,默认空格、Tab、换行
$BASH_SUBSHELL 子 Shell 嵌套层数
$OPTARG / $OPTIND getopts 的当前选项参数 / 下一个待处理参数下标

条件测试运算符速查

类别 运算符 说明
文件存在 -e -f -d -L -b -c -p -S 存在 / 普通文件 / 目录 / 软链接 / 块设备 / 字符设备 / 管道 / 套接字
文件权限 -r -w -x -u -g -k 可读 / 可写 / 可执行 / SUID / SGID / 粘滞位
文件属性 -s -O -G -nt -ot -ef 非空 / 属主是自己 / 属组匹配 / 更新 / 更旧 / 同一 inode
字符串 -z -n = != < > 长度为 0 / 非 0 / 相等 / 不等 / 字典序小于 / 大于
数值 -eq -ne -gt -ge -lt -le 等 / 不等 / 大于 / 大于等于 / 小于 / 小于等于
逻辑 ! -a -o && \|\| 非 / 与 / 或(-a/-o 已不推荐,改用 &&/\|\|)
变量 -v name -R name 变量已设置 / 是 nameref

[ ]、[[ ]]、(( )) 的选择:

结构 特点
[ ](等价于 test) POSIX 兼容;变量必须加双引号;不支持 &&、<(会被当重定向)、正则
[[ ]] Bash 关键字;不做单词拆分和路径展开,变量可不加引号;支持 && \|\| < > == 通配、=~ 正则(结果存 BASH_REMATCH)
(( )) 纯算术求值;内部变量不用 $;支持 > < == && \|\| ++ --;结果为 0 时退出码为 1

流程控制语法速查

# 分支
if 条件; then ...; elif 条件; then ...; else ...; fi
case "$var" in 模式1|模式2) ...;; *) ...;; esac    # ;; 结束 ;& 贯穿 ;;& 继续匹配(bash 4.0+)

# 循环
for i in 列表; do ...; done
for ((i=0; i<10; i++)); do ...; done
while 条件; do ...; done
until 条件; do ...; done
while read -r line; do ...; done < 文件            # 逐行读文件的标准写法
select opt in 选项...; do ...; done                # 自动生成编号菜单

# 循环控制
break [n]      # 跳出 n 层循环
continue [n]   # 跳过本次,继续第 n 层循环
exit [n]       # 退出脚本
return [n]     # 从函数返回,n 为 0-255

# 列表生成
{1..10}  {1..10..2}  {a..z}  {01..12}     # 花括号扩展(不支持变量,需 eval 或 seq)
$(seq 1 10)  $(seq 1 2 10)  $(seq -w 1 10)

数组速查

操作 索引数组 关联数组
声明 declare -a arr(可省略) declare -A arr(必须,bash 4.0+)
赋值 arr=(a b c) / arr[0]=a arr=([k1]=v1 [k2]=v2) / arr[k1]=v1
取值 ${arr[0]} ${arr[k1]}
全部值 "${arr[@]}" "${arr[@]}"
全部键 "${!arr[@]}" "${!arr[@]}"
个数 ${#arr[@]} ${#arr[@]}
追加 arr+=(d e) arr[k3]=v3
切片 ${arr[@]:1:3} 不支持
删除元素 unset 'arr[1]'(留下空洞) unset 'arr[k1]'
删除整个 unset arr unset arr
读文件入数组 mapfile -t arr < file(bash 4.0+) —
按分隔符拆分 IFS=':' read -r -a arr <<< "$str" —
最后一个元素 ${arr[${#arr[@]}-1]}(通用)或 ${arr[-1]}(bash 4.3+) —
打印定义 declare -p arr declare -p arr

遍历数组务必写 for x in "${arr[@]}":不加引号会对含空格的元素做单词拆分,写 "${arr[*]}" 会把整个数组合成一个字符串。用 unset 删元素后数组变稀疏,用 arr=("${arr[@]}") 可重建连续下标。CentOS 6 的 bash 4.1、CentOS 7 的 bash 4.2 都不支持负下标 ${arr[-1]}。

set / shopt 选项速查

选项 作用
set -e / -o errexit 命令失败立即退出(注意 7 类例外场景)
set -u / -o nounset 引用未定义变量时报错退出
set -o pipefail 管道中任一环失败即视为整体失败
set -x / -o xtrace 打印每条执行的命令(配合 PS4 定制前缀)
set -v / -o verbose 打印读入的每行原文
set -n / -o noexec 只做语法检查不执行
set -E / -o errtrace 让 ERR 陷阱在函数与子 Shell 中生效
set -T / -o functrace 让 DEBUG / RETURN 陷阱在函数中生效
set -o noclobber 禁止 > 覆盖已有文件(可用于原子加锁)
set -f / -o noglob 禁用通配符扩展
set -m / -o monitor 作业控制(lastpipe 需要先关闭它)
set -- 重设位置参数
set +选项 关闭对应选项
shopt -s lastpipe 管道最后一段在当前 Shell 执行(bash 4.2+,解决变量丢失)
shopt -s nullglob 通配符无匹配时展开为空而不是原样保留
shopt -s extglob 启用扩展通配 ?() *() +() @() !()
shopt -s nocasematch case / [[ == ]] 忽略大小写
shopt -s expand_aliases 脚本中启用别名(默认关闭)

严格模式模板:set -euo pipefail + IFS=$'\n\t' + trap 'echo "第 $LINENO 行出错" >&2' ERR;但批量运维脚本"允许部分失败",此时不要用 -e,改为手工检查 $? 并累计失败计数。

调试口诀

  1. 先查语法:bash -n script.sh,不执行只报语法错。
  2. 再看静态:shellcheck script.sh,能查出 90% 的引号、变量、逻辑隐患。
  3. 跟踪执行:bash -x script.sh,或在可疑段落前后加 set -x / set +x 局部跟踪。
  4. 定位行号:export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): ',让 -x 输出带文件名、行号与函数名。
  5. 捕获错误:set -eE 配合 trap 'echo "第 $LINENO 行失败: $BASH_COMMAND (码 $?)" >&2' ERR。
  6. 逐句单步:trap 'read -p "第 $LINENO 行: $BASH_COMMAND"' DEBUG。
  7. 打印变量:declare -p 变量名 比 echo 更可靠,能看出类型与是否含空格;数组必须用它检查。
  8. 隔离变量:把可疑片段抄到交互式 Shell 里单独跑,或用 bash -c '...' 在干净环境验证。
  9. 看退出码:echo $?;管道用 echo "${PIPESTATUS[@]}" 看每一环。
  10. 写日志:长期运行的脚本必须有带时间戳的日志函数与 exec >> "$LOG" 2>&1 重定向。

常见 shellcheck 告警对照:

编号 含义与处理
SC2086 变量未加双引号,可能被单词拆分 → 加 "$var"
SC2046 $(...) 未加引号 → 加引号或改用数组
SC2006 使用了反引号 → 改成 $(...)
SC2034 变量赋值后未使用 → 删除或加 # shellcheck disable=SC2034
SC2154 变量被引用但从未赋值 → 检查拼写
SC2164 cd 可能失败 → 写成 cd dir \|\| exit 1
SC2181 用 $? 间接判断 → 直接写 if cmd; then
SC2126 grep \| wc -l → 改用 grep -c
SC2115 rm -rf "$DIR/" 中 DIR 可能为空 → 写成 "${DIR:?}"

编码规范清单

  1. #!/bin/bash 明确解释器;需要跨平台时用 #!/usr/bin/env bash。
  2. 脚本开头写文件头注释:名称、功能、作者、日期、版本、用法、退出码。
  3. 变量名:局部与普通变量小写下划线,常量与环境变量大写,常量加 readonly。
  4. 所有变量引用一律加双引号,除了刻意需要单词拆分的场合。
  5. 数值比较用 [ "$a" -eq "$b" ] 或 (( a == b )),字符串比较用 [[ "$a" == "$b" ]]。
  6. 优先使用 [[ ]] 而不是 [ ];优先 $(...) 而不是反引号。
  7. 函数必须先定义后调用;函数内部变量一律 local;函数返回数据用 echo,返回状态用 return。
  8. 提示与错误信息输出到 stderr(>&2),只有函数的返回值走 stdout。
  9. 关键操作前做前置检查:root 权限、命令是否存在(command -v)、文件/目录是否存在、参数是否完整。
  10. 危险操作加保护:rm -rf "${DIR:?未定义}"、先 dry-run、操作前备份、路径白名单校验。
  11. 用 trap ... EXIT 清理临时文件;临时文件用 mktemp 创建,绝不用固定名。
  12. 需要防止并发用 flock 或 noclobber 锁文件,并处理残留锁。
  13. 密码不写在命令行(ps 可见),放入 chmod 600 的配置文件或环境变量。
  14. 绝不 eval 未经校验的外部输入。
  15. 缩进统一 4 空格,不用 Tab(<<-EOF 例外,它只认 Tab)。
  16. 单个函数不超过 50 行,单个脚本不超过 300 行,超出就拆分成函数库用 source 引入。
  17. 退出码规划:0 成功,1 通用错误,2 参数错误,其余自定义并写进文件头注释。
  18. 脚本要幂等:重复执行结果一致(建用户先判存在、写配置先判是否已写入)。
  19. 兼容性标注清楚:CentOS 6 用 service / chkconfig,CentOS 7 用 systemctl;运行时用 command -v systemctl 自动判断分支。
  20. 交付前必过三关:bash -n 语法检查、shellcheck 静态检查、测试环境实跑。

文本处理三剑客(grep / sed / awk)、正则表达式体系、find 与文件查找的完整内容,详见 06-02-文本处理与文件查找.md。进程、信号、作业控制的系统层面知识详见 04-01-进程与任务管理.md。

最后修改:2026 年 09 月 20 日
如果觉得我的文章对你有用,请随意赞赏