从源码编译SOF固件与Topology:定制音频DSP的完整指南
我最初接触 SOF 固件时也是老老实实去下载发布版拷进/lib/firmware/intel/sof就能出声觉得挺省心。直到有一天想给板子加一路自定义 EQ又想把 DMA buffer 调大一点来压制 xrun才发现预编译固件和 topology 根本改不动这才被逼着把源码编译这条路完整走了一遍。SOFSound Open Firmware是目前 Intel、AMD 平台非常主流的音频 DSP 固件框架。固件本身跑在 DSP 上负责音频处理、DMA 调度、DAI 数据传输这些事情而 topology 则是描述 DSP 内部音频管线怎么连、widget 和 kcontrol 怎么配、PCM 和 DAI 怎么映射的二进制配置。简单说固件决定 DSP“有什么能力”topology 决定这能力“怎么被用起来”。这篇博文就围绕“如何从源码编译 SOF 固件与 topology”展开适合已经能正常使用 SOF 预编译版本、但想进一步定制和调试音频链路的朋友也适合想从头摸清整个构建流程的嵌入式音频开发者。1. 为什么要自己编译预编译固件做不到的三件事1.1 定制不是改个参数那么简单很多朋友觉得修改音频行为只要改 alsa-lib 或者 UCM 配置文件就行这确实能解决一部分问题。但如果你要调整 DSP 内部 pipeline 的 buffer 大小、改变某个 module 的处理精度、动态加载一个新的效果器光靠用户态配置是做不到的。这些参数要么写在固件初始化流程里要么定义在 topology 的 m4 模板中两者都被打包成了二进制。预编译固件和官方 tplg 文件打开就是一堆结构体唯一能做的就是用 hex 编辑器去撞运气但这显然不现实。我自己遇到的一个典型场景是某块板子的 I2S 回采经常出现 underrun想尝试把 DMA 的 period 数从 2 改成 4同时把 buffer size 从 64KB 提到 128KB。这个改动在预编译固件上没有任何办法因为 buffer 配置是通过 topology 里的宏定义和控制字写入的。只有拿到源码修改对应的 m4 宏重新编译 topology 才能生效。另一个常见场景是加 EQ。SOF 自带一批通用算法模块比如eq_iir、eq_fir、tdfb、kpb但这些模块默认不一定被编译进固件更不一定被 topology 引用。预编译版本为了兼容性通常会开一个比较保守的配置。你自己编译时完全可以把不需要的模块去掉把需要的模块编进去做出来的固件体积更小加载也更快。1.2 排查问题需要符号和日志SOF 驱动跑出问题时dmesg 里往往会有一段类似sof-audio-pci ... firmware boot timeout的信息或者出现ipc error之类的关键字。只看这些想定位具体是哪个 module 的问题很难因为你拿不到固件内部日志的解析符号。SOF 固件调试需要专门的ldc文件它记录了固件日志字符串的符号表。官方发布包里确实会带 ldc但如果你的固件是从源码定制出来的符号表和你改了哪些宏、删了哪些模块就有对应关系必须用当前源码自己生成一份。自己编译固件时附带生成的sof-xxx.ldc就能和sof-logger工具配合把 DSP 内部每个模块的日志完整解析出来。我调试 DMA 中断风暴时就是靠这个定位到某个 DAI 的配置和时序对不上这个问题在预编译版本里基本没法下手。1.3 代码版本适配和平台差异SOF 的源码仓库迭代非常快不同版本对工具链、内核版本、内核驱动版本的兼容性都不一样。有时候你的内核是较新版本而系统自带的 SOF 固件可能还停留在较老版本两者之间即使二进制能加载行为也可能有差异。从源码编译可以精确控制固件版本让你在排查问题时排除“版本不匹配”这个干扰项。我实测过同样的源码在cavs25平台和cavs18平台上的构建选项完全不同。如果不自己编译你很难理解这些平台 flag 的意义。从进阶角度来说自己编译一次源码你对 SOF 的 Makefile/cmake 逻辑、平台架构差异的理解会上一个台阶之后再看官方 issue 讨论也轻松很多。2. 源码获取和环境准备2.1 仓库与子模块SOF 主仓库在 GitHub 的thesofproject/sof。此外你可能还会用到thesofproject/sof-bin预编译发布版本用于对照、thesofproject/alsa-ucm-confUCM 配置文件非必须。下载时最好带--recursive因为 SOF 仓库包含一些子模块比如tplg-tools、kmod等这些子模块在构建 topology 和驱动时都会用到。git clone --recursive https://github.com/thesofproject/sof.git cd sof git submodule update --init --recursive如果你只需要某个发布分支比如v2.4可以切到对应 taggit fetch --tags git checkout v2.4 git submodule update --init --recursive版本选择上没有绝对标准我的建议是“能用多新就用多新但不要太激进”。如果内核版本比较老选一个和内核发布时期接近的 SOF 版本往往更省事。太新的固件可能要求比较新的驱动接口反而起不来。2.2 工具链选择脚本优先Docker 兜底编译 SOF 固件最麻烦的是 xtensa 交叉工具链。SOF 官方推荐用 Docker 镜像里面已经装好了各个平台对应的 xtensa 工具链。如果不想折腾 Docker也可以用仓库里的scripts/xtensa-build-all.sh脚本它会尝试下载或使用本地已经安装的工具链。我自己的经验是除非你已经对 xtensa 工具链非常熟否则别手动去 crosstool-ng 编译工具链那是纯纯给自己找事。不同平台要求的编译器前缀不一样有的叫xtensa-apl-elf有的叫xtensa-cml-elf环境变量配置错了编译到一半就会报链接错误。先检查系统依赖sudo apt install build-essential cmake ninja-build m4 alsa-utils bison flex然后拉取工具链。以软件方式跑官方 Docker 是这样docker pull ghcr.io/thesofproject/sof但直接进容器编译涉及文件挂载比较绕。我更常用的是在源码目录下执行./scripts/xtensa-build-all.sh -a这个脚本会先检测本地可用的工具链如果找不到会提示你设置XTENSA_TOOLS_DIR或者安装对应工具链。脚本的-a参数表示构建所有平台实际使用时我建议只构建自己的平台否则耗时很长。2.3 确认目标平台编译之前必须先确认声卡对应的 DSP 平台否则编出来的固件根本加载不了。一般用lspci看音频设备lspci -nn | grep -i audio输出里会有类似Audio device [0403]: Intel Corporation ... [8086:....]的信息。根据设备 ID 判断平台比如常见的APL、CNL、CML、TGL、MTL等。如果你用的是笔记本大概率是CML或TGL如果是某些嵌入式板子则可能是APL或GLK。实在不确定可以先看系统里现有的固件名ls /lib/firmware/intel/sof/如果看到sof-apl.ri那基本就是 APL看到sof-tgl.ri就是 Tiger Lake。平台确定后后续所有编译参数都围绕这个平台来。3. 固件编译实操从脚本到 .ri 镜像3.1 编译命令和产物目录我编译时以脚本为主线因为脚本内部会处理好平台对应的配置文件和工具链前缀。假设目标平台是apl执行cd sof ./scripts/xtensa-build-all.sh -p apl如果一切顺利最终产物会在build_apl/sof/目录下包括sof-apl.ri固件镜像实际加载到 DSP 的文件sof-apl.ldc调试日志符号文件配合sof-logger使用若干中间文件新版 SOF 已经逐步迁移到 cmake 构建所以你也可以手动用 cmake 构建。以较新的 SOF 版本为例大致流程是cmake -B build -DPLATFORMapl cmake --build build工具链前缀可能会因为平台名不同而变化具体以源码里的xtensa-build-all.sh和顶层 CMakeLists.txt 为准。如果你的 SOF 版本比较老还在用 Kbuild 那套就直接用make加平台配置。总之版本差异很大务必先看 README 再动手。编译过程需要一段时间尤其是首次编译要链接很多库。我建议编译时开-j$(nproc)但如果你内存不大别开太多线程否则 OOM 很容易把编译过程搞挂。3.2 固件安装编译好的固件需要替换到系统固件目录。先备份旧文件sudo cp /lib/firmware/intel/sof/sof-apl.ri /lib/firmware/intel/sof/sof-apl.ri.bak sudo cp /lib/firmware/intel/sof/sof-apl.ldc /lib/firmware/intel/sof/sof-apl.ldc.bak然后拷贝新文件sudo cp build_apl/sof/sof-apl.ri /lib/firmware/intel/sof/ sudo cp build_apl/sof/sof-apl.ldc /lib/firmware/intel/sof/建议同时检查一下/lib/firmware/intel/sof/community/目录因为有些发行版会使用 community 版本的固件。如果你的系统加载的是 community 版本就需要把文件拷到对应目录或者去修改内核模块参数指定固件名。3.3 验证加载安装完固件后需要重新加载 SOF 驱动。不同平台的驱动模块名不一样常见的有snd_sof_pci_intel_apl、snd_sof_pci_intel_cnl、snd_sof_pci_intel_tgl等。以 APL 为例sudo modprobe -r snd_sof_pci_intel_apl sudo modprobe snd_sof_pci_intel_apl然后看日志dmesg | grep -i sof正常情况会出现类似sof-audio-pci-intel-apl 0000:00:0e.0: firmware: direct-loading firmware intel/sof/sof-apl.ri sof-audio-pci-intel-apl 0000:00:0e.0: boot completed看到boot completed基本就说明固件加载成功了。如果出现boot timeout或者firmware boot failed大概率是固件版本和驱动版本不匹配或者平台配置选错了。cat /proc/asound/cards aplay -l确认声卡节点存在后就可以用speaker-test或者aplay放一段音频验证功能。4. topology 编译实操改 m4、跑 alsatplg4.1 拓扑文件到底是什么topology 文件在 SOF 系统里是一个二进制容器描述音频数据在 DSP 内部如何流动。可以理解成“DSP 世界的接线图”里面定义了 PCM、DAI、buffer、widget、kcontrol、pipeline 等各个元素的属性。SOF 的拓扑源码是.m4文件默认放在tools/topology/目录下。m4是一种宏预处理语言通过宏展开生成最终的 DSP 拓扑描述然后交给alsatplg编译成二进制.tplg文件。直接看生成的二进制你根本看不懂所以我们要在 m4 层做修改。4.2 如何修改一个典型 m4以最常见的sof-hda-generic.m4为例它对应 HDA 声卡的通用拓扑。打开文件后你会看到大量define比如PIPE_FORMAT、HDA_DAI_PERIODS、HDA_DAI_BUFFER_SIZE等。这些宏值最终会被展开到各个 widget 的属性里。我来演示一个最实际的改动增加 period 数来缓解 underrun。原来的宏可能长这样define(HDA_DAI_PERIODS, 2)把它改成define(HDA_DAI_PERIODS, 4)同时把 buffer 调大define(HDA_DAI_BUFFER_SIZE, 262144)改动以后重新编译 topology。如果你用脚本构建一般会在构建过程中自动把tools/topology里的所有 m4 编译成 tplg。我个人更喜欢手动检查 m4 是否有语法错误方式是通过m4预处理器展开成中间文件m4 -I tools/topology -I tools/topology/m4 \ tools/topology/sof-hda-generic.m4 /tmp/sof-hda-generic.txt再检查展开结果确认宏值是否生效。手工编译 topology 的命令也很直接alsatplg -c sof-hda-generic.m4 -o sof-hda-generic.tplg这里的路径和 include 路径都需要配置好否则 m4 找不到依赖文件会报错。实际项目中我建议直接进tools/topology目录执行makeSOF 的 Makefile 已经处理好了依赖关系。4.3 编译和部署拓扑如果你只需要重新生成 topology不需要重新编译固件效率最高的是单独跑 topology 的构建。在tools/topology目录下make编译完成后会生成大量.tplg文件。找到你对应平台的那个比如sof-hda-generic.tplg拷贝到系统拓扑目录sudo cp sof-hda-generic.tplg /lib/firmware/intel/sof-tplg/加载时驱动默认会去/lib/firmware/intel/sof-tplg/找拓扑文件。如果你希望加载自定义名称的拓扑可以在/etc/modprobe.d/sof.conf里指定options snd_sof_pci_intel_apl sof_tplg_filenamesof-hda-generic.tplg注意这个参数不是所有版本都支持如果遇到unknown parameter的提示说明你的驱动版本不支持这样指定那就用默认文件名覆盖原文件即可。4.4 验证声卡和 PCM替换拓扑后最直接的验证方式是重新加载驱动然后看声卡是否枚举出预期的 PCM 设备。比如 HDA 通用拓扑会创建多个 PCM如hw:0,0、hw:0,1、hw:0,2等。查看cat /proc/asound/cards aplay -l如果 PCM 数量和预期一致说明拓扑加载正确。如果你的改动是调整了某个 kcontrol 的增益可以通过alsamixer或者amixer查看对应控件是否出现比如amixer -c 0 contents | grep -i EQ如果能看到你定义的 EQ 控件就说明定制拓扑已经生效了。5. 常见问题与排查技巧实录5.1 固件加载失败与版本匹配我遇到过很多次固件加载失败最典型的日志是sof-audio-pci ... firmware boot timeout多数情况下是固件版本和驱动版本对不上。你可以先用官方发布版固件替换回去确认是不是这个原因。另外有些平台需要对应的community固件比如 APL 的 community 版固件名可能不同如果/lib/firmware/intel/sof/community/里有文件优先检查驱动实际加载的是哪个路径。排查时先执行sudo dmesg | grep -E sof|firmware查看固件文件名是否和你预期的.ri一致。如果是sof-apl.ri但实际 install 时误拷成sof-cnl.ri那肯定是起不来的。5.2 工具链和编译错误编译阶段最常见的错误是找不到 xtensa 编译器比如xtensa-apl-elf-gcc: command not found这说明工具链不在 PATH 里。检查环境变量echo $PATH或者明确指定工具链目录再跑脚本。如果你用 Docker确保挂载了源码目录并且容器内的工具链路径被脚本正确识别。另一个常见问题是 m4 文件找不到 include报错像fatal error: m4/sof.m4: No such file or directory这是因为没有在正确的目录下执行alsatplg或make。进入tools/topology再执行或者在编译命令里加上-I指定 m4 所在目录。5.3 topology 加载失败如果固件加载成功但拓扑加载失败日志里会看到sof-audio-pci ... error: tplg file parse failed这通常是 tplg 文件和固件版本不匹配导致的。比如固件版本较旧不认识新版 tplg 里新增的 widget type或者反过来。解决方法是确保固件和 topology 来自同一个源码版本、同一次构建过程。如果手动改了 m4记住重新编译固件时也要把 topology 一起编译两者要保持同步。还有个小坑是alsatplg版本差异。不同发行版的alsa-lib版本不同老版本alsatplg可能不支持新语法。遇到解析失败时可以先升级alsa-utils和alsa-lib到较新版本。5.4 运行期 xrun 调优固件和拓扑都加载成功后实际播放可能出现underrun或overrun日志里会有xrun关键字。这个问题通常通过拓扑里的 buffer 配置调整。我把常用参数整理成了一张表方便对照现象大概率原因调整建议playback 时 underrunDMA buffer 太小period 数少增加HDA_DAI_BUFFER_SIZE增加HDA_DAI_PERIODScapture 时 overrun采集端 buffer 被瞬时写满增加 capture pipeline 的 buffer size播放有咔哒声period 数不均或 DAI 格式不匹配检查PIPE_FORMAT是否和 codec 配置一致系统负载高时 xrun 频繁调度延迟过大增大 buffer size同时检查 CPU freq 是否被限制调整后重新编译 topology替换文件再验证。这类问题没有“一套参数走天下”的方案需要结合具体硬件和负载来试。6. 我的几点体会从源码编译 SOF 固件和 topology说实话门槛不低我第一次踩坑花了不少时间。回头看最省时间的顺序是先把官方工具链跑通再用官方脚本原样编译一遍确认固件和拓扑都能正常加载后再去改 m4 做定制。很多人一上来就改宏结果固件编译失败都不知道是自己环境问题还是代码问题。还有一点建议大家养成习惯每次编译前记录一下当前 commit、平台参数、改动过的 m4 宏。我吃过一次亏改完 EQ 后固件跑出问题却想不起 baseline 是哪个版本最后只能重新 clone 一份源码对照。编译产物目录里最好保留一份buildinfo.txt这种成本很低排查问题时非常有用。另外topology 的定制实际上比固件本身更常被用到。如果你只是想加 EQ、调 buffer、动态切换 pipeline改 topology 就够了不需要重新编译固件。但如果你要加新的 DSP 模块或者修改模块内部算法那就必须走完整套“固件 topology”编译流程。我自己的习惯是先从 topology 下手跑通之后再考虑固件层面的改动循序渐进比较稳。