当前位置: 首页 > news >正文

C语言-基础语法学习

简介

C语言最初是由丹尼斯·里奇在贝尔实验室为开发UNIX操作系统而设计的,1972年诞生。它是一门静态类型的、面向过程的、编译型的高级语言。

C语言的特点:

  • 静态类型:声明变量时必须指定数据类型,变量的数据类型在编译时是确定的
  • 编译型:先编译,后执行,编译型语言生成的是可直接分发和执行的二进制文件
  • 面向过程:通过编写函数来实现功能

C语言可以直接和底层硬件交互,适合开发比较靠近硬件的程序,例如操作系统、驱动器等

C语言标准

C语言出现后,出现了多个版本,不同版本之间的C语言各有差异,为了让C语言健壮的发展下去,美国国家标准协会组织了由硬件厂商、软件设计师、编译器设计师等成员成立的标准C委员会,建立了通用的C语言标准。

标准定义了语言的语法和库函数等,确保了不同编译器之间的兼容性和一致性。

C语言标准的不同版本:

  • C89标准:1989年美国国家标准协会(ANSI)通过的C语言标准,人们习惯称之为“ANSI C”,随后的C90和C89是一样的
  • C99标准:1999年ISO(国际标准化组织)和IEC(国际电工委员会)正式发布了C99。C99引入了许多新特性,如内联函数、变量声明可以不放在函数开头、支持变长数组、初始化结构体允许对特定的元素赋值等。
  • C11标准:2011年ISO(国际标准化组织)和IEC(国际电工委员会)正式发布C语言标准第三版草案“C11”。C11提高了C语言对C++的兼容性,并增加了一些新的特性,这些新特性包括泛型宏、多线程、静态断言、原子操作等。
  • C17:对C11的一些小修正。

ANSI C:就是C语言的C89标准,只是人们习惯称呼为ANSI C而已

优缺点

优点:

  • 丰富的运算符:C语言提供了34种运算符
  • 允许直接访问物理地址,对硬件进行操作
  • 代码质量和汇编语言相当
  • 可移植性:C 语言是高度可移植的,在不改动或者只做很小改动的情况下,就可以把C语言的程序运行在不同平台;
  • 语言很小:C 语言完全基于变量、宏命令、函数和架构,整体非常小,因此C语言可以嵌入几乎现代所有微型处理器中,从冰箱到闹钟;
  • 大多数编程语言都是基于C语言实现的:几乎所有编程语言都由C语言实现,或者有着和C语言一样相似的语法和逻辑规则,因此,学会C语言能使程序员很快学会其他语言。

缺点:

  • 面向过程:C语言不支持面向对象编程,这就是为什么创造C++
  • 数据封装性差:由于C语言是面向过程的,所以它的数据封装性差,不如面向对象语言的数据封装性
  • 不安全:指针是C语言的一大特色,可以说是C语言优于其它高级语言的一个重要原因,但也就是因为它有指针,可以直接进行靠近硬件的操作,所以带来很多不安全的因素。
  • 语法限制不严格,对变量的类型约束不严格,对数组下标越界不作检查等

搭建开发环境

Windows

第一步:安装C语言的编译器

  • Windows下是没有C/C++的编译器的,在这里使用MinGW作为Windows平台的C语言编译器
  • MinGW:它是一个GCC的Windows移植版。
  • mingw的官网:http://www.mingw.org/
  • 下载安装MinGW,然后配置环境变量
  • 检测:gcc --version

第二步:安装IDE开发环境,这里选择VS Code,安装好后需要再安装两个插件:C/C++、Code Runner

完成,此时就可以使用C语言开发了。

MinGW

MinGW:Minimalist GNU on Windows,它将开源的C语言编译器gcc移植到了Windows平台下,可以将源代码编译为可以在Windows中运行的可执行程序。MinGW就是gcc的Windows版本,是开源免费的。

网址:https://sourceforge.net/projects/mingw/

下载页面:https://sourceforge.net/projects/mingw-w64/files/,下载 x86_64_win32-seh

安装:安装过程比较复杂,我也没有搞懂,基本原则就是把能勾的都勾选上。安装完成之后配置环境变量

验证安装结果:在命令行运行 gcc -v,查看编译器版本

VS Code

网址:https://code.visualstudio.com/,下载后直接安装即可,安装过程中没有什么特殊的配置。

安装完成后配置插件:Code Runner、C/C++

mac

mac平台上是自带C语言编译器的,可以使用命令’gcc -v’来查看

入门案例

新建文件:hello.c

编写代码:这里使用vs code作为开发工具

#include <stdio.h>

// 第一个C语言程序:hello world
int main() {
    printf("hello world! \n");
    return 0;
}

点击右上角的运行按钮,在控制台打印hello world。

点击运行按钮后,可以看到,控制台使用gcc编译器,执行了编译命令:

cd "e:\c\hello" && gcc hello.c -o hello && "e:\c\hello"hello

打开 hello.c 文件所在的文件夹,可以看到,有一个名为hello的exe程序,它是Windows平台上的可执行程序

可执行程序:可以被操作系统运行的程序。在window平台,后缀名为.exe的文件是一个可执行程序

代码讲解

程序的第一行:#include <stdio.h>

  • #include:C语言的预处理指令之一。预处理是在编译之前做的处理,预处理指令一般以 # 开头。#include 指令后面会跟着一个文件名,预处理器发现 #include指令后,就会根据文件名去查找文件,并把这个文件的内容包含到当前文件中
  • stdio.h:standard input/output,标准输入输出头文件,它包含了与输入输出相关的函数。这里之所以包含 stdio.h 文件,是因为在后面用到了在 stdio.h 内部声明的printf函数,这个函数可以向标准输出设备输出数据

第一行后面的内容:定义了一个main方法,每个C语言编写的程序都是从main方法开始执行,main方法中的内容:printf函数,向控制台输出数据;return语句,设置函数的返回值

main函数:一个程序必须有一个main函数,被称为主函数,是程序的入口函数,它的返回值是 int 类型。

main函数在C语言中的两种写法:

  • int main();
  • int main(int argc, char* argv[])

基础语法

注释

注释是对于代码的解释,方便用户理解代码,注释中的内容不会被执行。

注释的分类和格式:

  • 单行注释://

  • 多行注释: /* 注释内容 */ ,多行注释之间不可以嵌套

标识符

标识符:变量、方法的名称。用户起的名字,就叫标识符

标识符的命名规范:

  • 标识符只能由字母、数字和下划线组成。
  • 标识符不能以数字作为第一个字符。
  • 标识符不能使用关键字。
  • 标识符区分大小写字母,如add、Add和ADD是不同的标识符。

变量

变量的声明和初始化:

  • 声明:数据类型 变量名;
  • 初始化:变量名 = 值;
  • 同时声明和初始化:数据类型 变量名 = 值;

变量名的命名规范:

  • 变量名由字母、数字、下划线和美元符组成
  • 不能以数字开头
  • 不能和关键字重名

案例:

#include <stdio.h>

int main() {
    // 定义一个int类型的变量
    int a = 1;
    // 打印该变量
    printf("%d\n", a);  // 1
    return 0;
}

字面量

在一条赋值语句中,等号右侧的值称为字面量

整型字面量:整数类型的字面量,依据进制分为四种:

  • 二进制整数:以0b开头,如0b100,0B101011。
  • 八进制整数:以0开头,如0112,056。
  • 十进制整数:无特殊开头,如2,-158,0。
  • 十六进制整数:以0x开头,如0x108,-0X29。

小数型字面量:小数在计算机中使用浮点数表示,所以也称为浮点型,还可以称为实型。小数型字面量有两种形式

  • 十进制小数形式:由数字和小数点组成(注意:必须有小数点),如12.3、-45.6、1.0等。
  • 指数形式:又称科学计数法,由于计算机输入输出时,无法表示上角或下角,所以规定以字母e或E表示以10为底的指数,如12.34e3,代表12.34乘以10的3次方
    • “e”或“E”之前必须有数字,
    • “e”或“E”后面必须为整数,如不能写成e4、12e2.5等。

字符常量:字符必须放在单引号中,C语言中的字符常量共计128个,它们都收录在ASCII码表中,字符常量有两种:

  • 普通字符:用单引号括起来的单个字符,如:‘a’、‘8’、‘!’、‘#’。
  • 转义字符:由单引号括起来的包括反斜杠(\)的一串字符,如‘\n’、‘\t’、‘\0’等。转义字符表示将反斜杠后的字符转换成另外的意义,通常用来表示不能正常显示的字符,‘\n’、‘\t’、‘\0’这三个转义字符分别表示换行、TAB制表符和空字符。

字符串常量:字符串由多个字符组成,必须放在双引号中,例如“hello”、“123”、“itcast”等

基本数据类型

基本数据类型不可再分,是CPU可以直接运算的类型

整数类型

整数有3种数据类型:short、int、long

案例:

#include <stdio.h>

int main() {
    // short、int、long类型的变量
    short v1 = 1;
    int v2 = 2;
    long v3 = 3;

    printf("v1 == %d\n", v1);
    printf("v2 == %d\n", v2);
    printf("v3 == %ld\n", v3);

    printf("sizeof(short)=%ld字节\n", sizeof(short)); // 2字节
    printf("sizeof(int)=%ld字节\n", sizeof(int));     // 4字节
    printf("sizeof(long)=%ld字节\n", sizeof(long));   // 8字节
}

在上面的案例中,展示了short、int、long的用法,但是在实际的概念中,short是short int的简写,long是long int的简写,此外,还有long long int,也可以简写为long。

在实际使用过程中,只会定义short、int、long三种类型的整数变量,short int、long int、long long都不太常用。

整型数据可以被修饰符signed和unsigned修饰。

  • signed:如果没有声明singed或unsigned,那么默认被signed修饰,此时,整数的第一位表示符号位
  • unsigned:修饰 short、int、long,表明它们是一个无符号整数,整数的第一位表示数字,无符号整数不可以表示负数

案例:

#include <stdio.h>

int main() {
    unsigned short v7 = 7;
    unsigned int v8 = 8;
    unsigned long v9 = 9;

    printf("v7 == %d\n", v7);
    printf("v8 == %d\n", v8);
    printf("v9 == %ld\n", v9);

    printf("sizeof(unsigned short)=%ld字节\n", sizeof(unsigned short)); // 2字节
    printf("sizeof(unsigned int)=%ld字节\n", sizeof(unsigned int));     // 4字节
    printf("sizeof(unsigned long)=%ld字节\n", sizeof(unsigned long));   // 8字节
}

如果变量超过数据范围,c语言不会报错,而是会随机赋一个值,这需要开发者自己注意

小数类型

小数在内存中使用浮点数进行存储。

浮点数:以科学计数法的形式来存储小数。小数用科学计数法表示的时候,小数点是可以浮动的,如1234.5可以表示成12.345*10^2,也可以表示成1.2345*10^3,所以这时又称小数为浮点数。

科学计数法:把一个数表示成a*10^n (1<=a<10, n>0)的形式,就是科学计数法,a是有效数字,n是指数,n是几,a中的小数点向右移动几位。使用科学计数法可以用简单的形式表示一些很大的数字。指数指明了数的数量级,尾数指明了数的精确度

浮点数的组成部分:符号位、指数位、尾数位。

  • 符号位:表示浮点数的正负,0表示正数,1表示负数
  • 指数位:科学计数法中的指数,浮点数中的指数位采取移位存储的方式,为了表示正负指数
  • 尾数位:科学计数法中的有效数字部分,浮点数中的有效数字部分总是在1到2之间,所以小数点前的1可以省略不存储

小数的数据类型:float和double,一个小数默认是double类型。

  • float:单精度浮点型,
    • 长度:4字节,
    • 组成部分:1比特符号位,8比特指数位,23比特尾数位,指数位采取移位存储的方式,偏移量为127,指数的取值范围是 -127 ~ 128,
    • 取值范围:2的正负128次方左右
  • double:双精度浮点型,
    • 长度:8字节,
    • 组成部分:1比特符号位,11比特指数位,52比特尾数位,指数位采取移位存储的方式,偏移量为1023,指数的取值范围是 -1023 ~ 1024,
    • 取值范围:2的正负1024次方左右
  • long double:比double能表示的范围更广

案例:

#include <stdio.h>

int main() {
    float f1 = 1.3;
    double d1 = 1.4;
    long double ld1 = 1.6;

    printf("f1 == %f\n", f1);
    printf("d1 == %f\n", d1);
    printf("ld1 == %Lf\n", ld1);

    printf("sizeof(float) == %lu字节\n", sizeof(float));    // 4字节
    printf("sizeof(double) == %lu字节\n", sizeof(double));  // 8字节
    printf("sizeof(long double) == %lu字节\n", sizeof(long double));  // 16字节

    printf("sizeof(2.56) == %lu字节\n", sizeof(2.56));  // 8字节
    printf("sizeof(2.56f) == %lu字节\n", sizeof(2.56f));// 4字节
}

浮点数的精度问题:使用二进制无法准确地表示小数,例如0.9,在转换为二进制后是一个无限循环小数 0.1110011,0011无限循环,对于某些在二进制下无法准确表示的小数,势必要进行四舍五入,精确到某一位,这就是浮点数的精度。double能提供1516位有效位数,float能提供67位有效位数。

字符类型

C语言中的字符:ASCII码表中的字符或者 -128到127 之间的整数。C语言使用char来表示字符类型。字符要放在单引号中,每个字符变量都会占用1个字节。

案例:

#include <stdio.h>

int main() {
    // 声明字符类型的变量
    char c1 = 'A';
    char c2 = 98;

    // 打印字符类型的变量
    printf("c1 == %c\n", c1);
    printf("c2 == %c\n", c2);
    printf("sizeof(char)=%ld字节", sizeof(char));  // 1字节
}
ASCII码表

ASCII:美国信息交换标准码,American Standard Code for Information Interchange。ASCII编码是一个标准,其内容规定了把英文字母、数字、标点、字符转换成计算机能识别的二进制数的规则,并且得到了广泛认可和应用。

ASCII码表大致由三部分组成:

  • ASCII非打印控制字符:ASCII表上的数字0-31分配给了控制字符,用于控制打印机等一些外围设备。
  • ASCII打印字符:数字32-126分配给了能在键盘上找到的字符,
  • DELETE命令:数字127代表DELETE命令

参考:https://www.asciim.cn/

基本数据类型的长度

