1. 项目背景与需求拆解
RK3588这颗芯片在音视频领域的热度一直居高不下,四核A76加四核A55的架构,配上Mali-G610的GPU和独立的NPU,让它成了不少高端盒子、一体机、工控设备、商显终端的首选方案。我手上这块板子跑的是Android 12,硬件上配了两路HDMI输出加一路板载喇叭,客户的需求很直接:插上HDMI之后,HDMI和喇叭要能同时出声,而不是像默认行为那样HDMI一插上就把喇叭静音了。
这个需求听起来简单,但真正动起手来才发现,Android原生的音频策略框架里,HDMI和Speaker被定义成了互斥的输出设备。系统默认的优先级逻辑是:当检测到HDMI插入时,自动把音频路由切到HDMI,Speaker被强制静音。这套逻辑在手机和平板上是合理的,毕竟用户插了HDMI就是想用电视或显示器出声。但在商显、广告机、会议一体机这类场景里,经常需要HDMI输出给大屏的同时,本地喇叭也要同步发声,比如做双屏异显加本地提示音,或者需要HDMI和喇叭形成声场叠加。
要改这个行为,绕不开的核心模块就是tinyalsa_hal。这是Rockchip平台在Android上使用的音频硬件抽象层,负责把上层AudioFlinger的请求翻译成底层ALSA驱动的具体操作。它决定了音频走哪条通路、混音怎么处理、设备切换的时机和策略。网上关于tinyalsa_hal的资料比较零散,大部分是讲怎么编译、怎么加日志,真正涉及双HDMI加喇叭同步发声这种具体场景的完整修改方案并不多。我把自己踩过的坑和最终跑通的方案整理出来,给遇到同样问题的朋友一个可直接参考的路径。
这篇文章适合几类人看:一是正在做RK3588 Android 12音频定制的驱动工程师;二是需要实现多路音频同时输出的产品开发者;三是对Android音频HAL层感兴趣、想了解tinyalsa_hal内部机制的技术人员。即使你用的是RK3576或者其他Rockchip平台,思路也是相通的,只是寄存器地址和配置文件路径可能有差异。
2. tinyalsa_hal架构与音频路由机制解析
2.1 tinyalsa_hal在Android音频栈中的位置
Android的音频架构从上到下大致分四层:应用层的AudioTrack/AudioRecord,框架层的AudioFlinger和AudioPolicyService,硬件抽象层的audio.primary.xxx.so,以及最底层的ALSA驱动和codec芯片。tinyalsa_hal就处在HAL这一层,编译产物通常是audio.primary.rk3588.so,放在/vendor/lib/hw/或/vendor/lib64/hw/目录下。
它对上要实现Android定义的hardware/audio.h接口,包括open_output_stream、start_output_stream、out_write这些关键函数;对下要通过tinyalsa库的pcm_open、pcm_write、mixer_ctl_set_value等接口操作具体的声卡设备。RK3588平台上通常有多个声卡:card 0一般是板载codec(比如ES8388、RK809内置codec),card 1和card 2可能是HDMI音频控制器。每张声卡下面有多个PCM设备,对应不同的输出通路。
理解这个层级关系很重要,因为我们要改的“HDMI和喇叭同时发声”,本质上是在HAL层决定:当上层要求播放音频时,是只打开一个PCM设备,还是同时打开多个PCM设备并写入相同的数据。
2.2 Android原生音频路由的互斥逻辑
Android的AudioPolicyManager里有一套设备选择策略,核心逻辑在getDeviceForStrategy函数中。对于STRATEGY_MEDIA这种媒体播放策略,系统会按照优先级从高到低检查可用设备:HDMI > 有线耳机 > 蓝牙A2DP > Speaker。一旦HDMI被标记为可用(通过AUDIO_DEVICE_OUT_HDMI),Speaker就会被排除在候选列表之外。
这个策略的出发点是避免声音从多个设备同时出来造成回声或延迟差异。但在我们的场景里,HDMI和Speaker是物理上独立的输出通路,不存在回声问题,反而需要它们同步工作。所以修改分两个层面:一是让AudioPolicy认为这两个设备可以同时被选中,二是在HAL层真正实现同时向两个PCM设备写数据。
注意:直接改AudioPolicyManager的代码在Android 12上比较麻烦,因为它是framework层的核心模块,重新编译system分区镜像的工作量大,而且容易引入其他兼容性问题。更稳妥的做法是在HAL层做文章,让HAL在收到HDMI输出请求时,内部同时打开Speaker通路。
2.3 RK3588音频通路的硬件拓扑
RK3588的音频子系统比较复杂,它内部有多个I2S控制器、SPDIF控制器和HDMI音频模块。以我手上这块板子为例,硬件连接大致是这样的:
- I2S0连接到板载codec(ES8388),负责喇叭输出
- I2S1连接到第一路HDMI音频(通过HDMI TX0)
- I2S2连接到第二路HDMI音频(通过HDMI TX1)
- SPDIF可能用于光纤输出,这里没用到
在ALSA层面,对应的声卡和PCM设备编号需要在/vendor/etc/audio_policy_configuration.xml和HAL的配置里确认。你可以通过cat /proc/asound/cards查看声卡列表,通过cat /proc/asound/devices查看PCM设备编号。我板子上的实际输出是:
# cat /proc/asound/cards 0 [rockchipes8388 ]: rockchip-es8388 - rockchip-es8388 rockchip-es8388 1 [rockchiphdmi0 ]: rockchip-hdmi0 - rockchip-hdmi0 rockchip-hdmi0 2 [rockchiphdmi1 ]: rockchip-hdmi1 - rockchip-hdmi1 rockchip-hdmi1这意味着喇叭走card 0,两路HDMI分别走card 1和card 2。HAL层需要根据上层传来的设备类型,决定打开哪个card的哪个device。
3. 修改方案设计与关键代码实现
3.1 整体修改思路
我的方案是在tinyalsa_hal的output stream打开逻辑里做扩展。具体来说,当上层请求的输出设备包含AUDIO_DEVICE_OUT_HDMI时,HAL不仅打开HDMI对应的PCM设备,还额外打开Speaker对应的PCM设备,并在out_write函数里把同一份音频数据同时写入两个PCM句柄。
这样做的好处是改动集中在HAL层,不需要动framework,编译和替换都方便。缺点是HAL层需要自己维护两个PCM设备的状态同步,比如start、stop、pause这些操作都要同时作用到两个句柄上,否则会出现一个在播一个停了的情况。
另一个需要考虑的问题是采样率和格式的匹配。HDMI和Speaker可能支持不同的采样率,如果上层给的采样率是48kHz,而Speaker codec只支持44.1kHz,就需要做重采样。不过RK3588的ES8388和HDMI控制器都支持48kHz,所以这个问题在我的场景里不存在。如果你的硬件不支持,可能需要在HAL里加一个简单的重采样模块,或者强制上层使用统一的采样率。
3.2 关键数据结构扩展
先看tinyalsa_hal里output stream的结构体定义,通常在audio_hw.h里。原生定义大概是这样:
struct stream_out { struct audio_stream_out stream; pthread_mutex_t lock; bool standby; struct audio_device *dev; struct pcm_config config; struct pcm *pcm; ... };我需要加一个额外的pcm句柄和对应的配置:
struct stream_out { struct audio_stream_out stream; pthread_mutex_t lock; bool standby; struct audio_device *dev; struct pcm_config config; struct pcm *pcm; // 主通路,比如HDMI struct pcm *pcm_extra; // 额外通路,比如Speaker struct pcm_config config_extra; bool extra_enabled; // 标记额外通路是否启用 ... };这里pcm用于HDMI输出,pcm_extra用于Speaker输出。extra_enabled用来控制是否启用同步发声,方便后续通过属性或调试接口动态开关。
3.3 打开输出流的修改
在start_output_stream或open_output_stream函数里,需要根据设备类型判断是否要打开额外通路。核心逻辑如下:
static int start_output_stream(struct stream_out *out) { struct audio_device *adev = out->dev; int ret = 0; // 打开主通路 out->pcm = pcm_open(out->dev->card, out->dev->device, PCM_OUT | PCM_MONOTONIC, &out->config); if (!pcm_is_ready(out->pcm)) { ALOGE("cannot open pcm: %s", pcm_get_error(out->pcm)); return -ENODEV; } // 如果当前设备是HDMI,额外打开Speaker通路 if (out->dev->out_device & AUDIO_DEVICE_OUT_HDMI) { out->pcm_extra = pcm_open(SOUND_CARD_SPEAKER, PCM_DEVICE_SPEAKER, PCM_OUT | PCM_MONOTONIC, &out->config_extra); if (pcm_is_ready(out->pcm_extra)) { out->extra_enabled = true; ALOGD("extra speaker pcm opened for HDMI sync"); } else { ALOGE("cannot open extra pcm: %s", pcm_get_error(out->pcm_extra)); out->extra_enabled = false; } } return ret; }这里的SOUND_CARD_SPEAKER和PCM_DEVICE_SPEAKER需要根据实际硬件定义,我板子上是0和0。out->config_extra的采样率、通道数、格式要和主通路保持一致,否则写入的数据会不匹配。
实操心得:
pcm_open的时候一定要加PCM_MONOTONIC标志,这样两个PCM设备的时间戳基准是一致的,能减少音频不同步的问题。如果不加,HDMI和Speaker之间可能会有几十毫秒的延迟差,听起来像回声。
3.4 写入函数的双通路处理
out_write是音频数据真正写入PCM设备的地方,原生代码只写一个句柄,现在要改成同时写两个:
static ssize_t out_write(struct audio_stream_out *stream, const void *buffer, size_t bytes) { struct stream_out *out = (struct stream_out *)stream; int ret = 0; pthread_mutex_lock(&out->lock); if (out->standby) { ret = start_output_stream(out); if (ret != 0) { goto exit; } out->standby = false; } // 写主通路 if (out->pcm) { ret = pcm_write(out->pcm, buffer, bytes); if (ret != 0) { ALOGE("pcm_write failed: %s", pcm_get_error(out->pcm)); } } // 写额外通路 if (out->extra_enabled && out->pcm_extra) { int ret_extra = pcm_write(out->pcm_extra, buffer, bytes); if (ret_extra != 0) { ALOGE("extra pcm_write failed: %s", pcm_get_error(out->pcm_extra)); } } exit: pthread_mutex_unlock(&out->lock); return bytes; }这里有个细节要注意:pcm_write是阻塞式的,如果HDMI通路的缓冲区满了,它会阻塞等待,这期间Speaker通路也不会被写入。如果两个通路的消费速度差异很大,可能会导致其中一个出现underrun。解决办法是把两个pcm_write放到不同的线程里,或者用非阻塞模式加轮询。不过在实际测试中,HDMI和Speaker的缓冲区深度和消费速率基本一致,直接顺序写没有出现明显问题。
3.5 停止与待机逻辑的同步
out_standby和stop_output_stream里要同时关闭两个PCM句柄:
static int stop_output_stream(struct stream_out *out) { if (out->pcm) { pcm_close(out->pcm); out->pcm = NULL; } if (out->pcm_extra) { pcm_close(out->pcm_extra); out->pcm_extra = NULL; out->extra_enabled = false; } return 0; }如果不做这个同步关闭,会出现HDMI已经停了但Speaker还在响,或者反过来,导致下次打开时状态混乱。我一开始就踩了这个坑,待机后重新播放,Speaker通路没有重新打开,结果只有HDMI出声。
4. 编译、部署与调试验证
4.1 编译环境的准备
RK3588 Android 12的编译环境搭建这里不展开,假设你已经能正常编译整个Android系统。tinyalsa_hal的源码通常在hardware/rockchip/audio/tinyalsa_hal/目录下,编译产物是audio.primary.rk3588.so。
单独编译这个模块可以用:
source build/envsetup.sh lunch rk3588_s-userdebug mmm hardware/rockchip/audio/tinyalsa_hal/编译完成后,产物在out/target/product/rk3588_s/vendor/lib64/hw/audio.primary.rk3588.so(64位系统)。推送到板子上:
adb root adb remount adb push out/target/product/rk3588_s/vendor/lib64/hw/audio.primary.rk3588.so /vendor/lib64/hw/ adb shell sync adb reboot注意:替换HAL库之后一定要重启,因为audio HAL是在系统启动时加载的,直接kill audioserver虽然能重新加载,但有时候会有残留状态,重启最干净。
4.2 验证HDMI和喇叭是否同步发声
重启后,先确认HDMI插入时系统识别到的设备:
adb shell dumpsys audio | grep -A 5 "Devices"你应该能看到AUDIO_DEVICE_OUT_HDMI和AUDIO_DEVICE_OUT_SPEAKER同时出现在输出设备列表里。然后播放一个测试音频:
adb shell tinyplay /sdcard/test.wav -D 0 -d 0如果一切正常,HDMI显示器上的音箱和板载喇叭应该同时出声。你可以用手分别捂住喇叭和HDMI音箱来确认两路都在工作。
更精确的验证可以用tinycap同时录制两路输出,或者用示波器测量两路模拟输出的波形,看延迟差是否在可接受范围内。我实测下来,HDMI和Speaker的延迟差在10ms以内,人耳基本听不出回声。
4.3 通过属性动态控制同步发声
为了方便调试和后续产品化,我加了一个系统属性来控制是否启用同步发声:
static bool is_extra_output_enabled() { char value[PROPERTY_VALUE_MAX]; property_get("persist.vendor.audio.hdmi_speaker_sync", value, "1"); return (value[0] == '1'); }在start_output_stream里判断这个属性,如果为0就不打开额外通路。这样在不需要同步发声的场景下,可以通过setprop persist.vendor.audio.hdmi_speaker_sync 0关掉,不用重新编译。
4.4 常见问题排查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| HDMI出声但喇叭无声 | 额外通路未打开 | 查看logcat中是否有"extra speaker pcm opened" | 检查out_device是否包含HDMI标志,检查card/device编号 |
| 喇叭出声但HDMI无声 | 主通路打开失败 | pcm_is_ready返回false | 检查HDMI声卡是否被其他进程占用,检查audio_policy_configuration.xml |
| 两路都有声但不同步 | 时间戳基准不一致 | 用示波器测量延迟差 | 确保两个pcm_open都加了PCM_MONOTONIC |
| 播放一段时间后喇叭断流 | 缓冲区underrun | 查看pcm_get_error输出 | 增大config_extra.period_size和period_count |
| 待机后重新播放只有HDMI有声 | 额外通路未重新打开 | 检查standby标志和start_output_stream调用 | 确保stop_output_stream里正确关闭并重置extra_enabled |
| 系统启动后第一次播放有杂音 | PCM设备初始化未完成 | 检查codec上电时序 | 在HAL初始化时提前打开一次PCM再关闭,预热设备 |
4.5 性能与延迟优化
双通路写入会增加CPU占用,因为同一份数据要写两次。在我的测试中,48kHz、16bit、双声道的音频,单通路写入时audioserver的CPU占用约3%,双通路约5%,增加不明显。但如果你的应用场景对功耗敏感,可以考虑以下优化:
- 使用
pcm_writei的mmap模式,减少数据拷贝 - 把两个
pcm_write放到独立线程,避免相互阻塞 - 如果Speaker和HDMI的采样率不同,在HAL层做一次重采样,而不是让上层输出两份不同格式的数据
实操心得:RK3588的HDMI音频控制器和I2S控制器是独立的硬件模块,它们可以真正并行工作,不存在硬件层面的互斥。所以双通路同时发声在硬件上是完全可行的,瓶颈只在软件层的策略和HAL实现。
5. 扩展场景与兼容性考量
5.1 双HDMI同时输出的处理
我板子上有两路HDMI,如果两路都插上,系统会识别到两个HDMI设备。Android原生的策略是只选一个HDMI作为输出,通常是最后插入的那个。如果要实现双HDMI加喇叭三路同时发声,思路是一样的:在HAL层打开三个PCM句柄,out_write里写三次。
不过三路同时写对CPU和内存带宽的压力更大,而且三路之间的延迟同步更难保证。如果产品确实需要这个功能,建议在硬件上选择支持多路音频同步输出的HDMI splitter,或者在HAL层用时间戳对齐的方式做精细同步。
5.2 与Android音频策略的兼容
修改HAL层虽然绕开了framework的改动,但有一个潜在问题:AudioPolicyManager仍然认为HDMI和Speaker是互斥的,它可能会在某些场景下主动把Speaker静音。比如系统收到电话或通知时,可能会强制切到Speaker,这时候HDMI的输出会被打断。
要彻底解决这个问题,还是需要改audio_policy_configuration.xml,把HDMI和Speaker定义在同一个mixPort的devicePorts里,让策略层知道这两个设备可以同时使用。这个文件在/vendor/etc/目录下,修改后重启audioserver即可生效,不需要重新编译framework。
<mixPort name="hdmi_speaker_sync" role="source" flags="AUDIO_OUTPUT_FLAG_PRIMARY"> <profile name="" format="AUDIO_FORMAT_PCM_16_BIT" samplingRates="48000" channelMasks="AUDIO_CHANNEL_OUT_STEREO"/> <devicePort tagName="HDMI Out" type="AUDIO_DEVICE_OUT_HDMI" role="sink"/> <devicePort tagName="Speaker" type="AUDIO_DEVICE_OUT_SPEAKER" role="sink"/> </mixPort>5.3 不同Android版本的差异
Android 12的audio HAL接口是hardware/audio.h的5.0或6.0版本,和Android 11、13有一些差异。如果你移植到其他版本,主要注意以下几点:
- Android 13开始推荐使用AIDL版本的audio HAL,tinyalsa_hal的接口会有变化
- Android 11及以前,
audio_policy_configuration.xml的路径可能在/system/etc/而不是/vendor/etc/ - 不同版本的
pcm_config结构体字段可能有增减,编译时注意对齐
5.4 实际产品中的经验总结
我在这个项目上前后折腾了大概两周,大部分时间花在调试不同步和断流问题上。最后跑通的方案其实不复杂,核心就是三个点:HAL层多打开一个PCM、写入时多写一份、状态管理时多同步一个句柄。
有几个经验值得分享:第一,不要一上来就改framework,先从HAL层入手,改动小、风险低、验证快;第二,PCM_MONOTONIC标志一定要加,这是保证两路同步的关键;第三,调试的时候用tinyplay直接播放本地文件,比通过应用层播放更容易定位问题,因为排除了AudioTrack和AudioFlinger的干扰。
后续如果要做更精细的同步,可以考虑在HAL层引入一个简单的环形缓冲区,把两路PCM的写入时间戳对齐到同一个时钟基准。不过对于大部分商显和会议场景,当前的方案已经足够用了。