基于 Arduino 玩转 UINIO-MCU-ESP32 全攻略

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

ESP32-C3ESP32-S3 是当前市场上比较典型的两款主控方案,它们分别基于开源的 RISC-V 内核,以及商业化的 Xtensa 内核,并且同时支持 WiFi 与 Bluetooth 无线连接。由于日常工作当中经常使用到这两款微控制器,所以特意设计了 UINIO-MCU-ESP32C3UINIO-MCU-ESP32S3 两款核心板,关于它们硬件电路设计方面的内容,可以参考本文的姊妹篇《UINIO-MCU-ESP32 核心板电路设计》

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.9 版本,通过向 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
... .. ...

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);
}
}

LEDC 与 PWM

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

ESP32-C3ESP32-S3 分别拥有 6 和 8 个通道,用于产生独立的波形信号,最大精度为 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\),同理 ESP32S3 的最大计数值等于 \(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 软件定时器
}

基于 Arduino 玩转 UINIO-MCU-ESP32 全攻略

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

作者

Hank

发布于

2023-05-01

更新于

2023-06-01

许可协议