AVR单片机教程——示波器

2019/04/10 10:10
阅读数 16

本文隶属于<a href="https://www.cnblogs.com/jerry-fuyi/p/avr_tutorial.html" target="_blank">AVR单片机教程</a>系列。

 

在用DAC做了一个稍大的项目之后,我们来拿ADC开开刀。在本讲中,我们将了解0.96寸OLED屏,移植著名的U8g2库到我们的开发板上,学习在屏幕上画直线的算法,编写一个示波器程序,使用EEPROM加入人性化功能,最后利用示波器观察555定时器、放大电路、波形变换电路的各种波形。

本讲所需的资料可以在这里下载:<a href="https://pan.baidu.com/s/1nya-SyHvR-YtQcvkrv1nlw" target="_blank">提取码6xtk</a>。

OLED屏

我们使用的是0.96寸OLED屏,它由128*64个像素点构成,上16行为蓝色,下48行为黄色,两部分之间有大约两像素的空隙。虽然有两种颜色,但每个像素点都只能发出一种颜色的光,因此这块OLED屏算作单色屏。

可以插在开发板上的是显示屏模块,它由裸屏和PCB等组成,裸屏通过30 pin的排线焊接在PCB的反面。

在裸屏的内部有一块控制与驱动芯片,型号为SSD1315,与SSD1306兼容,它是外部与像素点之间的桥梁。SSD1315有200多个引脚,其中128个segment和64个common以动态扫描的方式驱动每一个像素点,这就是它为什么必须做在裸屏的内部。除了这些以外,它还有许多电源和控制引脚:

  • VDD是控制逻辑的供电,范围为1.65V到3.5V;VCC是OLED面板驱动电压,范围为7.5V到16.5V;VBAT是内部电荷泵的供电,范围为3.0V到4.5V,VBAT经电荷泵升压后提供给VCC,此时VCC需要连接电容到地;电荷泵需要两个外部电容,连接在C1PC1NC2PC2N之间;VCOMH是一个内部电压,需要连接电容到地;VSSVLSSBGGNDLS都接地;IREF用于控制参考电压。

  • BS[2:0]用于选择接口模式,支持4线SPI、3线SPI、I²C、8位8080和6800;E(RD)R/W(WR)在并行模式下使用;D[7:0]为数据,在SPI模式下,D0是时钟信号,D1是输入数据信号,D2连接D1或地;在I²C模式下,D0是时钟信号,D1D2一起是数据信号;RES是复位信号;CS是片选信号;D/C用于指定输入是数据还是指令,在I²C模式下为地址选择,在3线SPI模式下保持低电平;FRCLCLS都是时钟信号。

看起来很复杂,但事实上有些信号根本不用管,因为裸屏只有30个引脚,去掉了BS2E(RD)R/W(WR)D[7:3]FRCLCLS,这些都是不常用的(除了FR帧同步信号,我觉得有点用)。剩下的你也许需要学,但不是现在,而是在你的项目需要用裸屏的时候,因为那块蓝色的PCB把这些都处理好了,只留下了7个引脚:GNDVCCD0D1RESDCCS。可用的通信模式只有4线SPI、3线SPI和I²C,但已经相当丰富了,可以通过模块背面的电阻来选择,出厂时是4线SPI,也就是我们将要使用的模式。有的模块只支持I²C模式,也就只需要4个引脚了。

在4线SPI模式下,D0连接单片机USART1的XCK1D1连接TXD1CS连接PB2,这些是标准SPI的信号;RES连接PB0D/C连接PB1。芯片在时钟上升沿采样数据信号,SPI模式0或3都可以使用。接下来我们来看总线上的数据。

D/C为低时,总线上传输的是控制指令;当D/C为高时,总线上传输的是显示数据。64行被分为8页,芯片内部有1024字节的显存,每一字节对应一页中的一列,也就是纵向8个像素:

显存支持页面、水平、垂直三种寻址模式,伴随有一个指针,每写入一字节数据,指针就以某种形式增长,类似于我们在C中写的*ptr++

芯片支持很多指令,它们的长度由第一个字节决定,有各自的格式,大致可以分为以下几类:

  • 显存:寻址模式、行列地址、页面地址;

  • 显示:起始行、显示行数、对比度、各种remap、全亮、反转、睡眠、偏移;

  • 电源:IREF电流大小、VCOMH电压阈值、电荷泵开关;

  • 时钟:时钟频率、时钟分频、预充电周期;

  • 滚动:水平滚动、水平垂直滚动、滚动区域、启用禁用滚动;

  • 高级:淡化、闪烁、放大。

对照着datasheet,我们来写几个指令,让屏幕亮起来。

#include <stdarg.h>
#include <avr/io.h>
#include <ee2/bit.h>

void spi_init()
{
    UCSR1B =    1 << TXEN1;
    UCSR1C = 0b11 << UMSEL10
#define              UDORD1 2
           |    0 << UDORD1
#define              UCPHA1 1
           |    0 << UCPHA1
           |    0 << UCPOL1;
    set_bit(DDRD, 3);
    set_bit(DDRD, 4);
}

void spi_send(uint8_t _data)
{
    UDR1 = _data;
    while (!read_bit(UCSR1A, TXC1))
        ;
    set_bit(UCSR1A, TXC1);
}

void oled_init()
{
    spi_init();
    set_bit(DDRB, 0);  // RES
    set_bit(DDRB, 1);  // DC
    set_bit(DDRB, 2);  // CS
    set_bit(PORTB, 2); // CS  high
    set_bit(PORTB, 0); // RES high
}

void oled_control(uint8_t _size, ...)
{
    reset_bit(PORTB, 1); // DC low
    reset_bit(PORTB, 2); // CS low
    va_list args;
    va_start(args, _size);
    for (uint8_t i = 0; i != _size; ++i)
        spi_send(va_arg(args, int));
    va_end(args);
    set_bit(PORTB, 2);   // CS high
}

void oled_data(uint16_t _size, const uint8_t* _data)
{
    set_bit(PORTB, 1);   // DC high
    reset_bit(PORTB, 2); // CS low
    for (const uint8_t* end = _data + _size; _data != end; ++_data)
        spi_send(*_data);
    set_bit(PORTB, 2);   // CS high
}

int main(void)
{
    oled_init();
    oled_control(2, 0x8D, 0x95); // enable charge pump
    oled_control(1, 0xA1);       // segment remap
    oled_control(1, 0xC8);       // common remap
    oled_control(1, 0xAF);       // display on
    uint8_t data[128];
    for (uint8_t i = 0; i != 128; ++i)
        data[i] = i;
    for (uint8_t i = 0; i != 8; ++i)
    {
        oled_control(1, 0xB0 + i);
        oled_data(128, data);
    }
    while (1)
        ;
}

