活动开发板介绍
本次活动使用的开发板是MAX32690EVKIT开发板,其外设功能丰富,除了常规引出IO引脚之外,还搭配了TFT屏幕、耳机音频接口、蓝牙铰链天线、CAN接口等,其外形如下

任务分析
本次我选择的任务为
任务3:音频频谱分析与显示,其要求如下
使用 MAX32690EVKIT 板载麦克风输入接口接收音频信号。音频信号通过 MAX9867 编解码芯片采集,主控通过 I2C 接口配置芯片并通过数字音频接口获取数据,使用 FFT 等数字信号处理技术分析音频频谱,并在彩色屏幕上实时显示波形和频谱图。
功能要求:
- 音频采集
- 通过 I2C 接口初始化并配置 MAX9867 编解码芯片
- 设置合适的采样率(建议 8kHz - 16kHz)
- 通过数字音频接口(I2S/PCM)获取音频数据
- 使用 DMA 或中断方式连续采集音频数据
- 信号处理
- 实现 FFT(快速傅里叶变换)算法
- 计算音频信号的频谱分布
- 提取主要频率成分和幅度信息
- 屏幕显示
- 实时显示时域波形图
- 实时显示频域频谱图(柱状图或曲线)
- 刷新率至少 10fps
针对以上任务要求,设计系统,从LINE_IN音频输入采集信号,经MAX9867 CODEC数字化后通过I2S+DMA送入MCU,使用CMSIS-DSP库做FFT运算,最终在ST7735 TFT屏幕上同时绘制时域波形和频域频谱,其系统流程如下

其中MAX9867 CODEC作为I2S主机,用于生成BCLK/LRCLK,决定了采样率;MAX32690 MCU作为I2S从机,用于被动接收SCK/WS数据
关键参数
针对该采样-分析系统,设计相关采样参数如下
参数 | 取值 | 说明 |
主时钟 MCLK | 12.288 MHz | 提供给 CODEC |
采样率 fs | 16 kHz | 由 MAX9867 的 NI 分频比设定 |
FFT 点数 | 256 | 复数 FFT,得到 128 个可用频点 |
频率分辨率 | 62.5 Hz | fs / FFT_SIZE = 16000 / 256 |
频谱范围 | 0 – 8 kHz | 到奈奎斯特频率 |
采样位深 | 24-bit | 装在 32-bit 字中,MSB 对齐 |
其中采样率与NI分频比关系如下
当NI=0x2000,MCLK=12.288MHz时,fs=16kHz
软件工作流程
系统整体流程
设计系统整体运行流程如下

接下来是各个模块的介绍
CODEC初始化
MAX9867通过I2C配置,设计I2C写辅助函数完成CODEC的初始化
static int codec_reg_write(uint8_t reg, uint8_t val)
{
uint8_t buf[2] = { reg, val }; // I2C 写:先寄存器地址,后数据
mxc_i2c_req_t req;
req.i2c = CODEC_I2C; // MXC_I2C2
req.addr = CODEC_I2C_ADDR; // 0x18,MAX9867 的 7-bit 从地址
req.tx_buf = buf;
req.tx_len = sizeof(buf); // 2 字节
req.rx_buf = NULL; // 纯写,无读
req.rx_len = 0;
req.restart = 0;
req.callback = NULL; // 阻塞式(同步)传输
return MXC_I2C_MasterTransaction(&req);
}
MAX9867遵循标准I2C寄存器读写协议。一次寄存器写的总线时序是START→从地址(0x18)+W→寄存器地址→数据→STOP。因此buf[0]放寄存器地址、buf[1]放数据,一次2字节事务即可。callback=NULL表示使用阻塞模式,即函数返回时传输已完成。
但初始化过程存在一个问题:
寄存器0x08(DAI format)写入0x98会使能I2S主机模式。一旦CODEC开始产生BCLK/LRCLK,I2C总线就会失去响应,后续任何I2C写操作都会在MXC_I2C_MasterTransaction里死等。为了解决这个问题,可以使用以下方案:
- 把 0x08 放到最后一个写。先配置好时钟、ADC、LINE_IN、上电等所有寄存器,最后才写0x08启动I2S,之后不再有任何I2C操作。
- 每次I2C写之间加1ms延时。MAX9867需要时间处理每笔事务,连续背靠背写入可能导致I2C控制器阻塞。
寄存器写入顺序如下

