mp3.audio
大家好,我是积分猫。这篇文章用于说明我前两天发的mp3中,音乐播放部分技术实现思路。如果大家比较感兴趣怎么自己也来做一个,或者感兴趣想通过这个项目学一些东西,我会将这个做成一个系列教程。希望大家能够喜欢,如果遇见不懂得随时提问,我看到就会解答。
代码已经在Github开源:maxwell-ghost/rpi-pico-mp3: 积分猫用树莓派pico做的mp3 mp3最终成品展示:bilibili
0 写在前面
0.1 项目参考及教程
#c 动机 本项目的初始动机
本项目的初始动机来源于我某次看见Github上的一个项目,是教你如何裸机进行单片机开发的,看到后感觉很不错,于是star之后准备后续自己也整一个。再之后就是开春开始着手准备,拖拖拉拉知道现在才搞得有点模样。大家可以去瞧瞧这个项目,是很好的一个教程,点这里。
#d 裸机开发
这里的裸机开发是指不借助芯片厂商提供的SDK(Software Development Kit),仅使用GNU的C编译器(GCC)和Make工具来进行开发,是的,你没有听错,我当时也很震惊。
#e 使用Keil开发 裸机开发
接触过51单片机的应该对这个开发工具很熟悉,我大学的时候课程设计用过这个开发环境。当时只需要定义一个IO(Input & Output)口变量,然后调用工具提供的接口(Interface)就可以进行上层开发,不需要关注具体的底层细节。
0.2 要求
#c 建议 前置知识需求
要看懂本项目最起码需要C语言过关,并且懂一些计算机底层知识和基本数字电路知识。同时需要会一点Linux操作系统使用(Make 和 GCC)。不过这些在有ChatGPT之后简单了许多,可以自己先看看这篇文章,如果喜欢,何愁不会。
1 项目组织结构
这部分是关于项目是如何被组织起来,以及各个文件及目录的作用。如果感觉吃力可以跳过,后续如果做系列教程,我单独写一篇文章来说明。
1.0 树莓派Pico
#d 正常启动模式
树莓派Pico有两种启动模式,按住树莓派的按键(下图BOOTSEL)上电(Power on,就是插电),会进入U盘模式,此时计算机上会出现Pico化身的U盘。如果不按按键上电,则会进入正常启动模式。此时编写好的程序会运行。
#d U盘启动模式
树莓派Pico的U盘模式用于烧录编译(Compile)好的固件(Firmware),将工程文件中./build/firmware.uf2
文件拷贝到Pico中,此时firmware.uf2
被拷贝到树莓派Pico的Flash(类比计算机的硬盘)上,Pico就会自动加载镜像并进入正常启动模式。
#t 计算机开机 正常启动模式
类比计算机,树莓派的正常启动类似于计算机的启动。而U盘模式类似于计算机安装系统的模式。
1.1 启动文件关系描述
- 本项目通过
startup.c
文件中的_reset
函数启动,_reset
函数调用main
函数 main
函数位于main.c
文件中,main.c
通过引入头文件来管理各种功能,也就是通常C语言那个main,但是也不完全等同。
#c 流程 树莓派Pico启动
首先,树莓派Pico进入_reset
函数,完成系列操作之后,调用main
函数来完成mp3的所有功能。
#d _reset函数功能
具体而言,当硬件初始化完成进入Flash Second Stage(也就是退出U盘模式,运行我们写的代码开始运行的时候),这个时候内置的rom(Read Only Memory,出厂烧录的程序,不可更改,类比计算机的BIOS)代码已经读了256Byte的代码到SRAM(Static Radom Access Memory,类比计算机的内存)中,_reset
函数首先初始化XIP(eXecute In Place)硬件(用以访问Flash中烧录的代码,因此仅初始化读的功能)。然后初始化内存,具体而言,就是将flash中的代码复制到SRAM中,从text段开始复制到data段,然后将bss段(未初始化的变量)全部置零。
#d 初始化Flash _reset函数功能
初始化XIP用以读flash。
XIP_SSI->SSIENR = 0;
XIP_SSI->BAUDR = 2;
XIP_SSI->CTRLR0 = (0U << 21) | (3U << 8) | ((32 - 1) << 16); // Read
XIP_SSI->CTRLR1 = (0 << 0);
XIP_SSI->SPI_CTRLR0 = (3U << 24) | (6U << 2) | (2U << 8) | (0 << 0);
XIP_SSI->SSIENR = BIT(0); // Enable
#d 初始化SRAM _reset函数功能
初始化内存,将flash中的代码和初始化的数据读到SRAM中。
// Initialise memory
extern long _sbss, _ebss, _edata, _stext, _sflash;
for (long *src = &_sflash, *dst = &_stext; dst < &_edata;)
*dst++ = *src++;
for (long *src = &_sbss; src < &_ebss;)
*src++ = 0;
#d 初始化中断服务 _reset函数功能
然后中断向量表(指向中断服务程序)加载到对应的寄存器中,注意这里SCB->VTOR前低八位无效,原因是ARM的要求中断向量表的地址和256对齐。这一点由链接脚本(./link.ld
)中,boot部分为256Byte且vector段紧随其后保证。
extern void (*vectors[])(void);
SCB->VTOR = (uint32_t)vectors; // Point SCB to where our IRQ table is
#c 说明 中断概念理解
理解上面的 初始化中断服务
需要理解中断的概念,以及C语言中函数指针的以及函数指针数组,以及计算机是如何调用函数,这些知识,如果这个成为一个系列,我单独写一篇文章来讲这里。
#c 说明 无意义的返回
然后调用main函数,此函数不要返回,因为没有操作系统,返回值没有意义。
extern void main(void);
main(); // Call main()
for (;;) (void)0; // Loop forever if main() returns
#d main函数的返回意义
如果你学过C语言,那么一定好奇过,main
函数的返回值为什么是0。原因在于,在操作系统中,执行一个程序,如果成功执行,那么会返回给操作系统一个0作为成功的标志,如果执行失败,则会返回其他值,不同的值编码着不同的错误结果。因此,程序如果执行到return 0;
,那么代表着,程序执行成功。
int main() {
return 0;
}
1.2 应用文件关系描述
#d 总体关系描述
main.c
和其他文件工程组成了链接时候的.text \\ 表示代码
.rodata \\ 表示只读的数据
.data \\ 初始化的数据
.bss \\未初始化的数据
段,通过链接器将这些部分和启动代码部分组装起来,具体见./link.ld
。
#c 方法 C语言多文件管理
C语言通过头文件引入各个函数的声明和结构的定义,此时关于其他文件的函数调用仅仅只有调用指令,没有具体实现,通过编译具体头文件对应的.c文件,此时函数有了实现,程序变得完整。具体到这个项目,main
函数依赖hal.h, syscall.c, oled.h, pinmap.h, rpi_time.h, key.h, audio.h
,其中hal.h和pinmap.h
被oled.h, rpi_time.h, key.h, audio.h
依赖。
#c 说明 文件
- 硬件相关:hal.h hal.c
- 管脚定义相关:pinmap.h
- 显示相关:oled.h
- 时间相关: rpi_time.h
- 声音相关: audio.h
2 Audio
关于演奏的基本原理,大家可以看我写的这篇博客, 用Matlab演奏《错位时空》。具体不再赘述。
#d 演奏基本原理
通过Pico上的PWM(Pulse Width Modulation)硬件产生不同的频率的方波,对应不同的音高的音符,以及通过控制延时来控制音符的时间长短,这样就可以完美演奏一条音轨的音乐。声音的大小可以通过控制PWM波形的占空比来调整。
#d 占空比
占空比用于表示方波的一个周期的 \(f(t)=1\) 和 \(f(t) = 0\) 所占时间的比值。
#e 占空比为1:2 占空比
下图中,$[\ 0, T\ ]$ 代表一个周期,IOVDD表示1(这里是3.3V)。
2.1 管脚分配与硬件相关
#d 蜂鸣器
这里我们使用无源蜂鸣器来进行演奏,由于声音是震动产生的,我们使用特定频率的方波就可以让无源蜂鸣器产生震动,从而产生声音。
#c 说明 管脚分配
所有的管脚均可以配置为PWM输出,这里使用GP06号管脚。
#d PWM初始化
首先要启用PWM硬件电路,通过调用enable_subsystem
函数(这里的subsystem包括各种外设等,具体见RP2040硬件手册2.14)完成,然后设置参数。CC(Counter Compared)设置PWM的占空比,这里设置为占空比为0:0xffff(0xffff=65535),相当于一直输出0(初始化后要先调节占空比)。TOP设置计数器最大值,这里设置为0xffff。DIV用于调整频率,CSR用于使能PWM。
static inline void pwm_init(struct pwm* pwm_slice) {
if (pwm_slice == PWMCH3) {
enable_subsystem(BIT(14));
} else {
printf("Not support now!\n");
}
pwm_slice->CSR = 0; // Disable pwm
pwm_slice->CTR = 0; // Direct to pwm counter
pwm_slice->CC = 0x00000000; // compared value
pwm_slice->TOP = 0x0000ffff; // Max conut
pwm_slice->DIV = (8UL << 4); // 8倍分频, 使用这个来控制频率
pwm_slice->CSR = (1UL << 0); // 启用PWM
}
#c 说明 PWMCH3
可以看到,这里pwm_slice
是一个结构体指针,指向对应的寄存器(Register)的地址,通过操作寄存器来操作PWM硬件电路。这里和PWMCH3(需要查看数据手册来确定值,我们要初始化通道三,这里是0x4005003c
)做比较,初始化PWM硬件。
struct pwm {
volatile uint32_t CSR;
volatile uint32_t DIV;
volatile uint32_t CTR;
volatile uint32_t CC;
volatile uint32_t TOP;
};
#define PWMCH3 ((struct pwm *) 0x4005003c)
#t 调节旋钮 寄存器
这里的寄存器可以类比成调节旋钮,通过调节这些旋钮,就可以完成对硬件电路的操作。
#d PWM频率设置
PWM频率计算公式如下,这里需要根据f_pwm
(对应需要输出的频率,也就是音符频率下)算出DIV_INT
和DIV_FRAC
从而控制设置频率,具体见下面。DIV_INT
对应DIV寄存器11:4位,DIV_FRAC
对应3:0。
f_pwm = f_sys / period
period = (TOP + 1)x(CSR_PH_CORRECT+1)x(DIV_INT + DIV_FRAC/16)
推出 -> (DIV_INT + DIV_FRAC/16) = f_sys / (65536 x f_pwm)
TOP 设置为0xffff=65535
CSR_PH_CORRECT 设置为0x0, 设置为1后将会从零到满再到零,0代表从零到满再从零到满
DIV_INT 和 DIV_FRAC 设置频率
f_sys = 133MHz
2.2 音符频率计算与使用
#d 频率转换
下面的频率对应对应从C0音的16.35Hz到B6音的1975.53Hz。每一行位12个音,代表12平均律,也就是C0和C1的频率差为两倍,C0到C1中间总共12个音,分别为
$$ f_{C0},\ f_{C0} \cdot 2^\frac{1}{12}, \ f_{C0} \cdot 2^\frac{2}{12} , \ ... \ f_{C0} \cdot 2^\frac{11}{12} $$note_freq = [ 16.35, 17.32, 18.35, 19.45, 20.60, 21.83, 23.12, 24.50, 25.96, 27.50, 29.14, 30.87, # C0 C#0 D0 D#0 E0 F0 F#0 G0 G#0 A0 A#0 B0
32.70, 34.65, 36.71, 38.89, 41.20, 43.65, 46.25, 49.00, 51.91, 55.00, 58.27, 61.74, # C1
65.41, 69.30, 73.42, 77.78, 82.41, 87.31, 92.50, 98.00, 103.83, 110.00, 116.54, 123.47, #C2
130.81, 138.59, 146.83, 155.56, 164.81, 174.61, 185.00, 196.00, 207.65, 220.00, 233.08, 246.94, #C3
261.63, 277.18, 293.66, 311.13, 329.63, 349.23, 369.99, 392.00, 415.30, 440.00, 466.16, 493.88, #C4
523.25, 554.37, 587.33, 622.25, 659.25, 698.46, 739.99, 783.99, 830.61, 880.00, 932.33, 987.77, #C5
1046.50, 1108.73, 1174.66, 1244.51, 1318.51, 1396.91, 1479.98, 1567.98, 1661.22, 1760.00, 1864.66, 1975.53 #C6
]
我们需要为这些频率找到【通过DIV_INT和DIV_FRAC调节所能达到最相近的设置】,这里使用Python来转换。
#d DIV_INT于DIV_FRAC计算
首先对2.1的频率计算公式进行变形,带入(f_sys=133MHz)和(f_pwm=上面的Python列表),就可以得到(DIV_INT + DIV_FRAC/16)的值,然后取整数部分就是DIV_INT,小数部分可以通过枚举所有 1/16~15/16的值,取和目标小数部分差的绝对值最小来得出DIV_FRAC的值。
# 得到最接近的小数部分
def get_near(frac_float):
error = [] #误差表
for i in range(16):
error.append(abs(frac_float - i/16))
min_index = 0
min = 520
# 找出最小的值,对应的索引值为DIV_FRAC
for i, e in enumerate(error):
if e < min:
min = e
min_index = i
return min_index
#-> (DIV_INT + DIV_FRAC/16) = f_sys / (65536 x f_pwm)
request_float = [133000000 / (65536*item) for item in note_freq]
# 计算整数和小数部分
int_part = []
frac_part = []
for item in request_float:
int_part.append(int(item))
frac_part.append(get_near(item - int(item)))
#d 让C语言更易读取
最终结果如下为两张表,最终需要转换为pwm_slice->DIV
寄存器的格式,整数对应11:4位,小数对应3:0位。寄存器为32位,其他位未使用设为0。这里的Python代码将整数左移4位到11:4,与没有左移小数部分相与来得到对应的值,然后转换为16进制,补零到4位,然后和"0x"字符串相加,以"0x0000"字符串列表形式存储。
int16_allnote_list = []
for i, f in zip(int_part, frac_part):
temp = i << 4 | f
int16_allnote_list.append("0x" + hex(temp)[2:].zfill(4))
如下,为了让C语言更容易读取。
0x07c2, 0x0753, 0x06ea, 0x0685, 0x0628, 0x05cf, 0x057c, 0x052d, 0x04e3, 0x049d, 0x045a, 0x041c,
0x03e1, 0x03a9, 0x0375, 0x0343, 0x0314, 0x02e8, 0x02be, 0x0297, 0x0272, 0x024e, 0x022d, 0x020e,
0x01f0, 0x01d5, 0x01ba, 0x01a1, 0x018a, 0x0174, 0x015f, 0x014b, 0x0139, 0x0127, 0x0117, 0x0107,
0x00f8, 0x00ea, 0x00dd, 0x00d1, 0x00c5, 0x00ba, 0x00af, 0x00a6, 0x009c, 0x0094, 0x008b, 0x0083,
0x007c, 0x0075, 0x006f, 0x0068, 0x0063, 0x005d, 0x0058, 0x0053, 0x004e, 0x004a, 0x0046, 0x0042,
0x003e, 0x003b, 0x0037, 0x0034, 0x0031, 0x002e, 0x002c, 0x0029, 0x0027, 0x0025, 0x0023, 0x0021,
0x001f, 0x001d, 0x001c, 0x001a, 0x0019, 0x0017, 0x0016, 0x0015, 0x0014, 0x0012, 0x0011, 0x0010,
#c 用法 使用频率值
通过用C语言创建uint16_t
类型数组,然后索引使用值赋值给DIV寄存器来使用。
2.3 更高层级的抽象
这部分是关于播放音乐的,总体思路就是读一个音符,调整PWM频率,延时对应时长,然后读下一个音符重复同样的操作。
2.3.1 音乐概念
#d 八度
音乐中的高一个八度相当于上述的数组的索引整体加12,低一个八度相反。也就是频率变为之前的两倍或者1/2。C0高八度就是C1音,C2低八度就是C1音。
#d 调式
简谱中的数字为8个,0,1,2,3,4,5,6,7,其中1到7对应七个音,0对应休止符,也就是什么都不干。这七个音通过从12平均律的12个当中选取7个,于是就形成了不同的调式(modus)。不同的调式的音乐听起来有不同的感觉。目前做了下面这些调式。
enum e_modus {
F_MAJOR = 0,
F_SHARP_MAJOR,
A_MAJOR,
B_FLAT_MAJOR,
};
#d 起的调子高低
我也不知道该如何正式称呼这个。代表一首歌曲中数字1对应的音的频率是多高。平时唱歌时如果调子起高了,遇到音调更高的音我们就唱不上去。因为蜂鸣器在低音(低频率)效果不好,因此需要控制这个尽量高来保证比较好的结果。
#d 连音与乐谱
指音符之间过度平滑,没有停顿。给人读的音乐脚本。我们这里使用简谱。
2.3.2 程序对应
如果看这块吃力,建议去看我之前的一篇博客, 用Matlab演奏《错位时空》。
#e 乐谱 连音与乐谱
以下是faded的简谱对应,虽然很抽象。由我来说明一下,一行代表一小节,简谱的一行用三行空格隔开。里面每三个int8_t对应一个音符,或者一个控制字符,音符的第一个int8_t为 0-7,控制字符大于7。第二个int8_t对应的是高几个八度,第三个表示时间长短。
int8_t music_script_faded[] = {
1,ndot,nbar, 1,ndot,nbar, 1,ndot,nbar, 3,ndot,nbar, sep,0,0,
6,ndot,nbar, 6,ndot,nbar, 6,ndot,nbar, 5,ndot,nbar, sep,0,0,
3,ndot,nbar, 3,ndot,nbar, 3,ndot,nbar, 3,ndot,nbar, sep,0,0,
7,ddot,nbar, 7,ddot,nbar, 7,ddot,nbar, 6,ddot,nbar, sep,0,0,
0,ndot,bar, 1,ndot,bar, 1,ndot,bar, 6,ddot,bar,
1,ndot,bar, conn,0,0, 6,ddot,bar, 1,ndot,bar, 2,ndot,bar, sep,0,0, // You were
3,ndot,nbar, 1,ndot,bar, 1,ndot,bar, 5,ddot,bar, 3,ndot,nbar, 3,ndot,bar, sep,0,0,
0,ndot,nbar, 0,ndot,nbar, 0,ndot,bar, 1,ndot,bar, conn,0,0, 1,ndot,nbar, sep,0,0,
};
可以看到控制音符如下,用来实现连音和变调功能。
enum note_ctrl {
sep = 8, // 每小节的分隔符
con = 9, // 用来代替每小节的分隔符,如果出现连音
conn = 10, // 用于小节内的连音,connect note
modus_change = 11, // 用于改变调, 紧接着的用于表示新的调子
};
#c 说明 简谱对应
这个和简谱对应,ddot对应down dot(音符下面的点),是低一个八度;udot对应up dot(音符上面的点),是高一个八度;ndot表示no dot,表示不高不低。 nbar对应下面没有横线,bar表示一个横线,音符持续时间变为1/2,也就是原来的4分音符变成8分音符;注意bar2是变为1/4而不是1/3。
// 表示八度,点
enum eig {
ddot2 = -2,
ddot = -1,
ndot = 0,
udot = 1,
udot2 = 2
};
// 表示持续时间,横线
enum dur {
nbar = 1,
bar = 2,
bar2 = 4,
bar3 = 8
};
#e 调式
通过二维数组,每个元素由7个音,通过调号索引包含7个音的元素,再通过 7个音索引 索引base_freq_indexbase_freq_index为0
起点(决定起的调子的高低),最终完成不同歌使用不同的调式。
enum e_modus {
F_MAJOR = 0,
F_SHARP_MAJOR,
A_MAJOR,
B_FLAT_MAJOR,
};
// -12 -11 -10 -9 -8 -7 -6 -5 -4 -3 -2 -1 0 1 2 3 4 5 6 7 8 9 10 11
// C C# D D# E F F# G G# A A# B |||| C C# D D# E F F# G G# A A# B
uint16_t all_notes[] = {
0x07c2, 0x0753, 0x06ea, 0x0685, 0x0628, 0x05cf, 0x057c, 0x052d, 0x04e3, 0x049d, 0x045a, 0x041c, //C0
0x03e1, 0x03a9, 0x0375, 0x0343, 0x0314, 0x02e8, 0x02be, 0x0297, 0x0272, 0x024e, 0x022d, 0x020e, //C1
0x01f0, 0x01d5, 0x01ba, 0x01a1, 0x018a, 0x0174, 0x015f, 0x014b, 0x0139, 0x0127, 0x0117, 0x0107, //C2
0x00f8, 0x00ea, 0x00dd, 0x00d1, 0x00c5, 0x00ba, 0x00af, 0x00a6, 0x009c, 0x0094, 0x008b, 0x0083, //C3
0x007c, 0x0075, 0x006f, 0x0068, 0x0063, 0x005d, 0x0058, 0x0053, 0x004e, 0x004a, 0x0046, 0x0042, //C4
0x003e, 0x003b, 0x0037, 0x0034, 0x0031, 0x002e, 0x002c, 0x0029, 0x0027, 0x0025, 0x0023, 0x0021, //C5
0x001f, 0x001d, 0x001c, 0x001a, 0x0019, 0x0017, 0x0016, 0x0015, 0x0014, 0x0012, 0x0011, 0x0010, //C6
};
int modus[][7] = {
{ -7, -5, -3, -2, 0, 2, 4 },
{ -6, -4, -2, -1, 1, 3, 5 },
{ -3, -1, 1, 2, 4, 6, 8 },
{ -2, 0, 2, 3, 5, 7, 9 }
};
#e 其他概念
例如下面的乐谱,可以看到由一个小节间的连音和2个音符连音,以及正常的音符和休止符。
6,ndot,nbar, 6,ndot,nbar, 5,ndot,bar, 2,ndot,bar, con,0,0,
2,ndot,bar, 1,ndot,bar, conn,0,0, 1,ndot,nbar, conn,0,0, 1,ndot,nbar, sep,0,0,
0,ndot,nbar, 0,ndot,nbar, 0,ndot,nbar, sep,0,0,
下面为演奏音符函数的实现,可以看到休止符是通过禁用PWM一定的时长,来完成;正常音符通过设置PWM频率(pwm_dset_freq
函数)完成(all_notes
为上2.2 读取Python程序生成的16进制数 的数组)。而连音通过检测下一个音符是否是连音控制音符,从而正常演奏间隔20ms(NOTE_SEP_TIME)还是禁用PWM 20ms来实现演奏间隔。起的调子高低通过 base_freq_index
来设置音符的起始索引位置来实现。八度通过加数组索引 + 八度数*12
来实现。
/*
i: 音符索引
*/
void play_note(uint32_t i, int one_beat_ms, int8_t* music, uint8_t base_freq_index, enum e_modus modus_type) {
switch (music[i*3]) {
// 音符间连接
case conn: return; break;
// 小节连接
case con: return; break;
case sep: break;
case 0: // 休止符
pwm_disable(PWMCH3);
wait_ms((uint32_t)one_beat_ms - NOTE_SEP_TIME);
break;
default: // 1 2 3 4 5 6 7
pwm_dset_freq(PWMCH3, all_notes\
[ base_freq_index + (modus[modus_type][music[i*3] - 1]) + 12*music[i*3+1] ]);
wait_ms((uint32_t) ( (one_beat_ms / music[i*3+2]) - NOTE_SEP_TIME ) ); //减去间隔
break;
}
// 这里用来延时演奏间隔 如果遇见点或者横线
if (music[(i+1)*3] == con || music[(i+1)*3] == conn) {
wait_ms(NOTE_SEP_TIME);
} else {
// 正常音符的演奏间隔
pwm_disable(PWMCH3);
wait_ms(NOTE_SEP_TIME);
pwm_enable(PWMCH3);
}
}
2.4 音乐的播放和界面返回
#d 音乐播放与返回
通过一个for循环,循环音符数量次,每次循环都播放一个音符,来完成音乐播放,代码如下。通过每次循环捕捉按键按下,如果按下则结束循环,进入返回流程代码。
pwm_enable(PWMCH3);
for (uint32_t i = 0; i < note_num; i++) {
// 返回
if (key.pressed && (key_process(&key) == 0)) {
if (key.key_pin == KEY_R || key.key_pin == KEY_Y) {
key_reset(&key);
key_enable(true);
break;
}
key_reset(&key);
key_enable(true);
}
// 变调
if (music[i*3] == modus_change) {
modus_type = music[(i*3)+1];
continue;
}
play_note(i, one_beat_ms, music, base_freq_index, modus_type);
}
pwm_disable(PWMCH3);
管脚定义与分配
#c 定义 树莓派Pico管脚
![[rpi_pin.png]]
#c 定义 mp3管脚定义
// 第一列表示序号,无物理意义
// 第二行表示代码中使用的管脚号
|01 (GP00) -> OLED_RX 接受数据的管脚(用于SPI0)
|02 (GP01) -> OLED_CSn 片选(SPI0)
|03 (GND )
|04 (GP02) -> OLED_SCK (SPI0)
|05 (GP03) -> OLED_TX 发送数据 (SPI0)
|06 (GP04) -> OLED_DC 数据:命令(用于普通GPIO口)
|07 (GP05) -> OLED_RES reset(GPIO)
|08 (GND )
|09 (GP06) -> PWM 播放声音 // 连接无源蜂鸣器,蜂鸣器另一端接地
|10 (GP07)
|11 (GP08)
|12 (GP09)
|13 (GND )
|14 (GP10)
|15 (GP11)
|16 (GP12)
|17 (GP13)
|18 (GND )
|19 (GP14)
|20 (GP15) -> LED 闪烁(GPIO)
// 下面逆时针旋转和上图对应
|21 (GP16) -> UART_TX (UART0)
|22 (GP17) -> UART_RX (UART0)
|23 (GND )
|24 (GP18)
|25 (GP19)
|26 (GP20)
|27 (GP21)
|28 (GND )
|29 (GP22)
|30 (RUN )
|31 (GP26)
|32 (GP27)
|33 (GND )
|34 (GP28)
|35
|36
|37
|38 (GND )
|39
|40