先来看指令:

  • 0x8D, 0x95启用内置电荷泵,将输出电压设置为9.0V;

  • 0xA10xC8分别设置segment和common的remap,因为另一份datasheet中指明,显示屏的第一行连接Common 62,第一列连接Segment 127

  • 0xAF开启显示,显示是默认关闭的,需要手动开启;

  • 0xB00xB7设置页面寻址模式下的页面地址,这是默认的寻址模式,我们在循环中先设置地址,再发送128字节的数据,内容是0127,循环8次,把每一页都填满。

画出的是一个美丽的分形图:

再来看oled_control这个函数。参数列表的最后是...,表示可变参数。在函数调用时,匹配到...的参数需要用<stdarg.h>中的工具取用:

  • va_list是一个类型,创建一个这个类型的变量,表示可变参数列表;

  • va_start是一个宏,第一个参数为va_list变量,第二个为可变参数的数量;

  • va_arg取出可变参数列表中的下一个变量,类型由第二个参数指定;

  • va_end在使用完可变参数后做一些清理工作。

需要提醒的是,编译器无法检查标称的参数数量和类型与实际的是否符合。

移植U8g2库

<a href="https://github.com/olikraus/u8g2" target="_blank">U8g2</a>是一个著名的单色显示屏驱动与图形库。“U”是universal,支持众多显示驱动芯片;“8”是8-bit,单片机与芯片以字节为单位通信;“g”是graphics,有绘制各种图形的函数;“2”是第二代。

文首的资料中包含了U8g2仓库的全部资料,下载于2020年2月9日,你也可以从GitHub上下载。C源代码在文件夹csrc中,包含头文件与实现。为了在我们的项目中包含这些文件,我们在Atmel Studio的Solution Explorer中对项目右键,点击Add→New Folder,命名为“u8g2”,然后右键它并点击Add→Existing Item,选择csrc中的文件,它们就会被拷贝到项目目录下,在代码中可以通过`#include <u8g2/u8g2.h>引用头文件。

U8g2的使用很简单,<a href="https://github.com/olikraus/u8g2/wiki/u8g2setupc" target="_blank">Wiki</a>告诉我们,要首先创建u8g2_t类型的对象,随后每个函数的第一个参数都是它的指针。先根据显示屏的芯片型号选择合适的设置函数,初始化后就有那么多<a href="https://github.com/olikraus/u8g2/wiki/u8g2reference" target="_blank">函数</a>可以使用了。

U8g2没有提供SSD1315的驱动,但由于SSD1315与SSD1306兼容,我们可以选择u8g2_Setup_ssd1306_128x64_noname_f函数。后缀为_f的函数在RAM中设置了整个缓存,共128 * 64 / 8 = 1KB,这样用起来比较方便。

移植的核心就在于初始化时注册的两个回调函数。根据<a href="https://github.com/olikraus/u8g2/wiki/Porting-to-new-MCU-platform" target="_blank">Wiki</a>,我们要提供的两个函数的模板为:

uint8_t u8x8_comm_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    switch (msg)
    {
    case U8X8_MSG_BYTE_INIT:
        break;
    case U8X8_MSG_BYTE_SET_DC:
        break;
    case U8X8_MSG_BYTE_START_TRANSFER:
        break;
    case U8X8_MSG_BYTE_SEND:
        break;
    case U8X8_MSG_BYTE_END_TRANSFER:
        break;
    default:
        return 0;
    }
    return 1;
}

uint8_t u8x8_gpio_delay_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    switch (msg)
    {
    case U8X8_MSG_GPIO_AND_DELAY_INIT:
        break;
    case U8X8_MSG_DELAY_NANO:
        break;
    case U8X8_MSG_DELAY_100NANO:
        break;
    case U8X8_MSG_DELAY_10MICRO:
        break;
    case U8X8_MSG_DELAY_MILLI:
        break;
    case U8X8_MSG_GPIO_CS:
        break;
    case U8X8_MSG_GPIO_DC:
        break;
    case U8X8_MSG_GPIO_RESET:
        break;
    default:
        return 0;
    }
    return 1;
}

现在我们来一一填写其中的语句:

  • U8X8_MSG_GPIO_AND_DELAY_INIT,初始化GPIO与延时;

    set_bit(DDRB, 0);
    set_bit(DDRB, 1);
    set_bit(DDRB, 2);
    
  • U8X8_MSG_DELAY_NANO,延时若干纳秒,不超过100ns,由于CPU周期是40ns,函数调用的时间已经超过了100ns,因此什么都不做;

  • U8X8_MSG_DELAY_100NANO,延时几百纳秒,使用`<util/delay.h>提供的工具,延时精确到微秒,微秒数为参数除以10,由于除以10很慢,改为除以8;

    #define __DELAY_BACKWARD_COMPATIBLE__
    #define F_CPU 25000000UL
    #include <util/delay.h>
    
        _delay_us(arg_int >> 3);
    
  • U8X8_MSG_DELAY_10MICRO,延时几十微秒,同样使用_delay_us

        _delay_us(arg_int * 10);
    
  • U8X8_MSG_GPIO_CSU8X8_MSG_GPIO_DCU8X8_MSG_BYTE_INIT,分别设置CSD/CRES引脚电平,值为arg_int

    case U8X8_MSG_GPIO_CS:
        cond_bit(arg_int, PORTB, 2);
        break;
    case U8X8_MSG_GPIO_DC:
        cond_bit(arg_int, PORTB, 1);
        break;
    case U8X8_MSG_GPIO_RESET:
        cond_bit(arg_int, PORTB, 0);
        break;
    

    以上是第二个函数;

  • U8X8_MSG_BYTE_INIT,通信的初始化,照搬spi_init函数就可以了;

        UCSR1B =    1 << TXEN1;
        UCSR1C = 0b11 << UMSEL10
    #define              UDORD1 2
               |    0 << UDORD1
    #define              UCPHA1 1
               |    0 << UCPHA1
               |    0 << UCPOL1;
        set_bit(DDRD, 3);
        set_bit(DDRD, 4);
    
  • U8X8_MSG_BYTE_SET_DC,设置D/C引脚的电平,这在上面已经写过了,可以通过u8x8_gpio_SetDC来转发;

        u8x8_gpio_SetDC(u8x8, arg_int);
    
  • U8X8_MSG_BYTE_START_TRANSFERU8X8_MSG_BYTE_END_TRANSFER,开始传输和结束传输,即拉低和拉高CS电平;

    case U8X8_MSG_BYTE_START_TRANSFER:
        u8x8_gpio_SetCS(u8x8, 0);
        break;
    case U8X8_MSG_BYTE_END_TRANSFER:
        u8x8_gpio_SetCS(u8x8, 1);
        break;
    
  • U8X8_MSG_BYTE_SEND,发送数据,内容在arg_ptr中,大小为arg_int字节;

        for (const uint8_t* ptr = arg_ptr, *end = ptr + arg_int;
            ptr != end; ++ptr)
        {
            UDR1 = *ptr;
            while (!read_bit(UCSR1A, TXC1))
                ;
            set_bit(UCSR1A, TXC1);
            UDR1;
        }
    

