AVR单片机教程——示波器
jerry_fuyi 人气:1本文隶属于AVR单片机教程系列。
在用DAC做了一个稍大的项目之后,我们来拿ADC开开刀。在本讲中,我们将了解0.96寸OLED屏,移植著名的U8g2库到我们的开发板上,学习在屏幕上画直线的算法,编写一个示波器程序,使用EEPROM加入人性化功能,最后利用示波器观察555定时器、放大电路、波形变换电路的各种波形。
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
需要连接电容到地;电荷泵需要两个外部电容,连接在C1P
和C1N
、C2P
和C2N
之间;VCOMH
是一个内部电压,需要连接电容到地;VSS
、VLSS
、BGGND
、LS
都接地;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
是时钟信号,D1
和D2
一起是数据信号;RES
是复位信号;CS
是片选信号;D/C
用于指定输入是数据还是指令,在I²C模式下为地址选择,在3线SPI模式下保持低电平;FR
、CL
、CLS
都是时钟信号。
看起来很复杂,但事实上有些信号根本不用管,因为裸屏只有30个引脚,去掉了BS2
、E(RD)
、R/W(WR)
、D[7:3]
、FR
、CL
、CLS
,这些都是不常用的(除了FR
帧同步信号,我觉得有点用)。剩下的你也许需要学,但不是现在,而是在你的项目需要用裸屏的时候,因为那块蓝色的PCB把这些都处理好了,只留下了7个引脚:GND
、VCC
、D0
、D1
、RES
、DC
、CS
。可用的通信模式只有4线SPI、3线SPI和I²C,但已经相当丰富了,可以通过模块背面的电阻来选择,出厂时是4线SPI,也就是我们将要使用的模式。有的模块只支持I²C模式,也就只需要4个引脚了。
在4线SPI模式下,D0
连接单片机USART1的XCK1
,D1
连接TXD1
,CS
连接PB2
,这些是标准SPI的信号;RES
连接PB0
,D/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;0xA1
和0xC8
分别设置segment和common的remap,因为另一份datasheet中指明,显示屏的第一行连接Common 62
,第一列连接Segment 127
;0xAF
开启显示,显示是默认关闭的,需要手动开启;0xB0
到0xB7
设置页面寻址模式下的页面地址,这是默认的寻址模式,我们在循环中先设置地址,再发送128字节的数据,内容是0
到127
,循环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
,延时几百纳秒,使用`<utilhttps://img.qb5200.com/download-x/delay.h>提供的工具,延时精确到微秒,微秒数为参数除以10,由于除以10很慢,改为除以8;#define __DELAY_BACKWARD_COMPATIBLE__ #define F_CPU 25000000UL #include <utilhttps://img.qb5200.com/download-x/delay.h>
_delay_us(arg_int >> 3);
U8X8_MSG_DELAY_10MICRO
,延时几十微秒,同样使用_delay_us
;_delay_us(arg_int * 10);
U8X8_MSG_GPIO_CS
、U8X8_MSG_GPIO_DC
、U8X8_MSG_BYTE_INIT
,分别设置CS
、D/C
、RES
引脚电平,值为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_TRANSFER
、U8X8_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 <utilhttps://img.qb5200.com/download-x/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
设置对比度为0xCF
;0xD9, 0xF1
设置预充电周期,放电阶段时间最短,充电阶段时间最长;0xDB, 0x40
设置VCOMH
电压,亮度与之正相关,但0x40
是一个无效值,这个错误可以追溯到Adafruit的SSD1306库中;0x2E
禁用滚动;0xA4
设置显示内容跟随RAM;第二行是
u8g2_SetPowerSave
发送的指令:0xA6
设置显示不反转;0xAF
开启显示屏,初始化结束;第三行是
u8g2_SendBuffer
发送的指令:0x40
设置起始行为第0行;0x10
和0x00
设置起始列为第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
是相反数的关系;ystep
是y
变化的方向;检查x2 == 255
是为了防止后面出现死循环;第五部分就是Bresenham算法了,根据
swapxy
判断横纵坐标是否需要对换;err < 0
没有等号,这只不过是一个\(0.5\)向上进还是向下舍的问题;当ystep
为-1
时,由于dx
和dy
都是取了绝对值的,计算起来与\(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 <utilhttps://img.qb5200.com/download-x/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
函数把0
到255
的整数映射到63
到16
的整数,把ADC读到的8位数据转换为显示屏上的y坐标。在更新显示的过程中,程序先清除上一次绘制的波形,然后在每相邻两个ADC数据对应的点之间画上直线,连起来称为波形,最后更新波形区域的显示。就是这么简单粗暴,不加任何修饰,是不是很简单呢?
然后我们来加入花里胡哨的功能:
可调的采样率;
暂停功能;
可选的双通道;
可选的自适应。
这些问题背后有一个共同的时间控制问题,我们先来解决。与上一篇一样,我们把定时器中断的代码移动到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 <utilhttps://img.qb5200.com/download-x/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个寄存器:EEARH
和EEARL
,地址寄存器;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
,逐位检查old
和new
中对应的位,如果存在old
中的一位为0
而new
中的对应位为1
,则需要擦除;检查是否需要写入的方法是,如果存在可能被擦除以后的字节中的一位为1
而new
中的对应位为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空间换取耐久度的方法。
眼看着这篇是写不完了,为了不断更先发出来,以后再补完。
最后恭喜我寒假里一个任务都没有完成。下一篇遥遥无期。
加载全部内容