AVR单片机教程——示波器

本文隶属于AVR单片机教程系列。html

 

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

OLED屏

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

能够插在开发板上的是显示屏模块,它由裸屏和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均可以使用。接下来咱们来看总线上的数据。app

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库

U8g2是一个著名的单色显示屏驱动与图形库。“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的使用很简单,Wiki告诉咱们,要首先建立u8g2_t类型的对象,随后每一个函数的第一个参数都是它的指针。先根据显示屏的芯片型号选择合适的设置函数,初始化后就有那么多函数可使用了。

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

移植的核心就在于初始化时注册的两个回调函数。根据Wiki,咱们要提供的两个函数的模板为:

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;
        }

咱们再来细品一下回调这个概念。

可是回调是有必定代价的,本来能够调用肯定的函数,或者直接内联,如今须要使用函数指针了。众所周知,指令也是数据,存储在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才会显示。

picture

回调的另外一个好处是方便插入中间层。好比,我想知道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是一个无效值,这个错误能够追溯到Adafruit的SSD1306库中;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的时候才进行采样、转换、显示等工做就能够了。以及,以上各个选项都要在屏幕的黄色区域显示。

写到这里,我以为你应该先本身试着写写这个程序,而后再往下看。

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

#define PERIOD_MAX 9

uint16_t factor;
uint8_t period;
bool second;
bool pause;
bool adjust;

void init();

void timer_clear();
void timer_wait();

uint8_t adc_get(uint8_t* _channel);

void oled_waveform(uint8_t _data[][128]);
void oled_voltage(uint8_t _vdc, uint8_t _vpp);

void convert_adjust(uint8_t* _data, uint8_t* _result);
void convert_voltage(char* _string, uint8_t _value);

void set_period(uint8_t _period);
void set_second(bool _enable);
void set_adjust(bool _enable);
void set_pause(bool _enable);

int main()
{
    init();
    uint8_t waveform[2][128];
    uint8_t peripheral = 1;
    uint16_t counter = 1;
    uint8_t phase = 0;
    set_period(0);
    set_second(switch_status(SWITCH_0));
    set_adjust(switch_status(SWITCH_1));
    set_pause(false);
    while (1)
    {
        if (!--peripheral)
        {
            peripheral = 200;
            if (button_pressed(BUTTON_0))
            {
                if (period == PERIOD_MAX)
                    set_period(0);
                else
                    set_period(period + 1);
            }
            if (button_pressed(BUTTON_1))
                set_pause(!pause);
            if (switch_changed(SWITCH_0))
                set_second(switch_status(SWITCH_0));
            if (switch_changed(SWITCH_1))
                set_adjust(switch_status(SWITCH_1));
        }
        if (!pause && !--counter)
        {
            counter = factor;
            if (second)
            {
                uint8_t ch;
                uint8_t adc = adc_get(&ch);
                waveform[ch][phase >> 1] = adc;
            }
            else
                waveform[0][phase] = adc_get(NULL);
            ++phase;
            if ((!second && phase == 128) || (second && phase == 0))
            {
                phase = 0;
                uint8_t vol[2];
                convert_adjust(waveform[0], vol);
                if (second)
                    convert_adjust(waveform[1], vol);
                oled_waveform(waveform);
                oled_voltage(vol[0], vol[1]);
                timer_clear();
            }
        }
        timer_wait();
    }
}

void timer_init()
{
    TCCR1A =  0b00 << WGM10; // CTC mode
    TCCR1B =  0b01 << WGM12  // CTC mode
           | 0b001 << CS10;  // no prescaling
    OCR1A  = 124;            // 5us
}

void timer_period()
{
    static const uint16_t factors[PERIOD_MAX + 1] = {
        1, 2, 5, 10, 20, 50, 100, 200, 500, 1000
    };
    if (second)
        factor = factors[period];
    else
        factor = factors[period] << 1;
}

void timer_second()
{
    timer_period();
}

void timer_clear()
{
    TCNT1 = 0;
    set_bit(TIFR1, OCF1A);
    adc_get(NULL);
}

void timer_wait()
{
    while (!read_bit(TIFR1, OCF1A))
        ;
    set_bit(TIFR1, OCF1A);
}

uint8_t adc_count = 1;
uint8_t adc_channel = 0;

void adc_clock(uint8_t _prescaler)
{
    ADCSRA = (ADCSRA & ~(0b111 << ADPS0)) | _prescaler;
}

