简介:DLL to C v2.42 是一款面向逆向分析、遗留代码维护与 C/C++ 开发人员的应用软件,主要用于在丢失 DLL 源代码时,将动态链接库转换为可编译的 C/C++ 工程代码。它可生成 DLL 的数据结构、拆卸代码段,并提供结构、完整、注释、精确等多种反汇编模式,还能以动态、静态或直接地址方式初始化导入地址表,帮助读者恢复关键逻辑并继续调试、移植或二次开发。资源包共 110 个文件,约 1.1MB,以 40 个 cpp 源文件、36 个 h 头文件为主体,配套 9 个 txt 说明、7 个 sln 解决方案与 7 个 vcproj 工程文件,另有 dll、rc、png、exe 等运行与展示素材,目录按示例工程组织,便于直接打开对照。已有 698 人学习下载,适合需要理解 DLL 结构、验证反汇编结果或补齐旧项目源码的开发者参考。
1. DLL 折进主程序的理由:一次“找不到模块”逼出来的方案
交付包少带一个 dll,主程序双击后就没有反应,事件日志里只有一句模块加载失败;更难受的情况是同名旧版本已经躺在系统目录里,程序能跑但行为异常。这类问题在 Windows 桌面交付里出现频率极高,DLL to C v2.42 就是针对这个场景的工具:它读取目标 DLL 的导出表,把导出函数重建为 C 源码加函数指针表,让主程序把这份能力直接编译进自己的二进制,运行期不再去磁盘上找那个 dll。
它的适用边界很明确:原生 C 接口、导出以__cdecl或__stdcall暴露、没有复杂 C++ 类与模板的 DLL。做上位机、工控采集、给老系统做二次封装的人最容易获益;托管 .NET 程序集、COM 组件、只导出DllGetClassObject那一套的库,强行转换只会增加问题。
判断标准其实只有一条:导出表干不干净。干净就直接转,不干净就先包一层 C 接口再转。
2. 摸清导出表再动手:三条 dumpbin 命令定基线
很多人拿到 dll 的第一反应是直接拖进转换器,生成一堆编译不过的桩代码,然后再回头找原因。问题多半不在工具,而在导出表本身就不适合转。转换器做的是机械映射:导出表里的名字、序号、调用约定是什么样,生成的头文件就是什么样,它不会帮你把 C++ 修饰名翻译成人能看懂的原型,也不会替你把间接依赖补齐。因此花十分钟做基线检查,比后面调两天崩溃划算得多。
基线检查就三件事:这个 DLL 是什么形态、它导出什么、它还依赖谁。三件事都能用 MSVC 工具链自带的 dumpbin 完成,不需要装任何第三方工具。
2.1 先分清四种 DLL:能不能转在第一步就定了
同样是.dll后缀,内部结构差得很远。我把常见形态列成一张表,后面所有判断都从这张表来。
| 形态 | 导出表特征 | 能否直接转 | 我一般怎么处理 |
|---|---|---|---|
| 纯 C 导出 | 名字干净,_Add或_Add@8,无?前缀 | 可以 | 直接进转换器,按 2.2 校一遍签名 |
| C++ 导出 | 名字带?Add@@YAHHH@Z这类修饰 | 部分可以 | 只转自由函数,类成员在包装层改写成 C 接口 |
| COM 组件 | 通常只导出DllGetClassObject、DllCanUnloadNow等四个 | 不建议 | 保留注册流程,或在外面套一个 C 包装 DLL 再转 |
| 托管程序集 | 没有原生导出表 | 不行 | 走宿主接口加载,硬转没有意义 |
第一类最省事。导出名不带修饰或只带@N后缀,说明是__cdecl或__stdcall的自由函数,参数是基础类型或简单结构体,转换器生成的函数指针 typedef 基本可以直接用。
第二类要小心。C++ 编译器会对函数名做修饰,把命名空间、类名、参数类型都编进符号里,?开头的就是。转换器通常只能识别出“这是一个函数”,但给不出正确的参数类型,生成的桩要么参数个数不对,要么全按void*处理。我的做法是先看导出表里有没有非?开头的符号:有,说明库作者自己做了extern "C"导出,只转那部分;没有,就在自己这侧写一层 C 包装,把类实例藏在void*里,包装层编译成 DLL 之后再交给转换器。
第三类容易误判。COM 组件的导出表很短,看起来“很好转”,但它的能力全部通过QueryInterface在运行期暴露,导出表里根本没有业务函数。转了也只能得到四个加载函数,没有实际价值。这类组件该做的是保证注册表项和 DLL 路径正确,不是塞进主程序。
第四类直接排除。托管程序集在 PE 结构上没有原生导出,dumpbin 看/exports会是空的,不存在转换的基础。
2.2 三条命令:位数、导出、依赖
先开对应架构的命令行。32 位目标用 x86 的命令行,64 位目标用 x64 的,混用会看到误导性的输出。
:: 1) 确认目标位数,x86 与 x64 的 PE 头不一样,后面所有产物都要跟它对齐 dumpbin /headers demo.dll | findstr /i "machine" :: 2) 导出表落盘,后面拿它跟生成的头文件逐行比对 dumpbin /exports demo.dll > demo_exports.txt :: 3) 看它自己还依赖谁,间接依赖缺一个都可能跑不起来 dumpbin /dependents demo.dll第一条看machine那一行:x86是 32 位,x64是 64 位。这一步的意义在于,转换出来的 C 代码必须用相同位数的编译器编译,32 位 dll 生成的代码塞进 64 位 exe 里,链接阶段不会报错,运行期第一次调用就会崩,而且崩的位置往往在系统模块里,排查起来很费劲。
第二条产出的文件是后面所有工作的对照物。导出表每行是序号、RVA、名字三段。要特别注意只有序号没有名字的导出:这类函数在运行期只能靠序号取地址,转换器生成的接口通常也是按下标索引的,调用时不能写函数名。如果导出数量很多而你只需要其中几个,记下序号,第 3 章做导出筛选时用得上。
第三条看的是直接依赖。输出里除了系统 DLL,剩下那些第三方运行库就是要一起处理的。这里有个常见误解:以为把主 DLL 转进主程序就完事了。实际上主 DLL 在内部仍然会LoadLibrary它自己的依赖,那些依赖没跟着走,功能照样静默失败——函数返回错误码,但主程序不会崩,只是结果不对,这种问题最难查。
2.3 位数、运行库、路径:三个必须先对齐的前提
位数对齐。前面说过,这里再强调一次操作层面的细节:转换生成的 C 文件里如果内嵌了 DLL 映像字节数组,那个数组是按目标 DLL 的原样字节拷贝的,位数由原 DLL 决定,跟你用什么编译器编译无关。所以要么全 32 位,要么全 64 位,不要试图做“通用”版本。
运行库对齐。如果原 DLL 是/MD编译的,它内部依赖 CRT 的动态库;转成 C 代码之后,这部分依赖关系并不会消失,只是从“加载 dll”变成了“加载 CRT”。工程里如果用了/MT,两套 CRT 会同时存在于进程里,跨边界传递malloc出来的指针就会出问题。我的习惯是:转换生成的代码单独编成一个静态库,这个静态库跟原 DLL 保持同样的运行库设置,主工程怎么设不管,边界上只传 POD 结构体和基础类型。
路径对齐。转换之后,主程序不再从原路径加载那个 dll,但如果你保留了部分动态加载的路径,或者依赖里还有别的 dll,搜索顺序仍然生效。应用目录优先这一条在多数配置下成立,但系统目录里存在同名旧版本时,行为会很微妙。验证方法很土但有效:在干净环境里跑一遍,再把可疑的同名 dll 人为放进系统目录跑一遍,两次结果一致才算过关。
三件事做完,你手上应该有三份东西:位数结论、导出表文本、依赖清单。这三份东西在后面每一章都要用到,不要嫌麻烦跳过。
3. DLL to C v2.42 的转换流程:从一份 dll 到能编译的工程
基线清楚了,再进转换器。这一章按我实际的顺序走:先看它生成什么,再定三个关键选项,然后编译、验证第一次调用,最后接进既有工程。每一步都有它存在的理由,跳步的代价会在第 5 章以踩坑的形式还回来。
3.1 输入和生成物:先看清它吐出来什么
把 dll 作为输入喂进去,生成目录里出来的东西大致是四类:
| 生成物类型 | 作用 | 要不要手改 |
|---|---|---|
| 头文件 | 导出函数原型、函数指针 typedef、加载/卸载接口声明 | 不改,只在包装层引用 |
| C 源文件 | 导出表解析结果与桩函数实现 | 不改,随 dll 版本重新生成 |
| 映像数据 | 内存加载模式下内嵌的 DLL 字节数组 | 不改,体积大头在这里 |
| 示例工程 | 一个能直接编译的最小调用样例 | 参考,不直接用于生产 |
关键原则只有一句:生成物当成只读。转换器是按导出表机械生成的,下次换个 dll 版本重新跑一遍,整个目录会被覆盖。你在里面手写的任何补丁都会丢。所有适配逻辑放到自己的一层包装里,包装层只依赖生成的头文件,这样 dll 升级时只需要重新生成 + 重编,包装层不动。
生成的头文件里,函数指针 typedef 是最需要人工核对的部分。转换器能从导出名里读出调用约定和参数个数,但读不出参数的具体类型——指针它只能给void*,结构体它只能给不完整类型。所以头文件生成完,第一件事是拿 2.2 的导出表文本跟它逐行对,重点看三样:函数个数对不对、@N后缀跟参数总字节数对不对得上、有没有把数据导出(非函数)也当成函数生成了桩。
3.2 三个必须定的选项:导出筛选、调用约定、加载方式
选项面板里可调的项不少,真正影响成品质量的只有三个。
导出筛选。默认是全量导出,一个不落。实际项目里通常只用得到其中一小部分,剩下的调试接口、内部工具函数、甚至测试用的后门,全部转进来只会增加体积和风险。做法是拿 2.2 的导出表,把要用的序号列出来,填进筛选列表。筛完之后重新生成的函数指针表会变小,头文件也干净,后面写调用代码时不容易选错。
调用约定。这一项选错是整个流程里代价最高的错误,因为它编译能过、链接能过,只在运行期崩,而且崩得毫无规律。判断依据在导出名里:_Add@8是__stdcall,_Add是__cdecl,x64 平台统一约定、没有这个区别。详细展开放在第 4 章,这里只需要记住:导出名带@数字结尾的,一律按__stdcall处理。
加载方式。两种模式,差别不小。
| 对比项 | 静态导入桩 | 内嵌映像 + 运行时加载 |
|---|---|---|
| 启动速度 | 快,随主程序一起映射 | 略慢,初始化时要做一次映像映射与重定位 |
| 磁盘产物 | 无额外文件 | 无额外文件 |
| 调试体验 | 好,符号正常 | 差,调用栈里全是匿名地址 |
| 被杀软关注 | 低 | 高,行为特征接近加壳程序 |
| 适用场景 | 内部工具、交付环境可控 | 需要彻底隐藏依赖、且能过安全审核 |
我的默认选择是静态导入桩。内嵌映像那套只有在明确要求“磁盘上不能出现任何相关文件”时才用,用了就要准备好面对第 5 章第 4 条那个问题。
3.3 编译并验证第一次调用
生成目录里通常带一个示例工程,先把它编过,再改成自己的调用。下面这段是最小验证代码,函数名按生成的头文件替换。
#include <stdio.h> #include "demo_c.h" /* 生成的头文件:函数指针 typedef 与加载接口 */ int main(void) { /* 1. 初始化:内部完成导出解析,返回 0 表示成功 */ if (demo_load() != 0) { printf("load failed\n"); return -1; } /* 2. 通过生成的函数指针调用,签名必须跟头文件一致 */ int rc = demo_Add(1, 2); printf("Add(1,2)=%d\n", rc); /* 3. 释放,避免映射或句柄残留 */ demo_unload(); return 0; }这段代码本身没什么技术含量,价值在于它把问题切成了三段,任何一段失败都能定位到具体环节。
demo_load()返回非 0,说明加载阶段就失败了,跟业务函数无关。常见原因是位数不匹配、依赖的某个 dll 缺失、或者内嵌映像重定位失败。这时候不要去看业务代码,直接回到 2.2 的依赖清单逐项确认。
调用返回了但结果不对,说明加载成功、签名不匹配。优先怀疑调用约定和参数类型:__stdcall写成__cdecl在 x86 上会导致栈失衡,表现是调用返回后程序崩在下一行;参数类型写错(比如把int写成long long)不会崩,但值会错位。
demo_unload()之后崩,说明卸载时还有线程在使用这个模块。这个问题在带后台线程的库上非常典型,解决办法见第 4 章 4.3。
编译这一关本身也可能出问题,报错形式通常是“无法解析的外部符号”。原因基本是生成的那个 C 源文件没加进工程,或者导出筛选把符号排掉了。检查工程文件列表,再检查筛选列表,两处都对就没问题了。
3.4 接进既有工程:头文件顺序和链接顺序
验证通过之后,把生成代码接进正式工程。推荐做成静态库,不要每个 exe 各自编一份,否则同一个符号会在不同模块里各有一份副本,状态不共享,出问题时现象很怪。
# 生成代码单独成库,运行库设置与原 DLL 保持一致 add_library(demo_c STATIC third_party/demo_c/demo_c.c ) # 头文件目录对外公开,包装层直接 include target_include_directories(demo_c PUBLIC third_party/demo_c) # 明确告诉包装层:这是静态链接用法,不是动态导入 target_compile_definitions(demo_c PUBLIC DEMO_C_STATIC=1) # 主程序只链接这个静态库,不再链接原始 .lib target_link_libraries(app PRIVATE demo_c)三个点值得说。
第一,add_library用STATIC而不是OBJECT,是为了让链接器有机会做符号去重,多个模块引用同一个函数指针表时不会重复定义。
第二,target_include_directories用PUBLIC,因为包装层的头文件里会引用生成的 typedef,引用关系会传递出去。用PRIVATE的话,上层模块 include 包装层头文件时会报找不到demo_c.h。
第三,原来工程里如果链接了原始的.lib导入库,要一并去掉。导入库和生成代码同时存在,链接器可能优先选导入库,结果还是走动态加载,转换等于白做。这是最容易漏的一步,链接选项里搜一下.lib名字确认干净。
接完编一次全量,不要增量编译。生成代码的头文件结构和原导入库差异很大,增量编译的依赖图可能没更新,会出现“明明改了却还是老报错”的假象。
4. 调用约定与数据边界:签名对不上时的四条排查路径
前三章解决的是“能不能生成、能不能编过”,这一章解决“编过了但跑不对”。这类问题的共同点是没有任何编译期提示,全靠运行期现象反推。我把排查顺序整理成四条路径,按命中率从高到低排。
4.1__cdecl与__stdcall:x86 上的栈平衡
x86 平台上这两种约定的区别只有一个:谁负责清栈。__cdecl由调用方清,__stdcall由被调方清。声明不一致时,两边都按自己的理解清栈,结果是栈指针偏移,函数返回后返回到一个错误地址。
典型的翻车现象是:单次调用看起来正常,连续调用几次之后崩,或者崩在完全无关的代码行上。因为栈指针偏了之后,局部变量、返回地址全部错位,崩溃点跟真正的错误点没有关系。这也是为什么这类问题常被称为玄学——现象和原因之间隔了好几层。
x64 平台统一了调用约定,这个坑基本不存在,所以同样的代码 32 位崩、64 位正常,基本可以确定是这个问题。
排查方法很直接:打开生成的导出表,看函数名后缀。_Add@8里的8是两个int参数的总字节数,对应__stdcall;_Add没有后缀,对应__cdecl。然后回头检查函数指针 typedef 有没有写对。
/* 错误写法:漏掉 __stdcall,x86 下每次调用都会让栈指针偏移 8 字节 */ typedef int (*PFN_ADD)(int a, int b); /* 正确写法:与导出表里的 _Add@8 一一对应 */ typedef int (__stdcall *PFN_ADD)(int a, int b);注意__stdcall的位置在星号和函数名之间,写在typedef外面不起作用。这个语法细节看起来琐碎,但确实是高频错误点。
还有一个变体:@N里的 N 可以用来反推参数总长度。比如_Init@12,12 字节可能是三个int,也可能是两个指针加一个int,这时候要结合原库的头文件或者文档确认。如果 N 跟你以为的参数列表对不上,多半是漏了一个隐藏参数,常见于返回结构体的函数——编译器会插入一个指向返回缓冲区的指针,这个指针也算进 N 里。
4.2 名称修饰:@N后缀与?前缀
除了调用约定,名字本身也携带信息。三种前缀对应三种情况。
_单下划线开头,是__cdecl的修饰规则。@单 at 开头,是__fastcall。?问号开头,是 C++ 修饰名,内容被编码过,包含作用域、参数类型、const 限定等。
转换器处理前两种没问题,遇到?开头的就只能生成一个占位桩。如果你的导出表里?开头的符号占多数,说明这个库没有做 C 接口导出,正确做法是在原库外面写一层extern "C"的包装 DLL,把类操作转成句柄加函数的形式,再拿包装 DLL 去转。直接转原库,生成的东西没法用。
extern "C"这一层在 C++ 工程里 include 生成的头文件时也要注意。生成的头文件是纯 C 的,如果被 C++ 源文件包含,需要包在extern "C" { }里面,否则链接器会按 C++ 规则去找修饰过的符号,找不到就报未解析。稳妥的写法是在生成头文件外面套一层:
// wrapper.h:给 C++ 调用方用的统一入口 #ifdef __cplusplus extern "C" { #endif #include "demo_c.h" // 纯 C 头文件,必须走 C 链接 #ifdef __cplusplus } #endif这层包装不增加运行开销,但能省掉大量“明明定义了却找不到符号”的排查时间。
4.3 结构体、回调与线程归属
前两条是函数层面的问题,这一条是数据层面的。
结构体对齐。原 DLL 的头文件里如果用#pragma pack(1)压过对齐,转换生成的代码是不知道的——它只看到导出函数,看不到类型定义。结果就是你按默认对齐传一个结构体进去,DLL 按紧凑对齐读,每个字段都错位。现象是数值全是乱的,但不崩。解决办法是在包装层用#pragma pack重新声明一份完全一致的结构体,并在编译期用static_assert(sizeof(T) == 预期字节数)卡住,改错了编不过。
回调函数。带回调的导出函数是最容易出问题的一类。三个要求:回调本身必须是__stdcall(x86 下),不能是 C++ 成员函数(成员函数有隐藏的 this 指针),不能是捕获了局部变量的 lambda。满足这三条,回调才能被 DLL 正确调用。
线程归属。回调通常在 DLL 内部的线程上执行,不在你调用它的那个线程。这意味着回调里不能直接操作 UI 控件,也不能假设某个线程局部变量已经初始化。稳妥做法是回调里只把数据拷进队列,然后 post 一个消息给主线程处理。这个设计在转换前后是一样的,转换只是把“动态加载的 DLL”换成“编进主程序的代码”,线程模型没变。
卸载顺序。demo_unload()之前必须确保所有回调都已返回、所有后台线程都已退出。常见做法是提供一个显式的Stop导出函数,调用它之后再 unload。如果原库没有这个函数,就在包装层加一个引用计数,所有回调入口先加计数、出口减计数,unload 前自旋等待计数归零,超时就放弃卸载——宁可泄漏一块内存,也不要在还有线程跑着的时候把代码段释放掉,那种崩溃没法定位。
5. 避坑记录:五条按现象、原因、解决走一遍的踩坑清单
下面五条都是实际碰到过的,按“现象 → 原因 → 解决”写。每一条的共同点是:第一次遇到时都以为是工具的问题,查完之后发现是流程里少了一步。
第一条:生成的工程编译报“无法解析的外部符号”,报的符号名跟导出表里一模一样。
现象:头文件里有声明,导出表里也有,就是链接不过。
原因:生成目录里那个 C 源文件没加进工程。转换器把头文件和源文件分开放,示例工程里两者都在,自己新建工程时容易只拷头文件。另一种可能是导出筛选把符号排掉了,筛选列表里填的是序号,序号填错一位就会漏掉一串函数。
解决:先看工程文件列表里有没有那个.c,再看筛选列表的序号是否跟导出表文本一致。两处都确认之后重新生成一次,不要手动改生成物。
第二条:编译链接全过,运行到第一次调用就崩,崩的位置在系统模块里。
现象:断点打在调用那一行,单步进去直接跳到未知地址,调用栈是断的。
原因:调用约定不匹配。x86 下__stdcall声明成__cdecl,被调方清了一次栈,调用方又清了一次,栈指针偏移,返回地址被覆盖。
解决:按 4.1 的方法,用导出名的@N后缀反推,把函数指针 typedef 补上__stdcall。批量排查可以写个小脚本,把导出表里所有带@的名字提取出来,跟头文件里的 typedef 列表比对,缺一个补一个。
第三条:程序能跑,大部分功能正常,个别功能静默失效,返回错误码但不崩。
现象:比如某个查询函数总是返回空结果,日志里没有任何异常。
原因:间接依赖缺失。转换只处理了主 DLL,主 DLL 内部还会加载它自己的依赖,那些依赖没跟着走。另外还有一种情况是 TLS 回调没有执行——有些库靠线程局部存储做初始化,静态导入桩模式下初始化时机跟动态加载不一样。
解决:回到 2.2 的依赖清单,把非系统 DLL 全部找齐,跟主程序放同一目录或者一起处理。TLS 的问题比较隐蔽,判断方法是看原库有没有TlsAlloc相关的导出或文档说明,有的话优先用内嵌映像模式,它对初始化的模拟更完整。
第四条:程序被杀软拦截或者报可疑行为,用户那边直接装不上。
现象:开发机上一切正常,交付到用户环境被拦,或者装完被隔离。
原因:内嵌映像 + 运行时加载这个组合,行为特征跟加壳程序高度相似——内存里出现一个完整的 PE 映像,没有对应的磁盘文件。安全软件按行为规则判断,不看你代码写得干不干净。
解决:换静态导入桩模式,牺牲一点“隐藏性”换可用性。如果确实必须用内嵌模式,那就走正规途径:给主程序做代码签名,提前跟用户侧的安全管理员报备,把行为说明写进交付文档。指望换个编译选项就绕过去,长期看是行不通的。
第五条:dll 升级重新生成之后,原有调用代码全部编译失败。
现象:新版本 dll 的导出表变了,可能是加了前缀,可能改了函数名,也可能把几个函数合并成一个。
原因:包装层直接依赖生成的头文件,生成物一变,包装层全断。
解决:在生成物和自己业务代码之间加一层稳定的包装接口,包装层内部的实现跟着生成物走,对外的函数名、参数、返回值固定不动。dll 升级时只改包装层内部,业务代码不用动。这一层看起来是额外工作量,但第二次升级的时候就能把成本赚回来。
6. 上线前的验证清单:把“能跑”变成“能回归”的六项检查
转换做完、功能跑通,很多人就直接打包交付了。我吃过这个亏:开发机上一切正常,到现场因为一个间接依赖缺失,功能静默失效,排查花了整整两天。从那以后,上线前这几项我一次不落。
| 验证项 | 做法 | 通过标准 |
|---|---|---|
| 导出覆盖率 | 拿 2.2 的导出表文本与生成头文件逐行比对 | 用到的函数一个不少,未用到的确认已筛选 |
| 位数一致性 | 在 32 位和 64 位两种环境各跑一次 | 只在匹配位数的环境正常,另一种明确报错而非崩溃 |
| 依赖缺失 | 在干净虚拟机里跑,不装任何运行库 | 缺少依赖时有明确提示,不是静默失败 |
| 重复加载卸载 | 循环 100 次 load/unload | 无句柄泄漏,第 100 次与第 1 次结果一致 |
| 并发调用 | 8 个线程各调同一函数 1 万次 | 无崩溃,结果与单线程一致 |
| 版本回归 | 用上一版 dll 生成的产物跑同一套用例 | 行为差异有记录,不是“看起来一样” |
第三项值得单独说。干净虚拟机里跑,目的不是验证“能不能跑”,而是验证“跑不了的时候能不能说清楚”。静默失败是最难处理的,因为现场人员只会告诉你“功能没反应”,你得远程连上去一点点查。如果程序在缺依赖时能打印一行明确的缺失模块名,整个沟通成本降一个量级。
第四项和第五项是压测,重点看两个东西:句柄数有没有持续增长、卸载后重新加载的行为是否一致。有些库第一次加载正常,第二次加载返回错误,原因是内部有全局状态没清干净。这种情况下宁可放弃卸载能力,进程退出时统一释放,也不要让用户触发第二次加载。
第六项容易被忽略。换 dll 版本重新生成之后,行为可能有细微差异,比如默认参数变了、边界条件处理变了。用同一套用例跑两版,把差异列出来,比事后被用户发现要好。
最后一件事是我自己的习惯:每次转换完,把导出表文本、生成物版本、编译选项、验证记录放在同一个目录里存档。半年后有人问“这个模块是怎么来的”,翻出来就能说清楚,不用靠回忆。转换这件事,做对一次不难,难的是三个月后还能复现出来。希望帮到你。
本文还有配套的精品资源,点击获取