基于 UINIO-MCU-ESP32 的 Arduino 进阶教程

Arduino-ESP32 是由乐鑫科技GitHub 开源社区推出的一款基于 Arduino IDE板级支持包BSP,Board Support Package),除了兼容大部分通用的 Arduino API 之外,还能够支持 ESP32 系列芯片一些独有的特性化 API。由于几年以前已经撰写过一篇基于标准 Arduino API 的《玩转 Arduino Uno、Mega、ESP 开源硬件》,所以本篇文章不再赘述相关内容,而是结合 U8G2AsyncTimerRBD_BUTTONLiquidCrystal_I2CESP32SPISlaveServoSdFat 等常用第三方库,通过分析注释典型的示例代码,分门别类的介绍了各种片上资源外设的实例化运用。

ESP32-C3ESP32-S3 是当前市场上比较流行的两款物联网主控芯片方案,它们分别基于开源的 RISC-V 内核,以及商业化的 Xtensa 内核,并且同时支持 WiFi 与 Bluetooth 无线连接。由于日常工作当中经常使用到这两款微控制器,所以特意设计了 UINIO-MCU-ESP32C3UINIO-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 提供了对于 ESP32ESP32-S2ESP32-C3ESP32-S3 系列芯片的支持,各个片上外设的具体兼容情况可以参见下表:

注意:所有 ESP32 系列芯片都支持 SPI 以太网,其中 RMII 只有 ESP32 能够支持。

ESP32 Arduino 核心文档 当中提供了如下这些 API 的使用说明,具体内容可以点击下面表格当中的链接逐一查阅:

模数转换(ADC) 低功耗蓝牙(BLE) 传统蓝牙(Bluetooth) 数模转换(DAC)
深度休眠(Deep Sleep) 短距离无线通信(ESP-NOW) 以太网(Ethernet) 通用输入输出(GPIO)
霍尔传感器(Hall Sensor) 内部集成电路总线(I²C) 集成电路内置音频总线(I²S) 远程诊断(ESP Insights)
LED 控制(LEDC,LED Control) Preferences 脉冲计数器(Pulse Counter) ESP Rainmaker
复位原因(Reset Reason) 红外收发器(RMT) SDIO SD MMC
二阶 \(\Sigma\Delta\) 信号调制* 串行外设接口(SPI) 定时器(Timer) 触摸 TOUCH
通用串行总线(USB API) USB 通信设备类(USB CDC) USB 大容量存储类 API(USB MSC) 无线 Wi-Fi

安装完成 CH343P 的 USB 转串口驱动程序之后,就可以将 UINIO-MCU-ESP32 核心板连接至电脑,再打开 Arduino IDE 选择【ESP32C3 Dev Module】或者【ESP32S3 Dev Module】开发板,以及相应的 USB 端口,就可以完成全部的开发连接准备:

接下来,编写如下的代码,以 115200 波特率向 Arduino IDE 的【串口监视器】打印字符串 Welcome to UinIO.com

1
2
3
4
5
6
7
8
9
10
/* 该函数只调用一次 */
void setup() {
Serial.begin(115200); // 设置波特率
}

/* 该函数会循环执行 */
void loop() {
Serial.println("Welcome to UinIO.com"); // 向串口打印字符串
delay(1000);
}

如果 Arduino IDE 的【串口监视器】当中正确打印出了如下结果,就表明当前的开发环境已经搭建成功了:

1
2
3
4
Welcome to UinIO.com
Welcome to UinIO.com
Welcome to UinIO.com
... .. ...

注意:笔者设计的 UINIO-MCU-ESP32C3UINIO-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-ESP32GPIO0 引脚,具体的电路连接关系如下图所示:

注意ESP32 系列芯片高电平信号的最低电压值为 3.3V × 0.8 = 2.64V,而低电平信号的最高电压值为 3.3V × 0.1 = 0.33V

  • pinMode(pin, mode):配置引脚工作模式,其中 mode 参数可选的值有 INPUOUTPUTINPUT_PULLUPINPUT_PULLDOWN
  • digitalWrite(pin, value):设置数字输出引脚的电平状态,其中 value 参数可选的值是 HIGH 或者 LOW
  • delay(ms):延时函数,其参数 ms 的单位为毫秒
1
2
3
4
5
6
7
8
9
10
11
12
int LED_Pin = 0;

void setup() {
pinMode(LED_Pin, OUTPUT); // 配置该引脚为输出状态
}

void loop() {
digitalWrite(LED_Pin, HIGH);
delay(1000); // 延时 1 秒
digitalWrite(LED_Pin, LOW);
delay(1000); // 延时 1 秒
}

由于使用 delay() 延时函数会阻塞后续任务的执行,所以这里改用如下两个 API,通过循环计算时间差值的方式来实现 LED 灯的闪烁:

  • millis():程序当前运行的毫秒数;
  • micros():程序当前运行的微秒数;

下面的示例代码通过 UINIO-MCU-ESP32GPIO0 引脚控制一个 LED 灯,每间隔 1 秒循环不断的进行闪烁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int LED_Pin = 0;
int LED_Status = 0; // LED 目前的点亮状态
unsigned int prevTime = 0; // 前一次 LED 发光状态改变的时间

void setup() {
pinMode(LED_Pin, OUTPUT);
digitalWrite(LED_Pin, HIGH);
LED_Status = HIGH;
prevTime = millis();
}

void loop() {
unsigned int curTime = millis(); // 开始进行测试时刻的时间

/* 两次 LED 状态变化的间隔时间为 1 秒 */
if (curTime - prevTime > 1000) {
int Status = LED_Status == HIGH ? LOW : HIGH; // 切换 LED 状态

digitalWrite(LED_Pin, Status);
LED_Status = Status;
prevTime = curTime;
}
}

如果需要控制多个 LED 的闪烁,则需要将电路连接关系修改为下面的样子,此时控制引脚需要变更为 UINIO-MCU-ESP32GPIO1GPIO2

注意需要同步修改代码当中控制引脚变量 LED_Pin_x 的值,其它的功能代码只需要进行相应的复制粘贴即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
int LED_Pin_1 = 1;            // 将 LED 1 的控制引脚设置为 GPIO1
int LED_Status_1 = 0; // LED 1 目前的点亮状态
unsigned int prevTime_1 = 0; // 前一次 LED 1 发光状态改变的时间

int LED_Pin_2 = 2; // 将 LED 2 的控制引脚设置为 GPIO2
int LED_Status_2 = 0; // LED 2 目前的点亮状态
unsigned int prevTime_2 = 0; // 前一次 LED 2 发光状态改变的时间

void setup() {
/* LED 1 状态设置 */
pinMode(LED_Pin_1, OUTPUT);
digitalWrite(LED_Pin_1, HIGH);
LED_Status_1 = HIGH;
prevTime_1 = millis();

/* LED 2 状态设置 */
pinMode(LED_Pin_2, OUTPUT);
digitalWrite(LED_Pin_2, HIGH);
LED_Status_2 = HIGH;
prevTime_2 = millis();
}

void loop() {
unsigned int curTime_1 = millis(); // LED 1 开始进行测试时刻的时间
unsigned int curTime_2 = millis(); // LED 2 开始进行测试时刻的时间

/* LED 1 两次状态变化的间隔时间为 1 秒 */
if (curTime_1 - prevTime_1 > 1000) {
int Status_1 = LED_Status_1 == HIGH ? LOW : HIGH; // 切换 LED 1 的状态

digitalWrite(LED_Pin_1, Status_1);
LED_Status_1 = Status_1;
prevTime_1 = curTime_1;
}

/* LED 2 两次状态变化的间隔时间为 1 秒 */
if (curTime_2 - prevTime_2 > 1000) {
int Status_2 = LED_Status_2 == HIGH ? LOW : HIGH; // 切换 LED 2 的状态

digitalWrite(LED_Pin_2, Status_2);
LED_Status_2 = Status_2;
prevTime_2 = curTime_2;
}
}

按键控制 与 RBD_BUTTON 库

本示例需要将 UINIO-MCU-ESP32GPIO3GPIO4 分别连接至 LED按键

由于按键的控制引脚被配置为输入上拉 INPUT_PULLUP,所以当按键被按下时低电平有效,读取引脚的电平状态需要使用到如下的 API:

  • digitalRead(pin):读取指定输入引脚 pin 的电平状态,返回值是 HIGH 或者 LOW
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int LED_Pin = 3;    // LED 控制引脚
int LED_Status = 0; // LED 当前状态
int Switch_Pin = 4; // 按键控制引脚

void setup() {
pinMode(LED_Pin, OUTPUT);
pinMode(Switch_Pin, INPUT_PULLUP); // 配置按键控制引脚为输入上拉
digitalWrite(LED_Pin, HIGH);
LED_Status = HIGH;
}

void loop() {
int Switch_Status = digitalRead(Switch_Pin); // 读取按键引脚的状态

/* 当按键被接下时执行的代码 */
if(Switch_Status == LOW) {
LED_Status = !LED_Status;
digitalWrite(LED_Pin, LED_Status);
}
}

观察上述代码的运行结果,可以发现按键对于 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <RBD_Timer.h>
#include <RBD_Button.h>

int LED_Pin = 3; // LED 控制引脚
int LED_Status = 0; // LED 当前状态
int Switch_Pin = 4; // 按键控制引脚

RBD::Button button(Switch_Pin, INPUT_PULLUP); // 创建 button 对象

void setup() {
pinMode(LED_Pin, OUTPUT);
button.setDebounceTimeout(20); // 设置按键消抖延迟时间为 20 毫秒
}

void loop() {
/* 当按键被按下时候的处理逻辑 */
if(button.onPressed()) {
LED_Status = !LED_Status;
digitalWrite(LED_Pin, LED_Status);
}
}

基于 PWM 的 LEDC

LED 发光二极管的正常工作电压介于 1.8V ~ 2.0V 之间,由于该电压变化区间的取值范围较小,难以通过电压大小来控制 LED 的亮度。而脉冲宽度调制PWM,Pulse Width Modulation)则另辟蹊径,通过改变输出方波的占空比来控制 LED 的亮灭频率,从而达到调整亮度的目的。

ESP32-C3ESP32-S3 各拥有 68LEDC 通道,分别用于产生独立的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void setup() {
int GPIO = 4; // 指定 GPIO 引脚 4
int Channel = 0; // 指定 LEDC 通道 0
int Frequency = ledcSetup(Channel, 5000, 12); // 配置 LEDC 为 0 通道、频率为 5000 赫兹、精度为 12 位
Serial.begin(115200);

if (Frequency == 0) {
Serial.println("LEDC 配置失败");
} else {
Serial.println("LEDC 配置成功");
}

ledcAttachPin(GPIO, Channel); // 绑定 GPIO4 引脚与通道 0
ledcWrite(Channel, pow(2, 11)); // 将通道 0 的占空比调整为 50%
}

void loop() {}

接下来再利用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int GPIO4 = 4;    // 指定 GPIO 引脚 4
int Channel = 1; // 指定 LEDC 通道 1
int Duty = 0; // 当前信号的占空比
int Count = 0; // 占空比为 100% 时的等分数量
int Step = 0; // 占空比的步进值
int Breath = 3; // 每次呼吸的时间长度,单位为秒

void setup() {
ledcSetup(Channel, 5000, 12); // 配置 LEDC 为 1 通道、频率为 1000 赫兹、精度为 12 位
Count = pow(2, 12); // 获取占空比为 100% 时候的等分数量
Step = 2 * Count / (50 * Breath); // 每次占空比调整所需要增加的步进值
ledcAttachPin(GPIO4, Channel); // 绑定 GPIO4 引脚与通道 1
}

void loop() {
ledcWrite(Channel, Duty); // 每次循环都改变一次 PWM 信号的占空比
Duty += Step; // 步进值递增

/* 当占空比高于 100% 等分数量的时候 */
if (Duty > Count) {
Duty = Count; // 将占空比 Duty 限制为 100% 等分数量
Step = -Step; // 修改步进值为负数
}
/* 当占空比小于 0 等分数量的时候 */
else if (Duty < 0) {
Duty = 0; // 将占空比设置为 0
Step = -Step; // 修改步进值为负数
}

delay(30); // 等待 30 毫秒再进行下一次的占空比调整
}

上面代码当中的 delay() 函数会阻塞 UINIO-MCU-ESP32 的后续代码运行,下面通过 prevTimecurTime 两个变量来循环计算时间差值,实现一个非阻塞式的呼吸灯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int GPIO4 = 4;     // 指定 GPIO 引脚 4
int Channel = 1; // 指定 LEDC 通道 1
int Duty = 0; // 当前信号的占空比
int Count = 0; // 占空比为 100% 时的等分数量
int Step = 0; // 占空比的步进值
int Breath = 3; // 每次呼吸的时间长度,单位为秒
int prevTime = 0; // 记录前一次调整占空比的时间

void setup() {
ledcSetup(Channel, 5000, 12); // 配置 LEDC 为 1 通道、频率为 1000 赫兹、精度为 12 位
Count = pow(2, 12); // 获取占空比为 100% 时候的等分数量
Step = 2 * Count / (50 * Breath); // 每次占空比调整所需要增加的步进值
ledcAttachPin(GPIO4, Channel); // 绑定 GPIO4 引脚与通道 1
}

void loop() {
int curTime = millis(); // 记录执行到此处的当前时间

/* 判断距离上一次占空比调整是否超过 30 毫秒 */
if (curTime - prevTime >= 30) {
ledcWrite(Channel, Duty); // 每次循环都改变一次 PWM 信号的占空比
Duty += Step; // 步进值递增

/* 当占空比高于 100% 等分数量的时候 */
if (Duty > Count) {
Duty = Count; // 将占空比 Duty 限制为 100% 等分数量
Step = -Step; // 修改步进值为负数
}
/* 当占空比小于 0 等分数量的时候 */
else if (Duty < 0) {
Duty = 0; // 将占空比设置为 0
Step = -Step; // 修改步进值为负数
}
prevTime = curTime; // 更新时间
}
}

软件定时器 与 AsyncTimer 库

ESP32-C3ESP32-S3 分别拥有 2 个和 4硬件定时器,虽然它们的精度较高,但是数量着实有限。在一些对于精度要求不高的场合,可以考虑使用诸如 AsyncTimer 这样的第三方库来作为软件定时器使用,它适用于一些对于精度要求不高的场合(精度为毫秒级别),具体的使用步骤如下面所示:

  1. 首先,在 Arduino IDE 的【库管理器】当中安装 AsyncTimer 库;
  2. 然后,在工程代码当中包含头文件 #include <AsyncTimer.h>
  3. 接下来,声明定时器变量 AsyncTimer timer
  4. 最后,在 void loop() 函数当中调用 t.handle()

下面的示例代码,会通过 AsyncTimer 提供的 setTimeout() 函数,分别延时 3 秒和 5 秒向串口打印提示信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <AsyncTimer.h>

AsyncTimer timer;

/* 以普通函数方式使用 setTimeout() */
void task() {
Serial.println("调用 normal 函数");
}

