使用 MicroPython 开发 ESP32 简介
本套开发教程主要参考 Quick reference for the ESP32,这是 MicroPython 官方手册,里面包含了使用 MicroPython 操控 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等。ESP-IDF,是乐鑫官方的物联网开发框架,基于 C/C++ 语言提供了一个自给自足的 SDK,方便用户在这些平台上开发通用应用程序。
还有几个比较小众的开放方式比如 Lua、Javascript 等等,大家可以去了解。
为什么要学习 MicroPython?
Python,是一种面向对象的解释型计算机程序设计语言,它是纯粹的自由软件,源代码和解释器 CPython 遵循GPL(GNU General Public License)协议。Python 的设计目标之一是让代码具备高度的可阅读性。它设计时尽量使用其它语言经常使用的标点符号和英文单字,让代码看起来整洁美观。它不像其他的静态语言如 C、Pascal 那样需要重复书写声明语句,也不像它们的语法那样经常有特殊情况和意外。总之,Python 是一种简单易用的、能够运行在多个平台下的计算机编程语言。
MicroPython 是基于 Python 实现的简易版本,用于在嵌入式系统中运行,例如树莓派、ARM 单片机和 ESP32。它兼容大部分 Python3 语法,因此只要熟悉 Python3 就能无障碍上手 MicroPython。
而 MicroPython,是跑在 MCU(微控制器)上的 Python,通过内置的解释器执行 py 文件或者 py 命令,就可以让微控制器运行您想要的功能了。MicroPython 和 Python 编程语言一样,在任何板子上都可以使用通用的 API 控制硬件底层,比如点亮 LED 灯,读取传感器信息, LCD 显示字符串、控制电机、连接网络、连接蓝牙等等。
如果说 Arduino 让创客摆脱了各种编程环境配置,那么 Micropython 直接让创客摆脱了底层。命令行和解释执行,都是 C 语言所不具备的优势,运行 Micropython 的 SOC,就类似一台完整的电脑,我们用 python 文件和命令行,轻松控制这台电脑中的一切。
MicroPython 是人们连接各种任务的粘合剂,即便你不懂编程,不懂硬件,也可以通过它来控制 MCU。
MicroPython 它基于 ANSIC,语法跟 Python3 基本一致,拥有独立的解析器、编译器、虚拟机和类库等,所以可以在所支持的硬件平台上使用 Python 语言对硬件控制。 目前他支持基于 32-bit 的 ARM 处理器,比如说 STM32F401、STM32F405、STM32F407、ESP32 等,现如今支持的处理器更加丰富,比如 ESP8266 等,有兴趣的可以去官网了解下。
MicroPython 的启动流程
当我们启动 ESP32 后,MicroPython 系统将会依次执行一系列存放在单片机根目录下的的程序文件。
首先执行的是 boot.py 文件。该文件是由 MicroPython 系统创建的。因此你无需自己创建该文件就可以在刚刚刷好固件的单片机根目录下找到它。我不建议 MicroPython 的初学者对该文件进行修改,因为 boot.py 文件出现问题能会导致 MicroPython 无法正常启动,严重的会导致系统崩溃。要修复可能只有重新刷固件才行。所以除非你非常了解 MicroPython,否则请不要自行修改该文件。
执行完 boot.py 以后,启动后的单片机接下来将会执行 main.py 文件。如果你希望 ESP32 在每次启动后都执行一系列操作的话,可以将你的指令代码写入该文件。由于 main.py 文件是用户自己建立的而不是 MicroPython 系统建立的,因此在刚刚刷好固件的开发板根目录下是不存在该文件的。
MicroPython ESP32 环境搭建
如果你之前已经熟练掌握 Python 或已经使用 Python 开发,那么可以直接使用你原来习惯的开发软件来编程。
如果你是初学者或者喜欢简单而快速应用,那么推荐使用 Thonny。Thonny 是一款开源软件,以极简方式设计,对 MicroPython 的兼容性非常友善。而且支持 Windows、Mac OS、Linux、树莓派。由于开源,所以软件迭代速度非常快,功能日趋成熟。使用 Thonny 还有两个方便之处,可直接在该软件中实现给 ESP32 单片机刷 MicroPython 固件,可以实时预览 ESP32 的文件系统。
Thonny 也不是没有缺陷的,由于其过于轻量化的设计,Thonny 不具备代码提示功能等很多开发者常用工具,但是对于初学者而言,依然是一款十分方便的 IDE。如果你觉得 PyCharm 更适合你的话,本节课也会教给你如何使用 PyCharm 开发 Micropython。
安装 Thonny
要在电脑上成功安装 Thonny,首先必须要有安装包,我们可以在 Thonny 官网下载:https://thonny.org/,打开界面如下:

页面右上角有下载提示,根据电脑的系统选择不同的版本,然后下载即可。
如果感觉下载太慢,在我们的资料包中有 thonny-4.0.1.exe,可以直接使用。
下载好之后,鼠标右键点击 Thonny 安装程序,选择以管理员模式运行,之后就无脑点击 Next,选择好存放路径即可。
注意
注意:存放路径不能出现中文或特殊字符
如果能正常打开,说明安装成功。

之所以在下面的 shell 交互环境中有红色报错,是因为我们的 ESP32 中不存在 MicroPython 固件,因此,不用担心,咱们下一步就是烧录 ESP32 MicroPython 固件了。
配置 MicroPython 开发环境
首先,在 Thonny 中显示本地与开发板中的实时文件浏览窗口。
打开 Thonny 软件,点击视图选择文件,如下:

这时候,我们就看到了左侧出现本地和开发板的实时文件浏览窗口:

这时,我们看到在单片机中不存在任何文件,这也是为什么交互环境中报错的原因 - 没有 MicroPython 固件。
接下来,我们需要配置解释器并烧录固件到单片机中。
点击右下角,选择配置解释器。

在解释器页面,选择 MicroPython(ESP32) 和当前单片机占用的端口。

在点击 OK 之前,我们还需要把 MicroPython 固件烧录到 ESP32 单片机中。点击 install or update MicroPython

选择对应的端口以及固件,端口与之前配置解释器时的端口一致。

固件需要在 MicroPython的官网 下载,也可以在资料包中的开发工具中的ESP32 MicroPython 固件中找到。

点击安装,等待安装完毕即可。
如果安装失败,出现以下报错不用担心,只需要安装时,按住 BOOT 键即可。


出现以下信息即可松手。

安装完成后,我们可以看到在单片机设备中出现了 boot.py 文件,shell 环境也可以正常使用了。

运行程序
前面我们已经安装好了 Thonny IDE 和配置,接下来我们使用最简单的方式来做一个点亮 LED 的实验测试一下是否 MicroPython 环境是否搭建成功。
大家暂时先不用理解代码意思,后面章节会有讲解。这里主要是为了让大家了解一下 MicroPython 编程软件Thonny 的使用方法和原理。
在本地创建一个文件 main.py,

并将以下代码复制到 main.py 文件中。
import time
from machine import Pin
pin2 = Pin(2, Pin.OUT)
while True:
pin2.value(not pin2.value())
time.sleep(1)
点击左上角运行当前脚本,或者按 F5 运行。

然后我们就可以看到单片机上的一个 LED 开始闪烁,说明我们固件烧录成功了。

常见问题
1. 配置解释器没有发现端口

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

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

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

手动查找驱动程序,

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

选择 端口(COM 和 LPT),

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

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

这样就 OK 了。

面包板与杜邦线
我们在使用单片机的时候,电路搭建是必不可少的,而面包板和杜邦线可以让你更加轻松地搭建电路,这节课就来讲讲面包板与杜邦线的简单使用。
面包板
面包板是实验室中用于搭接电路的重要工具,熟练掌握面包板的使用方法是提高实 验效率,减少实验故障出现几率的重要基础之一。下面就面包板的结构和使用方法做简 单介绍。

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

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

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

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

控制 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。
软件设计
Pin 引脚类
MicroPython 中可使用 machine 模块中的 Pin 模块对 GPIO 输出控制。其构造方法如下:
构造函数 Pin(id, mode=-1, pull=-1, value, drive, alt):访问与给定 id 引脚. 如果在构造函数中给出了额外的参数,那么它们将用于初始化引脚。任何未指定的设置将保持其先前状态。
id是必填项,用于指定引脚,注意:可用的引脚范围是:0-19,21-23,25-27,32-39。mode指定引脚模式,可以是Pin.IN输入引脚,Pin.OUT。pull指定引脚是否连接了(弱)上拉电阻,并且可以是None没有上拉或下拉电阻,Pin.PULL_UP上拉电阻,Pin.PULL_DOWN下拉电阻。
其他参数在初级阶段涉及相对较少,更多内容可以参考官网文档。
from machine import Pin
# 创建一个输出引脚在 0 引脚
p0 = Pin(0, Pin.OUT)
# 给 P0 引脚先输出低电平,再输出高电平
p0.value(0)
p0.value(1)
# 给 P0 引脚先输出低电平,再输出高电平,等同于 p0.value(0),p0.value(1)
p0.on()
p0.off()
# 在 P2 创建一个输入引脚,并设置上拉电阻
p2 = Pin(2, Pin.IN, Pin.PULL_UP)
# 打印 P2 的值
print(p2.value())
通过上面的文档我们知道,想要让一个引脚输出高电平,只需要找到对应的 GPIO 然后通过 on() 或者 value(1) 操作就可以,同理如果想要输出低电平让 LED 灯灭,只需要调用 off() 或者 value(0) 就行。
1. 点亮一颗 LED
因此,如果我们想要点亮这颗 LED 的话,只需要先构建引脚对象,然后给这个引脚赋值一个高电平即可。
from machine import Pin
# 构建 pin_12 引脚对象,GPIO12输出
pin_12 = Pin(12, Pin.OUT)
# 使 Pin2 输出高电平,点亮LED
pin_12.value(1)
通过 Thonny 编写上述代码,然后运行,此时会看到电路中的 LED 灯被点亮了。
2. 闪烁的 LED 灯
我们已经成功点亮一颗 LED 了,接下来,可以尝试一下稍微复杂一点的逻辑,比如让这颗 LED 闪烁。
实现 LED 闪烁的原理很简单,就是在循环语句中使用延时模块。先设置高电平,延时 X 秒,再设置低电平,延时 X 秒,之后就不断循环该语句即可。
在之前的 Python 入门教程 中,我们学习了 for 和 while 两种循环语句,如果我们想要让灯泡一直闪烁,则需要设置无限循环,因此使用 while 更合适。
# 如果 while 的条件为 True,一直为真,就可以实现无限循环了。
while True:
pass
Python 中用到延时,可使用 time 模块,time 模块中常用的几个延时函数使用如下:
# 导入 time 模块
import time
# 延时 0.5 秒
time.sleep(0.5)
# 延时 100 毫秒
time.sleep_ms(100)
# 延时 100 微秒
time.sleep_us(100)
# 获取毫秒计时器当前值
time_1 = time.ticks_ms()
注意
这里的 time 模块是 MicroPython 中的 time 模块,与 Python 中的不同,更多使用方法可以参考官方文档 MicroPython Time 模块
所以,我们的程序可以这么写:
# 导入time模块
import time
# 导入 Pin 模块
from machine import Pin
# 构建 P12 对象,GPIO12输出
pin_12 = Pin(12, Pin.OUT)
# 永真循环
while True:
# 使 P12 输出高电平,点亮 LED
pin_12.on()
# 延时 0.5 秒
time.sleep(0.5)
# 使 P12 输出低电平,熄灭 LED
pin_12.off()
time.sleep(0.5)
运行程序,LED 就闪烁了。
流水灯实验
上节课我们已经学习了如何点亮一颗 LED 并且让其闪烁,这节课我们学习如何制作流水灯。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 5 |
| 1kΩ 电阻 | 5 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
每一个 LED 的正极与开发板一个 GPIO 引脚相连,并串联一个电阻,负极接 GND,如下图:

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

软件程序设计
1. 正常流水灯
设计这个程序时,我们需要使用的 Python 中的 列表 list 与 循环嵌套,以及 MicroPython 的延时模块。
列表用于存储所有用到的输出引脚以及所有的 LED Pin 对象。
循环嵌套的作用也很容易理解,外层的 while True 表示永真循环,可以让这个程序一直执行下去。但是,在每一次的时候,需要依次点亮对应的 LED,然后再依次熄灭对应的 LED。
'''
该程序作用是让 LED 依次点亮后依次熄灭
在线文档:https://docs.geeksman.com/
'''
import time
from machine import Pin
# 定义 LED 控制引脚
pin_index_list = [13, 12, 14, 27, 26]
# 定义 led_pin_list 列表,保存 LED 管脚配置对象
led_pin_list = []
# 循环给 led_pin_list 列表添加对象
for i in pin_index_list:
led_pin_list.append(Pin(i, Pin.OUT))
# LED全熄灭
for led_pin in led_pin_list:
led_pin.value(0)
while True:
# LED逐个点亮
for led_pin in led_pin_list:
led_pin.value(1)
time.sleep(0.05)
# LED逐个熄灭
for led_pin in led_pin_list:
led_pin.value(0)
time.sleep(0.05)
value 方法除了可以赋值外,也可以在不传递参数的时候,获取当前值的状态,比如:
from machine import Pin
led_pin = Pin(12, Pin.OUT) # 创建一个 LED Pin 对象
led_pin.value() # 获取该引脚的逻辑电平
因此,我们可以通过 led_pin.value(not led_pin.value()) 的方式,依次修改当前 LED 对象的状态。
提示
在逻辑值中 1 == True, 0 == False。
将:
while True:
# LED逐个点亮
for led_pin in led_pin_list:
led_pin.value(1)
time.sleep(0.05)
# LED逐个熄灭
for led_pin in led_pin_list:
led_pin.value(0)
time.sleep(0.05)
替换为
while True:
# 逐个改变 LED 状态
for led_pin in led_pin_list:
led_pin.value(not led_pin.value())
time.sleep(0.05)
2. 反复流水灯
我们还可以对该程序进行微调,比如之前是依次改变流水灯的状态,现在,修改为让流水灯往复亮。
'''
该程序作用是实现反复流水灯
在线文档:https://docs.geeksman.com/
'''
import time
from machine import Pin
# 定义 LED 控制引脚
pin_index_list = [13, 12, 14, 27, 26]
# 定义 led_pin_list 列表,保存 LED 管脚配置对象
led_pin_list = []
# 循环给 led_pin_list 列表添加对象
for i in pin_index_list:
led_pin_list.append(Pin(i, Pin.OUT))
# LED全熄灭
for led_pin in led_pin_list:
led_pin.value(0)
while True:
# LED逐个点亮
for led_pin in led_pin_list:
led_pin.value(1)
time.sleep(0.1)
# LED逐个熄灭
for led_pin in reversed(led_pin_list):
led_pin.value(0)
time.sleep(0.1)
3. LED 移动
让 LED 实现平移的效果是这样实现的,每次在我点亮这颗 LED 的时候,同时把上一颗 LED 的状态改为低电平,代码如下:
'''
该程序作用是实现 LED 的移动
在线文档:https://docs.geeksman.com/
'''
import time
from machine import Pin
# 定义 LED 控制引脚
pin_index_list = [13, 12, 14, 27, 26]
# 定义 led_pin_list 列表,保存 LED 管脚配置对象
led_pin_list = []
# 循环给 led_pin_list 列表添加对象
for i in pin_index_list:
led_pin_list.append(Pin(i, Pin.OUT))
# 获取 LED_Pin_list 的长度
num = len(led_pin_list)
# LED全熄灭
for led_pin in led_pin_list:
led_pin.value(0)
while True:
for i in range(num):
# LED逐个点亮
led_pin_list[i].value(1)
# 如果这颗 LED 是第一个,则需要改变最后一颗 LED 的状态
if i == 0:
led_pin_list[num - 1].value(0)
# 如果这颗 LED 不是第一个,则需要改变它之前一颗 LED 的状态
else:
led_pin_list[i - 1].value(0)
# 延时 0.2 秒
time.sleep(0.1)
快去自己的开发板上测试一下吧。
数码管显示
现阶段,无论 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 |
将材料按照下图相连:

软件程序设计
设计这个程序时,我们需要使用的 Python 中的 字典 dict 和 函数 function。
如果我们想让这个数码管某一引脚亮起来,那么我们需要给对应的引脚设置一个低电平。如果我们想要显示一个数字时,就需要让多个 LED 同时亮,比如数字 1 需要 b、c 引脚给低电平,其余引脚给高电平。程序可以这样写:
from machine import Pin
# 定义不同引脚对应不同的 Pin 对象
a = Pin(4, Pin.OUT)
b = Pin(5, Pin.OUT)
c = Pin(19, Pin.OUT)
d = Pin(21, Pin.OUT)
e = Pin(22, Pin.OUT)
f = Pin(2, Pin.OUT)
g = Pin(15, Pin.OUT)
dp = Pin(18, Pin.OUT)
# 将所有引脚对象存入列表中
led_list = [a, b, c, d, e, f, g, dp]
# 将所有引脚初始值设为 1 高电平
for led in led_list:
led.value(1)
# 显示数字 1,将 b,c 设置为低电平
b.value(0)
c.value(0)
这样写的话,我们不仅需要在使用前先把所有引脚逻辑电平拉高,而且如果我们想要显示数字 2,就需要修改多行代码:
# 显示数字 2,将 a, b, d, e, g 设置为低电平
a.value(0)
b.value(0)
d.value(0)
e.value(0)
g.value(0)
这样写的话非常麻烦,因此,我们可以使用字典来存储数字对应的所有引脚的逻辑值,再封装一个函数将字典中的值转换并应用到每个引脚中,代码如下:
import time
from machine import Pin
# 定义不同引脚对应不同的 Pin 对象
a = Pin(4, Pin.OUT)
b = Pin(5, Pin.OUT)
c = Pin(19, Pin.OUT)
d = Pin(21, Pin.OUT)
e = Pin(22, Pin.OUT)
f = Pin(2, Pin.OUT)
g = Pin(15, Pin.OUT)
dp = Pin(18, Pin.OUT)
# 将所有引脚对象存入列表中
led_list = [a, b, c, d, e, f, g, dp]
# 把所有数字对应的逻辑电平存入字典中,逻辑值依次为 abcdefgh
number_dict = {
0: [0, 0, 0, 0, 0, 0, 1, 1],
1: [1, 0, 0, 1, 1, 1, 1, 1],
2: [0, 0, 1, 0, 0, 1, 0, 1],
3: [0, 0, 0, 0, 1, 1, 0, 1],
4: [1, 0, 0, 1, 1, 0, 0, 1],
5: [0, 1, 0, 0, 1, 0, 0, 1],
6: [0, 1, 0, 0, 0, 0, 0, 1],
7: [0, 0, 0, 1, 1, 1, 1, 1],
8: [0, 0, 0, 0, 0, 0, 0, 1],
9: [0, 0, 0, 0, 1, 0, 0, 1],
}
# 创建在数码管上显示数字的函数
def display_number(number):
logic_list = number_dict.get(number)
if logic_list:
for i in range(len(logic_list)):
if logic_list[i] == 1:
led_list[i].value(1)
else:
led_list[i].value(0)
# 显示 0~9 十个数字
for i in range(10):
display_number(i)
time.sleep(0.5)
虽然说,咱们这个程序能够正常运行了,但是有两个小毛病,第一点就是不具备通用性,我们把所有代码都写在了同一个文件中,不够规范,尤其是声明数码管引脚对象,和显示数字的函数。如果下次实验,我依然用到了数码管,难不成我还要再把这些代码写一遍?
因此,我们要避免这样的问题,我们在 MicroPython 设备的根目录创建两个新的文件夹 common,libs:
common:存放自己写的公共常量、函数、类等等。libs:存放第三方的代码,内容与common一致,唯一的区别就是libs中的文件都不是自己手写的。
我们,在 common 目录下,创建 seg.py,并把以下代码剪切到该文件中:
from machine import Pin
# 定义不同引脚对应不同的 Pin 对象
a = Pin(4, Pin.OUT)
b = Pin(5, Pin.OUT)
c = Pin(19, Pin.OUT)
d = Pin(21, Pin.OUT)
e = Pin(22, Pin.OUT)
f = Pin(2, Pin.OUT)
g = Pin(15, Pin.OUT)
dp = Pin(18, Pin.OUT)
# 将所有引脚对象存入列表中
led_list = [a, b, c, d, e, f, g, dp]
# 把所有数字对应的逻辑电平存入字典中,逻辑值依次为 abcdefgh
number_dict = {
0: [0, 0, 0, 0, 0, 0, 1, 1],
1: [1, 0, 0, 1, 1, 1, 1, 1],
2: [0, 0, 1, 0, 0, 1, 0, 1],
3: [0, 0, 0, 0, 1, 1, 0, 1],
4: [1, 0, 0, 1, 1, 0, 0, 1],
5: [0, 1, 0, 0, 1, 0, 0, 1],
6: [0, 1, 0, 0, 0, 0, 0, 1],
7: [0, 0, 0, 1, 1, 1, 1, 1],
8: [0, 0, 0, 0, 0, 0, 0, 1],
9: [0, 0, 0, 0, 1, 0, 0, 1],
}
# 创建在数码管上显示数字的函数
def display_number(number):
if number_dict.get(number):
i = 0
for bit in number_dict.get(number):
if bit == 1:
led_list[i].value(1)
else:
led_list[i].value(0)
i += 1
把以上代码复制过来以后,我们就要明白我们这个代码的第二个毛病:变量与函数之前关联性太弱。因此,我们可以通过面向对象的方法来加强变量与函数之间的联系:
from machine import Pin
# 创建共阳型数码管对象
class Seg:
# 要求用户在调用的时候,填写所有段选管
def __init__(self, a, b, c, d, e, f, g, dp):
# 定义不同引脚对应不同的 Pin 对象
self.a = Pin(a, Pin.OUT)
self.b = Pin(b, Pin.OUT)
self.c = Pin(c, Pin.OUT)
self.d = Pin(d, Pin.OUT)
self.e = Pin(e, Pin.OUT)
self.f = Pin(f, Pin.OUT)
self.g = Pin(g, Pin.OUT)
self.dp = Pin(dp, Pin.OUT)
# 将所有引脚对象存放在 led_list 中
self.led_list = [self.a, self.b, self.c, self.d, self.e, self.f, self.g, self.dp]
# 把所有数字对应的逻辑电平存入字典中,逻辑值依次为 abcdefgh
self.number_dict = {
0: [0, 0, 0, 0, 0, 0, 1, 1],
1: [1, 0, 0, 1, 1, 1, 1, 1],
2: [0, 0, 1, 0, 0, 1, 0, 1],
3: [0, 0, 0, 0, 1, 1, 0, 1],
4: [1, 0, 0, 1, 1, 0, 0, 1],
5: [0, 1, 0, 0, 1, 0, 0, 1],
6: [0, 1, 0, 0, 0, 0, 0, 1],
7: [0, 0, 0, 1, 1, 1, 1, 1],
8: [0, 0, 0, 0, 0, 0, 0, 1],
9: [0, 0, 0, 0, 1, 0, 0, 1],
}
# 初始化所有引脚
self.clean()
def clean(self):
# 初始化状态
for i in self.led_list:
i.value(1)
def display_number(self, number):
# 显示数字
logic_list = self.number_dict.get(number)
if logic_list:
for i in range(len(logic_list)):
if logic_list[i] == 1:
self.led_list[i].value(1)
else:
self.led_list[i].value(0)
接着,我们在主文件中把该模块导入,并调用:
import time
from common.seg import Seg
if __name__ == '__main__':
# 创建共阳极数码管对象
seg_object = Seg(a=4, b= 5, c=19, d=21, e=22, f=2, g=15, dp=18)
# 显示 0 - 9
for i in range(10):
seg_object.display_number(i)
time.sleep(0.5)
4 位数码管显示
实验原理
4 位数码管,即 4 个 1 位数码管并列集中在一起形成一体的数码管。

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

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

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

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


软件程序设计
我们一步一步的来,先写一个最简单的程序,让任意一位数码管显示任意数字,代码可以这么写:
import time
from machine import Pin
# 定义位选线对象
seg_1 = Pin(5, Pin.OUT)
seg_2 = Pin(18, Pin.OUT)
seg_3 = Pin(19, Pin.OUT)
seg_4 = Pin(21, Pin.OUT)
# 定义位选线列表
seg_list = [seg_1, seg_2, seg_3, seg_4]
# 定义段选线对象
a = Pin(32, Pin.OUT)
b = Pin(25, Pin.OUT)
c = Pin(27, Pin.OUT)
d = Pin(12, Pin.OUT)
e = Pin(13, Pin.OUT)
f = Pin(33, Pin.OUT)
g = Pin(26, Pin.OUT)
dp = Pin(14, Pin.OUT)
# 定义段选线对象
led_list = [a, b, c, d, e, f, g, dp]
number_dict = {
# [a, b, c, d, e, f, g, dp]
0: [1, 1, 1, 1, 1, 1, 0, 0],
1: [0, 1, 1, 0, 0, 0, 0, 0],
2: [1, 1, 0, 1, 1, 0, 1, 0],
3: [1, 1, 1, 1, 0, 0, 1, 0],
4: [0, 1, 1, 0, 0, 1, 1, 0],
5: [1, 0, 1, 1, 0, 1, 1, 0],
6: [1, 0, 1, 1, 1, 1, 1, 0],
7: [1, 1, 1, 0, 0, 0, 0, 0],
8: [1, 1, 1, 1, 1, 1, 1, 0],
9: [1, 1, 1, 1, 0, 1, 1, 0],
}
# 清空位选线函数
def clear_seg():
# 清空所有的位选线,将所有位选线设置为高电平
for seg in seg_list:
seg.on()
# 清空段选线函数
def clear_led():
# 清空所有的段选线,将所有段选线设置为低电平
for led in led_list:
led.off()
# 清屏函数
def clear():
clear_seg()
clear_led()
# 显示数字的函数
def display_number(order, number):
# 逻辑电平列表
logic_list = number_dict.get(number)
if logic_list and 0 <= order < 4:
# 清屏
clear()
# 指定要显示的位置,把电平拉低
seg_list[order].off()
# 显示数字
for i in range(len(logic_list)):
led_list[i].value(logic_list[i])
# 第 3 位显示数字 1
# display_number(3, 1)
# 按顺序让所有位置显示0~9
for i in range(4):
for j in range(10):
display_number(i, j)
time.sleep(0.2)
我们选择多位数码管,肯定是要在不同位置显示不同数字的,这时候,我们需要用到 动态扫描。
什么是动态扫描
动态扫描是对位选端扫描,8 个引脚控制每个数码管的段选线,通过刷新位选端和 8 个引脚的状态,来实现显示不同的数字。
我们可以通过运行下面这段代码,更生动形象地理解 动态扫描 的原理:
# 定义位置管脚
seg_1 = Pin(5, Pin.OUT)
seg_2 = Pin(18, Pin.OUT)
seg_3 = Pin(19, Pin.OUT)
seg_4 = Pin(21, Pin.OUT)
seg_list = [seg_1, seg_2, seg_3, seg_4]
# 定义段选线对象
a = Pin(32, Pin.OUT)
b = Pin(25, Pin.OUT)
c = Pin(27, Pin.OUT)
d = Pin(12, Pin.OUT)
e = Pin(13, Pin.OUT)
f = Pin(33, Pin.OUT)
g = Pin(26, Pin.OUT)
dp = Pin(14, Pin.OUT)
# 将对应的引脚对象存储到列表
led_list = [a, b, c, d, e, f, g, dp]
# 共阴极数码管不同数字对应的逻辑电平
number_dict = {
# [a, b, c, d, e, f, g, dp]
0: [1, 1, 1, 1, 1, 1, 0, 0],
1: [0, 1, 1, 0, 0, 0, 0, 0],
2: [1, 1, 0, 1, 1, 0, 1, 0],
3: [1, 1, 1, 1, 0, 0, 1, 0],
4: [0, 1, 1, 0, 0, 1, 1, 0],
5: [1, 0, 1, 1, 0, 1, 1, 0],
6: [1, 0, 1, 1, 1, 1, 1, 0],
7: [1, 1, 1, 0, 0, 0, 0, 0],
8: [1, 1, 1, 1, 1, 1, 1, 0],
9: [1, 1, 1, 1, 0, 1, 1, 0],
}
# 显示数字的函数
def display_number(number):
# 逻辑电平列表
logic_list = number_dict.get(number)
if logic_list:
for i in range(len(logic_list)):
led_list[i].value(logic_list[i])
# 清空位选线函数
def clear_seg():
# 清空所有的位选线,将所有位选线设置为高电平
for seg in seg_list:
seg.on()
# 清空段选线函数
def clear_led():
# 清空所有的段选线,将所有段选线设置为低电平
for led in led_list:
led.off()
# 清空函数
def clear():
clear_seg()
clear_led()
if __name__ == '__main__':
# 清空显示内容
clear()
# 延时时间,初始为 355ms
count = 355
while True:
# seg_1 显示数字 1
clear_seg()
seg_1.off()
display_number(1)
time.sleep_ms(count)
# seg_2 显示数字 2
clear_seg()
seg_2.off()
display_number(2)
time.sleep_ms(count)
# seg_3 显示数字 3
clear_seg()
seg_3.off()
display_number(3)
time.sleep_ms(count)
# seg_4 显示数字 4
clear_seg()
seg_4.off()
display_number(4)
time.sleep_ms(count)
# 逐渐缩短延时时间
if count > 10:
if count > 110:
count -= 50
else:
count -= 10
理解了 动态扫描 的原理之后,我们就可以写代码了,先把之前写的这些代码复制过来,然后我们还需要实现通过动态扫描的方法实现 4 位数字显示的功能:
# 显示函数
def display_4_number(number):
# 获取格式化的数字列表
# 判断参数是否超过 9999
if number <= 9999:
# 获取每一位对应的数字
# # 获取第四位
# seg_4_number = number % 10
# number //= 10
# print(seg_4_number)
# # 获取第三位
# seg_3_number = number % 10
# number //= 10
# print(seg_3_number)
# # 获取第二位
# seg_2_number = number % 10
# number //= 10
# print(seg_2_number)
# # 获取第一位
# seg_1_number = number % 10
# number //= 10
# print(seg_1_number)
# 初始化每个位置对应的数字列表
number_list = []
# 使用循环的方式获取数字列表
for i in range(4):
number_list.insert(0, number % 10)
number //= 10
# 显示数字
for i in range(len(number_list)):
display_number(i, number_list[i])
time.sleep_ms(5)
这样,我们就能通过调用 display_4_number 函数,来显示数字内容了。
为了避免日后用到共阴极 4 位数码管时,需要重写此代码,我们采用面向对象的方式,把驱动代码封装成类,存放在 MicroPython 设备中的 common 目录下 four_digits_seg.py:
'''
common/four_digits_seg.py
4 位共阴极数码管公共类
上传到 MicroPython 设备中的 common 文件夹下
'''
import time
from machine import Pin
class Seg4Digit:
def __init__(self, seg_1, seg_2, seg_3, seg_4, a, b, c, d, e, f, g, dp):
# 定义位选线对象
self.seg_1 = Pin(seg_1, Pin.OUT)
self.seg_2 = Pin(seg_2, Pin.OUT)
self.seg_3 = Pin(seg_3, Pin.OUT)
self.seg_4 = Pin(seg_4, Pin.OUT)
# 定义位选线列表
self.seg_list = [self.seg_1, self.seg_2, self.seg_3, self.seg_4]
# 定义段选线对象
self.a = Pin(a, Pin.OUT)
self.b = Pin(b, Pin.OUT)
self.c = Pin(c, Pin.OUT)
self.d = Pin(d, Pin.OUT)
self.e = Pin(e, Pin.OUT)
self.f = Pin(f, Pin.OUT)
self.g = Pin(g, Pin.OUT)
self.dp = Pin(dp, Pin.OUT)
# 定义段选线对象
self.led_list = [self.a, self.b, self.c, self.d, self.e, self.f, self.g, self.dp]
self.number_dict = {
# [a, b, c, d, e, f, g, dp]
0: [1, 1, 1, 1, 1, 1, 0, 0],
1: [0, 1, 1, 0, 0, 0, 0, 0],
2: [1, 1, 0, 1, 1, 0, 1, 0],
3: [1, 1, 1, 1, 0, 0, 1, 0],
4: [0, 1, 1, 0, 0, 1, 1, 0],
5: [1, 0, 1, 1, 0, 1, 1, 0],
6: [1, 0, 1, 1, 1, 1, 1, 0],
7: [1, 1, 1, 0, 0, 0, 0, 0],
8: [1, 1, 1, 1, 1, 1, 1, 0],
9: [1, 1, 1, 1, 0, 1, 1, 0],
}
# 清屏函数
def clear(self):
# 清空所有的位选线,将所有位选线设置为高电平
for seg in self.seg_list:
seg.on()
# 清空所有的段选线,将所有段选线设置为低电平
for led in self.led_list:
led.off()
# 显示数字的函数
def display_number(self, order, number):
# 逻辑电平列表
logic_list = self.number_dict.get(number)
if logic_list and 0 <= order < 4:
# 清屏
self.clear()
# 指定要显示的位置,把电平拉低
self.seg_list[order].off()
# 显示数字
for i in range(len(logic_list)):
self.led_list[i].value(logic_list[i])
# 显示函数
def display_4_number(self, number):
# 判断参数是否超过 9999
if number <= 9999:
# 初始化每个位置对应的数字列表
number_list = []
# 使用循环的方式获取数字列表
for i in range(4):
number_list.insert(0, number % 10)
number //= 10
for i in range(len(number_list)):
self.display_number(i, number_list[i])
time.sleep_ms(5)
在主程序中,以下面几行代码的方式调用:
'''
该程序作用是使用面向对象的方法让四位数码管显示数字
在线文档:https://docs.geeksman.com/
'''
from common.four_digits_seg import Seg4Digit
if __name__ == '__main__':
# 初始化 4 位数码管对象
seg_object = Seg4Digit(seg_1=5, seg_2=18, seg_3=19, seg_4=21, a=32, b=25, c=27, d=12, e=13, f=33, g=26, dp=14)
while True:
seg_object.display_4_number(1234)
控制 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。

软件程序设计
此处我们需要使用 machine 模块中的 Pin 模块对 GPIO 输入检测。
与输出不同的是,设置输入引脚时,我们需要配置上拉或下拉电阻,目的是确定某个状态电路中的高电平或低电平。
上、下拉电阻的作用是提高电路稳定性,避免引起误动作。按键如果不通过电阻上拉到高电平,那么在上电瞬间可能就发生误动作,因为在上电瞬间单片机的引脚电平是不确定的,上拉电阻的存在保证了其引脚处于高电平状态,而不会发生误动作。
提示
如果你不认识上拉电阻和下拉电阻,在这个阶段是无所谓的,你只需要了解他们的存在是为了确定初始电平状态。 选择上拉电阻,GPIO 引脚默认位高电平,那我们想要改变信号,就需要传递一个低电平,接地。 选择下拉电阻,GPIO 引脚默认为低电平,那我们想要改变信号,就需要传递一个高电平,接电源。
因此,我们的代码需要这么写:
import time
from machine import Pin
# 创建按键输入引脚类,如果引脚的一端接 Vcc,则设置下拉电阻;如果一端接的是 GND,则配置上拉电阻。
pin_button = Pin(14, Pin.IN, Pin.PULL_DOWN)
# 定义 LED 输出引脚
pin_led = Pin(2, Pin.OUT)
# 判断 LED 的状态是否改变过
status = 0
while True:
# 按键消抖
if pin_button.value() == 1:
# 睡眠 10ms,如果依然为高电平,说明抖动已消失。
time.sleep_ms(10)
# 延时 10ms 后,如果依然为高电平,并且 LED 的状态没有改变
if pin_button.value() == 1 and status == 0:
pin_led.value(not pin_led.value())
# led 的状态发生了变化,即使我持续按着按键,LED 的状态也不应该改变。
status = 1
# 按键松开,记录 LED 状态的变量也需要响应的改变。
elif pin_button.value() == 0:
status = 0
PWM 呼吸灯实验
之前我们使用的 LED 做过流水灯的实验,这节课,我们学习制作呼吸灯,通过 LED 灯的亮度变化来验证 PWM 不同电压的输出。呼吸灯是指灯光在单片机的控制之下完成由亮到暗的逐渐变化,感觉好像是人在呼吸。
实验原理
脉冲宽度调制(PWM),是英文 Pulse Width Modulation 的缩写,简称脉宽调制,是利用微处理器的数字输出来对模拟电路进行控制的一种非常有效的技术,广泛应用在测量、通信到功率控制与变换的许多领域中。
PWM 通过调节输出不同频率(频率是指 1 秒钟内信号从高电平到低电平再回到高电平的次数(一个周期))、占空比(一个周期内高电平出现时间占总时间比例)的方波。以实现固定频率或平均电压输出。频率固定,改变占空比可改变输出电压,如下所示:

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

注意
一定要接电阻,不然会由于电流过大,烧坏 LED。
软件程序设计
PWM 可以通过 ESP32 所有 GPIO 引脚输出。所有通道都有 1 个特定的频率,从 1 到 40M 之间(单位是 Hz)。占空比的值为 0 至 1023 之间。
PWM 在 machine 的 PWM 模块中,我们也是只需要了解其构造对象函数和使用方法:
构造函数 machine.PWM(dest, freq, duty, duty_u16, duty_ns),使用以下参数构造并返回一个新的 PWM 对象:
dest是输出 PWM 的实体,通常是 machine.Pin 对象;freq应该是一个整数,用于设置 PWM 周期的频率(以 Hz 为单位);duty占空比,范围是 0 - 1023;duty_u16占空比,范围是 0 - 65535,2 的 16 次方;duty_ns以纳秒为单位设置脉冲宽度,范围是 0 - 50000。
使用方法:
from machine import Pin, PWM
# 从1个引脚中创建PWM对象
led = PWM(Pin(12), freq=20000, duty=512)
# 获取当前频率
led.freq()
# 设置频率
led.freq(1000)
# 获取当前占空比
led.duty()
# 设置占空比
led.duty(200)
# 使用 duty_u16 方法
led.duty_u16(12345)
# 使用 duty_ns 方法
led.duty_ns()
# 关闭引脚的 PWM
led.deinit()
因此,我们的代码可以这么写:
import time
from machine import Pin, PWM
# 创建 LED 控制对象
led = PWM(Pin(12), freq=1000)
while True:
# 渐亮
for i in range(0, 1024):
led.duty(i)
time.sleep_ms(1)
# 渐暗
for i in range(1023, 0, -1):
led.duty(i)
time.sleep_ms(1)
舵机实验
舵机在电子产品中非常常见,比如四足机器人、固定翼航模等都有应用,因此学习舵机对后续完成电子制作非常有意义。本节课学习使用 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 |

