CSAPP-记录

Last Updated on 2021年1月25日

  • Question In CSAPP:
    • switch比if-else高效吗?
    • switch可能更高效,比较相关的开销移动可能会移动到编译期.
    • while比for高效吗?
    • 二者相同
    • 指针比数组索引更高效吗?
    • 二者相同
    • 为什么在求和时,将结果存在本地变量中要比存储在传入的指针中更快?
      • 本地对象一般是在寄存器中, 而通过指针IO时,可能会触发内存操作
    • 为什么表达式的求值顺序会影响性能(括号的位置)
      • 限定求值顺序会影响编译器的优化.
      • 限定求值顺序有可能会改善内存访问的模式.
    • 函数调用的开销到底有多大?
    • 现代编译器中,一般可以忽略,部分toolchain甚至有链接期inline的机制.(用栈来模拟递归通常是得不偿失的,损失的可读性要远大于性能的微弱提升)
    • 主要是需要避免系统调用,系统调用将触发context switch
    • 为什么有些链接错误是在运行时触发的?
      • 因为编译期允许弱引用的存在,弱引用在链接时没找到是不报错的,仅在运行时报错.

  • Amdal定律
    • 若系统中某一个环节的执行占总执行时间比例为a,将其速度变为k倍后,可以计算出之后总体提速倍率为1/(1-a+a/k),可以看出,a越大,那么a可供提速的潜力越大.
    • 推论:若一个环节的执行占比为a, 那么即使把它的速度提高无穷大倍,至多带来1/(1-a) 的性能提升,如果a60%,那么加速这个环节至多带来2.5倍的总体性能提升.
    • Amdal 定律: 想要大幅提升整体性能,就必须提升所有环节的性能.
  • 编译器对Struct的对齐策略为: 优先保证IO效率
    • 对于Struct整体,按照max(alignof(elemetns))对齐,编译器为其分配的地址一定是对齐的,这将导致Struct的末尾被插入空穴.
      • Struct的整体对齐主要是为了保证使用数组这样的类型时每个实例都能分配到对齐的地址
      • 例如,struct内各个元素最大的对齐值为8,那么struct就是按8对齐的
    • 对于成员,每个元素都优先按自己的自然长度对齐,这将导致编译器在元素之间插入空穴,使每个成员的实际地址都能自然对齐.
  • 由于机器指令在设计中一般要考虑向前兼容,所以历史包袱往往比较大,例如 X86-64的二进制指令中,就只给寄存器保留了4位的索引号,所以X86中的regist file中至多也就16个寄存器.
  • CISC的理念是:需要什么功能,我们就设计什么功能,再添加一条对应的机器指令,这一般导致了复杂的芯片设计,由于变化多样,所以难以在机器级别高效优化执行效率
  • RISC的理念是:只实现必要的指令,简化硬件设计,从而便于设计更高效的执行机构,提高硬件的执行效率.(目前来看,RISC的指令也不少了)
    • RISC中一般没有高latency的指令
    • 通常所有指令都是定长的(4字节)
    • 内存寻址的模型更简单
    • 内存中的值不允许直接做操作数,必须先load到寄存器中.这种策略常被称为load/store体系.
      • 相应的,RISC结构中一般有更多的寄存器供使用.
      • RISC结构中,实现PROC调用时,一般不能通过内存传参,只能通过寄存器
    • 指令集设计不能进行完全的隔离,换言之,编译器在生成指令时,有的指令必须考虑具体的硬件型号,不能仅按指令集生成代码.
    • 没有自动条件测试,即某个操作完成后,不会自动地置寄存器(例如X86中的很多操作都会置OF,ZF,SF)

RISC 读作’risk’ CISK读作’sisk’, 事实上,二者的界限在逐渐模糊,彼此都在吸取对方的一些明显优势;例如,现代的RISC中常引入乘法器,浮点单元/ CISC则引入了流水线

