STM32로 NeoPixel 제어하기

아두이노페더(Feather) 기판과 같은 고급 개발 플랫폼은 사용하기 쉬운 라이브러리와 흔한 예제 코드를 통해 NeoPixel LED, 스트립, 매트릭스 등과 인터페이스 할 수 있도록 이미 훌륭한 지원을 제공하고 있습니다. 안타깝게도, STM32 개발 기판과 같은 보다 고급 플랫폼에서는 동일한 수준의 지원이 제공되지 않습니다. 이를테면, NeoPixel을 프로젝트에 통합시키고자 하는 개발자는 NeoPixel 통신 프로토콜과 이 프로토콜이 지닌 문제를 극복할 방법을 완전히 이해할 필요가 있습니다.

test_better_300_64

NeoPixel

Adafruit에서 "NeoPixel"이라는 브랜드로 명명한 개별 제어 가능(addressable)한 컬러 LED는 굉장한 인기 제품 라인으로 RGB 또는 RGBW 변형품을 이용할 수 있습니다. 두 제품 모두 빨간색, 녹색 및 파란색 LED와 함께 드라이버 칩이 통합되어 있으며, RGBW 부품은 순백색의 네 번째 LED도 통합되어 있습니다. 싱글 와이어(single-wire) 직렬 인터페이스와 비슷하지만 프로토콜의 타이밍 값과 데이터 구조에서 사소한 차이가 있는 인터페이스를 사용하여 두 NeoPixel 제품을 제어합니다.

WS2812

RGB NeoPixel은 실제로는 WS2812 지능형 제어 LED로, 데이터 신호 입력 핀(DIN)과 데이터 신호 출력 핀(DOUT)을 포함하고 있습니다. 이를 통해 하나의 데이터 라인만으로 여러 LED를 순차적으로 연결해 제어할 수 있습니다. 연결된 첫 번째 LED는 MCU로부터 수신한 데이터의 처음 3바이트를 처리하고 이 후의 모든 데이터를 다음 LED의 DIN 핀에 연결되어 있을 DOUT 핀으로 단순히 전달만 합니다. 리셋 신호(예를 들어, DIN 신호가 일정 시간 동안 로우 상태를 유지)를 수신할 때까지 LED들은 이러한 방식으로 데이터를 계속해서 다음 LED로 전달합니다. 전송 바이트는 그림 1에 나와 있는 프로토콜에 따라 구성됩니다. 첫 번째 바이트(G7-G0)는 녹색 LED의 8비트 PWM 강도를 나타내며, 0x00는 완전히 꺼진 것이며 0xFF 완전히 켜진 것입니다. 마찬가지로, 두 번째 바이트(R7-R0)는 빨간색 LED의 강도를 제어하며 세 번째 바이트(B7-B0)는 파란색 LED의 강도를 제어합니다.


그림 1: WS2812 LED의 3바이트 데이터 프로토콜 구조

이 각각의 24비트는 그림 2와 같이 구형파의 펄스 폭을 변조하여 인코딩 됩니다. 0 코드를 전송하든 1 코드를 전송하든 구형파의 주기는 1.25us에 고정되어 있습니다. WS2812의 경우 리셋 신호는 데이터 라인을 최소 50us 동안 로우로 유지시켜 만듭니다. 또한 그림 2에 표시된 타이밍 값의 허용 오차는 ±0.15μs입니다.

ws2812_timing
그림 2: WS2812 LED의 0과 1 비트 타이밍 도

SK6812

완전히 다른 부품인 NeoPixel의 RGBW 변형품은 실제로는 WS2812 LED와 동일한 원리로 동작하는 SK6812 지능형 제어 LED입니다. 그러나 네 번째 LED를 포함하고 있어서 그림 3과 같은 4바이트 데이터 프로토콜로 구현합니다. 그림 1과 비교하면, 유일한 차이점은 흰색 LED의 8비트 PWM 강도를 지정하는 데이터(W7-W0) 바이트가 결합되어 있다는 것입니다.


그림 3: SK6812 LED의 4바이트 데이터 프로토콜 구조

그림 4는 SK6812 제어 신호의 타이밍 값 역시 WS2812의 타이밍 값과 약간 다르다는 것을 보여줍니다(그래도 ±0.15μs 허용 오차가 여전히 적용됩니다). 이번에는 두 코드에 주목해 보면 두 코드 모두 구형파의 주기가 1.2μs에 고정되어 있습니다. 뿐만 아니라, SK6812의 리셋 신호 길이는 50μs가 아닌 80μs입니다.

