eval 命令详解与参数实测(Ubuntu 22.04)
以 Ubuntu 22.04(bash 5.1.16)实测逐用法讲解 bash 内建 eval:两轮展开原理、动态构造命令、间接取值、拼接多命令/管道/重定向、含空格参数的引号保持、退出码、注入风险与 ${!name}/数组等安全替代,附 eval 与 bash -c 执行环境对比。
eval 命令详解与参数实测(Ubuntu 22.04)
目标:全面掌握 bash 内建命令 eval 的每一种常用形态——把字符串当命令执行、两轮展开原理、
eval echo \$var与eval "echo $var"的差异、拼接多命令/管道/重定向、间接取值eval val=\$$name、动态变量名、退出码规则、含空格参数的引号保持,以及把用户输入交给 eval 导致的注入风险与${!name}/数组等安全替代,全部命令与回显已在 Ubuntu 22.04(bash 5.1.16)实测通过。
一、环境信息
本文所有命令均在以下环境实测,回显逐字照抄:
- 操作系统:Ubuntu 22.04.5 LTS(
PRETTY_NAME="Ubuntu 22.04.5 LTS") - 内核架构:x86_64
- Shell:GNU bash 5.1.16(1)-release(软件包 bash 5.1-6ubuntu1.1)
- 执行身份:root
- 演示数据目录:
/root/demo/eval/(含演示用的 data 子目录、app.conf 配置文件等) - 演示变量:使用
cmd、a、b、var、name等短名变量,均在演示脚本内独立执行
二、命令概览
2.1 语法与测试数据
eval 是 bash 内建命令,作用是把多个参数拼接成一个字符串,交给 shell 当作命令行重新解析再执行。语法只有一种形态(来自实测 help eval 首行):
eval [arg ...]
eval 命令字符串:把参数按空格拼接后重新分词、识别管道/重定向/分号等语法元素并执行;- 不带参数时
eval什么都不做,返回 0。
本文测试数据约定:所有演示在 /root/demo/eval/ 目录下的独立脚本中逐条执行;涉及「恶意输入」的演示只针对本目录内的测试文件,不触碰系统路径。每个小节均可独立执行复现。
💡 说明:eval 与普通命令最大的区别是「多一轮解析」——普通命令的参数在展开后只做一次分词,而 eval 会把拼接结果再交给 shell 完整解析一遍,因此分号、竖线、重定向符号这些「语法元素」只有经过 eval 才会生效(见 3.4、3.9 实测)。
2.2 help 全量输出(逐行照抄)
以下为 help eval 的完整实测输出:
$ help eval
eval: eval [arg ...]
Execute arguments as a shell command.
Combine ARGs into a single string, use the result as input to the shell,
and execute the resulting commands.
Exit Status:
Returns exit status of command or success if command is null.
关键信息解读:帮助原文明确了两点——拼接后「use the result as input to the shell」(重新交给 shell 解析,这就是两轮展开的来源);退出状态「Returns exit status of command or success if command is null」(透传所执行命令的退出码,空参数返回成功,见 3.7 实测)。
2.3 版本信息与内建归属
eval 是纯 shell 内建命令,没有独立的可执行文件与独立版本号,版本随 bash 发布。实测归属与版本:
$ type -a eval
eval is a shell builtin
$ bash --version | head -1
GNU bash, version 5.1.16(1)-release (x86_64-pc-linux-gnu)
$ dpkg -l bash | tail -1
ii bash 5.1-6ubuntu1.1 amd64 GNU Bourne Again SHell
⚠️ 注意:系统里不存在 /usr/bin/eval 之类的独立程序(type -a 只返回 shell builtin 一行),eval 的行为差异只取决于 bash 版本,与 coreutils 等外部软件包无关。
三、参数逐项实测
3.1 基本用法:把字符串当命令执行
最基础的形态:把存在变量里的字符串交给 eval 执行。预期结果是字符串被当作完整命令运行,退出码 0:
cmd="echo hello from eval"
eval $cmd # eval 执行字符串
$cmd # 对照:简单命令不经 eval 也能执行
echo 退出码=$?
实测回显:
$ eval $cmd
hello from eval
$ $cmd # 简单命令不经 eval 也能执行(对比)
hello from eval
$ echo 退出码=$?
退出码=0
💡 要点:单个「纯命令 + 纯参数」的字符串,不用 eval 也能执行(shell 分词后依次展开);eval 的价值在字符串里包含语法元素或需要二次展开的场景,下文逐项展开。
3.2 两轮展开原理演示
eval 的核心机制:shell 先把 eval 的参数正常展开一轮,eval 把拼接结果再交给 shell 解析展开第二轮。实测用两层变量验证:
a="b"
b="final_value"
echo \$$a # 不用 eval:只展开一层,得到字面量 "$b"
eval echo \$$a # eval:第一轮 $a 展开成 "$b",第二轮把 $b 再当变量展开
实测回显:
$ echo \$$a # 不用 eval:只展开一层,得到字面量 "$b"
$b
$ eval echo \$$a # eval:第一轮展开 $a 得到 "$b",第二轮把 $b 再当变量展开
final_value
💡 要点:echo \$$a 中 \$ 阻止了 $ 被展开,所以第一轮只把 $a 展开成 b,得到字面量 $b 打印出来;换成 eval 后,第一轮的产物 $b 被再次解析,b 变量的值 final_value 才出来。这就是「两轮展开」的全部含义——没有任何魔法,只是多过一遍解析器。
3.3 eval echo $var 与 eval "echo $var" 的差异
两种写法表面结果一样,差异只在「变量的值本身是另一个变量名」时暴露。实测对比,并附带 $$ 的经典坑:
var="hello"
eval echo \$var
eval "echo $var"
# 差异在「变量值本身是变量名」时体现:
name="PATH"
eval echo \$$name
eval "echo $$name" # 双引号版本:$$ 先被展开成 shell PID(经典坑)
实测回显:
$ eval echo \$var
hello
$ eval "echo $var"
hello
$ # 差异在「变量值本身是变量名」时体现:
$ name="PATH"
$ eval echo \$$name
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
$ eval "echo $$name" # 双引号版本:$$ 先被展开成 shell PID(经典坑)
25074name
💡 要点:
eval echo \$$name:\$把$保护到第一轮之后,第一轮展开$name得到PATH,第二轮展开$PATH,输出 PATH 的值。eval "echo $$name":双引号里$name在第一轮就被展开成PATH,拼进字符串后$没了,只剩echo PATH;更糟的是双引号里的$$先被展开成 shell 的 PID(实测 25074),最终输出25074name——两处都不是想要的结果。- 结论:要「延迟一轮展开」必须用
\$或单引号把$保护住;直接写双引号$var是最常见的 eval 误用。
3.4 eval 拼接多条命令、管道与重定向
普通展开后的分号、竖线、> 只是普通字符;eval 重新解析后才被识别为语法元素。实测三组对照:
# 多条命令
actions="echo one; echo two; echo three"
$actions # 不加 eval:分号只是 echo 的普通参数(对照)
eval $actions # eval 按语法重新解析,分号生效
实测回显:
$ $actions # 不加 eval:分号只是 echo 的普通参数(对比)
one; echo two; echo three
$ eval $actions # eval 按语法重新解析,分号生效
one
two
three
# 管道
target="/root/demo/eval"
pipe="| wc -l"
eval ls -l $target $pipe
ls -l $target $pipe # 不加 eval:竖线被当成普通参数传给 ls(对照)
实测回显:
$ eval ls -l $target $pipe
5
$ ls -l $target $pipe # 不加 eval:竖线被当成普通参数传给 ls(对比)
ls: cannot access '|': No such file or directory
ls: cannot access 'wc': No such file or directory
/root/demo/eval:
total 16
-rw-r--r-- 1 root root 2101 Oct 16 09:49 ev1.sh
-rw-r--r-- 1 root root 1053 Oct 16 09:49 ev2.sh
-rw-r--r-- 1 root root 426 Oct 16 09:49 ev3.sh
-rw-r--r-- 1 root root 849 Oct 16 09:49 ev4.sh
# 重定向
redir="> /root/demo/eval/out.txt"
eval echo "written by eval" $redir
cat /root/demo/eval/out.txt
echo "written without eval" $redir # 不加 eval:> 被当成普通参数(对照)
实测回显:
$ eval echo "written by eval" $redir
$ cat /root/demo/eval/out.txt
written by eval
$ echo "written without eval" $redir # 不加 eval:> 被当成普通参数(对比)
written without eval > /root/demo/eval/out.txt
💡 要点:ls -l ... | wc -l 输出 5(目录里 5 个条目);不加 eval 时竖线被当成文件名报 No such file or directory,重定向符号则原样出现在 echo 输出里。eval 是「字符串变语法」的唯一常规途径。
3.5 循环中动态生成变量名
在循环里用 eval 拼出变量名并赋值,是 eval 的经典惯用法(bash 4.3 后可改用 declare -n 命名引用,见 3.6 提示):
for i in 1 2 3; do eval "var_$i=value_$i"; done
echo $var_1 $var_2 $var_3
实测回显:
$ for i in 1 2 3; do eval "var_$i=value_$i"; done
$ echo $var_1 $var_2 $var_3
value_1 value_2 value_3
💡 要点:双引号字符串里 $i 第一轮展开成 1/2/3,eval 第二轮执行 var_1=value_1 等三条赋值。同样的效果用 declare var_$i=value_$i(或 declare -g)也能实现,且不需要经过 eval 的完整解析,注入面更小。
3.6 间接取值:eval val=$$name 与 ${!name} 替代
通过「变量名存在另一个变量里」取值,eval 与 bash 原生的 ${!name} 间接展开各是一种做法。实测:
name="HOME"
eval val=\$$name
echo $val
echo "${!name}" # bash 原生间接引用,无需 eval
实测回显:
$ eval val=\$$name
$ echo $val
/root
$ name="HOME"
$ echo ${!name}
/root
💡 要点:eval val=\$$name 两轮展开后等价于执行 val=$HOME;${!name} 一轮就完成同样的间接取值,且不会引入解析副作用,bash 2.0+ 都支持。纯取值场景一律优先 ${!name},eval 版本只在「间接赋值」「拼接更复杂表达式」时才有必要。
3.7 退出码规则:透传命令状态、127 与空参数
help eval 说明退出状态是「所执行命令的状态,空命令返回成功」。实测 true/false、命令不存在、空 eval 三种情况:
eval true; echo 退出码=$?
eval false; echo 退出码=$?
eval; echo 退出码=$? # 空参数返回 0
eval nosuchcmd_xyz 2>&1; echo "退出码=$?"
实测回显:
$ eval true; echo 退出码=$?
退出码=0
$ eval false; echo 退出码=$?
退出码=1
$ eval; echo 退出码=$? # 空参数返回 0
退出码=0
$ eval nosuchcmd_xyz; echo 退出码=$?
/root/demo/eval/ev5.sh: line 7: nosuchcmd_xyz: command not found
退出码=127
💡 要点:eval 本身不产生退出码,完全透传;拼出来的命令不存在时报 command not found 并返回 127。脚本里判断 eval 成败直接用 $?,与普通命令一致。
3.8 引号保持:变量值含空格时的三种写法
变量值里有空格(如文件路径)时,eval 拼接后会重新分词,前两种常见写法都会失败,只有把引号写进交给 eval 的字符串才有效:
touch "/root/demo/eval/my file.txt"
filename="/root/demo/eval/my file.txt"
eval ls -l $filename # 写法1:不加引号,拼接后被空白拆成两个参数
eval ls -l "$filename" # 写法2:外层引号在第一轮展开就被消耗,无效
eval "ls -l '$filename'" # 写法3(正确):引号写进交给 eval 的字符串里
rm "/root/demo/eval/my file.txt"
实测回显:
$ eval ls -l $filename # 写法1: 不加引号, 拼接后被空白拆成两个参数
ls: cannot access '/root/demo/eval/my': No such file or directory
ls: cannot access 'file.txt': No such file or directory
$ eval ls -l "$filename" # 写法2: 外层引号在第一轮展开就被消耗, 无效
ls: cannot access '/root/demo/eval/my': No such file or directory
ls: cannot access 'file.txt': No such file or directory
$ eval "ls -l '$filename'" # 写法3(正确): 引号写进交给 eval 的字符串里
-rw-r--r-- 1 root root 0 Oct 16 14:25 /root/demo/eval/my file.txt
$ rm "/root/demo/eval/my file.txt"
💡 要点:写法 2 最反直觉——"$filename" 的引号属于当前这轮解析,展开成 /root/demo/eval/my file.txt 后与 -l 拼接,eval 重新分词时照样在空格处断开。正确姿势是让引号「活到第二轮」:eval "ls -l '$filename'" 或 eval ls -l \"$filename\"。能不用 eval 就不用:ls -l "$filename" 本身就是对的。
3.9 安全性反例:把用户输入交给 eval 等于代码注入
eval 会执行字符串里的任何语法,包括拼接进来的分号与命令。实测模拟一个「用户输入路径」被 eval 的场景:
mkdir -p /root/demo/eval/data && echo important > /root/demo/eval/data/important.txt
user_input="/root/demo/eval/data; rm -rf /root/demo/eval/data" # 模拟恶意输入
echo "删除前: $(ls /root/demo/eval/data)"
eval ls $user_input
echo "删除后: $(ls /root/demo/eval/data 2>&1)"
实测回显:
$ echo "删除前: $(ls /root/demo/eval/data)"
删除前: important.txt
$ eval ls $user_input
important.txt
$ echo "删除后: $(ls /root/demo/eval/data 2>&1)"
删除后: ls: cannot access '/root/demo/eval/data': No such file or directory
同样的输入不经 eval 时只是报错,注入部分不会执行:
user_input2="x; echo PWNED"
ls $user_input2 2>&1
eval ls $user_input2 2>&1
实测回显:
$ ls $user_input2
ls: cannot access 'x;': No such file or directory
ls: cannot access 'echo': No such file or directory
ls: cannot access 'PWNED': No such file or directory
$ eval ls $user_input2
ls: cannot access 'x': No such file or directory
PWNED
💡 要点:eval ls $user_input 实际执行了 ls /root/demo/eval/data 和 rm -rf /root/demo/eval/data 两条命令——目录被删掉了;对照组 ls $user_input2 把 x;、echo、PWNED 当成三个文件名,什么也没执行,而 eval ls $user_input2 则把 echo PWNED 真的跑了。结论:任何来源不可信的字符串(用户输入、外部文件、环境变量)都绝不能进 eval;确需动态执行时用数组 + "$@" 或白名单映射(见 4.3)。
3.10 技术拓展:eval 与 bash -c 执行环境对比
「执行一段字符串」不止 eval 一种方式,bash -c 也能做到。实测两者在作用域上的本质差异:
eval "EVVAR=from_eval"
echo "$EVVAR" # eval 在当前 shell 执行,变量留下了
bash -c "BCVAR=from_bash_c"
echo "[$BCVAR]" # bash -c 在子进程执行,变量带不回来
实测回显:
$ eval "EVVAR=from_eval"
$ echo $EVVAR
from_eval
$ bash -c "BCVAR=from_bash_c"
$ echo $BCVAR
[]
两种方式横向对比(差异点均来自上节与 3.1~3.9 的实测):
| 维度 | eval(本文主角) | bash -c "命令" |
|---|---|---|
| 执行环境 | 当前 shell 内,无新进程 | 新起一个 bash 子进程 |
| 变量/函数影响 | 直接修改当前 shell(实测 EVVAR 留下) | 子进程内消失(实测 BCVAR 为空) |
| 退出码 | 透传所执行命令(3.7) | 子 shell 的退出码,语义相同 |
| 当前目录/cd | 影响当前 shell | 仅影响子进程 |
| 语法解析轮数 | 两轮(先展开再解析) | 一轮(字符串直接交给子 bash) |
| 适用场景 | 动态改当前环境、间接赋值 | 隔离执行、指定不同解释器选项 |
关键差异解读:要「影响当前 shell」只能用 eval(或 source 配置文件);bash -c 每次都是干净子进程,适合隔离执行外部字符串,但它改变不了调用方的任何状态。本文因需要动态构造并影响当前 shell 的变量与命令,实际采用 eval。
四、实战示例
4.1 按变量拼接备份命令
目标文件名带日期,命令行整体由变量拼出,这是 eval 最常见的生产用法:
src="/root/demo/eval/data"
mkdir -p $src && echo demo > $src/file.txt
dst="/root/demo/eval/backup-$(date +%F).tar.gz"
eval tar czf $dst $src
ls -l /root/demo/eval/backup-*.tar.gz
实测回显:
$ src="/root/demo/eval/data"
$ mkdir -p $src && echo demo > $src/file.txt
$ dst="/root/demo/eval/backup-$(date +%F).tar.gz"
$ eval tar czf $dst $src
tar: Removing leading `/' from member names
$ ls -l /root/demo/eval/backup-*.tar.gz
-rw-r--r-- 1 root root 165 Oct 16 09:51 /root/demo/eval/backup-2026-10-16.tar.gz
验证闭环:ls -l 确认归档文件已生成(165 字节)。本例其实不用 eval 也能跑(tar czf "$dst" "$src" 更稳),eval 版本的价值在「连命令名都要从配置里读」的场景。
4.2 读配置行动态构造命令(eval 惯用法)
配置文件里存「变量名=命令」,加载后用 eval 执行。先看一个不写引号的报错,再给修正版:
printf "greet=echo hello-config\n" > /root/demo/eval/app.conf
cat /root/demo/eval/app.conf
source /root/demo/eval/app.conf
echo "[$greet]"
实测回显(值不加引号,加载即报错):
$ cat /root/demo/eval/app.conf
greet=echo hello-config
$ source /root/demo/eval/app.conf # 配置行不加引号:报错
/root/demo/eval/app.conf: line 1: hello-config: command not found
$ echo $greet
[]
printf "greet=\"echo hello-config\"\n" > /root/demo/eval/app.conf
cat /root/demo/eval/app.conf
source /root/demo/eval/app.conf
echo "[$greet]"
eval $greet
实测回显(值整体加双引号后正常):
$ cat /root/demo/eval/app.conf
greet="echo hello-config"
$ source /root/demo/eval/app.conf
$ echo $greet
[echo hello-config]
$ eval $greet
hello-config
💡 要点:greet=echo hello-config 会被解析成「执行 hello-config 命令」,因为赋值后面的空格把整行变成了命令行;值整体用双引号包住才能把 echo hello-config 完整存进变量。这种「配置即命令」的设计必须保证配置文件写权限收敛到 root,否则等同于把 eval 注入的口子开进配置。
4.3 数组替代 eval 传参(避免注入)
多条参数的动态执行,用数组保存再整体展开,引号与分词都安全:
args=(/root/demo/eval /root/demo/eval/data)
ls -l "${args[@]}" | head -8
实测回显:
$ args=(/root/demo/eval /root/demo/eval/data)
$ ls -l "${args[@]}" | head -8
/root/demo/eval:
total 40
-rw-r--r-- 1 root root 24 Oct 16 09:51 app.conf
-rw-r--r-- 1 root root 165 Oct 16 09:51 backup-2026-10-16.tar.gz
drwxr-xr-x 2 root root 4096 Oct 16 09:51 data
-rw-r--r-- 1 root root 2088 Oct 16 09:50 ev1.sh
-rw-r--r-- 1 root root 1053 Oct 16 09:50 ev2.sh
-rw-r--r-- 1 root root 426 Oct 16 09:50 ev3.sh
💡 要点:"${args[@]}" 展开后每个元素仍是独立参数,空格不会被重新分词,天然免疫 3.9 的注入形态。判断准则:字符串里只有「参数」用数组,字符串里必须包含「语法」(分号/管道/重定向)才考虑 eval,且内容必须可信。
五、注意事项与常见问题
Q1:eval "echo $var" 输出了一串数字加变量名,怎么回事?
双引号里的 $$ 在第一轮就被展开成当前 shell 的 PID,$var 展开成值后 $ 符号已经消失,第二轮没有可展开的内容(见 3.3 实测的 25074name)。要在第二轮才展开的 $ 必须写成 \$ 或放进单引号:eval echo \$$name。
Q2:都说 eval 危险,到底危险在哪,怎么替代?
危险在于 eval 把字符串当完整 shell 语法执行,字符串里的分号/反引号/$(...) 全部生效(3.9 实测 rm -rf 被真实执行)。替代方案按场景选:纯间接取值用 ${!name}(3.6);纯传参用数组 "${args[@]}"(4.3);动态变量名用 declare var_$i=value_$i;确需执行含语法元素的字符串时,保证内容只来自脚本自身或 root 收敛的配置文件。
Q3:eval 报 command not found / 退出码 127 怎么排查?
eval 只是透传(3.7),127 说明拼出来的字符串里命令名不存在。排查方法:把 eval 临时换成 echo,先打印拼接结果确认字符串长什么样,重点检查变量是否为空、是否多出了换行或空格。
Q4:变量值里有空格,eval 后总是断成两个参数?
eval 会把拼接结果重新分词(3.8 实测),外层引号活不到第二轮。要么把引号写进 eval 字符串:eval "cmd '$var'";要么不用 eval,直接 cmd "$var"。能不用 eval 就不用。
Q5:哪些场景是 eval 真正不可替代的?
需要把「语法」装进字符串再执行的场景:动态拼多条命令(分号)、动态管道/重定向(3.4)、间接赋值 eval val=\$$name 的左值形式、以及 4.2 的「配置即命令」。这些都是当前 shell 内执行,bash -c 替代不了(子进程改不了环境,见 3.10 对比表)。
Q6:eval 后面的变量加不加引号有什么口诀?
口诀:第一轮保护 $,第二轮保护空格。\$var/单引号保证 $ 活到第二轮;第二轮生效的引号必须写在 eval 字符串里。两条都做不到时,说明该场景不该用 eval。
六、参考链接
- bash(1) 手册(Ubuntu 22.04 jammy,内建命令 eval 一节):https://manpages.ubuntu.com/manpages/jammy/en/man1/bash.1.html
- GNU Bash Reference Manual(Shell Builtin Commands / Shell Parameter Expansion 间接引用):https://www.gnu.org/software/bash/manual/
- 本地自省:
help eval、help -s eval(bash 内建帮助,2.2 节全量原文) - ShellCheck 静态检查工具(eval 误用检测,SC2086/SC2294 等):https://www.shellcheck.net/
- bash 源码仓库(builtins/eval.def 实现):https://git.savannah.gnu.org/cgit/bash.git