【Linux驱动】Mx1508驱动步进电机28BYJ-48适用于100ask_imx6ull

【零】硬件相关知识
(1)Mx1508 双路有刷直流马达驱动电路
Mx1508功能图
查看它的功能框图可发现,OUTA1,OUTB1组成一个H桥,OUTA2,OUTB2组成一个H桥。特别需要注意的是,它的VCC和VDD是隔离的,同时两个通道的VCC和VDD都可以有所不同。VCC控制逻辑电路的电平,同时控制了输入端的逻辑电平大小。VDD则控制了H桥上端的驱动电压大小。因此可以通过在VDD上接高于VCC的电压来驱动电压比VCC高的电机,但其实也高不了多少。

Mx1508驱动直流电机典型应用图
根据上图可知,它的典型应用是双路分别驱动一个直流电机,它的参数如下:
Mx1508参数
根据参数可知的重要信息是:最大驱动10V的电机,通道1的峰值电流比通道2的峰值电流大,如果按照峰值电流算,保守估计通道1 最大长时电流能到2A,通道2能到1A。
Mx1508真值表
这是Mx1508的逻辑真值表,简而言之就是输入为全高时,输出全低。输入不同时,输出同步其输入值。输入全低时,输出全为高阻态(可以认为NC),这是重点,后面需要考。
(2)28BYJ-48步进电机
28BYJ-48预览图
关于28BYJ-48的介绍,网上有很多了,这里主要捡一些自己的理解,不解释命名规则,有的博客说橙线是D相,有的博客说橙线是A相,由于电路图是将橙线接到了丝印为A的LED灯下,因此这里认为橙线为A相。
下面简要介绍一下四相八拍下的各相导通状态,以A-AB-B-BC-C-CD-D-DA的导通顺序为例:
四相八拍的各相导通情况
这里有几个需要注意的点:
1.红色线为COM,可以是VCC也可以是GND,是所有相的共极,如果接VCC,就属于共阳,如果接GND就属于共地。
2.上表中的1/0表示导通与不导通,而不是简单指橙色线电平是高电平还是低电平,因为受红(COM)线的接法不同和驱动电路的不同,不能简单的认为橙色线上高电平就是A相导通。
3.这里是A-AB-B-BC-C-CD-D-DA的导通顺序,正对着驱动端时,端子逆时针旋转,所以在这里,这种顺序定义为反转。相对的,A-AD-D-DC-C-CB-B-BA的导通顺序就为正转。
4.导通顺序不是绝对的,你可以以A相开始导通,也可以以B相开始导通,但是只要是四相八拍,就一定要按照“隔相”导通的方法,例如以D开始:D-DA-A-AB-B-BC-C-CD
5.如果四相全部导通,那么转子会受到四方的钳制从而急停,无法转动,但请注意此时的电机会发热严重。如果用来刹车,请仅保持四相导通较短时间,然后再将四相设置为不导通。
以上几点都是结合这个内部结构体总结而来:
内部结构体
在初学时,最为困惑的就是大多数人都说红色是VCC,结构图里共级有的是接VCC,有的是接GND,非常容易弄的晕头转向。在查看了那么多的资料后,我认为,红色线其实就是共极,你接VCC,那就是共阳,接GND,那就是共阴,仅此而已。如果是共阳,那么只需要在各相有较高的驱动电流就可以驱动各相了。如果是共阴,那么只需要在各相上分别给高电压即可。
(3)100ask_imx6ull 步进电机驱动模块
100ask驱动模块的电路图
电机接在J1 CON5上,模块通过J4 CON8接在通用模块上,根据通用模块的电路图可知:
驱动引脚只是使用了GPIO0-GPIO3,GPIO4没有使用(给遥控器制造了点可能)GPIO0-GPI03分别接在四个输入端,输出端接到电机的各相的线,同时在输出端还有一个5.1K电阻和LED灯接到地的电路。
进行简要的分析后,我们可以得到以下的结论:
1.28BYJ-48电机的红线接在+5V上,那么各相的接法为共阳级。
2.结合共阳极的驱动方法即各相有较大的驱动电流即可导通可知:
当OUT端为低时,电流从5V经过OUT端回流到地,从而有较大的驱动电流,此时该相导通,但旁路因为有5.1K的电阻,导致被短路,从而灯熄灭。因此,灯灭表示该相工作。
当OUT端为高时,OUT端和共阳端电压都为5V,此时相对地来说,电压仍然为5V(注意,此时类似于两个VCC并联),电流经5.1K电阻和LED灯流向地,LED灯点亮。而因为OUT端和共阳端没有电压差,所以驱动电流微弱或者没有,该相不导通。
当OUT端为高阻态时,OUT端可以视为NC(未接),此时电流直接走向5.1K电阻和LED灯流向地,LED灯点亮。但因为5.1K电阻和LED灯,导致电流极小,不满足驱动该相的条件,因此该相不导通。

