65.9K
CodeProject 正在变化。 阅读更多。
Home

ESP32 深度解析:现在是什么时间?

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2021年3月9日

MIT

18分钟阅读

viewsIcon

9526

downloadIcon

409

深入 ESP-IDF,添加一些非常棒的实时时钟功能。

注意:Platform IO 会在您下载 ds1307_example 项目时,代表您下载 esp32_i2crtc_ds1307 库,无论您是否从上方下载它们。如果您在首次打开项目后在 Platform IO 构建时遇到任何问题,请在任务中打开新的 PlatformIO CLI 提示符,然后输入“pio run”,这将强制它下载并构建所有内容。

i2c

引言

任何人都可以使用 Arduino 框架对 ESP32 进行编程,但要以灵活性、功能和潜在的代码大小为代价,具体取决于您正在做什么。认真对待 ESP32 意味着要放弃封装它的 Arduino 框架,而是深入研究 Espressif 物联网开发框架 (ESP-IDF) 本身。

ESP-IDF 是 ESP32 的核心“操作系统”。虽然它不是真正的操作系统,但它确实自带了一个名为 FreeRTOS 的系统,但它提供了人们期望从操作系统中获得的功能,例如基本硬件接口和 I/O、系统配置以及类似的东西。

ESP-IDF 是用 C 编写的,但我们将通过 C++ 使用它,只需稍作调整。

在本文中,我们将探索如何以主模式公开 I2C 总线主机,以与该总线上的一台或多台从属 I2C 设备进行通信。然后,我们将使用这些功能通过该总线与 DS1307 实时时钟进行通信。

Arduino 已经有实现此功能的库,但本文适用于那些不想依赖 Arduino 框架,或者想更深入地探索 ESP32 的读者。

必备组件

