Devin.KR

메모리 관리 - 동적 할당을 피하는 법

개발자KR 조회 5

이 장에서 배우는 것

앞 장에서 실행 시간과 마감 시간을 연결했다. 이번에는 그 시간 안에 사용할 메모리를 미리 확보하는 방법을 다룬다. 실행 시간이 짧아도 작업 기록을 저장할 공간을 구하지 못하면 컨베이어 제어기는 예정한 일을 끝낼 수 없다. 메모리 사용량과 부족할 때의 동작도 설계의 일부다.

동적 할당을 피한다는 말은 모든 변수를 전역으로 옮긴다는 뜻이 아니다. 객체의 개수와 수명을 정하고, 필요한 공간을 시작 전에 확보하며, 실행 중에는 그 공간의 소유권만 옮긴다는 뜻이다. 여기에 스택 사용량 관측과 최종 실행 파일의 메모리 배치 확인이 더해져야 한다.

  • 고정 크기 풀(fixed-size pool)로 컨베이어 작업 기록의 최대 개수를 제한한다.
  • 풀 고갈과 반납 오류를 검사하고 객체의 소유권을 설명한다.
  • 스택 경계 표식과 사용 흔적으로 서로 다른 이상을 관측한다.
  • 링커 맵(linker map)을 읽어 정적 저장 공간과 실제 메모리 예산을 대조한다.
  • PC 모형의 기능을 실제 FreeRTOS 설정과 API에 대응시킨다.

문제 상황

컨베이어 입구의 센서가 물체를 감지할 때마다 작업 기록을 하나 만든다고 하자. 기록에는 일련번호, 센서 측정값, 분류 경로가 들어간다. 출구에서 분류 작업을 끝내면 기록을 반납한다. 입구와 출구 사이에 머무는 물체가 늘어날수록 동시에 살아 있는 기록도 늘어난다.

처음에는 기록을 만들 때마다 malloc을 호출하고 처리 후 free를 호출했다. 짧은 시험에서는 문제가 없었다. 그러나 출구 처리가 늦어지자 기록이 쌓였다. 할당 실패 검사가 빠진 경로에서는 널 포인터를 사용했고, 실패를 검사한 경로에서도 이미 감지한 물체를 어떻게 취급할지 정하지 않았다. 다른 크기의 통신 버퍼까지 같은 힙(heap)을 사용하면서 메모리 예산을 설명하기도 어려워졌다.

이 사례에서 먼저 정할 것은 할당 함수의 종류가 아니다. 동시에 유지할 기록의 상한과 상한을 넘겼을 때의 처리다. 실제 장비라면 입구 유입 제한, 별도 배출, 상위 제어기에 통보하는 정책 등이 필요하다. 이 장의 PC 프로그램은 네 번째 요청을 거절하고 거절 횟수를 출력한다. 이것은 메모리 동작을 관찰하기 위한 정책이며, 실제 물체의 안전한 처리를 대신하지 않는다.

예제에서는 기록 세 개, 작업 두 개, 작업별 모형 스택 128바이트를 미리 확보한다. 할당 실패를 우연히 기다리지 않고 정해진 시각에 발생시킨다. 반납한 공간을 다시 얻는 과정과 경계 표식 손상도 재현한다. 실행할 때마다 같은 결과가 나와야 메모리 정책을 검토하기 쉽다.

고정 크기 풀로 객체 수와 소유권을 제한한다

풀은 같은 형식의 객체를 여러 개 담는 저장소다. 이 장에서는 Job 배열과 사용 여부 배열을 나란히 둔다. 획득 함수는 비어 있는 첫 칸을 찾아 주소를 돌려주고, 반납 함수는 그 주소가 풀의 사용 중인 칸인지 확인한다. 프로그램 실행 중 저장소의 크기는 바뀌지 않는다.

저장소를 Job 배열로 선언하면 컴파일러가 Job에 필요한 정렬을 보장한다. 바이트 배열을 만들고 Job 포인터로 형 변환하는 방법은 정렬을 별도로 증명해야 한다. 구조체 내부의 채움 바이트도 있으므로 기록 하나의 비용은 멤버 크기의 단순 합이 아니라 sizeof(Job)으로 계산한다. 풀 전체에는 사용 표시와 통계 필드도 포함된다.

세 칸이 모두 사용 중이면 획득은 실패하고 한 칸을 반납하면 그 칸을 다시 사용할 수 있다

