吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 2874|回复: 11
收起左侧

[易语言 原创] 利用 MinGW-w64 (i686) 移植代码到易语言

  [复制链接]
爱飞的猫 发表于 2023-10-5 09:12
本帖最后由 爱飞的猫 于 2023-10-9 05:44 编辑

之前试验了下移植 AES 代码,感觉效果还行,就是还得穿插 fasm 来计算偏移什么的有点麻烦。

这次试了下行内汇编,可以一次编译得到想要的二进制文件。

这个方案适合的情况:

  • 需要简单粗暴的移植几个在易语言里面写起来比较繁琐的函数/算法;
  • 降低系统资源 - 在不引用未编译函数的情况下不需要执行任何初始化的代码,数据均随二进制代码和易语言代码一同存储至 .text 只读可执行区段内。

这个方案不适合的情况:

  • 需要有大量查表数据、调用外部 API(虽然直接爬 PEB 写起来也不麻烦,脚手架写好后就能用了)的情况
  • 项目过大。太大的项目编译的字节码也不小,也比较难定位,这样还不如直接编译到 DLL 后引用了。
    • 如果只是想文件不落地,可以参考内存加载 DLL 的方案。

准备工作

1. 安装 MinGW-w64 (i686)

如果已经安装,使用你已经安装好的版本亦可 (如 MSYS2 环境)。

如果还没有,你需要安装一份【预编译的 MinGW-w64】。

本文使用的是 WinLibs 预构建 (GCC 13.2.0 + LLVM 16.0.6 + MinGW-w64 11.0.1 UCRT (release 2)),如果你需要更新版本可以查看其发布首页来获取。百度网盘存档

※ Windows 10 开始内置 UCRT 运行时。若系统低于该版本可以安装 UCRT 运行时,要求 Windows Vista SP2 或更高。

本文预设你的 mingw32 环境安装在目录 M:\mingw32。照着教程操作时请手动替换为你自己的安装路径,该目录下应有一个名为「i686-w64-mingw32」的子目录。

推荐带 llvm (clangd) 的完整版本 winlibs-i686-mcf-dwarf-gcc-13.2.0-llvm-16.0.6-mingw-w64ucrt-11.0.1-r2.7z
如果你不需要 clangd 支持也可以使用更小巧的 winlibs-i686-mcf-dwarf-gcc-13.2.0-mingw-w64ucrt-11.0.1-r2.7z

2. 准备 VS Code 并配置开发环境 (可选)

本文推荐使用 VS Code + clangd LSP 服务。

此处的子步骤可以根据自己需要跳过。

中文语言包

官方扩展商店有中文语言包可以安装。

按下 Ctrl + P,键入 ext install MS-CEINTL.vscode-language-pack-zh-hans 并回车,等待片刻后按照提示重启即可。

新建配置文件

强烈推荐建立一个干净的,用于 MinGW-w64 (i686) 的 C++ 环境,避免不同环境的插件干扰。

首先按下左下角的「齿轮」→「配置文件」→「创建配置文件…」。

配置文件名称随意填写,如 M32 - MinGW-w64 (i686),确保复制来源为「无」,然后点击创建。

以后要切换环境时,选择左下角的「齿轮」→「配置文件」→选择该配置环境。

安装/配置 clangd

按下 Ctrl + P,键入 ext install llvm-vs-code-extensions.vscode-clangd 并回车,等待安装完成。

按下「Ctrl + ,」打开设置:

  • 检索 clangd.path,将该值设定为 M:\mingw32\bin\clangd.exe
  • 检索 editor.formatOnSave,确保该项选中(保存时自动格式化代码)。
  • 检索 files.autoSave,推荐选择「onFocusChange」(切换焦点到终端时自动储存)。

测试编译环境

首先准备一个新的目录并使用 VSCode 打开。

点击左下角齿轮,确保「配置文件」菜单显示刚配置的「M32 - MinGW-w64 (i686)」。

左侧的「资源管理器」:

  • 建立文件 src/example_main.cpp
  • 建立文件 build.cmd

其中 build.cmd 的内容如下:

::::::::::::::::::::: [初始化环境] 开始
@pushd "%~dp0"