这是一个硬件项目,因此您需要一些基本组件。除了 ESP32 开发板本身,其中大部分(如果不是全部)组件都包含在 Arduino 入门套件中。

  1. 一块基于 ESP32 的开发板。本项目是使用 ESP32-WROOM 在 devkit01 主板上构建的。
  2. 一个 DS1307 实时时钟模块,工作电压为 +5VDC(+3.3VDC 会更“干净”,但我没有。如果您有,请更改模块上的电源输入。在这种情况下,您将需要使用 +3.3VDC 作为 VIN。在这个例子中,我们使用 +5VDC。
  3. 2 根 4.7k 电阻。我有时可以使用内部上拉电阻。其他时候则不太行。外部电阻最好,但如果您没有,可以尝试只使用内部电阻。它可能会起作用,也可能不起作用,或者如果您运气不好,它一开始会起作用,然后有一天您的时钟就会停止工作,直到您在总线上加上上拉电阻。
  4. 必不可少的电线、无焊洞洞板和 microUSB 数据线。

您还需要安装 Platform IO,因为我不想强制您使用很棒但商业化的 VisualGDB。

连接这个“大杂烩”

连接非常简单。以下假设使用的是基于 +5VDC 的 DS1307 时钟模块。

ESP32 GPIO21 ➟ DS1307 SDA ➟ 4.7k 电阻 #1 ➟ ESP32 +3.3VDC

ESP32 GPIO22 ➟ DS1307 SCL ➟ 4.7k 电阻 #2 ➟ ESP32 +3.3VDC

ESP32 +5VDC/VIN ➟ DS1307 +5VDC/VIN

ESP32 GND ➟ DS1307 GND

概念化这个混乱的局面

本项目由两个独立的库和一个示例组成。我们有用于与 I2C 总线上的设备通信的 esp32_i2c 库,以及使用 esp32_i2c 与基于 DS1307 的实时时钟模块通信的 rtc_ds1307 库。从某种意义上说,rtc_ds1307esp32_i2c 的示例代码。最后,我们有使用上述库的示例 PlatformIO 项目。

设计模式与注意事项

这些库中的类使用 C++ RAII 模式来处理资源获取和释放。但是,由于使用此模式意味着必须从类构造函数中进行非平凡调用,因此主要类具有一个 initialized() 访问器,可以查询它以确定对象是否成功初始化。在创建对象后应检查它,以确保它没有失败,因为……

我们在这些库中不使用异常处理,因为在 ESP32 这样的小型物联网设备上,堆栈帧的膨胀是相当大的限制。我们使用一种类似 C 的机制,即我们的对象将最后一个错误报告为数值。大多数成员返回一个 bool,如果它们返回 false,则可以通过 last_error() 访问器检索最后一个错误。所有 i2c 组件共享同一个最后一个错误,通过 esp32::i2c::last_error() 访问。时钟有自己的 last_error() 访问器。

I2C 通信

I2C 是一种两线协议,允许设备在短距离内以大约 100kHz 的频率串行通信。它们很常见,易于接线,并且单个主控制器可以控制多个从属设备。

硬件方面的缺点是它不适用于超过半米的距离(如果能达到的话),并且当您添加更多设备时,它会变得棘手。软件方面的缺点是它可能很难编程,因为通信协议在可预期的标准行为方面非常稀疏——每个设备都以类似的方式读取和写入寄存器,但不一定以完全相同的方式,例如。协议基本上是极低级别的,每个设备都可以根据自己的需求在之上扩展功能,遵循某种不成文的约定俗成。这种“根据自己的需求”可能会导致头疼。

在本文中,我们不打算将 ESP32 用作从属 I2C 设备,因为这并不是一个非常常见的用例,因此所有内容都将集中在主机端,即托管设备的那一端。

I2C 命令

ESP-IDF 使用一种队列技术来确保一系列读取和写入操作作为一个单元执行,这有助于保持正确的时序并减少执行此操作的代码负担。其思想是,您不是直接读写总线,而是向一个“命令链接”发出读写命令,该链接会将它们排队,并在所有命令都准备好后,作为一个批次执行它们。

I2C 寄存器

寄存器似乎是一种松散的标准。I2C 总线上没有特定的操作可以确认名为“寄存器”的事物存在,也没有代码可以指示它们的存在。然而,我遇到的每一个设备都通过一系列读/写握手来暴露它们,指定一个寄存器 ID。我将解释。

在大多数设备上,要读取或写入寄存器,您需要:

  1. 写入一个字节:7 位目标 I2C 地址,左移,然后与指示读取 (1) 或写入 (0) 的标志进行二进制 OR 操作,例如 address<<1|I2C_MASTER_READaddress<<1|I2C_MASTER_WRITE
  2. 写入一个字节:寄存器 ID/代码值。每个寄存器都有一个唯一的单字节标识符,这就是要放在这里的内容。
  3. 读取或写入一系列字节。读取或写入的长度取决于寄存器和设备。您需要知道会发生什么,以及它需要什么。

这大大复杂化了,因为每个不同的读或写操作都可以通过 ACK 和 NACK 来进行各种握手/验证,例如,对于时钟,当您读取一个寄存器时,最后一个字节始终使用 NACK 读取,但之前的字节使用 ACK 读取。我不确定这有多普遍。这一切都取决于您打算与之接口的硬件文档,并且弄错了可能会导致超时或功能失效。这并不适合胆小的人。如果您计划对基于 I2C 的硬件编写大量包装类,我建议您购买示波器,并在硬件层面学习 I2C 总线协议。说真的。

我提供了一些辅助函数来方便读取和写入这些寄存器,例如 begin_read()begin_write(),但我目前不建议使用 read_register()write_register(),因为我对它们的通用性不是很有信心。

起始和停止信号,以及重启

这些设备还有一个额外的复杂之处,那就是它们在批量读写期间期望起始和停止信号。通常,它是 start➟writes➟stop,但对于在同一次批量操作中进行读取和写入的命令,您需要在其中加入另一个起始信号,因此它更像是 start➟writes➟start➟reads➟stop。我从未在任何命令批次中见过超过一个停止信号,但我不能说这种情况不存在。在这方面我的经验有限。

DS1307 模块

基于 DS1307 的实时时钟模块提供了一种比 ESP32 的计时功能更高的精度,并且通过集成电池在其余电路断电时仍能提供计时。它还具有每秒递增日期和时间的逻辑。总而言之,这些功能构成了一个实时时钟。该模块通过 I2C 接口进行通信,此外还有两个用于供电的附加引脚,以及一个用于输出方波的引脚,其他设备可以使用该方波来同步自己的计时到一个精确信号。

我很确定可以使用此时钟的方波信号,将其连接回 ESP32 作为“晶振”输入源,ESP32 可以使用它来处理自己的计时,从而使 C 语言的 time 等库例程真正拥有适当的分辨率和功能,以便像真正的时钟一样运行。不幸的是,我还没有设法让它工作,我可能需要示波器来追踪问题。一旦我做到了,我将更新库和文章以反映这一点,您将能够使用内置的 C 和 C++ 时间库与此 [时钟] 配合使用。

除非您打算将其他硬件与时钟同步,否则方波功能不是特别有用,我们也不会在示例中使用它。正如我上面建议的,一旦我设法让这个同步功能与 ESP32 一起工作,我将在此添加更多内容来反映这些变化,包括涵盖 C 和 C++ 标准库中的核心时间功能,这将为我们打开。

硬件包装类生命周期

在普通计算机上,使用常规操作系统,您不会有硬件包装类。您有驱动程序。驱动程序几乎总是从您启动到操作系统关闭后一直存在。通常,您会希望对封装此平台上的硬件设备的任何东西执行类似操作。您可以通过将硬件包装类声明为全局变量来实现。我通常不关心全局变量,但如果有什么情况适合使用它们,那就是这里。全局变量无处不在,并且对任何知道该寻找什么的人都可访问。硬件也是如此。这是一个很好的匹配。

然而,我发现 ESP32 的初始化过程存在一个问题,即在达到 app_main() 入点之前,不能开始与 I2C 从属设备建立通信。这样做可能导致崩溃/重启循环。

这给我们的 RAII 模式带来了问题,即当我们尝试从包装类初始化或甚至检查从属设备是否存在时 - 例如,通过 ds1307 类的构造函数从 DS1307 时钟模块 - 会导致崩溃。我不确定为什么,只是说,在 app_main() 开始之前执行 I2C 总线操作显然是不安全的。这限制了我们将 ds1307 时钟包装器声明为全局变量的能力,因为它会导致构造函数过早触发。这是不可接受的。如果我们想要,我们必须能够将此声明为全局变量。

我们通过实际上不从构造函数初始化设备来解决这个问题。相反,我们只需将初始化所需的所有数据作为构造函数参数,然后稍后保存。一旦首次使用任何时钟功能,或者如果显式调用 initialize(),则此时时钟将被初始化。这并不完美。它会弄乱(如果不是破坏)RAII 模式,因为“资源获取即初始化”不再成立——初始化是惰性的。至少它与 RAII 配合良好,因此使用它的代码不必过多关注差异。只需知道,在您实际第一次使用时钟,或者调用 initialize() 之前,您实际上无法知道时钟是否存在。

当您将来为其他设备编写包装器时,请记住以上内容,因为您的包装器也可能需要采用惰性初始化方法。

编写这个混乱的程序

使用 ds1307 包装器类

我发现以代码开头很有效,所以这里是

#include <iostream>
#include <i2c_master.hpp>
#include <ds1307.hpp>

extern "C" {void app_main();}

using namespace std;
using namespace esp32;
using namespace rtc;

// compile time date portion
// adapted from dc42's solution:
// https://stackoverflow.com/questions/17498556/c-preprocessor-timestamp-in-iso-86012004
constexpr uint8_t compile_year = (uint8_t)((__DATE__[7] - '0') 
                                * 1000 + (__DATE__[8] - '0') 
                                * 100 + (__DATE__[9] - '0') 
                                * 10 + (__DATE__[10] - '0')
                                -1900);
constexpr uint8_t compile_month = (uint8_t)((__DATE__[0] == 'J') ? 
                                    ((__DATE__[1] == 'a') ? 
                                        1 : ((__DATE__[2] == 'n') ? 
                                            6 : 7))    // Jan, Jun or Jul
                                : (__DATE__[0] == 'F') ? 2 // Feb
                                : (__DATE__[0] == 'M') ? 
                                    ((__DATE__[2] == 'r') ? 
                                        3 : 5) // Mar or May
                                : (__DATE__[0] == 'A') ? 
                                    ((__DATE__[2] == 'p') ? 
                                        4 : 8) // Apr or Aug
                                : (__DATE__[0] == 'S') ? 9  // Sep
                                : (__DATE__[0] == 'O') ? 10 // Oct
                                : (__DATE__[0] == 'N') ? 11 // Nov
                                : (__DATE__[0] == 'D') ? 12 // Dec
                                : 0)-1;
constexpr uint8_t compile_day = (uint8_t)((__DATE__[4] == ' ') ? 
                                    (__DATE__[5] - '0') : (__DATE__[4] - '0') 
                                        * 10 + (__DATE__[5] - '0'));

constexpr uint8_t compile_hour = (uint8_t)(__TIME__[0]-'0')
                                    *10+(__TIME__[1]-'0');
constexpr uint8_t compile_minute=(uint8_t)(__TIME__[3]-'0')
                                    *10+(__TIME__[4]-'0');
constexpr uint8_t compile_second=(uint8_t)(__TIME__[6]-'0')
                                    *10+(__TIME__[7]-'0');

// initialize our master i2c
// host:
i2c_master g_i2c;
// initalize our clock
ds1307 g_clock(&g_i2c);

void app_main() {
    tm tm;
    // we're going to set the time and
    // date to the time and date this
    // code was compiled:
    tm.tm_year = compile_year;
    tm.tm_mon = compile_month;
    tm.tm_mday = compile_day;
    tm.tm_hour = compile_hour;
    tm.tm_min = compile_minute;
    tm.tm_sec = compile_second;
    tm.tm_isdst = -1;// for daylight savings. we don't know
    if(!g_clock.set(&tm)) {
        cout << "Error setting clock: " 
            << g_clock.last_error() 
            << " " 
            << esp_err_to_name(g_clock.last_error()) 
            << endl;
    }
    // now just print the date and time every second
    while(true) {

        if(!g_clock.now(&tm)) {
            cout << "Error reading clock: " 
                << g_clock.last_error() 
                << " " 
                << esp_err_to_name(g_clock.last_error()) 
                << endl;
        } else {
            cout << asctime(&tm);
        }
        // delay for 1 second
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

其中大部分只是解析编译器为我们定义的 __DATE____TIME__ 宏。它们以人类可读的字符串返回信息,而这正是我们不想要的,因此其中的代码在编译时将其解析出来,这是 C 和 C++ 的一个我最喜欢的功能——将这样的平凡任务从运行时/初始化移至它们所属的编译时。诸如此类的事情让我感到高兴。

之后,一旦应用程序启动,我们就将上面获取的所有值放入一个 tm 结构体并用它来设置时钟。

注意:我遇到过一些问题,特别是在不负责任地处理电压或将时钟引脚分配到项目中后,导致它基本上被重置并退出运行状态(g_clock.running() == false),这是因为我在台式机上对其进行了调整。这是硬件问题,不是软件问题。但是,一旦它不再处于运行状态,我找到的唯一方法是通过软件重新设置时钟。如果您的时钟可以初始化但无法提供时间,并且报告它没有 running(),您应该设置时钟。如果有人知道在 DS1307 时钟不再处于运行状态后重新启动它的方法,请在评论中告知我。

总之,在我们设置好之后,我们只需在一个循环中检索时间并每秒打印一次。

工作原理:I2C 通信

当我们创建时钟时,在创建之前,我们创建了一个 i2c_master 实例。

// initialize our master i2c
// host:
i2c_master g_i2c;
// initalize our clock
ds1307 g_clock(&g_i2c);

创建时,i2c_master 会使用您指定的引脚和设置初始化指定的 I2C“主机插槽”。我们使用了默认设置,因此构造函数没有参数。我们现在来看看构造函数。

i2c_master(
    i2c_port_t i2c_port=I2C_NUM_0, 
    gpio_num_t sda=GPIO_NUM_21,
    gpio_num_t scl=GPIO_NUM_22,
    bool sda_pullup=true,
    bool scl_pullup=true, 
    uint32_t frequency=100000,
    int interrupt_flags=0
    ) : m_initialized(false) {
    m_configuration.mode=i2c_mode_t::I2C_MODE_MASTER;
    m_configuration.sda_io_num = (int)sda;
    m_configuration.scl_io_num = (int)scl;
    m_configuration.sda_pullup_en = sda_pullup;
    m_configuration.scl_pullup_en = scl_pullup;
    m_configuration.master.clk_speed=frequency;
    esp_err_t res = i2c_param_config(i2c_port, &m_configuration);
    if (res != ESP_OK) {
        i2c::last_error(res);
        return;
    }
    res = i2c_driver_install(i2c_port, m_configuration.mode, 0, 0, interrupt_flags);
    if (res != ESP_OK) {
        i2c::last_error(res);
        return;
    } 
    m_port=i2c_port;
    m_initialized=true;
}

我通常不喜欢为构造函数使用这么多参数。我更喜欢引用 struct 来避免占用堆栈,但我不想删除可选参数,我认为它们是此代码的关键功能。没有它们,设置总线将非常困难。鉴于此初始化通常只发生一次,很小的性能影响不成问题。这些设置适用于 GPIO 引脚 21(SDA)和 22(SCL)上的主 I2C 总线插槽 0,这也是我们在本例中使用的。SDA 和 SCL 上的内部上拉电阻可能不是必需的,尽管它们默认为 true——我们已经在电路中包含了我们自己的,它们更可靠——但拥有它们并没有坏处,而且可能会帮助那些不负责任地跳过在电路中添加 2 根 4.7k 电阻的读者。除非您知道需要更改频率,否则请保持原样。

它所做的只是构建一个包含所有参数的配置 struct 并将其暂存以便稍后使用,然后告诉 ESP-IDF 使用它来配置参数(我实际上不知道这有什么作用——只知道您需要这样做),然后安装驱动程序。如果在此过程中发生任何错误,它将设置 last_error() 结果,然后提前返回。最后,它存储最后的配置信息——主机端口或插槽,并指示已完成初始化。

如果您查看 i2c_master 的源代码,您会发现析构函数并没有做太多事情,这可能会令人惊讶,考虑到我们在构造函数中所做的设置。据我所知,一旦安装了 I2C 主机插槽,就没有办法将其卸载或拆卸。它会一直存在直到重启。我想这是有道理的,因为这基本上只是配置硬件总线的一种方式,但这是需要注意的一点,因为您不能像使用 ESP-IDF 那样随意动态创建和销毁主机插槽。

除此之外,唯一其他主要感兴趣的是 execute() 方法。

bool execute(const i2c_master_command& command,
    TickType_t timeoutTicks=portMUX_NO_TIMEOUT) {
    esp_err_t res = i2c_master_cmd_begin(m_port,command.handle(),timeoutTicks);
    if(ESP_OK!=res) {
        i2c::last_error(res);
        return false;
    }
    return true;
}

我曾写过关于将读写操作排队作为批量命令,然后将其发送以执行的内容。这是最后的步骤。我们稍后将讨论批处理命令。所有这些只是在给定超时的情况下执行命令。

注意:我强烈建议避免使用无超时作为默认值,坦率地说,我没有提供更明智的值是未将其移除为默认值的主要原因。无超时是最合理选择的唯一时间是,如果您的应用程序中设备通信失败代表整个应用程序的灾难性故障,那么任何可能发生的停顿都没有意义——换句话说,如果与设备的通信失败将结束应用程序,那么无超时是可以的。

将读写操作打包成命令

所有读写操作都包含在 i2c_master_command 实例中。您可以随时创建一个这样的实例,通过向其发出读写操作来填充它,最后,在完成后使用 i2c_masterexecute() 方法执行它。

读取值时,您必须指定一个指针,该指针提供将要读取的目标数据的地址。注意我说了“将要”——这些指针直到调用 execute() 时才会被访问,并且您有责任确保此时它们仍然指向有效位置。只要您始终在创建 i2c_master_command 实例的同一方法中,并在填充完成后立即使用 execute(),这很容易。这是一个例子。这会将一个字节写入 DS1307 时钟的“控制寄存器”(ID 为 7),该寄存器用于设置集成方波的周期频率。

esp32::i2c_master_command cmd;
if(!cmd.start()) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!cmd.begin_write(m_address,true)) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!cmd.write(0x07,true)) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!cmd.write((uint8_t)value)) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!cmd.stop()) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!m_i2c->execute(cmd,5000/portTICK_PERIOD_MS)) {
    last_error(esp32::i2c::last_error());
    return false;
}
return true;