void setup() {
Serial.begin(115200);

unsigned short id1 = timer.setTimeout(task, 3000);
Serial.print("Timeout ID 1:");
Serial.println(id1);

/* 以 Lambda 函数方式使用 setTimeout() */
unsigned short id2 = timer.setTimeout([](){
Serial.println("调用 lambda 函数");
}, 5000);
Serial.print("Timeout ID 2:");
Serial.println(id2);
}

void loop() {
timer.handle(); // 必须调用该函数才能启动 AsyncTimer 软件定时器
}

/* Timeout ID 1:62510
Timeout ID 2:36048
调用 lambda 函数
调用 normal 函数 */

同样的,可以通过类似的方式调用 AsyncTimersetInterval() 函数,周期性的不断重复向串口打印提示信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <AsyncTimer.h>

AsyncTimer timer;

/* 以普通函数方式使用 setInterval() */
void task() {
Serial.println("调用 normal 函数");
}

void setup() {
Serial.begin(115200);

unsigned short id1 = timer.setInterval(task, 500);
Serial.print("Interval ID 1:");
Serial.println(id1);

/* 以 Lambda 函数方式使用 setInterval() */
unsigned short id2 = timer.setInterval([](){
Serial.println("调用 lambda 函数");
}, 800);
Serial.print("Interval ID 2:");
Serial.println(id2);
}

void loop() {
timer.handle(); // 必须调用该函数才能启动 AsyncTimer 软件定时器
}

/* Interval ID 1:62510
Interval ID 2:36048
调用 normal 函数
调用 lambda 函数
调用 normal 函数
调用 normal 函数
调用 lambda 函数
调用 normal 函数
... ... ... ... */

注意:注意每次调用 setTimeout()setInterval() 之后返回的 ID 值都并不相同。

接下来,结合前面介绍的 RBD_ButtonAsyncTimer 两个第三方库,让一个 LED 在刚开始启动的时候,每间隔 1 秒钟进行闪烁,而在按下按键之后,再切换至间隔 3 秒进行闪烁,再次按下按键则切换回间隔 1 秒进行闪烁,这里依然沿用之前的按键与 LED 实验电路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <RBD_Button.h>
#include <AsyncTimer.h>

int LED_Pin = 3; // LED GPIO
int switch_Pin = 4; // 按键 GPIO
int LED_Status = HIGH; // 设定 LED 初始状态为点亮
int blink = 1; // 设定 LED 闪烁的间隔时间
int taskID = 0; // 定时任务 ID

/* 定义带有消抖功能的按键,低电平有效 */
RBD::Button button(switch_Pin, INPUT_PULLUP);
AsyncTimer timer; // 声明定时器变量

/* 切换 LED 状态的定时器任务 */
void change_LED_Status() {
LED_Status = !LED_Status; // 切换 LED 的亮灭状态
digitalWrite(LED_Pin, LED_Status);
}

void setup() {
pinMode(LED_Pin, OUTPUT); // 设置 LED 引脚为输出模式
digitalWrite(LED_Pin, HIGH); // 执行 LED 的点亮操作
button.setDebounceTimeout(20); // 设置按键消抖延时为 20 毫秒
taskID = timer.setInterval(change_LED_Status, blink * 1000); // 创建周期性重复执行的定时任务
}

void loop() {
timer.handle(); // 启用 AsyncTimer 定时器

/* 判断按键状态 */
if (button.onPressed()) {
blink = blink == 1 ? 3 : 1; // 如果当前 LED 闪烁间隔为 1 秒,那么就将其修改为 3 秒,反之亦然
timer.changeDelay(taskID, blink * 1000); // 执行定时器 LED 闪烁间隔时间的修改操作
}
}

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 位(从 08191),其它型号默认为 12 位(从 04095)。
void analogSetClockDiv(uint8_t clockDiv); 设置 ADC 时钟的分频器,范围为 0 ~ 255,默认值为 1
void analogSetAttenuation(adc_attenuation_t attenuation); 设置全部通道的衰减系数,共拥有 ADC_ATTEN_DB_0ADC_ATTEN_DB_2_5ADC_ATTEN_DB_6ADC_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-ESP32GPIO2 引脚连接至电位器,而 GPIO1 作为 LED 发光二极管的控制引脚,接着编写并且上传如下的控制逻辑代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <AsyncTimer.h>

int taskId = 0; // 定时任务 ID
AsyncTimer timer; // 通过定时器,关联 LED 与 电位器

int LED_Pin = 1; // LED 连接的 GPIO
int LED_Channel = 0; // 指定输出 PWM 信号的 LEDC 通道
int Potential_Pin = 2; // 电位器连接的 GPIO

/* LED 亮度调整函数 */
void changeBrightness() {
int value = analogRead(Potential_Pin); // 读取电位器所连接 GPIO 引脚的原始值
Serial.printf("%d:", value);

auto voltage = analogReadMilliVolts(Potential_Pin); // 读取电位器所连接 GPIO 引脚的电压值
Serial.println(voltage);

int duty = value / 4095.0 * 1024; // 计算占空比,此处的常量 4095.0 必须为浮点类型(电位器为最小值 0 时,占空比也为 0,LED 熄灭;当电位器为最大值 4095 时,占空比为 1024,LED 最亮)
ledcWrite(LED_Channel, duty);
}

void setup() {
Serial.begin(115200);
analogReadResolution(12); // 设置 ADC 读取分辨率为 12 位,即读取到的最大值为 4096
analogSetAttenuation(ADC_11db); // 设置 ADC 的衰减值为 11 分贝

ledcSetup(LED_Channel, 1000, 10); // 设置 LEDC 通道的频率为 1000Hz,分辨率精度为 10
ledcAttachPin(LED_Pin, LED_Channel); // 关联 LEDC 通道与 LED 控制引脚

taskId = timer.setInterval(changeBrightness, 20); // 每间隔 20 毫秒改变一次 LED 亮度
}

void loop() {
timer.handle();
}

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); 用于定义 SDASCL 引脚,两个参数的默认值分别为 GPIO21GPIO22
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 总线的 SDASCL 引脚,以及通信频率。
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 函数的基本使用步骤如下面的列表所示:

  1. #include "Wire.h",包含 Wire.h 头文件;
  2. Wire.begin(),开始配置 I²C 总线;
  3. Wire.beginTransmission(I2C_DEV_ADDR),指定 I²C 从设备地址,开始进行数据传输;
  4. Wire.write(x),把数据写入到缓冲区;
  5. Wire.endTransmission(true),将缓冲区的全部数据写入至从设备
  6. Wire.requestFrom(I2C_DEV_ADDR, SIZE),请求读取指定从设备的数据;
  7. Wire.readBytes(temp, error),开始读取从设备响应的数据;

下面是一个如何在主设备模式下使用 I²C 总线的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "Wire.h"

uint32_t i = 0;
#define I2C_Address 0x55

void setup() {
Wire.begin();
Serial.begin(115200);
Serial.setDebugOutput(true);
}

void loop() {
delay(5000);

/* 向从设备写入数据 */
Wire.beginTransmission(I2C_Address);
Wire.printf("Hello UinIO.com! %u", i++); // 通过 I²C 总线发送数据
uint8_t error = Wire.endTransmission(true);
Serial.printf("endTransmission: %u\n", error);

/* 读取从设备 16 字节的响应数据 */
uint8_t bytesReceived = Wire.requestFrom(I2C_Address, 16);
Serial.printf("requestFrom: %u\n", bytesReceived);

/* 如果接收到的字节数据大于 0 */
if((bool)bytesReceived){
uint8_t temp[bytesReceived];
Wire.readBytes(temp, bytesReceived);
log_print_buf(temp, bytesReceived);
}
}

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 函数的基本使用步骤如下面的列表所示:

  1. #include "Wire.h",包含 Wire.h 头文件;
  2. Wire.onReceive(onReceive)Wire.onRequest(onRequest),创建两个回调函数来接收或者请求主设备的数据;
  3. Wire.begin((uint8_t)I2C_DEV_ADDR);,使用指定的地址配置 I²C 总线;
  4. Wire.slaveWrite((uint8_t *)message, strlen(message));,预先向从设备的缓冲区写入数据;

下面是一个如何在从设备工模式下使用 I²C 总线的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include "Wire.h"

uint32_t i = 0;
#define I2C_DEV_ADDR 0x55

/* 接收主设备数据的回调函数 */

void onRequest(){
Wire.print(i++);
Wire.print(" Packets.");
Serial.println("onRequest");
}

/* 接收主设备数据的回调函数 */
void onReceive(int len){
Serial.printf("onReceive[%d]: ", len);
/* 判断是否存在可用的数据 */
while(Wire.available()){
Serial.write(Wire.read()); // 读取并且打印 I²C 总线数据到串口
}
Serial.println();
}

void setup() {
Serial.begin(115200);
Serial.setDebugOutput(true);
Wire.onReceive(onReceive);
Wire.onRequest(onRequest);
Wire.begin((uint8_t)I2C_DEV_ADDR); // 将从设备注册到 I²C 总线

#if CONFIG_IDF_TARGET_ESP32
char message[64];
snprintf(message, 64, "%u Packets.", i++);
Wire.slaveWrite((uint8_t *)message, strlen(message));
#endif
}

void loop() {}

主从设备通信实例

接下来,以 UINIO-MCU-ESP32S3 作为主设备,而 UINIO-MCU-ESP32C3 作为从设备(I²C 地址为 55),两者的 SDASCK 都分别指定为为 GPIO5GPIO6,并且在从设备的 GPIO8 上面连接一枚 LED:

UINIO-MCU-ESP32S3 作为主设备,每间隔 2 秒就会向从设备 UINIO-MCU-ESP32C3 发送一个递增的数值,从设备接收到主设备的数据之后 LED 就会闪烁 0.5 秒,并且在收到的数值后面添加 已经被接收 字样,然后返回给主设备打印至串口,具体的示例代码如下面所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/* UINIO-MCU-ESP32S3 主设备程序 */
#include <Wire.h>

int number = 1; // 发送给从设备的数值
int address = 55; // 从设备 I²C 地址

void setup() {
Serial.begin(115200);
Wire.setPins(5, 6); // 设置 SDA 为 GPIO5,而 SCK 为 GPIO6

/* 将主设备添加至 I²C 总线 */
if (Wire.begin()) { //
Serial.println("加入 I²C 总线成功");
} else {
Serial.println("加入 I²C 总线失败");
}
}

void loop() {
/* 向从设备发送数据 */
char data[32];
itoa(number++, data, 10); // 将整型数值 number 转换为字符串 data

Wire.beginTransmission(address); // 开始向指定的从设备传输数据
Wire.write(data); // 开始写入 number 数值字符串
int result = Wire.endTransmission(); // 结束数据传输

/* 判断传输是否出现错误 */
if (result != 0) {
Serial.printf("传输错误:%d\r\n", result);
return; // 如果传输状态不为 0,那么就无需再执行后续的数据接收步骤,直接返回结束
}

delay(100); // 延时 100 毫秒,给从设备处理并且响应数据留出足够时间

/* 接收从设备发送的数据 */
int length = Wire.requestFrom(address, 32); // 发起对于从设备数据的请求,最多不超过 32 字节数据

/* 如果接收到了数据 */
if (length > 0) {
Serial.print("主设备接收的数据大小:");
Serial.println(length); // 打印接收到的数据大小

Wire.readBytes(data, 32); // 读取接收缓冲区的数据
Serial.println(data); // 打印接收缓冲区的数据

/* 把从设备发送回来的数据,以 16 进制格式打印出来 */
for (int index = 0; index < 32; index++) {
Serial.printf("%2X, ", data[index]);
if (index % 8 == 7) {
Serial.println();
}
}
Serial.println();
}
delay(2000);
}

/*
主设备接收的数据大小:32
5674 已经被接收
35, 36, 37, 34, 20, E5, B7, B2,
E7, BB, 8F, E8, A2, AB, E6, 8E,
A5, E6, 94, B6, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
*/

如果主设备 requestFrom() 所指定的 quantity 参数的数据量,大于从设备发送过来的数据量,那么多出的空间将会由 0xff 进行填充。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* UINIO-MCU-ESP32C3 从设备程序 */
#include <Wire.h>
#include <AsyncTimer.h>

int LED_Pin = 8; // 指定 LED 引脚
int address = 55; // 从设备 I²C 地址
char buffer[32]; // 数据接收缓冲区

AsyncTimer timer;

/* 接收主设备数据的回调函数,参数 length 表示主机发送过来的数据量 */
void onReceive(int length) {
if (length > 0) {
int size = Wire.readBytes(buffer, 32); // 读取主设备发送过来的数据到缓冲区
if (size > 0) {
buffer[size] = 0; // 将缓冲区数据转换为以结束符 0 结尾的字符串
digitalWrite(LED_Pin, HIGH); // 点亮 LED
timer.setTimeout([]() {
digitalWrite(LED_Pin, LOW); // 500 毫秒以后熄灭 LED
}, 500);
}
}
}

/* 向主设备发送数据的回调函数 */
void onRequest() {
strcat(buffer, " 已经被接收"); // 在主设备的数据结尾添加字符串 OK
Wire.write(buffer); // 将数据发送回主设备
Wire.write(0); // 发送字符串结束符
}

void setup() {
Serial.begin(115200);
pinMode(LED_Pin, OUTPUT);
Wire.onReceive(onReceive);
Wire.onRequest(onRequest);
Wire.setPins(5, 6); // 设置 SDA 为 GPIO5,而 SCK 为 GPIO6
Wire.begin(address);
}

void loop() {
timer.handle(); // 必须调用该函数才能启动 AsyncTimer 软件定时器
}

PCF8574 驱动 1602 液晶屏

1602 字符型液晶显示屏,一共可以显示 2 行内容,每一行可以显示 16 个字符,屏幕驱动芯片采用了日立HD44780,由于该屏幕在使用时需要占用大量 GPIO 引脚。所以需要借助德州仪器的 PCF8574 八位 GPIO 扩展器(工作电压介于 2.5V ~ 5.5V 范围),将其转换为两线制的 I²C 总线协议。

注意PCF8574 的 I²C 地址默认为 0x27,可以通过 电阻调整 PCF8574 模组 A0A1A2 位置的通断来修改其 I²C 地址。除此之外,还可以通过 PCF8574 模组上面的电位器,调整 1602 液晶显示屏的对比度。

LiquidCrystal_I2C 是一款兼容 HD44780PCF8574 的 LCD 屏幕驱动库,使用时需要将其工程 src 目录下的 LiquidCrystal_I2C.cppLiquidCrystal_I2C.h 文件拷贝至 Arduino IDE 的草图根目录,然后通过 #include "LiquidCrystal_I2C.h" 语句将其包含至 Arduino 草图源文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <Wire.h>
#include "LiquidCrystal_I2C.h"

LiquidCrystal_I2C LCD(0x27, 16, 2); // I²C 地址为 0x27,LCD 屏幕为 16 列 2 行

