面试真题 | 经纬恒润[20240901]
@[toc]
介绍自己的三个项目
根据你的每个项目深挖了一些问题
1.什么是内联函数和宏定义?
内联函数与宏定义
内联函数(Inline Functions): 内联函数是C++(也存在于C99及之后版本,通过inline
关键字实现,但行为可能有所不同)中用于减少函数调用的开销的一种技术。当编译器遇到内联函数的调用时,它会在调用点直接展开函数的代码,而不是像普通函数调用那样进行压栈、跳转和返回。这样做的好处是可以减少函数调用的开销,特别是对于那些体积小、调用频繁的函数。但是,如果内联函数过大或包含复杂的控制结构,编译器可能会忽略内联请求,因为过度内联可能会增加代码大小,影响缓存效率,反而降低性能。
宏定义(Macro Definitions): 宏定义是预处理指令的一种,用于在预处理阶段对代码进行文本替换。宏定义可以是无参数的(如#define PI 3.14159
),也可以是有参数的(如#define SQUARE(x) ((x) * (x))
)。宏定义的主要优点是简单、灵活,可以定义复杂的表达式或代码片段。但是,宏定义不进行类型检查,可能导致难以发现的错误(如运算符优先级问题),且宏展开后可能会增加代码体积,降低可读性。
追问及答案
追问1:内联函数与宏定义相比有哪些优点和缺点?
答案:
- 优点:
- 内联函数在编译时进行代码展开,可以避免函数调用的开销。
- 内联函数有类型检查,比宏定义更安全。
- 内联函数可以像普通函数一样进行调试。
- 缺点:
- 如果内联函数过大或复杂,编译器可能不会内联,导致内联请求被忽略。
- 过度内联可能会增加代码大小,影响缓存效率,反而降低性能。
追问2:在哪些情况下应该使用内联函数而不是宏定义?
答案:
- 当需要类型检查或作用域限制时,应使用内联函数。
- 当宏定义导致运算符优先级问题时,应使用内联函数。
- 当需要调试功能时,内联函数是更好的选择,因为宏定义在预处理阶段被替换,调试时可能看不到宏的展开结果。
- 对于简单的、体积小的函数,如果预计会被频繁调用,可以考虑使用内联函数以减少调用开销。
追问3:C++11引入了constexpr
关键字,它与内联函数和宏定义有何不同?
答案:
constexpr
用于修饰变量或函数,表示这些变量或函数的值在编译时就能确定。对于constexpr
函数,它必须满足以下条件:- 函数体必须是一个单一的返回语句。
- 函数体内只能包含类型安全的操作(即不会导致未定义行为的操作)。
- 函数的参数也必须是
constexpr
或字面量。
- 与内联函数相比,
constexpr
函数的主要用途是在编译时计算表达式的值,而不是减少函数调用的开销。constexpr
函数可以被用于模板元编程、编译时断言等场景。 - 与宏定义相比,
constexpr
函数提供了类型安全、作用域限制和调试能力,同时避免了宏定义可能导致的运算符优先级问题。但是,constexpr
函数不能像宏定义那样定义复杂的控制结构或代码片段。
2.宏定义作用在编译阶段的哪个阶段生效?
宏定义作用在编译的哪个阶段生效?
宏定义是在编译的预处理阶段被处理的。预处理是编译过程的第一步,它发生在正式的编译之前。在这个阶段,编译器会执行一系列的操作,包括头文件包含、宏替换、条件编译、去除注释以及添加行号等。宏定义(通过#define
指令)就是在这一步骤中被替换为相应的文本或代码。
追问及答案:
追问1:宏定义和函数相比有哪些主要区别?
答案:
宏定义和函数在多个方面存在主要区别,包括:
-
作用时机:宏定义在编译的预处理阶段进行简单的文本替换,不占用运行时间;而函数在运行时被调用和执行,会占用运行时间。
-
内存分配:宏定义在预处理阶段展开,不分配运行时的内存;函数调用时,可能需要分配临时内存来存储参数、局部变量等。
-
类型检查:宏定义不进行类型检查,只是简单的文本替换,可能导致类型不匹配的错误;而函数则具有严格的类型检查机制。
-
参数求值:宏定义在替换时会对参数进行多次求值(如果参数有副作用,如自增操作),可能导致意外的结果;而函数调用时,参数只被求值一次。
-
调试难度:由于宏定义只是简单的文本替换,没有独立的调用栈和行号信息,调试时可能较为困难;而函数则具有完整的调用栈和行号信息,便于调试。
追问2:宏定义有哪些常见的用途和优点?
答案:
宏定义在编程中有多种常见的用途和优点,包括:
-
定义常量:通过宏定义可以方便地定义常量,提高代码的可读性和可维护性。
-
简化代码:宏定义可以用于简化复杂的表达式或操作,使代码更加简洁明了。
-
条件编译:利用预处理指令(如
#ifdef
、#ifndef
、#endif
)和宏定义,可以实现条件编译,根据不同的条件包含或排除代码块。 -
避免幻数:幻数(即硬编码在代码中的字面量值)难以理解和维护,使用宏定义可以将这些值定义为有意义的常量名,提高代码的可读性。
-
模板编程:在C语言中,宏定义可以用于实现简单的模板编程,通过宏参数化地生成类似的代码段。
追问3:如何避免宏定义带来的潜在问题?
答案:
宏定义虽然强大,但也带来了一些潜在的问题,如类型不匹配、参数多次求值等。为了避免这些问题,可以采取以下措施:
-
谨慎使用宏定义:在可以使用函数或内联函数的情况下,优先考虑使用它们,因为它们具有类型检查和参数只被求值一次的特性。
-
使用括号保护:在宏定义中,使用括号将参数和整个宏体括起来,以防止替换时出现意外的优先级问题。
-
避免在宏定义中使用有副作用的参数:如果宏定义中的参数有副作用(如自增、自减操作),可能会导致替换后出现多次求值的问题,应尽量避免这种情况。
-
使用内联函数代替宏定义:在C99及以后的C标准中,引入了内联函数(
inline
函数),它可以像宏定义一样在调用点展开,但同时又具有类型检查等函数特性,是宏定义的一个较好替代品。
3.指针和数组的异同
指针和数组的异同
相同点:
-
访问方式:在C/C++中,数组名在大多数表达式中会被转换为指向数组首元素的指针。因此,可以通过指针和数组下标的方式访问数组中的元素。
-
内存布局:数组和指针在内存中的布局方式相似,都是连续存储的。数组是一块连续的内存区域,而指针则存储了某个类型数据的内存地址。
不同点:
-
类型检查:数组具有严格的类型检查,即数组的元素类型在声明时确定,并在整个生命周期内保持不变。而指针的类型是它所指向的数据的类型,但它本身可以指向任何类型的数据(通过类型转换),这可能导致类型不安全。
-
大小:数组的大小在编译时就已确定,并在整个生命周期内保持不变。而指针的大小则取决于它所指向的数据类型以及所在的平台(通常是机器字长或指针大小)。指针本身不存储数据的大小信息,除非额外管理。
-
操作:数组名在表达式中通常被转换为指向首元素的指针,但数组名本身不是指针,它不能被赋值或递增。而指针是变量,可以被赋值、递增、递减等。
-
内存分配:数组的内存分配是连续的,由编译器在栈上或静态存储区分配(对于全局数组或静态数组)。而指针可以指向动态分配的内存(如使用
malloc
或new
),也可以指向栈上或静态存储区的内存。
追问几个有深度的技术问题
-
问题:在C++中,
std::vector
和原生数组相比有哪些优势?- 答案:
std::vector
是C++标准模板库(STL)中的一个序列容器,它提供了比原生数组更丰富的功能和更高的灵活性。优势包括:动态大小(可以自动管理内存以存储任意数量的元素),边界检查(虽然可能通过迭代器失效等方式间接体现),提供迭代器支持以简化算法实现,以及成员函数如push_back
、pop_back
、resize
等,便于元素的添加、删除和大小调整。
- 答案:
-
问题:在嵌入式系统中,如何确保指针操作的安全性,避免野指针和内存泄漏?
- 答案:在嵌入式系统中,由于资源有限,确保指针操作的安全性尤为重要。避免野指针的方法包括:初始化指针(确保在使用前已指向有效内存或设置为
nullptr
),避免指针越界,以及在使用完指针后将其设置为nullptr
(虽然这主要是出于防御性编程的考虑,对于防止内存泄漏没有直接帮助)。防止内存泄漏则需要确保每次动态分配的内存最终都被释放(使用free
或delete
),并且注意匹配分配和释放的类型(malloc
/free
与new
/delete
不要混用)。此外,使用智能指针(如std::unique_ptr
、std::shared_ptr
)也是一种有效的防止内存泄漏的方法。
- 答案:在嵌入式系统中,由于资源有限,确保指针操作的安全性尤为重要。避免野指针的方法包括:初始化指针(确保在使用前已指向有效内存或设置为
-
问题:在C语言中,如何通过指针实现函数参数的按值传递和按引用传递?
- 答案:在C语言中,由于没有引用类型,所有函数参数都是通过值传递的。但是,当参数是指针时,虽然传递的是指针的值(即地址),但通过这个地址可以访问和修改指针所指向的数据,从而实现类似按引用传递的效果。按值传递时,可以直接传递变量的值;而按引用传递(通过指针)时,需要传递变量的地址,并在函数内部通过解引用操作来访问和修改该变量的值。需要注意的是,虽然通过指针可以实现类似按引用传递的效果,但也需要小心处理指针的解引用和空指针等问题。
4.讲一下结构体
当嵌入式面试官问到关于结构体(struct)的问题时,一个基本的回答可以从定义、用途、内存布局以及嵌入式系统中的应用等方面展开。
回答
结构体(struct)是C语言(以及C++、C#等其他编程语言)中一种复合数据类型,它允许将不同类型的数据项组合成一个单一的类型。结构体的定义通过struct
关键字开始,后跟结构体标签(即结构体的名称),接着是一对花括号{}
,花括号内是结构体成员的声明。每个成员可以是任何基本数据类型(如int、float等)或其他结构体类型。
示例代码:
struct Person {
char name[50];
int age;
float height;
};
在这个例子中,struct Person
定义了一个结构体类型,包含三个成员:name
(一个字符数组,用于存储名字)、age
(一个整型,用于存储年龄)和height
(一个浮点型,用于存储身高)。
结构体在嵌入式系统中的应用非常广泛,因为它提供了一种将相关数据组织在一起的方式,便于管理和访问。例如,在嵌入式系统中,可能会使用结构体来表示设备的配置参数、传感器数据、通信协议帧等。
追问及答案
追问1:结构体在内存中是如何布局的?
答案:结构体在内存中的布局依赖于编译器的实现和编译器选项(如对齐方式)。一般来说,结构体的成员会按照它们在结构体定义中出现的顺序在内存中连续存储。但是,为了访问效率,编译器可能会在每个成员之间添加填充字节(padding),以满足特定类型(如int、float等)的对齐要求。这意味着结构体实际占用的内存大小可能会大于其所有成员大小的总和。
追问2:如何计算结构体的大小?
答案:计算结构体大小的方法取决于编译器和目标平台的对齐规则。在大多数情况下,可以使用sizeof
运算符来获取结构体的大小。但是,要准确预测结构体的大小,需要了解目标平台的对齐规则以及编译器如何处理结构体的对齐和填充。此外,还可以通过调整编译器选项(如对齐选项)来影响结构体的大小。
追问3:在嵌入式系统中,如何优化结构体的内存使用?
答案:在嵌入式系统中,内存资源通常非常有限,因此优化结构体的内存使用非常重要。以下是一些优化技巧:
-
成员排序:根据成员的类型和大小重新排序结构体成员,以减少填充字节的数量。一般来说,应该将占用空间较小的成员放在前面,并将具有相同对齐要求的成员放在一起。
-
位字段:使用位字段(bit-fields)来存储只有几个可能值的成员。位字段允许在单个整数中存储多个小值,从而节省空间。
-
编译器选项:利用编译器的选项来优化结构体的对齐和填充。例如,一些编译器允许用户指定一个紧凑的对齐选项,以减少填充字节的数量。
-
共享成员:如果多个结构体实例包含相同的数据,并且这些数据在任何给定时间内都不会同时被访问,那么可以考虑将这些数据存储在单独的数组中,并在结构体中使用索引或指针来访问它们。这种方法称为“结构体数组”的变体,但在这里是反过来的——数组“结构体”。
-
自定义内存布局:在某些情况下,如果编译器提供的内存布局不符合要求,可以通过使用联合体(unions)或显式的内存操作(如指针和强制类型转换)来创建自定义的内存布局。但是,这种方法需要仔细考虑数据对齐和可移植性问题。
5.结构体里面内存对齐的规则
结构体内存对齐的规则
在嵌入式系统以及大多数现代编程环境中,结构体(或称为复合数据类型)的内存布局通常遵循一定的对齐规则,以提高内存访问的效率。这些规则可以因编译器、目标平台和架构的不同而有所差异,但通常遵循以下基本原则:
-
成员对齐:结构体中的每个成员根据其类型的大小和编译器/平台的要求进行对齐。例如,某些架构可能要求
int
类型成员在4字节边界上对齐,而double
类型成员在8字节边界上对齐。 -
结构体整体:结构体本身的起始地址也需要满足一定的对齐要求,这通常是结构体中最大成员的对齐要求,或者是编译器/平台指定的默认对齐值。
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
让实战与真题助你offer满天飞!!! 每周更新!!! 励志做最全ARM/Linux嵌入式面试必考必会的题库。 励志讲清每一个知识点,找到每个问题最好的答案。 让你学懂,掌握,融会贯通。 因为技术知识工作中也会用到,所以踏实学习哦!!!