Memory-safe in C

근래 갑작스럽게, 백악관, ‘C’와 ‘C++’ 사용 중단 촉구라는 글이 노출이 되어, 기술에 대해 이렇게 직관적이고 공격적인 단어를 사용해서 방향을 제시했을까라는 의문이 들어 원문을 찾아보았습니다. 역시나 원문은 NSA의 권고가 있다는 내용이고, 취지는 메모리 관리에 유리한 프로그래밍 언어들의 리스트를 취합하는 과정에서 “White House urges developers to avoid C and C++, use ‘memory-safe’ programming languages” 라고 정중하게 말한 것 뿐이었습니다. 프로그램을 작성할 때 메모리 관리에 대해 강조하고자 했다는 의도에는 동감하나, 굳이 이렇게 노골적인 단어로 번역했어야 하나하는 의문은 남습니다.

메모리 관리

어떠한 프로그램을 작성하든 결국 프로그래머가하는 일은 가용한 자원을 가져오고, 사용하고, 반납하는 작업을 반복해서 하는 것입니다. 언젠가부터 최근까지 {Front, Backend} 개발자라는 용어가 통용되고 있는 듯하고, 각 역할에 따라 각기 다른 철학을 가진 프로그래밍 언어를 사용하는 것으로 세분화 되어 있는 듯하여, 여기서 언급하는 “자원”에 대해서 쉽게 동의하지 않을 수도 있습니다. 하지만, 간단한 문자열 하나만 변수에 할당하더라도 우리는 “메모리”라는 자원을 할당하고 사용하는 것입니다. 프로그래밍 언어에 따라서는 반납에 대해 가비지 컬렉터(Garbage Collector)에 그 수행을 위임하여 편리함을 제공하고 있기는 하나, 모든 절차는 이 흐름에서 벗어나지 않게 됩니다.

여러가지 자원 중에서 메모리는 프로그램의 성능과 안전에 관련된 가장 중요한 자원이기 때문에, 별도로 메모리 관리라는 주제로 각 프로그래밍 언어별 많은 서적을 찾아 볼 수 있습니다. 당연한 말이지만, 굳이 구분하자면 메모리를 가져오고(할당), 반납하는 과정은 안정성과 관련이 있으며 성능은 메모리의 사용과 관련이 있다고 할 수 있습니다.

이를 설명하기 위해 임의의 문자열을 생성하는 generate_random_string() 함수와 주어진 문자열에서 숫자와 알파벳이 몇개씩 있는지 찾아보는 count_alnum() 함수를 만들어 보았습니다.

Function generate_random_string(length):
    Define characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    Define random_string = ''

    For i from 1 to length:
        Generate a random index from 0 to the length of characters - 1
        Append the character at the random index in characters to random_string

    Return random_string

Function count_alnum(input_string):
    Define count_digits = 0
    Define count_letters = 0

    For each character in input_string:
        If character is a digit:
            Increment count_digits by 1
        Else if character is an alphabet letter:
            Increment count_letters by 1

    Return count_digits, count_letters

이 두 함수를 사용하는 방법은 아마도 다음과 같이 진행 될 것입니다.

# Step 1: Generate a random string
length_of_random_string = 10  # Arbitrary length
random_string = generate_random_string(length_of_random_string)
print("Generated random string:", random_string)

# Step 2: Count numbers and alphabets in the generated string
digits_count, alphabets_count = count_alnum(random_string)
print("Digits count:", digits_count)
print("Alphabets count:", alphabets_count)

문자열은 generate_random_string() 함수내에서 생성되고, random_string으로 전달 됩니다. 이 random_string은 다시 count_alnum() 함수에 전달되어 {digits,alphabets}_count로 결과값을 반환 하도록 되어있습니다. 명시적으로 나타나지는 않았지만, random_string의 역할은 count_alnum()이 연산한 뒤에는 더이상 필요가 없기에 반환되어야하는 것이 마땅하나, 이 pseudo code가 가비지 컬렉터를 가지고 있어서 반환되었을 것이라고 생각하는 것이 편리할 듯합니다.

다만 여기서 말하고 싶은 것은, 위 문장에서 전달이라고 표현한 부분입니다. 생성한 random_string을 전달할 때, 새로운 메모리 영역으로 복사를 할 것인지, 아니면 random_string이 저장된 장소(포인터)를 알려줄지에 따라 사용하는 메모리의 양뿐만 아니라, 메모리 관리 (할당, 반환) 절차의 필요 여부가 결정되기 때문에 이는 성능과 연결이 될 수 밖에 없습니다.

문제는 C/C++로 작성하는 코드들은 대게 성능을 중요시하다보니, 메모리를 복사하기 보다는, 포인터를 전달하는 구조로 작성하게 됩니다. 전달 횟수가 거듭될수록, 그리고 한번에 처리해아는 할당된 메모리의 수가 많을수록, 관리하기는 어려워지며 이로 인하여 할당과 반환이 적절하기 이루어지지 않아 결국 Memory-Safe 문제를 불러오게 된다고 볼 수 있습니다.

