c语言进阶
Senior c
Published: 2021-05-13

c语言进阶知识

Code in GitHub: c

类型

整型家族:

  • char

    • unsigned char
    • signed char
  • short

    • unsigned short或unsigned short int
    • signed short或signed short int
  • int

    • unsigned int
    • signed int
  • long

    • unsigned long或unsigned long int
    • signed long或signed long int

浮点型家族:

  • float
  • double

构造类型(自定义类型):

  • 数组类型

    int [10],int[5],char [5]这些都是不同的数组类型

  • 结构体类型 struct

  • 枚举类型 enum

  • 联合类型 union

指针类型:

int* p,char* p,void* p等

空类型:

void表示空类型(无类型)

通常应用于函数的返回类型、函数的参数、指针类型

举例:

void test(){
    printf("ok\n");
}
int main(){
    test(100); // 正常输出ok,虽然这里传了个100过去,而test函数并没有形参去接它,但是函数依然没有报错,正常执行了。这是c语言比较模棱两可的地方。
    return 0;
}

那么对于上面这种函数,显然它不需要接收任何参数,那么我们可以这样写:

void test(void){ // 后面那个void意思是我这个函数它是不需要参数的
    printf("ok\n");
}
int main(){
    test(100); // 这个时候如果还传个100过去,那编译器就会报警告了
    return 0;
}

类型的意义

  • 使用类型开辟内存空间的大小(大小决定使用范围)
  • 如何看待内存空间的视角(比方说int和float,明明都是4字节的,内存中存储的方式却不一样,那是因为一个是int一个是float)

大小端(解释整型数存储到内存时有时候是按字节倒着存的现象)

现象:

int b = -10; // 那么b在内存中它很可能是存了个:0xf6ffffff
// 解释:b的原码、反码、补码:
// 10000000000000000000000000001010 - 原码
// 11111111111111111111111111110101 - 反码
// 11111111111111111111111111110110 - 补码
// 补码十六进制:0xfffffff6
// 而在内存中它是按照字节倒着存的,所以变成了:0xf6ffffff

什么是大端小端:

大端(存储)模式(也被称为大端字节序存储模式),是指数据的低位保存在内存的高地址中,而数据的高位,保存在内存的低地址中;

小端(存储)模式(也被称为小端字节序存储模式),是指数据的低位保存在内存的低地址中,而数据的高位,保存在内存的高地址中。

// 比方说有数:0x11223344这样一个数
// 那在内存中可以这样存:11 22 33 44,也可以这样存:44 33 22 11,当然也可以这样存:11 33 22 44,存的方法有千千万万种,但是最人性化的也就两种:11 22 33 44 和 44 33 22 11。这两种模式分别叫大端模式和小端模式

image-20210305172029330

为什么说11 22 33 44是属于大端模式呢?因为在十进制中我们按照万千百十个这样的顺序的话,万是属于高位的,个是属于低位的,那么十六进制也是一样,11 22 33 44中11是属于高位的,44是属于低位的,因为高位存在低地址,11 22 33 44被称为大端模式,同理44 33 22 11则被称为小端模式

所以我们上面的0xfffffff6以f6ffffff的形式存储在内存中,那显然就是小端模式了

为什么会有大小端模式之分呢?

因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着一个字节,一个字节为8bit,但是c语言中除了8bit的char之外,还有16bit的short,32bit的long(要看具体的编译器),另外,对于位数大于8位的处理器,例如16位或者32位的处理器,由于寄存器宽度大于一个字节,那么必然存在存储时如何安排多个字节的存储顺序的问题,因此就导致了大端存储模式和小端存储模式。

写程序判断当前机器是大端还是小端:

int check_sys(){
    int a = 1;
    // 返回1,小端
    // 返回0,大端
    return *(char*)&a;
}

案例:

// 已知大端存储
int main(){
    unsigned int a = 0x1234; // 由于是大端存储,因此a在内存中这么存:(假设从左到右是低地址到高地址)00 00 12 34 
    unsigned char b = *(unsigned char*)&a; // 取a的地址,强转成unsigned char*,意味着它的步长变1,再解引用拿到的应该是00这个字节,所以输出为0
    printf("%d\n", b); // 0
    return 0;
}

案例2:

int main(){
    union{
        short k;
        char i[2];
    }*s, a;
    s = &a;
    s->i[0] = 0x39;
    s->i[1] = 0x38;
    printf("%x\n", a.k); // 由于测试机器是小端,因此低位放低地址,高位放高地址,显然i[1](0x38)是低位,i[0](0x39)是高位,因此0x38放在低地址,0x39放在高地址,因此打印出来是3839
    return 0;
}

有符号数和无符号数所能表示的范围

拿char来举例

image-20210307155551394

  • 有符号数

    二进制补码 二进制原码 十进制
    00000000 00000000 0
    00000001 00000001 1
    00000010 00000010 2
    01111110 01111110 126
    01111111 01111111 127
    10000000 这个数比较特殊,我们在求原码的时候发现减一没地方去减了。那么在这里,我们直接将这个数定义为-128。其实定义成-128也是有一定道理的,假设使用9位二进制数来表示-128,那一定是110000000,那么它的反码是101111111,那么它的补码是110000000,我们发现补码的低8个比特位就是10000000 -128
    10000001 11111111 -127
    10000010 11111110 -126
    11111101 10000011 -3
    11111110 10000010 -2
    11111111 10000001 -1

    因此有符号的char的范围是:-128 -> 127

  • 无符号数

    无符号的char那很简单,没有符号位了,因此能表示的范围是:00000000 -> 11111111,也就是0 -> 255

同理int、short、long等其他整型

案例1:

int main(){
    char a = -128;
    char b = 128;
    printf("%u\n", a); // 4294967168
    printf("%u\n", b); // 4294967168
    return 0;
}

案例2:

unsigned int a = 0; // 00000000000000000000000000000000
a -= 1; // 11111111111111111111111111111111
printf("%u\n", a); // 由于是无符号数,因此输出为4294967295(11111111111111111111111111111111)

案例2变种:

unsigned int i;
for(i = 9; i >= 0; i--){
    printf("%u\n", i);
}
// 结果就是死循环

案例3:

int main(){
    char a[1000];
    int i;
    for(i = 0; i < 1000; i++){
        a[i] = -1 - i; 
    }
    printf("%d\n", strlen(a)); // 128 + 127 = 255
    return 0;
}
// 解释:11111111, 11111110, 11111101, ..., 10000000, 01111111, 01111110, ..., 00000000, 11111111, 11111110, ...
// 记住一点,char型永远都是截取比特位最后8位,如果进位那也是截取比特位最后8位,如果00000000减一不够减的时候向第9位借位之后还是会截取比特位最后8位,因此00000000减一就变成11111111

limits.h和float.h

limits.h用于找到整型数的取值范围

float.h用于找到浮点型数的取值范围

浮点数在内存中的存储

存进去的时候

根据国际标准IEEE(电气和电子工程协会)754,任意一个二进制浮点数V可以表示成下面的形式:

  • (-1)^S * M * 2^E
  • (-1)^S表示符号位,S=0时V为正数;S=1时V为负数
  • M表示有效数字,大于等于1,小于2.(为什么是大于等于1,小于2呢?首先科学计数法有效数字肯定是大于等于1的,然后这里是二进制所以只有1或0,因此有效数字小于2)
  • 2^E表示指数位

举例来说:

十进制的5.0,写成二进制是101.0,相当于(-1)^0 * 1.01 * 2^2。那么,按照上面V的格式,可以得出S=0,M=1.01,E=2。

十进制的-5.0,写成二进制是-101.0,相当于(-1)^1 * 1.01 * 2^2。那么,按照上面V的格式,可以得出S=1,M=1.01,E=2。

那么浮点数在内存中到底是怎么存储的呢?

image-20210307202642547

image-20210307202935460

IEEE 754对有效数字M和制数E,还有一些特别规定。前面说过,1≤M<2,也就是说,M可以写成1.xxxxxxx的形式,其中xxxxxx表示小数部分。

IEEE 754规定,在计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,只保存后面的xxxxxx部分。比如保存1.01的时候,只保存01,等到读取的时候,再把第一位的1加上去。这样做的目的,是节省1位有效数字。以32位浮点数为例,留给M只有23位,将第一位的1舍去以后,等于可以保存24位有效数字。

注意!对于M来讲,如果M没有满23位数字,则需要向后补0直到补满23位数字为止。

至于指数E,情况就比较复杂。

首先,E为一个无符号整数(unsigned int)。这意味着,如果E为8位,它的取值范围为0~255;如果E为11位,它的取值范围为0~2047.但是,我们知道,科学计数法中的E是可以出现负数的,所以IEEE 754规定,存入内存时E的真实值必须再加上一个中间数,对于8位的E,这个中间数是127;对于11位的E,这个中间数是1023.比如,2^10的E是10,所以保存成32位浮点数时,必须保存成10+127=137,即10001001。

比方说:

十进制的0.5,写成二进制是0.1(二进制中小数点后面的那个1表示2^(-1)),相当于(-1)^0 * 1.0 * 2^(-1),我们发现,E此时是-1,是个负数,但是我们知道内存中存E的地方是需要存无符号数的,那么这里怎么办呢?这里采取的方法是加一个中间数(对于8位的E,这个中间数是127;对于11位的E,中间数是1023),加上之后,E就算原先是负数,现在也变正数了,就可以存到内存中存E的地方了

注意!对于E来讲,不管它是正数还是负数,都要加中间数。

举例:

float f = 5.5;
// 5.5
// 101.1
// (-1)^0 * 1.011 * 2^2
// S = 0
// M = 1.011
// E = 2 (E此时还需要加上127,也就是说E最后存进去的是2 + 127 = 129 -> 10000001)
// 0 10000001 011 00000000000000000000 -> 0100 0000 1011 0000 0000 0000 0000 0000
// 0x40b00000

取出来的时候

还是上面的E

指数E从内存中取出还可以再分成三种情况:

E不全为0或不全为1(常规情况)

此时,浮点数就采用下面的规则表示,即指数E的计算值减去127(或1023)得到真实值,再在有效数字M前加上第一位的1,根据公式即可还原浮点数。如下:

0100 0000 1011 0000 0000 0000 0000 0000 -> 0 10000001 011 00000000000000000000

10000001 -> 128 -> 129 - 127 = 2

011 -> 1.011

得:(-1)^0 * 1.011 * 2^2 = 101.1 -> 5.5

E全为0

此时,浮点数指数E真实值为-127(或-1023)!那么我们可以想象最终还原回来的数将是这样的:1.xxx * 2^(-127) 或 -1.xxx * 2^(-127) 或 1.xxx * 2^(-1023) 或 -1.xxx * 2^(-1023) ,已经无限接近于0了

所以假设E全为0,这里会有一种特殊的处理方式,那就是浮点数的指数E等于1-127(或者1-1023)即为真实值,有效数字M不再加上第一位的1,而是还原为0.xxxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。如下:

0100 0000 1011 0000 0000 0000 0000 0000 -> 0 10000001 011 00000000000000000000

得:+/- (-1)^0 * 0.011 * 2^(-126),这是一个无限接近于0的数字

E全为1

此时,浮点数指数E真实值为128(或1024)!那么我们可以想象最终还原回来的数将是这样的:1.xxx * 2^128 或 -1.xxx * 2^128 或 1.xxx * 2^1024 或 -1.xxx * 2^1024,已经是一个无穷大的数字了。

此时如果有效数字M全为0,表示±无穷大(正负取决于符号位S)

案例:

int n = 9;
float* pFloat = (float*)&n;
printf("%d\n", n); // 9
printf("%f\n", *pFloat); // 0.000000

*pFloat = 9.0;
printf("%d\n", n); // 1091567616
printf("%f\n", *pFloat); // 9.0

指针的进阶

将数组名赋给指针

int arr[10] = {1,2,3,4,5};
int* p = arr; // 数组名表示首元素地址
// *(p + 2) == p[2] == *(arr + 2) == arr[2]
// 从这里可以看出p和arr其实是等价的

字符指针

// ”abcde"直接赋给指针p那是不现实的,因为指针p大小只有4个字节,而"abcde"是5个字节,根本放不下
// 实际上,字符串要赋值给指针,不是把整个字符串赋值给指针,而是把字符串首字符的地址赋值给指针
char* p = "abcde"; // "abcde"是一个常量字符串
printf("%c\n", *p); // a
printf("%s\n", p); // abcde
// 相当于发生了这些事:首先在内存中开辟空间存放常量字符串”abcde\0“,并且首字符a的地址是0x0012ff44,然后还会为指针p开辟空间并且指针p里面存放首字符a的地址,也就是0x0012ff44

image-20210308100339878

我们刚刚说”abcde“是常量字符串,那么我们能否通过指针p来改变它呢?比方说*p = ‘W’;答案是不行!如果强行执行将会报Segmentation fault - 段错误(访问非法内存时会报的错)

那么为啥会报错呢?那是因为”abcde“是常量字符串,无法被修改

那么最正确的写法是:

const char* p = "abcde"; // 使用const修饰*p,那么就不怕它被非法修改了

案例:

char arr1[] = "abc";
char arr2[] = "abc";
char* p1 = "abc"; // 最好写成 const char* p1 = "abc"
char* p2 = "abc"; // 最好写成 const char* p2 = "abc"
// 那么此时arr1 != arr2;p1 == p2
// 解释:如果”abc“是常量字符串,为了节省空间,”abc“就只需要创建并且存储一份在内存中就可以了,因此p1和p2指向的是同一块内存区域;而数组不一样,数组不管内容一不一样它都是需要重新创建并存储的

这里介绍一个网站:https://segmentfault.com/

数组指针

// int *p = NULL; // p是整型指针 - 指向整型的指针 - 可以存放整型的地址
// char* pc = NULL; // pc是字符指针 - 指向字符的指针 - 可以存放字符的地址
					// 数组指针 - 指向数组的指针 - 存放数组的地址
// int arr[10] = {0};
// arr - 首元素的地址
// &arr[0] - 首元素的地址
// &arr - 数组的地址
int arr[10] = {1, 2, 3, 4};
int* p1[10] = &arr; // ❌ 由于[]的优先级高于*,因此p1先和[结合了,那么它就是一个数组而不是指针了。因此这种写法错误!
int (*p)[10] = &arr; // ✔ 相当于*先和p结合了,那么它就是一个指针,后面的[10]说明它指向的是一个存放10个元素的数组,那么这个数组存放的元素是什么类型的呢?前面的int说明存放的元素是int型的
// 那么上面的p就是数组指针

那么我们来辨别一下下面四行代码分别表示什么:

int arr[5]; // arr是一个5个元素的整型数组
int* parr1[10]; // parr1 是一个数组,数组有10个元素,每个元素的类型是int*,parr1是指针数组
int (*parr2)[10]; // parr2 是一个指针,该指针指向了一个数组,数组有10个元素,每个元素的类型是int,parr2是数组指针
int (* parr3[10])[5]; // 首先由于[]优先级比*高,因此parr3先跟[]结合,因此parr3首先应该被定义为是一个数组。parr3是一个数组,该数组有10个元素,每个元素是一个类型为int(*)[5]的数组指针,该数组指针指向的数组有5个元素,每个元素的类型是int
// 解释一下如何得到int (* parr3[10])[5]中每一个元素的类型:首先int (* parr3[10])[5]中每一个元素应当为int (* parr3)[5],去掉名称parr3,剩下的就是类型,因此类型是int(*)[5];也可以简单直接一点,直接把数组名和方括号一起去了,也就是说把parr3[10]去了,那么剩下的int(*)[5]也就是元素类型

image-20210308135715481

案例1:

// 写出一个指向char* arr[5];的数组指针
char* (*parr)[5] = &arr;

案例2:

// 使用数组指针打印下面的数组
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int (*parr)[10] = &arr;
int i = 0;
for(i = 0; i < 10 ;i++){
    // printf("%d ", (*parr)[i]);
    // 或者:
    printf("%d ", *(*parr + i));
}

一般情况下数组指针都是在多维数组的情况下去使用

案例3:

// 有二维数组:
int arr[3][5] = {{1,2,3,4,5}, {2,3,4,5,6}, {3,4,5,6,7}};
// 有函数用于打印该二维数组:
print1(arr, 3, 5); // arr - 数组名 - 数组名就是数组首元素地址
// 那么问题来了,二维数组首元素是谁?答:不是1,而是{1,2,3,4,5}这个数组(该数组类型是int [5])
// 注意,在说二维数组首元素的时候我们需要先将二维数组想象成一维数组,再去讨论它的首元素
// 现在我们搞清楚传入print1函数的arr是个什么类型了(其实就是一个一维数组的地址),既然是数组的地址,那么我们就应该用数组指针去接它

这个时候我们就可以去写这个print1函数了:

void print1(int (*p)[5], int x, int y){
    int i = 0;
    int j = 0;
    for(i = 0; i < x; i++){
        for(j = 0; j < y; j++){
            // printf("%d ", *(*(p + i) + j)); 
            // 或 printf("%d ", (*(p + i))[j])
            // 或者直接:
            printf("%d", p[i][j]);
        }
        printf("\n");
    }
}
// 为了理解(*(p + i))[j],这里做一下说明
// 假设有数组:
int arr[2] = {1,2};
int* parr = arr;
// 则可以这样打印数组:
for(int i = 0; i < 2; i++){
    printf("%d ", parr[i]);
}

指针数组

int arr1[] = {1, 2, 3, 4, 5};
int arr2[] = {2, 3, 4, 5, 6};
int arr3[] = {3, 4, 5, 6, 7};
int* parr[] = {arr1, arr2, arr3};

image-20210308103503153

数组传参和指针传参

一维数组传参:

void test(int arr[]){} // ok
void test(int arr[10]){} // ok 这个10写跟不写没区别,就算写了写错了也没事,反正它是没用的
void test(int *arr){} // ok
void test2(int *arr[20]){} // ok 这个20写跟不写没区别,就算写了写错了也没事,反正它是没用的
void test2(int **arr){} // ok

int main(){
    int arr[10] = {0};
    int *arr2[20] = {0};
    test(arr);
    test2(arr2);
    return 0;
}

二维数组传参:

void test(int arr[3][5]){} // ok
void test(int arr[][5]){} // ok
void test2(int *arr); // err,二维数组首元素应该是一个一维数组,而整型指针应该是用来存放整型的地址的
void test2(int **arr); // err,二维数组首元素应该是一个一维数组,而二级整型指针应该是用来存放一级整型指针的地址的
void test2(int *arr[5]); // err
void test2(int (*arr)[5]); // ok
int main(){
    int arr[3][5] = {0};
    test(arr);
    test2(arr);
    return 0;
}

函数指针

数组指针是指向数组的指针

那么函数指针其实也就是指向函数的指针 - 存放函数地址的一个指针

int Add(int x, int y){}
int main(){
    // &函数名 和 函数名 都是函数的地址,它俩没有任何区别,一模一样
    printf("%p\n", &Add); // 005310E1
    printf("%p\n", Add); // 005310E1
    // 那么函数指针该怎么写呢??、
    int (*fp)(int, int) = Add; // 解释:首先它是个指针,因此是*fp,其次它需要两个参数,分别是int和int,因此后面跟(int, int),然后函数的返回值是int,因此前面写int
    (*fp)(3,4); // 通过函数指针调用函数
    return 0;
}

判断下面pfun1和pfun2哪个有能力存放函数的地址?

void (*pfun1)();
void *pfun2();
// 首先,能存放存储地址,就要求pfun1或者pfun2是指针,那哪个是指针?答案是:
// pfun1可以存放。pfun1先和*结合,说明pfun1是指针,指针指向的是一个函数,指向的函数无参数,返回值类型为void
// 而第二行代码中pfun2首先跟()结合,因此这行代码仅仅只是一个函数的声明,参数无,返回类型void*,pfun2只是这个函数的名称而已

那么函数指针的类型是什么呢?

// 还是一样,去掉函数名剩下的就是类型
// 比方说:
void test(double x, int y){}
int main(){
    void (*fp)(double, int) = test; // 那么函数指针fp的类型就是void (*)(double, int)
    return 0;
}

通过函数指针调用函数

void test(int x){
    return x + 1;
}
int main(){
    int a = 10;
    void (*pa)(int) = test; 
    // 这里pa、*pa、**pa和***pa完全一样,没有区别,*其实是摆设,加与不加都一样。
    // 那么怎么理解呢?思考一下,直接调用test函数是怎么调用的呢:test(10),我们把这里的test想象成地址,那么pa存的就是test的地址,那首先pa(a)就好理解了,因为pa里面存的就是test的地址,那直接调用就可以了;(*pa)(a)其实也好理解,因为pa存的是test的地址,那么我们解引用拿到test函数再去调用,也就变成了(*pa)(a)
    printf("%d\n", pa(a)); // 11
    printf("%d\n", (*pa)(a)); // 11
    printf("%d\n", (**pa)(a)); // 11 一般不这么写
    printf("%d\n", (***pa)(a)); // 11 一般不这么写
    return 0;
}

案例1:

(*(void (*)())0)(); // 首先中间的一段”void (*)()“它表示函数指针类型,而类型包了个括号放在数字0前面,显然是在强制类型转换,那这里的意思就是把0强制类型转换成:void (*)() 函数指针类型,而0就是一个函数的地址,然后第一个*就要发挥作用了,它的意思是解引用拿到函数指针0所指向的函数,最后我们可以看到代码末尾有一对小括号,那就是在调用这个函数

案例2:

void (*signal(int, void(*)(int)))(int); // 首先我们先思考signal(int, void(*)(int)),它的意思显然是函数声明,首先函数名是signal,然后第一个形参是int型的数,第二个形参是void(*)(int)类型的函数指针
// 然后我们来分析这个函数的返回类型是什么,思考一下,如果是int Add(int, int);这样的函数声明,显然它的返回类型是int,那我们是怎么得到它的返回类型就是int的呢?其实是去掉了Add(int, int),剩下了int,那么这个剩下的int就说明了Add函数的返回类型是int。
// 那么这里的函数也一样,我们首先去掉signal(int, void(*)(int)),剩下了void (*)(int),那就能说明该函数的返回类型了,就是void (*)(int),那为什么不写成一般情况下函数声明的形式,比方说void (*)(int) signal(int, void(*)(int))呢?那是应为这样声明是非法的,如果返回类型是函数指针,那么函数名必须写到void (*)(int)中的*的右边
// 所以总结一下这行代码意思是一个名为signal的函数的声明,该函数需要一个int型的数和一个void(*)(int)型的函数指针作为参数,它的返回类型是void(*)(int)型的函数指针

我们发现上面这种写法及其麻烦而且可读性几乎为0,那么可不可以改进呢?

我们可以使用typedef

// typedef void(*)(int) pfun_t; // ❌
typedef void(*pfun_t)(int); // ✔

// 这个时候我们就可以简化上面的代码语句了
pfun_t signal(int, pfun_t);

回调函数

回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。

那么如何将函数指针作为参数传入到函数中使用呢?

int Add(int, int){}
int Sub(int, int){}
// 将函数指针作为参数传入函数
void Calc(int (*pf)(int, int)){
    printf("%d\n", pf(2, 1));
}
int main(){
    Calc(Add);
    Calc(Sub);
    return 0;
}

案例:

// 编写通用的冒泡排序函数
void swap(char* buf1, char* buf2, int width){
    int i = 0;
    for(i = 0; i < width; i++){
        char tmp = *buf1;
        *buf1 = *buf2;
        *buf2 = temp;
        buf1++;
        buf2++;
    }
}
void bubble_sort(void* base, int sz, int width, int(*cmp)(const void* e1, const void* e2)){
    int i = 0;
    // 趟数
    for(i = 0; i < sz - 1; i++){
        // 每一趟比较的对数
        int j = 0;
        for(j = 0; j < sz - 1 - i; j++){
            if(cmp((char*)base + j * width, (char*)base + (j + 1) * width) > 0){
                // 交换
                swap((char*)base + j * width, (char*)base + (j + 1) * width, width);
            }
        }
    }
}

void*

无类型的指针

可以接收任意类型变量的地址

int a = 0;
char c = 'a';
void* p = &a; // ✔
p = &c; // ✔

案例:

// qsort函数的声明
void qsort(void* base, size_t num, size_t wid, int(*cmp)(const void* e1, const void* e2));

我们可以看到qsort函数第4个参数是一个函数指针,那么怎么来实现这个函数呢?

首先我们要了解下面两点:

void*类型的指针不能进行解引用操作

void* p = &a;
*p; // ❌

void*类型的指针不能进行加减整数的操作

void* p = &a;
p++; // ❌

现在我们回到qsort函数的第4个参数,也就是那个比较函数的实现:

// 如果要排序的数组里面是整型数
int cmp(const void* e1, const void* e2){
    // return *e1 - *e2; // ❌
    return *(int*)e1 - *(int*)e2; // ✔ 这里把e1和e2强制类型转换成int*型的指针
}
// 如果要排序的数组里面是浮点型数
int cmp(const void* e1, const void* e2){
    // return *(float*)e1 - *(float*)e2; // ✔ 这里把e1和e2强制类型转换成float*型的指针
    // 但是上面这种写法可能会有问题,因为函数的返回类型是int,但是两个浮点型的数做加减法得到的也是一个浮点型的数
    // 所以最好还是这么写:
    // if (*(float*)e1 == *(float*)e2) return 0;
    // else if (*(float*)e1 > *(float*)e2) return 1;
    // else return -1;
    
    // 当然也可以这么写:
    return ((int)(*(float*)e1 - *(float*)e2)); // 意思就是将两个浮点型的数做加减运算之后再将结果强制类型转换成int型
}

如果要排序的数组里面是结构体

struct Stu{
    char name[20];
    int age;
};

// 现在有结构体数组:struct Stu s[3] = {{"zhangsan", 20}, {"lisi", 30}, {"wangwu", 10}};
// 那么怎么写比较函数来对数组s按照年龄进行排序呢?如下:
int cmp_stu_by_age(const void* e1, const void* e2){
    return ((struct Stu*)e1)->age - ((struct Stu*)e2)->age;
}
// 那么怎么写比较函数来对数组s按照名字进行排序呢?如下:
int cmp_stu_by_name(const void* e1, const void* e2){
    // 比较名字就是比较字符串
    // 字符串比较不能直接用><=来比较,应该用strcmp函数
    return strcmp(((struct Stu*)e1)->name, ((struct Stu*)e2)->name);
}

函数指针数组

int Add(int x, int y){}
int Sub(int x, int y){}
int Mul(int x, int y){}
int Div(int x, int y){}
int main(){
    int (*pa)(int, int) = Add;
    int (*pa[4])(int, int) = {Add, Sub, Mul, Div}; // 函数指针数组。它所存放的元素的类型是int (*)(int, int)
    int i = 0;
    for(i = 0; i < 4; i++){
        printf("%d\n", pa[i](2,3));
    }
}

如何定义函数指针数组:

int (*par[10])(); // ✔
int (*par[])(); // ✔
int *par[10](); // ❌
int (*)() par[10]; // ❌

案例1:

int Add(int x, int y){}
int Sub(int x, int y){}
int Mul(int x, int y){}
int Div(int x, int y){}
int main(){
    int input = 0;
    // pf 是一个函数指针数组 - 转移表
    int (*pf[])(int, int) = {0, Add, Sub, Mul, Div};
    do{
        printf("请输入0-5之间的整数:>");
        scanf("%d", &input);
        if(input >= 1 && input <= 4){
            int res = pf[input](2, 3);
            printf("%d\n", res);
        }else if(input == 0){
            printf("退出!\n");
        }else{
            printf("错误!\n");
        }
    }while(input);
    return 0;
}

指向函数指针数组的指针

int main(){
    int arr[10] = {0};
    int (*p)[10] = &arr; // 取出数组的地址
    int(*pf)(int, int); // 函数指针
    int (*pfArr[4])(int, int); // pfArr是一个数组 - 函数指针的数组
    int(*(*ppfArr)[4])(int, int); // ppfArr是一个指向[函数指针数组]的指针,指针指向的数组有4个元素,每一个元素的类型是一个函数指针,每一个函数指针的类型是int(*)(int, int)
}

当然我们还可以一层一层的套下去,比方说还有指向函数指针数组的指针的数组等等

指针和数组面试题的解析

1、sizeof(数组名),这里的数组名表示整个数组,计算的是整个数组的大小;

2、&数组名,这里的数组名表示整个数组,取出的是整个数组的地址;

3、除此之外所有的数组名都表示首元素的地址

一维数组

int main(){
    int a[] = {1, 2, 3, 4}; // 4 * 4 = 16
    printf("%d\n", sizeof(a)); // 16 sizeof(数组名) - 计算的是数组总大小 - 单位是字节
    printf("%d\n", sizeof(a + 0)); // 4/8 数组名在这里表示首元素的地址,a+0还是首元素地址,地址的大小就是4/8个字节
    printf("%d\n", sizeof(*a)); // 4 数组名表示首元素的地址,*a就是首元素,sizeof(*a)就是4
    printf("%d\n", sizeof(a + 1)); // 4/8 数组名在这里表示首元素的地址,a+1是第2个元素的地址,地址的大小就是4/8个字节
    printf("%d\n", sizeof(a[1])); // 4 第二个元素的大小
    printf("%d\n", sizeof(&a)); // 4/8 &a取出的是数组的地址,但是数组的地址那也是地址,地址的大小就是4/8个字节
    printf("%d\n", sizeof(*&a)); // 16 &a是数组的地址,数组地址解引用访问数组(相当于*和&抵消了),因此sizeof(*&a)计算的就是sizeof(a)的值
    printf("%d\n", sizeof(&a + 1)); // 4/8 &a是数组的地址,&a+1虽然地址跳过整个数组,但还是地址
    printf("%d\n", sizeof(&a[0])); // 4/8 &a[0]是第一个元素的地址
    printf("%d\n", sizeof(&a[0] + 1)); // 4/8 &a[0]+1 是第二个元素的地址
    return 0;
}
int main(){
    int a[5] = {1,2,3,4,5};
    int* ptr = (int *)(&a + 1);
    printf("%d, %d\n", *(a + 1), *(ptr - 1)); // 2, 5
    return 0;
}

字符数组

int main(){
    char arr[] = {'a', 'b', 'c', 'd', 'e', 'f'};
    printf("%d\n", sizeof(arr)); // 6 sizeof计算的是数组大小
    printf("%d\n", sizeof(arr + 0)); // 4/8 arr是首元素的地址,arr+0还是首元素的地址
    printf("%d\n", sizeof(*arr)); // 1 arr是首元素的地址,*arr就是首元素,首元素是一个字符,而字符大小是一个字节
    printf("%d\n", sizeof(arr[1])); // 1 
    printf("%d\n", sizeof(&arr)); // 4/8 &arr虽然是数组的地址,但还是地址
    printf("%d\n", sizeof(&arr + 1)); // 4/8 &arr+1是跳过整个数组后的地址
    printf("%d\n", sizeof(&arr[0] + 1)); // 4/8 是第二个元素的地址
    return 0;
}
int main(){
    char arr[] = {'a', 'b', 'c', 'd', 'e', 'f'};
    printf("%d\n", strlen(arr)); // arr首元素地址往后找到'\0'才结束,因此是随机值
    printf("%d\n", strlen(arr + 0)); // arr+0 还是arr首元素地址,还是从arr首元素地址往后找到'\0'才结束,因此是随机值
    printf("%d\n", strlen(*arr)); // *arr是解引用arr首元素地址,这里是'a',而strlen接收的参数是地址,因此这里传'a'相当于传了97,而访问地址97的内存是非法的,因此会报错
    printf("%d\n", strlen(arr[1])); // arr[1]是arr第二个元素地址,这里是'b',而strlen接收的参数是地址,因此这里传'b'相当于传了98,而访问地址98的内存是非法的,因此会报错
    printf("%d\n", strlen(&arr)); // &arr是整个arr数组的地址,因此还是从arr首元素地址往后找到'\0'才结束,因此是随机值
    printf("%d\n", strlen(&arr + 1)); // &arr+1是跳过整个arr数组的地址,因此是从跳过整个arr数组的地址的那个地址往后找到'\0'才结束,因此是随机值
    printf("%d\n", strlen(&arr[0] + 1)); // &arr[0]+1是第二个元素的地址,因此是从第二个元素的地址往后找到'\0'才结束,因此是随机值
	return 0;
}
int main(){
    char arr[] = "abcdef";
    printf("%d\n", sizeof(arr)); // 7 sizeof(arr)计算的是数组的大小
    printf("%d\n", sizeof(arr + 0)); // 4/8 计算的是地址的大小 - arr+0 是首元素的地址
    printf("%d\n", sizeof(*arr)); // 1 *arr是首元素'a',sizeof(*arr)计算首元素'a'的大小
    printf("%d\n", sizeof(arr[1])); // 1 arr[1]是第二个元素,sizeof(arr[1])计算的是第二个元素的大小
    printf("%d\n", sizeof(&arr)); // 4/8 &arr虽然是数组的地址,但也是地址
    printf("%d\n", sizeof(&arr + 1)); // 4/8 &arr+1是跳过整个数组后的地址,但也是地址
    printf("%d\n", sizeof(&arr[0] + 1)); // 4/8 &arr[0]+1是第二个元素的地址
    return 0;
}
int main(){
    char arr[] = "abcdef";
    printf("%d\n", strlen(arr)); // 6
    printf("%d\n", strlen(arr + 0)); // 6
    printf("%d\n", strlen(*arr)); // err
    printf("%d\n", strlen(arr[1])); // err
    printf("%d\n", strlen(&arr)); // 6 &arr是数组的地址,需要用数组指针去接:char(*p)[7] = &arr;但是strlen接收的参数实际上是一个字符指针,因此这里会报警告,但是并不影响使用,因为它会把传进去的数组指针当成字符指针去使用
    printf("%d\n", strlen(&arr + 1)); // 随机值 &arr是整个数组的地址,&arr+1还是整个数组的地址,需要用数组指针去接:char(*p)[] = &arr+1;但是strlen接收的参数实际上是一个字符指针,因此这里会报警告,但是并不影响使用,因为它会把传进去的数组指针当成字符指针去使用
    printf("%d\n", strlen(&arr[0] + 1)); // 5
    return 0;
}