고정 크기 풀은 서로 다른 크기의 빈 공간을 합칠 필요가 없다. 대신 모든 칸이 같은 크기이므로 작은 내용만 저장해도 한 칸 전체를 차지한다. 이 예제는 형식이 같은 작업 기록에 잘 맞는다. 크기가 크게 다른 메시지까지 같은 풀에 넣으려 한다면 별도 풀을 두거나 기록 형식을 다시 정해야 한다.

용량은 평균 물체 수로 정하지 않는다. 한 기록이 유지되는 최대 시간 동안 들어올 수 있는 요청 수와 한꺼번에 들어오는 요청을 함께 살핀다. 센서 입력 간격이 엄격히 제한되어 있고 체류 시간의 상한도 입증되었다면 두 값으로 필요한 개수를 계산할 수 있다. 출력 지연의 상한이 없다면 유한한 풀 하나로 모든 입력을 받아들이겠다는 요구부터 성립하지 않는다.

이 구현의 획득과 반납은 최대 세 칸을 검사한다. 용량을 N으로 늘리면 검사 횟수의 상한도 N이 된다. 고정 크기라는 사실만으로 연산 시간이 용량과 무관해지지는 않는다. 빈 칸 목록으로 획득과 반납을 일정한 단계 수로 바꿀 수도 있지만, 작은 풀에서는 순차 탐색이 구현과 검증을 단순하게 만든다.

획득 직후에는 호출자가 기록을 소유한다. 처리 담당 위치에 포인터를 넘기면 그 위치가 소유자가 된다. 소유자는 마지막 사용 후 한 번만 반납하고 보관한 포인터를 비운다. 반납 함수가 중복 반납을 거절하더라도 소유권 규칙이 필요하다. 반납된 주소가 다른 기록에 재사용된 뒤 오래된 포인터로 반납하면, 단순한 사용 표시만으로는 새 소유자의 기록과 구분하지 못한다.

따라서 이 예제의 검사는 잘못된 주소와 재사용 전의 중복 반납을 찾는 보조 장치다. 오래된 참조까지 검출하려면 슬롯 번호와 세대 번호를 묶는 방식 등이 필요하다. 여기서는 포인터를 복제해 여러 곳에 남기지 않는 규칙으로 범위를 제한한다. 협동형 실행에서는 함수 사이에 다른 작업이 끼어들지 않지만, 선점 가능한 태스크 여러 개가 풀을 공유한다면 별도의 동기화도 필요하다.

스택의 여유와 경계 손상은 다른 관측값이다

스택(stack)은 함수 호출 정보와 자동 저장 기간 객체 등을 담는 실행 공간이다. 큰 지역 배열, 깊은 호출, 재귀 호출은 필요한 공간을 늘린다. 컴파일러의 최적화와 대상 프로세서의 호출 규약도 실제 사용량에 영향을 준다. 소스 코드에서 지역 변수의 크기를 더한 값만으로 태스크 스택 크기를 확정할 수는 없다.

경계 표식은 스택 영역 옆에 알려진 값을 놓고 나중에 바뀌었는지 확인하는 방법이다. 표식이 달라지면 경계 주변에 쓰기가 일어났다는 단서를 얻는다. 다만 표식이 그대로라고 해서 모든 접근이 정상이었다고 증명되지는 않는다. 멀리 벗어난 쓰기가 표식을 건너뛰거나, 손상된 값이 우연히 원래 값과 같을 수 있다. 검사 사이에 일어난 손상도 즉시 발견되지는 않는다.

사용 흔적은 다른 질문에 답한다. 시작할 때 영역을 일정한 바이트로 채워 두고, 실행 후 그 값이 남은 길이를 센다. 아직 건드리지 않은 것처럼 보이는 공간이 얼마나 되는지 추정할 수 있다. 관측된 최대 사용량은 시험한 경로의 결과다. 실행하지 않은 오류 경로나 더 깊은 호출 경로에 대한 보장은 아니다. 실제 스택에 같은 바이트 값이 기록되면 흔적을 과소평가할 수도 있다.

사용 흔적은 남은 공간을 추정하고 경계 표식은 경계 주변의 손상을 검사하므로 두 값은 따로 확인해야 한다

표준 C만으로 현재 실행 중인 호스트 스택의 경계를 알아내고 마음대로 채우는 것은 불가능하다. 다음 프로그램은 스택을 바이트 배열로 모형화한다. 작업 함수가 자신의 모형에 사용량을 명시하면 배열의 높은 인덱스 쪽을 0으로 기록한다. 사용 흔적은 누적해서 남기므로 이후 요청량이 작아져도 과거의 큰 사용량을 유지한다.

