stm32c8t6系列芯片定时器的HAL库函数详解(上)
本文深入解析STM32C8T6系列芯片通用定时器的HAL库函数,重点讲解定时器初始化、输入捕获模式的原理与应用。文章分为上下两部分,上部分详细剖析了定时器类型区别、初始化函数底层逻辑、输入捕获直接/间接模式的工作原理,并通过红外测距项目实践演示输入捕获的应用。内容包括:1. 定时器类型对比(基本/通用/高级定时器)及使用场景;2. TIM_HandleTypeDef结构体解析与初始化流程;3. 输
系列文章目录
第一章 stm32c8t6系列芯片定时器的HAL库函数详解(上)
第二章 stm32c8t6系列芯片定时器的HAL库函数详解(中)
第三章 stm32c8t6系列芯片定时器的HAL库函数详解(下)
目录
(2)Input Capture Indirect Mode
前言
在单片机领域中定时器是极为重要的,单片机的诸多内部功能的实现,离不开定时器的新奇劳作,所以我想基于我自己的一些理解详细解析一下HAL库中有关定时器的库函数,包括如何使用,函数原理,但一些基本的定时器工作原理我就不叙述了,这篇文章主要是讲述定时器函数的,前请根据需要阅读。
一、定时器有哪些?它们有什么区别?
首先定时器分为 基本定时器(Basic Timer)通用定时器(General-purpose Timer)高级定时器(Advanced Timer)。三者虽然功能是包含关系,但是在我看来这三种定时器多数情况,都有自己独特的分工。
我们先来讲基本定时器,它的功能是很单一简单的,只有定时器基本的计数计时功能,可用于触发DAC 和 计时触发中断和DMA请求。主要包含的定时器有TIM6/TIM7,由于stm32c8t6系列芯片不包含这两个定时器所以,我们这里不详细叙述,但其实我们要明白其实偶们会使用更高级定时器,基本定时器我们自然也会使用了。
不多说基本定时器,我们下面讲一下通用定时器,首先通用定时器是我们最常使用的定时器,它具有基本定时器的全部功能如DAC的触发,除此之外它还可以利用它四个通道(CH1~4)来实现输入捕获,和输出比较这两非常重要的功能,此外还有单脉冲模式,和多定时器互联等功能,在下文中我们会详细讨论。通用定时器在f1系列芯片中通常包含TIM2/TIM3/TIM4/TIM5,在我们的stm32c8t6系列芯片里只有TIM2/TIM3/TIM4这四个通用定时器。
讲完最常用的定时器,我们最后简单说一下高级定时器,相比于通用定时器,高级定时器有重复计数,死区时间带可编程的互补输出,断路输入等功能,重复计数很好理解,就是可以多次计数,减少中断次数提高运行效率,而后面这连个听起来就很高大上的功能,其实是用于更精准更安全的控制电机的功能,到底怎样的效果怎么使用我们下文详细说。
看完定时器的区别想必大家对单片机中定时器这个名词大脑中有了一定框架,那么我们直接进入通用定时器使用和有关函数的详解。
二、通用定时器(上)
1.定时器的初始化
如何初始化,想必我们都已经很熟悉了,但其中的原理你清楚吗?如果不清楚,下面我将代你走进底层,探寻初始化的秘密。我们在使用HAL库配置初始化时通常使用的是CubeMX这一款官方软件,我们配置好之后在文件中我们会看到如图的一段配置TIM2(或者其他定时器代码,我们这里以TIM2为例子讲解)。

我们可以很清楚的看到上半部分是给htim2的各个模式进行配置,在c语言角度其实就是给结构体htim2的成员赋值,在硬件角度就是给定时器模式相关的寄存器写入对应值配置寄存器模式(不过这里还没有写入,只是把模式的配置情况记录了下来,在后面初始化函数调用时才会真正的写入寄存器!)。寄存器角度我们这里不做深究,我们主要从代码角度来探究底层原理,想看到为什么这样配置htim2我们就要看他的结构体类型,我们找到它的定义如图。

(1)TIM_HandleTypeDef类型
很明显它的类型叫 TIM_HandleTypeDef 我们打开它的定义。

天哪!出现一大堆似乎看不懂的语法,别着急我们慢慢分析,首先我们先不管什么#if #else,这个结构体定义里最和结构体定义有关的就是这里的typedef struct{~~~~~}TIM_HandleTypeDef这一个大框了吧,这里是对中间这一大片结构体的名字起了个我们好理解的别名就是TIM_HandleTypeDef,接下来我们从内部一部分一部分的刨析我们先看第一部分也就是定时器写寄存器的这一部分如图。

我们可以看到第一个成员变量*Instance,很明显这是一个指针指向的是定时器寄存器的基地址,我们这里给它赋值就相当于写入基地址,也就是告诉硬件我要配置哪个定时器啦!(这里要求我们对c语言指针,地址等知识有一定的认识,如果不理解也没关系,下文会补充)。
然后我们来看这个指针变量的类型,也就是TIM_TypeDef,我们打开它的定义如图,

欸,这个也是一个结构体,但是看起来更加明了一些,它包含很多__IO uint32_t类型的成员变量,这些变量其实就是寄存器的一种映射,啊?你问我什么是映射?~-~映射就是把硬件上一个个分散的寄存器给它们分配地址,让它们和内存地址有一一对应的关系,这样方便我们对寄存器写入数值配置对应模式。(要是还不明白,我推荐咱们还是好好补一补c语言基础把)言归正传,例如第一个成员CR1(Control Register 1)它就是控制寄存器,用于控制定时器的基本功能,如计数器使能、方向、更新请求源等。同理下面都是寄存器,这些寄存器配置的就是定时器的状态,下面这些寄存器功能我就不在这篇文章详细讨论了(我会补充一篇定时器硬件原理来详细讨论这部分内容),那么又有好奇的小可爱会问了我只知道uint32_t这个类型啊,那么__IO uint32_t又是什么?和我学的uint32_t有什么区别吗?我这里解释一下,这里我们就需要看一下__IO的定义了,

其实,__IO uint32_t等价于volatile uint32_t ,volatile是 C 语言的一个关键字,它告诉编译器,被该关键字修饰的变量,其值可能会在程序未明确指定的情况下发生改变,编译器在对代码进行优化时,不能对该变量进行一些可能导致读取或写入操作被优化掉的操作。
看完了变量类型,我们来看一下给Instance这个指针变量赋值TIMx(例如TIM2)有什么意义?首先我们要知道TIM2到底表示了个啥,自然我们要打开TIM2的定义

很明显TIM2是后面TIM2_BASE的重定义,这里其实还对TIM2_BASE强制类型转换了在TIM2_BASE前的()就是强制类型转换的语法,我们还是先看类型再看变量,前面这个TIM_TypeDef*类型我们上文刚说过是一种结构体指针,而TIM2_BASE这个变量指的是什么?我们还是老办法,打开TIM2_BASE的定义
![]()
很清楚吧后面0x000啥啥啥就是一个二进制地址吧,而APB1PERIPH_BASE和TIM2_BASE一样就不打开看了,它是APB1总线的基地址,它两个的和就是我们定时器2的基地址,我们带回去,这里TIM2指的就是一个地址再把这个地址值强制类型转你换为TIM_TypeDef *类型的值,现在我们只有最后一个疑问了,为什么要把这个地址强转成一个结构体指针类型呢?首先我可以告诉你结论:这里的含义就是TIM2是一个指向这个结构体的指针强转之后TIM2就拥有了结构体里这些成员了,而这些恰好是TIM2的寄存器,这样就很巧妙的减少了代码赘余。(举个例子因为有很多定时器,我们如果给每一个定时器都写一份结构体那得写很多重复的代码了啊,毕竟在一种系列芯片下,同等级定时器的寄存器是一样的)这样操作还给指定定时器基地址后的地址来储存寄存器的状态参数,并且这些地址分配连续很有条理,可能你会问为什么结构体里,地址是连续的?是因为我内部给他提前指针指向来分配地址了嘛?当然不是啦~这么多寄存器都要分配啊,那多麻烦,这里就要讲到一个你可能不知道的c语言知识啦,那就是结构体成员在分配地址时有连续性这一特点,这就更巧妙的实现了给寄存器分配地址这项工作。
终于看完第一个成员变量了,这里声明:之后遇到我们上文讲的类似的东西,我们就一笔带过了。

接下来我们来看第二个成员变量Init顾名思义,这个变量是用来配置定时器初始化参数的,我们重点来看它的类型TIM_Base_InitTypeDef,还是老办法打开它的定义。

还是熟悉的配方结构体加成员变量,考虑到篇幅问题这里不对变量详细分析,但是我还是建议我们自己把后面的注释翻译一下锻炼自己读代码的能力。
看到这里我们就可以回到我们第一张配置寄存器状态的图了

从第二行开始,htim2.Init.引出的成员变量,就是我们上面提到的Init这个变量的成员变量,这样看来Init变量的作用是不是清晰很多。
不多说我们接着看TIM_HandleTypeDef的第三个成员变量

变量名Channel(通道)变量类型HAL_TIM_ActiveChannel,打开类型定义

诶!不是结构体了,是枚举,枚举的是定时器四个通道对应的状态值对应分别开启通道1234,最后一个状态是清除所有激活通道。
下一个成员变量*hdma[7],这是一个指向数组的指针,这个数组是用来存储DMA句柄信息的,什么意思我们先不管,我们先来看他的变量类型DMA_HandleTypeDef,打开定义

先不管DMA是啥我们一看又是熟悉的啥啥啥HandleTypeDef这很明显是用来配置什么东西的状态的,那么这里我们先解释什么是handle(句柄),句柄是一个抽象的引用(标识符),用于标识和访问系统中的资源(如文件、设备、内存块、窗口、网络连接等)。它本身不是资源本身,而是一个指向资源的 "指针" 或 "索引"。我们再来讲什么是DMA,我们不讨论在其他硬件领域DMA的作用,我们只讨论在定时器中是什么样的角色。在 STM32 微控制器中,DMA(直接内存访问)在定时器中起到重要作用,主要体现在数据传输和功能扩展方面例如数据自动传输,它可以降低CPU的负载是实现很多自动化程序的重要组成部分,这样解释其实很空,很抽象,那我们就先把它跳过等遇到了他发挥作用的地方我们再继续讨论,这样会更好理解一些。

后面这几个成员变量和DMA同理我们在配置htim2句柄时没用到我们等下面用到了在细讲。
这样我们TIM_HandleTypeDef类型第一部分成员变量我们就讲完了,我们来看第二部分:回调函数。首先没使用过HAL库的朋友对回调函数是很陌生的,那我们这里先解释一下什么是回调函数,回调函数是 HAL 库在初始化外设或者处理外设相关操作时,会预留一些回调函数的接口。当外设发生特定事件(如中断、DMA 传输完成等),HAL 库内部的代码会检测到该事件,并根据事先的设定,自动调用用户自定义的回调函数,而不需要用户在主程序中不断轮询检查事件是否发生的一种手段,回调函数的实现设计汇编层,我们本文以定时器为主所以不会讲解。那我们来看看TIM_HandleTypeDef类型结构体中有哪些回调函数吧。

