怕什么真理无穷,进一寸有一寸的欢喜。
在不间断的核酸和反思中又过去了一周,看了看修炼 点灯大师 的路途,还很遥远,本打算就这样浑浑噩噩的度过这个周末,但心中的灯还亮着,手上的灯又有什么理由不让它亮起来呢?
上一期我们探讨了下古老的 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,那么它现代化在哪呢?
- 从 8 位到 32 位,拥有更为广泛的编址空间,寄存器数量庞大。寄存器也变为了 32 位,单个寄存器的可配置项更多
- 从 单核 到 双核,更为复杂的启动逻辑,并行计算成为可能
- 从 裸机 到 系统,ESP32 支持实时操作系统,使用起来更方便,但底层逻辑更复杂
- 从 双向 到 单向,这是从 GPIO 的角度而言,在 51 的时代,GPIO 任何时候都是双向的,也就是即是输入也是输出 (这会造成一些困扰),而 ESP32 和一些其他更为现代化的 MCU 一样,需要进行配置是输入输出模式
总体而言,强大了很多,也复杂了很多。这也让我们的点灯方式上有了更多选择,本篇将用以下几种方式来进行点灯:
- 类似与 51 单片机的纯寄存器点灯
- 使用官方的 IDF 框架来进行点灯
- 使用 Arduino 框架来进行点灯
那么最贴近底层,也最为复杂的就是第一种点灯方式了,既然是修炼点灯,这是不可或缺的。这种纯寄存器的开发方式,在嵌入式开发中又称之为 裸机开发,所谓的裸机,就是指没有任何操作系统的情况下。
启动流程分析
在任何 MCU、CPU 进行裸机开发的第一步,都是需要了解下整个芯片是怎么运作起来的,也就是从上电到程序运行起来的整个流程。这在不同的 SoC 下不太一样,但总体而言都大同小异,也就是了解了 ESP32 的启动逻辑,对了解其它更为复杂的 CPU 启动逻辑也是有一定帮助的。
从宏观上 (也就是基于官方的 IDF 开发流程),ESP32 启动流程可以分为如下 3 个步骤:
- 一级引导程序被固化在了 ESP32 内部的 ROM 中,它会从 Flash 的
0x1000
偏移地址处加载二级引导程序至 RAM(IRAM & DRAM) 中 - 二级引导程序从 Flash 中加载分区表和主程序镜像至内存中,主程序中包含了 RAM 段和通过 Flash 高速缓存映射的只读段
- 主程序运行,这时第二个 CPU 和 RTOS 的调度器可以开始运行
标准流程里包含了两次引导和一些初始化过程,相对而言还是比较简单的。为了和官方参考手册中一致,我们也将 ESP32 的两个处理器内核分为 PRO CPU 和 APP CPU,需要注意的是,整个启动过程中只有 PRO CPU 在工作,到达第三步时才开启了 APP CPU。
首先是一级引导程序,这个是乐鑫官方固化在 ROM 里了,也就是说我们是没有源码也无法修改的。SoC 复位以后,PRO CPU 会立即开始运行,执行复位向量代码,复位向量代码位于 ESP32 芯片掩膜 ROM 的 0x40000400
地址处,该地址不能被修改。复位向量里的代码,会通过检测相关寄存器来判断复位源,从而进入不同的启动模式,比如进入下载模式 (可通过串口烧录二进制文件到 MCU 里),如果是常规的软件复位或看门狗复位,则会从 Flash 的 0x1000
偏移地址处加载二进制镜像,这里通常就是放置二级引导程序的位置了。当然,一级引导程序还做了些额外的事情:
- 初始化了栈指针 sp,所以我们可以直接用 C 语言了
- 开启了看门狗:在 Flash 启动过程中,定时器组 0(TIMG0)中的 MWDT 和 RWDT 自动使能。两个看门狗定时器的阶段 0 默认为在超时后复位系统
- 包含了一些实用的 C 函数,我们可以将其导出后直接使用
再看看二级引导程序,这段程序是开源的,也可以说是传统意义上的 bootloader 了,二级引导程序的代码在 IDF 框架的:components/bootloader 以及 components/bootloader_support 这两个文件夹里。可以说 bootloader 就是一个比较复杂的裸机程序,所以我们要编写的裸机程序完全可以参照这个 bootloader 来。
构建编译环境
那么问题就很简单了,一级引导在正常模式下,会从 Flash 的 0x1000
加载二进制镜像,那么我们将裸机程序放在这个位置岂不是就能自动执行了?大致是这样,但又不能直接这样,因为我们最终会通过交叉编译器来编译代码,现代化的嵌入式开发基本都是使用 GNU 那一套 GCC 交叉编译,直接编译出来的产物是 ELF 格式的文件,这个文件是不能被 ESP32 直接加载执行的。ESP32 能加载执行的文件格式定义在了 esp_app_format.h
这个头文件里,也比较简单,主要包含文件头信息以及其它节点布局信息,头信息的结构体如下:
1 | typedef struct { |
在 ESP32 这款 MCU 里,magic
固定是 0xE9
,在 C 或 S 系列下可能不一样,我们可以通过 hexdump
一个编译好的 bin 文件信息:
hexdump -Cn 32 test.bin
输出如下:
1 | 00000000 e9 02 02 10 24 04 08 40 ee 00 00 00 00 00 00 00 |�...$..@�.......| |
上一期在 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 的高速缓存外,剩余内存区域(从
0x40080000
至0x400A0000
)被用来存储应用程序中部分需要在RAM中运行的代码。- 链接器将非常量静态数据和零初始化数据放入
0x3FFB0000
—0x3FFF0000
这 256kB 的区域。注意,如果使用蓝牙堆栈,此区域会减少 64kB(通过将起始地址移至 0x3FFC0000 )
还那我们可以将指令存储在 SRAM 里的:0x40080000
- 0x400A0000
这个区域,静态常量和数据存储在 0x3FFB0000
- 0x3FFF0000
这个区域 (没有使用蓝牙和内存追踪)。注意这里指定的都是程序运行时的地址,我们最终的程序还是烧录到 Flash 的 0x1000
这个偏移地址,那最终程序是怎么跑到这些指定的地址上了呢?这就是一级引导程序干的事情了 (搬运工)。确定了这些地址,我们就可以构建链接脚本了,链接脚本的语法这里不展开,涉及到的面比较多 (主要是编译后的代码段如何分配),没有接触过的话,可以自行搜索学习下,以下是我们的链接脚本:
1 | MEMORY |
这里指定了 main
为首先执行的地址,也就是对应我们的 main
函数,当然如果你觉得 main
太普通了,你完全可以换成其它高大上的名称,这样入口函数就不是 main
了。另外值得一提的是我们这个链接脚本不支持 C++,要想支持 C++ 的话需要一些额外的段配置,且需要在程序运行初期进行 C++ 相关环境的布建,有兴趣的话,可以参考乐鑫的 bootloader 实现。
Makefile
为了方便编译运行,我们还是在写一个 Makefile 吧,这样也算回归古老传统的 GNU 开发模式了:
1 | CROSS_COMPILER_PREFIX := xtensa-esp32-elf |
可以看到,在 upload
这条规则里,我们也是使用乐鑫官方的 esptool.py
工具,并指定了 Flash 地址为 0x1000
。另外我们还生成了文件内存布局的 .map 描述文件,以及反编译后的 .dis 文件,用于查看配置是否正常。链接时,我们通过 -T
参数指定了链接脚本为 esp32.lds
。到此,我们的裸机编译环境就构建完成了,下面可以开始着手写些代码了。
构建运行环境
看到这一小节的标题时,是不是有点讶异?还要构建啥运行环境?对,我们要构建下 C 语言和裸机程序的运行环境。对于 C 语言而言,一级引导程序帮助我们干掉了很多事情,比如栈指针的初始化、内存布局、处理器模式等,这些在后续点灯其它 MCU 时,我们可是需要自己手动操作的,这也是为什么我选择了 ESP32 作为第二季,不至于一次性干太多复杂的事情。对于 ESP32 而言,我们只要手动清理下 .bss 段,理论上就不需要做其它 C 语言相关环境布建的工作了。
前面说过了,SoC 复位后开启了看门狗,所谓的看门狗可以认为就是一个硬件模块,程序正常运行时我们需要不断的喂狗 (通过操作喂狗寄存器),一段时间不喂狗的话,它就认为软件跑飞了,然后进行重启。不断喂狗比较麻烦,为了不被看门狗重启,我们需要手动关闭掉看门狗。
寄存器的操作方式
在开始写代码前,因为之前并没有提及如何对寄存器进行读写,这里简单介绍两种方式。上一季已经说明过统一编址和独立编址了,现在大多数 MCU 都是采用了统一编址,所以操作它的寄存器就和访问主存的方式一直,比如我们查询手册后获取了一个寄存器地址为 0x22332233
,进行读写操作的话,就可以直接转成一个 32位的指针进行:
1 | volatile unsigned int *reg = (volatile unsigned int *)(0x22332233); |
注意这里加上了 volatile
,避免编译器优化缓存,而造成未真实的进行内存访问操作。一般常见的还有定义成宏的使用方式,具体如下:
1 |
|
除此之外,通过查阅手册不难发现,相关联的寄存器基本都定义在连续的地址空间了,那么我们就可以用结构体来描述一组相关联的寄存器了,大致模式如下:
1 |
|
按照上述的定义,我们访问寄存器的方式就是这样了:
1 | int v = XXX_REG->REG1; // 读 |
清理 .bss 段
在 main
这个入口函数开始,我们先清理下 .bss 段的数据,这里存放的是零初始化数据,比如 C 语言中的全局变量,清理方式比较简单,代码如下:
1 | extern int _bss_start; |
这里 extern
出来的两个变量,是我们在链接脚本 (esp32.lds) 里指定的,它们记录了整个 .bss 段的起始和截止位置,这边简单的遍历置零即可。
关闭看门狗
这里一共有两条看门狗需要关闭,参考前面的内容:
在 Flash 启动过程中,定时器组 0(TIMG0)中的 MWDT 和 RWDT 自动使能。两个看门狗定时器的阶段 0 默认为在超时后复位系统
那么涉及到两组寄存器,按照手册的定义,我这边整理出了两个结构体,为了代码好看,这里还做了些预定义:
1 | typedef unsigned char uint8_t; |
以下则是两组寄存器的结构体了:
1 |
|
通过查阅手册,操作关闭看门狗寄存器前,需要先解除写保护,也就是对写保护寄存器写入一个特殊值:0x050D83AA1
,具体关闭的代码如下:
1 | static void wdt_close(void) { |
至此,我们的运行环境也布建完成了,为了验证我们的运行环境是否正常,我们可以简单来进行一下测试。
运行环境测试
前面部分有提到过,一级引导程序,也就是那段固化的代码,除了引导之外,还有一些实用方法可以使用,这些方法定义在 IDF 框架代码的 esp32.rom.ld
这个链接脚本文件里。我们挑选了延时以及串口打印函数来进行使用,在我们的 esp32.lds
链接文件里增加以下配置:
1 | PROVIDE ( ets_printf = 0x40007d54 ); |
让后再在相关头文件里将这些方法的原型定义出来:
1 | int ets_printf(const char *fmt, ...); |
要使用延时函数的话,这里需要配置下 CPU 的频率,以及测试代码,具体如下:
1 | int main(void) { |
我们将其 make upload
到开发板之后,可以通过 screen
命令进入串口,查看打印情况:
screen /dev/cu.usbmodem533A0265041 115200
串口的地址,请跟进实际情况来更改,如果一切正常的话,可以看到以下这样的输出,并且大约是每秒一次:
如此看来,初步验证是没有什么问题了,按下 ctrl + A
再按下 ctrl + K
退出串口终端,接下来就是激动人心的点灯时刻了。
裸机点灯
在正式点灯前,我们还是要看看今天要点的灯的硬件连接情况,主要是为了确定是通过哪个 IO 来进行操作。
原理图
这次要点的灯还比较高级,竟然是个 RGB 三色灯且有个外围驱动芯片 WS2812!通过原理图可以看到控制管脚接入到了 IO16,那么理论上我们通过这个 IO 相关寄存器就可以操作了。
GPIO
在了解 WS2812 驱动前,我们先将需要的 IO 配置好,我这边也参考手册把 GPIO 相关的寄存器定义好了:
1 |
|
既然这个 IO16 最终是操控 WS2812 的,我们就新建一个 ws2812.h 和 ws2812.c 文件来进行相关代码编写吧。查阅寄存器手册,要初始化 IO 为输出模式,可以通过简单输出模式进行配置,操作以下两个寄存器即可:
1 |
|
那么 GPIO 的输出比较简单,也就是高电平和低电平,通过 ESP32 的 GPIO 输出置位寄存器和输出清零寄存器即可:
1 | // 操作置位寄存器,输出 高电平 |
注意,上面操作的寄存器,控制的是 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 |
|
有了写 0 和写 1 还不够,要想点亮 RGB,还要按照 ws2812 的芯片描述输出更多的数据:
可以看到,这里需要按照 GRB 的方式发送 24 位数据,那么也很简单,通过上面的方法在封装一下即可:
1 | static void ws2812_write_byte(uint8_t byte) { |
那么我们的 ws2812 驱动就完成了,可以点灯了!!!
点灯效果
直接在 main
方法的起始地方进行调用即可:
1 | int main(void) { |
上面的示例代码里,我们点成了红色,但你可以进行自行修改,理论上可以合成的颜色还是比较丰富的,下面是最基础的 R、G、B 三原色效果:
如果觉得刺眼的话,可以把色值相应的调低一点。那么这是我为你们点亮的第二盏灯,这是一个五彩缤纷的灯,也像这五彩的世界,期望你们不要在这五彩的世界里,迷失了那个单纯的自己。
IDF 点灯
裸机点完了,那么使用 IDF 这种完备的框架来点灯的话,就要简单太多、太多了,具体直接看代码即可:
1 | void ws2812_init(void) { |
可以看到,除了 GPIO 的初始化,其它操作这里还是用了寄存器,因为使用框架的 gpio_set_level
来操作的话,时序上达不到 ws2812
的纳秒级别要求。当然,除了使用 GPIO,常规的驱动方式还是使用 PWM 波来,不过这不在我们这里的讨论范围了,使用 PWM 的话,相比于操作寄存器,框架层的封装就简单太多了。
WOKWI 在线点灯
最后,我们来一次真正的在线点灯,可以打开网页:
这里有一个可以在线仿真的编辑器,我们选择 ESP32 后,然后添加一下 FreeRTOS
库,再在右侧连接一个简单的电路如下:
就可以使用 Arduino 框架进行在线点灯了:
1 | void task1(void *pt) { |
如果没有异常,会看到这个灯一闪一闪的,可以直接打开这个地址查看配置和效果:
https://wokwi.com/projects/337946979076145746
关于 Arduino,这也是一个比较知名和强大的体系,使用起来很简单,但要细究其底层的封装,还是比较复杂的,有兴趣的可以自行去搜索学习!而 FreeRTOS 是一款现在已经非常流行的实时操作系统,提供了任务、队列、信号等功能,可以方便的构建出更健壮、通用的产品,这也是一个比较庞大的体系,这里还是点到为止,感兴趣的可以自行学习!
总结
今天的内容还是比较多的,那么我们稍微总结一下,本篇所涉及到或点到为止的内容:
- 启动引导流程
- 应用内存布局
- 链接脚本
- 寄存器操作方式
- 裸机实现 ws2812 时序
- Arduino、RTOS
结束的有点仓促,想要总结的东西太多,但看了下时间也不早了,后面有时间我们再慢慢聊。无论如何,今天的灯亮了,这心也就定了。