Linux无人值守音频播报:systemd服务与声卡排错实战
发布时间:2026/10/20 9:08:12 锦皓数字建站

上个月我在维护一台某设备的时候接了个看起来特别简单的需求机器是精简版Linux没有桌面开机之后要自动播放一段提示音之后按照固定时间循环播报整个过程不用人盯着。需求记录在项目代号18013下面核心玩法就是在Linux上把播放音频文件这件事做成一个随开机自启的service。这类需求在工控机、门禁、广告屏、停车场设备里太常见了。一开始我也以为写个播放命令扔进开机脚本就完事结果真正做下来才发现坑全藏在细节里播放器选型、声卡初始化顺序、systemd单元怎么写、退出码的语义、日志怎么看每一步都能让一个“看起来能跑”的服务在重启之后变哑巴。这篇文章我就按实际做下来的步骤完整写一遍把我踩过的坑和最终的方案都摆出来给同样要做无人值守音频播报的朋友做个参考。1. 这个需求从哪来给一台无人值守的设备加“会说话”的能力1.1 需求先拆成四句话这个项目设备摆在一个无人值守的机柜里运维不在现场设备状态只能靠声音反馈。需求最初只有一句“让它开机响一下然后再定期说句话”但落地的时候必须拆细。我把它整理成了四条开机自动播放一段音频提示设备正常启动后续要按照固定节奏重复播报而不是只播一次播放进程如果意外退出或卡死系统要能自动拉起来所有日志要能查到出了问题运维能远程判断是没播、没响还是服务挂了。这四条看起来简单实际上已经把方案方向定死了。第一条要求用开机自启机制第二条要求要么做常驻服务、要么做定时任务第三条是服务的守护策略第四条要求统一日志输出。这就是为什么不能只在命令行敲一条播放命令就结束。1.2 为什么不用 rc.local 和 crontab老玩家可能会想播放音频嘛写进/etc/rc.local不就行了或者用 crontabreboot跑一次。这两个方案都能让音频在开机后跑到但作为生产环境方案都有硬伤。rc.local在 systemd 系统里本身就是个兼容层实际由rc-local.service接管执行的脚本是孤儿进程不会被守护、不会自动重启。如果播放到一半程序崩溃你不会收到任何反馈。crontab reboot也是类似情况它能保证启动时机但没有任何进程守护能力退出码、标准输出、标准错误都不好跟踪。这也是我最终选择 systemd service 的原因它天然支持开机自启、进程守护、日志收集和依赖排序。用 systemd 不是因为它新而是因为无人值守场景要的就是“进程死了能自动爬起来”这个能力而 rc.local 和 cron 给不了。2. 播放器选型aplay、paplay、ffplay、mpv 到底该选谁2.1 先把候选播放器摆出来Linux 下命令行播放音频文件的工具不少但每个的适用边界差很多。我把当时考虑过的几个列了个表播放器所属包音频格式支持无桌面环境友好度备注aplayalsa-utils主要支持 WAV 等未压缩格式很友好ALSA 自带依赖最小体积最小paplaypulseaudio-utils取决于 PulseAudio 后端一般依赖音频服务常驻环境变量坑多ffplayffmpeg几乎所有常见格式友好需要加-nodisp解码能力强mpvmpv几乎所有常见格式友好需要加--no-video参数灵活mpg123mpg123MP3友好只适合 MP3 单一格式这个表里paplay我基本没考虑。PulseAudio 或 PipeWire 这类用户态音频服务本身价值是混音和应用管理但在无人值守的精简系统里多一个常驻服务就多一个故障点。而且 systemd 服务调 PulseAudio 还需要处理XDG_RUNTIME_DIR、DBUS_SESSION_BUS_ADDRESS这类环境变量权限和运行时路径稍微不对就静默失败排错成本很高。2.2 我的选择默认 ffplay轻量场景退回 aplay这个项目里我最后用的是ffplay来自 ffmpeg 包理由很直接音频文件可能是 MP3 或 WAV 混合出现aplay对 MP3 无能为力要再装mpg123或sox又增加依赖而 ffmpeg 生态在设备上经常已经存在ffplay一个命令就能覆盖所有格式。aplay依然有用如果确认音频文件全部是 WAV用aplay更省资源。它没有解码负担不依赖 SDL几 KB 的静态二进制就能干活。在内存很小的嵌入式板子上aplay是更优选。我在脚本里其实预留了一个切换开关如果音源改成 WAV播放命令可以从ffplay换成aplay。为什么不用 mpv 当主角mpv 也很好命令行参数甚至比 ffplay 更直观比如--volume80、--no-video。但 mpv 体积比 ffplay 大一圈启动和退出时还有配置文件解析、输入配置等动作。作为长期常驻服务越简单越好所以我倾向于 ffplay。3. 先手工把音频播通声卡、文件、音量三个前提3.1 先搞清楚声卡在不在、默认设备是谁无论用哪个播放器第一步都是确认声卡真的被系统识别了。我习惯先看这两处cat /proc/asound/cards ls /dev/snd//proc/asound/cards会列出系统里所有声卡/dev/snd/下面则会出现对应的设备节点比如pcmC0D0p表示“声卡 0 设备 0 的播放节点”。pcmC0D0p里的p是 playback也就是播放方向。多声卡设备上默认声卡经常不是你想用的那张。比如某设备上主板的 HDMI 音频排在板载声卡前面系统默认输出走 HDMI而设备根本没接显示器结果播放器跑得欢喇叭一声不响。遇到这种需要手动指定设备号比如aplay -D hw:0,0 test.wav ffplay -nodisp -autoexit -i test.wavhw:0,0就是“硬件设备 0、子设备 0”绕过了 ALSA 的默认路由。但要注意直接指定hw设备会比较霸道如果有别的进程占用了声卡它会报Device or resource busy。测试阶段用没问题正式服务里我建议用default或者自己在/etc/asound.conf里定义好 default 指向哪张卡。3.2 准备一段不会出错的测试音频测试音频不需要多好听关键是格式简单、可控。我常用下面这条命令自己生成一段一分钟的正弦波提示音ffmpeg -f lavfi -i sinefrequency880:duration1 -ar 44100 -ac 2 -c:a pcm_s16le test.wav这段会生成 1 秒、44.1kHz、双声道、16 位 WAV 文件。44.1kHz 16 位立体声 WAV 是兼容性最好的格式aplay、ffplay、mpv都能播。在正式服务里我强烈建议把所有音频统一转成 WAV。虽然 ffplay 支持 MP3但 MP3 有采样率、码率、ID3 标签各种变量嵌入式设备上的解码库版本不一容易出现“这台机器放得出来那台机器放不出来”的诡异问题。WAV 是纯 PCM 封装只要声卡驱动正常几乎不存在解码失败。测试播放命令ffplay -nodisp -autoexit -loglevel quiet -volume 80 -i /opt/audio-play/test.wav这里-nodisp告诉 ffplay 不要创建视频窗口-autoexit保证播放结束后进程自动退出-loglevel quiet把杂乱的输出关掉-volume 80把播放音量固定在 80%。这四条参数在后面的服务里都会用到。3.3 播放前把音量固定住很多设备装的是精简系统没有桌面、没有音量控制小程序。声卡默认音量往往是 0 或者很低直接播放你会以为服务没跑或者音频坏了。我习惯在播放前先用 ALSA 的amixer把音量固定住amixer -c 0 set Master 80% unmute amixer -c 0 set PCM 90% unmute有些声卡没有Master只有PCM有些 HDA 声卡的控制项叫Speaker。保险写法是先执行 Master失败再执行 PCMamixer -c 0 set Master 80% unmute 2/dev/null || amixer -c 0 set PCM 90% unmute 2/dev/null || true这行命令要放在播放脚本里因为 systemd 服务启动时系统可能还没有加载任何用户态音量配置只有 ALSA 驱动层面的默认值。测试阶段如果发现没声音不要急着写服务。先确认三件事aplay -l能看到声卡吗amixer的音量不是 0耳机或喇叭是不是真的插在了对应接口上这三个检查做完再往下走。4. 让 systemd 接管服务单元文件与包装脚本的完整设计4.1 为什么必须有一层包装脚本systemd 的ExecStart虽然能写命令但它不经过 shell 解释。你需要执行的逻辑里如果有重定向、管道、if 判断、循环直接写进ExecStart就是灾难。比如ExecStartcat /opt/audio-play/event.wav | aplay这种写法在 systemd 里会直接失败因为|不是 systemd 认识的语法。解决办法有两个一是用/bin/bash -c ...包一层二是写成独立脚本。我的做法是写独立脚本因为后续要扩展的逻辑远不止一条命令。脚本的好处是可以在本地手工跑一遍不用每次改 service 文件都daemon-reload。而且 service 文件里只需要一个ExecStart/opt/audio-play/play.sh排错时看脚本比看一大串参数直观得多。4.2 常驻型服务的单元文件拆解这个项目需要“循环播报”所以服务本身要一直活着。我设计的脚本结构是这样#!/usr/bin/env bash set -u AUDIO_DIR/opt/audio-play/files EVENT_FILE$AUDIO_DIR/event.wav LOG_FILE/opt/audio-play/play.log # 等待声卡设备出现避免和内核初始化赛跑 for i in $(seq 1 30); do [ -d /dev/snd ] break sleep 1 done # 固定音量 amixer -c 0 set Master 80% unmute 2/dev/null || amixer -c 0 set PCM 90% unmute 2/dev/null || true # 主循环 while true; do if [ -f $EVENT_FILE ]; then ffplay -nodisp -autoexit -loglevel quiet -volume 80 -i $EVENT_FILE $LOG_FILE 21 fi sleep 5 doneset -u是为了防止未定义变量时继续执行避免一些低级问题。while true加sleep 5是“常驻模式”的关键只要进程不退出systemd 就认为服务正常如果播放器异常退出这个循环会继续撑住不会把服务状态带坏。对应的 service 文件[Unit] DescriptionAudio playback service for project 18013 Aftersound.target network.target Wantssound.target [Service] Typesimple Userroot Grouproot WorkingDirectory/opt/audio-play EnvironmentXDG_RUNTIME_DIR/run/user/0 ExecStart/opt/audio-play/play.sh Restartalways RestartSec5 StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-user.target逐行说几个关键点。Aftersound.target表明确实希望音频相关服务先启动但我不指望它一定等到声卡驱动就绪所以脚本里还有等待/dev/snd的逻辑兜底。Restartalways表示无论进程以什么方式退出只要不是手动systemctl stop就自动重启。RestartSec5是两次重启的间隔防止播放器频繁崩溃时把 CPU 打满。StandardOutputjournal和StandardErrorjournal决定脚本里所有 stdout 和 stderr 都会进 systemd 日志后面一条journalctl命令就能看全。WantedBymulti-user.target是开机自启的标准挂载点保证进入多用户文本模式时就会拉起服务不需要等图形界面。4.3 开启自启并验证状态写完文件后先重新加载 systemd 配置再注册自启systemctl daemon-reload systemctl enable --now audio-play systemctl status audio-play --no-pager systemctl is-enabled audio-playenable --now同时完成两件事开机自启注册 立即启动。systemctl status会显示服务的当前状态、Main PID 和最近日志片段is-enabled返回enabled才代表开机自启真的生效。这个阶段最重要的一件事不要只听“服务 active”就放心。我见过太多active (running)却完全没声音的情况。确认方式有两个一是耳朵听二是在另一台机器上systemctl stop audio-play然后再start观察中断恢复是否正常。5. 开机自启后的排错journalctl 里看到的三个真实问题5.1 ExecStart 直接写管道和重定向的坑第一次搭建时我图省事把播放命令直接塞进ExecStart写成了类似下面这样ExecStart/bin/bash -c cat /opt/audio-play/event.wav | aplay虽然用 bash -c 把字符串包住了但当路径或参数稍微变化时引号和转义会非常难受。比如路径里有空格就必须在 service 文件里写一堆转义ExecStart/bin/bash -c ffplay -nodisp -autoexit -loglevel quiet -i \/opt/audio play/event.wav\systemd 的 ExecStart 对引号、空格、美元符号都有自己的解析规则写多了必然出错。我当时卡在这个地方大概半小时状态一直是failed日志里报的是系统找不到某个路径。后来把所有逻辑收进脚本ExecStart只保留一行/opt/audio-play/play.sh问题立刻消失了。经验就是任何复杂的命令都交给脚本ExecStart保持单一可执行文件。5.2 服务起来了但没声音声卡初始化竞争服务状态是active (running)音量也设置了但喇叭就是没动静。我去翻日志journalctl -u audio-play -b --no-pager看到类似这样的错误ALSA lib pcm.c:8424:(snd_pcm_open_noupdate) Unknown PCM cards.pcm.front ffplay: Cant open audio device: No such file or directory这个No such file or directory指向的其实是设备节点还没出现。systemd 服务在开机阶段启动很快而声卡驱动从加载到/dev/snd/设备节点就绪需要一小段时间。光靠Aftersound.target并不能保证设备节点可用因为sound.target只是个聚合节点实际声卡驱动的完成时机更晚。解决方案就是我前面脚本里加的那个等待循环每秒钟检查一次/dev/snd/目录是否存在最多等 30 秒。这个思路比在 service 文件里堆After依赖可靠得多。如果设备上有多个声卡还可以更精确地判断具体节点比如pcmC0D0p是否存在。还有一次是Device or resource busy这是声卡被占用了。多半是系统里另一个进程正在使用同一个 ALSA 设备比如 PulseAudio 或者另一个播放实例。用下面命令能看出来fuser -v /dev/snd/* lsof /dev/snd/*找到了占用进程停掉它播放立刻恢复正常。5.3 播放到一半退出的背后退出码与重启策略常驻服务跑了一段时间我发现日志里服务每隔几十秒就重启一次音频也跟着重复播放。看日志Main process exited, codeexited, status0/SUCCESS没有报错进程是自己正常退出的。问题出在播放器本身ffplay的-autoexit让它在音频播完之后自动退出这是对的但我的服务配了Restartalways所以 systemd 只要看到进程退出不管成功失败都会再拉起来。结果就是循环播放变成了每隔几秒重启服务。这其实是“一次性任务”和“常驻服务”两种语义的混淆。如果你要做的是播一次就结束正确做法是服务文件里不要写while true循环如果是开机播报用Typeoneshot配合RemainAfterExityes如果是定时播报交给 systemd timer。如果你要做的是常驻监控脚本里的while true才合适。我这里最终选择的是常驻型服务因为设备需要随时接收新指令如果进程退出了系统还要花时间重启期间可能会漏掉事件。我还遇到过一种更隐蔽的情况播放进程被系统 OOM killer 杀掉。设备内存太小音频文件解码时内存占用飙升。日志里没有codeexited而是进程直接消失伴随内核的 Out of memory 消息。这种就不是 service 配置问题而是要去给设备加交换分区或者换成更省内存的播放器。6. 从“播一次”到“按需播”timer、目录监控与条件触发的玩法6.1 用同名 timer 做全天定时播报常驻服务的循环播报比较粗糙它只能固定间隔重复播做不到“每天早上八点、中午十二点、下午六点各播一次”这种精确调度。要精确调度就得用 systemd timer。做法是建两个文件一个 service、一个 timer前缀必须一样/etc/systemd/system/audio-notify.service[Unit] DescriptionPeriodic audio notify service for project 18013 [Service] Typeoneshot Userroot ExecStart/opt/audio-play/play_once.sh /opt/audio-play/files/notify.wav/etc/systemd/system/audio-notify.timer[Unit] DescriptionTimer for periodic audio notify service [Timer] OnCalendar*-*-* 08,12,18:00:00 Persistenttrue [Install] WantedBytimers.target然后启用的是 timer而不是 servicesystemctl daemon-reload systemctl enable --now audio-notify.timer systemctl list-timers audio-notify.timer这里有个细节值得说Persistenttrue表示如果设备在计划播放的时间点是关机状态那么下次开机时会补播一次。这对无人值守设备特别友好因为机器不可能保证全天在线错过计划时间后至少能补上一次。play_once.sh就是没有 while 循环版本的单次播放脚本播放完就会退出。Typeoneshot匹配的就是这种“播完即走”的语义系统不会因为进程退出而把它当成故障。6.2 用 inotify 监听音频目录来了新音频就播放另一个常用需求是别人往设备上丢一个音频文件设备马上播出来。比如运维在办公室把一个 mp3 传到设备的某个目录设备立刻广播出来。这种用 inotify 实现最干净。安装工具apt install inotify-tools脚本大概这样#!/usr/bin/env bash set -u WATCH_DIR/opt/audio-play/incoming LOG_FILE/opt/audio-play/inotify.log inotifywait -m $WATCH_DIR -e close_write --format %f | while read -r f; do case $f in *.wav|*.mp3|*.flac) ffplay -nodisp -autoexit -loglevel quiet -volume 80 -i $WATCH_DIR/$f $LOG_FILE 21 ;; esac done-m让 inotifywait 保持前台运行持续监听-e close_write表示只在文件写完关闭后触发避免文件还在传输时就播放半个文件。脚本放到上一节的常驻服务里跑又是Restartalways兜底。这里容易踩的坑是如果误把图片、文本也放进目录脚本会因为ffplay找不到可播放音频而报错。所以我在脚本里用case做了扩展名过滤只处理 WAV、MP3、FLAC 三类文件。6.3 给服务加点判断逻辑实现不同状态播不同音最后一个玩法最实用同一个服务根据系统状态播不同的音频。比如温度超过阈值播告警音一切正常播提示音。实现方式就是在脚本里加条件判断。temp$(cat /sys/class/thermal/thermal_zone0/temp) if [ $temp -gt 60000 ]; then ffplay -nodisp -autoexit -loglevel quiet -volume 90 -i $AUDIO_DIR/warning.wav $LOG_FILE 21 else ffplay -nodisp -autoexit -loglevel quiet -volume 80 -i $AUDIO_DIR/normal.wav $LOG_FILE 21 fi这套逻辑可以和前面的 timer 或 inotify 配合定时播报时先判断系统状态再决定播警报还是正常。也可以做成服务启动时判断一次作为开机自检的一部分。项目做到后期我甚至把播放脚本抽象成了一个小函数库支持传入音频路径、音量、是否等待声卡就绪不同的服务复用同一套逻辑。这样不管是开机播报、定时播报、目录监听还是状态告警都只是换参数不换框架。我做这个小项目时最深的感受是Linux 音频播放和自启动服务单独拎出来都不算难但组合在一起要照顾的细节比想象中多得多。声卡什么时候就绪、播放器进程什么时候退出、服务重启的语义是什么、日志去哪里查每个细节都会在无人值守场景下放大成故障。做完之后我又把这个套路复用到好几台设备上基本只改音频路径和触发条件就能跑希望这篇文章也能帮你少踩几个我踩过的坑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。