嵌入式软件工程师面试题——2025校招社招通用(十一)

说明:

  • 面试题来源于网络书籍,公司题目以及博主原创或修改(题目大部分来源于各种公司);
  • 文中很多题目,或许大家直接编译器写完,1分钟就出结果了。但在这里博主希望每一个题目,大家都要经过认真思考,答案不重要,重要的是通过题目理解所考知识点,好应对题目更多的变化;
  • 博主与大家一起学习,一起刷题,共同进步;
  • 写文不易,麻烦给个三连!!!

1.什么是内存泄露,如何检测与避免

答案:
内存泄露
一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用malloc,、realloc、 new等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。
避免内存泄露的几种方式

  • 计数法:使用new或者malloc时,让该数+1,delete或free时,该数-1,程序执行完打印这个计数,如果不为0则表示存在内存泄露
  • 一定要将基类的析构函数声明为虚函数
  • 对象数组的释放一定要用delete []
  • 有new就有delete,有malloc就有free,保证它们一定成对出现

2.对象复用的了解,零拷贝的了解

答案:
对象复用
对象复用其本质是一种设计模式:Flyweight享元模式。
通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。

零拷贝
零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。
零拷贝技术可以减少数据拷贝和共享总线操作的次数。
在C++中,vector的一个成员函数 emplace_back() 很好地体现了零拷贝技术,它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于:使用push_back()函数需要调用拷贝构造函数和转移构造函数,而使用emplace_back()插入的元素原地构造,不需要触发拷贝构造和转移构造,效率更高。举个例子:

#include 
#include 
#include 
using namespace std;

struct Person
{
    string name;
    int age;
    //初始构造函数
    Person(string p_name, int p_age): name(std::move(p_name)), age(p_age)
    {
         cout << "I have been constructed" <<endl;
    }
     //拷贝构造函数
     Person(const Person& other): name(std::move(other.name)), age(other.age)
    {
         cout << "I have been copy constructed" <<endl;
    }
     //转移构造函数
     Person(Person&& other): name(std::move(other.name)), age(other.age)
    {
         cout << "I have been moved"<<endl;
    }
};

int main()
{
    vector<Person> e;
    cout << "emplace_back:" <<endl;
    e.emplace_back("Jane", 23); //不用构造类对象

    vector<Person> p;
    cout << "push_back:"<<endl;
    p.push_back(Person("Mike",36));
    return 0;
}
//输出结果:
//emplace_back:
//I have been constructed
//push_back:
//I have been constructed
//I am being moved.

3.C++的四种强制转换reinterpret_cast/const_cast/static_cast /dynamic_cast

答案:
reinterpret_cast:
用于将一个对象的内存表示重新解释为另一个类型的对象。它可以在不改变对象值的情况下,将一个类型转换为另一个类型。例如:

int a = 1;
double b = reinterpret_cast<double&>(a); // 将整数a的内存表示转换为双精度浮点数b

const_cast:
该运算符用来修改类型的const或volatile属性。除了const 或volatile修饰之外.
用于移除对象的const属性。它可以在需要修改const对象时使用,例如:

const int a = 1;
int* p = const_cast<int*>(&a); // 将const整数a的地址转换为非const整数指针p
*p = 2; // 修改p指向的值

static_cast:
没有运行时类型检查来保证转换的安全性
用于在编译时进行类型转换。它可以在需要显式类型转换时使用,例如:

int a = 1;
double b = static_cast<double>(a); // 将整数a转换为双精度浮点数b

dynamic_cast:
有类型检查,基类向派生类转换比较安全,但是派生类向基类转换则不太安全
用于在运行时进行类型转换。它可以在需要根据对象的实际类型进行类型转换时使用,例如:

class Base { virtual void print() {} };
class Derived : public Base {};

Base* basePtr = new Derived();
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); // 将基类指针basePtr转换为派生类指针derivedPtr

4.C++函数调用的压栈过程

答案:
函数的调用过程:
1)从栈空间分配存储空间
2)从实参的存储空间复制值到形参栈空间
3)进行运算
形参在函数未调用之前都是没有分配存储空间的,在函数调用结束之后,形参弹出栈空间,清除形参空间。
数组作为参数的函数调用方式是地址传递,形参和实参都指向相同的内存空间,调用完成后,形参指针被销毁,但是所指向的内存空间依然存在,不能也不会被销毁。
当函数有多个返回值的时候,不能用普通的 return 的方式实现,需要通过传回地址的形式进行,即地址/指针传递。

