速度优化

[English]

概述

提高代码执行速度是提升软件性能的关键要素,该优化也可能带来其他积极影响,比如降低总体功耗。然而,提高代码执行速度可能会牺牲其他性能,如 最小化二进制文件大小

决定优化目标

如果应用程序固件中的某个函数仅每周在后台执行一次,其执行时间是 10 ms 还是 100 ms 对整体性能的影响或可忽略不计。但如果某个函数以 10 Hz 的频率持续执行,其执行时间是 10 ms 还是 100 ms 就会对系统性能产生显著影响。

大多数应用程序固件中,只有一小部分函数需要优化性能,例如频繁执行的函数,或者必须满足应用程序对延迟或吞吐量的要求的函数。应针对这些特定函数优化其执行速度。

测量性能

想要提升某方面性能,首先要对其进行测量。

基本性能测量方法

可以直接测量与外部交互的性能,例如,测量一般网络性能可以参考 wifi/iperfethernet/iperf ,或者使用示波器或逻辑分析仪来测量与设备外设的交互时间。

此外,另一种测量性能的方法是在代码中添加计时测量:

#include "esp_timer.h"

void measure_important_function(void) {
    const unsigned MEASUREMENTS = 5000;
    uint64_t start = esp_timer_get_time();

    for (int retries = 0; retries < MEASUREMENTS; retries++) {
        important_function(); // 需要测量的代码
    }

    uint64_t end = esp_timer_get_time();

    printf("%u iterations took %llu milliseconds (%llu microseconds per invocation)\n",
           MEASUREMENTS, (end - start)/1000, (end - start)/MEASUREMENTS);
}

通过多次执行目标代码可以降低其他因素的影响,例如实时操作系统 (RTOS) 的上下文切换、测量的开销等。

  • 使用 esp_timer_get_time() 可以生成微秒级精度的“墙钟”时间戳,但每次调用计时函数都会产生适量开销。

  • 也可以使用标准 Unix 函数 gettimeofday()utime() 来进行计时测量,尽管其开销略高一些。

  • 此外,代码中包含 hal/cpu_hal.h 头文件,并调用 HAL 函数 cpu_hal_get_cycle_count() 可以返回已执行的 CPU 循环数。该函数开销较低,适用于高精度测量执行时间极短的代码。

  • 在执行“微基准测试”时(即仅对运行时间不到 1-2 ms 的小代码段进行基准测试),二进制文件会影响 flash 缓存的性能,进而可能会导致计时测量出现较大差异。这是因为二进制布局可能会导致在特定的执行顺序中产生不同模式的缓存缺失。执行较大测试代码通常可以抵消这种影响。在基准测试时多次执行一个小函数可以减少 flash 缓存缺失的影响。另外,将该代码移到 IRAM 中(参见 针对性优化 )也可以解决这个问题。

外部跟踪

应用层跟踪库 可以在几乎不影响代码执行的情况下测量其执行速度。

任务

如果启用了选项 CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS ,则可以使用 FreeRTOS API vTaskGetRunTimeStats() 来获取各个 FreeRTOS 任务运行时占用处理器的时间。

SEGGER SystemView 是一款出色的工具,可将任务执行情况可视化,也可用于排查系统整体的性能问题或改进方向。

提高整体速度

以下优化措施将提高几乎所有代码的执行效果,包括启动时间、吞吐量、延迟等:

  • 设置 CONFIG_ESPTOOLPY_FLASHMODE 为 QIO 或 QOUT 模式(四线 I/O 模式)。相较于默认的 DIO 模式,在这两种模式下,从 flash 加载或执行代码的速度几乎翻倍。如果两种模式都支持,QIO 会稍微快于 QOUT。请注意,flash 芯片以及 ESP32-S2 与 flash 芯片之间的电气连接都必须支持四线 I/O 模式,否则 SoC 将无法正常工作。

  • 设置 CONFIG_COMPILER_OPTIMIZATIONOptimize for performance (-O2) 。相较于默认设置,这可能会略微增加二进制文件大小,但几乎必然会提高某些代码的性能。请注意,如果代码包含 C 或 C++ 的未定义行为,提高编译器优化级别可能会暴露出原本未发现的错误。

  • 避免使用浮点运算 float。ESP32-S2 通过软件模拟进行浮点运算,因此速度非常慢。可以考虑使用不同的整数表示方法进行运算,如定点表示法,或者将部分计算用整数运算后再切换为浮点运算。

  • 避免使用双精度浮点运算 double。ESP32-S2 通过软件模拟进行双精度浮点运算,因此速度非常慢。可以考虑使用基于整数的表示方法或单精度浮点数。