void setup() {
Wire.setPins(5, 6); // 配置 I²C 总线的 SDA 为 GPIO5,而 SCL 为 GPIO6

LCD.init(); // 初始化 LCD
LCD.backlight(); // 开启 LCD 背光
LCD.print("Hello UinIO.com"); // 光标默认位于第 1 行第 1 列
LCD.setCursor(0, 1); // 将光标切换至第 2 行的第 1 列
LCD.print("Welcome to UinIO.com !"); // 字符数超过 16,超出长度的部分不会显示

/* 将显示内容修改为 Welcome To UinIO.com ! */
// LCD.setCursor(8, 1); // 将光标切换至第 2 行的第 8 列
// LCD.write('T'); // 把小写字母 t 替换为大写字母 T
// LCD.setCursor(9, 1); // 将光标切换至第 2 行的第 9 列
// LCD.write('O'); // 把小写字母 o 替换为大写字母 O

// /* 清除屏幕内容 */
// LCD.clear();

/* 滚动字符显示 */
for (int index = 0; index < 100; index++) {
LCD.scrollDisplayLeft(); // 每一次向左滚动 1 个字符
delay(500); // 延时 0.5 秒
}
}

void loop() {}

注意:需要将 PCF8574 模块上丝印为 SDASCL 的引脚,分别连接至 UINIO-MCU-ESP32GPIO5GPIO6 引脚。

外部中断 & 自旋锁

中断(Interrupt)是指计算机运行过程当中,如果出现某些意外情况需要干预时,程序能够自动停止当前正在运行的代码,转而处理这个新出现的情况,处理完毕之后再返回之前的程序继续执行。在 Arduino-ESP32 当中使用外部中断时,需要注意到以下情况:

  • delay() 函数依赖于中断,在中断服务程序当中无法调用;
  • micros() 函数刚开始会正常工作,但是可能会在 1 ~ 2 毫秒之后出现异常行为;
  • millis() 函数依赖于中断计数器,其返回值在中断服务程序当中不会增加;
  • delayMicroseconds() 并不会使用到中断计数器,因而能够在中断服务程序当中正常工作;

ESP32-Arduino 里的中断服务程序(ISR, Interrupt Service Routines)是一种没有参数返回值的特殊函数(如果代码中同时使用到多个中断服务程序,那么它们将会按照优先级的顺序进行执行),ESP32-Arduino 库支持以如下方式,在指定的引脚上面启用或者关闭外部中断服务:

1
2
attachInterrupt(digitalPinToInterrupt(pin), ISR, mode)  // 开启中断,并且添加中断服务程序
detachInterrupt(digitalPinToInterrupt(pin)) // 关闭中断
  • pin: 发生外部中断的 GPIO 引脚编号;
  • ISR: 发生外部中断时候,自动调用的中断服务函数(无参数,无返回值);
  • mode: 中断触发方式,取值可以为 LOW(低电平触发)、CHANGE(状态变化触发)、RISING(上升沿触发)、FALLING(下降沿触发)四个常量当中的一个;

接下来使用中断服务程序,完成一个当按键按下的时候,LED 发光二极管熄灭,而在按键弹起时 LED 点亮的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const byte LED = 5;     // LED 连接的 GPIO 引脚
const byte Button = 6; // BUTTON 连接的 GPIO 引脚

void switchPressed() {
/* 当 BUTTON 对应的 GPIO 引脚呈现高电平状态*/
if (digitalRead(BUTTON) == HIGH) {
digitalWrite(LED, HIGH); // LED 点亮
} else {
digitalWrite(LED, LOW); // LED 熄灭
}
}

void setup() {
pinMode(LED, OUTPUT); // 将 LED 对应的 GPIO 引脚设置为输出模式
pinMode(BUTTON, INPUT_PULLUP); // 将 BUTTON 对应的 GPIO 引脚设置为输入上拉模式
attachInterrupt(digitalPinToInterrupt(BUTTON), switchPressed, CHANGE);
}

上述代码只是在中断服务程序里控制 LED 的亮灭状态,如果需要使用一个全局变量,在中断服务程序与主程序之间传递数据,那么必须要将其声明为 volatile 类型,从而确保该全局变量总是被正确的更新,例如下面代码当中的 number 变量就使用了 volatile 关键字进行声明:

1
2
3
4
5
6
7
8
9
10
volatile int number = 0;

void ISR() {
number++;
}

void loop() {
int num = number;
Serial.println(num);
}

而在下面这份示例代码当中,如果程序执行到注释的位置发生了中断,那么变量 number 1 的值将不会得到更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
volatile int number1 = 0;
volatile int number2 = 0;

void ISR() {
number1++;
number2++;
}

void loop() {
int num1 = number1;
/*
如果程序执行到这个位置发生了中断,那么变量 number 1 的值不会被更新
*/
int num2 = number2;
Serial.println(num);
}

如果要确保 number 1 的值正常更新,就必须短暂的禁用中断。ESP32-Arduino 支持手动使能 interrupts()失能 noInterrupts() 中断服务:

1
2
3
4
5
6
7
8
9
void setup() {}

void loop() {
noInterrupts(); // 失能中断
/* 此处放置临界的时间敏感代码 */

interrupts(); // 使能中断
/* 其它代码 */
}

可以修改前面的示例代码,通过使用 interrupts()noInterrupts() 函数,使得程序即使执行到注释位置发生中断,也仍然可以确保变量 number1 被正确的更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
volatile int number1 = 0;
volatile int number2 = 0;

void ISR() {
number1++;
number2++;
}

void setup() {}

void loop() {
noInterrupts(); // 失能中断
int num1 = number1;
/******
即使程序执行到这个位置发生了中断,依然可以确保变量 number1 会被正确的更新
******/
int num2 = number2;
interrupts(); // 使能中断
Serial.println(num1);
Serial.println(num2);
}

由于 ESP32-C3ESP32-S3 两款微控制器都拥有两个计算核心,即使禁用了当前核心的中断服务,另外一个核心也同样可能访问到临界区(访问共用资源的程序片段)的资源,所以就需要在禁用中断的同时,对临界区的资源进行上锁。由 ESP-IDF 提供的 portMUX_INITIALIZER_UNLOCKED 自旋锁,同样可以应用在 ESP32-Arduino 的草图代码当中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;  // 定义自旋锁变量

volatile int number1 = 0;
volatile int number2 = 0;

IRAM_ATTR void ISR() {
portENTER_CRITICAL_ISR(&mux); // 中断函数访问临界资源开始
number1++;
number2++;
portEXIT_CRITICAL_ISR(&mux); // 中断函数访问临界资源结束
}

void setup() {}

void loop() {
portENTER_CRITICAL(&mux); // 主函数访问临界资源开始
int num1 = number1;
int num2 = number2;
portEXIT_CRITICAL(&mux); // 主函数访问临界资源结束

Serial.println(num1);
Serial.println(num2);
}

注意:上述代码当中的 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 总线的 SDASCL,分别连接到 UINIO-MCU-ESP32GPIO5GPIO6 引脚,同时把信号发生器的输出探头连接至 UINIO-MCU-ESP32GPIO8 引脚:

当信号发生器输出频率为 1000Hz,占空比为 50% 的方波信号时,下面的代码就可以使得 UINIO-MCU-ESP32 在 1602 屏幕上显示出频率 Freq: 1000.0占空比 Duty: 0.5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include "LiquidCrystal_I2C.h"

int PWM_Pin = 8; // 输入 PWM 信号的 GPIO 引脚
LiquidCrystal_I2C LCD(0x27, 16, 2);

volatile unsigned long RisingTime = 0; // 上升沿发生的时间
volatile unsigned long FallingTime = 0; // 下降沿发生的时间

/* 下面两个变量是 loop() 和 changeISR() 函数都会访问到的临界资源 */
volatile double Duty = 0; // 脉冲信号的占空比
volatile double Frequency = 0; // 脉冲信号的频率

portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; // 定义自旋锁变量

void changeISR() {
auto Now = micros(); // 每一次发生中断,都记录其发生的时间

/* 判断指定 GPIO 引脚的高低电平状态 */
if (digitalRead(PWM_Pin)) {

/* 中断函数访问临界区变量开始 */
portENTER_CRITICAL_ISR(&mux);
auto period = Now - RisingTime; // 周期等于当前中断发生的时间减去之前上升沿的时间
Frequency = 1e6 / (double)period; // 因为是以微秒作为单位,所以这里使用 1e6
auto width = FallingTime - RisingTime; // 脉冲宽度等于上升沿减去下降沿的出现时间
Duty = width / (double)period; // 占空比等于脉冲宽度除以周期
portEXIT_CRITICAL_ISR(&mux);
/* 中断函数访问临界区变量结束 */

RisingTime = Now; // 将本次中断发生的时间,保存为前一次上升沿发生的时间
} else {
FallingTime = Now; // 将本次中断发生的时间,保存为前一次下降沿发生的时间
}
}

void setup() {
/* 设置 1602 液晶屏的 I²C 总线 */
Wire.setPins(5, 6); // 指定 SDA 为 GPIO5,SCL 为 GPIO6

/* 初始化 1602 液晶显示屏 */
LCD.init();
LCD.backlight();
LCD.setCursor(0, 0);
LCD.print("Freq: ");
LCD.setCursor(0, 1);
LCD.print("Duty: ");

/* 将输入 PWM 的 GPIO 设置为输入模式,并且绑定至引脚电平发生变化时,就会触发的中断处理函数 */
pinMode(PWM_Pin, INPUT);
attachInterrupt(digitalPinToInterrupt(PWM_Pin), changeISR, CHANGE);
}

void loop() {
/* 每间隔 1 秒读取频率与占空比的全局变量 */
delay(1000);

/* 主函数访问临界区变量开始 */
portENTER_CRITICAL(&mux);
double DutyValue = Duty;
double FrequencyValue = Frequency;
portEXIT_CRITICAL(&mux);
/* 主函数访问临界区变量结束 */

/* 1602 液晶屏幕显示频率与占空比数值 */
LCD.setCursor(5, 0);
LCD.print(FrequencyValue);
LCD.setCursor(5, 1);
LCD.print(DutyValue);
}

定时器 Timer & 信号量 Semaphore

ESP32-C3 芯片内置有 2 个 54通用定时器(具有 16预分频器54 位可自动重载的向上/向下计数器)。 而 ESP32-S3 则内置有 4 个 54通用定时器(具有 16预分频器54 位可自动重载的向上/向下计数器)。

