Linux系统易错点

动静态库加载

1、为什么动态库的函数地址需要GOT表存起来呢?

(1)动态库每次加载的虚拟基地址不同 → 函数真实虚拟地址每次都变 → 不能写死在代码里 → 必须用 GOT 存起来让程序间接调用.

(2)动态链接器算出来的函数地址要存起来(一般在运行时计算),而代码段不让存(代码段只读),只能存在数据段的 GOT 里。

  • 代码段:只读,不能改 → 不能放 GOT
  • 数据段:可写,能修改 → 必须放 GOT

(3)磁盘上那套虚拟地址只是编译时 的布局方案,内存里真正用的虚拟地址是加载后重定位过的,动态库的起始虚拟地址,每次加载都会变,所以必须用 GOT 保存每次算出来的真实地址

(4)库已经有地址了没错,但你的程序看不见这个地址,必须有人把它放进 GOT 这个"公用信箱"里,程序才能拿到。

为什么程序看不见这个地址???

(1)现代操作系统为了安全,普遍采用了 ASLR(地址空间布局随机化) 。程序编译成功后,每次程序启动,操作系统都会把共享库随机扔到一个内存地址上。这意味着:第一次启动printf函数可能在 0x7f1234567000;下次启动 ,它可能就在 0x7fabcdef1000。

编译器不可能在编译时就硬编码这个地址,因为对于动态库来说编译时的地址在运行时是无效的。

(2)实际上,程序代码段(.text)在内存中是只读多个进程共享的。

假设程序里写死了 call 0x7f1234567000,但下次启动时库地址变了,这个指令就是错的。更严重的是,如果强行修改代码段里的这个地址,由于代码段是共享的,修改它会导致物理内存复制(写时拷贝),从而失去共享库节省内存的优势。

2、动态库函数调用流程

(1)调用某个动态函数时,PLT 中会先执行跳转到对应 GOT 表项的指令;此时 GOT 表中存放的仍是指向 PLT 内部的地址,因此会继续执行 PLT 后续指令,将重定位索引号压入栈中,再跳转到动态链接器的解析函数;
(2)动态链接器根据该索引号找到 .rela.plt 中对应的重定位条目,获取函数符号名并完成符号解析,找到该函数在内存中的真实虚拟地址;随后将这个真实地址写入对应的 GOT 表项。

(3)后续再次调用该函数时,PLT 直接从 GOT 表中读取真实地址并跳转,不再触发动态链接器解析。

补充知识:(1)动态库加载完成时,GOT 里没有最终函数地址,第一次调用时才计算并写入真实地址到 GOT,这种将符号解析和重定位的工作 ,从程序启动时推迟到该函数第一次被实际调用 时,就是Linux 下默认的 延迟绑定

(2)延迟绑定是默认行为,但可以通过 LD_BIND_NOW 环境变量或 -z now 链接选项改为立即绑定,在加载时完成所有地址重定位。

3、上面提到的各种表的性质:

.plt (PLT表)→ 代码段,不可写

.got.plt (GOT表)→ 数据段,GOT 可写(所以才有 GOT 劫持漏洞)

.rela.plt (重定位表)→ 只读数据,负责告诉动态链接器对于每一个外部函数调用,它的 GOT 槽位在哪,该用哪个符号去解析,以及用什么方式计算地址。

4、动静态库的异同

(1)无论是静态链接还是动态链接,最终函数在内存中的虚拟地址都可以表示为:

地址 = 基址 + 函数在模块内的固定偏移

(2) a. 解析时机不同:
静态链接 :在链接阶段(编译后的链接步骤 )就完成了符号解析和地址计算,生成的可执行文件里已经是最终虚拟地址
动态链接:在程序运行时,由动态链接器完成解析。延迟绑定更是把解析推迟到第一次调用时。

b. 重定位方式不同:

静态链接:链接时就知道地址,直接写入代码段。

动态链接:代码段必须位置无关(PIC),不能自修改,所有需要变动的地址都放在数据段的GOT,运行时动态填充。

5、静态库与动态库在编译 - 链接 - 运行全流程中的差异。
静态库(.a) :链接时完成最终地址绑定

(1)编译阶段:源文件(add.c等)→ 编译器 → 目标文件(.o) ,每个 .o 包含代码段 / 数据段(segment)、符号表、重定位信息。静态库(.a)本质是多个.o文件的打包归档 ,本身不参与地址计算,只是目标文件的集合。

(2)链接阶段:链接器从静态库中抽取 被可执行文件引用的**.o文件,与其他目标文件合并。**

