Skip to content

大疆嵌入式面经

来源:https://www.nowcoder.com/discuss/433017686663135232

1、自我介绍

2、项目中用了多线程与多进程,谈谈你的理解?

这里讲讲多线程与多进程。

多线程:

  1. 概念:多线程是指在同一进程中运行的多个线程,每个线程都有自己的执行流,但共享相同的地址空间和资源。多线程是轻量级的,线程切换的开销相对较小。
  2. 实现:在C++中,多线程通常通过使用标准库中的 <thread> 头文件来实现,例如 std::thread。线程间通信可以使用互斥锁、条件变量等机制。
  3. 适用场景:适用于任务之间有共享数据、需要高效的数据交换和通信的场景。多线程适合在同一进程内进行并发计算,但需要注意线程安全性和避免竞态条件。

多进程:

  1. 概念:多进程是指在操作系统中同时运行的多个独立的进程,每个进程有自己独立的地址空间和资源,彼此之间不共享数据。多进程通信相对复杂,通常需要使用进程间通信(IPC)机制。
  2. 实现:在C++中,多进程可以通过创建新的进程来实现,可以使用 <cstdlib> 头文件中的 fork 函数等。进程间通信可以使用管道、消息队列、共享内存等机制。
  3. 适用场景:适用于需要在不同进程之间进行独立计算、数据隔离、稳定性要求较高的场景。多进程适合在不同的进程中执行独立的任务,但需要注意进程间通信的开销和复杂性。

3、线程,进程间通信方式?

线程间通信方式:

  1. 互斥锁(Mutex):
    • 概念: 互斥锁是用来保护临界区的一种同步机制。只有成功获取锁的线程才能访问共享资源,其他线程需要等待锁的释放。
    • 使用: 线程通过调用 std::mutex 提供的成员函数 lockunlock 来进行加锁和解锁。
  2. 条件变量(Condition Variable):
    • 概念: 条件变量用于线程间的通知和等待。一个线程可以通过条件变量等待某个条件的发生,而另一个线程则可以通过条件变量发出信号来通知等待线程。
    • 使用: C++ 中使用 std::condition_variablestd::unique_lock 实现条件变量。
  3. 信号量(Semaphore):
    • 概念: 信号量是一种计数器,用于控制多个线程对共享资源的访问。线程可以通过对信号量进行 P 操作(等待)和 V 操作(释放)来进行同步。
    • 使用: C++11 引入了 std::semaphore
  4. 屏障(Barrier):
    • 概念: 屏障用于同步多个线程的执行,使它们在达到屏障点之前都会被阻塞,直到所有线程都到达后才继续执行。
    • 使用: C++11 引入了 std::barrier
  5. 线程安全队列:
    • 概念: 线程安全队列是一种数据结构,可以由一个线程安全地插入数据,而由另一个线程安全地取出数据。
    • 使用: 可以基于互斥锁和条件变量实现线程安全队列。
  6. 原子操作(Atomic Operations):
    • 概念: 原子操作是一种不可分割的操作,保证在多线程环境中的原子性。C++ 提供了 std::atomic 类型和相关的原子操作函数,用于实现线程安全的操作。
  7. 消息队列:
    • 概念: 消息队列是一种线程间通信的方式,其中一个线程可以向队列发送消息,而另一个线程可以从队列接收消息。
    • 使用: 通过使用线程安全的队列实现。
  8. 读写锁(Read-Write Lock):
    • 概念: 读写锁允许多个线程同时读取共享资源,但在写入时需要独占。这在某些场景下可以提高并发性能。
    • 使用: C++ 中使用 std::shared_mutex 实现读写锁。

