ESP32 嵌入式Web服务器实现与配置指南

在做物联网设备开发时,参数配置一直是个痛点。传统的串口指令配置需要用户连接电脑并安装驱动,体验较差;而屏幕配合按键的交互虽然直观,但对于不需要频繁操作的设备来说,增加了BOM成本和开发复杂度。

最优雅的方案莫过于让ESP32自己跑一个Web服务器。用户只需连上设备的Wi-Fi热点,打开浏览器就能看到配置界面。这篇文档记录了我目前项目中使用的方案:基于 esp_http_server组件实现的一个参数配置页面。

一、方案设计思路

整个交互流程非常简单直观,本质上就是典型的B/S架构(Browser/Server),只是Server跑在了一颗MCU上。

  1. AP模式启动:设备上电后开启Wi-Fi热点(SoftAP),供手机或电脑连接。
  2. DNS劫持(可选)或固定IP:用户连接热点后,通过浏览器访问网关IP(如 192.168.4.1)。
  3. GET请求:浏览器请求根路径 /,ESP32读取NVS中保存的当前配置,动态生成带有默认值的HTML表单页面返回给浏览器。
  4. POST请求:用户修改表单并点击提交,数据以POST请求发送到 /submit。ESP32解析数据,写入NVS掉电保存,并自动重启生效。

这套方案完全不需要依赖外网,也不需要APP,全平台通用。

二、核心代码实现

代码主要集中在 http_server.c中,使用了ESP-IDF自带的 esp_http_server组件。这个组件非常轻量,基于FreeRTOS任务运行。

1. 启动Web服务器

首先需要配置并启动HTTP Server任务。这里我针对RAM和并发连接做了一些调整,特别是加大了栈空间(stack_size),因为生成的HTML字符串可能会比较大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void start_webserver(void)
{
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); // 获取默认配置

// 针对嵌入式环境的调优
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192; // 增加堆栈以容纳大HTML字符串处理
config.max_open_sockets = 7; // 允许同时存在的连接数
config.lru_purge_enable = true; // 开启LRU清理,防止连接耗尽

httpd_handle_t server = NULL;
if (httpd_start(&server, &config) == ESP_OK)
{
// 注册URI处理函数
httpd_register_uri_handler(server, &hello); // GET /
httpd_register_uri_handler(server, &submit); // POST /submit
ESP_LOGI(TAG, "Web server started successfully");
}
}

2. 动态生成配置页面 (GET Handler)

这是最关键的一步。我们不能只是返回一个静态的HTML文件,因为我们需要把设备当前的配置回显在网页上。

我的做法是:

  1. 先定义一个包含 %s%d占位符的HTML模板字符串。
  2. 从各个模块(WiFi、MQTT、Modbus等)获取当前参数。
  3. 使用 snprintf将参数填入模板,生成最终的HTML。

注意:考虑到C语言处理长字符串比较麻烦,我采用了直接在该handler中拼接的方式。虽然不仅不够优雅,也占用了栈空间,但对于这种一次性的配置页面,简单有效就是最好的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* HTTP GET 处理函数 */
esp_err_t hello_get_handler(httpd_req_t *req)
{
// 1. 获取当前设备的所有配置参数
char ssid[16] = {0};
snprintf(ssid, 16, "%s", get_wifi_ssid());
// ... 获取其他参数 MQTT, TCP, Modbus等 ...

// 2. HTML 模板 (简化示意)
// 这里的 value=\"%s\" 就是关键,它让网页显示当前真实的值
const char *resp_template =
"<!DOCTYPE html><html><body>"
"<h1>GOATS 网络配置</h1>"
"<form action=\"/submit\" method=\"post\">"
"<fieldset><legend>网络配置</legend>"
"SSID: <input type=\"text\" name=\"ssid\" value=\"%s\"><br>"
"Password: <input type=\"password\" name=\"password\" value=\"%s\"><br>"
"</fieldset>"
// ... 其他表单项 ...
"<input type=\"submit\" value=\"保存并重启\">"
"</form>"
"</body></html>";

// 3. 动态生成最终 HTML
// 先计算长度,malloc申请内存,再snprintf格式化
int resp_len = snprintf(NULL, 0, resp_template, ssid, password, ...);
char *resp_str = malloc(resp_len + 1);

if (resp_str) {
snprintf(resp_str, resp_len + 1, resp_template, ssid, password, ...);
// 发送响应
httpd_resp_send(req, resp_str, HTTPD_RESP_USE_STRLEN);
free(resp_str); // 记得释放内存!
}
return ESP_OK;
}

