Zephyr RTOS 실습 #2 – Zephyr Thread를 이용한 멀티태스킹

서론

이전 글에서는 Zephyr RTOS를 Custom Board에 포팅하고 LED를 점멸시키는 과정을 살펴보았습니다. 이를 통해 Zephyr Thread 프로젝트를 직접 빌드하고 실행할 수 있는 개발 환경을 구축하고, Zephyr 애플리케이션을 실행하는 기본 과정을 익혔습니다.

이번 글부터는 Zephyr RTOS의 핵심 기능을 하나씩 실습해 보겠습니다. 첫 번째 주제는 Thread입니다. Thread는 RTOS에서 독립적인 작업을 수행하는 기본 실행 단위이며, 여러 작업을 동시에 수행하는 멀티태스킹의 핵심 요소입니다.

이번 실습에서는 두 개의 Thread를 생성하여 각각 독립적으로 LED를 점멸시키겠습니다. 하나의 Thread는 LED1을 500ms 주기로 점멸시키고, 다른 Thread는 LED2를 100ms 주기로 점멸시킵니다. 두 LED가 서로 다른 주기로 동작하는 모습을 통해 각 Thread가 독립적으로 실행되는 것을 확인해 보겠습니다.

또한 Zephyr에서 Thread를 생성하는 방법과 K_THREAD_DEFINE() 매크로의 사용법을 살펴보고, main() 함수의 역할도 함께 알아보겠습니다. 이번 실습을 통해 Zephyr Thread의 구조를 이해하고, 이후 Priority, Semaphore, Mutex와 같은 RTOS의 주요 기능을 학습하기 위한 기초를 마련하겠습니다.

Zephyr Thread

Thread는 Zephyr RTOS에서 독립적인 작업을 수행하는 기본 실행 단위입니다. 여러 개의 Thread를 생성하면 각 Thread는 자신에게 맡겨진 작업을 수행하며, Zephyr 커널의 Scheduler가 이들을 관리하여 멀티태스킹을 구현합니다.

Thread에 대한 자세한 사항은 Zephyr 공식 사이트에서 확인 할 수 있습니다.

Zephyr Thread

이번 실습에서는 두 개의 Thread를 생성합니다. 하나의 Thread는 LED1을 일정한 주기로 점멸시키고, 다른 Thread는 LED2를 서로 다른 주기로 점멸시킵니다. 두 LED가 각자의 주기에 따라 동작하는 모습을 통해 두 Thread가 독립적으로 실행되는 것을 확인할 수 있습니다.

Zephyr에서 Thread를 구성하는 방법은 크게 두 가지로 나누어 볼 수 있습니다.

main.c에서 Thread를 생성하는 방법

첫 번째 방법은 main() 함수에서 필요한 Thread를 직접 생성하는 방식입니다. 이때는 각 Thread의 Stack과 제어 구조체를 준비한 후 k_thread_create() 함수를 호출합니다.

개념적인 구조는 다음과 같습니다.

main.c
 ├── 시스템 초기화
 ├── LED1 Thread 생성
 ├── LED2 Thread 생성
 └── 기타 초기화 및 관리
이 방식에서는 Thread가 언제 생성되고 실행되는지를 main() 함수에서 직접 제어할 수 있습니다. 실행 중 특정 조건에 따라 Thread를 생성하거나, 시작 시점을 조절해야 하는 경우에 적합합니다.

예를 들면 다음과 같은 요소를 직접 정의해야 합니다.

K_THREAD_STACK_DEFINE(led1_stack, STACK_SIZE);
K_THREAD_STACK_DEFINE(led2_stack, STACK_SIZE);

struct k_thread led1_thread_data;
struct k_thread led2_thread_data;
그다음 main() 함수에서 Thread를 생성합니다.
k_thread_create(&led1_thread_data,
                led1_stack,
                K_THREAD_STACK_SIZEOF(led1_stack),
                led1_thread,
                NULL, NULL, NULL,
                PRIORITY,
                0,
                K_NO_WAIT);

k_thread_create() 함수의 파라메터에 대한 설명은 다음과 같습니다.

이 방식은 Thread의 생성 시점과 실행 조건을 세밀하게 제어할 수 있다는 장점이 있습니다. 그러나 Thread가 많아지면 main.c에 초기화와 생성 코드가 집중되어 파일이 복잡해질 수 있습니다.

