【ESP32 IDF】用RMT控制 WS2812 彩色灯带

在上一篇中,老周用 .NET Nano Framework 给大伙伴们演示了 WS2812 灯带的控制,包括用 SPI红外RMT 的方式。利用 RMT 是一个很机灵的方案,不过,可能很多大伙伴对 ESP32 的 RMT 不是很熟悉。除了乐鑫自己的文档,没几个人写过相关的水文,可见这里头空白的水市场很充足,老周一时手痒,就决定再水一篇博文。

不管你有没有做过物联网项目,只要你有关注,你就会发现,当今时尚流行忽悠不擦嘴巴。许多教程就拿个 MicroPython 或者 Arduino,贴几行代码,然后叫你烧录进去看效果。可是,效果看完了,你知道了啥?你学到了啥?你知道这里头做了啥?全 TM 不知。做教程的人只管忽悠,然后就没下文了。这就是它们老喜欢用 Python 的原因。基于脚本语言的特性,很多库都是高度封装的,拿来直接敲几行代码就完事了。写教程的是这样,做培训的也是这样。

用 Arduino 好不好?好,开柜即用;用 MicroPython 好不好?好,开桶即用。这就是现在为什么 Py 流行的原因,做培训的演示起来多起劲,几行代码(估计他们为这几行代码都练了无数次,都背下来了)天天敲,而且这么简单的代码,现场演示也不怕出错,然后告诉你:看看,做 AI,做 Iot 多简单!但是,老周是很 BS 这些人的,只告诉你吃鱼很香,却不告诉你怎么捕的鱼。Python 不是不能用,而是你不能指望凭它来学编程。脚本语言本来就是做辅助用的。

如果你一开始用的是 C 语言,就算你没在做项目,你反而可以坚持玩几年,甚至十几年。哪怕业余玩玩,也能一层一层地挖掘出很多有趣的东西。

还有一种更离谱的观点:Py 适合科研人员,可以快速验证结果。C语言留给开发的苦逼去干。老周认为:做科研的人在底层和基础知识方面更应该比开发的人强,不然你研究个鸵鸟蛋!连基本的原理和细节都搞不清楚,那就是纸上谈兵,洗钱罢了。就像现在某些建筑,某些服装,为什么会出现许多反人类设计;很多产品也是反人类设计?正是因为做设计的人对生产、对技术、对基础原理不了解,闭上眼睛无脑瞎编乱涂。有些设计人员对自己、对产品、对他人也是不负责的,自己设计的东西做出来,也不去试用一下,看看你设想的东西多么不靠谱。

所以,老周写的东西,一直以来都是立足于实际使用的,而不是立足炒作和无脑吹。吹得天花乱坠,如果用起来很难用的东西,老周是不会推荐的。

好了,不小心扯了一堆没用的了。有大伙伴可能说老周这么批别人不会得罪人吗?得不得罪有啥关系呢,老周跟他们又不是一伙的,没有利益关系,他们敢拿导弹轰老周吗?

OK,不扯蛋了。说回 RMT,ESP32 中,一个周期 RMT 消息共 32 位,分两段,每段16位。然后老周给你画个图。

别以为 32 个位这么多能描述一整条消息,不是的,它只描述了一个脉冲周期罢了。你看,这个脉冲是不是被分成了两段?为什么要分成两段?因为这样就能说清楚了:高电平占了多长时间,低电平占了多长时间。也就是说,这一帧的数据包含了两个电平的参数。

1-16 位是第一个电平,前15位表示该电平持续的时间,最后一位(图中的 L)表示电平,1表示高电平,0表示低电平;

17-32 位是第二个电平,前15位表示该电平的时长,最后一位表示电平,1是高,0是低。

举个例子:

0000 1101 0011 0111
0011 1111 0101 1000

先看第一行,最后一位是1,说明是高电平,时长就是 0000 1101 0011 011,不含最后一位。

第二行呢,最后一位是0,说明是低电平,时长就是 0011 1111 0101 100,不含最后一位。

如果整个脉冲全是低电平呢,那就这样:

0000 0000 1111 0110
0000 0000 0110 1010

