【前言】由于启明RA6M5的I2S被其他的端口占用,因此改为EK-RA6M5官方开发板进行测试。
工程:app/ek_ra6m5_radio(工作区 D:\luglZephyrproject,Zephyr 4.4.0,板卡 ek_ra6m5/r7fa6m5bh3cfc)
日期:2026-10
范围:M2 网络电台——原生 socket HTTP 拉流、minimp3 解码、重采样、SSIE0 → MAX98357A 出声, 以及"偶发咔哒杂音"的完整排障过程。
1. 数据通路总览
蜻蜓FM(64kbps MP3, 48kHz 立体声) │ TCP:实时阶段每 ~170ms 到达一个 ~1364B 的包 ▼ rx_thread(net_stream.c, prio 6)── zsock_recv → k_pipe_write │ mp3_pipe 24KB(≈3s 抖动缓冲) ▼ radio_thread(net_radio.c)── ibuf_fill 读管道 → minimp3 解码 │ 1152 样本/帧 → 重采样 48k→48.828kHz(线性插值,跨帧相位连续) ▼ audio_write_pcm(audio_i2s.c)── stage_buf 攒满 1152 样本/块 │ pcm_apply_volume 音量缩放 → k_mem_slab 块 → i2s_write ▼ 驱动 TX 队列(默认 4 块)+ slab 5 块(≈118ms 存货)→ SSIE0 FIFO(中断模式) │ BCK=P112 LRCK=P113 DIN=P115,音频时钟 GPT322 GTIOC2A 片内直连 ▼ MAX98357A → 喇叭
节奏控制的核心思想:全链路唯一节拍器是 I2S 播放。解码线程被 slab 分配(200ms 超时)自然节流,生产速率锁到消费速率。
2. 各模块实现要点
2.1 net_stream.c —— HTTP 拉流
nst_open():DNS → socket/connect → 发GET <path> HTTP/1.0\r\nHost: ...\r\nIcy-MetaData: 0\r\n\r\n(拒绝 ICY 元数据, 拿裸 MP3)→ 逐字节读响应头到 \r\n\r\n(检查含 "200")→ 起接收线程。
接收线程循环 zsock_recv(SO_RCVTIMEO 3s,便于 nst_close 跨线程关流), 净荷 k_pipe_write(..., K_NO_WAIT) 灌 mp3_pipe;管道满就丢本包剩余 ("丢比堵好")——为什么必须这样,见 §4 的翻车实录。
nst_close() 的顺序有讲究:先 shutdown(唤醒 recv)→ k_pipe_reset(丢弃旧流尾巴 + 唤醒阻塞中的管道操作)→ 再等接收线程退出 → 关 fd。 reset 在等退出之前,否则接收线程若卡在管道操作上要等满超时。
上层看门狗:nst_total_bytes() 5 秒不增长判断流,3 秒后重连。
2.2 net_radio.c —— 解码与重采样
MP3 输入滑窗 ibuf 5KB(最大 MP3 帧 ~1.4KB)。ibuf_fill() 顶格请求 读管道(注意:k_pipe_read 是"凑满才返回"语义,曾改小口多次读, 计数器好转但听感变差,已回滚,见 §4.4/§4.7)。
开播门槛:攒够 3KB 再 mp3dec_init,薄缓冲开场易断粮。
minimp3 解出 1152 样本/帧(48kHz 流)→ 线性插值重采样到 48.828kHz (I2S 实际速率,由 GPT322 3.125MHz/64 决定),跨帧保持相位连续。
dbg_passthrough(J-Link 写 1)可旁路重采样,用于试听对比定位杂音来源。
断流处理:nst_is_streaming() 变假或看门狗超时 → 退出解码循环, 由 radio_thread 3 秒后重连(重连期间播放队列存货播完自动转静音兜底)。
2.3 audio_i2s.c —— PCM 输出层
slab 所有权铁律:树内 i2s_renesas_ra_ssie 驱动的 i2s_write() 直接 接管缓冲区,发完 k_mem_slab_free 回 i2s_config.mem_slab——写入的块 必须分配自同一个 slab(drv_slab),不能用栈/静态/别的池。
攒块:stage_buf 攒满 1152 样本(一块)才送 I2S,不满一块绝不送 (补零发送会在每帧尾插静音,一顿一顿)。
队列断粮时驱动跌入 I2S_STATE_ERROR(此后写全失败),用I2S_TRIGGER_PREPARE 拉回 READY 重试;alloc 200ms 超时判 I2S 卡死,I2S_TRIGGER_DROP 释放卡住的块重来。
解码线程与静音填充线程经 i2s_mutex 串行操作 I2S(实测不串行会把 队列卡死在 ERROR 态)。
静音兜底:无 PCM 输入超 40ms 填静音块(K_NO_WAIT,填不进就说明 有内容在播,直接放弃——绝不能阻塞,否则静音块占满队列反过来饿死 解码线程,死亡螺旋)。门槛曾提到 250ms,已随调优回滚(§4.7)。
自测:audio_sine_test_enable() 切 1kHz 正弦,隔离音频链路自验。
2.4 播放队列深度
当前用驱动默认值(TX 队列 4 块 + slab 5 块 ≈ 118ms 存货)。 调优期曾设 CONFIG_I2S_RENESAS_RA_SSIE_TX_BLOCK_COUNT=16(≈378ms)+DRV_BLOCK_COUNT=18 吸收网络包块状到达(~170ms 一包)的间隙, 计数器上静音注入归零,但听感变差,已回滚(§4.7)。
3. 诊断手段(以后排查音频问题照这个来)
3.1 dbg_* 计数器 + J-Link 读内存(主要武器)
代码里埋了一组 volatile uint32_t(不依赖日志、不影响时序):
| dbg_frames | 已解码 MP3 帧 | ~413(41.3fps) |
| dbg_pcm_out | 重采样输出样本(每声道) | ~485000 |
| dbg_pipe_drop | 管道满丢弃字节 | 0(仅开播突发可有) |
| dbg_pipe_empty | 管道断粮(读超时) | 0 |
| dbg_enomsg | 播放队列满(重试节流次数) | 有值正常,配合下者判断 |
| dbg_silence | 断粮注入的静音块 | 0~5(这是杂音计) |
| dbg_i2s_stall | I2S 卡死恢复 | 0 |
读法(变量地址重编后可能变,先 nm 查):
# 查地址 .../gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-nm.exe build/zephyr/zephyr.elf | grep dbg_
import pylink
j = pylink.JLink(); j.open(); j.connect("R7FA6M5BH")
print(j.memory_read32(0x20013064, 1)) # 例:读 dbg_silencepylink 还能直接 j.reset(halt=False) 复位板子,整个"复位→等开播→ 定时采样计数器"可以脚本化,一次跑几分钟拿 delta 序列。
3.2 RTT 日志
控制块地址 0x20000410(nm ... | grep _SEGGER_RTT)。j.rtt_start(addr) 后 循环 rtt_read。注意同一时刻只能有一个 RTT 客户端(RTT Viewer 会抢读)。 驱动报错(如 Cannot write in state: 4)只在 RTT 日志里可见。
4. 翻车实录与经验(今天的主菜)
症状:电台播放中每隔两三秒一声"咔哒"。三轮假设、五版固件才修好, 每一步都留下了教训。
4.1 先测量再动手——一次计数器定位方向
起初怀疑网络丢包/重采样/驱动卡死。埋计数器实测 150 秒:pipe_drop=0、 i2s_stall=0、enomsg=0,唯独 dbg_silence 每 10 秒稳定涨 2~6—— 杂音 = 播放队列断粮时被插入的 23.6ms 静音块(静音↔音乐边界即爆音)。 没有这次测量,后面三轮都会在错误的方向上改代码。
4.2 翻车一:管道 24KB→128KB + 高开播门槛(丢字节死螺旋)
推理"缓冲太浅→加深",结果解码吞吐崩到 5 帧/秒、管道永远满、 丢字节 55KB/10s。机理:开播突发 >256KB(24KB 时代观测到突发 ≥69KB, 128KB 时代 ≥229KB)瞬间灌满管道,K_NO_WAIT 写丢弃产生字节空洞; MP3 帧被切碎 → 解码吞吐崩 → 管道更满 → 丢得更多,死螺旋。 24KB 小管道反而自愈:空洞 3 秒就被消耗掉,之后管道贴空跑、新数据 连续写入,流恢复干净。
经验:对"丢新数据"语义的环形缓冲,容量越大,溢出后的损坏态持续越久。
4.3 翻车二:管道写改阻塞 K_FOREVER(服务器停发)
推理"TCP 有流控,堵比丢好",结果每次连接服务器只发 5456 字节(4 个包,初始拥塞窗口)就静默,两轮复现。接收线程一挂起, TCP 窗口/ACK 节奏异常,服务器直接停发(具体协议细节未深挖, 可能与 Zephyr TCP 延迟 ACK 交互有关)。
经验:这套 Zephyr TCP + 电台服务器的组合里,接收线程必须永远 快速 recv,任何阻塞都会饿死连接。"丢比堵好"在此成立。
4.4 真根因:k_pipe_read 是"凑满请求量才返回"语义
用 J-Link 高分辨率盯 last_feed,看到解码线程周期停摆 1.2~1.4 秒。 读 zephyr/kernel/pipe.c 源码确认:z_impl_k_pipe_read 循环直到buf.used == len,只有超时/关闭才返回部分数据。而 ibuf_fill() 顶格 请求 5120B——管道贴空跑时要等 640ms 才凑满,低水位重填(丢一半 重新同步后请求 ~4500B)更是直接撞上 2s 超时。停摆期间队列被抽干、 静音注入;攒满后解码又瞬间喷 20+ 块,队列装不下被 ENOMSG 丢一半。"咔哒"就是这个限周期在浅队列时代的尾巴。
修复:ibuf_fill() 小口多次(单次 ≤512B,最坏等 64ms),不足一帧才 短阻塞,否则 K_NO_WAIT 有多少抓多少——解码节奏贴住网络到达。
经验:用 RTOS 的 IPC 原语前,先读一眼它的实现/文档语义。 k_pipe 不是 socket,没有"有多少返回多少"。
4.5 配套参数怎么定(都要有依据)——调优版的取值,已随回滚作废
播放队列 16 块(378ms):必须 > 网络包间隙实测 ~170ms。
静音门槛 250ms:必须 > 包间隙(否则正常播放误注静音), 且 < 播放队列存货 378ms(否则队列先抽空、驱动跌 ERROR)。
ENOMSG 重试节流:队列满时 k_sleep(25ms)(≈一块的播放时长 23.6ms) 重试 3 次再丢——把"丢音乐块"变成"生产者等消费者",音乐流连续。
这套取值逻辑本身自洽,问题出在"重试节流"改变了听感(§4.7)。
4.6 修复后体检数据(150s 播放)
frames 41.5fps 稳定、pcm_out 48.5k/s、pipe_drop=0、silence 每 10s 0~5(多为 0,原为稳定 2~8 且间歇爆冲到 150)、 i2s_stall=0。计数器全面转好——但用户复听后认为整体听感仍不如 原配置(细碎异常变多),次日全部回滚。
4.7 最终定论:
mp3_pipe 24KB、接收线程 K_NO_WAIT 丢弃写;
ibuf_fill 顶格请求(K_SECONDS(2))、开播门槛 3KB;
播放队列 5 块(驱动 TX 队列默认 4)、静音门槛 40ms、ENOMSG 直接丢;
dbg_* 计数器保留(以后排查还用得上)。
复盘:计数器只度量了"断粮"一个维度,没度量听感。ENOMSG 重试节流 让解码线程反复睡 25ms(实测 ~18 次/s,占 450ms/s),可能引入了新的 节奏型失真;原配置的偶发跳段虽然计数器难看,人耳反而不敏感。音频链路改动,听感验收必须排在计数器验收之后。
5. 已知未修的小事
启动初期偶发一两声 renesas_ra_i2s_ssie: Cannot write in state: 4(队列建立前的状态机竞争,自愈,影响一声以内)。
断流重连间隙频谱柱定格(无衰减路径),重新开播自动恢复。
电台服务器对短时间高频重连有限流迹象(连上只发几千字节就静默), 调试时避免让固件陷入快速重连循环。
6. 快速复现/回归清单
改音频链路后按序验证:
west build -b ek_ra6m5/r7fa6m5bh3cfc app/ek_ra6m5_radio && west flash -r jlink
pylink 脚本采样 150s:frames≈413/10s、pcm_out≈485k/10s、 pipe_drop=0(播放期间)、silence 每 10s 个位数(网络抖动时可 瞬时冲到几十,伴随 pipe_drop 脉冲,属本配置正常表现);
RTT 日志无 Cannot write in state(除启动一两声);
换台、暂停/播放、音量滑条各操作一遍;
人耳听 3 分钟——听感验收是最终裁判,计数器只是辅助。

我要赚赏金
