当前位置:   article > 正文

嵌入式软件和C/C++面经汇总_嵌入式cpp面经

嵌入式cpp面经

序言

本篇为收集的技术面的面经以及自己经历的补充
共分为两部分

  • 第一部分,网络上汇总的面经.(已标明出处)
  • 第二部分,鄙人亲自经历的面经汇总.(持续更新)

第一部分:网络面经汇总(共性)

第一章进程线程

1.1 进程线程的基本概念

进程是操作系统进行资源分配和调度的基本单位.一个进程包括:

  1. 程序代码:程序的指令序列
  2. 数据:程序运行时所需的变量、常量等
  3. 程序栈:函数调用时存储参数、局部变量和返回信息等
  4. 运行环境:环境变量、使用的文件系统、用户ID等
  5. 进程控制块:保存进程运行状态、调度信息、指示程序位置等.
    所以,进程是一个动态的执行单位,包含了程序运行所需的全部资源.操作系统会为每个进程分配不同的地址空间,使其相互隔离.

线程是进程中的一个执行单元,是CPU调度和分派的基本单位.它属于进程且共享进程的地址空间.
一个进程可以包含多个线程,线程间可以并发执行.线程有以下主要特点:

  1. 共享地址空间:线程属于进程,共享进程的地址空间和系统资源.
  2. 调度独立:线程有自己的执行序列、程序计数器和栈等,CPU可以独立调度线程.
  3. 低开销:线程切换不需要切换地址空间,只需要保存和恢复寄存器状态和栈帧等,开销小.
  4. 用户态线程:有的线程由应用程序控制调度,有的由内核控制调度.
  5. 同步:线程间需要同步避免竞态条件出现,常用互斥锁、条件变量等实现同步.
    所以,线程存在的主要目的是提高CPU利用率,线程间可以并发执行;并简化进程通信,线程间可以直接读写进程地址空间中的数据.
    但线程也带来了一定的管理难度,主要是线程间的同步和调度问题.如果不加以限制,线程可能会相互干扰,产生错误的执行顺序和结果.

1.1.1 什么是进程,线程,彼此有什么区别⭐⭐⭐⭐⭐

进程和线程都是操作系统进行运算调度的单位,但二者有以下几个主要区别:
进程:

  1. 进程有自己独立的地址空间,一个进程崩溃后,在保护模式下不会对其他进程产生影响.
  2. 进程切换时,耗费较多资源,程序栈和地址空间需要更新.
  3. 进程间通信较为复杂.
    线程:
  4. 线程共享进程的地址空间,一个线程崩溃可能会导致整个进程崩溃.
  5. 线程切换时,仅需更新程序栈和寄存器等少量数据,开销小.
  6. 线程间通信更简单,可以直接读写同一进程的地址空间.
  7. 线程更轻量,一个进程可以创建多个线程.
    主要区别在于:
  8. 隔离性:进程有独立地址空间,线程共享进程地址空间.
  9. 资源开销:进程切换开销大,线程切换开销小.
  10. 通信方式:进程间通信复杂,线程间通信简单.
  11. 数量:一个进程可以有多个线程.
    进程和线程都是并发执行的基本单位,线程的并发性主要在CPU层面,而进程的并发性主要在操作系统层面.理解进程和线程的区别,这是学习操作系统的一个关键点.
    通过进程和线程,我们可以让程序在执行时表现出并行的效果,充分利用CPU的运算能力.它们是现代操作系统进行运算调度和管理的两种手段.

关于线程共享进程的地址空间的解释
线程属于进程,一个进程可以包含多个线程.而进程有自己独立的地址空间,所以线程作为进程的组成部分,自然也共享这个地址空间.
这意味着:

  1. 线程可以直接访问进程的数据段、代码段、堆栈段等.
  2. 线程间可以直接交换数据,不需要特定的IPC方式.
  3. 如果一个线程修改了进程地址空间的数据,其它线程可以立即看到修改后的结果.
  4. 如果一个线程使进程地址空间的数据结构趋向崩溃,其它线程也随之受影响.
    因此,线程间的通信更加简单高效,这也是线程存在的主要意义.但线程也带来了线程间的干扰和同步问题,我们需要使用互斥锁等手段进行解决.

相比之下,进程有自己独立的地址空间,所以:

  1. 进程间无法直接访问彼此的地址空间,需要借助进程间通信(IPC)来交换数据.
  2. 一个进程挂掉不会影响其它进程,实现了进程间的隔离.
  3. 进程切换时,需要切换整个地址空间,消耗较大.而线程切换只需保存和恢复寄存器上下文及栈帧等,开销小得多.
    所以,线程的共享地址空间特性使其在通信和切换方面具有较大的优势,但也带来了一定的同步难度.理解线程与进程的这一关键区别,这是学习操作系统中的重点内容之一.

1.1.2多进程、多线程的优缺点⭐⭐⭐⭐

多进程和多线程都可以实现并发执行,但各有优缺点:
多进程:
优点:

  1. 隔离性:每个进程有独立的地址空间,一个进程崩溃不会影响其他进程.
  2. 系统级并发:可以充分利用多核CPU的资源.
    缺点:
  3. 资源开销大:进程切换时需要切换整个地址空间,性能开销高.
  4. 通信机制复杂:进程间通信需要复杂的同步机制.
  5. 数量限制:操作系统能支持的进程数目有限.
    多线程:

优点:

  1. 共享地址空间:线程共享进程的全部资源,通信简单高效.
  2. 轻量级:线程切换只需要切换程序栈和局部变量,开销小.
  3. 数量:一个进程可以有大量线程.
    缺点:
  4. 同步难度:线程共享资源可能出现同步问题,需要互斥锁等机制解决.
  5. 不利于多核:线程本质是CPU级并发,不利于多核资源利用.
  6. 安全性:一个线程错误可能导致整个进程崩溃.

1.1.3什么时候用进程,什么时候用线程⭐⭐⭐

选择多进程还是多线程,需要根据实际应用中的需求和资源进行权衡:
如果需要系统级并发和高安全性,选择多进程;
如果需要大量的轻量级并发和高效通信,选择多线程;
也可以两者结合使用,获得各自的优势.


1.1.4多进程、多线程同步(通讯)的方法⭐⭐⭐⭐⭐

多进程通讯:

IPC是Inter-Process Communication的缩写,意为进程间通信.它是指两个或多个进程之间进行数据交换和资源共享的机制.
IPC常见的方式有:

  1. 管道(Pipe):管道是两个进程之间的单向数据传输通道.管道只能用于父子进程或者兄弟进程之间的通信.
  2. 命名管道(Named Pipe):也称为FIFO,用于非相关进程之间的通信.它允许一个进程打开FIFO,另一个进程以读写的方式打开同一个FIFO,从而实现进程间通信.
  3. 消息队列(Message Queue):消息队列是保存在内核中的消息链表,用于进程之间的异步通信.
  4. 信号(Signal):信号是一种比较原始的进程间通信方式,用于通知接收进程某个事件已经发生.
  5. 共享内存(Shared Memory):共享内存是最快的IPC方式,它允许多个进程共享一块内存空间,各个进程可以直接读写这块内存空间,进行通信.
  6. 信号量(Semaphore):信号量是一种在多进程环境下控制对共享资源访问的机制.它时常用于进程间以及同一进程内不同线程之间的同步.
  7. 套接字(Socket):Socket允许不同主机上的两个进程进行网络通信.它可用于TCP/IP网络中的进程间通信.

多线程同步:

  1. 互斥锁(Mutex):用于同步对共享资源的访问,只有获得锁的线程可以访问资源.
  2. 条件变量(Condition Variable):用于线程间的通知和等待,当条件达成时唤醒等待的线程.
  3. 读写锁(Read-Write Lock):用于同步对共享资源的读和写,支持多个读线程访问或单个写线程访问.
  4. 死锁(Deadlock):并发线程在两处同时 wait 对方时发生.需避免.
  5. 生产者-消费者模型:使用空缓冲区和满缓冲区的条件变量实现同步.
  6. 线程本地存储(Thread Local Storage):每个线程有自己的变量副本,无需同步.
  7. 自旋锁(Spinlock):让 CPU 空循环等待锁的线程,占用 CPU,性能较差,现已较少使用.

1.1.5进程的空间模型⭐⭐⭐⭐

进程的地址空间通常采用以下几种模型:

  1. 连续空间模型:最简单的模型,每个进程有一段连续的地址空间,进程间的地址空间是相互独立的.此模型下创建进程和过程切换的开销最大,但实现最简单.
  2. 分段模型:将进程的逻辑地址空间分割成若干个段,每个段映射到不连续的物理地址.此模型需要维护段表来映射逻辑地址到物理地址,实现较复杂,但可以有效减小内存碎片.
  3. 页模型:将逻辑地址空间分割成大小相等的块,称为页,每个页映射到物理地址空间的一页框.同样需要页表映射逻辑地址到物理地址.此模型更加灵活,可以实现内存共享和拷贝等功能.
  4. 段页模型:将进程地址空间先分割成若干段,每个段再划分成页,需要维护段表和页表.此模型兼有分段模型和页模型的优点,是最常用的模型.
    这几种模型的区别在于地址翻译的粒度和映射机制的不同.理解各模型的原理,这是操作系统一个非常重要的概念.
    连续空间模型简单但不灵活;
    分段模型引入段表,可以部分共享和重映射;
    页模型以页为单位映射,更加灵活;
    段页模型综合以上优点,最为常用.

1.1.6进程线程的状态转换图 什么时候阻塞,什么时候就绪⭐⭐⭐

进程/线程会在阻塞和就绪状态之间转换:
就绪(Ready):正在等待获得CPU使用权,随时可执行.
阻塞(Blocked):无法执行,需要等待某事件发生后才能转为就绪状态.

进程/线程会在以下情况下转入阻塞状态:

  1. 等待输入输出完成(如读取文件)
  2. 等待访问临界资源(如等待获得互斥锁)
  3. 等待其他进程/线程的信号(如使用条件变量实现的生产者-消费者模型)
  4. 等待子进程/线程完成(如调用wait())
  5. 等待定时器到期(如sleep())

进程/线程会在以下情况下转为就绪状态:

  1. 输入输出完成或临界资源可用
  2. 收到信号或唤醒调用
  3. 子进程/线程完成
  4. 定时器到期(如sleep())

1.1.7父进程、子进程的关系以及区别⭐⭐⭐⭐

父进程和子进程是相对的概念:
父进程:正在执行的进程,调用了fork()系统调用产生子进程.
子进程:由父进程调用fork()产生的新进程.
当一个进程调用fork()时,操作系统会创建一个子进程,这个子进程拷贝父进程的地址空间,并在子进程中继续执行fork()的下一条指令.
所以,父进程和子进程有以下关系:

  1. 代码和数据:子进程拷贝父进程的代码和数据段,栈段等,在调用fork()时其内容相同.
  2. 文件描述符:子进程继承父进程的所有打开文件描述符,指向同一文件表项.
  3. 进程标识:子进程拥有新的进程ID和父进程ID,用于标识进程关系.
  4. 信号处理和资源:子进程默认继承父进程的全部信号处理方式和资源限制,但可以重新设置.
  5. 共享内存:父进程和子进程可以共享内存映射文件或共享内存,实现通信.
    另外,父进程和子进程执行fork()后,有以下两种情形:
  6. 父进程先执行,子进程后执行:这是最常见的情况,由于子进程是父进程的副本,所以必须等父进程调用fork(),子进程才开始执行.
  7. 子进程先执行,父进程后执行:这种情况较少,性能较差,是由于子进程必须等待父进程完成对fork()的调用,才能真正执行,导致子进程得不到调度执行.
    理解父进程和子进程的关系是学习Unix/Linux系统编程的基础,fork()系统调用是产生进程的唯一方法.

1.1.8什么是进程上下文、中断上下文⭐⭐

进程上下文:
当 CPU 调度进程执行时,会为其建立一个执行环境,包括程序计数器、栈指针、通用寄存器等,这些统称为进程上下文.
当进程被抢占时,操作系统会将其上下文保存,以便其下次调度时恢复执行.进程上下文切换涉及保存和恢复现场,其开销较大.
中断上下文:
当 CPU接收到中断时,当前执行的进程会被暂停,转入中断服务例程执行.
中断服务例程有自己的上下文,包括堆栈指针、程序计数器等.在中断处理完后,CPU 会恢复到中断发生前的进程,继续执行.
与进程上下文切换相比,中断上下文切换开销较小,只需要简单保存和恢复少数寄存器即可.
所以,进程上下文和中断上下文之间的主要区别在于:

  1. 执行主体:进程上下文属于进程,中断上下文属于中断服务例程.
  2. 切换开销:进程上下文切换开销大,中断上下文切换开销小.
  3. 执行周期:进程上下文可执行较长时间,中断上下文执行较短.
  4. 和用户态/内核态的对应关系:进程上下文通常在用户态,中断上下文在内核态.

1.1.9一个进程可以创建多少线程,和什么有关⭐⭐

  1. 操作系统的限制:不同的操作系统对进程可以创建的线程数目有不同的限制,这是硬性限制.如 Linux 下默认每个进程最多可以创建 4096 个线程.
  2. 物理内存的限制:线程需要消耗一定的内存空间(主要是栈).如果物理内存较小,将限制线程的数量.
  3. 虚拟内存的限制:如果进程的虚拟内存空间较小,也会限制其可以创建的线程数目.但由于线程共享进程的虚拟地址空间,这个因素影响较小.
  4. CPU 时间片的限制:操作系统需要为每个线程分配 CPU 时间,如果 CPU 时间片较小,将限制线程的数量.但通常时间片足够,这个因素影响也较小.
  5. 应用程序本身的限制:应用程序可以根据自身需求限制每个进程创建的线程数目.如果创建太多线程,会增加同步和管理的难度,影响程序性能.
    除操作系统提供的硬性限制外,上述因素对线程数目的限制主要是软性的,应用程序可以根据需要进行适当调整.
    总的来说,影响一个进程可以创建线程数目最大的因素是:
    操作系统的限制 > 物理内存 > 虚拟内存 > CPU 时间片 > 应用程序需要
    其中,操作系统的限制是硬性的,其他因素提供了软性的参考和限制.

1.2 并发,同步,异步,互斥,阻塞,非阻塞的理解

并发:同一时间间隔内,多个任务都在执行(执行的交替进行).并发程序设计的目的是利用多核 CPU 或多线程提高程序的执行效率.
同步:一个任务在执行某个操作时,需要等待其他任务的信息或信号才能继续执行.同步处理通常使用互斥锁,条件变量等机制实现.
异步:一个任务在执行某个操作时,不需要等待其他任务的信息或信号就继续执行.异步通常使用回调函数,事件等机制实现.
互斥:多个任务同一时间只能有一个任务进入临界区,其他任务必须等待.互斥的实现使用的是互斥锁(Mutex).
阻塞:一个任务在等待某个事件(如 I/O 操作或互斥锁)发生时,被暂停执行.阻塞通常用于同步处理,以协调多个任务的执行.
非阻塞:一个任务在等待某个事件(如 I/O 操作)时,不暂停执行.非阻塞通常用于异步处理,提高程序的响应性.
所以,这些概念之间的关系是:
并发提供了同时执行多个任务的环境;
同步和异步是任务之间的一种协作方式;
互斥是一种实现同步的机制;
阻塞和非阻塞是任务在等待事件时的两种不同状态.


1.2.1什么是线程同步和互斥⭐⭐⭐⭐⭐

线程同步:
是指多个线程协调其执行顺序和相互通信的机制.因为线程运行并行,所以需要同步来避免对共享资源的非法访问和不一致的执行结果.
常用的线程同步方法有:

  1. 互斥锁(Mutex):用于同步对共享资源的访问,只有获得锁的线程可以访问资源.
  2. 条件变量(Condition Variable):用于线程间的通知和等待,当条件达成时唤醒等待的线程.
  3. 读写锁(Read-Write Lock):用于同步对共享资源的读和写,支持多个读线程访问或单个写线程访问.
  4. 信号量(Semaphore):用于控制对资源的访问,支持多个资源的访问控制.
    线程互斥:
    是一种特殊的线程同步方式,用于同一时间只允许一个线程进入临界区.互斥主要使用互斥锁(Mutex)来实现,其机制是:
  5. 只有一个线程可以获得互斥锁,其他试图获得该锁的线程会阻塞;
  6. 获得互斥锁的线程进入临界区执行,执行完成后释放互斥锁;
  7. 互斥锁释放后,被阻塞的其他线程之一将获得该锁并进入临界区.
    所以,线程同步是一种更广义的协调线程执行的机制,互斥是其中的一种方式,用于同步对共享资源的独占访问.

