程序员的自我修养_读书笔记

news/2025/1/14 18:22:01/

1. 基本知识

1. 计算机系统

1.1 计算机系统的硬件部分

计算机最主要的三个部件:CPU、内存、I/O控制芯片。

早期的计算机中CPU和内存频率基本一致,两者直接连在一根总线上,I/O设备频率较低,通过I/O控制器链接在总线上。

北桥:为协调CPU、内存和高速的图像设备,专门设计了一个高速的北桥芯片用于它们之间交换数据。北桥芯片的最高速度为133MHz。

南桥:用于处理低速设备之间数据交换

对称多处理器(SMP): 一台计算机多个CPU,多个CPU在系统中所处的地位和发挥的功能完全相同。多处理器应用场景最多的场合是商用的服务器和需要大量计算的环境。

多核处理器(Multi-core processor): 多个处理器之间共享缓存部件,只保留多个核心并且包装成一个处理器。实际上是SMP的简化版。

1.2 计算机系统的软件部分

系统软件: 一般将用于管理计算机本身的软件称之为系统软件。系统软件可分为两类:平台性的软件(包括操作系统内核、驱动程序、运行库等)、用于应用程序开发的软件(编译器、连接器)

计算机软件的体系结构: 硬件————>操作系统内核——(系统调用)——>运行库————(API)————>应用软件

接口: 计算机体系结构中各个层级之间需要相互通信,相互通信所需的协议,一般称之为接口。

API: 应用程序使用的接口是运行库提供的,这种接口称之为应用程序编程接口(API)

系统调用:运行库使用的操作系统提供的接口称为系统调用接口。系统调用的实现往往以 软件中断 的方式提供。

操作系统的功能: 提供抽象的接口 + 管理硬件资源

计算机的资源主要分为CPU、存储器和I/O设备。 为了充分利用硬件资源,操作系统发展出了很多不同的方式。

CPU利用率提升历程:单道程序 ——> 多道程序 ——> 分时系统 ——> 多任务系统

多任务系统: 操作系统接管了所有的硬件资源并且本身运行在受硬件保护的级别。所有应用程序都已进程的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会得到CPU,但是如果运行时间超过限制,操作系统会暂停该进程,将CPU分配给其他的进程。这种CPU的分配方式成为抢占式——操作系统可以强制剥夺CPU的资源并且分配给它认为目前最需要的进程。

在多任务系统中,操作系统相当于主控,控制着CPU应该由哪个进程使用。

操作系统作为硬件的上层,它是对硬件从管理和抽象。对于操作系统上层的运行库和应用程序来说,它们希望看到的是统一的硬件访问模式。硬件细节全部由操作系统中的硬件驱动程序来完成。驱动程序可以看成是操作系统的一部分,其往往和操作系统一样运行在特权级,但是它又和操作系统内核之间具有一定的独立性,使得驱动程序有比较好的灵活性。

多任务操作系统中,站在进程的角度来看,每个进程仿佛独占了CPU,不用考虑与其他进程分享CPU的事情。那么针对每个进程,内存是如何使用的呢?

程序对内存的使用方式发展过程:程序直接运行在物理内存上 ————> 虚拟地址

程序直接运行在物理地址上存在的问题: 地址空间不隔离、内存使用效率低、程序运行的地址不正确

地址空间是一种比较抽象的概念,可以理解为地址范围。

地址空间分为两种: 物理地址空间 + 虚拟地址空间

物理地址空间: 物理地址空间是实实在在存在的,存在于计算机中,可以理解为物理内存。

虚拟地址空间:虚拟地址空间是虚拟的,其实并不存在,每个进程都有自己独立的虚拟地址空间,从而做到了进程间地址空间的隔离。

分段(Segmentation): 把一段程序所需的内存空间大小的虚拟空间映射到某一个地址空间。其实就是将虚拟地址空间和物理地址空间做一对一映射。

分段解决了 进程间地址空间不隔离 和 程序运行的地址不正确 的问题,但是仍旧没有解决内存使用效率低的问题。

分页(Paging): 分页的基本方法是把地址空间人为地等分为固定大小的页,每页的大小由硬件决定,一般为4KB。程序在运行过程中只把部分页映射到内存中,其余保留在地盘中,等到需要时在映射到内存中。从而实现高效的使用内存。

虚拟内存的实现需要依靠硬件的支持,一般都是采用MMU(集成在CPU中)来进行页映射。

CPU ———虚拟地址———> MMU ————物理地址————> 物理内存

2. 线程

2.1 线程基础

线程是程序执行流的最小单元,一个标准的线程由线程ID、当前指令指针、寄存器集合和堆栈组成。各个线程之间共享程序的内存空间(代码段、数据段、堆)以及一些进程级的资源(打开文件、信号)。

每个线程都有单独的栈,同一个进程中的所有线程共享一个堆。

线程的访问非常自由,它可以访问进程内存中的所有数据,甚至包括其他线程的堆栈,但实际上线程也有自己的私有存储空间:栈(尽管并非完全无法被其他线程访问,但一般情况下认为是私有数据)、线程局部存储(TLS)、寄存器

线程在计算机上总是并发执行的,当线程数量小于处理器数量时,是真正的并发。

在线程调度过程中,线程通常拥有三种状态:就绪态、运行态、等待态

每个线程都有自己的线程优先级,操作系统根据线程优先级来决定执行哪一个线程

在优先级调度下,存在一种饿死的现象,即优先级低的线程迟迟得不到调度。为解决该问题,操作系统会为迟迟得不到执行的线程提升优先级。

线程优先级改变的三种方式: 用户执行优先级、根据进入等待状态的频繁程度提升或降低优先级、长时间得不到执行而被提升优先级

windows中有标准的线程和进程。但是在linux中,线程并不是一个通用的概念,linux将所有的执行体称为任务(Task),每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。Linux下不同的Task之间可以共享内存空间,因而在实际意义上,共享了内存空间 的多个任务构成了一个进程,而这些任务就成了进程中的线程。

linux下可以使用三种方法创建一个新的任务: fork 、 exec、 clone。

fork函数产生一个和当前进程完全一样的新进程,并和当前进程一样从fork函数里返回。其中本任务的fork返回新的pid,新任务的fork返回0。

