← 返回教程库

让 ESP32-S3 当个网页服务器:手机网页控硬件

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 18 分钟 🟢 软件/低风险
你将学到
  • 用 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> &nbsp; <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 + ] 退出监视。)

你应该看到什么

按顺序对一下,每一步都有明确现象——一半看串口、一半看手机浏览器

  1. idf.py monitor 的日志里,先滚出连 WiFi 的过程,然后是关键的一行:

    I (3210) web: 拿到 IP: 192.168.1.50,浏览器访问这个地址
    I (3215) web: HTTP 服务器已启动
    

    这串 192.168.1.50 就是你要的地址——它是路由器分给 ESP32-S3 的局域网门牌号。

  2. 手机连上和 ESP32-S3 同一个 WiFi(注意是 2.4G 那个),浏览器地址栏输入那个 IP,回车。

  3. 页面上出现标题"ESP32-S3 控制台"和两个链接:"开灯"和"关灯"。

  4. 点"开灯"——板载灯立刻亮,页面变成"已开灯,返回";点"返回"再点"关灯",灯灭。

灯能跟着手机点击亮灭,这一刻你已经做出了一个最小的物联网控制器:手机是遥控器,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_valuev 调亮度、/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 路径和注册的对不上 确认链接里的 /onhttpd_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 长了会被截断,按你最长的请求留余量。


动手挑战

别只跑通示例,改一个出来才算掌握:

  1. 加一个亮度滑块:参照第三步的 set_handler,注册 /set 路由,配好 LEDC(PWM),打通"滑块值 → /set?v=数值httpd_query_key_valuev → LEDC 调占空比"这条链。前端用上面那段 AI 提示词生成。
  2. 加个状态显示:让网页上显示一行"当前:已开 / 已关",每次点击后更新(handler 里根据一个全局状态变量拼进返回的 HTML)。
  3. 进阶:把跳页改成 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_sendhttpd_query_key_value 的签名)随版本可能微调,以官方文档为准。

下一步:想让设备出门也能控、还能主动上报数据?学 MQTT 协议,把控制从局域网搬到云端。学完整条 L3 路线,回本阶段总览完整路线图看看下一关。

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明