3.结合以上2点的分析可总结的现象是:灯灭,该相导通,灯亮,该相不导通。同时可总结的控制逻辑是:OUT为L时,该相导通。OUT为H/Z时,该相不导通。
简化电路图
由以上三点,将GPIO0-GPIO3的高低电平分别设置为Bit0~Bit3(由低到高)组成一个十六进制的值,再结合四相八拍的导通顺序和真值表可以得出:
[GPIO3 GPIO2 GPIO1 GPIO0] →[0 0 1 0]→0x02
注意这里从高位到低位是D相-A相。
其中1表示IN引脚为高电平,0表示IN引脚为低电平。H表示OUT引脚为高电平,Z表示OUT引脚为高阻态。
四相八拍对应的控制值
这里有几个需要解释的点:
1.Mx1508本质上是两个H桥,所以它不存在同一通道的两个输出同时为H的情况。针对于一般的直流电机来说,通过控制流过电流的流向来控制电机旋转的方向,控制有效电压的大小来控制电机旋转的速度,控制电流的大小来控制电机的负载能力。所以,它的典型应用就是使用PWM模式,详情可以去看它的技术手册中关于PWM模式 A/B的解释。
2.如果想要搞清楚自己的程序是否有效控制该引脚,可以先只接模块而不接电机,从而避免因为共阳极的电机影响你点灯的顺序。去除电机后,模块上各相的灯只受OUT脚的电平控制。接不接电机,对于亮灯的现象有非常大的影响。例如第一拍,A相导通的情况下:如果不接电机,则模块上的A-D的灯应该是:灭亮灭灭;如果接了电机,则会出现A-D的灯为:灭亮亮亮,具体的电路分析可以看上面的结论2
【一】设备树
设备树引脚
虽然通用模块上的丝印是GPIO0-GPIO3,实际上是GPIO4_19~GPIO4_22
设备树
没什么特别的,注意这里是ACTIVE_HIGH,即高电平有效。
【二】驱动程序

#include <linux/module.h>
#include <linux/poll.h>
#include <linux/fs.h>
#include <linux/errno.h>
#include <linux/miscdevice.h>
#include <linux/kernel.h>
#include <linux/major.h>
#include <linux/mutex.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/stat.h>
#include <linux/init.h>
#include <linux/device.h>
#include <linux/tty.h>
#include <linux/kmod.h>
#include <linux/gfp.h>
#include <linux/gpio/consumer.h>
#include <linux/platform_device.h>
#include <linux/of_gpio.h>
#include <linux/of_irq.h>
#include <linux/irqflags.h>
#include <linux/interrupt.h>
#include <linux/irq.h>
#include <linux/slab.h>
#include <linux/fcntl.h>
#include <linux/timer.h>
#include <linux/sched.h>
#include <linux/wait.h>
#include <linux/cdev.h>
#include <linux/delay.h>
#include <linux/irq.h>
#include <media/rc-core.h>
#include <linux/platform_data/media/gpio-ir-recv.h>

#define motor_NAME    "mymotor"

struct motor_msg{
    
    
  int cmd;
  int angle;
  int speed;
};
struct gpio_motor{
    
    
	int gpio;
	struct gpio_desc *gpiod;
	int flag;
};

struct motor_device{
    
    
  /*file*/
  int major;
  dev_t devid;
  struct cdev cdev;
  struct class *class;
  struct device *device;
  /*motor dev*/
  int gpio_count;
  int m_speed;
  /*motor task*/
  struct mutex lock;
};

static struct motor_msg motor_msg;
static struct motor_device motor_dev;
static struct gpio_motor *gpio_motor;

u8 Motor_Stop_cmd = 0x00;
u8 Motor_Run_cmd[8] = {
    
    0x02,0x06,0x04,0x0c,0x08,0x09,0x01,0x03};//顺时针
