본문 바로가기

진리는어디에/Python

[Python] C/C++ 연동 'extension'

들어가며

기본적으로 스크립트 프로그래밍 언어인 파이썬은 사용하기 편리한 대신 컴파일 프로그래밍 언어에 비해 성능이 떨어진다. 이 문제에 대한 해결 방법으로 파이썬에서는 C/C++로 작성된 모듈을 파이썬에서 호출 할 수 있도록 하는 'extension'이라는 기능을 제공한다. 이번 시간에는 파이썬에서 C/C++의 함수를 호출하는 방법에 대해 살펴 보겠다.

본 포스트에 대한 다뤄진 프로젝트의 전체 소스 코드는 [여기]에서 확인 할 수 있다.

동적 라이브러리 만들기

파이썬에서 C/C++로 작성된 코드를 모듈로써 사용하기 위해서는 C/C++로 작성된 동적 라이브러리(윈도우의 경우 dll, 리눅스의 경우 so)가 필요하다. 이번 챕터에서는 각 윈도우와 리눅스에서 동적 라이브러를 만드는 방법에 대해 살펴 보도록 하겠다.

이미 동적 라이브러리를 만드는 방법에 대해 알고 있다면 이 챕터를 건너 뛰고 바로 다음 챕터 부터 시작해도 관계 없다.

윈도우 .dll 만들기

이번 포스트의 목적은 파이썬에서 C/C++ extension을 설명하는 것이므로 윈도우에서 dll을 만드는 방법에 대해서는 최소한의 필수적인 내용만 살펴보도록 하겠다. 비주얼 스튜디오를 이용한 dll 작성 관련 자세한 내용은 [여기]를 참고 하도록 하자.

새 프로젝트 만들기

가장 먼저 비주얼 스튜디오를 열어 'DLL(동적 연결 라이브러리)'프로젝트를 생성한다. 

비주얼 스튜디오 DLL 프로젝트 생성 메뉴

프로젝트 설정하기

DLL 프로젝트를 만들면 기본적인 설정은 자동으로 완성되어 있을 것이다. 하지만 개발 중 예상치 못한 문제와 맞딱드릴 수 있으므로 프로젝트 설정에 대해 조금만 알아 보고 넘어가도록 하겠다.

  • 미리 컴파일된 헤더 사용 안함
    윈도우에서만 사용한다면 상관 없지만 리눅스와의 호환성을 위해서 '미리 컴파일된 헤더 사용 안 함'을 선택한다.

미리 컴파일된 헤더 사용 안함

  • 플랫폼 : x86, x64 중 파이썬과 같은 아키텍쳐를 선택한다. 만일 파이썬이 64bit 플랫폼용인데 x86과 같은 32bit 플랫폼 모듈을 만들면 정상적으로 로딩이 되지 않는다.
  • 구성형식 : DLL 프로젝트를 생성 했다면 기본적으로 '동적 라이브러리(.dll)'로 설정되어 있을 것이다. 하지만 애플리케이션(.exe)이나 정적라이브러리(.lib)로 프로젝트를 생성했다고 하더라도 다시 프로젝트를 만들 필요는 없다. 프로젝트 속성 페이지에서 구성 형식만 변경해주면 된다.

플랫폼과 구성 형식 설정

리눅스 .so 만들기

리눅스용 .so 동적 라이브러리를 만들기 위해서는 프로젝트 설정 대신 컴파일 시 아래 처럼 별도의 컴파일 옵션을 지정한다. gcc 컴파일러를 이용한 라이브러리 만들기 관련 내용은 [여기]에 따로 정리 되어 있다.

g++ -shared -fPIC -o <동적 라이브러리 이름>.so ./<컴파일 대상 파일 이름>.cpp

C/C++ 모듈 작성

아래는 파이썬에서 C/C++의 함수를 호출 할 수 있는 여러 상황들을 가정하여 예제로 만들어 보았다. 윈도우의 경우는 dll에서 함수를 내보내기 위해서는 __declspec 키워드를 함수 앞에 지정해주어야 한다. 아래 예제에서는 윈도우와 리눅스에서 동일한 코드를 사용하기 위해 ifdef 전처리를 이용하여 구분했다.

