ESP32-S3 Rust 配网教程:AP+STA 共存模式实战
作者:CXi
平台:ESP32-S3R8N8(嘉立创开发板)
框架:esp-hal + embassy 异步运行时
功能:手机连接热点 → 浏览器选 Wi-Fi → ESP32 自动连路由器
一、项目简介
1.1 什么是 AP+STA 配网?
ESP32 本身没有屏幕和键盘,用户没法直接告诉它"连哪个 Wi-Fi"。配网就是解决这个问题的方案:
| 模式 | 说明 |
|---|---|
| AP 模式 | ESP32 自己变成一个 Wi-Fi 热点,手机连上去 |
| STA 模式 | ESP32 作为客户端,连接到你家的路由器 |
AP+STA 共存就是同时干这两件事:
- AP 热点给手机提供配网页面
- STA 连接到路由器获取互联网访问
1.2 配网流程
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 手机 │ │ ESP32 │ │ 路由器 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 连接热点 │ │
│ ESP32-Setup │ │
├──────────────────►│ │
│ │ │
│ 打开 192.168.4.1 │ │
├──────────────────►│ │
│ │ │
│ 点击"扫描" │ │
├──────────────────►│ │
│ │ 扫描附近 Wi-Fi │
│ ├─────────────────►│
│ 返回 Wi-Fi 列表 │◄─────────────────┤
│◄──────────────────┤ │
│ │ │
│ 输入密码,点击连接 │ │
├──────────────────►│ │
│ │ 连接路由器 │
│ ├─────────────────►│
│ │◄─────────────────┤
│ │ │
│ 显示设备 IP │ 获取到 IP │
│◄──────────────────┤ │
1.3 硬件准备
| 硬件 | 说明 |
|---|---|
| ESP32-S3R8N8 | 嘉立创 ESP32-S3 开发板(8MB Flash + 8MB PSRAM) |
| USB 数据线 | Type-C,用于供电和烧录 |
| 手机 | 用于连接热点并配网 |
二、项目结构
AP+STA/
├── Cargo.toml # 项目配置和依赖
├── build.rs # 构建脚本(链接器配置)
├── web/
│ └── index.html # 配网 Web 页面(编译时嵌入固件)
└── src/
├── lib.rs # 库入口,声明模块
├── state.rs # 共享状态:常量、全局变量、类型
├── wifi.rs # WiFi 控制:扫描、连接、断开
├── dhcp.rs # DHCP 服务器:给连热点的设备分配 IP
├── http.rs # HTTP 服务器:处理 Web 页面请求
├── led.rs # LED 指示灯:不同状态不同闪烁
└── bin/
└── main.rs # 程序入口:初始化硬件 + 启动任务
为什么拆成这么多文件?
嵌入式代码通常很紧凑,但每个模块职责不同:
state.rs是"数据中心",所有模块都从这里读取共享状态wifi.rs管硬件(射频),dhcp.rs管协议(IP 分配),http.rs管应用(Web 交互)- 拆开后每个文件 100-200 行,容易理解
三、核心代码讲解
3.1 共享状态(state.rs)
所有任务共用的数据集中管理:
rust
// 设备状态机(原子变量,无锁读写)
pub const STATE_AP: u8 = 0; // 等待配网
pub const STATE_CONNECTING: u8 = 1; // 正在连接路由器
pub const STATE_CONNECTED: u8 = 2; // 已连接
pub static APP_STATE: AtomicU8 = AtomicU8::new(STATE_AP);
为什么用 AtomicU8?
多个任务同时读写状态:
- WiFi 任务写入状态(连接成功/失败)
- LED 任务读取状态(决定闪烁频率)
- HTTP 任务读取状态(返回给前端)
原子变量保证读写是线程安全的,不需要加锁。
rust
// 任务间通信通道(容量 1,一发一收)
pub static CMD: Channel<CriticalSectionRawMutex, WifiCmd, 1> = Channel::new();
pub static RESP: Channel<CriticalSectionRawMutex, WifiResp, 1> = Channel::new();
Channel 是什么?
embassy 的 Channel 类似消息队列:
- HTTP 任务发
CMD:"请扫描 Wi-Fi" - WiFi 任务收
CMD,执行扫描,发RESP:"扫描完了,结果在这" - HTTP 任务收
RESP,返回给浏览器
容量为 1,保证每次只处理一个请求,不会乱序。
3.2 程序入口(main.rs)
rust
#[esp_rtos::main] // embassy 异步运行时入口
async fn main(spawner: Spawner) -> ! {
// 1. 初始化硬件
let p = esp_hal::init(esp_hal::Config::default().with_cpu_clock(CpuClock::max()));
esp_alloc::heap_allocator!(size: 128 * 1024); // 128KB 堆
// 2. 启动 embassy 运行时
let timg0 = TimerGroup::new(p.TIMG0);
let sw_int = SoftwareInterruptControl::new(p.SW_INTERRUPT);
esp_rtos::start(timg0.timer0, sw_int.software_interrupt0);
// 3. 配置 AP+STA 双模式
let ap_cfg = AccessPointConfig::default()
.with_ssid("ESP32-Setup")
.with_password(String::from("12345678"))
.with_auth_method(AuthenticationMethod::Wpa2Personal);
let (controller, ifaces) = esp_radio::wifi::new(
p.WIFI,
ControllerConfig::default().with_initial_config(
Config::AccessPointStation(StationConfig::default(), ap_cfg)
),
).unwrap();
// 4. 创建网络栈
let (ap_stack, ap_runner) = embassy_net::new(...); // AP 侧
let (sta_stack, sta_runner) = embassy_net::new(...); // STA 侧
// 5. 启动所有异步任务
spawner.spawn(net_runner(ap_runner).unwrap());
spawner.spawn(net_runner(sta_runner).unwrap());
spawner.spawn(dhcp_task(ap_stack).unwrap());
spawner.spawn(led_task(led_pin).unwrap());
spawner.spawn(wifi_task(controller, sta_stack).unwrap());
spawner.spawn(http_task_ap(ap_stack).unwrap());
spawner.spawn(http_task_sta(sta_stack).unwrap());
loop { Timer::after(Duration::from_secs(3600)).await; }
}
任务包装层是什么?
rust
// 库里的 async fn 不能直接被 spawner 使用
// 需要用 #[embassy_executor::task] 包装,生成 SpawnToken
#[embassy_executor::task(pool_size = 2)] // AP + STA 各一个
async fn net_runner(mut r: Runner<'static, Interface<'static>>) { r.run().await }
#[embassy_executor::task]
async fn dhcp_task(s: Stack<'static>) { dhcp::dhcp_task(s).await }
#[embassy_executor::task] 做了两件事:
- 把 async fn 包装成 embassy 能调度的任务
- 预分配任务栈空间(pool_size 决定同时运行几个实例)
3.3 WiFi 控制(wifi.rs)
WiFi 控制是整个系统的核心,通过 Channel 接收命令:
rust
pub async fn wifi_control_task(mut controller: WifiController<'static>, sta_stack: Stack<'static>) {
loop {
match CMD.receive().await { // 阻塞等待命令
WifiCmd::Scan => handle_scan(&mut controller).await,
WifiCmd::Connect(ssid, pw) => handle_connect(&mut controller, &sta_stack, ssid, pw).await,
WifiCmd::Disconnect => handle_disconnect(&mut controller).await,
}
}
}
扫描流程:
rust
async fn handle_scan(controller: &mut WifiController<'static>) {
// 调用 esp-radio 的扫描 API
let results = controller.scan_async(&ScanConfig::default()).await;
// 把结果转成 JSON 发回给 HTTP 任务
RESP.send(WifiResp::ScanDone(list)).await;
}
连接流程:
rust
async fn handle_connect(...) {
// 1. 保存 SSID 到全局状态
// 2. 配置 STA 参数(SSID + 密码)
// 3. 调用 controller.connect_async()(20s 超时)
// 4. 等待 DHCP 获取 IP(15s 超时)
// 5. 更新全局状态
APP_STATE.store(STATE_CONNECTED, Ordering::Relaxed);
}
3.4 DHCP 服务器(dhcp.rs)
AP 模式下,ESP32 自己就是 DHCP 服务器,给连上热点的手机分配 IP。
DHCP 协议简化流程:
手机:广播 Discover("有没有 DHCP 服务器?")
ESP32:单播 Offer("给你 192.168.4.10")
手机:广播 Request("我要 192.168.4.10")
ESP32:单播 ACK("确认,租约 3600 秒")
核心代码:
rust
pub async fn dhcp_task(stack: Stack<'static>) {
let mut socket = UdpSocket::new(stack, ...);
socket.bind(67).unwrap(); // DHCP 服务器端口
loop {
let (n, _) = socket.recv_from(&mut buf).await.unwrap();
let pkt = &buf[..n];
// 校验:BOOTREQUEST(op=1) + Magic Cookie
if pkt[0] != 1 || pkt[236..240] != [99, 130, 83, 99] { continue; }
// 提取消息类型(option 53)
let msg_type = dhcp_opt(pkt, 53).unwrap()[0];
let mac: [u8; 6] = pkt[28..34].try_into().unwrap();
// 分配 IP
let ip_octet = match msg_type {
1 => assign_or_get(mac), // Discover
3 => register_client(mac, dhcp_opt(pkt, 50)...), // Request
_ => continue,
};
// 构建响应并发送
let len = build_dhcp_resp(&mut resp, code, &xid, &mac, &ip);
socket.send_to(&resp[..len], dest).await.ok();
}
}
IP 地址池管理:
rust
const DHCP_START: u8 = 10; // 192.168.4.10
const DHCP_END: u8 = 50; // 192.168.4.50
fn assign_or_get(mac: [u8; 6]) -> u8 {
critical_section::with(|cs| {
let state = &mut *CLIENT_STATE.borrow(cs).borrow_mut();
// 已有租约则复用(客户端重启后保持同一 IP)
if let Some(c) = state.leases.iter().find(|l| l.mac == mac) {
return c.ip_last_octet;
}
// 从地址池分配新 IP
let octet = state.next_ip;
state.leases.push(DhcpLease { mac, ip_last_octet: octet }).ok();
state.next_ip = if state.next_ip >= DHCP_END { DHCP_START } else { state.next_ip + 1 };
octet
})
}
3.5 HTTP 服务器(http.rs)
Web 配网的核心------处理浏览器发来的 API 请求:
rust
pub async fn http_server_task(stack: Stack<'static>, label: &'static str) {
loop {
let mut socket = TcpSocket::new(stack, &mut rx_buf, &mut tx_buf);
socket.accept(80).await.unwrap(); // 监听 80 端口
let req = read_request(&mut socket).await;
let (method, path) = parse_request(&req);
match (method, path) {
("GET", "/") => send(socket, "200 OK", "text/html", HTML).await,
("GET", "/api/scan") => api_scan(&mut socket).await,
("POST", "/api/connect") => api_connect(&mut socket, &req).await,
("GET", "/api/status") => api_status(&mut socket).await,
("POST", "/api/disconnect") => api_disconnect(&mut socket).await,
_ => send(socket, "404", "text/plain", b"404").await,
}
}
}
API 接口说明:
| 接口 | 方法 | 功能 | 请求 | 响应 |
|---|---|---|---|---|
/ |
GET | 返回配网页面 | - | HTML |
/api/scan |
GET | 扫描附近 Wi-Fi | - | {"networks":[...]} |
/api/connect |
POST | 连接到路由器 | {"ssid":"...","password":"..."} |
{"ok":true} |
/api/status |
GET | 查询连接状态 | - | {"state":"connected","ip":"..."} |
/api/disconnect |
POST | 断开连接 | - | {"ok":true} |
简易 JSON 解析(不依赖 serde):
嵌入式系统资源有限,不引入 serde 库,手写简单的字符串查找:
rust
fn find_json_str<'a>(json: &'a str, key: &str) -> Option<&'a str> {
let needle: String = ["\"", key, "\":\""].concat(); // 构建 "key":"
let start = json.find(needle.as_str())? + needle.len();
let end = json[start..].find('"')?;
Some(&json[start..start + end])
}
3.6 LED 指示灯(led.rs)
最简单的任务------根据设备状态控制闪烁频率:
rust
pub async fn led_task(mut led: Output<'static>) {
let mut on = false;
loop {
let ms = match APP_STATE.load(Ordering::Relaxed) {
STATE_AP => 100, // 快闪:等待配网
STATE_CONNECTING => 250, // 中闪:正在连接
STATE_CONNECTED => 1000, // 慢闪:已连接
_ => 100,
};
on = !on;
led.set_level(if on { Level::Low } else { Level::High });
Timer::after(Duration::from_millis(ms)).await;
}
}
ESP32 板载 LED 通常是低电平点亮 (active-low),所以 Level::Low 是亮。
四、Web 前端页面
配网页面是纯 HTML + JavaScript,编译时通过 include_str! 嵌入固件:
rust
pub const HTML: &str = include_str!("../web/index.html");
页面功能:
- 显示热点信息(SSID、网关 IP)
- 扫描附近 Wi-Fi 列表(信号强度、加密方式)
- 选择网络 → 输入密码 → 点击连接
- 连接成功后显示设备 IP
- 每 3 秒自动刷新状态
前端轮询逻辑:
javascript
// 点击"连接"后,轮询状态直到连接成功或超时
function pollConnectResult(ssid, attempts) {
if (attempts >= 30) { showStatus("连接超时"); return; }
setTimeout(function() {
fetch('/api/status').then(r => r.json()).then(data => {
if (data.state === 'connected') {
showStatus("连接成功!IP: " + data.ip);
} else if (data.state === 'connecting') {
pollConnectResult(ssid, attempts + 1); // 继续轮询
} else {
showStatus("连接失败");
}
});
}, 1000); // 每秒检查一次
}
五、构建与烧录
5.1 环境准备
bash
# 安装 Rust(ESP32 专用工具链)
espup install
# 安装依赖工具
cargo install espflash
cargo install probe-rs-tools
5.2 编译
bash
cd examples/AP+STA
cargo build --release
5.3 烧录
bash
# 方式一:USB 直接烧录
cargo run --release
# 方式二:使用 probe-rs
cargo run --release --probe-rs
5.4 查看日志
bash
# RTT 日志(推荐)
probe-rs attach --chip esp32s3
# 或者串口监控
espflash monitor
六、关键概念速查
6.1 embassy 是什么?
embassy 是 Rust 的嵌入式异步运行时,类似 Tokio 但针对 MCU:
| 特性 | 说明 |
|---|---|
| async/await | 用同步的写法写异步代码,没有回调地狱 |
| 零开销 | 编译时生成状态机,没有运行时开销 |
| 任务调度 | 协作式调度,任务主动 yield |
| 定时器 | Timer::after(Duration::from_secs(1)).await |
6.2 critical_section 是什么?
嵌入式系统中的临界区保护:
- 进入临界区时关闭中断
- 退出临界区时恢复中断
- 保证同一时刻只有一个任务访问共享数据
rust
critical_section::with(|cs| {
let state = &mut *CLIENT_STATE.borrow(cs).borrow_mut();
// 这里可以安全地修改共享状态
});
6.3 heapless 是什么?
不用堆分配的数据结构,编译时确定容量:
rust
use heapless::String as HString; // 最多 32 字节的字符串
use heapless::Vec as HVec; // 最多 20 个元素的数组
let mut s = HString::<32>::new();
s.push_str("Hello").ok(); // 容量不够时返回 Err 而不是 panic
嵌入式系统堆空间有限(本项目只有 128KB),用 heapless 避免堆分配失败。
6.4 mk_static! 宏是什么?
rust
macro_rules! mk_static {
($t:ty, $val:expr) => {{
static CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
CELL.uninit().write($val)
}};
}
embassy 任务需要 'static 生命周期的数据。这个宏在 BSS 段(全局内存)分配空间,而不是在栈上。栈上数据在函数返回后会失效,BSS 段的数据永远存在。
七、常见问题
Q: 扫描不到 Wi-Fi?
- 确认 ESP32 天线连接正常
- 检查日志中是否有 "扫描完成: X 个网络"
- 某些 5GHz 网络可能扫不到(ESP32-S3 支持 2.4GHz + 5GHz)
Q: 连接路由器失败?
- 检查密码是否正确
- 确认路由器是 WPA2/WPA3 加密(不支持 WEP)
- 检查路由器是否开启了 MAC 过滤
Q: 打不开配网页面?
- 确认手机已连接到 ESP32 热点(不是移动数据)
- 浏览器地址栏输入
192.168.4.1(不是搜索引擎) - 尝试关闭手机的移动数据
Q: 编译报错 "cannot find macro mk_static"?
- 确认
main.rs中有use ap_sta_coex::mk_static; #[macro_export]宏需要显式引入
八、参考资料
作者:CXi
项目地址:https://github.com/cx693/Rust_ESP32_Dome
如有问题,欢迎提 Issue!