最后一位都是0,就表明这个周期没有高电平。于是,你能想到,如果一个周期内全是高电平呢,是不是这样?

0000 1111 0101 0011
0000 1000 0110 0111

至于电平的时间长度是单位,这个要看定时器的频率的。还记得吗?上一篇水文中,老周说默认用的是 APB 时钟,80 MHz,假设我们分频后让定时器的频率变成 1 MHz,即 1 000 000 Hz,然后 1s / 1000000 = 0.000001 秒,即 1 微秒(us)/ Tick。那么,这个15位的整数就和微秒数一致。

现在,你明白了 RMT 是怎么描述一个脉冲的了,于是,IDF 中有这么个类型:

typedef union {
    struct {
        uint16_t duration0 : 15;
        uint16_t level0 : 1; 
        uint16_t duration1 : 15; 
        uint16_t level1 : 1; 
    };
    uint32_t val; 
} rmt_symbol_word_t;

咦,这个类型咋这么怪啊?不怪,这种货叫做内联,说人话就是:里面的结构体和 val 的值共用内存。

前面的 struct 有四个字段:

duration0:第一个电平的时长,后面的冒号和15表示它占 15 位;

level0:表示第一个电平值,占一位;

duration1:第二个电平的时长,占 15 位;

level1:第二个电平的值,占一位。

那么,我问你,这四个字段加起来多位,是不是 32 ?val 的类型是 uint32 ,无符号32位整数。前面的结构体和 val 是不是大小相同?都是4个字节?是吧,于是,它们用同一块内存,也就是说,这个 rmt_symbol_word_t 你可以用四个字段去设置它,也可以直接用一个整数去设置。C 语言是直接操作内存的,可以强制转换,在后面调用相关函数时,可以取地址直接赋值给 void* (指针)。

请你记住这个类型,你可以字面翻译为”符号字“,或者叫 RMT 描述符号。记好了,一个符号字只描述一个周期的脉冲哦。要是向 WS2812 发数据,RGB共 24 位,一个灯珠你就要发 24 个 符号字,点亮两个灯就发 48 个符号字。我要点100个灯呢,那就 24*100 呗。你不妨理解为:一个符号字就是代表一个二进制位。有几个二进制位就得发送几个符号。

这里要说明一点:.NET Nano Framework 用的 IDF 是 4xx 的,而目前新的版本是 5xx 的,新旧版本之间在 RMT 操作上有很大区别,函数也不同。不过,原理差不多,说直白一点就是:把内存中的 rmt_symbol_word_t 队列发送出去。

由于版本更新,.NET Nano Framework 后面肯定要适配新版 IDF 的,所以,老周决定用新的版本的方式演示。在新版本 API 中,不需要分频设定了。其实直接设置频率更好,尤其是对初学者,总觉得分频很难懂。不过老周可以把分频总结为:把总线/或CPU/或其他振荡源的频率除以某个数,得到更低的频率。即原来的频率太高了,要降一降。比较,原频 120 MHz,分频系数为 4,那就调整为 120/4 = 30 MHz。树莓派(Raspberry Pi Pico)Pico 的官方SDK中,PWM的频率也是用到了分频。不过小草莓先分频,再计数。先把频率降一下,然后周期性地数 256(0-255),如果计数满 255 重新回到 0,再计数。所以,RPI Pico 的 PWM 频率其实算起来挺麻烦,要考虑分频,还要考虑计数次数。

ESP 32 新的 IDF 直接让你配置频率了,这样更方便更直观。

下面老周说说 RMT API 怎么用。不要听别人造谣,说 IDF 很难用,其实不难用的。毕竟是官方的,功能很全,官方团队直接维护。老周安装 IDF 就没失败过,这里再次强调用两点,保证你能成功安装:1、装好 Python 后,pip 改国内源;2、在 VS Code 的 Esp 插件中下载 IDF时,选乐鑫的服务器,不要选 github。

然后,其他选项你随意。其实它无非就用到两个目录,一个放 IDF 的源码,一个放编译的 tools。然后会设置环境变量 IDF_PATH 等。