每种数据类型的长度是由系统架构决定的,但是C语言保证short不会比int长,long不会比int短。

ANSI C规范标准确定了每种类型的最小值:

  • int 占据至少两个字节
  • short 占据至少两个字节
  • long 占据至少四个字节。
sizeof运算符

sizeof:C语言中的关键字,打印变量或数据类型的内存大小,返回一个无符号long类型的值

案例:

#include <stdio.h>

int main() {
    int v1 = 1;
    
    printf("%ld\n", sizeof v1);   // 4
    printf("%ld\n", sizeof(int)); // 4
    
    unsigned long v4 = sizeof v1;
    printf("%ld\n", sizeof(v4));  // 8
}

sizeof的使用,有两种语法:

  • sizeof(变量或数据类型);
  • sizeof 变量; // 这种格式只能用于变量

数据类型转换

数据类型转换:变量由一种数据类型转换为另一种数据类型,这两种数据类型必须是兼容的。

数据类型转换的分类:隐式类型转换和显示类型转换

  • 隐式数据类型转换:从下往上转换,是由系统自动进行,将占用内存小的数据类型转换为占用内存大的数据类型。
  • 显示数据类型转换:从上往下转换,需要用户手动进行,将占用内存大的数据类型转换为占用内存小的数据类型。

格式:数据类型 变量名 = (数据类型) 变量

案例:

#include <stdio.h>

int main() {
    // 隐式数据类型转换。int和float类型的数据相加,使用float来接收返回值,
    // 因为float表示的范围比int广
    int a = 10;
    float b = 10.1;
    float c = a + b;
    printf("隐式数据类型转换:%f\n", c);   // 20.100000

    // 显式数据类型转换,int和float类型的数据相加,把结果强转为int。
    int d = (int) a + b;   // 在实际开发中,发现如果不强转,结果和强转是一样的。
    printf("显示数据类型转换:%d\n", d);   // 20
    return 0;
}

变量的作用域

依据作用域来对变量进行分类:

  • 全局变量:定义在函数外的变量,C语言是面向过程的语言,只有函数没有类,定义在函数外的变量就是全局变量

  • 局部变量:定义在函数内的变量就是局部变量

变量的初始化:

  • 全局变量如果没有初始化,会赋一个默认的初始值
  • 局部变量如果没有初始化,不会赋默认的初始值,所有局部变量在声明时最好赋一个默认的初始值

在不考虑动态内存分配的前提下,分配给局部变量的内存会在函数结束之后立即释放。全局变量只在程序结束时才会释放。

案例:

#include <stdio.h>

// 全局变量
int global_int = 1;
void main() {
    // 局部变量
    int local_int = 2;

    printf("全局变量:%d \n", global_int);
    printf("局部变量:%d \n", local_int);
}

常量

使用const修饰的变量称为常变量,需要注意的是,虽然理论上常变量不能被修改,但C语言中仍能通过指针间接更改常变量的值。

案例:

#include <stdio.h>

int main(int argc, char const *argv[]) {
    const int a = 10;

    // 这一行会报错,expression must be a modifiable lvalue 表达式必须是一个可以被修改的左值,
    // 因为变量a的值不可以被修改
    // a = 11; 

    return 0;
}

输入输出

在之前学习变量的时候,会使用printf函数,向控制台输出变量的值,不同类型的变量,需要使用不同的格式占位符,例如 %d、%f、%c等,接下来详细地学习printf函数,并且学习从控制台接收数据、把数据打印到控制台。

实现输入输出功能的函数:

  • scanf函数:接收用户输入的数据,这个函数会阻塞地等待,直到用户输入回车符,然后返回用户输入的数据
  • printf函数:向控制台打印数据

案例:

#include <stdio.h>

int main()  {
    int number = 0;
    printf("你好,请输入一个数字:");
    // scanf函数。在这里使用 ‘&变量’ 的形式来获取变量地址
    scanf("%d", &number);

    printf("%d的平方: %d\n", number, number * number);
    return 0;
}

scanf函数

scanf函数:C语言标准库中的函数,位于stdio.h文件中,用于从标准输入流中读取数据,它可以按照指定的格式化字符串,将输入数据转换为相应的数据类型,并将其存储到指定的变量中。

  • 函数签名:int scanf(const char *, ...)
  • 参数:
    • 参数1:一个格式化字符串,用于指定要读取的数据类型和数据的格式。格式化字符串是包含格式占位符的字符串,格式占位符,就是形如%d、%s等类型的字符串,在最终结果中,这些占位符会被变量替换,替换规则是格式化字符串中的第一个占位符被随后第一个变量替换,依次类推。不同数据类型,会有不同的占位符。
    • 参数2:可变参,是存储数据的变量地址

案例:scanf("%d", &a);,从控制台读取一个整数类型的数据,存储到变量a中,‘&变量’ 的含义是获取变量的内存地址,在随后学习指针的过程中会接触到。

格式占位符和它对应的数据类型

  • %d:int类型、十进制整数
  • %ld:long类型、十进制整数
  • %f:float,单精度浮点数;
  • %lf:double,双精度浮点数;
  • %c:char,读取单个字符;
  • %s:字符串,以空格、制表符或换行符作为分隔符
  • %p:指针,实际是以十六进制的形式打印数字

printf函数详解

把数据打印到控制台,是标准库中的函数。

printf函数的格式:printf(“格式化字符串”, 变量1, 变量2, …);

printf函数中格式化字符串和scanf函数中的是一致的。

运算符

算术运算符:

  • +:加法运算符
  • -:减法运算符
  • *:乘法运算符
  • /:除法运算符
  • %:取模运算符,取除法运算的余数。
  • ++:自加运算符,对变量加1。自加运算符是单目运算符,只有一个操作数,
    • 如果运算符出现在操作数的右边:先把操作数放入表达式中运算,然后才把操作数加1
    • 如果运算符出现在操作数的左边:先把操作数加1,然后再把操作数放入表达式中运算
  • --:自减运算符,对变量减1,和自加运算符一样的使用规则

关系运算符:==、!=、>=、>、<=、<

逻辑运算符:使用逻辑运算符,将多个条件表达式连接在一起,判断它们作为一个整体的对错。

  • ! :逻辑取反,表达式为true,结果为false
  • &&:逻辑与,多个表达式全部为true,结果为true
  • ||:逻辑或,多个表达式只要有一个为true,结果为true

三目运算符:expression ? v1 : v2,如果 expression 为 true,结果是 v1,否则是 v2

位运算符:程序中的所有数在计算机内存中都是以二进制的形式储存的,位运算就是直接对存储在内存中的二进制位进行操作,位运算针对整数,小数的存储和计算和整数的不同,无法使用位运算。

  • &,按位与,遇0则0,通常用于二进制的取位操作,例如一个数 and 1 的结果就是取二进制的最末位,这可以用来判断一个整数的奇偶。
  • |,按位或,遇1则1,通常用于二进制特定位上的无条件赋值。
  • ^,按位异或,相同为0,不同为1,一个数被同一个数异或两次,结果等于它本身,可以用于加密数据
  • ~,按位取反,一元运算符,二进制数 0 变 1, 1 变 0
  • <<,左位移,二进制数整体向左位移多少位,右边补 0, // 二进制数在计算机中以补码的形式存储,没有符号位,读取时在转换成原码后才会有符号位,
  • >>,右位移,二进制数整体向右位移多少位,左边补上符号位,正数补 0,负数补 1

赋值运算符:等号拼接算术运算符和位运算符

sizeof运算符:计算变量的内存大小。

案例:

#include <stdio.h>

int main() {
    int a = 3;
    int b = 4;
    printf("变量a = %d\n", a);
    printf("变量b = %d\n", b);

    // 算术运算符
    printf("算术运算符:\n");
    printf("a + b = %d\n", a + b);
    printf("a - b = %d\n", a - b);
    printf("a * b = %d\n", a * b);
    printf("a / b = %d\n", a / b);
    printf("a %% b = %d\n", a % b);
    int c = a++;
    printf("int c = a++, c = %d, a = %d\n", c, a);
    int d = ++a;
    printf("int d = ++a, a = %d, d = %d\n", d, a);
    printf("\n");

    a = 3;
    b = 4;

    // 位运算符
    printf("位运算符:\n");
    printf("a & b = %d\n", a & b);  // 按位与,遇0则0,3 & 4 = 0011 & 0100
    printf("a | b = %d\n", a | b);  // 按位或,遇1则1
    printf("a ^ b = %d\n", a ^ b);  // 按位异或,相同为0,不同为1
    printf("~a = %d\n", ~a);   // 按位取反
    a = 3;
    printf("a << 1 = %d, a = %d\n", a << 1, a);
    printf("a >> 1 = %d, a = %d\n", a >> 1, a);

    return 0;
}

运算符的结合性

同样优先级的运算符是从左到右计算还是从右到左计算称为运算符的结合性(Associativity)。+ - * /是左结合的,等号是右结合的。

运算符的优先级

在C语言中后缀运算符的优先级最高,单目运算符的优先级仅次于后缀运算符,比其它运算符的优先级都高

流程控制语句

控制语句用来控制代码的执行顺序,1996年,两位计算机科学家证明:任何简单或复杂的算法都可以由顺序结构、选择结构、循环结构三种基本结构构成。

代码的三种基本结构:

  • 顺序结构:代码依照顺序依次执行
  • 选择结构:程序根据某个特定的条件,选择某个分支进行执行
  • 循环结构:反复执行某些操作,直到某条件是真或是假时才停止循环

顺序结构

没有什么好讲的,之前都是顺序结构,从上到下依次执行

选择结构

程序根据某个特定的条件,选择某个分支进行执行

if else

格式:

if(条件表达式1) {
   代码
} else if(条件表达式2) {
   代码
} 
...
else {
   代码
}

执行逻辑:从上向下执行,如果某个表达式为真,执行它对应的代码块,然后退出if结构,如果没有表达式为真并且有else语句,则执行else语句

使用if、else语句时有一条基本原则,总是优先把包含范围小的条件放在前面处理。

案例:

#include <stdio.h>

int main() {
    int num = 98;

    // 判断分数区间并打印一句话
    if (num = 100) {
        printf("您的分数是满分:%d\n", num);
    } else if (num > 90) {
        printf("您的分数是优:%d\n", num);
    } else if (num > 80) {
        printf("您的分数是良:%d\n", num);
    } else {
        printf("您的分数是及格:%d\n", num);
    }

    return 0;
}
switch case

格式:

switch(变量) {
    case1:
        语句块1;
        [break;]
    case 值n:
        语句块n;
        [break;]
   ... 
    default:
        语句块n+1;
        [break;]
}

执行逻辑:

  • 如果某个值等于变量的值,执行它所对于的case语句,
  • 如果没有break语句,那么执行完对应的case语句之后会接着执行后面的case语句,
  • 如果没有值和变量的值相同,那么执行default语句。

注意:case语句中不能声明变量,如果要声明变量,case语句后要加大括号

案例:

#include <stdio.h>

int main() {
    // 打印当前星期的中文名称
    int week = 1;

    switch (week) {
        case 1:
            printf("星期一\n");
            break;
        case 2:
            printf("星期二\n");
            break;
        case 3: 
            printf("星期三\n");
            break;
        case 4:
            printf("星期四\n");
            break;
        case 5:
            printf("星期五\n");
            break;
        case 6:
            printf("星期六\n");
            break;
        case 7:
            printf("星期日\n");
            break;
        default:
            printf("星期数不正确");
    }

    return 0;
}

循环结构

反复执行某些操作,直到某条件是真或是假时才停止循环。

for

案例:

#include <stdio.h>

int main() {
    for(int i = 1; i <= 5; i++) {
        printf("i = %d\n", i);
    }
    return 0;
}
while

案例:

#include <stdio.h>

int main() {
    int i = 1;
    while(i <= 5) {
        printf("i = %d\n", i);
        i++;
    }
    return 0;
}
do while

案例:

#include <stdio.h>

int main() {
    int i = 1;
    do {
        printf("i = %d\n", i);
        i++;
    } while (i <= 5);
    return 0;
}

continue

跳出本次循环,进入下一次循环

break

跳出当前循环,如果是双层循环,break只会跳出内层循环

goto

无条件跳转语句,可以跳转到指定位置

格式:goto 语句标记;

案例:在这个案例中,程序是一个死循环

#include <stdio.h>

int main() {
    hello:                         //hello是语句标记,其后跟冒号
    printf("hello");

    int a = 3;
    int b = 4;
    printf("a + b = %d\n", a + b);

    printf("跳转到函数开头\n");  
    goto hello;                    //跳转到hello标记处执行代码

    return 0;
}

goto会导致程序的流程变得难以控制,不推荐使用goto语句

代码练习-终止嵌套循环

break语句只支持终止单层循环,在多层循环中,如果在内层循环想要退出所有循环,可以使用一个变量来实现这个功能。

案例:

#include <stdio.h>

int main(int argc, char const *argv[]) {
    int should_terminate = 0;
    for (int i = 1; i < 10; i++) {
        printf("i == %d\n", i);
        for (int j = 11; j < 20; j++) {
            printf("    j == %d\n", j);
            if (j == 13) {
                should_terminate = 1;
                break;
            }
        }
        if (should_terminate) {
            break;
        }
    }
    return 0;
}

指针

指针:如果一个变量中存储了另一个变量的内存地址,那么这个变量就是指向另一个变量的指针,称之为指针或指针变量。变量的本质是内存地址的别名,通常这个内存地址中存储了某个数据,如果内存地址中存储了另一个内存地址,就是当前内存地址指向另一个内存地址,当前变量是指向另一个变量的指针。

指针相关的语法:

  • 获取变量的内存地址:使用 & 来获取变量的内存地址

  • 声明一个指针变量:数据类型 *变量名 = 内存地址;

指针变量的数据类型:

  • 从语法上看,指针的数据类型是去掉指针名以后剩下的,指针指向的数据类型是去掉指针名和 * 剩下的。
  • 指针指向的变量的数据类型决定了指针在内存中的寻址能力,如char类型决定了指针指向1个字节地址空间,int类型决定了指针变量指向4个字节地址空间。

案例:

#include <stdio.h>

int main() {
    int a = 10;
    printf("变量a的值:%d\n", a);

    // 打印变量a的内存地址
    printf("变量a的内存地址:%p\n", &a);  // 0x16d5a7318

    // 声明一个指针变量
    int* p1 = &a;
    printf("指针变量的值:%p\n", p1);
    printf("指针变量的长度:%lu字节\n", sizeof(p1));  // 8字节
}