u8 Motor_Reverse_cmd[8] = {
    
    0x02,0x03,0x01,0x09,0x08,0x0c,0x04,0x06};//逆时针

void Set_Motor_Contorl(u8 cmd)
{
    
    
  int i = 0;
  u8 cmd_bit[4];
  unsigned long flags;
  memset(cmd_bit,0,sizeof(cmd_bit));
  for(i = 0;i<4;i++)
  {
    
    
    cmd_bit[i] = ((cmd&0x0f)>>i)&0x01;//bit0~bit3
  }
  mutex_lock(&motor_dev.lock);
  local_irq_save(flags);
  for(i = 0;i<4;i++)
  {
    
    
    gpio_set_value(gpio_motor[i].gpio,cmd_bit[i]);
    //printk("gpio[%d] cmd_bit[%d]:%d\n",gpio_motor[i].gpio,i,cmd_bit[i]);
  }
  mdelay(motor_dev.m_speed);  
  local_irq_restore(flags);
  mutex_unlock(&motor_dev.lock);
}

void Motor_Stop(void)
{
    
    
  Set_Motor_Contorl(Motor_Stop_cmd);
}

void Motor_Start(int angle)
{
    
    
  int i = 0;
  for(i = 0;i<angle;i++)
  {
    
    
    Set_Motor_Contorl(Motor_Run_cmd[0]);
    Set_Motor_Contorl(Motor_Run_cmd[1]);
    Set_Motor_Contorl(Motor_Run_cmd[2]);
    Set_Motor_Contorl(Motor_Run_cmd[3]);
    Set_Motor_Contorl(Motor_Run_cmd[4]);
    Set_Motor_Contorl(Motor_Run_cmd[5]);
    Set_Motor_Contorl(Motor_Run_cmd[6]);
    Set_Motor_Contorl(Motor_Run_cmd[7]);
  }
}

void Motor_Reverse(int angle)
{
    
    
  int i = 0;
  for(i = 0;i<angle;i++)
  {
    
    
    Set_Motor_Contorl(Motor_Reverse_cmd[0]);
    Set_Motor_Contorl(Motor_Reverse_cmd[1]);
    Set_Motor_Contorl(Motor_Reverse_cmd[2]);
    Set_Motor_Contorl(Motor_Reverse_cmd[3]);
    Set_Motor_Contorl(Motor_Reverse_cmd[4]);
    Set_Motor_Contorl(Motor_Reverse_cmd[5]);
    Set_Motor_Contorl(Motor_Reverse_cmd[6]);
    Set_Motor_Contorl(Motor_Reverse_cmd[7]);
  }
}

static ssize_t motor_drv_read(struct file *file,char __user *buf,size_t size,loff_t *offset)
{
    
    
    printk("File:%s Function:%s line: %d\n",__FILE__,__FUNCTION__,__LINE__);
    return 0;
}

static ssize_t motor_drv_write(struct file *file,const char __user *buf,size_t size,loff_t *offset)
{
    
    
    int err;
    int data[3];
    printk("File:%s Function:%s line: %d\n",__FILE__,__FUNCTION__,__LINE__);
    err = copy_from_user(data,buf,sizeof(data));
    //printk("data0:%d data1:%d data2:%d\n",data[0],data[1],data[2]);
    if(err == 0)
    {
    
    
      motor_msg.cmd = data[0];
      motor_msg.angle = data[1];
      motor_msg.speed = data[2];
      if(motor_msg.speed > 10)
         motor_msg.speed = 10;
      if(motor_msg.speed<1)
         motor_msg.speed = 1;
      motor_dev.m_speed = 1*motor_msg.speed; 
        switch(motor_msg.cmd)
      {
    
    
        case 0:
        Motor_Stop();
        break;
        case 1:
        Motor_Start(motor_msg.angle);
        break;
        case 2:
        Motor_Reverse(motor_msg.angle);
        break;
        case 3:
        Motor_Start(motor_msg.angle);
        Motor_Reverse(motor_msg.angle);
        break;
        case 4:
        Set_Motor_Contorl(0x01);
        mdelay(1000);
        Set_Motor_Contorl(0x02);
        mdelay(1000);
        Set_Motor_Contorl(0x04);
        mdelay(1000); 
        Set_Motor_Contorl(0x08);
        mdelay(1000);
        default:
        motor_msg.cmd = -1;
        break;
      }
    }else
    {
    
    
      motor_msg.cmd = -1;
    }
    return motor_msg.cmd;
}