#include 
using namespace std;

int f(int n) 
{
	cout << n << endl;
	return n;
}

void func(int param1, int param2)
{
	int var1 = param1;
	int var2 = param2;
	printf("var1=%d,var2=%d", f(var1), f(var2));//如果将printf换为cout进行输出,输出结果则刚好相反
}

int main(int argc, char* argv[])
{
	func(1, 2);
	return 0;
}
//输出结果
//2
//1
//var1=1,var2=2

5.谈一谈移动构造函数

答案:

  • 我们用对象a初始化对象b后对象a我们就不在使用了,但是对象a的空间还在呀(在析构之前),既然拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;
  • 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间;
  • 移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只用用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值。
class Person {
public:
    Person() : name(""), age(0) {}
    Person(const std::string& n, int a) : name(n), age(a) {}
    Person(Person&& other) noexcept : name(std::move(other.name)), age(other.age) {
        other.name = "";
        other.age = 0;
    }

private:
    std::string name;
    int age;
};

6.如何获得结构成员相对于结构开头的字节偏移量

答案:
使用头文件中的,offsetof宏。
示例如下:

#include 
#include 
using namespace std;

struct  S
{
	int x;
	char y;
	int z;
	double a;
};
int main()
{
	cout << offsetof(S, x) << endl; // 0
	cout << offsetof(S, y) << endl; // 4
	cout << offsetof(S, z) << endl; // 8
	cout << offsetof(S, a) << endl; // 12
	return 0;
}

7.静态类型和动态类型,静态绑定和动态绑定的介绍

答案:

  • 静态类型:对象在声明时采用的类型,在编译期既已确定;
  • 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
  • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
  • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

从上面的定义也可以看出,非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。 举个例子:

#include 
using namespace std;

class A
{
public:
	/*virtual*/ void func() { std::cout << "A::func()\n"; }
};
class B : public A
{
public:
	void func() { std::cout << "B::func()\n"; }
};
class C : public A
{
public:
	void func() { std::cout << "C::func()\n"; }
};
int main()
{
	C* pc = new C(); //pc的静态类型是它声明的类型C*,动态类型也是C*;
	B* pb = new B(); //pb的静态类型和动态类型也都是B*;
	A* pa = pc;      //pa的静态类型是它声明的类型A*,动态类型是pa所指向的对象pc的类型C*;
	pa = pb;         //pa的动态类型可以更改,现在它的动态类型是B*,但其静态类型仍是声明时候的A*;
	C *pnull = NULL; //pnull的静态类型是它声明的类型C*,没有动态类型,因为它指向了NULL;
    
    pa->func();      //A::func() pa的静态类型永远都是A*,不管其指向的是哪个子类,都是直接调用A::func();
	pc->func();      //C::func() pc的动、静态类型都是C*,因此调用C::func();
	pnull->func();   //C::func() 不用奇怪为什么空指针也可以调用函数,因为这在编译期就确定了,和指针空不空没关系;
	return 0;
}

如果将A类中的virtual注释去掉,则运行结果是:

a->func();      //B::func() 因为有了virtual虚函数特性,pa的动态类型指向B*,因此先在B中查找,找到后直接调用;
pc->func();      //C::func() pc的动、静态类型都是C*,因此也是先在C中查找;
pnull->func();   //空指针异常,因为是func是virtual函数,因此对func的调用只能等到运行期才能确定,然后才发现pnull是空指针;
 

在上面的例子中,

  • 如果基类A中的func不是virtual函数,那么不论pa、pb、pc指向哪个子类对象,对func的调用都是在定义pa、pb、pc时的静态类型决定,早已在编译期确定了。
  • 同样的空指针也能够直接调用no-virtual函数而不报错(这也说明一定要做空指针检查啊!),因此静态绑定不能实现多态;
  • 如果func是虚函数,那所有的调用都要等到运行时根据其指向对象的类型才能确定,比起静态绑定自然是要有性能损失的,但是却能实现多态特性;

至此总结一下静态绑定和动态绑定的区别:

  • 静态绑定发生在编译期,动态绑定发生在运行期;
  • 对象的动态类型可以更改,但是静态类型无法更改;
  • 要想实现动态,必须使用动态绑定
  • 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;

8.指针加减计算要注意什么?

