使用 Arduino 开发 ESP32 简介
ESP32 开发板介绍
ESP32 是一款国产芯片,这个芯片专为移动设备、可穿戴设备与物联网应用而设计,集成了低功耗蓝牙和 Wi-Fi。这也是为什么 ESP32 在 DIY 爱好者中备受推崇的原因。

其中模块的含义:
| 序号 | 功能 |
|---|---|
| 1 | 复位按键 |
| 2 | MicroUSB 接口,用于程序下载、电源输入等 |
| 3 | BOOT 按键:启动模式选择,按下表示下载模式,放开表示运行模式 |
| 4 | ESP32-WROOM-32 模组:通用型 Wi-Fi + BT + BLEMCU 模组,模组集成了传统蓝牙、低功耗蓝牙和Wi-Fi,具有广泛的用途:Wi-Fi 支持极大范围的通信连接,也支持通过路由器直接连接互联网。 |
| 5 | GPIO(general purpose intput output)是通用输入输出端口的简称,可以通过软件来控制其输入和输出。 |
ESP32 芯片有 48 个引脚,具有多种功能,并不是所有的 ESP32 开发板的管脚都暴露在外,有些管脚不能使用。
ESP32 芯片有 34 个可编程的 GPIO 引脚,每个引脚执行多个功能,也就是 IO 口复用,设置 IO 口功能的时候只有一个功能会被激活。可以在程序中将引脚配置为 GPIO、ADC、UART 等等。此外,有些引脚具有特定的功能,使得它们适合或不适合特定的项目。
ESP32 开发板引脚图如下:

ESP32 开发方式
ESP32 的开发方式主要有三种:
MicroPython,常用的开发软件为 Thonny,支持 Python 语法,容易上手Arduino,便捷灵活、方便上手的开源电子原型平台,支持 ESP32、ESP8266等,可以使用 Arduino IDE 或者 VSCode/Clion + PlatformIO 进行项目开发。ESP-IDF,是乐鑫官方的物联网开发框架,基于 C/C++ 语言提供了一个自给自足的 SDK,方便用户在这些平台上开发通用应用程序。
还有几个比较小众的开放方式比如 Lua、Javascript 等等,大家可以去了解。
什么是 Arduino?
Arduino 是一款开源电子原型平台,由意大利的开发者 Massimo Banzi 和 David Cuartielles 共同开发。它由硬件和软件两部分组成,硬件部分包括一个控制器和一些电子元件,软件部分则是基于简单易学的 C++ 编程语言的 Arduino IDE。
Arduino 的最大优势就是它简单易学,开发门槛低,所以成为了电子制作、物联网等领域的热门选择。在 Arduino 的官方网站上,你可以找到各种各样的项目示例,从简单的 LED 闪烁到复杂的机器人控制都有。同时,Arduino 社区非常活跃,有很多志同道合的开发者分享自己的作品和经验,可以帮助新手更快地入门。
如果你是初学者,可以从以下几个方面了解 Arduino:
- 了解 Arduino 的特点和优势。Arduino 是一款方便快捷的原型开发平台,拥有简单易学的编程语言和大量的开源项目,可以让你快速上手,快速实现自己的创意;
- 了解 Arduino 的硬件组成。Arduino 包括一个主板和一些电子元件,如电阻、电容、LED 灯等。其中最重要的是主板,它包括一个控制器,可以通过 USB 接口与电脑进行通信,以及一些引脚,可以连接其他电子元件;
- 学习 Arduino 的编程语言。Arduino 的编程语言是基于 C++ 的,但是简化了很多语法,让初学者容易上手。你可以通过 Arduino IDE 编写代码,并将代码上传到主板上运行;
- 尝试一些简单的项目。例如让 LED 灯闪烁、控制舵机转动、检测温度等。这些简单的项目可以帮助你熟悉 Arduino 的编程语言和硬件组成,为以后的更复杂的项目打下基础。
总的来说,Arduino 是一款十分有趣和有用的电子原型平台。对于初学者来说,可以通过 Arduino 入门电子制作,实现自己的创意。希望这篇文章可以帮助你初步了解 Arduino,并激发你对电子制作的兴趣。
安装 Arduino 开发环境
想要玩开发板必须得写代码,要不然 Arduino 不知道怎么运行,Arduino 的开发语言是 C 语言,大家可能都听说过 C 语言很难,不适合新手小白入门。这一点大家不需要担心,准确来说,Arduino 属于类 C 语言,并没有 C 语言那么复杂,只用到了 C 语言中的基本语法。这也是为什么 Arduino 能够如此流行的原因。
下载 Arduino
官方下载地址:www.arduino.cc/en/software
打开网页就是下面这个图,官方的支持两种编程方式,一种是 Code Online,一种是下载软件本地编程,为了编程体验,果断选择下载软件。
官方的 IDE 支持不同的操作系统,根据你自己的平台选择即可。

官方最新的 IDE 已经出到了 2.0.4,但是 Arduino 2.x 仅支持 Win 10 之后的系统,所以如果你的电脑上是 Win7 的话,就只能选择 Arduino 1.8.x 了,当前页面继续下拉即可找到 Arduino 1.8.19。本套教程也将采用 1.8.19 版本演示。
提示
你也不需要纠结到底该使用 Arduino 1.x 还是 2.x 版本,因为后期,我们会使用 PlatformIO 开发 ESP32。

点击链接后会跳转到下载界面,如果你想要为开源软件做点贡献,可以考虑捐款,不想捐的话就点击 JUST DOWNLOAD 即可,在我们的资料包中也有 Arduino 1.8.19 与 2.0.4 的安装程序。

安装过程很简单,一直点击下一步即可,

勾选所有选项,点击下一步,

选择一个合适的安装路径,之后点击 install,等待安装完成即可。

安装 ESP32 开发环境
为了能够使用 Arduino IDE 开发 ESP32,你需要向 Arduino IDE 板管理器添加一个额外的源,然后安装 ESP32。我们需要先添加 ESP32 开发板附加网址。打开文件 菜单下的 首选项。

把下面的链接复制粘贴到 附加开发板管理网址 中:
https://espressif.github.io/arduino-esp32/package_esp32_index.json
https://espressif.github.io/arduino-esp32/package_esp32_dev_index_cn.json
提示
第二个链接是国内镜像的链接

再安装 ESP32 开发板,选择 工具 菜单中的 开发板 -> 开发板管理器...

在搜索栏中搜索 esp32,可以看到 esp32 库,选择 2.0.4 版本,点击安装,等待安装完成即可。

选择对应的开发板 ESP32 Dev Module,

选择好开发板后,工具菜单中就会多出一些选项,如果你不会调试的话,按照默认的即可。
接着,选择 ESP32 对应的端口,

最后,我们打开资料包中 0.开机测试代码中的 Arduino 测试代码,编译并运行,

注意
在上传时,按住 BOOT 键,进入下载模式,否则无法连接到单片机。
最后,如果开发板上有一个 LED 在闪烁,说明安装成功。运行的代码是保存在开发板的 FLASH 中的,断电后不会丢失。
常见问题
1. JSON下载失败和下载速度慢的问题
在 Arduino IDE 中的开发板管理器添加开发板 ESP32:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
但是,使用这个地址是没办法正常下载的,就会出现以下错误信息,

- 解决方法一:可以将之前的开发板地址更换为国内可以访问的代理加速地址,比如:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
https://arduino.me/packages/esp32.json
https://dl.espressif.com/dl/package_esp32_index.json
正常来说原地址和镜像地址的主域名会有区别,后面的路径是一样的。这个时候,如果镜像地址没有问题,就可以正常下载到 JSON 文件。
之后,我们就可以在 开发板管理器 中搜索到 esp32 了。

如果以上镜像地址均失效,可以尝试一下离线安装。
- 解决方法二:手动下载 esp32 包,在我们的资料包中的
3.开发工具下的esp32-2.0.4(离线库)。
复制该文件夹中的所有文件,

打开缓存目录:


将离线库的所有文件复制到该目录下的 staging 下的 packages 中:

注意
附加开发板管理器网址 仍然要填写,不能为空。
选择 工具 菜单中的 开发板 -> 开发板管理器...

在搜索栏中搜索 esp32,可以看到 esp32 库,选择 2.0.4 版本,点击安装,等待安装完成即可。

2. 配置解释器没有发现端口

这里有两种解决方法:
- 检查esp32连接电脑的数据线,如果是单纯的供电线是不可以的,需要更换为能传输数据的数据线。
- 安装对应的
ESP32 USB 驱动,可以将资料包中的开发工具中的ESP32 驱动 CP210X下的压缩包解压安装即可。

3. 检测到端口,但是有警告图标,端口无法使用

这种情况很有可能是设备驱动有问题。串口显示黄色的,需要更新设备驱动,如下图,右键设备,点击更新设备驱动。

手动查找驱动程序,

从计算机上的可用驱动程序列表中选取,

选择 端口(COM 和 LPT),

安装两个驱动,第一个是 USB 串行设备,

重复上图的操作,安装另一个驱动 USB 串行调制解调器设备,

这样就 OK 了。