下面请记住一个万能规律,不管你用的什么开发板,什么芯片,什么平台,所有外部设备的通信都是这样的流程:

1、配置参数;

2、init(初始化);

3、加载驱动(一般在 init 时就完成,这一步许多平台可省略);

4、读/写数据;

5、清理资源。

一、配置阶段

RMT API 定义专门的结构体,用于配置参数。

typedef struct {
    gpio_num_t gpio_num;
    rmt_clock_source_t clk_src;
    uint32_t resolution_hz; 
    size_t mem_block_symbols;  
    size_t trans_queue_depth; 
    int intr_priority; 
    struct {
        uint32_t invert_out: 1;  
        uint32_t with_dma: 1;    
        uint32_t io_loop_back: 1;
        uint32_t io_od_mode: 1;  
    } flags; 
} rmt_tx_channel_config_t;

这是配置发送的,如果接收数据,要用 rmt_rx_channel_config_t,用起来一样,搞懂一个,另一个就懂了。注意,接收和发送的函数是分布在两个头文件中的,发送是 rmt_tx.h,接收是 rmt_rx.h。因为驱动 WS2812 是输出,属于发送模式,咱们只用 rmt_tx_channel_config_t 结构体。

不要看它那么多成员,其实,在实际使用时,咱们不需要全都用,不用的保持默认(不赋值就是了)。

gpio_num:用来发信号的引脚,GPIO 号。这个可用枚举值(在 gpio_num.h 头文件中),如 GPIO_NUM_0 表示 GPIO0,GPIO_NUM_33 表示 GPIO33,也可以直接用整数,如 33、25、8 等。

clk_src:振动的时钟源,可以用 RMT_CLK_SRC_DEFAULT 表示默认值,即用 APB 时钟,80兆那个。一般不用选其他,毕竟不是每个板子都通用,默认是比较通用。

resolution_hz:这个就是直接设置频率了,不用思考分频的事了。

mem_block_symbols:分配内存量,常用 64。注意它的大小不是字节,而是 符号字(rmt_symbol_word_t),就是最开始咱们介绍那个,32位两个阶段那个,描述两个电平时长的。比如,设置64就是分配的内存可以放 64 个符号字,字节是 64 * 4,32位嘛,是吧,前面反复说了。

trans_queue_depth:队列深度,一般不要太大,4 或 8 均可。数据在传输时,不是马上就发出去的,而是放进一个队列中,然后驱动层会调度这个队列,慢慢发(其实很快发完)。设置为4表示队列中可以放(挂起)4条等待传输的符号字。

intr_priority:中断的优先值,非特殊情况保持默认。

另外,此结构体内嵌了一个 flags 结构体。

invert_out:是否电平反向,1表示开启。就是反转电平,比如,本来高的变低,低的变高。这个一般不用;

with_dma:是否走 DAM 通道,不占用CPU运算资源;

io_loop_back:就跟在电脑上 ping 127.0.0.1 一样,“我发给我自己”,即自发自收(在同一引脚上)。这个一般没啥用。

io_od_mode:是否设置为开漏模式。

二、初始化阶段

配置完相关参数后,调用 rmt_new_tx_channel 函数,用已配置的参数创建通信通道。

esp_err_t rmt_new_tx_channel(const rmt_tx_channel_config_t *config, rmt_channel_handle_t *ret_chan);

config 引用配置结构体实例,ret_chan 接收创建的通道句柄,后面在发送数据时要用。所以,在调用此函数前,先声明一个 rmt_channel_handle_t 类型的变量,最后是全局的。

新版 API 虽然精简了许多,但也有缺点:在配置好参数创建通道后,就不能再修改参数了,除非重新初始化。而旧版 API 是可以修改的。

三、启用通道

调用 rmt_enable 函数启用通道。

esp_err_t rmt_enable(rmt_channel_handle_t channel);

channel 就是刚刚创建的通道。这一步很关键,也很容易遗忘。不启用通道的话,是无法接收和发送数据的。如果忘了,你测试来测试去,死活不能工作,你甚至会怀疑自己写错了协议。如果要禁用通道,可以调用 rmt_disable 函数。

esp_err_t rmt_disable(rmt_channel_handle_t channel);