// file : c_module_main.cpp

#ifdef _MSC_VER                      // VC++
#define EXPORT __declspec(dllexport) // 윈도우용 dll export 지정자
#else
#define EXPORT                       // 리눅스의 경우 별다른 지정자가 필요치 않다
#endif

#include <vector>
#include <numeric>

extern "C"
{
    // 1. int 타입 인자를 받고, int 타입을 리턴하는 예
    EXPORT int add(int a, int b)
    {
        return a + b;
    }

    // 2. out 파라메터로 포인터를 사용하는 예
    EXPORT void sub(double a, double b, double* result)
    {
        *result = a - b;
    }

    // 3. 배열 파라메터를 사용하는 예
    EXPORT int accumulate(int* input, int size)
    {
        std::vector<int> v(input, input + size);
        int result = std::accumulate(v.begin(), v.end(), 0u);
        return result;
    }

    struct Rect
    {
        int x;
        int y;
        int width;
        int height;
    };

    // 4. 구조체 파라메터를 사용하는 예
    EXPORT int getarea(Rect* r)
    {
        return r->width * r->height;
    }
}

C/C++ 동적 모듈 컴파일

윈도우의 경우 비주얼 스튜디오를 이용해 간단하게 컴파일 할 수 있지만, 리눅스의 경우 앞에서 말한 것 처럼 컴파일 옵션을 이용해 아래와 같이 컴파일 할 수 있다. .

g++ -shared -fPIC -o libc_module.so ./c_module_main.cpp

동적 라이브러리 이름 앞에 붙은 'lib'는 리눅스 환경에서 라이브러리를 만들 때 의무적으로 붙여줘야 하는 일종의 약속이다. 위 과정을 완료하고 나면 윈도우의 경우는 c_module.dll, 리눅스의 경우 libc_module.so 파일을 결과로 얻을 수 있다.

파이썬에서 C/C++ 동적 모듈 사용

먼저 전체 코드를 한번 가볍게 훑어 보고, 각 예제에 대한 상세한 설명을 하도록 하겠다.

# -*- coding: utf-8 -*-
import ctypes                           # 파이썬 extension을 사용하기 위한 모듈
import platform                         # 파이썬 아키텍처를 확인하기 위한 모듈

if 'Windows' == platform.system() :     # 윈도우 운영체제에서 c 모듈 로드
    path = './x64/Debug/c_module.dll'
    c_module = ctypes.windll.LoadLibrary(path)
elif 'Linux' == platform.system() :     # 리눅스 운영체제에서 c 모듈 로드
    path = "./libc_module.so"
    c_module = ctypes.cdll.LoadLibrary(path)
else :
    raise OSError()

# 1. int 타입 인자를 받고, int 타입을 리턴하는 예
add = c_module.add
add.argtypes = (ctypes.c_int, ctypes.c_int)
add.restype = ctypes.c_int

res = add(1, 2)
print(res)

# 2. out 파라메터로 포인터를 사용하는 예
sub = c_module.sub
sub.argtypes = (ctypes.c_double, ctypes.c_double, ctypes.POINTER(ctypes.c_double))
sub.restype = None
outparam = ctypes.c_double()

sub(3.2, 2.2, outparam)
print(outparam.value)

# 3. 배열 파라메터를 사용하는 예
accumulate = c_module.accumulate
accumulate.argtypes = (ctypes.POINTER(ctypes.c_int), ctypes.c_int)
accumulate.restype = ctypes.c_int

s = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
arr = (ctypes.c_int * len(s))(*s)

res = accumulate(arr, len(s))
print(res)

# 4. 구조체 파라메터를 사용하는 예
class Rect(ctypes.Structure) :
    _fields_ = [
        ('x', ctypes.c_int),
        ('y', ctypes.c_int),
        ('width', ctypes.c_int),
        ('height', ctypes.c_int)
    ]

