HoloCubic小电视开发

发布于 2024-08-31  60 次阅读


简介
本项目主控选型为STM32。电路设计与焊接、外壳3D建模、软件开发从零开始,最终完成了一个Holocubic桌面站。实现了获取天气、时间的功能,且内部实现了多个APP,包括定时、图库、电脑投影、系统设置。涉及Altium Designer电路设计、立创EDA、fusion360外壳设计、STM32、DMP库、LVGL图形库等。项目灵感来源于B站up主稚晖君。

本项目开源,github开源链接:https://github.com/lihuayao1/STM32-Holocubic

二、硬件设计

该项目的电路设计分为两块电路板,一块是搭载STM32的主控板,另一块是粘在1.3寸IPS显示屏背后的屏幕转接板。

1.设计思路
(1)器件选型

主控选型:常见的MCU选择有51系列、ESP系列、STM32系列等。本项目需处理大量图像,移植图形库,且功能较多,需要联网,51系列的硬件资源不足,故淘汰。更优秀的方案为:方案一、STM32(主控)+ESP8266(联网);方案二、ESP32(主控+联网)。
本项目采用了方案一。由于屏幕采用的是240x240的彩色屏幕,每个像素点由16位数据(2个字节,RGB565)组成,故屏幕一帧需占用240x240x2=115200个字节,已超过100KB。虽然LVGL图形库不要求主控的RAM能够存放一帧图像,但很多时候,为确保画面不卡顿,主控的RAM空间应尽可能大,故STM32F1系列无法选用,可考虑STM32F4系列。
考虑到SPI通信速率与RAM容量的需求,本项目采用了STM32F407VET6作为主控芯片。该芯片RAM为192KB(其中64KB为CCMRAM,无法配合外设工作,但可分配给LVGL计算图像),ROM为512K。SPI1最快42M,SPI2最快21M,可分别用于控制显示屏与FLASH。

ESP8266:本项目采用的型号为ESP8266-12E。获取天气可以通过连接心知天气等平台,发送对应命令即可获取信息。获取时间可以采用发送AT+CIPSNTPCFG和AT+CIPSNTPTIME命令的方式。若ESP无法识别上述命令,则可能需要更新ESP内部固件,可通过ESP官方的flash_download_tool软件更新固件。

1.3寸IPS彩屏:用于画面显示,本项目采用的是中景园的1.3寸显示屏,插接式。该显示屏支持SPI或并口控制。其中,并口采用的是8080协议,作者本人已通过操纵IO引脚模拟该协议,并测试速度,发现屏幕刷新速度远不如SPI+DMA,故最后采用了SPI+DMA的方式控制屏幕。考虑到MCU片内有FSMC外设,通过FSMC模拟8080协议,速度应远高于SPI+DMA,但软件开发部分较为复杂,故暂时搁置该方式。电路中已设计FSMC连接,后续优化软件部分时,可以考虑切换控屏方式为FSMC+8080。

MPU6050:用于姿态检测,QFN24封装(焊接较麻烦,建议用风枪)。本项目的交互方式是摇晃,故需加入该传感器。将传感器传回的原始数据(加速度与角速度)经过姿态解算后可获得姿态角(俯仰、横滚、偏航)。通过检测俯仰角即可判断holocubic的上、下摇晃,检测横滚角即可判断holocubic的左、右摇晃。不建议采用偏航角判断某种动作,未加入磁力计的情况下,偏航角会有漂移情况,可能导致动作误判。

FLASH:本项目采用的型号为W25Q64,常用的SPI-FLASH,容量8M。内部存储了桌面GIF,天气图像,wifi信息,图库GIF等。其中图库存储的GIF每帧都是BMP格式,可直接刷新到屏幕上。其它图像存储格式为LVGL的图像格式(疑似jpg格式),需通过LVGL调度才能正常显示。

RGB彩灯:RGB彩灯内部集成了3个不同颜色的LED,注意不同颜色的LED限流电阻不同即可。该彩灯所连接的IO具有PWM功能,后续可实现呼吸灯,也可实现简单亮灭,用于提醒闹铃、系统故障等。

(2)原理图

主控板原理图:

大部分电路设计参考芯片datasheet推荐的电路即可,以下介绍一部分特殊的电路

复位电路:STM32的复位方式为低电平复位,默认情况下NRST处应为高电平。当需要复位时使NRST处变为低电平,并保持一段时间,随后恢复高电平。低电平保持的时间取决于单片机的要求。
本电路中,当系统上电时,电容充电,视为通路,电流由VCC3.3出发,经过电阻R1随后接地,故NRST处为低电平。随后,电容充电结束,视为断路,电流由VCC3.3出发,经过电阻R1,最后流入NRST,此时NRST恢复高电平,复位成功。可以通过更改电容的取值来改变低电平保持的时间,以满足不同单片机对低电平时间的要求。但在实际应用中,电子产品往往不仅在系统上电时需要复位,遇到功能异常等特殊情况时,也需要进行复位操作。所以,该复位电路中额外增加了手动复位的功能,这便是按键K1的作用。当按键K1按下时,电流由VCC3.3出发,经过电阻R1,随后流到GND,此时NRST的电位为低电平。当松开按键时,K1处相当于断路,NRST恢复高电平,手动复位成功。

