高品质音乐播放器(基于STM32F407)

新年好。

一.简述

在学习stm32f4时,我想要做一个项目来充分发挥f4系列的性能,正巧我对音频与UI比较感兴趣,于是我便选定了音乐播放器这个项目。

首先,我从项目名进行项目特点分析。

1.1 高品质(DAC,解码,WAV)

自然界中的音频是一种波,音源通过推动空气振动来传递能量,最终人耳鼓膜受迫振动转化为电信号传递给大脑。

而描述一个波的物理量就是频率与振幅,电脑如果需要存储高品质的音频那就是要确保频率与振幅被完整存储下来,而描述音频文件的存储质量的就是位深与采样频率。

这张图中,纵轴的长度(振幅)就由位深确定,横轴的每个单位左边之间的长度(频率)就由采样率决定。

所以通过采样率与位深就可以确定一个点的坐标,确定了点就可以画线即确定了音频波形。

那么怎么确保音频采样频率与位深足够呢?

香农采样定理就指出,采样频率大于原始信号频率的二倍即可,而人耳最大可听频率为20-20000Hz,只要确保采样率大于40000Hz即可。

而一般音乐的分贝数不会超过90分贝,16bit位深就足以存储98dB(动态范围)的音乐。

这就是CD级音质的由来 44100Hz 16bit 未压缩音频。

而WAV格式中直接存储了波形的未压缩原始数据,且无需进行过多解码,既满足音质也降低了对mcu性能的要求。

于是本项目最终支持48kHz 24bit WAV格式音频的播放,所以称其为高品质。

1.2 播放器(显示进度,播放切歌)

仅仅只能播放音频是远远不够的,还需要与用户交互的界面,

本项目使用了spi屏幕(lil9341),来显示

  • 播放列表
  • 播放操作(暂停,下一首,上一首,音量)
  • 播放进度(进度条)
  • 播放信息(采样率 位深 歌曲名(支持中文显示))

通过实体按钮来操作ui。

1.3 基于STM32F407

  • 具有168 MHz的Cortex™-M4内核,速度较快,能够满足解码音频与屏幕刷新任务,
  • 具有DMA和I2S能够快速输出32位的音频数据。
  • 具有FSMC能够连接外部SRAM满足UI组件以及文件系统的内存需求。
  • 具有SDIO,能够快速读取SD卡内容获取音频数据
  • 192kb SRAM

二.硬件

接下来,我根据音频数据的传输方向讲解硬件的构成。

2.1 SD卡

首先音频数据存储在SD卡中。

SD卡具有高容量、速度快和体积小的特点,stm32最快能够以5.4M/s的速度来读取数据,足以满足音频的要求。

以下是硬件连接。

2.2 STM32F407

存储在SD卡中的数据被MCU读取出来进行解码操作,再显示再屏幕上,最终通过DMA到达I2S发送到DAC(数模转换器)。

2.3 DAC(ES9018或开发板自带的WM8978)

在此之前的所有数据都是以二进制存储的,若将这些数据直接输出只会得到表示1、0的高低电平,这些离散的数字信号需要被转化为连续的模拟信号才可以。

这就需要DAC芯片来将数字信号来转化为模拟信号,而在音频领域,MCU与音频DAC的通信就是通过前文提及的I2S协议。

为了达到音频还原的高品质,就需要低失真,高信噪比,高动态范围,高码率的DAC。

这里我预留了两个选择即 - WM8978(STM32F407开发板自带) - ES9018K2m

2.3.1 WM8978

特性如下: - I2S 最高支持192kHz 24bit - 信噪比98dB - 40mW@16Ω的驱动力 - I2C控制 - QFN封装 体积小。

硬件连接

由于这个芯片已停产,且内置耳机驱动推力一般,信噪比也一般比不过ess公司的es9018,在测试时使用WM8978。

2.3.2 es9018

ess公司在高端音频DAC的性价比高,音频指标高,被许多公司使用,如oppo。

特性如下 - I2S 最高支持384kHz 32bit - 支持DSD(索尼与飞利浦发明的一种音频格式)输入 - 信噪比127dB - 120dB动态范围 - I2C控制 - 差分信号输出(低失真,抗干扰)

由于其无法直接驱动耳机,所以后面需要耳放电路,这里就使用了以往的项目,ESS9018k2m解码板。

2.4 扬声器

扬声器内部本质就是一个线圈+磁铁,线圈通过电流后根据电流大小产生磁场,磁场与磁铁相互作用推动空气,产生震动。

这里的扬声器直接接在了pwm_audio.

2.5 SRAM

虽然stm32f4内存很大,但是UI控件与文件系统需要许多内存,于是我使用了外置SRAM IS62WV51216,具有1MB的内存,足够ui,文件系统以及未来的RTOS使用。

在硬件连接上,需要将sram的地址线与数据线与mcu的FSMC相连,这里要注意的是不将数据线与地址线混淆即可

2.6 按键

无需多讲。 ## 2.7 SPI显示屏 在显示方面,使用了ili9341显示屏,带有触摸模块(暂未使用),分辨率为320x280px,能够满足对文字,按键的显示。

硬件连接只需提供电源,连接好spi即可。

三.软件

由于本项目实时性较高,需要格外注意对音频信号实时输出的把控,注意对音频处理的阻塞。

3.1 外设部分

3.1.1 I2S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 void I2S2_Init(u16 I2S_Standard,u16 I2S_Mode,u16 I2S_Clock_Polarity,u16 I2S_DataFormat)
{
I2S_InitTypeDef I2S_InitStructure;

RCC_APB1PeriphClockCmd(RCC_APB1Periph_SPI2, ENABLE);//使能SPI2时钟

RCC_APB1PeriphResetCmd(RCC_APB1Periph_SPI2,ENABLE); //复位SPI2
RCC_APB1PeriphResetCmd(RCC_APB1Periph_SPI2,DISABLE);//结束复位

I2S_InitStructure.I2S_Mode=I2S_Mode;//IIS模式
I2S_InitStructure.I2S_Standard=I2S_Standard;//IIS标准
I2S_InitStructure.I2S_DataFormat=I2S_DataFormat;//IIS数据长度
I2S_InitStructure.I2S_MCLKOutput=I2S_MCLKOutput_Disable;//主时钟输出禁止
I2S_InitStructure.I2S_AudioFreq=I2S_AudioFreq_Default;//IIS频率设置
I2S_InitStructure.I2S_CPOL=I2S_Clock_Polarity;//空闲状态时钟电平
I2S_Init(SPI2,&I2S_InitStructure);//初始化IIS


SPI_I2S_DMACmd(SPI2,SPI_I2S_DMAReq_Tx,ENABLE);//SPI2 TX DMA请求使能.
I2S_Cmd(SPI2,ENABLE);//SPI2 I2S EN使能.
};

i2s的DMA

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
 void I2S2_TX_DMA_Init(u8* buf0,u8 *buf1,u16 num)
{
NVIC_InitTypeDef NVIC_InitStructure;
DMA_InitTypeDef DMA_InitStructure;


RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1,ENABLE);//DMA1时钟使能

DMA_DeInit(DMA1_Stream4);
while (DMA_GetCmdStatus(DMA1_Stream4) != DISABLE){}//等待DMA1_Stream1可配置

/* 配置 DMA Stream */

