当前位置: 首页 > news >正文

从ELF到进程间通信:剖析Linux程序的加载与交互机制

一、ELF文件格式

用一张图片简单了解一下ELF。

在这里插入图片描述

我们所见过的 .o,可执行文件,.so都是ELF格式的当然了,不止这些文件

ELF文件描述的是文件的内容,文件属性在 inode 里

1. 理解ELF Header

ELF Header 是用来描述ELF文件整体的布局情况的(ELF Header的起始位置,大小,program header的大小,数量...)

readelf -h ... //读取 ELF Header

在这里插入图片描述

ELF Header 就是一个结构体左边是结构体的属性,右边是结构体的内容。在编译时就要填写这些数据,所以编译器是能够识别 ELF Header,而加载时,OS也要从ELF文件读取数据,所以OS也认识 ELF Header

2. 如何看待文件位置

磁盘文件就是一个一维数组,无论是二进制文件还是文本文件

所以,ELF文件内容也是一个一维数组。确定文件位置只需要知道起始偏移量和大小就可以了

3. 了解ELF

readelf -S ... //读取 Section Header

在这里插入图片描述

section header table(节头表) 是对节(section)的描述

section(节)是ELF文件中的基本组成单位,包含了特定类型的数据,ELF文件的各种信息和数据都存储在不同的节中如代码节存储了可执行代码,数据节存储了全局变量和静态数据等

section header table也是一个结构体

program header table(程序头表):列举了所有有效的段(seg)和属性,合并成为 seg 的方法表

我们都知道 OS 和磁盘文件进行 IO 的时候,以 4KB为单位。那么一个一个 section 就一定是 4KB的吗?不是的

多个 section 可能会有相同的属性比如代码节和字符常量都只具有读权限,所以在进行 IO 时,多个 section 会进行合并(4KB对齐),将这种多个 section 进行合并形成一个 segment ,叫做数据段(seg),ELF加载到内存的时候,是会被 OS自动合并成多个 seg,加载到内存中

readelf -l ... // 读取 program header table

在这里插入图片描述

4. 理解链接与加载

objdump -d ... //将二进制文件中的机器码转换为人类可读的汇编指令

在这里插入图片描述
在这里插入图片描述

可以看到,调用函数的地方,在没有经过链接时,地址是全0,这意味着CPU将来执行该函数的时候,是没有办法执行的,因为0地址是不允许被访问的,编译器暂时将函数的地址设置为 0

链接之后会怎么样呢?

在这里插入图片描述
在这里插入图片描述

链接之后,函数有了确切的调用地址所以链接过程做了什么呢

把要调用的函数地址,从 0 重定位到最终目标函数的地址,这叫做链接时地址重定位

readelf -s ... //读取符号表

在这里插入图片描述
在这里插入图片描述

汇编时这两个函数是未定义的,链接时,这两个函数有了具体的地址

这里的16代表的是什么呢?代表的是 run 在第几个 section 里

一个ELF可执行程序,在没有加载到内存的时候,有没有地址呢?答案是有的

链接时,已经进行了地址重定位

Linux系统编译形成可执行程序的时候,需要对代码和数据进行编址,当代CPU和计算机、操作系统,对ELF编址的时候,采用的做法都是采用“平坦模式”进行编址,编址范围是全0到全F,按照线性地址统一编址的

那么,什么是平坦模式呢

段起始地址 + 偏移量的方式,其中段起始地址为0,只有偏移量的方式叫做平坦模式

在这里插入图片描述

可以看到,都是有具体地址的函数的本质,就是相邻地址的集合

线性编址得到的地址,其实就是之前我们所说的虚拟地址

磁盘上的可执行文件,起始地址 + 偏移量的这种地址,叫做逻辑地址

逻辑地址和虚拟地址其实是一个东西,只不过在ELF文件中叫做逻辑地址,在内存中叫做虚拟地址

这就像一个人在不同的场合下会有不同的称呼一样。

所以,程序内部互相调用,互相访问的地址是什么地址呢?答案现在已经很明确了。就是虚拟地址,也是逻辑地址

