灵动微MM32 芯片串口升级OTA功能开发5. 写入FLASH与跳转功能

灵动微MM32 芯片串口升级OTA功能开发5. 写入功能

一、数据下载

1. 上位机发送固件数据功能开发

作用是将固件文件分块发送,每块最多256字节。每次发送后等待应答,确认成功后再发送下一块。下面是主要程序:

py 复制代码
"""
OTA 下载固件功能模块

实现固件数据下载的发送和应答处理。
"""

import os
import time
from typing import Callable

try:
    from PyQt6.QtWidgets import QApplication
    QT_AVAILABLE = True
except ImportError:
    QT_AVAILABLE = False
    QApplication = None  # type: ignore

from .comm import OTAComm
from .protocol import CMD_DOWNLOAD, TYPE_DATA, COMM_TIMEOUT_DOWNLOAD

LogCallback = Callable[[str], None]
ProgressCallback = Callable[[int], None]


def send_download_firmware(
    comm: OTAComm,
    bin_path: str,
    log: LogCallback,
    set_progress: ProgressCallback,
    stop_requested: Callable[[], bool],
    start_addr: int = 0x08004000,
    chunk_size: int = 256,
    total_timeout: float = COMM_TIMEOUT_DOWNLOAD,
    retry_interval: float = 0.1,
) -> bool:
    """
    发送固件数据到Flash。

    将固件文件分块发送,每块最多256字节。每次发送后等待应答,确认成功后再发送下一块。

    Args:
        comm: OTA通信对象
        bin_path: 固件文件路径
        log: 日志回调函数
        set_progress: 进度回调函数(0-100)
        stop_requested: 停止请求检查函数
        start_addr: Flash起始地址,默认0x08004000(应用区起始地址)
        chunk_size: 每块数据大小(字节),默认256字节(协议限制)
        total_timeout: 每块数据的超时时间(秒),默认5秒
        retry_interval: 重试间隔(秒),默认0.1秒(100ms)

    Returns:
        True 如果所有数据发送成功,False 如果失败
    """
    if not comm.is_open():
        raise RuntimeError("串口未打开")

    # 检查文件是否存在
    if not os.path.exists(bin_path):
        log(f"❌ 固件文件不存在: {bin_path}")
        return False

    # 读取固件文件
    try:
        with open(bin_path, 'rb') as f:
            firmware_data = f.read()
    except Exception as e:
        log(f"❌ 读取固件文件失败: {e}")
        return False

    firmware_size = len(firmware_data)
    if firmware_size == 0:
        log("❌ 固件文件为空")
        return False

    log(f"开始发送固件数据(文件大小: {firmware_size} 字节 / {firmware_size/1024:.2f}KB)...")
    log(f"起始地址: 0x{start_addr:08X}, 每块大小: {chunk_size} 字节")

    # 计算总块数
    total_chunks = (firmware_size + chunk_size - 1) // chunk_size
    log(f"总共需要发送 {total_chunks} 块数据")

    # 逐块发送
    current_addr = start_addr
    sent_bytes = 0

    for chunk_idx in range(total_chunks):
        # 检查停止请求
        if stop_requested():
            log("收到停止请求,停止发送固件数据...")
            return False

        # 计算当前块的数据
        offset = chunk_idx * chunk_size
        chunk_data = firmware_data[offset:offset + chunk_size]
        chunk_len = len(chunk_data)
        
        # 确保数据大小是2的倍数(半字对齐),如果不是,补齐到偶数
        if (chunk_len % 2) != 0:
            # 如果最后一块数据是奇数,补齐一个字节(填充0)
            chunk_data = chunk_data + b'\x00'
            chunk_len = len(chunk_data)

        # 更新进度(50% + 40% * 已发送比例)
        progress = 50 + int(40 * sent_bytes / firmware_size)
        set_progress(progress)

        log(f"发送第 {chunk_idx + 1}/{total_chunks} 块 (地址: 0x{current_addr:08X}, 大小: {chunk_len} 字节)...")

        # 发送数据包并等待应答
        start_time = time.time()
        attempt = 0
        success = False

        while (time.time() - start_time) < total_timeout:
            # 检查停止请求
            if stop_requested():
                log("收到停止请求,停止发送固件数据...")
                return False

            attempt += 1

            # 每次重试重新构造数据包(序列号不递增,只有收到应答后才递增)
            packet = comm.build_frame(
                cmd=CMD_DOWNLOAD,
                cmd_type=TYPE_DATA,
                addr=current_addr,
                data=chunk_data,
                auto_increment=False,  # 不自动递增序号
            )

            # 发送数据包
            if not comm.is_open():
                raise RuntimeError("串口未打开")
            sent = comm.serial.send(packet)  # type: ignore

            # 等待应答
            response = None
            check_start = time.time()
            accumulated_data = b""  # 累积接收数据,处理分片接收的情况
            while (time.time() - check_start) < 0.5:  # 每次等待500ms检查应答(Flash写入需要较长时间)
                # 检查停止请求
                if stop_requested():
                    log("收到停止请求,停止发送固件数据...")
                    return False

                if QT_AVAILABLE:
                    QApplication.processEvents()

                # 检查串口是否仍然打开(可能在停止升级时被关闭)
                if not comm.is_open():
                    break

                rx_data = comm.serial.receive_available()  # type: ignore
                if len(rx_data) > 0:
                    # 累积接收数据
                    accumulated_data += rx_data
                    
                    # 调试:打印接收到的原始数据(前32字节)
                    if len(accumulated_data) > 0 and len(accumulated_data) < 200:  # 只在数据较少时打印,避免输出过多
                        debug_hex = accumulated_data[:32].hex(' ').upper()
                        if len(accumulated_data) > 32:
                            debug_hex += "..."
                        log(f"[调试] 累积数据 ({len(accumulated_data)} 字节): {debug_hex}")
                    
                    # 从累积数据中提取协议帧(即使前面有日志文本)
                    frame, remaining = OTAComm.extract_protocol_frame(accumulated_data)
                    if frame is not None:
                        # 检查应答帧的CMD是否正确(应该是下载命令 0x12)
                        if len(frame) >= 7:
                            resp_cmd = frame[6]  # 应答帧的CMD在索引6
                            if resp_cmd == CMD_DOWNLOAD:
                                response = frame
                                log(f"[调试] 找到下载应答帧 ({len(frame)} 字节): {frame[:20].hex(' ').upper()}...")
                                # 更新累积数据为剩余数据,避免重复处理
                                accumulated_data = remaining
                                break
                            else:
                                # 不是下载命令的应答,可能是其他命令的应答,忽略
                                log(f"[调试] 收到其他命令应答 (CMD:0x{resp_cmd:02X}),忽略")
                                # 从累积数据中移除这个帧,继续查找
                                accumulated_data = remaining
                                continue
                        else:
                            # 帧太短,可能是错误的帧,忽略
                            accumulated_data = remaining
                            continue
                    else:
                        # 没有找到协议帧,可能是文本日志,打印但不中断
                        # 只打印新接收的数据,避免重复打印
                        try:
                            text = rx_data.decode('ascii', errors='replace')
                            text_clean = ''.join(c if 32 <= ord(c) < 127 or c in '\r\n' else '' for c in text)
                            if text_clean.strip():
                                log(f"[主控] {text_clean.strip()}")
                        except:
                            pass
                        
                        # 限制累积数据大小,避免内存占用过大(保留最后1KB用于查找帧头)
                        if len(accumulated_data) > 1024:
                            # 保留最后1KB,可能包含帧头
                            accumulated_data = accumulated_data[-1024:]

                time.sleep(0.01)  # 10ms检查间隔

            # 如果收到应答,检查状态
            if response is not None:
                # 收到成功应答后,才递增序号
                comm._increment_seq()
                # TODO: 解析应答包,检查状态码
                # 目前只要收到应答就认为成功
                success = True
                log(f"✅ 第 {chunk_idx + 1}/{total_chunks} 块发送成功")
                break

            # 检查是否已超时
            if (time.time() - start_time) >= total_timeout:
                break

            # 处理UI事件,避免界面卡死
            if QT_AVAILABLE:
                QApplication.processEvents()

            # 等待间隔后继续发送
            time.sleep(retry_interval)

        # 检查是否成功
        if not success:
            elapsed_total = time.time() - start_time
            log(f"❌ 第 {chunk_idx + 1}/{total_chunks} 块发送失败:已发送 {attempt} 次,等待 {elapsed_total:.1f}秒,仍未收到主控回复")
            return False

        # 更新地址和已发送字节数
        current_addr += chunk_len
        sent_bytes += chunk_len

        # 处理UI事件,避免界面卡死
        if QT_AVAILABLE:
            QApplication.processEvents()
        
        # 在发送下一块之前,给主控端一些时间完成Flash写入操作
        # Flash写入256字节(128个半字)可能需要几百毫秒,这里等待200ms
        # 这样可以确保主控端有足够时间完成Flash写入,避免下一块数据被阻塞
        if chunk_idx < total_chunks - 1:  # 不是最后一块
            time.sleep(0.2)  # 等待200ms,给Flash写入足够的时间

    # 所有块发送完成
    log(f"✅ 固件数据发送完成(共 {total_chunks} 块,{sent_bytes} 字节)")
    set_progress(90)  # 发送完成,进度到90%
    return True

