【动机】
shc
(Shell Compiler)是一个工具,用于将shell脚本编译成二进制执行文件,这样可以防止源代码泄露,并提高脚本的执行效率. 假设存在两个文件, 结构是:

其中, target 目录用来保存生成的可执行文件. hello.sh 的内容:
#!/bin/bash
say_hello(){
echo "hello,world! from file lib/hello.sh"
}
main.sh 的内容:
#!/bin/bash
#source ./lib/hello.sh
echo "this content is from main.sh"
#say_hello
执行脚本 main.sh,结果没什么好说的:

现在编译 main.sh:

得到临时文件 main.sh.x.c 和 目标文件 target/main:

运行可执行文件 main:

一切正常!
现在, 打开 main.sh 源文件被注释掉的两行代码:
#!/bin/bash
source ./lib/hello.sh
echo "this content is from main.sh"
say_hello
运行 main.sh

先删除之前生成的 main.sh.x.c 和 target/main, 以免混淆视听. 然后编译 main.sh, 无报错, 貌似一切正常:

现在执行生成的可执行文件 target/main, 貌似一切正常:

最后, 删除 lib/hello.sh 或者将其改名:

再次运行 target/main, 报错:

这意味着, 编译后得到可执行体, 还依赖于其他内部被 source 的文件, 即可执行体迁移到哪, 这些文件还得跟到哪, 且需要保持相对目录位置正确!
如何在编译时同时编译内部被 source 的文件, 以消除生成的可执行体对上述被 source 的文件(甚至可能存在的嵌套 source 的文件)的依赖?
【意图】
根据入口源文件中 source 文件的各种形式, 将该文件内容嵌入到其上级文件的适当位置, 最后得到一个无外部依赖的完整文件, shc 命令编译该完整文件即可. 注意, 生成完整文件过程中, 还需要去除所有注释, 以保证 shc 执行不受注释的影响.
【预备】
在 file.sh 文件中, 添加函数 path_parse, 封装对已知路径的名称/扩展名等等方面的需求.
:<<COMMENT
路径解析
$1: 文件路径(可以是目录或文件)
$2: 要解析的条目, 取值(以下提及的扩展名, 均包含 .):
1) par 所在目录(父目录)绝对路径(无尾部斜杆)
2) namex 名称(目录或文件, 包括扩展名)
3) exts 所有扩展名
4) ext 最后一个扩展名
5) 其他(包括空字符), 默认为名称(目录或文件, 不包括扩展名)
用法: path_parse <filepath> < par | namex | exts | ext | {any-else means only name} >
注意:
1) 不对给定路径的存在性检查
COMMENT
path_parse(){
local path=$(readlink -m "$1" ) # 绝对路径
local parent=$(dirname "$path")
local fullname=$(basename "$path")
local result temp
case "$2" in
par)
result=$parent;;
namex)
result=$fullname;;
exts)
temp=${fullname#*.}
[ "$temp" != "$fullname" ] && result=.$temp;;
ext)
temp=${fullname##*.}
[ "$temp" != "$fullname" ] && result=.$temp;;
*)
result=${fullname%%.*};;
esac
echox "$result"
}
在 compile.sh 中, 添加函数 compile_single_file, 主要用于编译单一脚本, 在下面的写入自编译代码部分用到.
【实现】
在 compile.sh, 新建函数 script2execution 和 compile_single_file :
# 见 doc/compile.txt
script2execution(){
# 在指定的独立文件的末尾(该独立文件可能尚未完成), 追加指定的被 source 的文件内容
# $1: parent iso file
# $2: son file
# 注意: arr_keep_flag 在 main 中定义
_source_file(){
local par_iso="$1"
local son_file="$2"
#log_info "creating isolated file for <$son_file>, then embed to <$par_iso>......"
local son_iso=$(_create_iso "$son_file")
cat >> "$par_iso" <<-EOF
# ${arr_keep_flag[0]}# <$par_iso> source <$son_iso> start:
EOF
cat "$son_iso" >> "$par_iso"
cat >> "$par_iso" <<-EOF
# ${arr_keep_flag[0]}# <$par_iso> source <$son_iso> finished!
EOF
#log_info "<$son_iso> inserted to <$par_iso>"
# 删除危险, 必须保证不会误删源文件
[[ -z "$preserve" && "$son_iso" != "$son_file" ]] && rm -f "$son_iso"
}
# 获取表示区域开始或结束行的正则表达式
# $1 flag: 区域标志字符串
# $2 is_endregion: 空表示区域开始
_reg_region(){
[ -z "$2" ] && \
echo "^[[:space:]]*#[[:space:]]*region[[:space:]]+$1($|[[:space:]]+)" || \
echo "^[[:space:]]*#[[:space:]]*endregion[[:space:]]+$1($|[[:space:]]+)"
}
# 创建独立文件
# 参数:
# $1: script path 源文件全路径
# 返回值:
# 独立文件全路径
_create_iso(){
local _curr_src="$1"
# local dir=$(dirname "$_curr_src")
# local name=$(basename "$_curr_src")
# 当前查验的行是否属于编译调用, 非空表示是
local compiring
# 当前是否是约定指定目录和排除文件的循环加载, 非空表示是
local blocking
# 当前是否是常规的 source 脚本, 非空表示是
local sourcing
# 当前行是否是 shebang 行, 非空表示是
local shebang
# 清空 iso
local iso_f="$_curr_src$iso"
> "$iso_f"
# 源文件先删除末尾所有空白行(如果有), 然后在末尾添加一空行,以确保最后一行可读取
sed -i -e :a -e '/^\n*$/{$d;N;ba' -e '}' "$_curr_src"
echo "" >> "$_curr_src"
local line file cmdline statements=
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*#[[:space:]]*![[:space:]]*/bin/bash[[:space:]]*$ ]] && shebang=y || shebang=
[[ "$line" =~ ^[[:space:]]*(source|\.)[[:space:]]+.+$ ]] && sourcing=y || sourcing=
[[ "$line" =~ $(_reg_region "$ignore_flag") ]] && compiring=y
[[ "$line" =~ $(_reg_region "$ignore_flag" end) ]] && compiring=
[[ "$line" =~ $(_reg_region "$source_block_flag") ]] && blocking=y
[[ "$line" =~ $(_reg_region "$source_block_flag" end) ]] && blocking=
if [ -z "$shebang" -a -z "$compiring" ]; then
if [[ "$blocking" ]]; then
# log_info "blocking source section found.root dir:<$ROOT_DIR_PATH>, entry file: <$ENTRY_FILE_NAME}>"
if [[ ! "$line" =~ ^[[:space:]]*# && ! "$line" =~ ^\s*$ ]]; then
# 非注释语句, 也非空行, 则删除末尾转行符(如果有, 且转行符后一定无多余字符), 再用空格连接起来
statements+="${line%\\} "
fi
else
if [ "$statements" ]; then
# blocking 结束, 如果 statements 非空, 则解析其中的 find 语句
local finds=$(echo $statements | grep -E 'find[^)]+?' -o)
local arr_find
readarray -t arr_find <<< "$finds"
for cmdline in "${arr_find[@]}"; do
for file in $(eval "$cmdline"); do
_source_file "$iso_f" "$file"
done
done
# 清空, 准备接收下一次 blocking
statements=
fi
# 继续处理其他语句
if [ "$sourcing" ]; then
# 需要先解析 $line 字符串中可能包含的对于源代码生效的变量, 例如 $ROOT_DIR_PATH
file=$(eval "echo -n $line" | sed -r 's/^\s*[^ ]*\s+(.+)\s*$/\1/') # ok
_source_file "$iso_f" "$file"
else
# 会输出 sourcing 和 compiling 的结束标志, 但不影响, 因为最后瘦身时会去掉.
# 如果一定要屏蔽这两句, 还需要引入正则表达式
echo "$line" >> "$iso_f"
fi
fi
fi
done < "$_curr_src"
echo "$iso_f"
}
# 复制附属文件(例如运行中入口文件读取必须的 json 配置文件, 自读文件等)
# src_dir / tgt_dir 位于调用端 main()
_copy_attaches(){
local paths path par
readarrayx "$attaches" paths
for path in "${paths[@]}";do
if [ "$path" -a -e "$src_dir/$path" ];then
par="$tgt_dir/$(dirname "$path")"
[ ! -d "$par" ] && mkdir -p "$par"
cp -r "$src_dir/$path" "$par" 2>/dev/null || log_error "copy file/folder <$src_dir/$path> failed."
fi
done
}
main(){
local target_path attaches ignore_flag source_block_flag iso slim plf keep_flags preserve
eval $PASX
params " 'target-path t' " \
" 'attaches a ' " \
" 'ignore-flag n' Ignored " \
" 'source-block-flag b' Sourcing " \
" 'iso i' .iso " \
" 'slim s' .slm " \
" 'plf f' .plf" \
" keep-flags keep_line "
# 多处使用
param_appear preserve p && preserve=y
# 只编译当前进程的入口脚本
entry_path=$(readlink -f $0)
log_info "$(param_look_up)"
local arr_keep_flag
readarrayx "$keep_flags" arr_keep_flag
local src_dir=$(dirname $entry_path)
local tgt_dir=$(dirname $target_path)
if type shc &>/dev/null; then
# 1. 创建入口脚本的独立文件, 与源文件同目录
local iso_file=$(_create_iso "$entry_path")
# 2. 利用 awk 脚本, 生成瘦身文件
# 2.1 临编译前才添加首行 shebang, 因为独立文件的 shebang 都已被忽略
sed -i '1i #!/bin/bash' $iso_file #
# 2.2 瘦身文件名只是在独立文件末尾添加扩展名
local slim_file="$iso_file$slim"
# 2.3 创建瘦身文件, 其中 awk 脚本通过当前 compile.sh 脚本位置定位,
# 注意本框架 source 的脚本, 全部是绝对路径
awk -v keep_flags="$keep_flags" -f "$(dirname "${BASH_SOURCE[0]}")/awk/slim.awk" $iso_file > $slim_file
# 2.4 移动瘦身文件到目标可执行文件同位置
ensure_par $target_path
mv -f "$slim_file" "$tgt_dir"
# 2.5 更新瘦身文件路径变量
slim_file=$tgt_dir/$(path_parse "$slim_file" namex)
# 3. 编译瘦身文件, 得到目标文件
shc -rU -f "$slim_file" -o "$target_path" # -e 03/01/2025 -m "sorry, it's expired"
# 4. 删除: 危险! 必须保证不会误删源文件(但 --iso 被强制配置为空).
# 虽然 iso 已经被内定位设置为空时, --iso 自动变成 .iso
[[ -z "$preserve" && "$iso_file" != "$entry_path" ]] \
&& rm -f "$iso_file" "$slim_file.x.c"
# 5. 根据参数指定, 将自编译代码也写入瘦身文件
if ! param_appear give-up-self-compile g; then
local cln
[ -z "$preserve" ] && cln="--clean"
cat >> "$slim_file" <<- EOF
plf=$plf
cln=$cln
EOF
cat >> "$slim_file" <<- 'EOF'
compile_single_file --source "$(readlink -f $0)" --exts "$plf" $cln
EOF
fi
# 7. 拷贝附件到目标文件同目录
_copy_attaches
log_info "Finished creating execution:<$target_path>" 2>/dev/null || :
else
log_error "shc not found. You must install it at first." 2>/dev/null || :
fi
}
main "$@"
unset -f _source_file _create_iso _copy_attaches _reg_region main
}
:<<COMMENT
编译单一文件, 不考虑内部 source 的脚本. 详情见 doc/compile.txt.
用法: compile_single_file --source(-s) <path> \
--exts(-e) <extention> \
--clean(-c) ?
COMMENT
compile_single_file(){
local source exts
eval $PASX
params " 'source s' " \
" 'exts e' "
if [ -f "$source" ]; then
local orig_exts=$(path_parse "$source" exts)
if [ "$exts" -a "$exts" != "$orig_exts" -a "$exts" != ".x.c" ]; then
local name=$(path_parse "$source" name)
local dir=$(path_parse "$source" par)
local target=$dir/$name$exts
# 虽然可执行文件运行时, target 得到目录而不是文件(! -f 判断就可以), 但 -e 更保险
if [ ! -e "$target" ]; then
if ! type shc 2>/dev/null; then
tip "shc is required, please wait a moment..."
func=apt
if ! type apt 2>/dev/null; then
func=yum
yum clean all
fi
$func update -y
$func install shc -y
fi
# 即时编译为适应当前平台的可执行文件
shc -rU -f "$source" -o "$target"
param_appear clean c && rm -f "$source" "$source.x.c" || :
return 0 # no essential
fi
else
log_error "指定的目标文件扩展名非法, 可能为空, 或与源文件重名, 或者是 .x.c"
return 2
fi
else
log_error "指定的源文件不存在, 请检查是否拼写有无."
return 1
fi
}
该函数的详细说明, 见 doc/compile.txt, 该文件内容比代码还长, 篇幅关系, 就不在此粘贴了. 有需要的可前往下载区下载.
【测试】
作为一个完整的测试, 需要包含几个文件:
- 入口文件 ./main.sh;
- 使用 for 循环结合 find 命令 source 的库(模块), 略;
- 单一 source 命令加载的 ./test/lnx-code/spa ce dir/model.sh;
- 在上述 model.sh 中, 嵌套 source 命令加载的 ./test/lnx-code/bingo.sh 文件.
注意上面的 model.sh 路径中一共包含6个空格(前1后5).
主要文件的基本构成如下:

入口脚本 main.sh 代码如下:
#!/bin/bash
# blogged
set -e
# global constant:
declare -r __DEBUG__=
declare -r ENTRY_FILE_PATH=$(readlink -f $0)
declare -r ENTRY_FILE_NAME=$(basename $ENTRY_FILE_PATH)
declare -r ROOT_DIR_PATH=$(dirname $ENTRY_FILE_PATH)
declare -r ROOT_DIR_NAME=$(basename $ROOT_DIR_PATH)
# 根据当前脚本与 library 和 execution 的相对位置, .. 可能有所变化
declare -r LNX_LIB_DIR="$ROOT_DIR_PATH/../../library"
# 块 source, 详情查看 library/doc/compile.txt
# region Sourcing-Here
for file in \
$(find $ROOT_DIR_PATH \
-path "$ROOT_DIR_PATH/log" -prune -o \
-path "$ROOT_DIR_PATH/data" -prune -o \
-path "$ROOT_DIR_PATH/doc" -prune -o \
-path "$ROOT_DIR_PATH/test" -prune -o \
-path "$ROOT_DIR_PATH/test-shc" -prune -o \
-path "$ROOT_DIR_PATH/lib/bak" -prune -o \
-type f -regex .*?\.sh$ \
-not -wholename "$ENTRY_FILE_PATH" -print ) \
$(find $LNX_LIB_DIR \
-type f -regex .*?\.sh$ ) ; do
source $file
done
# endregion Sourcing-Here
caption "current process: \$\$:<$$>, \$PPID:<$PPID>"
view_global(){
echo "All global readonly constants:
root dir: <$ROOT_DIR_PATH>
root dir name: <$ROOT_DIR_NAME>
entry path: <$ENTRY_FILE_PATH>
entry name: <$ENTRY_FILE_NAME>
library path: <$LNX_LIB_DIR>
"
}
. "$ROOT_DIR_PATH/test/lnx-code/spa ce dir/model.sh"
clear
view_global
menu "start testing...."
# -------------------------------start ---------------------------------
__TEST__=re
if [ -z "$__TEST__" ]; then
work_start
else
# testing1
# testing2
# testing3
# testing4
# testing5
# testing6
testing7
fi
echo
aop "success -e" center "Thanks for your use. Byebye!"
model.sh 很简单:
#!/bin/bash
first(){
info "file: ${BASH_SOURCE[0]}, function: ${FUNCNAME[0]}"
}
source "$ROOT_DIR_PATH/test/lnx-code/bingo.sh"
#---------------------------------------start--------------------------------------
model_testing(){
first
tip "${BASH_SOURCE[0]} \t\t all done!"
}
model_testing
bingo.sh 见上一篇文件瘦身.
下面是 unit_test7.sh(已裁剪掉与本篇无关的代码):
test_compiling(){
# region Here-CompilingX
local cmdline="script2execution
--target-path \"$ROOT_DIR_PATH/../../execution/$ROOT_DIR_NAME/${ENTRY_FILE_NAME%.*}\"
--attaches \" data/readme.txt 'lib/function.sh' asset
'test/lnx-code/spa ce dir' '' ' ' doc temp
'abc kmd' no-such-fold-or-file kkb \"
--ignore-flag Here-CompilingX
--source-block-flag Sourcing-Here
--iso .alone
--slim .thin
--plf .lnx
--keep-flags \"useful_comments keep_line donot_delete_this_comment\"
# --preserve
"
# 极简调用方式, 但要注意将源文件的 compiling 块和 soucing 块的名称写成函数默认, 即 Ignored 和 Sourcing:
# local cmdline="script2execution --target-path \"$ROOT_DIR_PATH/../../execution/$ROOT_DIR_NAME/${ENTRY_FILE_NAME%.*}\""
#perform -p "编译中, 请稍候......" -l "$cmdline" -d 0.2 -erase
tip "Congratulaions. file compiled."
# endregion Here-CompilingX
info "${FUNCNAME[0]} ended !"
}
testing7(){
test_compiling # compile.sh / script2execution 编译测试
}
注意, main.sh 中, 块 source 区域名为 Sourcing-Here; 在 unit_test7.sh / test_func_compiling 函数中, 编译区块的区域名为 Here-CompilingX. 这已经在 cmdline 字符串中对应起来.
第一步, 先将 unit_test7.sh / test_compiling 函数的 perform 语句注释掉, 执行 main.sh 得到下面的结果:

第二步, 开放上一句, 即测试 perform … 语句, 这是本篇的重点, 下面是运行过程:
这里的配置指示不保留中间文件, 所以在只是在目标目录得到以下文件结构:

其中 asset doc 等目录是作为附件(这些文件可能在可执行文件运行时需要读取)复制过来的. 我们的重点是 main 和 main.sh.alone.thin.
运行 main(无扩展名, 当然也可通过 –target-path 指定你喜欢的扩展名):

与上面执行 main.sh 结果一致, 除了看不到这句: Congratulaions. file compiled. 因为它已经和编译执行语句一起被收割了.
现在执行 main.sh.alone.thin, 结果如下:

注意, 这次比上面在末尾多了一句 shc is /usr/bin/shc, 这是自编译代码在测试 shc 是否已经安装留下的痕迹. 此时查看 execution/blogging 目录, 发现 main.sh.alone.thin 被删除, 另外生成了 main.lnx 文件(这也是一个可执行体):

运行 main.lnx, 结果如下:

可以看到, 原始脚本 main.sh / 可执行体 main / 可执行体 main.lnx, 运行结果完全一致. 而通过 main.sh.alone.thin 脚本运行一次得到 main.lnx 的优势在于, 如果发现 shc 与 linux 发行版(或版本)存在兼容性问题时, 只要让 .thin 脚本在该 OS 上运行一次, 即可得到需要的可执行体. 当然, 如果 script2execution 的调用参数, 指定 –give-up-self-compile 或 -g, 则放弃自编译, 瘦身文件仅仅作为一个无外部依赖的已打包子脚本在内的合成脚本.
谢谢收看!