C언어를 스크립트 언어처럼 사용하기
레딧의 r/programming 채널을 보다가 C언어를 스크립트 언어처럼 사용해봤다는 글을 발견했습니다.
C as a Scripting Language
https://lazarusoverlook.com/posts/c-as-scripting-language/
사용해보려고 오래간만에 우분투에 C언어를 위한 컴파일러도 설치했네요.(언제 설치했는지 이미 설치되어있긴 했어요.). 최근 몇년동안 파이썬을 주로 사용하기 때문에 C/C++과는 많이 멀어졌지요.
sudo apt update
sudo apt install build-essential
잠시 테스트 해봤는제 저자가 의도한대로 잘 동작합니다.
저자는 C언어가 컴파일러 언어이기 때문에 스크립트 언어로 사용하는 것이 쉽지 않다 생각했었지만..
C는 컴파일되기 때문에 스크립팅 언어로서 문제가 있습니다. 그래서 스크립트를 변경하려면 전체 엔진을 다시 컴파일하거나 최소한 스크립트 객체를 다시 컴파일한 다음 전체 엔진을 링크해야 합니다. 이 과정은 번거롭고 시간이 오래 걸리지만, 스크립팅 언어로 C를 원활하게 사용하는 방법을 몰라서 포기하고
방법을 찾아냈습니다. 런타임 심볼 링크를 사용하는 것입니다.
이것이 바로 이 깔끔한 트릭이 필요한 이유입니다: 런타임 심볼 링크! 기본적으로 모든 글로벌 심볼(함수 포함)이 노출된 상태로 엔진을 컴파일하고, 스크립트를 수정 없이 모든 메모리 주소에서 로드할 수 있는 위치 독립적 코드가 포함된 라이브러리로 컴파일합니다. 그런 다음 런타임에 해당 라이브러리를 엔진의 노출된 심볼에 연결하면 짜잔! 설명이 꽤 길었지만 아래 예제를 보면 이해가 쉬울 것입니다.
이는 리눅스 커널이 커널 모듈을 로드하는 방식과 매우 유사하며, 제가 주로 영감을 받은 부분입니다.
런타임 심볼 링크(Runtime Symbolic Link)는 프로그램이 실행되는 동안 동적으로 생성되거나 해석되는 심볼릭 링크를 의미합니다. 저자는 동적 라이브러리 로딩 (DLL, .so 파일)을 활용하는 새로운 방식을 발견한 거 같아보입니다. 물론 이미 사용되고 있을 수도 있지요.
일반적으로 C 프로그램은 컴파일 시점에 모든 함수가 연결됩니다. 하지만 런타임 심볼 링크는 프로그램이 실행 중에 외부 라이브러리(.so 파일)를 불러와서 그 안의 함수를 사용하는 기술입니다.
테스트엔 다음 두 개의 코드를 사용합니다.
engine.c는 기본 기능들을 제공하는 엔진입니다. 여기에선 spawn_entity 함수 하나만 준비되어 있습니다.
// engine.c
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h> // 동적 라이브러리 사용을 위한 헤더
// 이 함수를 script.c에서 사용할 예정
void spawn_entity(void) {
printf("Entity spawned!\n");
}
int main(void) {
// 런타임에 script.so 라이브러리를 메모리에 로드
void *handle = dlopen("./script.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "Unable to load script\n");
exit(1);
}
// 로드된 script.so 라이브러리에서 script_update 함수의 주소를 찾음
void (*script_update)(void) = dlsym(handle, "script_update");
if (!script_update) {
fprintf(stderr, "Unable to run script\n");
exit(2);
}
// 함수 포인터를 통해 외부 라이브러리의 함수를 실행
script_update();
// 라이브러리 언로드
if (dlclose(handle) != 0) {
fprintf(stderr, "dlclose error: %s\n", dlerror());
exit(3);
}
}
script.c는 엔진의 기능을 빌려서 특별한 일을 하는 스크립트입니다. 여기에선 엔진의 spawn_entity 함수를 가져와 사용합니다.
// script.c
#include <stdio.h>
// 외부 심볼릭을 참조하기 위해 spawn_entity() 함수를 선언만 하고 정의는 하지 않음
// 실행 시점에 engine.c의 spawn_entity() 함수와 링크됨
void spawn_entity(void);
void script_update(void) {
printf("Script called!\n");
spawn_entity(); // engine.c의 함수를 호출
}
다음처럼 컴파일 후. 실행합니다.
# engine.c를 실행 파일로 만들기
# -rdynamic: 함수들을 다른 라이브러리에서 사용할 수 있게 공개
# -ldl: 동적 라이브러리를 다루는 기능 추가(dlfcn.h)
$ gcc -rdynamic -ldl engine.c -o engine
# script.c를 동적 라이브러리(.so)로 만들기
# -shared: 독립 실행 파일이 아닌 라이브러리로 만들기
# -fPIC: 메모리 어느 위치에서든 실행 가능한 코드 생성
$ gcc -shared -fPIC script.c -o script.so
# script.so를 메모리에 로드하고 script.c의 spawn_entity() 호출.
# 운영체제가 자동으로 engine.c의 spawn_entity() 함수를 찾아서 연결
$ ./engine
Script called!
Entity spawned!
이렇게 한 경우 장점은 다음과 같습니다.
GCC/Clang 최적화를 통해 최소한의 C 런타임으로 네이티브에 가까운 성능 제공
엔진을 다시 컴파일하거나 다시 연결하거나 심지어 다시 시작하지 않고도 스크립트를 수정하고 핫 리로드할 수 있습니다.
엔진의 소스 코드 없이도 스크립트를 작성할 수 있습니다.
다음은 저자가 위 코드 방식을 사용시 문제 있을거라고 언급한 내용입니다.
보안문제
엔진의 모든 함수가 외부에 공개되는 문제가 있는데 해결하려면 다음처럼 하면 된다고 합니다.
// 공개하고 싶은 함수 앞에 추가
__attribute__((visibility("default"))) void spawn_entity();
// 컴파일 시 -fvisibility=hidden 옵션 추가
$ gcc -fvisibility=hidden -rdynamic -ldl engine.c -o engine
메모리 관리
스크립트 언어로 메모리를 수동으로 관리하는 것은 지루하고 오류가 발생하기 쉽지만, 제 엔진은 보임 가비지 컬렉터로 컴파일되므로 스크립트가 원하는 경우 자동으로 메모리를 관리할 수 있으므로 권장할 만합니다.
하지만 스크립트에서 객체 풀 + 자유 목록 또는 아레나(제 엔진은 내부적으로 GNU Obstack을 많이 사용합니다)와 같은 보다 효율적인 메모리 관리 방법을 사용하려는 경우 스크립트에서 자유롭게 사용할 수 있습니다.
안정성
VM 기반 엔진에서는 VM이 널 포인터를 역참조하는 등 충돌을 일으키는 코드를 실행해도 엔진은 계속 실행될 수 있습니다. 하지만 글에서 소개하는 방식의 시스템에서는 스크립트가 충돌하면 엔진 전체가 멈춥니다. 런타임에 VM이 충돌하는 것은 플레이어에게 여전히 용납되지 않기 때문에 몇 개의 세그 오류는 기꺼이 감수할 수 있는 위험입니다.
이러한 단점을 받아들일 수 없는 경우 스크립트 프로세스를 포크하고 IPC를 사용하여 엔진 프로세스와 통신하여 문제를 완화할 수 있지만 성능 오버헤드와 기술적 복잡성이 커집니다. 스레드는 메인 프로세스와 메모리를 공유하기 때문에 스레드 충돌이 발생하면 전체 프로세스가 함께 다운됩니다. 또한 Linux 커널과 같은 가드 페이지를 사용하면 포크보다 오버헤드가 적고 작동할 수 있지만 기술적인 복잡성을 감수해야 합니다.
Comments ()