注意
注意接线顺序
软件程序设计
上节课我们已经学习了 MicroPython 的 PWM 构造函数和方法,这节课依然可以 PWM。根据实验原理,我们可以直接操作 PWM 值来控制舵机的转动角度,代码如下:
'''
该程序作用是使用 PWM 模块控制舵机转动
在线文档:https://docs.geeksman.com/
'''
import time
from machine import Pin, PWM
# 定义舵机控制对象
my_servo = PWM(Pin(13))
# 定义舵机频率
my_servo.freq(50)
# 使用不同方法控制转动角度
# 使用 duty() 方法转动到 0°,duty 方法的范围是 0-1023,
# 因此,参数值为 1023 // 20 * 0.5 取整等于 25
my_servo.duty(25)
time.sleep(2)
# 使用 duty_u16() 方法转动到 90°,duty_u16 方法的范围是 0-65535,
# 因此,参数值为 65535 // 20 * 1.5 取整等于 4915
my_servo.duty_u16(int(65535//20*2.5))
time.sleep(2)
了解了如何驱动舵机模块了还不够,我们也可以像封装数码管驱动代码一样,自己写一个舵机驱动代码,像舵机这种常用的模块。
MicroPython 拥有着庞大的用户群,自然舵机模块也有开源的代码,直接拿过来使用即可,这就是使用 MicroPython 开发的高效之处,市面上常见的模块在网上几乎都可以找到相应的模块代码,大家一定要善于在网上搜索资源。
我们可以把从网上下载来的代码放到 MicroPython 设备中的 libs 目录下(libs 存放第三方库,common 存放自己写的常用变量,函数,类等等),代码如下:
from machine import PWM
import math
# originally by Radomir Dopieralski http://sheep.art.pl
# from https://bitbucket.org/thesheep/micropython-servo
class Servo:
"""
A simple class for controlling hobby servos.
Args:
pin (machine.Pin): The pin where servo is connected. Must support PWM.
freq (int): The frequency of the signal, in hertz.
min_us (int): The minimum signal length supported by the servo.
max_us (int): The maximum signal length supported by the servo.
angle (int): The angle between the minimum and maximum positions.
"""
def __init__(self, pin, freq=50, min_us=600, max_us=2400, angle=180):
self.min_us = min_us
self.max_us = max_us
self.us = 0
self.freq = freq
self.angle = angle
self.pwm = PWM(pin, freq=freq, duty=0)
def write_us(self, us):
"""Set the signal to be ``us`` microseconds long. Zero disables it."""
if us == 0:
self.pwm.duty(0)
return
us = min(self.max_us, max(self.min_us, us))
duty = us * 1024 * self.freq // 1000000
self.pwm.duty(duty)
def write_angle(self, degrees=None, radians=None):
"""Move to the specified angle in ``degrees`` or ``radians``."""
if degrees is None:
degrees = math.degrees(radians)
degrees = degrees % 360
total_range = self.max_us - self.min_us
us = self.min_us + total_range * degrees // self.angle
self.write_us(us)
接着在主程序中调用 servo.py 模块中的内容:
'''
该程序作用是使用 PWM 模块控制舵机转动
在线文档:https://docs.geeksman.com/
'''
import time
from machine import Pin
from libs.servo import Servo
# 定义舵机控制对象
my_servo = Servo(Pin(13), max_us=2500)
# 程序入口
if __name__ == '__main__':
while True:
my_servo.write_angle(0)
time.sleep(0.5)
my_servo.write_angle(45)
time.sleep(0.5)
my_servo.write_angle(90)
time.sleep(0.5)
my_servo.write_angle(135)
time.sleep(0.5)
my_servo.write_angle(180)
time.sleep(0.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 |
| 杜邦线 | 若干 |
电位器相当于一个滑动变阻器,两端引脚阻值是固定的,中间引脚对任何一端的引脚阻值是可变的,他等效于从中间把电位器分成两个串联的电阻,串联总阻值是确定的,一端接输入电源,一端接地

软件程序设计
ADC 在 machine 的 ADC 模块中,我们也是只需要了解其构造对象函数和使用方法即可。
构造方法 machine.ADC(pin, attn):
- 参数
pin:Pin 对象, - 参数
attn:配置衰减器增加电压测量范围。
使用方法:
import time
from machine import Pin, ADC
# 在 26 引脚创建 ADC 对象
adc = ADC(Pin(26))
# 获取 ADC 值
# 测量精度为 12 位,返回 0-4095(表示 0-1V)
val = adc.read()
# 返回 0-65535 范围内的整数。返回值表示 ADC 获取的原始读数,按比例缩放,最小值为 0,最大值为 65535。
val_u16 = adc.read_u16()
# 配置衰减器,能增加电压测量范围,参数可以是 ADC_ATTN_0DB(0dB 衰减,最大输出电压为 1 V, 默认配置)
# ADC_ATTN_2_5DB(2.5dB 衰减,最大输出电压为 1.34 V)、
# ADC_ATTN_6DB(6dB 衰减,最大输出电压为 2 V)、
# ADC_ATTN_11DB(11dB 衰减,最大输出电压为 3.3 V)
adc.atten(ADC.ATTN_11DB)
因此,在 shell 环境中打印出当前值的程序可以这么写:
import time
from machine import Pin, ADC
# 在 26 引脚创建 ADC 对象
adc = ADC(Pin(26))
# 配置衰减器,配置测量量程为 3.3V
adc.atten(ADC.ATTN_11DB)
while True:
print(f'adc:{adc.read()}')
time.sleep(0.1)
我们可以在 shell 控制台,鼠标右击,选择 显示绘图器,他可以把 shell 中输出的数字可视化,这样就可以看到 adc 的图像变化:

结合上一节课学习的 PWM,我们可以实现用电位器控制 LED 的亮灭,代码如下:
import time
from machine import Pin, ADC, PWM
# 在 26 引脚创建 ADC 对象, 并配置衰减器,配置测量量程为3.3V
adc = ADC(Pin(26, Pin.IN),atten=ADC.ATTN_11DB)
# 创建 LED PWM 控制对象
led = PWM(Pin(13, Pin.OUT), freq=1000)
while True:
led.duty_u16(adc.read_u16())
time.sleep(0.1)
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 会无法显示或者显示很暗。
我们先把开发板与 LCD1602 相连,然后上电,但是,我们会看到 Shell 环境会显示我们的设备在无限重启。

这是因为,我们用到了 ESP32 的 strapping 引脚(和芯片复位状态有关的引脚),strapping 引脚包括:
- GPIO 0
- GPIO 2
- GPIO 4
- GPIO 5 (启动时必须为高电平)
- GPIO 12 (启动时必须为低电平)
- GPIO 15 (启动时必须为高电平)
在硬件上要注意使用外接模块时不能将 GPIO12 拉高,否则将导致 ESP32 启动异常,而 I2C 总线空闲时两条线都是高电平。
此外,还有具有特定功能的管脚,使它们适合或不适合特定项目。下表显示了哪些管脚最适合用作输入和输出,哪些管脚需要小心。绿色突出显示的管脚可以使用。黄色突出显示的可以使用,但需要注意,因为它们可能在启动时有意外行为。不建议将红色突出显示的管脚用作输入或输出。

所以,我们把 Pin12 改为 Pin14,单片机上电就不会再重置了。

软件程序设计
SoftI2C 与 I2C 区别
下图标注的就是我们常常所说的 I2C 引脚接口,这里的接口指的就是硬件 I2C 接口,我们在软件中仅用 I2C 表示即可。

特点:
- I2C(硬件 I2C)是由相应的 I2C 驱动电路,其使用的 I2C 管脚也是专用的。SoftI2C 其接口比较灵活,不受管脚限制;
- SoftI2C(软件 I2C)一般是由电路中常见的 GPIO 管脚所组成,使用软件来控制管脚状态用以模仿 I2C(硬件 I2C)进行通信;
- I2C(硬件 I2C)效率远高于 SoftI2C。
SoftI2C 适用于所有支持输出的引脚,并通过 machine.SoftI2C 类访问:
构造函数 machine.SoftI2C(scl, sda, freq=400000, timeout=255): 构造一个新的 SoftI2C 对象。
scl应该是一个 pin 对象,指定用于 SCL 的 pin。sda应该是一个 pin 对象,指定用于 SDA 的 pin。freq应该是一个整数,用于设置 SCL 的最大频率。timeout是等待时钟延长(SCL 被总线上的另一个设备保持为低电平)的最长时间(以微秒为单位),之后会引发OSError(ETIMEDOUT) 异常。
构造函数 machine.I2C(id, scl, sda, freq=400000, timeout=255): 构造一个新的硬件 I2C 对象。
id是 0、1,表示默认的 I2C 引脚,0 表示 scl=Pin(18), sad=Pin(19); 1 表示 scl=Pin(25), sad=Pin(26),注意:不能与 scl、sda 共用;scl应该是一个 pin 对象,指定用于 SCL 的 pin。sda应该是一个 pin 对象,指定用于 SDA 的 pin。freq应该是一个整数,用于设置 SCL 的最大频率。timeout是等待时钟延长(SCL 被总线上的另一个设备保持为低电平)的最长时间(以微秒为单位),之后会引发OSError(ETIMEDOUT) 异常。
使用方法如下:
from machine import Pin, I2C
i2c = SoftI2C(scl=Pin(12), sda=Pin(13), freq=100000)
# 扫描设备,I2C 协议
i2c.scan()
# read 4 bytes from device with address 0x3a
i2c.readfrom(0x27, 4)
# write '12' to device with address 0x3a
i2c.writeto(0x27, '12')
# create a buffer with 10 bytes
buf = bytearray(10)
# write the given buffer to the peripheral
i2c.writeto(0x27, buf)
打印 Hello world
我们之前在做数码管实验和舵机实验的时候是不是都用过驱动代码,LCD1602 和舵机一样,都是很常用的模块,因此,在开源社区中也有相关的代码,我们可以自己找到下载下来,或者,把我们网站上准备好的的代码复制到 MicroPython 设备中,这个属于第三方代码对不对,所以我们把它放到 libs 目录下。
把下面代码放到 libs 目录下的 lcd_api.py 中,
'''libs/lcd_api.py'''
import time
class LcdApi:
# Implements the API for talking with HD44780 compatible character LCDs.
# This class only knows what commands to send to the LCD, and not how to get
# them to the LCD.
#
# It is expected that a derived class will implement the hal_xxx functions.
#
# The following constant names were lifted from the avrlib lcd.h header file,
# with bit numbers changed to bit masks.
# HD44780 LCD controller command set
LCD_CLR = 0x01 # DB0: clear display
LCD_HOME = 0x02 # DB1: return to home position
LCD_ENTRY_MODE = 0x04 # DB2: set entry mode
LCD_ENTRY_INC = 0x02 # DB1: increment
LCD_ENTRY_SHIFT = 0x01 # DB0: shift
LCD_ON_CTRL = 0x08 # DB3: turn lcd/cursor on
LCD_ON_DISPLAY = 0x04 # DB2: turn display on
LCD_ON_CURSOR = 0x02 # DB1: turn cursor on
LCD_ON_BLINK = 0x01 # DB0: blinking cursor
LCD_MOVE = 0x10 # DB4: move cursor/display
LCD_MOVE_DISP = 0x08 # DB3: move display (0-> move cursor)
LCD_MOVE_RIGHT = 0x04 # DB2: move right (0-> left)
LCD_FUNCTION = 0x20 # DB5: function set
LCD_FUNCTION_8BIT = 0x10 # DB4: set 8BIT mode (0->4BIT mode)
LCD_FUNCTION_2LINES = 0x08 # DB3: two lines (0->one line)
LCD_FUNCTION_10DOTS = 0x04 # DB2: 5x10 font (0->5x7 font)
LCD_FUNCTION_RESET = 0x30 # See "Initializing by Instruction" section
LCD_CGRAM = 0x40 # DB6: set CG RAM address
LCD_DDRAM = 0x80 # DB7: set DD RAM address
LCD_RS_CMD = 0
LCD_RS_DATA = 1
LCD_RW_WRITE = 0
LCD_RW_READ = 1
def __init__(self, num_lines, num_columns):
self.num_lines = num_lines
if self.num_lines > 4:
self.num_lines = 4
self.num_columns = num_columns
if self.num_columns > 40:
self.num_columns = 40
self.cursor_x = 0
self.cursor_y = 0
self.implied_newline = False
self.backlight = True
self.display_off()
self.backlight_on()
self.clear()
self.hal_write_command(self.LCD_ENTRY_MODE | self.LCD_ENTRY_INC)
self.hide_cursor()
self.display_on()
def clear(self):
# Clears the LCD display and moves the cursor to the top left corner
self.hal_write_command(self.LCD_CLR)
self.hal_write_command(self.LCD_HOME)
self.cursor_x = 0
self.cursor_y = 0
def show_cursor(self):
# Causes the cursor to be made visible
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY |
self.LCD_ON_CURSOR)
def hide_cursor(self):
# Causes the cursor to be hidden
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY)
def blink_cursor_on(self):
# Turns on the cursor, and makes it blink
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY |
self.LCD_ON_CURSOR | self.LCD_ON_BLINK)
def blink_cursor_off(self):
# Turns on the cursor, and makes it no blink (i.e. be solid)
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY |
self.LCD_ON_CURSOR)
def display_on(self):
# Turns on (i.e. unblanks) the LCD
self.hal_write_command(self.LCD_ON_CTRL | self.LCD_ON_DISPLAY)
def display_off(self):
# Turns off (i.e. blanks) the LCD
self.hal_write_command(self.LCD_ON_CTRL)
def backlight_on(self):
# Turns the backlight on.
# This isn't really an LCD command, but some modules have backlight
# controls, so this allows the hal to pass through the command.
self.backlight = True
self.hal_backlight_on()
def backlight_off(self):
# Turns the backlight off.
# This isn't really an LCD command, but some modules have backlight
# controls, so this allows the hal to pass through the command.
self.backlight = False
self.hal_backlight_off()
def move_to(self, cursor_x, cursor_y):
# Moves the cursor position to the indicated position. The cursor
# position is zero based (i.e. cursor_x == 0 indicates first column).
self.cursor_x = cursor_x
self.cursor_y = cursor_y
addr = cursor_x & 0x3f
if cursor_y & 1:
addr += 0x40 # Lines 1 & 3 add 0x40
if cursor_y & 2: # Lines 2 & 3 add number of columns
addr += self.num_columns
self.hal_write_command(self.LCD_DDRAM | addr)
def putchar(self, char):
# Writes the indicated character to the LCD at the current cursor
# position, and advances the cursor by one position.
if char == '\n':
if self.implied_newline:
# self.implied_newline means we advanced due to a wraparound,
# so if we get a newline right after that we ignore it.
pass
else:
self.cursor_x = self.num_columns
else:
self.hal_write_data(ord(char))
self.cursor_x += 1
if self.cursor_x >= self.num_columns:
self.cursor_x = 0
self.cursor_y += 1
self.implied_newline = (char != '\n')
if self.cursor_y >= self.num_lines:
self.cursor_y = 0
self.move_to(self.cursor_x, self.cursor_y)
def putstr(self, string):
# Write the indicated string to the LCD at the current cursor
# position and advances the cursor position appropriately.
for char in string:
self.putchar(char)
def custom_char(self, location, charmap):
# Write a character to one of the 8 CGRAM locations, available
# as chr(0) through chr(7).
location &= 0x7
self.hal_write_command(self.LCD_CGRAM | (location << 3))
self.hal_sleep_us(40)
for i in range(8):
self.hal_write_data(charmap[i])
self.hal_sleep_us(40)
self.move_to(self.cursor_x, self.cursor_y)
def hal_backlight_on(self):
# Allows the hal layer to turn the backlight on.
# If desired, a derived HAL class will implement this function.
pass
def hal_backlight_off(self):
# Allows the hal layer to turn the backlight off.
# If desired, a derived HAL class will implement this function.
pass
def hal_write_command(self, cmd):
# Write a command to the LCD.
# It is expected that a derived HAL class will implement this function.
raise NotImplementedError
def hal_write_data(self, data):
# Write data to the LCD.
# It is expected that a derived HAL class will implement this function.
raise NotImplementedError
def hal_sleep_us(self, usecs):
# Sleep for some time (given in microseconds)
time.sleep_us(usecs)
把下面代码放到 libs 目录下的 i2c_lcd.py 中,
'''libs/i2c_lcd.py'''
import utime
from libs.lcd_api import LcdApi
from machine import I2C
# PCF8574 pin definitions
MASK_RS = 0x01 # P0
MASK_RW = 0x02 # P1
MASK_E = 0x04 # P2
SHIFT_BACKLIGHT = 3 # P3
SHIFT_DATA = 4 # P4-P7
class I2cLcd(LcdApi):
# Implements a HD44780 character LCD connected via PCF8574 on I2C
def __init__(self, i2c, i2c_addr, num_lines, num_columns):
self.i2c = i2c
self.i2c_addr = i2c_addr
self.i2c.writeto(self.i2c_addr, bytes([0]))
utime.sleep_ms(20) # Allow LCD time to powerup
# Send reset 3 times
self.hal_write_init_nibble(self.LCD_FUNCTION_RESET)
utime.sleep_ms(5) # Need to delay at least 4.1 msec
self.hal_write_init_nibble(self.LCD_FUNCTION_RESET)
utime.sleep_ms(1)
self.hal_write_init_nibble(self.LCD_FUNCTION_RESET)
utime.sleep_ms(1)
# Put LCD into 4-bit mode
self.hal_write_init_nibble(self.LCD_FUNCTION)
utime.sleep_ms(1)
LcdApi.__init__(self, num_lines, num_columns)
cmd = self.LCD_FUNCTION
if num_lines > 1:
cmd |= self.LCD_FUNCTION_2LINES
self.hal_write_command(cmd)
def hal_write_init_nibble(self, nibble):
# Writes an initialization nibble to the LCD.
# This particular function is only used during initialization.
byte = ((nibble >> 4) & 0x0f) << SHIFT_DATA
self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E]))
self.i2c.writeto(self.i2c_addr, bytes([byte]))
def hal_backlight_on(self):
# Allows the hal layer to turn the backlight on
self.i2c.writeto(self.i2c_addr, bytes([1 << SHIFT_BACKLIGHT]))
def hal_backlight_off(self):
# Allows the hal layer to turn the backlight off
self.i2c.writeto(self.i2c_addr, bytes([0]))
def hal_write_command(self, cmd):
# Write a command to the LCD. Data is latched on the falling edge of E.
byte = ((self.backlight << SHIFT_BACKLIGHT) |
(((cmd >> 4) & 0x0f) << SHIFT_DATA))
self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E]))
self.i2c.writeto(self.i2c_addr, bytes([byte]))
byte = ((self.backlight << SHIFT_BACKLIGHT) |
((cmd & 0x0f) << SHIFT_DATA))
self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E]))
self.i2c.writeto(self.i2c_addr, bytes([byte]))
if cmd <= 3:
# The home and clear commands require a worst case delay of 4.1 msec
utime.sleep_ms(5)
def hal_write_data(self, data):
# Write data to the LCD. Data is latched on the falling edge of E.
byte = (MASK_RS |
(self.backlight << SHIFT_BACKLIGHT) |
(((data >> 4) & 0x0f) << SHIFT_DATA))
self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E]))
self.i2c.writeto(self.i2c_addr, bytes([byte]))
byte = (MASK_RS |
(self.backlight << SHIFT_BACKLIGHT) |
((data & 0x0f) << SHIFT_DATA))
self.i2c.writeto(self.i2c_addr, bytes([byte | MASK_E]))
self.i2c.writeto(self.i2c_addr, bytes([byte]))
第三方模块导入完成后,我们就可以写主程序了。
我们知道硬件 I2C 和软件 I2C 的区别在于,软件 I2C 是通过软件编程使 CPU 拉高拉低 SDA 和 SCL 引脚,模拟出 I2C 总线的;而硬件 I2C 则是使用 ESP32 内部的 I2C 硬件驱动器实现总线的读写。
很明显的,硬件 I2C 比软件 I2C 更加节约 CPU 资源,因为 CPU 不用去频繁操作 SDA 和 SCL 引脚了。如果你操作屏幕频繁,硬件 I2C 将是你最佳的选择。
如果我们使用硬件 I2C 的话,在 shell 中会跳出提示 Warning: I2C(-1, ...) is deprecated, use SoftI2C(...) instead,意思不建议你使用 I2C,建议你使用 SoftI2C,所以我们的代码还是使用软件 I2C 总线吧。
from machine import Pin, SoftI2C, I2C
from libs.i2c_lcd import I2cLcd
# 定义 SoftI2C 控制对象
i2c = SoftI2C(sda=Pin(13), scl=Pin(14), freq=100000)
# 获取 I2C 从机地址
address = i2c.scan()[0]
# 定义 I2CLCD 对象
i2c_lcd = I2cLcd(i2c, address, 2, 16)
# 显示 Hello world
i2c_lcd.putstr('Hello, world!')
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 |
| 杜邦线(跳线) | 若干 |