fork产生新任务的速度非常快,因为fork并不复制原任务的内存空间,而是和原任务共享一个写时复制的内存空间。

写时复制:两个任务可以同时自由的读取内存,但是任意任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用。

2.2 线程安全

多线程同时访问一个共享数据时,可能造成很恶劣的后果。

为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们要将各个线程对同一个数据的访问同步或采用原子操作。

同步:指一个线程访问数据未结束时,其他线程不得对同一个数据区进行访问。

同步最常见的方式是使用 锁。

锁是一种非强制机制,每一个线程在访问数据或资源前首相试图获取锁,并在访问结束之后释放锁。在锁已经被占用的时候试图获取锁,线程会等待,直到重新可用。

信号量:

互斥量:

临界区:

读写锁:

条件变量:

可重入:一个函数可重入指的是这个函数没有被执行完成,由于外部因素或内部调用,又一次进入该函数被执行。

一个函数被重入只有两种情况:多线程同时执行这个函数、函数自己调用自己。

可重入函数的特点:1. 不适用任何静态或全局非const变量 2. 不反悔任何静态或者全局的非const变量的指针 3. 仅依赖调用方提供的参数 4. 不依赖任何单个资源的锁 不调用任何不可重入函数。

可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。

2.3 线程模型

用户态线程并不一定在操作系统内核里对应同等数量的内核线程。

用户态线程与内核态线程的对应关系: 一对一模型、多对一模型、多对多模型

一对一模型: 用户态线程与内核线程一致,并发性能好。一般直接使用API或者系统调用创建的线都是一对一的线程。

一对一模型缺点:用户态线程数量受限、上下文切换开销大,执行效率下降

多对一模型:多个用户态线程映射到一个内核态线程。

多对一模型的缺点: 一个用户态线程阻塞,那么所有的线程都将无法执行,内核态线程也会随之阻塞。处理器的增多对 多对一模型的线程性能不会有明显提升。

多对一模型的优点:上下文切换效率高、线程数量无限制

多对多模型:多对多模型结合了多对一和一对一模型的特点,将多个用户态线程映射到少数但不止一个内核线程上。

2. 静态链接

1. 程序构建

从源代码到可执行文件,中间一共由四个过程: 预编译、编译、汇编、链接。

1.1 预编译

预编译过程将源文件编译成后缀为.i(或者 .ii)的文件,在此过程中将源文件中以 “#”开始的预编译指令。

gcc -E test.cpp -o test.ii

预编译过程中对预编译指令的处理规则:

  1. 将所有的#define删除,且展开所有的宏定义。
  2. 处理所有条件预编译指令
  3. 处理#include 预编译指令,将被包含的文件插入到该预编译指令的位置。
  4. 删除所有的注释
  5. 添加行号和文件名标识
  6. 保留所有#pragma编译指令,编译时需要使用。

1.2 编译

编译过程就是将预编译之后的文件进行一系列的分析及优化后生成相应的汇编代码文件。编译过程一般分为6步: 扫描、语法分析、语义分析、源代码优化、代码生成、目标代码优化。

gcc -S test.ii -o test.s

对于C语言来说,预编译和编译的程序是ccl,C++语言的预编译和编译的程序是cclplus。其实gcc命令是ccl等后台程序的包装,它会根据输出入参数的不同选择调用哪些程序

1.3 汇编

汇编过程是将汇编文件转换成由机器指令构成的目标文件的过程。

gcc -c test.s -o test.o

1.4 链接

链接过程是将多个目标文件和库文件连接在一起,生成可执行文件的过程。

重定位:重新计算各个目标地址的过程称之为重定位(Relation)。

当程序被分割成多个模块之后,这些模块最红如何组成一个单一的程序? 这个问题可以归结为各个模块之间如何通信的问题,以C/C++为例,模块间通信的方式有两种:模块间的函数调用 + 模块间的变量访问。函数调用需要知道函数的地址,变量访问也需要直到目标变量的地址。所以这两种方式可以归结为一种方式————模块间符号的引用。而模块间符号引用的过程就是 链接 的过程。

链接的主要内容就是把各个模块相互引用的地方处理好,使得各个模块能够正确的连接。

链接的主要过程包括: 地址和空间的分配、符号决议、重定位。

运行时库:是支持程序运行的基本函数的集合。

库:其实就是一组目标文件的包

2. 目标文件

2.1 目标文件格式

可执行文件格式主要是Windows下的PE和Linux下的ELF格式。目标文件和可执行文件的内容和格式很相似,所以一般跟可执行文件采用同一种格式存储。另外,动态链接库和静态链接库文件都可按照可执行文件格式存储。

Linux系统中采用ELF格式的文件有: 可重定位文件(目标文件)、可执行文件(.out)、共享目标文件(动态库文件)、核心转储文件

2.2 目标文件内容

目标文件中的内容: 数据段、代码段、Bss段、只读数据段(.redata)、注释信息段(.comment)、堆栈提示段(.note.GNU.stack)

目标文件将不同的信息以段的形式进行存储,源代码编译之后的机器指令放在代码段(.text), 已初始化的全局变量(包括静态和非静态)和静态局部变量放在数据段(.data),未初始化的全局变量和局部静态变量放在Bss段中。

未初始化的全局变量和局部静态变量的默认值为0,本来它们也可以放在data段中,但是因为它们都是0,所以它们在.data段分配空间并且存放数据0是没有必要的。在程序运行的时候未初始化的全局变量和局部静态变量的确需要占据内存,但是在目标文件或者可执行文件中不需要为它们分配空间并存放数据,所以bss段只是预留了位置,其中并没有内容。

数据和指令分段存放的好处:隔离、共享、提高CPU缓存命中率

只读数据段存放的是只读数据,一般是程序中的全局只读变量和字符串常量。

小技巧: static int x1 = 0; //x1存放在哪个段? -----Bss段
static int x2 = 1; //x2存放在哪个段? -----data段

为实现特定需求,可以指定相应数据或函数所处的段

重定位表:链接器在处理目标文件的过程中,需要对目标文件中的某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置,这些重定位所需的信息就存放在重定位表中。