进程间通信方式:

  1. 管道(Pipe): 父进程创建管道,然后通过 fork 创建子进程。父子进程之间可以通过管道进行通信,管道可以是单向的(父到子或子到父),也可以是双向的。
  2. 命名管道(FIFO): 是一种特殊的文件,用于进程间通信。多个进程可以通过打开同一个 FIFO 文件来进行通信。
  3. 消息队列: 进程可以使用消息队列进行通信,通过系统调用 msggetmsgsndmsgrcv 来发送和接收消息。
  4. 共享内存: 多个进程可以映射同一块共享内存,通过对共享内存的读写实现进程间通信。需要使用信号量等机制确保同步。
  5. 信号(Signal): 进程可以通过发送信号给其他进程来进行通信。信号是一种异步通信方式,常见的信号有 SIGKILLSIGTERM 等。
  6. 套接字(Socket): 在网络编程中,套接字被用于在不同主机上的进程间通信。也可以在同一主机上的不同进程间使用本地套接字。
  7. 文件锁: 进程可以通过文件锁来实现对文件的访问控制,从而进行通信。

4、有名管道与匿名管道区别?

  1. 有名管道(Named Pipe):
  • 命名: 有名管道有一个名字(路径),通过这个名字可以在不同进程中进行通信。在文件系统中可以看到相关的命名管道文件。
  • 进程间通信: 有名管道可以用于不相关的进程间通信,这些进程可以是不同的应用程序。
  • 持久性: 有名管道是持久的,即使创建它的进程终止,命名管道仍然存在于文件系统中,直到被显式地删除。
  • 创建和删除: 使用命令行工具或相关的系统调用可以创建和删除有名管道。
  1. 匿名管道(Anonymous Pipe):
  • 命名: 匿名管道没有名字,只能在相关的父子进程之间通信。
  • 进程间通信: 匿名管道主要用于相关的父子进程间通信,通常在一个进程中创建,然后通过 fork(Unix/Linux)或 CreateProcess(Windows)传递给子进程。
  • 临时性: 匿名管道是临时的,一旦相关的进程结束,管道就会被销毁。
  • 创建和删除: 匿名管道通常是在进程运行时创建的,无需显式的创建和删除操作。

区别:

  • 持久性: 有名管道是持久的,而匿名管道是临时的。
  • 命名: 有名管道有名字,而匿名管道没有。
  • 进程间通信: 有名管道可以用于不相关的进程间通信,而匿名管道主要用于相关的父子进程间通信。

5、说一下条件变量怎么实现?

通常与互斥锁(Mutex)一起使用。

  1. 互斥锁(Mutex): 互斥锁用于保护被条件变量控制的共享资源,确保在条件变量的等待和通知中不会发生竞争条件。互斥锁提供了对共享资源的独占访问。
  2. 条件变量(Condition Variable): 条件变量用于在一个或多个线程之间传递信号,通知其他线程发生了某个事件。条件变量通常与互斥锁一起使用,以确保在检查条件和等待之间的原子性操作。
  3. 共享条件变量的条件(Predicate): 条件变量需要与共享的条件一起使用,以确定等待线程是否应该继续执行或继续等待。这通常是由用户定义的布尔表达式或其他条件组成的。

代码示例:

c++
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void worker_thread() {
    std::unique_lock<std::mutex> lock(mtx);
    
    // 等待条件变为真
    cv.wait(lock, [] { return ready; });

    // 条件为真后执行的操作
    std::cout << "Worker thread is processing..." << std::endl;
}

int main() {
    std::thread worker(worker_thread);

    // 做一些工作...

    // 设置条件为真,并通知等待的线程
    {
        std::lock_guard<std::mutex> lock(mtx);
        ready = true;
    }
    cv.notify_one();

    worker.join();

    return 0;
}

6、信号量机制怎么实现的?该机制可以用于进程间通信吗?信号量机制中PV操作是原子操作吗?

信号量是一种用于线程或进程之间同步与互斥的机制,通常包括两个主要操作:P(等待)和V(发信号)。在C++中,信号量的实现通常基于互斥锁和条件变量。

信号量基本实现:

c++
#include <mutex>
#include <condition_variable>

class Semaphore {
public:
    explicit Semaphore(int count) : count_(count) {}

    void P() {
        std::unique_lock<std::mutex> lock(mutex_);
        while (count_ <= 0) {
            // 等待资源可用
            condition_.wait(lock);
        }
        --count_;
    }