还是不用在意#if #endif,映入眼帘的是一大堆函数指针(指向一个函数的指针),这些全都是和定时器有关的回调函数指针,我们来一个个看,他们都是个啥。
Base_MspInitCallback 和 Base_MspDeInitCallback分别在定时器基础功能初始化和反初始化时调用。他们通常用于在定时器基础功能初始化时配置底层硬件(如时钟、GPIO 等),在反初始化时释放相关资源。那这个回调函数到底该怎么使用呢?我这里给大家举个例子。
#include "stm32f4xx_hal.h" // 定义定时器句柄 TIM_HandleTypeDef htim1; // 定义GPIO初始化结构体 GPIO_InitTypeDef GPIO_InitStruct; // 定时器基础功能初始化时配置底层硬件回调函数 void Base_MspInitCallback(TIM_HandleTypeDef *htim) { if(htim->Instance == TIM1) { // 使能TIM1时钟 __HAL_RCC_TIM1_CLK_ENABLE(); // 使能GPIOA时钟 __HAL_RCC_GPIOA_CLK_ENABLE(); // 配置PA8为复用推挽输出模式 GPIO_InitStruct.Pin = GPIO_PIN_8; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Alternate = GPIO_AF1_TIM1; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); } } // 定时器基础功能反初始化时释放相关资源回调函数 void Base_MspDeInitCallback(TIM_HandleTypeDef *htim) { if(htim->Instance == TIM1) { // 失能TIM1时钟 __HAL_RCC_TIM1_CLK_DISABLE(); // 恢复PA8引脚为默认状态 HAL_GPIO_DeInit(GPIOA, GPIO_PIN_8); } } // 初始化定时器1并配置为PWM模式 void TIM1_PWM_Init(void) { TIM_OC_InitTypeDef sConfigOC; // 初始化定时器基本参数 htim1.Instance = TIM1; htim1.Init.Prescaler = 84 - 1; // 预分频器,设置为84分频,得到1MHz的计数频率 htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = 19999; // 设定周期,产生50Hz的PWM波(1M / (20000) = 50Hz) htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 0; HAL_TIM_Base_Init(&htim1); // 初始化PWM输出通道 sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 1000; // 初始占空比,对应舵机的中间位置(假设0.5ms - 2.5ms对应0 - 180度,1.5ms对应90度,1M计数频率下,1.5ms对应1500计数,这里先设为1000) sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim1, &sConfigOC, TIM_CHANNEL_1); // 启动PWM输出 HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); } int main(void) { HAL_Init(); // 注册定时器基础功能初始化回调函数 HAL_TIM_Base_MspInitCallback = Base_MspInitCallback; // 注册定时器基础功能反初始化回调函数 HAL_TIM_Base_MspDeInitCallback = Base_MspDeInitCallback; TIM1_PWM_Init(); while (1) { // 可以在这里通过修改sConfigOC.Pulse的值来改变舵机角度 // 例如,控制舵机转到0度 __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 500); // 对应0.5ms HAL_Delay(2000); // 保持一段时间 // 控制舵机转到180度 __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 2500); // 对应2.5ms HAL_Delay(2000); // 保持一段时间 } }
正如我给大家的示例我们在定时器配置时就调用的回调函数Base_MspInitCallback的内容(配置GPIO和使能定时器和GPIO)有的同学就要问啦,明明我们可以在其他地方例如GPIO_Init()但为什么要把这些写在回到函数中呢?我来给大家详细说明一下。
其实这得益于HAL库的设计,HAL库区别于标准库最大的特点就是让将 “功能配置” 与 “硬件底层配置” 更好的分离,提高代码的可移植性,这也是HAL库的设计理念,因此HAL 库本身设计了 Msp(MCU Specific Package)回调机制,用于适配不同 STM32 型号的硬件差异(如不同型号的时钟树、引脚分布不同)。例如:将代码从 STM32F407 移植到 STM32F103 时,只需修改 Msp 回调函数中的时钟使能和 GPIO 配置(因两者的时钟树和引脚复用定义不同),功能逻辑代码可直接复用。在大型项目中,通常由不同开发者负责 “功能逻辑” 和 “硬件驱动”,功能开发者只需关注定时器的功能参数(如 PWM 频率、占空比),无需了解具体硬件细节;硬件开发者只需维护 Msp 回调函数,确保引脚、时钟等配置正确。这种分工模式可减少代码冲突,提高开发效率。此外回调函数通过函数指针注册(如HAL_TIM_Base_MspInitCallback = Base_MspInitCallback),可在程序运行中动态切换不同的硬件配置逻辑。例如:同一定时器在不同场景下需要切换引脚输出(如切换 PWM 输出到不同舵机),只需动态修改回调函数的实现即可,无需重构整个初始化流。这使得我们对整个系统可以更灵活的控制,代码也更有逻辑结构更清晰,在我们以后写工程或者写小项目练习时可以多使用回调函数配置定时器控制外设所需要配置的GP IO等硬件配置,训练我们的代码工程性。
再给大家补充一下什么是反初始化,反初始化(De-initialization)是指将之前通过初始化操作配置好的硬件或软件资源恢复到初始状态,并释放相关资源的过程。我们使用反初始化可以在不需要某个外设继续工作时,通过反初始化可以释放该外设占用的资源,以便其他功能或外设能够使用这些资源,并且正确的反初始化操作可以确保系统在不同功能切换或异常情况下保持稳定,避免资源冲突或错误配置导致的问题。
其他回调函数在哪里被调用,我就不在这里详细说明了,感兴趣可以自己查询,但无论是什么回调函数的使用,都是两步走,第一步在主函数外写好回调函数的内容,第二步在主函数里注册回调函数,最后在配置好的行动后回调函数就会自动响应。
这样我们 TIM_HandleTypeDef 类型第二部分回调函数我们就讲完了,下面我们来看最后一部分:#if 和 #endif。
这其实就是一个简单的 C 语言语法,在 C 语言(以及 STM32 HAL 库这类嵌入式开发场景中),#if、#elif、#else、#endif 是预处理指令,用于条件编译。它们的作用是在代码编译阶段根据条件决定哪些代码会被编译,哪些会被忽略,类似于代码中的if-else逻辑,但作用于编译前的预处理阶段。
#if 条件表达式 // 条件为真时,编译这部分代码 #elif 另一个条件表达式 // 前一个条件为假,且当前条件为真时,编译这部分代码 #else // 所有条件都为假时,编译这部分代码 #endif
在我们结构体中他们起到了一个裁剪的作用,例如结构体中语法含义是:如果USE_HAL_TIM_REGISTER_CALLBACKS == 1就启用下面的回调函数指针,如果不等于1,就裁剪掉下面定义的回调函数指针,这里我们打开USE_HAL_TIM_REGISTER_CALLBACKS这个宏的定义

可以看出USE_HAL_TIM_REGISTER_CALLBACKS默认 == 0这说明这些回调函数的可注册默认是被裁剪的。我们要把宏 == 1U,这样才会启用我们的这一部分回调函数。
这样我们 TIM_HandleTypeDef 类型的内容就全部讲完了,我们再回头看这个结构体定义,是不是有种豁然开朗的感觉呢?
下面我们来看初始化的后半部分也就是下面这一堆判断语句,以及所包含函数。

(2)HAL_TIM_Base_Init();
我们先来看第一个if
打开 HAL_TIM_Base_Init 的定义

内容也不少,我们一部分一部分的看,先看第一个if下的内容

上半部分是在检查句柄分配,如果 HAL_TIM_Base_Init 传入的参数 TIM_HandleTypeDef *htim ,这个指针是一个空指针,也就是NULL那么返回一个错误码 HAL_ERROR 如果不为空则不会进入这个if语句。
下半部分使用一系列的断言宏 assert_param 来检查定时器相关参数的合法性。这些断言宏会验证定时器实例是否有效、计数模式是否正确、时钟分频系数是否在允许范围内、自动重装载值是否合法以及自动重装载预加载配置是否正确等。如果参数不合法,断言会触发错误处理。其实这和C 语言中 assert()的原理是一模一样的,HAL库中的断言规则我们就不进去看了,如果意愿大,我以后会单独出一篇文章来讲断言。
接下来我们来看第二个if

简单解释,如果我们传入的句柄的状态为reset(定时器处于重置状态,即定时器尚未初始化或处于初始状态)那么句柄的锁状态寄存器就会配置为未锁定状态。(在多任务或中断环境下,锁资源用于保护对定时器的访问,防止多个任务或中断同时操作定时器而导致的冲突),下面是判断是否启用回调函数的注册功能启用的话,就执行TIM_ResetCallback函数,它的作用是将中断回调函数重置为传统的弱回调函数(弱回调函数是库中提供的默认空实现,用户可以根据需要重写这些回调函数)

我们来看最后一个if,简单解释,如果句柄中Base_MspInitCallback函数指针为空指针,也就说明没有被注册那么就会让指针指向HAL_TIM_Base_MspInit函数,这个是库里给我们默认配置的回调函数,我们不妨打开定义看一下。

基本和咱们上面讲的一样,就是配置一些硬件(如 GPIO、时钟、中断等),然后将句柄传入Base_MspInitCallback运行我们的回调函数。如果没有启用回调函数注册那么就把句柄传入HAL_TIM_Base_MspInit函数,我们打开看一下函数内容。

其实和 Base_MspInitCallback 还真没啥区别。
接下来就是修改定时器状态,配置为忙碌状态,接下来调用 TIM_Base_SetConfig 函数,根据定时器句柄 htim 中的初始化参数(htim->Init)设置定时器的基本配置,如自动重装载值、预分频器值等,我们打开 TIM_Base_SetConfig 看看。

