用户
搜索

该用户从未签到

i春秋作家

Rank: 7Rank: 7Rank: 7

17

主题

44

帖子

3681

魔法币
收听
0
粉丝
87
注册时间
2016-12-7

i春秋签约作者春秋文阁

发表于 2017-12-29 09:38:37 3719816
本帖最后由 immenma 于 2017-12-29 12:17 编辑

序言

在啰嗦一堆话之前,我们先来看看这个云音乐播放器是怎么样的:
a1.jpg

你可以在B站看到这个设备的演示视频

或者在闲鱼直接买下来(假如还没卖出去的话)

当然,”废铜烂铁”当然不是真正的废铜烂铁,不过东西基本都是压箱底的玩意,东西是很简陋的,乃至于那几条杜邦线都是翻箱倒柜找出来的,但麻雀虽小五脏俱全,这玩意是一个实打实的网络云播放器,所有的音乐资源都存储在远端,,除了能够装逼外,除了你真的买不起一个手机,除非你真是只能掏出几十块钱的穷逼还想听在线音乐,除非你想在美好的大学本科四年(研究生有点难)的毕业季最后搞一把校优毕业设计,那么,你的机会来了!

我们先计算一下成本:
   1.VS1053b模块,这个价格为55元,当然,我最开始用的是VS1003b模块,这个只要20块钱还包邮,不用担心,不管是连线还是驱动程序,都能够100%兼容,你把这玩意换VS1003b没一点问题
a2.jpg
   2.STM32F407ZG最小系统
   这玩意估计是最贵的配件了,要58块钱,几个需要关照的参数比如工作频率168M HZ,192+4k SRAM,3个SPI接口,总的来说,这玩意用在云音乐播放器上算是奢侈了,不过我压箱底的就只有这玩意,当然你完全可以用更廉价的STM32F103VET6最小系统,这玩意只要36块钱,当然还有更乞丐的F10x系列,估计30块就够了,如果你自己动手焊接的话,也许12块钱就够了,当然因为我们秉承着最快最简单成本最低的装好逼,因此在成本最低和最简单上我们不得不做出一些艰难的决定,自己焊接这种事情除非你是老手,不然还是直接买来得快
a4.jpg

3.W5500网络模块
  这玩意很便宜,20块钱左右就能拿下,而且驱动编写简单无脑,IO快速延迟低,避免了直接手撸TCP/IP 协议的一堆问题,我实在想不出为什么不买这玩意.
a3.jpg

4.耳机
这个耳机十元包邮,某宝上很多,我就不多说了免得说我打广告.
a5.png

因此在动工之前,你可以先算一笔账,如何做好这玩意
我大致列出了一个价格清单之百元打造云播放器

土豪版本
STM32F407ZGT6最小系统58元+VS1053b模块55元+W5500模块20元+稍微能听的耳机一个50元+杜邦线洞洞板排针若干5元=188元!这价格估计能买个不错的MP3了,但是,买的东西不装逼啊,何况我们的还是云播放器呢.

平民版本
STM32F103VET6最小系统36元+VS1003b模块20元+W5500模块20元+耳机10元+杜邦线洞洞板排针若干5元=91元,你没看错,91元就能让你舒舒服服装个逼,不管是大学里还是高中里,都可以让你的逼格快速提升一个级别,开不开心?

乞丐版本
自己焊接的STM32F103VET6最小系统12元,那么你就在平民版本能省下24元,耳机可以路边摊5元的,那么就省下了29元,那么最后你只需要花62元就能以高逼格形态装上一逼.

硬件的东西基本上就是花花钱接接线的事情了,剩下就是软件的问题了,要打造这个简陋版本的云播放器,你需要完成以下程序的开发
1. STM32Fxxx一些初始化
2. W5500的驱动程序
3. VS10xx的驱动程序
4. 云音乐播放器的Server端
5. 驱动逻辑调度器
当然,开发环境一般使用keil+Visual studio来完成
如果你搞不懂上面的都是上面都是些什么玩意,没关系,这些我都写好了,你可以直接将附件中的源代码哪来编译然后烧录到芯片中.如果你想进入壕无人性的快捷装逼模式,笔者非常愿意将手上的现成品卖给你,除了不包邮之外,另外再收一丢丢的技术支持费用,没错,你没看错,笔者现成可用的成品+源码只需要1024元就能搞到手,让你愉快装个逼.(论文及技术支持费用另算)
a5.jpg



从这里开始
凡事都有个开始,因此在开始的第一件事情首先是理清我们要面对的是什么,简单来说是一个单片机网络音乐播放器.但我们也不得不面对一些”破铜烂铁”所带来的诸多问题,例如片上RAM只有可怜的192KB,因此不可能和PC端开发APP一样奢侈地开上几十兆的内存做缓存,另外168MHZ这一并不算高的主频也对我们的编码提出了挑战,我们不仅仅要合理分配好处理网络与音频的资源分配问题,内部总线的速率乃至于音乐播放的比特率都应该在严格的计算后进行处理,最后开发也是个问题,即使是C语言也并没有提供完整的标准库供你调用,因此很遗憾的是你不能使用一堆别人已经为你铺好的一堆路,这意味着很多时候你必须”造轮子”,但幸运的是笔者就是一个宁愿栽在自己手里也不想在别人的库中处处躲藏着一堆坑而死的不明不白,另外,VS1053的硬件码与W5500自带的Socket也帮我们省下了一大堆解码与协议上的问题.
   现在,如果和笔者一样有造轮子强迫症,那么,这篇文章接下来的内容,就是讨论如何造轮子的.
  在动工之前,一些必备的开发及调试环境也是必须的,在本章内容中,使用的开发环境为keil,笔者使用的版本为5.11.0,作为钦定的嵌入式开发IDE,有着对大部分主流芯片的支持 ,自动加入startup(类似于bootloader)代码也是
一大亮点,当然keil还有着屎一样的代码编辑体验绝对让你印象深刻.
a6.png
但不管怎么说,嵌入式开发八九不离十需要这个软件的支撑,集成好的环境总比你自己捣鼓些什么gcc交叉编译快得多,又没人看你编译方案多牛逼,为什么不挑选更方便快捷的方案呢?
   然后是visual studio这个号称宇宙最强的IDE,虽然在哪个IDE好用上可以说是各有说辞,但按笔者多年的使用经验来说,Visual studio的用户体验几乎可以用无出其右来形容,搭配visual assist X更是让码起来的感觉就像吸了毒,难怪不少码畜码起来废寝忘食,无需怀疑,visual studio的用户体验是毋庸置疑的.
   在本项目中,我们将使用Visual studio 编辑器用于编辑单片机与音乐服务端的代码,如果你不熟悉visual studio 我建议你需要稍微花点时间对其界面及操作学习.
   另外虽然所有的源代码在附件中都可以直接编译执行或者直接烧录到芯片当中,但是笔者仍然建议读者购买JLINK或者ST-Link等调试器并自己着手修改代码来完成自己所需要的功能.
a7.jpg
    最后,本文假设读者熟悉C语言,对数电模电与TCP/UDP网络通讯有基础了解,同时熟悉keil及visual studio的基本操作.



内部总线协议的选择
      其实本文标题起的并不怎么准确,其实不是选择,而是datasheet已经决定了,就用SPI总线来做模块间的通讯协议,简单来说就是SPI是钦定的,你没得选,但写这篇文章的目的,更多是为什么选择SPI协议而不使用别的.
首先是最直观的一点,不管是W5500还是VS1053模块都支持SPI协议,并且STM32F407ZGT6有三个SPI接口有良好的硬件加速,同时STLibrary(ST公司提供给STM系列的库)中有着对SPI的封装库,这意味着我们能不用花太多功夫在底层通讯协议上.
其次是SPI协议用到的引脚只有四根,连线简单,而且在速度上相对于串口这种龟速接口,至少能达到我们的要求,即便不使用DMA,也不会对效率上产生难以负担的问题.
因此内部总线的协议我们就选用SPI,当然本篇文章并不是专门给读者介绍SPI协议的,但列出一些关键的要点,让不了解SPI协议的朋友涨涨姿势,传授点人生经验,同时说明下时钟对SPI速率的影响好让他跑的比某地记者还快,那肯定是最吼的.
首先是SPI的连线方式,用一张图来概括,那就是
a8.jpg
在模块上这些引脚都有进行标注
a9.jpg
(W5500模块的引脚标注,SCS表示SS)
b1.jpg
(VS1053模块的标注SI表示MOSI,SO表示MOSI,XCS表示SS)
同时通过查阅STM32F407ZGT6的原理图,我们很容易找到三个SPI对应的引脚接口.在本篇中,我们只使用到了SPI1与SPI2作为外接外设
b2.jpg
因此现在你可以找到W5500和VS1053模块上的标注,并且对照其原理图的标注使用杜邦线把它接到STM32F407ZGT6的核心板上去了.
b3.jpg