    void V() {
        std::lock_guard<std::mutex> lock(mutex_);
        ++count_;
        // 通知等待的线程
        condition_.notify_one();
    }

private:
    std::mutex mutex_;
    std::condition_variable condition_;
    int count_;
};

例子中,P 操作用于获取资源,如果资源不可用,则线程会等待;V 操作用于释放资源,并通知等待的线程。

PV操作是否原子:

在一般情况下,PV操作不是原子的,因为它们涉及到检查和更新信号量的值。为了确保原子性,通常需要使用额外的同步机制,例如互斥锁。在上述的信号量实现中,P 操作和 V 操作分别使用了互斥锁,以确保在多线程环境下的原子性。

7、nuttx中(项目)原子操作怎么实现?linux中原子操作怎么实现的?讲讲具体流程

8、linux中断流程,谈谈你对中断上下文的理解

Linux中断的基本流程:

  1. 中断触发:外部设备或其他硬件产生中断信号,向CPU发送中断请求。
  2. 中断响应:CPU收到中断请求后,暂停当前执行的任务,将控制权转移到内核中断处理程序。
  3. 保存现场:内核保存当前进程的上下文(寄存器值、程序计数器等)。
  4. 调用中断服务例程(ISR):内核根据中断号查找相应的中断服务例程,并开始执行ISR。
  5. 处理中断:ISR负责处理中断,可能涉及设备驱动程序、数据处理等。
  6. 中断结束:ISR执行完毕后,将控制权返回给之前的进程,恢复之前保存的现场。

中断上下文:

中断上下文是指内核在处理中断时执行的代码路径。与用户进程的上下文不同,中断上下文具有以下特点:

  1. 无用户空间访问:不能访问用户空间的内存,因为它是在内核态执行的。
  2. 无法阻塞:不能被阻塞,因为它必须迅速响应中断请求。
  3. 无法休眠:不能休眠,因为休眠可能会导致系统无法响应中断。
  4. 无法调度其他进程:不能调度其他进程,因为它是在中断服务例程中执行的。
  5. 使用内核栈:使用内核栈,而不是用户栈。

9、来谈一下C++中多态

  1. 虚函数:

虚函数是在基类中声明为虚拟的函数,派生类可以对其进行重写。通过基类指针或引用调用虚函数时,实际调用的是对象的派生类中的实现。

示例代码:

c++
class Shape {
public:
    virtual void draw() const {
        // 基类虚函数实现
    }
};

class Circle : public Shape {
public:
    void draw() const override {
        // 派生类实现
    }
};

int main() {
    Shape* shape = new Circle();
    shape->draw();  // 调用派生类的实现
    delete shape;
    return 0;
}
  1. 纯虚函数和抽象类(Abstract Classes):

如果基类中有纯虚函数,该类就成为抽象类,无法实例化。派生类必须实现纯虚函数才能成为具体类。

参考代码:

c++
class AbstractShape {
public:
    virtual void draw() const = 0;  // 纯虚函数,使类成为抽象类
};

class ConcreteCircle : public AbstractShape {
public:
    void draw() const override {
        // 实现抽象函数
    }
};

int main() {
    AbstractShape* shape = new ConcreteCircle();
    shape->draw();
    delete shape;
    return 0;
}
  1. 运行时多态性和动态绑定:

使用基类指针或引用调用虚函数时,编译器无法确定调用的是哪个类的实现,而是在运行时动态决定。

参考代码:

c++
void printDrawing(const Shape& shape) {
    shape.draw();
}

int main() {
    Circle circle;
    printDrawing(circle);  // 动态绑定,调用Circle的draw()
    return 0;
}
  1. 虚析构函数:

当使用基类指针管理派生类对象时,如果基类有虚析构函数,可以确保在销毁对象时调用正确的析构函数。

c++
class Base {
public:
    virtual ~Base() {
        // 虚析构函数
    }
};

class Derived : public Base {
public:
    ~Derived() override {
        // 派生类析构函数
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr;  // 调用派生类的析构函数
    return 0;
}

10、纯虚函数与虚函数有哪些区别

虚函数:

  1. 实现: 虚函数在基类中有一个实现,但可以在派生类中被重写(覆盖)。
  2. 可选实现: 虚函数可以在基类中包含默认实现,允许派生类选择是否覆盖它。
  3. 实例化对象: 基类中可以包含实例化的虚函数,允许基类的对象被实例化。
  4. 使用关键字: 使用 virtual 关键字声明虚函数。

例子:

c++
class Base {
public:
    virtual void foo() {
        // 基类虚函数实现
    }
};

纯虚函数:

  1. 没有实现: 纯虚函数在基类中声明但没有实现,需要在派生类中实现。
  2. 强制实现: 派生类必须提供对纯虚函数的实现,否则它也会成为抽象类。
  3. 抽象类: 包含至少一个纯虚函数的类称为抽象类,无法被实例化。
  4. 使用关键字: 使用 virtual 关键字和 = 0 指定纯虚函数。

例子:

c++
class AbstractBase {
public:
    virtual void pureVirtualFunction() = 0;
};

11、虚函数是怎么实现的?虚函数表存在哪个地方?

虚函数实现原理:

  1. 虚函数声明:在基类中声明虚函数时,使用 virtual 关键字。虚函数的声明告诉编译器这是一个可以被派生类重写的函数。
c++
class Base {
public:
    virtual void foo() {
        // 虚函数实现
    }
};
  1. 虚函数表:对于包含虚函数的类,编译器会为其生成一个虚函数表。这个表是一个指针数组,每个元素指向一个虚函数的地址。虚函数表在编译时就被创建,每个类只有一个虚函数表,存储了属于这个类的所有虚函数的地址。

  2. 虚函数指针:对象的内存布局中包含一个指向虚函数表的指针,通常被称为虚函数指针或虚指针。这个指针存储了对象的虚函数表的起始地址。

  3. 动态绑定:在运行时,当通过基类指针或引用调用虚函数时,实际上是通过虚函数指针找到虚函数表,然后在表中查找并调用正确的虚函数。这就是动态绑定的实现。

虚函数表的存储位置:

虚函数表存储在内存中的一个特定位置,具体的位置由编译器和操作系统的实现决定。一般来说,虚函数表的指针存储在对象的内存布局的最前面。在单继承的情况下,虚函数表通常位于对象的开始处。对于多继承,可能会有多个虚函数表,每个表对应一个基类。

12、怎么访问类中私有变量,友元是单向的还是双向的?友元可以继承吗

  1. 通过公有成员函数: 类可以提供公有成员函数,通过这些函数来访问私有变量。

示例代码:

c++
class MyClass {
private:
    int privateVar;

public:
    void setPrivateVar(int value) {
        privateVar = value;
    }

    int getPrivateVar() const {
        return privateVar;
    }
};
  1. 通过友元函数: 友元函数可以访问类的私有成员。友元函数是在类外声明并定义的函数,在类中声明它是一个友元。
c++
class MyClass {
private:
    int privateVar;

public:
    friend void friendFunction(MyClass& obj);

    // other members...
};

void friendFunction(MyClass& obj) {
    obj.privateVar = 42;  // 友元函数可以访问私有变量
}
  1. 通过友元类: 友元类可以访问类的私有成员。
c++
class FriendClass {
public:
    void modifyPrivateVar(MyClass& obj) {
        obj.privateVar = 42;  // 友元类可以访问私有变量
    }
};

class MyClass {
private:
    int privateVar;

    friend class FriendClass;  // FriendClass 是 MyClass 的友元类

    // other members...
};

关于友元的性质:

  • 单向性: 友元关系是单向的。如果类A是类B的友元,不意味着类B自动成为类A的友元。
  • 不继承性: 友元关系不会被继承。如果类A是类B的友元,而类C是类B的派生类,不意味着类C也是类A的友元。

13、你怎么理解引用与指针,为什么有了指针还需要引用?

引用:

  1. 语法简洁: 引用是一个别名,使用 & 符号定义。相对于指针,引用的语法更加简洁。
c++
int x = 10;
int& ref = x;  // 引用
  1. 不可重新赋值: 一旦引用被初始化,它将一直引用同一个对象,不能被重新赋值为引用其他对象。