3. 处理表单提交 (POST Handler)

当用户点击“保存并重启”时,浏览器会发送一个POST请求,Body里包含类似 ssid=mywifi&password=123456...的字符串。我们需要解析这个字符串并保存到NVS。

这里我用了一个比较“暴力”但好用的方法:sscanf。在此场景下,我们对自己生成的表单结构非常清楚,所以直接按顺序解析即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/* HTTP POST 处理函数 */
esp_err_t submit_post_handler(httpd_req_t *req)
{
char buf[512]; // 接收缓冲区
int ret, remaining = req->content_len;

// 1. 读取 Body 数据
if ((ret = httpd_req_recv(req, buf, MIN(remaining, sizeof(buf)))) <= 0) {
if (ret == HTTPD_SOCK_ERR_TIMEOUT) {
httpd_resp_send_408(req);
}
return ESP_FAIL;
}

// 2. 打印看看收到了什么
printf("post raw data: %s\n", buf);

// 3. 解析参数
// 使用 sscanf 的字符集功能 [^&] 来截取参数,直到遇到 '&'
sscanf(buf,
"ID=%ld&ssid=%15[^&]&password=%15[^&]...",
&hw_id, ssid, password ...
);

// 4. 保存到 NVS (掉电不丢失)
save_integer_to_nvs("id", hw_id);
save_string_to_nvs("w_s", ssid);
save_string_to_nvs("w_p", password);
// ... 保存其他参数 ...

// 5. 反馈并重启
const char *resp_str = "<h1>保存成功! 五秒后将重启...</h1>";
httpd_resp_send(req, resp_str, HTTPD_RESP_USE_STRLEN);

printf("ESP32 will restart in 5 seconds...\n");
vTaskDelay(5000 / portTICK_PERIOD_MS);
esp_restart(); // 重启应用新配置

return ESP_OK;
}

三、前端页面的CSS美化

虽然是嵌入式设备,但页面也不能太丑。我在HTML头部内嵌了一些CSS样式。为了避免C代码中字符串拼接太乱,这里最好在开发时先写好标准的HTML/CSS文件(即工程中的 index.html),调试满意后再转换成C语言字符串。

目前使用了简单的 fieldsetlegend做分组,配合Flex布局让标签和输入框对齐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fieldset {
background-color: #0e7a6ca0; /* 统一色调 */
border-radius: 20px;
margin-bottom: 20px;
}
.form-group {
display: flex;
align-items: center; /* 垂直居中 */
width: 400px;
}
label {
flex: 0 0 200px; /* 固定标签宽度 */
text-align: right;
margin-right: 10px;
}

四、调试心得与注意点

  1. 栈溢出保护:HTTP Server任务默认栈可能不够用,特别是当表单项越来越多,HTML字符串变得很大时。务必在 httpd_config_t中调大 stack_size
  2. URL编码:浏览器提交的POST数据中,特殊字符(如空格、#、%等)会被URL编码(例如空格变 %20)。在简单的 sscanf解析中,如果SSID或密码包含特殊字符,可能会解析错误。完善的方案应该加入URL Decode步骤,但作为内部配置工具,可以暂时限制用户输入标准字符。
  3. NVS 键名限制:NVS的Key长度最长只能15个字符。可以看到代码中我用了简写如 w_s (wifi ssid)、m_ip (mqtt ip)等,既省空间又避免超长。
  4. 内存泄漏:在 hello_get_handler中使用了 malloc分配HTML缓冲区,一定要确保 free被执行,否则几次刷新页面后系统就会崩溃。

通过这套机制,ESP32就具备了一个独立、跨平台的配置后台,极大地提升了产品的易用性。