【以太网摄像头采集存储终端】HTTP A/B 双槽 OTA

1. 引言

在上一篇中,已经完成 STM32H757XIH6 双核 A/B 启动管理:CM7 固定 Bootloader 选择 CM7 应用槽,CM7 应用通过 SRAM4 handoff 指定 CM4 应用槽,EEPROM 双备份保存 confirmed、pending 和 trial。预先通过 ST-Link 烧录双槽镜像后,可以用 KEY0 登记候选并复位,实现本地切槽。相关原理见前文:

【STM32H757XIH6 双核】A/B 双槽 Bootloader + EEPROM 启动管理信息备份

完成启动管理后,接下来要解决的是:如何通过网络把新固件送进设备,并在传输中断、写入失败或新程序无法正常启动时保留恢复能力。本文以 STM32H757 双核实现为例,介绍 HTTP 传输、A/B 镜像管理和试启动确认如何配合。

本文采用的核心设计为:

两核各有独立的 A/B 应用槽,CM7 统一管理升级和启动元数据,每次只升级一个核。

实现的运行链路是:

CM7 接收 HTTP 上传 → 写目标核空闲槽 → 校验 → EEPROM 标记候选 → 整机复位 → 试启动 → 确认或回退

2. A/B 双槽分区

区域 CM7: Bank1 CM4: Bank2 大小
固定引导区 Bootloader: 0x08000000 Stub: 0x08100000 各 128 KiB
应用 A 0x08020000 0x08120000 各 384 KiB
预留区 0x08080000 0x08180000 各 128 KiB
应用 B 0x080A0000 0x081A0000 各 384 KiB

假设设备只有一个应用程序区:

Flash
┌─────────────────┐
│ 正在运行的旧程序 │
└─────────────────┘

如果直接覆盖当前应用,又没有独立的网络恢复入口或外部镜像暂存机制,那么写入中途断电后,应用可能无法启动,应用中的网络升级服务也就无法继续提供。

这里采用给每个核准备两个应用区的方式:

Flash
┌─────────────┐
│ 固定引导程序 │
├─────────────┤
│ A:当前程序  │ ← 继续保留
├─────────────┤
│ B:新程序    │ ← 升级写这里
└─────────────┘

升级时,当前运行 A 就写 B,当前运行 B 就写 A。新镜像直接在目标槽运行,没有再把 B 搬回 A 的过程。

因此,同一核需要分别构建 A、B 镜像。A 镜像不能仅更换烧录地址后当 B 镜像使用,因为链接地址和复位入口不同。两核槽号都是各自的 A=0、B=1。

这是固定地址链接、直接在槽内运行的方案。其他 OTA 方案也可以将下载区镜像搬运到固定执行区,或采用具备重定位能力的镜像;不能将“必须发布两份 BIN”推广到所有 A/B OTA。

这个设计保留了当前运行镜像,让上传中断、校验失败等情况不影响旧应用继续启动。固定 Bootloader 和 Stub 不属于这条 OTA 擦写路径。

3. 各模块职责

理解实现时,可以先按职责分层,再对应到具体文件:

层次 核心职责 不应越界承担的职责
HTTP 传输层 解析请求、收齐请求体、返回状态 不能以收到最后一个网络包代替完整镜像验收
OTA 管理层 管理会话、目标槽、偏移、校验及升级状态 不能在镜像未验收时提前切换启动目标
存储层 按 Flash 粒度擦写、保存并读回启动记录 不能把 RAM 缓冲进度当作已持久化进度
启动管理层 选择镜像、扣减试启动次数、确认或回退 不能仅凭向量表有效就认定业务运行正常

下面是这套分层在示例代码中的文件对应关系,便于结合源码阅读。

APP:

image

Common:

image

文件 职责
qt ota 上位机 Qt 上位机:查询运行槽、检查目标槽镜像入口、计算 CRC32、分块上传、网络异常后重连续传,以及保存和恢复本地上传任务
CM7/APP/NET/HTTP/app_http_server.c HTTP 连接接收、请求解析、路由分发
CM7/APP/NET/HTTP/app_http_util.c TCP 字节流与 OTA 请求之间的 HTTP 协议处理层
CM7/APP/OTA/app_http_ota.c OTA 协议入口、偏移检查、会话超时、成功后复位、CM4 启动确认
CM7/APP/OTA/app_ota.c OTA 状态机、选空闲槽、暂停外部模块、擦写、校验、保存 pending
Common/Src/bsp_flash_ram.c RAM 中执行的 Flash 擦除和编程
CM7/APP/BOOT/boot_eeprom.c 双备份启动记录、镜像回读校验、试启动计数和确认
Common/Src/boot_slot.c 地址与槽号转换、实际运行槽识别、向量表检查
Common/Src/cm4_ota_hold.c CM4 运行槽上报,以及擦写 Bank2 前的停核握手
Common/Src/cm4_boot_handoff.c CM7 向 CM4 Stub 传递本次应用启动地址

本工程使用 qt-ota 作为正式上传工具。设备端与上位机分别承担不同的恢复职责:设备端保留 RAM 接收会话并返回准确偏移,上位机负责网络异常后的重连、会话匹配和后续分块发送。本地保存上传任务不代表设备持久化了接收进度;设备掉电、复位或会话超时后,仍需重新开始上传。

4. HTTP 部分流程

接口 方法 必需请求信息 作用
/ota/status GET X-Ota-Core 查询目标核运行槽和升级状态
/ota/begin POST X-Ota-Core、X-Fw-Size、X-Fw-Version、X-Fw-Crc32、X-Fw-Base 新建会话,或匹配原会话并返回续传偏移
/ota/chunk POST X-Offset、BIN 数据块 按设备当前偏移提交数据
/ota/end POST 空请求体 验收完整镜像、保存 pending 并复位
/ota/abort POST 空请求体 取消尚未提交 pending 的会话

所有 POST 请求都需要 Content-Length,空请求体使用 Content-Length: 0。chunk 请求体是原始 BIN 字节,最大为 8192 字节,不使用 multipart 表单。本实现拒绝 Transfer-Encoding 和 Expect 请求头,因此上传端不能使用 HTTP chunked 编码或 Expect: 100-continue。

首次 begin 成功返回 OTA ready written=0;同一镜像的重复 begin 返回 OTA resume written=N。后续 chunk 的 X-Offset 必须等于 N。目标核、长度、版本、CRC32 或链接基址不匹配时,返回 409,并保留原会话。

