ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TSMaster脚本访问DLL:Python、C/C++与C#三条路线避坑指南

TSMaster脚本访问DLL:Python、C/C++与C#三条路线避坑指南 在 TSMaster 里写脚本这件事写得越深越早晚会撞上一堵墙手上有一堆现成的 dll里面有算法、有加密、有设备厂商给的驱动接口可脚本这边就是够不着。TSMaster 自带的脚本 API 覆盖的是总线收发、仿真、诊断、标定这些常规动作一旦要接第三方库脚本访问 dll 就成了绕不开的基本功。这篇按我自己的实操顺序把 Python 脚本、C/C 小程序、C# 小程序三条访问 dll 的路子从头捋一遍重点讲那些文档里不写、但一定会让你卡半天的细节位数匹配、调用约定、依赖链、回调对象的生命周期、字符串编码、dll 冲突。不管你是刚装完 TSMaster 想跑第一个脚本的新手还是已经在做台架自动化、想把老代码搬进来的老手都能直接抄配置、抄代码。1. 先想清楚脚本为什么要去访问 dll1.1 TSMaster 里三个能写脚本的入口这三个入口能碰 dll 的方式完全不同这也是很多人第一次踩坑的根本原因——拿着 Python 的思路去写 C 小程序或者拿 C 小程序的写法去套 C#结果编译能过、运行就崩。Python 脚本是上手最快的入口在脚本编辑器、全局脚本、测试用例的脚本步骤里都能写。它的优势是改一行跑一行不用编译特别适合算法验证、报文解析、数据后处理这类活儿。它访问 dll 靠的是 Python 自带的ctypes模块本质是动态加载 运行时查符号不需要任何头文件和 lib 文件。C/C 小程序是编译型的跑在软件进程内部适合高频、实时性要求高、需要贴着底层接口做的场合。TSMaster 的定时器回调、报文事件回调基本都在这个小程序里落地。它访问 dll 走的是标准 Windows 的链接/加载机制需要 dll、lib、头文件三件套齐活。C# 小程序介于两者之间.NET 生态里现成的东西拿来就用写串口、写数据库、调 HTTP 接口都很舒服。它访问 dll 靠的是DllImport这个平台调用特性签名声明写对了就能直接调。1.2 什么情况下非碰 dll 不可我把这些年遇到的场景归成四类基本能覆盖九成以上的需求。第一类是公司内部已有的算法库比如 CRC 校验、信号滤波、标定算法、故障诊断逻辑这些代码往往跑了很多年只有 dll 没有源码重写的风险比复用大得多。第二类是硬件厂商给的接口电源、程控电阻、示波器、数据采集卡、加密狗厂商一般只给 dll 加一份头文件和一份 PDF你没有别的选择。第三类是老的测试代码本身就是 C/C 写的逻辑复杂且经过长期验证直接包成 dll 复用比翻译成 Python 划算。第四类是需要被 TSMaster 驱动的 .NET 程序集设备这时候 C# 小程序反而是最顺的路。1.3 三条路线怎么选选路线的核心判断依据只有三个调用频率、实时性要求、以及你手上的资源形态有源码还是只有 dll。下面这张表是我自己总结的对照可以直接照着挑。判断维度Python ctypesC/C 小程序C# DllImport上手速度最快改完就跑最慢要配工程中等要编译调用频率上限几千次/秒有解释器开销几十万次/秒量级几万次/秒量级实时性差有 GC 和解释器抖动最好可控一般需要头文件/lib不需要需要不需要处理结构体/指针要手写类型映射直接用要写封送特性回调支持可以但有坑最自然可以委托要保引用适合的场景验证、后处理、低频控制实时回调、高频算法.NET 生态集成我的习惯是先用 Python 把 dll 调通确认导出名、参数、返回值、编码全对再决定要不要搬到 C 小程序。这个顺序能省掉大量时间因为 Python 侧报错清晰、改起来快而 C 小程序一旦加载失败往往连个像样的错误信息都看不到。2. 动手前的三道硬门槛位数、调用约定、依赖链这三道门槛不跨过去后面写多少代码都是白费。它们的共同特点是报错信息极其模糊看起来像是代码写错了实际上是环境问题。2.1 位数必须严格对上现在的 TSMaster 基本是 64 位程序这意味着三件事。Python 脚本跑在 TSMaster 主进程里解释器跟着主进程走所以是 64 位C/C 小程序编译时必须选 x64 平台选了 Win32 会直接加载失败C# 小程序要看清目标平台是 Any CPU 还是 x64Any CPU 在 64 位宿主下会以 64 位运行通常没问题但如果引用了 32 位的托管程序集就会炸。典型症状是OSError: [WinError 193] %1 不是有效的 Win32 应用程序或者 C 小程序加载时提示模块无效。这个错误码看着像文件损坏实际九成是位数不匹配。怎么确认一个 dll 是几位用 Visual Studio 开发者命令行的dumpbindumpbin /headers CalcLib.dll | findstr machine输出8664是 x64输出14C是 x86。没有 VS 的话用任意一个 PE 查看工具或依赖分析工具看头信息也行。提示不要试图用32 位兼容的思路硬扛。32 位进程没法把 64 位 dll 加载到自己地址空间里跨位调用只能走进程外方案——起一个 32 位中转进程用命名管道或共享内存通信。这套东西的成本和复杂度完全是另一个量级除非万不得已不要碰。2.2 调用约定stdcall 和 cdecl 差的那一下栈平衡调用约定说白了就是函数返回时谁来清理栈上的参数。Windows API 用的是__stdcall参数由被调用方清理很多第三方 C 库默认是__cdecl参数由调用方清理。如果调用方和被调用方的理解不一致栈指针就会错位后果是返回值全是垃圾、参数看起来被吃掉了、或者直接崩溃。Python 侧的区分方式很直接ctypes.CDLL(path)加载默认按cdecl调用ctypes.WinDLL(path)加载默认按stdcall调用。选错的表现非常典型函数明明返回 0你拿到的是个七位数或者第一次调用没事第二次调用直接进程消失。C 小程序侧更严格头文件里写的是__stdcall你的声明就必须写__stdcall一个字都不能少。否则编译链接都能过运行必崩。怎么确认一个 dll 的导出函数用的哪种约定看导出名的装饰形式dumpbin /exports CalcLib.dll如果看到_Calc_Add8这种带字节数后缀的是stdcall看到_Calc_Add这种只有前导下划线的是cdecl如果看到一长串带?和的乱码名字那是 C 编译器做了名称修饰说明 dll 作者没加extern C。最后这种情况最麻烦因为名字会随编译器版本变化只能靠GetProcAddress拿到修饰名去调或者找厂商要一个 C 接口的导出。2.3 依赖链真正的凶手往往是 dll 自己的 dllWinError 126找不到指定的模块是最常见的加载失败。绝大多数人的第一反应是路径写错了于是反复检查路径检查半天没问题。实际上八成的 126 不是目标 dll 不在而是目标 dll 依赖的某个 dll 不在。常见依赖有三类VC 运行库msvcp140.dll、vcruntime140.dll、vcruntime140_1.dll厂商的底层驱动以及某个被其他模块抢先加载的同名不同版本 dll——这就是大家常说的 dll 冲突。排查三板斧按顺序来用依赖分析工具打开目标 dll看哪几个节点标红装对应版本的 VC 运行库注意要装x64版装成 x86 版解决不了问题把目标 dll 和它所有依赖 dll 全部丢进同一个目录然后用绝对路径加载。关于 dll 冲突原理值得说清楚Windows 在同一个进程里同名 dll 只会加载一份。如果某个模块先把老版本的xxx.dll加载进来了你后面请求加载新版本时系统发现这个名字已经加载过了就会直接把老版本的句柄给你。你调用的一切都正常但行为就是不对。规避办法有两个。C 侧用LoadLibraryEx加LOAD_WITH_ALTERED_SEARCH_PATH标志让系统到 dll 自己所在的目录去找它的依赖而不是从主程序目录开始找。Python 侧用os.add_dll_directory()把依赖目录加进搜索路径这个在 Python 3.8 之后是必须的因为那时起 Windows 上加载 dll 不再默认搜 PATH。3. Python 脚本用 ctypes 访问 dll 全流程Python 是我最推荐的起点因为它的错误反馈最清晰。这一章按实际操作顺序走一遍。3.1 环境确认与 dll 放置策略第一件事是确认 TSMaster 内置 Python 的版本。在脚本里跑一句import sys print(sys.version)注意内置的 Python 环境只保证标准库可用numpy、pandas这类第三方包要看你的安装包版本里带没带。如果你打算在脚本里做大量数组运算先在脚本里import numpy试一下不行就得换个思路——要么自己在 C 侧把运算做完要么用ctypes配合原生数组手写循环。第二件事是 dll 放哪。我的习惯是在 TSMaster 工程目录下建一个libs子目录把 dll 和它的所有依赖一起丢进去然后脚本里用绝对路径拼出来。不要依赖系统 PATH也不要指望放到主程序目录就行——那会污染安装目录换个工程就乱套。import os DLL_DIR rD:\Project\TSMaster\Demo\libs if hasattr(os, add_dll_directory): os.add_dll_directory(DLL_DIR) # Python 3.8 必须 DLL_PATH os.path.join(DLL_DIR, CalcLib.dll)如果你的脚本需要跨机器部署别把绝对路径写死。可以读一个同目录的配置文件或者用工程根目录加子路径拼出来。有些执行方式下的 TSMaster 脚本拿不到__file__这种时候老老实实从工程配置里读路径比猜要靠谱。3.2 参数类型映射表与结构体对齐ctypes有一套自己的类型系统和 C 类型不是一一对应。下面这张表是我平时贴在显示器边上的照着填基本不会错。C 侧声明ctypes 写法关键备注intctypes.c_int固定 32 位unsigned intctypes.c_uintshortctypes.c_short16 位unsigned charctypes.c_ubytecharctypes.c_char单字节字符const char*ctypes.c_char_p传bytes不是strvoid*ctypes.c_void_p万能指针float/doublectypes.c_float/c_double别混用BOOLWin32ctypes.c_int4 字节boolCctypes.c_bool1 字节和 BOOL 不是一回事unsigned char[N](ctypes.c_ubyte * N)定长数组struct自定义Structure子类_pack_必须对齐字符串编码是另一个高频坑。C 侧的char*绝大多数情况下是 ANSI 编码在中文 Windows 上就是 GBK而 Python 侧字符串是 Unicode。传参的时候要显式编码取回来的时候要显式解码name 左前轮速 calc.Calc_SetName.argtypes [ctypes.c_char_p] calc.Calc_SetName.restype ctypes.c_int calc.Calc_SetName(name.encode(gbk))踩过的坑记录一下有一次我顺手写了encode(utf-8)传过去 C 侧按 GBK 解结果所有中文全变乱码但英文和数字完全正常排查了半天才想起来是编码问题。所以规矩就这么定死——对外传参一律 GBK除非头文件里明确写了宽字符接口。结构体的对齐更隐蔽。C 侧结构体如果有#pragma pack(1)Python 侧就必须写_pack_ 1否则字段偏移会差几个字节你会读到看起来完全随机的值。class CanFrame(ctypes.Structure): _pack_ 1 _fields_ [ (id, ctypes.c_uint), (dlc, ctypes.c_ubyte), (data, ctypes.c_ubyte * 8), (timestamp, ctypes.c_ulonglong), ]3.3 一个能跑通的完整例子假设厂商给了我们一个CalcLib.dll导出三个函数int Calc_Add(int, int)、int Calc_CRC16(const unsigned char*, int, unsigned short*)、void Calc_SetLogCallback(void(*)(int, const char*))全部是stdcall。完整脚本如下。import ctypes import os DLL_DIR rD:\Project\TSMaster\Demo\libs if hasattr(os, add_dll_directory): os.add_dll_directory(DLL_DIR) DLL_PATH os.path.join(DLL_DIR, CalcLib.dll) # stdcall 用 WinDLL若是 cdecl 则换成 CDLL calc ctypes.WinDLL(DLL_PATH) # 1) 简单函数 calc.Calc_Add.argtypes [ctypes.c_int, ctypes.c_int] calc.Calc_Add.restype ctypes.c_int print(Calc_Add(3,4) , calc.Calc_Add(3, 4)) # 2) 带输出缓冲区的函数 calc.Calc_CRC16.argtypes [ ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_ushort), ] calc.Calc_CRC16.restype ctypes.c_int def crc16(data: bytes) - int: buf (ctypes.c_ubyte * len(data)).from_buffer_copy(data) out ctypes.c_ushort(0) rc calc.Calc_CRC16( ctypes.cast(buf, ctypes.c_void_p), len(data), ctypes.byref(out) ) if rc ! 0: raise RuntimeError(Calc_CRC16 failed, rc%d % rc) return out.value print(CRC16 0x%04X % crc16(b\x01\x02\x03\x04\x05\x06\x07\x08))几个细节值得单独说。第一argtypes和restype一定要写。不写的话 ctypes 会按默认规则猜指针会被截断成 32 位在 64 位进程里直接崩。第二输出参数用ctypes.byref(out)比ctypes.pointer(out)更轻量也更快。第三from_buffer_copy会复制一份数据避免你后续改动原 bytes 影响 dllbytes 本身不可变但换成bytearray时就有这个风险了。读写一个结构体数组也顺手给出来做批量报文处理时用得上frames (CanFrame * 64)() calc.Calc_ReadFrames.argtypes [ctypes.POINTER(CanFrame), ctypes.c_int] calc.Calc_ReadFrames.restype ctypes.c_int n calc.Calc_ReadFrames(frames, 64) for i in range(n): print(hex(frames[i].id), frames[i].dlc, bytes(frames[i].data[:frames[i].dlc]))3.4 回调函数最容易闪退的地方回调是 Python 调 dll 里最危险的一环。写法本身很简单CALLBACK ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_char_p) def _on_log(level, msg): text msg.decode(gbk, errorsignore) if msg else print([dll][%d] %s % (level, text)) _cb CALLBACK(_on_log) # 存成模块级变量 calc.Calc_SetLogCallback.argtypes [CALLBACK] calc.Calc_SetLogCallback.restype None calc.Calc_SetLogCallback(_cb)大坑在这里如果你偷懒写成calc.Calc_SetLogCallback(CALLBACK(_on_log))Python 侧没有任何变量持有这个回调对象垃圾回收一触发就把它回收了。dll 下一次回调时跳到已经释放的地址整个进程瞬间消失。这种崩溃的恶心之处在于——它不在注册的那一刻发生而是在几秒或几十秒之后看起来毫无规律特别难定位。第二个要注意的点是线程。回调是在 dll 自己的线程里进来的跟你的脚本主线程不是一回事。在回调里直接动手操作 TSMaster 的界面对象或发报文接口很容易出现竞态。我的做法是在回调里只做一件事把数据塞进一个线程安全的队列然后在脚本的主循环或定时器里取出来处理。第三个点是异常。回调函数里抛出的 Python 异常不会优雅地传回 dll跨语言边界的行为是未定义的。所以回调体里必须自己包一层try/except出错就记日志绝不让异常逃出去。4. C/C 小程序直接链接 dll 的做法C 小程序的调用开销最小实时性最好代价是配置麻烦、出错难查。这一章讲配置和两种调用方式。4.1 工程配置的三件套与输出目录在 TSMaster 里写 C/C 小程序需要在工程设置里配好三样东西头文件搜索路径、lib 文件搜索路径、附加依赖项。配完之后编译链接能过但运行还会挂——因为小程序编译出来的 dll 是要被主程序加载的它所在的目录和你配的路径没关系。关键动作是把第三方 dll 复制到小程序输出 dll 的同一个目录里。如果你在小程序工程设置里找到了附加依赖项或DLL 搜索路径这类配置项优先用它没有的话就靠同目录摆放 绝对路径加载两条腿走路稳。还有两个编译选项必须注意。平台选x64和 TSMaster 保持一致。运行时库选/MD多线程 DLL不要选 /MT。原因是 dll 之间的内存分配和释放必须共用同一份 CRT如果你用 /MT第三方 dll 用 /MD就会出现在我这边 new、在你那边 delete的灾难症状是随机崩溃或者内存泄漏极难查。4.2 隐式调用与显式调用的取舍隐式调用就是编译期链接代码干净#pragma comment(lib, CalcLib.lib) extern C __declspec(dllimport) int __stdcall Calc_Add(int a, int b); void demo_implicit() { int r Calc_Add(3, 4); printf(Calc_Add %d\n, r); }优点是写起来清爽IDE 能补全。缺点也很致命程序启动时就必须能找到这个 dll找不到的话整个小程序加载失败而 TSMaster 那边给出的提示往往只是一句小程序加载失败你完全不知道是哪个 dll 的问题。显式调用多写几行但可控性完全不一样#include windows.h #include cstdio typedef int (__stdcall *PFN_ADD)(int, int); static PFN_ADD g_pfn_add nullptr; static HMODULE g_hmod nullptr; int ensure_calclib_loaded() { if (g_hmod g_pfn_add) return 0; g_hmod ::LoadLibraryExW( LD:\\Project\\Demo\\libs\\CalcLib.dll, nullptr, LOAD_WITH_ALTERED_SEARCH_PATH); if (!g_hmod) { DWORD err ::GetLastError(); printf([CalcLib] LoadLibrary failed, err%lu\n, err); return (int)err; } g_pfn_add (PFN_ADD)::GetProcAddress(g_hmod, Calc_Add); if (!g_pfn_add) { printf([CalcLib] GetProcAddress failed, err%lu\n, ::GetLastError()); return -1; } return 0; }LOAD_WITH_ALTERED_SEARCH_PATH这个标志的作用前面提过——让系统从 dll 自己所在的目录去找它的依赖。当你把依赖 dll 全放在libs目录里时这个标志几乎是必须的否则系统会从主程序目录开始找找不到就报 126。我的建议很明确调试期一律用显式调用把所有错误码都打出来。等接口稳定、部署环境固定了再决定要不要换成隐式。很多时候根本换回来——显式调用的那点代码量换来的可诊断性太值了。4.3 在定时器回调里调 dll 的注意事项TSMaster 的定时器回调跑在实时线程上在这个上下文里调外部 dll有几条线不能碰。首先不要在回调里做大块内存分配、磁盘 IO 或Sleep。这些操作会阻塞实时线程表现出来就是定时不准、界面卡顿、报文丢帧。实测过一个案例dll 单次调用耗时 3 毫秒定时器周期设成 1 毫秒界面上肉眼可见地卡报文时间戳也开始漂。其次一定要搞清楚 dll 是不是线程安全的。很多厂商的 dll 内部有全局缓冲区多个线程同时调用会互相踩。判断方法很简单——看头文件里有没有提到线程安全或者不可重入含糊不清的就当它不安全处理。做法是自己加一把临界区static CRITICAL_SECTION g_cs; static bool g_cs_inited false; int safe_calc_add(int a, int b) { if (!g_cs_inited) { ::InitializeCriticalSection(g_cs); g_cs_inited true; } ::EnterCriticalSection(g_cs); int r g_pfn_add ? g_pfn_add(a, b) : -1; ::LeaveCriticalSection(g_cs); return r; }第三绝对不要让 C 异常穿过 dll 边界。如果你的 dll 和主程序的 CRT 版本不一致异常穿越边界时会直接终止进程连日志都没有。规矩就是dll 内部自己try/catch对外只返回错误码一个异常都不许漏出来。第四如果你在回调里同时用 TSMaster 自身的接口TSApp命名空间那一套和外部 dll功能上没问题但要注意别在两边都做阻塞操作。我一般把外部 dll 的耗时调用抽到一个独立工作线程回调里只投递任务这样实时线程永远轻装。5. C# 小程序用 DllImport 引入外部接口C# 小程序的平台调用写起来最像声明一下就能用但封送处理有它自己的坑。5.1 签名声明与封送处理using System; using System.Runtime.InteropServices; public static class CalcLib { [DllImport(CalcLib.dll, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi)] public static extern int Calc_Add(int a, int b); [DllImport(CalcLib.dll, CallingConvention CallingConvention.Cdecl)] public static extern int Calc_CRC16(byte[] data, int len, out ushort crc); }几个要点。CallingConvention的默认值是StdCall对应Winapi但很多 C 库是Cdecl必须显式写清楚。虽然现在 64 位下 Windows 的调用约定已经统一了写清楚的好处是将来万一要切 32 位不会莫名其妙地崩。out ushort会被自动封送成指针比在 C 里手写指针舒服得多。结构体要显式标注布局和对齐[StructLayout(LayoutKind.Sequential, Pack 1)] public struct CanFrame { public uint Id; public byte Dlc; [MarshalAs(UnmanagedType.ByValArray, SizeConst 8)] public byte[] Data; public ulong Timestamp; }Pack要和 C 侧的#pragma pack一致。结构体里有定长字符串时用[MarshalAs(UnmanagedType.ByValTStr, SizeConst 32)]配CharSet.Ansi能自动帮你做 ANSI 和 Unicode 的转换。dll 的加载路径是另一个坑。C# 小程序的 dll 搜索路径和主进程有关最稳的办法是显式设置搜索目录[DllImport(kernel32.dll, CharSet CharSet.Unicode, SetLastError true)] private static extern bool SetDllDirectory(string lpPathName); SetDllDirectory(D:\Project\Demo\libs);不要图省事把 dll 复制到 TSMaster 主程序目录那会让安装目录越来越乱而且换台机器就失效。5.2 内存生命周期与托管对象钉住数组传给非托管代码时有个隐蔽陷阱如果 dll 把这个指针存起来了、稍后再用那么 GC 一旦压缩堆数组就被移动了dll 手里那个指针就变成了野指针。这种场景必须把托管对象钉住var buffer new byte[4096]; var handle GCHandle.Alloc(buffer, GCHandleType.Pinned); try { IntPtr ptr handle.AddrOfPinnedObject(); // 把 ptr 传给 dll } finally { handle.Free(); // 必须释放否则句柄泄漏 }回调这块和 Python 是同一类问题。委托必须有人持有引用否则 GC 回收之后非托管侧的回调就跳飞了。做法是把委托存成静态字段或者在调用完之后加一句GC.KeepAlive(callback)。还有一条规矩要记牢谁分配的内存谁释放。dll 里分配的内存一定要用 dll 自己导出的释放函数去释放绝对不要在 C# 里调Marshal.FreeHGlobal去放掉——两边的堆管理器不一样这么干必崩。6. 报错排查速查表与实测踩坑记录6.1 加载失败类报错速查报错含义最可能的原因处理办法WinError 126找不到模块依赖缺失少了 VC 运行库或依赖 dll依赖分析工具查红色节点补齐依赖WinError 193不是有效 Win32 程序位数不匹配64 位宿主加载 32 位 dlldumpbin /headers确认位数WinError 127找不到指定程序导出名不符名字修饰、拼写错误、大小写dumpbin /exports核对导出名WinError 1114DLL 初始化例程失败DllMain 出错dll 在 DllMain 里加载别的 dll 或建线程找厂商确认或用显式延迟加载绕开加载成功但行为不对dll 冲突同名老版本已被抢先加载绝对路径 独立目录隔离WinError 1114这个特别值得说一句。它出现的时候通常意味着 dll 的DllMain里干了不该干的事——比如在DLL_PROCESS_ATTACH阶段去调用LoadLibrary加载另一个 dll、创建线程、或者调用会阻塞的同步 API。Windows 的加载锁还在持有状态这些操作就会死锁或者失败。如果厂商不给你源码唯一的办法是绕开把 dll 的加载推迟到实际调用的时候显式LoadLibrary而不是在进程启动阶段就让它被隐式加载。6.2 调用即崩溃类问题调用一次就崩和调用两次才崩是两种完全不同的问题不要混在一起查。调用一次就崩八成是参数类型或调用约定错了。检查顺序先确认stdcall还是cdecl再确认参数宽度int和long在 64 位下都是 4 字节但size_t是 8 字节unsigned long在 Windows 上也是 4 字节unsigned long long是 8 字节最后确认结构体对齐。Python 侧特别容易犯的错是没写argtypes导致指针被当成int截断。调用两次才崩基本就是回调对象被 GC 回收了或者某个缓冲区被写越界、破坏了相邻内存。回调的问题前面讲过了解决办法就是把回调对象存成长生命周期变量。缓冲区越界的问题可以在 Python 侧把缓冲区开大一圈前后各留 32 字节的哨兵调完之后检查哨兵有没有被改写能快速判断是不是越界写。6.3 结果不对但不崩的问题这一类最难查因为没有任何报错。常见的三种情况我按出现频率排一下。排第一的是字符串编码。前面说过char*在中文 Windows 上基本都是 GBK你按 UTF-8 编过去就会乱码。排查办法很简单——传一个纯英文串过去如果正常基本就是编码问题。排第二的是结构体对齐。C 侧用了#pragma pack(1)Python 侧没写_pack_ 1字段偏移全错你会看到 ID 和 DLC 好像对得上但时间戳完全离谱。这种部分字段正确的现象是对齐问题的典型特征。排第三的是返回值语义理解错了。有些 dll 返回的是实际写入的字节数有些返回的是错误码有些返回 0 表示成功、有些返回 0 表示失败。这种事只能翻文档或者做实验确认——给一组已知输入看返回值是不是符合你的预期。6.4 一套固定的排查流程踩了足够多次之后我固化下来一套排查顺序从下往上打基本能在二十分钟内定位到问题用dumpbin /headers确认位数和宿主进程一致用dumpbin /exports把导出名原样抄下来别凭记忆拼用依赖分析工具打开 dll把红色节点全部解决掉改成绝对路径加载加上LOAD_WITH_ALTERED_SEARCH_PATH先在 Python 里最小化复现把参数、返回值、编码全部验证正确再把验证过的调用原样搬到 C 小程序或 C#全程打日志——加载结果、每次调用的参数和返回值、错误码一个都不省。第 5 步是我最想强调的。很多人上来就在 C 小程序里硬刚编译半天加载失败只会给一句模糊提示来回折腾几个小时。同样的逻辑用 Python 写十行代码报错清清楚楚十几分钟就能确认 dll 本身有没有问题。确认没问题了再搬效率差好几倍。7. 一些不成体系但很值钱的经验cts里加载 dll 的时候WinDLL和CDLL的选择可以现场验证。如果你不确定调用约定可以两个都试一次哪个不崩就是哪个——这个方法土但有效前提是崩的是 Python 进程而不是整个 TSMaster。所以务必先在独立的 Python 环境里做这个实验别在 TSMaster 里试。dll 目录隔离这件事我的做法是每个第三方库单独一个子目录目录名带上版本号。这样做的直接好处是同名不同版本的 dll 永远不会互相干扰出问题的时候也知道该退回到哪个版本。代价是磁盘上多几份文件这个代价值得付。关于调试有个小技巧特别管用在 Python 侧写一个probe.py脚本内容就是把 dll 加载一遍、把每个导出符号打印出来、用一组固定输入跑一遍调用。换机器、换版本、换编译器的时候先跑这个脚本二十秒就能判断环境是不是健康的。这个脚本我改过七八个版本现在是每次接手新 dll 的第一件事。最后一个经验是关于文档的。厂商给的 PDF 里参数表和返回值说明通常写得像谜语。真正靠谱的做法是拿 dll 去打边界值——传 0、传负数、传超大值看它返回什么、会不会崩。打完之后你对这个 dll 的脾气就有底了比读十页文档管用。当然这个实验必须在隔离的 Python 环境里做崩了也不影响 TSMaster 主进程。
返回列表