GCC背后的故事&C程序常量变量的地址分配
一、可执行程序的编译、组装过程
1、创建源代码文件
-
sub1.c:包含x2x函数的实现
float x2x(float a,float b)
{
float c=a*b;
return c;
}
-
sub1.h:
#ifndef SUB1_H
#define SUB1_H
float x2x(float a, float b);
#endif /* SUB1_H */
-
sub2.c:包含x2y函数的实现
float x2y(float a,float b)
{
float c=a/b;
return c;
}
-
sub2.h:
#ifndef SUB2_H
#define SUB2_H
float x2y(float x1, float x2);
#endif /* SUB2_H */
-
main.c:包含main函数的实现
#include <stdio.h>
#include "sub1.h"
#include "sub2.h"
int main()
{
float a=2,b=3,c=4,d=5;
float m,n;
m=x2x(a,b);
printf("m=%f\n",m);
n=x2x(c,d);
printf("n=%f\n",n);
printf("result=%f\n",x2y(m,n));
return 0;
}
2、编译生成目标文件
打开终端,使用以下命令编译源代码文件为目标文件
gcc -c sub1.c -o sub1.o
gcc -c sub2.c -o sub2.o
gcc -c main.c -o main.o
3、生成静态库文件
使用ar工具将sub1.o和sub2.o文件打包成一个静态库文件libsub.a
ar rcs libsub.a sub1.o sub2.o
4、链接生成可执行程序
使用gcc将main.o文件与libsub.a进行链接,生成最终的可执行程序
gcc main.o -L. -lsub -o executable
5、记录文件大小
使用以下命令查看生成的可执行程序的文件大小
ls -l executable
6、生成动态库文件
使用以下命令将sub1.o和sub2.o文件打包成一个动态库文件libsub.so
gcc -shared -o libsub.so sub1.o sub2.o
7、链接生成可执行程序
gcc main.o -L. -lsub -o executable1
-
报错:executable1无法执行,这个错误提示表明程序在运行时找不到所需的共享库文件
libsub.so
。
-
解决方法
-
确保已经正确编译和生成了
libsub.so
共享库文件,并将其放置在正确的位置。 -
执行
sudo ldconfig
命令,更新系统的共享库缓存,并确保共享库的路径被正确地链接到系统中。 -
将共享库文件
libsub.so
放置在系统的共享库搜索路径中,例如/usr/lib
或者/usr/local/lib
。可以使用以下命令将共享库文件复制到对应的路径:sudo cp libsub.so /usr/lib/
-
如果你不想将共享库文件放置在系统目录中,你可以通过设置 LD_LIBRARY_PATH 环境变量来指定共享库的路径。将下述命令中的
/path/to/libsub
替换为实际的共享库路径:export LD_LIBRARY_PATH="/path/to/libsub"
-
执行成功
8、记录文件大小
ls -l executable1
9、静态库VS动态库
可以看出使用动态库的可执行程序文件更小,这源于它们不同的载入方式,静态库的代码在编译的过程中已经载入到可执行文件中,所以最后生成的可执行文件相对较大。动态库的代码在可执行程序运行时才载入内存,在编译过程中仅简单的引用,所以最后生成的可执行文件相对较小。所以我们将静态库文件删除,程序任可以执行。
静态库 | 动态库 | |
---|---|---|
优点 | 1. 方便快捷; 2. 可执行程序直接运行,不需要额外链接,运行速度快。 | 1. 模块化开发,分解任务; 2. 易于局部维护和更新; 3. 共享功能模块,避免文件冗余; 4. 需要时加载,节省内存空间,提高程序加载速度 |
缺点 | 1. 加入多个程序共同使用同一个静态库,相当于把多段相同的代码保存在不同的可执行程序中,浪费资源空间; 2. 如果静态库中有全局变量,那么在几个模块中使用,将会导致全局变量有不同的值,这是非常严重的问题; 3. 静态库编译时,不会进行链接检查,所以这么多静态库的问题,在生成静态库阶段检查不出来。 4. 几个模块,引用同一静态库,如果有一模块没有编译到,会引起巨大的差异导致问题。 | 1. 动态链接本身需要花费一定的运行时间; 2. 过程调用链路变长; 3. 不严谨地更新对外共享的模块可能导致DLL地狱; 4. 老旧的系统或特殊的操作系统可能不支持动态加载 |
二、GCC不是一个人在战斗
GCC 的意思也只是 GNU C Compiler 而已。经过了这么多年的发展,GCC 已经不仅仅能支持 C语言;它现在还支持 Ada 语言、C++ 语言、Java 语言、Objective C 语言、Pascal 语言、COBOL语言,以及支持函数式编程和逻辑编程的 Mercury 语言,等等。而 GCC 也不再单只是 GNU C 语言编译器的意思了,而是变成了 GNU Compiler Collection 也即是 GNU 编译器家族的意思了,所以说:“GCC 不是一个人在战斗,GCC 背后其实有一堆战友”。另一方面,说到 GCC 对于操作系统平台及硬件平台支持,概括起来就是一句话:无所不在。
1.示例代码
#include <stdio.h>
int main(void)
{
printf("Hello World!\n");
return 0;
}
gcc test.c -o test
这是我们常用的编译指令,实质上,上述编译过程是分为四个阶段进行的,即预处理、编译、汇编和连接,现在我们来探究一下这些阶段的执行过程。
2. 预处理
gcc 的-E 选项,可以让编译器在预处理后停止,并输出预处理结果。在本例中,预处理结果就是将stdio.h 文件中的内容插入到 test.c 中
gcc -E test.c -o test.i
或使用如下命令,直接在命令行窗口中输出预处理后的代码
gcc -E test.c
预处理的主要作用:
-
处理'
#include
预编译指令,将被包含的文件插入到该预编译指令的位置。 -
将所有的
#define
删除,并且展开所有的宏定义,并且处理所有的条件预编译指令,比如#if #ifdef #elif #else #endif 等 -
添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号
-
根据
#if
后面的条件决定需要编译的代码。 -
删除所有的注释
-
保留所有的
#pragma
编译器指令,后续编译过程需要使用它们
3.编译为汇编代码
预处理之后,可直接对生成的test.i
文件编译,生成汇编代码:
gcc -S test.i -o test.s
gcc 的-S 选项,表示在程序编译期间,在生成汇编代码后,停止,-o 输出汇编代码文件
编译的主要作用:
-
编译器检查语法错误
-
将源代码翻译中间代码,例如汇编代码
-
对代码进行优化
4.汇编
对于上一小节中生成的汇编代码文件test.s
,gas 汇编器负责将其编译为目标文件
gcc -c test.s -o test.o
汇编的主要作用:
-
把hello.s 文件转换为二进制代码
5.链接
gcc 链接器是 gas 提供的,负责将程序的目标文件与所需的所有附加的目标文件连接起来,最终生成可执行文件。链接是将库文件包含在我们的程序中的过程。
对于上一小节中生成的 test.o
,将其与C标准输入输出库进行链接,最终生成程序 test
gcc test.o -o test
链接的主要作用:
-
链接器使用库文件将所需的定义添加到对象文件中
-
在Windows环境中生成一个可执行文件
test.exe
,在 Linux操作系统中生成test.out
文件
链接也分为静态链接和动态链接,其要点如下:
(1) 静态链接是指在编译阶段直接把静态库加入到可执行文件中去,这样可执行文件会比较大。链接器将函数的代码从其所在地(不同的目标文件或静态链接库中)拷贝到最终的可执行程序中。为创建可执行文件,链接器必须要完成的主要任务是:符号解析(把目标文件中符号的定义和引用联系起来)和重定位(把符号定义和内存地址对应起来然后修改所有对符号的引用)。
(2) 动态链接则是指链接阶段仅仅只加入一些描述信息,而程序执行时再从系统中把相应动态库加载到内存中去。
-
在Linux系 统中,gcc编译链接时的动态库搜索路径的顺序通常为:首先从 gcc 命令的参 数-L指定的路径寻找;再 从环 境变 量 LIBRARY_PATH 指 定的 路径 寻址;再 从默 认路 径
/lib、/usr/lib、 /usr/local/lib
寻找 。 -
在Linux系统中,执行二进制文件时的动态库搜索路径的顺序通常为:首先搜索编译目标代码时指定的动态库搜索路径;再从环境变量 LD_LIBRARY_PATH 指 定的路径 寻址;再从配置 文件/etc/ld.so.conf 中指定的动态库搜索路径;再从默认路径
/lib、/usr/lib
寻找 。 -
在Linux系统中, 可以 用
ldd
命令查看一个可执行程序依赖的共享库。
6. 多个程序文件的编译
通常整个程序是由多个源文件组成的,相应地也就形成了多个编译单元,使用 GCC 能够很好地管理这些编译单元。假设有一个由 test1.c
和test2.c
两个源文件组成的程序,为了对它们进行编译,并最终生成可执行程序 test
,可以使用下面这条命令:
gcc test1.c test2.c -o test
如果同时处理的文件不止一个,GCC 仍然会按照预处理、编译和链接的过程依次进行。如果深究起来,上面这条命令大致相当于依次执行如下三条命令:
gcc -c test1.c -o test1.o
gcc -c test2.c -o test2.o
gcc test1.o test2.o -o test
7. 检错
gcc -pedantic illcode.c -o illcode
-pedantic 编译选项并不能保证被编译程序与 ANSI/ISO C 标准的完全兼容,它仅仅只能用来帮助Linux 程序员离这个目标越来越近。或者换句话说,-pedantic 选项能够帮助程序员发现一些不符合ANSI/ISO C 标准的代码,但不是全部,事实上只有 ANSI/ISO C 语言标准中要求进行编译器诊断的那些情况,才有可能被 GCC 发现并提出警告。除了-pedantic 之外,GCC 还有一些其它编译选项也能够产生有用的警告信息。这些选项大多以-W开头,其中最有价值的当数-Wall 了,使用它能够使 GCC 产生尽可能多的警告信息。
gcc -Wall illcode.c -o illcode
GCC 给出的警告信息虽然从严格意义上说不能算作错误,但却很可能成为错误的栖身之所。一个优秀的 Linux 程序员应该尽量避免产生警告信息,使自己的代码始终保持标准、健壮的特性。所以将警告信息当成编码错误来对待,是一种值得赞扬的行为!所以,在编译程序时带上-Werror 选项,那么 GCC 会在所有产生警告的地方停止编译,迫使程序员对自己的代码进行修改,如下:
gcc -Werror test.c -o test
8.ELF文件
ELF文件是一种用于二进制文件、可执行文件、目标代码、共享库和core转存格式文件。是UNIX系统实验室(USL)作为应用程序二进制接口(Application Binary Interface,ABI)而开发和发布的,也是Linux的主要可执行文件格式。
一个典型的 ELF 文件包含下面几个段:
-
.text:已编译程序的指令代码段。
-
.rodata:ro 代表 read only,即只读数据(譬如常数 const)。
-
.data:已初始化的 C 程序全局变量和静态局部变量。
-
.bss:未初始化的 C 程序全局变量和静态局部变量。
-
.debug:调试符号表,调试器用此段的信息帮助调试。
可以使用 readelf -S 查看其各个 section 的信息
readelf -S test
由于 ELF 文件无法被当做普通文本文件打开,如果希望直接查看一个 ELF 文件包含的指令和数据,需要使用反汇编的方法。
使用 objdump -D 对其进行反汇编:
objdump -D test
使用 objdump -S 将其反汇编并且将其 C 语言源代码混合显示出来
objdump -S test
9. 总结
可以使用如下命令对程序进行编译并保存编译过程中产生的中间文件:
gcc -save-temps hello.c -o compilation
-
hello.i
预处理器产生的文件 -
hello.s
编译器编译后产生的文件 -
hello.o
汇编程序翻译后的目标文件 -
hello.exe
可执行文件(Linux系统会产生hello.out
文件)经过这一系列的步骤我们终于把我们可以理解和编写的c语言程序转换为计算机可以理解并执行的二进制文件,GCC如同我们和计算机连接的桥梁。
三、全局常量、全局变量、局部变量、静态变量、堆、栈等概念
1、全局常量
是在整个程序中都可以访问的固定数值或固定对象。它们的值在程序执行期间不可修改。
2、全局变量
是在整个程序中都可以访问的变量。它们的作用域是整个程序,即从定义的地方开始,一直到程序结束。
3、局部变量
是在特定的代码块或函数中定义的变量。它们的作用域仅限于定义它们的代码块或函数内部。一旦超出该范围,局部变量的值就无法访问。
全局变量 | 局部变量 | |
---|---|---|
作用域 | 整个程序可见 | 限定在特定的代码块或函数 |
生命周期 | 程序运行期间一直存在 | 仅在定义的代码块或函数的生命周期内存在 |
访问 | 在程序的任意位置都可访问 | 仅在定义的代码块或函数内部可访问 |
内存占用 | 占用内存较长时间,可能导致内存浪费 | 只在需要时分配内存,释放后可以被其他变量使用 |
可见性 | 可能导致命名冲突和意外的副作用 | 可以避免命名冲突和不必要的影响 |
多线程安全 | 在多线程环境下,需要谨慎使用或进行同步处理 | 在局部范围内,不影响其他线程的访问 |
调试 | 全局变量的调试较为复杂,可能影响代码的可读性和维护性 | 局部变量的调试较为容易,代码更加模块化和可控 |
共享性 | 可以被多个函数共享和修改 | 局部变量仅在定义的函数内部共享,更具封闭性 |
全局变量适合需要在多个函数之间共享数据的情况,但也需要注意全局变量的生命周期和可见性问题。局部变量更具有封装性和模块化,适合在特定的代码块或函数中使用,能够避免命名冲突和意外的影响。同时,根据代码的可读性和维护性考虑,建议尽量避免过多或过长时间使用全局变量。
4、静态变量
是在程序执行期间一直存在的变量。它们的生命周期与程序的整个运行时间相同,不受函数的调用和返回影响。静态变量在内存中的位置固定,多次调用函数时,静态变量的值会保持不变。
5、堆
是一块用于动态分配内存空间的区域。堆的内存空间由程序员手动分配和释放,用来存储一些动态的数据结构,如对象和数组。在堆上分配的内存由程序员手动释放,否则会造成内存泄漏。
6、栈
是一种用于存储函数调用和局部变量的内存区域。每当函数被调用时,栈会分配一块局部变量的内存空间,该空间会在函数执行完毕后自动释放。栈采用先进后出(LIFO)的数据结构,函数调用时,会一层层地将数据压入栈中,而返回时则按相反的顺序弹出。栈的大小通常是有限的,当栈溢出时,会导致程序异常终止。
堆 | 栈 | |
---|---|---|
内存分配 | 动态分配和释放,由程序员手动控制 | 自动分配和释放,由编译器和计算机自动管理 |
空间大小 | 堆空间较大,有较高的灵活性和可扩展性 | 栈空间较小,受限于系统资源,大小固定 |
分配速度 | 相对较慢,需要进行内存管理,可能会出现内存碎片 | 相对较快,由硬件提供支持,只需移动堆栈指针 |
数据结构 | 适合存储动态的、复杂的数据结构,如对象和数组 | 适合存储简单的、有限的局部变量和函数调用信息 |
生命周期 | 手动管理堆上分配的内存,使用完毕后应及时释放或销毁 | 栈上的数据随着函数调用的结束自动释放 |
垃圾回收 | 需要手动释放堆上的内存,否则可能导致内存泄漏 | 不需要手动处理,由系统自动回收垃圾数据 |
并发性 | 允许多个线程共享堆上的数据 | 栈上的数据是线程私有的,不会出现并发访问的问题 |
访问速度 | 相对较慢,通过指针间接引用数据 | 相对较快,通过栈指针直接访问数据 |
失败情况 | 内存分配失败时可能导致内存不足或程序崩溃 | 栈溢出可能导致程序异常终止 |
堆适合用于存储动态的、复杂的数据结构,如对象和数组,可以提供更大的空间和灵活的管理方式。但需要注意手动管理堆上分配的内存,避免内存泄漏。栈适合存储简单的、有限的局部变量和函数调用信息,具有快速的分配和释放速度,并且不需要手动处理。但是栈的空间较小,受限于系统资源,需要注意栈溢出的问题。
四、Ubuntu、stm32下的程序内存分配问题
1、示例代码
#include <stdio.h>
#include <stdlib.h>
int globalVariable1; // 全局变量1
int globalVariable2; // 全局变量2
int main() {
int stackVariable1; // 栈变量1
int stackVariable2; // 栈变量2
int *heapVariable1 = malloc(sizeof(int)); // 堆变量1
int *heapVariable2 = malloc(sizeof(int)); // 堆变量2
static int staticVariable1; // 静态全局变量1
static int staticVariable2; // 静态全局变量2
static int staticLocalVariable1; // 静态局部变量1
static int staticLocalVariable2; // 静态局部变量2
printf("Global variable address: %p\n", &globalVariable1);
printf(" %p\n", &globalVariable2);
printf("Stack variable address: %p\n", &stackVariable1);
printf(" %p\n", &stackVariable2);
printf("Heap variable address: %p\n", heapVariable1);
printf(" %p\n", heapVariable2);
printf("Static global variable address: %p\n", &staticVariable1);
printf(" %p\n", &staticVariable2);
printf("Static local variable address: %p\n", &staticLocalVariable1);
printf(" %p\n", &staticLocalVariable2);
free(heapVariable1); // 释放堆变量1
free(heapVariable2); // 释放堆变量2
return 0;
}
-
全局定义 变量 global_temp 静态变量 global_temp_static 常量 global_const 静态常量 global_const_static
-
局部定义 变量 local_temp 静态变量 local_temp_static 常量 local_const 静态常量 local_const_static
-
C语言在内存中一共分为如下几个区域:
区域 作用 内存栈区 存放局部变量名 内存堆区 存放new或者malloc出来的对象 常数区 存放局部变量或者全局变量的值 静态区 用于存放全局变量或者静态变量 代码区 二进制代码
2、Ubuntu环境中的变量分配
gcc main.c -o main
可以看到在Ubuntu下,栈区的地址存储是向上增长,堆区的地址存储也是向上增长;
3、STM32(Keil)环境中的变量分配
#include "stm32f10x.h" // Device header
#include <stdio.h>
#include <stdlib.h>
#include "Delay.h"
#include "OLED.h"
int globalVariable1; // 全局变量1
int globalVariable2; // 全局变量2
int main(void)
{
OLED_Init();//OLED初始化
while(1)
{
int stackVariable1; // 栈变量1
int stackVariable2; // 栈变量2
int *heapVariable1 = malloc(sizeof(int)); // 堆变量1
int *heapVariable2 = malloc(sizeof(int)); // 堆变量2
static int staticVariable1; // 静态全局变量1
static int staticVariable2; // 静态全局变量2
static int staticLocalVariable1; // 静态局部变量1
static int staticLocalVariable2; // 静态局部变量2
OLED_ShowHexNum(1,1,(int)&globalVariable1,8);
OLED_ShowHexNum(2,1,(int)&globalVariable2,8);
OLED_ShowHexNum(3,1,(int)&stackVariable1,8);
OLED_ShowHexNum(4,1,(int)&stackVariable2,8);
//OLED_ShowHexNum(1,1,(int)heapVariable1,8);
//OLED_ShowHexNum(2,1,(int)heapVariable2,8);
//OLED_ShowHexNum(3,1,(int)&staticVariable1,8);
//OLED_ShowHexNum(4,1,(int)&staticVariable2,8);
//OLED_ShowHexNum(1,1,(int)&staticLocalVariable1,8);
//OLED_ShowHexNum(2,1,(int)&staticLocalVariable2,8);
free(heapVariable1); // 释放堆变量1
free(heapVariable2); // 释放堆变量2
return 0;
}
}
这里没有使用串口调试工具,直接在OLED显示屏上显示各变量地址
在STM32下,栈区的地址存储是向下增长,堆区的地址存储却是向上增长。
STM32是一种基于ARM Cortex-M处理器的微控制器系列。这些处理器一般使用倒序堆栈(downward stack)结构,也被称为从高地址向低地址增长的堆栈。
在倒序堆栈结构下,栈指针的初始值是指向栈顶的最高地址,随着栈上的数据的入栈,栈指针向低地址方向递减。当数据从栈中弹出时,栈指针会向高地址方向递增。这种栈结构的好处是可以轻松地检测栈溢出,因为栈溢出会导致栈指针超出栈的最低地址范围。