러스트 외부함수

GoldenDusk·2024년 10월 25일
0

Rust

목록 보기
4/4

러스트 스터디 막주 발표가 나라서 발제를 위한 정리함

러스트 프로그래밍 언어에서 외부 함수 인터페이스(FFI, Foreign Function Interface)를 활용하여러스트 코드에서 C로 작성된 함수와 일부 C++로 작성된 함
수를 호출할 수 있다.

러스트는 시스템 프로그래밍 언어로서 메모리 안전성을 강조하지만, 기존의 C/C++로 작성된 라이브러리를 활용하거나 시스템 호출을 직접 다루어야 하는 경우가 많다. 이때 FFI는 러스트 코드가 외부 함수와 데이터를 교환할 수 있게 해주는 중요한 메커니즘이다.

1. FFI(외부 함수 인터페이스) 개요

외부 함수 인터페이스(FFI)두 가지 이상의 프로그래밍 언어가 서로의 코드를 호출할 수 있도록 하는 메커니즘이다. 이는 주로 시스템 프로그래밍, 운영 체제 API 호출, 네트워크 통신, 하드웨어 제어, 고성능 연산이 필요한 분야에서 유용하다. 러스트는 다른 언어와의 상호작용을 위한 안전하고 효율적인 도구를 제공하며, FFI를 통해 기존 C/C++ 라이브러리와 쉽게 통합할 수 있다.

러스트는 메모리 안전성과 소유권 모델을 통해 안전한 코드 작성을 장려합니다. 그러나 FFI를 사용할 때는 이러한 안전성이 완전히 보장되지 않습니다. 외부 언어와의 상호작용에서 데이터 타입과 메모리 레이아웃의 호환성을 맞추는 것이 매우 중요합니다. 이를 소홀히 하면 메모리 손상, 충돌, 예기치 않은 동작이 발생할 수 있습니다.

2. 러스트와 C 데이터 타입의 호환성

러스트와 C는 서로 다른 메모리 표현 방식과 데이터 정렬 방식을 사용하기 때문에, 두 언어 간의 데이터 타입을 호환시키는 것이 필요합니다. 이를 위해 러스트에서는 libc 크레이트를 사용하여 C의 기본 데이터 타입과 호환되는 타입을 제공합니다. 예를 들어, C의 int 타입은 러스트에서 c_int로, char 타입은 c_char로 매핑됩니다.

extern crate libc;

use libc::{c_int, c_char};

#[repr(C)]
struct MyStruct {
    field1: c_int,
    field2: c_char,
}

여기서 #[repr(C)] 어트리뷰트는 구조체의 메모리 레이아웃을 C와 동일하게 맞추도록 합니다. 이 어트리뷰트를 사용하지 않으면 러스트 컴파일러는 구조체의 메모리 레이아웃을 최적화할 수 있으며, 이는 C 코드와의 호환성 문제를 일으킬 수 있습니다. 예를 들어, 멀티바이트 데이터가 포함된 구조체의 경우 데이터 정렬 방식에 따라 구조체의 크기와 메모리 배치가 달라질 수 있습니다. 따라서 #[repr(C)]는 FFI에서 매우 중요합니다.

이 문서는 러스트와 C 언어 사이의 공통 데이터 표현에 대해 다룹니다. 러스트와 C는 시스템 프로그래밍 언어로서, 기계 수준에서 데이터 표현 방식이 유사한 경우가 많습니다. 이러한 공통점 덕분에 러스트 프로그램이 C 라이브러리와 상호작용할 수 있습니다. 그러나 두 언어 간의 데이터 타입, 메모리 레이아웃, 문자열 처리 방식 등이 다르기 때문에, 상호 운용성을 보장하려면 특정 규칙을 따라야 합니다. 본 문서는 이를 해결하기 위한 기법들을 설명합니다.

1) 러스트와 C의 공통 데이터 표현

러스트와 C는 시스템 프로그래밍 언어로서 기계 수준에서 데이터가 메모리에 어떻게 배치되는지에 대한 유사점을 공유한다. 예를 들어, 러스트의 usize와 C의 size_t는 동일한 데이터 표현을 가진다. 또한, 두 언어 모두 구조체를 지원하며, 구조체의 필드가 메모리에 순서대로 배치된다.

러스트의 데이터 타입은 C와의 호환성을 위해 std::os::raw 모듈을 통해 매핑된다. 이 모듈에는 C와 동일한 표현을 보장하는 여러 타입이 정의되어 있다.

기본적으로 제공되는 C 타입과 대응하는 러스트 타입의 매핑 표

