hal库总结学习
我会在hal库学习中添加一些问题及解答
1.GPIO口输入输出:

1.1推挽输出(GPIO_MODE_OUTPUT_PP):
任务:将小灯点亮

同时存在P-MOS 管和N-MOS 管,两个管子互补工作,源极分别接 VDD(3.3V)和 VSS(0V 地),漏极共同连接到 I/O 引脚。
我们通过代码写入 GPIO 的高低电平 →控制输出数据寄存器控制 P-MOS/N-MOS 的导通 / 截止
P-MOS 导通,N-MOS 截止,脚通过导通的 P-MOS 直接连接到 VDD(3.3V),引脚电平被强制拉高到 3.3V,此时有很强的拉电流能力,电路导通,小灯亮。
那么控制N-MOS 导通,P-MOS 截止, N-MOS 直接连接到地(0V),引脚电平被强制拉低到 0V,小灯灭。
HAL库中控制 P-MOS/N-MOS 的导通 / 截止的函数是啥?
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET); // 输出高电平(点亮LED)P-MOS导通,N-MOS截止
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET); // 输出低电平(熄灭LED)P-MOS截止,N-MOS导通
我们通过程序控制输出数据寄存器从而输出数据控制N-MOS 管的导通 / 截止,但是发现现在小灯亮了但没完全亮(工作电压为5V,但是VDD是3.3V),那如何让小灯在工作电压5V工作呢,这时候需要开漏输出了。
1.2开漏输出(GPIO_MODE_OUTPUT_OD):
开漏输出的本质是 “只能主动拉低,不能主动拉高”
输出逻辑 0,控制 N-MOS 管导通,引脚通过导通的 N-MOS 直接连接到地(0V),引脚电平被强制拉低到 0V
输出逻辑 1,逻辑控制 N-MOS 管截止,引脚与地完全断开,呈现高阻态,芯片不再驱动引脚引脚电平完全由外部电路决定:必须外接上拉电阻到电源,才能将引脚拉到高电平;无上拉时引脚电平不确定(浮空),这时候外接5V电源就可以完全让小灯亮起来了。

如电路图:仅保留了N-MOS 管,P-MOS 管被完全关闭(不参与工作),N-MOS 的源极直接接 VSS(0V 地),漏极连接到 I/O 引脚。(忽略复用输入功能)

1.3复用输出
复用推挽输出(AF_PP):外设驱动推挽结构,高低电平均由外设主动驱动
复用开漏输出(AF_OD):外设驱动开漏结构,仅能主动拉低,高电平需上拉
外设自动控制,软件仅能配置外设,无法直接写引脚,推挽 / 开漏结构不变,仅切换输入源(图中黄色的线)
2输入部分
2.1浮空输入(Floating Input)
上下拉电阻全部断开,引脚完全悬空.
输入阻抗极高,引脚电平完全由外部电路决定,悬空时电平不确定(易受干扰)
一般用于外部中断、按键(需外接上拉 / 下拉电阻)、UART RX 等外部信号输入
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; // 通用输入
GPIO_InitStruct.Pull = GPIO_NOPULL; // 浮空,无上下拉
2.2上拉输入(Pull-up Input)
接通 VDD 侧上拉电阻,断开 VSS 侧下拉电阻
引脚悬空时,被内部上拉电阻拉至高电平(VDD,3.3V);外部拉低时变为低电平
应用:按键输入(按键一端接引脚,一端接地,按下时引脚变低,松开自动上拉为高,无需外接电阻)
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; // 通用输入模式
GPIO_InitStruct.Pull = GPIO_PULLUP; // 开启上拉
2.3下拉输入(Pull-down Input)
配置:接通 VSS 侧下拉电阻,断开 VDD 侧上拉电阻
特性:引脚悬空时,被内部下拉电阻拉至低电平(0V);外部拉高时变为高电平
典型应用:按键输入(按键一端接引脚,一端接 VDD,按下时引脚变高,松开自动下拉为低)、传感器低电平有效信号
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLDOWN; // 开启下拉

2.4模拟输入:
输出驱动器(P-MOS/N-MOS)完全关闭,引脚与内部数字电路完全隔离,无任何数字驱动
上下拉电阻开关全部断开,引脚呈高阻态,不影响外部模拟信号
TTL 肖特基触发器(数字输入缓冲)被关闭,切断数字信号路径
啥是肖特基?
当外部信号有噪声、抖动、缓慢上升沿,普通比较器(单阈值)在中间电压会疯狂乱跳,肖特基触发器:只有明显高 / 明显低才翻转,中间波动直接吃掉。经过处理的电压呈现方波信号,即数字信号(0,1);所以没处理的是啥——模拟信号。

模拟输入路径直接连通:I/O 引脚 → 模拟输入 → 片上外设(ADC)
2.5复用功能输入:
是指将 GPIO 引脚从 “通用输入” 切换为 “片上外设专用输入”,让外部信号直接送给串口、SPI、定时器、I²C 等硬件模块,而不只是给 CPU 读高低电平,信号去向是外设,硬件模块来用,USART_RX、SPI_MISO、TIM_CHx 输入、I²C_SDA(片上外设)
3.EXTI(外部中断 / 事件控制器)
那复用功能输入的DC信号去哪了呢?
去EXTI了xdm。

有19个GPIO口中有EXTI,所以有输入线19 根,外部信号入口,对应 19 个 EXTI 线:
・EXTI0~EXTI15:GPIO 引脚(PAx~PDx 对应 EXTIx)
・EXTI16:PVD 电源电压检测
・EXTI17:RTC 闹钟
・EXTI18:USB 唤醒(F1 系列)
边沿检测电路检测输入线的电平变化,根据配置触发:
・上升沿触发(RISING)
・下降沿触发(FALLING)
・双边沿触发(BOTH)

他们可以编程调用,所以有对应的寄存器
软件中断事件寄存器通过软件写 1,手动触发中断 / 事件(无需外部信号),常用于调试。
或门(OR Gate) 合并「硬件边沿触发」和「软件触发」的信号,只要任意一个有效,就产生请求。
请求挂起寄存器:锁存中断请求:当有触发信号时,对应位被置 1,直到 CPU 响应中断后硬件自动清零
中断屏蔽寄存器 控制「中断请求」是否发送给 NVIC:
置 1:允许中断,请求会发送到 NVIC,置 0:屏蔽中断,请求被拦截(cubemx中只要将GPIO的NVIC打开自动就挂起1)
仅当「请求挂起」和「事件屏蔽」同时有效时,才输出事件信号

NVIC(中断控制器):接收中断请求,进行优先级仲裁,最终触发 CPU 进入中断服务函数(把他看作内部存着一个表的内存,里面存着中断函数)
| 中断服务函数名 | 中断向量号 | 对应管理的 EXTI 线 | 说明 |
|---|---|---|---|
| EXTI0_IRQHandler | EXTI0_IRQn | EXTI0 | 独立中断向量,仅对应 EXTI0 |
| EXTI1_IRQHandler | EXTI1_IRQn | EXTI1 | 独立中断向量,仅对应 EXTI1 |
| EXTI2_IRQHandler | EXTI2_IRQn | EXTI2 | 独立中断向量,仅对应 EXTI2 |
| EXTI3_IRQHandler | EXTI3_IRQn | EXTI3 | 独立中断向量,仅对应 EXTI3 |
| EXTI4_IRQHandler | EXTI4_IRQn | EXTI4 | 独立中断向量,仅对应 EXTI4 |
| EXTI9_5_IRQHandler | EXTI9_5_IRQn | EXTI5、EXTI6、EXTI7、EXTI8、EXTI9 | 共用中断向量,需在函数内判断具体触发线 |
| EXTI15_10_IRQHandler | EXTI15_10_IRQn | EXTI10、EXTI11、EXTI12、EXTI13、EXTI14、EXTI15 | 共用中断向量,需在函数内判断具体触发线 |
官方HAL库的中断函数定义

具体可以这么用:
void EXTI15_10_IRQHandler(void)
{
// 依次判断10-15号EXTI线的挂起位
if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_10) != RESET)
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_10)//cubemx自动生成清除中断标志位,cubemx好用爱用;
HAL_GPIO_EXTI_Callback(GPIO_PIN_10);
}
if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_11) != RESET)
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_11);
HAL_GPIO_EXTI_Callback(GPIO_PIN_11);
}
..................
................
if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_15) != RESET)
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_15);
HAL_GPIO_EXTI_Callback(GPIO_PIN_15);
}
EXTI 线编号与GPIO引脚一 一对应
| EXTI 线编号 | 可绑定的 GPIO 引脚 |
|---|---|
| EXTI0 | PA0、PB0、PC0 |
| EXTI1 | PA1、PB1、PC1 |
| EXTI2 | PA2、PB2、PC2 |
| ..... | ...... |
easy~
1.STM32F1 RTC 核心特性
时钟源:
推荐使用 LSE(32.768kHz 晶振),计时精度最高
备选 LSI(约 40kHz,精度较差,仅适合临时场景)
HSE 分频不推荐用于 RTC,会导致计时误差大
功能:
日历计时(秒 / 分 / 时 / 日 / 月 / 年)
闹钟(Alarm)触发中断
唤醒(Wakeup)定时器(部分封装支持)
备份寄存器(BKP)存储关键数据,掉电由 VBAT 保持
2.STM32 UART/USART 工作模式详解

1.基础通信模式
Asynchronous(异步模式)
最常用的 UART 模式,无需时钟线,仅 TX/RX 两根线
通信双方约定波特率、数据位、校验位、停止位
适用场景:串口调试、与 PC / 传感器 / 蓝牙模块通信
Synchronous(同步模式)
额外提供 SCLK 时钟线,由主机提供时钟同步数据
数据传输速率更稳定,适合高速短距通信
适用场景:与 SPI 兼容设备、某些显示模块通信
Single Wire (Half-Duplex)(单线半双工)
共用一根线(TX)进行收发,同一时间只能单向传输
节省 IO 口,适合总线式多设备通信
适用场景:RS485 总线、低成本多点通信
2.专用协议模式
Multiprocessor Communication(多处理器通信)
支持地址帧 / 数据帧区分,实现多设备总线寻址
适合一个主机带多个从机的场景
IrDA(红外数据协会模式)
兼容红外通信协议,通过红外收发器实现无线短距传输
适用场景:红外遥控器、旧款便携设备
LIN(本地互连网络模式)
符合 LIN 2.0/2.1 协议,用于汽车车身电子低成本通信
单总线、主从结构,速率最高 20kbps
SmartCard / SmartCard with Card Clock(智能卡模式)
兼容 ISO 7816 智能卡协议,支持卡时钟输出
适用场景:SIM 卡、金融 IC 卡、身份识别卡

- 基础参数(Basic Parameters)
| 参数 | 配置值 | 详细说明 |
|---|---|---|
| Baud Rate | 115200 Bits/s | 波特率:每秒传输 115200 位数据,是嵌入式开发最常用的速率之一,兼顾速度与稳定性 |
| Word Length | 8 Bits (including Parity) | 数据位长度:8 位(包含校验位),当前无校验位,实际有效数据为 8 位 |
| Parity | None | 校验位:无校验,不进行数据错误检测,传输效率更高 |
| Stop Bits | 1 | 停止位:1/2 位,用于标识一帧数据的结束 |
3.DMA 完全讲解(结合你的 UART + STM32 场景)
1.什么是 DMA?
DMA = 直接存储器访问一句话:让数据自己搬,不用 CPU 盯着。
普通串口发送 / 接收:CPU 一个字节一个字节发 / 收,全程占用
DMA 模式:CPU 只需要告诉 DMA「从哪搬、搬到哪、搬多少」,然后 CPU 就可以去干别的,搬完 DMA 会通知 CPU
优点:
解放 CPU,不阻塞主循环
高速传输、稳定
串口大量数据收发必备
- 你现在的场景:UART + DMA
你刚才看的是 UART 模式,现在配 DMA 就是让串口收发不占用 CPU。
UART 常用 3 种 DMA:
TX DMA:串口发送数据用 DMA
RX DMA:串口接收数据用 DMA
RX DMA + 空闲中断 (IDLE):接收不定长数据(最常用、最实用)
- CubeMX 配置 DMA(一步到位)
① 进入 USARTx → DMA Settings
② Add 添加
USART_TX:选择 Normal 正常模式
USART_RX:选择 Circular 循环模式(适合不停接收)
③ 优先级
一般 Medium 即可
数据量大可以设 High
④ 数据宽度
Byte(串口都是 1 字节)
- 最重要的 3 个函数(直接复制用)
1)DMA 发送串口数据(不阻塞)
// 发送 len 个数据,不阻塞 CPU
HAL_UART_Transmit_DMA(&huart1, (uint8_t*)buf, len);
2)DMA 循环接收(后台一直收)
// 开启 DMA 接收,存到 buf,最大接收 100 字节
uint8_t uart_buf[100];
HAL_UART_Receive_DMA(&huart1, uart_buf, 100);
3)DMA 发送完成回调
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart == &huart1)
{
// 发送完成后你要做的事
}
}
// 接收缓存
uint8_t rx_buf[128];
uint8_t rx_len = 0;
// 初始化时开启
HAL_UART_Receive_DMA(&huart1, rx_buf, 128);
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
void USART1_IRQHandler(void)
{
if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
{
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
HAL_UART_DMAStop(&huart1);
// 本次收到的数据长度
rx_len = 128 - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
// 在这里处理数据
// ...
// 重新开启 DMA 接收
HAL_UART_Receive_DMA(&huart1, rx_buf, 128);
}
HAL_UART_IRQHandler(&huart1);
}
4.串口重定向(printf 输出到 UART)
重定向,让 C 语言的 printf / scanf 直接用你的串口(UART)输出,支持 DMA / 阻塞 / 中断 任意模式。
- 直接可用代码(最常用)
在你的 main.c 或 usart.c 里添加这段代码:
/* 必须添加这个头文件 */
//# include "stdio.h"
/* 重定向 fputc:printf 会调用这个函数发送字符 */
int fputc(int ch, FILE *f)
{
/* 你的串口号,比如 huart1 */
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
/* 重定向 fgetc:scanf 会调用这个函数接收字符 */
int fgetc(FILE *f)
{
uint8_t ch = 0;
HAL_UART_Receive(&huart1, &ch, 1, HAL_MAX_DELAY);
return ch;
}
添加后,直接在代码里写:
printf("Hello 重定向成功!\r\n");
注意在usart.c添加这个typedef struct __FILE FILE;
串口助手就能收到文字!
for example:
uint8_t c_Data[] = "串口输出测试:nb666\r\n";
HAL_UART_Transmit(&huart1,c_Data,sizeof(c_Data),0xFFFF);
HAL_Delay(1000);
- 如果你想用 DMA 重定向(不卡 CPU)
把上面的 HAL_UART_Transmit 换成 DMA 版本即可:
int fputc(int ch, FILE *f)
{
// 等待上一次DMA发送完成
while(HAL_UART_GetState(&huart1) != HAL_UART_STATE_READY);
// DMA 发送单个字符
HAL_UART_Transmit_DMA(&huart1, (uint8_t *)&ch, 1);
return ch;
}
- 必须开启的设置(重要!)
如果发现 printf 不输出,99% 是这里没开:
Keil 设置
点击 魔法棒 → Target
勾选 Use MicroLIB (使用微库)
不勾这个,printf 不会工作!
5.PWM配置
5.1 STM32 PWM 模式 1 vs 模式 2(向上计数模式)
| 特性 | PWM mode 1 | PWM mode 2 |
|---|---|---|
| 计数 < CCR 时 | 有效电平 (High) | 无效电平 (Low) |
| 计数 ≥ CCR 时 | 无效电平 (Low) | 有效电平 (High) |
| 占空比公式 | Duty=ARR+1CCR×100% | Duty=ARR+1ARR+1−CCR×100% |
| 通俗理解 | 高电平时间短 = 小占空比高电平时间长 = 大占空比 | 低电平时间短 = 大占空比低电平时间长 = 小占空比 |

| 参数 | 配置值 | 含义与计算 |
|---|---|---|
| Prescaler (PSC) | 1440-1 | 预分频器,实际值为 1439公式:PSC = 分频系数 - 1 |
| Counter Mode | Up | 向上计数模式(最常用的 PWM 模式) |
| Counter Period (ARR) | 100-1 | 自动重装载值,实际为 99决定 PWM 周期:周期 = (PSC+1)*(ARR+1) / 定时器时钟 |
| Internal Clock Division (CKD) | No Division | 时钟不分频,保持原定时器时钟 |
| Repetition Counter (RCR) | 0 | 重复计数器,普通 PWM 无需使用 |
| auto-reload preload | Disable | 不开启自动重装载预装载(ARR 立即生效) |

| 参数 | 配置值 | 详细说明 |
|---|---|---|
| Mode | PWM mode 1 | 计数器值 < CCR 时输出有效电平,计数器值 ≥ CCR 时输出无效电平配合 CH Polarity=High,即:低占空比时输出低电平,高占空比时输出高电平 |
| Pulse (16 bits value) | 50 | 捕获比较寄存器 CCR 的初始值,决定 PWM 占空比占空比 = CCR / (ARR+1)结合之前 ARR=99 的配置,占空比 = 50 / 100 = 50% |
| Output compare preload | Enable | 开启 CCR 预装载功能,CCR 值在 ** 更新事件(UEV)** 时才生效,避免 PWM 输出出现毛刺、跳变,保证输出平滑 |
| Fast Mode | Disable | (高频输出PWM波)关闭快速模式,默认延迟一个时钟周期更新比较输出,适合常规 PWM 应用开启后可实现更快响应,但会增加硬件复杂度 |
| CH Polarity | High | 通道有效电平为高电平若改为 Low,则有效电平为低电平,占空比逻辑反转 |
| CH Idle State | Reset | 空闲状态时输出为复位电平(低电平)若改为 Set,则空闲时输出高电平 |
5.2 输出比较模式 (Output Compare, OC)(不咋用到)

| 模式名称 | 英文原名 | 核心动作 | 通俗解释 | 适用场景 |
|---|---|---|---|---|
| Frozen | (used for Timing base) | 不动作 | 计数器与 CCR 匹配时,引脚电平保持不变 | 定时器仅做计时,不控制输出引脚(最常用作基础时钟) |
| Active Level on match | 匹配时置高 | 输出 高电平 | 计数到 CCR 时,引脚强制变 高 | 产生一个单次启动信号(如启动电机) |
| Inactive Level on match | 匹配时置低 | 输出 低电平 | 计数到 CCR 时,引脚强制变 低 | 产生一个单次停止信号(如关断蜂鸣器) |
| Toggle on match | 匹配时翻转 | 电平翻转 | 计数到 CCR 时,高变低,低变高 | 产生方波(纯软件 PWM,无需 ARR 自动重载) |
| Forced Active | 强制置高 | 强制输出 高电平 | 无论计数器是否到 CCR,引脚强制保持 高 | 测试硬件通路,或强制拉高某个信号 |
| Forced Inactive | 强制置低 | 强制输出 低电平 | 无论计数器是否到 CCR,引脚强制保持 低 | 测试硬件通路,或强制拉低某个信号 |
5.3 PWM仿真
1.软件仿真(不需要单片机连接)






PWM输出的配置就已经完成了,但是不能输出产生PWM波,因为Cube在生成代码时,有很多外设初始
化完后默认是关闭的,需要我们手动开启。
/* use code begin 2*/
HAL_TIM_PWM_Start(&htim1,TIM_CHANNEL_1);//开启定时器1 通道1 PWM输出
HAL_TIM_PWM_Start(&htim1,TIM_CHANNEL_4);//开启定时器1 通道4 PWM输出
/* use code end 2*/
我们可以使用这个宏来修改占空比:
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, 40);
2.硬件仿真(st-link or dap)
6.1-认识电机驱动
市面上的驱动板比较多,我介绍一些常用的。
1.L298N(与TB6612的使用思路差不多)

| L298N 模块引脚 | STM32 引脚 | 功能说明 |
|---|---|---|
| IN1 | Px | 电机 A 方向控制 1 |
| IN2 | Px | 电机 A 方向控制 2 |
| IN3 | Px | 电机 B 方向控制 1 |
| IN4 | Px | 电机 B 方向控制 2 |
| ENA | Px (TIMx_CH1) | 电机 A 调速 PWM 输入 |
| ENB | Px (TIMx_CH2) | 电机 B 调速 PWM 输入 |
| GND | GND | 必须共地,保证电平参考一致 |
| 12V (可选) | 12V 直流电源正极 | 电机驱动电源(7~12V 通用) |
| GND | 12V 直流电源负极 | 电机电源地,同时与 STM32 GND 共接 |
| 5V(可选) | 5V 输入 | 给 STM32 逻辑供电(若模块带 5V 稳压) |
| OUT1 | 电机 A 正极 | 电机 A 输出端 1 |
| OUT2 | 电机 A 负极 | 电机 A 输出端 2 |
| OUT3 | 电机 B 正极 | 电机 B 输出端 1 |
| OUT4 | 电机 B 负极 | 电机 B 输出端 2 |
控制逻辑对照表(方便编程)
| 电机 | IN1/IN3 | IN2/IN4 | ENA/ENB | 电机状态 |
|---|---|---|---|---|
| 电机 A | 1 | 0 | PWM | 正转(调速) |
| 电机 A | 0 | 1 | PWM | 反转(调速) |
| 电机 A | 0 | 0 | 任意 | 停止 |
| 电机 B | 1 | 0 | PWM | 正转(调速) |
| 电机 B | 0 | 1 | PWM | 反转(调速) |
| 电机 B | 0 | 0 | 任意 | 停止 |
1.2例程使用代码
//========== 方向引脚定义 ==========
// L298N / TB6612 通用
#define AIN1_SET HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET)
#define AIN1_RESET HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET)
#define BIN1_SET HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET)
#define BIN1_RESET HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET)
void Motor_Set(int motor1, int motor2)
{
// 电机 B(motor1)
if(motor1 < 0)
{
BIN1_SET; // 反转
}
else
{
BIN1_RESET; // 正转
}
if(motor1 < 0)
{
if(motor1 < -99) motor1 = -99;
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, (100 + motor1));
}
else
{
if(motor1 > 99) motor1 = 99;
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, motor1);
}
// 电机 A(motor2)
if(motor2 < 0)
{
AIN1_SET; // 反转
}
else
{
AIN1_RESET; // 正转
}
if(motor2 < 0)
{
if(motor2 < -99) motor2 = -99;
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_4, (100 + motor2));
}
else
{
if(motor2 > 99) motor2 = 99;
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_4, motor2);
}
}
2.TB6612


通过程序输入的AIN1,AIN2//BIN1,BIN2控制电机A组B组正反转,AO1,AO2,BO1,BO2接电机的端口,PWMA,PWMB接PWM信号输入端,其他的正常接,注意一定要按原理图的额定电压接(VM这一块)。
2.2 电机驱动信号表

2.3例程使用代码
// TB6612 引脚定义
#define AIN1_SET HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET)
#define AIN1_RESET HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET)
#define BIN1_SET HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET)
#define BIN1_RESET HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET)```
void Motor_Set(int motor1, int motor2)
{
// 电机 B(motor1)
// 方向控制:正转/反转
if (motor1 < 0) BIN1_SET; // 反转
else BIN1_RESET; // 正转
// 转速控制
if(motor1 < 0)
{
if(motor1 < -99) motor1 = -99;
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, (100 + motor1));
}
else
{
if(motor1 > 99) motor1 = 99;
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, motor1);
}
// 电机 A(motor2)
// 方向控制
if(motor2 < 0) AIN1_SET; // 反转
else AIN1_RESET; // 正转
// 转速控制
if(motor2 < 0)
{if(motor2 < -99) motor2 = -99;
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_4, (100 + motor2));}
else
{ if(motor2 > 99) motor2 = 99; __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_4, motor2);
}
}
3.AT8266/AT4985
1.原理图及功率表


2.接线图
DCDC12V,降压模块12V转V,编码器直流有刷电机,STM32。

3.工作原理

通过PB13—AIN1的高低电平进行电机的正反转(AIN2-AIN1=P(PWM值),其中P>0,电机正转,P<0,电机逆转)
通过PA11-AIN2(只要能输出PWM波的PIN就行)
4.例程使用思路:
PA11-TIM1_CH4 定时器PWM输出-PWMA——AIN2
PB13-GPIO输出-AIN1
PA8-TIM1_CH1 定时器PWM输出-PWMB——BIN2
PB3-GPIO输出-BIN1
HAL GPIO_WritePin(AIN1_GPIO_Port,AIN1_Pin,GPIO_PIN_RESET);//设置AIN1 PB13为 低
平
HAL_GPIO_WritePin(BIN1_GPIO_Port,BIN1_Pin,GPIO_PIN_SET); //设置BIN1 PB3为高电
平
HAL_Delay(1000);
//两次会使得电机反向。
HAL_GPIO_WritePin(AIN1_GPIO_Port,AIN1_Pin,GPIO_PIN_SET);//设置AIN1 PB13为 高电
平
HAL_GPIO_WritePin(BIN1_GPIO_Port,BIN1_Pin,GPIO_PIN_RESET); //设置BIN1 PB3为低
电平
如何让电机90%电压转速 旋转:
为了方便移植和使用,我们GPIO电平控制写成宏(motor.h里配置)
#define AIN1_RESET HAL_GPIO_WritePin(AIN1_GPIO_Port,AIN1_Pin,GPIO_PIN_RESET)//设
置AIN1 PB13为 低电平
#define AIN1_SET HAL_GPIO_WritePin(AIN1_GPIO_Port,AIN1_Pin,GPIO_PIN_SET)//设置
AIN1 PB13为 高电平
#define BIN1_RESET HAL_GPIO_WritePin(BIN1_GPIO_Port,BIN1_Pin,GPIO_PIN_RESET)
//设置BIN1 PB3为低电平
#define BIN1_SET HAL_GPIO_WritePin(BIN1_GPIO_Port,BIN1_Pin,GPIO_PIN_SET)//设置
AIN1 PB13为 高电平
下面我们编写小车电机方向和速度控制:
函数封装好习惯
写的不行请指正(dog)
void Motor_Set (int motor1,int motor2)
{
//根据参数正负 设置选择方向
if(motor1 < 0) BIN1_SET;
else BIN1_RESET;
if(motor2 < 0) AIN1_SET;
else AIN1_RESET;
//motor1 设置电机B的转速
if(motor1 < 0)
{
if(motor1 < -99) motor1 = -99;//超过PWM幅值
//负的时候绝对值越小 PWM占空比越大
//现在的motor1 -1 -99
//给寄存器或者函数 99 1
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, (100+motor1));//修改定时器1
通道1 PA8 Pulse改变占空比
}
else{
if(motor1 > 99) motor1 = 99;
//现在是 0 1 99
//我们赋值 0 1 99
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, motor1);//修改定时器1 通道1PA8 Pulse改变占空比
}
//motor2 设置电机A的转速
if(motor2 < 0)
{
if(motor2 < -99) motor2 = -99;//超过PWM幅值
//负的时候绝对值越小 PWM占空比越大
//现在的motor2 -1 -99
//给寄存器或者函数 99 1
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_4, (100+motor2));//修改定时器1 通道4 PA11 Pulse改变占空比
}
else{
if(motor2 > 99) motor2 = 99;
//现在是 0 1 99
//我们赋值 0 1 99
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_4, motor2);//修改定时器1 通道4 PA11 Pulse改变占空比
}
}
这几款电机驱动的选择难舍难分,下面是几个电机的优缺点:
| 特性 | TB6612 | L298N | A4950 |
|---|---|---|---|
| 驱动类型 | 双路 H 桥直流电机驱动 | 双路 H 桥直流电机驱动 | 双路 H 桥直流电机驱动 |
| 电机驱动电压 | 4.5-10V | 7-12V(最高 46V) | 7.6-30V |
| 逻辑供电电压 | 2.7-5.5V(兼容 3.3V/5V 单片机) | 5V(需模块稳压) | 5V |
| 连续输出电流 | 1.2A / 路(峰值 3.2A) | 1A / 路(峰值 2A) | 1.5A / 路(峰值 3A) |
| 导通电阻 | ~0.14Ω(MOSFET,效率 > 90%) | >2Ω(双极型,效率 60-70%) | 低 MOSFET(效率 > 85%) |
| 发热情况 | 极低,无需散热片 | 极高,必须加散热片 | 低,小散热即可 |
| 特殊功能 | STBY 待机控制(低功耗) | 无 | 内置过流 / 过温 / 欠压保护 |
| 典型场景 | 电池供电小车、低功耗机器人 | 高压大电流电机、低成本方案 | 中高压电机、工业级场景 |
TB6612:电池供电小车、低功耗、小体积,效率高、发热低、兼容 3.3V 单片机。
L298N:高压大电流电机、低成本、资料多电压范围宽、驱动能力强、入门友好。
A4950/AT8266:中高压工业场景、高可靠性,宽压输入、全保护。
7.1 认识编码器
一、编码器官方定义
编码器(Encoder)是一种将角位移、角速度等机械运动参数转换为脉冲信号或数字代码的传感器装置,通过检测旋转或直线运动的位置、速度与方向信息,实现运动系统的闭环控制与精准定位,广泛应用于电机调速、伺服控制、工业自动化、机器人、精密传动等领域。
编码器有很多种,有角速度编码器,正交编码器,方向编码器,绝对值编码器
增量式编码器:
每旋转一个固定角度输出一组脉冲,仅记录相对位移,断电后数据丢失,需复位归零。特点:结构简单、成本低、响应快、适合速度与相对位置检测。
绝对式编码器:每个位置对应唯一数字编码,可直接读取绝对位置,断电不丢失,开机即可知当前位置。特点:精度高、抗干扰强、成本高,用于高精度定位场景。
啥?
编码器,电机自带的 “小尺子 + 计数器”
电机 转了多少圈
转得 有多快(转速)
往 哪个方向转
没有编码器:你只能给 PWM,不知道电机实际转没转、转多快、会不会打滑。
有编码器:你就能做 精准调速、走固定距离、定角转动、平衡小车。
具体参数:
转速: 单位时间测量到的脉冲数量(比如根据每秒测量到多少个脉冲来计算转速)
旋转方向: 两通道信号的相对电平关系
编码器输出的波形,如何通过单片机读取波形,然后计算出速度?
STM32单片机的定时器和通用定时器具有编码器接口模式
如图:

这个是计数方向与编码器信号的关系、我们拆开来看
仅在TI1计数、电机正转、对原始数据二倍频

在TI1和TI2都计数
可以看到这样就对原始数据四倍频了

计数方向可以根据cubemx中的counter mode设置:

counter mode有三种形式:
Up(向上计数)
波形:0 → ARR → 0 → ARR 锯齿波(如下左图)
溢出:CNT 达到 ARR 后触发更新中断(UEV),归零重计
Down(向下计数)
波形:ARR → 0 → ARR → 0 反向锯齿波(如下右图)
下溢:CNT 达到 0 后触发更新中断(UEV),从 ARR 重计
Center-aligned(中心对齐)
波形:0 → ARR → 0 → ARR 三角波
特点:递增 / 递减阶段各触发一次中断,适合生成对称 PWM 波形。

我们要测量速度,因此要获得单位时间计数器值变化量,有两种方法:
这次编码器计数值 = 计数器值+计数溢出次数 * 计数最大器计数最大值
计数器两次变化值 = 这次编码器计数值 - 上次编码器计数值
然后根据这个单位变化量计算速度
计数器变化量 = 当前计数器值
每次计数值清空
然后根据这个变化量 计算速度
那么编码器是如何算出速度?
举个例子:
JGA25系列的两个不同减速比的编码器:
| 参数 | 数值 | 计算结果 |
|---|---|---|
| 减速比 | 1:30 | 电机轴转 30 圈 → 输出轴转 1 圈 |
| 编码器线数(电机轴) | 11 线 / 圈 | 电机轴每转 11 个周期 |
| 输出轴等效线数 | 30×11 = 330 线 / 圈 | 轮子每转 330 个周期 |
| 4 倍频后总脉冲 | 330×4 = 1320 脉冲 / 圈 | 轮子每转计数器 + 1320 |
| 空载转速(输出轴) | ~200 RPM | 低速大扭矩,适合 AGV 小车 |
| 额定电压 | 12V | 同系列通用 |
| 参数 | 数值 | 计算结果 |
|---|---|---|
| 减速比 | 1:100 | 电机轴转 100 圈 → 输出轴转 1 圈 |
| 编码器线数(电机轴) | 11 线 / 圈 | 电机轴每转 11 个周期 |
| 输出轴等效线数 | 100×11 = 1100 线 / 圈 | 轮子每转 1100 个周期 |
| 4 倍频后总脉冲 | 1100×4 = 4400 脉冲 / 圈 | 超高精度,定位细腻 |
| 空载转速(输出轴) | ~60 RPM | 超低速,适合云台、机械臂关节 |
| 额定电压 | 12V | |
轮子转速计算: n=ΔN/(4×i×PPR)
为啥是4——默认encoder mod为Encoder Mode TI1 and TI2模式,4倍频
n:输出轴转速(RPM,转 / 秒)
ΔN:单位时间内定时器计数变化量(我程序写的是HAL_Delay(2),每两毫秒清除一次)
i:减速比
PPR:电机轴编码器线数(线 / 圈)
eg:
减速比 i=9.6,PPR=11,ΔN=422.4(1 圈计数值)
n=4×9.6×11422.4×60=60转每分钟
那么在cubemx如何配置?如图:

开启定时器和定时中断
HAL_TIM_Encoder_Start(&htim2,TIM_CHANNEL_ALL);//开启定时器2
HAL_TIM_Encoder_Start(&htim4,TIM_CHANNEL_ALL);//开启定时器4
HAL_TIM_Base_Start_IT(&htim2); //开启定时器2 中断
HAL_TIM_Base_Start_IT(&htim4); //开启定时器4 中断
在定义两个变量保存计数器值
short Encoder1Count = 0;//编码器计数器值
short Encoder2Count = 0;
为啥是short?
STM32 的TIMx_CNT是16 位无符号寄存器,取值范围 0 ~ 65535;
为了彻底解决溢出问题,无需手动维护溢出次数,强制转换后,CNT 的溢出 / 下溢会自动变成连续的正负值,直接用差值计算位移,完全不用管溢出!
比如向上计数模式时CNT 从0递增到65535,溢出后归零。
0~32767 → 转 short 后为0~32767(正数,正转)
32768~65535 → 转 short 后为-32768~-1(负数,相当于正转溢出后的连续值,当然我们的电机也不会溢出,ΔN=422.4,32767/422.4=77.57339015*500(每两毫秒清除一次)=38786.695075转每秒)
燃油超跑(自然吸气 / 涡轮):
法拉利 12Cilindri:9500 转 / 分 → 158.3 转 / 秒
阿波罗 Evo:8500 转 / 分 → 141.7 转 / 秒
雪佛兰科尔维特 Z06:8600 转 / 分 → 143.3 转 / 秒
兰博基尼 Temerario:10000 转 / 分 → 166.7 转 / 秒
顶级赛道 / 复古高转:
Gordon Murray S1 LM:12100 转 / 分 → 201.7 转 / 秒
Rodin FZero(NA):12000 转 / 分 → 200.0 转 / 秒
F1 赛车(规则限制):15000 转 / 分 → 250.0 转 / 秒(比赛常用换挡约 12000–14000 转 / 分 → 200–233 转 / 秒)
6666你家电机比超跑快几百倍,所以我们的电机连续值也不会溢出,不必担心。
比如向下计数模式时
32768~65535 → 转 short 后为-32768~-1(负数,反转)
0~32767 → 转 short 后为0~32767(正数,相当于反转下溢后的连续值)

每2ms读取计数器值->清零计数器
我们用这种方法
计数器变化量 = 当前计数器值
每次计数值清空
然后根据这个变化量 计算速度
Motor_Set(0,0);
//1.保存计数器值
Encoder1Count =(short)__HAL_TIM_GET_COUNTER(&htim4);
Encoder2Count =(short)__HAL_TIM_GET_COUNTER(&htim2);
//2.清零计数器值
__HAL_TIM_SET_COUNTER(&htim4,0);
__HAL_TIM_SET_COUNTER(&htim2,0);
printf("Encoder1Count:%d\r\n",Encoder1Count);//oled显示
printf("Encoder2Count:%d\r\n",Encoder2Count);//oled显示
HAL_Delay(2);
计算速度(假如单位设置为转每秒)
//定义速度变量
float Motor1Speed = 0.00;
float Motor2Speed = 0.00;
//计算速度
Motor1Speed = (float)Encode1Count*100/9.6/11/4;//计数值*100映射到0-100
Motor2Speed = (float)Encode2Count*100/9.6/11/4;
printf("Motor1Speed:%.2f\r\n",Motor1Speed);
printf("Motor2Speed:%.2f\r\n",Motor2Speed);
定时器(TIM)中断定时测量速度
上面我们实现:在主函数周期,读取计数器值然后计算速度,但是如果函数加入其他内容这个周期时间就很难保证。
为啥?
主循环是软循环,时间不可控,main 里有延时、有打印、有逻辑判断、有传感器读取,程序忙 周期变长,程序闲 周期变短
但是我们需要固定时间间隔如果时间不固定,同样脉冲数时间越长速度算得越慢,后面控制算法(PID)必须等周期运行,所以我们必须用定时器中断 + 固定周期采样,用定时器中断,可以保证无论主循环多忙,测速和控制周期永远精确不变。我们使用高级定时器 TIM1 产生 2ms 定时中断,在中断回调函数中完成编码器读取、速度计算、PID 运算和 PWM 输出。
因此:
我们先开启定时器、2ms进入一次定时器中断,中断回调函数执行代码即可,设置内部时钟源,使能自动重装载,开启定义更新中断。


HAL_TIM_Base_Start_IT(&htim1); //开启定时器1 中断
定时器回调函数中添加 速度计算内容
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim1) // TIM1 2ms 中断
{
static uint8_t TimerCount = 0;
TimerCount++;
if(TimerCount >= 5) // 10ms 执行一次
{
// 读取编码器
Encode1Count = (int16_t)__HAL_TIM_GET_COUNTER(&htim4);
Encode2Count = (int16_t)__HAL_TIM_GET_COUNTER(&htim2);
// 清零
__HAL_TIM_SET_COUNTER(&htim4, 0);
__HAL_TIM_SET_COUNTER(&htim2, 0);
// 计算转速 RPM
Motor1Speed = (float)Encode1Count * 10.0f / 11.0f /4.0f;
Motor2Speed = (float)Encode2Count * 10.0f / 11.0f /4.0f;
TimerCount = 0;
}
}
}
if(htim == &htim1)
判断进入的中断是否为 TIM1 高级定时器
TimerCount++;
每 2ms 进入一次中断,计数器自增
if(TimerCount %5 == 0)
2ms × 5 = 10ms
表示每 10ms 读取一次编码器、计算一次速度
读取编码器,清空编码器计数器,速度计算,
转速(RPM) = 10ms脉冲数 × 100(1秒=10个10ms)
÷ 9.6(减速比?或倍频系数)
÷ 11(编码器线数 PPR)
÷ 4(四倍频)

//定义变量
short Encode1Count = 0;
short Encode2Count = 0;
float Motor1Speed = 0.00;
float Motor2Speed = 0.00;
uint16_t TimerCount=0;
主函数就输出速度大小
printf("Motor1Speed:%.2f\r\n",Motor1Speed);
printf("Motor2Speed:%.2f\r\n",Motor2Speed);
外部变量需要声明一下
extern float Motor1Speed ;
extern float Motor2Speed ;

注意:根据电机和实际调整速度测量与占空比设置函数
由于硬件接线的天然反向
在小车电机接线中,左右电机安装方向相反或驱动板(TB6612/L298N/AT8266/AT4985)的方向引脚电平定义或电机正负极接线顺序等原因需要改一下,每个人的接线方式不同正负号加的地方也不一样。
找到motor_set 函数
void Motor_Set (int -motor1,int -motor2)//改一下
{
//根据参数正负 设置选择方向
if(motor1 < 0) BIN1_SET;
else BIN1_RESET;
if(motor2 < 0) AIN1_SET;
else AIN1_RESET;
.....
找到HAL_TIM_PeriodCallback函数
void HAL_TIM_PeriodCallback(TIM_HandleTypeDef *htim)
{
if(htim == &htim1) // TIM1 2ms 中断
{
static uint8_t TimerCount = 0;
TimerCount++;
if(TimerCount >= 5) // 10ms 执行一次
{
// 读取编码器
Encode1Count = -(int16_t)__HAL_TIM_GET_COUNTER(&htim4);//加负号
Encode2Count = (int16_t)__HAL_TIM_GET_COUNTER(&htim2);
.....
9.PID-速度控制
你的小车很多时候的运动参数不是恒定不变的,之前的 Motor_Set(50) 只是开环给 PWM,如果出现电池电压下降,速度变慢,小车载重 ,速度变慢,左右电机转速不一致 ,小车跑偏等情况,咋办?
我们先编写一个简单的控制方法
要求:转速控制在3.9-4.0转每秒
if(Motor1Speed>4.0) Motor1Pwm--;
if(Motor1Speed<3.9) Motor1Pwm++;
if(Motor2Speed>4.0) Motor2Pwm--;
if(Motor2Speed<3.9) Motor2Pwm++;
Motor_Set(Motor1Pwm,Motor2Pwm);
printf("Motor1Speed:%.2f Motor1Pwm:%d\r\n",Motor1Speed,Motor1Pwm);
printf("Motor2Speed:%.2f Motor2Pwm:%d\r\n",Motor2Speed,Motor2Pwm);
HAL_Delay(100);
开始电机没有到达4转每秒,PWM占空比逐渐增大,电机逐渐达到要求转速,到达要求转速后我们增加阻力,电机变慢,阻力大小不变PWM占空比逐渐更大转速逐渐更大。这样我们就把转速控制到我们想要的范围,但是我们并不满意、能够看出来控制的速度很慢(100ms一次),给电机一些阻力电机至少要2-3秒能够调整过来,这在一些场景是不允许的(比如小车转弯时速度很快,传感器来不及识别,但是PWM变化速度跟不上最后跑偏)。我们理想的控制效果是:在电机转速很慢的时候能快速调整,电机一直不能达到要求时能够更快速度调整。
9.2匿名上位机曲线显示速度波形方便观察数据
为了方便观察电机速度数据,我们通过上位机曲线显示一下。
匿名上位机官方下载链接:http://www.anotc.com/wiki/匿名产品资料/资料下载链接汇总
在匿名上位机资料下载链接,可以下载到协议介绍
匿名上位机V7通信协议,20210528发布:https://pan.baidu.com/s/1nGrIGWj6qr9DWOcGpKR5
1g 提取码:z8d1
CSDN 慕羽★大佬写的协议解析教程博客:https://blog.csdn.net/qq_44339029/article/details/106004997
大佬的通信格式介绍:
大端模式(BE big-endian),是指数据的低位保存在内存的高地址中,而数据的高位,保
存在内存的低地址中(低对高,高对低);
小端模式(LE little-endian),是指数据的低位保存在内存的低地址中,而数据的高位保
存在内存的高地址中(低对低,高对高)。

灵活格式帧(用户自定义帧)
0xF1 帧:
0xAA:一个字节表示开始
0xFF:一个字节表示目标地址
0xF1:一个字节表示发送功能码
1-40:一个字节表示数据长度
DATA0L DATA0H 第1个数据
DATA1L DATA1H 第2个数据
DATA2L DATA2H 第3个数据
DATA3L DATA3H 第4个数据
SUM 校验和
Ac 附加校验

那么数据内容有多个字节如何发送?
因为串口每次发送一个字节,但是数据可能是int16_t 16位的数据,或者int32_t 32位数据,每次发送
16位数据,先发送数据低八位,还是先发送数据高八位?
匿名协议通信介绍给出:DATA 数据内容中的数据,采用小端模式传送,低字节在前,高字节在后。
那么就要求,比如我们在发送16位数据0x2314我们要先发送低字节0x14,然后发送高字节0x23。
那么如何解析出低字节或者高字节,就需要知道多字节数据在单片机里面是怎么存的,因为STM32是小
端存储,所以低字节就在低位地址中,高字节高位地址中。
嵌入式通信协议只能逐字节发送,16 位(2 字节)、32 位(4 字节)数据必须拆分成单个uint8_t/char发送
如果使用32单片机那么一般是小端模式,0x23高地址,0x14在低地址,所以我们要先发低地址,再发高地址。
下面就是对16位数据,或者32位数据的拆分:
//需要发送16位,32位数据,对数据拆分,之后每次发送单个字节
//拆分过程:对变量dwTemp 去地址然后将其转化成char类型指针,最后再取出指针所指向的内容
#define BYTE0(dwTemp) (*(char *)(&dwTemp))
#define BYTE1(dwTemp) (*((char *)(&dwTemp) + 1))
#define BYTE2(dwTemp) (*((char *)(&dwTemp) + 2))
#define BYTE3(dwTemp) (*((char *)(&dwTemp) + 3))
void ANO_DT_Send_F1(uint16_t _a, uint16_t _b, uint16_t _c, uint16_t _d)//功能:通过F1帧发送4个uint16类型的数据
void ANO_DT_Send_F2(int16_t _a, int16_t _b, int16_t _c, int16_t _d)//功能:通过F2帧发送4个uint16类型的数据
void ANO_DT_Send_F3(int16_t _a, int16_t _b, int32_t _c )//功能:通过F3帧发送2个int16 + 1个int32
//都差不多xdm,我就一起放这里了。
{
uint8_t _cnt = 0; // 数组下标计数器(记录当前填到第几个字节)
uint8_t sumcheck = 0; // 和校验
uint8_t addcheck = 0; // 附加和校验
uint8_t i = 0; // 循环变量
// ========== 1. 填充帧头 ==========
data_to_send[_cnt++] = 0xAA; // 固定帧头
data_to_send[_cnt++] = 0xFF; // 目标地址
data_to_send[_cnt++] = 0xF1|0xF2|0xF3; // 功能码 F1,F2,F3
data_to_send[_cnt++] = 8; // 数据长度:4个uint16 = 8字节
// ========== 2. 填充4个uint16数据 ==========
// 小端模式 → 先发低字节BYTE0,再发高字节BYTE1
data_to_send[_cnt++] = BYTE0(_a);
data_to_send[_cnt++] = BYTE1(_a);
data_to_send[_cnt++] = BYTE0(_b);
data_to_send[_cnt++] = BYTE1(_b);
data_to_send[_cnt++] = BYTE0(_c);
data_to_send[_cnt++] = BYTE1(_c);
data_to_send[_cnt++] = BYTE0(_d);
data_to_send[_cnt++] = BYTE1(_d);
//这个是通过F3帧发送2个int16 + 1个int32填充数据//
// 第1个 int16
data_to_send[_cnt++] = BYTE0(_a);
data_to_send[_cnt++] = BYTE1(_a);
// 第2个 int16
data_to_send[_cnt++] = BYTE0(_b);
data_to_send[_cnt++] = BYTE1(_b);
// 第3个 int32(4字节:00000000 00000000 00000000 00000000)
data_to_send[_cnt++] = BYTE0(_c);
data_to_send[_cnt++] = BYTE1(_c);
data_to_send[_cnt++] = BYTE2(_c);
data_to_send[_cnt++] = BYTE3(_c);
// ========== 3. 计算校验位 ==========
// 校验范围:从帧头到最后一个数据
for ( i = 0; i < data_to_send[3]+4; i++)
{
sumcheck += data_to_send[i]; // 和校验
addcheck += sumcheck; // 附加校验
}
// ========== 4. 把校验位放入数组 ==========
data_to_send[_cnt++] = sumcheck;
data_to_send[_cnt++] = addcheck;
// ========== 5. 通过串口发送整帧 ==========
HAL_UART_Transmit(&huart1,data_to_send,_cnt,0xFFFF);
}
xdm,流程都差不多的。
//这是.h的宏定义和函数定义
#ifndef xxxxx_H(大写)
#define xxxxx_H
#include "main.h"
//需要发送16位,32位数据,对数据拆分,之后每次发送单个字节
//拆分过程:对变量dwTemp 去地址然后将其转化成char类型指针,最后再取出指针所指向的内容
#define BYTE0(dwTemp) (*(char *)(&dwTemp))
#define BYTE1(dwTemp) (*((char *)(&dwTemp) + 1))
#define BYTE2(dwTemp) (*((char *)(&dwTemp) + 2))
#define BYTE3(dwTemp) (*((char *)(&dwTemp) + 3))
void ANO_DT_Send_F1(uint16_t, uint16_t _b, uint16_t _c, uint16_t _d);
void ANO_DT_Send_F2(int16_t _a, int16_t _b, int16_t _c, int16_t _d);
void ANO_DT_Send_F3(int16_t _a, int16_t _b, int32_t _c );
#endif
去上位机测试一下波形
//电机速度等信息发送到上位机
//注意上位机不支持浮点数,所以要乘100
ANO_DT_Send_F2(Motor1Speed*100, 3.0*100,Motor2Speed*100,3.0*100);
9.3-P I D
终于到了pid算法。
PID是控制领域相当经典且重要的控制算法。
PID就是“比例(proportional)、积分(integral)、微分(derivative)”,是一种很常见的控制算法。它应用的范围相当之广。小到无人机,平衡车,大到工业领域的温度、液位、流量控制,都出现了PID的身影。
个人理解的PID就是一个“跟随”算法。
让被控对象,比如温度,水位,速度,高度,压力等到达我们期望的值,并且保持住。
PID在实际使用中更像一个数学的二元函数,你只要输入你想要的值,当前值,在调节好参数的情况下,无需关心内部过程,直接使用PID算出的结果值送入到执行机构即可。
PID有3个值,设定值(期望值,给定值),实际值(测量值),输出值(结果值)。
这是不是很像我们数学中经常使用的函数,y=f(x,y),输入期望值,当前值,得到结果。
PID存在非常多的变形,最基本的PID变形是增量式PID和位置式PID。还有其他的变形,如去掉PID某一部分的PD算法,PI算法,再有更高端一些的抗饱和PID,微分先行PID,自适应PID,还有模糊PID。这些PID算法万变不离其宗,只要掌握了基本PID使用规律,用什么样的PID都是一样的。

r(t):设定值(目标值 / 期望值)系统的输入,代表我们希望被控对象达到的状态(如电机目标转速、目标温度,KPI)
e(t):偏差信号e(t)=r(t)−y(t),是 PID 控制器的核心输入,代表目标与实际的差距
u(t):控制量PID 控制器的输出,用于驱动被控对象
y(t):被控量(实际值 / 反馈值)被控对象的实际输出,代表当前真实状态(如电机实际转速、实际温度)
2.三个核心环节的数学原理与作用
- 比例环节(P 环节)

核心作用:快速响应偏差,偏差越大,控制输出越大,是 PID 的 “主力”
特性:成比例放大偏差,能快速缩小误差,但无法消除静差(系统稳定后仍会存在固定偏差)
比例环节的作用是对偏差瞬间作出反应。偏差一旦产生,控制器立即产生控制作用, 使控制量向减少偏差的方向变化。 控制作用的强弱取决于比例系数Kp, 比例系数Kp越大,控制作用越强, 则过渡过程越快, 控制过程的静态偏差也就越小; 但是Kp越大,也越容易产生振荡, 破坏系统的稳定性。 故而, 比例系数Kp选择必须恰当, 才能过渡时间少, 静差小而又稳定的效果。
- 积分环节(I 环节)

作用:消除静差,对历史偏差进行累积,只要存在偏差,积分就会持续作用,直到偏差为 0
特性:滞后性强,能提升稳态精度,但会降低系统响应速度,易引发超调
从积分部分的数学表达式可以知道, 只要存在偏差, 则它的控制作用就不断的增加; 只有在偏差e(t)=0时, 它的积分才能是一个常数,控制作用才是一个不会增加的常数。 可见,积分部分可以消除系统的偏差。
积分环节的调节作用虽然会消除静态误差,但也会降低系统的响应速度,增加系统的超调量。积分常数Ti越大,积分的积累作用越弱,这时系统在过渡时不会产生振荡; 但是增大积分常数Ti会减慢静态误差的消除过程,消除偏差所需的时间也较长, 但可以减少超调量,提高系统的稳定性。
当 Ti 较小时, 则积分的作用较强,这时系统过渡时间中有可能产生振荡,不过消除偏差所需的时间较短。所以必须根据实际控制的具体要求来确定Ti 。
- 微分环节(D 环节)

作用:抑制超调、提前制动,根据偏差的变化率预判趋势,在偏差快速增大时提前施加反向控制
特性:对噪声敏感,能提升系统稳定性,但参数过大会导致系统震荡
实际的控制系统除了希望消除静态误差外,还要求加快调节过程。在偏差出现的瞬间,或在偏差变化的瞬间, 不但要对偏差量做出立即响应(比例环节的作用), 而且要根据偏差的变化趋势预先给出适当的纠正。为了实现这一作用,可在 PI 控制器的基础上加入微分环节,形成 PID 控制器。
微分环节的作用使阻止偏差的变化。它是根据偏差的变化趋势(变化速度)进行控制。偏差变化的越快,微分控制器的输出就越大,并能在偏差值变大之前进行修正。微分作用的引入, 将有助于减小超调量, 克服振荡, 使系统趋于稳定, 特别对髙阶系统非常有利, 它加快了系统的跟踪速度。但微分的作用对输入信号的噪声很敏感,对那些噪声较大的系统一般不用微分,或在微分起作用之前先对输入信号进行滤波。
将快速响应偏差,消除静差,抑制超调、提前制动的作用集合起来。
最后长这样:

4.数字PID
1.位置型PID算法:
将其离散化,利用积分与微分的定义
微分:

积分:

以T作为采样周期,表达式1可变为表达式2:

把表达式2中的参数整定后得到下面的表达式3:

其中:
k ―― 采样序号, k =0, 1, 2,……;
uk ―― 第 k 次采样时刻的计算机输出值;
e(k) ―― 第 k 次采样时刻输入的偏差值;
e(k-1) ―― 第 k -1 次采样时刻输入的偏差值;
Ki ――积分系数, Ki=Kp *T / Ti ;
Kd ――微分系数, Kd=Kp *Td / T ;
2.增量式pid:
大部分学习PID的同学最开始都是接触位置式PID,对于增量式PID的使用较少,有必要分析一下位置式PID与增量式PID的区别和适用环境:
通过之前对位置式PID的学习,我们知道主要PID的输出主要由P这个参数进行比例输出,I积分作为后面对静态误差的消除,而D是对系统速度的阻尼,防止过冲和超调的作用,可以看出位置式的PID达到稳定时需要一个误差通过P输出一个值或通过积分I来输出一个值供系统维持一个没有误差的状态(一般位置式使用在不需要一个输出值去维持一个稳定的状态的情况下,因为如果通过位置式PID要维持一个稳定的输出,它只能通过P和一个静态误差或I积分输出,这可能需要一个较大误差或积分,或加大P和I的参数值),因此一旦 PID 断开,输出直接归零,系统会瞬间失控。
对于一个需要输出一个稳定值的系统我们还可以使用增量式PID算法,那么增量式PID算法是什么呢?
我们可以理解为对一个输出量的调整,它的输出本身是一个变化值去对输出量的加减,与位置式不同,增量式不需要一个误差或误差积分去维持输出,因此当误差为0时增量式PID就输出为0了,它不去增加或减少输出量,此时系统就维持在一个没有误差但有输出的情况(例如水温控制,需要一个持续输出来控制温度),因此,增量式PID对系统控制比较稳定,即使在一个控制系统中突然去掉PID它也会维持当时的输出继续进行,而位置式一旦去掉PID那就输出为0了,对于一些控制体系比较危险.

1.P算法:
这个值的输出是与本次误差和上一次误差决定的,也就是说当我误差保持不变它就不会输出,通过xd对PID的模拟,xd发现这个值有两种情况,一种是当误差改变时它会一直往一个方向改变,就像轻轻推一个静止的物体它会一直加速运动,这显然不是我们要的。
那另一种则是它会一直维持一个误差,也就是说,当只有P算法时,它不会消除误差而是维持误差,例如一个误差为4,当你人为去改变误差为3时,那么P算法会去维持误差为4,也就是说P算法不是消除误差的而实是维持误差,与位置式不一样.但它的作用是与位置式一样,也就是增加反应,只不过它是增加维持误差的响应,也就是说当误差为2时,人为改变误差为1,而P维持误差为2的响应时间。
2.I算法:
通过公式可以看出,这个值与位置式PID的P是一样的,唯一区别在于它是一个累加的输出,也就是说在增量式PID中是通过这个值来减小误差从而靠近目标值。
由于它是累加的,所以只要存在误差就会加大输出,因此增量式PID中不会有静态误差,但由于它是累加式的靠近目标值,所以一般这个I值不会太大,还会受到P的影响,因此它响应到目标值会比较慢,从而使得增量式PID适用于慢响应的系统。
3.D算法
通过公式可以看出,这个D算法是对系统加速度的阻碍,也就是和位置式的D应该作用类似,只不过一个是对响应的速度作用,一个是对响应的加速度作用。
总结:
位置式 D:阻碍误差变化速度,抑制震荡与超调。
增量式 D:阻碍误差变化的加速度,抑制剧烈突变,让系统更柔顺稳定。
我去兄弟们扯多了,忘了现在要讲代码了,下面我带着兄弟们手撕pid代码。
1.初始化:
void PID_init()
{
pidMotor1Speed.actual_val=0.0;//刚开始电机还没转,当前速度默认为 0
pidMotor1Speed.target_val=0.00;//刚开始不要求电机转,目标速度 = 0
pidMotor1Speed.err=0.0;//当前误差 = 0,误差 = 目标 - 实际,刚开始目标 = 0,实际 = 0,误差自然是 0
pidMotor1Speed.err_last=0.0;//上一次误差 = 0,因为刚开始运行,上一次还没有计算过,所以是 0
pidMotor1Speed.err_sum=0.0;//误差累加和(积分)= 0
pidMotor1Speed.Kp=0;//Kp=0 :比例不工作
pidMotor1Speed.Ki=0;//Ki=0 :积分不工作
pidMotor1Speed.Kd=0;//Kd=0:微分不工作
}
2.比例p 控制函数,比例P积分I 控制函数,PID 控制函数
//比例p调节控制函数
float P_realize(tPid * pid,float actual_val)
{
pid->actual_val = actual_val;//传递真实值
pid->err = pid->target_val - pid->actual_val;//当前误差=目标值-真实值
// 纯P输出 = Kp * 误差
pid->actual_val = pid->Kp*pid->err;
return pid->actual_val;//返回真实值
}
//使用PI控制 输出=Kp*当前误差+Ki*误差累计值
//比例P 积分I 控制函数
float PI_realize(tPid * pid,float actual_val)
{
pid->actual_val = actual_val;//传递真实值
pid->err = pid->target_val - pid->actual_val;//当前误差=目标值-真实值
pid->err_sum += pid->err;//误差累计值 = 当前误差累计和
//使用PI控制 输出=Kp*当前误差+Ki*误差累计值
pid->actual_val = pid->Kp*pid->err + pid->Ki*pid->err_sum;
return pid->actual_val;
}
// PID控制函数
float PID_realize(tPid * pid,float actual_val)
{
pid->actual_val = actual_val;//传递真实值
pid->err = pid->target_val - pid->actual_val;////当前误差=目标值-真实值
pid->err_sum += pid->err;//误差累计值 = 当前误差累计和
//使用PID控制 输出 = Kp*当前误差 + Ki*误差累计值 + Kd*(当前误差-上次误差)
pid->actual_val = pid->Kp*pid->err + pid->Ki*pid->err_sum + pid->Kd*(pid->err - pid->err_last);
//保存上次误差: 这次误差赋值给上次误差
pid->err_last = pid->err;
return pid->actual_val;
}
3.定义一个 PID 结构体变量
创建一个 电机 1 速度控制的 PID 对象
所有误差、目标、实际值、参数都存在这里面
tPid pidMotor1Speed;
然后在pid.h中声明函数与定义:
#ifndef __PID_H
#define __PID_H
//声明一个结构体类型
typedef struct
{
float target_val;//目标值
float actual_val;//实际值
float err;//当前偏差
float err_last;//上次偏差
float err_sum;//误差累计值
float Kp,Ki,Kd;//比例,积分,微分系数
} tPid;
//声明函数
float P_realize(tPid * pid,float actual_val);
void PID_init(void);
float PI_realize(tPid * pid,float actual_val);
float PID_realize(tPid * pid,float actual_val);
#endif
然后在main中要调用PID_init()即可
理论有了,那么如何用理论解决实际呢(也就是如何控制电机速度?)
我们这里增加一个pid电机控制函数,将PWM与pid集合在一起。
void motorPidSetSpeed(float Motor1SetSpeed,float Motor2SetSpeed)
{
//改变电机PID参数的目标速度
pidMotor1Speed.target_val = Motor1SetSpeed;
pidMotor2Speed.target_val = Motor2SetSpeed;
//根据PID计算 输出作用于电机
Motor_Set(PID_realize(&pidMotor1Speed,Motor1Speed),PID_realize(&pidMotor2Speed,M
otor2Speed));
}
Motor_Set(PID_realize(&pidMotor1Speed,Motor1Speed),PID_realize(&pidMotor2Speed,Motor2Spe
ed));
比如控制一个小车:motorPidSetSpeed(1,2):1电机是左电机转速,2电机是右电机转速。(单位为每转每秒)
// motorPidSetSpeed(1,2);//向右转弯
// motorPidSetSpeed(2,1);//向左转弯
// motorPidSetSpeed(1,1);//前进
// motorPidSetSpeed(-1,-1);//后退
// motorPidSetSpeed(0,0);//停止
// motorPidSetSpeed(-1,1);//右原地旋转
// motorPidSetSpeed(1,-1);//左原地旋转
还可以实现向前加减速
//向前加速函数
void motorSpeedUp(void)
{
static float MotorSetSpeedUp=0.5;//静态变量 函数结束 变量不会销毁
if(MotorSetSpeedUp <= MAX_SPEED_UP) MotorSetSpeedUp +=0.5 ; //如果没有超过最大值就增加0.5
motorPidSetSpeed(MotorSetSpeedUp,MotorSetSpeedUp);//设置到电机
}
//向前减速函数
void motorSpeeddown(void)
{
static float MotorSetSpeeddown=3;//静态变量 函数结束 变量不会销毁
if(MotorSetSpeeddown >=0.5) MotorSetSpeeddown-=0.5;//判断是否速度太小
motorPidSetSpeed(MotorSetSpeeddown,MotorSetSpeeddown);//设置到电机
}
让OLED显示速度与总路程
这里我们只要计算出每个单位时间内小车行驶的长度然后一直相加,就是这一段时间行驶的总里程长度。
比如让我们20ms计算一次,20ms走过了多少距离,然后一直相加,就是走的总距离。这里我们可以使用
电机1进行计算;也可以电机1 和电机2相加然后除2。
if(TimerCount%10==0)//基础中断周期:2ms(TIM 中断每 2ms 触发一次,每 10 次中断(2ms×10=20ms)执行一次后续代码
{
//里程数(cm) += 时间周期(s)*车轮转速(转/s)*车轮周长(cm)
Mileage += 0.02*Motor1Speed*22;
Motor_Set(PID_realize(&pidMotor1Speed,Motor1Speed),PID_realize(&pidMotor2Speed,Motor2Speed));
TimerCount=0;
}
主函数我们通过OLED显示电机速度和小车里程
sprintf((char *)OledString,"V1:%.2f V2:%.2f", Motor1Speed,Motor2Speed);//显示两个电机的速度
OLED_ShowString(0,0,OledString,12);//这个是oled驱动里面的,(X 坐标,Y 坐标,要显示的内容(字符串 / 数组),字体大小)
sprintf((char *)OledString,"Mileage:%.2f ",Mileage);//显示里程数
OLED_ShowString(0,1,OledString,12);
10循迹功能,红外对管,灰度传感器
红外对管传感器的工作机制主要建立在红外光的发射、反射或直射接收以及信号处理的基础上。传感器的发射器和接收器同样集成在一体。发射管发出的红外光遇到被测物体后,依靠物体自身的表面将光漫反射回接收管。接收管接收到足够强度的反射光后触发信号输出。这种方式的检测距离较短,且受物体颜色、表面反光率影响较大(白色物体易反射,黑色物体易吸收);常用TCRT5000红外。
TCRT5000红外:
根据传感器特性,我们检测红外对管DO引脚的电压就可以知道,下面有黑线,DO高电平,开关灯灭,黑色是不反射红外线,当循迹模块遇到黑线,模块输出高电平D0=1,输出指示灯熄灭LED=1。
没有黑线,DO低电平,开关灯亮,红外发射器一直发射红外线,红外线经发射后被接收,此时输出低电平D0=0,输出指示灯点亮LED=0。

咋接?
| 引脚 | 功能 | 接线说明 |
|---|---|---|
| VCC | 电源正极 | 接 3.3V/5V 电源(推荐 5V 提升发射功率) |
| GND | 电源负极 | 接单片机 GND,共地 |
| DO | 数字信号输出 | 接 STM32 GPIO 引脚,用于开关量检测 |
| AO | 模拟信号输出 | 接 STM32 ADC 引脚,用于灰度 / 距离检测(可选,一般循迹可不接) |
下面我们通过单片机读取红外对管DO口的电压,就知道黑线在车下面的位置了。
1.STM32初始化
比如:OUT_1-PA1、OUT_2-PA2、OUT_3-PB0、OUT_4-PB1初始化为输入模式,假如
2.添加读取GPIO的宏
#define READ_HW_OUT_1 HAL_GPIO_ReadPin(HW_OUT_1_GPIO_Port,HW_OUT_1_Pin) //读取红外对管连接的GPIO电平
#define READ_HW_OUT_2 HAL_GPIO_ReadPin(HW_OUT_2_GPIO_Port,HW_OUT_2_Pin)
#define READ_HW_OUT_3 HAL_GPIO_ReadPin(HW_OUT_3_GPIO_Port,HW_OUT_3_Pin)
#define READ_HW_OUT_4 HAL_GPIO_ReadPin(HW_OUT_4_GPIO_Port,HW_OUT_4_Pin)
3.根据红外对管状态控制电机速度
if(READ_HW_OUT_1 == 0&&READ_HW_OUT_2 == 0&&READ_HW_OUT_3 == 0&&READ_HW_OUT_4== 0 )
{
printf("应该前进\r\n");
motorPidSetSpeed(1,1);//前运动
}
if(READ_HW_OUT_1 == 0&&READ_HW_OUT_2 == 1&&READ_HW_OUT_3 == 0&&READ_HW_OUT_4== 0 )
{
printf("应该右转\r\n");
motorPidSetSpeed(0.5,2);//右边运动
}
if(READ_HW_OUT_1 == 1&&READ_HW_OUT_2 == 0&&READ_HW_OUT_3 == 0&&READ_HW_OUT_4== 0 )
{
printf("快速右转\r\n");
motorPidSetSpeed(0.5,2.5);//快速右转
}
if(READ_HW_OUT_1 == 0&&READ_HW_OUT_2 == 0&&READ_HW_OUT_3 == 1&&READ_HW_OUT_4== 0 )
{
printf("应该左转\r\n");
motorPidSetSpeed(2,0.5);//左边运动
}
if(READ_HW_OUT_1 == 0&&READ_HW_OUT_2 == 0&&READ_HW_OUT_3 == 0&&READ_HW_OUT_4== 1 )
{
printf("快速左转\r\n");
motorPidSetSpeed(2.5,0.5);//快速左转
}
红外由于是光敏传感器易受到的环境光干扰,那么怎么办才能消除环境光干扰?
那么可以使用软件补偿的方式 :先开灯测总光,再关灯测背景光,做差值得到净反射值。
uint16_t read_reflectance(void) {
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 500); // 开启LED,用 PWM 开启,红外灯亮,发射红外光,让地面反射。
HAL_Delay(1);//等待 1ms,让红外光稳定、接收管响应稳定
uint16_t bright = ADC_ReadChannel(READ_HW_OUT_1);
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0); // 关闭LED
HAL_Delay(1);wwdd
uint16_t dark = ADC_ReadChannel(READ_HW_OUT_1);
return bright - dark; // 净反射值,大幅削弱恒定背景光影响
}
加入PID循迹
前面的代码我们对循迹功能的实现是代码直接判断几个状态,然后PID控制电机不同速度,现在可以使用红外对管状态作为PID控制的输入然后再控制电机。我们设计PID输入是红外对管的状态、然后输出一个速度值,然后左右电机去加或者减这个速度值,就可以完成根据红外对管输入对电机的差速控制。(本人“if else”终生受益者(dog),如有更好的寻线思路,请私信我)
uint8_t g_ucaHW_Read[4] = {0};//保存红外对管电平的数组
g_ucaHW_Read[0] = READ_HW_OUT_1;//读取红外对管状态
g_ucaHW_Read[1] = READ_HW_OUT_2;
g_ucaHW_Read[2] = READ_HW_OUT_3;
g_ucaHW_Read[0] = READ_HW_OUT_1;//读取红外对管状态
g_ucaHW_Read[1] = READ_HW_OUT_2;
g_ucaHW_Read[2] = READ_HW_OUT_3;
g_ucaHW_Read[3] = READ_HW_OUT_4;
if(g_ucaHW_Read[0] == 0&&g_ucaHW_Read[1] == 0&&g_ucaHW_Read[2] ==0&&g_ucaHW_Read[3] == 0 )
{
g_cThisState = 0;//前进
}
else if(g_ucaHW_Read[0] == 0&&g_ucaHW_Read[1] == 1&&g_ucaHW_Read[2] ==0&&g_ucaHW_Read[3]== 0 )
{
g_cThisState = -1;//应该右转
}
else if(g_ucaHW_Read[0] == 1&&g_ucaHW_Read[1] == 0&&g_ucaHW_Read[2] ==0&&g_ucaHW_Read[3] == 0 )
{
g_cThisState = -2;//快速右转
}
else if(g_ucaHW_Read[0] == 1&&g_ucaHW_Read[1] == 1&&g_ucaHW_Read[2] ==0&&g_ucaHW_Read[3] == 0)
{
g_cThisState = -3;//快速右转
}
else if(g_ucaHW_Read[0] == 0&&g_ucaHW_Read[1] == 0&&g_ucaHW_Read[2] ==1&&g_ucaHW_Read[3] == 0 )
{
g_cThisState = 1;//应该左转
}
else if(g_ucaHW_Read[0] == 0&&g_ucaHW_Read[1] == 0&&g_ucaHW_Read[2] ==0&&g_ucaHW_Read[3] == 1 )
{
g_cThisState = 2;//快速左转
}
else if(g_ucaHW_Read[0] == 0&&g_ucaHW_Read[1] == 0&&g_ucaHW_Read[2] ==1&&g_ucaHW_Read[3] == 1)
{
g_cThisState = 3;//快速左转
}
//动态降速
// 偏离越大 → 速度越慢
int abs_state = (g_cThisState < 0) ? -g_cThisState : g_cThisState;
if(abs_state == 0) g_fBaseSpeed = 5.0; // 中间 → 全速
else if(abs_state == 1) g_fBaseSpeed = 4.0; // 微偏 → 稍减速
else if(abs_state == 2) g_fBaseSpeed = 3.0; // 中偏 → 减速
else g_fBaseSpeed = 2.0; // 大偏 → 很慢
g_fHW_PID_Out = PID_realize(&pidHW_Tracking,g_cThisState);//PID计算输出目标速度这个速度,会和基础速度加减
g_fHW_PID_Out1 = 3 + g_fHW_PID_Out;//电机1速度=基础速度+循迹PID输出速度
g_fHW_PID_Out2 = 3 - g_fHW_PID_Out;//电机1速度=基础速度-循迹PID输出速度
if(g_fHW_PID_Out1 >5) g_fHW_PID_Out1 =5;//进行限幅 限幅速度在0-5之间
if(g_fHW_PID_Out1 <0) g_fHW_PID_Out1 =0;
if(g_fHW_PID_Out2 >5) g_fHW_PID_Out2 =5;
if(g_fHW_PID_Out2 <0) g_fHW_PID_Out2 =0;
if(g_cThisState != g_cLastState)//如何这次状态不等于上次状态、就进行改变目标速度和控制电机、在定时器中依旧定时控制电机
{
motorPidSetSpeed(g_fHW_PID_Out1,g_fHW_PID_Out2);//通过计算的速度控制电机
}
g_cLastState = g_cThisState;//保存上次红外对管状态
在pid.h中增加红外循迹的变量定义:
tPid pidHW_Tracking;//红外循迹的PID
pidHW_Tracking.actual_val=0.0;//当前偏离黑线的误差值
pidHW_Tracking.target_val=0.00;//红外循迹PID 的目标值为0
pidHW_Tracking.err=0.0;//循迹 PID 的目标永远是 0,正好压在黑线上
pidHW_Tracking.err_last=0.0;//上一次偏差
pidHW_Tracking.err_sum=0.0;//偏差累计
pidHW_Tracking.Kp=0.0;//负责快速修正偏差
pidHW_Tracking.Ki=0.0;//建议关闭积分项,避免积分累积导致的延迟与过冲,尽量再次
pidHW_Tracking.Kd=0.0;//提供阻尼作用,抑制震荡,使循迹更平稳顺滑
eg: tPid pidHW_Tracking 结构体定义如下:
typedef struct
{
float target_val;//目标值
float actual_val;//实际值
float err;//当前偏差
float err_last;//上次偏差
float err_sum;//误差累计值
float Kp,Ki,Kd;//比例,积分,微分系数
} tPid;
11.MPU6050
MPU6050模块正面上我们还可以看到上面标注了X、Y轴的坐标系 ,那个就是MPU6050自身的坐标系,如最右图所示。
以下是MPU6050的相关管脚,不过平时我们使用MPU6050时其实只需要用到VCC、GND、SCL和SDA这四个管脚。不过我们要注意一点,就是AD0管脚的作用,我们知道I2C通信中从机是要有的地址的,以区别多个从机。当AD0管脚接低电平时,从机地址是0xD0。从MPU6050的寄存器中我们可以得到答案,MPU6050作为一个I2C从机设备的时候,有8位地址,高7位的地址是固定的,就是WHO AM I 寄存器中的默认值—0x68,最低一位是由AD0的连线决定的。


| 引脚号 | 引脚名 | 接线状态 | 功能说明 |
|---|---|---|---|
| 1 | VCC | 接 5V | 电源输入,MPU6050 支持 3.3V~5V 供电,5V 可直接兼容 STM32 5V 系统 |
| 2 | GND | 接 GND | 电源地,必须与单片机共地,否则 I2C 通信异常 |
| 3 | SCL | 6050_SCL | I2C 通信时钟线,接 STM32 的 I2C_SCL 引脚 |
| 4 | SDA | 6050_SDA | I2C 通信数据线,接 STM32 的 I2C_SDA 引脚 |
| 5 | XDA | 悬空 | 辅助 I2C 主机接口,用于外接其他传感器(如磁力计),小车项目无需使用 |
| 6 | XCL | 悬空 | 辅助 I2C 时钟线,同 XDA,小车项目无需使用 |
| 7 | ADO | 悬空 | I2C 地址选择引脚:・悬空 / 接 GND → 地址 0x68(默认)・接 VCC → 地址 0x69单传感器使用默认 0x68 即可 |
| 8 | INT | 6050_INT | 中断输出引脚,可接 STM32 外部中断引脚,用于数据就绪触发 |
11.2 I2c通信协议(感谢CSDN博主「不脱发的程序猿」)
I2C(集成电路总线),由Philips公司(2006年迁移到NXP)在1980年代初开发的一种简单、双线双向的同步串行总线,它利用一根时钟线和一根数据线在连接总线的两个器件之间进行信息的传递,为设备之间数据交换提供了一种简单高效的方法。每个连接到总线上的器件都有唯一的地址,任何器件既可以作为主机也可以作为从机,但同一时刻只允许有一个主机。

I2C 标准是一个具有冲突检测机制和仲裁机制的真正意义上的多主机总线,它能在多个主机同时请求控制总线时利用仲裁机制避免数据冲突并保护数据。
只需要SDA、SCL两条总线;没有严格的波特率要求;所有组件之间都存在简单的主/从关系,连接到总线的每个设备均可通过唯一地址进行软件寻址;I2C是真正的多主设备总线,可提供仲裁和冲突检测;
传输速度分为四种模式:
标准模式(Standard Mode):100 Kbps
快速模式(Fast Mode):400 Kbps
高速模式(High speed mode):3.4 Mbps
超快速模式(Ultra fast mode):5 Mbps
通讯特性:
通常情况下,一个完整的I2C通信过程包括以下 4 部分
开始条件
地址传送
数据传送
停止条件
当总线上的主机都不驱动总线,总线进入空闲状态, SCL 和 SDA 都为高电平。总线空闲状态下总线上设备都可以通过发送开始条件启动通信。
当 SCL 线为高时,SDA 线上出现由高到低的信号,表明总线上产生了起始信号。 SDA 线上出现由低到高的信号,表明总线上产生了停止信号,如下图所示:
当两个起始信号之间没有停止信号时,即产生了重复起始信号。主机采用这种方法与另一个从机或相同的从机以不同传输方向进行通信(例如:从写入设备到从设备读出)而不释放总线。如下图所示:
开始条件或者重新开始条件后面的帧是地址帧(一个字节),用于指定主机通信的对象地址,在发送停止条件之前,指定的从机一直有效。
I2C通讯支持:7 位寻址和10 位寻址两种模式。
7 位寻址模式,地址帧(8bit)的高 7 位为从机地址,地址帧第 8 位来决定数据帧传送的方向:7 位从机地址 + 1位 读/写位,读/写位控制从机的数据传输方向(0:写; 1:读) 。帧格式如下所示:

10 位寻址模式,主机发送帧,第一帧 发送头序列(11110XX0,其中 XX 表示 10 位地址的高 两位),然后第二帧发送低八位从机地址。 主机接收帧 ,第一帧发送头序列(11110XX0,其中 XX 表示 10 位地址的高两位),然后第二帧发送低八位从机地址。接下来会发送一个重新开始条件,然后再发送一帧头序列(11110XX1 ,其中 XX 表示 10 位地址的高两位)帧格式如下所示:

解析如下:
S :表示开始条件;
SLA :表示从机地址;
R/W#:表示发送和接收的方向。当 R/W# 为“1” 时,将数据从从机发送到主机;当R/W#为“0” 时,将数据从主机发送到从机;
Sr :表示重新开始条件;
DATA :表示发送和接收的数据;
P :表示停止条件。
通信时序介绍:
1.起始条件
起始条件总是在传输开始时出现,并由主器件发起。这样做是为了唤醒总线上的空闲节点器件。SDA线从高电平切换到低电平,然后SCL线从高电平切换到低电平。


2.重复起始条件
在不发出停止条件的情况下,起始条件可以在传输期间重复。这是一种特殊情况,称为重复起始,用于改变数据读、写传输方向、重复尝试传输、同步多个IC,甚至控制串行存储器等。如下图所示:

3.地址帧
地址帧包含7位或10位序列,具体取决于可用性(参见数据手册)。如下图所示:

通过寻址来实现。地址帧始终是新消息中起始位之后的第一帧,主器件将其想要与之通信的节点地址发送到其所连接的每个节点。然后,每个节点将主器件所发送的地址与其自己的地址进行比较。如果地址匹配,它便向主器件发送一个低电压ACK位。如果地址不匹配,则节点什么也不做,SDA线保持高电平。
4.读⁄写位
地址帧的最后一位告知节点,主器件是想要将数据写入其中还是从中接收数据。如果主器件希望将数据发送到节点,则读⁄写位处于低电平。如果主器件请求从节点得到数据,则该位处于高电平。如下图所示:

5.ACK⁄NACK位
消息中的每一帧后面都跟随一个应答⁄不应答位。如果成功接收到一个地址帧或数据帧,则从机会向主机返回一个ACK位。如下图所示:

6.数据帧
主器件检测到来自从节点的ACK位之后,就准备发送第一数据帧。数据帧总是8位长,并以MSB优先方式发送,MPU6050 就是 高位在前 (MSB First)。每个数据帧之后紧接着一个ACK⁄NACK位,以验证该帧是否已成功接收。主器件或节点(取决于谁发送数据)必须收到ACK位,然后才能发送下一数据帧。时序和协议如下图所示:

7.停止条件
发送完所有数据帧之后,主器件可以向节点发送停止条件以停止传输。停止条件是指SCL线上的电压从低电平变为高电平,然后在SCL线保持高电平的情况下,SDA线上的电压从低电平变为高电平。
最小字 小字号 默认字号 稍大 大 更大 最大



浙公网安备 33010602011771号