对于每个需要重定位的代码或者数据段,都会有一个重定位表。一个重定位表同时也是一个段。

2.3 链接的接口——符号

在链接过程中,将函数和变量统称为符号,函数名和变量名就是符号名。

链接过程是基于符号才能正确完成的,链接过程中很关键的一部分就是符号的管理。每一个目标文件都有一个相应的符号表,这个符号表中记录了目标文件中所有符号,没有符号都有一个对应的值,这个值就是函数或变量的地址!

符号表中的内容:
1. 定义在本文件中的全局符号,可以被其他文件应用
2. 本目标文件应用的全局符号,却没有定义在本目标文件中,一般称为外部符号
3. 段名
4. 局部符号,这类符号只在编译单元内部可见,如静态局部变量。这类符号对于链接过程没有用途
5. 行号信息

特殊符号:链接器在生产可执行文件过程中,他会定义很多特殊符号,这些符号没有在程序中定义,但是程序员可以在程序中中直接生命并使用它。

C++符号修饰:为了处理函数重载,函数名冲突等问题,编译器引入了函数签名机制,根据函数名、命名空间、参数类型等信息生成一个不会重复的符号。

可以使用c++filt工具将函数签名解析为正常的函数。

extern “C” : C++编译器会将extern "C"修饰的函数当作C语言代码处理,C++的名称修饰机制将不会起作用。

宏 "__cplusplus"会在编译器编译C++代码时默认定义,可以使用该宏来判断当前编译单元是否是C++代码。

强符号和弱符号: C/C++语言来讲,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。

强符号和弱符号都是针对定义来讲的,并非针对符号的应用。例如,extern int a; 其中符号a既非强符号也非弱符号

链接器对强弱符号的处理原则:

  1. 不允许强符号被多次定义,如果不同的目标文件中出现多个同名的强符号,链接器会报重定义的错误。
  2. 如果一个符号在某个目标文件中是强符号,在另一个目标文件中是弱符号。则链接器会选择强符号。
  3. 如果一个符号在所有目标文件中都是弱符号,则选择其中一个占空间最大的一个。

强引用和弱引用:在链接过程中,如果目标文件引用了外部符号,但是没有找到符号的定义,链接器报符号未定义错误,这类符号的引用称之为强引用;如果引用的符号没有定义,链接器不报错,那么就称这种引用为弱引用。

Linux程序设计中,如果一个程序被设计成支持单线程或多线程模式,就可以通过弱引用的方式来判断当前程序是否连接到了多线程Glibc库,从而执行单线程版本或者多线程版本。例如

    #include <stdio.h>#include <pthread.h>int pthread_create(pthread_t*,const pthread_attr_t*,void* (*)(void*),void*) __attribute__((weak));int main(){if(pthread_create){printf("123\n");}else{printf("456\n");}return 0;}

Linux使用 “strip” 命令来去掉ELF文件中的调试信息。

3. 静态链接

3.1 空间与地址分配

对于链接器来说,整个链接过程就是将几个目标文件加工之后合并成一个输出文件。

链接器为目标文件分配地址和空间。这里的地址和空间有两层意思:

  1. 一个是指在输出的可执行文件中的空间。
  2. 一个指在装载后虚拟地址中的虚拟地址空间。

对于有实际数据的段,如数据段、代码段,它们在文件中和虚拟地址中都要分配空间。
对于.bss这样的段,分配空间的意义只局限于虚拟地址空间。

现代链接器空间分配基本上采用相似段合并的策略。该策略分为两步:空间与地址分配 + 符号解析与重定位

空间与地址分配: 扫描所有目标文件,获取各个段的长度、属性及位置,然后将同属性的段进行合并,并计算出输出文件中各个段合并之后的长度和位置,并建立映射关系。将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。

符号解析与重定位:使用 第一步中的信息,读取输入文件中段的数据、重定位信息,并且进行符号解析和重定位、调整代码中的地址。

链接前目标文件中的虚拟空间地址均为0,表示此时虚拟空间还没有分配;链接后的可执行文件中各个段都被分配了虚拟空间地址,所有虚拟内存地址均不为0

在Linux系统下,ELF可执行文件默认从0x08408000开始分配。

在将各个段进行合并之后,各个符号在段内的地址已经确定。此时,链接器需要为各个符号添加一个偏移值,使其能够调整到正确的虚拟地址。

3.2 符号解析与重定位

在目标文件中,引用符号的虚拟内存地址尚不确定,所以编译器将其置为无效值。在完成链接的空间与地址分配过程后,所有符号的虚拟内存地址均已确定,因此链接器会修正各个引用符号的虚拟内存地址。

对于可重定位的ELF文件来说,他必须包含重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表,而重定位表就是ELF文件中的一个段。

在重定位过程中,每一个重定位的入口都是对一个符号的引用,那么当链接器需要对某一个符号的引用进行重定位时,就要确定这个符号的目标地址。此时,链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。

当链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就会报出符号位定义错误。

重定位过程中指令修正方式:绝对寻址修正 + 相对寻址修正

绝对寻址修正和相对寻址修正的区别:绝对寻址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差。

3.3 COMMON 块

由于存在弱符号机制,多个目标文件中可能存在多个相同符号,但是符号的类型不同。链接器本身感知不到符号的类型,所以采用一个COMMON块的机制将弱符号放到COMMON块中,并且按照最大类型符号进行存储。

多个符号定义类型不一致的情况:

  1. 两个或两个以上强符号类型不一致 ———— 重定义错误
  2. 有一个强符号,其他都是弱符号,出现类型不一致
  3. 两个或者两个以上弱符号类型不一致

弱符号(未初始化的全局变量)为什么不能像未初始化的静态局部变量一样在.BSS段中分配空间?
当编译器将一个编译单元编译成目标文件时,如果该目标文件中包含弱符号,那么该弱符号最终占据的空间大小是不确定的,所以编译器无法为该符号在BSS段分配空间。但链接器在链接过程中确定弱符号的大小,所以它可以在最终的输出文件的BSS段为弱符号分配空间。综合来看,未初始化全局变量最终还是被放在BSS段中。

3.4 C++相关问题