更改 cache 大小

在 ESP32-S2 上,通过下面列出的 Kconfig 选项增加 cache 的大小,“cache 缺失”的频率可能会降低,从而在一定程度上提高整体速度。

备注

增加 cache 大小也将导致可用 RAM 的减少。

减少日志开销

尽管标准输出会先存储在缓冲区中,但缓冲区缺少可用空间时,应用程序将数据输出到日志的速度可能会受限。这点在程序启动并输出大量日志时尤为明显,但也可能随时发生。为解决这一问题,可以采取以下几种方法:

  • 通过调低应用日志默认等级 CONFIG_LOG_DEFAULT_LEVEL (引导加载程序日志等级的相应配置为 CONFIG_BOOTLOADER_LOG_LEVEL)来减少日志输出量。这样做不仅可以减小二进制文件大小,还可以节省一些 CPU 用于格式化字符串的时间。

  • 增加 CONFIG_ESP_CONSOLE_UART_BAUDRATE ,可以提高日志输出速度。如果使用内置 USB-CDC 作为串口控制台,那么串口传输速率不会受配置的波特率影响。

不建议的选项

以下选项也可以提高执行速度,但不建议使用,因为它们会降低固件应用程序的可调试性,并可能导致出现更严重的 bug。

  • 禁用 CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL 。这也会略微减小固件二进制文件大小。然而,它可能导致出现更严重的 bug,甚至出现安全性 bug。如果为了优化特定函数而必须禁用该选项,可以考虑在该源文件的顶部单独添加 #define NDEBUG

针对性优化

以下更改将提高固件应用程序特定部分的速度:

  • 将频繁执行的代码移至 IRAM。应用程序中的所有代码都默认从 flash 中执行。这意味着缓存缺失时,CPU 需要等待从 flash 加载后续指令。如果将函数复制到 IRAM 中,则仅需要在启动时加载一次,然后始终以全速执行。

    IRAM 资源有限,使用更多的 IRAM 可能会减少可用的 DRAM。因此,将代码移动到 IRAM 需要有所取舍。更多信息参见 IRAM(指令 RAM)

  • 针对不需要放置在 IRAM 中的单个源文件,可以重新启用跳转表优化。这将提高大型 switch cases 代码中的热路径性能。关于如何在编译单个源文件时添加 -fjump-tables -ftree-switch-conversion 选项,参见 组件编译控制

减少启动时间

除了上述提高整体性能的方法外,还可以微调以下选项来专门减少启动时间:

  • 最小化 CONFIG_LOG_DEFAULT_LEVELCONFIG_BOOTLOADER_LOG_LEVEL 可以大幅减少启动时间。如要在应用程序启动后获取更多日志,可以设置 CONFIG_LOG_MAXIMUM_LEVEL,然后调用 esp_log_level_set() 来恢复更高级别的日志输出。示例 system/startup_time 的主函数展示了如何实现这一点。

  • 如果使用 Deep-sleep 模式,启用 CONFIG_BOOTLOADER_SKIP_VALIDATE_IN_DEEP_SLEEP 可以加快从睡眠中唤醒的速度。请注意,启用该选项后在唤醒时将不会执行安全启动验证,需要考量安全风险。

  • 设置 CONFIG_BOOTLOADER_SKIP_VALIDATE_ON_POWER_ON 可以在每次上电复位启动时跳过二进制文件验证,节省的时间取决于二进制文件大小和 flash 设置。请注意,如果 flash 意外损坏,此设置将有一定风险。更多关于使用该选项的解释和建议,参见 项目配置

  • 禁用 RTC 慢速时钟校准可以节省一小部分启动时间。设置 CONFIG_RTC_CLK_CAL_CYCLES 为 0 可以实现该操作。设置后,以 RTC 慢速时钟为时钟源的固件部分精确度将降低。

  • 使用外部内存(启用 CONFIG_SPIRAM)时,启用外部内存 (CONFIG_SPIRAM_MEMTEST) 测试可能会大大增加启动时间(每测试 4 MB 的内存大约增加 1 秒)。禁用内存测试将减少启动时间,但将无法对外部存储器进行测试。

  • 使用外部内存(启用 CONFIG_SPIRAM)时,所有用作堆的内存(包括外部内存)都将被设为默认值,所以启用全面的 poisoning 将增加启动时间(每设置 4 MiB 的内存大约增加 300 毫秒)。

