stm32四种方式精密控制步进电机

news/2025/2/24 14:36:13

在搭建完clion的开发环境后,我决定重写之前的项目并优化完善,争取做出完全可落地的东西,也结合要写的论文内容一同学习下去。

因此,首当其冲的就是回到步进电机控制领域,把之前使用中断溢出进行步进电机控制的方案进行进一步优化。

目前使用中断溢出控制步进电机,有如下几个问题:

1.有时电压不稳定/电压太低,电机转不起来。

2.电机频率太高有响声,频率太低有气声,需要设置一个合理的范围值,同时不会因为数值的事情用很长时间去调试电机。

3.电机转向会出问题,或是反应不过来,或是接线有问题。

4.只能通过改变psc预分频器和arr自动重装载值去改变电机速度,无法通过改变占空比的方式控制电机,且驱动器上合适的细分数需要自己调节。

因此我这次使用了四种方式控制电机

1.模拟io控制

2.中断溢出控制

3.定时器比较通道控制

4.定时器pwm控制

芯片选用的是stm32f103zet6,全程未使用编码器/PID,电机驱动器用的是最简单常见的TB6612,主打一个好复现且好上手。电机控制比较稳定,可以根据自己需要选择控制方式。本篇文章使用的硬件和这两篇文章里介绍的一致:

stm32精密控制步进电机(基础篇)_stm32 步进电机-CSDN博客

stm32精密控制步进电机(升级篇)_stm32微机控制-CSDN博客

与之前不同的是我把使能引脚也加上了(PA7控制ENA-;ENA+,PUL+和DIR+接到5v或3.3v引脚),这里可以用万用表验证一下。

四种方式的详细代码我放在了github上,都使用了freertos系统,欢迎移步下载,可以试着点个star,谢谢: 

https://github.com/Re-restart/four_ways_to_control_stepper


怎么完成电机接线

Aout1与Aout2(A+与A-)是同相,Bout1与Bout2(B+与B-)是同相。把同相的两条线拧在一起,会发现难以拧动电机;或是直接测试电阻,两个引脚之间电阻极大,那这两个电机引脚就是同相的。

如果驱动器是9v的,建议接12v电源+DCDC,这样电流会更加稳定,电机抖动现象就可以减轻很多。如果外部不是稳定的开关电源,只靠板子和12v电池供电,最好不要设置板子的脉冲和方向引脚为推挽上拉,设置推挽会抢占一些电压,可能给驱动器分配的电压就变少了。

进行电机微秒级延时

众所周知,HAL_Delay和Osdelay都是毫秒延时。这种情况下需要一个定时器去负责微秒的延时,我使用的方式是把TIM4里的预分频器psc设为71,定时器时钟源频率是72MHZ,定时器输入时钟频率就是:

72MHZ/(71+1)=1MHZ

把arr值设置为65535,这样读取出来的寄存器值就是1微秒增加一次,最后比较出的值小于要执行的微秒数就可以。

void delay_us(uint16_t us)
{
    __HAL_TIM_SET_COUNTER(&htim4, 0);  // 清零了 TIM4 的计数器,因此不再需要 start_time 变量来记录初始时间
    while (__HAL_TIM_GET_COUNTER(&htim4) < us);  // 等待计数达到 us
}

另外还有一种做法是设置一个volatile变量(代表这个变量不要被编译器优化,可能在外设或中断中改变它的值,每次访问该变量时需要从内存中读取),start_time是用来读取中断的初始值的,和清零函数__HAL_TIM_SET_COUNTER(&htim4, 0)的作用一致。

为了处理溢出问题,可以利用无符号整数的特性,即(uint16_t)(delay_time-start_time)。

volatile uint16_t start_time=0;
volatile uint16_t delay_time=0;

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
  /* USER CODE BEGIN Callback 0 */

  /* USER CODE END Callback 0 */
  if (htim->Instance == TIM4) {
    delay_time++;
  }
  /* USER CODE BEGIN Callback 1 */

  /* USER CODE END Callback 1 */
}

void delay_us(uint16_t us)
{
    start_time=delay_time;
    while((uint16_t)(delay_time-start_time)<us);
}

按键进行外部中断控制