static int motor_drv_open(struct inode *node,struct file *file)
{
    
    
    int ret = 0;
    int i = 0;
    printk("File:%s Function:%s line: %d\n",__FILE__,__FUNCTION__,__LINE__);
    mutex_init(&motor_dev.lock);
    for(i = 0;i<motor_dev.gpio_count;i++)
    {
    
    
      ret = gpio_request(gpio_motor[i].gpio,NULL);
      if(ret < 0)
      {
    
    
        printk("gpio %d request fail!\n",i);
      }
      ret = gpio_direction_output(gpio_motor[i].gpio,0);
      if(ret < 0)
      {
    
    
        printk("gpio %d set direction fail!\n",i);
      }
    }
    return 0;
}

static int motor_drv_close(struct inode *node,struct file *file)
{
    
    
  int i = 0;
  printk("File:%s Function:%s line: %d\n",__FILE__,__FUNCTION__,__LINE__);
  Motor_Stop();
  for(i = 0;i<motor_dev.gpio_count;i++)
  {
    
    
    gpio_free(gpio_motor[i].gpio);
  }
  mutex_destroy(&motor_dev.lock);
  return 0;
}

static struct file_operations motor_drv = {
    
    
  .owner =  THIS_MODULE,
  .open  =  motor_drv_open,
  .read  =  motor_drv_read,
  .write =  motor_drv_write,
  .release = motor_drv_close,
};

static int motor_probe(struct platform_device *pdev)
{
    
    
  int ret = 0;
  int i = 0;
  struct device_node *np = pdev->dev.of_node;
	enum of_gpio_flags flag;
  printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
  motor_dev.m_speed = 1;
  motor_dev.gpio_count = of_gpio_count(np);
  if(motor_dev.gpio_count)
  {
    
    
    printk("motor device gpio count = %d\n",motor_dev.gpio_count);
  }else
  {
    
    
    printk("motor device has no gpio!\n");
    return -1;
  }
  
  gpio_motor = kzalloc(sizeof(struct gpio_motor)*motor_dev.gpio_count,GFP_KERNEL);
  if (!gpio_motor)
		return -1;
  for(i = 0;i<motor_dev.gpio_count;i++)
  {
    
    
    gpio_motor[i].gpio = of_get_gpio_flags(np,i,&flag);
    if(gpio_motor[i].gpio<0)
    {
    
    
      printk("of_get_gpio_flags fail!\n");
      goto MEMERROR;
    }else
    {
    
    
      printk("gpio %d get\n",gpio_motor[i].gpio);
    }
    gpio_motor[i].gpiod = gpio_to_desc(gpio_motor[i].gpio);
    gpio_motor[i].flag = flag;
  }
   if(motor_dev.major)
  {
    
    
   motor_dev.devid = MKDEV(motor_dev.major,0);
   ret = register_chrdev_region(motor_dev.devid,1,motor_NAME);
   printk("register successful\r\n");
  }else
  {
    
    
    ret = alloc_chrdev_region(&motor_dev.devid,0,1,motor_NAME);
    motor_dev.major = MAJOR(motor_dev.devid);
    printk("alloc_chrdev successful\r\n");
  }
  if(ret<0){
    
    
    printk("%s Couldn't alloc_chrdev_region,ret = %d\r\n",motor_NAME,ret); 
    goto ERROR;
  }
  cdev_init(&motor_dev.cdev,&motor_drv);
  ret = cdev_add(&motor_dev.cdev,motor_dev.devid,1);
  if(ret<0)
  {
    
    
    printk("Cannot add cdev\n");
    goto ERROR;
  }

  motor_dev.class = class_create(THIS_MODULE,motor_NAME);
  if(IS_ERR(motor_dev.class))
  {
    
    
   printk("Cannot create class\n");
   goto ERROR;
  }else
  {
    
    
    printk("class_create successful\r\n");
  }
  
  motor_dev.device = device_create(motor_dev.class,NULL,motor_dev.devid,NULL,motor_NAME);
  if(IS_ERR(motor_dev.device))
  {
    
    
    printk("Cannot create device\n");
    goto ERROR;
  }else
  {
    
    
    printk("device_create successful\r\n");
  }
  return 0;
ERROR:
  unregister_chrdev_region(motor_dev.devid, 1);
MEMERROR:
  kfree(gpio_motor);
  return -1;
}