那么,当可执行文件加载到内存的时候,代码也是数据啊!每一行代码都要有自己的物理地址

所以,现在既有了虚拟地址也有了物理地址,那么不就可以在页表上建立虚拟地址和物理地址之间的映射关系了吗!

那进程要如何跑起来呢?进程要运行得先知道进程的起始地址吧

在这里插入图片描述

entry point address 就是进程的起始地址,将来加载可执行文件时,就会在页表构建物理地址和虚拟地址的映射关系

那么,CPU要如何才能拿到进程的起始地址呢

在CPU内部,有EIP,CR3两个寄存器,MMU(内存管理)硬件单元,EIP就是之前说到过的PC指针,CR3寄存器存储的是页表的起始地址。加载可执行文件时,可执行程序的入口地址就会被EIP拿到,MMU通过页表将虚拟地址转化为物理地址

所以,虚拟地址空间技术需要OS支持,编译器支持,CPU硬件支持

现在,我们就理解了,mm_struct, vm_struct中的 start, end 以及页表当中的权限是从哪里来的了ELF合并之后,segments的地址得到的

5. 动态库加载

为什么不谈进程是如何看到静态库的呢

因为链接时静态库里的数据会被拷贝到目标文件里,可执行程序是不依赖静态库的,所以没必要谈。

那么进程是如何看到动态库的

结论让程序跑起来,除了要加载可执行文件,还要加载依赖的库文件

动态库也是ELF格式的,也有虚拟地址,加载到内存时,会有物理地址,就可以在页表上建立虚拟地址与物理地址的映射关系,动态库会被映射到虚拟地址空间的共享区中

所以,进程是如何看到对应的库文件的

结论通过自己的虚拟地址空间中的共享区看到的

一个进程依赖的库可不仅仅只有一个,所以,共享区中存在的库也会有很多

进程也不会只有一个,那么多进程是如何看待动态库的呢

当启动一个进程时,动态库会被加载到内存里,通过页表构建虚拟地址和物理地址的映射关系。启动多个进程时,OS不会将动态库进行二次加载,只需要将动态库的物理地址和新的进程虚拟地址构建映射关系即可,这样可以有效节省内存空间

所以,多进程是如何看到同一个库的每一个进程把要的库映射到自己的地址空间中

比如,每个进程都需要 printf,我们不需要将 printf 的实现拷贝到每一个进程里。

所以,动态库的本质通过地址空间映射,对公共代码进行去重

我们已经对多进程如何看到同一个库有了一个宏观的认知,接下来,就具体谈谈。

动态库加载到内存就会与进程构建映射关系,就可以得到库在虚拟地址空间中的起始地址了,假设是0x654321(基地址),我们知道动态库也是 ELF 格式的,编址的时候采用的是平坦模式,那么,我们就得到了库中函数的偏移量0x1234。比如进程中使用了 printf 函数,当调用 printf 函数时,基地址 + 偏移量的方式(0x654321 + 0x1234) 就得到了 printf 函数的虚拟地址,再经过页表映射就能够找到库当中 printf 函数的实现,这叫做加载时地址重定位

那么多进程看到同一个动态库时,库在每一个进程的映射中虚拟地址都一样吗当然不是了,但是库的偏移量是不会改变的。这就叫做与位置无关,所以前面使用的 fPIC 选项就是这个意思。

结论1库函数的调用也是在进程的虚拟地址空间范围内调用

结论2动态库被映射到进程的任意位置(一般是共享区),我们的进程都能调用

结论3多进程映射的时候,每个进程都会把动态库映射到自己的地址空间,但是起始地址可能不同

那么问题来了,上面的工作是谁来做呢(加载动态库,动态加载时地址重定位)

以前学习编程时,我们都知道程序是从 main 函数开始执行的,但是现在我们知道进程是有入口地址的(entry point address),所以进程是从入口地址开始执行的

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

可以看到,进程是先执行 _start 方法的。预编译时,可以对程序进行宏替换,去注释,包含头文件,条件编译工作简称增删改操作。那么,加载可执行文件时,也可以在程序 main 函数之前添加一些东西