也是很多,我们还是来逐句分析,等下面遇到类似逻辑代码我们就略过了,第一步是在保存当前 CR1 寄存器的值到局部变量 tmpcr1,以便后续修改和恢复。
void TIM_Base_SetConfig(TIM_TypeDef *TIMx, const TIM_Base_InitTypeDef *Structure)
{
uint32_t tmpcr1;
tmpcr1 = TIMx->CR1;
接下来开始配置计数器模式
/* Set TIM Time Base Unit parameters ---------------------------------------*/
if (IS_TIM_COUNTER_MODE_SELECT_INSTANCE(TIMx))
{
/* Select the Counter Mode */
tmpcr1 &= ~(TIM_CR1_DIR | TIM_CR1_CMS);
tmpcr1 |= Structure->CounterMode;
}
IS_TIM_CLOCK_DIVISION_INSTANCE(TIMx) 这个函数我们之前见过,是在前面断言那一部分的,那么功能我们就知道了,IS_TIM_CLOCK_DIVISION_INSTANCE(TIMx) 检查定时器是否支持时钟分频配置。tmpcr1 &= ~(TIM_CR1_DIR | TIM_CR1_CMS):清除 CR1 寄存器中的 DIR(方向)和 CMS(中心对齐模式选择)位;tmpcr1 |= Structure->CounterMode:设置新的计数器模式。
* 位操作详解
那么这两句代码是什么原理呢?我给大家讲一下,不想了解可以跳过。首先我们要知道在嵌入式编程中,寄存器的每一位都有特定的功能。为了精确控制这些位,通常使用位掩码和位操作。那什么是位掩码?位掩码是一个二进制数,其中某一位为 1,其余位为 0,例如,TIM_CR1_DIR 和 TIM_CR1_CMS 是位掩码,分别用于表示 CR1 寄存器中的 DIR(方向)和 CMS(中心对齐模式选择)位,这样感受还不直观,等下给大家举实际例子,那什么是位操作,其实c语言功底好的同学肯定不陌生了,其实就是三个操作符&(按位与) |(按位或) ~(按位取反),总结下来:按位与(&):用于清除或保留某些位;按位或(|):用于设置某些位;按位取反(~):用于反转一个数的所有位;有了这些基础知识,我们接下来讲原理,第一步组合位掩码:TIM_CR1_DIR | TIM_CR1_CMS 是将 TIM_CR1_DIR 和 TIM_CR1_CMS 进行按位或操作,生成一个新的位掩码,简单说就是将两个位掩码合成为一个了,例如,假设 TIM_CR1_DIR 的值为 0x00000004(二进制 00000000 00000000 00000000 00000100),TIM_CR1_CMS 的值为 0x00000008(二进制 00000000 00000000 00000000 00001000),则 TIM_CR1_DIR | TIM_CR1_CMS 的值为 0x0000000C(二进制 00000000 00000000 00000000 00001100),第二步取反位掩码:~(TIM_CR1_DIR | TIM_CR1_CMS) 是对组合后的位掩码进行按位取反,例如,~0x0000000C 的值为 0xFFFFFFF3(二进制 11111111 11111111 11111111 11110011),第三步按位与操作:tmpcr1 &= ~(TIM_CR1_DIR | TIM_CR1_CMS) 是将 tmpcr1 与取反后的位掩码进行按位与操作,按位与操作的规则是:只有当两个操作数的对应位都为 1 时,结果位才为 1;否则为 0,因此,取反后的位掩码中为 0 的位会将 tmpcr1 中对应的位清除为 0,而取反后的位掩码中为 1 的位会保留 tmpcr1 中对应的位不变。例如:假设 tmpcr1 的初始值为 0x0000000F(二进制 00000000 00000000 00000000 00001111),TIM_CR1_DIR | TIM_CR1_CMS 的值为 0x0000000C(二进制 00000000 00000000 00000000 00001100),~(TIM_CR1_DIR | TIM_CR1_CMS) 的值为 0xFFFFFFF3(二进制 11111111 11111111 11111111 11110011),按位与操作后,tmpcr1 的值为 0x00000003(二进制 00000000 00000000 00000000 00000011),可以看到,tmpcr1 中与 TIM_CR1_DIR 和 TIM_CR1_CMS 对应的位被清除为 0,而其他位保持不变。那么下面的按位或也是同理,其实它们两个看似很麻烦,其实总结下来就是 &= 用来清除某些位,不过要配合 ~ 取反使用,|= 用来配置某些位,| 是用来组合两个状态。
接下来对寄存器配置都用的这个原理
if (IS_TIM_CLOCK_DIVISION_INSTANCE(TIMx))
{
/* Set the clock division */
tmpcr1 &= ~TIM_CR1_CKD;
tmpcr1 |= (uint32_t)Structure->ClockDivision;
}
IS_TIM_CLOCK_DIVISION_INSTANCE(TIMx) 检查定时器是否支持时钟分频配置。
清除 CR1 寄存器中的 CKD(时钟分频)位。
根据 Structure->ClockDivision 设置新的时钟分频值。
/* Set the auto-reload preload */
MODIFY_REG(tmpcr1, TIM_CR1_ARPE, Structure->AutoReloadPreload);TIMx->CR1 = tmpcr1;
我们来看第一个函数MODIFY_REG,我们打开它的定义

诶,竟然不是一个函数,是一个宏定义,宏定义了一个名叫WRITE_REG的函数,而且还把传入的参数进行处理,我们上面讲过位操作符,第一个参数不变,第二个参数的意思是先CLEARMASK取反把位掩码提取,然后用 | 操作符把取反之后的 CLEARMASK 与 SETMASK 没有取反组合然后使用 & 操作符把 READ_REG(REG) 中的 CLEARMASK 的位掩码消除,把 SETMASK 的位掩码配置到 READ_REG(REG) 中,不过你不会以为 READ_REG(REG) 也是一个函数吧,其实我本来也这样一位的,当我打开他的定义发现,这个看似函数的“返回值”,其实就是参数自己

这看起来有点奇怪啊,这不多此一举吗定义的莫名其妙,但其实这样定义是有利于代码的可读性,我们读到 ((REG)) 不一定知道是什么,但读到 READ_REG(REG) 我们就能大致知道,这是一个读寄存器操作。
经过上面一系列的操作,我们就实现了这个“函数”(很多宏定义组合的伪函数),这个伪函数使用 MODIFY_REG 宏修改 tmpcr1 寄存器中的 ARPE(自动重装载预加载使能)位,根据 Structure->AutoReloadPreload 设置是否启用自动重装载预加载,然后用下一句代码来更新控制寄存器,也就是CR1.
/* Set the Autoreload value */
TIMx->ARR = (uint32_t)Structure->Period ;/* Set the Prescaler value */
TIMx->PSC = Structure->Prescaler;
这两步分别是设置自动重装载寄存器(ARR)的值为 Structure->Period,即定时器的周期值,和
设置预分频器寄存器(PSC)的值为 Structure->Prescaler,即定时器的预分频值,代码很简单就不叙述了。
if (IS_TIM_REPETITION_COUNTER_INSTANCE(TIMx))
{
/* Set the Repetition Counter value */
TIMx->RCR = Structure->RepetitionCounter;
}/* Generate an update event to reload the Prescaler
and the repetition counter (only for advanced timer) value immediately */
TIMx->EGR = TIM_EGR_UG;/* Check if the update flag is set after the Update Generation, if so clear the UIF flag */
if (HAL_IS_BIT_SET(TIMx->SR, TIM_FLAG_UPDATE))
{
/* Clear the update flag */
CLEAR_BIT(TIMx->SR, TIM_FLAG_UPDATE);
}
最后这一部分就一起讲吧,因为逻辑都是上面讲过的了,先使用检查函数IS_TIM_REPETITION_COUNTER_INSTANCE(TIMx) 检查定时器是否支持重复计数器配置(仅高级定时器支持),如果返回值是正确也就是1,那么就设置重复计数器寄存器(RCR)的值为 Structure->RepetitionCounter,不过这个是高级定时器的重复计数器功能,在我们通用定时器是没有这个功能的。之后向事件生成寄存器(EGR)写入 TIM_EGR_UG(更新生成)位,生成一个更新事件,使预分频器和重复计数器的值立即生效,最后一个if,还是检查函数HAL_IS_BIT_SET(TIMx->SR, TIM_FLAG_UPDATE) 检查状态寄存器(SR)中的更新标志(UIF)是否被设置,如果更新标志被设置,则清除该标志。
这样我们就看完了TIM_Base_SetConfig函数,回过头来,这个函数的功能就是在配置硬件资源吧,配置各种寄存器,这其实在各种硬件初始化中是很常见的一类函数。
下面我们回到主线继续把 HAL_TIM_Base_Init 讲完

第一句就是配置DMA突发状况操作的状态,将其配置为DMA 突发操作处于就绪状态。这条语句将 htim 所指向的定时器的 DMA 突发操作状态设置为就绪状态。接下来是配置通道状态,将全部TIM通道配置为就绪状态,下一句是配置互补通道状态为就绪状态,这个也是高级定时器的功能,接下来配置TIM的整体状态为就绪状态,当这些工作全部完成,返回HAL_OK。这样我们的HAL_TIM_Base_Init函数就结束了,但最后的最后,我还是想给大家看一下TIM_CHANNEL_STATE_SET_ALL函数的实现,其实这也是一个宏定义的伪函数。

我们可以看到他用了个宏定义,定义内容是一个do while() 循环 while括号里是0,就是这里不循环,执行一遍do就结束,我们来看do里面的内容,就是配置寄存器吧,代码很容易看懂,但这不是我想说的,我想说的是其实我们可以看到上面也有一个宏定义的函数 TIM_CHANNEL_STATE_SET 写法和下面这个函数很像都是直接配置寄存器,其实定时器绝大多数我们以为很复杂的函数,都是利用这种方法,对寄存器直接操作实现的,在下文我们也会看到不少这样的函数。

我们继续把定时器初始化部分结尾,当我们的返回值 == HAL_OK的时候,我们就执行一个判断错误的函数 Error_Handler() 没有为题内部就不会断言报错(一般没有问题),接下来我们的IC,OC,PWM的Init,我们放在下节定时器的输入捕获和输出比较专门讲,那么我们就剩最后一个函数了那就是HAL_TIM_MspPostInit。

(3)HAL_TIM_MspPostInit();
其实这个函数很简单内容也很少,我们就快速讲了,首先我们打开定义。

诶,我的妈呀,熟悉!太熟悉了!这不是我们要在回调函数里写的部分硬件配置吗?没错就是这样,库怕咱们初学者不知道,或者写的不规范,搞了一个这样的函数在msp文件里,防着我们没有初始化定时器对应的GPIO等硬件资源,但是话又说回来,我们在配置回调函数的硬件资源时可以跳过这个函数的配置,只要把其他配置好就可以啦,如果说就是要全部配置好,也没问题,只是我们不再需要HAL_TIM_MspPostInit();函数,把他直接删掉就可以了。
那么我们的第一节定时器的初始化就全部讲完了,相信大家应该学到了不少知识,下一节我将带大家走进定时器的输入捕获和输出比较这个非常常用的功能,期待我们的共同进步。
2.定时器的输入捕获
(1)Input Capture direct mode
我们IC章节第一个讲的就是Input Capture direct mode(输入捕获直接模式),首先我们要知道输入捕获直接模式是什么,他有什么用途,我来为大家一一解释。
作用:在该模式下,定时器会对外部输入信号的特定事件(如上升沿、下降沿等)进行捕获,并记录下发生该事件时定时器的计数值。通过对不同时刻捕获到的计数值进行计算,可以测量外部信号的周期、频率、脉宽等参数。
用途:常用于测量传感器输出信号的频率,比如转速传感器,通过测量其输出脉冲信号的频率来计算设备的转速;或者测量脉冲宽度,例如测量红外传感器接收到的脉冲信号的宽度来判断物体的距离等相关信息。
现在我们就对输入捕获直接模式有一定认识了,我们要了解IC的底层逻辑,就还是要从IC初始化开始,我们接下来讲这一章的第一个函数,也是我们初始化跳过的IC部分 HAL_TIM_IC_Init
HAL_StatusTypeDef HAL_TIM_IC_Init(TIM_HandleTypeDef *htim)
{
/* Check the TIM handle allocation */
if (htim == NULL)
{
return HAL_ERROR;
}
/* Check the parameters */
assert_param(IS_TIM_INSTANCE(htim->Instance));
assert_param(IS_TIM_COUNTER_MODE(htim->Init.CounterMode));
assert_param(IS_TIM_CLOCKDIVISION_DIV(htim->Init.ClockDivision));
assert_param(IS_TIM_PERIOD(htim->Init.Period));
assert_param(IS_TIM_AUTORELOAD_PRELOAD(htim->Init.AutoReloadPreload));
if (htim->State == HAL_TIM_STATE_RESET)
{
/* Allocate lock resource and initialize it */
htim->Lock = HAL_UNLOCKED;
#if (USE_HAL_TIM_REGISTER_CALLBACKS == 1)
/* Reset interrupt callbacks to legacy weak callbacks */
TIM_ResetCallback(htim);
if (htim->IC_MspInitCallback == NULL)
{
htim->IC_MspInitCallback = HAL_TIM_IC_MspInit;
}
/* Init the low level hardware : GPIO, CLOCK, NVIC */
htim->IC_MspInitCallback(htim);
#else
/* Init the low level hardware : GPIO, CLOCK, NVIC and DMA */
HAL_TIM_IC_MspInit(htim);
#endif /* USE_HAL_TIM_REGISTER_CALLBACKS */
}
/* Set the TIM state */
htim->State = HAL_TIM_STATE_BUSY;
/* Init the base time for the input capture */
TIM_Base_SetConfig(htim->Instance, &htim->Init);
/* Initialize the DMA burst operation state */
htim->DMABurstState = HAL_DMA_BURST_STATE_READY;
/* Initialize the TIM channels state */
TIM_CHANNEL_STATE_SET_ALL(htim, HAL_TIM_CHANNEL_STATE_READY);
TIM_CHANNEL_N_STATE_SET_ALL(htim, HAL_TIM_CHANNEL_STATE_READY);
/* Initialize the TIM state*/
htim->State = HAL_TIM_STATE_READY;
return HAL_OK;
}
(这里说明一下下文就不给大家截屏函数内容了,我个人感觉很糊看不清,以后库里的代码就以上面这种形式展现了,如果有建议可以在评论区讨论)
HAL_TIM_IC_Init函数其实和HAL_TIM_Base_Init函数的格式是一样的,先检查传入的句柄是不是不存在,不存在就返回HAL_ERROR错误,下面就是断言检查句柄状态,接下来检查定时器状态如果是未初始化状态HAL_TIM_STATE_RESET,就为定时器分配锁资源HAL_UNLOCKED ,接下来就是条件编译#if,如果启用回调函数的注册,然后调用TIM_ResetCallback函数,然后判断我们有没有注册,如果没有就使用库的默认注册函数IC_MspInitCallback,如果注册了那就把句柄传入并调用我们注册的回调函数,接下来设置定时器状态为忙碌状态,调用TIM_Base_SetConfig函数配置硬件资源,初始化 DMA 突发操作状态为 HAL_DMA_BURST_STATE_READY,表示 DMA 突发操作已准备好,然后配置通道状态为就绪状态,完成一切后配置定时器为就绪状态,返回HAL_OK,其实和HAL_TIM_Base_Init几乎差不多,只有回调函数有所不同。
我们再把MX_TIM2_Init函数后半IC部分拉出来,我们可以看到当返回HAL_OK之后,和之前一样会调用一次Error_Handler函数检查错误。
if (HAL_TIM_IC_Init(&htim2) != HAL_OK)
{
Error_Handler();
}
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig) != HAL_OK)
{
Error_Handler();
}
sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING;
sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI;
sConfigIC.ICPrescaler = TIM_ICPSC_DIV1;
sConfigIC.ICFilter = 0;
if (HAL_TIM_IC_ConfigChannel(&htim2, &sConfigIC, TIM_CHANNEL_1) != HAL_OK)
{
Error_Handler();
}
接下来就是配置主从触发模式了,先配置触发源为TIM_TRGO_RESET,这表示当定时器更新时,触发输出信号为复位信号。这通常用于同步其他外设,例如在定时器更新时复位另一个定时器或外设。然后配置的是该定时器是否为主模式,我们途中配置的是禁止主模式,也就是不控制任何其他定时器,接下来调用HAL_TIMEx_MasterConfigSynchronization函数,配置定时器主模式,这里有的同学可能觉得比较矛盾,我都配置禁止主模式了,为什么还要配置主模式,我来解释一下:HAL 库的 API 设计强调 "显式配置所有关键参数",即使某些功能最终要禁用,也需要通过结构体参数明确指定禁用状态,而不是省略配置步骤,对于定时器的主从模式,必须通过MasterSlaveMode参数明确设置为TIM_MASTERSLAVEMODE_DISABLE,同时需要指定MasterOutputTrigger(主模式触发输出)的状态(即使禁用主从模式,这个参数仍会影响定时器的某些硬件行为)。这样看来,我们很有必要看看HAL_TIMEx_MasterConfigSynchronization函数的内部是什么生态环境。我们打开定义
HAL_StatusTypeDef HAL_TIMEx_MasterConfigSynchronization(TIM_HandleTypeDef *htim,
const TIM_MasterConfigTypeDef *sMasterConfig)
{
uint32_t tmpcr2;
uint32_t tmpsmcr;
/* Check the parameters */
assert_param(IS_TIM_MASTER_INSTANCE(htim->Instance));
assert_param(IS_TIM_TRGO_SOURCE(sMasterConfig->MasterOutputTrigger));
assert_param(IS_TIM_MSM_STATE(sMasterConfig->MasterSlaveMode));
/* Check input state */
__HAL_LOCK(htim);
/* Change the handler state */
htim->State = HAL_TIM_STATE_BUSY;
/* Get the TIMx CR2 register value */
tmpcr2 = htim->Instance->CR2;
/* Get the TIMx SMCR register value */
tmpsmcr = htim->Instance->SMCR;
/* Reset the MMS Bits */
tmpcr2 &= ~TIM_CR2_MMS;
/* Select the TRGO source */
tmpcr2 |= sMasterConfig->MasterOutputTrigger;
/* Update TIMx CR2 */
htim->Instance->CR2 = tmpcr2;
if (IS_TIM_SLAVE_INSTANCE(htim->Instance))
{
/* Reset the MSM Bit */
tmpsmcr &= ~TIM_SMCR_MSM;
/* Set master mode */
tmpsmcr |= sMasterConfig->MasterSlaveMode;
/* Update TIMx SMCR */
htim->Instance->SMCR = tmpsmcr;
}
/* Change the htim state */
htim->State = HAL_TIM_STATE_READY;
__HAL_UNLOCK(htim);
return HAL_OK;
}
这个函数和之前的TIM_Base_SetConfig 是不是很像,毕竟他们都是控制函数嘛,这其中的寄存器操作我就不讲了,要是有疑惑可以看看我们上面讲的位操作详解。
最后这一部分就是对通道的配置了,我把上面那张图最后一部分拿下来。
sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING;
sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI;
sConfigIC.ICPrescaler = TIM_ICPSC_DIV1;
sConfigIC.ICFilter = 0;
if (HAL_TIM_IC_ConfigChannel(&htim2, &sConfigIC, TIM_CHANNEL_1) != HAL_OK)
{
Error_Handler();
}
先设置输入捕获通道的极性为上升沿触发,这意味着当输入信号的电平从低变高时,定时器会捕获当前的计数值,当然我们也可以在这里配置下降沿触发,或者是都触发,这个跟需要配置嘛;下一句代码设置输入捕获通道的选择为直接输入模式(TIM_ICSELECTION_DIRECTTI),直接输入模式表示输入捕获信号直接来自定时器的外部引脚,它还可以是TIM_ICSELECTION_INDIRECTTI(间接输入模式)和TIM_ICSELECTION_TRC(触发输入模式)。接下来是设置输入捕获预分频器为 1 分频(TIM_ICPSC_DIV1),预分频器用于对输入捕获信号进行分频,这里设置为 1 分频表示不对输入信号进行分频。下面是设置输入捕获滤波器的值为 0,输入捕获滤波器用于对输入信号进行滤波,减少噪声的影响。值为 0 表示不使用滤波器。最后调用HAL_TIM_IC_ConfigChannel配置通道的硬件资源,没问题了就调用错误判断函数,最后结束初始化。我们在这里简单来看一下HAL_TIM_IC_ConfigChannel函数的实现。
HAL_StatusTypeDef HAL_TIM_IC_ConfigChannel(TIM_HandleTypeDef *htim, const TIM_IC_InitTypeDef *sConfig, uint32_t Channel)
{
HAL_StatusTypeDef status = HAL_OK;
/* Check the parameters */
assert_param(IS_TIM_CC1_INSTANCE(htim->Instance));
assert_param(IS_TIM_IC_POLARITY(sConfig->ICPolarity));
assert_param(IS_TIM_IC_SELECTION(sConfig->ICSelection));
assert_param(IS_TIM_IC_PRESCALER(sConfig->ICPrescaler));
assert_param(IS_TIM_IC_FILTER(sConfig->ICFilter));
/* Process Locked */
__HAL_LOCK(htim);
if (Channel == TIM_CHANNEL_1)
{
/* TI1 Configuration */
TIM_TI1_SetConfig(htim->Instance,
sConfig->ICPolarity,
sConfig->ICSelection,
sConfig->ICFilter);
/* Reset the IC1PSC Bits */
htim->Instance->CCMR1 &= ~TIM_CCMR1_IC1PSC;
/* Set the IC1PSC value */
htim->Instance->CCMR1 |= sConfig->ICPrescaler;
}
else if (Channel == TIM_CHANNEL_2)
{
/* TI2 Configuration */
assert_param(IS_TIM_CC2_INSTANCE(htim->Instance));
TIM_TI2_SetConfig(htim->Instance,
sConfig->ICPolarity,
sConfig->ICSelection,
sConfig->ICFilter);
/* Reset the IC2PSC Bits */
htim->Instance->CCMR1 &= ~TIM_CCMR1_IC2PSC;
/* Set the IC2PSC value */
htim->Instance->CCMR1 |= (sConfig->ICPrescaler << 8U);
}
else if (Channel == TIM_CHANNEL_3)
{
/* TI3 Configuration */
assert_param(IS_TIM_CC3_INSTANCE(htim->Instance));
TIM_TI3_SetConfig(htim->Instance,
sConfig->ICPolarity,
sConfig->ICSelection,
sConfig->ICFilter);
/* Reset the IC3PSC Bits */
htim->Instance->CCMR2 &= ~TIM_CCMR2_IC3PSC;
/* Set the IC3PSC value */
htim->Instance->CCMR2 |= sConfig->ICPrescaler;
}
else if (Channel == TIM_CHANNEL_4)
{
/* TI4 Configuration */
assert_param(IS_TIM_CC4_INSTANCE(htim->Instance));
TIM_TI4_SetConfig(htim->Instance,
sConfig->ICPolarity,
sConfig->ICSelection,
sConfig->ICFilter);
/* Reset the IC4PSC Bits */
htim->Instance->CCMR2 &= ~TIM_CCMR2_IC4PSC;
/* Set the IC4PSC value */
htim->Instance->CCMR2 |= (sConfig->ICPrescaler << 8U);
}
else
{
status = HAL_ERROR;
}
__HAL_UNLOCK(htim);
return status;
}
虽然它很长,但是我们有了前面的基础理解它还是很轻松的,我们还是一部分一部分的分析,我们先看第一部分,首先判断传入参数的合法性,熟悉的断言操作,接下来执行资源锁机制
__HAL_LOCK(htim); // 加锁
// ... 配置逻辑 ...
__HAL_UNLOCK(htim); // 解锁
原理如图,__HAL_LOCK 和 __HAL_UNLOCK 是 HAL 库的线程安全机制,用于防止多线程同时操作同一个定时器资源(如中断服务程序和主程序同时配置定时器),确保配置过程的原子性。这个解释也挺学术的,不过我们应该可以很形象的理解这就是个保护机制。
if (Channel == TIM_CHANNEL_1)
{
/* TI1 Configuration */
TIM_TI1_SetConfig(htim->Instance,
sConfig->ICPolarity,
sConfig->ICSelection,
sConfig->ICFilter);
/* Reset the IC1PSC Bits */
htim->Instance->CCMR1 &= ~TIM_CCMR1_IC1PSC;
/* Set the IC1PSC value */
htim->Instance->CCMR1 |= sConfig->ICPrescaler;
}
这是第一个if下的内容,意思是如果是通道1他就会调用TIM_TI1_SetConfig函数用之前控制函数的方法,先创建记录变量,然后一些位操作,把各种状态位记录到变量里,最后把变量的值穿给寄存器。接下来,将CCMR1(比较/捕获寄存器1)清除旧值写入新值,完成配置。
else if (Channel == TIM_CHANNEL_2)
{
/* TI2 Configuration */
assert_param(IS_TIM_CC2_INSTANCE(htim->Instance));
TIM_TI2_SetConfig(htim->Instance,
sConfig->ICPolarity,
sConfig->ICSelection,
sConfig->ICFilter);
/* Reset the IC2PSC Bits */
htim->Instance->CCMR1 &= ~TIM_CCMR1_IC2PSC;
/* Set the IC2PSC value */
htim->Instance->CCMR1 |= (sConfig->ICPrescaler << 8U);
}
接下来看第二个if,这个是配置通道2的代码和配置通道1基本一样,唯一区别在于最后给CCRM写入的时候把sConfig->ICPrescaler向左移动了8位,这是因为通道 2 的预分频位(IC2PSC)在 CCMR1 寄存器的高 8 位(而通道 1 在低 8 位),因此写入时需要左移 8 位(<<8U)。
else if (Channel == TIM_CHANNEL_3)
{
/* TI3 Configuration */
assert_param(IS_TIM_CC3_INSTANCE(htim->Instance));
TIM_TI3_SetConfig(htim->Instance,
sConfig->ICPolarity,
sConfig->ICSelection,
sConfig->ICFilter);
/* Reset the IC3PSC Bits */
htim->Instance->CCMR2 &= ~TIM_CCMR2_IC3PSC;
/* Set the IC3PSC value */
htim->Instance->CCMR2 |= sConfig->ICPrescaler;
}
else if (Channel == TIM_CHANNEL_4)
{
/* TI4 Configuration */
assert_param(IS_TIM_CC4_INSTANCE(htim->Instance));
TIM_TI4_SetConfig(htim->Instance,
sConfig->ICPolarity,
sConfig->ICSelection,
sConfig->ICFilter);
/* Reset the IC4PSC Bits */
htim->Instance->CCMR2 &= ~TIM_CCMR2_IC4PSC;
/* Set the IC4PSC value */
htim->Instance->CCMR2 |= (sConfig->ICPrescaler << 8U);
}
下面就是配置通道3和通道4,和配置1,2的原理是一样的,不过用的是CCMR2其他是一样的,不重复赘述。
else
{
status = HAL_ERROR;
}
__HAL_UNLOCK(htim);
return status;
最后一部分是一个错误处理,若传入的 Channel 不是 TIM_CHANNEL_1 到 TIM_CHANNEL_4 中的任何一个,函数返回 HAL_ERROR。若正常就返回status,也就是HAL_OK。
配置好这些我们就完成了所有对于Input Capture direct mode的初始化配置了,我们接下来讲一下这个模式的应用场景,和如何使用,并举一个简单的例子。
应用场景:常用于测量传感器输出信号的频率,比如转速传感器,通过测量其输出脉冲信号的频率来计算设备的转速;或者测量脉冲宽度,例如测量红外传感器接收到的脉冲信号的宽度来判断物体的距离等相关信息。
使用方法:首先要配置定时器,选择输入捕获功能,并设置捕获的触发条件(上升沿、下降沿或者双边沿)。然后将外部信号连接到定时器对应的输入引脚。当满足触发条件时,定时器会自动将当前计数值存储到捕获寄存器中,软件后续可以读取捕获寄存器的值,并根据前后捕获的值进行计算,得到所需的测量结果。
接下来我们就用一个小项目讲一下使用方法代码怎么写。我们就来实现上面提到的“测量红外传感器接收到的脉冲信号的宽度来判断物体的距离”,这个项目我们会更加注重代码规范性,尽量接近我理解的公司项目标准。我会带大家从0到1把这个小项目写出来,并给大家解释代码,那么话不多说,我们开始发车。
* 项目实战:红外测距
1.简介
这一节我将从编写一个完整项目的角度给大家讲解直接输入捕获模式的一种最常用的使用方法,和内部函数实现原理,*函数回调的内部逻辑等等,全程干货,一定要认真看完。
2.一个高质量工程的开始
首先,在一个项目开始时,我们应该做什么?我给大家三个答案。
1.在kile5先建新工程起文件框架,根据需要临时思考并初始化硬件资源。
2.直接到cubemx里配置需要的硬件,边想需要什么边配置。
3.明确项目要求,设计项目方案,对照方案配置硬件资源。
很显然3是正确的,1盲目的代价只会让后来写到逻辑时越写越乱。2先想好需要什么但是一股脑全部配置上,也会让找bug这一步变得尤为困难,我们这一步设计工作看似繁琐多余,实际上这对我们项目的实现极为重要,希望大家能设计好再去配置,不要边配置边设计。我之前,第一次写一个从0到1的工程那会,我因为上两个错误,后续工作走了不少弯路,所以,我想和大家在正式开始讲解写一个工程前把我的这一点心得分享给大家,想让大家在学习过程中少走一些弯路。那么下面我们从设计来开始带着大家做。
-
明确项目需求
- 核心功能:通过输入捕获模式测量红外传感器脉冲宽度,计算物体距离。
- 性能要求:测量精度(如 ±1cm)、刷新频率(如 10Hz)、硬件资源限制(STM32F103C8T6 的 TIM2、PA0 等)。
- 可移植性目标:未来可能迁移到其他 STM32 型号(如 F4 系列),需隔离硬件配置。
-
硬件方案确认
- 传感器选型:确定红外传感器型号(如夏普 GP2Y0A21YK),明确输出信号特性(脉冲宽度与距离的关系)还有一些外设参数比如最大频率(这点很重要尤其是电机这种容易坏的外设)。
- 引脚分配:TIM2_CH1(PA0)接传感器输出。
- 冲突检查:确保 TIM2、PA0 未被其他外设(如调试接口、LED)占用。
接下来就是配置CubeMX。
这里CubeMX怎么用,包括选芯片型号怎么生成代码,这些基础就不给大家讲了。给大家简单说一下这个工程的GPIO和TIMER。
- 定时器配置(TIM2):
- 时钟源:
Internal Clock - 预分频器(Prescaler):
71(72MHz/72=1MHz,1us 计数单位) - 自动重装载值(Counter Period):
65535(0xFFFF) - 通道 1(CH1):
Input Capture direct mode - 触发极性:默认
Rising Edge(后续通过代码切换) - 使能中断:
TIM2 global interrupt
- 时钟源:
- GPIO 配置(PA0):
- 模式:
TIM2_CH1(复用推挽输入,由 CubeMX 自动关联定时器通道)
- 模式:
配置好之后我们生成代码,接下来基于 CubeMX 生成的工程,手动创建分层目录。
3.列表文件的创建与编写

创建文本文件(如图格式)并把架构描述写进去(如下图)。

写好框架,就要实现硬件相关的初始化逻辑(通过回调函数与 CubeMX 框架对接)。
4.msp文件的编写
-
回调注册机制实现
- 在
msp_callback.c中实现回调结构体的存储(msp_callbacks)、注册(Msp_RegisterCallbacks)和获取(Msp_GetCallbacks)函数。
- 在
-
传感器硬件配置(
ir_sensor_msp.c)- 实现
IR_Sensor_BaseMspInit:配置全局硬件(如 GPIOA 时钟使能)。 - 实现
IR_Sensor_TimMspInit:配置 TIM2 的时钟、GPIO(PA0)、中断优先级。 - 实现
IR_Sensor_TimMspDeInit:释放 TIM2 相关资源(反初始化 GPIO、关闭时钟)。 - 实现
IR_Sensor_RegisterMspCallbacks:将上述回调注册到系统中。
- 实现
-
修改 CubeMX 自动生成的
stm32f1xx_hal_msp.c- 在
HAL_MspInit、HAL_TIM_IC_MspInit、HAL_TIM_IC_MspDeInit中调用注册的回调函数,让用户配置生效。
- 在
接下来我们跟着步骤,来写代码,先完成msp_callback.c和.h文件,这两个文件,其实是为模块的msp函数与总msp函数搭建桥梁(原理我们下面回调逻辑实现部分详细讲解),既然我们要配置硬件,使用回调函数,那我们第一步就是要把USE_HAL_TIM_REGISTER_CALLBACKS 这个宏配置成1,为什么我们上面讲了。
![]()
接下来我们先写msp_callback.h文件