C 타입러스트 std::os::raw 타입
shortc_short
intc_int
longc_long
long longc_longlong
unsigned shortc_ushort
unsigned/unsigned intc_uint
unsigned longc_ulong
unsigned long longc_ulonglong
charc_char
signed charc_schar
unsigned charc_uchar
floatc_float
doublec_double
void *, const void **mut c_void, *const c_void
  • 여기서 c_void를 제외한 모든 타입은 러스트의 기본 타입에 대한 별칭이다. 예를 들어, c_char는 보통 i8 타입에 해당한다.

즉, 이 설명은 std::os::raw 모듈에 정의된 타입들이 실제로는 러스트의 기본 타입에 대한 별칭(alias)이라는 뜻이다. 즉, 이들 타입은 새로운 타입이 아니라, 기존의 러스트 기본 타입에 이름만 다른 별칭을 붙인 것에 불과하다.

예를 들어, std::os::raw::c_char는 보통 i8 타입에 해당한다. 이는 c_char라는 이름을 통해 C 언어의 char 타입을 표현하는 것이며, 러스트에서는 실제로 i8 타입으로 구현된다는 의미이다. 따라서, C 언어와의 호환성을 위해 c_char라고 부르지만, 실제로는 러스트의 i8 타입을 사용하게 된다.

즉, c_char는 러스트에서 다음과 같은 식으로 정의될 수 있다:

type c_char = i8;

다만, c_void의 경우는 예외로, 포인터로만 사용되며 실제 데이터 타입이 없는 타입이다.

이러한 별칭을 사용하는 이유는, 코드 작성 시 C와의 상호작용에서 타입의 의미를 더 명확하게 하기 위해서이다.

  • 러스트의 bool은 C나 C++의 bool과 동일하다.
  • 러스트의 32비트 char 타입은 구현 환경에 따라 폭과 인코딩이 다른 wchar_t와는 다르다. C의 char32_t 타입에 더 가깝지만 유니코드로 인코딩되는 게 보장되지 않는다는 문제가 있다.
  • 러스트의 기본 제공 타입 usizeisize는 C의 size_tptrdiff_t와 동일한 표현을 갖는다.
  • C와 C++ 포인터 그리고 C++ 레퍼런스는 러스트의 원시 포인터 타입 mut T와 const T에 해당한다.
  • 기술적으로 C표준은 구현 환경에서 러스트에 대응하는 타입이 없는 표현을 쓸 수 있게 허용한다. 예를 들어 36비트 정수를 지원할 수도 있고 부호 있는 값을 위한 부호크기 표현을 지원할 수도 있다. 그러나 러스트가 포팅되어 있는 플랫폼에는 모든 공통 C 정수 타입에 대해서 그에 대응하는 러스트 타입이 존재한다.

2) 데이터 타입 호환성

러스트와 C는 기본 타입의 호환성을 제공하지만, 타입별로 다음과 같은 차이점이 존재할 수 있다:

  • 정수 타입: C의 정수 타입은 구현 환경에 따라 크기가 다를 수 있다. 예를 들어, int는 대개 32비트이지만, 16비트나 64비트일 수도 있다. 따라서 c_int와 같은 적절한 타입을 사용해 타입 호환성을 보장해야 한다.
  • 문자 타입: 러스트의 char는 32비트 유니코드 문자로, C의 char와는 다르다. C의 charc_char로 매핑되며, 보통 i8 또는 u8 타입을 사용한다.
  • 포인터 타입: C의 포인터는 러스트의 원시 포인터 타입인 *mut T 또는 *const T로 매핑된다.
  • 구조체: C 구조체와 호환되는 러스트 구조체를 정의하려면 #[repr(C)] 어트리뷰트를 사용해야 한다. 이 어트리뷰트를 사용하면 러스트는 C와 동일한 메모리 배치를 따른다.

3) #[repr(C)] 어트리뷰트 사용

C와 호환되는 구조체를 정의할 때는 #[repr(C)] 어트리뷰트를 사용해야 한다.

이 어트리뷰트는 구조체의 필드를 메모리에 순서대로 배치하게 하여 C 컴파일러의 규칙을 따르게 한다. 이 어트리뷰트를 적용하면 러스트 컴파일러가 C 컴파일러와 동일한 방식으로 데이터를 메모리에 배치하게 된다. 이는 주로 FFI(Foreign Function Interface)를 통해 C와 데이터를 주고받을 때 호환성을 유지하기 위해 필요하다.

예제: C 구조체 매핑

