# 97-Bipedal wheeled robot
**Repository Path**: yangdev89/97-bipedal-wheeled-robot
## Basic Information
- **Project Name**: 97-Bipedal wheeled robot
- **Description**: 海思MCU体验官计划-97号项目
基于海思OpenMCU的双足轮机器人系统
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2024-11-03
- **Last Updated**: 2024-11-12
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# 基于海思 MCU 的轮足机器人应用案例开发
本开源项目基于海思 3061M 生态板(由 ECBMCU201MPC(核心板)和 ECBMOTORA(电机驱动板组成)),使用双电阻采样无感 FOC 算法控制轮足机器人
> 本项目是一款基于海思 MCU 的轮足机器人应用案例,旨在探索和展示海思 MCU 在机器人领域的应用潜力。项目整体分为平衡车和轮足机器人两个阶段,目前处于第一阶段
「海思MCU平衡车」
嵌入式入门第一步-「平衡车」
探索本项目的文档 »
查看Demo
·
报告Bug
·
提出新特性
---
## 目录
- [基于海思 MCU 的轮足机器人应用案例开发](#projectname)
- [目录](#目录)
- [项目介绍](#项目介绍)
- [文件目录说明](#文件目录说明)
- [上手指南](#上手指南)
- [模型设计](#模型设计)
- [simscape 动力学仿真验证](#simscape动力学仿真验证)
- [软件指南](#软件指南)
- [「方案一」 2208 电机+AS5600 绝对式编码器+双电阻采样有感 foc 案例](#「方案一」2208电机+AS5600绝对式磁编码器+双电阻采样有感foc案例)
- [引脚配置表](#引脚配置表)
- [AS5600 绝对式磁编码器](#AS5600绝对式磁编码器)
- [「方案二」 GBM2804H/2208 电机-100T 电机+双电阻采样无感 foc 案例](#「方案二」GBM2804H/2208电机-100T电机+双电阻采样无感foc案例)
- [HC-SR04 驱动](#HC-SR04驱动)
- [mpu6050 驱动](#mpu6050驱动)
- [oled 驱动](#oled驱动)
- [串口 DMA 数据同时收发](#串口DMA数据同时收发)
- [PID 核心控制代码](#PID核心控制代码)
- [「方案三」直流减速电机+AB 相增量式霍尔编码器+TB6612](#「方案三」直流减速电机+AB相增量式霍尔编码器+TB6612)
- [TB6612 电机驱动模块](#TB6612电机驱动模块)
- [引脚配置表](#引脚配置表)
- [编码器测速核心函数](#编码器测速核心函数)
- [硬件指南](#硬件指南)
- [作者](#作者)
- [版权说明](#版权说明)
- [鸣谢](#鸣谢)
- [本项目的改进方向](#本项目的改进方向)
- [对于 MCU 的小建议](#对于MCU的小建议)
### 项目介绍
这是一个完整的轮足机器人开源项目,包含从 3D 建模设计-->动力学仿真验证-->控制程序编写-->PID 调试的所有部分,项目开发过程中使用了三种电机方案,三种方案的控制思路都是一样的,以下是控制框图:
- 使用 Autodesk Fusion 完成的轮足机器人模型设计
- 使用 simscape 完成动力学仿真
- 使用 DMA 进行 MCU1 和 MCU2 的串口数据收发
- 多传感器(超声波传感器、MPU6050、oled 屏幕、AS5600 编码器)源代码
- 基于 VOFA+的数据可视化 PID 调试程序
整个平衡车从无到有,整整经历了 1 个多月的时间,在此过程中,遇到了众多的问题和挑战,但都逐一地解决了,在此过程中我深深地意识到解决问题的能力往往比单纯的知识或技能更重要,在实际的开发中解决的问题往往会让自己更加印象深刻,通过与他人分享遇到的问题,共同探讨解决方案,最终将问题解决,将会收获到满满的成就感,激励着我勇往直前
**如果本项目有值得你参考的地方,帮助你解决了问题,那将是对我最大的鼓舞**
### 文件目录说明
```
Bipedal wheeled robot
├── Autodesk_Fusion_models/ # 内含三种平衡车三维模型
│ ├── /2208电机/ # 2208电机平衡车Fusion源模型(使用AS5600绝对式磁编码器)
│ ├── /GBM2804H-100T电机/ # GBM2804H-100T电机平衡车Fusion源模型(双电阻采样无感foc使用的电机)
│ ├── /直流减速电机/ # 直流减速电机平衡车Fusion源模型(使用AB相霍尔增量式编码器)
├── model_4_w2/ # simscape仿真源文件
├── Bill_of_Materials/ # 物料清单
├── Option_1/ #「方案一」2208电机+AS5600绝对式磁编码器+双电阻采样有感foc案例
├── Option_2/ #「方案二」GBM2804H/2208电机-100T电机+双电阻采样无感foc案例
| ├── GBM2804H-100T/ # 上位机MCU1源代码
│ │ ├── /oled/ # oled驱动源代码
│ │ │ ├── inc/ # oled相关头文件
│ │ │ ├── src/ # oled相关源文件
│ │ ├── /mpu6050/ # mpu6050驱动源代码
│ │ │ ├── inc/ # mpu6050相关头文件
│ │ │ ├── src/ # mpu6050相关源文件
│ │ ├── /VOFA/ # VOFA+驱动源代码
│ │ │ ├── inc/ # VOFA+相关头文件
│ │ │ ├── src/ # VOFA+相关源文件
| ├── GBM2804H-100T_salve/ # 下位机MCU2源代码
├── Option_3/ #「方案三」直流减速电机+AB相增量式霍尔编码器+TB6612
├── VOFA+/ # VOFA+数据可视化示例源文件
├── Images # 图片文件夹
└── README.md # 项目说明文件
```
### 上手指南
**完整开发流程:**
1、梳理需要实现的功能和需求,在 Fusion 中进行平衡车三维模型设计
2、在 simscape 中导入三维模型,并进行动力学仿真,根据仿真结构修改模型
3、确认仿真无误后,对模型进行 3D 打印
4、编写传感器(超声波传感器、MPU6050、oled 屏幕、AS5600 编码器)底层驱动
5、实现两个板子之间的串口 DMA 通信,完成数据采集,确保数据传输正确
6、使用数据可视化软件 VOFA+进行 PID 调参
7、集成 PCB 打板(正在进行)
**目前方案一和方案三尚不完善,可作为参考**
#### 模型设计
本项目的模型设计使用**Autodesk Fusion**完成,项目第一阶段(平衡车),每条腿大腿和小腿间预留了两个螺丝孔将轮腿固定,项目中使用的螺丝直径均为**M3**,在对模型进行二次修改时注意根据自己的 3D 打印机精度误差进行调整螺丝孔径,比如我使用的 3D 打印机精度为 ±0.3mm,则将所有螺丝孔径调整至**M3.3** **_(我第一次打印的模型是 M3 孔径,光上螺丝就费了不少劲,一般只有在过盈配合(比如舵机转轴与打印件的连接)时,才会打印所需要的孔径,大家注意避坑!)_**
模型中使用的舵机为**MG996R**,未进行测试是否能达到实际的需求,如需在此项目基础上做第二阶段,则需对舵机进行选型,或更换为其他电机
##### 2208 电机模型
##### GBM2804H-100T 电机模型
##### 直流减速电机模型
#### simscape 动力学仿真验证
**实现效果**
**使用方法:**
1、切换到 model_4_w2 目录下,右键 model_4_w2 文件夹,添加指定路径-->选定的文件夹及子文件夹
2、双击进入 model_4_w2 文件夹,双击打开 model_4_w2.slx 文件,点击运行按钮
**注意:**
**1、matlab 版本需要在 2023a 及以上才能正常运行**
2、如果在打开 model_4_w2.slx 文件时,出现下图中的问号
右键问号处的区域,点击封装-->编辑图标图像-->浏览,选择model_4_w2/body下的对应图片路径,即可解决
#### 软件指南
##### 「方案一」2208 电机+AS5600 绝对式编码器+双电阻采样有感 foc 案例
###### 引脚配置表
| 引脚编号 | 引脚名称 | 引脚位置 |
| :------: | :------: | :------: |
| SCL0 | GPIO4_5 | J2_21 |
| SDA0 | GPIO4_6 | J2_12 |
| UART3_RX | GPIO1_4 | J1_34 |
| UART3_TX | GPIO1_3 | J2_30 |
首先需要注意一个点,案例中默认的串口波特率是**1843200**,一般的串口调试助手不支持这个波特率显示串口数据,需要修改为**115200 或者其他常用的波特率**。
此案例中使用的是 42JSF630AS-1000 伺服电机+ABZ 三相编码器,而我是用的 2208 电机+AS5600 编码器,需要对电机参数进行修改,并与案例中 ABZ 编码器所传出的数据进行对应
需要修改的电机参数为电机极对数、相电阻、直轴交轴电感、最大电流、最大转速、供电电压、电流幅值、IF 连续运行下的峰值电流、过压保护等参数,可以参考双电阻采样无感 foc 案例中的电机参数进行修改,在此不过多赘述
###### AS5600 绝对式磁编码器
使用此编码器时需要先安装配套的磁环,固定在电机的转轴上,这也就很大程度的限制了可以安装的编码器的电机类型
此编码器通过I2C通信,输出的数据为0~4096(即电机转一圈),依据此数据,使用定时器获取目前的数据,将两个定时器的数据相减,除以定时器的计时周期,即可得到转速信息
```c
/**
* @brief 读取AS5600传感器的原始角度值。
* @retval 返回原始角度值。
*/
void AS5600_RawAngleGet(__UINT16_TYPE__ *raw_Angle_) {
__UINT8_TYPE__ buffer[2] = {0}; // 创建一个缓冲区用于存储读取的数据
__UINT8_TYPE__ raw_angle_register = AS5600_RAW_ANGLE_REGISTER; // 获取原始角度寄存器的地址
i2cWrite(AS5600_WRITE_ADDR, &raw_angle_register, 1); // 向传感器发送寄存器地址
i2cRead(AS5600_READ_ADDR, buffer, 2); // 从传感器读取数据
raw_Angle = ((__UINT16_TYPE__)buffer[0] << 8) | (__UINT16_TYPE__)buffer[1]; // 将读取的数据组合成原始角度值
*raw_Angle_ = low_pass_filter( raw_Angle);
}
/**
* @brief 读取AS5600传感器的角度值,并转换为浮点数表示的角度。
* @param raw_Angle_ 指向存储原始角度值的变量的指针
* @param motor_Angle_ 指向存储计算后电机角度值的变量的指针
*/
void AS5600_AngleGet(__UINT16_TYPE__ *raw_Angle_, double *motor_Angle_) {
static __UINT16_TYPE__ angle_data_prev = 0; // 上一次的角度数据
static double full_rotation_offset = 0; // 存储完整旋转的偏移量
// 计算当前角度与上一次读取的角度之间的差值
__INT32_TYPE__ d_angle = (__INT32_TYPE__)*raw_Angle_ - (__INT32_TYPE__)angle_data_prev;
// 如果差值大于0.8倍的分辨率,则认为传感器已经完成了一次完整的旋转
if (abs(d_angle) > (0.8 * AS5600_RESOLUTION)) {
// 根据差值的正负,增加或减少2π到完整旋转偏移量中
full_rotation_offset += (d_angle > 0 ? -_2PI : _2PI);
}
angle_data_prev = *raw_Angle_; // 更新上一次读取的角度值为当前值
double raw_angle_rad = (*raw_Angle_ / (double)AS5600_RESOLUTION) * _2PI; // 将原始角度值转换为0到2π的范围
double normalized_angle_rad = fmod(raw_angle_rad + _PI, _2PI) - _PI; // 将0到2π的范围转换为-π到π的范围
*motor_Angle_ = full_rotation_offset + normalized_angle_rad; // 计算并返回实际的角度值,包括完整旋转的偏移量
}
/**
* @brief 计算电机的转速(RPM)并更新上一次的角度值。
* @param raw_Angle 当前的原始角度值。
* @retval 无返回值(void)。
*/
void AS5600_SpeedGet(__UINT16_TYPE__ *raw_Angle_ ,double *speed_RPM_) {
static __UINT16_TYPE__ last_raw_Angle = 0; // 上一次读取的原始角度
delta_Count = (*raw_Angle_ >= last_raw_Angle) ? \
((*raw_Angle_ - last_raw_Angle <= 2048) ? (*raw_Angle_ - last_raw_Angle) : (*raw_Angle_ + 4096 - last_raw_Angle)): \
((last_raw_Angle - *raw_Angle_ <= 2048) ? (last_raw_Angle - *raw_Angle_) : (*raw_Angle_ + 4096 - last_raw_Angle));
*speed_RPM_ = (double)delta_Count * 0.73242; // speed_RPM = deta_count/4096/0.02*60;
last_raw_Angle = *raw_Angle_; // 更新上一次的原始角度
}
void TIMER2_InterruptProcess(void *handle)
{
BASE_FUNC_UNUSED(handle);
AS5600_RawAngleGet(&raw_Angle);
AS5600_AngleGet(&raw_Angle,&motor_Angle);
AS5600_SpeedGet(&raw_Angle ,&speed_RPM);
// volatile __UINT16_TYPE__ last_raw_Angle_ = 0;
// 检查编码器值是否从最大跳变到最小,即经过了零点
if (last_raw_Angle > 2048 && raw_Angle < 2048) {
// 如果编码器值从高到低跨越了中间点,增加一圈
g_mc.motorSpinPos++;
}
else if (last_raw_Angle < 2048 && raw_Angle > 2048) {
// 如果编码器值从低到高跨越了中间点,减少一圈
g_mc.motorSpinPos++;
}
if(g_mc.motorSpinPos == 50)
{
g_mc.motorSpinPos = 0;
}
// 更新上次的编码器值
last_raw_Angle = raw_Angle;
}
```
获取完数据之后,将 AS5600 输出的数据与原案例中的数据进行对应,主要是将电机的转速、角度、转过的圈数与两个函数中输出的数值相对应
当一切就绪之后启动电机,发现电机转两圈就不转了,与薛老师请教之后,目前还暂时未解决,了解到一块板子只能驱动一个直流无刷电机之后,又下单了一块板子,没过多久板子出现问题无法输出三相 PWM,无奈选择了方案三,并同时又购买了一块板子和两个案例中使用的 GBM2804H-100T 电机
##### 「方案二」GBM2804H/2208 电机-100T 电机+双电阻采样无感 foc 案例
使用 I2C 一主多从,MCU1 作为主机,向从机(oled、mpu6050)读写数据
MCU1:上位机(主控、PID 核心控制程序、接收下位机转速、输出 q 轴电流)
MCU2:下位机(传输电机转速、接收 q 轴电流)
使用 DMA 串口通信 MCU1 作为上位机,MCU2 作为从机发送电机转速数据给主机 MCU1
**串级 PID 原理**
为什么使用串级 PID:**简单来说,速度值越大,倾角越大**
**实现路径**
MCU1 为主控,结合两电机的速度值通过速度环 PID 输出结果,结果传输给直立环作为目标值,通过 mpu6050 得到的 pitch 角度反馈目前的倾角数据,通过直立环 PID 输出 q 轴电流,从而达到直立
###### 引脚配置表
上位机 MCU1:
| 引脚编号 | 引脚名称 | 引脚位置 |
| :------: | :------: | :------: |
| SCL0 | GPIO4_5 | J2_21 |
| SDA0 | GPIO4_6 | J2_12 |
| UART3_RX | GPIO1_4 | J1_34 |
| UART3_TX | GPIO1_3 | J2_30 |
| Echo | GPIO5_3 | J1_37 |
| Trig | GPIO3_5 | J1_38 |
| 5V | / | J1_17 |
下位机 MCU2:
| 引脚编号 | 引脚名称 | 引脚位置 |
| :------: | :------: | :------: |
| UART3_RX | GPIO1_4 | J1_34 |
| UART3_TX | GPIO1_3 | J2_30 |
###### HC-SR04 驱动
**实现效果**
**超声波传感器原理:**
给 HC-SR04 的 Trig 引脚发送长达 10us 的 TTL 脉冲,模块内部进行测距,测距的结果通过回响信号传达,回响的 TTL 电平信号时间即是超声波从 HC-SR04 模块发出,触碰到障碍物后返回到 HC-SR04 模块的时间总和,将回响电平的时间/340/2 得到以米为单位的测距结果
**代码逻辑:**
给 Trig 高电平作为触发信号,将 ECHO 引脚设置为双边沿触发,使用外部中断读取 ECHO 引脚是否为高电平,若为高电平,则记下定时器的当前值,检测到为低电平的时候,获取当前计数器值,计算两次计数器的差值,差值*计数频率*分频系数*340/2*100,得到以 cm 为单位的数据
```c
// 计触发中断函数
void Echo_CallbackFunc(void *param)
{
if(HAL_GPIO_GetPinValue(&Echo_HANDLE, Echo_PIN) == 1)
{
count1 = g_timer0.baseAddress->timer_value;
}
else
{
count2 = g_timer0.baseAddress->timer_value;
}
}
#define FILTER_SIZE 5 // 滤波窗口大小
// 滑动平均滤波函数
float slidingAverageFilter(float newValue) {
static float buffer[FILTER_SIZE] = {0.0}; // 存储滤波窗口内的值
static int index = 0;
static float sum = 0.0; // 当前窗口内值的和
// 从当前窗口中移除旧值
sum -= buffer[index];
buffer[index] = newValue;
sum += newValue;
// 更新索引
index = (index + 1) % FILTER_SIZE;
float filteredValue = sum / FILTER_SIZE;
// 保留两位小数并舍去多余的部分
filteredValue = (int)(filteredValue * 100) / 100.0;
// 返回滤波后的值
return filteredValue;
}
deta_count = (count1 > count2)? \
count1 - count2 : \
count1 + g_timer0.baseAddress->timer_load - 1 - count2;
distance = (float)(deta_count*0.001813339f);
// distance = (float)(deta_count/150M*16*340/2*100);
filter_distance = slidingAverageFilter(distance);
DBG_PRINTF("distance:%fcm\r\n",filter_distance);
```
###### mpu6050 驱动
**实现效果**
mpu6050内部集成了三轴MEMS陀螺仪、三轴MEMS加速度计以及一个**可扩展的数字运动处理器DMP**(Digital Motion Processor),而且还可以连接一个第三方数字传感器(如磁力计)
可以通过I2C接口输出一个**6轴信号**(接上第三方数字传感器才可以输出9轴信号)。更加方便的是,有了DMP,可以结合InvenSense公司提供的运动处理资料库,实现姿态解算。通过自带的DMP,可以通过I2C接口输出9轴融合演算的数据,大大降低了运动处理运算对操作系统的负荷,同时也降低了开发难度。简单来说,陀螺仪测的是角速度,加速度传感器测的是加速度,通过姿态解算可以得到pitch(俯仰角)、roll(滚转角)、yaw(偏航角)这3个角
下图方便更好理解三个角的含义
mpu6050 姿态解算的方式有两种:**DMP 直接输出四元数**(以减轻外围微处理器的工作负担且避免了繁琐的滤波和数据融合)、**卡尔曼滤波数据融合**
想必使用过 mpu6050 的大家都清楚,DMP 库确实很方便,通过调用内置函数,直接可以输出三个轴的旋转角度,但是前提**必须与官方库中的函数接口相对应**,只要有任何一个地方出问题将导致 dmp 无法使用
相比于卡尔曼滤波数据融合的方式,它输出的**yaw 角比较稳定**,而卡尔曼滤波数据融合的方式得到的 yaw 角会随着时间的积累,出现**零漂**,时间越长、误差越大
**dmp 移植(未成功)**
抱着试一试的心态,我开始参考 stm32 的 DMP 库的移植案例,将 dmp 库移植到 3061m 上,但是经过了多次尝试之后,一直返回错误码-5
通过调试,发现最终卡在 mpu_load_firmware 函数内,MPU6050 固件库加载问题
查找解决方案时发现大家在移植 DMP 时,MPU6050 的固件加载函数 MPU_load_firmware()卡死,未能成功初始化,无法进入 DMP 模式,需要对 I2C 的时序进行更改,尝试多次修改无果之后,转向卡尔曼滤波数据融合姿态解算
**卡尔曼滤波数据融合姿态解算**
获取到三轴的加速度和角速度之后,使用卡尔曼滤波进行数据融合矫正,成功输出正确的 pitch 角和 roll 角,进行 yaw 角度获取时由于零漂问题,时间越长,误差越大,以下是卡尔曼滤波融合代码:
```c
/* 定义卡尔曼滤波所需要的基本变量 */
KalmanFilter kf_pitch; // 俯仰角卡尔曼滤波器
KalmanFilter kf_roll; // 滚转角卡尔曼滤波器
__INT16_TYPE__ AccX, AccY, AccZ, GYROX, GYROY, GYROZ; // 加速度计和陀螺仪原始数据
float gyroXrate, gyroYrate, gyroZrate, accPitch, accRoll; // 陀螺仪角速度和加速度计计算的角度
float pitch, roll, yaw; // 俯仰角、滚转角和偏航角
void KalmanFilter_Init(KalmanFilter *kf) {
kf->Q_angle = 0.001f; // 设置角度过程噪声
kf->Q_bias = 0.003f; // 设置偏差过程噪声
kf->R_measure = 0.03f; // 设置测量噪声
kf->angle = 0.0f; // 初始化角度
kf->bias = 0.0f; // 初始化偏差
kf->P[0][0] = 0.0f; // 初始化协方差矩阵
kf->P[0][1] = 0.0f;
kf->P[1][0] = 0.0f;
kf->P[1][1] = 0.0f;
}
float KalmanFilter_Update(KalmanFilter *kf, float newAngle, float newRate, float dt)
{
kf->rate = newRate - kf->bias; // 计算去偏差后的角速度
kf->angle += dt * kf->rate; // 预测当前角度
// 更新协方差矩阵
kf->P[0][0] += dt * (dt * kf->P[1][1] - kf->P[0][1] - kf->P[1][0] + kf->Q_angle);
kf->P[0][1] -= dt * kf->P[1][1];
kf->P[1][0] -= dt * kf->P[1][1];
kf->P[1][1] += kf->Q_bias * dt;
// 计算卡尔曼增益
float S = kf->P[0][0] + kf->R_measure;
float K[2];
K[0] = kf->P[0][0] / S;
K[1] = kf->P[1][0] / S;
// 计算测量误差
float y = newAngle - kf->angle;
kf->angle += K[0] * y; // 更新角度估计
kf->bias += K[1] * y; // 更新偏差估计
// 更新协方差矩阵
float P00_temp = kf->P[0][0];
float P01_temp = kf->P[0][1];
kf->P[0][0] -= K[0] * P00_temp;
kf->P[0][1] -= K[0] * P01_temp;
kf->P[1][0] -= K[1] * P00_temp;
kf->P[1][1] -= K[1] * P01_temp;
return kf->angle; // 返回更新后的角度
}
void Kalman_Init()
{
KalmanFilter_Init(&kf_pitch); // 初始化俯仰角卡尔曼滤波器
KalmanFilter_Init(&kf_roll); // 初始化滚转角卡尔曼滤波器
}
void Kalman_Calculate()
{
MPU6050_GetData(&AccX, &AccY, &AccZ, &GYROX, &GYROY, &GYROZ); // 读取加速度计和陀螺仪数据
gyroXrate = GYROX / 131.0; // 计算X轴角速度
gyroYrate = GYROY / 131.0; // 计算Y轴角速度
gyroZrate = GYROZ / 131.0; // 计算Z轴角速度
accPitch = atan2f(AccY, AccZ) * 180 / 3.14159265358979323846; // 计算加速度计俯仰角
accRoll = atan2f(AccX, AccZ) * 180 / 3.14159265358979323846; // 计算加速度计滚转角
pitch = KalmanFilter_Update(&kf_pitch, accPitch, gyroYrate, 0.01); // 更新俯仰角
roll = KalmanFilter_Update(&kf_roll, accRoll, gyroXrate, 0.01); // 更新滚转角
}
```
###### oled 驱动
**OLED,即有机发光二极管( Organic Light Emitting Diode )**。OLED 由于同时具备自发光,不需背光源、对比度高、厚度薄、视角广、反应速度快、可用于挠曲性面板、使用温度范围广、构造及制程较简单等优异之特性,被认为是下一代的平面显示器新兴应用技术
OLED 本身是没有显存的,他的显存是依赖 SSD1306 提供的一块显存,SSD1306 显存总共为**128\*64bit**大小,SSD1306 将这些显存分成了 8 页。每页包含了 128 个字节。
0.96 寸的 oled 又叫 12864 显示屏,分辨率为 128\*64(这么多个像素点),由 128 列,64 行组成,其中 64 行又被分为 8 页,每页 8 行,我们只需**控制指定的像素点亮灭**就能够实现对应的字符、数字、汉字的显示
**目前可实现功能:显示字符串、显示任意位数浮点数、显示中文**
**实现效果**
**显示一个字符串**
```c
//显示一个字符号串
void OLED_ShowString(unsigned char x,unsigned char y,char *chr,unsigned char Char_Size)
{
unsigned char j = 0;
while (chr[j] != '\0')
{
OLED_ShowChar(x, y, chr[j], Char_Size);
x += 8;
if (x > 120)
{
x = 0;
y += 2;
}
j++;
}
}
```
**任意长度浮点数**
```c
// 显示任意长度浮点数
void OLED_Showdecimal(__UINT8_TYPE__ x, __UINT8_TYPE__ y, float num, __UINT8_TYPE__ f_len, __UINT8_TYPE__ size2) {
__UINT8_TYPE__ t, temp;
__UINT8_TYPE__ enshow = 0;
int z_temp, f_temp;
int z_len = 0;
// 计算整数部分的位数
z_temp = (int)num;
if (z_temp == 0) {
z_len = 1;
} else {
while (z_temp != 0) {
z_temp /= 10;
z_len++;
}
}
// 重新计算z_temp
z_temp = (int)num;
// 整数部分
for (t = 0; t < z_len; t++) {
temp = (z_temp / oled_pow(10, z_len - t - 1)) % 10;
if (enshow == 0 && t < (z_len - 1)) {
if (temp == 0) {
OLED_ShowChar(x + (size2 / 2) * t, y, ' ', size2);
continue;
} else {
enshow = 1;
}
}
OLED_ShowChar(x + (size2 / 2) * t, y, temp + '0', size2);
}
// 小数点
OLED_ShowChar(x + (size2 / 2) * z_len, y, '.', size2);
// 小数部分
f_temp = (int)((num - (int)num) * oled_pow(10, f_len));
for (t = 0; t < f_len; t++) {
temp = (f_temp / oled_pow(10, f_len - t - 1)) % 10;
OLED_ShowChar(x + (size2 / 2) * (t + z_len) + 5, y, temp + '0', size2);
}
}
```
**显示中文**
```c
// 显示中文
void OLED_ShowCN(__UINT8_TYPE__ y, __UINT8_TYPE__ x, __UINT8_TYPE__ Num)
{
__UINT8_TYPE__ i;
__UINT8_TYPE__ wide=20;//字宽
OLED_Set_Pos(x, y); //参数1:把光标设置在第几页. 参数2:把光标设置在第几列
for (i = 0; i < wide; i++)
{
OLED_WriteData(OLED_F10x16[Num][i]); //显示上半部分内容
}
OLED_Set_Pos(x, y);
for (i = 0; i < wide; i++)
{
OLED_WriteData(OLED_F10x16[Num][i+wide]); //显示下半部分内容
}
}
```
###### 串口 DMA 数据同时收发
**实现效果**
DMA(Direct Memory Access,即直接存储器访问)
DMA 传输将数据从一个地址空间复制到另一个地址空间,**提供在外设和存储器之间或者存储器和存储器之间的高速数据传输**
我们都知道 CPU 有转移数据、计算、控制程序转移等很多功能,系统运作的核心就是 CPU,但是**CPU 无时不刻的在处理着大量的事务,但有些事情却没有那么重要**,比方说数据的复制和存储数据,如果我们把这部分的 CPU 资源拿出来,让 CPU 去处理其他**更重要的复杂计算事务**,能够更好的利用 CPU 的资源
因此:转移数据(尤其是转移大量数据)是可以不需要 CPU 参与。比如希望外设 A 的数据拷贝到外设 B,只要给两种外设提供一条数据通路,直接让数据由 A 拷贝到 B 不经过 CPU 的处理
**DMA 的作用就是实现数据的直接传输,而去掉了传统数据传输需要 CPU 寄存器参与的环节**
**下位机 MCU2 给上位机 MCU1 发送实时电机转速**
编程思路:将速度的浮点数类型数据转换为字符串数据,同时添加帧头和帧尾打包成数据包,再将数据包发送给 MCU1 进行解析
**下位机 MCU2 发送速度数据关键代码**
**_将浮点数转换为字符串数据_**
```c
// 转换为字符串函数
char* itoa(int value, char* buffer, int base) {
char* ptr = buffer;
char* start = buffer;
int sign = 0;
if (base == 10 && value < 0) {
sign = 1;
value = -value;
}
do {
int digit = value % base;
*ptr++ = (digit < 10 ? '0' + digit : 'A' + digit - 10);
} while (value /= base);
if (sign) *ptr++ = '-';
*ptr = '\0';
// 反转字符串
char* end = ptr - 1;
while (start < end) {
char temp = *start;
*start++ = *end;
*end-- = temp;
}
return buffer;
}
// 将浮点数转换为字符串
char* ftoa(float value, char* buffer, int precision) {
int integerPart = (int)value;
float fractionalPart = value - integerPart;
// 处理整数部分
itoa(integerPart, buffer, 10);
// 查找整数部分的长度
int len = strlen(buffer);
// 添加小数点
buffer[len++] = '.';
// 处理小数部分
for (int i = 0; i < precision; i++) {
fractionalPart *= 10;
int digit = (int)fractionalPart;
buffer[len++] = '0' + digit;
fractionalPart -= digit;
}
buffer[len] = '\0'; // 终止字符串
return buffer;
}
```
**_将字符串打包成数据包发送(帧头:"S",帧尾:"!"):_**
因为每次传输的电流值浮点数的整数部分位数是不确定的,所以我封装了一个函数用来**将任意长度**(加上帧头、帧尾和"\0"最多 16 个元素)的**浮点数打包成字符串类型的数据包**,实现任意长度的速度值发送
```c
// 从机传输速度值
void slave_Speed_Transmit(float speed) {
char buffer[16]; // 增加缓冲区大小以确保足够大
// 添加起始位
buffer[0] = 'S';
// 将浮点数转换为字符串
ftoa(speed, buffer + 1, 3); // 设置小数点后三位精度
// 计算字符串的长度
int length = strlen(buffer);
// 添加结束符
buffer[length++] = '!';
// 确保字符串以空字符终止
buffer[length] = '\0';
// 发送字符串
HAL_UART_WriteDMA(&g_uart3, (__UINT8_TYPE__ *)buffer, length);
}
```
**上位机 MCU1 接收数据包,解析出速度字符串,并转换为浮点数,存入变量中**
但是在数据发送过程中出现了**数据包顺序的错乱**,导致无法正确解析出速度值
所以我的做法是**使用一个比发送的数据长度更长的数组来接收数据包,从其中提取出正确顺序的数据包,再进行数据解析和存储**
下图中 Received data 是接收到的数据包,Extracted packet 是提取出的正确数据包,read_finish 打印的是成功解析后的速度浮点数值
以下是对应的数据包处理函数
```c
void ProcessReceivedData(char *data) {
char *start, *end; // 定义指针用于查找起始和结束标志
char tempBuffer[16]; // 临时缓冲区,用于存储提取出的数据包
// 打印接收到的所有数据
// DBG_PRINTF("Received data: %s\n", data);
// 在数据中查找完整的数据包
while ((start = strstr(data, "S")) != NULL) { // 查找起始标志 'S'
end = strstr(start, "!"); // 查找结束标志 '!'
if (end != NULL) { // 如果找到了结束标志
unsigned int len = end - start + 1; // 计算数据包的长度
if (len <= sizeof(tempBuffer)) { // 检查数据包长度是否在允许范围内
strncpy(tempBuffer, start, len); // 将数据包复制到临时缓冲区
tempBuffer[len] = '\0'; // 确保字符串以空字符终止
// 打印提取出的数据包
// DBG_PRINTF("Extracted packet: %s\n", tempBuffer);
// 提取速度信息
char *endPtr;
float speed = strtof(tempBuffer + 1, &endPtr); // 将字符串转换为浮点数
if (endPtr > tempBuffer + 1) { // 检查转换是否成功
receivedSpeed = speed; // 存储提取出的速度信息
// DBG_PRINTF("read_finish: %f\n", receivedSpeed); // 打印提取出的速度信息
} else {
DBG_PRINTF("Error: Invalid float format\n"); // 打印错误信息
}
}
// 移动指针,继续查找下一个数据包
data = end + 1;
} else {
break; // 如果没有找到结束标志,退出循环
}
}
}
```
**上位机 MCU1 给下位机 MCU2 发送经过串级 PID 计算之后的 q 轴电流**
编程思路:将电流的浮点数类型数据转换为字符串数据,同时添加帧头和帧尾打包成数据包,再将数据包发送给 MCU2 进行解析
流程和上述的速度值收发的过程一致,这里不过多赘述唯一的区别就是发送端换成了上位机 MCU1,接收端换成了 MCU2
在 q 轴电流传输过程中也存在**数据包顺序的错乱**的问题,我的解决方案也是**和上述的速度处理一样**
###### PID 核心控制代码
编程思路:
目标 Pitch 角度=PID(遥控输入期望速度-现在的轮速)
角度反馈输出量=PID(目标 Pitch 角度-陀螺仪测量 Pitch 角度)
左轮输出 q 轴电流 = 角度反馈输出量
右轮输出 q 轴电流 = 角度反馈输出量
上位机MCU1计算PID并输出
```c
float PI_Exec(PID_Handle *pidHandle)
{
// 断言参数 pidHandle 不为空
MCS_ASSERT_PARAM(pidHandle != NULL);
// 比例项计算
float p = pidHandle->kp * pidHandle->error;
// 积分项计算
float i = pidHandle->ki * pidHandle->ts * pidHandle->error + pidHandle->integral;
// 对积分项进行钳位
i = Clamp(i, pidHandle->upperLimit, pidHandle->lowerLimit);
// 更新积分项
pidHandle->integral = i;
// 静态钳位和输出计算
float val = p + i + pidHandle->feedforward;
// 对最终输出进行钳位
float out = Clamp(val, pidHandle->upperLimit, pidHandle->lowerLimit);
// 直立环
angle_target_pitch = out;
motor_output_left = angle_P * ((pitch - angle_pitch_offset) - angle_target_pitch) + angle_D * gyroYrate; //+ angle_D * mpu.getGyroY()
motor_output_right = angle_P * ((pitch - angle_pitch_offset) - angle_target_pitch) + angle_D * gyroYrate;
angle_last_pitch = pitch;
// 返回最终的PI控制输出
return out;
}
```
下位机MCU2接收上位机传来的q轴电流数据
``` c
float SPDCTRL_Exec(SPDCTRL_Handle *spdHandle, float spdTarget, float spdFbk)
{
MCS_ASSERT_PARAM(spdHandle != NULL);
float iqRef;
/* Speed error calculation */
spdHandle->spdPi.error = spdTarget - spdFbk;
/* speed controller pid calculation */
iqRef = PI_Exec(&spdHandle->spdPi);
iqRef = receivedCurrent;
return iqRef;
}
```
##### 「方案三」直流减速电机+AB 相增量式霍尔编码器+TB6612
由于过程中的操作不当导致 MCU 损坏,而一个 MCU 板子只能驱动一个电机,于是将方向转向了之前做过的直流减速电机+AB 相增量式霍尔编码器,读取完电机转速之后新 MCU 板子就到了,于是没接着往下做,这里列出供大家作为参考
###### TB6612 电机驱动模块
**接线图**
###### 引脚配置表
| 引脚编号 | 引脚名称 | 引脚位置 |
| :-------------------: | :------: | :------: |
| PWMA | GPIO2_4 | J2_17 |
| AIN1 | GPIO3_1 | J2_22 |
| AIN2 | GPIO3_0 | J2_24 |
| 左电机霍尔编码器 A 相 | GPIO4_3 | J2_26 |
| 左电机霍尔编码器 B 相 | GPIO3_4 | J2_13 |
| PWMB | GPIO0_5 | J2_32 |
| BIN1 | GPIO4_3 | J2_26 |
| BIN2 | GPIO1_3 | J2_30 |
| 右电机霍尔编码器 A 相 | GPIO2_0 | J2_3 |
| 右电机霍尔编码器 B 相 | GPIO2_1 | J2_14 |
###### 编码器测速核心函数
**设置电机 PWM 占空比**
PWM 输出频率为 10Hz,通过设置参考点 A 和参考点 B 的数值为 1 和 14999,即可只通过改变 A 点的数值就可以将占空比映射到 0-1 之间,从而实现 PWM 占空比的设置,调节精度为 1/15000
```c
// 设置电机PWM占空比(0~14999)
void motor_SetPWM(int motor1, int motor2) // 14999
{
if (motor1 < 0)
{
HAL_GPIO_SetValue(&left_motor_AIN1_HANDLE, left_motor_AIN1_PIN, GPIO_LOW_LEVEL);
HAL_GPIO_SetValue(&left_motor_AIN2_HANDLE, left_motor_AIN2_PIN, GPIO_HIGH_LEVEL);
}
else
{
HAL_GPIO_SetValue(&left_motor_AIN1_HANDLE, left_motor_AIN1_PIN, GPIO_HIGH_LEVEL);
HAL_GPIO_SetValue(&left_motor_AIN2_HANDLE, left_motor_AIN2_PIN, GPIO_LOW_LEVEL);
}
HAL_GPT_GetConfig(&left_motor_GPT); // 获取GPT当前配置
left_motor_GPT.refB0.refdot = motor1; // 设置PWM高电平所占时间
HAL_GPT_Config(&left_motor_GPT); // 应用新的GPT配置
if (motor2 < 0)
{
HAL_GPIO_SetValue(&right_motor_BIN1_HANDLE, right_motor_BIN1_PIN, GPIO_LOW_LEVEL);
HAL_GPIO_SetValue(&right_motor_BIN2_HANDLE, right_motor_BIN2_PIN, GPIO_HIGH_LEVEL);
}
else
{
HAL_GPIO_SetValue(&right_motor_BIN1_HANDLE, right_motor_BIN1_PIN, GPIO_HIGH_LEVEL);
HAL_GPIO_SetValue(&right_motor_BIN2_HANDLE, right_motor_BIN2_PIN, GPIO_LOW_LEVEL);
}
HAL_GPT_GetConfig(&right_motor_GPT); // 获取GPT当前配置
right_motor_GPT.refB0.refdot = motor1; // 设置PWM高电平所占时间
HAL_GPT_Config(&right_motor_GPT); // 应用新的GPT配置
}
```
**获取电机转速**
**qdm 正交编码模块**
本来想使用 MCU 自带的 qdm 正交编码器解码模块进行速度测量,这样占用 CPU 资源会相对较少,于是根据官网中的 qdm 案例进行改写,由于使用的是**AB 相增量式霍尔编码器**,不需要使用 Z 相,在询问毛老师之后发现需要把**Z 相接地**,不然会有干扰,但在使用过程中发现数值会在实际速度和 -53687088 之间来回跳变,添加了 ABZ 三相的滤波等级之后依然并未解决
**外部中断+定时器中断**
霍尔编码器测速原理:
霍尔编码器由码盘和霍尔元件组成,将设备运动时的位移信息变成连续的脉冲信号,脉冲个数表示位移量的大小,霍尔码盘与电机主轴同轴,码盘上等分的分布有多个磁极,电机转动时,霍尔元件会输出若干个脉冲信号,利用这些脉冲信号我们可以实现电机的测速和电机转向的判断
使用外部中断获取编码器的脉冲数并进行累计,定时器每隔一个周期(如15ms)读取目前的脉冲数,并将脉冲数/电机主轴转一圈输出的脉冲数/减速比/周期时间*60,得到每分钟的转速rpm
```c
// 编码器A相中断
void left_motor_A_interrput(void *param)
{
GPIO_Handle *handle = (GPIO_Handle *)param;
BASE_FUNC_UNUSED(handle);
if (HAL_GPIO_GetPinValue(&left_motor_A_HANDLE, left_motor_A_PIN) == 0)
{
if (HAL_GPIO_GetPinValue(&left_motor_B_HANDLE, left_motor_B_PIN) == 0)
pluse++;
else
pluse--;
}
else
{
if (HAL_GPIO_GetPinValue(&left_motor_B_HANDLE, left_motor_B_PIN) == 0)
pluse--;
else
pluse++;
}
}
// 编码器B相中断
void left_motor_B_interrput(void *param)
{
GPIO_Handle *handle = (GPIO_Handle *)param;
BASE_FUNC_UNUSED(handle);
if (HAL_GPIO_GetPinValue(&left_motor_B_HANDLE, left_motor_B_PIN) == 1)
{
if (HAL_GPIO_GetPinValue(&left_motor_A_HANDLE, left_motor_A_PIN) == 0)
pluse++;
else
pluse--;
}
else
{
if (HAL_GPIO_GetPinValue(&left_motor_A_HANDLE, left_motor_A_PIN) == 0)
pluse--;
else
pluse++;
}
}
// 获取速度
void get_Speed_timer0_interrupt(void *handle)
{
BASE_FUNC_UNUSED(handle);
/* USER CODE BEGIN get_Speed_timer0_interrupt */
pluse = (short)pluse;
// speed = pluse/16/30/15ms*60;
speed = pluse * 8.3;
pluse = 0;
/* USER CODE END get_Speed_timer0_interrupt */
}
```
#### VOFA+数据可视化 PID 调试
这里提供一个基础案例,方便大家根据自己想要实现的功能进行二次修改,所能实现的功能为:
使用 UART0 的 DMA 同时收发数据,将 PID 三个数值传输到 PC,实时可视化展示,通过调节左边三个控件,将所调节的当前数值发送回 MCU,实现闭环
**使用方法:**
将 VOFA+文件夹中的**两个 json 文件**直接导入到 VOFA+中,烧录 VOFA+中的**UART_DMA_VOFA**工程文件即可
**MCU 接收数据**
VOFA+给 MCU 发送的数据包格式为"P1=%.3f!###"
P 为帧头,!为帧尾,通过判断帧头的字母(P、I、D)三个字符串,对 P、I、D 三个参数进行分别赋值,判断 P 后的数字对左电机或者右电机进行赋值,###为占位符
因为每次所传输的参数长度的整数位不固定,可能是一位、两位或三位整数,为了保证一次传输的数据能够触发 DMA 接收完成中断添加的占位符,也算是间接完成了**串口不定长数据的接收**
以下是MCU对VOFA+发送过来的数据包进行解析的代码:
```c
// 解析接收到的数据,提取PID参数值
float VOFA_Get_Data(__UINT8_TYPE__ *DataBuff,__UINT8_TYPE__ DataBuff_Size)
{
__UINT8_TYPE__ data_Start_Num = 0; // 数据开始位
__UINT8_TYPE__ data_End_Num = 0; // 数据结束位
__UINT8_TYPE__ data_Num = 0; // 数据位
__UINT8_TYPE__ minus_Flag = 0; // 标志位,判断是否为负数
float data_return = 0.000f; // 存储解析得到的数据
// 遍历DataBuff数组,查找等号'='和感叹号'!'的位置
for(__UINT8_TYPE__ i = 0; i < DataBuff_Size; i++)
{
DBG_PRINTF("DataBuff[%d]: %c\r\n", i, DataBuff[i]);
// 记录等号'='位置,并将数据起始位置设为等号后一位
if(DataBuff[i] == '='){
data_Start_Num = i + 1; // 定位到数据起始位
DBG_PRINTF("\r\ndata_Start_Num: %d\r\n", data_Start_Num);
}
// 记录感叹号'!'位置,并结束查找
if(DataBuff[i] == '!'){
data_End_Num = i - 1; // 定位到数据结束位
DBG_PRINTF("\r\ndata_End_Num: %d\r\n", data_End_Num);
break;
}
}
// 检查数据起始位是否为负号'-'
if(DataBuff[data_Start_Num] == '-'){
data_Start_Num += 1; // 如果是负数,将数据起始位置后移一位到实际数据位
minus_Flag = 1; // 设置负数标志位
}
data_Num = data_End_Num - data_Start_Num + 1; // 计算数据位数
DBG_PRINTF("data_Num: %d\r\n", data_Num);
if(data_Num-1 == 4){
// 解析4位数据,格式为:x.xxx
data_return = (DataBuff[data_Start_Num] - 48) +
(DataBuff[data_Start_Num + 2] - 48) * 0.1f +
(DataBuff[data_Start_Num + 3] - 48) * 0.01f +
(DataBuff[data_Start_Num + 4] - 48) * 0.001f;
}
else if(data_Num-1 == 5){
// 解析5位数据,格式为:xx.xxx
data_return = (DataBuff[data_Start_Num] - 48) * 10 +
(DataBuff[data_Start_Num + 1] - 48) +
(DataBuff[data_Start_Num + 3] - 48) * 0.1f +
(DataBuff[data_Start_Num + 4] - 48) * 0.01f +
(DataBuff[data_Start_Num + 5] - 48) * 0.001f;
}
else if (data_Num - 1 == 6){
// 解析6位数据,格式为:xxx.xxx
data_return = (DataBuff[data_Start_Num] - 48) * 100 +
(DataBuff[data_Start_Num + 1] - 48) * 10 +
(DataBuff[data_Start_Num + 2] - 48) +
(DataBuff[data_Start_Num + 4] - 48) * 0.1f +
(DataBuff[data_Start_Num + 5] - 48) * 0.01f +
(DataBuff[data_Start_Num + 6] - 48) * 0.001f;
}
if(minus_Flag == 1){
data_return = -data_return; // 如果数据是负数,将结果取反
}
return data_return; // 返回解析得到的数据
}
MotorController motor_left; // 声明
MotorController motor_right; // 声明
//根据串口信息进行PID调参
void VOFA_PID_Adjust(__UINT8_TYPE__ *DataBuff,__UINT8_TYPE__ Motor_n)
{
float data_Get = 0.000f;
data_Get = VOFA_Get_Data(DataBuff,receive_Length); // 存放接收到的数据
// DBG_PRINTF("\r\ndata=%f\r\n",data_Get);
MotorController *motor;
// 根据Motor_n选择左右电机
if (Motor_n == 1) { // 左边电机
motor = &motor_left;
} else if (Motor_n == 2) { // 右边电机
motor = &motor_right;
} else {
return;
}
if (DataBuff[0] == 'P' && DataBuff[1] == '1') { // 位置环P
motor->position.kp = data_Get;
// DBG_PRINTF("MOTOR_P1: %f\r\n", motor->position.kp);
} else if (DataBuff[0] == 'I' && DataBuff[1] == '1') { // 位置环I
motor->position.ki = data_Get;
DBG_PRINTF("MOTOR_I1: %f\r\n", motor->position.ki);
} else if (DataBuff[0] == 'D' && DataBuff[1] == '1') { // 位置环D
motor->position.kd = data_Get;
DBG_PRINTF("MOTOR_D1: %f\r\n", motor->position.kd);
}
// else if (DataBuff[0] == 'P' && DataBuff[1] == '2') { // 速度环P
// motor->speed.kp = data_Get;
// } else if (DataBuff[0] == 'I' && DataBuff[1] == '2') { // 速度环I
// motor->speed.ki = data_Get;
// } else if (DataBuff[0] == 'D' && DataBuff[1] == '2') { // 速度环D
// motor->speed.kd = data_Get;
// } else if ((DataBuff[0] == 'S' && DataBuff[1] == 'p') && DataBuff[2] == 'e') { // 目标速度
// motor->target_speed = data_Get;
// } else if ((DataBuff[0] == 'P' && DataBuff[1] == 'o') && DataBuff[2] == 's') { // 目标位置
// motor->target_position = data_Get;
// }
}
```
**MCU 发送数据**
发送的数据需要遵守 VOFA+的数据格式才能被正确接收到,每次需要在发送的数据诸侯添加结束的帧尾比较繁琐
为此我封装了一个函数,调用此函数即可实现数据的发送,传入的参数为需要发送的三个浮点数变量,目前这个函数支持三个浮点数变量的同时传输,大家可以根据需要自行修改
```c
void UploadData_vofa(float data_1,float data_2,float data_3)
{
// 存储需要显示的数据,帧尾
static __UINT8_TYPE__ tempData[16] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0x80,0x7F};
static float temp[3];
// 将变量data1的值赋给temp数组的第一个元素
temp[0] = data_1;
temp[1] = data_2;
temp[2] = data_3;
// 使用memcpy函数将temp数组的内容复制到tempData数组中。这里假设temp数组的总大小不会超过tempData数组的大小。
memcpy(tempData, (__UINT8_TYPE__ *)&temp, sizeof(temp));
HAL_UART_WriteDMA(&g_uart0, (__UINT8_TYPE__ *)tempData, 16);
}
```
### 作者
Gitee: [李承津](https://gitee.com/nsdyhn) [杨阳](https://gitee.com/a-yang-studies-hard)
### 版权说明
该项目签署了 MIT 授权许可,详情请参阅 [LICENSE.txt](https://github.com/shaojintian/Best_README_template/blob/master/LICENSE.txt)
### 特别鸣谢 「梅科尔工作室」
初入大学的我,既是一个懵懂小白,更是一张白纸,在梅科尔工作室敲开了新世界的大门,三大基础培训中的初恋开发板 Arduino 更是深深地激发起了我对嵌入式的兴趣,从刚开始的 Arduino 到 ESP32、STM32、HI3861、昇腾、HI3061M,加上这三年时间的积淀也学习到了三维建模、动力学仿真的知识,具备了单人的项目开发能力,将这一张白纸逐渐填满,回过头来我甚至都不敢想象会自己能做到如此
感谢工作室对我的信任,李老师和学长学姐们三年以来对我的无私培养
同时,感谢毛家宝老师和薛嘉恺老师的耐心指导
在梅科尔工作室,遇见了更好的自己!!!
### 本项目的改进方向
下位机 MCU2 的资源未得到充分利用,主控基本是在上位机 MCU1,资源占用比较多
### 对于MCU的小建议
1、强烈建议在单块 MCU 板子上再增加一个三相电机接口
2、封装一个电机反转的函数,可以实现在不改变 UVW 接线顺序的情况下反转电机
3、对串口监视器进行优化,大多数情况下都会没有数值打印出来,只能换成其他的串口调试调试助手