STM32实战:SSI接口读取绝对值编码器的原理与实现
简介一份面向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 帧的时序到底长什么样SSISynchronous Serial Interface是绝对值编码器最常见的串行接口之一主机负责发时钟从机编码器在时钟沿上把数据位吐出来。一次完整的读取过程大致是先拉低片选信号编码器把最高位放到 DATA 线上主机给出第一个时钟上升沿随后每个下降沿切换下一位直到帧结束。空闲状态下 DATA 通常保持高电平CLK 停在某个固定电平具体以编码器手册为准。接线不算复杂最少三根线CLK、DATA、GND。有些编码器还有报警输出或方向选择引脚但核心读取链路就这三根。帧长一般是「分辨率位数 1」多出来的 1 位通常是状态位或校验位比如 12 位单圈编码器一帧就是 13 个时钟周期。读取顺序固定从 MSB 开始先收到的位是最高位。下表是 12 位单圈编码器在 SSI 模式下的典型参数用来对照配置参数典型值说明CLK 频率100 kHz ~ 500 kHz视编码器手册上限而定不要超帧长13 bit12 位数据 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中对齐困难中脉冲量编码器不太适合 SSITIM 更新中断采样好与 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 位单圈13uint16_t250 kHz17 位单圈18uint32_t250 kHz25 位多圈26uint32_t500 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/DATAKEIL 仿真看变量ST-Link 排错5.1 先用逻辑分析仪确认波形再怀疑代码代码跑不通时先看波形再查代码这是定位问题最快的方式。用逻辑分析仪同时抓 PB0CLK和 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 里看不到值。把这几个检查做下来位置数据可信了再去做控制环路才踏实。本文还有配套的精品资源点击获取