다음은 C 언어로 정의된 구조체와 이를 러스트에서 매핑하는 예제이다. 러스트에서는 기본적으로 구조체의 필드 순서가 컴파일러에 의해 최적화될 수 있다. 그러나 #[repr(C)]를 사용하면 구조체 필드가 선언된 순서대로 메모리에 배치되므로 C와 동일한 메모리 레이아웃을 보장할 수 있다.

  1. C 코드:

    typedef struct {
        char *message;
        int klass;
    } git_error;
  2. 러스트 코드:

    use std::os::raw::{c_char, c_int};
    
    #[repr(C)]
    pub struct git_error {
        pub message: *const c_char,
        pub klass: c_int,
    }

위 예제에서 git_error 구조체는 C에서 동일한 메모리 레이아웃을 가지므로 C 코드와 직접 상호작용할 수 있다.

4) 이넘(enum) 및 유니언(union) 매핑

러스트의 이넘과 C의 이넘도 #[repr(C)] 어트리뷰트를 통해 호환성을 보장할 수 있다. 러스트에서 이넘은 기본적으로 최적화된 표현을 사용하지만, #[repr(C)]를 붙이면 C의 int와 동일한 크기를 가진다.

예제: C 이넘 매핑

  1. C 코드:

    enum git_error_code {
        GIT_OK = 0,
        GIT_ERROR = -1,
        GIT_ENOTFOUND = -3,
        GIT_EEXISTS = -4
    };
  2. 러스트 코드:

    #[repr(C)]
    #[allow(non_camel_case_types)]
    enum git_error_code {
        GIT_OK = 0,
        GIT_ERROR = -1,
        GIT_ENOTFOUND = -3,
        GIT_EEXISTS = -4,
    }

이 코드에서 #[repr(C)]는 러스트 이넘이 C의 int 크기를 따르도록 한다. 이를 통해 C와 러스트 간의 데이터 교환이 가능하다.

참고로 이넘은 이넘은 "열거형"이라고 불리며, 특정한 이름을 가진 여러 상수 값을 나열한 데이터 타입입니다. 이넘을 사용하면 코드의 가독성을 높이고, 값의 의미를 더 명확하게 표현할 수 있다. 이넘은 주로 상태, 옵션, 플래그 등을 나타내는 데 유용하다.

유니언

C 언어에서의 유니언

union Number {
    int integer;
    float floating_point;
};

러스트에서 유니언을 정의하고 사용하는 방법:

#[repr(C)]
union Number {
    integer: i32,
    floating_point: f32,
}

fn main() {
    let num = Number { integer: 42 };

    unsafe {
        println!("Integer value: {}", num.integer);
    }
}

유니언여러 데이터 타입을 한 번에 저장할 수 있는 메모리 공간을 공유하는 방식이다. 유니언을 사용하면 여러 타입 중 하나의 값을 저장할 수 있다. 유니언의 주요 특징은 모든 필드가 동일한 메모리 위치를 공유한다는 점입이다. 따라서, 한 번에 하나의 값만 저장할 수 있으며, 어떤 필드가 유효한지 추적해야 한다.

러스트의 유니언은 unsafe 블록 안에서만 안전하게 접근할 수 있는 반면, 이넘은 안전하게 사용할 수 있다. unsafe 블록을 사용하는 이유는 외부 함수 호출이 메모리 안전성을 보장하지 않기 때문이다.

태그된 유니언 패턴

C에서는 태그된 유니언이라는 패턴을 통해 다양한 타입의 데이터를 표현할 수 있다. 이는 유니언과 함께 해당 유니언이 어떤 데이터를 나타내는지를 가리키는 태그 값을 가진 구조체로 구성된다.

예제(C 코드):

enum tag {
    FLOAT = 0,
    INT = 1,
};

union number {
    float f;
    int i;
};

struct tagged_number {
    enum tag t;
    union number n;
};

러스트

#[repr(C)] // C 언어와 호환되는 메모리 레이아웃을 지정합니다.
enum Tag {
    Float = 0, // 태그 값이 0인 경우, 값이 부동소수점(f32) 형식임을 나타냅니다.
    Int = 1,   // 태그 값이 1인 경우, 값이 정수(i32) 형식임을 나타냅니다.
}

#[repr(C)] // C 언어와 호환되는 메모리 레이아웃을 지정합니다.
union FloatOrInt {
    f: f32, // 유니언의 첫 번째 필드: 32비트 부동소수점 숫자
    i: i32, // 유니언의 두 번째 필드: 32비트 정수
}

