联网与通信
第 21 课:板子开热点——手机遥控 LED
板子自己开热点当网页服务器,手机浏览器点一下按钮,灯就亮了
这节课只有代码路线能做。 走图形化路线的同学可以跳过,不影响后面的课程。
原因很简单:这一课要让板子当网页服务器,需要 AP 模式和 WebServer 这两个功能。图形化平台的积木盒里没有这两样东西——不是你学不会,是那个平台没给这个工具。 跳过它,直接看第 22 课,完全接得上。
我是吴老师。这一课是联网单元里最特别的一节。
特别在哪?我先给你列一下它不需要什么:不需要路由器(上节课还得求着路由器放你进去)、不需要 MQTT 服务器(第 23 课要用)、不需要第二块板子(第 24 课要用)、不需要注册任何账号。
整节课你只需要三样东西:这块板子、一根数据线、一部手机。这是整个套件里,唯一一节什么都不用加就能做出来的联网实验。
一、先说你今天会看到什么
我把结果先给你看,因为这一节的体验特别直观:板子通上电跑上程序 → 你打开手机 WiFi 列表,看到一个叫 ESP32-LED 的网络 → 输密码连上去 → 打开浏览器输入一个地址 → 屏幕上出现一个网页 → 网页上有个按钮,你点一下,板子上的灯亮了。
第 5 步是这一课的灵魂,我要专门说一下:那个网页,不是从互联网上下载的。你家网线没插,路由器没开,流量也没走。那个网页,是从你手上这块板子里"吐"出来的。
二、视角的转变:它能给别人提供网页
上节课你学的是:板子去连别人的网。 它是客人,得报名字、交密码、等路由器放它进去。这一节课,板子自己当主人。
上节课的板子,是"打电话的人"——它拨号、它报名字、它等着对方接,主动权在路由器那边。
这节课的板子,是自己支了个摊。 它不需要任何人批准,它自己发一个信号出去:"我这有个网,叫 ESP32-LED,谁想来连?" 你的手机连上去,你和板子就组成了一个只有两个成员的小网络。这个网里没有互联网,也不需要互联网——它只是你和板子之间的一条链路。
然后是最关键的一步:它还是个"服务器"
光连上还不够。连上了只是一个"通道",就像你俩之间拉了根电话线,可电话那头还没人接。接下来板子做了一件很了不起的事:它在电话那头坐下来了。
学名叫服务器。这个词听着挺唬人——大部分人听到"服务器",想到的是机房里那种一人多高、嗡嗡响的大铁柜子。不是的。服务器的本质特别朴素:一个坐在那儿、等着别人来问、问什么就答什么的东西。一秒钟几百万次请求的大铁柜子是服务器,你这块二十块钱的板子,也是服务器。
用自动售货机来理解
这是我想到的最好的比喻。自动售货机不认人,也不需要认识你。 它就杵在那儿,你按"可乐",它就掉一罐可乐出来;你按"雪碧",就掉一罐雪碧;你按一个它没有的号,它不吭声。它不需要知道你是谁,只需要知道你按了哪个按钮。
| 自动售货机 | 你的板子 |
|---|---|
| 面板上的按钮 | 网页上的链接(/、/on、/off) |
| 你按了"可乐" | 手机访问 /on |
| 掉出来一罐可乐 | 板子回一段 HTML 文字 |
| 补货员改价格表 | 你改代码,重新烧录 |
这就是"服务器"这三个字的全部含义。记住这个画面,后面 MQTT、HTTP 全用它。
那"网页"到底是什么
还有一个认知要扭过来,这一课先埋个种子,第 22 课会展开讲:板子发过去的东西,不是一张画好的网页,是一堆文字。 就像这样一堆:<h2>这是我的 ESP32-C3</h2><button>开灯</button>。
是浏览器拿到这堆文字,照着它画出了你看到的那个漂亮网页。板子负责"写说明书",浏览器负责"照着说明书搭"。 这个分工你要记住——第 22 课我们会看到,板子去访问别人时,拿到的也是这么一堆文字。
三、接线:先把灯接上
| LED 模块 | 接到 |
|---|---|
| VCC | 3.3V |
| GND | GND |
Signal |
GPIO3 |
接线之前先断电——这是第 6 课就定下的规矩,一直没变。
为什么还用
GPIO3? 因为它是这块板子上最省心的引脚之一:不是 strapping 脚、不跟 flash 抢、不跟 USB 抢。(GPIO2、GPIO8、GPIO9那三个 strapping 脚建议避开,第 4 课讲过。)另外提醒一句:等以后你做"联网 + 模拟传感器"的项目时,别用
GPIO5——它是 ADC2 的唯一一路,一开 WiFi 读数就乱。做模拟实验用GPIO0到GPIO4。
四、第一步:只开热点
别一次写完。咱们一步一步来,每一步都要看到一个现象,再往下走。
#include <WiFi.h>
const char* AP_NAME = "ESP32-LED";
const char* AP_PASS = "12345678"; // 至少 8 位,短了热点起不来
void setup() {
Serial.begin(115200);
delay(1500);
WiFi.mode(WIFI_AP); // 当"主人",自己开一个网
WiFi.softAP(AP_NAME, AP_PASS); // 开!
Serial.print("热点名字:");
Serial.println(AP_NAME);
Serial.print("板子在这个网里的地址:");
Serial.println(WiFi.softAPIP()); // 自己打印出来,别信我说的
}
void loop() {
}烧进去,打开串口监视器。你会看到板子打印出一个地址,大概率是 192.168.4.1,但以你打印出来的为准。
为什么不写死? 因为我没有你手上那块板子。库的版本、你之前改过什么设置,都可能让这个地址不一样。我给你个"大概率",你自己打印确认——这就是第 5 课那句"先测,再猜"。
把打印出来的这个地址记在纸上。 下面每一步都要用它。写错了,后面全打不开,而且现象是"没反应",你根本猜不到是地址错了。
现在拿起手机,打开 WiFi 列表。看到一个叫 ESP32-LED 的网络了吗?看到了 → 恭喜,你的板子已经是个热点了。 看不到的话先别急,看下面第五节。
五、连不上、打不开的时候,按这个顺序查
注意:这一节的顺序跟第 20 课不一样。 第 20 课是"板子去连别人",这一课是"别人来连板子",所以排查方向反过来了——从板子这一头开始查,往手机那头走。
第 1 步:串口打印出来了吗
先看串口。 一个字都没有 → 程序没跑起来,不是网络问题,回第 7 课。打印了但热点名字不对 → 你代码里写的就是那个名字,改代码。打印了 IP 地址 → 好,把那个地址抄下来,往下走。
第 2 步:手机 WiFi 列表里能看到这个名字吗
看不到名字,说明热点根本没起来。 最常见的原因:密码少于 8 位——WiFi.softAP() 的密码最少要 8 个字符,短了它会开不起来,而且不一定报错,这是新手最容易踩的坑。看到名字了但连不上? 那就是密码的问题:大小写、数字 0 和字母 O,跟第 20 课一样的坑。
第 3 步:手机真的连上了吗
这一步最容易被忽略,因为手机经常会"假装"连上了。 看手机的状态栏或者 WiFi 详情页:是不是显示"已连接"?有没有给你分配一个 192.168.4.x 的地址?
十有八九,手机会弹一个提示:"此 WiFi 无法访问互联网,是否继续使用?"——这是完全正常的,不是故障。板子确实不能上网。 它的热点只是"手机 ↔ 板子"这条线,板子自己没连互联网,也不会帮手机转发。所以手机发现"这个网通不了外网",是它如实汇报。你要选"继续使用"或者"仍然连接"。
有的手机不弹窗,直接把它标成"无互联网连接",还在 WiFi 图标上打个感叹号。不用管那个感叹号。
第 4 步:手机是不是偷偷切回移动数据了
这一步是本节课最大的坑,我见过太多人卡在这里。 安卓和 iOS 都有个"贴心"的功能:发现当前 WiFi 上不了网,就自动切回移动数据。这个功能在平时是好事,但你做实验的时候是灾难——因为手机会用移动数据去打开你输的地址,那当然什么都打不开,板子那边的串口里,一个请求都不会出现。
两种解决办法。办法一(推荐,最粗暴有效):把移动数据整个关掉。 设置 → 移动网络 → 关掉。这一步没有副作用,而且能立刻排除掉这个可能。注意:别开飞行模式,飞行模式会把 WiFi 也一起关掉。办法二:关掉"自动切换"——这个开关在不同手机上叫法完全不一样:
| 系统 | 大概在哪儿 | 叫什么 |
|---|---|---|
| iPhone | 设置 → 蜂窝网络 | 无线局域网助理 |
| 安卓 | WLAN 设置 → 高级 | WLAN+ / 自动切换移动数据 / 智能网络切换 |
名字五花八门,找不到就直接用办法一。
第 5 步:浏览器里的地址对不对
两个常见的错误。错误一:输了 https://。 现在的浏览器很"聪明",你在地址栏敲个 IP,它可能自动给你补成 https://192.168.4.1。补上就完了——板子根本不懂 https,它只认 http,你会看到"无法建立安全连接"之类的报错。正确写法:老老实实敲全 http://192.168.4.1。
错误二:地址抄错了。 192.168.4.1 和 192.168.1.4 长得太像了。对着串口打印的那一行,一个数字一个数字地对。
第 6 步:页面能打开,但按钮点了没反应
这一条是"好消息"——说明前面五步全对了。这时候去看串口:点按钮的瞬间,有没有打印出一行"收到请求"?有打印 → 请求到了板子,问题在灯那一侧,查接线(Signal 是不是插在 GPIO3 上)、查引脚号;没打印 → 请求根本没到,回到第 4 步,手机还在偷跑移动数据。
看到了吗?又是"先打印,再判断"。串口里那一行字,直接告诉你问题在哪一半。
六、第二步:让板子回一个网页
热点通了,现在让板子学会"答话"。
#include <WiFi.h>
#include <WebServer.h>
const char* AP_NAME = "ESP32-LED";
const char* AP_PASS = "12345678";
WebServer server(80); // 板子在 80 号端口上"坐着等人来问"
// 有人访问 "/" 的时候,执行这个函数
void handleRoot() {
server.send(200, "text/html; charset=utf-8",
"<meta charset='utf-8'><h2>你好,我是 ESP32-C3</h2>");
}
void setup() {
Serial.begin(115200);
delay(1500);
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_NAME, AP_PASS);
Serial.print("地址:");
Serial.println(WiFi.softAPIP());
server.on("/", handleRoot); // 有人按"/"这个按钮,交给 handleRoot 处理
server.begin(); // 开始营业
Serial.println("服务器开张了");
}
void loop() {
server.handleClient(); // 一直盯着,有人来就招呼
}烧进去,手机连上热点,浏览器打开 http://192.168.4.1。屏幕上会出现一行字:"你好,我是 ESP32-C3"。这行字是从板子里出来的。
那句
server.handleClient()必须放在loop()里。因为它是"售货机里那个听声音的人"——你得让它一直在听。 忘了写,或者写进了
setup(),板子只会开张一次然后就不理人了,现象是"网页打不开"。
七、第三步:加上按钮,把灯点起来
#include <WiFi.h>
#include <WebServer.h>
const char* AP_NAME = "ESP32-LED";
const char* AP_PASS = "12345678";
const int LED_PIN = 3;
WebServer server(80);
// 网页。R"rawliteral(...)" 是"原样输出"的写法,里面写什么就发什么
const char* PAGE = R"rawliteral(
<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="font-family:sans-serif;text-align:center;padding-top:50px">
<h2>这是我的 ESP32-C3</h2><p>这个网页是它自己发出来的</p>
<p><a href="/on"><button style="font-size:20px;padding:14px 30px">开灯</button></a></p>
<p><a href="/off"><button style="font-size:20px;padding:14px 30px">关灯</button></a></p>
</body></html>
)rawliteral";
void handleRoot() { server.send(200, "text/html; charset=utf-8", PAGE); }
void handleOn() {
digitalWrite(LED_PIN, HIGH);
Serial.println("收到请求:开灯");
server.send(200, "text/html; charset=utf-8",
"<meta charset='utf-8'><h2>灯亮了</h2><a href='/'>回去</a>");
}
void handleOff() {
digitalWrite(LED_PIN, LOW);
Serial.println("收到请求:关灯");
server.send(200, "text/html; charset=utf-8",
"<meta charset='utf-8'><h2>灯灭了</h2><a href='/'>回去</a>");
}
void setup() {
Serial.begin(115200);
delay(1500);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_NAME, AP_PASS);
Serial.print("地址:");
Serial.println(WiFi.softAPIP());
server.on("/", handleRoot);
server.on("/on", handleOn);
server.on("/off", handleOff);
server.begin();
Serial.println("服务器开张了,用手机连 ESP32-LED 试试");
}
void loop() {
server.handleClient();
}烧进去,手机连上热点,浏览器打开 http://192.168.4.1,点"开灯"。板子上的灯亮了。
你现在做的这件事,跟"智能家居"是同一件事。 手机 App 控制客厅的灯,原理跟你这个一模一样:手机发一个请求过去,设备收到,动作。区别只是那台设备连的是公网,你这块板子连的是你的手机。但骨架是同一个骨架。
顺手试一件事:点完"开灯",回串口监视器看一眼。每次点按钮,那里都会多一行字。 这就是"每排除一个,范围就小一圈"的反面——每次打印,都让你多看见一层。
自测
答案与解析
板子不是路由器。 路由器会帮你把数据转发到外面去,板子不会——它也没这个本事。 它开的热点是一个"两个人的小房间",房间里只有你和它,没有通向外面的门。所以手机提示"无互联网连接",是它说了实话。选"继续使用"就行。
答案与解析
这节课从头到尾就不需要路由器。 板子开热点是"自己当主人",跟家里那个路由器一点关系都没有。这个选项之所以要单独拎出来讲,是因为它代表了最容易犯的思维定势——上节课刚学完"连路由器",这节就下意识觉得"没连上路由器肯定不行"。排查的时候,先把最不符合原理的那种可能划掉,你就少查一步。
答案与解析
判断谁是服务器,看的不是"谁在发命令",而是**"谁坐在那儿等着被访问、并负责回应"。手机是客户端**(主动去问的那一方),手机上的浏览器是客户端软件。路由器连参与都没参与——这一课根本不用它。
答题要点
答题要点应该包含:像的地方——售货机不认人、不需要认识你,你按哪个按钮它就出哪个货;它一直杵在那儿等着,你不按它也不着急;它只会做预先设定好的几件事,你按一个不存在的号它就没反应。板子也一样:它只认 /、/on、/off 这几个写死在代码里的"按钮"。不太贴切的地方——售货机的按钮是有限的几个,而服务器理论上可以接受任何请求,怎么回应完全取决于你怎么写程序。能主动说出比喻的边界,说明你不是在背概念。
拓展板子开的热点,最多能连几台手机?连满了会怎么样?
这个问题课上不讲,留给你自己找答案——**问 AI 是最好的办法**。
提示:搜"ESP32 softAP 最大连接数 max_connection"。想一想:如果十台手机同时点"开灯",那颗灯会怎样?板子扛得住吗?
拓展手机和板子之间到底传了些什么?能不能亲眼看见?
这个问题课上不讲,留给你自己找答案——**问 AI 是最好的办法**。
提示:搜"浏览器 开发者工具 Network 面板"、"HTTP 请求头 响应头"。想一想:用电脑连上这个热点,打开网页按 F12,你能看到哪些东西?
本节你要动手做的事
- [ ] 接好 LED 模块:
VCC→3.3V,GND→GND,Signal→GPIO3(接之前先断电) - [ ] 只开热点:跑第一段代码,把打印出来的地址抄在纸上
- [ ] 手机连上去:看到
ESP32-LED、输密码、选"继续使用"、记住那个"无互联网"提示是正常的 - [ ] 看到板子的网页:浏览器敲
http://+ 那个地址,确认那行字是板子发的 - [ ] 点按钮把灯点亮:点"开灯"灯亮,点"关灯"灯灭
- [ ] 看一眼串口:每点一次按钮,串口里多一行什么?这行字是从哪来的?
- [ ] 故意把地址输错一位:看看是什么现象。记住这个现象,以后一眼就能认出来
- [ ] 把移动数据关掉再试一遍:对比一下,有没有区别
停下来,把今天做的事在脑子里过一遍。
你手上这块板子:它开了一个网,它接住了一个请求,它回了一段文字,它点亮了一颗灯。 从头到尾,没有路由器、没有互联网、没有云服务器、没有花一分钱。
我再强调一次那个视角:你的板子不只是"能上网的设备",它自己就是一方。它能给别人提供服务。 这个视角你带走了,后面学 MQTT 会轻松很多——那时候你会明白,所谓"云平台",也不过就是一台一直开着的服务器而已,跟你这块板子干的事,本质上是同一件。
下一节我们把方向再反过来一次。这节课板子是服务器,别人来访问它;下一节课板子是客户端,它去访问别人——跑到公网上取一段真实的数据回来。
到时候你会发现,那个"取回来的东西",长得特别像你今天发出去的那堆 HTML。