getarea = c_module.getarea
getarea.argtypes = ctypes.POINTER(Rect),
getarea.restype = ctypes.c_int

r = Rect(0, 0, 5, 10)
res = getarea(r)
print(res)

C/C++ 모듈 로드하기

import ctypes                           # 파이썬 extension을 사용하기 위한 모듈
import platform                         # 파이썬 아키텍처를 확인하기 위한 모듈

if 'Windows' == platform.system() :     # 윈도우 운영체제에서 c 모듈 로드
    path = './x64/Debug/c_module.dll'
    c_module = ctypes.windll.LoadLibrary(path)
elif 'Linux' == platform.system() :     # 리눅스 운영체제에서 c 모듈 로드
    path = "./libc_module.so"
    c_module = ctypes.cdll.LoadLibrary(path)
else :
    raise OSError()
    
print(c_module)

# Windows OUTPUT :
#  <WinDLL 'D:\blog\374\x64\Debug\c_module.dll', handle 7ffcd1590000 at 0x1956be7afa0>
# Linux OUTPUT :
#  <CDLL './libc_module.so', handle 7fffba9957a0 at 7f6a1e9d0710>

가장 먼저 ctypes 모듈을 임포트 해야한다. ctypes 모듈은 파이썬 extension을 사용하는데 핵심적인 역할을 하는 모듈로써 파이썬과 C/C++을 연결하는데 필요한 기능들을 담고 있다.

다음으로 LoadLibrary() 함수를 이용해 C/C++ 동적 라이브러리를 로드한다. 이 함수는 운영 체제에 따라 패키지가 달라 지므로(윈도우의 경우는 ctypes.windll, 리눅스의 경우 ctypes.cdll 모듈을 사용한다), 위 코드에서는 platform.system() 함수를 이용하여 현재 OS 타입을 알아내고 그에 따라 적절한 LoadLibrary() 함수를 호출하여 로드된 모듈을 c_module 변수에 저장했다.

파라메터로 int 타입을 받고, int 타입을 리턴하는 예

# int add(int a, int b)
add = c_module.add
print(add)   # <_FuncPtr object at 0x000002196BFE9860>
add.argtypes = (ctypes.c_int, ctypes.c_int)
add.restype = ctypes.c_int

res = add(1, 2)
print(res)   # 3

먼저 로드 된 c_module 모듈 객체에서 add라는 이름을 가진 함수 객체를 찾아 add라는 변수에 저장했다. add를 통해 C/C++의 함수를 호출하기 위해서는 파라메터 리스트에 대한 정보와 리턴 타입에 대한 정보를 알려줘야 한다.

argtypes 어트리뷰트는 파이썬에서 C/C++ 모듈을 호출 할 때 넘겨지는 파라메터들의 타입 정보를 튜플 형태로 저장한다. 위 예에서 ctypes.c_int는 C/C++의 int타입을 인자로 넘긴다는 의미다. add 함수는 파라메터가 두 개이므로 튜플에 두 개의 ctypes.c_int를 저장하면 된다.

restype 어트리뷰트는 C/C++ 모듈에서 파이썬으로 리턴하는 값에 대한 타입 정보를 저장한다. 위 예에서는 int를 리턴하므로 역시 ctypes.c_int를 지정했다.

※ ctypes에서 사용하는 C호환 데이터형에 대한 정의는 [여기]에서 찾아 볼 수 있다.

out 파라메터로 포인터를 사용하는 예

# void sub(double a, double b, double* result)
sub = c_module.sub
sub.argtypes = (ctypes.c_double, ctypes.c_double, ctypes.POINTER(ctypes.c_double))
sub.restype = None
outparam = ctypes.c_double()

sub(3.2, 2.2, outparam)
print(outparam.value)  # 1.0

이 예제의 핵심은 파라메터로 포인터를 받아서, 그 포인터를 통해 결과를 돌려주는 것이다. 포인터를 표현하기 위해서는 ctypes.POINTER라고 지정한다.