我先把这个文件使用的一个陌生 C 语言语法给大家讲一下
知识点1--给函数类型起别名
不定义类型时,声明一个定时器初始化回调函数指针需要写:
void (*tim_init_ptr)(TIM_HandleTypeDef* htim); // 每次声明都要写完整的函数指针格式
定义类型后,声明变得简洁:
TIM_MspInitCallback tim_init_ptr; // 直接用别名,可读性大幅提升
我们只要遵循下面这个语法结构来写就可以给函数类型起别名
typedef 返回值类型 (*类型别名)(参数列表);
接下来对比上面两段代码你就能明白什么是函数别名,那么带回.h文件,第9和10行代码就是在给函数类型起别名,第14和15行就是在使用这个别名,看似我们创建的是两个变量,实际上是两个函数指针。
知识点2--系统回调池
为啥.h里有这个结构体呢?我们不妨看看它对应的.c文件,或许就能有些理解。

.c写成这样就可以啦,是不是没有理解,一眼懵逼,没关系,我来给大家讲一下,尽量让大家先理解代码是什么意思,我们看第一部分这个静态结构体,先解释为啥要静态,静态好处是只在本文件生效,可以避免和其他文件变量冲突,工程中很常见的手法,然后解释为啥写这样一个结构体(这个结构体的类型是我们刚刚在 .h 文件里创建的哈),其实学过数据结构的同学看到这个结构体应该还挺亲切的,这其实就是一个注册表,写明我在IC这一部分需要用到哪些回调函数,这和数据结构节点注册时要在结构体里添加头指针,注册节点数量等等成员变量,是一个道理。
我们这里先把结构体成员置空(防止指针为野指针)然后给ic_callback的函数结果赋值callback的函数内容,也就是我们说的搭建桥梁把传入的函数存储到我们的列表中,再使用最后这个函数获取我们存储的列表以及存储的函数内容,话说回来,有没有感觉这个文件代码效果像一个仓库,是一个函数中转站,这样一个msp_callback文件我们通常称之为——系统回调池。
接下来配置stm32f1xx_hal_msp.c这个是CubeMX生成的硬件配置文件,我们需要在他的基础上写一些IC回调函数的注册和具体实现,也就是把系统回调池中的用户自己设计注册的函数在HAL库分配的自动调用的回调函数中调用,是不是感觉:这有病吧,搞这么麻烦,我直接把这些内容写在HAL库给的这些回调函数中不就行了吗,nonono~这太low啦>~<,懂不懂什么叫解耦哇,哈哈开个玩笑,其实这样做就是想把用户代码和库代码解耦,这样更好维护回调内容,假如某一天,我突然发现回调函数里有bug!但是我又把所有回调配置写在了一起,要是只有100多行还可以找一找,那如果1k行,我们排着找,想想都头疼吧,那我在一个回调里放很多我们自己写的归类的回调函数,是不是就很方便了呢,这就是解耦的好处啊。

在用户代码1这里像我这样写就可以啦,什么HAL_IC_MspInit这些是库里的弱定义函数吧,如果忘了赶紧回去复习上文!我们在初始化IC时也就是调用HAL_TIM_IC_Init函数(在cubemx配置好定时器模式,代码自动生成的MX_TIM2_Init函数中被调用)时HAL_IC_MspInit会被调用。什么?说我没证据乱说?这非要让你心服口服,请看下图!