重复代码消除: C++编译器会产生很多重复代码,比如模板、外部内联函数、虚函数表等都可能在不同的编译单元内生成相同的代码。为了消除这些重复代码,编译器在编译目标文件的时候将这些代码单独放在一个段中。链接过程中,相同的代码只保留一份到最后的可执行文件的代码段中。

Gcc将这种需要在最终链接时合并的段叫“Link Once”,它的做法是将这种类型的段命名为“.gnu.linkonce.name”。

函数级别链接: 将所有的函数单独保存在一个段中,在连接过程中只链接必要的函数段。这种方式可以有效的减小输出文件的大小,但是会减慢编译和链接的时间。

C++全局对象的构造和析构在main函数执行前和执行后开始执行。因此ELF文件中定义了两个特殊的段:

  1. .init 段 :该段中保存的是可执行指令,它构成了进程的初始化代码。因此,在main函数开始执行之前。Glibc的初始化部分安排执行这个段中的代码指令。
  2. .finit段 :该段保存着进程终止代码指令,但main函数正常退出时,Glibc会安排执行这个段中的代码。

3.5 静态库链接

静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成为文件。

但代码中使用静态库中的函数时,在链接过程中需要链接静态库中的目标文件,而静态库中目标文件往往都依赖着其他目标文件,所以最终需要链接的目标文件很多,需要链接器去完成所需目标文件的查找和链接工作。

3. 装载与动态链接

1. 可执行文件的装载与进程

操作系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操作系统的掌控之中。进程只能使用操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。

32位操作系统的整个4GB内存空间被划分成两部分,其中操作系统本身用了一部分(约1G),剩下的3GB空间原则上都是留给进程使用的。

QUESTION: 32位CPU下,程序使用的空间能不能超过4GB?

ANSWER: 此处的空间如果指虚拟地址空间,答案为“否”,因为32位的CPU只能使用32位的指针,它的最大寻址范围就是0到4GB。
如果此处的空间指的是内存空间,那么答案为“是”,目前的CPU大部分采用36位的物理地址,可以访问高达64位的物理内存。

1.1 转载的方式

动态装载:根据程序运行的局部性原理,将程序中最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面。

覆盖装入和页映射 是两种很典型的动态转载方法。

覆盖装入: 程序在编写过程中将程序分割成若干模块,然后编写一个小的辅助代码(覆盖管理器)来管理这些模块何时驻入内存,何时应该被替换。 该方式基本已经被淘汰了

覆盖转入的方式要求程序员将模块按照它们的调用依赖关系组织成树状结构。

覆盖管理器需要保证两点:

  1. 模块关系树从任何一个模块到树的根模块都叫调用路径。当该模块被调用时,整个调用路径上的模块都在内存中。
  2. 禁止跨树间调用

页映射: 将内存和所有磁盘中的数据和指令按照“页”为单位划分成若干个页,在程序运行的过程中根据需要将页动态的换入或换出内存。

目前主流的操作系统都是按照这种方式转载可执行文件。

1.2 从操作系统的角度看可执行文件的转载

从操作系统来看,一个进程最关键的特征是它拥有独立的虚拟地址空间。

创建一个进程,然后装载相应的可执行文件并执行,在有虚拟存储的情况下,该过程最开始只需要做三件事:

  1. 创建一个独立的虚拟地址空间
  2. 读取可执行文件并且建立虚拟空间与可执行文件的映射关系
  3. 将CPU的指令寄存器设置成可执行文件的入口地址,然后启动。

创建虚拟地址空间:虚拟空间有一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建虚拟地址空间实际上并不是创建空间而是创建映射函数所需的相应的数据结构。

读取可执行文件并且建立虚拟空间与可执行文件的映射关系:建立虚拟空间与可执行文件之间的映射关系,可以在程序执行过程中发生缺页错误时,使操作系统知道当前所需要的页在可执行文件的哪个位置。

1.3 进程虚拟空间分布

可执行文件中包含很多段,在将可执行文件映射到进程虚拟空间的时候就会产生很多浪费(因为系统会以页长度作为映射单位)。站在操作系统的角度,它并不关系可执行文件中各个段中的内容,而仅仅关系各个段的权限(可读、可写、可执行)。为了减少映射过程中的浪费,系统会将相同属性的段合并成一个段进行映射。合并之后的段称为“Segment”。一个Segment中包含多个Section。

Segment实际是从装载的角度重新划分ELF文件的各个段。

对于LOAD类型的Segment,有可能它在ELF文件中占据的空间小于在进程虚拟地址空间中占据的空间。因为BSS段在ELF文件中不占据空间,但是在进程的虚拟地址空间中要占据空间,且该部分空间的内容全部为0。

1.4 堆和栈

进程的堆和栈在虚拟地址空间中也已VMA的形式存在。

一个进程基本上可以分为如下几种VMA区域:

  1. 代码VMA: 权限只读、可执行、有映像文件
  2. 数据VMA: 权限可读写、可执行、有映像文件
  3. 堆VMA: 权限可读写、可执行、无映像文件、匿名、可向上拓展
  4. 堆VMA: 权限可读写、不可执行、无映像文件、匿名、可向下拓展

进程刚刚启动的时候,需要知道一些进程运行的情况,最基本的就是系统环境变量和进程的运行参数。最常见的方式就是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中。在进程启动后,程序的库部分会把栈里的初始化信息中的参数信息传递给main函数,也就是我们熟知的argc和argv两个参数。

Linux内核装载ELF过程: 首先在用户层面,bash会调用fork()系统调用来创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件。原先的bash进程继续返回等待刚才启动的新进程结束。


2. 动态链接

2.1 为什么要动态链接

静态链接浪费空间。多个程序中可能包含相同的目标文件,这使得多个程序同时执行时内存中存在多份重复的内容。

静态链接对程序的更新、部署和发布会带来很多麻烦。因为程序中所使用的任何目标文件发生变更,整个程序都需要重新编译链接。

要解决静态链接浪费空间和更新困难的问题,最简单的方式就是把程序的模块相互分割开来,形成独立的文件,而不是将它们静态的链接在一起。

