1. 单片机代码运行时间测量方法详解

在嵌入式系统开发过程中,精确掌握关键代码段的执行时间是保障系统实时性、验证算法性能、调试时序敏感外设以及优化功耗的基础能力。无论是实现微秒级精准延时、校准通信协议时序(如I2C、SPI、One-Wire),还是评估中断响应延迟、分析任务调度开销,都离不开对代码实际运行时间的量化测量。然而,许多工程师在项目初期往往依赖理论估算或粗略经验判断,直到系统出现难以复现的时序异常、通信失败或实时性不达标问题时,才意识到缺乏实测数据支撑的设计存在巨大风险。

本文系统性地阐述两种经过工程实践验证的单片机代码运行时间测量方法:基于示波器的GPIO电平翻转法与基于内部定时器的软件计时法。二者原理清晰、实现简单、结果可靠,适用于STM32F103等主流Cortex-M3内核MCU平台。所有方案均以最小侵入性为设计原则,确保测试过程本身不对被测代码的原始行为产生显著干扰,所获数据可直接用于系统级时序分析与性能调优。

1.1 方法选择的工程依据

选择何种测量方法,需综合考虑测试精度需求、硬件资源可用性、开发环境限制及目标代码特性。示波器法本质是将时间信息转化为电压信号,利用示波器高带宽、高采样率的物理测量能力获取结果;而定时器法则完全在MCU内部完成时间捕获与计算,无需外部仪器,但其精度受限于定时器分辨率、中断服务程序(ISR)开销及软件读取逻辑引入的额外延迟。

对于微秒级(μs)至毫秒级(ms)的短时序测量,示波器法因其极低的系统开销和直观的波形呈现,成为首选方案。它仅需占用一个通用IO引脚,且在待测代码段起始与结束处插入两条位操作指令(如置高/置低),几乎不改变原有代码路径与执行周期。相比之下,定时器法虽能提供更长的测量范围(可达数十秒),但其软件实现中不可避免地引入了启动/停止定时器、查询标志位、清除中断标志等额外指令周期,这些开销会叠加到被测时间上,尤其在测量极短代码段(<10μs)时,误差占比可能超过20%。因此,本文将首先深入剖析示波器法的实现细节与精度验证,再详述定时器法的配置要点与误差补偿策略。

2. 基于示波器的GPIO电平翻转法

该方法的核心思想是将待测代码段的执行时间“可视化”为一段连续的高电平脉冲宽度。通过在代码段入口处将指定GPIO引脚置为高电平,在出口处将其拉低,即可在示波器上直接观测并精确测量该脉冲的持续时间。此方法的物理基础坚实,测量结果直观可信,是嵌入式工程师进行快速时序验证的利器。

2.1 硬件接口设计与信号完整性考量

本方案选用STM32F103C8T6微控制器的PB0引脚作为测试信号输出。选择该引脚基于以下工程考量:

  • 驱动能力匹配 :PB0配置为推挽输出模式( GPIO_Mode_Out_PP ),最大输出电流达25mA,足以驱动示波器探头的典型输入阻抗(1MΩ || 15pF),避免因负载过重导致上升/下降沿变缓。
  • 时钟域独立性 :PB0隶属于APB2总线,其时钟源(72MHz)与系统核心时钟一致,确保GPIO操作指令的执行周期稳定可预测。
  • 布局便利性 :在标准开发板(如Blue Pill)上,PB0通常位于边缘引脚,便于探头连接,减少飞线引入的寄生电感与电容。

为保障信号完整性,需注意:

  • 探头接地 :示波器探头的地线必须就近连接至MCU的GND引脚,形成最短回路,否则长地线会引入振铃与噪声,严重影响微秒级脉宽测量精度。
  • 探头衰减比 :使用10:1探头,其输入电容(约15pF)远小于1:1探头(约100pF),可最大限度降低对PB0引脚驱动电路的容性负载,保证边沿陡峭度。
  • 引脚初始化 :在 GPIO_Config() 函数中,明确设置PB0初始状态为低电平( GPIO_ResetBits(GPIOB, GPIO_Pin_0) ),避免上电瞬间的不确定电平干扰首次测量。

2.2 软件实现:SysTick驱动的微秒级延时与GPIO控制

待测代码段的典型代表是 Delay_us() 函数。本文采用SysTick系统定时器实现高精度微秒延时,其配置与使用逻辑如下:

#include "systick.h"
#include "gpio.h"