初识 Arduino 编程语言
我们说过 Arduino 是基于 C++,我们需要了解一下,Arduino 的语法以及如何使用。
注释
当你编写代码时,注释(comments)是非常重要的一部分。注释是对代码的解释和说明,而且对于其他开发者或者自己日后需要修改代码的时候,都非常有帮助。注释可以提高代码的可读性和可维护性,并且可以帮助你自己更好地理解代码。
注释是由双斜线 // 或者斜线星号 /*...*/ 来表示的。单行注释以两个斜线开头,多行注释则以斜线星号开头,以星号斜线结尾。例如:
// 这是一个单行注释
/*
这是一个多行注释
它可以跨越多行
*/
注释可以用来解释代码的功能,算法或者实现细节。例如,以下是一些常见的注释用法:
- 函数或者方法的用途
- 参数的说明
- 返回值的说明
- 代码实现的说明
- 代码的限制或者假设条件
- 作者信息、创建时间、修改时间等等
注释应该尽可能的清晰、简洁和明了,同时避免使用无用的注释,以免给代码带来混淆和干扰。注释应该随着代码一起更新,以确保注释和代码的一致性。
变量
当我们编写程序时,变量是一个非常基本的概念。一个变量可以存储一个值,这个值可以是数字、字符串、布尔值、对象等。
在 Arduino 编程语言中,变量需要在使用前声明,声明语法为:
数据类型 变量名;
数据类型指定变量可以存储的数据类型,常见的数据类型有:
int:整数类型,占用2个字节,可以表示范围为 -32768 到 32767 之间的整数。float:浮点数类型,占用4个字节,可以表示小数。char:字符类型,占用1个字节,可以表示一个字符。bool:布尔类型,占用1个字节,只有两个值:true 或 false。
变量名是标识符,命名规则为字母、数字、下划线的组合,第一个字符不能是数字。
下面是一些变量的示例:
int a; // 声明一个名为 a 的整型变量
float b = 3.14; // 声明一个名为 b 的浮点型变量,并初始化为 3.14
char c = 'A'; // 声明一个名为 c 的字符型变量,并初始化为字符 'A'
bool d = true; // 声明一个名为 d 的布尔型变量,并初始化为 true
变量在程序中可以被赋值或者修改:
int a = 10; // 初始化 a 为 10
a = 20; // 修改 a 的值为 20
除了上述基本数据类型外,Arduino 还支持其他的数据类型,如字符串类型 String,数组类型等。在使用变量时,需要根据需求选择合适的数据类型,避免浪费内存。
条件语句
当我们需要根据某个条件来执行不同的代码时,就需要使用条件语句。在 Arduino 编程语言中,常见的条件语句有 if 语句和 switch 语句。
if 语句
if 语句是最基本的条件语句,其语法如下:
if (condition) {
// if 条件成立时要执行的代码
}
其中,condition 是一个条件表达式,如果这个条件表达式的值为真,则执行花括号中的代码块。
如果需要在条件不成立时执行代码,则可以添加 else 语句:
if (condition) {
// if 条件成立时要执行的代码
} else {
// if 条件不成立时要执行的代码
}
当然,也可以在 else 语句后面添加一个 if 语句,从而实现多个条件判断。这个语法结构被称为 else if:
if (condition1) {
// if 条件 1 成立时要执行的代码
} else if (condition2) {
// if 条件 2 成立时要执行的代码
} else {
// 如果以上条件都不成立,则执行这里的代码
}
switch 语句
switch 语句也是一种条件语句,通常用于比较一个变量与一系列常量值。其语法如下:
switch (variable) {
case value1:
// 如果 variable 的值等于 value1,则执行这里的代码
break;
case value2:
// 如果 variable 的值等于 value2,则执行这里的代码
break;
default:
// 如果 variable 的值不等于任何一个 case 的值,则执行这里的代码
break;
}
在 switch 语句中,variable 是要进行比较的变量,而 case 是常量值。如果 variable 的值等于某个 case 的值,则执行该 case 对应的代码块,并且在代码块末尾添加 break 语句,以防止执行其他的 case。如果 variable 的值不等于任何一个 case 的值,则执行 default 中的代码块。需要注意的是,在 switch 语句中,每个 case 的值必须是常量,且不可重复。
总的来说,if 语句和 switch 语句都是用于控制程序执行流程的条件语句,开发者可以根据具体情况选择使用哪种语句。
循环语句
当我们需要重复执行一段代码时,就需要用到循环语句。在 Arduino 中,有两种主要的循环语句:for 循环和 while 循环。
for 循环
for 循环是一个控制结构,它允许你重复执行一系列语句,具体次数由循环次数确定。for 循环的语法如下:
for (初始化表达式; 布尔表达式; 更新表达式) {
// 代码块
}
for 循环由三部分组成:
- 初始化表达式:在循环开始时执行一次,通常用于初始化计数器。
- 布尔表达式:在每次迭代开始前计算,如果结果为 true,则执行循环体语句,否则退出循环。
- 更新表达式:在每次迭代结束后执行,通常用于更新计数器。
下面是一个简单的 for 循环的例子,它输出数字 0 到 9:
for (int i = 0; i < 10; i++) {
Serial.println(i);
}
在这个例子中,初始化表达式初始化了计数器 i 为 0,布尔表达式检查 i 是否小于 10,更新表达式将 i 增加 1。在每次迭代中,计数器 i 的值都会被输出。
while 循环
while 循环是另一种重复执行语句块的方法。它会在条件为 true 时重复执行代码块。while 循环的语法如下:
while (布尔表达式) {
// 代码块
}
while 循环只由一个条件表达式组成,当这个表达式为 true 时,执行循环体语句。在每次循环执行后,条件表达式都会被重新计算。如果条件表达式为 false,则跳过循环体语句,直接执行循环后面的代码。
下面是一个使用 while 循环输出数字 0 到 9 的例子:
int i = 0;
while (i < 10) {
Serial.println(i);
i++;
}
在这个例子中,初始化变量 i 的值为 0。while 循环的条件表达式检查变量 i 是否小于 10。只要条件为 true,循环就会一直执行,每次将变量 i 的值增加 1。在每次循环中,变量 i 的值都会被输出。
总体来说,for 循环适用于知道循环次数的情况,而 while 循环适用于不知道循环次数的情况。
数组
接下来我们来看看如何使用 Arduino 的数组。
数组是一种用于存储多个值的数据类型。数组的每个元素都有一个唯一的索引,可以使用这个索引来访问数组中的元素。
定义数组的语法如下:
type arrayName[arraySize];
其中,type 是数组中元素的数据类型,arrayName 是数组的名称,arraySize 是数组的大小,下面是一个例子:
int myArray[5];
这个代码定义了一个包含 5 个整数的数组,可以使用 myArray[0] 到 myArray[4] 访问这些元素。
可以在定义数组时初始化数组。例如:
int myArray[5] = {1, 2, 3, 4, 5};
这个代码定义了一个包含 5 个整数的数组,并将其初始化为 1、2、3、4、5。
也可以使用以下方式初始化数组:
int myArray[] = {1, 2, 3, 4, 5};
这个代码定义了一个包含 5 个整数的数组,并将其初始化为 1、2、3、4、5。
你可以使用下标访问数组元素。下标从 0 开始,例如:
int myArray[5] = {1, 2, 3, 4, 5};
int x = myArray[2]; // 将 x 的值设置为数组中下标为 2 的元素,即 3。
可以使用循环遍历数组中的所有元素。例如:
int myArray[5] = {1, 2, 3, 4, 5};
for (int i = 0; i < 5; i++) {
// 打印数组中的每个元素
Serial.println(myArray[i]);
}
Arduino 中还支持多维数组。例如:
int myArray[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
这个代码定义了一个 3 行 3 列的二维数组,并将其初始化为:
1 2 3
4 5 6
7 8 9
可以使用两个下标来访问数组中的元素。例如:
int myArray[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
int x = myArray[1][2]; // 将 x 的值设置为数组中第 2 行第 3 列的元素,即 6。
函数
当程序中需要执行某个特定任务时,函数是非常有用的。函数可以包含一些代码块,这些代码块可以在程序的其他地方多次调用。在 Arduino 编程语言中,函数包含了一个函数头和一个函数体。函数头包含了函数名称和参数列表,函数体包含了一些要执行的代码。
以下是一个简单的函数示例:
int add(int a, int b) { //函数头
int sum = a + b; //函数体
return sum;
}
这个函数的名称是 add,它有两个参数,分别是 a 和 b,函数体包含了将 a 和 b 相加的操作,最后通过 return 返回结果。
在程序中调用函数时,可以像这样使用:
int x = 3;
int y = 5;
int z = add(x, y); //调用函数
在这个示例中,我们将 x 和 y 作为参数传递给 add 函数,该函数返回它们的和,最后将结果存储在 z 变量中。
函数的参数也可以是其他类型的数据,例如字符串、浮点数等等。在 Arduino 编程语言中,函数也可以没有参数,也可以没有返回值。以下是一个没有参数和返回值的函数示例:
void sayHello() { //函数头
Serial.println("Hello World!"); //函数体
}
这个函数的名称是 sayHello,它没有参数和返回值。函数体包含了一条输出语句,它将字符串 "Hello World!" 输出到串行监视器中。
函数的使用可以让代码更加清晰、易读和易于维护。通过将代码块封装到函数中,可以使代码更加模块化,也可以避免在多个地方重复编写相同的代码。在编写代码时,应该尽可能地使用函数,以便使代码更加可读、易于维护和可扩展。
常用函数介绍
下面列出了一些常用的 Arduino 函数:
当使用 Arduino 进行编程时,有许多内置函数可用。这些函数可以帮助我们更轻松地编写程序,处理输入和输出,控制逻辑流和实现其他功能。下面是一些常用的 Arduino 函数:
pinMode(pin, mode): 用于配置数字引脚的输入或输出模式。pin 是数字引脚的编号,mode 是要设置的模式(输入或输出)。digitalWrite(pin, value): 用于在数字引脚上写入数字值(HIGH 或 LOW)。pin 是数字引脚的编号,value 是要写入的值。digitalRead(pin): 用于读取数字引脚上的数字值(HIGH 或 LOW)。pin 是数字引脚的编号。analogRead(pin): 用于读取模拟引脚上的模拟值(0-1023)。pin 是模拟引脚的编号。analogWrite(pin, value): 用于在支持 PWM 输出的数字引脚上输出模拟值(0-255)。pin 是数字引脚的编号,value 是要输出的值。delay(ms): 用于在程序中创建暂停(延迟)时间。ms 是要延迟的毫秒数。millis(): 返回自启动以来的毫秒数,可以用于时间跟踪和计时器。Serial.begin(baud): 用于初始化串口通信,其中 baud 是波特率。Serial.print(data): 用于将数据打印到串口监视器。data 可以是数字,字符串或其他数据类型。Serial.available(): 用于检查是否有数据可以从串口读取。
这些函数只是 Arduino 可用的众多函数中的一部分。熟悉这些常用函数可以帮助我们更轻松地编写程序,并为实现特定功能提供了有用的工具。
下面是一个简单的实例代码,演示了如何控制一个 LED 灯的亮灭:
// 设置 LED 引脚
int led_pin = 2;
void setup() {
// 设定引脚为输出模式
pinMode(led_pin, OUTPUT);
}
void loop() {
// 点亮 LED
digitalWrite(led_pin, HIGH);
// 等待一段时间
delay(1000);
// 关闭 LED
digitalWrite(led_pin, LOW);
// 等待一段时间
delay(1000);
}
这段代码中,我们首先定义了一个整型变量 led_pin,表示连接 LED 灯的引脚。在 setup() 函数中,我们将该引脚设定为输出模式,然后在 loop() 函数中交替点亮和关闭 LED 灯,并在两次操作之间等待 1 秒钟的时间。
总结
本文介绍了 Arduino 的基本概念和编程语言,并演示了如何通过 Arduino 板上的数字和模拟引脚来控制硬件设备。对于初学者而言,理解这些基础知识是开始进行更高级项目的基础。如果你想进一步深入了解 Arduino,可以参考官方文档或者更高级别的教程。
面包板与杜邦线
我们在使用单片机的时候,电路搭建是必不可少的,而面包板和杜邦线可以让你更加轻松地搭建电路,这节课就来讲讲面包板与杜邦线的简单使用。
实验原理
1. 面包板
面包板是实验室中用于搭接电路的重要工具,熟练掌握面包板的使用方法是提高实 验效率,减少实验故障出现几率的重要基础之一。下面就面包板的结构和使用方法做简 单介绍。

面包板的外观和内部结构如上图所示,常见的最小单元面包板分上、中、下三部分,上面和下面部分一般是由一行或两行的插孔构成的窄条,中间部分是由中间一条隔离凹槽和上下各 5 行的插孔构成的宽条。
上下两部分的窄条,外观和结构如下图:

窄条上下两行之间电气不连通。每 5 个插孔为一组,通常的面包板上有 10 组(为了方便展示,第一张图展示的是小面包板,只有 5 组,原理相同)。标注 + 的窄条一般接电源,- 接地。
中间部分宽条是由中间一条隔离凹槽和上下各 5 行的插孔构成。在同一列中的 5 个 插孔是互相连通的,列和列之间以及凹槽上下部分则是不连通的。外观及结构如下图:

2. 杜邦线
杜邦线是美国杜邦公司生产的有特殊效用的排线。 电子行业中,杜邦线可用于实验板的引脚扩展,增加实验项目等,能够非常牢靠地和插针连接,无需焊接,便于快速进行电路试验。
杜邦线的线头有公、母两种,公杜邦线指的是尖头的杜邦线,有针脚。母杜邦线是带孔的连接线。

杜邦线的三种类型为:公公线、公母线、母母线。

硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 4 |
| 1kΩ 电阻 | 4 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
注意
一定要接电阻,不然会由于电流过大,烧坏 LED。而且一定要保证断电操作,否则也会造成元器件的损坏
了解完面包板与杜邦线的工作原理后,我们可以先搭建一个最基础的电路。
并且我们可以使用 ESP32 开发板的 3V3 引脚或者 Vin 引脚供电与 LED 的阳极相连,阴极与电阻相连,电阻的另一端接 GND,如下图:

如果你想要同时点亮多个 LED,而我们的开发板只有两个电源引脚,这时候,我们就需要用到面包板的正负极拍针孔。
每一个 LED 的正极与面包板的正极拍针孔通过一个电阻相连,阴极接面包板负极,如下图:

控制 GPIO 输出 - 点亮 LED
不论学习什么单片机,最简单的外设莫过于 IO 口的高低电平控制 LED,本节课将向大家介绍如何使用 MicroPython 控制 ESP32 的 GPIO 输出。通过本节课的学习,让大家对 MicroPython 的程序架构有一定的认识,为以后大型项目程序学习打下基础,增强信心。
实验原理
1. GPIO 引脚
引脚又叫管脚,英文叫 Pin, 就是从集成电路(芯片以及一些电子元件)内部电路引出与外围电路的接线的接口。
在我们的 ESP32 开发板上, 我们可以把这些称为引脚, 这些引脚其实是从 ESP32 芯片内部引出来的, 我们可以看到每个引脚都标了自己独特的名字。

其中有一类引脚叫 GPIO 引脚, 负责输入/输出电压。开发板上 D 开头的引脚都是这种引脚, 比如 D2、D4、D15 等等
输入我们暂时不讲,这里我们先讲一下输出,简单来说,每个 GPIO 都可以输出高低电平。
什么是电平?
电路上某点的电压(对公共参考点)或电位是高还是低。比如在逻辑电路中,高于某个数值的电位称其为高电位,或高电平,低于某个数值的,为低电位或低电平。比如 ESP32 中,高电平的数值大于2.5V,低电平的数值小于0.5V,具体的数值最好通过测试研究来确定。
2. LED
LED(light-emitting diode) 即发光二极管。它具有单向导电性,通过 5mA 左右电流即可发光,电流越大,其亮度越强,但若电流过大,会烧毁二极管,一般我们控制在 3mA-20mA 之间,通常我们会在 LED 管脚上串联一个电阻,目的就是为了限制通过发光二极管的电流不要太大,因此这些电阻又可以称为限流电阻。当发光二极管发光时,测量它两端电压约为 1.7V,这个电压又叫做发光二极管的导通压降。
发光二极管正极又称阳极,负极又称阴极,电流只能从阳极流向阴极。直插式发光二极管长脚为阳极,短脚为阴极。

硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 1 |
| 1kΩ 电阻 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
LED 的正极接开发板的 D12 引脚,并串联一个电阻,负极接 GND,如下图:

注意
一定要接电阻,不然会由于电流过大,烧坏 LED。
软件设计
1. 点亮一颗 LED
因此,如果我们想要点亮这颗 LED 的话,只需要先设定相关引脚为输出模式,然后给这个引脚赋值一个高电平即可。
// 设置 LED 引脚
int led_pin = 12;
void setup() {
// 设定引脚为输出模式
pinMode(led_pin, OUTPUT);
// 点亮 LED
digitalWrite(led_pin, HIGH);
}
void loop() {
}
通过 IDE 编写上述代码,然后运行,此时会看到电路中的 LED 灯被点亮了。
2. 闪烁的 LED 灯
我们已经成功点亮一颗 LED 了,接下来,可以尝试一下稍微复杂一点的逻辑,比如让这颗 LED 闪烁。
实现 LED 闪烁的原理很简单,就是在 loop 函数中使用延时函数 delay。先设置高电平,延时 X 秒,再设置低电平,延时 X 秒,之后就不断循环该语句即可。
在之前的 初识 Arduino 编程语言 中,我们学习了 loop 函数的用法,如果我们想要让灯泡一直闪烁,则需要在 loop 函数中不断改变 LED 状态。
// 设置 LED 引脚
int led_pin = 12;
void setup() {
// 设定引脚为输出模式
pinMode(led_pin, OUTPUT);
}
void loop() {
// 点亮 LED
digitalWrite(led_pin, HIGH);
// 等待一段时间
delay(1000);
// 关闭 LED
digitalWrite(led_pin, LOW);
// 等待一段时间
delay(1000);
}
运行程序,LED 就闪烁了。
流水灯实验
上节课我们已经学习了如何点亮一颗 LED 并且让其闪烁,这节课我们学习如何制作流水灯。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 5 |
| 1kΩ 电阻 | 5 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
每一个 LED 的正极与开发板一个 GPIO 引脚相连,并串联一个电阻,负极接 GND,如下图:

当然你也可以选择只使用一个电阻:

软件程序设计
1. 正常流水灯
因为我们要用到多个 GPIO 引脚,所以,我们最好把所有的 GPIO 引脚放在一个数组中,然后遍历这个数组
// 定义 GPIO 引脚数组
int pin_list[5] = {13, 12, 14, 27, 26};
// 获取数组长度
int size = sizeof(pin_list) / sizeof(pin_list[0]);
void setup() {
// 设定 GPIO 引脚为输出模式
for (int i=0; i<size;i++) {
pinMode(pin_list[i], OUTPUT);
}
}
void loop() {
// 将所有引脚设置为高电平
for (int i=0;i<size;i++) {
digitalWrite(pin_list[i], HIGH);
delay(50);
}
// 将所有引脚设置为低电平
for (int i=0;i<size;i++) {
digitalWrite(pin_list[i], LOW);
delay(50);
}
}
2. 反复流水灯
我们还可以对该程序进行微调,比如之前是依次改变流水灯的状态,现在,修改为让流水灯往复亮。
// 定义 GPIO 引脚数组
int pin_list[5] = {13, 12, 14, 27, 26};
// 获取数组长度
int size = sizeof(pin_list) / sizeof(pin_list[0]);
void setup() {
// 设定 GPIO 引脚为输出模式
for (int i=0; i<size;i++) {
pinMode(pin_list[i], OUTPUT);
}
}
void loop() {
// 将所有引脚设置为高电平
for (int i=0;i<size;i++) {
digitalWrite(pin_list[i], HIGH);
delay(50);
}
// 将所有引脚设置为低电平
for (int i=size-1;i>=0;i--) {
digitalWrite(pin_list[i], LOW);
delay(50);
}
}
3. LED 移动
让 LED 实现平移的效果是这样实现的,每次在我点亮这颗 LED 的时候,同时把上一颗 LED 的状态改为低电平,并且当索引值为 0 时,让最后一颗 LED 状态改为低电平代码如下:
// 定义 GPIO 引脚数组
int pin_list[5] = {13, 12, 14, 27, 26};
// 获取数组长度
int size = sizeof(pin_list) / sizeof(pin_list[0]);
void setup() {
// 设定 GPIO 引脚为输出模式
for (int i=0; i<size;i++) {
pinMode(pin_list[i], OUTPUT);
}
}
void loop() {
// 将所有引脚设置为高电平
for (int i=0;i<size;i++) {
digitalWrite(pin_list[i], HIGH);
if (i > 0){
digitalWrite(pin_list[i-1], LOW);
}else {
digitalWrite(pin_list[size-1], LOW);
}
delay(250);
}
}
数码管显示
现阶段,无论 LCD 和 OLED 显示技术有多好,都无法替代这个古老的显示方式 - 数码管。直到现在,很多领域都离不开数码管。最主要的原因是他便宜有稳定,而且控制简单。


实验原理
数码管是一种半导体发光器件,其基本单元依然是 LED。 数码管按段数可分为七段数码管和八段数码管,八段数码管比七段数码管多一个发光二极管单元,也就是多一个小数点(DP),这个小数点可以更精确的表示数码管想要显示的内容。
按照能显示的位数可分为 1 位、2 位、3 位、4 位、5 位、6 位、7 位等数码管。

按发光二极管单元连接方式可分为共阳极数码管和共阴极数码管:
共阳数码管是指将所有发光二极管的阳极接到一起形成公共阳极(COM)的数码管,共阳数码管在应用时应将公共极 COM 接到+5V,当某一字段发光二极管的阴极为低电平时,相应字段就点亮,当某一字段的阴极为高电平时,相应字段就不亮。共阴数码管是指将所有发光二极管的阴极接到一起形成公共阴极(COM)的数码管,共阴数码管在应用时应将公共极 COM 接到地线 GND上,当某一字段发光二极管的阳极为高电平时,相应字段就点亮,当某一字段的阳极为低电平时,相应字段就不亮。
原理图如下:

引脚图中间的两个 COM,是公共端,共阴数码管要将其接地,共阳数码管将其接电源。a,b,c,d,e,f,g,dp 被称为段选线。
如何判断是共阴还是共阳?
如果你不清楚你的数码管到底是共阴还是共阳,可以使用下面三种方法测试。
- 一般在数码管的侧面会标注该数码管的型号,可以在浏览器中搜索该型号,获取对应的原理图

- 用 ESP32 单片机给面包板通电(3.3V 引脚),公共端通过一个限流电阻接电源, 用跳线连通电源和数码管的 LED 引脚,如果亮了说明是
共阳型数码管;反之,说明是共阳型数码管。

- 使用万用表的二极管档,红表笔接公共端,黑表笔接任一引脚,亮了说明是
共阳型数码管,反之,则说明是共阴型数码管。

提示
建议把所有引脚测试一遍,也可以检查出是否有坏了的 LED。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 1 位 8 段数码管 | 1 |
| 1kΩ 电阻 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
将材料按照下图相连:

软件程序设计
设计这个程序时,我们需要使用二维数组。
如果我们想让这个数码管某一引脚亮起来,那么我们需要给对应的引脚设置一个低电平。如果我们想要显示一个数字时,就需要让多个 LED 同时亮,比如数字 1 需要 b、c 引脚给低电平,其余引脚给高电平。程序可以这样写:
// 定义输出引脚并把所有引脚存到数组中
int pin_a = 4;
int pin_b = 5;
int pin_c = 19;
int pin_d = 21;
int pin_e = 22;
int pin_f = 2;
int pin_g = 15;
int pin_dp = 18;
int pin_array[8] = {pin_a, pin_b, pin_c, pin_d, pin_e, pin_f, pin_g, pin_dp};
// 定义数字显示逻辑的二维数组
int number_array[][8] = {
{0, 0, 0, 0, 0, 0, 1, 1}, // 0
{1, 0, 0, 1, 1, 1, 1, 1}, // 1
{0, 0, 1, 0, 0, 1, 0, 1}, // 2
{0, 0, 0, 0, 1, 1, 0, 1}, // 3
{1, 0, 0, 1, 1, 0, 0, 1}, // 4
{0, 1, 0, 0, 1, 0, 0, 1}, // 5
{0, 1, 0, 0, 0, 0, 0, 1}, // 6
{0, 0, 0, 1, 1, 1, 1, 1}, // 7
{0, 0, 0, 0, 0, 0, 0, 1}, // 8
{0, 0, 0, 0, 1, 0, 0, 1}, // 9
};
void setup() {
// 设置所有引脚为输出模式,初始化所有引脚为高电平
for (int i=0;i<8;i++){
pinMode(pin_array[i], OUTPUT);
digitalWrite(pin_array[i], HIGH);
}
}
void loop() {
// 显示数字
int num = 8;
for (int i=0;i<8;i++){
digitalWrite(pin_array[i], number_array[num][i]);
}
}
我们也可以使用函数来把显示数字的逻辑代码封装起来,方便我们在其他地方使用:
// 定义输出引脚并把所有引脚存到数组中
int pin_a = 4;
int pin_b = 5;
int pin_c = 19;
int pin_d = 21;
int pin_e = 22;
int pin_f = 2;
int pin_g = 15;
int pin_dp = 18;
int pin_array[8] = {pin_a, pin_b, pin_c, pin_d, pin_e, pin_f, pin_g, pin_dp};
// 定义数字显示逻辑的二维数组
int number_array[][8] = {
{0, 0, 0, 0, 0, 0, 1, 1}, // 0
{1, 0, 0, 1, 1, 1, 1, 1}, // 1
{0, 0, 1, 0, 0, 1, 0, 1}, // 2
{0, 0, 0, 0, 1, 1, 0, 1}, // 3
{1, 0, 0, 1, 1, 0, 0, 1}, // 4
{0, 1, 0, 0, 1, 0, 0, 1}, // 5
{0, 1, 0, 0, 0, 0, 0, 1}, // 6
{0, 0, 0, 1, 1, 1, 1, 1}, // 7
{0, 0, 0, 0, 0, 0, 0, 1}, // 8
{0, 0, 0, 0, 1, 0, 0, 1}, // 9
};
void display_number(int num){
// 清屏
for (int i=0;i<8;i++){
digitalWrite(pin_array[i], HIGH);
}
// 改变对应引脚的电平;
for (int i=0;i<8;i++){
digitalWrite(pin_array[i], number_array[num][i]);
}
}
void setup() {
// 设置所有引脚为输出模式,初始化所有引脚为高电平
for (int i=0;i<8;i++){
pinMode(pin_array[i], OUTPUT);
digitalWrite(pin_array[i], HIGH);
}
}
void loop() {
// 显示数字
for (int i=0;i<10;i++){
display_number(i);
delay(500);
}
}
4 位数码管显示
实验原理
4 位数码管,即 4 个 1 位数码管并列集中在一起形成一体的数码管。

当多位数码管一体时,它们内部的公共端是独立的,而负责显示什么数字的段线(a-dp)全部是连接在一起的,独立的公共端可以控制多位一体中的哪一位数码管点亮,而连接在一起的段线可以控制这个能点亮数码管亮什么数字,通常我们把公共端叫做 位选线 ,连接在一起的段线叫做 段选线,有了这两个线后,通过单片机及外部驱动电路就可以控制任意的数码管显示任意的数字了。
4 位数码管与 1 位数码管的原理基本一致,除了引脚不同。

相同的地方是,数码管中的 LED 的分段映射相同,如下图:

有区别的地方首先是 1 位数码管有两个相同的公共(COM)端接地或者接电源,而 4 位数码管没有公共端,有四个控制不同位置显示的选通端。

一般一位数码管有 10 个引脚,四位数码管是12 个引脚,关于具体的引脚及段、位标号大家可以查询相关资料,最简单的办法就是用数字万用表测量,若没有数字万用表也可用 5V 直流电源串接1k 电阻后测量,将测量结果记录,通过统计便可绘制出引脚标号。多位数码管有许多是按一定要求设计的,引脚不完全按照一般规则设定,所以需要在使用时查找手册,最直接的办法就是按照数码管上的标示向生产商要求。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 4 位数码管 | 1 |
| 1kΩ 电阻 | 4 |
| 杜邦线(跳线) | 若干 |
将材料按照下图相连:


软件程序设计
我们一步一步的来,先写一个最简单的程序,让任意一位数码管显示任意数字,代码可以这么写:
// 定义位选线引脚
int seg_1 = 5;
int seg_2 = 18;
int seg_3 = 19;
int seg_4 = 21;
// 定义位选线数组
int seg_array[4] = {seg_1, seg_2, seg_3, seg_4};
// 定义段选线引脚;
int a = 32;
int b = 25;
int c = 27;
int d = 12;
int e = 13;
int f = 33;
int g = 26;
int dp = 14;
// 定义位选线引脚
int led_array[8] = {a, b, c, d, e, f, g, dp};
// 定义共阴极数码管不同数字对应的逻辑电平的二维数组
int logic_array[10][8] = {
//a, b, c, d, e, f, g, dp
{1, 1, 1, 1, 1, 1, 0, 0}, // 0
{0, 1, 1, 0, 0, 0, 0, 0}, // 1
{1, 1, 0, 1, 1, 0, 1, 0}, // 2
{1, 1, 1, 1, 0, 0, 1, 0}, // 3
{0, 1, 1, 0, 0, 1, 1, 0}, // 4
{1, 0, 1, 1, 0, 1, 1, 0}, // 5
{1, 0, 1, 1, 1, 1, 1, 0}, // 6
{1, 1, 1, 0, 0, 0, 0, 0}, // 7
{1, 1, 1, 1, 1, 1, 1, 0}, // 8
{1, 1, 1, 1, 0, 1, 1, 0}, // 9
};
// 清屏函数
void clear() {
for (int i=0;i<4;i++) {
digitalWrite(seg_array[i], HIGH);
}
for (int i=0;i<8;i++) {
digitalWrite(led_array[i], LOW);
}
}
// 显示数字的函数
void display_number(int order, int number) {
// 清屏
clear();
// 把对应位选线的电平拉低
digitalWrite(seg_array[order], LOW);
// 显示数字
for (int i=0;i<8;i++) {
digitalWrite(led_array[i], logic_array[number][i]);
}
}
void setup() {
// 设置所有位选线引脚为输出模式,初始化所有位选线引脚为高电平
for (int i=0;i<4;i++) {
pinMode(seg_array[i], OUTPUT);
digitalWrite(seg_array[i], HIGH);
}
// 设置所有段选线引脚为输出模式,初始化所有段选线引脚为低电平
for (int i=0;i<8;i++) {
pinMode(led_array[i], OUTPUT);
digitalWrite(led_array[i], LOW);
}
}
void loop() {
// 第三位显示数字 3
// display_number(2, 3);
// 按顺序让所有位置显示 0~9
for (int i=0;i<4;i++) {
for (int j=0;j<10;j++) {
display_number(i, j);
delay(200);
}
}
}
我们选择多位数码管,肯定是要在不同位置显示不同数字的,这时候,我们需要用到 动态扫描。
什么是动态扫描
动态扫描是对位选端扫描,8 个引脚控制每个数码管的段选线,通过刷新位选端和 8 个引脚的状态,来实现显示不同的数字。
我们可以通过运行下面这段代码,更生动形象地理解 动态扫描 的原理:
/*
该程序的作用是演示动态扫描原理。
在线文档:https://docs.geeksman.com/esp32/Arduino/08.esp32-arduino-4-digits-7segment.html
*/
// 定义位选线引脚
int seg_1 = 5;
int seg_2 = 18;
int seg_3 = 19;
int seg_4 = 21;
// 定义位选线数组
int seg_array[4] = {seg_1, seg_2, seg_3, seg_4};
// 定义段选线引脚;
int a = 32;
int b = 25;
int c = 27;
int d = 12;
int e = 13;
int f = 33;
int g = 26;
int dp = 14;
// 定义位选线引脚
int led_array[8] = {a, b, c, d, e, f, g, dp};
// 定义共阴极数码管不同数字对应的逻辑电平的二维数组
int logic_array[10][8] = {
//a, b, c, d, e, f, g, dp
{1, 1, 1, 1, 1, 1, 0, 0}, // 0
{0, 1, 1, 0, 0, 0, 0, 0}, // 1
{1, 1, 0, 1, 1, 0, 1, 0}, // 2
{1, 1, 1, 1, 0, 0, 1, 0}, // 3
{0, 1, 1, 0, 0, 1, 1, 0}, // 4
{1, 0, 1, 1, 0, 1, 1, 0}, // 5
{1, 0, 1, 1, 1, 1, 1, 0}, // 6
{1, 1, 1, 0, 0, 0, 0, 0}, // 7
{1, 1, 1, 1, 1, 1, 1, 0}, // 8
{1, 1, 1, 1, 0, 1, 1, 0}, // 9
};
// 延时时间
int count = 355;
// 清屏函数
void clear() {
for (int i=0;i<4;i++) {
digitalWrite(seg_array[i], HIGH);
}
for (int i=0;i<8;i++) {
digitalWrite(led_array[i], LOW);
}
}
// 显示数字的函数
void display_number(int order, int number) {
// 清屏
clear();
// 把对应位选线的电平拉低
digitalWrite(seg_array[order], LOW);
// 显示数字
for (int i=0;i<8;i++) {
digitalWrite(led_array[i], logic_array[number][i]);
}
}
void setup() {
// 设置所有位选线引脚为输出模式,初始化所有位选线引脚为高电平
for (int i=0;i<4;i++) {
pinMode(seg_array[i], OUTPUT);
digitalWrite(seg_array[i], HIGH);
}
// 设置所有段选线引脚为输出模式,初始化所有段选线引脚为低电平
for (int i=0;i<8;i++) {
pinMode(led_array[i], OUTPUT);
digitalWrite(led_array[i], LOW);
}
}
void loop() {
display_number(0, 1);
delay(count);
display_number(1, 2);
delay(count);
display_number(2, 3);
delay(count);
display_number(3, 4);
delay(count);
if (count > 10) {
if (count > 110) {
count -= 50;
}else {
count -= 10;
}
}
}
理解了 动态扫描 的原理之后,我们就可以写代码了,先把之前写的这些代码复制过来,然后我们还需要实现通过动态扫描的方法实现 4 位数字显示的功能:
// 4 位数码管显示函数
void display_4_number(int number) {
// 把输入的数字格式化为 4 位数的数组
if (number < 10000) {
// 获取每一位对应的数字
// // 获取个位
// int seg_4_number = number % 10;
// number /= 10;
//
// // 获取十位
// int seg_3_number = number % 10;
// number /= 10;
//
// // 获取百位
// int seg_2_number = number % 10;
// number /= 10;
//
// // 获取千位
// int seg_1_number = number % 10;
// 定义格式化数组
int number_array[4];
// 使用循环获取格式化数组
for (int i=3;i>=0;i--) {
number_array[i] = number % 10;
number /= 10;
}
// 显示数字
for (int i=0;i<4;i++) {
display_number(i, number_array[i]);
delay(5);
}
}
}
这样,我们就能通过在 loop 函数中调用 display_4_number 函数,来显示数字内容了。
控制 GPIO 输入 - 按键实验
前面几节课介绍的都是 IO 口输出的使用,本节课我们通过按键实验来介绍 IO 口作为输入的使用并通过按键电路实现开关灯的效果。
实验原理
按键是一种电子开关,使用时轻轻按开关按钮就可使开关接通,当松开手时,开关断开。

按钮有两组引脚(触点)。当按下按钮时,它会连接这两个触点,从而关闭电路。
一般来说 4 脚开关(轻触按键)相距较远的是相通的,离得较近的是一组开关,最好是测量一下,如果懒得测,接对角肯定是可以的。
下图说明了按钮内部的连接:

使用按键的时候,通常情况下需要进行消抖。
什么是按键消抖?
该实验中所用开关为机械弹性开关,当机械触点断开、闭合时,由于机械触点的弹性作用,一个按键开关在闭合时不会马上稳定地接通,在断开时也不会一下子断开。因而在闭合及断开的瞬间均伴随有一连串的抖动,为了不产生这种现象而作的措施就是按键消抖。
按键的抖动对于人类来说是感觉不到的,但对单片机来说,则是完全可以感应到的,而且还是一个很漫长的过程,因为单片机处理的速度在微秒级,而按键抖动的时间至少在毫秒级。
一次按键动作的电平波形如下图。存在抖动现象,其前后沿抖动时间一般在 5ms~10ms 之间。由于单片机运行速度非常快,刚按下的时候会检测到低电平判断按键被按下。但是由于按键存在抖动,单片机在此时也会检测到高电平,误以为松开按键,紧接着又检测到低电平,判断到按键被按下。周而复始,在 5-10ms 内可能会出现很多次按下的动作,每一次按键的动作判断的次数都不相同。

这种抖动可能会影响程序误判,造成严重后果,一般我们采用两种方式对按键进行消抖:
- 硬件消抖,硬件消抖的典型做法是:采用 R-S 触发器或 RC 积分电路。
- 软件消抖,通常我们会使用软件延时 10ms 来消抖。例如,当按键按下后,引脚为低电平;所以首先读取引脚电平,若引脚为低电平,则延时 10ms 后再次读取引脚电平,若为低电平,则证明按键已按下。
硬件方法一般用在对按键操作过程比较严格,且按键数量较少的场合,而按键数量较多时,通常采用软件消抖。值得一提的是,对于复杂且多任务的单片机系统来说,若简单地采用循环指令来实现软件延时,则会浪费CPU宝贵的时间资源,大大降低系统的实时性,所以,更好的做法是利用定时中断服务程序或利用标志位的方法来实现软件消抖。
硬件电路设计
使用按键电路实现开关灯的效果。
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 1 |
| 1kΩ 电阻 | 1 |
| 4 脚按键 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
将材料按照下图相连:
按键一端接 3V3 引脚,一端接 D14,LED 接 D2。

软件程序设计
这里我们在使用 pinMode 方法的时候,第二个参数就不能传递 OUTPUT 了,并且我们需要使用 digitalRead 方法来获取输入值。
与输出不同的是,设置输入引脚时,我们需要配置上拉或下拉电阻,目的是确定某个状态电路中的高电平或低电平。
上、下拉电阻的作用是提高电路稳定性,避免引起误动作。按键如果不通过电阻上拉到高电平,那么在上电瞬间可能就发生误动作,因为在上电瞬间单片机的引脚电平是不确定的,上拉电阻的存在保证了其引脚处于高电平状态,而不会发生误动作。
在 Arduino 中,设置引脚的上拉电阻或下拉电阻需要使用 pinMode 函数和 INPUT_PULLUP 或 INPUT_PULLDOWN 常量。
如果要设置引脚为上拉电阻,需要将引脚设置为输入模式,并调用 pinMode(pin, INPUT_PULLUP) 函数,其中 pin 为引脚号。
提示
如果你不认识上拉电阻和下拉电阻,在这个阶段是无所谓的,你只需要了解他们的存在是为了确定初始电平状态。 选择上拉电阻,GPIO 引脚默认位高电平,那我们想要改变信号,就需要传递一个低电平,接地。 选择下拉电阻,GPIO 引脚默认为低电平,那我们想要改变信号,就需要传递一个高电平,接电源。
因此,我们的代码需要这么写:
// 定义 LED 与 按键引脚
int led_pin = 2;
int button_pin = 14;
// 定义 LED 逻辑值
int led_logic = 0;
// 判断 LED 的状态是否改变过
bool status = false;
void setup() {
pinMode(led_pin, OUTPUT);
pinMode(button_pin, INPUT_PULLDOWN);
}
void loop() {
// 按键消抖
if (digitalRead(button_pin)) {
// 睡眠 10ms,如果依然为高电平,说明抖动已消失。
delay(10);
if (digitalRead(button_pin) && !status) {
led_logic = !led_logic;
digitalWrite(led_pin, led_logic);
// led 的状态发生了变化,即使我持续按着按键,LED 的状态也不应该改变。
status = !status;
}else if (!digitalRead(button_pin)) {
status = false;
}
}
}
我们也可以不在 setup 前定义变量,而是选择 宏定义 的方法。
在 Arduino 中,宏定义是一种预处理器指令,它被用于创建常量和替代符号。通过使用宏定义,可以让代码更易读,更易于维护和修改,同时也可以避免在代码中出现重复的代码块。
宏定义以 #define 关键字开头,后面跟着宏名称和宏值。宏值可以是数字、字符或者表达式。一旦定义了宏,它将会被整个程序使用。
例如,下面的代码创建了一个宏定义 LED_PIN,将其值设置为 13:
#define LED_PIN 13
然后,在程序中可以使用宏定义来指定要使用的引脚,而不是写出具体的数字,从而使代码更具可读性:
#define LED_PIN 2
#define BUTTON_PIN 14
// 定义 LED 逻辑值
int led_logic = 0;
// 判断 LED 的状态是否改变过
bool status = false;
void setup() {
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLDOWN);
}
void loop() {
// 按键消抖
if (digitalRead(BUTTON_PIN)) {
// 睡眠 10ms,如果依然为高电平,说明抖动已消失。
delay(10);
if (digitalRead(BUTTON_PIN) && !status) {
led_logic = !led_logic;
digitalWrite(LED_PIN, led_logic);
// led 的状态发生了变化,即使我持续按着按键,LED 的状态也不应该改变。
status = !status;
}else if (!digitalRead(BUTTON_PIN)) {
status = false;
}
}
}
宏定义和变量定义都是定义标识符的方式,但二者有以下区别:
- 宏定义在预处理时进行处理,而变量定义在编译时进行处理。宏定义是一种文本替换机制,会在编译前被展开为其定义的文本,而变量则需要在编译时分配内存空间;
- 宏定义的作用域是从定义到文件结束,而变量定义的作用域则是在定义处到包含该定义的代码块结束;
- 宏定义可以定义函数或代码块,而变量定义只能定义变量;
- 宏定义不会占用内存空间,而变量需要分配内存空间;
- 宏定义不需要类型声明,而变量定义需要类型声明。
总之,宏定义主要是一种代码替换的机制,能够提高代码的可读性和可维护性,而变量定义则是用于在程序中存储和管理数据的标识符。
PWM 呼吸灯实验
之前我们使用的 LED 做过流水灯的实验,这节课,我们学习制作呼吸灯,通过 LED 灯的亮度变化来验证 PWM 不同电压的输出。呼吸灯是指灯光在单片机的控制之下完成由亮到暗的逐渐变化,感觉好像是人在呼吸。
实验原理
脉冲宽度调制(PWM),是英文 Pulse Width Modulation 的缩写,简称脉宽调制,是利用微处理器的数字输出来对模拟电路进行控制的一种非常有效的技术,广泛应用在测量、通信到功率控制与变换的许多领域中。
PWM 通过调节输出不同频率(频率是指 1 秒钟内信号从高电平到低电平再回到高电平的次数(一个周期))、占空比(一个周期内高电平出现时间占总时间比例)的方波。以实现固定频率或平均电压输出。频率固定,改变占空比可改变输出电压,如下所示:

硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 1 |
| 1kΩ 电阻 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
LED 的正极接开发板的 D12 引脚,并串联一个电阻,负极接 GND,如下图:

注意
一定要接电阻,不然会由于电流过大,烧坏 LED。
软件程序设计
1. analogWrite() 函数实现呼吸灯效果
想要通过 Arduino 输出 PWM 有两种方法,第一种就是使用 Arduino 自带的 analogWrite(pin, value) 函数,其中的两个参数:
pin:要写入的 Arduino 引脚。允许的数据类型:int.value:占空比:介于 0(始终关闭)和 255(始终开启)之间。允许的数据类型:int.
,代码如下:
// 宏定义 GPIO 输出引脚
#define LED_PIN 12
void setup() {
// 配置 GPIO 输出引脚
pinMode(LED_PIN, OUTPUT);
}
void loop() {
// 实现渐亮效果
for(int i=0;i<256;i++) {
// 设置亮度模拟值
analogWrite(LED_PIN, i);
// 延时 10ms
delay(10);
}
// 实现渐灭效果
for(int i=255;i>=0;i--) {
// 设置亮度模拟值
analogWrite(LED_PIN, i);
// 延时 10ms
delay(10);
}
}
2. LEDC 输出 PWM 信号
第二种是使用 ESP32 的 LEDC 外设,在 ESP32 上有一个 LEDC 外设模块专用于输出 PWM 波形。
LED PWM 控制器可以生成 16 路通道(0 ~ 15),波形的周期和占空比可配置。分为高低速两组,高速通道(0 ~ 7)由 80MHz 时钟驱动,低速通道(8 ~ 15)由 1MHz 时钟驱动。另外,每路 LED PWM 支持自动步进式地增加或减少占空比,可以用于 LED RGB 彩色梯度发生器。
作为刚入门的学习者,上面这段概念不理解也不影响我们后续的学习,我们需要了解的是 LEDC 的控制函数以及 PWM 信号的产生流程。
打开 esp32_hal_led.h 文件之后,我们可以看到 LEDC 的所有控制函数:
// 设置 LEDC 通道对应的频率和计数位数(占空比分辨率),返回最终频率
// 分辨率的意思就是把一个周期分成 2 的 resolution_bits 份。
uint32_t ledcSetup(uint8_t channel, uint32_t freq, uint8_t resolution_bits);
// 指定通道输出一定占空比波形
void ledcWrite(uint8_t channel, uint32_t duty);
// 类似于 arduino 的 tone ,当外接无源蜂鸣器的时候可以发出某个声音(根据频率不同而不同)
uint32_t ledcWriteTone(uint8_t channel, uint32_t freq);
// 该方法是上面方法的进一步封装,可以直接输出指定调式和音阶声音的信号
uint32_t ledcWriteNote(uint8_t channel, note_t note, uint8_t octave);
// 返回指定通道占空比的值
uint32_t ledcRead(uint8_t channel);
// 返回指定通道当前频率(如果当前占空比为0 则该方法返回0)
uint32_t ledcReadFreq(uint8_t channel);
// 将 LEDC 通道绑定到指定 IO 口上以实现输出
void ledcAttachPin(uint8_t pin, uint8_t channel);
// 解除 IO 口的 LEDC 功能
void ledcDetachPin(uint8_t pin);
使用 LEDC 外设的时候需要遵循以下步骤:
- 使用
ledcSetup()函数建立 LEDC 通道; - 通过
ledcAttachPin()将 GPIO 口与 LEDC 通道关联; - 通过
ledcWrite()、ledcWriteTone()、ledcWriteNote()设置频率、设置蜂鸣器音调等等 - 通过
ledcDetachPin()解除 GPIO 口与 LEDC 通道的关联
所有我们可以通过以下代码,实现呼吸灯效果:
#define FREQ 2000 // 频率
#define CHANNEL 0 // 通道
#define RESOLUTION 8 // 分辨率
#define LED 12 // LED 引脚
void setup()
{
ledcSetup(CHANNEL, FREQ, RESOLUTION); // 设置通道
ledcAttachPin(LED, CHANNEL); // 将通道与对应的引脚连接
}
void loop()
{
// 逐渐变亮
for (int i=0;i<pow(2, RESOLUTION); i++)
{
ledcWrite(CHANNEL, i); // 输出PWM
delay(5);
}
// 逐渐变暗
for (int i=pow(2, RESOLUTION)-1;i>=0;i--)
{
ledcWrite(CHANNEL, i); // 输出PWM
delay(5);
}
}
数模转换器 - ADC 实验
前面我们介绍使用 PWM 输出不同电压值,类似于模拟信号输出。如果需要 ESP32 检测外部输入的模拟信号该怎么办?这就是本节课要学习的 ADC,通过 ADC 将模拟信号转换为数字信号给 ESP32 处理。
实验原理
在学习 ADC 之前,我们要首先学习什么是模拟信号,什么是数字信号。
模拟信号(Analog Signal):模拟信号是连续变化的量或者信号,生活中接触到的信号基本都是模拟信号,温度变化,天体运动等等,这些都是连续的信息,都是模拟信号。模拟信号,简单的说就是用电信号模拟出其他的信号,比如用电信号模拟出图像,模拟出声音的声波。
数字信号(Digital Signal):数字信号是时间离散、数值离散的信号,数字信号存在采样,还存在量化,只能取到一些不连续的固定值,这也是数字信号和模拟信号之间可以进行相互转换的原因。
所以,总结就是模拟信号时间连续,幅值连续,数字信号时间离散,幅值离散。模拟电路就是使用、处理模拟信号的电路;数字电路就是使用、处理数字信号的电路。

我们举个简单的例子:
- 指针时钟显示的就是时间的模拟信号,他表示时间是连续变化的,
- 数字时钟显示的就是时间的数字信号,时间的显示是按照固定增量变化的,他可以显示 1、2、3,但是无法显示 1.1、2.1、等,所以在数字电路中使用的数字信号一般只能取 0 或 1,把 0 对应为低电平,把 1 对应为高电平,说白了就是有或者没有
数字信号是在模拟信号的基础上依次经过 采样、量化、编码而形成的。具体地说,采样 就是把输入的模拟信号按适当的时间间隔得到各个时刻的样本值;量化 是把经采样测得的各个时刻的值用二进制码来表示;编码 则是把量化生成的二进制数排列在一起形成顺序脉冲序列。

ADC(Analog to Digital Converter)即模数转换器,它可以将模拟信号转换为数字信号。由于单片机只能识别二进制数字,所以外界模拟信号常常会通过 ADC 转换成其可以识别的数字信号。常见的应用就是将变化的电压转成数字信号。
注意
使用默认配置时,ADC 引脚上的输入电压必须介于 0.0V 和 1.0V 之间(任何高于 1.0V 的值都将读为 4095)。如果需要增加测量范围,需要配置衰减器。

硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| LED | 1 |
| 1kΩ 电阻 | 1 |
| 电位器 | 1 |
| 面包板 | 1 |
| 杜邦线 | 若干 |
电位器相当于一个滑动变阻器,两端引脚阻值是固定的,中间引脚对任何一端的引脚阻值是可变的,他等效于从中间把电位器分成两个串联的电阻,串联总阻值是确定的,一端接输入电源,一端接地

软件程序设计
1. 在串口监视器显示 analogRead() 模拟输入
我们可以先在串口监视器中,打印一下读取到的 analogRead() 的值,这里需要使用到 Arduino 内置的串口函数:
Serial.begin():设置通信波特率,一般使用 9600,这样就可以在串口监视器中直接打印出来内容;Serial.println():在串口屏中打印内容。
因此,我们的代码可以这么写:
#define POT 26
// 初始化电位计输入信号
int pot_value;
void setup() {
// 设置串口通信波特率 9600
Serial.begin(9600);
pinMode(POT, INPUT);
}
void loop() {
// 读取电位计模拟输入值
pot_value = analogRead(POT);
// 打印模拟值在串口屏上
Serial.println(pot_value);
delay(50);
}
2. 使用 analogRead() 调节 LED 亮度
我们可以使用 analogRead() 来读取电位计传入的模拟值,范围是在 0 ~ 4095,而我们想要输入 analogWrite() 的参数范围是在 0 ~ 255,因此,我们还需要把读取到的模拟值范围转换成输出范围,代码如下:
#define POT 26
#define LED 13
// variable for storing the potentiometer value
int pot_value;
int led_value;
void setup() {
Serial.begin(9600);
pinMode(POT, INPUT);
pinMode(LED, OUTPUT);
}
void loop() {
// 读取电位计模拟输入值
pot_value = analogRead(POT);
// 把电位计模拟输入值转换为 LED 的模拟输出值。
led_value = pot_value / 16;
analogWrite(LED, led_value);
delay(50);
}
3. 使用 ADC 模拟通道输入
打开 esp32_hal_adc.h 文件之后,我们可以看到 ADC 的所有控制函数:
analogReadResolution(resolution):设置样本位和分辨率。它可以是一个介于 9(0 - 511)和 12 位(0 - 4095)之间的值。默认是 12 位分辨率。analogSetWidth(width):设置样本位和分辨率。它可以是一个介于 9(0 - 511)和 12 位(0 - 4095)之间的值。默认是 12 位分辨率。analogSetCycles(cycles):设置每个样本的循环次数。默认是 8。取值范围:1 ~ 255。analogSetSamples(samples):设置范围内的样本数量。默认为 1 个样本。它有增加灵敏度的作用。analogSetClockDiv(attenuation):设置ADC时钟的分压器。默认值为1。取值范围:1 ~ 255。adcAttachPin(pin):附加一个引脚到 ADC(也清除任何其他模拟模式可能是 on)。返回TRUE或FALSE结果。analogSetAttenuation(attenuation):设置所有 ADC 引脚的输入衰减。默认是 ADC_11db。其他取值:ADC_0db: 集没有衰减。ADC 可以测量大约 800mv (1V 输入 = ADC 读数 1088)。ADC_2_5db: ADC 的输入电压将被衰减,扩展测量范围至约。1100 mV。(1V 输入 = ADC 读数 3722)。ADC_6db: ADC 的输入电压将被衰减,扩展测量范围至约。1350 mV。(1V 输入= ADC 读数 3033)。ADC_11db: ADC 的输入电压将被衰减,扩展测量范围至约。2600 mV。(1V 输入= ADC 读数 1575)。
analogSetPinAttenuation(pin, attenuation):设置指定引脚的输入衰减。默认是 ADC_11db。衰减值与前一个函数相同。
因此,代码可以这么写:
#define POT 26
#define LED 13
#define CHANNEL 0
// 初始化模拟输入值
int pot_value;
void setup() {
Serial.begin(9600);
// 设置 ADC 分辨率
analogReadResolution(12);
// 配置衰减器
analogSetAttenuation(ADC_11db);
// 建立 LEDC 通道,配置 LEDC 分辨率
ledcSetup(CHANNEL, 1000, 12);
// 关联 GPIO 口与 LEDC 通道
ledcAttachPin(LED, CHANNEL);
}
void loop() {
// 获取模拟输入值
pot_value = analogRead(POT);
// 输出 PWM
ledcWrite(CHANNEL, pot_value);
delay(50);
}
IIC 驱动 LCD1602 液晶屏幕
上一课介绍了如何使用四位数码管模块来显示数字,本课将进一步介绍如何使用基于 IIC 接口驱动的 LCD1602 液晶屏。
在前面章节,我们已经学习过两种显示装置,数码管和 4 位数码管。使用它们可以直观显示一些字符数据,但是它们也有各种局限性,比如显示字符数据太少,硬件设计复杂、代码编写难度大等。这一章就来介绍一种非常简单且常用的显示装置--LCD1602 液晶显示器,使用它可以显示更多的字符数字。
实验原理
1. LCD1602 液晶屏
LCD1602 是很多单片机爱好者较早接触的字符型液晶显示器,所以,在这里花点时间是值得的。
1602 液晶屏的称呼来自于其显示的内容容量,其中的 16 代表每行的字符(数字或英文字符)数,02 代表屏幕一共两行,实际开发中根据需要显示信息的内容多少不但可以选用 1602 屏,还可以选用诸如 2004 屏等。如下图:

1602 液晶显示屏除了电源、地以外,有 3 个控制引脚 RS R/W E 和 8 个数据引脚 DB0-7。

2. 认识 IIC(I2C)接口
由于 1602 的管脚数过多,如果直接与 ESP32 开发板连接需要占用大量的 GPIO 管脚,不但容易造成资源浪费,连接也非常不方便。
因此实际使用时往往会给 1602 屏增加一块 IIC 驱动版,将 1602 的 16 个管脚连接到由 PCF8574T 作为主要芯片的驱动版上,将接口转换为 IIC 再连接开发板,具体情况如上图所示。
IIC 是一种硬件设备间常用的接口通讯协议,全称是 Inter-Integrated Circuit,也可以写为 I2C。他的设计时的理念是:信号线尽量少并且速率要尽量高。 信号线少,可以减少引脚占用,这对早期的芯片(引脚很少)的很重要。
使用 IIC 接口时一共需要连接四根线,包括:VCC、GND、SDA、SCL,其中 SDA 和 SCL 需要占用 GPIO 管脚,连接到开发板上任何一组 IIC 接口的对应管脚都可以。
标准的 I2C 需要两根信号线:
- SCL(Serial Clock):时钟线,时钟都是有 master 提供的
- SDA(Serial Data):双向数据线,发数据或者收数据(收发不能同时)
简单来说,只需要 2 根线,就可以对多台设备传输大量数据,减少单片机上 IO 口的占用。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 带有 IIC 模块的 LCD1602 液晶屏 | 1 |
| 杜邦线(跳线) | 若干 |
将材料按照下图相连:

注意
注意需要使用开发板上的 5V 电压,而不是 3.3V。真实环境下使用 3.3V 会无法显示或者显示很暗。
这里连接的这两个引脚可不是随便连接的,而是对应了 ESP32 芯片原理图,ESP32 的 I2C 引脚的 SDA 对应 D21,SCL 对应 D22,如图:

软件程序设计
在 Arduino 中使用 I2C 控制 LCD1602 需要下载第三方代码 LiquidCrystal_I2C,其中包括 LiquidCrystal_I2C.h 和 LiquidCrystal_I2C.cpp 两个文件,我们只需要把这两个文件放在项目的文件夹中即可。
Arduino 项目的创建非常简单,就是打开 Arduino IDE 后保存,它会在你选择的位置新建一个文件夹,这就是项目的文件夹。
.h 与 .cpp 文件到底是什么?在 Arduino 开发中,.h 和 .cpp 文件同样是用于代码组织和模块化的文件类型,但在 Arduino 环境中有些特殊的用法和约定。
.h文件(头文件):在 Arduino 中,头文件通常包含库、类、函数和变量的声明。头文件的目的是为了让多个源代码文件可以共享相同的声明,以便在程序中使用这些声明而无需重复编写。头文件通常使用预处理器指令#include引入到源文件中。在 Arduino 库中,.h文件中通常包含类的声明、常量定义、函数原型等。.cpp文件(源文件):在 Arduino 中,.cpp文件是用于存放函数和类的实现代码的文件。.cpp文件中包含了类成员函数的具体实现和其他函数的定义。通常,Arduino 项目中的.cpp文件中会包含.h文件,以便使用其中的声明。
需要注意的是,Arduino 开发中的 .h 和 .cpp 文件的约定与传统的 C++ 开发并不完全相同。Arduino IDE 会在编译过程中将 .ino 文件转换为 .cpp 文件,并将其中的代码放置在全局范围。因此,在 Arduino 项目中,.ino 文件也可以包含全局变量和函数,而不仅限于 .cpp 文件。
总结起来,.h 文件是用于声明库、类、函数和变量的头文件,而 .cpp 文件是用于实现函数和类的源文件。这样的组织方式有助于提高代码的可读性、可维护性和重用性。
接着,我们就可以了解 LiquidCrystal_I2C 库的使用:
LiquidCrystal_I2C(uint8_t addr, uint8_t cols, uint8_t rows):构造函数,用于构造 LCD I2C 对象,参数:addr是地址,默认的是 0x27,cols是 LCD 显示的列数,rows是 LCD 显示的函数;void init():初始化显示屏;void clear():清除 LCD 屏幕上内容,并将光标置于左上角;void home():将光标在定位在屏幕左上角;void noBacklight()与void backlight():是否开启背光;print():显示内容;void leftToRight()与void rightToLeft():控制文字显示的方向,默认是从左向右;void noDisplay()与void display():关闭显示或恢复显示(内容不会丢失);void setCursor(uint8_t col, uint8_t row):设置光标的位置,列,行,基于 0;void noCursor()与void cursor:显示与不显示光标,默认不显示;void noBlink()与void blink():光标是否闪烁,默认不闪烁。
现在,我们就可以写代码了。
1. 在屏幕上显示 Hello,world
了解了第三方库之后,我们先写一个最简单的程序,比如,在屏幕上显示 Hello, world,代码如下:
#include "LiquidCrystal_I2C.h"
// 设置 LCD1602 的地址,列数,行数
LiquidCrystal_I2C lcd(0x27, 16, 2);
void setup()
{
// 初始化 LCD 对象
lcd.init();
// 打印内容
lcd.backlight();
lcd.print("Hello, world!");
}
void loop()
{
}
2. 读取串口输入内容
在这个程序中,我们需要用到串口的另外两个方法 Serial.available() 与 Serial.read():
Serial.available():返回串口缓冲区中当前剩余的字符个数。一般用这个函数来判断串口的缓冲区有无数据,当Serial.available()>0时,说明串口接收到了数据,可以读取;Serial.read()指从串口的缓冲区取出并读取一个 Byte 的数据,比如有设备通过串口向 Arduino 发送数据了,我们就可以用Serial.read()来读取发送的数据。
#include "LiquidCrystal_I2C.h"
// 设置 LCD1602 的地址,列数,行数
LiquidCrystal_I2C lcd(0x27,16,2);
void setup()
{
// 初始化 LCD 对象
lcd.init();
// 开启背光
lcd.backlight();
// 开启串口通信
Serial.begin(9600);
}
void loop()
{
// 检测是否有串口输入
if (Serial.available()) {
// 延时以等待所有数据传输完成
delay(100);
// 清屏
lcd.clear();
// 反复读取串口的数据并在 LCD1602 屏幕上显示,直到数据读完
while (Serial.available() > 0) {
lcd.write(Serial.read());
}
}
}
在 VSCode 中使用 PlatformIO 开发 ESP32
我们已经学会了 Arduino IDE 的基本使用了,这节课,我们来学习使用另一款软件进行 Arduino 开发,他就是 PlatformIO。
PlatformIO 是一个开源的跨平台的物联网(IoT)开发平台,用于嵌入式系统和物联网设备的开发。它提供了一个统一的开发环境和工具链,支持多种硬件平台(如 Arduino、ESP8266、ESP32、Raspberry Pi 等)和开发框架(如 Arduino 框架、ESP-IDF、STM32Cube等),使开发者可以更轻松地进行嵌入式开发。
PlatformIO 的主要特点和优势包括:
跨平台支持:可以在 Windows、Mac 和 Linux 等操作系统上运行,并支持多种开发板和处理器架构。统一的开发环境:提供了集成开发环境(IDE)插件,如 VS Code、Clion 等,使开发者可以在一个统一的界面中进行代码编辑、编译、调试和上传。库管理和依赖管理:内置了强大的库管理器,可以方便地搜索、安装和更新开源库,同时支持管理项目的依赖关系。丰富的功能:支持代码自动完成、语法检查、固件升级、调试器、单元测试等功能,提供了全面的开发工具和功能。强大的扩展性:可以通过插件系统扩展功能,支持自定义构建脚本、添加新的开发板和框架等。社区支持:PlatformIO 拥有活跃的社区,开发者可以在社区中获取技术支持、交流经验和分享项目。
使用 PlatformIO 可以简化嵌入式开发的流程,提高开发效率,并使项目更易于管理和维护。无论是初学者还是有经验的嵌入式开发者,都可以受益于 PlatformIO 提供的功能和工具。
PlatformIO 只是一个插件,因此,我们需要先选择一个 IDE,在 IDE 中安装 PlatformIO 插件,这里我推荐一下两种:VSCode 与 Clion。具体该怎么选择呢?
- 如果你是经验丰富的程序员或者重度依赖 JetBrains 系列开发工具,建议使用 Clion;
- 如果你是初学者,且对 C/C++ 语言的认识仅停留在学习阶段,没有实战经验或商业项目,建议选择 VSCode,而且 VSCode 是免费。
为了照顾初学者,本套教程,使用 VSCode 安装 PlatformIO。
1. 安装 VSCode
前往 VSCode 官网 ,建议下载 System Installer 版本的安装包,该版本安装在非用户目录,例如 C 盘根目录。

下载完毕后进行安装,安装时建议按如下进行勾选。

安装完毕后打开软件,安装中文插件。

2. 安装 PlatformIO 插件
在 VSCode 中根据下图步骤安装 PlatformIO IDE 插件。

插件安装完毕后,VSCode 右下角会出现 PlatformIO 的下载进程,等待其下载完毕后即可。由于 PlatformIO 的服务器在国外,下载速度特别慢,耐心等待即可。
下载完毕后,PlatformIO 只下载了公共的数据包,没有下载特定板子的数据包,因此如需使用他人的工程,需依照该工程所用芯片新建一个工程,在第一次新建工程时,PlatformIO 会下载好该工程所需的文件。
假设需运行的工程所使用的芯片为 ESP32,使用的框架为 arduino,那么在第一次运行该工程前需先按照如下步骤新建一次工程,只有第一次需要,后续就可以直接运行了。
点击 VSCode 左下角的桌面图标。

点击 New Project,即可创建新项目。

设置项目的名称,选择开发板型号,开发框架以及项目路径。

点击 Finish 后,需要较长的一段时间,此阶段 PlatformIO 会下载该工程所需的文件,新建完成后,即可关闭此工程(直接关闭 VSCode 软件)。接着打开我们所需运行的工程即可(在工程路径下,鼠标右键后,选择通过 Code 打开)。
左下角的图标含义如图。

3. 如何使用 PlatformIO
当使用 PlatformIO 创建 ESP32 Arduino 项目时,项目目录的结构通常如下所示:

.pio:该文件夹是 PlatformIO 的工作目录,包含编译生成的二进制文件、日志文件等。.vscode:如果你在 VSCode 中使用 PlatformIO 插件,该文件夹包含了与项目相关的配置文件,如任务配置、调试配置等。include:存放头文件。lib:该文件夹用于存放项目依赖的库文件。你可以通过 PlatformIO 的库管理器安装所需的库,并它们会自动下载到该文件夹。src:该文件夹是存放源代码的主目录。你的主要代码文件(通常是.cpp和.h文件)应放在这个目录下。test:用于存放项目的测试代码和测试数据。这个目录通常用于编写单元测试或集成测试的代码,用于验证项目的功能和逻辑是否正确。.gitignore:如果你使用版本控制系统如Git进行项目管理,你可以在这个文件中指定需要忽略的文件和文件夹。platformio.ini:这是 PlatformIO 的配置文件,用于指定项目的配置选项,如目标硬件平台、编译选项、上传设置等。
在 PlatformIO 创建的项目中,lib 和 include 目录都是用于存放代码文件的特定目录。它们的区别如下:
lib目录:用于存放项目的依赖库文件。在这个目录下,可以放置项目需要引用的第三方库或自己编写的库。这些库文件通常是以源代码的形式提供,可以是单个文件或多个文件的集合。在构建过程中,这些库文件会被编译并链接到项目中。include目录:用于存放项目的头文件。头文件包含了函数、类、变量的声明,供其他源文件在编译时引用。在这个目录下,可以放置项目自定义的头文件,或者是一些需要被其他文件引用的第三方库的头文件。在编译过程中,编译器会在该目录下查找所需的头文件。
总结一下,lib 目录主要用于存放项目的依赖库文件,而 include 目录用于存放项目的头文件。这样的组织结构可以方便地管理项目所需的库文件和头文件,并在构建过程中正确引用和链接它们。
主要的代码,我们可以写在 main.cpp 中,src 目录下的 main.cpp 文件基本等同于 Arduino IDE 中创建的 .ino 文件。
只不过,在 main.cpp 文件的第一行需要 #include <Arduino.h>,其余的代码部分内容一致,我们可以直接把上节课读取串口输入的代码复制过来,但是不要忘了 #include <Arduino.h>。
#include <Arduino.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 16, 2); // set the LCD address to 0x27 for a 16 chars and 2 line display
void setup()
{
lcd.init(); // initialize the lcd
lcd.backlight();
Serial.begin(9600);
}
void loop()
{
// when characters arrive over the serial port...
if (Serial.available())
{
// wait a bit for the entire message to arrive
delay(100);
// clear the screen
lcd.clear();
// read all the available characters
while (Serial.available() > 0)
{
// display each character to the LCD
lcd.write(Serial.read());
}
}
}
想要运行该程序,可以点击左下角的或者右上角的上传选项,或者使用快捷键 ctrl + alt + u
这时候,我们还没有导入 LiquidCrystal_I2C 库,所以,代码会运行失败,想要在 PlatformIO 中导入第三方库比 Arduino IDE 方便的多,我们可以打开 PlatformIO Home 页面,点击 libraries,输入我们想要导入的库名称,添加到项目中即可。

PlatformIO 还会非常贴心的给你一个使用示例,

所以,如果你想要使用 ESP32 在 Arduino 框架下实现一个稍微复杂一点的项目的话,Platform 是你最佳的选择。
SPI 驱动 OLED 液晶屏幕
这一节我们学习如何使用 ESP32 开发板,通过 SPI 控制 OLED 液晶屏。
实验原理
1. SPI
SPI(Serial Peripheral Interface) 协议是由摩托罗拉公司提出的通讯协议,即串行外围设备接口,是一种同步、全双工、主从式接口,但并不是所有的 SPI 都是全双工。来自主机或从机的数据在时钟上升沿或下降沿同步。主机和从机可以同时传输数据。SPI 接口可以是 1 线、2 线 3 线式或 4 线式,这节课,我们用到的就是 3 线 SPI。
产生时钟信号的器件称为 主机。主机和从机之间传输的数据与主机产生的时钟同步。同 I2C 接口相比,SPI 器件支持更高的时钟频率。用户应查阅产品数据手册以了解 SPI 接口的时钟频率规格。
标准 4 线 SPI 芯片的管脚上只占用四根线。
MOSI: 主器件数据输出,从器件数据输入。MISO:主器件数据输入,从器件数据输出。SCK: 时钟信号,由主设备控制发出。CS(NSS): 从机设备选择信号,由主设备控制。当CS为低电平则选中从器件。
3 线 SPI 没有 MISO,或者 MISO 与 MOSI 共线。
SPI 接口只能有一个主机,但可以有一个或多个从机。下图显示了主机和从机之间的 SPI 连接。来自主机的片选信号用于选择从机。这通常是一个低电平有效信号,拉高时从机与 SPI 总线断开连接。当使用多个从机时,主机需要为每个从机提供单独的片选信号。MOSI 和 MISO 是数据线。MOSI 将数据从主机发送到从机,MISO将数据从从机发送到主机。

ESP32 集成了 4 个 SPI 外设。
- 其中两个在内部用于访问 ESP32 所连接的闪存。两个控制器共享相同的 SPI 总线信号,并且有一个仲裁器来确定哪个可以访问该总线。
- 另外两个是通用 SPI 控制器,分别称为 HSPI 和 VSPI。它们向用户开放,具有独立的总线信号,分别具有相同的名称。每条总线具有 3 条 CS 线,最多能控制 6 个 SPI 从设备。
I2C 与 SPI 区别
I2C 只需两根信号线,而标准 SPI 至少四根信号,如果有多个从设备,信号需要更多。一些 SPI 变种虽然只使用三根线—— SCK、CS 和双向的 MISO/MOSI,但 CS 线还是要和从设备一对一根。另外,如果 SPI 要实现多主设备结构,总线系统需额外的逻辑和线路。用 I2C 构建系统总线唯一的问题是有限的 7 位地址空间,但这个问题新标准已经解决 --- 使用 10 位地址。
如果应用中必须使用高速数据传输,那么 SPI 是必然的选择。因为 SPI 是全双工,IIC 的不是。SPI 没有定义速度限制,一般的实现通常能达到甚至超过 10Mbps。IIC 最高的速度也就快速+模式(1Mbps)和高速模式(3.4Mbps),后面的模式还需要额外的 I/O 缓冲区,还并不是总是容易实现的。SPI 适合数据流应用,而 IIC 更适合“字节设备”的多主设备应用。
SPI 有一个非常大的缺陷,主要是没有标准的协议,SPI 比较混乱,主要是没有标准的协议,只有moto的事实标准。所以衍生出多个版本,但没有本质的差异。
2. OLED 屏幕
OLED,即有机发光二极管(Organic Light Emitting Diode)。OLED 由于同时具备自发光,不需背光源、对比度高、厚度薄、视角广、反应速度快、可用于挠曲性面板、使用温度范围广、构造及制程较简单等优异之特性,被称为是第三代显示技术。
LCD 都需要背光,而 OLED 不需要,因为它是自发光的。这样同样的显示 OLED 效果要来得好一些。以目前的技术,OLED 的尺寸还难以大型化,但是分辨率确可以做到很高。

我们今天用到的屏幕是 0.96 寸的 SSD1306 芯片驱动的 OLED 屏幕。他的分辨率是 128*64,意思就是横向有 128 个像素点,纵向有 64 个
OLED 显示屏模块接口定义:
- GND:电源地。
- VCC:电源正(3.3~5V)。
- D0:OLED 的 D0 脚,在 SPI 通信中为时钟管脚。
- D1:OLED 的 D1 脚,在 SPI 通信中为数据管脚。
- RES:OLED 的 RES 脚,用来复位(低电平复位)。
- DC:数据和命令控制管脚。
- CS:片选管脚。

硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 0.96 寸 OLED 屏幕 | 1 |
| 按键 | 2 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |

软件程序设计
如果想要使用 Arduino 控制 SSD1306 驱动的 OLED 屏幕,有以下两种第三方库可以使用:
Adafruit_SSD1306 库:专门针对 SSD1306 驱动 OLED 屏幕的显示图形库;U8G2 库:目前 Arduino 平台上使用最广泛的 OLED 库。
1. Adafruit_SSD1306 控制 OLED 屏幕
想要使用 Adafruit_SSD1306,还需要安装 Adafruit_GFX 第三方库。Arduino 的 Adafruit_GFX 库为我们所有的 LCD 和 OLED 显示器提供了通用语法和图形功能集,也就是说这是一个通用图形库,并不针对特定的显示器型号。
Adafruit_GFX定义了一系列的绘画方法(线,矩形,圆等等),属于基础类,并且最重要的一点,drawPixel 方法由子类来实现;Adafruit_SSD1306定义了一系列跟 SSD1306 有关的方法,并且重写了 drawPixel 方法,属于扩展类。
首先,我们就需要先下载这两个第三方库,PlatformIO 已经为我们提供了方便的下载途径,我们可以直接在 PlatformIO 的 PIO HOME 页面中选择 Libraries 中分别搜索 Adafruit GFX Library 与 Adafruit_SSD1306,然后添加到项目中即可。

下载完以上两个第三方库之后,打开 platformio.ini 文件,可以看到 lib_deps 中出现了 SSD1306 与 Adafruit GFX Library 两个依赖,

在学习 Adafruit_SSD1306 之前,你需要明白无论什么 OLED 屏幕,最终都可以抽象为像素点阵,想显示什么内容就把具体位置的像素点亮起来。比如 SSD1306-12864 就是一个 128X64 像素点阵,这个点阵拥有自己的一套坐标系,在坐标系中,左上角是原点,向右是X轴,向下是Y轴。

接下来,我们就可以深入学习该库了。
SSD1306 包括 IIC 和 SPI 总线版本,所以针对不同版本又有对应的构造器方法,因为我们的 OLED 是 SPI 版本的,因此,我们只讲 SPI 总线的构造方法。以下代码是
/*!
@brief Constructor for SPI SSD1306 displays, using software (bitbang)
SPI.(软件SPI总线)
@param w
Display width in pixels
@param h
Display height in pixels
@param mosi_pin
MOSI (master out, slave in) pin (using Arduino pin numbering).
This transfers serial data from microcontroller to display.
@param sclk_pin
SCLK (serial clock) pin (using Arduino pin numbering).
This clocks each bit from MOSI.
@param dc_pin
Data/command pin (using Arduino pin numbering), selects whether
display is receiving commands (low) or data (high).
@param rst_pin
Reset pin (using Arduino pin numbering), or -1 if not used
(some displays might be wired to share the microcontroller's
reset pin).
@param cs_pin
Chip-select pin (using Arduino pin numbering) for sharing the
bus with other devices. Active low.
@return Adafruit_SSD1306 object.
@note Call the object's begin() function before use -- buffer
allocation is performed there!
*/
Adafruit_SSD1306(uint8_t w, uint8_t h, int8_t mosi_pin, int8_t sclk_pin,
int8_t dc_pin, int8_t rst_pin, int8_t cs_pin);
/*!
@brief Constructor for SPI SSD1306 displays, using native hardware SPI.(硬件SPI总线)
@param w
Display width in pixels
@param h
Display height in pixels
@param spi
Pointer to an existing SPIClass instance (e.g. &SPI, the
microcontroller's primary SPI bus).
@param dc_pin
Data/command pin (using Arduino pin numbering), selects whether
display is receiving commands (low) or data (high).
@param rst_pin
Reset pin (using Arduino pin numbering), or -1 if not used
(some displays might be wired to share the microcontroller's
reset pin).
@param cs_pin
Chip-select pin (using Arduino pin numbering) for sharing the
bus with other devices. Active low.
@param bitrate
SPI clock rate for transfers to this display. Default if
unspecified is 8000000UL (8 MHz).
@return Adafruit_SSD1306 object.
@note Call the object's begin() function before use -- buffer
allocation is performed there!
*/
Adafruit_SSD1306(uint8_t w, uint8_t h, SPIClass *spi,
int8_t dc_pin, int8_t rst_pin, int8_t cs_pin, uint32_t bitrate=8000000UL);
软件 SPI 总线用法,代码如下:
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels
// 软件SPI总线
// Declaration for SSD1306 display connected using software SPI (default case):
#define OLED_MOSI 13
#define OLED_CLK 18
#define OLED_DC 2
#define OLED_CS 4
#define OLED_RESET 15
Adafruit_SSD1306 oled(SCREEN_WIDTH, SCREEN_HEIGHT,
OLED_MOSI, OLED_CLK, OLED_DC, OLED_RESET, OLED_CS);
使用硬件 SPI 总线,代码如下:
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels
#define OLED_DC 2
#define OLED_CS 4
#define OLED_RESET 15
Adafruit_SSD1306 oled(SCREEN_WIDTH, SCREEN_HEIGHT,
&SPI, OLED_DC, OLED_RESET, OLED_CS);
接下来的方法(函数)无论是 I2C 还是 SPI 总线构建的,用法都是一致的:
clearDisplay:清除显示,该方法仅清除 Arduino 缓存,不会立即显示在屏幕上,可以通过调用 display 来立即清除;display:显示内容,这个方法才是真正把绘制内容画在 OLED 屏幕上(非常重要);drawCircle:绘制空心圆;fillCircle:绘制实心圆;drawTriangle:绘制空心三角形;fillTriangle:绘制实心三角形;drawRoundRect:绘制空心圆角方形;fillRoundRect:绘制实心圆角方形;drawBitmap:绘制 Bitmap 图形;drawXBitmap:绘制 XBitmap 图形;drawChar:绘制单个字符;getTextBounds:计算字符串在当前字体大小下的像素大小,返回左上角坐标以及宽度高度像素值;setTextSize:设置字体大小;setFont:设置字体;setCursor:设置光标位置;setTextColor:设置字体颜色;setTextWrap:设置是否自动换行;drawPixel:绘制像素点;drawFastHLine:绘制水平线;drawFastVLine:绘制垂直线;startscrollright:滚动到右边;startscrollleft:滚动到左边;startscrolldiagright:沿着对角线滚动到右边;startscrolldiagleft:沿着对角线滚动到左边;stopscroll:停止滚动:
使用 Adafruit_SSD1306 库分为三个步骤:
- 初始化 OLED,调用构造函数,调用 begin 方法;
- 初始化成功后,调用绘制类函数,当然可以设置颜色、字体等
- 绘制完毕,调用显示类函数 display。
了解完基本原理之后,我们就可以写一个简单的程序了,比如我们可以在屏幕上显示一些图形和字符,代码如下:
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128 // OLED 显示屏宽度
#define SCREEN_HEIGHT 64 // OLED 显示屏高度
// 软件SPI总线
#define OLED_MOSI 13
#define OLED_CLK 18
#define OLED_DC 2
#define OLED_CS 4
#define OLED_RESET 15
Adafruit_SSD1306 oled(SCREEN_WIDTH, SCREEN_HEIGHT,
OLED_MOSI, OLED_CLK, OLED_DC, OLED_RESET, OLED_CS);
void setup()
{
oled.begin();
oled.clearDisplay(); // 清除显示
oled.drawFastHLine(32, 5, 48, SSD1306_WHITE); // 绘制水平线
oled.drawLine(32, 5, 48, 30, SSD1306_WHITE); // 绘制线
oled.drawRect(5, 5, 10, 25, SSD1306_WHITE); // 绘制矩形
oled.fillRect(75, 5, 10, 30, SSD1306_WHITE); // 绘制实心矩形
oled.setCursor(5, 50); // 设置光标位置
oled.setTextSize(2); // 设置字体大小
oled.setTextColor(WHITE); // 设置文本颜色
oled.println("Hello, world!"); // 显示文字
oled.display(); // 显示内容
}
void loop()
{
}
2. 在 OLED 上显示进度条
我们也可以在 OLED 屏幕中实现一个进度条加载的动画效果,代码如下:
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128 // OLED 显示屏宽度
#define SCREEN_HEIGHT 64 // OLED 显示屏高度
// 软件SPI总线
#define OLED_MOSI 13
#define OLED_CLK 18
#define OLED_DC 2
#define OLED_CS 4
#define OLED_RESET 15
Adafruit_SSD1306 oled(SCREEN_WIDTH, SCREEN_HEIGHT,
OLED_MOSI, OLED_CLK, OLED_DC, OLED_RESET, OLED_CS);
// 初始化进度条变量
int progress = 0;
void setup()
{
oled.begin();
oled.setTextSize(2); // 设置字体大小
oled.setTextColor(SSD1306_WHITE); // 设置文本颜色
oled.display(); // 显示内容
}
void loop()
{
// 清空屏幕
oled.clearDisplay();
// 设置光标位置
oled.setCursor(25, 40);
// 显示文字
oled.println("Process");
// 显示进度条边框
oled.drawRoundRect(0, 10, 128, 20, 5, SSD1306_WHITE);
// 显示进度
oled.fillRoundRect(5, 15, progress, 10, 2, SSD1306_WHITE);
// 进度递增
if (progress < 118)
{
progress++;
}
else
{
progress = 0;
}
// 刷新屏幕
oled.display();
delay(50); // 延迟一段时间后更新显示
}
3. U8G2 库控制 OLED
学会使用 Adafruit_SSD1306 库之后,我们再学习另一个并且是 Arduino 平台上使用最广泛的 OLED 库 - U8G2 库。U8g2 是嵌入式设备的单色图形库,一句话简单明了。主要应用于嵌入式设备,包括我们常见的单片机。
安装方法与 Adafruit_SSD1306 一致,只需要在 PlatformIO 中的 libraries 中搜索对应的库,添加到项目中即可。

为什么要运用 U8g2 库?也就是说 U8g2 库能带给我们什么样的开发便利,主要考虑几个方面:
- 平台支持性好,兼容多款开发板如 ESP32、ESP8266、Arduino Uno 等;
- 显示控制器支持性好,基本上市面上的 OLED 都完美支持;
- API 众多,特别支持了中文,支持了不同字体,这是一个对于开发者来说不小的福利。
因为 U8G2 库兼容很多版本的驱动以及不同尺寸的 OLED,所以 U8G2 构造方法有很多,但是我们需要根据我们自己的 OLED 的型号,选择适合我们的构造方法。打开 U8g2lib.h 文件,找到构造器的位置:

我们可以看到这些构造方法的名字有一定的规律:U8G2_驱动芯片_屏幕尺寸_缓存大小_总线,而我们的 OLED 尺寸是 128x64,SPI 总线,SSD1306 驱动,因此,我们可以搜索 U8G2_SSD1306_128X64,

HW 表示硬件(hardware),SW 表示软件(software),4W 表示 4 线,因此,我们就找到了最适合我们的构造器
U8G2_SSD1306_128X64_NONAME_1_4W_SW_SPI
U8G2_SSD1306_128X64_NONAME_2_4W_SW_SPI
U8G2_SSD1306_128X64_NONAME_F_4W_SW_SPI
这里的 1、2、F 表示不同的缓存大小:
1;只有一页的缓冲区,需要使用 firstPage/nextPage 方法来循环更新屏幕,使用 128 字节的内存;2:保持两页的缓冲区,使用 256 字节的内存;F:保存有完整的显示的缓存,可以使用所有的函数,但是 ram 消耗大,一般用在 ram 空间比较大的开发板;
所有的软件模拟总线构造函数的第一个参数都是 rotation,这个参数表示显示内容是否旋转,U8G2 提供了以下几个选项:
U8G2_R0:不旋转;U8G2_R1:顺时针转 90°;U8G2_R2:顺时针转 180°;U8G2_R3:顺时针转 270°;U8G2_MIRROR:镜像翻转;
构造完对象之后,我们就可以学习 U8G2 的方法了,方法可以分为四大类(这里我们只列举了部分,详细内容可以查阅 u8g2 库):
- 基本函数
begin():初始化方法;initDisplay():初始化显示控制器,这个方法不需要我们单独调用,会在 begin 函数主动调用一次,我们主要理解即可,会在里面针对具体的 OLED 进行配置;;clearDisplay():清除屏幕内容,这个方法不需要我们单独调用,会在 begin 函数主动调用一次,我们主要理解即可,并且不要在 firstPage 和 nextPage 函数之间调用该方法;clear():清除操作;clearBuffer():清除缓冲区;enableUTF8Print():开启 Arduino 平台下支持输出 UTF8 字符集,我们的中文字符就是UTF8;home():重置显示光标的位置,回到原点(0,0);
- 绘制相关函数
drawPixel():绘制像素点;drawHLine():绘制水平线;drawLine():两点之间绘制线drawBox():画实心方形;drawFrame():画空心方形drawCircle():画空心圆;drawDisc():画实心圆;drawStr():绘制字符串,需要先设置字体,调用 setFont 方法;drawXBM()/drawXBMP():绘制图像;firstPage()/nextPage():绘制命令,firstPage 方法会把当前页码位置变成 0,修改内容处于 firstPage 和 nextPage 之间,每次都是重新渲染所有内容;print():绘制内容;
- 显示配置相关函数
getDisplayHeight():获取显示器的高度;getDisplayWidth():获取显示器的宽度;setCursor():设置绘制光标位置;setDisplayRotation():设置显示器的旋转角度;setFont():设置字体集(字体集用于字符串绘制方法或者glyph绘制方法);
- 缓存相关函数
getBufferPtr():获取缓存空间的地址;getBufferTileHeight():获取缓冲区的Tile高度,一个tile等于8个像素点;getBufferTileWidth():获取缓冲区的Tile宽度;getBufferCurrTileRow():获取缓冲区的当前Tile row;clearBuffer():清除内部缓存区;sendBuffer():发送缓冲区的内容到显示器。
U8g2 支持以下两种绘制模式:
Full screen buffer mode,全屏缓存模式;Page mode,分页模式;
全屏缓存模式使用步骤:
- 构造对象,根据 OLED 的型号选择对应的构造器,构造器必须带
F,因此,需要使用U8G2_SSD1306_128X64_NONAME_F_4W_SW_SPI; - 初始化对象,使用 begin() 方法,清除缓冲区内容,使用 u8g2.clearBuffer();
- 绘制内容,使用绘制函数或者设置字体等;
- 发送缓冲区的内容到显示器 u8g2.sendBuffer()。
了解完构造方法与使用方法之后,我们就可以来在程序中使用 U8G2 库了,代码如下:
#include <Arduino.h>
#include <U8g2lib.h>
// 构造对象
U8G2_SSD1306_128X64_NONAME_F_4W_SW_SPI u8g2(U8G2_R0, /* clock=*/18, /* data=*/13,
/* cs=*/4, /* dc=*/2, /* reset=*/15);
void setup(void)
{
// 初始化 oled 对象
u8g2.begin();
// 开启中文字符集支持
u8g2.enableUTF8Print();
}
void loop(void)
{
// 设置字体
u8g2.setFont(u8g2_font_unifont_t_chinese2);
// 设置字体方向
u8g2.setFontDirection(0);
//
u8g2.clearBuffer();
u8g2.setCursor(0, 15);
u8g2.print("Hello GeeksMan!");
u8g2.setCursor(0, 40);
u8g2.print("你好, ESP32!");
u8g2.sendBuffer();
delay(1000);
}
4. U8G2 库的分页模式实现进度条效果
分页模式的使用步骤:
- 构造对象,根据 OLED 的型号选择对应的构造器;
- 初始化对象,使用 begin() 方法,调用 firstPage() 进入第一页
- 开始一个 do while 循环,循环条件是 nextPage(),作用是进入下一页,如果还有下一页则返回 true;
- 在循环内部 操作一些绘制方法。
注意
请注意,firstPage() 和 nextPage() 必须配合使用,并在循环中正确调用。另外,确保在每次循环开始时使用 u8g2.clearBuffer() 清除缓冲区,以防止前一页的内容残留在当前页上。
#include <Arduino.h>
#include <U8g2lib.h>
U8G2_SSD1306_128X64_NONAME_2_4W_SW_SPI u8g2(U8G2_R0, /* clock=*/18, /* data=*/13,
/* cs=*/4, /* dc=*/2, /* reset=*/15);
int progress = 0;
void setup()
{
// 初始化 OLED 对象
u8g2.begin();
}
void loop()
{
// 进入第一页
u8g2.firstPage();
do
{
// 显示进度条边框
u8g2.drawFrame(0, 10, 128, 20);
// 显示进度
u8g2.drawBox(5, 15, progress, 10);
} while (u8g2.nextPage()); // 进入下一页,如果还有下一页则返回true
// 进度递增
if (progress < 118)
{
progress++;
}
else
{
progress = 0;
}
}
5. 按键控制菜单
在搞清楚 U8G2 库的使用方法之后,我们就可以设计一个按键控制菜单了,UI 大概就是下面这个样子

按键控制菜单的原理其实很简单 -,当我检测到按键按下的时候,就切换屏幕状态,因为只有部分区域发生了改变,让你产生立箭头移动的错觉,代码如下:
#include <Arduino.h>
#include <U8g2lib.h>
// PlatformIO 中 自己编写的函数如果处于末尾,需要在文件顶部显式声明
void display_menu(unsigned int index);
U8G2_SSD1306_128X64_NONAME_2_4W_SW_SPI u8g2(U8G2_R0, /* clock=*/18, /* data=*/13,
/* cs=*/4, /* dc=*/2, /* reset=*/15);
#define MENU_SIZE 4
char *menu[MENU_SIZE] = {"Item 1", "Item 2", "Item 3", "Item 4"};
#define BUTTON_UP 12
#define BUTTON_DOWN 14
// 定义当前选项
unsigned int order = 0;
void setup()
{
// 初始化 OLED 对象
u8g2.begin();
u8g2.setFont(u8g2_font_6x12_tr);
// 配置输入按键
pinMode(BUTTON_UP, INPUT_PULLUP);
pinMode(BUTTON_DOWN, INPUT_PULLUP);
}
void loop()
{
// 判断按键是否按下,并记录当前箭头位置
if(!digitalRead(BUTTON_UP))
{
order = (order - 1) % 4;
}else if (!digitalRead(BUTTON_DOWN))
{
order = (order + 1) % 4;
}
// 显示菜单
display_menu(order);
// 延时
delay(100);
}
void display_menu(unsigned int index)
{
// 进入第一页
u8g2.firstPage();
do
{
// 绘制页面内容
u8g2.drawStr(0, 12, "Menu");
u8g2.drawHLine(0, 14, 128);
for (int i = 0; i < MENU_SIZE; i++)
{
if (i == index)
{
u8g2.drawStr(5, (i + 2) * 12 + 2, ">");
u8g2.drawStr(20, (i + 2) * 12 + 2, menu[i]);
}
else
{
u8g2.drawStr(5, (i + 2) * 12 + 2, menu[i]);
}
}
} while (u8g2.nextPage()); // 进入下一页,如果还有下一页则返回 True.
}
在写这个程序的时候,我们用到了以下几个新的知识点,
- 使用
unsigned int可以声明小于 0 的整数; do ... while是先执行一次循环体,再判断的循环;- 自己写的函数需要在文件顶部声明;
- 指针数组是一个数组,其中的每个元素都是指针类型的变量。换句话说,指针数组存储了多个指针,每个指针可以指向内存中的不同位置。
外部中断
之前我们学习了 ESP32 的按键控制,当时通过查询 GPIO 输入电平来判断按键状态,这种方法占用 CPU 资源,效率不高。本节课我们学习外部中断,通过外部中断实现按键控制 LED。
实验原理
在单片机中,中断是指当 CPU 在正常处理主程序时,突然发生了另一件事件 A(中断发生)需要 CPU 去处理,这时 CPU 就会暂停处理主程序(中断响应),转而去处理事件 A(中断服务)。当事件 A 处理完以后,再回到主程序原来中断的地方继续执行主程序(中断返回)。这一整个过程称为中断。
例如,当你正在洗衣时,突然手机响了(中断发生),你暂时中断洗衣的工作,转去接电话(中断响应和中断服务),待你接完后,再回来继续洗衣(中断返回),这一过程就是中断。

当中断过程 A 中,发生了另一个中断级别更高的中断事件 B,则 CPU 又会中断当前的 A 转而去处理 B,完毕后再回到 A 的断点继续处理。这称为中断的嵌套。

中断的嵌套涉及到中断的优先级问题,优先级高的中断就可以在打断优先级低的中断执行。
中断可以根据中断源分为 硬件中断 和 软件中断:
硬件中断:也被称为外部中断,硬件中断响应外部硬件事件而发生。例如,当检测到触摸时会发生触摸中断,而当 GPIO 引脚的状态发生变化时会发生 GPIO 中断。GPIO 中断和触摸中断属于这一类;软件中断:当触发软件事件(例如定时器溢出)时,会发生这种类型的中断。定时器中断是软件中断的一个例子。
前面我们在做按键控制实验时,虽然能实现 IO 口输入功能,但代码是一直在检测 IO 输入口的变化,因此效率不高,特别是在一些特定的场合,比如某个按键,可能 1 天才按下一次去执行相关功能,这样我们就浪费大量时间来实时检测按键的情况。
为了解决这样的问题,我们引入外部中断概念,顾名思义,就是当按键被按下(产生中断)时,才去执行相关功能。这大大节省了 CPU 的资源,因此中断在实际项目中应用非常普遍。
ESP32 的外部中断有上升沿、下降沿、低电平、高电平触发模式。上升沿和下降沿触发如下:

若将按键对应 IO 配置为下降沿触发,当按键按下后即触发中断,然后在中断回调函数内执行对应的功能。
硬件电路设计
使用按键电路实现开关灯的效果。
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 1 |
| 1kΩ 电阻 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
将材料按照下图相连:

软件程序设计
Arduino 中的外部中断配置函数 attachInterrupt(digitalPinToInterrupt(pin), ISR, mode) 包括 3 个参数:
pin:GPIO 端口号;ISR:中断服务程序,没有参数与返回值的函数;mode:中断触发的方式,支持以下触发方式:LOW低电平触发HIGH高电平触发RISING上升沿触发FALLING下降沿触发CHANGE电平变化触发
在 Arduino 中使用中断需要注意一下几点:
尽量保证中断程序内容少
避免在中断处理函数中使用阻塞函数(如
delay()),使用非阻塞的延迟方法来处理需要延迟的操作(micros() 函数),以保证中断的正常执行和系统的稳定性。这是因为delay()函数会阻塞整个系统,包括中断的正常执行。当中断触发时,处理函数应该尽快执行完毕,以确保及时响应并避免中断积压;与主程序共享的变量要加上 volatile 关键字;
在 Arduino 中使用中断时,应尽量避免在中断处理函数中使用
Serial对象的打印函数。当在中断处理函数中使用Serial打印函数时,会导致以下问题:- 时间延迟:
Serial打印函数通常是比较耗时的操作,它会阻塞中断的执行时间,导致中断响应的延迟。这可能会导致在中断期间丢失其他重要的中断事件或导致系统不稳定。 - 缓冲区溢出:
Serial对象在内部使用一个缓冲区来存储要发送的数据。如果在中断处理函数中频繁调用Serial打印函数,可能会导致缓冲区溢出,造成数据丢失或不可预测的行为。
- 时间延迟:
为了避免这些问题,建议在中断处理函数中尽量避免使用 Serial 打印函数。如果需要在中断处理函数中输出调试信息,可以使用其他方式,如设置标志位,在主循环中检查标志位并进行打印,代码如下:
#define BUTTON 14
// 定义可以在外部中断函数中使用的变量
volatile bool flag = false;
// 定义外部中断函数
void handle_interrupt() {
flag = true;
number += 10000;
}
void setup() {
Serial.begin(9600);
pinMode(BUTTON, INPUT_PULLDOWN);
// 配置中断引脚
attachInterrupt(digitalPinToInterrupt(BUTTON), handle_interrupt, FALLING);
}
void loop() {
if (flag) {
Serial.println("外部中断触发");
flag = false;
}
}
因此,我们使用外部中断点灯的代码可以这么写:
#define BUTTON 14
#define LED 2
volatile bool flag = false;
void ISR() {
flag = true;
}
void setup() {
pinMode(BUTTON, INPUT_PULLDOWN);
pinMode(LED, OUTPUT);
// 配置中断引脚
attachInterrupt(digitalPinToInterrupt(BUTTON), ISR, FALLING);
}
void loop() {
if (flag) {
digitalWrite(LED, HIGH);
delay(2000);
digitalWrite(LED, LOW);
// 重置中断标志位
flag = false;
}
}
定时器中断
上一节我们介绍了 ESP32 的外部中断的使用,本节课介绍 ESP32 的定时器功能。
实验原理
定时器,顾名思义就是用来计时的,我们常常会设置计时或闹钟,然后时间到了就告诉我们要做什么。ESP32 也是这样,通过定时器可以完成各种预设好的任务。ESP32 定时器到达指定时间后也会产生中断,然后在回调函数内执行所需功能,这个和外部中断类似。
在 Arduino 中操控 ESP32 时,有 硬件定时器 和 软件定时器 两种类型的定时器可供选择。它们具有不同的工作原理和用途。
硬件定时器 是 ESP32 芯片上的内置计时器,它们是专门设计用于定时和计时任务的硬件模块。硬件定时器可以通过设置特定的寄存器来配置和控制,通常具有更高的精确度和稳定性。它们不受软件的影响,可以在后台独立运行,不会受到其他代码的干扰。硬件定时器适用于需要高精度和实时性的定时任务,例如 PWM 输出、捕获输入脉冲等。
软件定时器 是通过编写代码在 Arduino 中模拟实现的定时器。它们不依赖于硬件模块,而是使用计数器变量来实现定时功能。软件定时器是基于延时循环的原理,在特定的时间间隔内执行特定的任务。但是,使用软件定时器时需要注意,它们可能会受到其他代码的影响而产生误差,特别是当涉及到需要精确时间控制的应用时,如通信协议处理、高速数据采集等。
硬件定时器和软件定时器各有优劣,具体选择取决于你的应用需求。如果需要高精度、实时性和稳定性,建议使用硬件定时器。如果时间精度要求不高,或者只需要基本的定时功能,可以使用软件定时器来简化代码编写。
需要注意的是,ESP32 具有 4 个硬件定时器,具体使用哪个定时器取决于你的需求和硬件资源的可用性。请参考 ESP32 的官方文档和相关库的文档以获取更详细的信息和使用示例。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 2 |
| 1kΩ 电阻 | 2 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
LED 的正极接开发板的 D2、D4 引脚,并串联一个电阻,负极接 GND,如下图:

注意
一定要接电阻,不然会由于电流过大,烧坏 LED。
软件程序设计
1. 硬件定时器
在 ESP32 Arduino 开发环境中,可以使用以下几个库函数来配置和操作硬件定时器(Timer):
void timerBegin(timer_num_t timer_num, uint32_t divider, bool count_up):初始化硬件定时器,参数说明:
timer_num:定时器编号,可选值为 0-3 等。divider:定时器的分频系数,用于设置定时器的时钟频率。较大的分频系数将降低定时器的时钟频率。可以根据需要选择合适的值,一般设置为 80 即可;count_up:指定定时器是否为向上计数模式。设置为 true 表示向上计数,设置为 false 表示向下计数。
timerAttachInterrupt(hw_timer_t *timer, void (*isr)(void *), void *arg, int intr_type):用于将中断处理函数与特定的定时器关联起来,参数含义如下:
timer;定时器指针;isr: 中断处理函数。arg: 传递给中断处理函数的参数。intr_type: 中断类型,可选值为 ture(边沿触发)或 false(电平触发)。
timerAlarmWrite(hw_timer_t *timer, uint64_t alarm_value, bool autoreload):用于设置定时器的计数值,即定时器触发的时间间隔,参数含义如下:
timer:定时器指针;alarm_value: 定时器的计数值,即触发时间间隔;autoreload: 是否自动重载计数值,可选值为 true(自动重载)或 false(单次触发)。
timerAlarmEnable(hw_timer_t *timer):用于启动定时器,使其开始计数;timerAlarmDisable(hw_timer_t *timer):用于禁用定时器,停止计数;timerGetAutoReload(hw_timer_t *timer):获取定时器是否自动重新加载;timerAlarmRead(hw_timer_t *timer):获取定时器计数器报警值;timerStart(hw_timer_t *timer):计数器开始计数;timerStop(hw_timer_t *timer):计数器停止计数;timerRestart(hw_timer_t *timer):计数器重新开始计数,从 0 开始;timerStarted(hw_timer_t *timer):计数器是否开始计数。
以上是一些常用的硬件定时器相关的库函数,你可以根据自己的需求和定时器的特性,调用适当的函数来配置和操作硬件定时器。请参考 ESP32 的官方文档和相关库的文档,以获取更详细的信息。
使用硬件定时器的基本步骤如下:
- 初始化定时器:使用
timerBegin()函数初始化所需的硬件定时器; - 注册中断处理函数:使用
timerAttachInterrupt()函数将中断处理函数与定时器关联起来; - 设置定时器模式:使用
timerAlarmWrite(),设置触发一次,还是周期性触发; - 启动定时器:使用
timerAlarmEnable()函数启动定时器,使其开始计数。
因此,我们的代码可以这么写:
#define LED 2
#define LED_ONCE 4
hw_timer_t *timer = NULL;
hw_timer_t *timer_once=NULL;
// 定时器中断处理函数
void timer_interrupt(){
digitalWrite(LED, !digitalRead(LED));
}
void timer_once_interrupt() {
digitalWrite(LED_ONCE, !digitalRead(LED_ONCE));
}
void setup() {
pinMode(LED, OUTPUT);
pinMode(LED_ONCE, OUTPUT);
// 初始化定时器
timer = timerBegin(0,80,true);
timer_once = timerBegin(1, 80, true);
// 配置定时器
timerAttachInterrupt(timer,timer_interrupt,true);
timerAttachInterrupt(timer_once, timer_once_interrupt, true);
// 定时模式,单位us,只触发一次
timerAlarmWrite(timer,1000000,true);
timerAlarmWrite(timer_once, 3000000, false);
// 启动定时器
timerAlarmEnable(timer);
timerAlarmEnable(timer_once);
}
void loop() {
}
2. 软件定时器
使用软件计时器的时候,我们需要用到 ESP32 内置的库 Ticker,Ticker 是 ESP32 Arduino 内置的一个定时器库,这个库用于规定时间后调用函数。
接着我们来看看 Ticker 库的一些方法
detach():停止 Ticker;active():Ticker 是否激活状态,True 表示启用;once(n, callback,arg):n 秒后只执行一次 callback 函数,arg 表示回调函数的参数(不写表示没有);once_ms(n, callback,arg):n 毫秒后只执行一次 callback 函数,arg 表示回调函数的参数(不写表示没有);attach(n, callback, arg):每隔 n 秒周期性执行;attach_ms(n, callback, arg):每隔 n 毫秒周期性执行;
注意
不建议使用 Ticker 回调函数来阻塞 IO 操作(网络、串口、文件);可以在 Ticker 回调函数中设置一个标记,在 loop 函数中检测这个标记;
#include <Ticker.h>
#define LED 4
#define LED_ONCE 2
// 定义定时器对象
Ticker timer;
Ticker timer_once;
// 定义定时器中断回调函数
void toggle(int pin) {
digitalWrite(pin, !digitalRead(pin));
}
void setup() {
pinMode(LED, OUTPUT);
pinMode(LED_ONCE, OUTPUT);
// 配置周期性定时器
timer.attach(0.5, toggle, LED);
// 配置一次性定时器
timer_once.once(3, toggle, LED_ONCE);
}
void loop() {
}
舵机实验
舵机在电子产品中非常常见,比如四足机器人、固定翼航模等都有应用,因此学习舵机对后续完成电子制作非常有意义。本节课学习使用 MicroPython 的 PWM 对 SG90 舵机旋转角度控制。
实验原理
舵机是一种位置(角度)伺服的驱动器,适用于那些需要角度不断变化并可以保持的控制系统。舵机只是一种通俗的叫法,其本质是一个伺服电机。

舵机有很多规格,但所有的舵机都有外接三根线,分别用棕、红、橙三种颜色进行区分,由于舵机品牌不同,颜色也会有所差异,棕色为接地线,红色为电源正极线,橙色为信号线。只要通过信号线给予规定的控制信号即可实现舵机码盘的转动。

SG90 的主要电气参数:
- 使用电压: 4.8V - 6V
- 尺寸: 221.5mm x 11.8mm x 22.7mm
- 重量: 9g
- 角度范围:0-180°
舵机的工作原理是由接收机或者单片机发出信号给舵机,其内部有一个基准电路,将获得的直流偏置电压与电位器的电压比较,获得电压差输出。经由电路板上的 IC 判断转动方向,再驱动无核心马达开始转动,透过减速齿轮将动力传至摆臂,同时由位置检测器送回信号,判断是否已经到达定位。当电机转速一定时,通过级联减速齿轮带动电位器旋转,使得电压差为0,电机停止转动。一般舵机旋转的角度范围是 0 度到 180 度,当然也有 0 度到 360 度。
我们没有必要了解舵机的内部结构,只需要知道如何通过 PWM 控制其转动即可。舵机的控制就是通过一个固定的频率,给其不同的占空比的,来控制舵机不同的转角。
舵机的转动的角度是通过调节 PWM(脉冲宽度调制)信号的占空比来实现的,标准 PWM(脉冲宽度调制)信号的周期固定为 20ms(50Hz),理论上脉宽分布应在 1ms 到 2ms 之间,但是,事实上脉宽可由 0.5ms 到 2.5ms 之间,脉宽和舵机的转角 0°~180° 相对应。有一点值得注意的地方,由于舵机牌子不同,对于同一信号,不同牌子的舵机旋转的角度也会有所不同。

0.5-2.5ms 的 PWM 高电平部分对应控制 180 度舵机的 0-180 度,因此,对应的控制关系是这样的:

| 高电平占整个周期(20ms)的时间 | 舵机旋转的角度 | 对应的占空比 |
|---|---|---|
| 0.5ms | 0° | 0.5 // 20 |
| 1ms | 45° | 1 // 20 |
| 1.5ms | 90° | 1.5 // 20 |
| 2ms | 135° | 2 // 20 |
| 2.5ms | 180° | 2.5 // 20 |
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 舵机 | 1 |
| 杜邦线(跳线) | 3 |

注意
注意接线顺序
软件程序设计
1. LEDC 输出 PWM 信号
首先,我们使用 LEDC 输出 PWM 信号,根据之前的实验原理,我们可以确定频率、最大脉宽 与 最小脉宽,代码如下:
// 1/20 秒,50Hz 的频率,20ms 的周期,这个变量用来存储时钟基准。
#define FREQ 50
// 通道(高速通道(0 ~ 7)由 80MHz 时钟驱动,低速通道(8 ~ 15)由 1MHz 时钟驱动。)
#define CHANNEL 0
// 分辨率设置为 8,就是 2 的 8 次方,用 256 的数值来映射角度。
#define RESOLUTION 8
// 定义舵机 PWM 控制引脚。
#define SERVO 13
//定义函数用于输出 PWM 的占空比
int calculatePWM(int degree)
{
//20ms 周期内,高电平持续时长 0.5-2.5 ms,对应 0-180 度舵机角度。
//对应 0.5ms(0.5ms/(20ms/256))
float min_width = 0.6 / 20 * pow(2, RESOLUTION);
//对应 2.5ms(2.5ms/(20ms/256))
float max_width = 2.5 / 20 * pow(2, RESOLUTION);
if (degree < 0)
degree = 0;
if (degree > 180)
degree = 180;
//返回度数对应的高电平的数值
return (int)(((max_width - min_width) / 180) * degree + min_width);
}
void setup()
{
// 用于设置 LEDC 通道的频率和分辨率
ledcSetup(CHANNEL, FREQ, RESOLUTION);
// 将通道与对应的引脚连接
ledcAttachPin(SERVO, CHANNEL);
}
void loop()
{
for (int i = 0; i <= 180; i += 10)
{
// 输出PWM,设置 LEDC 通道的占空比。
ledcWrite(CHANNEL, calculatePWM(i));
delay(1000);
}
}
2. 使用第三方库控制舵机
如果我们想要使用 Arduino 控制舵机就需要现在 ESP32Servo 库,点击 项目,选择 加载库 中的 管理库...。

然后我们输入 ESP32Servo,点击安装即可。

我们可以在 VSCode 的 PlatformIO 中,根据案例了解 ESP32Servo 库的使用方法

代码如下:
#include <ESP32Servo.h>
#define SERVO_PIN 13
#define MAX_WIDTH 2500
#define MIN_WIDTH 500
// 定义 servo 对象
Servo my_servo;
void setup() {
// 分配硬件定时器
ESP32PWM::allocateTimer(0);
// 设置频率
my_servo.setPeriodHertz(50);
// 关联 servo 对象与 GPIO 引脚,设置脉宽范围
my_servo.attach(SERVO_PIN, MIN_WIDTH, MAX_WIDTH);
}
void loop() {
my_servo.write(180);
delay(1000);
my_servo.write(0);
delay(1000);
}
Wi-Fi 连接
连接路由器上网是我们每天都做的事情,日常生活中我们只需要知道路由器的账号和密码,就能使用电脑或者手机连接到无线路由器,然后上网冲浪。
如今物联网市场异常火爆,WIFI 是物联网中非常重要的角色,现在基本上家家户户都有 WIFI 网络,通过 WIFI 接入到互联网,成了智能家居产品普遍的选择。ESP32 内部已集成 WIFI 功能,可以说它就是为 WIFI 无线连接而生的。本章来学习 ESP32 的 WIFI,使用 Arduino 开发 WIFI 是非常简单而美妙的,让大家在学习物联网中变的简单有趣,WIFI 模块也是为什么 ESP32 可以迅速崛起的主要原因之一。
硬件电路设计
连接无线路由器,将 ESP32 的 IP 地址等信息通过 Shell 控制台输出显示。
由于 ESP32 内置 WIFI 功能,所以直接在开发板上使用即可,无需额外连接。
软件电路设计
Arduino 已经集成了 Wi-Fi 模块,因此我们可以直接使用该模块。
模块包含热点 AP 模式和客户端 STA 模式,热点 AP 是指电脑或手机端直接连接 ESP32 发出的热点实现连接,如果电脑连接模块 AP 热点,这样电脑就不能上网,因此在使用电脑端和模块进行网络通信时,一般情况下都是使用 STA 模式。也就是电脑和设备同时连接到相同网段的路由器上。
下面是一些 ESP32 Arduino 库中常用的 Wi-Fi 相关函数的介绍:
WiFi.begin(ssid, password):该函数用于连接到 Wi-Fi 网络。需要提供要连接的网络的 SSID 和密码作为参数。WiFi.disconnect():该函数用于断开当前的 Wi-Fi 连接。WiFi.status():该函数返回当前 Wi-Fi 连接的状态。返回值可能是以下之一:WL_CONNECTED:已连接到 Wi-Fi 网络。WL_DISCONNECTED:未连接到 Wi-Fi 网络。WL_IDLE_STATUS:Wi-Fi 处于空闲状态。WL_NO_SSID_AVAIL:未找到指定的 Wi-Fi 网络。
WiFi.localIP():该函数返回 ESP32 设备在 Wi-Fi 网络中分配的本地 IP 地址。WiFi.macAddress():该函数返回 ESP32 设备的 MAC 地址。WiFi.scanNetworks():该函数用于扫描周围可用的 Wi-Fi 网络。它返回一个整数,表示扫描到的网络数量。可以使用其他函数(如WiFi.SSID()和WiFi.RSSI())来获取每个网络的详细信息。WiFi.SSID(networkIndex):该函数返回指定索引的扫描到的 Wi-Fi 网络的 SSID。WiFi.RSSI(networkIndex):该函数返回指定索引的扫描到的 Wi-Fi 网络的信号强度(RSSI)。
#include <WiFi.h>
#define LED 2
// 定义 Wi-Fi 名与密码
const char * ssid = "WiFi名";
const char * password = "WiFi密码";
void setup() {
Serial.begin(9600);
// 断开之前的连接
WiFi.disconnect(true);
// 连接 Wi-Fi
WiFi.begin(ssid, password);
Serial.print("正在连接 Wi-Fi");
// 检测是否链接成功
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("连接成功");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
// 使用板载 LED 反馈连接成功
pinMode(LED, OUTPUT);
digitalWrite(LED, HIGH);
delay(100);
digitalWrite(LED, LOW);
delay(100);
digitalWrite(LED, HIGH);
delay(100);
digitalWrite(LED, LOW);
delay(100);
digitalWrite(LED, HIGH);
delay(1500);
digitalWrite(LED, LOW);
}
void loop() {
}
在 C 和 C++ 编程语言中,const char * 是一个常见的类型声明,通常用于表示指向字符常量的指针。让我们逐个解释这个声明的各个部分:
const:这是一个关键字,表示指针所指向的数据是常量,即不可修改。使用const修饰指针可以确保在使用指针时不会意外地修改所指向的数据;char:这是字符类型的关键字,表示该指针指向的数据是字符类型的数据。char类型通常用于表示单个字符或字符数组;*:这是指针声明符号,用于表示将声明一个指向特定类型的指针。在这种情况下,它表示将声明一个指向char类型数据的指针。
const char *:将上述部分组合在一起,表示一个指向字符常量的指针。这意味着该指针指向的字符数据是不可修改的。
可以使用 const char *声明来指向字符串常量,例如:
const char *str = "Hello, world!";
在上面的示例中,str 是一个指向字符常量的指针,它指向存储在内存中的字符串 "Hello, world!"。通过使用 const 关键字,我们确保不会通过 str 指针修改该字符串的内容。
刚才我们已经可以正常连接 WiFi 了,接下来,我们就可以来创建热点了。
#include <WiFi.h>
// 设置要创建的热点名与密码
const char * ssid = "ESP32_AP";
const char * password = "12345678";
void setup() {
Serial.begin(9600);
// 创建热点
WiFi.softAP(ssid, password);
// 打印热点 IP
Serial.print("Wi-Fi 接入的 IP:");
Serial.println(WiFi.softAPIP());
}
void loop() {
}
获取网络请求
这节课我们来学习如何使用 ESP32 获取网络请求。
实验原理
ESP32 支持 2.4G 网络,那我们可以通过发送 HTTP 请求来获取实时天气数据。一般来说,天气数据是由一些公共 API 接口提供的,这些接口需要向它们发送 HTTP 请求以获取数据。
HTTP 请求与 API
当我们在浏览器中输入网址或者使用应用程序时,我们实际上是向服务器发出请求。HTTP 请求是客户端(如浏览器)与服务器之间通信的方式,用于获取或发送 Web 资源。这些资源可以是文本文件、图像、脚本等,客户端通过 HTTP 协议发起请求,服务器返回相应的响应。
HTTP 请求通常由以下几个部分组成:
请求行:包含请求方法、请求 URL 和 HTTP 协议版本,例如
GET https://www.baidu.com/content-search.xml HTTP/1.1
GET 是请求方法,https://www.baidu com/ 是 URL 地址,HTTP/1.1 指定了协议版本。
HTTP 协议版本一般都是 HTTP/1.1,URL 是你要访问的地址,而请求方法除了 GET 还有 POST、PUT、DELETE 经常使用的 4 个请求方式,以及一些其他的请求方法。
请求头:包含与请求相关的信息,例如浏览器类型、请求时间等。请求体:包含请求所需的数据。
我们虽然可以对任意网址发送网络请求,但是这样毫无意义,比如,我想要获取某个地区的天气状况,就需要调用相对应的接口,也就是 API。
API(Application Programming Interface)是指应用程序编程接口,它定义了应用程序之间进行通信的方式和规范。API 允许不同的应用程序之间进行数据交换,使得应用程序可以共享资源和信息,从而提高应用程序的效率和可用性。
API 通常使用 HTTP 请求来提供服务,客户端通过发送 HTTP 请求访问 API,服务器则通过 HTTP 响应返回所需的数据。API 可以提供许多不同的服务,例如访问数据库、获取实时数据、处理图像等。
当我们使用别人提供的 API 的时候就需要遵守别人制定的规则,使用对应的链接、请求方法等等,我们需要查看 API 文档来获取这些信息。比如,我们今天使用聚合数据的 API 接口。

总之,HTTP 请求是客户端与服务器之间通信的方式,API 则是应用程序之间通信的方式。通过 HTTP 请求访问 API,我们可以实现不同应用程序之间的数据交换和共享。
JSON 数据
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,常用于 Web 应用程序之间的数据传输。它是一种文本格式,易于阅读和编写,并且可以被各种编程语言支持。
JSON 数据由键值对组成,其中键是字符串,值可以是字符串、数字、布尔值、数组、对象等数据类型。一个基本的 JSON 对象看起来像这样:
{
"name": "罗大富",
"age": 29,
"isStudent": false,
"hobbies": ["睡觉", "打游戏"],
"address": {
"city": "菏泽",
"state": "山东"
}
}
其中,name、age、isStudent、hobbies 和 address 都是键,而对应的值分别是字符串 "罗大富"、数字 29、布尔值 false、字符串数组 ["睡觉", "打游戏"] 和一个嵌套的 JSON 对象 {"city": "菏泽", "state": "山东"}。
JSON 数据通常用于 Web 应用程序中,例如从后端服务器获取数据或向后端服务器发送数据。在前端 JavaScript 中,可以使用内置的 JSON 对象将 JSON 字符串转换为 JavaScript 对象,或将 JavaScript 对象转换为 JSON 字符串。
软件程序设计
想要发送 HTTP 请求,我们就需要用到 HTTPClient 库。
HTTPClient 库是一个用于 Arduino 的 HTTP 客户端库,它提供了一组函数来轻松地发送 HTTP 请求并处理服务器响应。HTTPClient 库基于 ESP-IDF 的 HTTP 客户端实现,并在 Arduino 框架下进行了封装,使其易于使用。
以下是 HTTPClient 库的一些常用功能和函数:
HTTPClient http;:创建 HTTPClient 对象。http.begin(url):指定要发送请求的 URL。http.addHeader(name, value):添加 HTTP 头部。http.setAuthorization(username, password):设置 HTTP 基本身份验证的用户名和密码。http.setTimeout(timeout):设置请求超时时间(以毫秒为单位)。http.GET():发送 GET 请求,并返回一个 HTTP 状态码。http.POST(payload):发送 POST 请求,并将 payload 作为请求正文。http.responseStatusCode():获取响应的状态码。http.responseHeaders():获取响应的头部。http.responseBody():获取响应的正文。http.getString():获取响应正文作为字符串。http.getStream():获取响应正文作为流对象。http.end():关闭连接并释放资源。
我们从 Web 服务获取的是 JSON 数据,要想解析 JSON 数据,可以使用 Arduino 的 ArduinoJSON 库。ArduinoJSON 库使您能够解析和生成 JSON 数据,以及在 Arduino 上处理 JSON 格式的数据。
下面是使用 ArduinoJSON 库解析 JSON 数据的基本步骤:
- 引入
ArduinoJson.h头文件; - 创建一个 DynamicJsonDocument 对象来存储和处理 JSON 数据,
DynamicJsonDocument doc(1024); // 指定 JSON 文档的大小
- 使用
deserializeJson()函数将 JSON 数据解析到 DynamicJsonDocument 对象中:
deserializeJson(doc, json);
doc:DynamicJsonDocument 对象,用于存储解析后的 JSON 数据。json:包含 JSON 数据的字符串或字符数组。
- 通过使用
.as<type>()方法从解析后的 JSON 文档中获取值:
int value = doc["key"].as<int>();
key:JSON 对象的键。as<type>():将值转换为指定的类型,例如 int、float、String 等。
因此,我们的获取实时天气网络数据的代码可以这么写:
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
const char* ssid = "GeeksMan";
const char* password = "123456qq.";
// 定义
String url = "http://apis.juhe.cn/simpleWeather/query";
String city = "城市名";
String key = "你的请求Key";
void setup() {
Serial.begin(9600);
// 连接 WiFi
WiFi.begin(ssid, password);
Serial.print("正在连接 Wi-Fi");
// 检测是否连接成功
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("连接成功");
Serial.print("IP 地址:");
Serial.println(WiFi.localIP());
// 创建 HTTPClient 对象
HTTPClient http;
// 发送GET请求
http.begin(url+"?city="+city+"&key="+key);
int httpCode = http.GET();
// 获取响应状态码
Serial.printf("HTTP 状态码: %d", httpCode);
// 获取响应正文
String response = http.getString();
Serial.println("响应数据");
Serial.println(response);
http.end();
// 创建 DynamicJsonDocument 对象
DynamicJsonDocument doc(1024);
// 解析 JSON 数据
deserializeJson(doc, response);
// 从解析后的 JSON 文档中获取值
unsigned int temp = doc["result"]["realtime"]["temperature"].as<unsigned int>();
String info = doc["result"]["realtime"]["info"].as<String>();
int aqi = doc["result"]["realtime"]["aqi"].as<int>();
Serial.printf("温度: %d\n", temp);
Serial.printf("天气: %s\n", info);
Serial.printf("空气指数: %d\n", aqi);
}
void loop() {
}
如何制作自己的 API 后台
我们目前使用的这些 API 接口,都是其他公司提供的服务,而如果我们想要搭建自己的 API 接口,该怎么办呢?
首先,你需要明白,想要搭建自己的 API 接口,其实就是搭建你自己的网站,而搭建网站分为前端和后端,前端和后端是指构成Web应用程序的两个主要部分:
- 前端通常指 Web 应用程序中用户可以看到和与之交互的部分,包括网页布局、样式、交互和功能;
- 后端则是指 Web 应用程序的后台,用于处理服务器端的逻辑和数据存储,例如数据库和 API。
后端的功能往往是由编程语言(如 Python、Java 等)和相关框架实现的,而前端通常由 HTML、CSS 和 JavaScript 等技术实现。前端和后端需要通过网络协议(如 HTTP)进行通信,将前端用户输入的数据传递到后端进行处理,再将处理结果返回到前端。
前端和后端的交互使得 Web 应用程序具有强大的功能和可扩展性。
如果你是非科班出身,想要转行做程序员,前端是你最好的选择,WEB 前端是最容易入门的编程岗位,初级前端技术很容易掌握,高级前端需要一步步学习和工作经验的积累。
后端的分类就很多了,国内主流的后端开发使用的是 Java、Go、Python、Node.js、C++、Rust、PHP 等。所以你该怎么选呢?以下仅代表我个人建议:
- 如果你明确目的,打算未来深耕于 Web 后端,你的第一选择必然是 Java,因为 Java 在国内 Web 开发的地位无法撼动;
- 如果你是前端程序员,打算转行后端,那就先从 Node.js 开始,简单的说 Node.js 就是运行在服务端的 JavaScript,而 JS 也是前端必学的三大基础内容;
- 如果你并非前两者,Python 就是你最好的选择,使用 Python 最大的优点就是简单、效率高,我们后面也会给大家带来 Python Web 开发的教程;
- 千万不要学 PHP,大家或许听过 “PHP 是最好的语言” 这个程序员常用梗,因为 PHP 上手比较简单,收获了很多开发者,又是早期互联网的重要服务端语言,所以在各个社交平台上时常能见到讨论,甚至吹捧 PHP 的文章。其中难免有些技术较差,或者不能接受新技术的上古遗老开发者,于是只能在网上做键盘侠,声称 PHP 是最好的语言。
步进电机
这一节我们学习如何使用我们的 ESP32 开发板来控制步进电机。
实验原理
步进电机是一种通过步进(即以固定的角度移动)方式使轴旋转的电机。其内部构造使它无需传感器,通过简单的步数计算即可获知轴的确切角位置。这种特性使它适用于多种应用。
步进电机广泛应用于各种需要控制位置和速度的场景,例如:
- CNC 机床:用于控制刀具的位置和速度。
- 3D 打印机:用于控制打印头的位置和速度。
- 自动化设备:用于控制各种运动部件的位置和速度。
- 机器人:用于控制机器人的运动。
与所有电机一样,步进电机也包括固定部分(定子)和活动部分(转子)。定子上有缠绕了线圈的齿轮状突起,而转子为永磁体或可变磁阻铁芯。稍后我们将更深入地介绍不同的转子结构。

步进电机的基本工作原理为:
- 给一个或多个定子相位通电,线圈中通过的电流会产生磁场,而转子会与该磁场对齐;
- 依次给不同的相位施加电压,转子将旋转特定的角度并最终到达需要的位置。
下图显示了其工作原理。首先,线圈 A 通电并产生磁场,转子与该磁场对齐;线圈 B 通电后,转子顺时针旋转 60° 以与新的磁场对齐;线圈 C 通电后也会出现同样的情况。下图中定子小齿的颜色指示出定子绕组产生的磁场方向。

我们今天实验用到的是 28BYJ-48 步进电机,28BYJ-48 含义:28 指的是电机最大外径,B 指的是步进式电机,Y 指的是永磁式电机,J 指的是减速型电机,48 表示可以四相八拍。换句话说,28BYJ-48 的含义为外径 28 毫米四相八拍式永磁减速型步进电机。

首先我们需要了解什么是 4相永磁式,28BYJ-48 的内部结构示意图如下所示。先看里圈,它上面有 6 个齿,分别标注为 0~5,这个叫做转子,顾名思义,它是要转动的,转子的每个齿上都带有永久的磁性,是一块永磁体,这就是 永磁式 的概念。再看外圈,这个就是定子,它是保持不动的,实际上它是跟电机的外壳固定在一起的,它上面有 8 个齿,而每个齿上都缠上了一个线圈绕组,正对着的 2 个齿上的绕组又是串联在一起的,也就是说正对着的 2 个绕组总是会同时导通或关断的,如此就形成了 4 相,在图中分别标注为 A-B-C-D,这就是 4相 的概念。

步进电机驱动方式三种工作模式:
单四拍:这是最简单的步进电机驱动方式。这种方式,电机在每个瞬间只有一个线圈导通,消耗电力小。但在切换瞬间时没有任何的电磁作用在转子上,容易造成振动,也容易因为惯性而失步;双四拍:这种方式输出的转矩较大且振动较少,切换过程中至少有一个线圈通电作用于转子,使得输出的转矩较大,振动较小,也比单四拍平稳,不易失步;八拍:综合上述两种驱动信号,使用单四拍和双四拍交替进行的方式,每传送一个励磁信号,步进电机前进半个步距角。其特点是分辨率高,运转更加平滑,也是最常用的一种方式;
下面是这三种驱动方式的时序波形图:

接着,我们需要了解一下他的工作原理,假定电机的起始状态就如上图所示,逆时针方向转动,起始时是 B 相绕组的开关闭合,B 相绕组导通,那么导通电流就会在正上和正下两个定子齿上产生磁性,这两个定子齿上的磁性就会对转子上的 0 和 3 号齿产生最强的吸引力,就会如图所示的那样,转子的 0 号齿在正上、3 号齿在正下而处于平衡状态;此时我们会发现,转子的 1 号齿与右上的定子齿也就是 C 相的一个绕组呈现一个很小的夹角,2 号齿与右边的定子齿也就是 D 相绕组呈现一个稍微大一点的夹角。
接下来,我们把 B 相绕组断开,而使 C 相绕组导通,右上的定子齿将对转子 1 号齿产生最大的吸引力,而左下的定子齿将对转子 4 号齿,产生最大的吸引力,在这个吸引力的作用下,转子 1、4 号齿将对齐到右上和左下的定子齿上而保持平衡。
再接下来,断开 C 相绕组,导通 D 相绕组,过程与上述的情况完全相同,最终将使转子 2、5 号齿与定子 D 相绕组对齐,转子又转过了上述同样的角度。
当 A 相绕组再次导通,即完成一个 B-C-D-A 的四节拍操作后,转子的 0、3 号齿将由原来的对齐到上下 2 个定子齿,而变为了对齐到左上和右下的两个定子齿上,即转子转过了一个定子齿的角度。依此类推,再来一个四节拍,转子就将再转过一个齿的角度,8个四节拍以后转子将转过完整的一圈,而其中单个节拍使转子转过的角度就很容易计算出来了,即 360°/(8 * 4) = 11.25°,这个值就叫做 步进角度。而上述这种工作模式就是步进电机的 单四拍模式。
八拍 就是在单四拍的每两个节拍之间再插入一个双绕组导通的中间节拍,组成八拍模式。比如,在从 B 相导通到 C 项导通的过程中,假如一个 B 相和 C 相同时导通的节拍,这个时候,由于 B、C 两个绕组的定子齿对它们附近的转子齿同时产生相同的吸引力,这将导致这两个转子齿的中心线对比到 B、C 两个绕组的中心线上。这样一来,就使转动精度增加了一倍,而转子转动一圈则需要 8*8=64 拍。
双四拍 的工作模式其实就是把八拍模式中的两个绕组同时通电的那四拍单独拿出来,而舍弃掉单绕组通电的那四拍而已。其步进角度同单四拍是一样的,但由于它是两个绕组同时导通,所以扭矩会比单四拍模式大。
八拍模式 是这类4相步进电机的最佳工作模式,能最大限度的发挥电机的各项性能,也是绝大多数实际工程中所选择的模式。

转子转 64 圈,最终输出轴才会转一圈,也就是需要 64×64=4096 个节拍输出轴才转过一圈。4096 个节拍转动一圈,那么一个节拍转动的角度(步进角度)就是 360/4096 度。
单片机的管脚输出电流较小,只有零点几个毫安,吸纳电没也只有十几个毫安(大多数单片机只有几个毫安),输出最高电压也不会越过 5V,由于这个原因很少用单片机直接驱动外设。ULN2003 的作用就是把单片机的信号进行放大,吸纳电流可以达到 500mA,耐压也提高很多,基本能满足微型步进电机的驱动电流和电压。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 28BYJ-48 步进电机 | 1 |
| ULN2003 驱动板 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
步进电机有防呆插头,直接插在电路板上即可。

软件程序设计
#define IN_1 13
#define IN_2 12
#define IN_3 14
#define IN_4 27
// 设置延时,这个时间不能设置太小,否则电机来不及响应
int delay_time = 5;
void setup() {
// 设置输出模式
pinMode(IN_1, OUTPUT);
pinMode(IN_2, OUTPUT);
pinMode(IN_3, OUTPUT);
pinMode(IN_4, OUTPUT);
// 单四拍模式
for (int i=0;i<256;i++) {
digitalWrite(IN_1, 1);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 1);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 1);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 1);
delay(delay_time);
}
delay(1000);
// 双四拍模式
for (int i=0;i<256;i++) {
digitalWrite(IN_1, 1);
digitalWrite(IN_2, 1);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 1);
digitalWrite(IN_3, 1);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 1);
digitalWrite(IN_4, 1);
delay(delay_time);
digitalWrite(IN_1, 1);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 1);
delay(delay_time);
}
delay(1000);
// 双四拍模式,顺时针转 180°
for (int i=0;i<256;i++) {
digitalWrite(IN_1, 1);
digitalWrite(IN_2, 1);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 1);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 1);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 1);
digitalWrite(IN_4, 1);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 1);
digitalWrite(IN_3, 1);
digitalWrite(IN_4, 0);
delay(delay_time);
}
delay(1000);
// 八拍模式
for (int i=0;i<256;i++) {
digitalWrite(IN_1, 1);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 1);
digitalWrite(IN_2, 1);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 1);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 1);
digitalWrite(IN_3, 1);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 1);
digitalWrite(IN_4, 0);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 1);
digitalWrite(IN_4, 1);
delay(delay_time);
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 1);
delay(delay_time);
digitalWrite(IN_1, 1);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 1);
delay(delay_time);
}
// 步进电机运行完后断点,不然会发热
digitalWrite(IN_1, 0);
digitalWrite(IN_2, 0);
digitalWrite(IN_3, 0);
digitalWrite(IN_4, 0);
}
void loop() {
}
或者我们也可以使用第三方库 CheapStepper,代码如下:
#include <CheapStepper.h>
#define IN_1 13
#define IN_2 12
#define IN_3 14
#define IN_4 27
// 定义对象
CheapStepper stepper (IN_1, IN_2, IN_3, IN_4);
bool clockwise = true;
void setup() {
// 设置转速
stepper.setRpm(10);
// 通过步数旋转
stepper.moveTo(clockwise, 2048);
delay(1000);
// 通过角度旋转
stepper.moveDegrees(clockwise, 90);
}
void loop() {
// put your main code here, to run repeatedly:
}
PS2 双轴按键摇杆实验
之前我们已经学习了如何使用 PWM 和 ADC,这节课我们来学习如何使用 PS2 双轴摇杆来控制舵机。
实验原理
摇杆一般在航模、电玩、遥控车、云台等设备上应用广泛,很多带有屏幕的设备也经常使用摇杆作为菜单选择的输入控制。
双轴按键摇杆主要由两个电位器和一个按键开关组成,两个电位器随着摇杆扭转角度分别输出 X、Y 轴上对应的电压值,在 Z 轴方向上按下摇杆可触发轻触按键。在配套机械结构的作用下,无外力扭动的摇杆初始状态下,两个电位器都处在量程的中间位置。它就是两个电位器和按键的组合体。电位器是可变电阻器,与中学时学的滑动变阻器类似。

