红联Linux门户
Linux帮助

如何实现在Windows上运行Linux程序,附示例代码

发布时间:2017-05-16 10:05:35来源:linux网站作者:q303248153
微软在去年发布了Bash On Windows, 这项技术允许在Windows上运行Linux程序, 我相信已经有很多文章解释过Bash On Windows的原理,
而今天的这篇文章将会讲解如何自己实现一个简单的原生Linux程序运行器, 这个运行器在用户层实现, 原理和Bash On Windows不完全一样,比较接近Linux上的Wine.
示例程序完整的代码在github上,地址是 https://github.com/303248153/HelloElfLoader
 
初步了解ELF格式
首先让我们先了解什么是原生Linux程序
In computing, the Executable and Linkable Format (ELF, formerly named Extensible Linking Format), is a common standard file format for executable files, object code, shared libraries, and core dumps. First published in the specification for the application binary interface (ABI) of the Unix operating system version named System V Release 4 (SVR4),[2] and later in the Tool Interface Standard,[1] it was quickly accepted among different vendors of Unix systems. In 1999, it was chosen as the standard binary file format for Unix and Unix-like systems on x86 processors by the 86open project.
By design, ELF is flexible, extensible, and cross-platform, not bound to any given central processing unit (CPU) or instruction set architecture. This has allowed it to be adopted by many different operating systems on many different hardware platforms.
Linux的可执行文件格式采用了ELF格式, 而Windows采用了PE格式, 也就是我们经常使用的exe文件的格式.
ELF格式的结构如下
如何实现在Windows上运行Linux程序,附示例代码
大致上可以分为这些部分
ELF头,在文件的最开头,储存了类型和版本等信息
程序头, 供程序运行时解释器(interpreter)使用
节头, 供程序编译时链接器(linker)使用, 运行时不需要读节头
节内容, 不同的节作用都不一样
.text 代码节,保存了主要的程序代码
.rodata 保存了只读的数据,例如字符串(const char*)
.data 保存了可读写的数据,例如全局变量
还有其他各种各样的节
 
让我们来实际看一下Linux可执行程序的样子
以下的编译环境是Ubuntu 16.04 x64 + gcc 5.4.0, 编译环境不一样可能会得出不同的结果
首先创建hello.c,写入以下的代码
#include <stdio.h>
int max(int x, int y) {
return x > y ? x : y;
}
int main() {
printf("max is %d\n", max(123, 321));
printf("test many arguments %d %d %d %s %s %s %s %s %s\n", 1, 2, 3, "a", "b", "c", "d", "e", "f");
return 100;
}
然后使用gcc编译这份代码
gcc hello.c
编译完成后你可以看到hello.c旁边多了一个a.out, 这就是linux的可执行文件了, 现在可以在linux上运行它
./a.out
你可以看到以下输出
max is 321
test many arguments 1 2 3 a b c d e f
我们来看看a.out包含了什么,解析ELF文件可以使用readelf命令
readelf -a ./a.out
可以看到输出了以下的信息
如何实现在Windows上运行Linux程序,附示例代码
从上面的信息中我们可以知道这个文件的类型是ELF64, 也就是64位的可执行程序, 并且有9个程序头和31个节头, 各个节的作用大家可以在网上找到资料, 这篇文章中只涉及到以下的节
.init 程序初始化的代码
.rela.dyn 需要重定位的变量列表
.rela.plt 需要重定位的函数列表
.plt 调用动态链接函数的代码
.text 保存了主要的程序代码
.init 保存了程序的初始化代码, 用于初始化全局变量等
.fini 保存了程序的终止代码, 用于析构全局变量等
.rodata 保存了只读的数据,例如字符串(const char*)
.data 保存了可读写的数据,例如全局变量
.dynsym 动态链接的符号表
.dynstr 动态链接的符号名称字符串
.dynamic 动态链接所需要的信息,供程序运行时使用(不需要访问节头)
 