示例项目 system/startup_time 预配了优化启动时间的设置,文件 system/startup_time/sdkconfig.defaults 包含了所有相关设置。可以将这些设置追加到项目中 sdkconfig 文件的末尾并合并,但请事先阅读每个设置的相关说明。

任务优先级

ESP-IDF FreeRTOS 是实时操作系统,因此需确保高吞吐量或低延迟的任务获得更高优先级,以便立即运行。调用 xTaskCreate()xTaskCreatePinnedToCore() 会设定优先级,并且可以在运行时调用 vTaskPrioritySet() 进行更改。

此外,还需确保任务适时释放 CPU(通过调用 vTaskDelay()sleep() ,或在信号量、队列、任务通知等方面进行阻塞),以避免低优先级任务饥饿并造成系统性问题。 任务看门狗定时器 (TWDT) 提供任务饥饿自动检测机制,但请注意,正确的固件操作有时需要长时间运算,因此任务看门狗定时器超时并不总意味着存在问题。在这些情况下,可能需要微调超时时限,甚至禁用任务看门狗定时器。

内置任务优先级

ESP-IDF 启动的系统任务预设了固定优先级。启动时,一些任务会自动启动,而另一些仅在应用程序固件初始化特定功能时启动。为优化性能,请合理设置应用程序任务优先级,以确保它们不会被系统任务阻塞,同时需确保系统任务不会饥饿进而影响其他系统功能。

为此,可能需要分解特定任务。例如,可以在高优先级任务或中断处理程序中执行实时操作,并在较低优先级任务中处理非实时操作。

头文件 components/esp_system/include/esp_task.h 包含了用于设置 ESP-IDF 内置任务系统优先级的宏定义。更多系统任务详情,参见 后台任务

常见优先级包括:

  • 运行主任务 中执行 app_main 函数的主任务优先级最低 (1)。

  • 系统任务 ESP 定时器 用于管理定时器事件并执行回调函数,优先级较高 (22, ESP_TASK_TIMER_PRIO)。

  • FreeRTOS 初始化调度器时会创建定时器任务,用于处理 FreeRTOS 定时器的回调函数,优先级最低(1, 可配置 )。

  • 系统任务 事件循环库 用于管理默认的系统事件循环并执行回调函数,优先级较高 (20, ESP_TASK_EVENT_PRIO)。仅在应用程序调用 esp_event_loop_create_default() 时使用此配置。可以调用 esp_event_loop_create() 添加自定义任务配置。

  • lwIP TCP/IP 任务优先级较高 (18, ESP_TASK_TCPIP_PRIO)。

  • Wi-Fi 驱动程序 任务优先级较高 (23).

  • 使用 Wi-Fi Protected Setup (WPS)、WPA2 EAP-TLS、Device Provisioning Protocol (DPP) 或 BSS Transition Management (BTM) 等功能时,Wi-Fi wpa_supplicant 组件可能会创建优先级较低的专用任务 (2)。

  • 以太网驱动程序会创建一个 MAC 任务,用于接收以太网帧。如果使用默认配置 ETH_MAC_DEFAULT_CONFIG ,则该任务为中高优先级 (15)。可以在以太网 MAC 初始化时输入自定义 eth_mac_config_t 结构体来更改此设置。

  • 如果使用 ESP-MQTT 组件,它会创建优先级默认为 5 的任务( 可配置 ),可通过 CONFIG_MQTT_USE_CUSTOM_CONFIG 调整,也可以在运行时通过 esp_mqtt_client_config_t 结构体中的 task_prio 字段调整。

  • 关于 mDNS 服务的任务优先级,参见 性能优化

设定应用程序任务优先级

一般情况下,不建议将任务优先级设置得比内置的 Wi-Fi 操作更高,因为这样可能会使 CPU 被长时间占用,导致系统不稳定。

对于非常短、对时序要求严格且不涉及网络的操作,可以使用中断服务程序或是限制运行时间的最高优先级 (24) 任务。