我们再来细品一下回调这个概念。U8g2库不知道OLED屏的各引脚该如何写高低电平,因为这些硬件操作是平台相关的,于是就得问我们如何实现这些操作。它问的方式不是人机交互,而是要求程序员在初始化时注册一个回调函数,这个函数的参数和返回值的类型,所谓接口,包括参数的含义,都由库定义,我们提供的函数是这个接口在我们平台上的实现。库拿到了这个函数以后,在任何它需要硬件操作的时候,就可以调用这个回调函数。通过注册与回调,我们解决了高层对低层的依赖,并使程序容易移植——如果要在另一个单片机平台上驱动OLED,只需重新实现接口,替换掉原来的回调函数,而库的代码可以完全复用。

但是回调是有一定代价的,原本可以调用确定的函数,或者直接内联,现在需要使用函数指针了。众所周知,指令也是数据,存储在flash中;函数是指令序列,它的第一个指令的地址就是函数指针的值。CPU中有一个特殊的寄存器,叫程序计数器(Program Counter,PC),它保存着CPU要执行的指令的地址;函数指针是变量,保存在寄存器中,用函数指针调用函数本质上是把寄存器的内容加载进PC中。

现代CPU都是多级流水线的,CPU在执行一条指令的同时,取指部件会将待执行的指令从flash中取出,这是因为flash的读取往往比CPU慢。但是,遇到从寄存器加载PC的指令时,取指部件不知道下一条指令的位置,必须等待CPU译码、执行后,才能根据PC去取指令,需要额外消耗几个CPU周期。好在这个消耗不大,并且CPU已经足够快,我们很少考虑函数指针与回调带来的overhead。事实上C++的虚函数就是用函数指针实现的,而C++是以运行时效率著称的编程语言。

然后我们就可以开心地画图了!

#include <avr/io.h>
#include <avr/interrupt.h>
#define __DELAY_BACKWARD_COMPATIBLE__
#define F_CPU 25000000UL
#include <util/delay.h>
#include <ee2/bit.h>
#include "u8g2/u8g2.h"

static u8g2_t u8g2;

static uint8_t u8x8_comm_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);
static uint8_t u8x8_gpio_delay_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);

int main(void)
{
    u8g2_Setup_ssd1306_128x64_noname_f(&u8g2, U8G2_R0, u8x8_comm_callback, u8x8_gpio_delay_callback);
    u8g2_InitDisplay(&u8g2);
    u8g2_SetPowerSave(&u8g2, 0);
    u8g2_SetFont(&u8g2, u8g2_font_10x20_mr);
    u8g2_DrawStr(&u8g2, 0, 15, "AVR tutorial");
    u8g2_DrawStr(&u8g2, 0, 31, "by Jerry Fu");
    u8g2_SendBuffer(&u8g2);
	while (1)
        ;
}

u8g2_Setup_ssd1306_128x64_noname_f进行一些本机的初始化;u8g2_InitDisplay给芯片发送初始化序列,就是0x8D, 0x95之类的;u8g2_SetPowerSave关闭显示屏睡眠,也就是开启显示,这些指令都是在函数调用时就发送的。

u8g2_SetFont设置画字符的字体,u8g2_font_10x20_mr是一种16像素高的字体;u8g2_DrawStr在缓存中画字符串,两个数字分别是横纵坐标,在计算机屏幕上y轴是向下的;u8g2_SendBuffer更新显示屏显示,调用后显示屏上就会出现文字了。一定要注意,所有u8g2_Draw*函数都是在缓存中绘图,要调用u8g2_SendBuffer才会显示。

回调的另一个好处是方便插入中间层。比如,我想知道U8g2向OLED屏发送了什么指令,只需简单地修改回调函数:

static uint8_t u8x8_comm_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    static bool control;
    switch (msg)
    {
    // ...
    case U8X8_MSG_BYTE_SET_DC:
        control = !arg_int;
        u8x8_gpio_SetDC(u8x8, arg_int);
        break;
    case U8X8_MSG_BYTE_SEND:
        for (const uint8_t* ptr = arg_ptr, *end = ptr + arg_int;
            ptr != end; ++ptr)
        {
            if (control)
            {
                uart_set_align(ALIGN_RIGHT, 2, '0');
                uart_print_hex(*ptr);
                uart_print_char(' ');
            }
            UDR1 = *ptr;
            while (!read_bit(UCSR1A, TXC1))
                ;
            set_bit(UCSR1A, TXC1);
            UDR1;
        }
        break;
    case U8X8_MSG_BYTE_END_TRANSFER:
        if (control)
            uart_print_line();
        u8x8_gpio_SetCS(u8x8, 1);
        break;
    }
    return 1;
}

static uint8_t u8x8_gpio_delay_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
{
    switch (msg)
    {
    // ...
    case U8X8_MSG_GPIO_RESET:
        if (!arg_int)
            uart_print_string("reset\n");
        cond_bit(arg_int, PORTB, 0);
        break;
    }
    return 1;
}

然后在main中加入uart_init(UART_TX_256, 384);。串口收到以下信息:

reset
AE D5 80 A8 3F D3 00 40 8D 14 20 00 A1 C8 DA 12 81 CF D9 F1 DB 40 2E A4 A6 
AF 
40 10 00 B0 40 10 00 B1 40 10 00 B2 40 10 00 B3 40 10 00 B4 40 10 00 B5 40 10 00 B6 40 10 00 B7 
  • 第一行是u8g2_InitDisplay发送的指令:

    0xAE关闭显示屏;0xD5, 0x80设置时钟频率最高,分频系数为1,也就是显示频率最高;0xA8, 0x3F设置复用比为64,显示64行;0xD3, 0x00设置纵向显示偏移为0;0x40设置显示从第0行开始;0x8D, 0x14启用电荷泵,电压7.5V;0x20, 0x00使用水平寻址模式,但库的作者误认为是页面寻址模式;0xA1设置segment remap;0xC8设置common remap;0xDA, 0x12设置交错common模式;0x81, 0xCF设置对比度为0xCF0xD9, 0xF1设置预充电周期,放电阶段时间最短,充电阶段时间最长;0xDB, 0x40设置VCOMH电压,亮度与之正相关,但0x40是一个无效值,这个错误可以追溯到<a href="https://github.com/adafruit/Adafruit_SSD1306/issues/42" target="_blank">Adafruit的SSD1306库</a>中;0x2E禁用滚动;0xA4设置显示内容跟随RAM;

  • 第二行是u8g2_SetPowerSave发送的指令:

    0xA6设置显示不反转;0xAF开启显示屏,初始化结束;

  • 第三行是u8g2_SendBuffer发送的指令:

    0x40设置起始行为第0行;0x100x00设置起始列为第0列;0xB*设置页面地址为0到7;但是在水平寻址模式下,后3个指令都是没有用的,不信你自己写一个试试。