指示灯:焊接完成后,烧录程序点亮D1,可以测试芯片工作。D2为电源指示灯,上电即亮。

降压电路:采用Type-C端口进行供电,需要将端口电压转换为STM32的常用工作电压3.3V,故采用AMS1117-3.3

Type-C接口:常见的供电接口可采用USB或Type-C,本项目采用Type-C接口,与手机充电口相同,无需专门配线,不区分正反,更方便。为方便PC机与holocubic通信,以实现电脑投屏、命令控制的功能,电路中添加了CH340N,可实现USB转串口,波特率最高可达2Mbps。

晶振电路:25M晶振作为STM32的HSE提供时钟源输入,在STM32的时钟函数中,将25M时钟进行M=25分频,再经过N=336倍频,P=2分频,可以得到(25M/25)*336/2=168M的SYSCLK,作为系统工作时钟。

屏幕板原理图:

电容C2用于滤除干扰,R2为下拉电阻。两个FPC座子,一个连接屏幕,另一个连接FFC排线的一端(另一端连接主控板)

电容C2用于滤除干扰,R2为下拉电阻。两个FPC座子,一个连接屏幕,另一个连接FFC排线的一端(另一端连接主控板)。

2.焊接成品

该图为焊接完成的板子,左边是已连接屏幕的屏幕板,右边是主控板。白色的是FFC排线,通过该线可连接两块板子。

安装示意图(未放棱镜,未装外壳后盖)

三、外壳设计
此处推荐个fusion360实操视频,学完后自己摸索一段时间,两三天即可完成该外壳模型建模。该内容不属于嵌入式相关技术,此处不详细阐述。
https://www.bilibili.com/video/BV19p4y147aX?share_source=copy_web

外壳设计大致流程:
1.从AD导出PCB模型,在fusion360中打开该模型
2.在fusion360中,基于PCB模型,利用拉伸工具等建模外壳
3.通过“切割实体”工具将外壳分为两部分,以便安装时放入电路板。外壳应预留螺丝孔,电路板放入后,通过螺丝固定两部分外壳。

以下为项目总模型:

四、软件设计
一、通信层:

(1)MPU6050数据获取、DMP移植
MPU6050采用IIC通信,设备地址为0x68。本项目中采用的是软件模拟IIC,IIC过程较固定,此处不赘述。完成一系列信号时序模拟后,只需调用IIC_WriteDataToIMU及IIC_ReadDataFromIMU即可读写该传感器,调用MPU6050_InterruptInit即可完成中断初始化(每当MPU6050数据采集完成,会通过拉低某个引脚通知MCU处理数据,故此处需要初始化外部中断)。以下为初始化部分代码:

void MPU6050_Init(void)
{
	IIC_PinInit();
	MPU6050_InterruptInit();
	//bit7=1时复位,bit6=1时睡眠,bit5=1时循环睡眠和唤醒,bit3=1时使能温度传感器,bit2~0选择时钟
	IIC_WriteDataToIMU(MPU6050_RA_PWR_MGMT_1, 0x00);	//取消睡眠
	//输出频率分频可得采样率。输出频率为8k,bit7~0取值为a,陀螺仪采样率 = 8k/(1+a)
	IIC_WriteDataToIMU(MPU6050_RA_SMPLRT_DIV , 0x07);	    //陀螺仪采样率,1KHz
	//等于0x00或0x07时,陀螺仪输出频率8K,其它时候1K
	IIC_WriteDataToIMU(MPU6050_RA_CONFIG , 0x06);	        //低通滤波器的设置
	//bit7~bit5为1时使能自检,bit4~bit3用于设置陀螺仪范围
	IIC_WriteDataToIMU(MPU6050_RA_GYRO_CONFIG, 0x18);     //陀螺仪自检及测量范围,典型值:0x18(不自检,2000deg/s)
	//bit7~bit5为1时使能自检,bit4~bit3用于设置加速度计范围
	IIC_WriteDataToIMU(MPU6050_RA_ACCEL_CONFIG , 0x00);	  //配置加速度传感器工作在2G模式,不自检
}

初始化完成后,可通过读取0x3b,0x43地址的数据获取加速度计与陀螺仪的原始数据。但仅得到加速度与角速度无法直接用于项目,需要将原始数据转换为常用的姿态角。故此处需要导入DMP库,DMP全称Digital Motion Process,可用于姿态解算。
此链接为其它大神移植DMP的教程:
https://www.bilibili.com/video/BV17h411p7s3?p=2&vd_source=8faa99cf91f9a2f883ecb117349e29c0

