本项目主控选型为STM32。电路设计与焊接、外壳3D建模、软件开发从零开始,最终完成了一个Holocubic桌面站。实现了获取天气、时间的功能,且内部实现了多个APP,包括定时、图库、电脑投影、系统设置。涉及Altium Designer电路设计、立创EDA、fusion360外壳设计、STM32、DMP库、LVGL图形库等。项目灵感来源于B站up主稚晖君。
本项目开源,github开源链接:https://github.com/lihuayao1/STM32-Holocubic
B站视频效果:
https://www.bilibili.com/video/BV1hG41177gG?share_source=copy_web
holocubic桌面站
该项目的电路设计分为两块电路板,一块是搭载STM32的主控板,另一块是粘在1.3寸IPS显示屏背后的屏幕转接板。
(1)器件选型
主控选型:常见的MCU选择有51系列、ESP系列、STM32系列等。本项目需处理大量图像,移植图形库,且功能较多,需要联网,51系列的硬件资源不足,故淘汰。更优秀的方案为:方案一、STM32(主控)+ESP8266(联网);方案二、ESP32(主控+联网)。
本项目采用了方案一。由于屏幕采用的是240x240的彩色屏幕,每个像素点由16位数据(2个字节,RGB565)组成,故屏幕一帧需占用240x240x2=115200个字节,已超过100KB。虽然LVGL图形库不要求主控的RAM能够存放一帧图像,但很多时候,为确保画面不卡顿,主控的RAM空间应尽可能大,故STM32F1系列无法选用,可考虑STM32F4系列。
项目中,部分功能的图像存放在外置FLASH中,显示图像需从FLASH读取再刷新到屏幕上,为了更好的显示效果,应尽量提高SPI通信速率。
考虑到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软件更新固件。其它大神的更新固件教程:https://blog.51cto.com/u_13640625/4906860
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排线的一端(另一端连接主控板)。
该图为焊接完成的板子,左边是已连接屏幕的屏幕板,右边是主控板。白色的是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(×tamp);
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(×tamp);
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*)×tamp))
{
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效率,分析过程见下图:
查数据手册可知,APB1频率最高42MHz,APB2频率最高84MHz。SPI1挂载于APB2总线,SPI2挂载于SPI1总线。
查询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_RES*60在LVGL库中原本应为MY_DISP_HOR_RES*10。
用处:LVGL刷屏并不需要开辟全屏幕像素数240*240那么大的显存,而是开辟MY_DISP_HOR_RES*10个像素点的显存,每次将全部显存内容传到屏幕中。MY_DISP_HOR_RES的值是240,故每次在240*240的屏幕中刷新240*10个像素点,即10行。而整个屏幕有240行,每次刷新10行,共需要传输24次才能刷完整个屏幕。若扩大显存,可以减少传输次数,让DMA每次搬运大量数据,而不是多次启动DMA搬运少量数据。DMA擅长每次搬运大量数据,而不是多次启动,每次搬运少量数据,这样会导致传输效率慢,屏幕帧率下降,故此处显存的空间越大越好,建议整个项目开发完成后,查看剩余RAM大小,将剩余的空间分配给显存,本项目分配了240*60的显存空间。
lv_conf.h
#define LV_COLOR_16_SWAP 1
此处最好改为1。原因:图像的每一个像素是16位的,而项目中DMA传输的单位设置为了8位,画面会出现颜色异常。
例如:一张全为红色的240*240的页面,应使用u16类型,大小为240*240的数组存储,且数组内元素值全都为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库中原本为32U*1024U,本项目将其改为了60U*1024U,该处的作用是定义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的设计思路。
切换APP思路:
下图为界面(红框处还有个APP,被遮挡了)
(1)通过gui guider软件设计该页面,该页面由4个image组件、4个label组件构成,分别是app图案和app名字。该页面的大小为:宽240 长960。
(2)由于屏幕大小为240*240,该页面大小为240*960,长度超限了,故每次只能选中240*240的区域作为显示区域。LVGL提供了lv_obj_scroll_to_view函数,调用该函数可以让画面聚焦在某个组件,故项目中可以通过该函数选择聚焦四个图案中的一个,实现APP切换的效果。
1.闹钟功能
闹钟界面
该界面由3个label组件(DATE,SPECIFIC TIME,OFF/ON字符串)、6个dropdown组件(下拉框)、1个switch组件(开关)构成。
进入该界面之前,需检测switch状态是否打开,若switch状态关闭,则说明闹钟关闭,需读取当前系统时间作为下拉框的内容。若switch状态打开,则说明之前已设置过闹钟,需读取上次设置的闹钟时间作为下拉框的内容。本项目中利用全局变量,标记switch状态是否打开。
选中某个下拉框时,若holocubic向上摇晃,则打开下拉框。若向下摇晃,则退出闹钟app。若向左或向右摇晃则切换选中框。选中switch组件时,向左摇晃则开关关闭,向右摇晃则开关打开。此处需要用到以下LVGL函数接口:设置组件被选中或未选中的函数lv_obj_set_state和lv_obj_clear_state(打开和关闭switch组件也用该函数),打开下拉框和关闭下拉框的函数lv_dropdown_open,lv_dropdown_close,设置和获取下拉框选项的函数lv_dropdown_set_selected,lv_dropdown_get_selected。调用上述函数接口,再编写相应的业务代码即可完成该APP功能,此处不详细阐述。
2.图库功能
本项目中的图库存放了一张小猫的GIF,电路设计选型时,FLASH用的是W25Q64,空间只有8M。由于FLASH不仅要存图库的内容,还需存放wifi信息,天气图像等等,故8M的空间不是全都可以使用,剩余存放图库的空间大约只有5M,相当于FLASH中的80个BLOCK,故该动图的帧数较少,动画时间较短。此处简单阐述图库中小猫GIF占用FLASH的空间如何计算。
图库中的图像采用BMP格式(其它利用LVGL图形库存放的图像格式不是BMP)。BMP格式的图像,图像大小为240*240,每个像素由2个字节组成(RGB565),故一帧图像需要240*240*2=115200个字节存放,约110KB。FLASH每个BLOCK大小64KB,故每张图像需占用2个BLOCK。剩余80个BLOCK,故FLASH中可以存放40帧的GIF,本项目中存放了40帧的小猫图像,构成一张GIF。
注意点:由于MCU的RAM有限,剩余的RAM空间已无法开辟一个115200字节的数组存放一帧小猫图像,故需要借用投影功能的数组空间,该空间足够存放半张图像。显示GIF时,一帧图像的传输需要拆分成两次半张图像的传输。每次从FLASH读取上半张图像,再刷新这上半张图像到显示屏,随后读取下半张图像,刷新下半张图像到显示屏,完成以上过程便完成了一帧图像的显示。
3.投影功能
电脑端与单片机端代码思路:(详细可见工程中的代码)
由于图传数据量巨大,故不能采用常用的波特率115200,本项目经过多次测试,最终将波特率定为460800 * 6 + 230400。高于该值会导致画面出现颜色偏差,原因可能是速度过高,数据丢失,此处待优化。经VisualStudio端调试,测出每秒约能传输3张图像,故电脑投影的帧率应为3帧左右,勉强能用。有更好的办法进行优化,办法文末介绍,后续作者若有时间会进行对应的优化。
4.设置功能
下图为界面:
该界面用到了一个新的组件,名为textarea,即文本框(界面中的白色区域)。设置文本框内容的函数为lv_textarea_set_text。
进入该界面将读取系统的城市、wifi账号、wifi密码,并刷新到该页面上。若想更新holocubic的城市、wifi,可通过串口发送对应指令。holocubic收到对应指令将进行解析,执行相应操作,并将其写入flash,以确保掉电后重启系统,尝试连接的wifi是更新过的wifi,系统的城市所在地是更新过的城市。
指令格式:(注意末尾的分号以及等号两边不要加空格)
city=xxx;
wifi=xxx;
password=xxx;
以下简单阐述holocubic解析指令的思路:
1.串口接收一串指令(死循环接收字符。接收到一个字符后,若超过1s未再接收到新字符或接收到了分号,则说明已接收到一串指令,接下来准备解析字符串)
2.解析字符串:
(1)截取符号"=“前的字符串,判断该字符串是否是"city”,“wifi”,“password”,若不是则说明为错误指令,若是则进行下一步解析。
(2)截取符号"=“后,符号”;"前的字符串。该字符串代表城市名、wifi账号或wifi密码,将该字符串写入FLASH,并做对应的更新操作即可(如更新城市为深圳、重连当前的wifi)。
1.电脑投影:
(1)波特率460800 * 6 + 230400为测试后的经验值。STM32串口波特率最高支持4.5M,而datasheet中写到USB转串口芯片CH340N的波特率最高仅支持2M波特率。460800 * 6 + 230400已超过2M,可能会产生不稳定因素。后续优化可考虑两个方案:第一、将串口协议改为USB协议,该协议通信速度快,不需要经过CH340转换,理论上能达到更优的投影效果。但USB协议较为复杂,学习流程较长。第二、将协议由串口改为无线,通过wifi进行图传。但需要将主控换为ESP32等支持wifi的MCU,可能涉及较为复杂的wifi协议操作。
(2)投影时,若先向下摇晃退出投影APP,再关闭电脑端图传程序,可能导致再次进入投影APP时,图像颜色异常。
(3)VisualStudio串口传图时,有时会卡住,可能是MCU端波特率过高接收不稳定,重新进入投影APP即可恢复正常。
2.姿态解算:本项目中采用的姿态传感器为MPU6050,通过移植DMP库将MPU6050采集的数据进行计算,得到holocubic的姿态角。但在项目开发过程中,由于DMP部分代码不由自己完全掌握,姿态解算经常出现bug,且难以定位。如切换界面时,姿态角可能突变为一个极端值,再缓慢恢复至正常值,导致一切换界面,就触发了holocubic摇晃。
3.系统时间:holocubic只有在桌面界面时,才会通过ESP8266获取时间。如果长时间停留在APP功能不返回桌面,打开闹钟APP时,下拉框内显示的时间会是很久之前的时间。后续考虑将ESP8266获取时间的任务处理方式改为定时器中断处理或移植RTOS保证获取时间的任务得到运行。
4.wifi及天气:1.若holocubic放在室内的桌面,且连接的是手机热点,此时如果用户将手机带离室内,会导致ESP8266连接中断,用户带着手机再次返回时,ESP8266不会重新连接wifi,导致获取天气或时间时死机。后续考虑移植RTOS,创建一个任务监测wifi是否断开,若断开则重连。或者采用定时器中断监测wifi是否中断,若断开则重连。2.获取天气时,不能短时间内多次连接心知天气网站,否则会返回错误信息,故本项目每隔10分钟连接一次心知天气网站。连接成功后开启透传即可获取天气信息,获取完成后退出透传。但由于未知原因,开启透传后,发送获取天气命令给网站,经常得不到响应,导致系统一直在等待网站回复天气信息。且在等待期间若不断电重启,ESP8266会一直停在透传模式,需断电重启才能恢复正常。后续考虑检查心知天气返回信息,并采用别的方法向心知天气发送命令,而不是采用透传。
5.主控板FPC座子方向:由于PCB设计时,将FPC座子设计在了主控板的右侧。导致将主控板与屏幕安装到外壳内时需要弯折排线,长时间弯折可能影响排线寿命,且安装时排线会将屏幕向上顶,不利于后续胶水固定屏幕位置。后续应考虑将FPC座子的位置换到更易安装排线的位置、
本项目从个人开发者角度来说,有一定的工作量。以下总结一下作者开发过程中踩的坑。
总体流程:查datasheet,设计电路,画PCB -> 电路焊接 -> 设计外壳 -> 代码开发。
电路设计阶段,可能出现封装画错、选型错误等情况,作者本人在该阶段时,主控板画错了1次,屏幕板画错了1次。
电路焊接阶段,可能出现元件虚焊、反焊等情况。FPC座子、MPU6050焊接相对稍难,需细心操作。作者本人在该阶段时,由于未细分FPC座子分为上接、下接,故焊错了一次,将下接的FPC座子焊到了应为上接FPC座子的位置上,导致用排线连接FPC座子时,出现了连接相反的情况,将主控板应连屏幕板1脚的位置连到了屏幕板24脚。用电表测连通性时,显示无异常,但屏幕无法点亮。最后发现不接屏幕时,主控板工作正常,接上屏幕时,主控板芯片发热严重,才定位到可能是有地方反接。往后设计PCB与购买材料时,应考虑清楚FPC/FFC座子与排线的方向,并于PCB中预留足够的测试点(测FPC座子等密集引脚器件的连通性较难,应预留测试点)。
外壳设计阶段,主要踩的坑在螺丝孔设计部分。作者本人在该阶段时,外壳总共设计了三次。第一次,螺丝孔设计过小,小螺丝很难拧进外壳。第二次,换了稍大的螺丝孔与螺丝,但设计时,螺丝孔中未预留足够的空间放螺丝的圆头部分。第三次,成功。预留了足够的空间放螺丝的圆头部分,且让螺丝可以直接放入后盖的孔中,不需要将螺丝拧在后盖壳,拧螺丝只需往主壳拧即可。
代码开发阶段,作者本人先利用STM32F1的开发板,杜邦线连接相关模块,编写对应代码,测试各模块能否正常工作。确定能正常工作后,换STM32F4作为主控,设计电路、焊接PCB,再在该板子上进行真正意义上的项目开发。项目开发过程中,踩过的坑无数,主要集中在LVGL图形库以及DMP库。
本项目除剩余少量待优化环节,已基本完成,后续可能考虑移植FreeRTOS并重构项目的代码框架。感谢观看。