答案:

  • 对于指针的减法运算,其结果表示两个指针之间间隔了多少个“对应类型”的数据。例如,如果有两个整数指针p和q,它们之间的差值就是它们所指向的整数在内存中的距离(以字节为单位)
  • 关于指针的加法运算,它通常用于遍历数组或移动到下一个元素的位置。然而,必须注意的是,对指针进行加法运算并没有直接的意义。例如,不能简单地将一个整数加到一个指针上,也不能将两个指针相加得到一个新的指针。此外,当我们试图通过指针加减数字来移动指针位置时,这个数字必须是指针所指向数据类型的大小。例如,如果我们有一个整型指针p并想要将其向前移动4个位置,我们应该写成p += 4 * sizeof(int)。
  • 要避免访问未分配的内存或已经被释放的内存。这可能会导致程序崩溃或其他未定义的行为。
    举个栗子:
include <iostream>
using namespace std;

int main()
{
	int *a, *b, c;
	a = (int*)0x500;
	b = (int*)0x520;
	c = b - a;
	printf("%d\n", c); // 8
	a += 0x020;
	c = b - a;
	printf("%d\n", c); // -24
	return 0;
}

首先变量a和b都是以16进制的形式初始化,将它们转成10进制分别是1280(5162=1280)和1312(5*162+216=1312), 那么它们的差值为32,也就是说a和b所指向的地址之间间隔32个位,但是考虑到是int类型占4位,所以c的值为32/4=8

a自增16进制0x20之后,其实际地址变为1280 + 2164 = 1408,(因为一个int占4位,所以要乘4),这样它们的差值就变成了1312 - 1408 = -96,所以c的值就变成了-96/4 = -24

9.C++怎样判断两个浮点数是否相等?

答案:
在计算机编程中,由于浮点数的精度问题,直接比较两个浮点数是否相等可能会得到错误的结果。因此,通常采用一种称为“浮点数比较”的方法来判断两个浮点数是否相等。
以下是一种常见的方法:

  • 定义一个非常小的正数(例如0.00001),称为机器精度或epsilon。
  • 如果两个浮点数的差的绝对值小于等于epsilon,则认为这两个浮点数相等。

举个栗子:

#include 
#include 

bool float_equal(float a, float b, float epsilon = 1e-6) {
    return std::abs(a - b) < epsilon;
}

int main() {
    float num1 = 0.1 + 0.2;
    float num2 = 0.3;

    if (float_equal(num1, num2)) {
        std::cout << "两个浮点数相等" << std::endl;
    } else {
        std::cout << "两个浮点数不相等" << std::endl;
    }

    return 0;
}

10.类如何实现只能静态分配和只能动态分配

  • 前者是把new、delete运算符重载为private属性。后者是把构造、析构函数设为protected属性,再用子类来动态创建
  • 建立类的对象有两种方式:
    • 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;
    • 动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;
  • 只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上,可以将new运算符设为私有。

11.继承机制中对象之间如何转换?指针和引用之间如何转换?

答案:

对象之间的转换主要有两种类型:向上类型转换和向下类型转换。

向上类型转换
将派生类指针或引用转换为基类的指针或引用被称为向上类型转换。这种转换会自动进行,是安全的。例如,如果我们有一个指向派生类对象的基类指针,我们可以直接使用这个指针来操作派生类对象,无需进行任何额外的转换。
向下类型转换:
将基类指针或引用转换为派生类指针或引用被称为向下类型转换。这种转换需要显式进行,且只有在运行时才能确定是否成功。向上类型转换和向下类型转换的主要区别在于,向上类型转换是自动的、安全的,而向下类型转换需要显式进行,并可能存在风险。

指针和引用之间的转换主要有两种形式:指针转引用和引用转指针。
指针转引用
这是将一个指针赋值给一个引用。例如,当我们有一个指向某个对象的指针p时,我们可以创建一个引用r,使得r指向与p相同的对象。具体来说,我们可以通过使用*操作符来完成这个转换,如:int a = 10; int *p = &a; int &r = *p;。
引用转指针:
这是将一个引用赋值给一个指针。例如,如果我们有一个引用r,我们可以创建一个指针p,使得p指向与r相同的对象。为了实现这一点,我们可以使用&操作符来获取r的地址,如:int a = 10; int &r = a; int *p = &r;。

你可能感兴趣的:(嵌入式面试题,面试,嵌入式,C++,C)