DMA_InitStructure.DMA_Channel = DMA_Channel_0; //通道0 SPI2_TX通道
DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&SPI2->DR;//外设地址为:(u32)&SPI2->DR
DMA_InitStructure.DMA_Memory0BaseAddr = (u32)buf0;//DMA 存储器0地址
DMA_InitStructure.DMA_DIR = DMA_DIR_MemoryToPeripheral;//存储器到外设模式
DMA_InitStructure.DMA_BufferSize = num;//数据传输量
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;//外设非增量模式
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;//存储器增量模式
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;//外设数据长度:16位
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;//存储器数据长度:16位
DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;// 使用循环模式
DMA_InitStructure.DMA_Priority = DMA_Priority_High;//高优先级
DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable; //不使用FIFO模式
DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_1QuarterFull;
DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single;//外设突发单次传输
DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;//存储器突发单次传输
DMA_Init(DMA1_Stream4, &DMA_InitStructure);//初始化DMA Stream

DMA_DoubleBufferModeConfig(DMA1_Stream4,(u32)buf1,DMA_Memory_0);//双缓冲模式配置

DMA_DoubleBufferModeCmd(DMA1_Stream4,ENABLE);//双缓冲模式开启

DMA_ITConfig(DMA1_Stream4,DMA_IT_TC,ENABLE);//开启传输完成中断

NVIC_InitStructure.NVIC_IRQChannel = DMA1_Stream4_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0x00;//抢占优先级0
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0x00;//子优先级0
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;//使能外部中断通道
NVIC_Init(&NVIC_InitStructure);//配置

}

3.1.2 I2C

使用正点原子软件i2c
  • KEY
  • LCD

3.1.3 SD

总的来说,初始化时就是对sd卡发命令等待回应,来进行一次一次的判断。在代码层面就是定义结构体,传参调用函数。

而读取数据就是发出某一个命令后发出地址即可从fifo中遍历出数据。

3.1.4 FSMC(SRAM)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
void FSMC_SRAM_Init(void)
{
// 初始化GPIO口
GPIO_InitTypeDef GPIO_InitStructure;
FSMC_NORSRAMInitTypeDef FSMC_NORSRAMInitStructure;
FSMC_NORSRAMTimingInitTypeDef readWriteTiming;

RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB | RCC_AHB1Periph_GPIOD | RCC_AHB1Periph_GPIOE | RCC_AHB1Periph_GPIOF | RCC_AHB1Periph_GPIOG, ENABLE);
RCC_AHB3PeriphClockCmd(RCC_AHB3Periph_FSMC, ENABLE);

GPIO_InitStructure.GPIO_Pin = (3 << 0) | (3 << 4) | (0xff << 8);
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;
GPIO_Init(GPIOD, &GPIO_InitStructure);

GPIO_InitStructure.GPIO_Pin = (3 << 0) | (0X1FF << 7); // PE0,1,7~15,AF OUT
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; // 复用输出
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; // 推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; // 100MHz
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; // 上拉
GPIO_Init(GPIOE, &GPIO_InitStructure); // 初始化

GPIO_InitStructure.GPIO_Pin = (0X3F << 0) | (0XF << 12); // PF0~5,12~15
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; // 复用输出
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; // 推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; // 100MHz
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; // 上拉
GPIO_Init(GPIOF, &GPIO_InitStructure); // 初始化

GPIO_InitStructure.GPIO_Pin = (0X3F << 0) | GPIO_Pin_10; // PG0~5,10
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; // 复用输出
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; // 推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; // 100MHz
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; // 上拉
GPIO_Init(GPIOG, &GPIO_InitStructure); // 初始化

GPIO_PinAFConfig(GPIOD, GPIO_PinSource0, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource1, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource4, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource5, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource8, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource9, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource10, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource11, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource12, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource13, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource14, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOD, GPIO_PinSource15, GPIO_AF_FSMC);

GPIO_PinAFConfig(GPIOE, GPIO_PinSource0, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOE, GPIO_PinSource1, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOE, GPIO_PinSource7, GPIO_AF_FSMC); // PE7,AF12
GPIO_PinAFConfig(GPIOE, GPIO_PinSource8, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOE, GPIO_PinSource9, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOE, GPIO_PinSource10, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOE, GPIO_PinSource11, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOE, GPIO_PinSource12, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOE, GPIO_PinSource13, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOE, GPIO_PinSource14, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOE, GPIO_PinSource15, GPIO_AF_FSMC); // PE15,AF12

GPIO_PinAFConfig(GPIOF, GPIO_PinSource0, GPIO_AF_FSMC); // PF0,AF12
GPIO_PinAFConfig(GPIOF, GPIO_PinSource1, GPIO_AF_FSMC); // PF1,AF12
GPIO_PinAFConfig(GPIOF, GPIO_PinSource2, GPIO_AF_FSMC); // PF2,AF12
GPIO_PinAFConfig(GPIOF, GPIO_PinSource3, GPIO_AF_FSMC); // PF3,AF12
GPIO_PinAFConfig(GPIOF, GPIO_PinSource4, GPIO_AF_FSMC); // PF4,AF12
GPIO_PinAFConfig(GPIOF, GPIO_PinSource5, GPIO_AF_FSMC); // PF5,AF12
GPIO_PinAFConfig(GPIOF, GPIO_PinSource12, GPIO_AF_FSMC); // PF12,AF12
GPIO_PinAFConfig(GPIOF, GPIO_PinSource13, GPIO_AF_FSMC); // PF13,AF12
GPIO_PinAFConfig(GPIOF, GPIO_PinSource14, GPIO_AF_FSMC); // PF14,AF12
GPIO_PinAFConfig(GPIOF, GPIO_PinSource15, GPIO_AF_FSMC); // PF15,AF12

GPIO_PinAFConfig(GPIOG, GPIO_PinSource0, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOG, GPIO_PinSource1, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOG, GPIO_PinSource2, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOG, GPIO_PinSource3, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOG, GPIO_PinSource4, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOG, GPIO_PinSource5, GPIO_AF_FSMC);
GPIO_PinAFConfig(GPIOG, GPIO_PinSource10, GPIO_AF_FSMC);

readWriteTiming.FSMC_AccessMode = FSMC_AccessMode_A;
readWriteTiming.FSMC_AddressHoldTime = 0x00;
readWriteTiming.FSMC_AddressSetupTime = 0x00;
readWriteTiming.FSMC_BusTurnAroundDuration = 0x00;
readWriteTiming.FSMC_CLKDivision = 0x00;
readWriteTiming.FSMC_DataLatency = 0x00;
readWriteTiming.FSMC_DataSetupTime = 0x08;

FSMC_NORSRAMInitStructure.FSMC_AsynchronousWait = FSMC_AsynchronousWait_Disable;
FSMC_NORSRAMInitStructure.FSMC_Bank = FSMC_Bank1_NORSRAM3;
FSMC_NORSRAMInitStructure.FSMC_BurstAccessMode = FSMC_BurstAccessMode_Disable;
FSMC_NORSRAMInitStructure.FSMC_DataAddressMux = FSMC_DataAddressMux_Disable;
FSMC_NORSRAMInitStructure.FSMC_ExtendedMode = FSMC_ExtendedMode_Disable;
FSMC_NORSRAMInitStructure.FSMC_MemoryDataWidth = FSMC_MemoryDataWidth_16b;
FSMC_NORSRAMInitStructure.FSMC_MemoryType = FSMC_MemoryType_SRAM;
FSMC_NORSRAMInitStructure.FSMC_ReadWriteTimingStruct = &readWriteTiming;
FSMC_NORSRAMInitStructure.FSMC_WaitSignal = FSMC_WaitSignalPolarity_Low;
FSMC_NORSRAMInitStructure.FSMC_WaitSignalActive = FSMC_WaitSignalActive_BeforeWaitState;
FSMC_NORSRAMInitStructure.FSMC_WaitSignalPolarity = FSMC_WaitSignalPolarity_High;
FSMC_NORSRAMInitStructure.FSMC_WrapMode = FSMC_WrapMode_Disable;
FSMC_NORSRAMInitStructure.FSMC_WriteBurst = FSMC_WriteBurst_Disable;
FSMC_NORSRAMInitStructure.FSMC_WriteOperation = FSMC_WriteOperation_Enable;
FSMC_NORSRAMInitStructure.FSMC_WriteTimingStruct = &readWriteTiming;