进行符号解析,完成地址重定位:为每个段(代码 / 数据)分配固定的虚拟地址,修正指令中的地址引用(把相对地址转为绝对虚拟地址)

(3)最终生成的可执行文件,已经包含了静态库的代码和数据 ,所有符号地址都是链接时确定的最终虚拟地址。

(4)运行阶段:可执行文件加载到内存后,直接使用链接时确定的虚拟地址执行,无需再依赖静态库文件。

动态库(.so/.dll) :链接时做准备,运行时完成地址重定位

(1)编译阶段:源文件→编译器→目标文件(.o),但会生成位置无关代码(PIC ),确保代码可在任意虚拟地址加载。动态库(.so)是这些.o的链接产物,本身包含代码段 / 数据段、符号表和重定位信息。

(2)链接阶段(动态链接):链接器不会将动态库的代码复制到可执行文件,记录可执行文件依赖的动态库的库名和路径;为动态库中的符号创建 GOT 全局偏移表、PLT 过程链接表,用于运行时绑定。

(3)运行阶段:操作系统加载可执行文件后,启动动态链接器。动态链接器加载所有依赖的.so到进程虚拟地址空间(加载地址由操作系统分配,每次运行可能不同);

完成动态重定位:

  • 解析符号:在加载的.so中找到符号的实际加载地址。
  • 更新重定位结构:将符号的虚拟地址填入 GOT/PLT 表,完成地址绑定。

6、现要将hello.o和code.o文件链接形成可执行程序,链接过程包括

  • section 合并:同名 section → 大 section
  • segment 生成:按属性把若干 section 放到一个 segment
    注意:这个 section → segment 的映射关系,在链接阶段就已经写入 Program Header Table 了。

链接过程混淆点:ELF里Section Header Table(SHT ) 和 Program Header Table(PHT) 的根本区别和作用时机。

1、链接器"参考" SHT 中的 section 属性来决定 segment 的组织结构。

2、PHT 是"结果",不是"指导"。它是描述 segment 的元数据,不是驱动 segment 合并的源头。

到加载阶段时:只是把 segment 根据PHT的内容直接映射到内存,不再处理 section。

7、!!!!!如何理解"所有的可执行程序就是一个segment"?

(1)实际情况:

每个 segment(例如 .text、.data 等)都各自从 0 开始计算偏移,加载时操作系统将各个 segment 分别映射到内存地址上,然后通过加上基地址来确定最终的绝对地址。

(2)抽象描述:说"所有可执行程序就是一个 segment"是一种抽象的描述,强调编译时和链接时地址都是相对的,从 0 开始计算,再由加载器加上基地址,使得整个程序能够被重定位和加载到内存中的任意位置。

8、平坦模式

(1)虽然操作系统采用平坦内存模型,但在可执行文件(例如 ELF 文件)中,仍然保持了段(segment)的概念 。链接器在生成可执行文件时,会把代码段(.text)、数据段(.data/.bss)等逻辑上区分开,每个段内部的地址都是从 0 开始计算的。

当操作系统加载一个可执行程序时,它不会将所有段都加载到相同的基地址上,而是根据链接器生成的 Program Header Table 为每个段分配一个不同的基地址 。例如:

虽然在各自段内,函数或变量的偏移都是 100(例如 .text 内部偏移 100 和 .data 内部偏移 100),但它们不会冲突,因为基地址不同。

代码段可能被加载到虚拟地址 0x00400000 开始;

数据段可能被加载到虚拟地址 0x00600000 开始。
func 的绝对地址 = 0x00400000 + 100
int a 的绝对地址 = 0x00600000 + 100

(2)在平坦内存模型中,所有地址看起来像是在一个连续的地址空间中,但实际上操作系统通过虚拟内存机制将各个逻辑段映射到不同的非重叠区域。这样就确保了即使各段内偏移相同,它们最终在内存中的绝对地址也不同。

!!!!可以这么理解:可执行程序没加载到内存之前里面各个变量,函数的地址是偏移量,等加载到内存后加上不同的基地址再成为真正的虚拟地址。

9、动静态库、可执行程序、.O文件都是ELF格式,以一定的格式放入到二进制文件的。

缓冲区

1、不管是用户级的语言层缓冲区(库缓冲区)还是文件内核缓冲区还是用户将来自己new or malloc的缓冲区都是内存的一段空间,空间会被os管理起来。

2、重定向

