Skip to content

Instantly share code, notes, and snippets.

@wsxq2
Last active May 13, 2026 08:50
Show Gist options
  • Select an option

  • Save wsxq2/e6958be5f8ba63869dc943b903dd012b to your computer and use it in GitHub Desktop.

Select an option

Save wsxq2/e6958be5f8ba63869dc943b903dd012b to your computer and use it in GitHub Desktop.
backup raspberry pi sd image
#!/bin/bash
################################################################################
# 树莓派SD卡备份脚本(可安全地重复执行,即具备幂等性)
################################################################################
#
# 功能说明:
# 本脚本用于备份树莓派的SD卡,将整个系统(包括boot分区和root分区)
# 备份为一个.img镜像文件,该镜像可用于恢复或克隆到新的SD卡。
# 支持通过HTTP代理下载优化工具,自动记录并显示备份耗时。
#
# 使用方法:
# sudo ./backup_sd.sh [代理服务器地址] [输出文件路径]
#
# 参数说明:
# [代理服务器地址] - 可选参数,指定HTTP代理服务器的IP地址
# 如果不指定,默认为: 192.168.3.121
# 代理端口固定为7890
# [输出文件路径] - 可选参数,指定备份镜像的保存路径和文件名
# 如果不指定,默认保存为: /tmp/rpi-年月日时分秒.img
#
# 使用示例:
# 1. 使用默认代理和默认文件名备份:
# sudo ./backup_sd.sh
#
# 2. 指定代理服务器:
# sudo ./backup_sd.sh 192.168.1.100
#
# 3. 指定代理和输出文件名:
# sudo ./backup_sd.sh 192.168.1.100 /home/pi/backup/my-rpi-backup.img
#
# 4. 使用默认代理但指定输出文件名(需要提供第一个参数):
# sudo ./backup_sd.sh 192.168.3.121 /home/pi/backup/my-rpi-backup.img
#
# 注意事项:
# 1. 必须使用root权限运行(使用sudo)
# 2. 脚本会自动安装所需依赖:dosfstools, parted, kpartx, rsync
# 3. 需要通过HTTP代理下载PiShrink优化工具(代理端口7890)
# 4. 备份镜像大小约为已使用空间的1.5倍(使用rsync模式)
# 5. 备份过程中会排除以下目录:
# - .vscode-server, .cache, .ros(开发相关缓存)
# - /dev, /proc, /sys, /tmp(系统虚拟目录)
# - /media, /mnt, /run(挂载点和运行时目录)
# - 交换文件(swapfile)
# 6. 确保目标路径有足够的磁盘空间
# 7. 备份完成后会显示总耗时(小时、分钟、秒)
# 8. 备份完成后,可使用dd命令将镜像写入新SD卡:
# sudo dd if=备份文件.img of=/dev/sdX bs=4M status=progress
#
# 环境要求:
# - 操作系统:树莓派OS(Raspberry Pi OS)
# - 必需工具:bash, dd, fdisk, rsync等(会自动安装缺失的工具)
# - 网络要求:需要能够通过代理访问GitHub下载PiShrink工具
#
################################################################################
set -eu
PROXY_HOST="${1:-192.168.3.121}"
BOOTDEV=/dev/mmcblk0p1
ROOTDEV=/dev/mmcblk0p2
MOUNT_POINT=/mnt
[[ -d $MOUNT_POINT ]] || mkdir -p $MOUNT_POINT
TMPDIR=/tmp/
FILE="${2:-/tmp/rpi-`date +%Y%m%d%H%M%S`.img}"
USE_DUMP=false
# 进程锁文件
LOCKFILE=/var/lock/backup_sd.lock
# 用于cleanup的全局变量
LOOPDEVICE=""
MOUNTED=false
cleanup()
{
blue "Cleaning up..."
# 卸载挂载点
if mountpoint -q $MOUNT_POINT 2>/dev/null; then
umount $MOUNT_POINT 2>/dev/null || true
blue "Unmounted $MOUNT_POINT"
fi
# 清理loop设备
if [ -n "$LOOPDEVICE" ] && [ -e "$LOOPDEVICE" ]; then
kpartx -d $LOOPDEVICE 2>/dev/null || true
losetup -d $LOOPDEVICE 2>/dev/null || true
blue "Cleaned up loop device $LOOPDEVICE"
fi
# 释放锁文件
if [ -f "$LOCKFILE" ]; then
rm -f "$LOCKFILE"
fi
}
# 设置清理陷阱
trap cleanup EXIT ERR INT TERM
color_echo()
{
local color="$1"
shift
echo -e '\033['"$color"'m'"$@"'\033[0m'
}
green()
{
color_echo '1;32' "$@"
}
blue()
{
color_echo '0;36' "$@"
}
yellow()
{
color_echo '1;33' "$@"
}
red()
{
color_echo '1;31' "$@"
}
exit_with_error()
{
red "${1:-error!!!}"
exit 1
}
check_root()
{
if [ `whoami` != "root" ];then
exit_with_error "This script must be run as root!"
fi
}
acquire_lock()
{
# 创建锁文件目录
mkdir -p $(dirname "$LOCKFILE")
# 尝试获取锁
exec 200>"$LOCKFILE"
if ! flock -n 200; then
exit_with_error "Another backup process is already running. Lock file: $LOCKFILE"
fi
green "Process lock acquired"
}
check_devices()
{
if [ ! -b "$BOOTDEV" ]; then
exit_with_error "Boot device $BOOTDEV not found"
fi
if [ ! -b "$ROOTDEV" ]; then
exit_with_error "Root device $ROOTDEV not found"
fi
green "Devices $BOOTDEV and $ROOTDEV verified"
}
check_mount_point()
{
if mountpoint -q $MOUNT_POINT; then
exit_with_error "$MOUNT_POINT is already mounted. Please unmount it first."
fi
green "Mount point $MOUNT_POINT is available"
}
check_file_exists()
{
local file="$1"
if [ -f "$file" ]; then
exit_with_error "Output file $file already exists. Please remove it first or specify a different name."
fi
# 检查目标目录是否存在且可写
local dir=$(dirname "$file")
if [ ! -d "$dir" ]; then
exit_with_error "Directory $dir does not exist"
fi
if [ ! -w "$dir" ]; then
exit_with_error "Directory $dir is not writable"
fi
green "Output file path $file is valid"
}
install_software()
{
apt update
apt install -y dosfstools parted kpartx rsync
green "software is ready"
}
create_img_file()
{
local file disksize
file="${1}"
disksize="${2}"
dd if=/dev/zero of="$file" bs=1K count=0 seek="$disksize"
}
make_part_for_img_file()
{
local file fat32_start fat32_end ext4_start
file="${1}"
fat32_start="${2}s"
fat32_end="${3}s"
ext4_start="${4}s"
parted $file --script -- mklabel msdos
parted $file --script -- mkpart primary fat32 $fat32_start $fat32_end
parted $file --script -- mkpart primary ext4 $ext4_start -1
}
create_img_file_and_part()
{
local file="${1}"
local boot_total_size=$(df -P $BOOTDEV | tail -n 1 | awk '{print $2}')
local root_used_size=$(df -P $ROOTDEV | tail -n 1 | awk '{print $3}')
local scale=1.1
local disksize=$(echo $boot_total_size $root_used_size |awk '{print int(($1+$2)*'${scale}')}')
blue "disksize is $disksize kb"
create_img_file $file $disksize
# 直接从sysfs读取(最可靠)
local fat32_start=$(cat /sys/class/block/mmcblk0p1/start)
local fat32_end=$((fat32_start + $(cat /sys/class/block/mmcblk0p1/size) - 1))
local ext4_start=$(cat /sys/class/block/mmcblk0p2/start)
blue "fat32_start=$fat32_start fat32_end=$fat32_end ext4_start=$ext4_start"
make_part_for_img_file $file $fat32_start $fat32_end $ext4_start
green "create $file and parted it success"
}
backup_rootdev_rsync()
{
local file="$1"
local SWAPFILE=/swapfile
if [ -f /etc/dphys-swapfile ]; then
local SWAPFILE=`cat /etc/dphys-swapfile | grep ^CONF_SWAPFILE | cut -f 2 -d=`
fi
local EXCLUDE_SWAPFILE="--exclude $SWAPFILE"
rsync --force -rltWDEgopHS --delete --stats \
$EXCLUDE_SWAPFILE \
--exclude ".gvfs" \
--exclude ".vscode-server" \
--exclude ".cache" \
--exclude ".ros" \
--exclude "$boot_mnt" \
--exclude "/dev" \
--exclude "/media" \
--exclude "$MOUNT_POINT" \
--exclude "/proc" \
--exclude "/run" \
--exclude "/snap" \
--exclude "/sys" \
--exclude "/tmp" \
--exclude "lost\+found" \
--exclude "$file" \
/ $MOUNT_POINT
if [ -d /snap ]; then
mkdir $MOUNT_POINT/snap
fi
for i in boot dev media mnt proc run sys boot; do
if [ ! -d $MOUNT_POINT/$i ]; then
mkdir $MOUNT_POINT/$i
fi
done
if [ ! -d $MOUNT_POINT/tmp ]; then
mkdir $MOUNT_POINT/tmp
chmod a+w $MOUNT_POINT/tmp
fi
}
backup_rootdev_dump()
{
sudo dump -0uaf $TMPDIR/tmp12345 /
pushd $MOUNT_POINT
sudo restore -rf $TMPDIR/tmp12345
popd
#sudo dump -0uaf $MOUNT_POINT/rootdev.dump / # sudo restore -rf rootdev.dump
#sudo cp rootdev.dump $MOUNT_POINT
}
mkfs_and_mount_and_cp_files()
{
local file="$1"
local boot_label=$(dosfslabel $BOOTDEV | tail -n 1)
local root_label=$(e2label $ROOTDEV | tail -n 1)
local loopdevice=`losetup -f --show $file | tail -n 1`
LOOPDEVICE=$loopdevice # 保存到全局变量供cleanup使用
kpartx -va $loopdevice
local device="/dev/mapper/$(basename $loopdevice)"
local partBoot="${device}p1"
local partRoot="${device}p2"
[[ -e "$partBoot" ]] || exit_with_error "$partBoot not exist"
[[ -e "$partRoot" ]] || exit_with_error "$partRoot not exist"
mkfs.vfat -F 32 -n "$boot_label" $partBoot
mkfs.ext4 $partRoot
e2label $partRoot $root_label
mount -t vfat $partBoot $MOUNT_POINT
# boot mount point
local boot_mnt=$(findmnt -n $BOOTDEV | awk '{print $1}')
[[ -n $boot_mnt ]] || exit_with_error "boot_mnt empty"
cp -rfp ${boot_mnt}/* $MOUNT_POINT/
local opartuuidb=`blkid -o export $BOOTDEV | grep PARTUUID`
local opartuuidr=`blkid -o export $ROOTDEV | grep PARTUUID`
local npartuuidb=`blkid -o export ${partBoot} | grep PARTUUID`
local npartuuidr=`blkid -o export ${partRoot} | grep PARTUUID`
sed -i "s/$opartuuidr/$npartuuidr/g" $MOUNT_POINT/cmdline.txt
sync
umount $MOUNT_POINT
mount -t ext4 $partRoot $MOUNT_POINT
if $USE_DUMP; then
backup_rootdev_dump
else
backup_rootdev_rsync $file
fi
sed -i "s/$opartuuidb/$npartuuidb/g" $MOUNT_POINT/etc/fstab
sed -i "s/$opartuuidr/$npartuuidr/g" $MOUNT_POINT/etc/fstab
sync
umount $MOUNT_POINT
kpartx -d $loopdevice
losetup -d $loopdevice
LOOPDEVICE="" # 清理完成后清空
green "mkfs and cp files success"
}
optimize_img_file()
{
local file="$1"
blue "Optimizing image file..."
# 安装PiShrink
curl -O https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
chmod +x pishrink.sh
# 直接修改原文件
sudo ./pishrink.sh -v -n -d $file
}
set_http_proxy()
{
local proxy="$1"
export http_proxy="$proxy"
export https_proxy="$proxy"
green "HTTP proxy set to $proxy"
}
################################################################################
# 主执行流程
################################################################################
# 记录开始时间
START_TIME=$(date +%s)
blue "Starting SD card backup process..."
# 1. 检查root权限
check_root
# 2. 获取进程锁(防止并发执行)
acquire_lock
# 3. 验证设备存在
check_devices
# 4. 检查挂载点状态
check_mount_point
# 5. 检查输出文件
check_file_exists $FILE
set_http_proxy "http://$PROXY_HOST:7890"
# 6. 安装必要软件
install_software
# 7. 创建镜像文件并分区
create_img_file_and_part $FILE
# 8. 格式化、挂载并复制文件
mkfs_and_mount_and_cp_files $FILE
# 9. 优化镜像文件
optimize_img_file $FILE
# 计算总耗时
END_TIME=$(date +%s)
ELAPSED_TIME=$((END_TIME - START_TIME))
HOURS=$((ELAPSED_TIME / 3600))
MINUTES=$(((ELAPSED_TIME % 3600) / 60))
SECONDS=$((ELAPSED_TIME % 60))
green "All done. Backup saved to: $FILE"
blue "Total time elapsed: ${HOURS}h ${MINUTES}m ${SECONDS}s (${ELAPSED_TIME} seconds)"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment