一、思路分析
1、在没有实体编码器的情况下,要测试 "定时器编码器模式 + 速度计算" 的逻辑,核心是用 STM32 的 GPIO 模拟编码器的 A/B 相脉冲信号,让定时器的编码器模式 "误以为" 有真实编码器在转动,从而完成速度计算的测试。
2、核心原理:定时器的编码器模式是通过检测A/B 两相脉冲的相位差(超前 / 滞后)来判断方向、计数脉冲。因此,我们可以用两个 GPIO 引脚(模拟 A/B 相) 按固定时序翻转电平,模拟编码器的正转 / 反转脉冲,输入到定时器的编码器通道引脚,触发定时器计数。
二、STM32CubeMX的配置
1、RCC的配置
打开STM32CubeMX,新建工程,选择STM32,打开;
找到RCC;选择时钟源;

| 时钟源 | 选择的类型 | 作用 |
|---|---|---|
| HSE | Crystal/Ceramic Resonator | 高速外部时钟(High Speed External),选择 "晶振 / 陶瓷谐振器" 作为时钟源 |
| LSE | Crystal/Ceramic Resonator | 低速外部时钟(Low Speed External),同样选择晶振 / 陶瓷谐振器作为时钟源 |
然后在点击顶部Clock Configuration;
HCLK设为 72MHz(STM32F103 最大主频),配置如下:

2、SYS的配置
Debug(调试模式)选择:Serial Wire;
这是 ARM Cortex-M 系列芯片常用的调试协议,仅需 2 根引脚(SWDIO、SWCLK)即可实现调试、下载程序,是嵌入式开发中最常用的调试方式之一。

3、TIM2的配置**(测速定时中断,0.1 秒触发一次)**
用于固定周期计算速度:左侧Timers → 点击TIM2;
Mode Configuration:Mode:选择Internal Clock(内部时钟);
Parameter Settings(计算:72MHz 主频→定时 0.1 秒):
其他参数默认;
NVIC Settings:优先级:与 TIM3 一致或略低(如Preemption Priority=1)。
勾选TIM2 global interrupt;Counter Mode: Up;
Counter Period (ARR):设为999(定时周期:(999+1)/10kHz=0.1 秒);
Prescaler (PSC):设为7199(分频后时钟:72MHz/(7199+1)=10kHz);


4、TIM3的配置(编码器模式)
这是核心,用于 "接收" 模拟编码器的 A/B 相脉冲:
Mode:选择Encoder Mode(编码器模式);
Encoder Mode:选择Encoder Mode TI1 and TI2(同时检测 A/B 两相);
Counter Mode:默认Up/Down(支持正反转计数);
Channel 1/2:默认Input Capture direct mode(无需修改);
Prescaler (PSC):设为0(不分频,保证计数精度);
Counter Period (ARR):设为65535(16 位最大值,减少溢出频率);
最后勾选NVIC;

5、TIM4的配置(模拟编码器脉冲,1ms 触发一次)
用于定时翻转 GPIO 电平,生成模拟 A/B 相脉冲:
Mode:选择Internal Clock;
Prescaler (PSC):设为71(分频后时钟:72MHz/(71+1)=1MHz);
Counter Period (ARR):设为999(定时周期:(999+1)/1MHz=1ms);
Counter Mode:Up;
其他参数默认;
最后开启NVIC;


6、配置USART1(USB转串口)
Asynchronous:异步模式,是串口通信最常用的模式(无需时钟同步,仅通过 RX/TX 引脚通信);
| 参数项 | 当前值 | 作用 |
|---|---|---|
| Baud Rate | 115200 Bits/s | 波特率,即串口通信的速率,115200 是嵌入式开发中常用的高速波特率之一 |
| Word Length | 8 Bits (including Parity) | 数据位长度,此处为 8 位(含校验位),通常搭配 "无校验" 时实际为 8 位数据位 |
| Parity | None | 无校验位,简化通信流程(对可靠性要求高的场景会选择奇 / 偶校验) |
| Stop Bits | 1 | 1 位停止位,是串口通信的标准配置 |
| 参数项 | 当前值 | 说明 |
|---|---|---|
| Data Direction | Receive and Transmit | 全双工模式,支持同时接收和发送数据 |
| Over Sampling | 16 Samples | 16 倍过采样,提升串口通信的抗干扰能力(是默认且常用的配置) |

7、配置模拟编码器的 GPIO(PB0、PB1)
用于输出模拟 A/B 相脉冲,需连接到 TIM3 的编码器输入引脚(PA6/TIM3_CH1、PA7/TIM3_CH2):

然后将PB0和PB1配置为Output Push Pull(推挽输出)模式;


8、保存,选择MDK,生成





三、连接STM32,STLink,USB串口
STLink对应连接即可;

单片机连接USB串口是要与之相反【一方的发送端(TX)需要连接到另一方的接收端(RX)】。
因此,为了让两者能正常通信:
单片机的发送端(A9) 要连到 USB 串口的接收端(RXD)(单片机发的数据,USB 串口能收到);
单片机的接收端(A10) 要连到 USB 串口的发送端(TXD)(USB 串口发的数据,单片机能收到)。
单片机的A10接USB串口的TXD;
单片机的A9接USB串口的RXD;
然后GND接GND;
A6、A7引脚与B0、B1引脚进行短接;

