STM32C0316-DK FreeRTOS 실습 #2 – Semaphore를 이용한 Task 간 이벤트 전달

개요

이전 실습에서는 두 개의 Task를 생성하여 FreeRTOS의 기본적인 멀티태스킹 동작을 확인해 보았습니다. 단일 코어 MCU는 실제로 한 순간에 하나의 Task만 실행할 수 있지만, FreeRTOS 스케줄러는 매우 빠르게 Task를 전환함으로써 여러 Task가 동시에 실행되는 것처럼 보이게 합니다.

하지만 실제 응용 프로그램에서는 단순히 여러 Task가 독립적으로 동작하는 것만으로는 충분하지 않습니다. 어떤 Task는 특정 조건이 만족될 때까지 대기해야 하며, 다른 Task는 작업이 완료되었음을 알려주어야 하는 경우가 자주 발생합니다. 이처럼 Task 간에 동작 시점을 맞추거나 특정 이벤트를 전달하는 기능을 동기화(Synchronization) 라고 합니다.

이번 실습에서는 FreeRTOS의 대표적인 동기화 객체인 Binary Semaphore를 사용하여 한 Task가 다른 Task에 이벤트를 전달하는 방법을 살펴보겠습니다.

이를 통해 Semaphore의 기본 개념과 Give, Take 동작의 의미를 이해해 보겠습니다.

Semaphore란 무엇인가?

FreeRTOS에서는 여러 개의 Task가 동시에 실행되는 것처럼 동작합니다. 그러나 실제 응용 프로그램에서는 Task들이 서로 완전히 독립적으로 동작하는 경우보다 특정 시점에 서로 협력해야 하는 경우가 더 많습니다.

예를 들어 어떤 Task는 센서 측정을 수행하고, 다른 Task는 측정이 완료된 후에만 데이터를 처리해야 할 수 있습니다. 또는 버튼이 눌렸을 때 특정 Task를 실행해야 하는 경우도 있습니다. 이러한 상황에서는 한 Task가 다른 Task에게 특정 이벤트가 발생했음을 알려줄 수 있어야 합니다.

이때 사용하는 것이 Semaphore입니다.

Semaphore는 Task 간에 데이터를 전달하는 것이 아니라 특정 이벤트가 발생했음을 알리는 신호(Signaling)를 전달하기 위한 객체입니다.

Task A와 Task B가 존재한다고 가정해 보겠습니다. Task B는 특정 이벤트가 발생할 때까지 대기하고 있으며, Task A는 작업이 완료되면 Binary Semaphore를 설정(Give)합니다.

Task B는 Take 함수를 통해 Semaphore를 기다리고 있다가 Semaphore가 설정되면 즉시 실행을 시작하게 됩니다.

즉, Semaphore는 데이터를 전달하는 것이 아니라 특정 이벤트가 발생했음을 다른 Task에 알려주는 신호(Signaling)의 역할을 수행합니다.

위 그림의 동작 순서를 정리하면 다음과 같습니다.

  1. Task B는 Semaphore를 기다리며 Block 상태로 진입합니다.
  2. Task A가 특정 작업을 완료합니다.
  3. Task A가 Semaphore를 Give 합니다.
  4. 대기 중이던 Task B가 Semaphore를 Take 합니다.
  5. Task B의 실행이 시작됩니다.

아래 링크는 FreeRTOS 공식 사이트의 Binary Semaphores에 대한 설명입니다.

FreeRTOS binary semaphores

이번 실습에서는 Task A 역할을 LED2 Task가 수행하고, Task B 역할을 LED1 Task가 수행합니다. LED2 Task가 일정 횟수만큼 실행되면 Semaphore를 Give 하고, LED1 Task는 Semaphore를 기다리다가 신호를 받으면 LED를 점멸시키도록 구현해 보겠습니다.

이번 실습의 동작 구성

이번 실습에서는 두 개의 Task와 하나의 Binary Semaphore를 사용하여 Task 간 이벤트 전달을 구현합니다.

아래 그림은 전체 동작 흐름을 나타낸 것입니다.

LED2 Task는 주기적으로 실행되며 LED2를 점멸시키고 Count 값을 증가시킵니다.

Count 값이 5에 도달하면 Binary Semaphore를 Give 하여 이벤트를 발생시킵니다.

반면 LED1 Task는 Semaphore를 기다리며 Block 상태에 머무르게 됩니다. 이 상태에서는 CPU 시간을 소비하지 않고 대기하게 됩니다.

LED2 Task가 Semaphore를 Give 하면 LED1 Task는 즉시 깨어나 Semaphore를 Take 하고 LED1을 점멸시킵니다.