对上位机程序进行一次重构,结构如下:

复制代码
xundh-bootloader-desk/
├── main.py                 # 程序入口
├── ota/                    # OTA 功能模块
│   ├── __init__.py        # OTA 模块导出
│   ├── client.py          # OTA 客户端主类(协调各个功能)
│   ├── comm.py            # 底层通信层(协议帧构造、发送/接收)
│   ├── protocol.py        # 协议常量定义(命令码、状态码、CRC计算)
│   ├── handshake.py       # 握手功能实现
│   ├── erase.py           # 擦除Flash功能实现
│   └── download.py        # 下载固件功能实现
├── myserial/               # 串口管理模块
│   ├── __init__.py        # 串口模块导出
│   └── manager.py         # 串口通信封装(枚举、打开、发送、接收)
└── ui/                     # 用户界面模块
    ├── __init__.py        # UI 模块导出
    └── mainwindow.py      # 主窗口界面

2. bootloader 实现数据接收

(1)协议解析器

协议解析器状态机

协议解析器初始化后,解析器会按以下状态流转:

复制代码
IDLE → HEAD0 → HEAD1 → SEQ → SEQ_XOR → LEN_H → LEN_L → DATA → CRC_H → CRC_L → COMPLETE
  • IDLE: 等待帧头字节 0x5A
  • HEAD0/HEAD1: 接收帧头
  • SEQ/SEQ_XOR: 接收序号
  • LEN_H/LEN_L: 接收数据包长度
  • DATA: 接收数据包内容
  • CRC_H/CRC_L: 接收 CRC 校验
  • COMPLETE: 帧接收完成

OTA_Protocol_Init() 函数讲解

OTA_Protocol_Init() 用于初始化 OTA 协议解析器,将解析器状态重置为初始状态,准备接收新的协议帧。

(2)下载指令解析

c 复制代码
/**
 * @brief 处理下载固件命令
 * @param cmd_packet 命令包指针
 * @return true 处理成功,false 处理失败
 */
bool OTA_CmdHandler_HandleDownload(OTA_CommandPacket_t* cmd_packet)
{
    OTA_ResponsePacket_t response = {0};
    uint32_t write_addr;
    uint32_t write_size;
    BootFlash_Error_t flash_err;
    char debug_msg[64];
    
    if (cmd_packet == NULL) {
        return false;
    }
    
    /* 初始化应答包 */
    response.cmd = cmd_packet->cmd;
    response.addr = cmd_packet->addr;
    response.reserved = 0;
    response.data_len = 0;
    
    /* 检查命令类型 */
    if (cmd_packet->type != OTA_TYPE_DATA) {
        response.status = OTA_STATUS_PARAM_ERR;
        return OTA_Protocol_SendResponse(&response);
    }
    
    /* 解析写入地址 */
    write_addr = cmd_packet->addr;
    
    /* 解析数据大小 */
    write_size = cmd_packet->data_len;
    
    /* 检查数据大小:不能超过最大数据包大小,且必须是半字对齐 */
    if (write_size == 0 || write_size > OTA_MAX_PACKET_SIZE) {
        response.status = OTA_STATUS_PARAM_ERR;
        return OTA_Protocol_SendResponse(&response);
    }
    
    /* 检查数据大小是否为半字对齐(2字节的整数倍) */
    if ((write_size % 2) != 0) {
        response.status = OTA_STATUS_PARAM_ERR;
        return OTA_Protocol_SendResponse(&response);
    }
    
    /* 调用Flash写入函数 */
    flash_err = BootFlash_Write(write_addr, cmd_packet->data, write_size);
    
    /* 根据错误码设置应答状态 */
    switch (flash_err) {
        case BOOT_FLASH_OK:
            response.status = OTA_STATUS_SUCCESS;
            break;
            
        case BOOT_FLASH_ERR_PARAM:
        case BOOT_FLASH_ERR_SIZE:
            response.status = OTA_STATUS_PARAM_ERR;
            break;
            
        case BOOT_FLASH_ERR_ADDR:
            response.status = OTA_STATUS_ADDR_ERR;
            break;
            
        case BOOT_FLASH_ERR_ERASE:
            response.status = OTA_STATUS_FLASH_ERR;
            break;
            
        default:
            response.status = OTA_STATUS_ERROR;
            break;
    }
    
    /* 先发送应答,避免调试输出干扰应答识别 */
    bool send_result = OTA_Protocol_SendResponse(&response);
    
    return send_result;
}

二、下载完跳转应用程序

1. 上位机指令实现

py 复制代码
"""
OTA 跳转应用功能模块

实现跳转应用命令的发送和应答处理。
"""

import time
from typing import Callable

try:
    from PyQt6.QtWidgets import QApplication
    QT_AVAILABLE = True
except ImportError:
    QT_AVAILABLE = False
    QApplication = None  # type: ignore

from .comm import OTAComm
from .protocol import CMD_JUMP_APP, TYPE_CONTROL, COMM_TIMEOUT_DEFAULT, COMM_CHECK_INTERVAL, STATUS_SUCCESS

LogCallback = Callable[[str], None]


def send_jump_app(
    comm: OTAComm,
    log: LogCallback,
    stop_requested: Callable[[], bool],
    total_timeout: float = COMM_TIMEOUT_DEFAULT,
    retry_interval: float = 0.2,
) -> bool:
    """
    发送跳转应用命令,等待应答。

    Args:
        comm: OTA通信对象
        log: 日志回调函数
        stop_requested: 停止请求检查函数
        total_timeout: 总超时时间(秒),默认2秒
        retry_interval: 重试间隔(秒),默认0.2秒(200ms)

    Returns:
        True 如果跳转命令发送成功并收到成功应答,False 如果失败
    """
    if not comm.is_open():
        raise RuntimeError("串口未打开")

    log(f"开始发送跳转应用命令(间隔 {retry_interval*1000:.0f}ms,总超时 {total_timeout:.0f}秒)...")

    start_time = time.time()
    attempt = 0

    # 持续发送跳转命令,直到收到应答或达到总超时时间
    while (time.time() - start_time) < total_timeout:
        # 检查停止请求
        if stop_requested():
            log("收到停止请求,停止发送跳转命令...")
            return False

        attempt += 1
        elapsed = time.time() - start_time

        # 构造跳转应用命令包
        packet = comm.build_frame(
            cmd=CMD_JUMP_APP,
            cmd_type=TYPE_CONTROL,
            addr=0x00000000,
            data=b"",
            auto_increment=False,  # 不自动递增序号,收到应答后才递增
        )

        log(f"已发送跳转应用命令 (第 {attempt} 次,已用时 {elapsed:.1f}秒): {packet.hex(' ').upper()}")

        # 发送数据包
        if not comm.is_open():
            raise RuntimeError("串口未打开")
        sent = comm.serial.send(packet)  # type: ignore

        # 等待应答
        response = None
        check_start = time.time()
        accumulated_data = b""  # 累积接收数据,处理分片接收的情况
        while (time.time() - check_start) < 1.0:  # 每次等待1秒检查应答,给主控足够时间发送
            # 检查停止请求
            if stop_requested():
                log("收到停止请求,停止等待应答...")
                return False

            if QT_AVAILABLE:
                QApplication.processEvents()

            # 检查串口是否仍然打开
            if not comm.is_open():
                break

            data = comm.serial.receive_available()  # type: ignore
            if len(data) > 0:
                # 累积接收数据
                accumulated_data += data
                
                # 从累积数据中提取协议帧
                frame, remaining = OTAComm.extract_protocol_frame(accumulated_data)
                if frame is not None:
                    response = frame
                    accumulated_data = remaining
                    break
                else:
                    # 没有找到协议帧,可能是文本日志,打印但不中断
                    try:
                        text = data.decode('ascii', errors='replace')
                        text_clean = ''.join(c if 32 <= ord(c) < 127 or c in '\r\n' else '' for c in text)
                        if text_clean.strip():
                            log(f"[主控] {text_clean.strip()}")
                    except:
                        pass
                    
                    # 限制累积数据大小
                    if len(accumulated_data) > 1024:
                        accumulated_data = accumulated_data[-1024:]

            time.sleep(COMM_CHECK_INTERVAL)

        # 如果收到应答,检查状态
        if response is not None:
            # 收到应答后,才递增序号
            comm._increment_seq()
            log(f"收到跳转应用应答 ({len(response)} 字节): {response.hex(' ').upper()}")
            
            # 解析应答包,检查状态(应答包格式:帧头(2) + 序号(1) + 序号异或(1) + 长度(2) + 命令(1) + 状态(1) + 地址(4) + 保留(4) + CRC(2))
            if len(response) >= 18:
                status_byte = response[7]  # 状态字节位置
                if status_byte == STATUS_SUCCESS:
                    log("✅ 跳转应用命令成功,主控将跳转到应用程序")
                    return True
                else:
                    log(f"⚠️ 跳转应用命令失败,状态码: 0x{status_byte:02X}")
                    # 即使状态不是成功,也继续重试
            else:
                log(f"⚠️ 应答包长度异常: {len(response)} 字节")
            
            # 如果收到应答但状态不是成功,继续重试
            # 注意:如果主控已经跳转,可能不会再收到应答,所以这里继续重试可能无意义
            # 但为了完整性,还是继续重试直到超时

        # 检查是否已超时
        if (time.time() - start_time) >= total_timeout:
            break

        # 处理UI事件
        if QT_AVAILABLE:
            QApplication.processEvents()

        # 等待间隔后继续发送
        time.sleep(retry_interval)

    # 超时提示
    elapsed_total = time.time() - start_time
    log(f"❌ 跳转应用命令失败:已发送 {attempt} 次,等待 {elapsed_total:.1f}秒,仍未收到主控回复")
    log(f"   注意:如果主控已跳转到应用程序,可能不会再发送应答")
    return False