FSMC_NORSRAMInit(&FSMC_NORSRAMInitStructure); // 初始化FSMC配置

FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM3, ENABLE); // 使能BANK1区域3
}

以上配置就可以让标准库自动连接SRAM并且映射到0x6800000开始的,即FSMC_Bank1_NORSRAM3的地址。

3.1.5 WM8978

只需要配置好gpio,i2c后按照手册发送命令。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
   IIC_Init();//初始化IIC接口
res=WM8978_Write_Reg(0,0); //软复位WM8978
if(res)return 1; //发送指令失败,WM8978异常
//以下为通用设置
WM8978_Write_Reg(1,0X1B); //R1,MICEN设置为1(MIC使能),BIASEN设置为1(模拟器工作),VMIDSEL[1:0]设置为:11(5K)
WM8978_Write_Reg(2,0X1B0); //R2,ROUT1,LOUT1输出使能(耳机可以工作),BOOSTENR,BOOSTENL使能
WM8978_Write_Reg(3,0X6C); //R3,LOUT2,ROUT2输出使能(喇叭工作),RMIX,LMIX使能
WM8978_Write_Reg(6,0); //R6,MCLK由外部提供
WM8978_Write_Reg(43,1<<4); //R43,INVROUT2反向,驱动喇叭
//以下为mic配置
WM8978_Write_Reg(47,1<<8); //R47设置,PGABOOSTL,左通道MIC获得20倍增益
WM8978_Write_Reg(48,1<<8); //R48设置,PGABOOSTR,右通道MIC获得20倍增益
WM8978_Write_Reg(49,1<<1); //R49,TSDEN,开启过热保护
WM8978_Write_Reg(10,1<<3); //R10,SOFTMUTE关闭,128x采样,最佳SNR
WM8978_Write_Reg(14,1<<3); //R14,ADC 128x采样率
return 0;

3.2 文件部分

  • FATFS 为了管理像sd卡这种大容量存储,若直接存储二进制就无法保证文件的快速访问,文件的安全性。所以在这种存储中通常会以一定格式来存储这些文件的数据,常有的就有NTFS(windows),fat,exfat,hfs(苹果)。在SD卡中常用fat格式,而为了解码读取出fat文件格式中的文件数据,在mcu中我们就用FATFS来读取,写入fat文件格式。

    FATFS是一个免费开源的FAT 文件系统模块,专门为小型的嵌入式系统而设计。完全用标准C 语言编写,所以具有良好的硬件平台独立性。可以移植到8051、PIC、AVR、SH、Z80、H8、ARM 等系列单片机上而只需做简单的修改。它支持FATl2、FATl6 和FAT32,支持多个存储媒介;有独立的缓冲区,可以对多个文件进行读/写,并特别对8 位单片机和16 位单片机做了优化。

在这个项目中只需要引入三个头文件并且在fatfs中定义好sd卡读取写入函数和malloc函数即可

1
2
3
#include "ff.h"//fatfs库本体
#include "exfuns.h"//判断文件类型
#include "fattester.h"//封装了一些文件读取,目录访问的函数

  • MALLOC 由于stm32f407内存大且使用了外部sram,如此大的内存就需要合理的管理,本项目实现了一个malloc。

这个malloc将内存平分为一块一块,这里将每32字节分为一块 每块对应内存管理表中的一项 这个表就可以当作一个数组,每次malloc时z只需要访问数组找到空的元素,计算出空内存块的地址即可。 若释放内存就操作数组重置元素即可。

块1 未占用
块2 未占用
块3 未占用
块4 占用

这里只演示外部sram

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//mem2内存参数设定.mem2的内存池处于外部SRAM里面
#define MEM2_BLOCK_SIZE 32 //内存块大小为32字节
#define MEM2_MAX_SIZE 1024 *1024 //最大管理内存1M
#define MEM2_ALLOC_TABLE_SIZE MEM2_MAX_SIZE/MEM2_BLOCK_SIZE //内存表大小

// 内存池
__align(32) u8 mem2base[MEM2_MAX_SIZE] __attribute__((at(0X68000000)));
// 内存管理表
u16 mem2mapbase[MEM2_ALLOC_TABLE_SIZE] __attribute__((at(0X68000000 + MEM2_MAX_SIZE)));
// 内存管理参数
const u32 memblksize[SRAMBANK] = {MEM1_BLOCK_SIZE, MEM2_BLOCK_SIZE, MEM3_BLOCK_SIZE};


///////////////////////////////////////////////////////////////////////////////////////////


u32 my_mem_malloc(u8 memx, u32 size)
{
signed long offset = 0;
u32 nmemb; // 需要的内存块数
u32 cmemb = 0; // 连续空内存数
u32 i;
if (!malloc_dev.memrdy[memx])
malloc_dev.init(memx);
if (size == 0)return 0xFFFFFFFF; // 不需要分配
nmemb = size / memblksize[memx];
if (size % memblksize[memx])
nmemb++;
for (offset = memtblsize[memx] - 1; offset >= 0; offset--)
{
if (!malloc_dev.memmap[memx][offset])
cmemb++;
else
cmemb = 0;
if (cmemb == nmemb)
{
for (i = 0; i < nmemb; i++)
{
malloc_dev.memmap[memx][offset + i] = nmemb;
}
return (offset * memblksize[memx]);
}
}
return 0xFFFFFFFF;
}

3.3 显示部分

3.3.1 tft屏

调用spi,操作spi发送指令。

3.3.2 LVGL

LVGL是一个开源的轻量级图形库,内置了许多控件,任务管理系统。 移植的时候需要给定屏幕的画点函数。(在lvgl库目录下的porting/lv_port_disp.c)

由于lvgl内置了任务系统,所以需要心跳来让lvgld对任务进行时间分配,这里直接放入main函数的while循环即可。

1
2
3
4
5
6
7
#include "lvgl.h"
#include "gui_guider.h"
#include "events_init.h"
while (1) {
lv_tick_inc(1); // tick单位是ms,设置为5ms即可,一般只要有值就行。
lv_task_handler(); // 这个比较重要,从名字就能知道他是用来运行lvgl的task的
}

3.3.3 GUI-GUIDER

GUI Guider是恩智浦提供的用户友好型图形用户界面开发工具,通俗讲就是只需要拖拽操作就可以创建出页面,能够快速画出页面 画完页面只需要导出代码,加入项目目录、引用头文件就可以创建页面。

以上是导出的gui文件夹

其中最主要的是setui_scr_screen.csetup_scr_screen1.c这两个文件中存储了所有ui对象。