字符指针

int main(){
    char* p = "abcdef";
    printf("%d\n", sizeof(p)); // 4/8 计算指针变量p的大小
    printf("%d\n", sizeof(p + 1)); // 4/8 p+1得到的是字符'b'的地址
    printf("%d\n", sizeof(*p)); // 1 *p就是字符串的第一个字符'a'的大小
    printf("%d\n", sizeof(p[0])); // 1 int arr[10]; arr[0] == *(arr+0)  因此p[0] == *(p+0) == 'a'
    printf("%d\n", sizeof(&p)); // 4/8 &p取的是指针p的地址,所以计算的是地址的大小
    printf("%d\n", sizeof(&p + 1)); // 4/8 &p+1意思是先取出指针p的地址,再让它往后走一步,所以计算的仍旧是地址的大小
    printf("%d\n", sizeof(&p[0] + 1)); // 4/8 计算'b'的地址的大小
    return 0;
}
int main(){
    char* p = "abcdef";
    printf("%d\n", strlen(p)); // 6
    printf("%d\n", strlen(p + 1)); // 5
    printf("%d\n", strlen(*p)); // err
    printf("%d\n", strlen(p[0])); // err
    printf("%d\n", strlen(&p)); // 随机值 解释:&p指取出p的地址,那么p的地址可能是0x00131214,这个时候如果是小端存储那应该是这么存:14 12 13 00,如果使用strlen显然这里会得到结果为3,因为最后一个字符是00;而如果p的地址是0x12131415,按照小端存储则是:15 14 13 12,此时使用strlen就不知道会得到什么结果了,因为不知道什么时候遇到00
    printf("%d\n", strlen(&p + 1)); // 随机值 解释:先取出p的地址,再让它往后走一步,这个时候后面什么时候遇到00仍旧是不知道的
    printf("%d\n", strlen(&p[0] + 1)); // 5
    return 0;
}

二维数组

int main(){
    int a[3][4] = {0};
    printf("%d\n", sizeof(a)); // 48
    printf("%d\n", sizeof(a[0][0])); // 4
    printf("%d\n", sizeof(a[0])); // 16 a[0]相当于第一行作为一维数组的数组名,sizeof(arr[0])把数组名单独放在sizeof()内,计算的是第一行的大小
    printf("%d\n", sizeof(a[0] + 1)); // 4/8 a[0]是第一行的数组名,数组名此时是首元素的地址,a[0]其实就是第一行第一个元素的地址,所以a[0]+1就是第一行第二个元素的地址  
    printf("%d\n", sizeof(*(a[0] + 1))); // 4 *(a[0] + 1)是第一行第二个元素
    printf("%d\n", sizeof(a + 1)); // 4 a是二维数组的数组名,没有sizeof(a),也没有&(a),所以a是首元素地址,而把二维数组看成一维数组时,二维数组的首元素是他的第一行,a就是第一行(首元素)的地址,那么a+1就是第二行的地址
    printf("%d\n", sizeof(*(a + 1))); // 16 sizeof(a[1]) == sizeof(*(a+1))计算的是第二行的大小
    printf("%d\n", sizeof(&a[0] + 1)); // 4/8 第二行的地址
    printf("%d\n", sizeof(*(&a[0] + 1))); // 16 计算第二行的大小
    printf("%d\n", sizeof(*a)); // 16 a是首元素地址(第一行地址),那么*a就是第一行,sizeof(*a)就是计算第一行的大小
    printf("%d\n", sizeof(a[3])); // 16 虽然a[3]越界了,但是放入sizeof()中的表达式是不会真实运算的,sizeof只是根据类型来判断传入的东西占多少字节大小的内存空间而已,a[3]是一个4个整型的一维数组,所以大小是16
    return 0;
}

指针加减

struct Test{
    int Num;
    char* pcName;
    short sDate;
    char cha[2];
    short sBa[4];
}* p; // 这里的p其实就是一个struct Test*类型的全局的指针
// 假设p的值为0x100000,如下表达式的值分别是多少?
// 已知,结构体Test类型的变量大小是20个字节
int main(){
    p = (struct Test*)0x100000;
    printf("%p\n", p + 0x1); // 0x100014 结构体Test类型的变量大小是20个字节
    printf("%p\n", (unsigned long)p + 0x1); // 0x100001 转化成整型了,正常计算即可
    printf("%p\n", (unsigned int*)p + 0x1); // 0x100004 int型指针长度为4个字节
    return 0;
}
int main(){
    int a[4] = {1,2,3,4};
    int *ptr1 = (int*)(&a + 1);
    int *ptr2 = (int*)((int)a + 1);
    printf("%x,%x\n", ptr1[-1], *ptr2); // 0x4,0x02 00 00 00
    // 分析:首先ptr1应该比较好理解,&a+1之后强转成int*,指针指向数组a末尾的后面那个位置,然后ptr1[-1] == *(ptr1 - 1) 因此ptr1[-1]为数组a的最后一个元素,也就是4;然后来看ptr2,首先解释(int*)((int)a + 1); 先将a转成int型,此时a+1就是正常的加1,之后再将a+1的值转成int*,其实这就相当于指针往后走了一个字节(指针的最小步长单位是1字节),而数组a又是小端存储的,也就是说在内存中它是这么存的:01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00,那么原先ptr2解引用之后相当于拿出01 00 00 00这一段,而现在ptr2向后走了一个字节,此时再解引用就相当于拿出 00 00 00 02 这一段,由于是小端存储,所以其实就是这么一个十六进制数:02 00 00 00 。所以最后输出:0x4,0x02000000
    return 0;
}
int main(){
    int a[3][2] = {(0, 1), (2, 3), (4, 5)};
    int *p;
    p = a[0];
    printf("%d", p[0]); // 1 请看下图
    return 0;
}

image-20210322110952916

int main(){
    int a[5][5];
    int (*p)[4];
    p = a;
    printf("%p,%d\n", &p[4][2] - &a[4][2], &p[4][2] - &a[4][2]); // 0xFF FF FF FC,-4 请看下图
    // 解释:p = a的时候首先p的类型是int(*)[4],a是数组首元素也就是第一行,因此它的类型是int(*)[5],然后我们来看a[4][2]和p[4][2],a[4][2]其实很好理解,就是找到第五行的第三个元素,而p[4][2] == *(*(p + 4) + 2),也就是说以4个为一行,找到第5行的第3个元素,其实就是a数组第4行第4个元素。此时以%d打印&p[4][2] - &a[4][2]就不难了,答案是-4,以%p打印的时候首先%p打印的是地址,地址没有负数,因此被打印的数就要当成一个无符号数,-4的补码不难得到,由于是无符号数所以打印的时候补码直接被当成源码打印出来,也就得到了0xfffffffc的结果
    return 0;
}

image-20210322111830900

int main(){
    int aa[2][5] = {1,2,3,4,5,6,7,8,9,10};
    int* ptr1 = (int*)(&aa + 1); // 跳过整个数组,指向数组末尾元素的后面那个元素
    int* ptr2 = (int*)(*(aa + 1)); // *(aa + 1)马上想到aa[1],就是说aa是首元素地址,也就是第一行地址,+1之后就是第二行地址,因此解引用之后其实就是第二行,那么ptr2其实指向的就是第二行的首元素
    printf("%d,%d\n", *(ptr1 - 1), *(ptr2 - 1)); // 由于ptr1、ptr2都被转成int*(其实ptr2都不用转,它自己就是int*类型,因为数组名表示首元素地址),所以输出为10,5
    return 0;
}
int main(){
    char* a[] = {"work", "at", "alibaba"};
    char** pa = a;
    pa++;
    printf("%s\n", *pa); // at
    return 0;
}
int main(){
    char* c[] = {"ENTER", "NEW", "POINT", "FIRST"};
    char** cp[] = {c+3, c+2, c+1, c};
    char*** cpp = cp;
    printf("%s\n", **++cpp); // POINT
    printf("%s\n", *--*++cpp + 3); // ER
    printf("%s\n", *cpp[-2] + 3); // ST
    printf("%s\n", cpp[-1][-1] + 1); // EW
    
    return 0;
}
// 内存分布图见下图

image-20210324145911904

自定义类型:结构体,枚举,联合

结构体

匿名结构体类型(省略结构体标签tag)

struct
{
    int a;
    char b;
    float c;
}x;
struct
{
    int a;
    char b;
    float c;
} *p1;
struct
{
    int a;
    char b;
    float c;
}a[10], *p2;

int main(){
    p1 = &x; // 非法的,会报警告 因为虽然上面第一个结构体和第二个结构体成员变量都一样,看似是同一个类型,但是编译器会将它们视为两个不同的类型。而不同的类型是做不了这个操作的(p1 = &x)
    return 0;
}

结构的自引用

代码1:

struct Node{
    int data;
    struct Node next;
};
// 可行吗? 答案:不行! 试想一下,如果可以的话sizeof(struct Node)将会是无穷大

代码2:

struct Node{
    int data; 
    struct Node* next; //不存struct Node本身,而是存它的地址
}
// 可行吗?可以!

image-20210429165905832

对于这种结构体,它里面是分成一个数据域一个指针域

结构体的重命名

代码1:

// 下面这种写法是错误的
typedef struct{ // 结构体标签省略了,说明这里是想重命名匿名结构体
    int data;
    Node* next; // 这里的Node到底是先重命名结构体为Node再使用Node这个名称然后创建结构体呢还是先创建这个结构体,再重命名结构体为Node,再使用Node这个名称呢?是会有歧义的,所以这样写不行而且这样写编译器会报错!
}Node;

代码2:

// 所以建议就是在使用typedef的时候就不要省略结构体标签了
typedef struct Node{
    int data;
    struct Node* next; // 这里的struct也不要省略
}Node;

结构体变量的定义和初始化

不想初始化:

struct Node n1;

不想初始化但又不想里面的东西为空:

struct Node n1 = {0};

全局初始化:

struct Point{
    int a;
    int b;
};
struct Point p1 = {0}; // 全局初始化
struct Node{
    int data;
    struct Point p;
    struct Node* next;
}n1 = {20, {5, 6}, NULL}; // 全局初始化

int main(){
    return 0;
}

结构体内存对齐★

首先为什么会存在内存对齐呢?

  • 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常
  • 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问(假设是32位机器,那么一次能处理4个字节,此时有一个int型数据在内存块3-6的位置,那么这个时候需要先读一次(1-4),然后再读一次(5-8),才能将这个int型数据完整地读出来);而对齐的内存访问只需要一次访问

总的来说:

结构体的内存对齐是拿空间来换取时间的做法

结构体的对齐规则:

  • 第一个成员在与结构体变量偏移量为0的地址处(这里偏移量的单位其实就是字节,因为内存中是一个字节赋一个地址的)

  • 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处

    对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值

    VS中默认是8

    gcc没有默认对齐数

  • 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍

  • 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍

  • 结构体成员变量中如果有数组,那么数组的对齐数是按照数组中每个元素的类型来计算的

    struct CA{
        int a; // 对齐数为4
        char cs[16]; // 对齐数为1
        short s; // 对齐数为2
    }; // 计算得到:4 + 16 * 1 + 2 = 22,又因为最大对齐数是4,所以结构体CA所占的大小为:24
    

示例1:

struct S1{
    char c1; // 第一个成员变量直接放在该结构体变量在内存中开始定义的那个地址的偏移量为0的地址处
    // 后面的变量需要看对齐数
    int a; // 对齐数VS中默认为8,而a是int型,大小为4,两者取较小值为4,因此a放到该结构体变量在内存中开始定义的那个地址的偏移量为4的倍数的地址处(一看发现只需要偏移量为4而不需要偏移量为8、12、...)。此时跟上面的c1变量是隔了三个字节的内存空间(被浪费掉了)
    char c2; // 对齐数VS中默认为8,而c2是char型,大小为1,两者取较小值为1,因此c2放到该结构体变量在内存中开始定义的那个地址的偏移量为1的倍数的地址处,一看发现只需要放到变量a后面即可
}; // 最后结构体总大小为最大对齐数的整数倍(由于最大对齐数是4,因此只能取4、8、12、...)又因为上面这些成员变量消耗掉的内存空间大小为:4 + 4 + 1 = 9,所以只能取12(意味着最后三个字节也是被浪费的)

image-20210429180051230

示例2:

struct S1{
    char c1; // 第一个变量放到相对起始地址偏移量为0的地址处
    char c2; // 第二个变量对齐数是1,放到起始地址开始偏移量为1的倍数的地址处
    int a; // 第三个变量对齐数是4,放到起始地址开始偏移量为4的倍数的地址处
} // 最后由于上述变量耗费空间:1 + 1 + 2(浪费掉的空间) + 4 = 8,而8已经是4的倍数了,因此总大小就是8

image-20210429184702296

示例3:

struct S2{
    double d;
    char c;
    int i;
}; // 首先先算得结构体S2的大小为16
struct S3{
    char c; // 第一个变量放到相对起始地址偏移量为0的地址处
    struct S2 s2; // 嵌套的结构体对齐到自己的最大对齐数的整数倍处,所以先看结构体S2的最大对齐数,显然是8,那么变量s2的对齐数就是8和VS默认的8之间取最小值(如果是gcc的话由于gcc没有默认对齐数,所以直接就取8就行),其实就是8,那么它就应该放到相对起始地址偏移量为8的倍数的地址处,且占用16个字节
    double d; // 放到相对起始地址偏移量为8的倍数的地址处
}; // 上述变量占用空间:1 + 7(浪费掉的) + 16 + 8 = 32,再看上述变量最大对齐数是8,而32已经是8的倍数了,因此结构体S3的大小就是32

那么在设计结构体的时候,我们既要满足对齐,又要节省空间,怎么做呢?

让占用空间小的成员尽量集中在一起

使用#pragma这个预处理指令可以改变默认对齐数

#pragma pack(4) // 设置默认对齐数为4
struct S1{
    char c;
    double i;
};
#pragma pack() // 取消设置的默认对齐数,还原为默认
int main(){
    printf("%d\n", sizeof(struct S1)); // 12
    return 0;
}

结论:

结构在对齐方式不合适的时候,我们可以自己更改默认对齐数

使用offsetof()宏计算结构体成员相对于结构体起始位置的偏移量