指针变量的长度取决于内存地址的大小,这只与操作系统有关:

  • 在32位操作系统中,指针的大小是4个字节;
  • 在64位操作系统中,指针的大小是8个字节。

指针间接访问:指针中存储了某个变量的内存地址,通过指针来间接地访问该内存地址存储的值,称为指针间接访问,也叫做解引用。

  • 格式:*指针变量

案例:

#include <stdio.h>

int main() {
    int a = 10;
    int* p1 = &a;

    // 指针间接访问
    printf("指针变量指向的地址中存储的值:%d\n", *p1);  // 10
}

指针的计算

指针变量支持两种计算:

  • 指和整数相加减:指针变量的加减运算实质上是指针在内存中的移动,这种移动以指针指向的变量的数据类型为单位
  • 两个指针变量之间相减

指针运算和普通的算术运算的区别在于,指针预算的本质是改变指针指向的内存地址,这种运算方式常用于连续内存空间的相关操作,如数组、动态内存分配的空间。

案例:

#include <stdio.h>

int main() {
    // 栈内存内的两个int类型的变量a、b,栈内存是向下生长,
    // 变量a的地址值减1个单位,就是变量b的地址值
    int a = 10;
    int b = 11;
    int* p1 = &a;
    int* p2 = &b;

    printf("变量a的值:%d\n", a);   // 10
    printf("变量b的值:%d\n", b);   // 11
    printf("变量a的内存地址:%p\n", p1);  // 0x7ffcf38ad4b4
    printf("变量b的内存地址:%p\n", p2);  // 0x7ffcf38ad4b0
    
    // 指针运算
    int* p3 = p1 - 1;
    printf("变量a的内存地址减1,就是变量b的内存:%d\n", *p3);  // 11
}

指针[下标]

指针[下标]这种语法与*(指针 + 下标)是等价的

空指针 NULL

空指针:表明指针指向的地址是不可读取也不可以写入的,空指针使用NULL来表示。

NULL:一个宏常量,声明在stdio.h中:#define NULL ((void *) 0),将一个 0 值强制转换为一个 void * 类型的指针,0 值内存是不可以访问的。

在程序中,有时可能需要用到指针,但是又不确定指针在何时何处使用,可以先使定义好的指针指向空。

案例:

// 空指针
int* p1 = NULL;
printf("NULL:%p\n", p1);

// (void*)0
int* p2 = (void*)0;
printf("(void* 0):%p\n", p2);

野指针

一个指向无效的内存空间的指针

野指针形成的原因:

  • 指针变量定义后未初始化。定义的指针变量若没有被初始化,指针变量的值是一个随机值,指向系统中任意一块存储空间,这种未知指向的指针就是野指针,若该指针非法访问内存单元,会出现程序奔溃。
  • 指针指向了一个已释放的内存空间

在编程中应当确保不会出现野指针,最好将未初始化的指针和释放指向内存空间的指针赋值为NULL,防止意外操作野指针。

无类型指针

void:代表空,没有值,比如函数的返回值是void,就是函数没有返回值。

无类型指针:void *p,又称为void类型的指针、通用指针。无类型指针只有内存块的地址信息,没有类型信息。任一类型的指针都可以转为 void 指针,而 void 指针也可以转为任一类型的指针。

  • 作用:指针的类型可以帮助编译器知道,如何解读内存中存储的数据,但是,在向系统请求内存的时候,有时不确定会有什么样的数据写入内存,需要先获得内存块,稍后再确定写入的数据类型,这个时候就需要用到无类型指针
  • 注意,不能用 * 运算符来取出无类型指针指向的值,访问无类型指针指向的数据会提示“不允许使用不完整类型”

案例:

#include <stdio.h>

// 无类型指针
int main(int argc, char const *argv[]) {
    printf("void类型的长度:%lu字节\n", sizeof(void));  // 1字节
    
    int a = 10;
    // 无类型指针,指向一个int类型的变量
    void * p1 = &a;
    // 将无类型指针强转为int类型的指针,然后取出指针指向的变量中的数据
    int b = *(int *) p1;
    printf("b == %d\n", b);

    // int类型的指针
    int * p2 = &a;
    // 将int类型的指针转换为无类型的指针
    p1 = p2;
    return 0;
}

常量指针

指向常量的指针,指针指向的数据是不可变的。

定义常量指针的案例:

  • const int * p = &a;
  • int const * p = &a; // const修饰数据类型,表示指针指向的数据是不可变的

案例:

#include <stdio.h>

int main() {
    // 普通指针
    int a = 10;
    int* p = &a;
    printf("变量a的值:%d\n", a);
    printf("指向变量a的指针:%p\n", p);
    // 通过指针修改变量
    *p = 11;
    printf("通过指针修改变量a的值:%d\n", a);


    // 常量指针
    int a2 = 20;
    const int* p2 = &a;
    printf("变量a2的值:%d\n", a2);
    printf("指向变量a2的指针:%p\n", p2);
    // 通过指针修改变量
    // 这一行会编译失败,报错:error: read-only variable is not assignable,
    // 只读变量不可以赋值
    *p2 = 11;
    printf("通过指针修改变量a的值:%d\n", a2);
}

指针常量

指针是一个常量,表示指针本身不可变,但是指针指向的数据可以变

定义指针常量的案例:int * const p = &a;,const的作用是限制指针本身不可变,但是指针指向的数据是可变的。

案例:

#include <stdio.h>

int main() {
    int a = 10;
    int b = 11;
    printf("变量a的值:%d\n", a);

    // 定义常量指针
    int* const p = &a;

    // 修改指针指向的变量的值
    *p = 12;
    printf("变量a的值:%d\n", a);

    // 修改指针本身的值
    // p = &b;  // 这一行会报错,variable 'p' declared const here
    return 0;
}

常量的常指针

指针常量加常量指针,不可以修改指针的值,也不可以修改指针指向的值。

定义格式:const 数据类型* const 变量名 = 值

案例:

#include <stdio.h>

int main() {
    int a = 10;
    // 常量的常指针
    const int* const p = &a;
    printf("%d", *p);

    // 不可以通过指针修改变量
    // *p = 11; // 报错:read-only variable is not assignable

    // 不可以修改指针
    // cannot assign to variable 'p' with const-qualified type 'const int *const'
    // p = 11;   
    return 0;
}

二级指针

指向指针的指针,使用二级指针可以间接修改一级指针的指向,也可以修改一级指针指向的变量的值。

定义格式:数据类型 **变量名 = 值

案例:

#include <stdio.h>

int main() {
    int a = 10;
    // 声明一个指针
    int *p = &a;

    printf("变量a的值:a = %d\n", a);                  // 10
    printf("变量a的地址值:&a = %p\n", &a);             // 0x16d173338

    printf("创建一个指针p,指向变量a\n");
    printf("指针p的值:p = %p\n", p);                   // 0x16d173338
    printf("指针p的地址值:&p = %p\n", &p);              // 0x16d173330
    printf("通过指针p来获取变量a的值:*p = %d\n", *p);     // 10

    // 二级指针:指向一个指针的指针
    int **pp = &p;
    printf("指针pp的值:pp = %p\n", pp);                 // 0x16d173330
    printf("指针pp的地址值:&pp = %p\n", &pp);            // 0x16d173328
    printf("通过指针pp来获取指针p的值:*pp = %p\n", *pp);   // 0x16d173338

    printf("指针变量的长度:%lu\n", sizeof(int*));         // 8

    return 0;
}

受限指针

被restrict关键字修饰的指针,告诉编译器,该块内存区域只有当前指针一种访问方式,其他指针不能读写该块内存。

案例:

#include <stdio.h>
#include <stdlib.h>

// 受限指针
int main(int argc, char const *argv[]) {
    // 声明一个受限指针
    int * restrict p = malloc(4);

    // 通过受限指针来操作内存
    *p = 10;

    // 把受限指针赋值给另一个指针,可以访问。所以在这里并不清楚受限指针的具体作用
    int * p1 = p;
    *p1 = 11;
    printf("%d\n", *p1);

    return 0;
}

注意事项

指针常量和常量指针的区别

常量指针:指向常量的指针,指针本身可以改变,指向不同的地址,但是它所指向的数据不可以修改。

  • 格式:const 数据类型 *变量,可以理解为,const修饰数据类型,表示指针指向的数据不可变,但是指针本身可变,可以改变指向。

指针常量:指针是一个常量,可以和指针变量类比,普通指针就是指针变量。指针常量,指针本身不可变,一旦初始化就不可以改变,但是可以通过指针改变它指向的数据,如果数据本身可以被改变的话。

  • 格式:数据类型 * const 变量,可以理解为,const修饰指针,指针不可变,但是指针指向的数据可变。

案例:常量指针

#include <stdio.h>

int main(int argc, char const *argv[]) {
    const char *s = "aaa";
    printf("%s\n", s);   // aaa
    s = "bbb";           // 常量指针,指针可变
    printf("%s\n", s);   // bbb
    return 0;
}

案例:指针常量

#include <stdio.h>

int main(int argc, char const *argv[]) {
    char buf[] = "aaa";
    char * const s = buf;
    printf("%s\n", s);   // aaa
    s[0] = 'b';          // 指针常量,指针不可变
    printf("%s\n", s);   // baa
    return 0;
}
解引用运算符的计算结果是一个左值
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int main(int argc, char const *argv[]) {
    int a = 10;
    int *p = &a;
    *p = 11;
    printf("%d\n", *p);  // 解引用运算符的计算结果,是一个左值
    return 0;
}

数组

数组:相同数据类型的变量的集合,数组在内存中是连续的,数组的大小是固定的,一旦指定,就不可以修改

数组的使用:

  • 声明和初始化数组和格式:数据类型 变量名[长度]= {元素1, 元素2, ...};,元素个数和声明的长度可以不对应

  • 索引:表示元素在数组中的位置,从0开始。格式:数组名[索引];

数组的内存地址:

  • 数组名本质上是一个指针,它记录了数组在内存空间的起始地址,它的值不可以被更改,所以它是一个指针常量。

  • 编译器按内存地址递减的方式为数组分配内存。

  • 数组步长:相邻数组元素之间的内存地址距离,由数组类型决定。char类型的数组,由当前元素到下一个元素,跨越了1个字节内存,步长为1字节; double类型的数组,由当前元素到下一个元素,跨越了8个字节内存,步长为8字节。

注意事项:

  • C语言不会对数组的访问进行边界检查:C语言是不安全的编程语言,访问数组时不进行边界检查,当访问超出范围的数组元素时,虽然编译器会发出警告,但并不会阻止程序运行,程序会按数组步长依次读取内存

  • 数组名在表达式中实际是一个指针:数组名如果出现在一个表达式中,会被转换为数组中第一个元素的指针,这个指针是一个指针常量而不是一个指针变量,它的值是不可变的,只有在sizeof操作符中是例外,因为 sizeof 使用的是类型信息而不是内存信息。之所以会出现这种情况,是因为表达式只能获取到数组的内存而不是类型信息

案例:

#include <stdio.h>

int main() {
    // 定义一个数组
    int arr[5] = {1, 2, 3, 4, 5};

    // 访问数组中的元素
    printf("数组中的第二个元素:%d\n", arr[1]); // 数组下标从0开始

    // 获取数组的长度:计算方式是数组在内存中的大小除以元素数据类型的大小
    printf("数组的长度:%d\n", sizeof(arr) / sizeof(int)); 

    // 遍历数组
    printf("遍历数组:");
    for(int i = 0; i < sizeof(arr) / sizeof(int); i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    // 数组的内存分析
    printf("数组遍历的内存地址:%p\n", &arr);           // 000000000061FE00
    printf("数组中第一个元素的内存地址:%p\n", &arr[0]);  // 000000000061FE00
    printf("数组中第二个元素的内存地址:%p\n", &arr[1]);  // 000000000061FE04
    printf("数组中第三个元素的内存地址:%p\n", &arr[2]);  // 000000000061FE08
    printf("数组中第四个元素的内存地址:%p\n", &arr[3]);  // 000000000061FE0C
    printf("数组中第五个元素的内存地址:%p\n", &arr[4]);  // 000000000061FE10

    return 0;
}

数组名是一个指针常量,不可以对其进行赋值。可以使用数组名索引的方式修改字符数组中某个位置的单个字符。

案例:

#include <stdio.h>

int main() {
    int arr1[3] = {1, 2, 3};
    int arr2[3] = {4, 5, 6};

    printf("%p\n", &arr1);     // 000000000061FE14
    printf("%p\n", &arr1[0]);  // 000000000061FE14
    printf("%d\n", *arr1);     // 1
    
    // 这一行会报错,expression must be a modifiable lvalue,
    // 不可以对数组变量进行赋值,因为它本质上是一个指针常量
    // arr1 = arr2;

    return 0;
}

二维数组

数组中的元素是一个数组。

声明和初始化二维数组的格式:

  • 数据类型[数组的长度][数组中元素的长度] = {{元素1, 元素2, ...}, {元素3, 元素4, ...}, ...}

案例:

#include <stdio.h>

int main() {
    // 声明和初始化一个二维数组
    int arr[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};

    // 遍历二维数组
    for(int i = 0; i < 3; i++) {
        for(int j = 0; j < 4); j++) {
            printf("%d ", arr[i][j]);
        }
        printf("\n");
    }
    return 0;
}

变长数组

变长数组:C99标准提出的概念,它是指数组的大小可以是变量,而不用必须是常量。变长数组必须先声明,后初始化,不可以在声明的时候初始化。

案例:

#include <stdio.h>

int main() {
    int a = 3;
    // 声明一个变长数组
    int arr[a];

    // 初始化变长数组
    for(int i = 0; i < 3; i++) {
        arr[i] = i + 1;
    }

    // 遍历变长数组
    for(int i = 0; i < 3; i++) {
        printf("%d\n", arr[i]);
    }
    return 0;
}

在函数中以数组作为参数

把数组作为参数传递:需要传递数组名和长度,传参本质上是内存拷贝,C语言不允许数组拷贝,所以在传参时需要传一个数组名,然后再传一个数组的长度。

案例:

#include <stdio.h>

