凯哥stack

细说windows dll加载,一个cygwin错误引发的思考

缘起

近期,在一些特殊的使用场景中,与cygwin有了不解之缘,有一天,一位小伙伴求助,cygwin运行一段时间经常出现如下这个错误:

1
2
1 [main] make 4472 child_info_fork::abort: C:\cygwin\bin\cygreadline7.dll: Loaded to different address: parent(0x450000) != child(0x5D0000)
make: Makefile:225: fork: Resource temporarily unavailable

发生场景,这个小伙伴近期在调试某款软件的编译(cygwin环境下),加大并发后,编译变得不稳定,经常在运行2-3周后出现上述错误。

分析过程

拿到问题后,第一步基本上是google找,因为很多问题大部分人都碰到过,站在巨人的肩膀上,解决问题快,从网上看到的解决方法大概有两种:

  1. cygwin do full rebase
  2. 重启机器

还有一些不靠谱的,建议修改dll的base addreess,这种做法是否可行,需要弄清楚问题原因方能下结论,而cygwin full rebase本意其实就是重新安装cygwin,说重启机器的,基本没有找到原因,大部分人说可以解决。

于是本着负责任的态度(装),要对这个问题一探究竟,看看到底发生了什么,废话少说先下一份cygwin代码,找到报错的地方:

1
2
3
4
5
6
7
8
9
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
36
37
38
39
40
/* Allocate space for a dll struct. */
dll *
dll_list::alloc (HINSTANCE h, per_process *p, dll_type type)
{
......
guard (true);
/* Already loaded? For linked DLLs, only compare the basenames. Linked
DLLs are loaded using just the basename and the default DLL search path.
The Windows loader picks up the first one it finds.
This also applies to cygwin1.dll and the main-executable (DLL_SELF).
When in_load_after_fork, dynamically loaded dll's are reloaded
using their parent's forkable_ntname, if available. */
dll *d = (type != DLL_LOAD) ? dlls.find_by_modname (modname) :
in_load_after_fork ? dlls.find_by_forkedntname (ntname) : dlls[ntname];
if (d)
{
/* We only get here in the forkee. */
if (d->handle != h)
fabort ("%W: Loaded to different address: parent(%p) != child(%p)",
ntname, d->handle, h);
/* If this DLL has been linked against, and the full path differs, try
to sanity check if this is the same DLL, just in another path. */
else if (type == DLL_LINK && wcscasecmp (ntname, d->ntname)
&& (d->p.data_start != p->data_start
|| d->p.data_start != p->data_start
|| d->p.bss_start != p->bss_start
|| d->p.bss_end != p->bss_end
|| d->p.ctors != p->ctors
|| d->p.dtors != p->dtors))
fabort ("\nLoaded different DLL with same basename in forked child,\n"
"parent loaded: %W\n"
" child loaded: %W\n"
"The DLLs differ, so it's not safe to run the forked child.\n"
"Make sure to remove the offending DLL before trying again.",
d->ntname, ntname);
d->p = p;
}
else
......
}

第一,先判断子进程加载的dll是否已经在父进程中存在了,如果存在,判断地址是否相同,不同则退出,相同则进一步判断dll的各个关键段地址是否相同,不同则说明父子进程加载的dll并不是同一个,退出
为什么要在fork子进程的时候后检测dll地址是否与父进程一致呢?这是一个历史问题,这里就不得不提unix(unix like)和windows在创建子进程的差异,众所周知,unix like的系统创建子进程通过fork机制,该机制简单的说就是完全复制父进程的所有内存数据,其中就包括已经加载的共享库的信息,因此子进程在创建的那个点上,与父进程的共享库加载完全一致,如果子进程不想要父进程的数据呢,通过执行execve覆盖当前进程数据。而windows呢,使用另外一个思路创建新进程使用CreateProcess,通过第一个参数pszImageName指定可执行程序文件,创建出来的子进程是全新的,类似与fork+execve,这两种方式的争论一直存在,后面可以专题讲解以下,这里不过多讨论,总之cygwin这种检查父子进程dll库地址是否一致的原因就在于fork这个接口的约束。

OK,既然windows创建进程的方式是这种,那应该大概率无法保证父子进程的dll库地址一致,那cygwin是如何保证大部分运行dll地址都能保证一致呢,这就进入深水区了,即windows如何加载一个dll共享库。