실습의 동작 순서는 다음과 같습니다.

  1. LED1 Task는 Semaphore를 기다리며 Block 상태에 진입합니다.
  2. LED2 Task는 주기적으로 LED2를 점멸시키며 Count 값을 증가시킵니다.
  3. Count 값이 5에 도달하면 LED2 Task가 Semaphore를 Give 합니다.
  4. LED1 Task가 Semaphore를 Take 하고 실행을 재개합니다.
  5. LED1이 점멸한 후 다시 Semaphore를 기다리는 상태로 돌아갑니다.

실제 FreeRTOS에서는 LED1 Task가 Semaphore의 상태를 반복적으로 확인하는 것이 아니라 Semaphore가 없을 경우 Block 상태로 전환됩니다. 이후 LED2 Task가 Semaphore를 Give 하면 LED1 Task는 Ready 상태로 복귀하여 실행을 재개하게 됩니다.

위 그림에서 볼 수 있듯이 LED1 Task는 주기적으로 실행되는 것이 아니라 Semaphore 이벤트가 발생했을 때만 실행됩니다. 이는 불필요한 CPU 사용을 줄이고 Task 간의 동기화를 효율적으로 구현할 수 있게 해줍니다.

다음 장에서는 실제 코드에서 Binary Semaphore를 생성하는 방법을 살펴보겠습니다.

프로젝트 생성

Semaphore 실습을 위한 프로젝트는 이전 실습에서 작성한 프로젝트를 복사하여 사용하였습니다.

프로젝트 복사 방법에 대해서는 실습 #1에서 자세히 설명하였으므로 본 글에서는 생략하도록 하겠습니다.

실습 #1

실습 #1에서 사용한 프로젝트를 복사한 후 프로젝트 이름을 C9SW-FreeRTOS-M-Semaphore 로 변경하였습니다.

프로젝트를 복사한 후 Build 및 Download를 수행하면 이전 실습과 동일하게 두 개의 LED Task가 각각 다른 주기로 점멸하는 것을 확인할 수 있습니다.

이는 프로젝트 복사가 정상적으로 완료되었음을 의미하며, 이제 이 프로젝트를 기반으로 Semaphore 관련 기능을 추가해 보겠습니다.

아래는 프로젝트가 생성된 후의 모습입니다.

Binary Semaphore 생성

FreeRTOS에서 Binary Semaphore를 사용하기 위해서는 먼저 Semaphore 객체를 생성해야 합니다.

이번 프로젝트는 STM32CubeMX의 FreeRTOS Middleware를 사용하지 않고 FreeRTOS Kernel을 직접 포팅한 환경이므로 Semaphore 역시 코드에서 직접 생성하도록 하겠습니다.

먼저 main.c에서 전역 변수 영역에 Semaphore Handle을 선언합니다.

SemaphoreHandle_t ledSemaphore;

아래는 실제 선언한 화면입니다.

SemaphoreHandle_t는 FreeRTOS에서 Semaphore 객체를 참조하기 위한 핸들(Handle) 타입입니다. 이후 LED2 Task는 이 핸들을 이용하여 Semaphore를 Give하고, LED1 Task는 같은 핸들을 이용하여 Semaphore를 Take하게 됩니다.

다음으로 main() 함수에서 Scheduler가 시작되기 전에 Binary Semaphore를 생성합니다.

ledSemaphore = xSemaphoreCreateBinary();

이번 실습에서는 LedTask_Init() 함수로 Task를 생성한 후 Scheduler를 시작하기 전에 Semaphore를 생성하였습니다.

아래는 실제 코드가 추가된 화면입니다.

xSemaphoreCreateBinary() 함수가 호출되면 FreeRTOS는 Binary Semaphore 객체를 생성하고 해당 객체의 핸들을 반환합니다.

이렇게 생성된 Semaphore는 이후 두 개의 Task가 공유하여 사용하게 됩니다.

이번 실습에서는 다음과 같은 역할을 수행하도록 구성할 예정입니다.

LED2 Task
    |
    | Give
    v
Binary Semaphore
    ^
    | Take
    |
LED1 Task

LED2 Task는 일정 횟수의 점멸이 완료되면 Semaphore를 Give하고, LED1 Task는 Semaphore를 기다리다가 Take에 성공하면 LED를 점멸시키도록 구현할 예정입니다.

다음 장에서는 LED2 Task에서 Count 값을 증가시키고 일정 횟수에 도달하면 Semaphore를 Give하는 코드를 구현해 보겠습니다.

LED2 Task 구현(Semaphore Give)

이번 장에서는 LED2 Task에서 일정 조건이 만족되었을 때 Semaphore를 Give하는 코드를 추가하겠습니다.