4.1 HTTP 初始化

在 freertos.c 中,初始化 LWIP 并启动 HTTP 线程:

void StartDefaultTask(void *argument)
{
  MX_LWIP_Init();
  HAL_IWDG_Refresh(&hiwdg1);
  App_HTTP_Server_Thread_Start();
  for (;;)
  {
    HAL_IWDG_Refresh(&hiwdg1); // 周期喂狗,调度器正常运行即视为健康
    HAL_GPIO_TogglePin(LED0_GREEN_GPIO_Port, LED0_GREEN_Pin);
    osDelay(1000);
  }
}

4.2 HTTP 线程

在 app_http_server.c 中,执行 HTTP 线程:

/**
 * @brief   HTTP 服务器任务
 * @param   none
 * @return  none
 */
static void Http_Thread(void *argument)
{
    struct netconn *listen_connection; // 监听连接
    struct netconn *client_connection; // 客户端连接
    err_t error;                       // 错误码

    (void)argument; // 忽略参数

    osDelay(1000);

    /* 创建监听连接 */
    listen_connection = netconn_new(NETCONN_TCP);
    if (listen_connection == NULL)
    {
        LOG_ERROR("CM7", "HTTP: netconn_new failed");
        return;
    }

    /* 绑定端口 */
    error = netconn_bind(listen_connection, IP_ADDR_ANY, APP_HTTP_PORT);
    if (error != ERR_OK)
    {
        LOG_ERROR("CM7", "HTTP: bind port %u failed, error=%d",
                  (unsigned int)APP_HTTP_PORT, (int)error);
        netconn_delete(listen_connection); // 删除监听连接
        return;
    }

    /* 监听连接 */
    error = netconn_listen(listen_connection);
    if (error != ERR_OK)
    {
        LOG_ERROR("CM7", "HTTP: listen port %u failed, error=%d",
                  (unsigned int)APP_HTTP_PORT, (int)error);
        netconn_delete(listen_connection); // 删除监听连接
        return;
    } // 监听成功

    LOG_INFO("CM7", "HTTP: server listening on port %u",
             (unsigned int)APP_HTTP_PORT);
    netconn_set_recvtimeout(listen_connection, APP_HTTP_RECV_TIMEOUT_MS); // 设置接收超时时间:5000ms

    for (;;)
    {
        client_connection = NULL;
        app_http_ota_poll(); // 处理 CM4 启动确认和 OTA 接收会话超时

        /* HTTP 服务处理一个客户端的完整流程 */
        error = netconn_accept(listen_connection, &client_connection); // 等待连接
        if (error != ERR_OK)
        {
            osDelay(100U);
            continue;
        }
        http_handle_client(client_connection); // 处理客户端请求
        netconn_close(client_connection);      // 关闭客户端连接
        netconn_delete(client_connection);     // 释放连接资源
    }
}

4.2.1 HTTP 线程启动阶段

主要执行了如下操作:

  1. 分配一个 TCP 类型的 netconn 对象;失败则打印日志并退出线程。
  2. 绑定本地所有 IP 地址上的 80 端口。失败则删除连接并退出。
  3. 将连接设为监听状态,允许客户端连接。
  4. 给后面的 netconn_accept() 设置等待超时。

这里的 listen_connection 会一直保留,用于接收后续连接。创建、绑定或监听失败时,任务函数直接返回,没有自动重试逻辑。

4.2.2 HTTP 线程主循环阶段

核心流程就是:

检查 OTA 状态
    ↓
等待客户端连接
    ↓
接收并解析 HTTP 请求
    ↓
根据请求路径执行操作,发送 HTTP 响应
    ↓
关闭、释放客户端连接
    ↓
继续等待下一次连接

主要执行了以下操作:

  1. app_http_ota_poll() 这是本地维护操作,不会主动向上位机发 HTTP 请求。它主要做两件事:
    • CM4 上报实际运行槽后,满足条件时将 CM4 的 pending 槽确认。
    • OTA 会话超过空闲 60 秒或总时长 10 分钟时,取消会话并释放相关资源。
  2. 等待客户端建立 TCP 连接。例如上位机访问设备的 /status 或上传固件时,就会建立连接。
  3. http_handle_client(client_connection) 实际 HTTP 业务都在这里执行,顺序为:
    • 接收请求数据,解析请求方法、路径、请求头和完整请求体。
    • 再检查一次 OTA 超时。
    • 优先分发 OTA 请求。
    • 如果不是 OTA 请求,再处理普通 /status 查询或返回错误。
  4. netconn_close(client_connection) 主动关闭这个客户端 TCP 连接。
  5. netconn_delete(client_connection) 释放该连接的 netconn 资源(否则会内存泄漏,监听连接仍然存在,下一轮继续接收客户端)。
  • 它是串行服务器。 正在处理一个请求时,不会同时处理另一个客户端;Flash 擦除、固件校验也在该任务的调用链中同步执行。

  • TCP 连接结束不代表 OTA 会话结束。 一次 begin、每次 chunk、最后的 end 可以分别使用不同连接;OTA 进度保存在独立的会话变量中,所以关闭客户端连接后仍然可以继续上传。

4.3 http_handle_client() 流程

http_handle_client() 负责处理一个客户端的一次 HTTP 请求:
读取解析请求 → 优先处理 OTA → 处理普通状态查询 → 返回响应。

它由前面的 Http_Thread() 调用,执行完毕后再由外层线程关闭、释放连接。

/**
 * @brief   处理客户端请求
 * @param   conn  客户端连接
 * @return  none
 */
