基于ATtiny的WS2812灯条驱动

这篇文章展示了使用AVR单片机(ATtiny)高效驱动WS2812系列LED灯条。算法使用缓存与并行总线的设计来提高系统输出速度与单位时间的数据吞吐量。

--by Captdam @ Aug 2, 2025 Jul 9, 2025

[en] Here is the English version of this article

【中】 这里是这篇文章的中文版

数据格式

LED比特时序

WS2812使用PWM信号来表示0和1比特数据。对于每个比特信号,我们首先需要一定时长的高电平,然后再是低电平。其中,高电平的时长决定了这个信号到底为0(高电平时长较短)还是1(高电平时长较长)。

信号时序

不同厂家不同型号的WS2812要求的时序可能完全不同。不过,大部分情况下,0的高电平基本在0.5微秒以下,1的高电平基本在0.5微秒以上;低电平的时长则并不统一。

下表展现了一些型号的WS2812的时序要求:

比特时序:最短-典型-最长(单位:纳秒)
信号 信号0高电平 T0h 信号0低电平 T0l 信号1高电平 T1h 信号1低电平 T1l 总时长 重置
时序图
Tnh + Tnl
XL-6028RGBW-WS2812B X-250-470 X-1000-X 580-850-1000 X-400-X 1200-1250-X 80,000+
XL-5050RGBW-WS2812B-HM 200-295-350 550-595-1200 550-595-1200 200-295-350 900-X-X 80,000+
XL-3210RGBC-WS2812B 300-X-X 900-X-X 900-X-X 300-X-X 1200-X-X 200,000+
Worldsemi WS2812 220-X-380 580-X-1000 580-X-1000 580-X-1000 N/A 280,000+
Worldsemi WS2812C-2020-V1/W 220-X-380 580-X-1000 580-X-1000 580-X-1000 N/A 280,000+
Worldsemi WS2812B 250-400-550 700-850-1000 650-800-950 300-450-600 650-1250-1850 50,000+

可以看出,时序各不相同,比李云龙打平安县时的晋西北还要乱成一锅粥。我们也很难找到一个较为通用的时序,只能对于不同的信号制定不同的策略。更混乱的是,当在网上购买WS2812时,卖家通常只会说这是2812,但是我们没办法知道具体型号。除非我们一买就买一整卷,能看到标签上的厂家和型号信息。只能说,最哈还是实际测量时序,以及不要混用不同厂家不同型号的灯珠(除非我们能测试得出时序兼容)。

8MHz下CPU的输出周期

虽然我们可以使用0到20MHz中的任何一个频率来驱动AVR,一个低成本的系统将使用AVR内部自带的8MHz RC时钟(这样,我们不仅不需要额外的晶振和两个电容,还能额外得到两个IO) 。这样的话,我们的CPU将会以每125纳秒为一个周期。此情况下,输出1和0将需要的CPU周期为:

RC时钟的频率将取决于供电电压与环境温度。我们将需要校准RC时钟以保证输出时序的准确性。特别注意环境温度的变化将导致CPU周期长短变化!在设计时预留一些安全缓冲空间也很重要(比如3个CPU周期为375纳秒,离380纳秒的阈值太近了)。

我们可以微调RC时钟的实际频率来达到时序要求。