K_THREAD_DEFINE()을 사용하는 방법

두 번째 방법은 K_THREAD_DEFINE() 매크로를 사용하여 Thread를 정적으로 정의하는 방식입니다.

K_THREAD_DEFINE(thread_id,
                stack_size,
                entry_function,
                p1, p2, p3,
                priority,
                options,
                delay);
이 매크로에는 Thread의 Stack 크기, 실행 함수, Priority, 시작 지연 시간 등의 정보가 포함됩니다. Zephyr 커널은 빌드 과정에서 이 정보를 등록하고, 시스템이 시작될 때 해당 Thread를 자동으로 생성합니다.

따라서 main() 함수에서 별도로 k_thread_create()를 호출할 필요가 없습니다.

아래는 각 파라미터에 대한 설명입니다.

개념적인 구조는 다음과 같습니다.

main.c
 └── 별도의 작업 없음

led1_thread.c
 ├── LED1 제어 코드
 └── K_THREAD_DEFINE()

led2_thread.c
 ├── LED2 제어 코드
 └── K_THREAD_DEFINE()

각 Thread가 자신의 소스 파일 안에서 정의되므로, 기능별로 코드를 분리하여 관리할 수 있습니다.

이번 실습에서 사용하는 구조

이번 실습에서는 K_THREAD_DEFINE() 매크로를 사용하여 두 Thread를 정적으로 생성합니다.

main.c에서는 Thread를 생성하거나 Scheduler를 시작하지 않습니다.

#include <zephyr/kernel.h>

int main(void)
{
    return 0;
}
LED1을 제어하는 Thread는 led1_thread.c에 작성하고, LED2를 제어하는 Thread는 led2_thread.c에 작성합니다.
src
├── main.c
├── led1_thread.c
└── led2_thread.c
각 소스 파일의 마지막 부분에서 K_THREAD_DEFINE()을 사용하여 해당 Thread를 정의합니다.
K_THREAD_DEFINE(led1_thread_id,
                STACK_SIZE,
                led1_thread,
                NULL, NULL, NULL,
                PRIORITY,
                0,
                0);

이렇게 정의된 Thread는 프로그램이 시작될 때 Zephyr 커널에 의해 자동으로 생성되고 실행됩니다. 따라서 main() 함수가 비어 있어도 LED1 Thread와 LED2 Thread는 정상적으로 동작합니다.

두 방식의 차이

구분 k_thread_create() K_THREAD_DEFINE()
Thread 생성 위치 일반적으로 main() 또는 다른 함수 각 소스 파일의 전역 영역
생성 시점 함수가 호출되는 시점 시스템 시작 과정
Stack 정의 별도로 필요 매크로 내부에서 처리
동적 제어 생성 시점 제어 가능 시작 시 자동 생성
코드 구성 중앙 집중형 기능별 모듈화에 유리
적합한 경우 조건에 따른 생성과 실행 항상 동작하는 고정 Thread

k_thread_create() 방식은 프로그램 실행 중 Thread를 생성하거나 시작 시점을 직접 제어할 때 유용합니다. 반면 K_THREAD_DEFINE() 방식은 시스템 시작과 함께 항상 실행되어야 하는 Thread를 간단하게 구성할 때 적합합니다.

기능별 Thread 분리의 장점

이번 실습처럼 각 Thread를 별도의 소스 파일로 분리하면 하나의 파일이 하나의 기능을 담당하게 됩니다.

led1_thread.c   → LED1 점멸
led2_thread.c   → LED2 점멸
이후 기능을 추가할 때도 같은 구조를 유지할 수 있습니다.
button_thread.c → 버튼 입력 처리
uart_thread.c   → UART 통신
sensor_thread.c → 센서 측정
프로젝트의 기능이 늘어나더라도 main.c에 모든 코드가 집중되지 않으며, 각 Thread의 동작을 해당 소스 파일에서 독립적으로 확인하고 수정할 수 있습니다.

이번 실습에서는 이러한 모듈화 구조를 적용하여 main.c는 비워 두고, 두 개의 LED Thread를 각각 별도의 소스 파일에서 정의해 보겠습니다.