Bresenham直线算法

给定两个点,如何画一条线段?

用尺画呗,还能怎么画?

但是,第一,计算机没有尺;第二,计算机的屏幕是由像素点组成的,画一条两点之间的线段,实际上是在寻找与理论位置最接近的像素点的集合。我们将要学习的Bresenham算法是解决这个问题的一个经典并且高效的算法,它只涉及整数运算,无需除法,就可以在与两点之间距离成线性关系的时间内,使用常数大小的内存,计算出需要绘制的点的坐标。

这个算法的输入是4个整数$x_1$、$y_1$、$x_2$、$y_2$表示2个坐标,输出是一系列坐标,每计算出一个就绘制它,不存储到数组中。为了方便理解,我们先假设$x_1 < x_2$,$0 \leq k = \frac {y_2 - y_1} {x_2 - x_1} \leq 1$。

我们把像素视为格点,每个像素点都可以用唯一的坐标表示。由于$0 \leq k \leq 1$,每一列都只会有一个像素点是所求直线的一部分。为了求横坐标为$x_0$的一列上的这个点,我们应该计算$(x_1, y_1)$与$(x_2, y_2)$这两点所确定的直线与直线$x = x_0$的交点,然后把交点的纵坐标取整,作为格点也就是要绘制的像素点的纵坐标。

对于两个相差$1$的横坐标,对应精确纵坐标相差$k$,取整后相差$0$或$1$。Bresenham算法就是通过判断这个差值是$0$还是$1$来计算的。我们遍历从$x_1$到$x_2$的$x$,维护两个变量:$y$,表示当前绘制到的纵坐标,初始值为$y_1$;$e$,表示误差,如果把$x$对应的纵坐标确定为$y$,理论值比实际值大了多少,初始值为$0$。

$x$每加$1$,如果$y$不变,根据我们上面的分析,$e$就会增加$k$。当$-0.5 \leq e \le 0.5$时,我们无法找到更精确的$y$,因此$y$不变;当$e \geq 0.5$时,把$y$加上$1$会得到更精确的坐标,那么实际值加上$1$以后,误差也就要减去$1$。

我们用pixel(x, y)表示绘制$(x, y)$这个像素点。以上算法可以用C代码描述:

double e = 0;
for (int x = x1, y = y1; x <= x2; ++x)
{
    if (e >= 0.5)
    {
        ++y;
        e -= 1;
    }
    pixel(x, y);
    e += k;
}

但是这样涉及到浮点数了。我们注意到,$k$是一个有理数,可以通过把所有与$k$相关的数都乘上$k$的分母来把它化为整数。$e$初始值为0,运算都是加上$k$或减去$1$,乘上$k$的分母后就是整数了。$0.5$乘$k$的分母未必是整数,但是取整至多相差$0.5$,也当作整数来处理。与$0$比较比与变量比较更快一些,因此我们把$e$的初值设为$-0.5$乘$k$的分母,然后与$0$比较。这样线性处理后的$e$在以下代码中用$er$表示:

int er = (x1 - x2) >> 1;
for (int x = x1, y = y1; x <= x2; ++x)
{
    if (er >= 0)
    {
        ++y;
        er -= x2 - x1;
    }
    pixel(x, y);
    er += y2 - y1;
}

那么如何把所有的情况化归到符合简化条件的呢?我们结合U8g2的源码来看:

void u8g2_DrawLine(u8g2_t *u8g2, u8g2_uint_t x1, u8g2_uint_t y1, u8g2_uint_t x2, u8g2_uint_t y2)
{
    // part 1
    u8g2_uint_t tmp;
    u8g2_uint_t x,y;
    u8g2_uint_t dx, dy;
    u8g2_int_t err;
    u8g2_int_t ystep;
    uint8_t swapxy = 0;
    /* no intersection check at the moment, should be added... */
    
    // part 2
    if ( x1 > x2 ) dx = x1-x2; else dx = x2-x1;
    if ( y1 > y2 ) dy = y1-y2; else dy = y2-y1;
    if ( dy > dx ) 
    {
        swapxy = 1;
        tmp = dx; dx =dy; dy = tmp;
        tmp = x1; x1 =y1; y1 = tmp;
        tmp = x2; x2 =y2; y2 = tmp;
    }
    
    // part 3
    if ( x1 > x2 ) 
    {
        tmp = x1; x1 =x2; x2 = tmp;
        tmp = y1; y1 =y2; y2 = tmp;
    }
    
    // part 4
    err = dx >> 1;
    if ( y2 > y1 ) ystep = 1; else ystep = -1;
    y = y1;
#ifndef  U8G2_16BIT
    if ( x2 == 255 )
        x2--;
#else
    if ( x2 == 0xffff )
        x2--;
#endif
    
    // part 5
    for( x = x1; x <= x2; x++ )
    {
        if ( swapxy == 0 ) 
            u8g2_DrawPixel(u8g2, x, y); 
        else 
            u8g2_DrawPixel(u8g2, y, x); 
        err -= (uint8_t)dy;
        if ( err < 0 ) 
        {
        y += (u8g2_uint_t)ystep;
        err += (u8g2_uint_t)dx;
        }
    }
}
  • 第一部分是变量定义,intersection那一句注释的意思是,没有检查直线是否需要绘制(U8g2允许设置部分缓存,每次绘制画面的一部分并发送,多次绘制同样的画面,以时间换空间;如果直线不在当前绘制的画面中,后面的计算就不需要了,可以节省时间;这个函数没有做这样的检查);

  • 第二部分先计算$dx = |x_1 - x_2|, dy = |y_1 - y_2|$,然后交换横纵坐标以保证斜率的绝对值不超过$1$;

  • 第三部分判断$x_1$和$x_2$的大小关系,交换两点坐标使$x_1 \leq x_2$,这是为了使后面的for循环有效;

  • 第四部分初始化Bresenham算法需要使用的变量,err与之前代码中的er是相反数的关系;ystepy变化的方向;检查x2 == 255是为了防止后面出现死循环;

  • 第五部分就是Bresenham算法了,根据swapxy判断横纵坐标是否需要对换;err < 0没有等号,这只不过是一个$0.5$向上进还是向下舍的问题;当ystep-1时,由于dxdy都是取了绝对值的,计算起来与$k$取相反数的对应情况没有区别,不过是y变化的方向反了。

示波器

示波器是显示电压波形的仪器。它未必比万用表精确,但能反映出电压随时间变化的情况。我们来制作一个示波器,它有两个通道,采样间隔从10μs到10ms可调,带有自适应功能,即把波形平移放大到便于观测。两个按键用于调整时间间隔,两个开分别用于暂停显示和开启第二通道。这些功能对我们学习模拟电路有帮助。