完整CODEC初始化函数如下
static void codec_init(void)
{
/*
* Register 0x08 (DAI format) with value 0x98 enables I2S master mode.
* Once written, the CODEC starts generating BCLK/LRCLK and the I2C
* interface becomes unresponsive — any subsequent I2C write blocks
* indefinitely inside MXC_I2C_MasterTransaction (no timeout in the
* driver's while-loops).
*
* Solution: write 0x08 as the VERY LAST I2C transaction. All other
* registers (clock, ADC, line-in, power) are configured first, then
* 0x08 is written to start I2S. No I2C writes are needed after that.
*
* A small delay is added between writes — the MAX9867 needs time to
* process each I2C transaction. Without delays, back-to-back writes
* can cause the I2C controller to block.
*/
/* 1. Shutdown for configuration */
printf("[codec] Shutdown...\n");
fflush(stdout);
codec_reg_write(0x17, 0x00);
MXC_Delay(MXC_DELAY_MSEC(1));
/* 2. Clear all regs 0x04..0x16 to POR state */
for (int i = 0x04; i < 0x17; i++) {
codec_reg_write(i, 0x00);
MXC_Delay(MXC_DELAY_MSEC(1));
}
printf("[codec] Regs cleared\n");
fflush(stdout);
/* 3. MCLK prescaler: 12.288 MHz < 20 MHz → psclk=1 → 0x10 */
codec_reg_write(0x05, 0x10);
MXC_Delay(MXC_DELAY_MSEC(1));
/* 4. Sample rate NI=0x2000 → 16 kHz. reg 0x06=high, 0x07=low. */
codec_reg_write(0x06, 0x30 | ((CODEC_NI >> 8) & 0x0F));
MXC_Delay(MXC_DELAY_MSEC(1));
codec_reg_write(0x07, CODEC_NI & 0xFF);
MXC_Delay(MXC_DELAY_MSEC(1));
/* 5. DAI clock (controller mode): reg 0x09 = 0x02 */
codec_reg_write(0x09, 0x02);
MXC_Delay(MXC_DELAY_MSEC(1));
/* 6. ADC input: left=LINE_IN(2), right=NONE(0) → (2<<6)|(0<<4) = 0x80 */
codec_reg_write(0x14, 0x80);
MXC_Delay(MXC_DELAY_MSEC(1));
/* 7. ADC level: 0 dB → avl=3, avr=3 → (3<<4)|3 = 0x33 */
codec_reg_write(0x0D, 0x33);
MXC_Delay(MXC_DELAY_MSEC(1));
/* 8. Line-in: unmuted + 0dB gain=24dB→(24-0)/2=12 → 0x0C */
codec_reg_write(0x0E, 0x0C);
MXC_Delay(MXC_DELAY_MSEC(1));
codec_reg_write(0x0F, 0x0C);
MXC_Delay(MXC_DELAY_MSEC(1));
/* 9. Power up: SHDN=1, EN_LEFT_ADC=0x02, EN_LEFT_LINEIN=0x20 → 0xA2 */
codec_reg_write(0x17, 0xA2);
MXC_Delay(MXC_DELAY_MSEC(2));
/* 10. DAI format LAST — enables I2S master, I2C becomes unresponsive */
printf("[codec] Writing DAI format (0x08=0x98)...\n");
fflush(stdout);
codec_reg_write(0x08, 0x98);
printf("[codec] CODEC init complete\n");
fflush(stdout);
}
此外还有个问题需要注意:MAX32690外设驱动默认使能了"errata#16"的重复引脚集。I2C2的重复引脚集是P0.13/P0.14,EVKit上没有上拉(且P0.14被TFT占用),会导致MXC_I2C_Init()里的MXC_I2C_Recover()失败。在project.mk中定义-DMAX32690GTK_PACKAGE_TQFN让驱动只配置主引脚集即可解决。
PROJ_CFLAGS += -DMAX32690GTK_PACKAGE_TQFN
I2S+DMA采集
本项目中CODEC是主机,MCU是从机,设计I2S初始化代码如下
static void i2s_init(void)
{
mxc_i2s_req_t req;
/* CODEC 驱动 SCK/WS,所以 I2S 外设是从机;
* 音频以 24-bit 采样装在 32-bit、MSB 对齐的字里传输 */
req.wordSize = MXC_I2S_WSIZE_WORD; // 32-bit 字
req.sampleSize = MXC_I2S_SAMPLESIZE_TWENTYFOUR; // 24-bit 有效采样
req.bitsWord = 24;
req.adjust = MXC_I2S_ADJUST_LEFT; // 左(MSB)对齐
req.justify = MXC_I2S_MSB_JUSTIFY;
req.wsPolarity = MXC_I2S_POL_NORMAL;
req.channelMode = MXC_I2S_EXTERNAL_SCK_EXTERNAL_WS; // 外部时钟 = 从机
req.stereoMode = MXC_I2S_STEREO; // 立体声
req.bitOrder = MXC_I2S_MSB_FIRST;
req.clkdiv = 0; // 从机不分频
req.rawData = NULL;
req.txData = NULL; // 只收不发
req.rxData = i2s_rx_buffer;
req.length = I2S_RX_WORDS;
if (MXC_I2S_Init(&req) != E_NO_ERROR)
fatal_blink("Error initializing I2S");
/* 4 个共享 DMA 中断向量全部路由到驱动 Handler */
MXC_NVIC_SetVector(DMA0_IRQn, DMA0_IRQHandler);
/* ... DMA1/2/3 同理 ... */
NVIC_EnableIRQ(DMA0_IRQn); /* ... DMA1/2/3 同理 ... */
MXC_I2S_RegisterDMACallback(i2s_dma_callback);
}
关键配置字段如下
字段 | 取值 | 含义 |
channelMode | EXTERNAL_SCK_EXTERNAL_WS | 时钟来自外部(CODEC),即MCU为从机 |
sampleSize | TWENTYFOUR | ADC输出 24-bit 精度 |
wordSize | WORD (32-bit) | 24-bit采样打包进32-bit字 |
justify | MSB_JUSTIFY | 采样左对齐,有效数据在高位 bit[31:8] |
stereoMode | STEREO | 左右声道交替,缓冲区偶数字=左,奇数字=右 |
DMA中断处理:四个DMA通道中断都指向同一个驱动分发函数,MSDK内部会找到实际触发的通道并调用注册的回调:
void DMA0_IRQHandler(void) { MXC_DMA_Handler(); } // DMA1/2/3 相同
static void i2s_dma_callback(int ch, int err) {
dma_done = 1; // 只置标志,实际处理放到主循环
}
采集一帧——阻塞式设计:
static void capture_block(void)
{
dma_done = 0;
int ch = MXC_I2S_RXDMAConfig(i2s_rx_buffer,
I2S_RX_WORDS * sizeof(i2s_rx_buffer[0]));
if (ch < 0)
fatal_blink("Error configuring I2S RX DMA");
while (!dma_done) {} // 阻塞等待 DMA 采满
MXC_DMA_ReleaseChannel(ch); // 释放通道,下一帧重新申请
}
MXC_I2S_RXDMAConfig内部完成了DMA初始化、申请通道、配置源(I2SRXFIFO)/目的(内存缓冲)/长度,并使能RX。DMA把每个到达的I2S字自动搬进i2s_rx_buffer,采满一整帧后触发中断置位dma_done,主循环退出忙等。DMA采集时序如下