void adc_init()
{
    ADMUX  =    0b01 << REFS0
           |       1 << ADLAR
           | 0b00000 << MUX0;
    ADCSRA =       1 << ADEN;
}

void adc_period()
{
    if ((!second && period <= 0) || (second && period <= 1)) // single 10us or dual 20us
        adc_clock(0b100);                                    // divide by 16
    else
        adc_clock(0b101);                                    // divide by 32
}

void adc_second()
{
    adc_count = second ? 2 : 1;
    adc_period();
}

uint8_t adc_get(uint8_t* _channel)
{
    set_bit(ADCSRA, ADIF);
    if (_channel)
        *_channel = adc_channel;
    if (++adc_channel >= adc_count)
        adc_channel = 0;
    ADMUX = 0b01 << REFS0 | 1 << ADLAR | adc_channel << MUX0;
    set_bit(ADCSRA, ADSC);
    return ADCH;
}

u8g2_t u8g2;

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:
        UCSR1B =    1 << RXEN1
               |    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);
        UBRR1 = 10;
        break;
    case U8X8_MSG_BYTE_SET_DC:
        u8x8_gpio_SetDC(u8x8, arg_int);
        break;
    case U8X8_MSG_BYTE_START_TRANSFER:
        u8x8_gpio_SetCS(u8x8, 0);
        break;
    case U8X8_MSG_BYTE_SEND:
        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;
        }
        break;
    case U8X8_MSG_BYTE_END_TRANSFER:
        u8x8_gpio_SetCS(u8x8, 1);
        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:
        set_bit(DDRB, 0);
        set_bit(DDRB, 1);
        set_bit(DDRB, 2);
        break;
    case U8X8_MSG_DELAY_NANO:
        break;
    case U8X8_MSG_DELAY_100NANO:
        _delay_us(arg_int >> 3);
        break;
    case U8X8_MSG_DELAY_10MICRO:
        _delay_us(arg_int * 10);
        break;
    case U8X8_MSG_DELAY_MILLI:
        _delay_ms(arg_int);
        break;
    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;
    default:
        return 0;
    }
    return 1;
}

void clear_area(uint8_t x, uint8_t y, uint8_t w, uint8_t h)
{
    u8g2_SetDrawColor(&u8g2, 0);
    u8g2_DrawBox(&u8g2, x, y, w, h);
    u8g2_SetDrawColor(&u8g2, 1);
}

void oled_init()
{
    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);
    u8g2_SetFont(&u8g2, u8g2_font_5x7_mr);
    u8g2_SetFontPosTop(&u8g2);
}

void oled_period()
{
    static const char* const strings[PERIOD_MAX + 1] = {
        "10us ", "20us ", "50us ",
        "100us", "200us", "500us",
        "1ms  ", "2ms  ", "5ms  ",
        "10ms ",
    };
    u8g2_DrawStr(&u8g2, 0, 8, strings[period]);
    u8g2_UpdateDisplayArea(&u8g2, 0, 1, 4, 1);
}

void oled_second()
{
    u8g2_DrawStr(&u8g2, 0, 0, second ? "2" : "1");
    u8g2_UpdateDisplayArea(&u8g2, 0, 0, 1, 1);
}

void oled_adjust()
{
    u8g2_DrawStr(&u8g2, 8, 0, adjust ? "A" : " ");
    u8g2_UpdateDisplayArea(&u8g2, 1, 0, 1, 1);
}

void oled_pause()
{
    static const uint8_t xbm[2][8] = {
        {0b00000000, 0b00000000, 0b00000010, 0b00000110, 0b00001110, 0b00000110, 0b00000010, 0b00000000}, // playing
        {0b00000000, 0b00000000, 0b00001010, 0b00001010, 0b00001010, 0b00001010, 0b00001010, 0b00000000}, // paused
    };
    u8g2_DrawXBM(&u8g2, 16, 0, 4, 8, xbm[pause]);
    u8g2_UpdateDisplayArea(&u8g2, 2, 0, 1, 1);
}

void oled_waveform(uint8_t _data[][128])
{
    clear_area(0, 16, 128, 48);
    uint8_t count = 1;
    uint8_t shift = 4;
    uint8_t base[2] = {63};
    if (second)
    {
        count = 2;
        shift = 5;
        base[0] = 39;
        base[1] = 63;
    }
    for (uint8_t c = 0; c != count; ++c)
    {
        for (uint8_t x = 0; x != 128; ++x)
            _data[c][x] = base[c] - ((_data[c][x] * 3) >> shift);
        for (uint8_t x = 1; x != 128; ++x)
            u8g2_DrawLine(&u8g2, x - 1, _data[c][x - 1], x, _data[c][x]);
    }
    u8g2_UpdateDisplayArea(&u8g2, 0, 2, 16, 6);
}