这两个函数都声明在 rmt_common.h 头文件中。

四、创建编码器

创建编码器可以在启用通道之前完成,第【三】、【四】阶段顺序不重要。IDF 内置两个编码器:

1、bytes encoder:就是把你给它的字节数组转换为符号字(前面说过的 rmt_symbol_word);

2、copy encoder:这玩意儿很玄,如果你看官方文档介绍可能会怀疑人生,不知道说啥。老周用一句话概括:这货就是不处理不转换,你直接把符号字传给它,然后它复制到驱动层的内存中,放入队列准备发送。数据只是被复制,不会修改。这是防止让驱动空间的代码跨空间引用用户代码,那样有内存泄漏的风险,复制数据就不存在跨空间长距离引用,发完就清理。用户代码可能长期保持数据的生命周期。

当然,你可以写自己的编码器(组合使用内置的编码器)。若要自定义,请认识一下 rmt_encoder_t 结构体。

struct rmt_encoder_t {
    /* 编码时用 */
    size_t (*encode)(rmt_encoder_t *encoder, rmt_channel_handle_t tx_channel, const void *primary_data, size_t data_size, rmt_encode_state_t *ret_state);

    /* 重置编码器参数时用 */
    esp_err_t (*reset)(rmt_encoder_t *encoder);

    /* 清理编码器时用 */
    esp_err_t (*del)(rmt_encoder_t *encoder);
};

这个结构体的成员都是函数指针,你让它们分别指向你定义的函数,就实现了自定义编码了。这个东西你可能看得很绕,为什么函数的输入参数还要 rmt_encoder_t ?这是因为 C 结构体不能继承,要想实现类开继承的功能,就得定义一个更大的结构体,然后大结构体中引用 rmt_encoder_t,模拟调用基类成员。由于 IDF 支持 C++,为了好用,你不妨用 C++ 类去封装。

看看官方的源码是怎么封装的。

typedef struct rmt_bytes_encoder_t {
    rmt_encoder_t base;     // encoder base class
    size_t last_bit_index;  // index of the encoding bit position in the encoding byte
    size_t last_byte_index; // index of the encoding byte in the primary stream
    rmt_symbol_word_t bit0; // bit zero representing
    rmt_symbol_word_t bit1; // bit one representing
    struct {
        uint32_t msb_first: 1; // encode MSB firstly
    } flags;
} rmt_bytes_encoder_t;

typedef struct rmt_copy_encoder_t {
    rmt_encoder_t base;       // encoder base class
    size_t last_symbol_index; // index of symbol position in the primary stream
} rmt_copy_encoder_t;

就是定义一个结构体,然后里面有个 base,base 就是 rmt_encoder_t 类型,这就等于从抽象基类派生出 rmt_bytes_encoder和rmt_copy_encoder类型,其他成员则用于参数配置。访问 encode、reset、del 函数指针时就通过 S.base.encode(....) 来调用。当然,你自己写的话不一定要搞那么复杂,就是按 rmt_encoder_t 结构的三个函数指针成员,引其引用你写的函数就行了。

初始化 bytes encoder 使用 rmt_new_bytes_encoder 函数,初始化 copy encoder 使用 rmt_new_copy_encoder 函数。调用函数前,先声明 rmt_encoder_handle_t 类型的变量,该变量会引用创建的编码器,由函数的 ret_encoder 参数赋值。

esp_err_t rmt_new_bytes_encoder(const rmt_bytes_encoder_config_t *config, rmt_encoder_handle_t *ret_encoder);
esp_err_t rmt_new_copy_encoder(const rmt_copy_encoder_config_t *config, rmt_encoder_handle_t *ret_encoder);

创建编码器后用变量保存引用,不需要我们手动调用,传输数据时会自动调用。

五、发送数据

发送数据调用 rmt_transmit 函数,参数包括:刚创建的通道、编码器,以及要发送的符号字数组(多个符号字一同推入队列,不必一个一个推)。调用此函数只是把消息放进传输队列,至于是否立即发送,那看队列里面拥不拥挤了,由驱动层自行处理,我们不用管。