动态链接: 不对那些组成程序的目标文件进行链接,等到程序要运行的时候在进行链接。也就是说,把链接的过程推迟到运行时在进行。

动态连接的基本实现: 动态链接的基本思想是将程序按照模块拆分成各个相对独立的部分,在程序运行时才将它们链接在一起形成一个完成的程序,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。

Linux系统下,动态链接文件被称为动态共享对象,简称共享对象,一般都是以.so为拓展名的一些文件。

Windows系统下,动态链接文件被称为动态链接库,以.dll为拓展名的文件。

程序与动态链接文件之间真正的链接工作是由动态链接器完成。

动态链接程序在运行时,对应的进程空间中不仅有可执行文件的映射,还有动态链接文件和动态链接器文件的映射。

动态链接文件的最终装载地址在编译时是不确定的,而是转载时,动态装载器根据当前地址空间的空闲情况动态分配的。

3. 地址无关代码

共享对象在装载时,如何确定它在进程虚拟地址空间中的位置?

为了解决模块装载地址固定的问题,我们需要让共享对象在任意地址加载。即,共享对象在编译时不能假设自己在虚拟进程空间中的位置。

3.1 装载时重定位

装载时重定位(基址重置)的思想:在链接时,对所有绝对地址的引用不做重定位,而把这一步推迟到装载时在完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址进行重定位。

装载时重定位的缺点: 装载时重定位需要修改指令,所以没办法做到同一份指令被多个进程共享。

动态链接库中的可修改数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。

gcc在编译动态共享文件时,如果不使用-fPIC参数,只是用-shared,那么输出的共享文件就是使用装载时重定位的方法。

3.2 地址无关代码 PIC

装载时重定位会修改指令,使得同一份指令不能被多个线程共享。为了使程序模块中共享的指令部分在装载时不因装载地址的改变而改变,所以实现的基本想法是把指令中那些需要修改的部分分离出来,跟数据部分放在一起。这种方式就是地址无关代码方式。

共享对象中地址引用的方式可以分为四种:

  1. 模块内的函数调用、跳转
  2. 模块内的数据访问,如模块中定义的全局变量、静态变量
  3. 模块外的函数调用、跳转
  4. 模块外的数据访问、比如其他模块中定义的全局变量

这四种地址引用方式转换为地址无关性代码的方式:

  1. 模块内的函数调用、跳转——相对跳转和调用
  2. 模块内的数据访问,如模块中定义的全局变量、静态变量——相对跳转和调用
  3. 模块外的函数调用、跳转——间接跳转和调用(GOT)
  4. 模块外的数据访问、比如其他模块中定义的全局变量——间接跳转和调用(GOT)

间接跳转和调用GOT的基本思想:把地址相关的部分放到数据段中。由于其他模块的全局变量的地址是跟模块装载地址有关的,所以ELF的做法是在数据段里面建立一个指向这些变量的指针数组(也称为全局偏移表 GOT),但代码需要引用该全局变量时,可以通过GOT中相应的项间接引用。

如何区分一个DSO是否是PIC?
使用命令 readelf -d foo.so | grep TEXTREL , 如果有任何输出,那么该动态共享文件就不是PIC。

对于模块中定义的全局变量,共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,通过GOT来实现变量的访问。但共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器会把GOT中的相应地址指向该副本,这样该变量在实际运行时就始终有一个实例。如果变量在贡献给i按模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块的变量副本,如果该全局变量在程序主模块中没有副本,那么GOT中相应地址就指向模块内部的该变量副本。

数据段地址无关性: 对于数据段中的绝对地址引用,由于每个进程都会有一份独立的数据段副本,所以并不担心被进程修改。所以可以使用装载时重定位的方法来解决数据段中绝对地址引用问题。

对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表。但动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,动态链接器就会对该共享对象进行重定位。

3.3 延迟绑定(PLT)

动态链接性能较低的两个原因:

  1. 对于全局和静态数据要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先GOT定位,然后在进行间接跳转。流程复杂,降低性能。
  2. 程序开始执行时,动态链接器需要将必要的动态库装载完毕,然后进行符号查找和地址重定位等工作,减慢程序的启动速度。

为了解决第二种情况,ELF采用了一种延迟绑定(PLT)的做法。其基本思想是:当函数第一次被用到时才进行绑定(符号查找、重定位等)。这种做法能够大大提高程序的启动速度。

延迟绑定的基本原理:调用外部模块的函数,非延迟绑定的方式是通过GOT中相应的项进行跳转。为了实现延迟绑定,在查找GOT表之前加了一层间接跳转的过程。如果是第一次调用外部某函数,通过_dl_runtime_resolve函数进行调用。该函数执行完之后将目标函数的地址存入GOT中,下次调用该函数可以直接通过查找GOT表进行调用。

_dl_runtime_resolve函数的参数是待调用函数的符号和待调用函数所在模块。

3.4 动态链接相关结构

动态链接的基本过程:动态链接情况下,可执行文件的装载与静态链接的装载基本一样。当可执行文件的各个段映射到进程的虚拟地址空间之后,操作系统会启动动态链接器,动态链接器启动之后会根据当前的环境参数对可执行文件进行动态链接工作。当所有的动态链接工作完成之后,动态链接器会将控制权交给可执行文件的入口地址,程序开始正常执行。

可执行文件使用的动态链接器的位置是由可执行文件中.iterp段中的内容决定的。

.dynamic段:动态链接ELF中最重要的结构就是.dynamic段,这个段中保存了动态链接器所需要的基本信息。

.dynamic段的结构由一个类型值和一个附加的数值或者指针组成。类型包括动态链接符号表的地址、动态链接字符串表地址、初始代码地址和结束代码地址等。

.dynamic段里面保存的信息有点类似ELF文件头。

动态符号表: 用来表示动态链接这些模块之间的符号导入导出关系。动态符号表对应的段名是.dynsym,静态符号表中包含所有的符号,包括动态符号表中的符号。

3.5 动态链接的步骤和实现

动态链接的基本步骤:1. 启动动态链接器(自举 bootstrap) 2.装载所有所需的共享对象 3. 重定位和初始化

4. Linux 共享库的组织

4.1 共享库版本

