C语言之数据结构
Posted 阿C_C
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C语言之数据结构相关的知识,希望对你有一定的参考价值。
C语言中的基本结构体以及内存之间的关系,我们经常用到,所以我们今天来学习一下这些内容
内存
内存是什么,和数据结构有什么关系?
内存从哪里来?
内存是程序运行的活动之地,程序需要放在内存中运行的,程序运行时需要内存来存储一些临时变量数据。
内存在物理上本身是一个硬件器件,由硬件系统提供,内存在使用的时候需要由操作系统来统一管理,操作系统为了方便合理的管理内存,操作系统提供了多种机制来让应用程序使用内存,这些机制彼此不同,程序根据自己的情况来获取,使用和释放内存。
程序中,申请内存的方法有三种: 堆,栈,数据区;
栈内存
栈是在运行时自动分配和自动回收的,不需要编程者手动申请和释放。局部变量就是通过栈来实现的,使用起来比较方便简单。
反复使用
栈内存可以反复使用,在程序中可以反复使用栈的内存空间,每个进程被操作系统赋予一块栈内存,在进程运行的时候,使用栈的数据按照先进后出的方式进行栈的使用,操作系统来维护栈指针来反复使用这块栈内存。
脏内存
栈内存属于脏内存,也就是说当从栈中分配一块内存的时候,此时这个内存中的数据是随机的,并不是全0,上次使用该块内存的数据还会在这个内存中,C语言中定义一个局部变量但没有初始化时,它的值是随机的,因为这个局部变量就是位于栈内存中的。
临时性
栈内存的数据是临时性的,声明周期较短,只存在于一小段时间就使用完毕,弹出栈外了,C语言中,函数不能反悔一个栈变量的指针,就是这个原因,因为一旦函数执行完毕,其中的局部变量就弹栈了,该指针就会指向另外一个变量,就不是原来的那个变量了。
栈溢出
操作系统会事先给进程分配栈的大小,如果在函数中不停的分配局部变量但不释放,就会迅速导致栈内存空间被用完,导致栈内存空间溢出,例如我们常用的函数递归调用,就很容易导致栈溢出。
堆内存
和栈内存不同,操作系统使用堆管理器来管理,堆管理器属于操作系统的一个模块。
大块内存
堆内存可以分配大块的内存区域,多个进程使用同一个堆管理器来进行内存的申请和释放,管理器可以进行灵活管理,各进程按需分配使用并释放。
手动申请释放
堆管理器需要程序员编写代码来申请和释放,使用malloc和free函数进行操作。
脏内存
堆内存也是反复使用的,使用者使用完毕之后不会进行清除,所以堆内存也是脏内存,所以申请了内存之后,应该进行初始化之后再使用。
临时性
堆内存只在malloc和free之间属于该进程,进程可以对该堆内存进行操作,其他期间都不能访问,否则会发生错误。
malloc函数
malloc函数的原型为void *malloc(size_t size)
,返回值是void ,void 是一个指针类型,该指针类型可以转换为任何其他类型的指针,所以可以适应任何类型的数据分配。void 实际上就是一个内存地址,指向的所分配的内存空间的起始地址,这段空间将来用于存储什么类型的元素,就把void 转换为那种类型的指针即可。
malloc申请成功返回内存空间的地址,申请失败返回NULL,我们在实际使用中,一定要对malloc的返回值进行NULL判断,以防止内存申请失败时导致程序发生异常。
free函数
在我们申请到内存之后,使用一个指针进行存储,但是要切记的是,在没有调用free函数对该指针进行释放时,千万不能把该指针指向另外一个内存地址或者置为NULL,否则的话,申请的这块内存就丢失掉了,虽然在操作系统的堆管理器中当前进程还持有这块内存,但是进程已经失去了和该块内存联系的纽带,这也就是我们常说的内存泄露。丢掉的内存空间直到进程结束之后,才会被彻底释放。
使用free释放内存之后,这段内存空间就不能再使用了,否则会造成程序运行出错。
数据段
程序分为代码段,数据段,bss段等,编译器在编译程序的时候,将程序中的所有元素分成了不同的类型段,段是可执行程序的组成部分。
代码段,数据段和bss段
代码段是程序中可执行的部分,直观理解代码段就是由函数堆叠组成的,表示程序的动作。
数据段又称为数据区,静态数据区或者静态区,表示程序中的数据,就是C语言程序中的全局变量。
bss段又称为zi段,特点就是会被初始化为0,本质上也是属于数据段,但是指的是那些被初始化为0的数据段,也就是那些被初始化为0的全局变量。需要我们知道的是,语言中没有进行初始化的全局变量默认会被初始化为0,因为他们位于bss段。
代码段上的数据
在C语言中,定义一个类似于char *p = "linux";
的常量字串,其中的字符创实际上被分配在代码段上。
数据段的内容
使用static修饰的局部变量,会被分配在数据段上。所以我们现在知道,显示初始化为非0的全局变量和使用static修饰的局部变量,都是位于数据段的。
C语言中,使用堆,栈以及数据段都可以为程序提供可用内存,都可以提供给程序来定义变量,只不过在各自的使用和特性上有些许差别。
- 栈只能用于C语言中的局部变量,不能用于其他变量,栈的管理是自动的,由编译器和操作系统自动完成服务,程序员无法手动控制
- 堆内存是独立于程序的,由专门的堆管理器负责对各进程提供堆服务,需要程序员手动的向堆管理器申请和释放。
- 数据段对应于C中的全局变量和静态局部变量,维护和管理和栈一样是自动的
字符串
C语言没有原生的字符串类型,本质上字符串都是由字符数组拼接而来的,通过字符指针来简介实现的。
C语言中通过char *p = "linux";
的方式来定义字符串,这样实际上定义了一个字符指针,指向一段存储了字符串的内存地址,p本身就是指针,只不过指向了字符串的地址而已。
字符串本质上就是一串字符,而字符就是char类型的变量,C中使用ASCII对字符进行编码,将多个字符打包一起,就共同组成了一个字符串,字符串在内存中本质上是多个字符连续分布构成的
特点
C语言中的字符串有三个特点
- 指针指向字符串的头
- 尾部固定,总是以“\\0”来结尾
- 组成字符串的各个字符地址彼此相连
字符串的指针指向的是字符串的头,也就是其中第一个字符的地址,‘\\0’其实就是编码为0的ASCII字符,表示空字符。
要注意的是,指向字符串的指针,和字符串本身是两个东西,上面的代码中,p本身是个指针,占4个字节,而”linux”被分配在代码段,带上”\\0”占用6个字节,所以定义如上一个字符串,占用了10个字节。
除了使用字符串可以存储多个连续字符,我们还可以直接使用字符数组来存储。
字符串和字符数组
使用strlen可以测试一个字符串的长度,但是这个函数返回的结果是不包含字符串结尾符”\\0”的。
sizeiof可以得到数组中元素的个数,和数组的初始化状态没有关系,但是strlen是用来计算字符串长度的,不能传非字符串进去。
如果定义数组没有明确给出数组大小,则需要同时将该数组进行初始化,以便编译器计算数组的大小。
从内存分布来讲,使用字符串来初始化一个字符数组时,此时该字符数组也就相当于是一个字符串,长度是原生字符数组+1,该字符数组实际上和使用字符指针定义字符串是一样的,但是使用字符指针来进行字符串初始化的时候,是定义了一个指针和一个字符串。
结构体
结构体是一种自定义类型,使用时需要先定义类型,然后再使用类型来定义变量,或者在定义结构体类型的同事定义结构体变量。
数组和结构体
数组有两个明显的缺陷,首先是定义的时候必须明确的给出大小,并且之后不能更该数组的大小,其次,数组要求所有的元素类型必须一致,这点不够灵活。结构体解决了数组的第二个缺陷,结构体中的元素类型可以不同。
结构体元素的访问
结构体通过’.’或者’->’来访问结构体中的元素,其本质实际上都是一样的,使用‘.’号和’->’来访问的实质,就是用指针来进行访问。所以说这些方式实质上都是一样的。
结构体对齐
结构体要考虑元素的对其访问,每个元素实际占用字节数和自身类型所占用的字节数不一定完全一样,例如char类型的元素,实际占用的字节数有多种可能。
一般情况下如果使用’.’的方式进行结构体元素访问的时候不需要考虑结构体对齐的问题,但是如果使用指针的方式来访问的话,就需要考虑这个问题了。
结构体元素对齐访问的一个主要原因是为了配合硬件,硬件本身有物理上的限制,如果对齐访问可以很大的提高访问效率,我们知道,内存本身是物理器件,在设计时有一定的局限性,导致每次访问内存时如果对齐访问效率是最高的,如果不对齐访问,效率会下降很多,另外,Cache的一些特性,以及其他特性,也都要求内存需要对其访问。
内存对齐指令
编译器本身是可以设置内存对齐的规则的, 在32位系统中,一般默认按照4字节对齐,编译器设置为4字节对齐时,结构体整体本身必须安置在4字节对齐处,结构体对齐后的大小必须是4的倍数,设置为8字节对齐时,则必须安置在8字节对齐处,大小是8的倍数。
GCC中,常用的对齐指令有: attribute(packed)和attribute(aligned),其中第一个指定按照多少个字节对齐,第二个指令表示不对齐。
offsetof
我们通过架构体变量来访问其中的元素,本质上其实是通过指针来访问的,底层实质上是编译器帮我们自动计算了偏移量
offsetof宏的作用就是来计算结构体中某个元素和结构体首地址的偏移量,其实质还是通过编译器来自动计算的,offsetof的定义和使用是这样的:
#define offsetof(TYPE,MEMBER) ((int) &((TYPE *)0) -> MEMBER)
struct mystruct
char a;
int b;
short c;
...
int offseta = offsetof(struct mystruct, a);
在这个宏中,(TYPE )0表示的是把0地址强制类型转换为一个指针,该指针指向一个TYPE类型的结构体变量,实际上这个结构体变量可能不存在。由于这个指针类型是TYPE ,所指向的是TYPE类型的变量,值为0,可以通过该指针来访问结构体变量中的某个成员MEMBER,然后通过&来获取MEMBER的地址,由于我们变量首地址为0,所以此时MEMBER的地址也就是相对于结构体首地址的偏移量。
container_of
container_of宏的作用是通过结构体变量中某个元素的指针,反过来推出该结构体变量的指针。
在某些场景下,这种操作是非常必要的,这个宏的定义是这样的:
#define container_of(ptr, type, member) (
const typeof(((type *)0)->member) * __mptr = (ptr);
(type *)((char *)__mptr - offsetof(type, member));
)
这个宏由两句组成,其中,ptr是指向结构体元素member的指针,type是结构体类型,member是结构体中一个元素的元素名,这个宏返回的就是指向整个结构体变量的指针,类型是(type *)。typeof关键字作用是通过变量名得到变量的数据类型。
进一步的,我们一旦获取到了该结构体的指针,就可以通过该结构体指针获取其中其余元素的指针了。
这个宏的工作原理是这样的,先用typeof获取元素的类型,定义为一个指针,再使用指针减去该元素相对于整个结构体的偏移量,就得到了整个结构体变量的首地址,再强制转换为该结构体类型的指针。
共用体union
共用体的定义,使用和结构体是非常类似的,但是共用体和结构体本质上是完全不同的。
结构体中,各个元素是独立的,分布在不同的内存单元中,只是被结构体打包成一个整体,叫做结构体,但是在共用体中,元素不是独立的,使用同一个内存单元,这个内存空间有多种解释方式,不同的解释方式就是对应的不同的共用体元素类型。共用体中各个元素的地址是一样的,对同一块内存中存储的二进制进行不同的理解,就组成了共用体中元素类型。
共用体变量所占用的内存字节数,由于元素是共用的,所以占用的内存字节就是其中占用内存最大元素的大小。共用体不涉及到内存对齐问题,因为其中的元素都是从同一内存地址开始的。共用体常用于对同一内存单元进行多种不同方式解析的环境。
大小端模式
大小端常指的是计算机存储系统中的大小端,数据是按照字节为单位进行存储的,一个32bit的字节的二进制在内存中就有大端和小端两种存储模式,高字节保存在低地址,称为大端模式,反之高字节保存在高地址,则叫做小端模式。
大小端本身没有区分和优劣,但是要求存取都按照同样的大小端模式进行,否则就无法进行存取,实际中,大部分CPU都采用小端模式进行存取,在编程中,可以用代码来检测当前系统的大小端。
使用union来检测大小端
使用共用体检测大小端模式,代码如下:
union myunion
int a;
char b;
int is_little_endian(void)
// 大端模式返回1,小端模式返回0
union myunion u1.a = 1;
return u1.b;
当我们把u1的a设置为1,此时内存中的四个bit中,如果是小端模式,则是0001,大端模式是1000,用u1的b访问的时候是访问最低地址那个字节,则可以得出b如果是1,则是小端模式,如果是0,则是大端模式
使用指针检测大小端
使用指针的方式就更简单了,我们用指针重写上面的方法:
int is_little_endian(void)
// 大端模式返回1,小端模式返回0
int a = 1;
char b = (char *)&a;
将a的地址转换为char类型的指针,再进行解引用,最后就会得到最低那个字节的内容,如果是1就是小端,如果是0就是大端。很多时候看起来可以使用位与,移位,强制类型转换的方式来测试大小端,其实是不正确的。通信系统中,通信双方都需要明确通信的大小端,区别是先发送的是高位还是先接受的是高位,在实际中,通信协议中会明确的说明先发高字节还是低字节。
枚举
枚举其实是一些符号常量的集合,都是int类型的常量,每个符号和一个常量进行绑定,编译器对枚举的认知就是该符号常量所绑定的int类型的值。在C语言中不使用枚举是可以的,但是使用枚举符号,可以使我们的程序更加容易理解,编程更加直观。
宏定义和枚举
宏定义和枚举基本相当,可以进行替换,但是还是有区别的:
- 枚举是将多个有关联的符号封装在一个枚举中,而宏定义是散的,彼此之间没有联系
- 当要定义的常量属于一个有限集合时,适合使用枚举,否则就使用宏定义
以上是关于C语言之数据结构的主要内容,如果未能解决你的问题,请参考以下文章