每帧采集FFT_SIZE个立体声帧,每帧2个32-bit字(左、右),共FFT_SIZE*2个字。4个DMA中断向量统一路由到MXC_DMA_Handler。
信号处理(DSP)
信号处理流程为:提取左声道→归一化→加Hann窗→CFFT→求幅度
FFT(快速傅里叶变换)的作用为将时域信号x[n]变换到频域X[k]
对N=256点实信号做复数FFT,输出256个复数频点,但由于实信号频谱共轭对称,只有前128个(k=0-127)是独立有效的,正好1:1映射到屏幕的128列。每个频点k对应的物理频率为。
DSP初始化如下,需要预计算窗函数与FFT实例
static void dsp_init(void)
{
/* 预计算 Hann 窗以抑制频谱泄漏 */
for (int i = 0; i < FFT_SIZE; i++)
hann_window[i] = 0.5f * (1.0f - arm_cos_f32((2.0f * PI * i) / (FFT_SIZE - 1)));
arm_cfft_init_f32(&cfft_inst, FFT_SIZE); // 初始化 256 点 CFFT 实例
}
设计逐帧处理流程如下
static void process_block(float32_t *samples_out)
{
for (int i = 0; i < FFT_SIZE; i++) {
/* 24-bit 有符号采样位于 bit[31:8],算术右移保留符号 */
int32_t s = ((int32_t)i2s_rx_buffer[i * 2]) >> 8;
float32_t f = (float32_t)s / 8388608.0f; // 归一化到 [-1, 1),除以 2^23
samples_out[i] = f; // 保留原始样本给波形显示
fft_buffer[2 * i] = f * hann_window[i];// 复数实部 = 加窗后样本
fft_buffer[2 * i + 1] = 0.0f; // 复数虚部 = 0(实输入)
}
arm_cfft_f32(&cfft_inst, fft_buffer, 0, 1); // 就地复数 FFT(0=正变换,1=位反转输出)
arm_cmplx_mag_f32(fft_buffer, fft_mag, FFT_SIZE); // 每个频点求模 sqrt(re²+im²)
}
其中一些细节设计原因如下
取偶数下表i2s_rx_buffer[i*2]:立体声数据在缓冲区里左右交替存放:[L0,R0,L1,R1,...]。偶数下标[0,2,4...]就是左声道,本项目只用左声道。
Hann窗设计:FFT假设信号是周期性的,若一帧的首尾不连续会产生频谱泄漏(能量扩散到相邻频点)。Hann窗让帧两端平滑衰减到0,显著减少泄漏,其公式为
arm_cmplx_mag_f32:该函数实现对每个复数频点求模,得到幅度谱
TFT屏幕显示
设计静态UI画面,绘制画面的基本框架
static void draw_static_ui(void)
{
MXC_TFT_SetBackGroundColor(BLACK);
MXC_TFT_SetForeGroundColor(WHITE);
text_t label;
label.data = "Waveform"; label.len = 8;
MXC_TFT_PrintFont(2, 5, (int)&Liberation_Sans12x12[0], &label, NULL);
label.data = "Spectrum"; label.len = 8;
MXC_TFT_PrintFont(2, SPEC_TOP - 13, (int)&Liberation_Sans12x12[0], &label, NULL);
MXC_TFT_Line(0, WAVE_MID, SCREEN_W - 1, WAVE_MID, DARK_GREY); // 波形中线
MXC_TFT_Line(0, SPEC_BOT, SCREEN_W - 1, SPEC_BOT, DARK_GREY); // 频谱基线
/* 初始化"上一帧"状态:波形停在中线,频谱高度为 0 */
for (int x = 0; x < SCREEN_W; x++) {
prev_wave_y[x] = WAVE_MID;
prev_spec_h[x] = 0;
}
}
波形绘制(上半屏)
波形绘制的流程为逐列比较,每列只有一个波形点,若新点位置和上一帧相同就跳过,避免无谓的SPI写;若变了,先把旧点擦掉(恢复成背景色或中线色),再画新点。其流程如下