sk6812_timing
그림 4: SK6812 LED의 0과 1 비트 타이밍 도

절차

NeoPixel 제어 신호의 엄격한 타이밍 요구 사항 때문에, 어셈블리 언어를 사용하지 않는 한 단순한 비트 뱅잉(bit banging) 방식으로는 현실적으로 만들 수 없습니다. 반면에 다양한 MCU 주변 장치, 외부 하드웨어 또는 이들의 조합을 활용하여 해당 신호를 만드는 많은 다른 전략이 존재합니다. 이 중 가장 간단한 전략은 MCU 타이머가 PWM 출력 신호를 생성하도록 설정하는 것입니다. 이는 앞 부분에서 언급하였듯이, NeoPixel 제어 신호는 0과 1비트에 대한 듀티 사이클이 다른 고정 주파수 PWM 신호에 불과하기 때문입니다. 전송 프로토콜처럼 동일한 속도로 이 두 듀티 사이클 간에 효율적으로 전환하기 위해서는, DMA(Direct Memory Access) 스트림도 업데이트를 관리하도록 설정해야 합니다. 이 방법이 메모리 효율성이 가장 낮을 수는 있지만, 이해하기 쉽고, CPU 효율이 높으며, (STM32Cube 환경 덕분에) 구현하기 쉽습니다.

다음 절차는 STM32CubeIDE(버전 1.8.0), NUCLEO-F401RE 개발 기판 그리고 RGBW 5x8 NeoPixel Shield를 활용하여 상기의 방법을 구현합니다. 그러나, 이 단계들은 모든 STM32 MCU/기판 그리고 NeoPixel 제품에 일반화될 수 있습니다. STM32CubeIDE 프로젝트는 이미 생성한 것으로 가정합니다. 다른 통합 개발 환경을 선호한다면, STM32CubeIDE 대신 독립 실행형 STM32CubeMX 코드 설정 도구를 사용하여 프로젝트를 원하는 개발 플랫폼으로 내보낼 수 있습니다.

1. PWM 설정

a. 우선, STM32CubeMX 설정 파일인 .ioc 파일을 여는 것부터 시작합니다. 이러면 STM32CubeIDE가 MCU를 설정할 수 있는 Device Configuration Tool 퍼스펙티브로 전환됩니다.

b. NeoPixel과 인터페이스하기 위해 선택한 GPIO 핀에 타이머 채널 부가 기능(alternate function)을 할당합니다. 선택한 타이머 채널은 PWM 출력을 생성할 수 있어야 합니다. 그림 5는 저자의 프로젝트에서 PB10 핀을 선택하고 여기에 타이머 2, 채널 3(TIM2_CH3) 기능을 할당하는 것을 보여줍니다

tim2_gpio
그림 5: DIN에 연결된 GPIO 핀을 타이머 채널로 설정

c. 이전 단계에서 선택한 타이머 주변 장치(여기에서는 TIM2)를 왼쪽의 Timers 카테고리에서 선택하고 Mode and Configuration 패널을 엽니다. Mode 패널에서, Clock Source는 "Internal Clock"을 선택하고 적합한 타이머 채널의 드롭다운 목록에서 "PWM Generation CHx"를 선택합니다. 이전 단계에서 TIM2_CH3 부가 기능을 선택했기 때문에 그림 6에서 타이머 2, 채널 3이 "PWM Generation CH3"로 설정되어 있습니다. 이 단계를 완료하면 Pinout view에서 해당 GPIO 핀은 노란색에서 녹색으로 변경되어야 합니다.

d. Configuration 패널에서 “Prescaler” 및 “Pulse” 값이 모두 0으로 설정되어 있는지 확인합니다. AutoReload Register(ARR)라고도 불리는 Counter Period는 필요한 PWM 주기(RGB의 WS2812 LED를 사용하는 경우 1.25 μs, 또는 RGBW의 SK6812 LED를 사용하는 경우 1.2 μs)를 만들 수 있도록 설정해야 합니다. 이는 타이머 주변 장치의 클럭 속도에 따라 달라집니다. 원하는 PWM 주기를 클럭 주기로 나누고 1을 빼면 이 값을 얻을 수 있습니다(카운터가 0에서 시작하므로 1을 뺍니다). 저자의 경우, 해당 계산을 통해 ARR 값은 99.8이 산출되며 100으로 반올림하였습니다(그림 6). 이상적인 ARR 값 계산에 대한 자세한 설명은 아래를 참고하시기 바랍니다.


