让 ESP32-S3 当个网页服务器:手机网页控硬件
- 用 esp_http_server(httpd)让 ESP32-S3 开一个网页,手机浏览器直接打开
- 看懂 httpd_start / httpd_register_uri_handler / handler 返回 HTML 这条路由链
- 在 handler 里用 gpio_set_level 控制硬件,用 query 参数取滑块/按钮值
- 彻底理解局域网控制和云控制各自适合什么场景
你想用手机控制刚做好的硬件。第一反应可能是"得写个 App 吧"——安卓还是 iOS、要不要上架、怎么和设备通信,光想就劝退。其实有个快得多的办法:让 ESP32-S3 自己开个网页。手机连同一个 WiFi,浏览器输入它的地址,一个带按钮的页面就出来了,点一下灯就亮。
没有 App、没有云、没有账号,一个 handler 函数起步。这是局域网里控制硬件最快的路子,也是你理解"设备如何和手机对话"的最好切入点。读这篇前,你的 ESP32-S3 得能正常连上 WiFi——还没搞定先看让 ESP32-S3 连上 WiFi。本篇直接复用那一篇的 wifi_init_sta() 骨架,连上拿到 IP 之后,才开 HTTP 服务器。
原理:ESP32-S3 同时是设备,也是网站
平时你访问的网站,背后是某台服务器在响应请求。这里的关键转变是:ESP32-S3 自己就当那台服务器。
它连上 WiFi 后,路由器会分给它一个局域网 IP(比如 192.168.1.50)。这之后整条链路是这样的:
手机浏览器 ──(访问 192.168.1.50)──→ ESP32-S3
│ root_handler 返回一段 HTML(带按钮的网页)
手机显示网页 ←──────────────────────────┘
你点"开灯"按钮 ──(访问 192.168.1.50/on)──→ ESP32-S3
│ 收到 /on,on_handler 拉高 GPIO
灯亮,返回一句"已开灯" ←──────────────────────┘
拆开看就两件事:手机发一个 HTTP GET 请求,ESP32-S3 根据请求的 URI 路径(/、/on、/off)决定干什么。点按钮的本质,就是让浏览器去访问一个特定的地址,ESP32-S3 一看这个地址,就知道你要开灯还是关灯。
如果你用过 Arduino 的 WebServer + server.handleClient(),先把那套"在 loop 里反复轮询"的肌肉记忆放一边。ESP-IDF 的 esp_http_server(简称 httpd)是后台任务驱动的:你 httpd_start() 之后,它自己起了个 server 任务在那儿接客,请求一来就调用你登记的 handler——你的主程序该干嘛干嘛,不用每轮 handleClient()。这和上一篇 WiFi 的"事件驱动"是同一种产品级范式:你登记好回调,剩下交给系统。
理解了这条链路,后面的代码你看每一行都知道它在链路的哪个位置。
第一步:先把整段代码跑通
下面这段是完整可烧录的程序——它先用上一篇的 wifi_init_sta() 连上 WiFi,拿到 IP 后开一个 httpd 服务器,注册三条路由:/ 返回带"开/关"两个链接的网页,/on 拉高板载 LED、/off 拉低。把你家 2.4GHz WiFi 的名字和密码填进 WIFI_SSID / WIFI_PASS,放进工程的 main/main.c:
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_http_server.h"
#include "driver/gpio.h"
#include "nvs_flash.h"
#define WIFI_SSID "你的WiFi名"
#define WIFI_PASS "你的WiFi密码"
#define LED_GPIO GPIO_NUM_2 // 板载 LED;不同板子可能是 2 / 38 / 48,按你的板改
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
static const char *TAG = "web";
static EventGroupHandle_t s_wifi_event_group;
static int s_retry = 0;
/* ---------- WiFi 部分:复用上一篇 wifi_init_sta() 骨架,连上拿 IP ---------- */
static void wifi_event_handler(void* arg, esp_event_base_t base, int32_t id, void* data) {
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
if (s_retry < 5) { esp_wifi_connect(); s_retry++; ESP_LOGI(TAG, "重连中 %d/5", s_retry); }
else xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
} else 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));
s_retry = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
}
}
static void wifi_init_sta(void) {
s_wifi_event_group = xEventGroupCreate();
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));
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 wc = { .sta = { .ssid = WIFI_SSID, .password = WIFI_PASS } };
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc));
ESP_ERROR_CHECK(esp_wifi_start());
xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY);
}
/* ---------- HTTP 服务器部分:三个 handler + 注册路由 ---------- */
// 访问 / :返回带两个链接的网页
static esp_err_t root_handler(httpd_req_t *req) {
const char *html =
"<!DOCTYPE html><html><head><meta name='viewport' "
"content='width=device-width,initial-scale=1'></head><body>"
"<h2>ESP32-S3 控制台</h2>"
"<p><a href='/on'>开灯</a> <a href='/off'>关灯</a></p>"
"</body></html>";
httpd_resp_send(req, html, HTTPD_RESP_USE_STRLEN); // 长度交给它自己算
return ESP_OK;
}
// 访问 /on :拉高 GPIO 点灯,再回一句
static esp_err_t on_handler(httpd_req_t *req) {
gpio_set_level(LED_GPIO, 1); // ① 控制硬件
httpd_resp_send(req, "<a href='/'>已开灯,返回</a>", HTTPD_RESP_USE_STRLEN); // ② 回网页
return ESP_OK;
}
// 访问 /off :拉低 GPIO 灭灯
static esp_err_t off_handler(httpd_req_t *req) {
gpio_set_level(LED_GPIO, 0);
httpd_resp_send(req, "<a href='/'>已关灯,返回</a>", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static httpd_handle_t start_webserver(void) {
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); // 端口默认 80、栈大小等都给好了
httpd_handle_t server = NULL;
if (httpd_start(&server, &config) == ESP_OK) { // 起一个后台 server 任务
httpd_uri_t root = { .uri = "/", .method = HTTP_GET, .handler = root_handler };
httpd_uri_t on = { .uri = "/on", .method = HTTP_GET, .handler = on_handler };
httpd_uri_t off = { .uri = "/off", .method = HTTP_GET, .handler = off_handler };
httpd_register_uri_handler(server, &root); // 把路径和函数挂上去
httpd_register_uri_handler(server, &on);
httpd_register_uri_handler(server, &off);
ESP_LOGI(TAG, "HTTP 服务器已启动");
}
return server;
}
void app_main(void) {
ESP_ERROR_CHECK(nvs_flash_init()); // WiFi 射频校准数据存 NVS,连网前必做
gpio_reset_pin(LED_GPIO);
gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT); // 配 LED 为输出,相当于 pinMode OUTPUT
wifi_init_sta(); // 连上 WiFi、拿到 IP 才往下走
start_webserver(); // 开服务器,之后后台任务自己接客
// app_main 到这就可以返回了;server 在它自己的任务里跑,不需要 while(1) 轮询
}
在工程目录下一条命令编译、烧录、看日志:
idf.py build flash monitor
(第一次用要先 idf.py set-target esp32s3 选好芯片型号。按 Ctrl + ] 退出监视。)
你应该看到什么
按顺序对一下,每一步都有明确现象——一半看串口、一半看手机浏览器:
idf.py monitor的日志里,先滚出连 WiFi 的过程,然后是关键的一行:I (3210) web: 拿到 IP: 192.168.1.50,浏览器访问这个地址 I (3215) web: HTTP 服务器已启动这串
192.168.1.50就是你要的地址——它是路由器分给 ESP32-S3 的局域网门牌号。手机连上和 ESP32-S3 同一个 WiFi(注意是 2.4G 那个),浏览器地址栏输入那个 IP,回车。
页面上出现标题"ESP32-S3 控制台"和两个链接:"开灯"和"关灯"。
点"开灯"——板载灯立刻亮,页面变成"已开灯,返回";点"返回"再点"关灯",灯灭。
灯能跟着手机点击亮灭,这一刻你已经做出了一个最小的物联网控制器:手机是遥控器,ESP32-S3 是被控对象,中间靠网页连起来。注意整个过程里 app_main 早就返回了——服务器一直在它自己的后台任务里活着接客,这就是 httpd 和 Arduino handleClient() 最大的不同。
第二步:把这套 httpd 骨架讲透
例子能跑只是起点,看懂它你才能改成自己要的样子。WiFi 那半截上一篇讲透了,这里只拆 HTTP 服务器这部分。
httpd_start:开一个后台 server 任务
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); // 一套默认配置:端口 80、任务栈大小、最大连接数…
httpd_handle_t server = NULL;
httpd_start(&server, &config); // 真正启动,成功后 server 是这个服务器的句柄
HTTPD_DEFAULT_CONFIG() 是个宏,帮你把一堆参数填成合理默认值——其中 config.server_port 默认就是 80,所以浏览器里只写 IP、不用加端口号就能访问。httpd_start() 一旦返回 ESP_OK,系统就在后台起了一个专门的 server 任务,从此请求都由它接。这一步对应 Arduino 的 server.begin(),但关键区别是:它把"接客"放进了独立任务,不占用你的主流程。
httpd_register_uri_handler:把路径和函数对应起来
httpd_uri_t on = { .uri = "/on", .method = HTTP_GET, .handler = on_handler };
httpd_register_uri_handler(server, &on);
这是整段代码的核心,也就是"路由"。一个 httpd_uri_t 结构体描述一条路由:.uri 是路径、.method 是 HTTP 方法(这里都是 HTTP_GET,浏览器点链接发的就是 GET)、.handler 是收到这个请求时要跑的函数。httpd_register_uri_handler 把这条路由"挂"到服务器上——当有人访问 /on,server 任务就调用 on_handler。
你想多一个功能(比如调亮度),就多写一个 httpd_uri_t 加一条 httpd_register_uri_handler,再写对应的处理函数即可——扩展性全在这里,和 Arduino 的 server.on(路径, 函数) 是同一个意思,只是写法摊开了。
HTTPD_DEFAULT_CONFIG() 里有个默认值容易踩坑:config.max_uri_handlers 默认是 8。如果你注册的路由超过 8 条,第 9 条 httpd_register_uri_handler 会返回错误、那条路由静默失效。路由多了记得把这个值调大:config.max_uri_handlers = 16;(在 httpd_start 之前改)。
handler:每个函数干两件事
static esp_err_t on_handler(httpd_req_t *req) {
gpio_set_level(LED_GPIO, 1); // ① 控制硬件
httpd_resp_send(req, "<a href='/'>已开灯,返回</a>", HTTPD_RESP_USE_STRLEN); // ② 回网页
return ESP_OK;
}
每个 handler 的签名都是固定的 esp_err_t 函数名(httpd_req_t *req)——req 是这次请求的上下文(谁来的、带了什么参数都在里头),返回值告诉 httpd 处理结果(正常就 return ESP_OK)。函数体是这个套路:先操作硬件(gpio_set_level 拉高/拉低 GPIO,相当于 Arduino 的 digitalWrite),再用 httpd_resp_send 把一段内容回给浏览器。
httpd_resp_send(req, 数据, 长度) 第三个参数是数据长度,传 HTTPD_RESP_USE_STRLEN(值为 -1)就是让它自己用 strlen 算——回字符串时图省事就用它。root_handler 没控制硬件,只回了带两个链接的页面——它就是你看到的那个"开/关"界面。
没有 handleClient():为什么不用在 loop 里轮询
这是 httpd 比 Arduino WebServer 高明的地方,单独拎出来说。Arduino 里你必须在 loop 里反复 server.handleClient(),服务器才能持续响应;ESP-IDF 里根本没有这一步——httpd_start 起的那个 server 任务,自己在那儿监听端口、收到请求自己分发给 handler。你的 app_main 甚至可以直接返回,服务器照样活着。
正因为没有 handleClient(),Arduino 那个"loop 里别写长 delay 否则网页卡死"的坑在 httpd 这儿天然不存在——server 在独立任务里跑,你主任务睡多久都不影响它接客。但要注意另一类坑:handler 函数里别干太重的活、别长时间阻塞。handler 是在 server 任务里被调用的,你在某个 handler 里 vTaskDelay(pdMS_TO_TICKS(10000)) 死等 10 秒,这 10 秒内同一个 server 任务就顾不上别的请求了。handler 要快进快出,耗时操作扔给别的任务做。
第三步:带参数控制——滑块调亮度
只有"全开/全关"不够用,真正好玩的是拖滑块调亮度。这需要浏览器把一个数值传给 ESP32-S3,靠的是 URL 里的查询参数(query),比如访问 /set?v=180 表示"亮度设成 180"。ESP-IDF 里从 req 里取这个 v 的标准写法是 httpd_req_get_url_query_str + httpd_query_key_value:
// 访问 /set?v=180 ,取出 v 的值
static esp_err_t set_handler(httpd_req_t *req) {
char query[32];
char val[8];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { // 先拿到整串 "v=180"
if (httpd_query_key_value(query, "v", val, sizeof(val)) == ESP_OK) { // 再从里头抠出 v 的值
int duty = atoi(val); // "180" → 180
// 这里用 LEDC(PWM)把 duty 写进去调亮度,LEDC 配置见 PWM 那节
ESP_LOGI(TAG, "亮度设为 %d", duty);
}
}
httpd_resp_send(req, "ok", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
httpd_req_get_url_query_str 把 ? 后面那整串(v=180)拷到你给的缓冲区;httpd_query_key_value 再从这串里按 key("v")抠出对应的 value("180")。atoi 转成数字,喂给 LEDC(ESP-IDF 调 PWM 亮度的外设,对应 Arduino 的 analogWrite,配置方法在 PWM 那节展开)。别忘了在 start_webserver 里也给 /set 注册一条路由。
关键是把"前端怎么发请求"和"ESP32-S3 怎么收"这一对接口讲明白:路径叫 /set、参数叫 v,两边对齐就能联通。这块前端 HTML/CSS/JS 不必手写——让 AI 帮你生成,提示词里把这对接口写清楚:「我用 ESP-IDF 的 esp_http_server 做局域网控制,设备端 /set?v=数值 用 httpd_query_key_value 取 v 调亮度、/toggle 切换开关。帮我写一个适配手机的网页:一个大开关按钮请求 /toggle、一个滑块拖动时用 fetch 请求 /set?v=值,都不刷新整页。」只要这对接口对得上,AI 给的前端和你的固件就能直接联通。
局域网 vs 云:什么时候用哪个
这是本节最该想透的一件事。两条路没有谁更好,只有谁更合适:
| 局域网控制(本节) | 云控制 | |
|---|---|---|
| 手机和设备 | 必须在同一个 WiFi 下 | 任意网络都行,出门也能控 |
| 延迟 | 极低,几乎按下就响应 | 经过云中转,略高 |
| 成本 | 零成本,不依赖任何服务 | 要接平台或自建服务器 |
| 复杂度 | 一个 httpd + 几个 handler 就能跑 | 要处理连接、鉴权、断线重连 |
| 断网影响 | 路由器在就能用,不依赖外网 | 外网或云服务挂了就控不了 |
一句话判断:只在家里/办公室同一个 WiFi 下控制,就用局域网,简单可靠;要人在外面也能控、或者要让多个设备/多个人协同、设备主动上报数据,就得上云。下一步要学的 MQTT 协议 就是为云控制准备的——它解决的正是"出门也能控、设备主动推消息"这两件本节做不到的事。
故障排查:网页打不开或点了没反应
第一次做局域网控制,卡住很正常。照这张表从上往下查:
| 现象 | 最可能的原因 | 怎么办 |
|---|---|---|
| 串口压根没打印 IP | WiFi 没连上 / 名字密码错 | 看日志是不是在刷"重连中 N/5";确认填的是 2.4G 的 SSID、密码没多空格(见 WiFi 那篇排查表) |
| 有 IP,但浏览器打不开 | 手机和 ESP32-S3 不在同一个网 | 检查手机连的是不是同一个 WiFi,别连成了 5G 频段或访客网络 |
| 打不开,且两者确实同网 | IP 抄错 / 服务器没起来 | 重新核对日志里的 IP;确认日志有打 HTTP 服务器已启动,没打说明 httpd_start 失败了 |
| 网页能开,但点链接 404 | URI 路径和注册的对不上 | 确认链接里的 /on 和 httpd_uri_t .uri = "/on" 完全一致(大小写、斜杠都算) |
| 第 9 条路由没反应 | max_uri_handlers 默认 8 用满了 |
config.max_uri_handlers = 16; 后再 httpd_start |
| 灯不亮但页面正常跳转 | GPIO 选错 / 没配成输出 | 核对 LED_GPIO 是不是你板子的板载灯脚;确认 app_main 里配了 GPIO_MODE_OUTPUT |
| 偶尔能开、偶尔超时 | ESP32-S3 离路由器太远 / 信号弱 | 靠近路由器再试;信号弱时 Web 服务很不稳 |
最高频的坑是"手机和设备不同网"。家里常见两个 WiFi(一个 2.4G、一个 5G),名字还可能很像。ESP32-S3 只支持 2.4G,手机却可能连在 5G 上——这时两者虽在一个家,却不在同一个局域网,自然打不开。把手机切到和 ESP32-S3 同一个 2.4G 网络再试。次高频是 GPIO 选错:不同 ESP32-S3 开发板的板载 LED 脚不一样(常见 2、38、48,有的板根本没板载灯),点不亮先去查你这块板的原理图或卖家文档。
进阶:两个让体验更专业的升级
跑通基础版之后,这两步能让你的小控制器从"能用"变"好用"。
不跳页:用 AJAX 局部更新
基础例子每次点链接都会跳到一个新页面("已开灯,返回"),体验生硬。现代做法是让网页用 JavaScript 的 fetch 在后台发请求,只更新页面上一小块,不整页跳转。比如点开关时:
fetch('/toggle'); // 后台发请求,页面不动
ESP32-S3 端的 /toggle handler 照样 gpio_set_level 切换 LED,但 httpd_resp_send 可以只回一句状态文字甚至空内容。这样按钮点下去页面纹丝不动、灯却变了,和正经 App 一个手感。具体的 HTML+JS 同样交给 AI 生成,把"用 fetch 不刷新"这个要求写进提示词即可。
大页面别硬塞代码里:放进 SPIFFS
上面我们把 HTML 直接写成 C 字符串塞进固件,页面一复杂这串字符串就长得没法看、还得手动转义引号。正经做法是把 HTML/CSS/JS 当文件放进 SPIFFS(或 LittleFS)这种板载文件系统,handler 里读文件再 httpd_resp_send 发出去。这样前端文件归前端、固件归固件,改个样式不用重编 C 代码。ESP-IDF 有 esp_spiffs 组件和 httpd_resp_send 配合做这件事,页面大了一定要走这条路。
把 HTML 写在 C 字符串里时,字符串里的双引号必须转义或改用单引号。HTML 属性习惯用双引号(<a href="/on">),但它会和 C 字符串的双引号打架,要么写成 <a href=\"/on\">、要么像本篇示例那样 HTML 里统一用单引号(<a href='/on'>)。页面一长这种转义极易出错——这也是该把页面挪到 SPIFFS 的现实理由之一。还有:上面拿 query 用的缓冲区(char query[32])要够大,URL 长了会被截断,按你最长的请求留余量。
动手挑战
别只跑通示例,改一个出来才算掌握:
- 加一个亮度滑块:参照第三步的
set_handler,注册/set路由,配好 LEDC(PWM),打通"滑块值 →/set?v=数值→httpd_query_key_value取v→ LEDC 调占空比"这条链。前端用上面那段 AI 提示词生成。 - 加个状态显示:让网页上显示一行"当前:已开 / 已关",每次点击后更新(handler 里根据一个全局状态变量拼进返回的 HTML)。
- 进阶:把跳页改成 AJAX,新增
/toggle路由,点按钮fetch('/toggle')页面不动、灯却变。
卡住了就把你的前端代码、ESP32-S3 端的 handler 代码和想要的效果一起发给 AI,让它对照着帮你查接口(路径名、参数名)对不对得上。
小结 · 你现在掌握了什么
- 你能让 ESP32-S3 用
esp_http_server自己开一个网页,手机连同一个 WiFi 就能打开并控制硬件——全程复用上一篇的wifi_init_sta()骨架。 - 你看懂了
httpd_start起后台 server 任务、httpd_register_uri_handler把路径挂到 handler、handler 用gpio_set_level控硬件再httpd_resp_send回 HTML 这条完整链路。 - 你知道了 httpd 不用
handleClient()轮询(后台任务驱动),以及随之而来的新注意点:handler 要快进快出、别长阻塞。 - 你会用
httpd_req_get_url_query_str+httpd_query_key_value取 URL 参数来传滑块/数值,知道了局域网控制简单可靠但出不了门、云控制能远程但要付出复杂度成本,并能判断自己的项目该走哪条。
这套"设备开网页、手机当遥控"的思路,是你做任何家用智能硬件的起点。
本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档自校——尤其
esp_http_server的 API 细节(httpd_resp_send、httpd_query_key_value的签名)随版本可能微调,以官方文档为准。
下一步:想让设备出门也能控、还能主动上报数据?学 MQTT 协议,把控制从局域网搬到云端。学完整条 L3 路线,回本阶段总览或完整路线图看看下一关。