이제 우리가 집중해야할 문제는 무엇인지 간단해졌습니다. 할당된 메모리의 사용 방법은 프로그램마다, 알고리즘마다, 심지어 프로그래머의 개인 선호도에 따라 달라질 수 있습니다. 하지만 명확한 것은 할당한 것은 반드시 반환해야한다는 것이고, 다행이도 이를 위한 해결책은 GLib의 Automatic Cleanup 매크로가 제시를 하고 있습니다. C에서는 말이죠.

예외 처리를 포함한 메모리 할당 및 반환

C에서는 기본적으로 malloc()free()를 통해 메모리를 관리합니다. 그러나 이러한 primitive function은 내부적으로는 유저 영역에서 커널 영역으로 호출을 하는 것이고, 커널이 직접 응답하는 구조로 커널의 사정에 따라 반드시 성공한다는 보장이 없습니다. 실패한 경우에는 커널에서 알려준 에러코드를 확인해야하는 절차가 필요하며, 예외 상황에 대해 적절하게 대응하는 코드는 다음과 같습니다.

#include <stdio.h>
#include <stdlib.h>

void* safe_malloc(size_t size) {
    void *ptr = malloc(size);
    if (ptr == NULL) {
        // Handle the memory allocation failure
        fprintf(stderr, "Error: Memory allocation failed for %zu bytes.\n", size);
        exit(EXIT_FAILURE); // Exit the program or handle the error as per your requirement
    }
    return ptr;
}

void safe_free(void **ptr) {
    if (ptr != NULL && *ptr != NULL) {
        free(*ptr);
        *ptr = NULL; // Prevents double-free errors
    }
}

int main() {
    int *numbers = safe_malloc(10 * sizeof(*numbers)); // Allocate memory for 10 integers
    
    // Use the memory...
    for(int i = 0; i < 10; i++) {
        numbers[i] = i;
    }
    
    // When done, free the memory safely
    safe_free((void**)&numbers);
    
    // After safe_free, numbers is NULL and cannot be accidentally freed again
    if(numbers == NULL) {
        printf("Memory successfully freed and pointer is NULL.\n");
    }

    return 0;
}

기본적으로 메모리 할당에 실패한 것과 NULL에 대해 접근하는 경우를 방지한 것이기 때문에 위의 코드는 malloc()free()를 직접사용하는 것보다는 안전하다고 할수는 있습니다. 하지만 한가지 메모리 사용에 대해서는 그 효율을 장담할 수 없습니다. 반복적인 메모리 할당과 반환은 메모리를 조각으로 만들게 되고(fragmentation), 이는 결국 충분히 사용가능한 메모리가 있음에도 요청한 크기만큼의 연속된 공간이 존재하지 않아, 실제 할당에는 실패하는 상황이 발생하게 됩니다. 메모리 할당이 실패한 경우에는 위 코드에 기술한 내용에 따르면 프로그램을 바로 종료하게 되어있고 이는 메모리 관리에 실패한 것이나 다름없는 결과가 나오게 됩니다.

메모리 할당 및 반환 with GLIB

GNOME 프로젝트에서는 여러가지 경우를 상정하여 고안한 GLib를 제공하고 있습니다. 이는 기존 C에서 제공하는 시스템 호출에 대한 안성성과 효율성을 확보하기 위한 기능과 여러 유용한 자료구조를 제공하고 있으며, 거의 모든 운영체제에 호환이 되도록 작성되어있습니다. 메모리 할당과 반환의 관점에서만 본다면, 다음과 같은 예제로부터 시작할 수 있습니다.

#include <glib.h>
#include <stdio.h>

int main() {
    // Allocate memory for 10 integers using GLib's g_new
    // Syntax: g_new(Type, Number_of_Elements)
    gint *numbers = g_new(gint, 10);

    // Use the allocated memory
    for (int i = 0; i < 10; i++) {
        numbers[i] = i * 10; // Example operation
        printf("numbers[%d] = %d\n", i, numbers[i]);
    }

    // Free the allocated memory using GLib's g_free
    g_free(numbers);

    // After g_free, it's a good practice to set the pointer to NULL to avoid accidental use
    numbers = NULL;

    return 0;
}

여전히 메모리의 할당과 반환은 프로그래머의 몫입니다만, 다음 예제는 좀더 진보된 형태의 자료구조와 매크로를 통해 마치 가비지 콜렉터가 있는 듯한 형태로 사용하는 것을 볼 수 있습니다.

#include <glib.h>
#include <stdio.h>

// Automatic cleanup for GArray
G_DEFINE_AUTO_CLEANUP_FREE_FUNC(GArray, g_array_unref, NULL)