信息的表示和处理

  • 位补(X86中的NEG): NEG A = ~A+0x01 称为二进制位补,位补的意义为 A + ~A + 0x01 = 0 ==> -A = ~A + 0x01
    • 在整数计算中,位补是一种非常重要的运算,它用于获得等效”负数”,因此,数学意义上的-A,在计算机中就是对A做位补.
  • 位补是基于二进制加法器的”溢出”特性实现的,这个操作对无符号数/有符号数都由具体的意义.无论有符号,无符号,在二进制层面,加法/减法/乘法/除法/位移 是完全相同的
    • 鉴于此,有符号/无符号之间是可以任意混算的,但是C编译器总是将计算结果解释为”无符号数”,也就是常说的”C语言优先把有符号数转换为无符号数”
  • 重要结论:无论是否有符号,加/减/乘/除/位移中,-B都可以由B的位补(即NEG B = ~B+1)替代.
    • 仅以无符号数举例,对于8位二进制无符号数254的位补就是2,255的位补是1
    • 255-254 = 255 + 2 => 1;
    • 255 * (-254) = 255 * 2 = 510 % 256 = 254 = (-255) * 254 = 1 *254 = 254
  • 溢出并不影响加/减法的交换律及结合律,也就是说 (x+y-y) 永远等于x,和计算顺序无关.
  • 溢出影响乘/除法的结合律和交换律,也就是说(x*y/y)的结果和计算顺序有关
    • 乘法溢出的取巧检测方法是 x*y/y == x(常规方法是用一个大尺寸的z来存储x*y,然后判断z的高位是否有值).

二进制小数

  • 二进制小数的自然计数法是(-)xxxxx.yyyyyy,小数点左侧是整数部分,按正幂求和,右侧是小数部分,按负幂求和
  • 定点小数: 对自然计数法的一种实现,对于w位二进制数,小数点位置已经约定好,高k位是整数部分,低w-k位是小数部分.
  • IEEE754浮点小数:
    • 最高位是整体的符号位s;
    • 后续k位称为阶码,它的二进制无符号值记做e,决定了E,大部分时候E=e-BIAS
    • 再后续n位是有效数字位(一般称为尾数),大部分时候,二进制尾数xxxxxx对应的二进制小数是M=1.xxxxxx
    • 综上,就能得到相应的二进制小数V,即 V = (-1)^s * M * 2^E,由于2^E因子的作用是移动小数点,小数点位置是随E而浮动的,所以称为浮点数.
    • float中k=8,n=23;double中k=11,n=52
  • IEEE754浮点数的设计要求(也是基本特性):
    • 正负完全对称,且在每一侧都没有重复编码
    • 可以精确表示+0,-0;存在+无穷,-无穷;存在用于保留特殊信息的NaN
    • 排除符号位后,如果将剩余部分看成无符号数,那么”浮点数的绝对值”正相关于”无符号数的值”
  • IEEE754的具体设计:
    • 阶码二进制全为0时,即e=0时,对应的E=1+e-2^(k-1),尾数部分存储了0.xxx型有效数字(隐去了开头的0.),只记录了纯小数部分. 这一部分浮点数称为非正规段
    • 全0<阶码<全1,即e>0时,对应的E=e-2^(k-1),尾数部分存储了1.xxx型有效数字(隐去了开头的1.),只记录了纯小数部分,这一部分浮点数称为正规段.
    • 阶码全1,且尾数全0时,称为临界点,对应了无穷
    • 阶码全1,且尾数>0时,全都称为NaN
  • 为什么要有非正规段这一部分呢? 主要是为了解决”精确表示0″的问题.

  • IEEE754可以精确的表示整数,除了+-0,其余整数都落在正规段范围内
    • 容易证明,尾数为n位的ieee754浮点数能连续表示的整数与n+1位整型数相同
  • 浮点加法/乘法的结果都和计算顺序有关,且结果总有round问题,这可能造成麻烦,例如a*(b-c)不一定等于a*b-a*c
  • 与整数不同,浮点数有inf和-inf作为界,没有溢出问题,因此可以保证单调,即若有a>=b,则a+x>=b+x

机器眼中的程序