// 将数组中的所有元素相加,然后返回
int sum(int arr[], int len) {
    int result = 0;

    for (int i = 0; i < len; i++) {
        result += arr[i];
    }

    return result;
}

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    int arr_len = sizeof(arr) / sizeof(int);

    // 把数组作为参数传递:需要传递数组名和参数
    int result = sum(arr, arr_len);
    printf("运算结果:%d\n", result);

    return 0;
}

数组名是一个指针

数组名是一个指针,指向数组中的第一个元素的内存地址。

如果数组是从栈内存中分配地址,也就是说用户在声明数组后没有显示地使用内存分配函数为数组分配内存空间,那么此时数组所使用的内存空间就是在栈内存中分配的,此时,数组名中存储的内存地址,就是它本身的内存地址。

案例:

#include <stdio.h>

int main(int argc, char const *argv[]) {
    // 数组名是一个指针,指向数组中的第一个元素的内存地址。
    int arr[3] = {10, 11, 12};
    printf("数组名中存储的地址:%p\n", arr);      // 0xffffdbec80b8
    printf("数组名本身的地址:%p\n", &arr);      // 0xffffdbec80b8
    printf("数组名指向的地址的值:%d\n", *arr);  // 10
    return 0;
}

字符串

字符串是字符的集合,C语言中没有专门处理字符串的类型,它使用字符数组和字符指针来存储字符串

字符串常量

被双引号包裹起来的一系列字符,就是字符串常量,例如"hello world"

字符数组

存储字符串的字符数组必须以’\0’结尾,否则字符数组中只是存储了一堆普通字符。字符串其实就是一个以空字符‘\0’结尾的字符数组,在定义存储字符串的数组时,要手动在数组末尾加上‘\0’,或者直接使用字符串对数组进行初始化。

定义格式:char 数组名[] = 字符串;

案例:

#include <stdio.h>

int main() {
    // 字符数组
    char str1[] = {'h', 'i', '\0'};
    printf("%s\n", str1);

    // 直接把一个字符串常量赋值给一个字符数组
    char str2[] = "hello";
    printf("%s\n", str2);
    // 字符串的长度,在这里实际上是6,因为是 hello + '\0'
    printf("%d\n", sizeof(str2));   // 6

    return 0;
}

字符数组的特点:

  • 当数组名为左值时,它的类型是字符数组;
  • 当数组名为右值时,它的数据类型是字符指针。

字符数组最好在使用时初始化,或者随后使用内存拷贝函数

案例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char const *argv[]) {
    // 方式1:在使用时初始化
    char buf[] = "hello world";
    printf("%s\n", buf);

    // 方式2:声明后使用内存拷贝函数来为字符数组赋值。
    int len = 16;
    char buf2[len];
    memcpy(buf2, "hello world!!", 13);
    printf("%s\n", buf2);

    // 注意,字符数组先声明、后赋值是不可以的,例如,char 数组名[] = "abc",
    // 编译器会把它进行转换为 char 数组名[] = {'a', 'b', 'c', '\0'};,
    // 而字符串常量"abc",实际上是一个指向该字符串的字符指针,所以,先声明
    // 后赋值,buf3是一个字符数组,"abc"是一个字符指针,不可以把一个字符指针
    // 赋值给一个字符数组,但是可以把一个字符数组赋值给一个字符指针。
    char buf3[len];
    // buf3 = "abc";  这一行代码会报错:expression must be a modifiable lvalue
    return 0;
}

字符指针

指向字符串的指针,同时也指向了字符串第1个字符。格式:char *指针名 = 字符串;

案例:

#include <stdio.h>

int main() {
    char str[] = "hello";
    char *p = str;

    printf("%p\n", p);  // 000000000061FE12
    printf("%s\n", p);  // hello

    // 通过字符指针访问字符串中的第一个字符
    printf("%c\n", p[0]);
    // 通过字符指针访问字符串中的第二个字符
    printf("%c\n", *(p + 1));
    return 0;
}

案例:把字符数组赋值给字符指针

#include <stdio.h>

// 把字符数组赋值给字符指针
int main(int argc, char const *argv[]) {
    char str[8] = "hello";
    char * str2 = "1234567";

    printf("字符指针的内存地址:%p\n", str2);      // 0x400760
    printf("字符数组的内存地址:%p\n", str);       // 0xfffff9b8d1d0

    // 赋值
    str2 = str;
    printf("str2 = %s\n", str2); // hello

    printf("赋值后字符指针的内存地址:%p\n", str2);  // 0xfffff9b8d1d0

    return 0;
}

字符数组和字符指针的地址

字符数组指向栈内存,字符指针如果是使用字符常量进行初始化,指向常量区,如果是使用内存分配函数初始化,指向堆内存。

案例:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char const *argv[]) {
    char *s = "hello";
    printf("字符指针:字符串的首地址:%ld\n", s);  // 4195984  常量区

    char s1[] = "hello";
    printf("字符数组:字符串的首地址:%ld\n", s1);  // 140737129012608 栈内存

    char *s2 = (char *)malloc(16);
    printf("指向堆内存的字符指针:字符串的首地址:%ld\n", s2); // 36618256 堆内存
    return 0;
}

字符数组和字符指针的区别

  • 字符串的存储区域:
    • 字符数组:存储在栈中
    • 字符指针:存储在常量区中
  • 字符串是否可以被修改:
    • 字符数组:可以被修改
    • 字符指针:不可以被修改

字符串中的\0字符

字符串中存储一个\0字符

字符串默认以’\0’字符结尾,如果字符串中想要存储一个’\0’字符,该字符必须使用反斜杠进行转义

案例:

#include <stdio.h>

int main() {
    char *p_str = "He\0llo";
    printf("%s\n", p_str);     // He

    char *p_str1 = "He\\0llo";
    printf("%s\n", p_str1);    // He\0llo
    return 0;
}
验证字符以’\0’结尾
#include <stdio.h>

int main(int argc, char const *argv[]) {
    // 一个字符串的长度 = 字符的个数 + 1,因为字符串的末尾有一个 '\0' 字符
    printf("sizeof(\"hello\") = %d\n", sizeof("hello"));  // 6

    // 在这里验证:字符串以'\0'结尾,只要发现'\0',就不会再管后面的字符
    char buf[8] = {'a', 'b', 'c', 'd', 'e', '\0', '\77', '\78'};
    printf("buf = %s\n", buf);   // abcde
    // 结论:确实是这样子

    // 打印字符数组中的字符,这里是可以打印出全部字符的。
    for(int i = 0; i < sizeof(buf); i++) {
        printf("%c (%d)\n", buf[i], buf[i]);
    }
    return 0;
}

操作字符串

字符串的长度 strlen

使用strlen函数来计算字符串的长度,它的声明位于string.h文件中。strlen() 函数用于计算一个字符串的长度,即字符串中字符的个数,不包括字符串结束符 ‘\0’

  • 签名:size_t strlen (const char *__s);
  • 参数:只有一个参数,就是字符串

案例:

#include <stdio.h>
#include <string.h>

int main(int argc, char const *argv[]) {
    // 1、使用字符指针来存储字符串
    char * str = "hello";
    printf("str = %s\n", str);

    // 计算字符串的长度
    // 使用string.h中提供的函数来计算字符串的长度
    printf("strlen(str) = %lu\n", strlen(str));    // 5
    // 不可以使用sizeof来计算字符串的长度,因为这里获取到的实际上是字符指针的长度
    printf("sizeof(str) = %lu\n", sizeof(str));    // 8

    // 2、使用字符数组来存储字符串
    char str2[] = "hello";
    printf("str2= %s\n", str2);

    // 计算字符串的长度
    printf("strlen(str2) = %lu\n", strlen(str2));   // 5
    printf("sizeof(str2) = %lu\n", sizeof(str2));   // 6,因为它计算了字符串最末尾的'\0'

    // 3、一个中文字符在内存中的长度
    char * str3 = "武";
    printf("str3 = %s\n", str3);
    printf("strlen(str3) = %lu\n", strlen(str3));   // 3
    return 0;
}
字符串复制 strcpy strncpy
strcpy函数

字符复制

  • 签名:char *strcpy (char *dest, const char *src)
  • 参数:
    • dest:目标字符数组
    • src:源字符串,如果源字符串是一个字符指针,在复制完字符后,会改变字符指针的指向,源字符串是字符数组则不会

案例:

#include <stdio.h>
#include <string.h>

// 字符串复制
int main(int argc, char const *argv[]) {
    char * str = "hello world";
    printf("str的地址 = %p\n", str);  // 0x4007e8

    // 使用strcpy进行字符串复制,如果目标字符数组不够长,不会报错
    char buf[8];
    strcpy(buf, str);  // 在这里,目标字符数组不够长
    printf("buf = %s\n", buf);
    printf("str的地址 = %p\n", str);  // 0x646c72
    for (int i = 0; i < sizeof(buf); i++) {
        printf("%c (%d)\n", buf[i], buf[i]);
    }
    return 0;
}
strncpy函数

字符复制

  • 签名:char *strncpy (char *dest, const char *src, size_t __n)
  • 参数:
    • dest:目标字符数组
    • src:源字符串
    • n:要复制的字符数

案例:

#include <stdio.h>
#include <string.h>

// 字符串复制
int main(int argc, char const *argv[]) {
    char * str = "hello world";
    printf("str的地址 = %p\n", str);  //  0x400798

    // 使用strncpy进行字符串复制,指定目标字符数组的长度,避免发生段错误
    char buf[8];
    strncpy(buf, str, sizeof(buf) - 1);
    printf("buf = %s\n", buf);
    printf("str的地址 = %p\n", str);  // 0x400798

    return 0;
}
字符串比较 strcmp函数

strcmp函数:比较两个字符串是否相等

  • 签名:int strcmp (const char *__s1, const char *__s2)
  • 参数:两个参数,分别是两个字符串
  • 返回值:如果两个字符串相同,返回0,如果不想同,返回值取决于不同平台的实现,但基本行为相同,参数1大于参数2,返回正数,参数2大于参数1,返回负数。

案例:

#include <stdio.h>
#include <string.h>

int main() {
    char *a = "a";
    char *b = "a";

    printf("%d\n", strcmp(a, b));  // 0
}

多行字符串

C语言中一个特殊的语法糖,把两个字符串常量直接拼接在一起,视为一个字符串。

案例:

#include <stdio.h>

int main(int argc, char const *argv[]) {
    printf("Hello \n"
        "World!\n");
    return 0;
}

转义字符、转义序列、Unicode转移序列

转义字符:又称为转义序列,以反斜杠’\'开始,后跟一个或多个字符,用来代表一个特定的字符,并且这个字符通常是无法打印的,因为无法打印,所以才想到用转义字符来表示它,这也是转义的起源

常用的转义字符:

  • \t:制表符,它会根据已经打印的字符数填充足够的空格以达到下一个制表位
  • \n:换行符
  • \r:回车符

转义序列:在C语言中,除了常见的转义字符,还可以使用八进制和十六进制转义序列来表示一些特殊的字符。

  • 八进制转义序列:以 \ 后跟 1-3 位八进制数字表示,\nnn,范围为 0-377(0-255 十进制),例如,‘\0’,就是一个八进制的转义字符。
  • 十六进制转义序列:以 \x 后跟 1-2 位十六进制数字表示,\xhh,范围为 00-FF(0-255 十进制)

\0:C语言中常用的转义序列,表示空字符,八进制形式的转义序列。

Unicode转移序列:从C11标准开始,C语言增加了对Unicode转义序列的支持,使其能够在字符串和字符常量中表示Unicode字符。

  • '\u’后跟着四个十六进制数字用来表示一个16位的Unicode字符,小写的u
  • '\U’后跟了八个十六进制数字用来表示一个32位的Unicode码点,大写的U

布尔值

C语言中可以使用1和0来代替true和false,1相当于true,0相当于false

案例:

#include <stdio.h>

int main(int argc, char const *argv[]) {
    if (1) {
        printf("1\n");
    } else {
        printf("0\n");
    }
    return 0;
}

语法糖

省略括号

如果代码块中只有一行代码,可以省略大括号,但是不推荐。实际上有多行也可以,C语言在这方面要求不严。

#include <stdio.h>

int main(int argc, char const *argv[]) {
    if (1)
        puts("aaa");
    return 0;
}

总结

变量、地址值、指针

变量的声明就是在内存中为变量分配一个内存地址,例如,int a,在内存中分配4字节的内存,a是内存地址的别名。

变量的赋值:在变量代表的内存地址中存储数据。

变量中存储的值和变量的地址值:

  • 正常使用变量,是使用变量中存储的值
  • 使用 “&变量” 的形式,是获取变量的地址值

数组:

  • 数组名相当于一个指针,指向数组中的第一个元素。
  • 在栈内存分配空间的数组:数组名作为指针,指向它本身
  • 在堆内存分配空间的数组:数组名作为指针,指向堆内存

指针运算:

  • *指针:用于获取指针指向的变量中的值,它相当于指针指向的变量,可以通过它来改变指针指向的变量中的值
  • 指针和整数相加减:本质是指针在内存中移动,并且这种移动是以指针指向的变量的数据类型的长度为单位的。指针和整数相加减的结果,是一个右值
  • *(指针 + 整数):获取指针偏移整数位后的内存中的值
  • &(指针 + 整数):这种语法是错误的,因为 & 只能针对左值进行运算
  • 指针[下标]:相当于 *(指针 + 整数)

案例:

#include <stdio.h>
#include <stdlib.h>

