AVR单片机教程——DAC
本文隶属于AVR单片机教程系列。
单片机的应用场景时常涉及到模拟信号。我们已经会使用ADC把模拟信号转换成数字信号,本讲中我们要学习使用DAC把数字信号转换成模拟信号。我们还将搭建一个简单的功率放大器电路,用DAC通过扬声器播放音乐。
SPI总线
集成DAC的单片机不多,ATmega系列就不在此列。我们将要使用的10位DAC是通过SPI总线通信的,因此我们先来学习SPI总线。
SPI是一种同步串行通信总线,支持全双工通信。所谓同步,就是有时钟信号,类似上一讲中的595和165,并且硬件实现上相似;所谓全双工,就是收发可以同时进行,事实上SPI的收发是必须同时进行的,不过你可以有选择地忽略其中一个。
一次SPI通信涉及到两个设备,分别是主机和从机。区分主机和从机的标准并不是发送方是主机,而是发起方是主机。形象地说,我让你给我一个苹果,尽管你是发送方,但我是发起方,因此我是主机。
SPI有4根信号线:主发从收MOSI
、主收从发MISO
、时钟SCK
、片选SS
(以下省略上划线)。主机和从机的MOSI
、MISO
、SCK
一般直接连接,根据应用需要可以省去MOSI
或MISO
,从机的SS
可以连接主机的任意引脚,因为SS
上的信号极其简单。
两个以上的设备也可以通过SPI通信,连接方式是MOSI
、MISO
、SCK
直接连接,每两个可能通信的设备之间都需要一条单独的SS
信号线。标准的SPI要求从机在没有被选中时,MISO
为高阻态,因此没有被选中的设备不会干扰主机和从机之间的通信。如果同时有多个从机被选中,MISO
上的信号就会冲突,因此每次通信只能有一个主机和一个从机。SPI没有仲裁机制,多个设备可能同时发起通信,这时会有冲突。
在大多数应用中,多个SPI设备中只有一个,通常是单片机或更高级的处理器,会担任主机的角色,其他设备都是从机。所有设备都由单片机来控制,可以避免冲突。
SPI通信的时序既简单又复杂。简单在于一个时钟周期传输一位数据,没有校验和标识位等,与595、165等逻辑芯片类似,硬件实现也不复杂;复杂在于时钟极性和相位可以改变,这与595和165是确定地在上升沿移位不同。
SPI传输以字节为单位。当SS
变为低电平时,一次传输开始。若干字节传输结束后,SS
变为高电平。其间,每一个SCK
时钟周期,MOSI
和MISO
上传输一位数据。
CPOL
表示时钟极性,CPHA
表示时钟相位,由此SPI有4种模式,你可以在这里了解它们的具体含义。在实际应用中,我们应当根据SPI设备数据手册中的时序图或逻辑图来选择合适的模式,选择的原则是,在一方读取时,另一方发送的数据必须处于稳定状态,不能跳变。
关于SS
为什么是低电平有效,即低电平时从机被选中,以及很多其他信号也都是低电平有效,这主要是历史原因。在TTL工艺的时代,集成电路中输出低电平能力强,信号从高电平变到低电平快。而大多数功能都是边沿触发的,需要一个可靠的边沿,就选择了下降沿,于是就成了低电平有效。在CMOS的时代,高低电平基本对称,但在PCB布线上低电平有效的信号还是有一些优势,同时由于历史原因,这一习惯保留了下来。
开发板上能使用SPI总线通信的设备有74HC595、74HC165和DAC,它们都连接在单片机的USART1
上,使用SPI模式的USART通信。与SPI组件相比,SPI模式的USART只支持主机模式,有额外的缓冲,其余功能基本相同。涉及的寄存器也还是UART中的那几个,寄存器位的功能略有不同,请参考数据手册了解详情。
在UART模式下,双缓冲可以给程序一点喘气的时间,降低了对响应时间的要求。但是在SPI模式下,由于SPI是同时收发的,双缓冲反而带来了一点麻烦。如果第一次的意图是发送,第二次的意图是接收,那么第一次发送顺带接收的数据会保存在接收器的缓冲区中,第二次读到的是这个无效的数据,并非真实接收到的。我们的解决方案是,每发送一个字节,UDR1
寄存器除了写一次以外还要读一次,这样可以保持接收器的缓冲字节为空,保证了读到的一定是新鲜的数据。
由于我们现在还不知道这3个设备需要SPI的哪种模式,我们把配置和传输分离开:
#include <stdint.h>
#include <avr/io.h>
#include <ee2/bit.h>
#define UCPHA1 1 // macros not found in <avr/iom324pa.h>
#define UDORD1 2
typedef enum
{
SPI_SS_DAC = 0b00,
SPI_SS_NONE = 0b01,
SPI_SS_595 = 0b10,
SPI_SS_165 = 0b11
} spi_ss_t;
void usart1_spi_ss(spi_ss_t _which)
{
PORTC = (PORTC & ~(0b11 << PORTC2)) // protect other bits
| (_which & 0b11) << PORTC2; // configure PORTC3:2 bits
}
void usart1_spi_init()
{
reset_bit(DDRD, PORTD2); // RXD1/MISO input
set_bit(DDRD, PORTD3); // TXD1/MOSI output
set_bit(DDRD, PORTD4); // XCK1/SCK output
UCSR1B = 1 << RXEN1 // enable receiver
| 1 << TXEN1; // enable transmitter
UCSR1C = 0b11 << UMSEL10; // Master SPI mode
usart1_spi_ss(SPI_SS_NONE);
DDRC |= 0b11 << DDC2; // PC3:2 output, select none
}
typedef enum
{
SPI_CPOL_0 = 0 << UCPOL1, SPI_CPOL_1 = 1 << UCPOL1,
SPI_CPHA_0 = 0 << UCPHA1, SPI_CPHA_1 = 1 << UCPHA1,
SPI_MSB_FIRST = 0 << UDORD1, SPI_LSB_FIRST = 1 << UDORD1,
} spi_config_t;
void usart1_spi_mode(spi_config_t _config)
{
cond_bit(read_bit(_config, UCPOL1), UCSR1C, UCPOL1);
cond_bit(read_bit(_config, UCPHA1), UCSR1C, UCPHA1);
cond_bit(read_bit(_config, UDORD1), UCSR1C, UDORD1);
}
uint8_t usart1_spi_transceive(uint8_t _send)
{
UDR1 = _send; // start a transmission
while (!read_bit(UCSR1A, TXC1)) // wait until transmission finishes
;
set_bit(UCSR1A, TXC1); // clear TXC1 bit
return UDR1; // clear the buffer
}
usart1_spi_ss
通过PC3:2
引脚和74HC138芯片(你应该已经在《UART进阶》一讲中学习过了)控制DAC的CS
(相当于SS
)、595的RCLK
和165的SH/LD
。usart1_spi_transceive
进行一个字节的传输,参数作为发送的数据,接收到的作为返回值。需要特别注意的是,主机接收是主机发起的,发起的方式是向UDR1
寄存器写入,即发送一个字节,而写入的值无所谓。这也许有点反直觉,但SPI组件和SPI模式的USART组件确实都是这么设计的。
接下来我们来选择适用595和165的SPI模式。
595内部的移位寄存器在时钟上升沿移位,因此上升沿时MOSI
必须是稳定的,排除mode 1
和mode 2
。那么mode 0
和mode 3
中选哪个呢?事实上都可以。
void write_595_spi(uint8_t _data)
{
usart1_spi_mode(SPI_CPOL_0 | SPI_CPHA_0 | SPI_LSB_FIRST);
// or SPI_CPOL_1 | SPI_CPHA_1
usart1_spi_transceive(_data);
usart1_spi_ss(SPI_SS_595);
usart1_spi_ss(SPI_SS_NONE);
}
165内部的移位寄存器也在时钟上升沿移位,因此单片机必须在下降沿读取,排除mode 0
和mode 3
。那么mode 1
和mode 2
也是随便选一个就可以吗?在SH/LD
低电平过后,H
引脚上的电平就反映在QH
上了,我们得保证在MISO
第一次读取电平之前,移位寄存器没有被移位,即时钟上没有上升沿,因此mode 1
是不能选用的!应该选择mode 2
。不过呢,经过实测,mode 0
和mode 3
也是可以使用的,但是不推荐这样做。
uint8_t read_165_spi()
{
usart1_spi_mode(SPI_CPOL_1 | SPI_CPHA_0 | SPI_LSB_FIRST);
usart1_spi_ss(SPI_SS_165);
usart1_spi_ss(SPI_SS_NONE);
return usart1_spi_transceive(0);
}
还记得上一讲最后说只要3根线就能驱动595和165吗?在SPI模式下,由于MOSI
与MISO
无法合并,需要占用单片机4个引脚,不过也是相当不错的成绩了。
DAC
花了这么长篇幅写SPI,终于到了本讲的正题了。
数模转换器的功能是把数字信号转换成模拟信号,最常见的应用就是音频了。声音是介质的波,反应到电路中是模拟信号波形,通常经ADC在一定频率采样后,也许还会经过无损或有损的压缩,存储在数字设备中。采样的频率称为采样率,最常见的是44.1kHz。一定采样率下能记录的声音的最高频率为采样率的一半。在重现时,数字波形经过一些处理后传送给DAC,DAC以采样率的频率把数字信号还原为模拟信号,作为后续电路的信号源。
我原想用DAC播放现成的音乐,但很可惜我们的开发板办不到,因为音频的体积过于庞大。不过,与单片机驱动蜂鸣器发出旋律类似,计算机也可以合成声音,我们将用少量数据通过程序变换成声音。在此之前,我们先来学习开发板上这块型号为TLC5615的10位DAC的用法。你可以在这里下载它的数据手册。
TLC5615共有8个引脚:电源VDD
、AGND
;数字DIN
、SCLK
、CS
、DOUT
;模拟REFIN
、OUT
。REFIN
连接了2.5V的参考电压,因此输出电压为\(\frac {INPUT} {1024} \times 5V\),覆盖了GND
到VCC
之间的电压。
CS
连接74HC138的一个输出,SCLK
连接SCK
,DIN
连接MOSI
,DOUT
未连接。
根据写入时序图,一次写入的流程应该是:先拉低CS
电平,然后发送16位即2字节的数据,再把CS
拉高。SPI应选用mode 0
或mode 3
,因为SCLK
上升沿时DIN
的电平必须稳定。以及,位顺序是高位在前。
TLC5615支持16位和12位两种数据格式。16位是为了与SPI以字节为单位传输兼容,12位是为了减少写入的工作量。我们将选择16位格式,用SPI组件驱动。当然,你也可以用引脚的高低电平直接驱动,这样就可以用12位格式了。至于为什么没有10位格式,那多半是因为厂商还有一款12位DAC,它们使用了相同的控制逻辑。
在16位格式中,第一字节包含10位数据的高4位,可以通过把数据右移6位得到;第二字节包含低6位,放在这一字节的高6位中,可以通过把数据左移2位得到。
void write_dac_spi(uint16_t _data)
{
usart1_spi_mode(SPI_CPOL_0 | SPI_CPHA_0 | SPI_MSB_FIRST);
usart1_spi_ss(SPI_SS_DAC);
usart1_spi_transceive(_data >> 6);
usart1_spi_transceive(_data << 2);
usart1_spi_ss(SPI_SS_NONE);
}
众所周知,ADC和DAC都是有误差的,我们来感受一下这个误差有多大。把DAC输出引脚与一个ADC引脚相连接。
#include <ee2/dac.h>
#include <ee2/adc.h>
#include <ee2/uart.h>
#include <ee2/delay.h>
int main(void)
{
dac_init();
adc_init();
uart_init(UART_TX_64, 384);
for (uint16_t i = 0; i != 1 << 10; ++i)
{
dac_write_10bit(i);
delay(1);
uint16_t adc = adc_read_10bit(ADC_0);
uart_set_align(ALIGN_RIGHT, 4, '0');
uart_print_int(i);
uart_print_string(" ");
uart_set_align(ALIGN_RIGHT, 4, '0');
uart_print_int(adc);
uart_print_line();
}
while (1)
;
}
在每一遍循环中,我们让DAC输出一个值,等待1毫秒电压绝对稳定后,用ADC来读取DAC输出的电压。部分输出如下:
0000 0000
0001 0000
0002 0000
0003 0001
0004 0002
0005 0003
0006 0004
0007 0005
0008 0006
0009 0007
...
0507 0507
0508 0508
0509 0510
0510 0510
0511 0512
0512 0512
0513 0513
0514 0514
0515 0515
0516 0516
...
1014 1018
1015 1019
1016 1020
1017 1021
1018 1021
1019 1022
1020 1023
1021 1023
1022 1023
1023 1023
观察输出结果,我们发现,理论值与实际测量值相差不超过4;在电压接近正负电源电压时,DAC或ADC的误差较大;DAC输出与输入的关系总是单调的,这是TLC5615的结构所决定的。
功率放大
DAC可以输出音频信号,扬声器也需要用音频信号驱动,可不可以用DAC的输出直接驱动扬声器呢?
不行。第一,DAC的输出范围在0~5V范围内,其直流分量一般取中间电压即2.5V,无论扬声器的另一端接VCC
还是GND
,正负极之间都有2.5V的直流分量,会烧毁扬声器线圈。第二,DAC的输出电流很小,完全不足以驱动扬声器。我们需要功率放大电路。
这个功放电路由两个乙类功放组成,每个由一个运算放大器、一个NPN三极管和一个PNP三极管组成。为了方便,我们把运放的同相输入(标+
的一端)称为A
点,两个三极管的基极称为B
点,发射极称为C
点。
每个三极管都组成一个射极跟随器电路,当B
点电压高于C
点电压加上NPN管基极与发射极之间二极管的导通电压时,基极就会有微弱的电流,由于放大作用,发射极会有很大的电流从VCC
经NPN管流向外部,可以供扬声器使用;同样地,当C
点电压高于B
点电压加上PNP管基极与发射极之间二极管的导通电压时,会有很大的电流从外部经PNP管流向GND
。C
点电压随B
点电压的关系是单调的。
运算放大器是这样的器件,若以正负电压的中点(即2.5V)为参考点,它的输出电压是同相输入和反相输入之差的很多倍(一般至少1000倍,理想情况下认为是无穷大倍)。对于一定的同相输入电压,当运放输出电压即B
点电压变高时,C
点电压也会变高(因为单调),这就使得同相输入与反相输入之间电压减小,输出电压应当降低,形成一个负反馈的关系。换言之,若输出变高,则输出还应该变低,于是存在一个点使得运放的输入输出不变化。由于运放输出电压是有限的,两个输入端的电压必定相同,也就使得C
点电压与A
点电压相同。
输出对输入的反应很快,远快于音频信号的采样率,可以认为C
点电压始终与A
点电压保持相同,即使A
点电压在不断变化。所以,输出波形与输入波形相同,信号没有失真。又因为有三极管的存在,输出电流可以很大,就达到了功率放大的效果。
现实当然没有理论分析得那么完美,不然厂商为什么不把一个运放和两个三极管集成到DAC里面去呢?最主要的限制就是输出电压的范围。尽管DAC和运放都是轨至轨的,即输出范围为GND
到VCC
,由于三极管中基极与发射极之间需要导通电压,这一电压可以达到1V甚至更高,输出电压被限制在1V到4V。
另一个问题是交越失真,这是一种相当难听的失真。当输入信号非常优雅地从2.499V变到2.501V时,运放输出电压需要暴躁地从1.499V变到3.501V,由于运放输出变化速率是有限的,输出波形会跟不上输入波形的变化,形成失真。不过,相比于乙类运放的小信号死区,这种改进结构的失真小了很多。
然后我们开始搭建电路。除了开发板和各种杜邦线以外,我们还需要一个焊接好排针的扬声器、两个2N3906三极管、两个10kΩ电阻、一个0.1μF电容和一个100uF电容(可选,连接在VCC
和GND
之间)。搭建好的电路长成这样:
播放器
辛辛苦苦搭了那么复杂的电路,我们听什么呢?总不能还听方波吧,那样的话用蜂鸣器就可以了;要听就听最纯正的正弦波。
那么问题来了,如何用程序生成任意频率的正弦波呢?我猜,你的第一感觉是:
#include <math.h>
#include <avr/interrupt.h>
static const double timer_freq;
static const double note_freq;
ISR(TIMER1_COMPA_vect)
{
static double phase = 0;
double sine = sin(phase);
phase += note_freq / timer_freq * M_PI * 2;
if (phase > M_PI * 2)
phase -= M_PI * 2;
// ...
}
我们把计算正弦值的过程放在定时器中断中进行,它的频率为timer_freq
(\(f_t\)),在这个应用中也就是采样率。要播放的音符的频率为note_freq
(\(f_n\)),phase
表示这个正弦波的当前相位。这个声波的方程为\(y = A \sin(2\pi f_n t)\),在时间间隔\(\Delta t = \frac {1} {f_t}\)内,相位变化了\(\Delta \varphi = 2\pi \frac {f_n} {f_t}\)。程序的逻辑十分正确。
但是啊,我的老天爷,你竟然用double
!我敢打赌,你的程序会慢得像隔壁老太太一样。你看,单片机算一个sin
要75μs!如果需要同时有8个音符,再加上一些其他运算和控制指令,你的采样率不会超过1.5kHz,能播放的频率上限是750Hz,并且它还是个方波!
问题的来源是double
,AVR单片机没有计算浮点数的指令,所有的浮点运算都是用整数算法实现的。但是如果要用sin
,那么double
是无法避免的,因为它的参数和返回值都是double
类型。我们得实现一个纯整数的sin
函数。
我当然不会用泰勒展开去逼近sin
值,我的想法是,先把\(y = \sin x\)函数图像在x轴和y轴方向上分别放大若干倍,使得:
-
用整数表示函数值不会有太大的误差;
-
取图像上横坐标为整数的点,可以恢复图像的原貌。
然后把这些点的纵坐标保存在一个数组中,根据角度计算出最接近的数组下表,取出正弦值,不再需要计算正弦函数了。根据正弦函数的对称性,计算0到90度之间的就够了。
我让正弦函数的最大值为2184,把0到90度1024等分(稍后将解释为什么取这两个值),每个分界点求一个值。这1025个值当然不是手算,是写个电脑上跑的程序生成的。这个C++程序如下:
#include <iostream>
#include <fstream>
#include <iomanip>
#include <cmath>
#include <string>
std::ofstream file("sine.dat");
void print(int phase)
{
const int bound = (1 << 11) - 1;
int sin = std::round(std::sin(M_PI / 2 / 1024 * phase) * bound);
std::cout << std::setw(4) << phase << " " << sin << std::endl;
if (phase % 8 != 0)
file << ", ";
file << std::setw(6) << sin;
if (phase % 8 == 7)
file << ",\n";
}
int main()
{
std::cout << std::setfill('0');
file << std::hex << std::showbase << std::internal << std::setfill('0');
for (int i = 0; i <= 1024; ++i)
print(i);
file << std::endl;
return 0;
}
你当然可以把十进制数字直接写到文件中,我只是想让它整齐些。正弦值的表写在文件sine.dat
中,生成的数据如下(截取了最前两行和最后两行,下载链接在文末):
0x0000, 0x0003, 0x0007, 0x000a, 0x000d, 0x0011, 0x0014, 0x0017,
0x001b, 0x001e, 0x0022, 0x0025, 0x0028, 0x002c, 0x002f, 0x0032,
...
0x0888, 0x0888, 0x0888, 0x0888, 0x0888, 0x0888, 0x0888, 0x0888,
0x0888
于是我满怀欣喜地把它放到代码中去:
static const uint16_t sine_table[(1 << 10) + 1] = {
#include "sine.dat"
};
却得到一个错误:
Error:
Program Memory Usage: 4490 bytes 13.7 % Full
Data Memory Usage: 2071 bytes 101.1 % Full (Memory Overflow)
它说内存溢出了。原来,所有变量,即使是不变的变量也就是常量,也是放在SRAM中的,而ATmega324PA的SRAM一共只有2KB,刚好放不下sine_table
这个巨大的数组。但它其实没有必要放在SRAM中,放在flash中就可以了,因为我们只会读取而不会修改它,只需要读取速度快就可以了,flash是最好的选择。
<avr/pgmspace.h>
提供了把数据放在flash的工具:宏PROGMEM
,用于声明一个变量存放在flash空间,这样的变量需要通过指针来取用;函数memcpy_P
等,与定义在<string.h>
中的版本类似,但是数据来源的指针须指向flash空间。
要把sine_table
放在flash中,我们需要在声明式中添加PROGMEM
;sine_table
这个名字还是可以当做数组类型或指针类型来使用;取用sine_table
中的元素需要使用memcpy_p
函数,如memcpy_P(&data, sine_table + 42, 2);
可以把下标为42
的元素写到uint16_t data
中,最后一个参数2
表示uint16_t
是2字节宽的。
有了这些工具,我们就可以写一个整数版本的正弦函数了:
int16_t waveform_sine(uint16_t _phase)
{
static const uint16_t sine_table[(1 << 10) + 1] PROGMEM = {
#include "sine.dat"
};
uint16_t dst;
const uint16_t* src;
switch (_phase >> 10)
{
case 0: src = sine_table - 0 + _phase; break;
case 1: src = sine_table + (1 << 11) - _phase; break;
case 2: src = sine_table - (1 << 11) + _phase; break;
case 3: src = sine_table + (1 << 12) - _phase; break;
default: src = NULL; break;
}
if (src)
{
memcpy_P(&dst, src, 2);
return _phase >> 11 ? -dst : dst;
}
else
return 0;
}
参数_phase
的取值范围为0
到4095
。函数先把_phase
映射到0
到1024
上的一个值,然后取出正弦表中相应下标的值,最后根据_phase
是否大于等于2048
来决定是否要把取出的值取相反数(提示:奇变偶不变,符号看象限)。waveform_sine
函数与\(\sin\)函数的关系为:\(waveform\_sine(i) = b \sin(\frac {i} {4a} \cdot 2 \pi)\),其中\(a = 2^{10} = 1024\)为把0到90度分割的段数,\(b = 2184\)为振幅。
接下来我们来看基于waveform_sine
函数的定时器中断怎么写。首先我们把定时器中断的频率设为\(f_t = 2^{14} = 16384\)(稍后将解释为什么取这个值)。
// timer_freq = 1 << 14;
static const uint16_t note_freq;
ISR(TIMER1_COMPA_vect)
{
static uint16_t counter = 0;
int16_t sine = waveform_sine(counter >> 2);
counter += note_freq;
if (counter >= 1 << 14)
counter -= 1 << 14;
// ...
}
这里的每个字你都认识,可为什么就是看不懂呢?
counter
是一个计数器变量,它在每次中断中加上note_freq
。从之前程序中类型为double
的phase
变量可以看出,用作waveform_sine
的参数的应是counter
乘一个系数,设为\(k\)。由于\(waveform\_sine(k \cdot counter) = b \sin(\frac {k \cdot counter} {4a} \cdot 2 \pi)\),经过一个定时器中断间隔的时间,相位变化了\(\Delta \varphi = \frac {k \cdot f_n} {4a} \cdot 2 \pi\),而我们又想让\(\Delta \varphi = \frac {f_n} {f_t} \cdot 2 \pi\),故\(k = \frac {4a} {f_t} = \frac 1 4\),这就是counter >> 2
的来历。由于waveform_sine
的系数必须小于1 << 12
,counter
也必须小于1 << 14
。
现在可以解释\(a = 2^{10}\)和\(f_t = 2^{14}\)的原因了。我们必须保证counter * k
是比较好算的,右移两位就是属于好算的,而除以3或除以5都是不好算的,\(a\)和\(f_t\)必须是2的幂次倍数关系。在waveform_sine
函数中,我们还要看_phase
所对应的角所在的象限,需要计算_phase / a
,\(a\)也得是个2的幂次。
如果\(f_t\)太大,每次定时器中断的时间必须很短,对优化的要求太高,并且会使\(k\)太小,在数组中取值不精确;如果太小则频率上限太低。如果\(a\)太大,正弦数组占用的空间就太大;如果太小则同样\(k\)太小。综上,这两个值取了\(a = 2^{10} = 1024\)和\(f_t = 2^{14} = 16384\)。
然后我们来考虑怎么放音乐。音乐是由一个个音符组成的,每个音符都有音高、响度、时长这三个属性。在同一时刻可能有多个音符奏响,我们引入“音轨”的概念,共设置8个音轨,每个可以在同一时刻播放一个音符(这与一些软件中不同),也就是说允许同一时刻至多8个音符。音轨带有音符的三个属性,其中音高改为频率,这是为了方便标记空音轨,还需要加上一个用于记录当前相位的计数器变量。乐曲要控制节拍,在音符的结构体中加入一个延时字段,表示从前一个音符开始到这个音符奏响经过的时间。时长和延时都以拍为单位:
typedef struct
{
uint16_t frequency;
uint8_t loudness;
uint16_t duration;
uint16_t counter;
} Track;
typedef struct
{
uint8_t pitch;
uint8_t loudness;
uint16_t duration;
uint16_t delay;
} Note;
Track tracks[8];
我们实现响度控制的原理是把正弦值乘上loudness
。AVR是8位单片机,我不希望任何计算涉及到16位以上的变量,所以要合理地控制loudness
和waveform_sine
的值,使两者的乘积不超过int16_t
能表示的范围。设前者的最大值为\(c\),则\(b \cdot c \leq 2^{15} - 1\)。如果\(b\)太大而\(c\)太小,响度的粒度就很大;如果\(c\)太大而\(b\)太小,正弦值就不太精确。权衡一下,取\(c = 2^4 - 1 = 15\),相应地\(b = 2184\)。那0x7FFF / 0xF = 0x888
是个巧合吗?这其实和1.0 / 9 = 0.111...
是一个道理。
给DAC写512
可以使扬声器正极的电压为2.5V,两端电压相等,作为信号的零点。由于功放电路输入信号电压有限制,给DAC的10位数据也必须在一个范围之内,为方便计算,取为512 - 255
到512 + 255
。由此可以确定正弦值和响度乘积前的系数。我们想让单个音符可以达到最大音量,因此系数应为\(\frac {1} {128}\),但是这样的话多个音轨相加会导致16位整数溢出,并且溢出无法检测,所以把系数拆分为两段:先把8个音轨的正弦值和音量的乘积乘上一个系数,然后把这些结果相加,这一步是不会溢出的,然后再判断这个值是否会使最终结果超过范围,并把它限定在一定范围内,最后再乘一个系数。音轨共8个,第一个系数就取\(\frac {1} {8}\),第二个就是\(\frac {1} {16}\),范围是\([-2^{12} + 1, 2^{12} - 1]\)。
int16_t sum = 0;
for (uint8_t i = 0; i != 8; ++i)
{
int16_t sine = ...;
sum += (sine * tracks[i].loudness) >> 3;
...
}
if (sum > (1 << 12) - 1)
sum = (1 << 12) - 1;
else if (sum < 1 - (1 << 12))
sum = 1 - (1 << 12);
dac_write_10bit(512 + (sum >> 4));
以上是把音轨的数据转换为波形的方法,现在还需要把乐曲数据转换为音轨。我这里提供一组乐曲数据(完整数据见文末链接):
{7, 4, 32, 8},
{22, 6, 7, 0},
{14, 2, 28, 4},
{22, 2, 7, 4},
{21, 6, 4, 4},
{22, 6, 4, 4},
{26, 6, 8, 4},
{19, 6, 11, 8},
...
每一行第一个字段是音高,以中央C下的第一个E为0
,每半音加1;第二个是响度,取得比较小是为了防止过载失真;第三个是时长,以三十二分音符为单位;第四个是延时,单位与时长相同,都是非负值,因为音符是按时间排序的。乐曲的速度是四分音符102拍每分钟,相当于每个三十二分音符1205个采样点。我们在定时器中断中设置一个静态变量counter
(与Track
中的counter
不同),使得每1205次定时器中断更新一次音轨数据。更新音轨数据有两项任务:
-
把已经播放完的音符从音轨中删去。这就需要把
Track
中的counter
递减,它等于0意味着这个音符已经走到生命的终点,于是把frequency
清零,标记音轨为空。 -
读取乐曲数据,把达到延时时长的音符放到音轨中去。乐曲数据也比较大,需要用
PROGMEM
放到flash中。我们用一个指针cursor
指向播放到的位置,用一个Note
类型的变量temp
存放flash中读出的音符,在更新过程中把temp
中的delay
递减,减到0时选择一个空的音轨放入。
for (uint8_t i = 0; i != 8; ++i)
if (tracks[i].frequency && !--tracks[i].duration)
tracks[i].frequency = 0;
while (cursor != end && temp.delay == 0)
{
for (uint8_t i = 0; i != 8; ++i)
if (tracks[i].frequency == 0)
{
tracks[i].frequency = frequency[temp.pitch];
tracks[i].loudness = temp.loudness;
tracks[i].duration = temp.duration;
tracks[i].counter = 0;
break;
}
memcpy_P(&temp, ++cursor, sizeof(Note));
}
--temp.delay;
8个音轨的计算量很大,在更新音轨的周期还有更多指令,消耗很多CPU资源。我们需要一些优化手段:
-
如果用定时器中断的话,加上进入和退出中断所需的额外指令,中断的执行时间会超过定时器中断的间隔,无法控制时间。因此,我们把代码移动到
main
函数的主循环中执行,借助定时器的CTC模式实现精确的定时。 -
dac_write_10bit
是阻塞的,函数等待SPI组价发送完数据才返回,这是对CPU资源的浪费。我们改为使用异步发送,向UDR1
寄存器连续写入两个字节(因为USART组件有双缓冲功能),不等待它发送完成而直接执行下面的语句。但是,不能在写入后把CS
拉高,因为此时数据还没有发送完成。我们在每一次发送时,先把CS
拉高,然后立即拉低,再写UDR1
寄存器,DAC写入在下一次循环中CS
拉高时完成。由于循环间隔非常短,DAC的延迟是完全没有影响的。
完整的程序如下。一定记得要在Release配置下编译哦!
#include <stdlib.h>
#include <avr/io.h>
#include <avr/interrupt.h>
#include <avr/pgmspace.h>
int16_t waveform_sine(uint16_t _phase)
{
static const uint16_t sine_table[(1 << 10) + 1] PROGMEM = {
#include "sine.dat"
};
uint16_t dst;
const uint16_t* src;
switch (_phase >> 10)
{
case 0: src = sine_table - 0 + _phase; break;
case 1: src = sine_table + (1 << 11) - _phase; break;
case 2: src = sine_table - (1 << 11) + _phase; break;
case 3: src = sine_table + (1 << 12) - _phase; break;
default: src = NULL; break;
}
if (src)
{
memcpy_P(&dst, src, 2);
return _phase >> 11 ? -dst : dst;
}
else
return 0;
}
typedef struct
{
uint16_t frequency;
uint8_t loudness;
uint16_t duration;
uint16_t counter;
} Track;
typedef struct
{
uint8_t pitch;
uint8_t loudness;
uint16_t duration;
uint16_t delay;
} Note;
#define UCPHA1 1 // macros not found in <avr/iom324pa.h>
#define UDORD1 2
void timer_init()
{
TCCR1A = 0b00 << WGM10; // CTC mode
TCCR1B = 0b01 << WGM12 // CTC mode
| 0b001 << CS10; // no prescaling
OCR1A = 1525; // 16384sps
}
void timer_wait()
{
while (!read_bit(TIFR1, OCF1A))
;
set_bit(TIFR1, OCF1A);
}
void dac_init()
{
set_bit(DDRD, PORTD3); // TXD1/MOSI output
set_bit(DDRD, PORTD4); // XCK1/SCK output
UCSR1B = 1 << TXEN1; // enable transmitter
UCSR1C = 0b11 << UMSEL10 // Master SPI mode
| 0 << UDORD1 // MSB first
| 0 << UCPHA1
| 0 << UCPOL1; // SPI mode 0
set_bit(PORTC, PORTC2);
DDRC |= 0b11 << DDC2; // PC3:2 output, select none
}
void dac_write_async(uint16_t _data)
{
set_bit(PORTC, PORTC2); // select none
reset_bit(PORTC, PORTC2); // select DAC
UDR1 = _data >> 6;
UDR1 = _data << 2;
}
const uint16_t frequency[] = {
330, 349, 370, 392, 415, 440, 466, 494,
523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988,
1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976,
2093, 2217, 2349, 2489, 2637
};
const Note music[] PROGMEM = {
#include "music.dat"
};
int main()
{
dac_init();
Track tracks[8];
for (uint8_t i = 0; i != 8; ++i)
{
tracks[i].frequency = 0;
tracks[i].counter = 0;
}
uint16_t counter = 1;
const Note* cursor = music;
const Note* const end = music + sizeof(music) / sizeof(*music);
Note temp;
memcpy_P(&temp, cursor, sizeof(Note));
timer_init();
while (1)
{
int16_t sum = 0;
for (uint8_t i = 0; i != 8; ++i)
if (tracks[i].frequency)
{
int16_t sine = waveform_sine(tracks[i].counter >> 2);
sum += (sine * tracks[i].loudness) >> 3;
tracks[i].counter += tracks[i].frequency;
if (tracks[i].counter >= 1 << 14)
tracks[i].counter -= 1 << 14;
}
if (sum > (1 << 12) - 1)
sum = (1 << 12) - 1;
else if (sum < 1 - (1 << 12))
sum = 1 - (1 << 12);
dac_write_async(512 + (sum >> 4));
if (!--counter)
{
counter = 1205; // 102bpm, 32nd note
for (uint8_t i = 0; i != 8; ++i)
if (tracks[i].frequency && !--tracks[i].duration)
tracks[i].frequency = 0;
while (cursor != end && temp.delay == 0)
{
for (uint8_t i = 0; i != 8; ++i)
if (tracks[i].frequency == 0)
{
tracks[i].frequency = frequency[temp.pitch];
tracks[i].loudness = temp.loudness;
tracks[i].duration = temp.duration;
tracks[i].counter = 0;
break;
}
memcpy_P(&temp, ++cursor, sizeof(Note));
}
--temp.delay;
}
timer_wait();
}
}
如果电路和程序都正确,你会听到一段动听的音乐。能听到音乐的一定是真爱粉,mua~~~
这个程序还没有完结。如果我们想要一些更好听的音色,比如加一些偶次谐波,需要维护多个counter
,多次调用waveform_sine
吗?其实,从waveform_sine
这个名字就可以看出,这种把波形放在数组中取用的方法适用于任何形状的波形,也就是任何音色,只需新写一个函数,从另一个数组中取数据即可,比如waveform_piano
或waveform_guitar
。这些函数的签名一致,可以用函数指针来统一包装,由此可以在结构体Note
中增加一个表示音色的函数指针,实现更丰富的效果。
正弦表和乐曲数据可以在这里下载。
作业
-
通过单片机的datasheet了解I²C总线(即TWI),并分析比较SPI、I²C、USART三种总线。
-
测试595、165和DAC分别可以用哪些SPI模式,并分析原因。
-
查阅资料,了解DAC的性能指标、常见架构、误差类型与来源。
-
比较PWM和DAC实现的LED呼吸灯的效果差别,并分析原因。
-
* 电阻有误差,于是DAC和扬声器负极的参考电压有误差;DAC输出有误差;运算放大器有偏置电压,使输出有误差;种种误差的存在使得扬声器两端难免存在一点直流分量。于是程序中取
512
这个值就不准确了。你有没有办法找到一个值使直流分量最小?电路参数会随温度等环境因素而变化,你有没有办法让程序自动地找到这个值?(为了防止错误的程序导致扬声器损坏,请你拔掉扬声器测试程序。) -
*** 码谱真累。如果你有能力的话,写个MIDI转单片机代码的上位机程序呗!
posted on 2020-02-08 22:13 Jerry_SJTU 阅读(1883) 评论(0) 编辑 收藏 举报