从点灯到把数据稳定送到云端,中间隔着 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 使能要和硬件实际规格匹配,配错了表现为启动卡死或者随机崩溃。
评论(2)
作者是不是被这个坑折磨过,写得太真实了。
看完感觉自己像个假程序员。
请 登录 后参与评论。