这里,除了错误处理和将最新的 I2C 错误传播到时钟的 last_error() 之外,我们有 start➟begin_write➟write➟write➟stop,然后我们执行。

我们期望每次写入后都有 ACK,这通常是最常见的情况。我们上面使用了 begin_write() 来告诉命令我们将针对 I2C 地址 m_address 的设备进行寄存器写入操作。我们使用 *下一个* 写入来告诉它目标寄存器(0x07)。最后,在调用 start()stop() 之前的任何后续写入都将发送到该寄存器。

我对 execute() 调用设置了 5 秒的超时,但我从未发现它需要那么长时间才能失败。这是一个相当随意的数值。基本上,我选择了最长的超时时间,而不会让我想在等待时挖掉自己的眼睛,但在实践中,对于这个设备,在任何我遇到的情况下都不会达到该超时时间。我还没有在它真正着火的时候尝试过。

读取稍微复杂一些,因为通常还有另一个启动调用,而且读取数据还有额外的考虑因素。这是上述代码的一个推论——它使用寄存器读取操作来检索当前的方波周期频率值。在这种情况下,该设备上的这个特定寄存器在读取方面有点奇怪。它期望您写入它,但写入 0 个字节的数据,然后再读取寄存器。

esp32::i2c_master_command cmd;
if(!cmd.start()) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!cmd.begin_write(m_address,true)) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!cmd.write(0x07,true)) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!cmd.start()) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!cmd.begin_read(m_address,true)) {
    last_error(esp32::i2c::last_error());
    return false;
}
uint8_t r;
if(!cmd.read(&r,I2C_MASTER_NACK)) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!cmd.stop()) {
    last_error(esp32::i2c::last_error());
    return false;
}
if(!m_i2c->execute(cmd,5000/portTICK_PERIOD_MS)) {
    last_error(esp32::i2c::last_error());
    return false;
}

请注意,一旦我们写入寄存器值 0x07,我们就调用了 start() 而不是向该寄存器写入值?这就是我上面提到的奇怪的业务。

正常的寄存器读取会以 start➟begin_read➟read...➟stop 开始

时钟期望 start➟begin_write➟write➟start➟begin_read➟read➟stop

我上面强调了奇怪的业务。时钟有一个奇怪的查询方式。这是我在文章开头提到的那种愚蠢行为。设备就像雪花一样独特,所以如果您打算为其编写包装器,请确保您了解您的设备。

我不是全知的,也不是用示波器仔细研究了这个设备。我所做的是,我 rather shameless 地从几个示例和现有库(即使它们使用完全不同的 I2C 框架)中复制代码,并将它们与我从 DS1307 手册中收集到的信息进行了汇编和交叉引用,然后在调整和打磨我最终得到的内容,以实现该小工具的适当 I2C I/O。

这就足以提供一个可用的实时时钟库,并为额外的基于 I2C 的设备创建包装器提供一条途径,因为我们已经解开了它们的工作原理。祝您创作愉快!

历史

  • 2021 年 3 月 8 日 - 初次提交
© . All rights reserved.