#[repr(C)] // C 언어와 호환되는 메모리 레이아웃을 지정합니다.
struct Value {
    tag: Tag,          // 값의 타입을 나타내는 태그 필드 (Float 또는 Int)
    union: FloatOrInt, // 실제 값을 저장하는 유니언 필드
}

fn is_zero(v: Value) -> bool {
    unsafe { // 유니언의 데이터를 안전하지 않게 접근하므로 unsafe 블록을 사용해야 합니다.
        match v {
            // 태그가 Int이고, 유니언의 i 필드가 0일 때 true를 반환
            Value { tag: Tag::Int, union: FloatOrInt { i: 0 } } => true,
            // 태그가 Float이고, 유니언의 f 필드가 0.0일 때 true를 반환
            Value { tag: Tag::Float, union: FloatOrInt { f: num } } => (num == 0.0),
            // 그 외의 경우에는 false를 반환
            _ => false,
        }
    }
}

위 예제에서 is_zero 함수는 태그에 따라 유니언 필드를 확인하여 값이 0인지 검사한다.

5) 문자열 처리

러스트와 C의 문자열 표현 방식은 다르다. C의 문자열은 null 문자로 끝나는 char*이며, 러스트의 String 또는 &str은 길이를 명시적으로 저장하는 UTF-8 인코딩 문자열이다. 이러한 차이 때문에 문자열을 서로 변환해야 할 때 주의가 필요하다.

러스트의 std::ffi 모듈은 이를 위한 CStringCStr 타입을 제공한다. CString은 null 종단 문자열을 소유하는 타입이고, CStr은 차용된 문자열을 나타낸다.

예제: 러스트 문자열을 C 문자열로 변환

use std::ffi::CString; // CString 타입을 사용하기 위해 std::ffi 모듈을 가져옵니다.
use libc::c_char; // C 문자열 타입인 c_char를 사용하기 위해 libc 모듈을 가져옵니다.

// 외부 C 함수 선언
extern "C" {
    fn some_c_function(input: *const c_char); // C 함수 some_c_function을 외부에서 가져옵니다.
                                              // 이 함수는 C 문자열(*const c_char)을 인자로 받습니다.
}

fn main() {
    let rust_string = "Hello, C"; // 러스트 문자열을 정의합니다.
    let c_string = CString::new(rust_string).expect("CString::new failed");
    // CString::new()를 사용해 러스트 문자열을 C에서 사용 가능한 CString으로 변환합니다.
    // 문자열에 '\0'이 포함되어 있으면 변환에 실패하므로 expect()로 에러 처리를 합니다.

    let c_ptr = c_string.as_ptr();
    // C 문자열의 포인터를 가져옵니다. 이 포인터는 C 함수에서 사용할 수 있습니다.

    unsafe {
        some_c_function(c_ptr);
        // C 함수 some_c_function을 호출합니다. 이때 unsafe 블록을 사용해야 합니다.
        // 외부 C 코드를 호출하는 것은 러스트의 안전성 검사 범위를 벗어나기 때문입니다.
    }
}

이 예제에서는 CString::new를 사용하여 러스트 문자열을 null 종단 문자열로 변환한다. 그런 다음, as_ptr로 C 함수에 전달할 수 있는 포인터를 얻는다.

  • CString: 소유권을 가지는 널 종료 문자열. C에서 사용하기 위해 문자열을 변환할 때 사용된다.
  • CStr: 차용된 널 종료 문자열로, C에서 제공한 문자열을 읽기 전용으로 사용할 때 사용된다.

6) 안전한 FFI 설계

FFI 사용 시 러스트의 안전성을 보장하기 위해 여러 기법을 사용해야 한다. 메모리 해제, 에러 처리, 라이프타임 관리 등에서 주의가 필요하다.

  1. 메모리 해제 규칙: C에서 할당된 메모리는 C에서 해제하고, 러스트에서 할당된 메모리는 러스트에서 해제해야 한다.
  2. 예외 처리: 외부 함수 호출 시 반환된 값을 검증하여 예기치 않은 오류를 방지해야 한다.
  3. 라이프타임 관리: 러스트의 소유권 규칙을 준수하고, 외부 함수에서 사용되는 데이터의 유효성을 보장해야 한다.

예제: 메모리 해제

extern crate libc;
use libc::{c_char, malloc, free};
use std::ptr;

extern "C" {
    fn some_c_function(input: *mut c_char);
}

fn main() {
    unsafe {
        let ptr: *mut c_char = malloc(100) as *mut c_char;
        if ptr.is_null() {
            panic!("Failed to allocate memory");
        }

        some_c_function(ptr);
        free(ptr as *mut libc::c_void);
    }
}