프로젝트 생성 및 코드 구현

프로젝트 복사 및 이름 변경

이전 편에서 생성한 blinky_uart_v1_0 프로젝트를 복사하여 zephyr_thread 프로젝트를 생성합니다.

이번 실습에서는 기존 프로젝트를 기반으로 Thread 기능만 추가합니다. 따라서 프로젝트를 새로 생성하는 대신 기존 프로젝트를 복사하여 사용하는 것이 효율적입니다.

이와 같은 버전 관리 방식은 이전 실습 내용을 그대로 보존하면서 새로운 기능을 단계적으로 추가할 수 있다는 장점이 있습니다.

아래는 Visual Studio Code에서 프로젝트를 복사하여 zephyr_thread 프로젝트를 생성한 모습입니다.

프로젝트 폴더에 CHANGELOG.md 파일을 생성한 후, 아래와 같이 변경 이력을 기록합니다.

앞으로 프로젝트를 수정하거나 새로운 기능을 추가할 때마다 CHANGELOG.md 파일에 변경 내용을 기록합니다. 이러한 변경 이력은 프로젝트의 발전 과정을 추적하고 이전 버전과의 차이를 확인하는 데 도움이 됩니다.

Device Tree에 두 번째 LED 추가

이번 실습에서는 두 개의 Thread가 각각 하나의 LED를 제어하므로, 먼저 Device Tree에 두 번째 LED를 추가해야 합니다.

leds 노드의 led0 아래에 led1 노드를 추가합니다. 이번 예제에서는 PB14 핀을 두 번째 LED로 사용하며, Label은 “D3_LED”로 지정합니다.

아래 그림은 my_f103ve.dts 파일에서 led1 노드를 추가해야 하는 위치를 나타낸 것입니다.

다음과 같이 led1 노드를 추가합니다.

led1: led_1 {
    gpios = <&gpiob 14 GPIO_ACTIVE_LOW>;
    label = "D3_LED";
};

또한 aliases 노드에도 led1을 등록해야 Zephyr 애플리케이션에서 표준 LED 이름으로 사용할 수 있습니다.

aliases {
    led0 = &led0;
    led1 = &led1;
};

아래는 led1이 추가된 dts입니다.

이제 Device Tree에는 두 개의 LED가 등록되었으며, 이후 LED0 Thread와 LED1 Thread에서 각각 led0led1을 사용하여 독립적으로 LED를 제어할 수 있습니다.

LED Thread 구현

이제 각각의 LED를 제어하는 Thread를 구현해 보겠습니다.

이번 실습에서는 Device Tree의 aliases 이름과 동일한 규칙을 사용하기 위해 Thread의 이름도 led0_threadled1_thread로 지정하겠습니다. 이를 통해 Thread 이름과 제어하는 LED를 쉽게 대응시킬 수 있으며, 코드의 가독성과 유지보수성도 향상됩니다.

먼저 LED0 Thread를 구현한 후, 동일한 방법으로 LED1 Thread를 구현하겠습니다.

아래 그림과 같이 zephyr_thread/src 폴더 아래에 다음 네 개의 파일을 생성합니다.

  • led0_thread.c
  • led0_thread.h
  • led1_thread.c
  • led1_thread.h

각 Thread는 소스 파일(.c)과 헤더 파일(.h)로 구성합니다. 소스 파일에는 Thread의 실제 구현을 작성하고, 헤더 파일에는 다른 소스 파일에서 사용할 함수나 변수의 선언을 작성합니다. 이번 실습에서는 구조를 통일하기 위해 각 Thread마다 헤더 파일을 함께 생성하겠습니다.

이후 코드는 led0_thread.cled0_thread.h를 중심으로 설명합니다. led1_thread는 구조와 동작이 모두 동일하며, led0led1로 변경한 것만 다릅니다. 따라서 중복되는 설명은 생략하겠습니다.

아래는 led0_thread.h에 대한 코드 설명입니다.

아래는 led0_thread.c의 코드 설명입니다.

위 코드의 마지막 부분에는 K_THREAD_DEFINE() 매크로가 정의되어 있습니다.

K_THREAD_DEFINE()은 Thread의 Stack 크기, 실행 함수, 우선순위 등의 정보를 Zephyr 커널에 등록하는 매크로입니다.

