![cover](https://img-blog.csdnimg.cn/direct/3c4ea7c1e8dc43599c07e5b4508df5a4.png)
直流减速电机-位置环控制-位置式PID
PID 指的是比例(Proportional)、积分(Integral)和微分(Derivative)三个术语。在电机PID算法中,这三个术语分别对应着误差、误差累积和误差变化率。该算法通过不断测量电机的实际运动状态,并与期望运动状态进行比较,来计算出一个校正因子,从而使电机实现更加准确的运动。PID算法一般包括位置环、速度环和电流环这三个环节。它们分别对应控制系统的不同层次,用于实现不同的控制目
文章目录
一、PID是什么?
PID 指的是比例(Proportional)、积分(Integral)和微分(Derivative)三个术语。在电机PID算法中,这三个术语分别对应着误差、误差累积和误差变化率。该算法通过不断测量电机的实际运动状态,并与期望运动状态进行比较,来计算出一个校正因子,从而使电机实现更加准确的运动。
PID算法一般包括位置环、速度环和电流环这三个环节。它们分别对应控制系统的不同层次,用于实现不同的控制目标。今天的内容主要讲位置环的控制。
二、位置环是什么?
位置环(Position Loop):位置环是PID控制的最外层环节,也是最常见的环节。它通过比较设定值(目标位置)和实际值(位置反馈),计算出位置误差,并根据误差来调整输出量。在小车控制中,位置环用于控制小车运动到特定位置或实现路径规划。
让我给你举一个生动的例子来说明位置环PID的概念。
假设你是一名热爱自行车骑行的运动员,你想要骑行到目标长度为10公里的位置。为了实现这个目标,你可以使用一个类似于位置环PID的控制策略。
- 首先,设置目标位置:例如,你希望到达距离起点10公里的位置
- 然后,测量实际位置:使用里程表或GPS等设备来记录你的当前位置。
- 接下来,你可以计算出位置误差,即目标位置与实际位置之间的差异。
- 计算PID输出:根据位置误差,使用PID控制算法计算出一个控制信号,该信号用于调整你的位置。
P(比例)项:根据位置误差的大小调整输出信号的幅值。
I(积分)项:根据位置误差的累积量来消除系统的静态误差。
D(微分)项:根据位置误差的变化率来调整输出信号的斜率。 - 调整位置:根据PID输出信号,适当地调整你的位置,使实际位置逐渐接近目标位置。
总结起来,位置环PID控制可以帮助你在一条道路上控制你的位置。通过不断测量实际位置和目标位置之间的误差,并根据误差的大小和变化率来调整你的位置,你可以保持稳定的位置。
三、硬件
1.电机驱动芯片
电机驱动芯片AT8870的介绍AT8870的简单应用
2.直流减速电机
直流减速电机的介绍电机(按工作电源分类)介绍
所使用的直流减速电机相关参数:
• 减速比:是指没有减速齿轮时转速与有减速齿轮时转速之比。
• 空载转速:正常工作电压下电机不带任何负载的转速(单位为 r/min(转/分))。空载转速
由于没有反向力矩,所以输出功率和堵转情况不一样,该参数只是提供一个电机在规定电
压下最大转速的参考。
• 空载电流:正常工作电压下电机不带任何负载的工作电流(单位 mA(毫安))。越好的电
机,在空载时,该值越小。
• 负载转速:正常工作电压下电机带负载的转速。
• 负载电流:负载电流是指电机拖动负载时实际检测到的定子电流数值。
• 负载力矩:正常工作电压下电机带负载的力矩(N·m(牛米))。
• 负载电流:负载电流是指电机拖动负载时实际检测到的定子电流数值。
• 堵转力矩:在电机受反向外力使其停止转动时的力矩。如果电机堵转现象经常出现,则会
损坏电机,或烧坏驱动芯片。所以大家选电机时,这是除转速外要考虑的参数。堵转时间
一长,电机温度上升的很快,这个值也会下降的很厉害。
• 堵转电流:在电机受反向外力使其停止转动时的电流,此时电流非常大,时间稍微就可能
会烧毁电机,在实际使用时应尽量避免。
四、软件
1.位置环框图
目标位置是设定的位置,通过编码器获取电机累计转动的脉冲数作为反馈,经过位置式 PID 算法调节PWM占空比输出,实现电机实际位置的控制。
2.程序框图
①初始化任务:
电机初始化:配置定时器可以输出 PWM 控制电机 ;
编码器初始化:配置定时器可以读取编码器的计数值;
定时器初始化:配置基本定时器可以产生定时中断来执行 PID 运算;
PID参数初始化:配置目标值、实际值、误差、积分项、比例增益、积分增益和微分增益相关参数。
②设定目标值,并且使能电机;
③电机转动,编码器计数反馈
④PID闭环控制:在TIM1的更新中断回调函数里进行PID计算,限制电机PWM占空比
3.代码部分
3.1 电机
/**
* @brief 电机初始化
* @param 无
* @retval 无
*/
void motor_init(void)
{
TIMx_GPIO_Config();
TIM_PWMOUTPUT_Config();
}
/**
* @brief 电机引脚初始化
* @param 无
* @retval 无
*/
static void TIMx_GPIO_Config(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
/* 定时器通道功能引脚端口时钟使能 */
PWM_TIM_CH3_GPIO_CLK();
PWM_TIM_CH4_GPIO_CLK();
/* 定时器通道1功能引脚IO初始化 */
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; /*设置输出类型*/
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;/*设置引脚速率 */
PWM_TIM_GPIO_AF_ENALBE();/*设置复用*/
GPIO_InitStruct.Pin = PWM_TIM_CH3_PIN;/*选择要控制的GPIO引脚*/
HAL_GPIO_Init(PWM_TIM_CH3_GPIO_PORT, &GPIO_InitStruct);/*调用库函数,使用上面配置的GPIO_InitStructure初始化GPIO*/
GPIO_InitStruct.Pin = PWM_TIM_CH4_PIN;
HAL_GPIO_Init(PWM_TIM_CH4_GPIO_PORT, &GPIO_InitStruct);
}
/**
* @brief 电机PWM输出初始化
* @param 无
* @retval 无
*/
static void TIM_PWMOUTPUT_Config(void)
{
TIM_OC_InitTypeDef TIM_OCInitStructure;
TIM_HandleTypeDef DCM_TimeBaseStructure;
PWM_TIM_CLK_ENABLE(); /*使能定时器*/
DCM_TimeBaseStructure.Instance = PWM_TIM;
/* 累计 TIM_Period个后产生一个更新或者中断*/
DCM_TimeBaseStructure.Init.Period = PWM_PERIOD_COUNT - 1;//当定时器从0计数到PWM_PERIOD_COUNT,即为PWM_PERIOD_COUNT+1次,为一个定时周期
DCM_TimeBaseStructure.Init.Prescaler = PWM_PRESCALER_COUNT - 1;// 设定定时器频率为=TIMxCLK/(PWM_PRESCALER_COUNT+1)
DCM_TimeBaseStructure.Init.CounterMode = TIM_COUNTERMODE_UP; /*计数方式*/
DCM_TimeBaseStructure.Init.ClockDivision=TIM_CLOCKDIVISION_DIV1; /*采样时钟分频*/
HAL_TIM_PWM_Init(&DCM_TimeBaseStructure);/*初始化定时器*/
/*PWM模式配置*/
TIM_OCInitStructure.OCMode = TIM_OCMODE_PWM1;
TIM_OCInitStructure.Pulse = 500;
TIM_OCInitStructure.OCPolarity = TIM_OCPOLARITY_HIGH;
TIM_OCInitStructure.OCNPolarity = TIM_OCPOLARITY_HIGH;
TIM_OCInitStructure.OCIdleState = TIM_OCIDLESTATE_SET;
TIM_OCInitStructure.OCNIdleState = TIM_OCNIDLESTATE_RESET;
TIM_OCInitStructure.OCFastMode = TIM_OCFAST_DISABLE;
HAL_TIM_PWM_ConfigChannel(&DCM_TimeBaseStructure, &TIM_OCInitStructure, PWM_CHANNEL_1); /*配置PWM通道*/
HAL_TIM_PWM_Start(&DCM_TimeBaseStructure,PWM_CHANNEL_1); /*开始输出PWM*/
TIM_OCInitStructure.Pulse = 0;/*配置脉宽*/
HAL_TIM_PWM_ConfigChannel(&DCM_TimeBaseStructure, &TIM_OCInitStructure, PWM_CHANNEL_2); /*配置PWM通道*/
HAL_TIM_PWM_Start(&DCM_TimeBaseStructure,PWM_CHANNEL_2);/*开始输出PWM*/
}
/**
* @brief 使能电机
* @param 无
* @retval 无
*/
void set_motor_enable(void)
{
is_motor_en = 1;
MOTOR_FWD_ENABLE();
MOTOR_REV_ENABLE();
}
/**
* @brief 禁用电机
* @param 无
* @retval 无
*/
void set_motor_disable(void)
{
is_motor_en = 0;
MOTOR_FWD_DISABLE();
MOTOR_REV_DISABLE();
}
/**
* @brief 设置电机速度
* @param v: 速度(占空比)
* @retval 无
*/
void set_motor_speed(uint16_t v)
{
v = (v > PWM_PERIOD_COUNT) ? PWM_PERIOD_COUNT : v; // 上限处理
dutyfactor = v;
if (direction == MOTOR_FWD)
{
SET_REV_COMPAER(dutyfactor); // 设置速度
}
else
{
SET_FWD_COMPAER(dutyfactor); // 设置速度
}
}
/**
* @brief 设置电机方向
* @param 无
* @retval 无
*/
void set_motor_direction(motor_dir_t dir)
{
direction = dir;
if (direction == MOTOR_FWD)
{
SET_FWD_COMPAER(0); // 设置速度
SET_REV_COMPAER(dutyfactor); // 设置速度
}
else
{
SET_FWD_COMPAER(dutyfactor); // 设置速度
SET_REV_COMPAER(0); // 设置速度
}
}
/**
* @brief 电机位置式 PID 控制实现(定时调用)
* @param 无
* @retval 无
*/
void motor_pid_control(void)
{
if (is_motor_en == 1) // 电机在使能状态下才进行控制处理
{
float cont_val = 0; // 当前控制值
int32_t Capture_Count = 0; // 当前时刻总计数值
int32_t abs_Count = 0; // 实际量与目标值差量
/* 当前时刻总计数值 = 计数器值 + 计数溢出次数 * ENCODER_TIM_PERIOD */
Capture_Count = __HAL_TIM_GET_COUNTER(&TIM_EncoderHandle) + (Encoder_Overflow_Count * ENCODER_TIM_PERIOD);
cont_val = PID_realize(Capture_Count); // 进行 PID 计算
if (cont_val > 0) // 判断电机方向
{
set_motor_direction(MOTOR_FWD);
}
else
{
cont_val = -cont_val;
set_motor_direction(MOTOR_REV);
}
cont_val = (cont_val > PWM_MAX_PERIOD_COUNT*0.48) ? PWM_MAX_PERIOD_COUNT*0.48 : cont_val;// 速度上限处理
set_motor_speed(cont_val);// 设置 PWM 占空比
abs_Count = abs(Capture_Count - get_pid_target());
printf("%d,%f,%f,%d \n", Capture_Count, get_pid_target(),cont_val,abs_Count);//打印实际值,目标值,电机PWM,实际值与目标值差值
}
}
3.2 编码器
#define ENCODER_TOTAL_RESOLUTION (ENCODER_RESOLUTION * 4) /* 4倍频后的总分辨率 */
#define ENCODER_RESOLUTION 7 /* 编码器物理分辨率 */
#define REDUCTION_RATIO 380 /* 减速电机减速比 */
#define CIRCLE_PULSES (ENCODER_TOTAL_RESOLUTION * REDUCTION_RATIO)// 编码器一圈可以捕获的脉冲 (7 * 4)*380 = 10640
由上面的直流减速电机的相关参数我们可知直流减速电机的编码器一圈的物理脉冲数为7,电机的减速齿轮的减速比为1:380,下面的定时器编码器模式通过设置倍频来实现4倍频,所以电机转一圈总的脉冲数,即定时器能读到的脉冲数为(7 * 4)*380 = 10640。
/**
* @brief 编码器接口初始化
* @param 无
* @retval 无
*/
void Encoder_Init(void)
{
Encoder_GPIO_Init();/* 引脚初始化 */
TIM_Encoder_Init();/* 配置编码器接口 */
}
/**
* @brief 编码器接口引脚初始化
* @param 无
* @retval 无
*/
static void Encoder_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
TIM_HandleTypeDef TIM_EncoderHandle;
/* 定时器通道引脚端口时钟使能 */
ENCODER_TIM_CH1_GPIO_CLK_ENABLE();
ENCODER_TIM_CH2_GPIO_CLK_ENABLE();
ENCODER_TIM_AF_CLK_ENABLE(); /* 设置重映射 */
GPIO_InitStruct.Mode = GPIO_MODE_AF_INPUT; /* 设置输入类型 */
GPIO_InitStruct.Pull = GPIO_PULLUP; /* 设置上拉 */
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; /* 设置引脚速率 */
GPIO_InitStruct.Pin = ENCODER_TIM_CH1_PIN; /* 选择要控制的GPIO引脚 */
HAL_GPIO_Init(ENCODER_TIM_CH1_GPIO_PORT, &GPIO_InitStruct); /* 调用库函数,使用上面配置的GPIO_InitStructure初始化GPIO */
GPIO_InitStruct.Pin = ENCODER_TIM_CH2_PIN; /* 选择要控制的GPIO引脚 */
HAL_GPIO_Init(ENCODER_TIM_CH2_GPIO_PORT, &GPIO_InitStruct); /* 调用库函数,使用上面配置的GPIO_InitStructure初始化GPIO */
}
/**
* @brief 配置TIMx编码器模式
* @param 无
* @retval 无
*/
static void TIM_Encoder_Init(void)
{
TIM_Encoder_InitTypeDef Encoder_ConfigStructure;
ENCODER_TIM_CLK_ENABLE(); /* 使能编码器接口时钟 */
/* 定时器初始化设置 */
TIM_EncoderHandle.Instance = ENCODER_TIM;
TIM_EncoderHandle.Init.Prescaler = ENCODER_TIM_PRESCALER;
TIM_EncoderHandle.Init.CounterMode = TIM_COUNTERMODE_UP;
TIM_EncoderHandle.Init.Period = ENCODER_TIM_PERIOD;
TIM_EncoderHandle.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
TIM_EncoderHandle.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
Encoder_ConfigStructure.EncoderMode = ENCODER_MODE; /* 设置编码器倍频数 */
/* 编码器接口通道1设置 */
Encoder_ConfigStructure.IC1Polarity = ENCODER_IC1_POLARITY;
Encoder_ConfigStructure.IC1Selection = TIM_ICSELECTION_DIRECTTI;
Encoder_ConfigStructure.IC1Prescaler = TIM_ICPSC_DIV1;
Encoder_ConfigStructure.IC1Filter = 0;
/* 编码器接口通道2设置 */
Encoder_ConfigStructure.IC2Polarity = ENCODER_IC2_POLARITY;
Encoder_ConfigStructure.IC2Selection = TIM_ICSELECTION_DIRECTTI;
Encoder_ConfigStructure.IC2Prescaler = TIM_ICPSC_DIV1;
Encoder_ConfigStructure.IC2Filter = 0;
HAL_TIM_Encoder_Init(&TIM_EncoderHandle, &Encoder_ConfigStructure); /* 初始化编码器接口 */
__HAL_TIM_SET_COUNTER(&TIM_EncoderHandle, 0); /* 清零计数器 */
__HAL_TIM_CLEAR_IT(&TIM_EncoderHandle,TIM_IT_UPDATE); /* 清零中断标志位 */
__HAL_TIM_ENABLE_IT(&TIM_EncoderHandle,TIM_IT_UPDATE); /* 使能定时器的更新事件中断 */
__HAL_TIM_URS_ENABLE(&TIM_EncoderHandle); /* 设置更新事件请求源为:计数器溢出 */
HAL_NVIC_SetPriority(ENCODER_TIM_IRQn, 1, 0); /* 设置中断优先级 */
HAL_NVIC_EnableIRQ(ENCODER_TIM_IRQn); /* 使能定时器中断 */
HAL_TIM_Encoder_Start(&TIM_EncoderHandle, TIM_CHANNEL_ALL); /* 使能编码器接口 */
}
3.3 定时器
#define BASIC_TIM TIM1
#define BASIC_TIM_CLK_ENABLE() __HAL_RCC_TIM1_CLK_ENABLE()
#define BASIC_TIM_IRQn TIM1_UP_TIM16_IRQn
#define BASIC_TIM_IRQHandler TIM1_UP_TIM16_IRQHandler
#define BASIC_PERIOD_MS (50)//PID计算周期:50ms计算一次--频率20Hz
#define BASIC_PERIOD_COUNT (BASIC_PERIOD_MS*100)
#define BASIC_PRESCALER_COUNT (720)//100Khz
/**
* @brief 定时器1定时,50ms产生一次中断
* @param 无
* @retval 无
*/
void TIMx_Configuration(void)
{
TIMx_NVIC_Configuration();
TIM_Mode_Config();
}
/**
* @brief 定时器TIM1中断优先级配置
* @param 无
* @retval 无
*/
static void TIMx_NVIC_Configuration(void)
{
HAL_NVIC_SetPriority(BASIC_TIM_IRQn, 2,0);//设置抢占优先级,子优先级
HAL_NVIC_EnableIRQ(BASIC_TIM_IRQn); // 设置中断来源
}
static void TIM_Mode_Config(void)
{
BASIC_TIM_CLK_ENABLE();
TIM_TimeBaseStructure.Instance = BASIC_TIM;
TIM_TimeBaseStructure.Init.Period = BASIC_PERIOD_COUNT - 1;
TIM_TimeBaseStructure.Init.Prescaler = BASIC_PRESCALER_COUNT - 1;
TIM_TimeBaseStructure.Init.CounterMode = TIM_COUNTERMODE_UP; // 向上计数
TIM_TimeBaseStructure.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;// 时钟分频
HAL_TIM_Base_Init(&TIM_TimeBaseStructure);// 初始化定时器TIM1
HAL_TIM_Base_Start_IT(&TIM_TimeBaseStructure); // 开启定时器更新中断
}
3.4 PID
struct _pid
{
float target_val; //目标值
float actual_val; //实际值
float err; //定义偏差值
float err_last; //定义上一个偏差值
float Kp,Ki,Kd;//定义比例、积分、微分系数
float integral; //定义积分值
};
/**
* @brief PID参数初始化
* @note 无
* @retval 无
*/
void PID_param_init()
{
/* 初始化参数 */
pid.target_val=0.0;
pid.actual_val=0.0;
pid.err=0.0;
pid.err_last=0.0;
pid.integral=0.0;
pid.Kp = 0.00000;
pid.Ki = 0.00000;
pid.Kd = 0.00000;
}
/*
* @brief 设置目标值
* @param val 目标值
* @note 无
* @retval 无---------------
*/
void set_pid_target(float temp_val)
{
pid.target_val = temp_val;// 设置当前的目标值
}
/**
* @brief 获取目标值
* @param 无
* @note 无
* @retval 目标值
*/
float get_pid_target(void)
{
return pid.target_val;// 获取当前的目标值
}
/**
* @brief 设置比例、积分、微分系数
* @param p:比例系数 P
* @param i:积分系数 i
* @param d:微分系数 d
* @note 无
* @retval 无
*/
void set_p_i_d(float p, float i, float d)
{
pid.Kp = p; // 设置比例系数 P
pid.Ki = i; // 设置积分系数 I
pid.Kd = d; // 设置微分系数 D
}
/**
* @brief PID算法实现
* @param actual_val:实际值
* @note 无
* @retval 通过PID计算后的输出
*/
float PID_realize(float actual_val)
{
pid.err = pid.target_val - actual_val;/*计算目标值与实际值的误差*/
pid.integral += pid.err; // 误差累积
/*PID算法实现*/
pid.actual_val = pid.Kp * pid.err +
pid.Ki * pid.integral +
pid.Kd * (pid.err - pid.err_last);
pid.err_last = pid.err; /*误差传递*/
return pid.actual_val; /*返回当前实际值*/
}
3.5 串口设置
为了方便调试PID参数,调试工具我这边没有用野火的上位机,用的是VOFA+,因为不想按照野火的协议来写串口。
/*******************************************************************************
* 函数名:Moto_Task
* 描述 :Moto_Task
* 输入 :void
* 输出 :void
* 调用 :内部调用
* 备注 :电机任务
*******************************************************************************/
void Moto_Task(void)
{
uint8_t i = 0;
is_motor_en = 1;
if(uart1_rx_buf[i+3] == '1')
{
/* 增加一圈 */
target_location += CIRCLE_PULSES;
set_pid_target(target_location);
}
else
{
/* 减少一圈 */
target_location -= CIRCLE_PULSES;
set_pid_target(target_location);
}
}
/*******************************************************************************
* 函数名:Inquire_PID
* 描述 :查询PID参数
* 输入 :void
* 输出 :void
* 调用 :内部调用
* 备注 :
*******************************************************************************/
void Inquire_PID(void)
{
printf("pid.Kp = %f\r\n",pid.Kp);
printf("pid.Ki = %f\r\n",pid.Ki);
printf("pid.Kd = %f\r\n",pid.Kd);
}
/*******************************************************************************
* 函数名:Set_P
* 描述 :设置PID参数中的P值
* 输入 :void
* 输出 :void
* 调用 :内部调用
* 备注 :0.01-99.99
*******************************************************************************/
void Set_P(void)
{
uint8_t i = 0;
Store_P = AsciiToHex(uart1_rx_buf[i+3])*1000+AsciiToHex(uart1_rx_buf[i+4])*100 + AsciiToHex(uart1_rx_buf[i+5])*10 + AsciiToHex(uart1_rx_buf[i+6]);
pid.Kp = Store_P * 0.01;
printf("pid.Kp = %f\r\n",pid.Kp);
}
/*******************************************************************************
* 函数名:Set_I
* 描述 :设置PID参数中的I值
* 输入 :void
* 输出 :void
* 调用 :内部调用
* 备注 :0.01-99.99
*******************************************************************************/
void Set_I(void)
{
uint8_t i = 0;
Store_I = AsciiToHex(uart1_rx_buf[i+3])*1000+AsciiToHex(uart1_rx_buf[i+4])*100 + AsciiToHex(uart1_rx_buf[i+5])*10 + AsciiToHex(uart1_rx_buf[i+6]);
pid.Ki = Store_I * 0.01;
printf("pid.Ki = %f\r\n",pid.Ki);
}
/*******************************************************************************
* 函数名:Set_D
* 描述 :设置PID参数中的D值
* 输入 :void
* 输出 :void
* 调用 :内部调用
* 备注 :0.01-99.99
*******************************************************************************/
void Set_D(void)
{
uint8_t i = 0;
Store_D = AsciiToHex(uart1_rx_buf[i+3])*1000+AsciiToHex(uart1_rx_buf[i+4])*100 + AsciiToHex(uart1_rx_buf[i+5])*10 + AsciiToHex(uart1_rx_buf[i+6]);
pid.Kd = Store_D * 0.01;
printf("pid.Kd = %f\r\n",pid.Kd);
}
4.调试过程
我们以电机正转一圈为例,设定目标位置为脉冲数10640.
4.1 调试P值
下面的图显示的就是P值较大时的情况:
下面的图显示的就是P值较小时的情况:
总结:过大的P值可能导致系统不稳定,产生振荡,容易产生超调,即实际输出超过期望值。而较小的P值可以增加系统的稳定性。
4.2 加入D值
总结:D就像相反的方向用力,尽力抑制住振荡的变化,减少超调。
4.3 最终结果
这里我没有用到KI,只用到KP和KD。看着也挺稳定,就没有加入KI了。
5.测试结果
上电默认正转1圈,接收要正转1圈串口指令继续转动。
直流减速电机测试视频
五、总结
今天的电机位置式PID就讲到这里,代码是基于野火的例程进行修改的,感兴趣的可以自己去下载调试,感谢你的观看,谢谢!
![Logo](https://devpress.csdnimg.cn/79de2bf0b7994defa4242ef90d5513fa.jpg)
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)