그림 6: 선택한 타이머 채널을 PWM 출력으로 설정



ARR 값 계산

타이머 “Prescaler” 값이 0으로 설정되어 있다고 가정하면, ARR 값은 다음과 같이 간단하게 계산할 수 있습니다;

ARR = \frac{f_{timer}}{f_{PWM}} - 1 = \frac{T_{PWM}}{T_{timer}} - 1.

즉, ARR 값은 PWM 신호의 주기를 타이머 주변 장치 클럭 신호의 주기로 나눈 값입니다. 우리는 PWM 신호의 주기인 T_{PWM} 이 사용되는 NeoPixel 제품에 따라 1.25 μs 또는 1.2 μs 라는 것을 알고 있습니다(이 예시에서 T_{PWM} = 1.2 \mu{}s). T_{timer} 를 결정하려면, 타이머 주변 장치가 연결된 버스를 확인하기 위해 소자 규격서를 찾아봐야 할 수 있습니다. 규격서는 ST의 웹사이트에서 찾거나, 그림 7과 같이 STM32CubeIDE에서 Help > Target Device Docs and Resources를 선택한 후 MCU 탭에서 규격서를 찾을 수 있습니다.


그림 7: 소자 규격서 찾기

MCU(STM32F401RE) 규격서의 소자 블록 다이어그램은 타이머 TIM2가 APB1에 연결되어 있음을 보여줍니다(그림 8 참조).


그림 8: STM32F401xD/xE 블록 다이어그램의 일부(DS10086 발췌)

그림 9는 STM32CubeIDE에서 Clock Configuration 탭으로 전환하면 TIM2의 클럭이 84MHz임을 확인할 수 있다는 것을 설명합니다. T_{timer} = \frac{1}{f_{timer}} = \frac{1}{84\text{ MHz}}.


그림 9: 타이머 클럭 주파수 확인

따라서,

ARR = \frac{1.2 \times 10^{-6}}{\frac{1}{84 \times 10^6}} - 1 = 99.8

PWM 주기를 NeoPixel 제어 신호의 주기에 최대한 가깝게 하기 위해, 가장 가까운 정수로 반올림해서 ARR = 100 이 됩니다.


2. DMA 설정

a. Categories의 System Core에서 DMA 주변 장치를 선택합니다.

b. Configuration 패널의 DMA1 탭에서, Add 버튼을 클릭합니다. 드롭다운 메뉴에서 여러분의 타이머/채널 조합을 선택합니다. 저자의 프로젝트에서는 "TIM2_CH3/UP"을 선택하였습니다.

c. 이 새로운 DMA Request의 Direction을 "Memory To Peripheral"로 변경합니다.

d. Priority도 “Very High"로 변경합니다.

e. 기본 DMA Request 설정이 그림 10에 나와 있는 설정과 일치하는지 확인합니다.

f. .ioc 파일을 저장하여 프로젝트에 대한 코드를 생성합니다.


그림 10: PWM 신호의 듀티 사이클을 효율적으로 업데이트하기 위한 DMA 스트림 설정

3. 코드 작성

이 부분에서는 main.c 파일의 top-down 방식의 작업을 통해 NeoPixel LED의 컬러 능력을 테스트할 수 있는 간단한 응용 예제를 제공합니다. RGB WS2818 LED용과 RGBW SK6812 LED용의 두 가지 버전의 main() 함수가 제공됩니다.

a. 그림 1과 3의 전체 NeoPixel 데이터 구조 뿐만 아니라 개별 LED 색상 값에 쉽게 접근할 수 있도록 main.c 파일의 Private typedef 부분에 새로운 데이터 유형을 만드는 것이 도움이 됩니다. 목록 1은 RGB 및 RGBW NeoPixel 부품에 대한 typedef를 제공합니다. 이 코드는 /* USER CODE BEGIN PTD */ 주석과 /* USER CODE END PTD */ 주석 사이에 붙여넣어야 합니다.

목록 1: RGB WS2812 및 RGBW SK6812 LED 모두에 대한 사용자 정의 데이터 유형

typedef union
{
  struct
  {
    uint8_t b;
    uint8_t r;
    uint8_t g;
  } color;
  uint32_t data;
} PixelRGB_t;

