做杰理方案这些年,从AC690X一路调到AC701N,被问得最多的还是解码器的启动和关闭。很多刚接触杰理SDK的人拿到的demo里就是一句audio_decoder_open加一句audio_decoder_close,看着简单,真放到项目里跑一遍,问题就全出来了:解码器打不开、打开后没声音、声音断断续续、close之后内存没释放、二次打开直接死机。这篇文章把启动解码和关闭解码这套流程从头到尾捋一遍,讲清楚open和close背后到底发生了什么、参数怎么配、缓冲怎么给、关闭顺序怎么排,以及我在实际项目里踩过的那些坑。
这篇东西适合谁看?正在调杰理AC69XX/AC70XX/AC79XX播放功能的嵌入式工程师,尤其是从其他平台转过来、第一次碰杰理SDK的朋友。这类文章网上很少写透,大多数只是贴个demo,我争取把文档里没有的细节都补上。
1. 先把解码这件事想清楚:它在音频链路里处在哪个位置
1.1 解码器不是"读文件",而是一条完整的数据管线
很多同事第一次写播放功能时,以为解码器就是一个把MP3文件转成声音的函数。其实不对。杰理芯片上的解码器,严格说是"解码服务":它要从文件系统里读压缩数据,进行格式解析,把MP3、AAC、WAV、FLAC这些编码还原成PCM裸数据,然后通过DMA送到DAC或者IIS接口,最后经过功放输出到喇叭。这个链条里,读数据、解析头、解帧、输出PCM,每一环都可能出问题,而"启动解码"和"关闭解码"就是管这条管线的开和关。
理解这一点很重要,因为open和close并不是简单的"创建对象/销毁对象",它们背后涉及:
- 解码库实例的申请,每种编码格式的解码库占用的内存不同;
- 解码任务线程的创建与销毁,杰理平台上的解码通常跑在独立任务里;
- DMA通道和音频接口的占用释放;
- 采样率、声道数、位深等音频参数的配置和复位;
- 电源域的管理,有些芯片在解码时需要把相关模块的电打开,关闭后还要还原。
拿着这个视角再去看启动和关闭,很多"玄学问题"就不玄了。比如你close之后DAC没关干净,进入低功耗时电流偏高,这根本不是解码器的问题,而是关闭流程里音频外设没有复位。后面我会重点讲。
1.2 启动和关闭解码,面向的是哪几个真实场景
杰理平台上的"启动解码"和"关闭解码"不是只有播放MP3一个场景。我遇到过的真实需求大概有这几类:
- 场景A:按下播放键,从SD卡或U盘读一首歌开始播;播完自动切下一首,切歌时要把上一首的解码器关掉,再重新开一个新的解码器。
- 场景B:蓝牙音频模式下,手机推来的A2DP流需要解码;电话进来时,要暂停解码甚至关闭解码器,腾出CPU和内存给通话;挂断后再恢复。
- 场景C:语音提示音、按键音和主音乐之间要切换,不同音频源共享一个解码器还是各开一个,需要做决策。
- 场景D:低功耗待机前,必须把所有解码任务关掉、把DAC通道释放,否则进入不了浅睡或深睡。
不同场景下,启动和关闭的时机、参数、资源处理方式差别很大。比如场景C,主音乐和提示音如果各开一个解码器,内存压力会明显上来,通常的做法是提示音使用独立小解码器,或者干脆用预制的PCM/WAV数据直接灌到DAC,不走通用解码。这些取舍后面聊。
2. 启动解码:接口调用背后的状态流转和参数门道
2.1 open接口看着简单,它内部替你做了哪些事
以我在AC70XX和AC79XX项目里用过的杰理SDK为例,启动解码的核心接口是类似这样的形式:
struct audio_decoder *dec = audio_decoder_open(fops, &task_id);fops是文件操作句柄,task_id是解码任务ID。部分SDK版本里还要传一个audio_format或者解码回调,新一点的SDK把它封装成了decoder_create加decoder_start两步调用,但原理没有变。
这个open一进来,内部做的事情按顺序大致是:
- 校验传入的参数,比如
fops是否为NULL、task_id是否合法; - 申请解码器句柄结构体,这段内存一般是从系统内存池里动态分配的;
- 根据
fops读取文件头,判断文件类型(ID3、RIFF、ADTS这些头),确定要加载哪种格式的解码库; - 解码库初始化,包括码率、采样率、声道数的自动识别;
- 分配解码输入缓冲区和输出缓冲区,这是内存消耗的大头;
- 启动解码任务,创建任务用的栈空间也是一块不小的内存;
- 注册解码完成回调,给上层通知EOS。
也就是说,一次open可能消耗几十KB甚至上百KB内存。在只有几百KB RAM可用的芯片上,这非常可观。我后面说的"解码器反复开关导致内存不足",根源就在这里:一次没释放,两次没释放,第三次之后系统内存池就告急了。
2.2 参数配置的决定性作用
启动解码时,除了open调用本身,最重要的是把输出参数配对。杰理的解码器输出直接对接音频通道,常见的配置项包括:
- 解码格式:MP3、WMA、AAC、FLAC、WAV(PCM/ADPCM)等。同一个open接口通过头信息自动识别,但如果你知道固定格式,可以强制指定,省掉识别时间;
- 输出采样率:44.1kHz还是48kHz,这决定DAC和时钟配置,错了会变调;
- 声道选择:立体声还是强制单声道,单声道输出可以省一半的缓冲和DAC功耗;
- 解码码率上限:对CBR小码率文件可以配置固定码率,减少CPU占用。
曾经有个项目,播放48kHz的WAV文件一切正常,切到44.1kHz的MP3,声音明显偏快、偏高。排查到最后是输出采样率被固定成了48kHz,而MP3解码器按文件头识别出了44.1kHz,但音频输出模块没有跟着切换。杰理平台上的做法一般是解码器识别出采样率后,通过回调通知音频输出模块重新配置DAC,你在驱动里要接好这个回调,否则就出现上面这种"能响但音调不对"的问题。
2.3 解码缓冲区的分配原则
缓冲区怎么配,是新手里最容易随便填、又最容易出问题的地方。
先说输入缓冲。它用于存放从文件系统读出的压缩数据,太小会导致频繁读卡、解码饥饿、声音卡顿。一般建议至少能容纳几百毫秒的压缩数据。
再说输出缓冲。它是解码后PCM数据的暂存区,太小会让DAC周期性地等不到数据,表现为"哒哒"的断音。举个例子:一个典型MP3(128kbps)每秒产生约16KB的压缩数据,解码后44.1kHz双声道16bit PCM每秒约176.4KB。输出缓冲区如果按50ms算,至少要8.8KB;如果按100ms算就要17.6KB。在内存紧张时很多人把输出缓冲压到很低,结果就是DAC欠载。
我建议先按100ms到200ms的PCM输出缓冲起步,然后把输入缓冲控制在64KB以内,结合SDK的内存池大小逐步压。这个数值在不同SDK版本里体现为不同的宏定义,比如DAC_BUFFER_SIZE、DECODE_BUF_SIZE之类,各项目改法不一样,但原则是:先富余再优化。项目初期别抠得太狠,等整个播放流程稳定了,再一点点往下压,每压一次都做48小时老化测试,确认没有卡顿再收。
3. 关闭解码:释放资源比打开更需要细心
3.1 close的正确调用方式
关闭解码的接口一般是audio_decoder_close(dec)。看起来就是一行,但有几点必须注意。
第一,时机。关闭解码的时机要选在解码线程安全的位置。杰理平台一般在音频任务上下文中执行close,输入参数里带task_id,就是为了让close往解码任务里发一个退出消息,而不是直接从外部把任务杀掉。正确模型是:外部发起关闭请求,解码线程处理完当前帧后退出循环,再把资源释放。这个"优雅关闭"和"强杀任务"的差别,就是后面讲的死机问题的根源。
第二,顺序。先停输出,再关解码,还是先关解码再停输出?我的经验是:先让解码任务退出,确保没有线程再往DMA缓冲区写数据,再去关DAC通道、释放缓冲,最后释放解码器句柄。如果顺序反过来,DMA可能还在搬运一块已经被释放的内存,系统会进hardfault。
第三,清理。关闭后要把句柄置NULL,回调函数解除注册,防止DMA中断里还调用到已经释放的回调。这一步很多人忽略,导致的故障非常隐蔽。我自己就在一个项目的待机唤醒流程里栽过一回:close之后没把回调解挂,唤醒瞬间解码器回调被触发,直接踩了野指针。
3.2 关闭时的三种异常表现
我整理一下close过程中常见的三种异常,方便你对号入座:
| 异常现象 | 大概率原因 | 处理思路 |
|---|---|---|
| close之后立刻死机 | DMA还在搬运已释放的内存;DAC先于解码任务被关 | 严格按"先停任务、再停外设"的顺序调整 |
| close返回后再open失败,报内存不足 | close没有走完整资源释放,句柄和缓冲区泄漏 | 用内存池水位统计排查,定位泄漏点 |
| close之后系统能跑,但进不了低功耗 | 解码任务没真正退出、定时器未停、音频接口未释放 | 检查任务句柄状态,退出循环后把相关时钟全关了 |
这三种问题我全在项目里见过,每一种解决起来都不难,难的是复现和定位。所以建议从一开始就在close路径上多打日志,把"申请资源栈地址"和"释放资源栈地址"成对打出来,对账就一目了然。
3.3 启动和关闭必须成对出现
这听上去像废话,但在杰理平台上特别重要。因为很多代码路径是事件驱动的:播放事件、切歌事件、暂停事件、待机事件,每个事件都可能是不同文件写的,很容易出现"播放在A文件里open,暂停在B文件里只做了状态置位没做close",结果资源泄漏。
我见过一种脏做法:每个需要播放的地方都无条件open,把旧句柄直接覆盖。旧解码器没有被close,于是每次切歌都漏十几KB内存,播几十首之后系统就开始卡顿、蓝牙断连,最后复位。正确做法是维护一个播放状态机,在任何新播放开始之前,先确保旧解码器已close,或者让open接口内部设计成"先关旧、再开新"。
4. 一套可以抄作业的播放流程
4.1 代码骨架
给一个比较通用的播放流程,基于杰理SDK的常见接口,具体函数名以实际SDK版本为准。
/* 播放一个文件 */ struct audio_decoder *dec = NULL; struct audio_fops file_fops; file_fops.open = my_file_open; file_fops.read = my_file_read; file_fops.close = my_file_close; file_fops.lseek = my_file_lseek; file_fops.length = my_file_length; /* 如果有旧解码器,先关 */ if (dec != NULL) { audio_decoder_close(dec); dec = NULL; } /* 打开解码器 */ dec = audio_decoder_open(&file_fops, &task_id); if (dec == NULL) { /* 错误处理,返回错误码 */ return -1; } /* 配置输出参数:采样率、声道数、位深 */ audio_decoder_set_output_para(dec, 44100, 2, 16); /* 启动解码 */ audio_decoder_start(dec);这个骨架最核心的是open前后的成对逻辑:先清旧状态,再开新句柄。别小看这两行,很多卡顿问题出在旧句柄没有清理干净。另外注意fops里的open/read/close不要直接对文件系统裸调,中间最好加一层带锁的包装,防止解码任务和主任务同时操作文件指针,导致读取位置错乱。
4.2 状态事件处理
播放过程不是一路绿灯,需要处理的事件至少包括:
- 播放中数据不足(文件读取慢导致欠载)
- 解码错误(格式不支持、文件损坏)
- 播放完成(EOS)
- 用户主动停止
每个事件都对应一个明确动作。比如EOS事件里,要先把状态置为"停止",再close解码器,然后根据播放列表决定下一首是自动播放还是停在停止状态。我习惯用一个枚举状态变量:
typedef enum { PLAY_IDLE, PLAY_OPENING, PLAY_PLAYING, PLAY_PAUSED, PLAY_STOPPING, } play_state_t;每次状态切换时打印日志,后面调试问题会省很多事。千万别图省事只用一个bool表示"在播放",满足不了切歌和暂停同时处理的场景。
4.3 带按键控制的示例
写一个带按键控制的小例子。按键短按播放/暂停,长按停止。
void key_short_press(void) { if (play_state == PLAY_PLAYING) { audio_decoder_pause(dec); play_state = PLAY_PAUSED; } else if (play_state == PLAY_PAUSED) { audio_decoder_resume(dec); play_state = PLAY_PLAYING; } else { start_play("C:/music/test.mp3"); } } void key_long_press(void) { if (dec != NULL) { audio_decoder_close(dec); dec = NULL; play_state = PLAY_IDLE; } }注意暂停时不能close,因为暂停要保留解码位置,方便恢复;停止时则必须close,把资源释放出来。这两者的区别要记牢,很多项目为了省事,暂停直接close,导致恢复播放时从头开始,非常影响体验。
5. 真实项目中踩过的坑和排查方法
5.1 解码器反复开关导致的内存不足
某车载蓝牙项目,用户连续切歌二三十次,系统开始卡顿、蓝牙断连,最后复位。用内存统计看,每切一次歌内存池少了约20KB,每次close之后没有完全归位。后来定位到原因是:文件句柄释放了、解码器句柄释放了,但解码任务用的栈空间没有回收,因为close内部等待任务退出用了超时机制,超时后任务没退出,栈空间就泄漏了。
解决办法:排查是哪个条件导致解码任务没退出,延长等待超时时间,并且打开SDK里的"强制释放"选项。后来我也总结出一个习惯:在内存统计接口里定期打印各任务栈使用率和剩余堆内存,项目阶段就能尽早发现问题。这个习惯帮我省了很多通宵查bug的时间。
5.2 播放中直接close导致系统复位
另一个项目,工程师在按键处理里直接调用close,结果一按停止键系统就复位。看复位日志是总线错误。原因是close发消息给解码任务,但解码任务正在等待DMA中断同步,按键处理函数没有先同步等待解码任务结束,就从外部把DAC关了,DMA还挂着。关闭时,DMA下一笔传送直接踩到已释放的缓冲地址,触发hardfault。
正确流程是:外部请求停止后先置一个stop_flag,解码线程里探测到该标记就正常退出循环并释放资源,外部只负责等待状态变为IDLE,而不是直接关DAC。这一步处理干净,这台机器的停止按键问题才彻底消失。
5.3 多实例解码相互抢占问题
有些项目需要在音乐播放中叠加提示音,于是一口气开了两个解码器。两个解码器共享同一个DAC通道,输出采样率和声道数不一致时,DAC被反复切换配置,结果音乐声音发闷、提示音炸音。
我的做法是分层:主音乐用通用解码器,输出到主DAC通道;提示音用短小的预编码WAV或几百字节的PCM,不经解码器,直接放进一个小DMA缓冲区发送到另一个输出通道。如果硬件只有一个DAC,则提示音播放时先暂停主解码器输出,用"换源"方式处理。优先级和通道分配必须在项目启动前定清楚,不然后期改起来特痛苦。
最后再分享一下我个人的操作习惯。我在杰理方案上做播放功能这几年,反复验证下来,"启动解码和关闭解码"这件事,拼的从来不是open和close那两行代码,而是资源管理是否严谨、状态机是否周全、释放顺序是否正确。刚上手的朋友可以先把demo跑通,再往里面加暂停、切歌、低功耗这些逻辑,每加一个功能就盯一下内存池水位和任务状态,这样能少走很多弯路。写到这里,下一篇可以聊聊杰理解码中的缓冲区和低功耗协同配置,如果大家在实际项目中遇到这类问题,欢迎带着具体现象来交流。