ESP32-S3 自带 Wi-Fi。本章讲清楚:
①Wi-Fi 协议栈在芯片里的分层(LwIP/TCP-IP/事件);
②STA(连接路由器)模式的完整流程与事件驱动模型;
③与前面章节的整合思路(如通过手机控制电机);
④常见问题。目标不是让你成为网络专家,而是能写出第一个能上网的程序。
16.1 芯片里的网络分层(先建立地图)
一个网络请求从应用到天线,经过多层。ESP-IDF 帮你把下面这些层都封装好了:
你的应用(HTTP 请求 / MQTT 消息) │ LwIP(TCP/IP 协议栈:TCP/UDP/IP 分包) │ esp_netif(网络接口抽象:WiFi/以太网) │ esp_wifi(WiFi 驱动:连接路由器、收发无线帧) │ 射频硬件(天线)你主要打交道的是事件(esp_event):WiFi 连上/断开、拿到 IP 地址,
系统都会发出事件,你注册回调处理即可。
【硬件前提】ESP32-S3 只有 2.4GHz 的无线电。
S3 的 Wi-Fi 只支持2.4GHz(802.11 b/g/n),不支持 5GHz。
如果家里路由器是"仅 5GHz"的网络(或 2.4G 频段被关掉/隐藏了 SSID),
芯片扫遍信道也找不到它,症状就是日志里反复出现W (2311) wifi_sta: 断开,原因码 201(201 = NO_AP_FOUND,“没看到这个 AP”)。
遇到 201 先怀疑频段和 SSID,不要怀疑板子坏了:
到路由器后台打开 2.4GHz 频段(或单独给 2.4G 起一个 SSID 名),或者干脆用手机热点
开 2.4GHz 模式来测——这是本章所有示例的第一道关卡。
【三个词先说人话】
- SSID:路由器"报家门"的名字,一个字符串标签,区分大小写;
- IP:设备在局域网里的门牌号,别人要找你就报这个号(IPv4 就是四段数字,如 192.168.1.105);
- DHCP:路由器自动发门牌号的流程——设备喊一声"给我一个号",路由器回一个空闲 IP(顺带给出网关和 DNS)。
串成一句话:凭 SSID 找到路由器 → 对暗号(密码)→ DHCP 领到 IP → 这才算上网。
注意"领到 IP"只代表你和路由器之间这条链路通了,不代表外网可达
(DNS、网关、运营商都可能出问题,见 16.6 的对应行)。
16.2 STA 模式:连接家里的路由器
流程(先看全局)
① nvs_flash_init(WiFi 驱动要用 NVS 存射频校准数据。注意:只是校准数据! SSID/密码默认并不存在 NVS 里,要应用自己持久化,见 16.6 的说明) ② esp_netif_init + esp_event_loop_create_default(网络与事件系统) ③ esp_netif_create_default_wifi_sta(创建 STA 网络接口) ④ esp_wifi_init(初始化 WiFi 驱动) ⑤ 注册事件回调(WIFI_EVENT_STA_*、IP_EVENT_STA_GOT_IP) ⑥ esp_wifi_set_mode(WIFI_MODE_STA) ⑦ esp_wifi_set_config(配置 SSID/密码) ⑧ esp_wifi_start → 驱动开始扫描连接 ⑨ 收到 IP_EVENT_STA_GOT_IP → 局域网这条路通了(能不能出外网还要看 DNS/网关,见 16.6)完整示例(事件驱动写法,可直接抄进工程)
下面是一份完整的main/main.cpp:新建工程、把它替换进去、改两个宏,
再按后面的 CMakeLists 配好,就能编译烧录(对照第 3 章的工程骨架)。
这一版比"最小骨架"多了三样东西,全都是踩坑修正:
- include 列表补全(原骨架缺
esp_log.h和 FreeRTOS 头文件,编译不过); - SSID/密码用编译期长度校验写入,不再用
strcpy往固定数组里硬塞; - 断线重连不在事件回调里睡 3 秒(那是全书反复禁止的做法),
而是回调只投队列、由专门的任务做指数退避重连。
// main/main.cpp —— ESP32-S3 Wi-Fi STA 连接示例(v6.0.1 实测口径)#include<string.h>#include"esp_event.h"#include"esp_log.h"#include"esp_netif.h"#include"esp_wifi.h"#include"esp_wifi_default.h"// esp_netif_create_default_wifi_sta() 的声明在这里#include"freertos/FreeRTOS.h"#include"freertos/queue.h"#include"freertos/task.h"#include"nvs_flash.h"staticconstchar*TAG="wifi_sta";// ------- 改这两行就能连你家路由器 -------#defineWIFI_SSID"你的路由器名"#defineWIFI_PASS"你的密码"// 编译期长度校验。wifi_config_t 里 ssid 是 uint8_t[32]、password 是 uint8_t[64]// (见 esp_wifi_types_generic.h),不写校验、直接 strcpy 进去,一旦超长就是// 越界写内存——程序当场不崩,之后在莫名其妙的地方崩,最难查的那种。// sizeof 宏字符串时把结尾 '\0' 也算 1 字节,所以 SSID 实际最长 31 字符、// 密码最长 63 字符;超了直接在编译期报错,根本到不了运行。// sizeof 里的 ((wifi_config_t *)nullptr)->sta.ssid 只求类型尺寸、不求值,// 不会真的解引用空指针,这是 C/C++ 老手也常用的取成员尺寸写法。static_assert(sizeof(WIFI_SSID)<=sizeof(((wifi_config_t*)nullptr)->sta.ssid),"SSID 太长:加上结尾零字节最多 32 字节");static_assert(sizeof(WIFI_PASS)<=sizeof(((wifi_config_t*)nullptr)->sta.password),"密码太长:加上结尾零字节最多 64 字节");// 回调和重连任务之间用队列传消息——第 13 章的老朋友typedefenum{WIFI_EVT_DISCONNECTED,// "断线了,该重连"WIFI_EVT_GOT_IP,// "连上了,退避计时清零"}wifi_evt_t;staticQueueHandle_t s_evt_queue=NULL;staticvoidon_wifi_event(void*arg,esp_event_base_t base,int32_tid,void*data){if(base==WIFI_EVENT&&id==WIFI_EVENT_STA_START){esp_wifi_connect();// 驱动就绪,发起第一次连接}elseif(base==WIFI_EVENT&&id==WIFI_EVENT_STA_CONNECTED){ESP_LOGI(TAG,"已连上路由器(等待 IP)");}elseif(base==WIFI_EVENT&&id==WIFI_EVENT_STA_DISCONNECTED){wifi_event_sta_disconnected_t*e=(wifi_event_sta_disconnected_t*)data;// 原因码很有诊断价值(esp_wifi_types_generic.h):// 201=WIFI_REASON_NO_AP_FOUND(SSID 错/信号不到)、// 202=WIFI_REASON_AUTH_FAIL(多半密码错)、203=关联失败ESP_LOGW(TAG,"断开,原因码 %d",e->reason);// 铁律:回调里绝不 vTaskDelay!默认事件循环只有一个任务,// 你在这里睡 3 秒,全系统所有事件都停 3 秒——和第 13/15 章// "回调要快、重活交给任务"的原则直接冲突。// 这里只做一件事:往队列投一条消息,超时给 0(不等待)。wifi_evt_t evt=WIFI_EVT_DISCONNECTED;xQueueSend(s_evt_queue,&evt,0);}}staticvoidon_ip_event(void*arg,esp_event_base_t base,int32_tid,void*data){if(base==IP_EVENT&&id==IP_EVENT_STA_GOT_IP){ip_event_got_ip_t*e=(ip_event_got_ip_t*)data;ESP_LOGI(TAG,"拿到 IP: "IPSTR,IP2STR(&e->ip_info.ip));wifi_evt_t evt=WIFI_EVT_GOT_IP;xQueueSend(s_evt_queue,&evt,0);// 通知重连任务:链路已恢复// 到这里"局域网这条路"通了;能不能出外网还要看 DNS/网关(16.6)}}// 专门的重连任务:睡在这里不阻塞任何人staticvoidwifi_manager_task(void*arg){TickType_t backoff=pdMS_TO_TICKS(1000);// 初始退避 1 秒constTickType_t kMaxBackoff=pdMS_TO_TICKS(60000);// 封顶 60 秒wifi_evt_t evt;while(true){// 一直等消息,来一条处理一条(第 13 章的阻塞收队)xQueueReceive(s_evt_queue,&evt,portMAX_DELAY);if(evt==WIFI_EVT_GOT_IP){backoff=pdMS_TO_TICKS(1000);// 连上了,退避归零,下次从头算continue;}ESP_LOGW(TAG,"%u ms 后尝试重连",(unsigned)(backoff*portTICK_PERIOD_MS));vTaskDelay(backoff);// 睡在任务里,事件循环毫无压力esp_wifi_connect();// 指数退避:1s → 2s → 4s → … 最多 60s,避免路由器长时间挂掉时// 设备 1 秒一次地猛 reconnect(费电、刷屏、还可能挤占扫描)backoff=(backoff*2>kMaxBackoff)?kMaxBackoff:backoff*2;}}extern"C"voidapp_main(void){ESP_ERROR_CHECK(nvs_flash_init());// ① NVSESP_ERROR_CHECK(esp_netif_init());// ② 网络ESP_ERROR_CHECK(esp_event_loop_create_default());esp_netif_create_default_wifi_sta();// ③ 接口// 队列和重连任务要在 esp_wifi_start() 之前就绪,// 否则启动瞬间的断线事件来了却没人接s_evt_queue=xQueueCreate(8,sizeof(wifi_evt_t));xTaskCreate(wifi_manager_task,"wifi_mgr",4096,NULL,5,NULL);wifi_init_config_t ic=WIFI_INIT_CONFIG_DEFAULT();ESP_ERROR_CHECK(esp_wifi_init(&ic));// ④ 驱动// ⑤ 事件回调ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT,ESP_EVENT_ANY_ID,on_wifi_event,NULL));ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT,IP_EVENT_STA_GOT_IP,on_ip_event,NULL));wifi_config_t cfg={};// 先清零,两个数组的剩余字节全为 0memcpy(cfg.sta.ssid,WIFI_SSID,sizeof(WIFI_SSID));// 长度已由memcpy(cfg.sta.password,WIFI_PASS,sizeof(WIFI_PASS));// static_assert 把关ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));// ⑥ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA,&cfg));// ⑦ESP_ERROR_CHECK(esp_wifi_start());// ⑧// 之后一切靠事件驱动 + 重连任务,app_main 可以结束}配套main/CMakeLists.txt(少了任何一项都链接不过):
idf_component_register( SRCS "main.cpp" INCLUDE_DIRS "." REQUIRES nvs_flash esp_wifi esp_netif esp_event )
esp_log.h和freertos/*不用写进 REQUIRES:v6 里log、freertos
属于"公共依赖"(每个组件自动获得),这和第 3 章说driver已拆分不矛盾——
被拆掉的只是外设驱动,事件循环、Wi-Fi、NVS 这些仍要显式 REQUIRES。
抄完第一件事:替换那两个中文占位符
#define WIFI_SSID "你的路由器名"和#define WIFI_PASS "你的密码"里的中文
只是占位符,必须整对引号换成真值,四个细节最容易翻车:
- SSID区分大小写:
Home和home在空口上是两个不同的网络; - 肉眼难分的字符别抄错:
0(零)≠O、1(一)≠l(小写 L)、B≠8; - 引号内结尾不要留空格:
"MyWiFi "和"MyWiFi"也是两个网络; - 中文 SSID 能连,但编码坑多(下面另有一小节),测试期先起个英文名最省事。
改完必须重新idf.py build——宏是编译期展开进二进制的,不重编等于没改。
【动手框】
① 在哪执行:新建一个空工程(第 3 章骨架,或idf.py create-project wifi_sta),
上面的示例存成main/main.cpp、main/CMakeLists.txt换成上面那份;
在IDF 终端里cd到工程根目录(含CMakeLists.txt的那层)。
② 敲什么:idf.py set-target esp32s3 idf.py build idf.py-pCOM3 flash monitor③ 预期看到:编译末尾(文件名跟着你
project(...)里起的工程名走;
这个示例代码没几行,bin 却有 754KB(0xb82d0)——Wi-Fi 协议栈本身就是这么大一块,
对照第 5 章 l298n 工程的 172KB 就能感受到 16.5 说的那句"WiFi 很吃内存/体积"):wifi_sta_demo.bin binary size 0xb82d0 bytes. Smallest app partition is 0x100000 bytes. 0x47d30 bytes (28%) free. Project build complete. To flash, run: idf.py flash烧录末尾
Hash of data verified,串口输出见下面"跑起来应该看到什么"。
④ 没看到:a) 编译报esp_netif_create_default_wifi_sta was not declared
→ 去本节"头文件迷宫"那张表;b) 报端口占用 /PermissionError(13)
→ 关掉另一个监视器或串口助手(2.6 那张表的对应行);
c) 反复原因码 201→ 回到 16.1 末尾的2.4GHz 硬件前提框。
跑起来应该看到什么
下面是这一路走下来该看到的片段(每行的格式都按 v6.0.1 本地源码/预编译库里的
日志语句核对过;毫秒数、MAC、任务句柄、信道、RSSI、IP 会因环境不同而不同,
要对的是行的内容和先后顺序:驱动起来 → 握手 → 已连上路由器 → 拿到 IP):
I (749) main_task: Calling app_main() ← 进入你的 app_main(第 2 章 2.9) (nvs_flash_init 成功时不打日志: 静默通过就是正常) I (1021) wifi:wifi driver task: 3fceca64, prio:23, stack:6656, core=0 ← ④ esp_wifi_init I (1035) wifi:mode : sta (68:ee:xx:xx:xx:xx) ← ⑥ 模式 = STA,括号里是本机 MAC I (2312) wifi:new:<6,0>, old:<1,0>, ap:<255,255>, sta:<6,0>, prof:1, snd_ch_cfg:0x0 I (2313) wifi:state: init -> auth (0xb0) ← 802.11 三步握手开始(对暗号) I (2320) wifi:state: auth -> assoc (0x0) I (2331) wifi:state: assoc -> run (0x10) I (2335) wifi:connected with myhome, aid = 4, channel 6, BW20, bssid = cc:bb:xx:xx:xx:xx I (2336) wifi:security: WPA2-PSK, phy: bgn, rssi: -53, cipher(pairwise:0x3, group:0x3), pmf:0 I (2340) wifi_sta: 已连上路由器(等待 IP) ← 我们的回调:CONNECTED 事件 I (3012) esp_netif_handlers: sta ip: 192.168.1.105, mask: 255.255.255.0, gw: 192.168.1.1 I (3012) wifi_sta: 拿到 IP: 192.168.1.105 ← GOT_IP:DHCP 领到门牌号- 带
wifi_sta的两行是我们自己的ESP_LOGI(TAG 来自代码);
带wifi:的是驱动打的(TAG 就叫wifi,别以为是自己的日志),读个关键词就行; state: init -> auth -> assoc -> run走通、并出现connected with <你的 SSID>,
就说明 SSID 找对了、密码也对(密码错会停在 auth 前并报原因码 202/203);- 最后一行之后程序就没事了(本示例只做连接),监视器还在滚,
按Ctrl+]退出监视器(2.8 第 4 步的老规矩;退出后才能再次烧录)。 - 想验证"网络真的能用":把
esp_wifi_start()之后的逻辑换成 16.3 说的 HTTP 请求,
或者先到路由器管理页看这台设备有没有出现在客户端列表里。
头文件迷宫:esp_netif_defaults.h ≠ esp_wifi_default.h
上例最容易抄错的是那两个"长得像双胞胎"的头文件,这里一次讲清(v6.0.1 本地源码核实):
| 头文件 | 住在哪个组件 | 里面有什么 |
|---|---|---|
esp_netif_defaults.h(注意复数) | esp_netif | 只有一堆默认配置宏/结构体(如ESP_NETIF_DEFAULT_WIFI_STA()),没有esp_netif_create_default_wifi_sta()这个函数 |
esp_wifi_default.h(单数) | esp_wifi | esp_netif_create_default_wifi_sta()/esp_netif_create_default_wifi_ap()的真正声明在这里 |
而且esp_wifi.h内部已经#include "esp_wifi_default.h",
所以只写esp_wifi.h其实也能编过——但我们建议显式写上esp_wifi_default.h:
你用了哪个函数,就 include 哪个头,别依赖"搭便车",将来重构时不会一脸茫然。
(网上不少教程/旧代码把函数声明记到esp_netif_defaults.h头上,报
“was not declared in this scope” 时先来这里对答案。)
中文 SSID 小提醒
SSID 支持中文,但它只是一坨字节:路由器用 UTF-8 还是 GBK 编码各家不一,
一个汉字 UTF-8 占 3 字节、GBK 占 2 字节。设备扫到"乱码"SSID 而连不上时,
先怀疑编码,不是硬件坏了;家里路由器起个英文名能省去 90% 的麻烦。
(上面"抄完第一件事"那段讲的是占位符替换的坑:大小写、0/O、结尾空格;
这里讲的是路由器那边 SSID 本身是中文时的坑。两回事,别混。)
事件驱动模型的本质
不是"程序顺序等到连上",而是:注册回调 → 系统在事件发生时调用你。
这就是嵌入式/网络的通用模型:不要等,等会来叫你。
(和第 15 章"ISR 通知任务"是同一个思想。)
16.3 拿到了 IP 之后能干什么(三个方向)
先把这节出现的四个缩写说清(各一句话,够用):
- HTTP:浏览器/客户端和服务器"一问一答"的请求协议,
GET /api/speed?value=60就是它; - TCP/UDP:更底层的两条管道——TCP 保证顺序和送达(打电话),UDP 只管扔出去(广播喊话);
- WebSocket:把 HTTP 的"一问一答"升级成一条长期双向的管道,服务器能主动推消息给设备;
- MQTT:物联网专用的"订阅频道"协议——设备往
motor/cmd这个主题发消息,
订阅了它的谁都收得到,协议头比 HTTP 小得多,很适合带宽差的设备。
三个方向:
- HTTP 客户端:
esp_http_client(内置组件)向服务器发请求; - TCP/UDP:直接和上位机/手机 App 通信(自定义协议);
- HTTP 服务器 / WebSocket:设备自己当服务器,手机浏览器访问。
例:把电机项目升级成"手机遥控小车":
手机 → HTTP/WebSocket → ESP32 → 解析指令 → motor.setSpeed(…)。
网络任务(收指令)和电机任务(执行)之间用队列通信(第 13 章)。
16.4 配网:设备第一次怎么知道密码
真实产品不能让用户改代码。常见方案:
先补两个词:AP(Access Point,热点)是"别人来连我"的那个角色;
SoftAP就是用芯片的软件做出一个 AP(相对"硬件 AP"而言),
也就是让开发板自己变成一个 Wi-Fi 热点。STA 和 AP 两种角色 S3 可以同时担任。
| 方案 | 做法 |
|---|---|
| SmartConfig(乐鑫方案) | 手机 App 用 UDP 广播 SSID/密码,设备监听捕获 |
| SoftAP 配网 | 设备自己开热点(上面的 SoftAP),手机连上后填密码提交 |
| BLE 配网 | 手机通过蓝牙把配置发给设备(S3 支持) |
| 预置 | 出厂预烧一个默认 WiFi 配置 |
入门先做"预置 + 代码里改密码",能跑通后再学 SmartConfig。
16.5 资源与注意
- WiFi 使用 NVS 存储射频校准数据:必须先
nvs_flash_init(否则启动报错);
再强调一遍,NVS 里存的是校准数据,不是你写的 SSID/密码; - WiFi 会占用较多内存(约 200KB+),大内存应用注意和 PSRAM 配合;
- 天线/射频:天线区域不要被金属遮挡;天线下方尽量留空;
- 看门狗:WiFi 连接是异步的,不要在 app_main 里死等;
- 断线重连:回调里只投消息,重连在专门任务里做指数退避——
16.2 的完整示例就是这么写的,别在事件回调里 vTaskDelay。
16.6 常见问题速查
| 现象 | 原因 | 解决 |
|---|---|---|
nvs_flash_init失败 | NVS 分区没建/损坏 | 检查分区表、擦除 Flash |
日志反复断开,原因码 201(NO_AP_FOUND),怎么都扫不到网络 | 路由器只有 5GHz、SSID 隐藏、或 SSID 名字抄错(大小写/0 与 O/结尾空格) | 先看 16.1 末尾的2.4GHz 硬件前提框:开 2.4G 频段或用手机热点 2.4G 模式测;再核对WIFI_SSID的真值 |
| 连不上路由器 | SSID/密码错(原因码 202 多是密码错)、信道问题 | 核对配置、看 WIFI_EVENT 日志里的原因码 |
| 拿到 IP 但请求失败 | DNS/网关问题(DNS=把域名翻译成 IP 的电话簿,路由器没给好或外网不通都会栽在这) | 用 IP 直连测试;检查路由器 |
| 重启后又要填 WiFi 密码 | 以为"esp_wifi 默认把配置存 NVS 了" | 误会:esp_wifi 存进 NVS 的只有射频校准数据,SSID/密码存在 RAM 里的wifi_config_t,断电即忘。想不丢就自己持久化:把凭据写进 NVS(第 14 章 nvs_handle 的用法),开机读出来再esp_wifi_set_config |
| 启动很慢 | 扫描信道 | 固定信道可加速 |
16.7 想一想 + 小测验
想一想:
- 为什么
esp_wifi_start()之后 app_main 可以结束?(事件驱动) - 设备连上路由器后,靠什么确认"网络真的可用"?
- 手机遥控小车的消息流里,队列放在哪一层?
小测验:
- WiFi 初始化前必须先初始化哪个存储?(A. PSRAM B. NVS C. SRAM)
- 拿到 IP 的事件是?(A. WIFI_EVENT_STA_START B. IP_EVENT_STA_GOT_IP C. WIFI_EVENT_AP_START)
- 断线重连的正确姿势是?(A. 死等 B. 定时重连/退避 C. 重启)
- 事件驱动模型的本质是什么?
16.8 本章总结
- 分层:应用 → LwIP → esp_netif → esp_wifi → 射频;
- 硬件前提:S3 只有 2.4GHz,路由器不给开 2.4G 就永远扫不到(原因码 201);
- STA 流程:NVS → netif → 事件 → 模式 → 配置 → start → 等 GOT_IP;
- 事件驱动:注册回调,别死等;
- 拿到 IP 后可用 HTTP/TCP/MQTT,可把电机升级成遥控小车;
- 配网方案:SmartConfig / SoftAP / BLE,产品级必配。
下一章:把工程做"大"——组件化与工程进阶。