1.2.2线程同步与阻塞的关系?同步一定阻塞吗?阻塞一定同步吗?⭐⭐⭐⭐

线程同步与阻塞是两个不同的概念,但存在一定的关系:
线程同步:是指多个线程协调其执行顺序和相互通信的机制,用于避免对共享资源的非法访问和不一致的执行结果.常用方法有互斥锁、条件变量、读写锁、信号量等.
线程阻塞:是指线程在等待某个事件(如 I/O 操作或互斥锁)发生时,被操作系统挂起执行的状态.阻塞状态的线程不会被 CPU 调度执行,直到其等待的事件发生,其状态才会变为可执行状态.
那么,线程同步与阻塞的关系是:

  1. 同步不一定阻塞:例如使用线程局部存储等方法实现同步,线程仍然可执行,不会阻塞.
  2. 阻塞不一定同步:线程执行 I/O 操作时阻塞,但这并非实现多线程之间的同步协调,其目的在于等待 I/O 事件.
  3. 互斥锁等同步机制的使用会导致线程阻塞:当多个线程访问临界区时,未获得互斥锁的线程会阻塞,这时同步与阻塞同时存在.
    所以,同步的目的是协调多个线程的执行,可以使用不同的机制实现.而阻塞是线程的一种状态,是用于等待某事件发生.当使用互斥锁等机制实现同步时,未获得锁的线程会阻塞,同步与阻塞出现了直接的对应关系.
    但同步不等同于阻塞,阻塞也不仅限于实现同步.

1.3 孤儿进程、僵尸进程、守护进程的概念

孤儿进程:其父进程终止,但该进程还在运行的进程.孤儿进程会被 init 进程采集,并由 init 进程成为其父进程.
僵尸进程:其子进程已经终止,但父进程还没有调用 wait() 系统调用来获取子进程的终止信息,所以子进程的进程描述符仍然保留的进程.僵尸进程只占用进程描述符,不占用任何系统资源.
守护进程:是运行在后台并且一直运行的进程.守护进程不受控制终端的影响,在系统引导时启动,并一直运行等待处理事件.常见的守护进程有系统日志进程 syslogd、web 服务器进程 httpd 等.
所以,这几种进程的主要特征是:
孤儿进程:失去父进程,被 init 进程采集.
僵尸进程:子进程终止但父进程未回收,占用进程描述符.
守护进程:在后台持续运行,不受控制终端影响.
其中,僵尸进程和守护进程都是由我们的程序所产生,需要在设计中妥善处理:
僵尸进程:父进程要及时调用 wait() 获取子进程信息,避免产生僵尸进程.
守护进程:设置进程组,与控制终端分离,忽略终端信号等.


1.3.1如何创建守护进程:⭐⭐