#include<stddef.h>
struct S{
    char c;
    int i;
    double d;
};
int main(){
    printf("%d\n", offsetof(struct S, c)); // 0
    printf("%d\n", offsetof(struct S, i)); // 4
    printf("%d\n", offsetof(struct S, d)); // 8
    return 0;
}

手写offsetof()宏:

// 基本思想:由于在计算结构体成员偏移量的时候结构体可能未被实际创建,那么结构体成员变量也是未被实际创建的,所以没办法通过成员变量地址-结构体起始地址的方式来计算偏移量。
// 那么就应该换个思路,试想如果结构体起始地址是0,那么成员变量地址-0其实就是成员变量的偏移量了,也就是说如果结构体起始地址是0,成员变量的地址就是成员变量的偏移量
#define OFFSETOF(struct_name, member_name) (int)&(((struct_name*)0)->member_name)
struct S{
    char c1;
    int a;
    char c2;
};
int main(){
    printf("%d\n", OFFSETOF(struct S, c1));
    printf("%d\n", OFFSETOF(struct S, a));
    printf("%d\n", OFFSETOF(struct S, c2));
    return 0;
}

结构体传参

最好是传址而不是传值,因为函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销,而如果传递一个结构体对象的时候,结构体过大,参数压栈的系统开销比较大,会导致性能的下降。如果是传址的话,一个地址的大小最多也就4/8字节

那么问题来了,传址的话权限会比传值大,因为传址可以直接修改原结构体中的数据,而有时有我们不希望原结构体中数据可以被修改,而只是希望原结构体中的数据能被读,这个时候可以加const关键字做限定:

void Print(const struct S* ps);

位段

位段在底层开发,尤其是网络开发中很常用

结构体有实现位段的能力

什么是位段?

位段的声明和结构体是类似的,有两个不同:

  • 位段的成员必须是int、unsigned int 、signed int、short、char(一般情况下都是相同的类型:如果是int就全是int,如果是char就全是char。当然也会有混用的,但是最好不要混用)

  • 位段的成员名后边有一个冒号和一个数字

    // 位段 - 这个‘位’说的是二进制位
    struct A{
        int _a:2; // 这个2表示2个bit,意思是这个_a变量只需要2bit就行,没必要给4个字节的大小
        int _b:5; // 同理,5表示5个bit
        int _c:10; // 10表示10个bit
        int _d:30; // 30表示30个bit
    } // 2+5+10+30=47,总的算下来需要47bit,也就是6个字节(6*8=48)
    // 上面的A就是一个位段类型
    int main(){
        struct A a;
        printf("%d\n", sizeof(a)); // 8
        return 0;
    }
    
位段的内存分配
  • 位段的成员可以是int、unsigned int、signed int、char(属于整形家族)类型
  • 位段的空间上是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的
  • 位段涉及很多不确定因素,位段是不垮平台的,注意可移植的程序应该避免使用位段

解释一下为什么上面那个结构体A会占用8个字节:

首先结构体里面是整型成员变量,那么它一次就会给开辟一个整型变量空间(也就是4字节),然后往里面放成员变量,首先是_a(2bit),然后是_b(5bit),然后是_c(10bit),此时耗掉了17bit(2个字节多一点),然后遇到_d的时候发现这个整型变量空间不够用了(因为还剩15bit,但是_d变量需要30bit),此时会丢弃剩余的15bit,重新再开辟一个整型变量空间去存放_c变量,存完之后把剩余的2bit丢弃掉

所以总共消耗了两个整型变量空间,合计8个字节

image-20210429234659263

原先4 * 4 = 16字节的空间现在被压缩到8字节,可见位段就是为了节省空间,提高空间利用率而存在的

使用char的位段的示例:

struct S{
    char a : 3;
    char b : 4;
    char c : 5;
    char d : 4;
};
int main(){
    struct S s; // 首先开辟内存空间,由于位段的成员变量是char型,所以一次分配一个char型变量的空间,分配完a、b之后该char型变量空间还剩1bit,而c变量需要5bit,所以剩余的1bit被抛弃掉,重新再分配一个char型变量的空间去装c,装完c之后还剩3bit显然装不下d了,抛弃掉,重新再分配一个char型变量的空间去装d,最后剩余的4bit抛弃掉
    // 在装数据之前,先明确一点:申请一个char型变量空间(8bit),那么数据是从右往左放还是从左往右放呢?答案是在该编译器下是从右往左(上面也说了,位段涉及很多不确定因素,所以不跨平台,其实就体现在这里,不同编译器数据从右往左还是从左往右放是没有一个明确的标准的(C语言标准中根本没有定义这个标准),所以不同的编译器之间有可能是不一样的)
    s.a = 10; // a变量申请的空间是3bit,而10的二进制是:1010,截取010
    // a变量放入第一个char型变量的空间中:00000010
    s.b = 20; // b变量申请的空间是4bit,而20的二进制是:10100,截取0100
    // b变量放入第一个char型变量的空间中:00100010
    s.c = 3; // c申请的空间是5bit,而3的二进制是:101,所以前面补全变:00101
    // c放入第二个char型变量的空间中:00000101
    s.d = 4; // d申请的空间是4bit,而4的二进制是:100,所以前面补全变:0100
    // d放入第三个char型变量的空间中:00000100
    return 0;
}
// 所以该位段装入数据之后内存中存的就是:0010 0010 0000 0101 0000 0100(换成16进制也就是:2 2 0 3 0 4)

示例2:

#define MAX_SIZE A+B
struct S{
    unsigned char a : 3;
    unsigned char b : 4;
    unsigned char c;
    unsigned char d : 1;
}; // 显然用了3个字节
int main(){
    int A = 2;
    int B = 3;
    // sizeof(struct S) * MAX_SIZE -> sizeof(struct S) * A+B ->sizeof(struct S) * 2+3->3 * 2+3
    printf("%d\n", sizeof(struct S) * MAX_SIZE); // 9
    return 0;
}

示例3:

int main(){
    unsigned char puc[4];
    struct tagPIM{
        unsigned char ucPim1;
        unsigned char ucData0 : 1;
        unsigned char ucData1 : 2;
        unsigned char ucData2 : 3;
    }*pstPimData;
    pstPimData = (struct tagPIM*)puc;
    memset(puc, 0, 4); // 此时pstPimData指向内存:00000000 00000000 00000000 00000000 
    pstPimData->ucPim1 = 2; // 由于ucPim1占用一个字节,所以2直接从右往左放到第一个字节。此时pstPimData指向内存:00000010 00000000 00000000 00000000
    pstPimData->ucData0 = 3; // 由于ucData0占用1个bit,而3的二进制:0011,所以截1个bit,从右往左放到第二个字节。此时pstPimData指向内存:00000010 00000001 00000000 00000000
    pstPimData->ucData1 = 4; // 由于ucData1占用2个bit,而4的二进制:0100,所以截2个bit,从右往左放到第二个字节(为啥还是放第二个字节上面位段的内存分配机制已经解释很清楚了这里就不解释了)。此时pstPimData指向内存:00000010 00000001 00000000 00000000
    pstPimData->ucData2 = 5; // 由于ucData2占用3个bit,而5的二进制:0101,所以截3个bit,从右往左放到第二个字节(为啥还是放第二个字节上面位段的内存分配机制已经解释很清楚了这里就不解释了)。此时pstPimData指向内存:00000010 00101001 00000000 00000000
    printf("%02x %02x %02x %02x\n", puc[0], puc[1], puc[2], puc[3]); // 02 29 00 00 
    // %02x的意思是以16进制打印,并且只截取2位打印
    return 0;
}
位段的跨平台问题
  • int位段被当成有符号数还是无符号数是不确定的
  • 位段中最大位的数目不能确定(16位机器最大16,32位机器最大32(比方说在16位机器中写成27那必然会出问题,但是在32位机器下写27一点问题没有))
  • 位段中的成员在内存中从左向右分配还是从右向左分配标准尚未定义
  • 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的

总结:

跟结构相比,位段可以达到同样的效果,但是可以很好的节省空间,但是有跨平台的问题存在

位段的应用

image-20210502110309741

在网络传输的时候,除了要传输的数据本身,还会携带很多附加数据,比如版本号、首部长度等,而这些数据都是很整齐的4字节、8字节、16字节等,这个时候就可以用到位段来节省空间了,比方说上图的4位版本号、4位首部长度、8位服务类型、16位总长度,加起来刚好32位,这个时候如果用位段就正好可以用一个int型来存储。可以想象这样的数据包用位段来封装是非常合适的。

枚举

示例1:

// 枚举类型
enum Sex{
    // 枚举可能取值
    MALE, // 0
    FEMALE, // 1
    SECRET // 2
};
int main()[
    enum Sex s = FEMALE;
    printf("%d\n", s); // 1
    printf("%d %d %d\n", MALE, FEMALE, SECRET); // 0 1 2
    return 0;
]

示例2:

enum Sex{
    MALE = 2,
    FEMALE = 4,
    SECRET = 8
};
int main(){
    enum Sex s = FEMALE;
    printf("%d\n", s); // 4
    printf("%d %d %d\n", MALE, FEMALE, SECRET); // 2 4 8
    return 0;
}

示例3:

enum Sex{
    MALE = 2,
    FEMALE,
    SECRET = 8
};
int main(){
    enum Sex s = FEMALE;
    printf("%d\n", s); // 3
    printf("%d %d %d\n", MALE, FEMALE, SECRET); // 2 3 8
    return 0;
}

示例4:

enum Sex{
    MALE = 2,
    FEMALE,
    SECRET
};
int main(){
    enum Sex s = FEMALE;
    printf("%d %d\n", s); // 3
    printf("%d %d %d\n", MALE, FEMALE, SECRET); // 2 3 4
    return 0;
}

示例5:

enum Sex{
    MALE,
    FEMALE = 9,
    SECRET
};
int main(){
    enum Sex s = FEMALE;
    printf("%d %d\n", s); // 9
    printf("%d %d %d\n", MALE, FEMALE, SECRET); // 0 9 10
    return 0;
}

错误写法:

enum Sex{
    MALE,
    FEMALE,
    SECRET
};
int main(){
    enum Sex s = 2; // 右边是int型,左边是enum Sex型,两个东西类型都不一样,直接赋值是会有问题的
    return 0; 
}

枚举的优点

  • 增加代码的可读性和可维护性

  • 和#define定义的标识符比较枚举有类型检查,更加严谨

  • 防止了命名污染(封装)

  • 便于调试

    c语言的源代码需要经过若干过程才能变成可执行程序:

    C语言的源代码-->预编译(把涉及到MALE、FEMALE等的全部替换成0、1等,把注释全部去掉等)--->编译--->链接--->可执行程序
    

    如果使用#define定义常量,那么程序到了可调式阶段(可执行阶段)会将类似MALE、FEMALE这样的常量全部替换成0、1,我们眼睛看到的是MALE,但实际上它是0,这可能导致看到的和真正在执行的是两套代码逻辑,就有可能会导致有些代码错误不容易被及时发现;但是枚举不一样,枚举在程序可调式阶段(可执行阶段)也不会被完全替换,看到的是什么执行的就是什么,看到的是FEMALE执行的就是FEMALE,这样显然更便于发现错误(至少保证了看到的和真正执行的是同一套代码逻辑),也更便于调试

  • 使用方便,一次可以定义多个常量

枚举所占空间的大小

enum Sex{
    MALE,
    FEMALE,
    SECRET
};
int main(){
    enum Sex s = MALE;
    printf("%d\n", sizeof(s)); // 4 因为MALE、FEMALE这些枚举的值其实就是0、1,是整型,所以枚举的大小是4字节
    return 0;
}

联合

联合又名联合体又名共用体

联合是一种特殊的自定义类型,这种类型定义的变量也包含一系列的成员,特征是这些成员共用同一块空间(所以联合也叫共用体)

union Un{
    char c;
    int i;
};
int main(){
    union Un u;
    printf("%d\n", sizeof(u)); // 4
    printf("%p\n", &u); // 00F1F90C
    printf("%p\n", &(u.c)); // 00F1F90C
    printf("%p\n", &(u.i)); // 00F1F90C
    return 0;
}

联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)

使用联合来判断大小端:

int check_sys(){
    // union Un{
    //   char c;
    //   int i;
    // }u;
    // 或者可以写成匿名联合:
    union{
        char c;
        int i;
    }u;
    u.i = 1; // 先把成员变量i赋值为1,那么共用的那块空间就被赋值成了1,此时再看成员变量c的值就相当于截取了那块空间的第一个字符的值,巧妙的完成了大小端的判断
    // 返回1,表示小端
    // 返回0,表示大端
    return u.c;
}

联合大小的计算

  • 联合的大小至少是最大成员的大小
  • 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍

示例:

union Un{
    int a;
    char arr[5];
};// 首先a是4字节,arr是5字节,所以明确一点,结构体Un大小一定大于等于5字节;然后来看最大对齐数,首先a是4字节,vs下的默认对齐数是8,4和8比取较小值4,因此a的对齐数就是4,然后看arr,arr是一个数组,数组的对齐数是根据数组元素的类型来计算的,这里arr中的元素的类型是char,因此arr的对齐数是1,所以我们可以得到联合体Un最大对齐数是4。现在我们已经找到了对于联合Un的大小的两个限定条件:1、是4的倍数;2、大于等于5。因此可以得出联合Un的大小为8

示例2:

int main(){
    union{
        short k;
        char i[2];
    }*s, a;
    s = &a;
    s->i[0] = 0x39;
    s->i[1] = 0x38;
    printf("%x\n", a.k); // 由于测试机器是小端,因此低位放低地址,高位放高地址,显然i[1](0x38)是低位,i[0](0x39)是高位,因此0x38放在低地址,0x39放在高地址,因此打印出来是3839
    return 0;
}

动态内存分配

动态内存分配分配的就是堆里面的空间

有一种场景,试想如果我们使用定长数组来存放班级学生,则必须指定数组的长度,如:

struct Student stus[50];

那么问题来了,如果学生只有30个,那将浪费20个学生的空间

那么有没有变长数组的写法呢,比方说像这样:

int stu_num = 0;
printf("%s", "请输入学生人数:>");
scanf("%s", &stu_num);
struct Student stus[stu_num];

上述这种写法C99标准已经添加了,我们可以用gcc做个实验,编写好变长数组程序test.c,然后用gcc编译:

gcc test.c -std=c99

但是,变长数组有个问题:不是所有编译器都支持,还有相当一部分编译器在指定数组长度的时候需要写一个常量进去

这就是为什么需要动态内存分配

malloc和free

void* malloc(size_t size);

malloc函数向内存申请一块连续可用的空间,并返回指向这块空间的指针

  • 如果开辟成功,则返回一个指向开辟好空间的指针
  • 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查
  • 返回值的类型是void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定
  • 如果参数size为0,malloc的行为是标准未定义的,取决于编译器

void* free(void* memblock);

free函数用来释放动态开辟的内存

  • 如果参数ptr指向的空间不是动态开辟的,那free函数的行为是未定义的
  • 如果参数ptr是NULL指针,则函数什么事情都不做

示例:

#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main(){
    // 向内存申请10个整型的空间,并返回该内存空间的起始地址
    int* p = (int*)malloc(10 * sizeof(int));
    if(p == NULL){
        // 内存空间不够,开辟空间失败
        printf("%s\n", strerror(errno));
    }else{
        // 开辟内存成功
        // 注意此时开辟的内存里面放的都是一些随机的东西
    }
    // 当动态申请的空间不再使用的时候,就应该还给操作系统
    free(p); // 其实就算不free,程序生命周期结束的时候也会把申请的空间还回去,但是如果不free,这块内存空间就会一直被占用
    p = NULL; // free之后指针p指向的内存空间确实还回去了,但是指针p的值没改变,我们依然可以通过指针p找到那一块内存空间,找到它就有可能破坏它,那么这个时候指针p还是非常危险的,所以这个时候还需要再把指针p置为NULL
    return 0;
}

calloc

void* calloc(size_t num, size_t size);

  • 函数的功能是为num个大小为size的元素开辟一块空间,并且把空间的每个字节初始化为0
  • 与函数malloc的区别只在于calloc会在返回地址之前把申请的空间的每个字节初始化为全0

示例:

#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main(){
    // 向内存申请10个整型的空间,并返回该内存空间的起始地址
    // int* p = (int*)malloc(10 * sizeof(int));
    int* p = (int*)calloc(10, sizeof(int));
    if(p == NULL){
        // 内存空间不够,开辟空间失败
        printf("%s\n", strerror(errno));
    }else{
        // 开辟内存成功
        // 注意此时开辟的内存里面放的东西已经全部初始化为0
    }
    // 当动态申请的空间不再使用的时候,就应该还给操作系统
    free(p); // 其实就算不free,程序生命周期结束的时候也会把申请的空间还回去,但是如果不free,这块内存空间就会一直被占用
    p = NULL; // free之后指针p指向的内存空间确实还回去了,但是指针p的值没改变,我们依然可以通过指针p找到那一块内存空间,找到它就有可能破坏它,那么这个时候指针p还是非常危险的,所以这个时候还需要再把指针p置为NULL
    return 0;
}

realloc

void* realloc(void* memblock, size_t size);

用于调整动态开辟内存空间的大小

示例1:

int main(){
    int* p = (int*)malloc(20);
    if(p == NULL){
        printf("%s\n", strerror(errno));
    }else{
        int i = 0;
        for(i = 0; i < 5; i++){
            *(p + i) = i; 
        }
    }
    // 假设在使用malloc开辟的20个字节空间的时候发现20个字节不能满足我们的使用了
    // 希望能有40个字节的空间
    // 这里就可以使用realloc来调整动态开辟的内存
    // 
    // realloc使用的注意事项:
    // 1.如果p指向的空间之后有足够的内存空间可以追加,则直接追加,后返回p
    // 2.如果p指向的空间之后没有足够的内存空间可以追加,则realloc函数会重新找一个新的内存区域
    //  开辟一块满足需求的空间,并且把原来内存中的数据拷贝回来,释放旧的内存空间,最后返回新开辟的内存空间地址
    // 3.如果我们这么写:p = realloc(p, INT_MAX);显然realloc会失败,返回NULL,此时就把p赋值成了NULL,导致在内存扩容失败的同时连原空间都找不到了,这是非常不好的。所以得用一个新的变量来接收realloc函数的返回值,如下:
    int* ptr = realloc(p, 40);
    if(ptr != NULL){
        // 追加成功,就把ptr赋值给p
        p = ptr;
        int i = 0;
        for(i = 0; i < 10; i++){
            printf("%d ", *(p + i));
        } // 打印结果:0 1 2 3 4 -842150451 -842150451 -842150451 -842150451 -842150451
    }
    // 最后还是得释放内存
    free(p);
    p = NULL;
    return 0;
}

示例2:

...
int* p = (int*)malloc(20);
...
int* ptr = realloc(p, 40);
...

上述代码有个问题,本来由p指针来维护开辟的内存空间,使用了realloc之后换成ptr指针了,那么能不能还是用p指针来维护呢:

...
int* p = (int*)malloc(20);
...
p = realloc(p, 40);
...

**注意!**像上面这样的代码是有很大风险的!因为指针p可能会发生变化!

原因是:

首先malloc去堆申请空间的时候是哪里闲置就去哪里申请的(这一块肯定是有它自己内部的机制的),因此申请的空间不一定是紧挨着上一块被申请的内存空间的

试想一下如果只需要在当前申请的内存空间的基础上再扩容一点点:

image-20210503132037020

这个时候realloc就会返回p的地址

但是!当我们需要追加一块非常大的空间的时候,如果还是在p的空间后面追加,就有可能把后面有用的一些内存空间非法覆盖掉,这个时候问题就大了。那么这个时候realloc是这么做的:重新开辟一块指定大小的空间,将原空间的数据挪过来(挪完之后free掉原空间),并返回新开辟的空间的首地址,营造一种原空间被扩容的感觉:

image-20210503125459934

这个时候realloc返回的就不是原先p的地址了

使用realloc模拟malloc

int *p = (int*)realloc(NULL, 40); // 等价于malloc(40)

常见的动态内存错误

1、对NULL指针的解引用操作

int main(){
    int* p = (int*)malloc(40);
    // 万一malloc失败了,p就被赋值为NULL
    *p = 0; // err
    int i = 0;
    for(i = 0; i < 10; i++){
        *(p + i) = i; // err
    }
    free(p);
    p = NULL;
    return 0;
}

2、对动态开辟的内存的越界访问

int main(){
    int* p = (int*)malloc(5*sizeof(int));
    if(p == NULL){
        return 0;
    }else{
        int i = 0;
        for(i = 0; i < 10; i++){
            *(p + i) = i; // err
        }
    }
    free(p);
    p = NULL;
    return 0;
}

3、对非动态开辟内存使用free释放

int main(){
    int a = 10; // a是在栈上的,并不是动态开辟的内存
    int* p = &a;
    *p = 20;
    free(p); // err
    p = NULL;
    return 0;
}

4、使用free释放动态开辟内存的一部分

int main(){
    int* p = (int*)malloc(40);
    if(p == NULL){
        return 0;
    }
    int i = 0;
    for(i = 0; i < 10; i++){
        *p++ = i; // 这里指针p发生了变化
    }
    // 使用free释放动态开辟内存的一部分
    free(p);
    p = NULL;
    return 0;
} // 这段程序将会崩溃。如果要释放内存,必须从开辟的内存的起始位置开始释放,如果只释放了动态开辟内存的一部分(或者说上面的p指针发生了改变),程序将会崩溃

5、对同一块内动态内存的多次释放

int main(){
    int* p = (int*)malloc(40);
    if(p == NULL){
        return 0;
    }
    ...
    free(p);
    ...
    free(p); // err
    return 0;
}

如何来避免这种情况呢?

  • 保持原则:谁申请谁释放

  • 在释放掉之后将指针置为NULL

    free(p);
    p = NULL;
    ...
    free(p); // 这里就算再次释放,也是不会报错的,因为p被赋值为NULL,free(NULL)是不会有问题的
    

6、动态开辟内存忘记释放(内存泄露)

常见笔试题

void GetMemory(char* p){
    p = (char*)malloc(100);
}
void Test(void){
    char* str = NULL;
    GetMemory(str);
    strcpy(str, "hello world");
    printf(str);
}
int main(){
    Test();
    return 0;
}
// 1.运行的代码程序会出现崩溃的现象,崩溃是在“strcpy(str, "hello world");”这一句代码,原因是str此时是NULL
// 2.程序存在内存泄漏的问题
//  str以值传递的形式给p,因此p是对str的一份拷贝,p是GetMemory函数的形参,只能函数内部有效,等GetMemory函数返回之后,动态开辟内存尚未释放,并且无法找到,会造成内存泄漏
// 返回栈空间的地址的问题
char* GetMemory(void){
    char p[] = "hello world"; // 函数结束后被销毁
    return p; // 返回局部变量p的首元素地址
}
void Test(void){
    char* str = NULL;
    str = GetMemory(); // 拿到了已经被销毁的元素的地址
    printf(str); // 非法访问内存,所以打印出来的是随机值
}
int main(){
    Test();
    return 0;
}
// 返回栈空间的地址的问题
int* test(){
    int a = 10;
    return &a;
}
int main(){
    int* p = test();
    *p = 20; // 非法访问内存
    return 0;
}

//// 但是如果是这样写:
int* test(){
    static int a = 10; // a变全局变量,函数结束之后也不会被销毁
    return &a;
}
int main(){
    int* p = test();
    *p = 20;
    return 0;
}// 上面这个代码是完全没有问题的
// 释放的空间再次被使用
void Test(void){
    char* str = (char*)malloc(100);
    strcpy(str, "hello");
    free(str); // free释放str指向的空间后,并不会把str置为NULL
    if(str != NULL){
        strcpy(str, "world"); // 非法访问内存
        printf(str);
    }
}
int main(){
    Test();
    return 0;
}

c/c++程序的内存开辟

image-20210504005236542

c/c++程序内存分配的几个区域:

1、栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等

2、堆区(heap):一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。分配方式类似于链表

3、数据段(静态区)(static)存放全局变量、静态数据。程序结束后由系统释放

4、代码段:存放函数体(类成员函数和全局函数)、常量字符串的二进制代码

  • 内核空间

    比方说4个g的内存只有3个g分给用户使用,剩下的1个g让给操作系统使用了,这1个g的内存就叫内核空间

  • 数据段

    就是静态区

有了上面这幅图,我们就可以更好的理解static关键字修饰局部变量的例子了

实际上普通的局部变量是在栈区分配空间的,栈区的特点是在上面创建的变量出了作用域就销毁。

但是被static修饰的变量存放在数据段(静态区),数据段的特点是在上面创建的变量,直到程序结束才销毁,所以声明周期变长

柔性数组

C99中,结构中的最后一个元素允许未知大小的数组,这就叫做柔性数组成员

柔性数组的特点:

  • 结构中的柔性数组成员前面必须至少一个其他成员
  • sizeof返回的这种结构大小不包括柔性数组的内存
  • 包含柔性数组成员的结构用malloc()函数进行内存的动态分配,并且分配的内存应该大于结构体的大小,以适应柔性数组的预期大小

示例:

struct S{
    int n;
    int arr[]; // 未知大小的-柔性数组成员-数组的大小是可以调整的
};
// 或者
struct S{
    int n;
    int arr[0]; // 未知大小的-柔性数组成员-数组的大小是可以调整的
};// 这种写法跟上面那种写法是等价的,写上面那种写法编译器编不过去的时候可以写这种写法

包含柔性数组成员的结构体的大小

struct S{
    int n;
    int arr[]; 
};
int main(){
    struct S s;
    printf("%d\n", sizeof(s)); // 4 我们发现它并没有包含柔性数组成员的大小
    return 0;
}

柔性数组的使用

struct S{
    int n;
    int arr[]; // 柔性数组成员
};
int main(){
    struct S* ps = (struct S*)malloc(sizeof(struct S) + 5 * sizeof(int)); // 开辟一块内存空间,sizeof(struct S)为为非柔性数组成员开辟的空间的大小,5 * sizeof(int)为为柔性数组成员arr开辟的空间的大小,具体请看下图
    
    //// 开始使用该结构体
    ps->n = 100;
    int i = 0;
    for(i = 0; i < 5; i++){
        ps->arr[i] = i;
    }
    struct S* ptr = realloc(ps, 44);
    if(ptr != NULL){
        ps = ptr;
    }
    for(i = 5; i < 10; i++){
        ps->arr[i] = i;
    }
    for(i = 0; i < 10; i++){
        printf("%d\n", ps->arr[i]);
    }
    // 释放
    free(ps);
    ps = NULL;
    return 0;
}

image-20210504193240235

用指针的方法模拟柔性数组成员

struct S{
    int n;
    int* arr;
};
int main(){
    struct S* ps = (struct S*)malloc(sizeof(struct S));
    // 这里是把结构体开辟在堆上,所以用malloc去开辟结构体的内存空间,当然也可以将结构体开辟在栈上,只需要这么写:
    // struct S s;
    ps->arr = (int*)malloc(5 * sizeof(int));
    // 开始使用结构体S
    int i = 0;
    for(i = 0; i < 5; i++){
        ps->arr[i] = i;
    }
    for(i = 0; i < 5; i++){
        printf("%d ", ps->arr[i]);
    }
	int* ptr = (int*)realloc(ps->arr, 10 * sizeof(int));
    if(ptr != NULL){
        ps->arr = ptr;
    }
    for(i = 5; i < 10; i++){
        ps->arr[i] = i;
    }
    for(i = 0; i < 10; i++){
        printf("%d ", ps->arr[i]);
    }
    // 释放空间
    free(ps->arr);
    ps->arr = NULL;
    free(ps);
    ps = NULL;
    return 0;
}

柔性数组的好处

柔性数组成员较指针的好处在于malloc/free的次数少,不容易出错,且使用柔性数组成员的时候结构体的内存空间是连续的,相较于指针内存碎片更少(指针的方式使用malloc的次数多,而malloc申请内存是随机的,容易产生较多的内存碎片)。此外,内存空间是连续的一个好处是:访问效率更高(我们知道:寄存器 > 高速缓存(cache)> 内存 > 硬盘,根据局部性原理,当用户访问某个区域的内存时有80%的可能性将会访问紧接着该区域的内存的后面的内存,此时如果直接将紧接着该区域的内存的后面的内存直接读入寄存器或者高速缓存,那么将有很大的可能性用户会访问到这些内存,而访问的时候直接去寄存器或者高速缓存中取就行了而不需要再去读内存(查找数据的顺序是先去寄存器找,找不到再去高速缓存找,再找不到就去内存中找),显然内存空间是连续的话访问效率将会更高)

文件操作

一般的文件有两种:程序文件、数据文件

程序文件

包含源程序文件(后缀为.c),目标文件(windows环境后缀为.obj),可执行程序(windows环境后缀为.exe)

数据文件

文件的内容不一定是程序,而是程序运行时读写的数据。比如程序运行需要从中读取数据的文件,或者输出内容的文件

文件名

一个文件要有一个唯一的文件标识,以便用户识别和引用

文件名包含3部分:文件路径+文件名主干+文件后缀

例如:c:/code/test.txt

为了方便起见,文件表示常被称为文件名

文件类型

根据数据的组织形式,数据文件被称为文本文件或者二进制文件

数据在内存中以二进制的形式存储,如果不加转换的输出到外存,就是二进制文件

如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的文件就是文本文件

一个数据在内存中是怎么存储的呢?

字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以使用二进制形式存储

如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符一个字节:‘1’、‘0’、‘0’、‘0’、‘0’),而以二进制形式输出,则在磁盘上只占4个字节(因为10000是一个整型,整型在内存中占4字节,那么在外存也是占4字节)(VS2013测试)

image-20210504230843393

二进制形式存储的文件一般的文本编辑器是看不懂的,可以借助VS中的打开方式中的“二进制编辑器”打开来查看

文件缓冲区

输入缓冲区和输出缓冲区是两块独立的内存区域

ANSIC标准采用“缓冲文件系统”处理的数据文件的,所谓缓冲文件系统是指系统自动的在内存中为程序中每一个正在使用的文件开辟一块“文件缓冲区”。从内存向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上。如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译系统决定的

image-20210504232208322

文件指针

缓冲文件系统中,关键的概念是“文件类型指针”,简称“文件指针

每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名字,文件状态及文件当前的位置等),这些信息是保存在一个结构体变量中的。该结构体类型是由系统声明的,取名FILE(比方说要读test.txt文件,读到内存里面的不是test.txt文件本身,而是test.txt文件的相关信息,用于存这些信息的是一个结构体变量,该结构体的类型(FILE)是由系统声明的)

image-20210504234059074

不同的C编译器的FILE类型包含的内容不完全相同,但是大同小异

每当打开一个文件的时候,系统会根据文件的情况自动创建一个FILE结构的变量,并填充里面的信息

一般都是通过一个FILE的指针来维护这个FILE结构的变量

FILE* pf; // 文件指针变量

定义pf是一个指向FILE类型数据的指针变量。可以使pf指向某个文件的文件信息区(是一个结构体变量)。通过该文件信息区中的信息就能够访问该文件。也就是说,通过文件指针变量能够找到与它关联的文件

image-20210504234600020

文件的打开和关闭

在打开文件的同时,都会返回一个FILE*的指针变量指向该文件,也相当于建立了指针和文件的关系

ANSIC规定使用fopen函数来打开文件,fclose来关闭文件

FILE* fopen(const char* filename, const char* mode);
int fclose(FILE* stream);

打开方式如下:

文件使用方式 含义 如果指定文件不存在
“r”(只读) 为了输入数据,打开一个已经存在的文本文件 出错
“w”(只写) 为了输出数据,打开一个文本文件 建立一个新的文件
“a”(追加) 向文本文件尾添加数据 出错
“rb”(只读) 为了输入数据,打开一个二进制文件 出错
“wb”(只写) 为了输出数据,打开一个二进制文件 建立一个新的文件
“ab”(追加) 向一个二进制文件尾追加数据 出错
“r+”(读写) 为了读和写,打开一个文本文件 出错
“w+”(读写) 为了读和写,建立一个新的文件 建立一个新的文件
“a+”(读写) 打开一个文件,在文件尾进行读写 建立一个新的文件
“rb+”(读写) 为了读和写打开一个二进制文件 出错
“wb+”(读写) 为了读和写,新建一个新的二进制文件 建立一个新的文件
“ab+”(读写) 打开一个二进制文件,在文件尾进行读和写 建立一个新的文件