这里仅以screen和播放按钮为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
void setup_scr_screen(lv_ui *ui){

//Write codes screen
ui->screen = lv_obj_create(NULL, NULL);

//Write style LV_OBJ_PART_MAIN for screen
static lv_style_t style_screen_main;
lv_style_reset(&style_screen_main);

//Write style state: LV_STATE_DEFAULT for style_screen_main
lv_style_set_bg_color(&style_screen_main, LV_STATE_DEFAULT, lv_color_make(0xe8, 0xe8, 0xe8));
lv_style_set_bg_opa(&style_screen_main, LV_STATE_DEFAULT, 0);
lv_obj_add_style(ui->screen, LV_OBJ_PART_MAIN, &style_screen_main);

//Write codes screen_btn_4
ui->screen_btn_4 = lv_btn_create(ui->screen, NULL);

//Write style LV_BTN_PART_MAIN for screen_btn_4
static lv_style_t style_screen_btn_4_main;
lv_style_reset(&style_screen_btn_4_main);

//Write style state: LV_STATE_DEFAULT for style_screen_btn_4_main
lv_style_set_radius(&style_screen_btn_4_main, LV_STATE_DEFAULT, 50);
lv_style_set_bg_color(&style_screen_btn_4_main, LV_STATE_DEFAULT, lv_color_make(0xff, 0xff, 0xff));
lv_style_set_bg_grad_color(&style_screen_btn_4_main, LV_STATE_DEFAULT, lv_color_make(0xff, 0xff, 0xff));
lv_style_set_bg_grad_dir(&style_screen_btn_4_main, LV_STATE_DEFAULT, LV_GRAD_DIR_NONE);
lv_style_set_bg_opa(&style_screen_btn_4_main, LV_STATE_DEFAULT, 255);
lv_style_set_shadow_color(&style_screen_btn_4_main, LV_STATE_DEFAULT, lv_color_make(0x6f, 0x00, 0x69));
lv_style_set_shadow_width(&style_screen_btn_4_main, LV_STATE_DEFAULT, 0);
lv_style_set_shadow_opa(&style_screen_btn_4_main, LV_STATE_DEFAULT, 255);
lv_style_set_shadow_spread(&style_screen_btn_4_main, LV_STATE_DEFAULT, 0);
lv_style_set_shadow_ofs_x(&style_screen_btn_4_main, LV_STATE_DEFAULT, 0);
lv_style_set_shadow_ofs_y(&style_screen_btn_4_main, LV_STATE_DEFAULT, 0);
lv_style_set_border_color(&style_screen_btn_4_main, LV_STATE_DEFAULT, lv_color_make(0x01, 0xa2, 0xb1));
lv_style_set_border_width(&style_screen_btn_4_main, LV_STATE_DEFAULT, 2);
lv_style_set_border_opa(&style_screen_btn_4_main, LV_STATE_DEFAULT, 255);
lv_style_set_text_color(&style_screen_btn_4_main, LV_STATE_DEFAULT, lv_color_make(0x00, 0x00, 0x00));
lv_style_set_text_font(&style_screen_btn_4_main, LV_STATE_DEFAULT, &lv_font_chinese_12);

//Write style state: LV_STATE_FOCUSED for style_screen_btn_4_main
lv_style_set_radius(&style_screen_btn_4_main, LV_STATE_FOCUSED, 50);
lv_style_set_bg_color(&style_screen_btn_4_main, LV_STATE_FOCUSED, lv_color_make(0xff, 0xff, 0xff));
lv_style_set_bg_grad_color(&style_screen_btn_4_main, LV_STATE_FOCUSED, lv_color_make(0xff, 0xff, 0xff));
lv_style_set_bg_grad_dir(&style_screen_btn_4_main, LV_STATE_FOCUSED, LV_GRAD_DIR_NONE);
lv_style_set_bg_opa(&style_screen_btn_4_main, LV_STATE_FOCUSED, 255);
lv_style_set_shadow_color(&style_screen_btn_4_main, LV_STATE_FOCUSED, lv_color_make(0x6f, 0x00, 0x59));
lv_style_set_shadow_width(&style_screen_btn_4_main, LV_STATE_FOCUSED, 0);
lv_style_set_shadow_opa(&style_screen_btn_4_main, LV_STATE_FOCUSED, 255);
lv_style_set_shadow_spread(&style_screen_btn_4_main, LV_STATE_FOCUSED, 0);
lv_style_set_shadow_ofs_x(&style_screen_btn_4_main, LV_STATE_FOCUSED, 0);
lv_style_set_shadow_ofs_y(&style_screen_btn_4_main, LV_STATE_FOCUSED, 0);
lv_style_set_border_color(&style_screen_btn_4_main, LV_STATE_FOCUSED, lv_color_make(0x01, 0xa2, 0xb1));
lv_style_set_border_width(&style_screen_btn_4_main, LV_STATE_FOCUSED, 2);
lv_style_set_border_opa(&style_screen_btn_4_main, LV_STATE_FOCUSED, 255);
lv_style_set_text_color(&style_screen_btn_4_main, LV_STATE_FOCUSED, lv_color_make(0xf0, 0x00, 0x00));
lv_style_set_text_font(&style_screen_btn_4_main, LV_STATE_FOCUSED, &lv_font_chinese_12);

//Write style state: LV_STATE_PRESSED for style_screen_btn_4_main
lv_style_set_radius(&style_screen_btn_4_main, LV_STATE_PRESSED, 50);
lv_style_set_bg_color(&style_screen_btn_4_main, LV_STATE_PRESSED, lv_color_make(0xff, 0xff, 0xff));
lv_style_set_bg_grad_color(&style_screen_btn_4_main, LV_STATE_PRESSED, lv_color_make(0xff, 0xff, 0xff));
lv_style_set_bg_grad_dir(&style_screen_btn_4_main, LV_STATE_PRESSED, LV_GRAD_DIR_NONE);
lv_style_set_bg_opa(&style_screen_btn_4_main, LV_STATE_PRESSED, 255);
lv_style_set_shadow_color(&style_screen_btn_4_main, LV_STATE_PRESSED, lv_color_make(0x21, 0x95, 0xf6));
lv_style_set_shadow_width(&style_screen_btn_4_main, LV_STATE_PRESSED, 0);
lv_style_set_shadow_opa(&style_screen_btn_4_main, LV_STATE_PRESSED, 255);
lv_style_set_shadow_spread(&style_screen_btn_4_main, LV_STATE_PRESSED, 0);
lv_style_set_shadow_ofs_x(&style_screen_btn_4_main, LV_STATE_PRESSED, 0);
lv_style_set_shadow_ofs_y(&style_screen_btn_4_main, LV_STATE_PRESSED, 0);
lv_style_set_border_color(&style_screen_btn_4_main, LV_STATE_PRESSED, lv_color_make(0x01, 0xa2, 0xb1));
lv_style_set_border_width(&style_screen_btn_4_main, LV_STATE_PRESSED, 2);
lv_style_set_border_opa(&style_screen_btn_4_main, LV_STATE_PRESSED, 255);
lv_style_set_text_color(&style_screen_btn_4_main, LV_STATE_PRESSED, lv_color_make(0x00, 0x00, 0x00));
lv_style_set_text_font(&style_screen_btn_4_main, LV_STATE_PRESSED, &lv_font_chinese_12);

//Write style state: LV_STATE_DISABLED for style_screen_btn_4_main
lv_style_set_radius(&style_screen_btn_4_main, LV_STATE_DISABLED, 50);
lv_style_set_bg_color(&style_screen_btn_4_main, LV_STATE_DISABLED, lv_color_make(0xaa, 0x00, 0x1a));
lv_style_set_bg_grad_color(&style_screen_btn_4_main, LV_STATE_DISABLED, lv_color_make(0xff, 0xff, 0xff));
lv_style_set_bg_grad_dir(&style_screen_btn_4_main, LV_STATE_DISABLED, LV_GRAD_DIR_NONE);
lv_style_set_bg_opa(&style_screen_btn_4_main, LV_STATE_DISABLED, 255);
lv_style_set_border_color(&style_screen_btn_4_main, LV_STATE_DISABLED, lv_color_make(0x01, 0xa2, 0xb1));
lv_style_set_border_width(&style_screen_btn_4_main, LV_STATE_DISABLED, 2);
lv_style_set_border_opa(&style_screen_btn_4_main, LV_STATE_DISABLED, 255);
lv_style_set_outline_color(&style_screen_btn_4_main, LV_STATE_DISABLED, lv_color_make(0xd4, 0xd7, 0xd9));
lv_style_set_outline_opa(&style_screen_btn_4_main, LV_STATE_DISABLED, 255);
lv_obj_add_style(ui->screen_btn_4, LV_BTN_PART_MAIN, &style_screen_btn_4_main);
lv_obj_set_pos(ui->screen_btn_4, 212, 17);
lv_obj_set_size(ui->screen_btn_4, 96, 47);
ui->screen_btn_4_label = lv_label_create(ui->screen_btn_4, NULL);
lv_label_set_text(ui->screen_btn_4_label, "播放");
}

