4、Shell脚本基础:变量、循环、条件判断、函数,为批处理做准备
说实话,很多搞分子模拟的同学,一开始都觉得Shell脚本是运维才该学的东西。我自己以前也这么想。直到有一次,我需要跑100个配体-蛋白质的对接后处理,每个步骤都得手动敲命令……那次之后我彻底明白了,Shell脚本就是咱们做计算模拟的“第二双手”。
这一章咱们不扯虚的。我会把批处理最常用的四个核心知识点捞出来:变量、循环、条件判断、函数。说白了,你把这四样东西玩熟了,GROMACS的批处理流程就能自动化跑起来。
4.1 变量:给数据贴个标签
变量的概念很简单,就是给一段数据取个名字。你用这个名字,就能随时拿到里面的值。你可以理解为一个带名字的盒子。
先看一个最基础的定义和引用:
# 定义变量,等号两边不能有空格
SIM_NAME="lysozyme"
NSTEPS=500000
DT=0.002
# 引用变量,加美元符号
echo "模拟名称:$SIM_NAME"
echo "总步数:$NSTEPS"
echo "时间步长:$DT (ps)"
嗯,这里要注意一点。变量名区分大小写,SIM_NAME 和 sim_name 是两个不同的东西。我个人习惯用大写字母加下划线,一眼就能看出这是变量。
在批处理中,变量最常见的用途就是替换路径和参数。比如:
BASE_DIR="/data/project/md_sim"
STRUCTURE="protein.gro"
TOPOLOGY="topol.top"
# 用变量拼接路径
gmx grompp -f $BASE_DIR/params.mdp -c $BASE_DIR/$STRUCTURE -p $BASE_DIR/$TOPOLOGY -o $BASE_DIR/topol.tpr
这样做的好处是什么?下次换个体系,你只需要改前三行赋值,后面所有命令自动适配。我在项目中遇到过有人把路径硬编码在20条命令里,换体系时改到崩溃。
4.1.1 变量的作用范围
默认情况下,Shell脚本里定义的变量是全局的。这意味着你在函数外面定义一个变量,函数里面也能直接用。但反过来,函数里面定义的变量,在外面也可能被意外改掉。
用一个例子说明:
COUNT=5
my_func() {
COUNT=10
echo "函数内部 COUNT = $COUNT"
}
my_func
echo "函数外部 COUNT = $COUNT"
猜猜输出是什么?两个都是10。因为函数内部没有用 local 关键字,它直接修改了全局变量。在写复杂的批处理脚本时,这很容易埋坑。我建议,在函数内部使用 local:
my_func() {
local COUNT=10
echo "函数内部 COUNT = $COUNT"
}
my_func
echo "函数外部 COUNT = $COUNT" # 还是5,没有被污染
local。这能避免很多莫名其妙的值污染问题。
4.2 循环:让脚本自己重复跑
循环是批处理的核心。你想想看,GROMACS的模拟通常要跑多个副本、多个温度、多个配体……手动一条条地跑?那批处理的意义何在。
4.2.1 for循环
for 循环最适合处理已知列表。比如遍历一组结构文件:
for pdb_file in *.pdb; do
echo "处理结构文件:$pdb_file"
# 去掉后缀,得到文件名前缀
base_name=$(basename "$pdb_file" .pdb)
echo "生成top文件:${base_name}.top"
done
这里的 ${base_name} 用花括号括起来,是为了明确变量边界。不加花括号,写成 $base_name.top,Shell会以为变量名是 $base_name.top,找不到值就变成空。
另一个常用的是数字序列循环,配合 seq 命令:
for i in $(seq 1 5); do
echo "模拟副本 #$i"
mkdir -p "rep_$i"
cd "rep_$i"
cp ../template/* .
cd ..
done
我在跑自由能微扰(FEP)时经常用这种模式,lambda窗口从0到1,步长0.05,循环自动生成所有窗口的目录和输入文件。
4.2.2 while循环
while 循环适合处理不确定次数的情况,比如等待一个进程结束:
# 等待某个作业完成
JOB_ID=12345
while squeue -j $JOB_ID | grep -q "R\|PD"; do
echo "作业还在运行,等待30秒..."
sleep 30
done
echo "作业已完成,开始后续处理"
说白了,for 循环用于你知道要跑多少次,while 循环用于你只知道退出条件。在GROMACS批处理里,for 循环出现频率远高于 while。
4.3 条件判断:让脚本有“脑子”
脚本不能只会机械地执行,它需要根据状态做决策。条件判断就是脚本的“脑子”。
4.3.1 if/elif/else结构
最基本的格式:
if [ 条件 ]; then
# 条件成立时执行
elif [ 另一个条件 ]; then
# 另一个条件成立时执行
else
# 都不成立时执行
fi
注意 [ 和 ] 两侧必须有空格,这是新手最容易忽略的地方。我曾经排查过一个半小时的脚本错误,最后发现就是 [$VAR 少了个空格。
常用条件运算符:
| 表达式 | 含义 | 批处理中的应用 |
|---|---|---|
-f 文件 |
判断文件是否存在且为普通文件 | 检查topol.top是否已生成 |
-d 目录 |
判断目录是否存在 | 检查模拟目录是否已创建 |
-z 字符串 |
字符串长度是否为0 | 检查变量是否为空 |
数值1 -eq 数值2 |
数值是否相等 | 比较模拟步数或副本编号 |
数值1 -gt 数值2 |
数值1是否大于数值2 | 检查能量阈值 |
一个实际的批处理中的条件判断例子:
TPR_FILE="npt.tpr"
if [ -f "$TPR_FILE" ]; then
echo "已找到TPR文件,准备运行成品模拟"
gmx mdrun -deffnm npt -v
else
echo "错误:TPR文件 $TPR_FILE 不存在,请检查grompp步骤"
exit 1
fi
[ ] 内的变量,最好加上双引号。比如 [ -f "$TPR_FILE" ] 而不是 [ -f $TPR_FILE ]。一旦变量值为空或包含空格,不带引号的写法会直接导致语法错误。
4.4 函数:封装重复逻辑
函数的本质就是给一段代码逻辑起个名字。以后只要调用这个名字,这段逻辑就会执行。说白了,就是“一次定义,到处重用”。
定义函数的两种写法:
# 写法一
run_em() {
local nsteps=$1
local output=$2
echo "运行能量最小化,步数:$nsteps"
gmx mdrun -deffnm "$output" -nsteps "$nsteps"
}
# 写法二
function run_em() {
local nsteps=$1
local output=$2
echo "运行能量最小化,步数:$nsteps"
gmx mdrun -deffnm "$output" -nsteps "$nsteps"
}
调用函数时,参数通过 $1、$2、$3 等位置变量传递:
run_em 50000 em # $1=50000, $2=em
run_em 100000 em2 # $1=100000, $2=em2
在GROMACS批处理脚本中,我通常会封装几个核心函数:
# 预处理:grompp
do_grompp() {
local mdp=$1
local gro=$2
local top=$3
local tpr=$4
gmx grompp -f "$mdp" -c "$gro" -p "$top" -o "$tpr"
if [ $? -ne 0 ]; then
echo "grompp失败,退出"
exit 1
fi
}
# 成品模拟:mdrun
do_mdrun() {
local deffnm=$1
local nsteps=$2
gmx mdrun -deffnm "$deffnm" -nsteps "$nsteps" -v
}
# 后处理:提取能量数据
extract_energy() {
local edr=$1
local selection=$2
local output=$3
echo -e "$selection\n0" | gmx energy -f "$edr" -o "$output"
}
$? 是上一条命令的退出码。0表示成功,非0表示失败。在函数中检查 $?,是构建健壮批处理脚本的基石。
4.5 整合起来:一个简单的批处理框架
把前面四个概念串起来,就是一个能用的批处理脚本雏形了。下面这个例子展示了如何自动跑能量最小化和NVT平衡:
#!/bin/bash
# GROMACS批处理脚本 - 能量最小化 + NVT平衡
# === 变量定义 ===
BASE_DIR="/scratch/project/md"
STRUCTURE="$BASE_DIR/start.gro"
TOPOLOGY="$BASE_DIR/topol.top"
EM_MDP="$BASE_DIR/em.mdp"
NVT_MDP="$BASE_DIR/nvt.mdp"
EM_TPR="$BASE_DIR/em.tpr"
NVT_TPR="$BASE_DIR/nvt.tpr"
EM_OUT="$BASE_DIR/em"
NVT_OUT="$BASE_DIR/nvt"
# === 函数定义 ===
run_grompp() {
local mdp=$1
local gro=$2
local top=$3
local tpr=$4
echo "[$(date '+%H:%M:%S')] 运行 grompp..."
gmx grompp -f "$mdp" -c "$gro" -p "$top" -o "$tpr" || exit 1
}
run_mdrun() {
local deffnm=$1
local tpr=$2
echo "[$(date '+%H:%M:%S')] 运行 mdrun..."
gmx mdrun -deffnm "$deffnm" -s "$tpr" -v
if [ $? -ne 0 ]; then
echo "错误:mdrun失败"
exit 1
fi
}
# === 主流程 ===
echo "===== GROMACS批处理开始 ====="
echo "工作目录:$BASE_DIR"
# 第1步:能量最小化
echo "--- 步骤1/2:能量最小化 ---"
if [ -f "$EM_TPR" ]; then
echo "TPR文件已存在,跳过grompp"
else
run_grompp "$EM_MDP" "$STRUCTURE" "$TOPOLOGY" "$EM_TPR"
fi
run_mdrun "$EM_OUT" "$EM_TPR"
# 第2步:NVT平衡
echo "--- 步骤2/2:NVT平衡 ---"
if [ -f "$NVT_TPR" ]; then
echo "TPR文件已存在,跳过grompp"
else
run_grompp "$NVT_MDP" "${EM_OUT}.gro" "$TOPOLOGY" "$NVT_TPR"
fi
run_mdrun "$NVT_OUT" "$NVT_TPR"
echo "===== 批处理完成 ====="
这个脚本里,变量让路径统一管理,循环可以加在后面处理多副本,条件判断做了TPR存在性检查和错误处理,函数把grompp和mdrun的逻辑封装起来。你想想看,以后要跑新的体系,改几个变量名和路径就行了,不需要重写逻辑。