IoT-For-Beginners 实战:Wio Terminal 麦克风音频采集——DMAC 双缓冲与 Flash 直写的 C++ 实现
2026/9/14 11:53:53 网站建设 项目流程

IoT-For-Beginners 实战:Wio Terminal 麦克风音频采集——DMAC 双缓冲与 Flash 直写的 C++ 实现

【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners

本篇文章是 IoT-For-Beginners 项目"消费级智能设备"(6-consumer)第 1 课"用 IoT 设备识别语音"的 Wio Terminal 硬件分支教程,完整讲解如何用 C++ 在 Wio Terminal 上编写麦克风音频采集程序:利用 ADC + DMAC(直接内存访问控制器)定时采样、双缓冲轮转存储,并将 PCM 音频以 WAV 格式写入板载 4MB Flash。学完本文,你将掌握微控制器上"无操作系统环境下直接操作寄存器采集音频、规避 RAM 容量瓶颈"的完整工程实现,为后续接入语音转文本服务打下数据基础。

Wio Terminal 的音频采集挑战:192KB RAM 与 4MB Flash

Wio Terminal 内置 MEMS 麦克风,将声波振动转化为模拟电信号,再由片内 ADC(模数转换器)转换为数字信号。与树莓派等单板计算机不同,Wio Terminal 只有192KB RAM,不足以缓存超过几秒钟的未压缩音频;但它同时拥有4MB 板载 Flash,可以替代 RAM 作为音频数据的落盘介质。

先算一笔账(参考 课程 README 的"音频数据大小"小节):以 16-bit、16KHz 的采样规格采集语音,每秒数据量为:

  • 16-bit = 每样本 2 字节;
  • 16KHz = 每秒 16,000 个样本;
  • 16,000 × 2 字节 =32,000 字节/秒(约 32KB/s)

即便程序代码占用极小,192KB RAM 也撑不过 5 秒的录音。因此正确的做法是:将麦克风采到的音频直接写入 Flash,而不是在内存中堆积整段数据。这正是本课工程smart-timer的核心设计思路。

采样时序与 DMAC:用硬件时钟保证 16KHz 采样率

采集音频对时序极其敏感:要采集 16KHz 音频,就必须精确地每秒采样 16,000 次,且每次采样间隔严格相等。用普通的loop()代码很难保证这种确定性。解决办法是使用DMAC(Direct Memory Access Controller,直接内存访问控制器)——这是一种独立于 CPU 的电路,能按固定间隔从外设(如 ADC)抓取信号并写入内存,整个过程不打断处理器的运行。

DMAC 采集流程如下:

  1. 内置麦克风的模拟信号进入ADC被转换为数字值;
  2. DMAC 由定时器/计数器触发(本例为 TC5 溢出事件),以 16,000 次/秒的节奏从 ADC 结果寄存器搬移数据;
  3. 数据被写入预先分配的内存缓冲,缓冲写满后交由你的代码处理。

单缓冲有一个致命问题:处理缓冲期间会错过新采样。解决方案是双缓冲(double buffering)

  • DMAC 先写缓冲 1,写满后通知代码处理缓冲 1,同时 DMAC 转向写缓冲 2;
  • 缓冲 2 写满后再次通知代码,DMAC 又回到写缓冲 1。

只要处理一个缓冲的时间小于 DMAC 填满一个缓冲的时间,数据就不会丢失。本课代码正是基于这个"乒乓"轮转模型实现的。

Flash 的"粒度"约束:擦写必须按块对齐

音频数据最终要落到 Flash。与内存不同,Flash 的擦除与写入必须使用指定地址、指定长度,且按固定大小对齐——这个固定大小被称为粒度(granularity)。例如粒度是 4096 字节,如果你请求在地址 4200 处擦除,硬件可能实际擦除 4096~8192 的整段数据。因此写入 Flash 的音频数据必须以正确大小的分块进行,这也是FlashWriter类存在的意义。

任务一:配置 Flash 存储

创建工程并添加依赖

用 PlatformIO 新建一个名为smart-timer的 Wio Terminal 工程(board = seeed_wio_terminal),并在setup中配置串口。在 platformio.ini 中添加两个库依赖以访问板载 Flash:

[env:seeed_wio_terminal] platform = atmelsam board = seeed_wio_terminal framework = arduino lib_deps = seeed-studio/Seeed Arduino FS @ 2.1.1 seeed-studio/Seeed Arduino SFUD @ 2.0.2 build_flags = -DSFUD_USING_QSPI

其中seeed-studio/Seeed Arduino SFUD @ 2.0.2提供 SFUD(Serial Flash Universal Driver,串行 Flash 通用驱动)库,它被设计为兼容各类 Flash 芯片;仓库中的 platformio.ini 还通过build_flags = -DSFUD_USING_QSPI显式启用 QSPI 模式。

初始化 SFUD 并开启 QSPI 快速读

在 main.cpp 顶部引入头文件:

#include <Arduino.h> #include <sfud.h> #include <SPI.h>

然后在setup()中初始化 SFUD:

while (!(sfud_init() == SFUD_SUCCESS)) ; sfud_qspi_fast_read_enable(sfud_get_device(SFUD_W25Q32_DEVICE_INDEX), 2);
  • 第一段循环阻塞等待 SFUD 初始化完成;
  • sfud_qspi_fast_read_enable打开快速读功能。板载 Flash 通过QSPI(Queued Serial Peripheral Interface,队列式串行外设接口)访问——这是一种带队列的 SPI 控制器,能以极低的处理器占用实现连续访问,从而加快 Flash 读写。

实现FlashWriter:按粒度分块的 Flash 写入器

src下新建flash_writer.h,定义FlashWriter类。完整实现见仓库 flash_writer.h。其私有字段:

byte *_sfudBuffer; // 内存缓冲,写满后整块落 Flash size_t _sfudBufferSize; // 缓冲大小 = Flash 擦除粒度 size_t _sfudBufferPos; // 缓冲内当前写入位置 size_t _sfudBufferWritePos; // 下次写入 Flash 的起始地址 const sfud_flash *_flash; // 指向 Flash 设备的指针(部分 MCU 有多块 Flash)

init()获取 Flash 设备表首项,并按该芯片的erase_gran(擦除粒度)动态分配缓冲:

void init() { _flash = sfud_get_device_table() + 0; _sfudBufferSize = _flash->chip.erase_gran; _sfudBuffer = new byte[_sfudBufferSize]; _sfudBufferPos = 0; _sfudBufferWritePos = 0; }

说明:初始化放在init()方法而非构造函数中,是因为它必须在setup()完成 SFUD 初始化之后才可调用。

写入逻辑的核心是writeSfudBuffer(byte b):先写内存缓冲,缓冲写满后调用sfud_erase_write一次性擦写整块 Flash(同时擦除该位置旧数据),再推进写入地址:

void writeSfudBuffer(byte b) { _sfudBuffer[_sfudBufferPos++] = b; if (_sfudBufferPos == _sfudBufferSize) { sfud_erase_write(_flash, _sfudBufferWritePos, _sfudBufferSize, _sfudBuffer); _sfudBufferWritePos += _sfudBufferSize; _sfudBufferPos = 0; } }

另有两个配套方法:批量写入重载writeSfudBuffer(byte *b, size_t len)逐字节循环调用;flushSfudBuffer()负责把未写满的尾部缓冲强制落盘——因为录音数据几乎不可能是粒度的整数倍,最后一段必须显式 flush。尾部写入会产生少量多余数据,但后续只读取需要的部分,因此可接受。

reset()则把缓冲位置与 Flash 写入位置都归零,供每次录音结束后的重新录制使用:

void reset() { _sfudBufferPos = 0; _sfudBufferWritePos = 0; }

任务二:设置音频采集

定义采集常量config.h

src下新建config.h(仓库中该常量位于 mic.h 顶部),定义录音规格:

#pragma once #include <Arduino.h> #include "flash_writer.h" #define RATE 16000 #define SAMPLE_LENGTH_SECONDS 4 #define SAMPLES RATE * SAMPLE_LENGTH_SECONDS #define BUFFER_SIZE (SAMPLES * 2) + 44 #define ADC_BUF_LEN 1600

各常量含义如下:

常量说明
RATE16000采样率,16,000 即 16KHz
SAMPLE_LENGTH_SECONDS4录音时长(秒)。如需更长的定时器请求,增大此值即可,其余值会自动重算
SAMPLES64000总样本数 = 采样率 × 秒数
BUFFER_SIZE128044音频缓冲总大小:WAV 头 44 字节 + 128,000 字节音频数据(每样本 2 字节)
ADC_BUF_LEN1600DMAC 采集使用的每个缓冲的元素个数

定义Mic类:录音状态机

mic.h中定义Mic类并声明全局实例Mic mic;(见仓库 mic.h)。公开接口的核心是两个状态标志:

class Mic { public: Mic() { _isRecording = false; _isRecordingReady = false; } void startRecording() { _isRecording = true; _isRecordingReady = false; } bool isRecording() { return _isRecording; } bool isRecordingReady() { return _isRecordingReady; } ... private: volatile bool _isRecording; volatile bool _isRecordingReady; FlashWriter _writer; };
  • _isRecording:DMAC 一直在往内存缓冲写数据,该标志决定audioCallback是处理还是忽略这些缓冲;
  • _isRecordingReady:采满 4 秒后置位,通知主循环音频就绪;
  • 两个标志均声明为volatile,因为它们会在中断上下文中被改写;
  • _writerFlashWriter实例,负责把数据持久化到 Flash。

配置 DMAC + ADC:寄存器级的configureDmaAdc

这是本工程最"硬核"的部分(完整代码见 mic.h)。它以寄存器操作为主,要点拆解如下:

1) DMA 描述符与循环缓冲

typedef struct { uint16_t btctrl; uint16_t btcnt; uint32_t srcaddr; uint32_t dstaddr; uint32_t descaddr; } dmacdescriptor; volatile dmacdescriptor _wrb[DMAC_CH_NUM] __attribute__((aligned(16))); dmacdescriptor _descriptor_section[DMAC_CH_NUM] __attribute__((aligned(16))); dmacdescriptor _descriptor __attribute__((aligned(16)));

定义了两个 16 位对齐的描述符数组:_descriptor_section存放实际使用的描述符,_wrb存放写回描述符。两个描述符通过descaddr互相指向对方,构成环形描述符链,实现缓冲 1 与缓冲 2 的自动轮换。

2) 通道触发与传输属性

DMAC->BASEADDR.reg = (uint32_t)_descriptor_section; DMAC->WRBADDR.reg = (uint32_t)_wrb; DMAC->CTRL.reg = DMAC_CTRL_DMAENABLE | DMAC_CTRL_LVLEN(0xf); DMAC->Channel[1].CHCTRLA.reg = DMAC_CHCTRLA_TRIGSRC(TC5_DMAC_ID_OVF) | // 由 TC5 定时器溢出触发 DMAC_CHCTRLA_TRIGACT_BURST; // 突发传输

DMAC 通道 1 以TC5 定时器溢出作为触发源,每溢出一次执行一次突发搬移。

3) 两个描述符分别指向两个 ADC 缓冲

第一个描述符把数据从ADC1->RESULT.reg搬到_adc_buf_0末尾(dstaddr为数组首地址 + 长度 × 2 字节,DMAC 的DSTINC会让目标地址递增):

_descriptor.descaddr = (uint32_t)&_descriptor_section[1]; // 指向下一个描述符(环形) _descriptor.srcaddr = (uint32_t)&ADC1->RESULT.reg; // 数据源:ADC 结果寄存器 _descriptor.dstaddr = (uint32_t)_adc_buf_0 + sizeof(uint16_t) * ADC_BUF_LEN; // 目标:缓冲 0 _descriptor.btcnt = ADC_BUF_LEN; // 节拍数(转移数量) _descriptor.btctrl = DMAC_BTCTRL_BEATSIZE_HWORD | // 节拍宽度 16 位 DMAC_BTCTRL_DSTINC | // 目标地址递增 DMAC_BTCTRL_VALID | // 描述符有效 DMAC_BTCTRL_BLOCKACT_SUSPEND; // 块传输完成后挂起 memcpy(&_descriptor_section[0], &_descriptor, sizeof(_descriptor));

第二个描述符把descaddr指回_descriptor_section[0],目标换成_adc_buf_1,完成环形链:

_descriptor.descaddr = (uint32_t)&_descriptor_section[0]; _descriptor.srcaddr = (uint32_t)&ADC1->RESULT.reg; _descriptor.dstaddr = (uint32_t)_adc_buf_1 + sizeof(uint16_t) * ADC_BUF_LEN; ... memcpy(&_descriptor_section[1], &_descriptor, sizeof(_descriptor));

4) 中断与 ADC 配置

NVIC_SetPriority(DMAC_1_IRQn, 0); // DMAC1 中断优先级设为 0(最高) NVIC_EnableIRQ(DMAC_1_IRQn); // 挂接到 NVIC DMAC->Channel[1].CHINTENSET.reg = DMAC_CHINTENSET_SUSP; // 启用挂起(SUSP)中断

ADC 配置要点:输入通道选择AIN12,采样保持时长取SAMPLEN = 0x00(约 2.66μs),时钟预分频PRESCALER_DIV128(48MHz/128 = 375kHz),分辨率 12 位,FREERUN自由运行模式,最后软件触发启动转换并等待同步:

ADC1->INPUTCTRL.bit.MUXPOS = ADC_INPUTCTRL_MUXPOS_AIN12_Val; while (ADC1->SYNCBUSY.bit.INPUTCTRL) ; ADC1->SAMPCTRL.bit.SAMPLEN = 0x00; while (ADC1->SYNCBUSY.bit.SAMPCTRL) ; ADC1->CTRLA.reg = ADC_CTRLA_PRESCALER_DIV128; ADC1->CTRLB.reg = ADC_CTRLB_RESSEL_12BIT | ADC_CTRLB_FREERUN; while (ADC1->SYNCBUSY.bit.CTRLB) ; ADC1->CTRLA.bit.ENABLE = 1; while (ADC1->SYNCBUSY.bit.ENABLE) ; ADC1->SWTRIG.bit.START = 1; while (ADC1->SYNCBUSY.bit.SWTRIG) ;

5) TC5 定时器:产生 16KHz 采样节拍

GCLK->PCHCTRL[TC5_GCLK_ID].reg = GCLK_PCHCTRL_CHEN | GCLK_PCHCTRL_GEN_GCLK1; // 连接 48MHz 通用时钟 TC5->COUNT16.WAVE.reg = TC_WAVE_WAVEGEN_MFRQ; // 匹配频率(MFRQ)模式 TC5->COUNT16.CC[0].reg = 3000 - 1; // 触发频率 16kHz:源注释为 (4Mhz / 16000) - 1 while (TC5->COUNT16.SYNCBUSY.bit.CC0) ; TC5->COUNT16.CTRLA.bit.ENABLE = 1; // 启动定时器

TC5 以匹配频率模式运行,其比较值决定了采样节拍(源码注释中给出的公式为(4Mhz / 16000) - 1 = 3000 - 1),DMAC 通道 1 正是在 TC5 每个溢出周期被触发一次,从而以 16KHz 的节奏从 ADC 抓取样本。

💁 这段代码比单板计算机上的实现复杂得多,因为微控制器没有操作系统帮忙,代码运行在极低层级、直接操作硬件寄存器。这也是嵌入式开发的典型代价,社区虽有简化库,但底层细节仍无法回避。

WAV 文件头:wavFileHeaderinitBufferHeader

麦克风采集的是裸 PCM 数据,为了后续能被语音服务读取,需要按WAV 容器格式封装。WAV 文件 = 44 字节文件头 + 原始音频数据。代码用结构体定义头部(见 mic.h):

struct wavFileHeader { char riff[4]; /* "RIFF" */ long flength; /* 文件总长度(字节) */ char wave[4]; /* "WAVE" */ char fmt[4]; /* "fmt " */ long chunk_size; /* FMT 块大小(通常为 16) */ short format_tag; /* 1=PCM, 257=Mu-Law, 258=A-Law, 259=ADPCM */ short num_chans; /* 1=单声道, 2=立体声 */ long srate; /* 采样率(每秒样本数) */ long bytes_per_sec; /* 每秒字节数 = srate * bytes_per_samp */ short bytes_per_samp; /* 2=16-bit 单声道, 4=16-bit 立体声 */ short bits_per_samp; /* 每个样本的位数 */ char data[4]; /* "data" */ long dlength; /* 数据长度(文件长度 - 44) */ };