:: 指定 mingw32 目录
@set "MINGW32=M:\mingw32"
@set "PATH=%MINGW32%\i686-w64-mingw32\bin;%MINGW32%\bin;%PATH%"

:: 编译参数
@set "CC=gcc"
@set "CXX=g++"
@set "CXXFLAGS=-O2 -fPIC -Wall"
@set "LDFLAGS="

:: 确保 out 目录存在
@if not exist out @mkdir out
::::::::::::::::::::: [初始化环境] 结束

::::::::::::::::::::: 编译代码放在下面

:::::::::::: [测试文件] 开始
%CXX% %CXXFLAGS% -o out/example.exe src/example_main.cpp
:: 执行编译后的文件:
::   .\out\example.exe
:::::::::::: [测试文件] 结束

※ 你也可以自己手写一个 Makefile 代替该脚本。本文就不深入了。

其中 src/example_main.cpp 的内容如下:

#include <cstdio>

int main() {
  puts("Hello World!"); // 输出 "Hello World!"
}

此时可以按下「Ctrl + '」呼出终端,键入 .\build.cmd 开始构建。

构建完成后没有消息提示(如果发生错误会输出错误信息),执行 .\out\example.exe 可以看到可执行文件能够正常运行:

M:\demo\e-crc32>.\build.cmd

M:\demo\e-crc32> g++ -O2 -fPIC -Wall -o out/example.exe src/example_main.cpp

M:\demo\e-crc32>.\out\example.exe
Hello World!

魔改/移植代码

本次代码的移植对象是 CRC32

网络上已经有很多实现了,挑个 zlib@v0.9 里的实现

※ 使用这个古老版本因为没有加入太多「微优化」。

理解原始代码

里面的代码看着唬人,其实有用的没多少:

// 预先计算好的常数表
uLong crc_table[] = {
  0x00000000L, 0x77073096L, 0xee0e612cL, 0x990951baL, 0x076dc419L,
  // ... 省略 ...
  0x2d02ef8dL
};

// 宏 - 处理一次
#define DO1(buf) crc = crc_table[((int)crc ^ (*buf++)) & 0xff] ^ (crc >> 8);

// 函数本体
uLong crc32(crc, buf, len)
    uLong crc;
    Byte *buf;
    uInt len;
{
    if (buf == Z_NULL) return 0L;
    crc = crc ^ 0xffffffffL;
    while (len >= 8)
    {
      DO8(buf); // 等价于连续写 8 次 "DO1(buf)"。
                // 估计是在做什么优化。这一块可以忽略。
      len -= 8;
    }
    if (len) do {
      DO1(buf); // 展开这个宏就行。
    } while (--len);
    return crc ^ 0xffffffffL;
}

再将 crc32 稍微简化一下:

uint32_t crc32(uint32_t crc, const uint8_t* buf, size_t len) {
  if (buf == nullptr) return crc; // 我这里就改成返回传入的 CRC 值了。

  crc = ~crc;
  while (len--) {
    uint8_t idx = crc ^ *buf++;
    crc = crc_table[idx] ^ (crc >> 8);
  }
  crc = ~crc;

  return crc;
}

储存查表数据

通常来说,只读的静态数据会被编译器储存到 .rdata 区段中。但是我们的代码却在 .text

在 EXE 加载时,系统(EXE 装载代码)会根据 .reloc 区段的信息来进行「重定向」修正。

此时有一个问题 - 数据储存在不同的区段,如果需要手动修正这也太麻烦了。如果能直接将数据随代码储存在 .text 区段访问就好了…

※ 虽然可以用 char xxx[] = { ... } 的写法来内嵌,但是每次执行到该处都会初始化一次…

你可能已经发现了 - x86 并没有「取相对当前可执行地址偏移」的指令(x86-64 倒是有),但是却可以利用 CALL 指令来模拟:

  call _other_code ; 入栈下一个指令的地址 (_my_data)
                   ; 然后再跳转到目标函数 (_other_code)

_my_data:
  db "custom data"

_other_code:
  pop eax ; 此时 eax 为 _my_data 的地址,因此出栈就可以得到地址了。