// 变量、指针、内存地址
int main(int argc, char const *argv[]) {
    // 普通变量
    int a = 11;
    printf("变量a中存储的值:%d\n", a);            // 11
    printf("变量a的地址值:%ld\n", &a);            // 281474904908108

    // 指针:指针中存储的地址和指针本身的地址
    int * p = &a;
    printf("指针变量p中存储的地址:%ld\n", p);      // 281474904908108
    printf("指针变量p本身的地址:%ld\n", &p);       // 281474904908096
    printf("指针变量p指向的地址中的值:%ld\n", *p);  // 11

    // 数组
    // 数组名是一个指针,执行数组中的第一个元素的内存地址。
    // 数组作为指针,指向它本身,数组中存储的内存地址,是它本身的内存地址
    int arr[3] = {10, 11, 12};
    printf("数组变量arr中存储的地址:%ld\n", arr);         // 281474904908080
    printf("数组变量arr本身的地址:%ld\n", &arr);          // 281474904908080
    printf("数组变量arr指向的地址中的值:%ld\n", *arr);     // 10

    // *指针:相当于指针指向的变量
    *arr = 14;
    printf("数组变量arr指向的地址中的值:%ld\n", *arr);     // 14

    // 指向堆内存的数组
    int *arr2 = malloc(sizeof(int) * 3);
    arr2[0] = 15;
    printf("数组变量arr2中存储的地址:%ld\n", arr2);         // 210929328
    printf("数组变量arr2本身的地址:%ld\n", &arr2);          // 281474904908072
    printf("数组变量arr2指向的地址中的值:%ld\n", *arr2);     // 15
    printf("数组变量arr2指向的地址中的值:%ld\n", &(*arr2));  // 210929328

    // 字符指针
    // C语言对字符指针会做特殊处理,访问字符指针,获取到的是字符指针指向的字符串
    char *s = "hello world";
    printf("字符指针中的值:%s\n", s);                       // hello world
    printf("字符指针的地址值:%ld\n", &s);                   // 281474904908064
    printf("字符指针指向的内存中的值:%d %c\n", *s, *s);       // 104  h

    return 0;
}

字符指针、二级字符指针、指针数组

字符指针类型的二级指针和字符指针数组,本质上是一样的,只是字符指针数组使用起来更加直观,它们都是提前分配好内存,然后通过移动指针来操作一系列字符串。

案例:

#include <stdio.h>
#include <stdlib.h>

void show(int num, char *arr[]);

int main(int argc, const char *argv[]) {
    // 字符指针类型的二级指针
    char **ss = malloc(sizeof(char *) * 3);
    ss[0] = "aaa";
    ss[1] = "bbb";
    ss[2] = "ccc";
    show(3, ss);
    printf("------------\n");

    // 字符指针数组
    char *arr[] = {"ddd", "eee", "fff"};
    show(3, arr);

    return 0;
}

void show(int num, char *arr[]) {
    for (int i = 0; i < num; i++) {
        printf("%s\n", arr[i]);
    }
}

printf函数打印字符指针时,C语言对字符指针会做特殊处理

  • 使用%s来作为字符指针的格式替身符:%s需要一个地址值,它会从地址值向后寻找,直到找到’\0’字符,然后打印这个字符串
  • 使用%d来作为字符指针的格式替身符:打印字符指针中存储的地址值

案例:

#include <stdio.h>

int main(int argc, char const *argv[]) {

    // 字符指针
    char *c = "hello world";
    printf("字符指针中的值,转化为字符串:%s\n", c);          // hello world
    printf("字符指针中的值,转换为地址:%ld\n", c);           // 4196744
    printf("字符指针的地址值:%ld\n", &c);                  // 281474760290968
    printf("字符指针指向的内存中的值:%d %c %ld\n",
        *c, *c, &(*c));                                  // 104  h  4196744
    printf("字符指针偏移1位后指向的内存中的值:%d %c %ld\n",
        *(c + 1), *(c + 1), &(*(c + 1)));                // 101  e  4196745
    printf("---------------\n");

    // 二级指针,并且二级指针指向一个字符指针
    char ** ss = &c;
    printf("二级指针中存储的内存地址:%ld\n", ss);      // 281474760290968
    printf("二级指针本身的内存地址:%ld\n", &ss);       // 281474760290960
    printf("二级指针指向的指针中存储的内存地址:%ld\n", *ss);     // 4196744
    printf("二级指针指向的指针中存储的内存地址:%s\n", *ss);      // hello world
    printf("二级指针指向的指针中存储的内存地址上的值:%ld\n", **ss);   // 104
    // 这里如果是 %s,会报段错误,因为 %s 需要的是一个内存地址,而这里提供的仅仅是一个字符
    printf("二级指针指向的指针中存储的内存地址上的值:%c\n", **ss);    // h
    printf("二级指针指向的指针中存储的内存地址上的值的内存地址:%s\n",
        &(**ss));                                               // hello world
    printf("---------------\n");

    // 字符指针类型的数组
    char *arr[] = {"aaa", "bbb", "cccc"};
    printf("数组名中存储的内存地址:%ld\n", arr);            // 281474760290936
    printf("数组名的内存地址:%ld\n", &arr);                // 281474760290936
    printf("数组名指向的内存地址中存储的值:%s\n", *arr);     // aaa
    printf("数组名指向的内存地址中存储的值:%ld\n", *arr);    // 4198008
    // 这儿无法理解
    printf("数组名指向的内存地址中存储的值的所在内存地址:%ld\n",
        &(*arr));                                       // 281474760290936

    printf("数组名加1中存储的内存地址:%ld\n", arr + 1);          // 281474760290944
    //printf("数组名的内存地址:%ld\n", &(arr + 1));
    printf("数组名加1指向的内存地址中存储的值:%s\n", *(arr + 1));  // bbb
    printf("数组名加1指向的内存地址中存储的值:%ld\n", *(arr + 1)); // 4198016
    // 这儿无法理解
    printf("数组名加1指向的内存地址中存储的值的所在内存地址:%ld\n",
        &(*(arr + 1)));                                      // 281474760290944

    return 0;
}

运行时获取变量的数据类型

C是一种静态类型语言,这意味着在编译时每个变量的数据类型都是确定的,并且在运行时并不能查询这个信息。可以使用sizeof运算符来获取变量的大小,这可以给出一些关于变量类型的信息。对于指针变量,可以通过sizeof来得到指针所指向的数据类型的大小。

高级语法

函数

函数:可执行语句的集合,它们组合在一起实现某个功能。C语言是一门面向过程的语言,面向过程的语言是通过编写函数来实现程序的功能的

函数的格式:

// 函数签名:第一行的内容,除了大括号,是函数的签名,签名中定义了 函数返回值的数据类型、函数名、函数的
// 形参列表。
// 函数体:一对大括号中的内容是函数体,也是函数的实现。
返回值 函数名(参数列表) {
    函数体
}

案例:一个实现两数相加功能的函数:

#include <stdio.h>

// 实现两数相加功能
int add(int x, int y) {
    return x + y;
}

void main() {
    // 调用add函数
    int result = add(3, 4);
    printf("运算结果:%d", result);  // 7
}

在C语言中,函数必须先定义,后使用,或者可以先声明函数的签名,后调用,把函数的实现写在最后。

函数的声明和实现

案例:先声明函数,后实现函数

#include <stdio.h>

// 先声明函数,声明时可以省略参数名,但是建议加上,增加可读性
int add(int x, int y);

void main() {
    // 调用add函数
    int result = add(3, 4);
    printf("运算结果:%d", result);
}

// 函数的实现
int add(int x, int y) {
    return x + y;
}

只要在main函数前面声明过一个函数,main函数就知道这个函数的存在,就可以调用这个函数。究竟这个函数是做什么用,还要看函数的实现。如果只有函数的声明,而没有函数的实现,那么程序将会在链接时出错。

函数名

函数名本质上是一个指针,记录了函数代码在内存中的地址

案例:

#include <stdio.h>

int add(int x, int y) {
    return x + y;
}

int main(){
    int a = 3;
    printf("变量a的内存地址:%p\n", &a);    // 000000000061FE1C
    printf("函数add的内存地址:%p\n", add); // 0000000000401550
    return 0;
}

参数传递

函数的形参列表和实参列表:

  • 形参列表:声明函数时指定的参数
  • 实参列表:调用函数时指定的参数

函数的参数传递:

  • 值传递:如果变量中存储的值是一个普通的值,例如,int类型的变量,值是一个普通的整数,那么就是值传递。
  • 地址传递:如果变量中存储的值是一个地址值,就说,变量实际上是一个指针,那么就是地址传递。例如,变量是一个数组名,数组名实际上是一个指向数组的指针。

无论是值传递,还是地址传递,实际上都是将变量中的值复制一份,传递给函数,不同点在于,复制的是一个普通值,还是一个地址值,

  • 如果是一个普通值,那么在函数中无法影响到原先的变量
  • 如果是一个地址值,那么在函数中可以影响到原来的变量,除非是修改这个地址值本身

案例:

#include <stdio.h>

// 值传递
void func1(int a) {
    a = 1;
}

// 引用传递
void func2(int arr[]) {
    arr[0] = 1;
}

int main() {
    // 值传递,在函数体中修改参数的值,不会对函数体外的变量产生影响
    int a = 10;
    printf("变量a的值:%d\n", a);   // 10
    func1(a);
    printf("变量a的值:%d\n", a);   // 10

    // 引用传递,在函数体中修改参数的值,会对函数体外的变量产生影响
    int arr[5] = {6, 7, 8, 9, 10};
    printf("数组中第一个元素的值:%d\n", arr[0]); // 6
    func2(arr);
    printf("数组中第一个元素的值:%d\n", arr[0]); // 1
    return 0;
}

如果不希望参数在函数体中被改变,可以使用const修饰参数

递归函数

发生自调用的函数,也就是函数自己调用自己的行为

案例:

#include <stdio.h>

// 递归函数:求1到n的和
int sum(int n) {
    if (n == 1) {
        return 1;
    }

    // 在函数内部调用当前函数
    int tmp = sum(n - 1);
    return tmp + n;
}

int main() {
    int n = 0;
    printf("请输入一个数字:");
    scanf("%d", &n);

    int result = sum(n);
    printf("1到%d的和是:%d\n", n, result);
    return 0;
}

内联函数

在编译时,内联函数的函数体会被复制到调用处,这可以避免函数调用。

内联函数的格式:

inline 返回值 函数名(形参列表) {
    函数体
}

内联函数的产生背景:相对于普通的可执行语句,函数调用是比较消耗性能的,如果函数体比较短,频繁地函数调用比起简单的可执行语句会更加消耗性能,为了优化这个点,C语言提供了内联函数,它会在编译时,把函数体复制到调用处

注意事项:

  • C99标准支持内联函数
  • 内联函数只是一个建议,具体实现取决于编译器
  • 有些编译器可能不支持内联函数

函数指针

一个指向函数的指针:

  • 定义格式:返回值类型 (*变量名)(参数列表)
  • 作用:通过函数指针,可以在C语言中实现函数式编程。

案例1:把函数赋值给一个函数指针,这里是一个简单的使用方式,通过这种方式,如果把函数指针声明在方法的形参列表中,然后传入一个函数作为实参,就可以实现函数式编程。

#include <stdio.h>

int add(int x, int y) {
    return x + y;
}

int main() {
    // 定义一个函数指针
    int (*func1)(int, int) = add;

    int result = func1(3, 4);
    printf("计算结果:%d\n", result);
    return 0;
}

案例2:这里使用了typedef关键字,它用于声明类型别名。通过typedef,声明一个某种类型的函数指针,然后通过它来创建一个指针变量。

#include <stdio.h>

int add(int x, int y) {
    return x + y;
}

typedef int (*func2)(int, int);

int main() {
    // 定义一个函数指针
    func2 f1;
    f1 = add;
    int r = f1(5, 6);
    printf("%d\n", r);  // 11
    return 0;
}

回调函数

回调函数是一种设计模式,函数A调用函数B,同时向函数B中传入函数C的引用,函数B在执行过程中调用函数C,此时函数C就是一个回调函数。在回调函数的概念中,函数C的实例是由调用者函数A来提供的,同时是由被调用者函数B在某个时刻调用执行的,函数B调用函数C的目的是为了在某个特定的时刻通知函数A,只有出于这个目的,才算是回调函数,否则可以理解为函数式编程。

C语言中的回调函数是基于函数指针实现的。

函数指针:其本质是一个指针,指向的是一个函数的地址。在C语言中,每个函数在编译后都是存储在内存中,并且每个函数都有一个入口地址,根据这个地址,用户可以访问并使用这个函数。函数指针就是通过指向这个函数的入口,从而调用这个函数。

函数指针的声明:

  • 在形参列表中的声明方式:返回数据类型 (*函数名) (变量类型1,…);
    • 案例:int (*fun) (int, int);
  • 声明一个函数指针类型的变量:typedef 返回数据类型 (*函数名) (变量类型1,…)
    • 案例:typedef int (*exampleCallback)(char *); exampleCallback cmp;

指针函数:返回指针的函数,其本质是一个函数,而该函数的返回值是一个指针。案例:int* fun(int x,int y);

案例:回调函数

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static int result = 0;

// 作为回调函数使用
void callback() {
    // 回调函数修改结果为true,调用者循环获取结果
    result = 1;
    printf("执行回调函数\n");
}

// 被调用者
void callee(void (*func1)()) {
    printf("被调用者开始执行\n");
    sleep(3);
    func1();
}

// 调用者
void caller() {
    callee(callback);
    while (1) {
        if (result) {
            printf("执行成功\n");
            break;
        }
    }
}

int main(int argc, char const *argv[]) {
    caller();
    return 0;
}

案例:函数指针

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

// 声明一个函数指针
typedef int (*func)(int, int);

int sum(int x, int y) {
    return x + y;
}

int main(int argc, char const *argv[]) {
    // 声明一个函数指针类型的变量,指向sum方法,方法类型必须匹配,
    // 否则运行时会报警告信息
    func f1 = sum;
    // 通过函数指针来调用函数
    int a = f1(3, 4);
    printf("a == %d\n", a);  // a == 7
    return 0;
}

指针函数

函数的返回值是一个指针

可变参函数

可变参函数:参数数量可变的函数。

C语言中的可变参依赖于stgarg.h文件

可变参函数的声明:返回值 函数名(int 参数数量, ...);

  • 参数1:用户需要在调用可变参函数时,必须在第一个参数中指定向可变参传入的参数数量。例如,printf函数,第一个参数就是正常参数,正常参数中的格式替换符指定了后面可变参的个数。
  • 参数2:三个点,代表可变参

可变参函数的实现步骤:

  • 第一步:va_list 参数指针,声明一个指向可变参的指针
  • 第二步:va_start(参数指针, 正常参数),初始化参数指针。在这里是,正常参数必须是可变参的前一个参数,并且这个参数必须是指明实参中可变参的实际个数的参数
  • 第三步:va_arg(参数指针,参数的数据类型),获取可变参中的数据,需要多次调用,它每次都会依据顺序获取
  • 第四步:va_end(参数指针),关闭可变参

案例:

#include <stdio.h>
#include <stdarg.h>