ESP32 的通用定时器以 APB 时钟 APB_CLK 作为基本时钟源(该时钟频率由 CPU_CLK 的时钟(即微控制器当前的运行频率)决定,其中 ESP32-C3160 MHz,而 ESP32-S3240 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-C3ESP32-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-C3ESP32-S3 的分频系数应当分别被设置为 160240Arduino-ESP32 封装有一系列定时器相关的 API,它们的基本使用步骤如下面所示:

  1. 初始化硬件定时器,确定时钟频率以及计数方向;
  2. 绑定定时器中断服务程序;
  3. 设置定时器的计数值;
  4. 开始启动定时器;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 定义一个定时器指针变量 */
hw_timer_t* timer = NULL;

void IRAM_ATTR onTimer() {
/* 定时器中断服务程序 */
}

/* 初始化定时器,将 ESP32-C3 的计数周期设置为 1 微秒 */
timer = timerBegin(0, 160, true);

/* 绑定定时器与中断服务程序 */
timerAttachInterrupt(timer, onTimer,true);

/* 计数至 1000000 次的时候触发中断,即每间隔 1 秒触发一次中断 */
timerAlarmWrite(timer,1000000true);

/* 开启定时器 */
timerAlarmEnable(timer);

可以看到在定时器的整个使用过程当中,最为重要的就是定时器初始化函数 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
2
3
4
5
6
7
8
9
10
11
/* 中断服务程序 */
void ISR() {
// 中断任务
发送信号()
}

void loop() {
if( 接收信号() ) {
// 执行处理
}
}

由于 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
volatile int Count = 0;                         // 中断触发的次数
volatile unsigned long Time = 0; // 中断触发的时间

hw_timer_t* Timer1 = NULL; // 定时器 1 全局指针变量
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;

volatile SemaphoreHandle_t timerSemaphore; // 声明全局信号量

/* 定时器中断服务程序,每间隔 1 秒被执行一次 */
void IRAM_ATTR onTimer1() {
portENTER_CRITICAL_ISR(&timerMux);
Count++; // 中断触发次数自增 1
Time = micros(); // 获取当前微秒时间
portEXIT_CRITICAL_ISR(&timerMux);

xSemaphoreGiveFromISR(timerSemaphore, NULL); // 完成中断次数与时间的赋值之后就发送信号
}

void setup() {
Serial.begin(115200);
timerSemaphore = xSemaphoreCreateBinary(); // 创建二值信号量
Timer1 = timerBegin(0, 240, true); // 初始化定时器,分频系数为 240,即每间隔 1 微秒计数一次
timerAttachInterrupt(Timer1, onTimer1, true); // 添加定时器中断服务程序
timerAlarmWrite(Timer1, 1000000, true); // 每间隔 1 秒循环触发中断
timerAlarmEnable(Timer1); // 启动定时器
}

void loop() {
/* 检测二值信号,如果检测到,就会立即执行 */
if (xSemaphoreTake(timerSemaphore, 0) == pdTRUE) {

portENTER_CRITICAL(&timerMux);
auto count = Count;
auto time = Time;
portEXIT_CRITICAL(&timerMux);

Serial.println(count);
Serial.println(time);
}
}

HC-SR04 超声波模组

新款的 HC-SR04 超声波模组采用了 RCWL-9206 作为测距解调芯片,其工作电压范围介于 3V ~ 5.5V 之间,工作电流为 2.2mA ~ 3mA,同时支持 GPIOUARTI²C 三种通信方式,更多的性能参数可以参考下面的表格:

HC-SR04 超声波模组引脚接口的功能定义,如下面的表格所示:

通过配置模组上的电阻 \(R_4\)\(R_5\) 可以选择 HC-SR04 的引脚通信模式:

UINIO-MCU-ESP32 发射一个持续时间至少 10us 的脉冲信号到 HC-SR04Trig 引脚;此时 HC-SR04 会连续发送出 8 个 40KHz 频率的超声波信号,并且 HC-SR04Echo 引脚会切换为高电平;如果超声波信号没有响应,那么 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-ESP32S3GPIO8GPIO9 分别作为 HC-SR04 超声波模组的 EchoTrig 引脚,然后以 GPIO 通信方式,通过上面的 pulseIn() 函数读取 HC-SR04 传感器的数据,并且计算出以厘米作为单位的距离值,最后打印到波特率为 115200 的串口上面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const int Echo_Pin = 8;  // 指定 Echo 连接的 GPIO 引脚
const int Trig_Pin = 9; // 指定 Trig 连接的 GPIO 引脚

void setup() {
Serial.begin(115200);
pinMode(Trig_Pin, OUTPUT);
pinMode(Echo_Pin, INPUT);
}

void loop() {
/* 向 Trig 引脚发送 15us 脉冲,发出超声波信号 */
digitalWrite(Trig_Pin, HIGH);
delayMicroseconds(15); // 延时 15 微秒
digitalWrite(Trig_Pin, LOW);

/* 读取 Echo 引脚高电平脉冲的时间长度 */
auto time = pulseIn(Echo_Pin, HIGH);
double distance = time * 0.01715; // 根据脉冲时间求解出距离,单位为厘米
Serial.println(distance);

delay(200); // 确保两次测量的间隔时间不低于 200 毫秒。
}

由于上述代码当中的 pulseIn()delay() 函数在运行时都是阻塞式的,会严重的迟滞其它任务的执行时间,接下来会以非阻塞式的中断方式来实现相同功能,有所不同的是,这次会把结果显示到 1602 液晶屏幕上面:

  1. 首先,需要把外部中断 change 添加至连接到超声波传感器 Echo 的 GPIO 引脚,然后使用硬件定时器每间隔 500 毫秒向超声波模组的 Trig 引脚发送 15 微秒的脉冲,即每 1 秒钟进行两次测量。
  2. 然后,在发生上升沿中断的时候,记录下当前时间 \(t_1\)(超声波发送出去的时间);而发生下降沿中断的时候,也记录下当前时间 \(t_2\)(接受到超声波信号反射的时间),与此同时发送出一个二值信号量。
  3. 最后,当 loop() 函数在接收到信号量之后,根据获取到的 \(t_1\)\(t_2\) 的数值,就可以计算出物体与超声波探头之间的距离,并且显示在 1602 屏幕上面。

接下来,同样把 UINIO-MCU-ESP32S3GPIO5GPIO6 引脚,作为 1602 液晶显示屏 I²C 总线的 SDASCL。而 GPIO8GPIO9 分别作为 HC-SR04 超声波模组的 EchoTrig 引脚:

下面代码同样是以 GPIO 通信方式读取 HC-SR04 传感器的数据,并且以非阻塞式方式计算出以毫米作为单位的距离值,最后将该值显示到 1602 液晶屏幕上面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include "LiquidCrystal_I2C.h"

const int Echo_Pin = 8; // 指定 Echo 连接的 GPIO 引脚
const int Trig_Pin = 9; // 指定 Trig 连接的 GPIO 引脚

int Distance = 0; // 探头与被测物体的间隔距离(单位为毫米)
hw_timer_t* Timer1 = NULL; // 声明一个硬件定时器
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; // 定义自旋锁变量
volatile unsigned long StartTime = 0; // 超声波开始发射的时间
volatile unsigned long EndTime = 0; // 接收到超声波反射的时间
volatile SemaphoreHandle_t semaphore; // 二值信号量

LiquidCrystal_I2C LCD(0x27, 20, 4);

/* 硬件定时器中断服务程序 */
void IRAM_ATTR TrigerPulse() {
digitalWrite(Trig_Pin, HIGH);
delayMicroseconds(15); // 提供 15 微秒的脉冲信号
digitalWrite(Trig_Pin, LOW);
}

/* Echo 引脚状态变化中断服务程序 */
void IRAM_ATTR EchoChange() {
auto now = micros(); // 获取当前微秒时间
auto state = digitalRead(Echo_Pin);

/* 访问临界区资源 */
portENTER_CRITICAL_ISR(&mux);
/* 如果 Echo 引脚为高电平,表示超声波已经发出 */
if (state) {
StartTime = now;
} else {
EndTime = now;
}
portEXIT_CRITICAL_ISR(&mux);

/* 如果 Echo 引脚为低电平,表示已经接收到超声波反射 */
if (!state) {
xSemaphoreGiveFromISR(semaphore, NULL);
}
}

void setup() {
pinMode(Trig_Pin, OUTPUT);
pinMode(Echo_Pin, INPUT);

/* 设置 1602 液晶屏的 I²C 总线 */
Wire.setPins(5, 6); // 指定 SDA 为 GPIO5,SCL 为 GPIO6
LCD.init();
LCD.backlight(); // 打开 1602 液晶屏背光
LCD.print("Distance:"); // 显示 1602 液晶屏第 1 行内容

semaphore = xSemaphoreCreateBinary();

/* 硬件定时器 */
Timer1 = timerBegin(0, 240, true); // 把 UINIO-MCU-ESP32S3 分频系数设置为 240,即每 1 微秒进行 1 次计数
timerAttachInterrupt(Timer1, TrigerPulse, true); // 添加硬件定时器中断服务程序
timerAlarmWrite(Timer1, 500000, true); // 每间隔 500 微秒触发 1 次

attachInterrupt(digitalPinToInterrupt(Echo_Pin), EchoChange, CHANGE); // 添加 Echo 引脚状态变化中断服务程序
timerAlarmEnable(Timer1); // 启动定时器
}

void loop() {
/* 判断二值信号量 */
if (xSemaphoreTake(semaphore, 0) == pdTRUE) {
portENTER_CRITICAL(&mux);
auto time = EndTime - StartTime; // 计算出脉冲宽度对应的时间
portEXIT_CRITICAL(&mux);

double distance = time * 0.1715; // 根据脉冲宽度时间,计算出距离(单位为毫米)

/* 由于超声波传感器的有效测量距离为 3500 毫米以内,所以这里只处理低于该数值的距离数据 */
if (distance < 3500) {
/* 判断本次计算出的距离,与之前计算的距离是否一致,防止数据刷新过快导致 1602 屏幕闪烁 */
int distance_now = (int)distance;
if (distance_now != Distance) {
LCD.setCursor(0, 1);
LCD.printf("%d mm ", distance_now); // 将距离值显示到 1602 屏幕
Distance = distance_now;
}
}
}
}

伺服舵机 & ESP32Servo 库

航模玩家经常使用到的舵机,本质上是一种低成本的伺服电机(Servomotor)系统。其工作原理是通过内部的控制电路接收 PWM 脉冲宽度调制信号,然后控制内置电机转动,内置电机带动一系列的减速齿轮组把扭矩传递至输出轴舵盘输出轴会与用于反馈角度位置的电位器相互连接,当舵盘转动的时候,同时会带动电位器输出一个电压信号,反馈至舵机内部的控制电路,然后控制电路根据其位置决定舵机转动的角度或者速度

根据控制电路的不同,可以将舵机划分为数字舵机模拟舵机两种类型。而根据旋转角度的不同,也可以将其进一步划分为 180° 舵机和 360° 舵机两种类型:

  • 180° 舵机:可以通过脉冲宽度调制 PWM 信号控制旋转角度(从 度到 180° 度)。
  • 360° 舵机:可以 360° 度转动,只能调节转动速度,不能调节转动角度

舵机的控制信号是一个周期为 20 毫秒的 PWM 信号,其中脉冲宽度介于 0.5 ~ 2.5 毫秒范围之间,与其对应的线性旋转角度为 0° ~ 180°。换而言之,舵机会根据 PWM 信号的脉冲宽度,将输出轴旋转到一个指定的角度上面:

在接下来的列表当中,分别介绍了舵机非常重要的 4 个性能参数:

  1. 力矩:用于表示对物体作用时所产生转动效应大小的物理量(即 F力臂 r 的乘积),其单位为牛顿·米N·m)。
  2. 失速力矩:指转动轴在被外力锁定的情况下,以目标温升作为约束,可以连续输出力矩的最大值,有时候也将其称为堵转力矩堵转力矩通常高于额定力矩)。该参数的单位为千克·厘米Kg·cm),即舵机发生堵转的时候,1 厘米的力臂所能够提起的最大质量。
  3. 动作死区:该参数用于描述舵机的旋转精度,因为舵机内部的基准电路会产生周期为 20 微秒,脉冲宽度为 1.5 微秒的基准信号。通过内置的比较器,将控制信号与这个基准信号进行比较,从而判断出旋转的角度。但是舵机在实际工作当中,难以完全精确的控制角度,而比较器的存在又势必会导致舵机在停止点附近往复振荡,因而就需要舵机的控制电路将这个误差值吸收掉,这就是动作死区。常见小型舵机的死区时间为 5 微秒(对应角度为 0.45° 度),即如果想将舵机旋转 45° 度,其真正的停止位置会介于 45° ± 0.45° 范围之间。
  4. 反应转速:舵机在无负载的情况下,转动 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
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <ESP32Servo.h>

Servo Servo_1; // 声明 Servo 对象
int Min_Us = 500; // 舵机 0° 度时发出的脉冲宽度(单位为微秒)
int Max_Us = 2500; // 舵机 180° 度时发出的脉冲宽度(单位为微秒)
int Servo_1_Pin = 15; // 声明需要绑定到 Servo 对象的 GPIO 引脚

ESP32PWM::allocateTimer(1); // 指定生成 PWM 信号所使用的定时器
Servo_1.setPeriodHertz(50); // 指定 PWM 信号的频率
Servo_1.attach(Servo_1_Pin, Min_Us, Max_Us); // 将 Servo 对象绑定到指定的 GPIO 引脚

Servo_1.write(postion); // 发出 PWM 信号,让舵机旋转 0° ~ 180° 度

Servo_1.detach(); // 停止绑定 Servo 对象到 GPIO 引脚,并且释放对 LEDC 通道的占用

ESP32Servo 库的底层运用了定时器LEDC 来控制 PWM 信号的生成,其中 ESP32-C3 拥有 4 个定时器与 6 个独立的 PWM 通道,而 ESP32-S3 同样拥有 4 个定时器以及 8 个独立的 PWM 通道,具体可以参见下面的示意图:

舵机通常拥有 PWMVCCGND 三路外接引脚,其中 VCC 需要连接到一个独立的 5V 电源(确保工作电流稳定),而舵机的 GND 引脚需要与 UINIO-MCU-ESP32GND 形成共地连接(作为 PWM 信号的电平基准),除此之外的 PWM 则是属于用来输入 PWM 控制信号的引脚:

例如 SG90MG996R 型舵机的黄/橙色红色棕色杜邦线,就分别对应着舵机的 PWMVCCGND 引脚。接下来,通过 UINIO-MCU-ESP32 控制两个 SG90 微型舵机,分别将两个舵机的 PWM 信号线连接至 UINIO-MCU-ESP32GPIO9GPIO10 引脚:

下面的这份示例代码,可以使得两个 SG90 微型舵机分别从 旋转到 180° 度,以及从 180° 旋转到 度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <ESP32Servo.h>

/* 声明 Servo 对象 */
Servo Servo_1;
Servo Servo_2;

/* 需要绑定到 Servo 对象的 GPIO 引脚 */
int Servo_1_Pin = 9;
int Servo_2_Pin = 10;

int Min_Us = 500; // 舵机 0° 度时发出的脉冲宽度(单位为微秒)
int Max_Us = 2500; // 舵机 180° 度时发出的脉冲宽度(单位为微秒)

int Postion = -1; // 声明舵机位置变量
bool UP = true; // 用于判断 Postion 变量是向上还是向下进行计数

void setup() {
ESP32PWM::allocateTimer(1); // 指定使用 1 号定时器生成 PWM 信号

/* 配置舵机 1 的 PWM 控制信号 */
Servo_1.setPeriodHertz(50); // 设置 PWM 信号频率为 50 Hz
Servo_1.attach(Servo_1_Pin, Min_Us, Max_Us); // 把 Servo 对象绑定至指定 GPIO 引脚

/* 配置舵机 2 的 PWM 控制信号 */
Servo_2.setPeriodHertz(50); // 设置 PWM 信号频率为 50 Hz
Servo_2.attach(Servo_2_Pin, Min_Us, Max_Us); // 把 Servo 对象绑定至指定 GPIO 引脚
}

void loop() {
/* 基于当前位置判断 Postion 变量是向上还是向下计数 */
if (Postion == 181) {
UP = false;
} else if (Postion == -1) {
UP = true;
}

/* 根据 UP 变量的计数方向,对 Postion 变量进行加减 1 */
if (UP) {
Postion++; // 如果 Postion 变量是向上计数,那么每次执行角度就加 1
} else {
Postion--; // 如果 Postion 变量是向下计数,那么每次执行角度就减 1
}

Servo_1.write(Postion); // 让舵机 1 从 0° 旋转到 180° 度
Servo_2.write(180 - Postion); // 让舵机 2 从 180° 旋转到 0° 度

delay(15); // 每间隔 15 毫秒执行一次 loop 函数
}

注意:由于 UINIO-MCU-ESP32C3 采用了两线制 SPI 的 DIO 模式,因而在运行上述示例程序的时候,需要将 Arduino IDE 的 【Flash Mode】设置为 DIO 模式,否则会导致舵机程序无法正常工作。除此之外,因为 UINIO-MCU-ESP32C3 的第 GPIO11GPIO12GPIO13 引脚已经被用作 Flash 的 SPI 电源和信号引脚,所以无法用于控制舵机。

由多份源文件组成的草图工程

本节内容将会综合运用之前介绍过的 SG90 舵机和 HC-SR04 超声波模组,基于 UINIO-MCU-ESP32S3 实现一个能够自动打开盒盖的 UINIO-Auto-Box 智能收纳盒子项目,这里假设盒盖关闭时候舵机的角度为 度,而盒盖打开时候舵机的角度为 90° 度。当用手遮挡住超声波探头的时候,舵机旋转 90° 度打开盒盖。而当手离开之后,舵机就会回到 度位置,表示已经自动关闭盒盖。

  • SG90 舵机的 VCC 引脚连接到 UINIO-MCU-ESP32S35V 引脚,而 PWM 引脚连接到 GPIO7 引脚,除此之外两者的 GND 相互连接形成共地关系。
  • HC-SR04 舵机的 VCC 引脚连接到 UINIO-MCU-ESP32S33V3 引脚,而 Trig 引脚连接至 GPIO6Echo 引脚连接至 GPIO5,同样 GND 相互连接形成共地关系。

打开 Arduino IDE 新建一个名为 UINIO-Auto-Box 的草图工程,其主程序会被自动命名为 UINIO-Auto-Box.ino,然后手动添加超声波传感器相关的 Sonar.hSonar.cpp 源文件,盒盖控制相关的 Cover.hCover.cpp 源文件,以及舵机控制相关的 Servo.hServo.cpp 源文件,最后生成的工程结构如下面所示:

1
2
3
4
5
6
7
8
9
10
D:\Workspace\UINIO-Auto-Box
λ ls -l

-rw-r--r-- 1 UinIO.com 1049089 1458 7月 25 17:37 Cover.cpp
-rw-r--r-- 1 UinIO.com 1049089 394 7月 25 17:37 Cover.h
-rw-r--r-- 1 UinIO.com 1049089 492 7月 25 17:37 Servo.cpp
-rw-r--r-- 1 UinIO.com 1049089 183 7月 25 17:37 Servo.h
-rw-r--r-- 1 UinIO.com 1049089 2446 7月 25 17:37 Sonar.cpp
-rw-r--r-- 1 UinIO.com 1049089 275 7月 25 17:37 Sonar.h
-rw-r--r-- 1 UinIO.com 1049089 1459 7月 25 17:37 UINIO-Auto-Box.ino
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/*========== 主程序 ==========*/
#include "Sonar.h"
#include "Cover.h"
#include "Servo.h"

portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; // 定义全局自旋锁变量

/* 打开盒盖的函数 */
void openCover() {
bool send = false; // 用于判断本次是否向舵机发送命令

portENTER_CRITICAL(&mux);
/* 如果 OpenTime 等于 0 表示盒盖没有打开 */
if (OpenTime == 0) {
send = true; // 可以向舵机发送打开盒盖的命令
}
OpenTime = micros(); // 更新当前盒盖打开的时间
portEXIT_CRITICAL(&mux);

/* 判断当前是否需要向舵机发送命令 */
if (send) {
MyServo.write(90); // 向舵机发送打开盒盖的信号
}
}

/* 关闭盒盖的函数 */
void closeCover() {
MyServo.write(0); // 向舵机发送关闭盒盖的信号
}

void setup() {
Serial.begin(115200);

Servo_Init(); // 初始化舵机
Sonar_Init(&mux); // 初始化超声波模组
Cover_Detect_Init(&mux); // 初始化盒盖关闭
closeCover(); // 盒盖初始状态为关闭
}

void loop() {
/* 循环检测打开盒盖的信号 */
if (xSemaphoreTake(Open_Semaphore, 0) == pdTRUE) {
Serial.println("打开盒盖");
openCover(); // 打开盒盖
}

/* 循环检测关闭盒盖的信号 */
if (xSemaphoreTake(Close_Semaphore, 0) == pdTRUE) {
Serial.println("关闭盒盖");
closeCover(); // 关闭盒盖
}
}

Cover.h 与 Cover.cpp

1
2
3
4
5
6
7
8
9
10
11
12
/*========== Cover.h ==========*/
#pragma once

#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <esp32-hal-timer.h>

/* 头文件中使用 extern 关键字将全局变量声明为可供 UINIO-Auto-Box.ino 主程序调用的外部变量 */
extern volatile unsigned long OpenTime;
extern volatile SemaphoreHandle_t Close_Semaphore;

void Cover_Detect_Init(portMUX_TYPE* mux);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/*========== Cover.cpp ==========*/
#include "Cover.h"

hw_timer_t* CoverTimer = NULL; // 关闭盒盖的定时器
static portMUX_TYPE* _mux = NULL; // 用于接收全局自旋锁的变量
volatile SemaphoreHandle_t Close_Semaphore; // 关闭盒盖的信号量
volatile unsigned long OpenTime = 0; // 打开盒盖的时间

/* 盒盖关闭检测 */
void IRAM_ATTR detectCoverClose() {
portENTER_CRITICAL_ISR(_mux);
auto now = micros();

/* 如果前当时间 now 减去打开盒盖的时间大于或等于 4 秒(即障碍物离开超声波传感器已经 4 秒以上),
并且 OpenTime 不等于 0(即盒盖处于开启状态)*/
if (OpenTime != 0 && (now - OpenTime) >= 4000000) {
OpenTime = 0; // 让 OpenTime 变量重新归零
xSemaphoreGiveFromISR(Close_Semaphore, NULL); // 发送关闭盒盖的信号
}
portEXIT_CRITICAL_ISR(_mux);
}

/* 盒盖开关检测的初始化 */
void Cover_Detect_Init(portMUX_TYPE* mux) {
_mux = mux; // 接收全局自旋锁变量,同步临界区资源访问
Close_Semaphore = xSemaphoreCreateBinary(); // 定义二值信号量

/* 定义硬件定时器,每 500 毫秒执行 1 次盒盖关闭检测程序 */
CoverTimer = timerBegin(2, 80, true);
timerAttachInterrupt(CoverTimer, detectCoverClose, true);
timerAlarmWrite(CoverTimer, 500000, true);
timerAlarmEnable(CoverTimer);
}

Servo.h 与 Servo.cpp

1
2
3
4
5
6
/*========== Servo.h ==========*/
#pragma
#include <ESP32Servo.h>

extern Servo MyServo; // 声明舵机对象
void Servo_Init(); // 将舵机初始化函数声明为外部变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*========== Servo.cpp ==========*/
#include "Servo.h"

//舵机部分
Servo MyServo;
int Min_Us = 500; // 单位为微秒
int Max_Us = 2500; // 单位为微秒
int Servo_Pin = 7; // 声明需要绑定到 Servo 对象的 GPIO 引脚

/* 舵机初始化函数 */
void Servo_Init() {
ESP32PWM::allocateTimer(1); // 指定生成 PWM 信号所使用的定时器
MyServo.setPeriodHertz(50); // 指定 PWM 信号的频率为 50Hz
MyServo.attach(Servo_Pin, Min_Us, Max_Us); // 将 MyServo 对象绑定到指定的 GPIO 引脚
}

Sonar.h 与 Sonar.cpp

1
2
3
4
5
6
7
8
/*========== Sonar.h ==========*/
#pragma once
#include <esp32-hal-timer.h>
#include <freertos/semphr.h>
#include <freertos/FreeRTOS.h>

extern volatile SemaphoreHandle_t Open_Semaphore; // 将打开盒盖的信号量声明为外部变量
void Sonar_Init(portMUX_TYPE* mux);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/*========== Sonar.cpp ==========*/
#include "Sonar.h"

volatile SemaphoreHandle_t Open_Semaphore; // 声明打开盒盖的信号量

const int Echo_Pin = 5; // 指定 Echo 引脚
const int Trig_Pin = 6; // 指定 Trig 引脚
int Distance = 0; // 距离变量(厘米)

static portMUX_TYPE* _mux = NULL; // 用于接收全局自旋锁的变量
hw_timer_t* SonarTimer = NULL; // 硬件定时器指针变量
volatile unsigned long StartTime = 0; // 发出超声波的时间
volatile unsigned long EndTime = 0; // 接收到超声波的时间

/* 硬件定时器中断服务程序 */
void IRAM_ATTR Ping() {
digitalWrite(Trig_Pin, HIGH);
delayMicroseconds(15); // 通过延时 15 微秒产生脉冲信号
digitalWrite(Trig_Pin, LOW);
}

/* Echo 引脚的中断服务程序 */
void IRAM_ATTR changeISR() {
auto now = micros(); //当前时间
auto state = digitalRead(Echo_Pin);

portENTER_CRITICAL_ISR(_mux);
/* 如果 state 变量为低电平,表示刚刚发送出超声波 */
if (state) {
StartTime = now;
} else {
EndTime = now;
}

/* 如果 state 变量为低电平,表示已经收到超声波响应 */
if (!state) {
auto time = EndTime - StartTime; // 计算出脉冲宽度时间
auto distance = time * 0.01715; // 计算出实际距离

/* 当检测到的距离小于 10cm 的时候 */
if (distance <= 10) {
xSemaphoreGiveFromISR(Open_Semaphore, NULL); // 向主程序发送打开盒盖的信号
}
}
portEXIT_CRITICAL_ISR(_mux);
}

/* 初始化超声波测距模块 */
void Sonar_Init(portMUX_TYPE* mux) {
_mux = mux; // 接收全局自旋锁变量,同步临界区资源访问
pinMode(Trig_Pin, OUTPUT); // 配置 Trig 引脚的工作模式
pinMode(Echo_Pin, INPUT); // 配置 Echo 引脚的工作模式
Open_Semaphore = xSemaphoreCreateBinary(); // 创建打开盒盖的信号量

/* 测距定时器,每 200 毫秒检测一次距离 */
SonarTimer = timerBegin(0, 80, true);
timerAttachInterrupt(SonarTimer, Ping, true);
timerAlarmWrite(SonarTimer, 200000, true);

attachInterrupt(digitalPinToInterrupt(Echo_Pin), changeISR, CHANGE); // 添加 Echo 引脚的中断服务程序
timerAlarmEnable(SonarTimer); // 开始反复进行周期性的检测
}

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/SCKMOSI/SDOMISO/SDI 三条信号线都分别各自连接到一起:

SPI 总线上的主设备与从设备都分别内置有串行移位寄存器,主设备向该寄存器写入 1 个字节数据,就会进行一次数据传输:

  1. 将指定从设备CS 片选信号线拉低,开始与其进行通信。
  2. 主设备发出 SCLK 时钟信号,开始准备对从设备进行读写操作(时钟信号是高电平还是低电平有效,称为时钟极性)。
  3. 主设备把待发送的数据写入到发送缓冲区,然后通过过串行移位寄存器,将数据从 MOSI 信号线逐位发送给从设备;同时主设备也可以把 MISO 信号线上待接收的从设备数据,同样通过串行移位寄存器逐位移动到接收缓冲区
  4. 从设备也会把自己串行移位寄存器里的内容,通过 MISO 信号线返回给主设备;并且同样也可以通过 MOSI 信号线接收主设备发送过来的数据(数据是在时钟信号的上升沿还是下降沿处理,称为时钟相位)。
  5. 每经过 1 个 SCLK 时钟脉冲,SPI 总线上就可以接收或者发送 1bit 数据。

在上述 SPI 通信过程当中,时钟极性时钟相位是非常重要的两个概念:

  • 时钟极性 CPOLClock Polarity):表示 SPI 总线空闲时,时钟线 SCLK 处于高电平还是低电平;如果 CPOL = 0,那么时钟信号在总线空闲时处于低电平;如果 CPOL = 1,那么时钟信号在总线空闲时则处于高电平
  • 时钟相位 CPHAClock Phase):表示处理 SPI 总线数据采样的时间点,如果 CPHA = 0,那么在时钟信号 SCLK 的第 1 个跳变沿采样,第 2 个跳变沿被改变;如果 CPHA = 0,那么在时钟信号 SCLK 的第 1 个跳变沿被改变,第 2 个跳变沿采样

注意:上图当中的红色竖线代表数据采样(Sampled)的位置,而蓝色代表数据被改变(Launched)的位置。

根据 SPI 总线的时钟极性时钟相位,可以划分出四种不同的 SPI 总线通信工作模式,它们分别定义了在时钟信号的哪个边沿采样信号,哪个边沿改变信号

模式 时钟极性与相位
Mode 0 CPOL = 0CPHA = 0
Mode 1 CPOL = 0CPHA = 1
Mode 2 CPOL = 1CPHA = 0
Mode 3 CPOL = 1CPHA = 1

除此之外,在 SPI 串行通信过程当中,当前是最高有效位MSB,Most Significant Bit)优先传输,还是最低有效位LSB,Least Significant Bit)优先传输是非常重要的两个关键因素,收发双方必须保持传输时序的一致:

  • 最低有效位 (LSB) 优先 :传输一个字节的时候从低位先进行传输;
  • 最高有效位 (MSB) 优先:传输一个字节的时候从高位先进行传输;

注意:SPI 通信涉及的所有 API 函数都不能放置到中断服务程序当中,否则将会导致程序报错。

ESP32C3 & ESP32S3 的 SPI 外设

由于乐鑫早期的 ESP32 芯片(例如 ESP32-D0WD-V3ESP32-D2WDESP32-S0WDESP32-U4WDH),分别使用了 HSPIVSPI 来指代 SPI2SPI3 外设:

官方的 Arduino-ESP32 库出于兼容性考虑延续了这种叫法,它们默认的 GPIO 引脚编号,如下面的表格所示:

分类 主机输入从机输出引脚 主机输出从机输入引脚 时钟引脚 片选引脚
VSPI MISO = 19 MOSI = 23 SCLK = 18 CS = 5
HSPI MISO = 12 MOSI = 13 SCLK = 14 CS = 15

ESP32-C3 芯片集成有 SPI0SPI1SPI2 三个 SPI 总线控制器,因为 SPI0SPI1 主要用于访问外部 Flash 以及 PSRAM,所以仅有 SPI2 可以供用户配置使用(即 GP-SPI2)。

ESP32-S3 芯片集成有 SPI0SPI1SPI2SPI3 四个 SPI 总线控制器,同样因为 SPI0SPI1 被用于访问外部 Flash 以及 PSRAM,所以仅有 SPI2SPI3 可以供用户配置使用(即 GP-SPI2GP-SPI3)。

观察上述 ESP32-C3ESP32-S3 的 SPI 系统框图可以发现,两者都将 GP-SPI2 称为 FSPI(Fast SPI),因而在随后的主设备 SPI 官方库示例代码当中,宏定义里才会出现 #define VSPI FSPI 这样的语句。

注意ESP32-C3ESP32-S3 工作在主设备模式下的时钟频率都可以达到 80 MHz,而工作在从设备模式下的时钟频率也可以达到 60 MHz

主设备 SPI 官方库

Arduino-ESP32 封装的 SPI 库已经提供了主设备 SPI 总线通信的支持,使用时只需要包含 <SPI.h> 头文件即可,相关的方法都已经被封装至 SPIClass 类:

1
2
SPIClass *vspi = new SPIClass(VSPI);
SPIClass *hspi = new SPIClass(HSPI);

Arduino-ESP32 内部已经定义有一个 SPIClass SPI = new SPIClass(VSPI),可以在代码当中直接使用 SPI 对象控制总线通信,下面的伪代码展示了 SPI 主设备通信的基本过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <SPI.h>

SPIClass *SPI = new SPIClass(VSPI); // 创建 SPI 对象
SPI->begin(); // 使用默认引脚初始化 SPI 总线
pinMode(SPI->pinSS(),OUTPUT); // 把片选引脚设置为数字输出模式

/* 设置 SPI 总线工作参数,时钟频率、高低位优先、时钟的极性与相位,然后开始 SPI 数据传输 */
SPI->beginTransaction(SPISettings(spiClk,MSBFIRST,SPI_MODE0));
digitalWrite(SPI->pinSS(), LOW); // 拉低片选信号
SPI->transfer(data); // 开始传输数据
digitalwrite(spi->pinSS(), HIGH); // 拉高片选信号
/* 结束 SPI 数据传输 */
SPI->endTransaction();

SPI->end(); // 释放当前 SPI 总线的资源占用

下面的代码详细展示了 UINIO-MCU-ESP32S3 使用 Arduino-ESP32 库进行 SPI 主设备通信的整个步骤,由于 UINIO-MCU-ESP32C3 只存在一个 HSPI 可以供用户配置使用,运行下面代码会导致 'VSPI' was not declared in this scope 错误的出现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <SPI.h>
#define ALTERNATE_PINS // 预处理命令,用于判断是否启用自定义引脚

/* 如果预定义有 ALTERNATE_PINS,就使用自定义引脚 */
#ifdef ALTERNATE_PINS
#define VSPI_MISO 2
#define VSPI_MOSI 4
#define VSPI_SCLK 0
#define VSPI_SS 33

#define HSPI_MISO 26
#define HSPI_MOSI 27
#define HSPI_SCLK 25
#define HSPI_SS 32
/* 否则就使用 VSPI 和 HSPI 对应的默认引脚 */
#else
#define VSPI_MISO MISO
#define VSPI_MOSI MOSI
#define VSPI_SCLK SCK
#define VSPI_SS SS

