2025, Nov 15 15:02

Вызов методов C++ DLL из Python через ctypes и C++‑шим

Пошаговое руководство: как вызывать методы C++ DLL из Python через ctypes, работая с перемангленными именами, std::wstring и GetProcAddress, добавив C++‑шим.

Вызывать функции из C++ DLL в Python быстро становится непросто, когда вы не управляете библиотекой и у вас нет её заголовков. Особенно деликатной ситуация становится, как только в игру вступают методы классов и специфичные для C++ типы вроде std::wstring. Ниже — практическое руководство, как связать такую DLL с Python с помощью ctypes и небольшого C++‑шима, опираясь только на экспортируемые перемангленные имена.

С чем придётся иметь дело

DLL экспортирует перемангленные символы примерно такого вида:

2980  BA3 005A3060 ?getFoo@FooLib@@YAAEAVFoo@1@XZ
2638  A4D 005A3020 ?getApplicationData@Foo@FooLib@@QEAAAEAVApplicationData@2@XZ
2639  A4E 005A3030 ?getApplicationData@Foo@FooLib@@QEBAAEBVApplicationData@2@XZ
2738  AB1 000F8A30 ?getDataRootPath@ApplicationData@FooLib@@QEBA?AV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@XZ

Расшифровав их, получаем такие сигнатуры:

Foo __cdecl FooLib::getFoo()
ApplicationData& __thiscall FooLib::Foo::getApplicationData()
const ApplicationData& __thiscall FooLib::Foo::getApplicationData() const
std::wstring __thiscall FooLib::ApplicationData::getDataRootPath() const

Из Python легко найти и вызвать первые две через ctypes, трактуя всё как непрозрачные указатели:

from ctypes import *
lib = WinDLL(r"c:\\path\\to\\Foo.dll")

sym_getFoo = getattr(lib, "?getFoo@FooLib@@YAAEAVFoo@1@XZ")
sym_getFoo.argtypes = []
sym_getFoo.restype = c_void_p

sym_getApp = getattr(lib, "?getApplicationData@Foo@FooLib@@QEAAAEAVApplicationData@2@XZ")
sym_getApp.argtypes = [c_void_p]
sym_getApp.restype = c_void_p

sym_getApp(sym_getFoo())  # стабильное значение указателя возвращается многократно

Однако экспорт, возвращающий std::wstring, таким способом не вызывается — это приводит к сбоям или нарушениям доступа:

sym_getPath = getattr(lib, "?getDataRootPath@ApplicationData@FooLib@@QEBA?AV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@XZ")

Здесь мешают две вещи. Во‑первых, ctypes не умеет маршалить типы, доступные только в C++, такие как std::wstring. Во‑вторых, методы усложняют форму вызова, а __thiscall ctypes не поддерживает. Нужен небольшой C++‑шим, который на одной стороне говорит на ABI DLL, а на другой — на обычном C‑ABI.

Ключевая идея: как вызвать метод по адресу

В MSVC x86‑64 вызов метода класса эквивалентен вызову свободной функции с неявным первым параметром — указателем this. Концептуально это равносильно следующему:

struct S {
  int f(int arg);
};
int f(S *self, int arg);

Значит, получив адрес метода и приведя его к указателю на функцию, принимающую объект первым аргументом, можно вызывать его как обычную функцию. На x64 это избавляет от необходимости отдельно обрабатывать __thiscall.

Соберите шим, который превращает C++‑результат в C‑строку

Самый чистый путь — загрузить перемангленные функции через GetProcAddress, вызвать их, передав скрытый указатель this первым параметром, получить std::wstring на стороне C++, затем преобразовать его в размещённый в куче char*, который Python воспримет как C‑строку. Отдельная функция‑освободитель проясняет владение.

namespace {
  class AppCtx {
  public:
    std::wstring getDataRootPath() const;
  };

  class FooHost {
  public:
    AppCtx& getApplicationData();
    const AppCtx& getApplicationData() const;
  };

  FooHost& fetchFoo();
}

char *emitFlatString() {
  using FnGetFoo = FooHost&();
  auto *pGetFoo = (FnGetFoo*)GetProcAddress("?getFoo@FooLib@@YAAEAVFoo@1@XZ");

  using FnGetApp = AppCtx&(FooHost&);
  auto *pGetApp = (FnGetApp*)GetProcAddress("?getApplicationData@Foo@FooLib@@QEAAAEAVApplicationData@2@XZ");

  using FnGetRoot = std::wstring(AppCtx&);
  auto *pGetRoot = (FnGetRoot*)GetProcAddress("?getDataRootPath@ApplicationData@FooLib@@QEBA?AV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@XZ");

  std::wstring ws = pGetRoot(pGetApp(pGetFoo()));

  char *buf = (char*)std::malloc(ws.size() + 1);
  for (size_t i = 0; i <= ws.size(); ++i) {
    buf[i] = ws[i];  // предполагается отсутствие не-ASCII символов
  }
  return buf;
}