LED2 Task는 기존처럼 일정 주기로 LED2를 점멸합니다. 여기에 내부 카운터를 추가하여 LED2가 5회 점멸할 때마다 LED1 Task로 이벤트를 전달하도록 하겠습니다.

LED2 Task의 기본 동작은 다음과 같습니다.

LED2 점멸
    ↓
Count 증가
    ↓
Count가 5에 도달
    ↓
Semaphore Give
    ↓
Count 초기화

실제 코드는 다음과 같이 작성하였습니다.

위 코드에서 count 변수는 LED2 Task 내부에서만 사용하는 지역 변수입니다. LED2가 한 번 점멸할 때마다 count 값이 1씩 증가합니다.

count++;

그리고 count 값이 5에 도달하면 xSemaphoreGive() 함수를 호출하여 Binary Semaphore를 설정합니다.

xSemaphoreGive(ledSemaphore);

이 시점에서 Semaphore를 기다리고 있던 LED1 Task는 Block 상태에서 깨어날 수 있게 됩니다.

Semaphore를 Give한 후에는 다시 같은 동작을 반복하기 위해 count 값을 0으로 초기화합니다.

count = 0;

따라서 LED2 Task는 500ms마다 LED2를 점멸하고, 5번 점멸할 때마다 한 번씩 LED1 Task에 이벤트를 전달하게 됩니다.

여기서 중요한 점은 LED2 Task가 LED1 Task를 직접 호출하지 않는다는 것입니다. LED2 Task는 단지 Semaphore를 Give할 뿐입니다. LED1 Task가 언제 실행될지는 FreeRTOS 스케줄러가 결정합니다.

즉, 두 Task는 직접적으로 연결되어 있지 않고 Binary Semaphore를 통해 느슨하게 연결되어 있습니다. 이 구조가 FreeRTOS에서 Task 간 이벤트 전달을 구현하는 기본적인 방식입니다.

LED1 Task 구현(Semaphore Take)

이번 장에서는 LED2 Task가 전달한 Semaphore 이벤트를 수신하는 LED1 Task를 구현하겠습니다.

LED1 Task는 주기적으로 실행되는 것이 아니라 Semaphore가 발생할 때까지 대기(Block) 상태를 유지합니다. 그리고 LED2 Task가 Semaphore를 Give하면 즉시 깨어나 LED1을 점멸하게 됩니다.

LED1 Task의 동작 흐름은 다음과 같습니다.

Semaphore 대기
      ↓
Semaphore 수신
      ↓
LED1 점멸
      ↓
Semaphore 대기

실제 코드는 다음과 같이 작성하였습니다.

위 코드에서 가장 중요한 부분은 다음 구문입니다.

xSemaphoreTake(ledSemaphore,
               portMAX_DELAY)

xSemaphoreTake() 함수는 지정된 Semaphore를 획득(Take)하는 함수입니다.

첫 번째 인자는 Semaphore Handle이며, 두 번째 인자는 Timeout 시간을 의미합니다.

이번 실습에서는 다음과 같이 사용하였습니다.

portMAX_DELAY

이는 Semaphore가 발생할 때까지 무한정 기다리라는 의미입니다.

따라서 LED1 Task는 Semaphore가 없는 동안 계속 실행되는 것이 아니라 Block 상태로 전환됩니다.

Semaphore 없음
       ↓
Task Blocked
       ↓
CPU 사용 안 함

이 상태에서는 CPU 시간을 소비하지 않기 때문에 매우 효율적입니다.

이후 LED2 Task가 다음 코드를 실행하면

xSemaphoreGive(ledSemaphore);

FreeRTOS는 대기 중이던 LED1 Task를 Ready 상태로 변경합니다.

LED2 Task
      ↓
Semaphore Give
      ↓
LED1 Task Ready
      ↓
LED1 Task 실행

LED1 Task는 xSemaphoreTake() 함수에서 복귀하게 되며 pdTRUE를 반환합니다.

따라서 아래 코드가 실행됩니다.

HAL_GPIO_TogglePin(LED1_GPIO_Port,
                   LED1_Pin);

결과적으로 LED1이 한 번 점멸하게 됩니다.

이번 실습에서는 LED2 Task가 5회 점멸할 때마다 Semaphore를 Give하도록 구현하였으므로 LED1은 LED2의 다섯 번째 점멸마다 한 번씩 상태가 변경되는 것을 확인할 수 있습니다.

여기서 중요한 점은 LED1 Task가 LED2 Task의 상태를 직접 확인하지 않는다는 것입니다.

LED2 Task
      ↓
Semaphore
      ↓
LED1 Task