如果你不使用中断,但希望等到数据发出去了再执行后面的程序代码,那可以调用 rmt_tx_wait_all_done 函数,它会等待指定的时间,直到数据发送出去才返回。等待时间可以用最大值—— portMAX_DELAY。

六、清理

如果你的程序不是一直发数据,或只是特定时候发送。那传输完数据后应当清理相应的对象。

rmt_del_encoder:清除刚创建的编码器。

rmt_disable:禁用通道。

rmt_del_channel:清除通道。

如果程序一直发数据,可以不清理。

 

官方有一个示例是用 RMT 驱动灯带的,但那个用了混合编码器,弄得有点复杂,老周这里直接用 copy encoder 复制符号字。符号字咱们自己生成。

先做好初始化工作。

1、声明相关参数。

// 声明区
#define GPIO_NUM 6             // 引脚号
#define TICK_FREQ 10 * 1000000 // 频率
#define LED_NUM 24             // 灯珠数目

这里我把频率设置为 10 MHz,即一 tick 为 0.1 us。因为 WS2812 的电平时长有 0.2-0.8 us,所以要把 Tick 精确到 0.1 us,这样好控制。

2、声明全局变量。

static rmt_channel_handle_t txChannel;
/* 编码器 */
static rmt_encoder_handle_t rfEncoder;
/* 消息符号 */
static rmt_symbol_word_t zeroSymbol, oneSymbol, resetSymbol;
/* 要传输的颜色数据 */
static rmt_symbol_word_t rgbSymbols[24 * LED_NUM] = {0};

注意符号字数组,大小是灯珠数 * 24。为什么24呢?因为 RGB 数据加起来24位,一个符号字只能描述一个位。

zeroSymbol 表示发送 0 时的电平,表示发送 1 时的电平,resetSymbol 是复位电平,每发完一次数据都要一个复位电平,告诉 WS2812 我这儿发送完了。这几个电平信息的初始化代码:

void init_symbols()
{
    // 0码高电平
    zeroSymbol.duration0 = 0.4 * (TICK_FREQ / 1000000);
    zeroSymbol.level0 = 1;
    // 0码低电平
    zeroSymbol.duration1 = 0.8 * (TICK_FREQ / 1000000);
    zeroSymbol.level1 = 0;

    // 1码高电平
    oneSymbol.duration0 = 0.8 * (TICK_FREQ / 1000000);
    oneSymbol.level0 = 1;
    // 1码低电平
    oneSymbol.duration1 = 0.4 * (TICK_FREQ / 1000000);
    oneSymbol.level1 = 0;

    // 复位信号全为低电平
    resetSymbol.duration0 = 25 * (TICK_FREQ / 1000000);
    resetSymbol.level0 = 0;
    resetSymbol.duration1 = 25 * (TICK_FREQ / 1000000);
    resetSymbol.level1 = 0;
}

0 码这里设置的是 高电平持续 0.4 us,低电平持续 0.8 us;1 码相反。这里0.3-0.4,0.7-0.8都可以,老周这里设置大一点的值,不容易抽风。如果设置0.3 和 0.7,在 ESP32 Pico 上有时候会抽风(有的灯珠不亮或颜色不对)。

这个时间算的是 tick 周期计数,我们设的频率是每周期 0.1 us,除以1000000 就是一微秒内会 tick 多少次,这里就是 1 us tick 10 次,那么,0.4 us 就是 tick 0.4 * 10 = 4 次。就是这么算出来的。复位信号全是低电平,按数据手册是最少 50us,这里把50分两段,即电平1=25us,电平2=25us,电平值全为0。

那么,RGB 怎么转为符号字呢?WS 2812c 中是 GRB 排列的,其他的芯片可以查资料,或者多次试验来验证顺序。颜色值总共就 24 位,更简洁的方法是用一个 32 位整数来表示一个颜色。发送时从高位开始处理,每处理一位,就向左移一位。直接看代码。

