前言 Foreword
早在计算机技术不甚发达的时候,想让电脑播放音频或者音乐是件很困难的事情。因为当时的数字采样技术技术并不成熟。如果追究音质,采样后的文件体积将变得十分庞大。这对于那个「寸金难买寸内存」的年代来说,直接在软件里使用简直是不可能的事情。最开始可能只有蜂鸣器发出的单调的滴滴答答。直到后来,才出现了只保存乐谱,剩下发出声音的工作交给电路的 Tracker Music 和 MIDI。这也就是电子音乐以及各种效果器、合成器等的起点。
AY-3-8910 是一款由通用仪器 (General Instrument, GI) 公司生产的 8-bit 可编程声音发生器 (Programmable Sound Generator, PSG),内置三个通道,也就是说可以同时播放三种声音,同时还有一个噪声发生器以及一个包络发生器。在上个世纪 80 年代,广泛用于各种街机,例如 KONAMI 的 Gyruss;以及家用电脑,如 ZX Spectrum 中。[1]
这个项目是以 InternalRegister 的项目为基础改编而成。AY-3-8910 的手册请参见此链接。
硬件准备 Hardware Preparation
AY-3-8910 的元件引脚及功能如下表:
| 引脚Pins | 符号Symbol | 输入输出IO | 电平Level | 描述Description |
|---|---|---|---|---|
1 |
VSS |
- | 0V | 接地 |
2 |
NC |
- | - | 悬空 |
3 |
ANALOG CHANNEL B |
O | - | 模拟信号输出通道 B |
4 |
ANALOG CHANNEL A |
O | - | 模拟信号输出通道 A |
5 |
NC |
- | - | 悬空 |
6-13 |
IOB7-IOB0 |
I / O | L / H | IO 接口 A |
14-21 |
IOA7-IOA0 |
I / O | L / H | IO 接口 B |
22 |
CLOCK |
I | - | 时钟信号 |
23 |
RESET |
I | L | 重置,低电平有效 |
24 |
A9 |
I | L / H | 地址 9 |
25 |
A8 |
I | L / H | 地址 8 |
26, 39 |
TEST 2, TEST 1 |
- | - | 测试用引脚,悬空 |
27 |
BDIR |
I | L / H | 总线方向 |
28 |
BC2 |
I | L / H | 总线控制 2 |
29 |
BC1 |
I | L / H | 总线控制 1 |
30-37 |
DA7-DA0 |
I / O / 高阻抗 | L / H | 数据 / 地址总线 0-7 |
38 |
ANALOG CHANNEL C |
O | - | 模拟信号输出通道 C |
40 |
VCC |
- | +5V | +5V 电源 |
一共 40 个引脚,看起来貌似相当复杂,有点不知从何下手。但是仔细观察就会发现,IO 接口占了很大一部分。这些接口是用来与外部设备通信的,并不会影响到音频的功能,一般也很少用到。这也就是为什么这款芯片的亲戚 AY-3-8912 砍掉了一半的 IO 接口。本项目中不会有这种需求,因此全部悬空即可。除此之外,还有几个引脚标注了NC和TEST,这些也可以直接悬空。
输入引脚 Input Pins
剩下的引脚才是真正要用到的。电源、接地和 8 位的数据 / 地址总线自不用说。先来看用于输入的引脚。第 22 号引脚CLOCK是时钟信号输入。芯片若要发生波形,就必须借助时钟信号这种有周期性的方波信号来完成控制时序、调制波形等操作。时钟信号可以通过晶振来提供;不过在本项目中则会使用 Arduino 来生成。具体的生成方法将在本文后面进行详细介绍。
第 23 号引脚RESET是复位信号输入,低电平有效。复位时会将芯片所有的寄存器置 0。
第 24 和 25 号引脚A9, A8是地址线。这两个地址线的作用是什么呢?DA0-DA7这 8 个引脚既可以作输入数据,也可以输入地址。当作为地址线使用的时候,最多可以被映射成 个地址。而如果再加上这两根额外的地址线,就可以映射成 个地址。这个在有很多 IO 设备,或者同时使用很多块这种芯片时可以扩展地址的识别范围。简单来说,就是选择要用的芯片。不过说了这么多,本项目其实并不会用上。因此只需将A9接地,A8接电源即可。
接下来的第 27,28,29 号引脚BDIR, BC2, BC1引脚用于控制总线。虽然三个引脚一共有 8 种电平的组合,手册给出了一个简化的版本,即保持BC2为高电平,剩下的就交给BDIR和BC1控制。芯片生成的音调、音量以及噪声和包络发生都要通过向寄存器内写入数据才能控制,而在这之前还得先选择锁存器的地址。这一点在后面也会详细介绍。
输出引脚 Output Pins
OK,输入的部分介绍完了,接下来就是输出的部分。AY-3-8910 提供了三个通道输出,分别为第 3,4 以及 38 号引脚的 B,A,C 模拟输出。这些引脚内置了数模转换器,因此可以不需要再外接其他的转换电路。输出的功率可以直接带动入耳式的耳机,不过可以加个简单的滤波或者功放模块。
26号引脚变成了SEL。不过根据手册上的描述,这个引脚也可以直接悬空。因此这两个芯片是完全兼容的。
程序编写 Programming
生成时钟信号 Generating Clock Signal
笔者使用的 Arduino Mega 2560 采用的是基于 AVR 内核的 ATmega2560 处理器。在 AVR 内核中,一共有 3 个计时器,两个 8-bit (Timer 0 和 Timer 2),以及一个 16-bit (Timer 1) 分辨率的计时器,用于实现与时间有关的操作时,如生成时钟信号、定时中断、频率测量以及 PWM 等。如果要生成 2 MHz 的时钟信号,基本的思路是这样的:Arduino Mega 2560 的时钟频率为 16 MHz,通过设置内部的计数器,让它数到某个数时执行翻转输出引脚上的电平的操作,同时清零为下半个周期做准备。而在这个过程中,将用到如下寄存器:
| 寄存器Register | 功能Function |
|---|---|
TCNT |
计时器 / 计数器寄存器Timer / Counter Register |
TCCR |
计时器 / 计数器控制寄存器Timer / Counter Control Register |
OCR |
输出比较寄存器Output Compare Register |
其中用来进行计时或计数的就是TCNT1,而控制计数器工作模式的就是TCCR1A和TCCR1B。OCR1A就是设置计数器从 0 开始要数到的目标数。TCCR1A和TCCR1B是怎么配置计数器的工作模式呢?这两个寄存器一共有 8 个控制位,每个控制位的功能如下:
| 位Bit | TCCR1A | 描述Description | TCCR1B | 描述Description |
|---|---|---|---|---|
7 |
COM1A1 |
Compare Output Mode for Channel A | ICNC1 |
Input Capture Noise Canceler |
6 |
COM1A0 |
ICES |
Input Capture Edge Select | |
5 |
COM1B1 |
Compare Output Mode for Channel B | - | |
4 |
COM1B0 |
WGM13 |
||
3 |
COM1C1 |
Compare Output Mode for Channel C | WGM12 |
|
2 |
COM1C0 |
CS12 |
Clock Select | |
1 |
WGM11 |
Wave Generation Mode | CS11 |
|
0 |
WGM10 |
CS10 |
我们首先来看WGM10到WGM13这 4 位。这是用来控制波形生成模式的,一共有 16 种模式,其中大部分都是 PWM。在前文中提到过,计时的本质就是一个累加的计数器,因此我们要选择的模式是 CTC (Clear Timer on Compare Match) 模式,即计数器的值与目标数匹配时清零,重新开始。
诸位读者可能注意到了,表格中的模式 4 和 12 的行为都是 CTC,而唯一不同的地方在于 TOP 那一列。模式 4 为OCRnA,模式 12 为ICRn。这两个有什么区别呢?简单来说,TOP 的意思就是计数器的终点值。OCR是输出比较寄存器 (Output Compare Register)。这个寄存器会被写入一个特定的值来指定计数的终点;而ICR是输入捕获寄存器 (Input Capture Register)。当外部的输入信号发生变化时,这个寄存器会立马记录下此时计数器的值作为计数的终点。打个比方,OCR就像是计时器,按照人为设定好的时间来周期性地计时;而ICR则相当于秒表,测量外部事件发生的时间。时钟信号是一种周期性信号,因此应该选择模式 4,将WGM12这一位设置为1,其他的WGM位设为0。
接着再来看看COM1A1和COM1A0这两位。这两位用于比较输出控制,控制当计数器数到终点时,对应的输出引脚该怎么办。在比较输出模式,非 PWM的情况下,手册上给出了如下几种输出行为:
当COM1A1 = 0, COM1A0 = 1时,输出行为就是当匹配时翻转OC1A / OC1B / OC1C上的电平,这正是生成方波的关键。
CS10-CS12这 3 位用于选择时钟,里面预设了很多档位的分频 (prescaler)。如果以处理器 16 MHz 的时钟信号为基准,虽然在 8 分频的情况下正好是 ,不过由于真正的输出频率还得取决于计数器,因此不分频,将CS10设为1,其余位为0。
时钟信号将在OC1A引脚上输出。根据 Arduino 的引脚图,D11引脚的青色背景文字标注着OCA1。请注意,虽然基于 AVR 核心的处理器,例如 ATmega328P 等,也有相同的用于计时的寄存器,不过对于引脚的映射会略有不同。如果使用的是其他型号的开发板,请务必确认引脚的映射关系。
最后一步就是设定计数器的终点OCR1的值。这个值该怎么计算呢?诸位读者不妨跟着下面的过程推导一下。首先,每个脉冲的时长为:
由于 Timer 1 是从 0 开始计数的,所以总共OCR1A + 1个计数周期。每次OC1A翻转所需时间:
而一个完整的方波周期需要两次翻转,意味着需要经历两个计数周期:
最终输出公式:
当 时,代入可解得 。
其实说了这么多,要写的代码也就五行。因为这些涉及到了相当底层的操作,笔者也是边学边写,因此会尽可能记录得详细一点。
#define CLOCKOUT 11
TCCR1A = bit(COM1A0);
TCCR1B = bit(WGM12) | bit(CS10);
OCR1A = 3;
pinMode(CLOCKOUT, OUTPUT);
驱动芯片 Driving the Chip
能够生成稳定的时钟信号以后,接下来就是驱动这块芯片了。这块芯片的各种功能都是通过写寄存器实现的。
由于这块芯片是寄存器地址和数据共用,因此得先通过BDIR和BC1来指定总线的读写方向:
BDIR |
BC2 |
BC1 |
PSG 功能PSG Function |
|---|---|---|---|
0 |
1 |
0 |
非活动 INACTIVE |
0 |
1 |
1 |
从 PSG 读取 READ FROM PSG |
1 |
1 |
0 |
向 PSG 写入 WRITE TO PSG |
1 |
1 |
1 |
设置寄存器地址 LATCH ADDRESS |
那么,首先定义一个函数SetPsgMode()来切换模式:
enum PSGMode { INACTIVE, READ, WRITE, LATCH };
// Set the PSG function
void SetPsgMode(enum PSGMode mode) {
switch (mode) {
case INACTIVE:
digitalWrite(BC1, LOW);
digitalWrite(BDIR, LOW);
break;
case READ:
digitalWrite(BC1, HIGH);
digitalWrite(BDIR, LOW);
break;
case WRITE:
digitalWrite(BC1, LOW);
digitalWrite(BDIR, HIGH);
break;
case LATCH:
digitalWrite(BC1, HIGH);
digitalWrite(BDIR, HIGH);
}
}
按照手册上的描述,写入数据的流程应该是这样的:非活动 → 写入地址 → 非活动 → 写入数据 → 非活动。而DA0-DA7上写入数据的脉冲宽度应在 500 到 10,000 ns 之间。由此就构成了用于写入的函数WriteByte():
// Write data into the specified register
void WriteByte(uint8_t reg, uint8_t data) {
SetPsgMode(LATCH);
PORTF = reg;
delayMicroseconds(10);
SetPsgMode(INACTIVE);
SetPsgMode(WRITE);
PORTF = data;
delayMicroseconds(10);
SetPsgMode(INACTIVE);
}
这块芯片有 A,B,C 三个通道,而每个通道可以独立开关,或者控制音调 (Tone,或周期 Tone Period)、音量 (Volume,或振幅 Amplitude) 以及包络 (Envelope) 形状、周期;同时,也可以选择一个独立的噪声发生器和其中一个通道混合在一起输出。在本篇文章中暂时不会用到噪声和包络,前者常用于模仿鼓点或者爆炸等音效,后者则会改变输出音符的效果等。目前的任务是能够成功驱动这块芯片,并播放出简单的音符,譬如在通道 A 上播放 C4-B4 这七个音。
先看通道 A 的输出是怎么控制的。首先,R0和R1这两个寄存器用来调整输出的音调。R0用 8 位来微调 (Fine Tune),再加上R1的前 4 位粗调 (Coarse Tune),总共 12 位。其实和处理器输出时钟信号的原理如出一辙,这里的这 12 位本质上也是个计数器。计数器将从给定的值开始倒数,直到倒数到零时翻转输出引脚上的电平,并重新开始。那么,要传入寄存器的值该如何计算呢?手册上已经很贴心地给出了公式:
其中, 是时钟频率。由于芯片内部还有个 16 分频器,所以要除以 16; 是要生成的频率, 是要传入寄存器的值。例如,如果想生成 C4 (262 Hz) 这个音调的话,代入解得 。
网上有很多地方给出了音名和频率的对照关系表,手册上也有基于 1.78977 MHz 时钟频率计算出的应该写入寄存器的值。基于此可以生成一个头文件专门储存音名和要写入寄存器的值的对应关系。这样的好处就是每次要用什么音符直接调用它的名字即可,不仅可读性更高,而且可以比储存音名和频率的对应关系这种方案快一些,因为不需要每次再去计算要写入的值。不过,如果使用了不同的频率,最终输出会略有移调(其实也并无大碍,因为我们听音乐本质上听的是音程关系而非频率)。
芯片是通过 12 位来控制音调的,低 8 位要写入R0,高 4 位要写入R1,因此可以先定义一个 16 位的变量uint16_t tone。而要取它的低 8 位和高 4 位,可以配合逻辑与和右移操作实现,最后再强制类型转为为 8 位的数据。而音量则通过R10寄存器来控制,其中 bit0 - bit4 为音量的值(0-15),bit5 为静音。最后,再通过 delay()来控制音符播放的时间:
// Play the notes according to the specified parameters
void PlayNoteEvent(int duration, uint16_t tone, uint8_t volume) {
uint8_t r0 = (uint8_t) tone & 0xFF; // The value of Register 0
uint8_t r1 = (uint8_t) (tone >> 8) & 0x0F; // The value of Register 1
WriteByte(0, r0 & 0xFF);
WriteByte(1, r1 & 0x0F);
WriteByte(8, volume);
delay(duration);
}
初始化并启用通道 A 的输出。在R7寄存器中,bit0 就是控制通道 A 是否启用,低电平有效,因此将其设为0,其他设为1即可:
WriteByte(7, 0b00111110); // Enable channel A tone output
拓展:封装类 Encapsulating Classes
在此前的文章驱动 LCD12864 ST7920(一)中,是将整个驱动过程,划分成一个个的小步骤完成的。这个就是面向过程编程 (Procedural Programming)。这种编程方式的核心就是函数。例如,假设现在我们要完成「把大象放进冰箱里」,一共三步,即打开冰箱门、把大象放进去以及关闭冰箱门。在面向过程编程中,这三个步骤就会被封装成三个函数,然后由主函数依次调用。这种编程方式在早期的编程语言 例如 C,BASIC,Pascal 中尤为常见。
而在本项目中,将使用另外一种思维方式,即面向对象编程 (Object-Oriented Programming)。比起前者的将一个问题拆分为很多很多的步骤,这种则是将问题的各种属性、方法等封装在一起,抽象成一个类 (Class)。例如,在使用 C# 开发的游戏中,如果要控制角色的行为,通常都会通过它们的类实例化成一个可供操作的对象 (Object)。这个对象里面将包含各种譬如生命值、攻击力、防御力等属性,以及移动、攻击、受伤等方法。这样,当角色移动或受伤时,只需通过调用这个对象里面的方法,就可以控制角色的坐标或生命值。
这种编程方式的好处就是在一些大型项目中可以选择只暴露必须的接口,避免了函数过多导致的混乱;而且类也可以被继承,便于扩展和复用,例如在 Unity 中,脚本都会默认继承自一个叫做MonoBehaviour的类,这个类里面就已经预先封装好了比如初始化、每帧调用等方法。这里我不会展开太多,不过以后应该也会有独立游戏开发笔记的文章。回到正题,现代有很多编程语言都是面向对象的,例如 C++,C# 等;这也是 C++ 和 C 的主要区别之一。
那么该如何将目前写的代码改为面向对象的形式呢?首先在头文件ay38910.h中通过class声明AY38910Player这个类。这个将用于描述所有播放器共有的属性和行为。类里面的各种变量或方法统称为成员 (Member)。
面向对象编程中有个很重要的概念就是动态成员 (Dynamic Member) 和 静态成员 (Static Member)。那么,这两个究竟是什么意思呢?举个例子,由于 AY-3-8910 这块芯片有三个通道,因此每个通道播放的音符、持续的时间、音量等会不一样。这些会变化的内容就被称为动态成员。而静态成员就指那些每个对象共享的、不依赖于对象的。例如默认的 BPM 就可以看作是一个静态成员变量;而设置 BPM 的方法就是一个静态成员函数。
在一个类里面,可以通过public和private来设置权限,选择哪些成员是可以公开访问,哪些只能由同一个类的成员访问。例如,假设我不希望暴露WriteByte这个方法:
// ay38910.h
class AY38910Player {
public:
void SetPsgMode(enum PSGMode mode);
void Initialize();
void PlayNoteEvent(int duration, uint16_t tone, uint8_t volume);
private:
void WriteByte(uint8_t reg, uint8_t data);
}
这样,WriteByte()就只能通过由公开的方法间接访问了,避免了因为写入了错误的数据而直接无法工作。
不过这还没完。如果要在外部实现类里面的成员函数,例如ay38910.cpp,则需要通过作用域解析运算符::,来明确它们所属的作用域。例如:
// ay38910.cpp
#include "ay38910.h"
void AY38910Player::SetPsgMode(enum PSGMode mode) {}
void AY38910Player::WriteByte(uint8_t reg, uint8_t data) {}
目前,还只是定义了类以及实现方法。对于静态成员,可以直接通过::访问,比如AY38910Player::defaultBpm。而对于动态成员,则必须先实例化 (Instantiation) 成对象。打个比方,类就相当于一份蓝图,描述了某物的结构和行为,没有实际数据;而对象就是根据蓝图制造出来的机械或建筑,有实际功能,会在内存中分配空间储存成员变量。
一般实例化对象有两个方法。方法一:栈实例化。这是最简单、也最常用的方法。栈 (Stack) 是指程序运行时在内存中开辟的一小块固定的空间,用来取存一些局部和临时数据:
AY38910Player player;
现在,player就是一个AY38910Player对象。要访问这个对象里的成员,就可以通过成员访问运算符.:
player.SetPsgMode(INACTIVE);
栈会自动管理内存分配,一般用于局部变量、函数参数、栈实例化的对象等。而如果有一个巨大的数据容器,比如储存整首曲子的信息时,就不推荐使用栈,因为它的空间有限,过大的数据可能会造成栈溢出(Stack Overflow)。因此,对于这样的数据,或者是生命周期更长的,要使用方法二来实例化。
方法二:堆实例化。与栈不同的是,堆 (Heap) 是一块更大一点的空间,由操作系统动态分配。使用的时候必须得手动通过new分配,以及最后用delete释放。在分配时,new操作符返回的是一个指向为这个对象开辟的内存空间的地址。这也是和栈实例化不同的一点,因为后者可以直接访问。这样,就需要用指针通过成员访问运算符->来调用对象里的方法:
AY38910Player* playerPtr = new AY38910Player();
playerPtr -> SetModePsg(INACTIVE);
delete playerPtr;
那么,为什么堆需要手动释放内存呢?由于栈是位于一块固定的区域,而且里面的分配情况都会自动管理。例如在函数结束以后,临时数据等所占用的空间就会被自动释放;而通过new创建对象时,如果没有手动释放内存,那么程序在运行过程中就丢失了对这块堆内存的控制权,既不能被访问、也不能被重新分配给其他程序,而这就是内存泄漏 (Memory Leak)。所以,请务必记得在最后要释放内存。
不过,在 C++ 11 的标准中,提供了智能指针 (Smart Pointer)std::unique_ptr[2],包含在头文件<memory>中。可以通过如下方式来创建:
std::unique_ptr<AY38910Player> playerPtr =
std::make_unique<AY38910Player>();
通过这种方式封装的数据,其占用的内存将在函数销毁或超出作用域(即一对大括号包裹的区域)时自动释放。这是一个更好的 C++ 实践,因为它更安全,能防止某些异常场景下的内存泄漏,而且更高效。关于这点将在下一篇文章中进行更详细的介绍。
后记 Epilogue
为了驱动这块芯片,这个过程说不上一帆风顺,总会出些电路或者软件上的小毛病。光是调试就花费了不少时间。不过,当听到从扬声器里播放出预期的音符时的那种喜悦很快就将调试时的种种阴翳一扫而空。这个过程中也学到了很多很多的新知识。不仅仅是硬件和编程上的,关于如何采集芯片的输出信号、改编曲子、解析 Tracker Music 文件结构等,就涉及到了信号处理、乐理等内容了。在下篇文章中,我将会介绍如何制作一个简单的 Tracker Music 播放器。