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 "$@"
- Shebang:
#!/bin/bash,必须在第一行第一列,指定解析器。 - 注释块:脚本名、功能、作者、日期、版本,便于交接维护。
- 变量与函数定义。
- 主流程代码。
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 关于 $$ 与进程关系(实验)
- 在终端执行
echo $$查看终端 PID。 - 编写脚本
process:
echo $$
sleep 999
- 运行脚本,用
ps -el | grep process查看进程 PID 是否为脚本打印的 PID,并查看脚本进程的 PPID 是否为终端 PID。 - 执行
kill -9 pid终止脚本。 - 脚本终止后,
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手工模板(无外部依赖),其次用 GNUgetopt。
十一、数组
数组用于批量存储同类型数据。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)很关键:默认情况下ERRtrap 不会被 Shell 函数、命令替换、子 Shell 继承,加-E后才继承。同理set -T(functrace)让DEBUG和RETURNtrap 被函数继承。
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,由主循环在安全点检查标志位后退出;清理动作统一放在EXITtrap 里,保证任何退出路径都会执行。
十五、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
实验步骤:
- 在终端执行
echo $$查看终端 PID。 - 运行脚本
bash process。 - 用
ps -el | grep process查看进程 PID 是否为脚本打印的 PID,并查看脚本进程的 PPID 是否为终端 PID。 - 执行
kill -9 pid终止脚本。 - 脚本终止后,
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块、内置变量(NRNFFSOFSRSORSFILENAME$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,改为手工检查 $? 并累计失败计数。
调试口诀
- 先查语法:
bash -n script.sh,不执行只报语法错。 - 再看静态:
shellcheck script.sh,能查出 90% 的引号、变量、逻辑隐患。 - 跟踪执行:
bash -x script.sh,或在可疑段落前后加set -x/set +x局部跟踪。 - 定位行号:
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): ',让-x输出带文件名、行号与函数名。 - 捕获错误:
set -eE配合trap 'echo "第 $LINENO 行失败: $BASH_COMMAND (码 $?)" >&2' ERR。 - 逐句单步:
trap 'read -p "第 $LINENO 行: $BASH_COMMAND"' DEBUG。 - 打印变量:
declare -p 变量名比echo更可靠,能看出类型与是否含空格;数组必须用它检查。 - 隔离变量:把可疑片段抄到交互式 Shell 里单独跑,或用
bash -c '...'在干净环境验证。 - 看退出码:
echo $?;管道用echo "${PIPESTATUS[@]}"看每一环。 - 写日志:长期运行的脚本必须有带时间戳的日志函数与
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:?}" |
编码规范清单
#!/bin/bash明确解释器;需要跨平台时用#!/usr/bin/env bash。- 脚本开头写文件头注释:名称、功能、作者、日期、版本、用法、退出码。
- 变量名:局部与普通变量小写下划线,常量与环境变量大写,常量加
readonly。 - 所有变量引用一律加双引号,除了刻意需要单词拆分的场合。
- 数值比较用
[ "$a" -eq "$b" ]或(( a == b )),字符串比较用[[ "$a" == "$b" ]]。 - 优先使用
[[ ]]而不是[ ];优先$(...)而不是反引号。 - 函数必须先定义后调用;函数内部变量一律
local;函数返回数据用echo,返回状态用return。 - 提示与错误信息输出到 stderr(
>&2),只有函数的返回值走 stdout。 - 关键操作前做前置检查:root 权限、命令是否存在(
command -v)、文件/目录是否存在、参数是否完整。 - 危险操作加保护:
rm -rf "${DIR:?未定义}"、先 dry-run、操作前备份、路径白名单校验。 - 用
trap ... EXIT清理临时文件;临时文件用mktemp创建,绝不用固定名。 - 需要防止并发用
flock或noclobber锁文件,并处理残留锁。 - 密码不写在命令行(
ps可见),放入chmod 600的配置文件或环境变量。 - 绝不
eval未经校验的外部输入。 - 缩进统一 4 空格,不用 Tab(
<<-EOF例外,它只认 Tab)。 - 单个函数不超过 50 行,单个脚本不超过 300 行,超出就拆分成函数库用
source引入。 - 退出码规划:0 成功,1 通用错误,2 参数错误,其余自定义并写进文件头注释。
- 脚本要幂等:重复执行结果一致(建用户先判存在、写配置先判是否已写入)。
- 兼容性标注清楚:CentOS 6 用
service/chkconfig,CentOS 7 用systemctl;运行时用command -v systemctl自动判断分支。 - 交付前必过三关:
bash -n语法检查、shellcheck静态检查、测试环境实跑。
文本处理三剑客(grep / sed / awk)、正则表达式体系、
find与文件查找的完整内容,详见 06-02-文本处理与文件查找.md。进程、信号、作业控制的系统层面知识详见 04-01-进程与任务管理.md。
此处评论已关闭