OTA项目回顾(二)
1. 这一篇要解决什么问题
上一篇先从整体上看了 这个项目:它是一个基于 ESP32-C3 的 BLE OTA 桥接器。
这一篇开始进入代码内部,但不急着讲 BLE 细节。因为在 ESP-IDF 项目里,很多业务代码之所以这样写,背后其实都是 FreeRTOS 的任务模型在起作用。
这一篇重点回答几个问题:
app_main()是谁调用的?- ESP-IDF 程序为什么不是传统单片机里的
while (1)? - Task、Queue、Semaphore、Timer 分别解决什么问题?
- 为什么 BLE 回调里不能直接做 OTA 写 flash?
- 为什么项目里要把 UART 接收、BLE notify、OTA 处理拆成不同任务?
2. ESP-IDF 在项目中的位置
ESP-IDF 是乐鑫官方提供的 ESP32 系列开发框架。它可以理解成:
官方 SDK + 构建系统 + 硬件驱动 + 协议栈 + FreeRTOS 运行环境
项目的应用代码位于 main,它并不是直接操作所有底层寄存器,而是调用 ESP-IDF 提供的 API。
项目里常见的 ESP-IDF 头文件包括:
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "nvs_flash.h"
#include "esp_bt.h"
#include "esp_gap_ble_api.h"
#include "esp_gatts_api.h"
#include "driver/uart.h"
#include "esp_ota_ops.h"
#include "esp_partition.h"
对应关系如下:
| ESP-IDF 模块 | 在项目中的作用 |
|---|---|
| FreeRTOS | 提供 Task、Queue、Semaphore、Timer |
| UART Driver | 初始化 UART1,和下游 MCU 通信 |
| Bluetooth / BLE | 提供 BLE controller、GAP、GATTS API |
| NVS | 存储 BLE 地址、产品信息等非易失数据 |
| OTA API | 支持 ESP32-C3 自身 app 分区升级 |
| Partition API | 读写 ota_0、ota_1、fw_app 等分区 |
| Log 系统 | 提供 ESP_LOGI/W/E 日志输出 |
| Build System | 生成 bootloader、分区表和 app 镜像 |
可以这样理解:
我们的 业务代码
|
| 调用 ESP-IDF API
v
ESP-IDF 框架
|
| 驱动硬件、运行协议栈、调度任务
v
ESP32-C3 芯片
3. 从传统裸机思维到 RTOS 思维
很多人刚学单片机时,代码结构大概是这样:
int main(void)
{
board_init();
while (1)
{
scan_key();
read_sensor();
update_display();
handle_uart();
}
}
这种写法叫“超级循环”,也就是所有事情都放在一个 while (1) 里轮询。
它的优点是简单,适合小项目。但当系统复杂起来,就会遇到几个问题:
- 某个函数耗时太久,会影响其他模块响应。
- 串口、蓝牙、OTA、日志混在一个循环里,代码越来越难维护。
- 如果需要等待某个事件,比如 BLE 服务创建完成,写起来很别扭。
- 如果需要超时、重试、跨模块通信,就会出现很多状态变量。
该项目里同时有 BLE、UART、OTA、日志、DFU,已经不适合全部塞进一个循环。所以它采用了 RTOS 的思路:把不同职责拆成多个任务,再通过队列、信号量、定时器进行协作。
4. 从上电到 app_main:ESP32-C3 启动流程
app_main() 不是芯片上电后执行的第一行代码。
ESP32-C3 的启动链路可以分成三大阶段:
芯片复位 / 上电
|
v
一级 Bootloader:ROM Bootloader
|
v
二级 Bootloader:ESP-IDF Bootloader
|
v
应用程序入口:call_start_cpu0
|
v
ESP-IDF 应用启动流程
|
v
FreeRTOS main task
|
v
app_main()
4.1 一级 Bootloader:芯片 ROM 里的启动代码
一级 Bootloader 固化在芯片 ROM 里,是芯片出厂时就写好的代码,普通应用不能修改。
它主要负责最早期的启动判断:
- 判断芯片当前是正常启动,还是进入下载模式。
- 初始化最基本的 SPI Flash 读取能力。
- 从 Flash 的固定位置加载二级 Bootloader。
- 跳转到二级 Bootloader 继续执行。
平时我们用串口烧录程序时,其实也和 ROM Bootloader 有关。芯片进入下载模式后,PC 上的 esptool.py 会和 ROM Bootloader 通信,把 bootloader、分区表、app 等镜像写入 Flash。
4.2 二级 Bootloader:ESP-IDF 生成的 bootloader
二级 Bootloader 是 ESP-IDF 帮我们构建出来的。它不是写死在芯片 ROM 里的,而是作为一个独立二进制文件烧录到 Flash 中。
项目构建时,ESP-IDF 通常会生成两个主要程序:
bootloader.bin:二级 Bootloader。skybridge.bin:我们的应用程序。
SkyBridge 的 flash.sh 里可以看到烧录地址:
0x0 build/bootloader/bootloader.bin
0x8000 build/partition_table/partition-table.bin
0x9000 build/nvs_data_initial.bin
0xd000 build/ota_data_initial.bin
0x10000 build/skybridge.bin
二级 Bootloader 的职责包括:
- 初始化部分硬件和 Flash 访问配置。
- 读取分区表。
- 判断应该启动哪个 app 分区。
- 处理 OTA 启动选择。
- 校验 app 镜像。
- 把 app 的代码段、数据段加载或映射到正确的内存区域。
- 跳转到 app 的入口函数。
为什么需要二级 Bootloader?
因为 ROM Bootloader 很小,也不适合频繁改动。ESP-IDF 用二级 Bootloader 来支持更灵活的功能,比如分区表、OTA、安全启动、Flash 加密等。
4.3 分区表:Bootloader 如何知道启动哪个程序
Flash 不是一整块随便用的空间,而是被分区表划分成多个区域。
SkyBridge 的分区表是:
nvs data nvs 16K
otadata data ota 8K
phy_init data phy 4K
ota_0 app ota_0 1536K
ota_1 app ota_1 1536K
fw_app 0x40 0x00 512K
这些分区可以分成两类:
| 分区 | 类型 | 作用 |
|---|---|---|
nvs |
data | 保存 BLE 地址、产品信息等非易失数据 |
otadata |
data | 保存当前应该启动哪个 OTA app 分区 |
phy_init |
data | 保存射频初始化相关数据 |
ota_0 |
app | ESP32-C3 应用固件槽位 0 |
ota_1 |
app | ESP32-C3 应用固件槽位 1 |
fw_app |
custom | 下游 MCU 固件缓存区 |
二级 Bootloader 会读取分区表,找到 app 分区。
如果项目没有 OTA,可能只有一个 factory app 分区。SkyBridge 支持 OTA,所以有 ota_0 和 ota_1 两个 app 分区。
启动时,Bootloader 会查看 otadata,判断当前应该启动 ota_0 还是 ota_1。
4.4 OTA 和启动分区切换
这和项目里的 OTA 逻辑直接相关。
在 prf_ota.c 中,升级 ESP32-C3 自身固件时会调用:
partition = esp_ota_get_next_update_partition(NULL);
esp_ota_begin(partition, package_size, &update_handle);
esp_ota_write(update_handle, packet_data, packet_received);
esp_ota_end(update_handle);
esp_ota_set_boot_partition(update_partition);
这几步背后的含义是:
- 当前如果运行在
ota_0,就选择ota_1作为更新目标。 - 把新固件写入
ota_1。 - 写完后校验镜像。
- 调用
esp_ota_set_boot_partition()修改otadata。 - 下次重启时,Bootloader 读取
otadata,选择启动ota_1。
所以 OTA 并不是“写完 Flash 立刻跳过去运行”,而是:
当前 app 正常运行
|
| 写入另一个 app 分区
v
修改 otadata
|
| 重启
v
Bootloader 读取 otadata
|
v
启动新 app 分区
这也是为什么 Bootloader、分区表和 otadata 对 OTA 项目非常重要。
4.5 app 启动:从 Bootloader 跳到 ESP-IDF 应用入口
二级 Bootloader 选择好 app 分区后,会加载应用镜像,然后跳到应用入口。
但这个入口还不是我们写的 app_main()。
ESP-IDF 应用真正进入用户代码前,还会做一系列系统初始化,例如:
- 设置 CPU 和系统时钟。
- 初始化堆内存。
- 初始化 C 运行环境。
- 初始化系统组件。
- 初始化 FreeRTOS 相关结构。
- 创建并启动 FreeRTOS 调度器。
- 创建 main task。
- 在 main task 中调用
app_main()。
所以更完整的关系是:
二级 Bootloader
|
v
call_start_cpu0()
|
v
ESP-IDF 系统初始化
|
v
启动 FreeRTOS 调度器
|
v
main task
|
v
app_main()
这就解释了两个现象:
第一,app_main() 运行在 FreeRTOS 的 main task 里,所以在 app_main() 里可以直接调用 xTaskCreate()、xQueueCreate() 这些 FreeRTOS API。
第二,app_main() 可以返回。它返回只是 main task 结束,不代表整个芯片停止运行。只要系统里还有其他 task,例如 UART 接收任务、BLE notify 任务、OTA 任务,FreeRTOS 调度器就会继续运行。
4.6 一句话总结启动流程
可以这样记:
ROM Bootloader 负责从 Flash 加载 ESP-IDF 二级 Bootloader;二级 Bootloader 负责读取分区表和 otadata,选择并校验 app 分区;ESP-IDF 应用启动代码负责初始化运行环境和 FreeRTOS,最后在 main task 中调用用户编写的
app_main()。
面试时如果被问“ESP32 从上电到 app_main 经历了什么”,可以回答:
ESP32-C3 上电后先执行芯片 ROM 中的一级 Bootloader,它会判断启动模式并从 Flash 加载 ESP-IDF 的二级 Bootloader。二级 Bootloader 会读取分区表,根据 otadata 选择当前要启动的 app 分区,校验并加载应用镜像。之后跳转到 ESP-IDF 应用入口,完成 C 运行环境、堆、系统组件和 FreeRTOS 调度器初始化,最后创建 main task,并在 main task 里调用
app_main()。
更加详细的流程可见ESP-IDF官方:https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32c3/api-guides/startup.html
5. SkyBridge 的初始化顺序
程序入口在 app_main():
int app_main(void)
{
board_init();
com_uart_init();
bt_main_init();
esp_log_set_vprintf(_log_vprintf);
runloop_init();
application_init();
return 0;
}
初始化关系可以画成:
app_main
|
|-- board_init -> nvs_flash_init
|-- com_uart_init -> uart_driver_install -> xTaskCreate(uart_recv_handler)
|-- bt_main_init -> bt_dm_init -> bt_stack_init -> ble_main_init
|-- esp_log_set_vprintf
|-- runloop_init -> xQueueCreate -> xTaskCreate(runloop_task)
|-- application_init -> prf_ctrl_init / prf_trace_init / prf_ota_init
每一步都不是孤立的:
- NVS 要先初始化,因为 BLE 随机地址可能从 NVS 读取。
- UART 要先初始化,因为后续控制命令和 DFU 依赖串口。
- BLE 协议栈要先初始化,再创建 GATT 服务。
- GATT service 创建完成后,profile 层才能注册业务回调。
- 日志重定向要在服务可用后工作,才能通过 BLE trace 输出。
6. Task:把不同职责拆成独立执行单元
FreeRTOS 的 Task 可以先理解成“轻量级线程”。
创建任务:
xTaskCreate(some_task, "some", 2048, NULL, 5, NULL);
参数含义大概是:
| 参数 | 含义 |
|---|---|
some_task |
任务函数 |
"some" |
任务名,调试用 |
2048 |
栈大小 |
NULL |
传给任务的参数 |
5 |
任务优先级 |
NULL |
任务句柄 |
SkyBridge 里比较重要的 task 有:
| 任务 | 文件 | 作用 |
|---|---|---|
uart_recv_handler |
com_uart.c |
持续读取 UART 帧并分发 |
notify_task_handler |
ble_main.c |
从队列取 BLE notify 消息并发送 |
ota_task_handler |
prf_ota.c |
处理 OTA 状态机 |
runloop_task |
runloop.c |
执行被投递的函数任务 |
为什么要拆成多个 task?因为这些工作具有不同的节奏:UART 接收要及时,BLE notify 可能因为连接拥塞而等待,OTA 写 flash 比较耗时,runloop 则用于统一执行一些异步任务。
7. Queue:任务之间如何传递消息
Task 拆开之后,就会出现一个问题:任务之间怎么通信?FreeRTOS Queue 就是常用答案。
Queue 可以理解成一个线程安全的消息队列:
生产者 task / 回调
|
| xQueueSend
v
Queue
|
| xQueueReceive
v
消费者 task
7.1 runloop 队列
runloop.c 里定义了一个很小的事件循环:
typedef struct
{
runloop_task_t task;
void *args;
} runloop_id_t;
其他地方把“函数指针 + 参数”丢进队列,runloop_task 就会在自己的任务上下文里执行它。
这和直接调用函数有什么区别?直接调用函数时,函数运行在当前上下文。如果当前上下文是中断、BLE 回调或高优先级任务,那就不适合做耗时操作。投递到 runloop 后,函数会在 runloop task 里运行,调用方可以尽快返回。
7.2 BLE notify 队列
业务层调用:
ble_send_notify(gatt_if, attr_hdl, data, length);
它会把数据拆包后放入 notify_que_handle,真正发送发生在 notify_task_handler()。这样做的好处是:如果 BLE 当前拥塞,业务代码不会一直卡在发送逻辑里。
7.3 OTA 消息队列
OTA 写入回调收到 BLE 数据后,也没有直接处理,而是复制到队列:
static void ota_recv_handler(uint8_t *data, uint16_t length)
{
ota_msg_t *msg = pvPortMalloc(sizeof(ota_msg_t) + length);
msg->func = ota_data_handler;
msg->size = length;
memcpy(msg->data, data, length);
xQueueSend(ota_msg_queue, &msg, 0);
}
然后由 OTA task 处理。这个设计很关键:BLE GATT write 回调属于协议栈调用路径,不应该在里面写 flash、算整包 CRC、等待 UART DFU。否则可能阻塞 BLE 协议栈,导致连接不稳定甚至断开。
8. Semaphore:用来等待事件或保护资源
Semaphore 可以粗略分两类:
- Binary Semaphore:像一个“事件通知”。
- Mutex:像一个“资源锁”。
8.1 等待 BLE service 创建完成
以 svc_ota_init() 为例:
ready_sem_handle = xSemaphoreCreateBinary();
ble_register_gatts_callback(APP_OTA_ID, ota_gatts_event_handler);
esp_ble_gatts_app_register(APP_OTA_ID);
if (xSemaphoreTake(ready_sem_handle, pdMS_TO_TICKS(1000)) != pdPASS)
{
ESP_LOGE(LOG_TAG, "create ota service failed");
}
GATT service 的创建是异步的。调用 esp_ble_gatts_app_register() 之后,并不是马上就创建完成。ESP-IDF 后续会通过 GATTS 事件回调通知应用,比如 ESP_GATTS_START_EVT。所以这里的信号量等待的是“服务真的启动完成”这个事件。
8.2 控制 UART 接收暂停
com_uart.c 里有一个 com_recv_mux,用于暂停内部 UART 接收任务。当 DFU 需要独占串口时,会调用:
com_uart_pause_internal_recv(true);
这样可以暂时阻止普通 UART 接收任务继续消费串口数据,把 UART 留给 DFU 请求/响应使用。
这个设计背后的问题是:同一个 UART 上不能同时有两个逻辑随便读数据。否则 DFU 响应可能被普通接收任务读走,普通协议帧也可能被 DFU 逻辑误读。
9. Timer:不要用阻塞延时管理超时
FreeRTOS Timer 用于“过一段时间执行某个回调”。
OTA 里有两个 timer:
ota_timeout_timer = xTimerCreate("ota_timeout", pdMS_TO_TICKS(OTA_TIMEOUT), pdFALSE, NULL, timeout_callback);
ota_finish_timer = xTimerCreate("ota_finish", pdMS_TO_TICKS(1000), pdFALSE, NULL, finish_callback);
ota_timeout_timer 用于 OTA 超时。每当收到一包正确数据后,项目会重新启动超时计时。如果长时间没有收到下一包数据,就会触发超时回调,终止 OTA 并返回错误。
ota_finish_timer 用于升级完成后延迟重启。Timer 可以让“延迟执行”这件事交给系统调度,而不是阻塞当前逻辑。
10. 为什么 BLE 回调里不能直接写 flash
假设 BLE write 回调里直接做 OTA:
static void ota_write_callback(uint8_t *data, uint16_t len)
{
parse_packet(data, len);
esp_ota_write(...);
crc32_check(...);
request_next_packet(...);
}
看起来很直接,但问题很多:
- BLE 协议栈回调被占用太久,影响连接稳定性。
- Flash 写入耗时不稳定,可能造成 BLE 数据处理延迟。
- CRC 计算、分包状态机、错误处理会让回调函数变得很复杂。
- 后续如果还要触发 UART DFU,会进一步阻塞。
项目里的做法是:
BLE write 回调
|
| 复制数据
| 投递到 OTA queue
v
OTA task
|
| 处理状态机
| 写 flash
| 做 CRC
| 请求下一包
这就是典型的“回调轻量化,业务任务化”。
面试里可以这样讲:
BLE GATT write 回调只负责接收数据和投递消息,不在回调里做 flash 写入或整包校验。OTA 逻辑运行在独立 FreeRTOS task 中,通过 queue 与 BLE 回调解耦。这样可以避免阻塞蓝牙协议栈,同时让 OTA 状态机、超时重试和错误处理集中在一个任务上下文里。
11. 任务优先级怎么理解
FreeRTOS 任务有优先级,数字越大优先级越高。
项目里可以看到:
| 任务 | 优先级 | 说明 |
|---|---|---|
| UART 接收任务 | 9 | 串口接收比较实时,优先级较高 |
| BLE notify 任务 | 8 | 负责异步发送 BLE notify |
| OTA 任务 | 5 | 处理升级状态机 |
| runloop 任务 | 5 | 处理普通异步任务 |
优先级不是越高越好。高优先级任务如果长时间不阻塞、不延时,就会让低优先级任务得不到运行机会。
所以 FreeRTOS 任务里经常会看到:
xQueueReceive(..., portMAX_DELAY);
vTaskDelay(...);
这些调用的作用不仅是等待事件,也是在等待期间把 CPU 让给其他任务。
12. 从项目角度总结 FreeRTOS 设计
SkyBridge 的任务模型可以总结成:
BLE 协议栈回调
|
| write data
v
profile 回调
|
| 投递消息
v
OTA task / UART task / notify task
|
| 处理业务
v
BLE notify / UART send / Flash write
这个结构有几个优点:
- 回调函数短,协议栈不容易被阻塞。
- 耗时业务集中在 task 中,便于维护。
- Queue 让模块之间边界清晰。
- Semaphore 让异步初始化和资源互斥更可靠。
- Timer 让超时和延迟动作不阻塞主流程。
13. 这一篇的复习检查点
读完这一篇后,可以用下面的问题检查自己是否真的理解:
- ESP32-C3 上电后为什么不是直接执行
app_main()? - ROM Bootloader 和二级 Bootloader 分别负责什么?
- 分区表和
otadata在 OTA 里有什么作用? app_main()返回后程序为什么还能继续运行?xTaskCreate()的栈大小和任务优先级分别是什么意思?- Queue 和普通全局变量传数据有什么区别?
- Binary Semaphore 和 Mutex 的使用场景有什么不同?
- 为什么 BLE GATT service 创建需要等待事件?
- 为什么 OTA 超时适合用 Timer,而不是一直阻塞等待?
- 为什么不能让普通 UART 接收任务和 DFU 同时读同一个 UART?
- 为什么 BLE 回调里只投递消息,不直接写 flash?
14. 下一篇预告
下一篇进入 BLE:
- BLE GAP 和 GATT 分别是什么?
- 广播包里为什么只有 31 字节?
- Service、Characteristic、Descriptor、Handle 怎么理解?
- CCC 是什么,为什么 notify 前客户端要先写 CCC?
ble_main.c如何把 ESP-IDF 的 GATTS 事件分发给不同 service?- MTU 为什么会影响 notify 分包大小?
更多推荐

所有评论(0)