所以所谓的回调函数被自动调用其实也不是真的自动其实都是在初始化函数里被写好了的,不要听有的人乱讲什么回调函数的调用原理涉及到汇编知识,吓唬咱,其实也是在其他函数中用了一些手法来调用罢了。
接下来我们就到了第二步传感器硬件配置(ir_sensor_msp.c)了。
#include "ir_sensor_msp.h"
#include "msp_callback.h"
#include "stm32f1xx_hal.h"
/**
* @brief 红外传感器输入捕获MSP初始化(硬件配置)
*/
static void IR_Sensor_IcMspInit(TIM_HandleTypeDef* htim) {
if (htim->Instance == TIM2) { // 仅处理TIM2
// 1. 使能定时器时钟
__HAL_RCC_TIM2_CLK_ENABLE();
// 2. 配置GPIO(PA0 -> TIM2_CH1,输入捕获模式)
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE(); // 使能GPIOA时钟
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_AF_INPUT; // 复用输入(TIM2_CH1)
GPIO_InitStruct.Pull = GPIO_PULLDOWN; // 下拉输入(根据传感器调整)
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 3. 配置中断(TIM2全局中断)
HAL_NVIC_SetPriority(TIM2_IRQn, 0, 0); // 优先级:抢占0,子优先级0
HAL_NVIC_EnableIRQ(TIM2_IRQn);
}
}
/**
* @brief 红外传感器输入捕获MSP反初始化(释放硬件)
*/
static void IR_Sensor_IcMspDeInit(TIM_HandleTypeDef* htim) {
if (htim->Instance == TIM2) {
// 1. 关闭定时器时钟
__HAL_RCC_TIM2_CLK_DISABLE();
// 2. 反初始化GPIO
HAL_GPIO_DeInit(GPIOA, GPIO_PIN_0);
// 3. 禁用中断
HAL_NVIC_DisableIRQ(TIM2_IRQn);
}
}
/**
* @brief 注册红外传感器的MSP回调
*/
void IR_Sensor_RegisterMspCallbacks(void) {
Msp_Callback_TypeDef callbacks = {
.ic_init = IR_Sensor_IcMspInit, // 绑定IC初始化回调
.ic_dinit = IR_Sensor_IcMspDeInit // 绑定IC反初始化回调
};
Msp_RegisterICCallbacks(&callbacks);
}
代码也挺多的就不截keil5原图了,这一部分实现的就是我们 ic 部分用户自己写的回调函数的内容了,就是写我们需要的硬件资源配置的函数,这第一个函数写的不就是使能时钟, GPIO 初始化配置和 NVIC 初始化配置吗,第二个函数就是常规的反初始化函数配置吧,他的原版内容就出自文件time.c文件里HAL_TIM_Base_MspInit函数和HAL_TIM_Base_DeMspInit函数,需要把内容搬运哦。最后一个函数就是把这两个用户自己注册的回调函数扔进系统回调池最后一起被调用吧。
总结--回调逻辑梳理
到这里咱们msp_callback函数就结束了,我们来梳理一下回调逻辑。
1. 核心设计:回调注册结构体与接口
- 定义回调类型:在
msp_callback.h中定义输入捕获(IC)专用的回调函数指针类型,严格匹配 HAL 库原型:// 输入捕获初始化/反初始化回调类型(参数为TIM句柄,与HAL库一致) typedef void (*IC_MspInitCallback)(TIM_HandleTypeDef* htim); typedef void (*IC_MspDeInitCallback)(TIM_HandleTypeDef* htim); - 回调管理结构体:用于存储注册的回调函数,实现 “集中管理”:
typedef struct { IC_MspInitCallback ic_init; // 输入捕获初始化回调 IC_MspDeInitCallback ic_deinit; // 输入捕获反初始化回调 } Msp_CallbackTypeDef;
- 注册 / 获取接口:提供
Msp_RegisterICCallbacks(注册)和Msp_GetICCallbacks(获取)函数,供用户绑定自定义硬件配置逻辑。
2. 注册流程:用户硬件配置 → 系统回调池
- 用户实现硬件配置:在
ir_sensor_msp.c中实现传感器专属的硬件配置函数(如 GPIO、时钟、中断):// 初始化回调:配置TIM2的IC功能硬件(PA0、时钟、中断) static void IR_Sensor_IcMspInit(TIM_HandleTypeDef* htim) { ... } // 反初始化回调:释放TIM2的IC硬件资源 static void IR_Sensor_IcMspDeInit(TIM_HandleTypeDef* htim) { ... } -
注册到系统:通过
IR_Sensor_RegisterMspCallbacks将上述函数绑定到回调结构体,并注册到系统:void IR_Sensor_RegisterMspCallbacks(void) { Msp_CallbackTypeDef callbacks = { .ic_init = IR_Sensor_IcMspInit, .ic_deinit = IR_Sensor_IcMspDeInit }; Msp_RegisterICCallbacks(&callbacks); // 注册到系统回调池 } -
调用时机:在
main.c中,HAL 初始化前调用IR_Sensor_RegisterMspCallbacks,确保硬件配置在定时器初始化时生效:int main(void) { IR_Sensor_RegisterMspCallbacks(); // 先注册硬件回调 HAL_Init(); // 再初始化HAL库 // ... 后续初始化 }3. 与 HAL 库对接:触发硬件配置
当 HAL 库初始化定时器输入捕获功能时,会自动调用
HAL_TIM_IC_MspInit(CubeMX 生成的框架函数),该函数通过我们的注册机制触发用户自定义配置:// stm32f1xx_hal_msp.c 中HAL库的MSP初始化函数 void HAL_TIM_IC_MspInit(TIM_HandleTypeDef* htim_ic) { // 从系统回调池获取用户注册的IC初始化回调 Msp_CallbackTypeDef* callbacks = Msp_GetICCallbacks(); if (callbacks != NULL && callbacks->ic_init != NULL) { callbacks->ic_init(htim_ic); // 执行用户的硬件配置(如PA0、TIM2时钟) } }看完上面的梳理,想必大家对HAL库设计的回调机制有些认识啦。
5.模块功能实现与中断回调函数的衔接
首先我们先写ir_sensor.h吧。
#ifndef IR_SENSOR_H
#define IR_SENSOR_H
#include "stm32f1xx_hal.h"
// 红外传感器状态枚举
typedef enum {
IR_STATE_IDLE, // 空闲状态(等待上升沿)
IR_STATE_WAIT_FALLING // 等待下降沿
} IR_StateTypeDef;
// 红外传感器句柄结构体
typedef struct {
TIM_HandleTypeDef *htim; // 关联的定时器句柄
uint32_t channel; // 捕获通道(如TIM_CHANNEL_1)
IR_StateTypeDef state; // 传感器工作状态
uint32_t rising_cnt; // 上升沿捕获值
uint32_t falling_cnt; // 下降沿捕获值
uint32_t pulse_width_us; // 脉冲宽度(单位:us)
float distance_cm; // 计算得到的距离(单位:cm)
uint8_t data_ready; // 数据就绪标志(1: 新数据可用)
} IR_Sensor_HandleTypeDef;
/**
* @brief 初始化红外传感器
* @param irs: 传感器句柄指针
* @param htim: 定时器句柄指针(如&htim2)
* @param channel: 捕获通道(如TIM_CHANNEL_1)
*/
void IR_Sensor_Init(IR_Sensor_HandleTypeDef *irs, TIM_HandleTypeDef *htim, uint32_t channel);
/**
* @brief 启动传感器输入捕获
* @param irs: 传感器句柄指针
* @retval HAL状态(HAL_OK/HAL_ERROR)
*/
HAL_StatusTypeDef IR_Sensor_Start(IR_Sensor_HandleTypeDef *irs);
/**
* @brief 停止传感器输入捕获
* @param irs: 传感器句柄指针
* @retval HAL状态(HAL_OK/HAL_ERROR)
*/
HAL_StatusTypeDef IR_Sensor_Stop(IR_Sensor_HandleTypeDef *irs);
/**
* @brief 处理输入捕获事件(供中断回调调用)
* @param irs: 传感器句柄指针
* @param htim: 定时器句柄指针
*/
void IR_Sensor_HandleCapture(IR_Sensor_HandleTypeDef *irs, TIM_HandleTypeDef *htim);
/**
* @brief 获取测量的距离
* @param irs: 传感器句柄指针
* @param distance: 距离存储指针(单位:cm)
* @retval 是否成功获取新数据(1: 成功,0: 无新数据)
*/
uint8_t IR_Sensor_GetDistance(IR_Sensor_HandleTypeDef *irs, float *distance);
#endif
结构体,枚举这些语法大家肯定很熟悉了吧,我就不多啰嗦了,我们直接来看函数实现ir_sensor.c
#include "ir_sensor.h"
#include "stm32f1xx_hal.h"
/**
* @brief 初始化红外传感器
*/
void IR_Sensor_Init(IR_Sensor_HandleTypeDef *irs, TIM_HandleTypeDef *htim, uint32_t channel) {
if (irs == NULL || htim == NULL) return;
irs->htim = htim;
irs->channel = channel;
irs->state = IR_STATE_IDLE;
irs->rising_cnt = 0;
irs->falling_cnt = 0;
irs->pulse_width_us = 0;
irs->distance_cm = 0.0f;
irs->data_ready = 0;
}
/**
* @brief 启动输入捕获
*/
HAL_StatusTypeDef IR_Sensor_Start(IR_Sensor_HandleTypeDef *irs) {
if (irs == NULL) return HAL_ERROR;
return HAL_TIM_IC_Start_IT(irs->htim, irs->channel);
}
/**
* @brief 停止输入捕获
*/
HAL_StatusTypeDef IR_Sensor_Stop(IR_Sensor_HandleTypeDef *irs) {
if (irs == NULL) return HAL_ERROR;
return HAL_TIM_IC_Stop_IT(irs->htim, irs->channel);
}
/**
* @brief 处理捕获事件(上升沿/下降沿)
*/
void IR_Sensor_HandleCapture(IR_Sensor_HandleTypeDef *irs, TIM_HandleTypeDef *htim) {
if (irs == NULL || htim != irs->htim) return;
switch (irs->state) {
case IR_STATE_IDLE:
// 上升沿捕获:记录值并切换为下降沿
irs->rising_cnt = HAL_TIM_ReadCapturedValue(htim, irs->channel);
__HAL_TIM_SET_CAPTUREPOLARITY(htim, irs->channel, TIM_INPUTCHANNELPOLARITY_FALLING);
irs->state = IR_STATE_WAIT_FALLING;
break;
case IR_STATE_WAIT_FALLING:
// 下降沿捕获:计算脉冲宽度
irs->falling_cnt = HAL_TIM_ReadCapturedValue(htim, irs->channel);
// 处理定时器溢出(16位计数器)
if (irs->falling_cnt >= irs->rising_cnt) {
irs->pulse_width_us = irs->falling_cnt - irs->rising_cnt;
} else {
irs->pulse_width_us = (0xFFFF - irs->rising_cnt) + irs->falling_cnt;
}
// 根据传感器特性计算距离(示例公式,需根据 datasheet 校准)
// 假设:距离(cm) = 脉冲宽度(us) * 0.02 - 0.5
irs->distance_cm = (irs->pulse_width_us * 0.02f) - 0.5f;
if (irs->distance_cm < 0) irs->distance_cm = 0;
// 标记数据就绪,切换回上升沿
irs->data_ready = 1;
__HAL_TIM_SET_CAPTUREPOLARITY(htim, irs->channel, TIM_INPUTCHANNELPOLARITY_RISING);
irs->state = IR_STATE_IDLE;
break;
default:
irs->state = IR_STATE_IDLE;
break;
}
}
/**
* @brief 获取距离测量结果
*/
uint8_t IR_Sensor_GetDistance(IR_Sensor_HandleTypeDef *irs, float *distance) {
if (irs == NULL || distance == NULL || !irs->data_ready) {
return 0; // 无新数据
}
*distance = irs->distance_cm;
irs->data_ready = 0; // 清除就绪标志
return 1; // 成功获取
}
先利用结构体的列表特性,把模块变量化,并记录各种属性,这样我们让变量的初始化不那么松散,不会这里一个那里一个,统一管理也方便我们日后修改和增添。第一个函数是封装了库里的中断启动函数,因为遇到上升沿和下降沿我们要触发中断接收数据,所以我们需要启动IC的中断;第二个函数是停止数据收集的,也就是停止终端的触发,给大家把HAL库HAL_TIM_IC_Start_IT函数实现简单讲一下,IR_Sensor_Stop函数和HAL_TIM_IC_Start_IT函数内部原理几乎一样我们就只讲一个了。
HAL_StatusTypeDef HAL_TIM_IC_Start_IT(TIM_HandleTypeDef *htim, uint32_t Channel)
{
HAL_StatusTypeDef status = HAL_OK;
uint32_t tmpsmcr;
HAL_TIM_ChannelStateTypeDef channel_state = TIM_CHANNEL_STATE_GET(htim, Channel);
HAL_TIM_ChannelStateTypeDef complementary_channel_state = TIM_CHANNEL_N_STATE_GET(htim, Channel);
/* Check the parameters */
assert_param(IS_TIM_CCX_INSTANCE(htim->Instance, Channel));
/* Check the TIM channel state */
if ((channel_state != HAL_TIM_CHANNEL_STATE_READY)
|| (complementary_channel_state != HAL_TIM_CHANNEL_STATE_READY))
{
return HAL_ERROR;
}
/* Set the TIM channel state */
TIM_CHANNEL_STATE_SET(htim, Channel, HAL_TIM_CHANNEL_STATE_BUSY);
TIM_CHANNEL_N_STATE_SET(htim, Channel, HAL_TIM_CHANNEL_STATE_BUSY);
switch (Channel)
{
case TIM_CHANNEL_1:
{
/* Enable the TIM Capture/Compare 1 interrupt */
__HAL_TIM_ENABLE_IT(htim, TIM_IT_CC1);
break;
}
case TIM_CHANNEL_2:
{
/* Enable the TIM Capture/Compare 2 interrupt */
__HAL_TIM_ENABLE_IT(htim, TIM_IT_CC2);
break;
}
case TIM_CHANNEL_3:
{
/* Enable the TIM Capture/Compare 3 interrupt */
__HAL_TIM_ENABLE_IT(htim, TIM_IT_CC3);
break;
}
case TIM_CHANNEL_4:
{
/* Enable the TIM Capture/Compare 4 interrupt */
__HAL_TIM_ENABLE_IT(htim, TIM_IT_CC4);
break;
}
default:
status = HAL_ERROR;
break;
}
if (status == HAL_OK)
{
/* Enable the Input Capture channel */
TIM_CCxChannelCmd(htim->Instance, Channel, TIM_CCx_ENABLE);
/* Enable the Peripheral, except in trigger mode where enable is automatically done with trigger */
if (IS_TIM_SLAVE_INSTANCE(htim->Instance))
{
tmpsmcr = htim->Instance->SMCR & TIM_SMCR_SMS;
if (!IS_TIM_SLAVEMODE_TRIGGER_ENABLED(tmpsmcr))
{
__HAL_TIM_ENABLE(htim);
}
}
else
{
__HAL_TIM_ENABLE(htim);
}
}
/* Return function status */
return status;
}
咱们上面也讲过定时器的启动函数,想必看IC_IT应该不会很困难。
HAL_TIM_ChannelStateTypeDef channel_state = TIM_CHANNEL_STATE_GET(htim, Channel);
HAL_TIM_ChannelStateTypeDef complementary_channel_state = TIM_CHANNEL_N_STATE_GET(htim, Channel);
根据我们传入的句柄配置通道和互补通道的状态。
/* Check the parameters */
assert_param(IS_TIM_CCX_INSTANCE(htim->Instance, Channel));
/* Check the TIM channel state */
if ((channel_state != HAL_TIM_CHANNEL_STATE_READY)
|| (complementary_channel_state != HAL_TIM_CHANNEL_STATE_READY))
{
return HAL_ERROR;
}
检查参数合法性,检查通道状态是否异常(是否有其他功能正在利用通道)。
/* Set the TIM channel state */
TIM_CHANNEL_STATE_SET(htim, Channel, HAL_TIM_CHANNEL_STATE_BUSY);
TIM_CHANNEL_N_STATE_SET(htim, Channel, HAL_TIM_CHANNEL_STATE_BUSY);
switch (Channel)
{
case TIM_CHANNEL_1:
{
/* Enable the TIM Capture/Compare 1 interrupt */
__HAL_TIM_ENABLE_IT(htim, TIM_IT_CC1);
break;
}
case TIM_CHANNEL_2:
{
/* Enable the TIM Capture/Compare 2 interrupt */
__HAL_TIM_ENABLE_IT(htim, TIM_IT_CC2);
break;
}
case TIM_CHANNEL_3:
{
/* Enable the TIM Capture/Compare 3 interrupt */
__HAL_TIM_ENABLE_IT(htim, TIM_IT_CC3);
break;
}
case TIM_CHANNEL_4:
{
/* Enable the TIM Capture/Compare 4 interrupt */
__HAL_TIM_ENABLE_IT(htim, TIM_IT_CC4);
break;
}
default:
status = HAL_ERROR;
break;
}
给通道和互补通道配置为忙碌状态,防止其他功能抢占通道,接下来根据通道类型使能输入捕获通道的中断。
if (status == HAL_OK)
{
/* Enable the Input Capture channel */
TIM_CCxChannelCmd(htim->Instance, Channel, TIM_CCx_ENABLE);
/* Enable the Peripheral, except in trigger mode where enable is automatically done with trigger */
if (IS_TIM_SLAVE_INSTANCE(htim->Instance))
{
tmpsmcr = htim->Instance->SMCR & TIM_SMCR_SMS;
if (!IS_TIM_SLAVEMODE_TRIGGER_ENABLED(tmpsmcr))
{
__HAL_TIM_ENABLE(htim);
}
}
else
{
__HAL_TIM_ENABLE(htim);
}
}
如果上面操作都没问题,那么就使能输入捕获通道,最后“根据定时器模式(主 / 从)及从模式触发状态,控制定时器使能逻辑” ,确保从模式下触发未使能时主动启动定时器。其实这些上面讲过了,为了加深印象我再讲一遍。
模块功能实现
下面继续刚刚的代码讲解是怎么利用直接输入捕获来红外线测距的。
/**
* @brief 处理捕获事件(上升沿/下降沿)
*/
void IR_Sensor_HandleCapture(IR_Sensor_HandleTypeDef *irs, TIM_HandleTypeDef *htim) {
if (irs == NULL || htim != irs->htim) return;
switch (irs->state) {
case IR_STATE_IDLE:
// 上升沿捕获:记录值并切换为下降沿
irs->rising_cnt = HAL_TIM_ReadCapturedValue(htim, irs->channel);
__HAL_TIM_SET_CAPTUREPOLARITY(htim, irs->channel, TIM_INPUTCHANNELPOLARITY_FALLING);
irs->state = IR_STATE_WAIT_FALLING;
break;
case IR_STATE_WAIT_FALLING:
// 下降沿捕获:计算脉冲宽度
irs->falling_cnt = HAL_TIM_ReadCapturedValue(htim, irs->channel);
// 处理定时器溢出(16位计数器)
if (irs->falling_cnt >= irs->rising_cnt) {
irs->pulse_width_us = irs->falling_cnt - irs->rising_cnt;
} else {
irs->pulse_width_us = (0xFFFF - irs->rising_cnt) + irs->falling_cnt;
}
// 根据传感器特性计算距离(示例公式,需根据 datasheet 校准)
// 假设:距离(cm) = 脉冲宽度(us) * 0.02 - 0.5
irs->distance_cm = (irs->pulse_width_us * 0.02f) - 0.5f;
if (irs->distance_cm < 0) irs->distance_cm = 0;
// 标记数据就绪,切换回上升沿
irs->data_ready = 1;
__HAL_TIM_SET_CAPTUREPOLARITY(htim, irs->channel, TIM_INPUTCHANNELPOLARITY_RISING);
irs->state = IR_STATE_IDLE;
break;
default:
irs->state = IR_STATE_IDLE;
break;
}
}
/**
* @brief 获取距离测量结果
*/
uint8_t IR_Sensor_GetDistance(IR_Sensor_HandleTypeDef *irs, float *distance) {
if (irs == NULL || distance == NULL || !irs->data_ready) {
return 0; // 无新数据
}
*distance = irs->distance_cm;
irs->data_ready = 0; // 清除就绪标志
return 1; // 成功获取
}
我来讲一下这段代码,首先我们要测距就要知道测量原理,但是无论是红外测量还是超声波测量,只要是测距远离都是相似的:发出一个波传入上升沿信号,当波被物体遮挡,就会发出一个下降沿信号,这样我们只需要得到两个信号分别对应的定时器脉冲参数想减,就可以得到脉冲宽度,我们代码中开始就是接收上升沿记录对应参数,再转换为下降沿,接收参数,根据参数进行运算。最后代入测距公式,就可以得到距离,这个公式要根据数据手册,不同模块有不同的测距公式。
但是我们直接减这样的操作不够严谨,当定时器溢出时,我们还没有完成接收下降沿操作,这会导致结果是错误的,因此我来为大家提供一个比较常用的计算脉冲宽度的一种算法,这段算法逻辑是在处理 16 位定时器计数溢出 时,计算脉冲宽度(比如红外传感器的信号脉宽),核心是解决 “计数溢出导致的计数值不连续” 问题。因为直接看会比较抽象,所以我下面用生活化的例子解释会更清楚,以下分 场景模拟 和 数学推导 两部分拆解:
一、生活化场景模拟(把定时器当 “秒表”)
假设你有一个 16 位秒表,最大能数到 65535(对应 0xFFFF),数到 65535 后会自动归 0,重新从 0 开始数。现在用它测量 “烟花绽放的持续时间”(类似测量脉冲宽度):
场景 1:烟花没跨秒表溢出(falling_cnt >= rising_cnt)
- 步骤:
- 烟花开始时(上升沿),秒表显示
100(rising_cnt = 100)。 - 烟花结束时(下降沿),秒表显示
200(falling_cnt = 200)。
- 烟花开始时(上升沿),秒表显示
- 计算:
持续时间 = 结束值 - 开始值 =200 - 100 = 100→ 直接相减即可。
场景 2:烟花跨秒表溢出(falling_cnt < rising_cnt)
- 步骤:
- 烟花开始时(上升沿),秒表显示
65500(rising_cnt = 65500)。 - 秒表继续数:
65501 → 65535 → 0 → 100(溢出后归 0,继续数到100时烟花结束)。 - 烟花结束时(下降沿),秒表显示
100(falling_cnt = 100)。
- 烟花开始时(上升沿),秒表显示
- 问题:
直接相减会得到100 - 65500 = -65400,显然错误。 - 正确计算:
溢出前,秒表从65500数到65535(共65535 - 65500 = 35个数)。
溢出后,秒表从0数到100(共100个数)。
总持续时间 = 溢出前的数 + 溢出后的数 =35 + 100 = 135。
对应代码逻辑:
(0xFFFF - rising_cnt) + falling_cnt = (65535 - 65500) + 100 = 35 + 100 = 135
二、数学推导(回归定时器本质)
16 位定时器的最大计数值是 0xFFFF(即 65535),计数模式是溢出后归 0。
- 脉冲宽度定义:
脉冲宽度 = 下降沿计数值(falling_cnt) - 上升沿计数值(rising_cnt)
但因为定时器会溢出,所以需要分两种情况:
情况 1:falling_cnt >= rising_cnt(未溢出)
- 定时器从
rising_cnt数到falling_cnt,没有经历溢出。 - 脉冲宽度 =
falling_cnt - rising_cnt
情况 2:falling_cnt < rising_cnt(已溢出)
- 定时器先从
rising_cnt数到0xFFFF(溢出前的计数),再从0数到falling_cnt(溢出后的计数)。 - 溢出前的计数 =
0xFFFF - rising_cnt - 溢出后的计数 =
falling_cnt - 脉冲宽度 =
(0xFFFF - rising_cnt) + falling_cnt
这样讲大家应该能理解了吧。最后一个函数就很简单了,就是一个把前面算出来的值获取存储到成员变量中,方便我们后续处理。
最后我们打开tim.c文件这个文件是用来放定时器属性配置的,不过这里我们把定时器中断回调函数也放到这个文件。

上面两个空函数本来是有内容的,不过那些硬件配置被咱们上文剪切到其他msp文件了,所以直接配置为空文件就可以了,不过如果需要用到基础定时器,这两个回调函数还是要用的。
下面我们利用中断回调函数,如果通道一触发中断就直接调用stm32f1xx_it.c文件里的中断服务函数,今儿调用咱们的回调函数,把值计算出来存储到ir的成员变量里。
下面我们写最后一个文件main.c。
/* USER CODE BEGIN Header */
/**
******************************************************************************
* @file : main.c
* @brief : Main program body
******************************************************************************
* @attention
*
* Copyright (c) 2025 STMicroelectronics.
* All rights reserved.
*
* This software is licensed under terms that can be found in the LICENSE file
* in the root directory of this software component.
* If no LICENSE file comes with this software, it is provided AS-IS.
*
******************************************************************************
*/
/* USER CODE END Header */
/* Includes ------------------------------------------------------------------*/
#include "main.h"
#include "tim.h"
#include "gpio.h"
/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include "msp_callback.h"
#include "ir_sensor.h"
#include "ir_sensor_msp.h"
/* USER CODE END Includes */
/* Private typedef -----------------------------------------------------------*/
/* USER CODE BEGIN PTD */
IR_Sensor_HandleTypeDef ir_sensor;
float current_distance = 0.0f; // 当前测量距离
/* USER CODE END PTD */
/* Private define ------------------------------------------------------------*/
/* USER CODE BEGIN PD */
/* USER CODE END PD */
/* Private macro -------------------------------------------------------------*/
/* USER CODE BEGIN PM */
/* USER CODE END PM */
/* Private variables ---------------------------------------------------------*/
/* USER CODE BEGIN PV */
/* USER CODE END PV */
/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
/* USER CODE BEGIN PFP */
/* USER CODE END PFP */
/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */
/* USER CODE END 0 */
/**
* @brief The application entry point.
* @retval int
*/
int main(void)
{
/* USER CODE BEGIN 1 */
// 1. 注册传感器硬件回调(必须在HAL_Init前调用)
IR_Sensor_RegisterMspCallbacks();
/* USER CODE END 1 */
/* MCU Configuration--------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_TIM2_Init();
/* USER CODE BEGIN 2 */
// 5. 初始化红外传感器
IR_Sensor_Init(&ir_sensor, &htim2, TIM_CHANNEL_1);
// 6. 启动输入捕获
if (IR_Sensor_Start(&ir_sensor) != HAL_OK) {
Error_Handler(); // 启动失败处理
}
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
// 7. 周期性获取距离(100ms间隔)
if (IR_Sensor_GetDistance(&ir_sensor, ¤t_distance)) {
// 此处可添加距离数据处理(如打印到串口、控制执行器等)
// printf("Distance: %.2f cm\r\n", current_distance); // 需初始化串口
}
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
}
/**
* @brief System Clock Configuration
* @retval None
*/
void SystemClock_Config(void)
{
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
/** Initializes the RCC Oscillators according to the specified parameters
* in the RCC_OscInitTypeDef structure.
*/
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
RCC_OscInitStruct.HSEState = RCC_HSE_ON;
RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1;
RCC_OscInitStruct.HSIState = RCC_HSI_ON;
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;
if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
{
Error_Handler();
}
/** Initializes the CPU, AHB and APB buses clocks
*/
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
|RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;
if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK)
{
Error_Handler();
}
}
/* USER CODE BEGIN 4 */
/* USER CODE END 4 */
/**
* @brief This function is executed in case of error occurrence.
* @retval None
*/
void Error_Handler(void)
{
/* USER CODE BEGIN Error_Handler_Debug */
/* User can add his own implementation to report the HAL error return state */
__disable_irq();
while (1)
{
}
/* USER CODE END Error_Handler_Debug */
}
#ifdef USE_FULL_ASSERT
/**
* @brief Reports the name of the source file and the source line number
* where the assert_param error has occurred.
* @param file: pointer to the source file name
* @param line: assert_param error line source number
* @retval None
*/
void assert_failed(uint8_t *file, uint32_t line)
{
/* USER CODE BEGIN 6 */
/* User can add his own implementation to report the file name and line number,
ex: printf("Wrong parameters value: file %s on line %d\r\n", file, line) */
/* USER CODE END 6 */
}
#endif /* USE_FULL_ASSERT */
main.c实现用户逻辑就没啥太重点讲的东西了,毕竟咱们最熟悉的就是这个文件,就是要强调一下咱们 注册传感器硬件回调,必须在HAL_Init前调用。最后0报错0警告,我们只要调试一下看看有没有逻辑问题就可以。
那么咱们红外测距实验专题也结束啦,有没有收获以前没掌握的知识呢?
(2)Input Capture Indirect Mode
我们输入捕获的第二节,来讲一下直接输入捕获的好基友——间接输入捕获。在应用中间接捕获通常是用来辅助直接捕获的,比如咱们上面的实验,我们可以升级成直接捕获通道收集上升沿,间接捕获用来收集下降沿,为啥要这样搞?我们从这两个模式区别和双通道测量脉冲长度的优势来个大家讲解。
直接捕获和间接捕获的区别与使用双通道的优势
一、最本质区别:「触发源」与「通道关联」
1. 直接模式(Direct TI)
-
触发逻辑:独立响应自身通道的电平变化(上升 / 下降沿)。
比如CH1设为直接模式、上升沿触发,当引脚检测到上升沿时,立即冻结计数器,把当前计数值存入CCR1,触发捕获中断 / DMA。 -
硬件特征:通道独立工作,无需依赖其他通道,捕获源就是本通道引脚(
TIMx_CHx)。
2. 间接模式(Indirect TI)
-
触发逻辑:不独立检测引脚,而是 “复用” 直接模式通道的触发信号。
比如CH2设为间接模式、下降沿触发,它不会自己检测TIMx_CH2引脚,而是等CH1(直接模式通道)检测到上升沿后,把CH1的 “触发事件” 当作自己的输入,此时若实际引脚是下降沿(需外部信号符合),才会触发捕获。 -
硬件特征:必须绑定一个直接模式通道(如
CH2间接关联CH1),相当于 “共享” 直接通道的触发事件,再叠加自身的电平极性(上升 / 下降沿)过滤。
二、硬件层面:通道功能与引脚复用
| 模式 | 直接模式(Direct TI) | 间接模式(Indirect TI) |
|---|---|---|
| 引脚依赖 | 必须占用独立引脚(TIMx_CHx) |
不占用独立引脚(复用直接通道的引脚) |
| 触发源 | 本通道引脚的电平变化 | 直接通道的 “触发事件” + 自身极性过滤 |
| 典型场景 | 单独测一个信号的边沿(如单路脉冲) | 配合直接模式测 “互补边沿”(如 PWM 高低电平) |
三、应用场景:为什么选「直接 + 间接」测 PWM 脉宽?
1. 直接模式单独测脉宽的痛点
- 若只用
CH1直接模式测 PWM:- 测高电平(上升→下降沿):需先设上升沿触发,捕获后手动切换为下降沿,再触发一次捕获。
- 问题:切换沿的软件延时会引入误差(尤其高频 PWM),且流程繁琐(需两次捕获、状态判断)。
2. 间接模式的核心价值
- 用
CH1(直接,上升沿)+CH2(间接,下降沿):- 上升沿触发
CH1捕获(记t_start),同时硬件自动触发CH2进入 “等待下降沿” 状态。 - 下降沿到来时,
CH2直接捕获(记t_end),无需软件切换沿,硬件级同步消除延时误差。 - 优势:一次完整 PWM 周期(上升→下降)只需两次硬件捕获,效率和精度远高于软件切换。
- 上升沿触发
四、深层逻辑:为什么间接模式能 “自动同步”?
- 硬件内部通路:间接模式通道的触发信号,直接连到直接模式通道的 “触发输出”(类似一个内部导线)。
- 极性过滤:间接模式再对这个触发信号做 “电平极性” 判断(上升 / 下降沿),只有符合条件的边沿才会真正触发捕获。
简单说:直接通道负责 “检测有事件发生”,间接通道负责 “筛选事件的边沿类型”,两者配合实现 “一次事件,两次捕获(不同边沿)”。
五、总结:什么时候用间接模式?
- ✅ 必须场景:测 “互补边沿”(如 PWM 高低电平、差分信号正负沿),配合直接模式实现硬件级同步捕获。
- ❌ 避免场景:单独测一个信号的边沿(浪费通道资源,直接模式更简单)。
讲到这里大家应该就很清楚,间接和直接捕获的关系啦,那下面我们就从代码角度带大家升级一下咱们的红外测距工程,告诉大家怎样使用这个模式。
红外测距项目代码升级
在CubeMX配置好ch2为间接捕获下降沿模式,生成代码后就不需要修改msp文件了,因为咱们没有使用新的GPIO硬件资源,主要修改的就是tim.c,sensor.c,sensor.h其他逻辑和原来保持一致即可。
我们先来看tim.c
就是把判断条件添加了ch2,其他不变。
补充:记得重新生成代码后把HAL_TIM_Base_MspInit和HAL_TIM_Base_MspDeInit两个回调函数重新置空哦。
变化比较大的就是sensor.c了因为咱们的算法逻辑发生变化了嘛。先来看初始化函数IR_Sensor_Start。

在原本的基础上增加了个对间接通道的中断启动函数,其他逻辑相同。
下面是反初始化函数IR_Sensor_Stop
同样就是多了个间接捕获通道的stop函数。
最后是IR_Sensor_HandleCapture函数,从手动双模式切换,改为了通过双通道的方式,分别捕获上升沿和下降沿,我们来看一下具体实现。

相比于之前的代码也会简单不少。
这样我们在接收到上升沿(PA0引脚接收到高电平)就会进入中断,触发中断服务函数,使得调用中断回调函数,进而调用咱们写的运算函数,把上升沿的值进行记录,等到接收到下降沿,机会被复用在PA0引脚的间接捕获通道捕获下降沿,同样的方法记录下降沿时的数据,根据上文提到的算法,比较两个数据大小,代入相应公式计算,这样就完成了“单传感器用直接 + 间接通道捕获脉宽”逻辑的升级。
现在大家应该理解我在开头提到的,间接捕获是直接捕获的好基友这一比喻了吧。不过直接与间接的羁绊不止于此哦,他们还有一层一对一绑定的关系。
间接通道与直接通道的关联并非 “同编号绑定”,而是存在固定的通道配对关系(如 CH1 与 CH2 配对、CH3 与 CH4 配对)。
STM32 的通道配对规则(硬件设计)
在 STM32 的通用定时器(如 TIM2)中,输入捕获的 “直接 / 间接模式” 存在固定的通道配对:
- CH2 可以作为 CH1 的间接通道(共享 CH1 的输入信号);
- CH4 可以作为 CH3 的间接通道(共享 CH3 的输入信号)。
这是硬件层面设计的 “通道组” 关系(参考 STM32 参考手册《RM0008》中 “输入捕获通道映射” 章节),因此:
- 当 CH1 配置为直接模式(捕获 PA0 的上升沿),CH2 配置为间接模式时,CH2 会自动共享 CH1 的输入信号(即 PA0 的信号),而非 CH2 自身的引脚(PA1)。
- 此时 CH2 间接模式捕获的是PA0 的下降沿(与 CH1 的上升沿来自同一引脚),完全符合你的红外测距逻辑(用同一引脚的上升 / 下降沿计算脉宽)。
-
CH1 配置为直接模式:
ICSelection = TIM_ICSELECTION_DIRECTTI(直接模式),绑定 PA0 引脚,捕获上升沿。- 此时 CH1 的输入信号来自 PA0。
-
CH2 配置为间接模式:
ICSelection = TIM_ICSELECTION_INDIRECTTI(间接模式),根据硬件配对规则,自动关联 CH1 的输入信号(即 PA0)。- 因此 CH2 捕获的是 PA0 的下降沿(而非 PA1),与 CH1 的上升沿来自同一引脚,可直接计算脉宽。
参考手册依据(以 STM32F1 为例)
STM32F1 系列参考手册(RM0008)明确说明:
对于输入捕获,每个定时器有 4 个通道,分为两组:通道 1 和通道 2 为一组,通道 3 和通道 4 为一组。每组中,一个通道可配置为直接模式(直接连接外部引脚),另一个通道可配置为间接模式(共享同组直接通道的外部信号)。
所以我们在使用直接和间接时要清楚,谁是谁的好基友哦。
那么咱们输入捕获间接模式的内容就这样结束啦,大家有没有收获呢?
总结
这样咱们stm32c8t6系列芯片定时器的HAL库函数详解(上)的内容就全部结束啦,感谢您的阅读,我将尽快更新中和下部分的内容,如果我的文章有帮助到您,希望可以得到您的点赞和关注,这也是我更新更多优质文章的动力,那么我们在stm32c8t6系列芯片定时器的HAL库函数详解(中)不见不散~
预告:stm32c8t6系列芯片定时器的HAL库函数详解(中)我会讲解pwm,输出比较,以及高级定时器初始化,还有对应的实践项目练手!
更多推荐



所有评论(0)