将特定任务优先级设为 19,则较低层级的 Wi-Fi 功能可以无延迟运行,且仍然会抢占 lwIP TCP/IP 堆栈以及其他非实时内部功能,这对于不执行网络操作的实时任务而言是最佳选项。

lwIP TCP/IP 任务优先级 (18) 应高于所有执行 TCP/IP 网络操作的任务,从而避免优先级反转的问题。

默认配置下,除了个别例外,尤其是 lwIP TCP/IP 任务,大多数内置任务都固定在核 0 上执行。因此,应用程序可以方便地将高优先级任务放置在核 1 上执行。优先级大于等于 19 的应用程序任务在核 1 上运行时可以确保不会被任何内置任务抢占。为了进一步隔离各个 CPU 上运行的任务,配置 lwIP 任务 ,可以使 lwIP 任务仅在核 0 上运行,而非其他内核,这可能会根据其他任务的运行情况减少总 TCP/IP 吞吐量。

一般情况下,不建议将核 0 上的任务优先级设置得比内置的 Wi-Fi 操作更高,因为这样可能会使 CPU 被长时间占用,导致系统不稳定。选择优先级为 19 并在核 0 上运行可以使底层 Wi-Fi 功能运行无延迟,但仍会抢占 lwIP TCP/IP 栈和其他不太关键的内部功能。这对于无需执行网络操作且时序要求高的任务来说是一个选择。执行 TCP/IP 网络操作的任何任务都应该以低于 lwIP TCP/IP 任务 (18) 的优先级运行,以避免优先级反转问题。

备注

如果要让特定任务始终先于 ESP-IDF 内置任务运行,并不需要将其固定在核 1 上。将该任务优先级设置为小于等于 17,则无需与内核绑定,那么核 0 上没有执行较高优先级的内置任务时,该任务也可以选择在核 0 上执行。使用未固定的任务可以提高整体 CPU 利用率,但这会增加任务调度的复杂性。

备注

对内置 SPI flash 芯片进行写入操作时,任务会完全暂停执行。只有 IRAM 安全中断处理程序 会继续执行。

提高中断性能

ESP-IDF 支持动态 中断分配 和中断抢占。系统中每个中断都有相应优先级,较高优先级的中断将优先执行。

只要其他任务不在临界区内,中断处理程序将优先于所有其他任务执行。因此,尽量减少中断处理程序的执行时间十分重要。

要以最佳性能执行特定中断处理程序,可以考虑:

  • 调用 esp_intr_alloc() 时使用 ESP_INTR_FLAG_LEVEL2ESP_INTR_FLAG_LEVEL3 等标志,可以为更重要的中断设定更高优先级。

  • 如果确定整个中断处理程序可以在 IRAM 中运行(参见 IRAM 安全中断处理程序 ),那么在调用 esp_intr_alloc() 分配中断时,请设置 ESP_INTR_FLAG_IRAM 标志,这样可以防止在应用程序固件写入内置 SPI flash 时临时禁用中断。

  • 即使是非 IRAM 安全的中断处理程序,如果需要频繁执行,可以考虑将处理程序的函数移到 IRAM 中,从而尽可能规避执行中断代码时发生 flash 缓存缺失的可能性(参见 针对性优化 )。如果可以确保只有部分处理程序位于 IRAM 中,则无需添加 ESP_INTR_FLAG_IRAM 标志将程序标记为 IRAM 安全。

提高网络速度

提高 I/O 性能

使用标准 C 库函数,如 freadfwrite 时,相较于使用平台特定的不带缓冲系统调用,I/O 性能可能更慢,如 readwrite 。标准 C 库函数是为可移植性而设计的,它们会在执行时会引入一定开销和缓冲延迟,因此并不适用需要较高执行速度的场景。

FAT 文件系统 具体信息和提示如下:

  • 读取/写入请求的最大大小等于 FatFS 簇大小(分配单元大小)。

  • 使用 readwrite 而非 freadfwrite 可以提高性能。

  • 要提高诸如 freadfgets 等缓冲读取函数的执行速度,可以增加文件缓冲区的大小(Newlib 的默认值为 128 字节),例如 4096、8192 或 16384 字节。为此,可以在特定文件的指针上使用 setvbuf 函数进行局部更改,或者修改 CONFIG_FATFS_VFS_FSTAT_BLKSIZE 实现全局应用。

    备注

    增加缓冲区的大小会增加堆内存的使用量。