其实这么说吧,我之所以想认真学习linux,是因为我想认真学习怎么做嵌入式项目开发,然后我发现,其实如果需要在Ubuntu中如果需要深入开发,就不可避免接触很多脚本编写,这时候就需要通过shell脚本实现很多功能,自动化编译、镜像打包、批量烧写、日志过滤诸如此类的。所以其实shell编程学习也是绕不过去的一环。
Shell是什么?
其实shell是一个命令行解释器,它为用户提供了一个向Linux内核发送请求以便运行程序界面系统级程序,用户可以用shell来启动、挂起、停止甚至是编写一些程序。
其实像mkdir /home/menglao/桌面/menglaodir,其实这是一个在桌面新建一个menglaodir文件夹的指令,但是其实这个指令直接丢给linux内核,linux是不理解的,这里这里中间就需要一个命令行解释器,这就是shell的作用。
Shell脚本
首先脚本有其格式要求,其开头必须是:
#!/bin/bash
其实这句话的意思是,以下的内容都需要用bin中的bash程序来解释,如果用cat打开bash,会发现bash是一堆乱码,这是因为其实bash并不是文本文件,而是一些二进制代码,是给底层机器看的,bash这个程序在这里的作用就是充当人和机器间的翻译官,把人输入的英文代码指令,通过编译变成机器能理解的二进制代码,进而执行指令。

编写Shell脚本
我在/home/menglao/桌面下,vim menglao.sh

注意我文档的开头是有#!/bin/bash的,然后保存。
Shell脚本执行
方式一:直接输入绝对路径执行

但这里有一个值得留意的就是我们需要查看这个.sh文件是否有x就是可执行权限

只有这里有x才不会报错,当然了权限报错也好办,chmod u+x menglao.sh就好了。
例外:当然了当我们当前路径和需要被执行脚本路径在同一个目录下,我们也可以不用绝对路径,而用相对路径,如:./menglao.sh

方式二:sh+脚本,如:sh menglao.sh

这个方式无论menglao.sh是否有x,可被执行权限都可以执行。
Shell变量
在linux中Shell的变量分为系统变量和用户自定义变量。
系统变量有:$HOME、$PWD、$SHELL、$USER等等;
什么叫$HOME,其实就是当前用户home目录的路径,如下如:

通过set指令,可以查看当前系统的所有系统变量。
自定义变量:
基本语法 变量名=值;

这里可以看到这个.sh文档中age被赋值了一个int,这里有两点值得注意的,首先赋值时,变量名、等号、值之间不能有空格,不然会有以下的报错,如下图;其次需要输出一个变量时,需要以$变量名形式出现。



其实shell脚本的语法其实跟之前学的python也有很相似的地方,print的时候也是可以带字符串跟变量一起输出的。
撤销变量:unset 变量名

这里通过先定义一个name变量,其值为menglao字符串,但后续又撤销了这个变量,所以当我们sh menglao.sh这个脚本的时候,name对应的变量并不会被输出。

声明静态变量:readonly变量,注意此时不能用unset
所谓的静态变量就是从一开始被初始化之后就不会再变化的,全程保持同一个值的变量。
Shell变量的定义
定义变量的规则:
1、变量名字可以由字母、数字、下划线组成,但不可以是数字开头;
2、等号两侧都不能有空格;
3、变量名称一般习惯为大写;
4、如果将指令的返回值赋值给变量,有两种表达:
A=‘date’或者A=$(date),这两种表达方式都是将指令date所返回的年月日时分秒一大串的数据赋值给到A。


设置环境变量
基本语法
export 变量名=变量值;(将Shell变量输出为环境变量/全局变量)
source 配置文件;(让修改后的配置信息立即生效)
echo $变量名;(打印变量名所被赋的值,如echo $age,就会输出36)
实例:
我在/etc/profile中写入了export ORANGE_HOME=/home/orange,其实就相当于在系统配置中,把ORANGE_HOME这个变量提升为了全局变量,因为/etc/profile这是一个所有用户系统级别的profile配置文件。

