前言
在上一篇《从零开始:用CH592F制作CS2生命值胸章》的文章中,我展示了如何利用CH592F这颗蓝牙芯片制作一个和游戏联动的生命值指示器.
而本文将介绍生命值计数器的一个技术细节:如何使用CH592F驱动WS2812.
虽然WS2812的时序要求比较严格,通常可以使用GPIO翻转配合精准延时来实现,但那样会占用大量的CPU资源,导致蓝牙协议栈或其他中断任务受阻.
为了实现“零”CPU占用的炫酷灯效,我决定利用CH592F的SPI外设配合DMA来模拟WS2812的时序.

原理分析
WS2812的通讯协议大家都烂熟于心了,核心就是通过高低电平的占空比来区分0码和1码.
- 0码:高电平时间短(~0.4us),低电平时间长.
- 1码:高电平时间长(~0.8us),低电平时间短.
- 整个周期大约在1.25us左右,即频率约为800kHz.
如果我们将SPI的时钟频率设定为WS2812频率的4倍(约3.2MHz),那么发送一个字节(8位)的SPI数据所占用的时间,刚好对应2个WS2812的位周期(因为这里我们用4个SPI位来表示1个WS2812位).
- 模拟0码:发送二进制
1000 (0x8),即1个高电平+3个低电平.
- 模拟1码:发送二进制
1110 (0xE),即3个高电平+1个低电平.
这样,我们只需要在内存中开辟一块缓存,将RGB颜色数据“膨胀”转化为对应的SPI数据,然后通过DMA一键发送,即可彻底解放CPU.
核心代码实现
1. 初始化SPI
首先需要配置SPI0为主机模式.CH592F的系统主频通常为48MHz,为了凑出3.2MHz的SPI时钟,我们需要设置分频系数.
48MHz / 15 = 3.2MHz.
NeoPixel.cpp - init1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| void NeoPixelController::begin() { GPIOA_ResetBits (GPIO_Pin_12); GPIOA_ModeCfg (GPIO_Pin_12 |GPIO_Pin_13 | GPIO_Pin_14, GPIO_ModeOut_PP_5mA);
SPI0_MasterDefInit(); SPI0_CLKCfg (15);
memset (_spiBuffer, 0, 24); SPI0_MasterDMATrans (_spiBuffer, 24); }
|
2. 数据转换 (GRB -> SPI)
这是最关键的一步.我们需要将内存中紧凑的RGB(实际是GRB顺序)数据,展开为SPI总线需要的波形数据.
这里定义了两个宏来代表SPI发送的4位数据片段:
GRB_CODE_0: 0x8 (对应二进制 1000)
GRB_CODE_1: 0xE (对应二进制 1110)
为了节省空间,我们一个字节的SPI buffer存储两个WS2812位.
NeoPixel.cpp - convert1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| #define GRB_CODE_0 0x8 #define GRB_CODE_1 0xE
void NeoPixelController::convertGRBtoSPI (const uint8_t *grb, uint8_t *spi, uint16_t len) { memset (spi, 0, len * 3 * 4); for (uint16_t i = 0; i < len; i++) { for (uint8_t j = 0; j < 3; j++) { for (uint8_t k = 0; k < 4; k++) { for (uint8_t m = 0; m < 2; m++) { if (grb[3 * i + j] & (0x80 >> (2 * k + m))) { spi[3 * 4 * i + 4 * j + k] |= (GRB_CODE_1 >> (m * 4)); } else { spi[3 * 4 * i + 4 * j + k] |= (GRB_CODE_0 >> (m * 4)); } } } } } }
|
3. DMA 发送
数据转换完成后,发送过程就非常简单了.直接调用CH592F的DMA传输函数,CPU就可以去处理蓝牙连接或者睡觉了.
NeoPixel.cpp - show1 2 3 4 5 6 7
| void NeoPixelController::show() { convertGRBtoSPI (_grbBuffer, _spiBuffer, _numLeds); SPI0_MasterDMATrans (_spiBuffer, _numLeds * 3 * 4); }
|
封装与调用
为了方便使用,我将其封装成了一个NeoPixelController类,模仿了Arduino Adafruit_NeoPixel的接口风格.
头文件 NeoPixel.h:
NeoPixel.h1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| #ifndef NEOPIXEL_H #define NEOPIXEL_H
#include "CH59x_common.h"
class NeoPixelController { public: NeoPixelController (uint16_t numLeds, uint8_t spiInstance = 0); void begin(); void show(); void setPixelColor(uint16_t index, uint32_t color); void setPixelHSV(uint16_t index, uint8_t hue, uint8_t sat, uint8_t val); void clear(); void setBrightness(uint8_t brightness); static uint32_t Color(uint8_t r, uint8_t g, uint8_t b);
private: uint16_t _numLeds; uint8_t _spiInstance; uint8_t _brightness; uint8_t _grbBuffer[100 * 3]; uint8_t _spiBuffer[100 * 3 * 4];
void convertGRBtoSPI(const uint8_t *grb, uint8_t *spi, uint16_t len); uint32_t colorHSV(uint8_t hue, uint8_t sat, uint8_t val); };
#endif
|
在主程序 Main.c 中调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| NeoPixelController strip(32);
int main() { SetSysClock(CLK_SOURCE_PLL_48MHz); strip.begin(); strip.setBrightness(50);
while(1) { static uint8_t hue = 0; strip.rainbow(hue++, 255, 255); strip.show(); DelayMs(10); } }
|
总结
通过SPI+DMA的方式驱动WS2812,最大的优势在于时序极其稳定,且不消耗CPU算力.这对于CH592F这种单核蓝牙SoC来说非常重要,避免了因为关闭中断写时序而导致蓝牙连接不稳定的问题.
唯一的代价就是内存占用稍微大了一些(每个LED需要12字节的SPI buffer),但对于几十颗灯珠的装饰应用来说,CH592F的RAM绰绰有余.
补充说明
代码不仅仅可以运行在CH592系列芯片, 还可以运行在CH582系列芯片.