int main() {
    // Allocate a GArray with automatic cleanup
    g_autoptr(GArray) numbers = g_array_new(FALSE, FALSE, sizeof(gint));
    
    // Use the allocated array
    for (int i = 0; i < 10; i++) {
        gint num = i * 10; // Example operation
        g_array_append_val(numbers, num);
        printf("numbers[%d] = %d\n", i, g_array_index(numbers, gint, i));
    }

    // No need to manually free the array; it will be automatically freed
    // when it goes out of scope (i.e., at the end of the main function)
    
    return 0;
}

G_DEFINE_AUTO_CLEANUP_FREE_FUNC 매크로는 메모리 할당에 사용할 자료구조에 대해 반환할 때 사용하는 함수를 정의합니다. 그러면 해당 자원에 대해 참조점이 없을때 자동으로 여기서 선언한 반환용 함수가 호출 되게 됩니다. 메모리 할당시에 C에서는 포인터를 사용하나, 포인터에 대한 참조점 추적을 위해 g_autoptr 매크로를 통해서 포인터를 선언합니다. 그러면 이제 더이상 할당된 메모리에 대한 반환 시점을 고민하지 않아도 됩니다.

실제 프로젝트에서 사용 예

GLib의 자동화된 메모리 관리 매크로들은 가비지 컬렉터가 있는 언어인 양 어느정도 메모리 할당과 반환의 고민에서 해방되게끔 도와줍니다. 다만 이렇게 할당 메모리를 전달할때에 주의해야하는데, g_autoptr을 선언한 포인터는 블럭을 벗어나는 시점에서 반환 함수가 호출되기 때문에 블럭이 종료되는 시점에서 해당 포인터의 소유권을 g_steal_pointer를 통해 넘겨주는(말그대로 훔치는) 작업이 필요합니다.

다음은 이러한 매크로들을 공격적으로 사용했던 황새울 프로젝트의 코드의 일부 입니다.

///
/// https://github.com/hwangsaeul/gaeguli/blob/110822e039888b2fcc8a400e6fd20a88dd956fa2/gaeguli/pipeline.c#L498
///
GaeguliPipeline *
gaeguli_pipeline_new (GVariant * attributes)
{
  g_autoptr (GaeguliPipeline) pipeline = NULL;
  GaeguliVideoSource source;
  const gchar *device = NULL;
  GaeguliVideoResolution resolution;
  guint framerate = 0;
  GVariantDict attr;

  g_return_val_if_fail (g_variant_is_of_type (attributes,
          G_VARIANT_TYPE_VARDICT), NULL);

  g_variant_dict_init (&attr, attributes);
  g_variant_dict_lookup (&attr, "source", "i", &source);
  g_variant_dict_lookup (&attr, "device", "s", &device);
  g_variant_dict_lookup (&attr, "resolution", "i", &resolution);
  g_variant_dict_lookup (&attr, "framerate", "u", &framerate);

  g_debug ("source: [%d / %s]", source, device);
  pipeline = g_object_new (GAEGULI_TYPE_PIPELINE, "source", source, "device",
      device, "resolution", resolution, "framerate", framerate, "attributes",
      g_variant_dict_end (&attr), NULL);

  return g_steal_pointer (&pipeline);
}

...

///
/// https://github.com/hwangsaeul/gaeguli/blob/110822e039888b2fcc8a400e6fd20a88dd956fa2/tests/test-pipeline.c#L74C1-L103C2
/// 
static void
test_gaeguli_pipeline_instance (TestFixture * fixture, gconstpointer unused)
{
  GaeguliTarget *target;
  g_autoptr (GaeguliPipeline) pipeline =
      gaeguli_pipeline_new_full (GAEGULI_VIDEO_SOURCE_VIDEOTESTSRC, NULL,
      GAEGULI_VIDEO_RESOLUTION_640X480, 30);
  g_autoptr (GError) error = NULL;

  g_signal_connect (pipeline, "stream-started", G_CALLBACK (_stream_started_cb),
      fixture);
  g_signal_connect (pipeline, "stream-stopped", G_CALLBACK (_stream_stopped_cb),
      fixture);

  target = gaeguli_pipeline_add_srt_target_full (pipeline,
      GAEGULI_VIDEO_CODEC_H264_X264, GAEGULI_VIDEO_STREAM_TYPE_MPEG_TS, 2048000,
      "srt://127.0.0.1:1111", NULL, &error);

  g_assert_no_error (error);
  g_assert_nonnull (target);
  g_assert_cmpuint (target->id, !=, 0);
  fixture->target = target;

  gaeguli_target_start (target, &error);
  g_assert_no_error (error);

  g_main_loop_run (fixture->loop);

  gaeguli_pipeline_stop (pipeline);
}

위 코드에서 주의깊게 보실 부분은 GaeguliPipeline 포인터입니다. gaeguli_pipeline_new()의 내부에서 할당한 메모리를 외부 함수에서 불러와서 사용할때 포인터의 소유권을 넘긴 부분과 할당한 메모리의 반환을 위해 별다른 노력을 기울이지 않은 것을 볼 수 있습니다.

맺음말

우리는 답을 찾을 것이다. 늘 그랬듯이.

Comments