Week 4 Day 4: 구조체 데이터 전송

학습 목표

  • FreeRTOS Queue가 구조체를 전송할 때 내부적으로 데이터를 어떻게 복사하는지 이해한다
  • 포인터 전송 방식과 데이터 복사 방식의 장단점과 위험성을 비교하여 올바른 선택 기준을 파악한다
  • 구조체 크기가 Queue 메모리 사용량에 미치는 영향을 이해하고 최적화 방법을 적용한다
  • 센서 데이터 구조체를 Queue로 전달하는 완전한 실습 예제를 구현한다

Queue는 어떤 타입의 데이터든 uxItemSize 바이트만큼 복사하여 저장합니다. 구조체도 예외가 없습니다. 이 복사 특성이 안전성의 핵심이며, 동시에 크기 설계가 중요해지는 이유입니다.


1. Queue의 구조체 복사 동작

1.1 xQueueCreate와 uxItemSize

xQueueCreate(uxQueueLength, uxItemSize)에서 uxItemSize는 전달할 데이터 타입의 sizeof 값입니다. Queue는 생성 시 uxQueueLength * uxItemSize 바이트의 내부 버퍼를 Heap에 할당합니다. xQueueSend()를 호출하면 전달한 주소에서 uxItemSize 바이트를 그대로 내부 버퍼에 memcpy합니다.

/*
 * Queue 내부 동작 이해
 *
 * xQueueCreate(10, sizeof(SensorData_t)):
 *   Heap에 10 * sizeof(SensorData_t) 바이트 할당
 *   → Queue 깊이 10, 아이템 하나가 sizeof(SensorData_t) 바이트
 *
 * xQueueSend(xQueue, &data, timeout):
 *   내부적으로 memcpy(내부버퍼[tail], &data, uxItemSize) 수행
 *   → data 변수의 현재 값이 Queue에 스냅샷으로 저장됨
 *   → 이후 data 변수가 변경되어도 Queue 내부에는 영향 없음
 *
 * xQueueReceive(xQueue, &out, timeout):
 *   내부적으로 memcpy(&out, 내부버퍼[head], uxItemSize) 수행
 */

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include <stdio.h>
#include <stdint.h>

typedef struct
{
    uint8_t  sensor_id;
    uint16_t raw_value;
    float    temperature;
    uint32_t timestamp_ms;
} SensorData_t;   /* sizeof = 1 + 1(패딩) + 2 + 4 + 4 = 12바이트 (정렬에 따라 다를 수 있음) */

static QueueHandle_t xSensorQueue;

