Last active
May 13, 2026 08:50
-
-
Save wsxq2/e6958be5f8ba63869dc943b903dd012b to your computer and use it in GitHub Desktop.
backup raspberry pi sd image
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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