AVR单片机教程——PWM调光

PWM

两位数码管的驱动方式是动态扫描,每一位都只有50%的时间是亮的,我们称这个数值为其占空比。让引脚输出高电平点亮LED,占空比就是100%。

在驱动数码管时,我们迫不得已使占空比为50%,因为不能让两位真正同时地显示不同的数字。但是,我们也可以有意地让LED的占空比不到100%,以降低其亮度。

占空比是可以用程序来调节的。下面的程序允许用户用按键调整蓝色LED的占空比,并通过数码管来显示。

#include <ee1/ee.h>

#define DUTY_MAX 9

int main()
{
    led_init();
    button_init(PIN_NULL, PIN_NULL);
    segment_init(PIN_NULL, PIN_8);
    uint8_t duty = 0;
    while (1)
    {
        if (button_pressed(BUTTON_0) && duty > 0)
            --duty;
        if (button_pressed(BUTTON_1) && duty < DUTY_MAX)
            ++duty;
        segment_dec(duty);
        segment_display(SEGMENT_DIGIT_R);
        for (uint8_t i = 0; i != DUTY_MAX; ++i)
        {
            if (i < duty)
                led_set(LED_BLUE, true);
            else
                led_set(LED_BLUE, false);
            delay(1);
        }
    }
}

duty是一个整数,取值范围为09,分别表示LED的占空比为0/99/9。比如,当占空比为4/9时,在9毫秒的周期中,前4毫秒LED亮,后5毫秒LED不亮。

可以看见,占空比越大,LED亮度也越高。原来,在亮与暗之间,LED还有中间的状态。我们不是通过让引脚输出一个0V和5V之间的电压,而是让引脚电平迅速地在高低之间变化来实现的。

这种通过电平的快速跳变来实现模拟量效果的技术,称为脉冲宽度调制,简称PWM。

定时器

大多数单片机的定时器都可以输出PWM波,外设丰富的AVR单片机自然不例外。上一讲提到定时器0有四种工作模式,后两种就是快速PWM模式与相位修正PWM模式。

在快速PWM模式中,TCNT0寄存器的动作与普通模式相同,但还可以把OCR0A作为上限。对于非反转输出,TCNT0达到上限并清零后,引脚会输出高电平;而当TCNT0OCR0AOCR0B匹配时,OC0AOC0B会分别输出低电平;对于反转输出,前者为低,后者为高。一般使用非反转,输出PWM波的频率为\(f_{CPU} / 256N\)(对于上限为255的情况;\(N\)为分频系数),占空比为\((OCR0x + 1) / 256\)。由于占空比分母256为2的8次方,这个PWM输出是8位分辨率的。

相位修正PWM主要用于电机控制等对PWM波的形状要求比较严格的场合,这里不细讲。定时器1有更多工作模式,定时器2的时钟系统更为丰富,你可以在数据手册中一探究竟。

在占空比公式\((OCR0x + 1) / 256\)中,OCR0x可以取0255的值,因此占空比可以达到1,PWM模式下LED可达最大亮度;占空比不能达到0,因此用PWM控制的LED不能全暗。这有点麻烦,必须关掉PWM才能使LED暗,而不仅仅是往OCR0x中写入一个值了。为了日后使用方便,我们用函数把寄存器操作包装起来(整个库都在做这件事)。

在Atmel Studio中,静态库与可执行程序都属于project,可以并列存在于solution中。在上面软件PWM的程序所属的solution中,点击菜单栏File->New->Project(或Ctrl+Shift+N),选择“GCC C Static Library Project”,命名为“pwm”,在“Solution:”中选择“Add to solution”,“OK”后选择MCU型号,静态库项目就创建好了,默认带有一个library.c文件。

在“Solution Explorer”中,将library.c重命名为oc0a.c。选中“pwm”项目,右键->Add->New Item或菜单栏->Project->Add New Item或Ctrl+Shift+A,选择“Include File”,命名为oc0a.h(通常取相同的名字,但不是必须的)。

这个库需要提供两个函数:oc0a_init用于将OC0A引脚配置为PWM输出,oc0a_pwm设置输出PWM占空比,参数为一个无符号8位整数。

// oc0a.h

#ifndef OC0A_H
#define OC0A_H

#include <stdint.h>

/*
 * 函数:oc0a_init
 * 参数:无
 * 返回:void
 * 功能:将OC0A引脚配置为PWM输出,占空比为0。
 */
void oc0a_init();