/* 생산자 Task */
void vSensorTask(void *pvParameters)
{
    SensorData_t data;
    uint16_t raw = 0;

    while (1)
    {
        data.sensor_id    = 1U;
        data.raw_value    = raw;
        data.temperature  = raw * 3.3f / 4095.0f * 100.0f;   /* 시뮬레이션 */
        data.timestamp_ms = xTaskGetTickCount();

        /*
         * xQueueSend는 data의 현재 값 전체를 Queue에 복사합니다.
         * 이 줄 이후 data를 덮어써도 Queue 내부 값은 보존됩니다.
         */
        if (xQueueSend(xSensorQueue, &data, pdMS_TO_TICKS(10)) != pdTRUE)
        {
            printf("[Sensor] Queue full, data dropped\r\n");
        }

        raw = (raw + 100U) % 4096U;
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

/* 소비자 Task */
void vLoggerTask(void *pvParameters)
{
    SensorData_t received;

    while (1)
    {
        if (xQueueReceive(xSensorQueue, &received, portMAX_DELAY) == pdTRUE)
        {
            printf("[Logger] id=%u raw=%u temp=%.1f°C @%lums\r\n",
                   received.sensor_id,
                   received.raw_value,
                   received.temperature,
                   received.timestamp_ms);
        }
    }
}

int main(void)
{
    /*
     * Queue 깊이 20, 아이템 크기 = sizeof(SensorData_t)
     * Heap 사용량 = 20 * sizeof(SensorData_t) + Queue 제어 블록(~80바이트)
     */
    xSensorQueue = xQueueCreate(20, sizeof(SensorData_t));
    configASSERT(xSensorQueue != NULL);

    xTaskCreate(vSensorTask, "Sensor", 256, NULL, 3, NULL);
    xTaskCreate(vLoggerTask, "Logger", 512, NULL, 2, NULL);

    vTaskStartScheduler();
    while (1);
}

2. 포인터 전송 방식 vs 데이터 복사 방식

2.1 포인터 전송의 위험성

Queue에 데이터 자체 대신 포인터를 전달하는 방식은 Heap 사용량을 줄이는 것처럼 보이지만 심각한 문제를 일으킬 수 있습니다.

/*
 * 포인터 전송 — 위험한 패턴 예시
 *
 * 스택 변수의 주소를 Queue에 넣으면 함수가 반환된 후 해당 주소는 무효가 됩니다.
 * 이 패턴은 댕글링 포인터(Dangling Pointer)로 이어집니다.
 */

#include "FreeRTOS.h"
#include "queue.h"
#include <stdio.h>

static QueueHandle_t xPtrQueue;   /* SensorData_t* 포인터를 담는 Queue */

/* 잘못된 패턴: 스택 변수 주소 전송 */
void vBadSensorTask(void *pvParameters)
{
    while (1)
    {
        SensorData_t local_data;   /* 스택에 할당 */
        local_data.raw_value = 1234U;

        SensorData_t *ptr = &local_data;

        /*
         * Queue에 포인터를 넣습니다.
         * 이 함수(Task)는 무한 루프이므로 지금은 우연히 동작할 수 있습니다.
         * 하지만 일반 함수에서 이 패턴을 사용하면 함수 반환 후 스택이 재사용되어
         * 수신 측에서 포인터를 역참조하는 순간 쓰레기 값을 읽거나 크래시가 발생합니다.
         */
        xQueueSend(xPtrQueue, &ptr, 0);

        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

/*
 * 안전한 포인터 전송 패턴:
 * 동적으로 할당된 메모리의 주소를 전달하고,
 * 수신 측에서 사용 후 반드시 해제합니다.
 *
 * 주의: FreeRTOS Heap 단편화 위험으로 인해 빈번한 동적 할당/해제는 권장하지 않습니다.
 *       가능하면 데이터 복사 방식을 사용하고, 포인터 방식은 대용량 데이터(>64바이트)에만 고려합니다.
 */
void vGoodPointerTask(void *pvParameters)
{
    while (1)
    {
        SensorData_t *pData = pvPortMalloc(sizeof(SensorData_t));

        if (pData != NULL)
        {
            pData->raw_value = 5678U;
            pData->sensor_id = 2U;

            if (xQueueSend(xPtrQueue, &pData, pdMS_TO_TICKS(10)) != pdTRUE)
            {
                /* 전송 실패: 직접 해제해야 합니다 (수신 측에서 해제 불가) */
                vPortFree(pData);
            }
        }

        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void vPointerReceiverTask(void *pvParameters)
{
    SensorData_t *pData;

    while (1)
    {
        if (xQueueReceive(xPtrQueue, &pData, portMAX_DELAY) == pdTRUE)
        {
            printf("[Receiver] raw=%u\r\n", pData->raw_value);

            /* 수신 측에서 반드시 해제 */
            vPortFree(pData);
        }
    }
}

2.2 방식 선택 기준 비교

기준데이터 복사 방식포인터 전송 방식
안전성높음: Queue 내부에 독립적 복사본낮음: 원본 수명 관리 필요
Heap 사용Queue 깊이 × sizeof(구조체) 고정구조체 크기 무관, 포인터 크기(4바이트)만
권장 구조체 크기64바이트 이하64바이트 초과
해제 책임없음 (자동)수신 측 또는 실패 시 송신 측
ISR 사용xQueueSendFromISR 가능동적 할당은 ISR 내 불가

3. 구조체 크기 최적화

3.1 패딩과 정렬이 Queue 메모리에 미치는 영향

컴파일러는 구조체 멤버를 자연 정렬(natural alignment)에 맞게 배치합니다. 멤버 순서에 따라 패딩이 삽입되어 실제 크기가 예상보다 클 수 있습니다. Queue 깊이가 깊을수록 이 차이는 메모리에 직접 영향을 줍니다.

/*
 * 구조체 크기 비교 예제
 *
 * [비효율적 순서]
 * struct Inefficient {
 *   uint8_t  a;         // offset 0, size 1
 *   // 패딩 3바이트 (uint32_t 정렬)
 *   uint32_t b;         // offset 4, size 4
 *   uint8_t  c;         // offset 8, size 1
 *   // 패딩 1바이트 (uint16_t 정렬)
 *   uint16_t d;         // offset 10, size 2
 *   // 패딩 0바이트 (구조체 전체는 uint32_t 정렬 → 크기 12)
 * };
 * sizeof = 12
 *
 * [효율적 순서: 큰 타입 먼저]
 * struct Efficient {
 *   uint32_t b;         // offset 0, size 4
 *   uint16_t d;         // offset 4, size 2
 *   uint8_t  a;         // offset 6, size 1
 *   uint8_t  c;         // offset 7, size 1
 * };
 * sizeof = 8
 *
 * Queue 깊이 20이라면:
 *   비효율: 20 × 12 = 240바이트
 *   효율적: 20 ×  8 = 160바이트 (80바이트 절약)
 */

#include "FreeRTOS.h"
#include "queue.h"
#include <stdio.h>
#include <stdint.h>

/* 비효율적 레이아웃 */
typedef struct
{
    uint8_t  sensor_id;     /* 1바이트 + 패딩 3바이트 */
    uint32_t timestamp_ms;  /* 4바이트 */
    uint8_t  status;        /* 1바이트 + 패딩 1바이트 */
    uint16_t raw_value;     /* 2바이트 */
    /* 합계: 12바이트 (정렬 패딩 포함) */
} SensorPacketBad_t;

/* 효율적 레이아웃: 큰 타입 → 작은 타입 순서 */
typedef struct
{
    uint32_t timestamp_ms;  /* 4바이트 */
    uint16_t raw_value;     /* 2바이트 */
    uint8_t  sensor_id;     /* 1바이트 */
    uint8_t  status;        /* 1바이트 */
    /* 합계: 8바이트 (패딩 없음) */
} SensorPacketGood_t;

void print_sizes(void)
{
    printf("SensorPacketBad_t  size: %u bytes\r\n", (unsigned)sizeof(SensorPacketBad_t));
    printf("SensorPacketGood_t size: %u bytes\r\n", (unsigned)sizeof(SensorPacketGood_t));

    /* Queue 깊이 20 기준 */
    printf("Queue(20, Bad):  %u bytes\r\n", (unsigned)(20 * sizeof(SensorPacketBad_t)));
    printf("Queue(20, Good): %u bytes\r\n", (unsigned)(20 * sizeof(SensorPacketGood_t)));
}

3.2 공용체(Union)를 이용한 다형 메시지 설계

여러 종류의 메시지를 하나의 Queue로 처리해야 할 때, 공용체를 사용하면 Queue 아이템 크기를 단일 타입으로 통일할 수 있습니다.

/*
 * 다형 메시지 패턴:
 * 메시지 종류에 따라 다른 데이터를 담지만
 * Queue 아이템 크기는 항상 sizeof(Message_t)로 고정합니다.
 */

#include <stdint.h>
#include <string.h>

typedef enum
{
    MSG_SENSOR_DATA  = 0U,
    MSG_ERROR        = 1U,
    MSG_COMMAND      = 2U,
} MsgType_t;

typedef struct
{
    uint8_t  sensor_id;
    uint16_t raw;
    float    value;
} SensorPayload_t;

typedef struct
{
    uint32_t code;
    uint8_t  source;
} ErrorPayload_t;

typedef struct
{
    uint8_t  cmd;
    uint8_t  param[4];
} CmdPayload_t;

/*
 * 공용체 메시지: 가장 큰 페이로드 크기가 전체 크기를 결정합니다.
 * sizeof(Message_t) = sizeof(MsgType_t) + max(sizeof(SensorPayload_t), ...)
 */
typedef struct
{
    MsgType_t type;   /* 메시지 종류 */
    union
    {
        SensorPayload_t sensor;
        ErrorPayload_t  error;
        CmdPayload_t    cmd;
    } payload;
} Message_t;

static QueueHandle_t xMsgQueue;

/* 송신 측: 종류별 헬퍼 함수 */
void send_sensor_data(uint8_t id, uint16_t raw, float val)
{
    Message_t msg;
    msg.type              = MSG_SENSOR_DATA;
    msg.payload.sensor.sensor_id = id;
    msg.payload.sensor.raw       = raw;
    msg.payload.sensor.value     = val;
    xQueueSend(xMsgQueue, &msg, pdMS_TO_TICKS(10));
}

void send_error(uint32_t code, uint8_t source)
{
    Message_t msg;
    msg.type                  = MSG_ERROR;
    msg.payload.error.code    = code;
    msg.payload.error.source  = source;
    xQueueSend(xMsgQueue, &msg, pdMS_TO_TICKS(10));
}

/* 수신 측: type 필드로 분기 */
void vDispatchTask(void *pvParameters)
{
    Message_t msg;

    while (1)
    {
        if (xQueueReceive(xMsgQueue, &msg, portMAX_DELAY) == pdTRUE)
        {
            switch (msg.type)
            {
                case MSG_SENSOR_DATA:
                    printf("[Sensor] id=%u val=%.2f\r\n",
                           msg.payload.sensor.sensor_id,
                           msg.payload.sensor.value);
                    break;

                case MSG_ERROR:
                    printf("[Error] code=0x%08lX src=%u\r\n",
                           msg.payload.error.code,
                           msg.payload.error.source);
                    break;

                case MSG_COMMAND:
                    printf("[Cmd] cmd=0x%02X\r\n", msg.payload.cmd.cmd);
                    break;

                default:
                    break;
            }
        }
    }
}

4. 실습: 센서 데이터 구조체 전송 파이프라인

TIM2 100ms 주기로 여러 센서(온도, 습도, 조도)를 읽고 구조체로 묶어 Queue에 전달하면 로거 Task가 수신하여 UART로 출력하는 완전한 예제입니다.

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "stm32f4xx_hal.h"
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

/* ---- 구조체 정의 (크기 최적화: 큰 타입 먼저) ---- */
typedef struct
{
    uint32_t timestamp_ms;   /* 4바이트 */
    float    temperature;    /* 4바이트: 섭씨 */
    float    humidity;       /* 4바이트: % */
    uint16_t lux;            /* 2바이트: 조도 */
    uint8_t  sequence;       /* 1바이트: 패킷 순서 번호 */
    uint8_t  flags;          /* 1바이트: 비트 플래그 */
    /* 합계: 16바이트, 패딩 없음 */
} EnvData_t;

#define FLAG_TEMP_VALID   (1U << 0)
#define FLAG_HUM_VALID    (1U << 1)
#define FLAG_LUX_VALID    (1U << 2)

#define ENV_QUEUE_DEPTH  16U

static QueueHandle_t xEnvQueue;

/* ---- 시뮬레이션 센서 읽기 함수 ---- */
static float read_temperature(void)
{
    /* NTC 온도 센서: ADC 값으로부터 온도 계산 시뮬레이션 */
    uint16_t raw = (uint16_t)(ADC1->DR & 0x0FFFU);
    return 25.0f + (raw - 2048) * 0.01f;
}

static float read_humidity(void)
{
    /* SHT31 I2C 센서 읽기 시뮬레이션 */
    return 60.0f + (float)(HAL_GetTick() % 20);
}

static uint16_t read_lux(void)
{
    /* BH1750 I2C 조도 센서 읽기 시뮬레이션 */
    return (uint16_t)(100U + (HAL_GetTick() / 100U) % 900U);
}

/* ---- 수집 Task ---- */
void vCollectTask(void *pvParameters)
{
    EnvData_t data;
    uint8_t   seq = 0;

    while (1)
    {
        data.timestamp_ms = xTaskGetTickCount();
        data.sequence     = seq++;
        data.flags        = 0;

        /* 각 센서 읽기 및 유효 플래그 설정 */
        data.temperature = read_temperature();
        data.flags      |= FLAG_TEMP_VALID;

        data.humidity = read_humidity();
        data.flags   |= FLAG_HUM_VALID;

        data.lux    = read_lux();
        data.flags |= FLAG_LUX_VALID;

        /* 구조체 전체를 Queue에 복사 */
        if (xQueueSend(xEnvQueue, &data, pdMS_TO_TICKS(20)) != pdTRUE)
        {
            /* Queue 포화: 가장 오래된 데이터를 버리고 최신 데이터 삽입 */
            EnvData_t dummy;
            xQueueReceive(xEnvQueue, &dummy, 0);
            xQueueSend(xEnvQueue, &data, 0);
        }

        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

/* ---- 출력 Task ---- */
void vPrintTask(void *pvParameters)
{
    EnvData_t data;

    printf("=== 환경 데이터 모니터 시작 ===\r\n");
    printf("sizeof(EnvData_t) = %u bytes\r\n", (unsigned)sizeof(EnvData_t));
    printf("Queue 메모리 = %u bytes\r\n",
           (unsigned)(ENV_QUEUE_DEPTH * sizeof(EnvData_t)));

    while (1)
    {
        if (xQueueReceive(xEnvQueue, &data, portMAX_DELAY) == pdTRUE)
        {
            printf("[%5lums #%3u]", data.timestamp_ms, data.sequence);

            if (data.flags & FLAG_TEMP_VALID)
                printf(" T=%.1f°C", data.temperature);

            if (data.flags & FLAG_HUM_VALID)
                printf(" H=%.0f%%", data.humidity);

            if (data.flags & FLAG_LUX_VALID)
                printf(" L=%ulux", data.lux);

            printf("\r\n");
        }
    }
}

/* ---- 상태 모니터 Task ---- */
void vMonitorTask(void *pvParameters)
{
    while (1)
    {
        vTaskDelay(pdMS_TO_TICKS(5000));
        printf("[Monitor] Queue 대기: %u / %u\r\n",
               (unsigned)uxQueueMessagesWaiting(xEnvQueue),
               (unsigned)ENV_QUEUE_DEPTH);
    }
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_ADC1_Init();
    MX_USART2_UART_Init();

    xEnvQueue = xQueueCreate(ENV_QUEUE_DEPTH, sizeof(EnvData_t));
    configASSERT(xEnvQueue != NULL);

    xTaskCreate(vCollectTask, "Collect", 256, NULL, 3, NULL);
    xTaskCreate(vPrintTask,   "Print",   512, NULL, 2, NULL);
    xTaskCreate(vMonitorTask, "Monitor", 256, NULL, 1, NULL);

    vTaskStartScheduler();
    while (1);
}

학습 정리

항목내용
Queue 복사 동작xQueueSend 시 uxItemSize 바이트를 memcpy로 내부 버퍼에 복사
구조체 크기와 메모리Queue 깊이 × sizeof(구조체) = Heap 사용량, 크기 최적화 중요
데이터 복사 vs 포인터64바이트 이하는 복사, 초과는 동적 할당 후 포인터 (수명 관리 필수)
구조체 정렬 최적화큰 타입을 먼저 선언하면 패딩 최소화
공용체 메시지다양한 메시지 타입을 하나의 Queue로 처리, type 필드로 분기
Queue 포화 대응오래된 데이터 드롭 후 최신 데이터 삽입 또는 드롭 카운터 유지

실습 과제

  1. SensorPacketBad_tSensorPacketGood_tsizeof를 실제로 출력하고, Queue 깊이 50에서 두 구조체의 Heap 사용량 차이를 계산하여 출력하는 코드를 작성하십시오.
  2. EnvData_t 구조체에 GPS 좌표 필드(float lat, lon)를 추가했을 때 Queue 메모리가 얼마나 늘어나는지 계산하고, 포인터 방식으로 전환해야 하는 임계 크기를 판단하십시오.
  3. 실습 코드에서 Queue 포화가 발생했을 때 오래된 데이터를 버리는 대신, 가장 최근 5개의 데이터를 평균 내어 하나의 구조체로 압축하는 로직을 vCollectTask에 추가하십시오.

디버깅 팁

  1. xQueueSendpdFALSE를 반환하면 즉시 uxQueueSpacesAvailable()로 여유 공간을 확인한다. 0이면 Queue 깊이를 늘리거나 소비자 Task 우선순위를 올려야 한다.
  2. 포인터 전송 패턴에서 크래시가 발생하면 먼저 포인터가 가리키는 메모리의 수명을 추적한다. 스택 변수의 주소가 전달되지는 않았는지 확인한다.
  3. 구조체를 Queue에 넣었는데 수신 측의 값이 이상하다면 sizeofxQueueCreate에 전달한 크기와 비교한다. 타입이 바뀌었는데 Queue를 재생성하지 않았을 수 있다.
  4. 공용체 메시지에서 type 필드가 잘못된 값을 가리키면 default 케이스에 configASSERT(0)을 추가하여 즉시 오류를 발견할 수 있다.
profile
당신의 코딩 메이트

0개의 댓글