SPI是如何工作的
在简短地科普SPI协议以前,我回归到最原始的电这一基本概念上来,当然我们并不打算在这上面夸夸其谈,但说到电不可避免的就是必须谈到电压这一概念,高中数学告诉我们,电压的单位是伏特,我们往往用字母V来表示电压这一单位.
  如果两个电压不同的链路相连,电流就会从高电压往低电压“流动”,同时,电压也意味着更多东西。例如人体的安全电压是36V,除非你想“爽一把”,否者我不建议任何人去碰超过安全电压的电线,单片机的供电电压一般为12v,5V或3.3v,同时IO引脚的输出电压一般也不会超过5V,因此你不用担心触碰裸露的引脚会对你的生命安全造成多大的威胁,最后也就是我们主要需要关注的重点,在数电或模电中,我们往往规定某一电压为“低电平”并规定另一个比较高的电压为“高电平”,并以此来当做数学上的0和1(或者相反),因此可以想象,当一条线路上从低电平上升到高电平,实际上是这样一个图形
b4.jpg
如果你仔细看的话,这个上升的过程并不是100%垂直的,但这个上升的时间如此之短,因此在理想的状态下,我们希望这个上升的过程越短越好接近于垂直,我们管这个“上升”的直线叫上升沿。同样的,当这个电压又从高电平到低电平,图形就变为了
b5.jpg
同样的,我们把这个下降的过程叫下降沿,用时把这个上升又下降的一个过程叫做“脉冲”,因此,一个脉冲肯定包含了一个上升沿和一个下降沿。
实际上“脉冲”这一概念贯穿了整个数字电路的理念,在很多的通讯协议中,一秒钟内完成多少个这种“脉冲”直接和它的通讯速率挂钩,实际上SPI协议四根线中的SCLK引脚,就是不断地循环脉冲这一过程,显然,通讯协议的最终目的就是“读和写”或者叫接收数据发送数据,那么问题来了,什么时候发送数据,什么时候接收数据?
  这里就来到了SPI的第一个理念,相位。SPI一共有两种相位,即上升沿采样或降沿采样,现在我们假设SPI被设置为第一边沿采样,那么依照下图
b6.jpg
MISO为SPI主机输入,MOSI为主机输出,可以看到,在一个个边沿也就是上升沿,SPI从MISO读取数据,我们假设低电平为0,那么SPI读取到的第一个数据就为0,然后在第二个边沿也就是第一个下降沿,MOSI为输出数据,因为MOSI这个时候为高电平。也就是SPI向外发送了一个1,重复这一个过程,SPI就完成了数据通信
在上图的三个脉冲中,SPI读取了数据011,并发送了数据100,因为在一个脉冲中能同时完成一个位的收发,所以这个发送模式又被称为“全双工模式”。假设SCLK的频率是1
024HZ,那么在一秒钟内,通过SPI能够发送与接收的数据分别为1024/8=256字节,如果频率为1MHZ,那么就是256Kbytes/s的速度.
  显然的,需要SPI能够完成通讯需要相互商量好何时读写时钟范围
  SPI的主要参数如下
CPOL:时钟极性
CPHA:时钟相位
当CPOL为0时,初始状态也就是时钟空闲时电平为低;
当CPOL为1时,时钟空闲时电平为高;
当CPHA为0时,时钟周期的上升沿采集数据,时钟周期的下降沿输出数据;
当CPHA为1时,时钟周期的下降沿采集数据,时钟周期的上升沿输出数据;




开始编码类型的准备工作
如果在PC上敲代码,笔者会用下面一张图来表达我的心情
b7.jpg
但嵌入式裸机开发显然不能那么奔放,你必须小心翼翼地考虑到资源,时钟,运行效率等一系列问题,在某些情况下你必须自行实现内存管理,代码调度等一系列机制,在某些情况下,某些书本上的框架实现将变成瞎扯淡,你必须按照你对资源的理解量身订做适合这个系统的程序.
不管怎么样,笔者在编码的开头都习惯做一些准备工作,例如将关键类型做一下重命名
例如如下代码
[C] 纯文本查看 复制代码
#define     _IN[/size]
#define     _OUT
#define     PX_FALSE                   0
#define     PX_TRUE                            1
#define        PX_NULL                      0
#define     PX_PI                             3.14159265359f
typedef        void                            px_void;
typedef        int                               px_bool;
typedef        unsigned int             px_dword;
typedef    short             px_short;
typedef    unsigned short         px_word;
typedef        unsigned short         px_ushort;
typedef    unsigned int             px_uint;
typedef    int                               px_int;
typedef        char                           px_char;
typedef        char                           px_byte;
typedef        unsigned char          px_uchar;
typedef        unsigned long          px_ulong;
typedef        long                           px_long;
typedef    float                            px_float;
typedef    double                       px_double;
typedef    long long         px_qword;
typedef     unsigned long long  px_u64;
typedef     unsigned int        px_u32;
typedef     unsigned short      px_u16;
typedef     unsigned char       px_u8;

不管怎样,将一些关键字按照自己的规范进行重命名是个好习惯,在接近底层的开发中,我们很多时候需要关注数据的大小与对齐等一系列问题,因此我们不希望编译器与环境的不同导致在不同的地方导致过多的偏差(而导致项目需要大量修改),因此在项目中尽量的使用自定的类型,以便于说在以后的移植中做的代码修改量最少.
另外就是一些常用类型与常亮,例如TRUE和FALSE,NULL这样的经常用得到的常量或者是π这样的数学常量最好也先坐定义,然后_IN _OUT这样的空宏对参数的说明上的标注也非常有好处,这些工作都可以在一个头文件中定义完成.



时间中断
在讨论驱动编写之前,我们首先要先处理好定时器,毕竟在驱动编写的过程中,常常不可避免的需要编写延迟函数,如果在PC端开发,也许一个延迟函数仅仅只是需要简单的调用sleep函数就可以了,但在单片机中,你不得不了解时间是怎么来的.
大部分的单片机芯片都有提供时间中断,大致意思是你设置一个寄存器,该寄存器在某一时间间隔都会被减去1或加上1,当这个值变为0或者溢出后,程序就会跳转执行时间中断函数,现在的问题是,如何去设置时间的间隔,在附件中你可以找到stm32f407zgt6芯片的datasheet也就是数据手册,通过查找block diagram你将会看到下面这一张图
b8.jpg

上面标注的是个模块连接到的总线是哪块,同时这张图还给出了该总线的时钟频率,如果是新手的话,相信看到这张图已经开始觉得一整反胃了,然而笔者并不会说这张图看起来其实没那么复杂,我想说的是,等你天天被恶心,你总会习惯的,言归正传,假如你实在觉得头晕的话,这张表格下面的一段话给你划出了重点

b9.jpg
大致意思是当计时器连接到了APB2总线时,他的频率是168MHZ,当连接到APB1总线时,是84MHZ,在默认的情况下,计时器连接到APB2总线,也就是有一个寄存器其每秒钟做168M次减法,我们将这个寄存器,设置为时钟频率的千分之一,我们就能精确地延迟1ms,将它除以百万分之一,就能精确延迟1us
我们通过ST库函数中的SysTick_Config来设置这个计时寄存器,当计时寄存器为0后,它会重新设置为你设置的值,所以这个函数调用一次就够了:
[C] 纯文本查看 复制代码
SysTick_Config(168000000/1000);
当寄存器到0后,引发时间中断,调用回调函数
[C] 纯文本查看 复制代码
void SysTick_Handler(void){}
这个函数你可以在stm32f4xx_it.c中找到,在笔者的代码中,笔者定义了一个全局变量用于计算从芯片开始工作的时间.我们可以使用TimeGetTime函数来取得这个时间.同时用sleepms来延迟若干毫秒.
[C] 纯文本查看 复制代码
//SysTick handler[/size]
unsigned int __g_Timer=0;
void SysTick_Handler(void){g_Timer++;}
uint32_t TimeGetTime(void){return __g_Timer;}
void sleepms(uint32_t ms){
uint32_t CurrentTime=TimeGetTime();
   while(TimeGetTime()-CurrentTime<ms);
}




驱动架构
    如果按教科书上说的,代码架构应该高内聚低耦合,应该自顶向下,但笔者一直秉承着一句话,脱离环境讲什么架构优秀就是瞎扯淡,纸上谈兵就和说我明天买彩票一定会中奖一样不可靠,换句话说,应该叫只有合适的架构,没有优秀的架构.