这么多功能也许有点复杂,我们先从最简单的开始做起,这个版本没有任何花里胡哨的玩意儿,只有一个128*48的波形显示区域,采样率也固定在1kHz,别的什么都没有。

程序的基本思路是,在1ms的定时器中断中记录ADC读到的8位数据(显示屏的垂直分辨率还不到6位,没有必要读10位数据),每当读取到的数据量能填满显示屏时,也就是采样了128次时,处理数据并更新显示:

#include "u8g2/u8g2.h"
#define __DELAY_BACKWARD_COMPATIBLE__
#define F_CPU 25000000UL
#include <util/delay.h>
#include <ee2/bit.h>
#include <ee2/adc.h>
#include <ee2/timer.h>

static uint8_t u8x8_comm_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);
static uint8_t u8x8_gpio_delay_callback(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);
static u8g2_t u8g2;

uint8_t map(uint8_t _value)
{
    return 63 - ((_value * 3) >> 4);
}

void timer()
{
    static uint8_t phase = 0;
    static uint8_t waveform[128];
    waveform[phase++] = adc_read(ADC_0);
    if (phase == 128)
    {
        phase = 0;
        u8g2_SetDrawColor(&u8g2, 0);
        u8g2_DrawBox(&u8g2, 0, 16, 128, 48);
        u8g2_SetDrawColor(&u8g2, 1);
        for (uint8_t i = 1; i != 128; ++i)
            u8g2_DrawLine(&u8g2, i - 1, map(waveform[i - 1]),
                                 i    , map(waveform[i    ]));
        u8g2_UpdateDisplayArea(&u8g2, 0, 2, 16, 6);
    }
}

int main()
{
    u8g2_Setup_ssd1306_128x64_noname_f(&u8g2, U8G2_R0, u8x8_comm_callback, u8x8_gpio_delay_callback);
    u8g2_InitDisplay(&u8g2);
    u8g2_SetPowerSave(&u8g2, 0);
    u8g2_SendBuffer(&u8g2);
    adc_init();
    timer_init();
    timer_register(timer);
    while (1)
        ;
}

map函数把0255的整数映射到6316的整数,把ADC读到的8位数据转换为显示屏上的y坐标。在更新显示的过程中,程序先清除上一次绘制的波形,然后在每相邻两个ADC数据对应的点之间画上直线,连起来称为波形,最后更新波形区域的显示。就是这么简单粗暴,不加任何修饰,是不是很简单呢?

然后我们来加入花里胡哨的功能:

  1. 可调的采样率;

  2. 暂停功能;

  3. 可选的双通道;

  4. 可选的自适应。

这些问题背后有一个共同的时间控制问题,我们先来解决。与上一篇一样,我们把定时器中断的代码移动到main函数中,检测定时器寄存器的标志位来控制时间。ADC的读取间隔是10μs到10ms,都是10μs的倍数,考虑双通道,取定时器的周期为5μs,设置一个软件的分频系数与计数器变量,每若干个周期进行一次ADC转换。按键与开关的读取间隔与往常一样取1ms,用同样的方法使得每200个主循环周期执行一次读取。更新显示是很耗时的,最好在那个周期中把定时器重置,让它重新开始计时。

ADC的时钟是CPU时钟分频得到的,从触发一次读取到获得结果需要13个ADC时钟周期,相比10μs而言是不可忽略的时间,而adc_read函数会等待这段时间然后返回结果,这在ADC采样间隔短的时候会造成时间无法得到控制。为此,与上一篇中给DAC发送数据类似地,我们在循环中读取上一次ADC转换结果并触发一次转换,不去等待它而是在下一次循环中自然地获得其结果。在双通道模式下,要注意转换结果对应的通道是上一次选择的。

ADC时钟的分频系数与ADC的精度是需要权衡的,为了获得尽量精确的结果,我们根据采样间隔来设置分频系数:单通道10μs和双通道20μs,分频系数取16;双通道10μs应取分频系数为8,但这样的话两个通道会严重相互干扰,故放弃这种模式(在这种模式下,显示的波形是未定义的);其余都取32,8位精度下32分频足够了。

实现自适应功能需要放大波形,放大的方法当然不是转换为坐标以后做图像变换,而是放大原始数据然后转换为坐标。具体来讲,是用一次函数$y = k x + b$进行映射,在此之前先遍历数据,计算出合适的$k$和$b$。与此相关的还有数据到y坐标的映射,比起先前的版本需要多考虑双通道的情况。

最后,暂停功能无比简单,只需要设置一个暂停标志,当它为true的时候才进行采样、转换、显示等工作就可以了。以及,以上各个选项都要在屏幕的黄色区域显示。

看到这里,我觉得你应该先自己试着写写这个程序,然后再去资料包中寻找源代码(因为太长,所以不放在正文中了)。

你可以先开个定时器观察PWM波,或者翻到下面搭电路观察波形,还可以把自己的手作为输入试试。

EEPROM

采样率和双通道这两个参数有本质上的不同:双通道是否开启只取决于当时开关是拨到上还是拨到下,而采样率却是按键按下的次数累积决定的。因此在复位时,双通道功能的开关会保持,而采样率会重置。如果我们正在用50μs档观察波形,不小心碰到了下载器导致断电复位,我们得按两次按键才能恢复到50μs的选项;如果是10ms就更糟糕了。我们希望单片机能够记住我们的选项,这就需要用到一种复位断电都不会丢失数据的存储器——EEPROM。

那为啥不用同样属于非易失性存储的flash呢?因为flash必须以块为单位擦除,而EEPROM可以以字节为单位,这就使得EEPROM更适合于存储示波器参数这样的小数据。另外,我们需要时刻注意,EEPROM的寿命是有限的,只有100k次耐久,相比之下flash只有1万次,而SRAM没有限制。

ATmega324PA提供了1024字节的EEPROM。AVR的EEPROM是比较容易使用的,只需4个寄存器:EEARHEEARL,地址寄存器;EEDR,数据寄存器;EECR,控制寄存器。对EEPROM的操作共有3种:读取、擦除和写入。AVR还提供了擦除和写入原子地合并在一起这种操作。

你也许会疑惑,擦除和写入是什么关系呢?写入默认值不就可以擦除,为什么要多此一举呢?这是因为,EEPROM在出厂时所有位都是1,写入只能把位从1变成0,而只有擦除操作才能把位从0变成1,而且必须一个字节的8位一起。换句话说,我们平时讲的写入操作,到EEPROM这里相当于擦除加写入,这也是第4种操作的意义所在。

对照着数据手册,我们可以写几个函数,完成EEPROM的读取、擦除和写入:

void eeprom_wait()
{
    while (EECR & 1 << EEPE)
        ;
}

uint8_t eeprom_read(uint16_t _address)
{
    eeprom_wait();
    EEAR = _address;
    EECR |= 1 << EERE;
    return EEDR;
}