#define HSPI_MISO 12
#define HSPI_MOSI 13
#define HSPI_SCLK 14
#define HSPI_SS 15
#endif

/* 自动判断 IDF 编译的目标芯片是 ESP32-S2 还是 ESP32-S3 */
#if CONFIG_IDF_TARGET_ESP32S2 || CONFIG_IDF_TARGET_ESP32S3
#define VSPI FSPI
#endif

static const int spiClk = 1000000; // 定义 SPI 总线的时钟频率为 1 MHz

/* 声明 SPIClass 类型的初始化指针 */
SPIClass *vspi = NULL;
SPIClass *hspi = NULL;

void setup() {
/* 分别使用 HSPI 和 VSPI 初始化 SPIClass 实例 */
vspi = new SPIClass(VSPI);
hspi = new SPIClass(HSPI);

#ifndef ALTERNATE_PINS
vspi->begin(); // 初始化 VSPI,默认引脚 SCLK = 18, MISO = 19, MOSI = 23, SS = 5
#else
vspi->begin(VSPI_SCLK, VSPI_MISO, VSPI_MOSI, VSPI_SS); // 使用自定义引脚初始化 VSPI
#endif

#ifndef ALTERNATE_PINS
hspi->begin(); // 初始化 HSPI,默认引脚 SCLK = 14, MISO = 12, MOSI = 13, SS = 15
#else
hspi->begin(HSPI_SCLK, HSPI_MISO, HSPI_MOSI, HSPI_SS); // 使用自定义引脚初始化 HSPI
#endif

/* 设置 VSPI 和 HSPI 的片选引脚为数字输出 */
pinMode(vspi->pinSS(), OUTPUT);
pinMode(hspi->pinSS(), OUTPUT);
}

void loop() {
/* 通过 VSPI 和 HSPI 发送测试数据 */
spiCommand(vspi, 0b01010101);
spiCommand(hspi, 0b11001100);
delay(100);
}

void spiCommand(SPIClass *spi, byte data) {
//use it as you would the regular arduino SPI API
spi->beginTransaction(SPISettings(spiClk, MSBFIRST, SPI_MODE0));
digitalWrite(spi->pinSS(), LOW); // 拉低片选引脚,准备传输数据
spi->transfer(data); // 进行数据传输
digitalWrite(spi->pinSS(), HIGH); // 拉高片选引脚,结束数据传输
spi->endTransaction();
}

接下来介绍一下 Arduino-ESP32 当中 SPI 内置的相关方法,首先 SPISettings 类用于配置 SPI 总线通信端口的相关参数(默认的时钟频率 clock1MHz传输顺序 bitOrder高位优先时钟的极性与相位模式 dataModeMODE0):

SPISettings 构造函数 功能描述
SPISettings(uint32_t clock, uint8_t bitOrder, uint8_t dataMode) SPI 总线配置参数的载体,三个参数的默认值分别为 1000000SPI_MSBFIRSTSPI_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-V3ESP32-D2WDESP32-S0WDESP32-U4WDH 等较老型号的 ESP32 系列,暂不支持相对较新的 ESP32-C3ESP32-S3 芯片。如果在 Arduino IDE 当中选择以这两款芯片作为主控的开发板,那么就会导致编译错误的出现。所以在接下来的示例当中,都会以乐鑫官方采用 ESP32-D0WD 主控的 ESP32-DevKitC 开发板作为 SPI 总线从设备:

ESP32SPISlave 库可以支持以阻塞式等待的方式访问 SPI 传输事务队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <ESP32SPISlave.h>

ESP32SPISlave slave; // 定义 SPI 总线从设备通信对象

static constexpr uint32_t BUFFER_SIZE{ 32 }; // 定义缓冲区大小
uint8_t spi_slave_tx_buf[BUFFER_SIZE]; // 定义发送缓冲区
uint8_t spi_slave_rx_buf[BUFFER_SIZE]; // 定义接收缓冲区

void setup() {
slave.setDataMode(SPI_MODE0); // 设置 SPI 总线工作模式,即 SPI 时钟的极性与相位

/* 指定 SPI 引脚,可以选择默认的 HSPI 和 VSPI,也可以进行自定义
HSPI = CS: 15, CLK: 14, MOSI: 13, MISO: 12 -> default
VSPI = CS: 5, CLK: 18, MOSI: 23, MISO: 19 */
slave.begin(HSPI);
}

void loop() {
/* 阻塞等待,直至接收到主设备的传输事务 */
slave.wait(spi_slave_rx_buf, spi_slave_tx_buf, BUFFER_SIZE);

/* 如果主设备的传输事务已经结束,那么从设备 available() 就会返回传输的结果数量,并且自动更新 spi_slave_rx_buf 接收缓冲区 */
while (slave.available()) {
slave.pop(); // 操作数据接收缓冲区
}
}

相对应的,也能够支持以轮询的方式访问 SPI 传输事务队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <ESP32SPISlave.h>

ESP32SPISlave slave; // 定义 SPI 总线从设备通信对象

static constexpr uint32_t BUFFER_SIZE{ 32 }; // 定义缓冲区大小
uint8_t spi_slave_tx_buf[BUFFER_SIZE]; // 定义发送缓冲区
uint8_t spi_slave_rx_buf[BUFFER_SIZE]; // 定义接收缓冲区

void setup() {
slave.setDataMode(SPI_MODE0); // 设置 SPI 总线工作模式,即 SPI 时钟的极性与相位

/* 指定 SPI 引脚,可以选择默认的 HSPI 和 VSPI,也可以进行自定义
HSPI = CS: 15, CLK: 14, MOSI: 13, MISO: 12 -> default
VSPI = CS: 5, CLK: 18, MOSI: 23, MISO: 19 */
slave.begin(VSPI);
}

void loop() {
/* 如果当前队列当中没有剩余的传输事务 */
if (slave.remained() == 0) {
slave.queue(spi_slave_rx_buf, spi_slave_tx_buf, BUFFER_SIZE); // 那么就向队列新添加 1 个事务
}

/* 如果主设备的传输事务已经结束,那么从设备 available() 就会返回传输的结果数量,并且自动更新 spi_slave_rx_buf 接收缓冲区 */
while (slave.available()) {
slave.pop(); // 操作数据接收缓冲区
}
}

或者是以任务的方式访问 SPI 传输事务队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <ESP32SPISlave.h>

ESP32SPISlave slave; // 定义 SPI 总线从设备通信对象

static constexpr uint32_t BUFFER_SIZE{ 32 }; // 定义缓冲区大小
uint8_t spi_slave_tx_buf[BUFFER_SIZE]; // 定义发送缓冲区
uint8_t spi_slave_rx_buf[BUFFER_SIZE]; // 定义接收缓冲区

constexpr uint8_t CORE_TASK_SPI_SLAVE{ 0 };
constexpr uint8_t CORE_TASK_PROCESS_BUFFER{ 0 };

static TaskHandle_t task_handle_wait_spi = 0;
static TaskHandle_t task_handle_process_buffer = 0;

void task_wait_spi(void* pvParameters) {
while (1) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
slave.wait(spi_slave_rx_buf, spi_slave_tx_buf, BUFFER_SIZE); // 阻塞等待,直至接收到主设备的传输事务
xTaskNotifyGive(task_handle_process_buffer);
}
}

void task_process_buffer(void* pvParameters) {
while (1) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
slave.pop(); // 操作数据接收缓冲区
xTaskNotifyGive(task_handle_wait_spi);
}
}

void setup() {
slave.setDataMode(SPI_MODE0); // 设置 SPI 总线工作模式,即 SPI 时钟的极性与相位
/* 指定 SPI 引脚,可以选择默认的 HSPI 和 VSPI,也可以进行自定义
HSPI = CS: 15, CLK: 14, MOSI: 13, MISO: 12 -> default
VSPI = CS: 5, CLK: 18, MOSI: 23, MISO: 19 */
slave.begin(HSPI);

xTaskCreatePinnedToCore(task_wait_spi, "task_wait_spi", 2048, NULL, 2, &task_handle_wait_spi, CORE_TASK_SPI_SLAVE);
xTaskNotifyGive(task_handle_wait_spi);
xTaskCreatePinnedToCore(task_process_buffer, "task_process_buffer", 2048, NULL, 2, &task_handle_process_buffer, CORE_TASK_PROCESS_BUFFER);
}

void loop() {}

接下来的一系列表格当中,展示了 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-ESP32S3SCLK = 21MISO = 20MOSI = 19SS = 18ESP32-DevKitCSCLK = 14MISO = 12MOSI = 13SS = 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/*========== SPIMaster.ino(主设备) ==========*/

#include <SPI.h>
#define SPI_CLK 5000000 // 时钟信号频率,从设备时钟频率最高只能达到 10MHz

auto Hspi = SPIClass(HSPI); // 定义 SPIClass 对象
int count = 0;
uint8_t txBuffer[64]; // 声明发送缓冲区,非 DMA 方式最大传输 64 字节
uint8_t rxBuffer[64]; // 声明接收缓冲区,非 DMA 方式最大传输 64 字节

void setup() {
Serial.begin(115200);
Hspi.begin(21, 20, 19, 18); // 使用 GPIO21、GPIO20、GPIO19、GPIO18 初始化 HSPI 控制器
pinMode(Hspi.pinSS(), OUTPUT); // 将 HSPI 的片选信号线设置为数字输出
}

/* 每 1 次循环就是 1 次 SPI 传输事务 */
void loop() {
char temporary = 97 + count++ % 26; // 定义一个字符串,ASCII 码 97 代表小写字母 a(每次都会依次从 a 循环发送至 z)
Serial.println(temporary);

memset(txBuffer, temporary, 56); // 写入 56 个小写字母到从设备的发送缓冲区
txBuffer[56] = '~'; // 在 56 个小写字母的最后再加上 1 条波浪线
memset(rxBuffer, 0, 64); // 把接收缓冲区清零

/* 开始传输事务 */
Hspi.beginTransaction(SPISettings(SPI_CLK, MSBFIRST, SPI_MODE0)); // 配置 SPI 总线参数
digitalWrite(Hspi.pinSS(), LOW); // 拉低片选信号线
Hspi.transferBytes(txBuffer, rxBuffer, 57); // 主设备向从设备发送 57 字节的数据
digitalWrite(Hspi.pinSS(), HIGH); // 拉高片选信号线
Hspi.endTransaction();
/* 结束传输事务 */

/* 发送数据的同时,也在接收从设备发回的数据 */
for (int i = 0; i < 57; i++) {
Serial.print((char)rxBuffer[i]); // 向串口打印接收到的从设备字符数据
}

Serial.println();
delay(1000); // 延时 1 秒钟之后再重复上述过程
}

SPISlaveWait.ino

采用 ESP32-DevKitC 作为 SPI 从设备,基于 ESP32SPISlave 库提供的 wait() 方法,以阻塞等待的方式与主设备进行通信:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*========== SPISlaveWait.ino() ==========*/
#include <ESP32SPISlave.h>

ESP32SPISlave Slave; // 声明从设备对象

/* 定义接收与发送的缓冲区最大空间为 64 字节 */
static constexpr uint32_t BUFFER_SIZE{ 64 };
uint8_t txBuffer[BUFFER_SIZE];
uint8_t rxBuffer[BUFFER_SIZE];

void setup() {
Serial.begin(115200);
Slave.setDataMode(SPI_MODE0); // 设置 SPI 总线工作模式(时钟极性与相位)
Slave.begin(HSPI); // 初始化 SPI 总线资源
}

void starWait() {
Slave.wait(rxBuffer, txBuffer, BUFFER_SIZE); // 以阻塞等待的方式处理 SPI 数据
char temporary = NULL;

/* 判断传输结果数量 */
while (Slave.available()) {
int length = Slave.size(); // 获取主设备发送过来的数据长度
Serial.println(length);

for (int i = 0; i < length; i++) {
temporary = rxBuffer[i];
txBuffer[i] = temporary - 32; // 把字符的 ASIIC 编码减去 32,从而将其变为大写形式
Serial.print(temporary);
}
Serial.println();
Slave.pop(); // 处理完缓冲区数据之后,必须将本次 SPI 传输事务弹出
}
}

void loop() {
starWait();
}

SPISlaveQueue.ino

采用 ESP32-DevKitC 作为 SPI 从设备,基于 ESP32SPISlave 库提供的 queue() 方法与主设备进行通信,由于队列方式只能同时处理 3 个 SPI 传输任务。所以从设备需要初始化出 3 个传输任务,然后逐一用于处理数据的收发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/*========== SPISlaveQueue.ino ==========*/
#include <ESP32SPISlave.h>

ESP32SPISlave Slave; // 定义从设备对象

/* ESP32SPISlave 的队列方式只能同时处理 3 个 SPI 传输任务 */
static constexpr uint32_t BUFFER_SIZE{ 64 };

/* 第 1 个 SPI 传输任务的收发缓冲区 */
uint8_t rxBuffer0[BUFFER_SIZE];
uint8_t txBuffer0[BUFFER_SIZE];

/* 第 2 个 SPI 传输任务的收发缓冲区 */
uint8_t rxBuffer1[BUFFER_SIZE];
uint8_t txBuffer1[BUFFER_SIZE];

/* 第 3 个 SPI 传输任务的收发缓冲区 */
uint8_t rxBuffer2[BUFFER_SIZE];
uint8_t txBuffer2[BUFFER_SIZE];

void setup() {
Serial.begin(115200);
Slave.setDataMode(SPI_MODE0);
Slave.begin(HSPI);

/* 同时启动 3 个 SPI 总线传输任务 */
Slave.queue(rxBuffer0, txBuffer0, BUFFER_SIZE);
Slave.queue(rxBuffer1, txBuffer1, BUFFER_SIZE);
Slave.queue(rxBuffer2, txBuffer2, BUFFER_SIZE);
}

/* 处理接收缓冲区的数据,然后写入到发送缓冲区 */
void handleBuffer(uint8_t* rx_buffer, uint8_t* tx_buffer, uint32_t size) {
char temporary = NULL;

/* 循环将接收缓冲区当中的 ASCII 字符编码减去 32 转换为大写,然后再写入至发送缓冲区 */
for (int i = 0; i < size; i++) {
temporary = rx_buffer[i];
tx_buffer[i] = temporary - 32;
Serial.print(temporary);
}
Serial.println();
}

void starQueue() {
static int index = 0; // 处理 SPI 传输任务的缓冲区序号

/* 判断当前是否存在传输数据 */
while (Slave.available()) {
int length = Slave.size(); // 获取主设备发送过来的数据长度
Serial.println(length);

/* 判断缓冲区队列顺序 */
switch (index) {
/* 处理第 1 个缓冲区 */
case 0:
handleBuffer(rxBuffer0, txBuffer0, length);
Slave.queue(rxBuffer0, txBuffer0, BUFFER_SIZE); // 将该传输事务添加到队列
break;
/* 处理第 2 个缓冲区 */
case 1:
handleBuffer(rxBuffer1, txBuffer1, length);
Slave.queue(rxBuffer1, txBuffer1, BUFFER_SIZE); // 将该传输事务添加到队列
break;
/* 处理第 3 个缓冲区 */
case 2:
handleBuffer(rxBuffer2, txBuffer2, length);
Slave.queue(rxBuffer2, txBuffer2, BUFFER_SIZE); // 将该传输事务添加到队列
break;
}

index = (index + 1) % 3; // 采用取余的方式让 index 从 0 到 2 不断循环
Slave.pop(); // 把本次的 SPI 传输任务弹出处理队列
}
}