什么是动态链接
上面的程序中调用了printf函数, 然而这个函数的实现并不在./a.out中, 那么printf函数在哪里, 又是怎么被调用的?
printf函数的实现在glibc库中, 也就是/lib/x86_64-linux-gnu/libc.so.6中, 在执行./a.out的时候会在glibc库中找到这个函数并进行调用, 我们来看看这段代码
执行以下命令反编译./a.out
objdump -c -S ./a.out
我们可以看到以下的代码
如何实现在Windows上运行Linux程序,附示例代码
在这一段代码中,我们可以看到调用printf会首先调用0x400400的printf@plt
printf@plt会负责在运行时找到实际的printf函数并跳转到该函数
在这里实际的printf函数会保存在0x400406 + 0x200c12 = 0x601018中
需要注意的是0x601018一开始并不会指向实际的printf函数,而是会指向0x400406, 为什么会这样? 因为Linux的可执行程序为了考虑性能,不会在一开始就解决所有动态连接的函数,而是选择了延迟解决.
在上面第一次jmpq *0x200c12(%rip)会跳转到下一条指令0x400406, 又会继续跳转到0x4003f0, 再跳转到0x601010指向的地址, 0x601010指向的地址就是延迟解决的实现, 第一次延迟解决成功后, 0x601018就会指向实际的printf, 以后调用就会直接跳转到实际的printf上.
 
程序入口点
Linux程序运行首先会从_start函数开始, 上面readelf中的入口点地址0x400430就是_start函数的地址
如何实现在Windows上运行Linux程序,附示例代码
接下来_start函数会调用__libc_start_main函数, __libc_start_main是libc库中定义的初始化函数, 负责初始化全局变量和调用main函数等工作.
__libc_start_main函数还负责设置返回值和退出进程, 可以看到上面调用__libc_start_main后的指令是hlt, 这个指令永远不会被执行.
 