void eeprom_erase(uint16_t _address)
{
    eeprom_wait();
    EEAR = _address;
    EECR = 0b01 << EEPM0 | 1 << EEMPE;
    EECR |= 1 << EEPE;
}

void eeprom_write_only(uint16_t _address, uint8_t _value)
{
    eeprom_wait();
    EEAR = _address;
    EEDR = _value;
    EECR = 0b10 << EEPM0 | 1 << EEMPE;
    EECR |= 1 << EEPE;
}

EEPROM的读取是很快的,只需要几个CPU周期,写入和擦除则慢得多,各需要1.8ms,合并起来的操作需要3.4ms。上面的函数在第一次擦写的时候无需等待,但实际擦写完成是在1.8ms以后。如果调用时前一次擦写没有完成,函数会一直等待直到操作完成,然后执行当前擦写。

<a href="https://www.nongnu.org/avr-libc/user-manual/group__avr__eeprom.html" target="_blank"><avr/eeprom.h></a>提供了EEPROM的相关工具。函数名带有write的函数实际执行的是擦写操作;update的函数在擦写之前会检查内容是否需要修改,这样可以减少擦写次数。

但是这样仍不完美。EEPROM的100k次耐久指的是擦除和写入都不能超过100k次,在有些情况下我们可以避免擦除或写入或两者兼有,对EEPROM友善的同时减少了时间开销。比如,当原来的数据是0b00001111,要变成0b00001100时,就没有必要擦除,因为没有一位原来是0而需要变成1。

所以这个改进版的写入函数需要先读取原数据,再检查是否需要擦除以及是否需要写入,最后根据检查的结果来执行相应的EEPROM操作。具体检查是否需要擦除的方法是,假设原数据为old,新数据为new,逐位检查oldnew中对应的位,如果存在old中的一位为0new中的对应位为1,则需要擦除;检查是否需要写入的方法是,如果存在可能被擦除以后的字节中的一位为1new中的对应位为0,则需要写入。总结一下,我们需要写两个循环,每个循环体执行8遍。

但是直觉告诉我,下面的代码能起到相同的作用:

void eeprom_write(uint16_t _address, uint8_t _value)
{
    uint8_t original = eeprom_read(_address);
    bool need_erase = ~original & _value;
    uint8_t after_erase = need_erase ? 0xFF : original;
    bool need_write = after_erase != _value;
    if (!need_erase && !need_write)
        return;
    eeprom_wait();
    EEAR = _address;
    EEDR = _value;
    EECR = !need_erase << EEPM1
         | !need_write << EEPM0
         |           1 << EEMPE;
    EECR |= 1 << EEPE;
}

然而这样依然不完美。如果我们只需要一个字节,就像这个示波器程序那样,用一个固定字节存储这个参数,可以修改100k次。但是EEPROM共有1024字节,剩下的1023字节呢?没错,我们可以用完一个字节的耐久后用下一个字节,直到全部用完,这样就可以修改1亿次,当传家宝都没问题。问题在于没有办法检测一个字节的耐久是否耗尽。那我们是否可以再设置一个字节来记录写入了多少次?然后还得考虑这个字节的耐久,以及如何检测耐久耗尽以后的错误……

别把自己绕进去,一份来自Atmel官方的application note,<a href="https://www.microchip.com//wwwAppNotes/AppNotes.aspx?appnote=en592140" target="_blank">AVR101</a>,介绍了一种充分利用EEPROM空间换取耐久度的方法。

这种方法的核心是两个循环缓冲区(类似于之前讲过的循环数组):一个是参数缓冲区,存放实际数据,如示波器采样率;另一个是状态缓冲区,使得程序能够辨别参数缓冲区中哪一个单元存放的是有效的数据。

程序维护一个指针(未必是C语言中指针的概念,也许只是一个数组下标,至少能指向一块区域),它同步地指向两个缓冲区中的一格。当参数需要更新时,参数被写入参数缓冲区中此指针所指向的位置的下一格,状态缓冲区中的下一格的内容修改为指针指向内容加上1(255加上1溢出成为0)。在复位后,程序遍历状态缓冲区,当发现下一格不等于当前格加上1时,就表明参数缓冲区中对应位置存放的是有效数据。

有两种特殊情况需要考虑。第一种是程序第一次运行,EEPROM中无有效数据。由于程序烧写时,EEPROM会被擦除,数据全为0xFF,而当缓冲区大小不超过255字节时状态缓冲区中不会出现0xFF,可以据此判断是否存在有效数据。如果没有,就写入一个默认值。

第二种是参数更新到一半时单片机由于电压不稳等原因复位,因为参数更新过程中需要向两个缓冲区各写入一个字节(当然参数还有可能不止一个字节),非原子操作。应对这种可能性的方法是合理安排两次写入的顺序:先写参数,再写状态。如果在状态写入之前复位,复位后程序会判定上一次更新的数据为有效,这是安全的。

const uint8_t buffer_size = 8;
const uint8_t status_offset = 0;
const uint8_t parameter_offset = 8;
const uint8_t default_value = 0;
uint8_t buffer_ptr;

void write_parameter(uint8_t _value)
{
    uint8_t status = eeprom_read(status_offset + buffer_ptr) + 1;
    if (buffer_ptr == buffer_size - 1)
        buffer_ptr = 0;
    else
        ++buffer_ptr;
    eeprom_write(parameter_offset + buffer_ptr, _value);
    eeprom_write(status_offset    + buffer_ptr, status);
}

uint8_t read_parameter()
{
    uint8_t curr = eeprom_read(status_offset + (buffer_ptr = 0));
    for (; buffer_ptr != buffer_size - 1; ++buffer_ptr)
    {
        uint8_t next = eeprom_read(status_offset + buffer_ptr + 1);
        if ((uint8_t)(curr + 1) != next)
        {
            if (buffer_ptr == 0 && curr == 0xFF)
            {
                eeprom_write(parameter_offset + buffer_ptr, default_value);
                eeprom_write(status_offset    + buffer_ptr, 0);
                return default_value;
            }
            else
                return eeprom_read(parameter_offset + buffer_ptr);
        }
        curr = next;
    }
    return eeprom_read(parameter_offset + buffer_ptr);
}

把EEPROM功能整合进示波器程序的工作就交给你来完成吧!

555定时器

555定时器是一款常见的IC,最初在1972年由Signetics设计,现已得到非常广泛的应用。它的主要功能是精准定时,从微秒到小时都能胜任。配合上外置电阻电容等元器件,555定时器可以实现非常丰富的功能,包括非稳态振荡器、单稳态触发器、SR锁存器、施密特触发器等。