共享库的更像分为两种: 兼容更新 + 不兼容更新

兼容更新: 所有的更新只是在原有的共享库基础上添加一些内容,所有原有接口都保持不变

不兼容更新:共享库更新改变了原有的接口,使用该共享库原有借楼的程序可能不能运行或者运行异常

导致C语言共享库不兼容的行为主要有:

  1. 导出函数的行为发生变化
  2. 导出函数被删除
  3. 导出数据的结构发生变化
  4. 导出函数的接口发生变化

C++的二进制兼容性很差 !!!

4.2 共享库路径

FHS标准规定:一个系统中主要有三个存放共享库的位置

  1. /lib :主要存放系统中最关键和最基础的共享库
  2. /usr/lib :主要存放一些非系统运行时所需要的关键性库。主要是开发时用到的库
  3. 3./usr/local/lib :主要存放一些跟操作系统本身并不十分相关的库,主要是一些第三方应用程序的库

动态链接器查找共享库的顺序:

  1. 由环境变量LD_LIBRARY_PATH指定的路径
  2. 由路径缓存文件指定的路径
  3. 默认共享库路径,先、usr/lib 后/lib

4.3 共享库的构造和析构函数

GCC提供了一种共享库的构造/析构函数,只有在函数声明之间加上 “attribute((constructor))”的属性,即指定该函数为共享库构造函数,会在共享库被加载时执行
只有在函数声明之间加上 “attribute((destructor))”的属性,即指定该函数为共享库析构函数,会在共享库被卸载时执行

程序运行的环境由三部分组成: 内存 + 运行库 + 系统调用

内存是承载程序运行的介质,也是程序进行各种运算和表达的场所。

5. 库与运行库

1. 内存

1.1 程序的内部布局

在32位的系统中,内存空间拥有4GB的寻址能力。如今的程序可以直接使用32的地址进行寻址,这被称为平坦的内存模型。

虽然理论上程序有32位的寻址能力,但是大部分的操作系统都会将内存中的一部分挪给内核使用,应用程序无法直接访问这一段内存,这段内存称为 内核空间

Windows默认情况下会将高地址的2GB内存分配给内核(也可以配置成1GB),

Linux默认情况下将高地址的1GB空间分配给内核。

除去内核空间,剩余部分留给应用程序使用,这部分内存称为 用户空间

在用户空间中,有许多地址区间有特殊的地位,一般来讲,应用程序使用的内存空间中,有几个默认的区域:

  1. 栈: 用于维护函数调用的上下文,离开了栈,函数调用就无法实现。栈通常在用户空间的最高地址处,通常由几兆的内存。
  2. 堆:堆是用来容纳应用程序动态分配的内存区域。当应用程序使用new或者malloc申请内存时,得到来自内存中的堆里。堆通常存在于栈的下方,而且容量一般比栈大得多。
  3. 可执行文件映像:存储着可执行文件在内存中的映像。
  4. 保留区:保留区并不是一个单一的内存区域,而是内存中收到保护而禁止访问的内存区域的总称。
  5. 动态链接库映射区:用于映射装载的动态链接库。在linux下,如果可执行文件依赖其他共享库,那么系统就会为它在0x40000000开始的地址分配相应的空间,并将共享库载入到该空间。

1.2 栈

在计算机科学中,栈被定义为一种数据结构,可以将数据压入栈中或从栈中弹出。但是容器必须遵守的一条规则是:先入栈的数据后出栈。

在计算机系统中,栈则是一个具有先入后出属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。入栈使栈增大,出栈使栈减小。

在经典的操作系统中,栈总是向下增长的。

栈在程序运行的过程中具有举足轻重的地位。最终要的是,栈保存了一个函数调用所需要的维护信息。这些信息称为堆栈帧(或 活动记录)

堆栈帧一般包括以下几方面内容:

  1. 函数的返回地址和参数
  2. 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
  3. 保存的上下文:包括函数调用前后需要保持不变的寄存器。

函数的调用惯例:函数的调用方和被调用方对于函数如何调用需要有一个明确的约定,只有双方都遵守同样的约定,才能保证函数被正确的调用。

函数的调用管理包括以下几方面:

  1. 函数参数的传递顺序和方式:函数参数的传递方式有很多种,最常见的就是通过栈传递。函数的调用方将参数压入栈中,函数自己将参数从栈中取出使用。对于由多个参数的函数,调用惯例要规定函数调用方将参数入栈的顺序是从左到右,还是从右到左。
  2. 栈的维护方式:在函数调用方将参数入栈之后,函数体被调用,此后需要将压入栈中的参数全部弹出,以使栈在函数调用前后保持一致。这个弹出的工作可以由函数调用方来完成,也可以由函数本身来完成。
  3. 名字修饰的策略:为了链接的时候对调用惯例进行区分,调用惯例要对函数名字本身进行修饰。不同的调用惯例有不同的名字修饰策略。

C语言默认的调用惯例是cdecl 。参数传递方式是从右向左入栈,函数调用方负责出栈,名字修饰是在函数名前加一个下划线。

C++有自己特殊的调用惯例:thiscall

函数返回值传递:如果函数的返回值只有4个字节,则通过寄存器eax传递;如果函数返回值是5~8个字节,则通过寄存器eax和edx联合返回;当返回值超过8个字节的返回类型,函数返回时会使用一个临时的栈上内存区域作为中转,结果返回值对象会被拷贝两次。

对于C++的对象,函数返回时进行了一次拷贝构造和一次赋值操作,仍然相当于产生了两次拷贝。但是C++有返回值优化功能RVO,某些场景下可以减少一次拷贝。

1.3 堆与内存管理

1.3.1 什么是堆

栈上的数据在函数返回时就会被释放掉,所以无法将数据传递到函数外部。而全局变量没有办法动态生成,只能在编译时定义,在很多时候缺乏表现力。因此会用到堆。

堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分。在这片空间中,程序可以请求一块连续的内存并且自由的使用。这块内存在程序主动放弃之前都会一直保持有效。

管理着堆内存空间的往往是程序的运行库。