initBufferHeader()填充各字段并写入 Flash:

void initBufferHeader() { wavFileHeader wavh; strncpy(wavh.riff, "RIFF", 4); strncpy(wavh.wave, "WAVE", 4); strncpy(wavh.fmt, "fmt ", 4); strncpy(wavh.data, "data", 4); wavh.chunk_size = 16; wavh.format_tag = 1; // PCM wavh.num_chans = 1; // 单声道 wavh.srate = RATE; wavh.bytes_per_sec = (RATE * 1 * 16 * 1) / 8; wavh.bytes_per_samp = 2; wavh.bits_per_samp = 16; wavh.dlength = RATE * 2 * 1 * 16 / 2; wavh.flength = wavh.dlength + 44; _writer.writeSfudBuffer((byte *)&wavh, 44); }

可见本工程录音规格为16KHz / 16-bit / 单声道 PCM,头 44 字节被FlashWriter最先写入 Flash,随后才是音频数据。

音频回调:audioCallback与数据变换

当 DMAC 填满一个缓冲并挂起时,中断会触发audioCallback(uint16_t *buf, uint32_t buf_len)。其内部把 ADC 的12 位无符号值(0~1023 区间,注释原文如此)转换为16 位有符号值,再拆成高低两个字节写入 Flash:

void audioCallback(uint16_t *buf, uint32_t buf_len) { static uint32_t idx = 44; if (_isRecording) { for (uint32_t i = 0; i < buf_len; i++) { int16_t audio_value = ((int16_t)buf[i] - 2048) * 16; _writer.writeSfudBuffer(audio_value & 0xFF); _writer.writeSfudBuffer((audio_value >> 8) & 0xFF); } idx += buf_len; if (idx >= BUFFER_SIZE) { _writer.flushSfudBuffer(); idx = 44; _isRecording = false; _isRecordingReady = true; } } }

关键点:

  • 静态变量idx44起计数——这是为了跳过已写入的 WAV 头字节数;
  • 只有当_isRecording == true时才落盘,否则直接忽略缓冲;
  • 当累计字节数达到BUFFER_SIZE(128,044)时,先flushSfudBuffer()写掉尾部残余,再把_isRecording复位、_isRecordingReady置位,宣告 4 秒音频采集完成。

中断入口:dmaHandlerDMAC_1_Handler

dmaHandler()检查通道 1 的挂起标志SUSP,先恢复传输(DMAC_CHCTRLB_CMD_RESUME)并清除标志,再根据计数奇偶交替把_adc_buf_0/_adc_buf_1交给audioCallback

void dmaHandler() { static uint8_t count = 0; if (DMAC->Channel[1].CHINTFLAG.bit.SUSP) { DMAC->Channel[1].CHCTRLB.reg = DMAC_CHCTRLB_CMD_RESUME; DMAC->Channel[1].CHINTFLAG.bit.SUSP = 1; if (count) { audioCallback(_adc_buf_0, ADC_BUF_LEN); } else { audioCallback(_adc_buf_1, ADC_BUF_LEN); } count = (count + 1) % 2; } }

中断向量函数按名称约定实现,只需存在即可被调用:

void DMAC_1_Handler() { mic.dmaHandler(); }

initreset:启动与复位

Mic::init()依次完成:设置麦克风引脚模拟参考电压AR_INTERNAL2V23、初始化 Flash 写入器、写入 WAV 头、配置 DMAC/ADC。reset()则在录音使用完毕后复位标志与 Flash 写入位置,并重新写入 WAV 头,为下一次录音做好准备:

void init() { analogReference(AR_INTERNAL2V23); _writer.init(); initBufferHeader(); configureDmaAdc(); } void reset() { _isRecordingReady = false; _isRecording = false; _writer.reset(); initBufferHeader(); }

任务三:编写主程序采集音频

setup:串口、Flash、按钮与麦克风初始化

在 main.cpp 中:

void setup() { Serial.begin(9600); while (!Serial) ; // 等待串口就绪 delay(1000); while (!(sfud_init() == SFUD_SUCCESS)) ; sfud_qspi_fast_read_enable(sfud_get_device(SFUD_W25Q32_DEVICE_INDEX), 2); pinMode(WIO_KEY_C, INPUT_PULLUP); // 按钮 C 用于触发录音 mic.init(); Serial.println("Ready."); }

这里WIO_KEY_C即 Wio Terminal 顶部的 C 按钮(位于左侧、最靠近电源开关)。pinMode(WIO_KEY_C, INPUT_PULLUP)将其配置为上拉输入。

loop:按钮触发、等待完成、处理音频

processAudio()目前为空函数,本课后续将在这里接入语音转文本服务:

void processAudio() { }

主循环逻辑:

void loop() { if (digitalRead(WIO_KEY_C) == LOW && !mic.isRecording()) { Serial.println("Starting recording..."); mic.startRecording(); } if (!mic.isRecording() && mic.isRecordingReady()) { Serial.println("Finished recording"); processAudio(); mic.reset(); } }

状态流转如下:

  1. 检测到 C 按钮按下(digitalRead == LOW)且当前未在录音 → 调用startRecording()置位_isRecording
  2. DMAC/ADC 持续以 16KHz 采样,audioCallback在中断中把数据写入 Flash,直到 4 秒采满;
  3. 采满后_isRecording复位、_isRecordingReady置位;
  4. 主循环发现isRecordingReady()为真 → 打印 "Finished recording"、调用processAudio()处理音频、最后mic.reset()复位并写入新的 WAV 头,等待下一次录音。

构建、烧录与验证

在 PlatformIO 中编译并烧录到 Wio Terminal,打开串口监视器(波特率 9600),按 C 按钮并说话。预期输出:

--- Available filters and text transformations: colorize, debug, default, direct, hexlify, log2file, nocontrol, printable, send_on_enter, time --- More details at http://bit.ly/pio-monitor-filters --- Miniterm on /dev/cu.usbmodem1101 9600,8,N,1 --- --- Quit: Ctrl+C | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H --- Ready. Starting recording... Finished recording

完整代码与后续衔接

本课完整可运行的工程位于仓库 code-record/wio-terminal/smart-timer,其中:

  • main.cpp:串口、SFUD、按钮与主循环;
  • flash_writer.h:按 Flash 粒度分块的写入器;
  • mic.h:采集常量、Mic类、DMAC/ADC/TC5 配置与 WAV 封装;
  • platformio.ini:PlatformIO 环境与 SFUD 依赖。

麦克风与扬声器硬件接线(Wio Terminal 自带 MEMS 麦克风,可选配 ReSpeaker 扩展板)参见 wio-terminal-microphone.md。音频采集就绪后,下一步即可按课程路径继续学习如何把 Flash 中的 WAV 音频发送到语音服务转为文本(wio-terminal-speech-to-text.md),最终构建一个支持多语言的智能厨房定时器。

小结

本文完整还原了 Wio Terminal 上"按钮触发、4 秒 16KHz/16-bit 单声道录音并写入 Flash"的工程实现,核心要点可归纳为四条:

  1. RAM 不够、Flash 来凑:192KB RAM 无法容纳长音频,必须把 PCM 数据边采边写进 4MB 板载 Flash;
  2. DMAC 保证时序:用 TC5 定时器触发 DMAC 突发传输,以硬件节拍实现严格的 16KHz 采样率,CPU 几乎零参与;
  3. 双缓冲防丢数据:环形描述符链让 DMAC 在缓冲 1/缓冲 2 间轮换,处理一个缓冲的同时另一个缓冲继续采集;
  4. 按粒度分块写 FlashFlashWritererase_gran为块大小缓冲数据,满块擦写、尾块 flush,规避 Flash 粒度对齐约束,并以 44 字节 WAV 头 + PCM 数据完成标准封装。

这套"中断驱动 + 缓冲轮转 + 外部存储直写"的模式,是微控制器上处理流式传感数据的通用范式,掌握后可迁移到语音、振动、环境传感等各类高频采样场景。

【免费下载链接】IoT-For-Beginners12 Weeks, 24 Lessons, IoT for All!项目地址: https://gitcode.com/GitHub_Trending/io/IoT-For-Beginners

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询