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的SCKMOSI信号和一个定时器的波形输出的逻辑运算获得了能驱动WS2812B的信号。这让我对ATtiny3217的执念更加深了。

下面先来介绍一下今天的出场嘉宾。

ATtiny3217 Curiosity Nano

半年前,趁着可以用公款的时机,我拔草了种草已久的开发板。

ATtiny3217 x WS2812B梦幻联动-LMLPHP

在某宝买的,一块那么小的开发板竟然要105元。还有一款ATtiny3217 Xplained Pro,要300+,还不包括扩展板,超出了预算限制。店家只有现货1块,队友买第2块的时候商家告知要去订货,于是就退款了。

板上有两颗单片机:一个ATSAMD21E18,用作电源控制器、调试器、虚拟串口等;另一个当然是ATtiny3217啦。

ATtiny3217 x WS2812B梦幻联动-LMLPHP

没错,调试器,这对于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(压栈)、SBICBI(I/O寄存器的位操作)各减少一个周期。其中PUSH是最值得关注的,因为它大幅缩短了从事件触发到用户中断代码开始执行的间隔。(一个不太典型的中断disassembly见AVR单片机教程——定时器中断,它不典型在push太少,一般至少十几个。)

时钟终于不用通过熔丝位设置了,CLKCTRL可以运行时切换时钟源。中断也终于有两个优先级了,但有很多限制。

外设方面,首先是从xmega开始,寄存器就以struct来组织,比如以前设置PB6为输出是DDRB |= 1 << 6,现在是PORTB.DIR |= 1 << 6PORTB.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,这个功能可以这样实现:

ATtiny3217 x WS2812B梦幻联动-LMLPHP

按键的电平又GPIO读入,RTC产生一定频率的时钟,两者通过EVSYS接到CCL的LUT上(look-up table,可以实现任意3输入的组合逻辑,这里只用了按键一个输入),LUT输出接滤波器(filter,其输出在连续两次输入相同时才会更新),再通过EVSYS接到ADC触发转换。这些过程都是不需要CPU干预的,CPU此时应该处于一种睡眠状态,或在执行其他耗时的操作。ADC转换完成后产生中断,这才需要CPU执行相应代码。

WS2812B

WS2812B的信号是单线的,一方面这简化了灯带的设计,对级联也比较友好,但另一方面这种信号不是任何一种常见的总线,也不能由常见总线信号通过简单变换得到,这带来了一些困难。

ATtiny3217 x WS2812B梦幻联动-LMLPHP

每一位都是先高电平后低电平,01的差别在于高低电平的时间不同,0的高电平时间比较短。允许的时间范围都是比较宽的。通常每一位都是等长的,那么一位的时间范围为1.16 μs到1.38 μs。

每个灯有4个引脚:VCCGNDDINDODO上的信号是DIN信号除了前24个bit以外的部分,这24个bit以绿红蓝、MSB优先的顺序锁存进WS2812B。前一个灯的DO接后一个的DIN,如此级联。

没有信号时数据线保持低电平,当低电平时间超过280 μs时就会RESET,锁存的数据更新到亮度上。所有级联的灯在几乎同一时刻更新。

如果你以前接触过WS2812B,可能会觉得以上信息和你记忆中的有一些偏差。的确,上面这份datasheet来自官网,而网上流传的是之前的版本,外网上比较通用的版本如下:

ATtiny3217 x WS2812B梦幻联动-LMLPHP

有人对datasheet描述不明确感到不满,于是做了个实验测试高低电平时间的最低条件,并对WS2812B的内部原理作了猜测。实验结果如下:

ATtiny3217 x WS2812B梦幻联动-LMLPHP

方案

首先这不是我想出来的方案,链接在文首。

ATtiny3217 x WS2812B梦幻联动-LMLPHP

我们让定时器产生两倍于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。设置PER7,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.SINGLETCA0.SPLIT是什么关系呢?)

在SPI发送时要求WO2SCK同步,但此时并不知道计数器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

ATtiny3217 x WS2812B梦幻联动-LMLPHP

LUT寄存器的8位分别存放IN[2:0]的8种状态对应的输出。根据前面的时序图,在011101111三种情况下输出为1LUT值为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保护的,在ENABLE1时不能更改,因此要先配置其他寄存器,再enable LUT,最后enable CCL。

并非每个信号都能作为LUT的任意输入,如SCK只能接IN0MOSI只能接IN1,而普通的GPIO则不能直接接进LUT。如果需要的话,可以把GPIO接到event channel上,设置其用户为LUT,再在LUT中选择对应的EVOUT。如果SCK要接IN1MOSIIN0,只能用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上,输出;SCKMOSIWO2分别在PC0PC2PB2上,为了用逻辑分析仪观察波形,也配置为输出。

PORTA.DIRSET = PIN3_bm;
PORTB.DIRSET = PIN2_bm;
PORTB.PIN7CTRL = PORT_PULLUPEN_bm;
PORTC.DIRSET = PIN2_bm | PIN1_bm | PIN0_bm;

测试结果

ATtiny3217 x WS2812B梦幻联动-LMLPHP

It works!

ATtiny3217 x WS2812B梦幻联动-LMLPHP

这是一个字节的波形。WO2在左右各有一个额外的周期,但这并不影响LUT1-OUT在闲时为低电平(idle state = low)。

改进

先别高兴得太早,看看这里最后两个字节:

ATtiny3217 x WS2812B梦幻联动-LMLPHP

两个字节之间有明显的间隔,这从代码里也能看出来。虽然间隔时间比实测最短的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的连续发送呢?让我们来看看波形:

ATtiny3217 x WS2812B梦幻联动-LMLPHP

前两行很符合预期——SCK信号没有出现间断。加入第三行,密集的线条可能迷惑了你的双眼,但是第四行足够明显——第一字节的输出是正常的,但是第二字节就不对了。究其原因,是第二字节的第一个SCK上升沿出现在它本来应该对应的WO2上升沿和它后面的下降沿中间,换言之SCK滞后了。

ATtiny3217 x WS2812B梦幻联动-LMLPHP

继续向后观察,第3、4、5字节都貌似正常,第6字节又出错了。仔细观察,第3字节像是下降沿对齐的PWM信号,而第4字节是高电平中心对齐的(center-aligned)。以4字节为周期,后面重复。事实上,每两字节之间SCK低电平延长了2个CPU周期,相当于WO2信号的90°相位差;这样周期为4字节就很好理解了。

所以,以让我开心为目的的改进失败了。

讨论

TCA与SPI的同步

如果你仔细看代码的话,应该是无法理解TCA0.SINGLE.CNT = 3;中的magic number的。的确,这个数是我一点点改直到SCKWO2上升沿对齐这样试出来的。如果把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是一个在线的工具,帮助你配置引脚、时钟和各种组件,就像隔壁厂家的某立方体一样。

ATtiny3217 x WS2812B梦幻联动-LMLPHP

后记

最近在做一个涉及WS2812B灯带的项目。为了锻炼自己,我要把整个写级联WS2812B的操作做成无需CPU干预的,这当然离不开DMA。我在网上找到三种方案,但它们都有严重的内存overhead,以至于很难把整个灯带的数据在一次DMA请求中发送出去,至少不划算。

本文的方案则不存在这样的问题,因为WS2812B的一个字节就对应SPI的一个字节。但是TCA与SPI的同步和SCK信号在字节间被延长,尤其是后者,给我浇了一盆冷水。我还没有验证这种方案,但大概率是不行的,好在我还有别的方案。

你有什么方案吗?欢迎在评论区留言。

01-26 14:59