本章目标
1.如果动静态库同时存在,gcc会加载哪一个
2.如何理解FILE对象是c标准库申请的
3.ELF格式
4.程序加载与进程地址空间part2
1.如果动静态库同时存在,gcc会加载哪一个
关于这个问题,我们要进行分类讨论
1.如果我们gcc选择带-static的选项,也就是采取静态链接的情况
这种情况是强制要求我们所有的库都必须采用静态链接的,并且所有的库都必须存在静态库的版本
2.如果我们gcc选择带-satic的选项,但是没有静态库,同样采取静态链接
这种情况,会直接报错,因为根据我们上面的第一种情况,是要求我们所有依赖的库都必须要存在静态库版本的.因为我们要在链接的时候将库的.o文件和你自己程序编译号的.o文件进行合并
3.如果gcc,直接链接生成可执行文件,静态库和动态库同时存在
gcc会选择动态库,采用动态链接的情况,对于c标准库不需要指明,但是对于第三方库需要自己指定.
4.如果gcc,直接链接生成可执行文件,只有静态库的版本.
gcc会在能选择动态库的地方采用动态链接,对于只有静态库的库,会采用静态链接.
为什么要这么说,因为c标准库和c运行时库,我们是一般就有动态库的版本,我们的gcc不需要指明就能够找到.而只有静态库的版本的情况一般只存在第三方库的情况.
我们为了保证程序能够正常运行就必须选择静态库的版本.
2.如何理解FILE对象是c标准库申请的

我们在前面在基础io章节介绍过对于这些io有关的库文件,一定会涉及到文件句柄这个东西FILE,而一般fopen这种函数都是库函数.FILE对象的创建就一定是在fopen的函数内部创建的,也就是说库是可以帮助用户申请和创建对象和空间的.
我们一般会使用的库除了系统给我们提供的就是自己写的,而具体使用什么库也要我们自己决定,根据实际场景来决定.
我们现在演示下外部库的使用
我们选用ncurse库
bash
sudo apt install libncurses5-dev libncursesw5-dev
我们要使用一个库,就要先把它下载下来.
对于一个库最重要的就是它的,so .a以及它的头文件,它会下载到我们的usr/lib以及usr/include两个目录下

不过以-dev为结尾的是开发库它一般在这个目录下
bash
/usr/lib/x86_64-linux-gnu/pkgconfig
我们可以通过确认头文件是否存在来确定是否安装成功