static void http_handle_client(struct netconn *conn)
{
    /* 读取 HTTP 请求 */
    int result = http_read_request(conn);
    if (result != 0) // 如果读取失败
    {
        app_http_ota_receive_failed(); // 请求未收完整时保留会话,重连后按已接收偏移续传
        http_reply_text(conn,
                        -result,
                        "Request Rejected",
                        "text/plain; charset=utf-8",
                        "bad request\r\n");
        /* 回复错误码,由 result 决定,可能是 400,也可能是 408 超时、413 请求体过大等 */
        return;
    }

    app_http_ota_poll(); // 处理 CM4 启动确认和 OTA 接收会话超时,等待、接收期间可能跨过超时点,执行前必须再检查

    /* 分发 OTA 状态查询、开始、分块写入、结束和取消请求 */
    if (app_http_ota_dispatch(conn, &s_request) != 0) // OTA 路由已处理该请求
        return;                                       // 直接退出

    /* 处理 GET 请求 */
    if (strcmp(s_request.method, "GET") != 0) // 如果不是 GET 请求
    {
        http_reply_text(conn,
                        405,
                        "Method Not Allowed",
                        "text/plain; charset=utf-8",
                        "only GET is supported\r\n"); // 非 OTA 请求的方法不是 GET,回复 405
        return;
    }

    /* 处理 /status 请求 */
    if (strcmp(s_request.path, "/status") == 0 && s_request.content_length == 0U) // 如果是 /status 请求
    {
        /* 处理 /status 请求 */
        http_handle_status(conn);
        return;
    }
    /* 回复 404 错误码 */
    http_reply_text(conn,
                    404,
                    "Not Found",
                    "text/plain; charset=utf-8",
                    "not found\r\n");
}

4.3.1 读取 HTTP 请求

int result = http_read_request(conn);

从这个客户端连接接收数据,将解析结果保存在 request 中,主要包括:

  • method:请求方法,例如 GET、POST。
  • path:请求路径,例如 /status、/ota/chunk。
  • 请求头:例如固件大小、版本、CRC、分块偏移。
  • content_length 和请求体:例如本次上传的固件数据块。

http_read_request() 返回 0 表示成功,负值表示失败。

if (result != 0)
{
    app_http_ota_receive_failed();
    http_reply_text(conn, -result, ...);
    return;
}

读取失败时:

  • 通知 OTA 管理层发生接收失败,保留已有升级会话。
  • 回复 HTTP 错误。
  • 结束本次请求处理,不再执行路由。

4.3.2 再检查 OTA 是否超时

app_http_ota_poll();

因为读取请求可能花费一段时间,所以在真正执行请求之前再次检查:

  • OTA 会话是否已超时,需要取消。
  • CM4 是否满足启动确认条件。

例如:上传连接建立时会话还有效,但接收数据期间超过了 OTA 超时限制,此处会先清理会话,后续分块请求就不会继续写入已失效的升级会话。

4.3.3 优先交给 OTA 路由处理

if (app_http_ota_dispatch(conn, &s_request) != 0)
    return;

它检查请求路径是否属于:

/ota/status
/ota/begin
/ota/chunk
/ota/end
/ota/abort

如果属于 OTA 接口,就在内部执行对应操作并回复,包括参数错误等错误响应。

返回值 含义 后续动作
非 0 已处理这个 OTA 请求 直接返回
0 不是 OTA 路径 继续检查普通接口

4.3.4 对剩下的普通请求,只允许 GET

if (strcmp(s_request.method, "GET") != 0)
{
    http_reply_text(conn, 405, ...);
    return;
}

走到这里,说明这个请求没有被 OTA 路由接管。
如果它不是 GET,就返回:

405 Method Not Allowed

例如 POST /status 会走这里。
这不会阻止 OTA 的 POST 请求,因为 OTA 请求在上一步已经处理并返回了。

4.3.5 处理普通状态查询

if (strcmp(s_request.path, "/status") == 0 &&
    s_request.content_length == 0U)
{
    http_handle_status(conn);
    return;
}

只有同时满足以下条件才返回设备状态:

  • 方法为 GET,已由上一步保证。
  • 路径为 /status。
  • 没有请求体。

返回的 JSON 包括设备名称、网络状态、IP 和 CM7 当前运行槽。

4.3.6 剩余请求返回 404

http_reply_text(conn, 404, ...);

例如 GET /abc,没有对应路由,就返回 404 Not Found。按当前代码,GET /status 如果携带非空请求体,也会落到这里。

用几个请求串起来看最直观:

客户端请求 本函数的处理路径
GET /status 读取 → OTA 不接管 → GET 检查通过 → 返回设备状态
POST /ota/chunk 读取完整固件块 → OTA 检查 → OTA 路由检查偏移并写入 → 返回
POST /status 读取 → OTA 不接管 → 返回 405
GET /abc 读取 → OTA 不接管 → GET 检查通过 → 返回 404
请求没收完整就断线 读取失败 → 保留 OTA 会话 → 尝试回复错误 → 返回

这里的 return 都是结束本次客户端请求处理,不会退出 HTTP 服务线程;外层 Http_Thread() 会继续等待下一次连接。

5. HTTP 层接收固件包 .bin 流程

真正从网络接收固件包 .bin 数据的位置,是在 http_read_request() 中的这一行:

error = netconn_recv(conn, &net_buffer);

不过,接收网络数据、提取 BIN 内容和写入 Flash 分为三个环节。

5.1 从 TCP 连接接收数据

Http_Thread() -> http_handle_client() -> http_read_request():

/**
 * @brief   读取客户端请求
 * @param   conn  客户端连接
 * @return  none
 */
static int http_read_request(struct netconn *conn)
{
    struct netbuf *net_buffer = NULL;
    err_t error;
    uint32_t started = HAL_GetTick();
    int result = 0;
    app_http_request_init(&s_request); // 初始化 HTTP 请求结构体

    /* 设置接收超时 */
    netconn_set_recvtimeout(conn, 1000);                     // 设置接收超时时间为 1000ms
    netconn_set_sendtimeout(conn, APP_HTTP_RECV_TIMEOUT_MS); // 设置发送超时为 5000ms

    while (result == 0)
    {
        void *data;
        u16_t data_length;

        /* 接收数据 */
        error = netconn_recv(conn, &net_buffer);
        if (error != ERR_OK)
        {
            app_http_ota_poll();
            if (error == ERR_TIMEOUT && HAL_GetTick() - started < 30000U)
                continue;
            return error == ERR_TIMEOUT ? -408 : -400;
        }

        do
        {
            data = NULL;
            data_length = 0U;

            /* 处理数据 */
            if (netbuf_data(net_buffer, &data, &data_length) == ERR_OK)
            {
                result = app_http_request_feed(&s_request, data, data_length);
            }

            if (result < 0)
                break;
        } while (netbuf_next(net_buffer) >= 0); // 处理下一个数据块

        netbuf_delete(net_buffer); // 释放接收缓冲区
        net_buffer = NULL;         // 重置指针
        if (HAL_GetTick() - started >= 30000U && result == 0)
            return -408;
    }

    return result == 1 ? 0 : result;
}