在 gcc 编译器套件中,可以利用该特性将资源“内嵌”到代码中:

因此获取表数据就可以利用内联汇编来获取:

// 裸函数,不要 inline
__attribute__((naked, noinline)) const uint32_t *get_crc32_table() {
  asm("  call __exit_crc_table            \n"
      "  .incbin \"src/crc_table.bin\"    \n" // 引入资源文件
      "__exit_crc_table:                  \n" // "__exit_crc_table" 需要唯一性
      "  pop %eax                         \n"
      "  ret                              \n");
}

src/crc_table.bin 文件内容为 crc32_table 转存至小段序(Little-Endian)后储存的文件。

然后在用到它的函数进行一次初始化即可:

#include <cstdint>
#include <cstdio>

// ★★ 将上面的 get_crc32_table 放在此处

int main() {
  const auto* crc_table = get_crc32_table();
  printf("0x%08x", crc_table[1]); // 0x77073096
}

能够正常输出对应的值 0x77073096

获取代码数据

大多数情况下,函数出现在内存中的顺序和它们的实现在代码的位置是一致的。

因此我们可以通过在需要包含的所有函数前放置一个 __crc32_exp_start 和结尾放置一个 __crc32_exp_end 用来辅助定位:

文件 src/crc32.h

#pragma once
#include <cstdint>

void __crc32_exp_start();
uint32_t crc32(const uint8_t *buf, uint32_t len, uint32_t crc);
void __crc32_exp_end();

文件 src/crc32.cpp

#include "crc32.h"
#include <cstdint>

/////////////////////////////////////////////
void __crc32_exp_start() {};
/////////////////////////////////////////////

// ★★ 将上面的 get_crc32_table 放在此处

uint32_t crc32(const uint8_t *buf, uint32_t len, uint32_t crc) {
  if (!buf)
    return crc;

  const auto *crc_table = get_crc32_table();
  crc = ~crc;
  while (len--) {
    uint8_t idx = crc ^ *buf++;
    crc = crc_table[idx] ^ (crc >> 8);
  }
  crc = ~crc;

  return crc;
}

/////////////////////////////////////////////
void __crc32_exp_end() {}
/////////////////////////////////////////////

然后在 src/crc32_dump.cpp 加入在运行时提取对应机器码的逻辑:

#include "crc32.h"

#include <cassert>
#include <cstdint>
#include <fstream>

int main() {
  // 简单测试一下
  auto crc_52pojie = crc32((const uint8_t *)"52pojie", 7, 0);
  printf("crc32(52pojie) = %08x\n", crc_52pojie);
  assert(crc_52pojie == 0x78b5ff78);

  auto p_start = (char *)(__crc32_exp_start);
  auto p_end = (char *)(__crc32_exp_end);

  std::ofstream dump_bin("out/crc32.bin.wav", std::ios::binary);
  dump_bin.write(p_start, p_end - p_start);
  dump_bin.close();
  printf("dump size = %d bytes\n", p_end - p_start);
}

随后,在构建脚本底部加入新的编译过程:

[Plain Text] 纯文本查看 复制代码
1
2
3
4
5
6
::::::::::::::::::::: [构建 CRC32 与 dumper] 开始
%CXX% %CXXFLAGS% -c -o out/crc32.cpp.o src/crc32.cpp
%CXX% %CXXFLAGS% -c -o out/crc32_dump.cpp.o src/crc32_dump.cpp
%CXX% %LDFLAGS% -o out/crc32_dump.exe out/crc32.cpp.o out/crc32_dump.cpp.o
.\out\crc32_dump.exe
::::::::::::::::::::: [构建 CRC32 与 dumper] 结束

最后执行 .\build.cmd,将会编译并抽取这部分的代码到 out/crc32.bin.wav

添加引导

因为易语言的函数,大概长这样:

[Visual Basic] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
' 启动函数调用
函数名称 (1, 2, 3)
 
' -------------------------------
 
.子程序 函数名称, 整数型
.参数 参数1, 整数型
.参数 参数2, 整数型
.参数 参数3, 整数型
 
' 你的代码
返回 (9)

易语言生成的机器码 (经过黑月处理) 大概是这样:

  push 3
  push 2
  push 1
  call 函数名称    ; 标准 stdcall
  ; 省略

