C语言(进阶)笔记
第一章、数据的存储
1.整型在内存中的存储
1.1 原码、反码和补码
1 | int a = 10; |
计算机中的整数有三种表示方法,即原码、反码和补码。
三种表示方法均有符号位和数值位两部分,符号位都是用0表示“正”,用1表示“负”,而数值位负整数的三种表示方法各不相同。
原码—直接将二进制按照正负数的形式翻译成二进制就可以。
反码—将原码的符号位不变,其他位依次按位取反就可以得到了。
补码—反码+1即为补码
数值位正整数的原码、反码、补码都相同。
对于整型来说,数据存放内存中其实存放的是补码。
原因:在计算机系统中,数值一律用补码来表示和存储。原因在于使用补码可以将符号位和数值域统 一处理;
同时,加法和减法也可以统一处理(CPU只有加法器),另一方面,补码与原码相互转换,其运算过程是相同的,不需要额外的硬件电路。
1.2 大端和小端
1 | int a = -10; |
大小端是什么:
大端(存储)模式,是指数据的低位保存在内存的高地址中,而数据的高位,保存在内存的低地址中;
小端(存储)模式,是指数据的低位保存在内存的低地址中,而数据的高位,保存在内存的高地址中。
为什么会有大端和小端:
这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着一个字节,一个字节为8bit。
但是在C语言中除了8 bit的char之外,还有16 bit的short型,32 bit的long型(要看具体的编译器),另外,对于位数大于8位 的处理器,例如16位或者32位的处理器,由于寄存器宽度大于一个字节,那么必然存在着一个如何将多个字节安排的问题。因此就导致了大端存储模式和小端存储模式。
例如:一个 16bit 的 short 型 x ,在内存中的地址为 0x0010 , x 的值为 0x1122 ,那么 0x11 为高字节, 0x22 为低字节。
对于大端模式,就将 0x11 放在低地址中,即 0x0010 中, 0x22 放在高地址中,即 0x0011 中。小端模式则刚好相反。
我们常用的 X86 结构是小端模式,而 KEIL C51 则为大端模式。很多的ARM,DSP都为小端模式。有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。
举一个例题:
请简述大端字节序和小端字节序的概念,设计一个小程序来判断当前机器的字节序。
1 |
|
2.浮点型在内存中的存储
2.1 浮点数存储的例子
1 | int main(){ |
输出结果:
n的值为:9
*pFloat的值为:0.000000
num的值为:1091567616
*pFloat的值为:9.000000
2.2 浮点数存储规则
num 和 *pFloat 在内存中明明是同一个数,为什么浮点数和整数的解读结果会差别这么大?
要理解这个结果,一定要搞懂浮点数在计算机内部的表示方法。
详细解读:
根据国际标准IEEE(电气和电子工程协会) 754,任意一个二进制浮点数V可以表示成下面的形式:
(-1)^S * M * 2^E
(-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
M表示有效数字,大于等于1,小于2。
2^E表示指数位。
举例:
十进制的5.0,写成二进制是 101.0 ,相当于 1.01×2^2 。
那么,按照上面V的格式,可以得出s=0,M=1.01,E=2。
十进制的-5.0,写成二进制是-101.0 ,相当于-1.01×2^2 。
那么,s=1,M=1.01,E=2。
IEEE 754规定:
对于32位的浮点数,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M。
对于64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。
IEEE 754对有效数字M和指数E,还有一些特别规定:
前面说过, 1≤M<2 ,也就是说,M可以写成 1.xxxxxx 的形式,其中xxxxxx表示小数部分。
IEEE 754规定,在计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,只保存后面的xxxxxx部分。
比如保存1.01的时候,只保存01,等到读取的时候,再把第一位的1加上去。这样做的目的是为了节省1位有效数字。
以32位浮点数为例,留给M只有23位, 将第一位的1舍去以后,等于可以保存24位有效数字。
至于指数E,情况就比较复杂:
首先,E为一个无符号整数(unsigned int)。
这意味着,如果E为8位,它的取值范围为0255;如果E为11位,它的取值范围为02047。
但是,我们知道,科学计数法中的E是可以出现负数的,所以IEEE 754规定,存入内存时E的真实值必须再加上一个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023。
比如,2^10的E是10,所以保存成32位浮点数时,必须保存成10+127=137,即10001001。
然后,指数E从内存中取出还可以再分成三种情况:
①E不全为0或不全为1
这时,浮点数就采用下面的规则表示,即指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第一位的1。
比如:
0.5(1/2)的二进制形式为0.1,由于规定正数部分必须为1,即将小数点右移1位,则为 1.0*2^(-1),其阶码为-1+127=126,表示为 01111110,而尾数1.0去掉整数部分为0,补齐0到23位00000000000000000000000,则其二进制表示形式为:
1 | 0 01111110 00000000000000000000000 |
②E全为0
这时,浮点数的指数E等于1-127(或者1-1023)即为真实值,有效数字M不再加上第一位的1,而是还原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。
③E全为1
这时,如果有效数字M全为0,表示±无穷大(正负取决于符号位s);
解释一开始的例子:
为什么 0x00000009 还原成浮点数,就成了 0.000000 ?
首先,将 0x00000009 拆分,得到第一位符号位s=0,后面8位的指数 E=00000000 ,最后23位的有效数字M=000 0000 0000 0000 0000 1001。
1 | 9 -> 0000 0000 0000 0000 0000 0000 0000 1001 |
由于指数E全为0,所以符合上一节的第二种情况。因此,浮点数V就写成:
V=(-1)^0 × 0.00000000000000000001001×2^(-126)=1.001×2^(-146)
显然,V是一个很小的接近于0的正数,所以用十进制小数表示就是0.000000。
浮点数9.0,如何用二进制表示?还原成十进制又是多少?
首先,浮点数9.0等于二进制的1001.0,即1.001×2^3
1 | 9.0 -> 1001.0 ->(-1)^01.0012^3 -> s=0, M=1.001,E=3+127=130 |
那么,第一位的符号位s=0,有效数字M等于001后面再加20个0,凑满23位,指数E等于3+127=130, 即10000010。所以,写成二进制形式,应该是s+E+M,即
1 | 0 10000010 001 0000 0000 0000 0000 0000 |
这个32位的二进制数,还原成十进制,正是1091567616 。
第二章、指针的进阶
1.字符指针
在指针的类型中我们知道有一种指针类型为字符指针 char* ;
注意:
1 const char* pstr = "hello";这里并不是把字符串 hello 放到字符指针 pstr 里了,本质是把字符串 hello 首字符的地址放到了 pstr 中。
1 |
|
这里str3和str4指向的是一个同一个常量字符串。
C/C++会把常量字符串存储到单独的一个内存区域,当几个指针。指向同一个字符串的时候,他们实际会指向同一块内存。但是用相同的常量字符串去初始化不同的数组的时候就会开辟出不同的内存块。
所以str1和str2不同,str3和str4相同。
2.指针数组
指针数组是一个存放指针的数组。
1 | int* arr1[10]; //整形指针的数组 |
3.数组指针
3.1 定义
数组指针是指针,是能够指向数组的指针。
1 | int (*p)[10]; |
3.2 “数组名” vs “&数组名”
1 | int arr[10]; |
arr 和 &arr 是一样的东西吗?
举个例子:
1 |
|
结果:
arr = 012FF6EC
arr+1 = 012FF6F0
&arr= 012FF6EC
&arr+1= 012FF714
从上面代码的结果可知:&arr和arr,有同样的数值,但本质上是不同的。
实际上:
arr是数组名,数组名表示数组首元素的地址。
&arr 表示的是数组的地址,而不是数组首元素的地址。
本例中 &arr 的类型是: int(*)[10] ,是一种数组指针类型
数组的地址+1,跳过整个数组的大小,所以 &arr+1 相对于 &arr 的差值是40.
4.函数指针
1 |
|
结果:
003A1474
003A1474
输出了两个地址,这两个地址是 test 函数的地址。那么,如何保存函数的地址呢?
1 | void test() { |
补充两段有趣的代码:
1 | //代码1 |
5.函数指针数组
数组是一个存放相同类型数据的存储空间,指针数组是一个存放指针的数组。
那如果把函数的地址存到一个数组中,这个数组就叫函数指针数组。
1 | int (*parr[10])(); |
函数指针数组的用途:转移表
实例:
使用函数指针数组实现计算器。
1 |
|
6.指向函数指针数组的指针
指向函数指针数组的指针是一个指针
指针指向一个数组 ,数组的元素都是函数指针
1 | void test(const char* str){ |
7.回调函数
回调函数就是一个通过函数指针调用的函数。
如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。
回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。
举个例子:
首先先演示一下qsort函数(快速排序)的使用:
qsot函数可以排序任意类型的数据
1 |
|
使用回调函数,模拟实现qsort(采用冒泡的方式)
1 |
|
8.指针和数组的笔试题
X86环境
(X86环境下地址大小为4,X64环境下地址大小为8)
1 | //一维数组 |
总结:
数组名的意义:
- sizeof(数组名),这里的数组名表示整个数组,计算的是整个数组的大小。
- &数组名,这里的数组名表示整个数组,取出的是整个数组的地址。
- 除此之外所有的数组名都表示首元素的地址。
9.指针笔试题
笔试题1
1 | int main(){ |
笔试题2
1 | struct Test{ |
笔试题3
1 | int main(){ |
笔试题4
1 | int main(){ |
笔试题5
1 | int main(){ |
笔试题6
1 | int main(){ |
笔试题7
1 | int main(){ |
笔试题8
1 | int main(){ |
10.附录(qsort的使用)
1 |
|
第三章、字符串+内存函数
C语言中对字符和字符串的处理很是频繁,但是C语言本身是没有字符串类型的,字符串通常放在常量字符串或者字符数组中。
字符串常量适用于那些对它不做修改的字符串函数.
1.函数介绍
1.1 strlen
1 | size_t strlen ( const char * str ); |
strlen函数返回的是字符串中’\0’前面出现的字符个数(不包含’\0’)
strlen函数的返回值为size_t,是无符号的
size_t的真实类型与操作系统有关,
在32位架构中被普遍定义为:typedef unsigned int size_t;//4字节
而在64位架构中被定义为:typedef unsigned long size_t;//8字节
1 |
|
1.2 strcpy
字符串复制
1 | char* strcpy(char * dest, const char * src ); |
Copies the C string pointed by source into the array pointed by destination, including the terminating null character (and stopping at that point).
源字符串必须以 ‘\0’ 结束。
会将源字符串中的 ‘\0’ 拷贝到目标空间。
目标空间必须足够大,以确保能存放源字符串。
目标空间必须可变
1.3 strcat
字符串连接
1 | char * strcat ( char * dest, const char * src ); |
Appends a copy of the source string to the destination string. The terminating null character in destination is overwritten by the first character of source, and a null-character is included at the end of the new string formed by the concatenation of both in destination.
源字符串必须以 ‘\0’ 结束。
目标空间必须有足够的大,能容纳下源字符串的内容。
目标空间必须可修改。
1.4 strcmp
字符串比较
1 | int strcmp ( const char * str1, const char * str2 ); |
This function starts comparing the first character of each string. If they are equal to each other, it continues with the following pairs until the characters differ or until a terminating null-character is reached.
第一个字符串大于第二个字符串,则返回大于0的数字;
第一个字符串等于第二个字符串,则返回0;
第一个字符串小于第二个字符串,则返回小于0的数字。
1.5 strncpy
将指定长度的字符串复制到字符数组中
1 | char * strncpy ( char * destination, const char * source, size_t num ); |
Copies the first num characters of source to destination. If the end of the source C string (which is signaled by a null-character) is found before num characters have been copied, destination is padded with zeros until a total of num characters have been written to it.
拷贝num个字符从源字符串到目标空间。
如果源字符串的长度小于num,则拷贝完源字符串之后,在目标的后边追加0,直到num个。
1.6 strncat
在字符串的结尾追加n个字符
1 | char * strncat ( char * destination, const char * source, size_t num ) |
Appends the first num characters of source to destination, plus a terminating null-character.
If the length of the C string in source is less than num, only the content up to the terminating null-character is copied.
1.7 strncmp
比较字符串前 n 个字节
1 | int strncmp ( const char * str1, const char * str2, size_t num ); |
比较到出现另个字符不一样或者一个字符串结束或者num个字符全部比较完 。
return value indicates <0 the first character that does not match has a lower value in str1 than in str2 0 the contents of both strings are equal >0 the first character that does not match has a greater value in str1 than in str2
1.8 strstr
返回字符串中首次出现子串的地址
1 | char * strstr ( const char *str1, const char * str2); |
- Returns a pointer to the first occurrence of str2 in str1, or a null pointer if str2 is not part of str1.
1.9 strtok
分解字符串
1 | char * strtok ( char * str, const char * sep ); |
sep参数是个字符串,定义了用作分隔符的字符集合。
第一个参数指定一个字符串,它包含了0个或者多个由sep字符串中一个或者多个分隔符分割的标记。
strtok函数找到str中的下一个标记,并将其用 \0 结尾,返回一个指向这个标记的指针。(注:strtok函数会改变被操作的字符串,所以在使用strtok函数切分的字符串一般都是临时拷贝的内容并且可修改。)
strtok函数的第一个参数不为 NULL ,函数将找到str中第一个标记,strtok函数将保存它在字符串中的位置。
strtok函数的第一个参数为 NULL ,函数将在同一个字符串中被保存的位置开始,查找下一个标记。
如果字符串中不存在更多的标记,则返回 NULL 指针。
1.10 strerror
返回一个指向错误字符串的指针
1 | char * strerror ( int errnum ); |
- 返回错误码,所对应的错误信息。
实例:
1 |
|
1.11 memcpy
内存拷贝函数,从源内存地址的起始位置开始拷贝若干个字节到目标内存地址
1 | void * memcpy ( void * destination, const void * source, size_t num ); |
- 函数memcpy从source的位置开始向后复制num个字节的数据到destination的内存位置。
- 这个函数在遇到 ‘\0’ 的时候并不会停下来。
- 如果source和destination有任何的重叠,复制的结果都是未定义的。
- c语言规定,memcpy支持不重叠拷贝就够了,但是在vs,memcpy也能支持重叠拷贝。
1.12 memmove
拷贝字节
1 | void * memmove ( void * destination, const void * source, size_t num ); |
- 和memcpy的差别就是memmove函数处理的源内存块和目标内存块是可以重叠的。
- 如果源空间和目标空间出现重叠,就得使用memmove函数处理。
1.13 memcmp
把存储区 ptr1 和存储区 ptr2 的前 n 个字节进行比较
1 | int memcmp ( const void * ptr1, |
比较从ptr1和ptr2指针开始的num个字节。
return value indicates <0 the first byte that does not match in both memory blocks has a lower value in ptr1 than in ptr2 (if evaluated as unsigned char values) 0 the contents of both memory blocks are equal >0 the first byte that does not match in both memory blocks has a greater value in ptr1 than in ptr2 (if evaluated as unsigned char values)
2.函数的模拟实现
2.1 模拟实现strlen
三种方式
方式1(计数器)
1 | int my_strlen(const char *str){ |
方式2(无临时变量的计数器,递归)
1 | int my_strlen(const char *str){ |
方式3(指针-指针)
1 | int my_strlen(const char *str){ |
2.2 模拟实现strcpy
1 | char *strcpy(char *dest, const char *src ){ |
2.3 模拟实现strcat
1 | char* strcat ( char *dest, const char *src ){ |
2.4 模拟实现strstr
1 | char* strstr ( const char *str1, const char *str2){ |
2.5 模拟实现strcmp
1 | int my_strcmp (const char * src, const char * dst){ |
2.6 模拟实现memcpy
1 | void * memcpy ( void * dst, const void * src, size_t count){ |
2.7 模拟实现memmove
1 | void * memmove ( void * dst, const void * src, size_t count){ |
第四章、自定义类型详解
1.结构体
结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。
1.1 结构的声明
1 | //创建一个描述学生的结构体 |
在声明结构的时候,可以不完全的声明(省略结构体的标签)。
1 | struct{ |
1.2 结构的自引用
1 | struct Node{ |
1.3 结构体变量的定义与初始化
1 | struct Point{ |
1.4 结构体内存对齐
结构体中元素是按照定义顺序一个一个放到内存中去的,但并不是紧密排列的。
结构体的对齐规则:
1.第一个成员在与结构体变量偏移量为0的地址处。
2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
(对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值)
3.结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
4.如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
为什么存在内存对齐?
大部份资料都是这样说的
1.平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
2.性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问
总体来说,结构体的内存对齐是拿空间来换取时间的做法。
所以我们在设计结构体时,应该让占用空间小的成员尽量集中在一起。
1 | //练习1 |
1.5 修改默认对齐数
不知道大家是否见过 #pragma 这个预处理指令,这里使用可以改变我们的默认对齐数。
1 |
|
结论:结构在对齐方式不合适的时候,我么可以自己更改默认对齐数。
1.6 结构体传参
1 | struct S{ |
上面的 print1 和 print2 函数哪个好些?
答案:首选print2函数。
结构体传参的时候,要传结构体的地址。
原因:
函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。
如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。
2.位段
2.1 什么是位段
位段的声明和结构是类似的,有两个不同:
1.位段的成员必须是 int、unsigned int 或signed int 。
2.位段的成员名后边有一个冒号和一个数字。
实例:
1 | struct A{ |
2.2 位段的内存分配
1.位段的成员可以是 int unsigned int signed int 或者是 char (属于整形家族)类型
2.位段的空间上是按照需要以**4个字节( int )或者1个字节( char )**的方式来开辟的。
3.位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段
2.3 位段的跨平台问题
1.int 位段被当成有符号数还是无符号数是不确定的。
2.位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题。
3.位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
4.当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
总结:跟结构相比,位段可以达到同样的效果,但是可以很好的节省空间,但是有跨平台的问题存在。
3.枚举
3.1 枚举类型的定义
1 | //星期 |
以上定义的 enum Day , enum Sex , enum Color 都是枚举类型。
{}中的内容是枚举类型的可能取值,也叫 枚举常量 。
这些可能取值都是有值的,默认从0开始,一次递增1,当然在定义的时候也可以赋初值。
例如:
1 | //颜色 |
3.2 枚举的优点
1.增加代码的可读性和可维护性
2.和#define定义的标识符比较,枚举有类型检查,更加严谨。
3.防止了命名污染(封装)
4.便于调试
5.使用方便,一次可以定义多个常量
3.3 枚举的应用
1 | enum Color{ |
4.联合(共用体)
4.1 联合类型的定义
联合也是一种特殊的自定义类型
这种类型定义的变量也包含一系列的成员,特征是这些成员公用同一块空间(所以联合也叫共用体)。
1 | //联合类型的声明 |
4.2 联合的特点
联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)
1 | union Un{ |
4.3 联合大小的计算
联合的大小至少是最大成员的大小。
当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。
1 | union Un1{ |
4.4 面试题
判断当前计算机的大小端存储
1 | int check_sys(){ |
第五章、动态内存管理
1.动态内存分配的必要性
1 | int val = 20; //在栈空间上开辟四个字节 |
上述开辟空间方式有两个特点:
1.空间开辟大小是固定的。
2.数组在申明的时候,必须指定数组的长度,它所需要的内存在编译时分配
但是对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小在程序运行的时候才能知道,那数组的编译时开辟空间的方式就不能满足了。这时候就只能试试动态存开辟了
2.动态内存函数的介绍
2.1 malloc 和 free
malloc和free都声明在 stdlib.h 头文件中
1 | void* malloc (size_t size); |
这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。
如果开辟成功,则返回一个指向开辟好空间的指针。
如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。
返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
如果参数 size 为0,malloc的行为是标准是未定义的,取决于编译器。
1 | void free (void* ptr); |
free函数用来释放动态开辟的内存。
如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
如果参数 ptr 是NULL指针,则函数什么事都不做。
2.2 calloc
1 | void* calloc (size_t num, size_t size); |
函数的功能是为 num 个大小为 size 的元素开辟一块空间,并且把空间的每个字节初始化为0。
与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0。
2.3 realloc
1 | void* realloc (void* ptr, size_t size); |
ptr 是要调整的内存地址
size 调整之后新大小
返回值为调整之后的内存起始位置。
这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间。
realloc在调整内存空间的是存在两种情况 :
第一种情况:原有空间之后有足够大的空间
扩展内存就直接在原有内存之后直接追加空间,原来空间的数据不发生变化。
第二种情况:原有空间之后没有足够大的空间
在堆空间上另找一个合适大小的连续空间来使用。这样函数返回的是一个新的内存地址
realloc如果调整失败,会返回一个空指针
1 | int* p=(int*)calloc(10,sizeof(int)); |
3.常见的动态内存错误
3.1 对NULL指针的解引用操作
1 | void test(){ |
3.2 对动态开辟空间的越界访问
1 | void test(){ |
3.3 对非动态开辟内存使用free释放
1 | void test(){ |
3.4 使用free释放一块动态开辟内存的一部分
1 | void test(){ |
3.5 对同一块动态内存多次释放
1 | void test(){ |
3.6 动态开辟 内存忘记释放(内存泄漏)
1 | void test(){ |
动态开辟的空间一定要释放,并且正确释放。
4.经典的笔试题
题目1:
1 | void GetMemory(char *p){ |
请问运行Test 函数会有什么样的结果?
程序崩溃
1 | //修改后 |
题目2:
1 | char *GetMemory(void){ |
请问运行Test 函数会有什么样的结果?
会出现一段乱码,函数结束后空间会回收。
题目3:
1 | void GetMemory(char **p, int num){ |
请问运行Test 函数会有什么样的结果?
存在内存泄漏,没有释放内存。
题目4:
1 | void Test(void){ |
请问运行Test 函数会有什么样的结果?
非法访问内存,str指向的空间释放后不能再次使用。
5.C/C++程序的内存开辟
C/C++程序内存分配的几个区域:
1.内核空间:用户代码不能读写
2.栈区(stack)(向下增长):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。
3.堆区(heap)(向上增长):一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。分配方式类似于链表。
4.数据段(静态区)(static)存放全局变量、静态数据。程序结束后由系统释放。
5.代码段:存放函数体(类成员函数和全局函数)的二进制代码/只读常量
6.柔性数组
C99 中,结构中的最后一个元素允许是未知大小的数组,这就叫做『柔性数组』(flexible array)成员。
1 | typedef struct st_type{ |
6.1 柔性数组的特点
结构中的柔性数组成员前面必须至少一个其他成员。
sizeof 返回的这种结构大小不包括柔性数组的内存。
包含柔性数组成员的结构用malloc ()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小。
例如:
1 | typedef struct st_type{ |
6.2 柔性数组的使用
1 | //代码1 |
这样柔性数组成员a,相当于获得了100个整型元素的连续空间。
6.3 柔性数组的优势
1 | //代码2 |
上述 代码1 和 代码2 可以完成同样的功能,但是 方法1 的实现有两个好处:
- 方便内存释放
如果我们的代码是在一个给别人用的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户。用户调用free可以释放结构体,但是用户并不知道这个结构体内的成员也需要free,所以你不能指望用户来发现这个事。所以,如果我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就可以把所有的内存也给释放掉。
- 有利于提高访问速度
连续的内存有益于提高访问速度,也有益于减少内存碎片。(其实,我个人觉得也没多高了,反正你跑不了要用做偏移量的加法来寻址)
第六章、小练习:通讯录
一、介绍
通讯录(暂时不考虑名字相同)
1.可以存放1000个人的信息
2.人的信息:名字,年龄,电话,住址,性别
3.增、删、改、查联系人
动态增加
查询根据联系人的电话查询
4.排序(根据年龄排序)
二、完整代码
contact.h
1 |
|
contact.cpp
1 |
|
test.cpp
1 |
|
第七章、文件操作
1.文件的概念
磁盘上的文件是文件。
但是在程序设计中,我们一般谈的文件有两种:程序文件、数据文件(从文件功能的角度来分类的)。
- 程序文件
包括源程序文件(后缀为.c),目标文件(windows环境后缀为.obj),可执行程序(windows环境后缀为.exe)。
- 数据文件
文件的内容不一定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据的文件,或者输出内容的文件 。
本节主要讨论的是数据文件。
一个文件要有一个唯一的文件标识,以便用户识别和引用。
文件名包含3部分:文件路径+文件名主干+文件后缀( 例如: c:\code\test.txt )
为了方便起见,文件标识常被称为文件名。
2.文件的打开和关闭
2.1 文件指针
缓冲文件系统中,关键的概念是“文件类型指针”,简称“文件指针”。
每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名字,文件状态及文件当前的位置等)。这些信息是保存在一个结构体变量中的。该结构体类型是有系统声明的,取名FILE.
例如,VS2013编译环境提供的 stdio.h 头文件中有以下的文件类型申明:
1 | struct _iobuf { |
不同的c编译器的FILE类型包含的内容不完全相同,但是大同小异。
每当打开一个文件的时候,系统会根据文件的情况自动创建一个FILE结构的变量,并填充其中的信息,使用者不必关心细节。
一般都是通过一个FILE的指针来维护这个FILE结构的变量,这样使用起来更加方便。
1 | FILE* pf; //文件指针变量 |
定义pf是一个指向FILE类型数据的指针变量。可以使pf指向某个文件的文件信息区(是一个结构体变量)。通过该文件信息区中的信息就能够访问该文件。也就是说,通过文件指针变量能够找到与它关联的文件。
2.2 文件的打开和关闭
文件在读写之前应该先打开文件,在使用结束之后应该关闭文件。
在编写程序的时候,在打开文件的同时,都会返回一个FILE*的指针变量指向该文件,也相当于建立了指针和文件的关系。
ANSIC 规定使用fopen函数来打开文件,fclose函数来关闭文件
1 | //打开文件 |
mode(文件使用方式):
文件使用方式 | 含义 | 如果指定文件不存在 |
---|---|---|
“r”(只读) | 为了输入数据,打开一个已经存在的文本文件 | 出错 |
“w”(只写) | 为了输出数据,打开一个文本文件 | 建立一个新的文件 |
“a”(追加) | 向文本文件尾添加数据 | 建立一个新的文件 |
“rb”(只读) | 为了输入数据,打开一个二进制文件 | 出错 |
“wb”(只写) | 为了输出数据,打开一个二进制文件 | 建立一个新的文件 |
“ab”(追加) | 向一个二进制文件尾添加数据 | 出错 |
“r+”(读写) | 为了读和写,打开一个文本文件 | 出错 |
“w+”(读写) | 为了读和写,建议一个新的文件 | 建立一个新的文件 |
“a+”(读写) | 打开一个文件,在文件尾进行读写 | 建立一个新的文件 |
“rb+”(读写) | 为了读和写打开一个二进制文件 | 出错 |
“wb+”(读写) | 为了读和写,新建一个新的二进制文件 | 建立一个新的文件 |
“ab+”(读写) | 打开一个二进制文件,在文件尾进行读和写 | 建立一个新的文件 |
1 | /* fopen fclose example */ |
3.文件的顺序读写
功能 | 函数名 | 适用于 |
---|---|---|
字符输入函数 | fgetc | 所有输入流 |
字符输出函数 | fputc | 所有输出流 |
文本行输入函数 | fgets | 所有输入流 |
文本行输出函数 | fputs | 所有输出流 |
格式化输入函数 | fscanf | 所有输入流 |
格式化输出函数 | fprintf | 所有输出流 |
二进制输入 | fread | 文件 |
二进制输出 | fwrite | 文件 |
4.文件的随机读写
4.1 fseek
根据文件指针的位置和偏移量来定位文件指针。
1 | int fseek ( FILE * stream, long int offset, int origin ); |
第一个参数stream为文件指针
第二个参数offset为偏移量,正数表示正向偏移,负数表示负向偏移
第三个参数origin设定从文件的哪里开始偏移,可能取值为:SEEK_CUR、SEEK_END 或 SEEK_SET
SEEK_SET: 文件开头
SEEK_CUR: 当前位置
SEEK_END: 文件结尾
其中SEEK_SET,SEEK_CUR和SEEK_END依次为0,1和2。
1 | /* fseek example */ |
4.2 ftell
返回文件指针相对于起始位置的偏移量。
1 | long int ftell ( FILE * stream ); |
1 | /* ftell example */ |
4.3 rewind
让文件指针的位置回到文件的起始位置
1 | void rewind ( FILE * stream ); |
1 | /* rewind example */ |
5.文本文件和二进制文件
根据数据的组织形式,数据文件被称为文本文件或者二进制文件。
数据在内存中以二进制的形式存储,如果不加转换的输出到外存,就是二进制文件。
如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的文件就是文本文件。
一个数据在内存中是怎么存储的呢?
字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以使用二进制形式存储。
如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符一个字节),而二进制形式输出,则在磁盘上只占4个字节(VS2013测试)。
6.文件读取结束的判定
被错误使用的feof
牢记:在文件读取过程中,不能用feof函数的返回值直接用来判断文件的是否结束。而是应用于当文件读取结束的时候,判断是读取失败结束,还是遇到文件尾结束。
文本文件读取是否结束,判断返回值是否为 EOF ( fgetc ),或者 NULL ( fgets )
7.文件缓冲区
ANSIC 标准采用“缓冲文件系统”处理的数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块“文件缓冲区”。从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上。如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译系统决定的。
1 | /* example */ |
因为有缓冲区的存在,C语言在操作文件的时候,需要做刷新缓冲区或者在文件操作结束的时候关闭文件。如果不做,可能导致读写文件的问题。
第八章、程序的编译
1.程序的翻译环境和执行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码
2.详解编译+链接
2.1 翻译环境
组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。(linux后缀**.o** windows后缀**.obj**)
链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。
翻译环境本身又分为几个阶段:
- 预编译阶段
gcc -E test.c -o test.i
文本操作
①头文件的包含#include;
②删除注释
③#define定义符号的替换
- 编译
gcc -S test.i
->test.s
把C语言转换成汇编代码
①语法分析
②词法分析
③语义分析
④符号汇总
推荐两本书《编译原理》,《程序员的自我修养》
- 汇编
gcc -c test.s
->test.o
把汇编代码转换成二进制的指令
①形成符号表
- 链接
gcc test.o xxx.o -o test
->test(可执行文件,如果不自定义的化,默认a.out)
生成可执行程序
①合并段表
②符号表的合并和符号表的重定位
多个目标文件进行链接的时候会通过符号表来查看来自外部的符号是否存在
2.2 运行环境
程序执行的过程:
①程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
②程序的执行便开始。接着便调用main函数。
③开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
④终止程序。正常终止main函数;也有可能是意外终止。
3.预处理详解
3.1 预定义符号
1 | __FILE__ //进行编译的源文件 |
这些预定义符号都是语言内置的,可以直接用。
1 | /* example */ |
3.2 #define
#define 定义标识符
1 | /* example */ |
注意:在define定义标识符时,不要在后面加上 ;
#define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
宏的申明方式:
#define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中
1 | /* example */ |
注意:
参数列表的左括号必须与name紧邻。
如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分
用于对数值表达式进行求值的宏定义都应该用加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。
约定俗成:把宏名全部大写;函数名不要全部大写
#define 替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
①在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
②替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
③最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
注意:
①宏参数和#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归。
②当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索 。
#和##
- #的作用
①当字符串作为宏参数的时候可以把字符串放在字符串中
1 |
|
②使用 # ,可以把一个宏参数变成对应的字符串
1 |
|
- ##的作用
##可以把位于它两边的符号合成一个符号。
它允许宏定义从分离的文本片段创建标识符。
1 |
|
带有副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
x+1;//不带副作用
x++;//带有副作用
1 | //证明 具有副作用的参数所引起的问题 的例子 |
3.3 宏和函数的对比
宏通常被应用于执行简单的运算 ,为什么不用函数来执行呢?
原因:
①用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。
所以宏比函数在程序的规模和速度方面更胜一筹。
②更为重要的是函数的参数必须声明为特定的类型。
所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于>来比较的类型。
宏是类型无关的。
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
1 |
|
宏的缺点:当然和函数相比宏也有劣势的地方:
①每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
② 宏是没法调试的。
③宏由于类型无关,也就不够严谨。
④宏可能会带来运算符优先级的问题,导致程容易出现错。
宏和函数的对比:
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操作符优先 | 宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括 号 | 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。 | 函数参数只在传参的时候求值一 次,结果更容易控制。 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型。 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是不同的。 |
调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
3.3 #undef
这条指令用于移除一个宏定义
1 |
|
3.4 命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
例如:当我们根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大写,我们需要一个数组能够大写。)
1 |
|
编译指令:
1 | //linux 环境演示 |
3.5 条件编译
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
比如:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
1 |
|
常见的条件编译指令
1 | 1. |
3.6 文件包含
#include 指令可以使另外一个文件被编译。预处理器会先删除这条指令,并用包含文件的内容替换。
- 本地文件包含
1 |
先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。
- 库文件包含
1 |
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。 所以对于库文件也可以使用 “ ” 的形式包含(但是这样效率低,也不容易区分本地文件和库文件) 。
- 嵌套文件包含
如果出现了嵌套文件包含,就容易造成文件内容的重复,为了解决这个问题,可以使用条件编译。
在每个头文件的开头写:
1 |
|
或者
1 |
4.其他预处理指令
1 |
|
5.小练习
- 写一个宏,可以将一个整数的二进制位的奇数位和偶数位交换。
1 |
- 写一个宏,计算结构体中某变量相对于首地址的偏移,并给出说明
1 |
假设结构体的首地址是0开始,把他转换成结构体指针类型,再用 -> 获取他的成员,前面加 & 就是为了获取这个成员的地址。,最后再强制转换成unsigned long, 这样就得到了偏移量。