TinyAVR 1-series是Microchip于2018年推出的AVR单片机系列,定位是新一代的8位单片机,ATtiny3217是其中最高端的一款。相比于ATmega328P那个时代的AVR,ATtiny3217不仅增强了组件的功能,更是加入了EVSYS(Event System)和CCL(Configurable Custom Logic)这两大支撑CIP(Core Independent Peripherals)的组件,使得硬件中的消息传递十分灵活。对于我来说,有吸引力的是它带来的可玩性。
可惜,ATtiny3217只提供VQFN-24封装,而且国内渠道不太好买到,另外还没有下载器。第三方开发板目前还没有,官方的则价格很贵,下不了手。
WS2812B是Worldsemi(华彩威)的一款内置控制电路的LED,RGB三种颜色均有8位256级亮度。WS2812B的数据信号为单线归零码,带整形输出,(理论上)可以支持无限级联。单片机PWM控制RGB灯占用大量定时器资源,以旧AVR型号为例,RGB三个通道至少需要2个定时器,而定时器总共不过3个。在各种外置控制方案中,WS2812B整合了控制逻辑,更加小巧。
WS2812B以5050、灯带和软屏等形式出售,很容易获得,自己用5050设计PCB也很方便。
有一天我读到一篇application note,其中有用ATtiny1617(3217同系列)的CCL实现WS2812B的总线。我起初感到十分新奇,在看懂了实现原理之后,我直接拍手叫好——它利用SPI的SCK
和MOSI
信号和一个定时器的波形输出的逻辑运算获得了能驱动WS2812B的信号。这让我对ATtiny3217的执念更加深了。
下面先来介绍一下今天的出场嘉宾。
ATtiny3217 Curiosity Nano
半年前,趁着可以用公款的时机,我拔草了种草已久的开发板。
在某宝买的,一块那么小的开发板竟然要105元。还有一款ATtiny3217 Xplained Pro,要300+,还不包括扩展板,超出了预算限制。店家只有现货1块,队友买第2块的时候商家告知要去订货,于是就退款了。
板上有两颗单片机:一个ATSAMD21E18,用作电源控制器、调试器、虚拟串口等;另一个当然是ATtiny3217啦。
没错,调试器,这对于AVR是不多见的,因为调试器只有Microchip卖,它又卖得很贵——我们通常只用USBasp下载器。新的AVR系列都用UPDI(Unified Program and Debug Interface)来调试,包括烧写,USBasp是不支持的(但好像能支持xmega的PDI),而Curiosity Nano不仅能给板上的单片机调试,还可以通过官方推荐的硬改来调试外部单片机。
开发板两边的排针孔之间有16 mil的错位,排针用力插进去就能连接牢固,无需焊接。
ATtiny3217虽然从名字上看属于tiny系列,实际上比作为mega的ATmega328P和ATmega324PA等老产品强不少,至少跟xmega是一个级别的。在它之上有megaAVR 0-series(以ATmega4809为代表)系列和DA/DB系列,都是新产品。
ATtiny3217拥有32 KB flash、256字节EEPROM和2 KB SRAM。新产品的EEPROM不是真正的EEPROM,而是在HEF(high-endurance flash)中模拟出来的,由NVMCTRL提供字节粒度的读写。(BTW:Microchip的PIC系列先开始这么做的;EEPROM成本较高,我在多款单片机中看到了用flash取代EEPROM的趋势。)
CPU方面,0-/1-series都用AVRxt指令集(见AVR® Instruction Set Manual),相比328的AVRe+改进了指令周期数,主要是写RAM更快,使CALL
(子过程调用)、ST
(写RAM)、PUSH
(压栈)、SBI
和CBI
(I/O寄存器的位操作)各减少一个周期。其中PUSH
是最值得关注的,因为它大幅缩短了从事件触发到用户中断代码开始执行的间隔。(一个不太典型的中断disassembly见AVR单片机教程——定时器中断,它不典型在push
太少,一般至少十几个。)
时钟终于不用通过熔丝位设置了,CLKCTRL可以运行时切换时钟源。中断也终于有两个优先级了,但有很多限制。
外设方面,首先是从xmega开始,寄存器就以struct
来组织,比如以前设置PB6
为输出是DDRB |= 1 << 6
,现在是PORTB.DIR |= 1 << 6
或PORTB.DIRSET = 1 << 6
。(xmega以前的AVR的寄存器定义是各单片机中做得最差的之一,就算我已经写过几十遍定时器1 ms中断,每次写之前还是得查datasheet才能知道WGM0[2:0]的哪个组合是CTC模式。但凡稍微正常一点的头文件都会给一个TC0_WGM_CTC
之类的宏吧。
The Amazing $1 Microcontroller:
其实他们明明可以把这些宏定义补上去的。)
每个外设都是新的,不仅是寄存器组织变了,功能也有很大改进:
GPIO:以
DIRSET
等寄存器和虚拟端口两种方式支持位操作;一些组件的输入输出信号对应两组引脚,可以整体切换。定时器:16位TCA作PWM输出、2个16位TCB主要作输入、12位TCD生成两路同步PWM,还有一个16位RTC。
总线:USART中的fractional baud rate generator可以处理主频和波特率非整数倍的情况;SPI有了缓冲区;I²C支持1 MHz的Fm+,主机和从机可以在两组引脚上单独工作。
模拟:双10位ADC,其中一个会在需要时被电容触摸控制器占用,可通过随机延时消除任意频率的干扰;三个8位DAC,其中一个可以输出到外部;三个模拟比较器。
CIP:CCL用组合与时序逻辑实现事件的组合,EVSYS控制组件之间的连接。
针对CIP举个例子:按键按下时触发ADC转换,要求按键有消抖。常规的做法是每间隔一段时间读一次按键,用一定的算法消抖,判断按下时开始ADC转换;而借助CIP,这个功能可以这样实现:
按键的电平又GPIO读入,RTC产生一定频率的时钟,两者通过EVSYS接到CCL的LUT上(look-up table,可以实现任意3输入的组合逻辑,这里只用了按键一个输入),LUT输出接滤波器(filter,其输出在连续两次输入相同时才会更新),再通过EVSYS接到ADC触发转换。这些过程都是不需要CPU干预的,CPU此时应该处于一种睡眠状态,或在执行其他耗时的操作。ADC转换完成后产生中断,这才需要CPU执行相应代码。
WS2812B
WS2812B的信号是单线的,一方面这简化了灯带的设计,对级联也比较友好,但另一方面这种信号不是任何一种常见的总线,也不能由常见总线信号通过简单变换得到,这带来了一些困难。
每一位都是先高电平后低电平,0
和1
的差别在于高低电平的时间不同,0
的高电平时间比较短。允许的时间范围都是比较宽的。通常每一位都是等长的,那么一位的时间范围为1.16 μs到1.38 μs。
每个灯有4个引脚:VCC
、GND
、DIN
、DO
。DO
上的信号是DIN
信号除了前24个bit以外的部分,这24个bit以绿红蓝、MSB优先的顺序锁存进WS2812B。前一个灯的DO
接后一个的DIN
,如此级联。
没有信号时数据线保持低电平,当低电平时间超过280 μs时就会RESET,锁存的数据更新到亮度上。所有级联的灯在几乎同一时刻更新。
如果你以前接触过WS2812B,可能会觉得以上信息和你记忆中的有一些偏差。的确,上面这份datasheet来自官网,而网上流传的是之前的版本,外网上比较通用的版本如下:
有人对datasheet描述不明确感到不满,于是做了个实验测试高低电平时间的最低条件,并对WS2812B的内部原理作了猜测。实验结果如下:
方案
首先这不是我想出来的方案,链接在文首。
我们让定时器产生两倍于SCK
频率的方波WO2
,上升沿对齐;MOSI
设置为上升沿更新,从SCK
上升沿到下一个上升沿为一个bit。在这一bit中,高电平占前1/4为WS2812B的0
,1/2为1
。
单片机时钟频率为10 MHz(内部20 MHz,分频系数2),SCK
频率为10 MHz / 16 = 625 kHz,WO2
频率为1.25 MHz。这样算下来t0H = 400 ns,t0L = 1200 ns,t1H = t1L = 800 ns。尽管不符合上述任何一个版本的时序,但是都差得不大,实测可以工作(我也不知道我买的WS2812B应该参考哪个时序)。
时钟
ATtiny3217的时钟可以用程序更改,但还是有一个参数需要用熔丝位设置——内部RC时钟是20 MHz还是16 MHz。出厂默认是20 MHz,所以就不用改了。如果要改的话,在Microchip Studio(原Atmel Studio)的菜单栏Tools/Device Programming里。
CLKCTRL
寄存器组是被保护起来的,写入操作需要一个特殊的流程:先向CCP
(configuration change protection)寄存器里写IO寄存器对应的key,然后在4周期里写被保护的寄存器。
CCP = CCP_IOREG_gc;
CLKCTRL.MCLKCTRLB = CLKCTRL_PDIV_2X_gc | CLKCTRL_PEN_bm;
赋值号左边是寄存器,大部分都是分组的;右边的_gc
表示group configuration,_bm
表示bit mask,还有_bp
表示bit position。
下面是从iotn3217.h
(我们还是应该#include <avr/io.h>
)中截取的几段,展示了分组的寄存器定义以及相关的宏是如何用标准C语言实现的:
typedef volatile uint8_t register8_t;
//--------------------------------------------------------------------------
/* Clock controller */
typedef struct CLKCTRL_struct
{
register8_t MCLKCTRLA; /* MCLK Control A */
register8_t MCLKCTRLB; /* MCLK Control B */
register8_t MCLKLOCK; /* MCLK Lock */
register8_t MCLKSTATUS; /* MCLK Status */
register8_t reserved_1[12];
register8_t OSC20MCTRLA; /* OSC20M Control A */
register8_t OSC20MCALIBA; /* OSC20M Calibration A */
register8_t OSC20MCALIBB; /* OSC20M Calibration B */
register8_t reserved_2[5];
register8_t OSC32KCTRLA; /* OSC32K Control A */
register8_t reserved_3[3];
register8_t XOSC32KCTRLA; /* XOSC32K Control A */
register8_t reserved_4[3];
} CLKCTRL_t;
/* CLKCTRL.MCLKCTRLB bit masks and bit positions */
#define CLKCTRL_PEN_bm 0x01 /* Prescaler enable bit mask. */
#define CLKCTRL_PEN_bp 0 /* Prescaler enable bit position. */
#define CLKCTRL_PDIV_gm 0x1E /* Prescaler division group mask. */
#define CLKCTRL_PDIV_gp 1 /* Prescaler division group position. */
#define CLKCTRL_PDIV0_bm (1<<1) /* Prescaler division bit 0 mask. */
#define CLKCTRL_PDIV0_bp 1 /* Prescaler division bit 0 position. */
#define CLKCTRL_PDIV1_bm (1<<2) /* Prescaler division bit 1 mask. */
#define CLKCTRL_PDIV1_bp 2 /* Prescaler division bit 1 position. */
#define CLKCTRL_PDIV2_bm (1<<3) /* Prescaler division bit 2 mask. */
#define CLKCTRL_PDIV2_bp 3 /* Prescaler division bit 2 position. */
#define CLKCTRL_PDIV3_bm (1<<4) /* Prescaler division bit 3 mask. */
#define CLKCTRL_PDIV3_bp 4 /* Prescaler division bit 3 position. */
/* Prescaler division select */
typedef enum CLKCTRL_PDIV_enum
{
CLKCTRL_PDIV_2X_gc = (0x00<<1), /* 2X */
CLKCTRL_PDIV_4X_gc = (0x01<<1), /* 4X */
CLKCTRL_PDIV_8X_gc = (0x02<<1), /* 8X */
CLKCTRL_PDIV_16X_gc = (0x03<<1), /* 16X */
CLKCTRL_PDIV_32X_gc = (0x04<<1), /* 32X */
CLKCTRL_PDIV_64X_gc = (0x05<<1), /* 64X */
CLKCTRL_PDIV_6X_gc = (0x08<<1), /* 6X */
CLKCTRL_PDIV_10X_gc = (0x09<<1), /* 10X */
CLKCTRL_PDIV_12X_gc = (0x0A<<1), /* 12X */
CLKCTRL_PDIV_24X_gc = (0x0B<<1), /* 24X */
CLKCTRL_PDIV_48X_gc = (0x0C<<1), /* 48X */
} CLKCTRL_PDIV_t;
//--------------------------------------------------------------------------
#define CLKCTRL (*(CLKCTRL_t *) 0x0060) /* Clock controller */
SPI
上升沿串出,下降沿采样,这是SPI mode 1。SCK
频率为主频除以16。
SPI0.CTRLA = SPI_MASTER_bm | SPI_PRESC_DIV16_gc | SPI_ENABLE_bm;
SPI0.CTRLB = SPI_SSD_bm | SPI_MODE_1_gc;
SPI发送一字节:向寄存器写入来发送,轮询寄存器等待发送完成。
SPI0.DATA = byte;
while (!(SPI0.INTFLAGS & SPI_IF_bm))
;
TCA
产生方波通常用CTC(现FRQ)模式,但是极性不好控制(其实现在有CMPnOV
位了),改用PWM。设置PER
为7
,PWM周期为8个CPU周期;CMP2
为4,占空比为4 / 8 = 50%。
没有硬件设施可以实现定时器和SPI的同步,所以在初始化中先不开启定时器输出。
TCA0.SINGLE.CTRLA = TCA_SINGLE_CLKSEL_DIV1_gc;
TCA0.SINGLE.CTRLB = TCA_SINGLE_CMP2EN_bm | TCA_SINGLE_WGMODE_SINGLESLOPE_gc;
TCA0.SINGLE.PER = 7;
TCA0.SINGLE.CMP2 = 4;
(TCA有两种模式:一个16位(single)和两个8位(split)。你觉得TCA0.SINGLE
和TCA0.SPLIT
是什么关系呢?)
在SPI发送时要求WO2
和SCK
同步,但此时并不知道计数器CNT
的值,所以把它清零,然后开启输出。SPI发送完后再关闭输出。
void ws2812b_write(uint8_t byte)
{
TCA0.SINGLE.CNT = 0;
TCA0.SINGLE.CTRLA = TCA_SINGLE_CLKSEL_DIV1_gc | TCA_SINGLE_ENABLE_bm;
SPI0.DATA = byte;
while (!(SPI0.INTFLAGS & SPI_IF_bm))
;
TCA0.SINGLE.CTRLA = TCA_SINGLE_CLKSEL_DIV1_gc;
TCA0.SINGLE.CTRLC = 0;
}
CCL
LUT
寄存器的8位分别存放IN[2:0]
的8种状态对应的输出。根据前面的时序图,在011
、101
和111
三种情况下输出为1
,LUT
值为0xA8
。
CCL.LUT1CTRLB = CCL_INSEL1_SPI0_gc | CCL_INSEL0_SPI0_gc;
CCL.LUT1CTRLC = CCL_INSEL2_TCA0_gc;
CCL.TRUTH1 = 0xA8;
CCL.LUT1CTRLA = CCL_OUTEN_bm | CCL_ENABLE_bm;
CCL.CTRLA = CCL_RUNSTDBY_bm | CCL_ENABLE_bm;
CCL的寄存器是被ENABLE
保护的,在ENABLE
为1
时不能更改,因此要先配置其他寄存器,再enable LUT,最后enable CCL。
并非每个信号都能作为LUT的任意输入,如SCK
只能接IN0
、MOSI
只能接IN1
,而普通的GPIO则不能直接接进LUT。如果需要的话,可以把GPIO接到event channel上,设置其用户为LUT,再在LUT中选择对应的EVOUT。如果SCK
要接IN1
而MOSI
接IN0
,只能用EVSYS这种方法,但这没有任何意义——总是可以通过修改LUT
达到相同的功能。
GPIO
(Datasheet中的一些“GPIO”指的是GPIOR(general-purpose I/O registers),我们讲的GPIO叫“PORT”,有些章节里也叫“GPIO”。)
为了和application note中一致,SPI0和LUT1的输出都移到非默认的引脚上,在那里默认引脚和其他功能冲突了。Alternative pins通过PORTMUX
配置:
PORTMUX.CTRLA = PORTMUX_LUT1_ALTERNATE_gc;
PORTMUX.CTRLB = PORTMUX_SPI0_ALTERNATE_gc;
按键在PB7
上,没有外部上拉电阻,启用内部上拉电阻(在);LED在PA3
上,LUT1-OUT
即WS2812B的信号在PC1
上,输出;SCK
、MOSI
、WO2
分别在PC0
、PC2
、PB2
上,为了用逻辑分析仪观察波形,也配置为输出。
PORTA.DIRSET = PIN3_bm;
PORTB.DIRSET = PIN2_bm;
PORTB.PIN7CTRL = PORT_PULLUPEN_bm;
PORTC.DIRSET = PIN2_bm | PIN1_bm | PIN0_bm;
测试结果
It works!
这是一个字节的波形。WO2
在左右各有一个额外的周期,但这并不影响LUT1-OUT
在闲时为低电平(idle state = low)。
改进
先别高兴得太早,看看这里最后两个字节:
两个字节之间有明显的间隔,这从代码里也能看出来。虽然间隔时间比实测最短的RESET时间9 μs还要短一半,但让我很不舒服。
ATtiny3217的SPI有一个缓冲字节,利用它或许可以实现多个字节连续发送:
void ws2812b_write(const uint8_t* byte, uint8_t length)
{
TCA0.SINGLE.CNT = 3;
TCA0.SINGLE.CTRLA = TCA_SINGLE_CLKSEL_DIV1_gc | TCA_SINGLE_ENABLE_bm;
SPI0.INTFLAGS |= SPI_TXCIF_bm;
for (const uint8_t* end = byte + length; byte != end; ++byte)
{
while (!(SPI0.INTFLAGS & SPI_DREIE_bm))
;
SPI0.DATA = *byte;
}
while (!(SPI0.INTFLAGS & SPI_TXCIF_bm))
;
TCA0.SINGLE.CTRLA = TCA_SINGLE_CLKSEL_DIV1_gc;
TCA0.SINGLE.CTRLC = 0;
}
记得在AVR单片机教程——DAC中,USART in SPI mode的缓冲区一方面让我要额外注意每次都要把UDR0
读掉以获得新鲜的数据,另一方面在我需要连续发送两个字节时相比SPI更节省CPU资源,让我得以实现音乐播放器。如果要在编程简单和功能强大之间选择的话,我还是会选择后者。那么这次ATtiny3217的SPI缓冲区能否让它胜任WS2812B的连续发送呢?让我们来看看波形:
前两行很符合预期——SCK
信号没有出现间断。加入第三行,密集的线条可能迷惑了你的双眼,但是第四行足够明显——第一字节的输出是正常的,但是第二字节就不对了。究其原因,是第二字节的第一个SCK
上升沿出现在它本来应该对应的WO2
上升沿和它后面的下降沿中间,换言之SCK
滞后了。
继续向后观察,第3、4、5字节都貌似正常,第6字节又出错了。仔细观察,第3字节像是下降沿对齐的PWM信号,而第4字节是高电平中心对齐的(center-aligned)。以4字节为周期,后面重复。事实上,每两字节之间SCK
低电平延长了2个CPU周期,相当于WO2
信号的90°相位差;这样周期为4字节就很好理解了。
所以,以让我开心为目的的改进失败了。
讨论
TCA与SPI的同步
如果你仔细看代码的话,应该是无法理解TCA0.SINGLE.CNT = 3;
中的magic number的。的确,这个数是我一点点改直到SCK
和WO2
上升沿对齐这样试出来的。如果把SPI0.INTFLAGS |= SPI_TXCIF_bm;
这一句移到前面去,这个数就得改成7
——这说明移的那句需要4个周期来执行。
同理,改进前的TCA0.SINGLE.CNT = 0;
也只是一个巧合,而不是像application note上说的那样:
很显然,这样做是低效的、不安全的:低效在这个magic number需要花工夫去找,不安全在也许改变一下编译器的优化等级就能让你花的工夫作废。
另一种逻辑
老版本WS2812B的时序可以大致理解为1/3和2/3的高电平占比,而上述方案只能实现分母为4的占比。不过就1
而言,3/4比1/2更接近2/3,要做到3/4也只需要把IN[2:0] = 0b110
对应的输出改成1
就可以了。为什么application note不是这样做的呢?
在1/2的方案中,只要SCK
为低电平,输出就是低电平;SCK
的闲时电平是SPI mode能完全确定的,因而能保证输出的闲时电平为低。在3/4的方案中,三输入的组合逻辑可以理解为输入有至少两个高电平时输出为高(提问:哪款常见的逻辑IC能实现这样的功能?);那么如果数据的LSB为1
,输出就完全跟着WO2
走。而WO2
在SPI发送完后还有一段高电平,除非这一段能被消除,否则3/4方案就是不可行的。
那么如何消除呢?也可以像上面那样搞个magic number,开始发送后等待这么多个周期,然后关闭TCA输出。这个数只要在一个[n, n+3]的区间里即可,没那么严格。但是,一旦主频改变,重新找吧!
IO分配与占用
我开了SCK
等信号的输出,是为了看波形,如果不开,那个引脚还可以用吗?输出是不行的,一旦DIR
位为1
,它输出的就是SCK
信号;输入或许可以。
所以,尽管我只需要SCK
信号在内部使用,它却必须占用一个引脚,这好吗?ATtiny3217一共只有24个pin,尽管有alternative pins,但毕竟总数摆在这,挺容易冲突的。不知Microchip的工程师有没有思考过这个问题,还是说tiny系列的应用场景连24 pins都已经嫌多了?或许吧,虽然我舍不得。
那么如何安排引脚呢?Atmel START是一个在线的工具,帮助你配置引脚、时钟和各种组件,就像隔壁厂家的某立方体一样。
后记
最近在做一个涉及WS2812B灯带的项目。为了锻炼自己,我要把整个写级联WS2812B的操作做成无需CPU干预的,这当然离不开DMA。我在网上找到三种方案,但它们都有严重的内存overhead,以至于很难把整个灯带的数据在一次DMA请求中发送出去,至少不划算。
本文的方案则不存在这样的问题,因为WS2812B的一个字节就对应SPI的一个字节。但是TCA与SPI的同步和SCK
信号在字节间被延长,尤其是后者,给我浇了一盆冷水。我还没有验证这种方案,但大概率是不行的,好在我还有别的方案。
你有什么方案吗?欢迎在评论区留言。