AP+STA 共存模式实战

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] 做了两件事:

  1. 把 async fn 包装成 embassy 能调度的任务
  2. 预分配任务栈空间(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!