一直想做一个机械臂,做一些电机控制学习,于是就有了这台基于28BYJ-48的三自由度机械臂。一台最廉价的什么也做不了的机械臂。
1、28BYJ-48电机简介28BYJ-48是入门级步进电机里最常见的一款,便宜、量大。我买的是12V规格,每个电机配一块ULN2003驱动板。
优点是精度够用,4096半步转360°,1步=0.0879°,足以让机械臂按"度"来定位。缺点是扭矩小、只能带动轻负载;轻微抖动(转子和负载的惯量所致),换向时齿轮间隙还会带来一小段空程,所以驱动代码必须做加减速。

机械臂来源于开源项目:https://makerworld.com.cn/zh/models/2150801-bu-jin-dian-ji-28byj-48ji-jie-bi

这套机械臂采用串联结构,三个关节自上而下分别是:
| J1 | 底座 | 水平旋转 | 带动整条手臂左右转,决定"朝向" |
| J2 | 肩 | 俯仰 | 抬起或放下大臂 |
| J3 | 肘 | 俯仰 | 决定小臂与末端的伸出角度 |
图
这套机械臂的缺点也很明显:
①没有位置传感器,角度全靠步数推算。解决办法是上电前手动把手臂摆回中间位,定义为0°,三轴行程限制在±90°。
②转轴有缝隙,角度无法完全到位,会有±3°左右的偏差。
3、驱动方式主控选择ESP32-P4,三轴占用12个GPIO。ESP32的GPIO输出电流只有几十毫安,带不动电机线圈,所以每轴4路信号接ULN2003的四个输入端,电机五线中的公共端接12V。
| J1 | GPIO20 | GPIO21 | GPIO22 | GPIO23 |
| J2 | GPIO31 | GPIO32 | GPIO33 | GPIO34 |
| J3 | GPIO49 | GPIO50 | GPIO51 | GPIO52 |
驱动用八拍(半步)模式:A→AB→B→BC→C→CD→D→DA循环,比四拍(整步)更平顺,步距角再细分一半。转子转一圈需要64个半步,乘上64级减速齿轮,输出轴一圈就是4096个半步。
三轴采用同步模式:以行程最长的轴定速,同起同停。速度上限按负载区分,J1带动整条手臂,上限400Hz;J2、J3负载轻,能跑到600Hz。
4、运行效果把三个关节摆到中间位,运行三轴联动的参考代码stepper3.py:
from machine import Pin
import time
STEPS_PER_REV = 4096 # 半步/圈:90° = 1024 步
LIMIT = 90.0 # 软限位(度)
DIR = (-1, 1, -1) # 方向校正:J2 已实测;J1/J3 若反,单独改对应项
MAX_HZ = (400, 600, 600) # 各轴速度上限:J1 带整臂负载,可再降
RAMP_STEPS = 96 # 加减速步数上限(自动缩到行程的 1/4)
RAMP_F_MIN = 0.25 # 斜坡最低速度系数(起步/收尾不低于 hz*此值)
HOLD_MS = 150 # 到位后继续保持通电 ms(吸收停车振荡再断电;0=立即断)
MAX_DT_MS = 5 # 单次 tick 最多补 5ms 的步数(600Hz 下≈3 步)。
# 【关键】脉冲必须摊开:若一次补 24 步,脉冲间隔只有
# 几微秒,线圈来不及建立磁场 → 转子只抖不转。
# 代价:主循环 <200Hz 时整体速度会打折(宁可慢,不许爆发)
DBG = True # 串口调试输出
SEQ = ((1,0,0,0),(1,1,0,0),(0,1,0,0),(0,1,1,0),
(0,0,1,0),(0,0,1,1),(0,0,0,1),(1,0,0,1))
class Stepper:
def __init__(self, p1, p2, p3, p4, name="", dir_sign=1, max_hz=600, ramp=True):
self.pins = (Pin(p1, Pin.OUT), Pin(p2, Pin.OUT),
Pin(p3, Pin.OUT), Pin(p4, Pin.OUT))
self.name = name
self.dir_sign = dir_sign
self.max_hz = max_hz
self.ramp = ramp
self.seq = 0 # 相位表索引 0-7
self.pos = 0 # 当前位置(半步,页面角度约定)
self.to_go = 0 # 剩余步数
self.moved = 0 # 本次运动已走步数(加速段判据)
self.acc_n = 0 # 本次运动的加速步数(0=不加速)
self.dec_n = 0 # 本次运动的减速步数
self.off_ts = 0 # 到位后延迟断电的时间戳(0=无待断电)
self.sd = 1 # 相位方向(物理)
self.pd = 1 # 位置计数方向(页面约定)
self.hz = 400 # 步进速度(半步/秒)
self.acc = 0.0 # 步数累加器(带小数,随时间累积)
def off(self):
self.pins[0].value(0); self.pins[1].value(0)
self.pins[2].value(0); self.pins[3].value(0)
J1 = Stepper(20, 21, 22, 23, "J1", DIR[0], MAX_HZ[0])
J2 = Stepper(31, 32, 33, 34, "J2", DIR[1], MAX_HZ[1])
J3 = Stepper(49, 50, 51, 52, "J3", DIR[2], MAX_HZ[2])
_axes = (J1, J2, J3)
_last_ms = time.ticks_ms()
_ticks = 0 # 有效 tick 次数(诊断)
_dt_sum = 0 # 累计真实流逝时间 ms(诊断)
_dt_max = 0 # 本段最长 tick 间隔 ms(诊断)
def tick():
"""主循环调用:按真实流逝时间补步。必须频繁调用(>=200Hz)脉冲才平滑。"""
global _last_ms, _ticks, _dt_sum, _dt_max
now = time.ticks_ms()
dt = time.ticks_diff(now, _last_ms)
if dt <= 0:
return
_last_ms = now
_ticks += 1
_dt_sum += dt
if dt > _dt_max:
_dt_max = dt
if dt > MAX_DT_MS:
dt = MAX_DT_MS # 限幅:慢一点没关系,绝不爆发式跳步
for a in _axes:
if a.to_go > 0:
f = 1.0
if a.acc_n and a.moved < a.acc_n:
f = (a.moved + 1.0) / a.acc_n # 加速段:线性升速
elif a.dec_n and a.to_go < a.dec_n:
f = (a.to_go + 1.0) / a.dec_n # 减速段:线性降速,收尾趋近最低速
if f < RAMP_F_MIN:
f = RAMP_F_MIN
a.acc += a.hz * f * dt / 1000.0
while a.acc >= 1.0 and a.to_go > 0:
a.acc -= 1.0
a.seq = (a.seq + a.sd) & 7
s = SEQ[a.seq]
a.pins[0].value(s[0]); a.pins[1].value(s[1])
a.pins[2].value(s[2]); a.pins[3].value(s[3])
a.pos += a.pd
a.moved += 1
a.to_go -= 1
if a.to_go == 0: # 到位:先保持通电吸振,再断电
if HOLD_MS > 0:
a.off_ts = time.ticks_ms() + HOLD_MS
else:
a.off()
elif a.off_ts and time.ticks_diff(time.ticks_ms(), a.off_ts) >= 0:
a.off()
a.off_ts = 0
def _clamp_deg(deg):
return max(-LIMIT, min(LIMIT, deg))
def _deg_to_steps(deg):
return int(round(deg * STEPS_PER_REV / 360.0))
def _target_axis(a, deg, hz):
a.hz = max(20, min(a.max_hz, int(hz)))
tgt = _deg_to_steps(_clamp_deg(deg))
d = tgt - a.pos
a.acc = 0.0
a.moved = 0
a.off_ts = 0
if d == 0:
a.to_go = 0
return
# 运动中同向改道:已在速度上,跳过加速段直接巡航(只留减速段)。
# 否则跟踪时每次延长目标都从 25% 低速重新加速,看着一顿一顿"卡壳"。
same_dir = a.to_go > 0 and a.pd == (1 if d > 0 else -1)
a.to_go = abs(d)
a.pd = 1 if d > 0 else -1
a.sd = a.pd * a.dir_sign
# 加减速步数:按行程 1/4 缩,短行程自动不做斜坡
n = a.to_go // 4
if n > RAMP_STEPS:
n = RAMP_STEPS
if n < 4:
n = 0
if not a.ramp:
n = 0
if same_dir and n:
a.acc_n = 0
a.dec_n = n
else:
a.acc_n = n
a.dec_n = n
def set_targets(t1, t2, t3, hz=400, sync=False):
"""三轴绝对角度(页面约定,逆时针为正),越界自动夹到 ±90°。
本函数只设目标立即返回,实际步进由主循环 tick() 执行。
sync=True:三轴同起同停(最长行程轴按 hz 跑);
sync=False:各轴都按 hz 跑,先到先停。"""
ts = (t1, t2, t3)
ds = []
for i, a in enumerate(_axes):
ds.append(abs(_deg_to_steps(_clamp_deg(ts[i])) - a.pos))
m = max(ds)
for i, a in enumerate(_axes):
h = hz
if sync and m > 0:
h = int(hz * ds[i] / m)
if ds[i] > 0 and h < 20:
h = 20
_target_axis(a, ts[i], h)
if DBG:
print("[st] %s 目标 %7.1f° 剩余 %4d 步 hz %3d 加减速 %d 步"
% (a.name, ts[i], a.to_go, a.hz, a.acc_n))
def move_joints(t1, t2, t3, hz=400, sync=False):
set_targets(t1, t2, t3, hz, sync)
def home(hz=300):
"""三轴回 0°(上电中间位)"""
set_targets(0.0, 0.0, 0.0, hz, True)
def stop_all():
"""急停:立即停在当前位置并断电"""
for a in _axes:
a.to_go = 0
a.acc = 0.0
a.off_ts = 0
a.off()
def angles():
return tuple(a.pos * 360.0 / STEPS_PER_REV for a in _axes)
def moving():
return any(a.to_go > 0 for a in _axes)
def steps_left():
"""三轴剩余步数(诊断用)"""
return tuple(a.to_go for a in _axes)
def stats(reset=False):
"""(有效 tick 次数, 累计真实流逝 ms, 本段最长间隔 ms) —— 诊断用:
读心跳时应看到 tick 次数/秒 >= 200、最长间隔 <= 5ms 才算健康。"""
global _dt_max
r = (_ticks, _dt_sum, _dt_max)
if reset:
_dt_max = 0
return r
if __name__ == "__main__":
print("自检:三轴同步 +45° 再回 0(先确保上电位即 0°)")
move_joints(45, 45, 45, 300, True)
while moving():
tick()
time.sleep_ms(2)
print("到位:", ["%.1f" % x for x in angles()])
home(300)
while moving():
tick()
time.sleep_ms(2)
print("已回中:", ["%.1f" % x for x in angles()])
print("tick 统计:", stats())
运行后三轴会一起转到45°,再回到原点。
我要赚赏金