**注意!**使用方式是写的时候,如果原先已经存在文件,则会将文件的内容全部覆盖掉

示例1:

int main(){
    // 打开文件
    // 绝对路径写法
    fopen("c:\\User\\Administrator\\test.txt", "r");
    // 相对路径写法
    fopen("test.txt", "r");
    // 相对路径还可以这么写
    fopen("../test.txt", "r");
    return 0;
}

示例2:

int main(){
    FILE* pf = fopen("test.txt", "r");
    if(pf == NULL){
        // 说明打开文件失败,此时用于维护该文件的相关信息的FILE类型的结构体是不会被创建的,而且fopen返回的是NULL
        return 0;
    }
    // 打开成功
    // 读文件
    // 关闭文件(释放资源)
    fclose(pf);
    // 因为fclose是值传递,在释放完资源之后是不会改变指针pf的,因此还需要把指针pf置NULL
    pf = NULL;
    return 0;
}

文件的顺序读写

功能 函数名 适用于
字符输入函数 fgetc 所有输入流
字符输出函数 fputc 所有输出流
文本行输入函数 fgets 所有输入流
文本行输出函数 fputs 所有输出流
格式化输入函数 fscanf 所有输入流
二进制输入 fread 文件
二进制输出 fwrite 文件

示例1:

// 写
#include<string.h>
#include<stdio.h>
#include<errno.h>
int main(){
    FILE* pf;
    pf = fopen("test.txt", "w");
    if(pf != NULL){
        // 写文件
        fputs("hello world", pf);
        fputs("hello\n", pf);
        fputs("world", pf);
        fputc('b', pf);
        fputc('a', pf);
        fclose(pf);
        pf = NULL;
    }else{
        printf("%s\n", strerror(errno));
    }
    return 0;
}

示例2:

// 读 fgetc
#include<string.h>
#include<stdio.h>
#include<errno.h>
int main(){
    FILE* pf;
    pf = fopen("test.txt", "r");
    if(pf != NULL){
        // 读文件
        // 这里我们读两个字符
        printf("%c", fgetc(pf));
        printf("%c", fgetc(pf));
        fclose(pf);
        pf = NULL;
    }else{
        printf("%s\n", strerror(errno));
    }
    return 0;
}

示例3:

// 读 fgets
// char* fgets(char* string, int n, FILE* stream);
#include<string.h>
#include<stdio.h>
#include<errno.h>
int main(){
    char buf[1024] = {0};
    FILE* pf = fopen("test.txt", "r");
    if(pf != NULL){
        fgets(buf, 1024, pf);
        // puts(buf); // puts()打印完之后会自动换行,如果打印的东西本身就有换行,那打印完之后它也会再换一行
        printf("%s", buf);
        fgets(buf, 1024, pf);
        // puts(buf); // puts()打印完之后会自动换行,如果打印的东西本身就有换行,那打印完之后它也会再换一行
        printf("%s", buf);
        fclose(pf);
        pf = NULL;
    }else{
        printf("%s\n", strerror(errno));
    }
    return 0;
}

示例4:

// 从键盘读取一行文本信息并显示到控制台
int main(){
    char buf[1024] = {0};
    fgets(buf, 1024, stdin); // 从标准输入流读取
    fputs(buf, stdout); // 输出到标准输出流
    // 上面两行等价于下面两行:
    // gets(buf);
    // puts(buf);
    return 0;
}

示例5:

// fprintf
// int fprintf(FILE* stream, const char* format[, argument]...);
#include<stdio.h>
struct S{
    int n;
    float score;
    char arr[10];
};
int main(){
    struct S s = {100, 3.14f, "bit"};
    FILE* pf = fopen("test.txt", "w");
    if(pf == NULL){
        return 0;
    }
    // 格式化的形式写文件
    fprintf(pf, "%d %f %s", s.n, s.score, s.arr);
    fclose(pf);
    pf = NULL;
    return 0;
}

示例6:

// fscanf
// int fscanf(FILE* stream, const char* format[, argument]...);
#include<stdio.h>
struct S{
    int n;
    float score;
    char arr[10];
};
int main(){
    struct S s = {100, 3.14f, "bit"};
    FILE* pf = fopen("test.txt", "r");
    if(pf == NULL){
        return 0;
    }
    // 格式化的输入数据
    fscanf(pf, "%d %f %s", &(s.n), &(s.score), s.arr);
    printf("%d %f %s\n", s.n, s.score, s.arr);
    fclose(pf);
    pf = NULL;
    return 0;
}

示例7:

#include<stdio.h>
#include<string.h>
struct S{
    int n;
    float score;
    char arr[10];
};
int main(){
    struct S s = {0};
    fscanf(stdin, "%d %f %s", &(s.n), &(s.score), s.arr);
    fprintf(stdout, "%d %.2f %s", s.n, s.score, s.arr);
    return 0;
}

思考:从键盘输入和输出到屏幕

其实键盘和屏幕都是外部设备,键盘是标准输入设备(stdin),而屏幕是标准输出设备(stdout),这两个都是一个程序开启的时候默认打开的两个流设备,这也是为什么一个程序起来的时候我们默认就可以通过键盘和屏幕与之进行交互的原因

一个程序运行起来后会默认打开三个流(这三个流的类型都是FILE*):

  • stdin
  • stdout
  • stderr

示例8:

// 标准输入输出流
int main(){
    // 使用标准输入流接收字符
    int ch = fgetc(stdin);
    // 使用标准输出流输出字符
    fputc(ch, stdout);
    return 0;
}

fwrite与fread

size_t fwrite(const void* buffer, size_t size, size_t count, FILE* stream);

size_t fread(void* buffer, size_t size, size_t count, FILE* stream);

struct S{
    char name[20];
    int age;
    double score;
};
int main(){
    // struct S s = {"张三", 20, 55.6};
    struct S temp = {0};
    // FILE* pf = fopen("test.txt", "wb");
    FILE* pf = fopen("test.txt", "rb");
    if(pf == NULL){
        return 0;
    }
    //// 二进制的形式写文件
    // fwrite(&s, sizeof(struct S), 1, pf);
    // 二进制的形式读文件
    fread(&temp, sizeof(struct S), 1, pf);
    printf("%s %d %lf\n", temp.name, temp.age, temp.score);
    fclose(pf);
    pf = NULL;
    return 0;
}

sscanf与sprintf

int sscanf(const char* buffer, const char* format[, argument]…);

int sprintf(char* buffer, const char* format[, argument]…);

首先对比(scanf与fscanf与sscanf)以及(printf与fprintf与sprintf):

scanf/pritnf 是针对标准输入流/标准输出流的 格式化输入/输出语句

fscanf/fprintf 是针对所有输入流/所有输出流的 格式化输入/输出语句

sscanf/sprintf sscanf是从字符串中读取格式化的数据;sprintf是把格式化数据输出成(存储到)字符串

示例:

struct S s{
    int n;
    float score;
    char arr[10];
};
int main(){
    struct S s = {100, 3.14f, "abcdef"};
    struct S temp = {0};
    char buf[1024] = {0};
    // 把格式化的数据转换成字符串存储到buf
    sprintf(buf, "%d %f %s", s.n, s.score, s.arr);
    // printf("%s\n", buf);
    // 从buf中读取格式化的数据到temp中
    sscanf(buf, "%d %f %s", &(temp.n), &(temp.score), temp.arr);
    printf("%d %f %s\n", temp.n, temp.score, temp.arr);
    return 0;
}

文件的随机读写

fseek

根据文件指针的位置和偏移量来定位文件指针

int fseek(FILE* stream, long int offset, int origin);

这里的第三个参数origin表示起始位置,有三个选项:

  • SEEK_CUR

    文件指针的当前位置

  • SEEK_END

    文件的末尾位置

  • SEEK_SET

    文件的起始位置

示例:

int main(){
    FILE* pf = fopen("test.txt", "r");
    if(pf == NULL) return 0;
    // 1.定位文件指针
    fseek(pf, 2, SEEK_CUR); // 文件刚打开的时候文件指针默认就是指向起始位置,所以这里SEEK_CUR和SEEK_SET效果一样
    // 2.读取文件
    int ch = fgetc(pf);
    printf("%c\n", ch);
    // 1.定位文件指针
    fseek(pf, -2, SEEK_END);
    // 2.读取文件
    ch = fgetc(pf);
    printf("%c\n", ch);
    fclose(pf);
    pf = NULL;
    return 0;
}

ftell

返回文件指针相对于起始位置的偏移量

long int ftell(FILE* stream);

示例:

int main(){
    FILE* pf = fopen("test.txt", "r");
    if(pf == NULL){
        return 0;
    }
    // 1.定位文件指针
    fseek(pf, -2, SEEK_END);
    int pos = ftell(pf);
    printf("%d\n", pos);
    fgetc(pf);
    int pos = ftell(pf);
    printf("%d\n", pos);
    fclose(pf);
    pf = NULL;
    return 0;
}

rewind

让文件指针的位置回到文件的起始位置

void rewind(FILE* stream);

示例:

int main(){
    FILE* pf = fopen("test.txt", "r");
    if(pf == NULL){
        return 0;
    }
    int ch = fgetc(pf);
    printf("%c\n", ch);
    rewind(pf);
    ch = fgetc(pf);
    printf("%c\n", ch);
    fclose(pf);
    pf = NULL;
    return 0;
}

文件结束判定

feof这个函数不是用来判断读取文件是否结束,而是用来得知当读取文件的代码结束的时候,它结束的原因到底是什么(是因为遇到了eof结束的,还是因为遇到某些原因读取失败而导致程序结束的)

被错误使用的feof

注意:在文件读取过程中,不能用feof函数的返回值直接用来判断文件是否结束。

而是应用于当文件读取结束的时候,判断是读取失败结束,还是遇到文件尾结束。

1、文本文件读取是否结束,判断返回值是否为EOF(fgetc),或者NULL(fgets)

例如:

  • fgetc判断是否为EOF
  • fgets判断返回值是否为NULL

2、二进制文件的读取结束判断,判断返回值是否小于实际要读的个数

例如:

  • fread判断返回值是否小于实际要读的个数
// 首先来看看EOF,EOF其实就是-1
int main(){
    // EOF - end of file - 文件结束标志
    FILE* pf = fopen("test.txt", "r"); // 假设test.txt是一个空文件
    if(pf == NULL){
        return 0;
    }
    int ch = fgetc(pf);
    printf("%d\n", ch); // 由于test.txt是一个空文件,所以这里会打印-1
    fclose(pf);
    pf = NULL;
    return 0;
}

feof的正确用法:

// 文本文件的例子
int main(){
    FILE* pf = fopen("test.txt", "r");
    if(pf == NULL){
        perror("open file test.txt");
        return 0;
    }
    // 读文件
    int ch = 0;
    while((ch = fgetc(pf)) != EOF){
        putcchar(ch);
    }
    if(ferror(pf)){
        // 说明读取文件的时候出错了
        printf("error\n");
    }else if(feof(pf)){
        // 说明已经读取到文件的末尾了
        printf("end of file reached successfully");
    }
    fclose(pf);
    pf = NULL;
    return 0;
}
// 二进制文件的例子
enum{
    SIZE = 5;
}
int main(){
    double a[SIZE] = {1.0, 2.0, 3.0, 4.0, 5.0};
    double b = 0.0;
    size_t ret_code = 0;
    FILE* pf = fopen("test.bin", "wb"); 
    fwrite(a, sizeof(a), SIZE, pf);
    fclose(pf);
    pf = NULL;
    
    pf = fopen("test.bin", "rb");
    // 读double的数组
    while((ret_code = fread(&b, sizeof(double), 1, pf)) >= 1){
        printf("%lf\n", b);
    }
    if(feof(pf)){
        printf("遇到EOF\n");
    }
    else if(ferror(pf)){ // ferror这个函数用于检测流上是否发生错误(int ferror(FILE* stream);)
        perror("读取失败\n");
    }
    fclose(pf);
    pf = NULL;
    return 0;
}

程序的环境和预处理

image-20210510215502105

程序的翻译环境和执行环境

在ANSI C的任何一种实现中,存在两个不同的环境

第1种是翻译环境,在这个环境种源代码被转换为可执行的机器指令。第2种是执行环境,它用于实际执行代码

有时间可以去看看《程序员的自我修养》

编译+链接

首先是编译环境

程序编译过程:

image-20210510214513850

  • 组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)
  • 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序
  • 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中

源文件就是.c文件

目标文件就是.obj文件(注意windows下是.obj文件,linux下是.o文件)

任何一个源文件都会单独作为一个单元被编译器进行编译处理

示例:

假设有test.c文件和add.c文件,test.c文件包含程序入口函数(main函数),里面用到了add.c文件中的某个函数,经过编译之后就会生成两个.obj文件(目标文件):test.obj和add.obj

编译本身也分为几个阶段

image-20210511210349040

