一、项目介绍
本项目基于 Raspberry Pi RP2350B 微控制器,通过PWM(脉宽调制)技术控制蜂鸣器播放音乐,实现简易音乐播放器功能。用户可通过按键切换曲目,并支持文本形式存储音乐文件。项目旨在帮助初学者理解PWM原理、音频信号处理及嵌入式系统开发流程。支持按键控制播放状态(播放/停止、切歌)。音乐数据以结构化数组形式存储,通过定时器中断实现精准音符播放。
1.1 硬件介绍
- STEP_RP2350B核心板:由于RP2350的强大性能 - 双核Arm处理器 + 双核RISC-V处理器,以及可编程IO(PIO),该芯片不仅支持通用的外设总线(I2C、SPI以及UART)访问,还可以通过适当的配置让PIO访问高速外设,在很多场景下能够完成FPGA才能实现的功能。
- 小脚丫FPGA综合训练板:兼容小脚丫FPGA核心板的信号定义,可以使用大部分为小脚丫FPGA设计的各种扩展板,比如小脚丫FPGA综合训练板,其板载有:
- 128*32分辨率的OLED单色显示屏,通过SPI总线进行访问,可以显示文本和图形化信息,SPI是微处理器的重要外设之一,也是嵌入式编程必然用到的一种船型外设总线,要注意的是RP2350的SPI总线时钟SCLK和串行数据输出SPI_TX(也就是其它地方长用到的MOSI)对于管脚是有要求的,如果硬件设计中没有用到指定的信号线连接SPI的外设,可以通过RP2350的PIO进行重新定义,如果外设速度不快,可以通过软件仿真的方式来操作;
- 蜂鸣器,使用PWM驱动,是练习使用PWM的常用外设
- DS18B20温度传感器,是ADI公司非常经典的一颗单总线协议的外设器件,可以通过编程读取板上的温度
- 一个电位计 + 串行ADC,电位计用于提供变化的模拟信号,串行ADC将变化的模拟信号进行量化以后再通过I2C总线传输给RP2350B,这部分的功能主要是为了练习I2C总线以及低速的数据采集功能
- 由20个`电阻构成的10bit R-2R DAC,搭配FPGA的逻辑或RP2350的程序,通过DDS的机制生成频率可调、幅度可调的任意模拟信号波形
1.2 功能概览
- 通过PWM产生不同的音调,并驱动板上蜂鸣器将音调输出
- 能够播放三首不同的曲子,可以切换播放
- 曲子的切换使用核心板上的按键,需要有按键消抖的功能
- 播放的曲子的名字在OLED屏幕上显示出来(汉字显示)
1.3 系统框图
1.4 设计思路
根据 功能概览 设计使用以下功能:
- 按键输入(ADC)
- 蜂鸣器驱动(PWM)
- OLED屏驱动(SPI)
- LED闪烁(定时器)
- LED驱动(GPIO + Charlieplexing)
根据核心板布局,设计使用核心板上的4个按键和9个LED进行控制和显示当前播放曲目。使用扩展板上的OLED显示屏和蜂鸣器。核心板按键和LED功能定义如下图所示:
二、功能实现
2.1 软件流程图
流程图说明及关键流程分解:
2.1.1. setup初始化流程
串口初始化 → 延迟1秒 → 初始化板载LED/蜂鸣器/按键引脚 → 设置ADC分辨率为12位 关闭所有LED → 尝试初始化OLED显示屏 成功:继续执行 失败:打印错误信息 → 进入无限循环 清空OLED显示 → 显示项目标题 → 根据music_index点亮对应LED → 显示当前歌曲信息 → 打印"Init done."
2.1.2. Loop主循环流程
- 定时器模块(每秒执行一次): 执行LED闪烁
- 按键处理模块:扫描按键状态 → 记录当前按键值
- 新按键检测: 检查按键稳定时间(KEY_DEBOUNCE_DELAY)
- 确认有效按键后: → 更新当前按键 → 根据按键类型执行操作:
- 按键1:递减music_index(循环处理边界)→ 更新LED和显示
- 按键2:启动播放(需未处于播放状态)
- 按键3:停止播放(需处于播放状态)
- 按键4:递增music_index(循环处理边界)→ 更新LED和显示
- 音乐播放控制:每次循环调用play()维持非阻塞式播放
2.1.3. 关键状态维护机制
- 消抖处理:使用last_key_debounce_tick跟踪按键稳定时间
- 播放状态保护:通过isPlaying()防止重复启动/停止
- LED指示同步:music_index变化时自动更新显示和指示灯
2.1.4. 异常处理机制
- OLED初始化失败时进入安全模式(无限循环)
- 数组越界保护:music_index 的环形处理(0 ~ MUSIC_SIZE-1 范围)
2.2 实现过程
2.2.1 音符数据结构化:
(Ring Tone Text Transfer Language):RTTTL格式是标准的手机铃声格式,已经被许多手机所支持。很多外国的类似网站会以RTTTL来提供手机响铃。使用RTTTL的好处是以纯文字格式储存,传送及修改都很方便,但坏处是不能即时试听。使用 Arduino 开发驱动蜂鸣器播放 RTTTL 音乐有很多已经封装好的库可供使用。这里我使用的是 AnyRtttl v2.3.0 版本驱动蜂鸣器播放 RTTTL 音乐,关键代码如下:
const char * musics[MUSIC_SIZE] = { // 音乐列表
"碟中谍:d=16,o=6,b=95:32d,32d#,32d,32d#,32d,32d#,32d,32d#,32d,32d,32d#,32e,32f,32f#,32g,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,g,8p,g,8p,a#,p,c7,p,g,8p,g,8p,f,p,f#,p,a#,g,2d,32p,a#,g,2c#,32p,a#,g,2c,a#5,8c,2p,32p,a#5,g5,2f#,32p,a#5,g5,2f,32p,a#5,g5,2e,d#,8d"
};
pinMode(BUZZER_PIN, OUTPUT); // 蜂鸣器驱动引脚初始化
// 阻塞式播放
anyrtttl::blocking::play(BUZZER_PIN, musics[0]);
// 非阻塞式播放
anyrtttl::nonblocking::begin(BUZZER_PIN, musics[0]);
anyrtttl::nonblocking::play();
2.2.2 OLED驱动:
本次项目使用的是基于 SPI 接口的单色 OLED 屏幕,分辨率 128 * 32,驱动芯片 SSD1306。项目要求显示中文,这款 OLED 屏正好可以显示两行中文文本。 第一行固定展示项目标题:蜂鸣器音乐播放。 第二行显示当前播放音乐名称。
所需要显示的中文使用汉字取模软件进行取模,生成 C51 格式数据,将取模后的汉字数据保存至变量中,再使用 drawBitmap
进行绘制即可显示汉字。关键代码实现如下
// OLED SPI 引脚定义
#define OLED_MOSI 44
#define OLED_CLK 45
#define OLED_DC 42
#define OLED_CS 41
#define OLED_RESET 43
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT,
OLED_MOSI, OLED_CLK, OLED_DC, OLED_RESET, OLED_CS);
// 蜂(0) 鸣(1) 器(2) 音(3) 乐(4) 播(5) 放(6)
static const unsigned char title[][16] = {
{0x00,0x00,0x00,0x40,0x10,0x40,0x10,0xB0,0x11,0x20,0x1E,0xC0,0x74,0xF8,0x3F,0x4F},
{0x39,0xF0,0x1C,0x70,0x20,0xCC,0x47,0xF0,0x00,0x40,0x00,0x40,0x00,0x40,0x00,0x00},/*"蜂",0*/
{0x00,0x00,0x00,0x40,0x00,0x40,0x00,0xB0,0x01,0x50,0x3D,0x50,0x29,0x10,0x29,0x30},
{0x31,0x20,0x21,0xFC,0x00,0x04,0x03,0xF4,0x04,0x04,0x00,0x38,0x00,0x18,0x00,0x00},/*"鸣",1*/
{0x00,0x00,0x00,0x00,0x06,0xF8,0x1A,0x90,0x14,0xB0,0x0B,0x50,0x01,0x28,0x1F,0xD0},
{0x02,0x80,0x04,0x70,0x18,0x1C,0x06,0xF8,0x1B,0x90,0x12,0x90,0x0C,0x60,0x00,0x00},/*"器",2*/
{0x00,0x00,0x01,0x80,0x00,0x80,0x00,0xF0,0x07,0x60,0x02,0x40,0x02,0x84,0x07,0xFE},
{0x38,0x00,0x07,0xF0,0x04,0x20,0x03,0xA0,0x04,0x20,0x03,0xE0,0x00,0x20,0x00,0x00},/*"音",3*/
{0x00,0x00,0x00,0x00,0x00,0x60,0x01,0x80,0x06,0x00,0x08,0x80,0x08,0x90,0x0F,0xE0},
{0x08,0x80,0x00,0x80,0x08,0xB0,0x18,0x98,0x10,0x8C,0x03,0x80,0x01,0x00,0x00,0x00},/*"乐",4*/
{0x00,0x00,0x00,0x00,0x08,0x30,0x08,0x48,0x09,0xD0,0x0D,0x58,0x3B,0xE0,0x0C,0xD0},
{0x09,0x4C,0x3A,0x52,0xCB,0xE8,0x09,0x68,0x09,0xC8,0x19,0xF8,0x00,0x00,0x00,0x00},/*"播",5*/
{0x00,0x00,0x00,0x20,0x04,0x20,0x06,0x20,0x00,0x40,0x01,0x4C,0x7E,0x70,0x08,0x90},
{0x0E,0x10,0x12,0xA0,0x12,0x60,0x22,0x60,0x54,0x90,0x0C,0x0C,0x00,0x02,0x00,0x00},/*"放",6*/
};
/**
* oled 显示标题
*/
void displayTitle() {
// display.drawBitmap(64, 0, feng, 16, 16, WHITE);
for(int i = 0; i < sizeof(title) / 32; i ++) {
display.drawBitmap(i * 16, 0, (uint8_t *)(title[i * 2]), 16, 16, WHITE);
}
display.display();
}
2.2.3 按键处理与状态机:
根据原理图可知 4 个按键是通过 R-2R 网络连接到单片机 GPIO47 管脚。
每个按键按下时改变电阻分压比例,生成不同电压值。单片机读取相应管脚的电压值即可知道当前是哪个按键按下。按键触发后延时20ms再次检测,避免误触发。关键代码如下所示:
void loop() {
int key = scanKey(); // 按键扫描
if(key != lastKey) last_key_debounce_tick = millis(); // 记录按键开始时间,用于消抖计算
if((millis() - last_key_debounce_tick) > KEY_DEBOUNCE_DELAY) { // 按键消抖确认
if(key != currentKey) { // 按键确认
currentKey = key;
if(currentKey) { // 按键有效
Serial.print("Key ");
Serial.println(currentKey);
}
}
}
lastKey = key;
}
2.2.4 播放控制管理:
设计使用核心板上的 4 个按键进行播放控制,从左至右定义 4 个按键的功能为:上一首,播放,停止,下一首。上一首和下一首切换时会同步更新曲目顺序的 LED 显示和 OLED 歌曲名显示。上一首和下一首支持循环切换至最后一首和第一首功能。关键代码如下:
switch(currentKey) {
case 1: // 上一首
music_index--;
if(0 > music_index) { // 循环切换
music_index = MUSIC_SIZE - 1;
}
lightLED(music_index);
displaySong(music_index);
break;
case 2: // 开始播放
if(!anyrtttl::nonblocking::isPlaying()) {
anyrtttl::nonblocking::begin(BUZZER_PIN, musics[music_index]);
}
break;
case 3: // 停止播放
if(anyrtttl::nonblocking::isPlaying()) {
anyrtttl::nonblocking::stop();
}
break;
case 4: // 下一首
music_index++;
if(MUSIC_SIZE <= music_index) { // 循环切换
music_index = 0;
}
lightLED(music_index);
displaySong(music_index);
break;
default:
break;
}
2.2.5 播放索引管理:
核心板上有 8 颗 LED 灯,我用其作为播放索引的序号进行展示,方便直观地知道当前播放曲目的序号。例如:当播放第一首音乐时,左边的第一颗 LED 灯点亮。播放第八首音乐时,右边第一颗(从左至右数第八颗)LED 灯点亮。以此表示当前播放第几首音乐。
而这 8 颗 LED 灯使用 4 个 GPIO 引脚进行控制。常规的设计 4 个 GPIO 引脚只能驱动 4 个 LED 灯,而使用 查理复用算法(Charlieplexing) 则可以实现 N 个 GPIO 引脚可以驱动 N*(N-1)
个 LED 灯;如下图所示,8 个 LED 使用 4 个 GPIO 引脚进行驱动:
对于使用 2 个以上 GPIO 管脚驱动 LED 的就需要用到 GPIO 管脚的三种状态,即高、低、高阻。关键代码实现如下:
/**
* 初始化所有引脚为高阻态
*/
void resetLedPins() {
for (int i = 0; i < sizeof(led_pins) / sizeof(pin_size_t); i++) {
pinMode(led_pins[i], INPUT);
digitalWrite(led_pins[i], LOW);
}
}
/**
* 点亮指定编号的LED
*/
void lightLED(int ledNum) {
if (ledNum < 0 || ledNum >= LED_ARRAY_SIZE) return;
resetLedPins(); // 关闭所有LED
// 设置阳极引脚为输出高电平
pinMode(leds[ledNum].anode, OUTPUT);
digitalWrite(leds[ledNum].anode, HIGH);
// 设置阴极引脚为输出低电平
pinMode(leds[ledNum].cathode, OUTPUT);
digitalWrite(leds[ledNum].cathode, LOW);
}
三、功能展示
第一首曲目效果展示
第五首曲目效果展示
四、总结
遇到的问题
- LED 驱动时一开始使用简单的一个高电平搭配一个低电平进行驱动,结果导致有时候会点亮一个以上的LED,原因是高电平对应着不止一个LED的阳极。而其它引脚都为低电平的话会同时导通不止一个LED。解决方案是需要将其它管脚设置为高阻状态,需要导通的阳极管脚输出高电平,阴极管脚输出低电平即可。
- OLED 屏的驱动问题,由于一开始使用的是 PICO2 开发板,编写了OLED的驱动代码后烧录上去后 OLED 始终黑屏。这个问题困扰我很长时间,后经群友告知,PICO2 开发板使用的是 RP2350A 主控芯片,而我们使用的开发板使用的是 RP2350B 主控芯片,二者的管脚数量有差异,而驱动 OLED 的管脚正好是 RP2350A 主控芯片不具备的那几个管脚。了解了原因,解决方案就简单了,切换开发板为搭载了 RP2350B 主控芯片的开发板即可。这里我使用的开发板为
olimex_pico2xl
- RTTTL非阻塞式播放遇到旋律编较快的曲目停顿时间较短的话会导致优先抢占问题,导致用户按键无法正常处理。解决方案是使用RTOS进行任务优先级定义,这里我对 FreeRTOS 的移植不了解就没有继续进行研究。
心得体会
本次活动是我第一次接触 RP2350 主控芯片,并使用其进行项目开发。在些过程中遇到了很多的问题,也都一一解决。通过这次活动我对 RP2350 这款主控芯片有了进一步的认识,也通过这次活动更进一步掌握了 Arduino 开发相关的知识,以及 PlatformIO 开发 Arduino 程序的一些配置方面的问题。
最后感谢电子森林推出的这次活动,对于我来说是个很好的学习机会,理论结合实践。我们下期活动再见!
五、参考资料