不管如何来说,我们设计架构的目的是让代码明了好用,并且保证代码能够不用修改或很少修改就能移植到另外一个平台,毕竟谁都希望在以后少写代码少造同样的轮子.尤其在驱动编写的过程,不可避免的涉及一堆底层操作,如何封装好硬件有关的代码和无关的代码,直接影响到这个代码以后移植是否能做到快速好用.
按照一般的情况而言,应该将总线通讯的初始化代码封装为一块,然后在通讯协议的基础上,编写驱动的工作代码建立硬件虚拟层,最后在最上层调用驱动,实现功能,然而事实是,这种自顶向下的设计模式糟糕的一笔,你不得不花大量的时间封装文档,告诉别人或未来早已经忘记这段代码写的是什么的自己,应该到哪个源代码里去修改参数,再到哪个文件里去修改GPIO引脚,再去哪里改改时间延迟函数,更糟糕的是,假设如果这个设备和其它设备出现共用总线时,你不得不重新再考虑你通讯协议代码封装的在当前环境合不合适了.更有甚者,你需要移植到其它系列芯片中,这不得不让你重写通讯协议代码,你不得不再分出心思来把以前的代码实现全部删掉,然后在注释上写上,本驱动不实现通讯代码,你需要打开xx文件,在哪里添加初始化代码,在哪里添加发送数据代码,在哪接收……相信我,这所谓的自顶向下简直蠢爆了.
其实笔者总结了下驱动开发的规律,基本上九成以上的驱动,总结起来无非就是数据读和写的加上IO引脚控制和时间延迟的问题,至于通讯协议,无非还不是为了完成读写操作,本身并没有什么特别的内容,为什么不使用一个函数指针来规定驱动的读写与IO操作函数,这样我们就能够将驱动和通讯协议IO口操作剥离开来.实际上笔者使用这一方案多年,并且觉得确实在大部分的环境中工作良好,效率高效
实现的代码实际非常简单,定义一个PX_Linker结构体
[C] 纯文本查看 复制代码
typedef struct __PX_LINKER[/size]
{
      px_void *data;
      px_bool (* _PX_LinkerInit)(px_void *Info);
px_int  (* _PX_LinkerWrite)(_IN px_void *buffer,px_int size);
px_int  (* _PX_LinkerRead) (_OUT px_void *buffer,px_int size);
px_int (* _PX_LinkerIOCTL)(_IN px_int ioctl,_IN px_int io,_IN px_void *param);
}PX_Linker;

当中包含四个函数指针,一个data类型指针
其中_PX_LinkerInit用于实现通讯协议的初始化代码, _PX_LinkerWrite用于实现写数据代码_PX_LinkerRead用于实现读数据代码_PX_LinkerIOCTL则用于实现所有其他的控制代码,包括延迟和GPIO口的控制.
同时我们提供四个函数对PX_Linker进行操作避免直接操作结构体
其中PX_LinkerInit用于对PX_Linker进行初始化,包括设置其四个函数指针,函数原型如下
[C] 纯文本查看 复制代码
px_bool  PX_LinkerInit(PX_Linker *linker, px_void *Init,px_void *Write,px_void *Read,px_void *ioctl,px_void *param);
PX_LinkerWrite为一个宏定义,其目的仅仅只是调用写函数,其宏定义规则如下.其中lnk为指向PX_Linker的指针,buffer为写buffer,size为希望写入的字节数
[C] 纯文本查看 复制代码
#define PX_LinkerWrite(lnk,buffer,size) ((lnk)->_PX_LinkerWrite(buffer,size))
PX_LinkerRead为一个宏定义,其目的仅仅只是调用读函数,其宏定义规则如下.其中lnk为指向PX_Linker的指针,buffer为读取buffer指针,size为缓存区最大的大小.
[C] 纯文本查看 复制代码
#define  PX_LinkerRead(lnk,buffer,size) ((lnk)->_PX_LinkerRead(buffer,size))
PX_LinkerIOCTL用于调用PX_Linker中的控制函数.其中参数IOCTL为控制标识符.io及param都为传入参数.
[C] 纯文本查看 复制代码
#define  PX_LinkerIOCTL(lnk,ioctl,io,param) ((lnk)->_PX_LinkerIOCTL(ioctl,io,param))
在编写驱动时,我们只需要在上层(例如main函数中)去考虑芯片相关的通讯协议,同时实现几个PX_Linker 所需要的代码实现,然后将PX_Linker传递给驱动程序的逻辑实现当中就可以了,因为不包含主控芯片相关的代码,因此驱动的代码直接拷贝到其它的项目中也不会出什么问题,需要做的就仅仅只是重新在自己喜欢的地方完成通讯总线的实现就可以了.



VS1053b驱动程序开发SPI协议
VS1053连接到了STM32F407ZGT6的SPI2接口上,你可以在附件中找到VS1053B的datasheet,有关SPI通讯协议的要求你可以在DataSheet的第18页找到
c1.jpg
可以看到,第一个数据在SCK的第一个上升沿发生时,为了保证建立保持时间(就是电瓶变换后电压的维持时间),这个上升沿应该在半个周期后
c2.jpg
因此,SPI的时钟初始电平应该设置为高,在第一个上升沿就是脉冲的第二个边沿.除此之外,在Datasheet的第23页还提到了
c3.jpg
SPI的最大速度不得超过CLKI的六分之一,第三段提到,在初始状态下,CLKI=XTALI,通过对datasheet的进一步查找
c4.jpg
XTALI的速度应该在12-13MHZ之间,因此,SPI的速度不应该超过大约2.16MHZ,我们回到STM32F407ZGT6的数据手册上来,从图中我们可以看出,SPI2属于APB1总线(由AHB二分频而来),其最大的速率为42MHZ


c5.jpg

因此为了通讯速度不大于2.16MHZ,它至少应该被32分频.
同时我们查看SPI的时序图
c6.jpg

得知,其数据为8位,同时高位在前低位在后.
因此,SPI2的初始化代码如下
[C] 纯文本查看 复制代码
RCC_APB1PeriphClockCmd(RCC_APB1Periph_SPI2,ENABLE); [/size]
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; //全双工
      SPI_InitStructure.SPI_Mode = SPI_Mode_Master;  
      SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;//8位  
      SPI_InitStructure.SPI_CPOL  = SPI_CPOL_High;//极性
      SPI_InitStructure.SPI_CPHA = SPI_CPHA_2Edge;//相位
      SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;  
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_32;//波特率32分频
      SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;//高位在前地位在后
      SPI_InitStructure.SPI_CRCPolynomial = 7;
      SPI_Init(SPI2,&SPI_InitStructure);
      SPI_Cmd(SPI2,ENABLE);



VS1053b命令与寄存器
   不管怎么说,对硬件驱动的编写第一步个人还是比较喜欢先完成硬复位这一功能
  在Datasheet的9.2章节中,复位可以通过对XRESET也就是RST引脚拉低来完成,在拉低RST引脚后,DREQ引脚会拉低22000个时钟,也就是说,假如VS1053b运行在12.288MHz的频率,复位会有个1.8ms左右的延迟,当然,你可以通过判断DREQ引脚是否回到高电平来判断芯片是否成功复位.
   之后是编写VS1053的最基本操作功能了,对VS1053b的操作是通过寄存器的读写来完成的,在VS1053b的datasheet中,管这种操作叫SCI(Serial Command Interface)下面两张时序图,分别是对VS1053b的寄存器读与写的时序图:
c8.png
c7.jpg

同时,在下图中给出了SCI读写的命令格式:
c9.png
从时序图与给出的格式我们可以知道,在命令读的时候,首先先将XCS引脚拉低以片选设备,然后需要发送一个0x03表示读命令的操作码,最后发送一个16位地址,在此之后,该地址的值将会由MISO传输回来.
在命令写的时候,拉低XCS片选,发送一个0x02表示写命令的操作码,之后发送一个16位地址,之后发送一个16位的值,最后需要拉高XCS引脚,在命令执行期间,QREQ引脚将会被拉低至低电平,直到命令完成,才会回到高电平状态.
在到这一步的时候,我们基本可以完成VS1053b驱动最核心的部分了.
首先,我们知道,VS1053b除了SPI所需的三个引脚外(SS引脚由软件控制),还有XCS,RST,DREQ,XDCS三个引脚,因此,在控制函数中,除了对延迟函数的实现,还有对三个引脚的控制
下面的代码实现了SPI的读写和IOCTL(IO controller),我们完成这个代码,并将它设置给PX_Linker中(我们将XCS连接到PB12,DREQ连接到PB10.XDCS连接到PB11,XRST连接到PB9)
[C] 纯文本查看 复制代码
px_int PX_PROTOCAL_VS10xx_IOCTL(px_int ioctl,px_int io,px_void *param)
{
      px_int v=io;
      px_u16 PIN;
      if(ioctl==PX_DEVICE_VS10xx_IOCTL_XDCS) PIN=GPIO_Pin_11;
      if(ioctl==PX_DEVICE_VS10xx_IOCTL_XCS) PIN=GPIO_Pin_12;
      if(ioctl==PX_DEVICE_VS10xx_IOCTL_DREQ) PIN=GPIO_Pin_10;
      if(ioctl==PX_DEVICE_VS10xx_IOCTL_RST) PIN=GPIO_Pin_9;
      switch(ioctl)
      {
           case PX_DEVICE_VS10xx_IOCTL_SLEEPMS:
                 sleepms(v);
                 break;
           case PX_DEVICE_VS10xx_IOCTL_XDCS:
           case PX_DEVICE_VS10xx_IOCTL_XCS:
           case PX_DEVICE_VS10xx_IOCTL_RST:
           {
                 if(v)
                      GPIO_SetBits(GPIOB, PIN);
                 else
                      GPIO_ResetBits(GPIOB, PIN);
           }
           break;
           case PX_DEVICE_VS10xx_IOCTL_DREQ:
                 return GPIOB->IDR & PIN;
           case PX_DEVICE_VS10xx_IOCTL_SPI_HIGHSPEED:
           //  assert_param(IS_SPI_BAUDRATE_PRESCALER(SPI_BaudRatePrescaler_4));
                 SPI2->CR1&=0XFFC7;
                 SPI2->CR1|=SPI_BaudRatePrescaler_4;
                 SPI_Cmd(SPI2,ENABLE);
                 break;
           case PX_DEVICE_VS10xx_IOCTL_SPI_LOWSPEED:
           //      assert_param(IS_SPI_BAUDRATE_PRESCALER(SPI_BaudRatePrescaler_256));
                 SPI2->CR1&=0XFFC7;
                 SPI2->CR1|=SPI_BaudRatePrescaler_32;
                 SPI_Cmd(SPI2,ENABLE);
                 break;
      }
      return 0;
}
px_int PX_PROTOCAL_VS10xx_Write(px_void *buffer,px_int size)
{
      px_char *p=(px_char *)buffer;
      while(size--)
      {
           while((SPI2->SR&SPI_I2S_FLAG_TXE)==RESET);
           SPI2->DR = *p;
           while((SPI2->SR&SPI_I2S_FLAG_RXNE)==RESET);
           SPI2->DR;
           p++;
      }
      return 1;
}
px_int PX_PROTOCAL_VS10xx_Read(px_void *buffer,px_int size)
{
      px_char *p=(px_char *)buffer;
      while(size--)
      {
           while((SPI2->SR&SPI_I2S_FLAG_TXE)==RESET);
           SPI2->DR = 0x00;
           while((SPI2->SR&SPI_I2S_FLAG_RXNE)==RESET);
           *p=SPI2->DR ;
           p++;
      }
      return 1;
}

