Cortex-M4汇编代码运行慢于预期的原因排查求助
背景信息
我正在为STM32F4DISCOVERY开发板的STM32F407VG(Cortex-M4内核)编写对性能极度敏感的汇编代码,目标是压榨每一个时钟周期。我使用Cortex-M4内置的DWT周期计数器做基准测试,某输入规模下代码实际运行1494个周期。代码从Flash运行,CPU降频至24MHz以实现Flash零等待状态访问(ART加速器已禁用),两次连续读取DWT计数器仅产生1个周期的基准测试开销。
代码仅从Flash读取5个32位常量字(可能因同时读取指令和数据引发总线矩阵冲突),其余数据访问均在RAM中完成。我已确保所有分支目标32位对齐,并手动为部分指令添加.W后缀,仅余两条32位指令为16位而非32位对齐——其中一条在当前输入规模下不会执行,另一条是函数的最终POP指令,显然不在循环中运行。注:多数指令采用32位编码,平均指令长度为3.74字节。
我制作了电子表格统计每条指令的执行次数(循环内指令统计迭代次数),甚至考虑了分支是否命中(这会影响指令周期数)。参考《Cortex-M4技术参考手册(TRM)》获取指令周期数,且始终采用最保守估算:涉及流水线刷新开销的指令按最大3周期计算;所有加载和存储操作均按最坏情况计算,尽管TRM第3.3.2节提到诸多可减少周期数的特殊情况。电子表格包含两次读取DWT计数器之间所有指令的开销。
令我意外的是,电子表格预估代码应运行1268个周期,而实际为1494个周期,比理论最坏情况慢18%。即使完全展开负责约3/4执行时间的主循环,代码仍需1429个周期,而电子表格预估展开版本仅需1186个周期。
有趣的是,同算法的完全展开、精心调优的C版本仅需1309个周期。该C版本共有1013条指令,而汇编展开版本仅930条指令。两者均包含当前测试输入未触发的分支代码,但这部分代码对性能无显著影响。此外,C代码的平均指令长度仅略小,为3.59字节。
核心问题
- 我的汇编代码实际性能与预估存在显著偏差的原因是什么?
- 为何指令数更多、指令长度相近的C版本运行更快?
最小复现示例
主汇编函数
// #define UNROLL .cpu cortex-m4 .arch armv7e-m .fpu softvfp .syntax unified .thumb .macro MACRO r_0, r_1, r_2, d ldr lr, [r0, #\d] and \r_0, \r_0, \r_1, ror #11 and \r_0, \r_0, \r_1, ror #11 and lr, \r_0, lr, ror #11 and lr, \r_0, lr, ror #11 and \r_2, \r_2, lr, ror #11 and \r_2, \r_2, lr, ror #11 and \r_1, \r_2, \r_1, ror #11 and \r_1, \r_2, \r_1, ror #11 str lr, [r0, #\d] .endm .text .p2align 2 .global f f: push {r4-r11,lr} ldmia r0, {r1-r12} .p2align 2 #ifndef UNROLL mov lr, #25 push.w {lr} loop: #else .rept 25 #endif MACRO r1, r2, r3, 48 MACRO r4, r5, r6, 52 MACRO r7, r8, r9, 56 MACRO r10, r11, r12, 60 #ifndef UNROLL ldr lr, [sp] subs lr, lr, #1 str lr, [sp] bne loop add.w sp, sp, #4 #else .endr #endif stmia r0, {r1-r12} pop {r4-r11,pc}
主代码(需STM32F4 HAL)
#include "stm32f4xx_hal.h" void SysTick_Handler(void) { HAL_IncTick(); } void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct; RCC_ClkInitTypeDef RCC_ClkInitStruct; // Enable Power Control clock __HAL_RCC_PWR_CLK_ENABLE(); // The voltage scaling allows optimizing the power consumption when the device is // clocked below the maximum system frequency, to update the voltage scaling value // regarding system frequency refer to product datasheet. __HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE2); // Enable HSE Oscillator and activate PLL with HSE as source RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; // External 8 MHz xtal on OSC_IN/OSC_OUT RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; // 8 MHz / 8 * 192 / 8 = 24 MHz RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLM = 8; // VCO input clock = 1 MHz / PLLM = 1 MHz RCC_OscInitStruct.PLL.PLLN = 192; // VCO output clock = VCO input clock * PLLN = 192 MHz RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV8; // PLLCLK = VCO output clock / PLLP = 24 MHz RCC_OscInitStruct.PLL.PLLQ = 4; // USB clock = VCO output clock / PLLQ = 48 MHz if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) { while (1) ; } // Select PLL as system clock source and configure the HCLK, PCLK1 and PCLK2 clocks dividers RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; // 24 MHz RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; // 24 MHz RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV1; // 24 MHz RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; // 24 MHz if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_0) != HAL_OK) { while (1) ; } } void print_cycles(uint32_t cycles) { uint32_t q = 1000, t; for (int i = 0; i < 4; i++) { t = (cycles / q) % 10; ITM_SendChar('0' + t); q /= 10; } ITM_SendChar('\n'); } void f(uint32_t *); int main(void) { uint32_t x[16]; SystemClock_Config(); CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; uint32_t before, after; while (1) { __disable_irq(); before = DWT->CYCCNT; f(x); after = DWT->CYCCNT; __enable_irq(); print_cycles(after - before); HAL_Delay(1000); } }
运行说明
- 将上述代码导入含STM32F4 HAL的项目,需添加全局宏定义
HSE_VALUE=8000000(HAL默认25MHz晶振,开发板实际为8MHz)。 - 通过注释/取消注释代码开头的
#define UNROLL可切换展开/非展开版本。
DWT计数器调用点验证
对main()函数执行arm-none-eabi-objdump查看调用点:
80009da: 4668 mov r0, sp before = DWT->CYCCNT; 80009dc: 6865 ldr r5, [r4, #4] f(x); 80009de: f7ff fbd3 bl 8000188 <f> after = DWT->CYCCNT; 80009e2: 6860 ldr r0, [r4, #4]
可见两次读取DWT计数器之间仅存在跳转至f()函数的bl指令。
实测与预估数据
- 非展开版本实测运行1536个周期,展开版本为1356个周期。
非展开版本预估电子表格(未计入1个周期DWT开销)
| 指令 | 循环迭代次数 | 宏重复次数 | 单宏内指令数 | 周期数 | 总周期数 |
|---|---|---|---|---|---|
| bl (来自main) | 1 | 1 | 1 | 4 | 4 |
| push (12个寄存器) | 1 | 1 | 1 | 13 | 13 |
| ldmia (12个寄存器) | 1 | 1 | 1 | 13 | 13 |
| mov | 1 | 1 | 1 | 1 | 1 |
| push (1个寄存器) | 1 | 1 | 1 | 2 | 2 |
| ldr | 25 | 4 | 1 | 2 | 200 |
| and | 25 | 4 | 8 | 1 | 800 |
| str | 25 | 4 | 1 | 2 | 200 |
| ldr | 1 | 1 | 1 | 2 | 2 |
| subs | 1 | 1 | 1 | 1 | 1 |
| str | 1 | 1 | 1 | 2 | 2 |
| bne (命中) | 24 | 1 | 1 | 4 | 96 |
| bne (未命中) | 1 | 1 | 1 | 1 | 1 |
| stmia (12个寄存器) | 1 | 1 | 1 | 13 | 13 |
| pop (11个寄存器 + pc) | 1 | 1 | 1 | 16 | 16 |
| 1364 |
- 非展开版本误差:
1536/(1364 + 1) - 1 = 12.5%(+1为DWT计数器开销)。 - 展开版本预估:移除循环初始化及分支相关指令,总计减少105个周期,预估执行时间为1259个周期。
- 展开版本误差:
1356/(1259 + 1) - 1 = 7.6%。
内容的提问来源于stack exchange,提问作者swineone

