嵌入式面经三(每日十题)
1、h头文件中的ifndef/define/endif 的作用?
#ifndef
、#define
和#endif
预处理指令通常用于头文件保护。头文件保护的主要目的是防止头文件被多次包含而导致重复定义的问题。以下是它们的作用及用法:
#ifndef
(如果没有定义):检查一个符号是否未被定义。
#define
(定义符号):定义一个符号。
#endif
(结束条件编译):结束#ifndef
条件编译块。
格式如下:
#ifndef _NAME_H //`#ifndef`(如果没有定义):检查一个符号是否未被定义。
#define _NAME_H //定义符号:定义一个符号。
// 头文件内容 //结束`#ifndef`条件编译块。
#endif
当程序中第一次#include包含该头文件时,由于__NAME_H这个宏还没有被定义,所以会定义_NAME_H这个宏,并执行“头文件内容”这个代码;当发生多次#include时,因为前面已经定义了_NAME_H,所以不会再次重复执行“头文件内容”部分的代码,即防止重复定义。
假设有一个头文件名为example.h
,其内容如下:
#ifndef EXAMPLE_H
#define EXAMPLE_H
// 头文件的内容
void exampleFunction();
#endif // EXAMPLE_H
#ifndef EXAMPLE_H
:检查符号EXAMPLE_H
是否未被定义。如果未定义,则继续处理下面的代码。#define EXAMPLE_H
:定义符号EXAMPLE_H
。这样,如果该头文件再次被包含,#ifndef EXAMPLE_H
将会为假,整个头文件的内容将被跳过。- 头文件内容:包含该头文件所需的所有声明和定义,如函数声明、类型定义等。
#endif
:结束#ifndef
条件编译块。
2.为什么需要头文件保护
当一个头文件被多次包含时,如果没有头文件保护,编译器会遇到重复定义的错误。例如,假设example.h
头文件被多个源文件包含:
// file1.c
#include "example.h"
#include "another.h"
// file2.c
#include "example.h"
#include "yet_another.h"
如果another.h
和yet_another.h
也包含了example.h
,则example.h
的内容可能被处理多次。使用头文件保护可以防止这种情况发生,从而避免重复定义错误。
3.指针和数组的区别
- 数据类型:
- 指针是一种数据类型,用于存储内存地址。指针可以指向不同数据类型的内存位置。
- 数组是一种数据结构,用于存储相同数据类型的一组连续内存单元。
- 大小:
- 指针的大小通常与系统架构相关,它存储一个内存地址,因此大小在不同系统上可能会有所不同。
- 数组的大小是由其包含的元素数量决定,每个元素的大小也是相同的。
- 初始化和赋值:
- 指针需要显式初始化为一个有效的内存地址,可以通过将其设置为某个变量的地址来初始化。
- 数组在声明时需要指定大小,而且在创建时会自动初始化,可以直接为数组元素赋值。
- 地址运算:
- 指针允许进行地址运算,例如指针加法或减法,以访问内存中的相邻位置。
- 数组的元素在内存中是连续存储的,因此可以通过索引来访问不同的元素。
- 传递给函数:
- 指针可以用于将变量的地址传递给函数,以便在函数内部修改变量的值。
- 数组在传递给函数时通常会退化为指针,因此函数接收到的是指向数组第一个元素的指针。
- 动态内存分配:
- 指针可用于动态内存分配,例如使用
malloc
或new
来分配内存,然后通过指针访问分配的内存。 - 数组的大小通常在编译时确定,但C99标准引入了可变长度数组(VLA),允许在运行时指定数组大小。
4.指针和引用的区别
- 指针:
- 指针是一个变量,它存储另一个变量的内存地址。
- 指针可以为空(null),也可以重新分配给指向不同的变量。
- 指针需要使用解引用操作符
*
来访问所指向的值。 - 指针可以进行指针算术,如指针加法或减法。
- 指针可以指向不同类型的对象,但需要进行强制类型转换。
- 指针可能需要显式地管理内存,包括分配和释放内存。
- 引用:
- 引用是一个别名,它为已经存在的变量提供了另一个名称。
- 引用在创建时必须与现有变量绑定,无法改变绑定到不同的变量。
- 引用的语法更简洁,不需要解引用操作符,直接使用引用就可以访问所绑定的值。
- 引用不支持指针算术。
- 引用通常用于传递参数给函数,以便在函数内部修改参数的值。
- 引用不需要显式管理内存,不涉及内存分配和释放。
引用区别于指针的特性是 :
- 不存在空引用(保证不操作空指针)
- 必须初始化(保证不是野指针)
- 一个引用永远指向他初始化的那个对象(保证指针值不变)
5.野指针
野指针:是指指针指向的地址是不确定的;
野指针(Dangling Pointer)通常是指指针变量存储了一个无效的内存地址,也就是它指向的内存区域可能已经被释放或不再有效。野指针的操作是不安全的,因为它们可能导致未定义的行为或程序崩溃。
野指针一般来说可以被重新赋值,但这并不会解决野指针的问题。重新赋值一个野指针只是改变了它的目标地址,但仍然可能会导致访问无效内存。在C和C++中,遵循以下最佳实践来处理野指针:
- 避免野指针:在使用指针前,确保它指向有效的内存区域。不要让指针指向已释放的内存或未分配的内存。
- 初始化指针:在声明指针时,始终将其初始化为
NULL
(C语言)或nullptr
(C++语言)。这可以帮助你检测是否有野指针。 - 谨慎使用已释放的内存:如果确实要使用已释放的内存,确保在释放内存后不再访问它。
- 不要多次释放相同的内存:释放内存后,不要再次释放相同的内存块,否则会导致问题。
原因:
释放内存之后,指针没有及时置空
避免:
- 初始化置NULL
- 申请内存后判空
- 指针释放后置NULL
6.函数指针和指针函数的区别
- 函数指针:一个指向函数的指针变量、存储函数的地址,可以用于调用该函数
- 指针函数:一个返回指针的函数
7.C语言的编译过程
-
预处理:对源代码进行处理,展开头文件、宏定义,并去除注释等。生成的纯C代码文件。
-
编译:编译器将预处理后的源代码转化为汇编语言(.s文件)
-
汇编:汇编器将汇编语言代码转化为机器码指令,即可执行的二进制文件(.o文件)。
-
链接:链接器将所有需要用到的函数库和对象文件合并成一个可执行文件(.exe文件)。
8.什么是预编译,何时需要预编译
预编译:(Precompilation)是一种在编译前对源代码进行处理的技术,主要包括预处理和预编译头文件。预处理是编译过程中的第一个步骤,主要由C/C++的预处理器完成。预编译头文件则是为了减少编译时间而对常用的头文件进行预编译。
1.预处理:预处理是编译的第一阶段,主要包括以下几个步骤:
- 宏替换:处理
#define
宏定义,进行文本替换。 - 条件编译:处理
#if
、#ifdef
、#ifndef
、#else
、#elif
和#endif
等条件编译指令。 - 文件包含:处理
#include
指令,将包含的文件内容插入到当前文件中。 - 删除注释:删除源代码中的注释。
- 行连接:处理多行宏定义,将行连接起来。
预处理的结果是生成一个“纯净”的C/C++源代码文件,这个文件将被传递给编译器的下一阶段进行进一步处理。
2.预编译头文件:预编译头文件是对一些不经常更改的头文件进行预先编译,生成一个二进制的预编译头文件。当编译器再次遇到这些头文件时,可以直接使用预编译结果,而不需要重新解析和编译这些头文件,从而大大减少编译时间。
何时需要预编译头文件?
- 大型项目:在大型项目中,包含大量头文件和库文件,预编译头文件可以显著减少编译时间。
- 头文件不常变化:对于那些不经常更改的头文件(如标准库头文件、第三方库头文件),可以进行预编译。
- 频繁编译:在开发过程中,如果需要频繁编译代码,预编译头文件可以节省大量时间。
9.位操作
位操作(Bitwise operations)是在二进制层面上直接操作数据的一种方式。位操作通常用于低级别的编程,如嵌入式系统、硬件驱动开发和性能关键的应用中。常见的位操作包括与(AND)、或(OR)、异或(XOR)、取反(NOT)、左移(Shift Left)和右移(Shift Right)。如下:
1. 与操作(AND)
与操作符是 &
,它会将两个操作数对应位都为1的位设为1,其余位设为0。
int a = 5; // 二进制:0101
int b = 3; // 二进制:0011
int c = a & b; // 二进制:0001,结果:1
2. 或操作(OR)
或操作符是 |
,它会将两个操作数对应位只要有一个为1的位设为1。
int a = 5; // 二进制:0101
int b = 3; // 二进制:0011
int c = a | b; // 二进制:0111,结果:7
3. 异或操作(XOR)
异或操作符是 ^
,它会将两个操作数对应位不同的位设为1,相同的位设为0。
int a = 5; // 二进制:0101
int b = 3; // 二进制:0011
int c = a ^ b; // 二进制:0110,结果:6
4. 取反操作(NOT)
取反操作符是 ~
,它会将操作数的每个位取反。
int a = 5; // 二进制:0101
int c = ~a; // 二进制:1010,结果:-6(在大多数系统中,取反后的结果为负数)
5. 左移操作(Shift Left)
左移操作符是 <<
,它会将操作数的所有位向左移动指定的位数,右边空出的位补0。
int a = 5; // 二进制:0101
int c = a << 1; // 二进制:1010,结果:10
6. 右移操作(Shift Right)
右移操作符是 >>
,它会将操作数的所有位向右移动指定的位数。对于无符号数,左边空出的位补0;对于有符号数,补的值取决于实现。
int a = 5; // 二进制:0101
int c = a >> 1; // 二进制:0010,结果:2
位操作的应用场景
-
设置、清除和切换特定位:
- 设置位:
a |= (1 << n);
// 设置a的第n位为1 - 清除位:
a &= ~(1 << n);
// 清除a的第n位为0 - 切换位:
a ^= (1 << n);
// 切换a的第n位
- 设置位:
-
位掩码:用于从数据中提取特定位或者设置特定位。
#define BIT_MASK 0x0F // 低4位掩码 int a = 0xAB; // 二进制:10101011 int b = a & BIT_MASK; // 二进制:00001011,结果:11
-
效率优化:在某些场景下,使用位操作可以显著提高代码的执行效率。
10.什么是内联函数?什么是宏?内联函数及与宏的区别
1.内联函数(inline function)
定义与用法:
内联函数是在函数定义前使用 inline
关键字声明的函数。编译器会尝试在调用内联函数的地方直接展开函数体代码,而不是进行普通的函数调用,这样可以减少函数调用的开销。
inline int add(int a, int b) {
return a + b;
}
int main() {
int result = add(3, 4); // 编译器会尝试将 add(3, 4) 替换为 3 + 4
return 0;
}
优点:
- 类型检查:内联函数和普通函数一样,提供完整的类型检查。
- 调试:内联函数在调试时表现为正常函数,可以使用调试工具逐步调试。
- 作用域:内联函数遵循C语言的作用域规则,局部变量的作用域清晰明确。
- 维护性:内联函数更易于阅读和维护,具有明确的语法和语义。
缺点:
-
编译器依赖:内联只是对编译器的建议,具体是否内联由编译器决定。
-
代码变长,占用更多内存
2.宏(macro)
定义与用法:
宏是通过预处理器指令 #define
定义的文本替换。预处理器在编译前会将宏替换为定义的文本。
#define ADD(a, b) ((a) + (b))
int main() {
int result = ADD(3, 4); // 预处理器会将 ADD(3, 4) 替换为 ((3) + (4))
return 0;
}
优点:
-
简单替换:宏非常简单,只是文本替换,编译器不会对其进行类型检查,编译时间相对较短。加快了代码的运行效率
-
让代码变得更加的通用
缺点:
-
缺乏类型检查:宏没有类型检查,容易引发类型错误。
-
难以调试:宏在编译前被替换,调试时无法逐步调试宏的展开过程。
-
作用域问题:宏没有作用域,可能会引发命名冲突。
-
意外副作用:宏展开时可能会引发意外的副作用,特别是在参数带有副作用的表达式时,如下:
#define SQUARE(x) ((x) * (x)) int main() { int a = 3; int b = SQUARE(a++); // 宏展开后变为 ((a++) * (a++)),a会被递增两次,结果不符合预期 return 0; }
3.内联函数与宏的区别
相同点:
- 两者都是可以加快程序运行效率,使代码变得更加通用
不同点:
- 内联函数的调用是传参,宏定义只是简单的文本替换
- 内联函数可以在程序运行时调用,宏定义是在程序编译进行
- 内联函数有类型检测更加的安全,宏定义没有类型检测
- 内联函数在运行时可调式,宏定义不可以
- 内联函数可以访问类的成员变量,宏不可以
- 类中的成员函数是默认的内联函数