typedef union
{
  struct
  {
    uint8_t w;
    uint8_t b;
    uint8_t r;
    uint8_t g;
  } color;
  uint32_t data;
} PixelRGBW_t;

b. “Pulse” 레지스터 (일명 CCRx)의 값을 변경시키면 PWM 파형의 듀티 사이클이 변경됩니다. 따라서, 사용하는 NeoPixel에 필요한 0 코드와 1 코드 구형파(그림 2 또는 그림 4의 타이밍 도 중 하나)를 만들기 위해 적절한 CCRx 값을 계산해야만 합니다.

RGB WS2812 LED의 경우, 이 값들은 다음과 같이 계산됩니다:

0 = (ARR + 1)(0.32)
1 = (ARR + 1)(0.64)

RGBW SK6812 LED의 경우, 계산이 약간 다릅니다:

0 = (ARR + 1)(0.25)
1 = (ARR + 1)(0.5)

물론, 이렇게 계산된 값은 가장 가까운 정수로 반올림해야 합니다. main.c 파일의 Private define 부분에 각 값에 대한 #define 지시문을 만듭니다(아래 그림 11의 예제 참조).

c. CCRx 값 외에도, 제어 해야 할 NeoPixel LED 수와 DMA 버퍼 크기도 Private define 부분에서 정의해야 합니다. 그림 11에 나와 있듯이, LED 수를 해당하는 NeoPixel 데이터 구조의 비트 수에 곱하기만 하면 됩니다(그림 1 및 그림 3 참조). 마지막 CCRx 값은 0(리셋 신호)이어야 하므로 추가 버퍼 하나도 할당되어야 합니다.


그림 11: WS2812 및 SK6812 LED 모두를 위한 Private define

d. 목록 2에 제공된 DMA 완료 콜백 함수를 /* USER CODE BEGIN 0 *//* USER CODE END 0*/ 사이 Private user code 부분에 추가합니다. TIM_CHANNEL_x를 1c 단계에서 설정한 채널로 꼭 변경하십시오.

목록 2: HAL_TIM_PWM_PulseFinishedCallback() 함수 구현

void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim)
{
  HAL_TIM_PWM_Stop_DMA(htim, TIM_CHANNEL_x);
}

e. 마지막으로 애플리케이션 코드를 main.c 함수에 추가해야 합니다. 목록 3은 WS2812 LED를 사용하는 main() 함수 예제를 제공하며 목록 4는 SK6812 LED를 사용하는 비슷한 main() 함수 예제를 제공합니다. HAL_TIM_PWM_Start_DMA() 함수의 TIM_CHANNEL_x 인수는 1c 단계에서 설정한 채널과 일치하도록 다시 수정해야 합니다.

목록 3: RGB WS2812 LED를 위한 main() 함수 예제

int main(void)
{
  /* USER CODE BEGIN 1 */

  PixelRGB_t pixel[NUM_PIXELS] = {0};
  uint32_t dmaBuffer[DMA_BUFF_SIZE] = {0};
  uint32_t *pBuff;
  int i, j, k;
  uint16_t stepSize;

  /* USER CODE END 1 */

  /* MCU Configuration--------------------------------------------------------*/

  /* Reset of all peripherals, Initializes the Flash interface and the Systick. */
  HAL_Init();

  /* USER CODE BEGIN Init */

  /* USER CODE END Init */

  /* Configure the system clock */
  SystemClock_Config();

  /* USER CODE BEGIN SysInit */

  /* USER CODE END SysInit */

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_USART2_UART_Init();
  MX_DMA_Init();
  MX_TIM2_Init();
  /* USER CODE BEGIN 2 */

  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  k = 0;
  stepSize = 4;
  while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */

    for (i = (NUM_PIXELS - 1); i > 0; i--)
    {
      pixel[i].data = pixel[i-1].data;
    }

    if (k < 255)
    {
      pixel[0].color.g = 254 - k; //[254, 0]
      pixel[0].color.r =  k + 1;  //[1, 255]
      pixel[0].color.b = 0;
    }
    else if (k < 510)
    {
      pixel[0].color.g = 0;
      pixel[0].color.r = 509 - k; //[254, 0]
      pixel[0].color.b = k - 254; //[1, 255]
      j++;
    }
    else if (k < 765)
    {
      pixel[0].color.g = k - 509; //[1, 255];
      pixel[0].color.r = 0;
      pixel[0].color.b = 764 - k; //[254, 0]
    }
    k = (k + stepSize) % 765;

    // not so bright
    pixel[0].color.g >>= 2;
    pixel[0].color.r >>= 2;
    pixel[0].color.b >>= 2;

    pBuff = dmaBuffer;
    for (i = 0; i < NUM_PIXELS; i++)
    {
       for (j = 23; j >= 0; j--)
       {
         if ((pixel[i].data >> j) & 0x01)
         {
           *pBuff = NEOPIXEL_ONE;
         }
         else
         {
           *pBuff = NEOPIXEL_ZERO;
         }
         pBuff++;
     }
    }
    dmaBuffer[DMA_BUFF_SIZE - 1] = 0; // last element must be 0!

    HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_x, dmaBuffer, DMA_BUFF_SIZE);

    HAL_Delay(10);
  }
  /* USER CODE END 3 */
}