// 求多个int值的和
int add(int n, ...) {
    // va_list:声明一个参数指针
    va_list argArr;
    // va_start函数:初始化参数指针
    va_start(argArr, n);

    int i, sum = 0;
    for (i = 0; i < n; i++) {
        // var_arg:依次获取可变参的值
        sum += va_arg(argArr, int);
    }
    // 关闭可变参
    va_end(argArr);

    return sum;
}

int main(int argc, char const *argv[]) {
    int result = add(4, 1, 2, 3, 4);
    printf("相加结果:%d\n", result);  // 10
    return 0;
}

不支持函数重载

C语言中两个函数的名称必须不一致,否则,先声明的函数生效。

案例:

#include <stdio.h>

void test(int a, int b) {
    printf("%d\n", a);
    printf("%d\n", b);
}


int test(int a) {
    printf("%d\n", a);
}

int main(int argc, char const *argv[]) {
    int a = 10;
    int b = 11;
    test(a, b);
    // test(a);  // 这一行会报错,无法调用
    return 0;
}

main函数的两种写法

第一种:不需要从控制台接收参数时的写法

#include <stdio.h>

int main() {
    return 0;
}

第二种:可以从控制台接收参数

#include <stdio.h>

// 第一个参数是字符数组的长度,第二个参数是字符数组
int main(int argc, char const *argv[]) {
    return 0;
}

案例:遍历从控制台接收到的参数,第一个参数默认是文件名

// test.c
#include <stdio.h>

int main(int argc, char const *argv[]) {
    // 遍历从控制台接收到的参数
    for (int i = 0; i < argc; i++) {
        printf("第%d个参数:%s\n", i, argv[i]);
    }
    return 0;
}

编译,然后运行程序:./test a b c

方法中的静态变量

声明在函数内部的静态变量,只会被初始化一次,并且在函数调用完成后仍然保持其值,下一次调用该函数时静态变量的值是上一次调用完成后的值。

函数内部的静态变量,它的生命周期超过了函数本身,静态变量对于需要在函数调用之间保持信息的情况非常有用,因为它们可以在函数调用之间共享数据。

案例:

#include <stdio.h>

void func1() {
    static int count = 1;
    count++;
    printf("count == %d\n", count);
}

int main(int argc, char const *argv[]) {
    func1();  // count == 2
    func1();  // count == 3
    func1();  // count == 4
    return 0;
}

多模块编程

在大型的项目中, 不可能把所有的源代码都写到同一个文件中,通常是依据功能把代码组织到不同的文件中去,然后再把这些文件组织成可执行程序,在这个过程中,就涉及到了多模块开发、头文件、源码文件等知识。

头文件:后缀名为 .h 的文件,头文件中只包含函数的声明,函数保存在和头文件名字相同的、后缀名为 .c 的文件中,头文件可以理解为一个接口,一个源文件通过引入头文件,就可以访问到头文件对应的源文件中的代码。在入门案例中,‘#include <stdio.h>’,就是引入了一个头文件名为’stdio.h’,在这个头文件中定义了输入输出的功能。

源码文件:后缀名为.c的文件,它会引用自己对应的头文件,然后实现头文件中的所有函数。

自定义头文件

引用自定义的头文件,头文件的名称放在双引号中,而引用系统自带的头文件,头文件的名称放在尖括号中

尖括号与双引号的区别:尖括号表示直接在库中查找头文件进行编译,双引号表示先在放置源程序的文件夹里查找头文件,再去库里找。

在大型的C程序中,会把代码依据功能放到多个源文件中,然后,把函数的声明放到.h文件中,把函数的实现放到.c文件中,在main方法所在文件中引入.h文件,编译时同时编译多个源文件。

案例:将函数的声明和实现放到不同的文件中

// add.h 头文件
int add(int x, int y);

// add.c 头文件对应的源代码
#include "add.h"

int add(int x, int y) {
    return x + y;
}

// test.c main方法所在的文件
#include <stdio.h>
#include "add.h"

void main()  {
    int result = add(3, 4);
    printf("运算结果:%d", result);
}

在编译时,要指定所有的文件,gcc *.c -o test*.c表示所有的源文件,-o表示生成的可执行文件的名称

引用其它模块的成员

其它模块的成员,就是其它模块中的全局变量和方法。

  • 使用extern关键字,可以引用其它模块的成员
  • 使用static关键字修饰当前模块内的成员,可以防止它们被其它模块引用

模块:一个编译时概念,在编译时使用 类似于 gcc 模块1.c 模块2.c -o 可执行文件名 的形式,模块1和模块2才是两个独立的模块。如果用户使用 #include “文件” 的形式引用了其它模块,那么当前模块和其它模块在编译时实际上是一个模块,无论其它模块中的变量有没有被static修饰,在当前模块在都可以引用

extern

extern关键字:可以使用extern关键字来引用定义在其它模块中的变量或函数。

使用extern关键字引用外部成员的格式:

  • 声明变量:extern 变量;
  • 声明函数:extern 返回值 函数名(形参列表);

引用完之后就可以在当前模块中使用这些外部成员了。

static

static关键字:如果使用static关键字修饰变量或函数,就可以阻止其它引用当前模块的成员,

  • static修饰局部变量,该局部变量称为静态局部变量。静态局部变量与普通局部变量的区别在于存储位置不同,普通局部变量存储在栈中,静态局部变量存储在静态区,程序运行结束后释放静态局部变量的内存空间。普通变量必须手动初始化才可以引用,静态局部变量如果未初始化,系统会赋一个默认的初始值。
  • static修饰全局变量,该全局变量称为静态全局变量。静态全局变量与普通全局变量的区别在于链接方式不同,静态全局变量为内链接方式,即只在本文件中有效,外部文件不可见,而普通全局变量为外部链接方式,即可被其他源文件调用。
  • static还可以修饰函数,static修饰的函数是一个静态函数,其作用与静态全局变量相同,静态函数只在本文件内有效,不能被外部文件调用。
案例

模块1:var1.c

#include <stdio.h>

int a = 100;

static int b = 200;

int func1() {
    printf("普通函数func1\n");
}

static int func2() {
    printf("静态函数func2\n");
}

模块2:var2.c

 d#include <stdio.h>

extern int a;
// extern int b;  //  undefined reference to `b'
extern int func1();
// extern int func2();  // undefined reference to `func2'

int main() {
    printf("变量a的值:%d\n", a);
    // printf("变量b的值:%d\n", b);

    func1();
    // func2();
    return 0;
}

编译语句:gcc var1.c var2.c -o main; ./main

extern关键字的使用方式

在之前的案例中展示了extern关键字的一种使用方式,但这并不常见。

extern关键字最常见的使用方式,是在头文件中引用源文件中的变量,通过这种方式,可以把源文件中的变量暴露出来,供外界访问,另外,头文件中的函数默认被extern修饰。

extern关键字是如何被处理的:在链接阶段,链接器会寻找被extern修饰的变量的定义,并将该变量和它的定义连接起来,如果找到多个定义,就会报错。

使用案例:

// test1.h
extern int a;

// test1.c
int a = 10;

void cal_c() {
    a = 11;
}

// main.c
#include <stdio.h>
#include "test1.h"

int main(int argc, char const *argv[]) {
    printf("a = %d\n", a);  // a = 10
    cal_c();
    printf("a = %d\n", a);  // a = 11
    return 0;
}

// 编译语句:gcc test1.c main.c -o main

注意事项

在头文件中定义并初始化全局变量
错误案例

main.c

#include "test1.h"

int main(int argc, char const *argv[]) {
    printf("a = %d\n", a);

    test1();
    
    printf("a = %d\n", a);
    
    return 0;
}

test1.h

#ifndef __TEST1_H__
#define __TEST1_H__

int a = 10;

void test1();

#endif // !__TEST1_H__

test1.c

#include "test1.h"

void test1() {
    a = 11;
}