函数名称:
  push ebp
  mov ebp, esp     ; 即便这个函数没有储存变量,也会设置 ebp
                   ; <---  此处开始是我们编写的代码
  mov eax, 0x9
  jmp @f           ; "E9 00 00 00 00" 没啥用的长跳转
@@:
  mov esp, ebp
  pop ebp
  ret 0x0C         ; stdcall 调用返回

因此,如果我们需要一个能直接在易语言用的「ShellCode」,就得照着易语言的规则来。

回到 src/crc32.h,更新一下 __crc32_exp_start 的函数签名:

__attribute__((naked, noinline)) void __crc32_exp_start();

然后到 src/crc32.cpp 改写该函数:

/////////////////////////////////////////////
__attribute__((naked, noinline)) void __crc32_exp_start() {
  // 将易语言的参数重新入栈
  asm("mov 0x10(%ebp), %ecx \n");
  asm("mov 0x0C(%ebp), %edx \n");
  asm("mov 0x08(%ebp), %eax \n");
  asm("pushl %ecx");
  asm("pushl %edx");
  asm("pushl %eax");
  // 调用函数
  asm("call %0" ::"m"(crc32));
  asm("add $0xc, %esp"); // 默认是 cdecl 调用约定,调用方平衡堆栈。
  // 还原 ebp 并返回
  asm("leave");
  asm("ret $0x0C");
};
/////////////////////////////////////////////

重新编译,得到一份新的 crc32.bin.wav 文件。

面向 ShellCode 编程

「置入代码」是易语言提供的一个简单粗暴的内嵌「编译后的机器码」的方案,有点像内联汇编,但是你不能使用名称来引用变量等信息。

首先新建一个易语言程序,依次点击菜单「I.插入」→「R.资源」→「S.声音」,建立一个空白的「声音1」资源。

将名称更为「CRC32_代码」,双击该行右侧的「内容」列,选择「导入新声音(I)」,选择上一步编译得到的「crc32.bin.wav」文件。

回到代码区段(程序集1),粘贴下述内容:

[Visual Basic] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
.版本 2
 
开始测试()
 
.子程序 开始测试
.局部变量 crc, 整数型
 
' 模拟分段计算
crc = CRC32 (到字节集 (“Hello ”))
crc = CRC32 (到字节集 (“World”), crc)
检查 (crc = 1243066710)  ' 0x4a17b156
 
' 直接计算
crc = CRC32 (到字节集 (“52pojie”))
检查 (crc = 2025193336)  ' 0x78b5ff78
 
.子程序 Ab, 整数型, , 取字节集指针
.参数 变量, 字节集, 参考
 
置入代码 ({ 139, 69, 8, 139, 0, 131, 192, 8, 139, 229, 93, 194, 4, 0 })
返回 (-1)
 