cpp 复制代码
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main() {
    // 库函数
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    const char *s = "hello fwrite\n";
    fwrite(s, strlen(s), 1, stdout);

    // 系统调用
    const char *ss = "hello write\n";
    write(1, ss, strlen(ss));

    fork();
    return 0;
}
  • 直接运行 ./a.out:终端是行缓冲,\n 会触发刷新,所以 4 行内容通过fd+系统调用拷贝到文件内核缓冲区,最后再拷贝到log.txt正常输出(只要把数据交给操作系统就相当于交给了硬件)。
  • 重定向到文件 ./a.out > log.txt:标准输出变为全缓冲,fork() 时父进程缓冲区未刷新,子进程复制了缓冲区,最终父子进程退出时各自刷新缓冲区,导致库函数输出的 3 行内容重复输出了 2 次;fork() 之前系统调用write,所以"hello write" 已经在文件内核缓冲区了,只打印一次。

3、涉及到 io 的操作都是数据拷贝,无论是从用户(语言层)的缓冲区到操作系统的内核级缓冲区还是从操作系统的缓冲区到磁盘,都是数据的拷贝,因此提高计算机的效率就是要提高拷贝的效率。

4、用户层缓冲区的刷新内部调用的是系统的write函数,把用户层的缓冲区的内容拷贝到系统里,同时用户层的文件关闭也封装了系统的close.

文件

1、不同设备表示二进制的方式不一样:磁盘--南北极ns,内存--充放电,高低电平,网络--有无,波形图的高低

2、磁盘可以看成是三维数组,c(柱面)h(磁道/磁头)s(扇区)就是数组三个下标

1KB(Kilobyte) = 1024B(字节),一个扇区:512B,1"块" =8个扇区 = 4096Bytes = 4KB

3、很多扇区构成磁道,很多相同半径的磁道构成柱面,很多柱面就构成磁盘-->把磁盘看成一维数组,扇区就是基本元素 ,标识扇区不再用CHS而是通过数组下标LBA

4、磁盘没有目录(目录和普通文件都是文件),在内存才有树状的目录结构(os会在内存记录磁盘io访问过的路径)。

5、分区是文件系统的载体 ,分区一定要有特定的目录进行关联,把磁盘分区挂载到某一个目录,进入目录,就相当于进入这个分区,在目录下创建新文件就相当于在分区里创建新文件。通过路径,有了inode,怎么知道这个 iNode 在哪个分区呢,路径里面就记录着挂载的分区。

6、文件加载流程:

fopen时,os在内核会添加cwd得到路径,(1)根据路径找到指定文件,查找struct dentry树进行路径搜索,把指定文件路径所有的节点打开,找到文件名和struct inode的映射关系;

(2)或者在磁盘里:根据路径依次搜索,找到文件的分区和磁盘inode,根据文件的inode找到磁盘文件的属性和内容,在内核中创建struct file:struct dentry->sturct inode,文件缓冲区,struct file_operations * f_op(文件操作函数集合的指针),然后把inode属性填充 ,把磁盘属性加载到内存,文件内容部分/全部加载到缓冲区里 ,然后把 struct file 对象的地址 在文件描述表分配,给用户返回文件描述符

7、(1)软链接(ln -s src dst):(可以是目录,普通文件)独立的一个文件,保存目标文件的路径,相当于一个快捷方式,软链接不是目录。

(2)硬链接(ln src dst):用户可以在当前目录的内容里新建一组新的文件名(只能是普通文件)与同一个 inode number 的映射关系,其实就是文件的引用计数+1,备份文件的原理就是这样。

只有操作系统可以对目录进行硬链接,比如"." ".."

相关推荐
赵民勇2 小时前
gtkmm之耗时操作不阻塞界面
linux·c++
Vect__2 小时前
记录3.20和3.21做过的一些力扣的思考
linux·算法·leetcode
原来是猿2 小时前
Linux-【ELF文件】
linux·运维·服务器
似水এ᭄往昔2 小时前
【Linux】--基础开发工具->gcc/g++
linux·运维·服务器
顶点多余2 小时前
Linux中库的制作和原理详解
linux·运维·服务器
feng_you_ying_li2 小时前
liunx指令的介绍(2)
linux·运维·服务器
claider2 小时前
Vim User Manual 阅读笔记 usr_25.txt Editing formatted text 编辑有格式的文本
linux·笔记·vim
yiwenrong2 小时前
系统初始化
linux
逸Y 仙X2 小时前
文章八:ElasticSearch特殊数据字段类型解读
java·大数据·linux·运维·elasticsearch·搜索引擎