☰
嵌入式 Web Server 实战:用 Mongoose + C++ 打造轻量 HTML 控制台
2026/10/1 6:54:21 网站建设 项目流程

1. 嵌入式 Web Server 到底解决什么问题,Mongoose + C++ 适合谁

嵌入式设备上跑一个 Web Server,最直接的收益是:不用装上位机、不用配串口工具,浏览器打开一个 IP 就能看状态、点按钮。我在一块 ARM 板子上做环境监测终端时,最初用串口打印调试,客户现场没有串口线就抓瞎。后来把状态页做成 HTML,现场工程师用手机连上设备热点就能看温湿度曲线,问题定位效率完全不一样。

Mongoose 是一个单文件(mongoose.c+mongoose.h)的嵌入式网络库,把 TCP、HTTP、WebSocket、MQTT 都封装好了,编译进工程就能用,不依赖 libevent、不依赖 OpenSSL(要 HTTPS 可以自己开)。它特别适合这几类人:做工业网关、数据采集器、边缘计算盒子的嵌入式 C/C++ 开发者;想给裸机或 RTOS 设备加一个轻量配置页面的工程师;以及像我这样后端经验不多、但需要快速交付一个可访问控制台的开发者。

它的资源占用很小,实测在 Cortex-A7 平台上,一个静态 HTML 页面 + 两个 JSON 接口,常驻内存增加不到 2MB,CPU 占用在空闲时几乎为 0。相比自己用 socket 手写 HTTP 解析,Mongoose 帮你处理了请求行解析、Header 解析、分块传输、keep-alive 这些琐碎但容易出错的细节。你只需要关心“收到这个 URL 该返回什么”。

这篇文章会从零开始,给出 Mongoose 的集成方式、C++ 请求处理代码、HTML 页面模板,以及浏览器访问和接口验证的完整步骤。你跟着做,应该能在一个下午跑通一个可用的嵌入式控制台。中间我会把踩过的坑标出来,尤其是 HTML 文件读取和响应头设置这两块,当时卡了我好几天。

2. 把 Mongoose 集成进 C++ 工程:文件、编译与事件循环

Mongoose 的集成方式非常简单,官方推荐直接把mongoose.c和mongoose.h复制到你的工程目录。但这里有几个细节,网上很多例子没讲清楚,我按实际工程配置来说明。

首先是文件放置。我习惯建一个third_party/mongoose/目录,把两个文件放进去。然后在 CMakeLists.txt 里这样写:

# 假设工程根目录下有 third_party/mongoose/ add_library(mongoose STATIC third_party/mongoose/mongoose.c ) target_include_directories(mongoose PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/third_party/mongoose ) target_compile_definitions(mongoose PUBLIC MG_ENABLE_LOG=1 MG_ENABLE_LINES=1 )

注意mongoose.c是 C 文件,如果你的主工程是 C++,链接时要用extern "C"包住头文件包含,否则会出现符号找不到的问题。我在main.cpp里是这样写的:

extern "C" { #include "mongoose.h" }

编译参数上,Mongoose 默认会开启一些功能,如果你只需要 HTTP,可以在编译时定义MG_ENABLE_MQTT=0、MG_ENABLE_WS=0来减小体积。我实测下来,关掉 MQTT 和 WebSocket 后,静态库体积从 380KB 降到 210KB 左右。

接下来是事件循环。Mongoose 的核心是mg_mgr,所有网络事件都通过它驱动。一个最小可运行的骨架长这样:

#include <iostream> #include <fstream> #include <sstream> extern "C" { #include "mongoose.h" } static void fn(struct mg_connection *c, int ev, void *ev_data) { if (ev == MG_EV_HTTP_MSG) { struct mg_http_message *hm = (struct mg_http_message *) ev_data; // 这里处理请求 mg_http_reply(c, 200, "Content-Type: text/plain\r\n", "hello\n"); } } int main() { struct mg_mgr mgr; mg_mgr_init(&mgr); mg_http_listen(&mgr, "http://0.0.0.0:8000", fn, NULL); std::cout << "Server started on port 8000" << std::endl; for (;;) { mg_mgr_poll(&mgr, 1000); } mg_mgr_free(&mgr); return 0; }

mg_http_listen的第二个参数是监听地址,0.0.0.0:8000表示监听所有网卡的 8000 端口。如果你的板子有多个网口,想只绑定某一个,可以写成http://192.168.1.100:8000。mg_mgr_poll的第二个参数是超时毫秒数,嵌入式场景下建议设成 1000,太短会空转耗 CPU,太长会影响响应实时性。

这里有个容易忽略的点:mg_mgr_poll是阻塞的,如果你还需要在主循环里做别的事(比如读传感器),要么把 poll 超时设小一点,要么把 Web Server 放到独立线程。我一般用独立线程,因为传感器采集周期和网络事件周期不一样,混在一起容易互相拖累。

3. 可复制配置:请求路由、HTML 文件读取与 JSON 接口

这一节是核心,我把完整的请求处理代码拆成三块:路由分发、静态 HTML 返回、JSON 接口。你可以直接复制到自己的工程里改。

先看路由分发。Mongoose 的mg_http_match_uri用来匹配 URL,支持通配符。我习惯用一个handle_request函数统一处理:

static void handle_request(struct mg_connection *c, struct mg_http_message *hm) { std::string uri(hm->uri.buf, hm->uri.len); std::cout << "Request URI: " << uri << std::endl; if (uri == "/" || uri == "/index.html") { serve_file(c, "index.html", "text/html"); } else if (uri == "/api/status") { serve_status_json(c); } else if (uri == "/api/control") { handle_control(c, hm); } else { mg_http_reply(c, 404, "Content-Type: text/plain\r\n", "Not Found\n"); } }

然后是静态文件返回。这里就是我当初卡住的地方:HTML 文件内容不能直接拼在mg_http_reply的格式化字符串里,因为 HTML 里可能有%字符,会被当成格式符解析,导致输出错乱甚至崩溃。正确做法是先把文件读进std::string,然后用mg_http_reply的%.*s格式,或者直接用mg_http_reply的 body 参数传字符串指针。

static void serve_file(struct mg_connection *c, const char *path, const char *mime) { std::ifstream file(path, std::ios::binary); if (!file.is_open()) { mg_http_reply(c, 404, "Content-Type: text/plain\r\n", "File not found\n"); return; } std::stringstream buffer; buffer << file.rdbuf(); std::string content = buffer.str(); file.close(); char header[128]; snprintf(header, sizeof(header), "Content-Type: %s\r\n", mime); mg_http_reply(c, 200, header, "%.*s", (int)content.size(), content.c_str()); }

注意%.*s的用法:第一个参数是长度,第二个是字符串指针。这样即使 HTML 里有%也不会出问题。另外std::ios::binary很重要,Windows 上不加会多出\r,Linux 上影响不大,但养成习惯比较好。

JSON 接口返回设备状态。假设你有一个全局的结构体存温度、湿度、开关状态:

struct DeviceState { float temperature; float humidity; bool relay_on; }; static DeviceState g_state = {25.6f, 60.2f, false}; static void serve_status_json(struct mg_connection *c) { char json[256]; snprintf(json, sizeof(json), "{\"temperature\":%.1f,\"humidity\":%.1f,\"relay\":%s}", g_state.temperature, g_state.humidity, g_state.relay_on ? "true" : "false"); mg_http_reply(c, 200, "Content-Type: application/json\r\n", "%s", json); }

控制接口需要解析 POST 的 body。Mongoose 把 body 放在hm->body里,你可以用mg_json_get_bool之类的辅助函数,也可以自己解析。简单场景下我直接判断字符串:

static void handle_control(struct mg_connection *c, struct mg_http_message *hm) { std::string body(hm->body.buf, hm->body.len); if (body.find("\"relay\":true") != std::string::npos) { g_state.relay_on = true; } else if (body.find("\"relay\":false") != std::string::npos) { g_state.relay_on = false; } mg_http_reply(c, 200, "Content-Type: application/json\r\n", "{\"result\":\"ok\",\"relay\":%s}", g_state.relay_on ? "true" : "false"); }

如果你需要更规范的 JSON 解析,Mongoose 内置了mg_json_get_*系列函数,可以按路径取值,比如mg_json_get_bool(hm->body, "$.relay", &val)。这个在 body 嵌套较深时很有用。

4. 验证请求:浏览器访问、curl 测试与成功结果

代码写完后,编译运行。假设你的可执行文件叫embedded_server,在板子上执行:

./embedded_server

终端会输出Server started on port 8000。然后在同一网段的电脑浏览器里输入http://<板子IP>:8000/,应该能看到你的 HTML 页面。如果页面显示空白或者乱码,先检查Content-Type是否正确,HTML 必须是text/html,JSON 必须是application/json。

用 curl 验证接口更直接:

curl -v http://192.168.1.100:8000/api/status

正常返回应该是:

{"temperature":25.6,"humidity":60.2,"relay":false}

再测试控制接口:

curl -X POST http://192.168.1.100:8000/api/control \ -H "Content-Type: application/json" \ -d '{"relay":true}'

返回:

{"result":"ok","relay":true}

这时候再刷新浏览器页面,如果 HTML 里有轮询逻辑,继电器状态应该会变成“开”。我实测下来,从点击按钮到页面更新,局域网内延迟在 50ms 以内,完全满足控制台需求。

HTML 页面模板我放在下面,你可以直接保存为index.html放在可执行文件同目录:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>设备控制台</title> <style> body { font-family: sans-serif; margin: 20px; background: #f5f5f5; } .card { background: #fff; padding: 20px; border-radius: 8px; margin-bottom: 16px; } .value { font-size: 28px; color: #2c3e50; } button { padding: 10px 24px; font-size: 16px; cursor: pointer; } </style> </head> <body> <div class="card"> <h2>温度</h2> <div class="value" id="temp">--</div> </div> <div class="card"> <h2>湿度</h2> <div class="value" id="humi">--</div> </div> <div class="card"> <h2>继电器</h2> <div class="value" id="relay">--</div> <button onclick="toggleRelay(true)">开启</button> <button onclick="toggleRelay(false)">关闭</button> </div> <script> async function refresh() { const res = await fetch('/api/status'); const data = await res.json(); document.getElementById('temp').textContent = data.temperature + ' °C'; document.getElementById('humi').textContent = data.humidity + ' %'; document.getElementById('relay').textContent = data.relay ? '开启' : '关闭'; } async function toggleRelay(on) { await fetch('/api/control', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({relay: on}) }); refresh(); } refresh(); setInterval(refresh, 2000); </script> </body> </html>

这个页面每 2 秒轮询一次状态,点击按钮会 POST 控制指令。你可以根据实际需求改成 WebSocket 推送,Mongoose 也支持,但轮询在轻量场景下更简单可靠。

5. 本篇常见错排查:401、local proxy failed、reading choices 与 OAuth

嵌入式 Web Server 调试时,报错往往不在 Mongoose 本身,而在网络环境或请求格式。我整理了几个高频问题,对照着排查能省不少时间。

401 Unauthorized:如果你在 Mongoose 里加了简单的鉴权(比如检查 Header 里的 token),浏览器直接访问会返回 401。这时候要么在 HTML 里带上 token,要么把鉴权逻辑改成只对/api/路径生效,静态页面放行。我一般用mg_http_match_uri(hm, "/api/*")来判断。

local proxy failed:这个报错通常出现在你用 curl 或浏览器通过代理访问板子 IP 时。检查环境变量http_proxy、https_proxy是否设置了代理,如果有,用export http_proxy=清掉,或者用curl --noproxy '*'跳过代理。嵌入式设备在内网,不需要走代理。

reading choices 相关报错:如果你在 HTML 里用了fetch请求接口,但接口返回的不是合法 JSON,浏览器控制台会报SyntaxError: Unexpected token或者reading 'choices'之类的错误。这通常是因为mg_http_reply的Content-Type没设成application/json,或者返回的 body 里混入了调试信息。检查serve_status_json里的 header 和 snprintf 格式。

OAuth 相关报错:有些同学会把 Mongoose 和云端 API 对接,如果涉及 OAuth token 刷新,注意 token 过期时间。嵌入式设备时钟可能不准,导致 JWT 校验失败。建议在设备启动时通过 NTP 同步一次时间,或者用长期有效的设备密钥。

另外还有一个我踩过的坑:mg_http_reply的格式化字符串如果直接传 HTML 内容,里面的%会被解析。比如 HTML 里有width: 100%,就会导致输出截断。解决办法就是前面说的%.*s,或者用mg_http_reply的mg_str版本。这个坑当时让我排查了一整天,因为现象是页面只显示一半,也不报错。

如果你在集成过程中遇到编译报错undefined reference to mg_http_listen,检查mongoose.c是否加入了编译,以及 C++ 里是否用了extern "C"。这两个是最常见的链接问题。

6. 从能跑到好用:接口文档、模型验证与长期编码方案

跑通一个页面只是开始。实际项目里,你还需要考虑接口文档、模型验证和长期维护。TaoToken 在这几个环节能帮上忙:它的接入文档(https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=doc)里有完整的 API 说明,你可以用来对照调试自己的接口格式;模型对话(https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=model_chat)可以快速验证 JSON 结构是否符合预期;如果你要长期做嵌入式 Agent 或编码辅助,Coding Plan(https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=coding_plan)提供了更稳定的调用方案。

API Key 在控制台(https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=console)生成,接口地址是 https://taotoken.net/api。如果你用 Claude Code 做代码润色,可以参考 ClaudeCodeAnthropic 的配置方式,把 Base URL 指向 https://taotoken.net/api,Key 填控制台生成的,Model ID 按文档选。这样在写 Mongoose 回调函数时,可以让模型帮你检查内存泄漏和边界条件。

最后说一个实用技巧:Mongoose 的日志级别可以通过mg_log_set(MG_LL_DEBUG)打开,调试请求解析时很有用。但生产环境记得关掉,否则串口输出会拖慢响应。我一般在main开头根据编译宏决定日志级别,Release 版本设成MG_LL_ERROR,只打印错误。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询