  2. 不需要解引用操作符: 使用引用时不需要使用解引用操作符 *

  3. 无法为null: 引用必须在初始化时指定引用的对象,不存在空引用的概念。

指针:

  1. 语法相对复杂: 指针通过 * 符号声明,需要使用解引用操作符 * 访问所指向的值。
c++
int x = 10;
int* ptr = &x;  // 指针
  1. 可重新赋值: 指针可以被重新赋值为指向其他对象。

  2. 需要解引用操作符: 访问指针所指向的值需要使用解引用操作符 *

  3. 可以为null: 指针可以具有空值(null),即指向空地址。

为什么需要引用?

  1. 语法简洁: 引用提供了一种更简洁的语法,特别是在传递函数参数或声明引用时。
  2. 避免空指针问题: 引用不允许为空,因此在某些情况下可以减少空指针问题的发生。
  3. 用于函数参数传递: 在函数参数传递中,引用可以避免拷贝对象的开销,同时允许在函数内修改传递的参数。
  4. 用于运算符重载: 引用常常用于运算符重载,使得代码更加简洁和易读。

14、C++新特性,你知道哪些?谈谈你对Lambda表达式的理解,谈谈你对智能指针理解,智能指针有什么缺点

C++11新特性

  1. 自动类型推导(Auto): 允许编译器推导变量的类型,使代码更加简洁。
C++
auto x = 5; // x的类型将被推导为int
  1. 范围-based for 循环: 简化了对容器元素的遍历。
C++
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (const auto& num : numbers) {
    // 使用num
}
  1. 智能指针: 引入了std::shared_ptrstd::unique_ptr等智能指针,用于管理动态分配的内存,帮助防止内存泄漏。
C++
std::shared_ptr<int> sharedPtr = std::make_shared<int>(42);
  1. Lambda 表达式: 允许在函数内部定义匿名函数,提高代码可读性和灵活性。
C++
auto add = [](int a, int b) { return a + b; };
  1. nullptr: 引入了空指针常量nullptr,用于替代传统的空指针NULL
C++
int* ptr = nullptr;
  1. 强制类型转换(Type Casting): 引入了static_castdynamic_castconst_castreinterpret_cast等更安全和灵活的类型转换操作符。
C++
double x = 3.14;
int y = static_cast<int>(x);
  1. 右值引用和移动语义: 支持通过右值引用实现移动语义,提高了对临时对象的处理效率。
C++
std::vector<int> getVector() {
    // 返回一个临时vector
    return std::vector<int>{1, 2, 3};
}

std::vector<int> numbers = getVector(); // 使用移动语义
  1. 新的容器和算法: 引入了新的容器,如std::unordered_mapstd::unordered_set,以及一些新的算法。
C++
std::unordered_map<int, std::string> myMap = {{1, "one"}, {2, "two"}};
  1. 线程支持(std::thread): 提供了原生的多线程支持,使得并发编程更加方便。
C++
#include <thread>

void myFunction() {
    // 线程执行的代码
}

int main() {
    std::thread t(myFunction);
    t.join(); // 等待线程结束
    return 0;
}

C++14:

  • 泛型 Lambda 表达式
  • std::make_unique(用于创建 std::unique_ptr

C++17:

  • 结构化绑定
  • std::optional 类型
  • std::variant 类型
  • std::any 类型
  • 并行算法

C++20:

  • 概念(Concepts)
  • 协程(Coroutines)
  • 初始化语句(Init Statement)在 if 和 switch 中
  • 三向比较运算符(Three-way comparison operator <=>)
  • Ranges 库

Lambda 表达式:

Lambda 表达式是 C++11 引入的一项功能,允许在代码中定义匿名函数。Lambda 表达式的基本语法如下:

c++
[ capture_clause ] ( parameter_list ) -> return_type {
    // 函数体
}

其中:

  • capture_clause 捕获外部变量的方式,可以是值捕获、引用捕获、或混合捕获。
  • parameter_list 是函数参数列表。
  • return_type 是返回类型。
  • {} 包含了 Lambda 表达式的函数体。

Lambda 表达式的主要优势在于其简洁性和方便性,特别是在需要传递函数作为参数的场景,比如 STL 算法中。

智能指针:

智能指针是 C++ 提供的一种用于管理动态分配内存的工具。常见的智能指针有 std::shared_ptrstd::unique_ptr

  • std::shared_ptr
    • 原理:std::shared_ptr 使用引用计数来管理内存。每当你将一个 std::shared_ptr 指向某个对象时,引用计数会增加。当引用计数达到零(没有任何 shared_ptr 指向该对象)时,对象的内存将被自动释放。
    • 何时释放内存:当最后一个指向对象的 std::shared_ptr 被销毁或重置时,对象的内存会被释放。
  • std::unique_ptr
    • 原理:std::unique_ptr 使用独占所有权语义,确保只有一个指针可以拥有资源。它不使用引用计数,而是在 std::unique_ptr 被销毁时自动释放资源。
    • 何时释放内存:当 std::unique_ptr 被销毁、离开其作用域或通过 std::move 转移资源时,资源会被释放。
  • std::weak_ptr
    • 原理:std::weak_ptr 是一种弱引用智能指针,它用于协助解决循环引用问题。它不会增加引用计数,仅用于监视 std::shared_ptr 的状态。
    • 何时释放内存:std::weak_ptr 不会直接释放内存,它只是用于观察 std::shared_ptr 的生命周期。内存释放由相关的 std::shared_ptr 负责。
  • std::auto_ptr(已过时,不建议使用):
    • 原理:std::auto_ptr 具有独占所有权,类似于 std::unique_ptr。但它有一些缺陷,不支持复制操作,且没有移动语义。
    • 何时释放内存:当 std::auto_ptr 被销毁时,它会释放其内存。然而,由于其缺陷,它容易导致不安全的内存管理。

智能指针的缺点:

  1. 额外开销: 智能指针需要维护额外的信息,比如引用计数,因此在内存和性能上可能会有一些额外开销。
  2. 循环引用问题: 在使用 std::shared_ptr 时,如果形成循环引用,可能导致对象无法正确释放,从而引起内存泄漏。
  3. 不适用于所有场景: 智能指针不是适用于所有场景的通用解决方案。在一些特定的情况下,裸指针可能更为合适。

15、C++编译过程以及编译后(可执行程序)内存分配,你了解其中bss段吗?

  1. 预处理(Preprocessing): 在编译的第一阶段,源代码经过预处理器处理。预处理器执行以下操作:
    • 移除注释:删除源代码中的注释行(以///* */形式)。
    • 展开宏:将源代码中定义的宏展开为其实际内容。
    • 处理条件编译:根据条件编译指令(如#ifdef#ifndef#if等)决定是否包含或排除特定代码块。
  2. 编译(Compilation): 在编译阶段,预处理后的源代码被编译器翻译成中间代码(通常是汇编语言或机器代码的一种表示形式),这个中间代码通常称为汇编代码(Assembly Code)。
  3. 汇编(Assembly): 汇编器将中间代码翻译成目标机器的汇编语言代码。这是一个与具体计算机架构相关的步骤。
  4. 链接(Linking): 在链接阶段,编译器会将源代码中使用的外部库(如标准库、自定义库)与程序的汇编代码进行链接,以创建可执行文件。链接器执行以下操作:
    • 解析符号引用:将源代码中引用的函数或变量与其定义关联起来。
    • 解析库依赖:确定程序需要的外部库,并将其链接到程序中。
    • 生成可执行文件:将所有的汇编代码和库组合成一个可执行文件。
  5. 优化(Optimization)(可选): 一些编译器会在编译阶段执行代码优化,以提高程序的性能和效率。优化可以包括常量折叠、循环展开、内联函数等。
  6. 生成可执行文件(Executable): 最终,编译器将链接后的代码生成可执行文件,这个文件包含了计算机可以直接执行的指令。
  7. 执行程序: 用户可以运行生成的可执行文件,将程序加载到计算机的内存中,并开始执行。

可执行程序的内存分配:

  1. 代码段(Text Segment):
    • 存放程序的机器代码,通常是只读的。
    • 包含可执行程序的指令。
  2. 数据段(Data Segment):
    • 存放全局变量和静态变量。
    • 分为初始化的数据段(.data)和未初始化的数据段(.bss)。
  3. 堆(Heap):
    • 动态分配的内存空间,由程序员管理。
    • 使用 newdeletemallocfree 进行分配和释放。
  4. 栈(Stack):
    • 存放局部变量、函数参数和函数调用信息。
    • 由系统自动管理。

bss 段:

  • 未初始化的数据段(BSS Segment):
    • 包含程序中未初始化的全局变量和静态变量。
    • 在可执行程序加载时,内核会分配这些变量的内存,并初始化为零(或其他默认值)。
    • bss 是 Block Started by Symbol 的缩写。

16、可执行程序的后缀是什么?它的内存开始地址是什么?空间范围是多少?

可执行程序的后缀通常没有固定规定,不同操作系统和编译环境可能采用不同的命名规范。在大多数情况下,可执行程序的后缀可以是任何合法的文件名后缀,例如 .out.exe、无后缀等。

关于可执行程序的内存开始地址和空间范围,这取决于操作系统和加载器的具体实现。不同的操作系统和执行环境可能有不同的加载地址和地址空间范围。

在典型的情况下:

  • 内存开始地址: 可执行程序被加载到内存时,操作系统通常会为其分配一个起始地址,称为加载地址。这个地址通常是操作系统根据系统和应用程序的需要动态选择的。
  • 空间范围: 可执行程序在内存中占据的地址空间范围取决于程序的大小和操作系统的地址空间布局。在 32 位系统上,典型的用户空间地址范围为 0x08048000 到 0xffffffff(Linux 系统),而在 64 位系统上,用户空间地址范围更为广泛。

17、你所做的项目nuttx系统有没有虚拟内存的概念,linux中怎么把虚拟地址与物理地址对应,详细展开讲一讲(分页,分段及段页时,要说一下大概实现算法以及它们之间的区别)

在 Linux 系统中,虚拟地址到物理地址的映射是通过页表来实现的。

分页:

分页是一种内存管理机制,其中虚拟内存和物理内存都被划分为固定大小的页面。操作系统使用页表来映射虚拟地址到物理地址。基本步骤如下:

  1. 划分页面: 将虚拟地址空间和物理地址空间分为固定大小的页面(通常是 4KB)。
  2. 建立页表: 操作系统维护一个页表,将每个虚拟页面映射到物理页面。
  3. 地址转换: 当程序引用虚拟地址时,通过页表查找得到对应的物理地址。
  4. 页表项: 页表的每个项称为页表项(Page Table Entry,PTE),包含了虚拟页面号到物理页面号的映射关系。

分段:

分段是另一种内存管理机制,其中虚拟地址和物理地址被划分为不同的段。每个段具有不同的权限和属性。基本步骤如下:

  1. 划分段: 将虚拟地址空间和物理地址空间划分为不同的段,例如代码段、数据段等。
  2. 建立段表: 操作系统维护一个段表,将每个段映射到物理地址。
  3. 地址转换: 当程序引用虚拟地址时,通过段表查找得到对应的物理地址。
  4. 段表项: 段表的每个项称为段描述符,包含了段的基址、大小、权限等信息。

段页式内存管理:

在实际系统中,分页和分段可以结合使用,形成段页式内存管理。这样,虚拟地址首先通过段表转换得到段的基址,然后再通过页表进行页面内部的偏移。基本步骤如下:

  1. 划分段和页面: 将虚拟地址和物理地址分别划分为段和页面。
  2. 建立段表和页表: 操作系统维护段表和页表,将虚拟地址映射到物理地址。
  3. 地址转换: 虚拟地址首先通过段表得到段的基址,然后通过页表得到页面的物理地址。
  4. 段表项和页表项: 段表的每个项是段描述符,包含了段的基址等信息。页表的每个项是页表项,包含了虚拟页面号到物理页面号的映射关系。

区别与选择:

  • 分页的优势:
    • 页面大小是固定的,简化了内存管理。
    • 适用于稀疏地址空间,不需要为每个段都分配物理空间。
  • 分段的优势:
    • 适用于不规则的地址空间,每个段可以有不同的权限和属性。
    • 更容易支持共享、动态增长的数据结构。
  • 段页式内存管理的选择:
    • 结合分页和分段的优势,适应不同的内存管理需求。
    • 大多数现代操作系统采用段页式内存管理,如 x86-64 架构的操作系统。

18、你写过的代码你对它们优化过吗?用了哪些优化方法?(我回答了一个用移位优化乘除,然后面试官就问你浮点数运算怎么处理,我当时答了采用FPU,但这题确实把我问懵了)

19、你用过GDB调试吗?讲一讲常用命令。如果我编译报错,怎么定位错误位置,你知道gdb怎么进行多线程调试吗?

常用 GDB 命令:

  1. 启动 GDB:

    gdb executable_name
  2. 运行程序:

    run [args]
  3. 设置断点:

    break function_name
    break filename:line_number
  4. 查看源代码:

    list [filename:line_number]
  5. 单步执行:

    step
  6. 逐过程执行:

    next
  7. 继续执行:

    continue
  8. 查看变量值:

    print variable_name
  9. 查看栈帧:

    backtrace
  10. 查看寄存器:

    info registers

定位错误位置:

如果编译报错,可以使用 GDB 来定位错误位置。首先,在编译时需要添加调试信息,以便 GDB 进行符号表查找。

gcc -g -o executable_name source_code.c

然后,启动 GDB 并运行程序:

gdb ./executable_name

在 GDB 中,使用 run 命令运行程序。如果程序崩溃,GDB 会在错误发生时停下。

多线程调试:

在 GDB 中进行多线程调试时,可以使用以下命令:

  1. 设置断点:

    break function_name
  2. 查看线程列表:

    info threads
  3. 选择线程:

    thread thread_id
  4. 切换到下一个线程:

    thread next
  5. 切换到指定线程:

    thread apply thread_id_list command
  6. 查看当前线程的栈帧:

    backtrace
  7. 查看指定线程的栈帧:

    thread apply thread_id backtrace

20、你知道回溯的概念吗?谈一谈栈回溯,采用gdb怎么调试?

回溯(Backtrace)指的是在程序运行过程中打印调用栈信息,显示函数调用关系和对应的代码行号。回溯可以帮助开发人员定位程序崩溃或错误的位置。在 C/C++ 程序中,可以通过栈回溯来获取函数调用链的信息。

在 GDB 中,栈回溯是一种常见的调试手段,用于查看程序崩溃时的函数调用栈。以下是在 GDB 中进行栈回溯的基本步骤:

  1. 启动 GDB:

    gdb ./executable_name
  2. 运行程序:

    run [args]
  3. 当程序崩溃时,查看栈回溯:

    backtrace

或者使用简写命令 bt

bashCopy code
bt
  1. 查看特定栈帧的信息:

    frame frame_number

其中,frame_number 是栈帧的编号,可以通过 backtrace 命令查看。

栈回溯的使用示例:

假设有以下简单的 C++ 程序:

cpp
cppCopy code#include <iostream>

void func3() {
    throw std::runtime_error("Exception in func3");
}

void func2() {
    func3();
}

void func1() {
    func2();
}

int main() {
    try {
        func1();
    } catch (const std::exception& e) {
        std::cerr << "Exception caught: " << e.what() << std::endl;
    }

    return 0;
}

使用 GDB 进行调试:

  1. 编译程序时加上调试信息:

    g++ -g -o my_program my_program.cpp
  2. 启动 GDB 并运行程序:

    gdb ./my_program
  3. 在 GDB 中运行程序,当程序崩溃时,使用 backtrace 查看栈回溯:

    run

    在异常抛出后,可以使用 backtrace 查看栈回溯信息。

    backtrace

    或者使用简写命令 bt

    bt

21、反问