最后,我们实现了读写寄存器的两个函数,你可以在附件的源代码中查看到它的完整实现,不过就和我们之前说的步骤一样,只不过他从文字变成了代码:
[C] 纯文本查看 复制代码
px_void PX_DEVICE_VS10xx_Write_Register(PX_DEVICE_VS10xx *device,px_u8 addr,px_u16 data)[/size]
{
  if(PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_DREQ,0,PX_NULL)==0)
           return;
PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_SPI_LOWSPEED,0,PX_NULL);
      PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_XDCS,1,PX_NULL);
      PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_XCS,0,PX_NULL);
PX_DEVICE_VS10xx_WriteByte(device,PX_DEVICE_VS10xx_VS_WRITE_COMMAND);
      PX_DEVICE_VS10xx_WriteByte(device,addr);
      PX_DEVICE_VS10xx_WriteByte(device,data>>8);
      PX_DEVICE_VS10xx_WriteByte(device,data);
      PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_XCS,1,PX_NULL);
PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_SPI_HIGHSPEED,0,PX_NULL);
}
 
px_u16 PX_DEVICE_VS10xx_Read_Register(PX_DEVICE_VS10xx *device,px_u8 addr)
{
      px_u16 reg;
  if(PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_DREQ,0,PX_NULL)==0)
           return 0;
PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_SPI_LOWSPEED,0,PX_NULL);
      PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_XDCS,1,PX_NULL);
      PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_XCS,0,PX_NULL);
  PX_DEVICE_VS10xx_WriteByte(device,PX_DEVICE_VS10xx_VS_READ_COMMAND);
      PX_DEVICE_VS10xx_WriteByte(device,addr);
      reg=(PX_DEVICE_VS10xx_ReadByte(device)<<8);
      reg|=PX_DEVICE_VS10xx_ReadByte(device);
      PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_XCS,1,PX_NULL);
PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_SPI_HIGHSPEED,0,PX_NULL);
  return reg;
}
至此,我们完成了VS1053b驱动最基本的功能,你可以尝试调用PX_DEVICE_VS10xx_Write_Register和 PX_DEVICE_VS10xx_Read_Register函数去读写地址看看写入的值和读出的值是否一致,如果一致,表明你的通讯协议工作正常.



VS1053b写数据
VS1053b写音频数据非常的简单,只需要拉低XCS片选.拉低XDCS数据片选,然后通过SPI直接发送音频文件数据就可以了,VS1053b会自动识别音频文件的文件头并且对其进行解码.
如果你的的配置正确的话.应该很快就能听到音乐了.那么,问题就变成了我们何时向VS1053b写数据.根据datasheet
d2.png
当DREQ引脚拉高时,VS1053b至少能接收32字节的数据,因此,我们每次就向VS1053b写入32字节的数据.
下面的代码实现了向1053b写数据,当DREQ引脚为高时,这个函数将会直接返回PX_FALSE
[C] 纯文本查看 复制代码
px_bool PX_DEVICE_VS10xxWrite(PX_DEVICE_VS10xx *device,px_byte data[32])[/size]
{
      px_u8 i;
      if (PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_DREQ,0,PX_NULL))
      {                                       
      PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_XDCS,0,PX_NULL);
           for(i=0;i<32;i++)
                 PX_LinkerWrite(device->linker,&data,1);
      PX_LinkerIOCTL(device->linker,PX_DEVICE_VS10xx_IOCTL_XDCS,1,PX_NULL);
           return PX_TRUE;
      }
      return PX_FALSE;
}





VS1053驱动核心功能
笔者原本希望将驱动所有的函数功能是怎么实现的,代码是怎么写的与他根据datasheet的哪一步来完成都写出来,但是其具体的步骤在datasheet中已经写得非常详细了,因此在本篇文中,我们接下来更多讨论的是如何实现,而不是如何写代码.
在上面的篇幅中,我们实现了读写寄存器与如何向VS1053b中写入数据.
现在,你可以在附件中找到并打开PX_DEVICE_VS10xx.h
并找到如下几个函数的定义:
[C] 纯文本查看 复制代码
px_bool PX_DEVICE_VS10xxInit(PX_DEVICE_VS10xx *device,PX_Linker *linker);[/size]
px_bool PX_DEVICE_VS10xxReset(PX_DEVICE_VS10xx *device);
px_bool PX_DEVICE_VS10xxSoftReset(PX_DEVICE_VS10xx *device);
px_bool PX_DEVICE_VS10xxWrite(PX_DEVICE_VS10xx *device,px_byte data[32]);
px_bool PX_DEVICE_VS10xxPatch(PX_DEVICE_VS10xx *device,px_byte *bin,px_int binsize);
px_void PX_DEVICE_VS10xxSinTest(PX_DEVICE_VS10xx *device);

其中PX_DEVICE_VS10xxInit完成了VS1053b的初始化操作,它的实现包括初始化PX_DEVICE_VS10xx结构体并将它连接到对应的PX_Linker当中.
PX_DEVICE_VS10xxReset实现了VS1053b的复位,在它的实现中包含一个硬复位和一个软复位
PX_DEVICE_VS10xxSoftReset则是软复位的实现,你可以在VS1053b播放完一首歌或者切歌时调用这个复位函数.
PX_DEVICE_VS10xxWrite用于向VS1053b写音频数据,如果写入失败它会返回PX_FALSE,这是由于VS1053b当前正”忙”造成的,你可以在这段时间完成其他的事情.
PX_DEVICE_VS10xxPatch用于加载补丁,如果需要播放FLAC格式音乐,就需要调用这个函数加载补丁
PX_DEVICE_VS10xxSinTest用于开始正弦测试,你可以在初始化之后的任何时候调用这个函数用来测试芯片是否正常工作,如果芯片正常,你应该会听到”滴~~~”的声音.



W5500驱动程序SPI协议
根据W5500的datasheet,其SPI的模式如下说明
d3.png
W5500在每次的上升沿采样,在下降沿将会切换MISO与MOSI,这样,W5500就能同时支持支持SPI的模式1与模式3
根据SPI Frame
d4.png
得知其低位在前,高位在后,读至此处,相信读者参照上一章节的SPI配置应该明白代码如何编写了,因此,在此不再复述了.



W5500命令与寄存器
W5500的驱动与VS1053b同样采用寄存器读写的方式来驱动,在本章节中,不再复述代码如何编写,在PX_DEVICE_W5500.c中,你可以找到W5500的驱动的完整代码.我们将篇幅更多的留给”如何实现”
首先我们要注意的一点是,使用SPI传输一个字时W5500的寄存器采用的是Big endian,也就是说,假如你要传输一个字,你需要先把高8位发送过去,然后才能发送低8位
W5500读寄存器的步骤如下:

1.    拉低SS片选
2.    写入需要读写的地址(2字节word)
3.    写入读寄存器操作码(这是一个位与操作,请查阅PX_DEVICE_W5500.c)
4.    读寄存器值
5.    拉高SS片选
W5500写寄存器的步骤如下:

1.拉低SS片选
2.写入需要读写的地址(2字节word)
3.写入写寄存器操作码(这是一个位与操作,请查阅PX_DEVICE_W5500.c)
4.写值
5.拉高SS片选
W5500写数据步骤如下

1.拉低SS片选
2.写入写寄存器操作码并与上Socket*32+8的值(这是一个位与操作,请查阅PX_DEVICE_W5500.c)
3.写值
4.拉高SS片选