所以我在给这个profile文件写入了ORANGE_HOME这个变量之后还需要source /etc/profile这个配置文件,就是告诉系统,我的/etc/profile配置文件更新了。

然后现在ORANGE_HOME就成了一个全局变量了,我就可以想使用date一样,调用这个变量了,如下图:


Shell脚本中的注释
分为单行注释和多行注释;

单行注释使用#号;
多行注释使用 :<<! 内容
内容
内容!
以:<<!开头,以!结尾,其实是有些怪,如上图。
可以看到注释部分是不会被执行的。
位置参数变量(逻辑与python的位置传参相似)
位置参数变量本质就是 Shell 脚本用来 “接收调用时传入的参数” 的变量,按参数传入的顺序位置编号,而$0之所以是脚本自己的名字,是因为脚本本身是 “调用指令里的第 0 个位置”。



Linux 执行指令的底层逻辑是:“指令主体 + 指令参数”,脚本作为 “可执行的指令主体”,自然占据第 0 位,后面的都是给这个主体传的参数:
-
比如你敲ls -l /home:ls是指令主体(对应$0),-l是$1,/home是 $2;
-
比如你敲./deploy_detect.sh 参数1 参数2:./deploy_detect.sh是指令主体(对应$0),参数1是$1,参数2是 $2;


其实这么一看就很直观了,我开始只是理解不了为什么menglao.sh会是$0,然后其实背后的逻辑就是等价于python中的自定义函数的位置传参。
其实这里做一个类比,所谓的位置参数变量是很像python中的位置传参的,当我们自定义一个函数的时候,会给这个函数一些参数,后期调用的时候,我们只需要对应写入参数,这些参数会按照自己的顺序来插入到相应的运算中去:
# 定义函数:按位置接收模型名、树莓派IP
def transfer_model(model_name, pi_ip):
print(f"模型名:{model_name}")
print(f"树莓派IP:{pi_ip}")
# 调用函数:按位置传参(第一个值对应model_name,第二个对应pi_ip) transfer_model("yolov8n.onnx", "192.168.1.100")
预定义变量(系统变量都是预定义变量)
Shell设计者事先已经定义好的变量,可以直接在Shell脚本中使用的。
所以其实所有的系统变量都是预定义变量,如$PWD $HOME $USER这些。

我常识修改刚刚的menglao.sh来测试,利用位置参数变量(位置传参),让menglao.脚本打印这几个预定义变量出来,具体修改如下:

我这里是通过后台运行了我之前的一个显示日历的脚本,就是mydate.sh,文档中注释了就是在需要运行的脚本后面添加&号,就是代表后台运行的意思,得出的结果是:

运算符
在Shell中有各式各样的运算符,通常有三种表达方式。(其实说实话要记得东西还挺多挺反人类的~~~~~~)
$((运算表达式))
$[运算表达式]
`expr m + n`; 这里的expr是expression表达式的意思,之所以需要使用`反引号引起来,是因 为如果这个等式的值需要赋值个一个变量就需要这样引起来,然后如果使用 expr运算,m、n这些值跟运算符一定要有空格,不然又会报错;
`expr m – n` ; 减法运算;
`expr m \\* n` ; 乘法运算,乘法运算符需要\\*反斜杠加星号,且需要前后`反引号;
`expr m / n` ; 除法运算;
`expr m % n` ; 取余运算;
假如说算一个很简单的(2+3)*4,我建议还是用$(((2+3)*4)),这种表达算了,expr那种太鬼复杂了,稍微写错一点点就会报错,我问过豆包说$((运算表达式))是容错率最高的运算方式


来做个练习,利用位置参数变量传入,来做些最基础的加减乘除运算;

![]()
所以这里就是SUM1 是1+2;SUM2是3*4;SUM3是5除6为0。
流程控制(整个Shell编程的核心本质)
所谓的流程控制包含了条件判断、循环、流程跳转,它是Shell脚本的骨架,变量、运算符、命令等是Shelll脚本的血肉,所有的知识点都是为了流程控制的落地。
条件判断(分为单分支和多分支)
基本语法:[ condition ] 注意中括号前后需要有空格, 跟expr运算一样,真的挺奇怪的~
那怎么来知道这个condition有没有成立呢,原来是要靠判断[ condition ]的返回值,如果为0就是true,如果大于1就是false,这也蛮奇怪的。