其实guiguider的主要作用就是生成lvgl样式(style)对象减轻ui开发负担。

最终将这些对象都集合在一个结构体中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
typedef struct
{
lv_obj_t *screen;
bool screen_del;
lv_obj_t *screen_btn_4;
lv_obj_t *screen_btn_4_label;
lv_obj_t *screen_label_1;
lv_obj_t *screen_label_2;
lv_obj_t *screen_list_1;
lv_obj_t *screen_btn_5;
lv_obj_t *screen_btn_5_label;
lv_obj_t *screen_btn_6;
lv_obj_t *screen_btn_6_label;
lv_obj_t *screen_bar_1;
lv_obj_t *screen_btn_7;
lv_obj_t *screen_btn_7_label;
lv_obj_t *screen_btn_8;
lv_obj_t *screen_btn_8_label;
lv_obj_t *screen_bar_2;
lv_obj_t *screen_1;
bool screen_1_del;
lv_obj_t *screen_1_bar_1;
lv_obj_t *screen_1_label_1;
lv_obj_t *screen_1_label_2;
}lv_ui;
接下来无论是要修改样式还是制作动画都只需要访问结构体中的对象来设置即可。

3.3.4 UNICODE,UTF-8与GB2312

本项目因为需要显示中文字体,必然绕不开的就是字符编码。 由于fatfs文件系统的中文字符编码是gb2312格式,而lvgl只能显示出utf-8格式的字体所以需要一个将gb2312编码转化为utf-8编码的函数。

首先通过fatfs读出文件名f_readdir(&dir, &fileinfo); 将文件信息读取到fileinfo结构体中。 fn = *fileinfo.lfname ? fileinfo.lfname : fileinfo.fname; 然后将文件名地址赋值给fn指针(fn是一个char*类型的指针)。 之后我们通过遍历*fn来进行处理。

首先,GB2312编码中,每个汉字占两个字节,但是文件名中可能不只有汉字,所以我们可以通过fn[i] & 0x80来判断fn[i]fn[i+1]是否属于一个汉字,若属于一个汉字则将这两个字节拼接:src = fn[i + 1] | (fn[i] << 8);; src中存储的就是一个gb2312编码的汉字。

读取出gb2312格式的汉字后只需要通过fatfs中内置的int temp = (int)ff_convert(src, 1);函数来将其转化为Unicode字符,其原理就是简单的查表,

这一步操作相当于招到了这个汉字的序号,而我们还需要将编码为一种特定的格式,就是utf-8格式

1
2
3
4
U+0000  - U+007F:   0xxxxxxx (1个字节)
U+0080 - U+07FF: 110xxxxx 10xxxxxx (2个字节)
U+0800 - U+FFFF: 1110xxxx 10xxxxxx 10xxxxxx (3个字节)
U+10000 - U+10FFFF: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (4个字节)

例如

1
2
3
4
5
6
7
 4   E    2    D
0100 1110 0010 1101
-----------------------------
1110xxxx 10xxxxxx 10xxxxxx // 上表第三行
____0100 __111000 __101101 // 将 4E2D 的二进制带入上面的格式中
-----------------------------
11100100 10111000 10101101
所以,只需要按照上面的规律实现一个函数就可以转化unicode为utf-8格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
/********************************************    *  ********************************
* 将一个字符的Unicode(UCS-2和UCS-4)编码转换成 UTF-8编码.
*
* 参数:
* unic 字符的Unicode编码值
* pOutput 指向输出的用于存储UTF8编码值的缓 冲 区的指针
* outsize pOutput缓冲的大小
*
* 返回值:
* 返回转换后的字符的UTF8编码所占的字节数, 如 果 出错则返回 0 .
*
* 注意:
* 1. UTF8没有字节序问题, 但是Unicode有字节 序 要求;
* 字节序分为大端(Big Endian)和小端 (Little Endian)两种;
* 在Intel处理器中采用小端法表示, 在此采 用 小端法表示. (低地址存低位)
* 2. 请保证 pOutput 缓冲区有最少有 6 字节 的 空间大小!
******************************************* * ********************************/
int enc_unicode_to_utf8_one(unsigned long unic, unsigned char *pOutput,
int outSize)
{
// assert(pOutput != NULL);
// assert(outSize >= 6);

if (unic <= 0x0000007F)
{
// * U-00000000 - U-0000007F: 0xxxxxxx
*pOutput = (unic & 0x7F);
return 1;
}
else if (unic >= 0x00000080 && unic <= 0x000007FF)
{
// * U-00000080 - U-000007FF: 110xxxxx 10xxxxxx
*(pOutput + 1) = (unic & 0x3F) | 0x80;
*pOutput = ((unic >> 6) & 0x1F) | 0xC0;
return 2;
}
else if (unic >= 0x00000800 && unic <= 0x0000FFFF)
{
// * U-00000800 - U-0000FFFF: 1110xxxx 10xxxxxx 10xxxxxx
*(pOutput + 2) = (unic & 0x3F) | 0x80;
*(pOutput + 1) = ((unic >> 6) & 0x3F) | 0x80;
*pOutput = ((unic >> 12) & 0x0F) | 0xE0;
return 3;
}
else if (unic >= 0x00010000 && unic <= 0x001FFFFF)
{
// * U-00010000 - U-001FFFFF: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
*(pOutput + 3) = (unic & 0x3F) | 0x80;
*(pOutput + 2) = ((unic >> 6) & 0x3F) | 0x80;
*(pOutput + 1) = ((unic >> 12) & 0x3F) | 0x80;
*pOutput = ((unic >> 18) & 0x07) | 0xF0;
return 4;
}
else if (unic >= 0x00200000 && unic <= 0x03FFFFFF)
{
// * U-00200000 - U-03FFFFFF: 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
*(pOutput + 4) = (unic & 0x3F) | 0x80;
*(pOutput + 3) = ((unic >> 6) & 0x3F) | 0x80;
*(pOutput + 2) = ((unic >> 12) & 0x3F) | 0x80;
*(pOutput + 1) = ((unic >> 18) & 0x3F) | 0x80;
*pOutput = ((unic >> 24) & 0x03) | 0xF8;
return 5;
}
else if (unic >= 0x04000000 && unic <= 0x7FFFFFFF)
{
// * U-04000000 - U-7FFFFFFF: 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
*(pOutput + 5) = (unic & 0x3F) | 0x80;
*(pOutput + 4) = ((unic >> 6) & 0x3F) | 0x80;
*(pOutput + 3) = ((unic >> 12) & 0x3F) | 0x80;
*(pOutput + 2) = ((unic >> 18) & 0x3F) | 0x80;
*(pOutput + 1) = ((unic >> 24) & 0x3F) | 0x80;
*pOutput = ((unic >> 30) & 0x01) | 0xFC;
return 6;
}

return 0;
}

