但光能转还不够。步进电机天生是开环系统,一旦遇到负载突变或高速运转,就容易丢步——电机实际转过的角度和指令之间出现偏差,系统却毫不知情。怎么办?加个编码器做闭环。
今天分享的是StreakingJerry的两个相互关联的项目:先用ESP32-S3+CircuitPython把TMC4361A和TMC2160驱动起来,实现开环控制;再加入ADI的ADMT4000磁编码器,在软件层面实现丢步检测和自动补偿。从"转起来"到"转得准",完整走一遍。
步进电机驱动:https://www.eetree.cn/project/detail/4761
配合ADMT4000编码器实现步进电机闭环控制:https://www.eetree.cn/project/detail/4764
01
硬件方案
项目用到三块核心芯片/模块:
- TMC4361A-BOB:Trinamic运动控制器评估板,内置完整的斜坡生成器(梯形/S曲线),支持位置模式和速度模式,通过STEP/DIR脉冲驱动下游驱动器,还支持SPI透传(Cover Register)和编码器闭环接口
-
TMC2160-BOB:高性能步进电机驱动器评估板,支持最高256微步细分、StealthChop超静音和SpreadCycle高动态驱动模式,内置电流调节
- ADMT4000:ADI的多圈绝对角度传感器,基于GMR技术,SPI通信,单圈分辨率12位,支持掉电圈数记忆
主控选用ESP32-S3,开发环境为CircuitPython——无需编译,保存即运行,交互式REPL方便逐步验证寄存器配置,非常适合这种需要频繁调试的场景。
02
Part 1:开环控制——先让电机转起来
系统架构
ESP32-S3通过两路SPI分别连接TMC4361A和TMC2160。TMC4361A接收运动参数后自动生成斜坡轨迹并输出脉冲,TMC2160接收脉冲驱动电机。
TMC4361A驱动开发
这是整个项目工作量最大的部分。作者为TMC4361A编写了一套完整的CircuitPython驱动类,涵盖了寄存器映射、SPI通信协议、运动模式管理和事件状态查询。
SPI通信的核心——TMC4361A采用"1字节地址+4字节数据"的5字节帧格式:
def _spi_transfer(self, address, data):# TMC4361 expects 1 byte address + 4 bytes datawrite_buffer = bytearray(5)read_buffer = bytearray(5)write_buffer[0] = addresswrite_buffer[1] = (data >> 24) & 0xFFwrite_buffer[2] = (data >> 16) & 0xFFwrite_buffer[3] = (data >> 8) & 0xFFwrite_buffer[4] = data & 0xFFself._cs.value = False # Select chipself._spi.try_lock()self._spi.write_readinto(write_buffer, read_buffer) # Perform full duplex transferself._spi.unlock()self._cs.value = True # Deselect chipself._spi_status = read_buffer[0] # First byte of read_buffer is the status byte# Basic error checking based on SPI status byte (refer to TMC4361A datasheet for details)# This is a simplified check, more detailed error handling can be added.# if self._spi_status & 0x01: # Check for general error flag (example, actual bit might vary)# print(f"Warning: TMC4361 SPI communication error detected. Status: {self._spi_status:02X}")# The returned data is the 32-bit value from the chip, starting from the second byte of read_buffer# 组合32位数据value = (read_buffer[1] << 24) | (read_buffer[2] << 16) | (read_buffer[3] << 8) | read_buffer[4]# 检查最高位是否为1(负数)if value & 0x80000000:# 进行符号扩展,将32位有符号整数转换为Python的负数表示value = value - 0x100000000return value
TMC2160配置
TMC2160的配置相对简单,三条关键寄存器写入即可:
write_reg(0x10,0x00061F0A)# IHOLD_IRUN:运行电流和保持电流
write_reg(0x6C,0x000100C3)# CHOPCONF:使能斩波器,设置微步细分
write_reg(0x00,0x00000004)# GCONF:启用外部STEP/DIR模式
运动控制实现
运动参数的核心是微步数计算:
round=360/1.8*16*16# 一圈的微步数(1.8°步距角,16微步)
位置模式——指定目标,TMC4361A自动执行梯形加减速到达:
# 设置TMC4361的运动参数round = 360 / 1.8 * 16 * 16tmc.set_accelerations(round, round, 0, 0)# 设置目标位置tmc.set_ramp_mode(TMC4361.TMC4361.RampMode.POSITIONING_MODE, TMC4361.TMC4361.RampType.TRAPEZOIDAL_RAMP)tmc.set_max_speed(round)target = int(round * 3)tmc.set_target_position(target)print("Target position set")
速度模式——持续匀速运转,负值反转:
# 设置目标速度tmc.set_ramp_mode(TMC4361.TMC4361.RampMode.VELOCITY_MODE, TMC4361.TMC4361.RampType.TRAPEZOIDAL_RAMP)tmc.set_max_speed(round * -1)print("Target velocity set")
开环效果
至此,定速旋转、指定角度/圈数旋转、正反转切换全部实现,运行流畅:
Speed: 51200, Position: 12800, Target Reached: False
Speed: 51200, Position: 25600, Target Reached: False
...
Speed: 0, Position: 153600, Target Reached: True
Target reached!
电机转起来了。但问题也来了——开环系统对丢步完全无感知。
03
Part 2:闭环控制——加编码器,不丢步
为什么需要闭环?
开环步进电机的先天缺陷在于:控制器"以为"电机转到了指定位置,但实际是否到达无从得知。负载突变、加速过快、机械卡顿都可能导致丢步,而系统对此毫不知情。
解决思路也很直接:在电机轴端安装角度编码器,实时读取实际角度,与目标角度比较,检测到偏差就自动补偿。
闭环系统架构
在开环基础上增加了ADMT4000编码器反馈回路:
- ESP32-S3 通过SPI分别连接TMC4361A和ADMT4000
- TMC4361A 通过STEP/DIR驱动TMC2160,这里改用了SPI Passthrough透传配置TMC2160,省去一路独立SPI
- ADMT4000 安装在电机轴端,实时测量实际角度并反馈给MCU
-
MCU在软件层面完成"目标角度 vs 实际角度"比较,检测丢步并计算补偿
SPI Passthrough透传配置
闭环项目中,TMC2160的配置改由TMC4361A的SPI Passthrough功能代理,减少了一路SPI接线。关键是TMC4361A的SPI_OUT_CONF寄存器需要正确设置 spi_output_format=12(对应TMC2160系列)。
写入和读取TMC2160寄存器时,需分别操作COVER_HIGH和COVER_LOW两个寄存器,操作后等待0.1秒让传输完成,再读取响应——这个时序等待很容易被忽略。
def initialize_spi_passthrough(self):"""初始化TMC4361以启用与TMC2160的SPI passthrough功能"""print("正在初始化TMC4361的SPI Passthrough功能...")# 读取当前的SPI_OUT_CONF值spi_out_conf = self.read_register(TMC4361_SPI_OUT_CONF_REGISTER)print(f"当前的 SPI_OUT_CONF: 0x{spi_out_conf:08X}")# 设置 spi_output_format 为 13 (b'1101') 以选择TMC2130/2160# 同时确保 COVER_DATA_LENGTH (bits 23:21) 为 0spi_out_conf &= ~0x00E0000F # 清除 spi_output_format 和 COVER_DATA_LENGTH 位spi_out_conf |= 0x0000000C # 设置 spi_output_format = 12# spi_out_conf = 0x44401280 | 0x0C << 0print(f"将要写入的 SPI_OUT_CONF: 0x{spi_out_conf:08X}")self.write_register(TMC4361_SPI_OUT_CONF_REGISTER, spi_out_conf)# 再次读取以确认写入成功new_spi_out_conf = self.read_register(TMC4361_SPI_OUT_CONF_REGISTER)print(f"确认后的 SPI_OUT_CONF: 0x{new_spi_out_conf:08X}")if new_spi_out_conf == spi_out_conf:print("SPI Passthrough 初始化成功!")else:print("SPI Passthrough 初始化失败!")def spi_passthrough_transfer_v2(self, address, data=None):"""修正后的SPI passthrough传输方法,增加了前置配置。Args:address: TMC2160寄存器地址 (7位)data: 要写入的数据 (32位)(None表示读取操作)Returns:读取的数据或状态 (32位)"""if data is not None:# 写操作tmc2160_address_byte = (address | 0x80) & 0xFF# 再写入地址到COVER_HIGH,触发传输self.write_register(TMC4361_COVER_HIGH_REGISTER, tmc2160_address_byte)# 先写入数据到COVER_LOWself.write_register(TMC4361_COVER_LOW_REGISTER, data)time.sleep(0.1)# 写操作通常返回0或状态,这里简化处理return 0else:# 读操作tmc2160_address_byte = address & 0x7F# 再写入地址到COVER_HIGH,触发传输self.write_register(TMC4361_COVER_HIGH_REGISTER, tmc2160_address_byte)# 对于读操作,先向COVER_LOW写入虚拟数据0self.write_register(TMC4361_COVER_LOW_REGISTER, 0)# 等待传输完成time.sleep(0.1)# 读取响应# 注意:根据数据手册,响应数据在COVER_DRV_LOW中,状态在COVER_DRV_HIGH中response_data = self.read_register(TMC4361_COVER_DRV_LOW_REGISTER)response_status = self.read_register(TMC4361_COVER_DRV_HIGH_REGISTER)print(f"TMC2160响应 - 状态: 0x{response_status:08X}, 数据: 0x{response_data:08X}")return response_data
ADMT4000驱动开发
ADMT4000使用32位SPI帧通信,角度解析逻辑是核心:
- 单圈角度:ANGLE寄存器高12位 ÷ 4096 × 360°
- 多圈绝对角度:ABSANGLE寄存器高6位为圈数(6位补码),低10位为角度(0.351°/LSB)
- 边界处理:圈数=54时为无效值需返回NaN,≥55时按补码处理为负数
import timefrom adafruit_bus_device.spi_device import SPIDevice# Register addresses_CNVPAGE = 0x01_ABSANGLE = 0x03_ANGLE = 0x05_GENERAL = 0x10 # General Register, Page 0x02_FAULT = 0x06 # Fault Register, Page Agnostic# Bit masks for GENERAL register# No direct software reset bit for GMR found in datasheet. Reset is physical.class ADMT4000:"""Driver for the ADMT4000 multiturn sensor."""def __init__(self, spi, cs, baudrate=10000000, polarity=0, phase=0):self.spi_device = SPIDevice(spi, cs, baudrate=baudrate, polarity=polarity, phase=phase)self._buffer = bytearray(4)def _read_register(self, address):"""Read a 16-bit value from a register."""# Per datasheet Figure 18 (SPI Register Read Operation):# The first bit sent by the microcontroller is ignored.# The second bit is set low (0) to select the read register configuration.# The 6-bit register address A5 to A0 is then clocked into the ADMT4000.# This means the first byte of the 32-bit frame should be structured as:# [Ignored Bit (can be 0)][R/W Bit (0 for read)][A5][A4][A3][A2][A1][A0]# So, the command byte should be (address & 0x3F).# The remaining 3 bytes of the command frame are 0x00.self._buffer[0] = address & 0x3Fself._buffer[1] = 0x00self._buffer[2] = 0x00self._buffer[3] = 0x00# Perform the 32-bit SPI transactionwith self.spi_device as spi:spi.write_readinto(self._buffer, self._buffer)# The datasheet (Figure 18) indicates the response frame structure:# Byte 0: [Ignored Bit][R/W Bit (1)][A5][A4][A3][A2][A1][A0] (echo of command, but R/W=1)# Byte 1: [Data MSB (D15)][D14][D13][D12][D11][D10][D9][D8]# Byte 2: [Data LSB (D7)][D6][D5][D4][D3][D2][D1][D0]# Byte 3: [First Bit Following Data (always 1)][C1][C0][CRC4][CRC3][CRC2][CRC1][CRC0]# The 16-bit data is in self._buffer[1] and self._buffer[2].return (self._buffer[1] << 8) | self._buffer[2]def _write_register(self, address, value):"""Write a 16-bit value to a register."""# Per datasheet Figure 19 (32-Bit Write Operation for 16-Bit Register):# The first bit sent by the microcontroller is ignored.# The second bit is set high (1) for a write.# The 6-bit register address A5 to A0 is then clocked into the ADMT4000.# This makes up the first byte of the 32-bit SPI frame.# Command byte: [Ignored Bit (can be 0)][R/W Bit (1 for write)][A5][A4][A3][A2][A1][A0]# So, the command byte should be (0x40 | (address & 0x3F)). (0x40 sets the R/W bit to 1)self._buffer[0] = 0x40 | (address & 0x3F)self._buffer[1] = (value >> 8) & 0xFF # MSB of dataself._buffer[2] = value & 0xFF # LSB of dataself._buffer[3] = 0x00 # The datasheet mentions 3 following bits are not specified, and then 5 bits CRC. For now, send 0.with self.spi_device as spi:spi.write(self._buffer)def gmr_reset(self):"""Resets the GMR turn count sensor. This will set the turn count to 45.Requires a conversion sequence abort and restart after reset.Note: The GMR sensor reset is a physical action (e.g., applying a strong magnetic fieldat a specific angle or over-rotating the magnet). This method handles the softwaresequence (abort/restart conversion) that should follow the physical reset."""# Abort current conversion sequence# Set CNVPAGE bits 15:14 to 0b11 to abortcurrent_cnvpage = self._read_register(_CNVPAGE)self._write_register(_CNVPAGE, (current_cnvpage | 0xC000)) # Set bits 15:14 to 11time.sleep(0.01) # Small delay for the device to process# Restart the conversion sequence# Set CNVPAGE bits 15:14 to 0b00 to restartself._write_register(_CNVPAGE, (current_cnvpage & 0x3FFF)) # Set bits 15:14 to 00time.sleep(0.01) # Small delaydef reset_angle_registers(self):"""Resets the ANGLE and ABSANGLE registers.The datasheet indicates that ANGLE and ABSANGLE are read-only registers.Their values are derived from sensor measurements. To 'reset' them,a GMR sensor reset is the primary mechanism, which sets the turn count to 45.This function will trigger the GMR reset sequence."""print("Warning: ANGLE and ABSANGLE registers are read-only. Performing GMR reset to affect turn count.")self.gmr_reset()@propertydef angle(self):"""The single-turn angle in degrees."""raw_angle = self._read_register(_ANGLE)# The ANGLE register provides the upper 12 bits of the angle data [15:4].# Angle Resolution = 360°/4096.# The raw_angle read from the device is already the 16-bit value, where bits [15:4] are angle data.angle_data = raw_angle >> 4 # Shift right by 4 to get the 12-bit angle valuereturn (angle_data / 4096.0) * 360.0@propertydef absolute_angle(self):"""The absolute angle over multiple turns, in degrees."""raw_abs_angle = self._read_register(_ABSANGLE)# ABSANGLE[15:10] contains the number of whole turns (6 bits).# ABSANGLE[9:0] contains the angle information in straight binary with a resolution of 0.351° (10 bits).# Extract turn count (bits 15-10)turn_count_raw = (raw_abs_angle >> 10) & 0x3F # Mask to ensure 6 bits# Extract angle part (bits 9-0)angle_part_raw = raw_abs_angle & 0x03FF # Mask for lower 10 bits# Handle invalid turn count (0b110110 = 54) as per datasheet Table 11if turn_count_raw == 0b110110:return float("nan")# Handle two\'s complement for negative turns (0b110111 to 0b111111) as per datasheet Table 11if turn_count_raw >= 0b110111:turn_count = turn_count_raw - 64 # Convert 6-bit two\'s complement to negative integerelse:turn_count = turn_count_raw# Calculate absolute angle: turn_count * 360° + angle_part * 0.351°return (turn_count * 360.0) + (angle_part_raw * 0.351)@propertydef turn_count(self):"""The number of turns."""raw_abs_angle = self._read_register(_ABSANGLE)# ABSANGLE[15:10] contains the number of whole turns (6 bits).turn_count_raw = (raw_abs_angle >> 10) & 0x3F # Mask to ensure 6 bits# Handle invalid turn countif turn_count_raw == 0b110110:return None# Handle two\'s complement for negative turnsif turn_count_raw >= 0b110111:return turn_count_raw - 64else:return turn_count_raw
闭环补偿逻辑
核心循环非常直观:
目标角度 – 实际角度(ADMT4000读取)= 丢步角度
丢步角度 × (每圈脉冲数 / 360) = 需要补偿的脉冲数
当前位置 + 补偿脉冲数 → 写入新的目标位置
每个控制周期都重新读取编码器并重新计算,形成持续纠偏。实际角度等于目标角度时保持不动。
闭环效果
设定目标35圈(12600°),电机运转中终端实时打印丢步角度和补偿步数。检测到丢步时系统自动下发补偿脉冲,电机最终准确到达目标位置。
04
踩坑记录
两个项目踩过的坑汇总如下:
SPI通信类:
- TMC4361A读操作需两次传输——第一次发地址"预约",第二次才取到数据,只读一次会拿到脏数据
- 32位有符号数处理——Python中需手动符号扩展(if value & 0x80000000: value -= 0x100000000),否则负方向运动数据出错
- CircuitPython SPI锁——每次操作前必须 try_lock(),操作后必须 unlock()
- SPI Passthrough时序——COVER寄存器操作后需等0.1s,否则读到错误数据
配置类:
5. TMC2160 STEP/DIR必须显式使能——GCONF寄存器bit2必须置1,否则电机不动
6. 速度寄存器的定点格式——TMC4361A的速度采用23.8格式(23位整数+8位小数),写入前需浮点→定点转换
7. 外部时钟——TMC4361A需外部时钟,用ESP32-S3的PWM输出8MHz方波即可,省去晶振
编码器类:
8. ADMT4000圈数边界值——圈数=54为无效值需特殊处理,否则角度计算会跳变
05
总结
这两个项目完整展示了步进电机控制从开环到闭环的进化路径:
- 开环阶段:从底层SPI通信到上层运动控制,一步步把TMC4361A+TMC2160方案跑通,实现定速旋转、位置控制、正反转等全部基础功能
- 闭环阶段:引入ADMT4000磁编码器做角度反馈,在软件层面实现丢步检测和自动补偿,将步进电机的可靠性提升了一个层级
StreakingJerry为TMC4361A和ADMT4000分别编写了完整的CircuitPython驱动库,代码结构清晰,具有很好的复用价值。当然方案也有继续优化的空间——比如提高控制循环频率、加入PID算法使补偿更平滑等——但作为原型验证已经非常完整。
正如StreakingJerry所说:"时隔一年再次参加电子森林的活动,活动依旧创意十足,诚意满满!"
活动推荐
01
Funpack5-2
看完这个项目是不是手痒了?这个项目就是参加硬禾Funpack活动设计的,如果你也想参加,Funpack 5-2 正在进行中,欢迎来参加~
Funpack 5-2 带来的板卡是 Microchip EV41C56A——这是一套基于PIC32CM LS00 Curiosity Nano的触摸评估套件,搭载ARM Cortex-M23内核,集成了安全启动、TrustZone安全隔离、电容触摸控制等特性,同时具备超低功耗表现。
本期任务三选一:
- 触摸输入与LED控制——利用触摸按键实现LED灯的交互控制
- PWM输出与RGB灯控制——通过PWM驱动实现RGB灯的色彩混合控制
- 自定义命题——发挥创意,自由发挥
加群找组织
进入活动主页后【得捷购】购买板卡,任选一个任务完成后最高可返300元,活动截止到6月14日,自己一个人研究不如加入组织一起玩,有问题一起解决,感兴趣的小伙伴快来参与啦~
活动主页:https://www.eetree.cn/page/digikey-funpack
02
ADMT4000机器控制竞赛
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
欢迎购买并加群参与竞赛:
扫码购买
扫码加群
点击阅读原文查看更多