파이썬으로 시스템 프로그래밍을 하다 보면 표준 라이브러리에 없는 저수준 기능이 필요한 순간이 온다. 특정 syscall을 직접 호출해야 하거나, 이미 검증된 C 라이브러리(.so)를 재사용하고 싶거나, 순수 파이썬으로는 느린 연산을 C로 짜둔 코드에 넘기고 싶은 경우다. 이럴 때 별도의 C extension을 만들어 컴파일하고 배포 파이프라인에 끼워 넣는 건 번거로운데, 표준 라이브러리인 ctypes를 쓰면 컴파일 과정 없이 순수 파이썬 코드만으로 기존 공유 라이브러리를 그 자리에서 곧바로 호출할 수 있다. 이 글에서는 ctypes로 libc 함수를 호출하는 기본 패턴부터 구조체(struct) 매핑, 커스텀 공유 라이브러리 연동, errno 에러 처리까지 정리한다.
기본 사용법 — libc 함수 호출
ctypes는 CDLL로 공유 라이브러리를 로드한다. 함수를 호출하기 전에 argtypes(인자 타입)와 restype(반환 타입)을 지정해줘야 파이썬 객체와 C 타입 사이의 변환이 정확하게 이뤄진다.
import ctypes
libc = ctypes.CDLL("libc.so.6")
libc.getpid.restype = ctypes.c_int
print("PID via ctypes:", libc.getpid())
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype = ctypes.c_size_t
print("strlen:", libc.strlen(b"hello ctypes"))$ python3 libc_basic.py
PID via ctypes: 2655
strlen: 12
argtypes를 생략해도 대부분의 경우 동작은 하지만, 인자 타입이 자동 추론에 어긋나면(예: 64비트 정수를 32비트로 잘못 해석하는 등) 조용히 잘못된 값이 들어가거나 세그폴트가 나는 경우가 있어 명시하는 편이 안전하다.
구조체와 포인터 다루기
C 함수가 구조체 포인터를 인자로 받는 경우, ctypes.Structure를 상속해 동일한 필드 레이아웃을 파이썬 클래스로 선언하면 된다. clock_gettime()은 struct timespec 포인터에 결과를 채워 넣는 대표적인 함수다.
import ctypes
class Timespec(ctypes.Structure):
_fields_ = [("tv_sec", ctypes.c_long), ("tv_nsec", ctypes.c_long)]
libc = ctypes.CDLL("libc.so.6", use_errno=True)
ts = Timespec()
CLOCK_MONOTONIC = 1
ret = libc.clock_gettime(CLOCK_MONOTONIC, ctypes.byref(ts))
print("ret:", ret, "sec:", ts.tv_sec, "nsec:", ts.tv_nsec)_fields_에 나열하는 필드 이름과 타입, 순서는 실제 C 구조체 정의와 정확히 일치해야 한다. 순서가 틀리면 컴파일 에러 없이 엉뚱한 값이 매핑되므로, 대상 함수의 man page나 헤더 파일에서 구조체 정의를 반드시 확인해야 한다. 값을 전달할 때는 ctypes.byref()로 포인터를 넘긴다.
커스텀 공유 라이브러리 만들고 연동하기
libc뿐 아니라 직접 만든 C 코드도 같은 방식으로 붙일 수 있다. 먼저 간단한 C 파일을 작성하고 공유 라이브러리로 컴파일한다.
// example.c
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
double average(double *arr, int len) {
double sum = 0.0;
for (int i = 0; i < len; i++) {
sum += arr[i];
}
return sum / len;
}$ gcc -shared -fPIC -o libexample.so example.c
파이썬에서는 상대/절대 경로로 로드한다. 배열은 ctypes.POINTER와 c_double 배열 타입으로 넘긴다.
import ctypes
lib = ctypes.CDLL("./libexample.so")
lib.add.argtypes = [ctypes.c_int, ctypes.c_int]
lib.add.restype = ctypes.c_int
print("add(3, 4) =", lib.add(3, 4))
lib.average.argtypes = [ctypes.POINTER(ctypes.c_double), ctypes.c_int]
lib.average.restype = ctypes.c_double
values = (ctypes.c_double * 5)(1.0, 2.0, 3.0, 4.0, 5.0)
print("average =", lib.average(values, len(values)))$ python3 custom_lib.py
add(3, 4) = 7
average = 3.0
(ctypes.c_double * 5)(...)처럼 타입에 곱셈 연산자를 쓰면 고정 길이 배열 타입을 즉석에서 만들 수 있다. 이렇게 만든 배열은 내부적으로 연속된 메모리 블록이라 C 함수 쪽에서 double *로 그대로 받을 수 있다.
errno로 에러 처리하기
많은 POSIX 함수는 실패 시 -1을 반환하고 실제 에러 원인은 전역 변수 errno에 남긴다. 파이썬 프로세스에서는 스레드마다 값이 섞이지 않도록 CDLL 생성 시 use_errno=True를 지정하고, 호출 직후 ctypes.get_errno()로 값을 읽어야 한다.
import ctypes
import os
libc = ctypes.CDLL("libc.so.6", use_errno=True)
libc.open.argtypes = [ctypes.c_char_p, ctypes.c_int]
libc.open.restype = ctypes.c_int
O_RDONLY = 0
fd = libc.open(b"/no/such/file", O_RDONLY)
if fd == -1:
errno = ctypes.get_errno()
print("open failed, errno =", errno, "->", os.strerror(errno))
else:
print("fd:", fd)$ python3 errno_example.py
open failed, errno = 2 -> No such file or directory
파이썬 내장 errno 모듈에는 errno.ENOENT 같은 상수가 이미 정의돼 있어 매직넘버 대신 이 상수와 비교하는 편이 가독성이 좋다.
주의사항
argtypes/restype을 생략하면 기본적으로c_int로 취급되는데, 실제 함수가 포인터나 64비트 값을 반환하면 값이 잘리거나 크래시로 이어질 수 있다. 특히 반환값을 포인터로 다루는 함수는restype지정이 필수에 가깝다.- ctypes로 만든 배열이나 버퍼는 파이썬 쪽에서 참조를 들고 있는 동안만 유효하다. 함수에 임시 객체를 바로 넘기고 참조를 저장해두지 않으면 가비지 컬렉션으로 메모리가 해제된 뒤 C 쪽에서 해제된 메모리에 접근하는 use-after-free가 발생할 수 있다.
- 라이브러리 이름은 플랫폼마다 다르다.
libc.so.6는 glibc 기반 리눅스 기준이며, musl 기반(Alpine 등)이나 macOS에서는 다른 이름/경로를 써야 한다.ctypes.util.find_library("c")로 플랫폼에 맞는 이름을 찾을 수 있다. - ctypes 호출 중에는 GIL(Global Interpreter Lock)이 해제된다. C 함수가 오래 걸리는 블로킹 작업이라면 다른 파이썬 스레드가 그 사이 계속 실행될 수 있다는 뜻이므로, 공유 상태에 접근하는 코드라면 별도의 동기화가 필요할 수 있다.
마무리
ctypes는 새 C extension을 빌드하지 않고도 기존 공유 라이브러리를 파이썬에서 바로 호출할 수 있게 해주는 표준 도구다. argtypes/restype을 꼼꼼히 맞추고, 구조체 필드 순서를 정확히 매핑하고, errno와 메모리 수명 관리만 신경 쓰면 대부분의 C 인터페이스를 안전하게 파이썬에서 재사용할 수 있다. 더 복잡한 바인딩이 필요하다면 cffi나 pybind11 같은 대안도 있지만, 간단한 함수 몇 개를 붙이는 정도라면 표준 라이브러리인 ctypes만으로 충분한 경우가 많다.