-lt :小于 little;
-le :小于等于little equal;
-eq :等于equal;
-gt : 大于great;
-ge : 大于等于great equal;
-ne : 不等于 not equal;
其实通过实例可以看出,判断语句也不是真的只有[ condition ]的,还是要配合着if then fi这些。

![]()
其实整体格式应该是这样的:
if [ condition ] #使用if 和 [ conditiion ]配合,输入判断条件是什么;
then #使用then进入若真时输出什么;
echo xxx #echo xxx代表若真时需要执行什么;
fi #以fi结束整个判断语句,此时只有若真会执行指令,若假这里是不做任何 操作的;

![]()
判断23和22 数值的大小,如上图。


判断/root/shcode/aaa.txt文档是否存在,没有输出代表不存在。

但是我通过新建这个shcode目录touch了一个aaa.txt文档,再去判断就有存在显示了,再删掉又没有了。
if(当输入数值进行判断再执行指令时使用)
单分支条件判断(就是只会在为真的时候才会执行)
if [ condition ] #使用if 和 [ conditiion ]配合,输入判断条件是什么;(条件判断)
then #使用then进入若真时输出什么;
echo xxx #echo xxx代表若真时需要执行什么;(执行代码)
fi #以fi结束整个判断语句,此时只有若真会执行指令,若假这里是不做任何 操作的;
多分支条件判断(就是为真时执行什么,为假时执行什么)
if [ condition ] #使用if 和 [ conditiion ]配合,输入判断条件是什么;(条件判断)
then #使用then进入若真时输出什么;
echo xxx #echo xxx代表若真时需要执行什么;(执行代码)
elif [ condition ] #继续判断[ condition ] ,输入判断条件;(条件判断)
then #使用then进入若真时输出什么;
echo xxx #echo xxx代表若真时需要执行什么;(执行代码)
fi #以fi结束整个判断语句,此时只有若真会执行指令,若假这里是不做 任何操作的;
我复写了一个多分支的判断,判断当输入位置参数变量是否及格的这么一个Shell脚本;


可以看到当位置参数变量$1 为80和50的时候输出的echo内容是不一样的。
case(当输入固定值执行指令时使用)
case其实是属于多分支判断语句,适用于多个变量值需要执行不同逻辑时可以优先适用case,比起if-elif-else更加简洁。
基本语法:(通过if和case语句 ,我们不难发现,一个很明显的语法特征就是开头和结尾是指令正反写)
case $1 in #判定$1位置参数变量输入的值与以下判断哪个值;
"1")
echo "周一" #当$1输入变量值为1时执行“周一”输出;
;;
"2")
echo "周二"
;;
"3")
echo "周三"
;;
"4")
echo "周四"
;;
"5")
echo "周五"
;;
*)
echo "周末"
;;
esac

可以明显看到,当$1输入变量值为6和3的时候是完全不同的输出,当然了,这个例子其实很片面的。
还有就是case有一个局限性,就是它只能执行当输入值为一个已经设定好的固定值时,执行什么代码,而不能把输入值作一个比较,然后再看执行什么代码,就是它并非if的进阶版。
循环语句
循环执行重复指令,核心有三种:for、while、until。
for循环
for 遍历变量 in 值(通常是很多个,这样才能循环多次赋值给遍历变量) #循环语句
do
echo xxx #执行代码(循环体)
done #循环结束语




上面两个实例都是使用循环语句复写的,两者最大的区别是一个是i在$*中遍历取值,一个是i在$@中遍历取值,可以看到两者同样执行echo “num is $i”,却输出了完全不同的内容。
这里其实体现了$@和$*的区别,两者都是位置参数变量,但是:
$*:合并所有位置参数的值,变成一个字符串,只合适把所有参数看做一个整体输出的时候;
$@:保留每一个位置参数的值的独立性,是遍历所有位置参数的标准用法。
所以从上面的实例可以看出,如果使用循环遍历需要使用$@。
for((初始值;循环控制条件;变量变化)) #适合作用于
do
echo xxx
done


