5. 链接器优化
线程局部储存访问模式,就使用的方式而言,是分层的。最通用的模式是常规动态模式,它可以随处使用。当生成执行映像本身时,初始可执行模式可以无条件使用。如果一个共享对象不是要动态载入的,它也可以使用。这两个模式已经定义了一个层次。余下两个模式,如果定义是与引用在同一个模块中,是对更通用模式之一的特殊优化。访问模式之间的层次及转换可以图形化地显示如下 [1] :
[1] 这个图一开始是由 Mike Walker 制作的
这个图显示了访问线程局部变量的一个代码序列可以(或不可以)被编译器及链接器优化。实线表示从任意位置出发的默认路径。缺省方式总是不改变代码。优化由虚线表示。
优化可以来自五个原因:
程序员告诉编译器为一个执行映像产生代码,而不是共享对象。
程序员告诉编译器产生的代码不会直接访问动态加载的变量(使用 dlsym 就可以了)。
编译器认识到一个线程局部变量是受保护的。即,引用与定义在同一个模块中。
链接器知道创建的是一个执行映像还是共享对象。执行映像(类型 ET_EXEC )遵从第一个分类,像位置无关执行映像( Position Independent Executable )那样。
链接器知道,在执行映像代码中,对线程局部变量的引用,是否无条件地,被出现在执行映像中的定义所满足。定义不需要受保护,因为执行映像总是符号查找路径上出现的第一个对象。
在对架构的访问模式的描述中,我们已经解释了使用这些模式的先决条件。在下面的章节里,我们详细解释代码放宽如何发生。这不是无足轻重的,因为在代码序列中所使用的指令之间没有 1 : 1 的关系,我们必须处理这些差异。
在这个文档目前所定义的架构中,只有 IA-32 , SPARC , x86-64 , Alpha 及 SH 定义了链接器优化。为 IA-64 做这个优化,至少可以说,非常困难。为 IA-64 产生的代码,理想地,要把代码序列中的代码束( bundle )尽可能地分开,以提高并行度。但这意味着定位归属在一起的指令就是要做的一切,但琐碎。即便使用重定位标记指令也不能奏效,因为多个代码序列可以被合并起来,一次载入或保存多个线程局部变量。只有非常复杂的流分析( flow analysis )可以找出单个代码序列( individual code sequences ),而目前还没有这样的计划。
定义了优化的架构要求编译器按要求发布代码。这,连同标记指令的重定位,将允许链接器识别代码序列。小的改动像使用不同的寄存器,可以很容易地被屏蔽掉。代码序列如何被识别的细节将不在这里讨论。我们假定链接器有这个能力,集中关注这里完成的实际工作。
5.1. IA-32 链接器优化
这个链接器可以执行 4 种不同的优化,它们通过减少运行时重定位及从内存载入,来节省执行时间。图只显示了 3 个转换,不过初始可执行到局部可执行的转换是可以执行的。因为 Sun 及 GNU 版本的代码序列不相同,这里我们需要分别讨论它们。
概括某些优化的副作用。如果原始代码使用常规动态或局部动态访问模式,使用函数 __tls_get_addr 来访问变量。如果只是使用了这两个模式,这意味着 TLS 块的分配可以被推迟,就像前面章节解释的那样。如果链接器执行优化,对 TLS 块不使用 __tls_get_addr 的访问,就有机会最终分配,静态模式自动启用并且必须设置 DF_STATIC_TLS 标记的内存。这通常不是个障碍,因为对静态 TLS 块的访问是频繁的,而对动态加载代码,延迟分配非常有用。
常规动态到初始可执行
链接器能执行的最重要的放宽,可能是从常规动态到初始可执行模式。常规动态模式是运行时代价最高的,因此应该尽量避免。首先,我们处理 Sun 版本。
GD à IE 代码转换 | 初始重定位 符号 |
0x00 leal x@dtlndx (%ebx), %edx 0x06 pushl %edx 0x07 call x@TLSPLT 0x0c popl %edx 0x0d nop ⇓ 0x00 movl x@tpoff (%ebx), %edx 0x06 movl %gs:0, %eax 0x0c subl %edx, %edx | R_386_TLS_GD_32 x R_386_TLS_GD_PUSH x R_386_TLS_GD_CALL x R_386_TLS_GD_POP x
⇓ ⇓ R_386_TLS_IE_32 x |
GOT [n] | 未解决的重定位 R_386_TLS_TPOFF32 x |
每当创建一个可执行映像时,就可以执行这个优化。这个优化对于 GNU 版本是类似的。
GD à IE 代码转换 | 初始重定位 符号 |
0x00 leal x@tlsgd(, %ebx, 1), %eax 0x07 call __tls_get_addr@plt ⇓ 0x00 movl %gs:0, %eax 0x06 addl x@gotntpoff (%ebx), %eax | R_386_TLS_GD x R_386_PLT32 __tls_get_addr ⇓ ⇓
R_386_TLS_GOTIE x |
GOT [n] | 未解决的重定位 R_386_TLS_TPOFF x |
现在可以清楚了,为什么两个版本的常规动态模式的代码序列都比需要的要长。在 Sun 的案例中的 nop ,及 GNU 版本中的 SIB 形式都是需要的,为 IE 代码序列留下余地。
常规动态到局部可执行
ELF 的符号查找规则定义,如果一个在可执行映像中用到的符号,被定义在这个可执行映像中,它总是会被选上的。原因是这个可执行映像总是在查找域链表的头部。因此,常规动态到局部动态模式也是相当常见的,这个模式甚至比到初始可执行模式还要廉价。
GD à LE 代码转换 | 初始重定位 符号 |
0x00 leal x@dtlndx (%ebx), %edx 0x06 pushl %edx 0x07 call x@TLSPLT 0x0c popl %edx 0x0d nop ⇓ 0x00 movl x@tpoff (%ebx), %edx 0x05 nop 0x06 movl %gs:0, %eax 0x0c subl %edx, %edx | R_386_TLS_GD_32 x R_386_TLS_GD_PUSH x R_386_TLS_GD_CALL x R_386_TLS_GD_POP x
⇓ ⇓ R_386_TLS_LE_32 x |
| 未解决的重定位 |
对于 Sun 版本,这个优化减少了一条指令,并使用一个内存载入及一个算术运算替代了函数调用。 GNU 的版本同样给力:
GD à LE 代码转换 | 初始重定位 符号 |
0x00 leal x@tlsgd(, %ebx, 1), %eax 0x07 call __tls_get_addr@plt ⇓ 0x00 movl %gs:0, %eax 0x06 addl x@ntpoff, %eax | R_386_TLS_GD x R_386_PLT32 __tls_get_addr ⇓ ⇓
R_386_TLS_LE x |
| 未解决的重定位 |
请注意在替代的代码中 movl 指令的长度。它假定使用了 mod R/M 个字节。
局部动态到局部可执行
如果用户没有告诉编译器,代码是用于一个可执行映像的目的,链接器仍然存在优化代码的可能,不过就像下面所见的,结果不是很理想。
LD à LE 代码转换 | 初始重定位 符号 |
0x00 leal x1@tmdnx ( %ebx), %edx 0x06 pushl %edx 0x07 call x1@TLSPLT 0x0c popl %edx … 0x10 movl $x1@dtpoff, %edx 0x15 addl %eax, %edx ⇓ 0x00 movl %gs:0, %eax 0x06 nop 0x07 nop 0x08 nop 0x09 nop 0x0a nop 0x0b nop 0x0c nop … 0x10 movl $x1@tpoff, %eax 0x15 addl %eax, %edx | R_386_TLS_LDM_32 x1 R_386_TLS_LDM_PUSH x1 R_386_TLS_LDM_CALL x1 R_386_TLS_LDM_POP x1
R_386_TLS_LDO_32 x1
⇓
R_386_TLS_LE_32 x1 |
| 未解决的重定位 |
一长串的 nop 是为局部动态模式所产生的大的代码序列导致的。在这里它无法避免。只有程序员告诉编译器,代码是用于可执行映像的,才可以避免它。这里描述的来自 Sun 提供的文献。 GNU 版本有同样的问题,不过解决了它,以减小对运行时性能影响。
LD à LE 代码转换 | 初始重定位 符号 |
0x00 leal x@tlsldm (%ebx), %eax 0x07 call __tls_get_addr@plt … 0x10 leal x@dtpoff (%eax), %edx ⇓ 0x00 movl %gs:0, %eax 0x06 nop 0x07 leal 0x0 (%esi, 1), %esi … 0x10 leal x@ntpoff (%eax), %edx | R_386_TLS_LDM x1 R_386_PLT32 __tls_get_addr
R_386_TLS_LDO_32 x1 ⇓ ⇓
R_386_TLS_LE x1 |
| 未解决的重定位 |
在地址 0x07 的指令需要一些解释。它可能看上去像某些做很多事情代价比较高的指令,但事实上它是一个 no-op 。寄存器 %esi 的值,在乘以 1 、加上 0 后,保存回相同的寄存器。选择这条指令的原因是,它是长指令,确切地 4 字节。这意味着填满 5 字节的空洞,我们只需要再多一条 nop 指令。这比使用 7 条 nop 指令(类似 Sun 的做法)要划算得多。
在局部动态模式不是计算地址,而是直接载入或保存变量的情况下,转换后的代码也同样简单地载入或保存。这个转换是简单的,就像上面代码所显示的:通过把重定位 R_386_TLS_LDO_32 改成 R_386_TLS_LE-32 实现 -x1@tpoff (%eax) ,并使用它替换 x1@dtpoff (%eax) 。
初始可执行到局部可执行
如果代码被编译只在一个可执行映像中使用,并且变量在这个可执行映像中有效( available ),这个最后的优化帮助榨出最后一分性能。这个转换,与局部动态到局部可执行的转换比较,远没有那么挥霍。
IE à LE 代码转换 | 初始重定位 符号 |
0x00 movl x@tpoff (%ebx), %edx 0x06 movl %gs:0, %eax 0x0c subl %edx, %edx ⇓ 0x00 movl $x@tpoff, %edx 0x05 nop 0x06 movl %gs:0, %eax 0x0c subl %edx, %eax | R_386_TLS_IE_32 x
⇓ R_386_TLS_LE_32 x |
| 未解决的重定位 |
这个优化节省了一个运行时重定位,把一个内存载入转换为载入一个立即数,不过也加入了一个新指令。这个指令是 nop ,它不会对执行带来什么影响。 GNU 版本则不需要那么难看。
IE à LE 代码转换 | 初始重定位 符号 |
0x00 movl %gs:0, %eax 0x06 addl x@gotntpoff (%ebx), %eax ⇓ 0x00 movl %gs:0, %eax 0x06 leal x@ntpoff (%eax), %eax |
R_386_TLS_GOTIE x ⇓ ⇓
R_386_TLS_LE x |
| 未解决的重定位 |
5.2. SPARC 链接器优化
因为用于 SPARC 的模式基本上等同于 IA-32 ,毫不奇怪,这里同样有相同的 4 种优化。一般而言,归究于 SPARC 处理器的 RISC 指令集,这里的优化要清晰一些,不像 IA-32 不统一的 CISC 指令长度。
常规动态到初始可执行
这个优化设法去掉一个运行时重定位,以及对 __tls_get_addr 函数的调用。但是用于静态 TLS 块的内存分配不能推迟了,并且必须设置 DF_STATIC_TLS 标记。
GD à IE 代码转换 | 初始重定位 符号 |
0x00 sethi %hi (@dtlndx (x)), %o0 0x04 add %o0, %lo (@dtlndx (x)), %o0 0x08 add %l7, %o0, %o0 0x0c call __tls_get_addr@plt ⇓ 0x00 sethi %hi (@tpoff (x)), %o0 0x04 or %o0, %lo (@tpoff (x)), %o0 0x08 ld [%l7+%o0], %o0 0x0c add %g7, %o0, %o0 | R_SPARC_TLS_GD_HI22 x R_SPARC_TLS_GD_LO10 x R_SPARC_TLS_GD_ADD x R_SPARC_TLS_GD_CALL x ⇓ ⇓ R_SPARC_TLS_IE_HI22 x R_SPARC_TLS_IE_LO10 x |
GOT [n] | 未解决的重定位 R_SPARC_TLS_DTPOFF32 x |
在这里我们不不列出 64 位版本。其中的区别与 4.3.3 节描述的一样。实际用于 GOT 指针的寄存器(上面代码中是 %l7 )可以不同。链接器将,从标记着 R_SPARC_TLS_GD_ADD 的指令,了解使用的寄存器。
常规动态到局部可执行
这个优化亦很直观,常规动态模式的指令为局部可执行模式的指令所替代。唯一需要记住的事是,使用一个 nop 填充短的局部可执行代码。
GD à LE 代码转换 | 初始重定位 符号 |
0x00 sethi %hi (@dtlndx (x)), %o0 0x04 add %o0, %lo (@dtlndx (x)), %o0 0x08 add %l7, %o0, %o0 0x0c call __tls_get_addr@plt ⇓ 0x00 sethi %hi (@tpoff (x)), %o0 0x04 xor %o0, %lox (@tpoff (x)), %o0 0x08 add %g7, %o0, %o0 0x0c nop | R_SPARC_TLS_GD_HI22 x R_SPARC_TLS_GD_LO10 x R_SPARC_TLS_GD_ADD x R_SPARC_TLS_GD_CALL x ⇓ ⇓ R_SPARC_TLS_IE_HIX22 x R_SPARC_TLS_IE_LOX10 x |
| 未解决的重定位 |
这个优化移除了两个运行时重定位,以及对 __tls_get_addr 的调用。不过,可执行映像必须设置标记 DF_STATIC_TLS 。
局部动态到局部可执行
局部动态到局部可执行模式的转换,在 SPARC 上,也是最不理想的。最好是许可( enable )编译器马上产生最适宜的代码。不过,不管怎样,优化还是有效的,因为它消除了一个运行时重定位及对 __tls_get_addr 的调用。
LD à LE 代码转换 | 初始重定位 符号 |
0x00 sethi %hi (@tmdnx (x1)), %o0 0x04 add %o0, %lo (@tmndx (x1)), %o0 0x08 add %l7, %o0, %o0 0x0c call __tls_get_addr ... 0x10 sethi %hix (@dtpoff (x1)), %l1 0x14 x0r %l1, %lox (@dtpoff (x1)), %l1 0x18 add %o0, %l1, %l1 ⇓ 0x00 nop 0x04 nop 0x08 nop 0x0c mov %g0, %o0 ... 0x10 sethi %hix (@dtpoff (x1)), %l1 0x14 x0r %l1, %lox (@dtpoff (x1)), %l1 0x18 add %o0, %l1, %l1 | R_SPARC_TLS_LDM_HI22 x1 R_SPARC_TLS_LDD_LO10 x1 R_SPARC_TLS_LDM_ADD x1 R_SPARC_TLS_LDM_CALL x1
R_SPARC_TLS_LDO_HIX22 x1 R_SPARC_TLS_LDO_LOX22 x1 R_SPARC_TLS_LDO_ADD x1 ⇓ ⇓
R_SPARC_TLS_LE_HIX22 x1 R_SPARC_TLS_LE_LOX10 x1 |
| 未解决的重定位 |
这个优化同样要求可执行映像设置 DF_STATIC_TLS 标记。
初始可执行到局部可执行
如果程序员告诉编译器代码只用于可执行映像,但只有链接器知道变量被定义在这个可执行映像中,下面的优化可以帮助消除剩下的运行时重定位。
IE à LE 代码转换 | 初始重定位 符号 |
0x00 sethi %hi (@tpoff (x)), %o0 0x04 or %o0, %lo (@tpoff (x)), %o0 0x08 ld [%l7 + %o0], %o0 0x0c add %g7, %o0, %o0 ⇓ 0x00 sethi %hi (@tpoff (x)), %o0 0x04 xor %o0, %lo (@tpoff (x)), %o0 0x08 mov %o0, %o0 0x0c add %g7, %o0, %o0 | R_SPARC_TLS_IE_HI22 x R_SPARC_TLS_IE_LO10 x R_SPARC_TLS_IE_LD x R_SPARC_TLS_IE_ADD x ⇓ ⇓ R_SPARC_TLS_IE_HIX22 x R_SPARC_TLS_IE_LOX10 x |
| 未解决的重定位 |
因为局部可执行模式的代码序列只有 3 条指令,在 0x08 的指令作为 no-op 加入。
5.3. SH 链接器优化
正如 IA-32 及 SPARC 那样,这个链接器可以执行一系列优化。但由于 SH 的代码结构及所使用的代码序列,可提供的选择有限。
常规动态到初始可执行
如果可以使用初始可执行模式,使用常规动态模式编译的代码,通过执行下面的转换,可以节省 2 条指令,及可能一个 GOT 项。
GD à IE 代码转换 | 初始重定位 符号 |
0x00 mov.l lf, r4 0x02 mova 2f, r0 0x04 mov.l 2f, r1 0x06 add r0, r1 0x08 jsr @r1 0x0a add r12, r4 0x0c bra 3f 0x0e nop .align 2 1: .long x@tlsgd 2: .long __tls_get_addr@plt 3: ⇓ 0x00 mov.l 1f, r0 0x02 stc gbr, r4 0x04 mov.l @(r0, r2), r0 0x06 bra 3f 0x08 add r4, r0 0x0a nop 0x0c nop 0x0e nop .align 2 1: .long x@gottpoff 2: .long 0 3: |
R_SH_TLS_GD_32 x
⇓ ⇓
R_SH_TLS_IE_32 x |
GOT [n] | 未解决的重定位 R_SH_TLS_TPOFF32 x |
对 __tls_get_addr 的调用被优化掉了,并且与跳转相关的指令及数据结构都没有了。注意到我们可以移动 bra 指令,这样用 nop 填充的,不需要的内存,不会再执行。
常规动态到局部可执行
从常规动态到局部可执行模式的转换与前一个转换几乎相同。我们只多节省了一条指令,并且没有留下运行时重定位。
GD à LE 代码转换 | 初始重定位 符号 |
0x00 mov.l lf, r4 0x02 mova 2f, r0 0x04 mov.l 2f, r1 0x06 add r0, r1 0x08 jsr @r1 0x0a add r12, r4 0x0c bra 3f 0x0e nop .align 2 1: .long x@tlsgd 2: .long __tls_get_addr@plt 3: ⇓ 0x00 mov.l 1f, r0 0x02 stc gbr, r4 0x04 mov.l @(r0, r2), r0 0x06 bra 3f 0x08 nop 0x0a nop 0x0c nop 0x0e nop .align 2 1: .long x@tpoff 2: .long 0 3: |
R_SH_TLS_GD_32 x
⇓ ⇓
R_SH_TLS_LE_32 x |
| 未解决的重定位 |
再一次,可以巧妙地放置 bra 指令,来避免执行,所有用于填充目的的 nop 指令。
局部动态到局部可执行
这个最后的优化允许转换局部动态代码到局部可执行代码序列。以这个方式产生的代码,比上面描述的局部可执行代码序列,有更高效的潜力,因为线程寄存器只读一次。
LD à LE 代码转换 | 初始重定位 符号 |
0x00 mov.l lf, r4 0x02 mova 2f, r0 0x04 mov.l 2f, r1 0x06 add r0, r1 0x08 jsr @r1 0x0a add r12, r4 0x0c bra 3f 0x0e nop .align 2 1: .long x@tlsgd 2: .long __tls_get_addr@plt 3: … mov.l .Lp, r1 mov.l r0, r1 … mov.l .Lq, r1 mov.l r0, r1 … .Lp: .long x1@dtpoff .Lq: .long x2@dtpoff ⇓ 0x00 bra 3f 0x02 stc gbr, r0 0x04 nop 0x06 nop 0x08 nop 0x0a nop 0x0c nop 0x0e nop .align 2 1: .long 0 2: .long 0 3: ... mov.l .Lp, r1 mov.l r0, r1 … mov.l .Lq, r1 mov.l r0, r1 … .Lp: .long x1@tpoff .Lq: .long x2@tpoff |
R_SH_TLS_LD_32 x1
R_SH_TLS_LDO_32 x1 R_SH_TLS_LDO_32 x2 ⇓ ⇓
R_SH_TLS_LE_32 x1 R_SH_TLS_LE_32 x2 |
| 未解决的重定位 |
因为计算用到的重定位地址现在非常简单(只是载入线程寄存器),序言( prologue )也同样简单。在跳转到第一个数据的延迟槽中可以执行一条指令。