运行库相当于向操作系统申请了一块较大的内存,然后零售给程序用。当运行库申请的内存全部使用完毕之后会再次向操作系统申请。当运行库向程序零售空间时,必须管理好堆空间,以免冲突。因此,运行库需要一个算法来管理堆空间,即堆分配算法。

Linux系统下提供了两种堆空间分配的方式,即两个系统调用:brk()系统调用 + mmap()系统调用

brk系统调用的作用实际上就是设置进程数据段的结束地址,即它可以扩大或者缩小数据段(Linux下数据段和BSS段合并在一起统称数据段)。如果我们将数据段的地址向高位移动,那么扩大的部分就可以被我们作为堆空间。

mmap系统调用的作用是向操作系统申请一段虚拟地址空间。但它不见地址空间映射到某个文件的时候,我们称这段空间是匿名空间。匿名空间可以作为堆空间。

glic中的malloc函数处理用户空间请求的原则是:对于小于128K的请求来说,他会在现有的堆空间中按照堆分配算法分配一块空间给程序;对于大于128K的请求来说,他会使用mmap函数为它分配一块匿名空间,然后在匿名空间中为用户分配空间。

1.3.2 堆分配算法

1.空闲链表法

将堆中各个空闲的块按照链表的方式链接起来,当用户申请一块内存的时候,可以遍历整个列表,直到找到合适大小的块并且将他们拆分,当用户释放空间时将它合并到空闲链表中。

  1. 位图(bitmap)

将整个堆划分成大量的块,每个块大小相同。当用于申请内存的时候,总是分配整数个块的空间给用户,第一个块称为已分配区域的头,其余的称为已分配区域的主体。然后使用一个整数数组来记录块的使用情况,其余每个块只有头/主体/空闲三种状态,因此只要两位就可以表示一个块。

优点:速度快、稳定性好(为避免越界读写,可以备份一个位图)、块不需要额外的信息,易于管理

缺点:分配内存的时候容器产生碎片、堆空间很多或者块太小时,位图会很大。

  1. 对象池

当被分配对象的大小时几个固定的值时,可以基于这个特征设计一个更为高效的堆算法——对象池。对象池的思路很简单,按照每次要求分配的内存大小将内存空间分配成大量的小块,每次请求时只需要找到一个小块就可以了。

2. 运行库

2.1 入口函数及初始化

操作系统装载程序之后,首先运行的代码并不是main的第一行,而是别的代码。这些代码负责准备好main函数执行所需要的环境并且负责调用main函数。main函数返回之后,它会记录main函数的返回值,调用atexit注册的函数,然后结束进程。

程序的入口点实际上是一个程序的初始化和结束部分,它往往是运行库的一部分。

一个典型程序的运行步骤大致如下:

  1. 操作系统创建进程,将控制权交给程序的入口,这个入口往往是运行库的某一个入口函数
  2. 入口函数对运行库和程序运行环境进行初始化。
  3. 入口函数完成初始化之后调用main函数,正式开始执行程序主体部分。
  4. main函数执行完毕之后,返回到入口函数,入口函数进行清理工作,包含全局变量析构,堆销毁,关闭I/O等,然后进行系统调用,结束进程

2.2 入口函数

glibc入口函数: glibc的启动过程在不同的情况下差别很大。静态glibc和动态glibc、glibc用于可执行文件或用于共享库都有很大差别。其中静态glibc用于可执行文件是最简单的情况。

glibc的程序入口是_strat函数,_start函数有汇编语言实现且和平台相关。

_start函数最开始的三个操作:

  1. 将ebp寄存器清零。ebp寄存器设置为0正好可以体现出在外层函数的特殊地位
  2. 获取用户参数的数量,即argc的值
  3. 获取用户参数和环境变量,即argv和环境变量

2.3 运行库与I/O

IO即输入输出,对于计算机来说,I/O代表了计算机与外界的交互,交互的对象可以是人或者其他设备。

对于一个程序来讲,I/O代指了程序与外界的交互,包括文件、管道、网络等。

更广义的讲,I/O指代任何操作系统理解为“文件”的事务。

在操作系统层面,文件操作也有类似FILE的一个概念,在Linux里,叫做文件描述符,在windows里,叫做句柄(handle)。

用户通过某个函数打开文件以获得文件描述符,此后用户操作文件皆通过该文件描述符进行。

设计句柄或者文件描述符的原因在于,句柄或者文件描述符可以防止用户随意读写操作系统内核的文件对象。文件句柄或者描述符都是和内核文件对象相关联的,但是关联细节不对用户暴露。

在内核中,每个进程都有一个私有的“打开文件列表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件。这个打开文件列表的下表称为fd。

Linux中,值为0,1,2的fd分别代表标准输入、标准输出和错误输出。在程序中打开的文件得到的fd从3开始增长。

I/O初始化函数需要在用户空间中建立stdin、stdout、stderr及其对应的文件结构,使得程序进入main之后可以直接使用printf等函数。

2.4 C/C++运行库

任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使程序能够正常运行。这套代码至少包括入口函数及其所依赖的函数构成的函数集合,当然还包括各种标准库函数的实现。这样的代码称之为运行时库。

一个C语言运行库大概包含了如下功能:

  1. 启动和退出:包括入口函数及入口函数依赖的其他函数
  2. 标准函数
  3. I/O:I/O功能的封装和实现
  4. 堆:堆的封装和实现
  5. 语言实现:语言中特殊功能的实现
  6. 调试

运行库是平台相关的,因为它与操作系统结合的非常紧密。C语言的运行库从某种程度上来讲就是C语言程序与操作系统之间的抽象层,它将不同操作系统API抽象成相同的库函数。

由于全局和静态对象的构造必须出现在main函数之前执行,全局和静态对象的析构必须在main函数执行之后执行。为满足此需求,运行库在每个目标文件中引入了两个与初始化相关的段.init和.finit段。运行库会保证所有位于这两个段中的代码会先于/后于main函数执行,所以用他们来实现全局和静态对象的构造和析构。链接器在链接时会把所有目标文件的.init和.finit按照顺序收集起来,然后合并成输出文件中的.init和.finit。

其实C++全局对象的构造函数和析构函数并没有直接放在.init和.finit段中,而是把一个执行所有构造和析构函数的调用放在里面,由这些函数真正的构造和析构。