목록 4: RGBW SK6812 LED를 위한 main() 함수 예제

int main(void)
{
  /* USER CODE BEGIN 1 */

  PixelRGBW_t pixel[NUM_PIXELS] = {0};
  uint32_t dmaBuffer[DMA_BUFF_SIZE] = {0};
  uint32_t *pBuff;
  int i, j, k;
  uint16_t stepSize;

  /* USER CODE END 1 */

  /* MCU Configuration--------------------------------------------------------*/

  /* Reset of all peripherals, Initializes the Flash interface and the Systick. */
  HAL_Init();

  /* USER CODE BEGIN Init */

  /* USER CODE END Init */

  /* Configure the system clock */
  SystemClock_Config();

  /* USER CODE BEGIN SysInit */

  /* USER CODE END SysInit */

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_USART2_UART_Init();
  MX_DMA_Init();
  MX_TIM2_Init();
  /* USER CODE BEGIN 2 */

  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  k = 0;
  stepSize = 4;
  while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */

    for (i = (NUM_PIXELS - 1); i > 0; i--)
    {
      pixel[i].data = pixel[i-1].data;
    }

    if (k < 255)
    {
      pixel[0].color.g = 254 - k; //[254, 0]
      pixel[0].color.r =  k + 1;  //[1, 255]
      pixel[0].color.b = 0;
      pixel[0].color.w = 0;
    }
    else if (k < 510)
    {
      pixel[0].color.g = 0;
      pixel[0].color.r = 509 - k; //[254, 0]
      pixel[0].color.b = k - 254; //[1, 255]
      pixel[0].color.w = 0;
      j++;
    }
    else if (k < 765)
    {
      pixel[0].color.g = 0;
      pixel[0].color.r = 0;
      pixel[0].color.b = 764 - k; //[254, 0]
      pixel[0].color.w = k - 509; //[1, 255]
    }
    else if (k < 1020)
    {
      pixel[0].color.g = k - 764; //[1, 255]
      pixel[0].color.r = 0;
      pixel[0].color.b = 0;
      pixel[0].color.w = 1019 - k; //[254, 0]
    }
    k = (k + stepSize) % 1020;

    // 50% brightness
    pixel[0].color.g >>= 2;
    pixel[0].color.r >>= 2;
    pixel[0].color.b >>= 2;
    pixel[0].color.w >>= 2;

    pBuff = dmaBuffer;
    for (i = 0; i < NUM_PIXELS; i++)
    {
       for (j = 31; j >= 0; j--)
       {
         if ((pixel[i].data >> j) & 0x01)
         {
           *pBuff = NEOPIXEL_ONE;
         }
         else
         {
           *pBuff = NEOPIXEL_ZERO;
         }
         pBuff++;
     }
    }
    dmaBuffer[DMA_BUFF_SIZE - 1] = 0; // last element must be 0!

    HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_x, dmaBuffer, DMA_BUFF_SIZE);

    HAL_Delay(10);
  }
  /* USER CODE END 3 */
}

이제 프로젝트가 성공적으로 빌드 되어 여러분의 소자에서 코드를 실행할 수 있을 것입니다.

결과

위에 제공된 RGB 및 RGBW 설정에 의해 생성된 제어 신호를 로직 분석기를 사용하여 캡처하였습니다. 두 가지 모두 아래 그림 12와 13에 각각 나와 있습니다. 그림 2와 4에 명시된 예상 출력과 일치합니다.


그림 12: 생성된 WS2812 제어 신호(0b0011을 전송)


그림 13: 생성된 SK6812 제어 신호(0b0010을 전송)



영문 원본: Controlling NeoPixels with STM32