마지막에는 경계 필드 하나를 직접 바꿔 감지 함수를 시험한다. 배열 밖에 쓰지 않으므로 C의 정의되지 않은 동작을 이용하지 않는다. 이것은 검사 로직의 시험이지, PC의 실제 스택 넘침 시험이 아니다. 작업 두 개도 실제로는 같은 호스트 호출 스택에서 순서대로 실행된다.

PC 모형과 FreeRTOS에서 확인할 대상의 대응
목적PC 예제FreeRTOS 대응주의점
작업 실행 공간 확보정적 작업 정보와 모형 배열xTaskCreateStatic스택 배열과 StaticTask_t 저장소를 제공하며 정적 할당 설정이 필요하다.
작업 기록 재사용Job 고정 크기 풀응용 프로그램의 풀이 풀에 일대일로 대응하는 범용 커널 풀 API는 없다.
스택 여유 관측stack_model_peakuxTaskGetStackHighWaterMark모형은 사용 바이트를, API는 관측된 최소 잔여 스택을 StackType_t 단위로 보고한다.
넘침 검사stack_model_okconfigCHECK_FOR_STACK_OVERFLOW와 vApplicationStackOverflowHook검사 방식과 지원 범위는 설정 및 포트에 따라 확인한다.

xTaskCreateStatic에 전달하는 스택 깊이는 바이트 수가 아니라 StackType_t 원소 수다. 예를 들어 해당 포트에서 sizeof(StackType_t)가 4라면 깊이 256은 스택 배열 1,024바이트에 해당한다. 제어 블록 등의 비용은 별도다. 스택 여유 조회 API도 포함 설정을 확인해야 한다. 정적 태스크 생성만 사용한다고 라이브러리와 다른 구성 요소의 동적 할당까지 사라지는 것은 아니다.

사실 확인에는 정적 태스크 생성 API, 스택 여유 조회 API, 스택 사용량 및 넘침 검사 설명을 참고한다. 아래 코드는 이 API의 구현을 복제하지 않은 독립적인 PC 모형이다.

링커 맵으로 확보한 공간을 확인한다

소스 코드의 배열 선언은 계획이고, 링크 결과는 실행 파일에 반영된 배치다. 링커 맵에는 입력 파일, 출력 영역, 심벌 주소와 크기 등의 정보가 나온다. 형식은 링커마다 다르지만 어떤 객체가 어느 영역을 얼마나 차지하는지 추적한다는 목적은 같다.

일반적인 MCU 구성에서 .text는 명령어, .rodata는 읽기 전용 데이터, .data는 초기값이 있는 쓰기 가능 데이터, .bss는 0으로 초기화하는 데이터를 담는다. .data는 실행 중 RAM을 차지하면서 초기값 이미지가 플래시에도 필요할 수 있다. .bss는 초기값 바이트를 파일에 길게 저장하지 않아도 실행 중에는 해당 크기의 RAM이 필요하다.

따라서 실행 파일 크기와 RAM 사용량은 같지 않다. 플래시에 놓이는 초기값의 위치와 RAM에서 사용할 위치가 다른 구성에서는 두 주소를 구분한다. 영역 시작 주소, 크기, 정렬 때문에 생긴 간격을 함께 읽어야 한다. 임베디드 링커 스크립트가 별도로 예약한 힙과 스택도 예산에 넣는다.

예제의 g_pool, g_pending, 두 모형 스택은 정적 저장 기간을 갖는다. 초기화되지 않은 정적 객체는 실행 시작 전에 0으로 초기화된다. 모형 스택을 0xA5로 채우는 코드는 실행 중 초기화다. 그 때문에 배열의 저장 영역이 초기값 있는 데이터 영역으로 바뀌는 것은 아니다. 반면 함수 포인터가 들어 있는 g_tasks는 초기값과 재배치가 필요한 영역에 배치될 수 있다.

맵을 읽을 때는 먼저 큰 영역의 합을 보고, 다음으로 큰 객체를 찾고, 마지막으로 예상한 증가량을 확인한다. 풀의 용량을 세 개에서 네 개로 늘렸다면 Job 한 개뿐 아니라 사용 표시와 정렬 영향도 살핀다. 동일한 빌드 조건의 이전 맵과 비교하면 실수로 추가한 대형 배열을 발견하기 쉽다.

Linux의 ELF 출력과 macOS의 Mach-O 출력은 영역 이름부터 다르다. macOS에서는 __TEXT와 __DATA 계열의 세그먼트 및 섹션을 보게 되며, 심벌 이름 앞에 밑줄이 붙기도 한다. PC 맵에서 관찰한 크기와 배치를 MCU에 그대로 대입하지 않는다. 포인터 크기, 구조체 정렬, 라이브러리, 링커 설정이 다르기 때문이다.