시스템이 부팅되면 Zephyr 커널은 이 정보를 이용하여 led0_thread를 자동으로 생성하고 실행합니다. 따라서 main() 함수에서 별도로 Thread를 생성하거나 k_thread_create()를 호출할 필요가 없습니다.

아래는 Thread를 K_THREAD_DEFINE()으로 정의한 경우의 main.c입니다.

위 코드에서 main() 함수는 별도의 작업을 수행하지 않고 return 0;을 실행한 후 종료됩니다.

그러나 led0_threadled1_thread는 이미 K_THREAD_DEFINE()에 의해 Zephyr 커널에 등록되어 있으므로, Main Thread가 종료된 이후에도 Scheduler에 의해 계속 실행됩니다.

즉, main() 함수는 이번 실습에서 Thread를 생성하는 역할을 하지 않으며, 실제 LED 제어는 led0_threadled1_thread가 담당합니다.

Thread의 실행 구조는 다음과 같습니다.

K_THREAD_DEFINE()은 Thread를 즉시 실행하는 함수가 아니라, 시스템 시작 시 Zephyr 커널이 자동으로 생성하고 실행할 Thread를 미리 등록하는 매크로입니다.

참고

Zephyr는 k_thread_create()를 이용하여 main() 함수에서 Thread를 생성하는 방식과, K_THREAD_DEFINE()을 이용하여 정적으로 생성하는 방식을 모두 지원합니다. 이번 실습에서는 기능별 모듈화가 쉽고 구조가 간단한 K_THREAD_DEFINE() 방식을 사용하였습니다.

CMakeLists.txt 수정

새로 작성한 led0_thread.cled1_thread.c가 빌드에 포함되도록 CMakeLists.txt 파일을 수정합니다.

기존 CMakeLists.txtmain.c만 등록되어 있다면 다음과 같이 두 개의 Thread 소스 파일을 추가합니다.

cmake_minimum_required(VERSION 3.20.0)

find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(zephyr_thread)

target_sources(app PRIVATE
    src/main.c
    src/led0_thread.c
    src/led1_thread.c
)

target_sources()는 Zephyr 애플리케이션에 포함할 소스 파일을 지정하는 명령입니다.