计算性能

  • 一般而言,for循环的分支条件总是不依赖之前的计算结果,所以for循环的分支预测正确率一般都是100%
  • 条件传送要比条件跳转强得多,没有分支预测失败的额外开销.(通过?:三元运算符替代if-else可以辅助编译器进行优化.)
  • 在硬件层面,对于一条硬件指令,主要关注以下指标:
    • issue time: 执行单元能够连续接收issue的最小间隔
    • latency: 执行完成指令所需的时间
    • capacity: 执行单元的数目
  • 在流水线模型中,issue time总是小于等于latency,在场景合适时,对一个执行单元,每过issue time就能有一个旧指令完成(同时允许一个新指令被单元接收),仿佛指令执行只需要issue time,看起来相当于latency变小了,这种现象称为latency hidding.
    • 指令的流水化一般受限于硬件水平和指令自身的限制.例如有的指令算法自身可以流水化,但是对应的硬件设计过于复杂;而有的指令,其对应算法自身就是无法流水化的.
    • issue time为1的硬件指令称为”完全流水线化的”(fully pipelined),可以完全 hide latency
  • 当某个函数内的指令彼此有严格的执行顺序限制时(一般是数据依赖引起的),就必须等前面一个执行完成才能执行下一个,这种函数的性能特征称为latency bound
  • 当某个函数内的指令彼此无关时,就可以连续的issue出去,这些指令的性能特征称为throughput bound
    • 对一般的x86 CPU而言,从极限throughput指标评定, 整数优于浮点, 加法(+,-,^,&)优于乘除, 乘法优于除法
    • 对于某个指令,应该始终保持每个执行单元均应该有Latency/(issue time)个指令在流水线中,才能做到流水线长期满载,达到throughput性能的极限

存储器的性能—-高速缓冲体系

在典型的x86体系中,OS内核启动完成后, 将把CPU切入保护模式, 之后,CPU执行单元使用的任意地址都是虚拟地址,在进行ld/st时,典型流程为: CPU访问一个虚拟地址VA (Virtual Address),该请求被发送到MMU,MMU把VA转换为物理地址PA (Phisical Address),然后把PP发送给高速缓冲组件,由高速缓冲组件负责进行具体的数值读取.

在上面的过程中,代码(包括内核代码)使用的只能是VA,MMU的存在对CPU而言是完全透明的.

高速缓冲体系

  • 当MMU向高速缓存发出请求时,高速缓存若找到数据,称为命中,就直接返回数据;反之,称为miss.当高速缓冲组件发生miss时,会逐级把请求下发;若最终级的cache还是miss,才会发出transaction请求.
    • 一般而言,数据一定会经过高速缓冲,每一个数据段进入高速缓冲一般都意味着某个之前已缓冲的数据被移出.
  • 除了寄存器和SRAM外,其他的各式各样的存储器在物理上一般有这样的特征:
    • 一个load/store至多只能转化为物理上一段定长的transaction,具体而言,是在对齐地址上的定长transaction,用户所需的数据需要由硬件(或用户自己)从这一段transaction中提取.
    • 连续读/写的性能要优于随机读/写
  • 高速缓冲有hit timemiss penalty两个指标,前者用于表明在命中时返回值的周期,后者用于表明在miss时返回值的周期.
    • 在多级缓冲中,发生miss panalty时,既需要从向下级转发请求,又需要把下级返回的值更新到当前级的cache中,所以开销一般是比较大的.
  • 对于多层缓冲的体系,如果一个缓冲项移出LX Cache后,就不会再移入,那么代码的性能就是以LX Cache为界的.
    • 在一些极端的场合中,我们可以刻意地按照L1的尺寸来组织数据,使得以L1为界.
  • 算法可以针对高速缓存进行优化,以避免cache miss,但是一般而言,高速缓存逻辑上只保证在单路连续访问内存时,能够得到最佳命中率.
  • 典型x86架构中,高速缓存组件会把内存视为一个逻辑上的矩阵,矩阵中的每个元素对应了定长的B=2^b个字节的数据段,称为Block.
    • B=2^b这个值是由Cache单元的数据容量决定的.
    • B=2^b一般对应了物理层单个transaction的数据尺寸
  • 每个Cache单元将用于服务一列内存Block,就是图中矩阵的一列.(很多时候,单个Cache单元被称为Cache Line)
    • 换言之,位于(i,j)的内存Block只会由行号i决定被哪个Cache单元服务.
  • 在现代CPU中,可能会有多个Cache单元服务于同一组内存Block,服务于同一组内存BlockCache单元组成Cache Set,Cache Set中的Cache单元个数称为”组相联度”,记作E,例如,下图中的E就为2.
    • Cache Set的个数一般为2^s型的,它直接决定了图中矩阵的列数.
    • 显然E越大,涉及的调度问题就越复杂

