控制LED灯的亮灭是MCU开发中一个最简单的应用功能,实现这个应用功能包含了MCU开发中工程的构建、编译的过程、下载烧录的方式、开机运行的流程等等内容。

1. 开发工具链

针对ESP32开发,乐鑫官方提供了ESP-IDF框架以及对应的开发工具链,开发环境的搭建可以参考上一章节的内容。作为一个LED灯控制的简单程序,只需要基于Xtensa架构的GNU交叉编译工具的支持。

2. LED闪烁灯程序

以通用的main函数作为程序的入口,通过控制ESP32 GPIO输出高低电平来控制LED灯的亮灭。

2.1. 关闭看门狗

参考ESP-IDF目录components/bootloader裸机引导程序的实现流程以及ESP32芯片数据手册,可以知道Flash启动时使能了主系统看门狗定时器MWDT和RTC看门狗定时器RWDT,程序应加入关闭看门狗的代码。

设置TIMGn_Tx_WDTCONFIG0_REG寄存器的TIMGn_Tx_WDT_FLASHBOOT_MOD_EN(位14)为0即可关闭主系统看门狗定时器MWDT。
02 LED闪烁灯 - 图1

设置RTC_CNTL_WDTCONFIG0_REG寄存器的RTC_CNTL_WDT_FLASHBOOT_MOD_EN (位10)为0即可关闭RTC看门狗定时器RWDT。
02 LED闪烁灯 - 图2

2.2. 初始化GPIO

通常情况下,芯片上电以后GPIO口处于输入状态,控制LED灯需设置成输出。本文使用ESP32-LyraT开发板,LED灯控制引脚为GPIO22,需设置该引脚为GPIO输出功能,并使能输出。

通过设置GPIO交换矩阵配置寄存器GPIO_FUNCn_OUT_SEL_CFG_REG的输出选择为256,使对应的GPIO n引脚为GPIO输出功能。通过设置GPIO输出使能置位寄存器PIO_ENABLE_W1TS_REG对应n位为1,使能GPIO n的输出。通过设置GPIO输出置位寄存器GPIO_OUT_W1TS_REG对应n位为1, GPIO n输出高电平,通过设置GPIO输出清零寄存器GPIO_OUT_W1TC_REG对应n位为1, GPIO n输出低电平。
02 LED闪烁灯 - 图3

2.3. 循环闪烁

在while循环里面控制GPIO输出低电平,然后软件延时,再输出高电平,软件延时,如此循环,实现LED灯的闪烁。

2.4. c代码

LED闪烁灯项目路径为D:\esp32\led,完整的LED闪烁灯代码led.c如下:

#define GPIO_LED            22

#define REG_WRITE(_r, _v)    (*(volatile unsigned int *)(_r)) = (_v)
#define REG_READ(_r)        (*((volatile unsigned int *)(_r)))

#define GPIO_OUT_W1TS_REG                0x3FF44008
#define GPIO_OUT_W1TC_REG                0x3FF4400C
#define GPIO_ENABLE_W1TS_REG            0x3FF44024
#define GPIO_FUNC0_OUT_SEL_CFG_REG        0x3FF44530

#define RTC_CNTL_WDTWPROTECT_REG        0x3FF480A4
#define RTC_CNTL_WDTCONFIG0_REG            0x3FF4808C
#define TIMG_WDTWPROTECT_REG            0x3FF5F064
#define TIMG_WDTCONFIG0_REG                0x3FF5F048

#define RTC_CNTL_WDT_WKEY_VALUE            0x50D83AA1
#define RTC_CNTL_WDT_FLASHBOOT_MOD_EN    10
#define TIMG_WDT_WKEY_VALUE                0x50D83AA1
#define TIMG_WDT_FLASHBOOT_MOD_EN        14

void Gpio_SetLevel(unsigned char GpioNum, unsigned char Level)
{
    if (Level) {
        REG_WRITE(GPIO_OUT_W1TS_REG, 1 << GpioNum);
    } else {
        REG_WRITE(GPIO_OUT_W1TC_REG, 1 << GpioNum);
    }
}

void Gpio_Init(unsigned char GpioNum)
{
    REG_WRITE(GPIO_ENABLE_W1TS_REG, 1 << GpioNum);
    // GPIO_FUNCn_OUT_SEL值256选择GPIO_OUT_REG的bit n作为输出值
    REG_WRITE(GPIO_FUNC0_OUT_SEL_CFG_REG+GpioNum*4, 256);
}

