继上月底STM32F4上的SDIO模块读取SD卡搞通了以后,终于实现了一个简单的FAT32文件系统,然后读取卡上的WAV文件,从I2S输出信号给我的CS4398 DAC来播放音乐。尽管还简陋了不少,终于我自己的第一个硬件音乐播放器(摆脱电脑)诞生了。精力有限,能简化的先简化了,所以目前它的局限有:
(1) 只支持44.1kHz/16-bit 的CD格式WAV音频文件(无压缩格式)播放
(2) 只支持格式化为FAT32文件系统的SD、SDHC卡
(3) 不能在一个文件中进行快进、倒带索引,只能从每个文件开始连续播放。只有播放/暂停、下一首、上一首、停止的按钮控制。
(4)没有屏幕显示,调试信息只通过UART输出,文件名字符不能输出汉字等。
(5)只检索根目录下的.wav文件,上限255个;不检索子目录。
先上线路图:这是我的STM32F411开发板
MCU外围不多,除了必要的电源部分,就是SD卡座、USB座、各种接口的插针,以及少数按钮和LED了。
布线还是我自己的风格,Gerber文件附在后面,可以用ViewMate、CAM350等软件来看。
这个开发板就是按照做音乐播放器来设计的,当然DIY玩的不是做产品,就没考虑外观、用户界面那些了。MCU选了STM32F411RET6 的原因有:(1)M4F内核,又有128kB SRAM,做软解MP3、FLAC资源充足。(2)便宜,F407价格相似计算能力更强但是封装尺寸大一档。(3)SDIO, USB OTG FS都有。(4) I2S支持外部clock输入,也就是I2S做master但是和DAC的MCLK是绝对同步的。
目前的主程序框架是很直接的,在必要的初始化之后就访问SD卡,读取根目录检索WAV文件。找到的可播放文件的信息建立一个playlist,然后就在 idle_process() 和 play_file() 两个函数之间切换,对应停止和播放两个状态。
int main(void) { int i; config_pins(); config_clock(); uart_setup(); uart_wstr("rn-- UART enabled --"); config_i2s(); NVIC_EnableIRQ(DMA1_Stream4_IRQn); if(sdio_init()) { sdio_setmode(); // set 4-bit mode uart_wstr("rnSet 4-bit mode"); if(fat32_init(0)) { uint8_t n, i; int8_t r; myfile file; n=create_playlist(); show_playlist(n); for(;;) { i=idle_process(r,n); open_file0(&file,playlist_mem[i*3],playlist_mem[i*3+1]); uart_wstr("rnPlay ""); uart_wstr(playlist_mem[i*3+2]); uart_wstr("""); r=play_file(&file); } } else uart_wstr("rnCannot recognize file system"); } for(;;) { __WFI(); } }
uart_wstr() 以及类似的 uart_whex()等函数是我用来调试的,也在没有LCD显示的时候用来当作UI的一部分了。
FAT32文件系统,我考虑过用fatfs, 但又觉得稍嫌复杂,于是自己写了一个简化的FAT32支持函数库,只读的,不支持文件seek,呵呵,每次读文件只能读512字节。
#include #include "stm32f4xx.h" /* simplified FAT32 read-only access library */ #include "fat32.h" extern void device_read_blk(uint32_t blk, void *buf); static unsigned char SPC_SHIFT; static unsigned int DATA_OFFSET; static unsigned int FAT1_OFFSET; static unsigned int ROOTDIR_POS; char fat32_init(uint32_t bootblk) { struct BOOTSEC32 boot; uint8_t tmp; device_read_blk(bootblk, &boot); if(boot.tail[420]!=0x55 || boot.tail[421]!=0xaa) return 0; if(strncmp(boot.filesysid, "FAT32 ",8)!=0) return 0; if(boot.BytesPerSec!=512) return 0; if(boot.NumFATs!=2) return 0; ROOTDIR_POS=boot.RootLocation; tmp=boot.SecPerCluster; SPC_SHIFT=0; while(tmp>1) { tmp>>=1; SPC_SHIFT++; } FAT1_OFFSET=bootblk+boot.ReservedSectors; DATA_OFFSET=FAT1_OFFSET+2*boot.SecPerFAT-2*boot.SecPerCluster; // cluster space starts from 2 return 1; } static uint32_t chain_next_cluster(uint32_t cluster) { static uint32_t fat[128]; static uint32_t cache_fat; uint32_t fat_blk = FAT1_OFFSET+cluster/128; uint8_t offset=cluster%128; if(fat_blk != cache_fat) { device_read_blk(fat_blk, fat); cache_fat=fat_blk; } return fat[offset]; } static uint32_t chain_next_blk(uint32_t blk) { uint32_t blk_rel=blk-DATA_OFFSET; uint8_t mask=(1<<SPC_SHIFT)-1; uint32_t next_cluster; if((blk_rel & mask)!=mask) return blk+1; next_cluster=chain_next_cluster(blk_rel>>SPC_SHIFT); if((next_cluster & 0x0fffffff)!=0x0fffffff) return DATA_OFFSET+(next_cluster<<SPC_SHIFT); else // End of file return 0xffffffff; } char read_file_blk(myfile *f, void *buf) { if(f->blk==0xffffffff) // End of file return 0; device_read_blk(f->blk, buf); f->blk=chain_next_blk(f->blk); f->pos+=512; if(f->pos>=f->length) f->pos=f->length-1; return 1; } void open_file0(myfile *f, uint32_t cluster, uint32_t len) // open by cluster num { f->blk = DATA_OFFSET+(cluster<<SPC_SHIFT); f->pos = 0; f->length = len; f->cls_ini = cluster; } uint32_t get_rootdir(void) { return ROOTDIR_POS; }
FAT32读取还算简单,因为文件是一个链表存放,顺藤摸瓜就可以了。链表每一节点是一个簇(Cluster),对应若干个扇区(磁盘的概念,也就是512字节单位的存储块). 从目录索引里面获得文件的起始簇号以后,一方面换算成扇区号去访问数据,另一方面到FAT表(文件分配表)去查下一个簇的编号。打开文件是以起始簇号为参数,而不是文件名,匹配文件名的过程交给主程序部分去负责了。
只考虑播放WAV文件就简单了很多,跳过开头的44字节WAV文件头(我偷懒了都没判断一下是不是44.1k/16-bit立体声格式),后面就是PCM数据了,可以直接送给I2S模块输出。STM32的I2S硬件是和SPI复用的,在I2S模式下每次写数据寄存器只能是16-bit,也就是对应左或右声道一个PCM数据(因为CD格式也是16-bit),MCU可以在I2S数据发送成功后给一个中断,然后再写16-bit的数据,那么每秒要写88200次,这么做效率太低了,而且万一中断处理不及时就会造成破音。
所以必须要用DMA来传输,DMA将要播放的PCM数据从SRAM当中搬运到I2S模块,这个过程不需要CPU参与。CPU可以在这个时候去干访问文件啊解码等等的活,也可以什么都不干进入SLEEP模式。因为I2S的数据是连续的,即使没有播放音乐,也需要让它不断发送"0",故将DMA配置为循环模式,从RAM中取数时,自动重置指针。DMA在内存缓冲区读完和读到一半的时候,可以给CPU一个中断请求,此时更新数据就可以了。
我在RAM中开了2kB的空间作为PCM数据DMA缓冲
#define HALF_COUNT 256 uint32_t i2s_play_buf[2*HALF_COUNT]; volatile char low_half_fill;
low_half_fill 这个变量是由中断服务程序设置的,表示该写缓冲区的哪一半。负责填缓冲区的是 play_file() 这个函数:
int8_t play_file(myfile *fp) { uint32_t tbuf[128]; uint32_t *pcm_buf; uint32_t lastpos=0; int i, remain; char fin; char paused=0; if(!read_file_blk(fp, tbuf)) return; remain=128-11; // skip 44-byte WAV header fin=0; for(;;) { char button; LED_off(); __WFI(); // wait DMA button=get_button(); if(button) { if(button & BUTTON_PLAY) { paused=!paused; if(paused) silence_buffer(); } else { silence_buffer(); if(button & BUTTON_NEXT) return 1; if(button & BUTTON_PREV) return -1; return 0; } } if(paused) continue; LED_on(); if(low_half_fill) pcm_buf=i2s_play_buf; else pcm_buf=i2s_play_buf+HALF_COUNT; for(i=0;i<remain;i++) // remaining data pcm_buf[i]=tbuf[i+(128-remain)]; for(;;) { int k; if(!read_file_blk(fp, tbuf)) // end cluster { for(;i<HALF_COUNT;i++) pcm_buf[i]=0; LED_off(); __WFI(); // play rest silence_buffer(); return 1; } k=0; for(;i<HALF_COUNT && k<128;i++) { pcm_buf[i]=tbuf[k]; k++; } if(i==HALF_COUNT) { remain=128-k; break; } } if(fp->pos-lastpos>176400) { uart_wstr("."); lastpos+=176400; } } }
读取文件的操作也在这个函数中进行。读SD卡会有I/O的延迟,但因为有缓冲,只要保证平均读取速度超过回放的速度就不会产生间断。读文件和DMA的同步依靠中断来实现:主程序完成一次缓冲区数据准备之后就用WFI指令进行休眠,当DMA取数据达到阈值时CPU唤醒——又有数据空缺可以干活了,然后主程序读SD卡取数据。此外,如果有按钮动作,或者文件读到尾部了,就把缓冲区清零然后返回。
处理目录表的部分在 create_playlist() 这个函数当中。读文件和读目录使用的是同样的函数,顺序读取。每当找到一个WAV文件时,就把起始簇号、文件长度和文件名记录到一块内存 playlist_mem 中,后面播放时就不再访问根目录了。想了解实现细节的网友请下载源程序查看。
评论