我们可以让ai生成一段代码供我们测试使用
c
#include <ncurses.h>
#include <unistd.h> // 用于 usleep()
#include <math.h> // 用于数学计算
// 简单的心形公式函数
int is_heart(int x, int y, int size) {
// 心形公式: (x^2 + y^2 - 1)^3 - x^2 * y^3 = 0
// 调整缩放和偏移
double sx = (double)x / size;
double sy = (double)y / size - 0.5;
// 心形函数
double result = pow(sx*sx + sy*sy - 0.6, 3) - sx*sx * sy*sy*sy;
// 如果结果接近0,表示在心上
return fabs(result) < 0.03;
}
// 第二种心形:使用字符形状
void draw_heart_shape(int start_x, int start_y) {
// 心形图案的像素表示
const char *heart[] = {
" **** **** ",
" ****** ****** ",
"****************",
" ************** ",
" ************ ",
" ******** ",
" **** ",
" ** ",
NULL
};
attron(COLOR_PAIR(1) | A_BOLD);
for (int i = 0; heart[i] != NULL; i++) {
mvprintw(start_y + i, start_x, "%s", heart[i]);
}
attroff(COLOR_PAIR(1) | A_BOLD);
}
// 第三种心形:使用ASCII字符
void draw_ascii_heart(int start_x, int start_y) {
const char *ascii_heart[] = {
" @@@@ @@@@ ",
" @@@@@@ @@@@@@ ",
"@@@@@@@@@@@@@@@",
" @@@@@@@@@@@@@ ",
" @@@@@@@@@@@ ",
" @@@@@@@@ ",
" @@@@ ",
" @@ ",
NULL
};
attron(COLOR_PAIR(2));
for (int i = 0; ascii_heart[i] != NULL; i++) {
mvprintw(start_y + i, start_x, "%s", ascii_heart[i]);
}
attroff(COLOR_PAIR(2));
}
// 数学公式绘制的心形
void draw_math_heart(int center_x, int center_y, int size) {
attron(COLOR_PAIR(3) | A_BOLD);
for (int y = -size; y <= size; y++) {
for (int x = -2*size; x <= 2*size; x++) {
if (is_heart(x, y, size)) {
int px = center_x + x;
int py = center_y + y;
if (px >= 0 && py >= 0) {
mvaddch(py, px, ACS_DIAMOND); // 使用ncurses特殊字符
}
}
}
}
attroff(COLOR_PAIR(3) | A_BOLD);
}
// 跳动的心形动画
void draw_animated_heart(int center_x, int center_y) {
int max_radius = 8;
for (int frame = 0; frame < 20; frame++) {
clear();
// 计算当前帧的缩放
double scale = 1.0 + 0.1 * sin(frame * 0.3);
// 绘制心形
attron(COLOR_PAIR((frame % 3) + 1));
for (int y = -max_radius; y <= max_radius; y++) {
for (int x = -2*max_radius; x <= 2*max_radius; x++) {
int sx = (int)(x * scale);
int sy = (int)(y * scale);
if (is_heart(sx, sy, max_radius)) {
int px = center_x + x;
int py = center_y + y;
if (px >= 0 && py >= 0) {
char ch;
switch (frame % 4) {
case 0: ch = '@'; break;
case 1: ch = '*'; break;
case 2: ch = '#'; break;
case 3: ch = '&'; break;
default: ch = ACS_DIAMOND;
}
mvaddch(py, px, ch);
}
}
}
}
attroff(COLOR_PAIR((frame % 3) + 1));
// 显示帧信息
mvprintw(center_y + max_radius + 2, center_x - 10,
"跳动的心形 - 帧: %d", frame + 1);
refresh();
usleep(100000); // 暂停100ms
}
}
int main() {
// 初始化ncurses
initscr();
// 启用颜色
start_color();
// 初始化颜色对
init_pair(1, COLOR_RED, COLOR_BLACK); // 红色心形
init_pair(2, COLOR_MAGENTA, COLOR_BLACK); // 紫色心形
init_pair(3, COLOR_CYAN, COLOR_BLACK); // 青色心形
// 启用特殊字符
raw(); // 禁用行缓冲
keypad(stdscr, TRUE); // 启用功能键
noecho(); // 不显示输入字符
curs_set(0); // 隐藏光标
// 获取终端尺寸
int max_y, max_x;
getmaxyx(stdscr, max_y, max_x);
// 计算中心位置
int center_y = max_y / 2;
int center_x = max_x / 2;
// 清屏
clear();
// 绘制标题
attron(A_BOLD | A_UNDERLINE);
mvprintw(2, center_x - 10, "NCURSES 心形图案集");
attroff(A_BOLD | A_UNDERLINE);
// 绘制多个心形
mvprintw(4, center_x - 15, "按任意键切换不同心形...");
refresh();
getch(); // 等待按键
// 1. 绘制字符心形
clear();
mvprintw(2, center_x - 8, "1. 字符心形");
draw_heart_shape(center_x - 8, center_y - 4);
refresh();
getch();
// 2. 绘制ASCII心形
clear();
mvprintw(2, center_x - 8, "2. ASCII心形");
draw_ascii_heart(center_x - 8, center_y - 4);
refresh();
getch();
// 3. 绘制数学公式心形
clear();
mvprintw(2, center_x - 10, "3. 数学公式心形");
draw_math_heart(center_x, center_y, 8);
refresh();
getch();
// 4. 跳动的心形动画
clear();
mvprintw(2, center_x - 8, "4. 跳动的心形");
mvprintw(4, center_x - 15, "观看动画,按任意键继续...");
refresh();
getch();
draw_animated_heart(center_x, center_y);
// 结束信息
clear();
attron(A_BOLD | COLOR_PAIR(1));
mvprintw(center_y - 2, center_x - 10, "❤️ 感谢使用! ❤️");
attroff(A_BOLD | COLOR_PAIR(1));
mvprintw(center_y + 2, center_x - 15, "按任意键退出程序...");
refresh();
getch();
// 结束ncurses
endwin();
return 0;
}

需要指定数学库和ncurses库

成功跑起来了.
3.ELF格式
在前面我们说过,库的本质的一群.o文件的结合,对于库来说,它本身也是一个文件
无论是点.so .a .o 这些都是二进制文件,他们都遵循着同一套标准,同一套格式,他们才能进行合并.
ELF(Executable and Linkable Format) 是 Unix/Linux 系统上可执行文件、目标代码、共享库和核心转储的标准文件格式
它是Linux特有的文件格式.
一般分为以下四种
•可重定位⽂件(Relocatable File) :即xxx.o⽂件。包含适合于与其他⽬标⽂件链接来创建可执⾏⽂件或者共享⽬标⽂件的代码和数据。
•可执⾏⽂件(Executable File) :即可执⾏程序。
•共享⽬标⽂件(Shared Object File) :即xxx.so⽂件。
•内核转储(core dumps) ,存放当前进程的执⾏上下⽂,⽤于dump信号触发。
而ELF格式主要分为以下四个部分