2.5 运行库与多线程

线程的访问非常自由,它可以访问进程内存中的所有数据,甚至包括其他线程的堆栈,但实际运用中线程有自己的私有存储空间。

线程的私有存储空间包括:

  1. 线程局部存储(TLS)
  2. 寄存器

线程之间共享的内容包括:

  1. 全局变量
  2. 堆上的数据
  3. 函数里的静态变量
  4. 程序代码。任何线程都有权力读取并执行任何代码
  5. 打开文件

2.5.1 线程局部存储实现

各个编译器公司都有自己的TLS标准。我们在g++/clang++/xlc++中可以看到如下的语法:__thread int errCode;

即在全局或者静态变量的声明中加上关键字__thread,即可将变量声明为TLS变量。每个线程将拥有独立的errCode的拷贝,一个线程中对errCode的读写并不会影响另外一个线程中的errCode的数据。

C++11对TLS标准做出了一些统一的规定。与__thread修饰符类似,声明一个TLS变量的语法很简单,即通过thread_local修饰符声明变量即可。

int thread_ local errCode;

一旦声明一个变量为thread_local,其值将在线程开始时被初始化,而在线程结束时,该值也将不再有效。对于thread_local变量地址取值(&),也只可以获得当前线程中的TLS变量的地址值。

3. 系统调用

3.1 什么是系统调用

系统调用是应用程序与操作系统内核之间的接口,它决定了应用程序是如何与内核打交道的。无论程序是直接进行系统调用还是通过运行库,最终还是会抵达系统调用这个层面上。

为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些必须由操作系统支持的行为,每个操作系统都会提供一套接口,这套接口就是系统调用 。系统调用通常通过中断来实现。

每个系统调用对应内核源代码中的一个函数,它们都以sys_开头。

系统调用完成了应用程序与内核之间交流的工作。大部分系统调用都有两个特点:

  1. 使用不便
  2. 各个操作系统之间系统调用不兼容。

为了解决系统调用存在的问题,出现了运行库。它作为应用程序与内核之间的一个抽象层,可以保持这样的特点:

  1. 使用简便
  2. 形式统一

3.2 系统调用的原理

操作系统有两种特权级别,分别是用户态和内核态

由于有多种特权模式的存在,操作系统就可以让不同的代码运行在不同的模式上,以限制它们的权力,提高稳定性和安全性。

系统调用是运行在内核态的,应用程序基本是运行在用户态的。

操作系统一般是通过中断来从用户态切换到内核态。

中断一般具有两个属性,一个称为中断号,一个称为中断处理程序。

不同的中断具有不同的中断号。在内核中,有一个数组称为中断向量表,这个数组的第n项包含了指向第n号中断的中断处理程序的指针。

由于中断号是有限的,操作系统会用一个或少数几个中断号来对应所有的系统调用。Linux系统使用0x80作为系统调用的中断号,系统调用号通过eax寄存器传入内核。


http://www.ppmy.cn/news/104291.html

相关文章

这10道测试用例面试题,面试官肯定会问到

前言 软件测试面试中&#xff0c;测试用例是非常容被问到的一个点&#xff0c;今天小编就给大家把最常见的测试用例方面的问题给大家整理出来&#xff0c;希望对大家的面试提供帮助。 1、 什么是测试用例‍ 答&#xff1a;测试用例的设计就是如何覆盖所有软件表现出来的状态…

asan 输出案例解析

asan 功能 内存越界, 错误释放, 临时变量地址引用 输出案例 代码 int main(int argc, char **argv) {int *array new int[100];delete [] array;return array[argc]; // BOOM }编译 .PHONY:all all:g test.cpp -O -g -fsanitizeaddress输出结果 33220ERROR: AddressSani…

土木女生,挖掘个人优势成为程序员

大家好&#xff0c;这里是程序员晚枫&#xff0c;全网同名&#xff0c;专注于0基础转行程序员的方向规划和学习辅导。 后台咨询第二多的问题&#xff0c;就是&#xff1a;如何转行&#xff1f; 后台咨询第一多的问题&#xff0c;就是&#xff1a;如何快速又轻松的转行&#x…

【自然语言处理】【大模型】ChatGLM-6B模型结构代码解析(单机版)

ChatGLM-6B模型结构代码解析(单机版) ​ 本文介绍ChatGLM-6B的模型结构&#xff0c;代码来自https://huggingface.co/THUDM/chatglm-6b/blob/main/modeling_chatglm.py。 相关博客 【自然语言处理】【大模型】ChatGLM-6B模型结构代码解析(单机版) 【自然语言处理】【大模型】BL…

Dubbo配置

dubbo配置官网参考 1.配置原则 JVM 启动 -D 参数优先&#xff0c;这样可以使用户在部署和启动时进行参数重写&#xff0c;比如在启动时需改变协议的端口。 XML 次之&#xff0c;如果在 XML 中有配置&#xff0c;则 dubbo.properties 中的相应配置项无效。 Properties 最后&a…

python-sqlite3使用指南

python下sqlite3使用指南 文章目录 python下sqlite3使用指南开发环境sqlite3常用APICRUD实例参考 开发环境 vscode ​ 开发语言&#xff1a; python vscode SQLite插件使用方法&#xff1a; 之后在这里就可以发现可视化数据&#xff1a; sqlite3常用API Python 2.5.x 以上…

GoogleTest之gMock: Macros

目录 EXPECT_CALL EXPECT_CALL EXPECT_CALL(mock_object,method_name(matchers...)) 创建一个mock对象mock_object&#xff0c;这个对象有一个名为method_name的方法&#xff0c;方法的参数为matchers…。 EXPECT_CALL必须在任何mock对象之前使用。 以下方法的调用&#xff0c…

日撸 Java 三百行day56-57

文章目录 day56-57 kMeans 聚类1.kMeans聚类理解2.代码理解2.1代码中变量的理解2.2代码理解 day56-57 kMeans 聚类 1.kMeans聚类理解 无监督的机器学习算法&#xff0c;其中k是划分为几个簇&#xff0c;并且选择k个数据作为不同簇的聚类中心&#xff0c;计算每个数据样本和聚…