또한 맵은 함수의 최대 호출 깊이를 계산한 보고서가 아니다. 운영체제가 마련하는 PC의 실행 스택이나 실행 중 라이브러리 내부에서 얻는 메모리를 정적 객체 목록만으로 모두 설명할 수 없다. 이 장의 모형 배열은 맵에 나타나지만 실제 호스트 스택 사용량은 따로 다뤄야 한다. 정적 배치와 실행 중 관측을 함께 보는 이유다.

완성 코드

다음 내용을 memory_demo.c로 저장한다. hal_sim 함수는 시간과 센서값을 제공하고, sched_sim_run은 두 작업을 정해진 순서로 호출한다. 스레드는 생성하지 않는다. 요구한 빌드 옵션의 -pthread는 사용하지만, 이 프로그램의 실행은 한 흐름 안에서 이루어진다. 대괄호 번호 주석은 뒤의 해설과 연결된다.

memory_demo.c

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

/* [01] Fixed storage limits. */
enum {
    POOL_CAPACITY = 3,
    MODEL_STACK_BYTES = 128,
    RUN_TICKS = 7
};

#define STACK_MARK UINT32_C(0x51A7C0DE)

typedef struct {
    unsigned serial;
    unsigned sensor;
    unsigned route;
} Job;

typedef struct {
    Job items[POOL_CAPACITY];
    bool used[POOL_CAPACITY];
    unsigned in_use;
    unsigned peak;
    unsigned denied;
} JobPool;

typedef struct {
    uint32_t low_mark;
    unsigned char bytes[MODEL_STACK_BYTES];
    uint32_t high_mark;
} StackModel;

/* [02] Objects with static storage duration. */
static JobPool g_pool;
static Job *g_pending[POOL_CAPACITY];
static StackModel g_capture_stack;
static StackModel g_finish_stack;
static unsigned g_tick;
static unsigned g_serial;

/* [03] Small deterministic HAL simulation. */
static unsigned hal_sim_now(void)
{
    return g_tick;
}

static unsigned hal_sim_sensor(void)
{
    return 1000U + hal_sim_now();
}

/* [04] Acquire at most one typed object. */
static Job *pool_acquire(JobPool *pool)
{
    for (size_t i = 0; i < POOL_CAPACITY; ++i) {
        if (!pool->used[i]) {
            pool->used[i] = true;
            pool->in_use++;
            if (pool->in_use > pool->peak) {
                pool->peak = pool->in_use;
            }
            pool->items[i] = (Job){0};
            return &pool->items[i];
        }
    }
    pool->denied++;
    return NULL;
}

/* [05] Equality checks avoid subtracting an unknown pointer. */
static bool pool_release(JobPool *pool, Job *job)
{
    for (size_t i = 0; i < POOL_CAPACITY; ++i) {
        if (job == &pool->items[i]) {
            if (!pool->used[i]) {
                return false;
            }
            pool->used[i] = false;
            pool->in_use--;
            return true;
        }
    }
    return false;
}

/* [06] This is a stack model, not the host call stack. */
static void stack_model_init(StackModel *stack)
{
    stack->low_mark = STACK_MARK;
    memset(stack->bytes, 0xA5, sizeof stack->bytes);
    stack->high_mark = STACK_MARK;
}

static bool stack_model_touch(StackModel *stack, size_t bytes)
{
    if (bytes > sizeof stack->bytes) {
        return false;
    }
    memset(&stack->bytes[sizeof stack->bytes - bytes],
           0, bytes);
    return true;
}

static size_t stack_model_peak(const StackModel *stack)
{
    size_t untouched = 0;
    while (untouched < sizeof stack->bytes
           && stack->bytes[untouched] == 0xA5) {
        untouched++;
    }
    return sizeof stack->bytes - untouched;
}

static bool stack_model_ok(const StackModel *stack)
{
    return stack->low_mark == STACK_MARK
        && stack->high_mark == STACK_MARK;
}