W5500驱动核心功能
         W5500对网络的读写是通过socket来执行的,在W5500的驱动中,只实现了UDP通讯(为什么使用UDP)在下一章节讨论,在本章驱动中,仅使用到了Socket0作为通讯,延迟,使用本章的W5500驱动时,使用了GCOSNETWORK对W5500进行了进一步封装,不需要关注socket细节,直接进行网络通讯就行了.
         GCOSNETWORK.h提供了三个网络通讯相关的函数:
[C] 纯文本查看 复制代码
px_bool GCOS_UDPInit(px_char *localip,px_char *masker,px_char *gateway,px_u16 listenPort);[/size]
px_int  GCOS_UDPWrite(GCOS_addr_in *target,px_byte *buffer,px_int bufferSize);
px_int  GCOS_UDPRead(GCOS_addr_in *target,px_byte *buffer);
其中px_bool GCOS_UDPInit用于实现网络的初始化工作,包括配置本机IP,子网掩码,网关与UDP的监听端口
如果初始化成功,这个函数会返回PX_TRUE,否者返回PX_FALSE,这个函数没有提供错误日志输出,如果这个函数返回PX_FALSE,你需要好好检查你的W5500是否正确连接并且配置正确了.当然因为源代码是公开的,所以通过单步调试这应该并不是难题.
函数原型:
[C] 纯文本查看 复制代码
 px_bool GCOS_UDPInit(px_char *localip,px_char *mask,px_char *gateway,px_u16 listenPort);
功能:初始化网络(W5500芯片)
参数:localip,本机IP,为一个字符串参数,例如”192.168.1.100”
mask:子网掩码,也是一个字符串参数
gateway:网关
listenport:UDP监听端口
函数原型:
[C] 纯文本查看 复制代码
px_int  GCOS_UDPWrite(GCOS_addr_in *target,px_byte *buffer,px_int bufferSize);
功能:发送数据包
参数:target 目标结构体
buffer 发送缓存区
buffersize 发送大小
返回值,返回成功发送的数据大小

函数原型:
[C] 纯文本查看 复制代码
px_int  GCOS_UDPRead(GCOS_addr_in *target,px_byte *buffer);
功能:接收数据包,这个函数是非阻塞式的,如果没有数据包收到,这个函数直接返回0
参数:target 用于接收目标描述信息的GCOS_addr_in结构体
buffer 接收缓存区,这个缓存区至少需要1460字节的大小
返回值,返回成功收到的数据大小

示例代码:
[C] 纯文本查看 复制代码
#inclide “GCOSNETWORK.h”[/size]
char buffer[1460];
int main(void)
{
GCOS_addr_in addr;
 
int size; if(GCOS_UDPInit(“192.168.1.100”,”255.255.255.0”,”192.168.1.1”,”12345”))
{
      if(size=GCOS_UDPRead(&addr,buffer))
         GCOS_UDPWrite(&addr,buffer,size);
}
}
上面的程序,用于将收到的数据包”原路返回”.





网络协议TCP与UDP
      W5500提供了UDP与TCP两种协议供我们进行选择,那么,这个云播放器到底是采用UDP还是TCP进行通讯就是一个问题了.
   首先UDP是一个相当简单的协议,它有着更加简短的包头,这意味着你可以省下更多的带宽来做又用的事情,它是无连接的,你也不需要担心你的W5500是否有足够多的socket来维持多连接,另外它拥有非常好的可控性.你也不用花太多心思去处理一堆开放性连接带来的问题.
   同时,TCP带来的好处是显而易见的,它有可靠的丢包重发机制,能够保证时序性,连线断线一目了然,我们也不需要去关心那些烦人的心跳包,如果说到文件传输,那么几乎很多答案都是:别考虑了,用TCP吧,你看谁谁谁用TCP不一样做的很好.是的,TCP有如此之多的优势,看上去确实十分的诱人,但是如果你真正思考一下我们所处的环境,并且真正理解TCP在何时才算真正的”有效”,你也许会发现,TCP并不适合在当前的系统中来做.




为什么我们使用UDP而非TCP
实际上,TCP之前所述的优势,在我们当前片上系统并不好使
1.    我们的片上系统只能提供100kb左右的缓存,如果没有做额外的修改,而W5500仅仅能提供2k的收缓存,如果我们设计的每个包的大小在1.3K左右,那意味着TCP所谓保证的时序性将不再起作用.更加糟糕的是,我们将无法在带宽处理上做出优化,假设在一个网络状况极其糟糕的环境中,你必须等待服务器回应数据包后才能请求下一个,假如延迟在百毫秒乃甚于一些高码率的音频在几十毫秒以上,这种延迟都将是毁灭性的.这将直接导致音频无法正常播放.
2.    TCP也许能帮我们来实现重发,但这个功能我们在UDP上一样可以非常简单的实现,但TCP的重发机制在第一点所述(window根本不足以容纳更多的包,这种重发还可能导致毁灭性的延迟)已经成为了一种累赘,同时,TCP在接近底层的编码上也显得不那么的”友好”,你必须开始花心思来处理W5500给你的一堆中断问题(比如连接,断线….)为此你得开个状态机来重新处理这堆问题(比如断线重连)增加自己的工作量,而UDP没有这些本没有必要去耗费精力的问题.
3.    UDP在带宽与延迟优化上,实现起来简单多了,具体的步骤我们将在下一个章节进行讨论.



数据结构实现

环形缓存
在因为片上系统资源有限,所以绝大多数的时候,我们无法将整个多媒体资源下载下来后再进行播放.以此来看,环形缓存是个不错的选择.
在流媒体的环形缓存中,一般存在两个指针,下面笔者使用几张图来演示环形缓存是如何工作的.
首先我们先定义两个指针,一个是当前播放指针,该指针指向的地址将是下一次被写到播放器中的音频数据,一个是当前数据写入指针,该指针指向的地址将是下一次写到环形缓存中的音频数据.
在下面的图中,我们用字母PP(Play Pointer)来表示播放指针,用WP(Write Pointer)来表示数据写入指针

1.    在最开始的时候,WP和PP都在环形缓存的开始地址
d5.png
2.    很快,网络收到了第一个数据包,这个数据被写入到了环形缓存中.
d6.png
3.    现在,播放器开始工作了,从PP指针开始,逐步将音频数据写到音频设备中,这个时候,音乐也就开始响起来了
d7.png
4.    很快,在PP还没有到达WP的位置时,网络收到了第二个数据包,WP又前进了一大截
d8.png
5.    我们重复这一个过程,很快,WP到达了环形缓存的尾部
d9.png
6.    如果音频还有数据,那么这个时候,WP会立刻切换回到了环形缓存的首部.
e1.png
7.因为收到速度包的速度会快于播放速度,所以,很快,WP就接近了PP
e2.png
8.这个时候,假如再次写入一个”块的数据”就会导致没有播放的音频数据被覆盖,因此,这个时候应该等待PP与WP的位置至少有一个”块”的距离,才会将下一个音频数据写进去.
9.最后重复这个过程,直到这个音频数据被全部播放完毕.





数据交互过程
         私人云音乐播放器的好处是,我们基本不用去抄心数据安全和服务端是否会被攻击的问题,所有的数据交互我们都可以默认是安全可信的,毕竟服务端仅仅只是为你一个人在”服务”,如果你将服务端部署在公网的地址上,应该也不大可能会有人蛋疼的去攻击一个毫无利益关系的东西.
在本项目中,我们使用一种”问答式”的交互方式,这有点像HTTP协议的模式,服务端从不主动发起数据交互请求,所有的数据交互,都是客户端主动发起的.
当然,我们现在所制的云音乐播放器,并不需要做到商业产品水准好去赚他一个亿,我们的目的简单来说就是为了更好更简单的装逼,最好是别人看起来很牛逼,实际上做起来很简单的那种效果.因此我们的交互协议也并不用实现的有多么的复杂.
下面我们简单的使用文字来描述,这款云音乐播放器是如何用网络进行数据交互的
1.播放器:发送数据包,请求某个音频文件数据
2.服务端:如果之前有打开其他音频文件,关闭它,同时打开播放器请求的音频文件,返回这个音频文件的数据大小,准备读取数据
3.播放器:请求该音频文件的第一块数据
4.服务端:返回这个音频文件的第一块数据
5.播放器:请求该音频文件的第二块数据
6.服务端:返回这个音频文件的第二块数据
……
7.重复上述过程直到音频数据发送完毕
8.回到步骤1,播放下一个音频



服务端逻辑实现
我们首先先来实现比较简单的服务端,为了尽量保持精简设计,服务端只处理两种类型的数据包,一个是客户端发送上来的音频文件请求包,一种是音频数据请求包,其中音频数据请求包是根据最后一次请求的音频文件而定的
其逻辑实现大致如下
1.    初始化网络,监听UDP端口
2.    当收到音频文件请求包时,打开这个音频文件,并且返回这个音频文件的文件大小,如果这个文件不存在,返回的大小为0
3.    当收到音频数据请求包时,返回对应音频数据
4.    重复2,3过程