Cache_Index

  • 综上可知,内存中的每个字节可以通过(行号,列号,偏移)来定位,具体而言,物理地址PP将会用于决定(Tag Index,Set Index,Offset Index)型的坐标,物理实现中,高速缓存将用这三个坐标来进行快速定位.
    • Tag Index相当于图中的Block所在的行号,Set Index相当于列号,Offset Index则确定具体的字节位置.
  • 一般而言,Cache Line单元除了会存储某个Block的数据外,还会存储该BlockTag Index.当MMU的PP地址发送到高速缓存时,访问内存时,Cache Index可以直接根据PP地址中的Tag Index部分判断当前已缓存的部分是否是PP对应的
  • 对于上层开发者,很容易从代码层面控制load的行为,从而保证Cache单元的使用效率.但是当使用store时,情况则复杂的多,一般可以近似使用”回写模型”:数据总是优先写入缓存,只在必要时才会写入内存
    • 写入时,若发现PP对应的Block在缓存中,则直接写入缓存.
    • 若PP对应的Block不在缓存汇总,则将对应的Block移入高速缓存.(这里会发起一次load transaction)
    • 只有在块被移出cache时,才会发生具体的内存写入store transaction.
  • 编程中,寄存器和Cache都是不可编程的珍贵资源,一定要避免寄存器溢出以及cache miss
  • C的volatile可以令编译器生成强制写入/读取内存的指令,以绕过缓存机制.
  • 关于矩阵乘法对Cache友好的编程: 使用外积方式实现, 也就是说,对于C=AB , C=[col1, col2, col3, col4][row1; row2 ; row3; row4]=sum(col_AirowBi). 从分块矩阵乘法来看,A的每一列是一个子矩阵,B的每一行是一个子矩阵
    • 在实践中,可以随着架构的不同调整外积中的列数,例如,每A的每2列是一个子矩阵,B的每2行是一个子矩阵.
    • 从CPU端的代码看,相当于循环顺序变为 k -> i -> j, i和j分别用于迭代行号和列号
  • 补充: 内存的物理结构
    • 一个内存条会划分为多个Bank(对DDR内存而言,每个Bank就是一个物理颗粒), 每个Bank有自己的位宽,代表了单次Bank级IO的位数.
    • 内存控制器在操作多个Bank时,Bank之间是可以并行IO的.当内存控制器想要IO单个Bank内的多个值时,则只能是串行IO的.
    • 上面两个概念对于有”Bank”概念的场景基本都是适用的,但是不同的架构对Bank适用的策略可能略有不同.
    • 典型X86的架构下,会把连续地址对应的分配到多个bank上,从而使得可以在一个Bank-IO的周期内就读取出大片的连续数据.
    • 之前我们使用的NPU中,连续地址优先分配到单个Bank内,分配完后,才分配到下一个Bank.

常见的优化手段

  • 指针的aliasing: 在传入多个指针时,可以通知编译器”对于指针访问的空间,不会发生指针重叠的情况”,也就是说,在任意时刻,指针指向的对象彼此总是不会重合.
    • 如果不进行强调,那么编译器会认为指针相关的操作可能产生数据依赖(即写读依赖),从而会禁用部分优化.
  • 在CPU中,常规unroll和block unroll仍然是有效的
    • 常规unroll相当于复制粘贴.
    • block unroll例如:写两个指令a[i]+b[i];a[i+1024]+b[i+1024];
    • 只有比较激进的编译器优化才可能做这两种unroll

