
自己折腾ESP32也有一阵子了从点灯到连上WiFi再到真正从网上拉数据下来每一步踩的坑加起来能绕开发板两圈。这次这篇记录一下我用ESP-IDF让ESP32联网获取温度和天气信息的完整过程不光是贴代码连当初怎么排查问题、为什么这么写都一并交代清楚。这套玩法做完等于把“联网-请求-解析-展示”这条物联网开发的主干道都走了一遍后续做远程控制、数据上报、告警推送全都是同一个套路。如果手头有ESP32开发板并且正在用或准备切到ESP-IDF这个框架这篇的代码和思路可以直接抄。哪怕之前只玩过Arduino也没关系下面会尽量把IDF里的关键概念拆开讲明白老手可以直接跳去后两章看代码和避坑。1. 项目整体设计与方案选型解析1.1 为什么选ESP-IDF而不是Arduino网上关于ESP32联网的教程十个里有八个是Arduino环境的。Arduino确实上手快WiFi库封装得极其友好几行代码就能连上路由器。但一旦项目开始变复杂——比如你要同时维护WiFi重连、做JSON解析、定期上报数据、跑个WebServer——Arduino那套单文件写法很快就捉襟见肘了出了问题你甚至不知道SDK内部发生了什么。ESP-IDF是乐鑫官方的物联网开发框架虽然学习曲线陡一点但它的任务调度、内存管理、错误处理机制都是按工业级标准来的。开发时能直接看到WiFi事件回调、内存剩余量、任务栈使用情况程序运行稳定了之后可以做到几十天不重启这一点在Arduino上很难实现。我做这个小项目的心态很简单反正硬着头皮也得学会IDF不如借这个“获取天气”的小项目把环境调通、把关键API摸熟。事实证明这个选择很值项目本身不大但框架的关键环节都碰了一遍。1.2 整体架构怎么设计这个项目的目标很明确ESP32通过WiFi连接路由器访问一个公开的天气API拿到当前温度和天气现象解析出有用字段。这里有个关键设计决策——温度和天气数据是从互联网API获取还是搭配本地传感器一起读取。我没把问题复杂化定了两条线远端数据通过HTTP GET请求一个天气API返回JSON格式数据从中解析出温度、天气现象文字、湿度等字段。本地数据顺带用ESP32内置的霍尔传感器或外接DS18B20读一下当前环境温度两条温度放在一起对比也能顺便验证传感器数据与网络数据的差异。整个系统的工作流程是开机初始化NVS和netif → 初始化并连接WiFi → 拿到IP地址触发事件 → 创建HTTP客户端请求天气API → 用cJSON解析返回的数据 → 提取温度等字段 → 串口打印或送往OLED屏幕。这里每一条我用的是**任务Task**方式来做避免在事件回调里做耗时操作。1.3 天气数据源怎么选公开的天气API不少我试过国外的心知天气、OpenWeatherMap也试过国内的聚合数据。选择标准有三条注册是否简便、免费额度是否够用、返回字段是否是标准JSON。个人最终用的是心知天气的免费版原因很简单免费版每天有请求次数限制个人DIY完全够用。返回的JSON结构清晰字段名规范省去很多解析麻烦。在国内网络环境下访问速度稳定延迟可以接受。要注意一点免费API的接口结构和字段随时有可能调整所以代码里对JSON解析的部分一定要做容错处理不能因为API改了字段就导致整个程序崩溃。后面会专门讲这个坑。2. WiFi Station模式配置与连接2.1 三步初始化流程缺一不可ESP-IDF里WiFi连接不是直接调一个函数就完事的它要先完成底层初始化。很多人卡在这一步就是因为少了某个init函数。按照我的实践标准步骤是初始化NVS非易失性存储WiFi驱动和证书校验等模块都会用NVS存取数据。创建netif网络接口相当于声明“我要用一个Station类型的网卡”。创建事件循环并注册回调后续的WiFi连接成功、断开、拿到IP等事件都会投递到事件循环里。代码骨架是这样的esp_err_t ret nvs_flash_init(); if (ret ESP_ERR_NVS_NO_FREE_PAGES || ret ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); ret nvs_flash_init(); } ESP_ERROR_CHECK(ret); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); esp_netif_create_default_wifi_sta(); wifi_init_config_t cfg WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(cfg));这三个步骤挨个过一遍底层才算就绪。但就绪不代表连上网真正的联网动作是通过“设置WiFi配置启动WiFi”来触发的。2.2 事件回调触发机制理解它才算入门WiFi连接是一个异步过程你不能傻等一个返回值。正确做法是注册事件回调函数在回调里监听不同事件类型。核心事件有两个WIFI_EVENT_STA_DISCONNECTEDWiFi断开或连接失败时触发。这里必须做重连处理不然路由器重启一次你的设备就永久掉线了。IP_EVENT_STA_GOT_IP成功拿到IP地址说明真正的网络连接已经建立此时才能去发起HTTP请求。这两个事件对应了“数据链路层通了”和“网络层通了”两个环节。我一开始只监听了GOT_IP结果发现如果WiFi密码错误程序就卡在重试死循环里连个错误日志都没有。后来加上DISCONNECTED事件的打印和重连计数才彻底解决。事件回调里还有个原则不要在回调函数里直接做耗时操作比如发HTTP请求、做JSON解析。回调的责任就是把状态记录下来告诉主任务“可以去干活了”。我是用一个全局标志位g_wifi_connected来标记状态主任务循环里检测到这个标志位后再执行网络请求。2.3 SSID和密码怎么处理更灵活一开始图省事直接把SSID和密码硬编码在代码里#define WIFI_SSID MyHomeWiFi #define WIFI_PASS password123这种方式对写Demo没问题但一旦换网络环境就得重新编译烧录麻烦。后来我把它们挪到了menuconfig里用Kconfig.projbuild定义一个配置项menu WiFi Configuration config WIFI_SSID string Router SSID default MyHomeWiFi config WIFI_PASS string Router Password default password123 endmenu然后在代码里用CONFIG_WIFI_SSID和CONFIG_WIFI_PASS引用。这样换WiFi时只需要跑一下idf.py menuconfig改几个字符编译烧录一步到位不用在代码里翻来翻去找宏定义。2.4 连接失败重试策略的工程化处理这是我在实际调试中体会最深的一点。第一次联网成功后我以为万事大吉结果路由器一重启设备直接变砖——WiFi明明断了程序却毫不知情傻愣愣地等天气数据。重连策略我最终这么设计记录连续失败次数retry_count。每次触发DISCONNECTED若重试次数小于10次调用esp_wifi_connect()重新连接。超过10次则重启芯片通过esp_restart()强制恢复。为什么选择“重启”而不是继续重试因为很多时候WiFi连不上是底层协议栈进入了异常状态软件层的重连已经救不回来了。重新初始化所有硬件是成本最低的恢复手段。这段逻辑用了一个while循环配合vTaskDelay来实现每500ms检查一次连接状态超时就重启。3. HTTP客户端请求天气数据3.1 为什么用esp_http_client而不是直接socket理论上你可以直接用BSD socket去拼HTTP请求字符串但那样要做太多底层脏活包括TCP连接管理、HTTP头部拼接、响应接收和超时处理。ESP-IDF自带的esp_http_client组件把这些都封装好了它支持GET/POST、HTTPS、超时设置、重定向跟随、事件回调等完整功能。用起来像什么像Python里的requests库但比requests还要“嵌入式化”一点——它把数据通过事件回调一块一块地吐给你而不是一次性给你一整包。我的使用方式是事件回调模式esp_http_client_config_t config { .url url_buf, .method HTTP_METHOD_GET, .event_handler http_event_handler, .timeout_ms 10000, .buffer_size 4096, }; esp_http_client_handle_t client esp_http_client_init(config); esp_err_t err esp_http_client_perform(client); esp_http_client_cleanup(client);这里有个关键技术点buffer_size的大小决定了单次HTTP响应能接收多大数据。天气API返回的JSON一般几百字节到几KB4096字节足够。如果你要请求的是图片或大文件这个值就得调到能覆盖响应体的大小。3.2 GET请求的URL怎么拼以心知天气为例请求URL长这样https://api.seniverse.com/v3/weather/now.json?keyYOUR_API_KEYlocationbeijinglanguagezh-Hansunitc这个URL里有几个参数要和代码解耦API Key、城市名、语言、温度单位。把这些参数放到menuconfig里配置代码中通过snprintf动态拼接URLchar url_buf[256]; snprintf(url_buf, sizeof(url_buf), %s?key%slocation%slanguage%sunit%s, CONFIG_WEATHER_API_URL, CONFIG_WEATHER_API_KEY, CONFIG_WEATHER_LOCATION, CONFIG_WEATHER_LANGUAGE, CONFIG_WEATHER_UNIT);注意这里snprintf的用法它比sprintf安全得多会自动截断过长的字符串不会造成缓冲区溢出。嵌入式开发里凡是往缓冲区写字符串一律用带n的版本函数。3.3 响应数据处理策略在http_event_handler里重点处理两个事件HTTP_EVENT_ON_DATA这是数据片段到达事件。需要把data指针里的内容追加到全局缓冲区里。HTTP_EVENT_ON_FINISH响应接收完毕。这里才能对完整数据做JSON解析。这里有一个非常容易踩的坑HTTP_EVENT_ON_DATA可能会被触发多次每次收到的只是完整数据的一个分片。所以不能简单地把数据交给解析函数而是要先拷贝到一个累加缓冲区等FINISH事件后再统一处理。另外还要注意缓冲区越界问题。我在全局定义了一个足够大的静态缓冲区每次累加时都检查剩余空间不够了就丢弃并打日志防止内存被写坏。if (evt-event_id HTTP_EVENT_ON_DATA) { int copy_len MIN(evt-data_len, sizeof(g_response_buf) - g_response_len - 1); memcpy(g_response_buf g_response_len, evt-data, copy_len); g_response_len copy_len; }3.4 关于HTTPS和证书的取舍默认情况下esp_http_client支持HTTPS请求但需要配置证书校验。有两种做法使用系统自带的证书包esp_crt_bundle_attach把整个CA证书集合打包进固件支持绝大多数公网HTTPS服务。使用cert_pem参数指定一个固定的服务器证书。我推荐用第一种方案。配置示例如下esp_http_client_config_t config { .url url_buf, .crt_bundle_attach esp_crt_bundle_attach, };这个方案的好处是如果你换了API服务商不需要重新配证书。代价是固件体积会增大几十KB但对ESP32来说完全无所谓。4. JSON解析与温度数据提取4.1 cJSON库JSON解析的标配ESP-IDF里内置了cJSON库这是专门为嵌入式环境优化的JSON解析库特点是轻量、快速、API简单。使用套路极其固定先cJSON_Parse()把字符串解析成树形结构再用cJSON_GetObjectItem()逐层取字段最后用cJSON_Delete()释放内存。关键点是每次解析完都要及时释放内存。cJSON解析出来的对象是动态分配的挂在堆上忘记删除的话跑几次HTTP请求内存就爆了。调试时可以用esp_get_free_heap_size()查看剩余堆内存确认没有泄漏。4.2 天气JSON结构解析实操心知天气的weather/now.json接口返回结构大致如下简化版{ results: [{ location: { name: 北京 }, now: { text: 晴, temperature: 26, humidity: 40% }, last_update: 2025-01-15T10:00:0008:00 }] }解析代码也很有代表性cJSON *root cJSON_Parse(g_response_buf); if (root NULL) { ESP_LOGE(TAG, JSON parse error); return; } cJSON *results cJSON_GetObjectItem(root, results); cJSON *first cJSON_GetArrayItem(results, 0); cJSON *now cJSON_GetObjectItem(first, now); const char *text cJSON_GetObjectItem(now, text)-valuestring; int temperature atoi(cJSON_GetObjectItem(now, temperature)-valuestring); ESP_LOGI(TAG, 当前位置天气%s温度%d℃, text, temperature); cJSON_Delete(root);这里我特意用了atoi把字符串温度转成整数。为什么不用atof因为温度值通常是整数或一位小数用整数存储省内存、显示方便。如果要保留小数精度再用strtof也不迟。4.3 防止解析NULL指针崩溃JSON解析最容易崩溃的方式是结构里某个字段不存在你依然去访问它的valuestring成员直接段错误重启。防御性写法是每个节点取出后立即判空cJSON *now cJSON_GetObjectItem(first, now); if (now NULL) { ESP_LOGE(TAG, now field missing); cJSON_Delete(root); return; }这个坑我在调API时真实遇到过——下午请求还好好的晚上服务商悄悄改了字段名程序直接崩溃重启。从那以后我写解析代码必加判空宁可多写几行不要崩溃重启。4.4 本地温度获取内置传感器与DS18B20远端的天气温度是从接口拿到的但为了和实际环境对比我还顺手做了本地温度读取。ESP32芯片内部是有个温度传感器的但精度实在一般误差在±5°C以上用来做趋势观察还可以做精确测量肯定不行。更实用的做法是外接DS18B20单总线温度传感器。关键点是需要使用一线总线OneWire协议代码稍微多几步。读取周期建议至少750ms因为DS18B20的转换时间就是这么多。注意上拉电阻不能省数据线要接4.7kΩ电阻到3.3V。我当时测试的环境温度26.3°C天气API报的北京温度是31°C差别大是因为两个数据源所处的地理位置和测量环境完全不同这个差异属于正常现象。5. 完整代码实现与编译烧录过程5.1 工程目录结构规划ESP-IDF工程有自己固定的目录结构要求建议规划成下面这样weather_station/ ├── CMakeLists.txt ├── sdkconfig.defaults ├── main/ │ ├── CMakeLists.txt │ ├── Kconfig.projbuild │ └── main.c顶层的CMakeLists.txt里只需要三行指明项目名字即可。核心逻辑都在main目录下。5.2 完整可运行代码核心版这次的核心代码拆成三个主要函数WiFi初始化、HTTP请求、JSON解析。下面是骨架代码已实测在ESP32-WROOM-32D ESP-IDF v5.1.2环境下编译通过#include stdio.h #include string.h #include freertos/FreeRTOS.h #include freertos/task.h #include esp_wifi.h #include esp_event.h #include esp_netif.h #include nvs_flash.h #include esp_http_client.h #include esp_crt_bundle.h #include cJSON.h #include esp_log.h static const char *TAG weather; static char g_response_buf[4096]; static int g_response_len 0; static bool g_wifi_connected false; static void http_event_handler(esp_http_client_event_t *evt) { switch (evt-event_id) { case HTTP_EVENT_ON_DATA: if (g_response_len evt-data_len sizeof(g_response_buf) - 1) { memcpy(g_response_buf g_response_len, evt-data, evt-data_len); g_response_len evt-data_len; } break; case HTTP_EVENT_ON_FINISH: g_response_buf[g_response_len] \0; break; default: break; } } static void wifi_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { if (event_base WIFI_EVENT event_id WIFI_EVENT_STA_START) { esp_wifi_connect(); } else if (event_base WIFI_EVENT event_id WIFI_EVENT_STA_DISCONNECTED) { ESP_LOGW(TAG, WiFi disconnected, retrying...); esp_wifi_connect(); } else if (event_base IP_EVENT event_id IP_EVENT_STA_GOT_IP) { ip_event_got_ip_t *event (ip_event_got_ip_t *)event_data; ESP_LOGI(TAG, Got IP: IPSTR, IP2STR(event-ip_info.ip)); g_wifi_connected true; } } static void wifi_init_sta(void) { esp_netif_create_default_wifi_sta(); wifi_init_config_t cfg WIFI_INIT_CONFIG_DEFAULT(); esp_wifi_init(cfg); esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL); esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL); wifi_config_t wifi_config { .sta { .ssid CONFIG_WIFI_SSID, .password CONFIG_WIFI_PASS, }, }; esp_wifi_set_mode(WIFI_MODE_STA); esp_wifi_set_config(WIFI_IF_STA, wifi_config); esp_wifi_start(); } static void fetch_weather(void) { if (!g_wifi_connected) return; char url_buf[256]; snprintf(url_buf, sizeof(url_buf), %s?key%slocation%slanguage%sunit%s, CONFIG_WEATHER_API_URL, CONFIG_WEATHER_API_KEY, CONFIG_WEATHER_LOCATION, CONFIG_WEATHER_LANGUAGE, CONFIG_WEATHER_UNIT); memset(g_response_buf, 0, sizeof(g_response_buf)); g_response_len 0; esp_http_client_config_t config { .url url_buf, .method HTTP_METHOD_GET, .event_handler http_event_handler, .timeout_ms 10000, .crt_bundle_attach esp_crt_bundle_attach, }; esp_http_client_handle_t client esp_http_client_init(config); esp_err_t err esp_http_client_perform(client); if (err ESP_OK) { parse_weather_json(g_response_buf); } else { ESP_LOGE(TAG, HTTP request failed: %s, esp_err_to_name(err)); } esp_http_client_cleanup(client); } static void parse_weather_json(const char *json) { cJSON *root cJSON_Parse(json); if (root NULL) { ESP_LOGE(TAG, JSON parse failed); return; } cJSON *results cJSON_GetObjectItem(root, results); cJSON *first cJSON_GetArrayItem(results, 0); if (first NULL) { ESP_LOGE(TAG, results array empty); cJSON_Delete(root); return; } cJSON *now cJSON_GetObjectItem(first, now); cJSON *text cJSON_GetObjectItem(now, text); cJSON *temp cJSON_GetObjectItem(now, temperature); if (text temp) { ESP_LOGI(TAG, 当前天气%s温度%s℃, text-valuestring, temp-valuestring); } cJSON_Delete(root); } void app_main(void) { esp_err_t ret nvs_flash_init(); if (ret ESP_ERR_NVS_NO_FREE_PAGES || ret ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); ret nvs_flash_init(); } ESP_ERROR_CHECK(ret); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); wifi_init_sta(); while (1) { if (g_wifi_connected) { fetch_weather(); } vTaskDelay(pdMS_TO_TICKS(60000)); // 每60秒刷新一次 } }上面这段代码已经具备完整可运行的条件parse_weather_json函数里遗漏了temp-valuestring字符串转数字的步骤实际使用按需求加atoi或atof即可。5.3 menuconfig关键配置项在main/Kconfig.projbuild里定义WiFi与天气参数后编译前要到idf.py menuconfig里把下面几项填好项目WiFi配置里的Router SSID、Router Password天气API里的API URL、API Key、Location、Language、Unit菜单里每个配置都有默认值直接回车进入对应子菜单修改并保存即可。这样你的设备换个环境只需要重新配置这两块区域业务代码一行都不用动。5.4 编译烧录与串口验证在工程根目录里依次执行idf.py set-target esp32 idf.py menuconfig idf.py build idf.py -p /dev/ttyUSB0 flash monitor编译过程中如果遇到组件版本警告多半是系统里缓存了旧版ESP-IDF环境变量检查一下IDF_PATH指向即可。串口监视器里如果看到Got IP和当前天气晴温度31℃之类的日志说明整条链路已经通了。6. 常见问题与排查技巧实录6.1 WiFi一直连接不上看不到Got IP首先要区分是“连不上路由器”还是“拿到了IP但上不了网”。排查顺序分别是确认SSID、密码是否配对。用手机开热点测试排除路由器MAC地址过滤等限制因素。查看串口日志里的WIFI_EVENT_STA_DISCONNECTED原因码0x201表示密码错误0x103表示AP没响应。如果反复循环重连考虑供电不足的问题。ESP32在WiFi发射瞬间电流可能冲到300mA以上劣质USB线或者电压降都会导致无线模块工作异常。我遇到过一次特别诡异的情况程序在办公室网络一切正常拿到自己工位附近就一直断开重连。后来发现是那个区域2.4GHz信道干扰太严重。解决办法是通过配置把WiFi信道固定到一个干净的频段不干扰的情况下问题就消失了。6.2 HTTP请求返回成功但JSON解析失败这种问题多半出在响应数据不是纯JSON这一点上。某些API服务商在返回内容里会夹带BOM头UTF-8文件头或者响应内容被gzip压缩了。处理办法检查Content-Encoding响应头如果是gzip需要在esp_http_client配置里开启disable_auto_redirect并用解压函数处理。给JSON字符串加一个“跳过非JSON字符”的前处理函数遇到第一个{才开始组装。另一个隐藏问题是响应数据在分片存储时可能有顺序问题我在调试时打印g_response_len发现偶发数据不完整。后来把缓冲区从1024字节扩大到4096情况明显好转。6.3 程序跑一段时间后死机或重启很大概率是内存泄漏。罪魁祸首往往是cJSON_Delete漏调用或者HTTP缓冲区重复分配未释放。定位方式很简单ESP_LOGI(TAG, Free heap: %d, esp_get_free_heap_size());在任务循环里打印剩余堆内存跑一段时间观察数值是否持续下降。如果每次请求后空闲堆都比上次少几百字节那就是泄漏了去检查所有动态内存分配函数是否成对出现。还有一个容易忽略的问题任务的栈大小。如果WiFi回调或者HTTP事件里的局部变量太大可能栈溢出系统会自动重启或者抛Task watchdog错误。防御做法是在xTaskCreate时预留足够的栈空间我一般设8KB起步复杂任务给12KB。6.4 中文天气现象显示乱码串口日志或OLED屏幕上显示乱码核心原因是编码不匹配。天气API返回的text字段是UTF-8编码如果你的屏幕驱动库默认用GB2312或者ASCII去解析中文就会变成乱码。解决思路有两种在API请求参数里指定languageen获取英文天气现象绕开中文编码。用支持UTF-8的点阵字库或者用十六进制转义序列直接匹配中文。对于最简单的玩法建议先用英文把功能跑通后再升级中文字库。6.5 ESP-IDF版本API差异不同版本的ESP-IDF之间API变化很大。比如v4.x使用的tcpip_adapter到v5.x完全替换为esp_netif。如果你看的老教程代码编译报错大概率是版本差异导致。我的建议是直接使用最新的稳定版ESP-IDF写新代码。v5.x的事件循环、WiFi配置方式都已经稳定生态也在向这个版本聚集。除非你维护的是老项目否则没必要迁就旧代码。6.6 刷新频率和网络请求次数权衡天气API免费版通常有每分钟或每天的请求次数限制。如果把刷新间隔设得太短比如5秒很快就耗尽免费额度而且频繁请求对路由器也是负担。我的实测建议天气数据刷新间隔设为10到30分钟足够本地温度可以每秒读一次。如果你需要更实时的天气数据可以改用NTP校时后定时请求但尽量在路由器配置里把设备的上网行为加白名单避免被误杀。在我这个项目里我把刷新间隔设定为10分钟然后在每次请求前检查时间只在整10分钟时才发起HTTP请求这样一天的请求次数刚好144次对免费额度来说非常安全。最后再说两句实在的整个项目从开始调环境到最终跑通花了不到一个完整的下午。最难的不是代码本身而是理解ESP-IDF里“事件驱动”和“回调机制”这种异步编程思路。只要跨过这个心理门槛后面再去做HTTP POST、MQTT、OTA升级全都能顺下来。后续我还打算把这个天气站加上OLED显示和实体按键把城市切换、刷新间隔调节都做成可交互的再挂上一个小风扇做个温控联动。这次先到这里篇幅已经够长了等我把下一版折腾出来再接着写。