/* [07] Capture owns a job until it stores it in g_pending. */
static bool task_capture(StackModel *stack)
{
    const unsigned now = hal_sim_now();
    if (now > 3U && now != 5U) {
        return true;
    }
    if (!stack_model_touch(stack, 40U)) {
        return false;
    }

    const unsigned serial = ++g_serial;
    Job *job = pool_acquire(&g_pool);
    if (job == NULL) {
        printf("t=%u capture id=%u rejected\n", now, serial);
        return true;
    }

    *job = (Job){serial, hal_sim_sensor(), serial % 2U};
    for (size_t i = 0; i < POOL_CAPACITY; ++i) {
        if (g_pending[i] == NULL) {
            g_pending[i] = job;
            printf("t=%u capture id=%u accepted\n", now, serial);
            return true;
        }
    }

    /* An internal ownership error must not leak the new job. */
    const bool released = pool_release(&g_pool, job);
    if (!released) {
        puts("capture rollback failed");
    }
    return false;
}

/* [08] The finishing task consumes and releases owned jobs. */
static bool task_finish(StackModel *stack)
{
    const unsigned now = hal_sim_now();
    if (now != 4U && now != 6U) {
        return true;
    }
    if (!stack_model_touch(stack, 56U)) {
        return false;
    }

    for (size_t i = 0; i < POOL_CAPACITY; ++i) {
        Job *job = g_pending[i];
        if (job == NULL) {
            continue;
        }

        printf("t=%u finish id=%u sensor=%u route=%u\n",
               now, job->serial, job->sensor, job->route);
        if (!pool_release(&g_pool, job)) {
            return false;
        }
        g_pending[i] = NULL;

        if (now == 4U) {
            break;
        }
    }
    return true;
}

/* [09] Each task carries a separate model, not a real stack. */
typedef bool (*TaskFn)(StackModel *);

typedef struct {
    const char *name;
    TaskFn run;
    StackModel *stack;
} SimTask;

static SimTask g_tasks[] = {
    {"capture", task_capture, &g_capture_stack},
    {"finish", task_finish, &g_finish_stack}
};

/* [10] Sequential cooperative calls with boundary checks. */
static bool sched_sim_run(void)
{
    const size_t count = sizeof g_tasks / sizeof g_tasks[0];

    for (g_tick = 0; g_tick < RUN_TICKS; ++g_tick) {
        for (size_t i = 0; i < count; ++i) {
            SimTask *task = &g_tasks[i];
            if (!stack_model_ok(task->stack)) {
                printf("before task: %s guard damaged\n", task->name);
                return false;
            }
            if (!task->run(task->stack)) {
                printf("task failed: %s\n", task->name);
                return false;
            }
            if (!stack_model_ok(task->stack)) {
                printf("after task: %s guard damaged\n", task->name);
                return false;
            }
        }
    }
    return true;
}

/* [11] Run the scenario and report reproducible observations. */
int main(void)
{
    stack_model_init(&g_capture_stack);
    stack_model_init(&g_finish_stack);

    if (!sched_sim_run()) {
        return 1;
    }

    printf("pool: in_use=%u peak=%u denied=%u\n",
           g_pool.in_use, g_pool.peak, g_pool.denied);
    printf("stack model: capture=%zu/%u finish=%zu/%u bytes\n",
           stack_model_peak(&g_capture_stack),
           (unsigned)MODEL_STACK_BYTES,
           stack_model_peak(&g_finish_stack),
           (unsigned)MODEL_STACK_BYTES);

    printf("guards before injection: %s\n",
           stack_model_ok(&g_capture_stack)
           && stack_model_ok(&g_finish_stack) ? "ok" : "damaged");

    /* [12] Corrupt a field directly; never write out of bounds. */
    g_capture_stack.low_mark = 0;
    const bool detected = !stack_model_ok(&g_capture_stack);
    printf("guard injection: %s\n",
           detected ? "detected" : "missed");

    return detected && g_pool.in_use == 0U ? 0 : 1;
}

줄별 해설

[01] 세 상수는 메모리와 실행 시나리오의 상한을 드러낸다. Job은 세 개의 unsigned 멤버를 가진다. unsigned의 크기를 특정 바이트 수로 가정하지 않았으므로 출력 결과는 같아도 객체의 실제 크기는 대상에 따라 달라질 수 있다. 바이트 단위 예산은 해당 빌드의 sizeof와 맵으로 확인한다.

[02]와 [03] 풀과 보관 포인터는 정적 초기화로 0에서 시작한다. 포인터 배열은 널 포인터로 초기화된다. 센서값은 1000에 현재 시각을 더한 값이다. 난수와 실제 시간을 쓰지 않아 같은 입력을 반복한다. hal_sim_now는 시뮬레이터 시각이며 실제 경과 시간을 측정하지 않는다.

