系列文章目录


第一章 stm32c8t6系列芯片定时器的HAL库函数详解(上)

第二章 stm32c8t6系列芯片定时器的HAL库函数详解(中)

第三章 stm32c8t6系列芯片定时器的HAL库函数详解(下)


目录

目录

系列文章目录

目录

前言

一、定时器的输出比较

(1)Output Compare No Output

浅谈模式优缺

(2)Output Compare CH1

二、定时器的PWM

(1)PWM Generation No Output

(2)PWM Generation CH1

1.PWM周期计算公式

2.PWM控制电机

​编辑

浅谈数组方式解决代码冗余在单片机资源下是否可取

电机功能函数的实现

三、项目实战:利用PID算法控制电机

(1)代码如何移植

1.msp_callback文件的配置

2.encoder文件的配置

编码器介绍

encoder的代码实现

(2)PID算法

1.算法公式

2.PID算法代码实现

3.PID算法的使用

总结


前言

        在stm32c8t6系列芯片定时器的HAL库函数详解(上)的学习中,我们了解到了,定时器的类型,定时器的初始化,输入捕获模式,那么在stm32c8t6系列芯片定时器的HAL库函数详解(中),我们将继续讲解定时器的其他用途,例如pwm,输出比较,还会有PID算法的详解,咱们这一节侧重点就不在HAL库提供的函数代码实现了,侧重点在咱们自己的代码实现,那么话不多说,让我们进入第二部分的学习。

一、定时器的输出比较

        我们先简单了解一下啥是输出比较吧,“输出比较” 是嵌入式系统(尤其是微控制器)中一种常用的外设功能,主要用于生成精确的时序信号(如脉冲宽度调制 PWM、周期性电平翻转等),广泛应用于电机控制、灯光调光、定时器等场景。

        输出比较在CubeMX的模式配置中是有两种模式的(我们这里先不提PWMmode),它们分别是Output Compare CH1和Output Compare No Output,那么我们就分两节讲解本部分内容。

(1)Output Compare No Output

  • 模式介绍:这是输出比较模式的一种,该模式下不会产生任何实际的输出信号,主要用于软件事件或计数比较 。通过比较定时器计数器(CNT)值与捕获 / 比较寄存器(CCR)值,来触发特定的软件行为,比如在程序中标记某个时间点或事件的发生,或是用于内部逻辑判断,而不需要对外输出物理信号。
  • 应用场景:在一些需要进行时间逻辑判断但不需要对外输出控制信号的场景中使用。例如,在多任务处理的嵌入式系统中,用于记录某个任务执行的时间周期,判断是否达到预定的时间间隔,进而触发相应的任务调度或状态切换,但是不需要通过引脚输出信号去控制外部设备 ;又如在数据采集系统中,通过比较定时器的值,判断数据采集的时间点是否到达,从而控制数据采集的节奏和顺序 ,也无需输出外部信号。

浅谈模式优缺

        说实在的,这个模式是真的不咋用,尤其是对精度没有那么大要求的场景。咱先来看他第一个作用:多任务处理,其实咱们第一想到的应该就是RTOS(实时操作系统)吧,这样想那这个输出比较不输出模式好像还有用到他的地方,但事实上人RTOS这个功能的实现用的是基本定时器加中断的方式平替的,既然聊到RTOS了那就给大家简单科普一下他是怎么个平替:

定时器相关原理的运用

  • 基本定时器定时:RTOS 通常会利用嵌入式微控制器的定时器资源,类似于普通定时器定时功能。比如设置一个系统滴答定时器(SysTick),它是一个递减计数的定时器,当计数值减到 0 时,会触发一个中断。通过配置定时器的时钟源和分频系数,可以设定系统滴答定时器的计数周期,进而实现精确的时间基准。例如,在 STM32 微控制器中,SysTick 定时器可以使用系统时钟作为时钟源,通过合理配置,实现 1ms 的定时中断,为 RTOS 提供基本的时间刻度。
  • 类似输出比较的时间标记:虽然不是直接使用 Output Compare CH1 模式中引脚输出功能,但会利用其比较机制的思想。RTOS 在运行过程中,会为每个任务分配一个时间片,当一个任务开始执行时,记录当前定时器的计数值,当定时器计数值与预设的时间片值进行比较,达到时间片上限时,触发任务切换中断,将当前任务挂起,调度下一个就绪任务执行。这有点类似于输出比较模式中通过比较计数值和设定值来触发特定动作,只不过这里触发的是任务调度而不是引脚电平变化。

任务管理与调度机制

  • 任务状态管理:RTOS 维护着一个任务控制块(TCB)列表,每个任务都有对应的 TCB,记录任务的各种信息,如任务优先级、任务状态(就绪、运行、阻塞等)、任务堆栈指针等。通过对任务状态的管理,RTOS 可以清楚地知道哪些任务可以运行,哪些任务需要等待特定事件。
  • 优先级调度算法:RTOS 使用不同的任务调度算法,常见的有优先级抢占调度算法。当一个高优先级任务进入就绪状态时,即使当前低优先级任务还未执行完其时间片,RTOS 也会立即暂停低优先级任务,切换到高优先级任务执行,以保证高优先级任务能够及时得到处理。这是基于任务优先级的管理,并非单纯定时器比较的原理。

中断处理与事件机制

  • 中断处理:除了定时器中断用于任务调度外,RTOS 还会处理各种外部中断和内部中断。例如,当外部设备产生中断信号时,RTOS 会暂停当前正在运行的任务,保存任务上下文,然后跳转到中断服务程序处理中断事件,处理完成后再恢复之前的任务继续执行。
  • 事件机制:RTOS 提供事件机制来实现任务之间的同步和通信。一个任务可以等待特定事件的发生,当另一个任务触发了该事件(比如通过信号量、消息队列等方式),等待的任务会从阻塞状态变为就绪状态,从而被 RTOS 调度执行。这也是 RTOS 实现多任务协调工作的重要机制,与定时器比较原理不同。

        上面讲的这些看不懂也不要紧,就是了解一下知道有这么个东西就可以了。我想表达的是他这个模式的使用确实不主流。

        咱们再来看第二个功能:控制数据采集的节奏和顺序。说白了也是可以使用基础定时器加中断平替,这个应该更直观吧~既然都能平替,那搞这个模式有啥用啊?额,非要说优点的话,那肯定是有的,这个模式毕竟是使用硬件资源,他可以解放CPU啊,你配置好了也不用在代码里费劲了,多省心哈哈。缺点也很明了,那就是执行的任务必须比较简单不灵活。

(2)Output Compare CH1

  • 模式介绍:该模式同样基于输出比较功能,会输出与通道 1 的比较结果相关联的信号。定时器将计数器(CNT)的值与捕获 / 比较寄存器(CCR)的值进行比较,根据比较结果(如相等、大于、小于等),按照预先配置的逻辑,通过通道 1 引脚输出相应的电平信号(高电平、低电平或电平翻转等) 。
  • 应用场景:常用于需要进行同步输出控制的场合。比如在电机调速系统中,通过该通道输出 PWM(脉冲宽度调制)波形,控制电机的转速和转向,根据 CNT 和 CCR 的比较结果,改变输出 PWM 波的占空比,进而实现对电机速度的调节 ;在 LED 灯光亮度调节应用中,输出不同占空比的 PWM 信号来控制 LED 的亮灭时间比例,从而达到调节亮度的目的 ;此外,还可用于同步多个设备的工作,通过输出的信号作为同步时钟或触发信号,让多个设备按照预定的时序协同工作。

        事实上PWM有自己的控制模式,这个模式也是一个可以被平替的模式,不过配置起来比配置PWM模式更加简单。

严谨一点说这个模式的作用的话我们还是通过对比咱们的平替方案看看吧。

1. 功能实现逻辑

  • Output Compare CH1
    核心是 硬件自动比较:定时器计数器(CNT)与比较寄存器(CCR)值实时对比,匹配时直接触发引脚电平变化(或中断),无需 CPU 频繁干预。
    例如配置 “匹配时引脚翻转”,硬件自动完成电平切换,逻辑简单直接。

  • 普通定时器定时 + 中断
    依赖 软件中断响应:定时器溢出 / 周期到后触发中断,在中断函数里通过软件控制引脚电平(或修改状态)。
    例如定时 1ms 触发中断,在中断里写代码 GPIO_SetBits(...) 或 GPIO_ResetBits(...) 改变电平。

2. 资源占用与效率

  • Output Compare CH1

    • 优势:硬件自动执行比较 + 输出,CPU 几乎无需参与 “电平切换” 过程,可释放 CPU 处理其他任务。
    • 场景:适合需要精准定时触发、且不想占用 CPU 资源的简单控制(如周期性电平翻转、事件标记)。
  • 普通定时器定时 + 中断

    • 劣势:每次定时到期需触发中断,CPU 要进入中断函数执行软件逻辑(如改电平、改状态)。若系统中断多、任务重,可能因频繁响应中断影响主流程效率。
    • 场景:逻辑简单时(如纯软件状态标记)也能用,但高频率定时(如 10us 级)可能导致中断风暴,拖累系统。

3. 精度与实时性

  • Output Compare CH1

    • 精度:依赖定时器硬件计数,比较动作由硬件电路完成,定时精度 = 定时器时钟精度,几乎无额外延迟。
    • 实时性:匹配瞬间立即触发输出(如电平翻转),响应极快,适合对 “触发时机” 要求高的场景(如高精度时序控制)。
  • 普通定时器定时 + 中断

    • 精度:定时精度本身由定时器硬件保证,但进入中断 + 执行软件代码会有 微小延迟(中断响应时间 + 代码执行时间)。若代码逻辑复杂(如多级条件判断),延迟可能更明显,影响最终输出精度。
    • 实时性:中断触发后需等待 CPU 响应、执行代码,相比硬件自动输出,实时性稍弱。对 “微秒级精准触发” 场景(如高速信号同步),可能达不到要求。

4. 典型平替场景与建议

如果需求是 “简单逻辑控制、状态切换,对周期稳定性和精度要求不高”(比如秒级、百毫秒级定时,纯软件标记或低速电平控制),用 普通定时器定时 + 中断 完全可以平替 Output Compare CH1,实现思路:

  1. 配置定时器定时周期(如 1s),使能定时中断。
  2. 中断函数里:
    • 切换引脚电平(实现硬件信号输出);
    • 标记软件状态(如 flag = 1,触发其他逻辑)。

但如果追求 “硬件级精准触发、低 CPU 占用、高实时性”(比如高频电平翻转、无需 CPU 干预的自动输出),Output Compare CH1 更优,无需平替。

总结:选哪种?

  • 想 “偷懒” 且追求效率 → 优先用 Output Compare CH1,硬件自动完成输出,省心又省 CPU;
  • 逻辑简单、且 不介意 CPU 参与 → 普通定时器 + 中断也能平替,但要注意高频率定时的中断压力;
  • 对 “触发精度、实时性” 要求极高 → 必须用 Output Compare CH1 这类硬件输出模式,普通中断方式难满足。

        据我了解这个模式由于高精度的优点在一些特定领域的使用是不可被替代的,我在网上找了些例子给大家了解一下。

案例 1:高精度电机控制(工业自动化)

需求:控制伺服电机 / 步进电机,需要输出精准的 PWM 或时序信号,驱动电机按照复杂轨迹运动(如 3D 打印、机械臂)。

Output Compare 的核心价值

  • 硬件级精准触发
    电机控制需要高频、精准的时序(比如 20kHz 以上的 PWM)。用 Output Compare CH1 模式,定时器硬件自动比较 CNT 和 CCR,匹配时直接翻转引脚电平,无需 CPU 干预,保证信号周期、占空比的绝对精准。
    如果换成 “普通定时 + 中断”,CPU 要频繁响应中断、软件翻转电平,会引入 中断延迟(几十纳秒到微秒级),导致 PWM 波形畸变,电机抖动、丢步。

  • 灵活的波形控制
    复杂运动轨迹需要动态调整 PWM 占空比、相位。Output Compare 可通过修改 CCR 寄存器实时改变比较值,硬件自动同步输出新波形;而纯软件方案修改电平的 “延迟”,会让电机响应滞后,轨迹失控。

项目场景

比如 3D 打印的喷头控制:

  • 用 Output Compare CH1 输出高频 PWM,精准控制喷头加热丝的功率(占空比 → 温度);
  • 同时用 Output Compare CH2 输出另一路信号,控制喷头电机的进给速度;
  • 硬件自动输出,CPU 只需专注 “轨迹规划” 和 “参数计算”,系统效率、稳定性直接拉满。

案例 2:汽车电子(发动机点火、喷油控制)

需求:发动机 ECU(电子控制单元)需要根据曲轴位置、转速,精准控制火花塞点火时刻、喷油嘴喷油时长,误差超过几微秒就可能导致动力下降、油耗飙升。

Output Compare 的核心价值

  • 微秒级时序控制
    发动机转速高达 6000 转 / 分时,曲轴每转 1 度仅需约 27.7μs。Output Compare 模式下,定时器硬件实时比较 CNT 和 CCR,匹配瞬间立即触发点火 / 喷油信号,保证点火时刻精准到 1μs 内。
    纯软件方案(定时 + 中断)的 “延迟”,会让点火提前或滞后,直接影响燃烧效率,甚至导致发动机故障。

  • 硬件级安全冗余
    汽车电子对可靠性要求极高,Output Compare 由硬件电路保证输出,即使 CPU 因其他任务短暂阻塞,定时器仍能独立工作,避免因软件延迟导致的安全事故。

项目场景

发动机点火控制流程:

  1. 曲轴传感器采集到 “上止点” 信号,触发定时器启动;
  2. Output Compare CH1 实时比较 CNT 和 CCR(CCR 由 ECU 算法动态计算,对应点火提前角);
  3. 匹配时直接输出点火信号,驱动火花塞放电,硬件自动完成,无需 CPU 干预

案例 3:医疗设备(输液泵、呼吸机)

需求:输液泵需要精准控制液体流速(误差 < 1%),呼吸机需要根据患者呼吸节奏,动态调整气流压力、频率。

Output Compare 的核心价值

  • 低抖动、高稳定输出
    输液泵的步进电机驱动,需要稳定的 PWM 信号控制流速。用 Output Compare 输出硬件波形,信号周期、占空比 100% 由硬件保证;而纯软件方案的 “中断延迟” 会让电机转速波动,流速不准,直接威胁患者安全。

  • 多任务并行不干扰
    医疗设备的 CPU 要同时处理 “传感器数据采集”“人机交互”“故障诊断”,用 Output Compare 输出控制信号,硬件自动运行,不占用 CPU 资源;若用纯软件定时,CPU 被频繁打断,其他任务响应滞后,可能漏判故障。

项目场景

输液泵流速控制:

  • Output Compare CH1 输出 10kHz PWM,控制步进电机转速(占空比 → 流速);
  • Output Compare CH2 输出另一路信号,控制电磁阀的开关(精准到毫秒级);
  • CPU 只需修改 CCR 寄存器调整参数,硬件自动同步执行,患者流速稳如老狗。

当然大家看看就行,这些项目肯定是没玩过的。

        总的来说优势还是有的,而且相比起输出比较不输出模式要更实用一些,而且它不仅可以配置PWM而且也可以输出一些简单的电平信号,我们下文也会对比PWM模式给大家讲清楚。

二、定时器的PWM

(1)PWM Generation No Output

  • 模式介绍:“PWM Generation No Output” 模式下,不会将 PWM 波输出到引脚,通常用于需要利用定时器的 PWM 相关功能,但又不需要实际输出 PWM 波形到外部引脚的场景。比如在一些系统中,定时器的 PWM 触发机制可以用来触发其他外设的工作,像 ADC(模拟数字转换器)采样。
  • 应用场景
    • ADC 采样触发:在一些涉及模拟信号采集和处理的系统,如电机控制系统中,需要实时采集电流、电压等模拟信号。可设置定时器的某个通道为 “PWM Generation No Output” 模式,利用 PWM 的定时机制,周期性地触发 ADC 进行采样,以确保采集到的数据的时效性和准确性。
    • 系统同步:当多个外设需要按照一定的时间顺序协同工作时,该模式可作为一个时间基准,触发其他模块执行相应操作,实现系统各部分的同步运行。
  • 项目实践举例:在基于 STM32 的 FOC(磁场定向控制)电机驱动项目中,使用高级定时器 TIM1,将 CH4 配置为 PWM generation no output 模式,用于触发 ADC 采样。通过这种方式,在电机运行过程中实时采集电流等反馈信号,为 FOC 算法提供数据支持,以实现对电机的精确控制 。

这项功能也是用来定时触发采样,处理数据的,咱们留到讲高级定时器再拿出来说。

(2)PWM Generation CH1

  • 模式介绍:“PWM Generation CH1” 表示使能定时器的 CH1 通道进行 PWM 信号输出。在该模式下,可以通过配置定时器的相关参数,如自动重装载值(ARR)、捕获比较寄存器值(CCR)等,来设置 PWM 波的周期和占空比。PWM 波有两种模式,PWM mode 1 表示计数器当前值(cnt)小于等于 CCR 时输出高电平,否则输出低电平;PWM mode 2 则是 cnt 小于等于 CCR 时输出低电平,否则输出高电平 。
  • 应用场景
    • LED 调光:在 LED 照明系统中,通过改变 PWM 信号的占空比,可以控制流经 LED 的平均电流,从而实现对 LED 亮度的调节。比如在智能家居系统中,用户可以通过手机 APP 发送指令,调整 PWM 占空比,实现对室内灯光亮度的无极调节。
    • 电机调速:在直流电机或步进电机控制中,PWM 信号用于控制电机的转速。通过改变 PWM 波的占空比,改变电机两端的平均电压,进而改变电机的转速。例如在智能小车项目中,通过控制驱动电机的 PWM 信号占空比,实现小车的速度控制。
    • 电源转换:在开关电源电路中,PWM 信号用于控制开关管的导通和关断时间,实现电压的变换和稳定输出。
  • 项目实践举例:在基于 STM32 的 LED 调光项目中,将 PA0 引脚设置为 TIM2_CH1(PWM 输出),在 TIM2 的 Channel1 配置中,选择 PWM Generation CH1 模式,设置好 Counter Period(对应 PWM 信号频率),通过编写代码改变 CCR 的值,进而改变 PWM 波的占空比,实现对连接在该引脚上的 LED 灯的亮度调节 。在电机控制项目中,使用 TIM2 的 CH1 通道输出 PWM 波控制直流电机的转速,通过调整占空比,让电机以不同的速度运行 。

        PWM输出才是咱们讲的重点,我们还是先来看CubeMX的配置(这里我配置TIM2的CH3为PWM输出模式)然后根据上一节讲的顺序来写一个项目。

        咱们先来实现pwm控制电机工作吧

如图配置TIM2,我们先来看配置的这些数值有什么含义。

1.PWM周期计算公式

PWM 周期 T 的计算公式为:T = \frac{(PSC + 1) \times (ARR + 1)}{f_{clk}}其中:

  • PSC(Prescaler)是预分频器的值,在图中预分频器的值为 71 。
  • ARR(Auto - Reload Register)是自动重装载值,图中自动重装载值为 999 。
  • f_{clk}是定时器的输入时钟频率,图中输入时钟频率为 36MHz。

根据我图中配置,那PWM的周期就是2ms了。

2.PWM控制电机

        PWM模式等基础配置这里不讲了,其他的配置暂时用不到,下文用到了咱们再说~不要着急哦。

        有了上一节的工程代码基础,这次我带大家一点一点写结构体,让大家知道是以怎样的一个思路写工程代码的。

        硬件资源配置回调啥的咱们先不管,咱们先创建motor.c和motor.h 文件简单验证模块功能是否正常,先写头文件中的列表部分。

列举所有咱们可能在motor部分需要的成员变量(不用列举全,后面发现缺少可以很方便的找到这里进行补充)。

接下来根据成员需求写成员变量的枚举(一般情况下state这样的状态量才需要)

然后转到motor.c写结构体配置函数

浅谈数组方式解决代码冗余在单片机资源下是否可取

        写到这里我突然想到了一个问题:配置一个轮子就要调用一次这个配置函数,那配置4个甚至是6个轮子那不得使用6次啊?不行这也太赘余了,有没有办法一次性全部搞定呢?有的有的,我还真想到一个好理解的方法!那就是数组(用来存放属性相同的变量)。不过不要高兴的才早,使用数组来批量配置,有一个致命的缺点,那就是占内存,咱们单片机最紧张的的就是内存资源了,所以我只给大家一个思路,但是不是特别很推荐使用(虽然这个方法确实可以很好的解决代码冗余),不推荐原因其实还有很多,比如用数组来写,代码难度很高,特别是函数的参数如果传入一个成员数量不是一个常量的数组(例如arr[n],虽然你把n的大小初始化好了,但是这也不是一个常量)那就需要传入数组的地址,如果这个数组是一个int类型的还好,如果是一个GPIO_HandleTypeDef*类型的,存了几个指针的数组,那传进函数的参数就是GPIO_HandleTypeDef **类型的二级指针,这样为了代码几行的整洁,多写了几十行的代码,就很得不偿失了,并且我们使用的芯片是STM32F103C8T6,RAM只有20KB,所以需要谨慎使用内存。我还是那句话,提供个思路但不推荐在这个芯片环境下使用。

        but为了满足大家的求知欲,我来把电机的状态配置函数升级一下,首先咱们先回到motor.h把结构体拆分成单电机状态句柄和电机组状态句柄(不改也可以,我只是在自己写升级代码时觉得这样搞变量类型更清楚)。

就是把之前那个拆开了,我写的是模拟使用四个电机,也就是四轮小车,为了节约引脚资源,我把左右侧轮子各为一组,共两组,所以有电机组的概念,拆好之后回到motor.c写电机初始化函数。

void Motor_InitAll(void) {
    // 初始化左侧电机组
    left_motor_group.in1_port = (GPIO_TypeDef*)MOTOR_GROUP_IN_PORT[0][0];
    left_motor_group.in1_pin = MOTOR_GROUP_IN_PIN[0][0];
    left_motor_group.in2_port = (GPIO_TypeDef*)MOTOR_GROUP_IN_PORT[0][1];
    left_motor_group.in2_pin = MOTOR_GROUP_IN_PIN[0][1];
    left_motor_group.stby_port = (GPIO_TypeDef*)MOTOR_GROUP_STBY_PORT[0];
    left_motor_group.stby_pin = MOTOR_GROUP_STBY_PIN[0];
    
    for (int i = 0; i < 2; i++) {
        left_motor_group.motors[i].htim = (TIM_HandleTypeDef*)MOTOR_TIM[0][i];
        left_motor_group.motors[i].pwm_channel = MOTOR_CH[0][i];
        left_motor_group.motors[i].pwm_duty = 0;
        left_motor_group.motors[i].direction = MOTOR_DIR_STOP;
        left_motor_group.motors[i].is_enabled = 0;
        
        // 启动PWM
        HAL_TIM_PWM_Start(left_motor_group.motors[i].htim, left_motor_group.motors[i].pwm_channel);
        __HAL_TIM_SET_COMPARE(left_motor_group.motors[i].htim, 
                             left_motor_group.motors[i].pwm_channel, 0);
    }
    
    // 初始化右侧电机组(类似左侧)
    right_motor_group.in1_port = (GPIO_TypeDef*)MOTOR_GROUP_IN_PORT[1][0];
    right_motor_group.in1_pin = MOTOR_GROUP_IN_PIN[1][0];
    right_motor_group.in2_port = (GPIO_TypeDef*)MOTOR_GROUP_IN_PORT[1][1];
    right_motor_group.in2_pin = MOTOR_GROUP_IN_PIN[1][1];
    right_motor_group.stby_port = (GPIO_TypeDef*)MOTOR_GROUP_STBY_PORT[1];
    right_motor_group.stby_pin = MOTOR_GROUP_STBY_PIN[1];
    
    for (int i = 0; i < 2; i++) {
        right_motor_group.motors[i].htim = (TIM_HandleTypeDef*)MOTOR_TIM[1][i];
        right_motor_group.motors[i].pwm_channel = MOTOR_CH[1][i];
        right_motor_group.motors[i].pwm_duty = 0;
        right_motor_group.motors[i].direction = MOTOR_DIR_STOP;
        right_motor_group.motors[i].is_enabled = 0;
        
        HAL_TIM_PWM_Start(right_motor_group.motors[i].htim, right_motor_group.motors[i].pwm_channel);
        __HAL_TIM_SET_COMPARE(right_motor_group.motors[i].htim, 
                             right_motor_group.motors[i].pwm_channel, 0);
    }
    
    // 禁用所有电机组( standby = HIGH 激活TB6612)
    HAL_GPIO_WritePin(left_motor_group.stby_port, left_motor_group.stby_pin, GPIO_PIN_SET);
    HAL_GPIO_WritePin(right_motor_group.stby_port, right_motor_group.stby_pin, GPIO_PIN_SET);
}

这个代码逻辑挺容易理解的吧,都是常见语法,我简单说一下:先配置组0也就是左侧轮子,先给组电机结构体成员分配内容,然后给组内单个电机成员的成员变量分配内容,这个地方是这个代码唯一不好理解的点了,比如这一句:right_motor_group.motors[i].htim = (TIM_HandleTypeDef*)MOTOR_TIM[1][i] ;第一个 . 引出组内成员 motors[i] 这个成员是一个数组,数组有两个成员,分别是前轮和后轮,每个成员也有自己的成员变量(没看明白,可以回头看看.h文件的那两个结构体),第二个 . 就是引出单电机的成员htim然后给他赋值,本来这里直接写&htim1就行但是这样每个轮子都要写一遍,咱上面就是以内觉得这个地方冗余才升级的代码,实用的数组的方式,为了少写几遍配置函数,而多写了好多好多代码。配置好成员,就调用PWM启动函数和占空比配置函数就结束了左轮组的配置,右轮的配置原理和左轮是一模一样的。下面咱们来看一下咱们的数组是咋写的。

// 电机组配置数据 - 存储在Flash中(const)
static const GPIO_TypeDef* MOTOR_GROUP_IN_PORT[2][2] = {
    {GPIOB, GPIOB}, // 左侧: IN1_PORT, IN2_PORT
    {GPIOB, GPIOB}  // 右侧: IN1_PORT, IN2_PORT
};

static const uint16_t MOTOR_GROUP_IN_PIN[2][2] = {
    {GPIO_PIN_2, GPIO_PIN_3}, // 左侧: IN1_PIN, IN2_PIN
    {GPIO_PIN_4, GPIO_PIN_5}  // 右侧: IN1_PIN, IN2_PIN
};

static const GPIO_TypeDef* MOTOR_GROUP_STBY_PORT[2] = {
    GPIOA, // 左侧STBY端口
    GPIOA  // 右侧STBY端口
};

static const uint16_t MOTOR_GROUP_STBY_PIN[2] = {
    GPIO_PIN_11, // 左侧STBY引脚
    GPIO_PIN_12  // 右侧STBY引脚
};

// 电机PWM配置 - 存储在Flash中
static const TIM_HandleTypeDef* MOTOR_TIM[2][2] = {
    {&htim1, &htim1}, // 左侧电机: PWM定时器
    {&htim1, &htim1}  // 右侧电机: PWM定时器
};

static const uint32_t MOTOR_CH[2][2] = {
    {TIM_CHANNEL_1, TIM_CHANNEL_2}, // 左侧电机: PWM通道
    {TIM_CHANNEL_3, TIM_CHANNEL_4}  // 右侧电机: PWM通道
};

// 电机组实例
MotorGroup_HandleTypeDef left_motor_group;
MotorGroup_HandleTypeDef right_motor_group;

虽然真的很麻烦,但是写完还是很有成就感的。这样列举数组有点像一个列表吧,清楚的展现了硬件资源的配置。这种方法确实有更高的可读性,但是内存占用是实打实的~而且没啥性能上的好处,也就是说不仅空间复杂度变高了,时间复杂度也没减少。

电机功能函数的实现

        先说好电机驱动模块的咋用你得知道哈,要不然看不懂下面代码,我这里不讲。咱先来实现最好实现的,方向控制函数,也就是配置in1和in2。

void Motor_SetGroupDirection(uint8_t group, Motor_DirectionTypeDef dir) {
    MotorGroup_HandleTypeDef* mg = (group == 0) ? &left_motor_group : &right_motor_group;
    
    switch (dir) {
        case MOTOR_DIR_FORWARD:
            HAL_GPIO_WritePin(mg->in1_port, mg->in1_pin, GPIO_PIN_SET);
            HAL_GPIO_WritePin(mg->in2_port, mg->in2_pin, GPIO_PIN_RESET);
            break;
        case MOTOR_DIR_BACKWARD:
            HAL_GPIO_WritePin(mg->in1_port, mg->in1_pin, GPIO_PIN_RESET);
            HAL_GPIO_WritePin(mg->in2_port, mg->in2_pin, GPIO_PIN_SET);
            break;
        case MOTOR_DIR_STOP:
        default:
            HAL_GPIO_WritePin(mg->in1_port, mg->in1_pin, GPIO_PIN_RESET);
            HAL_GPIO_WritePin(mg->in2_port, mg->in2_pin, GPIO_PIN_RESET);
            break;
    }
    
    // 更新组内所有电机的方向状态
    for (int i = 0; i < 2; i++) {
        mg->motors[i].direction = dir;
    }
}

        也是简单讲一讲,参数就是我要配置哪个组,方向配置成啥。第一句是一个三目操作符,语法:x = A?B:C,意思就是A成不成立成立x = B不成立x = C。代码中group = 0就是group是组0吗,按照我的设计是就是左边轮子,不是(那就是组1)那就是控制右轮子。下面就是一个switch语句根据电机驱动模块(例如TB6612)的工作原理,通过配置in1和in2的电平配置方向,当然这里我没有实现左转和右转,感兴趣可以自己试试。最后把现在的状态情况传入结构体成员作为标志。

        方向函数实现了,进而咱们就要实现控制车速和方向的总控制函数吧。

void Motor_SetSpeed(uint8_t group, uint8_t motor_idx, Motor_DirectionTypeDef dir, uint16_t duty) {
    if (group > 1 || motor_idx > 1) return;
    if (duty > 1000) duty = 1000;
    
    MotorGroup_HandleTypeDef* mg = (group == 0) ? &left_motor_group : &right_motor_group;
    
    // 设置方向(如果需要改变)
    if (mg->motors[motor_idx].direction != dir) {
        Motor_SetGroupDirection(group, dir);
    }
    
    // 设置PWM占空比
    mg->motors[motor_idx].pwm_duty = duty;
    __HAL_TIM_SET_COMPARE(mg->motors[motor_idx].htim, 
                         mg->motors[motor_idx].pwm_channel, duty);
}

        参数分别是:哪一组,组里的那个轮子,方向是什么,配置多少速度(占空比)。前面不说了,最后一部分就是先把当前占空比状态存到成员变量里,调用修改占空比的函数修改占空比。

然后再实现个停车函数吧

void Motor_StopGroup(uint8_t group) {
    MotorGroup_HandleTypeDef* mg = (group == 0) ? &left_motor_group : &right_motor_group;
    
    // 停止组内所有电机
    for (int i = 0; i < 2; i++) {
        mg->motors[i].pwm_duty = 0;
        __HAL_TIM_SET_COMPARE(mg->motors[i].htim, mg->motors[i].pwm_channel, 0);
        mg->motors[i].direction = MOTOR_DIR_STOP;
    }
    
    // 设置方向为停止
    HAL_GPIO_WritePin(mg->in1_port, mg->in1_pin, GPIO_PIN_RESET);
    HAL_GPIO_WritePin(mg->in2_port, mg->in2_pin, GPIO_PIN_RESET);
}

就是把占空比改成0.成员变量记录状态配置为停车状态。不过这是停一组轮子,停两组轮子就调用两次,不啰嗦了。

        那么我们PWM Generation CH1这一部分就结束了,下面就进入咱们最期待的工程实战了,那么跟随我的脚步,我们来完成下面的挑战吧。

三、项目实战:利用PID算法控制电机

        在本项目中,我会给大家详细讲解PID算法的原理,PID如何融入我们的代码中,之前写的工程如何移植到我的新项目里,以及主从触发模式等,话不多说咱们发车。

(1)代码如何移植

        在讲移植之前,我先和大家说说咱们用这个算法干了个啥事,首先可能遇到一种情况,地面特别粗糙,小车在这样的地面跑,有没有可能电机转不动,或者说没有咱们想要的那种速度?不过这种情况比较极端;换一种情景,如果你玩过stm32智能车之类的项目,你可能遇到这样一个问题:我明明给四个轮子配置了一样的速度(例如都是80,方向向前)但是跑起来怎么不是直线,跑着跑着跑歪了,这就是因为地形,点击性能等多方面原因导致的这个结果,那我想避免怎么办呢?于是,调速的想法就产生了,我时不时检测一下我轮子的速度,然后和我配置的速度相比较,慢了我就让他配置的快一点,反之则配置的慢一点。想实现这个想法,咱们要做两件事:第一就是测速,也就是测PWM的占空比和实际匹不匹配,于是咱们就要用到上节讲的:通过输入捕获得到上升沿和下降沿的间隔,进而得到脉冲宽度,改一下这个算法就可以得到占空比。

        所以咱们可以把之前的项目代码移植进来。复制咱们的msp_callback一系列文件,还有ir_sensor文件到对应的文件夹,完成后把ir_sensor改成encoder就可以啦,咱们先来配置msp文件。

1.msp_callback文件的配置

        还是别忘了先把 USE_HAL_TIM_REGISTER_CALLBACKS == 1 置1,然后先打开 TIM_HandleTypeDef 类型的定义里面有咱们的定时器所有回调函数的声明,咱们根据需要选择然后在msp_callback.h里写对应的结构体列表,用于接收回调函数。

就像这样把这些内容一模一样的写一份pwm版的就可以了,照猫画虎,我就不把所有文件的照片都贴出来了。

2.encoder文件的配置

编码器介绍

        其实硬件原理方面我不想在本文讲的,不过编码器的原理牵扯到后面算法部分代码的实现,所以我还是先讲一讲吧,要不算法代码那一部分理解起来就太费事了。如果知道编码器工作原理的同学可以把这一部分跳过了。

一、核心组成:3 个「关键角色」

霍尔编码器的硬件结构很简单,主要包括 3 部分:

  • 旋转磁体:贴在电机轴上,随电机一起转动(比如一个圆形磁铁,交替分布 N 极和 S 极)。
  • 霍尔传感器:固定在电机外壳上,对着磁体(通常有 2 个)。
  • 信号处理电路:把传感器的微弱信号放大、整形,输出数字脉冲。

二、工作原理:磁体转动 → 脉冲变化

霍尔传感器的特性是:当磁场方向变化时,它输出的电压会反转(高电平→低电平,或低电平→高电平)
结合电机旋转,这个过程可以想象成:

  1. 磁体旋转,磁场交替变化
    电机轴转动时,磁体跟着转,N 极和 S 极会交替经过霍尔传感器。比如:

    • 当 N 极对着传感器时,传感器输出高电平(1);
    • 当 S 极对着传感器时,传感器输出低电平(0)。
  2. 双相编码器有 A、B 两路脉冲信号,波形特点:

    • 两路脉冲相位差 90°(正交信号),即 A 相和 B 相的跳变时间错开 1/4 周期;
    • 正转和反转时,A、B 相的超前 / 滞后关系相反(这是判断方向的关键);
    • 每转一圈,A、B 相各自产生固定数量的脉冲(比如每转 100 个脉冲,A、B 相各跳变 100 次)。
  3. 控制器通过脉冲计算运动状态
    控制器(比如单片机)接收这 3 路脉冲信号,通过「数脉冲个数」和「看脉冲顺序」,就能算出:

    • 位置:累计脉冲总数 → 对应电机转了多少圈 / 角度;
    • 速度:单位时间内的脉冲数 → 对应电机转得多快;
    • 方向:脉冲相位变化顺序 → 对应电机正转还是反转。

看完介绍,大致对霍尔编码器有一些了解了,但是对如何测速还是有些模糊,这不要紧,咱们边看代码,边学习。不过要记得先在cubemx里把定时器配置成编码器模式啊!

encoder的代码实现

        咱们先写msp哈,咱们得让逻辑跑正确了,再去升级代码,而不是一次性把代码升级好,再去写逻辑,所以先写.c和.h代码跑正确了再写map。

先写枚举和结构体。

然后是初始化函数代码(把结构体的成员都初始化一下,之前讲过了不啰嗦)。

void Hall_Encoder_Init(Hall_Encoder_HandleTypeDef *encoder, 
                      TIM_HandleTypeDef *htim, 
                      uint32_t ppv, 
                      Encoder_ModeTypeDef mode) {
    if (encoder == NULL || htim == NULL) return;
    
    encoder->htim = htim;
    encoder->raw_count = 0;
    encoder->last_count = 0;
    encoder->pulse_count = 0;
    encoder->speed_rpm = 0.0f;
    encoder->speed_rads = 0.0f;
    encoder->direction = 0;
    encoder->pulses_per_revolution = ppv;
    encoder->mode = mode;
    encoder->last_update_time = HAL_GetTick();
    encoder->data_ready = 0;
}

这里的ppv是每转脉冲数,这个要看数据手册,常见的编码器 ppv 有 256、512、1024、2048 等。

        写完初始化下面就是启动函数,咱们motor也是这样一个逻辑写的。

HAL_StatusTypeDef Hall_Encoder_Start(Hall_Encoder_HandleTypeDef *encoder) {
    if (encoder == NULL) return HAL_ERROR;
    
    // 启动编码器接口模式
    if (HAL_TIM_Encoder_Start(encoder->htim, TIM_CHANNEL_ALL) != HAL_OK) {
        return HAL_ERROR;
    }
    
    // 启用更新中断
    __HAL_TIM_ENABLE_IT(encoder->htim, TIM_IT_UPDATE);
    
    return HAL_OK;
}

调用启动函数,并且启动更新中断。然后写停止函数。

HAL_StatusTypeDef Hall_Encoder_Stop(Hall_Encoder_HandleTypeDef *encoder) {
    if (encoder == NULL) return HAL_ERROR;
    
    HAL_TIM_Encoder_Stop(encoder->htim, TIM_CHANNEL_ALL);
    __HAL_TIM_DISABLE_IT(encoder->htim, TIM_IT_UPDATE);
    
    return HAL_OK;
}

对照启动函数的stop函数,接下来就是最重要的数据处理函数。

void Hall_Encoder_Update(Hall_Encoder_HandleTypeDef *encoder) {
    if (encoder == NULL) return;
    
    uint32_t current_time = HAL_GetTick();
    uint32_t time_elapsed = current_time - encoder->last_update_time;
    
    // 获取当前计数值
    encoder->raw_count = (int32_t)__HAL_TIM_GET_COUNTER(encoder->htim);
    
    // 计算增量(处理16位计数器溢出)
    int32_t delta = 0;
    if (encoder->raw_count >= encoder->last_count) {
        delta = encoder->raw_count - encoder->last_count;
    } else {
        // 处理向下溢出
        delta = (int32_t)(0xFFFF - encoder->last_count) + encoder->raw_count + 1;
    }
    
    // 根据模式调整增量
    delta = delta / encoder->mode;
    
    // 更新总脉冲计数
    encoder->pulse_count += delta;
    encoder->last_count = encoder->raw_count;
    
    // 计算速度(如果时间间隔足够)
    if (time_elapsed > 0) {
        // 计算转速(RPM)
        float pulses_per_minute = (delta * 60000.0f) / time_elapsed;
        encoder->speed_rpm = pulses_per_minute / encoder->pulses_per_revolution;
        
        // 计算角速度(rad/s)
        encoder->speed_rads = encoder->speed_rpm * (2 * M_PI) / 60.0f;
        
        // 判断方向
        encoder->direction = (delta >= 0) ? 0 : 1;
        
        encoder->data_ready = 1;
    }
    
    encoder->last_update_time = current_time;
}

        咱们现获取几个数据为后来的计算做准备,第一个就是当前计数器,在咱们这里就是定时器二和定时器三的计数值,然后让他后上一次的计数值想减(初始为0),得到一共收集了多长时间的数据,接下来获取当前计数值cnt(通过收集ab相的数据,定时器的编码器模式会自动处理ab相数据,把两个相位差90°的波形图,换算为一个更精确的波形图,其中cnt计数值为负数就说明在反转,为正数就说明在正转),接下来计算一下脉冲增量,用的逻辑和咱们写的sensor的逻辑是一样的,接下来算一下总脉冲数,这里主要是为了防止总数超过65535(根据自己定时器配置有所不同),记录当前值并把它赋值给成员变量“上一次的值”,为下一次做准备也就是说,下次计算脉冲增量的时候就不是 - 0 了,下面开始计算,先算的是每分钟多少脉冲数,最后那个f的意思是这个数是浮点数,也就是flot,咱们计数器的单位是毫秒,转化成分钟就是60000.0,然后除上prr就可以得到每分钟的转数,通过每分钟的转速乘2π,就可以得到每分钟角速度,然后除上60,就是秒。然后判断cnt的正负,差量的正负也可以很好的反应这一点,例如初始cnt为正,现在cnt为负,那么差就是负数,所以原理是一样的,用cnt或者是增量正负都可以,结果是一样的,最后记录定时器的末时间。

下面写一个数据get的函数接口,方便在main函数里获取数据。

uint8_t Hall_Encoder_GetSpeed(Hall_Encoder_HandleTypeDef *encoder, 
                             float *speed_rpm, 
                             float *speed_rads, 
                             uint8_t *direction) {
    if (encoder == NULL || !encoder->data_ready) {
        return 0;
    }
    
    if (speed_rpm != NULL) {
        *speed_rpm = encoder->speed_rpm;
    }
    
    if (speed_rads != NULL) {
        *speed_rads = encoder->speed_rads;
    }
    
    if (direction != NULL) {
        *direction = encoder->direction;
    }
    
    encoder->data_ready = 0;
    return 1;
}
int32_t Hall_Encoder_GetPosition(Hall_Encoder_HandleTypeDef *encoder) {
    if (encoder == NULL) return 0;
    return encoder->pulse_count;
}

再写一个编码器状态重置函数,把结构体的所有数据重置。

void Hall_Encoder_Reset(Hall_Encoder_HandleTypeDef *encoder) {
    if (encoder == NULL) return;
    
    encoder->raw_count = 0;
    encoder->last_count = 0;
    encoder->pulse_count = 0;
    encoder->speed_rpm = 0.0f;
    encoder->speed_rads = 0.0f;
    encoder->direction = 0;
    encoder->last_update_time = HAL_GetTick();
    encoder->data_ready = 0;
    
    __HAL_TIM_SET_COUNTER(encoder->htim, 0);
}

再写一个校准函数。

void Hall_Encoder_Calibrate(Hall_Encoder_HandleTypeDef *encoder, uint32_t ppv, Encoder_ModeTypeDef mode) {
    if (encoder == NULL) return;
    
    encoder->pulses_per_revolution = ppv;
    encoder->mode = mode;
}

如果有其他需要自己可以多写一些接口。

最后我们在it函数里把中断回调加上。

最后回到encoder.h文件把咱们定义好的函数声明一下就可以啦。

// 初始化函数
void Hall_Encoder_Init(Hall_Encoder_HandleTypeDef *encoder, 
                      TIM_HandleTypeDef *htim, 
                      uint32_t ppv, 
                      Encoder_ModeTypeDef mode);

// 启动/停止函数
HAL_StatusTypeDef Hall_Encoder_Start(Hall_Encoder_HandleTypeDef *encoder);
HAL_StatusTypeDef Hall_Encoder_Stop(Hall_Encoder_HandleTypeDef *encoder);

// 更新函数(在定时器中断中调用)
void Hall_Encoder_Update(Hall_Encoder_HandleTypeDef *encoder);

// 获取速度数据
uint8_t Hall_Encoder_GetSpeed(Hall_Encoder_HandleTypeDef *encoder, 
                             float *speed_rpm, 
                             float *speed_rads, 
                             uint8_t *direction);

// 获取位置数据
int32_t Hall_Encoder_GetPosition(Hall_Encoder_HandleTypeDef *encoder);

// 重置编码器
void Hall_Encoder_Reset(Hall_Encoder_HandleTypeDef *encoder);

// 校准函数
void Hall_Encoder_Calibrate(Hall_Encoder_HandleTypeDef *encoder, uint32_t ppv, Encoder_ModeTypeDef mode);

(2)PID算法

        PID算法是一种调节算法,目标值和实际值的差值,进行对电机等惯性运动的状态调整,不只局限于小车的调速,包括平衡车的平衡调整,四驱飞机的平衡调整等等方面,它是一种好用,并且使用起来并不费力的一种方法。

1.算法公式

一共有三个公式:

1.定义误差: error(t) = target(t) - actual(t)

2.PID输出值: out(t) = K_p \left( error(t) + \frac{1}{T_i} \int_0^t error(t) dt + \frac{T_d error(t)}{dt} \right)

3.PID输出值: out(t) = K_p * error(t) + K_i * \int_0^t error(t) dt + K_d * \frac{d\mathrm{error}(t)}{dt}

        这第一个公式就是求目标值和我的实际值的差值的,第二个公式是课本的写法,实际上使用不到这儿公式的,第三个公式才是咱们要用到的PID输出值的公式,我们下面给大家讲一下这个公式的含义。

        首先这个公式不是想牛顿定律那样,有明确的物理意义的公式,而是一种总结性的,有点像大模型归纳的产物,就是经过大量数据总结得到的,因此到底为什么这个地方要是积分,那个地方要是微分,这个不是绝对的,我的意思是可以根据模型优化公式变量,自然更可以增加变量。

        咱们先看第一部分,out(t) = K_p * error(t),也叫比例系项,这一部分的作用是在发生误差时立刻给一个驱动力来降低误差,这样可以第一时间减少误差让当前值趋向于目标值,不过有两个缺点,第一:过快的改变惯性运动原件容易刹不住车,就像玩赛车游戏,当我们发现车向左偏的时候我们会立刻向右控制方向,但是他的惯性很大,导致车立刻向右跑,我们又向左边控制,车立刻以一个更大的角度像我们控制的方向倾斜,这样在咱们的控制之下,车越跑越歪,放在电机上是同样的道理,最后只会是越调整越不稳定(当然现实情况没有这么极端,很少出现自震荡这样的情况,不过也会因为过快的调整导致超出我们的目标值,并且kp比例系数越大偏离越明显,有点像惯性啊);第二:会存在一个稳态误差的现象,我们先看一个波形图。

咱们可以直观的看到,不同比例系数对应的目标波形和实际波形,无论kp为多大总是会产生与实际值相差的状态,并且逐渐保持稳定,这就是稳态误差,那他是如何产生的呢?这就取决于电机的摩擦力了,由于咱们快速的调整速度让实际值趋近目标值,这对导致err差值快速减少,驱动力快速减少在摩擦力的作用下速度不断降低,因此差值增大,驱动力增大弥补差值,以此不断重复,导致最后在驱动力和摩擦力的双重作用下,实际值趋近于一个稳定的值但是不到目标值(到了目标值驱动力就为0系统就会降速),此时驱动力和摩擦力近乎相等,因此就产生了稳态误差。

        为了解决上面的两个问题,咱们在公式里引入了两个变量,咱们先讲解决问题二的积分项。

                                out(t) = K_p \cdot error(t) + K_i \cdot \int_{0}^{t} error(t) \, dt

这一项的含义就是我们会收集历史时刻的误差,并把他们累加,求得的积分加到比例项,这样就可以让稳态误差逐渐消失,误差逐渐减小累加的值逐渐趋于0,最后达到稳态。我也给大家一些波形图体会一下。

        最后为了解决问题一,我们引入了第三个变量微分项

                out(t) = K_p * error(t) + K_i * \int_0^t error(t) dt + K_d * \frac{d\mathrm{error}(t)}{dt}

也就是公式的第三部分,这一部分的含义是对误差进行微分,也就是求导,就是求斜率,这一项是一个抑制项,如果比例项长得太快了,我就会重拳出击,抑制总量的增长,防止因为惯性涨过了,我也给大家几个波形图体会。

这样大家就可以很直观的感受到这三个变量的作用了吧,在这三个量的共同调节下,咱们的实际值才趋近于目标值,且非常稳定。

最后总结一下各项的特点:

比例项:

  • 比例项的输出值仅取决于当前时刻的误差,与历史时刻无关。当前存在误差时,比例项输出一个与误差呈正比的值,当前不存在误差时,比例项输出 0
  • (K_p)越大,比例项权重越大,系统响应越快,但超调也会随之增加
  • 纯比例项控制时,系统一般会存在稳态误差,(K_p)越大,稳态误差越小

积分项:

  • 积分项的输出值取决于 0~t 所有时刻误差的积分,与历史时刻有关。积分项将历史所有时刻的误差累积,乘上积分项系数(K_i)后作为积分项输出值
  • 积分项用于弥补纯比例项产生的稳态误差,若系统持续产生误差,则积分项会不断累积误差,直到控制器产生动作,让稳态误差消失
  • (K_i)越大,积分项权重越大,稳态误差消失越快,但系统滞后性也会随之增加

微分项:

  • 微分项的输出值取决于当前时刻误差变化的斜率,与当前时刻附近误差变化的趋势有关。当误差急剧变化时,微分项会负反馈输出相反的作用力,阻碍误差急剧变化
  • 斜率一定程度上反映了误差未来的变化趋势,这使得微分项具有 “预测未来,提前调控” 的特性
  • 微分项给系统增加阻尼,可以有效防止系统超调,尤其是惯性比较大的系统
  • (K_d)越大,微分项权重越大,系统阻尼越大,但系统卡顿现象也会随之增加

2.PID算法代码实现

        首先咱们之前的公式是一个连续性的公式,他适用于模拟电路,但是咱们的数字电路是不适用的,因此我们要把连续性的公式转化为离散型公式,也就相当于一个连续的函数积分转化为,不连续的一个一个点组成的函数积分,如下图大家直观理解一下。

如图咱们把连续的积分曲线给分段,变成了离散的积分曲线,这样就使得PID输出共生变成了下面这样。

                        u(k) = K_p e(k) + K_i T \sum_{j=0}^{k} e(j) + K_d \frac{e(k) - e(k-1)}{T}

咱们把T乘到系数里就到都以下式子。

                        out(k) = K_p \cdot e(k) + K_i \cdot \sum_{j=0}^{k} e(j) + K_d \cdot \bigl( e(k) - e(k - 1) \bigr)

这就是咱们需要在代码里实现的逻辑。

        接下来怎么创建pid.h和pid.c文件,在.h中创建pid句柄的结构体

typedef struct {
    float Kp;           // 比例系数
    float Ki;           // 积分系数
    float Kd;           // 微分系数
    float integral;     // 积分项
    float prev_error;   // 上一次误差
    float output;       // 输出值
    float output_limit; // 输出限制
    float integral_limit; // 积分限制
    float setpoint;     // 设定值
    float measured_value; // 测量值
    uint32_t last_time; // 上次计算时间
} PID_Controller;

接下来打开.c来实现函数逻辑,首先写的是初始化函数。

void PID_Init(PID_Controller* pid, float Kp, float Ki, float Kd, 
              float output_limit, float integral_limit) {
    pid->Kp = Kp;
    pid->Ki = Ki;
    pid->Kd = Kd;
    pid->integral = 0.0f;
    pid->prev_error = 0.0f;
    pid->output = 0.0f;
    pid->output_limit = output_limit;
    pid->integral_limit = integral_limit;
    pid->setpoint = 0.0f;
    pid->measured_value = 0.0f;
    pid->last_time = HAL_GetTick();
}

给结构体成员赋值。下面写核心算法逻辑函数。

float PID_Calculate(PID_Controller* pid, float setpoint, 
                   float measured_value, uint32_t current_time) {
    // 计算时间差(转换为秒)
    float dt = (current_time - pid->last_time) / 1000.0f;
    if (dt <= 0) dt = 0.01f; // 避免除以零
    
    pid->setpoint = setpoint;
    pid->measured_value = measured_value;
    
    // 计算误差
    float error = setpoint - measured_value;
    
    // 比例项
    float proportional = pid->Kp * error;
    
    // 积分项(带抗饱和)
    pid->integral += error * dt;
    
    // 积分限制
    if (pid->integral > pid->integral_limit) {
        pid->integral = pid->integral_limit;
    } else if (pid->integral < -pid->integral_limit) {
        pid->integral = -pid->integral_limit;
    }
    
    float integral = pid->Ki * pid->integral;
    
    // 微分项
    float derivative = pid->Kd * (error - pid->prev_error) / dt;
    pid->prev_error = error;
    
    // 计算输出
    pid->output = proportional + integral + derivative;
    
    // 输出限制
    if (pid->output > pid->output_limit) {
        pid->output = pid->output_limit;
    } else if (pid->output < -pid->output_limit) {
        pid->output = -pid->output_limit;
    }
    
    pid->last_time = current_time;
    return pid->output;
}

还是先计算时间差,这里主要是为了和编码器的时间差比较,如果编码器时间长那就不能算pid,因为编码器这一次的结果还没得到,就又调用pid函数,会把上一次的编码器的值算两遍pid输出值,这样是没有意义的,接下来传入目标值和实际值,计算差值,下面算出比例项,通过累加算出积分项,积分限制咱么这里先不讲,忽略这部分代码,最后算出微分项,把三项相加得到咱们的out,输出限制也是先忽略,等咱们优化pid的时候再讲。这样咱们的pid算法函数就实现了。

下面就是写一些接口了,先是重置pid控制状态函数。

void PID_Reset(PID_Controller* pid) {
    pid->integral = 0.0f;
    pid->prev_error = 0.0f;
    pid->output = 0.0f;
    pid->last_time = HAL_GetTick();
}

接下来是参数配置函数。

void PID_SetTunings(PID_Controller* pid, float Kp, float Ki, float Kd) {
    pid->Kp = Kp;
    pid->Ki = Ki;
    pid->Kd = Kd;
}

再写一个限制配置函数吧。

void PID_SetOutputLimits(PID_Controller* pid, float min, float max) {
    pid->output_limit = max;
}

最后回到.h文件声明。

void PID_Init(PID_Controller* pid, float Kp, float Ki, float Kd, 
              float output_limit, float integral_limit);
float PID_Calculate(PID_Controller* pid, float setpoint, 
                   float measured_value, uint32_t current_time);
void PID_Reset(PID_Controller* pid);
void PID_SetTunings(PID_Controller* pid, float Kp, float Ki, float Kd);
void PID_SetOutputLimits(PID_Controller* pid, float min, float max);

这样我们的pid.c和.h文件就写好了。

3.PID算法的使用

        咱们在创建一个PID控制文件可以叫closed_loop(闭环),也是现在.h文件里写句柄结构体。

typedef struct {
    PID_Controller speed_pid;
    PID_Controller position_pid;
    Hall_Encoder_HandleTypeDef *encoder;
    uint8_t motor_group;
    uint8_t motor_index;
    uint8_t control_mode;
    float target_speed;     // RPM
    float target_position;  // 脉冲数
    uint32_t last_pid_time;
    uint32_t pid_interval;  // PID计算间隔(ms)
} ClosedLoop_Controller;

这个主要是为了服务pid和encoder文件里函数调用参数传入的。接下来是控制函数的初始化配置,还是给结构体成员赋值。

void ClosedLoop_Init(ClosedLoop_Controller *ctrl, 
                    Hall_Encoder_HandleTypeDef *encoder,
                    uint8_t motor_group, 
                    uint8_t motor_index) {
    ctrl->encoder = encoder;
    ctrl->motor_group = motor_group;
    ctrl->motor_index = motor_index;
    ctrl->control_mode = CLOSED_LOOP_MODE_SPEED;
    ctrl->target_speed = 0;
    ctrl->target_position = 0;
    ctrl->pid_interval = 10; // 10ms控制周期
    
    // 初始化速度PID
    PID_Init(&ctrl->speed_pid, SPEED_PID_KP, SPEED_PID_KI, SPEED_PID_KD, 
             SPEED_PID_LIMIT, SPEED_PID_LIMIT/2);
    
    // 初始化位置PID
    PID_Init(&ctrl->position_pid, POSITION_PID_KP, POSITION_PID_KI, POSITION_PID_KD, 
             POSITION_PID_LIMIT, POSITION_PID_LIMIT/2);
}

我给大家讲一下可能不理解的成员变量,第一个就是这个ctrl_mode这个配置为了闭环速度模式,其实还有个闭环位置模式,这个是有两种pid控制可以用,一个是电机调速度,比如我配置的60速度,我实际是20那我就提高控制驱动力来提速,位置控制就是,通过控制电机转的位置,比如我让电机转3圈,现在还一圈没转,那就会有一个驱动力驱动电机转到圈这个指定位置,这个就是位置pid模式,当然这个是自己写的,还可以是距离,姿态等等,根据需要都可以配置。接下来写每个模式的目标值传入函数。

void ClosedLoop_SetSpeedMode(ClosedLoop_Controller *ctrl, float target_rpm) {
    ctrl->control_mode = CLOSED_LOOP_MODE_SPEED;
    ctrl->target_speed = target_rpm;
    PID_Reset(&ctrl->speed_pid);
}

void ClosedLoop_SetPositionMode(ClosedLoop_Controller *ctrl, float target_pulses) {
    ctrl->control_mode = CLOSED_LOOP_MODE_POSITION;
    ctrl->target_position = target_pulses;
    PID_Reset(&ctrl->position_pid);
}

接下来就是要写在中断里的函数了,数据处理函数。

void ClosedLoop_Update(ClosedLoop_Controller *ctrl) {
    uint32_t current_time = HAL_GetTick();
    
    // 检查是否到达PID计算时间
    if (current_time - ctrl->last_pid_time < ctrl->pid_interval) {
        return;
    }
    
    ctrl->last_pid_time = current_time;
    
    float measured_speed;
    uint8_t direction;
    
    // 获取编码器速度数据
    if (Hall_Encoder_GetSpeed(ctrl->encoder, &measured_speed, &direction)) {
        float output;
        Motor_DirectionTypeDef motor_dir;
        
        if (ctrl->control_mode == CLOSED_LOOP_MODE_SPEED) {
            // 速度环PID计算
            output = PID_Calculate(&ctrl->speed_pid, ctrl->target_speed, 
                                  measured_speed, current_time);
            
            // 确定电机方向
            motor_dir = (output >= 0) ? MOTOR_DIR_FORWARD : MOTOR_DIR_BACKWARD;
        } 
        
        // 应用PID输出到电机
        uint16_t pwm_duty = (uint16_t)fabsf(output);
        if (pwm_duty > 1000) pwm_duty = 1000;
        
        Motor_SetSpeed(ctrl->motor_group, ctrl->motor_index, motor_dir, pwm_duty);
    }
}

        同样先进行时间比对,更新时间标签。接下来获取当前实际速度,根据返回值确定时候获取成功,进而进入if语句,然后调用PID计算函数,算出输出量,传入目标旋转方向,防止占空比超过最大值,写了限制,最后控制电机旋转。

void ClosedLoop_Stop(ClosedLoop_Controller *ctrl) {
    Motor_StopGroup(ctrl->motor_group);
    PID_Reset(&ctrl->speed_pid);
    PID_Reset(&ctrl->position_pid);
}

咱们再来一个停止接口。这个文件的.c就写完了,咱们再把函数声明一下。

void ClosedLoop_Init(ClosedLoop_Controller *ctrl, 
                    Hall_Encoder_HandleTypeDef *encoder,
                    uint8_t motor_group, 
                    uint8_t motor_index);

void ClosedLoop_SetSpeedMode(ClosedLoop_Controller *ctrl, float target_rpm);
void ClosedLoop_SetPositionMode(ClosedLoop_Controller *ctrl, float target_pulses);
void ClosedLoop_Update(ClosedLoop_Controller *ctrl);
void ClosedLoop_Stop(ClosedLoop_Controller *ctrl);

最后再定义上咱们自己起名字的两个模式。

#include "pid.h"
#include "encoder.h"
#include "motor.h"
#include "math.h"

#define CLOSED_LOOP_MODE_SPEED  0
#define CLOSED_LOOP_MODE_POSITION 1

这样.c和.h就都完成了。

最后再在it文件也就是中断文件里把调用安排上,在这之前咱们启动一下TIM4,给咱们PID控速函数搞一个定时器中断,

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if (htim->Instance == TIM2) {
        // 更新编码器数据
        Hall_Encoder_Update(&hall_encoder);
    }
		else if (htim->Instance == TIM4) {
        // PID控制周期
        ClosedLoop_Update(&left_motor_ctrl);
    }
}

咱们使用只要去main.c文件里把初始化函数写好,调用模式配置函数就可以了。给大家演示一下一个最简单的例子。

/* USER CODE BEGIN 2 */
	// 初始化编码器
  Hall_Encoder_Init(&hall_encoder, &htim2, 11, ENCODER_MODE_4X); // 假设11PPR,4倍频
  
  // 启动编码器
  Hall_Encoder_Start(&hall_encoder);
  
  // 初始化电机
  Motor_InitAll();
  
  // 初始化闭环控制器
  ClosedLoop_Init(&left_motor_ctrl, &hall_encoder, 0, 0); // 左电机组,第一个电机
  
  // 启动定时器4用于PID计算
  HAL_TIM_Base_Start_IT(&htim4);
  /* USER CODE END 2 */

最后只要在while(1)里调用ClosedLoop_SetSpeedMode(&left_motor_ctrl, 100.0f);就可以啦。

        最后咱们来讲一下上面对积分和输出为什么要有限制,主要是为了防止机器卡住,积分项就会不断增大,逐渐越积越多,如果机器恢复工作就会以最大速度工作一段时间,把积分积累的消耗掉,最后恢复正常,这是我们不希望看到的,因此加了限制让积分有最大值这样就不会无限制增大,可以避免这种特殊情况。

        这样PID的这一节也就讲完了,但是PID的知识并没有结束,如果想学习跟高级的PID,包括双环多环PID,还有一些图书情况的PID优化例如不完全微分等知识可以去看我的PID详解这篇文章,届时我会带着大家更加深入的走进算法的世界。

总结

        咱们stm32c8t6系列芯片定时器的HAL库函数详解(中)的课程到这里就结束了,感谢您的阅读,不知道我的文章有没有让你学到新知识呢?当然我也将尽快更新下部分的内容,如果我的文章有帮助到您,希望可以得到您的点赞和关注,这也是我更新更多优质文章的动力,那么我们在stm32c8t6系列芯片定时器的HAL库函数详解(下)不见不散~

预告:stm32c8t6系列芯片定时器的HAL库函数详解(下)我会讲解高级定时器相比于通用定时器的区别以及功能的使用方法。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