
精华液
我已经创建了许多不同的爱好电子设备,但我有一个奇怪的功能:如果板上有一个声音压电发射器(蜂鸣器),在完成项目的主要工作后,我会开始胡说八道,并让他演奏各种旋律(尽可能) 在冗长的过程结束时加入旋律以引起注意特别有用。 例如,当我建造一个临时曝光相机来照亮光刻胶时,就使用了它。
但是,当我开始寻找网络上AVR的频率生成示例时,由于某种原因,我遇到了庞大或不够简洁的项目,这些项目以纯编程方式实现了声音频率的生成。 然后我决定自己弄清楚...
抒情离题
我的爱好包括在微控制器上创建各种设备,因为这与我的教授不交叉。 活动(软件开发)时,我认为自己是绝对的自学成才,并且在电子领域还不太强。 实际上,我更喜欢PIC微控制器,但是碰巧我积累了一定数量的Atmel AVR微控制器(现在是Microchip)。 立即预订,以免我手中没有AVR。 这是我在Atmel MCU上的第一个项目,即Atmega48pa。 该项目本身执行一些有效负载,但是在这里我将仅描述与声音频率的产生有关的一部分。 产生频率的测试我称为“ buzic”,是蜂鸣器音乐的缩写。 是的,我几乎忘记了:在Habr上有一个昵称为
buzic的用户,我想立即警告该备忘录在任何情况下均不适用于他,以防万一,我立即为使用字母组合“ Buzic”表示歉意。
所以走吧
我从网络上结识了很多示例-所有这些示例都是基于固件主体中最简单的周期构建的,或者是由于定时器的中断而构建的。 但是它们都使用相同的方法来生成频率:
- 为微控制器提供高水平的支持
- 拖延
- 低位馈入微控制器的脚
更改延迟和计时器设置-调整频率。
这种方法不太适合我,因为 我不想编写用于手动控制微控制器脚的代码。 我希望“石头”为我生成声音频率,而我只是设置某些寄存器的值,从而改变它(频率)。
在研究数据表(以下简称DS)时,我仍然找到了我需要的定时器模式-就像您可能已经猜到的那样,该模式是CTC(比较匹配时清除定时器)模式。 稍微地说,由于播放音乐的功能不是主要功能,因此我更愿意为其选择计时器2(SD的第22段)。
众所周知,几乎任何微控制器都具有在计时器上实现的PWM信号生成模式,并且完全是硬件。 但是在此任务中,PWM不适合,因为 硬件只会产生一个频率。 因此,我们需要PFM(脉冲频率调制)。 PFM的一些相似之处是CTC计时器模式(第22.7.2 LH节)。
CTC模式
Atmega48pa微控制器中的计时器2是8位的,也就是说,它从0到255“滴答”,然后绕了一圈。 顺便说一句,计时器可以朝不同的方向前进,但在我们的情况下则不行。 下一个必需的组件是比较单元。 简而言之,该模块是与计时器有关的所有事件的发起者。 事件可能是不同的,例如中断,微控制器某些分支的电平变化等(显然,我们对第二个感兴趣)。 您可能会猜到,比较模块的名称不只是-它还将固件开发人员选择的特定值与当前计时器值进行比较。 如果计时器值达到我们设置的值,则会发生事件。 定时器溢出或复位期间也会发生事件。
好的,我们得出的结论是,在某些时候,计时器和比较模块一起将单片机的脚上的电平独立地改变为相反的电平对我们很方便-从而产生脉冲。第二项任务是设置这些脉冲之间的间隔-即 控制产生的频率。 CTC模式的整体唯一性在于,在此模式下,计时器不会结束(255),但会在达到设定值时重置。 因此,通过更改此值,我们实际上可以控制频率。 例如,如果将比较模块的值设置为10,则微控制器脚上的电平变化将比将其设置(比较模块的值)设置为200
的频率高 20倍。
现在,我们可以控制频率!
铁

微控制器的引脚排列表明,我们需要将蜂鸣器连接到PB3(OC2A)的腿或PD3(OC2B)的腿,因为 OC2A和OC2B的确切含义是,定时器2在这些分支上可以生成信号。
我通常用来连接蜂鸣器的方案是:
因此,我们组装了设备。寄存器
在上一段中,我们决定了脚的选择-这是PB3(OC2A),我们将使用它。 如果您需要PD3,那么对于她来说,一切将是相同的,从故事中可以清楚地看到。
我们将通过更改3个寄存器来配置计时器2:
- TCCR2A-模式设置和行为选择
- TCCR2B-模式设置和计时器分频器(也是FOC位-我们不使用它们)
- OCR2A(用于PD3支脚的情况为OCR2B)-比较模块的值
首先考虑寄存器TCCR2A和TCCR2B

