C语言代码

1
2
3
4
5
6
7
8
9
10
//main.c
#include<stdio.h>
int d = 100;
int x = 200;
void p1(void);
int main(){
p1();
printf("d=%d, x=%d\n", d, x);
return 0;
}
1
2
3
4
5
//p1.c
double d;
void p1(){
d = 1.0;
}

执行下面shell命令

1
gcc -o myproc main.c p1.c

返回如下警告信息

1
/usr/bin/ld: Warning: alignment 4 of symbol `d' in /tmp/ccxObbFO.o is smaller than 8 in /tmp/cckd5Wdy.o

让我们运行下myproc,看输出结果

1
./myproc

返回

1
d=0, x=1072693248

为什么是这两个输出结果?

这涉及到:数据的表示,数据在内存中的存储方式,以及链接时的一些知识。

数据宽度与存储容量的单位

之所以要写这个部分,是因为很多人都没有搞清楚“字”和“字长”,以及“按字编址”和“按字节编址”的区别

我们知道,比特(bit)是计算机中存储,处理,传输信息的最小单位,而最基本的计量单位是字节(Byte)。因为现代计算机的存储器中基本采用“按字节编址”,字节就变成了最小可寻址单位。

存储器被划分成众多的存储单元,并将其从0开始编号。所谓“按字节编址”是指一个存储单元的大小是1个字节,即8个bit。4GB的存储器,如果按字节编址,则被划分成$4GB/1B = 4G$ 个存储单元,即地址范围为$0$ 到 $2^{32}-1$ 的范围,即寻址范围。那么按字编址,即一个存储单元的大小是1个字。

除了比特和字节以外,还经常使用“字”(word) 作为单位。这里需要注意区分“字”和“字长”的概念。

字长是指CPU一次能并行处理的二进制数的位数 。但其实这个概念于我而言是不好理解的,什么叫一次能并行处理?怎么处理?我们不妨先看这样的概念(对这个概念进行解释后,可以发现其实两者等价。):字长是指数据通路的宽度

所谓数据通路是指CPU内部数据流经的路径以及路径上的部件(比如总线,运算器,通用寄存器等),主要是CPU内部进行数据运算、存储和传送的部件,这些部件的宽度基本上要一致,才能相互匹配。比如一个32位数据总线,传送32位的数据到通用寄存器,如果通用寄存器的位数不是32位的话,则两者无法匹配,传送起来非常不方便。

因此,字长=CPU内部总线的宽度=运算器的位数=通用寄存器的位数。比如一个CPU的数据总线是32位的,那么该CPU的字长也是32位。我们所说的32位机器,64位机器,实际是指该CPU的字长为32位或者64位。

那么为什么说字长是指CPU一次能并行处理的二进制数的位数呢?就拿传送数据这类处理来说,一个32位的数据总线,一次能传送的数据即为32位,即为字长。

如果我们把CPU和存储器联系起来,存储器按字节编址,那么32位的总线一次能传送4个单元的数据到CPU;如果存储器按字编址,那么32位的总线一次能传送1个单元的数据到CPU。

最后我们来说下“字”。很多人认为字=字长,实际不是的。字表示的是处理信息的单位,用来度量数据类型的宽度

字和字长的宽度可以一样,也可以不同。比如对于32位的x86架构,不管字长多少,“字”的宽度一直是16位,而从80386处理器开始,字长就为32位了。对于MIPS 32体系架构,其字和字长均为32位。

另外,说到数据的单位,还需要补充的是通信中使用的带宽单位和数据的单位有差别的,这在计算机网络中非常容易弄错。比如说:数据单位千兆字节(GB),1GB = 1024MB,而千兆字节每秒(GB/s, GBps),1GB/s = 1000MB/s。即数据的换算单位是1024,带宽的换算单位是1000,尽管两者符号相同。

数据的存储和排列顺序

首先,我们要知道高级语言中声明的基本数据类型有不同的长度,不同机器上同一种数据类型因为ISA, 字长和编译器的不同而不同。我们下面介绍典型机器中的C语言中数据类型的宽度,以字节为单位。

注意,ANSI C标准未规定long double的确切精度。

从上面可以看出,如果存储器按字节编址,那么一个基本类型可能占用多个存储单元。

  • 一个int变量x=-10, x的存放地址为100,其机器数即为FFFFF6H,占4个单元
  • 机器数:$-10=-1010B$ , $[-10]_补=FFFFF6H$
  • (关于数据的表示,新写一篇博文介绍)

我们需要考虑:多个字节在存储单元中存放的顺序如何?

为什么要考虑这些呢?因为我们使用”取数“指令访问100号单元取出x时,要知道x的4个字节是怎么排列的!

这里涉及到大端模式和小端模式

  • 如果ISA规定使用大端模式:则高字节为变量起始地址
  • 如果ISA规定使用小端模式:则低字节为变量起始地址

这里x86系列使用的是小端模式,故排列顺序为

  • 地址:100,101,102,103
  • 内容:F6, FF, FF, FF

有些机器两种方式都支持,可通过特定控制位来设定采用哪种方式。

1
2
如何检测系统的字节顺序:
可以借助Union的特性,首先,联合体各成员共享存储空间,按最大长度成员所需空间大小为目标,且所有成员都是从低地址开始存放。联合体用于各成员的使用时间互斥时。
1
2
3
4
5
6
7
8
9
10
11
#include<stdio.h>
int main(){
union NUM{
int a;
char b;
}num;
num.a = 0x12345678;
if(num.b == 0x12) printf("Big Endian\n");//大端
else printf("Little Endian\n");
printf("num.b=0x%X\n", num.b); //十六进制格式输出
}
1
2
3
4
5
6
7
结果:
Little Endian
num.b = 0x78

即(P为起始地址)
地址:P P+1 P+2 P+3
内容:78 56 34 12

注意:存放方式不同的机器间程序移植或数据通信时,涉及叫字节顺序的交换问题。比如一个大端模式的机器的数据传送给另一个小端模式的机器,这里还涉及到字节顺序交换的问题。同时,音、视频和图像等文件格式或处理程序都涉及到字节顺序问题。

  • 比如:Little endian: GIF, PC Paintbrush, Microsoft RTF,etc
  • Big endian: Adobe Photoshop, JPEG, MacPaint, etc

数据的对齐存放

为了理解数据对齐存放的概念和必要性,我们先探讨几个基本数据类型在内存中的存放

1
int i, short k, double x, char c, short j;

在典型32位机器下讨论,我们分成两种情况:对齐和不对齐,如下图

首先,对于典型32位机器而言,CPU的字长为32位,故CPU每次最多可以存取从某个字地址开始的4个单元!对于不对齐的存储方式,在访问double类型的变量x时,需要访存3次!!!对齐存放时,只需要访存2次!

所以,显而易见,虽然对齐存放浪费了一些空间,但是减少了访存次数!

那么,对齐到底是什么?对齐是指数据的地址就是相应的边界地址 ,比如Linux下的double类型以4B为边界地址(Windows 的double以8B位边界地址),也就是说,double类型的起始地址是4的倍数!int类型也是以4B为边界对齐。char类型不用对齐,因为只有一个字节,short类型以2B为边界,即起始地址为2的倍数。

现代的指令系统支持对字节,半字,字,双字的访问。

  • 字节地址:任意
  • 半字(假设1个字=32位):起始地址为2的倍数(二进制表示时末位为0)
  • 字地址:起始地址是4的倍数(末尾00)

通过数据的对齐,我们不难想到,对于结构体变量,变量的定义顺序会影响其内存布局!这也是为什么要了解数据的对齐方式的原因。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
struct TEST1{
int i;
short si;
double d;
char c;
}test1;

struct TEST2{
int i;
short si;
char c;
double d;
}test2;

注:可以使用预编译语句来对结构体和类中成员变量设置对齐方式

1
#pragma pack()

链接时如何处理多重定义符号

对于多个C语言源文件,我们经过预处理,编译,汇编形成可重定位的目标文件后,要对他们进行链接,形成可执行的目标文件。

  • 新开一个博文,进行详细解释

这里我们主要介绍链接时的多重定义符号问题。

链接操作的步骤

首先,我们简单介绍链接操作的步骤。

  • 确定符号引用关系
    • 程序中有符号(变量,函数)的定义和引用
      • void swap(){…} 符号的定义
      • swap(); 符号的引用
      • int* xp = &x; 定义符号xp, 引用符号x
    • 编译器将定义的符号放在符号表中
    • 链接器将每个符号的引用与一个确定的符号定义建立关联
  • 合并相关的目标文件
  • 确定每个符号的地址(虚拟地址空间的地址)
  • 在符号的引用处填上符号的地址

符号的类型

每个目标文件都有一个符号表,包含在该文件中定义和引用的符号,有三种类型

  • 本文件内部定义的全局符号(全局符号):即不带static的全局变量和函数
    • 注:静态全局变量和函数都只能在当前文件引用,不带static的全局变量和函数可以被所有文件引用
  • 其他文件定义,在本文件中引用的符号(外部符号)
  • 仅有本文件定义并引用的符号(本地符号):即带static的全局变量和函数

注意:局部变量分配在栈中,不会在过程外被引用,不是符号定义!

全局符号会引发的问题和解决方法

根据上面的描述,全局符号可以被所有文件引用,这样就可能出现一个问题,如果在不同文件中定义了相同名字的全局符号,链接器该如何处理冲突问题?

链接器引入了一种特性:即全局符号的强弱,对强弱不同的全局符号定义了若干规则,我们首先看全局符号的强弱问题。

  • 函数名和已初始化的全局变量名为强符号
  • 未初始化的全局变量名为弱符号

比如以下例子

由此,链接器定义了对多重定义符号的处理规则

  • 强符号只能被定义一次,否则链接错误
  • 若一个符号被定义为一次强符号和多次弱符号,则以强定义符号为准
  • 若有多个弱符号定义,则任选其中一个

解决C语言代码!

现在回到我们的C语言代码。

1
2
3
4
5
6
7
8
9
10
//main.c
#include<stdio.h>
int d = 100;
int x = 200;
void p1(void);
int main(){
p1();
printf("d=%d, x=%d\n", d, x);
return 0;
}
1
2
3
4
5
//p1.c
double d;
void p1(){
d = 1.0;
}

我们可以看到,两个源文件里面都有d这个变量,在main.c中这个是强符号(被初始化的全局变量),在p1.c中是弱符号(未初始化的全局变量)。所以!最终链接后是以强符号为准 ,即d的类型是int

但是!!在编译p1.c时,编译器并不知道它是int型的,只是将其看作double型的

所以此时!编译生成的汇编指令是浮点数的赋值指令!!

现在我们再讨论main.c的编译情况,我们想知道变量x和d在内存中如何表示和排列的,则需要知道是小端模式还是大端模式。

main.c编译汇编生成的目标文件main.o中存储了相关信息,我们用以下命令进行查看

1
readelf -h main.o

返回的信息中截取Data部分

1
Data:    2's complement, little endian

这说明数据用补码表示,采用小端模式存储, 即低字节在变量起始地址。而变量d的补码表示为01100100=64H,变量x的补码表示是011001000B=C8H,其内存布局为:

当p1.o和p2.o链接生成最终的可执行目标文件后,执行该程序,则从main函数开始执行,调用p1(); 由于是浮点数的赋值指令,所以赋给int型变量d的值为双精度浮点数值1.0

众所周知,IEEE754标准的双精度浮点数的结构为下列所示,并且小数点前的1是隐含表示的

  • 符号位 Sign (S) : 1bit
  • 阶码部分Exponent (E) : 11bit
  • 尾数部分Mantissa (M) : 52bit

所以,其符号位为0,指数为2^0,即阶码的真值为0,用移码表示为

  • 真值+偏置常数,编码位数为n时,偏置常数取$2^{n-1}-1$
  • 那么11位的移码,偏置常数为1023
  • 所以移码表示为1023=01111111111B=3FFH

尾数部分为全0(小数点前的1是隐含表示的)

所以1.0的浮点数表示为3FF0 0000 0000 0000H

新的内存布局为:

执行该浮点数操作指令时,原来的变量x的内存被冲刷掉了

所以,最后printf打印出的结果就是:

1
d = 0, x = 1072693248 (即0x3FF00000H)

最后提一下警告信息

1
/usr/bin/ld: Warning: alignment 4 of symbol `d' in /tmp/ccxObbFO.o is smaller than 8 in /tmp/cckd5Wdy.o

可以看到,int变量d的对齐方式是4B对齐,小于double的8B对齐,所以输出了警告。