以下摘取移植DMP的核心步骤:(除核心步骤外,移植过程仍有大量坑,结合教程看更佳)
1.实现IIC的读写函数。供Sensors_I2C_WriteRegister、Sensors_I2C_ReadRegister调用
2.实现延时函数、全局ms中断。供delay_ms及get_tick_count调用
3.实现printf,便于log_i,log_e打印数据
4.外部中断初始化,在中断函数内调用gyro_data_ready_cb。(原因:IMU采集完成时,会拉低某个引脚,引起MCU外部中断,通知MCU处理数据。此处通过中断时调用gyro_data_ready_cb,提醒DMP任务处理采集的数据。)

/* The following functions must be defined for this platform:
 * i2c_write(unsigned char slave_addr, unsigned char reg_addr,
 *      unsigned char length, unsigned char const *data)
 * i2c_read(unsigned char slave_addr, unsigned char reg_addr,
 *      unsigned char length, unsigned char *data)
 * delay_ms(unsigned long num_ms)
 * get_ms(unsigned long *count)
 * reg_int_cb(void (*cb)(void), unsigned char port, unsigned char pin)
 * labs(long x)
 * fabsf(float x)
 * min(int a, int b)
 */
#define i2c_write   Sensors_I2C_WriteRegister
#define i2c_read    Sensors_I2C_ReadRegister 
#define delay_ms    delay_ms
#define get_ms      get_tick_count
#define log_i       printf
#define log_e       printf
#define min(a,b) ((a<b)?a:b)

实现上述接口后,将库中main.c的main函数删减并拆分,可以得到MPU6050_DMP_Init和MPU6050_DMP_Task函数(此步骤教程内无),分别用于初始化DMP库、DMP解算姿态。上电后,调用一次初始化,后续只需调用MPU6050_DMP_Task即可解算姿态角。以下为上述两个函数的代码:

unsigned char accel_fsr,  new_temp = 0;
unsigned long timestamp;                                  
void MPU6050_DMP_Init(void)
{  
  inv_error_t result;
	unsigned short gyro_rate, gyro_fsr;	
	struct int_param_s int_param;
  result = mpu_init(&int_param);
  if (result) {
      printf("Could not initialize gyro.\n");
  }
  result = inv_init_mpl();
  if (result) {
      printf("Could not initialize MPL.\n");
  }
	inv_enable_quaternion();
	inv_enable_9x_sensor_fusion();
	inv_enable_fast_nomot();
	inv_enable_gyro_tc();
  inv_enable_eMPL_outputs();
  result = inv_start_mpl();
  if (result == INV_ERROR_NOT_AUTHORIZED) {
      while (1) {
          printf("Not authorized.\n");
      }
  }
  if (result) {
      printf("Could not start the MPL.\n");
  }
	mpu_set_sensors(INV_XYZ_GYRO | INV_XYZ_ACCEL);
	mpu_configure_fifo(INV_XYZ_GYRO | INV_XYZ_ACCEL);
	mpu_set_sample_rate(DEFAULT_MPU_HZ);
	mpu_get_sample_rate(&gyro_rate);
	mpu_get_gyro_fsr(&gyro_fsr);
	mpu_get_accel_fsr(&accel_fsr);
	inv_set_gyro_sample_rate(1000000L / gyro_rate);
	inv_set_accel_sample_rate(1000000L / gyro_rate);
	inv_set_gyro_orientation_and_scale(
					inv_orientation_matrix_to_scalar(gyro_pdata.orientation),
					(long)gyro_fsr<<15);
	inv_set_accel_orientation_and_scale(
					inv_orientation_matrix_to_scalar(gyro_pdata.orientation),
					(long)accel_fsr<<15);
	hal.sensors = ACCEL_ON | GYRO_ON;
	hal.dmp_on = 0;
	hal.report = 0;
	hal.next_pedo_ms = 0;
	hal.next_temp_ms = 0;
  get_tick_count(&timestamp);
	dmp_load_motion_driver_firmware();
	dmp_set_orientation(inv_orientation_matrix_to_scalar(gyro_pdata.orientation));
	hal.dmp_features = DMP_FEATURE_6X_LP_QUAT | DMP_FEATURE_TAP |
			DMP_FEATURE_ANDROID_ORIENT | DMP_FEATURE_SEND_RAW_ACCEL | DMP_FEATURE_SEND_CAL_GYRO |
			DMP_FEATURE_GYRO_CAL;
	dmp_enable_feature(hal.dmp_features);
	dmp_set_fifo_rate(DEFAULT_MPU_HZ);
	mpu_set_dmp_state(1);
	hal.dmp_on = 1;
}
void MPU6050_DMP_Task()
{	
    unsigned long sensor_timestamp;
    int new_data = 0;
    get_tick_count(&timestamp);
        if (timestamp > hal.next_temp_ms) {
            hal.next_temp_ms = timestamp + TEMP_READ_MS;
            new_temp = 1;
        }	
    if (!hal.sensors || !hal.new_gyro) {
        return;
    }    
         if (hal.new_gyro && hal.dmp_on) {
            short gyro[3], accel_short[3], sensors;
            unsigned char more;
            long accel[3], quat[4], temperature;
            dmp_read_fifo(gyro, accel_short, quat, &sensor_timestamp, &sensors, &more);
            if (!more)
                hal.new_gyro = 0;
            if (sensors & INV_XYZ_GYRO) {
                inv_build_gyro(gyro, sensor_timestamp);
                new_data = 1;
                if (new_temp) {
                    new_temp = 0;
                    mpu_get_temperature(&temperature, &sensor_timestamp);
                    inv_build_temp(temperature, sensor_timestamp);
                }
            }
            if (sensors & INV_XYZ_ACCEL) {
                accel[0] = (long)accel_short[0];
                accel[1] = (long)accel_short[1];
                accel[2] = (long)accel_short[2];
                inv_build_accel(accel, 0, sensor_timestamp);
                new_data = 1;
            }
            if (sensors & INV_WXYZ_QUAT) {
                inv_build_quat(quat, 0, sensor_timestamp);
                new_data = 1;
            }
        } else if (hal.new_gyro) {
            short gyro[3], accel_short[3];
            unsigned char sensors, more;
            long accel[3], temperature;
            hal.new_gyro = 0;
            mpu_read_fifo(gyro, accel_short, &sensor_timestamp,
                &sensors, &more);
            if (more)
                hal.new_gyro = 1;
            if (sensors & INV_XYZ_GYRO) {
                inv_build_gyro(gyro, sensor_timestamp);
                new_data = 1;
                if (new_temp) {
                    new_temp = 0;
                    mpu_get_temperature(&temperature, &sensor_timestamp);
                    inv_build_temp(temperature, sensor_timestamp);
                }
            }
            if (sensors & INV_XYZ_ACCEL) {
                accel[0] = (long)accel_short[0];
                accel[1] = (long)accel_short[1];
                accel[2] = (long)accel_short[2];
                inv_build_accel(accel, 0, sensor_timestamp);
                new_data = 1;
            }	
        }
        if (new_data) {
            inv_execute_on_data();
            read_from_mpl();
        }
}