static int motor_remove(struct platform_device *pdev)
{
    
    
 printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
 kfree(gpio_motor);
 cdev_del(&motor_dev.cdev);
 unregister_chrdev_region(motor_dev.devid,1);
 device_destroy(motor_dev.class,motor_dev.devid);
 class_destroy(motor_dev.class);
 return 0;
}


static const struct of_device_id my_motor[]=
{
    
    
  {
    
     .compatible = "mymotor,motordrv"},
  {
    
     },
};

static struct platform_driver motor_driver = {
    
    
  .probe = motor_probe,
  .remove = motor_remove,
  .driver = {
    
    
    .name = "mymotor",
    .of_match_table = my_motor,
  },
};

static int __init motor_modules_init(void)
{
    
    
   int err;
   printk("File:%s Function:%s line: %d\n",__FILE__,__FUNCTION__,__LINE__);
   /* 1.register led_drv*/
   err = platform_driver_register(&motor_driver);
   return err;
}

/* 5. Exit Function */
static void __exit motor_modules_exit(void)
{
    
    
  printk("File:%s Function:%s line: %d\n",__FILE__,__FUNCTION__,__LINE__);
  platform_driver_unregister(&motor_driver);
}

/* 6. complete dev information*/

module_init(motor_modules_init);//init 
module_exit(motor_modules_exit);//exit

MODULE_LICENSE("GPL");

程序的逻辑为:从设备树中读取GPIO的信息→在Open函数中进行GPIO申请→在write函数中读取用户APP给出的指令并进行操作
这里需要稍作解释的就是Set_Motor_Contorl函数,它实际上就是填入一个u8的参数(当然高4位是不使用的),从而控制GPIO0-GPIO3的电平,最后达到完成步进电机的1拍脉冲。这个函数中也有一个测试打印,如果需要就请去掉注释。cmd_bit[0]~cmd_bit[3]分别存储了GPIO0-GPIO3的电平。Motor_Start和Motor_Reverse分别表示电机顺时针和逆时针旋转的执行函数,其实也就是在Set_Motor_Contorl中填上不同的设置值。这里笔者是拆分写成8次设置,这样可以比较直观的看到8拍,觉得麻烦的人也可以写成循环。
【三】应用APP

#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <poll.h>
#include <signal.h>

#define ONE_CIRCLE_PULSE   512

enum COMMAND{
    
    
   Stop = 0,
   Run = 1,
   Reverse = 2,
   RunAndReverse = 3,
   Test = 4,
};

enum SPEED{
    
    
   Very_Fast = 1,
   Fast = 2,
   Medium = 3,
   Slow = 4,
   Very_Slow = 5,
};

struct motor_msg{
    
    
  int cmd;
  int angle;
  int speed;
};

static struct motor_msg motor_msg;
int fd;
int data[3];

static void MsgToData(int *data,struct motor_msg msg)
{
    
    
  data[0] = msg.cmd;
  data[1] = msg.angle;
  data[2] = msg.speed;
}

static void sig_func(int signal)
{
    
      
   int ret = 0;
   motor_msg.cmd = Stop;
   MsgToData(data,motor_msg);
   ret = write(fd,data,sizeof(data));
   close(fd);
   exit(0);
}

int main(int argc,char **argv)
{
    
    
   char *filename;
   int ret = 0;
   if(argc!=2)
   {
    
    
     printf("Usage:%s <dev>\n",argv[0]);
     return -1;
   }
   //signal(SIGSTOP,sig_func);//Ctrl+Z
   signal(SIGINT,sig_func);//Ctrl+C
   signal(SIGTERM,sig_func);
   signal(SIGKILL,sig_func);//无法捕获kill -9 ,应用层不能kill -9 退出
   filename = argv[1];
   fd = open(filename,O_RDWR);
   if(fd<0)
   {
    
    
     printf("Open file fail!\n");
     goto ERROR;
   }

   motor_msg.cmd = RunAndReverse;
   motor_msg.angle = ONE_CIRCLE_PULSE*2;
   motor_msg.speed = Very_Fast;
   MsgToData(data,motor_msg);

   ret = write(fd,data,sizeof(data));

   ERROR:
   close(fd);
   return 0;
}