즉, 두 Task는 Semaphore를 통해서만 통신하며 서로의 내부 동작을 알 필요가 없습니다.

이러한 구조는 Task 간 결합도를 낮추고 프로그램을 보다 모듈화할 수 있게 해줍니다.

다음 장에서는 실제 보드에서 실행한 결과를 확인하고 Semaphore Give와 Take가 어떻게 동작하는지 살펴보겠습니다.

Build, 에러 해결 및 실행 결과

LED1 Task와 LED2 Task에 Semaphore 관련 코드를 추가한 후 Build를 수행하였더니 다음과 같은 컴파일 에러가 발생하였습니다.

unknown type name 'SemaphoreHandle_t'

또한 다음과 같은 경고도 함께 발생하였습니다.

implicit declaration of function
'xSemaphoreCreateBinary'

원인은 Semaphore 관련 타입과 함수가 선언되어 있는 헤더 파일이 포함되지 않았기 때문입니다.

이번 프로젝트는 FreeRTOS Kernel을 수동으로 포팅한 환경이므로 Semaphore를 사용하기 위해서는 semphr.h 헤더 파일을 직접 추가해야 합니다.

main.cled_task.c 모두에 위 헤더를 추가한 후 다시 Build를 수행하였습니다.

이후 다음 코드가 정상적으로 인식되었습니다.

컴파일 에러가 모두 사라졌으며 아래 그림과 같이 정상적으로 Build가 완료되었습니다.

컴파일 에러가 모두 사라졌으며 정상적으로 Build가 완료되었습니다.

왜 semaphr.h가 필요한가?

예를 들면 다음과 같습니다.

기능 헤더 파일
Task task.h
Queue queue.h
Semaphore semphr.h
Event Group event_groups.h

따라서 Semaphore 관련 API를 사용할 경우 반드시 다음 헤더를 포함해야 합니다.

semphr.h

정상적으로 Build가 완료되었으므로 보드에 다운로드하여 실행 결과를 확인해 보았습니다. 실행 결과 LED1은 동작하였지만, 예상했던 시점보다 더 자주 토글하는 것을 확인할 수 있었습니다.

확인 결과 LED1은 LED2가 약 2.5회 점멸할 때마다 한 번씩 토글하였습니다. 이는 우리가 의도한 동작과는 다릅니다.

우리가 원하는 동작은 LED2가 5회 점멸한 후 LED1이 한 번 토글하는 것입니다. 따라서 LED의 상태 변화 횟수가 아닌 실제 점멸 횟수를 기준으로 계산해야 합니다.

LED 1회 점멸은 ON과 OFF 두 번의 상태 변화로 이루어지므로, 5회 점멸은 총 10회의 상태 변화에 해당합니다.

따라서 아래와 같이 Count 조건을 10으로 변경하였습니다.

코드를 수정한 후 다시 Build 및 Download를 수행하였습니다.

실행 결과 LED2가 5회 점멸할 때마다 LED1이 한 번 토글하는 것을 확인할 수 있었습니다.

이를 통해 Binary Semaphore를 이용하여 한 Task가 다른 Task에 이벤트를 전달할 수 있음을 확인하였습니다.

마무리

이번 실습에서는 FreeRTOS의 Binary Semaphore를 사용하여 Task 간에 이벤트를 전달하는 방법을 살펴보았습니다.

LED2 Task가 일정 횟수만큼 실행되면 Semaphore를 Give하고, LED1 Task는 Semaphore를 기다리다가 Take에 성공하면 LED 상태를 토글하도록 구현하였습니다.

실습 과정에서는 Semaphore 관련 헤더 파일이 포함되지 않아 컴파일 에러가 발생하기도 하였으며, LED의 토글 횟수와 실제 점멸 횟수의 차이로 인해 예상과 다른 동작이 발생하기도 하였습니다. 이러한 문제를 하나씩 해결하면서 Semaphore의 동작 원리를 더욱 명확하게 이해할 수 있었습니다.

특히 Semaphore는 데이터를 전달하는 것이 아니라 특정 이벤트가 발생했음을 다른 Task에 알려주는 신호(Signaling)의 역할을 수행한다는 점을 확인할 수 있었습니다. 또한 Semaphore를 기다리는 Task는 Block 상태로 전환되어 CPU 시간을 소비하지 않는다는 점도 확인할 수 있었습니다.

다음 실습에서는 Queue를 사용하여 Task 간에 실제 데이터를 전달하는 방법을 살펴보겠습니다. Semaphore가 이벤트를 전달하는 도구라면 Queue는 데이터를 전달하는 도구라는 점에서 두 기능의 차이를 비교해 보는 것도 좋은 학습이 될 것입니다.

댓글 남기기