0%

点灯大师在线点灯之 ESP32

怕什么真理无穷,进一寸有一寸的欢喜。

在不间断的核酸和反思中又过去了一周,看了看修炼 点灯大师 的路途,还很遥远,本打算就这样浑浑噩噩的度过这个周末,但心中的灯还亮着,手上的灯又有什么理由不让它亮起来呢?

上一期我们探讨了下古老的 51 单片机,并借机铺垫了很多嵌入式开发的基础知识,今天我们来看一款更加现代化的 MCU — ESP32,这是我所接触到的第一款国产芯片,我愿称之为“国货之光”!

ESP32 简介

ESP32 是由乐鑫生产的一系列国产芯片,相比于国外的芯片,最最直接的优点就性价比,同等级别的芯片,ESP32 的价格可能是国外芯片的一半都不到。以下是一些比较官方的介绍:

ESP32 是乐鑫信息科技推出的一块 WIFI 芯片,集成了天线开关、射频、功率放大器、低噪放大器、过滤器和电源管理模块,整个解决方案占用了最少的印刷电路板面积。2.4 GHz Wi-Fi 加蓝牙双模芯片采用 TSMC 低功耗 40nm 技术,功耗性能和射频性能最佳,安全可靠,易于扩展至各种应用。具有以下特点:

  • 性价比高
  • 体积小
  • 功能强大,支持 LWIP 协议,FreeRTOS
  • 支持三种模式:AP, STA, AP + STA 共存模式
  • 支持 Python、Lua 编程,让你开发更简单

在上面的介绍里可以看到,通过烧录一些特定固件,可以支持 Python、Lua 编程,也就是说我们可以通过 Python、Lua 来进行点灯,但这不在我们修炼的范围 (不要迷恋这些旁门左道!),毕竟这些都是给小孩子玩的,成年人的灯还是要用成年人的方式来点。

开发板简介

截止本篇文章发表前,ESP 一共有以下系列:

  • ESP32-S2
  • ESP32-S3
  • ESP32-C2
  • ESP32-C3
  • ESP32
  • ESP8266

其中 ESP8266 是最早出名的一款芯片,因为价格便宜且带 WiFi 功能,很多硬件方案里并不将其作为主控芯片,而是单纯的当成 WiFi 模块来进行使用 (尴尬的地位啊!)。经历了 ESP8266 之后,ESP32 登场,这应该是目前大家所讨论的广义上的 ESP32 了,同样的价格低廉,外加蓝牙、WIFi 应有尽有,并且还是双核结构,性价比简直无与伦比了。后续又推出了 C 和 S 系列的 ESP32,C 系列算是对 ESP32 进行了阉割,更低的价格、更低的功耗;而 S 是对 ESP32 的增强,价格贵一点、能力强一点。

今天我们要探讨的是经典的 ESP32,也就是基于 Xtensa 双核 32 位 LX6 微处理器的这款。上一期我们说过了,有了 MCU 还需要一些外围电路来驱动,也就是需要开发板。ESP32 的开发板某宝里到处都是,以下是我手头上的一款:

开发板下面是 iPhone 6s,对比可以看到这款板子是非常小巧的,当然板载的东西就要少很多了 (相比上期的 51 单片机开发板),不过灯还是有的,对于修炼点灯而言,我们要求的板载外设并不多,只要有灯就够了!

点灯几种方式

在正式点灯之前,我们要先聊聊能有哪些方式来点灯 (茴字的写法?),这也是上期在讲 51 单片机时没有涉及的。本篇开头部分就已经说了,相比于 51 而言,ESP32 是一个更加现代化的 MCU,那么它现代化在哪呢?

  1. 从 8 位到 32 位,拥有更为广泛的编址空间,寄存器数量庞大。寄存器也变为了 32 位,单个寄存器的可配置项更多
  2. 从 单核 到 双核,更为复杂的启动逻辑,并行计算成为可能
  3. 从 裸机 到 系统,ESP32 支持实时操作系统,使用起来更方便,但底层逻辑更复杂
  4. 从 双向 到 单向,这是从 GPIO 的角度而言,在 51 的时代,GPIO 任何时候都是双向的,也就是即是输入也是输出 (这会造成一些困扰),而 ESP32 和一些其他更为现代化的 MCU 一样,需要进行配置是输入输出模式

总体而言,强大了很多,也复杂了很多。这也让我们的点灯方式上有了更多选择,本篇将用以下几种方式来进行点灯:

  1. 类似与 51 单片机的纯寄存器点灯
  2. 使用官方的 IDF 框架来进行点灯
  3. 使用 Arduino 框架来进行点灯

那么最贴近底层,也最为复杂的就是第一种点灯方式了,既然是修炼点灯,这是不可或缺的。这种纯寄存器的开发方式,在嵌入式开发中又称之为 裸机开发,所谓的裸机,就是指没有任何操作系统的情况下。