片上系统实现
到这一步,我们就可以开始编写单片机上的主要逻辑实现了,总的来说,片上系统主要分为以下几个步骤
1.    初始化片上系统的一些关键控制单元(比如时钟和中断)
2.    初始化W5500 VS1053b的驱动
3.    设定一个有限状态机,包括”待命””播放”两周状态
4.    ,当当前处于待机状态时,向服务端请求音频文件信息,当当前处于播放状态时,向服务端请求音频数据
5.    假如当前环形缓存中有数据时,将数据写到VS1053b中,直到不再能写入或者环形缓存的没有需要写入的数据
6.    判断现在W5500是否有收到数据包,如果有的话,将它写到环形缓存中.
7.    回到第四部直到音乐播放完成.




数据与延迟优化驱动调度的考虑
         上面的实现在局域网这种低延迟几乎不丢包的良好环境中工作的很好,尽管买个路由器组个内网应该不是什么大问题,但是假如我们希望将服务端迁移到公网这种说不准的环境中.那么我们面临的问题就变得多了起来,毕竟不是人人都是土豪也不是人人都拉得起专网.
  我们第一个需要考虑的问题是,在播放器发送请求数据包后,不管怎么说,肯定是需要经过一个延迟延迟才能收到返回回来的数据包的,这个时长是多久恐怕很难说得准,在内网这种网络良好的环境中,其延迟可能小于1ms,而在一般的公网环境中,其延迟可能在几十到几百之间,而网络节点跳数多,在而某一个节点刚好是”土豆路由器”这种情况的话,其延迟甚至可能上千.
  那么在发送请求后我们应该如何等待就成了一个问题,假设我们直接使用Sleep(自旋锁延迟)函数的话,显然这部分的性能就被浪费了.我们现在回到片上驱动中,在本个项目中,我们除了使用W5500有数据交互外,还VS1053b同样有数据交互,这也就是为什么我们的播放器在发送完数据请求后,不直接去等待接收数据,而是先检查环形缓存,把这些数据写入VS1053B中直到无法继续写入为止.这段写入的时间虽然不多却刚好可以用来等待数据回来的那段时间.
于是在对VS1053B完成了数据交互后,再去W5500里去”找数据”.



何时重发数据
        既然是UDP,那么我们就不得不考虑数据包丢失问题,如果是发的速度过快,不仅会加重服务器的负担,甚至可能导致网络拥堵,如果重发的时间太久,就可能导致音频数据达不到正常播放的要求.
我们通过数据手册可以知道,VS1053b拥有512字节的数据RAM,我们假设现在播放的是最苛刻的”无损音乐”格式,其采样率为44100HZ,每个数据大小为16位,双声道,这也就是说其每秒播放176400字节的数据,那么,其能保证大约3ms的播放时长,当然假如是mp3或者ogg这种格式,其将会延迟更长.也就是说,在最极端的情况下3毫秒内我们没有向VS1053b里写数据,音乐播放就会马上变成哑巴了,幸运的是,除非数据量实在密度太大,即便是UDP,平常也不容易造成丢包,同时如果我们将环形缓存的数据都算上(100kb并假设当中都有数据),那么我们就能争取到600毫秒左右的延迟并保证在环形缓存用完前不出”岔子”,因此折中而言,我们可以将丢包重发时间定在100-200毫秒左右时间.






空间换时间
   这里所说的空间换时间并不是<<数据结构>>课程里使用表查询之类的手段优化计算时间,这里的空间换时间指的是使用片上有限的资源去尽量减少网络延迟对音频系统造成的影响.
在之前的实现中,我们每次只发送一个请求,实际上在环形缓存还有空间的时候我们一次可以发送多个请求,这有点像”并行”传输,就像一条上海到广州的公路,数据交互就像一辆车过去然后载了货物再回来,实际上我们要充分利用带宽,只要公路够宽一次派几辆车过去就可以了,这也就是”为什么我们使用UDP而不使用TCP”的优化,TCP必须保证请求数据包必须到达才会处理下一个请求数据包(为了保证时序性),假如第一个请求数据包多次丢包,那么就导致之后的请求数据包全塞在网络上,时序性的保证反而造成了我们无法充分利用带宽,而UDP就不同了,哪怕丢包严重,只要有一个请求数据包到达了服务端,就能完成一块数据的传输,而其它丢包的数据,等待下次重传就行了.
为此,我们需要建立一个请求列队,来记录当前已经向服务器请求了哪些数据,当我们收到对应的数据时,再将队列中对应的记录更新为下一个请求的数据.
例如我们建立了一个长度为6的队列,第一次,我们就请求
1,2,3,4,5,6块数据,现在我们将请求数据包发送给服务端,这个时候,因为网络原因,4,5,6数据包丢包了或者服务端发回来的数据包丢包了,需要重发,但是1,2,3数据包请求的数据成功回来,那么,队列就被更新为7,8,9,4,5,6,然后重复上述过程,直到所有数据包传输完成.



请求列队长度和请求延迟
    请求列队很好解决了延迟的问题,但请求列队的延迟也是一个应该被考虑的问题,如果将请求列队的请求数据包一股脑全部发送到服务端,那么就很有可能因为缓存区的限制或者服务端根本来不及处理这些请求更甚者在数据包回传时,因为W5500只有2K的收缓存,而导致大量的丢包,这样不仅仅不能够解决延迟问题,反而导致大量的带宽被白白浪费掉,那么,发送完第一个请求后延迟一段时间再发下一个请求显然是个好主意,那么这段时间应该如何确定呢,回到我们之前在”何时重发数据”里的计算公式,我们知道,如果我们想要播放一段无损数据,那么,我们应该在3ms内向VS1053中写入512字节的数据,这么计算的话,假如我们的一个数据帧包含1kb的数据,假如不使用请求列队,其能允许的延迟应该在6ms以内,但是在公网远端环境中,6ms的延迟显然不是那么容易达到的,因此我们建立了请求列队.
按照带宽来算,0.5k能播放大约3毫秒,那么1秒针大约需要160kb的数据,假设请求列队的长度是8,那么,理论上一次来回我们就能传输8kb的数据,假设延迟是客户端到服务端的延迟是50毫秒,那么假设带宽足够的前提下,一秒钟理论上我们能够传输160kb的数据,因为每次请求对应1kb的数据,所以,每次发送请求的间隔应该在6毫秒左右.当然,假如我们把请求列队拓展到16的长度,那么这个间隔就能达到12毫秒.
实际上即使是无损格式例如FLAC APE ACC,都有进行数据压缩,实际上上面讨论的是WAV这种几乎没有压缩的情况,因此,实际实现起来我们的条件还宽裕的多,在本章编写的程序中,笔者取列队长度为16,延迟为12毫秒,使用的外网服务器的延迟在42ms左右,在这种环境下播放器工作良好,没有出现卡顿现象.
e3.png




其它的工作
在完成总体的系统设计并实现其功能后,笔者想着是否应该在原来的基础上多加一点什么东西,添加三个指示灯是个不错的主意,这不仅可以让我们知道现在这个播放器在做什么,也可以通过指示灯了解当前的网络状况或者找些bug.
e4.jpg
笔者使用的是绿红黄三个二极管,压降2.0-2.2v,串联万能的1k的电阻,因为电流需求不高,GPIO口直接驱动.
其中
黄灯闪烁表示当前有数据发出
绿灯闪烁表示当前有数据收到
红灯亮起表示当前VS1053没有播放音频,红灯熄灭表示当前正在播放音乐.




