7AA7 博客

ESP32 开发入门:从点灯到联网上报

Kernighan 林 UID:124
2026-09-05 23:06
34 2 1

从点灯到把数据稳定送到云端,中间隔着 GPIO 电气特性、RTOS 任务调度、WiFi 状态机、看门狗四件事。这四件事里任何一个没想清楚,设备都会在跑一段时间之后才出问题。

开发框架的定位差异

ESP-IDF 是官方框架,直接基于 FreeRTOS,暴露完整的组件配置(menuconfig)、分区表、驱动层。适合需要对内存布局、外设时钟、低功耗模式做精细控制的项目。构建基于 CMake,组件化的目录结构对长期维护友好。

Arduino 框架在 IDF 之上包了一层,setup()/loop() 的模型上手快,库生态丰富。代价是屏蔽了很多底层细节,遇到时序、内存、低功耗这类问题时不好往下钻。

选型上不是非此即彼:快速验证原型用 Arduino,进入工程化阶段切到 IDF 是常见路径。也可以混合——在 IDF 工程里以组件形式引入 Arduino 核心。具体支持情况以所用版本的官方文档为准。

GPIO:上下拉、开漏与复用

一个 GPIO 配置要考虑的不只是输入还是输出。

gpio_config_t cfg = {
    .pin_bit_mask = (1ULL << GPIO_NUM_4),
    .mode         = GPIO_MODE_INPUT,
    .pull_up_en   = GPIO_PULLUP_ENABLE,
    .pull_down_en = GPIO_PULLDOWN_DISABLE,
    .intr_type    = GPIO_INTR_DISABLE,
};
gpio_config(&cfg);

上下拉:按键和 I2C 总线这类场景必须显式配置。内部上拉的阻值量级在几十 kΩ,对 I2C 这种对上升沿有要求的总线,速率提上去之后内部上拉往往不够,需要外部电阻。上下拉只在输入模式生效,配置成输出时设了也没用。

开漏(GPIO_MODE_OUTPUT_OD):输出低时拉低,输出高时靠上拉电阻拉高。用途有两个——多个设备共享一条线做线与逻辑(I2C 就是这么工作的),以及电平转换(上拉到 3.3 V 或 5 V 取决于要对接的电平,前提是引脚容忍该电压)。

引脚复用:ESP32 的 GPIO 矩阵把大部分外设信号路由到任意引脚,但有一批引脚有固定用途或限制。GPIO6~11 通常接片外 flash,不要拿来当普通 IO;部分引脚是仅输入(无输出驱动);部分 ADC 通道与 WiFi 共用资源,WiFi 开启时 ADC2 的采样会受影响。哪些引脚受限、限制到什么程度,以具体型号的技术参考手册为准,不同版本差异不小。

FreeRTOS 任务与优先级

loop() 之外,任务创建用 xTaskCreate:

void sensor_task(void *arg) {
    for (;;) {
        read_sensor_and_push();
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

xTaskCreate(sensor_task, "sensor", 4096, NULL, 5, NULL);

第二个参数是栈大小,单位是字不是字节,4096 意味着 16 KB 栈(32 位架构)。给少了会溢出,给多了浪费 RAM。先用大值跑起来,再用 uxTaskGetStackHighWaterMark() 实测收敛。

优先级饥饿:FreeRTOS 是抢占式调度,只要高优先级任务处于就绪态,低优先级任务就一行都跑不了。如果高优先级任务里写了不带阻塞的死循环——哪怕是空转等一个标志位——同优先级以下的所有任务全部停摆,包括网络任务、日志任务、IDLE 任务。IDLE 任务跑不了意味着无法释放已删除任务的内存、也无法进入低功耗。

正确做法是任何等待都用阻塞式的同步原语:

/* 错:空转等待,占满 CPU 且饿死低优先级任务 */
while (!flag) { }

/* 对:阻塞等待,CPU 让给其他任务 */
xQueueReceive(queue, &msg, portMAX_DELAY);
/* 或者 */
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(1000));

vTaskDelay 让任务进入阻塞态,taskYIELD 和 vTaskDelay(0) 只让出给同优先级。延时用 pdMS_TO_TICKS 转节拍,直接写数字会随 configTICK_RATE_HZ 变化而失真。

临界区:访问共享数据用互斥量。中断与任务之间共享数据用 portENTER_CRITICAL_ISR 或者干脆把数据放进队列。临界区内不要调用可能阻塞的 API。

WiFi 连接状态机

SDK 提供了 WiFi 事件回调(连接、断开、拿到 IP)。SDK 自带的自动重连主要处理链路层断连,应用层如果只依赖它,会出现”事件显示已连接但实际发不出数据”的状态——比如拿到 IP 之后上游网关不通、DHCP 租约异常、或者拿到 IP 之前就开始发包。

稳妥的做法是自己维护一个应用层状态机:

typedef enum { ST_INIT, ST_CONNECTING, ST_ONLINE, ST_BACKOFF } net_state_t;
static net_state_t state = ST_INIT;
static uint32_t backoff_ms = 2000;

void net_task(void *arg) {
    for (;;) {
        switch (state) {
        case ST_INIT:
            wifi_start_and_connect();
            state = ST_CONNECTING;
            break;

        case ST_CONNECTING:
            if (wait_ip_with_timeout(15000)) {   /* 等 IP,带超时 */
                state = ST_ONLINE;
                backoff_ms = 2000;
            } else {
                state = ST_BACKOFF;
            }
            break;

        case ST_ONLINE:
            if (!mqtt_publish_once()) {          /* 应用层连通性才算在线 */
                state = ST_BACKOFF;
            }
            vTaskDelay(pdMS_TO_TICKS(5000));
            break;

        case ST_BACKOFF:
            wifi_stop();                          /* 彻底关掉再重来,清理内部状态 */
            vTaskDelay(pdMS_TO_TICKS(backoff_ms));
            backoff_ms = MIN(backoff_ms * 2, 60000);
            state = ST_INIT;
            break;
        }
    }
}

重连失败后先 stop 再 start,比单纯再调一次 connect 干净,能避免内部残留状态导致的连不上。退避间隔要加随机抖动,避免一批设备在同一时刻集体重连。

看门狗

ESP-IDF 有两级看门狗:任务看门狗(Task WDT)监控任务是否长期不让出 CPU,中断看门狗监控中断/临界区是否关太久。触发后会打印回溯信息再复位。

喂狗的位置很关键。在循环体末尾无条件喂狗是错的——循环体里的某个环节卡死了,喂狗照样执行,看门狗形同虚设。合理的做法是用任务通知或者事件组,各关键任务周期性置位,由一个专门的监督任务检查全部位是否都置上,都置上才喂狗。

长期运行的循环里必须有阻塞调用或 vTaskDelay,否则任务看门狗会触发。这在写 busy-wait 的驱动时特别容易踩。

采集-上报的最小骨架

typedef struct { int32_t value; uint32_t seq; uint32_t ts; } sample_t;

static QueueHandle_t sample_q;
static SemaphoreHandle_t i2c_mux;

void采集_task(void *arg) {
    sample_t s = {0};
    TickType_t last = xTaskGetTickCount();
    for (;;) {
        xSemaphoreTake(i2c_mux, portMAX_DELAY);
        int r = sensor_read(&s.value);          /* 失败要区分:偶发重试 / 持续报错 */
        xSemaphoreGive(i2c_mux);

        if (r == 0 && range_check(s.value)) {
            s.seq++; s.ts = now();
            if (xQueueSend(sample_q, &s, 0) != pdTRUE) {
                /* 队列满:网络断了。覆盖最旧的一条或丢弃新数据,二选一,别静默 */
                xQueueReceive(sample_q, &s, 0);   /* 丢弃最旧 */
                xQueueSend(sample_q, &s, 0);
            }
        }
        vTaskDelayUntil(&last, pdMS_TO_TICKS(1000));
    }
}

void report_task(void *arg) {
    sample_t s;
    for (;;) {
        if (net_is_online() && xQueueReceive(sample_q, &s, pdMS_TO_TICKS(1000))) {
            if (mqtt_publish(TOPIC, &s, sizeof(s), QOS1) != 0) {
                xQueueSendToFront(sample_q, &s, 0);  /* 发失败塞回去,下次重试 */
            }
        }
    }
}

用 vTaskDelayUntil 而不是 vTaskDelay,前者按绝对节拍推进,不会因处理耗时累积漂移。I2C 总线用互斥量保护,避免两个任务同时访问同一外设。采集和上报分成两个任务,网络阻塞就不会影响采样周期。

队列深度决定了断网时能缓存多少条。深度 16 意味着断网期间最多攒 16 条,之后开始覆盖。这个数按上报周期和可容忍的断网时长来定:能容忍断网 5 分钟、上报周期 1 秒,深度就要 300 上下。太小则断网恢复后历史已被覆盖,太大则吃 RAM。需要缓存跨重启就要落到 flash 或 RTC 保留内存,代价是 flash 擦写寿命和掉电一致性问题。

常见坑

Strapping 引脚:芯片上电时会采样一批引脚的电平来决定启动模式和部分配置。如果这些引脚外接了上拉或下拉电路,可能导致启动模式异常。GPIO0 是最典型的一个——拉低进下载模式。设计电路时查清楚所用型号的 strapping 引脚列表(以技术参考手册为准),避免在这些引脚上挂会影响启动电平的电路。

串口占用:默认日志走 UART0,如果把它配成与外部设备通信的串口,日志就没了,而且下载时可能受影响。要么换到 UART1/UART2,要么在 menuconfig 里把日志输出改到别的通道。

启动日志:上电时 UART0 会输出 ROM 的启动信息,波特率固定。如果 MCU 一侧的串口配置与之不一致,会看到一段乱码然后恢复正常——这是正常的,不是硬件故障。

WiFi 与 ADC:部分 ADC 通道与 WiFi 共用硬件资源,WiFi 工作时采样会失败或不准。需要稳定采样就把传感器接到不受影响的通道,具体通道分配查手册。

flash 频率与 PSRAM:menuconfig 里的 flash 频率、PSRAM 使能要和硬件实际规格匹配,配错了表现为启动卡死或者随机崩溃。

738浏览量
34赞
2评论
2参与用户
14 分钟阅读时间

评论(2)

  • 何遇 · 2026-09-06 01:48

    作者是不是被这个坑折磨过,写得太真实了。

    回复
  • 江眠 · 2026-09-06 10:33

    看完感觉自己像个假程序员。

    回复