void releaseFlatString(char *p) {
  std::free(p);
}

В этом шиме фиктивные C++‑классы нужны лишь для локального выражения типов; совместимость по макетам данных не предполагается. Более того, можно вовсе убрать эти определения и приводить всё к void*, сохраняя корректную семантику вызова.

Если предпочитаете полностью непрозрачные указатели

Ту же идею можно выразить, используя только void* там, где фигурируют ссылки и указатели. Шим получится ещё компактнее, без вводимых названий классов.

using FGetFoo = void*();
auto *fnGetFoo = (FGetFoo*)GetProcAddress("?getFoo@FooLib@@YAAEAVFoo@1@XZ");

using FGetApp = void*(void*);
auto *fnGetApp = (FGetApp*)GetProcAddress("?getApplicationData@Foo@FooLib@@QEAAAEAVApplicationData@2@XZ");

using FGetRoot = std::wstring(void*);
auto *fnGetRoot = (FGetRoot*)GetProcAddress("?getDataRootPath@ApplicationData@FooLib@@QEBA?AV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@XZ");

Дальше вызываете fnGetRoot(fnGetApp(fnGetFoo())) тем же способом, копируете символы в заново выделенный буфер C и возвращаете его в Python вместе с парной функцией освобождения.

Альтернативный вариант: передавать указатели на функции из Python в шим

Поскольку ctypes умеет получать указатель на функцию, можно передать этот адрес в шим и вызвать его в C++, приведя к нужной сигнатуре.

char *emitFlatString2(
    void *app_from_python,
    void *getRoot_from_python
) {
  using RootCall = std::wstring(void*);
  auto *pRoot = (RootCall*)getRoot_from_python;

  std::wstring ws = pRoot(app_from_python);
  char *out = (char*)std::malloc(ws.size() + 1);
  for (size_t i = 0; i <= ws.size(); ++i) {
    out[i] = ws[i];  // предполагается, что содержимое только ASCII
  }
  return out;
}

void releaseFlatString2(char *p) {
  std::free(p);
}

На стороне Python вы получите и указатель на объект, и указатель на функцию через getattr по перемангленному имени, после чего передадите эти непрозрачные значения в шим.

Почему это работает?

Ключ — относиться к вызову метода как к вызову свободной функции, принимающей объект первым параметром. Именно так ABI MSVC на x86‑64 понижает такие вызовы, поэтому, зная адрес символа, можно вызвать его, приведя к типу указателя на функцию, которая явно принимает скрытый указатель this. Это обходит проблемы с __thiscall и оставляет ctypes в комфортной зоне — вы не просите его маршалить C++‑типы.

Вторая составляющая — изоляция типов, существующих только в C++. std::wstring через ctypes не проходит. Вместо этого потребляйте его внутри шима и возвращайте обычный char* в Python плюс парную функцию освобождения. Тонкая прослойка экспорта extern "C" вокруг API шима на базе char* — это нормально; не экспортируйте функции со std::wstring в C‑линковке. Перенос преобразования внутрь обычной C++‑функции избавляет от предупреждений о C‑линковке с C++‑типами и от падений при прямом экспорте таких функций.

Что важно помнить

Такие детали, как способ загрузки DLL и получение GetProcAddress в вашем процессе, — отдельные вопросы, которые здесь не разбираются. Кроме того, в примерах предполагается только ASCII при «уплощении» std::wstring в char*. Если возвращаемый текст содержит не‑ASCII символы, такой наивный копирующий код не даст корректного результата.

Итоги

Когда нужно обратиться к чисто C++ DLL из Python без заголовков, опирайтесь на правило понижения вызова методов на x64 и рассматривайте методы как свободные функции с явным указателем this. Разрешайте символы по перемангленным именам, вызывайте их из C++‑шима и конвертируйте любые C++‑специфичные возвращаемые типы в плоское C‑представление до перехода через границу. Экспортируйте только C‑интерфейс и сопровождайте любые выделенные буферы явным освободителем. Такой подход минимален, надёжен в заданных рамках и избегает неопределённого поведения, возникающего при попытке заставить ctypes работать с C++‑типами или неподдерживаемыми соглашениями о вызовах.