如您所见,我们有3组对我们很重要的位-这些是COM2xx,WGM2x和CS2x系列的位
我们需要更改的第一件事是WGM2x-这是选择生成模式的主要内容-这些位用于选择我们的CTC模式。
注意:很明显,在LH中,“更新OCR0x at”中的错字应该是OCR2x即 代码将如下所示:
TCCR2A = _BV(WGM21) ;
如您所见,尚未使用TCCR2B。 WGM22应该为零,但已经为零。
下一步是配置COM2xx位,更准确地说是COM2Ax- 我们使用支脚PB3(对于PD3,COM2Bx的使用方式相同)。 PB3支路会发生什么取决于他们。
COM2xx位取决于我们使用WGM2x位选择的模式,因此我们必须在LH中找到相应的部分。 因为 我们有CTC模式,即 不是PWM,那么我们正在寻找“比较输出模式,非PWM”板,它是:

在这里,您需要选择“切换”-以便当计时器达到设定值时,腿上的水平变为相反。 恒定的电平变化并实现我们所需频率的生成。
因为 COM2xx位也位于TCCR2A寄存器中-只有它改变了:
TCCR2A = _BV(COM2A0) | _BV(WGM21) ;
自然地,您还需要选择带有CS2x位的分频器,当然,将PB3脚设置为输出...但是我们还没有这样做,因此当我们打开MK时,我们不会以难以理解的频率听到刺耳的声音,但是当我们进行所有其他设置时,踩脚退出-将在下面说明。
因此,让我们对初始化进行全面介绍:
#include <avr/io.h> //set bit - using bitwise OR operator #define sbi(x,y) x |= _BV(y) //clear bit - using bitwise AND operator #define cbi(x,y) x &= ~(_BV(y)) #define BUZ_PIN PB3 void timer2_buzzer_init() { // PB3 cbi(PORTB, BUZ_PIN); // PB3 , cbi(DDRB, BUZ_PIN); // TCCR2A = _BV(COM2A0) | _BV(WGM21) ; // ( ) OCR2A = 0; }
我使用了cbi和sbi宏(在网络上某个地方监视)来设置各个位,并以这种方式保留它。 当然,这些宏已放置在头文件中,但为清楚起见,我将其放在此处。
计算音符的频率和持续时间
现在我们来探讨这个问题的实质。 不久前,一些音乐家的熟人试图将有关音乐人员的某些信息带入我的程序员的大脑,我的大脑几乎快要沸腾了,但我仍然从这些对话中汲取了很多有用的东西。
我立即警告您-可能会出现很大的误差。
- 每个度量包括4个季度
- 每个旋律都有节奏-即 每分钟的此类季度数
- 每个音符及其部分1 / 2、1 / 3、1 / 4等都可以作为一个整体演奏。
- 每个音符当然都有一定的频率
我们检查了最常见的情况,实际上,至少对于我来说,那里的一切都更加复杂,因此,我不会在这个故事的框架内讨论这个主题。
好吧,好的,我们将使用已有的东西。 对我们来说,最重要的事情是最终获得音符的频率(实际上是OCR2A寄存器的值)及其持续时间(例如,以毫秒为单位)。 因此,有必要进行一些计算。
因为 我们处于编程语言的框架之内,旋律最容易存储在数组中。 以格式设置数组每个元素的最合乎逻辑的方法是note + duration。 必须计算以字节为单位的元素大小,因为我们是在微控制器下编写的,并且这里的资源非常紧张-这意味着以字节为单位的元素大小必须足够。
频次
让我们从频率开始。 因为 我们有8位定时器2,OCR2A比较寄存器也是8位。 也就是说,我们的旋律数组元素将已经至少2个字节,因为您仍然需要保存持续时间。 实际上,这种工艺的限制是2个字节。 温和地说,我们还是听不到好声音,而且花更多的字节是不合理的。
因此,我们停止在2个字节处。实际上,在计算频率时,还会出现另一个大问题。如果您查看音符的频率,我们将看到它们被分为八度。

