基于STM32F411+WM8978的超小MP3播放器

STM32F4拥有相对比较完善的音频系统——独立的音频PLL、灵活的IIS外设,以及足够大的内存和强劲的性能,非常适合用来制作音频播放器。我好像对音频播放应用情有独钟,我从很早之前就开始制作MP3播放器,但是它们要不体积大,要不功能弱,因此我都不是特别满意。下图是我在大二的时候开始设计的一款MP3的最终硬件版本。由于当时水平有限(这块板子的初版是我画的第一块PCB),板子的面积比较大,并且用的方案也是非常老旧的外置解码器VS1053方案,单片机只是负责读卡并把数据搬运进解码芯片里,因此只是实现了基础的功能就被我放弃开发了。但是在制作这个MP3的时候,自己也积攒下了很多设计经验,比如电池管理电路等等。PCB的右下方密密麻麻的就是分立器件的电源通路及充电电路。

设计的第一款MP3

但是我心中一直有着一个制作一台自己满意的MP3的梦想。在高中时候陪伴我最久的电子产品,可以算得上是iPod Shuffle了。它的功能很简单,只支持音频播放,也正是因为这个原因,它才能得以能够在高中阶段陪伴我。虽然早已停产,但其仍然可以被称为苹果最经典的一代纯MP3播放器。从很久之前我就在想,如果自己也能制作一个这样的播放器就好了。虽然看了拆解被直接劝退了,但是做一个神似但形不似的MP3,应该也是可以做到的吧。

一代经典iPod Shuffle 4

于是,我很快开始了硬件选型。首先是单片机的选择,肯定离不开最熟悉的STM32。至于具体的型号,由于这一次要实现较高的集成度,自然是在满足硬件需求的情况下,选最小的封装、最少的引脚数。经过选型,STM32F411CxU6是最合适的选择。它具有QFN48脚封装,由于没有引脚,比QFP的小了一圈,并且有全功能IIS,并且还在48脚封装提供了SDIO的接口,可以说是为这个项目量身设计的。

确定单片机之后,便是音频方案的选择。我制作的的第一个MP3,使用的便是使用独立解码芯片的方案。VS1053除了充当了DAC及耳放的角色,也充当了音频处理器的角色。它内置了一块DSP处理器,我们只需要通过SPI直接将音频文件源源不断的送进去,就可以播放音乐,VS1053会替我们处理好一切关于解码及播放的事情。这样的系统结构比较简单,适合于那些性能不够强无法解码音频流的单片机。第二种方案,就是使用一片单纯的DAC芯片,音频数据由单片机进行解压为PCM,将PCM数据流直接发送到DAC,转换为模拟信号输出。我们选择的STM32F411具有100MHz的主频,完全可以应付各种音频格式的解码,并且还有128K的内存,应对FLAC这种很耗内存的格式也是完全没有问题。第二种方案由于使用单片机自己处理音频,可玩性与可定制性更高,同时也可以帮助我们更好了解音频解码的原理。因此,本制作最终选择了第二种方案。

第二种方案需要一片音频DAC芯片,这里我选用的是WM8978。这个芯片应该有很多人都听说过,就是正点原子STM32F407开发板上使用的音频DAC。这颗芯片具有我们需要的集成耳机放大器、GBUF缓冲器(可以节约两个输出耦合电容)、内部电子音量调节等功能。其实这片芯片还有外放喇叭功放、麦克风ADC等功能,这里都没有用到,还是有一点浪费的。芯片使用IIS接收单片机发送过来的音频信号,IIS的MCLK主时钟由单片机内部的独立PLL提供,也就是说整个系统只需要一个晶振(当然音质党就不要纠结音质了,就是听个响)。单片机同时通过IIC控制芯片的各项参数。

为了将这个MP3设计成一个相对完成且完善的项目,除了基本的音频播放之外,它也应该具有良好的使用体验。为此,我在它的身上加入了USB通讯的功能(固件中未开发,硬件支持),可以用来做USB声卡的实验以及通过USB升级固件。对于电池的部分,我使用了BQ24075T。这是TI出品的一款电池管理芯片,支持电源通路(插入电源使用电源,不插入电源时使用电池)、线性单节锂电池充电器(最高电流支持500mA)、断开电池(对降低关机电流具有非常重要的作用)、充电状态指示、充电电流设定等功能,可以完美的满足我们对于电源管理的需求。

BQ24075T的datasheet

