第二章 启动时间测量:从DWT到示波器的四把尺子
做多核启动优化,第一件事不是改代码,而是先搞清楚——你的系统到底花了多少时间在启动上?
我见过太多工程师,上来就对着代码一顿猛改,改完发现启动时间反而更长了。为什么?因为没有基准数据。你连起点和终点都没量准,怎么知道优化有没有效果?
这一章,我带你过一遍启动时间测量的四种方法。从最精准的DWT周期计数器,到最直观的示波器抓波形,每种方法都有它的适用场景。我个人习惯是:调试阶段用DWT和SYSTICK,验证阶段上逻辑分析仪,最终交付前用示波器拍个波形留档。
2.1 DWT时钟周期计数器——精度最高的“尺子”
DWT(Data Watchpoint and Trace)是Cortex-M内核自带的调试组件。它里面有一个CYCCNT寄存器,专门用来数CPU时钟周期。说白了,就是一颗从复位开始一直往上数的计数器,永不停止。
为什么我偏爱DWT?因为它精度最高,一个周期都不差。而且它不占用定时器资源,对实时系统零干扰。
核心要点:DWT_CYCCNT是32位寄存器,在168MHz主频下约25.6秒溢出一次。测量短时间启动段完全够用。
2.1.1 初始化与读取代码
/* 启用DWT时钟周期计数器 */
void DWT_Init(void)
{
/* 使能DWT外设 */
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
/* 复位CYCCNT计数器 */
DWT->CYCCNT = 0;
/* 使能CYCCNT计数器 */
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}
/* 读取当前计数值 */
uint32_t DWT_GetCycles(void)
{
return DWT->CYCCNT;
}
/* 测量两个时间点之间的周期数 */
uint32_t DWT_MeasureDelta(uint32_t start)
{
return DWT_GetCycles() - start;
}
这段代码我几乎每个项目都会复用。注意一点:CoreDebug->DEMCR必须在使能CYCCNT之前配置好,否则计数器不会跑。我曾经在这个顺序上栽过跟头,查了半天才发现是初始化顺序反了。
2.1.2 测量启动时间段的典型用法
/* 在启动代码最前面记录起点 */
uint32_t start_cycles = 0;
void EarlyInit(void)
{
DWT_Init();
start_cycles = DWT_GetCycles();
/* ... 其他初始化代码 ... */
}
/* 在进入main()或RTOS启动后记录终点 */
void MainEntry(void)
{
uint32_t elapsed = DWT_MeasureDelta(start_cycles);
/* 转换为微秒:elapsed_us = elapsed / (SystemCoreClock / 1000000) */
float elapsed_us = (float)elapsed / (SystemCoreClock / 1000000.0f);
/* 保存到调试变量或串口输出 */
debug_printf("Boot time: %.2f us (%u cycles)\r\n", elapsed_us, elapsed);
}
小技巧:如果测量段跨越了时钟配置变化(比如从HSI切换到PLL),DWT的计数频率会跟着变。我建议在时钟稳定后再读取DWT,或者干脆用SYSTICK做跨时钟域的测量。
2.2 SYSTICK与TIM定时器——灵活的第二选择
DWT虽然精准,但有些场景下它不太方便。比如你想测量某个外设初始化函数的执行时间,或者想统计多个启动阶段的耗时分布。这时候,SYSTICK和通用定时器反而更好用。
2.2.1 SYSTICK测量法
SYSTICK是Cortex-M内核自带的24位递减计数器。它通常被用作操作系统的心跳,但也可以临时拿来测时间。我习惯在启动阶段用SYSTICK做分段计时,等RTOS启动后再把SYSTICK交给系统。
/* 配置SYSTICK为1ms中断模式 */
void SYSTICK_Init(void)
{
SysTick_Config(SystemCoreClock / 1000); /* 1ms中断一次 */
}
/* 全局时间戳变量 */
volatile uint32_t sys_tick_ms = 0;
void SysTick_Handler(void)
{
sys_tick_ms++;
}
/* 获取当前毫秒时间戳 */
uint32_t GetTick(void)
{
return sys_tick_ms;
}
/* 测量某段代码的执行时间 */
void MeasurePhase(void)
{
uint32_t t0 = GetTick();
/* 被测代码段 */
HAL_SomeModule_Init();
uint32_t t1 = GetTick();
uint32_t cost_ms = t1 - t0;
debug_printf("Phase cost: %u ms\r\n", cost_ms);
}
注意:SYSTICK的精度只有1ms,对于微秒级的启动段不够用。如果被测代码执行时间小于1ms,你会得到0ms的结果。这时候要么用DWT,要么把SYSTICK中断间隔改成100us。
2.2.2 TIM定时器测量法
通用定时器比SYSTICK更灵活。你可以配置一个TIM以最高频率计数(比如168MHz),然后像DWT一样读取计数值。而且TIM可以同时测量多个通道,非常适合做多核启动的并行时间分析。
/* 使用TIM2作为高精度计时器 */
void TIM2_Init(void)
{
__HAL_RCC_TIM2_CLK_ENABLE();
TIM2->PSC = 0; /* 不分频,直接使用内核时钟 */
TIM2->ARR = 0xFFFFFFFF; /* 最大自动重装值 */
TIM2->CR1 |= TIM_CR1_CEN; /* 使能计数器 */
}
/* 读取当前计数值 */
uint32_t TIM2_GetCounter(void)
{
return TIM2->CNT;
}
/* 测量Core0和Core1的启动时间差 */
void MeasureDualCoreBoot(void)
{
uint32_t t_core0_start = TIM2_GetCounter();
/* Core0启动代码 */
uint32_t t_core1_start = TIM2_GetCounter();
/* Core1启动代码 */
uint32_t t_core1_end = TIM2_GetCounter();
uint32_t core0_boot = t_core1_start - t_core0_start;
uint32_t core1_boot = t_core1_end - t_core1_start;
uint32_t skew = (t_core1_start > t_core0_start) ?
(t_core1_start - t_core0_start) : 0;
debug_printf("Core0: %u, Core1: %u, Skew: %u\r\n",
core0_boot, core1_boot, skew);
}
| 测量方法 | 精度 | 资源占用 | 适用场景 |
|---|---|---|---|
| DWT CYCCNT | 1个时钟周期 | 无(内核自带) | 短时间、高精度测量 |
| SYSTICK | 1ms(可调) | 1个中断 | 长时间、分段计时 |
| TIM定时器 | 1个时钟周期 | 1个定时器 | 多通道、并行测量 |
2.3 逻辑分析仪——多信号同步抓取
软件测量有个天然缺陷:它会影响被测系统本身。你想想看,每打印一条调试信息,就要多花几十微秒。对于启动时间优化来说,这几十微秒可能就是瓶颈所在。
逻辑分析仪就没有这个问题。它只监听GPIO电平变化,对目标系统零干扰。我一般在代码的关键节点插入GPIO翻转指令,然后用逻辑分析仪同时抓取多个信号。
2.3.1 硬件连接与代码植入
/* 定义测量用的GPIO引脚 */
#define BOOT_PIN_CORE0_START GPIO_PIN_0 /* PA0 */
#define BOOT_PIN_CORE0_READY GPIO_PIN_1 /* PA1 */
#define BOOT_PIN_CORE1_START GPIO_PIN_2 /* PA2 */
#define BOOT_PIN_CORE1_READY GPIO_PIN_3 /* PA3 */
/* 初始化GPIO为推挽输出 */
void BootMeasure_GPIO_Init(void)
{
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitTypeDef gpio = {0};
gpio.Pin = BOOT_PIN_CORE0_START | BOOT_PIN_CORE0_READY |
BOOT_PIN_CORE1_START | BOOT_PIN_CORE1_READY;
gpio.Mode = GPIO_MODE_OUTPUT_PP;
gpio.Pull = GPIO_NOPULL;
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &gpio);
/* 初始化为低电平 */
HAL_GPIO_WritePin(GPIOA, BOOT_PIN_CORE0_START | BOOT_PIN_CORE0_READY |
BOOT_PIN_CORE1_START | BOOT_PIN_CORE1_READY,
GPIO_PIN_RESET);
}
/* 在启动代码关键位置插入 */
void Core0_Start(void)
{
HAL_GPIO_WritePin(GPIOA, BOOT_PIN_CORE0_START, GPIO_PIN_SET);
/* ... Core0初始化代码 ... */
HAL_GPIO_WritePin(GPIOA, BOOT_PIN_CORE0_READY, GPIO_PIN_SET);
}
void Core1_Start(void)
{
HAL_GPIO_WritePin(GPIOA, BOOT_PIN_CORE1_START, GPIO_PIN_SET);
/* ... Core1初始化代码 ... */
HAL_GPIO_WritePin(GPIOA, BOOT_PIN_CORE1_READY, GPIO_PIN_SET);
}
经验之谈:逻辑分析仪的采样率至少要设到被测信号最高频率的4倍。对于168MHz主频的系统,我建议用200MHz以上采样率的分析仪。便宜一点的24MHz采样率也能用,但测出来的上升沿会有抖动。
2.4 示波器实测——最直观的“铁证”
示波器是最后一道防线。当你在逻辑分析仪上看到时序异常,或者DWT报出奇怪的数据时,示波器能帮你确认信号质量。我遇到过好几次,代码逻辑完全正确,但示波器一看,GPIO上升沿有毛刺,原来是电源纹波太大导致电平抖动。
2.4.1 示波器测量要点
- 探头补偿:测量前先用1kHz方波校准探头,确保波形不畸变
- 接地方式:用弹簧接地夹,不要用长接地线,否则会引入噪声
- 触发设置:用上升沿触发,触发电平设在1.65V(3.3V系统的一半)
- 时基选择:启动时间一般在ms级别,时基设在200us/div到1ms/div之间
避坑指南:我曾经用示波器测一个低功耗项目的启动时间,结果发现每次测量结果都不一样。查了半天,原来是示波器探头的10pF电容影响了RC复位电路的时序。对于高阻抗的复位引脚,建议用1x档位或者有源探头。
2.5 四种方法对比与选择策略
说了这么多,到底该用哪种?我总结了一个选择矩阵,你可以根据实际情况来定。
| 场景 | 推荐方法 | 理由 |
|---|---|---|
| 单次启动总时间 | DWT | 精度高,零干扰 |
| 多阶段耗时分布 | SYSTICK + 数组记录 | 可记录多个时间戳 |
| 双核启动同步性 | TIM + GPIO + 逻辑分析仪 | 多通道并行抓取 |
| 最终验证与报告 | 示波器截图 | 直观、可信、可存档 |
我个人习惯是:开发阶段用DWT做精细测量,调试阶段用逻辑分析仪看整体时序,交付前用示波器拍一张波形图贴在文档里。这样既有数据支撑,又有视觉证据,客户看了也放心。
2.6 实战案例:一次启动时间优化前后的数据对比
最后分享一个真实案例。之前做的一个双核项目,客户要求启动时间必须小于100ms。第一次用DWT测量,结果是187ms,远超目标。
我用逻辑分析仪抓了四个信号:Core0启动、Core0就绪、Core1启动、Core1就绪。波形显示Core0启动后等了80ms才开始Core1的初始化——原来代码里有个不必要的延时等待。
去掉这个延时后,启动时间降到了95ms。再用示波器拍了一张波形图,贴在测试报告里,客户当场就签字通过了。
关键数据:
- 优化前:187ms(DWT测量)
- 优化后:95ms(DWT测量)
- 优化幅度:49.2%
- 验证工具:逻辑分析仪 + 示波器
你看,没有准确的测量,你根本不知道瓶颈在哪里。这四种方法,你至少得掌握两种。DWT和逻辑分析仪是我最推荐的组合,一个管精度,一个管视野,搭配起来几乎能覆盖所有启动时间测量场景。