ws2812驱动时序分析 C/C++

admin 2020-5-6 1877

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。


先来分析时序,在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状态为低电平

如果发送的数据为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_();
}
}

这个编译出来的结果和test1()一样,略过

void test6()
{
u8 i = 0;
for(i; i < 8; i++)
{
_nop_();
}
}

这个编译出来的结果和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主频自行修改!

结论

1.复位信号为50us以上的低电平,复位信号不会熄灭已经点亮的灯珠(假设已经发送了5个红色数据,此时复位,然后又发送2个蓝色数据,那么灯珠点亮的状态为蓝蓝红红红,而不是蓝蓝灭灭灭)

2.小于0.45us的高电平为逻辑0,大于0.45us的高电平为逻辑1,低电平的时长不要超过复位信号的时长

3.每位或每字节或每3字节传输完成后建议保持低电平,如果保持高电平且持续时间大于0.45us就会被认为逻辑1,假设下一个传输的数据是0就会出错,因此只建议在完成所有传输后保持高电平

4.因为每位数据传输完成后是低电平,因此发送下一位数据的时间间隔不要超过50us,否则会被判定为复位信号,因此可以充分利用50us以内的低电平的这段时间做一些比较费时的操作,如读取数据、计算下一个灯珠的颜色等

5.只有收到完整的24位数据才会点亮一颗灯珠,之后的数据被传送到下一颗灯珠,任意时刻都可以发送复位信号,未传输完成的数据会被丢弃

以上结论仅代表个人观点共大家参考,并不等同于官方说面,虽说是参考,但也非常有意义,其中部分细节datasheet中并未说明,以让大家少走弯路。市场上有ws2812的兼容灯珠,个人精力有限,对于这些兼容灯珠本人不保证此结论的正确性。

最新回复 [0]
返回