ESP32-S3玩转步进电机:开环转起来,闭环不丢步

步进电机控制是嵌入式开发中的经典课题。市面上常见的A4988、DRV8825方案虽然上手简单,但在精度、静音性和高级运动控制方面存在明显瓶颈。ADI的TMC4361A+TMC2160方案则提供了一套"运动控制器+功率驱动"的完整架构——TMC4361A负责轨迹规划(支持S曲线、梯形斜坡),TMC2160负责功率驱动(支持256微步、StealthChop静音模式),两者通过STEP/DIR信号级联,MCU只需通过SPI配置参数即可。

但光能转还不够。步进电机天生是开环系统,一旦遇到负载突变或高速运转,就容易丢步——电机实际转过的角度和指令之间出现偏差,系统却毫不知情。怎么办?加个编码器做闭环。


今天分享的是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)和编码器闭环接口
TMC4361A 板卡图.png
  • TMC2160-BOB:高性能步进电机驱动器评估板,支持最高256微步细分、StealthChop超静音和SpreadCycle高动态驱动模式,内置电流调节
  • ADMT4000:ADI的多圈绝对角度传感器,基于GMR技术,SPI通信,单圈分辨率12位,支持掉电圈数记忆

image.png

主控选用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 data        write_buffer = bytearray(5)        read_buffer = bytearray(5)

        write_buffer[0] = address        write_buffer[1] = (data >> 24) & 0xFF        write_buffer[2] = (data >> 16) & 0xFF        write_buffer[3] = (data >> 8) & 0xFF        write_buffer[4] = data & 0xFF

        self._cs.value = False  # Select chip        self._spi.try_lock()        self._spi.write_readinto(write_buffer, read_buffer) # Perform full duplex transfer        self._spi.unlock()        self._cs.value = True   # Deselect chip

        self._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 - 0x100000000        return 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编码器反馈回路:

  1. ESP32-S3 通过SPI分别连接TMC4361A和ADMT4000
  2. TMC4361A 通过STEP/DIR驱动TMC2160,这里改用了SPI Passthrough透传配置TMC2160,省去一路独立SPI
  3. ADMT4000 安装在电机轴端,实时测量实际角度并反馈给MCU
  4. 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) 为 0        spi_out_conf &= ~0x00E0000F # 清除 spi_output_format 和 COVER_DATA_LENGTH 位        spi_out_conf |= 0x0000000C # 设置 spi_output_format = 12        # spi_out_conf = 0x44401280 | 0x0C << 0        print(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_LOW            self.write_register(TMC4361_COVER_LOW_REGISTER, data)            time.sleep(0.1)            # 写操作通常返回0或状态,这里简化处理            return 0        else:            # 读操作            tmc2160_address_byte = address & 0x7F


            # 再写入地址到COVER_HIGH,触发传输            self.write_register(TMC4361_COVER_HIGH_REGISTER, tmc2160_address_byte)            # 对于读操作,先向COVER_LOW写入虚拟数据0            self.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 & 0x3F        self._buffer[1] = 0x00        self._buffer[2] = 0x00        self._buffer[3] = 0x00

        # Perform the 32-bit SPI transaction        with 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 data        self._buffer[2] = value & 0xFF         # LSB of data        self._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 field        at a specific angle or over-rotating the magnet). This method handles the software        sequence (abort/restart conversion) that should follow the physical reset.        """        # Abort current conversion sequence        # Set CNVPAGE bits 15:14 to 0b11 to abort        current_cnvpage = self._read_register(_CNVPAGE)        self._write_register(_CNVPAGE, (current_cnvpage | 0xC000)) # Set bits 15:14 to 11        time.sleep(0.01) # Small delay for the device to process

        # Restart the conversion sequence        # Set CNVPAGE bits 15:14 to 0b00 to restart        self._write_register(_CNVPAGE, (current_cnvpage & 0x3FFF)) # Set bits 15:14 to 00        time.sleep(0.01) # Small delay

    def 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()

    @property    def 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 value        return (angle_data / 4096.0) * 360.0

    @property    def 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 11        if turn_count_raw == 0b110110:            return float("nan")

        # Handle two\'s complement for negative turns (0b110111 to 0b111111) as per datasheet Table 11        if turn_count_raw >= 0b110111:            turn_count = turn_count_raw - 64  # Convert 6-bit two\'s complement to negative integer        else:            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)

    @property    def 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 count        if turn_count_raw == 0b110110:            return None

        # Handle two\'s complement for negative turns        if turn_count_raw >= 0b110111:            return turn_count_raw - 64        else:            return turn_count_raw


闭环补偿逻辑

核心循环非常直观:

目标角度 – 实际角度(ADMT4000读取)= 丢步角度

丢步角度 × (每圈脉冲数 / 360) = 需要补偿的脉冲数

当前位置 + 补偿脉冲数 → 写入新的目标位置

每个控制周期都重新读取编码器并重新计算,形成持续纠偏。实际角度等于目标角度时保持不动。


闭环效果

设定目标35圈(12600°),电机运转中终端实时打印丢步角度和补偿步数。检测到丢步时系统自动下发补偿脉冲,电机最终准确到达目标位置。

image.png


04

踩坑记录


两个项目踩过的坑汇总如下:

SPI通信类:

  1. TMC4361A读操作需两次传输——第一次发地址"预约",第二次才取到数据,只读一次会拿到脏数据
  2. 32位有符号数处理——Python中需手动符号扩展(if value & 0x80000000: value -= 0x100000000),否则负方向运动数据出错
  3. CircuitPython SPI锁——每次操作前必须 try_lock(),操作后必须 unlock()
  4. 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安全隔离、电容触摸控制等特性,同时具备超低功耗表现。


本期任务三选一:

  1. 触摸输入与LED控制——利用触摸按键实现LED灯的交互控制
  2. PWM输出与RGB灯控制——通过PWM驱动实现RGB灯的色彩混合控制
  3. 自定义命题——发挥创意,自由发挥


加群找组织

进入活动主页后【得捷购】购买板卡,任选一个任务完成后最高可返300元,活动截止到6月14日,自己一个人研究不如加入组织一起玩,有问题一起解决,感兴趣的小伙伴快来参与啦~


活动主页:https://www.eetree.cn/page/digikey-funpack

02

ADMT4000机器控制竞赛

如果你也想玩ADMT4000,硬禾ADMT4000模块机器控制设计竞赛也在进行中,9大任务可选,完成不仅全额返还购板金额,还有补贴和优秀项目奖。

活动主页:https://www.eetree.cn/activity/137

赛题

方向

难度

硬件需求

题目一

旋转方向与转速检测

⭐

模块+MCU

题目二

多圈角度读取与断电记忆验证

⭐

模块+MCU

题目三

车轮里程计

⭐⭐

模块+MCU

题目四

多圈密码转盘锁

⭐⭐

模块+MCU

题目五

卷线机构断电角度恢复

⭐⭐⭐

+电机+绕线轴

题目六

升降控制(角度→高度映射)

⭐⭐⭐

+电机+丝杆

题目七

机械臂关节安全限位控制

⭐⭐⭐⭐

+电机+杆件

题目八

多圈→单圈转台精确控制

⭐⭐⭐⭐

+步进电机+齿轮组

题目九

自由发挥

⭐⭐⭐

自定

欢迎购买并加群参与竞赛:

购买即参加


扫码购买

加入组织


扫码加群


点击阅读原文查看更多

发表评论