声明
本文版权归作者bxgj所有,未经作者授权,本文禁止以任何形式在任何平台(包括但不限于各网站、论坛、博客、微博、公众号等)部分或全部地转载,禁止二次修改后声明原创。授权转载内容请注明出处(如作者:xxx,转载自xxx),并标明本站网址。文中程序仅供学习使用,本人不承担任何由使用文中代码产生的法律责任。
ws2812相信有不少人都用过,大家对这款彩色LED真的是又爱又恨,爱的是它它使用简单,采用单总线通信方式,节约IO口,而且可以多级串联。而普通的彩色LED不是共阴就是共阳,每个颜色一个引脚,一般都是用PWM驱动,想要控制亮度、颜色就要分别控制每个引脚上的PWM占空比,想要驱动多个LED就更麻烦了。恨的是ws2812对时序的要求比较高,对低速单片机不太友好。今天我们就详细谈一谈ws2812的驱动。
不想看分析过程的直接跳到最后看总结。
拿到一款芯片,第一件事就是找datasheet,找datasheet很简单,找一份靠谱的datasheet有时很困难,特别是国内一些翻译后datasheet往往有错误,我被坑过很多次。说真的我也不想看英文手册,但是没办法,除非有官方中文版,还是要尽量看原版英文手册。就这款芯片来说,我找到了多个版本,不管是国外,国内的,官网上的,还是某些文库中的,关于时序的定义竟然完全没有一样的!!!!这里直接把时序部分摘出来给大家看一下。
第一种
第二种
第三种
第四种
第五种
第六种
看完这些datasheet我完全不知道该相信哪个,总的来说,第一张图片中的参数似乎比较平均,所以我就以它为基准来编写驱动。
在和大家分享我的驱动程序之前,先来看看别人的驱动程序。目前在网上能找到的驱动大多是STM32、STM8、Arduino的驱动,很少有STC单片机的驱动。
STM32的驱动方式目前我见到的有三种,第一种直接控制IO口,并精确调整延时,这个没什么好说的,略过;第二种将SPI的时钟调整为8MHz,发送一字节正好是1.25us,给ws2812发送0即通过SPI总线发送11000000b,发送1即通过SPI总线发送11111100b,非常巧妙的一种方式;第三种方式使用PWM,周期设置为3MHz,发送0就把占空比设置为33%,发送1就把占空比设置为66%,也是一种不错的方式。关于Arduino我不想说了,网上代码太多了,核心部分都是用汇编写的。STC的驱动也有一部分,但我真的不敢恭维,有的就是无脑堆_nop_();,有的要求时钟必须为24MHz,总之能用就行,没有一篇文章会详细分析时序,我真的无力吐槽。
所成我给我定个小目标:写一个简单好用的STC单片机ws2812驱动。
我们常用的主频一般是12MHz或11.0592MHz,我的驱动要争取能在这个主频下工作,况且不是所有的场合都要用24MHz这么高的频率,比如低功耗的场合,如果单片机中的某些代码精确依赖时钟,改变主频对整个代码的改动也比较大,比如定时器、串口的波特率都需要改。如果你没什么追求,只要能用就行,那么可以略过中间的分析过程直接跳到最后看结论。最后说一句,杠精自重!
先看看我们能不能借鉴别人的驱动方案,STC15的SPI速率最高为SYSCLK/4,也就是说主频要达到32MHz才可以,软件中最高只能设置到30MHz,何况这个主频超过我的目标,SPI方案PASS。PWM方案应该有可行性,但是8DIP封装的单片机没有PWM接口,也没有SPI接口,这个方案我也考虑。那么最后就只有控制IO口这一个方案了。
根据这个思路,我们会编写类似以下代码(这部分代码只做演示,杠精自重)
void ws2812_write_byte(u8 dat)
{
u8 i = 0;
for(i = 0; i < 8; i++)
{
if((dat & 0x80) == 0x80)
{
WS2812_IO = 1;
delay_us(0.7);
WS2812_IO = 0;
delay_us(0.6);
}
else
{
WS2812_IO = 1;
delay_us(0.35);
WS2812_IO = 0;
delay_us(0.8);
}
dat = dat << 1;
}
}
显然,这个代码在STC这样的低速单片机上是不能正常工作的,毕竟执行任何语句都需要时间。当然在STM32等高速单片机上这种代码是有可能正常工作的。
当然这个代码有优化空间,我们可以将一些相同的操作提取出来,比如设置IO口还有延时,于是我们可以得到这样的代码
void ws2812_write_byte(u8 dat)
{
u8 i = 0;
for(i = 0; i < 8; i++)
{
WS2812_IO = 1;
delay_us(0.3);
if((dat & 0x80) == 0x80)
{
delay_us(0.4);
WS2812_IO = 0;
delay_us(0.6);
}
else
{
WS2812_IO = 0;
delay_us(0.8);
}
dat = dat << 1;
}
}
比前一个代码稍微好一些,但还是不实用
听说while循环比for循环效率高,使用自减计数比自加计数效率高,那我们就再改一版(关于循环下文有详细分析,杠精自重!)
void ws2812_write_byte(u8 dat)
{
u8 i = 8;
while(i--)
{
WS2812_IO = 1;
delay_us(0.3);
if((dat & 0x80) == 0x80)
{
delay_us(0.4);
WS2812_IO = 0;
delay_us(0.6);
}
else
{
WS2812_IO = 0;
delay_us(0.8);
}
dat = dat << 1;
}
}
我们知道51单片机有一种数据类型是其他架构所不一定具备的,那就是布尔型(sbit,或称为位),直接对位进行操作要比使用与逻辑判断更快,零点几微秒的延时真的太短了,如果我们把delay_us函数直接展开成NOP指令还能节省函数调用的时间开销,于是我们得到一个比较复杂,但性能大幅提升的新代码
// 需根据实际情况适当调整NOP语句数量
#define SEND_BIT0 {WS2812_IO=1;_nop_();_nop_();_nop_();WS2812_IO=0;_nop_();_nop_();_nop_();_nop_();_nop_();_nop_();_nop_();}
#define SEND_BIT1 {WS2812_IO=1;_nop_();_nop_();_nop_();_nop_();_nop_();_nop_();_nop_();WS2812_IO=0;_nop_();_nop_();_nop_();}
u8 bdata LED_DATA;
sbit DAT_bit0 = LED_DATA^0;
sbit DAT_bit1 = LED_DATA^1;
sbit DAT_bit2 = LED_DATA^2;
sbit DAT_bit3 = LED_DATA^3;
sbit DAT_bit4 = LED_DATA^4;
sbit DAT_bit5 = LED_DATA^5;
sbit DAT_bit6 = LED_DATA^6;
sbit DAT_bit7 = LED_DATA^7;
void ws2812_write_byte(u8 dat)
{
LED_DATA = dat;
if(DAT_bit0){SEND_BIT1}else{SEND_BIT0};
if(DAT_bit1){SEND_BIT1}else{SEND_BIT0};
if(DAT_bit2){SEND_BIT1}else{SEND_BIT0};
if(DAT_bit3){SEND_BIT1}else{SEND_BIT0};
if(DAT_bit4){SEND_BIT1}else{SEND_BIT0};
if(DAT_bit5){SEND_BIT1}else{SEND_BIT0};
if(DAT_bit6){SEND_BIT1}else{SEND_BIT0};
if(DAT_bit7){SEND_BIT1}else{SEND_BIT0};
}
上面这个代码就比较实用了,但是我实际测试中发现,这个代码在24MHz下可以用,但12MHz下是不能用的(因为分支跳转指令比较耗时,12MHz时分支跳转占用的时间不能满足ws2812时序的要求),走到这一步如果还想继续优化就必须深入分析汇编代码。
本文所用的单片机型号为STC15W4K32S4,所用指令集为STC-Y5指令集,仅适用于STC15Fxx、STC15Lxx、STC15Wxx系列,不包括STC15F104E(A版)、STC15L104E(A版)、STC15F204EA(A版)、STC15L204EA(A版)。
STC-Y5以前的指令集很多指令的执行时间要大于STC-Y5的指令集;最新的STC8Fxx及STC8Axx系列采用的STC-Y6指令集大幅度优化,绝大多数指令被优化为单周期指令,执行速度远远快于STC-Y5。由于ws2812对时序的要求较高,本文中的代码高度依赖于这些指令的执行时间,因此文中代码 完全不保证 在其他单片机上能正常驱动ws2812。
列举并对比程序中用到的几条指令在不同版本指令集上的执行时间
指令集 | STC-Y1 | STC-Y3 | STC-Y5 | STC-Y6 |
---|---|---|---|---|
代表型号 | STC89C52 | STC12C5A60S2 | STC15W4K32S4 | STC8A8K64S4A12 |
MOV A, Rn (寄存器送入累加器) | 12 | 1 | 1 | 1 |
MOV Rn, A (累加器送入寄存器) | 12 | 2 | 1 | 1 |
MOV Rn, #data (立即数送入寄存器) | 12 | 2 | 2 | 1 |
MOV bit, C (进位位送入直接地址位) | 24 | 4 | 3 | 1 |
RL A (累加器循环左移) | 12 | 1 | 1 | 1 |
RLC A (累加器带进位循环左移) | 12 | 1 | 1 | 1 |
CLR A (清零累加器) | 12 | 1 | 1 | 1 |
CLR C (清零进位位) | 12 | 1 | 1 | 1 |
CLR bit (清零直接地址位) | 12 | 4 | 3 | 1 |
SETB bit (置一直接地址位) | 12 | 4 | 3 | 1 |
INC Rn (寄存器加一) | 12 | 3 | 2 | 1 |
DEC Rn (寄存器减一) | 12 | 3 | 2 | 1 |
ADD A, direct (直接地址单元中的数据加到累加器) | 12 | 3 | 2 | 1 |
SUBB A, #data (累加器带借位键立即数) | 12 | 2 | 2 | 1 |
JZ rel (累加器为零转移) | 24 | 3 | 4 | 条件不成立不转移1时钟;条件成立则转移3时钟 |
JNC rel (进位为零转移) | 24 | 3 | 3 | 条件不成立不转移1时钟;条件成立则转移3时钟 |
CJNE Rn, #data, rel (寄存器与立即数相比较,不相等则转移) | 24 | 4 | 4 | 条件不成立不转移2时钟;条件成立则转移3时钟 |
SJMP rel (相对转移) | 24 | 3 | 3 | 3 |
DJNZ Rn, rel (寄存器减1,非零转移) | 24 | 4 | 4 | 条件不成立不转移2时钟;条件成立则转移3时钟 |
LCALL addr16 (长调用子程序) | 24 | 6 | 4 | 3 |
RET (子程序返回) | 24 | 4 | 4 | 3 |
NOP (空指令) | 12 | 1 | 1 | 1 |
先来分析时序,在ws2812的datasheet中可以看到TH+HL=1.25us,假设主频为12MHz,1.25us即0.00000125/(1/12M)=15个周期,也就是说我们要在15个周期内执行一定数量的指令来完成IO置高、IO置低、数据移位、跳转等所有必要的操作!
略去漫长的调试过程,最后我写出了最精简的汇编代码
void ws2812asm(unsigned char dat)
{
#pragma asm
MOV A, R7
MOV R6, #0x08
WS2812LOOP:
SETB P3.7
RLC A
MOV P3.7, C
NOP
CLR P3.7
DJNZ R6, WS2812LOOP
#pragma endasm
}
来分析一下这个代码,R7即函数调用时传入的dat,将dat放入累加器A中,然后将8放入R6中作为循环计数,这里我用的是P3.7引脚,用SETB语句将它置为高电平,然后用RLC指令将dat左移一位,最高位进入进位位C,使用MOV语句将进位位的值赋给P3.7引脚,NOP延时,之后将P3.7引脚置为低电平,DJNZ将计数值减一不为零则跳转到WS2812LOOP继续执行。整个代码的循环体耗时为3+1+3+1+3+4=15个CLK,完美!
我们从WS2812LOOP处分析一下时序,假设调用函数前发送了复位信号,此时IO状态为低电平
CLK | 时间/us | 语句 | IO状态 | 说明 |
---|---|---|---|---|
1 | 0.083 | SETB P3.7 | 0 | |
2 | 0.166 | SETB P3.7 | 0 | |
3 | 0.250 | SETB P3.7 | 0 | |
4 | 0.333 | RLC A | 1 | SETB语句执行完毕,IO被置高 |
5 | 0.417 | MOV P3.7, C | 1 | |
6 | 0.500 | MOV P3.7, C | 1 | |
7 | 0.583 | MOV P3.7, C | 1 | |
8 | 0.667 | NOP | 0/1 | 这里IO的状态为0或1 |
9 | 0.750 | CLR P3.7 | 0/1 | 如果发送的数据为0即为0 |
10 | 0.833 | CLR P3.7 | 0/1 | 否则为1 |
11 | 0.916 | CLR P3.7 | 0/1 | |
12 | 1.000 | DJNZ R6, WS2812LOOP | 0 | |
13 | 1.083 | DJNZ R6, WS2812LOOP | 0 | |
14 | 1.166 | DJNZ R6, WS2812LOOP | 0 | |
15 | 1.250 | DJNZ R6, WS2812LOOP | 0 |
如果发送的数据为0,则高电平为0.33us,低电平为0.92us
如果发送的数据为1,则高电平为0.67us,低电平为0.58us
这个时序基本符合datasheet中的要求,误差也在±150us以内。
简单验证一下,试试看看究竟能不能驱动ws2812
void main()
{
WS2812_IO = 1;
Delay100us();
WS2812_IO = 0;
Delay100us();
ws2812asm(100);
ws2812asm(0);
ws2812asm(0);
while(1)
{
}
}
事实证明这个代码完全可以正常驱动ws2812!
但是,等等,好像有哪里不对,我们这里只考虑循环体的执行时间,循环体外有两个MOV语句,函数调用前会有一个MOV语句将参数传入寄存器R7,之后LCALL语句调用函数,调用完之后还有RET语句返回,这样一来整个函数调用的总时间为3+4+1+2+14+4=28个CLK,约2.4us,超过了手册中要求的1.25us,即使算上600ns的误差也还是超时了,但是ws2812却被正常点亮了!也就是说即使时序不完全符合datasheet中的要求也是可以工作的!这是非常重要的一点!最后经过我的反复实验得出一个结论:小于45us的高电平为判定为逻辑0,大于45us的高电平被判定为逻辑1,低电平的时长只要不要超过复位信号的时长都可以完成数据的传输!
有了这个结论,我们的编程工作就会轻松许多,只要我们将循环的跳转、赋值、计算灯珠颜色等耗时的操作放在低电平时就行了。
作为一个有最求的人,我觉不满足于此,汇编代码比C代码要难一些,阅读也有点费劲,那么能不能写出符合时序的C代码呢?来尝试一下。
置位、清位、左移这三个操作时必须的,完全没有优化空间,那么只剩一个循环可以优化。前文说到要详细研究一下各种循环语句,一般处理器中都有为零跳转指令、减一为零跳转等类似指令,使用自减计数只需要一条指令即可完成循环的跳转,若果使用自加计数,一般会被编译为自加、判断、跳转三条指令,因此一般来说while循环配合自减计数是效率最高的。但是,对于GNU等编译器,它们会充分利用目标平台的指令的特点进行优化,甚至将for自加的循环优化为while自减的循环,另外对于PC或其他高性能的平台,一个循环上损失的效率可以忽略不计,编程时不用在这种细节上纠结。杠精自重!
我这里写了6种循环,编译出来逐一进行分析(这里统一采用无符号数,我知道还有有符号数、浮点数等计数方式,>=、<=、==等判断条件的方式,精力有限,有兴趣的自己研究,杠精自重!)
void test1()
{
u8 i = 8;
while(i)
{
_nop_();
i--;
}
}
C:0x19BE MOV R7,#0x08
C:0x19C0 NOP
C:0x19C1 DJNZ R7,C:19C0
C:0x19C3 RET
汇编代码非常简单,是所有循环中效率最高的,只用一条DJNZ语句就完成了减一、判断和跳转,除去NOP语句,整个循环体耗时为4个CLK。
void test2()
{
u8 i = 8;
while(i)
{
i--;
_nop_();
}
}
C:0x199C MOV R7,#0x08
C:0x199E MOV A,R7
C:0x199F JZ C:19A5
C:0x19A1 DEC R7
C:0x19A2 NOP
C:0x19A3 SJMP C:199E
C:0x19A5 RET
和test1()函数的区别在于语句的顺序不同,自减、判断和跳转被拆分成三条语句,除去NOP语句,整个循环体耗时1+4+2+3=10个CLK
void test3()
{
u8 i = 0;
while(i < 8)
{
_nop_();
i++;
}
}
C:0x1993 CLR A
C:0x19B0 MOV R7,A
C:0x19B1 NOP
C:0x19B2 INC R7
C:0x19B3 CJNE R7,#0x08,C:19B1
C:0x19B6 RET
与前两个函数不同,这里采用自加计数循环,除去NOP语句,循环体耗时2+4=6个CLK
void test4()
{
u8 i = 0;
while(i < 8)
{
i++;
_nop_();
}
}
C:0x008E CLR A
C:0x008F MOV R7,A
C:0x0090 MOV A,R7
C:0x0091 CLR C
C:0x0092 SUBB A,#0x08
C:0x0094 JNC C:009A
C:0x0096 INC R7
C:0x0097 NOP
C:0x0098 SJMP C:0090
C:0x009A RET
同样是自加计数循环,这是所有循环中效率最低的一个,除去NOP语句,整个循环体用了6条语句,耗时1+1+2+3+2+3=12个CLK
void test5()
{
u8 i = 8;
for(i; i != 0; i--)
{
_nop_();
}
}
C:0x0080 MOV R5,#0x08
C:0x0082 NOP
C:0x0088 DJNZ R5,C:0082
C:0x008A RET
这个编译出来的结果和test1()一样,略过
void test6()
{
u8 i = 0;
for(i; i < 8; i++)
{
_nop_();
}
}
C:0x1965 CLR A
C:0x1966 MOV R5,A
C:0x1967 NOP
C:0x196D INC R5
C:0x196E CJNE R5,#0x08,C:1967
C:0x1971 RET
这个编译出来的结果和test3()一样,略过
所以我们可以得出结论,采用while自减循环的方式效率最高,调试的过程略过,最后我们得到如下代码
void ws2812_write_byte(u8 dat)
{
u8 i = 8;
dat <<= 1;
while(i)
{
WS2812_IO = 1;
WS2812_IO = CY;
WS2812_IO = 0;
dat <<= 1;
i--;
}
}
来看一下上面的C代码编译后对应的汇编代码,比我写的汇编代码效率稍微低一点,但是完全可以正常工作。我不是针对某些编译器(GNU编译的代码真的让我感叹写出这种编译器的人真牛B),在这里我只说C51这个编译器,它编译出的代码不一定比自己写的更好,我知道设置里面可以选择优化体积或优化速度,我这里采用默认选择第8级优化,Reuse Common Entry Code,优化速度Fever Speed(杠精自重)。
C:0x1AB3 MOV R6,#0x08
C:0x1AB5 MOV A,R7
C:0x1AB6 ADD A,ACC(0xE0)
C:0x1AB8 MOV R7,A
C:0x1AB9 SETB P37(0xB0.7)
C:0x1ABB MOV P37(0xB0.7),C
C:0x1ABD CLR P37(0xB0.7)
C:0x1ABF MOV A,R7
C:0x1AC0 ADD A,ACC(0xE0)
C:0x1AC2 MOV R7,A
C:0x1AC3 DJNZ R6,C:1AB9
C:0x1AC5 RET
C语言中的左移、右移依照被操作数是有符号数或无符号数被编译为算数左移、右移或逻辑左移右移指令,C51中的RL、RLC、RR、RRC这4条指令都是循环左移、右移,没有算数左移、右移和逻辑左移右移指令,因此在C51中被编译为MOV A,R7;ADD A,ACC(0xE0);MOV R7,A 这3条指令,执行的时间更长了。ADD A,ACC(0xE0)这句可能不太好理解,单独讲一下,这句是将直接地址单元中的数据加到累加器,这里的直接地址单元就是ACC,它的地址是0xE0,查阅手册我们发现0xE0这个地址就是累加器A本身,也就是说将累加器自己的内容加上自己,也就是相当于乘2,也就是左移一位。
最终我们得出了驱动ws2812最精简的C语言代码,可以正常工作于12MHz或11.0592MHz,其他主频请根据实际情况增减NOP语句的数量。
void ws2812_write_byte(u8 dat)
{
u8 i = 8;
dat <<= 1;
while(i)
{
WS2812_IO = 1;
// 如果主频较高可在此处适当增加_nop_():
// 将下面的dat <<= 1;移至此处也可以
WS2812_IO = CY;
WS2812_IO = 0;
dat <<= 1;
i--;
}
}
再次强调,本代码仅用于STC-Y5指令集的单片机,包括STC15Fxx、STC15Lxx、STC15Wxx系列,不包括STC15F104E(A版)、STC15L104E(A版)、STC15F204EA(A版)、STC15L204EA(A版)。由于ws2812对时序的要求较高,本代码高度依赖这些指令的执行时间,对于其他STC单片机或其他架构单片机完全不保证 能正常驱动ws2812,请根据指令的执行周期、MCU主频自行修改!
结论
- 复位信号为50us以上的低电平,复位信号不会熄灭已经点亮的灯珠(假设已经发送了5个红色数据,此时复位,然后又发送2个蓝色数据,那么灯珠点亮的状态为蓝蓝红红红,而不是蓝蓝灭灭灭)
- 小于0.45us的高电平为逻辑0,大于0.45us的高电平为逻辑1,低电平的时长不要超过复位信号的时长
- 每位或每字节或每3字节传输完成后建议保持低电平,如果保持高电平且持续时间大于0.45us就会被认为逻辑1,假设下一个传输的数据是0就会出错,因此只建议在完成所有传输后保持高电平
- 因为每位数据传输完成后是低电平,因此发送下一位数据的时间间隔不要超过50us,否则会被判定为复位信号,因此可以充分利用50us以内的低电平的这段时间做一些比较费时的操作,如读取数据、计算下一个灯珠的颜色等
- 只有收到完整的24位数据才会点亮一颗灯珠,之后的数据被传送到下一颗灯珠,任意时刻都可以发送复位信号,未传输完成的数据会被丢弃
以上结论仅代表个人观点共大家参考,并不等同于官方说面,虽说是参考,但也非常有意义,其中部分细节datasheet中并未说明,以让大家少走弯路。市场上有ws2812的兼容灯珠,个人精力有限,对于这些兼容灯珠本人不保证此结论的正确性。