制作我们自己的可执行打包程序,第13部分,线程本地存储

2020-06-26 02:59:55

欢迎回来,感谢你加入我们的阅读笔记…。关于ELF文件的系列文章的第13部分介绍了它们是什么,它们可以做什么,动态链接器对它们做了什么,以及我们自己如何做到这一点。

到目前为止,我一直很成功地避免谈论TLS(不,不是那个),但我想我们已经到了不能再拖延的地步了,所以。

我们从艰难地阅读文件的经历中知道,作为一个用户端应用程序,我们是计算机上的客人。

当然,我们可能会执行一些指令,我们甚至可能会礼貌地要求某些设备符合我们的需求,但最终调用这些指令的是内核。我们在这里是被容忍的。老实说,内核根本不会执行任何东西。

不过,内核偶尔会让非内核代码执行。再说一次,它负责这种情况的确切发生方式和时间-以及持续多长时间。

到目前为止,我们已经对进程是如何加载到内存中形成了一个相当好的想法:内核本身解析我们想要执行的文件,如果它解析了它(尽管它对几乎不像我们感兴趣的事情那么多),映射一些东西,然后“不插手”控制它。

但是交接是什么意思呢?具体来说,会发生什么呢?好吧,今天不是我们进入内核调试的日子(虽然…。不。除非?没有),但我们肯定能大致知道发生了什么事。

计算机是什么?一堆可怜的登记簿。这是正确的,因为所有的全局变量都是向下的。

以下是一些CPU寄存器的值,恰好是echidna的主要函数开始执行时的值:

就这些吗?不要啊!这里有128位寄存器(SSE)、256位寄存器(AVX)、512位寄存器(AVX-512)-当然,我们还有x87/FPU寄存器,当您需要一个协处理器来处理这些寄存器时。

这简直是一团糟。关键是,我们有一堆全局变量,读写速度非常快。因此,优化编译器倾向于在任何可能的情况下使用它们。

这里的“他们”指的是从%rax到%r15这一系列中的通用型。有时,如果您的优化器感到特别头晕目眩,一些%xmmN寄存器也会出现(正如我们在上一篇文章中痛苦地了解到的那样)。

还有一些特殊用途的寄存器,如cs、ss、ds、es等等,我们并不特别关心这四个寄存器,因为在64位Linux上,我们的内存模型要简单一些。

事实上,我们一直都在使用寄存器来发送内核LoveLetters--例如,在echidna的Write函数中:

发布不安全的FN写入(fd:u32,buf:*const U8,count:usize){ASM!(";mov rax,1 SysCall";::";{rdi}";(Fd),";{rsi}";(Buf),";{rdx}";(Count):";rax";,";rr。英特尔(34;))}。

因此,内核应用程序和用户应用程序都使用寄存器。我最喜欢的寄存器之一--我正在写一系列关于ELF文件的文章--是%rip,指令指针。

我被告知它并不总是那么简单,但在64位Linux上,它只是指向我们当前正在执行的代码的(虚拟)地址。当程序执行向前移动时,%rip-by不管编码刚执行的指令需要多少字节,都会向前移动:(#*_)。

因此,这回答了我们问题的一部分-内核如何将控制权“移交”给程序:它只是改变了%rip!处理器做的是最好的。井。有点像。比方说,“在其他事情中”。

(请注意,在x86上,您不能直接写入%rip寄存器-您必须使用jmp、call或ret等指令。)。

公平地说,它也从环0切换到环3--这也是我们在“艰难阅读文件”第2部分中简要讨论过的。并且它从“内核虚拟地址空间”切换到“用户地虚拟地址空间”。

重点是--这也是进程间切换的工作原理。对于用户而言,进程是并行执行的,但是对于内核而言,它的调度器分配的是时间片。每当它让process“foo”执行一段时间时,它:

从环0切换到环3,也跳转到上次中断进程“foo”时的地址%Riphad。

最终,系统计时器中断关闭,执行立即跳回内核的中断处理程序-在这一点上,内核决定进程是淘气的还是好的,以及它是否值得更多的时间。

如果不是-例如,如果内核决定我们下一步真的应该给进程“bar”更多时间,那么内核将保存“foo”的状态(大多数注册器),重置一组CPU状态(主要是内存缓存),并按照我们刚才描述的方式切换到“bar”。

这是非常遥远的事情概观。它也不完全正确。但就我们的目的而言,它是足够正确的。

这是针对流程的。但是线程呢?线程也是“抢占多任务”的--它们的执行可以被暴力中断,而不是显式地放弃控制(即。“抢占”),以便可以执行其他线程。

“另一个”多任务是协作性的多任务--你不需要内核的帮助就可以做到这一点。这就是协同程序的工作方式--只是在决定轮到谁的时候,一些用户州的人都在一起玩得很好。

不过,线程之间的切换更简单。因为代理进程的所有线程共享相同的地址空间。因此,当从一个切换到另一个时,需要保存和恢复的状态较少。

但是…。问题出现了:如何区分线程?如果使用相同的入口点启动了几个线程,您如何知道哪个线程是哪个线程呢?这是CPU处理的事情吗?还是内核?

//在`elk/sample/twothread/twothreads.c`#include<;unistd.h>;#include<;pthread.h>;void*in_thread(void*unuse){while(1){睡眠(1);}}int main(){pthread_t1,t2;pthread_create(&;t1,null,in_thread,null);pthread_create(&;t2,null,in_thread,

$cd elk/sample/twothread$GCC twothreads.c-pthread-o twothreads.c-pthread-o twothread$./twothread(程序不打印任何内容,并且永远不会退出)。

$gdb-quiet./twothread正在读取符号./twothreads.(Gdb)在0x1175处插入_threadBreakpoint 1:文件twothreads.c,第6行。(Gdb)运行启动程序:/home/amos/ftl/elk/samples/twothreads/twothreads[Thread调试使用libthreaddb已启用]使用主机libthreaddb库";/usr/lib/libthreaddb.so.1";。[新线程0x7ff7da5700(lwp 14253。在两个线程处命中断点1,in_thread(未使用=0x0)。c:66休眠(1);

到目前为止一切都说得通。我们有三个线程--主线程和我们创建的另外两个线程。所以说真的,这个节目应该叫“三读”。

(Gdb)位于/usr/lib/libpthread.so.0克隆()中的/usr/lib/libpthread.so.0#2 0x00007ff7ea83d3中的bt#0 in_thread(未使用=0x0)。c:6#1 0x00007ffff7f7846f in start_thread()from/usr/lib/libc.so.6。

就像INFO寄存器一样,GDB有INFO线程,这让我们知道它们都在发生什么:

(Gdb)信息线程ID目标ID帧1线程0x7ffff7da6740(Lwp 14249)";两个线程";0x00007ffff7f79a67位于/usr/lib/libpthread.so.0*2线程0x7ff7da5700(Lwp 14253)";两个线程";in_thread(未使用=0xxthread.so.0*2线程中(未使用=0xpthread.so.0*2线程0x7ff7da5700(Lwp 14253)";两个线程";in_thread(未使用=0xxthread.so.0*2线程。

我们可以将“当前gdb线程”设置为我们想要的任何值,例如,如果我们想要查看主线程在做什么:

(Gdb)线程1[切换到线程1(线程0x7ffff7da6740(Lwp 14249))]#0 0x00007ff7f79a67 in__pthread_clockjoin_ex()from/usr/lib/libpthread.so.0(Gdb)bt#0x00007ff7f79a67 in__pthread_clockjoin_ex()from/usr/lib/libpthread.so.0(Gdb)bt#0x00007ff7f79a67 in__pthread_clockjoin_ex()from/usr/lib/libpthread.so.0。

(Gdb)main()中的帧1#1 0x000055555551e3,位于两个线程。c:1414pthread_Join(t1,NULL);(Gdb)info localst1=140737351669504t2=140737343276800(Gdb)p/x t1$1=0x7ff7da5700(Gdb)p/x t2$2=0x7ff75a4700。

看起来像是指针。好吧。现在,我们知道了如何检查各种线程的状态,让我们来看看我们的两个线程是怎么回事--它们是背靠背的:

事情看起来相似得令人毛骨悚然。它们应该-两个线程都在做完全相同的事情-等待时间耗尽,一次一秒。

当然,有些寄存器值关闭了0x1000(%RBP到%R10),但是,例如,%RIP对于这两个寄存器来说是完全相同的。老实说,这让人安心。并非我们所有的假设都是错误的。

但一定有办法把它们区分开来。对于初学者来说,“pthread”(POSIXthread)被实现为一个用户地库:

从到系统读取共享对象库0x00007ffff7fd3100 0x00007ffff7ff2b14是(*)/lib64/ld-linux-x86-64.so.20x00007ff7f76ad0 0x00007ff7f858c5是(*)/usr/lib/libpthread.so.00x00007ff7dce630 0x00007ff7f18e。

…。并且它公开了像pthreadself()这样的函数,该函数返回调用线程的ID。所以它必须知道我们当前在哪个线程中。我们所要做的就是…。寄存器。

让我们做一些我希望在几个月前就想明白的事情,当时我还在研究“滚动自己的动态链接器”是否是一件不太合理的事情。

(Gdb)disas pthread_self:0x00007ffff7e2efa0<;+0>;函数的汇编代码的pthread_selfDump:endbr64 0x00007ff7e2efa4<;+4>;:MOV rax,QWORD PTR fs:0x10 0x00007ff7e2efad<;+13>;:retEnd of Assembly。

额外分段。指向额外数据的指针(‘E’代表‘Extra’)。

F段(FS)。指向更多额外数据的指针(‘F’在‘E’之后)。

G段(GS)。指向更多额外数据的指针(‘G’在‘F’之后)。

太棒了。因此,“s”代表“段”,“f”代表“fxtra data”。

不过,等一下。我非常肯定每次我们看它的时候%fs都是0x0。让我们仔细检查一下:

(Gdb)t a i r fsThread 3(线程0x7ffff75a4700(Lwp 14475)):fs 0x00线程2(线程0x7ffff7da5700(Lwp 14474)):fs 0x00线程1(线程0x7ff7da6740(Lwp 14473)):fs 0x0。

t a a i r fs只是线程应用所有信息寄存器fs的模糊方式。

这是对的-只要它不含糊,gdb就可以让您缩短任何命令或选项名称。事实上,如果您看到正在使用快捷键,并且不确定它的作用,您可以询问gdb,因为它的help命令也接受快捷键形式。

(Gdb)help NISTEP一条指令,但通过子例程调用继续。用法:nexti[N]参数N表示第N步(或直到程序因其他原因停止)。

这是在撒谎吗?是。如果是这样的话,pthreadself将尝试从内存地址0x0+0x10读取,并且肯定是Segerror。

(Gdb)print(void*)pthread_self()[切换到线程0x7ffff7da5700(Lwp 14474)]从gdb调用函数时,程序在另一个线程中停止。包含函数(Pthreadself)的表达式的求值将被放弃。函数执行完成后,gdb将静默停止。

调度程序锁定是gdb的一个特性,它会礼貌地要求Linux内核不要抢占当前线程,因为我们正在查看它。

因此,广发银行在撒谎。但这并不完全令人惊讶--%fs寄存器是线程本地的(在Linux 64位!请记住,寄存器的用途完全在ABI中定义,它由内核决定(ITSO),并且GDB本身运行自己的线程,与次要的线程不同。

我们已经有一段时间没有讨论过奇怪的gdb术语了,所以,以防万一,“次要的”就是“正在调试的进程”。我知道呀。怪怪的。继续前进。

是否有其他方法可以获取%fs寄存器的内容?当然有!我们可以通过arcprctl syscall礼貌地询问内核。我们将使用它的包装器:

#include<;asm/prctl.h>;#include<;sys/prctl.h>;int arch_prctl(int code,无符号长地址);int arch_prctl(int code,unsign long*addr);#定义ARCH_SET_GS 0x1001#DEFINE ARCH_SET_FS 0x1002#DEFINE ARCH_GET_FS 0x1003#DEFINE ARCH_GET_GS 0x1004。

这就对了。同一函数一次定义为获取uint64_t,第二次定义为获取指向uint64_t的指针。您知道,因为它既可以获取内容,也可以设置内容。

这就是libc的运作方式,宝贝。告诉你C有类型系统的人不是在妄想就是在调皮捣蛋。

为什么要举行这个仪式?嗯,%fs和%gs不是通用寄存器-它们是段寄存器。在64位时代之前,段寄存器要重要得多。

那一年是1976年。自从8位Intel8008发布以来,已经过去了四年,其他公司也在陆续发布16位微处理器。

数字设备公司(DEC)、飞兆半导体和国家半导体都发布了某种形式的16位微处理器。一年前,National甚至发布了Pace,这是一款松散地基于自己的IMP-16设计的单片机。

与此同时,英特尔的iAPX432项目已经进行了一年,这..。确实可以保证至少有一整篇文章。Ada是处理器的目标编程语言,它支持面向对象的编程和基于能力的寻址。

然而,iAPX432项目却举步维艰--事实证明,这些抽象的东西并不是免费的。它们不仅需要明显更多的晶体管,而且与竞争对手的微处理器相比,同等程序的性能也会受到影响。

因此,在1976年5月,英特尔的同事们说“好吧,让我们在iAPX432烹饪完成之前发布一些16位芯片吧。”现在距离德州仪器(TI)发布另一款单船16位微处理器TMS9900还有一个月,压力是实实在在的。

但是“16位芯片”到底是什么意思呢?嗯,实际上是…。这要视情况而定。

例如,我曾将英特尔8008称为“8位芯片”--但它没那么简单。

当然,8008的寄存器是8位的。每个位都可以打开或关闭:

每一位也对应于2的幂-通过将每个“开”位中的两位的幂相加,我们得到一个无符号整数的值:

有符号整数涉及的比较多,而浮点数涉及的甚至更多。但是我们不要太分心了。

如果你只用8位来编码内存地址,那么你只能寻址256字节的内存。

因此,即使是8位芯片通常也有更大的“地址总线”。8008有一条14位的地址总线,这意味着它的PC寄存器(程序计数器,我们在x86-64上称之为指令指针)的宽度是..14位。

如何使用8位通用寄存器操作14位地址?使用其中两个寄存器!为什么是14位而不是16位?嗯,当你在做开心果的时候,每根大头针都很重要:

该芯片具有8位宽的数据总线和14位宽的地址总线,可寻址16KB的内存。由于英特尔在1972年只能生产18针DIP封装,总线必须进行三倍多路复用。因此,该芯片的性能是非常有限的,它需要大量的外部逻辑来解码所有的信号。

因此,多亏了引脚复用,8008可以寻址16KiB的内存,这仍然不是很多。回到70年代,英特尔是一家致力于制造内存芯片的初创公司。不难理解,他们希望人们使用能够寻址更多内存的微处理器。

8086';的设计更大。它采用40针封装,因此他们能够将数据引脚的数量减少到20个--而且还带有一些多路复用功能。通过20位地址总线,8086能够提供高达1MIB的物理地址空间。

但就像以前一样,8086的通用寄存器更小--它们只有16位。单个寄存器仍然不足以引用物理存储器地址。

怎么办呢?使用分段!8086引入了四个段寄存器:从中读取指令的代码段(CS)、通用内存的数据段(DS)、堆栈段(SS)和额外段(ES),当您需要从内存的一个区域复制到另一个区域时,这些寄存器可用作临时存储空间。

指令通常采用16位偏移量参数,根据指令的性质,它会将该偏移量与相关的段寄存器相加。每个段寄存器都是…寄存器。也是16位。16+16=20,一切正常。

这意味着,对于8086,每个单独的存储器地址可以由4096个不同的段:偏移量对引用。

这也意味着,只要您的整个程序(代码和数据)适合单个64K段,您就可以拥有从0开始的不错的偏移量(对于您的段)。

如果它不能容纳在一个64K的段中,那么你的偏移量就不再是16位的了,你必须开始在不同的段之间来回切换,并处理时髦的指针大小。

如果要引用同一段中的内存,可以使用NEAR指针:

如果要引用另一个段中的内存,可以使用远指针。

如果您想引用另一个段中的内存,并且您的指针算法可能会更改指针的值以引用另一个段,则可以使用一个巨大的指针:

1982年,英特尔推出了80286,我们将称之为286型,它引入了几个新奇的东西。首先,数据引脚不再是多路复用的-芯片有68个引脚,其中16个引脚专用于地址总线。

第二:286引入了“保护虚拟地址模式”。然而,在8086上,代码、堆栈和数据段可以(并且做到了!)。重叠,286号可以避免这种情况。还可以为段分配“特权级别”-具有较低权限级别的段不能访问具有较高权限级别的段。

还记得保护环吗?我们在“艰难的阅读文件”(第2部分)中谈到了环0和环3--仅此而已!

“环”是特权级别,当前特权级别存储在CS寄存器的较低两位中。您知道吗,我们的示例程序运行的是…。

…。在3号环!这是理所当然的,因为它是一个常规的用户程序,而不是内核代码。

然而,286';的保护模式使用起来有点烦人--首先,它破坏了与旧的8086应用程序的兼容性。更糟糕的是,一旦您将其从“真实”模式切换到“保护”模式,您就无法在不执行硬重置的情况下切换回来。

但是,少数使用286保护模式的应用程序能够使用完整的24位物理地址空间:16MiB。理论上。实际上,286主板只支持高达4MiB的RAM-即使那样,购买这么多内存也是昂贵得令人望而却步。

快进到1985年。日美半导体大战正如火如荼。英特尔最终决定停止生产DRAM,目前专注于微处理器。

1985年10月,英特尔发布了80386(我们称之为“386”),这是对80286体系结构进行32位扩展的第一个实现。最后,数据宽度和地址宽度是相同的:32位。

这意味着-从理论上讲-386能够寻址4GiB的RAM。

然而,在实践中,让你拥有那么多内存的板--或者任何接近它的地方--都是不存在的。即使只有几兆字节的RAM也会让您望而却步。

Tall Tree Systems推出了Jram系列的最新成员Jram-3,JRAM-3是第四代多功能存储板,是备受赞誉的Jram-2的继任者。JRAM-3专为满足主要电子表格供应商正在实施的最新扩展内存规范标准而设计,可访问高达8兆字节的内存,以实现更大、更高效的电子表格。JRAM-3还可用于DOS存储器、电子芯片。

..