/* SysTick定时周期定义:1us */
#define SYSTICKPERIOD            0.000001f
#define SYSTICKFREQUENCY         (1.0f / SYSTICKPERIOD)

/**
  * @brief  读取SysTick的状态位COUNTFLAG
  * @param  None
  * @retval SET or RESET
  */
static FlagStatus SysTick_GetFlagStatus(void)
{
    if (SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk)
    {
        return SET;
    }
    else
    {
        return RESET;
    }
}

/**
  * @brief  配置系统滴答定时器 SysTick
  * @param  None
  * @retval 1 = failed, 0 = successful
  */
uint32_t SysTick_Init(void)
{
    /* 设置定时周期为1us */
    if (SysTick_Config(SystemCoreClock / SYSTICKFREQUENCY))
    {
        return 1;
    }
    /* 关闭滴答定时器且禁止中断 */
    SysTick->CTRL &= ~(SysTick_CTRL_ENABLE_Msk | SysTick_CTRL_TICKINT_Msk);
    return 0;
}

/**
  * @brief   us延时程序,1us为一个单位
  * @param   nTime: Delay_us(10) 则实现的延时为 10 * 1us = 10us
  * @retval  None
  */
void Delay_us(__IO uint32_t nTime)
{
    /* 清零计数器并使能滴答定时器 */
    SysTick->VAL   = 0;
    SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk;

    for (; nTime > 0; nTime--)
    {
        /* 等待一个延时单位的结束 */
        while (SysTick_GetFlagStatus() != SET);
    }
    /* 关闭滴答定时器 */
    SysTick->CTRL &= ~SysTick_CTRL_ENABLE_Msk;
}

SysTick_Init() 函数完成SysTick的初始化:首先调用 SysTick_Config() 将重装载值( LOAD 寄存器)设为 SystemCoreClock / 1000000 (即72),实现1μs的定时周期;随后关闭定时器使能位与中断使能位,使其处于待命状态。 Delay_us() 函数则通过循环方式,每次等待SysTick计数器从重载值递减至0(触发 COUNTFLAG ),从而精确累积 nTime 个1μs周期。

GPIO控制逻辑封装为宏 TX(a) ,其定义简洁高效:

#define TX(a) if(a) GPIO_SetBits(GPIOB,GPIO_Pin_0); else GPIO_ResetBits(GPIOB,GPIO_Pin_0)

该宏展开后为两条原生位操作指令,无函数调用开销,确保在代码段边界引入的额外时间极小(在72MHz主频下,约为140ns)。

2.3 测量实例与精度分析

main() 函数中构建测试场景:

int main(void)
{
    GPIO_Config();      // 初始化PB0为推挽输出
    SysTick_Init();     // 初始化SysTick为1us周期

    while(1)
    {
        TX(HIGH);       // 开始测量:PB0置高
        Delay_us(10);   // 待测代码段:10us延时
        TX(LOW);        // 结束测量:PB0拉低
        Delay_us(100);  // 间隔,便于示波器观察
    }
}

使用带宽≥100MHz的数字示波器(如Rigol DS1054Z)捕获PB0波形,结果如下表所示:

Delay_us(n) 参数 示波器实测脉宽 理论值 绝对误差 相对误差
1 2.2 μs 1 μs +1.2 μs +120%
10 11.4 μs 10 μs +1.4 μs +14%
100 101.0 μs 100 μs +1.0 μs +1.0%

误差来源深度解析:

  • 固定开销(Overhead) TX(HIGH) TX(LOW) 两条指令本身消耗约2个CPU周期(1个 if 判断+1个位操作),在72MHz下约为27.8ns。此开销恒定,不随 nTime 变化。
  • SysTick启动/停止延迟 SysTick->VAL = 0 SysTick->CTRL 位操作各需1-2个周期,总计约55ns。
  • 循环与条件判断开销 for 循环的初始化、条件检查、自减操作在每次迭代中引入约3-4个周期(约55ns)。当 nTime=1 时,此开销占比极大;当 nTime=100 时,占比降至约0.5%。
  • SysTick_GetFlagStatus() 查询延迟 :该函数包含一次寄存器读取与一次位运算,约2个周期(27.8ns),在 nTime 次循环中累加。

综上, Delay_us(n) 的实际执行时间为:
T_actual = n × 1μs + T_overhead + n × T_loop_overhead
其中 T_overhead ≈ 1.0μs (主要由GPIO操作与SysTick启停构成), T_loop_overhead ≈ 0.014μs 。这完美解释了实测数据: n=10 时, T_actual ≈ 10 + 1.0 + 0.14 = 11.14μs ,与11.4μs高度吻合。

