简介:面向物联网嵌入式开发者的ESP32实战例程,基于Visual Studio Code与ESP-IDF工具链,采用C语言编写,演示STA模式下连接路由器AP热点的完整流程。例程在ESP32-S3上验证运行,代码内已定义外设接线,并配有详细注释,方便读者对照硬件快速移植,也适合进一步接入传感器或扩展功能;若需调整硬件,可依据注释快速定位对应引脚。压缩包共23个文件,以C源码和头文件为核心,辅以CMake构建脚本、VS Code调试配置、分区表CSV、SDK配置及README说明,整体仅47KB,目录结构清晰,便于按需查阅;main目录中直接放置主程序,BSP目录封装底层驱动,扩展新模块时可减少重复改动。目前已有265人学习下载,适合正在入门ESP32网络开发或需要现成WiFi连接参考代码的工程师与学生,可以直接基于此例程修改网络参数、调试AP连接过程,并作为后续物联网项目或课程设计的起点。
1. 为什么在 VS Code 里用 ESP-IDF C 代码做 ESP32-S3 的 WiFi STA 连接更接近产品
很多做物联网的工程师第一次接触 ESP32,习惯打开 Arduino IDE 写两行 WiFi.begin(),马上就能连上路由器。但这个例程偏偏放在 VS Code + ESP-IDF 环境里,用纯 C 写 STA 连接逻辑。我拆完这个 wifi_sta 例程后最大的感受是:Arduino 把 WiFi 栈包得太严,而 ESP-IDF 把事件驱动模型、内存管理、断线重连都露给你看。后面要接传感器、走 MQTT、做低功耗,这套骨架才兜得住。
例程跑在 ESP32-S3 上,目录里带 CMakeLists、sdkconfig、components、16MiB 分区表、main.c,.vscode 下也配置了任务与调试参数。适合两类人:刚学完 C 语言、想从 Arduino 往 RTOS 思路转的人;以及已经在用 ESP-IDF 但想确认 WiFi STA 事件处理怎么写得干净的工程师。
2. ESP-IDF 工程骨架:CMakeLists、sdkconfig 与 16MiB 分区表怎么配合
2.1 先看目录结构,components 和 BSP 为什么是独立目录
拿到例程后,先不要急着编译,把目录摊开看。顶层有 CMakeLists.txt、sdkconfig、components、partitions-16MiB.csv,main 目录下还有一份 CMakeLists.txt 和 main.c,.vscode里是编辑器任务配置。ESP-IDF 的构建系统会把所有子目录里的组件包进来,但 main 和 components 的职责不一样:main 是应用入口,components 放的是可以被复用的模块,这个例程里把 BSP 放在 components 里,而不是让 main.c 直接去操作 GPIO。
| 文件或目录 | 在工程里的角色 |
|---|---|
| CMakeLists.txt | 工程入口,告诉构建系统加载哪个项目 |
| sdkconfig | 保存 Kconfig 配置,如 flash 大小、分区表路径 |
| components/ | 独立模块,BSP 就放在这里 |
| partitions-16MiB.csv | 16MB flash 分区布局 |
| main/CMakeLists.txt | 注册 main 组件对应的源文件和依赖 |
| .vscode/ | 编译、烧录、调试任务的编辑器配置 |
很多新手把所有初始化、协议栈都写进 main.c,连引脚定义也硬编码在文件顶部,最后换一块屏就要复制整个工程。components 目录的作用就是把板级硬件相关代码拆出去,让主程序只调用“读温度”“初始化传感器”这类接口。这样后续做多个硬件版本时,只需替换 components 下的 BSP 组件,main 里的业务逻辑不用动。
2.2 分区表内容与 flash 大小的匹配
partitions-16MiB.csv 是例程默认的 16MB flash 分区布局。解读这份文件之前,你要先理解 ESP-IDF 的分区表机制:它是一张放在 flash 固定位置的 CSV 表,bootloader 和 app 都靠它才能知道自己的偏移量和大小。
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x500000, storage, data, spiffs, 0x510000, 0xAF0000,第一行 nvs 放在 0x9000,这是 bootloader 后面比较固定的位置;nvs 大小 0x6000,存 WiFi 校准、MAC 和用户 key-value。phy_init 是射频初始化数据,只有 0x1000。factory 从 0x10000 开始,给了 5MB,实际这个例程的固件只有几百 KB,留这么大的原因是后面要放 OTA 或大容量固件。最后的 storage 是 spiffs,用来存文件系统和传感器校准参数。
如果 flash 不是 16MB,这个分区表需要改。最常见的问题是:芯片只有 8MB flash,却使用了这个 csv,烧录时 esptool 会提示分区表超出 flash 范围。改法很简单,把 storage 的 size 减小,保证偏移量 + 大小 <= 芯片 flash 总容量即可。
要让这份 csv 真正生效,得同时告诉编译系统“用自定义分区表”和“flash 大小是 16MB”。我一般这样操作:
idf.py set-target esp32s3 idf.py menuconfig在 menuconfig 的 Serial flasher config 里把 Flash size 改成 16MB,再到 Partition Table 里选择 Custom partition CSV,并把文件名填成 partitions-16MiB.csv。退出保存后,build 目录里会重新生成 sdkconfig。sdkconfig 文件会记录这些选项,所以它通常会被加进编译依赖。如果只改分区表 CSV 不改 flash size,esptool.py 烧录时会按默认 4MB 处理,导致写进去的数据错乱。
提示:如果分区表与实际 flash 不匹配,烧录阶段不一定立刻报错,但运行到 OTA 或 spiffs 写入时会访问到错误地址。
2.3 CMakeLists 的组件注册方式
根目录 CMakeLists.txt 是工程入口:
cmake_minimum_required(VERSION 3.16) include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(wifi_sta)这里include($ENV{IDF_PATH}/tools/cmake/project.cmake)是官方构建脚本的位置,project(wifi_sta)会在当前目录下生成 build 系统。IDF_PATH由 VS Code 扩展或 export.bat 设置,所以如果你在普通终端里跑 idf.py 找不到命令,先检查这个环境变量。
main/CMakeLists.txt 的内容更贴近实际开发:
idf_component_register( SRCS "main.c" INCLUDE_DIRS "." REQUIRES nvs_flash esp_wifi esp_event esp_netif)SRCS 告诉构建系统 main 组件包含哪些源文件,INCLUDE_DIRS 是头文件搜索路径,REQUIRES 列出编译和链接阶段必须依赖的组件。这里把 nvs_flash、esp_wifi、esp_event、esp_netif 写全,主要是避免清理构建之后出现“隐式依赖找不到头文件”的问题。components 下的 BSP 组件也是同样的写法,只不过它的 SRCS 换成自己的源文件,再通过 REQUIRES 被 main 引用。
3. STA 模式连接路由器 AP:WiFi 事件循环与断线重连的 C 代码实现
3.1 先初始化 NVS,别跳过这一步
main.c 的入口是 app_main,代码不长,但顺序有讲究。
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) { nvs_flash_erase(); nvs_flash_init(); } wifi_sta_init(); }ESP-IDF 的 WiFi 驱动依赖非易失存储来保存射频校准数据、MAC 地址备份等。如果 NVS 分区被 app 固件升级搞坏,nvs_flash_init 会返回ESP_ERR_NVS_NO_FREE_PAGES或ESP_ERR_NVS_NEW_VERSION_FOUND。我一般在这里直接擦掉重新初始化,代价是之前存过的用户数据会丢,但例程阶段没有需要保留的数据。
3.2 事件循环:STA 连接不是阻塞调用
接下来是初始化函数。ESP-IDF 的 WiFi STA 模式没有“connect 然后阻塞直到成功”的同步 API,它把状态变化通过事件循环回调抛给你。初始化时先创建三个基础对象:netif、event loop、event group。
先看事件回调,它是整个 STA 逻辑的核心:
static const char *TAG = "wifi_sta"; static EventGroupHandle_t s_wifi_event_group; static esp_netif_t *s_sta_netif = NULL; #define WIFI_CONNECTED_BIT BIT0 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_LOGI(TAG, "STA started, connecting..."); esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { ESP_LOGW(TAG, "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)); xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); } }wifi_sta_init 函数把这几个对象串起来:
static void wifi_sta_init(void) { s_wifi_event_group = xEventGroupCreate(); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); s_sta_netif = esp_netif_create_default_wifi_sta(); wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); ESP_ERROR_CHECK(esp_event_handler_instance_register( WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL)); ESP_ERROR_CHECK(esp_event_handler_instance_register( IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL)); wifi_config_t wifi_config = { .sta = { .ssid = "ROUTER_SSID", .password = "ROUTER_PASSWORD", .threshold.authmode = WIFI_AUTH_WPA2_PSK, }, }; ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_start()); }esp_netif_init创建 netif 基础层,esp_event_loop_create_default提供事件循环。esp_netif_create_default_wifi_sta会创建一个连接到 WiFi STA 驱动的事件源,返回值要保存,后面配置静态 IP 要用。WIFI_INIT_CONFIG_DEFAULT是宏,用来填充 wifi_init_config_t,包括收发缓冲区大小、静态 RX/TX buffer 数量;例程场景用默认值即可。
| 事件名 | 触发时机 | 例程里的动作 |
|---|---|---|
| WIFI_EVENT_STA_START | STA 驱动启动完成,还没有发起连接 | 主动 esp_wifi_connect() |
| WIFI_EVENT_STA_DISCONNECTED | 连接断开、密码错误、AP 消失 | 再次 esp_wifi_connect() |
| IP_EVENT_STA_GOT_IP | DHCP 成功分配 IP | 记录 IP,置位 event group |
很多教程只在 START 事件里调 connect,没有处理 DISCONNECTED。实际路由重启、信号抖动非常常见。直接在这个事件里再次调用 esp_wifi_connect 是简单做法,ESP32 的 WiFi 驱动内部对频繁连接有退避,不会立刻把 AP 打崩。如果产品要控制重连次数,这个例程的事件结构已经留好了扩展点:在 DISCONNECTED 分支里加一个计数器。
3.3 wifi_config_t 的参数含义
esp_wifi_set_config里的参数是 WiFi STA 连接成败的关键。例程中 SSID 和密码写死在结构体里:
| 字段 | 说明 |
|---|---|
| ssid | 路由器 SSID,最大 32 字节,需要与字符数组长度匹配 |
| password | 密码,最大 64 字节;对开放网络可留空 |
| threshold.authmode | 允许的最低认证方式,设为 WPA2_PSK 表示低于 WPA2 的路由不连 |
由于.sta内部的 ssid 和 password 是定长 char 数组,不是指针,所以可以直接给字符串字面量;结构体整体{ .sta = { ... } }会先把剩余字节清零,避免没有终止符。如果路由器是中文 SSID,需要先转成 UTF-8 字节数组,直接写字符串在某些字符集下会有问题。
调用esp_wifi_set_mode(WIFI_MODE_STA)必须在esp_wifi_set_config之前,不然会返回ESP_ERR_WIFI_MODE。调用esp_wifi_start后事件回调才会开始收到WIFI_EVENT_STA_START。
4. VS Code 下编译烧录与串口日志:tasks.json、launch.json 和常见报错
4.1 先确认扩展和目标芯片
这个例程的 .vscode 目录默认针对 ESP32-S3。安装 Espressif IDF 扩展后,扩展会自动读取 IDF_PATH。第一次编译前,建议在 VS Code 命令面板里执行ESP-IDF: Set Espressif Device Target,选 esp32s3。不要直接跳过这一步,否则 sdkconfig 里的芯片配置还是旧型号。
settings.json 里至少需要确认端口:
{ "idf.port": "COM3", "idf.adapterTargetName": "esp32s3" }idf.port是烧录串口,Windows 下通常 COM3/COM4,macOS 下是 /dev/cu.usbmodem*。idf.adapterTargetName告诉扩展用哪个芯片的 openocd/jtag 配置,跑纯 WiFi 例程时主要影响编译宏,如果设置成 esp32,API 大部分通用,但链接时会少掉 S3 专有外设的寄存器声明。
4.2 tasks.json 构建任务
tasks.json 的作用是把命令面板里的 build/flash 变成可重复执行的 VS Code 任务。例程中常见的配置是:
{ "version": "2.0.0", "tasks": [ { "label": "build", "type": "shell", "command": "idf.py build", "options": { "cwd": "${workspaceFolder}" } }, { "label": "flash-monitor", "type": "shell", "command": "idf.py -p ${config:idf.port} flash monitor", "options": { "cwd": "${workspaceFolder}" } } ] }这里${config:idf.port}会从 settings.json 自动读串口号,所以不需要在命令里写死。flash monitor在烧录完成后直接打开串口监视器,退出监视器按 Ctrl+] 即可回到终端。注意 monitor 会独占串口,如果再开一个终端执行 flash 任务,会报Failed to open port。
4.3 launch.json 调试与日志排错
如果要打断点看 WiFi 事件回调里的变量,需要 launch.json。例程目录下保留了调试入口,核心内容大致是:
{ "version": "0.2.0", "configurations": [ { "name": "ESP32-S3 Debug", "type": "esp-idf", "request": "launch", "MIMode": "gdb", "target": "${command:extensionId.pickTarget}", "port": "3333", "gdbpath": "${command:extensionId.getGdbPath}" } ] }port: 3333是 OpenOCD 默认的 gdb 端口。调试前要烧录一次固件,再启动 OpenOCD/JTAG 连接。实际调试中如果串口监视器还开着,JTAG 用的调试引脚会和串口打印冲突,常见表现形式是 gdb 连接后立刻断开。我一般先关掉 monitor,再点调试。
不用 JTAG 时,WiFi 例程出错主要靠串口日志。下面是这个例程最容易遇到的几个问题:
| 日志或现象 | 原因 | 处理 |
|---|---|---|
idf.pynot found | 当前终端没有加载 ESP-IDF 环境 | 用 VS Code 扩展内置终端,或手动执行 export.bat/export.sh |
Failed to open port COM3 | 串口被 monitor 占用,或驱动没装 | 关掉所有串口工具,重新插拔 USB |
Invalid chip type | target 不是 esp32s3 | 重新执行 set-target esp32s3 并清理 build |
| STA disconnect 反复出现 | 密码或 SSID 不对 | 先用手机热点验证,再确认 SSID 中没有不可见字符 |
ESP_ERR_WIFI_NOT_STARTED | connect 调用早于 start 事件 | 只在 STA_START/DISCONNECTED 回调里调用 connect |
第一条在 Windows 上最常见,因为你可能在系统 PowerShell 里直接敲 idf.py,而不是在 ESP-IDF CMD 终端里。VS Code 扩展会在任务终端中自动激活环境,所以用任务构建一般不会触发。第三条 Invalid chip type 通常发生在旧工程换到 S3 时,build 目录残留了旧目标文件,执行一次idf.py fullclean再 set-target 最稳妥。
5. 进阶技巧:静态 IP 与快速重连,让 STA 连接在 ESP32-S3 上更稳
5.1 用静态 IP 固定设备地址
如果 ESP32-S3 上电后要上报传感器数据到局域网服务器,DHCP 分配的地址每次可能变,上位机就很难维护设备列表。不要用“绑定 MAC 到固定 IP”这种依赖路由器的方案,直接在设备里禁用 DHCP 并设置静态 IP。
前提是wifi_sta_init里保存了s_sta_netif,在完成 netif 创建后、esp_wifi_start前,加入这段:
esp_netif_ip_info_t ip_info = {0}; esp_netif_set_ip4_addr(&ip_info.ip, 192, 168, 1, 50); esp_netif_set_ip4_addr(&ip_info.netmask, 255, 255, 255, 0); esp_netif_set_ip4_addr(&ip_info.gw, 192, 168, 1, 1); ESP_ERROR_CHECK(esp_netif_dhcpc_stop(s_sta_netif)); ESP_ERROR_CHECK(esp_netif_set_ip_info(s_sta_netif, &ip_info));esp_netif_dhcpc_stop要放在esp_netif_set_ip_info前面,否则 DHCP 客户端可能覆盖掉手写 IP。设置完成后,GOT_IP 事件仍然会触发,因为 netif 层认为 IP 已经可用;如果日志里没有 GOT_IP,检查静态 IP 地址和网关是否和路由在同一个网段。
5.2 用事件位通知业务层,而不是在回调里开线程
最后给一个常用技巧:业务任务等待连接完成。在传感器采集或 MQTT 初始化任务里加一行:
xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT, false, true, portMAX_DELAY); ESP_LOGI(TAG, "wifi linked, start sensor task");事件组位WIFI_CONNECTED_BIT在每次 GOT_IP 回调里置位,业务线程等待到该事件后再初始化 MQTT 或传感器,避免上电时序错乱。如果想支持断线重连后的业务恢复,可以在 DISCONNECTED 回调里用xEventGroupClearBits清除该位,这样业务线程会被阻塞,等下次 GOT_IP 再继续。这是把事件循环和业务解耦最直接的方法,不必在回调里创建 FreeRTOS 任务。改完这两处后重新编译烧录,拔掉路由器电源再插回,观察串口日志,如果静态 IP 恢复时间小于一次 DHCP 完整重协商,说明这步配置已经生效。
本文还有配套的精品资源,点击获取