void set_rgb(int index, uint32_t grb)
{
    if (index < 0 || index > LED_NUM - 1)
    {
        return; // 索引无效
    }
    // 循环的开始和结束索引
    int startIdx = index * 24;
    int endIdx = startIdx + 24;
    for (int i = startIdx; i < endIdx; i++)
    {
        if (grb & 0x00800000)
        {
            // 1
            rgbSymbols[i] = oneSymbol;
        }
        else
        {
            // 0
            rgbSymbols[i] = zeroSymbol;
        }
        // 左移一位
        grb <<= 1;
    }
}

index 是某个灯珠的索引,每一次处理都跟 0x00800000 进行“与”运算,就是确定第 24 位(最高)位是否为1,若为1就用 oneSymbol 变量的值,若为0就用 zeroSymbol 变量的值,赋值一轮后,让颜色值左移一位,就能实现从高位到低位发送了。

下面代码初始化发送通道和编码器。

void init_tx_channel()
{
    rmt_tx_channel_config_t cfg = {
        // GPIO
        .gpio_num = GPIO_NUM,
        // 时钟源:默认是APB
        .clk_src = RMT_CLK_SRC_DEFAULT,
        // 分辨率,即频率
        .resolution_hz = TICK_FREQ,
        // 内存大小,指的是符号个数,不是字节个数
        .mem_block_symbols = 64,
        // 传输队列深度,不要设得太大
        .trans_queue_depth = 4
        // 禁用回环(自己发给自己)
        //.flags.io_loop_back=0
    };
    // 调用函数初始化
    ESP_ERROR_CHECK(rmt_new_tx_channel(&cfg, &txChannel));
}

void init_encoder()
{
    // 目前配置不需要参数
    rmt_copy_encoder_config_t cfg = {};
    // 创建拷贝编码器
    ESP_ERROR_CHECK(rmt_new_copy_encoder(&cfg, &rfEncoder));
}

调用 API 时,可以嵌套在 ESP_ERROR_CHECK 宏中,它会自动检查调用是否成功,不成功就输出错误。

下面代码发送数据。

void send_data()
{
    // 配置
    rmt_transmit_config_t cfg = {
        // 不要循环发送
        .loop_count = 0};
    // 发送
    ESP_ERROR_CHECK(rmt_transmit(txChannel, rfEncoder, rgbSymbols, sizeof(rgbSymbols), &cfg));
    // 等待发送完毕
    // ESP_ERROR_CHECK(rmt_tx_wait_all_done(txChannel, portMAX_DELAY));
    // 发送复位信号
    ESP_ERROR_CHECK(rmt_transmit(txChannel, rfEncoder, &resetSymbol, 1, &cfg));
    // 等待完成
    ESP_ERROR_CHECK(rmt_tx_wait_all_done(txChannel, portMAX_DELAY));
}

 

在 app_main 函数中,先显示红色,一秒后显示蓝色,再过一秒显示绿色。

while (1)
{
    // 红色
    for (i = 0; i < LED_NUM; i++)
    {
        set_rgb(i, COLOR_U32(0xff, 0x0, 0x0));
    }
    send_data();
    // 延时
    vTaskDelay(1000 / portTICK_PERIOD_MS);
    // 蓝色
    for (i = 0; i < LED_NUM; i++)
    {
        set_rgb(i, COLOR_U32(0x0, 0x0, 0xff));
    }
    send_data();
    // 延时
    vTaskDelay(1000 / portTICK_PERIOD_MS);
    // 绿色
    for (i = 0; i < LED_NUM; i++)
    {
        set_rgb(i, COLOR_U32(0x0, 0xff, 0x0));
    }
    send_data();
    // 延时
    vTaskDelay(1000 / portTICK_PERIOD_MS);
}

vTaskDelay 是 RTOS 系统移植函数,表示当前任务延时。注意这个延时函数的参数不是秒或毫秒,而是“跑多少圈” Tick。portTICK_PERIOD_MS 表示一毫秒 Tick 的步数。为什么是相除,不是相乘?这个,老周举一个不太恰当的例子:假如你跑一圈有 2000 步,现在我要你跑 8000 步,问你要跑几圈 ?答案就是 8000 / 2000 = 4 圈。就是这样。

这些 RTOS 函数在包含头文件时得小心,你得先包含 FreeRTOS.h,然后再包含其他头文件,否则容易报错。