8MHz下CPU的输出周期 (最短向上取整-典型-最长向下取整
信号 信号0高电平 T0h 信号0低电平 T0l 信号1高电平 T1h 信号1低电平 T1l 总时长 重置
XL-6028RGBW-WS2812B X-2-3 X-8-X 5-7-8 X-3-X 10-10-X 640+
XL-5050RGBW-WS2812B-HM 2-2-2 5-5-9 5-5-9 2-2-2 8-X-X 640+
XL-3210RGBC-WS2812B 3-X-X 8-X-X 8-X-X 3-X-X 10-X-X 1600+
Worldsemi WS2812 2-X-3 5-X-8 5-X-8 5-X-8 N/A 2240+
Worldsemi WS2812C-2020-V1/W 2-X-3 5-X-8 5-X-8 5-X-8 N/A 2240+
Worldsemi WS2812B 2-3-4 6-7-8 6-6-7 2-3-4 6-10-14 400+

16MHz下CPU的输出周期

如果使用16MHz的晶振来驱动的话(例如Arduino):

一些AVR可以使用内部的PLL来达到更高的频率而不需要依赖外部晶振与时钟输入。

8MHz下CPU的输出周期 (最短-典型-最长)
信号 信号0高电平 T0h 信号0低电平 T0l 信号1高电平 T1h 信号1低电平 T1l 总时长 重置
XL-6028RGBW-WS2812B X-4-7 X-16-X 10-14-16 X-6-X 20-20-X 1280+
XL-5050RGBW-WS2812B-HM 4-5-5 9-10-19 9-10-19 4-5-5 15-X-X 1280+
XL-3210RGBC-WS2812B 5-X-X 15-X-X 15-X-X 5-X-X 20-X-X 3200+
Worldsemi WS2812 4-X-6 10-X-16 10-X-16 10-X-16 N/A 4480+
WorldsemiWS2812C-2020-V1/W 4-X-6 10-X-16 10-X-16 10-X-16 N/A 4480+
Worldsemi WS2812B 4-6-8 12-14-16 11-13-15 5-7-9 11-20-29 800+

16MHz下CPU的输出周期

如果使用AVR支持的最高频率(20MHz)的外部晶振:

20MHz下CPU的输出周期 (最短-典型-最长)
信号 信号0高电平 T0h 信号0低电平 T0l 信号1高电平 T1h 信号1低电平 T1l 总时长 重置
XL-6028RGBW-WS2812B X-5-9 X-20-X 12-17-20 X-8-X 24-25-X 1600+
XL-5050RGBW-WS2812B-HM 4-6-7 11-12-24 11-12-24 4-6-7 18-X-X 1600+
XL-3210RGBC-WS2812B 6-X-X 18-X-X 18-X-X 6-X-X 24-X-X 4000+
Worldsemi WS2812 5-X-7 12-X-20 12-X-20 12-X-20 N/A 5600+
Worldsemi WS2812C-2020-V1/W 5-X-7 12-X-20 12-X-20 12-X-20 N/A 5600+
Worldsemi WS2812B 5-8-11 14-17-20 13-16-19 6-9-12 13-25-37 1000+

示例:XL-5050RGBW-WS2812B-HM & Worldsemi WS2812B在不同频率的CPU下面的时序要求

让我们通过XL-5050RGBW-WS2812B-HM和Worldsemi WS2812B在不同频率的CPU下面的时序要求来举例:

示例:XL-5050RGBW-WS2812B-HM & Worldsemi WS2812B在不同频率的CPU下面的时序要求
信号 信号0高电平 T0h 信号0低电平 T0l 信号1高电平 T1h 信号1低电平 T1l 总时长 重置
时序图
Tnh + Tnl
XL-5050RGBW-WS2812B-HM
时序 200-295-350 550-595-1200 550-595-1200 200-295-350 900-X-X
8MHz下CPU的输出周期 2周期 / 250纳秒 6周期 / 750纳秒 6周期 / 750纳秒 2周期 / 250纳秒 8周期 / 1000纳秒
16MHz下CPU的输出周期 5周期 / 312.5纳秒 10周期 / 625纳秒 10周期 / 625纳秒 5周期 / 312.5纳秒 15周期 / 937.5纳秒
Worldsemi WS2812B
时序 250-400-550 700-850-1000 650-800-950 300-450-600 650-1250-1800
8MHz下CPU的输出周期 3周期 / 375纳秒 7周期 / 875纳秒 6周期 / 750纳秒 4周期 / 500纳秒 10周期 / 1250纳秒
16MHz下CPU的输出周期 6周期 / 375纳秒 14周期 / 875纳秒 13周期 / 812.5纳秒 7周期 / 437.5纳秒 20周期 / 1250纳秒

8MHz对于XL-5050RGBW-WS2812B-HM来说太慢了。详情参考下一个章节。

此外,无论是数据0或是数据1都是以高电平开头。在某个时间点(这个例子中的第2个周期时),我们才发送数据(信号0就拉低,信号1就保持拉高)。在另一个时间点(这个例子中的第6个周期时),拉低信号。

显然,在8MHz的情况下,这个时序的要求是很严的。我们将必须把数据缓存到高速的SRAM中,然后再一次性发送出去。

重置

要重置信号流(以从头开始全新的信号流),我们将需要把信号拉低一段不短的时间。上面的例子展示了80微秒或200微秒,不同型号要求不一样,要参考具体型号的时序。

我们可以使用这段时间来在SRAM中准备我们的数据。

此外,我们还可以使用这段时间来操作多条LED链。比如,我们使用100微秒来对LED链A发送数据,使用接下来的1000微秒来对LED链B发送数据。此时,LED链A已完成的重置,我们可以回到LED链A发送下一串数据。

LED字格式

字(Word)代表一个数据单元,其长度取决于应用,而不是固定的16或32比特。

LED0 G7:G0 LED0 R7:R0 LED0 B7:B0 LED1 G7:G0 LED1 R7:R0 LED1 B7:B0

WS2812支持24位RGB色彩。因此,每个LED将会需要24比特的数据来编程。首先是绿色,然后红色,最后蓝色。先发送MSB(高位先发)。

多个LED被串联在一起构成一个链,我们使用一个数据流来控制这个LED链。链中的第一个LED将会消耗最前面24比特信号(比特0到23),第二个LED将会消耗接下来的24比特信号(比特24-47),并以此类推。

八路平行总线

分区 0 LED0 G7:G0 分区 0 LED0 R7:R0 分区 0 LED0 B7:B0 分区 0 LED1 G7:G0 分区 0 LED1 R7:R0 分区 0 LED1 B7:B0 分区 1 LED0 G7:G0 分区 1 LED0 R7:R0 分区 1 LED0 B7:B0 分区 1 LED1 G7:G0 分区 1 LED1 R7:R0 分区 1 LED1 B7:B0 分区 2 LED0 G7:G0 分区 2 LED0 R7:R0 分区 2 LED0 B7:B0 分区 2 LED1 G7:G0 分区 2 LED1 R7:R0 分区 2 LED1 B7:B0

当LED数量增加时,要控制整条链路的LED所需要的时间也会相应增加。

现在,我们只使用了一个输出端口。而AVR(和其他的8位单片机)通常把8个IO集合为一个端口(port)。只操作端口中的单个比特输出对单片机来说是极为低效的。

为了加快传输速度,我们可以将LED链切分为8个分区(Segment)。这样,我们就可以将一个端口的8个IO分别接上一个分区了。

如上图所示,单片机输出的第1个字节将作为八个分区中每个分区的第1个比特,单片机输出的第2个字节将作为八个分区中每个分区的第2个比特,并以此类推……

示例:将24个LED分割为8个分区

下面的例子将展示把24个LED分割为8个分区,每个分区为3个LED。

在分区前,我们想要输出的数据为:


		LED 0 G		LED 0 R		LED 0 B		LED 1 G		LED 1 R		LED 1 B		LED 2 G		LED 2 R		LED 2 B
Segment 0	0000 0000	0000 0001	0000 0010	0000 0100	0000 0101	0000 0110	0000 1000	0000 1001	0000 1010
Segment 1	0001 0000	0001 0001	0001 0010	0001 0100	0001 0101	0001 0110	0001 1000	0001 1001	0001 1010
Segment 2	0010 0000	0010 0001	0010 0010	0010 0100	0010 0101	0010 0110	0010 1000	0010 1001	0010 1010
Segment 3	0011 0000	0011 0001	0011 0010	0011 0100	0011 0101	0011 0110	0011 1000	0011 1001	0011 1010
Segment 4	0100 0000	0100 0001	0100 0010	0100 0100	0100 0101	0100 0110	0100 1000	0100 1001	0100 1010
Segment 5	0101 0000	0101 0001	0101 0010	0101 0100	0101 0101	0101 0110	0101 1000	0101 1001	0101 1010
Segment 6	0110 0000	0110 0001	0110 0010	0110 0100	0110 0101	0110 0110	0110 1000	0110 1001	0110 1010
Segment 7	0111 0000	0111 0001	0111 0010	0111 0100	0111 0101	0111 0110	0111 1000	0111 1001	0111 1010
	

分区后,我们需要输出:


Addr		X0/X8		X1/X9		X2/XA		X3/XB		X4/XC		X5/XD		X6/XE		X7/XF
0X		0000 0000	0000 1111	0011 0011	0101 0101	0000 0000	0000 0000	0000 0000	0000 0000
0X		0000 0000	0000 1111	0011 0011	0101 0101	0000 0000	0000 0000	0000 0000	1111 1111
1X		0000 0000	0000 1111	0011 0011	0101 0101	0000 0000	0000 0000	1111 1111	0000 0000
1X		0000 0000	0000 1111	0011 0011	0101 0101	0000 0000	1111 1111	0000 0000	0000 0000
2X		0000 0000	0000 1111	0011 0011	0101 0101	0000 0000	1111 1111	0000 0000	1111 1111
2X		0000 0000	0000 1111	0011 0011	0101 0101	0000 0000	1111 1111	1111 1111	0000 0000
3X		0000 0000	0000 1111	0011 0011	0101 0101	1111 1111	0000 0000	0000 0000	0000 0000
3X		0000 0000	0000 1111	0011 0011	0101 0101	1111 1111	0000 0000	0000 0000	1111 1111
4X		0000 0000	0000 1111	0011 0011	0101 0101	1111 1111	0000 0000	1111 1111	0000 0000
	

因为所有分区的第1个比特合在一起是00000000,所以输出00000000。所有分区的第2个比特合在一起是00001111,所以接下来输出00001111。所有分区的第3个比特合在一起是00110011,所以接下来输出00110011。

竖着看分区前的数据,横着看分区后的数据。

驱动软件

比特驱动

上文中我们讨论到,时序要求非常紧迫,特别是对于XL-5050RGBW-WS2812B-HM,最短的信号要求我们在350纳秒内完成发送,那比8MHz的3个周期(375纳秒)还要短。因此,比特驱动最重要的目标就是快且准。

对于每个比特,我们需要:

  1. 在最开始,输出高电平。
  2. 读取当前比特的颜色数据,并前移读指针。
  3. 在某个时间点,输出颜色数据。
  4. 在某个时间点,拉低输出电平。
  5. 检查数据结尾(是否完成所有LED的输出)。
    • 如果未完成,回到第一步。
    • 如果完成,停止发送并保持拉低一段周期。我们可以趁机准备之后的数据。

下面,我们用C来写一下这个驱动。在这个例子中,我们将使用ATtiny461的PORTA


1.c
#define SIZE (24 * 8) // 24 bits/LED

#include <avr/io.h>

volatile uint8_t data[SIZE];

void main() {
	DDRA = 0xFF;
	for (volatile uint8_t* p = data; p < &(data[SIZE]); p++) {
		PORTA = 0xFF;
		PORTA = *p;
		/* asm("nop\n"); */
		PORTA = 0x00;
	}
	for(;;);
}
	

使用avr-gcc 1.c -o 1.out -mmcu=attiny461 -O3编译。

坏家伙出现了!16位内存总线下的for循环太慢了!

使用avr-objdump -m avr25 -D 1.out > 1.txt来反编译上面的例子:


00000048 <main>:
  48:	8f ef       	ldi	r24, 0xFF	; 255
  4a:	8a bb       	out	0x1a, r24	; 26
  4c:	e0 e6       	ldi	r30, 0x60	; 96
  4e:	f0 e0       	ldi	r31, 0x00	; 0
  50:	9f ef       	ldi	r25, 0xFF	; 255
  52:	9b bb       	out	0x1b, r25	; 27 === Cycle 0   ===
  54:	81 91       	ld	r24, Z+		; load needs 2 cycles
  56:	8b bb       	out	0x1b, r24	; 27 === Cycle 3   ===
  ...               	nop			;    === Cycle 3+n ===
  58:	1b ba       	out	0x1b, r1	; 27 === Cycle 4+n ===
  5a:	81 e0       	ldi	r24, 0x01	; 1
  5c:	e0 32       	cpi	r30, 0x20	; 32
  5e:	f8 07       	cpc	r31, r24
  60:	a8 f3       	brcs	.-16     	; 0x52 <main+0xa> === Cycle 8+n, plus another 2 cycles to jump to beginning of the loop ===
  62:	ff cf       	rjmp	.-2      	; 0x68 <main+0x20>
	

问题:

等等……我们是不是可以,比如说:重新排列一下指令来达到时序要求?比如把检测指针的操作放在nop的地方。

驱动就要跑得快

这就轮到汇编上场了:


2.c

#define SIZE (24 * 8) // 24 bits/LED

#include <avr/io.h>
uint8_t data[SIZE];

__attribute__((naked)) void burst(uint8_t* start, uint8_t* end) {
	asm(
		"movw	r30, r24\n\t"		// First argument in r25:r24, r31:r30 (Z) are call-used regs
		"ldi	r24, 0xFF\n\t"		// R24 is call-used, content in R24 copied to R30 and no loinger required
		// Loop starts
		"1:	\n\t"
		"out	%[port], r24\n\t"	// Cycle 0
		"ld	r25, Z+\n\t"		// R25 is call-used (load requires 2 cycles)
		"out	%[port], r25\n\t"	// Cycle 3
		"cp	r30, r22\n\t"		// Second argument in r23:r22
		"cpc	r31, r23\n\t"
		"out	%[port], r1\n\t"	// Cycle 6, R1 is always 0, OUT instruction will NOT clob zero flag
		"nop	\n\t"
		"nop	\n\t"
		"brne	1b\n\t"			// Cycle 9 and 10 (branch requires 2 cycles)
		"ret	\n\t"
		:
		: [port] "I" (_SFR_IO_ADDR(PORTA))
	);
}

void main() {
	DDRA = 0xFF;
	burst(data, &(data[SIZE]));
	for(;;);
}
		

使用avr-gcc 2.c -o 2.out -mmcu=attiny461 -O3编译。

再使用avr-objdump -m avr25 -D 2.out > 2.txt反编译:


00000048 <burst>:
  48:	fc 01       	movw	r30, r24
  4a:	8f ef       	ldi	r24, 0xFF	; 255
  4c:	8b bb       	out	0x1b, r24	; 27
  4e:	91 91       	ld	r25, Z+
  50:	9b bb       	out	0x1b, r25	; 27
  52:	e6 17       	cp	r30, r22
  54:	f7 07       	cpc	r31, r23
  56:	00 00       	nop
  58:	00 00       	nop
  5a:	1b ba       	out	0x1b, r1	; 27
  5c:	c1 f3       	brne	.-18     	; 0x4c <burst+0x4>
  5e:	08 95       	ret

00000060 <main>:
  60:	8f ef       	ldi	r24, 0xFF	; 255
  62:	8a bb       	out	0x1a, r24	; 26
  64:	60 e2       	ldi	r22, 0x20	; 32
  66:	71 e0       	ldi	r23, 0x01	; 1
  68:	80 e6       	ldi	r24, 0x60	; 96
  6a:	90 e0       	ldi	r25, 0x00	; 0
  6c:	ee df       	rcall	.-38     	; 0x48 <burst>
  6e:	ff cf       	rjmp	.-2      	; 0x6e <main+0xe>
		

成功达成时序要求!

这个驱动遵守AVR-GCC calling convention,只对AVR-GCC ABI兼容!

字格式转换

因为使用平行的数据输出设计,我们将需要对原始的GRB8进行一点格式转换的处理才能使用。

下面的C程序将完成这个格式转换的目标:


#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <errno.h>

typedef struct __attribute__((packed)) RGB {
	uint8_t r, g, b;
} RGB;
typedef struct __attribute__((packed)) Lane {
	uint8_t g[8];
	uint8_t r[8];
	uint8_t b[8];
} Lane;

int main(int argc, char* argv[]) {
	if (argc != 7) {
		fprintf(stderr, "Use: this leds segments lines frames source dest, (%d given)\n", argc);
		return 1;
	}
	int led = atoi(argv[1]);
	int segment = atoi(argv[2]);
	int line = atoi(argv[3]);
	int frame = atoi(argv[4]);
	fprintf(stderr, "%d LEDs per segment, %d segments, %d bits buffer, %d lines, %d frames\n", led, segment, led * segment * 24, line, frame);

	uint8_t* bin = malloc(led * segment * 3);
	uint8_t* bout = malloc(led * segment * 3);
	FILE* fin = fopen(argv[5], "rb");
	FILE* fout = fopen(argv[6], "wb");
	int pRead[segment]; // Offset in bit (not byte)
	int pWrite;

	for (int iframe = 0; iframe < frame; iframe++) {
		for (int iline = 0; iline < line; iline++) {
			pWrite = 0;
			for (int isegment = 0; isegment < segment; isegment++) {
				pRead[isegment] = isegment * led * 24; // Each segment has 24 * led bits
			}
			fread(bin, 3, led * segment, fin);
			for (int iled = 0; iled < led; iled++) {
				for (int ibit = 0; ibit < 24; ibit++) {
					for (int isegment = 0; isegment < segment; isegment++) {
						int readIdx = pRead[isegment] >> 3;
						int readBit = 7 - (pRead[isegment] & 0b111);
						uint8_t x = ( bin[readIdx] >> readBit ) & 1;
						pRead[isegment]++;
						//fprintf(stderr, "Read %d: Byte %d, bit %d: %d\n", ibit, readIdx, readBit, x);
						int writeIdx = pWrite >> 3;
						int writeBit = 7 - (pWrite & 0b111);
						uint8_t write = bout[writeIdx];
						write &= ~(1<<writeBit);
						write |= x << writeBit;
						bout[writeIdx] = write;
						pWrite++;
					}
				}
			}
			fwrite(bout, 3, led * segment, fout);
			for (int i = 0; i < 3 * led * segment; i++) {
				fprintf(stderr, "Write %d:\t", i);
				for (int j = 7; j >= 0; j--) {
					fprintf(stderr, "%c", '0' + ((bout[i] >> j) & 1));
				}
				fprintf(stderr, "\n");
				if (i % 8 == 0b111) fprintf(stderr, "\n");
			}
		}
	}

	free(bin);
	free(bout);
	fclose(fin);
	fclose(fout);
	return 0;
}
	

使用gcc -O3 image.c -o image编译。

假设有24个LED分割为8个分区,每个分区为3个LED,原始数据在文件test.in中。我们可以使用命令:./image 3 8 1 1 test.in test.out。生成的处理后的数据将保存在文件test.out中。

我们应该在电脑上预处理数据。使用单片机实时处理将会很慢。

硬件选择

ATtiny系列单片机选择

为了追求最高效率,我们需要找到一款至少有一个完整8位IO端口的单片机。

根据我的另一篇博客,我们有如下选项:

内存大小选择

为了达到时序要求,我们需要在发送数据前,将数据首先缓存到内存SRAM中。

因为每个LED需要24比特数据,切我们使用了8个分区。那么,每8个LED就需要24字节的缓存。

因此,理论上来说,ATtiny能支持的最大LED数量为:

LED数量与单片机内存大小对比
ATtiny信号 SRAM内存大小(字节) 最大可支持LED数量 所需内存 剩余可用内存
2X 128 40 120 8
4X 256 80 240 16
8X 512 168 504 8

如果还要更多内存的话,就需要Tmega了。

你可以制作PCB并焊接LED灯珠(如果你很享受焊接SMD的话)。此外,也可以使用现成的灯带贴在铁棍和木棍上,方便又实惠。

这种灯带有不同密度,常见的有每米144灯珠、60灯珠、30灯珠。下表展现了几种内存大小下使用不同密度灯带的长度关系:

常见灯带的灯珠数量与长度对比
缓存大小 灯珠数量 144/m 60/m 30/m
120 40 0.278m 0.667m 1.333m
240 80 0.556m 1.333m 2.667m
504 160 1.167m 2.8m 5.6m
1008 336 2.333m 5.6m 11.2m
2040 680 4.722m 11.333m 22.667m

单元测试

测试电路

现在,我们可以做一个4灯珠的测试单元来验证我们上面的讨论内容。这个测试单元将模拟1分区中前4个LED。下面展示了这个测试单元的电路图与PCB(当然也可以用灯带代替):

测试电路图
测试电路图
测试电路板PCB
测试电路板PCB

这里是这个测试单元的KiCAD工程文件

注意信号从左边输入,右边输出。但是,供电在下方,接地在上方。这设计有点违背直觉,我不懂最先设计封装时为什么把供电放下面接地放上面。

我们需要把这个测试单元连接在一块ATtiny44单片机上,并以port A pin 0作为信号端口,以模拟测试分区0。

做好的PCB
做好的PCB
测试单元连接单片机
测试单元连接单片机

LED全亮且颜色不同


#define SIZE (24 * 3) // 24 bits/LED, 8 segment, 3LEDs per segment

#include <avr/io.h>

uint8_t data[SIZE] = {
//	MSB 7 6     5     4     3     2     1     0 LSB
	
	/* LED 0 */
	0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, //Green
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Red
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Blue
	/* LED 1 */
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Green
	0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Red
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Blue
	/* LED 2 */
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Green
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Red
	0x55, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Blue
	
	/* Parallel design, each bit in a byte represents a segment */
};

__attribute__((naked)) void burst(uint8_t* start, uint8_t* end) {
	asm(
		"movw	r30, r24\n\t"		// First argument in r25:r24, r31:r30 (Z) are call-used regs
		"ldi	r24, 0xFF\n\t"		// R24 is call-used, content in R24 copied to R30 and no loinger required
		// Loop starts
		"1:	\n\t"
		"out	%[port], r24\n\t"	// Cycle 0
		"ld	r25, Z+\n\t"		// R25 is call-used
		"out	%[port], r25\n\t"	// Cycle 3
		"cp	r30, r22\n\t"		// Second argument in r23:r22
		"cpc	r31, r23\n\t"
		"out	%[port], r1\n\t"	// Cycle 6, R1 is always 0, OUT instruction will not clob zero flag
		"nop	\n\t"
		"brne	1b\n\t"			// Cycle 7 and 8
		"ret	\n\t"
		:
		: [port] "I" (_SFR_IO_ADDR(PORTA))
	);
}

int main() {
	DDRA = 0xFF;
	PORTA = 0x00;
	
	for (uint8_t d = 0; ; d++) {
		burst(data, &(data[SIZE]));
		
		for (uint32_t i = 0; i < 100000; i++); // Reset
	}
	
	return 0;
}
	

在上面的例子中,我们将驱动8个分区共24个LED(即每个分区3个LED)。我们将颜色数据保存在数列data[SIZE]中。


/* LED 0 */
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, //Green
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Red
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Blue
	

最前面24字节将会驱动各个分区中第1个LED。

示例中,单片机将会对每个分区都输出11111111 00000000 00000000,这就会将绿色全功率打开。


/* LED 1 */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Green
0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Red
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Blue
	

接下来的24字节将会驱动各个分区中第2个LED。

示例中,单片机将会对每个分区都输出00000000 10000000 00000000,这就会将红色半功率打开。


/* LED 2 */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Green
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Red
0x55, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //Blue
	

再然后的24字节将会驱动各个分区中第3个LED。

我们要分比特地来看这个例子。因为蓝色地第一个字节是0x550b01010101),因此单片机会输出00000000 00000000 10000000到分区0、2、4、6,输出00000000 00000000 00000000到分区1、3、5、7。这就会将分区0、2、4、6的蓝色半功率打开,但是将分区1、3、5、7的蓝色关闭。