Exception

  • 在硬件设计及软件设计中,Exception有着多种多样的意义,在CSAPP中,Exception是这样一种意思: CPU发现了一个状况,需要暂停手头的工作,去解决这个状况.
    • 在某些硬件设计的Spec中,一般称由CPU自己发现的问题为Exception,如页面错误,除法错误等. 在这种场景下,Exception的发现和处理都是CPU自己干的,所以是同步的. 这些Spec一般把外部报告给自己的问题称为Interrupt,在Interrupt时,对于发起方,它并不知道CPU何时能完成工作,只能接收CPU的通知,所以是异步的.
    • CSAPP中的异常包含了上面两种情况.
  • 在CSAPP的语境中,在执行X时若发现了Exception,那么主要问题有:
    • 这个问题是否可以解决? 不可解决的话就直接退出进程.
    • 解决完之后是再尝试X,还是执行X的下一条指令?
  • 在CSAPP的语境中,对Exception的处理流程为:CPU切换到内核模式 -> 依照异常号执行handler -> 恢复到之前的CPU模式 -> 看情况执行
  • CSAPP的语境中,Exception有四个典型的行为
    • Interrupt: 物理上,每个时钟周期都会检查”中断引脚”, 当CPU开启了中断检测,且检查到中断引脚的高电平时,CPU就按中断给出的异常号执行handler。
    • Fault:一般是CPU执行指令X时自己发现的可恢复的错误,这些错误有CPU设计者预定义的异常号,可以由OS来提供相应的handler(或使用默认的),问题解决后再次尝试执行X.
    • Abort:一般是CPU完全无法处理的问题,Abort的handler一般是直接终止整个进程。
    • Trap: 一般称由用户代码主动触发的Exception为”Trap”
  • 基于Trap实现的syscall汇编指令:让用户态代码能请求内核的服务(看起来就是调用了内核的函数)
    • 当用户请求syscall时,会触发一个特定的Trap异常(例如x86的INT 21H就是触发21号软中断),该Trap异常的handler将根据用户准备的syscall_numbercall对应的函数.(注意,syscall_number和异常号是不同的)
    • 习惯上,syscall的逻辑必须像call那样,是同步的.因此,在系统调用触发后,一般的流程为: 内核把调用方block,然后开始按照用户进程的要求办事(中间可能会切换到其他进程),办完事后,内核才会重新调度调用方.
    • 显然,syscall的核心目的是执行一个函数,但是中间却涉及了模式切换,异常处理,以及最终的等待, 所以syscall的开销很大,要尽量避免使用.
  • 异常触发的handler和在内核中直接call handler还是有一定区别
    • 异常handler执行后,不一定是执行异常发生时的下一条指令,有可能是再执行一次触发异常的指令。
    • handler执行前后,对现场的备份/恢复必须更加严谨,保证能够完全恢复。
  • 异常的一个经典作用是实现抢占式调度器, 调度器主要是通过周期性的时钟中断触发的.

在早期的OS中,CPU资源是非抢占式调度的(内核不会抢占进程的CPU时间),没有时钟中断来触发调度器, 必须得进程主动把资源让给内核后(一般是yeild,sleep,或ProcessEvent之类), 才有机会进行调度,进而才有机会让其他进程执行.

  • 进程的Context主要包括:寄存器,内核调用栈(用户栈在虚拟内存中,也就是在页表中),页表,进程表,文件表.
    • 每个进程在产生系统调用后,内核都可能会调用一系列函数,所以进程需要自己的内核调用栈.
    • 页表实际就代表了进程的虚拟内存空间,备份页表就相当于备份了进程的整个虚拟内存.
  • 异常是导致”伪唤醒”的一个根源之一.例如,对于sleep的进程,它可以被信号唤醒, sleep被恢复时并不会去检查恢复的原因,而是直接继续执行. 例如auto remain=sleep(duration)就会返回一个remain,用于表示剩余的未休眠时间.
  • Linux中的信号可以视为是”软异常”,因为它通常是由内核实现的,而非是硬件. 对于用户进程而言,信号的作用和常规的异常是完全一致的: 它导致进程不可预见的被外部信号中断,并跳转到信号处理程序.
    • 由于信号处理程序本质也是用户空间的代码,这意味着它也能被其他异常中断.
    • 在运行信号处理程序时,如果又来了同一个信号,那么新来的会被挂起,直到当前程序执行完. 如果来的是不同的信号,那么会先去执行新的信号handler
  • 一个针对信号的良好编程模式: 在用户空间自己维护一个信号队列,信号响应函数仅仅是往这个队列中添加对象. 由用户来选择何时处理信号.
    • 此时一般要在每个信号处理函数开始时临时暂时阻塞其他所有信号.
      > 信号不但会中断控制流,还会导致非预期的并发行为, 导致编程中引入诸多隐患, 如果可以, 不要使用它.

