面试真题 | CVTE[20240916]
@[toc]
1、C++单例模式
回答 C++ 单例模式问题
问题概述: 在C++中,单例模式是一种常用的设计模式,用于确保一个类仅有一个实例,并提供一个全局访问点来获取该实例。这种模式在需要控制资源访问(如数据库连接、配置管理器等)时非常有用。
详细回答:
实现C++中的单例模式,通常需要考虑线程安全和懒汉式(Lazy Initialization)与饿汉式(Eager Initialization)的区别。
1. 饿汉式单例(线程安全,但非懒加载)
饿汉式单例在类声明时就完成了实例的初始化,因此是线程安全的,但无论是否使用到这个实例,它都会被创建,这可能会造成资源的浪费。
class Singleton {
private:
static Singleton instance; // 静态实例
Singleton() {} // 私有构造函数,防止外部创建实例
public:
static Singleton& getInstance() {
return instance;
}
// 禁止拷贝构造函数和赋值运算符
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
// 静态成员变量需要在类外定义
static Singleton instance_;
};
// 类外定义静态成员变量
Singleton Singleton::instance_;
2. 懒汉式单例(懒加载,但需注意线程安全)
懒汉式单例在第一次调用getInstance()
时才创建实例,实现了懒加载。但如果不加处理,在多线程环境下可能会创建多个实例,因此需要加锁来保证线程安全。
#include <mutex>
class Singleton {
private:
static Singleton* instance; // 静态指针
static std::mutex mtx; // 静态互斥锁
Singleton() {} // 私有构造函数
public:
static Singleton* getInstance() {
if (instance == nullptr) {
std::lock_guard<std::mutex> lock(mtx); // 加锁
if (instance == nullptr) { // 双重检查锁定
instance = new Singleton();
}
}
return instance;
}
// 禁止拷贝构造函数和赋值运算符
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
~Singleton() {
delete instance; // 注意:这里可能导致野指针问题,通常使用智能指针或避免在单例类中管理资源
}
private:
static Singleton* instance_;
static std::mutex mtx_;
};
// 类外定义静态成员变量
Singleton* Singleton::instance_ = nullptr;
std::mutex Singleton::mtx_;
注意:懒汉式单例在析构时需要注意,如果其他部分还持有该单例的指针,直接删除实例可能会导致野指针问题。一种解决方案是使用智能指针(如std::unique_ptr
),但需注意智能指针的拥有权转移问题。
面试官追问:
-
你提到的双重检查锁定(Double-Checked Locking)是为了解决什么问题?它总是线程安全的吗?
- 回答:双重检查锁定是为了减少锁的开销,只在第一次创建实例时加锁。但在某些编译器和平台下,由于指令重排等问题,它可能不是完全线程安全的。
-
有没有其他方式实现线程安全的单例,而不需要使用锁?
- 回答:可以使用C++11中的
std::call_once
和std::once_flag
,或者利用局部静态变量的初始化特性(在函数内部定义的静态变量是线程安全的,且只初始化一次)。
- 回答:可以使用C++11中的
-
在你的懒汉式单例实现中,如果
Singleton
类管理了资源(如文件句柄、网络连接等),析构时直接delete
实例可能会导致什么问题?如何解决?- 回答:直接
delete
实例可能导致在单例析构时,其他部分还试图访问该单例,造成野指针访问或资源访问错误。可以通过智能指针(如std::shared_ptr
,但需注意循环引用问题)或确保在程序结束前不再使用单例来避免这个问题。更好的做法是让单例不管理资源,而是作为资源的访问点。
- 回答:直接
2、虚函数、纯虚函数
当然,我可以更具体地解释虚函数和纯虚函数在C++中的细节,以及它们之间的关系和区别。
虚函数(Virtual Functions)
定义与特性:
- 虚函数是基类中使用
virtual
关键字声明的成员函数。它允许在派生类中被重写(覆盖),以实现多态性。 - 当通过基类指针或引用调用虚函数时,会根据对象的实际类型(即对象的运行时类型)来调用相应的函数版本,这种机制称为动态绑定或晚期绑定。
- 虚函数在基类中可以有默认实现,即使派生类没有重写该虚函数,也可以通过基类指针或引用调用基类中的虚函数实现。
实现机制:
- 虚函数的实现依赖于虚函数表(Virtual Table, VTable)和虚函数指针(Virtual Pointer, VPtr)。
- 每个包含虚函数的类都有一个对应的虚函数表,表中存储了该类所有虚函数的地址。
- 当类的对象被创建时,编译器会在对象内部添加一个虚函数指针,该指针指向对象的虚函数表。
- 当通过基类指针或引用调用虚函数时,编译器会首先找到该指针所指向的虚函数表,然后在表中查找对应函数的地址,并调用该函数。
用途:
- 虚函数主要用于实现运行时多态性,使得基类指针或引用可以指向派生类对象,并调用派生类中重写的虚函数。
纯虚函数(Pure Virtual Functions)
定义与特性:
- 纯虚函数是在基类中声明的虚函数,但它没有具体的实现,仅用于在接口中声明函数的存在。在函数声明的末尾加上
= 0
来标记为纯虚函数。 - 包含至少一个纯虚函数的类被称为抽象类。抽象类不能实例化对象,主要用于定义接口,强制派生类实现特定的功能。
- 纯虚函数在派生类中必须被重写,否则派生类也将成为抽象类。
实现方式:
- 纯虚函数在基类中通常没有实现体(即没有函数体,只有声明)。但是,在某些情况下,为了提供默认行为或辅助实现,基类可能会提供一个受保护的成员函数(非纯虚函数)作为默认实现。
- 派生类通过重写纯虚函数来提供具体的实现,从而满足接口要求。
用途:
- 纯虚函数主要用于实现接口继承,即定义一个类应该具备哪些功能(接口),但不具体实现这些功能,留给派生类去实现。
- 它强制派生类实现特定的函数,从而保证了类的层次结构中的一致性和可扩展性。
虚函数与纯虚函数的主要区别
定义 | 基类中使用virtual 关键字声明的成员函数,可以有默认实现 |
基类中使用virtual 关键字声明并在末尾加上= 0 的成员函数,没有具体实现 |
实现 | 可以在基类或派生类中有具体实现 | 必须在派生类中有具体实现,否则派生类也将成为抽象类 |
用途 | 实现运行时多态性,允许基类指针或引用调用派生类中的重写函数 | 定义接口,强制派生类实现特定的功能 |
类的类型 | 所在的类可以是抽象类(如果包含其他纯虚函数),也可以是普通类 | 所在的类一定是抽象类 |
实例化 | 所在的类可以被实例化 | 所在的类不能被实例化,只能作为基类使用 |
面试官可能的追问
-
虚函数和纯虚函数在内存中的表现有何不同?
- 虚函数和纯虚函数在内存中的表现主要体现在虚函数表和虚函数指针上。每个包含虚函数的类都有一个虚函数表,而每个类的对象都有一个指向该虚函数表的虚函数指针。纯虚函数的存在并不影响虚函数表和虚函数指针的生成,但含有纯虚函数的类(抽象类)不能实例化对象,因此其对象的内存布局(如果不考虑通过派生类实例化)在理论上是不可见的。
-
虚析构函数的作用是什么?为什么需要它?
- 虚析构函数用于确保通过基类指针删除派生类对象时,能够正确调用派生类的析构函数,从而避免资源泄露。如果不将析构函数声明为虚函数,当通过基类指针删除派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致派生类部分占用的资源没有被正确释放。
-
虚函数和函数重载、函数覆盖有什么区别?
- 函数重载(Overloading)是在同一作用域内,函数名相同但参数列表(参数类型、个数或顺序)不同的函数。它与虚函数无关,是编译时多态的一种实现方式。
- 函数覆盖(Overriding)是指派生类中存在一个与基类中的虚函数名称、参数列表和返回类型都相同的函数,该函数会覆盖基类中的虚函数实现。它是运行时多态的一种实现方式,与虚函数紧密相关。
- 虚函数是实现函数覆盖的基础,而函数重载与虚函数、函数覆盖是并行的概念,它们分别解决了不同的问题。
3、堆和栈区别
回答堆和栈的区别
在嵌入式系统或任何使用C/C++等语言开发的系统中,堆(Heap)和栈(Stack)是两种基本的内存分配区域,它们在管理内存的方式、用途、性能特点以及生命周期等方面存在显著差异。
-
管理方式:
- 栈(Stack):是一种后进先出(LIFO)的数据结构,由编译器自动管理。每当函数调用时,会在栈上为其局部变量分配空间,并在函数返回时自动释放这些空间。栈的大小在程序编译时就已确定,通常是有限的。
- 堆(Heap):是一种动态内存分配的区域,由程序员手动通过如
malloc
、calloc
、realloc
(C语言中)或new
(C++中)等函数申请,并通过free
或delete
等函数释放。堆的大小通常远大于栈,且大小仅受物理内存或操作系统限制。
-
用途:
- 栈:主要用于存储局部变量、函数参数、返回地址等,是函数调用的基础。
- 堆:用于存储程序运行期间动态分配的内存,如大型数据结构、动态数组、字符串等。
-
性能:
- 栈:由于栈的分配和释放由编译器自动处理,且栈空间是连续的,因此栈上的内存分配和释放通常非常快。
- 堆:堆内存的分配和释放需要操作系统进行,可能涉及复杂的算法和内存碎片管理,因此相比栈而言,堆内存分配和释放的速度较慢,且可能引入额外的性能开销。
-
生命周期:
- 栈:栈上变量的生命周期与所在函数的执行周期相同,一旦函数执行完毕,栈上为该函数分配的所有空间都将被自动释放。
- 堆:堆上分配的内存除非显式释放,否则将一直存在,这可能导致内存泄漏。
面试官可能的追问
-
嵌入式系统中对堆和栈的使用有什么特别的注意事项?
- 在嵌入式系统中,由于资源通常较为有限(如内存和处理器速度),因此合理使用堆和栈变得尤为重要。尽量避免在栈上分配大量内存或使用深层次的递归调用,以防止栈溢出。同时,对于堆内存的管理,需要特别注意内存泄漏和碎片问题,确保系统的稳定性和可靠性。
-
能否给出一个具体的例子,说明在什么情况下你会选择使用堆而不是栈?
- 当需要动态地分配一个大小在运行时才能确定的数据结构时(如用户输入的数据长度),或者需要长期存储某个对象而该对象的生命周期不局限于某个函数或方法时,通常会选择使用堆内存。例如,实现一个动态数组或链表时,由于它们的长度在编译时无法确定,因此需要在堆上动态分配内存。
-
在嵌入式系统中,如何避免堆内存泄漏?
- 避免堆内存泄漏的关键在于确保每次
malloc
/new
操作后都有对应的free
/delete
操作。此外,可以采用一些辅助工具或策略,如智能指针(在C++中)、内存池技术、定期的内存泄漏检测工具等,来帮助开发者及时发现和修复内存泄漏问题。同时,良好的编程习惯和代码审查也是预防内存泄漏的重要手段。
- 避免堆内存泄漏的关键在于确保每次
怎么判断栈的大小?方法?
在嵌入式系统中,栈(Stack)的大小是一个重要的考量因素,因为它直接关系到程序的稳定性和可靠性。栈主要用于存储局部变量、函数调用参数、返回地址等临时数据。判断栈的大小通常可以通过以下几种方法来实现:
1. 编译器和链接器设置
最直接的方式是通过编译器和链接器的设置来指定栈的大小。在嵌入式开发中,这通常是在项目的构建配置中完成的。例如,在使用GCC编译器时,可以通过-Wl,--stack,size
(其中size
是具体的栈大小,单位通常是字节)这样的链接器选项来设置栈的大小。这种方法最直接,但需要开发者在编译时就明确知道所需的栈大小。
2. 运行时检查
在某些情况下,可能需要在程序运行时动态地检查栈的使用情况。这通常涉及到在栈的起始和结束位置放置特定的标记(例如使用特定的内存模式或调试工具),并在程序执行过程中定期检查这些标记之间的空间使用情况。这种方法比较复杂,需要深入理解目标平台的内存布局和栈的使用机制。
3. 使用调试器和工具
在开发过程中,可以使用调试器(如GDB)或专门的内存分析工具来查看栈的使用情况。这些工具通常能够显示栈的当前大小、栈的使用趋势以及栈溢出的警告。这是调试和性能分析阶段常用的方法。
4. 查看系统文档和限制
对于特定的嵌入式系统或操作系统,其栈的大小可能受到硬件或操作系统的限制。因此,查阅相关的系统文档或开发指南也是了解栈大小的一个重要途径。
面试官追问:
-
如果你在设置栈大小时估计得太小,程序运行时可能会发生什么问题?
- 回答:如果栈的大小设置得过小,程序在运行过程中可能会因为栈溢出而崩溃。栈溢出通常会导致程序异常终止,甚至可能影响到系统的稳定性。
-
在嵌入式系统中,为什么栈的大小这么重要?
- 回答:在嵌入式系统中,资源通常非常有限。栈的大小直接影响到程序的运行效率和稳定性。如果栈太小,可能会限制程序的递归深度或导致局部变量溢出;如果栈太大,则会浪费宝贵的内存资源。因此,合理设置栈的大小对于嵌入式系统的开发至关重要。
-
有没有遇到过因为栈大小设置不当而导致的实际问题?你是如何解决的?
- 回答:(根据个人经验回答)例如,在开发某个嵌入式应用时,由于最初对栈的大小估计不足,导致在深度递归调用时出现了栈溢出的问题。通过增加栈的大小并使用调试器来观察栈的使用情况,最终解决了这个问题。同时,也学会了在开发过程中更加关注栈的使用情况,以避免类似问题的发生。
4、xv6启动流程
xv6启动流程
xv6是一个简化的Unix-like操作系统,主要用于教学和研究目的,特别是针对RISC-V架构。在xv6的启动流程中,主要涉及到硬件层面的初始化、引导加载程序(bootloader)的执行、内核的加载以及内核的初始化等步骤。以下是对xv6启动流程的详细解析:
1. 硬件上电与初始化
- 上电复位:当按下系统电源按键后,计算机首先进行硬件复位,确保所有寄存器和其他硬件组件处于初始状态。
- 检查和测试硬件:计算机进行一系列硬件检查和自检操作,包括内存检测、CPU测试等,以确保硬件组件功能正常。
- 初始化硬件组件:初始化和配置计算机上的各种硬件组件,如内存控制器、输入输出设备和其他外设,为这些组件提供电源并设置时钟信号。
- 读取配置数据:从非易失性存储器(如CMOS)读取系统配置数据,如BIOS设置、引导设备顺序等。这些操作通常由计算机的固件(如BIOS或UEFI)完成。
2. 引导加载程序
- 执行引导加载程序:计算机将运行存储在只读存储器(ROM)中的引导加载程序。该程序的职责是将xv6内核加载到内存中,并将执行权交给内核。在RISC-V架构中,这一步骤通常由QEMU模拟器或实际的硬件平台完成。
3. 内核加载与初始化
-
内核加载:引导加载程序将xv6内核加载到内存的特定位置(通常是物理地址0x80000000处,因为地址范围0x0:0x80000000包含I/O设备)。
-
执行_entry代码:在Machine mode下,CPU从kernel/entry.S的_entry处开始执行。此时,虚拟内存尚未工作,虚拟地址直接映射到物理地址。
- 设置栈指针(sp),为每个CPU分配一个4096字节的栈。
- 读取当前CPU的硬件线程ID(hartid),并计算栈的起始地址。
- 跳转到start()函数(位于kernel/start.c)。
-
start()函数执行:
- 配置处理器状态寄存器(mstatus),将之前的特权模式设置为Supervisor mode,并设置返回地址(mepc)为main函数的地址。
- 禁用分页机制(将satp设置为0)。
- 将所有中断和异常委托给Supervisor mode处理。
- 配置物理内存保护,以允许Supervisor mode访问所有物理内存。
- 初始化时钟中断,以便操作系统能够进行多任务调度和计时。
- 切换到Supervisor mode,并跳转到main()函数执行。
4. 进入main()函数
- main()函数执行:在Supervisor mode下,xv6内核的main()函数开始执行。该函数负责初始化内核的剩余部分,包括文件系统、网络协议栈等。
- 用户态进程启动:一旦内核初始化完成,它会启动第一个用户态进程(通常是init进程)。此后,操作系统进入正常的运行状态,等待用户输入或处理其他事件。
面试官追问示例
-
在xv6中,为什么选择将内核加载到物理地址0x80000000处?
- 答:xv6选择将内核加载到物理地址0x80000000处,主要是因为地址范围0x0:0x80000000通常被I/O设备占用。为了避免与I/O设备地址空间冲突,xv6将内核放置在更高的物理地址上。
-
_entry函数中是如何设置栈指针的?
- 答:在_entry函数中,首先通过读取当前CPU的硬件线程ID(hartid)来计算每个CPU的栈起始地址。然后,将该地址加载到栈指针(sp)寄存器中,以便后续执行C代码时能够使用正确的栈空间。
-
xv6是如何处理时钟中断的?
- 答:xv6通过编程时钟芯片来产生定时器中断。在start()函数中,xv6会调用timerinit()函数来初始化时钟芯片。该函数会设置时钟芯片的下一个中断时间,并将中断处理函数注册到中断控制器中。当中断发生时,CPU会跳转到相应的中断处理函数执行,从而触发操作系统的任务调度等操作。
5、C++重写
在C++中,重写(Override)是面向对象编程中的一个重要概念,它允许子类提供一个特定签名的成员函数,该函数将替换(或重写)基类中具有相同签名(即相同的函数名、返回类型、参数列表和const
限定符)的虚函数。重写是多态性的一个关键方面,它允许通过基类的指针或引用来调用派生类中定义的函数版本。
完整且有深度的回答
在C++中,要正确地重写基类中的虚函数,你需要遵循几个规则:
-
函数签名必须匹配:派生类中的重写函数必须与基类中的虚函数在函数名、返回类型(协变返回类型除外)、参数列表以及
const
限定符上完全匹配。 -
访问权限:派生类中的重写函数可以有更宽松的访问权限(例如,基类中的函数是
protected
,派生类中的可以是public
),但不能有更严格的访问权限(如基类中的是public
,派生类中的不能是private
)。 -
静态和非静态成员函数:静态成员函数不能被重写,因为静态成员函数属于类而不是类的实例,因此不参与多态。
-
协变返回类型:C++11及以后的版本支持协变返回类型,即派生类中的重写函数可以返回基类虚函数返回类型的派生类。
-
final关键字:如果一个类被声明为
final
,则该类不能被继承,因此其成员函数也不能被重写。同样,如果一个虚函数被声明为final
,则它不能在派生类中被重写。 -
override关键字(C++11及以后):虽然不是强制性的,但使用
override
关键字可以显式地指示一个函数旨在重写基类中的虚函数。如果编译器发现该函数没有正确地重写基类中的任何虚函数,则会报错。
示例代码
class Base {
public:
virtual void func() {
std::cout << "Base::func()" << std::endl;
}
virtual ~Base() {}
};
class Derived : public Base {
public:
void func() override { // 注意override关键字
std::cout << "Derived::func()" << std::endl;
}
};
int main() {
Base* ptr = new Derived();
ptr->func(); // 输出:Derived::func(),展示了多态性
delete ptr;
return 0;
}
面试官追问
-
如果在派生类中有一个与基类虚函数同名的函数,但参数列表不同,这算不算重写?
- 答:不算。这称为函数重载(Overloading),不是重写。重写要求函数签名完全匹配。
-
如果基类中的虚函数是私有的,派生类能否重写它?
- 答:不能。私有成员函数在派生类中是不可见的,因此无法在派生类中进行重写。如果需要在派生类中重写基类中的函数,则该函数在基类中至少应该是受保护的(protected)或公有的(public)。
-
override
关键字除了提供编译时检查外,还有其他作用吗?- 答:
override
关键字主要用于编译时检查,确保开发者意图重写基类中的虚函数。此外,它还可以提高代码的可读性,让其他开发者更容易理解代码的意图。
- 答:
-
在多继承的情况下,如果两个基类有同名的虚函数,派生类如何正确地重写它们?
- 答:在多继承的情况下,如果两个基类有同名的虚函数,派生类可以通过作用域解析操作符(
::
)来指定它想要重写哪个基类的虚函数。然而,更常见的是让这两个基类继承自一个共同的接口类(也称为抽象基类),并在该接口类中声明虚函数。这样,派生类只需重写接口类中的虚函数即可。
- 答:在多继承的情况下,如果两个基类有同名的虚函数,派生类可以通过作用域解析操作符(
6、智能指针
智能指针(Smart Pointers)
智能指针是C++(以及在一些其他语言中)中用于自动管理动态分配内存的指针类模板。它们通过封装裸指针(raw pointers),提供对动态分配内存的自动释放功能,从而避免了内存泄漏和野指针的问题。智能指针的主要类型包括std::unique_ptr
、std::shared_ptr
和std::weak_ptr
(这些是C++11及以后版本中提供的)。
1. std::unique_ptr
std::unique_ptr
表示对对象的独占所有权。同一时间内只能有一个std::unique_ptr
指向一个给定的对象(通过禁用拷贝构造函数和拷贝赋值运算符,只提供移动语义)。当std::unique_ptr
被销毁时,它所指向的对象也会被自动删除。
使用场景:适用于那些只应有一个所有者管理其生命周期的资源,如动态分配的内存、文件句柄等。
面试官追问:
std::unique_ptr
是如何实现独占所有权的?- 如果尝试拷贝一个
std::unique_ptr
会发生什么?如何解决这个问题? std::u
和是如何协同工作的?
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
让实战与真题助你offer满天飞!!! 每周更新!!! 励志做最全ARM/Linux嵌入式面试必考必会的题库。 励志讲清每一个知识点,找到每个问题最好的答案。 让你学懂,掌握,融会贯通。 因为技术知识工作中也会用到,所以踏实学习哦!!!