windows dll加载机制

参考微软的文档,对dll有很多篇幅的介绍,基本几个点如下:

  • dll是一种共享库,数据格式是windows PE格式(基于COFF的扩展),包含很多段,比如代码段、数据段、只读数据段、重定向段等等

  • 每个进程加载dll的时候会将dll映射到自己的虚拟地址空间

  • 每个dll有一个引用计数,当没有任何线程引用该dll后,dll被卸载

  • 多进程加载相同的dll库时,加载在相同的基地址,共享相同的物理地址,达到节省内存使用,并减少文件读写的开销,原文如下:

    1
    Multiple processes that load the same DLL at the same base address share a single copy of the DLL in physical memory. Doing this saves system memory and reduces swapping.

    看到这里有点不解,对于熟知linux动态库加载的人来说,相同共享库的代码段和只读数据段在多进程之间共享,依靠的是在内核中映射的物理地址相同,而windows这里描述的dll的base address到底是什么呢?带着这个问题又查了很多材料,进一步弄清楚,这个base address就是虚拟地址:

  • 维基百科上描述windows的dll使用固定地址(虚拟地址)

    1
    The code in a DLL is usually shared among all the processes that use the DLL; that is, they occupy a single place in physical memory, and do not take up space in the page file. Windows does not use position-independent code for its DLLs; instead the code undergoes relocation as it is loaded, fixing addresses for all its entry points at locations which are free in the memory space of the first process to load the DLL
  • PE文件optional header中记录的imageBase字段
    微软文档中描述ImageBase字段

  • 本地实测结果一致
    查看dll的ImageBase与运行时地址对比

为什么有如此奇怪的约束?与windows使用dll共享库的历史有关,历史上老的windows版本,在win95之前,windows的多进程之间没有地址空间隔离,所有进程运行在同一个地址空间,这样在多进程之间共用的共享库只有加载在同一个虚拟地址上,才能共享相同的物理内存空间,但是后期windows版本,在win95以后,每个进程有独立地址空间后,仍然沿用相同的虚拟地址,查了很多材料,从微软官方解释说,如果使用相同的虚拟地址,则可以提升dll加载的性能,比如不需要重新读取文件,不需要重新映射内存,不需要重新定位符号,但是从一个系统实现的直觉上感受,在windows内核中应该做了一些投机取巧的操作,虚拟内存肯定是要重新映射的,只不过可以拿先启动的进程的表过来,快速映射,地址一致可以减少查找时间,笔者猜测这种设计存在可能的原因有:

  • 不排除早期从单一地址空间向进程独立地址空间演进时,为了兼容老的应用,未重新设计,该机制一直沿用至今
  • 为了提升dll加载性能,内核中使用了一些投机取巧的实现,总之windows内核代码没有开源,我们只能基于这个实现进行猜测

关于ASLR,windows在vista版本以后宣传支持ASLR(Address space layout randomization),即地址空间随机化,但在使用上一直颇受争议,在linux上默认所有共享库都是地址无关的,因此在linux上是能ASLR是一个内核的特性,即只要创建进程的时候,内核通过随机地址加载共享库、栈空间、堆空间即可,而windows则需要特别注意,在exe/dll编译的时候必须加/DYNAMICBASE,并且不能strip relocations,还有一个非常有意思的实现是,当windows启动后ASLR为dll分配一个随机地址后,在重启之前,所有进程使用该dll的虚拟地址并不会发生变化,因此很多人质疑,windows从未真正使能ASLR。

当然cygwin的dll没有使能ASLR。

cygwin的处理

从上面可以看到,cygwin要保证fork接口的一致性,必然作如下的处理:

  • 关闭所有exe/dll的动态地址,即不使用/DYNAMICBASE,用来阻止windows在cygwin进程内是能ASLR
  • 为每个dll库规划imageBase(安装阶段),即dll的prefer address,告知windows,加载dll的时候按照此地址加载
  • 同时cygwin在/usr/share/doc/Cygwin/_autorebase.README中也提到,当自己增量增加一个库文件等情况需要一个rebase,这样cygwin会重新为dll规划地址
    cygwin安装包dll与安装后对比,可以看到dll的ImageBase调整过

OK,按照上述操作按理能保证fork不会出现问题,那么上面讨论的问题出现的破绽在哪?

根因