所以,加载可执行文件时添加的这些方法是从哪里来的呢

在这里插入图片描述

进程启动时,要先加载 ld 链接库,_start 就是 ld 内部的方法

动态链接:这是关键的一步,_start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确的映射到动态库中的实际地址

所以,上述的工作是由OS和动态链接器完成的

加载时地址重定位是基地址 + 偏移量得来的那么,代码区不是只读的吗,能直接把最终调用地址写入到代码区吗当然不可以了

在虚拟地址空间中,数据区存放着一个 .got表,这个表里存放着动态计算后的绝对虚拟地址(基地址 + 偏移量)。将来调用时,直接在 .got表里进行跳转就可以了,因为 .got表是在数据区的,所以 .got表是可以被修改的

偏移量在编译形成 ELF 格式时就已经确定了

在这里插入图片描述

二、进程间通信

1. 什么是进程通信?为什么要有进程通信?

如果未来进程之间需要协同呢?比如说A进程获取数据,B进程处理数据,是不是就需要这两个进程之间互相通信。

那么,进程之间要互相通信,就要求一个进程要把数据交给另一个进程。但是进程是具有独立性的,即便亲如父子进程,也不可能把数据给另一个进程

所以,进程间通信,就必须要有OS参与

这就像是两个人吵架了,就需要第三个人来进行调解。

所以,进程间通信的前提先让不同的进程看到同一份资源

这份资源一定是OS提供的某种形式的内存空间

进程间通信的目的

1.数据传输:一个进程需要将它的数据发送给另一个进程

2.资源共享:多个进程之间共享同样的资源

3.通知事件:一个进程需要向另一个或一组进程发送消息,通知它们发生了某种事件(如进程终止时要通知父进程)

4.进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程)

2. 进程间通信分类

管道:匿名管道 pipe、命名管道

System V IPC:System V 消息队列、System V 共享内存、System V 信号量

POSIX IPC:消息队列、共享内存、信号量、互斥量、条件变量、读写锁

3. 管道的概念与理解

在这里插入图片描述

我们都知道,命令的本质是可执行程序,命令行上命令启动之后就变成了进程,所以 who,wc -l 是两个进程,那么 who 进程是如何把自己的数据交给 wc -l 这个进程的呢?就是基于管道(| 代表管道)实现的

在这里插入图片描述

父进程创建子进程,子进程会以父进程为模板,拥有相同的 struct task_struct,task_struct 里有一个 struct file_struct* files 指针,该指针指向一个 struct file_struct的结构体,这个结构体里有一个 struct file* fd_array[]结构体指针数组。对于普通文件而言,也就是说,父子进程看到的文件是同一份文件。 struct file 里包含着文件的各种属性,通过 struct file 中的 inode 访问页缓存(inode 里面存储了文件的原信息和磁盘块的位置)加载的数据的,所有进程只要通过同一个inode 就可以访问同一份页缓存,父子进程拿着同样的文件描述符访问同一个文件中的 inode,不就可以看到同一份数据了吗

这不就是类似于管道吗。

文件的读写位置只有一个,对于父子进程来说,父进程在下标为100处写数据,子进程在100处是读不到数据的。所以,管道与普通文件是有差别的

为了解决这个问题,所以,管道在初始化时就创建了两个独立的 struct file (读端和写端),子进程通过继承的文件描述符来访问它们,这样父进程在向管道中写入的时候,子进程就可以从管道缓冲区的当前可读位置处读取数据

读端和写端有独立的 struct file 对象,但这两个 struct file 通过 private_data 字段共享同一个 pipe_inode_info 结构体

struct pipe_inode_info
{unsigned int head, tail; //读写指针struct pipe_buffer bufs[16]; //数据缓冲区...
}

它们的 f_op 和 private_data 被初始化为指向相同的管道操作集和 pipe_inode_info。管道一般适用于亲缘关系的父子进程

在这里插入图片描述

父进程要以读写方式打开管道文件。那么,为什么要以读写的方式呢?