代码设计如下
static void draw_waveform(const float32_t *samples)
{
for (int x = 0; x < SCREEN_W; x++) {
float32_t f = samples[x * 2] * WAVE_GAIN; // 256 样本→128 列,隔点取样
int y = WAVE_MID - (int)(f * (WAVE_H / 2)); // 幅度映射为纵向偏移
if (y < WAVE_TOP) y = WAVE_TOP; // 限幅到波形区
else if (y > WAVE_BOT) y = WAVE_BOT;
if ((uint8_t)y == prev_wave_y[x]) continue; // 没变化→跳过,不写屏
int py = prev_wave_y[x]; // 擦除旧点:
MXC_TFT_WritePixel(x, py, 1, 1, // 若旧点在中线上则恢复中线色
(py == WAVE_MID) ? DARK_GREY : BLACK);
MXC_TFT_WritePixel(x, y, 1, 1, GREEN); // 画新点
prev_wave_y[x] = (uint8_t)y; // 更新状态
}
}
频谱绘制(下半屏)
代码设计如下
static void draw_spectrum(void)
{
for (int x = 0; x < NUM_BINS; x++) {
float32_t mag = (x == 0) ? 0.0f : fft_mag[x]; // 跳过 DC 分量(bin 0)
int h = (int)(mag * SPEC_GAIN); // 幅度→柱高(像素)
if ((float)h < SPEC_FLOOR) h = 0; // 噪声门限,太矮当作 0
else if (h > SPEC_H) h = SPEC_H; // 限幅到频谱区高度
int ph = prev_spec_h[x];
if (h == ph) continue; // 高度没变→跳过
if (h > ph) // 柱子变高:只在顶部补画新增的段
MXC_TFT_WritePixel(x, SPEC_BOT - h + 1, 1, h - ph, YELLOW);
else // 柱子变矮:擦除减少的段
MXC_TFT_WritePixel(x, SPEC_BOT - ph + 1, 1, ph - h, BLACK);
prev_spec_h[x] = (uint8_t)h;
}
}
其中MXC_TFT_WritePixel(x,y,w,h,color)实际是"填充矩形"——这里用w=1、h=段高画一条竖线段。柱子变高时只补顶部新增的那几个像素(h-ph行黄色),变矮时只擦掉减少的那几个像素(ph-h行黑色),柱子中间不变的部分完全不动,从而提高刷新效率。
main函数启动序列
int main(void)
{
MXC_Delay(MXC_DELAY_SEC(2)); // 等待调试器附着 / 电源稳定
MXC_ICC_Enable(MXC_ICC0); // 使能指令缓存,加速代码执行
MXC_SYS_Clock_Select(MXC_SYS_CLOCK_IPO);
SystemCoreClockUpdate(); // 100 MHz 主频
i2c_init(); // 1. 先起 I2C(含总线恢复容错)
codec_init(); // 2. 配置 CODEC(DAI 格式最后写!)
i2s_init(); // 3. 起 I2S 从机 + DMA 中断
dsp_init(); // 4. 音频通路就绪后再准备 DSP
MXC_TFT_Init(); // 5. 最后起显示
draw_static_ui();
while (1) { // 主循环:采集→处理→显示
capture_block();
process_block(samples);
draw_waveform(samples);
draw_spectrum();
LED_Toggle(1); // 心跳,指示循环在运行
}
}
构建与运行
project.mk构建配置
MFLOAT_ABI = hard # 硬件浮点,CMSIS-DSP 必需
LIB_CMSIS_DSP = 1 # 引入 CMSIS-DSP(arm_cfft_f32 等)
PROJ_CFLAGS += -DMAX32690GTK_PACKAGE_TQFN # 修复 I2C2 引脚问题
FONTS = FONTS = LiberationSans12x12 LiberationSans24x24 LiberationSans28x28 LiberationSans16x16 # 引入字体
硬件连接
电脑的音频输出接口(台式机的绿色接口或笔记本耳机口)接入LINE_IN(J6)

P1_7和P1_8引脚的JP9和JP10跳线帽由原本的UART切换到I2C


实现效果
实现效果如下图所示

对于噪声门限、时域波形增益、频域波形增益,可以修改代码中的宏定义参数实现
#define WAVE_GAIN 50.0f ///< Vertical gain applied to the normalized waveform
#define SPEC_GAIN 300.0f ///< Linear gain applied to FFT magnitudes
#define SPEC_FLOOR 0.2f ///< Bars below this height (px) are treated as empty
活动总结
感谢电子森林和得捷电子举办的这次互动,MAX32690EVKIT开发板有着无比丰富的外设资源,ADI的库函数调用方式像将于别的MCU是另一种开发方式。在这个过程中,我对ADI的芯片的开发、CODEC的配置以及DMA对数据的传输有了更深的理解。同时不少踩坑是在AI工具的帮助下解决的,AI让我对新MCU有了更快速的认识与理解。