启动流程分析

在任何 MCU、CPU 进行裸机开发的第一步,都是需要了解下整个芯片是怎么运作起来的,也就是从上电到程序运行起来的整个流程。这在不同的 SoC 下不太一样,但总体而言都大同小异,也就是了解了 ESP32 的启动逻辑,对了解其它更为复杂的 CPU 启动逻辑也是有一定帮助的。

从宏观上 (也就是基于官方的 IDF 开发流程),ESP32 启动流程可以分为如下 3 个步骤:

  1. 一级引导程序被固化在了 ESP32 内部的 ROM 中,它会从 Flash 的 0x1000 偏移地址处加载二级引导程序至 RAM(IRAM & DRAM) 中
  2. 二级引导程序从 Flash 中加载分区表和主程序镜像至内存中,主程序中包含了 RAM 段和通过 Flash 高速缓存映射的只读段
  3. 主程序运行,这时第二个 CPU 和 RTOS 的调度器可以开始运行

标准流程里包含了两次引导和一些初始化过程,相对而言还是比较简单的。为了和官方参考手册中一致,我们也将 ESP32 的两个处理器内核分为 PRO CPU 和 APP CPU,需要注意的是,整个启动过程中只有 PRO CPU 在工作,到达第三步时才开启了 APP CPU。

首先是一级引导程序,这个是乐鑫官方固化在 ROM 里了,也就是说我们是没有源码也无法修改的。SoC 复位以后,PRO CPU 会立即开始运行,执行复位向量代码,复位向量代码位于 ESP32 芯片掩膜 ROM 的 0x40000400 地址处,该地址不能被修改。复位向量里的代码,会通过检测相关寄存器来判断复位源,从而进入不同的启动模式,比如进入下载模式 (可通过串口烧录二进制文件到 MCU 里),如果是常规的软件复位或看门狗复位,则会从 Flash 的 0x1000 偏移地址处加载二进制镜像,这里通常就是放置二级引导程序的位置了。当然,一级引导程序还做了些额外的事情:

  1. 初始化了栈指针 sp,所以我们可以直接用 C 语言了
  2. 开启了看门狗:在 Flash 启动过程中,定时器组 0(TIMG0)中的 MWDT 和 RWDT 自动使能。两个看门狗定时器的阶段 0 默认为在超时后复位系统
  3. 包含了一些实用的 C 函数,我们可以将其导出后直接使用

再看看二级引导程序,这段程序是开源的,也可以说是传统意义上的 bootloader 了,二级引导程序的代码在 IDF 框架的:components/bootloader 以及 components/bootloader_support 这两个文件夹里。可以说 bootloader 就是一个比较复杂的裸机程序,所以我们要编写的裸机程序完全可以参照这个 bootloader 来。

构建编译环境

那么问题就很简单了,一级引导在正常模式下,会从 Flash 的 0x1000 加载二进制镜像,那么我们将裸机程序放在这个位置岂不是就能自动执行了?大致是这样,但又不能直接这样,因为我们最终会通过交叉编译器来编译代码,现代化的嵌入式开发基本都是使用 GNU 那一套 GCC 交叉编译,直接编译出来的产物是 ELF 格式的文件,这个文件是不能被 ESP32 直接加载执行的。ESP32 能加载执行的文件格式定义在了 esp_app_format.h 这个头文件里,也比较简单,主要包含文件头信息以及其它节点布局信息,头信息的结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef struct {
uint8_t magic; /*!< Magic word ESP_IMAGE_HEADER_MAGIC */
uint8_t segment_count; /*!< Count of memory segments */
uint8_t spi_mode; /*!< flash read mode (esp_image_spi_mode_t as uint8_t) */
uint8_t spi_speed: 4; /*!< flash frequency (esp_image_spi_freq_t as uint8_t) */
uint8_t spi_size: 4; /*!< flash chip size (esp_image_flash_size_t as uint8_t) */
uint32_t entry_addr; /*!< Entry address */
uint8_t wp_pin; /*!< WP pin when SPI pins set via efuse (read by ROM bootloader,
* the IDF bootloader uses software to configure the WP
* pin and sets this field to 0xEE=disabled) */
uint8_t spi_pin_drv[3]; /*!< Drive settings for the SPI flash pins (read by ROM bootloader) */
esp_chip_id_t chip_id; /*!< Chip identification number */
uint8_t min_chip_rev; /*!< Minimum chip revision supported by image */
uint8_t reserved[8]; /*!< Reserved bytes in additional header space, currently unused */
uint8_t hash_appended; /*!< If 1, a SHA256 digest "simple hash" (of the entire image) is appended after the checksum.
* Included in image length. This digest
* is separate to secure boot and only used for detecting corruption.
* For secure boot signed images, the signature
* is appended after this (and the simple hash is included in the signed data). */
} __attribute__((packed)) esp_image_header_t;