void loop() {
starQueue();
}

基于 SPI 操作 SD 存储卡

本节内容介绍的这款 SD 存储卡模组,可以使得 UINIO-MCU-ESP32 通过 SPI 接口以及文件系统读写 SD 存储卡(同时支持普通 Micro SD 和高速 Micro SDHC 存储卡)。该款模组还板载有电平转换芯片,可以同时兼容 5V3.3V 规格的电平信号。而自带的 3.3V 线性稳压器,也可以使其分别工作于 5V3.3V 电源下。

这款 SD 存储卡模组 一共拥有六个外接引脚,它们分别是 GNDVCCMISOMOSISCKCS,具体的引脚排列顺序可以参考下图:

Arduino-ESP32 提供的 SD 库

笔者目前使用 Arduino-ESP32 库的 2.0.11 版本,已经基于 SPI 总线通信,提供了对于 SD 卡操作的支持(可以支持中文文件名,以及 UTF-8 编码的文件内容),使用时只需要在 Arduino 草图源文件当中包含 SPI.hSD.h 头文件即可。由于截止到 2023 年 8 月为止,该库依然还处于开发状态,官方并未提供详尽的 API 文档说明,只是提供了一份比较典型的 SD 卡读写示例代码。接下来就基于这份代码,以自定义 SPI 通信引脚的方式,读写一颗文件系统为 FAT32,存储容量为 32GB 的 TF 存储卡:

首先把 UINIO-MCU-ESP32 的引脚 SS = GPIO0SCLK = GPIO1MOSI = GPIO2MISO = GPIO3 分别与读卡器模块的 CSSCKMOSIMISO 引脚相互连接,然后再将读卡器模组的 VCCGND 分别接入至 UINIO-MCU-ESP325VGND 电源,最后就可以下载并且运行这份参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
#include <FS.h>
#include <SD.h>
#include <SPI.h>

/*========== 列出目录 ==========*/
void listDir(fs::FS &fs, const char *dirname, uint8_t levels) {
Serial.printf("列出目录: %s\n", dirname);

File root = fs.open(dirname);
if (!root) {
Serial.println("打开目录发生错误!");
return;
}
if (!root.isDirectory()) {
Serial.println("这不是一个目录!");
return;
}

File file = root.openNextFile();
while (file) {
if (file.isDirectory()) {
Serial.print(" 目录 : ");
Serial.println(file.name());
if (levels) {
listDir(fs, file.path(), levels - 1);
}
} else {
Serial.print(" 文件: ");
Serial.print(file.name());
Serial.print(" 尺寸: ");
Serial.println(file.size());
}
file = root.openNextFile();
}
}

/*========== 建立目录 ==========*/
void createDir(fs::FS &fs, const char *path) {
Serial.printf("新建目录: %s\n", path);
if (fs.mkdir(path)) {
Serial.println("目录建立成功!");
} else {
Serial.println("目录建立错误!");
}
}

/*========== 移除目录 ==========*/
void removeDir(fs::FS &fs, const char *path) {
Serial.printf("移除目录: %s\n", path);
if (fs.rmdir(path)) {
Serial.println("目录移除成功!");
} else {
Serial.println("目录移除错误!");
}
}

/*========== 读取目录 ==========*/
void readFile(fs::FS &fs, const char *path) {
Serial.printf("正在读取文件: %s\n", path);

File file = fs.open(path);
if (!file) {
Serial.println("打开文件读取失败");
return;
}

Serial.print("读取到的文件内容: ");
while (file.available()) {
Serial.write(file.read());
}
file.close();
}

/*========== 写入目录 ==========*/
void writeFile(fs::FS &fs, const char *path, const char *message) {
Serial.printf("写入文件: %s\n", path);

File file = fs.open(path, FILE_WRITE);
if (!file) {
Serial.println("打开文件写入失败!");
return;
}
if (file.print(message)) {
Serial.println("文件写入成功!");
} else {
Serial.println("文件写入失败!");
}
file.close();
}

/*========== 向文件末尾追加内容 ==========*/
void appendFile(fs::FS &fs, const char *path, const char *message) {
Serial.printf("追加到文件: %s\n", path);

File file = fs.open(path, FILE_APPEND);
if (!file) {
Serial.println("打开文件追加失败!");
return;
}
if (file.print(message)) {
Serial.println("内容追加成功!");
} else {
Serial.println("内容追加失败!");
}
file.close();
}

/*========== 重命名文件 ==========*/
void renameFile(fs::FS &fs, const char *path1, const char *path2) {
Serial.printf("重新命名文件 %s 为 %s\n", path1, path2);
if (fs.rename(path1, path2)) {
Serial.println("文件重命名成功!");
} else {
Serial.println("文件重命名失败!");
}
}

/*========== 删除文件 ==========*/
void deleteFile(fs::FS &fs, const char *path) {
Serial.printf("删除文件: %s\n", path);
if (fs.remove(path)) {
Serial.println("文件删除成功!");
} else {
Serial.println("文件删除失败!");
}
}

/*========== 测试文件输入输出 ==========*/
void testFileIO(fs::FS &fs, const char *path) {
File file = fs.open(path);
static uint8_t buf[512];
size_t len = 0;
uint32_t start = millis();
uint32_t end = start;
if (file) {
len = file.size();
size_t flen = len;
start = millis();
while (len) {
size_t toRead = len;
if (toRead > 512) {
toRead = 512;
}
file.read(buf, toRead);
len -= toRead;
}
end = millis() - start;
Serial.printf("读取 %u 字节花费了 %u 毫秒\n", flen, end);
file.close();
} else {
Serial.println("打开文件读取失败!");
}


file = fs.open(path, FILE_WRITE);
if (!file) {
Serial.println("打开文件写入失败!");
return;
}

size_t i;
start = millis();
for (i = 0; i < 2048; i++) {
file.write(buf, 512);
}
end = millis() - start;
Serial.printf("写入 %u 字节花费了 %u 毫秒\n", 2048 * 512, end);
file.close();
}

void setup() {
Serial.begin(115200);

SPIClass *hspi = new SPIClass(HSPI); // 初始化 HSPI 总线
hspi->begin(1, 3, 2, 0); // 指定 SPI 通信引脚(SCLK, MISO, MOSI, SS)

/* SD.begin(指定 GPIO0 为片选引脚,使用 HSPI 控制器,频率为 4 MHz,SD 卡挂载点,最大文件数量,如果为空是否进行格式化) */
if (!SD.begin(0, *hspi, 4000000, "/SD", 5, false)) {
Serial.println("存储卡挂载失败!");
return;
}
uint8_t cardType = SD.cardType();

if (cardType == CARD_NONE) {
Serial.println("未检测到 SD 卡!");
return;
}

Serial.print("SD 卡类型: ");
if (cardType == CARD_MMC) {
Serial.println("MMC 存储卡");
} else if (cardType == CARD_SD) {
Serial.println("SDSC 存储卡");
} else if (cardType == CARD_SDHC) {
Serial.println("SDHC 存储卡");
} else {
Serial.println("未知类型卡");
}

uint64_t cardSize = SD.cardSize() / (1024 * 1024);
Serial.printf("SD 卡容量: %lluMB\n", cardSize);

listDir(SD, "/", 0);
createDir(SD, "/成都");
listDir(SD, "/", 0);
removeDir(SD, "/成都");
listDir(SD, "/", 2);

writeFile(SD, "/网站.txt", "您好,");
appendFile(SD, "/网站.txt", "电子技术博客 UinIO.com!\n");
readFile(SD, "/网站.txt");

deleteFile(SD, "/UinIO.txt");
renameFile(SD, "/网站.txt", "/UinIO.txt");
readFile(SD, "/UinIO.txt");
testFileIO(SD, "/成都.txt");

Serial.printf("全部容量: %lluMB\n", SD.totalBytes() / (1024 * 1024));
Serial.printf("已经使用的容量: %lluMB\n", SD.usedBytes() / (1024 * 1024));
}

void loop() {}

上述代码下载执行之后,测试用的 SD 卡上面会生成一个内容为 您好,电子技术博客 UinIO.com! 的文件 UinIO.txt,以及通过文件输入输出写入了内容为空的 成都.txt 文件,同时会以 115200 波特率向串口打印如下一系列执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
SD 卡类型: SDHC 存储卡
SD 卡容量: 29822MB

列出目录: /
目录 : System Volume Information
文件: 成都.txt 尺寸: 1048576
文件: UinIO.txt 尺寸: 41
新建目录: /成都
目录建立成功!

列出目录: /
目录 : System Volume Information
文件: 成都.txt 尺寸: 1048576
文件: UinIO.txt 尺寸: 41
目录 : 成都
移除目录: /成都
目录移除成功!

列出目录: /
目录 : System Volume Information
列出目录: /System Volume Information
文件: IndexerVolumeGuid 尺寸: 76
文件: WPSettings.dat 尺寸: 12
文件: 成都.txt 尺寸: 1048576
文件: UinIO.txt 尺寸: 41

写入文件: /网站.txt
文件写入成功!
追加到文件: /网站.txt
内容追加成功!
正在读取文件: /网站.txt
读取到的文件内容: 您好,电子技术博客 UinIO.com!
删除文件: /UinIO.txt
文件删除成功!
重新命名文件 /网站.txt 为 /UinIO.txt
文件重命名成功!
正在读取文件: /UinIO.txt
读取到的文件内容: 您好,电子技术博客 UinIO.com!
读取 1048576 字节花费了 2383 毫秒
写入 1048576 字节花费了 2515 毫秒
全部容量: 29802MB
已经使用的容量: 1MB

注意:如果串口打印出的调试内容提示 存储卡挂载失败!,那么可以将这片 SD 卡拔出之后重新插入,然后按下 UINIO-MCU-ESP32 核心板上面的 RESET 按钮,重新执行上述程序。

第三方提供的 SdFat 库

因为 ESP32-Arduino 官方库的 SD 卡相关 API 暂时还不够完善,所以本节内容将会介绍功能更加丰富的 SdFat 库,可以同时支持 SDSDHCSDXC 类型的存储卡,以及 FAT16FAT32exFAT 文件系统。该库的 API 文档可以访问 SdFat 源文件\doc 目录下,压缩文件 html.zip 当中的 index.html。总体上来看,SdFat 库是通过 SdFat32SdExFatSdFs 三个类来分别代表不同的存储卡文件系统:

  1. SdFs 类:用于支持 FAT16FAT32 以及 exFAT 文件系统,对应的文件类为 FsFile
  2. SdFat32 类:用于支持 FAT16FAT32 文件系统,对应的文件类为 File32
  3. 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-ESP32SS = GPIO0SCLK = GPIO1MOSI = GPIO2MISO = GPIO3 与读卡器模块的 CSSCKMOSIMISO 引脚连接,而读卡器模块的 VCCGND 则分别接入 UINIO-MCU-ESP325VGND 进行供电,最后就可以编写并且执行如下的参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include "SdFat.h"

#define SD_FAT_TYPE 0 // 0 表示遵循 SdFatConfig.h 里的配置、1 表示 FAT16/FAT32、2 表示 exFAT、3 表示 FAT16/FAT32 和 exFAT

/* 定义 SPI 通信引脚的编号为常量 */
const uint8_t SOFT_SS = 0;
const uint8_t SOFT_SCLK = 1;
const uint8_t SOFT_MOSI = 2;
const uint8_t SOFT_MISO = 3;

SoftSpiDriver<SOFT_MISO, SOFT_MOSI, SOFT_SCLK> softSpi;

/* 软件 SPI 总线,可以忽略 Speed 参数 */
#if ENABLE_DEDICATED_SPI
#define SD_CONFIG SdSpiConfig(SOFT_SS, DEDICATED_SPI, SD_SCK_MHZ(0), &softSpi)
#else
#define SD_CONFIG SdSpiConfig(SOFT_SS, SHARED_SPI, SD_SCK_MHZ(0), &softSpi)
#endif

#if SD_FAT_TYPE == 0
SdFat sd;
File file;
#elif SD_FAT_TYPE == 1
SdFat32 sd;
File32 file;
#elif SD_FAT_TYPE == 2
SdExFat sd;
ExFile file;
#elif SD_FAT_TYPE == 3
SdFs sd;
FsFile file;
#else
#error Invalid SD_FAT_TYPE
#endif

void setup() {
Serial.begin(115200);

/* 等待 USB 串行接口就绪 */
while (!Serial) {
yield();
}
Serial.println("请输入任意字符开始测试:");

/* 等待控制台输入 */
while (!Serial.available()) {
yield();
}

/* 使用上面的宏定义 SD_CONFIG 配置 SPI 总线 */
if (!sd.begin(SD_CONFIG)) {
sd.initErrorHalt();
}

/* 在 SD 存储卡上面创建并且打开一个 电子技术博客.txt 文件 */
if (!file.open("电子技术博客.txt", O_RDWR | O_CREAT)) {
sd.errorHalt(F("文件创建或者打开失败!"));
}
file.println(F("欢迎访问 UinIO.com,获取技术分享文章,以及更多有趣的开源项目。")); // 向上面创建的 .txt 文件写入内容

file.rewind(); // 将文件当前的操作位置初始为零

while (file.available()) {
Serial.write(file.read()); // 读取并且打印 .txt 当中的内容到串口
}

file.close();

Serial.println(F("SD 存储卡写入完成!"));
}

void loop() {}

UINIO-MCU-ESP32 开始运行上述代码之后,可以打开一个第三方的串口上位机程序(例如 VOFA+ 或者 COMTransmit),首先将其波特率设置为 115200,然后手动向串口上位机的【发送窗口】输入 test 并且按下发送,此时 UINIO-MCU-ESP32 就会自动向 SD 存储卡写入内容:欢迎访问 UinIO.com,获取技术分享文章,以及更多有趣的开源项目。,同时串口上位机的【接收窗口】会打印出如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ESP-ROM:esp32s3-20210327
Build:Mar 27 2021
rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)
SPIWP:0xee
mode:DIO, clock div:1
load:0x3fce3808,len:0x44c
load:0x403c9700,len:0xbe4
load:0x403cc700,len:0x2a68
entry 0x403c98d4

请输入任意字符开始测试:
test

欢迎访问 UinIO.com,获取技术分享文章,以及更多有趣的开源项目。
SD 存储卡写入完成!

注意DSPIDual SPI)常用于 SPI 总线通信的 Flash 存储器,由于 Flash 存储器无需使用全双工通信,所以 DSPIMOSIMISO 都作为并行数据传输线,从而工作在半双工模式下,可以达到在单个时钟周期内,双倍提升数据传输速率的目的。