[04] 빈 칸을 찾았을 때만 in_use를 늘리고, 증가한 값으로 최대 점유량을 갱신한다. 반환 전에 구조체 대입으로 멤버를 초기화한다. 구조체 전체 바이트를 통신 형식으로 쓰겠다는 뜻은 아니며, 채움 바이트에 특정 값을 보장할 필요도 없다. 빈 칸이 없으면 denied만 늘리고 NULL을 반환한다.

[05] 입력 포인터를 각 원소 주소와 비교한다. 외부 객체의 유효한 포인터나 NULL이 들어와도 배열 내부 포인터 뺄셈을 하지 않는다. 일치하는 사용 중 원소가 있어야 점유량을 줄인다. 이미 반납된 원소는 false를 반환하므로 단순 중복 반납이 통계를 망가뜨리지 않는다. 초기화되지 않은 포인터 값을 전달해도 된다는 의미는 아니다.

[06] 두 표식 사이의 배열만 사용 흔적 영역이다. touch는 요청량을 먼저 검사하므로 129바이트 요청으로 배열 밖에 쓰지 않는다. peak는 낮은 인덱스에서 처음 바뀐 바이트를 찾아 사용 흔적의 길이를 계산한다. 배열 전체를 다시 채우지 않는 한 이 값은 감소하지 않는다. 모형의 방향은 설명을 위한 선택이며 대상 CPU의 스택 방향을 선언하는 것이 아니다.

[07] 입력은 시각 0, 1, 2, 3, 5에 발생한다. 일련번호는 거절된 요청에도 부여하므로 네 번째 요청의 번호가 빠진 상태로 다섯 번째 기록이 남는다. 획득 실패는 정해 둔 동작이므로 작업 자체는 성공으로 반환한다. 반면 풀에서 획득했는데 보관할 빈 위치가 없다면 내부 불일치이므로 획득한 기록을 반납하고 실행을 중단한다.

[08] 시각 4에는 첫 기록만 처리하고, 시각 6에는 남은 기록을 모두 처리한다. 출력은 기록을 반납하기 전에 한다. 반납 후에는 해당 주소가 재사용될 수 있으므로 내용을 계속 읽지 않는다. g_pending의 빈 위치에 새 기록을 넣는 구조라서 시각 6의 처리 순서는 일련번호 순서가 아니다. 이 배열은 선입선출 자료구조가 아니라 소유 기록을 보관하는 표다.

[09]와 [10] 작업 정보에는 이름, 함수, 모형 스택 주소가 들어간다. 실행기는 각 작업의 호출 전후에 경계를 검사한다. 두 작업이 동시에 실행되지 않으므로 이 코드의 풀에는 잠금이 필요하지 않다. 실제 RTOS로 옮길 때는 작업 생성뿐 아니라 풀을 공유하는 실행 문맥까지 검토해야 한다.

[11]과 [12] 실행이 끝난 뒤 점유량과 흔적을 출력한다. 마지막 경계 손상은 검사 함수가 변화에 반응하는지 확인하는 의도된 입력이다. 이를 발견하고 모든 기록이 반납되었으면 종료값은 0이다. 모형의 사용량 40과 56은 코드가 주입한 값이며 컴파일러가 측정한 함수별 스택 사용량이 아니다.

실행 결과

macOS와 Linux에서 다음과 같이 빌드하고 실행한다. 아래 표준 출력은 코드의 정해진 호출 순서에서 나오는 예상 결과다.

cc -std=c11 -Wall -Wextra -pthread memory_demo.c -o memory_demo
./memory_demo
t=0 capture id=1 accepted
t=1 capture id=2 accepted
t=2 capture id=3 accepted
t=3 capture id=4 rejected
t=4 finish id=1 sensor=1000 route=1
t=5 capture id=5 accepted
t=6 finish id=5 sensor=1005 route=1
t=6 finish id=2 sensor=1001 route=0
t=6 finish id=3 sensor=1002 route=1
pool: in_use=0 peak=3 denied=1
stack model: capture=40/128 finish=56/128 bytes
guards before injection: ok
guard injection: detected

최대 점유량 3은 세 칸을 모두 사용했다는 뜻이다. 거절 횟수 1이 있으므로 이 입력 시나리오를 모두 수용하기에는 용량이 부족했다. 반대로 denied가 0인 시험 한 번만으로 실제 입력 전체에 충분하다고 결론 내릴 수도 없다. 관측 결과는 설계한 입력 상한과 함께 해석한다.

Linux에서 GNU ld 또는 호환 옵션을 지원하는 링커를 사용할 때는 다음 명령으로 맵을 만든다. 성공하면 컴파일 명령 자체는 표준 출력을 만들지 않으며 memory.map 파일이 생긴다.