虚拟内存

虚拟内存是非常重要的一个抽象, 是由OS内核与硬件共同实现. 在虚拟内存启用后,CPU发出的寻址指令访问的都是虚拟地址,需要先通过MMU转换为物理地址后, 才能发送给高速缓冲组件.

从OS内核的角度看虚拟内存和从用户角度看是非常不同的,OS内核既需要维护虚拟内存机制,又要在虚拟内存中运行,比用户更复杂一些.

对于用户,则不必关注虚拟内存的实现,可以认为: 用户进程完整的占用了2^64大小的地址空间,除了顶部是内核使用的区域,剩下的区域都是进程私有的.

注: 虽然在各个用户进程的虚拟内存中,”内核区”被硬件限制不可访问,但是从OS的实现方式来说,每个进程的”内核区”中存储的都是完全一样的内容,OS内核的代码及数据就是存储在内核区. 对每个用户进程而言,它可以认为只有内核和自身两个程序在计算机中运行.

通过MMU访问内存的具体流程

  • 对于”n”位的虚拟内存空间, 整个虚拟内存空间可以被均匀地划分为若干个页(Page), 称为虚拟页VP(Virtual Page),每一页的大小是PageSize(现代系统中PageSize一般是4K或者4M). 根据PageSize,物理内存也可以划分为连续的若干个页,称为物理页PP(Physical Page)
  • 在使用虚拟内存时, 需要在物理内存中先准备若干页表(内核有一个页表,每个进程都有一个自己的页表), PTBR寄存器指向的页表称为激活页表,激活页表将供MMU使用.
  • 页表中存储的值称为PTE(Page Table Entry), 第k个PTE存储着第k个VP的信息,这个信息很简单,就是[valid,address]
  • 当硬件指令想要访问一个虚拟地址时VA时,它把VA发送给了MMU, MMU根据VA可以计算出它所在的VP的索引k, 然后通过PTBR寄存器读取页表中的PTE k项目, 得到第k个VP的信息[valid,address].
    • 如果valid为true: 那么,此时的VP是存储在主存中的, address存储的就是VP在主存中的地址.
    • 如果valid为false:
    • 那么, 若address为0, 则意味着该VP是未定义过的VP,不能使用.
    • 如果address不是0, 此时VP是存储在磁盘中的,address里存储的就是VP在磁盘上的地址,MMU会进一步触发缺页异常,之后由CPU来将对应的页swap到物理内存中.

程序

  • 常见的”调用约定”:
    • cdecl:调用方退栈,从右至左的顺序压入参数
    • stdcall:函数体退栈,从右至左压栈
    • fastcall: 函数体退栈,头两个参数用寄存器传入,其余的从右到左压栈
    • pascal: 函数体退栈,从左到右压栈
    • thiscall:MSVC限定,函数体退栈,this指针通过ecx传递(或者说第一个参数用ecx传递),其余参数从右到左压栈
  • Windows下产生的函数调用代码是比较”稀疏”的,里面会插入nop,move edi edi这样的”无用”代码,这是为了辅助用户(包括OS开发者)实现一些trick,例如,在运行期把这些代码改成call xxx,就能在特定位置拦截函数调用.
  • 现代OS及编译器对”缓冲区溢出”的防御.
    • ASLR(Address-Space Layout Randomization)地址随机化技术:在每次程序运行时,所有实例的地址都是不确定的(变量,栈,库代码,程序代码),此时,攻击者就难以通过反汇编预测攻击地址了.
    • 一个简单的栈随机化防御策略: 每次进入函数时,在栈帧头部随机的插入若干个空白字节,此时的栈空间基本就是不可预测的了.
    • GCC可以插入栈空间守护代码,在运行期检测栈上的数组是否存在越界访问.
      > 关于蠕虫和病毒:蠕虫一般是指独立的可执行文件,它们可以独立的执行/传播;病毒一般是指含有恶意代码的文件,这些文件只能通过其他手段被载入(例如病毒伪装成一个插件,在应用程序加载插件时,病毒利用漏洞执行自己的恶意代码)