PS2 摇杆的五个端口分别为 VCC,X,Button,Y,GND。
摆动 PS2 游戏摇杆时,随着接触刷改变接触位置,可变电阻器(电位器)的引脚处的输出电压即发生变化。X,Y 轴为模拟输入信号而 Z 轴是数字输入信号,因此,X 和 Y 端口连接到 ADC 引脚,而 Z 端口连接到数字端口。所以我们一共需要使用 ESP32 的三个 GPIO 引脚,其中两个模拟信号输入引脚和一个数字信号输入引脚。
PS2 游戏摇杆正常状态(不受力状态)检测电压常态时为 1.65V 附近,最大值 3.3V,最小值 0V,用 ESP32 自带 ADC 模数转换模块的两个通道分别检测电压值的变化就可以知道摇杆指向的位置了。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| PS2 摇杆模块 | 1 |
| 舵机 | 1 |
| 杜邦线(跳线) | 若干 |
PS2 模块的 +5V 引脚接 ESP32 的 3V3 引脚,一般情况下 ESP32 的 ADC 电压输入范围为 0-3.3V,高于 3.3V 可能会烧坏 ADC。摇杆模块的 GND 接 GND;模块的 SW 接 D4,VRX 接 D15,VRY 接 D2。
舵机接开发板另一侧的 5V 引脚、GND 和 D13。