将处理完的字符存储在数组中,最后使用lv_list_add_btn(ui->screen_list_1, LV_SYMBOL_AUDIO, utf);就可以在lvgl的列表中加入一个显示中文文件名的按钮。

3.3.5 常用汉字库

在转化为utf-8后,lvgl已经认识了,但是缺少字体文件,接下来还需要将常见的ttf字体文件转化为一个个字符数组,这里我使用了gui-guider内置的字体生成器 最终会生成如下的字模数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

/* U+9ED4 "黔" */
0x0, 0x0, 0x24, 0x9, 0xa0, 0x0, 0x2, 0xc5,
0xe5, 0xf0, 0xc9, 0x20, 0x0, 0x2e, 0x8e, 0xbe,
0xe, 0x1a, 0x0, 0x2, 0xd7, 0xf3, 0xe5, 0x70,
0xb9, 0x0, 0x2c, 0x5e, 0x4a, 0x72, 0xb3, 0xfa,
0x0, 0x34, 0xe7, 0x80, 0xe, 0x12, 0x0, 0x1,
0x2e, 0x10, 0x23, 0x3a, 0x60, 0x3, 0x9a, 0xe7,
0x30, 0x0, 0xd4, 0x0, 0x2a, 0x50, 0x17, 0x40,
0x1f, 0x10, 0x0, 0x85, 0x4b, 0x5a, 0x4, 0xf0,
0x0, 0x2e, 0x45, 0x40, 0x0, 0x7c, 0x0, 0x0,
0x10, 0x0, 0x0, 0x0, 0x0, 0x0,

/* U+9ED8 "默" */
0x0, 0x0, 0x0, 0x5, 0x90, 0x0, 0x3, 0xc3,
0x94, 0xe0, 0x5d, 0x55, 0x0, 0x3b, 0x5e, 0x6c,
0x3, 0xd3, 0xe0, 0x3, 0xb4, 0xe2, 0xd0, 0x3d,
0x5, 0x10, 0x3a, 0x3e, 0x38, 0x36, 0xd4, 0x86,
0x0, 0x43, 0xe3, 0xa0, 0x3d, 0x40, 0x0, 0x0,
0xe, 0x0, 0x4, 0xc7, 0x0, 0x2, 0xbc, 0xe8,
0x40, 0x69, 0xb0, 0x0, 0x19, 0x40, 0x5a, 0x1b,
0x48, 0x90, 0x0, 0x93, 0x7a, 0xb7, 0xa0, 0x2f,
0x80, 0x37, 0x13, 0x5, 0x70, 0x0, 0x74, 0x0,
0x0, 0x1, 0x0, 0x0, 0x0, 0x0,

/* U+9EEF "黯" */
0x96, 0x85, 0xc4, 0xa, 0x50, 0x0, 0xb7, 0xd6,
0xd3, 0x7, 0xb0, 0x50, 0xbb, 0xf9, 0xc5, 0x55,
0x6c, 0xb1, 0xb8, 0xd5, 0xd3, 0x73, 0x4f, 0x20,
0x51, 0xd1, 0x70, 0x56, 0x77, 0x30, 0x13, 0xd6,
0x72, 0x44, 0x85, 0xa5, 0x34, 0xe7, 0x63, 0x73,
0x39, 0x30, 0x8d, 0x84, 0x3, 0xf0, 0xc, 0x60,
0x36, 0x34, 0xc3, 0xf4, 0x4d, 0x50, 0xab, 0x65,
0x84, 0xf0, 0xc, 0x60, 0x41, 0x0, 0x4, 0xe3,
0x3c, 0x50, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,

/* U+9F0E "鼎" */
0x0, 0x0, 0x95, 0x5c, 0x40, 0x0, 0x0, 0x90,
0xe4, 0x3b, 0x52, 0x91, 0x1, 0xe0, 0xe1, 0xa,
0x53, 0xf0, 0x1, 0xe0, 0xe4, 0x3b, 0x53, 0xf0,
0x1, 0xe0, 0xd4, 0x3b, 0x43, 0xf0, 0x1, 0xe3,
0x83, 0x9, 0x46, 0xf0, 0x0, 0x10, 0xc6, 0xf,
0x10, 0x20, 0x4, 0x88, 0xd6, 0xf, 0x55, 0xe3,
0x0, 0xe4, 0xc6, 0xf, 0x10, 0xf1, 0x7, 0x60,
0xc7, 0xf, 0x10, 0xf2, 0x13, 0x0, 0xc6, 0xd,
0x0, 0xc1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,

3.4 多任务

在这么一个播放器中,单片机在同一时间段需要处理许多任务,例如按下按钮的检测,动画的展示,文件的读取,音频数据的传输... 此时若单纯的使用定时器会显得杂乱,不好管理,而且实时性较强的音频解码可能会被阻塞导致音乐卡顿,此时就需要多任务管理。

3.4.1 lvgl内置的任务模块

lvgl内置了一个非抢占式的任务模块(也就是一个任务无法打断另一个任务),虽然任务模块为非抢占式,不如RTOS的抢占式,但是由于其占用少,而且在本项目中的最耗时的刷新屏幕与解码音乐也无法被打断,所以最终选择了lvgl内置的任务模块。

在lvgl中创建一个任务很简单:

1
2
3
4
5
lv_task_t *task      = lv_task_create(my_task, 100, LV_TASK_PRIO_HIGHEST, NULL);
//my_task:任务函数
//100:每次调用间隔
//LV_TASK_PRIO_HIGHEST:优先级为高;
//NULL:传参给任务函数的参数指针。
创建完任务后就可以让lvgl自行根据优先级高低调用任务函数。