先新建系统任务控制函数,利用两个板子自带的按钮做外部中断,控制电机左转/右转。这个按键逻辑是默认flag=1,flag_key=1。按下按键后,防抖10s,并把flag和flag_key全置为0。

按键松开后,按键引脚恢复到之前的电平,因为也不会同时摁,就用flag,flag_key和按键引脚松开后的状态共同判断,区分电机状态并执行stepper_turn函数。

void StartDefaultTask(void *argument)
{
  /* USER CODE BEGIN StartDefaultTask */
  /* Infinite loop */

   for(;;) {
     if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) {
       osDelay(10);
       flag = 0;
     }

     if(flag == 0 && flag_key == 1) {
       if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
         stepper_turn(120,360,32,CW);
       }
       flag = 1;
     }
/
     if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_RESET) {
       osDelay(10);
       flag_key = 0;
     }

     if(flag_key == 0 && flag == 1) {
       if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_SET) {
         stepper_turn(120,360,32,CCW);
       }
     }
     flag_key = 1;
   }

电机引脚初始设置

中断和模拟io控制电机都是PA5为PUL-控制引脚,PA6为DIR-控制引脚,PA7为ENA-控制引脚。输入捕获和pwm用的都是TIM3-Channel1,也就是PC6。不是不能设置推挽引脚哈,这个主要看外界的电压状态,如果外界供电稳定,引脚电压也够,那全设置推挽上拉也没什么问题。但是我需要大多数情况下都没问题的引脚配置,所以会设置开漏不上拉,只有PA7是推挽上拉。

  /*Configure GPIO pins : PA5 PA6 */
  GPIO_InitStruct.Pin = GPIO_PIN_5|GPIO_PIN_6;
  GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;
  GPIO_InitStruct.Pull = GPIO_NOPULL;
  GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
  HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

  /*Configure GPIO pin : PA7 */
  GPIO_InitStruct.Pin = GPIO_PIN_7;
  GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
  GPIO_InitStruct.Pull = GPIO_PULLUP;
  GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
  HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

  /*Configure GPIO pin Output Level */
  HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5|GPIO_PIN_6, GPIO_PIN_RESET);

  /*Configure GPIO pin Output Level */
  HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET);


//

void HAL_TIM_MspPostInit(TIM_HandleTypeDef* timHandle)
{

  GPIO_InitTypeDef GPIO_InitStruct = {0};
  if(timHandle->Instance==TIM3)
  {
  /* USER CODE BEGIN TIM3_MspPostInit 0 */

  /* USER CODE END TIM3_MspPostInit 0 */

    __HAL_RCC_GPIOC_CLK_ENABLE();
    /**TIM3 GPIO Configuration
    PC6     ------> TIM3_CH1
    */
    GPIO_InitStruct.Pin = GPIO_PIN_6;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);

    __HAL_AFIO_REMAP_TIM3_ENABLE();

  /* USER CODE BEGIN TIM3_MspPostInit 1 */

  /* USER CODE END TIM3_MspPostInit 1 */
  }

}

模拟IO控制步进电机

通过编写stepper_turn函数控制模拟IO。这里面注意一下,ENA-引脚是推挽上拉状态,初始为SET,此时ENA-引脚电平为0v。

已知ENA+引脚电平是3.3v或5v,构成使能信号有效,电机pulse引脚开始翻转,电机转动。电机停止转动时,使能引脚恢复到reset状态。

电机转动的时候,使能引脚PA7其实是有电压的,2v~3v的样子。可以看作这个引脚是来激活驱动器的。如果讨厌这个使能引脚/板子引脚资源比较少,可以不接使能+和使能-引脚,PUL+和DIR+接到5v或3.3v引脚,PUL-和DIR-用于控制电机即可。

tim是周期,angle是角度,subdivide是电机驱动器的细分数,dir是电机旋转方向。

