基于 UINIO-MCU-ESP32 的 Arduino 进阶教程
Arduino-ESP32
是由乐鑫科技在 GitHub
开源社区推出的一款基于 Arduino IDE
的板级支持包(BSP,Board Support
Package),除了兼容大部分通用的 Arduino
API 之外,还能够支持 ESP32
系列芯片一些独有的特性化
API。由于几年以前已经撰写过一篇基于标准 Arduino API
的《玩转 Arduino
Uno、Mega、ESP 开源硬件》,所以本篇文章不再赘述相关内容,而是结合
U8G2
、AsyncTimer
、RBD_BUTTON
、LiquidCrystal_I2C
、ESP32SPISlave
、Servo
、SdFat
等常用第三方库,通过分析注释典型的示例代码,分门别类的介绍了各种片上资源外设的实例化运用。
ESP32-C3 和 ESP32-S3 是当前市场上比较流行的两款物联网主控芯片方案,它们分别基于开源的 RISC-V 内核,以及商业化的 Xtensa 内核,并且同时支持 WiFi 与 Bluetooth 无线连接。由于日常工作当中经常使用到这两款微控制器,所以特意设计了 UINIO-MCU-ESP32C3 和 UINIO-MCU-ESP32S3 两款核心板,关于它们硬件电路设计方面的相关内容,可以进一步参考本篇文章的姊妹篇《UINIO-MCU-ESP32 核心板电路设计》。由于本文属于 Arduino 进阶性质的教程,阅读时需要具备一定的嵌入式开发经验,萌新可以阅读笔者更早之前撰写的《玩转 Arduino Uno、Mega、ESP 开源硬件》。
Arduino IDE 2 开发环境
Arduino IDE 2 相较于之前的 1.8.19
版本,提供了更加友好的用户界面,新增了自动补全
、内置调试器
、Arduino Cloud 同步
等功能,拥有一个改进的侧边栏,使得常用的功能更加易于访问,详细用法可以查阅
Arduino 官方提供的《Arduino IDE 2
Tutorials》:
注意:Arduino IDE 创建的以
.ino
作为后缀名的源代码文件,被称为草图(Sketche)文件。
Arduino-ESP32 库概览
乐鑫科技在 GitHub 开源社区推出的 Arduino-ESP32
板级支持包,目前已经更新到 2.0.11
版本,通过向
Arduino IDE
的【开发板管理器】添加如下的开发板管理器地址
,就可以完成
Arduino-ESP32 板级支持包的安装:
- 稳定版本链接:
https://espressif.github.io/arduino-esp32/package_esp32_index.json
- 开发版本链接:
https://espressif.github.io/arduino-esp32/package_esp32_dev_index.json
Arduino-ESP32 提供了对于 ESP32、ESP32-S2、ESP32-C3、ESP32-S3 系列芯片的支持,各个片上外设的具体兼容情况可以参见下表:
注意:所有 ESP32 系列芯片都支持 SPI 以太网,其中 RMII 只有 ESP32 能够支持。
《ESP32 Arduino 核心文档》 当中提供了如下这些 API 的使用说明,具体内容可以点击下面表格当中的链接逐一查阅:
安装完成 CH343P 的 USB 转串口驱动程序之后,就可以将 UINIO-MCU-ESP32 核心板连接至电脑,再打开 Arduino IDE 选择【ESP32C3 Dev Module】或者【ESP32S3 Dev Module】开发板,以及相应的 USB 端口,就可以完成全部的开发连接准备:
接下来,编写如下的代码,以 115200
波特率向
Arduino IDE 的【串口监视器】打印字符串
Welcome to UinIO.com
:
1 | /* 该函数只调用一次 */ |
如果 Arduino IDE 的【串口监视器】当中正确打印出了如下结果,就表明当前的开发环境已经搭建成功了:
1 | Welcome to UinIO.com |
注意:笔者设计的 UINIO-MCU-ESP32C3 和 UINIO-MCU-ESP32S3 两款开源硬件在本文后续内容当中。都将会被统称为 UINIO-MCU-ESP32,如果没有进行特殊说明,那么所有示例代码都同时兼容两款核心板。
LED 定时闪烁(阻塞 & 非阻塞)
发光二极管(LED,Light Emitting
Diode)在正向导通之后就会发光,对于直插式发光二极管(长脚为正,短脚为负),其红色和黄色的正向压降为
2.0V ~ 2.2V
,而绿色、白色、蓝色产生的正向压降为
3.0V ~ 3.2V
,额定工作电流介于 5mA ~ 20mA
范围之间。接下来以红色发光二极管为例,介绍其限流电阻的计算方法。
首先,红色 LED 正常工作时产生的压降约为 2.0V
,而 ESP32
引脚输出的高电平为 3.3V
,此时限流电阻上流过的电压等于
3.3 - 2.0 = 1.3V
,而红色发光二极管的额定电流约为
10mA
,所以这个限流电阻的取值应当为 \(\frac{1.3V}{0.01A} =
130Ω\),这里近似的取电阻标称值为
120Ω
,并且将其连接到 Arduino-MCU-ESP32 的
GPIO0 引脚,具体的电路连接关系如下图所示:
注意:ESP32 系列芯片高电平信号的最低电压值为
3.3V × 0.8 = 2.64V
,而低电平信号的最高电压值为3.3V × 0.1 = 0.33V
。
pinMode(pin, mode)
:配置引脚工作模式,其中mode
参数可选的值有INPU
、OUTPUT
、INPUT_PULLUP
、INPUT_PULLDOWN
;digitalWrite(pin, value)
:设置数字输出引脚的电平状态,其中value
参数可选的值是HIGH
或者LOW
;delay(ms)
:延时函数,其参数ms
的单位为毫秒;
1 | int LED_Pin = 0; |
由于使用 delay()
延时函数会阻塞后续任务的执行,所以这里改用如下两个
API,通过循环计算时间差值的方式来实现 LED 灯的闪烁:
millis()
:程序当前运行的毫秒数;micros()
:程序当前运行的微秒数;
下面的示例代码通过 UINIO-MCU-ESP32 的
GPIO0
引脚控制一个 LED 灯,每间隔 1
秒循环不断的进行闪烁:
1 | int LED_Pin = 0; |
如果需要控制多个 LED
的闪烁,则需要将电路连接关系修改为下面的样子,此时控制引脚需要变更为
UINIO-MCU-ESP32 的 GPIO1
和
GPIO2
:
注意需要同步修改代码当中控制引脚变量 LED_Pin_x
的值,其它的功能代码只需要进行相应的复制粘贴即可:
1 | int LED_Pin_1 = 1; // 将 LED 1 的控制引脚设置为 GPIO1 |
按键控制 与 RBD_BUTTON 库
本示例需要将 UINIO-MCU-ESP32 的 GPIO3
和 GPIO4
分别连接至 LED
和按键:
由于按键的控制引脚被配置为输入上拉
INPUT_PULLUP
,所以当按键被按下时低电平有效,读取引脚的电平状态需要使用到如下的
API:
digitalRead(pin)
:读取指定输入引脚pin
的电平状态,返回值是HIGH
或者LOW
;
1 | int LED_Pin = 3; // LED 控制引脚 |
观察上述代码的运行结果,可以发现按键对于 LED
亮灭状态的控制并不准确,这是由于按键在按下时,触点的接触不够稳定所导致。在这里我们可以方便的借助
RBD_BUTTON
这款第三方库来消除这种抖动。接下来在 Arduino IDE
当中安装 RBD_Button 以及关联的
RBD_Timer 依赖库,由于该库所提供的
Button
类位于 C++ 的RBD
命名空间当中,所以其构造函数的调用形式应当书写为:
1 | RBD::Button constructor(pin,[input, input_pullup, input_pulldown]) |
Button
类当中提供了如下一系列可以用于消除按键抖动的方法:
button.isPressed()
:当按键被按下或开启时返回true
,否则返回false
;button.isReleased()
:当按键弹起或者释放时返回true
,否则返回false
;button.onPressed()
:当按钮被按下(已经去除抖动)一次以后返回true
,接下来必须释放按钮,并且再次按下才能够返回true
;button.onReleased()
:当按钮被释放(已经去除抖动)一次以后返回true
,接下来必须按下按钮,并且再次释放才能够返回true
;button.setDebounceTimeout(value)
:设置消除抖动的时间,参数的单位为毫秒;
修改前面的示例代码,加入按键消抖的处理逻辑,可以看到在消除抖动错误的同时,代码的书写也得到了极大简化:
1 |
|
基于 PWM 的 LEDC
LED 发光二极管的正常工作电压介于
1.8V ~ 2.0V
之间,由于该电压变化区间的取值范围较小,难以通过电压大小来控制 LED
的亮度。而脉冲宽度调制(PWM,Pulse
Width
Modulation)则另辟蹊径,通过改变输出方波的占空比来控制
LED 的亮灭频率,从而达到调整亮度的目的。
ESP32-C3 和 ESP32-S3 各拥有
6
和 8
个 LEDC
通道,分别用于产生独立的 PWM 波形信号,最大精度为 14
位。Arduino-ESP32 提供了专门的 LED
控制 API(LEDC,LED Control),可以方便的以 PWM 方式来控制
LED 的亮度,具体的 API 方法可以参考下面的列表:
API | 功能描述 |
---|---|
uint32_t ledcSetup(uint8_t channel, uint32_t freq, uint8_t resolution_bits); |
用于设置 LEDC 通道的频率和分辨率; |
void ledcWrite(uint8_t chan, uint32_t duty); |
设置指定 LEDC 通道的占空比; |
uint32_t ledcRead(uint8_t chan); |
获取指定 LEDC 通道的占空比; |
uint32_t ledcReadFreq(uint8_t chan); |
获取指定 LEDC 通道的频率; |
uint32_t ledcWriteTone(uint8_t chan, uint32_t freq); |
用于在指定频率上将 LEDC 通道设置为
50% 占空比的 PWM 音调; |
uint32_t ledcWriteNote(uint8_t chan, note_t note, uint8_t octave); |
用于将 LEDC 通道设置为指定的音符; |
void ledcAttachPin(uint8_t pin, uint8_t chan); |
用于将指定的 GPIO 引脚绑定至 LEDC 通道; |
void ledcDetachPin(uint8_t pin); |
用于取消指定的 GPIO 引脚与 LEDC 通道的绑定; |
uint32_t ledcChangeFrequency(uint8_t chan, uint32_t freq, uint8_t bit_num); |
用于动态改变 LEDC 通道的频率; |
void analogWrite(uint8_t pin, int value); |
用于在指定 GPIO
引脚上写入模拟值(PWM 波形信号),该接口兼容 Arduino
官方的 analogWrite() 函数; |
void analogWriteResolution(uint8_t bits); |
用于设置所有 analogWrite()
通道的分辨率; |
void analogWriteFrequency(uint32_t freq); |
用于设置所有 analogWrite()
通道的频率; |
下面的示例代码将 LEDC 配置为 0
通道,工作频率为 5000
赫兹,精度为 12
位(即将一个周期划分为 \(2^{12}\) 等分)。如果需要将其占空比调整为
50%
,那么高电平就需要占据 \(2^{12} \div 2 = 2^{12 - 1} = 2^{11}\)
等分:
1 | void setup() { |
接下来再利用 LEDC 和 PWM
实现一个呼吸灯效果,具体策略为每秒钟调整占空比 50
次,假设
T
为呼吸周期,那么 LED 从熄灭到最高亮度需要经过的时间为
\(\frac{T}{2}\)(即半个呼吸周期)。这样每半个周期就需要进行
\(50 \times \frac{T}{2}\)
次占空比调整,而 count
表示占空比为 100%
时候的等分数量,step
就是每次占空比调整所需要增加的步进值
\(step = \frac{count}{50 \times \frac{T}{2}} =
2 \times \frac{count}{50 \times T}\),当占空比超过
Count
时,就需要逐步将 Step
步进值递减至
0
:
1 | int GPIO4 = 4; // 指定 GPIO 引脚 4 |
上面代码当中的 delay()
函数会阻塞
UINIO-MCU-ESP32 的后续代码运行,下面通过
prevTime
和 curTime
两个变量来循环计算时间差值,实现一个非阻塞式的呼吸灯:
1 | int GPIO4 = 4; // 指定 GPIO 引脚 4 |
软件定时器 与 AsyncTimer 库
ESP32-C3 和 ESP32-S3 分别拥有
2
个和 4
个硬件定时器,虽然它们的精度较高,但是数量着实有限。在一些对于精度要求不高的场合,可以考虑使用诸如
AsyncTimer
这样的第三方库来作为软件定时器使用,它适用于一些对于精度要求不高的场合(精度为毫秒级别),具体的使用步骤如下面所示:
- 首先,在 Arduino IDE
的【库管理器】当中安装
AsyncTimer
库; - 然后,在工程代码当中包含头文件
#include <AsyncTimer.h>
; - 接下来,声明定时器变量
AsyncTimer timer
; - 最后,在
void loop()
函数当中调用t.handle()
;
下面的示例代码,会通过 AsyncTimer 提供的
setTimeout()
函数,分别延时 3
秒和
5
秒向串口打印提示信息:
1 |
|
同样的,可以通过类似的方式调用 AsyncTimer 的
setInterval()
函数,周期性的不断重复向串口打印提示信息:
1 |
|
注意:注意每次调用
setTimeout()
和setInterval()
之后返回的 ID 值都并不相同。
接下来,结合前面介绍的 RBD_Button 和 AsyncTimer 两个第三方库,让一个 LED 在刚开始启动的时候,每间隔 1 秒钟进行闪烁,而在按下按键之后,再切换至间隔 3 秒进行闪烁,再次按下按键则切换回间隔 1 秒进行闪烁,这里依然沿用之前的按键与 LED 实验电路:
1 |
|
ADC 模数转换
模数转换器(ADC,Analog to Digital Converter)是一种常见外设,用于将模拟信号转换为便于 ESP32 微控制器,读取与处理的数字信号。
- ESP32-C3 集成有两个 12 位的逐次逼近寄存器型(SAR, Successive Approximation Register)ADC,一共支持 6 个模拟通道输入,其中 ADC1 支持 5 个模拟通道输入(已工厂校准),而ADC2 支持 1 个模拟通道输入(未工厂校准);
- ESP32-S3 同样集成有两个 12 位逐次逼近寄存器型 ADC,一共拥有 20 个模拟输入通道,乐鑫官方推荐优先使用 ADC1;
Arduino-ESP32 当中针对 ADC 外设,提供了如下一系列通用的 API 函数:
Arduino 通用的 ADC API | 功能描述 |
---|---|
uint16_t analogRead(uint8_t pin); |
获取指定引脚或者 ADC 通道的原始值。 |
uint32_t analogReadMilliVolts(uint8_t pin); |
获取指定引脚或者 ADC 通道的原始值(以毫伏为单位)。 |
void analogReadResolution(uint8_t bits); |
设置 analogRead()
返回值的分辨率,ESP32S3 默认为 13
位(从 0 到 8191 ),其它型号默认为
12 位(从 0 到 4095 )。 |
void analogSetClockDiv(uint8_t clockDiv); |
设置 ADC 时钟的分频器,范围为
0 ~ 255 ,默认值为 1 。 |
void analogSetAttenuation(adc_attenuation_t attenuation); |
设置全部通道的衰减系数,共拥有
ADC_ATTEN_DB_0 、ADC_ATTEN_DB_2_5 、ADC_ATTEN_DB_6 、ADC_ATTEN_DB_11
四个选项。 |
void analogSetPinAttenuation(uint8_t pin, adc_attenuation_t attenuation); |
设置指定引脚或者 ADC 通道的衰减系数。 |
bool adcAttachPin(uint8_t pin); |
将 GPIO 引脚关联至 ADC,关联成功返回
true ,否则返回 false 。 |
ADC 衰减系数 | ESP32-C3 可测量输入电压范围 | ESP32-S3 可测量输入电压范围 |
---|---|---|
ADC_ATTEN_DB_0 |
0 mV ~ 750 mV | 0 mV ~ 950 mV |
ADC_ATTEN_DB_2_5 |
0 mV ~ 1050 mV | 0 mV ~ 1250 mV |
ADC_ATTEN_DB_6 |
0 mV ~ 1300 mV | 0 mV ~ 1750 mV |
ADC_ATTEN_DB_11 |
0 mV ~ 2500 mV | 0 mV ~ 3100 mV |
注意:ESP32S3 的最高采样分辨率为 13 位,由于计数范围从
0
开始进行计数,所以其最大计数值为 \(2^{13} - 1 = 8191\),同理 ESP32C3 的最大计数值等于 \(2^{12} - 1 = 4095\)。
ESP32 专用的 ADC API | 功能描述 |
---|---|
void analogSetWidth(uint8_t bits); |
设置硬件采样分辨率,取值范围为
9 ~ 12 ,默认值是 12 ; |
void analogSetVRefPin(uint8_t pin); |
设置需要进行 ADC 校准的引脚。 |
int hallRead(); |
读取连接至 36 (SVP)和
39 (SVN)引脚的霍尔传感器 ADC 值。 |
接下来通过 ADC 完成一个实验,使用电位器调整 UINIO-MCU-ESP32 的 ADC 引脚所读取到的输入电压,然后根据这个输入电压的大小,调节 GPIO 引脚输出信号的占空比,从而达到调整 LED 亮度的目的,UINIO-MCU-ESP32 的电路连接关系如下图所示:
可以看到,这里把 UINIO-MCU-ESP32 的
GPIO2
引脚连接至电位器,而 GPIO1
作为 LED
发光二极管的控制引脚,接着编写并且上传如下的控制逻辑代码:
1 |
|
I²C 总线主从通信
内部集成电路总线(I²C,Inter-Integrated
Circuit)是一种低速串行通信协议(标准模式
100 Kbit/s
,快速模式 400 Kbit/s
),采用
SDA
(串行数据线)和
SCL
(串行时钟线)两线制结构(需要使用上拉电阻),分别可以连接多个设备,每个设备都拥有唯一的
7 位地址(最多 128
个设备)。Arduino-ESP32 的 I²C 库实现了 Arduino
Wire 官方库当中的如下一系列 API 函数:
I²C 通用 API | 功能描述 |
---|---|
bool begin(); |
基于默认参数配置 I²C
外设,正确初始化之后返回 true 。 |
bool setPins(int sdaPin, int sclPin); |
用于定义 SDA 和
SCL 引脚,两个参数的默认值分别为 GPIO21 和
GPIO22 。 |
bool setClock(uint32_t frequency); |
设置 I²C
总线的时钟频率,默认为 100KHz 。 |
uint32_t getClock(); |
获取 I²C 总线的时钟频率。 |
void setTimeOut(uint16_t timeOutMillis); |
设置 I²C 总线超时时间(毫秒)。 |
void setTimeOut(uint16_t timeOutMillis); |
获取 I²C 总线超时时间(毫秒)。 |
size_t write(const uint8_t *, size_t); |
将数据写入到总线缓冲区,返回值为写入数据的大小。 |
bool end(); |
完成 I²C 通信并且释放之前所有被分配的外设资源。 |
Arduino-ESP32 当中的 I²C 总线可以分别运行于主设备(I²C Master Mode)和从设备(I²C Slave Mode)两种不同的工作模式:
I²C 主设备模式:该模式用于向从设备发起通信,由主设备发出时钟信号,并且负责发起与从设备的通信。
I²C 从设备模式:时钟信号依然由主设备产生,如果 I²C 地址与从设备匹配,那么这个从设备就会响应主设备。
I²C 主设备模式
下面的表格展示了 I²C 总线工作在主设备模式下时所使用到的 API:
I²C 主设备模式 API | 功能描述 |
---|---|
bool begin(int sdaPin, int sclPin, uint32_t frequency) |
指定 I²C 总线的 SDA 和
SCL 引脚,以及通信频率。 |
void beginTransmission(uint16_t address) |
开始启动与指定 I²C 地址从设备的通信。 |
uint8_t endTransmission(bool sendStop); |
将数据写入至缓冲区以后,使用该函数把数据发送给从设备,参数
sendStop 用于使能 I²C 总线停止信号。 |
uint8_t requestFrom(uint16_t address, uint8_t size, bool sendStop) |
要求从设备向主设备发送响应数据。 |
上述 API 函数的基本使用步骤如下面的列表所示:
#include "Wire.h"
,包含Wire.h
头文件;Wire.begin()
,开始配置 I²C 总线;Wire.beginTransmission(I2C_DEV_ADDR)
,指定 I²C 从设备地址,开始进行数据传输;Wire.write(x)
,把数据写入到缓冲区;Wire.endTransmission(true)
,将缓冲区的全部数据写入至从设备;Wire.requestFrom(I2C_DEV_ADDR, SIZE)
,请求读取指定从设备的数据;Wire.readBytes(temp, error)
,开始读取从设备响应的数据;
下面是一个如何在主设备模式下使用 I²C 总线的示例代码:
1 |
|
I²C 从设备模式
下面的表格展示了 I²C 总线工作在从设备模式下时所使用到的 API:
I²C 从设备模式 API | 功能描述 |
---|---|
bool Wire.begin(uint8_t addr, int sdaPin, int sclPin, uint32_t frequency) |
在从设备模式下,必须通过传递从设备的地址来调用
begin() 函数。 |
void onReceive( void (*)(int) ) |
定义从设备接收主设备数据的回调函数。 |
void onRequest( void (*)(void) ) |
定义从设备请求主设备数据的回调函数。 |
size_t slaveWrite(const uint8_t *, size_t) |
接收到响应数据之前,该函数用于向从设备的缓冲区写入数据。 |
上述 API 函数的基本使用步骤如下面的列表所示:
#include "Wire.h"
,包含Wire.h
头文件;Wire.onReceive(onReceive)
和Wire.onRequest(onRequest)
,创建两个回调函数来接收或者请求主设备的数据;Wire.begin((uint8_t)I2C_DEV_ADDR);
,使用指定的地址配置 I²C 总线;Wire.slaveWrite((uint8_t *)message, strlen(message));
,预先向从设备的缓冲区写入数据;
下面是一个如何在从设备工模式下使用 I²C 总线的示例代码:
1 |
|
主从设备通信实例
接下来,以 UINIO-MCU-ESP32S3 作为主设备,而
UINIO-MCU-ESP32C3 作为从设备(I²C 地址为
55
),两者的 SDA
和 SCK
都分别指定为为 GPIO5
和 GPIO6
,并且在从设备的
GPIO8
上面连接一枚 LED:
UINIO-MCU-ESP32S3 作为主设备,每间隔 2
秒就会向从设备 UINIO-MCU-ESP32C3
发送一个递增的数值,从设备接收到主设备的数据之后
LED 就会闪烁 0.5 秒,并且在收到的数值后面添加 已经被接收
字样,然后返回给主设备打印至串口,具体的示例代码如下面所示:
1 | /* UINIO-MCU-ESP32S3 主设备程序 */ |
如果主设备 requestFrom()
所指定的
quantity
参数的数据量,大于从设备发送过来的数据量,那么多出的空间将会由
0xff
进行填充。
1 | /* UINIO-MCU-ESP32C3 从设备程序 */ |
PCF8574 驱动 1602 液晶屏
1602 字符型液晶显示屏,一共可以显示 2
行内容,每一行可以显示 16
个字符,屏幕驱动芯片采用了日立的
HD44780,由于该屏幕在使用时需要占用大量 GPIO
引脚。所以需要借助德州仪器的 PCF8574 八位 GPIO
扩展器(工作电压介于 2.5V ~ 5.5V
范围),将其转换为两线制的
I²C 总线协议。
注意:PCF8574 的 I²C 地址默认为
0x27
,可以通过0Ω
电阻调整 PCF8574 模组A0
、A1
、A2
位置的通断来修改其 I²C 地址。除此之外,还可以通过 PCF8574 模组上面的电位器,调整 1602 液晶显示屏的对比度。
LiquidCrystal_I2C
是一款兼容 HD44780 和 PCF8574 的 LCD
屏幕驱动库,使用时需要将其工程 src
目录下的
LiquidCrystal_I2C.cpp
和 LiquidCrystal_I2C.h
文件拷贝至 Arduino IDE 的草图根目录,然后通过
#include "LiquidCrystal_I2C.h"
语句将其包含至 Arduino
草图源文件:
1 |
|
注意:需要将 PCF8574 模块上丝印为
SDA
和SCL
的引脚,分别连接至 UINIO-MCU-ESP32 的GPIO5
和GPIO6
引脚。
外部中断 & 自旋锁
中断(Interrupt)是指计算机运行过程当中,如果出现某些意外情况需要干预时,程序能够自动停止当前正在运行的代码,转而处理这个新出现的情况,处理完毕之后再返回之前的程序继续执行。在 Arduino-ESP32 当中使用外部中断时,需要注意到以下情况:
delay()
函数依赖于中断,在中断服务程序当中无法调用;micros()
函数刚开始会正常工作,但是可能会在1 ~ 2
毫秒之后出现异常行为;millis()
函数依赖于中断计数器,其返回值在中断服务程序当中不会增加;delayMicroseconds()
并不会使用到中断计数器,因而能够在中断服务程序当中正常工作;
ESP32-Arduino 里的中断服务程序(ISR, Interrupt Service Routines)是一种没有参数和返回值的特殊函数(如果代码中同时使用到多个中断服务程序,那么它们将会按照优先级的顺序进行执行),ESP32-Arduino 库支持以如下方式,在指定的引脚上面启用或者关闭外部中断服务:
1 | attachInterrupt(digitalPinToInterrupt(pin), ISR, mode) // 开启中断,并且添加中断服务程序 |
pin
: 发生外部中断的 GPIO 引脚编号;ISR
: 发生外部中断时候,自动调用的中断服务函数(无参数,无返回值);mode
: 中断触发方式,取值可以为 LOW(低电平触发)、CHANGE(状态变化触发)、RISING(上升沿触发)、FALLING(下降沿触发)四个常量当中的一个;
接下来使用中断服务程序,完成一个当按键按下的时候,LED 发光二极管熄灭,而在按键弹起时 LED 点亮的程序:
1 | const byte LED = 5; // LED 连接的 GPIO 引脚 |
上述代码只是在中断服务程序里控制 LED
的亮灭状态,如果需要使用一个全局变量,在中断服务程序与主程序之间传递数据,那么必须要将其声明为
volatile
类型,从而确保该全局变量总是被正确的更新,例如下面代码当中的
number
变量就使用了 volatile
关键字进行声明:
1 | volatile int number = 0; |
而在下面这份示例代码当中,如果程序执行到注释的位置发生了中断,那么变量
number 1
的值将不会得到更新:
1 | volatile int number1 = 0; |
如果要确保 number 1
的值正常更新,就必须短暂的禁用中断。ESP32-Arduino
支持手动使能 interrupts()
和失能 noInterrupts()
中断服务:
1 | void setup() {} |
可以修改前面的示例代码,通过使用 interrupts()
和
noInterrupts()
函数,使得程序即使执行到注释位置发生中断,也仍然可以确保变量 number1
被正确的更新:
1 | volatile int number1 = 0; |
由于 ESP32-C3 和 ESP32-S3
两款微控制器都拥有两个计算核心,即使禁用了当前核心的中断服务,另外一个核心也同样可能访问到临界区(访问共用资源的程序片段)的资源,所以就需要在禁用中断的同时,对临界区的资源进行上锁。由
ESP-IDF 提供的
portMUX_INITIALIZER_UNLOCKED
自旋锁,同样可以应用在 ESP32-Arduino
的草图代码当中:
1 | portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; // 定义自旋锁变量 |
注意:上述代码当中的
IRAM_ATTR
关键字,同样是由 ESP-IDF 所提供,用于把这段代码保存至芯片内部的 RAM(IRAM)里面(否则放置到 Flash),从而提高中断服务程序的响应速度,建议 ESP32-Arduino 的中断服务程序都使用该关键字进行声明。
测量 PWM 的频率 & 占空比
本示例将会基于 UINIO-MCU-ESP32
实现一个频率与占空比的测量功能。首先,需要开启指定 GPIO
引脚的外部中断,当每一次触发中断的时候,都记录下信号上升沿与下降沿发生的时间(单位为微秒
)。然后,在中断服务程序里使用
digitalRead()
函数,判断当前属于高电平还是低电平。最后,记录前一次上升沿、下降沿产生的时间,并且在下一个上升沿中断发生时进行如下一系列计算:
- 脉冲宽度:脉冲高电平信号的持续时间,即下图中的 \(t_4 - t_3\);
- 脉冲周期:两个相临脉冲信号之间的时间间隔,即下图里的 \(t_7 - t_5\);
- 占空比:一个脉冲周期内,高电平信号持续时间占据整个周期时间的比值,即 \(\frac{1}{脉冲周期}\);
- 脉冲频率:单位时间内产生的脉冲个数,即 \(\frac{脉冲宽度}{脉冲周期}\);
这里同样将 1602 液晶屏 I²C 总线的 SDA
和
SCL
,分别连接到 UINIO-MCU-ESP32 的
GPIO5
和 GPIO6
引脚,同时把信号发生器的输出探头连接至
UINIO-MCU-ESP32 的 GPIO8
引脚:
当信号发生器输出频率为 1000Hz
,占空比为 50%
的方波信号时,下面的代码就可以使得 UINIO-MCU-ESP32 在
1602 屏幕上显示出频率 Freq: 1000.0
和占空比 Duty: 0.5
:
1 |
|
定时器 Timer & 信号量 Semaphore
ESP32-C3 芯片内置有 2 个 54
位通用定时器(具有 16
位预分频器和 54
位可自动重载的向上/向下计数器)。 而
ESP32-S3 则内置有 4 个 54
位通用定时器(具有 16
位预分频器和 54
位可自动重载的向上/向下计数器)。
ESP32 的通用定时器以 APB 时钟
APB_CLK
作为基本时钟源(该时钟频率由 CPU_CLK
的时钟(即微控制器当前的运行频率)决定,其中 ESP32-C3
为 160 MHz
,而 ESP32-S3 为
240 MHz
),而 16 位预分频器(取值范围为
1 ~ 65536
)的作用就是对 APB
时钟进行分频,从而产生时基计数器时钟
TB_CLK
(每经过 1 个周期向上或者向下进行计数)。
注意:所谓分频就是将信号频率降低到原来的 \(\frac{1}{N}\),称为 N 分频。
已知 \(CPU\_CLK_{C3} = 160MHz\),而
\(CPU\_CLK_{S3} =
240MHz\),假设每一次计数的时间间隔为 10
微秒,那么所需的频率等于其倒数 \(\frac{1}{10
\mu S} = 0.1MHz\),此时 ESP32-C3 和
ESP32-S3 的分频系数应当分别被设置为:
\[ \begin{cases} \frac{CPU\_CLK\_{C3}}{0.1MHz} = \frac{160}{0.1} = 1600 \\ \frac{CPU\_CLK\_{S3}}{0.1MHz} = \frac{240}{0.1} = 2400 \end{cases} \]
换而言之,如果计数周期为 1
微秒,那么
ESP32-C3 和 ESP32-S3
的分频系数应当分别被设置为 160
和
240
。Arduino-ESP32
封装有一系列定时器相关的 API,它们的基本使用步骤如下面所示:
- 初始化硬件定时器,确定时钟频率以及计数方向;
- 绑定定时器中断服务程序;
- 设置定时器的计数值;
- 开始启动定时器;
1 | /* 定义一个定时器指针变量 */ |
可以看到在定时器的整个使用过程当中,最为重要的就是定时器初始化函数
timerBegin()
。如果定时器初始化成功,那么其返回值为一个
timer
结构体,反之则会返回
NULL
。除此之外,该函数的第 1 个参数 num
是定时器编号,第 2 个参数 divider
是定时器分频系数,第 3 个参数 countUp
则是定时器计数方向,更多关于
Arduino-ESP32 当中定时器的 API 可以参考下面的表格:
API | 功能描述 |
---|---|
hw_timer_t * timerBegin(uint8_t num, uint16_t divider, bool countUp) |
配置定时器; |
void timerEnd(hw_timer_t *timer) |
结束定时器; |
uint32_t timerGetConfig(hw_timer_t *timer) |
获取定时器配置; |
void timerSetConfig(hw_timer_t *timer, uint32_t config) |
配置已经初始化的定时器; |
void timerAttachInterrupt(hw_timer_t *timer, void (*fn)(void), bool edge) |
添加定时器中断服务程序; |
void timerDetachInterrupt(hw_timer_t *timer) |
拆除定时器中断服务程序; |
void timerStart(hw_timer_t *timer) |
开始定时器计数; |
void timerStop(hw_timer_t *timer) |
停止定时器计数; |
void timerRestart(hw_timer_t *timer) |
重启定时器计数; |
void timerWrite(hw_timer_t *timer, uint64_t val) |
设置定时器的计数值; |
void timerSetDivider(hw_timer_t *timer, uint16_t divider) |
设置定时器的分频系数; |
void timerSetCountUp(hw_timer_t *timer, bool countUp) |
设置定时器的计数方向; |
void timerSetAutoReload(hw_timer_t *timer, bool autoreload) |
设置定时器计数值的自动重载; |
bool timerStarted(hw_timer_t *timer) |
判断定时器是否在运行; |
uint64_t timerRead(hw_timer_t *timer) |
获取定时器的计数值; |
uint64_t timerReadMicros(hw_timer_t *timer) |
获取定时器的计数(微秒); |
uint64_t timerReadMilis(hw_timer_t *timer) |
获取定时器的计数(毫秒); |
double timerReadSeconds(hw_timer_t *timer) |
获取定时器的计数(秒); |
uint16_t timerGetDivider(hw_timer_t *timer) |
获取定时器的分频系数; |
bool timerGetCountUp(hw_timer_t *timer) |
获取定时器的计数方向; |
bool timerGetAutoReload(hw_timer_t *timer) |
获取定时器计数值的自动重载状态; |
void timerAlarmEnable(hw_timer_t *timer) |
使能定时器告警事件的生成; |
void timerAlarmDisable(hw_timer_t *timer) |
失能定时器告警事件的生成; |
void timerAlarmWrite(hw_timer_t *timer, uint64_t alarm_value, bool autoreload) |
设置定时器的自动加载与告警值; |
bool timerAlarmEnabled(hw_timer_t *timer) |
获取定时器的告警状态; |
uint64_t timerAlarmRead(hw_timer_t *timer) |
获取定时器的告警值; |
uint64_t timerAlarmReadMicros(hw_timer_t *timer) |
获取定时器的告警值(微秒); |
double timerAlarmReadSeconds(hw_timer_t *timer) |
获取定时器的告警值(秒); |
信号量(Semaphore
[ˈseməfɔːr])用于解决并发任务当中的互斥与同步问题。可以简单的将其理解为一个队列(只需要关注该队列当中元素的个数),也可以将其理解为一个整型的全局变量(用于记录信号个数)。而二值信号量则表示的是一种只存在两种状态的队列(有信号
或者无信号
),使用时通过检测信号是否存在,再来决定是否处理相关的任务。相比于全局变量,二值信号量可以等待信号,并且保证操作的原子化,通常应用于中断服务程序与主任务之间的状态同步,其基本使用方法如下面的示例所示:
1 | /* 中断服务程序 */ |
由于 Arduino-ESP32 在底层是基于嵌入式实时操作系统 FreeRTOS 构建,该操作系统提供有一系列信号量 API,因而在 Arduino-ESP32 当中同样可以直接进行调用:
API | 功能描述 |
---|---|
SemaphoreHandle_t xSemaphoreCreateBinary() |
创建一个二进制信号量,并返回一个可以引用该信号量的句柄。 |
xSemaphoreTake( SemaphoreHandle_t xSemaphore, TickType_t xTicksToWait ) |
获取信号量。 |
xSemaphoreTakeFromISR(SemaphoreHandle_t xSemaphore, signed BaseType_t *pxHigherPriorityTaskWoken) |
在中断服务程序里获取信号量。 |
xSemaphoreGive( SemaphoreHandle_t xSemaphore ) |
释放信号量。 |
xSemaphoreGiveFromISR(SemaphoreHandle_t xSemaphore, signed BaseType_t *pxHigherPriorityTaskWoken) |
在中断服务程序里释放信号量。 |
在下面的示例代码当中,通过结合使用定时器中断和二值信号量,每间隔
1
秒钟打印中断被触发的次数 Count
以及时间
Time
:
1 | volatile int Count = 0; // 中断触发的次数 |
HC-SR04 超声波模组
新款的 HC-SR04 超声波模组采用了
RCWL-9206 作为测距解调芯片,其工作电压范围介于
3V ~ 5.5V
之间,工作电流为
2.2mA ~ 3mA
,同时支持
GPIO、UART、I²C
三种通信方式,更多的性能参数可以参考下面的表格:
HC-SR04 超声波模组引脚接口的功能定义,如下面的表格所示:
通过配置模组上的电阻 \(R_4\) 和 \(R_5\) 可以选择 HC-SR04 的引脚通信模式:
由 UINIO-MCU-ESP32 发射一个持续时间至少
10us
的脉冲信号到 HC-SR04 的
Trig
引脚;此时 HC-SR04 会连续发送出 8 个
40KHz
频率的超声波信号,并且 HC-SR04 的
Echo
引脚会切换为高电平;如果超声波信号没有响应,那么
Echo
引脚就会在维持 38ms
的高电平之后,重新切换为低电平状态:
如果超声波信号被物体反射,那么 Echo
引脚就会从高电平切换至低电平,从而产生出一个脉冲信号,
这个脉冲的宽度就是超声波从发射到被物体反射回来的间隔时间,即经过超声波探头与物体之间
2 倍距离所耗费的时间:
根据超声波在空气当中的传输速度,就可以计算出超声波探头与物体之间的距离(两次测量的间隔时间不能低于
200
毫秒):
\[ 超声波探头与物体之间的距离 = 超声波传输速度 \times \frac{脉冲宽度时间}{2} \]
由于环境温度会对超声波信号的传输速度造成影响,如果需要更加精确的测量距离,那么就必须把温度的因素纳入考量范围,并且进行相应的温度补偿:
环境温度 | 超声波典型传输速度 |
---|---|
0℃ |
330.45 米每秒 |
20℃ |
342.62 米每秒 |
40℃ |
354.85 米每秒 |
Arduino 官方库当中提供的 pulseIn()
函数可以用于读取脉冲信号的持续时间(以微秒为单位),其中参数
pin
是需要用于读取脉冲信号的
UINIO-MCU-ESP32 引脚编号,而参数 value
则用于指定脉冲的类型(高电平脉冲 HIGH
或者低电平脉冲 LOW
),可选的参数
timeout
则用于设置脉冲读取的超时时间(单位为微秒,默认为
1000
,即 1
秒钟)。
1 | pulseIn(pin, value, timeout) |
这里把 UINIO-MCU-ESP32S3 的 GPIO8
和
GPIO9
分别作为 HC-SR04 超声波模组的 Echo
和
Trig
引脚,然后以 GPIO
通信方式,通过上面的 pulseIn()
函数读取 HC-SR04
传感器的数据,并且计算出以厘米作为单位的距离值,最后打印到波特率为
115200
的串口上面:
1 | const int Echo_Pin = 8; // 指定 Echo 连接的 GPIO 引脚 |
由于上述代码当中的 pulseIn()
和 delay()
函数在运行时都是阻塞式的,会严重的迟滞其它任务的执行时间,接下来会以非阻塞式的中断方式来实现相同功能,有所不同的是,这次会把结果显示到
1602 液晶屏幕上面:
- 首先,需要把外部中断
change
添加至连接到超声波传感器Echo
的 GPIO 引脚,然后使用硬件定时器每间隔500
毫秒向超声波模组的Trig
引脚发送15
微秒的脉冲,即每 1 秒钟进行两次测量。 - 然后,在发生上升沿中断的时候,记录下当前时间 \(t_1\)(超声波发送出去的时间);而发生下降沿中断的时候,也记录下当前时间 \(t_2\)(接受到超声波信号反射的时间),与此同时发送出一个二值信号量。
- 最后,当
loop()
函数在接收到信号量之后,根据获取到的 \(t_1\) 与 \(t_2\) 的数值,就可以计算出物体与超声波探头之间的距离,并且显示在 1602 屏幕上面。
接下来,同样把 UINIO-MCU-ESP32S3 的
GPIO5
和 GPIO6
引脚,作为 1602 液晶显示屏 I²C
总线的 SDA
和 SCL
。而 GPIO8
和
GPIO9
分别作为 HC-SR04 超声波模组的 Echo
和
Trig
引脚:
下面代码同样是以 GPIO 通信方式读取 HC-SR04 传感器的数据,并且以非阻塞式方式计算出以毫米作为单位的距离值,最后将该值显示到 1602 液晶屏幕上面:
1 |
|
伺服舵机 & ESP32Servo 库
航模玩家经常使用到的舵机,本质上是一种低成本的伺服电机(Servomotor)系统。其工作原理是通过内部的控制电路接收
PWM
脉冲宽度调制信号,然后控制内置电机转动,内置电机带动一系列的减速齿轮组把扭矩传递至输出轴和舵盘。输出轴会与用于反馈角度位置的电位器相互连接,当舵盘转动的时候,同时会带动电位器输出一个电压信号,反馈至舵机内部的控制电路,然后控制电路根据其位置决定舵机转动的角度
或者速度
。
根据控制电路的不同,可以将舵机划分为数字舵机和模拟舵机两种类型。而根据旋转角度的不同,也可以将其进一步划分为
180°
舵机和 360°
舵机两种类型:
180°
舵机:可以通过脉冲宽度调制 PWM 信号控制旋转角度(从0°
度到180°
度)。360°
舵机:可以360°
度转动,只能调节转动速度,不能调节转动角度。
舵机的控制信号是一个周期为 20
毫秒的
PWM 信号,其中脉冲宽度介于 0.5 ~ 2.5
毫秒范围之间,与其对应的线性旋转角度为
0° ~ 180°
。换而言之,舵机会根据 PWM
信号的脉冲宽度,将输出轴旋转到一个指定的角度上面:
在接下来的列表当中,分别介绍了舵机非常重要的 4 个性能参数:
- 力矩:用于表示力对物体作用时所产生转动效应大小的物理量(即力
F
与力臂r
的乘积),其单位为牛顿·米(N·m
)。 - 失速力矩:指转动轴在被外力锁定的情况下,以目标温升作为约束,可以连续输出力矩的最大值,有时候也将其称为堵转力矩(
堵转力矩
通常高于额定力矩
)。该参数的单位为千克·厘米(Kg·cm
),即舵机发生堵转的时候,1
厘米的力臂所能够提起的最大质量。 - 动作死区:该参数用于描述舵机的旋转精度,因为舵机内部的基准电路会产生周期为
20
微秒,脉冲宽度为1.5
微秒的基准信号。通过内置的比较器,将控制信号与这个基准信号进行比较,从而判断出旋转的角度。但是舵机在实际工作当中,难以完全精确的控制角度,而比较器的存在又势必会导致舵机在停止点附近往复振荡,因而就需要舵机的控制电路将这个误差值吸收掉,这就是动作死区。常见小型舵机的死区时间为5
微秒(对应角度为0.45°
度),即如果想将舵机旋转45°
度,其真正的停止位置会介于45° ± 0.45°
范围之间。 - 反应转速:舵机在无负载的情况下,转动
60°
度所需要的时间。
下面的两个表格,分别展示了采用塑料齿轮和传动轴的通用型 SG90 微型舵机(上图左)的性能参数,以及采用金属齿轮和传动轴的通用型 MG996R 小型舵机(上图右)的性能参数:
SG90 舵机性能参数 | 参数值 |
---|---|
旋转角度 | 180° (± 15°) |
工作电压 | 4.8 V ~ 6 V (典型值为
5 V ) |
空载电流 | 10 mA |
转动电流 | 100 mA ~ 250 mA |
堵转电流 | 360 mA |
失速力矩 | 1.7 Kg·cm |
转动速度 | 0.12 秒 / 60° |
MG996R 舵机性能参数 | 参数值 |
---|---|
旋转角度 | 180° (± 10°) |
工作电压 | 4.8 V ~ 6 V (典型值为
5 V ) |
空载电流 | 10 mA |
转动电流 | 170 mA ~ 400 mA |
堵转电流 | 1.3 A ~ 1.5 A |
失速力矩 | 13 Kg·cm |
转动速度 | 0.2 秒 / 60° |
ESP32Servo 库实现了 Arduino 官方舵机驱动库 Servo 的全部功能,可以直接在 Arduino IDE 的【库管理器】当中搜索安装,其主要 API 如下面的表格所示:
API | 功能描述 |
---|---|
Servo |
用于操作连接到 UINIO-MCU-ESP32 引脚的舵机对象。 |
int attach(pin, min, max) |
将指定的 GPIO 引脚关联到 1 个 LEDC
通道,并且返回通道编号(如果失败返回 0 )。其中参数
min 的最小取值为 500 (默认值为
544us ),而 max 的最大取值为
2500 (默认值为 2400us )。 |
void write() |
指定舵机的旋转角度(0°~180° )。 |
void writeMicroseconds() |
以微秒 作为单位设置脉冲宽度(必须设置)。 |
int read() |
获取之前写入舵机的旋转角度(0°~180° )。 |
int readMicroseconds() |
获取之前写入舵机的脉冲宽度(以微秒 为单位)。 |
bool attached() |
如果舵机对象 Servo 成功绑定至
UINIO-MCU-ESP32 的 GPIO 引脚,那么就会返回
true 。 |
void detach() |
停止绑定 Servo 对象到 GPIO
引脚,并且释放对于 LEDC 通道的占用。 |
setTimerWidth(value) |
设置 PWM 定时器输出的脉冲宽度。 |
int readTimerWidth() |
获取 PWM 定时器输出的脉冲宽度。 |
在使用上面表格当中的 void write()
函数指定舵机旋转角度的时候,传入的参数值会遵循如下的自动转换规则:
API | 功能描述 |
---|---|
< 0 |
0 |
0 - 180 |
value (以度数为单位) |
181 - 499 |
180 |
500 - (min-1) |
min |
min - max |
value (以微秒为单位) |
(max + 1) - 2500 |
max |
下面的伪代码简洁的演示了 ESP32Servo 库的基本使用步骤,以及相关的重要 API 函数:
1 |
|
ESP32Servo 库的底层运用了定时器和 LEDC 来控制 PWM 信号的生成,其中 ESP32-C3 拥有 4 个定时器与 6 个独立的 PWM 通道,而 ESP32-S3 同样拥有 4 个定时器以及 8 个独立的 PWM 通道,具体可以参见下面的示意图:
舵机通常拥有 PWM
、VCC
、GND
三路外接引脚,其中 VCC
需要连接到一个独立的 5V
电源(确保工作电流稳定),而舵机的 GND
引脚需要与
UINIO-MCU-ESP32 的 GND
形成共地连接(作为 PWM 信号的电平基准),除此之外的
PWM
则是属于用来输入 PWM 控制信号的引脚:
例如 SG90 和 MG996R
型舵机的黄/橙色、红色、棕色杜邦线,就分别对应着舵机的
PWM
、VCC
、GND
引脚。接下来,通过
UINIO-MCU-ESP32 控制两个 SG90
微型舵机,分别将两个舵机的 PWM
信号线连接至
UINIO-MCU-ESP32 的 GPIO9
和
GPIO10
引脚:
下面的这份示例代码,可以使得两个 SG90 微型舵机分别从
0°
旋转到 180°
度,以及从 180°
旋转到 0°
度:
1 |
|
注意:由于 UINIO-MCU-ESP32C3 采用了两线制 SPI 的 DIO 模式,因而在运行上述示例程序的时候,需要将 Arduino IDE 的 【Flash Mode】设置为
DIO
模式,否则会导致舵机程序无法正常工作。除此之外,因为 UINIO-MCU-ESP32C3 的第GPIO11
、GPIO12
、GPIO13
引脚已经被用作 Flash 的 SPI 电源和信号引脚,所以无法用于控制舵机。
由多份源文件组成的草图工程
本节内容将会综合运用之前介绍过的 SG90 舵机和
HC-SR04 超声波模组,基于
UINIO-MCU-ESP32S3 实现一个能够自动打开盒盖的
UINIO-Auto-Box
智能收纳盒子项目,这里假设盒盖关闭时候舵机的角度为 0°
度,而盒盖打开时候舵机的角度为 90°
度。当用手遮挡住超声波探头的时候,舵机旋转 90°
度打开盒盖。而当手离开之后,舵机就会回到 0°
度位置,表示已经自动关闭盒盖。
- 把 SG90 舵机的
VCC
引脚连接到 UINIO-MCU-ESP32S3 的5V
引脚,而PWM
引脚连接到GPIO7
引脚,除此之外两者的GND
相互连接形成共地关系。 - 把 HC-SR04 舵机的
VCC
引脚连接到 UINIO-MCU-ESP32S3 的3V3
引脚,而Trig
引脚连接至GPIO6
,Echo
引脚连接至GPIO5
,同样GND
相互连接形成共地关系。
打开 Arduino IDE 新建一个名为
UINIO-Auto-Box
的草图工程,其主程序会被自动命名为
UINIO-Auto-Box.ino
,然后手动添加超声波传感器相关的
Sonar.h
与 Sonar.cpp
源文件,盒盖控制相关的 Cover.h
与
Cover.cpp
源文件,以及舵机控制相关的
Servo.h
与 Servo.cpp
源文件,最后生成的工程结构如下面所示:
1 | D:\Workspace\UINIO-Auto-Box |
- C/C++ 的全局变量可以定义在
.cpp
源文件当中,然后在对应的.h
头文件当中将其声明为extern
外部变量。 - Arduino IDE 会自动为
.ino
草图文件添加依赖的头文件,而在.h
和.cpp
源文件当中使用信号量相关的方法时,就需要手动包含源文件#include <freertos/FreeRTOS.h>
和#include <freertos/semphr.h>
,并且#include <freertos/FreeRTOS.h>
必须放置在<freertos/semphr.h>
之前。而在使用定时器相关的方法时,则需要手动包含源文件#include <esp32-hal-timer.h>
。 - 必须将 Arduino IDE 的【工具】设置为
Events Run On: "Core 0"
和Arduino Runs On: "Core 1"
,才能够正确的运行本节的示例程序。
注意:由于 UINIO-MCU-ESP32C3 属于 RISC-V 架构的单核微控制器,所以无法正常运行本节的示例程序。如果强行上传示例程序,串口会打印出错误信息:
Guru Meditation Error: Core 0 panic'ed (Load access fault). Exception was unhandled.
。
UINIO-Auto-Box.ino
1 | /*========== 主程序 ==========*/ |
Cover.h 与 Cover.cpp
1 | /*========== Cover.h ==========*/ |
1 | /*========== Cover.cpp ==========*/ |
Servo.h 与 Servo.cpp
1 | /*========== Servo.h ==========*/ |
1 | /*========== Servo.cpp ==========*/ |
Sonar.h 与 Sonar.cpp
1 | /*========== Sonar.h ==========*/ |
1 | /*========== Sonar.cpp ==========*/ |
SPI 总线主从通信
SPI 总线协议原理
串行外设接口(SPI,Serial Peripheral Interface)是一种高速、全双工、同步通信总线,其优点在于支持全双工通信(可以同时进行数据的接收与发送),数据传输速率相对 I²C 总线更加迅速,不过其缺点在于没有应答机制(无法判断数据是否准确收发)。
SPI 总线通信协议只允许一个主设备,但是可以存在多个从设备,其一共拥有着四条物理信号线:
- SCLK (Serial
Clock):用于主设备向从设备传输时钟信号,也被称作
SCK
; - MOSI (Master Output Slave
Input):
主设备
输出,从设备
输入,也称为 SDO (Slave Device Output); - MISO (Master Input Slave
Output):
主设备
输入,从设备
输出,也称为 SDI (Slave Device Input); - CS (Chip Select):片选信号线,由主设备控制(低电平有效),用于选择当前需要通信的从设备,也被称作 SS (Slave Select);
除了 CS/SS 片选信号线需要每一台 从设备
都与 主设备
进行单独连接之外,其它的
SCLK/SCK、MOSI/SDO、MISO/SDI
三条信号线都分别各自连接到一起:
SPI
总线上的主设备与从设备都分别内置有串行移位寄存器,主设备向该寄存器写入
1
个字节数据,就会进行一次数据传输:
- 将指定从设备的
CS
片选信号线拉低,开始与其进行通信。 - 主设备发出
SCLK
时钟信号,开始准备对从设备进行读写操作(时钟信号是高电平
还是低电平
有效,称为时钟极性)。 - 主设备把待发送的数据写入到发送缓冲区,然后通过过
串行移位寄存器
,将数据从MOSI
信号线逐位发送给从设备;同时主设备也可以把MISO
信号线上待接收的从设备数据,同样通过串行移位寄存器
逐位移动到接收缓冲区。 - 从设备也会把自己
串行移位寄存器
里的内容,通过MISO
信号线返回给主设备;并且同样也可以通过MOSI
信号线接收主设备发送过来的数据(数据是在时钟信号的上升沿还是下降沿处理,称为时钟相位)。 - 每经过 1 个
SCLK
时钟脉冲,SPI 总线上就可以接收或者发送1bit
数据。
在上述 SPI 通信过程当中,时钟极性和时钟相位是非常重要的两个概念:
- 时钟极性 CPOL(Clock
Polarity):表示 SPI 总线空闲时,时钟线
SCLK
处于高电平还是低电平;如果CPOL = 0
,那么时钟信号在总线空闲时处于低电平;如果CPOL = 1
,那么时钟信号在总线空闲时则处于高电平; - 时钟相位 CPHA(Clock
Phase):表示处理 SPI 总线数据采样的时间点,如果
CPHA = 0
,那么在时钟信号SCLK
的第 1 个跳变沿采样,第 2 个跳变沿被改变;如果CPHA = 0
,那么在时钟信号 SCLK 的第 1 个跳变沿被改变,第 2 个跳变沿采样;
注意:上图当中的红色竖线代表数据采样(Sampled)的位置,而蓝色代表数据被改变(Launched)的位置。
根据 SPI 总线的时钟极性与时钟相位,可以划分出四种不同的 SPI 总线通信工作模式,它们分别定义了在时钟信号的哪个边沿采样信号,哪个边沿改变信号:
模式 | 时钟极性与相位 |
---|---|
Mode 0 | CPOL = 0 ,CPHA = 0 |
Mode 1 | CPOL = 0 ,CPHA = 1 |
Mode 2 | CPOL = 1 ,CPHA = 0 |
Mode 3 | CPOL = 1 ,CPHA = 1 |
除此之外,在 SPI 串行通信过程当中,当前是最高有效位(MSB,Most Significant Bit)优先传输,还是最低有效位(LSB,Least Significant Bit)优先传输是非常重要的两个关键因素,收发双方必须保持传输时序的一致:
- 最低有效位 (LSB) 优先 :传输一个字节的时候从低位先进行传输;
- 最高有效位 (MSB) 优先:传输一个字节的时候从高位先进行传输;
注意:SPI 通信涉及的所有 API 函数都不能放置到中断服务程序当中,否则将会导致程序报错。
ESP32C3 & ESP32S3 的 SPI 外设
由于乐鑫早期的 ESP32 芯片(例如
ESP32-D0WD-V3
、ESP32-D2WD
、ESP32-S0WD
、ESP32-U4WDH
),分别使用了
HSPI 和 VSPI 来指代 SPI2
和 SPI3
外设:
官方的 Arduino-ESP32 库出于兼容性考虑延续了这种叫法,它们默认的 GPIO 引脚编号,如下面的表格所示:
分类 | 主机输入从机输出引脚 | 主机输出从机输入引脚 | 时钟引脚 | 片选引脚 |
---|---|---|---|---|
VSPI | MISO = 19 |
MOSI = 23 |
SCLK = 18 |
CS = 5 |
HSPI | MISO = 12 |
MOSI = 13 |
SCLK = 14 |
CS = 15 |
ESP32-C3 芯片集成有
SPI0
、SPI1
、SPI2
三个 SPI
总线控制器,因为 SPI0
和 SPI1
主要用于访问外部
Flash 以及 PSRAM,所以仅有 SPI2
可以供用户配置使用(即
GP-SPI2)。
而 ESP32-S3 芯片集成有
SPI0
、SPI1
、SPI2
、SPI3
四个 SPI 总线控制器,同样因为 SPI0
和 SPI1
被用于访问外部 Flash 以及 PSRAM,所以仅有
SPI2
、SPI3
可以供用户配置使用(即
GP-SPI2 和 GP-SPI3)。
观察上述 ESP32-C3 和 ESP32-S3 的
SPI 系统框图可以发现,两者都将 GP-SPI2
称为
FSPI(Fast SPI),因而在随后的主设备 SPI
官方库示例代码当中,宏定义里才会出现 #define VSPI FSPI
这样的语句。
注意:ESP32-C3 与 ESP32-S3 工作在主设备模式下的时钟频率都可以达到
80 MHz
,而工作在从设备模式下的时钟频率也可以达到60 MHz
。
主设备 SPI 官方库
Arduino-ESP32 封装的 SPI
库已经提供了主设备 SPI 总线通信的支持,使用时只需要包含
<SPI.h>
头文件即可,相关的方法都已经被封装至
SPIClass
类:
1 | SPIClass *vspi = new SPIClass(VSPI); |
Arduino-ESP32 内部已经定义有一个
SPIClass SPI = new SPIClass(VSPI)
,可以在代码当中直接使用
SPI
对象控制总线通信,下面的伪代码展示了 SPI
主设备通信的基本过程:
1 |
|
下面的代码详细展示了 UINIO-MCU-ESP32S3 使用
Arduino-ESP32 库进行 SPI 主设备通信的整个步骤,由于
UINIO-MCU-ESP32C3 只存在一个 HSPI
可以供用户配置使用,运行下面代码会导致
'VSPI' was not declared in this scope
错误的出现:
1 |
|
接下来介绍一下 Arduino-ESP32 当中 SPI
内置的相关方法,首先 SPISettings
类用于配置 SPI
总线通信端口的相关参数(默认的时钟频率
clock
为 1MHz
、传输顺序
bitOrder
为
高位优先
、时钟的极性与相位模式
dataMode
为 MODE0
):
SPISettings 构造函数 | 功能描述 |
---|---|
SPISettings(uint32_t clock, uint8_t bitOrder, uint8_t dataMode) |
SPI
总线配置参数的载体,三个参数的默认值分别为
1000000 、SPI_MSBFIRST 、SPI_MODE0 。 |
除此之外,Arduino-ESP32 库还通过
SPIClass
类,提供了丰富的 SPI
通信相关的工具函数,具体如下面的表格所示:
SPIClass 中的主要 API | 功能描述 |
---|---|
void begin(int8_t sck=-1, int8_t miso=-1, int8_t mosi=-1, int8_t ss=-1) |
初始化 SPI 总线。 |
void end() |
结束 SPI 总线的资源占用。 |
void beginTransaction(SPISettings settings) |
使用 SPISettings
作为参数,开始进行 SPI 总线通信。 |
void endTransaction(void) |
结束 SPI 总线通信。 |
int8_t pinSS() |
返回 SPI 片选引脚。 |
SPIClass 中的 Transfer API | 功能描述 |
---|---|
void transfer(void * data, uint32_t size) |
发送 size 个字节的
data 数据,但是并不会接收数据。 |
uint8_t transfer(uint8_t data) |
发送 1 个字节的 data
数据,同时接收 1 个字节的数据。 |
uint16_t transfer16(uint16_t data) |
发送 2 个字节的 data
数据,同时接收 2 个字节的数据。 |
uint32_t transfer32(uint32_t data) |
发送 4 个字节的 data
数据,同时接收 4 个字节的数据。 |
void transferBytes(const uint8_t * data, uint8_t * out, uint32_t size) |
接收 size
个字节到读取缓冲区 data ,或者发送
size 个字节到输出缓冲区
out 。 |
void transferBits(uint32_t data, uint32_t * out, uint8_t bits) |
接收 size
位数据到读取缓冲区 data ,或者发送
size 位数据到输出缓冲区
out 。 |
SPIClass 中的 Write API | 功能描述 |
---|---|
void write(uint8_t data) |
发送 1 个字节的 data
数据,但是不会接收数据。 |
void write16(uint16_t data) |
发送 2 个字节的 data
数据,但是不会接收数据。 |
void write32(uint32_t data) |
发送 4 个字节的 data
数据,但是不会接收数据。 |
void writeBytes(const uint8_t * data, uint32_t size) |
发送 size 个字节的
data 数据,但是不会接收数据。 |
void writePattern(const uint8_t * data, uint8_t size, uint32_t repeat) |
循环发送 size 个字节的
data 数据 repeat 次,但是不会接收数据。 |
void writePixels(const void * data, uint32_t size) |
请参考用户目录
USER\AppData\Local\Arduino15\packages\esp32\hardware\esp32\2.0.9\libraries\SPI\src
下面的源码。 |
从设备 SPI 第三方库
由于 Arduino-ESP32 官方库只支持把
ESP32 芯片作为 SPI
主设备来使用,并未提供 SPI 从设备通信相关的
API,需要在 Arduino IDE 当中安装第三方库 ESP32SPISlave。由于该库目前仅支持诸如
ESP32-D0WD-V3
、ESP32-D2WD
、ESP32-S0WD
、ESP32-U4WDH
等较老型号的 ESP32 系列,暂不支持相对较新的
ESP32-C3 与 ESP32-S3 芯片。如果在
Arduino IDE
当中选择以这两款芯片作为主控的开发板,那么就会导致编译错误的出现。所以在接下来的示例当中,都会以乐鑫官方采用
ESP32-D0WD 主控的 ESP32-DevKitC
开发板作为 SPI 总线从设备:
ESP32SPISlave 库可以支持以阻塞式等待的方式访问 SPI 传输事务队列:
1 |
|
相对应的,也能够支持以轮询的方式访问 SPI 传输事务队列:
1 |
|
或者是以任务的方式访问 SPI 传输事务队列:
1 |
|
接下来的一系列表格当中,展示了 ESP32SPISlave 库当中提供的一系列 API 函数:
SPI 总线配置 API | 功能描述 |
---|---|
bool begin(const uint8_t spi_bus = HSPI) |
使用默认的 HSPI 或者
VSPI 作为 SPI 通信引脚。 |
bool begin(const uint8_t spi_bus, const int8_t sck, const int8_t miso, const int8_t mosi, const int8_t ss) |
自定义 SPI 通信引脚。 |
void setDataMode(const uint8_t m) |
设置 SPI 数据模式(SPI 时钟的极性与相位)。 |
void setSpiMode(const uint8_t m) |
设置 SPI 工作模式。 |
void setSlaveFlags(const uint32_t flags) |
设置从设备标记。 |
void setQueueSize(const int n) |
设置队列大小。 |
bool end() |
结束 SPI 传输。 |
SPI 传输事务 API | 功能描述 |
---|---|
bool wait(uint8_t* rx_buf, const size_t size) |
阻塞式等待传输事务,只接收不发送,成功返回
true ,失败返回 false 。 |
bool wait(uint8_t* rx_buf, const uint8_t* tx_buf, const size_t size) |
阻塞式等待传输事务,即接收也发送,成功返回
true ,失败返回 false 。 |
bool queue(uint8_t* rx_buf, const size_t size) |
添加传输事务,只接收不发送,成功返回
true ,失败返回 false 。 |
bool queue(uint8_t* rx_buf, const uint8_t* tx_buf, const size_t size) |
添加传输事务,即接收也发送,成功返回
true ,失败返回 false 。 |
void yield() |
等待主设备处理完毕所有传输事务,如果
yield 完成,则更新全部缓冲区。 |
SPI 传输结果信息 API | 功能描述 |
---|---|
size_t available() const |
如果主设备的传输事务已经结束,那么从设备的
available() 就会返回传输的结果数量,并且自动更新
spi_slave_rx_buf 接收缓冲区。 |
size_t remained() const |
判断传输队列当中剩余的事务数量。 |
uint32_t size() const |
从传输队列当中接收到的字节数量。 |
void pop() |
获取从设备接收缓冲区当中的数据。 |
注意:SPI 传输涉及的 API 中都以传输事务作为基础,当主设备拉低片选线,并且时钟线上发出脉冲信号时,就开始了 1 次全双工的 SPI 传输事务。每一个时钟脉冲都意味着主设备通过
MOSI
线发送1
个数据位到从设备,并且同时从设备通过MISO
线返回1
个数据位。当传输事务结束之后,主设备就会拉高片选线。每一次 SPI 总线传输事务,主设备与从设备所能传输的最大数据量为64
字节,如果需要传输更大的数据,则必须借助 DMA 方式进行。
主 & 从设备 SPI 通信实例
本小节将会基于 SPI 总线实现
UINIO-MCU-ESP32S3(主设备)与
ESP32-DevKitC(从设备)之间的相互通信,把两块核心板的
HSPI/SPI2 按照下图关系相互连接,即
UINIO-MCU-ESP32S3 的
SCLK = 21
、MISO = 20
、MOSI = 19
、SS = 18
与 ESP32-DevKitC 的
SCLK = 14
,MISO = 12
,MOSI = 13
,SS = 15
一一对应连接。同时两者的 GND
也要连接到一起,从而形成共地连接关系:
让主设备每间隔 1
秒钟,向从设备发送小写英文字符串,从设备接收之后将其转换为大写形式再返还给主设备,主设备将接收到的大写字符串打印至
Arduino IDE 的【串口监视器】。接下来,分别在
Arduino IDE 当中新建 3 份草图源文件,它们分别是用于 SPI
主设备的 SPIMaster.ino
、从设备(采用阻塞等待处理方式)的
SPISlaveWait.ino
、从设备(采用队列处理方式)的
SPISlaveQueue.ino
:
SPIMaster.ino
采用 UINIO-MCU-ESP32S3 作为 SPI 主设备,基于 Arduino-ESP32 官方库提供的 SPI 通信 API 与从设备进行数据交互。
1 | /*========== SPIMaster.ino(主设备) ==========*/ |
SPISlaveWait.ino
采用 ESP32-DevKitC 作为 SPI 从设备,基于
ESP32SPISlave 库提供的 wait()
方法,以阻塞等待的方式与主设备进行通信:
1 | /*========== SPISlaveWait.ino() ==========*/ |
SPISlaveQueue.ino
采用 ESP32-DevKitC 作为 SPI 从设备,基于
ESP32SPISlave 库提供的 queue()
方法与主设备进行通信,由于队列方式只能同时处理 3 个 SPI
传输任务。所以从设备需要初始化出 3
个传输任务,然后逐一用于处理数据的收发。
1 | /*========== SPISlaveQueue.ino ==========*/ |
基于 SPI 操作 SD 存储卡
本节内容介绍的这款 SD 存储卡模组,可以使得
UINIO-MCU-ESP32 通过 SPI 接口以及文件系统读写 SD
存储卡(同时支持普通 Micro SD 和高速 Micro
SDHC 存储卡)。该款模组还板载有电平转换芯片,可以同时兼容
5V
和 3.3V
规格的电平信号。而自带的
3.3V 线性稳压器,也可以使其分别工作于 5V
和 3.3V
电源下。
这款 SD 存储卡模组 一共拥有六个外接引脚,它们分别是
GND
,VCC
,MISO
,MOSI
,SCK
,CS
,具体的引脚排列顺序可以参考下图:
Arduino-ESP32 提供的 SD 库
笔者目前使用 Arduino-ESP32 库的 2.0.11
版本,已经基于 SPI 总线通信,提供了对于 SD
卡操作的支持(可以支持中文文件名,以及 UTF-8
编码的文件内容),使用时只需要在 Arduino 草图源文件当中包含
SPI.h
和 SD.h
头文件即可。由于截止到 2023 年 8
月为止,该库依然还处于开发状态,官方并未提供详尽的 API
文档说明,只是提供了一份比较典型的 SD
卡读写示例代码。接下来就基于这份代码,以自定义 SPI
通信引脚的方式,读写一颗文件系统为 FAT32,存储容量为
32GB
的 TF 存储卡:
首先把 UINIO-MCU-ESP32 的引脚
SS = GPIO0
、SCLK = GPIO1
、MOSI = GPIO2
、MISO = GPIO3
分别与读卡器模块的
CS
、SCK
、MOSI
、MISO
引脚相互连接,然后再将读卡器模组的 VCC
和 GND
分别接入至 UINIO-MCU-ESP32 的 5V
和
GND
电源,最后就可以下载并且运行这份参考代码:
1 |
|
上述代码下载执行之后,测试用的 SD 卡上面会生成一个内容为
您好,电子技术博客 UinIO.com!
的文件
UinIO.txt
,以及通过文件输入输出写入了内容为空的
成都.txt
文件,同时会以 115200
波特率向串口打印如下一系列执行结果:
1 | SD 卡类型: SDHC 存储卡 |
注意:如果串口打印出的调试内容提示
存储卡挂载失败!
,那么可以将这片 SD 卡拔出之后重新插入,然后按下 UINIO-MCU-ESP32 核心板上面的 RESET 按钮,重新执行上述程序。
第三方提供的 SdFat 库
因为 ESP32-Arduino 官方库的 SD 卡相关 API
暂时还不够完善,所以本节内容将会介绍功能更加丰富的 SdFat 库,可以同时支持
SD、SDHC、SDXC
类型的存储卡,以及
FAT16、FAT32、exFAT
文件系统。该库的 API 文档可以访问 SdFat 源文件 的
\doc
目录下,压缩文件 html.zip
当中的
index.html
。总体上来看,SdFat 库是通过
SdFat32
、SdExFat
、SdFs
三个类来分别代表不同的存储卡文件系统:
SdFs
类:用于支持 FAT16 和 FAT32 以及 exFAT 文件系统,对应的文件类为FsFile
;SdFat32
类:用于支持 FAT16 和 FAT32 文件系统,对应的文件类为File32
;SdExFat
类:用于支持 exFAT 文件系统,对应的文件类为ExFile
;
该库可以方便的通过 Arduino IDE
的【库管理器】进行安装,安装之后需要修改 Arduino 项目文件夹
libraries\SdFat\src
下的 SdFatConfig.h
头文件,将宏定义 #define USE_UTF8_LONG_NAMES
的值修改为
1
,即采用 UTF-8
格式编码所有字符串,从而能够自由的使用中文字符。除此之外,通过修改
SdFatConfig.h
头文件中 SPI_DRIVER_SELECT
宏定义的值,还可以选择当前是使用 SPI 硬件总线(使用 SPI
控制器默认的引脚)还是 SPI 软件总线(自定义 SPI
通信引脚):
0
:如果存在优化的自定义 SPI 驱动程序,则使用它,否则使用标准库驱动程序。1
:总是使用标准库驱动程序。2
:总是使用 SoftSpiDriver 模板类的外部 SPI 驱动程序。3
:总是使用从 SdSpiBaseClass 类派生的外部 SPI 驱动程序。
接下来,我们把 SdFatConfig.h
头文件里的
SPI_DRIVER_SELECT
配置为 2
,而
#define USE_UTF8_LONG_NAMES
的值配置为
1
,同时依然将 UINIO-MCU-ESP32 的
SS = GPIO0
、SCLK = GPIO1
、MOSI = GPIO2
、MISO = GPIO3
与读卡器模块的
CS
、SCK
、MOSI
、MISO
引脚连接,而读卡器模块的 VCC
和 GND
则分别接入
UINIO-MCU-ESP32 的 5V
和 GND
进行供电,最后就可以编写并且执行如下的参考代码:
1 |
|
UINIO-MCU-ESP32
开始运行上述代码之后,可以打开一个第三方的串口上位机程序(例如
VOFA+ 或者
COMTransmit),首先将其波特率设置为
115200
,然后手动向串口上位机的【发送窗口】输入
test
并且按下发送,此时 UINIO-MCU-ESP32
就会自动向 SD
存储卡写入内容:欢迎访问 UinIO.com,获取技术分享文章,以及更多有趣的开源项目。
,同时串口上位机的【接收窗口】会打印出如下信息:
1 | ESP-ROM:esp32s3-20210327 |
注意:DSPI(Dual SPI)常用于 SPI 总线通信的 Flash 存储器,由于 Flash 存储器无需使用全双工通信,所以 DSPI 将
MOSI
和MISO
都作为并行数据传输线,从而工作在半双工模式下,可以达到在单个时钟周期内,双倍提升数据传输速率的目的。
借用 FreeRTOS 的多任务与互斥量
FreeRTOS
是一款适用于微控制器和小型微处理器的嵌入式实时操作系统(RTOS,Real-time
Operating
System),其提供了任务
与通知
、队列
、流缓冲区
、消息缓冲区
、信号量
/互斥锁
、软件定时器
、事件组
等丰富特性,可以协助开发人员在资源受限的嵌入式场景下,实现稳定可靠的实时任务调度与协作。当
Arduino IDE 成功安装 Arduino-ESP32
之后,就会在如下目录里发现 ESP32-C3 和
ESP32-S3 源码实现都内嵌有
FreeRTOS。
- ESP32C3 内嵌的
FreeRTOS:
C:\Users\Hank\AppData\Local\Arduino15\packages\esp32\hardware\esp32\2.0.11\tools\sdk\esp32c3\include\freertos
; - ESP32S3 内嵌的
FreeRTOS:
C:\Users\Hank\AppData\Local\Arduino15\packages\esp32\hardware\esp32\2.0.11\tools\sdk\esp32s3\include\freertos
;
相应的,Arduino-ESP32 当中所使用的
FreeRTOS 都会通过头文件 FreeRTOSConfig.h
来进行配置,其位置位于 Arduino-ESP32
用户安装目录的如下路径:
- ESP32C3 的
FreeRTOSConfig.h
:Arduino15\packages\esp32\hardware\esp32\2.0.11\tools\sdk\esp32c3\include\freertos\include\esp_additions\freertos\FreeRTOSConfig.h
; - ESP32S3 的
FreeRTOSConfig.h
:Arduino15\packages\esp32\hardware\esp32\2.0.11\tools\sdk\esp32s3\include\freertos\include\esp_additions\freertos\FreeRTOSConfig.h
;
例如 Arduino-ESP32 当中的 loop()
函数,就是通过在 FreeRTOS 当中创建一个优先级为
1
的任务来进行执行的。也正是因为如此,我们同样可以在
Arduino 草图代码当中引入 FreeRTOS 相关的头文件来使用其相关的特性。
注意:当前本文使用的 Arduino-ESP32 版本为
2.0.11
,其内嵌的 FreeRTOS 版本为V10.4.x
。
多任务处理
当 Arduino IDE 安装了 Arduino-ESP32 之后,菜单栏的上【工具】下就会出现一系列配置选项。对于 UINIO-MCU-ESP32S3 这样的多核微控制器,就可以发现如下两条配置项:
- Arduino Runs On:指定用于运行 Arduino 核心的微控制器内核;
- Events Run On:指定用于运行 Arduino 事件的微控制器内核;
上述两个配置项的值可以分别被指定为
Core 0
和 Core 1
,除此之外,由于
Arduino-ESP32 库在底层实现上,使用了嵌入式实时操作系统
FreeRTOS,所以在 Arduino
草图代码也可以通过包含如下两个头文件,以使用其提供的多任务处理函数:
1 |
Arduino-ESP32 当中可以通过
xTaskCreatePinnedToCore()
函数创建一个任务,如果任务创建成功,则会返回
pdPASS
,否则表示创建失败:
1 | BaseType_t xTaskCreatePinnedToCore( |
任务创建之后,就可以通过 vTaskDelete()
函数结束并且删除掉一个任务:
1 | void vTaskDelete( |
除此之外,还可以利用 uxTaskPriorityGet()
返回参数任务
XTask
的优先级:
1 | UBaseType_t uxTaskPriorityGet( |
以及使用 XPortGetCoreID()
返回当前任务运行于哪一个微控制器内核:
1 | BaseType t IRAM ATTR XPortGetCoreID( void ); |
在接下来的示例代码当中,通过直接包含 FreeRTOS 配置文件
FreeRTOSConfig.h
的方式(也可以采用分别包含
FreeRTOS.h
和 task.h
两个头文件的方式),展示了如何基于采用 ESP32-S3
多核微控制器的 UINIO-MCU-ESP32S3 进行多任务的处理:
1 |
|
将上述代码下载到 UINIO-MCU-ESP32S3
上面,核心板就会每间隔 2 秒,以 115200
波特率向
Arduino IDE 的串口监视器打印如下内容:
1 | Task-1 任务运行在:Core 0 |
互斥锁机制
FreeRTOS 提供的互斥锁机制是一种包含有优先级继承机制的二进制信号量,之前介绍过的二进制信号量可以用于实现任务与任务,以及任务与中断之间的同步。而互斥锁则有助于更好的实现资源的互斥访问,它就像是保护互斥资源的一个令牌,当任务需要访问资源时,必须首先获取这个令牌;而在使用完资源之后,则必须返回这个令牌,从而使得其它任务能够继续访问该资源。
Arduino-ESP32 可以通过
xSemaphoreCreateMutex()
函数创建一个互斥锁,执行之后就会返回这个互斥锁的句柄:
1 | SemaphoreHandle_t xSemaphoreCreateMutex( void ); |
在创建信号量之后,接下来就可以通过 xSemaphoreTake()
函数获取互斥锁信号量,如果获取成功就返回
pdTRUE
,如果获取失败则返回 pdFALSE
:
1 | xSemaphoreTake( |
除此之外,互斥锁信号量可以通过 xSemaphoreGive()
函数进行释放,信号量释放成功就返回
pdTRUE
,如果发生错误则返回 pdFALSE
:
1 | xSemaphoreGive( |
下面的伪代码,简单明了的展示了互斥锁信号量的典型使用方法:
1 | /* 获取互斥锁信号量 */ |
接下来的示例代码当中,通过使用互斥信号量确保了 Task-1
和
Task-2
两个任务,对于互斥资源变量 number
的同步访问:
1 |
|
注意:不能在中断服务程序当中使用 FreeRTOS 的互斥锁信号量。
基于 WIFI 传输数据
Arduino-ESP32 提供了一系列 WIFI 相关的 API,支持 802.11b/g/n 无线局域网标准,可以用于扫描 WIFI 接入点,也支持 WPA2、WPA3 等 WIFI 安全模式,除此之外还提供了 WIFI 的 STA 和 AP 两种工作模式:
Wi-Fi 客户端模式,也被称为 STA 模式(Station mode),这种模式支持把 ESP32 连接到一个 WIFI 接入点。
Wi-Fi 接入点模式,也被称为 AP 模式(Access Point mode),这种模式下 ESP32 被配置为一个接入点,可以通过提供 Wi-Fi 局域网接收其它设备的连接。
WIFI 相关 API 函数概览
通用 API | 功能描述 |
---|---|
wifi_event_id_t onEvent(WiFiEventCb, arduino_event_id_t = ARDUINO_EVENT_MAX) |
注册一个 WIFI 事件回调函数。 |
void removeEvent(WiFiEventCb, arduino_event_id_t = ARDUINO_EVENT_MAX) |
移除一个 WIFI 事件回调函数。 |
setHostname(const char *hostname) |
设置 DHCP 客户端标识。 |
const char *getHostname() |
获取 DHCP 客户端标识。 |
static void useStaticBuffers(bool bufferMode) |
设置 Wi-Fi
缓冲区的内存分配方式,true 为静态,而 false
为动态。 |
bool setDualAntennaConfig(uint8_t gpio_ant1, uint8_t gpio_ant2, wifi_rx_ant_t rx_mode, wifi_tx_ant_t tx_mode) |
配置双天线功能,仅支持带有 RF 开关的 ESP32 使用。 |
AP 模式 WIFI 相关 API | 功能描述 |
---|---|
WiFi.softAP(ssid, password) |
启动 Wi-Fi 作为接入点。 |
bool softAP(const char* ssid, const char* passphrase = NULL, int channel = 1, int ssid_hidden = 0, int max_connection = 4, bool ftm_responder = false) |
配置 Wi-Fi 的 AP 特性 |
bool softAPConfig(IPAddress local_ip, IPAddress gateway, IPAddress subnet) |
用于配置静态 IP、网关、子网。 |
bool softAPdisconnect(bool wifioff = false) |
强制断开 AP 连接。 |
uint8_t softAPgetStationNum() |
返回当前连接到 AP 的客户端数量。 |
IPAddress softAPIP() |
获取 AP 的 IPv4 地址。 |
IPAddress softAPBroadcastIP() |
获取 AP 的 IPv4 广播地址。 |
IPAddress softAPNetworkID() |
获取 AP 网络的 ID。 |
uint8_t softAPSubnetCIDR() |
获取 AP 网络的子网 CIDR。 |
IPAddress softAPSubnetMask() |
获取 AP 网络的子网掩码。 |
bool softAPenableIpV6() |
启用 IPv6 支持。 |
IPv6Address softAPIPv6() |
获取 IPv6 地址。 |
bool softAPsetHostname(const char * hostname) |
设置 AP 的主机名称。 |
const char * softAPgetHostname() |
获取 AP 的主机名称。 |
uint8_t* softAPmacAddress(uint8_t* mac) |
设置或者获取 AP 的 MAC 地址。 |
String softAPSSID(void) const |
获取 AP 网络的 SSID。 |
STA 模式 WIFI 相关 API | 功能描述 |
---|---|
wl_status_t begin(const char* ssid, const char *passphrase = NULL, int32_t channel = 0, const uint8_t* bssid = NULL, bool connect = true); |
启动 Wi-Fi 连接。 |
bool config(IPAddress local_ip, IPAddress gateway, IPAddress subnet, IPAddress dns1 = (uint32_t)0x00000000, IPAddress dns2 = (uint32_t)0x00000000) |
配置 IP 地址、网关、子网、DNS 信息。 |
IPAddress(uint8_t first_octet, uint8_t second_octet, uint8_t third_octet, uint8_t fourth_octet) |
IPAddress 格式由 4
个字节进行定义。 |
bool reconnect() |
重新连接 Wi-Fi。 |
bool disconnect(bool wifioff = false, bool eraseap = false) |
强制断开 Wi-Fi 连接。 |
bool isConnected(); |
获取 Wi-Fi 连接状态。 |
bool setAutoReconnect(bool autoReconnect) |
设置连接丢失时,是否开启自动重新连接。 |
bool getAutoReconnect() |
获取连接丢失时,自动重连的设置状态。 |
bool setMinSecurity(wifi_auth_mode_t minSecurity) |
设置 AP 连接的最低安全性,默认为
WIFI_AUTH_WPA2_PSK 。 |
WiFiMulti 相关 API | 功能描述 |
---|---|
bool addAP(const char* ssid, const char *passphrase = NULL) |
添加多个 AP 接入点。 |
uint8_t run(uint32_t connectTimeout=5000) |
开始运行 WiFiMulti。 |
WiFiScan 相关 API | 功能描述 |
---|---|
int16_t scanNetworks(bool async = false, bool show_hidden = false, bool passive = false, uint32_t max_ms_per_chan = 300, uint8_t channel = 0) |
开始扫描可用的 WiFi 网络。 |
int16_t scanComplete() |
采用异步模式获取扫描状态。 |
void scanDelete() |
删除 RAM 当中的最后一次扫描结果。 |
bool getNetworkInfo(uint8_t networkItem, String &ssid, uint8_t &encryptionType, int32_t &RSSI, uint8_t* &BSSID, int32_t &channel) |
获取扫描到的 WIFI 网络信息。 |
提供了 WIFI 接入点,并且运行了一个 Web
服务器,http://192.168.4.1/H
去打开 LED on or
http://192.168.4.1/L
使用 HttpClient 发起 GET 请求
ESP32-Arduino 内嵌有一个开源的 HttpClient
库,可以方便的与 Web 服务器进行交互。下面的示例代码会通过 WIFI
局域网,不断的向 http://www.uinio.com
地址发起一个
HTTP GET 请求:
1 |
|
配合 ArduinoJson 发起 POST 请求
除此之外,配合第三方 JSON 解析库 ArduinoJson 使用,还可以方便的以
POST 方式传输 JSON
格式的数据。下面的示例代码,将会携带一个包含有 data
属性的
JSON 对象参数,向远程服务器的 http://192.168.1.1:8080/test
接口发起一个 HTTP POST
请求,并且将响应的结果打印出来:
1 |
|
基于 UINIO-MCU-ESP32 的 Arduino 进阶教程