555定时器由3个5kΩ电阻(这并非555这个名字的由来)、2个比较器、SR锁存器、NPN晶体管或N沟道MOS管、非门构成。CON可作输入或输出,用于设定THRTRI的参考电压,通常连接一个电容即可。THRTRI是输入,当THR高于CON(2/3VCC)时,SR锁存器清零,输出高电平(因为是反相输出);当TRI低于1/2CON(1/3VCC)时,SR锁存器置位,输出低电平。RST低电平有效,用于把SR锁存器清零。OUT是输出,电平与SR锁存器的输出相反;DIS也是输出,通常用于电容放电,当OUT为低电平时接地,高电平时悬空。

用555搭建的非稳态振荡器(多谐振荡器)可以输出方波,需要两个电阻和一个电容(CON上的电容可以不接;RST也可以不接)。

我们先定性地分析一下。在振荡的过程中,C1上的电压即THRTRI的电压,在1/3VCC到2/3VCC之间摆动。当电压低于1/3VCC时,U02输出高,OUT输出高,DIS不放电,电源通过R1R2C1充电。当充电到2/3VCC时,U01输出高,OUT输出低,DIS接地,C1通过R2向地放电。放电到1/3VCC以后开始循环。

根据理论计算,在一个周期中:

  • 高电平的时长为$t_h = \ln (2) \cdot (R_1 + R_2) \cdot C_1$;

  • 低电平的时长为$t_l = \ln (2) \cdot R_2 \cdot C_1$;

  • 频率为$f = \frac 1 {\ln (2) \cdot (R_1 + 2R_2) \cdot C_1}$;

  • 占空比为$duty = \frac {R_1 + R_2} {R_1 + 2R_2}$,总是大于$50%$。

我们取$R_1 = R_2 = 10k \Omega$,得到频率为481Hz,占空比为2/3:

在上电时或复位(指555的RST从低电平变为高电平)后,电源需要把电容从0充电到2/3VCC,所以第一个高电平会长一些:

放大电路

利用示波器,我们来研究几个模拟电路。555输出的方波信号幅度大,不方便我们研究放大电路,因此我们先用一个电阻和一个电容把方波转换为幅度不大的三角波。我们将在下一节中学习这种电路。

放大电路,放大的是电压或者电流。我们打算把这个信号的电压放大10倍,但是用三极管搭建的、可以把电压放大10倍的电路,输入电阻往往不是很大,而三角波源的输出电阻有100kΩ,当后级输入电阻与前级输出电阻相当或更小时,信号的很大一部分会衰减掉。为此,我们先设置一个可以把电流放大的电路。

为了理解这个电路的工作原理与作用,我们先来分析一下发射极连接的电阻对于输入的影响。三极管的特性是集电极电流是基极电流的$\beta$倍,那么发射极电流就是集电极电流的$(\beta + 1)$倍,集电极电流是发射极电流的$\frac 1 {\beta + 1}$倍,小信号晶体管的$\beta$通常大于100。基极和发射极之间是一个PN结,发射极电压始终比基极电压低0.6V,可以看作串联了一个0.6V的电池,这种电路也因此被称为“射极跟随器”。所以,发射极连接的10kΩ电阻从基极来看相当于一个超过1MΩ的电阻,即输入电阻大于1MΩ。输出电阻则是前级输出的$\frac 1 {\beta + 1}$倍,小于1kΩ。

简而言之,射极跟随器的电压放大倍数为1,输入阻抗大,输出阻抗小。在我们搭建的电路中,前级输出电阻为100kΩ,射极跟随器的输入电阻大于1MΩ,已经足够大了。输出阻抗小于1kΩ,后级电路的输入阻抗应当大于10kΩ。

理论与实际是否相符呢?我们把电路分成直流和交流两部分来看。利用示波器的自适应功能,我们观察到555输出的直流分量大约为2.8V,三极管基极为2.81V,发射极为2.23V;交流部分,基极与发射极的峰峰电压均为0.23V;这些都和理论分析一致。

有了输出阻抗小的信号源,我们可以开始搭建电压放大电路了。

这是一个共射放大电路。输入回路是,从信号源Q1发射极开始,经C3耦合,通过Q2的BE结,从R8回到地;输出回路是,从R7上方的电源开始,经过R7Q2R8回到地,输出信号在Q2的集电极。Q2发射极是两个回路共用的部分,因此称为共射放大电路。

电容的特点是“隔直流,通交流”,C3把输入信号的直流部分与Q2基极电压完全隔开,使后者的直流部分完全由R5R6分压决定,这个电压为$U_b = \frac {R_6} {R_5 + R_6} \cdot V_{CC} = 0.9V$;交流部分可以畅通无阻地通过C3,到达Q2基极。

Q2发射极电压$U_e = U_b - 0.6V = 0.3V$(实际测得0.25V),电流$I_e = \frac {U_e} {R_8} = 0.25mA$,集电极电流$I_c = \frac {\beta} {\beta + 1} \cdot I_e \approx I_e$,电压$U_c = V_{CC} - I_c R_7 = 2.5V$,这些电压与电流是这个共射放大电路的直流工作点。

对交流信号的分析以直流工作点为参考(实际电压或电流减去直流电压或电流)。当输入电压有$u_b$时,发射极电压$u_e = u_b$,电流$i_e = \frac {u_b} {R_8}$,相应地集电极电流$i_c \approx i_e = \frac {u_b} {R_8}$,电压$U_c + u_c = V_{CC} - (I_c + i_c) R_7$,$u_c = - i_c R_7 = - \frac {R_7} {R_8} u_b = -10u_b$。

这样算一通我们得出结论,这个电路可以把电压放大10倍,输出与输入反相。事实上,当三极管的$\beta$较大时,共射放大电路的放大倍数由集电极和发射极的两个电阻决定,$A = \frac {R_c} {R_e}$。输入信号峰峰电压为0.23V,输出为2.27V,与理论计算的倍数相近。

关于这个电路,还有一些需要进一步说明的地方。

输入耦合的部分实际上是由电容C3R5R6并联电阻组成的高通滤波器,其截止频率为$f = \frac 1 {2 \pi R C} = \frac {R_5 + R_6} {2 \pi R_5 R_6 C_3} = 0.88Hz$,远低于波形频率,信号衰减可以忽略。电容选用的是10μF电解电容,电解电容是有极性的,正极应该接在直流电压高的一端。

三极管工作在放大状态的条件是集电极电压高于基极高于发射极(PNP型相反)。直流工作点当然要满足这一条件,叠加上交流电压也要满足,否则波形会失真。当交流输入电压为$u_b$时,基极电压$U_b + u_b$,集电极电压$U_c - 10u_b$,$u_b$不能超过0.14V;另一端,发射极必须有电流,$u_e$大于零,$u_b$不能小于-0.25V。

还记得运放吗?运算放大器擅长运算,电压的线性运算都不在话下。

电路中的电位器可以用开发板上的POT,总阻值为10kΩ。