위 예제는 C 스타일의 메모리 할당과 해제를 올바르게 관리하는 방법을 보여준다.

3. 외부 함수 선언 및 호출

extern 블록을 사용하여 러스트 코드에서 외부 C 함수와 전역 변수를 선언하고 사용하는 방법을 설명한다.

1) extern 블록을 사용한 외부 함수 선언

extern 블록은 러스트 실행 파일에 링크된 외부 라이브러리에 정의된 함수나 변수를 선언할 때 사용한다.

예를 들어, 표준 C 라이브러리에 정의된 strlen 함수를 러스트 코드에서 사용하려면

use std::os::raw::c_char;

extern {
    fn strlen(s: *const c_char) -> usize;
}
  • extern 블록 안에서 strlen 함수의 시그니처를 선언하여 러스트가 이 함수의 이름과 타입을 알 수 있게 합니다. 함수의 정의는 이후에 링크된다.
  • strlen은 C에서 문자열의 길이를 반환하는 함수로, *const c_char 타입의 문자열 포인터를 인자로 받는다.

2) unsafe 블록과 외부 함수 호출

외부 함수는 unsafe로 선언된 함수로 간주된다. 따라서 호출할 때도 unsafe 블록을 사용해야 합니다. 이는 외부 함수 호출 시 메모리 안전성을 보장할 수 없기 때문이다.

예를 들어, strlen을 호출하는 코드

use std::ffi::CString;

let rust_str = "I'll be back";
let null_terminated = CString::new(rust_str).unwrap(); // CString으로 변환하여 널 종료 문자열 생성
unsafe {
    assert_eq!(strlen(null_terminated.as_ptr()), 12); // unsafe 블록 내에서 strlen 호출
}
  • CString::new 함수는 주어진 문자열이 널 문자를 포함하고 있지 않으면, 끝에 널 바이트를 추가하여 CString을 만든다. CString은 C 스타일 문자열을 표현하며, 이를 통해 외부 C 함수와 안전하게 상호 작용할 수 있다.
  • as_ptr 메서드를 사용하여 C 문자열의 포인터를 얻는다.

3) CString::new의 동작과 비용

  • CString::newInto<Vec<u8>>를 구현한 타입을 인수로 받는다.
    • &str를 전달하면 힙에 새로 할당된 문자열의 복사본을 만들어야 하므로 할당과 복사가 발생한다.
    • String을 값으로 넘기면 그 문자열을 소비하여 새로운 할당이 필요 없다.
  • CStringCStr로 역참조할 수 있으며, CStras_ptr 메서드를 사용해 C 스타일 문자열 포인터를 얻을 수 있다.

4) 전역 변수 선언

extern 블록에서는 외부 전역 변수도 선언할 수 있다. 예를 들어, POSIX 시스템의 environ 변수는 환경 변수 목록을 가리키는 포인터 배열이다.

use std::os::raw::c_char;

extern {
    static environ: *mut *mut c_char;
}
  • 이 선언은 C의 extern char **environ과 대응된다.

5) 전역 변수 사용 예제

전역 변수 environ의 첫 번째 환경 변수를 출력하는 예제

use std::ffi::CStr;

unsafe {
    if !environ.is_null() && !(*environ).is_null() {
        let var = CStr::from_ptr(*environ); // C 문자열을 CStr로 변환
        println!("first environment variable: {}", var.to_string_lossy());
    }
}
  • environ이 null이 아닌지 확인한 후, CStr::from_ptr을 사용해 C 문자열을 러스트의 CStr로 변환한다.
  • to_string_lossy 메서드는 CStr을 UTF-8 문자열로 변환합니다. 만약 문자열이 유효한 UTF-8이면 그대로 반환하고, 그렇지 않으면 유니코드 대체 문자로 변환한다.

이러한 방법으로 extern 블록을 통해 C 함수와 전역 변수를 선언하고, 러스트에서 이를 호출하여 사용할 수 있다. 이를 통해 러스트와 C 간의 상호 운용성을 확보할 수 있다.

4. 외부 라이브러리 링크 설정

1) 러스트에서 외부 라이브러리 함수 사용하기

러스트 프로그램에서 특정 C 라이브러리의 함수를 사용하려면, 해당 라이브러리를 러스트 코드에 링크해야 한다.

이를 위해 #[link] 어트리뷰트를 사용하고, extern 블록을 이용해 외부 함수를 선언할 수 있다.

