2025, Nov 11 03:00

Calling a C++ DLL from Python with ctypes and a tiny C++ shim: mangled names, MSVC x64, std::wstring

Step-by-step guide to call a C++ DLL from Python with ctypes: resolve mangled MSVC x64 symbols, pass this, convert std::wstring via a tiny C++ shim.

Calling into a C++ DLL from Python gets tricky fast when you don’t control the library and don’t have its headers. The situation becomes especially delicate once member functions and C++-only types like std::wstring enter the picture. Below is a practical walkthrough of how to bridge such a DLL with Python using ctypes and a tiny C++ shim, relying only on the exported mangled names.

What we’re up against

The DLL exposes mangled symbols like these:

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

Interpreting those, the relevant signatures are:

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

From Python, it’s straightforward to locate and call the first two with ctypes by treating everything as opaque pointers:

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())  # stable pointer value returned repeatedly

However, the export that returns std::wstring is not callable this way and leads to crashes or access violations:

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

There are two blockers here. First, ctypes doesn’t marshal C++-only types like std::wstring. Second, member functions complicate the call shape, and __thiscall isn’t something ctypes supports. You need a small C++ shim that speaks the DLL’s ABI on one side and a plain C ABI on the other.

The core insight: how to call member functions by address

On MSVC x86-64, invoking a member function is equivalent to calling a non-member function with an implicit first parameter for the this pointer. Conceptually, these are codegen-equivalent:

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

That means if you obtain the address of a member function and cast it to a function pointer that takes the object as its first argument, you can call it like a free function. This avoids special handling for __thiscall on x64.

Build a shim that converts the C++ result into a C string

The cleanest route is to load the mangled functions with GetProcAddress, call them with the hidden this pointer as the first argument, receive the std::wstring on the C++ side, then convert it to a heap-allocated char* your Python code can treat as a C string. A separate deallocator ensures ownership is clear.

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];  // assumes no non-ASCII characters
  }
  return buf;
}
void releaseFlatString(char *p) {
  std::free(p);
}

The shim above keeps dummy C++ classes purely to express types locally; there is no expectation of layout compatibility. In fact, you can remove those dummy declarations entirely and cast everything to void* while keeping calling semantics intact.

If you prefer all-opaque pointers

You can express the same idea using only void* where references and pointers are involved. This keeps the shim minimal and avoids introducing placeholder class names.

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");

You would then call fnGetRoot(fnGetApp(fnGetFoo())) the same way, copy the characters into a newly allocated C buffer, and return it to Python along with a matching free-style function.

Alternate flow: pass function pointers from Python into the shim

Since ctypes can fetch the function pointer for you, you can pass that raw address into the shim and invoke it from C++ with a cast to the appropriate signature.

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];  // assumes ASCII-only content
  }
  return out;
}
void releaseFlatString2(char *p) {
  std::free(p);
}

On the Python side, you would obtain both the object pointer and the function pointer via getattr on the mangled symbol, then hand those opaque values to the shim.

Why does this work?

The breakthrough is treating a member function call as a free function taking the object as its first argument. That’s exactly how the x86-64 MSVC ABI lowers these calls, so once you have the symbol address you can invoke it by casting to a pointer-to-function type that takes the hidden this pointer explicitly. This bypasses __thiscall concerns and lets you keep ctypes in its comfort zone by never asking it to marshal C++ types.

The second part is isolating C++-only return types. std::wstring won’t round-trip through ctypes. Instead, you consume it inside the shim and return a plain char* to Python, plus a companion release function. A thin extern "C" export layer around the shim’s char*-based API is fine; do not expose functions with std::wstring in C linkage. Keeping the conversion inside a normal C++ function avoids warnings about C linkage with C++ types and sidesteps the crash that occurs when attempting to expose those directly.

Notes worth keeping in mind

Be aware that details like how the DLL is loaded and how GetProcAddress is obtained in your process are orthogonal concerns not covered here. Also, the examples assume ASCII-only data when flattening std::wstring into char*. If the returned text contains non-ASCII characters, that naive copy will not produce meaningful results.

Takeaways

When you must call into a C++-only DLL from Python without headers, lean on the x64 member-call lowering rule and treat member functions as free functions taking an explicit this pointer. Resolve symbols by their mangled names, invoke them from a C++ shim, and convert any C++-specific return types to a plain C representation before crossing the boundary. Keep the exported surface area C-only and pair any allocated buffers with a dedicated deallocator. This approach is minimal, robust within the constraints given, and avoids undefined behavior from forcing ctypes to handle C++ types or unsupported calling conventions.

The article is based on a question from StackOverflow by Nick Bauer and an answer by Quuxplusone.