最终的实现(片上系统/C语言)
[C] 纯文本查看 复制代码
int main()[/size]
{
/////////////////////////////////////////////////////////////////////////////////////////////
//变量定义部分
////////////////////////////////////////////////////////////////////////////////////////////
   PX_Linker VS10xxLinker,LEDLinker;
   PX_DEVICE_VS10xx VS10xx;
   PX_DEVICE_LED  LED;
   GCOS_addr_in gcos_addr_in,gcos_addr_out;
   px_byte zeroByte32[32]={0};
   px_int TokenCurrentPlayIndex=0;
   px_int TokenCurrentPlayOffset=0;
   px_int TokenLastRequestIndex=0;
   px_int TokenLastWrite=0;
   px_int TokenCount=0;
   px_int TokenFreeCount=PX_NETPLAYER_CACHE_TOKEN;
   px_int calcTi,i,lastQueueStamp=0;
   px_int CurrentMusicIndex=0;
   px_int outline=0;
   px_uint lastsendtime;
   px_bool LED_Green=PX_FALSE,LED_Yellow=PX_FALSE;
   PX_NETPLAYER_STATUS status=PX_NETPLAYER_STATUS_STANDBY;
   PX_NETPLAYER_PACKET Packet;
/////////////////////////////////////////////////////////////////////////////////////////////
//功能实现部分
////////////////////////////////////////////////////////////////////////////////////////////
         //定义服务器地址与端口
gcos_addr_out=GCOS_UDP_TARGET_ADDRIN("47.104.129.1",PX_NETPLAYER_SERVER_PORT);
//初始化片上时钟
   TimerInit();
//初始化LED驱动
          if (!PX_LinkerInit(&LEDLinker,PX_DEVICE_LED_Init,PX_NULL,PX_NULL,PX_DEVICE_LED_IO,PX_NULL))
                   while(1);
          PX_DEVICE_LEDInit(&LED,&LEDLinker);
//红指示灯量
          PX_DEVICE_LED_RED(&LED,PX_TRUE);
  if(!PX_LinkerInit(&VS10xxLinker,PX_PTOTOCAL_VS10xx_SPI_Init,PX_PROTOCAL_VS10xx_Write,PX_PROTOCAL_VS10xx_Read,PX_PROTOCAL_VS10xx_IOCTL,PX_NULL))
            while(1);
//初始化VS1053驱动
   if(!PX_DEVICE_VS10xxInit(&VS10xx,&VS10xxLinker))
            while(1);
//初始化网络驱动
   if(!GCOS_UDPInit("192.168.1.108","255.255.255.0","192.168.1.1",54321))
            while(1);
//复位,当切歌时跳转到这个位置
RESET:
//一些基础变量的初始化
  //初始化列队
   for (i=0;i<PX_NETPLAYER_QUEUE_LEN;i++)
   {
            queue.p_index=-1;//当这个请求列队的请求索引为-1时表示这个列队目前空闲
            queue.timestamp=0;
   }
   TokenLastWrite=0;
   TokenCurrentPlayIndex=0;
   TokenCurrentPlayOffset=0;
   TokenLastRequestIndex=0;
   TokenCount=0;
   TokenFreeCount=PX_NETPLAYER_CACHE_TOKEN;
   outline=0;
   lastsendtime=0;
   lastQueueStamp=0;
   status=PX_NETPLAYER_STATUS_STANDBY;
   //软复位VS1053
   PX_DEVICE_VS10xxSoftReset(&VS10xx);
   while(1)
   {
                 if (status==PX_NETPLAYER_STATUS_STANDBY) //假如当前是待命状态
                 {
                                     //向服务器请求音频文件的数据
                                      PX_DEVICE_LED_RED(&LED,PX_TRUE);
                                     Packet.type=PX_NETPLAYER_PACKET_TYPE_COMMAND_QUERY_MUSIC;
                                     Packet.param[0]=CurrentMusicIndex;
                                     GCOS_UDPWrite(&gcos_addr_out,(px_char *)(&Packet),8);
                                     PX_DEVICE_LED_YELLOW(&LED,LED_Yellow=!LED_Yellow);
                                     sleepms(1000);
                 }
//假如当前是播放状态,而且还要音频数据,并且已经达到了请求延迟所需时间
                            if(status==PX_NETPLAYER_STATUS_PLAYING&&TokenLastWrite<TokenCount&&TimeGetTime()-lastsendtime>PX_NETPLAYER_QUEUE_SEND_INVERVAL)
                            {
                                     Packet.type=PX_NETPLAYER_PACKET_TYPE_COMMAND_QUERY_PACKET;
//遍历请求列队
                                     for (i=0;i<PX_NETPLAYER_QUEUE_LEN;i++)
                                     {
                                               if (lastQueueStamp>=PX_NETPLAYER_QUEUE_LEN)
                                               {
                                                        lastQueueStamp=0;
                                               }
 
                                               if(queue[lastQueueStamp].p_index!=-1)
                                               {        //假如这个请求已经存在列队中,检查是否需要重发
                                                        if (TimeGetTime()-queue[lastQueueStamp].timestamp>PX_NETPLAYER_QUEUE_RETRY_TIME)
                                                        {
                                                                 Packet.param[0]=queue[lastQueueStamp].p_index;
                                                                 queue[lastQueueStamp].timestamp=TimeGetTime();
                                                                 lastsendtime=TimeGetTime();
                                                                 GCOS_UDPWrite(&gcos_addr_out,(px_char *)(&Packet),8);//重发数据,同时黄灯切换闪烁
                                                                 PX_DEVICE_LED_YELLOW(&LED,LED_Yellow=!LED_Yellow);
                                                                 if (outline++>128)
                                                                 {
//断线了,红等亮起
                                                                 PX_DEVICE_LED_RED(&LED,PX_TRUE);
                                                                 goto RESET;
                                                                 }
                                                                 lastQueueStamp++;
                                                                 break;
                                                        }
                                               }
                                               else
                                               {
//假如这个列队没有被使用,而且当前还有数据需要继续请求,缓存区也没有满
                                                        if (TokenFreeCount&&TokenLastRequestIndex<TokenCount)
                                                        {
                                                                 Packet.param[0]=TokenLastRequestIndex;
                                                                 queue[lastQueueStamp].p_index=TokenLastRequestIndex;
                                                                 queue[lastQueueStamp].timestamp=TimeGetTime();
                                                                 lastsendtime=TimeGetTime();
//请求数据,将这个请求添加到这个列队中
                                                                 GCOS_UDPWrite(&gcos_addr_out,(px_char *)(&Packet),8);
                                                                 PX_DEVICE_LED_YELLOW(&LED,LED_Yellow=!LED_Yellow);
                                                                 TokenLastRequestIndex++;
                                                                 TokenFreeCount--;
                                                                 lastQueueStamp++;
                                                                 break;
                                                        }
                                               }
                                               lastQueueStamp++;
                                     }
 
                            }
   //如果当前处于播放状态
                            if(status==PX_NETPLAYER_STATUS_PLAYING)
                            {
//循环直到VS1053写满数据
                                     while (PX_TRUE)
                                     {
                                               if(TokenLastWrite-TokenCurrentPlayIndex<=0)
                                               {
//当前收到的数据不足以写入到VS1053驱动中,红等亮起
                                                        PX_DEVICE_LED_RED(&LED,PX_TRUE);
                                                        break;
                                               }
//熄灭红等,准备向
                                               PX_DEVICE_LED_RED(&LED,PX_FALSE);
                                               calcTi=TokenCurrentPlayIndex%PX_NETPLAYER_CACHE_TOKEN;
//向VS1053写入音频数据,这个时候应该可以听到声音了
                                               if (PX_DEVICE_VS10xxWrite(&VS10xx,(px_byte *)(Audio_Buffer+calcTi*PX_NETPLAYER_CACHE_TOKEN_SIZE+TokenCurrentPlayOffset))==0)
                                               {
                                                        break;
                                               }
                                               TokenCurrentPlayOffset+=32;
                                               if (TokenCurrentPlayOffset>=PX_NETPLAYER_CACHE_TOKEN_SIZE)
                                               {
                                                        TokenCurrentPlayIndex++;
                                                        TokenCurrentPlayOffset=0;
                                                        TokenFreeCount++;
                                               }
                                     }
//如果播放完成了
                                     if (TokenCurrentPlayIndex>=TokenCount)
                                     {
                                               CurrentMusicIndex++;
//播放下一曲,同时向VS1053中写入2048字节的0
                                               for(calcTi=0;calcTi<64;calcTi++)
                                               {
                                                        while(!PX_DEVICE_VS10xxWrite(&VS10xx,(px_byte *)(zeroByte32)))
                                                                 sleepms(5);
                                               }
//红灯亮起
                                               PX_DEVICE_LED_RED(&LED,PX_TRUE);
//goto Reset
                                               goto RESET;
                                     }
                            }
//收到数据处理流程
                                     if (GCOS_UDPRead(&gcos_addr_in,(px_char *)(&Packet))!=0)
                                     {
//绿灯闪烁
                                               PX_DEVICE_LED_GREEN(&LED,LED_Green=!LED_Green);
                                               if (status==PX_NETPLAYER_STATUS_STANDBY)
                                               {
//如果这个数据包是音频文件信息的数据包
                                                        if (Packet.type==PX_NETPLAYER_PACKET_TYPE_INFO_AUDIO_PACKETINFO)
                                                        {
                                                                 if (Packet.param[0]==0)
                                                                 {
                                                                           CurrentMusicIndex=0;
                                                                           goto RESET;
                                                                 }
                                                                 TokenCurrentPlayIndex=0;
                                                                 TokenCurrentPlayOffset=0;
                                                                 TokenLastRequestIndex=0;
                                                                 TokenLastWrite=0;
                                                                 TokenCount=Packet.param[0];
                                                                 lastQueueStamp=0;
                                                                 TokenFreeCount=PX_NETPLAYER_CACHE_TOKEN;
                                                                 status=PX_NETPLAYER_STATUS_PLAYING;
//初始化一些数据,然后将当期状态机切换为播放状态
                                                        }
                                               }
                                               else if(status==PX_NETPLAYER_STATUS_PLAYING)
                                               {
//假如收到的数据包是音频数据
                                                        if (Packet.type==PX_NETPLAYER_PACKET_TYPE_INFO_DATA)
                                                        {
//检查请求列队,看看这个数据属于哪一个请求
                                                                 for (i=0;i<PX_NETPLAYER_QUEUE_LEN;i++)
                                                                 {
                                                                           if (Packet.param[0]==queue.p_index)
                                                                           {
//将数据写到环形缓存中,并将这个请求项标记为”空闲”
                                                                                    calcTi=queue.p_index%PX_NETPLAYER_CACHE_TOKEN;
                                                                                    queue.p_index=-1;
                                                                                    //memory copy
                                                                                    memcpy(Audio_Buffer+calcTi*PX_NETPLAYER_CACHE_TOKEN_SIZE,Packet.data,sizeof(Packet.data));
                                                                                    outline=0;
                                                                                    break;
                                                                           }
                                                                 }
//计算当期已经收到的数据块(可以播放的)
                                                                 TokenLastWrite=TokenLastRequestIndex;
                                                                 for (i=0;i<PX_NETPLAYER_QUEUE_LEN;i++)
                                                                 {
                                                                           if (queue.p_index!=-1&&queue.p_index<TokenLastWrite)
                                                                           {
                                                                                    TokenLastWrite=queue.p_index;
                                                                           }
                                                                 }
                                                        }
                                               }
                                     }
   }
}
 