위 예에 아웃 파라메터로 사용되는 세번째 인자의 타입은 double형 포인터이므로 ctypes.POINER(ctypes.c_double)과 같이 지정 할 수 있다. 아웃 파라메터를 통해 돌려 받은 값은 value 어트리뷰트를 이용해 접근 할 수 있다.

배열을 파라메터로 사용하는 예

# int accumulate(int* input, int size)
accumulate = c_module.accumulate
accumulate.argtypes = (ctypes.POINTER(ctypes.c_int), ctypes.c_int)
accumulate.restype = ctypes.c_int

s = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
arr = (ctypes.c_int * len(s))(*s)
print(arr)     # <__main__.c_long_Array_10 object at 0x000002A20F5FDA40>

res = accumulate(arr, len(s))
print(res)

배열을 파라메터로 사용하기 위해서 배열을 포인터 형식으로 배열의 사이즈와 함께 C 모듈에게 넘긴다.

핵심은 파이썬에게 연속적인 메모리 공간, 즉 배열을 넘겨줘야 하는데 파이썬에서는 배열을 지원하지 않는다. 그래서 ctypes.c_int를 이용해 s의 길이 만큼의 배열 객체를 별도로 만들어야 한다. 위에서 arr 객체를 출력해보면 c_long_Array_10이라는 객체가 생성됨을 알 수 있다. 이 객체를 초기화 할 때, s의 요소들을 언패킹하여 arr의 각 요소들에 리스트 s의 요소들의 값을 채우고 있다.

구조체 파라메터를 사용하는 예

# int getarea(Rect* r)
class Rect(ctypes.Structure) :
    _fields_ = [
        ('x', ctypes.c_int),      # int x
        ('y', ctypes.c_int),      # int y
        ('width', ctypes.c_int),  # int width
        ('height', ctypes.c_int)  # int height
    ]

getarea = c_module.getarea
getarea.argtypes = ctypes.POINTER(Rect),
getarea.restype = ctypes.c_int

r = Rect(0, 0, 5, 10)
res = getarea(r)
print(res)

파이썬 클래스의 객체와 C/C++의 구조체의 객체는 엄연히 다른 메모리 구조를 가지고 있다. 파이썬 클래스 객체를 C/C++ extension 함수에 그대로 넘겨주게 되면 C/C++에서는 이해하지 못하는 메모리 덩어리일 뿐이다. 그래서 C/C++에서 이해 할 수 있는 형태로 객체를 재구성해서 넘겨 주어야 한다.

먼저 ctypes.Strucure 클래스를 상속 받아 _fields_ 어트리뷰트를 정의해야 한다. _fields_는 필드(멤버 변수) 이름과 필드 타입을 포함하는 2-튜플의 리스트여야 한다. 필드 타입은  c_int와 같은 ctypes 타입이거나 다른 파생된 ctypes 타입(구조체, 공용체, 배열, 포인터)이어야 한다.

위 예에서는 C/C++ Rect 구조체의 각 멤버 변수들의 이름과 타입에 매칭되는 파이썬 코드를 보여 주고 있다. 이상 위와 같이 정의된 파이썬 Rect 클래스는 일반 파이썬 클래스와 동일하게 사용 할 수 있으며, C/C++ extension 함수의 인자로 넘겨졌을 때 C/C++에서 해석이 가능하다.

마치며

이상 파이썬 코드에서 C/C++로 작성된 공용 모듈(dll, so)의 함수를 호출하는 방법에 대해 알아 보았다. 다음 시간에는 일반 C/C++로 작성된 공용 모듈의 함수를 파이선의 모듈을 호출하는 것과 동일하게 호출 할 수 있는 방법에 대해 살펴 보도록 하겠다.

또한 파이썬에서 C/C++의 함수를 호출하는 extension과 반대로 C/C++에서 파이썬 코드를 호출 할 수 있는 embedding이라는 기술도 있으니 함께 살펴 보길 권한다. embedding에 대한 자세한 사항은 [여기]에서 확인 할 수 있다.

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!