可以看得出,这种for语句的写法,与上面的写法有着明显的区别,这种写法不是在一堆参数中遍历取值,而是指定一定的取值范围,这里的取值范围就是i<=100,所以i=1是初始值,i<=100是循环控制条件,i++是变量变化,这里的i++等于经历了一个小循环。

其实还可以吧在这个i<=100的100变成一个位置参数($1),暴露在外,让在这个参数可以输入调整。
while循环
基本语法:
whil [ condition ] #条件判断,记得中括号前后都需要空格
do
echo xxx #执行代码(循环体),值得注意的是,这里的执行代码可以不止 一个的,可以执行多个
done #结束语句


这里可以看到,这个while循环首先创建了两个变量并初始赋值为0,然后仍然是一个总计的代码,利用 变量A和位置参数(输入值)的数值大小对比,只要变量A仍然小于等于输入值是,继续执行A的+1循环和SUM2的+A循环,这里就 体现了循环体执行代码不但只执行一行代码的特点。
读取控制台命令(其实跟python中的input()自定义函数一样作用)
read指令,读取控制台命令(其实所谓的读取控制台命令就是等待用户输入指令然后作为变量的数值赋值给变量)。
read 参数 变量名;
-p:指定读取值的提示符;
-t :指定读取值等待的时间(秒),如果没有在指定时间内输入,就不等待了。
实例:
read -p “请输入一个数值作为NUM1” NUM1 #读取用户输入的数值,然后给到NUM1
相当于NUM1=input()
echo "现在NUM1数值为$NUM1" #输出NUM1数值


![]()

可以看得出来,在第二次测试中加入了一个-t 10 在-p前面,其实就是进而限定需要再time10秒内完成输入,否则就像第二行右侧截图那样,没有输入的数值,自动退出脚本,当然呐,也不是很智能那种,因为既然都没有用户输入数据,那为什么还要执行下面的echo,就有点傻。
但通过这两个实例,其实都是为了弄清究竟read作用是什么-p又有什么用,-t又有什么用,其实-p -t不用都可以的,等于是read NUM3,就相当于用户在输入同时赋值给了这个NUM3了。
系统函数
其实系统函数有很多,这里只列举了两个。
basename
基本语法:basename path(路径) 格式后缀(如若需要筛选掉)
用于在文档路径中截取文件名或者文件名+格式名:
如我有一个文档/home/menglao/桌面/menglao.sh
如果我只想要这个文档的文件名+格式名(.sh)
那就执行:
basename /home/menglao/桌面/menglao.sh; #输出menglao.sh
如果这时,我连文档的格式名都不需要,只需要文档的文件名,那么就:
basename /home/menglao/桌面/menglao.sh .sh; #输出menglao
据说这个系统函数常用于读取路径中的文档名称之用。
dirname
基本语法:dirname path(路径)
用于在文档路径中截取目录部分,可以理解是截取basename的剩余部分,basename只留文档名,dirname只留目录部分。
如我有一个文档/home/menglao/桌面/menglao.sh
如果我想要这个文档的目录路径,
那就执行:
dirname /home/menglao/桌面/menglao.sh; #输出/home/menglao/桌面
自定义函数
所谓的自定义函数就是自己写入想要达成功能的函数嘛,跟其他编程语言都差不多的,最大的差异其实在于实现语法。
function 自定义函数名(例如我下面的getSUM()) {
函数体(其实就是封装需要执行的逻辑,假设有两个指令)
指令1
指令2
}
以上就是自定义函数的基本语法了,最大的区别于python的自定义函数就是需要{}两个花括号括起整个函数体,当然进阶写法不用function直接自定义函数名加{}也是可以的。
实例:

上面示例就是使用了两个读取控制台命令而来的NUM1和NUM2相加,这里有几个知识点的,首先是$(())运算一定要熟悉,其次是当使用read的时候-p “这里面其实是提示用户所要输入的东西的字符串提示” NUM1,NUM1这里才是需要赋值的变量,但是这个变量是不需要在前面加$符的,这个是需要牢记的。