下面是完整代码:

#include <stdlib.h>
#include <string.h>
#include "driver/rmt_common.h"
#include "driver/rmt_encoder.h"
#include "driver/rmt_types.h"
#include "driver/rmt_tx.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

// 声明区
#define GPIO_NUM 6             // 引脚号
#define TICK_FREQ 10 * 1000000 // 频率
#define LED_NUM 24             // 灯珠数目
// #define DELAY_MS 20            // 延时

// 将RGB转为GRB整数
#define COLOR_U32(r, g, b) ( \
    (uint32_t)g << 16 |      \
    (uint32_t)r << 8 |       \
    (uint32_t)b)

// 变量区
/* 发送通道 */
static rmt_channel_handle_t txChannel;
/* 编码器 */
static rmt_encoder_handle_t rfEncoder;
/* 消息符号 */
static rmt_symbol_word_t zeroSymbol, oneSymbol, resetSymbol;
/* 要传输的颜色数据 */
static rmt_symbol_word_t rgbSymbols[24 * LED_NUM] = {0};

/************* 自定义函数 ******************/
void init_tx_channel()
{
    rmt_tx_channel_config_t cfg = {
        // GPIO
        .gpio_num = GPIO_NUM,
        // 时钟源:默认是APB
        .clk_src = RMT_CLK_SRC_DEFAULT,
        // 分辨率,即频率
        .resolution_hz = TICK_FREQ,
        // 内存大小,指的是符号个数,不是字节个数
        .mem_block_symbols = 64,
        // 传输队列深度,不要设得太大
        .trans_queue_depth = 4
        // 禁用回环(自己发给自己)
        //.flags.io_loop_back=0
    };
    // 调用函数初始化
    ESP_ERROR_CHECK(rmt_new_tx_channel(&cfg, &txChannel));
}

/* 初始化符号 */
void init_symbols()
{
    // 0码高电平
    zeroSymbol.duration0 = 0.4 * (TICK_FREQ / 1000000);
    zeroSymbol.level0 = 1;
    // 0码低电平
    zeroSymbol.duration1 = 0.8 * (TICK_FREQ / 1000000);
    zeroSymbol.level1 = 0;

    // 1码高电平
    oneSymbol.duration0 = 0.8 * (TICK_FREQ / 1000000);
    oneSymbol.level0 = 1;
    // 1码低电平
    oneSymbol.duration1 = 0.4 * (TICK_FREQ / 1000000);
    oneSymbol.level1 = 0;

    // 复位信号全为低电平
    resetSymbol.duration0 = 25 * (TICK_FREQ / 1000000);
    resetSymbol.level0 = 0;
    resetSymbol.duration1 = 25 * (TICK_FREQ / 1000000);
    resetSymbol.level1 = 0;
}

/* 初始化编码器 */
void init_encoder()
{
    // 目前配置不需要参数
    rmt_copy_encoder_config_t cfg = {};
    // 创建拷贝编码器
    ESP_ERROR_CHECK(rmt_new_copy_encoder(&cfg, &rfEncoder));
}

/* 设置颜色 */
void set_rgb(int index, uint32_t grb)
{
    if (index < 0 || index > LED_NUM - 1)
    {
        return; // 索引无效
    }
    // 循环的开始和结束索引
    int startIdx = index * 24;
    int endIdx = startIdx + 24;
    for (int i = startIdx; i < endIdx; i++)
    {
        if (grb & 0x00800000)
        {
            // 1
            rgbSymbols[i] = oneSymbol;
        }
        else
        {
            // 0
            rgbSymbols[i] = zeroSymbol;
        }
        // 左移一位
        grb <<= 1;
    }
}

/* 发送数据 */
void send_data()
{
    // 配置
    rmt_transmit_config_t cfg = {
        // 不要循环发送
        .loop_count = 0};
    // 发送
    ESP_ERROR_CHECK(rmt_transmit(txChannel, rfEncoder, rgbSymbols, sizeof(rgbSymbols), &cfg));
    // 等待发送完毕
    // ESP_ERROR_CHECK(rmt_tx_wait_all_done(txChannel, portMAX_DELAY));
    // 发送复位信号
    ESP_ERROR_CHECK(rmt_transmit(txChannel, rfEncoder, &resetSymbol, 1, &cfg));
    // 等待完成
    ESP_ERROR_CHECK(rmt_tx_wait_all_done(txChannel, portMAX_DELAY));
}