.子程序 CRC32, 整数型, 公开, 可重复调用 (如文件分段计算)。后续调用需要提供上一次计算得到的值作为[初始值]。
.参数 内容, 字节集, 参考
.参数 初始值, 整数型, 可空, 若不指定或是初次调用,设为 [#CRC32_默认初始值]。
 
返回 (CRC32_高级 (Ab (内容), 取字节集长度 (内容), 初始值))
 
.子程序 CRC32_高级, 整数型, 公开, 可重复调用 (如文件分段计算)。后续调用需要提供上一次计算得到的值作为[初始值]。
.参数 缓冲区地址, 整数型
.参数 缓冲区长度, 整数型
.参数 初始值, 整数型, , 如果是第一次运行,初始值应为 [#CRC32_默认初始值]
 
置入代码 (#CRC32_代码)
返回 (-1)

此时在易语言 IDE 按下 F5 运行,底部的「输出」栏目应当输出下述内容:

* 测试: CRC32
* 完成,没有错误。

这表示我们移植的代码可以正常在易语言内运行。

优化输出

你可能已经发现之前代码提到的「默认是 cdecl 调用约定」。既然是默认的,那自然也可以使用其他的调用约定

其中一个比较特殊的是 regparm(3),即前三个参数通过 eaxedxecx 传递,后续的参数再入栈,在三个或更少参数的情况时会会有一定机率让编译的代码变小。

回到 src/crc32.h 进行更改:

[Diff] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
#include <cstdint>
 
+#ifndef __g_fastcall
+#define __g_fastcall __attribute__((regparm(3)))
+#endif
 
 __attribute__((naked, noinline)) void __crc32_exp_start();
-uint32_t crc32(const uint8_t *buf, uint32_t len, uint32_t crc);
+__g_fastcall uint32_t crc32(const uint8_t *buf, uint32_t len, uint32_t crc);
 void __crc32_exp_end();

也对 src/crc32.cpp 进行更改:

[Diff] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/////////////////////////////////////////////
 __attribute__((naked, noinline)) void __crc32_exp_start() {
-  // 将易语言的参数重新入栈
+  // 将易语言的参数导入过来
+  // params: eax, edx, ecx
   asm("mov 0x10(%ebp), %ecx \n");
   asm("mov 0x0C(%ebp), %edx \n");
   asm("mov 0x08(%ebp), %eax \n");
-  asm("pushl %ecx");
-  asm("pushl %edx");
-  asm("pushl %eax");
   // 调用函数
   asm("call %0" ::"m"(crc32));
-  asm("add $0xc, %esp"); // 默认是 cdecl 调用约定,调用方平衡堆栈。
   // 还原 ebp 并返回
   asm("leave");
   asm("ret $0x0C");
 };
 /////////////////////////////////////////////
 
@@: 下面一点继续更改~
 
-uint32_t crc32(const uint8_t *buf, uint32_t len, uint32_t crc) {
+// buf@eax, len@edx, crc@ecx
+__g_fastcall uint32_t crc32(const uint8_t *buf, uint32_t len, uint32_t crc) {
   if (!buf)

重新编译,然后导入易语言,测试一切正常。

其它

胶水代码写起来还是挺麻烦的。

如果要同时导出多个函数,可以考虑将入口的 __crc32_exp_start 添加一个参数(如参数序号),然后根据这个值将参数转发到对应的函数。

最终的代码存档:

e-CRC32 代码存档 2023.10.05-10.08.7z (41.83 KB, 下载次数: 13)

※ 压缩包中的 v2023.10.05 目录为跟着教程走的结果。其他两个目录为后续更新添加的内容,主要为 C++ 方面的性能优化。


2023.10.08 更新:

  • 加入 CRC32c 实现
    • 同时加入了同利用 SSE 4.2 加速指令的优化实现
  • 重构代码
  • 加入 crc32_tool.exe 计算工具和对应的源码

crc32_tool.exe 运算速度特别块,实测正己老师的第一课 第一节.模拟器环境搭建.mp4 能在 259ms 内计算完成,吞吐率约 23.028 Gbps (大概 2.8GBps)。

> (Measure-Command { .\crc32_tool.exe "M:\Test\test.mp4" | Out-Default }).TotalMilliseconds
M:\Test\test.mp4: 401AF35D
259.5613

这应该是全网用易语言实现的 CRC32 算法的最快的一个了

2023.10.07 更新:

  • 加入可选的优化
    • 查表模式可以选择从 1K 表 → 4K 表 的加速运算;
    • 加入 Hagai 的无码表加速模式(默认不使用)
  • 加入编译开关接口,附加 build.default.cmd 构建推荐构建版本,或 build.full.cmd 构建所有支持的模式(查表 4K)

2023.10.06 更新:

找到个基于 SSE 4.2 的 CRC32 计算优化代码:

// SSE4.2 + PCLMUL 实现修改自:
// https://chromium.googlesource.com/chromium/src/+/f9a8b512a90f410bb9302a3137855fb688316d3d/third_party/zlib/crc32_simd.c#24
/*
 * crc32_sse42_simd_(): compute the crc32 of the buffer, where the buffer
 * length must be at least 64, and a multiple of 16. Based on:
 *
 * "Fast CRC Computation for Generic Polynomials Using PCLMULQDQ Instruction"
 *  V. Gopal, E. Ozturk, et al., 2009, http://intel.ly/2ySEwL0
 */

玩具:手动对齐置入代码

本来看编译器这么执着于将代码对齐,于是试了下… 但是发现性能并没有多大变化。

使用 TCC 编译,编译出来的是小巧的单文件。

原理很简单,找到程序里的代码区段然后将两个特征码包围的内容前后挪动(两个特征码合起来刚好 16 字节),使其实际代码为对齐 0x10 处开始。

e-naive-align 手动对齐置入代码 v2023.10.06.7z (4.13 KB, 下载次数: 2)

免费评分

参与人数 7威望 +1 吾爱币 +30 热心值 +7 收起 理由
笙若 + 1 + 1 谢谢@Thanks!
苏紫方璇 + 1 + 20 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
DEATHTOUCH + 2 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
vqzhanshi + 1 + 1 谢谢@Thanks!
hrh123 + 1 + 1 用心讨论,共获提升!
bypasshwid + 3 + 1 涉猎真广,技术大拿!
wystudio + 2 + 1 用心讨论,共获提升!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

DEATHTOUCH 发表于 2023-10-9 00:19
爱飞的猫 发表于 2023-10-8 00:39
加上了,不过实测速度不如利用 `pclmulqdq` 和 SSE 4.2 的优化指令,能在更短的 cycle 时间内处理更多数 ...

这样来看你的那个crc32c_sse42_crc32_proc里面的
[C++] 纯文本查看 复制代码
1
2
3
4
5
6
while (len >= 4)
{
  crc = _mm_crc32_u32(crc, *(const uint32_t *)buf);
  len -= 4;
  buf += 4;
}

这部分就可以进一步提高吞吐量了,就是32位的可能写起来稍微麻烦了点吧。
 楼主| 爱飞的猫 发表于 2023-10-8 00:39
DEATHTOUCH 发表于 2023-10-5 13:05
可以考虑顺便再加一个Intel SSE4.2(应该也有15年了)的crc32,虽然多项式不一样,不过用来自校验速度更快 ...

加上了,不过实测速度不如利用 `pclmulqdq` 和 SSE 4.2 的优化指令,能在更短的 cycle 时间内处理更多数据(64 字节一组来计算)。

有个论文但我没读,直接扒的别人代码… CRC32c 部分应该还可以再优化下,如数据不足 64 字节时进行 16 字节块的处理。
wasm2023 发表于 2023-10-5 11:24
DEATHTOUCH 发表于 2023-10-5 13:05
可以考虑顺便再加一个Intel SSE4.2(应该也有15年了)的crc32,虽然多项式不一样,不过用来自校验速度更快,毕竟32位一次4字节。

点评

加上了,不过实测速度不如利用 `pclmulqdq` 和 SSE 4.2 的优化指令,能在更短的 cycle 时间内处理更多数据(64 字节一组来计算)。 有个论文但我没读,直接扒的别人代码… CRC32c 部分应该还可以再优化下,如数据  详情 回复 发表于 2023-10-8 00:39
这个过段时间再试,到时候另外整个函数计算。 我倒是找到个利用 SSE 4.2 和 PCLMULQDQ 指令的优化版,一会更新…  详情 回复 发表于 2023-10-6 12:19
满不懂 发表于 2023-10-5 14:50
谢谢分享!收藏学习。
 楼主| 爱飞的猫 发表于 2023-10-6 12:19
本帖最后由 爱飞的猫 于 2023-10-6 12:28 编辑
DEATHTOUCH 发表于 2023-10-5 13:05
可以考虑顺便再加一个Intel SSE4.2(应该也有15年了)的crc32,虽然多项式不一样,不过用来自校验速度更快 ...

这个过段时间再试,到时候另外整个函数计算。

我倒是找到个利用 SSE 4.2 和 PCLMULQDQ 指令的优化版,计算结果是一致的。
龙小 发表于 2023-10-12 17:26
好东西,收藏看看
Nevatu 发表于 2023-10-20 15:44
感谢分享!!!
pythonkylinos 发表于 2023-11-23 18:37
这个MinGW QT 的程序逆向是什么思路?

点评

文章里没有提到 QT?  详情 回复 发表于 2023-11-24 05:27
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2025-5-22 03:45

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表