上位机通过 POST /ota/chunk 把 BIN 的一部分放进 HTTP 请求体发送过来。
这里收到的是 TCP 数据,可能包含 HTTP 请求头、BIN 数据,也可能只是其中一部分。

接着获取缓冲区里的数据指针和长度:

netbuf_data(net_buffer, &data, &data_length);

5.2 解析 HTTP,把固件包数据保存到请求体缓冲区

获取到缓冲区里的数据指针和长度后,再交给 HTTP 解析器:

result = app_http_request_feed(&s_request, data, data_length);

解析器区分 HTTP 请求头和请求体。进入请求体后,这行代码将收到的字节放进 request->body。

request->body[request->body_length++] = data[i];

Http_Thread() -> http_handle_client() -> http_read_request() -> app_http_request_feed():

/**
 * @brief   向 HTTP 请求结构体中添加数据
 * @param   request  要添加数据的 HTTP 请求结构体指针
 * @param   data  要添加的数据指针
 * @param   length  要添加的数据长度
 * @retval  0 请求未完整,需要更多数据
 *          1 请求完整
 *          负值表示错误,其相反数为对应 HTTP 状态码
 */
int app_http_request_feed(app_http_request_t *request, const uint8_t *data, size_t length)
{
    for (size_t i = 0U; i < length; i++)
    {
        if (request->header_complete == 0U)
        {
            if (request->header_length >= APP_HTTP_HEADER_MAX) return -431;
            if (data[i] == 0U || (data[i] < 32U && data[i] != '\r' && data[i] != '\n' && data[i] != '\t')) return -400;
            request->header[request->header_length++] = (char)data[i];
            request->header[request->header_length] = '\0';
            if (request->header_length >= 4U &&
                memcmp(request->header + request->header_length - 4U, "\r\n\r\n", 4U) == 0)
            {
                int result = http_parse_header(request);
                if (result != 0) return result;
                request->header_complete = 1U;
            }
        }
        else
        {
            if (request->body_length >= request->content_length) return -400;
            request->body[request->body_length++] = data[i];
        }
    }
    return (request->header_complete && request->body_length == request->content_length) ? 1 : 0;
}

对于 /ota/chunk 请求,request->body 中保存的就是本次上传的 BIN 数据块。

5.3 完整数据块接收成功后,OTA 写入 FLASH

这里先检查 X-Offset 是否等于当前接收进度,检查通过后,将 BIN 数据交给 app_ota_write()。
OTA 层先放入编程缓冲区,缓冲区满或执行 end 时,再调用底层 Flash 编程接口写入空闲槽。

result = app_ota_write(request->body, request->body_length);

Http_Thread() -> http_handle_client() -> app_http_ota_dispatch():

/**
 * @brief 分发 OTA 状态查询、开始、分块写入、结束和取消请求
 * @param conn  客户端连接
 * @param request  已解析完成的 HTTP 请求及完整请求体
 * @retval 0 非 OTA 路径;1 已处理,包括返回 HTTP 错误的情况
 * @note  end 成功后返回响应并复位;长度上限由 HTTP 请求解析层检查。
 */
