一个进程的内存可以分为内核区域和用户区域。
Mach-O工作原理:
加载执行流程:
Kernel内核:
- 解析Mach-O头部;2,映射__Text(只读); 3, 映射__DATA(读写)
Dyld:
- 递归加载依赖库
- 符号绑定与重定位(Rebase & Rebind)
- 初始化Objective-C运行时
- 调用+load方法
- 执行main函数
App:
- 程序运行。
抽象内存布局
cpu运行一个程序,实质就是在顺序执行该程序的机器吗。一个程序怼机器码会被组织到同一个地方,这个地方就是代码段。
另外,程序在运行过程中必然要操作数据。这其中,对于有初值的变量,它的初始值会存放在程序的二进制文件中,而且,这些数据部分也会被装载到内存中,即程序的数据段。数据段存放怼是程序中已经初始化且不为0的全局变量和静态变量。
对于未初始化的全局变量和静态变量,因为编译器知道他们怼初始值都是0,因此便不需要再在程序的二进制映像中存放那么多0,只需要记录他们怼大小即可,这便是BSS段。BSS(Block Started By Symbol)。
数据段和BSS段存放的数据也只是部分数据,只要是全局变量和静态变量,但程序在运行过程中,仍然需要记录大量的临时变量,以及运行时生成怼变量,这需要新怼内存区域,即程序的堆空间和栈空间。 与代码段和数据段不同的是,堆栈并不是从磁盘中加载的,而是程序运行过程中申请的,在程序运行结束后释放。
总的来说,一个程序要启动后的内存区域:__Text段、__Data段、__LINKEDIT段、堆空间和栈空间。
代码段保存的是程序的机器指令,这一段区域的内存往往是可读可执行,但不可写。
数据段保存的程序的静态变量和全局变量
bss段用于无初值的变量区域。
堆是程序员自由申请的空间,当我们在写程序时要保存数据,优先选择堆空间。
栈是函数执行时的活跃记录。
磁盘程序段(section)&& 内存程序段(Segment)
对于磁盘的程序,每一个单元结构称为Section,可以通过readelf -S来查看二进制文件中所有的Section信息。对于内存镜像,每一个单元结构称之为Segment,可以通过readelf -l的选项,来查看二进制文件加载到内存只会怼segment布局信息。
一般多个Section会对应一个segment。例如.text、.rodata等一些只读的section,会被映射到内存的一个只读/执行的segment里;而.data、。bss等一些可读写的Section,则会被映射到内存的一个具有读写权限的segment里。并且对于磁盘二进制中一些辅助信息的section,例如.symtab、。strtab等,不需要在内存中进行映射。
总的来说,Section主要是指在磁盘中的程序段,而Segment则用来指代内存中的程序段,segment是将具有相同权限属性的section集合在一起,系统为它们分配的一块内存空间。
谈谈你在哪些场景使用过mmap?
mmap是最常见怼三种用途,分别是使用私有匿名映射在堆上分配可用内存,私有文件映射用于加载动态链接库,以及是用共享映射来创建共享内存,以达到进程间通信的目的。
链接器 连接CPU与程序员之间的桥梁,把程序中的符号转换成CPU执行时的内存地址,即负责将符号转换为地址。
链接器的第一个作用:就是把多个中间文件合并成一个可执行文件。
链接器在合并多个目标文件的时候,并不是简单地将各个section合并就可以了,它还需要考虑每个目标中的符号的地址。这就引出了 链接器的第二个任务:重定位。所谓重定位,就是当被调用者的地址变化了,要让调用者知道新的地址是什么。
链接器的工作流程
第一步:链接器需要对编译器生成的多个目标(.o)文件进行合并,一版采取的策略是相似段的合并,最终生成共享文件(.so)或者可执行文件。这个阶段中,链接器对输入的各个目标进行扫描,获取各个段的大小,并且同时会收集所有的符号定义以及引用信息,构建一个全局怼符号表。当链接器构造好了最终的文件布局以及虚拟内存布局后,我们根据符号表,也就能确定了每个符号的虚拟地址。
第二步: 链接器会对整个文件再进行第二遍扫描,这一阶段,会利用第一遍扫描得到的符号表信息,依次对文件中每个符号引用的地方进行地址替换。也就是对符号的解析以及重定位过程。
这就是链接器常用怼两步链接步骤。简单来说,就是进行两遍扫描:第一遍扫描完成文件合并、虚拟内存布局的分配以及符号表信息收集;第二遍扫描是完成了 符号的重定位过程。
重定位是符号解析的重要步骤,是我们理解静态链接和动态链接的基础原理。
总结:从源文件生成二进制可执行文件,这一过程主要包含了 编译和链接 两个步骤,其中,编译的作用是生成性能优越的机器码。对于编译单元内部的静态函数,可以在编译时通过相对地址的办法,生成call指令,因为无论将来调用者和被调用者被安置到什么地方,他们之间的相对距离都不会发生变化。
而其他类型的变量和函数在编译时,编译器并不知道它们的最终地址,所以只能使用占位符(比如0)来临时代替目标地址。
链接器的任务是为所有变量和函数分配地址,并把被分配到的地址回写到调用者处,链接的过程主要分为两步:第一步是多文件合并,同时为符号分配地址;第二步是将符号的地址回写到引用它的地方。其中,地址回写有一个专门的名字叫重定位。重定位的过程依赖于目标文件中的重定位表。
编译器和链接器的区别
编译器的作用主要是将源代码文件翻译成中间结构(例如LLVM IR),然后对它进行优化,以优化程序的性能。编译器的输出是汇编文件,汇编文件中仍然保留了符号。然后,汇编编译器再将汇编文件翻译成中间文件。最后,链接器将中间文件合并合并起来,组成二进制可执行程序。这种合并不是简单地拼接,而是要为了符号分配地址,然后再把地址回填到引用符号的地方,这个过程就是重定位。而重定位才是链接器最重要的任务。
LIBRARY_PATH的使用时机是 链接器在做链接的时候,LD_LIBRARY_PATH的使用时机是在程序运行时。 动态链接技术节省内存。
LIBRARY_PATH:静态库
LD_LIBRARY_PATH: 动态库
不同模块之间符号的链接过程,需要推迟到加载时进行,这也是 动态链接 技术名字的由来。
动态链接的重定位发生在 加载期间 或者 运行期间。加载期间的重定位,它的实现依赖于 地址无关代码。
总结:为了节约内存,让进程间可以共享代码,可以把共享的代码都抽出来,放到一个文件中,多个进程可共享这个文件。这个可共享的文件就是动态库文件。动态库文件中的符号要在加载时才被解析,所以这种技术就叫动态链接技术。
如果两个共享库之间有引用关系的话,引用者和被引用者之间的相对位置都不能确定了。这时就需要引入 地址无关代码技术。对于内部函数或数据访问,因为其相对偏移是固定的,所以可以通过 相对偏移寻址的方式来生成代码。对于外部和全局函数或数据访问,则通过GOT表的方式,利用间接跳转将对 绝对地址的访问 转换为 对GOT表的相对偏移寻址,由此得到了地址无关的代码。
地址无关代码的核心结构
要实现代码段的地址无关代码,思路就是通过添加一个中间层,使得对全局符号的访问由直接访问变成间接访问。我们可以引入一个固定地址,让引用者与这个固定地址之间的相对偏移是固定的,然后这个地址处再填入其真正的地址,当然,这个地方必然位于 数据段 ,是每个进程私有的,这样才能做到在不同的进程里,可以访问不同的虚拟地址。这个新引入的固定地址就是全局偏移表GOT。
GOT中指向的是调用目标的在各自进程中的虚拟地址,我们是通过GOT表间接访问的方式,将对外部符号地址的直接依赖消除了。
每个进程都有自己的私有GOT段,GOT中记录了当前的so文件所引用的所有外部符号。这些外部符号都需要进行解析和重定位。这个工作由loader负责,其为符号分配并记录地址,然后将这些地址回写进GOT表。这个过程的原理和链接器的两阶段重定位过程几乎一致,区别仅仅是linker操作的是文件中的地址,而loader操作的内存地址。
动态链接通过GOT表加一层间接寻址的方式,解决了代码中call指令对绝对地址的依赖,从而实现了PIC的能力。GOT表中的地址是由加载器loader在加载时填充的。
patch code技术
A方法会调用B方法,这里本来应该生成“callq B”的指令,但因为B尚未加载,无法确定它的地址,就先调用一个虚拟内部函数,执行指令callq virtual_call_B。在调用virtual_call_B后,会加载并解析B方法,得到B方法的地址。最后一步是将B方法的地址写回到callq指令,执行callq B。
这就像在原来的代码安装了一个机关,当CPU执行到这个机关时,就会触发一次符号的重定位,然后这个机关就被替换掉了。下一次CPU再执行到这个call指令的时候,就可以正常调用到B方法了。
延迟绑定技术
为了避免在加载时就把GOT表中的符号全部解析并重定位,就需要 把要做的事情推迟到 必须做的时刻。
对于我们当前的问题来说,将函数地址的重定位工作一直推迟到第一次访问的时候再进行,这就是延迟绑定技术。
patch code 是一种延迟绑定技术,但是它要在运行时修改指令参数,这会带来风险。
所以动态库的延迟绑定选择了继续使用 GOT表来进行间接调用,然后patch的对象就不再是指令了,而是GOT中的一项。
理想情况下,我们把GOT中的待解析符号的地方都填成 动态符号解析的函数就可以了,当CPU执行到这个函数的时候,就会跳转进去解析符号,然后把GOT表的这一项填成符号的真正的地址。
静态库和动态库的区别?
静态库是编译阶段就和可执行文件打包链接在一起的,它可以看成是中间文件的简单集合,保留了符号,只有在静态链接的过程,才会真正地做地址分配和重定位。
而动态库在编译阶段,它的代码并不会被合并到可执行文件中,在运行时才会被加载进内存中,它被加载进内存的地址是不固定的,所以每次加载完成以后,才能为它的符号分配真实的内存地址,然后再把地址回填到引用它的GOT表中。动态库的一个优点是可以在多个进程间共享,从而可以减少内存的重复。
某次多级跳转的延迟绑定技术的整个重定位过程最终只会修改GOT的某个位置,其他位置都不必发生变化。
.code(也即.text段)和.plt(过程链接表)会被加载到内存的代码段(code segment),它的权限是可读可执行的,但不可写。.got表会被加载到数据段,它的权限是可读可写。
动态链接器的作用是用来对可执行文件中需要动态链接的这些全局符号进行重定位解析,填写GOT表等。对于动态加载链接库例如ld-linux。so的动态链接过程是在自身启动之后,首先需要完成自己的符号解析和重定位的过程,这个过程叫做 动态链接器的自举。
静态链接的过程中,不同的.o文件定义了相同的符号,此时链接器会报出redefine的错误。而动态链接加载库的执行策略是不同的,在碰到相同的符号时,只会将第一次碰到的符号添加到全局符号表中,而后碰到重名的符号就会自动忽略。这导致的结果是,不同包的同名函数,在运行时能看到的只有加载顺序在前的函数定义。例如,libfoo1.so依赖在前,那么最终运行时只能看到libfoo1.so的foo函数,即使是libfoo2.so里的函数调用foo,调用的也是libfoo1.so里的foo,而不是自己so的foo。
一个命令执行程序,在它的main函数执行之前,发生了哪些事情?
首先,shell在执行一个新的命令时,第一步会使用fork创建一个进程;第二步,调用execve来运行新的程序,execve的作用是处理命令行参数,处理环境变量,设置好进程空间,比如堆空间,主线程栈空间等。
第三步:动态链接器加载程序所依赖的动态库,如果动态库需要在加载时重定位,那么这一步加载器就会解析动态库的符号并做重定位,如果是运行时重定位,这一步只需要做最基本的动态库文件注册即可。这些工作都完成以后,就可以跳转到main函数中执行了。
共享库的加载过程,如何被加载进内存的?
共享库是通过系统调用mmap映射到内存的,映射方式为只读。共享库的代码段,因为不会被修改,所以多个进程可以共享,其背后的原理是各个进程的页表对共享库文件的代码段映射的物理内存相同。
对于共享库中的全局变量,因为映射方式为只读,所以会对全局变量添加写保护,这样当有进程需要对全局变量修改时,会触发写时复制,在内存中新开辟一个物理页,供这个进程单独使用,通过这样来保证了各个进程全局变量私有。
请简述mmap和malloc的区别与联系。
先说区别,首先,它们的实现机制不同,mmap是操作系统提供的系统调用,而malloc则是glibc提供的分配内存的接口;其次,它们的作用不同,malloc主要是为了分配堆内存,但是mmap除了可以用于分配内存,还可以用于加载动态链接库,进行驱动文件映射以加速I0,还可以用于创建共享内存进行进程间通信。
它们的联系是malloc和mmap都有分配堆内存的能力,然后malloc在向操作系统申请大块内存的时候还是要依赖于mmap的。
--------------------------------------swift------------------------------------------------
1,与 @usableFromInline 的关系
@frozen public struct AnyHashable {
// 内部存储,虽然标记为 public,但实际上是内部使用
@usableFromInline
internal var _box: _AnyHashableBoxBase
// 公开的初始化器
@inlinable
public init<H: Hashable>(_ base: H) box {}
}
组合使用的好处:
-
@frozen:保证布局稳定
-
@usableFromIn line:允许在模块内联中使用内部 API
-
@inlinable:允许跨模块边界内联函数体
PAC(指针认证码)和PIC(位置无关代码)是两个完全不同领域的技术,作用和目的也不同。 PIE:位置无关可执行文件
PAC:Pointer Authentication Code 安全机制,防止指针被篡改,提升程序和系统安全性
PIC: Position-Indepent Code 编译/链接技术 生成可在任意内存地址加载执行的代码,提高代码重用和安全
PAC:指针认证码
是硬件支持的安全基址,给指针附加认证码,防止指针被篡改。
工作原理:利用硬件密钥和指针值计算认证码,使用时验证,认证失败则触发异常。
应用场景:主要防止控制流动劫持攻击(例如内存漏洞修改函数指针、返回地址等)
应用架构:目前主要是在苹果的ARM64e架构上实现
PIC:位置无关代码:
通常指动态链接库(Shared Libraries / .so / .dylib) 中的代码。
它是一种编译时的代码生成策略。编译器在生成机器码时,不使用绝对内存地址,而是使用相对寻址(如通过全局偏移表 GOT 或程序计数器 PC-relative)来访问全局变量和函数。
一种编译技术,生成的机器代码不依赖于固定的内存地址,可以被加载到任意地址执行。
工作原理:通过使用相对地址和间接寻址,避免代码中出现绝对地址,支持动态链接库(.dylib / .so)和共享库。
PIE:位置无关可执行文件
对象:指主程序(Main Executable) 本身。它是一种链接时的输出格式。它要求整个可执行文件(包括代码段、数据段)都必须像动态库一样,能够在内存中的任意位置加载并正常运行。
安全与随机化:主要目的是支持主程序的 ASLR(地址空间布局随机化)。在 PIE 出现之前,主程序的基地址是固定的,容易成为内存攻击的靶子。PIE 使得主程序的代码段(__TEXT)和数据段(__DATA)也能像动态库一样被随机偏移(Slide),极大地提升了安全性。
应用场景:动态库和共享库的编译;
操作系统加载器将库加载到任意地址。
提高代码重用和节省内存
any hashable 和 Hashable协议
Hashable是一个协议,定义了类型必须实现的接口,例如hash(into:)和‘==’。
当写any Hashable,它表示“某个具体类型的实例,这个类型符合Hashable协议,但不知道具体是什么类型。这就是存在类型“Existential Type”,它封装了一个具体类型的值,但只暴露协议定义的接口。
为什么 any Hashable 本身不符合 Hashable?
any Hashable是一个协议类型的值,它本身没有实现 Hashable协议的具体方法。
可以把它理解为一个黑盒,里面装着某个具体协议的实例,这个实例符合Hashable,但协议类型本身没有实现哈希算法。
swift字典的key类型必须是Hashable的值,但 any Hashable这个协议类型本身明确实现‘hash(into:)’和‘==’。 换句话说,编译器不知道如何对 any Hashable类型的值计算哈希值或比较相等,因为它知道这是某个符合Hashable的类型,但不知道是哪一个或不知道具体实现内容,只知道接口。
因此,any Hashable 不能满足字典Key的要求。
AnyHashable可以作为字典key,是因为它是一个结构体,已经实现了Hashable协议。它内部封装了具体的Hashable类型实例,并且实现了调用内部实例的哈希和比较方法。所以AnyHashable可以作为字典Key,支持混合不同类型的Hashable值。
Hashable协议和字典key的约束关系
Swift字典的key类型必须满足Hashable协议,也就是说,key类型本身必须实现哈希算法(hash(into:))和相等比较(“==”)。这保证了字典能通过哈希算法快速定位键对应的值。
什么时候可以作为字典key?
只有当类型本身实现了Hashable协议的所有要求,包括哈希函数和相等比较,才能作为字典Key。
虚拟内存基于程序局部性原理
对于iOS 应用来说,虚拟内存是基于物理内存上建立的一个逻辑地址空间。 虚拟内存不是真实的硬件内存,而是操作系统通过页表构造出来的一个抽象地址空间。 每个进程看到的是连续的 4GB(32 位)或 16EB(64 位,实际使用部分)虚拟地址空间,但实际上背后的物理内存可能是碎片化的,甚至只有部分在物理内存中。
- CPU找到真实地址的步骤:
》确定页目录基址
-》定位页目录项(PDE):32位的虚拟地址拆分成10位-10位-12位三段
-〉定位页表项(PTE):存储着物理地址
-》确定真实的物理地址
2,线性地址(虚拟地址)映射到物理地址;
虚拟地址是一个整数,
逻辑地址是一对整数(包含了段基址和段内偏移值的地址形式)
由CPU的MMU将线性地址映射为物理地址,然后就可以交给 地址总线 去进行读写了。
只有用到的符号才会被重定位,这就是延迟绑定技术。
dl_runtime_resolve解析符号并做重定位
signal(SIGPIPE, SIG_IGN) 忽略SIGPIPE信号,防止网络操作导致应用崩溃
ps:Swift 5.7允许在函数参数中使用some关键字来声明不透明类型参数
Swift 5.3 以后,private 访问权限在同一类型的 extension 中也可以访问(即扩展被视为同一作用域),但在之前版本中不行。
如果你使用的是较新版本 Swift,private 在同一类型的 extension 中是可以访问的.
@State 的作用是:
让 SwiftUI 在视图结构体(struct)之外,为这个属性分配一个独立的、持久的存储空间,并在值变化时自动触发视图重绘。
- DispatchQueue.main是主线程队列,但它和Swift开发的MainActor并不完全等价。
Swift并发的MainActor是一种任务隔离机制,编译器会静态检查访问权限,而GCD是运行时调度。
编译器的静态检查不认为DispatchQueue.main.asyncAfter里的闭包自动属于@MainActor。
推荐用 @MainActor 标记类,或者用 Task { @MainActor in ... } 来确保闭包执行环境。
这样才能消除编译器的“数据竞争”警告。