以此帖记录本次LVGL的设计,本工程主要有三个部分构成:作为执行单元的机械臂,作为中间连接的树莓派,作为显示终端和控制器的粤嵌GEC6818开发板。实现的功能主要是在开发板上部署LVGL,实现对机械臂的控制,并且让控制流程尽可能的顺畅,直白,简单。
代码如下:
https://download.csdn.net/download/2203_75650990/90464078
1.机械臂
这台机械臂是一个相对独立的模块,主要是STM32F4作为主控,由6个步进电机和4个谐波减速器构成。电机的驱动由STM32F1完成,系统的通信总线协议为CAN协议。机械臂的框架主要分为:
[PC/上位机] <--UART--> [主控板] <--CAN--> [电机驱动器]
Core部分:
这个部分是位于STM32F4的主控层,主要负责接收外界的控制消息,定义消息格式,分析各种数据,给所有的电机设定参数,控制电机运动等等功能。
其代码结构为:
Project/
├── Core/ # STM32 HAL库相关(C语言)
│ ├── Inc/
│ │ └── main.h
│ └── Src/
│ └── main.c # C语言入口
│
└── Application/ # 应用层代码(C++)
├── Inc/
│ └── loop.h
└── Src/
└── loop.cpp # 包含Main函数
这里是一个标准的底层-顶层代码结构,底层为HAL库生成的代码,用于运行初始化程序,主要是使用C进行开发,由Core/Src/main.c/main()函数进行指导。而顶层的代码为C++书写,主要负责完成功能,由UserApp/main.cpp/Main()函数指导。两层通过common_inc.h向连接:
#ifndef LOOP_H
#define LOOP_H
#ifdef __cplusplus
extern "C" {
#endif
/*---------------------------- C Scope ---------------------------*/
#include "stdint-gcc.h"
void Main();
#ifdef __cplusplus
}
#endif
#endif
在main()函数中调用common_inc.h中的Main()函数即可完成应用层的使用。而其通信模组主要是通过:
// 在控制循环中发送电机控制指令
dummy.MoveJoints(dummy.targetJoints);
// 更新关节状态
dummy.UpdateJointAngles();
dummy.UpdateJointPose6D();
整块板子的所有功能由FreeRTOS统一调用,有下列任务:
ControlLoopFixUpdate (实时优先级)
核心控制任务,由Timer7(200Hz)触发
主要职责:
-
执行电机运动控制(MoveJoints)
-
更新关节状态(UpdateJointAngles)
-
更新机械臂位姿(UpdateJointPose6D)
ControlLoopUpdate (普通优先级)
命令处理任务
主要职责:
-
从命令队列读取指令
-
解析并执行PC发来的命令
OledUpdate (普通优先级)
显示更新任务
主要职责:
-
读取MPU6050数据
-
显示系统状态:
-
IMU数据
-
关节角度
-
末端位姿
-
系统运行模式
-
电机状态标志
-
RGBUpdate (普通优先级)
LED指示任务
主要职责:
-
根据系统状态控制RGB灯
-
提供视觉反馈
所有任务的数据流向:
PC命令 -> ThreadControlLoopUpdate解析 -> 设置DummyRobot参数 -> ThreadControlLoopFixUpdate执行 -> 发送电机指令/更新状态 -> ThreadOledUpdate显示状态 -> ThreadRGBUpdate指示状态
最后的通信部分也是相对比较简单,定义各自的功能即可:
基础控制指令 (!开头):
!START - 启动/使能机械臂
!STOP - 紧急停止
!HOME - 回零位
!RESET - 回休息位置
!DISABLE - 失能机械臂
!CALIBRATION - 校准零位偏移
状态查询指令 (#开头):
#GETJPOS - 获取当前关节角度
#GETLPOS - 获取当前末端位姿
#CMDMODE - 设置命令模式
电机参数设置指令 (#开头):
#SET_DCE_KP node value - 设置指定关节位置环P参数
#SET_DCE_KI node value - 设置指定关节位置环I参数
#SET_DCE_KD node value - 设置指定关节位置环D参数
#REBOOT node - 重启指定关节电机
运动控制指令:
// 关节空间运动
>j1,j2,j3,j4,j5,j6[,speed] - 顺序执行的关节运动
&j1,j2,j3,j4,j5,j6[,speed] - 可打断的关节运动
// 笛卡尔空间运动
@x,y,z,a,b,c[,speed] - 笛卡尔空间运动
Motor部分:
代码结构与Core相同都是嵌套的C++结构。电机控制主要是专注于电机转动控制,保证电机转动平稳精准,并且能接收到外部的通知:
通信处理(CAN)
主要是can通信,将can的信息通过can.c中的回调函数HAL_CAN_RxFifo0MsgPendingCallback存储到RxData数组中,然后在应用层的interface_can.cpp中调用这个数据,并且解析数据内容。
can的指令有很多种,主要的格式是ID+数据,其中ID即为回调函数中cmd = RxHeader.StdId & 0x7F;然后调用OnCanCmd(cmd, RxData, RxHeader.DLC);传入的ID,有多种指令:
基础电机控制命令 (0x00-0x0F,不存储到EEPROM)
0x01: 使能电机(切换到速度模式或停止模式) 0x02: 执行校准 0x03: 设置电流目标值 0x04: 设置速度目标值 0x05: 设置位置目标值(可选返回当前位置和完成状态) 0x06: 设置带时间的位置目标值 0x07: 设置带速度限制的位置目标值
配置参数命令 (0x10-0x1F,存储到EEPROM)
存储的意思是通过boardConfig.configStatus = CONFIG_COMMIT
触发EEPROM存储(_data[4]控制是否保存) ,主函数Main()种有监控这个值的地方:if (boardConfig.configStatus == CONFIG_COMMIT)
0x11: 设置CAN节点ID 0x12: 设置电流限制 0x13: 设置速度限制 0x14: 设置加速度 0x15: 设置当前位置为零位并保存 0x16: 设置开机自动使能 0x17-0x1A: 设置DCE控制器参数(Kp, Kv, Ki, Kd) 0x1B: 设置堵转保护
查询命令 (0x20-0x2F)
0x21: 获取当前电流 0x22: 获取当前速度 0x23: 获取当前位置 0x24: 获取零位偏移 0x25: 获取电机温度
系统命令 (0x7D-0x7F)
0x7D: 使能电机温度监控 0x7E: 擦除配置(恢复默认值) 0x7F: 系统重启
运动规划
拥有5个主要的追踪器:
// 五个主要追踪器 CurrentTracker currentTracker; // 电流追踪器 VelocityTracker velocityTracker; // 速度追踪器 PositionTracker positionTracker; // 位置追踪器 PositionInterpolator positionInterpolator; // 位置插值器 TrajectoryTracker trajectoryTracker; // 轨迹追踪器
在motion_planner.cpp/MotionPlanner::AttachConfig(Config_t* _config)函数中初始化,这些追踪器的主要作用是:
class CurrentTracker {//控制电机扭矩的平稳变化
void CalcSoftGoal(int32_t _goalCurrent) {
// 处理电流的平滑变化
int32_t deltaCurrent = _goalCurrent - trackCurrent;
// 根据目标电流和当前电流的差值,计算平滑的电流变化
if (deltaCurrent > 0) {
// 需要增加电流
CalcCurrentIntegral(currentAcc);
} else if (deltaCurrent < 0) {
// 需要减小电流
CalcCurrentIntegral(-currentAcc);
}
}
};
class VelocityTracker { // 防止速度突变,确保加减速平滑
void CalcSoftGoal(int32_t _goalVelocity) {
// 处理速度的平滑变化
int32_t deltaVelocity = _goalVelocity - trackVelocity;
// 根据目标速度和当前速度的差值,计算平滑的速度变化
if (deltaVelocity > 0) {
// 需要加速
CalcVelocityIntegral(velocityAcc);
} else if (deltaVelocity < 0) {
// 需要减速
CalcVelocityIntegral(-velocityAcc);
}
}
};
class PositionTracker {//实现位置控制
void CalcSoftGoal(int32_t _goalPosition) {
// 计算位置差值
int32_t deltaPosition = _goalPosition - trackPosition;
// 根据位置差值生成适当的速度曲线
if (deltaPosition > 0) {
// 需要正向运动
if (needDownLocation > deltaPosition) {
// 需要减速
CalcVelocityIntegral(-velocityDownAcc);
} else {
// 可以继续加速
CalcVelocityIntegral(velocityUpAcc);
}
}
// 更新位置
CalcPositionIntegral(trackVelocity);
}
};
class PositionInterpolator { // 两个位置点之间生成平滑轨迹
void CalcSoftGoal(int32_t _goalPosition) {
// 记录位置变化
recordPositionLast = recordPosition;
recordPosition = _goalPosition;
// 计算估计速度和位置
estPositionIntegral += (goalPosition - lastPosition) * CONTROL_FREQUENCY;
estVelocity = estPositionIntegral >> 6;
// 更新输出
goPosition = estPosition;
goVelocity = estVelocity;
}
};
class TrajectoryTracker { //实现复杂轨迹的跟踪,确保运动的连续性
void CalcSoftGoal(int32_t _goalPosition, int32_t _goalVelocity) {
// 计算动态加速度
dynamicVelocityAcc = calcDynamicAcc(_goalPosition, _goalVelocity);
// 处理超时情况
if (overtimeFlag) {
// 执行减速停止
CalcVelocityIntegral(-velocityDownAcc);
} else {
// 正常轨迹跟踪
CalcVelocityIntegral(dynamicVelocityAcc);
}
// 更新位置
CalcPositionIntegral(velocityNow);
}
};
以上就是所有的跟踪器的定义,也就是应用于电机运动时的工具:
跟踪器的使用方式是在interface_can.cpp中对应命令
0x05( 位置模式(MODE_COMMAND_POSITION)),精确控制到指定位置,自动生成速度曲线,使用DCE控制器,有加减速过程,主要用于关节控制。 0x04(速度模式(MODE_COMMAND_VELOCITY)),维持恒定速度,平滑加减速,使用PID控制器,不关心具体位置,适用于风扇/泵类负载。 0x03(电流模式(MODE_COMMAND_CURRENT)),直接控制电机扭矩,快速响应,最简单的控制方式,无位置速度反馈,适用于力反馈设备。
分别调用对应的CalcSoftGoal函数进行设置,比如位置模式:motionPlanner.positionTracker.CalcSoftGoal(controller->goalPosition);这样的函数,最后将参数设置到controller中,然后调用controller->CalcDceToOutput()函数执行设置。
总的流程就是:
main.cpp中调用motor.Tick20kHz();函数,这个函数中存在编码器(获取角度和速度,就是磁编码器)和电机的函数,电机的函数叫做CloseLoopControlTick,这个函数中就有设置witch (controller->modeRunning)对应的模式的位置。
不同的模式选择的控制器也不同:
case MODE_COMMAND_POSITION:
// 位置模式使用DCE控制器
controller->CalcDceToOutput(controller->softPosition, controller->softVelocity);
break;
case MODE_COMMAND_VELOCITY:
// 速度模式使用PID控制器
controller->CalcPidToOutput(controller->softVelocity);
break;
case MODE_COMMAND_CURRENT:
// 电流模式直接输出
controller->CalcCurrentToOutput(controller->softCurrent);
break;
这三种控制器各有各的优劣,使用场景不相同。
最后就是电机的底层控制FOC,其位于刚刚介绍的CalcCurrentToOutput()函数中,这个函数包含在CalcDceToOutput和CalcPidToOutput中,可以说后二者就是前者的衍生体。
FOC电机控制
FOC控制位于tb67h450_base.cpp的代码中,用于电机控制,其被上文中的控制层调用得以控制电机的运动。TB67H450是一个双H桥电机驱动器,这段代码实现了基于正弦波的FOC控制。核心函数为SetFocCurrentVector函数。
void SetFocCurrentVector(uint32_t _directionInCount, int32_t _current_mA)
{
// 1. 计算两相的角度位置
phaseB.sinMapPtr = _directionInCount & 0x000003FF; // 取模1024
phaseA.sinMapPtr = (phaseB.sinMapPtr + 256) & 0x000003FF; // A相超前B相90度
// 2. 查表获取正弦值
phaseA.sinMapData = sin_pi_m2[phaseA.sinMapPtr];
phaseB.sinMapData = sin_pi_m2[phaseB.sinMapPtr];
// 3. 计算电流幅值(DAC值转换)
uint32_t dac_reg = abs(_current_mA);
dac_reg = (uint32_t)(dac_reg * 5083) >> 12; // 电流值映射到DAC范围
// 4. 计算两相实际电流值
phaseA.dacValue12Bits = (dac_reg * abs(phaseA.sinMapData)) >> sin_pi_m2_dpiybit;
phaseB.dacValue12Bits = (dac_reg * abs(phaseB.sinMapData)) >> sin_pi_m2_dpiybit;
// 5. 设置电流幅值
SetTwoCoilsCurrent(phaseA.dacValue12Bits, phaseB.dacValue12Bits);
// 6. 设置方向(H桥控制)
// Phase A方向控制
if (phaseA.sinMapData > 0)
SetInputA(true, false); // 正向
else if (phaseA.sinMapData < 0)
SetInputA(false, true); // 反向
else
SetInputA(true, true); // 刹车
// Phase B方向控制
if (phaseB.sinMapData > 0)
SetInputB(true, false); // 正向
else if (phaseB.sinMapData < 0)
SetInputB(false, true); // 反向
else
SetInputB(true, true); // 刹车
}
机械臂模块的实现比较复杂,因为需要考虑很多现实层面的因素,为了保证机械臂运行平稳,需要增加很多额外的保障和限制,但是对于机械臂的控制相对简单。仅需要输入对应封装好的指令即可,所以接下来就是对于主控板GEC6818的程序设计。
2.GEC6818+LVGL
GEC6818这块开发板就像是正宗的Linux系统,应该是由工具直接编译而成,并非通常意义上的发行版,但是这个原生系统的好处是,不需要额外的复杂配置,全部采用Linux原生的API,不需要其他的工具链,仅需要标准的arm-Linux工具链即可开发。并且开发板的性能相对较好,运行LVGL基本不会出现卡顿,所以用来做交互页面绰绰有余。
前期准备
在进行开发之前要配置好开发板的环境,遵循正常的arm开发板的流程,因为原生的Linux中并没有apt包管理这样方便的东西,所以只能通过在更方便下载和使用工具的Linux系统上先编译代码,将编译后的可执行文件上传到开发板上,然后运行。所以采用的是虚拟机编译,上传文件到开发板上运行的模式。
首先在自己的Ubuntu虚拟机上需要安装arm-linux的交叉编译工具链,并且特定是5.4.0版本的,将工具链解压在一个位置,然后需要配置环境变量
这样就可以直接编译了,当然,LVGL有自己的MakeFile,所以这个地方的设置{CC}只是在平时编译小程序的时候可以用{CC} xxx -o xxx来编译。
工具链配置好后就是解决传输文件的问题,官方给出的用串口传输文件的速度非常慢,因此最好的方式是通过tftp传输文件数据,而tftp必须要主机与开发板处于同一网络下,其标准就是能够相互ping通。方法如下:
1.用网线将开发板连接到电脑上,在电脑上用ipconfig查询当前是哪个网络适配器连接到了开发板。(注意这里的网络适配器是什么意思,在控制面板中可以看到网络适配器,其中每一个网络适配器都标志着一个网络接口,这个接口可以是物理接口,也可以是虚拟接口,一般电脑上有多少个网口,就有多少个物理接口。而虚拟接口多是虚拟机创建的,或者来自USB网卡)
2.找到对应的网络适配器后就设置IPV4为固定IP地址,这样无论是什么设备连接到这个网口,本机的地址就固定为设置的这个地址了。
3.在开发板上设置一个相同网段下的固定IP,可以通过ifconfig eth0 192.168.1.120(例子)设置,但是这个是临时的。更常见的方法是修改网络配置文件。但是由于GEC6818并非发行版的Linux,因此并没有很方便的配置文件,所以需要修改/etc/init.d,在这个目录下新建一个脚本,里面写上想要配置的网络即可。最后记得给脚本文件权限,然后重启开发板。这个时候无论是在开发板上还是在电脑上,ping对方的IP都能收到数据了。
网络互通后,准备进行tftp的传输,tftp只是一种传输协议,想要互传文件还需要对应的应用程序,好在很多软件都能提供tftp服务器这样的功能,在PC上开启一个tftp服务器,就能与开发板连线传输了,开发板上则是使用指令:tftp 10.1.8.236 -g -r demo 即可传输文件:
部署LVGL
这一步稍微有一些困难,但是LVGL提供了强大的移植能力,去到LVGL的官方仓库下载v8的版本即可,具体的移植过程参考这篇博客:
LVGL移植到ARM开发板(GEC6818开发板)_6818 lvgl-CSDN博客
设计项目框架
这是至关重要的一步,我希望完成的功能是实现一个机器人教导与回放系统,允许用户通过图形界面控制机器人、记录动作序列,并回放这些序列。系统基于LVGL图形库和多线程架构,通过UART与外部机器人控制器通信。因此为了支撑足够多的功能,我们需要详细设计我们的代码框架:
核心架构组件
1. 页面基类 (PageBase)
PageBase
是所有页面的基础,定义了页面生命周期和基本功能:
typedef struct PageBase {
char name[MAX_NAME_LENGTH]; // 页面名称
lv_obj_t* root; // 页面根对象
// 生命周期回调
void (*onCustomAttrConfig)(struct PageBase* self);
void (*onViewLoad)(struct PageBase* self);
void (*onViewDidLoad)(struct PageBase* self);
void (*onViewWillAppear)(struct PageBase* self);
void (*onViewDidAppear)(struct PageBase* self);
void (*onViewWillDisappear)(struct PageBase* self);
void (*onViewDidDisappear)(struct PageBase* self);
void (*onViewUnload)(struct PageBase* self);
void (*onViewDidUnload)(struct PageBase* self);
} PageBase;
这种设计模拟了面向对象中的继承和多态,允许每个具体页面重写这些生命周期方法,实现自定义行为。
2. 页面工厂 (AppFactory)
页面工厂实现了对象创建的抽象,将页面类型与创建逻辑解耦:
typedef PageBase* (*PageCreatorFunc)(void);
typedef struct {
char names[MAX_PAGES][MAX_NAME_LENGTH]; // 页面名称数组
PageCreatorFunc creators[MAX_PAGES]; // 页面创建函数数组
int count; // 已注册页面数量
} AppFactory;
这种设计使用了工厂模式,允许通过名称动态创建页面对象,增强了系统的可扩展性。
3. 页面管理器 (PageManager)
页面管理器负责页面间的导航和转换,维护页面栈:
typedef struct {
struct {
char name[MAX_NAME_LENGTH]; // 页面名称
char path[MAX_NAME_LENGTH]; // 页面路径
PageBase* page; // 页面对象
} entries[MAX_PAGES]; // 页面条目
int pageCount; // 注册的页面数量
PageBase* pageStack[MAX_PAGE_STACK]; // 页面栈
int stackSize; // 栈大小
} PageManager;
这种设计实现了类似前端路由的功能,通过路径名或页面名导航到不同页面,并自动管理页面生命周期事件。
4. 资源池 (ResourcePool)
资源池集中管理系统的共享资源(字体、图像等):
void ResourcePool_Init(void);
lv_font_t* ResourcePool_GetFont(int fontId);
lv_img_dsc_t* ResourcePool_GetImage(int imageId);
这种设计实现了资源的中央管理和懒加载,避免重复加载资源,优化内存使用。在实现了基本的框架之后,就是功能设计,本次一共设计了四个页面,一个主页面和三个可跳转页面:
主页面 (DummyHome)
主页面是系统的入口点,提供导航到其他功能页面的入口,同时可以获取机械臂的位姿信息并且打印到屏幕上。
控制页面 (CtrlPage)
控制页面提供机器人的实时控制功能,主要分为三种控制模式,滑块控制各个轴,输入各个轴的角度,笛卡尔空间坐标系。
教导页面 (TeachPage)
教导页面是系统的核心功能,实现机器人动作的录制和回放。
系统信息页面 (SystemInfosPage)
系统信息页面提供系统状态和配置功能。
遇到的问题
1.图片格式问题:
在进行工程的时候,发现一张图片无论如何都加载不出来,报错Segmentation fault
这个时候我进行了很多查找,在排除是我的代码写的有问题后,我认为是不是图片过大导致堆栈溢出,经过查询发现我设置的内存大小有4M,并且还设置了5个缓冲区,与图片的大小无关。
我察觉到是图像的格式出现了问题,理论上我默认的图像都是RGB8888格式,也就是1个字节的r,g,b,Alpha通道。但是我发现,有一张图片并不是这个格式,在脚注中提示这是一个RGB5658格式的图片。于是我翻看了我lvgl的配置文件,发现我的lvgl只支持32位色深,也就是RGB8888格式的图像
因此就是这里出现了问题。紧接着我进行下一步实验,如上图所示强行修改为16位,结果发现开发板不支持显示565的图像,所以#define LV_COLOR_DEPTH 只能设置32位.
2.串口与USB通信:
因为我的机械臂是实现的USB CDC串行标准协议。这个协议的好处是速度快且适用范围广。支持标准的串行协议,因此可以将这个口连接到开发板上的OTG接口。这个接口可以动态切换主机(Host)和设备(Device)角色,可以很好的与机械臂通信。但是很遗憾,如果要使用这块板子的OTG,似乎非常复杂,因此这里我换了一种思路,使用树莓派作为数据的中转站,因为树莓派的配置我非常熟悉,可以很轻松的对串口数据进行中转。只需要在树莓派上:使用正确的波特率监听
stty -F /dev/ttyAMA0 115200 raw -echo cat /dev/ttyAMA0
这是可以查询串口是否连接正常,如果能够监听到发来的消息,则说明串口没有问题。中转代码非常简单,做一次数据传递即可.
3。运行的时候经常会出现堆栈溢出的情况
问题原因:
UI线程与工作线程的冲突:
-
LVGL(和大多数UI框架)通常要求所有UI操作都发生在主线程(UI线程)中
-
而你的回放线程是一个独立的工作线程,它直接操作了UI元素(如popup_content等)
状态不一致:
-
当回放线程运行时,它可能会修改UI元素,但同时主线程也可能在操作这些元素
-
这导致了竞态条件(race condition),即两个线程同时访问和修改共享资源
内存访问问题:
-
当线程访问已被释放或无效的UI元素时,会导致段错误
-
特别是如果主线程删除了一个UI元素,而回放线程还在尝试访问它
解决办法:
分离关注点:
-
工作线程只负责后台逻辑(获取数据、发送命令等)
-
状态更新通过全局状态(使用互斥锁保护)来传递
-
UI更新只通过安全的消息接口(如show_message)执行
减少依赖:
-
不再直接依赖特定的UI元素(如popup_content)
-
在线程开始时复制所有需要的数据,然后使用这些数据的副本
-
这样即使原始数据被修改,线程仍可以安全地工作
明确的资源管理:
-
清晰定义资源的所有权(谁创建,谁负责清理)
-
使用更安全的内存管理方式
-
确保在所有退出路径上都释放资源