int app_http_ota_dispatch(struct netconn *conn, const app_http_request_t *request)
{
    app_ota_core_t core = APP_OTA_CORE_CM7;                           // 默认 CM7 核心
    char text[1024];                                                  // 响应体缓冲区
    char core_name[16];                                               // 核心名称缓冲区
    int result;                                                       // 操作结果
    int is_status = strcmp(request->path, "/ota/status") == 0;        // 是否状态查询
    int is_begin = strcmp(request->path, "/ota/begin") == 0;          // 是否开始
    int is_chunk = strcmp(request->path, "/ota/chunk") == 0;          // 是否分块写入
    int is_end = strcmp(request->path, "/ota/end") == 0;              // 是否结束
    int is_abort = strcmp(request->path, "/ota/abort") == 0;          // 是否取消
    if (!is_status && !is_begin && !is_chunk && !is_end && !is_abort) // 如果不是 OTA 路径
        return 0;
    /* 仅状态查询使用 GET,其余管理接口使用 POST。 */
    if (strcmp(request->method, is_status ? "GET" : "POST") != 0)
    {
        ota_reply(conn, 405, "Method Not Allowed", "invalid method\n");
        return 1;
    }
    /* 只有 chunk 接口接收二进制请求体。 */
    if (!is_chunk && request->content_length != 0U)
    {
        ota_reply(conn, 400, "Bad Request", "body must be empty\n");
        return 1;
    }
    /* 查询和 begin 显式指定核心;后续操作沿用当前唯一会话。 */
    if (is_status || is_begin)
    {
        result = app_http_header_get(request, "X-Ota-Core", core_name, sizeof(core_name));   // 获取 HTTP 请求头字段值(核心)
        if (result != 0 || (strcmp(core_name, "cm7") != 0 && strcmp(core_name, "cm4") != 0)) // 如果获取失败或核心名称不是 cm7 或 cm4
        {
            ota_reply(conn, 400, "Bad Request", "X-Ota-Core must be cm7 or cm4\n");
            return 1;
        }
        core = strcmp(core_name, "cm4") == 0 ? APP_OTA_CORE_CM4 : APP_OTA_CORE_CM7; // 根据核心名称设置核心
    }
    if (is_status) // 如果是状态查询接口
    {
        result = app_ota_format_status(text, sizeof(text), core); // 格式化状态查询响应
        if (result < 0)
            ota_reply(conn, 503, "Service Unavailable", "running slot unavailable\n");
        else
            ota_reply(conn, 200, "OK", text);
        return 1;
    }
    if (is_begin) // 如果是开始接口
    {
        uint32_t size, version, crc, base;
        /* 获取 HTTP 请求头字段值 */
        if (app_http_header_u32(request, "X-Fw-Size", &size) != 0 ||
            app_http_header_u32(request, "X-Fw-Version", &version) != 0 ||
            app_http_header_u32(request, "X-Fw-Crc32", &crc) != 0 ||
            app_http_header_u32(request, "X-Fw-Base", &base) != 0)
        {
            ota_reply(conn, 400, "Bad Request", "size/version/crc32/base required as u32\n");
            return 1;
        }
        /* 检查是否正在忙 */
        if (app_ota_is_busy())
        {
            if (s_receiving != 0U && app_ota_session_matches(size, version, core, crc, base))
            {
                /* 重复 begin 不擦除、不重置 CRC,也不延长会话时限。 */
                snprintf(text, sizeof(text), "OTA resume written=%lu\n",
                         (unsigned long)app_ota_written_bytes());
                if (app_http_reply(conn, 200, "OK", "text/plain; charset=utf-8", text) != 0)
                    app_http_ota_receive_failed();
                return 1;
            }
            ota_reply(conn, 409, "Conflict", "OTA busy\n");
            return 1;
        }
        /* 底层完成目标槽保护、外部设备暂停、必要的 CM4 hold 和擦除。 */
        result = app_ota_begin(size, version, core, crc, base);
        /* 检查开始结果 */
        if (result != 0)
        {
            snprintf(text, sizeof(text), "begin failed rc=%d\n", result);
            int code = result == -2 ? 413 : (result == -8 || result == -6 ? 409 : 503);
            ota_reply(conn, code, code == 413 ? "Payload Too Large" : (code == 409 ? "Conflict" : "Service Unavailable"), text);
            return 1;
        }
        /* 擦除成功后才计时;ready 响应丢失时可重复 begin 查询偏移。 */
        s_started = s_activity = HAL_GetTick();
        s_receiving = 1U;
        /* 回复 ready 响应 */
        if (app_http_reply(conn, 200, "OK", "text/plain; charset=utf-8", "OTA ready written=0\n") != 0)
            app_http_ota_receive_failed();
        return 1;
    }
    /* 处理取消请求 */
    if (is_abort)
    {
        /* pending 已保存的阶段不可撤销,不能回复“已取消”。 */
        if (app_ota_is_busy() && s_receiving == 0U)
        {
            ota_reply(conn, 409, "Conflict", "pending reboot; cannot abort\n");
            return 1;
        }
        app_ota_abort();
        s_receiving = 0U;
        ota_reply(conn, 200, "OK", "OTA aborted\n");
        return 1;
    }
    if (s_receiving == 0U) // 如果没有接收会话
    {
        ota_reply(conn, 409, "Conflict", "no receiving session\n");
        return 1;
    }
    if (is_chunk) // 如果是 chunk 接口
    {
        uint32_t offset;
        if (app_http_header_u32(request, "X-Offset", &offset) != 0 || request->body_length == 0U)
        {
            ota_reply(conn, 400, "Bad Request", "offset and nonempty body required\n");
            return 1;
        }
        /* 拒绝跳块或重复块,但保留会话供客户端查询正确偏移。 */
        if (offset != app_ota_written_bytes())
        {
            snprintf(text, sizeof(text), "offset mismatch expect=%lu\n", (unsigned long)app_ota_written_bytes());
            ota_reply(conn, 409, "Conflict", text);
            return 1;
        }
        result = app_ota_write(request->body, request->body_length);
        if (result != 0)
        {
            app_ota_abort();
            s_receiving = 0U;
            snprintf(text, sizeof(text), "write failed rc=%d\n", result);
            ota_reply(conn, 500, "Internal Server Error", text);
            return 1;
        }
        /* 仅成功接收并交给底层的数据块刷新空闲计时。 */
        s_activity = HAL_GetTick();
        snprintf(text, sizeof(text), "written=%lu\n", (unsigned long)app_ota_written_bytes());
        if (app_http_reply(conn, 200, "OK", "text/plain; charset=utf-8", text) != 0)
            app_http_ota_receive_failed();
        return 1;
    }
    /* 剩余路由为 end:底层校验完整镜像并持久化 pending。 */
    result = app_ota_finish();
    s_receiving = 0U;
    if (result != 0)
    {
        app_ota_abort();
        snprintf(text, sizeof(text), "finish failed rc=%d\n", result);
        ota_reply(conn, 500, "Internal Server Error", text);
        return 1;
    }
    /* pending 已落盘,响应丢失也继续复位,由客户端查询最终启动结果。 */
    ota_reply(conn, 200, "OK", "OTA OK, rebooting\n");
    (void)netconn_close(conn);
    osDelay(400U);
    HAL_NVIC_SystemReset();
    return 1;
}

5.4 函数调用关系

http_handle_client()
    │
    ├─ http_read_request()
    │      ├─ netconn_recv()             ← 从网络接收数据
    │      └─ app_http_request_feed()
    │             └─ request->body      ← 保存本次 BIN 数据块
    │
    └─ app_http_ota_dispatch()
           └─ /ota/chunk
                  └─ app_ota_write()     ← 缓冲 BIN 数据
                         └─ flush_prog_buf()
                                └─ bsp_flash_ram_program_buffer()     ← 写入 Flash

设备不是一次接收整个 BIN 文件,而是由上位机分块上传。每次完整接收一个 /ota/chunk 请求后,才把这一块交给 OTA 层处理。

本实现支持会话内断线续传。重复 begin 时,目标核、长度、版本、CRC32、链接基址必须全部匹配,设备返回当前 written。该值包含 Flash 已写入数据和 RAM 编程缓冲数据。掉电或复位后会话丢失,需要重新上传。只有成功写入数据块刷新空闲计时;状态查询和重复 begin 不刷新计时。pending 已保存后不可再续传或取消,设备将继续复位。

这里的 chunk 指应用层分块上传;HTTP 请求仍使用 Content-Length,当前解析器不支持 Transfer-Encoding: chunked。

6. OTA 部分流程

OTA 部分负责管理升级会话、保护运行中的固件、写入空闲槽,以及安排新固件试启动和确认。
除去 HTTP 收发,当前项目的 OTA 流程就是:
选择空闲槽 → 暂停相关业务 → 擦除 → 分块写入 → 校验完整镜像 → 保存待启动记录 → 复位试运行 → 确认新槽或失败回退

核心入口都在 app_ota.c,分别是:

app_ota_begin();   // 准备升级、擦除目标槽
app_ota_write();   // 缓冲并写入固件数据
app_ota_finish();  // 校验、保存 pending
app_ota_abort();   // 取消升级、恢复资源
flowchart TD A["PC 查询目标核实际运行槽"] --> B["选择另一槽对应的 BIN"] B --> C["begin:检查条件、暂停外部模块、擦空闲槽"] C --> D["chunk:按连续 offset 分块写入"] D --> E["end:长度、CRC、向量表、Flash 回读校验"] E --> F["EEPROM 保存 pending,trial=3"] F --> G["整机复位"] G --> H["选择候选槽并持久化扣减 trial"] H --> I{"新应用是否完成确认"} I -->|是| J["候选转为 confirmed"] I -->|未确认并发生复位| K{"是否还有试启动次数"} K -->|有| H K -->|无| L["回退旧 confirmed"]

以“CM7 当前运行 A 槽,升级到 B 槽”为例,同一套流程也支持升级 CM4,需要分清两个角色:

  • 执行升级操作的核:始终是 CM7,负责网络接收、校验、Flash 擦写和 EEPROM 记录。
  • 被升级的目标核:可以是 CM7,也可以是 CM4,由请求头 X-Ota-Core 指定。
对比项 升级 CM7 升级 CM4
请求头 X-Ota-Core: cm7 X-Ota-Core: cm4
上传文件 CM7 目标槽 BIN CM4 目标槽 BIN
写入区域 Bank1 的空闲槽 Bank2 的空闲槽
擦写前处理 暂停外部设备 暂停外部设备,并让 CM4 进入 RAM 等待区
保存升级记录 boot_eeprom_set_pending() boot_eeprom_set_cm4_pending()
复位后选择新槽 CM7 Bootloader CM7 选择 CM4 槽,交给 CM4 Stub 跳转
启动确认 CM7 提交自身 pending CM4 上报运行槽,由 CM7 提交 CM4 pending

源码中就是通过这个分支区分:

if (core == APP_OTA_CORE_CM4)
{
    // CM7 暂停 CM4,然后擦写 CM4 的 Bank2 空闲槽
}
else
{
    // CM7 擦写自身 Bank1 的空闲槽
}

所以,一次 OTA 会话只升级一个核。如果两核固件都需要更新,就分别进行一次 CM7 升级和一次 CM4 升级;当前流程不是同时提交两核的联合升级。完整过程如下。

6.1 开始升级:app_ota_begin()

首先判断这次升级能不能进行:

  • 没有其他 OTA 会话正在进行。
  • 指定的目标核是 CM7 或 CM4。
  • 被升级目标核的实际运行槽有效,且与 EEPROM 中该核的 confirmed 槽一致;两核均不存在尚未处理完成的 pending。
  • BIN 声明的链接地址等于空闲槽地址。
  • 固件大小在 8 字节到 384 KiB 之间。

当前运行 A,就选择 B 作为写入目标:

当前运行:CM7 A,0x08020000
升级目标:CM7 B,0x080A0000

然后记录本次会话的信息:

s_image_size   = image_size;     // 固件总长度
s_version      = version;        // 固件版本
s_expected_crc = expected_crc32; // 预期 CRC
s_offset       = 0U;             // 已写入 Flash 的长度
s_prog_fill    = 0U;             // 编程缓冲区中的长度
s_crc          = 0xFFFFFFFFUL;   // CRC 初始值

接着暂停外部设备,避免外部设备的业务影响升级。

如果升级的是 CM4,还要额外执行:

cm4_ota_hold_request(...);

让 CM4 进入 RAM 等待区,不再从 Bank2 执行指令,然后才能擦写 CM4 所在的 Bank2。

最后擦除目标槽:

erase_inactive_slot(s_target_slot);     // CM7
erase_cm4_inactive_slot(s_target_slot); // CM4

擦除的是空闲槽,当前运行槽保留。 擦除成功后进入接收状态:

s_state = APP_OTA_RECV;

6.2 接收固件块:app_ota_write()

HTTP 层每收齐一个固件块,就把数据交给它:

app_ota_write(data, len);

这里先检查状态和长度,保证累计数据不超过约定的固件大小,再复制到 8 KiB 编程缓冲区:

memcpy(&s_prog_buf[s_prog_fill], p, take);
s_prog_fill += take;

缓冲区满时:

s_crc = crc32_update(s_crc, s_prog_buf, s_prog_fill);
flush_prog_buf();

flush_prog_buf() 将数据写到:

写入地址 = s_target_base + s_offset;
例如 CM7 B 槽:
第 1 个 8 KiB → 0x080A0000
第 2 个 8 KiB → 0x080A2000
第 3 个 8 KiB → 0x080A4000
……

底层通过:

bsp_flash_ram_program_buffer(addr, s_prog_buf, s_prog_fill);

执行实际 Flash 编程。成功后使对应缓存失效,更新偏移并清空缓冲区。

8 KiB 是 OTA 层的编程缓冲大小,底层实际按 32 字节 FlashWord 编程。最后一组数据不足 32 字节时,使用 0xFF 补齐后编程;镜像登记长度和 CRC32 仍按原始 BIN 的有效字节数计算,不包含补齐字节。

擦写函数位于 .RamFunc,需要链接脚本和启动代码配合,保证运行前已复制到可执行 RAM。升级 CM4 时,CM4 在发布 hold ACK 前关闭中断,随后只执行 RAM 等待代码,避免擦写 Bank2 期间继续从该 Bank 取指。

这里两个变量的区别是:

变量 含义
s_offset 已成功写入 Flash 的字节数
s_prog_fill 还在 RAM 编程缓冲区中的字节数
written 两者之和,表示会话已经接收的总字节数

因此,最后不足 8 KiB 的部分可能暂时还在 RAM 中,等待结束阶段写入。

6.3 上传结束:app_ota_finish()

收到结束请求后,依次检查:

收到的总长度是否等于约定长度
    ↓
将最后不足 8 KiB 的数据写入 Flash
    ↓
计算最终 CRC,与预期 CRC 比较
    ↓
检查目标槽向量表是否合法
    ↓
再次校验实际 Flash 镜像,并保存 EEPROM pending

向量表检查主要用于判断镜像是否具有合理的初始栈指针和复位入口;实际 Flash 校验还会核对镜像长度、入口范围及完整 CRC。

全部通过后,调用:

boot_eeprom_set_pending(...);     // CM7
boot_eeprom_set_cm4_pending(...); // CM4

此时 EEPROM 中的状态可以理解为:

confirmed = A    旧固件仍然是已确认版本
pending   = B    新固件准备试运行
trial     = 3    允许三次试启动
flags     = pending 有效