/*
 * 函数:oc0a_pwm
 * 参数:uint8_t _duty - 占空比的整数表示
 * 返回:void
 * 功能:将OC0A引脚输出PWM波的占空比设置为(_duty / 256)。
 */
void oc0a_pwm(uint8_t _duty);

#endif

在头文件oc0a.h中,我们定义了这两个函数,并以注释形式提供了说明,包括参数、返回值与功能。

然后,在oc0a.c中提供这些函数的实现。

#include "oc0a.h"
#include <avr/io.h>

void oc0a_init()
{
    PORTB &=   ~(1 << PORTB3); // PB3 low level
    DDRB  |=     1 << DDB3;    // PB3 output mode
    TCCR0A =  0b00 << COM0A0   // normal port operation
           |  0b11 << WGM00;   // fast PWM mode
    TCCR0B =   0b0 << WGM02    // fast PWM mode
           | 0b010 << CS00;    // 8 prescale
}

void oc0a_pwm(uint8_t _duty)
{
#define COMA_MASK (~(0b11 << COM0A0)) // mask for COMnA bits
    if (_duty)                        // fast PWM mode
        TCCR0A = (TCCR0A & COMA_MASK) // protect other bits
               | 0b10 << COM0A0,      // non-inverting mode
        OCR0A  = _duty - 1;           // duty = (OCRnx + 1) / 256
    else                              // turn PWM off
        PORTB &= ~(1 << PORTB3);      // PB3 low level
        TCCR0A = (TCCR0A & COMA_MASK) // protect other bits
               | 0b00 << COM0A0;      // normal port operation
}

实现文件应该首先包含对应的头文件,以确保函数接口一致。

作为底层操作的封装,这些函数中涉及到很多寄存器。对寄存器的操作没有写成直接用一个数字来赋值,而是由多种位运算组合起来,这是单片机编程特有的。比如,PORTB3宏定义在<avr/io.h>中,值为3,意义为PORTB的第3位(最低位为第0位)控制PB3引脚;1 << PORTB3生成一个这一位为1,其余位为0的数;对它取~,得到只有这一位为0,其余位为1的数;让PORTB与这个数进行&=运算,可以保持其他位不变而这一位变成0,这是因为0与一位“与”的结果是0,而1与一位“与”的结果就是那位的值。再比如,COM0A060b00 << COM0A0COM0A1:0两位填00,同理0b11 << WGM00WGM1:011,两数|运算,就把TCCR0A中的这两段同时填好了(参考数据手册查看位定义)。

并且,这样写是有多种原因的:对于PORTB等寄存器,函数只负责其中的一位,而赋值语句会影响其他位;对于OCR0A等寄存器,代码中明确写出每一位的名称与值,可以增强可读性。

如果是开源库,注释是写给想深入了解的用户看的;如果是闭源库,以头文件与库文件的形式发布,注释是写给以后的自己看的;总之,需要有注释。注释的目的是消除读者(包括自己)的疑惑。读者不知道0b010 << CS00的意义,就注明“8分频”,这是数据手册写的;读者不明白为什么OCR0A的赋值语句中需要-1,就把占空比的公式放上去,其中有+1

还需要提醒的是,以上代码的可移植性有些欠缺,因为0b前缀的二进制数是GCC的扩展,不属于C语言标准。最贴近二进制的标准表示方法是十六进制,但是需要手动地转换(在0b00000b11110x00xF之间建立映射,就像涂答题卡时的F-AB到K-BD一样),这也是把寄存器赋值展开写的理由。

呼吸灯

为了测试这个库,我们再新建一个项目,这次选择“GCC C Executable Project”,之后的过程想必你已经做过很多遍了。不同的是引用头文件的写法有点变化,之前写的oc0a.h位于../pwm/目录下,../意为上级目录;以及,需要手动添加这个库,在“Solution Explorer”中该项目的“Libraries”上右键,点击“Add Library”,在“Project Libraries”一页中勾选“pwm”项目;这样就可以使用刚才写的两个函数了。

我们来实现呼吸灯的效果,即LED从暗慢慢变亮,再变暗,像呼吸一样。

#include <ee1/delay.h>
#include "../pwm/oc0a.h"

int main()
{
    oc0a_init();
    int brightness = 0, fadeAmount = 5;
    while (1)
    {
        oc0a_pwm(brightness);
        brightness = brightness + fadeAmount;
        if (brightness <= 0 || brightness >= 255)
            fadeAmount = -fadeAmount;
        delay(30);
    }
}