软件程序设计
SoftSPI 与 SPI
SPI 和 SoftSPI 与 I2C 和 SoftI2C 的基本上都一样,SPI 指硬件自带的外设功能,SoftSPI 指使用硬件上的 I/O 口模拟 SPI 接口,以实现 SPI 功能。
特点:
- 相比于 SPI 来说,SoftSPI 占用的 MCU 资源较多,速度相比于 SPI 来说比较慢
- SPI 发送数据和传送数据,不需要MCU进行处理,是由硬件进行处理。
- 使用 SoftSPI 可以在不同的处理器或者不同架构间进行代码的移植,代码通用性强。

构造函数:
machine.SoftSPI(baudrate=500000, polarity=0, phase=0, bits=8, firstbit=MSB, sck=None, mosi=None, miso=None):构造一个新的软件 SPI 对象。必须给出额外的参数,通常至少是 sck、mosi 和 miso,这些用于初始化 I2C 总线。baudrate:SPI 通讯速率,也就是 SCK 引脚上的频率,SPI 并没有规定最高速度,通讯速率完全是由通信双方的能力所决定。在实际使用情况中,需要根据通讯双方的数据手册和实际情况来调整通讯速率。polarity:时钟极性(为 1 或者 0),若为 0 则总线空闲时 SCK 输出低电平,反之则输出高电平;phase:时钟相位(为 1 或者 0),若为 0 则在第一个时钟边缘捕获数据,反之则在第二个时钟边缘捕获数据;bits:每次传输的数据位数;firstbit:先传输高位还是低位;sck/mosi/miso:均为 SPI 使用的引脚,应为 Pin 对象。
machine.SPI(id, ...): 在给定的 SPI 通道(id)上构造一个 SPI 对象。在没有附加参数的情况下,SPI 对象被创建但不初始化(它具有总线上次初始化的设置,如果有的话)。如果给出了额外的参数,则总线被初始化。id:使用的 SPI 通道,可为 1 或者 2,通常用于选择硬件 HSPI、VSPI(HSPI、VSPI 是一样的,只不过是换个名字用于区分)等;- 其余参数与 SoftSPI 一致。
| HSPI(id = 1) | VSPI(id = 2) | |
|---|---|---|
| SCK | 14 | 18 |
| MOSI | 13 | 23 |
| MISO | 12 | 19 |
将 ssd1306.py 驱动文件上传到 ESP32 中的 libs 目录下
# MicroPython SSD1306 OLED driver, I2C and SPI interfaces Modified by Bigrich-Luo
import time
import framebuf
# register definitions
SET_CONTRAST = const(0x81)
SET_ENTIRE_ON = const(0xa4)
SET_NORM_INV = const(0xa6)
SET_DISP = const(0xae)
SET_MEM_ADDR = const(0x20)
SET_COL_ADDR = const(0x21)
SET_PAGE_ADDR = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP = const(0xa0)
SET_MUX_RATIO = const(0xa8)
SET_COM_OUT_DIR = const(0xc0)
SET_DISP_OFFSET = const(0xd3)
SET_COM_PIN_CFG = const(0xda)
SET_DISP_CLK_DIV = const(0xd5)
SET_PRECHARGE = const(0xd9)
SET_VCOM_DESEL = const(0xdb)
SET_CHARGE_PUMP = const(0x8d)
class SSD1306:
def __init__(self, width, height, external_vcc):
self.width = width
self.height = height
self.external_vcc = external_vcc
self.pages = self.height // 8
# Note the subclass must initialize self.framebuf to a framebuffer.
# This is necessary because the underlying data buffer is different
# between I2C and SPI implementations (I2C needs an extra byte).
self.poweron()
self.init_display()
def init_display(self):
for cmd in (
SET_DISP | 0x00, # off
# address setting
SET_MEM_ADDR, 0x00, # horizontal
# resolution and layout
SET_DISP_START_LINE | 0x00,
SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0
SET_MUX_RATIO, self.height - 1,
SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0
SET_DISP_OFFSET, 0x00,
SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12,
# timing and driving scheme
SET_DISP_CLK_DIV, 0x80,
SET_PRECHARGE, 0x22 if self.external_vcc else 0xf1,
SET_VCOM_DESEL, 0x30, # 0.83*Vcc
# display
SET_CONTRAST, 0xff, # maximum
SET_ENTIRE_ON, # output follows RAM contents
SET_NORM_INV, # not inverted
# charge pump
SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14,
SET_DISP | 0x01): # on
self.write_cmd(cmd)
self.fill(0)
self.show()
def poweroff(self):
self.write_cmd(SET_DISP | 0x00)
def contrast(self, contrast):
self.write_cmd(SET_CONTRAST)
self.write_cmd(contrast)
def invert(self, invert):
self.write_cmd(SET_NORM_INV | (invert & 1))
def show(self):
x0 = 0
x1 = self.width - 1
if self.width == 64:
# displays with width of 64 pixels are shifted by 32
x0 += 32
x1 += 32
self.write_cmd(SET_COL_ADDR)
self.write_cmd(x0)
self.write_cmd(x1)
self.write_cmd(SET_PAGE_ADDR)
self.write_cmd(0)
self.write_cmd(self.pages - 1)
self.write_framebuf()
def fill(self, col):
self.framebuf.fill(col)
def pixel(self, x, y, col):
self.framebuf.pixel(x, y, col)
def scroll(self, dx, dy):
self.framebuf.scroll(dx, dy)
def text(self, string, x, y, col=1):
self.framebuf.text(string, x, y, col)
class SSD1306_I2C(SSD1306):
def __init__(self, width, height, i2c, addr=0x3c, external_vcc=False):
self.i2c = i2c
self.addr = addr
self.temp = bytearray(2)
# Add an extra byte to the data buffer to hold an I2C data/command byte
# to use hardware-compatible I2C transactions. A memoryview of the
# buffer is used to mask this byte from the framebuffer operations
# (without a major memory hit as memoryview doesn't copy to a separate
# buffer).
self.buffer = bytearray(((height // 8) * width) + 1)
self.buffer[0] = 0x40 # Set first byte of data buffer to Co=0, D/C=1
self.framebuf = framebuf.FrameBuffer1(memoryview(self.buffer)[1:], width, height)
super().__init__(width, height, external_vcc)
def write_cmd(self, cmd):
self.temp[0] = 0x80 # Co=1, D/C#=0
self.temp[1] = cmd
self.i2c.writeto(self.addr, self.temp)
def write_framebuf(self):
# Blast out the frame buffer using a single I2C transaction to support
# hardware I2C interfaces.
self.i2c.writeto(self.addr, self.buffer)
def poweron(self):
pass
class SSD1306_SPI(SSD1306):
def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
self.rate = 10 * 1024 * 1024
dc.init(dc.OUT, value=0)
res.init(res.OUT, value=0)
cs.init(cs.OUT, value=1)
self.spi = spi
self.dc = dc
self.res = res
self.cs = cs
self.buffer = bytearray((height // 8) * width)
self.framebuf = framebuf.FrameBuffer1(self.buffer, width, height)
super().__init__(width, height, external_vcc)
def write_cmd(self, cmd):
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
self.cs.on()
self.dc.off()
self.cs.off()
self.spi.write(bytearray([cmd]))
self.cs.on()
def write_framebuf(self):
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
self.cs.on()
self.dc.on()
self.cs.off()
self.spi.write(self.buffer)
self.cs.on()
def poweron(self):
self.res.on()
time.sleep_ms(1)
self.res.off()
time.sleep_ms(10)
self.res.on()
上传了 ssd1306.py 文件后,我们就可以敲代码了。
1. 显示文本
from machine import Pin, SoftSPI
from libs.ssd1306 import SSD1306_SPI
# 定义对应的管脚对象
spi = SoftSPI(sck=Pin(18), mosi=Pin(13), miso=Pin(19))
# 创建 OLED 对象
oled = SSD1306_SPI(width=128, height=64, spi=spi, dc=Pin(2),
res=Pin(15), cs=Pin(4))
# 清屏
oled.fill(0)
# 画点
# oled.pixel(30, 30, 1)
# oled.pixel(30, 31, 1)
# oled.pixel(30, 32, 1)
# oled.pixel(30, 33, 1)
# oled.pixel(30, 34, 1)
# oled.pixel(30, 35, 1)
# 画方块
# for x in range(30, 61):
# for y in range(30, 61):
# oled.pixel(x, y, 1)
# 打印 Hello world 在屏幕上
oled.text('Hello, world!', 10, 38)
# 显示内容
oled.show()
2. 显示中文
如果你想要在屏幕上显示中文,有两种方法:
- 使用中文字体库,想要使用中文字体库需要烧录支持中文字体库的固件,但是字库文件较大;
- 使用取模软件,对用到的字体进行取模;
我们这个实验的屏幕很小,用来看电子书的话确实有点费眼,而且,我们的主要目的是做一个菜单,用不到太多汉字,因此,我们选择使用取模软件显示中文。在我们资料包中的 3.开发工具 中的 PCtoLCD2002。

打开 PCtoLCD2002.exe,点击设置,按照下图设置

之所以要按照上图方式设置的原因:
点阵格式:设置为阴码,阴码是亮点为 1,阳马是亮点为 0,我们的实验主要是在屏幕背景为暗点(亮点为 0)的情况下进行的,因此,选择阴码;取模方式:选择行列式,点阵逻辑从上向下变化;取模走向:选择顺向,更符合日常生活的数学逻辑;输出数制:选择十六进制,其实无所谓,如果这里选择十六进制,自定义格式的时候数据前缀输入0x,十进制则什么也不填,因为最后都要转换成二进制数。
之后,我们就可以输入自己想要生成的字模,点击 生成字模

为什么要转换成二进制数?这就涉及到了 OLED 屏幕的显示原理,屏幕相当于有无数个很小的 LED 阵列组成,而我们设置了字宽和字高均为 16,也就是说,我们要在这个 16*16 的点阵上显示我们想要的图案,比如下图,

图中我们可以看到英文字母只占到了汉字一半的空间,这是因为英文字母,符号,数字这些通用字符都是半角(一字符占用一个标准的字符位置),汉字是全角(一个字符占用两个标准字符位置)。因此,我们显示的汉字也是分开显示的,这也是为什么生成的字模中,G 只输出了一行,而极占了两行。
行列式的显示逻辑是从第一行开始向右取 8 个点作为一个字节,然后从第二行开始向右取 8 个点作为第二个字节...依此类推。生成的这些十六进制数,每一个都表示这一行 8 个 点的逻辑状态,比如 0x10,转换成二进制就是 0b10000,如果不足 8 位,我们就在他的前面补 0,0b10000 与 0b00010000 对计算机来说并没有区别,代码如下:
num_list = [0x10,0x13,0x10,0x10,0xFC,0x10,0x30,0x38,0x55,0x55,0x91,0x11,0x12,0x12,0x14,0x11]
for num in num_list:
# 十六进制转二进制,通过 replace 方法去除 0b 前缀
num_binary = bin(num).replace('0b', '')
# 补 0
while len(num_binary) < 8:
num_binary = '0' + num_binary
print(num_binary)
这样,我们就可以很清楚的看出来 极 字的左半边部分:

所以,如果我们想要显示右半边的话,就需要保持竖轴不变,横轴向右移动 8 个像素,之后使用 pixel() 方法把这些点显示在屏幕上,那我们的程序就可以这么写:
from machine import Pin, SoftSPI
from libs.ssd1306 import SSD1306_SPI
# 定义对应的管脚对象
spi = SoftSPI(sck=Pin(18), mosi=Pin(13), miso=Pin(19))
# 创建 OLED 对象
oled = SSD1306_SPI(width=128, height=64, spi=spi, dc=Pin(2),
res=Pin(15), cs=Pin(4))
# 清屏
oled.fill(0)
# 定义坐标
x = 30
y = 20
# 汉字字典
character_dict = {
'极': [0x10,0x13,0x10,0x10,0xFC,0x10,0x30,0x38,0x55,0x55,0x91,0x11,0x12,0x12,0x14,0x11,
0x00,0xFC,0x84,0x88,0x88,0x90,0x9C,0x84,0x44,0x44,0x28,0x28,0x10,0x28,0x44,0x82],
'客': [0x02,0x01,0x7F,0x40,0x88,0x0F,0x10,0x2C,0x03,0x1C,0xE0,0x1F,0x10,0x10,0x1F,0x10,
0x00,0x00,0xFE,0x02,0x04,0xF0,0x20,0x40,0x80,0x70,0x0E,0xF0,0x10,0x10,0xF0,0x10],
'侠': [0x08,0x08,0x08,0x17,0x10,0x32,0x31,0x50,0x9F,0x10,0x10,0x11,0x11,0x12,0x14,0x18,
0x40,0x40,0x40,0xFC,0x40,0x48,0x50,0x40,0xFE,0xA0,0xA0,0x10,0x10,0x08,0x04,0x02],#侠2
'实': [0x02,0x01,0x7F,0x40,0x88,0x04,0x04,0x10,0x08,0x08,0xFF,0x01,0x02,0x04,0x18,0x60,
0x00,0x00,0xFE,0x02,0x84,0x80,0x80,0x80,0x80,0x80,0xFE,0x40,0x20,0x10,0x08,0x04],#实3
'验': [0x00,0xF8,0x08,0x48,0x48,0x49,0x4A,0x7C,0x04,0x04,0x1D,0xE4,0x44,0x04,0x2B,0x10,
0x20,0x20,0x50,0x50,0x88,0x04,0xFA,0x00,0x44,0x24,0x24,0xA8,0x88,0x10,0xFE,0x00],#验4
'室': [0x02,0x01,0x7F,0x40,0x80,0x3F,0x04,0x08,0x1F,0x01,0x01,0x3F,0x01,0x01,0xFF,0x00,
0x00,0x00,0xFE,0x02,0x04,0xF8,0x00,0x20,0xF0,0x10,0x00,0xF8,0x00,0x00,0xFE,0x00],#室5
}
def display_zh_character(character, x, y):
num_list = character_dict[character]
for i in range(16):
left = bin(num_list[i]).replace('0b', '')
right = bin(num_list[i + 16]).replace('0b', '')
# 补 0
while len(left) < 8:
left = '0' + left
while len(right) < 8:
right = '0' + right
num_binary = left+right
for j in range(len(num_binary)):
oled.pixel(x + j, y + i, int(num_binary[j]))
def display_zh(text, x, y):
for i in range(len(text)):
display_zh_character(text[i], x + i * 16, y)
display_zh('极客侠实验室', 30, 20)
oled.show()
你也可以使用面向对象的方法,通过继承 SSD1306_SPI 的方式,把 character_dict、display_zh_char、display_zh 变成 SSD1306_SPI 子类的属性和方法。如果你想在屏幕上显示图片也是这个原理。
3. 按键控制菜单
在搞清楚 OLED 显示方法之后,我们就可以设计一个按键控制菜单了,UI 大概就是下面这个样子

按键控制菜单的原理其实很简单 -,当我检测到按键按下的时候,就切换屏幕状态,因为只有部分区域发生了改变,让你产生立箭头移动的错觉,代码如下:
import time
from machine import Pin, SoftSPI
from libs.ssd1306 import SSD1306_SPI
# 定义 SoftSPI 对象
spi = SoftSPI(sck=Pin(18), mosi=Pin(13), miso=Pin(19))
# 定义 SSD1306 SPI 控制对象
oled = SSD1306_SPI(width=128, height=64, spi=spi,
dc=Pin(2), res=Pin(15), cs=Pin(4))
# 定义 按键输入引脚对象,并配置上拉电阻
button_up = Pin(12, Pin.IN, Pin.PULL_UP)
button_down = Pin(14, Pin.IN, Pin.PULL_UP)
# 定义菜单选项
menu_items = ['Item 1', 'Item 2', 'Item 3', 'Item 4']
# 记录当前位置的值
current_item = 0
def display_menu(index):
oled.fill(0)
oled.text('Menu', 0, 0)
oled.text('-' * 20, 0, 10)
for i in range(len(menu_items)):
if i == index:
oled.text('> ' + menu_items[i], 0, 20 + i * 10)
else:
oled.text(menu_items[i], 0, 20 + i * 10)
oled.show()
# 初始化显示屏幕状态
display_menu(current_item)
while True:
if not button_up.value():
current_item = (current_item + 1) % len(menu_items)
display_menu(current_item)
if not button_down.value():
current_item = (current_item - 1) % len(menu_items)
display_menu(current_item)
time.sleep(0.1)
串口通信
单片机中最常用的通讯协议有 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 和 串口模块都连接电脑。
将材料按照下图相连:

软件程序设计
UART 在 machine 的 UART 模块中,我们也是只需要了解其构造对象函数和使用方法即可。
构造函数 UART(id, baudrate, rx=None, tx=None, bits=8, parity=None, stop=1),作用是创建 UART 对象。
id:0、1、2;baudrate:波特率,常用 115200、9600;关于波特率,单片机或计算机在串口通信时的速率用波特率表示,它定义为每秒传输二进制代码的个数,即 1 波特= 1 位/秒,单位是 bps。关于波特率的计算,在串行通信中,收发双方对发送或接收数据的速率要有约定。我们的电脑可以使用串口调试工具来设置我们电脑得参数,而我们的 ESP32 单片机就只能通过编程来设置了。rx:数据接收引脚;tx:数据发送引脚;bits:数据位,默认为 8,在数据包的起始位之后紧接着的就是要传输的主体数据内容,也称为有效数据,有效数据的长度常被约定为 8 位或 9 位长。parity:数据校验位,默认为 None,在有效数据之后,有一个可选的数据校验位。stop:默认为 1,停止位。
使用方法如下:
from machine import UART
# 创建 uart 对象
uart = UART(2, baudrate=9600)
# 写入 5 个 字节的内容
uart.write('hello')
# 读取到第 5 位的内容
uart.read(5)
# 判断串口是否输入数据
uart.any()
因此,我们可以这么写来实现与 PC 端串口助手进行数据收发:
from machine import UART
# 定义 UART 控制对象
uart = UART(2, 115200)
# 发送数据到串口工具中
uart.write('Hello')
while True:
if uart.any():
text = uart.read(20)
print(text)
外部中断
之前我们学习了 ESP32 的按键控制,当时通过查询 GPIO 输入电平来判断按键状态,这种方法占用 CPU 资源,效率不高。本节课我们学习外部中断,通过外部中断实现按键控制 LED。
实验原理
在单片机中,中断是指当 CPU 在正常处理主程序时,突然发生了另一件事件 A(中断发生)需要 CPU 去处理,这时 CPU 就会暂停处理主程序(中断响应),转而去处理事件 A(中断服务)。当事件 A 处理完以后,再回到主程序原来中断的地方继续执行主程序(中断返回)。这一整个过程称为中断。
例如,当你正在洗衣时,突然手机响了(中断发生),你暂时中断洗衣的工作,转去接电话(中断响应和中断服务),待你接完后,再回来继续洗衣(中断返回),这一过程就是中断。

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

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

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

软件程序设计
外部中断也是通过 Pin 模块来配置的,使用方法如下:
from machine import Pin
button = Pin(14, Pin.IN, Pin.PULL_DOWN)
# 配置中断模式
button.irq(handler, trigger)
其中 button.irq(handler, trigger) 是配置中断模式,参数意义:
handler: 中断执行的回调函数;trigger: 触发中断的方式,共 4 种,分别是 Pin.IRQ_FALLING(下降沿触发)、Pin.IRQ_RISING(上升沿触发)、Pin.IRQ_LOW_LEVEL(低电平触发)、Pin.IRQ_HIGH_LEVEL(高电平触发)。
因此,我们的代码需要这么写:
import time
from machine import Pin
button = Pin(14, Pin.IN, Pin.PULL_DOWN)
led = Pin(2, Pin.OUT)
# 定义 button 的外部中断函数
def button_irq(button):
time.sleep_ms(80)
if button.value() == 1:
led.value(not led.value())
button.irq(button_irq, Pin.IRQ_RISING)
定时器中断
上一节我们介绍了 ESP32 的外部中断的使用,本节课介绍 ESP32 的定时器功能。
实验原理
定时器,顾名思义就是用来计时的,我们常常会设置计时或闹钟,然后时间到了就告诉我们要做什么。ESP32 也是这样,通过定时器可以完成各种预设好的任务。ESP32 定时器到达指定时间后也会产生中断,然后在回调函数内执行所需功能,这个和外部中断类似。
ESP32 内置 4 个 64-bit 通用定时器。每个定时器包含一个 16-bit 预分频器和一个 64-bit 可自动重新加载向上/向下计数器。
使用计时器的好处,是实现类似并行处理的功能,也就是一个应用里只能有一个 while True,两个以上都是不可以的,但是如果使用定时器,就可以同时运行多个 while True。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 直插式 LED | 2 |
| 1kΩ 电阻 | 2 |
| 杜邦线(跳线) | 若干 |
| 面包板 | 1 |
LED 的正极接开发板的 D2、D4 引脚,并串联一个电阻,负极接 GND,如下图:

注意
一定要接电阻,不然会由于电流过大,烧坏 LED。
软件程序设计
ESP32 定时器位于 machine 模块当中。可以调用的定时器有 timer0-3 共 4 个定时器。
构造函数 class machine.Timer(id, ...):构造给定 id 的新计时器对象,可以是任意整数 n(这个整数会转化成 n % 4),但是最多调用 4 个,新调用的会抢占定时器。
Timer.init(*, mode=Timer.PERIODIC, period=- 1, callback=None):定时器初始化,其中的参数:
mode:2 种工作模式,Timer.ONE_SHOT(执行一次)、Timer.PERIODIC(周期性);period:单位为 ms;callback:定时器中断后的回调函数。
Timer.deinit():销毁计时器。
因此,我们的代码需要这么写:
import time
from machine import Pin, Timer
# 定义 Pin 控制引脚
led_1 = Pin(2, Pin.OUT)
led_2 = Pin(4, Pin.OUT)
# 定义定时器中断的回调函数
def timer_irq(timer_pin):
led_1.value(not led_1.value())
# 定义定时器
timer = Timer(0)
# 初始化定时器
timer.init(period=500, mode=Timer.PERIODIC, callback=timer_irq)
while True:
led_2.value(not led_2.value())
time.sleep(1)
Wi-Fi 连接
连接路由器上网是我们每天都做的事情,日常生活中我们只需要知道路由器的账号和密码,就能使用电脑或者手机连接到无线路由器,然后上网冲浪。
如今物联网市场异常火爆,WIFI 是物联网中非常重要的角色,现在基本上家家户户都有 WIFI 网络,通过 WIFI 接入到互联网,成了智能家居产品普遍的选择。ESP32 内部已集成 WIFI 功能,可以说它就是为 WIFI 无线连接而生的。本章来学习 ESP32 的 WIFI,使用 MicroPython 开发 WIFI 是非常简单而美妙的,让大家在学习物联网中变的简单有趣,WIFI 模块也是为什么 ESP32 可以迅速崛起的主要原因之一。
硬件电路设计
连接无线路由器,将 ESP32 的 IP 地址等信息通过 Shell 控制台输出显示。
由于 ESP32 内置 WIFI 功能,所以直接在开发板上使用即可,无需额外连接。
软件电路设计
MicroPython 已经集成了 network 模块,因此我们可以直接使用该模块。
模块包含热点 AP 模式和客户端 STA 模式,热点 AP 是指电脑或手机端直接连接 ESP32 发出的热点实现连接,如果电脑连接模块 AP 热点,这样电脑就不能上网,因此在使用电脑端和模块进行网络通信时,一般情况下都是使用 STA 模式。也就是电脑和设备同时连接到相同网段的路由器上。
构造函数 network.WLAN(interface_id): 创建 WIFI 连接对象,interface_id 分为热点 network.AP_IF 和 客户端 network.STA_IF 模式。
使用方法:
# 导入 network 模块
import network
# 创建 WIFI 连接对象
wlan = network.WLAN(network.STA_IF)
# 激活 wlan 接口。True 是激活,False 关闭
wlan.active(True)
# 扫描允许访问的 SSID
wlan.scan()
# 检查设备是否已经连接成功
wlan.isconnected()
# WIFI 连接,ssid 是账号,password 是密码
wlan.connect('ssid', 'key')
# 获取接口的 mac 地址,也就是物理地址
wlan.config('mac')
# 获取接口的 IP、子网掩码(netmask)、网关(gw)、DNS 地址
wlan.ifconfig()
提示
无线网络中 SSID,是路由器发送的无线信号的名字。如果你将你的无线路由器的SSID:命名为:123456,那么当你的无线路由器开启,并启用了无线功能,和允许了 SSID 广播,那么你就可以轻易的找到你自己的路由器的无线网络。
1. 热点模式
热点模式允许用户将自己的ESP32配置为热点,这让多个 ESP32 芯片之间的无线连接在不借助外部路由器网络的情况下成为可能。
import network
ap = network.WLAN(network.AP_IF) # 创建一个热点
ap.active(True) # 激活热点
ap.config(essid='ESP32') # 为热点配置essid(即热点名称)
运行之后,我们就可以使用电脑在 WiFi 列表中找到 ESP32 热点。
2. 连接 WiFi
更多的情况下,我们会想要将 ESP32 连接到 WiFi 网络。因此,我们的代码可以这么写:
import time
import network
# 设置路由器 WiFi 账号与密码
ssid = '要连接的 Wifi 名'
password = 'Wifi 密码'
# 创建 WIFI 连接对象
wlan = network.WLAN(network.STA_IF)
# 激活 wlan 接口
wlan.active(True)
# 扫描允许访问的 WiFi
print('扫描周围信号源:', wlan.scan())
print("正在连接 WiFi 中", end="")
#
wlan.connect(ssid, password)
# 如果一直没有连接成功,则每隔 0.1s 在命令号中打印一个 .
while not wlan.isconnected():
print(".", end="")
time.sleep(0.1)
# 连接成功之后,打印出 IP、子网掩码(netmask)、网关(gw)、DNS 地址
print(f"\n{wlan.ifconfig()}")
现在,我们的开发板就已经成功连接 WiFi 了,接下来几节课,我们就可以让我们的开发板进行网络通讯了。
3. 实现单片机上电自动连接 WiFi
我们对以上代码稍作改动,将其封装为一个函数,并把 WIFI 名字和密码改为 wifi_connect 的参数,并把代码放置在 MicroPython 设备中的 common 目录下的 wifi.py 中,代码如下:
# common/wifi.py
import time
import network
def wifi_connect(ssid, password):
# 创建 WIFI 连接对象
wlan = network.WLAN(network.STA_IF)
# 激活 wlan 接口
wlan.active(True)
# 断开之前的链接
wlan.disconnect()
# 扫描允许访问的 WiFi
print('扫描周围信号源:', wlan.scan())
print("正在连接 WiFi 中", end="")
# 连接 wifi
wlan.connect(ssid, password)
# 如果一直没有连接成功,则每隔 0.1s 在命令号中打印一个 .
while not wlan.isconnected():
print(".", end="")
time.sleep(0.1)
# 连接成功之后,打印出 IP、子网掩码(netmask)、网关(gw)、DNS 地址
print(f"\n{wlan.ifconfig()}")
如果我们想要实现他的开机自启动,你需要在 MicroPython 设备的根目录下,创建一个 main.py 文件,并写入以下代码:
# main.py
from common.wifi import wifi_connect
# 连接 wifi
wifi_connect('ssid', 'password')
注意
如果上述代码放到 boot.py 中也是可以执行的。boot.py 是开机最先执行的文件,最后会由它加载 main.py。
main.py 文件开机会被 boot.py 文件引导,可以将自己的代码放在里面。
boot.py 文件里面可以声明包含自己要用到的模块,里面可以定制自己开机程序(也就是在运行 main.py 文件前的程序),但是官方建议该文件里面的程序越小越好。
RTC 实时时钟
本节课来学习使用 MicroPython 中的 RTC 模块。
实验原理
RTC 全称为实时时钟(Real-time Clock),是一种与 CPU 互不干扰,独立于 CPU 运行的计时设备。
RTC 主要用于在计算机系统关机时,保存计算机系统时钟,以便在下次计算机系统开机时能够从 RTC 中恢复出正确的时间。
RTC 的应用场景非常广泛,例如实现时间戳功能、自动唤醒、计时器等。在一些需要记录时间的项目中,RTC 可以作为重要的时间标记。
硬件电路设计
物料清单(BOM 表):
| 材料名称 | 数量 |
|---|---|
| 带有 IIC 模块的 LCD1602 液晶屏 | 1 |
| 杜邦线(跳线) | 若干 |
将材料按照下图相连:

注意
注意需要使用开发板上的 5V 电压,而不是 3.3V。真实环境下使用 3.3V 会无法显示或者显示很暗。
软件程序设计
MicroPython 集成了内置时钟模块,因此我们需要学习 RTC 的构造函数和使用方法:
构造函数 machine.RTC(): 构建 RTC 对象。
使用方法如下:
import machine
# 创建 RTC 对象
rtc = machine.RTC()
# 设置日期与时间。按顺序分别是:(年,月,日,星期,时,分,秒,微秒)
# 其中星期使用 0-6 表示周一至周日
rtc.datetime(2023, 1, 1, 0, 0, 0, 0)
# 获取当前的时间
rtc.datetime()
我们可以使用 Thonny 给单片机同步实时时钟。
点击右下角,选择配置解释器。

勾选 同步设备的实时时钟,点击确定,重新连接单片机即可。

注意
本实验需要用到 I2C 驱动代码,代码在 第 12 节课 中。
因此如果想要在 LCD 液晶屏上显示当前时间的话,可以这么写:
import time
from machine import Pin, RTC, SoftI2C
from libs.i2c_lcd import I2cLcd
# 定义一个 SoftI2C 的对象,指定 sda 和 scl 的 GPIO 口,并设置好通信的频率
i2c = SoftI2C(sda=Pin(14),scl=Pin(13),freq=100000)
# 获取 lcd 的地址,因为只控制了一个屏幕,因此选第 0 个设备的地址
DEFAULT_I2C_ADDR = i2c.scan()[0]
print(f"LCD设备为列表:{i2c.scan()}")
# 定义一个I2CLcd对象,设置模式为i2c,地址,行数,行的大小16个字节
lcd = I2cLcd(i2c, DEFAULT_I2C_ADDR, 2, 16)
# 定义 RTC 控制对象
rtc = RTC()
# 定义星期
week = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
while True:
date_time=rtc.datetime()
lcd.clear()
lcd.putstr("%d-%02d-%02d %s\n" %(date_time[0],date_time[1],date_time[2], week[date_time[3]]))
lcd.putstr(" %02d:%02d:%02d" % (date_time[4],date_time[5],date_time[6]))
time.sleep(1)
我们也可以使用定时器来重写这段代码:
from machine import Pin, RTC, Timer, SoftI2C
from libs.i2c_lcd import I2cLcd
# 定义一个 SoftI2C 的对象,指定 sda 和 scl 的 GPIO 口,并设置好通信的频率
i2c = SoftI2C(sda=Pin(14),scl=Pin(13),freq=100000)
# 获取 lcd 的地址,因为只控制了一个屏幕,因此选第 0 个设备的地址
DEFAULT_I2C_ADDR = i2c.scan()[0]
print(f"LCD设备为列表:{i2c.scan()}")
# 定义一个I2CLcd对象,设置模式为i2c,地址,行数,行的大小16个字节
lcd = I2cLcd(i2c, DEFAULT_I2C_ADDR, 2, 16)
# 定义RTC控制对象
rtc=RTC()
# 定义定时器对象
timer = Timer(0)
# 定义星期
week=("Mon","Tue","Wed","Thu","Fri","Sat","Sun")
# 定义定时器中断函数
def timer_irq(timer_obj):
date_time=rtc.datetime()
print(date_time)
lcd.clear()
lcd.putstr("%d-%02d-%02d %s\n" %(date_time[0],date_time[1],date_time[2], week[date_time[3]]))
lcd.putstr(" %02d:%02d:%02d" % (date_time[4],date_time[5],date_time[6]))
# 初始化定时器对象
timer.init(mode=Timer.PERIODIC, period=1000, callback=timer_irq)
WebREPL 远程访问
这节课我们来学习 MicroPython WebREPL 命令行交互环境搭建。
实验原理
与 Python 相同,MicroPython 也同样具有命令行交互式环境,简称 REPL,因此 WebREPL 就是网络版 MicroPython 交互环境。
虽然 MicroPython 具有传统 Python 语言的基本语法和使用规则,但 MicroPython 是专为嵌入式系统所设计。因此 MicroPython 与 Python 在应用环境方面具有一些区别。
我们在 Thonny 上用的 MicroPython REPL 是通过数据线来通讯的。作为物联网开发板,ESP32 的强项是 WIFI 联网,使用 WIFI 实现 REPL 的功能就是 WebREPL 了。
操作方法
首先,请通过 Thonny 软件打开 MicroPython 的 REPL。并且在 shell 环境中输入 import webrepl_setup,输入以上指令并按下回车后,我们将进入 WebREPL 的设置模式。
之后,在命令行中会出现的第一个问题是询问我们是否让该开发板每次启动时自动开启 WebREPL,E 是开启,D 是关闭,空行表示退出。这里,我们输入字符 E 并按下回车

接下来是为 WebREPL 设置密码。以后每次登录 WebREPL 都将用到此密码。输入密码,确保两次输入内容一致。密码为 4-9 个字符组合。

系统设置完成后需要重启 ESP32 开发板,输入 y 即可。

之后就能在 MicroPython 设备中看到多了一个新文件 webrepl_cfg.py,其中的内容是你之前的密码。

并且 boot.py 文件中,原本注释起来的内容,也解开注释了。

现在,我们还需要对 boot.py 文件进行修改,让他在启动的时候先链接 WiFi,再开启 webrepl。
在 common 目录下添加 wifi.py(在我们之前的 连接 WiFi,我们已经学习了如何连接 WiFi),将以下代码复制到该文件中。
# common/wifi.py
import time
import network
def wifi_connect(ssid, password):
# 创建 WIFI 连接对象
wlan = network.WLAN(network.STA_IF)
# 激活 wlan 接口
wlan.active(True)
# 断开之前的链接
wlan.disconnect()
# 扫描允许访问的 WiFi
print('扫描周围信号源:', wlan.scan())
print("正在连接 WiFi 中", end="")
# 连接 wifi
wlan.connect(ssid, password)
# 如果一直没有连接成功,则每隔 0.1s 在命令号中打印一个 .
while not wlan.isconnected():
print(".", end="")
time.sleep(0.1)
# 连接成功之后,打印出 IP、子网掩码(netmask)、网关(gw)、DNS 地址
print(f"\n{wlan.ifconfig()}")
修改 boot.py 文件:
# This file is executed on every boot (including wake-boot from deepsleep)
#import esp
#esp.osdebug(None)
from common.wifi import wifi_connect
import webrepl
# 设置路由器 WiFi 账号与密码
ssid = '要连接的 Wifi 名'
password = 'Wifi 密码'
# 连接 WiFi
wifi_connect(ssid, password)
webrepl.start(password='你设置的密码')
设置完毕后,保存代码,重启 ESP32 单片机。

WebREPL 启动成功,复制上图地址,按照下图配置解释器,

正常连接单片机,这个操作可以让你后期对成品项目进行调试时,不需要连接数据线,只需要在同一局域网下,就可以实现对单片机编程。

MicroPython 官网也提供了另一种工具:http://micropython.org/webrepl/,用法是完全一样的,

注意
需要注意的是,WebREPL 最大连接数为 1。
获取网络请求
这节课我们来学习如何使用 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 字符串。
软件程序设计
urequests 模块
当我们使用 MicroPython 在嵌入式系统中开发物联网(IoT)应用时,我们通常需要使用网络连接。通过使用 MicroPython 的 urequests 模块,可以轻松地从 Web 服务器上获取数据,实现基于网络的功能。
urequests 模块是用于在 MicroPython 中进行 HTTP 请求的模块。它实现了 HTTP 客户端协议,允许我们使用 GET、POST、PUT 和 DELETE 请求等基本的 HTTP 请求类型。
下面是使用 urequests 模块发送 GET 请求的示例:
import urequests
response = urequests.get('https://www.example.com')
print(response.text)
这个示例中,我们使用 urequests.get() 方法向 https://www.example.com 发送 GET 请求,并将响应的内容打印出来。
注意
注意,在发送 HTTP 请求后,我们需要使用 response.text 来获取服务器返回的数据,或者,我们可以使用 json() 方法,把 json 数据转换成字典。
除了 get() 方法,urequests 模块还提供了其他几个方法:
post(url, data=None, json=None, headers={}, **kw): 发送 HTTP POST 请求。data参数用于指定请求数据,json参数用于指定JSON数据,headers参数用于指定 HTTP 标头,**kw参数用于传递其他参数;put(url, data=None, **kw): 发送 HTTP PUT 请求。data参数用于指定请求数据,**kw参数用于传递其他参数;delete(url, **kw): 发送 HTTP DELETE 请求。**kw参数用于传递其他参数。
在发送请求时,我们可以通过传递参数来指定请求的数据、标头和其他选项。下面是一个例子,展示如何向 Web 服务器发送带有自定义标头的 GET 请求:
import urequests
headers = {'Authorization': 'Bearer <token>'}
response = urequests.get('https://api.example.com', headers=headers)
print(response.text)
在这个示例中,我们在 headers 参数中指定了一个名为 Authorization 的标头,以便在请求中包含我们的 API 令牌。
总之,通过使用 urequests 模块,我们可以轻松地在 MicroPython 中进行 HTTP 请求,与互联网上的 Web 服务器进行通信,并在 IoT 应用中获取所需的数据。
1. 获取实时天气数据
在使用前,一定要先连接 WiFi
import urequests
# 定义请求参数字典
request_params = {'city': '郑州', 'key': '你自己的 api 密钥'}
response = urequests.post(f'http://apis.juhe.cn/simpleWeather/query?city={request_params ["city"]}&key={request_params ["key"]}')
# print(response.text)
# print(type(response.json()))
# 获取数据
realtime = response.json()['result']['realtime']
temp = realtime['temperature']
info = realtime['info']
aqi = realtime['aqi']
print(f'温度:{temp}°C\n天气:{info}\n空气指数:{aqi}')
如何制作自己的 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 |
步进电机有防呆插头,直接插在电路板上即可。

软件程序设计
我们先通过代码来了解,如何通过 ULN2003 驱动步进电机转动指定步数,我们已经知道八拍模式下,4096 个节拍转动一圈,那我们如果想要转动半周,也就是 180°,就需要 2048 拍,我们的代码,每次循环走每 8 拍(四拍模式下虽然走的是四拍,但是与八拍转动相同的角度)。因此,我们需要循环 2048/8=256 次,才能转动 180°。
那如果我们想要转动其他角度就可以通过这么一个公式 4096/8 * 角度/360 来计算。
import time
from machine import Pin
a = Pin(13, Pin.OUT)
b = Pin(12, Pin.OUT)
c = Pin(14, Pin.OUT)
d = Pin(27, Pin.OUT)
delay_time = 2 # 这个时间不能设置太小,否则电机来不及响应
print("单四拍模式")
for i in range (256): # 顺时针转动180度
a.value(1)
b.value(0)
c.value(0)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(1)
c.value(0)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(0)
c.value(1)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(0)
c.value(0)
d.value(1)
time.sleep_ms(delay_time)
# 改变脉冲的顺序, 可以方便的改变转动的方向
for i in range (256): # 逆时针转动转动180度
a.value(0)
b.value(0)
c.value(0)
d.value(1)
time.sleep_ms(delay_time)
a.value(0)
b.value(0)
c.value(1)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(1)
c.value(0)
d.value(0)
time.sleep_ms(delay_time)
a.value(1)
b.value(0)
c.value(0)
d.value(0)
time.sleep_ms(delay_time)
# 双四拍模式
print("双四拍模式")
for i in range (256): # 顺时针转动 180 度
a.value(1)
b.value(1)
c.value(0)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(1)
c.value(1)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(0)
c.value(1)
d.value(1)
time.sleep_ms(delay_time)
a.value(1)
b.value(0)
c.value(0)
d.value(1)
time.sleep_ms(delay_time)
print('八拍模式')
for i in range(256):
a.value(1)
b.value(0)
c.value(0)
d.value(0)
time.sleep_ms(delay_time)
a.value(1)
b.value(1)
c.value(0)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(1)
c.value(0)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(1)
c.value(1)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(0)
c.value(1)
d.value(0)
time.sleep_ms(delay_time)
a.value(0)
b.value(0)
c.value(1)
d.value(1)
time.sleep_ms(delay_time)
a.value(0)
b.value(0)
c.value(0)
d.value(1)
time.sleep_ms(delay_time)
a.value(1)
b.value(0)
c.value(0)
d.value(1)
time.sleep_ms(delay_time)
# 步进电机停止后需要使四个相位引脚都为低电平,否则步进电机会发热
a.value(0)
b.value(0)
c.value(0)
d.value(0)
使用第三方模块驱动步进电机
我们也可以使用 ULN2003 的第三方模块驱动步进电机,把一下代码上传到 libs 目录下的 uln2003.py 文件中。
# libs/uln2003.py
import time
# only test for uln2003
class Uln2003:
FULL_ROTATION = int(4075.7728395061727 / 8)
HALF_STEP = [
[1, 0, 0, 0],
[1, 1, 0, 0],
[0, 1, 0, 0],
[0, 1, 1, 0],
[0, 0, 1, 0],
[0, 0, 1, 1],
[0, 0, 0, 1],
[1, 0, 0, 1],
]
FULL_STEP = [
[1, 1, 0, 0],
[0, 1, 1, 0],
[0, 0, 1, 1],
[1, 0, 0, 1]
]
def __init__(self, pin1, pin2, pin3, pin4, delay, mode='FULL_STEP'):
if mode == 'FULL_STEP':
self.mode = self.FULL_STEP
else:
self.mode = self.HALF_STEP
self.pin1 = pin1
self.pin2 = pin2
self.pin3 = pin3
self.pin4 = pin4
self.delay = delay # Recommend 10+ for FULL_STEP, 1 is OK for HALF_STEP
# Initialize all to 0
self.reset()
def step(self, count, direction=1):
"""Rotate count steps. direction = -1 means backwards"""
if count < 0:
direction = -1
count = -count
for x in range(count):
for bit in self.mode[::direction]:
self.pin1(bit[0])
self.pin2(bit[1])
self.pin3(bit[2])
self.pin4(bit[3])
time.sleep_ms(self.delay)
self.reset()
def angle(self, r, direction=1):
self.step(int(self.FULL_ROTATION * r / 360), direction)
def reset(self):
# Reset to 0, no holding, these are geared, you can't move them
self.pin1(0)
self.pin2(0)
self.pin3(0)
self.pin4(0)
接着在其他程序中调用 Uln2003 这个类即可,代码如下:
from machine import Pin
from libs.uln2003 import Uln2003
motor = Uln2003(pin1=Pin(13), pin2=Pin(12), pin3=Pin(14), pin4=Pin(27), delay=2, mode='HALF_STEP')
motor.angle(180, -1)
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。

软件程序设计
因此,在 shell 环境中打印出当前值的程序可以这么写:
import time
from machine import Pin, ADC
# 定义摇杆的引脚
ps2_x = ADC(Pin(15), atten=ADC.ATTN_11DB)
ps2_y = ADC(Pin(2), atten=ADC.ATTN_11DB)
ps2_button = Pin(4, Pin.IN)
while True:
print(f'x:{ps2_x.read()} y:{ps2_y.read()} z:{ps2_button.value()}')
time.sleep(0.1)
接下来,我们就可以使用 PS2 摇杆模块控制舵机了,代码如下:
import time
from machine import Pin, ADC, PWM
# 定义摇杆引脚
ps2_x = ADC(Pin(15), atten=ADC.ATTN_11DB)
ps2_y = ADC(Pin(2), atten=ADC.ATTN_11DB)
ps2_button = Pin(4, Pin.IN)
# 定义舵机控制引脚
my_servo = PWM(Pin(13), freq=50)
while True:
# 读取 X 轴模拟信号
x_value = ps2_x.read()
# 在一个周期内(20ms), 0.5ms -> 0°, 2.4ms -> 180°
# 0.5/20 * 1024
servo_angle = x_value/4095 *(2.4-0.5)/20 *1024 + 0.5/20 * 1024
# duty 方法控制舵机转动,0-1023, 0°-> 0.5/20 * 1024;
# 180° -> 2.4/20*1024;
my_servo.duty(int(servo_angle))
time.sleep(0.1)
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 都可以正常工作,代码如下:
import time
from machine import Pin
# 定义行引脚
row_1 = Pin(13, Pin.OUT)
row_2 = Pin(25, Pin.OUT)
row_3 = Pin(2, Pin.OUT)
row_4 = Pin(27, Pin.OUT)
row_5 = Pin(23, Pin.OUT)
row_6 = Pin(4, Pin.OUT)
row_7 = Pin(22, Pin.OUT)
row_8 = Pin(18, Pin.OUT)
# 定义行对象列表
row_list = [row_1, row_2, row_3, row_4, row_5, row_6, row_7, row_8]
# 定义列引脚对象
col_1 = Pin(26, Pin.OUT)
col_2 = Pin(21, Pin.OUT)
col_3 = Pin(19, Pin.OUT)
col_4 = Pin(12, Pin.OUT)
col_5 = Pin(5, Pin.OUT)
col_6 = Pin(14, Pin.OUT)
col_7 = Pin(33, Pin.OUT)
col_8 = Pin(32, Pin.OUT)
# 定义列对象列表
col_list = [col_1, col_2, col_3, col_4, col_5, col_6, col_7, col_8]
# 初始化所有行为高电平
for row in row_list:
row.on()
# 初始化所有列为低电平
for col in col_list:
col.off()
while True:
# 循环遍历所有位置,先遍历行,再遍历列
for row in row_list:
row.off()
for col in col_list:
col.on()
time.sleep_ms(100)
col.off()
row.on()
2. 在点阵屏上显示图案
这里我们需要再次用到取模软件,与之前不同的是,这里我们需要新建图像并且,设置图像的宽和高为 8,这样就能保证与我们的点阵屏对应。

字模设置也需要改一下,

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

这里,我们获取了一个 8 个 16 进制数的列表,每个 16 进制数转换成二进制就是 LED 点阵屏每行所有 LED 的逻辑,
{0x00,0x66,0xFF,0xFF,0xFF,0x7E,0x3C,0x18}
因此,我们的代码可以这么写:
import time
from machine import Pin
# 定义行引脚对象
row_1 = Pin(13, Pin.OUT)
row_2 = Pin(25, Pin.OUT)
row_3 = Pin(2, Pin.OUT)
row_4 = Pin(27, Pin.OUT)
row_5 = Pin(23, Pin.OUT)
row_6 = Pin(4, Pin.OUT)
row_7 = Pin(22, Pin.OUT)
row_8 = Pin(18, Pin.OUT)
# 定义行对象列表
row_list = [row_1, row_2, row_3, row_4, row_5, row_6, row_7
, row_8]
# 定义列引脚对象
col_1 = Pin(26, Pin.OUT)
col_2 = Pin(21, Pin.OUT)
col_3 = Pin(19, Pin.OUT)
col_4 = Pin(12, Pin.OUT)
col_5 = Pin(5, Pin.OUT)
col_6 = Pin(14, Pin.OUT)
col_7 = Pin(33, Pin.OUT)
col_8 = Pin(32, Pin.OUT)
# 定义列对象列表
col_list = [col_1, col_2, col_3, col_4, col_5, col_6, col_7
, col_8]
# 初始化所有行为高电平
for row in row_list:
row.on()
# 初始化所有列为低电平
for col in col_list:
col.off()
# 定义图案的逻辑列表
hex_list = [0x00,0x66,0xFF,0xFF,0xFF,0x7E,0x3C,0x18]
# 定义逻辑值列表
logic_list = []
# 格式化逻辑列表
for hex in hex_list:
logic = bin(hex).replace('0b', '')
# 补 0
while len(logic) < 8:
logic = '0' + logic
logic_list.append(logic)
# 显示图像
while True:
for i in range(8):
for j in range(8):
col_list[j].value(int(logic_list[i][j]))
row_list[i].value(0)
time.sleep_ms(1)
row_list[i].value(1)
3. 使用 PS2 摇杆控制 LED 移动
最后,我们就可以写摇杆控制 LED移动的代码了:
import time
from machine import Pin, ADC
# 定义摇杆引脚
ps2_x = ADC(Pin(15), atten=ADC.ATTN_11DB)
ps2_y = ADC(Pin(35), atten=ADC.ATTN_11DB)
ps2_button = Pin(34, Pin.IN)
# 定义行引脚
row_1 = Pin(13, Pin.OUT)
row_2 = Pin(25, Pin.OUT)
row_3 = Pin(2, Pin.OUT)
row_4 = Pin(27, Pin.OUT)
row_5 = Pin(23, Pin.OUT)
row_6 = Pin(4, Pin.OUT)
row_7 = Pin(22, Pin.OUT)
row_8 = Pin(18, Pin.OUT)
# 定义行对象列表
row_list = [row_1, row_2, row_3, row_4, row_5, row_6, row_7, row_8]
# 定义列引脚对象
col_1 = Pin(26, Pin.OUT)
col_2 = Pin(21, Pin.OUT)
col_3 = Pin(19, Pin.OUT)
col_4 = Pin(12, Pin.OUT)
col_5 = Pin(5, Pin.OUT)
col_6 = Pin(14, Pin.OUT)
col_7 = Pin(33, Pin.OUT)
col_8 = Pin(32, Pin.OUT)
# 定义列对象列表
col_list = [col_1, col_2, col_3, col_4, col_5, col_6, col_7, col_8]
# 初始化所有行为高电平
for row in row_list:
row.on()
# 初始化所有列为低电平
for col in col_list:
col.off()
# 初始化一个 LED 的位置
led_pos = [1, 1]
while True:
x_value = ps2_x.read()
y_value = ps2_y.read()
# 清除 LED 状态
row_list[led_pos[0]].value(1)
col_list[led_pos[1]].value(0)
# 检测 x 轴是否移动
if x_value > 4095 / 2 + 300 and led_pos[0] > 0:
led_pos[0] -= 1
elif x_value < 4095 / 2 - 300 and led_pos[0] < 7:
led_pos[0] += 1
# 检测 y 轴是否移动
if y_value > 4095 / 2 + 300 and led_pos[1] < 7:
led_pos[1] += 1
elif y_value < 4095 / 2 - 300 and led_pos[1] > 0:
led_pos[1] -= 1
# 显示 LED
row_list[led_pos[0]].value(0)
col_list[led_pos[1]].value(1)
time.sleep_ms(50)
继电器模块
这节课我们来学习继电器模块。
实验原理

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

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

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

软件程序设计
当使用 ESP32 MicroPython 控制继电器时,通常需要使用 GPIO 引脚。继电器本质上是电磁开关,通过电磁线圈控制机械开关的闭合和断开,因此需要一个能够输出高电平和低电平的 IO 口来控制继电器的开关。
以下是一个 MicroPython 控制继电器的简单示例:
import time
from machine import Pin
# 将 D15 引脚配置为 GPIO输出
relay_pin = Pin(15, Pin.OUT)
# 打开继电器
def relay_on():
relay_pin.value(1)
# 关闭继电器
def relay_off():
relay_pin.value(0)
# 闪烁灯
def blink():
relay_on()
time.sleep(0.5)
relay_off()
time.sleep(0.5)
# 循环执行闪烁
while True:
blink()
蜂鸣器实验
这节课我们学习蜂鸣器,并用蜂鸣器制作电子琴。
实验原理
当涉及到蜂鸣器时,我们通常会遇到两种类型:无源蜂鸣器和有源蜂鸣器。它们在工作原理和使用方式上有所不同。
有源蜂鸣器(Active Buzzer)
有源蜂鸣器是一种集成了驱动电路的蜂鸣器,它可以直接通过电流激励产生声音,不需要外部设备。有源蜂鸣器内部集成了振片、驱动电路和共振腔。当给有源蜂鸣器提供电流时,它会根据电流的变化产生声音。有源蜂鸣器通常具有更好的声音质量和音量控制能力。在使用有源蜂鸣器时,我们可以通过控制电流的大小和频率来控制蜂鸣器的声音。一般来说,我们可以通过改变输入电流的大小来调整音量,通过改变输入电流的频率来调整音调和音乐效果。

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

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

软件程序设计
1. 有源蜂鸣器定时器闹钟
我们可以通过外部中断和定时器中断来制作一个闹钟,按下按键时中断闹钟
import time
from machine import Pin, Timer
# 定义控制引脚对象
button = Pin(13, Pin.IN, Pin.PULL_UP)
active_buzzer = Pin(22, Pin.OUT)
# 定义 button 的外部中断函数
def button_irq(button):
time.sleep_ms(10)
if not button.value():
active_buzzer.off()
# 定义定时器中断的回调函数
def timer_irq(timer_pin):
active_buzzer.value(1)
# 定义定时器
timer = Timer(0)
# 初始化定时器,设置闹钟开启时间
timer.init(period=5000, mode=Timer.ONE_SHOT, callback=timer_irq)
# 设置按键中断,打断闹钟
button.irq(button_irq, Pin.IRQ_FALLING)
2. 6 音符电子琴
我们想要弹奏一首歌就需要他的乐谱,最简单的就是小星星,

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

我们可以按照上图乐谱,用 6 个按键分别控制不同的音符,代码如下:
import time
from machine import Pin, PWM
# 定义按键对象,并存储到列表中
button_1 = Pin(25, Pin.IN, Pin.PULL_UP)
button_2 = Pin(26, Pin.IN, Pin.PULL_UP)
button_3 = Pin(27, Pin.IN, Pin.PULL_UP)
button_4 = Pin(14, Pin.IN, Pin.PULL_UP)
button_5 = Pin(12, Pin.IN, Pin.PULL_UP)
button_6 = Pin(13, Pin.IN, Pin.PULL_UP)
button_list = [button_1, button_2, button_3, button_4, button_5, button_6]
# 定义无源蜂鸣器 PWM 控制对象
pos_buzzer = PWM(Pin(23, Pin.OUT))
# 定义音符对应频率
tone_list = [262, 294, 330, 350, 393, 441, 495]
# 初始化音符频率
tone = 0
while True:
# 检测哪个按键触发
for i in range(len(button_list)):
if not button_list[i].value():
tone = tone_list[i]
print(i)
# 蜂鸣器发声
if tone:
pos_buzzer.duty(512)
pos_buzzer.freq(tone)
else:
pos_buzzer.duty(0)
tone = 0
time.sleep_ms(10)
3. 播放音乐
我们也可以自动播放这首歌,代码如下:
import time
from machine import Pin, PWM
# 定义无源蜂鸣器 PWM 对象
pos_buzzer = PWM(Pin(23, Pin.OUT))
# 定义音调频率列表
tone_list = [262, 294, 330, 350, 393, 441, 495]
# 定义乐谱音符列表
music = [1, 1, 5, 5, 6, 6, 5, 0,
5, 5, 4, 4, 3, 3, 2, 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]
# 自动播放音乐
for i in music:
pos_buzzer.duty(900)
if i:
pos_buzzer.freq(tone_list[i-1])
time.sleep_ms(500)
pos_buzzer.duty(0)
time.sleep_ms(10)
# 占空比设置为 0
pos_buzzer.duty(0)
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 |
矩阵键盘从左到右依次接 D19、D18、D5、D17、D16、D4、D2、D15。
LCD 1602 接 5V 电源,SCL 接 D27,SDA 接 D14。

软件程序设计
这里我们需要用到 Python 的一个内置函数 enumerate()。
enumerate() 函数用于将一个可遍历的数据对象(如列表、元组或字符串)组合为一个索引序列,同时列出数据和数据下标,一般用在 for 循环当中。
以下是 enumerate() 方法的语法:enumerate(sequence, [start=0]),其中的参数:
sequence:一个序列、迭代器或其他支持迭代对象;start:下标起始位置。
用法如下:
seasons = ['Spring', 'Summer', 'Fall', 'Winter']
for i, ele in enumerate(seasons):
print(i, ele)
这样,我们就能既获取到索引值 i,也能获取到元素 ele。
因此,我们可以先把按键值打印在 Shell 命令行中,代码如下:
import time
from machine import Pin
# 行引脚设置为输入
row_pins = [Pin(19, Pin.IN, Pin.PULL_UP),
Pin(18, Pin.IN, Pin.PULL_UP),
Pin(5, Pin.IN, Pin.PULL_UP),
Pin(17, Pin.IN, Pin.PULL_UP)]
# 列引脚设置为输出
col_pins = [Pin(16, Pin.OUT),
Pin(4, Pin.OUT),
Pin(2, Pin.OUT),
Pin(15, Pin.OUT)]
def read_keypad():
keys = [
['1', '2', '3', 'A'],
['4', '5', '6', 'B'],
['7', '8', '9', 'C'],
['*', '0', '#', 'D']
]
for j, col_pin in enumerate(col_pins):
col_pin.value(0) # 将当前列设置为低电平
for i, row_pin in enumerate(row_pins):
if row_pin.value() == 0: # 检测行引脚的状态
# 将当前列恢复为高电平
col_pin.value(1)
return keys[i][j] # 返回按下的按键
col_pin.value(1) # 将当前列恢复为高电平
return None # 没有按键被按下
# 循环读取键盘状态
while True:
key = read_keypad()
if key is not None:
print("按下的按键:", key)
time.sleep(0.1) # 短暂延迟
最后,我们就可以再使用 LCD1602 屏幕显示我们输入的内容,并且校验输入的密码是否正确,代码如下:
import time
from machine import Pin, SoftI2C
from libs.i2c_lcd import I2cLcd
# 行引脚设置为输入
row_pins = [Pin(19, Pin.IN, Pin.PULL_UP),
Pin(18, Pin.IN, Pin.PULL_UP),
Pin(5, Pin.IN, Pin.PULL_UP),
Pin(17, Pin.IN, Pin.PULL_UP)]
# 列引脚设置为输出
col_pins = [Pin(16, Pin.OUT),
Pin(4, Pin.OUT),
Pin(2, Pin.OUT),
Pin(15, Pin.OUT)]
# 定义硬件 I2C 控制对象
i2c = SoftI2C(sda=Pin(14), scl=Pin(27), freq=100000)
# 获取 I2C 设备地址
address = i2c.scan()[0]
# 定义 I2cLcd 对象
i2c_lcd = I2cLcd(i2c, address, 2, 16)
def read_keypad():
keys = [
['1', '2', '3', 'A'],
['4', '5', '6', 'B'],
['7', '8', '9', 'C'],
['*', '0', '#', 'D']
]
for j, col_pin in enumerate(col_pins):
col_pin.value(0) # 将当前列设置为低电平
for i, row_pin in enumerate(row_pins):
if row_pin.value() == 0: # 检测行引脚的状态
# 将当前列恢复为高电平
col_pin.value(1)
return keys[i][j] # 返回按下的按键
col_pin.value(1) # 将当前列恢复为高电平
return None # 没有按键被按下
# 正确密码
password = '123456'
# 显示的密码
pwd_input = ''
i2c_lcd.putstr('Enter Password:\n')
# 循环读取键盘状态
while True:
key = read_keypad()
if key is not None:
# 重置屏幕内容
i2c_lcd.clear()
i2c_lcd.putstr('Enter Password:\n')
if key == 'D' and pwd_input != '':
pwd_input = pwd_input[:-1]
elif key == '*':
# 校验密码
i2c_lcd.clear()
i2c_lcd.putstr('Enter Password:\n')
print(f'pwd_input: {pwd_input}, pwd: {password}')
if pwd_input == password:
i2c_lcd.putstr('Correct!')
else:
i2c_lcd.putstr('Wrong!')
# 重置 pwd_input
pwd_input = ''
# 非特殊按键则显示在屏幕上
elif key != 'D' and key !='*' and len(pwd_input) < 15:
pwd_input += key
i2c_lcd.putstr(pwd_input)
time.sleep(0.2) # 短暂延迟
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 |

软件程序设计
想要使用 MicroPython 操作 SD 卡模块,需要使用第三方模块,大家可以在 GitHub 对应的 MicroPython 驱动库下载,或者复制下面的代码并把以下代码上传到 MicroPython 设备中的 libs 目录下:
"""
MicroPython driver for SD cards using SPI bus.
Requires an SPI bus and a CS pin. Provides readblocks and writeblocks
methods so the device can be mounted as a filesystem.
Example usage on pyboard:
import pyb, sdcard, os
sd = sdcard.SDCard(pyb.SPI(1), pyb.Pin.board.X5)
pyb.mount(sd, '/sd2')
os.listdir('/')
Example usage on ESP8266:
import machine, sdcard, os
sd = sdcard.SDCard(machine.SPI(1), machine.Pin(15))
os.mount(sd, '/sd')
os.listdir('/')
"""
from micropython import const
import time
_CMD_TIMEOUT = const(100)
_R1_IDLE_STATE = const(1 << 0)
# R1_ERASE_RESET = const(1 << 1)
_R1_ILLEGAL_COMMAND = const(1 << 2)
# R1_COM_CRC_ERROR = const(1 << 3)
# R1_ERASE_SEQUENCE_ERROR = const(1 << 4)
# R1_ADDRESS_ERROR = const(1 << 5)
# R1_PARAMETER_ERROR = const(1 << 6)
_TOKEN_CMD25 = const(0xFC)
_TOKEN_STOP_TRAN = const(0xFD)
_TOKEN_DATA = const(0xFE)
class SDCard:
def __init__(self, spi, cs):
self.spi = spi
self.cs = cs
self.cmdbuf = bytearray(6)
self.dummybuf = bytearray(512)
self.tokenbuf = bytearray(1)
for i in range(512):
self.dummybuf[i] = 0xFF
self.dummybuf_memoryview = memoryview(self.dummybuf)
# initialise the card
self.init_card()
def init_spi(self, baudrate):
try:
master = self.spi.MASTER
except AttributeError:
# on ESP8266
self.spi.init(baudrate=baudrate, phase=0, polarity=0)
else:
# on pyboard
self.spi.init(master, baudrate=baudrate, phase=0, polarity=0)
def init_card(self):
# init CS pin
self.cs.init(self.cs.OUT, value=1)
# init SPI bus; use low data rate for initialisation
self.init_spi(100000)
# clock card at least 100 cycles with cs high
for i in range(16):
self.spi.write(b"\xff")
# CMD0: init card; should return _R1_IDLE_STATE (allow 5 attempts)
for _ in range(5):
if self.cmd(0, 0, 0x95) == _R1_IDLE_STATE:
break
else:
raise OSError("no SD card")
# CMD8: determine card version
r = self.cmd(8, 0x01AA, 0x87, 4)
if r == _R1_IDLE_STATE:
self.init_card_v2()
elif r == (_R1_IDLE_STATE | _R1_ILLEGAL_COMMAND):
self.init_card_v1()
else:
raise OSError("couldn't determine SD card version")
# get the number of sectors
# CMD9: response R2 (R1 byte + 16-byte block read)
if self.cmd(9, 0, 0, 0, False) != 0:
raise OSError("no response from SD card")
csd = bytearray(16)
self.readinto(csd)
if csd[0] & 0xC0 == 0x40: # CSD version 2.0
self.sectors = ((csd[8] << 8 | csd[9]) + 1) * 1024
elif csd[0] & 0xC0 == 0x00: # CSD version 1.0 (old, <=2GB)
c_size = csd[6] & 0b11 | csd[7] << 2 | (csd[8] & 0b11000000) << 4
c_size_mult = ((csd[9] & 0b11) << 1) | csd[10] >> 7
self.sectors = (c_size + 1) * (2 ** (c_size_mult + 2))
else:
raise OSError("SD card CSD format not supported")
# print('sectors', self.sectors)
# CMD16: set block length to 512 bytes
if self.cmd(16, 512, 0) != 0:
raise OSError("can't set 512 block size")
# set to high data rate now that it's initialised
self.init_spi(1320000)
def init_card_v1(self):
for i in range(_CMD_TIMEOUT):
self.cmd(55, 0, 0)
if self.cmd(41, 0, 0) == 0:
self.cdv = 512
# print("[SDCard] v1 card")
return
raise OSError("timeout waiting for v1 card")
def init_card_v2(self):
for i in range(_CMD_TIMEOUT):
time.sleep_ms(50)
self.cmd(58, 0, 0, 4)
self.cmd(55, 0, 0)
if self.cmd(41, 0x40000000, 0) == 0:
self.cmd(58, 0, 0, 4)
self.cdv = 1
# print("[SDCard] v2 card")
return
raise OSError("timeout waiting for v2 card")
def cmd(self, cmd, arg, crc, final=0, release=True, skip1=False):
self.cs(0)
# create and send the command
buf = self.cmdbuf
buf[0] = 0x40 | cmd
buf[1] = arg >> 24
buf[2] = arg >> 16
buf[3] = arg >> 8
buf[4] = arg
buf[5] = crc
self.spi.write(buf)
if skip1:
self.spi.readinto(self.tokenbuf, 0xFF)
# wait for the response (response[7] == 0)
for i in range(_CMD_TIMEOUT):
self.spi.readinto(self.tokenbuf, 0xFF)
response = self.tokenbuf[0]
if not (response & 0x80):
# this could be a big-endian integer that we are getting here
for j in range(final):
self.spi.write(b"\xff")
if release:
self.cs(1)
self.spi.write(b"\xff")
return response
# timeout
self.cs(1)
self.spi.write(b"\xff")
return -1
def readinto(self, buf):
self.cs(0)
# read until start byte (0xff)
for i in range(_CMD_TIMEOUT):
self.spi.readinto(self.tokenbuf, 0xFF)
if self.tokenbuf[0] == _TOKEN_DATA:
break
else:
self.cs(1)
raise OSError("timeout waiting for response")
# read data
mv = self.dummybuf_memoryview
if len(buf) != len(mv):
mv = mv[: len(buf)]
self.spi.write_readinto(mv, buf)
# read checksum
self.spi.write(b"\xff")
self.spi.write(b"\xff")
self.cs(1)
self.spi.write(b"\xff")
def write(self, token, buf):
self.cs(0)
# send: start of block, data, checksum
self.spi.read(1, token)
self.spi.write(buf)
self.spi.write(b"\xff")
self.spi.write(b"\xff")
# check the response
if (self.spi.read(1, 0xFF)[0] & 0x1F) != 0x05:
self.cs(1)
self.spi.write(b"\xff")
return
# wait for write to finish
while self.spi.read(1, 0xFF)[0] == 0:
pass
self.cs(1)
self.spi.write(b"\xff")
def write_token(self, token):
self.cs(0)
self.spi.read(1, token)
self.spi.write(b"\xff")
# wait for write to finish
while self.spi.read(1, 0xFF)[0] == 0x00:
pass
self.cs(1)
self.spi.write(b"\xff")
def readblocks(self, block_num, buf):
nblocks = len(buf) // 512
assert nblocks and not len(buf) % 512, "Buffer length is invalid"
if nblocks == 1:
# CMD17: set read address for single block
if self.cmd(17, block_num * self.cdv, 0, release=False) != 0:
# release the card
self.cs(1)
raise OSError(5) # EIO
# receive the data and release card
self.readinto(buf)
else:
# CMD18: set read address for multiple blocks
if self.cmd(18, block_num * self.cdv, 0, release=False) != 0:
# release the card
self.cs(1)
raise OSError(5) # EIO
offset = 0
mv = memoryview(buf)
while nblocks:
# receive the data and release card
self.readinto(mv[offset : offset + 512])
offset += 512
nblocks -= 1
if self.cmd(12, 0, 0xFF, skip1=True):
raise OSError(5) # EIO
def writeblocks(self, block_num, buf):
nblocks, err = divmod(len(buf), 512)
assert nblocks and not err, "Buffer length is invalid"
if nblocks == 1:
# CMD24: set write address for single block
if self.cmd(24, block_num * self.cdv, 0) != 0:
raise OSError(5) # EIO
# send the data
self.write(_TOKEN_DATA, buf)
else:
# CMD25: set write address for first block
if self.cmd(25, block_num * self.cdv, 0) != 0:
raise OSError(5) # EIO
# send the data
offset = 0
mv = memoryview(buf)
while nblocks:
self.write(_TOKEN_CMD25, mv[offset : offset + 512])
offset += 512
nblocks -= 1
self.write_token(_TOKEN_STOP_TRAN)
def ioctl(self, op, arg):
if op == 4: # get number of blocks
return self.sectors
上传并保存代码之后,我们就可以对 SD 卡中的数据进行增删改查了,代码如下:
import os
from machine import Pin, SoftSPI
from libs.sdcard import SDCard
# 初始化SD卡模块
spi = SoftSPI(-1, miso=Pin(19), mosi=Pin(23), sck=Pin(18))
sd = SDCard(spi, Pin(5))
# 挂载文件系统
vfs = os.VfsFat(sd)
os.mount(vfs, "/sd")
# 打印 SD 卡内容
print(f'文件列表:{os.listdir()}')
# 创建新文件并写入数据
print('创建并写入数据')
file_path = "/sd/data.txt"
with open(file_path, "w") as file:
file.write("Hello, World!")
# 创建新文件后,重新打印 SD 卡内容
print(f'文件列表:{os.listdir()}')
# 读取文件内容并进行处理
print('读取文件内容')
with open(file_path, "r") as file:
content = file.read()
print(content)
# 追加数据到现有文件中
print('追加写入数据')
with open(file_path, "a") as file:
file.write(" Appended data")
# 读取文件内容并进行处理
print('读取文件内容')
with open(file_path, "r") as file:
content = file.read()
print(content)
# 删除文件
print('删除文件')
os.remove(file_path)
# 删除文件后,重新打印 SD 卡内容
print(f'文件列表:{os.listdir()}')
光敏电阻
本节课我们来学习如何通过光敏电阻控制 LED。
实验原理
光敏电阻(photoresistor/light-dependent resistor,缩写为 LDR)是一种基于内光电效应的模拟传感器,一般用于光的测量、控制以及光电转换。常见应用有:
- 光控开关;
- 环境检测系统中的日光追踪。

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

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

软件程序设计
1. 在串口监视器中显示
我们可以先把读取到的光敏电阻的值打印在串口监视器中,代码如下:
from machine import Pin, ADC
import time
# 定义光敏电阻引脚
ldr_pin = ADC(Pin(27), atten=ADC.ATTN_11DB)
# 串口打印读取到的 ldr 的值
while True:
print(f'ldr: {ldr_pin.read()}')
time.sleep(0.1)
2. 根据光亮控制 LED
我们可以使用光敏电阻来控制 LED 的亮灭,这里需要准备 2 个 LED,其中一个 LED 负责接受 ADC 值,输出 PWM,光照越高,亮度越小;另一个 LED 则模拟日常生活中常见的楼梯间的光控灯,当光照强度过低时点亮,强度过高时熄灭。代码如下:
from machine import Pin, ADC, PWM
import time
# 定义不同功能的引脚
ldr_pin = ADC(Pin(27), atten=ADC.ATTN_11DB)
# 使 ADC 的采样宽度与 PWM 的占空比保持一致
ldr_pin.width(10)
pwm_pin = PWM(Pin(4), freq=2000)
led_pin = Pin(2, Pin.OUT)
# 串口打印读取到的 ldr 的值
while True:
ldr_value = ldr_pin.read()
# 打印读取到的 ldr 的值
print(f'ldr: {ldr_value}')
# 将 adc 值转换成 pwm
pwm_pin.duty(ldr_value)
# 控制 LED 的亮灭
if ldr_value > 600:
led_pin.value(1)
else:
led_pin.value(0)
time.sleep(0.1)
注意
如果你在搭建搭建光敏电阻电路的时候,使用的是第二种方法(固定电阻接 GND,光敏电阻接 VCC),那么你使用以上程序获取到的值是完全相反的,实现的效果也是相反的。
温湿度传感器 - DHT11
本节课我们来学习温湿度传感器。
实验原理
无论是工业领域还是我们的日常生活,温度和湿度一直都是两个比较重要的指标,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 |
将材料按照下图相连:

软件程序设计
MicroPython 的标准库中含有 DHT 库,可以导入模块即可直接使用,具体使用方法可以查看官方手册
实际上只有一个使用案例,这里我们可以看到 DHT11 和 DHT22 传感器是如何使用的

代码如下:
import time
from machine import Pin
from dht import DHT11
# 定义 DHT11 控制对象
dht11 = DHT11(Pin(13))
while True:
try:
dht11.measure() #调用 DHT 类库中测量数据的函数
temp = dht11.temperature()
humid = dht11.humidity()
print(f"温度:{temp}°C, 湿度:{humid}RH")
except OSError as e:
print(f"出现异常:{e},正在重试...")
# 如果延时时间过短,DHT11 温湿度传感器不工作
time.sleep(2)
注意
由于 DHT11 温湿度传感器在第一次读取的时候,会出现 OSError 的异常,当你再次运行的时候,即可正常运行,因此,我们只需要捕获第一次出现的错误即可。
最后,我们就可以把从传感器中读取到的数据显示在 LCD1602 屏幕上,需要提前把 I2C LCD1602 的驱动文件 上传到 ESP32 中,
注意
本实验需要用到 I2C 驱动代码,代码在 第 12 节课 中。
代码如下:
import time
from machine import Pin, SoftI2C
from dht import DHT11
from libs.i2c_lcd import I2cLcd
# 定义 DHT11 控制对象
dht11 = DHT11(Pin(13))
# 定义 SoftI2C 控制对象
i2c = SoftI2C(sda=Pin(19), scl=Pin(18), freq=100000)
# 获取 I2C 从机地址
address = i2c.scan()[0]
# 定义 I2CLCD 对象
lcd = I2cLcd(i2c, address, 2, 16)
while True:
try:
# 调用 DHT11 的测量方法
dht11.measure()
temp = dht11.temperature()
humid = dht11.humidity()
print(f"温度:{temp}°C, 湿度: {humid}RH")
# 清屏
lcd.clear()
# 显示
lcd.putstr(f"Temp: {temp}\xDFC\n")
lcd.putstr(f"Humid: {humid}RH")
except OSError as e:
print(f"捕获到异常:{e}, 重试中...")
# 延时太短,DHT11 不工作
time.sleep(2)
超声波测距
本节课来学习使用 MicroPython 控制超声波传感器并获取距离数据,最终搭配一个蜂鸣器实现倒车雷达的效果。
实验原理
超声波是一种频率高于 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 |

软件程序设计
在使用超声波传感器测距时,我们需要用到 machine 模块中的 time_pulse_us(pin, pulse_level, timeout_us=1000000) 方法,具体可以参考 MicroPython 官方手册:
time_pulse_us() 的作用是在给定引脚上为脉冲计时,并返回脉冲持续时长,单位为 us。为低脉冲计时时,pulse_level 参数应为 0;为高脉冲计时时,该参数应为 1。
若引脚的当前输入值与 pulse_level 不同,该函数首先需等待,直至引脚输入与 pulse_level 相等;然后为引脚与 pulse_level 相等的时段计时。若引脚已与 pulse_level 相等,则计时立即开始。
1. 超声波测距
该程序的功能是通过超声波模块测算距离并打印在命令行中,代码如下:
import time
from machine import Pin, time_pulse_us
# 定义echo 与 trig 引脚
echo = Pin(14, Pin.IN)
trig = Pin(27, Pin.OUT)
# 将 trigger 引脚设置为低电平
trig.value(0)
while True:
# 发送一个 10us 的方波脉冲
trig.value(1)
time.sleep_us(10)
trig.value(0)
# 获取脉冲时间
pulse_time = time_pulse_us(echo, 1)
# 根据时间计算距离
distance = pulse_time * 0.3432 / 2
print('距离为: %dmm' % distance)
time.sleep(0.1)
由于 HCSR04 很常用,因此,网上也有很多别人封装好的模块,比如下面代码:
import machine, time
from machine import Pin
__version__ = '0.2.0'
__author__ = 'Roberto Sánchez'
__license__ = "Apache License 2.0. https://www.apache.org/licenses/LICENSE-2.0"
class HCSR04:
"""
Driver to use the untrasonic sensor HC-SR04.
The sensor range is between 2cm and 4m.
The timeouts received listening to echo pin are converted to OSError('Out of range')
"""
# echo_timeout_us is based in chip range limit (400cm)
def __init__(self, trigger_pin, echo_pin, echo_timeout_us=500*2*30):
"""
trigger_pin: Output pin to send pulses
echo_pin: Readonly pin to measure the distance. The pin should be protected with 1k resistor
echo_timeout_us: Timeout in microseconds to listen to echo pin.
By default is based in sensor limit range (4m)
"""
self.echo_timeout_us = echo_timeout_us
# Init trigger pin (out)
self.trigger = Pin(trigger_pin, mode=Pin.OUT, pull=None)
self.trigger.value(0)
# Init echo pin (in)
self.echo = Pin(echo_pin, mode=Pin.IN, pull=None)
def _send_pulse_and_wait(self):
"""
Send the pulse to trigger and listen on echo pin.
We use the method `machine.time_pulse_us()` to get the microseconds until the echo is received.
"""
self.trigger.value(0) # Stabilize the sensor
time.sleep_us(5)
self.trigger.value(1)
# Send a 10us pulse.
time.sleep_us(10)
self.trigger.value(0)
try:
pulse_time = machine.time_pulse_us(self.echo, 1, self.echo_timeout_us)
return pulse_time
except OSError as ex:
if ex.args[0] == 110: # 110 = ETIMEDOUT
raise OSError('Out of range')
raise ex
def distance_mm(self):
"""
Get the distance in milimeters without floating point operations.
"""
pulse_time = self._send_pulse_and_wait()
# To calculate the distance we get the pulse_time and divide it by 2
# (the pulse walk the distance twice) and by 29.1 becasue
# the sound speed on air (343.2 m/s), that It's equivalent to
# 0.34320 mm/us that is 1mm each 2.91us
# pulse_time // 2 // 2.91 -> pulse_time // 5.82 -> pulse_time * 100 // 582
mm = pulse_time * 100 // 582
return mm
def distance_cm(self):
"""
Get the distance in centimeters with floating point operations.
It returns a float
"""
pulse_time = self._send_pulse_and_wait()
# To calculate the distance we get the pulse_time and divide it by 2
# (the pulse walk the distance twice) and by 29.1 becasue
# the sound speed on air (343.2 m/s), that It's equivalent to
# 0.034320 cm/us that is 1cm each 29.1us
cms = (pulse_time / 2) / 29.1
return cms
2. 自定义 HCSR04 类
我们可以通过学习他人的代码,并将其转化为自己的代码,从而加深学习,提高自己的编程能力,下面是我仿写的代码:
import time
from machine import Pin, time_pulse_us
# 定义 HCSR04 公共类
class HCSR04:
def __init__(self, trigger_pin, echo_pin):
# 初始化 trigger 与 echo 引脚
self.trigger = Pin(trigger_pin, Pin.OUT)
self.echo = Pin(echo_pin, Pin.IN)
# 将 trigger 引脚设置为低电平
self.trigger.value(0)
def get_distance(self):
# 初始化传感器
self.trigger.value(0)
time.sleep_us(5)
# 通过 T 探头发送 1 个 10us 的脉冲
self.trigger.value(1)
time.sleep_us(10)
self.trigger.value(0)
# 获取脉冲时间
pulse_time = time_pulse_us(self.echo, 1)
# 根据时间计算距离
distance = pulse_time * 0.3432 / 2
return distance
提示
第三方代码尽量放在 libs 目录中,自己写的代码放在 common 目录下
然后,我们就可以调用该模块中的 HCSR04 公共类了,代码如下:
import time
from common.hcsr04 import HCSR04
# 定义 HCSR04 对象
sensor = HCSR04(27, 14)
while True:
# 获取测量距离
distance = sensor.get_distance()
print('距离为: %dmm' %distance)
time.sleep(0.1)
3. 倒车雷达系统
最后,我们可以根据获取的距离来实现倒车雷达的效果,距离近的时候蜂鸣器会报警,LED 闪烁,随着距离越来越近,蜂鸣器发生频率与 LED 的闪烁频率都会越来越频繁,代码如下:
import time
from machine import Pin
from common.hcsr04 import HCSR04
# 定义 HCSR04 对象
sensor = HCSR04(27, 14)
# 定义蜂鸣器与LED对象
buzzer = Pin(15, Pin.OUT)
led = Pin(2, Pin.OUT)
# 初始化
buzzer.value(0)
led.value(0)
while True:
# 获取距离
distance = sensor.get_distance()
print('距离为: %dmm' % distance)
# 如果距离过近则报警,距离越近,报警越频繁
if distance < 200:
delay_time = int(distance)
buzzer.value(1)
led.value(1)
time.sleep_ms(delay_time)
buzzer.value(0)
led.value(0)
time.sleep_ms(delay_time)
else:
buzzer.value(0)
led.value(0)
time.sleep(0.1)
Microdot 搭建 Web 服务
这节课我们来学习,在 ESP32 上搭建一个 Web 服务,并通过这个服务实现点灯的效果。
实验原理
1. Socket 套接字
说到 Socket,就不得不提两个计算机专业词汇最糟糕的翻译:鲁棒性(Robustness) 和 套接字(Socket),翻译之后与没有翻译的效果一样,依然看不懂什么意思。
当涉及到网络通信时,Socket 是一个常见的概念。它是在计算机网络中实现通信的一种抽象概念或编程接口。通过 Socket,不同计算机之间可以建立连接并进行数据交换。
Socket 可以看作是一种通信端点,它使用 IP 地址和端口号来标识不同的设备和应用程序。每个 Socket 都与一个特定的协议相关联,例如 TCP 或 UDP,用于在网络上进行数据传输。
Socket 翻译过来其实就是插座的意思,在台湾和香港被翻译成 网络插座,这种翻译方式其实也很好的反映了 Socket 通信的特点。
Socket 是通信的基石,是支持 TCP/IP 协议的网络通信的基本操作单元。它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:连接使用的协议(通常是 TCP 或 UDP),本地主机的IP地址,本地进程的协议端口,远地主机的 IP 地址,远地进程的协议端口。

从上图可以看到,建立 Socket 通信需要一个服务器端和一个客户端。对于客户端,则需要知道电脑端的 IP 和端口即可建立连接。(端口可以自定义,范围在 0~65535,注意不占用常用的80等端口即可。)
下面是 Socket 的一些关键概念:
IP地址:在计算机网络中,每个设备都有一个唯一的 IP 地址,用于标识设备的位置。IP 地址由一系列数字组成,例如 IPv4 地址是由四个十进制数(0-255)组成,中间用点分隔,如 192.168.0.1。端口号:端口号用于标识一个特定的应用程序或服务,使数据可以传输到正确的目的地。端口号是一个数字,范围从 0 到 65535。0 到 1023 的端口号是为一些特定的服务保留的,例如HTTP的端口号是80,HTTPS的端口号是443。Socket 类型:在 Socket 编程中,有两种常见的套接字类型:Stream Socket(流套接字)和Datagram Socket(数据报套接字)。Stream Socket(流套接字)使用 TCP 协议,提供可靠的、面向连接的通信,确保数据的可靠性和按顺序的传输。Datagram Socket(数据报套接字)使用 UDP 协议,提供无连接的通信,适用于实时性要求高的应用,如音视频传输。
客户端和服务器:在 Socket 通信中,通常有两个主要角色:客户端和服务器。客户端是发起连接请求的一方,通常是一个应用程序或设备。服务器是提供服务的一方,它监听指定的端口号,并等待客户端的连接请求。
通过 Socket,客户端和服务器可以建立连接,并通过发送和接收数据进行通信。客户端可以向服务器发送请求,并接收服务器的响应。服务器可以接收客户端的请求,并向客户端发送响应。
在实际的 Socket 编程中,使用不同编程语言提供的 Socket API,如 C/C++ 的 socket 库、Python 的 socket 模块等,来创建、连接、发送和接收数据。这些 API 提供了一组函数和方法,开发者可以使用这些函数和方法来实现网络通信的各个方面。
总而言之,Socket 是一种用于实现网络通信的抽象概念,通过使用 IP 地址和端口号,不同计算机之间的应用程序可以建立连接,并通过发送和接收数据进行通信。
所以,socket 的出现只是可以更方便的使用 TCP/IP 协议栈而已,简单理解就是其对 TCP/IP 进行了抽象,形成了几个最基本的函数接口。比如 create,listen,accept,connect,read 和 write 等等。以下是通讯流程:
以上的内容,简单来说就是如果用户面向应用来说,那么 ESP32 只需要知道通讯协议是 TCP 或 UDP、服务器的 IP 和端口号这 3 个信息,即可向服务器发起连接和发送信息。
2. 前端
我们刚刚也说了 ESP32 可以作为服务端,也就是说把 ESP32 作为一个服务器,实际上呢,这种方式并不常用,因为 ESP32 的性能与一般的云服务器完全没有可比性,但是也许有一些特殊的情况,导致我们不得不使用 ESP32 作为服务器的时候。
我们平时用到的 HTTP 服务有很多,比如我们现在打开一个网站,看到的这个页面,这个页面中的内容其实也是代码编写出来的,我们可以点击键盘的 F12,打开 开发者工具,查看页面元素,就可以看到我们页面的源代码了。

我们需要明白搭建一个 Web 服务一般都会包括前端和后端,如果你只是搭建一个 API 服务的话,前端就可以不涉及。
前端是指在 Web 开发中负责实现用户界面和用户交互的部分。前端开发涉及使用 HTML、CSS 和 JavaScript 等技术来构建网页,并与用户进行互动。当涉及到前端开发时,HTML、CSS 和 JavaScript 是三种主要的技术,它们共同构成了现代 Web 页面的基础。
HTML(超文本标记语言):
- HTML 是一种标记语言,用于定义网页的结构和内容。
- 使用 HTML 标签,可以创建网页的各种元素,如标题、段落、图像、链接等。
- HTML 使用标签(例如 div、p、img)和属性(例如class、id、src)来描述页面元素的结构和属性。
CSS(层叠样式表):
- CSS 用于描述网页的样式和外观。
- 使用 CSS 选择器和属性,可以为 HTML 元素指定样式,如字体、颜色、布局等。
- CSS 样式可以直接嵌入在 HTML 中,也可以在外部 CSS 文件中定义,并通过链接引入到 HTML 中。
JavaScript:
- JavaScript 是一种脚本语言,用于为网页添加交互和动态功能。
- JavaScript 可以通过操作 HTML 元素、处理用户输入、发送网络请求、处理数据等来实现各种功能。
- JavaScript 可以直接嵌入在 HTML 中,也可以作为外部脚本文件链接到 HTML 中。
这三种技术相互配合,实现了现代 Web 页面的开发和呈现,HTML 定义了网页的结构和内容,CSS定义了网页的样式和外观,JavaScript 为网页添加了交互和动态功能。
通过使用 HTML、CSS 和 JavaScript,前端开发者可以创建吸引人、交互式的 Web 页面和应用程序。HTML 负责构建页面结构,CSS 负责设计页面样式,JavaScript 负责处理用户行为和动态交互。这三种技术共同构建了现代 Web 开发的基础。
硬件电路设计
由于 ESP32 内置 WiFi 功能,所以直接在开发板上使用即可,无需额外连接。
软件程序设计
MicroPython 目前有两个 Web 框架:microWebSrv 和 Microdot。
由于 microWebSrv 不支持 ESP8266,所以,我们选择学习如何使用 Microdot。
Microdot 是一个受 Flask 启发的简约 Python Web 框架,被设计为轻量级的 Web 服务器,适用于资源受限的嵌入式设备,例如 ESP32,ESP8266。它只需要很少的 RAM 和存储空间,并且具有较低的 CPU 消耗.
你可以通过定义多个路由来处理不同的 URL 请求。每个路由由 URL 路径和相应的处理函数组成。当收到匹配的请求时,服务器将调用相应的处理函数。Microdot 还支持静态文件服务,可以轻松地将静态文件(如 HTML、CSS、JavaScript、图像等)提供给客户端。你只需要指定一个目录,服务器将自动处理静态文件的请求。集成了简单的模板引擎,使您可以轻松地生成动态的HTML响应。您可以在 HTML 文件中定义占位符,然后使用模板引擎将占位符替换为实际的值。
Microdot 支持 HTTP 的 GET 和 POST 请求。您可以通过定义相应的路由和处理函数来处理不同类型的请求。
下面是一个简单的介绍如何使用 Microdot:
首先,我们需要在 Microdot 的 GitHub 仓库下载对应的文件 microdot.py,或者,在我们的 资料包 中的 3.开发工具 也能找到 Microdot 的源代码,并把该代码复制到 Micropython 设备中的 libs 目录下

接着需要创建 Microdot 应用,导入 Microdot 模块,并创建一个 Microdot 应用对象
from libs.microdot import Microdot
app = Microdot()
在 Microdot 中,使用路由来指定 URL 与视图函数之间的关系。视图函数是处理请求并生成响应的函数。可以使用 @app.route 装饰器来定义路由,例如:
@app.route('/')
def home():
return 'Hello, MicroDot!'
在上述示例中,@app.route('/')定义了一个根路由,它对应于网站的根 URL。home()函数是视图函数,当访问根 URL 时,该函数将被执行。在此示例中,它简单地返回一个字符串作为响应。
最后就是运行这个应用,只需要在 Python 文件的末尾,添加一个简单的代码块:
if __name__ == '__main__':
app.run()
这样我们就会启动 Microdot 开发服务器,并且监听默认的 5000 端口。你可以在手机端或者浏览器中访问 http://ESP32的局域网IP:5000 来查看应用程序的响应
您可以根据需要在应用程序中添加更多的路由和视图函数。通过定义不同的路由和对应的视图函数,您可以实现不同的页面和功能。
Microdot 还提供了许多功能和扩展,例如模板引擎、表单处理、数据库集成等。您可以根据需求选择适合您的扩展来增强您的应用程序。
当然,Microdot 的内容不止这些,还有很多功能可以参考 Microdot 使用文档
所以,我们可以通过以下方式实现手机 LED 亮灭的效果:
注意
这段代码并不规范!目的是为了让初学者快速的搭建一个 Web 服务,在生产环境中,有更多的协议和规范,来让你实现手机控制 LED 亮灭的效果。
from machine import Pin
from common.wifi import wifi_connect
from libs.microdot import Microdot
led_pin = Pin(2, Pin.OUT)
# 定义 WIFI 的账号密码
ssid = 'GeeksMan'
password = '123456qq.'
# 连接 WiFi
wifi_connect(ssid, password)
app = Microdot()
htmldoc = '''<!DOCTYPE html>
<html>
<head>
<title>ESP32 MicroPython Microdot Web 服务</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<div>
<h1>ESP32 MicroPython Microdot Web 服务</h1>
<p>你好,Microdot!</p>
<p><a href="/shutdown">关闭服务</a></p>
<p><a href="/led">开关灯</a></p>
</div>
</body>
</html>
'''
@app.route('/')
def hello(request):
return htmldoc, 200, {'Content-Type': 'text/html'}
@app.route('/shutdown')
def shutdown(request):
request.app.shutdown()
return 'The server is shutting down...'
@app.route('/led')
def led(request):
led_pin.value(not led_pin.value())
return htmldoc, 200, {'Content-Type': 'text/html'}
app.run(host='0.0.0.0', port=5000, debug=True)
这些只是一些简单的 Web 服务的搭建,如果你真的想要学习 Python Web 开发的话,可以考虑学习 Django/Flask/FastAPI,首推 Django,因为 Django 自带后端管理系统,可以让你少写一点前端的代码,并且实现数据可视化管理操作。
Web 开发模式
目前主流 Web 开发模式有两种,分别是:
基于服务端渲染的传统 Web 开发模式,服务器发送给客户端的 HTML 页面,是在服务器通过字符串拼接,动态生成的。不需要使用 Ajax、Axios 这样的网络请求库额外请求页面的数据。优点是前端耗时少。因为服务器端负责动态生成 html 内容,浏览器只需要直接渲染页面即可,有利于 SEO。因为服务器端响应的是完整的 html 页面内容,所有爬虫更容易爬取获得信息,更有利于 SEO。缺点是占用服务器端资源。因为服务器端完成 HTML 页面内容的拼接,如果请求较多,给服务器造成一定的访问压力,不利于前后端分离,开发效率低。使用服务器渲染,无法进行分工合作,尤其对于前端复杂度高的项目,不利于项目高效开发。
基于前后端分离的新型 Web 开发模式,依赖于 Ajax、Axios、fetch 网络请求技术的广泛应用。就是后端只负责提供 API 接口,前端使用网络请求调用接口的开发模式。优点是开发体验好。前端专注于 UI 页面开发,后端专注于 API 的开发,且前端有更多的选择性。用户体验好。Ajax 技术的广泛应用,极大的提高了用户体验,可以轻松实现页面局部刷新。减轻了服务器端的渲染压力。因为页面最终是在每个用户的浏览器中生成的。不利于 SEO。因为完整的 html 页面内容在客户端动态拼接完成,爬虫对无法爬取页面有效信息。(解决方案:利用 Vue、React 等前端框架的 SSR 技术能够很好的解决 SEO 问题)。
当前我们这段代码使用的就是第一种服务端渲染的前后端不分离的开发模式,如果你只是想开发一些简单的小项目,使用这种方式是没有问题的,但是,项目复杂度高一点之后,就会导致后期维护困难。因此,如果你只是想要做一些小项目,可以选择服务器渲染,在 Python 中可以使用 Flask 框架,如果你想做一些复杂度高一点的项目,并且前后端分离,可以选择使用 Django 与 Djanog RestFramework 插件,前端使用 Vue/React。
旋转编码器
本节课来学习使用 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. 计数器
第一个程序,我们来通过代码获取旋转编码器的转动方向,做一个计数器,顺时针旋转则加,逆时针旋转则减
from machine import Pin
# 定义控制引脚
a = Pin(26, Pin.IN)
b = Pin(27, Pin.IN)
# 记录上一个 A 的电平状态
last_state_a = a.value()
# 初始化计数器数值
count = 0
while True:
# 获取当前 a 的电平状态
current_state_a = a.value()
# 检测 a 引脚的电平变化, 为避免重复计数,只检测上升沿或者下降沿
if not current_state_a and last_state_a:
# 如果 AB 不相等说明顺时针转动,反之则为逆时针
if b.value() != current_state_a:
count += 1
print(f' 顺时针转动, {count}')
else:
count -= 1
print(f' 逆时针转动, {count}')
# 更新上一个 A 的电平状态
last_state_a = current_state_a
我们也可以使用外部中断的方法来改写以上代码,代码如下:
from machine import Pin
a = Pin(26, Pin.IN)
b = Pin(27, Pin.IN)
count = 0
# 记录上一个 a 引脚状态
last_state_a = a.value()
def a_func(a):
global count, last_state_a
# 获取当前的 a 引脚电平
current_state_a = a.value()
if current_state_a != last_state_a and b.value():
if current_state_a != b.value():
count += 1
print(f" 顺时针旋转, count: {count}")
else:
count -= 1
print(f" 逆时针旋转, count: {count}")
last_state_a = current_state_a
a.irq(a_func, Pin.IRQ_FALLING|Pin.IRQ_RISING)
2. 旋转编码器控制菜单
最后,我们使用旋转编码器与 OLED 屏幕实现一个控制菜单的实验,该实验需要用到 SPI 驱动 OLED 液晶屏幕 中的 SSD1306.py 驱动文件,将 SSD1306.py 文件上传到 ESP32 设备中的 libs 目录中即可,代码如下:
from machine import Pin, SoftSPI
from libs.ssd1306 import SSD1306_SPI
# 定义 SOFTSPI 对象
spi = SoftSPI(sck=Pin(18), mosi=Pin(5), miso=Pin(19))
# 创建 OLED 对象
oled = SSD1306_SPI(width=128, height=64, spi=spi, dc=Pin(2), res=Pin(15), cs=Pin(4))
# 定义旋转编码器对象
a = Pin(26, Pin.IN)
b = Pin(27, Pin.IN)
# 记录上一个 A 的电平状态
last_state_a = a.value()
# 定义菜单选项
menu_items = ['Item 1','Item 2','Item 3','Item 4' ]
# 定义当前索引位置
current_item = 0
# 显示菜单的函数
def display_menu(index):
oled.fill(0)
oled.text('Menu', 0, 0)
oled.text('-'*20, 0, 10)
for i in range(len(menu_items)):
if i == index:
oled.text('> ' + menu_items[i], 0, 20 + i * 10)
else:
oled.text(menu_items[i], 0, 20 + i * 10)
oled.show()
# 初始化显示屏幕的状态
display_menu(current_item)
while True:
# 获取当前 a 的电平状态
current_state_a = a.value()
# 检测 a 引脚的电平变化, 为避免重复计数,只检测上升沿或者下降沿
if not current_state_a and last_state_a:
# 如果 AB 不相等说明顺时针转动,反之则为逆时针
if b.value() != current_state_a:
current_item = (current_item + 1) % (len(menu_items))
else:
current_item = (current_item - 1) % (len(menu_items))
display_menu(current_item)
# 更新上一个 A 的电平状态
last_state_a = current_state_a