2. bootloader对于jump指令解析

c 复制代码
/**
 * @brief 处理跳转应用命令
 * @param cmd_packet 命令包指针
 * @return true 处理成功,false 处理失败
 */
bool OTA_CmdHandler_HandleJumpApp(OTA_CommandPacket_t* cmd_packet)
{
    OTA_ResponsePacket_t response = {0};
    
    if (cmd_packet == NULL) {
        return false;
    }
    
    /* 检查命令类型 */
    if (cmd_packet->type != OTA_TYPE_CONTROL) {
        response.status = OTA_STATUS_PARAM_ERR;
    } else {
        /* 检查应用程序是否有效 */
        if (BootJump_IsApplicationValid()) {
            response.status = OTA_STATUS_SUCCESS;
            /* 发送应答后跳转 */
            response.cmd = cmd_packet->cmd;
            response.addr = cmd_packet->addr;
            response.reserved = 0;
            response.data_len = 0;
            OTA_Protocol_SendResponse(&response);
            
            /* 跳转到应用程序 */
            BootJump_ToApplication();
        } else {
            response.status = OTA_STATUS_ERROR;
        }
    }
    
    /* 构建应答包 */
    response.cmd = cmd_packet->cmd;
    response.addr = cmd_packet->addr;
    response.reserved = 0;
    response.data_len = 0;
    
    /* 发送应答 */
    return OTA_Protocol_SendResponse(&response);
}

本示例的应用程序,会闪烁LED,以看出是否启动主程序成功。

运行示例:

本代码开源地址:

https://gitee.com/xundh/xundh-arm-m0-ota

本示例代码主要由AI生成。

下一章节将实现应用中通过magic地址值写入实现OTA功能。

相关推荐
VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue音乐管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·课程设计
一招定胜负3 小时前
navicat连接数据库&mysql常见语句及操作
数据库·mysql
热心市民蟹不肉3 小时前
黑盒漏洞扫描(三)
数据库·redis·安全·缓存
chian_ocean3 小时前
openEuler集群 Chrony 时间同步实战:从零构建高精度分布式时钟体系
数据库
Databend3 小时前
构建海量记忆:基于 Databend 的 2C Agent 平台 | 沉浸式翻译 @ Databend meetup 上海站回顾及思考
数据库
αSIM0V4 小时前
数据库期末重点
数据库·软件工程
2301_800256114 小时前
【第九章知识点总结1】9.1 Motivation and use cases 9.2 Conceptual model
java·前端·数据库
不会写程序的未来程序员4 小时前
Redis 的核心机制(线程模型、原子性、Bigkey、单线程设计原因等)
数据库·redis
木鹅.4 小时前
接入其他大模型
数据库·redis·缓存