C语言常用知识结构深入学习
面试大保健-C语言-变量day01
1. C语言的重要性
大家好!今天我们来聊一聊 C 语言。作为嵌入式开发的基础,C语言在面试中必定是一个重点,虽然具体会问到哪些问题不好预测,但可以肯定的是,基础知识绝对不会少问。所以,大家要特别注意不要在这些基础知识上出错。因为如果你答不对这些基础的东西,面试官可能会觉得你对整个技术栈的掌握还不够。而如果你没能答对一些复杂的高级知识,反倒会显得理解还不深,但不至于大幅扣分。
2. C语言的编译流程
流程概览
在学习 C 语言时,了解编译流程是非常重要的,面试中也经常会问到这个问题。C 语言从源码到可执行文件的整个过程包括四个主要步骤:
- 预处理
- 编译
- 汇编
- 链接
详细过程
- 预处理:
这一阶段处理了所有的预编译指令,比如#include
和#define
。在预处理过程中,会把引入的头文件内容插入到当前的源文件中,宏定义也会进行替换,注释和多余的空格也会被去除,最终生成一个干净的源代码。 - 编译:
这一阶段的任务是将源代码翻译成汇编代码,生成.asm
文件。 - 汇编:
汇编阶段将汇编代码转化为机器代码,也就是二进制文件,生成目标文件.obj
。 - 链接:
最后,链接阶段会把多个目标文件和可能依赖的库文件链接成一个完整的可执行文件。这时,我们就可以运行程序了。
编译流程总结
整个流程如图所示:
确保你能流利地描述每个阶段的具体内容,尤其是预处理阶段处理的内容,不仅仅是名称,更要知道其背后的作用。
3. C语言的内存模型
C语言程序的内存模型将程序在内存中划分为几个区域,每个区域的功能不同,具体如下:
- 堆区:
存放动态分配的内存,通常通过malloc
等函数来分配。 - 栈区:
存放局部变量和函数调用信息。 - 静态区(或数据区):
存放全局变量和静态变量,注意,初始化过的全局变量和静态变量存放在一部分未初始化的则存放在另一部分。 - 代码区:
存放程序的执行代码,也就是最终编译生成的机器码。
内存模型示意图
理解这些内存区域以及它们的作用非常重要。特别是在涉及到静态和全局变量时,记住它们的存储位置以及生命周期。
4. 标识符和命名规范
标识符是用来命名变量、函数、数组等的名字。我们有两个层面的规范需要遵守:
- 硬性规范
- 标识符由字母、数字和下划线组成,但不能以数字开头。
- 标识符不能是关键字。
- 命名习惯
- 一般有两种命名风格:
- 下划线命名法:使用下划线连接每个单词,如
my_variable
。 - 驼峰命名法:第一个单词小写,后续单词首字母大写,如
myVariable
。
- 下划线命名法:使用下划线连接每个单词,如
- 一般有两种命名风格:
规范示意图
牢记这些规范,可以确保你的代码命名整洁且符合标准。
5. 变量和常量的区别
在 C 语言中,变量和常量的区别主要体现在以下几个方面:
- 是否可修改
- 变量的值可以在程序运行时修改。
- 常量的值一旦被设定,就不能再改变。
- 值的确定时间
- 变量的值在运行时决定。
- 常量的值在编译时确定。
- 存储位置
- 变量的存储位置通常在栈区或堆区。
- 常量的存储位置通常在数据区。
- 生命周期
- 变量的生命周期由其作用域决定。
- 常量的生命周期通常与程序的生命周期一致。
变量与常量示意图
6. 常量定义的方式
在 C 语言中,常量可以通过 #define
或 const
来定义。它们之间有以下区别:
- #define
- 预处理器指令,进行文本替换,无法进行类型检查。
- 没有作用域限制,常常替换全局范围内的所有相同名字。
- const
- 具备类型检查和作用域控制,更加安全。
定义常量示意图
根据实际情况,选择合适的方式来定义常量。如果对类型安全有更高要求,建议使用 const
;如果需要效率更高的全局替换,可以使用 #define
。
总结
今天我们复习了 C 语言的编译流程、内存模型、标识符命名规范、变量与常量的区别等内容。这些知识点不仅在日常编码中十分重要,也是面试中常常出现的基础题目。希望大家能够牢记并在实际编码中灵活运用。如果你有任何疑问或需要进一步探讨的地方,随时欢迎提问!
面试大保健-C语言-宏day02
1. 宏的基础知识
大家好,今天我们要聊的主题是宏。宏是 C 语言中非常重要的一部分,它涉及到预处理指令,广泛应用于代码优化、调试、跨平台开发等方面。可能有同学平时用得很多,但突然让你列举出来几个常用的宏定义时,可能就懵了。不要担心,今天我们一起来复习一下。
常用宏指令
首先,我们来看几个常见的预处理指令。你在日常编程中经常会用到的有:
#include
:用来引入外部的头文件。#define
:用于定义宏,也就是给常量、表达式等起一个别名。#if
,#ifdef
,#else
,#endif
:这些是条件编译指令,用来根据条件决定是否编译某段代码。#undef
:用来取消已经定义的宏。
宏定义
你可能常常用 #define
来定义一个宏变量。例如:
#define PI 3.14159
这个定义的作用就是,编译器在编译过程中会把所有出现 PI
的地方替换为 3.14159
。非常简单但是非常有用。
预处理的作用
宏定义的预处理操作是在编译过程开始之前进行的。这意味着在编译阶段,所有的宏都已经被替换好了。所以宏指令的使用不仅能够提高效率,还能够在一些特定情况下帮助我们控制程序的编译过程。
2. 条件编译
什么是条件编译?
条件编译是指在编译过程中,根据特定条件选择性地编译某些代码。这对于处理不同平台或调试代码非常有用。我们通常使用 #if
, #ifdef
, #else
, #endif
等指令来实现条件编译。
举个例子:
#ifdef DEBUG
printf("Debugging is enabled.\n");
#endif
上面的代码只有在宏 DEBUG
被定义时,才会被编译。这样我们就可以在开发阶段开启调试输出,而在发布阶段关闭它,从而减少不必要的代码。
条件编译的应用场景
- 功能开关:在某些嵌入式系统中,功能的开启或关闭通常通过宏来控制。例如,使用 FreeRTOS 时,我们可以通过定义宏来决定是否启用某些功能,如信号量、队列等。这不仅能够使程序更加灵活,还能够根据需要裁剪程序的大小。
- 调试阶段的输出:当我们在开发阶段进行调试时,经常使用
printf
等输出调试信息。通过条件编译,我们可以方便地控制这些调试信息的输出。例如:
#define DEBUG
#ifdef DEBUG
printf("Debug message\n");
#endif
当你不再需要调试信息时,只需要去掉 #define DEBUG
这一行,所有的调试信息就会消失。
- 跨平台开发:在跨平台开发时,不同的操作系统可能有不同的系统库和 API。通过条件编译,我们可以根据不同的平台选择不同的代码路径。例如,在 Windows 上使用某些函数,在 Linux 上使用其他函数:
#ifdef _WIN32
// Windows specific code
#else
// Linux specific code
#endif
这样,你就可以在同一代码库中管理多个平台的差异。
条件编译流程图
3. 宏的优势与挑战
优势
- 提高效率:宏常常用于常量和代码块的替换,避免了重复编写相同的代码,提高了代码的复用性。
- 代码简洁:宏能够用简洁的方式定义复杂的操作,比如常见的数学运算、数组操作等。
- 条件编译:帮助我们在不同环境或平台下选择性地编译代码,极大地增强了代码的灵活性。
挑战
- 调试困难:宏是通过文本替换的方式工作,这意味着调试时,你很难追踪宏的值和行为。这就要求我们在使用宏时,要格外小心。
- 命名冲突:宏的命名容易与其他标识符冲突,特别是在大型项目中,容易造成一些难以发现的错误。为了避免这种情况,建议使用有意义且独特的宏名称。
4. 宏和函数的区别
宏和函数虽然看起来相似,但实际上它们有很多区别:
- 替换方式:宏是通过预处理器进行文本替换,而函数是通过程序执行时调用的。
- 参数传递:宏的参数是直接插入到文本中,因此没有类型检查;而函数的参数有类型检查。
- 计算结果:宏在每次调用时都会重新计算,而函数只会计算一次并返回结果。
示例:
#define SQUARE(x) ((x) * (x))
上面是一个宏定义,它会在每次出现 SQUARE(x)
时,进行文本替换。这就可能会带来副作用,特别是当 x
是一个复杂表达式时:
SQUARE(a + b) // 宏展开后变为 ((a + b) * (a + b))
这可能导致计算错误。为了避免这种情况,我们通常会使用小括号确保表达式的正确性。
总结
宏是 C 语言中强大的工具,可以在编译阶段进行文本替换和条件编译。它能帮助我们实现代码的灵活性和高效性,尤其在嵌入式开发、调试和跨平台开发中有着广泛应用。虽然宏使用起来简单,但也要注意避免宏带来的副作用,如命名冲突、调试困难等问题。
希望今天的讲解能帮助你更好地理解宏的概念和应用,记得在编程过程中熟练运用它,让你的代码更加简洁、灵活!
面试大保健-C语言-数据类型day03
1. C语言中的数据类型
大家好,今天我们要学习 C 语言中的数据类型。C 语言的基本数据类型分为两大类:整数类型和浮点类型。我们常见的整数类型包括 char
、short
、int
、long
以及 long long
,而浮点类型有 float
、double
和 long double
。
整数类型
char
:通常用于存储字符,占用1个字节。short
:较小的整数类型,占用2个字节。int
:标准整数类型,通常占用4个字节。long
:比int
更大的整数类型,占用4个字节,具体取决于编译器和平台。long long
:更大的整数类型,占用8个字节。
浮点类型
float
:用于存储单精度浮点数,占用4个字节。double
:用于存储双精度浮点数,占用8个字节。long double
:用于存储扩展精度浮点数,通常占用16个字节。
注意事项
数据类型的大小实际上会因平台和编译器的不同而有所不同。例如,32位和64位系统上的 int
类型可能会有所不同,甚至某些特定平台(如嵌入式系统)上,int
和 long
可能占用不同的字节数。
流程图:C语言数据类型示意
2. 整数和浮点数的存储原理
整数的存储原理
整数在计算机内存中的存储采用二进制补码的形式。补码能够简化加法与减法操作,因为正负数的运算可以统一使用同一套硬件电路,而无需特别处理符号位。
正数存储:
正数直接转换为二进制表示,补码与原码相同。
负数存储:
负数的存储过程包括三步:
- 先将数字转换为二进制原码。
- 然后取反得到反码。
- 最后加1得到补码。
补码的使用使得加减法操作更为简便,可以用相同的电路进行处理。
浮点数的存储原理
浮点数在内存中采用标准的科学计数法表示。每个浮点数被分为三个部分:
- 符号位:表示数字的正负。
- 指数部分:表示数字的幂次。
- 尾数部分:存储数字的有效位。
浮点数存储的最大挑战是如何高效地处理指数部分,它采用了偏移量的方式存储。例如,对于 float
类型,指数部分使用8位存储,偏移量为127,而 double
类型使用11位存储,偏移量为1023。
科学计数法存储示例
假设我们有浮点数 1.23 × 10^3
,它将被转换为标准的二进制科学计数法存储:
- 尾数:存储数字的有效部分(小数部分)。
- 指数:存储数字的幂次。
浮点数的存储流程图
3. 数据类型自动转换规则
C语言支持数据类型之间的自动转换,特别是在不同类型的运算中。转换遵循一定的规则:
- 整型与整型运算:较小的数据类型会转换为较大的数据类型。例如,
short
类型和int
类型运算时,short
会自动转换为int
。 - 整型与浮点型运算:整型会转换为浮点型进行运算,结果也会是浮点型。
- 浮点型与浮点型运算:较小的浮点类型(如
float
)会转换为较大的浮点类型(如double
)。
有符号与无符号数的转换
当有符号数与无符号数进行运算时,有符号数会被转换为无符号数。这是因为无符号数不能表示负数。
自动转换流程图
4. 枚举类型的使用场景与优点
使用场景
- 状态机:枚举类型非常适合用来表示状态机中的各种状态。例如,在开发无人机项目时,可以使用枚举来定义不同的状态,如
IDLE
、FLYING
、LANDING
等。 - 方法返回值:在某些方法中,返回值可能是多个预定义状态的其中之一,这时枚举可以提高代码的可读性和安全性。
优点
- 提高代码可读性:使用有意义的枚举值,可以让代码更加清晰易懂。例如,
enum State { IDLE, FLYING, LANDING }
,比起用整数值0, 1, 2
来表示状态要直观得多。 - 类型安全:枚举类型确保只有枚举值中定义的值可以作为状态传入,避免了不合法值的出现,从而提高代码的安全性。
流程图:枚举类型的优势
5. typedef与#define的区别
typedef
和 #define
都可以用来为类型创建别名,但两者有明显的区别:
typedef
:在编译时会进行类型检查,并且支持作用域限制。它是C语言中的一种正式语法。#define
:在预处理阶段进行文本替换,不能进行类型检查,也没有作用域限制。
typedef
示例
typedef unsigned int uint;
#define
示例
#define UINT unsigned int
typedef
更安全,因为它会检查类型,而 #define
只是简单的文本替换,容易出现潜在的错误。
总结
今天我们复习了 C 语言中常见的数据类型,包括整数类型和浮点类型,并深入探讨了它们的存储原理、自动类型转换规则以及常用的 typedef
和 enum
类型。理解这些基本概念对于编写高效、安全的代码至关重要。
希望大家能够将这些知识点巩固,并在日后的编程实践中灵活运用!
面试大保健-C语言-数组day04
1. C语言数组的特点
大家好,今天我们来讨论 C 语言中的数组。作为 C 语言的基础概念之一,数组在面试中非常常见。对于数组,你需要了解它的一些基本特点。
数组的基本特点
- 内存连续性
数组中的元素在内存中是连续排列的。也就是说,数组占用的是一块连续的内存空间,这对于快速访问数组元素非常有利。 - 固定长度
一旦定义了数组的长度,数组的长度是固定的,无法在运行时动态改变。如果你在使用数组时,发现原有长度不够用,那么唯一的办法就是创建一个新的数组,并将原数组的元素拷贝到新的数组中,增加其长度。 - 通过下标访问
数组中的元素可以通过下标直接访问,访问速度非常快。编译器会根据数组的起始地址和元素的大小计算出具体元素的内存地址,直接定位到该位置。
计算数组的长度
数组的长度可以通过 sizeof
运算符来计算。假设我们有一个整数数组 arr
,可以通过以下方式来获取数组的元素个数:
sizeof(arr) / sizeof(int)
这里 sizeof(arr)
获取整个数组占用的字节数,sizeof(int)
获取每个元素的字节数,通过二者相除就能得到数组的长度。
数组存储示意图
2. 字符串的本质
在 C 语言中,字符串本质上是一个字符数组。每个字符串都是以一个特殊的字符 '\0'
结尾的,这个字符被称为“空字符”,它用于标记字符串的结束。
字符串为何需要结束标志
你可能会好奇,为什么 C 语言中的字符串需要加一个结尾的 '\0'
。这主要是为了在我们使用函数(比如 printf
)时,能够正确地判断字符串的结束位置。如果没有这个结尾标志,程序就无法知道字符串的长度,从而无法正确处理它。
字符数组赋值与字符串长度
如果你有一个字符串自变量 str = "hello world"
,并将其赋值给一个字符数组,C 编译器会自动在字符串的末尾加上一个 '\0'
,因此这个数组的长度会比字符串本身多一个字节。
示例:sizeof
与 strlen
char str[] = "hello world";
printf("Sizeof str: %zu\n", sizeof(str)); // 12
printf("Strlen(str): %zu\n", strlen(str)); // 11
这里,sizeof(str)
返回的是数组的总字节数,包括 '\0'
,而 strlen(str)
返回的是字符串的长度,不包括结尾的 '\0'
。
字符串存储示意图
3. 多维数组及其内存组织
多维数组是数组的一种扩展,表示数组的每个元素本身还是一个数组。比如,二维数组就是一个由多个一维数组构成的数组。
多维数组的存储形式
多维数组在内存中的存储仍然是连续的。举个例子,假设我们有一个二维数组 int arr[3][4]
,它实际上就是一个含有 3 个元素,每个元素是一个含有 4 个整数的一维数组。虽然它看起来像是一个二维数组,但在内存中,它被存储为一个连续的内存块。
示例:二维数组的存储
假设我们定义一个二维数组 int arr[3][4]
,它在内存中的存储方式如下:
arr[0][0] arr[0][1] arr[0][2] arr[0][3]
arr[1][0] arr[1][1] arr[1][2] arr[1][3]
arr[2][0] arr[2][1] arr[2][2] arr[2][3]
内存中存储的顺序是:arr[0][0]
, arr[0][1]
, arr[0][2]
, arr[0][3]
, arr[1][0]
, arr[1][1]
, arr[1][2]
, arr[1][3]
, arr[2][0]
, arr[2][1]
, arr[2][2]
, arr[2][3]
。
示例代码:二维数组
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");
}
多维数组存储示意图
graph LR
A[二维数组] --> B[连续存储]
B --> C[存储顺序:行优先]
C --> D[内存连续访问]
4. 数组与字符串的常见操作
在 C 语言中,数组和字符串的操作是经常出现的任务。你应该熟练掌握以下几项基本操作:
- 数组初始化:数组必须在声明时初始化,否则会造成未定义行为。
- 字符串赋值:通过字符数组来接收字符串字面量,并记得加上
'\0'
。 - 数组访问:数组通过下标来访问元素,速度非常快,但要确保不会越界。
- 多维数组操作:对于多维数组,你可以通过嵌套循环来遍历所有元素。
通过理解这些基本概念,你可以更有效地操作 C 语言中的数组和字符串。
总结
今天我们学习了 C 语言中的数组、字符串和多维数组。通过掌握它们的基本特点、内存组织形式以及常见操作,你将能够更加高效地进行 C 语言编程,特别是在面试中,你会遇到很多与数组和字符串相关的题目。希望大家多加练习,熟能生巧!
面试大保健-C语言-指针day05
1. 指针的基本运算
大家好!今天我们要深入了解 C 语言中的指针。指针是 C 语言中一个非常重要的概念,掌握指针对理解底层内存操作和提高编程能力非常有帮助。让我们一步步解析指针的基本运算。
1.1. 两个同类型指针相减的结果
两个指针相减得到的结果是它们之间元素的个数,而不是它们所指向的地址之间的差值。换句话说,指针相减的结果是“元素的数量”,而不是地址的字节差。
举个例子:
int arr[] = {10, 20, 30, 40};
int *ptr1 = &arr[0];
int *ptr2 = &arr[2];
printf("%ld\n", ptr2 - ptr1); // 输出:2
在这个例子中,ptr2 - ptr1
结果为 2,表示 ptr2
和 ptr1
之间相隔了两个 int
类型的元素。
1.2. 两个同类型指针相加
两个指针不能相加,这是不允许的操作。指针相加是没有意义的,因为它们指向的是内存中的特定位置,而两个指针合并为一个指向某个位置的指针并没有实际意义。
1.3. 指针与整数相加减的结果
指针加减一个整数,会得到指向新位置的指针。这个新位置是根据指针当前指向的元素类型大小来计算的。如果你加 1,指针会指向下一个元素;减 1,则指向前一个元素。
举个例子:
int arr[] = {10, 20, 30};
int *ptr = &arr[0];
ptr++; // 指向 arr[1]
printf("%d\n", *ptr); // 输出:20
在这个例子中,ptr++
让指针指向了数组中的下一个元素。
1.4. 指针比较大小
指针是可以进行比较大小的,比较的是指针的地址值。比如你可以比较两个指针哪个指向的内存地址更大。
示例代码:
int arr[] = {10, 20, 30};
int *ptr1 = &arr[0];
int *ptr2 = &arr[2];
if (ptr2 > ptr1) {
printf("ptr2 指向的地址比 ptr1 大\n");
}
这里的 ptr2 > ptr1
比较的是它们指向的地址的大小。
指针运算流程图
2. 数组名与指针的关系
2.1. 数组名是指针吗?
数组名本质上是一个标识符,它代表了数组实体的位置。在某些情况下,数组名会被隐式地转换为指向第一个元素的指针。关键在于数组名和指针的区别:数组名是数组的标识符,而指针是一个变量,存储了内存地址。
什么时候数组名表示整个数组?
- 只有在使用
sizeof
计算数组大小时,数组名表示的是整个数组。 - 只有在使用取地址操作符
&
时,数组名表示的是整个数组。
数组名转为指针的情况
在其他情况下(例如数组名参与运算),数组名会隐式地转换为指向第一个元素的指针。
2.2. 数组名与指针的比较
int arr[] = {1, 2, 3};
int *p = arr;
printf("%p\n", arr); // 输出数组起始地址
printf("%p\n", p); // 输出与 arr 相同的地址
这两个输出是相同的,因为 arr
在大多数情况下被当作指向第一个元素的指针。
2.3. 数组指针与指针数组的区别
- 数组指针:指向整个数组的指针。数组指针的类型是“指向数组的指针”。
- 指针数组:是一个数组,其中每个元素都是指针。指针数组的元素是指针类型。
示例:
int *arr_ptr = arr; // 数组指针
int *ptr_arr[3]; // 指针数组
数组指针指向的是整个数组,而指针数组则包含多个指针。
3. 常量指针与指针常量
3.1. 常量指针
常量指针是指针本身不可修改,但可以修改它所指向的内容。换句话说,常量指针的“指针”部分是常量。
int x = 10;
int y = 20;
int * const ptr = &x; // 常量指针,ptr 不可修改
*ptr = 20; // 可以修改 ptr 所指向的值
3.2. 指针常量
指针常量是指指针本身不可修改,但可以修改它所指向的内容。换句话说,指针常量的“值”部分是常量。
int x = 10;
int y = 20;
const int *ptr = &x; // 指针常量,可以修改 ptr,但不能修改 ptr 所指向的内容
ptr = &y; // 可以修改指针 ptr
*ptr = 30; // 错误,不能修改 ptr 所指向的内容
3.3. 常量指针与指针常量的区别
- 常量指针:指针的地址不可以改变,但可以修改它指向的内容。
- 指针常量:指针指向的内容不可改变,但指针的地址可以改变。
3.4. 写法区别
int * const ptr = &x; // 常量指针
const int * ptr = &x; // 指针常量
4. 空指针、悬空指针与野指针
4.1. 空指针
空指针是指一个未指向任何有效地址的指针。在 C 语言中,空指针通常被初始化为 NULL
。
int *ptr = NULL;
4.2. 悬空指针
悬空指针是指一个原本指向某块内存的指针,在该内存被释放后,指针仍然指向这块已经释放的内存,变成了悬空指针。
4.3. 野指针
野指针是指一个没有被初始化或者指向非法地址的指针。野指针访问时可能会导致程序崩溃或未定义行为。
4.4. 如何避免这些问题?
- 初始化指针:始终将指针初始化为
NULL
,以避免空指针。 - 释放内存后置空指针:释放内存后,及时将指针设为
NULL
,避免悬空指针。 - 越界访问检查:避免指针越界访问数组,确保访问的地址是有效的。
总结
今天我们学习了 C 语言中的指针运算、指针和数组的关系、常量指针与指针常量、空指针、悬空指针与野指针等基础概念。指针是 C 语言中非常强大的工具,掌握了指针的使用,可以让你更深入理解内存管理和优化代码。
希望大家能够牢记这些关键点,熟练运用指针,提高编程能力!
面试大保健-C语言-结构体day06
1. 结构体变量和指针访问成员的区别
大家好,今天我们要聊聊结构体,首先来看一下结构体变量和结构体指针访问成员的区别。你可能知道,用结构体变量和结构体指针访问成员最大的不同就是符号的使用。
- 结构体变量:使用点操作符
.
来访问成员。 - 结构体指针:使用箭头操作符
->
来访问成员。
举个例子
假设有如下结构体定义:
struct Person {
char name[20];
int age;
};
struct Person p1 = {"Alice", 25};
struct Person *p2 = &p1;
- 通过结构体变量
p1
访问成员:
printf("%s\n", p1.name); // 输出:Alice
printf("%d\n", p1.age); // 输出:25
- 通过结构体指针
p2
访问成员:
printf("%s\n", p2->name); // 输出:Alice
printf("%d\n", p2->age); // 输出:25
总结
p1.name
使用点操作符直接访问成员。p2->name
使用箭头操作符通过指针访问成员。
结构体变量与指针的内存传递差异
当我们在函数参数中传递结构体时,如果直接传递结构体变量,它会进行值传递,即复制结构体内容。而如果传递的是结构体指针,它会进行引用传递,即传递结构体的地址,内存开销更小。
流程图:结构体成员访问方式
graph LR
A[结构体变量] --> B[使用点操作符访问成员]
A[结构体指针] --> C[使用箭头操作符访问成员]
B --> D[内存复制,较大开销]
C --> E[内存地址传递,较小开销]
2. 结构体的长度计算
2.1. 结构体的内存大小
结构体的长度计算并不简单,它并不是将各个成员的大小直接相加。因为结构体成员的存储是受到字节对齐的影响的。
举个例子:
struct Example {
char a;
short b;
int c;
};
直观上,char
占 1 字节,short
占 2 字节,int
占 4 字节,直接相加的话总共应该占 7 字节,但实际上由于字节对齐的关系,结构体的总大小是 12 字节。
2.2. 字节对齐的概念
字节对齐是为了让内存访问更加高效,尤其是在 CPU 访问内存时,它通常一次读取一个字节(8 位)。为了提高效率,编译器会将变量存放在内存地址对齐到特定字节边界的位置。
- 对于
short
类型,起始地址必须是 2 的倍数。 - 对于
int
类型,起始地址必须是 4 的倍数。
2.3. 如何计算结构体的大小
假设我们定义如下结构体:
struct Example {
char a;
short b;
int c;
};
char a
占 1 字节,short b
占 2 字节,但为了满足字节对齐的要求,b
会被放置到地址 2,a
后的第一个空字节(1 字节)将会被跳过。int c
占 4 字节,它会被放置在地址 4(4 字节的对齐要求)。
最终,结构体的大小为 12 字节,而不是 7 字节。因为 short
和 int
都需要对齐。
2.4. 结构体内存对齐示意图
3. 结构体与共用体的区别
3.1. 结构体和共用体的内存使用
- 结构体:每个成员都有独立的内存空间,它们的内存互不干涉。
- 共用体:所有成员共用同一块内存空间,内存大小是所有成员中占用内存最大的一个。
举个例子:
union Example {
char a;
int b;
};
- 在这个共用体中,
a
和b
会共享同一块内存,内存的总大小是int
所需的 4 字节,而不是char
的 1 字节。
3.2. 结构体与共用体内存示意图
4. 结构体属性的顺序和内存布局优化
4.1. 调整结构体成员顺序的影响
结构体的成员顺序会影响它的内存占用。通过合理安排成员顺序,可以减少内存的浪费。比如,将大小相似的成员放在一起,以避免编译器插入不必要的填充字节。
举个例子:
struct Example {
char a; // 1 byte
double b; // 8 bytes
int c; // 4 bytes
};
这个结构体会占用 16 字节,其中可能会有一些填充字节。
4.2. 优化结构体内存布局
通过合理调整结构体成员的顺序,我们可以让结构体的内存占用更加紧凑。例如,将 int
和 double
放在一起,避免中间出现不必要的空隙。
struct OptimizedExample {
double b; // 8 bytes
int c; // 4 bytes
char a; // 1 byte
};
这个优化后的结构体内存使用更加紧凑,减少了不必要的填充字节。
4.3. 结构体内存优化示意图
总结
今天我们学习了 C 语言中的结构体,重点讲解了结构体成员的访问方式、内存计算、字节对齐、结构体与共用体的区别等。通过合理安排结构体成员的顺序,可以有效减少内存占用,从而提高程序的效率。
希望大家在实践中能够灵活运用结构体,优化内存布局,提升代码性能!
面试大保健-C语言-函数day07
1. 形参与实参的区别
大家好,今天我们要聊的是 C 语言中的函数。首先,来看看形参和实参的区别。
- 形参:是函数声明中在函数参数列表中声明的参数。它只是一个占位符,表示函数将接收某种类型的值。
- 实参:是我们在调用函数时传入的实际参数,它们会替代形参。
形参与实参的内存区别
形参在函数原型中只是声明,它不会占用实际内存空间。只有当函数被调用时,形参才会作为局部变量在栈中分配内存。
举个例子
假设我们有一个函数:
void printSum(int a, int b) {
printf("Sum: %d\n", a + b);
}
a
和b
是形参,它们只是声明并占位,只有在函数调用时才会占用栈内存。- 当我们调用
printSum(3, 4);
时,3
和4
是实参,它们传递给了形参a
和b
。
2. 主函数的参数与返回值
主函数的参数和返回值规则,根据应用场景会有所不同。在嵌入式开发中,主函数通常没有参数和返回值,而在系统应用层开发中,主函数会有参数和返回值。
嵌入式开发
在嵌入式开发(例如 STM32)中,main()
函数通常没有参数和返回值,因为它直接作为程序入口执行,不需要处理传入参数。
应用层开发
在应用层开发中,main()
函数通常有参数和返回值。主函数的常见参数是命令行参数:
int main(int argc, char *argv[]) {
// argc 是参数的个数,argv 是参数数组
}
返回值通常是整数,0
表示程序正常结束,非零值表示出错。
返回值和参数的意义
- 返回值:通常,
0
表示成功,非零值表示错误。通过返回值可以告诉操作系统程序的执行状态。 - 参数:
argc
是传入的参数个数,argv
是一个字符串数组,存储传入的命令行参数。
流程图:主函数参数与返回值
graph LR
A[主函数] --> B[没有参数和返回值(嵌入式)]
A[主函数] --> C[有参数和返回值(应用层开发)]
C --> D[argc:参数个数]
C --> E[argv:参数数组]
C --> F[返回值:0表示成功]
3. 函数原型
函数原型是函数声明,它告诉编译器函数的返回类型、函数名和参数类型。函数原型不包含函数体。
举个例子
int add(int a, int b); // 函数原型
这表示有一个名为 add
的函数,返回类型是 int
,接受两个 int
类型的参数。
函数原型帮助编译器在调用函数时检查参数类型和返回值类型,确保程序的正确性。
4. 常用的 C 语言系统函数
在 C 语言中,有很多常用的系统函数,特别是在处理输入输出、内存管理、字符串操作等方面。我们来列举几个常用的系统函数。
常用函数
-
输入输出:
printf
:用于打印输出到标准输出(通常是控制台)。scanf
:用于从标准输入(通常是键盘)读取数据。
-
内存操作:
malloc
、calloc
:用于动态分配内存。free
:释放动态分配的内存。memcpy
:用于复制内存块。
-
字符串操作
:
strlen
:计算字符串长度。strcpy
:复制字符串。strcmp
:比较两个字符串。
示例代码
#include <stdio.h>
#include <string.h>
int main() {
char str[100];
printf("Enter a string: ");
scanf("%s", str);
printf("Length of the string: %lu\n", strlen(str));
return 0;
}
流程图:常用系统函数
5. 传递指针与传递值的区别
在 C 语言中,函数参数的传递有两种方式:按值传递和按指针传递。这两者的主要区别在于是否改变原始数据。
传值
- 按值传递:当你将变量传递给函数时,函数接收到的是变量的副本,函数内的改变不会影响原变量。
- 适用于数据量小、需要保护原始数据不被修改的情况。
void increment(int a) {
a = a + 1; // 只是改变了副本,不会影响原始变量
}
int main() {
int x = 10;
increment(x);
printf("%d\n", x); // 输出:10
return 0;
}
传指针
- 按指针传递:当你传递变量的地址给函数时,函数直接修改原变量的值。
- 适用于数据量大的结构体或数组,能够节省内存并允许函数修改原始数据。
void increment(int *a) {
*a = *a + 1; // 直接修改原始变量的值
}
int main() {
int x = 10;
increment(&x);
printf("%d\n", x); // 输出:11
return 0;
}
流程图:传递指针与值的区别
6. 回调函数
回调函数是一个通过函数指针传递给其他函数的函数。当其他函数完成特定任务时,它会调用这个回调函数。
回调函数的应用
回调函数的最大好处是解耦。我们将处理逻辑与调用逻辑分开,使得代码更加灵活。回调函数常常用于事件驱动编程中,比如 GUI 程序或者网络请求。
示例:使用回调函数
#include <stdio.h>
void myCallback() {
printf("Callback function called\n");
}
void executeCallback(void (*callback)()) {
printf("Executing callback...\n");
callback();
}
int main() {
executeCallback(myCallback); // 传递回调函数
return 0;
}
流程图:回调函数的使用
总结
今天我们学习了 C 语言中的函数,包括形参与实参的区别、主函数的参数与返回值、常用系统函数、传递指针与值的区别,以及回调函数的应用。希望大家在实际编码中能灵活运用这些知识,提高代码的可维护性和执行效率。
面试大保健-C语言-关键字day08
1. static
关键字的使用
大家好,今天我们要讨论的是 C 语言中的一些重要关键字,首先来看 static
。这个关键字在 C 语言中有多个使用场景,具体来说,它用在变量上和函数上有不同的含义。我们先从变量开始说起。
1.1. static
用于变量
-
局部静态变量:当
static
用在局部变量前时,表示该变量的生命周期不再是局部的,而是程序的整个生命周期。换句话说,静态局部变量在整个程序运行期间存在,但它的作用域依然局限于定义它的函数。void func() { static int count = 0; count++; printf("%d\n", count); }
每次调用
func()
时,count
的值会累加,而不会重新初始化为 0。虽然它是局部变量,但它的生命周期持续到程序结束。 -
全局静态变量:当
static
用于全局变量时,表示该变量只能在当前文件内访问,外部文件无法引用它,起到了封装作用。static int global_var = 100;
这里的
global_var
仅在当前源文件中有效,外部无法访问。
1.2. static
用于函数
-
当
static
用于函数时,意味着该函数的作用域也被限制在当前文件内,外部文件无法调用此函数。这样做通常是为了模块化代码,只让函数在文件内部使用,避免外部干扰。static void helper_function() { printf("This function is only accessible within this file.\n"); }
这个
helper_function()
只能在当前源文件内使用,其他文件无法调用它。
2. extern
关键字
extern
是另一个重要的关键字,它用于声明外部变量或函数。这个关键字告诉编译器,在当前文件之外存在一个变量或函数,编译器不需要为它分配内存空间。
2.1. extern
用于变量
当你在不同的文件之间共享数据时,可以使用 extern
来声明一个在其他文件中定义的变量。
// file1.c
int x = 5;
// file2.c
extern int x;
在 file2.c
中,使用 extern
声明 x
,表示它在 file1.c
中已定义。编译时,链接器会将这两个文件的 x
变量关联起来。
2.2. extern
用于函数
extern
也用于声明一个在其他文件中定义的函数。这通常用于跨文件调用函数。
// file1.c
void func() {
printf("Hello from file1\n");
}
// file2.c
extern void func();
这里,file2.c
可以调用 file1.c
中的 func()
,即使 func()
在 file2.c
中没有定义。
3. volatile
关键字
volatile
关键字告诉编译器不要对变量进行优化。它的主要作用是防止编译器进行缓存优化,以保证每次访问变量时都从内存中读取数据。这对于多线程编程或者硬件寄存器的访问非常有用。
3.1. 禁止缓存优化
假设我们有一个变量,它的值可能会被外部因素修改(例如硬件中断、外部设备等),在这种情况下,如果编译器进行缓存优化,可能会导致数据读取不及时,无法获取最新值。
volatile int flag;
这里,flag
变量被声明为 volatile
,表示它的值可能会在程序执行过程中被外部改变,因此每次访问 flag
时,必须从内存中读取,而不能从寄存器中缓存。
3.2. 应用场景
- 多任务系统:在嵌入式系统中,可能会有多个任务共享变量,
volatile
确保每次读取共享变量时都能获取最新值,避免缓存错误。 - 硬件寄存器访问:当直接访问硬件寄存器时,变量的值可能随时改变,因此需要使用
volatile
来确保每次都从硬件读取。
3.3. 代码示例
volatile int counter;
void ISR() {
counter++; // 假设在中断服务例程中修改 counter
}
int main() {
while (counter < 10) {
// 等待中断更新 counter
}
printf("Counter reached 10\n");
return 0;
}
在这个例子中,counter
可能会被中断服务例程修改,因此它必须被声明为 volatile
,否则编译器可能会优化掉对 counter
的读取,导致程序无法正确运行。
总结
今天我们学习了 C 语言中的一些关键字:static
、extern
和 volatile
。这些关键字在不同场景下有着重要作用:
static
:用于控制变量和函数的作用域和生命周期。extern
:用于声明外部变量和函数,帮助实现跨文件的数据共享。volatile
:用于禁用编译器的缓存优化,确保变量每次都从内存中读取,常用于多线程和硬件编程。
理解这些关键字,能让你在编写高效、可靠的 C 语言代码时,更好地控制内存和资源的使用。希望大家能够多加练习,熟练掌握这些关键字的用法!
面试大保健-C语言-内存-上day09
1. 是否能申请大于物理内存的内存
在面试中,关于内存管理的问题是经常会问到的。一个常见的问题是:“在一台 1GB 内存的计算机上,能否申请 1.2GB 的内存?”这个问题的答案并不是简单的“能”或“不能”,我们需要根据不同的情况来分析。
1.1. 裸机开发
如果是裸机开发(没有操作系统支持的情况下),答案是“不能”。裸机环境没有操作系统的支持,也就没有虚拟内存的功能。因此,计算机的内存分配会受到物理内存的限制。
1.2. 操作系统环境
如果是在操作系统上开发,情况就有所不同。我们需要根据是否支持虚拟内存来判断能否分配 1.2GB 的内存。操作系统如 Linux、Windows、macOS 等,通常都支持虚拟内存,这使得我们可以申请比物理内存更大的内存。
虚拟内存的作用
虚拟内存是由操作系统提供的一种功能,它能够让程序认为自己拥有更多的内存,而实际上操作系统会将部分数据暂存到硬盘上,只有在需要时才从硬盘加载回内存。
1.3. 虚拟内存的作用
如果操作系统支持虚拟内存,那么即使物理内存只有 1GB,程序也可能成功申请到 1.2GB 的内存,因为操作系统会使用硬盘空间来扩展虚拟内存。需要注意的是,虚拟内存并不是物理内存的扩展,它是通过硬盘交换区域(如分页文件)来模拟更多的内存空间。
流程图:内存申请是否成功
2. 虚拟内存的工作原理
2.1. 虚拟内存的管理
虚拟内存是通过**内存管理单元(MMU)**和操作系统共同协作来实现的。每个进程在执行时都有自己的虚拟地址空间,虚拟地址空间从 0 开始,这样每个进程都可以认为自己拥有独立的内存,避免了进程之间的内存冲突。
举个例子:
假设你有三个进程在运行,每个进程都认为它自己有 1GB 的内存。实际上,操作系统将这些虚拟地址映射到物理内存的不同部分。通过这种方式,多个进程可以共享物理内存,而不会相互干扰。
2.2. 虚拟内存的优势
- 简化应用层开发:每个进程都有自己的虚拟内存空间,开发者不需要担心其他进程是否占用了同一块内存。
- 扩展内存容量:通过虚拟内存,操作系统能够使程序使用比物理内存更多的内存空间。
2.3. 如何实现虚拟内存
虚拟内存的实现依赖于分页机制,操作系统将虚拟内存划分为固定大小的页,并将这些虚拟页映射到物理内存中的不同页框。当程序访问某个虚拟地址时,操作系统会通过页表找到对应的物理地址。
如果某个页面不在物理内存中,操作系统会将其从硬盘中的交换空间加载到内存中,这个过程被称为页面置换。
2.4. 虚拟内存的好处
- 隔离进程:每个进程的虚拟内存是独立的,进程间不会发生内存冲突。
- 内存共享:不同进程可以共享物理内存的某些部分,但它们的虚拟内存空间保持独立。
- 延迟加载:操作系统可以根据需要将部分内存从物理内存移到硬盘,从而节省内存空间。
2.5. 页面置换机制
当物理内存不够用时,操作系统会选择一些不活跃的页面,将它们从内存中移到硬盘,腾出空间给新的进程。这种机制就是页面置换。当需要访问这些页面时,操作系统会将它们从硬盘中读取回来。
流程图:虚拟内存的工作原理
3. 虚拟内存的实际应用
3.1. 多任务处理
在操作系统中,虚拟内存的使用让多个进程能够同时运行,每个进程在自己的虚拟内存中运行,操作系统通过调度和分页来管理这些进程,保证它们不会相互干扰。
3.2. 嵌入式开发与虚拟内存
在嵌入式开发中,特别是使用像 FreeRTOS 这样的轻量级操作系统时,虚拟内存的支持并不像 Linux 那样完善。在这种情况下,内存的管理往往由开发者自己控制,物理内存和虚拟内存之间的映射更加直接。
3.3. 内存泄漏与虚拟内存
虚拟内存并不会防止内存泄漏。程序员仍然需要注意动态内存的分配和释放,否则会导致内存泄漏,即使虚拟内存存在,也无法避免因为资源未释放导致的内存不足问题。
总结
今天我们讨论了关于内存的一些重要概念,重点是虚拟内存。虚拟内存是操作系统提供的一种机制,它可以让应用程序申请比物理内存更多的内存,并且简化了多进程的内存管理。通过虚拟内存,每个进程都拥有独立的虚拟地址空间,避免了内存冲突,并能更高效地利用系统资源。
了解这些概念对编写高效、可靠的程序非常重要,尤其在多任务系统和嵌入式系统开发中,虚拟内存的使用和管理将直接影响程序的性能和稳定性。
面试大保健-C语言-内存-下day10
1. 内存泄露与内存溢出
大家好,今天我们继续讨论 C 语言中的内存管理问题,特别是内存泄漏和内存溢出。首先,了解这些概念对开发稳定的程序非常重要。
1.1. 什么是内存泄露?
内存泄露指的是在程序运行过程中,申请了内存空间但未能释放。这导致了内存的浪费,最终可能导致系统内存耗尽。
- 内存泄漏的发生:通常是因为程序员在动态内存分配后忘记释放内存,或者失去对已分配内存的控制(如通过错误的指针操作)。
内存泄漏的常见问题是:随着时间的推移,内存泄漏越来越多,最终可能导致系统的内存溢出。
1.2. 什么是内存溢出?
内存溢出是指程序尝试使用超过操作系统为其分配的内存时发生的错误。内存溢出的常见原因有:
- 内存泄漏:导致内存不断增长,最终超出了系统的内存限制。
- 内存分配请求过大:如果程序申请的内存量大于系统能够提供的物理内存,内存溢出也会发生。
1.3. 如何解决内存泄漏和溢出?
-
避免内存泄漏:程序员必须确保在不再需要内存时及时释放内存。良好的内存管理和代码审查是避免内存泄漏的关键。
不用的时候free掉
-
解决内存溢出:如果内存溢出是因为程序申请了过大的内存,则需要优化内存使用,或考虑使用更大的内存。硬件扩展或增加物理内存也是一种解决办法。
增大空间
流程图:内存泄漏与溢出
2. 堆与栈的区别
接下来我们讨论堆和栈的区别。堆和栈是程序中两种不同的内存分配区域,它们各自有不同的特点和用途。
2.1. 管理方式
- 栈:由系统或编译器自动管理内存。当函数被调用时,局部变量被分配在栈上,函数执行完毕后,栈上的内存会自动释放。
- 堆:由程序员手动管理内存。程序员需要显式地申请和释放内存,如果不及时释放,就可能发生内存泄漏。
2.2. 访问效率
- 栈的访问效率较高:栈的内存分配遵循后进先出(LIFO)的原则,每次调用函数时,只需在栈顶分配内存,释放时也从栈顶弹出,操作非常简单高效。
- 堆的访问效率较低:堆中的内存是动态分配的,因此内存的分配和释放更复杂,特别是多次分配和释放后可能会出现内存碎片化问题。
2.3. 分配方向
- 栈:栈通常从高地址向低地址分配内存。
大小确定了, 所以从高地址向低地址分配
-
堆:堆则通常从低地址向高地址分配内存。
内存空间分配, 大小不确定, 分配的时候才决定, 用到少, 分多少那种感觉
2.4. 举个例子
在栈中,函数调用时局部变量依次入栈,而在函数返回时,局部变量出栈。
而在堆中,内存分配是动态的,需要手动管理。
int *p = malloc(sizeof(int)); // 堆上分配内存
free(p); // 释放堆内存
流程图:堆与栈的区别
3. 堆栈溢出
3.1. 栈溢出
栈溢出通常发生在递归调用过深或局部变量过大的情况下。由于栈内存是有限的,如果函数调用层次太深,栈空间被耗尽,就会发生栈溢出。
- 函数调用层次太深:如果递归调用太多,每次调用都会在栈上分配一块内存,最终可能导致栈空间耗尽。
- 局部变量过大:如果函数内声明了非常大的局部变量,它们会占用较大的栈空间,也可能导致栈溢出。
3.2. 堆溢出
堆溢出通常发生在内存泄漏或内存申请过大时。程序申请了超过系统可用物理内存的内存,导致堆内存溢出。
- 内存泄漏:如果程序在堆上申请了内存,但没有释放,那么随着程序运行,堆内存将被耗尽,最终导致堆溢出。
- 申请过大的内存:如果程序请求的内存超出了系统的内存限制,也会发生堆溢出。
3.3. 解决方案
- 栈溢出:优化递归调用,避免过深的递归;减少局部变量的大小,避免占用过多栈空间。
- 堆溢出:确保释放申请的堆内存,避免内存泄漏;合理分配内存,避免申请过大的内存。
流程图:堆栈溢出的原因
4. 动态内存分配
在 C 语言中,动态内存分配是通过一些标准库函数来实现的。最常用的函数有 malloc
、calloc
、realloc
和 free
。
4.1. malloc
和 calloc
-
malloc
:分配指定大小的内存,返回一个指向该内存的指针。如果分配失败,返回NULL
。int *ptr = malloc(sizeof(int) * 10); // 分配 10 个 int 大小的内存
-
calloc
:分配指定数量的内存,并初始化为 0。int *ptr = calloc(10, sizeof(int)); // 分配并初始化为 0
4.2. realloc
和 free
-
realloc
:重新调整已分配内存的大小。如果新大小比原来大,则会保留原来的数据并扩展内存空间。ptr = realloc(ptr, sizeof(int) * 20); // 调整内存大小
-
free
:释放之前通过malloc
、calloc
或realloc
分配的内存。free(ptr); // 释放内存
4.3. 注意事项
- 申请的内存必须在使用完毕后及时释放,避免内存泄漏。
- 避免频繁申请和释放小块内存,可能会导致内存碎片化。
流程图:动态内存管理
总结
今天我们讨论了 C 语言中的内存管理问题,包括内存泄漏、内存溢出、堆和栈的区别、堆栈溢出的原因以及动态内存分配的使用。掌握这些概念能够帮助我们在开发中避免常见的内存问题,提升程序的稳定性和效率。希望大家在实际编程时,能够灵活运用这些知识,确保程序的内存管理正确无误!