这时只是“新固件已经写好,准备试运行”,还没有将 B 槽确认为正式版本。

OTA 状态变为:

s_state = APP_OTA_PENDING_REBOOT;

然后调用方回复上位机,触发:

HAL_NVIC_SystemReset();

注意,复位在 HTTP OTA 路由层触发,app_ota_finish() 本身只完成校验和 pending 提交。

6.4 复位后:选择新槽试运行

启动管理逻辑根据 EEPROM 记录选择 pending 槽,并在试启动前保存扣减后的次数:

第一次试运行 B:trial 3 → 2
第二次试运行 B:trial 2 → 1
第三次试运行 B:trial 1 → 0

两核的启动路径有所不同:

目标核 新槽启动方式
CM7 由固定 Bootloader 选择并跳转到应用槽
CM4 CM7 调用 boot_eeprom_prepare_cm4_boot() 选择槽,将地址写入共享内存,再由固定 CM4 Stub 跳转

当前应用工程可以看到这些启动管理接口;板上 CM7 Bootloader 的实际实现需要结合它自己的工程核验。

6.5 新固件启动后:确认或回退

新固件尚未确认时,每次复位后的候选试启动都会消耗一次 trial;额度耗尽后,启动管理逻辑排除该候选,并回退到仍然有效的旧 confirmed 镜像。

卡死是否能够自动触发复位,取决于看门狗是否已经启动、是否仍被其他任务喂养,以及是否存在其他复位来源。本工程 CM7 的部分外设初始化发生在 IWDG 初始化之前,因此不能保证所有初始化阶段的卡死都能自动恢复。默认任务周期喂狗也只能说明该任务仍能执行,不能证明网络、相机和全部业务正常。

当前 CM7 在 RTOS 启动前完成确认,CM4 在上报实际运行槽后由 CM7 确认。确认成功后,本次试启动事务结束;随后发生的业务故障不会继续使用这次 trial 自动回退。

CM7 调用:

boot_eeprom_commit_pending(&ee);

CM4 则在上报实际运行槽后,由 CM7 的管理逻辑调用:

boot_eeprom_commit_cm4_pending(&meta);

确认成功后:

confirmed = B
pending 标志清除
trial = 0

以后就正常从 B 启动,下一次升级可以写 A 槽。

如果新固件在确认前卡死,看门狗等机制使设备复位,后续启动继续消耗试启动次数;额度耗尽后,启动管理逻辑回退到旧的 confirmed 槽 A。

6.6 升级中途失败:app_ota_abort()

写入、校验失败,或者主动取消、会话超时,会进入取消流程:

  • 释放 CM4 hold。
  • 清空 RAM 中的升级进度和缓冲状态。
  • 恢复外部设备。
  • 回到 APP_OTA_IDLE。

已经擦写的空闲槽内容不会还原,在尚未形成有效 pending 记录时,重启仍使用旧 confirmed 镜像。若 EEPROM 保存或读回报错,提交结果可能不确定,应重新读取有效记录判断;重启可能恢复旧状态,也可能进入已完整验收候选的试启动状态。

整体关系可以记成:

A 槽运行
   ↓
擦除并写入 B 槽
   ↓
完整校验通过
   ↓
EEPROM:confirmed=A,pending=B
   ↓
复位,试运行 B
   ├─ 到达确认点并提交成功 → confirmed=B
   └─ 确认前失败,复位次数耗尽 → 回退 A

7. 故障发生与恢复

分析 OTA 可靠性时,主要问断电发生在哪个阶段,以及当时哪些状态已经持久化。

完整镜像通过验收后,将 pending 持久化,是进入试启动阶段的关键提交点;新应用保存 confirmed,则是接受新版本的第二个提交点。 在登记 pending 之前,下载进度不应改变正式启动选择。

核心故障 当前处理 恢复方式与限制
上传断网 保留 RAM 会话和接收偏移 重连后续传;设备复位或掉电后需从头上传
会话超时 空闲 60 秒或总时长 10 分钟后取消,释放资源并调用业务恢复接口 重新开始升级
Flash 写入或镜像校验失败 取消升级,不正常登记 pending,保护旧 confirmed 镜像 排除原因后重新完整上传
EEPROM 提交异常或提交结果丢失 使用双备份、CRC 和序号选择有效记录 重新查询状态;报错不代表 pending 一定未落盘
新镜像启动后未确认 最多试启动 3 次,仍未确认则在下一次启动回退旧镜像 需要复位才能推进次数;两核独立回退
新镜像确认后业务故障 不再触发本轮试启动回退 需再次升级修复或本地维护;当前确认点较早

等待重连期间,外部设备仍保持暂停;升级 CM4 时,CM4 继续保持 hold。保留会话是为了允许恢复传输,并不代表等待期间相关业务已经恢复。

EEPROM 双备份并不是同时写两份。通常先保留旧有效副本,再更新较旧的一份,通过记录长度、CRC 和序号判断有效性与新旧。读回校验用于确认本次保存结果,不能替代下一次上电时重新校验记录。

还有一个容易忽略的前提:A/B 槽位保留旧程序,不等于保留旧程序所需的配置。如果候选固件在确认前不可逆地改写配置或数据格式,即使程序回退,旧版本也可能无法正常工作。涉及数据迁移时,应采用向后兼容、备份或延后提交等方式。

8. CRC、签名和版本管理

前面的校验主要解决镜像是否完整、能否在目标槽启动,还需要区分固件来源和升级权限:

机制 能解决的问题 不能单独保证的事情
CRC32 检测传输和存储中的意外损坏 固件是否由可信发布者提供;攻击者可以同时替换镜像和 CRC
固件数字签名 使用可信公钥验证被签名的镜像及元数据 当前请求者是否有操作设备的权限
HTTPS 在正确验证证书的前提下保护传输并认证服务端;客户端认证需另行配置 镜像经过其他渠道保存或传递后的可信性
升级接口鉴权 限制谁能发起上传、取消等操作 上传镜像本身是否可信
版本与防降级策略 限制哪些版本允许安装 仅记录一个版本号并不会自动阻止降级

8.1 CRC 校验算法

目前使用的 CRC 主要用于 固件镜像完整性校验和 EEPROM 启动记录校验。它们使用同一种软件 CRC32 算法,但校验的数据和作用不同。