在 ESP32 这款 MCU 里,magic 固定是 0xE9,在 C 或 S 系列下可能不一样,我们可以通过 hexdump 一个编译好的 bin 文件信息:

 hexdump -Cn 32 test.bin

输出如下:

1
2
3
00000000  e9 02 02 10 24 04 08 40  ee 00 00 00 00 00 00 00  |�...$..@�.......|
00000010 00 00 00 00 00 00 00 01 00 00 ff 3f 1c 00 00 00 |..........�?....|
00000020

上一期在 51 烧录部分我们使用了自己的烧录工具 burnit,这个工具其实也支持 ESP32 的烧录,但是没有进行 ELF 转 ESP32 镜像格式的功能,这个等后续有时间再加入。这一期我们就直接用乐鑫官方的工具来进行操作吧,环境安装的话参考官网,这里就不赘述了。可以通过以下命令将 ELF 转为 ESP32 支持的镜像 (Flash 相关参数根据自己的芯片自行调整):

esptool.py --chip esp32 elf2image \
           --flash_mode dio       \
           --flash_freq 40m       \
           --flash_size 2MB       \
           -o <输出的BIN文件> <输入的ELF文件>

链接脚本

有了上述工具,我们将直接编译出来的 ELF 转成 BIN 文件的话,ESP32 依然是无法正确执行的,因为需要正确的内存布局。所谓内存布局,就是代码也从哪里开始运行,以及最终要被加载到哪个位置,这是需要在链接阶段进行确定的,而要能指定正确的位置我们还需要参考官方手 IDF 的 bootloader 来:

  • 内部 SRAM0 区域的一部分分配为指令 RAM。除了开始的 64kB 用作 PRO CPU 和 APP CPU 的高速缓存外,剩余内存区域(从 0x400800000x400A0000 )被用来存储应用程序中部分需要在RAM中运行的代码。
  • 链接器将非常量静态数据和零初始化数据放入 0x3FFB00000x3FFF0000 这 256kB 的区域。注意,如果使用蓝牙堆栈,此区域会减少 64kB(通过将起始地址移至 0x3FFC0000 )

还那我们可以将指令存储在 SRAM 里的:0x40080000 - 0x400A0000 这个区域,静态常量和数据存储在 0x3FFB0000 - 0x3FFF0000 这个区域 (没有使用蓝牙和内存追踪)。注意这里指定的都是程序运行时的地址,我们最终的程序还是烧录到 Flash 的 0x1000 这个偏移地址,那最终程序是怎么跑到这些指定的地址上了呢?这就是一级引导程序干的事情了 (搬运工)。确定了这些地址,我们就可以构建链接脚本了,链接脚本的语法这里不展开,涉及到的面比较多 (主要是编译后的代码段如何分配),没有接触过的话,可以自行搜索学习下,以下是我们的链接脚本:

esp32.lds
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
MEMORY
{
iram_seg (RWX): org = 0x40080000, len = 0x20000
dram_seg (RW) : org = 0x3FFB0000, len = 0x40000
}

ENTRY(main);

SECTIONS
{
.text : {
*(.literal .text .literal.* .text.* .stub .gnu.warning .gnu.linkonce.literal.* .gnu.linkonce.t.*.literal .gnu.linkonce.t.*)
*(.fini.literal)
*(.fini)
*(.gnu.version)
. += 16;
} > iram_seg

.rodata : {
*(.rodata)
*(.rodata.*)
*(.gnu.linkonce.r.*)
*(.rodata1)
*(*.lit4)
*(.lit4.*)
*(.gnu.linkonce.lit4.*)
} > dram_seg

.data : {
*(.data)
*(.data.*)
*(.gnu.linkonce.d.*)
*(.data1)
*(.sdata)
*(.sdata.*)
*(.gnu.linkonce.s.*)
*(.sdata2)
*(.sdata2.*)
*(.gnu.linkonce.s2.*)
*(.jcr)
} > dram_seg

.bss : {
. = ALIGN (8);
_bss_start = ABSOLUTE(.);
*(.dynsbss)
*(.sbss)
*(.sbss.*)
*(.gnu.linkonce.sb.*)
*(.scommon)
*(.sbss2)
*(.sbss2.*)
*(.gnu.linkonce.sb2.*)
*(.dynbss)
*(.bss)
*(.bss.*)
*(.gnu.linkonce.b.*)
*(COMMON)
. = ALIGN (8);
_bss_end = ABSOLUTE(.);
} > dram_seg
}

这里指定了 main 为首先执行的地址,也就是对应我们的 main 函数,当然如果你觉得 main 太普通了,你完全可以换成其它高大上的名称,这样入口函数就不是 main 了。另外值得一提的是我们这个链接脚本不支持 C++,要想支持 C++ 的话需要一些额外的段配置,且需要在程序运行初期进行 C++ 相关环境的布建,有兴趣的话,可以参考乐鑫的 bootloader 实现。