应用层通过数组来传输数据至驱动,如果想要传输结构体可以使用ioctl的一些做法,这里没有使用。
这里需要解释的是以下几点:
1.sig_func与信号槽的目的是在退出程序时,对电机的控制端设置停止的命令。本程序并没有写while(1)的循环执行,假设在main中有一个while(1),其中一直执行走1步的命令,那么对于驱动层来说就是:循环执行走8拍。可问题是,在8拍的最后,是没有全不导通的情况的。所以,如果此时退出程序,驱动层的代码依然会将GPIO的状态停留在最后一拍的情况。此时就会造成一个致命的问题即某相一直导通。这无疑会使得电机发热且不正常。
2.motor_msg中的speed一项,我设置了枚举来规避其中的逻辑陷阱,细心的小盆友可能会发现,Very_Fast的值会比Very_Slow的值小。这并不是错误,查看驱动程序代码可以发现,motor_msg.speed实际上是控制了Set_Motor_cmd函数中的mdelay的延时时间大小,延时时间小时,每一拍之间的时间间隔也就小,从而电机运转的更快。经过测试,这个时间间隔最小为1ms,在设置为100us时,电机并不能正常转动。在设置为1s时,电机转的有点困难。
3.ONE_CIRCLE_PULSE 的值得出是通过计算和现象结合得到的,关于28BYJ-48电机的描述我就不再重复,这里需要注意的是它的减速比和步距角。如果以8拍运行,电机主轴的齿数始终为8,那么电机主轴的步距角为:360/(8x8)= 5.625°,再由于有减速比1:64,那么在执行端的步距角为:5.625°/64。因此,转一圈的情况下一共需要多少拍呢?需要360/(5.625/64) = 4096拍,程序中以8拍为一步,则一圈需要4096/8 = 512步。这也是这个定义的值的来的原因,经过观察,发现确实在512时能正好转一圈。如果是四相四拍呢?例如AB-BC-CD-DA,此时主轴的步距角为:360°/(8x4)= 11.25°,从而执行端的步距角为:11.25°/64。最后可得,以4拍为1步的情况下,仍然需要512步。
4.程序实际上有一个非常严重的bug,那就是应用层只负责给数据,其他留给驱动层去做,假如我填入一个非常大的步,例如走1000圈,但是程序中途需要将电机暂停,或者程序中途退出了需要电机关闭,此时因为顺序执行的缘故,在驱动层没走完的圈,它依然还在走。所以这种限定了一共走多少步的情况下,是无法暂停的。这也是这个程序接下来最需要改进的一个点。有一种办法是设定while(1),每次走一步,记录总共走多少步,如果得到暂停命令,就停止循环。较为简单的办法,就是在驱动层设置一个flag,在flag为1的情况下,可以执行Set_motor_cmd函数,在flag为0时,不得执行。但因为所有的动作都是在write函数中执行,所以现有的问题是:指令端和执行端没有剥离开的问题。
相关公式
【四】Makefile

KERN_DIR = //home/book/100ask_imx6ull-sdk/Linux-4.9.88

all:
	make -C $(KERN_DIR) M=`pwd` modules
	$(CROSS_COMPILE)gcc -o motor_app motor_app.c


clean:
	make -C $(KERN_DIR) M=`pwd` modules clean
	rm -rf modules.order
	rm -f motor_app


obj-m   +=motor_my_drv.o 

使用方法:

insmod motor_my_drv.ko
./motor_app /dev/mymotor
kill -9 [PID]
rmmod motor_my_drv.ko

参考博客:
https://blog.csdn.net/anchoretor/article/details/113780470?ops_request_misc=&request_id=&biz_id=102&utm_term=28byj-48%E6%AD%A5%E8%BF%9B%E7%94%B5%E6%9C%BA&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduweb~default-2-113780470.nonecase&spm=1018.2226.3001.4187
https://blog.csdn.net/weixin_51341083/article/details/125274007
https://blog.csdn.net/sunshineQY/article/details/90486422?ops_request_misc=&request_id=&biz_id=102&utm_term=28byj-48%E6%AD%A5%E8%BF%9B%E7%94%B5%E6%9C%BA&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduweb~default-7-90486422.nonecase&spm=1018.2226.3001.4187
http://www.taichi-maker.com/homepage/reference-index/motor-reference-index/28byj-48-stepper-motor-intro/

猜你喜欢

转载自blog.csdn.net/qq_32006213/article/details/131371284