cc -std=c11 -Wall -Wextra -pthread memory_demo.c -Wl,-Map=memory.map -o memory_demo

macOS의 Apple 링커에서는 다음 형식을 사용한다. 두 명령은 운영체제에 맞게 하나를 선택한다.

cc -std=c11 -Wall -Wextra -pthread memory_demo.c -Wl,-map,memory.map -o memory_demo

맵에서 g_pool, g_capture_stack, g_finish_stack, g_pending을 찾아 소속 영역과 크기를 확인한다. 주소와 섹션 이름, 지역 심벌의 표시 여부는 도구에 따라 달라지므로 고정된 맵 출력을 제시하지 않는다. 심벌별 크기가 없으면 영역 기여 내역이나 해당 도구의 심벌 목록을 함께 확인한다. 단순히 이웃 심벌의 주소 차이를 객체 크기로 간주하면 정렬 간격까지 포함할 수 있다.

실무에서 자주 틀리는 것

아래 코드는 오류와 수정 원리를 비교하는 부분 예제다. 각각 별도 완성 프로그램이 아니며, 앞의 자료형과 함수가 있는 문맥을 전제로 한다.

획득 성공을 가정한다

용량을 계산했더라도 실패 경로는 필요하다. 예상 밖의 입력을 받았을 때 NULL에 접근하면 원래의 부족 현상을 다른 오류로 바꾼다.

/* Wrong */
Job *job = pool_acquire(&g_pool);
job->serial = 10U;
/* Correct */
Job *job = pool_acquire(&g_pool);
if (job == NULL) {
    puts("capture rejected");
} else {
    job->serial = 10U;
    /* This short example finishes using the job here. */
    if (!pool_release(&g_pool, job)) {
        puts("release failed");
    }
}

실제 입력 담당 코드에서는 메시지 출력 대신 정해 둔 유입 처리 정책을 실행한다. 공간이 없을 때 무한 반복으로 획득을 재시도하면, 같은 협동형 실행 흐름에 있는 반납 담당 작업이 실행되지 못할 수 있다.

외부 포인터를 먼저 빼서 슬롯을 구한다

포인터 뺄셈은 같은 배열 안의 원소와 끝 다음 위치라는 조건을 만족해야 한다. 검사할 주소가 그 배열에 속하는지 모르는 상태에서 뺄셈을 먼저 하는 것은 검증 순서가 잘못되었다.

/* Wrong: job might not belong to pool->items. */
size_t index = (size_t)(job - pool->items);
if (index < POOL_CAPACITY) {
    pool->used[index] = false;
}
/* Correct: pool_release checks equality and ownership state. */
if (!pool_release(pool, job)) {
    puts("invalid release");
}

주소 검사가 통과해도 오래된 별칭 문제는 남는다. 반납한 뒤 원래 보관 위치를 NULL로 만드는 규칙과, 소유권을 넘긴 호출자가 더 이상 사용하지 않는 규칙을 함께 적용한다.

실제 배열 밖에 써서 감지를 시험한다

경계 검사 시험을 위해 범위를 벗어나면 시험 프로그램 자체가 정의되지 않은 동작을 한다. 뒤에 원하는 표식이 있을 것이라는 추측도 컴파일러 최적화나 객체 배치 앞에서는 근거가 되지 않는다.

/* Wrong */
g_capture_stack.bytes[MODEL_STACK_BYTES] = 0;
/* Correct: explicit fault injection into an existing field. */
g_capture_stack.low_mark = 0;
if (!stack_model_ok(&g_capture_stack)) {
    puts("guard damage detected");
}

수정 코드는 검사 함수의 반응만 검증한다. 실제 대상에서의 스택 크기 적정성은 호출 경로 검토와 실행 중 여유 관측으로 따로 확인한다.

큰 지역 배열을 두고 맵만 확인한다

함수 안에 둔 대형 자동 배열은 정적 RAM 목록에서 보이지 않을 수 있다. 맵에 큰 객체가 없다는 이유로 스택도 작다고 판단하면 안 된다.

/* Wrong assumption: a small static RAM total makes this harmless. */
unsigned char report_buffer[4096];
report_buffer[0] = 0;
/* Build the report here. */
/* Correct only when one execution context owns this buffer. */
static unsigned char report_buffer[4096];
report_buffer[0] = 0;
/* Build the report here. */

