基于粤嵌GEC6818的LVGL开发

        以此帖记录本次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)

  • 在线程开始时复制所有需要的数据,然后使用这些数据的副本

  • 这样即使原始数据被修改,线程仍可以安全地工作

明确的资源管理

  • 清晰定义资源的所有权(谁创建,谁负责清理)

  • 使用更安全的内存管理方式

  • 确保在所有退出路径上都释放资源

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值