Uboot 文件系统分片下载功能实现详解(解决大镜像内存不足问题)
最近在做Rk3506固件适配开发,遇到了一个很经典的问题:设备SPI NAND平台的内存有限,但文件系统镜像越来越大。传统的整包下载方式,需要把整个rootfs镜像全部读到Uboot内存中再统一烧写,内存直接爆满导致下载失败。
为了解决这个问题,我改造了Uboot的文件系统下载与烧写逻辑,实现了分片下载、分片续烧的功能。今天把整套实现思路、新旧代码对比、核心逻辑踩坑点完整分享出来,方便大家快速移植适配。
一、功能改造背景
先简单说下为什么要做这次改造:
之前我们自己的下载工具和Uboot固件的交互逻辑很简单,就是一次性下载完整文件系统镜像,将整个镜像载入Uboot内存后,再执行整体擦除、整体烧写。
但随着产品迭代,rootfs镜像体积持续增大,部分平台的Uboot内存空间已经无法承载完整大镜像,直接导致下载烧写流程报错、固件升级失败。
所以核心改造方案就是:摒弃整包下载模式,采用分片下载+逐片烧写,不用将完整镜像存入内存,极大降低内存占用,完美适配大体积文件系统。
二、核心概念区分(避坑重点)
很多小伙伴容易把「分片」和「分包」搞混,这里我结合实际开发场景通俗区分一下:
-
分片(整片数据) :是我们自定义的大数据切割单元,比如设置单分片20M。完整130M镜像会被切成多片,最后一片大小可不足20M,Uboot的处理单位是「分片」,每接收完一整片就执行一次烧写。
-
分包(单包数据) :是通信链路的传输单元。受限于串口/网络传输协议,20M的单分片无法一次性传输,下载工具会自动将单个分片切割为多个小包传输,Uboot逐包接收、校验,集齐一整片数据后再烧写。
三、新旧烧写接口代码对比
本次改造核心是新增了分片烧写接口,替换原有整包烧写逻辑,先给大家直接上新旧代码对比,直观看到差异。
1. 旧版:整包烧写接口(write_rootfs)
旧接口逻辑非常简单,粗暴的「先擦除、后整写」,只支持完整镜像一次性烧写,也是内存溢出的根源。
c
int write_rootfs(unsigned long ram_addr, unsigned int len)
{
char cmd[128] = {0};
DEBUG_INFO("+%s\r\n",__func__);
// 整体擦除rootfs分区
sprintf(cmd, "mtd erase rootfs");
run_cmd(cmd);
// 一次性写入完整镜像数据
sprintf(cmd, "mtd write rootfs 0x%lx 0x0 0x%x", ram_addr, len);
run_cmd(cmd);
DEBUG_INFO("-%s\r\n",__func__);
return 0;
}
旧接口弊端 :必须等待完整镜像载入内存才能执行擦除和烧写,大镜像直接内存超限,无续写能力。
2. 新版:分片续烧接口(write_rootfs_split_img)
我新增的分片接口核心优化两点:仅首次分片擦除分区、支持偏移量续烧,完美适配分片下载场景。
c
int write_rootfs_split_img(unsigned long ram_addr, unsigned int len, unsigned int csn)
{
char cmd[128] = {0};
unsigned int current_split_num;
int ret_run_cmd = 0;
// 静态变量:记录上一次烧写偏移,实现续写不覆盖
static unsigned long last_woff = 0;
DEBUG_INFO("+%s\r\n",__func__);
current_split_num = csn;
// 分片序号异常校验
if (current_split_num < 0)
return -1;
// 校验数据是否512字节对齐,打印异常日志
if (len % 512 != 0)
DEBUG_INFO("len is not align with 512(%d, %d)\r\n", len, csn);
// 关键逻辑:仅第0片(第一个分片)执行分区擦除
if (!current_split_num) {
sprintf(cmd, "mtd erase rootfs");
ret_run_cmd = run_cmd(cmd);
if (ret_run_cmd < 0)
return -1;
}
// 基于偏移量持续写入,不重复擦除、不覆盖已有数据
sprintf(cmd, "mtd write rootfs 0x%lx 0x%lx 0x%x", ram_addr, last_woff, len);
ret_run_cmd = run_cmd(cmd);
if (ret_run_cmd < 0)
return -1;
// 累加偏移量,为下一分片续烧做准备
last_woff += len;
DEBUG_INFO("-%s\r\n",__func__);
return 0;
}
四、分片下载核心实现逻辑拆解
结合协议规范和代码实现,我把整套分片下载、烧写的流程拆解清楚,大家看了就能彻底理解原理。
1. 整体交互流程
整套交互是「电脑端下载工具 ↔ Uboot固件」双向通信,流程如下:
-
电脑端下载工具新增「分片方式下载文件系统」选项,触发分片下载流程;
-
Uboot接收 0x90 指令帧,协商单分片大小(该大小同时是Uboot堆内存申请大小);
-
电脑端下载工具按协商的分片大小切割镜像,再将每个分片分包传输;
-
Uboot接收 0x92 数据帧,逐包校验、集齐单分片数据后调用分片烧写接口;
-
循环执行分片接收+烧写,直至所有分片传输完成,结束升级。
2. 关键协议指令作用
-
0x90 指令(分片大小协商):握手指令,主要用来确定单分片的最大尺寸,Uboot根据该值申请内存,避免内存溢出;
-
0x92 指令(分片数据传输):核心数据传输指令,携带当前分片序号、总分片数、数据包序号、校验和、分片数据等关键信息。
3. 分片烧写核心逻辑细节
这部分是我开发时的核心重点,也是和整包烧写最大的区别:
① 分区擦除只执行一次
通过 current_split_num 判断,只有第0片分片会执行 mtd erase rootfs。如果每片都擦除,会导致前一片烧写的数据被清空,这是新手最容易踩的坑!
② 静态变量记录烧写偏移
用 static unsigned long last_woff 静态变量永久保存上一次的烧写偏移,每烧写完一个分片,就累加当前分片长度,下一分片从新偏移位置开始写入,实现无缝续烧。
③ 512字节对齐校验
EMMC/MTD设备烧写要求数据尽量512字节对齐,我加了日志打印,一旦数据不对齐就打印提示,方便后续调试适配。
五、Uboot侧配套改造点
除了新增烧写接口,本次功能完整落地还需要修改两个核心函数,适配新协议指令:
1. ProcessCmd 函数(download.c)
在命令解析函数中新增两个协议分支,专门处理分片下载的握手和数据传输指令:
-
case 0x90:处理分片大小协商请求,回复应答帧,告知上位机本地分片配置; -
case 0x92:处理分片数据包,解析分片序号、校验和、包序号等信息,完成数据接收与校验。
2. RetPacket 函数(download.c)
在数据包应答函数中新增0x90、0x92指令的应答逻辑,根据接收、烧写结果返回对应状态码:
-
01:单包接收成功,请求下一包数据;
-
F0:当前整片分片烧写成功;
-
F1:当前整片分片烧写失败;
-
F3:所有分片传输烧写完成,整体升级成功;
-
F2/F5:校验失败、分包序号异常等错误状态。
c
void ProcessCmd(BYTE *rxPktBuf)
{
/**
* @brief 0x90 分片下载握手协商分支
* @note 流程:打印调试日志 -> 向上位机上报本机单分片最大尺寸
* -> 初始化升级UI界面、进度条底板
* -> 等待后续0x92分片数据包传输
*/
case 0x90:
DEBUG_INFO("case 0x90\r\n");
// 应答上位机:返回本机配置的单分片大小(单位MB)
// 参数说明:携带spsize_m分片尺寸数据,告知上位机按该大小切割镜像
data_packet_len = RetPacket(udp_data, 1, func_code, 1,
(BYTE *)(&spsize_m), 1);
// 发送应答帧,完成分片大小握手协商
EviewTx(rxPktBuf, udp_data, data_packet_len, cmd_wait_status);
// ========== 以下为屏幕UI初始化:初始化升级进度条界面 ==========
// 清空屏幕,刷新升级界面底色
DRAW_ClrScreen(WHITE);
// 打印文件系统升级中提示文字
DRAW_TextOnScreen(90, 120, "Downloading and writing rootfs......");
// 绘制进度条外框(内层黑色边框)
DRAW_Rect(80, 140,
80 + PROCESSBAR_WIDTH, 140 + PROCESSBAR_HEIGHT, BLACK);
// 绘制进度条外框(外层加粗边框,美化界面)
DRAW_Rect(79, 139,
81 + PROCESSBAR_WIDTH, 141 + PROCESSBAR_HEIGHT, BLACK);
// 填充进度条背景为浅灰色,等待后续分片烧写动态填充红色进度
DRAW_FillRect(81, 141,
79 + PROCESSBAR_WIDTH, 139 + PROCESSBAR_HEIGHT, LIGHTGRAY);
break;
/**
* @brief 0x92 分片数据传输协议处理分支
* @note 整体流程:首次申请分片内存 -> 解析包/片参数 -> 首包初始化分片信息
* -> 逐包拼接数据 -> 单分片收完校验和 -> 分片烧写
* -> 反馈状态、更新进度条、收尾完成升级
*/
case 0x92:
// 1、首次进入分片下载流程,初始化申请分片对应的DDR缓存内存
if (!split_apply_malloc_first_flag) {
split_apply_malloc_first_flag = 1; // 标记内存已申请,避免重复申请内存导致内存泄漏
spsize = spsize_m << 20; // 将分片大小单位从 MB 转换为 Byte(左移20位=*1024*1024)
DDR_buffer = get_dl_buff(spsize); // 根据分片大小申请对应DDR下载缓存内存
}
// 2、解析当前数据包的关键参数(从协议帧指定偏移位置读取)
current_pkg_num = UcharToUint(&data_packet[0xB]); // 当前分片内的小包序号
current_pkg_total_num = UcharToUint(&data_packet[0xF]);// 当前分片的总包数
current_pkg_size = UcharToUshort(&data_packet[0x2]) - 20;// 当前小包的有效数据长度(剔除20字节协议头)
// 3、当前分片的第一个小包:初始化整片分片的基础信息
if (!current_pkg_num) {
current_split_num = data_packet[0x5] & 0xff; // 当前正在传输的分片序号
current_split_chksum = UcharToUint(&data_packet[0x6]);// 当前整片分片的校验和(用于收尾校验)
total_split_num = data_packet[0xA] & 0xff; // 本次升级的总分片数
memset(DDR_buffer, 0xFF, spsize); // 清空分片缓存,防止脏数据干扰
last_write_offset = 0; // 重置分片内写入偏移,从缓存起始位置开始拼接
current_split_size = 0; // 重置当前分片已接收数据总长度
// 异常判断:当前分片序号超过总分片数,属于非法数据包,直接报错退出
if (current_split_num >= total_split_num) {
data_packet_len = RetPacket(udp_data, 0, func_code,
0xF5, NULL, 0); // 返回F5:总包数超出错误
EviewTx(rxPktBuf, udp_data, data_packet_len, cmd_wait_status);
break;
}
}
// 4、合法小包:将当前包数据拼接写入DDR分片缓存
// 只要不是当前分片最后一个包,都持续拼接数据、累加偏移和长度
if (current_pkg_num <= current_pkg_total_num - 1) {
current_split_size += current_pkg_size; // 累加当前分片已接收总数据大小
// 将当前小包有效数据拷贝到缓存对应偏移位置,实现分片数据拼接
memcpy(DDR_buffer + last_write_offset,
data_packet + 0x13, current_pkg_size);
last_write_offset = current_split_size; // 更新写入偏移,为下一包拼接做准备
}
// 5、判断是否为当前分片最后一个小包
if (current_pkg_num < current_pkg_total_num - 1) {
// 不是最后一包:回复01状态,通知上位机继续发送下一包数据
data_packet_len = RetPacket(udp_data, 1, func_code, 1, NULL, 0);
EviewTx(rxPktBuf, udp_data, data_packet_len, cmd_wait_status);
break;
} else if (current_pkg_num == current_pkg_total_num - 1) {
// 是最后一包:当前分片数据接收完成,开始校验整片分片数据完整性
write_checksum = 0;
// 按4字节累加计算当前分片的校验和(与上位机下发校验和比对)
for (i = 0; i < current_split_size; i += 4)
write_checksum += *((DWORD *)(DDR_buffer + i));
// 6、校验失败:数据传输异常、丢包/错包,返回F2校验错误
if (write_checksum != current_split_chksum) {
data_packet_len = RetPacket(udp_data, 0, func_code,
0xF2, NULL, 0);
EviewTx(rxPktBuf, udp_data, data_packet_len, cmd_wait_status);
break;
} else {
// 7、校验成功:调用分片烧写接口,烧写当前完整分片数据到ROOTFS分区
if (write_rootfs_split_img((unsigned long)DDR_buffer,
current_split_size, current_split_num) < 0) {
// 分片烧写失败:返回F1烧写错误
data_packet_len = RetPacket(udp_data, 0, func_code,
0xF1, NULL, 0);
EviewTx(rxPktBuf, udp_data, data_packet_len,
cmd_wait_status);
break;
} else {
// 8、分片烧写成功,区分是否为最后一片分片
if (current_split_num < total_split_num - 1) {
// 不是最后一片:返回F0单片烧写成功,等待下一片分片传输
data_packet_len = RetPacket(udp_data, 1, func_code,
0xF0, NULL, 0);
EviewTx(rxPktBuf, udp_data, data_packet_len,
cmd_wait_status);
// 更新屏幕升级进度条,根据分片数量比例刷新
DRAW_FillRect(82, 142,
82 + (current_split_num + 1) *
((PROCESSBAR_WIDTH - 4) / (total_split_num)),
138 + PROCESSBAR_HEIGHT, RED);
break;
} else if (current_split_num == total_split_num - 1) {
// 是最后一片分片:整片镜像传输+烧写全部完成
data_packet_len = RetPacket(udp_data, 1, func_code,
0xF3, NULL, 0);
EviewTx(rxPktBuf, udp_data, data_packet_len,
cmd_wait_status);
// 清空屏幕,打印升级成功提示
DRAW_ClrScreen(WHITE);
DRAW_TextOnScreen(90, 120,
"Downloading and Writing OK");
// 写入ROOTFS校验标记,标记系统分区合法可用
ee_write((unsigned char*)&ROOTFS_mark,
ROOTFS_CHECK, 2);
break;
} else {
/* 理论代码不会走到该分支,属于异常兜底 */
}
}
}
break;
}
}
} else {
/* normally, it would never come here */
}
break;
}
int do_evdown(cmd_tbl_t *cmdtp, int flag, int argc, char *const argv[])
{
ProcessCmd(rxbuff);
return 0;
}
U_BOOT_CMD(
evdown, 1, 0, do_evdown,
"for download",
"\n"
"evdown - Download kernel and rootfs through eview USB\\Ether\\Serial.\n"
);
六、改造总结
这次分片下载功能改造,从根本上解决了Uboot内存不足导致大体积rootfs镜像无法升级的问题,核心优势非常明显:
-
内存占用极低:无需加载完整镜像,仅需单分片大小内存即可完成升级;
-
兼容性强:完美适配rk3506(SPI NAND)平台,新旧逻辑可兼容切换;
-
稳定性高:增加校验、对齐检测、状态应答,避免烧写错乱、数据覆盖问题。
整套改造逻辑简单清晰,代码可直接移植到同架构SPI NAND平台!