Makefile

为了方便编译运行,我们还是在写一个 Makefile 吧,这样也算回归古老传统的 GNU 开发模式了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CROSS_COMPILER_PREFIX := xtensa-esp32-elf
CC := $(CROSS_COMPILER_PREFIX)-gcc
LD := $(CROSS_COMPILER_PREFIX)-ld
OBJDUMP := $(CROSS_COMPILER_PREFIX)-objdump

FNAME := # 这里填写最终生成的文件名
OBJS += # 这里填写需要参与链接的 .o 文件

$(FNAME).bin: $(FNAME).elf
esptool.py --chip esp32 elf2image --flash_mode dio --flash_freq 40m --flash_size 2MB -o $@ $^

$(FNAME).elf: $(OBJS)
$(LD) -Map $(FNAME).map -Tesp32.lds -o $@ $^
$(OBJDUMP) -D $@ > $(FNAME).dis

%.o: %.c
$(CC) -Wall -nostdlib -O2 -c -o $@ $<

upload: $(FNAME).bin
esptool.py --chip esp32 --baud 115200 --before default_reset --after hard_reset write_flash -z --flash_mode dio --flash_freq 40m --flash_size detect 0x1000 $(FNAME).bin

clean:
@rm -rf *.o *.elf *.dis *.bin *.map

可以看到,在 upload 这条规则里,我们也是使用乐鑫官方的 esptool.py 工具,并指定了 Flash 地址为 0x1000。另外我们还生成了文件内存布局的 .map 描述文件,以及反编译后的 .dis 文件,用于查看配置是否正常。链接时,我们通过 -T 参数指定了链接脚本为 esp32.lds。到此,我们的裸机编译环境就构建完成了,下面可以开始着手写些代码了。

构建运行环境

看到这一小节的标题时,是不是有点讶异?还要构建啥运行环境?对,我们要构建下 C 语言和裸机程序的运行环境。对于 C 语言而言,一级引导程序帮助我们干掉了很多事情,比如栈指针的初始化、内存布局、处理器模式等,这些在后续点灯其它 MCU 时,我们可是需要自己手动操作的,这也是为什么我选择了 ESP32 作为第二季,不至于一次性干太多复杂的事情。对于 ESP32 而言,我们只要手动清理下 .bss 段,理论上就不需要做其它 C 语言相关环境布建的工作了。

前面说过了,SoC 复位后开启了看门狗,所谓的看门狗可以认为就是一个硬件模块,程序正常运行时我们需要不断的喂狗 (通过操作喂狗寄存器),一段时间不喂狗的话,它就认为软件跑飞了,然后进行重启。不断喂狗比较麻烦,为了不被看门狗重启,我们需要手动关闭掉看门狗。

寄存器的操作方式

在开始写代码前,因为之前并没有提及如何对寄存器进行读写,这里简单介绍两种方式。上一季已经说明过统一编址独立编址了,现在大多数 MCU 都是采用了统一编址,所以操作它的寄存器就和访问主存的方式一直,比如我们查询手册后获取了一个寄存器地址为 0x22332233,进行读写操作的话,就可以直接转成一个 32位的指针进行:

1
2
3
volatile unsigned int *reg = (volatile unsigned int *)(0x22332233);
int v = *reg; // 读
*reg = 2233; // 写

注意这里加上了 volatile,避免编译器优化缓存,而造成未真实的进行内存访问操作。一般常见的还有定义成宏的使用方式,具体如下:

1
2
3
4
#define REG *(volatile unsigned int *)(0x22332233)

int v = REG; // 读
REG = 2233; // 写

除此之外,通过查阅手册不难发现,相关联的寄存器基本都定义在连续的地址空间了,那么我们就可以用结构体来描述一组相关联的寄存器了,大致模式如下:

1
2
3
4
5
6
7
8
#define XXX_REG_BASE (0x22332233)
#define XXX_REG ((XXX_REG_Def *)XXX_REG_BASE)

typedef struct {
volatile unsigned int *REG1;
volatile unsigned int *REG2;
volatile unsigned int *REG3;
} XXX_REG_Def;

按照上述的定义,我们访问寄存器的方式就是这样了:

1
2
int v = XXX_REG->REG1; // 读
XXX_REG->REG1 = 2233; // 写

清理 .bss 段

main 这个入口函数开始,我们先清理下 .bss 段的数据,这里存放的是零初始化数据,比如 C 语言中的全局变量,清理方式比较简单,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extern int _bss_start;
extern int _bss_end;

static void bss_reset(void) {
int *start = &_bss_start;
while (start != &_bss_end) {
*start = 0;
start++;
}
}