每当MPU6050_DMP_Task完成一次姿态解算,则会调用read_from_mpl函数。全局变量中有Pitch,Roll,Yaw,分别代表俯仰角、横滚角、偏航角。在read_from_mpl函数中修改上述全局变量,即代表更新了姿态角,其它函数可通过读取上述全局变量获取当前姿态。以下为该函数代码:

extern float Pitch,Roll,Yaw;
static void read_from_mpl(void)
{
    long data[9];
    int8_t accuracy;
    unsigned long timestamp;
		
		if (inv_get_sensor_type_euler(data, &accuracy,
            (inv_time_t*)&timestamp))
		{
			Pitch = data[0]*1.0 / (1 << 16);
			Roll = data[1]*1.0 / (1 << 16);
			Yaw = data[2]*1.0 / (1<<16);
		}
}

(2)SPI+DMA读写FLASH
由于SPI效率直接决定了显示屏帧率和FLASH读写速度,故应先分析SPI效率,分析过程见下图:

查询SPI控制寄存器SPI_CR1的BR[2:0]可知,SPI的通讯速率至少为fPCLK/2,而fPCLK是指SPI挂载的APB总线的效率。可得f(SPI1) = f(APB2)/2 = 42M,f(SPI2) = f(APB1)/2 = 21M。SPI1最高42MHz,SPI2最高21MHz,为保证显示效果最佳,应让效率较高的SPI1用于刷屏,效率较低的SPI2用于FLASH读取图像。
本项目中,SPI通信采用硬件SPI,配置流程较固定,此处不阐述。经测试,纯硬件SPI通信速率远不如硬件SPI+DMA,且如果不采用DMA,刷屏会占用大量的CPU时间,故最终采用了硬件SPI+DMA的方案。由于STM32F4与F1的DMA机制略有区别,此处进行简单阐述。
下图分别为为F1和F4的DMA分配情况:

由上图可知,F4系列的DMA多了“数据流”的概念。F1只需配置DMAx的某个通道即可完成初始化,而F4的每个DMA有8个数据流,每个数据流有8个通道。初始化DMA时,需指定清楚需要哪个数据流的哪个通道。读FLASH需要用到SPI2,故需要配置DMA1的数据流3通道0。刷新屏幕需要用到SPI1,故需要配置DMA2的数据流3通道3。

由于读写FLASH使用的DMA和刷屏使用的DMA配置与使用流程类似,以下只介绍如何初始化FLASH的DMA以及如何利用DMA读取FLASH。刷屏使用的DMA原理类似,不赘述。

初始化DMA1步骤:
1.开时钟、复位DMA
2.配置DMA1的数据流3通道0,其中(SPI2_BASE+0x0C)为SPI的数据寄存器地址,DMA将从该地址读取len个字节到data数组。最后四个成员DMA_FIFOMode、DMA_FIFOThreshold 、DMA_MemoryBurst 、DMA_PeripheralBurst为F4固件库特有,照下面的代码配置即可,无需理会。
3.使能DMA
4.配置DMA更新中断。(由于DMA开始传输后,CPU进行别的工作,需要中断提醒CPU传输已完成,故需要配置DMA更新中断)
初始化DMA1代码:

void SPI2DMA_Init(u8 data[],u16 len)
{
	RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1,ENABLE);
	DMA_DeInit(DMA1_Stream3);
	while(DMA_GetCmdStatus(DMA1_Stream3) == ENABLE);	//等待复位完成
	
	DMA_InitTypeDef DMA_InitStructure;
	DMA_InitStructure.DMA_Channel = DMA_Channel_0;
	DMA_InitStructure.DMA_PeripheralBaseAddr = (SPI2_BASE+0x0C);
	DMA_InitStructure.DMA_Memory0BaseAddr = (u32)data;
	DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory;
	DMA_InitStructure.DMA_BufferSize = len;
	DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
	DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
	DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
  	DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;	
	DMA_InitStructure.DMA_Mode = DMA_Mode_Normal;	//只传输一次
	DMA_InitStructure.DMA_Priority = DMA_Priority_High;
	DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable;        
 	DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_Full;
	DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single;    
  	DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;
	DMA_Init(DMA1_Stream3,&DMA_InitStructure);

	
	DMA_Cmd(DMA1_Stream3,ENABLE);
	while(DMA_GetCmdStatus(DMA1_Stream3) == DISABLE);	//等待使能完成
	
	NVIC_InitTypeDef NVIC_InitStruct;
	NVIC_InitStruct.NVIC_IRQChannel = DMA1_Stream3_IRQn;
	NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
	NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
	NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
	NVIC_Init(&NVIC_InitStruct);
}

DMA读FLASH步骤:
1.配置SPI与DMA
2.向FLASH发送读取命令(需提前关闭DMA,避免发送命令时,FLASH返回的应答误触DMA)
3.使能DMA读取返回数据(需提前关闭SPI全双工,变成只读模式,否则读取FLASH时需MCU不停发送无效数据DUMMY)
4.实现DMA中断函数(用于提醒CPU传输完成)
DMA读FLASH代码:

void FLASH_DMAReadData(u8 block,u8 sector,u8 data[],u16 len)
{
	u32 realAddress = block*65536 + sector*4096;
	
	SPI_DeInit(SPI2);
	SPI2DMA_Init(data,len);	//每次重新设置DMA之前,需把SPI时钟关掉,否则会出现漏一个字节的bug
	SPI2_Init();
	
	SPI_I2S_DMACmd(SPI2,SPI_I2S_DMAReq_Rx,DISABLE);	//避免写入地址时,从机返回DUMMY导致误触DMA
	DMA_ITConfig(DMA1_Stream3,DMA_IT_TC,DISABLE);
	
	SPI2_CS_DOWN;
	SPI_WriteReadData(0x03);	//读命令
	SPI_WriteReadData(realAddress>>16);
	SPI_WriteReadData(realAddress>>8);
	SPI_WriteReadData(realAddress);	
	SPI2->CR1 |= (1 << 10);	//关闭全双工,无需发DUMMY即可获取数据,方便DMA工作
	
	SPI_I2S_DMACmd(SPI2,SPI_I2S_DMAReq_Rx,ENABLE);	//一旦接收数据寄存器非空,产生请求
	DMA_ITConfig(DMA1_Stream3,DMA_IT_TC,ENABLE);

}

u8 DMA1_COMPLETED = 0;	//全局变量,用于标记DMA传输完成
void DMA1_Stream3_IRQHandler(void)
{
	if(DMA_GetITStatus(DMA1_Stream3,DMA_IT_TCIF3))
	{
		DMA_ClearITPendingBit(DMA1_Stream3,DMA_IT_TCIF3);
		SPI2->CR1 &= ~(1 << 10);	//恢复SPI的全双工
		SPI2_CS_UP;
		DMA1_COMPLETED = 1;
	}
}

(3)SPI+DMA读写显示屏、LVGL移植
刷屏的SPI+DMA与前面类似,不赘述。需注意的是显示屏初始化时,0x36寄存器可以设置显示屏刷新方向,正常情况下赋值为0x00即可。但经过棱镜反射后的画面是镜像的,故需要赋值为0x80。(开发过程中,还未安装棱镜,为了看起来更直观,赋值为0x00即可,最后改成0x80)

此外,需要注意的是,当赋值为0x80后,不仅刷新方向会发生改变,刷新区域也不再是行0-239,列0-239,而是行80-319,列0-239。编写屏幕开窗函数时,应注意0x36寄存器内的值,若赋值0x80,则需要对行地址加80。刷新区域见下图红框区域:

此处介绍LVGL移植,后续做的页面都是基于LVGL图形库。
其它大神的移植教程:https://blog.csdn.net/weixin_42111891/article/details/124989266

核心移植步骤:
1.复制库到工程中
2.实现LCD_Color_Fill接口
3.ms定时中断中,添加lv_tick_inc(1)语句,用于为LVGL记录节拍

移植完成后,main中调用lv_init(),lv_port_disp_init(), lv_port_indev_init()三个函数初始化LVGL。这三个函数分别用于LVGL系统初始化,LVGL显示接口初始化,LVGL输入设备注册。本项目未用到输入设备,可不调用lv_port_indev_init函数。建好页面后,只需在while循环中调用lv_task_handler,显示屏就会自动显示该页面。

LVGL库中某些文件较重要的地方:

lv_port_disp.c

 static lv_disp_draw_buf_t draw_buf_dsc_1;
 static lv_color_t buf_1[MY_DISP_HOR_RES * 60];                          /*A buffer for 10 rows*/
 lv_disp_draw_buf_init(&draw_buf_dsc_1, buf_1, NULL, MY_DISP_HOR_RES * 60);   /*Initialize the display buffer*/

此处的MY_DISP_HOR_RES60在LVGL库中原本应为MY_DISP_HOR_RES10。
用处:LVGL刷屏并不需要开辟全屏幕像素数240240那么大的显存,而是开辟MY_DISP_HOR_RES10个像素点的显存,每次将全部显存内容传到屏幕中。MY_DISP_HOR_RES的值是240,故每次在240240的屏幕中刷新24010个像素点,即10行。而整个屏幕有240行,每次刷新10行,共需要传输24次才能刷完整个屏幕。若扩大显存,可以减少传输次数,让DMA每次搬运大量数据,而不是多次启动DMA搬运少量数据。DMA擅长每次搬运大量数据,而不是多次启动,每次搬运少量数据,这样会导致传输效率慢,屏幕帧率下降,故此处显存的空间越大越好,建议整个项目开发完成后,查看剩余RAM大小,将剩余的空间分配给显存,本项目分配了240*60的显存空间。

lv_conf.h

#define LV_COLOR_16_SWAP   1

此处最好改为1。原因:图像的每一个像素是16位的,而项目中DMA传输的单位设置为了8位,画面会出现颜色异常。
例如:一张全为红色的240240的页面,应使用u16类型,大小为240240的数组存储,且数组内元素值全都为0xF800(RGB565,前5位全1代表纯红色)。由于STM32的存储方式为小端模式,即低位数据放到低地址,故内存中存放红色的顺序是00 F8,数组内存放的元素全都是00 F8,那么会导致DMA搬运时按照该顺序搬运,画面显示的颜色将会全是0x00F8。而将宏定义LV_COLOR_16_SWAP设置为1,LVGL计算图像时会把前后两个字节颠倒,一张全为红色的240*240的页面,数组内的元素值将全都为0x00F8,再由于小端模式存放,DMA传输的数据将全是0xF800,画面成功显示红色。

#  define LV_MEM_SIZE    (60U * 1024U)          /*[bytes]*/	

此处LVGL库中原本为32U1024U,本项目将其改为了60U1024U,该处的作用是定义LVGL计算图像的空间大小,越大越好。由于STM32F407VET6的RAM有64KB的CCMRAM,该内存无法与DMA等外设配合,故将其大部分空间都分配给LVGL计算图像。
使用CCMRAM需根据该资料配置keil:https://blog.csdn.net/qq_43509546/article/details/117032730

 static LV_ATTRIBUTE_LARGE_RAM_ARRAY MEM_UNIT work_mem_int[LV_MEM_SIZE / sizeof(MEM_UNIT)] __attribute__((section("ccmram")));

配置完成后,在lv_mem.c中,修改上面的代码即可将LVGL的计算空间开辟到CCMRAM。

#define LV_DISP_DEF_REFR_PERIOD     20      /*[ms]*/

该宏定义决定了LVGL刷新的最高帧率,每当LVGL超过LV_DISP_DEF_REFR_PERIOD未刷新显示界面,就会刷新显示界面。该值过高会导致画面卡顿,该值太低也没作用,因为画面传输可能达不到LV_DISP_DEF_REFR_PERIOD毫秒传输完成一帧,导致尚未传输完成就又执行一次刷新页面的任务。
若每次刷新页面是刷全屏,设置为每20ms刷一次全屏,那么页面的帧率就是50帧(50帧的效果看起来已非常流畅)。但由于显存大小有限,刷新页面并不是每次刷全屏,故达不到上述效果。但好在LVGL图形库较智能,页面部分改变的话不需要每次传输整张图像,而是传输页面被改变的部分(如全红的页面,页面下半截变为蓝色,则LVGL只会传输半张蓝图,而不是传输整张半红半蓝的图),这对提高帧率有显著的帮助。某些情况需要更新全屏页面时可能达不到理论效果50帧,但运行画面时,大部分情况不需要刷全屏,可以达到50帧的刷新效果。

#define LV_USE_PERF_MONITOR     0
#define LV_USE_MEM_MONITOR      0
#define LV_USE_REFR_DEBUG       0

这三个宏定义在项目开发过程中可能用的到。第一个与第二个宏定义置1,可在显示屏角落显示当前页面帧率与内存占用情况,对实时掌握画面刷新效果有较大帮助。第三个宏定义置1会用随机的矩形框自动填充画面,较少使用。
(4)ESP8266通信
本项目联网采用ESP8266,MCU可通过串口以字符串形式发送AT指令与其通信。串口使用较简单,此处不阐述。
联网核心步骤:
1.发送AT+CWMODE=1,将ESP8266设置为station模式(station模式:连wifi,AP模式:开热点)
2.AT+CWJAP_DEF指令连接wifi
联网代码:

u8 wifiState = WIFI_NOCONNECT;
void ESP8266_ConnectWifi(void)
{
	ESP8266_SendString("AT+CWMODE=1",50,ESP8266_NO_PROCESS_RETURN_DATA);	//station模式
	char wifiStr[200] = "AT+CWJAP_DEF=\"";	//连wifi需发送的命令
	strcat(wifiStr,cityWifiInfo.wifiName);	strcat(wifiStr,"\",\"");
	strcat(wifiStr,cityWifiInfo.wifiPassword);	strcat(wifiStr,"\"");	//将wifi账号密码连接到数组尾部
	ESP8266_SendString(wifiStr,100,ESP8266_PROCESS_RETURN_DATA);	//发送命令
	while(strstr((char*)usart2RecvData,"OK")==NULL);	//等待ESP8266返回OK,代表连接完成
	memset(usart2RecvData,0,sizeof(usart2RecvData));usart2Pos = 0;	//清除返回的数据
	wifiState = WIFI_CONNECTED;	//标记wifi连接成功
}

读取天气:
参考链接:https://blog.csdn.net/weixin_44453694/article/details/111874441
获取时间:
1.发送AT+CIPSNTPCFG=1,8设置时域
2.发送AT+CIPSNTPTIME?,随后解析ESP8266通过串口返回的字符串即可获得时间

二、应用层:
1.FLASH数据烧录
holocubic桌面站启动时会从FLASH中读取wifi信息与图像信息,故holocubic在运行正式代码前,需要提前写入FLASH内容。本项目采用了新建一个keil工程的方式烧录FLASH。在该工程中利用条件编译的方式,进行多次编译和烧录。每次编译和烧录可以向外部FLASH写入少部分数据。经过多次编译与烧录,最终将全部数据写入FLASH。具体可参照FLASH烧录工程的代码。下图为FLASH中存储内容的分布图:

2.桌面显示
GUI设计采用NXP的GUI Guider软件,该软件可以随意拖拽组件到想要的位置,实现图形化设计界面,非常直观。每设计完一个页面,可以自动生成LVGL代码,只需将生成的代码移植到工程中,即可实现GUI Guider软件中设计界面,holocubic显示屏中显示画面的效果。将界面移植到keil的具体过程较复杂,可以参考教程链接。

其它大神移植教程:https://blog.csdn.net/qq_53000374/article/details/126546396

以下简单阐述本项目中移植时需注意的知识点:
知识点1:GUI Guider初次使用较难,需慢慢尝试。GUI Guider内设计界面结束后,点击Generate Code生成代码

知识点2:将生成的代码添加到工程时,为了区分不同页面,需要改动函数名、文件名

generated目录内:
guider_customer_fonts文件夹为自定义字体,可无视。
guider_fonts文件夹为在gui guider软件中,设计该页面时用到的字体。
images文件夹为gui guider软件中,设计该页面时用到的图像。
events_init.c和events_init.h,用于事件处理,本项目中没什么用。
gui_guider.c和gui_guider.h,很重要,内有各个切换各个页面的函数和各个页面的结构体类型定义。
guider_lv_conf.h,字体相关的一些宏定义。
setup_scr_screen.c,创建界面的函数,很重要,gui_guider.c中的函数会调用setup_scr_screen.c中的函数创建界面,并将该页面作为“显示页面”。

若keil工程中尚未移植一个页面,则直接将当前gui guider生产的generated文件夹中的东西都搬到keil工程中。例如gui_guider.c等源文件,keil工程内尚未有该文件,直接拿来用即可。虽然只有一个页面时,源文件可以直接拿来用,但建议进行一些命名更改,更改规则下面会提到。

若keil工程中已移植过页面,再次添加新的页面,则不能添加新的gui_guider.c等源文件到keil中,因为keil中已有同名文件,而是应该修改gui_guider.c等源文件的内容。
做法:
第1步.若新页面用到了原keil工程没有用到的字体,则从新页面的gui guider工程中复制缺失的字体源文件到keil工程。
第2步.若新页面用到了原keil工程没有用到的图片,若缺失,则同理。
第3步.将setup_scr_screen.c复制到keil工程中,并适当改名,如改名为setup_scr_deskop.c。
第4步.将gui guider软件生成的gui_guider.c的函数setup_ui复制到keil的gui_guider.c中,并适当改名,如改名为setup_ui_deskop,表明这是创建桌面界面的函数。将gui guider软件生成的gui_guider.h中的lv_ui类型定义复制到keil的gui_guider.h中,并适当改名,如改名为lv_ui_deskop,表明这是桌面页面的结构体类型。
(除了这些地方,其它地方也会出现setup_ui、lv_ui之类的旧名字,若出现则将它们改掉,一律抛弃setup_ui、lv_ui这种指代不明的命名方式,改为setup_ui_xx、lv_ui_xx)
第5步.若第1、2步添加了新的字体或图片,则在guider_fonts.h中添加字体的声明,在gui_guider.h添加图片的声明。