创建守护进程通常需要以下几个步骤:

  1. 调用fork()产生子进程.
  2. 在子进程中调用setsid()产生新的会话和进程组,与控制终端分离.
  3. 关闭标准输入、输出和错误文件描述符.
  4. 设置当前工作目录.
  5. 忽略控制终端产生的信号.
  6. 在后台运行,直到接收到退出信号.
    具体实现代码如下:
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>  
int main() 
{
    pid_t pid;
    
    // 产生子进程
    pid = fork();
    
    if (pid < 0) 
    {
        exit(1);      // fork 失败,退出
    } 
    else if (pid > 0) 
    {
       exit(0);      // 这是父进程,退出
    }
    
    // 设置进程组ID,与控制终端分离
    setsid();  
    
    // 忽略SIGHUP信号
    signal(SIGHUP, SIG_IGN);  
    
    // 设置工作目录
    chdir("/");
    
    // 关闭文件描述符
    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);  
    
    // 循环直到接收退出信号
    while (1) 
    {
        // daemon 进程执行内容
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

1.3.2正确处理僵尸进程的方法⭐⭐⭐⭐

僵尸进程是父进程创建子进程后,子进程终止但父进程还未调用wait()系统调用获取子进程信息而产生的.僵尸进程只占用进程描述符,不占用任何系统资源.
要正确处理僵尸进程,主要有以下两种方法:

  1. 父进程调用wait()系统调用,获取子进程信息.
    这是最简单的方法,代码如下:
pid_t pid;
int status;

pid = fork();
if (pid == 0) {
    // 子进程代码
} else {
    // 父进程代码
    wait(&status);      // 等待子进程退出,获取退出状态
}
2. 将子进程设置为守护进程,与父进程的生命周期分离.
这种方法不需要父进程调用wait()获取子进程信息,代码如下:
c
pid_t pid;  

pid = fork();
if (pid == 0) {  
    // 子进程成为守护进程
    setsid();
} else {
    // 父进程代码 
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

setsid()系统调用将子进程分离成新的会话和进程组,与控制终端分离,实现了与父进程生命周期的分离.
所以,正确处理僵尸进程的两个方法是:

  1. 父进程及时调用wait()系统调用,获取子进程信息;
  2. 将子进程设置为与父进程生命周期分离的守护进程.

第二章C/C++高频面试题

2.1 c和c++区别、概念相关面试题

  1. C 是面向过程的语言,C++支持面向对象编程.C++支持类、对象、继承、多态等面向对象特征.
  2. C++支持函数重载、运算符重载、模板等特性,C不支持.
  3. C++支持异常处理,C不支持.使用try/catch可以捕获并处理异常.
  4. C++支持命名空间,可以避免命名冲突.C不支持命名空间.
  5. C++支持引用,可以作为函数的参数和返回值.C只支持指针,没有引用的概念.
  6. C++支持新式类型转换(dynamic_cast, static_cast, reinterpret_cast, const_cast),提供更严格的类型检查.C只支持旧式类型转换.
  7. C++标准库更丰富,包括STL容器类、算法、迭代器等.C标准库较为简单.
  8. C++支持输入/输出流(iostream),用于屏幕输入输出.C使用标准输入输出函数(printf, scanf).
  9. C++支持bool、const_cast等关键字.C不支持这些关键字.
  10. C++发展更快,标准更新更频繁.C标准较难发生重大变化.
    综上,C++相比于C,拥有更丰富的语言特征,支持面向对象,功能更加强大.但是C语言简单易学,执行效率略高,适用于底层开发.

2.1.1 new和malloc的区别⭐⭐⭐⭐⭐

new 和 malloc 都是用来动态分配内存的操作,但二者有以下主要区别:

  1. 功能:new 是 C++ 中的运算符,用于分配内存并初始化对象.malloc 是 C 中的函数,仅用于分配内存,不进行初始化.
  2. 返回值:new 返回对象的引用.malloc 返回 void 指针.
  3. 类型安全:new 进行类型检查,不允许对无效类型进行分配.malloc 是一个低级函数,不进行类型检查.
  4. 空值检查:new 分配失败时会抛出 std::bad_alloc 异常.malloc 分配失败时会返回 NULL 指针.
  5. 内存释放:用 new 分配的内存使用 delete 释放.用 malloc 分配的内存使用 free 释放.
    所以,总结来说:
  • new 是面向对象的高级封装,进行初始化,类型安全检查和异常处理;
  • malloc 是低级函数,更轻量级,不进行初始化,类型检查和异常处理.
    下面是使用 new 和 malloc 分别分配内存的示例:
// 使用 new 
Person *p = new Person;
delete p;

// 使用 malloc
void *mem = malloc(sizeof(Person));
Person *p = (Person*)mem; 
free(mem);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

理解 new 和 malloc 的区别,可以帮助我们在 C 和 C++ 程序中选择适合的方式动态分配内存.通常来说:

  • 在 C++ 中优先选择 new,除非有特殊需求;
  • 在 C 中只能选择 malloc;
  • 在混用 C 和 C++ 时,可根据需要选择 new 或 malloc.

2.1.2 malloc的底层实现⭐⭐⭐⭐

malloc()函数是C语言中用于动态内存分配的标准库函数.它的主要作用是从堆区分配一块连续的内存空间,并返回该空间的首地址.
malloc()的主要底层实现步骤如下:

  1. 从操作系统获取一大块空闲内存空间,这块空间称为堆区.堆区的内存管理由malloc()函数负责.
  2. 在堆区维护多个内存块,这些内存块可能处于空闲或已分配状态.内存块使用链表或双向链表连接,方便查找空闲块.
  3. malloc()函数首先会搜索堆区,查找一块足够大的空闲内存块.如果有空闲内存块,则将其分配给请求的内存,并返回该内存块的首地址.
  4. 如果没有足够大的空闲内存块,则向操作系统申请更多内存,并添加到堆区.然后再从中分配一块内存返回.
  5. 当释放内存时调用free()函数,内存块的状态变为空闲.malloc()在再次分配内存时,会优先选择空闲块.
  6. malloc()可能会对内存块进行合并,以提供更大的空闲块和更好的内存利用率.
  7. 对已分配的内存块进行间接管理,记录内存块的大小、空闲与否等信息,方便内存的回收和重用.
    以上就是malloc()的主要底层实现机制.理解动态内存分配的实现原理,可以帮助我们更好地利用内存,编写高效的程序.

2.1.3在1G内存的计算机中能否malloc(1.2G)?为什么?⭐⭐

在1G内存的计算机中调用malloc(1.2G)无法成功分配1.2G的内存空间.这是因为malloc()函数分配的内存空间是在进程私有的堆区,堆区的大小是有限的,不可能超过系统的物理内存.
当调用malloc(1.2G)时,会发生以下情况:

  1. malloc()会先检查堆区是否有大于等于1.2G的连续空闲内存块,如果没有则继续步骤2.
  2. malloc()会尝试向操作系统申请更多内存,以扩展堆区的大小.但是操作系统只能从物理内存中分配,而物理内存只有1G,所以无法满足1.2G的分配请求.
  3. 因无法从操作系统获得足够的内存,malloc(1.2G)会返回NULL,内存分配失败.
  4. 调用malloc(1.2G)的程序会因为得到NULL指针而出现段错误崩溃.
    所以,在1G内存的系统中,不可能成功分配大于1G的内存空间,malloc(1.2G)会失败并返回NULL.这是因为:
  5. malloc()分配的内存来自有限的堆区;
  6. 堆区的内存来自操作系统分配的物理内存;
  7. 操作系统只能从有限的物理内存中分配内存给进程.
    当内存分配请求超过系统的物理内存时,必然会导致分配失败.这也是内存管理的基本原理与限制.

2.1.4指针与引用的相同和区别;如何相互转换?⭐⭐⭐⭐⭐

  1. 都是变量的别名,都可以间接访问另一个变量.
    区别:
  2. 定义:指针使用*,引用使用&.如:int *p; int &r;
  3. 内存占用:指针占用内存空间,引用不占用.引用是编译器替换的别名.
  4. 赋值操作:指针进行指向的变量赋值,引用进行引用的变量赋值.
  5. 空值:指针可以为NULL,引用不可以为空.
  6. 动态内存分配:只有指针可以进行动态内存分配,如:p = new int;
    相互转换:
  7. 将指针转换为引用:直接使用&获取指针的地址作为引用.如:int *p; int &r = *p;
  8. 将引用转换为指针:直接获取引用的地址.如:int &r; int *p = &r;
    所以,指针与引用的主要区别在于:
    指针是一个实实在在占用内存空间的变量,可以进行动态内存分配;
    引用只是编译器将别名替换为实际变量的一种机制,不占用内存空间.
    但两者的目的都是用于间接访问另一个变量,提供了变量的别名.

2.1.5 C语言检索内存情况 内存分配的方式⭐⭐⭐

检索内存情况:

  1. malloc_stats():获取 malloc 管理的内存使用统计信息,比如已分配内存块的数量和大小.
  2. mallinfo():获取 malloc 管理的内存池的详细信息,比如空闲块的数量、大小等.
  3. getrusage():获取进程的资源使用情况,包括最大RSS(Resident Set Size)等,可以检查进程使用的最大物理内存.
    内存分配方式:
  4. malloc():从堆区动态分配内存,需要指定分配空间的大小,返回 void 指针.
  5. calloc():从堆区动态分配内存,需要指定分配元素的个数和大小,返回 void 指针,并将分配的空间初始化为0.
  6. realloc():重新调整 malloc 或 calloc 分配的内存空间,可以变小也可以变大.
  7. free():释放 malloc、calloc 或 realloc 分配的内存空间.
  8. brk()和sbrk():扩展或缩小程序断,改变堆区的大小.brk()直接设置程序断,sbrk()相对调整程序断.
  9. mmap():从虚拟地址空间映射物理内存,创建内存映射文件或匿名映射区域.
  10. munmap():解除 mmap 创建的内存映射.

2.1.6 extern”C” 的作用⭐⭐⭐

extern “C” 的作用是告诉编译器,引用的外部符号遵循C语言的命名规则.
在C++中,extern “C” 通常用于:

  1. 调用C语言库函数.因为C++支持函数重载,而C语言不支持,如果不使用extern “C”,编译器可能会产生二义性.
    例如:
// 调用C语言的printf函数
extern "C" {
    int printf(const char* format, ...); 
}
  • 1
  • 2
  • 3
  • 4
  1. 允许C语言代码调用C++函数.因为C++支持函数重载和名称修饰,而C语言不支持.使用extern "C"可以让C++函数遵循C语言的命名规范,方便C语言调用.
    例如:
// 定义供C语言调用的函数
extern "C" void func() {
    // ...
}
  • 1
  • 2
  • 3
  • 4
  1. 声明供C语言使用的变量或常量.extern “C” 可以让变量/常量名称遵循C语言规范,以便C语言访问.
    例如:
// 定义供C语言使用的全局变量
extern "C" int count;
  • 1
  • 2

所以,总结来说,extern “C” 的主要作用是:
使C++符号(主要是函数和全局变量)遵循C语言的命名和链接规则,以便C语言解析和调用;
调用C语言提供的库函数和全局符号.


2.1.7头文件声明时加extern,而在定义时不要加⭐⭐⭐⭐

因为extern可以多次声明,但只有一个定义


2.1.8函数参数压栈顺序,即关于__stdcall和__cdecl调用方式的理解⭐⭐⭐

在C/C++中,函数参数的压栈顺序分为__stdcall和__cdecl两种方式:
__stdcall:从右到左压栈.返回值由调用函数弹出.主要用于Windows API.
__cdecl:从右到左压栈.返回值由被调用函数弹出.C/C++的默认方式.
以计算两个整数和为例:

__stdcall:

int __stdcall add(int a, int b) {
    return a + b; 
}

int main() {
    int sum = add(1, 2); // sum = 3
}

__cdecl:

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
int __cdecl add(int a, int b) {
    return a + b;
}

int main() {
    int sum = add(1, 2); // sum = 3
} 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

在汇编层面,函数调用会将参数压入栈中.以上两个函数的压栈顺序为:
__stdcall:
向下增长的栈:
b (2)
a (1)
__cdecl:
向下增长的栈:
b (2)
a (1)
可以看出,两种方式参数的压栈顺序相同,都是从右到左.区别在于:
__stdcall:返回值由调用函数弹出; 比如说main(调用函数)
__cdecl:返回值由被调用函数弹出; 比如说 add(被调用函数)

当弹出返回值时,被调用函数的栈帧可能被清空,也可能未被清空,这取决于所使用的函数调用约定。
主要有两种情况:

  1. __stdcall调用约定:
    被调用函数直接返回,调用函数负责弹出返回值和清除被调用函数的栈帧。
    被调用函数的栈帧被清空。
  2. __cdecl调用约定:
    被调用函数首先弹出自己的返回值,然后返回调用函数。
    被调用函数自己负责清理栈帧,其栈帧被清空。

2.1.9重写memcpy()函数需要注意哪些问题⭐⭐

重写memcpy()函数需要注意以下几点:

  1. 参数检查:检查源地址src、目标地址dst和拷贝长度n是否有效.如果非法,返回NULL或报错.
  2. 拷贝方向:确定是从src到dst拷贝,还是从dst到src拷贝.这取决于需求,一般默认从src到dst.
  3. 重叠检测:检查src和dst是否有重叠,如果有重叠要确保正确的拷贝方向.一般从高地址向低地址拷贝可以避免覆盖尚未拷贝的数据.
  4. 指针越界检测:检查src+n和dst+n是否会越过各自缓冲区的边界.如果越界,则返回NULL或报错.
  5. 拷贝实现:进行实际的字节拷贝,一般使用循环实现,每次拷贝一个字节或一个大小适宜的块.
  6. 对齐检测:如果要精确重写memcpy,需要考虑内存对齐问题,采用对应体系结构的对齐方式进行拷贝.
  7. 返回值:返回目标地址dst,和标准memcpy函数相同.
    一个简单的memcpy()实现如下:
void *my_memcpy(void *dst, const void *src, size_t n) 
{
    // 参数检查
    if (!dst || !src || !n) {
        return NULL; 
    }
    
    // 获取指针
    char *d = (char*)dst;
    const char *s = (const char*)src;
    
    // 检查重叠
    if (dst > src && dst < src + n) {
        s += n;
        d += n;
    }
    
    // 拷贝
    while (n--) {
        *d++ = *s++;
    }
    
    return dst;
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

2.1.10数组到底存放在哪里⭐⭐⭐

数组存放在栈区或堆区,取决于数组的定义方式:

  1. 栈区数组:自动释放内存,存放在栈区.定义方式为:
int arr[5];  // 数组大小必须是常量
  • 1
  1. 堆区数组:手动释放内存,存放在堆区.定义方式为:
int *arr = malloc(5 * sizeof(int)); 
  • 1

2.1.11 struct和class的区别 ⭐⭐⭐⭐⭐

struct和class在C++中本质上都是类(class),都可以用作基类或派生类.
区别在于默认成员访问权限不同:
struct:默认public继承,用于定义数据结构;
class:默认private继承,更适用于定义抽象数据类型.
但通过显式指定访问权限,struct和class可以互相替换,没有本质区别.

比如结构体显示指定访问权限

struct A {
   int a;  // 默认为public
   A(int x) :a(x) {

   }
};



struct C : protected A {  // OK,A可以是C的基类
   int c;  // 默认继承A的public权限
   C(int x) :c(x), A(x)
   {

   }
};

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

2.1.12 char和int之间的转换;⭐⭐⭐

  1. char -> int:自动类型提升
    char是1个字节,int是4个字节.把char类型转换为int类型,会自动提升为int,并用其ASCII值来填充高位.
    例如:
char c = 'A';  // c = 65
int i = c;      // i = 65
  • 1
  • 2
  1. int -> char:显示类型转换
    把int类型转换为char类型,需要显示类型转换,并且int的值必须在char能表示的范围内(0~255).
    例如:
int i = 65; 
char c = (char)i; // c = 'A'
  • 1
  • 2
  1. char* -> int*:隐含类型提升
    char* 是一个字符指针,存储字符数组的首地址.转换为 int* 时,由于 int 的长度大于 char,会隐含地将 char* 的长度提升为 int.
    例如:
char str[10] = "Hello";
char* p = str;   // p = str
int*  q = p;      // q = p,类型提升为int*
  • 1
  • 2
  • 3
  1. int* -> char*:显示类型转换
    把int* 转换回 char* 需要显示类型转换,并且确保 int* 实际所指对象的长度不超过 char* 能表示的最大长度.
    例如:
int arr[10] = {1, 2, 3};
int* p = arr;         
char* q = (char*)p;  // 显示类型转换为char*
  • 1
  • 2
  • 3

2.1.13 static的用法(定义和用途)⭐⭐⭐⭐⭐

static关键字的主要用途有:

  1. 静态变量:
  • 静态全局变量:在所有文件内可见,只初始化一次.例如:
// file1.c
static int x = 0; 

// file2.c
extern int x;  
int main() {
    x = 1;     // x = 1
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 静态局部变量:在函数内声明,只初始化一次,并保留值在多次调用之间.例如:
void func() {
    static int x = 0;  
    x++;            // 第1次x=1;第2次x=2;... 
    printf("%d ", x);
}

int main() {
    func();     // 1 
    func();     // 2
    func();     // 3
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  1. 静态函数:只能被该文件内的函数调用,相当于文件内私有函数.例如:
static void hello() {
    printf("Hello!"); 
}

int main() {
    hello();   // 正确,可以调用
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

// 其他文件中无法调用hello()

  1. 静态常量:一次定义,程序运行期间不能改变.例如:
static const int SIZE = 5; 
  • 1

所以,总结来说,static的主要用途为:

  • 限定变量和函数的作用域(静态全局变量、静态局部变量、静态函数)
  • 使变量的值在多次调用之间保留(静态局部变量)
  • 定义静态常量

2.1.14const常量和#define的区别(编译阶段、安全性、内存占用等) ⭐⭐⭐⭐

const常量和#define的主要区别有:

  1. 编译阶段:
  • const常量:在编译阶段会进行类型检查,并分配内存空间,属于变量.
  • #define:在预处理阶段进行宏替换,不进行类型检查,不分配内存,属于宏.
    例如:
const int a = 5;     // int a = 5;   // 编译阶段分配内存
#define b 5            // 预处理阶段进行宏替换
  • 1
  • 2
  1. 变量与宏:
  • const常量是变量,有类型和内存空间.
  • #define是宏,在预处理阶段进行替换,没有类型和内存空间.
  1. 作用域:
  • const常量的作用域遵循变量的作用域规则.
  • #define的作用域从定义点开始,直到文件结束.
  1. 类型安全:
  • const常量是类型安全的,在编译阶段进行类型检查.
  • #define是非类型安全的,只进行简单的字符串替换,可能导致意外错误.
  1. 内存占用:
  • const常量会占用内存空间.
  • #define不会占用任何内存空间.
    所以,总结来说:
  • 当需要类型安全和内存空间时,使用const常量;
  • 当追求最高的运行效率和最小的内存占用时,使用#define宏;
  • 也可以两者结合使用,发挥各自的优势.

2.1.15 volatile作用和用法 ⭐⭐⭐⭐⭐

volatile关键字的主要作用是:防止变量被错误优化.
在C/C++中,编译器会对代码进行优化,以提高程序的运行效率.但这可能导致volatile变量产生未定义行为,因此需要使用volatile关键字进行标识,告诉编译器不要对其进行优化.
volatile有以下两种主要用法:

  1. 访问共享变量:
    当多个线程同时访问一个共享变量时,使用volatile可以确保各个线程都能读取该变量的最新值.例如:
volatile int cnt = 0;

void increment() {
    cnt++;  // 读取cnt的值,进行修改,再写入
}

int main() {
    // 线程1
    increment();  
    
    // 线程2
    increment();   
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

这里使用volatile确保cnt不会被优化为寄存器变量,这样每个线程都能读取到最新值.
2. 访问硬件寄存器:
当我们从寄存器中读取值或写入值时,使用volatile可以防止编译器对这些代码进行优化,确保每次都真正读取/写入硬件寄存器.例如:

volatile unsigned char *pIoAddr;
pIoAddr = (unsigned char *)0x1000;   

// 读取寄存器值
data = *pIoAddr;  

// 写入寄存器值
*pIoAddr = 0x05;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

这里使用volatile来保证对寄存器的每次访问都是真实的读/写操作.
所以,总结来说,volatile的主要作用是:

  • 防止变量被错误优化,以确保各线程读取的是变量的最新值;
  • 防止寄存器访问被错误优化,以确保每次访问都是真实的读/写操作.
    理解volatile的作用,可以帮助我们编写更加健壮和稳定的多线程程序.

2.1.16有常量指针 指针常量 常量引用 没有引用常量,解释以下这是为什么?⭐⭐⭐

  1. 常量指针:const修饰指针,指针指向的值是常量.可以修改去指向其他的变量.
    但无法通过常量指针的解引用*去修改其指向的值
    例如:
int a = 5;  
const int* p = &a;    // 常量指针p 或写成 int const * p 只要const在*前面都行
a = 10;                // 可以修改
p = &b;                // 可以修改
*p =21;             //不能修改 报错
  • 1
  • 2
  • 3
  • 4
  • 5
  1. 指针常量:const修饰指针, 表示指针是常量,不能指向别的对象,但指向的值可以被修改.
    其指向的地址不能修改,但可以通过解引用去修改其值 作用相当于引用
    例如:
const int b = 10;  
int* const p = &b;    // 指针常量p
b = 15;                // 编译错误,b是常量  
p = &a;                // 不能修改
*p =21;                //可以修改
  • 1
  • 2
  • 3
  • 4
  • 5
  1. 常量引用:const修饰引用,引用一个常量.例如:
int c = 20;
const int& ref = c;   // 常量引用ref
c = 25;                // 可以修改 
ref = 30;              // 编译错误,ref是一个常量引用
  • 1
  • 2
  • 3
  • 4
  1. 引用常量:不存在这样的情况.引用本身就是地址,没有实际的值,所以没有引用常量一说.
    需要注意的是,常量指针和指针常量的区别在于const的位置不同,表示含义也不同.常量引用实际上是引用一个常量.
    所以,可以总结如下:
  • 常量指针:const修饰常量,const 紧挨类型名,const保持在*号前面
  • 指针常量:const修饰指针,const放在*后
  • 常量引用:const修饰引用,引用一个常量;
  • 引用常量:不存在,引用本身没有实际的值.

2.1.17没有指向引用的指针.⭐⭐⭐

因为引用是没有地址的,但是有指针的引用


2.1.18c/c++中变量的作用域⭐⭐⭐⭐⭐

在C/C++中,变量的作用域主要有以下几种:

  1. 局部变量:在函数内定义的变量,只能在定义它的函数内使用.例如:
void func() {
    int a = 5;   // 局部变量a
}

int main() {
    func();
    printf("%d", a);  // 错误,a的作用域只在func()内
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  1. 函数参数:在函数定义中作为参数的变量,只在该函数内有作用.例如:
void add(int a, int b) {   // a和b为函数参数
    int sum = a + b;
}

int main() {
    add(1, 2);
    printf("%d", a);   // 错误,a的作用域只在add()内
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  1. 全局变量:在函数外定义的变量,在整个文件内都可以使用.例如:
int g = 10;   // 全局变量g

void func1() {
    printf("%d", g);   // 正确,可以使用g
}

void func2() {
    printf("%d", g);   // 正确,可以使用g
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  1. 命名空间变量:在命名空间内定义的变量,只能在该命名空间内使用.例如:
namespace ns1 {
    int a = 5;
}

namespace ns2 {
    printf("%d", a);  // 错误,a的作用域在ns1内
} 

ns1::printf("%d", a); // 正确,通过作用域解析运算符::使用ns1内的a
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

2.1.29 c++中类型转换机制?各适用什么环境?dynamic_cast转换失败时,会出现什么情况?⭐⭐⭐

在C++中,有以下几种类型转换机制:

  1. 静态类型转换:包括隐式转换和强制类型转换.

    • 隐式转换:编译器自动完成,不会失去信息,如double到int.
    • 强制类型转换:使用强制转换运算符(),可能失去信息,如int到char.
  2. const_cast:去除 Const-volatile 类型的常量性和易变性,常用于常量数据的写入.
    比如:
    const int a = 5;
    const int* p = &a;
    int* q = const_cast<int*>§; // 去除p的常量性
    *q = 10; // 现在可以修改a的值

  3. dynamic_cast:用于执行多态类型之间的没有阶层关系的转换,执行过程中会检查转换的安全性,如果没有继承或派生关系则会返回Null.

  4. reinterpret_cast:可以在任何两种类型直接转换,二进制数据直接复制,不会检查转换的安全性.

  5. static_cast:指明必须存在继承关系的显示类型转换,同时检查是否违反 const-volatile 属性.
    这几种类型转换的使用场景如下:

  • 静态类型转换:基本类型之间的转换.
  • const_cast:常量类型到非常量类型的转换.
  • dynamic_cast:多态类型之间的转换,且返回值提供类型检查.
  • reinterpret_cast:低级转换,将一种类型的二进制数据视为另一种类型.可能导致未定义行为,需谨慎使用.
  • static_cast:将一种类型明确转换为另一种类型,要求必须有继承关系.
    当dynamic_cast转换失败时(没有继承关系),如果是指针类型会返回NULL,如果是引用类型,会抛出std::bad_cast异常.

2.2 继承、多态相关面试题 ⭐⭐⭐⭐⭐
2.2.1继承和虚继承 ⭐⭐⭐⭐⭐

继承和虚继承是面向对象编程中的两个重要概念.
继承:

  • 允许创建一个新类,继承一个或多个已有的父类.
  • 子类继承父类的属性和方法,可以直接使用,也可以重写.
  • 用于实现重用代码,避免重复开发,提高效率.
    虚继承:
  • 同一个基类可以被多个子类继承.这使得不同的子类拥有相同的基类,并且能够通过基类的指针或引用进行操作.
  • 基类在继承树中只出现一次,不会产生重复.这避免了同一个基类被继承多次而带来的内存占用增大和调用时的二义性问题.
  • 要实现虚继承,需要在继承语句前加上关键字virtual.
    例如:
class Base 
{
public:
    void fun() { ... }
};

class Derived1 : virtual public Base 
{
public:
    ...
};

class Derived2 : virtual public Base 
{ 
public:
    ...
};

class Derived3 : public Derived1, public Derived2 
{  
public:
    ...
};
这里,Derived3同时继承Derived1和Derived2,而Derived1和Derived2又虚继承自同一个基类Base.所以Derived3只会有一个Base,避免了重复继承同一个基类带来的问题.
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

2.2.2多态的类,内存布局是怎么样的 ⭐⭐⭐⭐⭐

多态类的内存布局与普通类不同.理解多态类的内存布局对学习C++面向对象编程很重要.
普通类的内存布局:

  • 所有对象有相同的内存布局,包含vptr、vtable和数据成员.
  • vptr指向vtable,vtable包含实际调用的函数地址.
  • 当调用方法时,直接查找vtable获取方法地址调用.
    多态类的内存布局:
  • 派生类对象拥有基类对象的所有内存,再加上派生类自己的额外内存.
  • 派生类和基类共用相同的vptr,但vtable指向自己的方法.
  • vptr始终指向基类的vtable.当使用基类指针或引用调用方法时,会使用基类vtable.
  • 当使用派生类指针或引用调用方法时,会使用派生类vtable.
  • 这种机制实现了动态绑定,允许基类接口调用派生类实现.
    例如:
class Base 
{
public:
    virtual void fun1() { ... }
    virtual void fun2() { ... }
};

class Derived : public Base
{
public:
    virtual void fun1() { ... }  // 重写基类方法
    virtual void fun3() { ... }  // 新增方法
}; 

int main()
{
    Derived d;
    Base* b = &d;    
    
    b->fun1();  // 调用Derived::fun1(),使用派生类vtable
    b->fun2();  // 调用Base::fun2(),使用基类vtable
    
    d.fun1();   // 调用Derived::fun1(),使用派生类vtable
    d.fun3();   // 派生类独有方法,使用派生类vtable
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

可以看出,多态类的内存布局与调用机制实现了动态绑定的效果,这是面向对象编程和C++的核心特征之一.


2.2.3被隐藏的基类函数如何调用或者子类调用父类的同名函数和父类成员变量 ⭐⭐⭐⭐⭐

在继承关系中,子类可以调用被隐藏的基类函数或父类的同名函数/成员变量的几种方式:

1. 使用作用域解析运算符::显式调用基类函数:
class Base 
{
public:
    void fun() { ... }
};

class Derived extends Base 
{
public:
    void fun() { ... }
    
    void callBaseFun() 
    {
        Base::fun();  // 调用基类fun()
    }
};
1. 使用基类指针或引用调用:
cpp
void Derived::callBaseFun() 
{
    Base* b = this;
    b->fun();       // 调用基类fun()
}
cpp
void Derived::callBaseFun() 
{
    Base& b = *this;
    b.fun();        // 调用基类fun()
}
1. 使用using声明来重命名基类函数:
cpp
class Derived extends Base 
{
public:
    using Base::fun;   // 使用using重命名基类fun为fun
    void fun() { ... } 
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

现在fun()会调用派生类的fun(),而Base::fun()可以调用基类版本.

  1. 在子类构造函数中使用基类构造函数初始化基类子对象:
    Derived::Derived() : Base() { ... }

这会调用Base()构造函数,用于初始化基类子对象.
5. 使用基类的同名非静态成员直接访问:

class Base 
{
public:
    int val;
};

class Derived extends Base 
{
public:
    void accessBaseVal() 
    {
        val = 1;     // 访问基类val
        Base::val = 2; // 也可以使用作用域解析运算符访问
    }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

2.2.4多态实现的三个条件、实现的原理 ⭐⭐⭐⭐⭐

多态性是C++面向对象编程的一个关键特征.实现多态性需要满足三个条件:

  1. 继承:派生类继承基类
  2. 虚函数:基类中定义虚函数
  3. 重写:派生类重写基类的虚函数
    多态性实现的原理是动态绑定:
  • 每个对象都包含一个vptr,指向一个vtable.
  • vtable中包含虚函数的入口地址.
  • 当使用指针或引用调用虚函数时:
  1. 首先根据对象类型确定使用哪个类的vtable.
    2.然后在vtable中查找对应函数入口地址.
    3.最后调用该函数.
  • 所以,调用同一 interface,会执行不同的函数,这就是动态绑定的效果.
    例如:
class Base 
{
public:
    virtual void fun() { ... }
};

class Derived extends Base 
{
public:
    virtual void fun() { ... }  // 重写基类虚函数
};

int main() 
{
    Base* b = new Derived();
    b->fun();   // 调用Derived::fun(),动态绑定
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

这里,虽然 b 是Base类型,但因为它指向Derived对象,所以会调用Derived::fun(),实现动态绑定.
总结一下,多态性实现需要:

  1. 继承:建立父子类关系
  2. 虚函数:用于动态绑定,基类定义
  3. 重写:派生类重写虚函数
  4. 动态绑定:运行时根据对象实际类型来绑定函数调用

2.2.5对拷贝构造函数 深浅拷贝 的理解 拷贝构造函数作用及用途?什么时候需要自定义拷贝构造函数?⭐⭐⭐

拷贝构造函数的作用是:
使用另一个同类型对象初始化一个新对象.
拷贝构造函数的语法是:

ClassName (const ClassName &obj) {
    // 拷贝构造函数体
}
  • 1
  • 2
  • 3

它与普通构造函数的区别是:参数是相同类型的引用.
拷贝构造函数需要在以下情况下自定义:

  1. 类中包含动态内存分配,需要深拷贝而不是浅拷贝.
  • 浅拷贝只拷贝指针,导致两个对象共享一块内存.深拷贝分配新的内存并拷贝数据.
  1. 类中包含资源如文件、网络连接等,拷贝时需要打开/关闭、连接/断开资源.
  2. 类中成员变量具有指向同一对象的指针或引用,拷贝时需要处理这种关系.
  3. 防止拷贝时导致的异常情况.默认的拷贝构造函数实现是浅拷贝,这会引发问题.
  4. 类是一个抽象数据类型,拷贝时内部状态需要特殊处理.
    如果不定义拷贝构造函数,编译器会默认生成一个浅拷贝的版本.
    所以当需要深拷贝或资源特殊处理时,必须自定义拷贝构造函数.

举个简单示例:

class Person {
public:
    Person() {
        name = new string;
        *name = "Default Name";
    }
    
    Person(const Person &p) {  // 自定义拷贝构造函数
        name = new string;
        *name = *p.name;   // 深拷贝,分配新的内存
    }

private:
    string *name;
};

int main() {
    Person p1;
    Person p2 = p1;  // 使用拷贝构造函数
     cout << *p1.name << endl; // Default Name
     cout << *p2.name << endl; // Default Name
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

2.2.6析构函数可以抛出异常吗?为什么不能抛出异常?除了资源泄露,还有其他需考虑的因素吗?⭐⭐⭐

析构函数不能抛出异常.这是因为:

  1. 析构函数被调用时,对象的生命周期已经结束,资源被释放完毕.如果抛出异常,会导致资源泄露,因为异常会跳出析构函数,导致未被释放的资源无法再被访问.
  2. 析构函数通常被编译器自动调用,用户无法捕获其抛出的异常.这会导致程序异常终止.
  3. C++要求析构函数不能抛出异常.如果析构函数的定义中包含可能抛出异常的语句,编译器会报错.
    所以,析构函数中不能包含任何可能抛出异常的语句,必须确保资源能被无异常释放完毕.
    除资源泄露外,析构函数抛出异常还会造成其他问题:
  4. 程序crash:在对象生命结束后抛出的异常会跳出整个调用栈,导致程序异常终止.
  5. 内存泄露:对象中的动态内存由析构函数释放,抛出异常后内存无法释放,会产生内存泄露.
  6. 资源未关闭:如打开的文件、网络连接在析构函数抛出异常后无法关闭,会产生资源泄露的问题.
  7. 程序难以恢复:因为对象的生命周期已经结束,抛出的异常难以在合适的地方被捕获并处理,程序很难回到正常状态.
    所以,C++要求析构函数必须保证不抛出异常.编写析构函数时,需要注意确保每个可能抛出异常的语句都被try-catch块包裹,并在catch中对资源进行回收.

2.2.7什么情况下会调用拷贝构造函数(三种情况)⭐⭐⭐

  1. 使用另一个同类型对象初始化一个新对象
    例如:
Person p1;
Person p2 = p1;  // 调用拷贝构造函数
  • 1
  • 2

这里使用p1初始化p2,会调用Person类的拷贝构造函数.

  1. 以值作为函数参数传递对象时
    例如:
void func(Person p) { ... }

Person p;
func(p);  // 调用拷贝构造函数
  • 1
  • 2
  • 3
  • 4

在将p作为实参传给func()时,会调用拷贝构造函数生成p的一个副本.
2. 以值作为函数的返回值返回对象时
例如:

Person func() { ... } 

Person p = func(); // 调用拷贝构造函数
  • 1
  • 2
  • 3

func()的返回值是Person对象,这需要调用拷贝构造函数生成一个临时对象,然后将其返回.
所以总结来说,当我们使用另一个同类型对象初始化一个新对象,或者对象以值作为函数参数或返回值时,都会调用其拷贝构造函数.
拷贝构造函数相比于普通构造函数,增加了一个同类型对象作为参数.它的主要作用是:
将一个对象初始化为另一个对象的副本.
如果类中没有定义拷贝构造函数,编译器会自动生成一个浅拷贝的版本.所以当需要深拷贝或资源管理时,必须自定义拷贝构造函数.


2.2.8析构函数一般写成虚函数的原因⭐⭐⭐⭐⭐

  1. 允许对基类指针释放派生类对象
    例如:
class Base {
public:
    virtual ~Base() {}  // 虚析构函数
};

class Derived extends Base {
public: 
    ~Derived() {}
};

int main() {
    Base *b = new Derived();
    delete b;  // 调用Derived::~Derived()
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

这里我们对基类指针b调用delete,如果Base()不是虚函数,会直接调用Base::Base(),无法正确释放Derived对象所占用的资源.定义为虚函数后,可以调用正确的析构函数Derived::~Derived().

  1. 确认当前对象的实际类型
    对象的静态类型是指针或引用的类型,动态类型是实际创建的对象类型.虚析构函数可以根据对象的动态类型调用正确的析构函数.
  2. 允许子类修改父类的析构函数行为
    如果父类析构函数不是虚函数,子类将无法修改其行为,只能复写析构函数而无法重载.定义为虚函数后,子类可以重载父类的析构函数.
  3. 符合多态性原则
    在C++中,虚函数用于实现运行时多态性.析构函数作为类的一个重要接口,定义为虚函数可以与类的其他虚函数一致,提供面向对象的体系结构.
    所以,总结来说,析构函数定义为虚函数主要是出于以下目的:
  • 允许正确释放子类对象
  • 实现运行时逻辑与静态类型不符的情况
  • 允许子类修改父类析构函数行为(重载)
  • 与类的其他虚函数一致,提供完整的面向对象体系结构

2.2.9构造函数为什么一般不定义为虚函数⭐⭐⭐⭐⭐

构造函数用于对象的初始化,它创建对象并设置初始状态.虚函数的调用依赖于对象的实际类型,需要对象完全创建并且事先知晓对象的实际类型,这与构造函数的语义不符.此外,定义为虚函数会产生额外空间开销.所以,构造函数一般不定义为虚函数.


2.2.10什么是纯虚函数⭐⭐⭐⭐⭐

纯虚函数是虚函数的一种,它只有函数声明而没有函数体.纯虚函数的定义使用“= 0”syntax,如:
cpp
virtual void func() = 0; // 纯虚函数
包含纯虚函数的类是抽象类,无法实例化.它强制要求所有派生类实现该虚函数,用于定义接口.


2.2.11静态绑定和动态绑定的介绍⭐⭐⭐⭐

静态绑定(Static Binding): 函数调用在编译时决定,这要求在编译时就能确定函数的调用对象.仅普通成员函数才有静态绑定.
动态绑定(Dynamic Binding): 函数调用在运行时(根据对象的真实类型)决定,这需要虚函数和智能指针或引用.动态绑定实现了运行时多态,是C++的一大特点.


2.2.12 C++所有的构造函数 ⭐⭐⭐

C++有5种构造函数:

  1. 默认构造函数:无参构造函数,由编译器自动生成.
  2. 参数化构造函数:有参构造函数,根据传入参数初始化对象.
  3. 拷贝构造函数:使用同类型对象初始化另一个对象.
  4. 移动构造函数:使用右值引用类型的参数列表,用于移动语义.
  5. 转换构造函数:可以从其他类型转换而来,使用explicit关键字避免隐式转换.

2.2.13重写、重载、覆盖的区别⭐⭐⭐⭐⭐

重写(Override): 子类中与父类虚函数具有相同名称、参数列表和返回值类型的函数.用于实现动态绑定和运行时多态.
重载(Overload): 同一个类中,多个函数具有相同名称但参数列表不同.用于处理不同的参数类型和个数.
覆盖(Overide): 与C++中的重写概念相同,子类中与父类虚函数有相同的函数签名,替换父类的虚函数实现.


2.2.14成员初始化列表的概念,为什么用成员初始化列表会快一些(性能优势)?⭐⭐⭐⭐

成员初始化列表出现在构造函数参数列表后的冒号后面,用于初始化类的成员变量.
使用成员初始化列表而不是在构造函数体内初始化成员变量有以下优势:

  1. 避免重复调用构造函数.在初始化列表中只调用一次构造函数,在函数体内需要调用两次.
  2. 可以初始化const类型和引用类型的成员.这些类型必须在定义时初始化,在构造函数体内无法初始化.
  3. 效率更高.初始化列表可以直接初始化成员,无须调用构造函数,效率更高.

2.2.15如何避免编译器进行的隐式类型转换;(explicit)⭐⭐⭐⭐

在C++中,如果构造函数只有一个参数,编译器会自动进行隐式类型转换,这会引起一些问题.
可以使用explicit关键字将其定义为显式构造函数,防止编译器进行隐式类型转换:


class Person {
public:
    explicit Person(int age) { ... }  // 显式构造函数 
};
int main() {
    Person p = 10;   // 错误,无法隐式转换
    Person p(25)
 }  

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

第三章网络编程

3.1 TCP UDP
3.1.1 TCP、UDP的区别 ⭐⭐⭐⭐⭐

TCP是连接的,UDP是无连接的.
TCP保证可靠发送,UDP不保证.
TCP面向字节流,UDP面向报文.
TCP首部开销大,UDP首部开销小.
TCP传输速度慢,UDP传输速度快.


3.1.2 TCP、UDP的优缺点⭐⭐⭐

TCP 优点:

  1. 可靠传输
  2. 保序
  3. 面向连接
    TCP 缺点:
  4. 传输效率低(首部开销大,速度慢)
  5. 资源消耗大
    UDP 优点:
  6. 高效(首部小,速度快)
  7. 资源消耗小
    UDP 缺点:
  8. 不可靠
  9. 不保序
  10. 无连接

3.1.3 TCP UDP适用场景⭐⭐⭐

TCP适用场景:

  1. 需要可靠传输的场景,如文件传输、邮件发送等.
  2. 需要建立连接的场景,如HTTP协议.
    UDP适用场景:
  3. 对实时性要求高,但能容忍一定数据丢失的场景,如直播,语音聊天等.
  4. 数据报比较小的场景,这样UDP的首部开销对效率的影响比较小.

3.1.4 TCP为什么是可靠连接⭐⭐⭐⭐

TCP有以下特征使其成为可靠连接:

  1. 三次握手建立连接,四次挥手关闭连接: 这可以确保客户端与服务端都准备好传送数据后才开始.
  2. 校验和:TCP首部包含校验和字段,用于检验TCP报文内容是否发生变化,确保报文完整.
  3. 确认和重传机制:TCP每发送一个报文段后,会等待对方确认(ACK).如果超时未收到ACK,会重传该报文段.这可以确保报文到达.
  4. 流量控制:TCP使用滑动窗口来控制发送方的数据发送量,防止接收方来不及处理数据.这可以避免接收方的内存溢出.
  5. 序号:TCP为每个字节的数据分配一个序号,接收方按序号排列数据,这可以保证数据到达顺序.
    所以,TCP具有完整性校验、流量控制、确认重传、序号排序等机制,这使其成为一种面向连接的可靠协议.

3.1.5典型网络模型,简单说说有哪些;⭐⭐⭐

常见的网络模型有:

  1. OSI七层模型:物理层、数据链路层、网络层、运输层、会话层、表示层、应用层.
  2. TCP/IP四层模型:网络接入层、Internet层、传输层、应用层.

3.1.6 Http1.1和Http1.0的区别⭐⭐⭐

Http1.1与Http1.0的主要区别:

  1. 连接方式:Http1.1默认是持久连接,Http1.0默认是非持久连接.需要手动添加Connection:keep-alive头让Http1.0支持持久连接.
  2. 管线化:Http1.1支持报文的管线化传输,Http1.0不支持.
  3. 新增方法:Http1.1新增了PUT、DELETE、OPTIONS、TRACE等方法.
  4. mejor:Http1.1新增了Host头,可以指定要访问的服务器. solving Host头可以支持服务器虚拟主机.
  5. 状态码:Http1.1新增了一些状态码,如204 No Content等.
  6. 缓存控制:Http1.1新增了Cache-Control头,可以更精细地控制缓存策略.

3.1.7 URI(统一资源标识符)和URL(统一资源定位符)之间的区别⭐⭐

URI和URL的主要区别:
URI是URL的超集,URL是一种具体的URI.
URI用于唯一标识一个资源,URL提供了找到该资源的方式.
URI只定义了资源的标识,URL在URI的基础上添加了定位该资源的信息,如主机名、路径等.
所有URL都是URI,但不所有URI都是URL.


3.2 三次握手、四次挥手

3.2.1什么是三次握手⭐⭐⭐⭐⭐

三次握手是TCP连接建立的过程.它分为三个步骤:

  1. 客户端发送SYN报文,包含自己的初始序号seq=x
  2. 服务器收到SYN报文,回复ACK报文,确认客户的SYN报文,并包含自己的SYN报文以及初始序号seq=y
  3. 客户端收到服务器的SYN+ACK报文,回复ACK报文,确认服务器的SYN报文,并包含确认号ack=y+1
    此时,TCP连接完成三次握手,进入已连接状态,客户端和服务器可以开始传送数据.

3.2.2为什么三次握手中客户端还要发送一次确认呢?可以二次握手吗?⭐⭐⭐⭐

三次握手需要客户端再次发送ACK报文的原因是:

  1. 确保服务器收到客户端的SYN报文.如果服务器的SYN+ACK报文丢失,客户端不会再次发送SYN报文,导致服务器一直等待,连接失败.所以客户端再回复一个ACK可以确认连接成功建立.
  2. 区分客户端正常的SYN报文和SYN攻击.如果是SYN攻击,攻击者不会发送第三次ACK报文.所以服务器接收到客户端的ACK报文后,可以确认这是一个正常的连接请求而非攻击.
    不能使用二次握手的原因:
  3. 无法确认服务器是否真的收到了客户端的SYN报文,可能导致连接失败.
  4. 无法防范SYN攻击,因为攻击者也可以简单的发送SYN+ACK报文.
    所以,三次握手可以更可靠地建立TCP连接,这是它比二次握手更优的原因.

3.2.3为什么服务端易受到SYN攻击?⭐⭐⭐⭐

服务端易受到SYN攻击的原因是:

  1. 服务端在收到客户端的SYN报文后,会分配资源(如内存)并记录相关信息,等待客户端的确认报文.
  2. 攻击者发送大量伪造的SYN报文,服务端会分配大量资源等待确认,最终导致资源耗尽.
  3. 由于攻击者不会发送确认报文,所以这些等待的资源被占用却得不到释放,成为无用的等待,这就是SYN洪水攻击.

3.2.4什么是四次挥手⭐⭐⭐⭐⭐

  1. 客户端发送FIN报文,停止发送数据,释放连接.
  2. 服务器收到FIN报文,回复ACK确认报文,确认序号为收到的FIN报文序号+1.服务器进入CLOSE-WAIT状态.
  3. 服务器发送FIN报文,请求释放连接.
  4. 客户端收到FIN报文,回复ACK报文,确认 Release 连接.
    此时,TCP连接完成四次挥手,进入CLOSED状态,客户端和服务器彻底释放连接.

3.2.5为什么客户端最后还要等待2MSL?⭐⭐⭐⭐

MSL(Maximum Segment Lifetime)是报文段最大生存时间.2MSL时间内,报文段有可能被重传.
客户端等待2MSL的原因是:

  1. 防止重复接收到服务器的FIN报文段.如果客户端直接关闭连接,可能会再次接收到服务器重传的FIN报文,这会因为连接已经关闭而被丢弃,造成资源浪费.
  2. 防止ACK丢失.如果客户端发送的最后一个ACK丢失了,服务器会重传FIN报文,这时因为客户端已经关闭连接,无法再次确认,造成服务器长时间等待.
    所以,客户端等待2MSL时间可以确保服务器成功接收到最后的ACK报文,之后再完全释放连接,这可以使连接释放更加可靠.

3.2.6为什么建立连接是三次握手,关闭连接确是四次挥手呢?⭐⭐⭐⭐

三次握手和四次挥手之所以采用不同的步骤数,原因是:
建立连接时,客户端和服务器都是从初始状态开始的,都可以主动发起连接请求.所以采用三步握手,客户端首先发起SYN请求,服务器回复ACK确认,客户端再次确认ACK.
关闭连接时,由于连接已建立,其中一方要主动发起关闭请求.此时另一方可能还在发送数据,所以需要四步挥手来协调连接的释放:

  1. 发起关闭的一方发送FIN,请求释放连接.
  2. 对方收到FIN,回复ACK确认,但此时连接还未释放.
  3. 对方也发送FIN,请求释放连接.
  4. 发起关闭的一方收到FIN,回复ACK确认.此时双方的FIN均得到确认,连接释放.
    如果采用三步握手,可能导致被动关闭的一方的数据还未发送就连接已关闭,所以需要四步挥手来更可靠地释放连接.
    所以,三次握手建立连接和四次挥手释放连接,都是为了使TCP连接更加可靠而设计的.

第四章常见排序算法

4.1 排序算法
4.1.1各种排序算法的时间空间复杂度、稳定性⭐⭐⭐⭐⭐

算法最好最坏平均空间稳定性
冒泡排序O(n)O(n^2)O(n^2)O(1)稳定
选择排序O(n^2)O(n^2)O(n^2)O(1)不稳定
插入排序O(n)O(n^2)O(n^2)O(1)稳定
希尔排序O(nlogn)O(nlog^2n)O(nlogn)O(1)不稳定
归并排序O(nlogn)O(nlogn)O(nlogn)O(n)稳定
快速排序O(nlogn)O(n^2)O(nlogn)O(logn)不稳定
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)不稳定

4.1.2各种排序算法什么时候有最好情况、最坏情况(尤其是快排) ⭐⭐⭐⭐

最好情况:数据已经排序,选取的划分元素恰好是中间元素
平均情况:选取的划分元素接近中间
最坏情况:数组中只有两个不同数值,选取的划分元素总是极端元素.
例如快速排序:
最好情况:O(nlogn),当数组已经排序,每次选取的划分元素恰好是中间元素时
平均情况:O(nlogn),选取的划分元素随机接近中间
最坏情况:O(n^2),当数组中只有两个不同数值,每次选取的划分元素总是极端元素时,会退化为冒泡排序.


4.1.3 快速排序

    void quick_sort(int q[], int l, int r)
    {
        if (l >= r) return;//结束条件

        int i = l - 1, j = r + 1, x = q[l + r >> 1];
        while (i < j)
        {
            do i ++ ; while (q[i] < x);
            do j -- ; while (q[j] > x);
            if (i < j) swap(q[i], q[j]);
        }
        quick_sort(q, l, j), quick_sort(q, j + 1, r);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

4.1.4 归并排序

   void merge_sort(int q[], int l, int r)
   {
       if (l >= r) return;

       int mid = l + r >> 1;
       merge_sort(q, l, mid);
       merge_sort(q, mid + 1, r);

       int k = 0, i = l, j = mid + 1;
       while (i <= mid && j <= r)
           if (q[i] <= q[j]) tmp[k ++ ] = q[i ++ ];
           else tmp[k ++ ] = q[j ++ ];

       while (i <= mid) tmp[k ++ ] = q[i ++ ];
       while (j <= r) tmp[k ++ ] = q[j ++ ];

       for (i = l, j = 0; i <= r; i ++, j ++ ) q[i] = tmp[j];
   }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

4.2 STL库相关
4.2.1 vector list异同⭐⭐⭐⭐⭐

相同点:

  1. 都是顺序容器,元素存储在连续的内存空间中.
  2. 都是动态数组,大小可以改变.
    不同点:
  3. vector元素是连续存储的,list元素通过双向链表连接,不必连续.
  4. vector的内存空间在创建时就分配好,list是动态分配的.
  5. vector支持随机访问,list只支持顺序访问.
  6. vector的插入和删除较慢,list的插入和删除较快.

4.2.2 vector内存是怎么增长的vector的底层实现⭐⭐⭐⭐

vector的内存空间是连续的,在创建时根据初始大小分配内存.当元素增加超过当前容量时,vector会重新分配内存,新空间的大小为当前空间的2倍.
vector的底层结构如下:

struct vector {
    char* start;     //首元素地址
    char* end;       //尾元素的下一个位置
    char* endOfStorage; //当前空间的尾部
    //...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

插入元素时,如果超出endOfStorage,则会分配新空间,将原数据复制过来,释放旧空间.

4.2.3 vector和deque的比较⭐⭐⭐⭐

相同点:

  1. 都是顺序容器,元素存储在连续的内存空间中.
  2. 都是动态数组,大小可以改变.
    不同点:
  3. vector的内存空间在创建时就分配好,deque的内存空间以small chunks(小块)的形式动态增长.
  4. vector的内存空间连续,deque的内存空间不必连续.
  5. 插入和删除操作,vector要慢一些,deque的插入和删除在两端较快.
  6. vector支持随机访问,deque也支持,但deque的随机访问稍微低效一点.
    所以,总体来说,deque在两端的插入删除有优势,在中间随机访问稍微劣势,其他方面和vector差不多.

4.2.4为什么stl里面有sort函数list里面还要再定义一个sort⭐⭐⭐

STL中sort函数使用的排序算法是比较排序,它要求被排序的容器必须支持随机访问迭代器.
list是以双向链表实现的,只支持双向迭代器,不支持随机访问.所以list不能直接使用通用的sort算法.
为了给list也提供排序功能,STL在list中另外实现了一个sort成员函数,该函数使用归并排序算法,它只需要双向迭代器就可以工作,所以可以用于list.
所以,原因是算法要求的迭代器种类不同导致的.STL为了各容器都提供排序功能,在list中重载了sort成员函数.


4.2.5 STL底层数据结构实现⭐⭐⭐⭐

vector: 数组
list: 双向链表
deque: 双端队列
stack: 以deque或list实现
queue: 以deque或list实现
priority_queue: 以vector和堆结构实现
set/multiset: 红黑树
map/multimap: 红黑树
hash_set/hash_map: 哈希表


4.2.6利用迭代器删除元素会发生什么?⭐⭐⭐⭐

使用迭代器删除STL容器中的元素会发生以下情况:

  1. 删除当前迭代器所指向的元素.当前迭代器失效.
  2. 容器尾部的元素会向前移动,原来的尾元素会被删除,尾迭代器也随之失效.
  3. 除了当前迭代器和尾迭代器之外,容器内其他迭代器不受影响,仍然有效.
  4. 删除元素后,容器的size会减小1.

4.2.7 map是如何实现的,查找效率是多少⭐⭐⭐⭐⭐

map是基于二叉树(红黑树)实现的. map的查找时间复杂度为O(logN).
map的底层结构为二叉搜索树,它具有以下特点:

  1. 任意节点的左子树中所有节点的值都小于该节点的值;右子树中所有节点的值都大于该节点的值.
  2. 左、右子树也分别为二叉搜索树.
  3. 中序遍历二叉搜索树得到有序序列.
    map的查找过程为:
  4. 从根节点开始查找,如果目标key等于根节点key,则找到,否则进行第2步.
  5. 如果目标key小于根节点key,则递归左子树查找;如果目标key大于根节点key,则递归右子树查找.
  6. 若找到目标节点,返回其value,否则插入新节点.
    由于map采用二叉搜索树,每次可以排除一半的搜索范围,因此时间复杂度为O(logN).

4.2.8几种模板插入的时间复杂度 ⭐⭐⭐⭐⭐

常见的STL容器插入元素的时间复杂度:
vector:
vector的底层是数组,插入元素需要移动元素,时间复杂度O(N).
但vector在元素少时(不触发扩容)是O(1).
list:
list的底层是双向链表,插入元素只需要调整指针,时间复杂度O(1).
deque:
deque的插入取决于插入的位置,在头尾插入是O(1),在中间插入需要移动元素,是O(N).
set/map:
set和map的底层是红黑树,插入元素需要维护二叉搜索树,时间复杂度O(logN).
unordered_set/unordered_map:
unordered_set和unordered_map的底层是哈希表,插入元素需要 rehash,时间复杂度期望是O(1).
所以,总结下来,常见容器的插入时间复杂度为:

vector:O(N)/O(1) 
list:O(1) 
deque:O(1)/O(N)  
set/map:O(logN)
unordered_set/unordered_map:O(1) 
  • 1
  • 2
  • 3
  • 4
  • 5

第五章Linux操作系统常见面试题

5.1 Linux内核相关

5.1.1 Linux内核的组成⭐⭐

什么搞清楚什么是内核?内核就是软件层面的核心系统程序.

  1. 进程调度子系统:负责进程之间的调度与切换.
  2. 内存管理子系统:负责内存的分配、回收和交换.
  3. 文件系统:负责文件读取、写入和管理.
  4. 网络子系统:实现网络协议栈和网络设备驱动.
  5. 驱动程序:负责磁盘驱动、键盘鼠标驱动等硬件驱动.
  6. 安全机制:实现用户管理、文件权限控制等安全功能.
  7. 体系结构相关代码:与CPU体系结构密切相关的汇编代码.
  8. 其他:进程通信、编译器、命令行解析等机制.
    所以,Linux内核是一个庞大的软件体系,它涉及操作系统的各个方面,相互配合来完成对系统的管理与调度.

5.1.2用户空间与内核通信方式有哪些?⭐⭐⭐⭐⭐

用户空间和内核空间的通信方式主要有:

  1. 系统调用:用户空间调用内核空间的函数,进入内核空间执行.
  2. 信号:用于进程间通信,也是一种用户空间和内核空间通信的方式.
  3. /proc文件系统:用户空间可以通过/proc下的伪文件与内核空间通信.
  4. ioctl:用于设备驱动的控制,也是一种用户内核通信方式.
  5. netlink socket:用于内核空间和用户空间进程的网络通信.
  6. 内存映射:可以将文件映射到内存,实现用户内核通信.
  7. 管道和消息队列:允许用户空间和内核模块通信的有名管道.
  8. 套接字:用户空间通过套接字产生的系统调用与内核空间网络栈通信.
  9. 事件通知:通过epoll等机制,内核空间将事件通知给用户空间.
    所以,Linux提供了丰富的手段允许用户空间和内核空间进行通信与协作.这是Linux强大功能的基础.

5.1.3系统调用read()/write(),内核具体做了哪些事情⭐⭐

系统调用read()和write()在内核空间中会做以下事情:
read():

  1. 获取参数fd(文件描述符),buf(存放读入数据的缓冲区地址),count(要读入的字节数).
  2. 根据文件描述符fd找到对应文件的文件结构,并获取文件当前读写位置offset.
  3. 如果文件是从磁盘上读取,内核需要将数据从磁盘读入页缓存.这涉及磁盘调度与数据复制.
  4. 将数据从页缓存复制到用户空间buf,复制的字节数为内核可以读取的和用户请求的字节数的最小值.
  5. 减小文件大小,并增加offset,即文件读写位置向后移动.
  6. 将复制的字节数返回给用户空间.
    write():
  7. 获取参数fd(文件描述符),buf(存放写入数据的缓冲区地址),count(要写入的字节数).
  8. 根据文件描述符fd找到对应文件的文件结构,并获取文件当前读写位置offset.
  9. 如果文件是写入磁盘,内核需要查找页缓存中对应的页,如果页不存在则需要分配新页.
  10. 将用户空间buf的数据复制到页缓存中.复制的字节数为内核可以写入的和用户请求的字节数的最小值.
  11. 增加文件大小,并增加offset,即文件读写位置向后移动.
  12. 将复制的字节数返回给用户空间.
  13. 如果需要,内核会将数据从页缓存刷新到磁盘.
    所以,read()和write()的主要工作是数据在用户空间、页缓存和磁盘之间的复制与移动,以及文件的指针位移.这涉及到进程调度、内存管理、文件系统和磁盘调度等内核工作.

5.1.4系统调用的作用⭐⭐⭐⭐⭐

系统调用的主要作用是:

  1. 实现用户空间和内核空间的通信.系统调用允许用户空间调用内核空间的函数,以完成一定的操作.
  2. 保护内核空间的资源与数据.系统调用是进入内核空间的唯一方式,内核可以在系统调用处对权限进行检查,避免非法访问.
  3. 实现进程封装与权限隔离.每个进程只能执行自己的系统调用,不能干涉其他进程,这是操作系统实现多进程的基础.
  4. 完成用户空间无法完成的操作.比如磁盘访问、网络通信等都只能在内核空间实现,用户空间必须通过系统调用请求内核完成这些操作.
  5. 提供用户空间访问硬件的抽象接口.系统调用将硬件的层次结构与实现细节隐藏在内核空间,对用户空间提供统一的接口.
    所以,系统调用的存在是操作系统架构的基础,它同时具有通信,保护,封装和抽象的作用,是内核机制实现的媒介.操作系统能够应用于各种不同的硬件平台,正是由于系统调用的抽象作用.

5.1.5内核态,用户态的区别⭐⭐⭐⭐⭐

内核态和用户态的主要区别如下:

  1. 权限不同:内核态有最高权限,用户态权限受限.
  2. 可访问资源不同:内核态可以访问全部资源,用户态只能访问部分资源.
  3. 运行模式不同:内核态使用超级用户模式,用户态使用普通用户模式.
  4. 错误处理不同:内核态错误会导致内核崩溃,用户态错误只影响单个进程.
  5. 指令集不同:内核态可以使用特权指令,用户态只能使用限制指令集.
  6. 执行效率不同:内核态执行效率较高,用户态需系统调用进入内核态才能执行.
  7. 调度方式不同:内核态通过中断调度,用户态通过进程调度与上下文切换.
  8. 访问空间不同:内核态可以访问整个物理空间,用户态只能访问自身的虚拟空间.
    所以,内核态具有最高权限和最广的资源访问范围,用于操作系统内核的运行.而用户态用于运行用户程序,权限和资源受限,需要通过系统调用来请求内核的服务.这种模式使得系统资源得到有效保护.

5.1.6 bootloader内核 根文件的关系⭐⭐⭐⭐

Bootloader的启动过程通常分为两阶段:
第一阶段:主引导记录(MBR)加载与执行

  1. BIOS/UEFI寻找可引导设备,找到MBR(主引导记录),并加载到内存执行.
  2. MBR检查分区表,确定哪个分区包含bootloader,并加载bootloader到内存.
  3. 执行bootloader的第一阶段.
    第二阶段:bootloader加载内核
  4. bootloader校验并加载内核镜像文件到内存.
  5. 设定内核启动参数,如内核命令行、内核加载地址等.
  6. 切换到内核启动代码,开始执行内核.
  7. bootloader的任务完成,内核开始初始化硬件并启动系统.
    综上,BIOS/UEFI先找到MBR并执行,MBR再找到bootloader并执行第一阶段.第一阶段bootloader然后加载内核到内存,设置启动参数,并跳转执行内核.
    这两阶段的过程保证了系统引导的连贯性,MBR引导bootloader,bootloader引导内核,这是启动链的基本过程.
    不同的系统和硬件会稍有差异,但保持这两阶段引导的基本思路.内核的加载和设置参数是bootloader最重要的工作.

5.1.7 Bootloader多数有两个阶段的启动过程:⭐⭐⭐

是的,Linux的内核是由bootloader装载到内存中的.
Linux的启动过程如下:

  1. 系统上电,CPU从BIOS/UEFI固件开始执行.
  2. BIOS/UEFI初始化硬件,并找到第一个可引导设备(通常是硬盘).
  3. 加载该设备的主引导记录MBR到内存执行.
  4. MBR中含有分区表信息和bootloader,MBR执行bootloader的第一阶段.
  5. bootloader(如GRUB等)的第一阶段检验启动参数和内核镜像文件.
  6. bootloader加载Linux内核镜像文件到内存,并为其设置启动参数.
  7. bootloader执行Linux内核镜像,Linux内核开始运行.
  8. Linux内核初始化设备,挂载根文件系统并启动init进程.
  9. init进程启动其他服务进程,系统启动完成.
    所以,linux的内核是在第6步由bootloader加载到内存中的.bootloader设置好参数并执行内核后,内核开始运行并负责后续的系统初始化.
    如果没有bootloader,Linux的内核无从加载和启动.bootloader为内核的运行提供了引导和条件.所以,Linux内核是由bootloader装载到内存中,然后由bootloader执行,这是Linux系统启动的基本过程.

5.1.8 linux的内核是由bootloader装载到内存中的?⭐⭐⭐

是的,Linux的内核是由bootloader装载到内存中的.
Linux的启动过程如下:

  1. 系统上电,CPU从BIOS/UEFI固件开始执行.
  2. BIOS/UEFI初始化硬件,并找到第一个可引导设备(通常是硬盘).
  3. 加载该设备的主引导记录MBR到内存执行.
  4. MBR中含有分区表信息和bootloader,MBR执行bootloader的第一阶段.
  5. bootloader(如GRUB等)的第一阶段检验启动参数和内核镜像文件.
  6. bootloader加载Linux内核镜像文件到内存,并为其设置启动参数.
  7. bootloader执行Linux内核镜像,Linux内核开始运行.
  8. Linux内核初始化设备,挂载根文件系统并启动init进程.
  9. init进程启动其他服务进程,系统启动完成.
    所以,linux的内核是在第6步由bootloader加载到内存中的.bootloader设置好参数并执行内核后,内核开始运行并负责后续的系统初始化.
    如果没有bootloader,Linux的内核无从加载和启动.bootloader为内核的运行提供了引导和条件.所以,Linux内核是由bootloader装载到内存中,然后由bootloader执行,这是Linux系统启动的基本过程.
    另外,需要说明的是:
  10. bootloader读取内核镜像文件,并没有直接加载到内存,而是解压和复制到内存,然后设置启动参数.
  11. 执行内核意味着跳转到内核的入口地址,并不会执行整个内核,内核也会继续初始化.
  12. bootloader与内核之间通过多种机制进行参数传递,如内核命令行、启动参数块等.
  13. 正常引导后,bootloader的代码会退出或进入监控模式.
    所以,内核的装载和启动离不开bootloader的参与,这也是操作系统引导过程的重要一环.内核与bootloader相互配合,完成系统的引导工作.

5.1.9为什么需要BootLoader⭐⭐⭐⭐

需要BootLoader的主要原因是:

  1. 内核是操作系统的关键部分,但其本身不具备硬件操作能力,无法直接从硬盘启动.需要BootLoader将内核加载到内存,并转移控制权给内核.
  2. 引导过程需要访问硬件,设置内核参数等,内核代码还未启动无法完成.需要BootLoader完成初期的硬件访问和参数设置.
  3. BIOS/UEFI固件的功能有限,无法直接启动复杂的操作系统内核.需要BootLoader作为中间软件来与操作系统接口.
  4. 引导过程需要装载多个组件(内核,initramfs,设备树等),需要BootLoader来完成这些资源的装载工作.
  5. 操作系统的内核会不定期更新,但硬件不会更改.所以需要BootLoader来适配不同版本的内核.
  6. 支持多种操作系统在同一台计算机上选择启动.需要BootLoader来提供启动菜单和引导不同系统的内核.
  7. 支持硬盘分区与文件系统识别.启动过程需要访问文件系统和加载文件的BootLoader.
    所以,BootLoader承担了较为底层的硬件初始化、装载内核和选择启动项等工作.它屏蔽了硬件差异,为操作系统内核的加载和启动提供了环境.
    如果没有BootLoader,操作系统的内核就无法从存储介质上启动,也无法与底层硬件进行交互,失去运行的基础.
    这也是操作系统无法直接从硬件firmware上启动,需要引入BootLoader软件的原因.它具有硬件适配与接口转换的作用.

5.1.10 Linux内核同步方式总结⭐⭐⭐⭐

Linux内核中的同步方式主要有:

  1. 自旋锁:cpu一直循环检测锁的状态,直到获得锁.效率高但会造成cpu空转.
  2. 互斥锁:获取不到锁的线程会睡眠,释放锁的线程会唤醒等待的线程.
  3. 读写锁:共享锁(多个读线程同时访问)和独占锁(只有一个写线程访问).
  4. 信号量:计数信号量用来限制访问某资源的线程数.
  5. 上下文切换:主动让出cpu使用权,由调度程序选择其他线程运行.
  6. 禁止中断:临界区代码禁止中断发生,进入后完成操作再开中断.
  7. 原子操作:对一个数据的多条操作作为一个整体执行,中间不会被中断.
  8. 互斥体:线程获取后能独占使用某资源,使用完成再释放该互斥体.
  9. SPIN LOCK:高速旋转锁,与互斥锁相比获取时间更短.
  10. 序列化:多个线程通过一定顺序依次访问共享资源,避免同时访问.
    Linux内核使用上述同步机制来协调多线程/进程对共享资源的访问,使之变得有序和串行. 不同的同步方式有不同的适用场景,内核会根据需要选择恰当的方式.
    这些同步机制是保证操作系统内核正确工作的基础.

5.1.11为什么自旋锁不能睡眠 而在拥有信号量时就可以?⭐⭐⭐⭐

这是因为自旋锁和信号量的机制不同:
自旋锁:

  1. 未获得锁的线程会一直循环检测锁的状态,这段时间cpu处于忙等待状态.
  2. 依赖cpu的高速循环来获得锁,睡眠会让出cpu,无法保证快速得到锁.
  3. 由于未睡眠,线程一直保持运行状态,调度器也无法进行线程调度.
  4. 无法分配cpu使用权给其他线程,效率低且会影响系统性能.
    信号量:
  5. 未获得信号量的线程会调用块锁操作并睡眠,释放cpu使用权.
  6. 只有在信号量被释放时,线程才会被唤醒获得信号量.
  7. 睡眠的线程变成可调度状态,调度器可以选择其他线程运行.
  8. 能够充分利用cpu,不会阻塞其他线程,整体性能高.
  9. 使用计数器对可访问资源数加以限制,而不是全量互斥 like 自旋锁.
    所以,自旋锁之所以不能睡眠,是因为它依赖忙等待来获得锁,睡眠会让线程失去获得锁的时机.
    而信号量使用唤醒机制来通知线程获得资源,线程可以睡眠从而释放cpu,这也是它较自旋锁高效的原因.
    两者机制完全不同,自旋锁追求响应速度以可能造成线程饥饿,而信号量同时兼顾效率与公平性.根据使用场景选择适当的同步机制是设计高效系统的关键.

5.1.12 linux下检查内存状态的命令⭐⭐⭐

linux下常用的检查内存状态的命令有:

  1. free:显示当前内存使用总量和空闲内存量.
    bash
    free -h # -h 人性化显示

  2. vmstat:详细显示内存相关统计信息.
    bash
    vmstat 2 3 # 每2秒获取一次信息,共3次

  3. pidstat:统计每个进程的内存使用情况.
    bash
    pidstat -r 2 3 # 每2秒获取一次信息,共3次

  4. sar:历史记录和monitoring系统活动情况.
    bash
    sar -r 2 10 # 每2秒检查一次,共10次,监控内存相关信息

  5. top:动态监控进程状态,其中包括内存统计信息.
    bash
    top -b -n 2 # 批处理模式共2次,查看内存信息
    这些命令可以监控内存使用量、使用比例、分配情况、进程内存使用等信息.
    通过这些信息可以了解系统内存的负载、分配和潜在瓶颈,也是检测内存泄漏和优化的重要手段.


5.1.13 linux的软件中断⭐⭐⭐

软件中断. 软件中断和我们常说的中断(硬件中断) 不同之处在于, 它是通
过软件指令触发而并非外设引发的中断, 也就是说, 又是编程人员开发出的一种异
常(该异常为正常的异常) . 操作系统一般是通过软件中断从用户态切换到内核态


5.2 其他操作系统常见面试题
5.2.1大小端的区别以及各自的优点,哪种时候用⭐⭐⭐⭐⭐

大小端分为大端(Big Endian)和小端(Little Endian)两种:
大端:高位字节存储在低地址,低位字节存储在高地址.
小端:低位字节存储在低地址,高位字节存储在高地址.
大小端的优缺点:
大端:
优点:地址加1,值也加1,计算方便.
缺点:不容易理解,两台系统间数据交互困难.
小端:
优点:数据在内存的表示更自然,两台不同系统间数据交互方便.
缺点:地址加1,值不一定加1,计算稍复杂.
选择的时候需要考虑:

  1. 本地计算机架构:如果是大端架构用大端,小端架构用小端.
  2. 数据交互:如果数据需要在不同系统间交换,通常选择小端.因为大多数系统采用小端.
  3. 运算性能:如果程序涉及大量低级算术运算,可以选择与机器本地序一致的大小端,以提高运算性能.
    所以,没有绝对的好坏,根据具体应用场景选择适当的大小端.一般来说,小端得到更广泛应用,大端在某些特定领域也有不俗表现.
    程序员需要理解两种方式的区别与适用场景,在设计数据存储格式和交互接口时做出合理选择.

5.2.2 一个程序从开始运行到结束的完整过程(四个过程)⭐⭐⭐⭐⭐

一个程序从开始运行到结束的完整过程通常分为四个阶段:

  1. 编译:源代码(.c)文件由编译器编译为目标文件(.o),包含机器码.
  2. 链接:多个.o文件和库文件由链接器链接生成可执行文件(程序).
  3. 加载:操作系统将可执行文件加载到内存中,生成进程映像并为之分配资源.
  4. 执行:处理器根据进程映像的机器指令并结合输入、输出等运行环境执行程序.
    编译阶段实现源代码转化为机器可理解的指令.链接阶段实现各模块和库的组合.
    加载阶段将链接结果装入内存并创建进程运行环境.执行阶段按照加载结果在处理器上运行.
    这四个阶段加起来完成程序的构建和执行.大致流程如下:
    源代码(编程语言) --> 编译 --> 对象文件(.o)
    对象文件 --> 链接 --> 可执行文件(程序)
    可执行文件 --> 加载 --> 内存映像(进程)
    内存映像 --> 执行 --> 运算结果
    不同编程语言和操作系统会稍有不同,但基本过程保持一致.这也是我们在学习编程和操作系统时,需要理解的程序构建与执行的完整流程.
    各个阶段的正确实现和流畅衔接最终决定程序的正常工作.

5.2.3什么是堆,栈,内存泄漏和内存溢出?⭐⭐⭐⭐

堆:动态分配内存,可以任意增长和释放,由程序员管理.
栈:函数参数、局部变量等自动分配和释放,由编译器管理.
内存泄漏:程序未释放已经不再使用的堆内存,导致内存资源浪费.
内存溢出:程序尝试访问超出可用内存范围的地址,导致程序崩溃.
具体来说:
堆:

  • 由malloc/new在运行时动态分配内存
  • 程序员需要手动释放内存(free/delete)
  • 大小可变,增长和释放由程序控制
  • 用于对象和数据的动态分配
    栈:
  • 由编译器自动分配和释放
  • 大小和生命周期由编程语言决定
  • 用于函数调用的参数传递和局部变量
  • 没有内存释放问题
    内存泄漏:
  • 堆内存使用后未及时或正确释放
  • 会致使可用内存持续减少,严重时导致程序崩溃
  • 需程序员在开发和测试时防范和修复
  • 造成系统性能下降和资源浪费
    内存溢出:
  • 程序试图访问超出可用内存范围的地址
  • 通常是由于数组索引越界、指针运算错乱等 programming bug 导致
  • 会引起程序异常和崩溃
  • 需要在开发阶段就避免和修复
    所以,堆和栈是内存的两种分配方式.内存泄漏和内存溢出是内存使用中的两类常见问题.
    程序员需要熟练运用堆和栈,并在开发过程中防范内存错误,编写出健壮的程序.

5.2.4堆和栈的区别⭐⭐⭐⭐⭐

堆和栈的主要区别如下:

  1. 分配方式:
    堆:动态分配,程序运行时由程序员分配和释放.
    栈:自动分配,编译时由编译器插入分配和释放代码.
  2. 生命周期:
    堆:由程序员决定,可动态分配和释放.
    栈:由程序结构决定, entering 和leaving 函数自动释放.
  3. 空间大小:
    堆:可动态扩展大小.
    栈:大小固定,由编译器分配.
  4. 访问速度:
    堆:相对较慢.
    栈: 访问速度快.
  5. 线程安全:
    堆:需要使用锁和同步来保证线程安全.
    栈:天然的线程安全.
  6. 内存释放:
    堆:需程序员手动释放.
    栈:自动释放,无需程序员干预.
  7. 示例用途:
    堆:动态对象、数组等.
    栈:函数的参数和局部变量.
    所以,堆与栈是内存管理的两种不同方式,各有优缺点.根据具体需要选择使用堆或栈,或者二者结合,这需要程序员在设计和编程时理解二者区别与联系.
    正确地管理堆内存和使用栈空间,对编写高效稳定程序至关重要.
    两者的主要联系在于:
    程序的运行离不开内存的分配,堆和栈的有机结合为程序提供了内存资源.
    堆可动态分配内存满足程序开发与运行的需求,栈自动释放内存简化了内存管理的工作.

5.2.5死锁的原因、条件 创建一个死锁,以及如何预防⭐⭐⭐⭐⭐

死锁的原因:

  1. 竞争:多个进程在不同的顺序获取资源.
  2. 资源限制:至少有一个进程持有不可剥夺的资源而被阻塞.
  3. 进程不可抢占:进程无法被抢占资源.
  4. 循环等待:两个或多个进程形成一环路,每个进程等待下一个进程释放资源.
    死锁的必要条件(哲学家进餐问题):
  5. 互斥:每个资源仅能被一个进程使用.
  6. 占有且等待:进程已获得部分资源,但等待其他资源.
  7. 不可剥夺:资源在进程使用期间不能被其他进程抢占.
  8. 循环等待:进程间形成环路,每个进程等待下一个进程释放资源.
    构造一个死锁:
#include <pthread.h>
#include <stdio.h>
#define NUM_THREADS 5   
#define TCOUNT 10  
int count = 0;
pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER;

void *inc_count(void *t) {  
   pthread_mutex_lock(&mutex1);
   count++;
   printf("Incrementing count in thread %d to %d\n",*(int*)t, count);
   pthread_mutex_unlock(&mutex1);
}

void *dec_count(void *t) {  
   pthread_mutex_lock(&mutex2); 
   count--;
   printf("Decrementing count in thread %d to %d\n",*(int*)t, count);
   pthread_mutex_unlock(&mutex2);
}

int main(void) {  
   pthread_t threads[NUM_THREADS];
   int a[NUM_THREADS], b[NUM_THREADS];

   for (int i = 0; i < NUM_THREADS; i++) {
     a[i] = i; b[i] = (i+1)%NUM_THREADS; 
     printf("Creating thread %d and %d\n", i, b[i]);
     pthread_create(&threads[i], NULL, inc_count, &a[i]); 
     pthread_create(&threads[b[i]], NULL, dec_count, &b[i]); 
   }
   pthread_exit(NULL);
}
运行结果:
Creating thread 0 and 1
Incrementing count in thread 0 to 1
Decrementing count in thread 1 to 0
Creating thread 1 and 2
Incrementing count in thread 2 to 1
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40

然后程序卡住,出现死锁.
预防死锁的方法:

  1. 避免一个进程同时占有多个资源.
  2. 避免进程之间形成环路依赖.
  3. 使用资源有序分配法,按资源编号顺序分配资源.
  4. 使用资源分级法,按资源重要级别分配资源.
  5. 设计进程可抢占,在发生死锁时收回部分资源.
  6. 不在同一个时间内分配所有资源给进程.
    所以,理解死锁产生的原因和条件,在实际编程中遵循预防死锁的方法,是编写高并发程序的关键.

5.2.6硬链接与软链接的区别;⭐⭐⭐⭐⭐

  1. 硬链接:
  • 实际文件,不占用磁盘空间.
  • 链接数增加,文件大小不变.
  • 删除一个硬链接文件,不影响其他链接和原文件.
  • 不能跨文件系统,硬链接文件与原文件必须在同一文件系统.
    软链接:
  • 特殊文件,占用磁盘空间.
  • 软链接文件大小可能很小.
  • 删除软链接文件,不影响原文件.
  • 可跨文件系统,软链接文件与原文件可以在不同文件系统.
  1. 指向目标:
    硬链接:每个硬链接指向原文件inode.
    软链接:软链接文件中记录目标文件路径名.
  2. 存取速度:
    硬链接:与普通文件相同,不需要路径解析,速度快.
    软链接:需要解析路径和查找目标文件,速度较慢.
    所以,硬链接与软链接都是文件系统中的链接方式,但机制不同.
    硬链接通过共享inode的方式实现文件链接,访问速度快但只能在同文件系统内.
    软链接通过路径记录目标文件位置,可跨文件系统但速度较慢.
    在实际应用中,根据需要选择对应的链接机制.硬链接用于文件备份或高速访问.软链接适用于跨文件系统的链接或临时测试.
    熟练掌握二者区别并灵活运用,是管理文件系统和构建高效程序的基本技能.

5.2.7虚拟内存,虚拟地址与物理地址的转换⭐⭐⭐⭐

虚拟内存:为每个进程设置一个逻辑地址空间,通过映射管理离散的物理内存,实现内存空间的虚拟化.
它带来的好处:

  1. 隔离进程,提高系统安全性.
  2. 扩展物理内存大小,通过交换技术实现虚拟内存超过物理内存.
  3. 利用局部性原理提高内存使用效率.
  4. 简化内存管理,通过映射机制隐藏物理内存碎片等细节.
    虚拟地址与物理地址的转换通过映射表(页表)实现.
  5. 首先将虚拟地址划分为页号和页内偏移.
  6. 查找页表找到对应的物理页框号.
  7. 结合物理页框号和页内偏移计算得到物理地址.
  8. 如果页表项无效,则产生缺页中断,操作系统从辅助存储装入对应页面.
  9. 重新启动指令,访问物理内存.

5.2.8计算机中,32bit与64bit有什么区别⭐⭐⭐

32bit与64bit的主要区别:

  1. 地址寻址空间:
    32bit:2^32 = 4G地址空间.
    64bit:2^64 = 16E地址空间.
    所以,64bit可以访问的内存空间更大,适合处理更大规模的数据.
  2. 寄存器宽度:
    32bit:通用寄存器和指令寄存器宽度为32bit.
    64bit:通用寄存器和指令寄存器宽度为64bit.
    64bit的寄存器宽度更大,可以存储更大的数据,执行更复杂的运算.
  3. 指令集:
    32bit的指令集不同于64bit,有的指令在64bit上不存在或功能不同.
    所以,需要针对32bit或64bit分别进行程序优化,最大发挥其性能.
  4. 速度:
    理论上64bit可以执行更复杂的操作,但指令数目更大,内存访问也需要更长时间.
    所以,32bit与64bit的速度根据具体程序和操作系统而定,没有绝对的快慢之分.
  5. 程序兼容性:
    32bit程序需要借助兼容层才能在64bit系统运行,性能会有所下降.
    64bit程序无法在32bit系统运行.
    所以,64bit系统具有更优异的内存寻址能力和运算性能,但程序兼容性较差.
    根据实际需要选择32bit或64bit系统,关键看内存需求和程序要运行的环境.
    32位与64位的区别实际上体现了计算机结构技术的持续进步,它带来更大的内存空间和更强的运算能力.
    但也导致了程序兼容性的问题,这需要相关技术不断跟进才能促进新老系统的协同发展.

5.2.9中断和异常的区别⭐⭐⭐⭐⭐

中断与异常的主要区别:

  1. 产生原因:
    中断:由外部设备发出,通知CPU有异步事件发生.
    异常:由CPU检测到错误条件,例如除零错误、缺页错误等.
  2. 处理方式:
    中断:CPU暂停当前程序,转而执行中断服务程序,然后恢复运行.
    异常:CPU暂停当前程序,转而执行异常处理程序,由其决定是否恢复运行.
  3. 恢复运行:
    中断:一般会恢复到中断前的指令并继续执行.
    异常:根据异常处理的结果,可能恢复运行,也可能终止.
  4. 产生时机:
    中断:由外部设备的状态变化触发,时机无法确定.
    异常:由CPU在执行指令过程中检测到错误条件,时机与执行的指令相关.
    所以,中断和异常都会使CPU暂停当前程序并转而执行另一程序,但产生原因和处理结果不同.
    中断由外部设备发出,通常会恢复运行,用于响应异步事件.
    异常由CPU内部检测错误产生,运行恢复与否取决于异常处理,用于纠正或终止错误程序状态.
    理解中断与异常的区别,正确设计中断服务程序和异常处理程序,是编写健壮程序和操作系统的基础.

5.2.10中断怎么发生,中断处理大概流程⭐⭐⭐⭐

  1. 中断发生:
    外部设备(键盘、鼠标等)产生状态变化,通过中断请求线通知CPU.
    CPU暂停当前指令,记录返回地址等信息.
  2. 查找中断向量表:
    中断向量表中记录每个中断或异常对应中断服务程序的入口地址.
    CPU根据中断请求号在表中查找对应的入口地址.
  3. 跳转执行中断服务程序:
    CPU跳转至中断服务程序的入口地址开始执行.
  4. 保存现场:
    中断服务程序首先保存CPU寄存器状态和断点等信息,以免丢失.
  5. 处理中断事件:
    中断服务程序执行与中断对应的处理逻辑. 比如读取新按键并存入缓冲区等.
  6. 恢复现场:
    中断处理完成后,恢复先前保存的CPU状态,使得中断前的程序可以继续运行.
  7. 返回并恢复运行:
    中断服务程序执行完毕后,通过IRET指令返回中断前的程序并继续执行.

5.2.11 Linux 操作系统挂起、休眠、关机相关命令⭐⭐

Linux下相关命令:
挂起(suspend):

  • 暂停系统当前状态,转入低功耗模式,可以快速恢复运行状态.
  • 命令:systemctl suspend
    休眠(hibernate):
  • 将系统当前状态保存到硬盘,然后完全关闭系统.
  • 下次开机可恢复至休眠前的状态.
  • 命令:systemctl hibernate
    关机(shutdown):
  • 完全关闭系统,需要重新启动系统.
  • 命令:
    shutdown -h now # 立即关机
    shutdown -h 1 # 1分钟后关机
    shutdown -r now # 立即重启
    reboot # 立即重启
    halt # 关闭系统,等同于shutdown -h now
    所以,Linux系统提供挂起、休眠和关机三种方式来暂停或关闭系统.
    挂起将系统当前状态保存到内存,可以很快恢复运行,常用于短时间离开.
    休眠将系统状态保存到硬盘,下次开机时恢复至休眠前状态,用于长时间离开.
    关机完全关闭系统,需要重新启动,用于系统维护或遇到严重问题.
    理解三种机制的区别,熟练运用相关命令,可以更好地管理Linux系统的运行.

5.2.12数据库为什么要建立索引,以及索引的缺点⭐⭐

数据库建立索引的好处:

  1. 提高查询速度.通过索引快速定位到记录,减少IO读取.
  2. 提高连接性能.在连接过程中有条件检索,通过索引过滤不符合条件的数据.
  3. 避免表锁.通过索引实现行级锁定,提高并发访问性能.
  4. 提高限制性检查.在插入或更新数据时通过索引快速检查唯一性或外键限制.
  5. 实现快速排序.通过索引实现较快的排序操作.
    但是,索引也有以下缺点:
  6. 索引本身也占用空间.过多索引会增加存储空间并降低DML操作速度.
  7. 索引维护成本高.插入、更新和删除数据时,索引也需要做相应的修改,会增加性能开销.
  8. 可能出现索引不准确.异常值或唯一值会造成索引失效,需定期维护或重建索引.
  9. 降低并发性.在DML操作时,索引也需要加锁以保证 Consistency,会对并发访问产生影响.
  10. 查询需考虑索引选择.过于依赖索引可能会选择非最优索引,需要根据SQL和索引定义选择最优索引.
    所以,数据库索引的建立需要综合考虑,权衡索引带来的好处和可能产生的代价.

第六章 单片机常见面试题

6.1 CPU 内存 虚拟内存 磁盘/硬盘 的关系⭐⭐⭐

  1. CPU与内存:
    CPU执行指令需要读取指令和数据,内存存储指令和数据供CPU读取.
    CPU通过总线与内存连接,遵循一定时序读写内存中的数据.
    内存容量较小但速度较快,用于存储CPU当前执行的程序和数据.
  2. 内存与磁盘:
    磁盘呈辅助存储设备,容量较大但速度较慢.
    内存中的数据在断电时会丢失,磁盘用来长期存储数据和程序.
    CPU需要的数据和指令会从磁盘加载到内存,供CPU读取执行.
  3. 虚拟内存与内存、磁盘:
    虚拟内存认为内存空间连续,但实际对应于不连续的物理内存和磁盘空间.
    它通过内存管理机制,将内存和磁盘抽象为连续的逻辑地址空间.
    程序可以像访问内存一样访问虚拟内存,但实际上部分数据会存放在磁盘.
    所以,CPU、内存、虚拟内存和磁盘之间存在着密切的关系:
    CPU访问内存和虚拟内存中的数据和指令,来执行任务.
    内存高速缓存虚拟内存中的部分数据,虚拟内存通过映射将逻辑地址转化为物理内存和磁盘地址.
    磁盘为内存和虚拟内存提供更大更持久的存储空间.
    这种关系构成了现代计算机的存储层次结构,通过空间换时间,实现高速运行和海量存储.
    理解各设备之间的联系与区别,掌握数据在不同层次间的流转规律,是编写系统应用和优化性能的基础.

6.2 CPU内部结构⭐⭐⭐⭐

CPU主要包含如下内部结构:

  1. 寄存器:
    用于暂存数据和指令,包括通用寄存器、程序计数器和标志寄存器等.
  2. 指令解码器:
    将机器指令解码为各个功能部件的控制信号,以执行指令.
  3. 算术与逻辑单元:
    执行计算和逻辑运算,是CPU的主要计算部件.
  4. 控制单元:
    协调各部件工作,生成控制总线和定时信号,实施程序的流程控制.
  5. Cache记忆体:
    高速缓存包含指令缓存和数据缓存,缓存内存中的数据和指令以提高读取速度.
  6. 总线:
    数据总线、地址总线和控制总线将CPU内部各部件及与外部设备连接起来,用于信息传输.
  7. 时钟发生器:
    产生控制CPU工作频率和步进的时钟信号.
    所以,CPU通过这些内部组成部件来执行指令和完成计算、逻辑运算与流程控制等工作.

6.3 ARM结构处理器简析 ⭐⭐

6.3 ARM结构处理器简析 :
ARM是一种基于RISC(精简指令集计算机)设计理念的处理器结构,主要特征如下:

  1. 指令高度精简,一般为32位,易于流水线执行.
  2. 寄存器较多,由16个32位通用寄存器和32个32位专用寄存器组成.
  3. 采用精致的流水线结构,提高指令执行效率.ARM7TDMI最多5级流水线.
  4. 指令并不直接支持多任务和虚拟内存管理,这些工作由操作系统软件来完成.
  5. 实地址模式和保护模式可以切换,可运行不同的操作系统.
  6. 支持条件执行指令,可以提高程序执行效率.
  7. 采用 Von Neumann 架构,程序和数据存放在相同的存储器空间.
  8. 支持各种数据类型:32位字,16位半字,8位字节和2的幂次方的浮点型数据.
  9. 融合了精确中断和快速中断两种中断处理机制.
  10. 提供丰富的低功耗模式,适用于电池供电的便携设备.

6.4波特率是什么,为什么双方波特率要相同,高低波特率有什么区别;⭐⭐⭐⭐

波特率:也称波特频率,是通信设备连接的信号输入或者输出的频率,通常以位/秒为单位.
为什么通信双方波特率要相同:
通信双方使用相同的波特率保证数据接收和发送的同步,如果波特率不同则无法正确解码和识别对方发送的数据.
通信发送端按照一定波特率发送数据,接收端正是根据预先约定的波特率来对数据流进行采样和解码的.
如果波特率不一致,那么接收端对数据流的采样就不是按照正确的时序进行,最终导致解码出错和通信失败.
高低波特率的区别:
高波特率:

  1. 通信速度快,单位时间内可以发送和接收更多数据.
  2. 对系统时钟和硬件要求更高,通信设备成本较高.
  3. 数据容易受到干扰,传输距离较短.
    低波特率:
  4. 通信速度慢,系统要求低,设备成本较低.
  5. 抗干扰能力强,适合长距离传输.
  6. 时延较大,不适合对实时性要求高的场合.
    所以,波特率是衡量通信速度的关键参数,但通信可靠性和系统成本也与其相关.
    选择合适的波特率需要综合考虑,通常根据通信距离、传输环境和设备性能等因素来确定.
    高波特率下,通信速度快但系统要求高,主要用于近距离和要求实时的通信.
    低波特率则相反,用于长距离或非实时通信,或者系统要求较低的场合.
    掌握波特率的概念及其与通信可靠性、速度和成本的关系,有助于评估和选择通信方案.
    这是学习通信原理和开发通信设备的基础知识.

6.5arm和dsp有什么区别⭐⭐

  1. 设计目的不同:
    arm:旨在实现一般计算和控制功能,用于各种嵌入式系统和移动设备.
    dsp:专注于数字信号处理,用于通信、声音、图像等信号采集和处理.
  2. 指令集不同:
    arm:精简指令集,通用寄存器较多,较适合一般运算和控制.
    dsp:针对信号处理优化的指令集,如SIMD指令,较适合大量数据并行运算.
  3. 架构不同:
    arm:Von Neumann 架构,程序存储与数据共享内存空间.
    dsp:改良型哈佛架构,程序存储与数据存储分开,有专门的程序存储器和数据存储器.
  4. 流水线不同:
    arm:较短流水线用于通用运算,时序灵活.
    dsp:较长专用流水线,可大幅提高复杂DSP算法的执行效率.
  5. 外设不同:
    arm:外设较完备,可运行操作系统和各种应用.
    dsp:外设简单,主要用于DSP算法加速,需要与arm等通用处理器配合使用.
    所以,arm和dsp虽然都是常见的嵌入式处理器,但由于设计目标和用途不同,在结构、指令集、流水线和外设等方面均有较大差异.
    arm作为一种通用MCU,可运行各类应用软件,支持各种外设,主要用于控制和移动应用.
    dsp则专注于DSP算法加速,作为辅助处理器为arm等MCU协助信号处理.
    理解二者的区别与联系,有助于在系统设计中恰当地选择arm或是dsp,或二者结合,来实现不同的功能需求.
    这是研究嵌入式系统设计和DSP技术的重要基础知识.

6.6 ROM RAM的概念浅析⭐⭐⭐

ROM和RAM是计算机存储器的两种主要类型,其主要概念如下:
ROM(只读存储器):

  1. 只读,无法更改和擦除存储的内容.存储的数据在制造过程中写入,不可更改.
  2. 存储内容不丢失.即使停电,存储内容也不会丢失.
  3. 访问速度较快.基本等同于RAM的访问速度.
  4. 较小容量.通常在KB至MB级别.
  5. 制程成本较高但单位存储空间成本较低.
    ROM由于其只读不可擦除的特点,主要用于存放系统启动程序和一些固定的程序与数据.
    RAM(随机存储器):
  6. 读写双用,可自由更改和擦除存储内容.是CPU运行程序和存储变量的主要空间.
  7. 存储内容易失.断电后存储内容会消失.需要使用ROM等非易失性存储备份.
  8. 访问速度快.可以达到与CPU运算速度相匹配的速度.
  9. 较大容量.可达GB甚至TB级别.
  10. 制程成本较低但单位存储空间成本较高.
    RAM由于其快速读写的特性,是CPU运行程序和存储临时变量的重要组成部分.但存储容量和成本远高于ROM.
    所以,ROM和RAM作为计算机的两种基本存储器,分别具有不同的特点:
    ROM是非易失性的只读存储器,用于存放不变的系统程序和数据.
    RAM是易失性的读写存储器,用于存储运行中的程序和变量数据.

6.7 IO口工作方式:上拉输入 下拉输入 推挽输出 开漏输出⭐⭐⭐⭐

IO口的四种工作方式:

  1. 上拉输入:
    IO口无输出,外接上拉电阻与高电平连接.
    当输入信号为低电平时,IO口读入0.当输入浮空或连接高电平时,上拉电阻将IO口拉高,此时IO口读入1.
  2. 下拉输入:
    IO口无输出,外接下拉电阻与低电平连接.
    当输入信号为高电平时,IO口读入1.当输入浮空或连接低电平时,下拉电阻将IO口拉低,此时IO口读入0.
  3. 推挽输出:
    IO口可输出高低两种电平.当输出高电平时,IO口输出高电压;当输出低电平时,IO口输出低电压.
    推挽输出可直接驱动较低阻抗负载,输出电流较大,但功耗也较大.
  4. 开漏输出:
    IO口只有低电平输出,当输出高电平时,IO口以高阻态浮空.
    需要外接上拉电阻与高电平连接,才能得到完整的高低电平输出.
    开漏输出的优点是可以以简单的方式实现有线或无线的“或”连接和总线结构,输出功耗较小.
    所以,IO口的工作方式主要有上拉输入、下拉输入、推挽输出和开漏输出4种:
    上拉和下拉输入用于输入信号采集,通过外部上拉或下拉电阻实现信号放大与反相.
    推挽输出可以产生完整的高低电平,直接驱动一般负载,功耗较大.
    开漏输出适用于总线应用,可简单实现逻辑“或”运算,功耗较小.

6.8扇区 块 页 簇的概念⭐⭐⭐⭐

扇区(Sector):
是磁盘的最小物理存储单位,大小通常为512字节.
硬盘通过磁头在盘片表面读写数据,一个扇区对应盘片上连续的弧段.
块(Block):
是文件系统对磁盘扇区的逻辑管理单位,包含1个或多个扇区.
文件系统通过块来对磁盘扇区进行抽象和管理,用户操作文件的最小单位即为块.
块的大小在设计文件系统时确定,如4KB或8KB,须是扇区大小的整数倍.
页(Page):
是操作系统实现虚拟内存时的管理单位.由连续的块组成,大小为4KB或更大.
页实现了逻辑地址到物理地址的映射,使应用程序可以像访问连续内存一样访问磁盘.
簇(Cluster):
是文件系统将若干个连续块组织在一起进行文件存储的单位.
文件在磁盘上存放时,会被分配若干簇用来存放用户数据.
簇的大小在设计文件系统时确定,通常为4KB至64KB或更大.
为提高文件的读写效率而设置,文件的最小读写单位即为一个簇.
所以,这几个概念的层次关系为:
扇区→块→页→簇
扇区是磁盘的物理存储单元.
块是文件系统管理扇区的基本单元.
页是操作系统实现虚拟内存时与块对应的逻辑单元.
簇是文件系统将几个连续块组织在一起存放文件的最小单元.


6.9简述处理器在读内存的过程中,CPU核、cache、MMU如何协同工作?画出CPU核、cache、MMU、内存之间的关系示意图加以说明⭐⭐

  1. CPU核发出读内存请求,首先会查询一级指令缓存,如果命中,则直接从缓存读取指令;如果不命中,则查二级缓存.
  2. 二级缓存也未命中,则CPU核将逻辑地址发送到MMU进行地址翻译.MMU根据页表翻译为物理地址.
  3. MMU返回的物理地址发送到存储器控制器,搜索相应的存储模块并调用数据.
  4. 存储器控制器从存储模块读取数据后,会更新二级缓存和一级指令缓存.
  5. CPU核从一级指令缓存中获取指令并执行.
    它们之间的关系示意图如下:
        +-----------+       +---------+  
逻辑地址|    CPU核   |-------|   MMU   | 
        +-----------+       +---------+  
                    |                |  
                    |       +--------+--------+
                    +-------|   存储器控制器 |  
                            +--------+--------+  
                                     |       |      
                    +--------------+       +--------------+
物理地址         |       |                      |       |
+-----------+   |    +--+--+                 +--+--+    |  
|   二级缓存 |   |    |存储器|                 |存储器|    |
+-----------+   |    +--+--+                 +--+--+    |
+-----------+   |                      +--+--+        |  
|   一级缓存 |   |                      |存储器|        |  
+-----------+   |                      +--+--+        |
                 |                                     |  
                 |                                     |

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

所以,在读内存过程中,CPU核、缓存、MMU和存储器控制器密切配合:
CPU核发出读请求,首先查询缓存,不命中则送MMU进行地址翻译.
MMU将逻辑地址翻译为物理地址,发送到存储器控制器.
存储器控制器从存储模块读取数据,并更新缓存.
CPU核从一级缓存获取数据并执行.
这种协同机制实现了存储器层次化,使CPU可以高速地执行指令和访问大容量存储.


6.10请说明总线接口USRT、I2C、USB的异同点(串/并、速度、全/半双工、总线拓扑等)⭐⭐⭐⭐⭐

USART(Universal Asynchronous Receiver/Transmitter):

  1. 异步串行接口,采用串口通信.
  2. 通信速度较低,一般115200bps或以下.全/半双工可选.
  3. 通常只连接两设备,点对点连接.
    I2C(Inter-Integrated Circuit):
  4. 同步串行接口,通信速度较快,可达400kbps.
  5. 双向全双工通信,可连接多主机和多从机.
  6. 总线结构,所有设备共享总线.
    USB(Universal Serial Bus):
  7. 同步串行接口,高速通信,USB2.0可达480Mbps.全双工.
  8. 主从结构,一主机连接多个从机设备.
  9. 总线拓扑,通过中继器可扩展连接设备数量.
    所以,这三种总线接口的主要异同点为:
  10. USART为异步接口,I2C和USB为同步接口.USART速度较慢.
  11. USART一般为点对点连接,I2C和USB为总线结构,可连接多个设备.
  12. USART通常为全/半双工,I2C和USB为全双工.
  13. USART和I2C为串行接口,USB也为串行接口但速度较快.
  14. I2C为并行总线,USART和USB未对总线并行度作规定.

6.11什么是异步串口和同步串口⭐⭐⭐⭐⭐

异步串口:

  1. 不以时钟信号为基准进行传输,仅依靠起始位和停止位确定每帧数据的开始和结束.
  2. 传输速度慢,一般不超过115200bps.
  3. 通信双方的baud率必须匹配,否则无法正确解码数据.
    同步串口:
  4. 以绑定的时钟信号为基准进行传输,起始位和停止位可选.
  5. 传输速度快,可达Mbps级别.
  6. 时钟信号由硬件自动产生和同步,通信双方无需担心baud率匹配问题.
    所以,异步串口依靠起始位和停止位达到同步,速度慢且双方baud率需匹配.
    同步串口利用专用时钟信号实现同步,速度快且无需考虑双方baud率设置.

6.12 FreeRTOS同优先级的任务创建的执行顺序是什么?

在FreeRTOS中,会把新创建的任务以头插法的形式放在同优先级的就绪链表中,若优先级相同,后创建的任务会新得到执行.


题目转载于链接:https://leetcode.cn/circle/discuss/u3CZOa/
来源:力扣(LeetCode)
答案由我自己查询提供,可能会有错误,仅供参考.

第二部分 个人面经汇总(个性)

  • STM32的PWM波是如何计算的?

定时器频率/定时器的重转载值= 系统时钟频率/定时器的预分频系数/定时器的重装载值

  • FreeRTOS和RT-Thread有什么区别?

FreeRTOS的内核比RTT的要小很多.FREERTOS是一个精简的内核,而RTT不仅是一个实时内核,还具备丰富的软件包(如MQTT软件包),FreeRTOS的实时性会更高一些,因为它最小上下文具体切换只需要3个计数周期,因为在最理想的情况下,FreeRTOS 的任务切换只需要 3 条机器指令就可以完成:

1. PUSHSR     ; 保存当前任务的状态
2. MOV  SP,新任务SP ; 切换栈指针到新任务
3. POPSR      ; 恢复新任务的状态
  • 1
  • 2
  • 3

这 3 条指令分别用于:保存当前任务现场、切换任务栈、恢复新任务现场,所以理论上 FreeRTOS 的最快上下文切换时间就是这 3 条指令的执行时间,大约相当于 3 个机器周期,这就是 “3 个计数器” 的来历。
相比而言,RT-Thread 的上下文切换会更加复杂一些,除了栈指针和程序状态寄存器之外,还需要保存更多的 CPU 寄存器状态,并在切换后恢复,所以上下文切换时间长一些,一般在 10 个周期左右。
所以,当 FreeRTOS 说其 “最小上下文切换时间有 3 个计数器” 时,其实就是说在最理想的条件下,其任务切换最快可以在 3 个机器周期内完成,这主要得益于其精简的设计思想,切换时只需要保存和恢复最必要的任务现场,所以上下文切换开销很小。而 RT-Thread 由于需要支持更丰富的功能,其任务现场要保存和恢复的内容更多,所以上下文切换开销也更大,时间长一些。

  • 项目让你最难受的地方,分析思路和解决思路?

  • 串口中断中数据是怎么处理的?
    保存到数组或环形队列里.在STM32中的串口中断回调函数中处理.

  • 串口数据接收,如果一个较大的数据包发送过来(1K字节以上,带帧头 帧长和校验码)你怎么解析和处理?
    考虑buffer接收长度,对方发送的数据有异常的处理,接收超时机制

标准答案:
1. 定义包头和包长度字段
需要在数据包中定义包头和包长度两个字段。包头用于标识新包的开始,包长度用以指示整个数据包的长度。这两个字段的定义规则需要发送方和接收方事先确定。
1. 读取包头和包长度 
当接收到新数据时,首先读取包头判断是否为新包开始。如果是,则继续读取包长度字段,得到整个数据包的长度信息。
1. 申请包长度所需的缓冲空间 
根据获得的包长度信息,需要申请足够的缓冲空间存放整个数据包的内容。如果长度超过默认缓冲区长度,需要动态申请空间。
1. 循环读取数据至缓冲区填满 
将串口接收到的数据循环读取至步骤3申请的缓冲区,直到填满为止。这个过程可能伴随数据拷贝,需要考虑接收效率。
1. 校验数据包 
接收到完整数据包后,需要对数据包进行校验,验证包长度、CRC校验码以及其他关键字段是否正确。只有校验通过的包才会被进一步处理。 
1. 处理数据包内容  
对校验通过的数据包,进行解析并处理其内容。完成后继续等待下一个数据包的接收与解析。
1. 错误处理 
在数据包接收与校验的过程中,可能出现超时、长度错误、CRC校验失败等问题。需要在代码中加入相应的错误处理逻辑,避免程序崩溃。
所以,对较长数据包的串口接收,关键在于: 
1. 定义包头和包长度,获取包长信息;
2.  提前申请足够缓冲区空间,避免缓冲区溢出;
3.循环读取至缓冲区填满,考虑接收效率; 
4. 严格校验包内容,过滤错误包;
5. 增加超时和错误处理机制,提高健壮性。
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • IIC协议解释一下?

  • SPI协议解释一下?

  • CAN协议解释一下?

  • 串口UART与RS232,RS485的区别

  • FreeRTOS的任务是如何进行调度的?

首先判断是否配置了可以抢占,若配置了可以抢占则每次从优先级链表中去查找,如果高优先级链表中的任务就绪则得到执行.

  • FreeRTOS中什么时候发生任务调度?

tick中断(这个在free rt os中默认是以systick为滴答时基).
主动调用taskYIELD()进行任务切换。
硬件中断,这个有很多种,比如在串口中断回调函数中,或者是其他的中断回调函数中唤醒了某个高线程,则在中断结束后立刻发生调度,无需等待tick中断的调度。

  • 在Free RTOS中若是配置为非礼让+非抢占,则当前任务会一直得到执行,为什么?

在FreeRTOS中,如果通过宏配置将任务调度策略设置为非抢占(PREEMPT_OFF)且非礼让(INTERRUPT_OFF),则当前执行的任务将一直运行,其他任务无法被调度执行。
这是因为:

  1. 非抢占模式下,只有在任务主动放弃CPU(通过taskYIELD()函数调用)时,才会进行任务切换。如果任务一直运行且不主动放弃CPU,则其他任务无法执行。
  2. 非礼让模式下,禁止所有中断发生。这使得RTOS无法在定时中断中进行任务切换,时间片功能也无法使用。所以当前任务将持续运行,其他任务无法被调度。
  3. 同优先级任务在非抢占非礼让模式下也无法切换,因为时间片轮转功能被禁止,任务只有主动放弃CPU才可以切换。
  4. 处在非抢占模式下的任务可以调用taskYIELD()主动放弃CPU,但非礼让模式下该调用无效,任务仍然不会切换。
    所以,在非抢占非礼让的配置下,由于时间片功能与中断被禁止,任务切换机制被完全禁止。当前任务可以毫无限制地持续运行,CPU资源无法被其他任务获得。这种模式下任务的实时性是最差的,但可以获得最高的执行效率。
    一般来说,非抢占非礼让模式很少使用,除非对CPU利用率和执行效率有极高要求的场合。更多时候,我们会选择:
- 抢占模式:可以根据优先级实现任务自动切换,获得较好的实时性;
- 礼让模式:通过开启中断,可以使用时间片来共享CPU,达到较好的任务切换效果;
- 抢占+礼让:兼顾实时性与效率,是FreeRTOS中最常用的配置模式。
  • 1
  • 2
  • 3
  • 冒泡排序的思路是什么? 解释一下时间复杂度的计算? 为什么是O(N^2)

  • 裸机开发的怎么实现一个软件定时器? 如何定时处理100个任务?

  • IO口有哪些模式? 推挽输出和开漏输出的区别是什么?

  • IIC的读时许解释一下?(这个和上面的IIC时许相似,只不过问得更具体)

  • 链表有二分查找吗? 一般什么情况下用二分查找?

  • DFS,BFS算法解释一下.(深搜和宽搜,像具体得算法还算遇到的比较少了)

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家自动化/article/detail/627175
推荐阅读
相关标签
  

闽ICP备14008679号