如果将编译再细化,则可以分为以下三个阶段:

  • 预编译(这个阶段做的事其实都是文本操作)(用预处理指令执行一些文本操作)

    gcc -E test.c -o test.i // 使用gcc -E命令
    

    假设有test.c文件,里面包含若干头文件(#include的文件)以及若干注释以及若干#define(如:#define MAX 1000),然后执行上面的命令

    打开test.i会发现#include <...>不见了,而是被换成了一大堆代码;并且注释也不见了;并且代码中的MAX都被替换成了1000(比方说test.c中是:int x = MAX;test.i中就会变成:int x = 1000;)

    原因是预编译的时候把#include <...>里面的内容全部包含进来了;并且会把注释行全部用空格替换掉;并且会将#define的东西做一个替换

    怎么验证呢?很简单,比方说test.c中包含#include <stdio.h>,我们只需要找到c库中的stdio.h来和test.i对比一下就好了(注意:VS环境下stdio.h在Program Files(x86)\Microsoft Visual Studio ...\VC\include文件包下;linux环境下stdio.h在/usr/include/std文件下)

    得出结论:

    • 预编译的时候做了头文件(#include的文件)的包含
    • 做了注释删除(使用空格替换所有的注释)
    • 做了#define的文本替换
    • ...(当然,预编译阶段做的事情还有很多)
  • 编译(这个阶段做的事其实就是把c代码翻译成汇编代码)

    对上面预编译之后的test.i进行编译

    gcc -S test.i
    

    执行命令之后会生成test.s文件(里面放的都是汇编代码):

    image-20210511210946507

    详细来讲分为四步骤:

    • 语法分析(分析语法错误)

    • 词法分析(编译原理,把代码变语法树...)

    • 语义分析(先读懂语义,再进行翻译)

    • 符号汇总

      把全局变量、函数定义这些符号都汇总起来

      那么这些汇总起来的符号有什么用呢?汇编阶段就有用了,更大的用途会在链接器那一块会用到

      比方说test.c文件中有全局变量g_val和函数main:

      image-20210511221302042

      那么test.s中就会有这些符号:

      image-20210511221354871

      而且我们会发现诸如i、arr这样的局部变量是不会被汇总的(test.s中没有局部变量i、arr等)

  • 汇编(这个阶段做的事其实就是把汇编代码转换成二进制指令)

    gcc -c test.c
    

    执行命令之后生成test.o文件(可执行文件)

    gcc -c test.c -o test
    

    执行命令之后生成test文件(可执行文件)

    形成符号表

    在汇编的过程中还有一个过程是形成符号表

    示例:

    假设我们有两个文件:add.c和test.c,test.c中有主函数main,且用到了add.c中的Add函数

    那么此时如果经过编译就会形成add.o和test.o两个目标文件:

    image-20210511222250323

    还记得预编译过后的编译过程中的符号汇总吗?

    add.c经过编译的符号汇总之后会记录符号:Add,test.c经过编译的符号汇总之后会记录符号:Add和main

    之后应该是进入汇编阶段了,此时会将符号放入表里面形成符号表,具体来讲就是一个符号对应一个地址:

    image-20210511222657131

    这个过程就叫做形成符号表

    那么此时add.c经过汇编之后就会形成符号表:

    image-20210511222827218

    此时test.c经过汇编之后也会形成符号表:

    image-20210511222913139

    注意,由于在test.c文件中main函数是确切存在的一个函数,所以它的地址就可以直接写,但是Add函数只是一个声明,所以它的地址写的是一个无实际意义的地址,比如说放一个0x000

编译之后就可以做链接了,链接阶段需要做:

  • 合并段表
  • 符号表的合并和符号表的重定位

示例:

还是上面add.c和test.c两个文件的例子

在汇编生成add.o和test.o之后,这两个.o文件需要链接在一起

此时会先合并段表

首先解释一下什么叫合并段表:

拿add.o来讲,它有自己固定的格式,会固定把自己分成几个段:

image-20210511223848901

同理,test.o文件也会分成几个段(注意,段的格式(这种格式名叫elf文件格式)都是固定的,都是一样的,只不过段里面放的内容不一样):

image-20210511224027434

那么链接期间会将这两个目标文件连接在一起:

image-20210511224359254

此时会把对应的段上的数据合并在一起,这就是所谓的合并段表,当然,这里只是轻描淡写地说了一下合并段表的大致情况,事实上不是这么轻松就合并的,它会有自己的规则来合并

它会把多个这样的elf文件格式的文件合并到一起,最终形成一个可执行文件(注意,可执行文件也是elf格式的文件)

之后是符号表的合并和重定位

上面说了,每个.c文件汇编之后都会形成各自的符号表,这个符号表需要合并

那么怎么合并符号表呢?

首先,没有冲突的符号表肯定是直接并进去了,那么当有冲突的时候呢?它会使用地址有效的那一个(这个就是符号地址的重定位)

此时就会形成一张表格:

image-20210511230241963

此时就把符号表合并了,合并的同时有些符号的地址还进行了重定位

显然,符号表的合并和重定位的意义就在于当用到某个函数的时候可以准确地找到这个函数

链接期间会通过符号表去找函数,如果找不到,则会发生链接错误(有三种错误:编译错误、链接错误、运行错误)

至此,可执行程序就生成了,整个翻译环境所做的事情就结束了

image-20210511232500246

总的来讲,刚才的过程就是:隔离编译,一起链接

运行环境

程序执行的过程:

1、程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可以是通过可执行代码置入只读内存来完成;

2、程序开始执行,接下来会调用main函数;

3、开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程中一直保留他们的值;

4、终止程序,正常终止main函数;当然也可能是意外终止

详解预编译

我们刚才讲编译可以分为:预编译、编译、汇编

现在我们就来好好聊聊预编译

预定义符号

__FILE__ // 进行编译的源文件
__LINE__ // 文件当前的行号
__DATE__ // 文件被编译的日期
__TIME__ // 文件被编译的时间
__FUNCTION__ // 打印所在函数的函数名
__STDC__ // 如果编译器遵循ANSI C,其值为1,否则未定义(测试之后gcc下可以打印出来1,VS下__STDC__直接报未定义的错了)

这些预定义符号都是语言内置的,举个例子:

...
18|...
19|...
20|printf("%s\n", __FILE__); // d:\c\test.c
21|printf("%d\n", __LINE__); // 21
22|printf("%s\n", __DATE__); // May 12 2021
23|printf("$s\n", __TIME__); // 19:09:32

示例:

// 写日志文件
int i = 0;
int arr[10] = {0};
FILE* pf = fopen("log.txt", "w");
for(i = 0; i < 10; i++){
    arr[i] = i;
    fprintf(pf, "file:%s line:%s date:%s time:%s i=%d\n", __FILE__, __LINE__, __DATE__, __TIME__, i);
}
fclose(pf);
pf = NULL;

预处理指令(#开头的指令)

#define
#include
#pragma
#if
#endif
#ifdef
#line
#error
...

#define:

#define定义标识符

语法:

#define name stuff

举例:

#define MAX 1000
#define STR "hehe"
#define reg register // 为register这个关键字创建一个简短的名字
#define do_forever for(;;) // 用更形象的符号来替换一种实现
#define CASE break;case // 在写case语句的时候自动把break写上
// 如果定义的stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续航符)
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
                         date:%s\ttime:%s\n",\
                         __FILE__, __LINE__,\ 
                         __DATE__, __TIME__);

提问:

在define定义标识符的时候,要不要在最后加上分号?

答:

最好不要,这样容易导致问题。比如下面的场景:

#define MAX 10;
int main(){
 printf("%d\n", MAX); // 这里会报错,因为预编译后这边的代码就会变:printf("%d\n", 10;); 显然这是错误的语法。最坑爹的是,这个时候报的错你还不容易找到错误到底在哪里
 return 0;
}

#define定义宏

#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)

下面是宏的申明方式:

#define name(parameter-list) stuff其中的parameter-list是一个由逗号隔开的符号表,它们可能出现在stuff中

**注意:**参数列表的左括号必须与name紧邻。如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。

如:

#define SQUARE( x ) x * x
// 这个宏接收一个参数x,如果在上述声明之后,你把SQUARE(5);置于程序中,预处理器就会用下面这个表达式替换上面的表达式:5 * 5;

宏可能会引发的问题:

示例1:

#define DOUBLE(X) X+X
int main(){
    int a = 5;
    int ret = 10 * DOUBLE(a); // 这里在预编译的时候会变成:int ret = 10 * 5+5;
    printf("%d\n", ret); // 55
    return 0;
}
// 解决方法:给宏加括号,如下:
#define DOUBLE(X) ((X) + (X)) // 这里如果是10 * DOUBLE(5);在预编译的时候也会变成:10 * ((5) + (5));

示例2:

#define SQUARE(x) x*x
int main(){
    int ret = SQUARE(5 + 1); // 这里在预编译的时候会变成:int ret = 5 + 1*5 + 1
    printf("%d\n", ret); // 11
    return 0;
}
// 解决方法:给宏加括号,如下:
#define SQUARE(x) (x)*(x) // 这个时候就算是SQUARE(5 + 1); 在预编译的时候也会变成:(5 + 1)*(5 + 1)

小结:

用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用

#define替换规则:

在程序中扩展#define定义符号和宏时,需要涉及几个步骤:

1、在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号,如果是,它们首先被替换;

示例:

#define MAX 100
#define DOUBLE(x) ((x)+(x))
int main(){
    int ret = 10 * DOUBLE(MAX + MAX); // DOUBLE(MAX + MAX) -> DOUBLE(100 + 100) -> ((100 + 100) + (100 + 100))
    return 0;
}

2、替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值替换;

3、最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号,如果是,就重复上述处理过程(一般来讲,替换完符号之后就开始替换宏了)

注意:

  • 宏参数和#define定义中可以出现其他#define定义的变量,但是对于宏,不能出现递归

  • 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索

    示例:

    #define MAX 100
    int main(){
        printf("MAX: %d\n", MAX); // 字符串中的MAX不会被替换
        return 0;
    }
    

#和##:

先来说#的作用:

如何把参数插入到字符串中?

场景:

void print(int a){
    printf("the value of a is %d\n", a);
}
int main(){
    int a = 10;
    int b = 20;
    print(a); // the value of a is 10 这里还可以
    print(b); // the value of a is 20 这里就比较怪了,因为我们期望的是:the value of b is 20
    return 0;
}

首先来看一看下面的代码:

int main(){
    printf("hello world\n"); // hello world
    printf("hel" "lo " "world\n"); // hello world
    printf("hello " "world\n"); // hello world
    char* p = "abc" "def";
    printf("%s\n", p); // abcdef
    return 0;
}// 在c语言中当两个字符串紧挨着在一起的时候会被当成一个字符串去处理

现在我们来看看怎么去解决上面那个场景的问题:

#define PRINT(X) printf("the value of "#X" is %d\n", X) // 这里#X中的X是不会被替换成10或者20的,而是会变成"a"或者"b"(PRINT(a)传进去的参数是a,那么#X就会变"a",PRINT(b)传进去的参数是b,那么#X就会变"b"),也就是说#X表示的意思是X表达的内容所对应的字符串,举例来说就是:比方说是PRINT(a),那么PRINT(a)就会被替换成:printf("the value of ""a"" is %d\n", a);
int main(){
    int a = 10;
    int b = 20;
    PRINT(a); // the value of a is 10
    PRINT(b); // the value of b is 20
    return 0;
}

然后我们来说##的作用:

##可以把位于它两边的符号合成一个符号。它允许宏定义从分离的文本片段创建标识符

示例1:

#define CAT(X, Y) X##Y
int main(){
    int Class84 = 2021;
    printf("%d\n", CAT(Class, 84)); // CAT(Class, 84) -> Class##84 -> Class84 -> printf("%d\n", Class84);
    return 0;
}

示例2:

#define SUM(num, value) sum##num += value
int main(){
    int sum5 = 3;
    // SUM(4, 10); // 报错,因为根本没有定义sum4这个变量
    SUM(5, 10); // SUM(5, 10) -> sum##5 += 10 -> sum5 += 10
    printf("%d\n", sum5); // 13
    return 0;
}

带有副作用的宏参数:

当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么在使用这个宏的时候就可能导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果,如:

int main(){
    int a = 1;
    // int b = a + 1; // b变了,a不变,不带副作用
    // int b = ++a; // b变了,a也变了,带副作用
    return 0;
}

现在来看带有副作用的宏参数:

#define MAX(X, Y) ((X)>(Y)?(X):(Y))
int main(){
    int a = 10;
    int b = 11;
    int max = MAX(a++, b++); // MAX(a++, b++) -> ((a++)>(b++)?(a++):(b++)) -> 首先a、b变11、12,然后表达式为假,执行b++,因此max最后的值为12、a为11、b为13
    printf("%d\n", max); // 12
    printf("%d\n", a); // 11
    printf("%d\n", b); // 13
    return 0;
}

函数与宏的对比

属性 #define定义宏 函数
代码长度 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码
执行速度 更快 存在函数的调用和返回的额外开销,所以相对慢一些
操作符优先级 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则临近操作符的优先级可能会产生不可预料的后果,所以建议在写宏的时候多写一些括号 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测
带有副作用的参数 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果 函数参数只在传参的时候求值一次,结果更容易控制
参数类型 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型。此外,参数能传类型 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使它们执行的任务是相同的。此外,函数参数不能传类型
调试 宏是不方便调试的 函数是可以逐语句调试的
递归 宏是不能递归的 函数是可以递归的
  • 函数在调用的时候会有函数调用和返回的开销

  • 宏在预编译阶段就完成了替换,没有函数的调用和返回的开销

    假设某段代码使用了函数Max2,我们使用反汇编来看一看函数Max2的具体的执行情况:

    1、函数调用的准备工作(调用的开销):

    image-20210513193928544

    2、函数真正的执行:

    image-20210513194005715

    3、函数返回的开销:

    image-20210513194045265

    宏是没有诸如函数的调用和函数的返回的开销的

宏常用于执行简单的运算,比如在两个树中找出较大的一个

那么为什么不用函数来完成这个任务呢?原因有二:

  • 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹
  • 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之宏可以适用于整形、长整型、浮点型等可以用于比较的类型。宏是类型无关的

当然,和宏相比函数也有劣势的地方:

  • 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度(函数只需要保存一份,然后哪里用到哪里去调用就行;但是宏的话是替换,所以只要哪里用到了哪里就会被替换这么一段代码,试想一下有好多地方用到某个宏,那预编译之后将会有很多重复代码,整个代码文件也可能变得非常大)

  • 宏是没有办法调试的

    调试的时候已经是可执行程序了,显然已经经过了预编译,也就是说代码已经被替换了,但是我们肉眼看到的还是原先的宏。这种情况可能导致出问题的时候无法快速定位问题

  • 宏由于类型无关,也就不够严谨

  • 宏可能会带来运算符优先级的问题,导致程序容易出错

  • 可能会出现带有副作用的宏参数,但是这一点在函数里是不会有的,因为宏是做了一个代码的完全替换,而函数传进去的是变量的实际值,如:

    int Max(int x, int y){
        return x > y ? x : y;
    }
    int main(){
        int a = 1;
        int b = 2;
        int ret = Max(++a, ++b); // 实际上这里会先计算出传入的参数的值,然后再传入Max函数。也就是说真正传入Max函数的是数值2和3
        return 0;
    }
    

宏有时候可以做到函数做不到的事情,比如:宏的参数可以出现类型,但是函数不行,如:

#define SIZEOF(type) sizeof(type)
int main(){
    int ret = SIZEOF(int); // SIZEOF(int) -> sizeof(int)
    printf("%d\n", ret);
    return 0;
}

应用场景:

#define MALLOC(num, type) (type*)malloc(num*sizeof(type))
int main(){
    // int* p = (int*)malloc(10*sizeof(int));
    // 用了宏就可以这么写:
    int* p = MALLOC(10, int);
    return 0;
}

命名约定:宏名全部大写,函数名不要全部大写

最后,要说一点:在c++或者c99标准的c语言中尽量不要使用宏!因为宏有诸多劣势,而是使用inline内联函数,内联函数可以完全替代宏

#undef:

用于移除一个宏定义,如:

#define MAX 100
int main(){
    printf("%d\n", MAX);
    #undef MAX
    printf("%d\n", MAX); // 报错,提示没有定义MAX
    return 0;
}

场景:如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除

命令行定义:

许多c的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。例如:当我们根据同一个源文件要编译出不同的一个程序的不同版本的时候这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组;但是另外一个机器内存大些,我们需要一个大些的数组)

示例:

int main(){
    int arr[SZ] = {0};
    return 0;
}

显然上面的代码在编译的时候会因为SZ未定义而报错。那么此时可以执行下面这行编译命令:

gcc test.c -D SZ=10  // -D表示参数,表示在执行这个命令的时候给命令传递一些参数进去,这里我们传进去了一个SZ

注意:命令行定义其实也是在预编译阶段把预定义的符号的值替换到代码中,比方说上面的SZ会在预编译阶段全部替换成10

条件编译:

在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令,比如说:

调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译

示例:

int main(){
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int i = 0; 
    for(i = 0; i < 10; i++){
        arr[i] = 0;
        #ifdef DEBUG // 由于没有定义DEBUG,这块代码不会参与预编译(预编译阶段这块代码就被删掉了)
        printf("%d ", arr[i]);
        #endif
    }
    return 0;
}
#define DEBUG // 直接这样写也可以
int main(){
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int i = 0; 
    for(i = 0; i < 10; i++){
        arr[i] = 0;
        #ifdef DEBUG // 由于定义了DEBUG,这块代码会参与预编译
        printf("%d ", arr[i]);
        #endif
    }
    return 0;
}

常见的条件编译指令:

1. #if 常量表达式 // … #endif // 常量表达式由预处理器求值 如: #define __DEBUG__ 1 #if __DEBUG__ // … #endif

示例:

#if 1
printf("ok"); // ok会被打印
#endif

#if 1+1
printf("ok"); // ok会被打印
#endif

#if 1-1
printf("ok"); // ok不会被打印
#endif

#if 0
printf("ok"); // ok不会被打印
#endif

2.多分支的条件编译 #if 常量表达式 // … #elif 常量表达式 // … #else // … #endif

示例:

#if 1==1
	printf("haha\n");
#elif 2==1
	printf("hehe\n");
#else
	printf("lala\n");
#endif

3.判断是否被定义

#if defined(symbol)

#ifdef symbol

#if !defined(symbol)

#ifndef symbol

示例1:

#define DEBUG 0
int main(){
    // #if defined(DEBUG) // 就算DEBUG被定义成0,这块代码还是会被预编译
    // 也可以这么写:
    #ifdef DEBUG
    printf("hehe\n"); // 会输出hehe
    #endif
    return 0;
}

示例2:

#define DEBUG 1
int main(){
    // #if !defined(DEBUG) // 就算DEBUG被定义成1,这块代码还是不会被预编译
    // 也可以这么写:
    #ifndef DEBUG
    printf("hehe\n"); // 不会输出hehe
    #endif
    return 0;
}

4.嵌套指令

#if defined(OS_UNIX)

​ #ifdef OPTION1

​ unix_version_option1();

​ #endif

​ #ifdef OPTION2

​ unix_version_option2();

​ #endif

#elif defined(OS_MSDOS)

​ #ifdef OPTION2

​ msdos_version_option2();

​ #endif

#endif

文件包含:

我们已经知道,#include指令可以使另外一个文件被编译。就像它实际出现于#include指令的地方一样

这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换。这样一个源文件被包含10次,那就实际被编译10次

头文件被包含的方式:

  • 本地文件包含

    #include “filename”

    查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。

    linux环境的标准头文件的路径:

    /usr/include

    VS环境的标准头文件的路径:

    c:/Program Files (x86)/Microsoft Visual Studio 9.0/VC/include

    注意按照自己的安装路径去找

  • 库文件包含

    #include <filename.h>

    查找头文件直接去标准路径下查找,如果找不到就提示编译错误

    这样是不是可以说,对于库文件也可以使用"“的形式包含?答案是肯定的,可以

    但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了

存在这样一个问题(嵌套文件包含):

当我们在一个文件中重复多次引入同一个头文件:

#include "test.h"
#include "test.h"
#include "test.h"

int main()
...

执行预编译命令:

gcc test.c -E > test.i

我们会发现test.i中多次重复复制粘贴了test.h中的函数声明(假设test.h中只有Add这一个函数声明):

image-20210513195221551

那怎么解决呢?很简单,还是条件编译,如下:

test.h文件我们这样写:

#ifndef __TEST_H__ // 如果没有定义过符号__TEST_H__,那下面的代码就会参与预编译
#define __TEST_H__
int Add(int x, int y);
#endif

这样就完美解决了嵌套文件包含的问题

还有一种写法同样能解决该问题:

#pragma once
int Add(int x, int y);

gcc、gdb与make

参考:https://blog.csdn.net/xiahouzuoxin/article/details/25481023(gcc+gdb+make)

c语言函数

scanf和gets()

头文件string.h

scanf读到空格就停止了

gets(char* buffer)可以读取一行

pow()

头文件math.h

计算次方

getchar()和putchar()

getchar()用于接收键盘的字符,putchar()用于将字符输出(相当于printf())

int ch = getchar();
putchar(ch); // 用于输出getchar()获取的字符,相当于printf("%c\n", ch)

案例:

// 由于scanf读到空格就停止了,而我们又希望将空格后面的字符也全部都读掉以防止在scanf后面使用getchar()函数的时候getchar()误读到一些字符。那么可以怎么做呢?
char password[20] = { 0 };
scanf("%s", password);
int ch = 0;
// 我们知道用户输入字符到最后会按下回车键以结束,而这个回车键就是\n,所以我们可以通过判断getchar()是否读到\n来判断是否已经读完了用户所输入的所有字符
// 通过这个原理,我们就可以编写以下while循环了:
while ((ch = getchar()) != '\n') {
    ; // 只写一个;表示循环里面什么也不做
}

strcat()与strncat()

char* strcat(char* strDest, const char* strSource);

char* strncat(char* strDest, const char* strSource, size_t count);

strcat()

  • 源字符串必须以'\0’结束

  • 目标空间必须足够的大,能容纳下源字符串的内容

  • 目标空间必须可修改

  • 字符串自己追加自己是不行的

  • 手写strcat()

    char* my_strcat(char* dest, const char* src){
        char* ret = dest;
        assert(dest);
        assert(src);
        // 找到目标字符串的'\0'
        while(*dest != '\0'){
            dest++;
        }
        // 追加
        while(*dest++ = *src++){
            ;
        }
        return ret;
    }
    

字符串拼接

注意:自己给自己追加的时候不能用strcat()而要用strncat()

原因是strcat()的原理是找到第一个字符串的\0,然后用第二个字符串一个字符一个字符地从第一个字符串的\0开始往后追加(覆盖掉第一个字符串的\0),直到到第二个字符的\0为止

所以这个时候如果是自己追加自己的话就会导致自己的第一个字符覆盖掉自己的\0,导致最后找不到\0而没办法停止而报错

strstr()

用于判定是否是子字符串,是的话就找到并返回子字符串在字符串中的起始地址(第一次出现的位置的地址),不是的话就返回空指针

示例:

#include<stdio.h>
int main(){
	str1 = "abcde";
    str2 = "bcd";
    char* ret = strstr(str1, str2);
    if(ret == NULL){
        printf("%s\n", "没找到");
    }else{
        printf("子字符串在字符串中的起始地址是:%p\n", ret);
        printf("子字符串是:%s\n", ret);
    }
    return 0;
}

手写strstr():

  • 暴力遍历(BF算法)

    char* my_strstr(const char* p1, const char* p2){
        assert(p1 && p2);
        char* s1 = p1;
        char* s2 = p2;
        char* cur = (char*)p1;// 不转的话会报警告
        if(*p2 == '\0') return (char*)p1; // 因为p1的类型是const char*,因此要强转成char*返回
        while(*cur){
            s1 = cur;
            s2 = (char*)p2;// 不转的话会报警告
            while(*s1 && *s2 && (*s1 == *s2)){
                s1++;
                s2++;
            }
            if(*s2 == '\0') return cur; // 找到子串
            cur++;
        }
        return NULL; // 找不到子串
    }
    
  • KMP算法

    根据模式字符串计算出next数组,然后根据next数组跳过一些无意义的匹配。时间复杂度O(m+n)

strlen()

要注意的是strlen返回的类型是size_t而size_t等同于unsigned int,因此下面这种歧义需要注意:

if(strlen("abc") - strlen("abcde") > 0) printf("%s\n", "yes");
else printf("%s\n", "no");
// 结果打印“yes”,因为无符号数做加减操作之后类型还是无符号数

strcpy()

  • 源字符串必须以'\0’结束
  • 会将源字符串中的'\0’拷贝到目标空间
  • 目标空间必须足够大,以确保存放源字符串
  • 目标空间必须可变(所以目标空间是常量字符串那是不行的)

手写strcpy():

char* my_strcpy(char* dest, const char* src){
    assert(dest);
    assert(src);
    char* ret = dest;
    // 拷贝src指向的字符串的dest指向的空间,包含'\0'
    // while(*src != '\0'){
    //     *dest++ = *src++;
    // }
    // *dest = *src; // 赋值最后的'\0'
    // 上面的代码可以简写成:
    while(*dest++ = *src++){
        ;
    }
    return ret;
}

strcmp()

int strcmp(const char* str1, const char* str2);

  • 第一个字符串大于第二个字符串,返回一个大于0的数字
  • 第一个字符串等于第二个字符串,返回0
  • 第一个字符串小于第二个字符串,返回一个小于0的数字

手写strcmp():

int my_strcmp(const char* str1, const char* str2){
    asseet(str1 && str2);
    // 比较
    while(*str1 == *str2){
        if(*str1 == '\0') return 0;
        str1++;
        str2++;
    }
    // if(*str1 > *str2) return 1;
    // else return -1;
    // 或者说这么写:
    return (*str1 - *str2);
}

strncpy()

长度受限

char* strncpy(char* destination, const char* source, size_t num);

  • 拷贝num个字符从源字符串到目标空间
  • 如果源字符串的长度小于num,则拷贝完源字符串之后,在目标的后边追加0,直到num个

strncat()

长度受限

char* strncat(char* destination, const char* source, size_t num);

  • 如果num <= source的长度,则在拼接之后会自动补一个\0
  • 如果num > source的长度,则跟strcat()没有区别,超出长度的部分不会再补\0

strncmp()

长度受限

int strncmp(const char* str1, const char* str2, size_t num);

  • 比较到出现另个字符不一样或者一个字符串结束或者num个字符全部比较完

strtok()

char* strtok(char* str, const char* sep);

  • sep参数是个字符串,定义了用作分隔符的字符集合
  • 第一个参数指定一个字符串,它包含了0个或者多个由sep字符串中一个或多个分隔符分割的标记
  • strtok函数找到str中的下一个标记,并将其用\0结尾,返回一个指向这个标记的指针。(注:strtok函数会改变被操作的字符串,所以在使用strtok函数切分的字符串一般都是临时拷贝的内容并且可修改)
  • strtok函数的第一个参数不为NULL时,函数将找到str中第一个标记,strtok函数将保存它在字符串中的位置
  • strtok函数的第一个参数为NULL,函数将在同一个字符串中被保存的位置开始,查找下一个标记
  • 如果字符串中不存在更多的标记,则返回NULL指针

示例:

char arr[] = "zpw@bitedu.tech";
char* p = "@."; // 根据@或者.来做分割
// 需要提前保留一份arr,因为strtok()函数会破坏字符串本身
char buf[1024] = {0};
strcpy(buf, arr);
// 开始切割arr
char* ret = NULL;
for(ret = strtok(arr, p); ret != NULL; ret = strtok(NULL, p)){
    printf("%s\n", ret);
    // 分析:第一次切割会将zpw@bitedu.tech中的第一个@换成\0,如:zpw\0bitedu.tech,因此打印zpw;第二次切割由于strtok函数中存了一个静态变量来维护要切割的字符串,因此不需要再传入第一个参数,此时将会对bitedu.tech进行切割,显然切割之后会变成:bitedu\0tech,因此打印bitedu;第三次会对tech进行切割,此时没有找到切割字符,因此打印tech;第四次进入for循环的时候执行ret = strtok(NULL, p),由于已经没有剩余的字符了,因此返回NULL,因此不会进入到for循环体里面
}
// 上述程序的执行结果为:
// zpw
// bitedu
// tech

strerror()

char* strerror(int errnum);

返回错误码所对应的错误信息

int main(){
    // 错误码	错误信息
    // 0	  No error
    // 1	  Operation not permitted
    // 2	  No such file or directory
    // ...
    char* str = strerror(2);
    printf("%s\n", str);
    return 0;
}

真实情况下肯定不是这么用的,真实情况下需要配合errno.h头文件:

#include<errno.h> // 必须引入
int main(){
    // 错误码	错误信息
    // 0	  No error
    // 1	  Operation not permitted
    // 2	  No such file or directory
    // ...
    // errno 是一个全局的错误码的变量
    // 当C语言的库函数在执行过程中,发生了错误,就会把对应的错误码,赋值到errno中
    char* str = strerror(errno);
    printf("%s\n", str);
    return 0;
}

使用示例:

#include <errno.h>
// 打开文件的时候报错
FILE* pf = fopen("test.txt", "r");
if(pf == NULL){
    printf("%s\n", strerror(errno));
}

perror()

是strerror的加强版,用法比strerror更简单

int main(){
    FILE* pf = fopen("test.txt", "r");
    if(pf == NULL){
        // strerror - 把错误码对应的错误信息的字符串地址返回
        // printf("%s\n", strerror(errno));
        
        // perror
        perror("hehe"); // 如果发生错误,则会打印:hehe: No such file or directory.(相当于是定制化的错误信息的字符串 + 错误码对应的错误信息的字符串)
        return 0;
    }
    fclose(pf);
    pf = NULL;
    return 0;
}

字符分类函数

函数 如果传入的参数符合下列条件则返回真
iscntrl 任何控制字符
isspace 空白字符:空格'',换页'\f',换行'\n',回车'\r',制表符'\t’或者垂直制表符'\v'
isdigit 十进制数字0-9
isxdigit 十六进制数字,包括所有十进制数字,小写字母a-f,大写字母A-F
islower 小写字母a-z
isuppper 大写字母A-Z
isalpha 字母a-z或A-Z
isalnum 字母或数字,a-z,A-Z,0-9
ispunct 标点符号,任何不属于数字或者字母的图形字符(可打印)
isgraph 任何图形字符
isprint 任何可打印字符,包括图形字符和空白字符

使用这些函数需要引入头文件:ctype.h

字符转换函数

常用的有两个:

int tolower(int c); // 转小写

int toupper(int c); // 转大写

memcpy()(专门处理不重叠的内存拷贝)

void* memcpy(void* destination, const void* source, size_t num);

  • 函数memcpy从source的位置开始向后复制num个字节的数据到destination的内存位置

  • 这个函数在遇到'\0’的时候并不会停下来

  • 如果source和destination有任何的重叠,复制的结果都是未定义的

  • 手写memcpy

    void* my_memcpy(void* dest, const void* src, size_t num){
        void* ret = dest;
        assert(dest && src);
        while(num--){
            *(char*)dest = *(char*)src;
            ++(char*)dest;
            ++(char*)src;
        }
        return ret;
    }
    

示例1:

int arr1[] = {1,2,3,4,5};
int arr2[5] = {0};
memcpy(arr2, arr1, sizeof(arr1));

示例2:

struct S{
    char name[20];
    int age;
};
int main(){
    struct S arr1[] = {{"颤三", 20}, {"里斯", 22}};
    struct S arr2[3] = {0};
    memcpy(arr2, arr1, sizeof(arr1));
    return 0;
}

注意,使用上面我们自己写的my_memcpy()自己拷贝自己的时候可能会出问题(数据可能会被覆盖掉):

int main(){
    int arr[] = {1,2,3,4,5,6,7,8,9,10};
    int i = 0;
    // 从arr头部开始拷贝5个数到arr + 2开始的位置,期望是得到:1 2 1 2 3 4 5 8 9 10
    my_memcpy(arr + 2, arr, 5 * sizeof(int));
    for(i = 0; i < 10; i++){
        printf("%d\n", arr[i]);
    } // 实际上打印输出的是 1 2 1 2 1 2 1 8 9 10,那是因为在赋值的过程中数据被覆盖了
    return 0;
}
// 上面我们用的是自己写的内存拷贝函数(my_memcpy),实际上,如果使用库函数memcpy的话依然是可以得到1 2 1 2 3 4 5 8 9 10这个结果的。只不过,C语言标准规定:memcpy只需要专注于处理不重叠的内存拷贝,而memmove只需要专注于处理重叠的内存拷贝

这里是因为my_memcpy是从前往后拷贝的,所以导致了有用的数据被覆盖,那么能不能实现从后往前拷贝呢?看起来好像从后往前拷贝可以解决问题,实际上还是不行,试想一下如果是这样操作:

my_memcpy(arr, arr + 2, 5 * sizeof(int)); // 从arr + 2开始的位置拷贝5个数到arr头部,期望得到:3 4 5 6 7 6 7 8 9 10
// 事实上如果my_memcpy是从后往前的拷贝,我们依然会得到一个不符合预期的结果:7 6 7 6 7 6 7 8 9 10

可见memcpy不适合处理内存重叠(比如自己拷贝自己)的拷贝,那么有没有一个函数可以处理这种情况呢?有!它是memmove()

memmove()(专门处理重叠的内存拷贝)

用于处理内存重叠(比如自己拷贝自己)情况的拷贝

void* memmove(void* dest, const void* src, size_t count);

int main(){
    int arr[] = {1,2,3,4,5,6,7,8,9,10};
    int i = 0;
    memmove(arr + 2, arr, 5 * sizeof(int));
    for(i = 0; i < 10; i++){
        printf("%d\n", arr[i]);
    } // 输出1 2 1 2 3 4 5 8 9 10
    return 0;
}

手写memmove

void* my_memmove(void* dest, const void* src, size_t count){
    void* ret = dest;
    assert(dest && src);
    if(dest < src){
        // 从前往后拷贝字符
        while(count--){
            *(char*)dest = *(char*)src;
            ++(char*)dest;
            ++(char*)src;
        }
    }else{
        // 从后往前拷贝字符
        while(count--){
            *((char*)dest + count) = *((char*)src + count);
        }
    }
    return ret;
}

memcmp()

int memcmp(const void* ptr1, const void* ptr2, size_t num);

用于比较从ptr1和ptr2指针开始的num个字节

示例:

#include<stdio.h>
int main(){
    int arr1[] = {1, 2, 3, 4, 5};
    int arr2[] = {1, 2, 5, 4, 3};
    int ret = memcmp(arr1, arr2, 2 * sizeof(int)); // 0
    return 0;
}    

memset()

void* memset(void* dest, int c, size_t count);

示例:

int main(){
    char arr[5] = ""; // \0 \0 \0 \0 \0
    memset(arr, '#', 5); //# # # # # 
    return 0;
}

示例2:

int main(){
    int arr[10] = {0};
    memset(arr, 1, 10); // 意思是改arr的前10个字节,每个字节改成1
    // 01 01 01 01 01 01 01 01 01 01 00 00 00 ...(40个字节)
    return 0;
}