예제: libgit2 라이브러리 사용

  1. 코드 예시

    use std::os::raw::c_int;
    
    #[link(name = "git2")] // "git2" 라이브러리를 링크
    extern {
        pub fn git_libgit2_init() -> c_int; // 외부 함수 선언
        pub fn git_libgit2_shutdown() -> c_int; // 외부 함수 선언
    }
    
    fn main() {
        unsafe {
            git_libgit2_init();    // 외부 함수 호출
            git_libgit2_shutdown(); // 외부 함수 호출
        }
    }
  2. 설명

    • #[link(name = "git2")] 어트리뷰트는 러스트가 git2 라이브러리를 최종 실행 파일에 링크하도록 한다.
    • extern 블록 내부에서 외부 C 라이브러리 함수들을 선언한다.
    • 함수 호출은 메모리 안전성 보장이 어렵기 때문에 unsafe 블록에서 호출한다.

2) 외부 라이브러리 빌드 및 설정

  • libgit2와 같은 C 라이브러리를 사용하려면, 먼저 라이브러리를 시스템에 설치하거나 직접 빌드해야 한다.
  • 빌드 도구로는 CMake, 파이썬 등이 필요하다.
  • 리눅스, 맥OS, 윈도우에서 라이브러리를 빌드하는 과정은 서로 비슷하지만 운영체제에 따라 몇 가지 차이점이 있다.

러스트 프로그램에서 libgit2 사용하기

  1. 러스트 프로그램 생성

    $ cargo new --bin git-toy
    $ cd git-toy
    • cargo 명령어를 사용해 러스트 프로젝트를 생성
  2. 프로그램 빌드 및 실행 시 문제

    • 만약 cargo run을 통해 빌드하면, libgit2 라이브러리를 찾지 못할 경우 아래와 같은 오류가 발생한다.

      error: linking with 'cc' failed: exit status: 1
      = note: /usr/bin/ld: error: cannot find -lgit2
  3. 빌드 스크립트 작성 (build.rs)

    • Cargo.toml 파일이 있는 디렉터리에 build.rs 파일을 생성하고 다음 내용을 추가한다.

      fn main() {
          println!("cargo:rustc-link-search=native=/path/to/libgit2/build");
      }
    • 이 스크립트는 cargo가 빌드할 때 실행되며, 라이브러리의 검색 경로를 추가한다.

  4. 공유 라이브러리 설정

    • 리눅스, 맥OS, 윈도우에서 각각의 환경 변수 설정을 통해 실행 시 라이브러리를 찾을 수 있도록 한다.

      # 리눅스
      $ export LD_LIBRARY_PATH=/path/to/libgit2/build:$LD_LIBRARY_PATH
      
      # 맥OS
      $ export DYLD_LIBRARY_PATH=/path/to/libgit2/build:$DYLD_LIBRARY_PATH
      
      # 윈도우
      > set PATH=C:\path\to\libgit2\build\Debug;%PATH%

C 라이브러리 정적 링크

  • 라이브러리를 정적으로 링크하는 방법도 있다. 이렇게 하면 라이브러리 코드가 실행 파일에 포함되어 별도의 라이브러리 파일이 필요하지 않는다.

-sys 크레이트 관례

  • 러스트 생태계에서는 C 라이브러리와의 FFI를 제공하는 크레이트에 -sys 접미사를 붙이는 것이 일반적이다. 예를 들어, git2 라이브러리에 대한 러스트 바인딩은 git2-sys라는 이름을 사용할 수 있다.

5. libgit2 라이브러리를 사용하여 러스트에서 안전한 인터페이스를 구축하는 방법

libgit2의 사용법을 제대로 이해하기 위해서는 다음의 두 가지 질문에 답해야 한다.

  • 러스트에서 libgit2 함수를 쓰려면 무엇이 필요할까?
  • 여기에 안전한 러스트 인터페이스를 구축하려면 어떻게 해야 할까?

1) 러스트에서 libgit2 함수를 사용하기 위해 필요한 것

