简介:一份面向STM32开发者的绝对值编码器应用实例资源,专注解决电机控制与位置反馈中的编码器数据读取与解析问题,适合正在学习STM32 HAL库、需要快速上手绝对值编码器通信协议的嵌入式工程师。压缩包共78个文件,以35个h头文件与33个c源文件为主,配合工程配置文件、Hex固件、Keil工程及启动文件等,完整覆盖从硬件驱动到上层应用的组织结构。包体大小仅323KB,轻量便于直接导入工程参考。目前已有2087人学习下载,适合作为入门到进阶的参考案例。内容涵盖基于STM32F10x的TIM3、LED、绝对值编码器模块,并包含HAL库相关配置与中断处理逻辑,读者可从中学习SSI等数字接口协议解析、位置计算以及实时性优化思路,结合自身项目快速修改复用。
1. 绝对值编码器与 STM32:先把 SSI 读取链路走通再看代码
增量编码器有个老毛病:断电前必须把脉冲数记好,否则重启就丢位置。绝对值编码器不走这条路,它的位置是串行读出来的,上电后读一帧就是完整位置,中间不用记数,这就是为什么它适合做伺服电机定位、机械臂关节反馈这类掉电不能丢坐标的场景。这个压缩包给的是一个在 STM32F103 上跑通的例子,核心链路很清楚:TIM3 输出 SSI 同步时钟,外部设备按节拍把数据位送到 GPIO,中断里逐位拼接成原始帧,再由软件完成格雷码转二进制,最终通过串口把 16 位位置值打印出来。适合刚接手绝对值编码器、想搞明白协议和代码怎么对应的工程师,也适合毕业设计里要做电机位置反馈的学生。下面按「协议 → 时钟 → 采集 → 改参数 → 排错」往下拆。
2. 硬件接线与时钟方案:为什么用 TIM3 产生 SSI 时钟而不是 GPIO 翻转
2.1 SSI 帧的时序到底长什么样
SSI(Synchronous Serial Interface)是绝对值编码器最常见的串行接口之一,主机负责发时钟,从机(编码器)在时钟沿上把数据位吐出来。一次完整的读取过程大致是:先拉低片选信号,编码器把最高位放到 DATA 线上,主机给出第一个时钟上升沿,随后每个下降沿切换下一位,直到帧结束。空闲状态下 DATA 通常保持高电平,CLK 停在某个固定电平,具体以编码器手册为准。
接线不算复杂,最少三根线:CLK、DATA、GND。有些编码器还有报警输出或方向选择引脚,但核心读取链路就这三根。帧长一般是「分辨率位数 + 1」,多出来的 1 位通常是状态位或校验位,比如 12 位单圈编码器一帧就是 13 个时钟周期。读取顺序固定从 MSB 开始,先收到的位是最高位。下表是 12 位单圈编码器在 SSI 模式下的典型参数,用来对照配置:
| 参数 | 典型值 | 说明 |
|---|---|---|
| CLK 频率 | 100 kHz ~ 500 kHz | 视编码器手册上限而定,不要超 |
| 帧长 | 13 bit | 12 位数据 + 1 位状态位 |
| 帧间间隔 | ≥ 25 μs | 主机停止时钟后需要短暂恢复期 |
| 数据格式 | 二进制或格雷码 | 多数绝对值编码器输出格雷码,需软件转换 |
这里有个新人容易踩的坑:看到串行接口就想着用 SPI 硬件去读。SPI 确实能发时钟也能收数据,但 SSI 的帧长往往不是 8/16 位这种规整长度,加上空闲电平、停止相位和 SPI 协议并不完全一致,调试起来反而麻烦。这个工程选择用定时器 PWM 产生时钟,是很务实的方案。
2.2 TIM3 输出 SSI 时钟的配置方法
用定时器产生 SSI 时钟的思路是:把某个定时器的通道配置成 PWM 输出,频率设置为编码器能接受的 CLK 频率,占空比 50%。这样波形稳定,频率由硬件保证,不存在软件翻转 GPIO 带来的抖动问题。以工程里 STM32F103 标准外设库为例,PB0 复用为 TIM3_CH3,初始化代码如下:
void TIM3_SSI_Clock_Init(void) { GPIO_InitTypeDef GPIO_InitStruct; TIM_TimeBaseInitTypeDef TIM_TimeBaseStruct; TIM_OCInitTypeDef TIM_OCInitStruct; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // CLK 输出引脚 PB0,复用推挽输出 GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStruct); // 定时器时基:72MHz / 36 = 2MHz,计数到 2 就是 1MHz TIM_TimeBaseStruct.TIM_Prescaler = 36 - 1; TIM_TimeBaseStruct.TIM_Period = 2 - 1; TIM_TimeBaseStruct.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStruct.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStruct); // PWM1 模式,占空比 50% TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStruct.TIM_Pulse = 1; TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC3Init(TIM3, &TIM_OCInitStruct); // PB0 对应 TIM3_CH3 TIM_Cmd(TIM3, DISABLE); // 先不启动,读取时再开 }这里两个关键参数是TIM_Prescaler和TIM_Period。系统时钟 72 MHz,分频 36 后定时器计数频率为 2 MHz,再数 2 个周期翻转一次电平,输出就是 1 MHz 方波。实际使用时建议把频率降到 250 kHz 左右,一是很多编码器手册标称最高 500 kHz,留余量更稳;二是后续要在定时器中断里逐位读数据,频率越低 CPU 负担越小。最后TIM_Cmd(TIM3, DISABLE)是因为 SSI 时钟不需要持续输出,只在读帧期间打开。
2.3 中断采样方案的比较与选择
时钟有了,数据怎么读?常见方案有三种:GPIO 查询、外部中断、定时器更新中断。GPIO 查询实现最简单,主循环里翻转 CLK 再读 DATA,但主循环里一旦有其他任务耗时,时钟沿就会抖动,位置值容易错位。外部中断接 DATA 线也不理想,因为 SSI 的数据是在时钟沿上变化的,外部中断捕捉到的是数据跳变,时序上不好对齐帧起始位置。
这个工程采用的做法是定时器更新中断:TIM3 每溢出一次,说明 PWM 走完一个周期,此时在中断服务函数里读一次 DATA 引脚,正好对应一个时钟周期采一位。这样采集节奏和 CLK 完全同步,代码结构也直观。三种方案对比:
| 方案 | 时序稳定性 | CPU 开销 | 适合场景 |
|---|---|---|---|
| GPIO 翻转 + 查询 | 差,受主循环干扰 | 低 | 演示、低速调试 |
| 外部中断捕捉 DATA | 中,对齐困难 | 中 | 脉冲量编码器,不太适合 SSI |
| TIM 更新中断采样 | 好,与 CLK 同步 | 中高 | SSI 串行读取的标准做法 |
定时器中断频率等于 CLK 频率。如果 CLK 设 1 MHz,中断就是每秒 100 万次,在 72 MHz 主频下负担偏重;降到 250 kHz 后每 4 μs 一次中断,配合帧长 13 bit 算下来,一帧数据采集期间中断次数很少,CPU 占用完全可接受。这也是我建议把 CLK 配置在 100~500 kHz 区间的原因。
3. ABSENCODER 模块拆读:数据帧捕获、格雷码转换与主循环输出
3.1 工程目录结构与代码路径
拿到压缩包先别急着编译,把目录结构理一遍,很多信息在文件名里就有了。工程的核心组织如下:
Demo-c/ ├─ HARDWARE/ │ ├─ ABSENCODER/ # 编码器协议解析主体 │ └─ TIM3/ # 定时器时钟配置 ├─ USER/ │ ├─ main.c # 主流程 │ └─ stm32f10x_it.c # 中断服务函数 ├─ SYSTEM/ │ ├─ delay/ │ ├─ sys/ │ └─ usart1/ # 串口输出位置值 ├─ CORE/ # 内核相关与启动文件 ├─ STM32F10X_FWLIB/ # 标准外设库 └─ OBJ/ └─ Demo-c.hex # 编译产物HARDWARE 目录把硬件驱动按外设拆开,编码器读取放在 ABSENCODER 里,时钟产生放在 TIM3 里,职责是分开的。USER 目录下的stm32f10x_it.c保存中断服务函数,采集逻辑应该在这里入口。SYSTEM 里的 usart1 负责把位置数据打出来,调试时很关键。整体是典型的 STM32F103 标准库工程结构,用 Keil 打开Demo-c.uvproj直接编译即可。这套源码是标准 C 写的,如果拿到 C++ 工程里用,头文件加extern "C"包一层就能编译。
3.2 在定时器中断里逐位采集数据帧
采集是逐位进行的。每次 TIM3 更新中断到来,读取一下 DATA 引脚电平,把它拼到原始帧变量的末尾,同时记录已接收位数。当位数达到预设帧长,说明一帧读完了,关闭时钟,置完成标志。核心逻辑如下:
#define ABS_SSI_FRAME_BITS 16 // 一帧总位数:分辨率 + 状态位 volatile uint16_t raw_frame = 0; // 原始数据帧,逐位左移拼接 volatile uint8_t bit_cnt = 0; // 已接收位数 volatile uint8_t frame_done = 0; // 一帧是否采集完成 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if (!frame_done) { // 左移一位,把当前 DATA 电平拼到最低位 raw_frame = (raw_frame << 1) | (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1) ? 1 : 0); bit_cnt++; if (bit_cnt >= ABS_SSI_FRAME_BITS) { frame_done = 1; // 通知主循环处理 TIM_Cmd(TIM3, DISABLE); // 停止输出时钟 bit_cnt = 0; // 复位位计数 } } } }这里raw_frame每中断一次左移一位,再按位或上 DATA 电平,采够 16 位后停止时钟。GPIO_ReadInputDataBit读取的是引脚实时电平,不受之前输出配置影响。frame_done是主循环和中断之间的握手信号,主循环发现它置 1 后就解析数据、启动下一次采集。这种逐位左移的写法,恰好符合 SSI 先收 MSB 的顺序,不需要额外做位序反转。
关于 DATA 引脚接哪个 GPIO,工程里存在多种可能性,示例中用 PA1 是为了和代码对应。实际接线时只要是普通输入引脚都能用,但要确认该引脚没有其他复用功能冲突。
3.3 格雷码转二进制:逐位异或的直觉理解
绝对值编码器内部很多使用格雷码输出,原因是格雷码相邻两个数值只有一位变化,抗干扰能力强,不会出现二进制那种多位同时跳变导致读错的情况。但格雷码不能直接用于计算,要用换算公式转成二进制。转换算法是逐位异或,代码极短:
uint16_t GrayToBinary(uint16_t gray) { uint16_t bin = gray; while (gray >>= 1) { bin ^= gray; } return bin; }理解这段代码的关键在于:二进制的高位和格雷码高位相同,从高位往下,每一位二进制值等于当前位格雷码与上一位二进制值异或。while循环里不断把 gray 右移并异或到 bin 上,实际上就是从高位到低位逐位完成这个递推。12 位以内的编码器用这个函数毫无压力,如果分辨率超过 16 位,把类型换成uint32_t即可。对于位数较少的编码器,也可以用查表法,提前把 256 个 8 位映射存好,速度更快但代码量略大,看具体场景取舍。
3.4 主循环轮询与串口输出
中断负责采集,主循环负责消费数据。最简单可靠的模式是轮询标志位,frame_done置 1 后立即处理,处理完马上启动下一帧读取,保证位置更新能跟上编码器转速。示例:
while (1) { if (frame_done) { uint16_t binary = GrayToBinary(raw_frame); printf("pos=%d\r\n", binary); frame_done = 0; TIM_SetCounter(TIM3, 0); // 计数器清零 TIM_Cmd(TIM3, ENABLE); // 开始下一帧采集 } }这里printf通过 usart1 重定向到串口,调试时可以直接在 PC 上观察位置变化。TIM_SetCounter(TIM3, 0)在启动前清零计数器,确保新一帧的时钟从完整周期开始。主循环里不要放延时和阻塞操作,否则会拖慢帧启动节奏,一旦编码器转速较高,位置更新就会滞后。另外值得提醒一句:中断里读 GPIO 要保证 IO 时钟已经使能,否则读回来的一直是 0。
4. 改分辨率、圈数与零点:从 demo 变成可用的位置反馈模块
4.1 改分辨率时,代码里有两处要同步调整
开发板上跑通 demo 只是第一步,真到自己项目里,第一件事通常就是换编码器分辨率。12 位、17 位、25 位编码器的读取参数完全不同,必须同步修改两处:帧长位宽和变量类型。帧长等于分辨率加状态位,分辨率变了,ABS_SSI_FRAME_BITS跟着变。变量类型也要看位数是否超过 16:
| 编码器分辨率 | 帧长(含状态位) | raw_frame 类型 | 建议 CLK 频率 |
|---|---|---|---|
| 12 位单圈 | 13 | uint16_t | 250 kHz |
| 17 位单圈 | 18 | uint32_t | 250 kHz |
| 25 位多圈 | 26 | uint32_t | 500 kHz 以内 |
修改示例:如果换用 17 位编码器,把宏改为ABS_SSI_FRAME_BITS 18,同时把raw_frame声明为volatile uint32_t。位置值换算时,用实际分辨率计算满量程,比如 17 位对应1 << 17 = 131072,转成角度就是position * 360.0 / 131072.0。这一步最容易出错,很多人改完分辨率发现角度跳变不对,检查一下类型和移位运算就能发现问题。
4.2 单圈与多圈的数据拼接
多圈绝对值编码器输出的是「圈数 + 单圈位置」的组合帧,本质上是把两组二进制数拼在一起。比如 25 位多圈编码器,高 12 位是圈数,低 13 位是单圈位置,读取后的处理逻辑是拆开再合并:
#define TURN_BITS 12 // 圈数位宽 #define SINGLE_TURN_BITS 13 // 单圈位宽 int32_t total_position = 0; // 假设 raw_frame 已转换为二进制 uint16_t turn = raw_frame >> SINGLE_TURN_BITS; // 高 12 位 uint16_t single = raw_frame & ((1U << SINGLE_TURN_BITS) - 1); // 低 13 位 total_position = ((int32_t)turn << SINGLE_TURN_BITS) | single;注意圈数可以是带符号的,正转加圈、反转减圈,具体取决于编码器手册。拼接后拿total_position做后续控制,才能保证多圈场景下位置单调累加。
4.3 方向取反与掉电保存零点
编码器安装方向和生产校准总会带来两个问题:旋转方向和坐标零点。方向取反最简单的办法是在读出位置后做一次镜像换算:final = max_value - raw,这样正转变反转,逻辑清晰且不影响原始帧。零点校准稍微麻烦一点,需要把当前位置设置为 0,然后把偏差值保存下来,掉电不丢。常见做法是存到 STM32 内部 Flash 的最后一个页,代码示意:
uint32_t zero_offset = 0; // 任意时刻执行:把当前位置设为零点 zero_offset = current_raw; // 写入内部 Flash(以 STM32F103 为例) FLASH_Unlock(); FLASH_ErasePage(ADDR_FLASH_PAGE_127); FLASH_ProgramWord(ADDR_FLASH_PAGE_127, zero_offset); FLASH_Lock();上电时先读出这个偏移,再用position = raw - zero_offset做校正。这个 demo 本身不带 Flash 存储逻辑,需要自己加上,但方法和代码都很成熟。需要注意校准动作必须做滤波,连续读几次数值一致再写 Flash,避免在干扰瞬间把错误位置存成零点。
4.4 读数容错的常规做法
SSI 读取在工业现场容易受到干扰,尤其是时钟和数据线走线较长时。几个常规手段:一是帧校验,多出的状态位可以用来检测编码器内部错误,不为预期值时丢弃这一帧;二是连续性校验,连续两帧读数完全一致才更新位置,不一致就计数并丢弃;三是超时保护,启动采集后如果迟迟收不满一帧,要强制停止时钟并复位状态,防止卡在读取流程里。实现时加一个简单的看门狗计数即可,收到一帧清零,长时间没有完整帧就重新初始化采集状态机。这些细节在 demo 里未必齐全,但上了设备之后就是保命功能。
5. 调试与验证:示波器看 CLK/DATA,KEIL 仿真看变量,ST-Link 排错
5.1 先用逻辑分析仪确认波形再怀疑代码
代码跑不通时,先看波形再查代码,这是定位问题最快的方式。用逻辑分析仪同时抓 PB0(CLK)和 DATA 引脚,触发条件设为 CLK 下降沿。正确波形应该是:启动采集后出现一串等宽脉冲,每个周期数据线稳定地变成高或低电平。如果根本没有时钟,检查 TIM3 是否使能、PWM 输出配置是否正确;如果有时钟但数据线毫无变化,大概率是 DATA 引脚接错或编码器没上电。CLK 频率明显异常时,用示波器量一下实际频率,对照 72 MHz 主频重新算分频和周期参数,TIM_Prescaler写错会导致频率差很多倍。
5.2 串口输出位置值的判断方法
串口打印是第二个验证手段。烧录后打开串口助手,波特率要和usart1初始化一致,常见是 115200。手动缓慢转动编码器,观察打印值。位置值应该连续变化,正转递增、反转递减。出现乱码查波特率和系统时钟倍频配置;出现全 0 检查 DATA 引脚电平和接线;出现固定的大数来回跳,多半是格雷码转换没做或分辨率位数不匹配。调试期间建议把raw_frame和转换后的二进制一起打印出来,便于对照编码器手册核实每一帧数据是否和实际位置对应。
5.3 SWD 下载报错的三个高频原因
不少人在这个阶段遇到 Keil 报error: no stm32 target found,第一反应是烧录器坏了,实际上常见原因有三个:目标板没有独立供电或供电电压不够,ST-Link 的 SWDIO/SWCLK 接线松动,以及目标板处于复位状态导致无法连接。排查顺序是先看 ST-Link 指示灯,再用 stm32 st-link utility 做一次连接测试,能连上就说明硬件链路没问题,回 Keil 检查调试器设置就行。另外装完驱动后如果设备管理器里出现感叹号,通常是驱动版本问题,用 Zadig 重装或手动指定驱动目录即可解决。还有一种容易被忽略的情况:项目里某个引脚初始化干扰了 SWD 口,比如 PA13/PA14 被复用,连不上时查一下工程里有没有对这两个引脚的 GPIO 配置。
5.4 用 KEIL 仿真器内部逻辑分析仪观察变量
没有示波器时,KEIL 自带的逻辑分析仪也能救急。进入仿真模式后,在 Peripherals 菜单打开 Logic Analyzer,把TIM3_IRQHandler里的bit_cnt和 DATA 引脚电平添加进去,设置合适的显示比例,运行后能看到中断触发的节奏。bit_cnt从 0 递增到帧长后复位的波形,能直观反映采集流程是否走通。再用 Watch 窗口观察raw_frame,转动编码器时数值随位置变化而变,就说明整条链路没有问题。DEBUG 模式下把优化等级调到最低,避免变量被优化掉导致 Watch 里看不到值。把这几个检查做下来,位置数据可信了,再去做控制环路才踏实。
本文还有配套的精品资源,点击获取