void Delay(void)
{
    volatile int i;
    for (i=0; i<1000000; i++) {

    }
}

void Wdt_Disable(void)
{
    unsigned int Value;
    REG_WRITE(RTC_CNTL_WDTWPROTECT_REG, RTC_CNTL_WDT_WKEY_VALUE);
    Value = REG_READ(RTC_CNTL_WDTCONFIG0_REG);
    Value &= ~(1 << RTC_CNTL_WDT_FLASHBOOT_MOD_EN);
    REG_WRITE(RTC_CNTL_WDTCONFIG0_REG, Value);
    REG_WRITE(RTC_CNTL_WDTWPROTECT_REG, 0);

    REG_WRITE(TIMG_WDTWPROTECT_REG, TIMG_WDT_WKEY_VALUE);
    Value = REG_READ(TIMG_WDTCONFIG0_REG);
    Value &= ~(1 << TIMG_WDT_FLASHBOOT_MOD_EN);
    REG_WRITE(TIMG_WDTCONFIG0_REG, Value);
    REG_WRITE(TIMG_WDTWPROTECT_REG, 0);    
}

void main(void)
{
    Wdt_Disable();
    Gpio_Init(GPIO_LED);
    while (1) {
        Gpio_SetLevel(GPIO_LED, 0);
        Delay();
        Gpio_SetLevel(GPIO_LED, 1);
        Delay();
    }
}

3. 编译运行

ESP32使用Xtensa架构的GNU交叉编译工具,可以使用通用的GUN工具命令编译代码,链接生成可执行代码,也可以生成相应的输出文件,如map文件、反汇编等等用于分析调试。

3.1. 编译

用gcc命令编译led.c,输出led.o文件。

xtensa-esp32-elf-gcc.exe -Os -c led.c -o led.o

02 LED闪烁灯 - 图4

3.2. 链接

编译生成目标文件后,需要通过链接器链接为一个可执行文件,可以通过链接脚本控制链接过程。链接脚本可以指定程序的入口、各个输入段的内存布局等等,其有特定的语法格式。可以参考ESP-IDF目录\components\bootloader\subproject\main\ld\esp32下bootloader.ld文件,实现ESP32裸机程序的链接脚本。

LED闪烁灯项目的链接脚本led.ld实现如下:

MEMORY
{
  iram_seg (RWX) :           org = 0x40078000, len = 0x8000  /* 32KB, APP CPU cache */
}

/*  Default entry point:  */
ENTRY(main);

SECTIONS
{
  .iram.text :
  {
    _stext = .;
    _text_start = ABSOLUTE(.);
    *(.literal .text .literal.* .text.* .stub .gnu.warning .gnu.linkonce.literal.* .gnu.linkonce.t.*.literal .gnu.linkonce.t.*)
    *(.fini.literal)
    *(.fini)
    *(.gnu.version)

    /** CPU will try to prefetch up to 16 bytes of
      * of instructions. This means that any configuration (e.g. MMU, PMS) must allow
      * safe access to up to 16 bytes after the last real instruction, add
      * dummy bytes to ensure this
      */
    . += 16;

    _text_end = ABSOLUTE(.);
    _etext = .;
  } > iram_seg
}

链接脚本指定了程序的入口为main,代码段.text加载到0x40078000地址的内存区域执行。由于LED闪烁灯程序比较简单,只有代码段.text以及字面量段.literal,没有全局变量静态变量.bss段和.data段、常数.rodata段,可以不指定未使用段的内存布局。

用ld命令链接目标文件,生成可执行文件led.elf。用-T指定led.ld链接脚本,用-Map指定输出map文件,可以了解程序、数据、IO空间等等的映射关系。

xtensa-esp32-elf-ld.exe -Map led.map -T led.ld led.o -o led.elf

02 LED闪烁灯 - 图5

3.3. 反汇编

链接生成可执行elf文件后,可以提取可执行段并生成反汇编代码,用于代码优化、调试等分析。

用objdump命令反汇编led.elf,输出led.dis反汇编文件。

xtensa-esp32-elf-objdump.exe -d led.elf > led.dis

02 LED闪烁灯 - 图6

ESP32为Xtensa架构,该架构具有很强的可重构性和可拓展性,允许自定义全新指令,用于硬件加速,可提升处理器的运算性能同时又便于软件实现控制。详细的汇编指令可参考Xtensa指令集体系结构(ISA)数据手册。