如果只是读或者写的方式打开管道文件,那么子进程继承下来的就只有读或者写的方式,这不符合通信的逻辑。进程通信就应该一个写一个读

假设,父进程写入数据,子进程读取数据,那么最好是将父进程的读端关闭,子进程的写端关闭。为什么呢?主要是为了防止误操作

所以,这也导致了,管道是单向通信的如果父子进程之间需要互相通信,那就创建两个管道

采用这种做法,就形成了一个单向通信的信道,这个单向通信,基于文件的通信方式就叫做管道

在这里插入图片描述

4. 系统调用

// pipefd[0]代表读端,pipefd[1]代表写端,这是固定的
int pipe(int pipefd[2]); //创建管道,成功返回0,失败返回 -1

那么,使用管道进行进程间的通信,我们打开文件了吗,有路径,文件名吗是没有的。但是OS创建了两个 struct file 对象,所以,这两个文件不是从磁盘上加载进来的,不用向磁盘刷新,是内存级文件,没有名字,所以叫做匿名管道

现在,完善管道的定义是一个基于文件系统的一个内存级的单向通信的文件,主要用来进程间通信(IPC)的

管道的4种情况和5大特性

四种情况1. 写端不关,写端不写,管道里面没有数据,读端就会被阻塞 。2.读端不读,读端也不关,写满就不在写入。3.写端不写,写端关闭,read读到返回值为0,表示读到文件结尾。4.读关闭,写正常,OS会杀掉写进程

五大特性1. 常用于具有血缘关系的进程,进行IPC 。2. 单向通信。 3. 管道的生命周期随进程 。4. 面向字节流。 5. 管道自带同步机制

补充知识:单次向管道里面写入,写入的字节数小于PIPE_BUF,写入操作就是原子的

什么是原子呢简单来说就是一件事情只有对和错两种状态,不会有第三种状态

今天的内容分享到此结束,觉得不错的小伙伴给个一键三连吧。

http://www.lryc.cn/news/617160.html

相关文章:

  • 音视频学习(五十三):音频重采样
  • 动态创建可变对象:Python类工厂函数深度解析
  • Vue3从入门到精通:3.1 性能优化策略深度解析
  • Unity跨平台性能优化全攻略:PC与安卓端深度优化指南 - CPU、GPU、内存优化 实战案例C#
  • docker集群
  • 在Linux中部署tomcat
  • MyBatis高级特性与性能优化:从入门到精通的实战指南
  • NEON性能优化总结
  • EXISTS 替代 IN 的性能优化技巧
  • Unity大型场景性能优化全攻略:PC与安卓端深度实践 - 场景管理、渲染优化、资源调度 C#
  • C# 异步编程(BeginInvoke和EndInvoke)
  • openEuler、 CentOS、Ubuntu等 Linux 系统中,Docker 常用命令总结
  • Selenium经典面试题 - 多窗口切换解决方案
  • 深入解析游戏引擎(OGRE引擎)通用属性系统:基于Any类的类型安全动态属性设计
  • 如何在 Ubuntu 24.04 LTS Linux 上安装和使用 Flatpak
  • 游戏引擎(Unreal Engine、Unity、Godot等)大对比:选择最适合你的工具
  • [Ubuntu] VNC连接Linux云服务器 | 实现GNOME图形化
  • 从零开始的云计算生活——项目实战容器化
  • Ubuntu 22.04 离线环境下 Python 包与 FFmpeg 安装全攻略​
  • Python 爬虫:Selenium 自动化控制(Headless 模式 / 无痕浏览)
  • 使用Windbg分析多线程死锁项目实战问题分享
  • 从零开始的云计算生活——第四十一天,勇攀高峰,Kubernetes模块之单Master集群部署
  • 数据结构 双链表与LinkedList
  • 云原生环境Prometheus企业级监控
  • 浅谈 LangGraph 子图流式执行(subgraphs=True/False)模式
  • redis(2)-java客户端使用(IDEA基于springboot)
  • Selenium动态元素定位
  • glide缓存策略和缓存命中
  • 探秘华为:松山湖的科技与浪漫之旅
  • 打烊:餐厅开业前的“压力测试”