在这里有必要单独提出的就是关机状态下的电流问题以及电源通路的问题。通常情况下,我们希望在关机状态下,对电池的消耗为0,并且充电状态下系统使用外接电源输入,断开电池连接。这就要求我们必须设计一个开关电路,使其在系统工作时导通,系统关机时直接断开电池与后级电池的连接,从根本上杜绝静态电流的产生。简单来说,这部分电路就是一个在电池与系统供电之间的MOS管电路。这个MOS管在关机状态下是断开的,并且受一个按键(也就是兼做开机的按键)的控制,按键按下时MOS管导通。除此之外,这个MOS管在充电的时候也应该断开,整个系统由一个从电源输入跨接到MOS管后的二极管供电。老MP3上的电源管理部分原理图如下图所示,可以看到由分立器件实现还是比较复杂的。

老MP3上的电源管理电路

但当我们使用了BQ24075T之后,这些部分都在芯片内部集成化了。电路也大大被简化了。下图是使用了BQ24075T之后的电源管理部分原理图。图中芯片的SYSOFF脚便是电池与系统电源的控制脚。当此脚为高电平时,电池与系统断开,低电平时连接。实际应用中,此脚接到单片机的一个IO上(开漏),并使用一个二极管连接到播放(长按开机)键上。当系统关闭时,SYSOFF脚由内置的上拉电阻保持高电平,系统供电断开,耗电几乎为0。开机时,按下KEY5,系统上电,单片机开始工作,并开始计算按键按下的时间,当长按达到2秒后,单片机的PWR_EN脚输出低电平,维持系统电源,并完成开机动作。由于二极管的存在,这个低电平并不会影响按键正常的功能。关机时,检测到KEY5长按事件后,单片机将SYSOFF置高。等待按键抬起后,系统电源断开,完成关机动作,系统又回到超低功耗待机状态。

BQ24075T的电源管理电路

确定了方案之后,接下来就是开搞,但是在搞的过程中也遇到了几个问题。首先,单片机的管脚问题。由于芯片的脚数比较少,因此IIS的脚和SDIO的脚出现了冲突,需要将SDIO改为1Bit模式。不过好在MP3的数据量比较小,速度稍微慢一些也没有问题。1Bit模式下,只需要3个IO口,甚至比SPI模式还要少一个。为了追求小巧,所有集成电路都选择了QFN封装。LDO为了省电,选择了静态电流只有3uA的XC6206。

在第一版的制作过程中,出现了很多问题。第一个就是SD卡无法初始化的问题。这个程序在我之前洞洞板焊的板子上是没有问题的,但是到了我画的板子上就出了问题。经过令人绝望的排查,我发现了由于我在洞洞板上使用的是SD卡模块,上面有上拉电阻,因此我就理所当然认为SD卡不用上拉电阻,所以板子上也没有画上拉电阻,这也就导致了SD卡无法识别的问题。第二个问题是电源管理问题。由于是使用的分立器件,由于一些参数问题,会导致插入充电器之后,MOS管没有及时断开,导致仍有一部分电流通过MOS管流向电池。还有第三个问题,就是播放时音频有背景杂音。经过排查,发现杂音出在读取SD卡时的高频电流声串到音频电源中,引起吱吱吱的底噪。

制作的第一版硬件

经过不懈的熬夜与脱发,第三版硬件终于新鲜出炉了。第三版硬件主要是改进了前面说到的几个问题:增加了SD卡上的上拉电阻、改用了集成的充电及电源管理芯片,以及最重要的,解决了杂音的问题。从上图中可以看到,整个电路是一整块地,这样难免会让数字电路的高频杂音串到模拟部分,造成杂音。新版的电路如下图所示。可以看到,新版的电路中,使用了集成的电源管理芯片,大大减少了分立器件的数量,并且分开了数字地与模拟地,并使用0欧姆电阻在远离数字芯片的位置进行单点接地并将模拟电源与数字电源分开,使用两路LDO并使用LC电路分别进行滤波。

新版MP3硬件

同时,新版电路中大大优化了电路,改变了一些器件的封装,电阻改用了更小的排阻等等优化,反正直观上看起来还是蛮漂亮滴。下面再上几张图。

正面照

在背面粘上电池,插上内存卡,就构成了一个比较完整的MP3了。如果不需要调试的话,可以使用烧录夹烧录程序,这样正面的排针就不需要焊接,会更加美观。

背面照,可以看到还是非常小巧的

下面是2D PCB的截图,可以看出左下角的模拟部分的地线是与右边的数字地线分隔开并使用0欧电阻单电接地的。PCB文件可以在文末连接中下载。

PCB截图