OC0A引脚连接到开发板左侧RGBW中任意一个,你就会看到对应的LED有呼吸灯的效果。

RGBW

RGBW代表红绿蓝白。理论上,红绿蓝即可组合出所有颜色,而白色的加入即提供了纯正的白光,也能增强整个LED的亮度。

如果你在室内光下观察上面程序的效果,你会发现,尽管变量brightness,所谓亮度,是随时间线性变化的,但是视觉效果上,在整个亮起的过程中,明显是前半段亮度变化快,后面亮度几乎不变。而如果你用手电筒去照着它然后观察,就能感受到后半段的亮度变化。这可能是因为人眼对弱光环境下的强光变化不敏感。

rgbw_set函数解决了这个问题。它不是直接把参数转发给pwm_set,而是用映射后的参数调用;这个映射作为数学上的函数,在x较小时y增长较慢,较大时增长较快,从而抵消人眼的错觉。

// oc0a.c

#include <ee1/delay.h>
#include <ee1/rgbw.h>

void init();
void breathe();
void flash();

int main()
{
    init();
    while (1)
        breathe(), flash();
}

void init()
{
    rgbw_init(PIN_4, PIN_5, PIN_6, PIN_7);
}

void breathe_phase(uint8_t* _status, int8_t* _alter)
{
    for (uint8_t step = 0; step != 200; ++step)
    {
        for (uint8_t which = 0; which != 4; ++which)
            rgbw_set(which, _status[which] += _alter[which]);
        delay(5);
    }
}

void breathe()
{
    uint8_t status[4] = {0, 0, 0, 0};
    int8_t pre[4] = {1, 0, 0, 0};
    int8_t loop[][4] =
    {
        {-1, 1, 0, 0},
        {0, -1, 1, 0},
        {1, 0, -1, 0},
    };
    int8_t post[4] = {-1, 0, 0, 0};
    breathe_phase(status, pre);
    for (uint8_t cnt = 2; cnt--;)
        for (uint8_t pha = 0; pha != sizeof(loop) / sizeof(*loop); ++pha)
            breathe_phase(status, loop[pha]);
    breathe_phase(status, post);
}

void flash_phase(bool* _pattern)
{
    for (uint8_t which = 0; which != 4; ++which)
        rgbw_set(which, _pattern[which] ? 200 : 0);
    delay(500);
}

void flash()
{
    bool extra[4] = {0, 0, 0, 0};
    bool loop[][4] =
    {
        {1, 0, 0, 0},
        {1, 1, 0, 0},
        {0, 1, 0, 0},
        {0, 1, 1, 0},
        {0, 0, 1, 0},
        {1, 0, 1, 0},
    };
    flash_phase(extra);
    for (uint8_t cnt = 2; cnt--;)
        for (uint8_t pha = 0; pha != sizeof(loop) / sizeof(*loop); ++pha)
            flash_phase(loop[pha]);
    flash_phase(extra);
}

这段代码把灯变化的模式用数字表示,而不是用一定参数的函数调用来硬编码,使程序易于修改与扩展。

作业

  1. 阅读数据手册,实现在OC1A引脚上输出12位分辨率的、带相位与频率修正的PWM波。注意占空比为0和1的情况。
  2. 玩玩灯吧!

原文地址:https://www.cnblogs.com/jerry-fuyi/p/12164485.html

时间: 2024-10-13 00:20:48

AVR单片机教程——PWM调光的相关文章

AVR单片机教程——定时器中断

本文隶属于AVR单片机教程系列. ? 中断,是单片机的精华. 中断基础 当一个事件发生时,CPU会停止当前执行的代码,转而处理这个事件,这就是一个中断.触发中断的事件成为中断源,处理事件的函数称为中断服务程序(ISR). 中断在单片机开发中有着举足轻重的地位--没有中断,很多功能就无法实现.比如,在程序干别的事时接受UART总线上的输入,而uart_scan_char等函数只会接收调用该函数后的输入,先前的则会被忽略.利用中断,我们可以在每次接受到一个字节输入时把数据存放到缓冲区中,程序可以从缓

AVR单片机教程——EasyElectronics Library v2.0手册

本文隶属于AVR单片机教程系列. ? adc.h bit.h button.h buzzer.h dac.h delay.h ee.h exin.h exout.h lcd.h ldr.h led.h pin.h pot.h print.h pwm.h rgbw.h rotary.h segment.h switch.h timer.h tone.h uart.h wave.h ? 主要更新: 由于修改了一些接口,与之前版本不完全兼容,主版本号更新为2: 正式支持中断,初步使用回调: UART支