• ELF头(ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂件的其他部分。
•程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表⾥记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开。
•节头表(Section header table) :包含对节(sections)的描述。
•节(Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。
ELF头:
我们可以通过
bash
readelf -h xxx
这个指令来去查看一个二进制文件的ELF头

这里面存放着这个二进制文件的管理信息,我们一眼可见可以理解的就是每一个区域的起始地址和大小,
我们挑几个重要的来说
magic:标志着一个文件我文件格式,我们一般管这个叫做魔术头,它一般是确定的数字
entry point address 程序的入口地址
在后面我们说程序加载的时候会详细介绍这个.
我们的程序开始,一般来说,在我们之前的学习,main函数是我们的程序的入口地址
但是实际情况并不是这样的.我们在程序启动的时候会有像加载环境变量,初始化堆栈等等任务来要去执行.
number of program header:表示程序头表中有多少条目.
下面的number of sertion number:表示的是扇区里有多少条目
setion(扇区):
我们的代码和数据,都会划分类型以节为单位放在这个区域
常见的节就业数据节,代码节,符号表等
我们想查看一个二进制程序的具体的扇区情况.我们可以将它反汇编进行查看
bash
objdump -S main


我们可以在这里找到代码节的起始地址就是12c0
这点和ELF头给我们的信息是一致的.
setion header table(节头表):
我们为了将节的信息进行统一管理,节头表描述他们的起始位置和偏移量以及权限
我们可以通过
bash
readelf -S xxx
来去查看一个文件的节头表

program header table(程序头表):
这个部分是与程序加载的时候有关,在我们前面了解过在磁盘上,我们是按照扇区512字节为单位进行存储,为了提高效率和解耦,os才用了4kb的数据块进行加载.
但是对于elf文件来说,我们按照一个个节进行加载就太过于浪费了.
有的节的大小并不会占满一个数据块
所以os会在加载的时候将权限相同的节合并成一个段(segment)
来进行加载.
要注意对于elf本身来说是不存在段这个说法的.
这个段的阶段是在加载之后才有的.
bash
readelf -l xxx
我们可以通过这个指令去查看一个程序的段信息


我们可以将所以的段中的节相加它一定是我们的在elf中节的个数
对于库的合并,本质上就是将elf相同的区域进行合并,然后重新编址形成一个新的文件

4.程序加载与进程地址空间part2
在说这个问题前,我们先用两个问题来进行引出这个标题
1.对于一个进程它是先创建数据结构,还是先加载elf文件?
2.对于一个程序它在运行前是否有地址?
对于第一个问题,我们在part1的时候就已经解释过一部分了.
一定是先创建内核数据结构,在去加载elf,甚至可以懒加载,或者不加载.
那么要创建一个数据结构就一定要去填信息,信息从哪里来?
那么一定还是elf,再具体的一点,就是program head table 以及 ELF headtable
我们程序我们需要找到程序的入口地址,以及程序的每个区域的大小,以及权限等
这一点可以从内核进行验证

我们mm_struct当中标记了每一个区域的大小.
对于第二个问题
我们先直接给出结论,它是一定存在地址.这个地址是在编译时就已经确定的
因为我们的程序,在os加载的时候需要知道加载到哪一个部分.
为了方便进行查找.我们的程序会在全0到全f的空间进行编址.
通过偏移量确定每一区域的具体地址.
这种方法进行编址我们叫做平坦模式编址,他就是我们的虚拟地址,但是更应该确定的是,它应该叫做逻辑基址.
这套技术我们的编译器遵守,因为我们要让程序可以被进程地址空间加载.
进程地址空间根据虚拟地址在通过页表进行分配物理地址,
通过这套技术,我们只要拿到虚拟地址就可以通过偏移量+磁盘中位置确定elf文件在磁盘中的位置.通过每一块区域的起始地址(这个一般是在vm_area_struct当中的)+偏移量确定它在进程地址空间的虚拟地址.进而通过页表确定它在内存当中的物理地址
如果把这套虚拟地址的技术比作蓝图的话,物理地址就是具体的房子
介绍完这个两个问题,我们就继续我们的正题,对于一个程序它是如何进行加载的

首先会为它在内核中创建数据结构,但是为了初始化信息,就一定要引导elf文件加载到物理内存当中,进而去初始化信息,同时填充页表.
对于必要的已经确定的部分,如代码节,数据节,这些不变的会直接加载进来.
对于堆栈这种变化的,会用整个内核空间减去其他已经确定的区域,为他们确定一个大致的范围.这些部分的信息一般是来自于program header table 中,通过ld, 也就是加载器来完成.ld这个东西本身来自于c运行时库,对于库的加载,我们下一节再说.
接着会从ELF拿到start point address 将它填入到cpu中EIP,程序计数器当中.
cr3寄存器放着进程页表的物理地址,通过mmu将虚拟地址转化成物理地址.
这个部分我们在信号部分再介绍.
接着这些初始化工作完成,在跳转到程序的入口地址,去依此按条执行程序.
对于part2部分要具体理解的一定是os和编译器是同时遵守虚拟地址这套技术的