因为只输出了72比特到每个分区,且每个LED消耗24比特数据。所以,各分区第三个LED之后的LED将不会得到任何数据。

LED全亮且颜色不同
LED全亮且颜色不同

把程序烧录进单片机。可以看到第一个LED全功率发出绿色,第二个与第三个LED分别半功率发出红色与蓝色。

单片机时钟频率为8MHz,使用avrdude -P com3 -c arduino -p t44 -U lfuse:w:0xa2:m来设置ATtiny44的low fuse来使用内置8MHz RC时钟。

闪烁LED


int main() {
	DDRA = 0xFF;
	PORTA = 0x00;
	
	for (uint8_t d = 0; ; d++) {
		data[0] = ~data[0];
		burst(data, &(data[SIZE]));
		for (uint32_t i = 0; i < 100000; i++); // Reset
		data[32] = ~data[32];
		burst(data, &(data[SIZE]));
		for (uint32_t i = 0; i < 100000; i++); // Reset
		data[64] = ~data[64];
		burst(data, &(data[SIZE]));
		for (uint32_t i = 0; i < 100000; i++); // Reset
	}
	
	return 0;
}
	

之这个例子中,单片机将分别对LED 0的绿色的最高位、LED 1的红色的最高位和LED 2的蓝色的最高位进行比特反转。这将会导致LED的亮度按照下表模式变化:

闪烁测试模式
时间 LED 0 LED 1 LED 2 注释
起始 11111111-00000000-00000000 00000000-10000000-00000000 00000000-00000000-10000000 原始数据
6n+1 01111111-00000000-00000000 00000000-10000000-00000000 00000000-00000000-10000000 LED 0半功率
6n+2 01111111-00000000-00000000 00000000-00000000-00000000 00000000-00000000-10000000 LED 1关闭
6n+3 01111111-00000000-00000000 00000000-00000000-00000000 00000000-00000000-00000000 LED 2关闭
6n+4 11111111-00000000-00000000 00000000-00000000-00000000 00000000-00000000-00000000 LED 0全功率
6n+5 11111111-00000000-00000000 00000000-10000000-00000000 00000000-00000000-00000000 LED 1半功率
6n+6 11111111-00000000-00000000 00000000-10000000-00000000 00000000-00000000-10000000 LED 2半功率

下面,对单片机编程。

闪烁LED
闪烁LED

可以看到,LED根据上表的模式闪烁着。注意绿色LED的强度变化。