本帖最后由 爱飞的猫 于 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] 纯文本查看 复制代码 ::::::::::::::::::::: [构建 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] 纯文本查看 复制代码 ' 启动函数调用
函数名称 (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] 纯文本查看 复制代码 .版本 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) ,即前三个参数通过 eax 、edx 和 ecx 传递,后续的参数再入栈,在三个或更少参数的情况时会会有一定机率让编译的代码变小。
回到 src/crc32.h 进行更改:
[Diff] 纯文本查看 复制代码 #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();
[Diff] 纯文本查看 复制代码 /////////////////////////////////////////////
__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, 下载次数: 12)
※ 压缩包中的 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)
|