从反汇编代码可以看出,函数总是以entry指令开头,该指令主要实现两个功能:

1、为函数分配堆栈帧,计算并设置函数的栈指针。

2、根据函数调用指令calln/callxn,将寄存器窗口移动/旋转n个物理寄存器。

例如entry a1, 32,表示为函数分配32个字长的堆栈帧,并计算设置函数栈指针a1的值。当使用call8调用该函数时,寄存器窗口移动/旋转8个物理寄存器。

针对函数的调用,Xtensa架构设计了一种窗口旋转方式的寄存器管理机制,将逻辑寄存器和物理寄存器分开。对于ESP32,有16个逻辑寄存器(指令中的a0~a15),有64个环形物理寄存器,通过WindowBase寄存器,使逻辑寄存器可滑动对应实际的物理寄存器。从而避免寄存器覆盖,减少入栈和出栈的操作。

这一设计机制可让嵌入式软件性能得到明显提高。这是因为使用函数时,往往需要入栈,保存函数使用前的寄存器现场,函数结束时,需要出栈,恢复之前的寄存器现场后返回。入栈和出栈将对外部内存进行读写,而外部内存对于高性能CPU来说是慢速设备,需打断流水线等待访问完成,因此频繁访问外部内存将极大地影响性能。通常高性能的CPU,如Cortex-A系列ARM核,通过增加一级或二级Cache来降低CPU读写等待内存的几率。而Xtensa架构直接减少了函数调用时的入栈和出栈,降低了内存的访问,更大限度地提升了性能。

02 LED闪烁灯 - 图7

3.4. 烧录文件

链接生成的可执行文件为elf格式,该文件保存了二进制可执行代码,适用于Unix类系统环境。ESP32无法直接运行elf代码,需要用官方提供的esptool.py工具把elf文件转换成ESP32可烧录的bin文件。该工具在ESP-IDF下 \components\esptool_py\esptool目录,实现从elf文件提取可执行段(如.text、.rodata、.data段),并记录段加载地址及长度,同时加入镜像头,生成bin文件。

用esptool.py命令转换led.elf,输出led.bin文件

esptool.py --chip esp32 elf2image --flash_mode="dio" --flash_freq "40m" --flash_size "4MB" -o led.bin led.elf

用WinHex打开led.bin,并对照ESP-IDF下bootloader工程,可以分析ESP32烧录bin的格式。
02 LED闪烁灯 - 图8

02 LED闪烁灯 - 图9

ESP32 bin文件总是以0xE9作为起始,镜像头共有24字节,第0x1字节为内存段总数,0x40x7字节为入口地址,0x180x1B为第一个内存段加载地址,0x1C~0x1F为第一个内存段长度,0x20开始为第一个内存段数据,直到该段最后一个数据。如果有第二段、第三段等更多的内存段,则按第一段相同的格式往下排列。

从led.bin二进制数据可以看出,led.bin只有1个内存段(代码段),入口地址为0x400780F0(main函数入口),第一个内存段(代码段)加载地址为0x40078000,段数据长度为0x0000013C字节。

上电后,ESP32最先运行内部厂商启动代码,该启动代码会从SPI Flash的0x1000偏移处获取镜像头信息,把相应段加载到各自段内存区域,最后根据入口地址跳转执行用户程序。

以led.bin为例, 上电后内部ROM从Flash中加载代码段共0x0000013C字节到0x40078000内存区域,最后跳转到入口地址0x400780F0执行用户程序。

转换生成的led.bin可用esptool.py工具通过串口的方式下载烧录到SPI Flash中。

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 led.bin

02 LED闪烁灯 - 图10

3.5. 启动运行

内部ROM上电启动,打印信息load: 0x40078000,len:316,即表示从SPI Flash加载代码段316字节到0x40078000内存区域。打印信息entry 0x400780f0,即表示入口地址为0x400780f0,并跳转执行,此时板载的LED灯正常闪烁。
02 LED闪烁灯 - 图11

4. 总结

ESP-IDF框架使用CMake工具自动化配置构建项目,生成makefile,通过python等脚本给开发者提供一个友好的前端,避免了各种工具命令的直接调用,极大地方便和简化项目的开发,通过本文可以对这些自动化管理工具、脚本等运行机制有一个基本的概念。