前言
最近因为接到一个关于音频的软件修改需求:通过MCU发出人声以及音乐;原先只支持播放电子音。
对于MCU来说,播放格式基本就是PCM,简单就理解为直接存储PWM信息,没有任何其他说明结构:
PCM(Pulse Code Modulation)也被称为脉冲编码调制。PCM音频数据是未经压缩的音频采样数据裸流,它是由模拟信号经过采样、量化、编码转换成的标准的数字音频数据。
提一嘴,WAV格式就是PCM之前加上44字节的说明信息。对MCU场景是不需要的。
常规按照16KHz 8bit存储PCM信息。一秒钟就16KB。板载的STM32F030型号只有32K Flash和8K Ram,于是外置一个8MFlash作为音频存储。
16000 Byte/s
≈16KB/s
板子还没到,提前做了一个F407的demo:通过串口把 PCM 文件烧录到外部 Flash,并配一套上位机做管理。
目标其实很简单:
- 能烧录不同大小的 PCM 文件
- 能知道 Flash 里已经存了哪些音频
- 能避免重复 ID 和地址冲突
- 能把已有音频再读出来导出
看起来不复杂,但真正做下来,重点是怎么把 Flash 里的音频记录管理清楚。
初始方案
最开始的测试版本很直接:
- MCU 收到
START指令后开始接收 PCM - 按
256字节一页写入 Flash - 收完后再读回校验
这个版本适合快速验证链路,对接 SSCOM 也很方便。
但问题也很明显:
- 只能记住最近一次烧录的音频
- 上位机没法知道 Flash 里到底有几条音频
- 如果新文件地址和旧文件重叠,很容易误写
所以后面就从“能烧进去”升级到了“能管理起来”。
最开始串口控制帧本身其实很简单,就是:
#define CMD_START 0x01U
#define CMD_END 0x02U
#define CMD_READ_INDEX 0x05U
typedef struct
{
uint8_t voice_id;
uint32_t flash_addr;
uint32_t file_length;
} transfer_context_t;
上位机发 CMD_START,MCU 解析出 voice_id、写入地址和文件长度后,就进入接收 PCM 的状态。
改善方案
针对上述的几点可能产生的问题,主要补了三个方面的措施。
1. 给音频做索引
在 Flash 前面单独留出元数据区,记录每条音频的:
VoiceID- 起始地址
- 文件大小
- CRC32
这样 MCU 和上位机都能知道当前已经保存了哪些音频。
当前记录结构大概就是这样:
typedef struct
{
uint8_t valid;
uint8_t voice_id;
uint32_t flash_addr;
uint32_t file_length;
uint32_t crc32;
} audio_record_t;
Header 里再额外保存版本、记录数量和索引区地址:
memcpy(header, "VOIC", 4U);
header[4] = 0x02U;
header[5] = 0x00U;
header[12] = (uint8_t)(voice_count & 0xFFU);
header[13] = (uint8_t)(voice_count >> 8);
WriteLe32(&header[16], target_base + METADATA_INDEX_OFFSET);
WriteLe32(&header[20], records_crc);
2. 改成双区域元数据
考虑后面还要移植到 RAM 很小的芯片,所以没有走“大块缓存再整体覆盖”的思路,而是把元数据区做成双区域。
简单说就是:
- A 区存一份索引
- B 区再存一份索引
- 每次更新时写到另一块
- 上电时选择最新且有效的一份
这样做的好处是比较稳,也更适合后面移植到小 RAM MCU。
地址分配现在是这样的:
#define METADATA_REGION_A_ADDR 0x00000000UL
#define METADATA_REGION_B_ADDR 0x00001000UL
#define AUDIO_DATA_MIN_ADDR 0x00002000UL
保存时不覆盖当前区域,而是切到另一块:
uint32_t target_base =
(g_metadata_active_base == METADATA_REGION_A_ADDR)
? METADATA_REGION_B_ADDR
: METADATA_REGION_A_ADDR;
if (Flash_EraseRange(target_base, FLASH_SECTOR_SIZE) != HAL_OK)
{
return HAL_ERROR;
}
3. 烧录前先做冲突检查
现在烧录前会先检查:
VoiceID是否重复- 计划写入的地址范围是否和已有音频重叠
如果有问题,MCU 和上位机都会报错,不允许继续写。
这一点很重要,不然文件一多,后面迟早会把别的音频覆盖掉。
MCU 侧的检查逻辑大概是这样:
if (Metadata_HasVoiceId(g_transfer.voice_id) != 0U)
{
Protocol_SendString("VOICE_ID_EXISTS\r\n");
return RESULT_CONFLICT;
}
if (Metadata_HasAddressConflict(g_transfer.flash_addr, g_transfer.file_length, 0xFFU) != 0U)
{
Protocol_SendString("FLASH_RANGE_CONFLICT\r\n");
return RESULT_CONFLICT;
}
地址冲突判断本质上就是这一句:
if ((new_start < old_end) && (new_end > old_start))
{
return 1U;
}
上位机功能