说完了硬件,我们来说说软件。软件上,由于这个MP3的功能比较简单,没有过多的用户交互,因此没有使用RTOS,而是直接裸机编程。整个程序分为前台和后台两部分:前台主要是主循环,这个循环里包括了读取文件结构以及播放函数;后台函数主要是定时器中断,中断中处理按键、电源管理、LED闪烁等事务。播放部分,读取SD卡以及将解码后的数据送往IIS均使用DMA,可以大幅降低CPU使用,在等待DMA器件,单片机通过 __WFI() 函数进入休眠状态,等待DMA传输完成中断唤醒后继续执行,喂狗操作也在此时进行。

int main(void) {
   uint8_t error_cnt = 0,res;
   PowerOnSequence();
   WDT_Init();//在SD卡读取与暂停时喂狗
   res = WM8978_Init();
   if(res) { //WM8978初始化失败
      LED_SetBlinkPattern(LED_BLINK_WARNING);
      Delay_WithFeedingDog();
      return 1;
   }
   while(1) {
      res = f_mount(&fatfs,"",1);//尝试挂载文件系统
      if(res) { //失败
         if(error_cnt == 1) {
            LED_SetBlinkPattern(LED_BLINK_ERROR);
         }
         error_cnt ++;
         delay_ms(100);
         if(error_cnt > 100) {
            return 1;
         }
         if(AudioPlayInfo.PlayRes == AudioPlay_Exit) {
            return 1;
         }
         continue;
      }
      LED_SetBlinkPattern(LED_BLINK_CLEAR);
      error_cnt = 0;
      MP3_Player();
      if(AudioPlayInfo.PlayRes == AudioPlay_Exit) { //播放器退出
         return 1;
      }
   }
}

主函数如上所示。主函数首先执行开机过程。由于按下开机按键后或插入充电器后单片机就上电运行,因此该过程首先检测充电器插入状态:若充电器为插入状态,则此时有可能是插入充电器造成的自动开机,程序接下来检测开机按键。如果开机按键未按下,程序则停在 PowerOnSequence() 中,不继续执行后续程序;若开机键按下,则等待长按事件触发后,跳出循环,执行初始化函数。如果电源充电器未插入,则执行上述充电器开机按键按下的操作。此部分代码如下。

void PowerOnSequence(void)
{
   uint8_t boot_cnt = 0;
   uint8_t batt_percrntage;
   
   /*初始化硬件*/
   delay_init();
   Power_Init();
   KEY_GPIO_Init();
   LED_PWM_Init();
   batt_percrntage = Power_GetBattPercentage();
   srand(Power_GetRandSeed());//随机数种子
   while(1) { //充电与开机循环
      if(!PWR_PGOOD) { //插入适配器 开始关机充电
         Power_AdapterStatus = 1;
         while(1) { //充电过程
         
            Power_Switch(1);
            if(PWR_CHRG == 0) { //正在充电
               LEDR(255); LEDB(0);
            }
            else { //充满
               LEDR(0); LEDB(255);
            }
            if(KEY_PLAY_PIN) { //按下开机键
               LEDB(0); LEDR(0);
               break;//退出充电
            }
            if(PWR_PGOOD) { //拔出充电器
               Power_Switch(0);//关闭电源
            }
         }
      }
      while(KEY_PLAY_PIN) { //开机过程
         if(boot_cnt <= 100) { //长按计数
            boot_cnt ++;
         }
         delay_ms(10);
         if(boot_cnt > 100) { //成功开机
            Power_Switch(1);//开启电源
            if(batt_percrntage >= 30){//根据电量亮灯
               LEDB(255); LEDR(255);
            }
            else if(batt_percrntage > 0) { //电池电量低
               LEDR(255);
            }
            else {
               continue;//拒绝开机
            }
         }
      }
      if(boot_cnt > 100) { //成功开机
         break;
      }
      boot_cnt = 0;
   }
   delay_ms(1000);
   LEDB(0);LEDR(0);
   KEY_Timer_Init();
}

在主函数中,可以看到检测初始化SD卡被包含在主循环中,这是方便当SD卡被以意外拔出时,播放主程序因错误返回时,可以无需复位系统,在重新插入SD卡时马上恢复播放而设计的。如果等待SD卡重新插入超时或播放函数返回关机事件,主函数会返回,并执行void _sys_exit(int x)中的函数,完成关机流程。如果初始化无误,就进入如下所示的播放函数。播放函数首先读取文件夹,并将第一层 while 循环作为文件夹切换的循环:若此循环被执行,则发生了文件夹切换。第二层 while循环作为曲目切换的循环,若此循环被执行,则说明发生了文件。第二层while中的 MusicSwitchProc() 函数内部进行了播放的切换,包括各种播放模式的切换等。在最里一层也就是曲目的循环中,会对播放结果进行处理;播放结果来自播放函数内空闲函数读取按键的值,包括切换歌曲、切换文件夹、播放错误等。