libgit2와 같은 외부 C 라이브러리를 러스트에서 사용하려면 몇 가지 준비 단계와 규칙을 따라야 한다.

  1. FFI 설정:

    • 외부 C 함수를 러스트에서 호출하려면 FFI를 사용해야 한다. FFI는 외부 언어(C, C++, 등)로 작성된 함수나 변수를 러스트에서 사용하게 해주는 인터페이스이다.
    • extern 블록과 #[link] 어트리뷰트를 사용해 라이브러리 함수를 선언하고, 실행 파일을 빌드할 때 라이브러리를 링크해야 한다.
  2. 라이브러리 링크 설정:

    • #[link(name = "libgit2")]와 같은 어트리뷰트를 사용해 러스트가 외부 라이브러리를 연결하도록 지시할 수 있다.
    • build.rs 파일을 작성하여 빌드 시점에 컴파일 플래그를 설정하거나 라이브러리 검색 경로를 추가할 수 있다.
  3. 메모리 안전성 확보:

    • 외부 함수 호출을 위해서는 메모리 안전성을 보장해야 한다. 러스트의 소유권 및 메모리 안전성 규칙을 따르기 위해, 호출은 unsafe 블록 내에서 수행되며, 개발자가 메모리 관리를 명시적으로 처리해야 한다.
  4. 데이터 정렬:

    • #[repr(C)] 어트리뷰트를 사용하여 구조체의 메모리 정렬을 C와 동일하게 설정해야 합니다. 이는 데이터 정렬 문제를 방지하기 위함이다.

코드 예시: FFI 설정과 외부 함수 호출

extern crate libc;
use libc::{c_int, c_char};

#[link(name = "git2")]
extern "C" {
    fn git_libgit2_init() -> c_int;
    fn git_libgit2_shutdown() -> c_int;
}

fn main() {
    unsafe {
        // libgit2 초기화 함수 호출
        git_libgit2_init();
        println!("libgit2 initialized");

        // libgit2 종료 함수 호출
        git_libgit2_shutdown();
        println!("libgit2 shutdown");
    }
}

위 코드는 libgit2의 초기화 및 종료 함수를 호출하는 기본적인 FFI 사용 예시이다. unsafe 블록 내에서 외부 함수를 호출하며, FFI를 통해 외부 라이브러리와 상호작용할 수 있다.

2) 안전한 러스트 인터페이스 구축 방법

러스트의 FFI는 기본적으로 안전하지 않으므로, 외부 라이브러리를 사용할 때 안전한 인터페이스를 구축하는 것이 중요하다. 이를 위해 몇 가지 권장 사항과 기법을 따를 수 있다.

1. 메모리 해제 규칙 준수

  • C에서 할당된 메모리는 C에서 해제하고, 러스트에서 할당된 메모리는 러스트에서 해제해야 한다. 이는 메모리 누수나 이중 해제(double-free) 문제를 방지한다.
extern crate libc;
use libc::{malloc, free, c_char};
use std::ptr;

extern "C" {
    fn some_c_function(input: *mut c_char);
}

fn main() {
    unsafe {
        // C 스타일 메모리 할당
        let ptr: *mut c_char = malloc(100) as *mut c_char;
        if ptr.is_null() {
            panic!("Failed to allocate memory");
        }

        // 외부 함수 호출
        some_c_function(ptr);

        // 메모리 해제
        free(ptr as *mut libc::c_void);
    }
}

위의 예제에서는 malloc을 통해 메모리를 할당하고, 외부 C 함수 호출 후 free를 사용하여 메모리를 해제한다. 메모리 해제 규칙을 준수하는 것이 중요하다.

2. 라이프타임 및 메모리 관리

  • 러스트의 라이프타임 규칙을 FFI와의 상호작용에 명확히 적용하여, 유효하지 않은 메모리를 참조하지 않도록 해야 한다.
  • 객체 수명 관리와 관련해서는 러스트의 수명 매개변수('a)와 PhantomData를 활용할 수 있다.

예시: 안전한 구조체 정의와 메모리 관리

use std::marker::PhantomData;

#[repr(C)]
pub struct Repository {
    raw: *mut libc::c_void, // libgit2의 git_repository 구조체 포인터
    _marker: PhantomData<()>,
}

impl Drop for Repository {
    fn drop(&mut self) {
        unsafe {
            // libgit2의 메모리 해제 함수 호출
            raw::git_repository_free(self.raw);
        }
    }
}

Drop 트레이트를 구현하여 객체가 스코프에서 벗어날 때 메모리를 해제하도록 한다.

3. 예외 처리 및 오류 관리

  • FFI를 사용할 때는 외부 함수의 반환 값을 항상 검사하고, 오류가 발생했을 때 적절히 처리해야 한다. 이는 FFI의 안전성을 보장하는 중요한 부분이다.

에러 타입 정의

러스트의 에러 처리 방식을 활용하여, libgit2와 같은 외부 라이브러리에서 발생하는 에러를 러스트다운 방식으로 처리할 수 있다.

#[derive(Debug)]
pub struct Error {
    code: i32,
    message: String,
    class: i32,
}

pub type Result<T> = std::result::Result<T, Error>;

fn check(code: i32) -> Result<i32> {
    if code >= 0 {
        Ok(code)
    } else {
        Err(Error {
            code,
            message: "An error occurred".to_string(),
            class: 0,
        })
    }
}

4. 멀티스레딩과 동시성 관리

  • 외부 함수가 멀티스레드 환경에서 안전하게 동작하는지 확인해야 한다. 이는 FFI를 통해 호출되는 외부 함수의 스레드 안전성을 보장하기 위해 필수적이다.

3) 고급 기법: bindgen을 사용한 자동 바인딩 생성