void stepper_turn(int tim,float angle,float subdivide,uint8_t dir)
{
  int n,i;
  /*根据细分数求得步距角被分成多少个方波*/
  n=(int)(angle/(1.8/subdivide));
  if(dir==CW)        //顺时针
  {
    HAL_GPIO_WritePin(MOTOR_DIR_GPIO_PORT,MOTOR_DIR_PIN,HIGH);
  }
  else if(dir==CCW)//逆时针
  {
    HAL_GPIO_WritePin(MOTOR_DIR_GPIO_PORT,MOTOR_DIR_PIN,LOW);
  }
  /*开使能*/
  HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_SET);
  /*模拟方波*/
  for(i=0;i<n;i++)
  {
    HAL_GPIO_WritePin(MOTOR_PUL_GPIO_PORT,MOTOR_PUL_PIN,LOW);
    delay_us(tim/2);
    HAL_GPIO_WritePin(MOTOR_PUL_GPIO_PORT,MOTOR_PUL_PIN,HIGH);
    delay_us(tim/2);
  }
  /*关使能*/
  HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_RESET);
}

中断溢出控制步进电机

和之前文章里的类似,这里不再详细讲解。逻辑稍微改良了一下,不再通过读取引脚状态计数,而是每溢出两次,脉冲引脚翻转一次。这是因为中断需要保持实时性,需要尽量处理简单的逻辑。

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
  /* USER CODE BEGIN Callback 0 */

  /* USER CODE END Callback 0 */
  if (htim->Instance == TIM2) {
    HAL_IncTick();
  }
  /* USER CODE BEGIN Callback 1 */
  if(htim->Instance==TIM5)
  {
    pulse=pulse+1;
    if(pulse%2==0){
      Pulse_Toggle();
    }
    __HAL_TIM_CLEAR_IT(&htim5, TIM_IT_UPDATE);
  }

  /* USER CODE END Callback 1 */
}

 中断跟其他方式相比,比较容易出现失控,所以启动之前需要先关闭中断,停止电机运行,然后再打开电机驱动和使能。

for(;;) {
     if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) {
       osDelay(10);
       flag = 0;
     }

     if(flag == 0 && flag_key == 1) {
       if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
         StopMotor();
         Dir_CW();
         StartMotor();
         HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_SET);
       }
       flag = 1;
     }

     if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_RESET) {
       osDelay(10);
       flag_key = 0;
     }

     if(flag_key == 0 && flag == 1) {
       if(HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) == GPIO_PIN_SET) {
         StopMotor();
         Dir_CC();
         StartMotor();
         HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_SET);//打开使能
       }
       flag_key = 1;
     }

     if(pulse > 3200) {
       pulse = 0;
       StopMotor();
       BlinkLEDs();
     }
  }

然后我把比较重要的操作都封装成函数,这样便于调用。HAL_TIM_Base_MspDeInit用于关闭定时器中断,HAL_TIM_Base_MspInit打开定时器中断。

void StopMotor(void) {
  HAL_TIM_Base_MspDeInit(&htim5);
  HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_RESET);
}

void StartMotor(void) {
  HAL_TIM_Base_MspInit(&htim5);
}

定时器比较通道控制步进电机

在这里通过PC6引脚控制步进电机,不需要考虑什么引脚推挽还是开漏的问题,直接设置通道就可以了。如果这里不加延时,电机会一直转。我设置过像HAL_TIM_OC_DelayElapsedCallback一样的回调函数去按照比较通道执行顺序计数,但是并不好用,会发现初始的库文件并没有弱定义这个函数,遂放弃这种方式。

void stepper_turn(uint8_t dir)
{
  if(dir==CW)        //顺时针
  {
    HAL_GPIO_WritePin(MOTOR_DIR_GPIO_PORT,MOTOR_DIR_PIN,HIGH);
  }
  else if(dir==CCW)//逆时针
  {
    HAL_GPIO_WritePin(MOTOR_DIR_GPIO_PORT,MOTOR_DIR_PIN,LOW);
  }
  /*开使能*/
  HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_SET);
  /* 启动比较输出并使能中断 */
  HAL_TIM_OC_Start_IT(&htim3,TIM_CHANNEL_1);
  for(uint16_t i=0;i<2000;i++) {
    delay_us(1000);
  }
  stepper_stop();
}

void stepper_stop(void) {
  HAL_TIM_OC_Stop_IT(&htim3,TIM_CHANNEL_1);
  HAL_GPIO_WritePin(MOTOR_EN_GPIO_PORT,MOTOR_EN_PIN,GPIO_PIN_RESET);
}

在定时器内设置速度,使能比较通道后关闭中断,需要中断时再去调用并打开中断。这里面电机速度计算过程如下,可算得电机速度周期是1.375ms。