Shell编程综合练习
题目:ESP32 轻量化模型定时备份与旧文件清理脚本
需求分析:
定时执行:每天凌晨 2:30,将 ESP32 的轻量化识别模型(存储在/home/esp/models/目录下的所有.onnx文件),备份到嵌入式设备的存储目录/data/esp_backup/models/;
提示信息:备份开始和备份结束时,输出包含 “当前时间 + 操作状态” 的提示(例如:[2026-01-17 02:30:00] 开始备份ESP32模型…);
文件命名:备份后的压缩包以 “备份时间” 为核心命名,格式要求:年-月-日_时-分_模型标识.tar.gz(例如:2026-01-17_0230_esp_models.tar.gz);
清理旧文件:在备份完成后,检查/data/esp_backup/models/目录下是否存在7 天前及更早的备份文件,若存在则自动删除这些旧文件(避免嵌入式设备存储占满)。
要求:
编写一个 Shell 脚本实现上述需求,并说明如何配置定时任务让脚本每天凌晨 2:30 自动执行。
在做这个练习之前,需要先理清一下思路。这其实是一个定时备份,压缩、清除文件等功能的脚本。
新建一个脚本文件来执行这一些指令的所以vim /home/esp/esp32models_backup.sh;
接下来就涉及到好几个知识点了,date指令的延展使用;各种路径变量定义;条件判断语句的使用(if);.onnx文件的筛选(find);cp指令使用;压缩指令的运用(tar);清理文档(rm);定时任务调度(crontab)。
首先需要先获取时间,这个时间获取是很重要的,会涉及到后面的带时间戳的子目录的创建;压缩文件命名数据来源。
# ========== 1. 时间变量定义(核心:只赋值一次,避免拼写错)=============
DATETIME=$(date +%Y-%m-%d_%H%M%S) #+%Y-%m-%d_%H%M%S这是date指令的 指定参数版
echo "当前备份时间戳:${DATETIME}" #输出字符串+变量值
然后就是定义各种关于路径的变量名及其相应路径
# ======= 2. 备份路径定义(统一用"${}"包裹,避免拆分)===================
BACKUPSRC_DIR="/home/esp/models" # 备份源目录
BACKUP_DIR="/data/espbackup" # 备份主目录
BACKUP_PATH="${BACKUP_DIR}/${DATETIME}" # 动态备份子目录(整合变量,减少拼接)
这里关键关注"${BACKUP_DIR}/${DATETIME}"这种写法,这种写法相当于目录路径下关联一个有日期时间为命名的目录,特别是当被mkdir -p创建的时候。
定义好了各种变量之后,就会涉及到这些目录路径原来是否存在的判断,这个也是新手与熟练之间的区别,说实话作为新手其实连基本的语法可能都未必能写得全,还要思考整体逻辑顺序和目录是否存在的这类判定就是进阶选手考虑的了,但其实十分必要,不然Shell编程出来就是很基础很粗糙。
# ========= 3. 目录检查与创建(逻辑优化:先建主目录,再建子目录)=============
# 第一步:检查并创建主备份目录(确保根目录存在)
if [ -d "${BACKUP_DIR}" ]; then
echo "✅ 备份主目录已存在:${BACKUP_DIR}"
else
echo "⚠️ 备份主目录不存在,正在创建:${BACKUP_DIR}"
mkdir -p "${BACKUP_DIR}" # 只创建主目录,子目录后续单独处理
echo "✅ 备份主目录创建成功:${BACKUP_DIR}"
fi
# 第二步:检查并创建本次备份的时间戳子目录(无论主目录是否存在,确保子目录存在)
if [ -d "${BACKUP_PATH}" ]; then
echo "✅ 本次备份子目录已存在:${BACKUP_PATH}"
else
echo "⚠️ 本次备份子目录不存在,正在创建:${BACKUP_PATH}"
mkdir -p "${BACKUP_PATH}"
echo "✅ 本次备份子目录创建成功:${BACKUP_PATH}"
这里其实也有个知识点需要关注的,除了本章说的if判断会使用[ condition ]之外,其实还有一种[ ! condition ]的表达方式,上面一种是当判定为真时执行,而加了!则会是判断为假的时候执行,特别适合判断是否没有这个目录,如果没有就可以执行创建这类的操作。
当判断好所有所需的路径都是完整路径之后,就会涉及到备份.onnx后缀文档的问题,其实它的本质就是一个cp指令,但是其实内里相当多小知识点。首先我们使用了-name “*.onnx”来筛选所有文档后缀是.onnx的文档,避免直接把整个/home/esp/models目录直接复制那么冗余;其次这里使用了类似管道命令的-exec,-exec跟 | 最大的区别是因为cp需要的是参数,-exec可以提供参数,而管道指令给到下一个指令处理的只是文档数据,对于cp指令而言无法使用;{}在cp -r遍历复制后面其实是占位符,替代了每次find出来的.onnx的文档路径,然后复制到"${BACKUP_PATH}/"中,这里的路径末尾需要有/最为路径结束的示意;因为使用了-exec所以整个指令最后需要加上 \\; 来表示整个find -exec \\;结束。
# ============== 4. 筛选并复制.onnx文件(修复-exec语法+拼写错误)==============
# 先检查是否有可备份的.onnx文件,避免空备份
ONNX_FILES=$(find "${BACKUPSRC_DIR}" -name "*.onnx")
if [ -z "${ONNX_FILES}" ]; then
echo "❌ 未找到任何.onnx格式的文件,备份终止!"
exit 1 # 无文件则退出,避免创建空压缩包
fi
# 执行复制(核心修复:-exec结尾加\\;,修正DATETTIME拼写错误)
echo "📤 开始复制.onnx文件到:${BACKUP_PATH}"
find "${BACKUPSRC_DIR}" -name "*.onnx" -exec cp -rv {} "${BACKUP_PATH}/" \\;
# 检查复制是否成功(避免复制失败导致空目录)
if [ -z "$(ls -A "${BACKUP_PATH}")" ]; then
echo "❌ .onnx文件复制失败,备份目录为空!"
rm -rf "${BACKUP_PATH}" # 删除空目录,避免冗余
exit 1
fi
tar压缩实际上在这里反而是比较简单的指令应用了。
# ======== 5. 压缩备份目录(修复拼写错误+确保目录非空)==============
echo "📦 开始压缩备份目录:${BACKUP_PATH}"
cd "${BACKUP_DIR}" || exit 1 # 切换目录失败则退出(嵌入式场景目录权限可能出错)
tar -zcvf "${DATETIME}.tar.gz" "${DATETIME}" # 修正DATATIME拼写错误,加双引号
最关键的知识点是tar的基本语法,这里是tar -zcvf 压缩文件名 压缩文件路径,所以在这里
"${DATETIME}.tar.gz"就是压缩文件名,利用了时间戳作为文件名;
"${DATETIME}"就是需要压缩的文件路径;
其实最后一步就是rm作为核心了。
# ============ 6. 清理临时目录(确保压缩成功后再删除)=====================
if [ -f "${BACKUP_DIR}/${DATETIME}.tar.gz" ]; then
echo "✅ 压缩包创建成功:${BACKUP_DIR}/${DATETIME}.tar.gz"
echo "🗑️ 清理临时备份目录:${BACKUP_PATH}"
rm -rf "${BACKUP_PATH}" # 修正DATETTIME拼写错误
echo "✅ 临时目录清理完成!"
else
echo "❌ 压缩包创建失败,保留临时目录:${BACKUP_PATH}"
exit 1
fi
echo -e "\\n🎉 所有备份流程执行完成!最终备份文件:${BACKUP_DIR}/${DATETIME}.tar.gz"
这里其他很多都只是一些描述性的echo输出。

最后还有一步,就是定时执行脚本:
首先需要crontab -e:创建一个定时任务(以vim形式编辑);
![]()
然后再设置每天2时30分执行这个脚本;
![]()
当设置完后会提示;

然后可以通过crontab -l查看已经设置了的定时任务;




