1. 软件框架
手表OV-Watch的总体软件架构如下所示,具体代码详见仓库。
V2.4版本以后的手表的代码分为Bootloader和APP了,为的是方便用户戴在手上进行不用拆解的升级,BOOT区后面划分了一个Flag区,用于记录是否是完整的APP,这个位置是APP传输完成后才记录的,为的是保证程序完整性。
下面我将从自底向上来讲解手表的软件结构
1.1. MDK工程结构
MDK工程结构不是完全对应Software文件夹中的文件结构的。注意,本项目默认使用的MDK中编译器为compiler version 5
├─Application/MDK-ARM # 用于存放.s文件
├─Application/User/Core # 用于存放CubeMX生成的初始化文件
│ │ main.c
│ │ gpio.c
│ │ ...
│
├─Application/User/System # 用于存放自定义的delay.c sys.h等
│ │ delay.c
│ │ ...
│
├─Application/User/Tasks # 用于存放任务线程的函数
│ │ user_TaskInit.c
│ │ user_HardwareInitTask.c
│ │ user_RunModeTasks.c
│ │ ...
│
├─Application/User/MidFunc # 用于存放管理函数
│ │ StrCalculate.c
│ │ HWDataAccess.c
│ │ PageManager.c
│
├─Application/User/GUI_APP # 用于存放用户的ui app
│ │ ui.c
│ │ ...
│
├─Application/User/GUI_FONT_IMG # 用于存放字体和图片
│ │ ...
│
├─Drivers/CMSIS
│ │ ...
│
├─Drivers/User/BSP # 用于存放板载设备驱动
│ │ ...
│
├─Middleware/FreeRTOS # FreeRTOS的底层
│ │ ...
│
├─Middleware/LVGL/GUI # LVGL的底层
│ │ ...
│
└─Middleware/LVGL/GUI_Port # 用于存放LVGL驱动
├─lv_port_disp.c
├─lv_port_indev.c
1.2. CubeMX框架
工程是用CubeMX生成的MDK工程,这里默认使用的AC5编译,这里默认大家已经能够熟练使用CubeMX和HAL库了,HAL库淡化硬件层非常适合进行软件开发。
本次手表项目使用到的片上外设包括GPIO, IIC, SPI, USART, TIM, ADC, DMA, 具体的对PCB板上器件的驱动,例如LCD, EEPROM等,详见BSP。
[!TIP|style:flat|label:Tip 小贴士|iconVisibility:visible] 请下拉代码,自行看代码细节~
简述一下各个片上外设的用途:
DMA这里主要是配合SPI,SPI通信不通过CPU而是通过DMA直接发送,如果使用多线程,那么视觉上来讲,刷屏应该就会快一些,因为CPU可以去执行其它任务;
IIC主要用来跟Back板各个传感器进行通信,传感器都挂在一个总线上的;
TIM主要是提供时基,另外一个就是给LCD调节背光;
ADC只接了一个电池的分压,进行电池电压采样,预估剩余电量;
USART接了蓝牙,方便进行IAP和与手机和电脑的助手通信。
1.3. 板载驱动BSP
详见BSP文件夹中的代码,BSP很多很杂,不需要了解的同学可以先跳过,直接调用相关API函数即可。
[!Warning|style:flat|label:Warning 注意|iconVisibility:visible] 很多设备初始化函数是有低功耗设置的,不要轻易改!!
简述一下部分BSP:
WDOG采用外置的原因是,想要做睡眠低功耗,那么使用MCU内部的看门狗关闭不了,只能一直唤醒喂狗,否则就要重启,那么这样就失去了睡眠的意义了;
IIC使用的软件模拟的方式进行驱动,没有用硬件IIC,还是更推荐使用硬件IIC,IIC总线的定义如下,定义了GPIO的口和CLK使能函数,在各个设备中直接用
iic_bus_t
进行创建IIC即可,然后调用iic_hal.c
中的API即可;
typedef struct
{
GPIO_TypeDef * IIC_SDA_PORT;
GPIO_TypeDef * IIC_SCL_PORT;
uint16_t IIC_SDA_PIN;
uint16_t IIC_SCL_PIN;
void (*CLK_ENABLE)(void);
}iic_bus_t;
key按键的驱动,GPIO设置有添加中断,这个是为了按键唤醒进入STOP模式的MCU;
EM7028心率,驱动中只写了对寄存器的读取,具体的心率算法和血氧算法没有放在BSP中;
DataSave数据保存,为了方便保存手表设置等数据,自定义了EEPROM中的存储帧,详见代码;
IMU,dmp库中的init函数是改过的,还有MP6050.c,有比较多低功耗相关的,建议直接拿去用,不需要再改了。关于MPU6050记步的问题,难点主要在实现低功耗记步,它的内部是有一个寄存器存储步数的,应该MPU6050内部是有记步算法的,不需要用户在外部再自己设计记步算法了。
1.4. 硬件访问机制-HWDataAccess
1.4.1. LVGL仿真和MDK工程的互相移植
为什么加入HWDataAccess.c,而不直接调用BSP的API呢,主要是为了方便移植和管理。
上面图片所示这个OV_Watch/User
文件夹中的Func
文件夹和GUI_APP
文件夹,全部复制到LVGL仿真文件夹中,如下所示,即完成了仿真的移植。
同理,你在LVGL仿真中,改完UI App后,想要移植回MDK工程,看下实物效果,也是直接将LVGL仿真中的user_test中的文件复制过去即可。
当然,MDK工程和LVGL仿真工程的移植过程需要改一个东西,就是HWDataAccess.h
中的使能:
/***************************
* Hardware Define
***************************/
/**
* if not use, just set 0
*
*
* if just test ui, no hardware, just set HW_USE_HARDWARE 0
*
*/
#define HW_USE_HARDWARE 1
#if HW_USE_HARDWARE
#define HW_USE_RTC 1
#define HW_USE_BLE 1
#define HW_USE_BAT 1
#define HW_USE_LCD 1
#define HW_USE_IMU 1
#define HW_USE_AHT21 1
#define HW_USE_SPL06 1
#define HW_USE_LSM303 1
#define HW_USE_EM7028 1
#endif
如果是在仿真中,就把HW_USE_HARDWARE
定义为0
即可,MDK中自然就是定义为1
。使用这个HWDataAccess就方便把硬件抽象出来了,具体的代码详见代码。
1.4.2. HWDataAccess具体使用方式
在HWDataAccess.c
中,使用结构体进行各个硬件管理,如下代码所示。各个typedef定义在HWDataAccess.h
中可以看到。
/***************************
* External Variables
***************************/
HW_InterfaceTypeDef HWInterface = {
.RealTimeClock = {
.GetTimeDate = HW_RTC_Get_TimeDate,
.SetDate = HW_RTC_Set_Date,
.SetTime = HW_RTC_Set_Time,
.CalculateWeekday = HW_weekday_calculate
},
.BLE = {
.Enable = HW_BLE_Enable,
.Disable = HW_BLE_Disable
},
.Power = {
.power_remain = 0,
.Init = HW_Power_Init,
.Shutdown = HW_Power_Shutdown,
.BatCalculate = HW_Power_BatCalculate
},
.LCD = {
.SetLight = HW_LCD_Set_Light
},
.IMU = {
.ConnectionError = 1,
.Steps = 0,
.wrist_is_enabled = 0,
.wrist_state = WRIST_UP,
.Init = HW_MPU_Init,
.WristEnable = HW_MPU_Wrist_Enable,
.WristDisable = HW_MPU_Wrist_Disable,
.GetSteps = HW_MPU_Get_Steps,
.SetSteps = HW_MPU_Set_Steps
},
.AHT21 = {
.ConnectionError = 1,
.humidity = 67,
.temperature = 26,
.Init = HW_AHT21_Init,
.GetHumiTemp = HW_AHT21_Get_Humi_Temp
},
.Barometer = {
.ConnectionError = 1,
.altitude = 19,
.Init = HW_Barometer_Init,
},
.Ecompass = {
.ConnectionError = 1,
.direction = 45,
.Init = HW_Ecompass_Init,
.Sleep = HW_Ecompass_Sleep
},
.HR_meter = {
.ConnectionError = 1,
.HrRate = 0,
.SPO2 = 99,
.Init = HW_HRmeter_Init,
.Sleep = HW_HRmeter_Sleep
}
};
如何在UI层使用HWDataAccess呢,例如在HomePage中的调节LCD亮度的回调函数中,这么使用,可以看到直接调用HWInterface.LCD.SetLight(ui_LightSliderValue);
即可。
void ui_event_LightSlider(lv_event_t * e)
{
lv_event_code_t event_code = lv_event_get_code(e);
lv_obj_t * target = lv_event_get_target(e);
if(event_code == LV_EVENT_VALUE_CHANGED)
{
ui_LightSliderValue = lv_slider_get_value(ui_LightSlider);
HWInterface.LCD.SetLight(ui_LightSliderValue);
}
}
那么他是如何在有硬件的MDK工程中也能用,LVGL无硬件的仿真也能用,我们看到HWInterface.LCD.SetLight
对应的函数是什么:
HW_InterfaceTypeDef HWInterface = {
// 省略前面
.LCD = {
.SetLight = HW_LCD_Set_Light
},
// 省略后面
}
首先看到HWInterface.LCD.SetLight
定义的是函数HW_LCD_Set_Light
,而这个函数的内容如下,即当HW_USE_LCD使能时,运行这个函数,能够正常调光,当LVGL仿真中不使能硬件HW_USE_HARDWARE
时, HW_USE_LCD
也不使能,则此函数执行空,工程也不会报错。
void HW_LCD_Set_Light(uint8_t dc)
{
#if HW_USE_LCD
LCD_Set_Light(dc);
#endif
}
1.5. LVGL页面管理-PageManager
[!TIP|style:flat|label:Tip 小贴士|iconVisibility:visible] 这个PageManager可以直接拿过去用于LVGL相关项目,方便进行页面管理~
1.5.1. PageManager框架
OV-Watch手表项目的LVGL页面有很多,在GUI_App文件夹中,Screen文件夹中存放着所有的page。由于screen很多,所以有必要进行页面管理。这里开一个栈进行页面管理。
首先看到PageManager.h
, Page_t
结构体是用于描述一个LVGL页面的,里面的对象有初始化函数init
,反初始化函数deinit
以及一个用于存放lvgl对象的地址的lv_obj_t **page_obj
。
PageStack_t
结构体描述一个界面栈,用于存放Page_t
页面结构体,top
表示栈顶。
// 页面栈深度
#define MAX_DEPTH 6
// 页面结构体
typedef struct {
void (*init)(void);
void (*deinit)(void);
lv_obj_t **page_obj;
} Page_t;
// 页面堆栈结构体
typedef struct {
Page_t* pages[MAX_DEPTH];
uint8_t top;
} PageStack_t;
extern PageStack_t PageStack;
再看到PageManager.c
,栈的初始化还有push和pop操作就不再赘述了,在pop函数中,除了将top减1,还调用了页面deinit函数,负责反初始化当前页面
stack->pages[--stack->top]->deinit();
Page_Back()
, Page_Back_Bottom()
, Page_Load()
就是主要在代码中调用的函数了,分别的作用是Back到上一个界面,Back到最底部的Home界面,以及load新的界面。
1.5.2. 如何在ui app中使用PageManager
这里以代码比较少的ui_ChargPage.c
为例,这个page也是我很久之前使用square line生成的,一般会生成ui_ChargPage_screen_init
函数。
首先我们需要注册一个Page结构体存储当前的页面,填充好初始化init
,反初始化函数deinit
以及LVGL页面对象&ui_ChargPage
,然后我的deinit是
用于删除定时器timer
的,这里的timer
主要用于刷当前页面的数据,所以不在当前页面时需要删除掉。
// 省略前面...
///////////////////// Page Manager //////////////////
Page_t Page_Charg = {ui_ChargPage_screen_init, ui_ChargPage_screen_deinit, &ui_ChargPage};
/////////////////////// Timer //////////////////////
// need to be destroyed when the page is destroyed
static void ChargPage_timer_cb(lv_timer_t * timer)
{
if(Page_Get_NowPage()->page_obj == &ui_ChargPage)
{
// 刷新数据等操作
}
}
///////////////////// SCREEN init ////////////////////
void ui_ChargPage_screen_init(void)
{
// 省略中间...
// private timer
ui_ChargPageTimer = lv_timer_create(ChargPage_timer_cb, 2000, NULL);
}
/////////////////// SCREEN deinit ////////////////////
void ui_ChargPage_screen_deinit(void)
{
lv_timer_del(ui_ChargPageTimer);
}
// 省略后面...
1.6. 多线程任务
这里默认大家已经会用FreeRTOS了,此项目都用的CMSIS_OS_V2的API。Tasks文件以及其作用如下所示。
├─Application/User/Tasks # 用于存放任务线程的函数
│ ├─user_TaskInit.c # 初始化任务
│ ├─user_HardwareInitTask.c # 硬件初始化任务
│ ├─user_RunModeTasks.c # 运行模式任务
│ ├─user_KeyTask.c # 按键任务
│ ├─user_DataSaveTask.c # 数据保存任务
│ ├─user_MessageSendTask.c # 消息发送任务
│ ├─user_ChargeCheckTask.c # 充电检查任务
│ ├─user_SensUpdateTask.c # 传感器更新任务
│ ├─user_ScrRenewTask.c # 屏幕刷新任务
任务初始化
TaskInit.c
,注册各个任务,分配空间,注册一些信号量,任务的汇总可以看这个文件。同时也创建了一个软件定时器,用于记录空闲时间,即用户没有操作过长就会发出idle
信号,idle
过长,就会发出STOP
信号,进入睡眠。LVGL的时钟提供也放在这个文件夹,为LvHandlerTask
。看门狗的喂狗Task也放在这个文件中。硬件初始化
user_HardwareInitTask.c
,其实这里应该叫BootTask,不仅仅有硬件的初始化,最后还有LVGL的初始化。这个任务运行完后,会把本任务删除,即调用vTaskDelete(NULL);
;按键任务
keytask
,按键发生即发出信号量,调osMessageQueuePut(Key_MessageQueue, &keystr, 0, 1);
和osMessageQueuePut(IdleBreak_MessageQueue, &IdleBreakstr, 0, 1);
,一个是按键信号量,一个是空闲打断信号量;屏幕切换任务
user_ScrRenewTask.c
,接受按键信号量,然后调用PageManager中的函数;运行模式切换任务
user_RunModeTasks.c
,主要用于进入STOP模式和退出STOP模式,接收到Idle超时发出的Stop_MessageQueue
信号量就进入STOP模式。这里跟硬件和UI的睡眠时间设置强相关,比较耦合,代码还有goto,有点小小不规范,请见谅~其它详见代码。
2. BootLoader与无线升级
Bootloader程序在Software/IAP_F411
中,这个是我拿官方的IAP程序稍微改了一下。这个工程有需要的就自行研究一下,大家可以直接拿去用的,不需要改什么。
我唯一改的地方就是加了一个FLAG
验证,即在FLASH中留了一个位置放FLAG,用于判断APP程序是否完好。进入升级模式时,FLAG
位置自动清空,FLAG
位是在传输完成验证后才写入内容的,可以保证APP传输的完整。