简介:面向MTK平台音频开发者与嵌入式驱动学习者的软件架构梳理文档,聚焦MTK audio框架的层次划分与调用链路,帮助读者理解从应用界面到硬件驱动的完整音频播放流程。资源包仅含1个doc文档,约43KB,以文字与结构图说明为主,重点讲解MMI Task层的播放器界面与逻辑处理、MID与Media接口层的媒体操作封装,以及Media Task层消息分发和L1 Audio驱动、编解码库之间的数据传递。文中结合MTK 6225平台audio player实例,给出mdi_audio_play_file、media_aud_play_file等接口及AUD_WAIT_EVENT同步机制的代码片段,并说明mmi_audply_play与mmi_audply_do_play_action两层播放接口的调用关系,便于对照源码定位问题。该文档已有2933人学习,适合需要快速建立MTK音频软件架构整体认知、梳理播放请求从MMI到驱动的传递路径,以及为后续二次开发与调试打基础的读者参考。
1. MTK audio 架构:一个同步接口,底下跑着两套任务
先看一个反直觉的地方。mdi_audio_play_file()的签名返回一个mdi_result,调用方拿到返回值就能继续往下走,看起来和普通同步函数没有区别。但在这个 MTK audio 架构里,它并没有当场把音频播起来:它只是往另一个任务的消息队列塞了一条播放请求,然后把自己挂起,等对方的某个事件标志位置位之后才醒过来。接口是同步的,执行是异步的,这对矛盾就是整套 MTK audio 软件架构的骨架。
搞懂它,表面上是记住 MMI Task、Media Task、L1Audio 三个名字,实际要搞清楚这条请求在路上被转了几手、每一手改了什么、结果又怎么回去。因为一旦某个环节的约定被破坏——事件没置位、结果没回填——用户看到就一个字:卡。
2. MMI Task 层:播放器为什么写不成一条顺序流程
MMI Task 是三层里唯一直接面对用户的,audio player 模块就跑在这里。它管界面、管按键、管播放列表,也管往下调 MID 模块发播放请求。这一层最容易让人误判的地方,是它看起来应该有“主流程”,实际上没有。
2.1 简化 MVC:screen、msghandler、按键逻辑各管一段
audio player 在 MMI Task 内按一个简化版 MVC 切开:screen 部分负责显示,msghandler 部分负责接收和分发消息,主程序逻辑部分负责按键处理和状态迁移。显示用控件实现,控件自己有一套绘制和焦点管理,所以这层界面代码本身比较薄,难点不在画,而在什么时候该画什么。
播放列表不走控件,走文件操作,具体的读写落在audioplayerplaylist这一块。这一点值得单独拎出来说:列表的增删改不是纯内存操作,背后有 I/O 代价。循环播放、随机播放这类场景,如果每次切歌都重新读一遍列表文件,界面会有可感知的停顿。常见的做法是把列表在进入播放界面时读进内存,只在增删时回写。
控件这套东西原文只说“较为简单、可进一步参考控件实现细节”,但实际读代码时要注意控件的事件回传——控件的按键和触摸最终也会变成 MMI 的消息,和播放器自己的按键处理汇到同一个入口,所以按键处理函数里经常要判断“当前焦点在哪个控件上”。
2.2 回调驱动:等于不存在一条从头到尾的主干函数
MTK 平台对事件的处理走 callback function。这句话的分量比听起来重得多:整个模块里找不到一条main() → play() → wait() → done()式的主干,打开源文件想找“播放这个动作的完整流程”,是找不到的。
流程是被事件推着走的。按键来了触发一次回调,定时器到了触发一次回调,下层结果回来了再触发一次回调。每个界面在创建、进入的时候各自设定自己的回调集合,于是同一个事件在不同界面下走到的函数体可能完全不同。这就是为什么在这套代码里读流程,第一步永远是先确认“我现在在哪个界面”,再去翻那个界面对应的回调表。
这种结构带来的调试习惯是:打日志必须带上界面标识和事件 ID,只打函数名会把 A 界面的日志误认成 B 界面的。我在类似结构上吃过亏,两条不同界面的日志混在一起看,得出过完全相反的结论。
回调驱动还有一个隐性成本:状态机是隐式的。界面的状态散落在若干静态变量和上下文结构里,没有一个集中的state字段告诉你“现在处于暂停中”。要判断当前状态,往往得看几个标志位的组合,这也是后面避坑章节里很多问题的根源。
2.3 两层播放接口:mmi_audply_play和mmi_audply_do_play_action
为了给 BT 一个统一入口,这一层做了两层播放接口:mmi_audply_play和mmi_audply_do_play_action()。
分两层的动机不复杂。播放入口不止一个:本地界面点播放、蓝牙侧下发播放命令、后台恢复播放。如果每个入口都直接调do_play_action,那每个入口都得自己判断当前状态、自己决定要不要先停、要不要先切音源。把判断收敛到mmi_audply_play这一层,do_play_action只负责执行已经被判定为合法的动作,是合理的职责切割。
但原文也直说了:模块接口比较复杂,有些庞杂和混乱。原因不难推——接口是不同时期、为不同入口陆续加出来的,同一个语义常常有多个函数名并存,状态判断散在几个地方。读这种演进多年的平台代码,别指望用一条规则概括全部接口,只能按“入口”归类:本地入口一条线、蓝牙入口一条线,两条线在do_play_action汇合。按这个维度切,混乱的接口表就能整理成两张小表。
3. MID 与 Media API 层:同步调用是怎么用消息和事件撑起来的
这一层是整个架构里最值得拆的部分。它对外提供一个看起来同步的 API,对内却完全是消息驱动的,两套语义之间的转换就发生在这里。
3.1 从mdi_audio_play_file到media_aud_play_file:参数怎么一路传下去
上层的入口长这样:
mdi_result mdi_audio_play_file(void *file_name, U8 play_style, void *cache_p, mdi_callback handler);四个参数各有各的归宿。file_name是文件路径,最终要传到 L1Audio 层去打开文件;play_style是播放风格(单次、循环之类),Media Task 会把它存进aud_context_p->play_style,后续暂停恢复都靠它;cache_p是缓冲区指针,只有流式播放那条路径才真正用得上,合成铃声那条路径会忽略它;handler是回调,供 MID 层在操作完成后反向通知上层。
往下走到 Media 接口层:
kal_int32 media_aud_play_file(module_type src_mod_id, void *file_param) { aud_result = MED_RES_OK; aud_send_play_file_req(src_mod_id, file_param); // 投递请求消息 AUD_WAIT_EVENT(AUD_EVT_PLAY); // 挂起,等播放事件 return aud_result; }这里多了个src_mod_id,它不是给播放用的,是给回执用的——Media Task 处理完之后要靠它判断“这条请求是谁发的、要不要单独回一条确认消息”。aud_send_play_file_req内部调的是msg_send_ext_queue(ilm_ptr),把一条 ILM(层间消息)推进 media task 的 external message queue。
注意这个函数的返回路径:aud_result在发消息之前被置成MED_RES_OK,真正的结果要等AUD_WAIT_EVENT醒来之后才可能被改写。这个细节很关键——如果事件等到了但结果没被改写,函数返回的就是这个初始的 OK,上层会以为播放成功了。这就是为什么后面的 handler 里,写结果和置事件必须严格成对。
3.2AUD_WAIT_EVENT与kal_retrieve_eg_events:挂起是怎么实现的
等待逻辑被包成了一个宏:
#define AUD_WAIT_EVENT(evt_) do { \ kal_uint32 retrieved_events; \ kal_retrieve_eg_events(aud_context_p->aud_event, (evt_), \ KAL_OR_CONSUME, &retrieved_events, KAL_SUSPEND); \ } while (0)逐个参数看。aud_context_p->aud_event是事件组句柄,一个任务一个,全局共享;evt_是要等的位,比如AUD_EVT_PLAY;KAL_OR_CONSUME是匹配和消费语义,“或”表示只要有一位命中就返回,“consume”表示命中的位会被清掉;retrieved_events是输出,告诉你实际命中了哪几位;KAL_SUSPEND表示没有命中就挂起当前任务,而不是立刻返回一个超时错误。
这里有两个设计选择值得说。
一是为什么用事件组而不是信号量。一个aud_event要同时承载 PLAY、STOP、PAUSE、错误等多种事件,信号量只能计数、不能区分类型,拿到一个信号量根本不知道是哪种操作完成了。事件组本质是位图,天然能表达“哪几件事发生了”。
二是它没有超时参数。KAL_SUSPEND是无限期挂起。如果 Media Task 那边因为任何原因漏发了事件,MMI Task 就一直睡在那里,界面表现为完全无响应——连按键都不回,因为按键回调也是这个任务在处理。这不是理论风险,是老平台上很典型的一类现场故障。
还有一个容易忽略的点:宏里对retrieved_events这个局部变量取了地址传进去,说明kal_retrieve_eg_events是同步写入的,返回时值已经有效。这意味着调用返回后可以直接读,不需要额外的同步手段。
3.3 同步封装的代价:谁负责超时,谁负责错误
把这三段串起来看,“同步 API”的实现方式是:发消息 → 挂起 → 等事件 → 读结果。代价集中在三处。
第一处是超时无人负责。API 层不设超时,Media Task 层也不保证一定发事件,那“没发”这件事就只能靠现场暴露。实践中要补的是在调用侧加看门狗,或者在 Media Task 的消息处理入口做兜底——任何一条请求消息进来,处理路径上所有 return 分支都必须保证最终置位对应事件。
第二处是结果与事件之间有个窗口。aud_set_result()和AUD_SET_EVENT()是两个独立的写操作,如果顺序反了,等待方可能先被唤醒、后读到旧结果。正确顺序永远是先写结果、再置事件,这一点在下一章的 handler 代码里能看得很清楚。
第三处是错误语义被压平了。mdi_result是一个枚举,但底层可能返回的是解码器特有的错误码,中间要经过一次映射。映射表如果写得不全,就会出现“底层报了解码失败、上层看到的是通用失败”的情况,排查时信息量损失很大。我一般会在映射函数里加一条日志,把原始码和映射后的码一起打出来。
4. Media Task 与 L1Audio:一条请求如何被分发到具体解码器
消息到了 Media Task 之后,事情才开始真正发生。这一层要解决两个问题:一条消息该交给谁处理,以及同一个“播放”动作在不同格式下该走哪条完全不同的路径。
4.1med_main→aud_main:为什么要两级分发
media task 启动之后,med_task_main拿到外部消息,先调med_main((void*)¤t_ilm)做第一次分发。med_main按消息所属的模块大类分流,audio 类消息被交给aud_main(ilm_struct *ilm_ptr),再由aud_main按具体的消息 ID 调用对应的 handler。
分两级不是为了好看。一个 media task 同时托管 audio、video、FM 几类业务,如果只有一级 switch,那个 switch 会有几十上百个 case,新增一个模块就得往同一个函数里塞代码,冲突概率极高。分成两级之后,med_main只关心“这条消息属于哪个模块”,aud_main只关心“audio 内部这是哪个请求”,各自可以独立扩展。
aud_main内部是典型的消息 ID 到 handler 的映射表。对播放请求MSG_ID_L4AUD_MEDIA_PLAY_REQ,对应的处理函数是aud_media_play_req_hdlr。
4.2aud_media_play_req_hdlr的三段式结构
这个函数是整个链路的核心,结构非常规整地分成三段:
void aud_media_play_req_hdlr(ilm_struct *ilm_ptr) { // step 1: 释放冲突资源,设置播放参数 // (停掉正在播的、释放上一首占用的解码器句柄等) // step 2: 按格式选择播放函数 switch (aud_context_p->current_format) { case MED_TYPE_MMF: result = aud_melody_play_by_name(msg_p->file_name, msg_p->play_style, 0); break; case MED_TYPE_M4A: aud_context_p->source_type = AUD_FILE; aud_context_p->play_style = msg_p->play_style; result = aud_media_play_file_stream(msg_p->file_name, msg_p->play_style, 1, KAL_FALSE, msg_p->cache_p); break; case MED_TYPE_3GP: case MED_TYPE_MP4: aud_context_p->source_type = AUD_FILE; aud_context_p->play_style = msg_p->play_style; result = aud_media_play_audio_track_in_video(msg_p->file_name, msg_p->play_style, KAL_TRUE, KAL_FALSE); break; default: result = MED_RES_INVALID_FORMAT; break; } // step 3: 开始播放后处理 aud_set_result((kal_int32)result); // 先写结果 AUD_SET_EVENT(AUD_EVT_PLAY); // 再置事件,唤醒等待方 if (aud_context_p->src_mod != MOD_MMI) { // 非 MMI 来源要单独回执 aud_send_media_play_cnf(result); } }先说 step 1。它没有出现在代码片段里,但注释写得很明确:“释放冲突资源,设置播放参数”。这一步不能省——上一首歌可能还在播、解码器句柄可能还开着、音频通路可能还占着。如果直接进 step 2,表现就是两路声音叠在一起,或者新请求因为资源被占而失败。所有“切歌有杂音”“新旧声音重叠”的问题,先查这里。
step 2 是一个按格式枚举做的路由。注意它不是在判断“文件是不是 AAC”,而是在读aud_context_p->current_format——这个字段是上层在发请求之前就填好的。也就是说,格式判断的责任在 MID 层之上,Media Task 只是照着执行。这个划分有好处也有坏处:好处是 Media Task 不用自己做文件头解析,坏处是上层判错格式时,下层会平静地走错分支。
step 3 的顺序是这段代码里最容易被改坏的地方。aud_set_result必须在AUD_SET_EVENT之前,因为等待方被唤醒后第一件事就是读aud_result。顺序颠倒的话,在单核抢占模型下大概率没事(因为两步之间没有调度点),但一旦中间插入了任何可能引发调度的操作,就会出现“偶发读到旧结果”的玄学问题。
最后那个src_mod != MOD_MMI的判断也值得说。MMI 自己发的播放请求,靠事件同步就够了,不需要额外的确认消息;其他模块(比如后台服务、蓝牙相关的处理模块)没有在aud_event上等待,它们只能靠消息回执知道结果,所以要单独发一条aud_send_media_play_cnf。同一个操作,两种通知机制并存,这是这套架构里同步语义和消息语义共处的直接体现。
4.3 格式路由:三条路径的设计差异
把 switch 里的分支摊开看,能看出三种格式在架构里的地位完全不同:
| 格式类型 | 入口函数 | 关键参数 | 路径性质 |
|---|---|---|---|
MED_TYPE_MMF | aud_melody_play_by_name | 文件名、播放风格、0 | 合成铃声,不走文件流 |
MED_TYPE_M4A | aud_media_play_file_stream | 文件名、风格、1、KAL_FALSE、缓存指针 | 流式解码,需要缓冲区 |
MED_TYPE_3GP/MED_TYPE_MP4 | aud_media_play_audio_track_in_video | 文件名、风格、KAL_TRUE、KAL_FALSE | 复用视频容器的音轨解析 |
MMF 走的是 melody 通道,本质是让底层的合成器按乐谱生成波形,和“解码一个音频文件”是两套东西,所以它连缓存指针都不需要。M4A 走流式路径,第五个参数直接就是上层传下来的cache_p,说明缓冲区是调用方提供的,Media Task 只负责转交。3GP 和 MP4 合并到一个分支,因为它们共用容器格式,音频只是其中一条 track,所以函数名里带audio_track_in_video,参数里那两个布尔值分别控制“是否按音轨解析”和“是否立即开始”。
三条路径对应三套完全不同的下层接口,但上层看到的都是mdi_audio_play_file一个函数。这就是分层在这套架构里真正兑现的价值:格式的差异被关在 Media Task 的 switch 里,没有泄漏到 UI 层。新增一种格式,改动范围基本就是这个 switch 加一个 L1Audio 的打开函数。
4.4 L1Audio 的句柄抽象:AAC_Open与函数表
再往下一层,就是具体解码器的地盘了。以 AAC 为例,接口是一整套:
/* 打开解码器:注册反向通知回调,拿到句柄 */ MHdl *AAC_Open(void (*handler)(MHdl *handle, Media_Event event), STFSAL *pstFSAL, void *param); /* 之后所有操作都以句柄为第一参数 */ Media_Status aacMFPlayFile(MHdl *hdl); Media_Status aacMFResumeFile(MHdl *hdl); Media_Status aacMFPause(MHdl *hdl); Media_Status aacMFStop(MHdl *hdl);AAC_Open传进去三样东西。handler是反向通知通道——解码器内部遇到播放结束、解码错误这类事件时,通过它回调上层,形成一条从下往上的通路。pstFSAL是文件访问抽象层,解码器不直接碰文件系统,所有读操作都走这层,好处是同一套解码代码既能读本地文件也能读别的数据源。param是解码器自己的参数块。
返回值MHdl *是这个设计的核心。打开动作返回的不是一个状态码,而是一个句柄,后续 play、resume、pause、stop 全部以这个句柄为第一参数。这和 C 语言里模拟面向对象是同一个套路——句柄里装着这个解码器实例的状态和函数指针表。
再结合上一节看:aud_media_play_req_hdlr的 switch 里如果选到 AAC 分支,实现上会先调AAC_Open拿到句柄,把这组函数接口存进上下文;之后收到暂停、停止请求时,就不需要再判断格式了,直接从上下文里取出存好的函数指针调用即可。格式判断只发生在打开时一次,后续操作全部走已绑定的接口——这样aud_main里处理暂停的函数就不用关心当前播的是 AAC 还是 M4A。
代价也很清楚:句柄的生命周期必须和上下文严格绑定。Open 了没 Close,就是解码器实例泄漏;Close 之后上下文里还留着指针,下次暂停就是野指针。这类问题在长时间运行的播放器上表现为“播了几十首之后开始失败”,非常难查。
5. 避坑与排查:事件、格式、资源三类翻车现场
这套架构的故障现象高度集中,基本都是“卡住”“没反应”“声音不对”三种。下面五条是这类结构里最容易踩的,每条按现象、原因、解决来拆。
5.1 界面整体卡死,按键也不响应
现象:点了播放之后,整个界面失去响应,不只是播放没开始,连返回键都没反应,只能重启。
原因:MMI Task 阻塞在AUD_WAIT_EVENT(AUD_EVT_PLAY)上,而 Media Task 那边没有置位这个事件。因为按键回调也跑在同一个任务里,任务被挂起,按键自然也没人处理。触发点通常是 handler 里某条提前 return 的路径忘了置事件,比如参数校验失败直接返回、或者aud_media_play_req_hdlr里 step 1 的某个分支抛出了错误码就退出了。
解决:把“置事件”这件事做成强制约定——handler 里任何一条退出路径,都必须经过统一的收尾代码。具体做法是把 step 3 抽成一个函数,所有 return 改成 goto 到收尾标签。另外在 MID 层的等待外面加一层看门狗,超时后打印aud_context_p里的当前状态,至少能让现场暴露出来,而不是变成一个纯黑匣子。
5.2 第二次播放瞬间返回,实际没播
现象:第一首歌正常播放,暂停后换一首再点播放,函数立刻返回成功,但没有任何声音。
原因:事件位残留。KAL_OR_CONSUME的语义是“命中就清位”,但如果某次等待用的不是这个语义(比如某处代码改成了不消费的模式),或者同一位被置了两次而只消费了一次,下一次调用AUD_WAIT_EVENT时会立刻命中残留位返回,读到的是上一次的aud_result。表现出来就是“瞬间成功但没播”。
解决:全局统一事件消费语义,不允许同一事件组上混用消费和非消费模式。排查手段是在AUD_WAIT_EVENT前后各打一条日志,把retrieved_events打出来——如果宏还没真正阻塞就返回了,日志的时间戳间隔会明显偏小。
5.3 切歌时新旧声音叠在一起
现象:播放中切下一首,两路声音同时出来,持续几百毫秒后旧的那路才消失。
原因:aud_media_play_req_hdlr的 step 1 没做完整。上一首的解码器句柄没有 Close,音频通路没有释放,新的一路直接开在旧通路之上。这类问题在功能测试里容易被忽略,因为大部分时候旧的一路会很快自然播完。
解决:step 1 里做严格的资源释放,顺序是先 Stop 再 Close 再释放通路,反过来的话 Stop 会作用在已经释放的句柄上。另外在上下文里维护一个“当前活跃句柄”的字段,新的 Open 之前断言它为空,把隐式问题变成显式崩溃。
5.4 文件能识别出来,但播放时走进 default 分支
现象:某个格式的文件在列表里能显示、能选中,点播放返回失败,日志显示MED_RES_INVALID_FORMAT。
原因:current_format是上层填的,aud_media_play_req_hdlr只是照着 switch。上层如果按扩展名判断格式,而文件实际内容与扩展名不符(改了后缀的文件、容器里套了别的编码),就会填出一个 switch 里没有的枚举值。反过来,如果上层填的枚举和 Media Task 的 switch 不同步——比如新加了格式但 switch 没跟上——也会掉进 default。
解决:在 default 分支里把current_format的原始值打出来,这是定位这类问题最快的一步。长期看,格式判断应该以文件头为准而不是扩展名,并且把枚举定义和 switch 放在同一个头文件附近,新增枚举时靠编译告警提醒遗漏。
5.5 蓝牙侧播放结束,本地界面状态不更新
现象:通过蓝牙发起播放,音频正常播完,但本地播放界面还停留在“播放中”。
原因:回执通道走错了。aud_media_play_req_hdlr末尾的src_mod != MOD_MMI判断决定了要不要发aud_send_media_play_cnf。如果请求来源标识填错,本该走消息回执的请求被当成 MMI 内部请求,就不会有 cnf 发出去;反过来,MMI 的请求如果被误判成外部来源,会多发一条没人处理的 cnf,堆在队列里。
解决:src_mod_id从最上层一路传下来,中途任何一次重新封装都不能丢掉或覆盖它。排查时在 handler 入口打一条带src_mod的日志,和实际发起方对一遍,基本一次就能对上。
6. 设计取舍:这套深调用栈换来了什么,又付出了什么
把三层摊平看,这套架构的核心决策其实只有一个:用消息和事件在两个任务之间搭一座桥,然后在桥的两端各自伪装。对上层伪装成同步函数调用,对下层伪装成普通的异步消息。所有复杂度都是这个决策的利息。
收益是明确的。UI 层完全不用碰解码器和驱动,它只知道mdi_audio_play_file一个入口;解码器完全不用知道消息队列,它只认句柄和回调。格式的差异被关在 Media Task 的一个 switch 里,新增格式的改动范围可控。这套划分在功能层面是干净的。
代价同样明确,而且集中在可观测性上:
| 维度 | 表现 |
|---|---|
| 调用栈深度 | 一次播放跨两个任务、四五个函数层级,静态阅读很难串起来 |
| 同步语义 | 靠事件位模拟,没有超时,出问题就是整体挂起 |
| 状态可见性 | 状态散在上下文结构里,没有集中的状态机 |
| 资源生命周期 | 句柄、缓冲区、通路三套资源要各自配对释放 |
还有一个容易被忽略的取舍:为什么格式路由放在 Media Task 而不是 MID 层。放在 Media Task,MID 层就能保持格式无关,代价是格式判断的责任被推到了更上层,而更上层拿到的信息其实更少(它只有文件名)。这个错位是 5.4 那条问题的结构性根源。
验证这套架构是否健康,我一般看两个东西。一是事件位和结果写入是否严格成对,这个可以通过在aud_set_result和AUD_SET_EVENT前后打时间戳来验证——两者的间隔应该是微秒级,如果出现毫秒级的间隔,说明中间插进了别的东西。二是消息队列的深度,播放请求堆积说明 Media Task 的处理速度跟不上,这时候往下查通常能发现是某条路径上的资源释放卡住了。
血泪经验是:这套代码里,任何一次“为了省事少写一行”都会在几个月后以卡死的形式回来找你。从那以后我每次改 handler,都强制走一遍“所有 return 路径是否都置了事件”的检查,不管改动多小。希望帮到你。
本文还有配套的精品资源,点击获取