四、代码实现
1、明确代码保护区间
(1)多区间的核心设计逻辑
CubeMX 会根据代码的功能位置划分不同编号 / 命名的 USER CODE 区间,目的是让你把不同用途的自定义代码,精准放在对应的位置,既不破坏自动生成的代码结构,又能保证逻辑的正确性。
(2)常见的多区间类型(以 main.c 为例)
| 区间标记 | 位置 | 用途(该放什么代码) |
|---|---|---|
USER CODE BEGIN 1 |
main 函数顶部(头文件后) | 定义全局变量、自定义函数声明、宏定义等 |
USER CODE BEGIN 2 |
外设初始化后、主循环前 | 初始化类代码(如串口发送初始提示、传感器初始化) |
USER CODE BEGIN WHILE |
主循环while(1)内部 |
主循环的核心业务逻辑(如定时采集、数据处理) |
USER CODE BEGIN 3 |
主循环while(1)结束后 |
极少用,一般留空(主循环是死循环,不会执行到) |
USER CODE BEGIN 4 |
main 函数末尾 | 自定义函数的实现(若不想单独建文件可放这) |
(3)另外的代码保护区间
/* USER CODE BEGIN PV */:仅定义文件内的私有全局变量;
/* USER CODE BEGIN PM*/: 定义仅当前文件使用的宏;
/* USER CODE BEGIN PD */: 声明仅当前文件使用的函数、自定义数据类型(结构体 / 枚举);
/* USER CODE BEGIN PTD */: 定义仅当前文件使用的结构体、枚举、类型别名(typedef);
/* USER CODE BEGIN PFP */仅当前文件(如main.c、usart.c)内使用的自定义函数的实现代码;
| 标记 | 核心用途 | 作用域 | 记忆技巧 |
|---|---|---|---|
| PM | 私有宏定义 | 当前文件 | M = Macros(宏) |
| PD | 私有函数 / 类型声明 | 当前文件 | D = Declarations(声明) |
| PTD | 私有自定义类型(结构体 / 枚举) | 当前文件 | TD = Typedefs(类型定义) |
| PV | 私有变量(之前讲过) | 当前文件 | V = Variables(变量) |
2、代码写入
(1)在/* USER CODE BEGIN PV */中写入:
正转时,A、B 相的电平会按{1,0} → {1,1} → {0,1} → {0,0}循环变化;
反转时,电平变化顺序则相反(如{1,0} → {0,0} → {0,1} → {1,1})。
cpp
int n = 0;
// 模拟编码器的状态与时序
uint8_t encoder_state = 0;
const uint8_t forward_seq[4][2] = {{1,0}, {1,1}, {0,1}, {0,0}}; // 正转时序
(2)在/* USER CODE BEGIN PFP */中写入:
判断正旋,反旋,计算转速;
cpp
//记录溢出数量
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
if (htim->Instance == TIM3) {
// 获取编码器旋转方向:0=正旋(上溢),1=反旋(下溢)
uint32_t direction = __HAL_TIM_DIRECTION_STATUS(htim);
if (direction == 0) { // 根据方向更新溢出计数n
n++; // 正旋/上溢,n+1
} else {
n--; // 反旋/下溢,n-1
}
if (direction == 0) {
printf("ARR direction = %d Forward\r\n", direction);
printf("n = %d\r\n", n); }
else {
printf("ARR direction = %d Reverse\r\n", direction);
printf("n = %d\r\n", n); }
}
// 新增:TIM4触发模拟编码器脉冲
else if (htim->Instance == TIM4) {
// 按正转时序翻转PB0(A相)、PB1(B相)
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, forward_seq[encoder_state][0]);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, forward_seq[encoder_state][1]);
encoder_state = (encoder_state + 1) % 4;
}
else if(htim->Instance == TIM2){
//printf("TIM2\r\n");
uint16_t x = TIM3->CNT;
uint16_t total = 0;
//判断正反
if(n >= 0){
total = x + n*10;
}else{
total = (10-x)-(n+1)*10;
}
printf("speed%d = %lf\r\n",n>=0?1:-1,total*1.0/0.1);
TIM3->CNT = 0;
n = 0;
}
}
(3)在 /* USER CODE BEGIN 2 */中开启定时器
cpp
//要手动始能中断,是否需要手动始能,看手册中的中断控制寄存器的初始化的值
__HAL_TIM_ENABLE_IT(&htim3, TIM_IT_UPDATE);
//以中断方式开启定时器
HAL_TIM_Encoder_Start_IT(&htim3, TIM_CHANNEL_ALL);
// 启动TIM2/TIM4定时中断
HAL_TIM_Base_Start_IT(&htim2);
HAL_TIM_Base_Start_IT(&htim4);
(4)在 while (1)循环里写入模拟信号
cpp
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);
HAL_Delay(10); // 控制脉冲频率(数值越小,计数越快,越易溢出)
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET); // 模拟B相(PA9)高电平(滞后A相,形成90°相位差)
HAL_Delay(10);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // 模拟A相低电平
HAL_Delay(10);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET); // 模拟B相低电平
HAL_Delay(10);
(5)最后在/* USER CODE BEGIN 4 */中重定向**fputc**
原本fputc会将字符输出到终端(如电脑控制台);
重定义后,字符会通过HAL_UART_Transmit函数发送到 USART1 串口,实现 "用printf打印数据到串口" 的功能。
cpp
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
return ch;
}
五、进行烧录测试
完成代码编写以后,编译一下,然后利用STLink烧录到STM32;
接着打开串口助手;

打开串口,按下单片机上面的复位按键;
B1接A7;
B0接A6;


n=0 出现时对应的方向是 Forward,这是因为 TIM2 中断会重置 n=0;
属于代码中 "重置计数" 的预期行为,并非异常。