借用 FreeRTOS 的多任务与互斥量

FreeRTOS 是一款适用于微控制器和小型微处理器的嵌入式实时操作系统(RTOS,Real-time Operating System),其提供了任务通知队列流缓冲区消息缓冲区信号量/互斥锁软件定时器事件组等丰富特性,可以协助开发人员在资源受限的嵌入式场景下,实现稳定可靠的实时任务调度与协作。当 Arduino IDE 成功安装 Arduino-ESP32 之后,就会在如下目录里发现 ESP32-C3ESP32-S3 源码实现都内嵌有 FreeRTOS

  • ESP32C3 内嵌的 FreeRTOSC:\Users\Hank\AppData\Local\Arduino15\packages\esp32\hardware\esp32\2.0.11\tools\sdk\esp32c3\include\freertos
  • ESP32S3 内嵌的 FreeRTOSC:\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.hArduino15\packages\esp32\hardware\esp32\2.0.11\tools\sdk\esp32c3\include\freertos\include\esp_additions\freertos\FreeRTOSConfig.h
  • ESP32S3 的 FreeRTOSConfig.hArduino15\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 0Core 1,除此之外,由于 Arduino-ESP32 库在底层实现上,使用了嵌入式实时操作系统 FreeRTOS,所以在 Arduino 草图代码也可以通过包含如下两个头文件,以使用其提供的多任务处理函数:

1
2
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

Arduino-ESP32 当中可以通过 xTaskCreatePinnedToCore() 函数创建一个任务,如果任务创建成功,则会返回 pdPASS,否则表示创建失败:

1
2
3
4
5
6
7
8
9
BaseType_t xTaskCreatePinnedToCore(
TaskFunction_t pvTaskCode, // 指向任务入口函数的指针,该函数会不断运行,其原型为 void task( void* param )
const char * const pcName, // 任务的描述性名称
unsigned short usStackDepth, // 分配用于任务堆栈的字数(非字节)
void *pvParameters, // 传递给创建任务的参数
UBaseType_t uxPriority, // 创建任务执行的优先级 0 ~ 24,空闲任务的优先级为 0,而 loop 函数的优先级为 1
TaskHandle_t *pvCreatedTask, // 用于传递创建任务的句柄
const BaseType_t xCoreID // 值 0 或者 1 表示任务运行的微控制器内核编号,值 tskNO_AFFINITY 表示可以运行于任意的内核
);

任务创建之后,就可以通过 vTaskDelete() 函数结束并且删除掉一个任务:

1
2
3
void vTaskDelete(
TaskHandle_t pxTask // 需要删除的任务句柄,直接传递 NULL 会导致当前调用任务被删除
);

除此之外,还可以利用 uxTaskPriorityGet() 返回参数任务 XTask 的优先级:

1
2
3
UBaseType_t uxTaskPriorityGet(
TaskHandle_t xTask // 需要查询的任务句柄,直接传递 NULL 会返回调用任务的优先级
);

以及使用 XPortGetCoreID() 返回当前任务运行于哪一个微控制器内核:

1
BaseType t IRAM ATTR XPortGetCoreID( void );

在接下来的示例代码当中,通过直接包含 FreeRTOS 配置文件 FreeRTOSConfig.h 的方式(也可以采用分别包含 FreeRTOS.htask.h 两个头文件的方式),展示了如何基于采用 ESP32-S3 多核微控制器的 UINIO-MCU-ESP32S3 进行多任务的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <FreeRTOSConfig.h>

/* 任务 1 */
void Task1(void* parameter) {
static int count = 0;
int value = *((int*)parameter); // 获取整型指针参数的值

/* 计数值小于 200 就继续执行任务 */
while (count++ < 200) {
int core = xPortGetCoreID();
Serial.printf("Task-1 任务运行在:Core %d\n", core); // 打印当前任务运行在哪一个微控制内核
Serial.printf("Task-1 任务接收到的参数为: %d\n", value); // 打印当前任务接收到的参数
delay(2000);
}

/* 计数值大于 200 就退出任务执行 */
vTaskDelete(NULL); // 结束当前任务
}

/* 任务 2 */
void Task2(void* parameter) {
static int count = 0;

/* 计数值小于 200 就继续执行任务 */
while (count++ < 200) {
int core = xPortGetCoreID();
Serial.printf("Task-2 任务运行在:Core %d\n", core); // 打印当前任务运行在哪一个微控制内核
delay(2000);
}

/* 计数值大于 200 就退出任务执行 */
vTaskDelete(NULL); // 结束当前任务
}

void setup() {
Serial.begin(115200);

/* 创建任务 1,运行在 ESP32-S3 的 Core 0,优先级为 15 */
TaskHandle_t handle1; // 任务句柄
int parameter = 2023; // 任务参数
xTaskCreatePinnedToCore(Task1, "Task-1", 2048, (void*)&parameter, 15, &handle1, 0);

/* 创建任务 2,运行在 ESP32-S3 的 Core 1,优先级为 15 */
xTaskCreatePinnedToCore(Task2, "Task-2", 2048, NULL, 15, NULL, 1);
}

void loop() {
int core = xPortGetCoreID(); // 获取运行的微控制器内核
Serial.printf("loop 函数任务运行在:Core %d\n", core);

auto priority = uxTaskPriorityGet(NULL);
Serial.printf("loop 函数任务的优先级为: %d\n", priority);

Serial.println();
delay(2000); // 一个任务里的 delay() 函数不会影响到其它任务的运行,即虽然该任务延时 2 秒,但是其它任务依然按照正常速度执行
}

将上述代码下载到 UINIO-MCU-ESP32S3 上面,核心板就会每间隔 2 秒,以 115200 波特率向 Arduino IDE 的串口监视器打印如下内容:

1
2
3
4
5
Task-1 任务运行在:Core 0
Task-1 任务接收到的参数为: 2023
Task-2 任务运行在:Core 1
loop 函数任务运行在:Core 1
loop 函数任务的优先级为: 1

互斥锁机制

FreeRTOS 提供的互斥锁机制是一种包含有优先级继承机制的二进制信号量,之前介绍过的二进制信号量可以用于实现任务与任务,以及任务与中断之间的同步。而互斥锁则有助于更好的实现资源的互斥访问,它就像是保护互斥资源的一个令牌,当任务需要访问资源时,必须首先获取这个令牌;而在使用完资源之后,则必须返回这个令牌,从而使得其它任务能够继续访问该资源。

Arduino-ESP32 可以通过 xSemaphoreCreateMutex() 函数创建一个互斥锁,执行之后就会返回这个互斥锁的句柄:

1
SemaphoreHandle_t xSemaphoreCreateMutex( void );

在创建信号量之后,接下来就可以通过 xSemaphoreTake() 函数获取互斥锁信号量,如果获取成功就返回 pdTRUE,如果获取失败则返回 pdFALSE

1
2
3
4
xSemaphoreTake(
SemaphoreHandle_t xSemaphore, // 获取到的信号量句柄
TickType_t xTicksToWait // 等待信号量可用的节拍时间,指定为 portMAX_DELAY 会导致任务无限期阻塞(即没有超时)
);

除此之外,互斥锁信号量可以通过 xSemaphoreGive() 函数进行释放,信号量释放成功就返回 pdTRUE,如果发生错误则返回 pdFALSE

1
2
3
xSemaphoreGive(
SemaphoreHandle_t xSemaphore // 待释放的信号量句柄
);

下面的伪代码,简单明了的展示了互斥锁信号量的典型使用方法:

1
2
3
4
5
6
7
/* 获取互斥锁信号量 */
if( xSemaphoreTake(xSemaphore, xTicksToWait) ) {
// ... ... ...
// 开始处理临界资源
// ... ... ...
xSemaphoreGive(xSemaphore); // 释放互斥锁信号量
}

接下来的示例代码当中,通过使用互斥信号量确保了 Task-1Task-2 两个任务,对于互斥资源变量 number 的同步访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <FreeRTOSConfig.h>

xSemaphoreHandle xSemaphore; // 声明互斥锁信号量
int number = 0; // 定义互斥资源

/* 任务 2,用于打印互斥资源 number 的值 */
void Task1(void* parameter) {
static int count = 0;
int value = *((int*)parameter); // 获取整型指针参数的值

/* 计数值小于 200 就继续执行任务 */
while (count++ < 200) {
int core = xPortGetCoreID();
Serial.printf("Task-1 任务运行在:Core %d\n", core); // 打印当前任务运行在哪一个微控制内核
Serial.printf("Task-1 任务接收到的参数为: %d\n", value); // 打印当前任务接收到的参数

/* 获取互斥锁信号量 */
if (xSemaphoreTake(xSemaphore, portMAX_DELAY)) {
Serial.printf("互斥资源 number 的值为: %d\n", number); // 打印互斥资源 number
xSemaphoreGive(xSemaphore);
}
delay(2000);
}

/* 计数值大于 200 就退出任务执行 */
vTaskDelete(NULL); // 结束当前任务
}

/* 任务 2,用于对互斥资源 number 进行自增 1 操作 */
void Task2(void* parameter) {
static int count = 0;

/* 计数值小于 200 就继续执行任务 */
while (count++ < 200) {
int core = xPortGetCoreID();
Serial.printf("Task-2 任务运行在:Core %d\n", core); // 打印当前任务运行在哪一个微控制内核

/* 获取互斥锁信号量 */
if (xSemaphoreTake(xSemaphore, portMAX_DELAY)) {
number++; // 互斥资源 number 自增 1
xSemaphoreGive(xSemaphore); // 释放互斥资源
}
delay(2000);
}

/* 计数值大于 200 就退出任务执行 */
vTaskDelete(NULL); // 结束当前任务
}

void setup() {
Serial.begin(115200);
xSemaphore = xSemaphoreCreateMutex();

/* 创建任务 1,运行在 ESP32-S3 的 Core 0,优先级为 15 */
TaskHandle_t handle1; // 任务句柄
int parameter = 2023; // 任务参数
xTaskCreatePinnedToCore(Task1, "Task-1", 2048, (void*)&parameter, 15, &handle1, 0);

/* 创建任务 2,运行在 ESP32-S3 的 Core 1,优先级为 15 */
xTaskCreatePinnedToCore(Task2, "Task-2", 2048, NULL, 15, NULL, 1);
}

void loop() {
int core = xPortGetCoreID(); // 获取运行的微控制器内核
Serial.printf("loop 函数任务运行在:Core %d\n", core);

auto priority = uxTaskPriorityGet(NULL);
Serial.printf("loop 函数任务的优先级为: %d\n", priority);

Serial.println();
delay(2000); // 一个任务里的 delay() 函数不会影响到其它任务的运行,即虽然该任务延时 2 秒,但是其它任务依然按照正常速度执行
}

注意:不能在中断服务程序当中使用 FreeRTOS 的互斥锁信号量。

基于 WIFI 传输数据

Arduino-ESP32 提供了一系列 WIFI 相关的 API,支持 802.11b/g/n 无线局域网标准,可以用于扫描 WIFI 接入点,也支持 WPA2WPA3 等 WIFI 安全模式,除此之外还提供了 WIFI 的 STAAP 两种工作模式:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <HTTPClient.h>

WiFiMulti wifiMulti;

void setup() {
Serial.begin(115200);
Serial.println("===== 开始 [SETUP] 配置 =====");

/* 延时等待 5 秒 */
for (uint8_t t = 5; t > 0; t--) {
Serial.printf("正在运行 [SETUP],请等待 %d... 秒\n", t);
Serial.flush();
delay(1000);
}

wifiMulti.addAP("SSID", "Password");
}

void loop() {
/* 等待 WIFI 连接成功 */
if ((wifiMulti.run() == WL_CONNECTED))
HTTPClient http;

Serial.print("准备 [HTTP] 请求...\n");
http.begin("http://www.uinio.com"); // 准备向 UinIO.com 发起 HTTP 请求

Serial.print("开始 [HTTP] GET 请求...\n");
int HttpCode = http.GET(); // 开始连接,发送 HTTP 协议头

/* 当 HTTP 状态码为负值时表示出现错误 */
if (HttpCode > 0) {
Serial.printf("当前 [HTTP] GET 请求成功,响应状态码为: %d\n", HttpCode); // 打印 HTTP 请求响应状态码

/* 打印 GET 请求获取到的内容 */
if (HttpCode == HTTP_CODE_OK) {
String payload = http.getString();
Serial.println(payload);
}
} else {
Serial.printf("当前 [HTTP] GET 请求失败,错误信息为: %s\n", http.errorToString(HttpCode).c_str());
}
http.end(); // 结束 HTTPClient 服务
}

delay(6000); // 延时 6 秒
}

配合 ArduinoJson 发起 POST 请求

除此之外,配合第三方 JSON 解析库 ArduinoJson 使用,还可以方便的以 POST 方式传输 JSON 格式的数据。下面的示例代码,将会携带一个包含有 data 属性的 JSON 对象参数,向远程服务器的 http://192.168.1.1:8080/test 接口发起一个 HTTP POST 请求,并且将响应的结果打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>

WiFiMulti wifiMulti;

void setup() {
Serial.begin(115200);
Serial.println("===== 开始 [SETUP] 配置 =====");

/* 延时等待 5 秒 */
for (uint8_t t = 5; t > 0; t--) {
Serial.printf("正在运行 [SETUP],请等待 %d... 秒\n", t);
Serial.flush();
delay(1000);
}

wifiMulti.addAP("SSID", "Password");
}

void loop() {
/* 等待 WIFI 连接成功 */
if ((wifiMulti.run() == WL_CONNECTED)) {
HTTPClient http;

Serial.print("准备 [HTTP] 请求...\n");
http.begin("http://192.168.1.1:8080/test"); // 准备向 UinIO.com 发起 HTTP 请求

Serial.print("开始 [HTTP] POST 请求...\n");
http.addHeader("Content-Type","application/json");

String serializeResult;
DynamicJsonDocument JsonParameter(1024);
JsonParameter["data"] = "电子技术博客 UinIO.com";
serializeJson(JsonParameter, serializeResult);

int HttpCode = http.POST((uint8_t*)serializeResult.c_str(), serializeResult.length()); // 开始连接,发送 HTTP 协议头

/* 当 HTTP 状态码为负值时表示出现错误 */
if (HttpCode > 0) {
Serial.printf("当前 [HTTP] POST 请求成功,响应状态码为: %d\n", HttpCode); // 打印 HTTP 请求响应状态码

/* 打印 GET 请求获取到的内容 */
if (HttpCode == HTTP_CODE_OK) {
String payload = http.getString();
Serial.println(payload);
}
} else {
Serial.printf("当前 [HTTP] POST 请求失败,错误信息为: %s\n", http.errorToString(HttpCode).c_str());
}
http.end(); // 结束 HTTPClient 服务
}

delay(6000); // 延时 6 秒
}

基于 UINIO-MCU-ESP32 的 Arduino 进阶教程

http://www.uinio.com/Project/Arduino-ESP32/

作者

Hank

发布于

2023-05-01

更新于

2024-09-22

许可协议