结论 :示波器法测量结果高度可靠。对于 n ≥ 10 的延时,相对误差已低于1.5%,完全满足绝大多数嵌入式应用的时序验证需求。若需更高精度,可通过实测 T_overhead 值,在应用层进行软件补偿。

3. 基于内部定时器的软件计时法

当缺乏示波器或需在量产环境中进行自动化测试时,利用MCU内部定时器(如TIM2)进行软件计时是另一套完备方案。该方法将时间测量完全集成于固件中,通过读取定时器计数值差来计算代码执行时间,具备部署灵活、无需外部设备的优势。

3.1 TIM2定时器配置与驱动逻辑

本方案选用TIM2作为计时基准,其时钟源配置为72MHz(APB1总线频率×2),通过预分频器(PSC)与自动重装载寄存器(ARR)组合,实现1μs的计数分辨率:

#include "timer.h"

#define SYSTICKPERIOD            0.000001f
#define SYSTICKFREQUENCY         (1.0f / SYSTICKPERIOD)

/**
  * @brief  定时器2的初始化,定时周期1us
  * @param  None
  * @retval None
  */
void TIM2_Init(void)
{
    TIM_TimeBaseInitTypeDef  TIM_TimeBaseStructure;

    /* 使能TIM2时钟:APB1 = 36MHz, TIM2CLK = 36MHz * 2 = 72MHz */
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

    /* 时间基准配置 */
    TIM_TimeBaseStructure.TIM_Period = SystemCoreClock / SYSTICKFREQUENCY - 1; // 71
    TIM_TimeBaseStructure.TIM_Prescaler = 0; // 无预分频
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);

    TIM_ARRPreloadConfig(TIM2, ENABLE); // 使能ARR预装载
    TIM_UpdateRequestConfig(TIM2, TIM_UpdateSource_Global); // 更新事件源
    TIM_ClearFlag(TIM2, TIM_FLAG_Update); // 清除更新标志
}

TIM2_Init() 将TIM2配置为向上计数模式,重装载值设为71( 72000000 / 1000000 - 1 ),结合0预分频,确保计数器每1μs递增1。 Delay_us() 函数复用前述逻辑,仅将SysTick替换为TIM2:

void Delay_us(__IO uint32_t nTime)
{
    /* 清零计数器并使能定时器 */
    TIM2->CNT   = 0;
    TIM_Cmd(TIM2, ENABLE);

    for (; nTime > 0; nTime--)
    {
        /* 等待一个延时单位的结束 */
        while (TIM_GetFlagStatus(TIM2, TIM_FLAG_Update) != SET);
        TIM_ClearFlag(TIM2, TIM_FLAG_Update);
    }
    TIM_Cmd(TIM2, DISABLE);
}

3.2 时间捕获与结果解析

为精确捕获 Delay_us(1000) 的执行时间,需在调用前后读取TIM2的当前计数值。此处采用 SysTick_Time_Start() SysTick_Time_Stop() 函数(实际应为 TIM2_Time_Start/Stop ,原文命名有误),其核心逻辑为:

typedef struct {
    uint32_t TimeStart;
    uint32_t TimeStop;
    uint32_t TimeWidth;
    uint32_t TimeWidthAvrage;
} TimingVarTypeDef;

TimingVarTypeDef Time;

void TIM2_Time_Start(void)
{
    Time.TimeStart = TIM2->CNT; // 记录起始计数值
}

void TIM2_Time_Stop(void)
{
    Time.TimeStop = TIM2->CNT;  // 记录结束计数值
    Time.TimeWidth = Time.TimeStop - Time.TimeStart;
    // 滤波与平均处理(略)
}

main() 中调用:

int main(void)
{
    TIM2_Init();
    while(1)
    {
        TIM2_Time_Start();
        Delay_us(1000);
        TIM2_Time_Stop();
        // 此处可将Time.TimeWidthAvrage通过串口打印
    }
}

实测 Time.TimeWidthAvrage = 0x119B8 = 72120 (十进制)。由于TIM2计数周期为1/72MHz ≈ 13.89ns,故实际时间为:
72120 × 13.89ns = 1,001,746.8ns ≈ 1.0017ms
与理论值1000μs的误差为+1.7μs,相对误差0.17%。此精度已远超示波器法在长时序下的表现,且完全规避了外部仪器的不确定性。