在本项目中就通过这种方法结合状态机来解码音乐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
u8 my_task(lv_task_t *task)
{

if (playFlag == 1 && wavtransferend == 1 && audiodev.status & 1)
{
show_secbar(wavctrl.cursec * 100 / wavctrl.totsec);
wavtransferend = 0;
if (wavwitchbuf)
fillnum = wav_buffill(audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps); // 填充buf2
else
fillnum = wav_buffill(audiodev.i2sbuf1, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps); // 填充buf1

wav_get_curtime(audiodev.file, &wavctrl); // 得到总时间和当前播放的时间
if (fillnum != WAV_I2S_TX_DMA_BUFSIZE)
{
// 这里是播放一首歌完毕的时候
audio_stop();
playFlag = 0;
res = 0xff;
fillnum = 0;
myfree(SRAMEX, audiodev.tbuf); // 释放内存
myfree(SRAMEX, audiodev.i2sbuf1); // 释放内存
myfree(SRAMEX, audiodev.i2sbuf2); // 释放内存
myfree(SRAMEX, audiodev.file); // 释放内存
mf_play_next_file("0:");
return 0;
}
return 0;
}
else if (playFlag == 1 && wavtransferend == 0)
{
return 1;
}
else if (playFlag == 0)
{

audiodev.file = (FIL *)mymalloc(SRAMEX, sizeof(FIL));
audiodev.i2sbuf1 = mymalloc(SRAMEX, WAV_I2S_TX_DMA_BUFSIZE);
audiodev.i2sbuf2 = mymalloc(SRAMEX, WAV_I2S_TX_DMA_BUFSIZE);
audiodev.tbuf = mymalloc(SRAMEX, WAV_I2S_TX_DMA_BUFSIZE);
if (audiodev.file && audiodev.i2sbuf1 && audiodev.i2sbuf2 && audiodev.tbuf)
{
res = wav_decode_init(fileName, &wavctrl); // 得到文件的信息
show_bitrate(wavctrl.samplerate, wavctrl.bps);
if (res == 0) // 解析文件成功
{
if (wavctrl.bps == 16)
{
WM8978_I2S_Cfg(2, 0); // 飞利浦标准,16位数据长度
I2S2_Init(I2S_Standard_Phillips, I2S_Mode_MasterTx, I2S_CPOL_Low, I2S_DataFormat_16bextended); // 飞利浦标准,主机发送,时钟低电平有效,16位扩展帧长度
}
else if (wavctrl.bps == 24)
{
WM8978_I2S_Cfg(2, 2); // 飞利浦标准,24位数据长度
I2S2_Init(I2S_Standard_Phillips, I2S_Mode_MasterTx, I2S_CPOL_Low, I2S_DataFormat_24b); // 飞利浦标准,主机发送,时钟低电平有效,24位扩展帧长度
}
I2S2_SampleRate_Set(wavctrl.samplerate); // 设置采样率
I2S2_TX_DMA_Init(audiodev.i2sbuf1, audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE / 2); // 配置TX DMA
i2s_tx_callback = wav_i2s_dma_tx_callback; // 回调函数指wav_i2s_dma_callback
audio_stop();
res = f_open(audiodev.file, (TCHAR *)fileName, FA_READ); // 打开文件
printf("%s\n",fileName);
if (res == 0)
{
f_lseek(audiodev.file, wavctrl.datastart); // 跳过文件头
fillnum = wav_buffill(audiodev.i2sbuf1, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
fillnum = wav_buffill(audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps);
audio_start();
if (res == 0)
{
while (wavtransferend == 0)
; // 等待wav传输完成;
wavtransferend = 0;
if (wavwitchbuf)
fillnum = wav_buffill(audiodev.i2sbuf2, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps); // 填充buf2
else
fillnum = wav_buffill(audiodev.i2sbuf1, WAV_I2S_TX_DMA_BUFSIZE, wavctrl.bps); // 填充buf1

playFlag = 1;
wav_get_curtime(audiodev.file, &wavctrl); // 得到总时间和当前播放的时间

}
}
else
res = 0XFF;
}
else
{
res = 0XFF;
myfree(SRAMEX, audiodev.tbuf); // 释放内存
myfree(SRAMEX, audiodev.i2sbuf1); // 释放内存
myfree(SRAMEX, audiodev.i2sbuf2); // 释放内存
myfree(SRAMEX, audiodev.file); // 释放内存
}
}
else
res = 0XFF;
}
}

值得一提的是:自定义任务的优先级无法高于lvgl内部任务(如刷新动画,按钮检测)。

3.4.2 事件

lvgl具有事件模块,当用户操作屏幕或点击按钮时,通过lvgl可以设置对应事件的回调函数来让单片机做出相应反应。

在这个项目中常用的就是这个方式:lv_obj_set_event_cb(ui->screen_btn_4, screen_btn_4event_handler); 第一个参数是触发事件的对象,第二个是回调函数,这里不区分事件是什么,而是在回调函数的参数event中来判断是什么事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
static void screen_btn_4event_handler(lv_obj_t *obj, lv_event_t event)
{
switch (event)
{
case LV_EVENT_PRESSED:
{

}
break;
default:
break;
}
}

以上这段代码就是判断lvgl的btn4对象是否被按下。

3.5 用户交互

3.5.1 焦点、组与按键

前文提到了任务系统就是隔一段事件以一定优先级来执行任务函数,这与按键扫描类似,lvgl中也是通过轮询的方式来看哪个按键被按下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

lv_group_t *group;
/*Initialize your keypad or keyboard if youhave*/
keypad_init();
/*Register a keypad input device*/
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_KEYPAD;
indev_drv.read_cb = keypad_read;
indev_keypad = lv_indev_drv_register(indev_drv);
// 创建一个group
group = lv_group_create();
lv_indev_set_group(indev_keypad, group);
/* Will be called by the library to read the key */
static bool keypad_read(lv_indev_drv_t *indev_drv, lv_indev_data_t *data)
{
static uint32_t last_key = 0;

/*Get whether the a key is pressed and save the pressed key*/
uint32_t act_key = KEY_Scan(1);
if (act_key != 0)
{
data->state = LV_INDEV_STATE_PR;

/*Translate the keys to LVGL control characters according to your key definitions*/
switch (act_key)
{
case 1:
act_key = LV_KEY_NEXT;
break;
case 2:
act_key = LV_KEY_PREV;
break;
case 3:
act_key = LV_KEY_LEFT;
break;
case 4:
act_key = LV_KEY_RIGHT;
break;
case 5:
act_key = LV_KEY_ENTER;
break;
}

last_key = act_key;
}
else
{
data->state = LV_INDEV_STATE_REL;
}

data->key = last_key;

/*Return `false` because we are not buffering and no more data to read*/
return false;
}

lvgl中定义了几种按键的对应操作,例如代码中的LV_KEY_RIGHT就代表了选中下一个对象(焦点移到下一个对象)。

但在这之前我们还需要告诉lvgl应该在哪几个对象中切换焦点。 这就需要将这些对象加入到一个group中:

1
2
3
4
5
6
7

lv_group_add_obj(group, guider_ui.screen_btn_4);
lv_group_add_obj(group, guider_ui.screen_btn_5);
lv_group_add_obj(group, guider_ui.screen_btn_6);
lv_group_add_obj(group, guider_ui.screen_btn_7);
lv_group_add_obj(group, guider_ui.screen_btn_8);
lv_group_add_obj(group, guider_ui.screen_list_1);

在本项目屏幕上的表现就是下一个按钮发光以及字体变红。

3.5.2 值与文字的设置

在这个界面中,需要设置值的就是标题、采样率、进度条和文件列表项目。

只需要将lvgl对象的值传入lvgl的特定函数就可以设置值了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void show_bitrate(u16 bitrate, u16 bps)
{
lv_label_set_text_fmt(_ui->screen_label_1, "%dhz %dbit", bitrate, bps);
lv_label_set_text_fmt(_ui->screen_1_label_2, "%dhz %dbit", bitrate, bps);
}

void show_secbar(u8 value)
{
lv_bar_set_value(_ui->screen_bar_1, value, LV_ANIM_OFF);
lv_bar_set_value(_ui->screen_1_bar_1, value, LV_ANIM_OFF);
}

void show_title(const char *text)
{
lv_label_set_text(_ui->screen_label_2, text);
lv_label_set_text(_ui->screen_1_label_1, text);
}

3.5.3 界面的切换

前文提及,音频解码需要较高实时性且尽量不被阻塞,刷屏占用较长时间的mcu这就会导致音乐卡顿,于是我的解决方案是在播放音乐时强制跳转到播放界面,只有暂停才可以跳出这个页面,所以这就涉及到lvgl切换页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static void screen_btn_4event_handler(lv_obj_t *obj, lv_event_t event)
{
switch (event)
{
case LV_EVENT_PRESSED:
{
if (audiodev.status == 0)
{

lv_label_set_text(_ui->screen_btn_4_label, "PLAYING");
lv_scr_load(_ui->screen_1);
lv_group_focus_freeze(group, ENABLE);
audiodev.status |= 1 << 0;
}
else
{
audiodev.status &= 0 << 0;

lv_scr_load(_ui->screen);
lv_group_focus_freeze(group, DISABLE);
lv_label_set_text(_ui->screen_btn_4_label, "暂停中");
}
}
break;
default:
break;
}
}

其中:lv_scr_load(_ui->screen_1);函数就是加载另一个页面,这样就会显示播放页面了。

3.6 音频播放

3.6.1 选歌

当焦点移到文件列表上是通过之前提及的事件回调函数就可以判断出点击,点击之后通过回调函数参数可以获得触发事件的对象。

触发事件的对象中含有在文件列表初始化时就写入的文件名(gb2312编码)属性

这样就可以播放音乐了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void screen_list_1_item_event_handler(lv_obj_t *obj, lv_event_t event)
{
switch (event)
{
case LV_EVENT_PRESSED:
{
// 处理文件项目点击事件。
lv_obj_t *play_btn = _ui->screen_btn_4;
strcpy(play_btn->file_src, obj->file_src);
static char src[128];
int i;
for (i = 0; i < 128; i++)
{
src[i] = 0;
}
strcat(src, "0:");
strcat(src, obj->file_src);
if (f_typetell(src) == 0X40)
{
set_play_name(src);//设置播放音乐名
audiodev.status |= 1 << 0;
lv_label_set_text(_ui->screen_btn_4_label, "PLAYING");
lv_scr_load(_ui->screen_1);
lv_group_focus_obj(_ui->screen_btn_4);
lv_group_focus_freeze(group, ENABLE);
show_title(obj->file_utf);

}
}
break;
default:
break;
}
}

3.6.2 切歌

切歌的逻辑本质也是遍历,通过记录当前播放的歌曲来遍历找到正在播放的歌曲,此时就可以知道下一首歌曲的文件名,播放即可。

而播放上一首歌同样也是遍历,每步遍历都保存上一次文件名,知道找到正在播放的音乐,此时播放上一首即可。

这里以播放下一首为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
u8 next_song_flag; // 判断是否下一首歌
// 得到某个文件下一个文件
u8 mf_play_next_file(u8 *path)
{
FRESULT res;
lv_obj_t *play_btn = _ui_fat->screen_btn_4;
char src[128];
memset(src, 0, 128);
char *fn;
fileinfo.lfname = mymalloc(SRAMEX, fileinfo.lfsize);
res = f_opendir(&dir, (const TCHAR *)path);
if (res == FR_OK)
{
while (1)
{
unsigned char utf[128];
res = f_readdir(&dir, &fileinfo);
if (res != FR_OK || fileinfo.fname[0] == 0)
break;

fn = *fileinfo.lfname ? fileinfo.lfname : fileinfo.fname;
if (next_song_flag)
{
next_song_flag = 0;
strcat(src, "0:");
strcat(src, fn);
audiodev.status &= 0 << 0;
strcpy(play_btn->file_src, fn);
if (f_typetell(src) == 0X40)
{
set_play_name(src);

audiodev.status |= 1 << 0;
lv_scr_load(_ui_fat->screen_1);
lv_group_focus_obj(_ui_fat->screen_btn_4);
lv_group_focus_freeze(group, ENABLE);
show_title(src);
}
return 0;
}
next_song_flag = strcmp(play_btn->file_src, fn) == 0;
}
}
myfree(SRAMEX, fileinfo.lfname);
return res;
}

3.6.3 调节音量

调节音量其实就是通过i2c给dac发送信息,以wm8978为例:

1
2
3
4
5
6
7
void WM8978_SPKvol_Set(u8 volx)
{
volx&=0X3F;//限定范围
if(volx==0)volx|=1<<6;//音量为0时,直接mute
WM8978_Write_Reg(54,volx); //寄存器54,喇叭左声道音量设置
WM8978_Write_Reg(55,volx|(1<<8)); //寄存器55,喇叭右声道音量设置,同步更新(SPKVU=1)
}
在调节完之后调用lv_bar_set_value(_ui->screen_bar_2, vol, LV_ANIM_OFF);就可以设置音量条。

3.6.4 显示进度,音频采样率与码率

首先,音频采样率、码率与文件大小在wav文件头部信息中都有,所以只需要获取到文件头信息并且解析就行。

思路:按照wav文件头编写结构体,结构体对象就会自然而然在内存中按照wav头展开,之后若要访问就可以直接访问结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
 
//RIFF块
typedef __packed struct
{
u32 ChunkID; //chunk id;这里固定为"RIFF",即0X46464952
u32 ChunkSize ; //集合大小;文件总大小-8
u32 Format; //格式;WAVE,即0X45564157
}ChunkRIFF ;
//fmt块
typedef __packed struct
{
u32 ChunkID; //chunk id;这里固定为"fmt ",即0X20746D66
u32 ChunkSize ; //子集合大小(不包括ID和Size);这里为:20.
u16 AudioFormat; //音频格式;0X01,表示线性PCM;0X11表示IMA ADPCM
u16 NumOfChannels; //通道数量;1,表示单声道;2,表示双声道;
u32 SampleRate; //采样率;0X1F40,表示8Khz
u32 ByteRate; //字节速率;
u16 BlockAlign; //块对齐(字节);
u16 BitsPerSample; //单个采样数据大小;4位ADPCM,设置为4
// u16 ByteExtraData; //附加的数据字节;2个; 线性PCM,没有这个参数
}ChunkFMT;
//fact块
typedef __packed struct
{
u32 ChunkID; //chunk id;这里固定为"fact",即0X74636166;
u32 ChunkSize ; //子集合大小(不包括ID和Size);这里为:4.
u32 NumOfSamples; //采样的数量;
}ChunkFACT;
//LIST块
typedef __packed struct
{
u32 ChunkID; //chunk id;这里固定为"LIST",即0X74636166;
u32 ChunkSize ; //子集合大小(不包括ID和Size);这里为:4.
}ChunkLIST;

//data块
typedef __packed struct
{
u32 ChunkID; //chunk id;这里固定为"data",即0X5453494C
u32 ChunkSize ; //子集合大小(不包括ID和Size)
}ChunkDATA;

//wav头
typedef __packed struct
{
ChunkRIFF riff; //riff块
ChunkFMT fmt; //fmt块
// ChunkFACT fact; //fact块 线性PCM,没有这个结构体
ChunkDATA data; //data块
}__WaveHeader;

获得采样率和码率只需要调用:lv_label_set_text_fmt(_ui->screen_label_1, "%dhz %dbit", bitrate, bps);就可以格式化修改标签。

获得进度的原理就是计算读取了文件大小的多少然后算出比例:

1
2
3
4
5
6
7
void wav_get_curtime(FIL *fx, __wavctrl *wavx)
{
long long fpos;
wavx->totsec = wavx->datasize / (wavx->bitrate / 8); // 歌曲总长度(单位:秒)
fpos = fx->fptr - wavx->datastart; // 得到当前文件播放到的地方
wavx->cursec = fpos * wavx->totsec / wavx->datasize; // 当前播放到第多少秒了?
}

之后设置进度条就可以了。

四.缺点及下一步方向

4.1 显示部分

  • 刷屏速度缓慢
  • 中文字显示不够清晰,字库仍有字无法显示
  • 缺少其他语言支持

4.2 用户交互

  • 缺少触摸屏支持(硬件已支持)
  • 歌曲、音量历史记录

4.3 音频部分

  • 缺少MP3、flac等音频格式支持

4.4 总结

总的来说stm32f407性能与速度较快但还不够快,尤其在动画的显示与音频解码这种实时性较强的多任务表现较弱,而且若需要对项目进行扩展,其速度可能更加不够。

下一步将会尝试使用速度更快的单片机(如esp32,stm32h7),以及使用上RTOS这种实时性强的系统(如freertos)。


高品质音乐播放器(基于STM32F407)
http://jiangno.com/2025/02/27/25_2_27_audioplayer/
作者
江の
发布于
2025年2月27日
许可协议