项目中有大量界面,故会有很多个setup_scr_xx.c类型的文件。创建页面最终是通过setup_scr_xx.c中的代码实现,只需让其它源文件调用这些代码即可。下图为整个项目开发完成时,gui_guider文件夹内的文件。可以看见,除了setup_scr_xx.c外,其它源文件只有一份。

蓝框内的文件夹存放了用到的字体和图片,不同的setup_scr_xx.c会用到不同的字体或图片,它们都存放在蓝框的文件夹内。声明字体的头文件guider_fonts.h也在蓝框的文件夹内,声明图片在gui_guider.h在蓝框外。
红框内为不同界面的setup_scr_xx.c文件,内部分别存放了不同界面的创建函数。gui_guider.c中会调用这些文件的函数,实现界面切换。

知识点3:gui_guider.c中的函数是如何实现界面切换的?
以下以切换至桌面界面为例讲解切换原理,相关的代码见下方gui_guider.h,gui_guider.c,setup_scr_deskop.c三个文件:
gui_guider.h

typedef struct
{
	lv_obj_t *screen;
	lv_obj_t *screen_gif;
	lv_obj_t *screen_hour;
	lv_obj_t *screen_min;
	lv_obj_t *screen_second;
	lv_obj_t *screen_city;
	lv_obj_t *screen_weather;
	lv_obj_t *screen_weather_info;
	lv_obj_t *screen_date;
	lv_obj_t *screen_week;
	lv_obj_t *screen_weather_text;
	lv_obj_t *screen_temperature;
}lv_ui_deskop;

gui_guider.c

void setup_ui_deskop(lv_ui_deskop *ui){
	setup_scr_deskop(ui);
	lv_scr_load_anim(ui->screen,LV_SCR_LOAD_ANIM_FADE_ON,500,0,true);
}

setup_scr_deskop.c

void setup_scr_deskop(lv_ui_deskop *ui)
{
	//此处代码较多,不复制
}

每个页面都有属于自己的lv_ui_xx类型,setup_scr_xx.c文件。本例中,桌面界面有属于自己的lv_ui_deskop类型和setup_scr_deskop.c文件。lv_ui_deskop类型结构体里面存放了各种指针,比如指向城市名label组件的指针screen_city、指向天气图标image组件的指针screen_weather等等。
gui_guider.c中setup_ui_deskop函数实现切换页面原理:
1.外部传入一个lv_ui_deskop类型指针ui,将该指针传入setup_scr_deskop函数。setup_scr_deskop函数是创建界面的函数,该函数会为lv_ui_deskop结构体内的各个指针申请空间,并赋初值(相当于申请了组件的空间,并让结构体中的指针指向这些组件)。
2.各个组件创建完成,调用lv_scr_load_anim函数将该页面作为“显示页面”,LVGL调度任务lv_task_handler会实时将“显示页面”传输到显示屏。
注意:gui_guider软件生产的代码,调用的是lv_scr_load函数,不是lv_scr_load_anim。这两个函数的区别:lv_scr_load_anim将当前页面作为“显示页面”之前,会释放之前作为显示页面的页面占用的内存,且页面更替时会有渐变的效果,更美观。lv_scr_load不会释放之前页面的内存,进行多次页面切换时,可能导致内存耗尽,系统死机,且页面更替无渐变效果。

知识点4:系统运行时,如何实时改动LVGL组件的内容?例如,当时间到达晚上十二点时,如何将界面的日期改为第二天。
LVGL中提供了lv_label_set_text等一系列函数接口,可以用于修改组件内容。将组件和字符串作为参数传入lv_label_set_text,即可修改某个label组件的内容。同理还有修改图片组件的lv_img_set_src函数、修改进度条组件的lv_bar_set_value函数等等。
故修改日期的大致流程为:MCU通过ESP8266获取到当前时间,检测到日期发生了改变,随即调用lv_label_set_text函数,将桌面页面中,日期对应的label组件的内容进行修改。由于目前桌面页面为“显示页面”,LVGL调度任务lv_task_handler检测到“显示页面”发生了变动,将图像信息传输到显示屏,用户即可在显示屏中看到日期的变化。
注:本项目中并不是直接调用lv_label_set_text函数,而是封装多了一层函数于set_scr_deskop.c中,名为updateDeskopDate,该函数调用了lv_label_set_text函数。其它源文件调用updateDeskopDate即可实现改变日期label组件的内容。本项目中,其它更改组件属性的函数,都采用上述方式编写,命名为updatexxx。这样做的原因有两个:1.安全性,其它文件不应随便调用lv_label_set_text函数,而应该由set_scr_deskop.c提供接口。2.若lv_label_set_text函数不在set_scr_xx.c调用,而是在其它文件调用,且参数为中文字符串时,会出现找不到中文字符的情况。疑似原因是set_scr_xx.c和其它.c文件的编码格式不同。

3.APP功能

此处简单阐述APP选择界面是如何做到滑动切换APP的,再介绍各个APP的设计思路。


作者非常懒惰,一句话我都不想留,除非我想