void app_main(void)
{
    // 1、初始化通道
    init_tx_channel();
    // 2、初始化符号
    init_symbols();
    // 3、初始化编码器
    init_encoder();
    // 4、使能通道
    ESP_ERROR_CHECK(rmt_enable(txChannel));

    int i;
    /* 进入循环 */
    while (1)
    {
        // 红色
        for (i = 0; i < LED_NUM; i++)
        {
            set_rgb(i, COLOR_U32(0xff, 0x0, 0x0));
        }
        send_data();
        // 延时
        vTaskDelay(1000 / portTICK_PERIOD_MS);

        // 蓝色
        for (i = 0; i < LED_NUM; i++)
        {
            set_rgb(i, COLOR_U32(0x0, 0x0, 0xff));
        }
        send_data();
        // 延时
        vTaskDelay(1000 / portTICK_PERIOD_MS);

        // 绿色
        for (i = 0; i < LED_NUM; i++)
        {
            set_rgb(i, COLOR_U32(0x0, 0xff, 0x0));
        }
        send_data();
        // 延时
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
}

下面是效果:

 

补充一下渐变效果。原理是用RGB的终值减去初值,然后各自除以灯珠数,得到一个平均递变的值。在循环时,除第一个和最后一个灯珠外,其他灯珠的颜色都用初值 + 插值 * 索引,就是每个灯珠都依次递增(减),最终趋近终值。

// 初始值
uint8_t r0 = 255, g0 = 0, b0 = 0;
// 最终值
uint8_t r1 = 0, g1 = 0, b1 = 255;
// 计算要插补的均值
uint8_t ri, gi, bi;
ri = (r1 - r0) / LED_NUM;
gi = (g1 - g0) / LED_NUM;
bi = (b1 - b0) / LED_NUM;
// 循环设置灯珠颜色
for (i = 0; i < LED_NUM; i++)
{
    // 如果是第一个灯,直接用初值
    if (i == 0)
    {
        uint32_t color = COLOR_U32(r0, g0, b0);
        set_rgb(i, color);
        continue;
    }
    // 如果是最后一个灯,直接用终值
    if (i == LED_NUM - 1)
    {
        uint32_t c = COLOR_U32(r1, g1, b1);
        set_rgb(i, c);
        continue;
    }
    // 其他情况,用插值
    uint32_t color = COLOR_U32(
        r0 + i * ri,
        g0 + i * gi,
        b0 + i * bi);
    set_rgb(i, color);
}
// 发送
send_data();
// 等待3秒
vTaskDelay(3000 / portTICK_PERIOD_MS);
// 再来一次
r0 = 204;
g0 = 0;
b0 = 204;
r1 = 0;
g1 = 102;
b1 = 0;
// 计算插入均值
ri = (r1 - r0) / LED_NUM;
gi = (g1 - g0) / LED_NUM;
bi = (b1 - b0) / LED_NUM;
for (i = 0; i < LED_NUM; i++)
{
    if (i == 0)
    {
        uint32_t c = COLOR_U32(r0, g0, b0);
        set_rgb(i, c);
        continue;
    }
    if (i == LED_NUM - 1)
    {
        uint32_t c = COLOR_U32(r1, g1, b1);
        set_rgb(i, c);
        continue;
    }
    uint32_t c = COLOR_U32(
        r0 + i * ri,
        g0 + i * gi,
        b0 + i * bi);
    set_rgb(i, c);
}
// 发送
send_data();
// 等待3秒
vTaskDelay(3000 / portTICK_PERIOD_MS);

效果如下图:

由于计算插值的时候没有用浮点数,渐变看起来不太丝滑。

 

好了,今天就水到这里了。

posted @ 2024-04-20 23:09  东邪独孤  阅读(2235)  评论(4编辑  收藏  举报