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_0ota_1fw_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_0ota_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);

这几步背后的含义是:

  1. 当前如果运行在 ota_0,就选择 ota_1 作为更新目标。
  2. 把新固件写入 ota_1
  3. 写完后校验镜像。
  4. 调用 esp_ota_set_boot_partition() 修改 otadata
  5. 下次重启时,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 分包大小?
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