最终的实现(服务端/C++)
[C++] 纯文本查看 复制代码
int main()
{
///////////////////////////////////////////////////////////////////////////////////////////
//一些局部变量定义
///////////////////////////////////////////////////////////////////////////////////////////
         Cube_SocketUDP_IO       _io;
         Cube_SocketUDP_O         _o;
         Cube_SocketUDP              Net;
         SOCKADDR_IN                            _inaddr;
         pt_byte                                data[PX_NETPLAYER_BUFFER_SIZE];
         pt_byte                                rdata[PX_NETPLAYER_CACHE_TOKEN_SIZE];
         pt_string           filePath;
         PX_NETPLAYER_PACKET *pPacket=(PX_NETPLAYER_PACKET *)data;
         PX_NETPLAYER_PACKET Reply;
         pt_int                                   fileSize,SumToken;
         FILE                              *pf=PT_NULL;
         _o.Buffer=&Reply;
         _o.Size=sizeof(Reply);
         _io.Port=PX_NETPLAYER_SERVER_PORT;
//初始化网络
         if (!Net.Initialize(_io))
         {
                   return 0;
         }
 
         printf("NetPlayer Server running....\n");
//因为是问答式,所以只有在收到数据时才进行回应
         while (Net.ReceiveData(data,sizeof(data),_inaddr))
         {
                   _o.to=_inaddr;
                   switch (pPacket->type)
                   {
//假如收到的数据时请求音频信息额数据包
                   case PX_NETPLAYER_PACKET_TYPE_COMMAND_QUERY_MUSIC:
                            if (pf!=PT_NULL)
                            {
                                     fclose(pf);
                            }
//查找这个音频文件
                            filePath=pt_string("./")+pt_string().Number(pPacket->param[0])+".data";
                           
                            if ((pf=fopen(filePath.buffer,"rb"))!=PT_NULL)
                            {
//打开这个音频文件,计算音频文件的大小
                                     printf("Music Play %s\n",filePath.buffer);
                                     fseek(pf,0,SEEK_END);
                                     fileSize=ftell(pf);
                                     fseek(pf,0,SEEK_SET);
 
                                     Reply.type=PX_NETPLAYER_PACKET_TYPE_INFO_AUDIO_PACKETINFO;
//计算这个音频文件需要多少帧来传输
                                     Reply.param[0]=(fileSize)/PX_NETPLAYER_CACHE_TOKEN_SIZE;
                                     if (fileSize%PX_NETPLAYER_CACHE_TOKEN_SIZE)
                                     {
                                               Reply.param[0]++;
                                     }
                                     SumToken=Reply.param[0];
                                     //read first token
                                     memset(rdata,0,sizeof(rdata));
                                     fread(rdata,1,sizeof(rdata),pf);
//将结果发回
                                     Net.Send(_o);
                            }
                            else
                            {
//没找到这个音频文件
                                     printf("未找到文件 %s\n",filePath.buffer);
                                     Reply.type=PX_NETPLAYER_PACKET_TYPE_INFO_AUDIO_PACKETINFO;
                                     Reply.param[0]=0;//No packets
                                     Net.Send(_o);
                            }
                            break;
//假如是音频数据请求的数据包
                   case PX_NETPLAYER_PACKET_TYPE_COMMAND_QUERY_PACKET:
                            {
                                     if (pf==PT_NULL)
                                     {
                                               break;
                                     }
        
                                     if(pPacket->param[0]<SumToken)
                                     {
         //将文件指针移动到对应的数据                             fseek(pf,pPacket->param[0]*PX_NETPLAYER_CACHE_TOKEN_SIZE,SEEK_SET);
                                               memset(rdata,0,sizeof(rdata));
    //读取音频数据
                                               fread(rdata,1,sizeof(rdata),pf);
                                               Reply.type=PX_NETPLAYER_PACKET_TYPE_INFO_DATA;
                                               Reply.param[0]=pPacket->param[0];
                                               memcpy(Reply.data,rdata,sizeof(rdata));
   //将音频数据发送回去
                                               Net.Send(_o);
                                               Sleep(2);
                                     }
                            }
                            break;
                   }
         }
}



后记
    这个<<破铜烂铁打造云音乐播放器>>总算是告一段落了,我们洋洋洒洒进行了一通操作,尽管这个东西看上去并不复杂,但是经验告诉我们复杂的东西往往没有想象的那么复杂,而简单的东西总是比它表面看起来复杂的多,笔者原本打算一周内把这个”破铜烂铁”做出来,但实际上足足花了两至三倍的时间.
贫乏的片上资源总是能引起人的思考,因此读者也看到了最终完成这个项目,从简单的单片机模块连线开始,逐步涉及到了总线时钟,网络协议,数据结构优化,声学相关的知识,笔者也非常庆幸自己在早些的时候走的那些”弯路”,而不是所谓的几天学会什么东西之类的快速入门,如果不是那些看似麻烦的基础知识,相信这个东西做起来肯定是处处碰壁.这也告诉了我们,学习没有捷径,空中楼阁不管看上去多么的华丽,迟早也是倒塌禁不住考验.
在为学之路上,更多需要的是精益求精,步步为营的态度和一些愚公移山的”傻”劲,而贪图捷径,异想天开,就和捕风捉影一样不可靠.
最后,读者可以在附件中找到这个项目的完整源代码,你可以尝试将它复现出来,然后添加一些自己的东西,相信这个玩意在大学中作为吹嘘的资本还是有一些的,你还可以将它用在自己的毕业设计或大作业中,相信弄个优秀应该不是什么大问题.

源代码,datasheets 附件打包下载地址:
游客,如果您要查看本帖隐藏内容请回复

评分

参与人数 7积分 +6 魔法币 +27 收起 理由
redog20021985 + 1
tg_user11 + 5 感谢你的分享,i春秋论坛有你更精彩!.
元气肥 + 5 表姐你还会这个?
职业养狐人 + 5 表姐厉害666666,我能说什么,操作猛如虎。.
zhanggpeterx + 5 6666
z7788520 + 6 我还能说啥,看不懂,一个字,屌.
jasonx + 6 表姐666

查看全部评分

本帖被以下淘专辑推荐:

发表于 2017-12-31 11:22:08
2017年的最后一天,祝表姐元旦快乐,新的一年工作顺利,生活幸福!
请问表姐卖不卖!!!
请问表姐卖不卖!!!
使用道具 举报 回复
这么强,表示对硬件一无所知,看到楼上都喊楼主表姐,我就想问楼主到底细不细妹咋
使用道具 举报 回复
发表于 2017-12-31 10:06:28
yyyxy 发表于 2017-12-29 11:06
就是这篇,又坑我1千多稿费!!!!!

蛋总就是壕
求知若饥,虚心若愚。
使用道具 举报 回复
我这个生存在最底层最辣鸡的web狗表示 ..... 看不懂!!!
使用道具 举报 回复
发表于 2017-12-31 10:04:19
大佬是怎样炼成的?
绝对离不开知识的累积!!
求知若饥,虚心若愚。
使用道具 举报 回复
大佬,方便把百度云的密码发一下么
使用道具 举报 回复

大佬是怎样炼成的?
绝对离不开知识
使用道具 举报 回复
厉害,好厉害,非常厉害,满分得厉害
使用道具 举报 回复
发表于 2017-12-29 10:30:57

回帖奖励 +1

前排表白,表姐强无敌
低调求发展,潜心学安全
使用道具 举报 回复

回帖奖励 +1

奖励
使用道具 举报 回复
发表于 2017-12-29 11:06:23
就是这篇,又坑我1千多稿费!!!!!
欢迎加入i春秋QQ群大家庭,每人只能任选加入一个群哦!投稿请加我QQ:286894635。
i春秋白帽子军团:451217067
i春秋-韩:556040588
i春秋CTF交流学习群:234714762
使用道具 举报 回复
发表于 2017-12-29 13:38:04
目测这明显是嵌入式开发
使用道具 举报 回复
发表于 2017-12-29 13:41:08
一顿操作猛如虎
使用道具 举报 回复
发表于 2017-12-29 16:31:31
给表姐跪了。
使用道具 举报 回复
感谢楼主分享
使用道具 举报 回复
发表于 2017-12-29 22:48:42
感觉用树莓派实现会简单点
使用道具 举报 回复
发表于 2017-12-30 12:49:27
佩服佩服
使用道具 举报 回复
发表于 2017-12-31 11:03:30
简直六得不要不要得,完全看不懂
使用道具 举报 回复
123下一页
发新帖
您需要登录后才可以回帖 登录 | 立即注册