bindgen 도구를 사용하면 C 헤더 파일을 분석하여 러스트 바인딩을 자동으로 생성할 수 있다. 이는 대규모 C 라이브러리와 연동 작업을 간소화한다.

bindgen을 사용한 예제

bindgen path/to/header.h -o bindings.rs

위 명령을 사용하여 C 헤더 파일에서 러스트 바인딩 파일(bindings.rs)을 생성할 수 있다. 이를 통해 C 라이브러리의 구조체, 함수 등을 러스트에서 쉽게 사용할 수 있다.


4) 성능 최적화를 위한 고려 사항

FFI를 사용할 때 성능을 최적화하기 위해 다음과 같은 전략을 사용할 수 있다.

  1. 호출 빈도 줄이기:

    • 외부 함수 호출은 오버헤드가 크기 때문에, 반복적인 호출을 줄이고 가능한 한 한 번의 호출로 여러 작업을 수행하도록 설계해야 한다.
  2. 메모리 정렬 최적화:

    • 데이터 정렬을 명시적으로 맞추어 성능 저하를 방지해야 한다. #[repr(C)]를 사용하여 메모리 정렬을 일관되게 설정한다.
  3. 배치된 데이터 처리:

    • 개별 데이터 대신 배치(batch) 단위로 데이터를 처리하여 호출 오버헤드를 줄이고 성능을 향상시킬 수 있다.

러스트에서 libgit2와 같은 외부 라이브러리를 FFI를 통해 사용하려면, 메모리 관리, 데이터 정렬, 라이프타임 규칙 등을 고려해야 한다. FFI의 비안전성을 다루기 위해 unsafe 블록 내에서 외부 함수를 호출하며, 데이터 정렬 및 메모리 해제 규칙을 준수해야 한다.

러스트의 타입 시스템과 메모리 안전성을 활용하여 FFI와의 상호작용에서 발생할 수 있는 잠재적인 문제를 예방할 수 있다. 이를 통해 안전하고 효율적인 외부 라이브러리 사용이 가능해진다.

결론

러스트는 단순한 언어가 아니다. 러스트의 목표는 매우 다른 두 세계에 걸쳐 있다. 러스트는 안전하게 설계된 데다 클로저와 이터레이터 같은 편의성까지 갖춘 모던 프로그래밍 언어로, 실행 중인 머신이 가진 날것 그대로의 능력을 최소한의 실행 시점 오버헤드로 제어하게 하는 걸 목표로 한다.

언어의 윤곽은 이러한 목표에 의해서 결정된다. 러스트는 대부분의 빈틈을 안전한 코드로 메운다.차용 검사기와 무비용 추상화는 미정의 동작의 위험을 무릅쓰지 않고도 최대한 하드웨어에 가까이 다가설 수 있게 해준다.

이걸로 부족하거나 기존 C 코드를 활용하고 싶을 때를 위해서 안전하지 않은 코드와 외부 함수 인터페이스가 마련되어 있다.

그러나 다시 말하지만 러스트는 안전하지 않은 기능을 손에 쥐여주고는 그저 행운을 빈다는 말뿐인 그런 언어가 아니다.

목표는 늘 안전하지 않은 기능으로 안전한 API를 구축하는 것이다.

libgit2를 가지고 한 일이 바로 그것이다. 러스트 팀이 만든 Box, Vec, 기타 컬렉션, 채널 등도 같은 방식이 적용됐다. 표준 라이브러리를 가득 메운 안전한 추상화의 이면에는 안전하지 않은 코드로 된 구현이 자리 잡고 있다. 러스트와 같은 야망을 품은 언어라면 아무래도 단순한 도구가 되는 데서 그칠 순 없을 것이다.

그러나 러스트는 안전하고 빠를 뿐 아니라 동시적이고 효율적이다. 러스트로 하드웨어의 성능을 최대한 활용하는 크고, 빠르고, 안전하고, 견고한 시스템을 구축하자. 러스트로 더 나은 소프트웨어를 만들자

profile
내 지식을 기록하여, 다른 사람들과 공유하여 함께 발전하는 사람이 되고 싶다. gitbook에도 정리중 ~

0개의 댓글