target_sources(app PRIVATE
여기서 app은 Zephyr 애플리케이션의 기본 빌드 대상입니다. PRIVATE는 등록된 소스 파일이 현재 애플리케이션 빌드에만 사용된다는 의미입니다.
src/main.c
src/led0_thread.c
src/led1_thread.c
빌드에 포함할 소스 파일을 나열합니다. 헤더 파일인 led0_thread.hled1_thread.h는 소스 파일에서 #include하여 사용하므로 target_sources()에 별도로 등록할 필요가 없습니다.

따라서 이번 프로젝트에서는 다음 세 개의 소스 파일이 함께 컴파일됩니다.

main.c
led0_thread.c
led1_thread.c

각 소스 파일은 개별적으로 컴파일된 후 하나의 Zephyr 애플리케이션으로 링크됩니다. 이 과정에서 led0_thread.cled1_thread.c에 정의된 K_THREAD_DEFINE() 매크로도 최종 실행 파일에 포함됩니다. 따라서 시스템이 시작되면 두 Thread가 Zephyr 커널에 의해 생성되고 실행됩니다.

아래는 수정된 CMakeLists.txt 의 내용입니다.

이번 수정으로 main.c, led0_thread.c, led1_thread.c가 모두 빌드 대상에 포함되며, 두 개의 LED Thread를 실행할 준비가 완료됩니다.

 

prj.conf 및 CHANGELOG.md 수정

prj.conf는 다음과 같이 변경합니다.

CONFIG_KERNEL_BIN_NAME은 빌드 후 생성되는 실행 파일의 이름을 지정합니다. 프로젝트 이름과 동일한 zephyr_thread로 설정하면 다음과 같은 결과 파일이 생성됩니다.

zephyr_thread.elf
zephyr_thread.hex
zephyr_thread.bin

CHANGELOG.md는 프로젝트의 변경 이력을 기록하는 파일입니다. 빌드에는 직접적인 영향을 주지 않지만, 기능 추가와 수정 내용을 추적하기 위해 지속적으로 업데이트하는 것이 좋습니다.

이번 실습의 변경 이력은 다음과 같이 기록합니다.

Build 및 Download

지금까지 프로젝트 설정과 Thread 구현이 모두 완료되었습니다. 이제 프로젝트를 빌드하여 실행 파일을 생성해 보겠습니다.

Visual Studio Code의 Terminal에서 진행하며 Build 명령을 간단하게 입력하기 위해 프로젝트 폴더인 zephyr_thread로 이동합니다.

Build를 위한 명령어는 다음과 같습니다.

옵션 설명
-b my_f103ve 사용할 Board 지정
-p always 기존 Build 결과를 삭제하고 새로 Build
-DBOARD_ROOT="../../my_boards" 사용자 Custom Board 위치 지정

Build가 정상적으로 완료되면 아래와 같은 메세지를 볼 수 있습니다.

Build가 완료되면 zephyr_thread.elf가 생성됩니다. 이 파일이 실제 타깃 보드에 다운로드되는 실행 파일입니다.

이제 이 파일을 타겟 보드에 Download를 하면 되는데 1편에서도 언급하였지만
이번 실습 환경에서는 west flash가 자동 실행되지 않는 문제가 있으므로, STM32CubeProgrammer를 사용하여 zephyr_thread.elf를 다운로드하겠습니다.

아래는 Download가 완료된 화면입니다.

다운로드가 완료되면 STM32CubeProgrammer에서 Disconnect를 수행한 후 타깃 보드의 Reset 버튼을 누릅니다. 그러면 Zephyr가 부팅되며 led0_threadled1_thread가 실행되어 두 개의 LED가 서로 다른 주기로 점멸하는 것을 확인할 수 있습니다.

실험 결과

빌드와 다운로드를 완료한 후 보드를 실행하면 LED0(PB13)LED1(PB14)가 각각 설정한 주기에 따라 점멸하는 것을 확인할 수 있습니다.

이번 실습에서는 LED0 Thread의 점멸 주기를 500ms, LED1 Thread의 점멸 주기를 100ms로 설정하였습니다. 실행 결과 LED1은 LED0보다 빠르게 점멸하며, 두 LED가 서로 독립적으로 동작하는 것을 확인할 수 있었습니다.

또한 main() 함수에서 별도의 Thread를 생성하지 않았음에도 두 LED가 정상적으로 동작하였습니다. 이는 K_THREAD_DEFINE()으로 정의한 Thread가 시스템 시작과 함께 Zephyr 커널에 의해 자동으로 생성되고 Scheduler에 의해 실행되기 때문입니다.

이번 실습을 통해 K_THREAD_DEFINE()을 사용한 정적 Thread 생성 방식과 Zephyr의 기본적인 Thread 실행 구조를 실제 동작을 통해 확인할 수 있었습니다.

결론

이번 실습에서는 Zephyr RTOS에서 Thread를 생성하고 실행하는 가장 기본적인 방법을 학습하였습니다. 특히 K_THREAD_DEFINE()을 사용하면 main() 함수에서 k_thread_create()를 호출하지 않아도 Zephyr 커널이 시스템 시작과 함께 Thread를 자동으로 생성하고 실행한다는 점을 확인하였습니다.

또한 Thread별로 소스 파일을 분리하여 구현함으로써 기능별 모듈화가 가능하며, 프로젝트 규모가 커지더라도 코드의 가독성과 유지보수성을 높일 수 있다는 점도 확인하였습니다. 이러한 구조는 실제 임베디드 프로젝트에서도 널리 사용되는 방식입니다.

이번 실습에서 구현한 두 개의 LED Thread는 서로 다른 주기로 독립적으로 실행되었으며, 이를 통해 Zephyr Scheduler가 여러 Thread를 동시에 관리하고 실행하는 기본적인 동작을 확인할 수 있었습니다.

다음 실습에서는 Semaphore를 이용하여 Thread 간의 동기화(Synchronization)를 구현해 보겠습니다. 두 Thread가 하나의 이벤트를 주고받는 과정을 실습하면서, Zephyr에서 Thread 간 협업이 어떻게 이루어지는지 살펴보겠습니다.

 

 

댓글 남기기