使用的 CRC 校验算法是标准 CRC-32/IEEE(CRC-32/ISO-HDLC):

参数 当前实现
宽度 32 位
初始值 0xFFFFFFFF
反射多项式 0xEDB88320
数据处理顺序 按字节输入,每字节从低位开始处理
最终处理 按位取反,相当于异或 0xFFFFFFFF

核心的计算可概括为:

crc = 0xFFFFFFFF;
for (每个字节)
{
    crc ^= 当前字节;
    for (8 次)
    {
        crc = (crc & 1) ? ((crc >> 1) ^ 0xEDB88320) : (crc >> 1);
    }
}
result = ~crc;

0xEDB88320 是常见多项式 0x04C11DB7 的反射形式,适合这里“右移、低位优先”的实现。

8.2 OTA 中的 CRC 校验流程

OTA 开始时,HTTP 层从请求头取得发布文件的信息:

X-Fw-Size       镜像长度
X-Fw-Version    版本号
X-Fw-Crc32      预期 CRC32
X-Fw-Base       镜像链接地址

这里的预期 CRC 是上传端提供的,设备保存到 s_expected_crc 变量中。

整个过程如下:

上传端提供镜像长度和预期 CRC
            ↓
设备接收数据,分段累计 CRC,同时写入空闲槽
            ↓
finish:检查总长度和接收数据 CRC
            ↓
检查目标槽向量表
            ↓
重新读取目标 Flash,计算镜像 CRC
            ↓
将镜像信息和 pending 状态写入 EEPROM

8.3 EEPROM 中的双层 CRC

EEPROM 中存放两类容易混淆的 CRC 字段:

字段 校验对象
app_crc[2] CM7 的 A/B 镜像
cm4_app_crc[2] CM4 的 A/B 镜像
结构末尾的 crc32 EEPROM 启动记录本身

也就是说,“保存镜像 CRC 的记录”本身还受另一层 CRC 保护。

工程在 EEPROM 中维护两份记录:

Copy0:0x0000
Copy1:0x0100

加载时分别检查魔数、版本、长度、槽号和 CRC:

  • 两份都有效,选择 seq 更大的记录。
  • 只有一份有效,使用有效记录。
  • 两份都无效,填入默认启动记录。

保存时增加 seq、重新计算记录 CRC,并优先写较旧或无效的备份。这样写入中途断电造成一份记录损坏时,另一份仍可能可用。CRC 用来识别损坏,双备份用来保留恢复依据。

8.4 CRC 校验兜底程度

CRC 的作用是检测数据损坏,例如传输错误、镜像截断、Flash 写入异常、EEPROM 记录损坏。它不能证明固件逻辑正确,也不能证明固件发布者可信。

尤其当前预期 CRC 由上传端提供,修改镜像后可以重新计算 CRC,因此 CRC 不等同于数字签名,也不提供防篡改认证。

当前工程实现了镜像 CRC32、实际 Flash 回读校验及版本信息登记,尚未实现设备端固件签名验签、HTTP 升级接口鉴权、TLS 传输或防降级版本策略。X-Fw-Version 当前用于元数据登记和续传会话匹配,不要求新版本号高于已安装版本。

Qt 本地任务中的 SHA-256 用于确认恢复上传时原 BIN 文件没有改变,不能代替设备端的固件来源认证,也不构成安全启动链。

镜像 CRC 还应能标识目标芯片、板型、目标核、长度、版本等信息。X-Fw-Base 是上传方的声明,不能单独证明 .bin 的真实链接地址或硬件适配性;向量表检查也不能证明镜像适用于当前板型。使用镜像头或发布清单时,应把这些元数据与镜像内容一起纳入签名验证。

双核发布还要管理版本组合。例如先升级 CM7、尚未升级 CM4 时,新 CM7 必须能与旧 CM4 通信;回退后也应检查另一核是否兼容。若无法保持这种兼容,就需要进一步设计双核成组升级和共同提交机制。防降级策略同样要为合法的试启动回退保留空间,避免新版本尚未确认就使旧版本失去启动资格。

9. Qt 上位机上传与恢复流程

image

9.1 上传流程

查询运行槽 -> 选择另一槽对应的 .bin -> 检查镜像入口 -> 保存本地任务 -> begin -> 按偏移发送 chunk -> end

9.2 恢复流程

核心故障 当前处理 恢复方式与限制
上传断网、超时或临时服务错误 自动退避重连,查询设备偏移后续传 连续恢复失败达到 60 秒后暂停并保留任务;单请求超时 45 秒
程序关闭或进程退出 保留本地任务,关闭窗口不发送 abort 重开后校验原 BIN,再按设备偏移恢复
设备掉电或会话失效 提示从头上传 通常确认后重新擦除;若会话在查询后失效,begin 可能已先擦除目标槽
固件变更或设备状态冲突 暂停或拒绝恢复,保留任务 恢复原文件或核对槽位、pending 状态后处理
用户取消 请求设备 abort,成功后删除任务 取消失败保留任务;进入 end 阶段后禁用取消
end 响应丢失 只查询目标运行槽、版本、长度及 pending 状态 不自动重发 end;无法确认时保留任务,需人工判断
end 正常返回成功 显示上传成功并删除任务 不自动等待启动确认,仍需查询状态和检查实际业务

10. 操作步骤与验证边界

  1. 首次部署分别烧录固定 Bootloader、CM4 Stub 和匹配地址的应用镜像。
  2. 查询实际运行槽,构建对应目标槽 BIN。
  3. 在 Qt 中选择设备、目标核、版本号和 BIN。
  4. 上传成功后等待设备复位。
  5. 查询运行槽、confirmed、pending、trial,并核对串口启动日志和相机业务。
验证项目 应记录的结果
正常上传 目标核、目标槽、长度、CRC32及上传结果
半块断线 重连后的 written 未包含未完成块
完整块响应丢失 重连后的 written 包含已提交块
Qt 关闭后恢复 本地任务恢复,按设备偏移继续
设备会话失效 提示从头上传,不误用本地进度
新槽启动 Bootloader 校验、实际运行槽、确认结果
业务验证 实际画面、帧率及其他关键功能

博客导航

博客导航

posted @ 2026-10-20 10:33  膝盖中箭卫兵  阅读(18)  评论(0)    收藏  举报
ORCID iD icon https://orcid.org/0000-0001-5102-772X