网关类设备现场调试时,最省事的不是接串口,也不是装专用 App,而是让工程师掏出手机连上设备热点,打开浏览器就能改 IP、看采集状态、下发参数。
需求边界
先明确要什么:一个能跑在几 MB 内存、几十 MHz 主频设备上的 HTTP 服务,能返回静态页面(HTML/CSS/JS)、响应配置读写的接口,并把参数写入非易失存储。它不需要支撑上千并发连接,同时在线的管理员通常也就一两个。
选型
| 方案 |
体积 |
特点 |
适用 |
| Boa |
极小(~100KB) |
单线程、CGI 支持、多年未活跃更新 |
极简老项目 |
| lighttpd |
中等(~500KB+fastcgi) |
成熟、支持 CGI/FastCGI、配置灵活 |
需要 CGI 脚本(PHP/Lua) |
| Mongoose / libmicrohttpd |
可裁剪到几十 KB |
以库形式嵌入、事件回调、易集成 |
新项目、需要自定义 API |
新项目推荐 嵌入式库方案(Mongoose 或 libmicrohttpd):把它们作为库链接进主程序,HTTP 服务和业务逻辑在同一个进程里,直接读写全局变量、直接调用采集接口,不需要 CGI 那套进程间通信,调试也简单。
以 Mongoose 为例的最小服务
Mongoose 用事件回调模型,注册一个 HTTP 监听,所有请求进同一个回调,按 URI 路由。
#include "mongoose.h"
static const char *s_listen_on = "http://0.0.0.0:80";
static const char *s_root_dir = "/www"; // 静态页面目录
// 事件处理器:mg_http_serve_dir 服务静态文件,自己处理 /api
static void cb(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;
// 配置读取:GET /api/config
if (mg_match(hm->uri, mg_str("/api/config"), NULL)) {
char json[256];
build_config_json(json, sizeof(json)); // 组装当前参数
mg_http_reply(c, 200, "Content-Type: application/json\r\n", "%s", json);
return;
}
// 配置下发:POST /api/config
if (mg_match(hm->uri, mg_str("/api/config"), NULL) &&
mg_strcmp(hm->method, mg_str("POST")) == 0) {
if (apply_config_from_json(hm->body.ptr, hm->body.len)) {
save_config_to_flash(); // 持久化到非易失存储
mg_http_reply(c, 200, "", "{\"result\":\"ok\"}");
} else {
mg_http_reply(c, 400, "", "{\"result\":\"bad param\"}");
}
return;
}
// 其余请求当作静态文件请求
struct mg_http_serve_opts opts = { .root_dir = s_root_dir };
mg_http_serve_dir(c, hm, &opts);
}
}
int main(void)
{
struct mg_mgr mgr;
mg_mgr_init(&mgr);
mg_http_listen(&mgr, s_listen_on, cb, &mgr);
for (;;) mg_mgr_poll(&mgr, 100); // 事件循环
return 0;
}
核心是一个事件循环 mg_mgr_poll。/api/... 路径自己解析处理,其他路径交给 mg_http_serve_dir 返回静态文件。整个 HTTP 服务和采集主程序在同一进程里,读到的配置直接就是内存中的变量。
REST 风格的配置接口
前后端用 JSON 交换配置,前端 fetch 调用。接口设计保持简单一致:
// 前端读取配置
fetch('/api/config').then(r => r.json()).then(cfg => {
document.getElementById('serverIp').value = cfg.serverIp;
});
// 前端提交配置
fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({serverIp: '192.168.1.100', pollInterval: 5})
}).then(r => r.json()).then(res => alert(res.result));
C 侧的 JSON 解析不必引入重型库,Mongoose 自带轻量 mg_json_get_* 系列函数,能按路径取出字段:
// 从 POST 的 JSON body 里取字段(Mongoose 内置 JSON 助手)
int apply_config_from_json(const char *body, size_t len)
{
struct mg_str json = mg_str_n(body, len);
double poll_interval;
char server_ip[64];
if (mg_json_get_num(json, "$.pollInterval", &poll_interval) &&
mg_json_get_str(json, "$.serverIp", server_ip, sizeof(server_ip))) {
g_config.poll_interval = (uint32_t)poll_interval;
strncpy(g_config.server_ip, server_ip, sizeof(g_config.server_ip));
return 1; // 成功
}
return 0; // 字段缺失或格式错误
}
参数持久化与生效
配置改动要能重启后保留。常见做法是存成 JSON 文件放到一个独立的可写分区(如 /mnt/config/config.json),或者直接写 Flash 的某个扇区。
// 保存:序列化后写临时文件再重命名,避免写一半掉电损坏
int save_config_to_flash(void)
{
char tmp[] = "/mnt/config/.tmp.json";
char dst[] = "/mnt/config/config.json";
FILE *f = fopen(tmp, "w");
if (!f) return -1;
fprintf(f, "{\"serverIp\":\"%s\",\"pollInterval\":%u}",
g_config.server_ip, g_config.poll_interval);
fflush(f);
fsync(fileno(f)); // 确保落盘
fclose(f);
rename(tmp, dst); // 原子替换
return 0;
}
用“写临时文件 + rename”而不是直接覆盖,是为了防止写入过程中掉电留下半个损坏的配置——rename 在同一文件系统内是原子操作,要么旧配置要么新配置,不会是不完整的中间态。
别忽略安全
本地配置页面也要有基本防护,设备一旦被接入局域网就不是“本地”了:
// 简单的会话口令校验:未登录则跳转到登录页
if (!is_authenticated(hm) && !mg_match(hm->uri, mg_str("/login.html"), NULL)) {
mg_http_reply(c, 301, "Location: /login.html\r\n", "");
return;
}
- 改默认口令:出厂默认 admin/admin 是最大的安全缺口,首启强制改密。
- 接口鉴权:不能只靠前端隐藏按钮,每个
/api/config 请求都要在服务端校验会话。
- 不要 root 运行:Web 服务进程用普通用户跑,缩小被攻破后的影响面。
完整链路
嵌入式 Web 配置的关键是把 HTTP 服务和业务逻辑放进同一个进程——用嵌入式 HTTP 库而非独立服务器进程,省掉 CGI 通信开销,调试也直观。