第8章 产生脚本
《Shell脚本学习指南》第8章 整理笔记
本章主题:实战编写完整shell工具脚本,两个大型示例程序:pathfind、build‑all。
核心学到的能力:
- shell脚本完整工程化结构:头部注释、安全加固、辅助函数、长选项解析、参数校验、异常处理、退出码控制。
eval动态字符串执行;basename/dirname;循环控制break 2;命令行长选项解析模板。
- 远程ssh批量构建软件包;后台并行任务;日志收集。
⚠本章偏向工程实战,不是基础语法;写生产shell脚本可以直接复用本章模板骨架。
8.1 pathfind 工具
功能简介
UNIX没有原生工具:根据一个冒号分隔的环境变量(类似PATH),在它的各个目录里面搜索匹配的文件。
- 输入:环境变量名 + 文件模式;
- 选项
--all:输出全部匹配;不使用只输出第一个匹配就停止;
- 找不到的文件打印报错到stderr;退出码等于找不到的文件数量(上限125)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
|
#! /bin/sh -
#
# 在查找路径下寻找一个或多个原始文件或文件模式,
# 查找路径乃由特定的环境变量所定义。
#
# 标准输出所产生的结果,通常是查找路径下找到的每个文件之第一个实体的完整路径,
# 或是“filename:notfound”的标准错误输出。
#
# 如果所有文件都找到,则退出码为0,
# 否则,即为找不到的文件个数(非0)
# (Shell的退出码限制为125)
#
# 语法:
# pathfind [--all] [--?] [--help] [--version] envvar pattern(s)
#
# 使用--all选项时,在路径下的每个目录都会被查找,
# 而非停在第一个找到者。
IFS='
'
OLDPATH="$PATH"
PATH=/bin:/usr/bin
export PATH
error()
{
echo "$@" 1>&2
usage_and_exit 1
}
usage()
{
echo "Usage: $PROGRAM [--all] [--?] [--help] [--version] envvar pattern(s)"
}
usage_and_exit()
{
usage
exit $1
}
version()
{
echo "$PROGRAM version $VERSION"
}
warning()
{
echo "$@" 1>&2
EXITCODE=`expr $EXITCODE + 1`
}
all=no
envvar=
EXITCODE=0
PROGRAM=`basename $0`
VERSION=1.0
while test $# -gt 0
do
case $1 in
--all | --al | --a | -all | -al | -a )
all=yes
;;
--help | --hel | --he | --h | '--?' | -help | -hel | -he | -h | '-?' )
usage_and_exit 0
;;
--version | --versio | --versi | --vers | --ver | --ve | --v |
-version | -versio | -versi | -vers | -ver | -ve | -v )
version
exit 0
;;
-*)
error "Unrecognized option: $1"
;;
*)
break
;;
esac
shift
done
envvar="$1"
test $# -gt 0 && shift
test "x$envvar" = "xPATH" && envvar=OLDPATH
dirpath=`eval echo '"$'$envvar'"' 2>/dev/null | tr : ' '`
# 为错误情况进行健全检测
if test -z "$envvar"
then
error Environment variable missing or empty
elif test "x$dirpath" = "x$envvar"
then
error "Broken sh on this platform: cannot expand $envvar"
elif test -z "$dirpath"
then
error Empty directory search path
elif test $# -eq 0
then
exit 0
fi
for pattern in "$@"
do
result=
for dir in $dirpath
do
for file in $dir/$pattern
do
if test -f "$file"
then
result="$file"
echo $file
test "$all" = "no" && break 2
fi
done
done
test -z "$result" && warning "$pattern: not found"
done
test $EXITCODE -gt 125 && EXITCODE=125
exit $EXITCODE
|
调用示例
1
2
3
4
5
6
7
8
|
# 在PATH路径下找ls,只输出第一个匹配
pathfind PATH ls
# --all 找出PATH下全部叫ls的文件
pathfind --all PATH ls
# 在PATH里面匹配模式 ?sh,找ksh/zsh/csh
pathfind --all PATH '?sh'
|
脚本整体骨架拆解(工程模板,重点)
①脚本开头安全加固
1
2
3
4
5
6
|
#! /bin/sh -
IFS='
' # 重置IFS,抵御IFS注入攻击
OLDPATH="$PATH"
PATH=/bin:/usr/bin
export PATH # 重写PATH,子进程只能使用可信系统命令,防止恶意程序欺骗
|
安全要点:
- 重置
IFS,防止特殊分隔符攻击;
- 重置
PATH,限制子进程只能调用系统标准命令,避免执行用户目录下恶意同名程序;
export PATH让所有子进程继承这个安全PATH。
②定义辅助函数(减少重复代码)
| 函数 |
作用 |
error() |
打印错误到stderr,打印帮助,直接退出脚本 |
usage() |
打印使用帮助信息,用$PROGRAM取脚本名,硬编码脚本名不利于脚本改名 |
usage_and_exit() |
打印帮助 + 指定退出码退出 |
version() |
打印版本号 |
warning() |
打印警告到stderr,统计错误计数EXITCODE+=1,不退出脚本 |
变量约定:书中习惯,局部变量小写;全局/共享变量大写。
③初始化变量
1
2
3
4
5
|
all=no # 是否输出全部匹配,默认no,找到第一个就break
envvar= # 用户传入的环境变量名称
EXITCODE=0 # 统计找不到的文件个数
PROGRAM=`basename $0`# 获取脚本自身名字,$0是脚本完整路径;basename剥离路径只留文件名
VERSION=1.0
|
basename /usr/bin/ls → ls;只做字符串处理,不检查文件是否真实存在。配套命令dirname,取出目录部分。
④解析命令行参数(长选项模板!工作脚本高频复用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
while test $# -gt 0
do
case $1 in
--all | --al | --a | -all | -al | -a )
all=yes
;;
--help|--hel|--he|--h|'--?'|-help|-hel|-he|-h|'-?' )
usage_and_exit 0
;;
--version|--versio|--versi|--vers|--ver|--ve|--v |
-version|-versio|-versi|-vers|-ver|-ve|-v )
version
exit 0
;;
-*)
error "Unrecognized option: $1"
;;
*)
break # 遇到非选项,结束选项解析循环
;;
esac
shift
done
|
要点:
while test $# -gt 0:参数个数大于0就循环;
case匹配长选项缩写,兼容单横杠‑a、双横杠--all;
-*)捕获所有以‑开头、没有匹配的非法选项,报错退出;
*) break:碰到不是选项的参数,跳出循环,剩下$@就是业务参数;
- 每轮循环末尾执行
shift,丢弃已经处理完成的$1。
解析完选项后:剩下第一个参数是环境变量名,剩余参数是要搜索的文件名模式。
1
2
|
envvar="$1"
test $# -gt 0 && shift
|
⑤eval 处理动态获取环境变量的值【难点】
需求:变量envvar里面存的是环境变量的名字字符串,例如envvar="PATH",想要拿到$PATH的值。
1
|
dirpath=`eval echo '"$'$envvar'"' 2>/dev/null | tr : ' '`
|
拆解:
- 拼接出字符串
echo "$PATH",交给eval二次解析执行;
- eval执行后输出PATH内容,例如
/bin:/usr/bin:/home/bin;
- 管道交给
tr : ' ',把冒号分隔的路径列表,替换成空格分隔。
eval:对字符串完整再跑一整套shell解析流程;功能强大,但容易引入安全风险,谨慎使用。
eval命令详解
eval 到底是什么
eval 是 shell 特殊内建命令。
一句话:eval 接收一串字符串,把这串字符串,再完整重新做一遍全套shell解析,然后执行解析完得到的命令。
普通命令:shell只解析一次就执行。
eval:先正常解析一遍拿到文本,再把文本交给shell再完整解析第二轮,然后运行。
重点:eval本身不会生成echo,echo是字符串里本来就有的命令;eval只是把拼装出来的字符串当命令跑一遍。
简单小例子理解
示例1
1
2
|
cmd="echo hello"
$cmd
|
- shell第一轮解析:
$cmd替换成字符串 echo hello
- 普通执行:把
echo hello当成1个单词,试图找名叫echo hello的程序,找不到,报错。
1
2
|
cmd="echo hello"
eval $cmd
|
- 第一轮shell解析:
$cmd替换得到文本:echo hello
- eval登场:把这串文本,重新交给shell,再完整解析一次
- 第二轮解析:分词,识别命令
echo,参数hello
- 执行
echo hello,输出hello。
👉 eval的能力就是:二次解析字符串,把字符串变成真正可执行的命令行。
回到pathfind那行:eval echo '"$'$envvar'"'
假设 envvar=PATH
第一步:shell第一轮解析(eval还没干活)
原始代码片段:
1
|
eval echo '"$'$envvar'"'
|
shell先做变量替换:$envvar替换为PATH。
传给eval的参数字符串变成:
⚠此时这还只是一堆文本字符串,并没有执行echo,只是字符串内容长得像一条命令。
第二步:eval接手
eval拿到字符串 echo "$PATH",把这一整段当做新的命令行,完整重新解析、执行。
于是shell识别出:
- 命令:
echo
- 参数:
"$PATH",再解析变量$PATH,取出PATH环境变量的值。
于是就执行了 echo "/bin:/usr/bin:...",打印出PATH的内容。
❗eval并不会凭空创造echo;echo是我们拼接出来的字符串里面写死的;eval只是负责把字符串翻译成真正命令执行。
对比:不加eval会发生什么?
envvar=PATH,第一轮替换后得到参数:"$PATH"
直接执行echo,只是原样打印字面文本:
不会展开PATH变量!因为只解析了一轮,双引号只是普通字符串字符,不会触发变量展开。
eval工作流程图
普通命令:
源代码 → shell【一次解析】 → 执行命令
eval命令:
源代码 → shell【第一轮解析,得到字符串文本】 → eval把文本丢回shell【第二轮完整解析】 → 执行最终命令
⚠eval巨大的风险(重点)
因为eval会二次解析任意字符串,如果字符串内容来自用户外部不可信输入,会造成shell注入漏洞。
危险示范:
1
2
|
var=';rm -rf $HOME'
eval "echo $var"
|
经过eval二次解析,;会被识别成分隔多条命令,rm就会被执行!
pathfind脚本里面eval是安全的:envvar来自脚本自己解析的命令行参数,做过控制,不受外部恶意注入。
再举极简demo,肉眼观察eval干了什么
1
2
3
4
5
6
|
#!/bin/sh
a="echo 123"
echo "====不用eval ===="
$a
echo "====用eval ===="
eval $a
|
输出:
1
2
3
4
|
====不用eval ====
sh: echo 123: not found
====用eval ====
123
|
- 不用eval:把
echo 123整体当成一个程序名字,找不到报错。
- eval:二次解析,拆分成命令
echo,参数123,正常执行。
总结
eval 是shell特殊内建,功能:对输入的字符串,再完整执行一遍shell解析流程,之后运行得到的命令。
- eval不会产生新命令,字符串里面写什么,第二轮就执行什么;pathfind例子中字符串里写了
echo,所以eval就会执行echo。
- 普通代码只会解析一轮;eval多一轮解析,这也是它强大同时危险的根源。
- 风险:不要把不受信任的外部输入交给eval,会命令注入。
记忆口诀:
eval = 把字符串,再当一行命令重新解析跑一次。
⑥健壮性检查(sanity check)
拿到变量之后做各种合法性判断,空值、异常值直接报错退出。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
if test -z "$envvar"
then
error Environment variable missing or empty
elif test "x$dirpath" = "x$envvar"
then
error "Broken sh on this platform: cannot expand $envvar"
elif test -z "$dirpath"
then
error Empty directory search path
elif test $# -eq 0
then
exit 0
fi
|
"x$var"技巧:防止变量展开后以‑开头,导致test误识别为选项。
⑦三层嵌套循环
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
for pattern in "$@" # 外层:遍历每一个待搜索的文件模式
do
result=
for dir in $dirpath # 中层:遍历环境变量里面每一个目录
do
for file in $dir/$pattern #内层:拼接路径做文件名通配符匹配
do
if test -f "$file" # 判断是否普通文件
then
result="$file"
echo $file
test "$all" = "no" && break 2 # break 2 跳出两层循环!
fi
done
done
test -z "$result" && warning "$pattern: not found"
done
|
重点:break 2,一次性跳出内层、中层两层循环;如果--all没有开启,找到第一个匹配就直接停止该目录搜索。
退出码限制:shell退出码最大有效值125,超过就强制截断。
1
2
|
test $EXITCODE -gt 125 && EXITCODE=125
exit $EXITCODE
|
$#与$@区别
把两个点讲清楚
$# 数字:参数的总个数
"$@" 全部位置参数「内容」,是一整组参数列表,不是数字!
先回顾 pathfind 这几行关键代码:
1
2
3
4
|
envvar="$1"
test $# -gt 0 && shift # ←这里执行了shift!
# ……后面才是
for pattern in "$@"
|
调用示例:
调用这条命令时,脚本刚启动那一刻:
$1 = PATH
$2 = ls
$3 = gcc
$# = 3 (一共3个参数,这是数字)
"$@" = "PATH" "ls" "gcc" (全部参数的内容)
第一步:envvar="$1"
把第一个参数 $1(也就是PATH)存到变量envvar。
此时位置参数还没变:$1=PATH,$2=ls,$3=gcc
第二步:test $# -gt 0 && shift
执行了 shift!
shift 的作用:丢掉 $1,后面参数整体往前挪一位。
shift执行之后状态发生变化:
$1 现在变成原来的$2 → ls
$2 现在变成原来的$3 → gcc
$# 从 3 变成 2(参数个数,数字)
"$@" 现在变成:"ls" "gcc"
shift把原来第一个参数PATH从位置参数列表里扔掉了,所以后面 "$@" 里面就不再包含 PATH!
PATH并没有消失,它已经提前保存到变量envvar里面留着用了,不再需要留在位置参数里。
分清极易混淆的两个特殊变量
| 特殊变量 |
是什么 |
示例(调用pathfind PATH ls gcc刚启动时) |
$# |
数字,代表参数有多少个 |
3,仅仅是数字,没有字符串内容 |
"$@" |
全部位置参数的内容列表 |
"PATH" "ls" "gcc",是一串参数,不是数字 |
💡记忆:
# 像计数的井号 → 数个数(数字)
@ 代表“全部、每一个”,代表参数内容集合。
shift 前后对比表
调用:pathfind PATH ls gcc
| 项目 |
shift执行之前 |
shift执行之后 |
| $1 |
PATH |
ls |
| $2 |
ls |
gcc |
| $3 |
gcc |
(无) |
| $#(参数数量) |
3 |
2 |
| “$@” |
"PATH" "ls" "gcc" |
"ls" "gcc" |
| envvar |
PATH |
PATH(不受shift影响,已经存起来了) |
所以后面:
1
2
|
for pattern in "$@"
do
|
循环遍历的就是 ls、gcc。
- 第一轮
pattern=ls
- 第二轮
pattern=gcc
PATH已经提前存入envvar,用来拿路径;不再参与pattern循环。
举个极简小测试脚本 test.sh,你可以复制运行验证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
#!/bin/sh
echo "==== shift之前 ===="
echo "\$# = $#"
echo "\$1 = $1"
echo "\"\$@\" : $@"
envvar="$1"
test $# -gt 0 && shift
echo "==== shift之后 ===="
echo "\$# = $#"
echo "\$1 = $1"
echo "\"\$@\" : $@"
echo "envvar=$envvar"
|
运行:
输出:
1
2
3
4
5
6
7
8
9
|
==== shift之前 ====
$# = 3
$1 = PATH
"$@" : PATH ls gcc
==== shift之后 ====
$# = 2
$1 = ls
"$@" : ls gcc
envvar=PATH
|
=号两变空格规则
= 空格规则,分两大场景:赋值 vs test字符串比较
1
|
test "$all" = "no" && break 2
|
这里 = 两边必须要有空格!
1、赋值:变量=值,=两边严禁空格
1
2
|
all=no # 正确赋值,=左右不能空格
all = no # 错误!shell会把all识别成命令,试图运行命令 all,参数是= no
|
赋值语法:VAR=value
=紧贴左右,不能有空格。
- 如果加空格,shell把第一个单词当做命令名;
all被当成要执行的外部命令。
2、test 字符串比较:test 字符串1 = 字符串2,=两边必须空格
1
2
|
test "$all" = "no" # 正确,=两边空格
test "$all"="no" # 错误!没有空格
|
为什么 test "$all"="no" 是错的?
test 是一个命令(内建命令),它接收多个参数。
1
2
|
test "$all" = "no"
# arg1 arg2 arg3
|
- 参数1:
"$all"
- 参数2:
=
- 参数3:
"no"
test依靠这3个参数识别:做字符串相等比较。
如果写:test "$all"="no"
shell只会传给test一个参数:"no=no"(拼接后的一整个字符串)。
test拿到单个字符串,规则:字符串非空就返回true,判断逻辑完全失效,永远为真!
[ "$all" = "no" ] 和test一样,[也是shell内建命令,]是最后一个参数,=两边同样必须空格。[和test命令等价的,都是测试字符串。唯一区别是[需要一个]结束。
1
2
|
[ "$all" = "no" ] # ✅
[ "$all"="no" ] # ❌
|
3、还有一种:==,在不同场景
- POSIX
test / [ ]:只用单个=做字符串相等;== 部分sh不支持,尽量不要用。
- bash的双中括号
[[ "$a" == "$b" ]]:可以用==,两边也要空格。
4、还有哪些地方=不能加空格?
① 变量导出赋值
1
2
|
export ALL=no # ✅无空格
export ALL = no # ❌报错
|
② 算术赋值 $(( )) 内部
$(( ... )) 算术上下文,赋值 = 两边不要空格;比较 ==两边要有空格
1
2
|
a=$((b=10)) # ✅赋值,=无空格
a=$(( b == 10 )) # ✅比较,==两边空格
|
③ case 语句,)前面的等号不是运算符,属于模式,不受这套规则影响
汇总表格
| 场景 |
写法要求 |
示例 |
| 普通变量赋值 |
=两边不能有空格 |
all=no ✅;all = no ❌ |
export赋值 |
=两边不能有空格 |
export all=no ✅ |
test / [ ] 字符串相等比较 |
=两边必须有空格 |
test "$all" = "no" ✅;test "$all"="no" ❌ |
$(( )) 算术赋值 |
=两边不要空格 |
x=$((i=5)) |
$(( )) 算术相等判断 |
==两边要有空格 |
x=$(( i == 5 )) |
[[ ]] bash字符串比较 |
==两边要有空格 |
[[ $a == $b ]] |
回到原句
1
|
test "$all" = "no" && break 2
|
拆解:
test 是命令;"$all"、=、"no" 是传给test的3个独立参数,=左右必须空格。
&&:前面test返回0(真),才执行后面 break 2。
坑点模拟演示
1
2
3
4
5
|
all="yes"
# 错误写法,少空格
if test "$all"="no"; then
echo "条件成立"
fi
|
哪怕all=yes,依然输出条件成立!
因为传给test只有一个参数字符串 yes=no,非空字符串,test直接判定为真,根本没有做相等比较。
记忆口诀
赋值等号不能隔;比较等号两边隔。
赋值:var=val紧挨着;
test比较:test A = B 等号左右留空格。
补充小提示:很多新手写错 if [ $a=$b ],就是这里踩坑。
pathfind关键知识点小结
- shell工程脚本标准模板:安全初始化 → 辅助函数 → 变量初始化 → while+case解析长选项 → 健壮校验 → 业务逻辑 → 合理退出码。
break N:跳出N层循环。
basename $0 获取脚本自身名字,避免硬编码脚本名称。配套dirname取出路径。
eval用于动态解析变量名字;慎用。
- 退出码上限125;找不到的文件数量作为脚本退出状态。
- 安全实践:重置
IFS、重置可信PATH。
8.2 build‑all 工具(高级实战)
功能简介
并行在多台远程主机上编译源码软件包。
- 本地找到源码压缩包;scp复制到各个远程机器;
- 远程机器解压包,执行
configure、make、make check;
- 后台并行执行多台主机构建,互不等待;全部构建输出保存独立日志;
- 支持配置文件指定主机列表、编译环境;支持命令行选项覆盖配置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
|
# ! /bin/sh -
# shebang:指定使用POSIX sh解释器;- 安全选项,禁止欺骗脚本
# 在一台或多台构建主机上,并行构建一个或多个包
#
# 语法
# build-all [--?]
# [--all "..."]
# [--cd "..."]
# [--check "..."]
# [--configure "..."]
# [--environment "..."]
# [--help]
# [--logdirectory dir]
# [--on "[user@]host[:dir][,envfile] ..."]
# [--source "dir ..."]
# [--userhosts "file(s)"]
# [--version]
# package(s)
#
# 可选用的初始化文件
# $HOME/.build/directories 源码搜索目录列表
# $HOME/.build/userhosts 远程主机列表 [user@]host[:dir][,envfile]
# 重置IFS恢复默认:空格、tab、换行,抵御IFS环境变量注入攻击
IFS='
'
# 重置PATH,只保留系统可信目录,防止恶意同名程序劫持
PATH=/usr/local/bin:/bin:/usr/bin
export PATH # export:子进程继承这个安全PATH
UMASK=002 # 设置掩码变量,002 新建文件组可读写,其他只读
umask $UMASK # 设置进程实际umask
##############################################################################
# 函数 build_one:处理单台远程主机完整构建流程
# 入参示例 $1 = [email protected]:/local/build,$HOME/.build/c99
##############################################################################
build_one() {
# 语法:
# build_one [user@]host[:build‑directory][,envfile]
# arg="`eval echo $1`"
# $1输入:[email protected]:/local/build,$HOME/.build/c99
# eval会展开$HOME,执行echo,得到[email protected]:/local/build,/home/jones/.build/c99
arg="`eval echo $1`"
# userhost="`echo $arg | sed -e 's/:.*//'`"
# sed:把第一个冒号以及后面全部删掉
# [email protected]:/local/build,xxx
# 结果 [email protected]
userhost="`echo $arg | sed -e 's/:.*//'`"
# user="`echo $userhost | sed -e 's/@.*//'`"
# sed:把@以及后面全部删掉
# [email protected]
# 结果 user=jones
user="`echo $userhost | sed -e 's/@.*//'`"
# test "$user" = "$userhost" && user=$USER
# 判断:如果没有@符号(user等于userhost),说明没有指定用户名,则取当前本地登录用户$USER
# 例:userhost=freebsd.example.com,则user=freebsd.example.com,条件成立 user=$USER
test "$user" = "$userhost" && user=$USER
# host="`echo $userhost | sed -e 's/^[^@]*@//'`"
# sed:删除从开头到@的全部字符;拿到主机名
# [email protected]
# 结果 host=freebsd.example.com
host="`echo $userhost | sed -e 's/^[^@]*@//'`"
# envfile="`echo $arg | sed -e 's/^[^,]*,//'`"
# sed:删除开头直到第一个逗号;取逗号后面的环境配置文件路径
# [email protected]:/local/build,$HOME/.build/c99
# 结果 envfile=/home/jones/.build/c99
envfile="`echo $arg | sed -e 's/^[^,]*,//'`"
# test "$envfile" = "$arg" && envfile=/dev/null
# 判断:如果字符串没变=没有逗号,代表没有传入envfile,设置为/dev/null空文件
test "$envfile" = "$arg" && envfile=/dev/null
# builddir="`echo $arg | sed -e 's/^.*://' -e 's/,.*//'`"
# 两个sed:①删除开头到冒号;②删除逗号以及后面;拿到远程构建目录
# [email protected]:/local/build,$HOME/.build/c99
# 结果 builddir=/local/build
builddir="`echo $arg | sed -e 's/^.*://' -e 's/,.*//'`"
# test "$builddir" = "$arg" && builddir=/tmp
# 判断:没有冒号,未指定构建目录,默认使用/tmp
test "$builddir" = "$arg" && builddir=/tmp
# parbase=`basename $PARFILE`
# basename:去掉路径,只取文件名
# PARFILE=/home/src/gcc‑12.2.tar.gz → parbase=gcc‑12.2.tar.gz
parbase=`basename $PARFILE`
# NB: 如果这些模式被更换过,则更新 find_package()
# package=`echo $parbase | sed 多条替换,去掉各种压缩包后缀
# parbase=gcc‑12.2.tar.gz → package=gcc‑12.2
package=`echo $parbase | \
sed -e 's/[.]jar$//' \
-e 's/[.]tar[.]bz2$//' \
-e 's/[.]tar[.]gz$//' \
-e 's/[.]tar[.]Z$//' \
-e 's/[.]tar$//' \
-e 's/[.]tgz$//' \
-e 's/[.]zip$//'`
# 如果我们在远程主机上看不到包文件,则复制过去
# 打印将要执行的ssh命令,仅日志输出,不会执行
echo $SSH $SSHFLAGS $userhost "test -f $PARFILE"
# if $SSH $SSHFLAGS $userhost "test -f $PARFILE"
# ssh登录远程主机执行 test -f $PARFILE,判断该压缩包是否已经存在远程
if $SSH $SSHFLAGS $userhost "test -f $PARFILE"
then
# 远程已经存在,parbaselocal直接使用远程完整路径,不需要scp上传
parbaselocal=$PARFILE
else
# 远程不存在:parbaselocal只用文件名;执行scp把本地包传到远程builddir目录
parbaselocal=$parbase
echo $SCP $PARFILE $userhost:$builddir
$SCP $PARFILE $userhost:$builddir
fi
# 在远程主机上解开存档文件、构建,及以后台执行方式检查它
sleep 1 # 睡眠1秒,保证时间戳不同,日志文件名不会冲突
now=`date $DATEFLAGS` # 获取当前时间戳,例 2026.09.14.10.20.30
logfile="$package.$host.$now.log" # 日志文件名 gcc‑12.2.freebsd.example.com.2026.09.14.10.20.30.log
# nice $SSH ... "一大段远程shell脚本" < /dev/null > "$LOGDIR/$logfile" 2>&1 &
# nice:降低进程优先级;
# ssh把双引号内全部文本发送远程机器执行;
# < /dev/null:清空ssh标准输入,防止ssh挂起等待输入;
# >... 2>&1:标准输出、标准错误全部写入本地日志文件;
# & 【后台运行】:build_one函数立刻返回,不等远程编译结束!
nice $SSH $SSHFLAGS $userhost "
echo '
# 尝试执行远程前置钩子脚本BUILDBEGIN;兼容 .(posix) 和 source(csh/bash);|| true保证失败不中断
test -f $BUILDBEGIN && . $BUILDBEGIN || \
test -f $BUILDBEGIN && source $BUILDBEGIN || \
true;
# 打印各种诊断信息,全部写入日志
echo 'Package: $package'
echo 'Archive: $PARFILE'
echo 'Date: $now'
echo 'Local user: $USER'
echo 'Local host: `hostname`'
echo 'Local log directory: $LOGDIR'
echo 'Local log file: $logfile'
echo 'Remote user: $user'
echo 'Remote host: $host'
echo 'Remote directory: $builddir'
printf 'Remote date: '
date $DATEFLAGS
printf 'Remote uname: '
uname -a || true;
printf 'Remote gcc version: '
gcc --version | head -n 1 || echo;
printf 'Remote g++ version: '
g++ --version | head -n 1 || echo;
# 读取envfile配置,去掉注释;换行转为空格
echo 'Configure environment: `$STRIPCOMMENTS $envfile | $JOINLINES`'
echo 'Extra environment: $EXTRAENVIRONMENT'
echo 'Configure directory: $CONFIGUREDIR'
echo 'Configure flags: $CONFIGUREFLAGS'
echo 'Make all targets: $ALLTARGETS'
echo 'Make check targets: $CHECKTARGETS'
echo 'Disk free report for $builddir/$package:'
df $builddir | $INDENT
echo 'Environment:'
env | env LC_ALL=C sort | $INDENT
echo '
umask $UMASK; # 设置远程机器文件掩码
cd $builddir || exit 1; # 进入远程构建目录,失败直接退出远程脚本
/bin/rm -rf $builddir/$package; # 删除旧源码目录,保证干净构建;用绝对rm,规避别名‑i
$PAR $parbaselocal; # 执行解压命令,例 tar xfz gcc‑12.2.tar.gz
test '$parbase' = '$parbaselocal' && /bin/rm -f $parbase; # 如果是scp上传的包,解压完毕删除远程压缩包
cd $package/$CONFIGUREDIR || exit 1; # 进入源码构建子目录
test -f configure && \
chmod a+x configure && \ # configure增加可执行权限
env `$STRIPCOMMENTS $envfile | $JOINLINES` \ # 加载envfile内环境变量
$EXTRAENVIRONMENT \
nice time ./configure $CONFIGUREFLAGS ; # 执行configure配置
nice time make $ALLTARGETS && nice time make $CHECKTARGETS ; # make编译 && make check测试
echo '==============================' ;
echo 'Disk free report for $builddir/$package:' ;
df $builddir | $INDENT ;
printf 'Remote date: ' ;
date $DATEFLAGS ;
cd; # 切回远程用户家目录
test -f $BUILDBEGIN && . $BUILDBEGIN || \
test -f $BUILDBEGIN && source $BUILDBEGIN || \
true ;
echo '==============================' ;
" < /dev/null > "$LOGDIR/$logfile" 2>&1 &
}
##############################################################################
# find_file函数:判断文件是否可读;找到则用全局变量PAR、PARFILE保存解压命令和文件路径
# 参数1:文件路径;参数2:解压命令字符串,如 "tar xfz"
# 返回0成功,1失败;sh函数只能返回0‑255状态;结果靠全局变量传出
##############################################################################
find_file() {
# Usage:
# find_file file program‑and‑args
# Return 0 (success) if found, 1 (failure) if not found
if test -r "$1" # test -r 判断文件是否存在并且可读
then
PAR="$2" # 全局变量PAR保存解压命令,例 tar xfz
PARFILE="$1" # 全局变量PARFILE保存完整文件路径
return 0 # return 0 函数执行成功
else
return 1 # return 1 文件不可读,失败
fi
}
##############################################################################
# find_package函数:在SRCDIRS源码目录列表查找软件压缩包
# $1入参:包名,例 gcc‑12.2
# 找到后全局变量 PAR、PARFILE 赋值;找到就return退出函数
##############################################################################
find_package() {
# Usage: find_package package‑x.y.z
# base=`echo "$1" | sed -e 's/[-_][.]*[0‑9].*$//'`
# sed:切掉版本号部分;输入gcc‑12.2 → base=gcc
base=`echo "$1" | sed -e 's/[-_][.]*[0-9].*$//'`
PAR= # 清空全局解压命令变量
PARFILE= # 清空全局文件路径变量
for srcdir in $SRCDIRS # 循环遍历每一个源码搜索目录 SRCDIRS是空格分割字符串
do
test "$srcdir" = "." && srcdir="`pwd`" # 如果目录是点,替换成当前工作目录绝对路径
for subdir in "$base" "" # 优先在gcc子目录查找;找不到再直接当前目录查找
do
# NB: update package setting in build_one() if this list changes
# 依次尝试各种压缩后缀;find_file找到文件,&& return 立刻退出整个函数
find_file $srcdir/$subdir/$1.tar.gz "tar xfz" && return
find_file $srcdir/$subdir/$1.tar.Z "tar xfz" && return
find_file $srcdir/$subdir/$1.tar "tar xf" && return
find_file $srcdir/$subdir/$1.tar.bz2 "tar xfj" && return
find_file $srcdir/$subdir/$1.tgz "tar xfz" && return
find_file $srcdir/$subdir/$1.zip "unzip -q" && return
find_file $srcdir/$subdir/$1.jar "jar xf" && return
done
done
}
##############################################################################
# set_userhosts函数:读取主机配置文件,把配置内容追加到ALTUSERHOSTS全局变量
# 参数$@:传入一个或多个配置文件名
##############################################################################
set_userhosts() {
# Usage: set_userhosts file(s)
for u in "$@" # 循环每一个传入的文件名
do
if test -r "$u" # 如果文件直接可读
then
ALTUSERHOSTS="$ALTUSERHOSTS $u"
elif test -r "$BUILDHOME/$u" # 否则去 ~/.build/ 目录下找该文件
then
ALTUSERHOSTS="$ALTUSERHOSTS $BUILDHOME/$u"
else
error "File not found: $u" # 都找不到,报错退出
fi
done
}
##############################################################################
# usage() 打印帮助文本,使用here‑doc cat <<EOF,避免大量echo
##############################################################################
usage() {
cat <<EOF
Usage:
$PROGRAM [--?]
[--all "..."]
[--cd "..."]
[--check "..."]
[--configure "..."]
[--environment "..."]
[--help]
[--logdirectory dir]
[--on "[user@]host[:dir][,envfile] ..."]
[--source "dir ..."]
[--userhosts "file(s)"]
[--version]
package(s)
EOF
}
##############################################################################
# usage_and_exit:打印帮助,然后以传入状态码退出脚本
##############################################################################
usage_and_exit() {
usage
exit $1
}
##############################################################################
# version:打印脚本版本号
##############################################################################
version() {
echo "$PROGRAM version $VERSION"
}
##############################################################################
# warning:输出警告到stderr;不退出脚本;EXITCODE计数器+1,统计错误数量
# expr实现整数+1,兼容非常古老sh,不使用$(( ))
##############################################################################
warning() {
echo "$@" 1>&2
EXITCODE=`expr $EXITCODE + 1`
}
##############################################################################
# ================== 全局变量初始化,脚本真正业务入口之前 ==================
##############################################################################
ALLTARGETS= # make all目标,--all选项传入
altlogdir= # --logdirectory 用户指定日志目录
altsrcdirs= # --source 命令行传入的额外源码目录
ALTUSERHOSTS= # 保存主机配置文件名称列表
BUILDBEGIN=./.build/begin # 远程构建前执行钩子脚本
BUILDEND=./.build/end # 远程构建结束钩子脚本
BUILDHOME=$HOME/.build # 用户配置根目录 ~/.build
CHECKTARGETS=check # 默认make check目标
CONFIGUREDIR= # --cd:源码子构建目录
CONFIGUREFLAGS= # --configure:configure参数
DATEFLAGS="+%Y.%m.%d.%H.%M.%S" # date时间格式,用于日志文件名
EXITCODE=0 # 全局错误计数
EXTRAENVIRONMENT= # --environment:额外环境变量
INDENT="awk '{print \" \" \$0}'" # 工具字符串:输出缩进4空格
JOINLINES="tr '\n' ' '" # 工具字符串:换行转为空格
LOGDIR= # 本地日志存放目录
PROGRAM=`basename $0` # 获取脚本自身名字,例 build‑all
SCP=scp # scp命令,方便替换为rcp;调试可改为echo只打印
SSH=ssh # ssh命令,方便替换rsh;调试改为echo
SSHFLAGS=${SSHFLAGS--x} # ssh参数;未设置环境变量则默认‑x关闭X11转发
STRIPCOMMENTS='sed -e s/#.*$//' # 工具字符串:sed删除#开头注释
userhosts= # 解析完成后的远程主机列表字符串
VERSION=1.0 # 脚本版本
# 默认的初始化文件路径
defaultdirectories=$BUILDHOME/directories
defaultuserhosts=$BUILDHOME/userhosts
# SRCDIRS="`$STRIPCOMMENTS $defaultdirectories 2> /dev/null`"
# 读取directories配置文件,用sed去掉#注释;stderr屏蔽错误(文件不存在)
SRCDIRS="`$STRIPCOMMENTS $defaultdirectories 2> /dev/null`"
# test -z "$SRCDIRS" && SRCDIRS="......"
# 如果读取配置后SRCDIRS为空,赋值内置默认源码搜索目录列表
test -z "$SRCDIRS" &&
SRCDIRS="
/usr/local/src
/usr/local/gnu/src
$HOME/src
$HOME/gnu/src
/tmp
/tmp
/tmp
/var/tmp
"
##############################################################################
# ==========【脚本业务真正入口】:while+case+shift解析命令行长选项 ==========
# 示例调用 build‑all --on host1 gcc‑12
# $#为参数总个数;循环直到参数处理完毕
##############################################################################
while test $# -gt 0
do
case $1 in
# --all /‑a 选项;shift把选项吃掉;$1保存传入make目标
--all | --al | --a | -all | -al | -a )
shift
ALLTARGETS="$1"
;;
# --cd 选项,指定源码内部构建子目录
--cd | --cd )
shift
CONFIGUREDIR="$1"
;;
# --check make check目标参数
--check | --chec | --che | --ch | -check | -chec | -che | -ch )
shift
CHECKTARGETS="$1"
;;
# --configure 传入configure命令参数
--configure | --configur | --configu | --config | --confi | \
--conf | --con | --co | \
-configure | -configur | -configu | -config | -confi | \
-conf | -con | -co )
shift
CONFIGUREFLAGS="$1"
;;
# --environment 设置额外环境变量
--environment | --environmen | --environme | --environm | --environ | \
--enviro | --envir | --envi | --env | --en | --e | \
-environment | -environmen | -environme | -environm | -environ | \
-enviro | -envir | -envi | -env | -en | -e )
shift
EXTRAENVIRONMENT="$1"
;;
# --help 打印帮助,退出码0成功
--help | --hel | --he | --h | '--?' | -help | -hel | -he | -h | '-?' )
usage_and_exit 0
;;
# --logdirectory 指定日志目录
--logdirectory | --logdirector | --logdirecto | --logdirect | \
--logdirec | --logdire | --logdir | --logdi | --logd | --log | \
--lo | --l | \
-logdirectory | -logdirector | -logdirecto | -logdirect | \
-logdirec | -logdire | -logdir | -logdi | -logd | -log | -lo | -l )
shift
altlogdir="$1"
;;
# --on 在命令行直接添加远程主机
--on | --o | -on | -o )
shift
userhosts="$userhosts $1"
;;
# --source 添加额外源码搜索目录
--source | --sourc | --sour | --sou | --so | --s | \
-source | -sourc | -sour | -sou | -so | -s )
shift
altsrcdirs="$altsrcdirs $1"
;;
# --userhosts 指定主机列表配置文件
--userhosts | --userhost | --userhos | --userho | --userh | \
--user | --use | --us | --u | \
-userhosts | -userhost | -userhos | -userho | -userh | \
-user | -use | -us | -u )
shift
set_userhosts "$1"
;;
# --version打印版本,退出0
--version | --versio | --versi | --vers | --ver | --ve | --v | \
-version | -versio | -versi | -vers | -ver | -ve | -v )
version
exit 0
;;
# ‑*)捕获所有以‑开头的未知选项,调用error报错退出
-*)
error "Unrecognized option: $1"
;;
# *) 非选项,全部是包名,跳出解析循环;剩余$@就是软件包名称
*)
break
;;
esac
shift # shift丢弃已经处理完的$1,参数整体向前移动一位
done
##############################################################################
# 寻找适当的邮件客户端程序,用于发送邮件通知日志位置
# 循环遍历候选mail/mailx路径,找到第一个可执行程序就break跳出
##############################################################################
for MAIL in /bin/mailx /usr/bin/mailx /usr/sbin/mailx /usr/ucb/mailx \
/bin/mail /usr/bin/mail
do
test -x $MAIL && break
done
test -x $MAIL || error "Cannot find mail client" # 找不到邮件程序直接报错退出
# 命令行--source传入目录优先级高于配置文件,放SRCDIRS前面
SRCDIRS="$altsrcdirs $SRCDIRS"
# 处理主机列表:合并命令行‑‑on 和 userhosts配置文件内容
if test -n "$userhosts"
then
# 如果命令行--on已经填了主机,则追加配置文件内主机
test -n "$ALTUSERHOSTS" &&
userhosts="$userhosts `$STRIPCOMMENTS $ALTUSERHOSTS 2> /dev/null`"
else
# 命令行没给主机,使用默认配置文件 defaultuserhosts
test -z "$ALTUSERHOSTS" && ALTUSERHOSTS="$defaultuserhosts"
userhosts="`$STRIPCOMMENTS $ALTUSERHOSTS 2> /dev/null`"
fi
# 检查是否没有任何远程主机,userhosts为空直接报错退出
test -z "$userhosts" && usage_and_exit 1
##############################################################################
# 主业务循环:遍历每一个待构建软件包;$@ 经过选项解析后,剩下的全部包名
##############################################################################
for p in "$@"
do
find_package "$p" # 调用函数,本地查找p这个包;结果存入全局PARFILE
if test -z "$PARFILE" # PARFILE为空=没有找到源码包
then
warning "Cannot find package file $p" # 警告计数+1;不退出,继续下一个包
continue
fi
LOGDIR="$altlogdir" # 优先使用用户‑‑logdirectory指定日志目录
# 判断日志目录:未设置 / 不是目录 / 不可写;则依次fallback多个候选目录
if test -z "$LOGDIR" -o ! -d "$LOGDIR" -o ! -w "$LOGDIR"
then
for LOGDIR in "`dirname $PARFILE`/logs/$p" $BUILDHOME/logs/$p \
/usr/tmp /var/tmp /tmp
do
test -d "$LOGDIR" || mkdir -p "$LOGDIR" 2> /dev/null # 目录不存在尝试创建,屏蔽错误
test -d "$LOGDIR" -a -w "$LOGDIR" && break # 找到可写目录立刻跳出循环
done
fi
# 组装提示消息,控制台打印,同时发邮件给$USER,告知日志位置
msg="Check build logs for $p in `hostname`: $LOGDIR"
echo "$msg"
echo "$msg" | $MAIL -s "$msg" $USER 2> /dev/null
# 循环每一台远程主机,调用build_one;build_one内部ssh带&后台执行,不会阻塞
for u in $userhosts
do
build_one $u
done
done
# shell退出码最大有效值125;超过强制截断赋值125
test $EXITCODE -gt 125 && EXITCODE=125
exit $EXITCODE # 脚本退出,返回错误计数值
|
调用示例
1.准备配置目录
脚本默认读取 $HOME/.build
directories:本地搜索源码包的目录列表,一行一个路径;
userhosts:远程主机列表,每行格式示例:
字段:用户名@主机:远程构建目录,环境变量配置文件
$HOME/.build/c99示例(环境配置文件,设置编译器)
2.使用示例
1
2
3
4
5
6
7
8
|
# 在全部配置的主机编译两个包
build-all coreutils‑5.2.1 gawk‑3.1.4
# 只在指定主机编译
build-all --on loaner.example.com gnupg‑1.2.4
# 指定本地源码搜索目录
build-all --source $HOME/work butter‑0.3.7
|
3.脚本输出
- 本地控制台打印日志路径;同时发邮件通知;
- 真正编译输出保存在本地LOGDIR目录下日志文件,文件名格式
包名.主机.时间.log;
- 因为远程任务后台运行,脚本退出不等于编译结束,需要看日志确认编译是否成功。
核心要点拆解
脚本整体分为 5 大块
- 脚本头部:shebang、安全初始化、全局变量、工具字符串常量
- 全部自定义函数集合(业务能力封装)
- 命令行长选项解析(
while+case+shift)
- 配置文件读取、环境预处理(邮件客户端、主机列表、源码搜索目录)
- 主业务循环:遍历每个软件包 → 查找源码包 → 确定日志目录 → 循环每台主机调用
build_one后台构建 → 设置退出码退出
第一块:脚本头部
1
2
3
4
5
6
7
|
#! /bin/sh -
IFS='
'
PATH=/usr/local/bin:/bin:/usr/bin
export PATH
UMASK=002
umask $UMASK
|
#!/bin/sh -:调用 POSIX sh,-开启安全模式,防止欺骗。
IFS重置为默认空格 /tab/ 换行,抵御 IFS 注入。
- 重置 PATH 为系统可信目录,export 让子进程继承安全 PATH。
umask 002:新建文件权限掩码,组用户可读可写。
紧接着一大片全局变量:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
BUILDBEGIN=./.build/begin # 远程构建前要执行的钩子脚本
BUILDEND=./.build/end # 远程构建结束后钩子脚本
BUILDHOME=$HOME/.build # 用户配置目录 ~/.build
CHECKTARGETS=check # 默认make目标 make check
DATEFLAGS="+%Y.%m.%d.%H.%M.%S" # 日志文件名时间戳格式
SSH=ssh
SCP=scp
SSHFLAGS=${SSHFLAGS--x} # ssh默认参数 -x关闭X11转发
STRIPCOMMENTS='sed -e s/#.*$//' # 去掉配置文件#注释
INDENT="awk '{print \" \" \$0}'" # 输出缩进
JOINLINES="tr '\n' ' '" # 换行转空格
PROGRAM=`basename $0` # 获取脚本自身名字
VERSION=1.0
EXITCODE=0 # 错误计数
……
|
第二块:全部自定义函数集合
| 函数 |
功能说明 |
重要细节 |
build_one() |
处理单台远程主机完整构建流程,脚本最复杂函数 |
解析user@host:builddir,envfile;ssh执行一大段远程shell,放到后台&;输出全部重定向日志 |
find_file() |
判断文件是否可读,保存解压命令与文件路径 |
没有return返回数据,使用全局变量PAR、PARFILE传出结果,sh函数只能返回0‑255状态码,复杂结果靠全局变量 |
find_package() |
遍历源码目录,查找软件压缩包,识别多种压缩后缀 |
.tar.gz/.tar.bz2/.tgz/.zip/.jar;循环调用find_file,找到就return退出函数 |
set_userhosts() |
读取主机列表配置文件 |
读取$HOME/.build/xxx主机配置,保存到ALTUSERHOSTS |
usage() |
打印帮助文本 |
使用cat <<EOF here‑doc,不再一堆echo |
usage_and_exit() |
打印帮助 + 指定状态码退出 |
复用usage |
version() |
打印版本号 |
|
warning() |
打印警告,不退出脚本,EXITCODE+1 |
用expr做整数自增,兼容古老sh |
重点拆解 build_one() 函数(核心难点)
入参示例:[email protected]:/local/build,$HOME/.build/c99
格式规则:[用户@]主机[:远程构建目录][,环境变量配置文件]
build_one内部步骤
- 解析参数字符串,用sed拆分4部分:
- user:远程用户名
- host:远程主机名
- builddir:远程机器工作目录,缺省
/tmp
- envfile:远程环境配置文件,缺省
/dev/null
- 判断远程机器上是否已经存在这个源码包:
- 如果远程已经存在:不复制;
- 不存在:调用
scp把本地压缩包传到远程。
sleep 1:保证日志文件名时间戳不重复。
- 拼接日志文件名:
包名.主机.时间.log
nice $SSH ... "长shell脚本" < /dev/null >日志 2>&1 &
nice降低CPU优先级,不抢占交互式用户资源;
- ssh向远程主机传递一大段多行shell代码;
< /dev/null:把ssh标准输入置空,防止挂起等待输入;
>log 2>&1:stdout、stderr全部写入本地日志;
- 末尾
&:放到后台执行! → 实现多台主机并行,build_one立刻返回,不会等待编译结束。
远程机器上执行的整套流程(ssh传递过去的脚本)
- 优先执行钩子脚本
$BUILDBEGIN;兼容. 和 source 两种shell;
- 打印大量诊断信息写入日志:包名、时间、gcc/g++版本、磁盘剩余、全部环境变量;
umask设置权限;cd进入远程构建目录;
/bin/rm -rf 删除旧构建目录,干净构建环境;
- 解压源码包;
- 加载envfile里面的编译环境变量,执行
./configure;
make编译、make check做测试;
- 打印构建结束信息,执行结束钩子脚本
$BUILDEND。
坑:引号嵌套极其麻烦:一部分变量本地shell展开,另一部分传到远程主机再展开。
find_package 函数说明
1
2
|
find_package() {
base=`echo "$1" | sed -e 's/[-_][.]*[0-9].*$//'`
|
比如输入gcc‑13.2.0,sed把版本号切掉得到base=gcc;
会优先在gcc/子目录下找压缩包,找不到再在当前目录查找。
识别多种后缀,匹配成功调用find_file,保存解压命令到全局变量PAR,文件路径保存到PARFILE。
shell没有真正的函数返回值,find_package找到包之后,外部脚本读取全局变量PARFILE判断是否找到。
第三块:命令行选项解析
while test $# -gt 0; do case ... esac; shift; done
支持长选项:
--all --cd --check --configure --environment --logdirectory --on --source --userhosts --help --version
逻辑:
while test $# -gt 0 参数大于0循环;
- case匹配选项,部分选项需要
shift 拿后面参数;
-*)捕获非法选项,调用error报错退出;
*) break:遇到非选项,跳出循环;
- 每轮末尾
shift,处理完一个参数丢掉$1。
解析完选项之后剩下的位置参数就是要构建的软件包名字。
第四块:预处理
- 寻找mail/mailx邮件程序:构建完成发送邮件通知日志位置。遍历一堆路径,找到第一个可执行的邮件客户端。找不到直接error退出。
- 拼接源码搜索目录
SRCDIRS:命令行--source优先级高于配置文件。
- 读取
userhosts主机列表:
- 命令行
--on可以直接写主机;
--userhosts读取配置文件;
- 默认读取
$HOME/.build/userhosts;
- 使用
$STRIPCOMMENTS去掉配置文件中#注释。
- 校验:如果
userhosts为空,没有任何主机,直接报错退出。
第五块:主循环业务逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
for p in "$@" # 遍历每一个软件包
do
find_package "$p" # 本地查找源码包
if test -z "$PARFILE" # PARFILE为空=找不到包
then
warning "Cannot find package file $p"
continue
fi
# 挑选日志目录,多位置fallback:源码目录logs → ~/.build/logs → /usr/tmp → /tmp
LOGDIR="$altlogdir"
if test -z "$LOGDIR" -o ! -d "$LOGDIR" -o ! -w "$LOGDIR"
then
for LOGDIR in "`dirname $PARFILE`/logs/$p" $BUILDHOME/logs/$p /usr/tmp /var/tmp /tmp
do
test -d "$LOGDIR" || mkdir -p "$LOGDIR" 2> /dev/null
test -d "$LOGDIR" -a -w "$LOGDIR" && break
done
fi
# 发送邮件提示日志位置
msg="Check build logs for $p in `hostname`: $LOGDIR"
echo "$msg"
echo "$msg" | $MAIL -s "$msg" $USER 2> /dev/null
for u in $userhosts # 遍历每一台远程主机
do
build_one $u # 启动后台构建任务,不阻塞
done
done
# shell退出码最大125,超过截断
test $EXITCODE -gt 125 && EXITCODE=125
exit $EXITCODE
|
⚠关键点:build_one $u内部ssh命令带&后台运行。
脚本不会等待远程编译完成,执行完立刻退出;编译还在远程服务器跑。
如果希望脚本阻塞等待全部任务结束,脚本末尾增加一条 wait。
本章通用shell工程实践总结
脚本的工程经验
- 日志非常重要:记录时间、编译器版本、磁盘空间、环境变量,方便排查编译失败。
- 健壮性:目录不存在尝试自动创建日志目录,多位置fallback。
- 安全:脚本禁止root运行,远程执行
/bin/rm -rf要谨慎。
- 可移植性兼容不同shell:同时兼容
. 和source;很多操作用|| true避免命令失败直接终止整条ssh命令。
- 变量展开区分:哪些变量在本地shell展开,哪些在远程主机shell展开,引号处理很复杂。
脚本开头安全模板
1
2
3
4
5
6
|
#! /bin/sh -
IFS='
'
OLDPATH="$PATH"
PATH=/bin:/usr/bin
export PATH
|
辅助函数模板 error / warning / usage
1
2
3
4
|
error() { echo "$@" 1>&2; usage_and_exit 1; }
warning() { echo "$@" 1>&2; EXITCODE=`expr $EXITCODE + 1`; }
usage() { cat <<EOF ... EOF; }
usage_and_exit(){ usage; exit $1; }
|
长选项解析模板 while + case + shift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
while test $# -gt 0
do
case $1 in
--xxx|-x)
#处理逻辑
;;
--help|-h)
usage_and_exit 0
;;
--version|-v)
version; exit 0
;;
-*)
error "unknown option $1"
;;
*)
break
;;
esac
shift
done
|
小工具
basename $0 获取脚本名字;dirname获取路径;
break N 跳出N层循环;
"x$var"技巧,避免变量为空/以‑开头干扰test;
- 退出码上限125,超过强制赋值125;
- shell函数没有返回值,复杂结果靠全局变量输出;
eval慎用,用于动态解析变量名;
- 远程ssh批量任务可以放到后台
&实现并行;需要等待则使用wait。
小结
eval会完整重跑一遍shell解析流程,输入不可信的时候会产生安全漏洞。
- ssh传递复杂多行命令,本地和远程两层shell引号嵌套极其容易出错。
- 后台运行子shell,父shell拿不到子shell内变量;需要用全局变量返回结果。