为了少一点人工操作,也用CodeX做了一个简单上位机。
它现在能做这些事:
- 一键读取当前 Flash 中所有音频记录
- 显示音频数量、ID、地址、大小、CRC
- 选择 PCM 文件后直接执行烧录流程
- 每
256字节发送一次,并支持设置发送间隔 - 删除单条音频记录
- 读回完整音频并导出成文件
- 自动推荐下一个可用烧录地址
其中我觉得最实用的是“自动推荐地址”。
因为一旦 Flash 里有多条音频,手工算下一个地址其实很容易出错。让工具自动推荐,会省很多事。
上位机烧录主流程实际上也不复杂:
self.client.send_frame(CMD_START, bytes([voice_id]) + struct.pack("<II", flash_addr, total))
self.client.wait_for_start_ready(timeout=FRAME_TIMEOUT)
while offset < total:
chunk = data[offset : offset + PAGE_SIZE]
self.client.send_raw(chunk)
offset += len(chunk)
time.sleep(interval_ms / 1000.0)
self.client.wait_for_text_contains("PCM_RX_DONE", timeout=STREAM_DONE_TIMEOUT)
self.client.query_ack(CMD_END, timeout=VERIFY_TIMEOUT)
推荐地址的思路也很直接,就是找当前所有记录中结束地址最大的那一条,再按扇区对齐:
def _recommend_next_address(self, records):
if not records:
return 0x2000
max_end = 0x2000
for record in records:
record_end = record.address + record.length
if record_end > max_end:
max_end = record_end
return self._align_up(max_end, 4096)
调试踩坑
这次还有一个很典型的小坑。
最开始用 SSCOM 发 PCM 文件时,前面大部分数据都能进去,但最后一段会超时。后面确认下来,问题不是协议本身,而是发送节奏。
最后的处理方式也比较朴素:
- 每
256字节之间留一点间隔 - 默认给
100ms,之前试过1、10、50ms都不行
这样链路就稳定很多,也更符合调试现场的使用习惯。
对应的主机发送代码就是:
if offset < total and interval_ms > 0:
time.sleep(interval_ms / 1000.0)
看起来很简单,但对串口工具和 MCU 接收状态机来说,如果串口处理的有点问题,也没有缓冲,可能会出现传输数据丢失的情况。
总结
这次做完以后,最大的感受是:
“烧录功能”本身不难,难的是后续管理。
如果只是把 PCM 写进 Flash,其实很快就能做完;但如果希望后面可维护、可删除、可导出、可防冲突,那索引、校验和上位机配套就必须一起补上。
现在这套方案至少把几个基础问题理顺了:
- Flash 里有哪些音频可以查清楚
- 新文件不会随便覆盖旧文件
- 单条音频可以删除
- 后面移植到小 RAM MCU 也还有空间
Discussion
基于 GitHub Discussions。可以直接参与讨论、补充观点,或者留下你的问题。