정적 저장소로 바꾸면 호출할 때마다 스택에 확보할 필요가 없고 맵에서 예산을 추적할 수 있다. 그러나 총 메모리 요구량 자체가 줄어드는 것은 아니다. 같은 함수를 여러 실행 문맥에서 호출하면 버퍼를 공유하게 된다. 호출 중첩이 가능하면 문맥별 버퍼를 제공하거나 작은 조각으로 나누어 처리하는 설계가 필요하다.

한눈에 보기

메모리 설계에서 각 관측과 검사가 알려 주는 범위
수단알 수 있는 것알 수 없는 것예제의 판단
고정 크기 풀동시에 저장할 객체 수의 상한상한을 넘는 실제 물체의 처리 정책세 개까지 보관하고 추가 요청은 거절한다.
점유량 통계시험 중 최대 점유와 거절 횟수시험하지 않은 입력의 필요 용량최대 3개, 거절 1회다.
경계 표식검사 시점의 표식 손상모든 범위 밖 접근의 부재직접 주입한 손상을 검출한다.
사용 흔적관측된 영역 사용의 추정값미실행 경로를 포함한 사용 상한모형에서 40바이트와 56바이트다.
링커 맵링크된 정적 객체와 영역 배치실제 호출 스택의 최대 깊이풀과 모형 배열의 저장 비용을 확인한다.

메모리 예산에는 풀 본체뿐 아니라 사용 표시, 보관 포인터, 태스크별 스택, 제어 블록, 라이브러리 저장소가 들어간다. 각 항목의 상한을 설명하고 부족을 관찰할 수 있어야 한다. 다음 장에서는 이렇게 발견한 이상을 장치의 결함 처리와 연결한다.

연습 문제

  1. POOL_CAPACITY만 4로 바꾸면 거절 횟수와 최대 점유량은 어떻게 되는가. 시각 6에 출력되는 일련번호 순서도 구하라.
  2. stack_model_init 직후 24바이트, 72바이트, 16바이트를 차례로 touch했다고 하자. peak의 결과를 구하고, 이후 129바이트 요청이 기존 흔적과 표식에 어떤 영향을 주는지 설명하라.
  3. 어떤 MCU에서 Job 한 개가 12바이트이고 풀 전체가 52바이트, 실제 태스크 스택 두 개가 각각 512바이트, 제어 블록 두 개가 각각 96바이트라고 확인했다. 이 항목들의 RAM 합계를 구하라. 0으로 초기화된다는 이유로 풀의 비용을 빼도 되는지 설명하라.
  4. 포인터 a로 기록을 획득한 뒤 반납했다. 같은 칸을 b가 다시 획득했고, 오래된 a로 pool_release를 호출했다. 현재 구현의 결과와 이를 막기 위한 설계 선택 두 가지를 설명하라.

정답과 해설

  1. 시각 3의 네 번째 요청도 수용하므로 거절 횟수는 0, 최대 점유량은 4다. 시각 4에 첫 칸을 반납하고 시각 5에 일련번호 5를 그 칸에 넣는다. 시각 6에는 배열 인덱스 순서로 5, 2, 3, 4가 출력된다. 입력 순서 보존이 요구된다면 보관 표와 별개로 처리 순서를 관리해야 한다.
  2. 결과는 72바이트다. 큰 사용 흔적 뒤에 작은 영역만 다시 기록해도 앞서 남긴 흔적이 사라지지 않는다. 129바이트 요청은 크기 검사에서 false를 반환하므로 기존 배열과 표식을 바꾸지 않는다. 이 거절은 모형 API의 범위 검사 결과이며, 실제 CPU가 스택 부족을 자동으로 막았다는 뜻이 아니다.
  3. 52 + 512 × 2 + 96 × 2이므로 1,268바이트다. 풀 전체 52바이트에 기록 저장 공간이 포함되어 있으므로 Job 크기를 다시 더하지 않는다. 0 초기화 영역도 실행 중 RAM을 차지한다. 다른 전역 객체, 정렬 간격, 별도 인터럽트 스택 등이 있다면 그 비용은 추가로 계산해야 한다.
  4. a와 b의 주소가 같고 해당 칸이 사용 중이므로 반납이 성공하여 b의 기록을 잘못 해제한다. 첫째, 소유권을 한 곳에만 두고 반납 또는 전달 후 이전 보관 위치를 비워 오래된 별칭을 남기지 않는다. 둘째, 슬롯 번호와 세대 번호를 함께 전달하고 둘 다 일치해야 반납하도록 바꾼다. 세대 번호도 유한하므로 재사용 주기와 번호 순환 시의 처리까지 정해야 한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.