IoT OTA 差分升级:bspatch 减小固件体积
先说结论
两个版本间改动不大(改 bug、调参数),差分包通常是全量的 5-15%。全量改了 50% 以上,差分就没意义了。做差分升级推荐 bspatch,算法成熟、实现简单。
做 OTA 升级时最头疼的是固件体积。ESP32 OTA 分区通常划 512KB-1MB,一次全量升级要传 1MB 数据。如果是 NB-IoT 或 4G Cat.1 模块,带宽就几十 kbps,传 1MB 要 2-3 分钟。
差分升级只传变化的部分,通常几十 KB 就够了。
一、bsdiff/bspatch 原理
bsdiff 的原理:
- 对旧固件和新固件做二进制对比
- 生成一个差分文件(.patch),包含:添加的数据 + 删除的标记 + 额外数据
- 设备端用 bspatch 把旧固件 + 差分文件合成新固件
bspatch 依赖 bzip2 压缩,差分文件通常比原始差异大一点,但远小于全量。
二、生成差分包
在服务器端生成差分包:
# 安装bsdiffsudoaptinstallbzip2libbz2-devgitclone https://github.com/mendsley/bsdiff.gitcdbsdiff&&make# 生成差分包bsdiff firmware_v1.bin firmware_v2.bin firmware_v2.patchPython 版生成差分:
importbsdiffwithopen('firmware_v1.bin','rb')asf:old_data=f.read()withopen('firmware_v2.bin','rb')asf:new_data=f.read()patch=bsdiff.diff(old_data,new_data)withopen('firmware_v2.patch','wb')asf:f.write(patch)print(f"Old:{len(old_data)}bytes")print(f"New:{len(new_data)}bytes")print(f"Patch:{len(patch)}bytes")print(f"Ratio:{len(patch)/len(new_data)*100:.1f}%")三、ESP32 差分升级实现
ESP32 的 OTA 分区方案:
分区表: nvs 0x9000 24KB otadata 0x16000 8KB ota_0 0x20000 1MB (当前运行分区) ota_1 0x120000 1MB (OTA写入分区)差分升级流程:
- 设备从服务器下载 patch 文件
- 从 ota_0 读旧固件 + patch → 生成新固件 → 写入 ota_1
- 切换到 ota_1 启动
#include"esp_ota_ops.h"#include"bzlib.h"// bzip2库// bspatch核心函数intbspatch(constuint8_t*old,int64_toldsize,constuint8_t*patch,int64_tpatchsize,uint8_t*new,int64_tnewsize){// 差分文件头: 8字节magic + 8字节newsize + 8字节diffsize + 8字节extrasizeif(patchsize<32)return-1;int64_tbzip2_diff_size=*(int64_t*)(patch+8);int64_tbzip2_extra_size=*(int64_t*)(patch+16);int64_tctrl_size=*(int64_t*)(patch+24);// 解压diff数据uint8_t*diff_buf=malloc(bzip2_diff_size*3);unsignedintdiff_out=bzip2_diff_size*3;BZ2_bzBuffToBuffDecompress((char*)diff_buf,&diff_out,(char*)(patch+32),bzip2_diff_size,0,0);// 解压extra数据uint8_t*extra_buf=malloc(bzip2_extra_size*3);unsignedintextra_out=bzip2_extra_size*3;BZ2_bzBuffToBuffDecompress((char*)extra_buf,&extra_out,(char*)(patch+32+bzip2_diff_size),bzip2_extra_size,0,0);// 合成新固件int64_toldpos=0,newpos=0;int64_tdiff_pos=0,extra_pos=0;while(newpos<newsize){// 1. 从old拷贝+diff修正int64_tcopy_len=...;// 从ctrl数据读取for(inti=0;i<copy_len&&newpos<newsize;i++){if(oldpos+i<oldsize){new[newpos]=old[oldpos+i]+diff_buf[diff_pos];}else{new[newpos]=diff_buf[diff_pos];}newpos++;diff_pos++;}oldpos+=copy_len;// 2. 从extra拷贝copy_len=...;// 从ctrl数据读取memcpy(new+newpos,extra_buf+extra_pos,copy_len);newpos+=copy_len;extra_pos+=copy_len;// 3. 调整old位置oldpos+=...;// 从ctrl数据读取的偏移}free(diff_buf);free(extra_buf);return0;}// OTA差分升级主函数esp_err_tota_delta_update(constchar*patch_url){// 1. 下载patch文件uint8_t*patch_data=NULL;size_tpatch_size=0;esp_err_terr=http_download(patch_url,&patch_data,&patch_size);if(err!=ESP_OK)returnerr;// 2. 读取当前固件constesp_partition_t*running=esp_ota_get_running_partition();constesp_partition_t*update=esp_ota_get_next_update_partition(NULL);uint8_t*old_firmware=malloc(running->size);spi_flash_mmap_handle_tmap_handle;esp_partition_mmap(running,0,running->size,SPI_FLASH_MMAP_DATA,(constvoid**)&old_firmware,&map_handle);// 3. 解析patch头获取新固件大小int64_tnew_size=*(int64_t*)(patch_data+8);// 4. 分配新固件缓冲区uint8_t*new_firmware=malloc(new_size);if(!new_firmware){printf("Not enough memory for new firmware\n");returnESP_ERR_NO_MEM;}// 5. 合成新固件bspatch(old_firmware,running->size,patch_data,patch_size,new_firmware,new_size);// 6. 写入OTA分区esp_ota_handle_tupdate_handle=0;esp_ota_begin(update,OTA_SIZE_WITH_SECTOR,&update_handle);esp_ota_write(update_handle,new_firmware,new_size);esp_ota_end(update_handle);esp_ota_set_boot_partition(update);// 7. 清理free(new_firmware);free(patch_data);esp_partition_munmap(map_handle);printf("Delta OTA done, restarting...\n");esp_restart();returnESP_OK;}四、内存优化
ESP32 内部 SRAM 只有 520KB,放不下 1MB 固件。必须分块处理:
#defineCHUNK_SIZE(4*1024)// 4KB分块intbspatch_streaming(FILE*old_fp,FILE*patch_fp,FILE*new_fp,int64_told_size,int64_tnew_size){uint8_t*old_chunk=malloc(CHUNK_SIZE);uint8_t*new_chunk=malloc(CHUNK_SIZE);uint8_t*diff_buf=malloc(CHUNK_SIZE);int64_told_pos=0,new_pos=0;while(new_pos<new_size){// 分块读取旧固件intread_size=min(CHUNK_SIZE,old_size-old_pos);fseek(old_fp,old_pos,SEEK_SET);fread(old_chunk,1,read_size,old_fp);// 分块读差分数据fread(diff_buf,1,read_size,patch_fp);// 合成新数据for(inti=0;i<read_size;i++){new_chunk[i]=old_chunk[i]+diff_buf[i];}// 写入新固件分区fwrite(new_chunk,1,read_size,new_fp);old_pos+=read_size;new_pos+=read_size;}free(old_chunk);free(new_chunk);free(diff_buf);return0;}五、服务器端差分生成
自动化生成差分包:
#!/usr/bin/env python3importbsdiffimportos,sys,hashlibdefgenerate_patch(old_path,new_path,patch_dir):withopen(old_path,'rb')asf:old_data=f.read()withopen(new_path,'rb')asf:new_data=f.read()patch=bsdiff.diff(old_data,new_data)# 用版本号命名差分包old_ver=extract_version(old_path)new_ver=extract_version(new_path)patch_path=f"{patch_dir}/patch_{old_ver}_to_{new_ver}.bin"withopen(patch_path,'wb')asf:f.write(patch)# 生成manifestmanifest={"old_version":old_ver,"new_version":new_ver,"old_md5":hashlib.md5(old_data).hexdigest(),"new_md5":hashlib.md5(new_data).hexdigest(),"patch_size":len(patch),"full_size":len(new_data),"patch_url":f"https://ota.example.com/patches/{patch_path}",}returnmanifest# 生成当前版本到最近3个版本的差分包forold_verin["1.0.0","1.0.1","1.0.2"]:manifest=generate_patch(f"firmware_v{old_ver}.bin",f"firmware_v1.0.3.bin","patches/")print(f"Generated:{manifest}")六、实测数据
ESP32 固件,1MB:
| 版本差异 | 全量大小 | 差分包大小 | 压缩比 |
|---|---|---|---|
| 改了3个函数 | 1024KB | 8KB | 0.8% |
| 加了1个模块 | 1024KB | 45KB | 4.4% |
| 修改了20%代码 | 1024KB | 180KB | 17.6% |
| 大改(50%不同) | 1024KB | 520KB | 50.8% |
| 完全不同 | 1024KB | 1050KB | 102.5% |
下载时间对比(NB-IoT 20kbps 带宽)
| 方案 | 下载数据量 | 下载时间 |
|---|---|---|
| 全量OTA | 1024KB | ~7分钟 |
| 差分OTA(小改) | 8KB | 3.2秒 |
| 差分OTA(中改) | 45KB | 18秒 |
| 差分OTA(大改) | 180KB | 73秒 |
七、踩坑清单
- 内存不足:ESP32 做 bspatch 需要同时加载旧固件 + 差分 + 新固件到内存。1MB 固件做差分要 2MB+ 内存,必须用 PSRAM 或分块处理
- MD5 校验:差分合成后必须校验 MD5。任何一字节错误都会导致固件跑飞
- 版本跳跃:差分包是版本 A 到版本 B 的。如果设备运行版本 A,要升到版本 C,需要先生成 A→C 的差分,或先升到 B 再升到 C
- Flash 对齐:ESP32 Flash 写入以 4KB 为单位。差分合成的新固件如果不是 4KB 对齐,末尾补 0xFF
- 回滚机制:差分升级失败后,ota_0 分区没被覆盖,重启还能跑旧版。但如果写到一半 OTA 分区坏了,下次启动会进 ota_1 的无效分区。建议双区切换 + 回滚标志
- bzip2 库:ESP32 上用 bzip2 需要移植。用
make menuconfig→ Component config → bzip2 启用。也可以改成不压缩的差分格式(体积大些但省 CPU) - 生成效率:服务器端生成差分包比较慢(几十秒)。建议在 CI/CD 中预生成,不要实时生成