int main(void) {
bss_reset();
...

这里 extern 出来的两个变量,是我们在链接脚本 (esp32.lds) 里指定的,它们记录了整个 .bss 段的起始和截止位置,这边简单的遍历置零即可。

关闭看门狗

这里一共有两条看门狗需要关闭,参考前面的内容:

在 Flash 启动过程中,定时器组 0(TIMG0)中的 MWDT 和 RWDT 自动使能。两个看门狗定时器的阶段 0 默认为在超时后复位系统

那么涉及到两组寄存器,按照手册的定义,我这边整理出了两个结构体,为了代码好看,这里还做了些预定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef unsigned char       uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;
typedef signed char int8_t;
typedef signed short int16_t;
typedef signed int int32_t;
typedef signed long long int64_t;

#define CONTACT(x, y) _CONTACT(x, y)
#define _CONTACT(x, y) x ## y

#define __REG volatile
#define __REG_RESERVE(count) __REG uint32_t CONTACT(__RESERVE, __LINE__) [count]

以下则是两组寄存器的结构体了:

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
#define RTC_CNTL_BASE       (0x3FF48000)
#define TIMG0_BASE (0x3FF5F000)

#define RTC_CNTL ((RTC_CNTL_Def *)RTC_CNTL_BASE)
#define TIMG0 ((TIMG_Def *)TIMG0_BASE)

typedef struct {
__REG uint32_t OPTIONS0; // 0x3FF48000
__REG uint32_t SLP_TIMER0; // 0x3FF48004
__REG uint32_t SLP_TIMER1; // 0x3FF48008
__REG uint32_t TIME_UPDATE; // 0x3FF4800C
__REG uint32_t TIME0; // 0x3FF48010
__REG uint32_t TIME1; // 0x3FF48014
__REG uint32_t STATE0; // 0x3FF48018
__REG uint32_t TIMER1; // 0x3FF4801C
__REG uint32_t TIMER2; // 0x3FF48020
__REG_RESERVE(2); // 0x3FF48024 - 0x3FF48028
__REG uint32_t TIMER5; // 0x3FF4802C
__REG uint32_t ANA_CONF; // 0x3FF48030
__REG uint32_t RESET_STATE; // 0x3FF48034
__REG uint32_t WAKEUP_STATE; // 0x3FF48038
__REG uint32_t INT_ENA; // 0x3FF4803C
__REG uint32_t INT_RAW; // 0x3FF48040
__REG uint32_t INT_ST; // 0x3FF48044
__REG uint32_t INT_CLR; // 0x3FF48048
__REG uint32_t STORE0; // 0x3FF4804C
__REG uint32_t STORE1; // 0x3FF48050
__REG uint32_t STORE2; // 0x3FF48054
__REG uint32_t STORE3; // 0x3FF48058
__REG uint32_t EXT_XTL_CONF; // 0x3FF4805C
__REG uint32_t EXT_WAKEUP_CONF; // 0x3FF48060
__REG uint32_t SLP_REJECT_CONF; // 0x3FF48064
__REG uint32_t CPU_PERIOD_CONF; // 0x3FF48068
__REG_RESERVE(1); // 0x3FF4806C
__REG uint32_t CLK_CONF; // 0x3FF48070
__REG uint32_t SDIO_CONF; // 0x3FF48074
__REG_RESERVE(1); // 0x3FF48078
__REG uint32_t VREG; // 0x3FF4807C
__REG uint32_t PWC; // 0x3FF48080
__REG uint32_t DIG_PWC; // 0x3FF48084
__REG uint32_t DIG_ISO; // 0x3FF48088
__REG uint32_t WDTCONFIG0; // 0x3FF4808C
__REG uint32_t WDTCONFIG1; // 0x3FF48090
__REG uint32_t WDTCONFIG2; // 0x3FF48094
__REG uint32_t WDTCONFIG3; // 0x3FF48098
__REG uint32_t WDTCONFIG4; // 0x3FF4809C
__REG uint32_t WDTFEED; // 0x3FF480A0
__REG uint32_t WDTWPROTECT; // 0x3FF480A4
__REG_RESERVE(1); // 0x3FF480A8
__REG uint32_t SW_CPU_STALL; // 0x3FF480AC
__REG uint32_t STORE4; // 0x3FF480B0
__REG uint32_t STORE5; // 0x3FF480B4
__REG uint32_t STORE6; // 0x3FF480B8
__REG uint32_t STORE7; // 0x3FF480BC
__REG uint32_t LOW_POWER_ST; // 0x3FF480C0
__REG_RESERVE(1); // 0x3FF480C4
__REG uint32_t HOLD_FORCE; // 0x3FF480C8
__REG uint32_t EXT_WAKEUP1; // 0x3FF480CC
__REG uint32_t EXT_WAKEUP1_STATUS; // 0x3FF480D0
__REG uint32_t BROWN_OUT; // 0x3FF480D4
} __attribute__((packed)) RTC_CNTL_Def;

typedef struct {
__REG uint32_t T0CONFIG; // 000
__REG uint32_t T0LO; // 004
__REG uint32_t T0HI; // 008
__REG uint32_t T0UPDATE; // 00C
__REG uint32_t T0ALARMLO; // 010
__REG uint32_t T0ALARMHI; // 014
__REG uint32_t T0LOADLO; // 018
__REG_RESERVE(1); // 01C
__REG uint32_t T0LOAD; // 020
__REG uint32_t T1CONFIG; // 024
__REG uint32_t T1LO; // 028
__REG uint32_t T1HI; // 02C
__REG uint32_t T1UPDATE; // 030
__REG uint32_t T1ALARMLO; // 034
__REG uint32_t T1ALARMHI; // 038
__REG uint32_t T1LOADLO; // 03C
__REG_RESERVE(1); // 040
__REG uint32_t T1LOAD; // 044
__REG uint32_t WDTCONFIG0; // 048
__REG uint32_t WDTCONFIG1; // 04C
__REG uint32_t WDTCONFIG2; // 050
__REG uint32_t WDTCONFIG3; // 054
__REG uint32_t WDTCONFIG4; // 058
__REG uint32_t WDTCONFIG5; // 05C
__REG uint32_t WDTFEED; // 060
__REG uint32_t WDTWPROTECT; // 064
__REG uint32_t RTCCALICFG; // 068
__REG uint32_t RTCCALICFG1; // 06C
} __attribute__((packed)) TIMG_Def;

通过查阅手册,操作关闭看门狗寄存器前,需要先解除写保护,也就是对写保护寄存器写入一个特殊值:0x050D83AA1,具体关闭的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void wdt_close(void) {
// close RTC watchdog
RTC_CNTL->WDTWPROTECT = 0x050D83AA1;
RTC_CNTL->WDTCONFIG0 = 0;
RTC_CNTL->WDTWPROTECT = 0;

// close TIMG0 watchdog
TIMG0->WDTWPROTECT = 0x050D83AA1;
TIMG0->WDTCONFIG0 = 0;
TIMG0->WDTWPROTECT = 0;
}

int main(void) {
bss_reset();
wdt_close();
...

至此,我们的运行环境也布建完成了,为了验证我们的运行环境是否正常,我们可以简单来进行一下测试。

运行环境测试

前面部分有提到过,一级引导程序,也就是那段固化的代码,除了引导之外,还有一些实用方法可以使用,这些方法定义在 IDF 框架代码的 esp32.rom.ld 这个链接脚本文件里。我们挑选了延时以及串口打印函数来进行使用,在我们的 esp32.lds 链接文件里增加以下配置:

1
2
3
PROVIDE ( ets_printf = 0x40007d54 );
PROVIDE ( ets_delay_us = 0x40008534 );
PROVIDE ( ets_update_cpu_frequency_rom = 0x40008550 );

让后再在相关头文件里将这些方法的原型定义出来:

1
2
3
int ets_printf(const char *fmt, ...);
void ets_update_cpu_frequency_rom(uint32_t ticks_per_us);
void ets_delay_us(uint32_t us);

要使用延时函数的话,这里需要配置下 CPU 的频率,以及测试代码,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
int main(void) {
bss_reset();
wdt_close();
ets_update_cpu_frequency_rom(80);

while (1) {
ets_delay_us(1000 * 1000);
ets_printf("hello esp32, hello makee !\n");
}

return 0;
}

我们将其 make upload 到开发板之后,可以通过 screen 命令进入串口,查看打印情况:

screen /dev/cu.usbmodem533A0265041 115200

串口的地址,请跟进实际情况来更改,如果一切正常的话,可以看到以下这样的输出,并且大约是每秒一次:

如此看来,初步验证是没有什么问题了,按下 ctrl + A 再按下 ctrl + K 退出串口终端,接下来就是激动人心的点灯时刻了。

裸机点灯

在正式点灯前,我们还是要看看今天要点的灯的硬件连接情况,主要是为了确定是通过哪个 IO 来进行操作。

原理图

这次要点的灯还比较高级,竟然是个 RGB 三色灯且有个外围驱动芯片 WS2812!通过原理图可以看到控制管脚接入到了 IO16,那么理论上我们通过这个 IO 相关寄存器就可以操作了。

GPIO

在了解 WS2812 驱动前,我们先将需要的 IO 配置好,我这边也参考手册把 GPIO 相关的寄存器定义好了:

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
#define GPIO_BASE   (0x3FF44004)
#define GPIO ((GPIO_Def *)GPIO_BASE)

typedef struct {
__REG uint32_t OUT; // 0x3FF44004
__REG uint32_t OUT_W1TS; // 0x3FF44008
__REG uint32_t OUT_W1TC; // 0x3FF4400C
__REG uint32_t OUT1; // 0x3FF44010
__REG uint32_t OUT1_W1TS; // 0x3FF44014
__REG uint32_t OUT1_W1TC; // 0x3FF44018
__REG_RESERVE(1); // 0x3FF4401C
__REG uint32_t ENABLE; // 0x3FF44020
__REG uint32_t ENABLE_W1TS; // 0x3FF44024
__REG uint32_t ENABLE_W1TC; // 0x3FF44028
__REG uint32_t ENABLE1; // 0x3FF4402C
__REG uint32_t ENABLE1_W1TS; // 0x3FF44030
__REG uint32_t ENABLE1_W1TC; // 0x3FF44034
__REG uint32_t STRAP; // 0x3FF44038
__REG uint32_t IN; // 0x3FF4403C
__REG uint32_t IN1; // 0x3FF44040
__REG uint32_t STATUS; // 0x3FF44044
__REG uint32_t STATUS_W1TS; // 0x3FF44048
__REG uint32_t STATUS_W1TC; // 0x3FF4404C
__REG uint32_t STATUS1; // 0x3FF44050
__REG uint32_t STATUS1_W1TS; // 0x3FF44054
__REG uint32_t STATUS1_W1TC; // 0x3FF44058
__REG_RESERVE(1); // 0x3FF4405C
__REG uint32_t ACPU_INT; // 0x3FF44060
__REG uint32_t ACPU_NMI_INT; // 0x3FF44064
__REG uint32_t PCPU_INT; // 0x3FF44068
__REG uint32_t PCPU_NMI_INT; // 0x3FF4406C
__REG_RESERVE(1); // 0x3FF44070
__REG uint32_t ACPU_INT1; // 0x3FF44074
__REG uint32_t ACPU_NMI_INT1; // 0x3FF44078
__REG uint32_t PCPU_INT1; // 0x3FF4407C
__REG uint32_t PCPU_NMI_INT1; // 0x3FF44080
__REG_RESERVE(1); // 0x3FF44084
__REG uint32_t GPIO_PIN[40]; // 0x3FF44088 - 0x3FF44124
__REG_RESERVE(2); // 0x3FF44128 - 0x3FF4412C
__REG uint32_t FUNC_IN_SEL_CFG[256]; // 0x3FF44130 - 0x3FF4452C
__REG uint32_t FUNC_OUT_SEL_CFG[40]; // 0x3FF44530 - 0x3FF445CC
} __attribute__((packed)) GPIO_Def;

既然这个 IO16 最终是操控 WS2812 的,我们就新建一个 ws2812.h 和 ws2812.c 文件来进行相关代码编写吧。查阅寄存器手册,要初始化 IO 为输出模式,可以通过简单输出模式进行配置,操作以下两个寄存器即可:

1
2
3
4
5
6
7
8
#define RGB_PIN (16)

void ws2812_init(void) {
// 配置 GPIO 输出使能置位寄存器
GPIO->ENABLE_W1TS = (1 << RGB_PIN);
// 配置 GPIO 外设输出选择寄存器
GPIO->FUNC_OUT_SEL_CFG[RGB_PIN] = 0x100;
}

那么 GPIO 的输出比较简单,也就是高电平和低电平,通过 ESP32 的 GPIO 输出置位寄存器输出清零寄存器即可:

1
2
3
4
// 操作置位寄存器,输出 高电平
GPIO->OUT_W1TS = (1 << RGB_PIN);
// 操作清零寄存器,输出 低电平
GPIO->OUT_W1TC = (1 << RGB_PIN);

注意,上面操作的寄存器,控制的是 0 - 31 号 IO,也就是相应的位操作即可控制相应的 IO,所以会有对应的移位操作。万事俱备了,就差怎么点亮这款高级的 RGB 灯了!

WS2812 时序

对于和外围芯片进行通讯,我们经常会提到时序,那么什么是时序呢?首先我们从原理图中可以看到,这款芯片只靠一个 IO 就可以进行控制,如果高低电平分别表示 1 和 0 的话,那么一个 IO 只能表现出两种状态 0 或则 1,显然一个 RGB 颜色的灯,靠这两种状态肯定是不够的,那么怎么表现出更多状态呢?这就要引入另外一个分量,也就是时间,有了这个分量,我们就可以表现出无限的状态。比如我们将一个时间 T 划分成 8 份: t0-t7,在 t 这个时间段,如果 IO 电平为低则代表 0、IO 电平为高则代表 1,那么整个时间 T 内,我们就可以构造一个字节的数据 (8 位)。这就是大多数通讯协议的原理,比如串口、I2C、I2S、SPI 等,无非是其它的协议可能会多出一些时钟线和数据线,用来更快更精准的传输数据。在购买相关外围芯片时,厂家基本都会提供通讯的时序图,下面是 WS2812 的时序图:

简单来看,我们要输出 0 的话,就是在 TOH 这个时间段高电平,然后再在 T0L 这个时间段持续低电平,输出 1 的话类似。ws2812 的翻转时间比较短,在纳秒级别了,在 ESP32 里,我们可以通过空指令来实现一个大约的时间,于是写 1 和 写 0 的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define NOP do { __asm__ __volatile__ ("nop"); } while (0)

static void ws2812_delay(short num) {
for (short j = 0; j < num; j++)
NOP;
}

static void ws2812_write_zero(void) {
GPIO->OUT_W1TS = (1 << RGB_PIN);
ws2812_delay(6);
GPIO->OUT_W1TC = (1 << RGB_PIN);
ws2812_delay(32);
}

static void ws2812_write_one(void) {
GPIO->OUT_W1TS = (1 << RGB_PIN);
ws2812_delay(32);
GPIO->OUT_W1TC = (1 << RGB_PIN);
ws2812_delay(6);
}

有了写 0 和写 1 还不够,要想点亮 RGB,还要按照 ws2812 的芯片描述输出更多的数据:

可以看到,这里需要按照 GRB 的方式发送 24 位数据,那么也很简单,通过上面的方法在封装一下即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void ws2812_write_byte(uint8_t byte) {
for (int i = 0; i < 8; i++) {
if (byte & 0x80) {
ws2812_write_one();
} else {
ws2812_write_zero();
}
byte <<= 1;
}
}

void ws2812_write_rgb(uint8_t r, uint8_t g, uint8_t b) {
ws2812_write_byte(g);
ws2812_write_byte(r);
ws2812_write_byte(b);
}

那么我们的 ws2812 驱动就完成了,可以点灯了!!!

点灯效果

直接在 main 方法的起始地方进行调用即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
int main(void) {
bss_reset();
wdt_close();
ets_update_cpu_frequency_rom(80);

ws2812_init();
ws2812_write_rgb(0xFF, 0, 0); // 红色

while (1) {
ets_delay_us(1000 * 1000);
ets_printf("hello esp32, hello makee !\n");
}
}

上面的示例代码里,我们点成了红色,但你可以进行自行修改,理论上可以合成的颜色还是比较丰富的,下面是最基础的 R、G、B 三原色效果:

如果觉得刺眼的话,可以把色值相应的调低一点。那么这是我为你们点亮的第二盏灯,这是一个五彩缤纷的灯,也像这五彩的世界,期望你们不要在这五彩的世界里,迷失了那个单纯的自己。

IDF 点灯

裸机点完了,那么使用 IDF 这种完备的框架来点灯的话,就要简单太多、太多了,具体直接看代码即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
void ws2812_init(void) {
gpio_pad_select_gpio(RGB_PIN);
gpio_reset_pin(RGB_PIN);
gpio_set_direction(RGB_PIN, GPIO_MODE_OUTPUT);
}

// ... 其余代码与寄存器版本一致

void app_main(void) {
ws2812_init();
ws2812_write_rgb(0xFF, 0, 0);
while (1);
}

可以看到,除了 GPIO 的初始化,其它操作这里还是用了寄存器,因为使用框架的 gpio_set_level 来操作的话,时序上达不到 ws2812 的纳秒级别要求。当然,除了使用 GPIO,常规的驱动方式还是使用 PWM 波来,不过这不在我们这里的讨论范围了,使用 PWM 的话,相比于操作寄存器,框架层的封装就简单太多了。

WOKWI 在线点灯

最后,我们来一次真正的在线点灯,可以打开网页:

https://wokwi.com

这里有一个可以在线仿真的编辑器,我们选择 ESP32 后,然后添加一下 FreeRTOS库,再在右侧连接一个简单的电路如下:

就可以使用 Arduino 框架进行在线点灯了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void task1(void *pt) {
pinMode(12, OUTPUT);
while(1) {
digitalWrite(12, !digitalRead(12));
vTaskDelay(800);
}
}

void setup() {
// put your setup code here, to run once:
Serial.begin(115200);

xTaskCreate(task1, "LED BLINK", 1024, NULL, 1, NULL);

}

void loop() {

}

如果没有异常,会看到这个灯一闪一闪的,可以直接打开这个地址查看配置和效果:

https://wokwi.com/projects/337946979076145746

关于 Arduino,这也是一个比较知名和强大的体系,使用起来很简单,但要细究其底层的封装,还是比较复杂的,有兴趣的可以自行去搜索学习!而 FreeRTOS 是一款现在已经非常流行的实时操作系统,提供了任务、队列、信号等功能,可以方便的构建出更健壮、通用的产品,这也是一个比较庞大的体系,这里还是点到为止,感兴趣的可以自行学习!

总结

今天的内容还是比较多的,那么我们稍微总结一下,本篇所涉及到或点到为止的内容:

  1. 启动引导流程
  2. 应用内存布局
  3. 链接脚本
  4. 寄存器操作方式
  5. 裸机实现 ws2812 时序
  6. Arduino、RTOS

结束的有点仓促,想要总结的东西太多,但看了下时间也不早了,后面有时间我们再慢慢聊。无论如何,今天的灯亮了,这心也就定了。