AVR单片机教程——UART进阶

本文隶属于AVR单片机教程系列. ? 在第一期中,我们已经开始使用UART来实现单片机开发板与计算机之间的通信,但只是简单地讲了讲一些概念和库函数的使用.在这一篇教程中,我们将从硬件与软件等各方面更深入地了解UART. USART组件 一直在讲的UART其实是USART组件的一部分,USART比UART多了同步的一部分,但这一部分用得太少(我从来没用过),而且缺乏实例,所以就略过了.然而,单片机的设计者很机智地把这个鸡肋功能升华了一下,USART组件可以支持SPI模式.SPI是一种同步串行总线,

AVR单片机教程——矩阵键盘

本文隶属于AVR单片机教程系列. ? 开发板上有4个按键,我们可以把每一个按键连接到一个单片机引脚上,来实现按键状态的检测.但是常见的键盘有104键,是每一个键分别连接到一个引脚上的吗?我没有考证过,但我们确实有节省引脚的方法. 矩阵键盘 这是一个4*4的矩阵键盘,共有16个按键只需要8个引脚就可以驱动.我们先来看看它的原理. 每个按键有两个引脚,当按键按下时接通.每一行的一个引脚接在一起,分别连接到左边4个端口,称为"行引脚":每一列的另一个引脚接在一起,分别连接到右边的4个端口,称

AVR单片机教程——数字输入

我们已经学习了如何使用按键和拨动开关,不知你有没有好奇 button_down 和 switch_status 等函数是如何实现的.本篇教程带你一探究竟,让我们从按键的原理开始. 在原理图中,按键的符号如下图所示: 符号很简单,就是两个触点上方有一个动片,当按下时与两个触点接触.实际上按键内部的机械结构大体上就是这样,实现的功能是,没有按下时两端断路,按下时两端短路. 还有一种画法是这样的,即电键: 就按键内部的机械结构来说,第一种更加真实,但从电路角度来看,两者没什么区别. 但是我们的开发板上

AVR单片机教程——串口发送

到目前为止,我们的开发板只能处理很小量的数据:读取几个引脚电平,输出几个LED,顶多用数码管显示一个两位数字.至于输入一个指令.输出一条调试信息,甚至用scanf和printf来输入输出,在已经接触过的这些器件上是难以想象的.而本讲"串口发送"与下一讲"串口接收",将打开这一扇大门. 硬件 本讲的主题是UART(Universal Asynchronous Receiver-Transmitter,通用异步收发器),俗称串口.实际上串口是串行接口的统称,在单片机领域

Windows on Device 项目实践 1 - PWM调光

在前一篇文章<Wintel物联网平台-Windows IoT新手入门指南>中,我们讲解了Windows on Device硬件准备和软件开发环境的搭建,以及Hello Blinky项目的演示.在这篇文章开始,我们进入项目实践部分. 由于Windows on Device设计的时候以Galileo为硬件平台,所以其外设接口也是兼容了Arduino的标准.另外,其编程模式也是参考了Arduino中使用的Wiring Language. 1. PWM原理 Pulse Width Modulation

AVR单片机教程——闪烁LED

上次我们把LED点亮了.你可能已经试过把 LED_RED 换成其他灯,也可能已经用 led_on() 把所有LED一起点亮了.但是LED点亮以后,程序就退出了,之后LED一直没有暗,直到没有供电.这一次,我们用程序来控制LED的亮和暗. 新建一个C executable项目,选择ATmega324PA单片机,在项目属性中添加库libee1,将配置改为Release.这是本教程现阶段中每一次新建项目都要做的.我刚才尝试用project template简化,然而设置无法导入. 默认生成的main.

AVR单片机教程——数码管

先解答之前一个思考题:如果不把引脚配置为输出而写高电平,连接LED会怎样? 实验结果是,LED会亮,但相比于输出高电平的情况,亮度很低.这是为什么呢? 通过上一篇教程我们知道,引脚输入输出模式是由寄存器DDRx中DDxn位控制的,可以推断出 pin_mode 函数会改变一个引脚对应的DDxn值,输入为0,输出为1,而其复位后的值为0,即输入,因此如果不把引脚配置为输出,它的模式就是输入.类似地 pin_write 函数会改变PORTxn,其值为函数的第二个参数. 所以不配置输出而写高电平的结果就