尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

【万字讲透】py to pyd——py代码加密技术py版DLL

【万字讲透】py to pyd——py代码加密技术py版DLL 摘要源自AI总结本文系统介绍了 Python 的 .pyd 扩展模块。首先解释了 .pyd 的本质是 Windows DLL对比了其与 .py 文件在速度、源码保护、跨平台等方面的差异。接着详细说明了通过批处理脚本和 setup.py 构建 .pyd 的方法强调了构建环境与运行环境必须一致。文章还探讨了同名 .pyd 和 .py 文件的加载优先级并提出了使用 .pyi 桩文件解决编辑器静态分析问题。最后针对构建环境与运行环境不一致的场景介绍了通过子进程桥接调用 .pyd 模块的方法并分析了子进程返回值returncode、stdout、stderr的获取与使用方式。一、何为pyd一个可以被python直接import导入的编译产物本质上是Windows DLL因此它不是独立脚本也不是包含全部运行环境的程序不会将依赖库也一同打包这也是它和.exe的根本区别。更简单直白一些可以将pyd文件理解成源文件的加密版本只是不能像源文件一样独立运行无法执行其main函数罢了。除此之外的一些差别有PY 文件PYD 文件本质Python 源码编译后的动态库速度慢解释执行快编译执行源码保护源码暴露二进制保护跨平台所有平台需要平台版本运行环境和构建环境需一致使用方式import fileimport file文件大小小大修改直接修改源码需要重新编译二、Python代码编译方式(pyd生成方法)2.1、构建启动脚本批处理工具——锁定构建环境确保编译过程产物可控通过.bat批处理工具指定构建环境python解释器环境、C编译器环境、模块名等信息。使用.bat的好处在于可以自定义构建环境而不是让系统自己从环境变量PATH中查找可用的C编译器批处理工具echo off setlocal set PYTHONC:... ...\Python\3.8.5\x86\python.exe set VSCMDC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat call %VSCMD% x86 if errorlevel 1 goto :failed %PYTHON% setup_pyd.py build_ext --inplace if errorlevel 1 goto :failed echo. echo Build finished. Check for pyd. goto :done :failed echo. echo Build failed. Please check the messages above. pause exit /b 1 :done pause endlocal.bat脚本的执行流程是 1. 设置 Python 解释器路径需要与指定的C编译架构一致如python 3.8.5对应于X86架构 | ↓ 2. 设置 VS 编译器环境脚本路径 | ↓ 3. 调用 vcvarsall.bat 激活编译环境需要指定架构X64\X86 | ↓ 4. 在已激活的编译环境中执行 python setup_pyd.py build_ext --inplace该批处理程序等价于手动命令call C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat x86 C:\Program Files (x86)\TOSUN\TSMaster\Data\Python\3.8.5\x86\python.exe setup_pyd.py build_ext --inplace关键步骤注释# 首先定义一个变量PYTHON指向目标 Python 解释器。因为.pyd必须用与最终执行的python环境一致的环境来构建执行环境与pyd的构建环境必须一致通常精确到小python小版本即minor级严谨一些需要精确到macro级所以这里写死为为特定的python环境。然后定义一个变量VSCMD指向 Visual Studio 的环境初始化脚本。两步直接隔离环境变量。set PYTHONC:\Program Files (x86)\TOSUN\TSMaster\Data\Python\3.8.5\x86\python.exe set VSCMDC:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat# 执行vcvarsall.bat并传入x86作用是了解即可把cl.exe、link.exe加入 PATH设置 Windows SDK、MSVC 头文件和库文件路径选择 32 位工具链激活 32 位编译环境匹配 32 位 Python这里的call很重要vcvarsall.bat内部会执行子批处理并设置环境变量如果不加call批处理可能直接把控制权转走后面的命令就不会执行了。call %VSCMD% x86# 这是核心构建命令用指定 Python 执行setup_pyd.pybuild_ext告诉 setuptools 构建 C 扩展--inplace表示构建完成后把.pyd放到当前目录而不是只放在build/lib里%PYTHON% setup_pyd.py build_ext --inplace2.2、定义构建规则setup_pyd.py# setup_pyd.py from setuptools import setup, Extension from Cython.Build import cythonize Extension(): 定义一个需要编译的C/C扩展模块 extensions [ Extension( lin_ldf_helper, # 最终模块名与编译的python模块同名 sources[src/lin_ldf_helper.py], # 源码的位置相对于构建规则的路径 ), ] setup(): 将配置信息交给setuptools通过 setuptools 告诉 Python 如何构建、命名和打包一个 C 扩展。 cythonize()将.py转换为C代码并编译 setup( namelin_ldf_helper, # 如果上传PyPI通过pip下载时包的名称可自定义 version1.0.0, # 版本号 descriptionLIN LDF parsing helper compiled to a pyd extension, # 简短描述 ext_modulescythonize( extensions, compiler_directives{language_level: 3}, # 指定 python 3 的语法规则 ), )编译出的.pyd文件名含义lin_ldf_helper.cp38-win32.pydPython 3.8 32 位 Windowslin_ldf_helper.cp311-win_amd64.pydPython 3.11 64 位 Windows三、同名pyd和py在同一目录下的优先级pyd和py的导入方式相同如果A.py与其在32位环境下的编译结果A.cp38-win32.pyd位于相同路径下此时B.py通过import A的方式加载模块A会首先加载A.cp38-win32.pyd。但这有一个大前提就是当前 Python 必须能够识别该.pyd的版本/位数后缀。例如Python 3.8 x86识别.cp38-win32.pyd所以它优先Python 3.11 x64只识别.cp311-win_amd64.pyd不识别.cp38-win32.pyd所以在 Python 3.11 x64 下.cp38-win32.pyd会被当成“不存在的扩展”同一目录里的源码lin_ldf_helper.py就会作为模块被加载所以为了确保进程可以加载到你想加载的模块最好不要将同名的py和pyd置于同一路径下否则可能会静默加载了你原本不希望加载的同名py文件但又不会报错行为很难排查。四、加载.pyd——pyi桩文件4.1、像加载py一样加载pyd加载pyd?刚才不是提到加载pyd的方式与加载py模块的方式是一样的吗这有什么好特别说明的。以刚才编译好的 lin_ldf_helper.cp311-win_amd64.pyd 为例试想一下你将工程下的源码lin_ldf_helper.py文件删除只保留其编译产物此时主程序加载该模块在编译器中会观察到什么现象没有名称为“XXX”的模块不用担心这只是编辑器的静态分析找不到lin_ldf_helper。原因是编辑器不会执行代码它只按自己的搜索路径找模块而.pyd又是编译产物源码又被删除或在其他路径下所以静态分析默认认为模块不存在了。看着很别扭如果主程序在运行的时候pyd可以被加载倒也无妨就怕你以为运行的时候可以加载但实际上没有这个模块所以有什么办法可以优化一下吗去掉这个红波浪提示答案是再加一个与被加载模块同名的.pyi类型桩文件lin_ldf_helper.pyi。其他方法通过加备注“# type: ignore”可以忽略掉编辑器的错误提示。4.2、.pyi桩文件——Python的“头文件/接口声明文件”对于pyhton源码中的函数def parse_ldf_summarydef parse_ldf_summary(ldf_path: str, encoding: Optional[str] None) - Dict[str, Any]: Parse an LDF file and return a summary dict. ldf ldfparser.parse_ldf(ldf_path, encodingencoding) ... ... ... ... ... ... ... ... return { protocol_version: str(ldf.get_protocol_version()), language_version: str(ldf.get_language_version()), speed_kbps: ldf.get_baudrate(), master: master.name, slaves: slaves, frames: frames, schedule_tables: schedule_tables, }将其源文件进行编译后可以在同名pyi文件中以相同结构进行声明如# lin_ldf_helper.pyi from typing import Any, Dict, Optional def parse_ldf_summary(ldf_path: str, encoding: Optional[str] None) - Dict[str, Any]: ...这样一来不仅可以消除红色波浪线还可以让编辑器知道这个模块有哪些函数让编辑器提示参数类型、返回值类型不参与运行运行时还是加载.pyd.pyd可以放在任何地方只要编辑器能通过.pyi找到接口声明就能识别且运行时只要解释器可以遍历sys.path中的既定路径集合在它们中查找到真正可供调用的pyd包即可注意如果在被编译的源码中为sys.path添加pyd路径此时编译后将查看不到哪些路径被加入到了系统路径中不便于排查问题所以最好不要在被编译的源码中sys.path添加pyd路径而是在外部提前将路径加好。如有这样的文件结构Pyd_File/ ├── call_pyd.py ← 主程序 ├── lin_ldf_helper.pyi ← 给编辑器识别的桩文件 └── next_level_folder/ ← 下一级目录 └── lin_ldf_helper.cp38-win32.pyd ← 实际运行时被主程序加载的pyd模块假设pyd模块的源码中需要加载一个第三方库库名为ldfparser这个库是专用于解析Lin总线数据库文件的但这个库位于其他位置比如Self_Site_Pachages路径下。如果在pyd模块的源码中通过如下方式精准导入将源码进行编译后将无法查看到新增了哪些自定义的库查询路径不便于排查问题# lin_ldf_helper.py 源码 import json from typing import Any, Dict, List, Optional import os import sys Pyd_File os.path.dirname(os.path.dirname(os.path.abspath(file))) Project_Root os.path.dirname(Pyd_File) Make ldfparser and lark importable at runtime. Self_Site_Packages os.path.join( Project_Root, Parse_Ldf_Demo, self_site_packages ) # 拼包得到第三方库的路径 sys.path.insert(0, Self_Site_Packages) # 将路径加入到库查询路径列表中 import ldfparser # 解释器会遍历路径列表直至在某个正确路径下找到这个库所以建议在pyd外部提前插入自定义库查询路径如# lin_ldf_helper.py 源码 import json from typing import Any, Dict, List, Optional import os import sys import ldfparser # type: ignore在外部比如工程的主程序中统一添加库的路径到库路径查询列表中做集中管理和配置。# call_pyd.py 主程序 import json import os import sys Pyd_File os.path.dirname(os.path.abspath(file)) r\next_level_folder Project_Root os.path.dirname(os.path.dirname(Pyd_File)) Make ldfparser and lark importable at runtime. Self_Site_Packages os.path.join( Project_Root, Parse_Ldf_Demo, self_site_packages ) sys.path.insert(0, Self_Site_Packages) # 第三方库的所在目录 sys.path.insert(0, Pyd_File) # pyd模块的所在目录 import lin_ldf_helper这是一种好的编程习惯。五、加载并使用陌生的pyd背景假设你要实现一个功能恰好隔壁部门的大佬给了你一个成熟的pyd模块但是他只是粗略的告诉你这个模块的功能是可以满足你的需求的但是其中的函数接口、入参返回值形式这样的关键信息你你无从得知。5.1、获取其中的可调用对象、对象类型、对象入参如果手头有一个pyd包我们只知道它大致是做什么的但具体如何调用其中的方法无从知晓该怎么办注意接下来的方法只是说明如何在突然拿到一个pyd库后尽快调用它的方法来完成工作这种方法无法探究出它的全部接口是一种临时可行方案实际使用时还是查询pyd的使用说明或联系开发者最为稳妥。比如有这样一个文件TSParseArxml.pyd从文件名可知它用于解析axml配置文件但是其构建环境的相关信息python版本majorminor、位数都被抹去了采用如下方法获取其可调用对象import TSParseArxml print([i for i in dir(TSParseArxml) if callable(getattr(TSParseArxml, i)) and not i.startswith(_)], end----)[ParseCANArxml, ParseETHArxml, ParseFrArxml]----我们想使用其中的可调用对象方法 或 类ParseCANArxml获取它的入参方法对象的 或 类的实例初始化方法__init__的from TSParseArxml import * print(inspect.signature(ParseCANArxml), end----)(ArxmlFile, outputPath)----接下来就可以直接调用from TSParseArxml import * ParseCANArxml(ArxmlFilerD:... ...... ...\xxx.arxml, outputPathrD:... ...)最终完成axml的解析并生成解析结果到目录outputPath下。5.2、构建环境和运行环境不一致如果运行环境python 3.11.8x64架构❌通过测试在该运行环境下加载TSParseArxmlimport TSParseArxml 或 from TSParseArxml import *时报错No module named TSParseArxmlTraceback (most recent call last): File C:\... ...\BRIDGE.py, line 3, in module from TSParseArxml import * ModuleNotFoundError: No module named TSParseArxml尝试切换运行环境为python 3.8.5x86架构✔️代码正常执行说明这个pyd模块的构建环境是python 3.8。实际上我们拿到的源码编辑产物往往只适用于单一运行环境除非针对各种运行环境都执行构建一次但这并不符合现实情况现实情况往往需要我们在与构建环境不一致的情况下使用模块该怎么做答案是当运行环境与模块的构建环境不同时通过启动子进程实现模块加载和调用。典型方法如下# X86_bridge.py import subprocess import json import os import pickle env os.environ.copy() env[PYTHONUTF8] 1 X86_PYTHON rC:\Program Files (x86)... ...\Python\3.8.5\x86\python.exe BRIDGE rC:... ...\BRIDGE.py arxml_path rD:... ...\xxx.arxml proc subprocess.run( [X86_PYTHON, BRIDGE, arxml_path], capture_outputTrue, textTrue, encodingutf-8, # 指定外层的编码方式 envenv, ) print(fx86 bridge: {proc.returncode}, {proc.stdout}, {proc.stderr}) data json.loads(proc.stdout) print(f{data}, {type(data)})subprocess.run([PYTHON, BRIDGE, arg1, arg2, ... ...])本质上就是执行命令行PYTHON BRIDGE arg1 arg2 来在指定的python环境下执行程序其中的BRIDGE就是我们创建的用于加载并调用模块的桥接器文件本质上是用于独立运行模块中函数接口的脚本例1、如果该脚本如下所示:# BRIDGE.py from TSParseArxml import * ParseCANArxml(ArxmlFilerD:... ...\xxx.arxml, outputPathrD:... ...)在python 3.11.8x64架构环境下启动子进程就能够成功调用pyd模块中的函数了x86 bridge: 0, ,例2、如果脚本如下所示# BRIDGE.py from TSParseArxml import * if len(sys.argv) 2: print(f参数个数为2:{sys.argv[0]}和{sys.argv[1]}, end) print(f打印内容1,) print(f打印内容2, end----) sys.exit(detect_arxml_type(sys.argv[1]))其中detect_arxml_type是模块中用于判断文件类型的函数如果arxml格式其返回值为整数5。脚本执行结果x86 bridge: 5, 参数个数为2:C:\... ...\BRIDGE.py和D:\... ...\XXX.arxml打印内容1 打印内容2----,例3、如果脚本如下所示# BRIDGE.py from TSParseArxml import * if len(sys.argv) 2: sys.exit(参数个数为2)脚本执行结果x86 bridge: 1, , 参数个数为2那么子进程执行脚本结束后的三个“返回值” proc.returncode, proc.stdout, proc.stderr返回的都是什么内容呢我们可以怎样利用它们呢如何获取模块函数接口的业务返回值呢5.3、子进程“返回值”——returncodestdoutstderrreturncode 子进程的“返回值”也就是退出码如果指定了退出码是整数则与指定的退出码相等否则默认为returncode1stdout 子进程中 print() 打印的内容会将所有的print内容组合起来成一个字符串不是对象本体stderr 错误/诊断输出即sys.exit(非整数对象) 的内容一、returncode和stderr与子进程的退出码即sys.exit(参数)相关。参数为整数时returncode与参数相等stderr为空字符串参数为其他类型时returncode固定为整数1stderr为参数的str强转结果哪怕参数是对象TSParseArxml.ParseCANArxml object at 0x023AE058stderr也是str类型“TSParseArxml.ParseCANArxml object at 0x023AE058”。总结如下sys.exit(参数)returncode(int类型)stderr(str类型)sys.exit(0)或没有推出代码0sys.exit(5)5sys.exit(abc)1abcsys.exit([...])1[...]可见二者结合使用通过灵活设置退出码可以很好地反映子进程的最终运行情况。二、stdout为运行在子进程中的脚本的所有print内容即按照print的先后顺序将所有打印内容首位连接成新的字符串。如# BRIDGE.py from TSParseArxml import * Axxml_Parser ParseCANArxml(ArxmlFilerD:... ...\xxx.arxml, outputPathrD:... ...) if len(sys.argv) 2: print(f参数个数为2:{sys.argv[0]}和{sys.argv[1]}, end) print(f打印内容1) # 不指定end print(f{Axxml_Parser}, end----) sys.exit(detect_arxml_type(sys.argv[1]))脚本执行结果x86 bridge: 5, 参数个数为2:C:\... ...\BRIDGE.py和D:\... ...\XXX.arxml打印内容1 TSParseArxml.ParseCANArxml object at 0x014FE058----,stdout可以充当子进程的log记录它会将子进程中所有print的内容都强转为str类型后进行拼接这就是为什么打印内容1后出现了换行因为print在未指定end的情况下默认end为\n。除此之外可以通过它获取子进程的业务数据即对象必须是可以被保存到json中的前提是子进程执行的代码块中只出现过一次print否则将报错且print的内容必须为json字符串因为通过stdout获取业务数据print的必须是json字符串如果多次print试想一下两个拼接在一起的json字符串在进行data json.loads(proc.stdout)解码时将因无法判断拼接后的数据到底是什么类型而出错如# BRIDGE.py from TSParseArxml import * print(json.dumps([1, 2, 3], ensure_asciiFalse), end) end必须为空字符串脚本执行结果x86 bridge: 0, [1, 2, 3],通过如下方式查看此时stdout的类型data json.loads(proc.stdout) print(f{data}, {type(data)})[1, 2, 3], class list完美通过在子进程中唯一print json字符串拿到的数据不再是str类型了而是从子进程中实实在在地拿到了对象即业务数据。但是在实际工程应用中中最好不要通过stdout返回业务数据因为通过print返回业务数据的方式本身就不专业也不规范。正确返回子进程业务数据的方法应该是子进程将想要返回的业务数据保存在外部json文件或pkl文件后者可以完美保存任何python对象中主进程通过读取这些外部文件获取子进程返回的业务数据。
返回列表