void oled_voltage(uint8_t _vdc, uint8_t _vpp)
{
    if (!second && adjust)
    {
        static char strings[2][10] = {"Vdc=", "Vpp="};
        convert_voltage(strings[0] + 4, _vdc);
        u8g2_DrawStr(&u8g2, 83, 0, strings[0]);
        convert_voltage(strings[1] + 4, _vpp);
        u8g2_DrawStr(&u8g2, 83, 8, strings[1]);
    }
    else
        clear_area(83, 0, 45, 16);
    u8g2_UpdateDisplayArea(&u8g2, 10, 0, 6, 2);
}

void convert_adjust(uint8_t* _data, uint8_t* _result)
{
    if (!adjust)
        return;
    uint16_t sum = 0;
    uint8_t min = 255, max = 0;
    for (uint8_t x = 0; x != 128; ++x)
    {
        sum += _data[x];
        if (_data[x] < min)
            min = _data[x];
        if (_data[x] > max)
            max = _data[x];
    }
    _result[0] = (sum + 64) >> 7;
    uint8_t pp = _result[1] = max - min;
    uint8_t k = pp ? 255 / pp : 1;
    int16_t b = ((255 - k * pp) >> 1) - min * k;
    for (uint8_t x = 0; x != 128; ++x)
        _data[x] = k * _data[x] + b;
}

void convert_voltage(char* _string, uint8_t _value)
{
    uint16_t mv10 = (_value * 125 + 32) >> 6;
    _string[3] = mv10 % 10 + '0';
    mv10 /= 10;
    _string[2] = mv10 % 10 + '0';
    mv10 /= 10;
    _string[0] = mv10 + '0';
    _string[1] = '.';
    _string[4] = 'V';
    _string[5] = '\0';
}

void init()
{
    button_init(PIN_NULL, PIN_NULL);
    switch_init(PIN_NULL, PIN_NULL);
    adc_init();
    timer_init();
    oled_init();
}

void set_period(uint8_t _period)
{
    period = _period;
    adc_period();
    timer_period();
    oled_period();
    timer_clear();
}

void set_second(bool _enable)
{
    second = _enable;
    adc_second();
    timer_second();
    oled_second();
    timer_clear();
}

void set_adjust(bool _enable)
{
    adjust = _enable;
    oled_adjust();
    timer_clear();
}

void set_pause(bool _enable)
{
    pause = _enable;
    oled_pause();
    timer_clear();
}

你能够先开个定时器观察PWM波,或者翻到下面搭电路观察波形,还能够把本身的手做为输入试试。

EEPROM

采样率和双通道这两个参数有本质上的不一样:双通道是否开启只取决于当时开关是拨到上仍是拨到下,而采样率倒是按键按下的次数累积决定的。所以在复位时,双通道功能的开关会保持,而采样率会重置。若是咱们正在用50μs档观察波形,不当心碰到了下载器致使断电复位,咱们得按两次按键才能恢复到50μs的选项;若是是10ms就更糟糕了。咱们但愿单片机可以记住咱们的选项,这就须要用到一种复位断电都不会丢失数据的存储器——EEPROM。

那为啥不用一样属于非易失性存储的flash呢?由于flash必须以块为单位擦除,而EEPROM能够以字节为单位,这就使得EEPROM更适合于存储示波器参数这样的小数据。另外,咱们须要时刻注意,EEPROM的寿命是有限的,只有10万次耐久,相比之下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之后。若是调用时前一次擦写没有完成,函数会一直等待直到操做完成,而后执行当前擦写。

<avr/eeprom.h>提供了EEPROM的相关工具。函数名带有write的函数实际执行的是擦写操做;update的函数在擦写以前会检查内容是否须要修改,这样能够减小擦写次数。

可是这样仍不完美。EEPROM的10万次耐久指的是擦除和写入都不能超过10万次,在有些状况下咱们能够避免擦除或写入或二者兼有,对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;
}

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

别把本身绕进去,一份来自Atmel官方的application note,AVR101,介绍了一种充分利用EEPROM空间换取耐久度的方法。

眼看着这篇是写不完了,为了避免断更先发出来,之后再补完。

最后恭喜我寒假里一个任务都没有完成。下一篇遥遥无期。