软件程序设计
我们可以先在串口监视器中打印读取到的数值,代码如下:
#define PS2_X 15
#define PS2_Y 2
#define SW 4
void setup() {
// 配置衰减器
// analogSetAttenuation(ADC_11db);
// 配置输入模式
pinMode(PS2_X, INPUT);
pinMode(PS2_Y, INPUT);
pinMode(SW, INPUT_PULLUP);
// 配置串口通信波特率
Serial.begin(9600);
}
void loop() {
// 读取数值
Serial.printf("x: %d, y: %d, z: %d\n", analogRead(PS2_X), analogRead(PS2_Y), digitalRead(SW));
delay(100);
}
了解完如何在 Arduino 中控制 PS2 摇杆模块后,我们就可以使用 PS2 摇杆模块控制舵机了,这里我们还需要用到 Arduino 中的一个新的函数 map。
map 函数主要功能为将范围为 A 的变量等比例转化至 B 中,在 Arduino 编程中有广泛应用,例如将 10 位模拟输入结果转化至 8 位模拟输出、利用模拟输入值控制舵机角度等。
x = map(value, fromLow, fromHigh, toLow, toHigh);
x,value 为同类型变量,fromHigh 与 fromLow 为 t 变量本身的上下界,toHigh 与 toLow 为 x 变量的上下界。该函数将t变量值根据范围比例变换后将结果存入 x 变量。
代码如下:
#define PS2_X 15
#define PS2_Y 2
#define SW 4
#define RESOLUTION 12
#define SERVO 13
#define CHANNEL 0
#define FREQ 50
int value;
//20ms 周期内,高电平持续时长 0.5-2.5 ms,对应 0-180 度舵机角度。
//对应 0.5ms(0.5ms/(20ms/256))
int min_width = 0.6 / 20 * pow(2, RESOLUTION);
//对应 2.5ms(2.5ms/(20ms/256))
int max_width = 2.5 / 20 * pow(2, RESOLUTION);
void setup() {
// 配置衰减器
analogSetAttenuation(ADC_11db);
// 设置 ADC 分辨率
analogReadResolution(RESOLUTION);
// 配置输入模式
pinMode(PS2_X, INPUT);
pinMode(PS2_Y, INPUT);
pinMode(SW, INPUT_PULLUP);
// 配置串口通信波特率
Serial.begin(9600);
// 用于设置 LEDC 通道的频率和分辨率
ledcSetup(CHANNEL, FREQ, RESOLUTION);
// 将通道与对应的引脚连接
ledcAttachPin(SERVO, CHANNEL);
}
void loop() {
// 将 ADC 的值映射到舵机的转动范围
value = map(analogRead(PS2_Y), 0, pow(2, RESOLUTION), min_width, max_width);
// 读取数值
Serial.printf("x: %d, y: %d, z: %d, 映射后的 y: %d\n", analogRead(PS2_X), analogRead(PS2_Y), digitalRead(SW), value);
ledcWrite(CHANNEL, value);
delay(100);
}
最后,我们通过第三方库 ESP32Servo.h,让 PS2 摇杆控制舵机转动,代码如下:
#include <ESP32Servo.h>
#define PS2_X 15
#define PS2_Y 2
#define SW 4
#define SERVO 13
#define RESOLUTION 12
#define FREQ 50
// 定义 Servo 对象
Servo my_servo;
int value;
void setup() {
// 配置输入模式
pinMode(PS2_X, INPUT);
pinMode(PS2_Y, INPUT);
pinMode(SW, INPUT_PULLUP);
// 配置串口通信波特率
Serial.begin(9600);
// 分配硬件定时器
ESP32PWM::allocateTimer(0);
// 设置频率
my_servo.setPeriodHertz(FREQ);
// 关联 servo 对象与 GPIO 引脚,设置脉宽范围
my_servo.attach(SERVO, 500, 2500);
}
void loop() {
value = map(analogRead(PS2_Y), 0, pow(2, RESOLUTION), 0, 180);
// 读取数值
Serial.printf("x: %d, y: %d, z: %d, 映射后的 y: %d\n", analogRead(PS2_X),
analogRead(PS2_Y), digitalRead(SW), value);
// 输出PWM
my_servo.write(value);
delay(100);
}
8x8 LED 点阵模块
之前我们已经点亮过数码管了,今天我们来学习 8x8 点阵 LED 屏,先通过点阵屏显示一个图案,最终实现使用 PS2 摇杆控制点阵屏制作一个 LED 移动的小游戏。

实验原理
LED 8*8 点阵屏模块是一种常见的 LED 屏幕模块,它由 8 行 8 列的 LED 点阵组成。每个 LED 点可以控制亮灭,通过对每个点的亮灭状态的控制,可以在屏幕上显示出各种图案和文字等信息。
这个 8x8 点阵的原理,其实与数码管是一样的。看看下面的原理图就知道了。

从图中可以看出,LED 点阵屏由 64 个发光二极管组成,且每个发光二极管是放置在行线和列线的交叉点上,当对应的某一列置 1 电平,某一行置 0 电平,则相应的二极管就亮,我们这款点阵屏是共阴型的,共阳型则相反,给列置 0 电平,给行置 1 电平。

直接使用 I/O 口驱动,占用较多的 I/O 口资源,特别是随着点阵屏的数量增加,所以一般的应用,会选择专用的驱动芯片,例如 74HC595,MAX7219 等等。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| PS2 摇杆模块 | 1 |
| 8*8 LED 点阵屏 | 1 |
| 杜邦线(跳线) | 若干 |
PS2 模块的 +5V 引脚接 ESP32 的 3V3 引脚,GND 接 GND,SW 接 D34,X 接 D15,Y 接 D35。
LED 点阵屏的 1-8 分别接 D23、D22、D21、D19、D18、D5、D4、D2;9 - 16 分别接 D13、D12、D14、D27、D26、D25、D33、D32。

软件程序设计
1. 循环遍历所有 LED
我们第一个程序就先写检测一下是否所有的 LED 都可以正常工作,代码如下:
// 定义行引脚数组
int row_array[8] = {13, 25, 2, 27, 23, 4, 22, 18};
// 定义列引脚数组
int col_array[8] = {26, 21, 19, 12, 5, 14, 33, 32};
void setup() {
// 配置所有行引脚为输出模式,初始化为高电平
for (int i=0;i<8;i++) {
pinMode(row_array[i], OUTPUT);
digitalWrite(row_array[i], HIGH);
}
// 配置所有列引脚为输出模式,初始化为低电平
for (int i=0;i<8;i++) {
pinMode(col_array[i], OUTPUT);
digitalWrite(col_array[i], LOW);
}
}
void loop() {
// 遍历所有的 LED
for (int i=0;i<8;i++) {
digitalWrite(row_array[i], LOW);
for (int j=0;j<8;j++) {
digitalWrite(col_array[j], HIGH);
delay(100);
digitalWrite(col_array[j], LOW);
}
digitalWrite(row_array[i], HIGH);
}
}
2. 在点阵屏上显示图案
这里我们需要再次用到取模软件,与之前不同的是,这里我们需要新建图像并且,设置图像的宽和高为 8,这样就能保证与我们的点阵屏对应。

字模设置也需要改一下,

接着在屏幕上画出我们想要显示的图案,比如一个爱心,然后生成字模。

这里,我们获取了一个 8 个 16 进制数的列表,每个 16 进制数转换成二进制就是 LED 点阵屏每行所有 LED 的逻辑,
{0x00,0x66,0xFF,0xFF,0xFF,0x7E,0x3C,0x18}
在 Arduino 中,想要把 16 进制数转二进制时,可以使用 bitRead() 函数,该函数在单片机中使用时比较频繁的,尤其对于数码管以及与数码管类似的存在未操作的器件中使用较多。这里我们介绍一下它的使用方法。
bitRead(x, n)
参数说明:
x: 被读取位的数值;n: 被读取的位置(右起第一位为 0 位,第二位为 1,以此类推。)- 返回值:1 或 0。
因此,我们的代码可以这么写:
// 定义行引脚数组
int row_array[8] = {13, 25, 2, 27, 23, 4, 22, 18};
// 定义列引脚数组
int col_array[8] = {26, 21, 19, 12, 5, 14, 33, 32};
// 定义图案逻辑数组
int hex_array[8] = {0x00,0x66,0xFF,0xFF,0xFF,0x7E,0x3C,0x18};
void setup() {
// 设置串口波特率
Serial.begin(9600);
// 配置所有行引脚为输出模式,初始化为高电平
for (int i=0;i<8;i++) {
pinMode(row_array[i], OUTPUT);
digitalWrite(row_array[i], HIGH);
}
// 配置所有列引脚为输出模式,初始化为低电平
for (int i=0;i<8;i++) {
pinMode(col_array[i], OUTPUT);
digitalWrite(col_array[i], LOW);
}
}
void loop() {
for (int i=0;i<8;i++) {
for (int j=0;j<8;j++) {
digitalWrite(col_array[j], bitRead(hex_array[i], j));
}
digitalWrite(row_array[i], LOW);
delay(1);
digitalWrite(row_array[i], HIGH);
}
}
3. 使用 PS2 摇杆控制 LED 移动
最后,我们就可以写摇杆控制 LED移动的代码了:
#define PS2_X 15
#define PS2_Y 35
// 定义行引脚数组
int row_array[8] = {13, 25, 2, 27, 23, 4, 22, 18};
// 定义列引脚数组
int col_array[8] = {26, 21, 19, 12, 5, 14, 33, 32};
// 初始化 LED 位置
int led_pos[2] = {1, 1};
// 初始化摇杆信号变量
int x_value;
int y_value;
void setup() {
// 配置 PS2 摇杆引脚
pinMode(PS2_X, INPUT);
pinMode(PS2_Y, INPUT);
// 配置所有行引脚为输出模式,初始化为高电平
for (int i=0;i<8;i++) {
pinMode(row_array[i], OUTPUT);
digitalWrite(row_array[i], HIGH);
}
// 配置所有列引脚为输出模式,初始化为低电平
for (int i=0;i<8;i++) {
pinMode(col_array[i], OUTPUT);
digitalWrite(col_array[i], LOW);
}
}
void loop() {
// 读取摇杆信号
x_value = analogRead(PS2_X);
y_value = analogRead(PS2_Y);
// 清除 LED 之前的状态
digitalWrite(row_array[led_pos[0]], HIGH);
digitalWrite(col_array[led_pos[1]], LOW);
// 检测 x 轴是否移动
if (x_value > 4095 / 2 + 300 && led_pos[0] < 7) {
led_pos[0] += 1;
}else if (x_value < 4095 / 2 - 300 && led_pos[0] > 0) {
led_pos[0] -= 1;
}
// 检测 y 轴是否移动
if (y_value > 4095 / 2 + 300 && led_pos[1] > 0) {
led_pos[1] -= 1;
}else if (y_value < 4095 / 2 -300 && led_pos[1] < 7) {
led_pos[1] += 1;
}
// 显示新位置的 LED
digitalWrite(row_array[led_pos[0]], LOW);
digitalWrite(col_array[led_pos[1]], HIGH);
delay(50);
}
继电器模块
这节课我们来学习继电器模块。
实验原理

为什么我们要使用继电器呢?继电器可以将小电流转化为大电流,从而控制大功率电器的开关。例如,当我们需要控制家里的电灯或电器时,由于电灯或电器的负载电流较大,直接用微控制器或其他低功率电子元件控制开关是不现实的,这时就需要用继电器来控制开关。另外,继电器还可以实现电路的隔离,从而保护低功率电子元件,使其不会受到高电压或大电流的影响。因此,在很多电子控制系统中,继电器都是不可或缺的重要元件。
继电器是一种电气控制设备,用于在低电压电路中控制高电压电路的开关。它是由一个线圈、一组可触点和一个机械部件组成。当线圈通电时,机械部件会移动,使可触点连接或断开高电压电路。

继电器的工作原理是利用电磁作用原理,将电路切换从一个电路转变为另一个电路。继电器的电路中有一个线圈,当电流通过线圈时,产生磁场使线圈中的铁芯吸引可移动触点,使它与固定触点相连或断开。固定触点是电路的一部分,而可移动触点是独立的。

继电器的使用可以实现多个电路之间的隔离,也可以使开关电路控制更大功率的设备。例如,可以使用低电平的开关电路控制高电平的电机,从而实现电机的控制。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 2 |
| 1kΩ 电阻 | 1 |
| 继电器模块 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
把继电器模块上电,输入引脚接开发板的 D15 引脚。
两个 LED 的阳极分别接继电器模块的的 常开 和 常闭 引脚,并串联一个电阻,阴极接 GND,如下图:

软件程序设计
当使用 Arduino 控制继电器时,通常需要使用 GPIO 引脚。继电器本质上是电磁开关,通过电磁线圈控制机械开关的闭合和断开,因此需要一个能够输出高电平和低电平的 IO 口来控制继电器的开关。
以下是一个 Arduino 控制继电器的简单示例:
#define RELAY_PIN 15
// 初始化硬件定时器对象
hw_timer_t *timer = NULL;
void timer_interrupt() {
digitalWrite(RELAY_PIN, !digitalRead(RELAY_PIN));
}
void setup() {
// 配置 D15 为 GPIO 输出
pinMode(RELAY_PIN, OUTPUT);
// 初始化定时器
timer = timerBegin(0, 80, true);
// 配置定时器
timerAttachInterrupt(timer, timer_interrupt, true);
// 设置定时器模式
timerAlarmWrite(timer, 500000, true);
// 启动定时器
timerAlarmEnable(timer);
}
void loop() {
}
蜂鸣器实验
这节课我们学习蜂鸣器,并用蜂鸣器制作电子琴。
实验原理
当涉及到蜂鸣器时,我们通常会遇到两种类型:无源蜂鸣器和有源蜂鸣器。它们在工作原理和使用方式上有所不同。
有源蜂鸣器(Active Buzzer)
有源蜂鸣器是一种集成了驱动电路的蜂鸣器,它可以直接通过电流激励产生声音,不需要外部设备。有源蜂鸣器内部集成了振片、驱动电路和共振腔。当给有源蜂鸣器提供电流时,它会根据电流的变化产生声音。有源蜂鸣器通常具有更好的声音质量和音量控制能力。在使用有源蜂鸣器时,我们可以通过控制电流的大小和频率来控制蜂鸣器的声音。一般来说,我们可以通过改变输入电流的大小来调整音量,通过改变输入电流的频率来调整音调和音乐效果。

注意
这里的 源 不是指电源,而是指震荡源。
无源蜂鸣器(Passive Buzzer)
无源蜂鸣器是一种简单的声音发生器,它通常由振片和共振腔组成。无源蜂鸣器不具备驱动电路,因此需要外部的电子设备来产生声音。当给无源蜂鸣器施加交变电压时,振片会振动并产生声音。无源蜂鸣器的工作频率由施加的电压频率决定。在使用无源蜂鸣器时,我们需要通过控制电压的频率和占空比来控制蜂鸣器的声音。通过改变交变电压的频率和占空比,我们可以产生不同的音调和音乐效果。

需要注意的是,无论是无源蜂鸣器还是有源蜂鸣器,其工作电压和电流都需要在规定范围内,以免损坏蜂鸣器或引起其他问题。
总结起来,无源蜂鸣器和有源蜂鸣器是两种常见的蜂鸣器类型。无源蜂鸣器需要外部设备来产生声音,而有源蜂鸣器内部集成了驱动电路,可以直接产生声音。在使用时,我们通过控制电压的频率、占空比或电流的大小和频率来控制蜂鸣器的声音。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 按键开关 | 6 |
| 无源蜂鸣器 | 1 |
| 有源蜂鸣器 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
有源蜂鸣器正极接 D22,无源蜂鸣器正极接 D23;6 个按键依次接 D25,D26,D27,D14,D12,D13。

软件程序设计
1. 有源蜂鸣器定时器闹钟
我们可以通过外部中断和定时器中断来制作一个闹钟,按下按键时中断闹钟
#define BUTTON 13
#define BUZZER 22
hw_timer_t *timer = NULL;
// 初始化闹钟触发时间
int second = 5;
// 定时器中断处理函数
void timer_interrupt(){
digitalWrite(BUZZER, HIGH);
}
// 外部中断处理函数
void handle_interrupt(){
digitalWrite(BUZZER, LOW);
}
void setup() {
// 配置引脚模式
pinMode(BUTTON, INPUT_PULLUP);
pinMode(BUZZER, OUTPUT);
// 初始化定时器
timer = timerBegin(0,80,true);
// 配置定时器
timerAttachInterrupt(timer,timer_interrupt,true);
// 定时模式
timerAlarmWrite(timer,second*1000*1000,false);
// 启动定时器
timerAlarmEnable(timer);
// 配置外部中断引脚
attachInterrupt(digitalPinToInterrupt(BUTTON), handle_interrupt, FALLING);
}
void loop() {
}
2. 6 音符电子琴
我们想要弹奏一首歌就需要他的乐谱,最简单的就是小星星,

蜂鸣器是通过不同的频率发出不同声调的,具体对应数值如下图:

我们可以按照上图乐谱,用 6 个按键分别控制不同的音符,代码如下:
#define BUZZER 23
#define CHANNEL 0
#define RESOLUTION 8
#define FREQ 20000
// 定义按键数组
int button_array[6] = {25, 26, 27, 14, 12, 13};
// 定义音符频率数组
int tone_array[7] = {262, 294, 330, 350, 393, 441, 495};
// 初始化发声频率
int tone_value = 0;
void setup() {
// 配置引脚模式
pinMode(BUZZER, OUTPUT);
for (int i=0;i<6;i++) {
pinMode(button_array[i], INPUT_PULLUP);
}
// LEDC 外设
ledcSetup(CHANNEL, FREQ, RESOLUTION);
ledcAttachPin(BUZZER, CHANNEL);
}
void loop() {
// 检测按键是否按下
for (int i=0;i<6;i++) {
if (!digitalRead(button_array[i])) {
tone_value = tone_array[i];
}
}
// 蜂鸣器发声
ledcWriteTone(CHANNEL, tone_value);
tone_value = 0;
delay(10);
}
}
3. 播放音乐
我们也可以自动播放这首歌,代码如下:
#define BUZZER 23
#define CHANNEL 0
#define RESOLUTION 8
#define FREQ 20000
// 定义音符频率数组
int tone_array[8] = {0, 262, 294, 330, 350, 393, 441, 495};
// 乐谱
int music[] = {1, 1, 5, 5, 6, 6, 5, 0,
4, 4, 3, 3, 2, 2, 1, 0,
5, 5, 4, 4, 3, 3, 2, 0,
5, 5, 4, 4, 3, 3, 2, 0,
1, 1, 5, 5, 6, 6, 5, 0,
4, 4, 3, 3, 2, 2, 1, 0};
// 初始化发声频率
int tone_value = 0;
void setup() {
// 配置引脚模式
pinMode(BUZZER, OUTPUT);
// LEDC 外设
ledcSetup(CHANNEL, FREQ, RESOLUTION);
ledcAttachPin(BUZZER, CHANNEL);
}
void loop() {
for (int i=0; i<sizeof(music)/sizeof(music[0]);i++) {
ledcWriteTone(CHANNEL, tone_array[music[i]]);
delay(500);
}
delay(2000);
}
4x4 矩阵键盘
今天我们来学习 4x4 矩阵键盘,并最终实现一个密码开锁的功能。
实验原理
薄膜按键,是一块带触点的 PET 薄片,用在 PCB、FPC 等线路上作为开关使用,在使用者与仪器之间起到一个重要的触感型开关的作用。与传统的硅胶按键相比,薄膜按键具有更好的手感、更长的寿命,可以间接的提高使用导电膜的各类型开关的生产效率。

薄膜按键的工作原理很好理解,薄膜上的触点位于 PCB 板上的导电部位,当按键受到外力按压时,触点的中心点下凹,接触到 PCB 上的线路,从而形成回路,电流通过,整个产品就得正常工作。

这个键盘有 16 个按键,如果 16 个按键均为独立按键的话,需要占用 16 个 IO 口,对于我们的开发板来说还是可以接受的,但是如果有 64 个按键,那单片机的 IO 口就完全不能能满足我们的需求,因此,就出现了矩阵键盘将这 8 根线连接到单片机的 8 个 IO 口上,通过程序扫描键盘就可检测 16 个键,如果我们想实现 64 个按键的话就只需要用到 16 个 IO口,可以参考 LED 点阵屏。

无论是独立键盘还是矩阵键盘,单片机检测其是否被按下的依据都一样,即检测与该键对应的 IO 口是否为低电平,独立键盘有一端固定为低电平,此种方式编程比较简单。而矩阵键盘两端都与单片机 IO 口相连,因此在检测时需编程通过单片机1/0口送出低电平,检测方法有多种,最常用的是 行列扫描 和 线翻转法。
行列扫描法:检测时,先送一列为低电平,其余几列全为高电平(确定列数),然后立即轮流检测一次各行是否有低电平,若检测到某一行为低电平(确定行数),则便可确认当前被按下的键是哪一行哪一列的,用同样方法轮流送各列一次低电平,再轮流检测一次各行是否变为低电平,这样即可检测完所有的按键,当有键被按下时便可判断出按下的键是哪一个键。当然,也可以将行线置低电平,扫描列是否有低电平,从而达到整个键盘的检测;线翻转法:使所有行线为低电平时,检测所有列线是否有低电平,如果有,就记录列线值:然后再翻转,使所有列线都为低电平,检测所有行线的值,由于有按键按下,行线的值也会有变化,记录行线的值。从而就可以检测到全部按键
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 4*4 矩阵键盘 | 1 |
| LCD1602 液晶屏 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
矩阵键盘从左到右依次接 D13、D12、D14、D27、D26、D25、D33、D32。
LCD 1602 接 5V 电源,SCL 接 D22,SDA 接 D21。

软件程序设计
1. 在串口监视器中显示触发的按键
我们可以先把按键值打印在串口监视器中,代码如下:
// 定义行引脚
int row_pins[4] = {13, 12, 14, 27};
// 定义列引脚
int col_pins[4] = {26, 25, 33, 32};
// 按下的按键
char key;
void setup() {
// 设置通信波特率
Serial.begin(9600);
// 行引脚设置为输入
for (int i=0;i<4;i++) {
pinMode(row_pins[i], INPUT_PULLUP);
}
// 列引脚设置为输出
for (int i=0;i<4;i++) {
pinMode(col_pins[i], OUTPUT);
// 初始化为高电平
digitalWrite(col_pins[i], HIGH);
}
}
char read_keypad() {
// 定义键盘按键布局
char keys[4][4] = {
{'1', '2', '3', 'A'},
{'4', '5', '6', 'B'},
{'7', '8', '9', 'C'},
{'*', '0', '#', 'D'}
};
// 行列扫描法
for (int j=0;j<4;j++) {
// 将当前列设置为低电平
digitalWrite(col_pins[j], LOW);
for (int i=0;i<4;i++) {
// 检测行输入引脚状态, 检测到低电平,说明按键被按下,则返回该按键值
if (!digitalRead(row_pins[i])) {
// 将该行恢复为高电平
digitalWrite(col_pins[j], HIGH);
return keys[i][j];
}
}
// 将该行恢复为高电平
digitalWrite(col_pins[j], HIGH);
}
return NULL;
}
void loop() {
// 保存读取到的按键值
key = read_keypad();
if (key) {
Serial.printf("检测到按键按下: %c\n", key);
}
delay(200);
}
2. 与 LCD1602 实现密码锁功能
最后,我们就可以再使用 LCD1602 屏幕显示我们输入的内容,并且校验输入的密码是否正确,
在这里,我们会需要用到一些新的函数:
int strcmp(const char* str1, const char* str2):判断两个字符串的大小,相等则返回 0;void *memset(void *s, int v, size_t n):数组初始化函数,这里 s 可以是数组名,也可以是指向某一内在空间的指针;v 为要填充的值,一般使用 0 初始化内存单元;n 为数组的字节大小;memset 是对大的数组或结构体进行初始化操作的最快方法,因为他是直接对内存进行操作的。strlen (const char* str):计算字符串 str 的长度
代码如下:
#include "LiquidCrystal_I2C.h"
// 设置 LCD1602 的地址,列数,行数
LiquidCrystal_I2C lcd(0x27,16,2);
// 定义行引脚
int row_pins[4] = {13, 12, 14, 27};
// 定义列引脚
int col_pins[4] = {26, 25, 33, 32};
// 按下的按键
char key;
// 定义密码
const char password[] = "4567";
char enteredPassword[5] = ""; // 保存用户输入的密码
void setup() {
// 设置通信波特率
Serial.begin(9600);
// 行引脚设置为输入
for (int i=0;i<4;i++) {
pinMode(row_pins[i], INPUT_PULLUP);
}
// 列引脚设置为输出
for (int i=0;i<4;i++) {
pinMode(col_pins[i], OUTPUT);
// 初始化为高电平
digitalWrite(col_pins[i], LOW);
}
// 初始化 LCD 对象
lcd.init();
// 开启背光
lcd.backlight();
lcd.setCursor(0, 0);
lcd.print("Enter Password:");
}
char read_keypad() {
// 定义键盘按键布局
char keys[4][4] = {
{'1', '2', '3', 'A'},
{'4', '5', '6', 'B'},
{'7', '8', '9', 'C'},
{'*', '0', '#', 'D'}
};
// 行列扫描法
for (int j=0;j<4;j++) {
// 将当前列设置为低电平
digitalWrite(col_pins[j], LOW);
for (int i=0;i<4;i++) {
// 检测行输入引脚状态, 检测到低电平,说明按键被按下,则返回该按键值
if (!digitalRead(row_pins[i])) {
// 将该行恢复为高电平
digitalWrite(col_pins[j], HIGH);
return keys[i][j];
}
}
// 将该行恢复为高电平
digitalWrite(col_pins[j], HIGH);
}
return NULL;
}
// 检测密码
void check_password(char key) {
if (key) {
if (key == '#') {
// 输入完成,验证密码
if (strcmp(enteredPassword, password) == 0) {
lcd.clear();
lcd.print("Access granted!");
} else {
lcd.clear();
lcd.print("Access denied!");
}
delay(2000);
lcd.clear();
lcd.print("Enter Password:");
memset(enteredPassword, 0, sizeof(enteredPassword)); // 清空已输入的密码
} else {
// 添加输入的字符到密码字符串中
if (strlen(enteredPassword) < 4) {
lcd.setCursor(strlen(enteredPassword), 1);
lcd.print("*");
enteredPassword[strlen(enteredPassword)] = key;
}
}
}
delay(500);
}
void loop() {
// 获取按键信息
key = read_keypad();
// 显示并校验密码
check_password(key);
}
SD 卡实验
这节课我们学习如何使用 MicroPython 控制 SD 卡模块。
实验原理
SD卡(Secure Digital Card)是一种常见的可移动存储设备,用于存储和传输数据。它是一种闪存存储卡,具有较小的尺寸、高存储容量和可擦写的特性。

SD 卡具有以下主要特点:
尺寸小:SD 卡采用了较小的尺寸,便于携带和使用。标准尺寸的 SD 卡尺寸为 32mm × 24mm × 2.1mm,而微型 SD 卡和迷你 SD 卡则更小。高存储容量:SD 卡的存储容量可以从几百兆字节到数百千兆字节不等。现代的 SD 卡通常具有较大的存储容量,可以满足各种数据存储需求。可擦写性:SD 卡可以被多次擦写和重新写入,使其非常适合存储和传输数据。用户可以根据需要将数据写入 SD 卡,并随时进行修改或删除。高速传输:SD 卡支持高速数据传输,以满足对快速读写速度的需求。不同类型的 SD 卡可能具有不同的传输速度标准,例如 SDSC、SDHC 和 SDXC 等。兼容性:SD 卡具有广泛的兼容性,可以在许多设备上使用,例如数字相机、移动电话、音频播放器、电脑等。通过适配器,SD 卡还可以与其他类型的存储设备接口兼容。
SD 卡通常用于存储照片、音频、视频、文档和其他文件。它们广泛应用于数码相机、移动设备、嵌入式系统和各种消费电子产品中。在使用 SD 卡时,需要注意保护数据的安全性和完整性,避免数据丢失或损坏。
把 SD 卡通过读卡器连接到电脑,右击 SD 卡选择 格式化 选项,之后,我们可以看到 文件系统 选项,如下图

SD 卡使用的文件系统是指在 SD 卡上组织和管理文件和文件夹的方法。常见的 SD 卡文件系统有 FAT16、FAT32 和 exFAT 等。
FAT16(File Allocation Table 16):FAT16 是一种较早的文件系统,支持最大容量为 2GB 的存储设备。它使用 16 位的文件分配表来记录文件的存储位置和状态。FAT16 文件系统有一定的局限性,无法处理大容量存储设备和单个文件超过 2GB 的情况。FAT32(File Allocation Table 32):FAT32 是一种较为常见的文件系统,支持最大容量为 2TB 的存储设备。它采用 32 位的文件分配表,可以更有效地管理存储空间和文件索引。FAT32 文件系统被广泛用于移动存储设备、数码相机和其他消费电子设备。exFAT(Extended File Allocation Table):exFAT 是一种针对大容量存储设备设计的文件系统。它支持最大容量为 128PB 的存储设备和单个文件大小为 16EB 。exFAT 文件系统在支持大容量和大文件的同时,还具有较好的兼容性,可以在 Windows、Mac 和 Linux 等多个操作系统上使用。
SD 卡文件系统负责管理文件和目录的存储和访问。它使用文件分配表来记录文件的物理位置和状态,以及目录结构来组织文件和子目录。通过文件系统,用户可以方便地创建、读取、写入和删除文件,实现对存储设备中数据的管理和访问。
在使用 SD 卡时,需要选择适合的文件系统,根据存储设备的容量和应用需求进行设置。同时,还需要注意在使用过程中正确地操作文件系统,避免数据损坏和文件丢失的风险。
需要注意的是,在 SD 卡的文件系统选项中,存在 NTFS 选项,这个并不是 SD 卡的文件系统,如果你使用该选项格式化 SD 卡,会导致 SD 卡模块读取不到内容。
NTFS(New Technology File System) 是一种现代的文件系统,最早由微软引入并用于 Windows NT 操作系统及其后续版本。它具有许多先进的功能和优势,适用于处理大容量磁盘驱动器和大文件。
以下是 NTFS 的一些特点:
支持大容量存储:NTFS 支持非常大的磁盘容量,可以处理多 TB 级别的存储设备。高性能:NTFS 采用了先进的索引结构和数据组织方式,具有快速读取和写入文件的能力,可以提供较高的数据访问性能。安全性:NTFS 支持文件和文件夹级别的访问控制,可以设置权限和加密保护,保障数据的安全性。容错能力:NTFS 具有容错和恢复功能,可以自动修复文件系统错误和数据损坏,并提供一致性和完整性保护。支持大文件:NTFS 支持单个文件的最大大小为 16EB(1EB = 1024PB),可以处理非常大的文件。支持文件压缩和加密:NTFS 提供了文件压缩和加密的功能,可以节省存储空间并保护敏感数据的安全性。
NTFS 是在 Windows 操作系统中广泛使用的文件系统,适合用于处理大容量存储和大文件的场景。它在性能、安全性和功能方面都有一定的优势,可以满足现代计算机系统对文件系统的要求。
接着我们来了解一下 SD 卡模块,SD 卡模块通过标准的 SPI 协议与单片机进行连接,如果我们采用双线或者三线 SPI 协议就无法实现全双工的数据读写功能。唯一需要注意的是 SD 卡模块的 VCC 接 5V 电源引脚。

硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| SD 卡模块 | 1 |
| SD 卡 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |

软件程序设计
在 Arduino 中对 SD 卡进行操作,可以使用 ESP32 自带的 SD 库。SD 库提供了一系列函数用于在 SD 卡上进行文件操作。下面是 SD 库中的一些常用函数的详细说明:
SD.begin(uint8_t ssPin=SS, SPIClass &spi=SPI, uint32_t frequency=4000000, const char * mountpoint="/sd", uint8_t max_files=5, bool format_if_empty=false):初始化 SD 卡。此函数用于初始化 SD 卡并启动 SPI 通信。使用硬件 SPI,参数可以不填,使用默认参数即可。例如:SD.begin()。open():打开一个文件,并返回一个 File 对象,以便进行后续的文件操作。它接受两个参数,第一个是文件路径,第二个是文件打开模式。常见的打开模式包括FILE_READ(只读)、FILE_WRITE(只写)、FILE_APPEND(追加写入)等。available():检查文件是否可用(即是否已经打开),如果文件可用,则返回 true,否则返回 false。read():读取文件内容,从文件中读取一个字节的数据,并返回读取到的数据。write():写入文件内容。它接受一个参数,即要写入的数据。println():写入一行数据。此函数与write()类似,但它会在写入数据后自动换行。close():关闭文件,释放 SD 卡的资源,结束 SD 卡的使用。remove():删除指定的文件。它接受一个参数,即要删除的文件路径。exists():检查指定路径的文件是否存在。如果文件存在,则返回 true,否则返回 false。mkdir():创建指定路径的目录。openNextFile():打开 SD 卡中的下一个文件。返回一个 File 对象,表示打开的文件。如果没有更多文件可打开,则返回一个空的 File 对象。name():获取打开文件的文件名。返回一个 char 类型的指针,指向文件名的字符串。isDirectory():检查打开的文件是否是一个目录。如果文件是一个目录,则返回 true;否则返回 false。
这些函数可以帮助你在 SD 卡上进行文件操作。使用 openNextFile() 函数,你可以迭代打开 SD 卡中的每个文件。使用 name() 函数,你可以获取打开文件的文件名。使用 isDirectory() 函数,你可以判断文件是一个目录还是一个普通文件。这些函数的组合可以帮助你遍历 SD 卡上的文件,并执行相应的操作。
以上是 SD 库中的一些常用函数,你可以根据具体的应用需求选择适合的函数来进行 SD 卡上的文件操作。你可以参考 SD 库的官方文档和示例代码以了解更多函数和用法。
因此,我们对 SD 卡中的文件进行读写操作的代码可以这么写:
#include <SD.h>
// 初始化文件对象
File my_file;
void setup() {
Serial.begin(9600);
if (!SD.begin()) {
Serial.println("SD卡初始化失败");
return;
}
Serial.println("SD卡初始化成功");
// 读取文件列表
list_files("/");
// 创建一个新的文件并写入数据
my_file = SD.open("/test.txt", FILE_WRITE);
if (my_file) {
my_file.println("你好,SD 卡"); // 写入数据
my_file.close(); // 关闭文件
Serial.println("数据写入完成");
} else {
Serial.println("无法打开文件");
}
delay(1000);
// 读取文件内容
my_file = SD.open("/test.txt");
while (my_file.available()) {
Serial.write(my_file.read());
}
my_file.close();
Serial.println("\n文件读取完成");
delay(1000);
// 修改文件内容
my_file = SD.open("/test.txt", FILE_APPEND);
my_file.println("修改文件");
my_file.close();
Serial.println("文件修改成功");
delay(1000);
// 读取文件内容
Serial.println("读取文件内容");
my_file = SD.open("/test.txt");
while (my_file.available()) {
Serial.write(my_file.read());
}
my_file.close();
Serial.println("\n文件读取完成");
delay(1000);
// 删除文件
if (SD.remove("/test.txt")) {
Serial.println("文件删除成功");
} else {
Serial.println("文件删除失败");
}
// 读取文件列表
list_files("/");
}
void loop() {
// 在这里进行其他操作
}
// 读取文件列表
void list_files(const char* path) {
Serial.println("文件列表:");
File root = SD.open(path);
File file = root.openNextFile();
while (file) {
if (file.isDirectory()) {
Serial.print("目录:");
} else {
Serial.print("文件:");
}
Serial.println(file.name());
file = root.openNextFile();
}
root.close();
}
为什么文件列表中存在 System Volume Information?
System Volume Information是 Windows 操作系统在存储设备上创建的隐藏文件夹,用于存储系统恢复信息和其他系统相关数据。当你在 Windows 中使用 SD 卡时,它会在 SD 卡的根目录下创建这个文件夹。
需要注意的是,使用 ESP32 自带的 SD 库时,在我们读取文件列表时,无法读取到中文名,因此,如果你想要读取到中文名的话,可以下载第三方库 SDFat。

串口通信
单片机中最常用的通讯协议有 UART、I2C、SPI。我们已经学习了 I2C 和 SPI。这节课,我们来学习 UART,也就是串口通讯。
串口基本上是所有单片机中都具备的资源外设,使用它可实现程序下载,串口通信等。由于串口通信的简单方便,现如今越来越多的设备和模块支持串口通信功能,让开发工作变得越来越简单且高效。这节课我们来学习如何使用 MicroPython 控制 ESP32 的串口实现数据收发。
实验原理
要了解串口通信就要先了解串行通信和并行通信:
并行通信就是说我们的数据字节用多条数据线同时开始发送,这种传输方式只适合短距离传输,这种传输方式使用较少,而且长距离传输成本高,所以只需要简单了解即可;串行通信是将数据字节一位一位的形式在一条传输线上逐个的传输,只需要一条数据线就可以了。发送时,要把并行数据变成串行数据发送到线路上,接收时,再把串行数据变为并行数据。
而关于串行数据传输也分为了两种方式,异步串行通信和同步串行通信,一般同步串行方式使用较少,一般不会使用,不了解也没关系,而一定要了解的是异步串行通信方式。
异步通信 是指通信的发送与接收设备使用各自的时钟控制数据的发送和接收过程,为使双方收发协调,要求发送和接收的设备的时钟尽可能一致。
异步通信是以字符(构成的帧)为单位进行传输,字符与字符之间的间隙(时间间隔)是任意的,当每个字符的各位是以固定的时间传送的,即字符之间不一定有 位间隔 的整数倍关系,但同一字符内的各位之间的距离均为 位间隔 的整数倍。异步通信的一帧字符信息由 4 部分组成,如下图所示:

起始位,数据位,校验位还有就是停止位,由上图所示,一般我们也不需要使用校验位。但是串行通信偶尔也会使用校验位,校验位由名字就可以知道,就是说看你这帧数据有没有错误,在我们的串行通信中一般使用奇偶校验,数据位尾随的 1 位为奇偶校验位。奇校验时,数据中 1 的个数与校验位的和是奇数就为奇校验,反之就是偶校验,接收字符时,我们通过对 1 的个数的校验,若发现 1 的个数不一致,那么就说明数据传输过程中出现了错误。
UART 全称为通用异步收发传输器(Universal Asynchronous Receiver/Transmitter),其工作原理是约定好通讯的波特率,然后将数据一位位地进行传输。

ESP32 有三个硬件 UART:UART0、UART1 和 UART2。它们每个都分配有默认的 GPIO,如下表:
| UART0 | UART1 | UART2 | |
|---|---|---|---|
| TX | 1 | 10 | 17 |
| RX | 3 | 9 | 16 |
UART0 用于下载和 REPL(交互式解释器) 调试,UART1 用于模块内部连接 FLASH,通常也不使用,因此可以使用 UART2 与外部串口设备进行通信。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 串口模块 | 1 |
| 杜邦线(跳线) | 若干 |
ESP32 的 RX2 引脚连串口模块的 TX,TX2 连 RX,让 ESP32 和 串口模块都连接电脑。
将材料按照下图相连:

软件程序设计
我们在之前一直都在使用 Serial 对象,在 Arduino IDE 的串口监视器中显示信息,其实这个 Serial,就是在 HardwareSerial.h 头文件中定义好的,它提供了与硬件串口通信相关的类和函数。该头文件定义了 HardwareSerial 类,允许你使用硬件串口进行通信。
HardwareSerial 类是用于访问和控制硬件串口的主要类。你可以使用预定义好的 HardwareSerial 对象(Serial、Serial1、Serial2 分别对应了 UART0、UART1 和 UART2。)与特定的硬件串口进行通信。
下面是 HardwareSerial 类的常用函数:
begin():初始化硬件串口的通信。你需要在使用硬件串口之前调用该函数,并指定所需的波特率。available():检查是否有可用的串口数据可供读取。如果串口接收缓冲区中有数据,该函数将返回一个大于0的值。read():从串口接收缓冲区中读取一个字节的数据,并将其作为无符号字节返回。write():向串口发送数据。你可以使用该函数发送单个字节、字符数组或字符串。print()和println():这些函数可用于将数据以文本形式发送到串口。你可以使用这些函数来发送数字、字符串、字符和其他数据类型的内容。
所以,我们就可以使用 Serial 与 Serial2 实现两个串口之间的信息交互,代码如下:
void setup() {
// 初始化串口通信波特率
Serial.begin(9600);
Serial2.begin(9600);
}
void loop() {
// 从串口监视器读取输入数据
if (Serial.available()) {
char data = Serial.read();
// 将数据发送到 UART2
Serial2.write(data);
}
// 从UART2读取输入数据
if (Serial2.available()) {
char data = Serial2.read();
// 将数据发送到 UART0
Serial.write(data);
}
}
光敏电阻
本节课我们来学习如何通过光敏电阻控制 LED。
实验原理
光敏电阻(photoresistor/light-dependent resistor,缩写为 LDR)是一种基于内光电效应的模拟传感器,一般用于光的测量、控制以及光电转换。常见应用有:
- 光控开关;
- 环境检测系统中的日光追踪。

它包含两个电极引线,一片陶瓷基体,用硫化镉或者是硒化镉等材料制成,核心部分是光电导体。光敏电阻的工作原理基于光电效应,利用光照下半导体材料的电导率发生改变的特性。有光照时产生载流子参与导电,在外加电场的作用下做漂移运动,电子向正极,空穴向负极,电阻值减小;光照消失后,电子空穴对复合,阻值也恢复原值,就像是一个自动的滑动变阻器,能随着光照强度的不同改变阻值。
简单来说,光照越强,光敏电阻的阻值越小;光照越弱,阻值越大。
光敏电阻的电路非常简单,分为以下两种方法:
- 光敏电阻与一个电阻串联,电阻接 +5V,光敏电阻接 GND。光照越强,光敏电阻的阻值越小,分配到的电压越小,ADC 读取到的值就越小;
- 光敏电阻与一个电阻串联,电阻接 GND,光敏电阻接 +5V。光照越强,光敏电阻的阻值越小,分配到的电压越小,ADC 读取到的是固定电阻的电压,因此,ADC 读取到的值越大。

硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 光敏电阻 | 1 |
| LED | 2 |
| 10KΩ 电阻 | 1 |
| 1kΩ 电阻 | 2 |
| 杜邦线(跳线) | 若干 |
将材料按照下图相连:

软件程序设计
1. 在串口监视器中显示
我们可以先把读取到的光敏电阻的值打印在串口监视器中,代码如下:
#define LDR 27
// 初始化光敏电阻输入信号
int ldr_value;
void setup() {
// 设置串口通信波特率 9600
Serial.begin(9600);
pinMode(LDR, INPUT);
}
void loop() {
// 读取光敏电阻模拟输入值
ldr_value = analogRead(LDR);
// 打印模拟值在串口屏上
Serial.println(ldr_value);
delay(100);
}
2. 根据光亮控制 LED
我们可以使用光敏电阻来控制 LED 的亮灭,这里需要准备 2 个 LED,其中一个 LED 负责接受 ADC 值,输出 PWM,光照越高,亮度越小;另一个 LED 则模拟日常生活中常见的楼梯间的光控灯,当光照强度过低时点亮,强度过高时熄灭。代码如下:
#define LDR 27
#define PWM 4
#define LED 2
#define FREQ 2000 // 频率
#define CHANNEL 0 // 通道
#define RESOLUTION 12 // 分辨率
// 初始化光敏电阻输入信号
int ldr_value;
void setup() {
// 设置串口通信波特率 9600
Serial.begin(9600);
// 设置引脚模式
pinMode(LDR, INPUT);
pinMode(PWM, OUTPUT);
pinMode(LED, OUTPUT);
// PWM初始化
ledcSetup(CHANNEL, FREQ, RESOLUTION); // 设置通道
ledcAttachPin(PWM, CHANNEL); // 将通道与对应的引脚连接
}
void loop() {
// 读取光敏电阻模拟输入值
ldr_value = analogRead(LDR);
// 打印模拟值在串口屏上
Serial.println(ldr_value);
// 将光敏电阻的模拟输入值转换成 LED 的 PWM
ledcWrite(CHANNEL, ldr_value);
// 控制 LED 的亮灭
if (ldr_value > 3000) {
digitalWrite(LED, HIGH);
}else {
digitalWrite(LED, LOW);
}
delay(100);
}
注意
如果你在搭建搭建光敏电阻电路的时候,使用的是第二种方法(固定电阻接 GND,光敏电阻接 VCC),那么你使用以上程序获取到的值是完全相反的,实现的效果也是相反的。
温湿度传感器
本节课我们来学习温湿度传感器。
实验原理
无论是工业领域还是我们的日常生活,温度和湿度一直都是两个比较重要的指标,DHT11 和 DHT22 是 DHT 系列中使用最广泛的两种传感器。它们有着相同的引脚,用法一致,下图是两者的规格对比:

如果拆下传感器的外壳,其实里面只有一个 NTC 热敏电阻和一个湿度传感元件。

湿度传感部件有两个电极,中间有一个保湿基底(通常是盐或导电塑料聚合物)。随着湿度的升高,基板吸收水蒸气,导致离子的释放和两个电极之间电阻的降低。电阻的变化与湿度成正比,可以测量湿度来估计相对湿度。

DHT11 与 DHT22 还包括用于测量温度的 NTC(热敏电阻)。热敏电阻是一种电阻随温度变化的电阻器。从技术上讲,所有电阻器都是热敏电阻,因为它们的电阻随温度略有变化,但这种变化通常非常小,难以测量。热敏电阻的设计使其电阻随温度而急剧变化(每度 100Ω 或更大),而且电阻随着温度的升高而减小。

DHT11 和 DHT22 传感器的连接都相对简单。它们有四个引脚:

VCC:传感器供电引脚,建议使用 5V 电源。使用 5V 电源,传感器可以放置在 20 米外。在 3.3V 电源电压下,传感器可以放置在 1 米外;Data:通过串行数据输出温度和湿度;NC:Not connected,无连接;GND:接地;
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| LCD1602 液晶屏 IIC | 1 |
| DHT11 温湿度传感器 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
将材料按照下图相连:

软件程序设计
本实验通过 DHT11 读取当前环境的温湿度,并将其打印在 LCD1602 屏幕与串口监视器中。我们这个实验会直接调用 DHT 与 LCD1602 I2C 的第三方库,因此,需要提前在 Arduino IDE 中安装 DHT sensor library 与 LiquidCrystal I2C 第三方库

我们可以先把读取到的数据打印在串口监视器中,代码如下:
#include <DHT.h>
// 初始化 DHT 对象
DHT dht(13, DHT11);
void setup() {
Serial.begin(9600);
dht.begin();
}
void loop() {
// 读取湿度
float humid = dht.readHumidity();
// 读取温度
float temp = dht.readTemperature();
// 显示内容
Serial.print("湿度: ");
Serial.print(humid);
Serial.print("% 温度: ");
Serial.print(temp);
Serial.println("°C ");
delay(2000);
}
最后,我们就可以把从传感器中读取到的数据显示在 LCD1602 屏幕上,代码如下:
#include <LiquidCrystal_I2C.h>
#include <DHT.h>
// 声明 LCD1602 I2C 对象
LiquidCrystal_I2C lcd(0x27, 16, 2);
// 初始化 DHT 对象
DHT dht(13, DHT11);
void setup()
{
// 初始化 LCD 对象
lcd.init();
// 打开背光
lcd.backlight();
// 设置串口通信波特率
Serial.begin(9600);
// 初始化 dht 对象
dht.begin();
}
void loop(){
// 读取湿度
float humid = dht.readHumidity();
// 读取温度
float temp = dht.readTemperature();
// 显示内容
Serial.print("湿度: ");
Serial.print(humid);
Serial.print("% 温度: ");
Serial.print(temp);
Serial.println("°C ");
// 清屏
lcd.clear();
// 打印内容在 LCD1602 上
lcd.print("temp: ");
lcd.print(temp);
// 输出度数符号
lcd.write(0xdf); // 也可以写成 lcd.print(char(223))
lcd.print(char(223));
lcd.print("C");
// 换行
lcd.setCursor(0, 1);
lcd.print("humid: ");
lcd.print(humid);
lcd.print("%");
delay(2000);
}
提示
大多数 LCD1602 的 HD44780 控制器 内置了 ° 符号,其字符编码为 十进制 223(十六进制 0xDF),可以直接输出。
超声波测距
本节课来学习使用 Arduino 控制超声波传感器并获取距离数据,最终搭配一个蜂鸣器实现倒车雷达的效果。
实验原理
超声波是一种频率高于 20000Hz 的声波,超声波的方向性好,反射能力强,易于获得较集中的声能,在水中传播距离比空气中远,可用于测距、测速、清洗、焊接、碎石、杀菌消毒等。
超声波可用于许多不同的领域。超声波设备用于检测物体和测量距离。超声成像或超声检查常用于医学。在产品和结构的无损检测中,超声波用于检测不可见的缺陷。在工业上,超声波用于清洁、混合和加速化学过程。蝙蝠和鼠海豚等动物使用超声波来定位猎物和障碍物。
本实验使用的超声波模块为 HC-SR04 超声波传感器,其原理为利用超声波在遇到障碍物后反射,结合声波在空气中的传播速度,可以得出传播的距离。该模块外观如下图所示:

超声波传感器使用声纳来确定与物体的距离。我们使用的超声波模块由 2 个超声波探头组成:
T:表示Transmitter(发射),负责发送超声波信号;R:表示Receiver(接收),负责接收回响信号;
注意
如果在使用过程中,对其中任意一个探头进行遮挡,都会使超声波无法正常测量距离。
底部有四个引脚:
VCC:5V 供电引脚;GND:接地;TRIG:控制信号输入;ECHO:回响信号输出;

以上时序图表示超声波模块的基本工作原理:
- 采用 IO 口 TRIG 触发测距,给一个 10us 的高电平信号;
- 模块自动发送 8 个 40khz 的方波,自动检测是否有信号返回;
- 有信号返回,通过 IO 口 ECHO 输出一个高电平,高电平持续的时间就是超声波从发射到返回的时间。测试距离=(高电平时间*声速(340M/S))/2
Trig 引脚是用来输入一个长为 10us 的高电平方波,通过输入这一方波,模块会自动发射 8 个 40KHz 的声波,并在这个时刻,Echo 引脚会由 0 变为 1,此时开启定时器定时。当超声波返回并被模块接收到后,Echo 端引脚电平从 1 变为 0,出现下降沿。这时候结束定时,通过计算定时器的值并乘上 340m/s 的声音传播速度,即可计算所测距离。
回响信号的脉冲宽度与所测的距离成正比。由此通过发射信号到收到的回响信号时间间隔可以计算得到距离。公式如下:
距离 = 高电平时间 * 声速(340m/s)/2
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 有源蜂鸣器 | 1 |
| LED | 1 |
| 1KΩ 电阻 | 1 |
| 超声波模块 HC-SR04 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
| 超声波模块 | 1 |

软件程序设计
Arduino 提供一个测量脉冲时间长度的 pulseln() 函数,语法格式:
pulseIn(pin, value)
pulseIn(pin, value, timeout)
该函数将返回微秒 us 单位的脉冲时间也就是说 pulseIn() 测出来的是超声波从发射到接收所经过的时间。
参数说明如下:
Pin:需要读取脉冲的引脚;value:需要读取的脉冲类型, HIGH 或 LOWtimeout:超时时间,单位微秒,数据类型为⽆符号长整型。
pulseIn() 函数用于读取引脚脉冲的时间长度,脉冲可以是 HIGH 或 LOW。如果是 HIGH,函数将先等引脚变为高电平,然后开始计时,一直到变为低电平为止。返回脉冲持续的时间长短, 单位为 ms。如果超时还没有读到的话, 将返回 0。
1. 超声波测距
该程序的功能是通过超声波模块测算距离并打印在命令行中,代码如下:
// 定义超声波模块引脚
#define TRIG 27
#define ECHO 14
void setup() {
Serial.begin(9600);
pinMode(TRIG, OUTPUT);
pinMode(ECHO, INPUT);
}
void loop() {
// 清除trigPin
digitalWrite(TRIG, LOW);
delay(5);
// 将trigPin设置为HIGH状态10微秒
digitalWrite(TRIG, HIGH);
delay(10);
digitalWrite(TRIG, LOW);
// 读取echoPin,返回声波传播时间(微秒)
float time = pulseIn(ECHO, HIGH);
// 计算距离
float distance = time * 0.3432 / 2;
Serial.print("距离: ");
Serial.print(distance);
Serial.println("mm");
delay(100);
}
2. 倒车雷达系统
最后,我们可以根据获取的距离来实现倒车雷达的效果,距离近的时候蜂鸣器会报警,LED 闪烁,随着距离越来越近,蜂鸣器发生频率与 LED 的闪烁频率都会越来越频繁,代码如下:
#define TRIG 27
#define ECHO 14
#define LED 2
#define BUZZER 15
int delay_time;
void setup() {
Serial.begin(9600);
pinMode(TRIG, OUTPUT);
pinMode(ECHO, INPUT);
pinMode(LED, OUTPUT);
pinMode(BUZZER, OUTPUT);
// 将 LED 与蜂鸣器设置为低电平
digitalWrite(LED, LOW);
digitalWrite(BUZZER, LOW);
}
void loop() {
// 清除trigPin
digitalWrite(TRIG, LOW);
delay(5);
// 将trigPin设置为HIGH状态10微秒
digitalWrite(TRIG, HIGH);
delay(10);
digitalWrite(TRIG, LOW);
// 读取echoPin,返回声波传播时间(微秒)
float time = pulseIn(ECHO, HIGH);
// 计算距离
float distance = time * 0.3432 / 2;
Serial.print("距离: ");
Serial.print(distance);
Serial.println("mm");
// 距离过近则报警
if (distance < 200) {
delay_time = int(distance);
Serial.println(delay_time);
digitalWrite(LED, HIGH);
digitalWrite(BUZZER, HIGH);
delay(delay_time);
digitalWrite(LED, LOW);
digitalWrite(BUZZER, LOW);
delay(delay_time);
} else {
digitalWrite(LED, LOW);
digitalWrite(BUZZER, LOW);
delay(100);
}
}
旋转编码器
本节课来学习使用 MicroPython 控制旋转编码器。
实验原理
旋转编码器是一种位置传感器,它将旋钮的角位置(旋转)转换为数字信号输出,可用于确定旋钮的转动方向,被广泛应用于各种领域。旋转编码器听起来虽然很陌生,但实际上在我们生活中十分常用,比如鼠标滚轮、汽车音箱的旋钮。

之前,我们学过一个与其类似的元件 电位计,它不同于普通电位器,电位计一般只能旋转大约 3/4 圈,而旋转编码器可以无限旋转,并能精确测量相对位置变化。旋转编码器是电位器的现代数字等效物,并且用途更广泛。
在旋转编码器内部,有一个带有均匀间隔槽孔的圆盘,圆盘与 GND 相连,另外,编码器还有另外两个金属探针 A 和 B,这些引脚将帮助我们确定旋钮的转动方向。

当我们转动编码器的旋钮时,圆盘会随之一起转动。根据转动方向的不同,探针 A 和 B 会先后接触到 GND,并先后产生两个脉冲信号,这两个信号会存在一个 90° 的相位差,形成一种正交编码。当顺时针旋转旋钮时,A 引脚先于 B 引脚接地。当逆时针旋转旋钮时,B 引脚先于 A 引脚接地,具体工作原理可以参考以下两个动图:


通过监控每个引脚何时连接或断开接地,我们可以确定旋钮旋转的方向。这可以通过简单地观察 A 的状态改变时 B 的状态来完成。
当 A 改变状态时:
- 如果
B != A,则旋钮为顺时针转动

- 如果
B = A,则旋钮为逆时针转动

旋转编码器模块的引脚排列如下:

VCC是正电源电压,通常在 3.3 至 5 伏之间。KEY是按钮开关的输出(低电平有效)。当按下旋钮时,电压变低。S2金属探针引脚,用于确定旋转量S1金属探针引脚,用于确定旋转量。GND是接地连接。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 旋转编码器模块 | 1 |
| OLED 屏幕 | 1 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
OLED 的 SCK(D0)接开发板 D18、SDA(D1)接 D5、RES 接 D15、DC 接 D2、CS 接 D4
旋转编码器的 S2 引脚接开发板 D26、S1接 D27,KEY 按键没有用到,可以不接。

软件程序设计
1. 计数器
第一个程序,我们来通过代码获取旋转编码器的转动方向,做一个计数器,顺时针旋转则加,逆时针旋转则减,代码如下:
#define A 26
#define B 27
// 定义 A 上一个引脚状态变量
int last_state_a;
// 定义计数器变量
int count = 0;
void setup() {
// 设置引脚模式
pinMode(A, INPUT);
pinMode(B, INPUT);
// 设置串口通信波特率
Serial.begin(9600);
// 记录上一个 a 引脚的状态
last_state_a = digitalRead(A);
}
void loop() {
// 获取当前 A 引脚电平
int current_state_a = digitalRead(A);
// 检测 A 引脚状态变化,只检测下降沿
if(!current_state_a && last_state_a) {
// 如果 AB 状态不同,则顺时针选装,反之则逆时针
if(digitalRead(B) != current_state_a) {
count++;
Serial.print("顺时针旋转, ");
}else {
count--;
Serial.print("逆时针旋转, ");
}
Serial.println(count);
}
// 更新上一个引脚电平状态
last_state_a = current_state_a;
}
2. 旋转编码器控制菜单
最后,我们使用旋转编码器与 OLED 屏幕实现一个控制菜单的实验,该实验与 SPI 驱动 OLED 液晶屏幕 中的菜单 UI 界面一致,可以参考之前的教程,代码如下:
#include <Arduino.h>
#include <U8g2lib.h>
#define A 26
#define B 27
// PlatformIO 中 自己编写的函数如果处于末尾,需要在文件顶部显式声明
void display_menu(int index);
U8G2_SSD1306_128X64_NONAME_2_4W_SW_SPI u8g2(U8G2_R0, /* clock=*/18, /* data=*/5,
/* cs=*/4, /* dc=*/2, /* reset=*/15);
// 定义菜单列表
char *menu[4] = {"Item 1", "Item 2", "Item 3", "Item 4"};
// 定义当前选项
unsigned int order = 0;
// 定义 A 上一个引脚状态变量
int last_state_a;
void setup()
{
// 初始化 OLED 对象
u8g2.begin();
u8g2.setFont(u8g2_font_6x12_tr);
// 配置输入按键
pinMode(A, INPUT);
pinMode(B, INPUT);
// 记录上一个 a 引脚的状态
last_state_a = digitalRead(A);
// 初始化菜单
display_menu(order);
}
void loop()
{
// 获取当前 A 引脚电平
int current_state_a = digitalRead(A);
// 检测 A 引脚状态变化,只检测下降沿
if(!current_state_a && last_state_a) {
// 如果 AB 状态不同,则顺时针选装,反之则逆时针
if(digitalRead(B) != current_state_a) {
order = (order + 1) % 4;
}else {
order = (order - 1) % 4;
}
display_menu(order);
}
// 更新上一个引脚电平状态
last_state_a = current_state_a;
}
void display_menu(int index)
{
// 进入第一页
u8g2.firstPage();
do
{
// 绘制页面内容
u8g2.drawStr(0, 12, "Menu");
u8g2.drawHLine(0, 14, 128);
for (int i = 0; i < 4; i++)
{
if (i == index)
{
u8g2.drawStr(5, (i + 2) * 12 + 2, ">");
u8g2.drawStr(20, (i + 2) * 12 + 2, menu[i]);
}
else
{
u8g2.drawStr(5, (i + 2) * 12 + 2, menu[i]);
}
}
} while (u8g2.nextPage()); // 进入下一页,如果还有下一页则返回 True.
}
RGB LED
本节课我们来学习使用 MicroPython 控制 RGB LED 模块。
实验原理
之前我们已经学习过 LED 了,并且实现过控制 LED 亮灭以及呼吸灯的实验,但是 LED 只能发出一种颜色的光,而 RGB LED 是一种集成红(R)、绿(G)、蓝(B)三种颜色 LED 芯片的复合发光器件,通过 PWM(脉宽调制)控制各颜色通道的亮度比例。

这里,先简单了解 RGB 加色模型,我们可以通过红、绿、蓝三种颜色就可以模拟出其他颜色,每个通道的颜色强度范围是 0-255 ,任何一个数值的变化都会有一种颜色对应,可以实现 256*256*256 种颜色的混合输出,除了使用 3 个 0-255 范围的数值表示颜色之外,还可以将三个数值转换成 16 进制数,前面加上 # 来表示颜色,这就是我们常见的 16 进制颜色编码。比如下图是不同颜色对应的 RGB 值
| 颜色 | 对应的 RGB 值 | 16 进制颜色码 |
|---|---|---|
| 红色 | RGB(255, 0, 0) | #FF0000 |
| 橙色 | RGB(255, 127, 0) | #FF7F00 |
| 湖蓝 | RGB(0, 128, 255) | #0080FF |
| 玫瑰红 | RGB(255, 0, 128) | #FF0080 |

了解了 RGB 加色原理之后,我们就可以来学习 RGB LED 了,RGB LED 通常有 4 个引脚,分为共阳和共阴两种类型。它们的核心区别在于 公共端(COM)的极性和驱动方式。以下是两者的详细对比:
| 类型 | 公共端(COM) | LED 连接方式 | 点亮条件 |
|---|---|---|---|
| 共阴极 | 阴极(GND) | 三个 LED 的阴极短接,阳极独立 | 向 R/G/B 引脚输出 高电平(+3.3V 或 +5V),电流从控制端流向公共端(GND),LED 点亮 |
| 共阳极 | 阳极(+VCC) | 三个 LED 的阳极短接,阴极独立 | 向 R/G/B 引脚输出 低电平(GND),电流从公共端(VCC)流向控制端,LED 点亮 |

本套教程,我们使用的是共阳极 RGB 模块。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 共阳极 RGB LED 模块 | 1 |
| 杜邦线(跳线) | 若干 |

软件程序设计
第一步,我们先使用 GPIO 控制 RGB LED 输出最基础的几种颜色,代码如下:
#define R_PIN 15
#define G_PIN 4
#define B_PIN 2
void setup() {
// 初始化引脚
pinMode(R_PIN, OUTPUT);
pinMode(G_PIN, OUTPUT);
pinMode(B_PIN, OUTPUT);
// 共阳型 LED 的控制端为高电平时熄灭
digitalWrite(R_PIN, HIGH);
digitalWrite(G_PIN, HIGH);
digitalWrite(B_PIN, HIGH);
}
void loop() {
// 红色
digitalWrite(R_PIN, 0);
digitalWrite(G_PIN, 1);
digitalWrite(B_PIN, 1);
delay(1000);
// 绿色
digitalWrite(R_PIN, 1);
digitalWrite(G_PIN, 0);
digitalWrite(B_PIN, 1);
delay(1000);
// 蓝色
digitalWrite(R_PIN, 1);
digitalWrite(G_PIN, 1);
digitalWrite(B_PIN, 0);
delay(1000);
// 红 + 绿
digitalWrite(R_PIN, 0);
digitalWrite(G_PIN, 0);
digitalWrite(B_PIN, 1);
delay(1000);
// 红 + 蓝
digitalWrite(R_PIN, 0);
digitalWrite(G_PIN, 1);
digitalWrite(B_PIN, 0);
delay(1000);
// 蓝 + 绿
digitalWrite(R_PIN, 1);
digitalWrite(G_PIN, 0);
digitalWrite(B_PIN, 0);
delay(1000);
// 红 + 绿 + 蓝
digitalWrite(R_PIN, 0);
digitalWrite(G_PIN, 0);
digitalWrite(B_PIN, 0);
delay(1000);
}
由于 GPIO 只能控制电平高低,因此,我们可以显示的颜色是有限的,所以,我们还需要使用 PWM 调光,才能输出更多的颜色。
需要注意的是,由于共阳与共阴 LED 的输出方式是完全相反,因此,我们在使用共阳 RGB LED 时需要输出的 PWM 的占空比应该是 255 - RGB 值,这里,我们可以直接使用 analogWrite() 函数进行 PWM 输出,因为 analogWrite() 输出的占空比范围默认为 0-255,代码如下:
#define R_PIN 15
#define G_PIN 4
#define B_PIN 2
// 将 RGB 颜色值转换为共阳型 RGB LED 的占空比
void set_color(int r, int g, int b) {
analogWrite(R_PIN, 255-r);
analogWrite(G_PIN, 255-g);
analogWrite(B_PIN, 255-b);
}
void setup() {
// 初始化引脚
pinMode(R_PIN, OUTPUT);
pinMode(G_PIN, OUTPUT);
pinMode(B_PIN, OUTPUT);
}
void loop() {
// 玫瑰红
set_color(255, 0, 128);
delay(1000);
// 橙色
set_color(255, 127, 0);
delay(1000);
// 湖蓝
set_color(0, 128, 255);
delay(1000);
}
这里是一些常用的 RGB 颜色,大家可以用这些 RGB 值测试一下代码显示的颜色是否正确:

