Blog Post

[嵌入式] PCM文件的Flash烧录管理

2026/7/30
嵌入式Flash串口上位机

前言

最近因为接到一个关于音频的软件修改需求:通过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;
}

上位机功能

1785399934933

为了少一点人工操作,也用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 也还有空间

参考

音频 PCM / WAV 格式详解 - 知乎

Discussion

基于 GitHub Discussions。可以直接参与讨论、补充观点,或者留下你的问题。