AudioPlayRes MP3_Player(void)
{	
   static uint8_t file_error_cnt;
   NumberOfFolders = ReadRootDir();
   CurrentMusic = 0;
   CurrentFolder = 0;
   while(1) { //文件夹切换
      FolderSwitchProc();//切换文件夹
      NumberOfMusics = ReadFileList(FolderList[CurrentFolder]);//切换文件夹
      if(NumberOfMusics == 0) { //当前是空文件夹
         continue;//继续执行上一次的文件夹操作
      }
      file_error_cnt = 0;//复位错误计数
      RandomPlay_Reset();//复位随机播放
      LastValidPlayRes = AudioPlay_Next;//默认下一曲
      while(1) { //曲目切换
         MusicSwitchProc();//切换乐曲
         AudioPlayFile(FolderList[CurrentFolder],FileList[CurrentMusic]);//播放
         if(AudioPlayInfo.PlayRes == AudioPlay_NextFolder || AudioPlayInfo.PlayRes == AudioPlay_PrevFolder) { //若切换文件夹 则退出循环
            break;
         }
         else if(AudioPlayInfo.PlayRes == AudioPlay_Exit || AudioPlayInfo.PlayRes == AudioPlay_OpenFileError || AudioPlayInfo.PlayRes == AudioPlay_ReadFileError) { //播放结束
            return AudioPlayInfo.PlayRes;
         }
         else if(AudioPlayInfo.PlayRes == AudioPlay_Next || AudioPlayInfo.PlayRes == AudioPlay_Prev) {
            LastValidPlayRes = AudioPlayInfo.PlayRes;//保存上一次的正常操作
         }
         else if(AudioPlayInfo.PlayRes != AudioPlay_OK && AudioPlayInfo.PlayRes != AudioPlay_PlayEnd) { //播放出错
            AudioPlayInfo.PlayRes = LastValidPlayRes;//按照上一次操作重新操作 达到跳过的效果
            file_error_cnt ++;
            if(file_error_cnt > 5)//连续错误5次达到阈值
            {
               //错误处理代码
            }
         }
      }
   }
}

后台进程则由定时器负责完成。定时器终端里包含了电源管理、按键长短按处理、LED处理等,当产生相应按键长短按事件之后,会调用 Key_LongPress_Callback()函数与 Key_ShortPress_Callback()函数进行响应的处理。在处理过程中产生的LED事件比普通的LED时间要高,这样可以是LED及时响应按键。

void TIM2_IRQHandler(void)
{
   static Key_Status status = {Key_None,Key_None,0,0};
   LED_BlinkPattern led_pattern = {blink_none		,	0,	0};
   if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { //检查TIM2更新中断发生与否
      led_pattern = Power_Check();//检测电源插入
      status.pressed_key = Key_Scan();//记录按键值
      if(status.pressed_key == Key_None && !status.last_key) { //按键完全抬起之后
         status.long_pressed = 0;
      }
      if(status.pressed_key) { //如果按键按下了
         if(!status.long_pressed) { //当前还未触发长按事件
            status.long_press_cnt ++;
            if(status.long_press_cnt >= KEY_SCAN_LONGPRESS_TRIGGER_TICKS) {
               led_pattern = Key_LongPress_Callback(status.pressed_key);//长按按键事件
               status.long_pressed = 1;
            }
         }
      } else {
         status.long_press_cnt = 0;
      }
      if(status.last_key && (status.pressed_key == Key_None) && !status.long_pressed) { //按键抬起边沿 并且没有触发过长按事件
         led_pattern = Key_ShortPress_Callback(status.last_key);//按键短按事件
      }
      status.last_key = status.pressed_key;//按下时不记录按键状态
      LED_Process(led_pattern);//处理LED闪烁
      TIM_ClearITPendingBit(TIM2, TIM_IT_Update);//清除TIMx更新中断标志
   }
}

音频播放部分,使用libhelix作为MP3的软件解码库,libflac作为flac的软件解码库。二者工作流程均为读取SD卡数据到缓冲区->解压数据并写入解码缓冲区->复制到播放缓冲区->配置DMA使其按照采样率播放。由于音频部分代码比较复杂,有兴趣了解的可以直接在文末下载代码查看。

程序最大支持32G SD卡,最多支持10个目录,每个目录512个文件。程序最终实现的功能为:长按中键开机,开机后中央按键为播放/暂停,长按关机。左右按键短按为上一首/下一首,长按为上个文件夹/下个文件夹,上下键为音量增减,长按上键可以切换顺序播放(单闪)、单曲循环(双闪)、随机播放(三闪),长按下键可以显示电量,LED分别闪烁1-4次代表电量。

程序与PCB原理图下载

1+

留下评论