72MHZ/(1+899)=0.08 \\ (109+1)/0.08=1.375ms

void MX_TIM3_Init(void)
{

  /* USER CODE BEGIN TIM3_Init 0 */

  /* USER CODE END TIM3_Init 0 */

  TIM_ClockConfigTypeDef sClockSourceConfig = {0};
  TIM_MasterConfigTypeDef sMasterConfig = {0};
  TIM_OC_InitTypeDef sConfigOC = {0};

  /* USER CODE BEGIN TIM3_Init 1 */

  /* USER CODE END TIM3_Init 1 */
  htim3.Instance = TIM3;
  htim3.Init.Prescaler = 899;
  htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
  htim3.Init.Period = 109;
  htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
  htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
  if (HAL_TIM_Base_Init(&htim3) != HAL_OK)
  {
    Error_Handler();
  }
  sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
  if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK)
  {
    Error_Handler();
  }
  if (HAL_TIM_OC_Init(&htim3) != HAL_OK)
  {
    Error_Handler();
  }
  sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
  sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
  if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK)
  {
    Error_Handler();
  }
  sConfigOC.OCMode = TIM_OCMODE_TOGGLE;
  sConfigOC.Pulse = 0;
  sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
  sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
  if (HAL_TIM_OC_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
  {
    Error_Handler();
  }
  /* USER CODE BEGIN TIM3_Init 2 */

  /*使能比较通道*/
  TIM_CCxChannelCmd(TIM3,TIM_CHANNEL_1,TIM_CCx_ENABLE);
  HAL_TIM_OC_Stop_IT(&htim3,TIM_CHANNEL_1);
  /* USER CODE END TIM3_Init 2 */
  HAL_TIM_MspPostInit(&htim3);

}

定时器PWM方式控制步进电机

其实就是把使能比较通道方式换成pwm通道。 sConfigOC.Pulse/(arr+1)这个值是占空比,可以把它设置在tim周期的50%到80% 之间。

但其实这种方式个人认为并不利于电机控制,因为如果想旋转需要的角度,需要设置主从定时器和输入触发源,而且cubeide会初始化很多配置,其中就包括psc,arr和pulse占空比,不利于程序本身的赋值。而且开启PWM时,必须同时开启AFIO时钟!!必须配置对应引脚为复用输出!HAL_TIM_MspPostInit(&htim3);就是这个定时器引脚定义函数,所以下面的定时器配置里是HAL_TIM_PWM_Start(&htim3,TIM_CHANNEL_1);,假如设置HAL_TIM_PWM_Stop,在freertos里再打开,是驱动不了步进电机的。。。。。这件事情卡了我很久。

好处是它和输出比较方式都随时转换方向,而且不需要考虑推挽还是开漏引脚,控制是真的很稳。

void MX_TIM3_Init(void)
{

  /* USER CODE BEGIN TIM3_Init 0 */

  /* USER CODE END TIM3_Init 0 */

  TIM_ClockConfigTypeDef sClockSourceConfig = {0};
  TIM_MasterConfigTypeDef sMasterConfig = {0};
  TIM_OC_InitTypeDef sConfigOC = {0};

  /* USER CODE BEGIN TIM3_Init 1 */

  /* USER CODE END TIM3_Init 1 */
  htim3.Instance = TIM3;
  htim3.Init.Prescaler = 719;
  htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
  htim3.Init.Period = tim_per-1;
  htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
  htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
  if (HAL_TIM_Base_Init(&htim3) != HAL_OK)
  {
    Error_Handler();
  }
  sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
  if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK)
  {
    Error_Handler();
  }
  if (HAL_TIM_PWM_Init(&htim3) != HAL_OK)
  {
    Error_Handler();
  }
  sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
  sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
  if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK)
  {
    Error_Handler();
  }
  sConfigOC.OCMode = TIM_OCMODE_PWM1;
  sConfigOC.Pulse = tim_per/2;
  sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
  sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
  if (HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
  {
    Error_Handler();
  }
  /* USER CODE BEGIN TIM3_Init 2 */
  HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1);
  HAL_TIM_PWM_Start(&htim3,TIM_CHANNEL_1);
  /* USER CODE END TIM3_Init 2 */
  HAL_TIM_MspPostInit(&htim3);

}