编译和链接

  • 关于libc.so与glibc. 早期的linux使用称为libc.so的一个C语言运行库,在后期该库放弃了维护,转而使用了glibc,所以现在可以说libc和glibc是同一个东西.
  • 一般而言,汇编器的输出文件都是”目标文件”,Windows下是PE格式的,unix下是ELF格式的,链接阶段几乎就只是对目标文件进行修修补补而已.
  • 目标文件中一般包括几段不同的信息,这些段中,有的是ELF标准要求给出的,有的是语言Runtime需要的(典型的如CRT),有的是Debugger需要的,有的是编译器自定义的. 一般而言,目标文件的处理及debugger最好都使用和工具链绑定的.
  • 奇技淫巧: 事实上,我们可以在C/CPP源代码中直接引用目标文件中的名字.(例如,有的名字是编译器添加的)
    • 1.你需要知道mangle后的名字(或者在CPP中,使用extern “C”禁用mangle)
    • 2.如果是跨目标文件访问,你需要保证名字是exported.
namespace myname{
    int var =42;
}
extern "C" double _ZN6myname3varE;// 这里类型不重要,重要的是名字,名字已经绑定到指定的对象了
extern "C" char __executable_start[];//访问编译器的内置变量,在生成目标文件后,这个名字被编译器添加
int main(){
    int * c = &_ZN6myname3varE // 指向myname::var
    char * exec_start=__executable_start;// 指向入口,即main()


}
  • 静态库.a实际上是一个archive文件,链接器在链接的过程中,会丢弃没有被使用的.o目标文件,这也就引入了链接顺序的问题. 链接顺序问题的解决方法:保证所有需要的.o都被扫描并保留即可,可以把一些库多写几次.

静态链接和动态链接的核心区别: symbol解析的方式不同,动态链接的符号一定是在运行期解析的

动态库和静态库

  • 通过GOT间接引用的符号性质上和外部符号是一样的.
  • ldd列出的顺序就是动态库加载的顺序,动态库加载的时间顺序影响了每个动态库的符号解析.
    • ldd加载第 k 个动态库时,会尝试从前k-1个动态库的符号表中查找定义,并尝试解决k中的未定义符号.
  • 对于部分硬件,编译动态库时,使用fPIC会在某些场合生成性能更好的代码,这些平台上,编译器一般会优先建议你使用fPIC进行编译.
    • 例如,在x86_64下,正常编译动态库几乎总是会要求使用fPIC,否则就需要使用一些奇怪的编译选项来影响编译器的指令生成.
  • .o或.a文件的各个段数据会在进入可执行文件或动态库时全部被合并.
  • .so和可执行文件的结构没有本质上的区别,二者的段结构相同
  • fPIC的核心是间接寻址,而不是相对寻址.(很多指令都可以是相对寻址的)

  • 函数的局部变量(栈上)已经是相对寻址的了,所以没有变化,变化主要是其他的静态地址.

    • 对于当前单元可见的对象,如函数/变量,直接分配到本单元的RW段,并按照相对寻址的方式去使用这些对象. 在AMD64下,实现很简单,就是用当前指令的地址(IP寄存器的值)加上offset即可.
    • 对于当前单元不可见的对象,全部按照fPIC的策略进行引用,也就是说,使用access GOT[symbol_name]的形式来访问对象,GOT[symbol_name]的地址位于当前编译单元的某个确定位置,可以通过offset访问,其内部的值将由链接器Patch
  • Note: 函数调用的GOT实现相对复杂一些,主要是希望能支持延迟链接的特性.

  • 函数调用相对复杂一些,当一个函数的定义被加载到内存后,对linker而言,它的地址是已知的.
    got.plt段创建一个 foo@plt 函数直接跳转到 got.plt[foo]开始执行.

    • got.plt[foo] 一开始存储的是一个懒惰加载函数 init
    • init函数会修改got.plt[foo]的值
extern int var_foo; // 当前单元不可见, 使用GOT
int var_bar; //当前单元可见,分配至RW段,并按相对寻址使用

int func_foo();

int func_bar(){

}