对于大多数简单的旋律,3个八度音阶就足够了,但是我决定闪避并实现6个:大,小和下4个。
现在,让我们脱离音乐,回到微控制器编程的世界。
AVR(以及绝大多数其他MK)中的任何计时器都与MK本身的频率有关。 我的电路中石英的频率为16Mhz。 在我的情况下,相同的频率由F_CPU“定义”确定为等于16000000.在TCCR2B寄存器中,我们可以选择分频器,以使定时器2不会以每秒16000000次的疯狂速度“滴答”,但要慢一些。 如上所述,分频器由CS2x位选择。
注意:很明显,在LH中,错字而不是“ CA2x”应该是CS2x问题出现了-如何配置分频器?
为此,您需要了解如何计算OCR2A寄存器的值。 计算起来非常简单:
OCR2A = F_CPU /(石英分频器* 2)/音符频率例如,在第一个八度和除数256之前记下音符(CS22 = 1,CS21 = 1,CS20 = 0):
OCR2A = 16000000 /(256 * 2)/ 261 = 119
我将立即说明乘2的乘数来自哪里。事实是,我们通过COM2Ax寄存器选择了“切换”模式,这意味着脚的电平从低到高(反之亦然)的变化将在定时器的2次传递中发生:计时器达到OCR2A的值并更改微控制器的脚,例如,从1变为0,仅在第二圈将0变回1。因此,每个全波分别需要2圈计时器,除数必须乘以2,否则我们只能得到音符频率的一半。
因此,上述不幸...
如果在大八度音阶之前记下音符,然后将除数保留为256:
OCR2A = 16000000 /(256 * 2)/ 65 = 480 !!!
480-该数字明显大于255,并且物理上不适合8位OCR2A寄存器。怎么办 显然是更改了分频器,但是如果将分频器设置为1024,则使用大八度音阶,一切都会很好。 问题将从高八度开始:
洛杉矶4八度-OCR2A = 16000000 /(1024 * 2)/ 3520 = 4
尖锐的第四个八度音程-OCR2A = 16000000 /(1024 * 2)/ 3729 = 4
OCR2A值不再不同,这意味着声音也将不再不同。只有一种方法:对于音符的频率,不仅需要存储OCR2A寄存器的值,还需要存储石英分频器的位。 因为 对于不同的八度音阶,石英分频器的值将有所不同,我们需要在TCCR2B寄存器中对其进行设置!现在一切都准备就绪-最后我解释了为什么不能立即在timer2_buzzer_init()函数中填写除数值。
不幸的是,分频器又增加了3位。 并且它们将必须放在旋律数组元素的第二个字节中。
宏万岁 #define DIV_MASK (_BV(CS20) | _BV(CS21) | _BV(CS22)) #define DIV_1024 (_BV(CS20) | _BV(CS21) | _BV(CS22)) #define DIV_256 (_BV(CS21) | _BV(CS22)) #define DIV_128 (_BV(CS20) | _BV(CS22)) #define DIV_64 _BV(CS22) #define DIV_32 (_BV(CS20) | _BV(CS21)) #define NOTE_1024( x ) ((F_CPU / (1024 * 2) / x) | (DIV_1024 << 8)) #define NOTE_256( x ) ((F_CPU / (256 * 2) / x) | (DIV_256 << 8)) #define NOTE_128( x ) ((F_CPU / (128 * 2) / x) | (DIV_128 << 8)) #define NOTE_64( x ) ((F_CPU / (64 * 2) / x) | (DIV_64 << 8)) #define NOTE_32( x ) ((F_CPU / (32 * 2) / x) | (DIV_32 << 8))
在笔记的持续时间内,我们只剩下5位,因此让我们计算一下持续时间。
持续时间
首先,您需要将速度值转换为临时单位(例如,以毫秒为单位)-我这样做是这样的:
乐器的持续时间,以毫秒为单位=(60,000 ms * 4个季度)/速度值。因此,如果我们谈论拍子部分,则需要对这个值进行除法,起初我认为分频器通常向左移动就足够了。 即 代码是这样的:
uint16_t calc_note_delay(uint16_t precalced_tempo, uint16_t note) { return (precalced_tempo / _BV((note >> 11) & 0b00111)); }
即 我用了3个位(剩下的5个位),并从2度到1/128的范围内获得了部分音乐节拍。 但是当我给一个朋友问我在我的铁片上写些铃声时,有人问为什么没有1/3或1 / 6th,我开始思考...
最后,我制作了一个棘手的系统来获得这样的持续时间。 剩下的2倍中的一位-我花了乘以3的符号作为移位后获得的时钟分频器。 最后一位是指示是否有必要减去1。这很难描述,更容易看到代码:
uint16_t calc_note_delay(uint16_t precalced_tempo, uint16_t note) { note >>= 11; uint8_t divider = _BV(note & 0b00111); note >>= 3; divider *= ((note & 0b01) ? 3 : 1); divider -= (note >> 1); return (precalced_tempo / divider); }
然后,我“定义”了所有可能的注释(小于1/128的注释除外)。
他们在这里 #define DEL_MINUS_1 0b10000 #define DEL_MUL_3 0b01000 #define DEL_1 0 #define DEL_1N2 1 #define DEL_1N3 (2 | DEL_MINUS_1) #define DEL_1N4 2 #define DEL_1N5 (1 | DEL_MINUS_1 | DEL_MUL_3) #define DEL_1N6 (1 | DEL_MUL_3) #define DEL_1N7 (3 | DEL_MINUS_1) #define DEL_1N8 3 #define DEL_1N11 (2 | DEL_MUL_3 | DEL_MINUS_1) #define DEL_1N12 (2 | DEL_MUL_3) #define DEL_1N15 (4 | DEL_MINUS_1) #define DEL_1N16 4 #define DEL_1N23 (3 | DEL_MUL_3 | DEL_MINUS_1) #define DEL_1N24 (3 | DEL_MUL_3) #define DEL_1N31 (5 | DEL_MINUS_1) #define DEL_1N32 5 #define DEL_1N47 (4 | DEL_MUL_3 | DEL_MINUS_1) #define DEL_1N48 (4 | DEL_MUL_3) #define DEL_1N63 (6 | DEL_MINUS_1) #define DEL_1N64 6 #define DEL_1N95 (5 | DEL_MUL_3 | DEL_MINUS_1) #define DEL_1N96 (5 | DEL_MUL_3) #define DEL_1N127 (7 | DEL_MINUS_1) #define DEL_1N128 7
全部放在一起
总计,我们的铃声数组元素具有以下格式。
- 1bit:延迟分频器-1
- 1bit:延迟分配器* 3
- 3bit:延迟分频器移位
- 3bit:CPU时钟分频器
- 8位:OCR2A值
只有16位。
亲爱的读者,如果您愿意,您可以自己幻想格式,也许会诞生比我更强大的功能。
我们忘记添加空白便笺,即 沉默。 最后,我解释了为什么在一开始,在timer2_buzzer_init()函数中,我们专门将PB3支路设置为输入而不是输出。 更改寄存器DDRB,我们将打开和关闭“静音”或整个合成的播放。 因为 我们不能使用值为0的注释-这将是“空”注释。
定义缺少的宏和启用声音生成的功能:
#define EMPTY_NOTE 0 #define NOTE(delay, note) (uint16_t)((delay << 11) | note) ........ ........ ........ void play_music_note(uint16_t note) { if (note) { TCCR2B = (note >> 8) & DIV_MASK; OCR2A = note & 0xff; sbi(DDRB, BUZ_PIN); } else cbi(DDRB, BUZ_PIN); }
现在,我将向您展示根据此原理编写的铃声是什么样的:
const uint16_t king[] PROGMEM = { NOTE(DEL_1N4, MI3), NOTE(DEL_1N4, FA_3), NOTE(DEL_1N4, SOL3), NOTE(DEL_1N4, LA3), NOTE(DEL_1N4, SI3), NOTE(DEL_1N4, SOL3), NOTE(DEL_1N2, SI3), NOTE(DEL_1N4, LA_3), NOTE(DEL_1N4, FA_3), NOTE(DEL_1N4, LA_3), NOTE(DEL_1N4, EMPTY_NOTE), NOTE(DEL_1N4, LA3), NOTE(DEL_1N4, FA3), NOTE(DEL_1N2, LA3), NOTE(DEL_1N4, MI3), NOTE(DEL_1N4, FA_3), NOTE(DEL_1N4, SOL3), NOTE(DEL_1N4, LA3), NOTE(DEL_1N4, SI3), NOTE(DEL_1N4, SOL3), NOTE(DEL_1N4, SI3), NOTE(DEL_1N4, MI4), NOTE(DEL_1N4, RE4), NOTE(DEL_1N4, SI3), NOTE(DEL_1N4, SOL3), NOTE(DEL_1N4, SI3), NOTE(DEL_1N2, RE4), NOTE(DEL_1N2, EMPTY_NOTE), };
播放铃声
我们还有一项任务-演奏旋律。 为此,我们需要“遍历”铃声阵列,保持相应的暂停并切换音符的频率。 显然,我们需要另一个计时器,顺便说一下,可以像我通常那样将其用于其他常规任务。 此外,您可以在此计时器中断时或在主循环中在数组元素之间切换,并使用计时器计算时间。 在此示例中,我使用了第二个选项。
如您所知,任何MK程序的主体都包含一个无限循环:
int main(void) { for(;;) {
在其中,我们将沿着数组“运行”。 但是我们需要一个类似于WinApi中的GetTickCount的函数,该函数返回Windows操作系统上的毫秒数。 但是自然地,在MK的世界中,没有“开箱即用”的功能,因此我们必须自己编写。
计时器1
为了计算时间间隔(我故意不写毫秒,稍后您会明白为什么),我将定时器1与已知的CTC模式结合使用。 定时器1是一个16位定时器,这意味着比较模块的值已经由2个8位寄存器OCR1AH和OCR1AL指示-分别用于高字节和低字节。 我不想详细描述计时器1的工作,因为这不适用于本备忘录的主要主题。 因此,我只会用两个字告诉您。
我们实际上需要3个功能:
- 计时器初始化
- 计时器中断处理程序
- 返回时间间隔数的函数。
代码C文件 #include <avr/io.h> #include <avr/interrupt.h> #include <util/atomic.h> #include "timer1_ticks.h" volatile unsigned long timer1_ticks; // ISR (TIMER1_COMPA_vect) { timer1_ticks++; } void timer1_ticks_init() { // // CTC , 8 TCCR1B |= (1 << WGM12) | (1 << CS11); // OCR1AH = (uint8_t)(CTC_MATCH_OVERFLOW >> 8); OCR1AL = (uint8_t) CTC_MATCH_OVERFLOW; // TIMSK1 |= (1 << OCIE1A); } unsigned long ticks() { unsigned long ticks_return; // , ticks_return // ATOMIC_BLOCK(ATOMIC_FORCEON) { ticks_return = timer1_ticks; } return ticks_return; }
在显示带有一定常量CTC_MATCH_OVERFLOW的头文件之前,我们需要及时返回
“ Duration”部分,并确定对旋律最重要的宏,该宏将计算旋律的速度。 我等待了很长时间才能确定它,因为它直接与播放器相连,因此与定时器1相连。
初步近似,如下所示(请参见“持续时间”部分中的计算):
#define TEMPO( x ) (60000 * 4 / x)
我们在输出中获得的值必须随后将第一个参数替换为
calc_note_delay函数。 现在仔细看一下calc_note_delay函数,即该行:
return (precalced_tempo / divider);
我们看到通过计算TEMPO宏获得的值除以某个除数。 回想一下,我们定义的最大除数为
DEL_1N128 ,即 除数将为128。
现在,让我们使用等于240的通用速度值并进行一些简单的计算:
60000 * 4/240 = 1000太恐怖了! 我们只有1000,因为此值仍将除以128,所以我们冒着高比率滑落到0的风险。
这是持续时间的第二个问题。怎么解决呢? 显然,为了扩展速度值的范围,我们需要以某种方式增加通过计算TEMPO宏获得的数量。 这只能以一种方式完成-摆脱毫秒并以特定时间间隔计算时间。 现在您了解了为什么我一直都避免在故事中提及“毫秒”。 让我们定义另一个宏:
#define MS_DIVIDER 4
让我们将其除以毫秒-例如,将毫秒除以4(250μs)。
然后,您需要更改TEMPO宏:
#define TEMPO( x ) (60000 * MS_DIVIDER * 4 / x)
现在,出于良知,我将提供用于定时器1的头文件:
#ifndef TIMER1_TICKS_H_INCLUDED #define TIMER1_TICKS_H_INCLUDED #define MS_DIVIDER 4 #define CTC_MATCH_OVERFLOW ((F_CPU / 1000) / (8 * MS_DIVIDER)) void timer1_ticks_init(); unsigned long ticks(); #endif
现在,我们可以更改MS_DIVIDER来调整任务的范围-我的代码中有4个-这足以完成任务。
注意:如果仍有任何任务“绑定”到计时器1,请不要忘记将它们的时间控制值乘以/除以MS_DIVIDER。转盘
现在让我们来编写播放器。 我认为代码和注释将使一切变得清晰。
int main(void) { timer1_ticks_init();
结论
我希望这份备忘录对尊敬的读者和我自己都是有用的,以免忘记演奏音乐的所有细微差别,以防万一我再次拿起AVR微控制器。
好吧,传统上是视频和源代码(我是在代码块环境中开发的,因此不要害怕晦涩的文件):
源代码