实现Linux程序运行器
在拥有以上的知识后我们可以先构想以下的运行器需要做什么.
因为x64的Windows和Linux程序使用的cpu指令集都是一样的,我们可以直接执行汇编而不需要一个指令模拟器,
而且这次我打算在用户层实现, 所以不能像Bash On Windows一样模拟syscall, 这个运行器会像下图一样模拟libc库的函数
如何实现在Windows上运行Linux程序,附示例代码
这样运行器需要做的事情有:
解析ELF文件
加载程序代码到指定的内存地址
加载数据到指定的内存地址
提供动态链接的函数实现
执行加载的程序代码
这些工作会在以下的示例程序中一一实现, 完整的源代码可以看文章顶部的链接
首先我们需要把ELF文件格式对应的代码从binutils中复制过来, 它包含了ELF头, 程序头和相关的数据结构, 里面用unsigned char[]是为了防止alignment, 这样结构体可以直接从文件内容中转换过来
ELFDefine.h:
如何实现在Windows上运行Linux程序,附示例代码
接下来我们定义一个读取和执行ELF文件的类, 这个类会在初始化时把文件加载到fileStream_, execute函数会负责执行
HelloElfLoader.h:
#pragma once
#include <string>
#include <fstream>
namespace HelloElfLoader {
class Loader {
std::ifstream fileStream_;
public:
Loader(const std::string& path);
Loader(std::ifstream&& fileStream);
void execute();
};
}
构造函数如下, 也就是标准的c++打开文件的代码
HelloElfLoader.cpp:
Loader::Loader(const std::string& path) :
Loader(std::ifstream(path, std::ios::in | std::ios::binary)) {}
Loader::Loader(std::ifstream&& fileStream) :
fileStream_(std::move(fileStream)) {
if (!fileStream_) {
throw std::runtime_error("open file failed");
}
}
接下来将实现上面所说的步骤, 首先是解析ELF文件
如何实现在Windows上运行Linux程序,附示例代码
ELF文件的的开始部分就是ELF头,和Elf64_External_Ehdr结构体的结构相同, 我们可以读到Elf64_External_Ehdr结构体中,
然后ELF头包含了程序头和节头的偏移值, 我们可以预先获取到这些参数
节头在运行时不需要使用, 运行时需要遍历程序头
如何实现在Windows上运行Linux程序,附示例代码
还记得我们上面使用readelf读取到的信息吗?
如何实现在Windows上运行Linux程序,附示例代码
这里面类型是LOAD的头代表需要加载文件的内容到内存,
Offset是文件的偏移值, VirtAddr是虚拟内存地址, FileSiz是需要加载的文件大小, MemSiz是需要分配的内存大小, Flags是内存的访问权限,
这个示例不考虑访问权限(统一使用PAGE_EXECUTE_READWRITE).
这个程序有两个LOAD头, 第一个包含了代码和只读数据(.data, .init, .rodata等节的内容), 第二个包含了可写数据(.init_array, .fini_array等节的内容).
把LOAD头对应的内容加载到指定的内存地址后我们就完成了构想中的第2个第3个步骤, 现在代码和数据都在内存中了.
接下来我们还需要处理动态链接的函数, 处理所需的信息可以从DYNAMIC头得到
DYNAMIC头包含的信息有
如何实现在Windows上运行Linux程序,附示例代码
一个个看上面代码中涉及到的类型
DT_JMPREL: 重定位记录的开始地址, 指向.rela.plt节在内存中保存的地址
DT_PLTREL: 重定位记录的类型 RELA或RE, 这里是RELAL
DT_PLTRELSZ: 重定位记录的总大小, 这里是24 * 2 = 48
如何实现在Windows上运行Linux程序,附示例代码
DT_SYMTAB: 动态符号表的开始地址, 指向.dynsym节在内存中保存的地址
DT_STRTAB: 动态符号名称表的开始地址, 指向.dynstr节在内存中保存的地址
DT_STRSZ: 动态符号名称表的总大小
如何实现在Windows上运行Linux程序,附示例代码
在遍历完程序头以后, 我们可以知道有两个动态链接的函数需要重定位, 它们分别是__libc_start_main和printf, 其中__libc_start_main负责调用main函数
接下来让我们需要设置这些函数的地址
如何实现在Windows上运行Linux程序,附示例代码
上面的代码遍历了DT_JMPREL重定位记录, 并且在加载时设置了这些函数的地址,
其实应该通过延迟解决实现的, 但是这里为了简单就直接替换成最终的地址了.
上面获取函数实际地址的逻辑我写到了resolveLibraryFunc中,这个函数的实现在另外一个文件, 如下
如何实现在Windows上运行Linux程序,附示例代码
理解这段代码需要先了解什么是x86 calling conventions, 在汇编中传递函数参数的办法由很多种, 像cdecl是把所有参数都放在栈中从低到高排列, 而fastcall是把第一个参数放ecx, 第二个参数放edx, 其余参数放栈中.
我们需要模拟的64位Linux程序,它传参使用了System V AMD64 ABI标准, 先把参数按RDI, RSI, RDX, RCX, R8, R9的顺序设置,如果有再多参数就放在栈中.
而64位的Windows传参使用了Microsoft x64 calling convention标准, 先把参数按RCX, RDX, R8, R9的顺序设置,如果有再多参数就放在栈中, 除此之外还需要预留一个32字节的影子空间.
如果我们需要让Linux程序调用Windows程序中的函数, 需要对参数的顺序进行转换, 这就是上面的汇编代码所做的事情.
转换前的栈结构如下
[原返回地址 8bytes] [第七个参数] [第八个参数] ...
转换后的栈结构如下
[返回地址 8bytes] [影子空间 32 bytes] [第五个参数] [第六个参数] [第七个参数] ...
因为需要支持不定个数的参数, 上面的代码用了一个thread local变量来保存原返回地址, 这样的处理会影响性能, 如果函数的参数个数已知可以换成更高效的转换代码.
在设置好动态链接的函数地址后, 我们完成了构想中的第4步, 接下来就可以运行主程序了
// 获取入口点
std::uint64_t entryPointAddress = *reinterpret_cast<const std::uint64_t*>(elfHeader.e_entry);
void(*entryPointFunc)() = reinterpret_cast<void(*)()>(entryPointAddress);
std::cout << "entry point: " << entryPointFunc << std::endl;
std::cout << "====== finish loading elf ======" << std::endl;
// 执行主程序
// 会先调用__libc_start_main, 然后再调用main
// 调用__libc_start_main后的指令是hlt,所以必须在__libc_start_main中退出执行
entryPointFunc();
入口点的地址在ELF头中可以获取到,这个地址就是_start函数的地址, 我们把它转换成一个void()类型的函数指针再执行即可,
至此示例程序完成了构想中的所有功能.
执行效果如下图
如何实现在Windows上运行Linux程序,附示例代码
这份示例程序还有很多不足, 例如未支持32位Linux程序, 不支持加载其他Linux动态链接库(so), 不支持命令行参数等等.
而且这份示例程序和Bash On Windows的原理有所出入, 因为在用户层是无法模拟syscall.
我希望它可以让你对如何运行其他系统的可执行文件有一个初步的了解, 如果你希望更深入的了解如何模拟syscall, 可以查找rdmsr和wrmsr指令相关的资料.
 
本文永久更新地址:http://www.linuxdiyf.com/linux/30807.html