void caller(){
  void * v1 = func_foo + var_foo;
  void * v2 = func_bar + var_bar;
}

  • fPIC编译得到的obj_fpic一般不能和非fPIC编译的obj_no_fpic进行链接.
    • 非fPIC编译的对象obj_no_fpic往往需要在链接期将代码段写成某一个固定的mem_addr,这种relocation的方式一般肯定会导致运行期错误, 对fPIC对象obj_fpic而言通常是不可接受的
  • fPIC是一种编译期的技术,在编译期生成代码时,它将代码段中的内容全部变为间接寻址(例如,使用GOT[“OBJ_NAME”]来获得对象的地址,或者相对PC寻址),从而可以避免链接期对自己代码段内容的重定位
    • fPIC需要使用GOT,所以理论性能会更差一些
    • 使用fPIC编译的代码,在mmap到内存后,被修改的部分比较少,所以可以避免CoW,更好的节约物理内存.
    • 小技巧:由于-fPIC编译的文件都是不需要重定位的,所以可以通过readelf -d foo.so | grep TEXTREL,看是否存在重定位表,来判断其类型
    • WINDOWS仅支持加载时重定位,不支持fPIC
    • arm64的动态库必须使用fPIC
  • 强引用/弱引用
    • 强引用: 必须在链接阶段解决的外部引用,找不到定义将报错”Undefined reference”
    • 弱引用: 在链接阶段可以不解决的外部引用,找不到定义不报错
    • 可以通过__attribute__((weakref)) void foo()强制使用弱引用.
    • 使用弱引用可以帮我们在运行期判断函数是否存在,从而辅助我们选择函数分支,例如,可以通过if(foo)判断是否有foo
  • 强定义/弱定义
    • 强定义: 有初始化语句的全局定义称为强定义(由于函数定义都是初始化了的,所以都是强定义)
    • 弱定义: 没有初始化语句的全局定义称为弱定义
    • 强定义唯一,弱定义不唯一,强定义优先于弱定义
      • 多个强定义:”redefination”
      • 一个强定义+多个弱定义:只保留强定义,(此时,部分访问可能会出现越界,例如强定义int a=4与弱定义long a;,在使用后者访问a时,就可能越界)
      • 多个弱定义:保留长度最长的那个弱定义,(此时,所有使用该对象的位置都可以获得足够大的地址,从而安全的IO)
    • 可以使用编译期修饰符将对象强制为__attribute__((weak)) int foo=2
    • 使用弱定义可以提供可被覆盖的对象,例如某静态库中有一个弱定义var,用户可以给出一个强定义覆盖它.
    • 从上面可以知道,弱定义在编译阶段是不能确定实际使用的地址的,必须在链接阶段解决;链接后的文件是没有弱定义的.(静态库只是archive,没有涉及链接器)
  • 动态链接过程中的”重定义”不会报错,但是执行结果与链接顺序有关,后加载的会被忽略,这一特性会导致一些Trick
    • 对于手动载入的动态库,dlsym和dlopen可以提供解决方案
    • 对于自动载入的动态库,可以通过链接期参数控制链接行为
    • 如果用库x.so来拦截函数foo(),那么在x.so中,可以通过dlsym动态查找到被拦截函数的地址,从而便于实现钩子.
  • C环境下对函数调用的拦截(打桩,插装)
    • 编译期拦截:主要是用#define old myold这样的宏实现
    • 链接期拦截:主要是利用GCC的--warp,foo链接选项.
      • 所有对foo的调用都被链接到__warp_foo
      • 那么如何使用原始版本呢? 使用__real_foo的调用将链接到原始版本
    • 运行期拦截:通过LD_PRELOAD来进行
  • 在编译可执行文件时,输出的可执行文件默认是不包含动态符号表的,这可能导致dlopen()打开的库无法访文进程的部分符号.可以使用-Wl,-export-dynamic 避免.
    • 例如,程序时dlopen了某个libfoo.so,这个libfoo.so 需要调用一个外部提供的bar()函数,而恰好这个bar函数是由可执行文件对应的main.c提供的,由于可执行文件没有导出动态符号,在动态链接阶段,链接器就会报错.
  • 对于动态库,只要符号是导出的,它的解析就一定会放到动态链接期resolve.
    • 例如,在libB中声明并定义了FooB(),那么即便是在libB中调用FooB(),这次调用也不会在编译期resolve