分析运放电路的核心在于“虚短”和“虚断”的原则。在引入了负反馈的前提下,运放的两个输入端的电压总是相同,是为“虚短”;运放输入端没有电流,是为“虚断”。根据虚短,同相输入和反相输入电压相等,

$$u_P = u_N$$

同相输入端,根据虚断,输入没有电流,这个基于运放的放大电路的输入电阻为无穷大,是非常理想的。反相输入端,设电位器上段电阻为$R_a$,下段为$R_b$,由基尔霍夫电流定律,

$$\frac {V_{CC} - u_N} {R_a} + \frac {u_O - u_N} {R_5} = \frac {u_N} {R_b}$$

代入并化简得

$$u_O = (1 + \frac {R_4 R_5} {R_a R_b}) u_P - \frac {R_5} {R_a} V_{CC}$$

通过旋转电位器,可以调节输出直流分量到大约2.5V,此时最适合观察波形。在我的设备上,POT的电压为2.85V,算出$R_a = 4.3k\Omega, R_b = 5.7k\Omega$,交流放大倍数$A = 1 + \frac {R_4 R_5} {R_a R_b} = 19.2$。输入峰峰电压0.23V,输出4.65V,与理论计算接近。

波形变换

那个被我们放大了10倍甚至20倍的小信号,是怎样产生的呢?555输出的方波经过一个电阻和一个电容的处理后就成为了三角波,这个过程就是波形变换,电阻和电容组成的波形转换电路是RC积分器。

为了理解RC积分器的工作原理,我们先来考虑RC串联电路对于突变输入电压的响应。假设有一个电阻$R$和电容$C$串联,在其两端施加一个电压,在$t = 0$时刻从0跳变到$U$并保持不变。设电路中电流为$i$,电阻和电容上的电压分别为$u_R$和$u_C$,则由电容的电流电压关系,

$$i = C \frac {{\rm d}u_C} {{\rm d}t}$$

又由欧姆定律,

$$u_R = iR$$

代入串联电路电压关系$U = u_R + u_C$,得微分方程

$$u_C + RC \frac {{\rm d}u_C} {{\rm d}t} = U$$

代入初值$u_C \bigg|_{t = 0} = 0$,解得

$$u_C = U(1 - e^{- \frac {t} {\tau}})$$

其中$\tau = RC$称为时间常数。

但是这跟积分有什么关系呢?把上述微分方程变形,

$$u_C = \frac {1} {RC} \int (U - u_C){\rm d}t$$

如果$u_C$的变化相对于$U$可以忽略不计,那么$u_C$就是$U$的交流分量的积分了。在方波的情形下,方波的周期必须远大于$RC$,才能保证$u_C$在它的直流分量附近不大的范围内变动,从而使它近似为对输入的积分。从另一个角度考虑,完整的$u_C$曲线应是指数函数,$\Delta t$远小于$\tau = RC$的一段曲线可以视为直线,这与常数的积分是直线相符。

在我们的电路中,$R = 100k \Omega$,$C = 100nF$,$RC = 0.01s$,大于555输出方波的周期。当555输出低电平时,电容充电,电压下降;输出高电平时,电容放电,电压上升;上升和下降的曲线都近似为直线,形成了三角波。

积分电路可以把方波变成三角波,那么三角波积分后得到什么呢?我们利用积分运算电路来一探究竟。

R8R9C4组成了一个$u_r = 2.5V$电压源,与U3R6C3R7一起组成积分运算电路。我们先忽略R7来分析这个电路。

因为虚短,运放反相输入端与同相输入端保持电压$u_r$,则电阻$R_6$上的电流(以从左向右为正)为$i = \frac {u_i - u_r} {R_6}$。根据虚断,这些电流全部给了$C_3$,则$C_3$上的电压(左减右)为$u_C = \frac {1} {C_3} \int i {\rm d}t = \frac {1} {R_6 C_3} \int (u_i - u_r) {\rm d}。那么运放的输出电压就是$u_O = u_r - u_C = u_r = \frac {1} {R_6 C_3} \int (u_i - u_r) {\rm d}$,若以$u_r$为参考电压,输出就是输入电压对时间的积分除以$R_6 C_3$。

但是这样的电路不太稳定。如果输入电压的直流分量比$u_r = 2.5V$高一点点,那么高的一点点会因为积分的作用不断累积,使输出电压达到地,交流信号就无法输出了。如果低于$u_r$,同理输出会达到$V_{CC}$。这样的情况是不可避免的,前级不可能做到输出非常精确地等于$u_r$。

R7正是为了解决这个问题而生的。C3可以通过R7放电,电压越高,放电电流越大,电压下降也就越快。当C3放电与充电一样快时,电压便不再增加,起到限制直流电压影响的作用。换一种说法,如果没有R7,由于电容隔直通交,对于直流电来说电阻无穷大,增益也无穷大,从而使输出达到饱和。而有了R7,对于直流电,电阻和电容的并联系统的电阻为电阻阻值,增益有限,可以一定程度上消除直流电压的影响。

那么这个R7对交流信号有什么影响呢?更加定量一点,我们借助“容抗”的概念来回答这个问题。对于555输出的481Hz的信号,电容的容抗为$X_C = \frac {1} {2 \pi f C} = 3.31k\Omega$,比$R_6$小几倍,因此对于三角波而言,并联系统中电容起主要作用,电阻的作用可以忽略。

上图是积分电路的输入和输出信号。当输入大于其平均值时,输出电压下降;输入从正变为负时,输出电压最低。

我们观察到,输入波形麻麻赖赖的,而输出波形就很圆润,这是因为一次函数的积分是二次函数。实际上三角波经过积分后就可以当成正弦波来看,在仿真中输出波形的THD为13%,含有丰富的二次和四次谐波。总之,积分电路可以用于波形变换,方波积分后变为三角波,再积分后成为正弦波。

作业

  1. 在OLED屏上显示动态表情。参考:<a href="https://www.cnblogs.com/jerry-fuyi/p/oled_gif.html" target="_blank">做个别出心裁的圣诞礼物</a>。

  2. 了解Bresenhan画圆算法;

  3. * 如果不暂停示波器,波形曲线会左右晃动,难以观察;如果暂停,又无法观察波形随时间的变化情况。你能否让示波器程序自行调整采样频率,使得屏幕上恰好显示一个或两个完整的周期,并且其位置保持不变?

  4. 在一个单片机应用中,程序需要存储一个不超过80字节的字符串。在电源随时可能断开的情况下,如何获得尽可能高的耐久?

  5. 学习使用555定时器搭建单稳态触发器,并根据原理图解释现象。

  6. 设计一个电路,有两个输入$u_1$、$u_2$,输出$\frac {u_1 - u_2} {2} + 2.5V$。

  7. 如何把一定频率的某种波形转换成锯齿波?

原文出处:https://www.cnblogs.com/jerry-fuyi/p/12306817.html

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部