破绽就在于,微软说dll中的imageBase是prefer address,言外之意就是优先使用的地址,那就是非绝对地址,所以那什么情况下会出现不使用该地址的情况呢?总结以下大概是以下的情况:

  • 当前进程中该虚拟地址空间已经被占用
  • 多个dll之间出现地址冲突,包括地址相同,地址空间重叠
  • dll更新导致文件变大,占用更大空间,而原来分配的空间无法满足使用
    出现冲突后的处理方式也极为简单,即从动态的虚拟内存地址随机申请一个按照64K对齐的地址(windows对dll加载地址要求),然后重新加载该dll库(windows称之为relocate)。在这种情况下,本文中出现的cygreadline7.dll的地址被占用后,在父子进程都通过动态申请得到,即存在大概率地址不一致情况导致cygwin报错。

而cygreadline7.dll地址被占用的原因非常具有戏剧化,大家都知道,一般电脑中都装有杀毒软件,杀毒软件为了监控程序的合法性,一般会在运行程序上挂载一些dll用来检查程序的行为,问题就出现在这个杀毒软件的dll上,该dll默认使能了ASLR,开始ASLR为其分配了一个加载地址,该地址并没有和cygwin的dll存在冲突,在开始运行很好,直到某一天杀毒软件更新,同时该监控的dll也更新了,地址空间变大了,导致ASLR为该dll重新分配了加载地址,而新的地址正好与cygreadline7.dll的ImageBase冲突了,而杀毒软件的dll先于cygreadline7.dll加载,导致windows只能为cygreadline7.dll分配动态地址,最终导致父子动态地址不一致。

以上过程可以通过微软提供的ProcessMonitor工具抓取信息得到,该工具在windows上调试非常强大,可以看到各种信息,在出现问题后,正是借助与它才能得到问题完整的信息链条,微软做工具还是有一套。

手动修改dll地址冲突后的地址分配情况

解决方法

前面提到的2个方法中,先说rebase,rebase的本意是cygwin的重新安装,cygwin在安装时对cygwin自身的dll重新设置ImageBase,来保障cygwin范围内的dll加载地址不会冲突,而本文中提到的问题并非cygwin内部的dll之间出现冲突,而是cygwin与windows中其他dll存在地址冲突,因此rebase无法解决。rebase适用于针对cygwin自己的库地址冲突的情况,比如自己基于cygwin开发了一个dll与cygwin现有dll的地址冲突了,抑或cygwin新安装了库文件导致和现有库有冲突的情况,因此我的建议是按照重启的方式处理,因为从windows的ASLR机制看,重启后会为那些使能ASLR的dll重新随机分配地址,然而总有在运行更长或者更复杂的情况,在未来某一天还是会出现冲突的情况不可避免。

总结和建议

windows dll加载地址分配方法:

  • 对于未使能ASLR的dll(没有使用/DYNAMICBASE编译),windows优先使用ImageBase作为地址加载,如果该地址在进程空间中被占用,则动态申请地址重新加载,按照windows的做法,这种情况不能共用物理内存,会导致更大的内存占用
  • 对于使能了ASLR的dll,windows在该dll第一次被加载时全局分配地址,分配后,为了共用物理内存,后续进程使用相同的地址加载,除非冲突情况。

可能解决问题的方法和建议:

  • 使用64位cygwin,64位程序有更大的地址空间,更不容易造成地址冲突
  • 也许cygwin考虑将dll使能ASLR,这样其实windows的ASLR会为dll分配相对固定的地址,而且作为统一管理,也许会比固定地址更好些
  • 对出现这种问题先看问题的原因,如果是cygwin自身库冲突,建议使用rebase
  • 如果是其他windows中未使能ASLR的dll与cygwin冲突,建议rebase该dll库
  • 如果是cygwin以外的,使能了ASLR的dll与cygwin冲突,建议重启解决

本文涉及软件版本:
windows server 2012
cygwin 3.0.7 32bit

凯哥stack

著作权归作者所有,禁止转载


专题:

本文发表于 2020-04-09,最后修改于 2020-05-05。

本站永久域名kaige86.com,也可搜索「 凯哥stack 」找到我。

期待关注我的 ,查看最近的文章和动态。


上一篇 « 友情链接 下一篇 » 苹果和谷歌联手打造新冠接触者追踪应用?

推荐阅读

Big Image