之前试验了下移植 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!");
}
此时可以按下「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);
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;
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:
db "custom data"
_other_code:
pop eax
在 gcc 编译器套件中,可以利用该特性将资源“内嵌”到代码中:
因此获取表数据就可以利用内联汇编来获取:
__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"
" pop %eax \n"
" ret \n");
}
※ src/crc_table.bin
文件内容为 crc32_table
转存至小段序(Little-Endian)后储存的文件。
然后在用到它的函数进行一次初始化即可:
#include <cstdint>
#include <cstdio>
int main() {
const auto* crc_table = get_crc32_table();
printf("0x%08x", crc_table[1]);
}
能够正常输出对应的值 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() {};
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);
}
随后,在构建脚本底部加入新的编译过程:
易语言生成的机器码 (经过黑月处理) 大概是这样:
push 3
push 2
push 1
call 函数名称
函数名称:
push ebp
mov ebp, esp
mov eax, 0x9
jmp @f
@@:
mov esp, ebp
pop ebp
ret 0x0C
因此,如果我们需要一个能直接在易语言用的「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");
asm("leave");
asm("ret $0x0C");
};
重新编译,得到一份新的 crc32.bin.wav
文件。
面向 ShellCode 编程
「置入代码」是易语言提供的一个简单粗暴的内嵌「编译后的机器码」的方案,有点像内联汇编,但是你不能使用名称来引用变量等信息。
首先新建一个易语言程序,依次点击菜单「I.插入」→「R.资源」→「S.声音」,建立一个空白的「声音1」资源。
将名称更为「CRC32_代码」,双击该行右侧的「内容」列,选择「导入新声音(I)」,选择上一步编译得到的「crc32.bin.wav
」文件。
回到代码区段(程序集1),粘贴下述内容:
重新编译,然后导入易语言,测试一切正常。
其它
胶水代码写起来还是挺麻烦的。
如果要同时导出多个函数,可以考虑将入口的 __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 计算优化代码:
玩具:手动对齐置入代码
本来看编译器这么执着于将代码对齐,于是试了下… 但是发现性能并没有多大变化。
使用 TCC 编译,编译出来的是小巧的单文件。
原理很简单,找到程序里的代码区段然后将两个特征码包围的内容前后挪动(两个特征码合起来刚好 16 字节),使其实际代码为对齐 0x10 处开始。
e-naive-align 手动对齐置入代码 v2023.10.06.7z
(4.13 KB, 下载次数: 2)