这段代码报错:multiple definition of `a’。

错误原因:在头文件test1.h中直接定义并初始化了全局变量a,然后该头文件被多个源文件包含,当编译器预处理时,a的定义会出现在每个包含该头文件的源文件中,导致在链接阶段出现多重定义错误。

解决方法:从头文件中移除变量定义,头文件应该只用于声明变量和函数原型,而不是定义变量。将变量定义移到一个源文件中。

正确案例

main.c

#include "test1.h"

int main(int argc, char const *argv[]) {
    printf("a = %d\n", a);

    test1();
    
    printf("a = %d\n", a);
    
    return 0;
}

test1.h

#ifndef __TEST1_H__
#define __TEST1_H__

#include <stdio.h>

extern int a;

void test1();

#endif // !__TEST1_H__

test1.c

#include "test1.h"

int a = 10;

void test1() {
    a = 11;
}

运行结果:

[root@7ceffbc16271 p2]# gcc main.c test1.c test1.h -o test1
[root@7ceffbc16271 p2]# ./test1
a = 10
a = 11
在头文件中定义并初始化静态变量

案例:

main.c

#include "test1.h"

int main(int argc, char const *argv[]) {
    printf("a = %d, b = %d\n", a, b);

    test1();

    printf("a = %d, b = %d\n", a, b);
    
    return 0;
}

test1.h

#ifndef __TEST1_H__
#define __TEST1_H__

#include <stdio.h>

extern int a;

static int b = 20;

void test1();

#endif // !__TEST1_H__

test1.c

#include "test1.h"

int a = 10;

void test1() {
    a = 11;
    b = 21;
}

执行结果:

[root@7ceffbc16271 p2]# gcc main.c test1.c test1.h -o test1
[root@7ceffbc16271 p2]# ./test1
a = 10, b = 20
a = 11, b = 20

代码分析:在头文件中声明并初始化静态变量,不会导致编译错误,因为静态变量只存在于当前编译单元,每个包含test1.h的源文件都有自己的独立副本,在当前案例中,在test.c中修改了静态变量,但是在main.c中确没有生效,这不是预期的行为,所以,在头文件中声明并初始化静态变量是不合适的。

正确的做法:静态变量应该声明并初始化在源文件中,并且,静态变量的目的就是保证变量只在当前编译单元内生效,所以,静态变量不可以在头文件中使用extern关键字引用。

头文件和源文件之间的对应关系是如何确定的?

头文件和源文件之间的对应关系是由程序员通过代码指令来确定的。

对应关系的建立:

  • 源文件使用#include "头文件.h"指令来包含头文件,这会让编译器在这个指令所在位置插入头文件中的内容
  • 在头文件中使用 #ifndef 、#define 、#endif指令来确保头文件不会被重复包含
  • 完成上述两步,源文件和头文件的对应关系就建立好了。程序员在编译源代码时,只会用到源文件,由源文件来引用头文件
如果不同文件中的名称有冲突怎么办

多个文件,如果命名有冲突,在编译时会报错。

复合数据类型

结构体 struct

一种复合数据类型,可以把不同类型的数据整合在一起,这些数据称为该结构体的成员

定义结构体的格式:

struct 名称  {
    数据类型 成员名1;
    ....
}

创建结构体类型的变量:struct 结构体名称 变量名 = {成员1, 成员2, ....}

访问结构体的成员:变量.成员。

案例:

#include <stdio.h>

int main() {
    // 定义一个结构体 Student
    struct Student {
        char name[10];
        int age;
        char gender[5];
    };

    // 创建一个Student类型的变量
    struct Student student1 = {"张三", 18, "男"};

    // 访问结构体中的成员
    printf("name = %s, age = %d, gender = %s\n", 
        student1.name, student1.age, student1.gender);

    return 0;
}

结构体内存分析:结构体中的变量会紧密地排列在内存中

案例:

#include <stdio.h>

int main() {
    // 结构体的内存分析
    struct S1 {
        char m1;
        short m2;
        int m3;
        double m4;
        char m5[20];
        long m6;
    };

    struct S1 s1 = {1, 2, 3, 4, "abcdefg", 20};

    int a = 10;

    // 结构体和它的成员的内存地址:
    printf("结构体变量的内存地址:%p\n", &s1);             // 000000000061FDF0
    printf("第一个成员,char类型:%p\n", &s1.m1);         // 000000000061FDF0  + 2
    printf("第二个成员,short类型:%p\n", &s1.m2);        // 000000000061FDF2  + 2
    printf("第三个成员,int类型:%p\n", &s1.m3);          // 000000000061FDF4  + 4
    printf("第四个成员,double类型:%p\n", &s1.m4);       // 000000000061FDF8  + 8
    printf("第五个成员,字符数组,长度20:%p\n", &s1.m5);   // 000000000061FE00  + 20
    printf("第五个成员,long类型:%p\n", &s1.m6);         // 000000000061FE14  + 8
    printf("变量a:%p\n", &a);                          // 000000000061FDEC

    return 0;
}

通过指针来访问结构体中的成员:指针->成员

案例:

#include <stdio.h>

int main() {
    struct Student {
        char name[10];
        int age;
        char gender[5];
    };

    struct Student s1 = {"李四", 28, "男"};

    // 将结构体类型的变量赋值给一个指针
    struct Student * p1 = &s1;

    printf("%s, %d, %s\n", p1 -> name, p1 -> age, p1 -> gender);

    return 0;
}

联合体 union

类似于结构体,是一种复合数据类型,可以把不同类型的数据整合在一起,和结构体不同的地方是,联合体中多个字段共用一段内存,同一时刻只能保存一个成员的值,如果对新的成员赋值,就会把原来成员的值覆盖掉。

联合体占用的内存等于最长的成员占用的内存,联合体中不能出现复合数据类型

定义联合体的语法:

union 联合体名称 {
   member definition;
   member definition;
   ...
   member definition;
} [one or more union variables]; 

案例:

#include <stdio.h>

int main(int argc, char const *argv[]) {
    // 声明一个联合体
    union Data {
        char m1;
        int m2;
        double m3;
    };

    // 使用联合体
    union Data d1;
    
    d1.m1 = 'a';
    printf("d1.m1 == %c\n", d1.m1);

    d1.m2 = 10;
    printf("d1.m2 == %d\n", d1.m2);

    d1.m3 = 100.5;
    printf("d1.m3 == %lf\n", d1.m3);

    // 联合体的内存分析

    // 因为联合体中最长的成员是double,所以联合体的长度是8
    printf("联合体类型的变量的长度:%d\n", sizeof(d1));
    printf("%p\n", d1);     // 4059200000000000
    printf("%p\n", d1.m3);  // 4059200000000000

    return 0;
}

枚举 enum

枚举用于组织常量,使用枚举,使用更加高效地管理常量。

定义枚举的格式: enum 枚举名称 {常量1, 常量2, ...};,每一个枚举常量都和一个数字相关联。

使用案例:

#include <stdio.h>

int main(int argc, char const *argv[]) {

    enum DAY {
        MON = 1,
        TUE,
        WED,
        THU,
        FRI,
        SAT,
        SUN
    } day;

    // 遍历枚举元素
    for (day = MON; day <= SUN; day++) {
        printf("枚举元素:%d \n", day);
    }

    enum DAY today = WED;

    printf("%d\n", today);

    return 0;
}

类型别名 typedef

为数据类型起一个简单的。格式:typedef 数据类型 别名;

为结构体起别名:typedef 结构体定义 别名;

案例:

typedef struct Student {
    int num;
    char name[10];
    char sex;
} STU;

预处理

预处理:在编译之前对代码进行的处理

预处理指令:预处理过程中可以执行的指令,预处理指令通常以 # 开头

常用的预处理,包括宏定义、文件包含、条件编译等

宏定义

宏:全称是宏指令,是一种规则,把输入根据指定的规则转换为输出,输入和输出都是字符串

宏在很多地方都有类似的概念,这里做一个总结和比较:

  • Excel中的宏:一个记录和回放工具,它可以简单地记录用户演示的Excel步骤,然后自动执行这些步骤,从而节省时间
  • C语言中的宏:在预处理阶段,根据宏指定的规则,对代码的文本内容进行替换。替换时要注意宏所对应的值的数据类型。

宏的处理分为两个阶段

  • 定义宏常量:定义好的宏,称为宏常量
  • 宏展开:替换宏常量的操作,称为宏展开

定义宏的格式:

  • 不带参数的宏:#define 宏 值
  • 带参数的宏:#define 宏(参数,…) 值。带参数的宏和函数很像,并且如果只是简单的语句,它的开销比函数要小
    • 可以使用 # 来进行字符串化的处理,被替换后的内容会自动加上双引号
    • 可以使用 # 把多个参数连接到一起
    • 字符串最好使用小括号括起来,字符串中的参数也是

案例:不带参数的宏

#include <stdio.h>
// 定义宏
#define ABC "ABC"
#define PI 3.1415926
#define p (PI *10)

int main(int argc, char const *argv[]) {
    printf("%s\n", ABC);
    printf("%lf\n", PI);
    printf("%lf\n", p);
    return 0;
}

案例:带参数的宏,一个可以根据半径计算圆的周长的宏

#include <stdio.h>
#define PI 3.14
#define CIR(x) (2 * 3.14 * (x))

int main(int argc, char const *argv[]) {
    double result = CIR(3);
    printf("%lf", result);
    return 0;
}

#define指令:用于定义一个宏,define后的第一个参数是宏名,第二个参数是字符串,用户可以在程序中使用宏名,在编译时,会把宏替换为它对应的字符串

使用宏时的注意事项:

  • 宏定义只是简单的字符串替换,并不进行语法检查,因此,宏替换的错误要等到系统编译时才能被发现。
  • 宏的有效范围:一般情况下,宏定义需要放在源程序的开头,函数定义的外面,它的有效范围是从宏定义语句开始至源文件结束。
  • 如果宏定义中的字符串出现运算符,需要在合适的位置加上括号,如果不添加括号可能会出现错误
  • 宏定义允许嵌套,宏定义中的字符串中可以使用已经定义的宏名

宏定义和变量定义的区别:

  • 宏定义只是在代码编译之前,对代码的当中出现的宏进行文本替换
  • 变量的定义是在代码编译之后,执行的过程当中创建,变量代表了一个内存地址,可以去修改内存地址的值
取消宏定义

#undef指令。在#define定义了一个宏之后,可以使用#undef取消该宏定义,如果预处理器在编译源代码时,发现#undef指令,那么#undef后面这个宏就会被取消,无法生效了。

  • 格式:#undef 宏名称

无参宏注意事项:

  • 宏名一般用大写字母表示,以便于与变量区别。
  • 宏定义末尾不必加分号,否则连分号一并替换。
  • 宏定义可以嵌套。
  • 可用#undef命令终止宏定义的作用域。
  • 使用宏可提高程序通用性和易读性,减少不一致性,减少输入错误和便于修改。如数组大小常用宏定义。
  • 预处理是在编译之前的处理,而编译工作的任务之一就是语法检查,预处理不做语法检查。
  • 宏定义写在函数的花括号外边,作用域为其后的程序,通常在文件的最开头。
  • 字符串中不包含宏,否则该宏名当字符串处理。
  • 宏定义不分配内存,变量定义分配内存。

带参宏注意事项:

  • 宏名和形参表的括号间不能有空格。
  • 宏替换只作替换,不做计算,不做表达式求解。
  • 函数调用在编译后程序运行时进行,并且分配内存。宏替换在编译前进行,不分配内存。
  • 宏展开使源程序变长,函数调用不会。
  • 宏展开不占用运行时间,只占编译时间,函数调用占运行时间(分配内存、保留现场、值传递、返回值)。
  • 为防止无限制递归展开,当宏调用自身时,不再继续展开。如:#define TEST(x) (x + TEST(x))被展开为1 + TEST(1)。
宏常量中的#和##
单井号

在宏体中,如果宏参数前加个#,那么在宏体扩展的时候,宏参数会被扩展成字符串的形式

案例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define NAME(name) #name 

int main(int argc, char const *argv[]) {
    char *s = NAME(AAA);
    printf("%s\n", s);  // AAA
    return 0;
}
双井号

“##” 是连接符,可以连接两个宏参数或一个宏参数与标识符,用于生成新的字符串或标识符。

当用户在宏定义中使用##时,编译器会在宏展开过程中将##两侧的符号连接起来,形成一个新的符号。如果两侧是标识符(即变量名、类型名等),它们会被合并成一个单独的标识符;如果一侧是字符串字面量,它会与另一侧的标识符或字符串连接成一个新的字符串。

##运算符只能用于宏定义中。当宏参数为空或仅包含空白时,连接操作仍然会发生,但可能生成无效或意料之外的标识符。

案例:

#include <stdio.h>

#define CONCAT(a, b) a##b

typedef struct s1 {
    int CONCAT(x, _value);
} S1;

int main(int argc, char const *argv[]) {
    S1 s = {3};
    
    // 使用CONCAT宏
    printf("Value: %d\n", s.x_value); // 连接后变成x_value,输出:Value: 5
    return 0;
}

案例讲解:在结构体中根据宏来生成变量名。 // 我也不知道为什么要这么做,这是我在阅读源码时遇到的代码。见到了知道是怎么回事就可以。

文件包含

在编译代码之前,把另外一个源文件当中的内容引入到当前的c文件中

文件包含的格式:

  • #include<文件名>:使用<文件名>这种形式,会在c语言的标准库当中去搜索文件
  • #include"文件名":使用"文件名"这种形式,会在程序所在的位置搜索文件,如果找不到,再到标准库去搜索文件

条件编译

根据常数表达式决定某段代码是否执行。这可以增强代码的跨平台能力,例如,在不同平台上引入的头文件和库函数可能并不相同,这是就可以使用条件编译来选择性地引入不同的头文件

有两种方式来进行条件编译:

第一种:根据常数表达式,使用#if、#else、#endif

#if 常数表达式
  程序段1
#else
  程序段2
#endif

第二种:根据宏是否被定义,使用#ifdef、#else、#endif

#ifdef 宏名   // 如果定义了宏,走if分支,否则走else分支
  程序段1
#else
  程序段2
#endif

#ifndef 宏名  // 如果没有定义宏,走if分支,否则走else分支
   程序段1
#else
   程序段2
#endif

#if的使用案例1:

#include <stdio.h>

int main(int argc, char const *argv[]) {
    int a = 1;
    int b = 2;
    #if a > b
        printf("111\n");
    #else
        printf("222\n");
    #endif
    return 0;
}

#if的使用案例2:判断当前程序的运行平台

通过宏定义来判断当前程序的运行平台:

  • __linux__:表示当前程序在Linux平台上运行。
  • __APPLE____MACH__:表示当前程序在Mac OS平台上运行。
  • _WIN32:表示当前程序在Windows平台上运行。

案例:

#include <stdio.h>

int main(int argc, char const *argv[]) {
    #if defined(__linux__)
        printf("linux")
    #elif defined(__APPLE__) || defined(__MACH__)
        printf("mac");
    #elif defined(__WIN32__)
        printf("win");
    #endif
    
    return 0;
}

#ifdef的使用案例:根据宏是否被定义,走不同的分支

#include<stdio.h>
#define PI 3.14

int main() {
#ifdef PI
    printf("%g",PI);
#else
    printf("未定义");
#endif
    return 0;
}

pragma

#pragma:设定编译器的状态或者指示编译器完成特殊的功能

使用预处理指令防止头文件被多次加载

为了防止头文件被多次加载,需要使用#define命令来设置一个宏

案例:add_minus.h文件

#ifndef __ADD_MINUS_H__

#define __ADD_MINUS_H__

int add(int, int);
int minus(int, int);

#endif  // __ADD_MINUS_H__

断言

C语言使用assert函数来实现断言

assert函数:使用前需要先引入assert.h 文件。它接收一个表达式expression作为参数,如果表达式值为真,继续往下执行程序,如果表达式值为假,assert()会调用abort()函数终止程序的执行,并提示失败信息。

案例:断言

#include <stdio.h>
#include <assert.h>

int main(int argc, char const *argv[]) {
    int number = -1;

    printf("请输入一个数字:");
    scanf("%d", &number);

    assert(number != 0);  // 如果用户输入0,会立刻报错,Assertion failed!,然后退出程序
    printf("用户的输入:%d\n", number);

    return 0;
}

禁止断言:断言一般用于程序调试中,在程序调试结束后需要取消断言,但如果在程序调试时使用了很多断言,一条一条的取消比较麻烦,C语言提供了#define NDEBUG宏定义禁用assesrt()断言。#define NDEBUG语句必须放在assert.h头文件之前,如果放在assert.h文件后面,不能取消断言。

案例:

#include <stdio.h>
#define NDEBUG     // 禁止断言
#include <assert.h>

int main(int argc, char const *argv[]) {
    int number = -1;

    printf("请输入一个数字:");
    scanf("%d", &number);

    assert(number != 0);
    printf("用户的输入:%d\n", number);

    return 0;
}

语法总结

关键字-总结

1、数据类型关键字(12个)

  • char:声明字符型变量或函数
  • short:声明短整型变量或函数
  • int:声明整型变量或函数
  • long:声明长整型变量或函数
  • float:声明单精度浮点类型变量或函数
  • double:声明双精度浮点类型变量或函数
  • signed:声明有符号类型变量或函数
  • unsigned:声明无符号类型变量或函数
  • enum:声明枚举类型
  • struct:声明结构体类型或函数
  • union:声明共用体类型或函数
  • void:声明无返回值函数、无类型指针

2、控制语句关键字(12个)

  • if:条件语句
  • else:if条件语句否定分支
  • switch:多条件分支选择语句
  • case:switch条件语句分支
  • default:switch语句中的“其他”分支
  • for:for循环语句
  • do:do…while循环语句循环体
  • while:while循环语句
  • break:跳出当前循环,执行循环后面的代码
  • continue:跳出当前循环,执行下一次循环
  • return:子程序(函数)返回语句
  • goto:无条件跳转语句

3、存储类型关键字(5个)auto,extern,register,static,const

  • auto:声明自动变量,即由系统根据上下文环境自动确定变量类型
  • extern:声明外部变量或函数
  • register:声明寄存器变量
  • static:声明静态变量或函数

4、其他关键字

  • const:声明只读变量
  • sizeof:计算数据类型长度
  • typedef:给数据类型取别名
  • volatile:使用volatile修饰的变量,在程序执行中可被隐含的改变

5、C99新增关键字

  • inline:定义内联函数
  • restrict:用于限定指针,表明指针是一个数据对象的唯一且初始化对象
  • _Bool:声明一个布尔类型变量或函数
  • _Complex:声明一个复数类型变量或函数
  • _Imaginary:声明一个虚数类型变量或函数

变量的分类-总结

依据作用域对变量进行划分

  • 局部变量:在函数或块中声明的变量称为局部变量。它必须在块的开始处声明,在使用局部变量之前必须要初始化它
  • 全局变量:在函数或块之外声明的变量称为全局变量。任何函数都可以改变全局变量的值。它可用于所有函数。它必须在块的开始处声明。
  • 静态变量:用static关键字声明的变量称为静态变量。它在多个函数调用之间保留其值。

变量的修饰符-总结

extern:引用其它模块的变量

static:静态变量

auto:用于修饰局部变量,其作用是表明该变量是存储在栈上的,如果使用auto在不声明数据类型的情况下直接定义变量,则变量类型默认是int类型。由于现在编译器默认局部变量都是auto,因此auto已经很少使用了。

volatile:一个类型修饰符,用于告知编译器在处理该类型变量时必须直接从其地址读取值,而不是使用寄存器中的缓存值或者进行任何优化。它主要用来处理那些可能被外部因素(如硬件、并发线程)改变的变量,确保对这些变量的访问是“实时”的。

C语言编码规范

代码行:一行代码最好只写一条语句

对齐与缩进:

  • 一般用设置为4个空格的Tab键缩进,不用空格缩进
  • 多行注释不能嵌套
  • 运算符的左右加空格
  • 参数列表中逗号后面加空格

C程序的内存布局

计算机内存

当运行程序时,操作系统会把程序装载到内存,并为其分配相应的内存空间,存储程序中的变量、常量、函数代码等。

操作系统的内存划分:当有程序运行时,操作系统将内存划分成7个部分,每一部分内存空间都有严格的地址范围

  • 系统内核空间:系统内核空间供操作系统使用,不允许用户直接访问。
  • 栈空间:用于存储局部变量等数据,在为局部变量分配空间时,栈总是从高地址空间向低地址空间分配内存,即栈内存的增长方式是从高内存地址到低内存地址。
  • 动态库内存空间:用于加载程序运行时链接的库文件。
  • 堆空间:由用户自己申请释放,其增长方式是从低到高。
  • 读/写数据内存空间:用于存储全局变量、静态全局变量等数据。
  • 只读代码/数据内存空间:用于存储函数代码、常量等数据。
  • 保留区间:保留区间是内存的起始地址,具有特殊的用途,不允许用户访问。

C程序的内存

C语言程序编译运行过程中,主要涉及到的内存空间包括栈、堆、数据段、代码段,这四部分就是C程序员通常所说的内存四区

  • 栈:Stack,用来存储函数调用时的临时信息区域,栈顶地址和栈大小是系统预先规定好的。栈内存由编译器自动分配和释放。
  • 堆:Heap,不连续的内存区域,各部分区域由链表将它们串联起来。堆内存是由内存申请函数获取,由内存释放函数归还。若申请的内存空间在使用完成后不释放,会造成内存泄漏。堆内存的大小是由系统中有效的虚拟内存决定的,可获取的空间较大,而且获得空间的方式也比较灵活。虽然堆区的空间较大,但必须由程序员自己申请,而且在申请的时候需要指明空间的大小,使用完成后需要手动释放。另外,由于堆区的内存空间是不连续的,容易产生内存碎片。
  • 数据段:分为三个部分,bss段、data段、常量区。
    • bss段用于存储未初始化或初始化为0的全局变量、静态变量;
    • data段存储初始化的全局变量、静态变量;
    • 常量区存储字符串常量和其他常量存储。
  • 代码段:Text Segment,也称为文本段,存放的是程序编译完成后的二进制机器码和处理器的机器指令,当各个源文件单独编译之后生成目标文件,经链接器链接各个目标文件并解决各个源文件之间函数的引用,可执行的程序指令通过从代码段获取得以运行。

C程序中不同内存区域存储的值

  • 栈区:局部变量,函数形参,函数调用
  • 堆区:动态内存如malloc等申请使用
  • 静态区:全局变量,static修饰的局部变量
  • 常量区:常量字符串
  • 常量区中的内容在整个程序的执行期间是不允许被修改的,且同一份常量字符串只会创建一份,不会重复创建存储。

段错误

Segmentation Fault,一种常见的运行时错误,段错误是指访问的内存超出了系统给这个程序所设定的内存空间,例如访问了不存在的内存地址、访问了系统保护的内存地址、访问了只读的内存地址等等情况。

引起段错误的原因:

  • 未初始化指针:当使用一个未初始化的指针,或者指针没有被正确分配内存时,尝试访问或修改该指针指向的内存会导致段错误。
  • 数组越界:当访问或修改数组的元素时,如果超出了数组的边界范围,就会导致段错误。
  • 释放已释放的内存:在使用free()函数释放内存后,如果再次尝试访问或修改已释放的内存,就会导致段错误。
  • 栈溢出:当递归调用层数太多或者使用大量的本地变量时,可能会导致栈溢出,进而导致段错误。
  • 字符串操作错误:在使用字符串函数时,如strcpy()、strcat()等,如果目标字符串没有足够的空间来容纳源字符串,可能导致段错误。

当程序遇到段错误时,通常会在终端输出一个错误消息,并终止程序的执行。为了调试段错误,可以使用调试器(如 gdb)来跟踪错误发生的位置。调试器可以帮助用户定位具体的代码行,从而找到导致段错误的原因。

C语言从编译到执行的基本步骤

基本步骤:

  • 编写源代码
  • 预处理:处理预处理指令。预处理操作具体包括:
    • #define:展开所有宏定义,将宏替换为它定义的值;
    • 条件编译:处理所有条件编译指令,#ifdef、#ifndef、#endif等。
    • #include:处理文件包含语句,将包含的文件直接插入到语句所在处。
    • #pragma:编译器指令#pragma会被保留
    • 删除所有注释;
    • 添加行号和文件标识,以便在调试和编译出错时快速定位到错误所在行。
  • 编译:进行词法分析、语法分析、语义分析、优化处理等工作,最终生成汇编文件
  • 汇编:将编译生成的汇编文件翻译成计算机能够执行的指令,存储到目标文件中。在Linux系统中的目标文件是.o文件,Windows系统中是.obj文件,通常汇编后的文件包含了代码段和数据段。在实际开发过程中,编译和汇编通常是一个步骤
  • 链接:生成二进制文件后,文件尚不能运行,若想运行文件,需要将目标文件与代码中用到库文件进行绑定,这个过程称为链接。链接的主要工作就处理程序各个模块之间的关系,完成地址分配、空间分配、地址绑定等操作,链接操作完成后将生成可执行文件。链接过可以分为静态库链接和动态库链接。
    • 静态库:在Linux中是“.a”文件,Windows下是“.lib”文件。静态库文件本质上是一组目标文件的集合,静态库链接指的是在程序链接过程中将包含该函数功能的库文件全部链接到目标文件中。程序在编译完成后的可执行程序无需静态库支持
    • 动态库:也称为共享库,在Linux中是“.so”文件,Windows下是“.dll”文件。动态库链接指的是在程序运行时只对需要的目标文件进行链接,因此程序在运行过程中离不开动态库文件,动态库解决了静态库资源的浪费并且实现了代码共享、隐藏了实现细节、便于升级维护等特点
  • 可执行程序:链接完成后,就会生成可执行程序

库:Library,一些提前编写好的、可复用的代码。

  • 静态链接库:Static Linkable Library,在编译期由编译器和链接器将它集成到可执行文件中,
  • 动态链接库,Dynamic Linkable Library,后缀名为 .dll 的文件,程序在运行时动态的加载。

实战项目

https://github.com/codecrafters-io/build-your-own-x,这是github上的资源,提供了各种实战项目,可以自己做一个组件,类似于编辑器、编译器等

https://viewsourcecode.org/snaptoken/kilo/index.html,做一个自己的终端编辑器,类似于vi那样,这是教程。

https://github.com/wuyaojun108/ki,这是我自己照着教程实现的终端编辑器,比较简单,可以作为练手项目

开发过程中遇到的报错信息总结

报错:expression must have a constant value

用户试图使用一个非常量表达式来初始化一个数组的大小、执行数组声明,或者用在任何需要编译时使用常量表达式的场合,比如用作 case 语句中的标签。

尽管在某些编译器和 C99 以及后续标准中,使用变量来定义数组的大小(变长数组)是允许的,但在标准 C90 中,数组大小必须是编译时确定的常量表达式。

案例:错误的情况

int n = 10;
int array[n]; // 错误:变量 n 不是一个常量表达式

案例:正确的情况

#define N 10
int array[N]; // 正确:使用宏定义作为数组大小
const int N = 10;
int array[N]; // 正确:使用 const 定义的常量来声明数组

案例3:编程数组

#include <stdlib.h> // 包含 malloc 和 free 函数

int n = 10;
int *array = malloc(n * sizeof(int)); // 动态分配

if (array != NULL) {
    // 使用数组
}

free(array); // 使用完后释放内存

无法引用某个头文件

问题描述:在Linux系统上进行开发,在开发过程中,发现总是无法引用某个头文件,而且很奇怪,在一个文件中可以引用,但是在另一个文件中不可以

问题原因:

  • 在Linux系统上开发时,会从/usr/include/目录下寻找依赖的头文件。
  • 在当前问题中,依赖stdio.h文件,stdio.h文件又依赖stddef.h、stdarg.h文件,但是/usr/include/目录下确实没有这两个头文件。
  • 所以,stdio.h文件中本身就在报错,只是用户没有在代码中使用到报错的地方,所以用户代码中不会报错,但是一但使用到错误的地方,就会报错。
  • 解决办法就是在/usr/include/目录下加入正确的stddef.h、stdarg.h文件,这两个文件在/usr/lib/gcc/aarch64-redhat-linux/8/include/下有,复制一份即可

无法使用头文件中的某个成员

也许该成员并不是C语言标准库中的一部分,而是一个与操作系统相关的成员。需要结合代码上下文来分析,如果当前操作系统不支持,需要使用其它方式。

realloc(): invalid next size

分配内存时出现错误,大概原因是,之前得到的缓冲区,某处代码写的数据,已经越界了。程序虽然没有出错,却破坏了结构。所以在realloc的时候,就崩溃了。解决方法是查代码,把访问越界内存的代码修改掉。

案例1:一般情况下,即使内存访问越界,也不会报错

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int main(int argc, char const *argv[]) {
    int *p1 = (int *)malloc(4);
    *p1 = 10;
    printf("%d\n", *p1);  // 10
    *(p1 + 1) = 11;       // 在这里内存访问已经越界了,因为之前只分配了4字节的内存
    printf("%d\n", *(p1 + 1));  // 11

    p1 = realloc(p1, 8);
    *(p1 + 1) = 12;
    printf("%d\n", *(p1 + 1));
    return 0;
}

案例2:执行结构体的指针,如果内存访问越界,realloc的时候会报错

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

typedef struct struct1 {
    int i;
    char *s;
} struct1_t;

int main(int argc, char const *argv[]) {
    struct1_t *p_st = (struct1_t *)malloc(sizeof(struct1_t) * 1);

    p_st[0].i = 10;
    p_st[0].s = "aaa";
    printf("%d %s\n", p_st[0].i, p_st[0].s);  // 10 aaa

    p_st[1].i = 11;
    p_st[1].s = "bbb";
    printf("%d %s\n", p_st[1].i, p_st[1].s);  // 11 bbb

    p_st = realloc(p_st, sizeof(struct1_t) * 2);  // realloc(): invalid next size   Aborted

    return 0;
}

段错误

Segmentation fault,内存访问出错,例如访问了不存在的或没有权限访问的内存。

出现段错误的几种情况:

  • 访问未初始化的指针:如果声明了一个指针但没有给它分配内存,然后试图通过这个指针访问内存,这将导致段错误。
  • 越界访问:如果试图访问一个数组的非法索引,即超过了数组的大小,这也会导致段错误。
  • 解引用空指针:如果试图通过一个空指针访问内存,这也会导致段错误。

案例1:访问未初始化的指针

int *ptr;
*ptr = 10; // 段错误,因为ptr没有初始化

案例2:越界访问

int arr[10];  
arr[20] = 10; // 段错误,因为试图访问数组的非法索引

案例3:解引用空指针

int *ptr = NULL;  
*ptr = 10; // 段错误,因为试图通过一个空指针访问内存

编译器的警告信息:隐式声明函数

隐式声明函数:一个函数未被声明,但却被调用了,此时gcc会报这样的警告信息,或者一个函数所在源文件没有被编译为.o二进制文件。C语言规定,对于没有声明的函数,自动使用隐式声明,也就是根据函数的调用方式,自动生成函数的签名。要想解决这个警告,就需要在调用函数前声明函数

getline()是在<stdio.h>中声明的,但是这个函数不是标准的ANSI函数,而是由GNU扩展的,因此为了正确使用,需要添加宏:#define _GNU_SOURCE

告警信息 ‘sizeof’ on array function parameter ‘buf’ will return size of ‘char *’

当在函数参数中声明数组时,sizeof运算符会返回指针的大小,而不是数组的大小。这是因为在函数参数中,数组会被隐式地转换为指针。为了解决这个问题,可以通过其他方式传递数组的长度给函数,例如在传参时指定数组的长度

案例:sizeof运算符使用了函数参数中声明的数组

#include <stdio.h>

int sum(int arr[], int len) {
    // 告警信息:'sizeof' on array function parameter 'arr' will return size of 'int *'
    int a = sizeof(arr); 
    int result = 0;
    for (int i = 0; i < len; i++) {
        result += arr[i];
    }
    return result;
}

int main(int argc, char const *argv[]) {
    int arr[] = {1, 2, 3};
    int result = sum(arr, sizeof(arr) / sizeof(int));
    printf("result: %d\n", result); // 6
    return 0;
}

报错:dereferencing pointer to incomplete type

这个错误通常是由于在使用指针时遇到了不完整的类型声明导致的。C语言中要求在使用指针之前,必须对所指向的类型进行完整的声明,以便编译器可以知道该类型的大小和结构。

以下是可能导致这个错误的一些常见情况:

  • 缺少类型定义:如果在使用指针之前没有提供完整的类型定义,编译器无法确定该类型的大小,因此会报错。确保在使用指针之前,先对所指向的类型进行完整的定义。
  • 缺少头文件:有时,在使用指针所指向的类型之前,可能没有包含相关的头文件。头文件通常包含了类型的定义和其他必要的声明。确保在使用指针之前,先包含相关的头文件。
  • 结构体定义不完整:如果在使用指向结构体的指针之前,结构体的定义是不完整的,编译器无法确定结构体的大小。在使用指针之前,确保结构体的定义是完整的。

要解决这个错误,需要检查代码中相关的类型声明和头文件包含,并确保在使用指针之前,相关类型的定义是完整的。

报错 Program received signal SIGSEGV, Segmentation fault.

在使用gdb调试程序的过程中,接收到一个信号 SIGSEGV,然后程序就是自动退出了。

SIGSEGV:这个信号代表程序发生了段错误,也就是程序试图访问它没有权限访问的内存,例如,解引用空指针、数组索引越界、栈溢出、已被释放的指针。

http://www.dtcms.com/a/113256.html

相关文章:

  • 【Linux系统】linux下的软件管理
  • 大数据技术发展与应用趋势分析
  • `use_tempaddr` 和 `temp_valid_lft ` 和 `temp_prefered_lft ` 笔记250405
  • web性能检测工具lighthouse
  • k8s 1.23升级1.24
  • JavaSE基础——第六章 类与对象(二)
  • 使用dockerbuildx在x86机器上构建arm版docker镜像
  • 神经网络基础
  • 嵌入式AI简介
  • java面向对象 - 封装、继承和多态
  • 浅谈ai - Activation Checkpointing - 时间换空间
  • HANA如何在存储过程里执行动态SQL
  • 智慧节能双突破 强力巨彩谷亚VK系列刷新LED屏使用体验
  • 初识Linux-基本常用指令(一篇学会操作指令)
  • 03.unity开发资源 获取
  • 05.unity 游戏开发-3D工程的创建及使用方式和区别
  • Windows程序中计时器WM_TIMER消息的使用
  • Golang的Goroutine(协程)与runtime
  • 使用MATIO库读取Matlab数据文件中的稀疏矩阵
  • JAVA阻塞队列
  • OrangePi入门教程(待更新)
  • C++开发工具全景指南
  • 【java】在 Java 中,获取一个类的`Class`对象有多种方式
  • 6.5.图的基本操作
  • YOLOX 检测头以及后处理
  • 联网汽车陷入网络安全危机
  • 贪心算法之任务选择问题
  • mmap函数的概念和使用方案
  • 爬楼梯问题-动态规划
  • 3536 矩形总面积