几种方式的优缺点总结

个人认为这里面设置最简单,相对来说最可控,占用单片机内部资源最少的还是模拟io方式,延时的问题通过定时器TIM4解决后,大大增加了可控的精度。而且实际测试时,12v供电它的引脚电压基本能稳到10v,且电机发热时一样可运行,也不需要特定的gpio驱动,是和模拟i2c一样的,比较好的方式,之后做项目可能会多用这种电机控制方式。

如果特定项目需要快速切换电机方向,建议选择定时器比较通道控制,或以PWM方式控制电机。

另外,发现TIM1是没有办法设置TIM_OCMODE_TOGGLE方式的,只能设置TIM_OCMODE_TIMING,也查了相关手册,并没介绍这个地方,之后可能会继续完善看看这是怎么回事。


http://www.niftyadmin.cn/n/5864472.html

相关文章

图像处理篇---图像处理中常见参数

文章目录 前言一、分贝&#xff08;dB&#xff09;的原理1.公式 二、峰值信噪比&#xff08;PSNR, Peak Signal-to-Noise Ratio&#xff09;1.用途2.公式3.示例 三、信噪比&#xff08;SNR, Signal-to-Noise Ratio&#xff09;1.用途2.公式3.示例 四、动态范围&#xff08;Dyna…

如何清理cache-loader生成的缓存目录?

清理 cache-loader 生成的缓存目录可以帮助避免潜在的缓存问题和不必要的磁盘占用。以下是几种清理缓存的有效方法&#xff1a; 一、手动清理 1. 定位缓存目录 在 Webpack 配置中&#xff0c;你可以指定 cache-loader 的缓存目录。默认情况下&#xff0c;缓存目录可能位于项…

深入解析JVM垃圾回收机制

1 引言 本节常见面试题 如何判断对象是否死亡&#xff08;两种方法&#xff09;。简单的介绍一下强引用、软引用、弱引用、虚引用&#xff08;虚引用与软引用和弱引用的区别、使用软引用能带来的好处&#xff09;。如何判断一个常量是废弃常量如何判断一个类是无用的类垃圾收…

一款社交媒体中查用户名的工具

简介 追踪 400 多个社交网络中的用户名社交媒体账户以查找账户 安装 # python环境 pip安装 pip install sherlock-project # Mac环境 brew安装 brew install sherlock # docker安装 docker pull sherlock/sherlock 使用方式 ->$ sherlock -h usage: sherlock [-h] [-…

Linux 命令大全完整版(08)

3. 文档编辑命令 joe 功能说明&#xff1a;编辑文本文件。语  法&#xff1a;joe [-asis][-beep][-csmode][-dopadding][-exask][-force][-help][-keepup][-lightoff][-arking][-mid][-nobackups][-nonotice][-nosta][-noxon][-orphan][-backpath<目录>][-columns<…

基于GraphQL的电商API性能优化实战

以下是一个基于 GraphQL 的电商 API 性能优化实战案例&#xff0c;涵盖从问题分析到具体优化措施的实施过程&#xff1a; 一、初始问题分析 在电商场景下&#xff0c;随着业务发展&#xff0c;基于 GraphQL 的 API 出现了一些性能瓶颈。例如&#xff1a; 复杂查询导致响应时间过…

Spring Cloud微服务入门

### 一、什么是Spring Cloud微服务&#xff1f; 想象一下&#xff0c;你有一个超大的玩具积木&#xff0c;把它拆成很多个小积木&#xff0c;每个小积木都有自己的功能&#xff0c;比如有的是轮子&#xff0c;有的是车身&#xff0c;有的是发动机。这些小积木就是“微服务”&a…

AI多模态梳理与应用思考|从单文本到多视觉的生成式AI的AGI关键路径

摘要&#xff1a; 生成式AI正从“文本独舞”迈向“多感官交响”&#xff0c;多模态将成为通向AGI的核心路径。更深度的多模态模型有望像ChatGPT颠覆文字交互一样&#xff0c;重塑物理世界的智能化体验。 一、多模态的必然性&#xff1a;从单一到融合 生成式AI的起点是文本生成…