上个月我在维护一台某设备的时候,接了个看起来特别简单的需求:机器是精简版Linux,没有桌面,开机之后要自动播放一段提示音,之后按照固定时间循环播报,整个过程不用人盯着。需求记录在项目代号18013下面,核心玩法就是在Linux上把播放音频文件这件事做成一个随开机自启的service。
这类需求在工控机、门禁、广告屏、停车场设备里太常见了。一开始我也以为写个播放命令扔进开机脚本就完事,结果真正做下来才发现,坑全藏在细节里:播放器选型、声卡初始化顺序、systemd单元怎么写、退出码的语义、日志怎么看,每一步都能让一个“看起来能跑”的服务在重启之后变哑巴。这篇文章我就按实际做下来的步骤完整写一遍,把我踩过的坑和最终的方案都摆出来,给同样要做无人值守音频播报的朋友做个参考。
1. 这个需求从哪来:给一台无人值守的设备加“会说话”的能力
1.1 需求先拆成四句话
这个项目设备摆在一个无人值守的机柜里,运维不在现场,设备状态只能靠声音反馈。需求最初只有一句“让它开机响一下,然后再定期说句话”,但落地的时候必须拆细。我把它整理成了四条:
- 开机自动播放一段音频,提示设备正常启动;
- 后续要按照固定节奏重复播报,而不是只播一次;
- 播放进程如果意外退出或卡死,系统要能自动拉起来;
- 所有日志要能查到,出了问题运维能远程判断是没播、没响还是服务挂了。
这四条看起来简单,实际上已经把方案方向定死了。第一条要求用开机自启机制,第二条要求要么做常驻服务、要么做定时任务,第三条是服务的守护策略,第四条要求统一日志输出。这就是为什么不能只在命令行敲一条播放命令就结束。
1.2 为什么不用 rc.local 和 crontab
老玩家可能会想,播放音频嘛,写进/etc/rc.local不就行了;或者用 crontab@reboot跑一次。这两个方案都能让音频在开机后跑到,但作为生产环境方案都有硬伤。
rc.local在 systemd 系统里本身就是个兼容层,实际由rc-local.service接管,执行的脚本是孤儿进程,不会被守护、不会自动重启。如果播放到一半程序崩溃,你不会收到任何反馈。crontab @reboot也是类似情况,它能保证启动时机,但没有任何进程守护能力,退出码、标准输出、标准错误都不好跟踪。
这也是我最终选择 systemd service 的原因:它天然支持开机自启、进程守护、日志收集和依赖排序。用 systemd 不是因为它新,而是因为无人值守场景要的就是“进程死了能自动爬起来”这个能力,而 rc.local 和 cron 给不了。
2. 播放器选型:aplay、paplay、ffplay、mpv 到底该选谁
2.1 先把候选播放器摆出来
Linux 下命令行播放音频文件的工具不少,但每个的适用边界差很多。我把当时考虑过的几个列了个表:
| 播放器 | 所属包 | 音频格式支持 | 无桌面环境友好度 | 备注 |
|---|---|---|---|---|
| aplay | alsa-utils | 主要支持 WAV 等未压缩格式 | 很友好 | ALSA 自带,依赖最小,体积最小 |
| paplay | pulseaudio-utils | 取决于 PulseAudio 后端 | 一般 | 依赖音频服务常驻,环境变量坑多 |
| ffplay | ffmpeg | 几乎所有常见格式 | 友好 | 需要加-nodisp,解码能力强 |
| mpv | mpv | 几乎所有常见格式 | 友好 | 需要加--no-video,参数灵活 |
| mpg123 | mpg123 | MP3 | 友好 | 只适合 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 更直观,比如--volume=80、--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 "sine=frequency=880:duration=1" -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,失败再执行 PCM:
amixer -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就是灾难。比如:
ExecStart=cat /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" 2>&1 fi sleep 5 doneset -u是为了防止未定义变量时继续执行,避免一些低级问题。while true加sleep 5是“常驻模式”的关键:只要进程不退出,systemd 就认为服务正常;如果播放器异常退出,这个循环会继续撑住,不会把服务状态带坏。
对应的 service 文件:
[Unit] Description=Audio playback service for project 18013 After=sound.target network.target Wants=sound.target [Service] Type=simple User=root Group=root WorkingDirectory=/opt/audio-play Environment=XDG_RUNTIME_DIR=/run/user/0 ExecStart=/opt/audio-play/play.sh Restart=always RestartSec=5 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target逐行说几个关键点。After=sound.target表明确实希望音频相关服务先启动,但我不指望它一定等到声卡驱动就绪,所以脚本里还有等待/dev/snd的逻辑兜底。Restart=always表示无论进程以什么方式退出,只要不是手动systemctl stop,就自动重启。RestartSec=5是两次重启的间隔,防止播放器频繁崩溃时把 CPU 打满。
StandardOutput=journal和StandardError=journal决定脚本里所有 stdout 和 stderr 都会进 systemd 日志,后面一条journalctl命令就能看全。WantedBy=multi-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: Can't open audio device: No such file or directory这个No such file or directory指向的其实是设备节点还没出现。systemd 服务在开机阶段启动很快,而声卡驱动从加载到/dev/snd/设备节点就绪需要一小段时间。光靠After=sound.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, code=exited, status=0/SUCCESS没有报错,进程是自己正常退出的。问题出在播放器本身:ffplay的-autoexit让它在音频播完之后自动退出,这是对的;但我的服务配了Restart=always,所以 systemd 只要看到进程退出,不管成功失败都会再拉起来。结果就是循环播放变成了每隔几秒重启服务。
这其实是“一次性任务”和“常驻服务”两种语义的混淆。如果你要做的是播一次就结束,正确做法是:
- 服务文件里不要写
while true循环; - 如果是开机播报,用
Type=oneshot配合RemainAfterExit=yes; - 如果是定时播报,交给 systemd timer。
如果你要做的是常驻监控,脚本里的while true才合适。我这里最终选择的是常驻型服务,因为设备需要随时接收新指令,如果进程退出了,系统还要花时间重启,期间可能会漏掉事件。
我还遇到过一种更隐蔽的情况:播放进程被系统 OOM killer 杀掉。设备内存太小,音频文件解码时内存占用飙升。日志里没有code=exited,而是进程直接消失,伴随内核的 Out of memory 消息。这种就不是 service 配置问题,而是要去给设备加交换分区,或者换成更省内存的播放器。
6. 从“播一次”到“按需播”:timer、目录监控与条件触发的玩法
6.1 用同名 timer 做全天定时播报
常驻服务的循环播报比较粗糙,它只能固定间隔重复播,做不到“每天早上八点、中午十二点、下午六点各播一次”这种精确调度。要精确调度,就得用 systemd timer。
做法是建两个文件,一个 service、一个 timer,前缀必须一样:
/etc/systemd/system/audio-notify.service:
[Unit] Description=Periodic audio notify service for project 18013 [Service] Type=oneshot User=root ExecStart=/opt/audio-play/play_once.sh /opt/audio-play/files/notify.wav/etc/systemd/system/audio-notify.timer:
[Unit] Description=Timer for periodic audio notify service [Timer] OnCalendar=*-*-* 08,12,18:00:00 Persistent=true [Install] WantedBy=timers.target然后启用的是 timer,而不是 service:
systemctl daemon-reload systemctl enable --now audio-notify.timer systemctl list-timers audio-notify.timer这里有个细节值得说:Persistent=true表示如果设备在计划播放的时间点是关机状态,那么下次开机时会补播一次。这对无人值守设备特别友好,因为机器不可能保证全天在线,错过计划时间后至少能补上一次。
play_once.sh就是没有 while 循环版本的单次播放脚本,播放完就会退出。Type=oneshot匹配的就是这种“播完即走”的语义,系统不会因为进程退出而把它当成故障。
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" 2>&1 ;; esac done-m让 inotifywait 保持前台运行,持续监听;-e close_write表示只在文件写完关闭后触发,避免文件还在传输时就播放半个文件。脚本放到上一节的常驻服务里跑,又是Restart=always兜底。
这里容易踩的坑是:如果误把图片、文本也放进目录,脚本会因为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" 2>&1 else ffplay -nodisp -autoexit -loglevel quiet -volume 80 -i "$AUDIO_DIR/normal.wav" >> "$LOG_FILE" 2>&1 fi这套逻辑可以和前面的 timer 或 inotify 配合:定时播报时,先判断系统状态,再决定播警报还是正常。也可以做成服务启动时判断一次,作为开机自检的一部分。
项目做到后期,我甚至把播放脚本抽象成了一个小函数库,支持传入音频路径、音量、是否等待声卡就绪,不同的服务复用同一套逻辑。这样不管是开机播报、定时播报、目录监听还是状态告警,都只是换参数,不换框架。
我做这个小项目时最深的感受是:Linux 音频播放和自启动服务单独拎出来都不算难,但组合在一起,要照顾的细节比想象中多得多。声卡什么时候就绪、播放器进程什么时候退出、服务重启的语义是什么、日志去哪里查,每个细节都会在无人值守场景下放大成故障。做完之后我又把这个套路复用到好几台设备上,基本只改音频路径和触发条件就能跑,希望这篇文章也能帮你少踩几个我踩过的坑。