关键优势与局限:

  • 优势 :测量范围大( 2^32 × 13.89ns ≈ 59.65s ),精度高(亚微秒级),可无缝集成至自动化测试框架。
  • 局限 :软件开销显著。 TIM2_Time_Start/Stop 本身需数个周期读取 CNT 寄存器; Delay_us() while 循环查询 TIM_FLAG_Update 引入的延迟(约1-2μs)会叠加到测量结果中。对于 <10μs 的极短代码段,此方法误差可能超过50%。

4. 两种方法的工程化对比与选型指南

下表从七个关键维度对两种方法进行量化对比,为工程师提供清晰的选型依据:

对比维度 示波器GPIO翻转法 内部定时器软件计时法
硬件依赖 必需示波器(≥100MHz带宽) 无需外部设备,仅依赖MCU内部定时器
软件侵入性 极低:仅2条GPIO操作指令 中等:需启动/停止定时器、读取寄存器、处理标志位
测量精度 高(示波器自身精度±1%~3%) 极高(取决于定时器分辨率与软件开销补偿)
适用时序范围 100ns ~ 1s(受示波器显示与触发能力限制) 1μs ~ 59.65s(受32位计数器溢出限制)
实时性影响 可忽略(GPIO操作为原子指令) 显著(定时器启停、标志查询引入μs级延迟)
调试便捷性 直观:波形一目了然,易于发现毛刺与抖动 间接:需通过调试器查看变量或串口打印
量产可行性 仅限研发阶段,无法用于产线自动化测试 高:可固化为测试固件,支持批量自动校验

选型决策树:

  • 场景A:研发调试、快速验证、时序敏感外设(如I2C Start/Stop) → 优先选择 示波器法 。其零配置、瞬时反馈、直观波形的特性,能极大提升问题定位效率。
  • 场景B:算法性能评估、任务调度分析、长周期(>10ms)执行时间统计 → 推荐 定时器法 。其大范围、高精度、可编程的特性,是构建系统性能基线的基石。
  • 场景C:量产校准、固件自检、客户现场诊断 → 必须采用 定时器法 。通过UART/USB将 TimeWidthAvrage 转换为标准时间单位(μs/ms)并上报,是工业级产品的标配功能。

5. 实践建议与高级技巧

5.1 误差最小化黄金法则

  • 示波器法 :始终使用10:1探头,并将探头接地夹紧贴MCU GND引脚;测量前执行示波器自校准;对同一代码段进行10次以上采样,取中位数而非平均值,以消除偶发噪声干扰。
  • 定时器法 :在 TIM2_Time_Start() 前插入 __DSB() (数据同步屏障)指令,确保所有先前指令完成;在 TIM2_Time_Stop() 后立即读取 CNT ,避免后续代码干扰;对 nTime 较小的 Delay_us() ,预先测量并扣除固定开销(如 T_overhead = 1.0μs )。

5.2 复杂代码段的测量策略

对于包含分支、循环、函数调用的非线性代码段,单一测量易受缓存、流水线、中断抢占影响。推荐采用 统计学方法

  • 在主循环中连续执行待测代码段1000次;
  • 使用定时器法记录总时间,除以1000得平均单次执行时间;
  • 同时开启SysTick中断(1ms周期),在中断服务程序中统计1秒内该代码段被执行的次数,反向验证平均时间。

5.3 多核/多任务环境的特殊考量

在FreeRTOS等RTOS环境下,需确保测量期间无高优先级任务抢占。可在 taskENTER_CRITICAL() 临界区中执行 TIM2_Time_Start/Stop ,或为测量任务分配最高优先级并禁用调度器( vTaskSuspendAll() )。

6. 总结:构建可信赖的时序验证体系

精确的代码运行时间测量并非锦上添花的调试技巧,而是嵌入式系统工程化开发的基石。本文详述的两种方法——示波器GPIO翻转法与内部定时器软件计时法——分别对应着“快速洞察”与“深度量化”的不同工程需求。前者以物理世界的直观性,为开发者提供了无可辩驳的时序证据;后者则以数字世界的严谨性,为系统性能建模与长期可靠性验证提供了数据支撑。

在实际项目中,应摒弃“二选一”的思维定式,转而构建分层验证体系:在原理图设计阶段,即规划专用测试引脚(如预留未连接的GPIO);在固件架构中,将 TIM2_Time_Start/Stop 等API封装为标准测试库;在量产测试规范中,明确定义关键路径(如ADC采样、PWM生成、CAN报文发送)的时序容差与自动化校验流程。唯有如此,方能在芯片主频不断提升、软件复杂度日益增长的今天,依然牢牢掌控住嵌入式系统的时序命脉。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