嵌入式工程师( C / C++ )笔试面试题汇总

发布于:2025-04-22 ⋅ 阅读:(20) ⋅ 点赞:(0)

注:本文为 “嵌入式工程师笔试面试题” 相关文章合辑。

未整理去重。

如有内容异常,请看原文。


嵌入式必会 C 语言笔试题汇总

Z 沉浮 嵌入式之旅 2021 年 01 月 19 日 00:00

  1. 用预处理指令 #define 声明一个常数,用以表明 1 年中有多少秒(忽略闰年问题)

    #define SECONDS_PER_YEAR (60 * 60 * 24 * 365) UL
    

    期望看到以下几点:

    1. 掌握 #define 语法的基本知识(例如:不能以分号结束,括号的使用等)。
    2. 明白预处理器会计算常数表达式的值,直接写出计算表达式比直接写出实际值更清晰且无代价。
    3. 意识到该表达式会使 16 位机的整型数溢出,因此需要用长整型符号 L,告诉编译器这是一个长整型数。
    4. 如果使用 UL(表示无符号长整型),说明有较好的起点。
  2. 写一个 “标准” 宏 MIN,输入两个参数并返回较小的一个。

    #define MIN(A, B) ((A) <= (B) ? (A) : (B))
    

    测试目的:

    1. 掌握 #define 在宏中应用的基本知识。
    2. 理解三重条件操作符,它使编译器能产生比 if-then-else 更优化的代码。
    3. 懂得在宏中将参数用括号括起来。
    4. 讨论宏的副作用,例如:Least = MIN(*p++, b); 会发生什么?
  3. 预处理器标识 #error 的目的是什么?

    正确答案:编译程序时,只要遇到 #error 就会跳出一个编译错误。其目的是保证程序是按照你所设想的那样进行编译的。

  4. 嵌入式系统中经常要用到无限循环,你怎么样用 C 编写死循环呢?

    首选方案:

    while (1) { }
    

    其他方案:

    for (;;) { }
    

    或者使用 goto

    Loop: ... goto Loop;
    

    如果给出 for (;;) 作为方案,需探究其基本原理;如果给出 goto 方案,可能说明是汇编语言程序员或想进入新领域的 BASIC/FORTRAN 程序员。

  5. 用变量 a 给出以下定义:

    a) 一个整型数(An integer)

    b) 一个指向整型数的指针(A pointer to an integer)

    c) 一个指向指针的指针,它指向的指针是指向一个整型数(A pointer to a pointer to an integer)

    d) 一个有 10 个整型数的数组(An array of 10 integers)

    e) 一个有 10 个指针的数组,该指针是指向一个整型数的(An array of 10 pointers to integers)

    f) 一个指向有 10 个整型数数组的指针(A pointer to an array of 10 integers)

    g) 一个指向函数的指针,该函数有一个整型参数并返回一个整型数(A pointer to a function that takes an integer as an argument and returns an integer)

    h) 一个有 10 个指针的数组,该指针指向一个函数,该函数有一个整型参数并返回一个整型数(An array of ten pointers to functions that take an integer argument and return an integer)

    答案:

    a) int a; // An integer
    b) int *a; // A pointer to an integer
    c) int **a; // A pointer to a pointer to an integer
    d) int a[10]; // An array of 10 integers
    e) int *a[10]; // An array of 10 pointers to integers
    f) int (*a)[10]; // A pointer to an array of 10 integers
    g) int (*a)(int); // A pointer to a function that takes an integer argument and returns an integer
    h) int (*a[10])(int); // An array of 10 pointers to functions that take an integer argument and return an integer
    
  6. 关键字 static 的作用是什么?

    在 C 语言中,关键字 static 有三个明显的作用:

    1. 在函数体中,一个被声明为静态的变量在这一函数被调用过程中维持其值不变。
    2. 在模块内(但在函数体外),一个被声明为静态的变量可以被模块内所有函数访问,但不能被模块外其他函数访问。它是一个本地的全局变量。
    3. 在模块内,一个被声明为静态的函数只可被这一模块内的其他函数调用。也就是说,这个函数被限制在声明它的模块的本地范围内使用。
  7. 关键字 const 是什么含义?

    const 表示“只读”。如果应试者能正确回答此问题,可进一步询问以下声明的含义:

    const int a;
    int const a;
    const int *a;
    int *const a;
    int const *a const;
    

    解释:

    • 前两个的作用相同,a 是一个常整型数。
    • 第三个表示 a 是一个指向常整型数的指针(整型数不可修改,但指针可以)。
    • 第四个表示 a 是一个指向整型数的常指针(指针指向的整型数可以修改,但指针不可修改)。
    • 最后一个表示 a 是一个指向常整型数的常指针(指针指向的整型数和指针本身均不可修改)。

    使用 const 的理由:

    1. 为读代码的人传达有用信息,声明参数为常量可明确其用途。
    2. 给优化器提供附加信息,可能产生更紧凑的代码。
    3. 保护不希望被修改的参数,防止无意的代码修改,减少错误。
  8. 关键字 volatile 有什么含义?并给出三个不同的例子。

    定义为 volatile 的变量可能会被意想不到地改变,编译器不会假设该变量的值。优化器在用到该变量时必须每次都重新读取其值,而不是使用寄存器中的备份。

    例子:

    1. 并行设备的硬件寄存器(如状态寄存器)。
    2. 中断服务子程序中会访问的非自动变量。
    3. 多线程应用中被多个任务共享的变量。

    如果应试者能正确回答,可进一步询问以下问题:

    1. 一个参数是否可以同时是 constvolatile?解释原因。

    2. 一个指针是否可以是 volatile?解释原因。

    3. 下面的函数有什么错误:

      int square(volatile int *ptr) { return ptr * ptr; }
      

      答案:

      1. 是的。例如只读的状态寄存器,它是 volatile 因为可能被改变,同时是 const 因为程序不应修改它。

      2. 是的。例如中断服务子程序修改指向缓冲区的指针。

      3. 该代码的错误在于 ptr * ptr 的计算。由于 ptr 指向的是 volatile 类型的变量,编译器会生成类似以下代码:

        int square(volatile int *ptr) {
            int a, b;
            a = *ptr;
            b = *ptr;
            return a * b;
        }
        

        由于 *ptr 的值可能被改变,ab 可能不同,导致结果不是预期的平方值。正确的代码如下:

        long square(volatile int *ptr) {
            int a;
            a = *ptr;
            return a * a;
        }
        
  9. 嵌入式系统总是要用户对变量或寄存器进行位操作。给定一个整型变量 a,写两段代码,第一个设置 a 的 bit 3,第二个清除 a 的 bit 3。在以上两个操作中,要保持其他位不变。

    反应类型:

    1. 不知道如何下手,说明没有嵌入式系统的工作经验。

    2. 使用 bit fields,这种方法不可移植且不可重用。

    3. 使用 #defines 和 bit masks 操作,这是高可移植性的方法。最佳解决方案如下:

      #define BIT3 (0x1 << 3)
      static int a;
      void set_bit3(void) { a |= BIT3; }
      void clear_bit3(void) { a &= ~BIT3; }
      
  10. 嵌入式系统经常具有要求程序员去访问某特定的内存位置的特点。在某工程中,要求设置一绝对地址为 0x67a9 的整型变量的值为 0xaa55。编译器是一个纯粹的 ANSI 编译器。写代码去完成这一任务。

    实现方式:

    int *ptr;
    ptr = (int *)0x67a9;
    *ptr = 0xaa55;
    

    或者更晦涩的方法:

    *(int *const)(0x67a9) = 0xaa55;
    

    建议在面试中使用第一种方案。

  11. 中断是嵌入式系统中重要的组成部分,这导致了很多编译开发商提供一种扩展 — 让标准 C 支持中断。具代表事实是,产生了一个新的关键字 __interrupt。下面的代码就使用了 __interrupt 关键字去定义了一个中断服务子程序 (ISR),请评论一下这段代码的。

    __interrupt double compute_area(double radius) {
        double area = PI * radius * radius;
        printf("Area = %f", area);
        return area;
    }
    

    该函数存在以下错误:

    1. ISR 不能返回一个值。
    2. ISR 不能传递参数。
    3. 在许多处理器/编译器中,浮点运算一般是不可重入的。有些处理器/编译器需要额外的寄存器入栈,有些则不允许在 ISR 中做浮点运算。此外,ISR 应该短而高效,浮点运算不适宜。
    4. printf() 通常存在重入和性能问题。ISR 不应调用 printf()
  12. 下面的代码输出是什么,为什么?

    void foo(void) {
        unsigned int a = 6;
        int b = -20;
        (a + b > 6) ? puts("> 6") : puts("<= 6");
    }
    

    输出是 "> 6"。原因是当表达式中存在有符号类型和无符号类型时,所有操作数都自动转换为无符号类型。因此,-20 转换为一个很大的正整数,导致表达式结果大于 6。

  13. 评价下面的代码片断:

    unsigned int zero = 0;
    unsigned int compzero = 0xFFFF; /* 1's complement of zero */
    

    对于一个 int 型不是 16 位的处理器来说,上面的代码是不正确的。应编写如下:

    unsigned int compzero = ~0;
    

    该问题能揭露出应试者是否懂得处理器字长的重要性。

  14. 尽管不像非嵌入式计算机那么常见,嵌入式系统还是有从堆(heap)中动态分配内存的过程的。那么嵌入式系统中,动态分配内存可能发生的问题是什么?

    期望应试者能提到内存碎片、碎片收集问题、变量的生命周期等。可进一步讨论以下代码片段的输出:

    Char *ptr;
    if ((ptr = (char *)malloc(0)) == NULL)
        puts("Got a null pointer");
    else
        puts("Got a valid pointer");
    

    输出是 "Got a valid pointer"。可讨论库例程这样做的合理性。

  15. typedef 在 C 语言中频繁用于声明一个已经存在的数据类型的同义字。也可以用预处理器做类似的事。例如,思考以下例子:

    #define dPS struct s *
    typedef struct s *tPS;
    

    以上两种情况的意图都是要定义 dPStPS 作为一个指向结构 s 的指针。哪种方法更好呢?(如果有的话)为什么?

    答案是 typedef 更好。例如:

    dPS p1, p2;
    tPS p3, p4;
    

    第一个扩展为:

    struct s *p1;
    struct s p2; // 错误,p2 是一个实际的结构,而不是指针
    

    第二个正确地定义了 p3p4 为两个指针。

  16. C 语言允许一些令人震惊的结构,下面的结构是合法的吗,如果是它做些什么?

    int a = 5, b = 7, c;
    c = a+++b;
    

    该代码是合法的。根据最右结合原则,编译器将其处理为:

    c = a++ + b;
    

    执行后,a = 6b = 7c = 12。该问题可引发关于代码编写风格、可读性和可维护性的讨论。


嵌入式笔试面试题目系列(汇总)

原创 Jasonangel 嵌入式 Linux 系统开发 2021 年 01 月 19 日 08:17

本系列按类别对题目进行分类整理,有利于对嵌入式的笔试面试考察框架有一个完整的理解。

一、进程与线程

  1. 什么是进程、线程,有什么区别?

    进程是资源(CPU、内存等)分配的基本单位,线程是 CPU 调度和分配的基本单位(程序执行的最小单位)。同一时间,如果 CPU 是单核,只有一个进程在执行,所谓的并发执行,也是顺序执行,只不过由于切换速度太快,你以为这些进程在同步执行而已。多核 CPU 可以同一时间点有多个进程在执行。

  2. 多进程、多线程的优缺点

    一个进程由进程控制块、数据段、代码段组成,进程本身不可以运行程序,而是像一个容器一样,先创建出一个主线程,分配给主线程一定的系统资源,这时候就可以在主线程开始实现各种功能。当我们需要实现更复杂的功能时,可以在主线程里创建多个子线程,多个线程在同一个进程里,利用这个进程所拥有的系统资源合作完成某些功能。

    优缺点:

    1. 一个进程死了不影响其他进程,一个线程崩溃很可能影响到它本身所处的整个进程。
    2. 创建多进程的系统花销大于创建多线程。
    3. 多进程通讯因为需要跨越进程边界,不适合大量数据的传送,适合小数据或者密集数据的传送。多线程无需跨越进程边界,适合各线程间大量数据的传送。并且多线程可以共享同一进程里的共享内存和变量。
  3. 什么时候用进程,什么时候用线程

    1. 创建和销毁较频繁使用线程,因为创建进程花销大。
    2. 需要大量数据传送使用线程,因为多线程切换速度快,不需要跨越进程边界。
    3. 安全稳定选进程;快速频繁选线程。
  4. 多进程、多线程同步(通讯)的方法

    进程间通讯:

    • 有名管道 / 无名管道
    • 信号
    • 共享内存
    • 消息队列
    • 信号量
    • socket

    线程通讯(锁):

    • 信号量
    • 读写锁
    • 条件变量
    • 互斥锁
    • 自旋锁
  5. 进程线程的状态转换图

    • 就绪状态:进程已获得除 CPU 外的所有必要资源,只等待 CPU 时的状态。一个系统会将多个处于就绪状态的进程排成一个就绪队列。
    • 执行状态:进程已获 CPU,正在执行。单处理机系统中,处于执行状态的进程只有一个;多处理机系统中,有多个处于执行状态的进程。
    • 阻塞状态:正在执行的进程由于某种原因而暂时无法继续执行,便放弃处理机而处于暂停状态,即进程执行受阻。(这种状态又称等待状态或封锁状态)

    典型事件包括:请求 I/O,申请缓冲空间等。通常,将处于阻塞状态的进程排成一个队列,有的系统还根据阻塞原因不同把这些阻塞集成排成多个队列。

    图片

    状态转换:

    • 就绪 → 执行:处于就绪状态的进程,当进程调度程序为之分配了处理机后,该进程便由就绪状态转变成执行状态。
    • 执行 → 就绪:处于执行状态的进程在其执行过程中,因分配给它的一个时间片已用完而不得不让出处理机,于是进程从执行状态转变成就绪状态。
    • 执行 → 阻塞:正在执行的进程因等待某种事件发生而无法继续执行时,便从执行状态变成阻塞状态。
    • 阻塞 → 就绪:处于阻塞状态的进程,若其等待的事件已经发生,于是进程由阻塞状态转变为就绪状态。
  6. 父进程、子进程

    父进程调用 fork() 以后,克隆出一个子进程,子进程和父进程拥有相同内容的代码段、数据段和用户堆栈。父进程和子进程谁先执行不一定,看 CPU。一般会设置父进程等待子进程执行完毕。

  7. 说明什么是上下文切换?

    • 进程上下文:一个进程在执行的时候,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
    • 中断上下文:由于触发信号,导致 CPU 中断当前进程,转而去执行另外的程序。那么当前进程的所有资源要保存,比如堆栈和指针。保存过后转而去执行中断处理程序,快速执行完毕返回,返回后恢复上一个进程的资源,继续执行。这就是中断的上下文。

二、C/C++ 题目

  1. newmalloc

    做嵌入式时,内存资源有限,因此内存相关题目是高频考点。

    • mallocfree 是 C/C++ 的库函数,需要头文件支持 <stdlib.h>newdelete 是 C++ 的关键字,不需要头文件,需要编译器支持。
    • 使用 new 操作符申请内存分配时,无需指定内存块的大小,编译器会根据类型信息自行计算。而 malloc 则需要显式地支持所需内存的大小。
    • new 操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无需进行类型转换,故 new 是符合类型安全性的操作符。而 malloc 内存分配成功则是返回 void*,需要通过强制类型转换将 void* 指针转换成我们需要的类型。
    • new 内存分配失败时,会抛出 bad_alloc 异常。malloc 分配内存失败时返回 NULL
  2. 在 1G 内存的计算机中能否 malloc(1.2G)?为什么?(2021 浙江大华二面问题)

    答:是有可能申请 1.2G 的内存的。

    解析:malloc 的作用是向程序的虚拟空间申请一块虚拟地址空间,与物理内存没有直接关系,得到的是在虚拟地址空间中的地址,之后程序运行所提供的物理内存是由操作系统完成的。

  3. extern "C" 的作用

    我们可以在 C++ 中使用 C 的已编译好的函数模块,这时候就需要用到 extern "C"extern "C" 都是在 C++ 文件里添加的。

    extern 在链接阶段起作用(四大阶段:预处理 – 编译 – 汇编 – 链接)。

  4. strcatstrncatstrcmpstrcpy 哪些函数会导致内存溢出?如何改进?(2021 浙江大华二面问题)

    strcpy 函数会导致内存溢出。

    • strcpy 拷贝函数不安全,它不做任何的检查措施,也不判断拷贝大小,不判断目的地址内存是否够用。

      char *strcpy(char *strDest, const char *strSrc)
      
    • strncpy 拷贝函数,虽然计算了复制的大小,但是也不安全,没有检查目标的边界。

      strncpy(dest, src, sizeof(dest));
      
    • strncpy_s 是安全的。

    • strcmp(str1, str2),是比较函数,若 str1 = str2,则返回零;若 str1 < str2,则返回负数;若 str1 > str2,则返回正数。(比较字符串)

    • strncat() 主要功能是在字符串的结尾追加 n 个字符。

      char *strncat(char *dest, const char *src, size_t n);
      
    • strcat() 函数主要用来将两个 char 类型连接。例如:

      char d[20] = "Golden";
      char s[20] = "View";
      strcat(d, s);
      // 打印 d
      printf("%s", d);
      

      输出 dGoldenView(中间无空格)。

    延伸:

    • memcpy 拷贝函数,它与 strcpy 的区别就是 memcpy 可以拷贝任意类型的数据,strcpy 只能拷贝字符串类型。memcpy 函数用于把资源内存(src 所指向的内存区域)拷贝到目标内存(dest 所指向的内存区域);有一个 size 变量控制拷贝的字节数;

      函数原型:

      void *memcpy(void *dest, void *src, unsigned int count);
      
  5. static 的用法(定义和用途)(必考)

    1. static 修饰局部变量:使其变为静态存储方式(静态数据区),那么这个局部变量在函数执行完成之后不会被释放,而是继续保留在内存中。
    2. static 修饰全局变量:使其只在本文件内部有效,而其他文件不可连接或引用该变量。
    3. static 修饰函数:对函数的连接方式产生影响,使得函数只在本文件内部有效,对其他文件是不可见的(这一点在大工程中很重要很重要,避免很多麻烦,很常见)。这样的函数又叫作静态函数。使用静态函数的好处是,不用担心与其他文件的同名函数产生干扰,另外也是对函数本身的一种保护机制。
  6. const 的用法(定义和用途)(必考)

    const 主要用来修饰变量、函数形参和类成员函数:

    1. const 修饰常量:定义时就初始化,以后不能更改。
    2. const 修饰形参:func(const int a){};该形参在函数里不能改变。
    3. const 修饰类成员函数:该函数对成员变量只能进行只读操作,就是 const 类成员函数是不能修改成员变量的数值的。

    const 修饰的东西都受到强制保护,可以预防意外的变动,能提高程序的健壮性。

    参考一个大佬的回答:

    我只要一听到被面试者说:“const 意味着常数”,我就知道我正在和一个业余者打交道。去年 Dan Saks 已经在他的文章里完全概括了 const 的所有用法,因此 ESP (译者:Embedded Systems Programming) 的每一位读者应该非常熟悉 const 能做什么和不能做什么。

    如果你从没有读到那篇文章,只要能说出 const 意味着“只读”就可以了。尽管这个答案不是完全的答案,但我接受它作为一个正确的答案。如果应试者能正确回答这个问题,我将问他一个附加的问题:下面的声明都是什么意思?

    const int a;
    int const a;
    const int *a;
    int *const a;
    int const *a const;
    
    • 前两个的作用是一样,a 是一个常整型数。
    • 第三个意味着 a 是一个指向常整型数的指针(也就是,整型数是不可修改的,但指针可以)。
    • 第四个意思 a 是一个指向整型数的常指针(也就是说,指针指向的整型数是可以修改的,但指针是不可修改的)。
    • 最后一个意味着 a 是一个指向常整型数的常指针(也就是说,指针指向的整型数是不可修改的,同时指针也是不可修改的)。
  7. volatile 作用和用法

    一个定义为 volatile 的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。精确地说就是,优化器在用到这个变量时必须每次都小心地重新读取这个变量在内存中的值,而不是使用保存在寄存器里的备份(虽然读写寄存器比读写内存快)。

    回答不出这个问题的人是不会被雇佣的。这是区分 C 程序员和嵌入式系统程序员的最基本的问题。搞嵌入式的家伙们经常同硬件、中断、RTOS 等等打交道,所有这些都要求用到 volatile 变量。不懂得 volatile 的内容将会带来灾难。

    以下几种情况都会用到 volatile

    1. 并行设备的硬件寄存器(如:状态寄存器)
    2. 一个中断服务子程序中会访问到的非自动变量
    3. 多线程应用中被几个任务共享的变量
  8. const 常量和 #define 的区别(编译阶段、安全性、内存占用等)

    • #define max 100 定义的常量是没有类型的(不进行类型安全检查,可能会产生意想不到的错误),所给出的是一个立即数,编译器只是把所定义的常量值与所定义的常量的名字联系起来,define 所定义的宏变量在预处理阶段的时候进行替换,在程序中使用到该常量的地方都要进行拷贝替换。
    • const int max = 255 定义的常量有类型(编译时会进行类型检查)名字,存放在内存的静态区域中,在编译时确定其值。在程序运行过程中 const 变量只有一个拷贝,而 #define 所定义的宏变量却有多个拷贝,所以宏定义在程序运行过程中所消耗的内存要比 const 变量的大得多。
  9. 变量的作用域(全局变量和局部变量)

    • 全局变量:在所有函数体的外部定义的,程序的所有部分(甚至其他文件中的代码)都可以使用。全局变量不受作用域的影响(也就是说,全局变量的生命期一直到程序的结束)。
    • 局部变量:出现在一个作用域内,它们是局限于一个函数的。局部变量经常被称为自动变量,因为它们在进入作用域时自动生成,离开作用域时自动消失。关键字 auto 可以显式地说明这个问题,但是局部变量默认为 auto,所以没有必要声明为 auto

    局部变量可以和全局变量重名,在局部变量作用域范围内,全局变量失效,采用的是局部变量的值。

  10. sizeofstrlen(字符串,数组)

    • 如果是数组:

      #include <stdio.h>
      int main() {
          int a[5] = {1, 2, 3, 4, 5};
          printf("sizeof 数组名 =%d\n", sizeof(a));
          printf("sizeof *数组名 =%d\n", sizeof(*a));
      }
      

      运行结果:

      sizeof 数组名 = 20
      sizeof *数组名 = 4
      
    • 如果是指针,sizeof 只会检测到是指针的类型,指针都是占用 4 个字节的空间(32 位机)。

      sizeof 是什么?是一个操作符,也是关键字,就不是一个函数,这和 strlen() 不同,strlen() 是一个函数。

      那么 sizeof 的作用是什么?返回一个对象或者类型所占的内存字节数。我们会对 sizeof 中的数据或者指针做运算吗?基本不会。例如 sizeof(1 + 2.0),直接检测到其中类型是 double,即是 sizeof(double) = 8。如果是指针,sizeof 只会检测到是指针的类型,指针都是占用 4 个字节的空间(32 位机)。

      char *p = "sadasdasd";
      sizeof(p): 4
      sizeof(*p): 1 // 指向一个 char 类型的
      

      除非使用 strlen(),仅对字符串有效,直到 '\0' 为止了,计数结果不包括 \0

      要是非要使用 sizeof 来得到指向内容的大小,就得使用数组名才行,如

      char a[10];
      sizeof(a): 10  // 检测到 a 是一个数组的类型。
      

      图片

      关于 strlen(),它是一个函数,考察的比较简单:

      strlen("\n\t\tag\AAtang")
      

      答案:11

  11. 经典的 sizeof(struct)sizeof(union) 内存对齐

    内存对齐作用:

    1. 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
    2. 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

    结构体 struct 内存对齐的 3 大规则:

    1. 对于结构体的各个成员,第一个成员的偏移量是 0,排列在后面的成员其当前偏移量必须是当前成员类型的整数倍。
    2. 结构体内所有数据成员各自内存对齐后,结构体本身还要进行一次内存对齐,保证整个结构体占用内存大小是结构体内最大数据成员的最小整数倍。
    3. 如程序中有 #pragma pack(n) 预编译指令,则所有成员对齐以 n 字节为准(即偏移量是 n 的整数倍),不再考虑当前类型以及最大结构体内类型。
    #pragma pack(1)
    struct fun {
        int i;
        double d;
        char c;
    };
    

    sizeof(fun) = 13

    struct CAT_s {
        int ld;
        char Color;
        unsigned short Age;
        char *Name;
        void (*Jump)(void);
    } Garfield;
    
    1. 使用 32 位编译,int 占 4,char 占 1,unsigned short 占 2,char* 占 4,函数指针占 4 个,由于是 32 位编译是 4 字节对齐,所以该结构体占 16 个字节。(说明:按几字节对齐,是根据结构体的最长类型决定的,这里是 int 是最长的字节,所以按 4 字节对齐)。
    2. 使用 64 位编译,int 占 4,char 占 1,unsigned short 占 2,char* 占 8,函数指针占 8 个,由于是 64 位编译是 8 字节对齐(说明:按几字节对齐,是根据结构体的最长类型决定的,这里是函数指针是最长的字节,所以按 8 字节对齐)所以该结构体占 24 个字节。
    //64 位
    struct C {
        double t;   //8   1111 1111
        char b;     //1      1
        int a;      //4      0001111
        short c;    //2     11000000
    };
    

    sizeof(C) = 24; // 注意:1 4 2 不能拼在一起

    char 是 1,然后在 int 之前,地址偏移量得是 4 的倍数,所以 char 后面补三个字节,也就是 char 占了 4 个字节,然后 int 四个字节,最后是 short,只占两个字节,但是总的偏移量得是 double 的倍数,也就是 8 的倍数,所以 short 后面补六个字节。

    联合体 union 内存对齐的 2 大规则:

    1. 找到占用字节最多的成员。
    2. union 的字节数必须是占用字节最多的成员的字节的倍数,而且需要能够容纳其他的成员。
    //x64
    typedef union {
        long i;
        int k[5];
        char c;
    } D;
    

    要计算 union 的大小,首先要找到占用字节最多的成员,本例中是 long,占用 8 个字节,int k[5] 中都是 int 类型,仍然是占用 4 个字节的,然后 union 的字节数必须是占用字节最多的成员的字节的倍数,而且需要能够容纳其他的成员,为了要容纳 k(20 个字节),就必须要保证是 8 的倍数的同时还要大于 20 个字节,所以是 24 个字节。

    引申:位域(大疆笔试题)

    C 语言允许在一个结构体中以位为单位来指定其成员所占内存长度,这种以位为单位的成员称为“位段”或称“位域”(bit field)。利用位段能够用较少的位数存储数据。一个位段必须存储在同一存储单元中,不能跨两个单元。如果第一个单元空间不能容纳下一个位段,则该空间不用,而从下一个单元起存放该位段。

    1. 位段声明和结构体类似。
    2. 位段的成员必须是 intunsigned intsigned int
    3. 位段的成员名后边有一个冒号和一个数字。
    typedef struct_data {
        char m:3;
        char n:5;
        short s;
        union {
            int a;
            char b;
        };
        int h;
    } _attribute_((packed)) data_t;
    

    答案 12

    mn 一起,刚好占用一个字节内存,因为后面是 short 类型变量,所以在 short s 之前,应该补一个字节。所以 mn 其实是占了两个字节的,然后是 short 两个个字节,加起来就 4 个字节,然后联合体占了四个字节,总共 8 个字节了,最后 int h 占了四个字节,就是 12 个字节了。

    *attribute*((packed)) 取消对齐

    GNU C 的一大特色就是 __attribute__ 机制。__attribute__ 可以设置函数属性(Function Attribute)、变量属性(Variable Attribute)和类型属性(Type Attribute)。

    __attribute__ 书写特征是:__attribute__ 前后都有两个下划线,并且后面会紧跟一对括弧,括弧里面是相应的 __attribute__ 参数。

    跨平台通信时用到。不同平台内存对齐方式不同。如果使用结构体进行平台间的通信,会有问题。例如,发送消息的平台上,结构体为 24 字节,接受消息的平台上,此结构体为 32 字节(只是随便举个例子),那么每个变量对应的值就不对了。

    不同框架的处理器对齐方式会有不同,这个时候不指定对齐的话,会产生错误结果。

  12. inline 函数

    在 C 语言中,如果一些函数被频繁调用,不断地有函数入栈,即函数栈,会造成栈空间或栈内存的大量消耗。为了解决这个问题,特别引入了 inline 修饰符,表示为内联函数。

    大多数的机器上,调用函数都要做很多工作:调用前要先保存寄存器,并在返回时恢复,复制实参,程序还必须转向一个新位置执行。C++ 中支持内联函数,其目的是为了提高函数的执行效率,用关键字 inline 放在函数定义(注意是定义而非声明)的前面即可将函数指定为内联函数,内联函数通常就是将它在程序中的每个调用点上“内联地”展开。

    内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。

  13. 内存四区,什么变量分别存储在什么区域,堆上还是栈上。

    图片

    图片

    文字常量区,叫 .rodata,不可以改变,改变会导致段错误。

    int a0 = 1;
    static int a1;
    const static a2 = 0;
    extern int a3;
    
    void fun(void) {
        int a4;
        volatile int a5;
        return;
    }
    
    • a0:全局初始化变量;生命周期为整个程序运行期间;作用域为所有文件;存储位置为 data 段。
    • a1:全局静态未初始化变量;生命周期为整个程序运行期间;作用域为当前文件;储存位置为 BSS 段。
    • a2:全局静态变量
    • a3:全局初始化变量;其他同 a0
    • a4:局部变量;生命周期为 fun 函数运行期间;作用域为 fun 函数内部;储存位置为栈。
    • a5:局部易变变量;
  14. 使用 32 位编译情况下,给出判断所使用机器大小端的方法。

    图片

    联合体方法判断方法:利用 union 结构体的从低地址开始存,且同一时间内只有一个成员占有内存的特性。大端储存符合阅读习惯。联合体占用内存是最大的那个,和结构体不一样。

    ac 公用同一片内存区域,所以更改 c,必然会影响 a 的数据

    #include <stdio.h>
    int main() {
        union w {
            int a;
            char b;
        } c;
        c.a = 1;
        if (c.b == 1)
            printf("小端存储 \n");
        else
            printf("大端存储 \n");
        return 0;
    }
    

    指针方法

    通过将 int 强制类型转换成 char 单字节,p 指向 a 的起始字节(低字节)

    #include <stdio.h>
    int main() {
        int a = 1;
        char *p = (char *)&a;
        if (*p == 1) {
            printf("小端存储 \n");
        } else {
            printf("大端存储 \n");
        }
        return 0;
    }
    
  15. 用变量 a 给出以下定义:

    a) 一个整型数;
    b) 一个指向整型数的指针;
    c) 一个指向指针的指针,它指向的指针是指向一个整型数;
    d) 一个有 10 个整型的数组;
    e) 一个有 10 个指针的数组,该指针是指向一个整型数;
    f) 一个指向有 10 个整型数数组的指针;
    g) 一个指向函数的指针,该函数有一个整型参数并返回一个整型数;
    h) 一个有 10 个指针的数组,该指针指向一个函数,该函数有一个整型参数并返回一个整型数
    

    答案:

    a) int a;
    b) int *a;
    c) int **a;
    d) int a[10];
    e) int *a[10];
    f) int (*a)[10];
    g) int (*a)(int);
    h) int (*a[10])(int);
    
  16. 与或非,异或。运算符优先级

    sum = a & b << c + a ^ c;

    其中 a = 3, b = 5, c = 4(先加再移位再 & 再异或)答案 4

    图片

第三章 网络编程

1. TCP、UDP 的区别

TCP— 传输控制协议,提供的是面向连接、可靠的字节流服务。当客户和服务器彼此交换数据前,必须先在双方之间建立一个 TCP 连接,之后才能传输数据。

UDP— 用户数据报协议,是一个简单的面向数据报的运输层协议。UDP 不提供可靠性,它只是把应用程序传给 IP 层的数据报发送出去,但是并不能保证它们能到达目的地。

  1. TCP 是面向连接的,UDP 是面向无连接的。
  2. UDP 程序结构较简单。
  3. TCP 是面向字节流的,UDP 是基于数据报的。
  4. TCP 保证数据正确性,UDP 可能丢包。
  5. TCP 保证数据顺序到达,UDP 不保证。

2. TCP、UDP 的优缺点

TCP 优点:可靠稳定

TCP 的可靠体现在 TCP 在传输数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制,在数据传完之后,还会断开来连接用来节约系统资源。

TCP 缺点:慢,效率低,占用系统资源高,易被攻击

在传递数据之前要先建立连接,这会消耗时间,而且在数据传递时,确认机制、重传机制、拥塞机制等都会消耗大量时间,而且要在每台设备上维护所有的传输连接。然而,每个连接都会占用系统的 CPU,内存等硬件资源。因为 TCP 有确认机制、三次握手机制,这些也导致 TCP 容易被利用,实现 DOS、DDOS、CC 等攻击。

UDP 优点:快,比 TCP 稍安全

UDP 没有 TCP 拥有的各种机制,是一种无状态的传输协议,所以传输数据非常快,没有 TCP 的这些机制,被攻击利用的机会就少一些,但是也无法避免被攻击。

UDP 缺点:不可靠,不稳定

因为没有 TCP 的这些机制,UDP 在传输数据时,如果网络质量不好,就会很容易丢包,造成数据的缺失。

3. TCP UDP 适用场景

TCP:传输一些对信号完整性,信号质量有要求的信息。

UDP:对网络通讯质量要求不高时,要求网络通讯速度要快的场景。

4. TCP 为什么是可靠连接?

因为 TCP 传输的数据满足 3 大条件,不丢失,不重复,按顺序到达。

5. OSI 典型网络模型,简单说说有哪些

图片

6. 三次握手、四次挥手

三次握手

图片

  1. TCP 服务器进程先创建传输控制块 TCB,时刻准备接受客户进程的连接请求,此时服务器就进入了 LISTEN(监听)状态。
  2. TCP 客户进程也是先创建传输控制块 TCB,然后向服务器发出连接请求报文,这是报文首部中的同部位 SYN=1,同时选择一个初始序列号 seq=x,此时,TCP 客户端进程进入了 SYN-SENT(同步已发送状态)状态。TCP 规定,SYN 报文段(SYN=1 的报文段)不能携带数据,但需要消耗掉一个序号。
  3. TCP 服务器收到请求报文后,如果同意连接,则发出确认报文。确认报文中应该 ACK=1,SYN=1,确认号是 ack=x+1,同时也要为自己初始化一个序列号 seq=y,此时,TCP 服务器进程进入了 SYN-RCVD(同步收到)状态。这个报文也不能携带数据,但是同样要消耗一个序号。
  4. TCP 客户进程收到确认后,还要向服务器给出确认。确认报文的 ACK=1,ack=y+1,自己的序列号 seq=x+1,此时,TCP 连接建立,客户端进入 ESTABLISHED(已建立连接)状态。TCP 规定,ACK 报文段可以携带数据,但是如果不携带数据则不消耗序号。
  5. 当服务器收到客户端的确认后也进入 ESTABLISHED 状态,此后双方就可以开始通信了。

四次挥手

图片

  1. 客户端进程发出连接释放报文,并且停止发送数据。释放数据报文首部,FIN=1,其序列号为 seq=u(等于前面已经传送过来的数据的最后一个字节的序号加 1),此时,客户端进入 FIN-WAIT-1(终止等待 1)状态。TCP 规定,FIN 报文段即使不携带数据,也要消耗一个序号。
  2. 服务器收到连接释放报文,发出确认报文,ACK=1,ack=u+1,并且带上自己的序列号 seq=v,此时,服务端就进入了 CLOSE-WAIT(关闭等待)状态。TCP 服务器通知高层的应用进程,客户端向服务器的方向就释放了,这时候处于半关闭状态,即客户端已经没有数据要发送了,但是服务器若发送数据,客户端依然要接受。这个状态还要持续一段时间,也就是整个 CLOSE-WAIT 状态持续的时间。
  3. 客户端收到服务器的确认请求后,此时,客户端就进入 FIN-WAIT-2(终止等待 2)状态,等待服务器发送连接释放报文(在这之前还需要接受服务器发送的最后的数据)。
  4. 服务器将最后的数据发送完毕后,就向客户端发送连接释放报文,FIN=1,ack=u+1,由于在半关闭状态,服务器很可能又发送了一些数据,假定此时的序列号为 seq=w,此时,服务器就进入了 LAST-ACK(最后确认)状态,等待客户端的确认。
  5. 客户端收到服务器的连接释放报文后,必须发出确认,ACK=1,ack=w+1,而自己的序列号是 seq=u+1,此时,客户端就进入了 TIME-WAIT(时间等待)状态。注意此时 TCP 连接还没有释放,必须经过 2 * MSL(最长报文段寿命)的时间后,当客户端撤销相应的 TCB 后,才进入 CLOSED 状态。
  6. 服务器只要收到了客户端发出的确认,立即进入 CLOSED 状态。同样,撤销 TCB 后,就结束了这次的 TCP 连接。可以看到,服务器结束 TCP 连接的时间要比客户端早一些。

第四章 常见算法

十种常见排序算法可以分为两大类:

  • 非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破 (O(n \log n)),因此称为非线性时间比较类排序。
  • 线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。

图片

算法优劣评价术语

  • 稳定性:稳定:如果 a 原本在 b 前面,而 a = b,排序之后 a 仍然在 b 的前面;不稳定:如果 a 原本在 b 的前面,而 a = b,排序之后 a 可能会出现在 b 的后面。
  • 排序方式:内排序:所有排序操作都在内存中完成,占用常数内存,不占用额外内存。外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行,占用额外内存。
  • 复杂度
    • 时间复杂度:一个算法执行所耗费的时间。
    • 空间复杂度:运行完一个程序所需内存的大小。

图片

图片

至于各种算法的原理以及代码实现,由于太多并且比较复杂,不在本文列出。但推荐两本入门的书:《啊哈!算法》、《大话数据结构》。电子版我会发在交流群里。

排序算法很多,嵌入式要求的不会太多,你会冒泡排序、快速排序、插入排序就可以解决很多问题。难的比如动态规划问题,图的路径问题,嵌入式考的比较少,纯软才会考这些。(大公司和独角兽公司考的会相对难一些)

第五章 Linux 操作系统题目

1. Linux 内核的组成部分

Linux 内核主要由五个子系统组成:进程调度,内存管理,虚拟文件系统,网络接口,进程间通信。

图片

2. Linux 系统的组成部分

Linux 系统一般有 4 个主要部分:

  • 内核
  • shell
  • 文件系统
  • 应用程序

图片

图片

3. 用户空间与内核通信方式有哪些?

  1. 系统调用。用户空间进程通过系统调用进入内核空间,访问指定的内核空间数据。
  2. 驱动程序。用户空间进程可以使用封装后的系统调用接口访问驱动设备节点,以和运行在内核空间的驱动程序通信。
  3. 共享内存 mmap。在代码中调用接口,实现内核空间与用户空间的地址映射,在实时性要求很高的项目中为首选,省去拷贝数据的时间等资源,但缺点是不好控制。
  4. copy_to_user()copy_from_user(),是在驱动程序中调用接口,实现用户空间与内核空间的数据拷贝操作,应用于实时性要求不高的项目中。

以及:

procfs (/proc)
sysctl (/proc/sys)
sysfs (/sys)
netlink 套接口

4. 系统调用与普通函数调用的区别

系统调用:

  1. 使用 INTIRET 指令,内核和应用程序使用的是不同的堆栈,因此存在堆栈的切换,从用户态切换到内核态,从而可以使用特权指令操控设备。
  2. 依赖于内核,不保证移植性。
  3. 在用户空间和内核上下文环境间切换,开销较大。
  4. 是操作系统的一个入口点。

普通函数调用:

  1. 使用 CALLRET 指令,调用时没有堆栈切换。
  2. 平台移植性好。
  3. 属于过程调用,调用开销较小。
  4. 是一个普通功能函数的调用。

5. 内核态,用户态的区别

内核态:操作系统在内核态运行 —— 运行操作系统程序。

用户态:应用程序只能在用户态运行 —— 运行用户程序。

当一个进程在执行用户自己的代码时处于用户运行态(用户态),此时特权级最低,为 3 级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态。Ring3 状态不能访问 Ring0 的地址空间,包括代码和数据;当一个进程因为系统调用陷入内核代码中执行时处于内核运行态(内核态),此时特权级最高,为 0 级。执行的内核代码会使用当前进程的内核栈,每个进程都有自己的内核栈。

6. bootloader、内核、根文件的关系

启动顺序:bootloader -> linux kernel -> rootfile -> app

Bootloader 全名为启动引导程序,是第一段代码,它主要用来初始化处理器及外设,然后调用 Linux 内核。Linux 内核在完成系统的初始化之后需要挂载某个文件系统作为根文件系统(Root Filesystem),然后加载必要的内核模块,启动应用程序。(一个嵌入式 Linux 系统从软件角度看可以分为四个部分:引导加载程序(Bootloader),Linux 内核,文件系统,应用程序。)

7. Bootloader 启动的两个阶段:

Stage1: 汇编语言

  1. 基本的硬件初始化(关闭看门狗和中断,MMU(带操作系统),CACHE。配置系统工作时钟)
  2. 为加载 Stage2 准备 RAM 空间
  3. 拷贝内核映像和文件系统映像到 RAM 中
  4. 设置堆栈指针 sp
  5. 跳到 Stage2 的入口点

Stage2: C 语言

  1. 初始化本阶段要使用到的硬件设备(led uart 等)
  2. 检测系统的内存映射
  3. 加载内核映像和文件系统映像
  4. 设置内核的启动参数

嵌入式系统中广泛采用的非易失性存储器通常是 Flash,而 Bootloader 就位于该存储器的最前端,所以系统上电或复位后执行的第一段程序便是 Bootloader。

8. Linux 下检查内存状态的命令

1)查看进程:top
2)查看内存:free
3)cat /proc/meminfo
4)vmstat

假如一个公司服务器有很多用户,你使用 top 命令,可以看到哪个同事在使用什么命令,做什么事情,占用了多少 CPU。

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

预处理(Pre-Processing)、编译(Compiling)、汇编(Assembling)、链接(Linking)

10. 什么是堆,栈,内存泄漏和内存溢出?

栈由系统操作,程序员不可以操作。

所以内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定),使用完后必须显式释放的内存。应用程序一般使用 mallocnew 等函数从堆中分配到一块内存,使用完后,程序必须负责相应的调用 freedelete 释放该内存块,否则,这块内存就不能被再次使用。

内存溢出:你要求分配的内存超出了系统能给你的,系统不能满足需求,于是产生溢出。

内存越界:向系统申请了一块内存,而在使用内存时,超出了申请的范围(常见的有使用特定大小数组时发生内存越界)

内存溢出问题是 C 语言或者 C++ 语言所固有的缺陷,它们既不检查数组边界,又不检查类型可靠性 (type-safety)。众所周知,用 C/C++ 语言开发的程序由于目标代码非常接近机器内核,因而能够直接访问内存和寄存器,这种特性大大提升了 C/C++ 语言代码的性能。只要合理编码,C/C++ 应用程序在执行效率上必然优于其它高级语言。然而,C/C++ 语言导致内存溢出问题的可能性也要大许多。

11. 死锁的原因、条件

产生死锁的原因主要是:

  1. 因为系统资源不足。
  2. 进程运行推进的顺序不合适。
  3. 资源分配不当等。

如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。其次,进程运行推进顺序与速度不同,也可能产生死锁。

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

  1. 互斥条件:一个资源每次只能被一个进程使用。
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

12. 硬链接与软链接

链接操作实际上是给系统中已有的某个文件指定另外一个可用于访问它的名称。对于这个新的文件名,我们可以为之指定不同的访问权限,以控制对信息的共享和安全性的问题。如果链接指向目录,用户就可以利用该链接直接进入被链接的目录而不用打一大堆的路径名。而且,即使我们删除这个链接,也不会破坏原来的目录。

  1. 硬链接

    硬链接只能引用同一文件系统中的文件。它引用的是文件在文件系统中的物理索引(也称为 inode)。当您移动或删除原始文件时,硬链接不会被破坏,因为它所引用的是文件的物理数据而不是文件在文件结构中的位置。硬链接的文件不需要用户有访问原始文件的权限,也不会显示原始文件的位置,这样有助于文件的安全。如果您删除的文件有相应的硬链接,那么这个文件依然会保留,直到所有对它的引用都被删除。

  2. 软链接(符号链接)

    软连接,其实就是新建立一个文件,这个文件就是专门用来指向别的文件的(那就和 Windows 下的快捷方式的那个文件有很接近的意味)。软连接产生的是一个新的文件,但这个文件的作用就是专门指向某个文件的,删了这个软连接文件,那就等于不需要这个连接,和原来的存在的实体原文件没有任何关系,但删除原来的文件,则相应的软连接不可用。

13. 计算机中,32bit 与 64bit 有什么区别

64bit 计算主要有两大优点:可以进行更大范围的整数运算;可以支持更大的内存。

64 位操作系统下的虚拟内存空间大小:地址空间大小不是 (2^{32}),也不是 (2^{64}),而一般是 (2^{48})。因为并不需要 (2^{64}) 那么大的寻址空间,过大的空间只会造成资源的浪费。所以 64 位 Linux 一般使用 48 位表示虚拟空间地址,40 位标识物理地址。

14. 中断和异常的区别

内中断:同步中断(异常)是由 CPU 内部的电信号产生的中断,其特点为当前执行的指令结束后才转而产生中断,由于有 CPU 主动产生,其执行点必然是可控的。

外中断:异步中断是由 CPU 的外设产生的电信号引起的中断,其发生的时间点不可预期。

15. 中断怎么发生,中断处理流程

请求中断 → 响应中断 → 关闭中断 → 保留断点 → 中断源识别 → 保护现场 → 中断服务子程序 → 恢复现场 → 中断返回。

图片

16. Linux 操作系统挂起、休眠、关机相关命令

关机命令有 haltinit 0poweroffshutdown -h 时间,其中 shutdown 是最安全的。

重启命令有 rebootinit 6shutdown -r 时间。

在 Linux 命令中 reboot 是重新启动,shutdown -r now 是立即停止然后重新启动。

具体可用参数可以百度。

17. 说一个 Linux 下编译优化选项:

加:-o

18. 在有数据 cache 情况下,DMA 数据链路为:

外设 - DMA-DDR-cache-CPU

19. Linux 命令

  1. 改变文件属性的命令:chmod ( chmod 777 /etc/squid 运行命令后,squid 文件夹(目录)的权限就被修改为 777(可读可写可执行))

  2. 查找文件中匹配字符串的命令:grep

  3. 查找当前目录:pwd

  4. 删除目录:rm -rf 目录名

  5. 删除文件:rm 文件名

  6. 创建目录(文件夹):mkdir

  7. 创建文件:touch

  8. vivim 文件名也可以创建

  9. 解压:tar -xzvf 压缩包

    打包:tar -cvzf 目录(文件夹)

  10. 查看进程对应的端口号

    1. 先查看进程 pid
    ps -ef | grep 进程名
    2. 通过 pid 查看占用端口
    netstat -nap | grep 进程 pid
    

20. 硬实时系统和软实时系统

软实时系统:

Windows、Linux 系统通常为软实时,当然有补丁可以将内核做成硬实时的系统,不过商用没有这么做的。

硬实时系统:

对时间要求很高,限定时间内不管做没做完必须返回。

VxWorks,uCOS,FreeRTOS,WinCE,RT-thread 等实时系统;

21. MMU 基础

现代操作系统普遍采用虚拟内存管理(Virtual Memory Management)机制,这需要 MMU(Memory Management Unit,内存管理单元)的支持。有些嵌入式处理器没有 MMU,则不能运行依赖于虚拟内存管理的操作系统。

也就是说:操作系统可以分成两类,用 MMU 的、不用 MMU 的。

用 MMU 的是:Windows、MacOS、Linux、Android;不用 MMU 的是:FreeRTOS、VxWorks、UCOS……

与此相对应的:CPU 也可以分成两类,带 MMU 的、不带 MMU 的。

带 MMU 的是:Cortex-A 系列、ARM9、ARM11 系列;

不带 MMU 的是:Cortex-M 系列……(STM32 是 M 系列,没有 MMU,不能运行 Linux,只能运行一些 UCOS、FreeRTOS 等等)。

MMU 就是负责虚拟地址(virtual address)转化成物理地址(physical address),转换过程比较复杂,可以自行百度。

第六章 单片机常见面试题

1. ROM 与 RAM

这一点我另一篇文章讲解过,这里放链接:

ROM 与 RAM 的区别

2. IO 口工作方式(学过 STM32 的人应该很熟悉)

上拉输入、下拉输入、推挽输出、开漏输出。

3. 请说明总线接口 USRT、I2C、USB 的异同点

(串/并、速度、全/半双工、总线拓扑等)

图片

4. IIC 协议时序图

必须会画出来,我面试被问到过,让我画,我画了个大概。

IIC 协议有两根线,一根 SCL 时钟线,一根 SDA 数据线,如图可以看到开始信号和结束信号的电平状态。开始后,因为 IIC 总线可以挂在很多设备(不超过 8 个),所以先发送一个设备地址,选中这个设备,设备地址最后一位代表了是写还是读。选中设备后,再发送寄存器地址,代表选中某个寄存器,再开始传输数据。

八位设备地址 = 7 位从机地址 + 读/写地址,

再给地址添加一个方向位位用来表示接下来数据传输的方向,

0 表示主设备向从设备 (write) 写数据,

1 表示主设备向从设备 (read) 读数据

图片

开始信号:SCL 为高电平时,SDA 由高电平向低电平跳变,开始传送数据。

结束信号:SCL 为高电平时,SDA 由低电平向高电平跳变,结束传送数据。

应答信号:接收数据的 IC 在接收到 8bit 数据后,向发送数据的 IC 发出特定的低电平脉冲,表示已收到数据。CPU 向受控单元发出一个信号后,等待受控单元发出一个应答信号,CPU 接收到应答信号后,根据实际情况作出是否继续传递信号的判断。若未收到应答信号,由判断为受控单元出现故障。

IIC 信号在数据传输过程中,当 SCL=1 高电平时,数据线 SDA 必须保持稳定状态,不允许有电平跳变,只有在时钟线上的信号为低电平期间,数据线上的高电平或低电平状态才允许变化。SCL=1 时 数据线 SDA 的任何电平变换会看做是总线的起始信号或者停止信号。

IIC 我也有一篇文章有讲解,请看链接:

IIC 总线最多可以挂多少个设备

5. 单片机的 SP 指针始终指向

栈顶

6. IIC 总线在传送数据过程中共有三种类型信号:

它们分别是:开始信号、结束信号和应答信号。

7. FIQ 中断向量入口地址:

FIQ 和 IRQ 是两种不同类型的中断,ARM 为了支持这两种不同的中断,提供了对应的叫做 FIQ 和 IRQ 处理器模式(ARM 有 7 种处理模式)。

FIQ 的中断向量地址在 0x0000001C,而 IRQ 的在 0x00000018

8. SPI 四种模式,简述其中一种模式,画出时序图

在芯片资料上极性和相位一般表示为 CPOL(Clock POLarity)和 CPHA (Clock PHAse), 极性和相位组合成 4 种工作模式。

图片

spi 四种模式 SPI 的相位 (CPHA) 和极性 (CPOL) 分别可以为 0 或 1,对应的 4 种组合构成了 SPI 的 4 种模式 (mode)

Mode 0 CPOL=0, CPHA=0

Mode 1 CPOL=0, CPHA=1

Mode 2 CPOL=1, CPHA=0

Mode 3 CPOL=1, CPHA=1

时钟极性 CPOL: 即 SPI 空闲时,时钟信号 SCLK 的电平(1: 空闲时高电平;0: 空闲时低电平) 时钟相位 CPHA: 即 SPI 在 SCLK 第几个边沿开始采样(0: 第一个边沿开始;1: 第二个边沿开始)

sd 卡的 spi 常用的是 mode 0 和 mode 3,这两种模式的相同的地方是都在时钟上升沿采样传输数据,区别这两种方式的简单方法就是看空闲时,时钟的电平状态,低电平为 mode 0 ,高电平为 mode 3。

具体的通信过程请自行百度,2021 年秋招大疆笔试题考了这道题。

第七章 杂项面试题

1. 讲一讲冯诺依曼和哈佛体系的区别

哈佛结构是一种将程序指令存储和数据存储分开的存储器结构。目前使用哈佛结构的中央处理器和微控制器有很多,ARM9、ARM10 和 ARM11,51 单片机属于哈佛结构。

冯・诺伊曼结构也称普林斯顿结构,是一种将程序指令存储器和数据存储器合并在一起的存储器结构。

2. 面向对象编程的三大特性

以及重载的意思。重载,是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。

3. http 默认端口号

80

4. linux 中 mysql 数据库默认的端口是

3306

5. 编程习惯小知识点

C 语言编程中,单片机平台,一般有 .c.h 文件,如果一个人在 .h 文件中定义了一个变量,会有什么后果。(讨论编程习惯的问题,我一般是只在 .h 文件中声明函数,不会做变量定义;另外,编程中每一个模块都会有对应的 .c.h 文件,最终的总程序自己定义一个 comm.ccomm.h 去调用各个模块,这样的习惯我觉得还行)

if 语句中如果是或运算(|),第一个条件满足时,第二个条件还会判断吗。或运算的话,当然不会,因为 0|1=1,中断了。


嵌入式软件工程师笔试面试指南 - C/C++

嵌入式与 Linux 那些事 于 2021-04-15 17:45:09 发布

哈喽,大家好。最近几天,我把去年秋招总结的笔试面试的一些内容,又进行了重新规划分类。详细分成了 简历书写,面试技巧,面经总结,笔试面试八股文总结 等四个部分。

其中,八股文又分成了 C/C++数据结构与算法分析Arm 体系与架构Linux 驱动开发操作系统网络编程名企笔试真题 等七个部分。本次八股文更新,对于部分不合适的内容进行了删减,新增了 C++ 相关内容。

C/C++

关键字

C 语言宏中 "#""##" 的用法
  1. #)字符串化操作符

    作用:将宏定义中的传入参数名转换成用一对双引号括起来参数名字符串。其只能用于有传入参数的宏定义中,且必须置于宏定义体中的参数名前。

    如:

    #define example(instr) printf("the input string is:\t%s\n", #instr)
    #define example1(instr) #instr
    

    当使用该宏定义时:

    example(abc); // 在编译时将会展开成:printf("the input string is:\t%s\n","abc")
    string str = example1(abc);  // 将会展成:string str="abc"
    
  2. ##)符号连接操作符

    作用:将宏定义的多个形参转换成一个实际参数名。

    如:

    #define exampleNum(n) num##n
    

    使用:

    int num9 = 9;
    int num = exampleNum(9); // 将会扩展成 int num = num9
    

    注意

    a. 当用 ## 连接形参时,## 前后的空格可有可无。

    如:

    #define exampleNum(n)       num ## n
    // 相当于 #define exampleNum(n)      num##n
    

    b. 连接后的实际参数名,必须为实际存在的参数名或是编译器已知的宏定义。

    c. 如果 ## 后的参数本身也是一个宏的话,## 会阻止这个宏的展开。

    #include <stdio.h>
    #include <string.h>
    #define STRCPY(a, b)   strcpy(a##_p, #b)
    int main() {
        char var1_p[20];
        char var2_p[30];
        strcpy(var1_p, "aaaa");
        strcpy(var2_p, "bbbb");
        STRCPY(var1, var2);
        STRCPY(var2, var1);
        printf("var1 = %s\n", var1_p);
        printf("var2 = %s\n", var2_p);
        //STRCPY(STRCPY(var1,var2),var2);
        // 这里是否会展开为: strcpy(strcpy(var1_p,"var2")_p,"var2“)?答案是否定的:
        // 展开结果将是:  strcpy(STRCPY(var1,var2)_p,"var2")
        //## 阻止了参数的宏展开!如果宏定义里没有用到 # 和 ##, 宏将会完全展开
        // 把注释打开的话,会报错:implicit declaration of function 'STRCPY'
        return 0;
    }
    

    结果:

    var1 = var2
    var2 = var1
    
关键字 volatile 有什么含意?并举出三个不同的例子?
  1. 并行设备的硬件寄存器。存储器映射的硬件寄存器通常加 volatile,因为寄存器随时可以被外设硬件修改。当声明指向设备寄存器的指针时一定要用 volatile,它会告诉编译器不要对存储在这个地址的数据进行假设。

  2. 一个中断服务程序中修改的供其他程序检测的变量volatile 提醒编译器,它后面所定义的变量随时都有可能改变。因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有 volatile 关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。

  3. 多线程应用中被几个任务共享的变量。简单地说就是防止编译器对代码进行优化。比如如下程序:

    XBYTE[2] = 0x55;
    XBYTE[2] = 0x56;
    XBYTE[2] = 0x57;
    XBYTE[2] = 0x58;
    

    对外部硬件而言,上述四条语句分别表示不同的操作,会产生四种不同的动作,但是编译器却会对上述四条语句进行优化,认为只有 XBYTE[2] = 0x58忽略前三条语句,只产生一条机器代码)。如果键入 volatile,编译器会逐一地进行编译并产生相应的机器代码(产生四条代码)。

关键字 static 的作用是什么?
  1. 在函数体中,只会被初始化一次,一个被声明为静态的变量在这一函数被调用过程中维持其值不变。
  2. 在模块内(但在函数体外),一个被声明为静态的变量可以被模块内所有函数访问,但不能被模块外其他函数访问。它是一个本地的全局变量(只能被当前文件使用)。
  3. 在模块内,一个被声明为静态的函数只可被这一模块内的其他函数调用。也就是说,这个函数被限制在声明它的模块的本地范围内使用(只能被当前文件使用)。
在 C 语言中,为什么 static 变量只初始化一次?

对于所有的对象(不仅仅是静态对象),初始化都只有一次,而由于静态变量具有“记忆”功能,初始化后,一直都没有被销毁,都会保存在内存区域中,所以不会再次初始化。存放在静态区的变量的生命周期一般比较长,它与整个程序“同生死、共存亡”,所以它只需初始化一次。而 auto 变量,即自动变量,由于它存放在栈区,一旦函数调用结束,就会立刻被销毁

extern "C" 的作用是什么?

extern "C" 的主要作用就是为了能够正确实现 C++ 代码调用其他 C 语言代码。加上 extern "C" 后,会指示编译器这部分代码按 C 语言的进行编译,而不是 C++ 的。

const 有什么作用?
  1. 定义变量(局部变量或全局变量)为常量,例如:

    const int N = 100; // 定义一个常量 N
    N = 50; // 错误,常量的值不能被修改
    const int n; // 错误,常量在定义的时候必须初始化
    
  2. 修饰函数的参数,表示在函数体内不能修改这个参数的值。

  3. 修饰函数的返回值。

    a. 如果给用 const 修饰返回值的类型为指针,那么函数返回值(即指针)的内容是不能被修改的,而且这个返回值只能赋给被 const 修饰的指针。例如:

    const char GetString(); // 定义一个函数
    char *str = GetString(); // 错误,因为 str 没有被 const 修饰
    const char *str = GetString(); // 正确
    

    b. 如果用 const 修饰普通的返回值,如返回 int 变量,由于这个返回值是一个临时变量,在函数调用结束后这个临时变量的生命周期也就结束了,因此把这些返回值修饰为 const 是没有意义的。

  4. 节省内存,避免不必要的内存分配。例如:

    #define PI 3.14159 // 该宏用来定义常量
    const double Pi = 3.14159 // 此时并未将 P 放入只读存储器中
    double i = Pi // 此时为 Pi 分配内存,以后不再分配
    double I = PI // 编译期间进行宏替换,分配内存
    double j = Pi // 没有内存分配再次进行宏替换,又一次分配内存
    
什么情况下使用 const 关键字?
  1. 修饰一般常量。一般常量是指简单类型的常量。这种常量在定义时,修饰符 const 可以用在类型说明符前,也可以用在类型说明符后。例如:

    int const x = 2; const int x = 2;
    
  2. 修饰常数组。定义或说明一个常数组可以采用如下格式:

    int const a[8] = {1, 2, 3, 4, 5, 6, 7, 8};
    const int a[8] = {1, 2, 3, 4, 5, 6, 7, 8};
    
  3. 修饰常对象。常对象是指对象常量,定义格式如下:

    class A {
        const A a;
        A const a;
    };
    

    定义常对象时,同样要进行初始化,并且该对象不能再被更新。修饰符 const 可以放在类名后面,也可以放在类名前面。

  4. 修饰常指针

    const int *p; // 常量指针,指向常量的指针。即 p 指向的内存可以变,p 指向的数值内容不可变
    int const *p; // 同上
    int *const p; // 指针常量,本质是一个常量,而用指针修饰它。 即 p 指向的内存不可以变,但是 p 内存位置的数值可以变
    const int *const p; // 指向常量的常量指针。即 p 指向的内存和数值都不可变
    
  5. 修饰常引用。被 const 修饰的引用变量为常引用,一旦被初始化,就不能再指向其他对象了。

  6. 修饰函数的常参数。const 修饰符也可以修饰函数的传递参数,格式如下:

    void Fun(const int Var);
    

    告诉编译器 Var 在函数体中不能被改变,从而防止了使用者一些无意的或错误的修改。

  7. 修饰函数的返回值。const 修饰符也可以修饰函数的返回值,表明该返回值不可被改变,格式如下:

    const int FunI();
    const MyClass Fun2();
    
  8. 在另一连接文件中引用 const 常量。使用方式有

    extern const int i;
    extern const int j = 10;
    
new/deletemalloc/free 的区别是什么?
  1. newdelete 是 C++ 中的操作符,而 mallocfree 是标准库函数。
  2. 对于非内部数据对象来说,只使用 malloc 是无法完成动态对象要求的,一般在创建对象时需要调用构造函数,对象消亡时,自动的调用析构函数。而 malloc free 是库函数而不是运算符,不在编译器控制范围之内,不能够自动调用构造函数和析构函数。而 new 在为对象申请分配内存空间时,可以自动调用构造函数,同时也可以完成对对象的初始化。同理,delete 也可以自动调用析构函数。而 malloc 只是做一件事,只是为变量分配了内存,同理,free 也只是释放变量的内存。
  3. new 返回的是指定类型的指针,并且可以自动计算所申请内存的大小。而 malloc 需要我们计算申请内存的大小,并且在返回时强行转换为实际类型的指针。
  4. new 内存分配失败时,会抛出 bad_alloc 异常。malloc 分配内存失败时返回 NULL
strlen("\\0") =sizeof("\\0")=

strlen("\\0") = 0sizeof("\\0") = 2

strlen 用来计算字符串的长度(在 C/C++ 中,字符串是以 “\0” 作为结束符的),它从内存的某个位置(可以是字符串开头,中间某个位置,甚至是某个不确定的内存区域)开始扫描直到碰到第一个字符串结束符 \0 为止,然后返回计数器值。sizeof 是 C 语言的关键字,它以字节的形式给出了其操作数的存储大小,操作数可以是一个表达式或括在括号内的类型名,操作数的存储大小由操作数的类型决定。

sizeofstrlen 有什么区别?

strlensizeof 的差别表现在以下 5 个方面。

  1. sizeof 是运算符(是不是被弄糊涂了?事实上,sizeof 既是关键字,也是运算符,但不是函数),而 strlen 是函数。sizeof 后如果是类型,则必须加括弧,如果是变量名,则可以不加括弧。
  2. sizeof 运算符的结果类型是 size_t,它在头文件中 typedefunsigned int 类型。该类型保证能够容纳实现所建立的最大对象的字节大小。
  3. sizeof 可以用类型作为参数,strlen 只能用 char* 作参数,而且必须是以 “0 结尾的。sizeof 还可以以函数作为参数,如 int g(),则 sizeof(g()) 的值等于 sizeof(int) 的值,在 32 位计算机下,该值为 4。
  4. 大部分编译程序的 sizeof 都是在编译的时候计算的,所以可以通过 sizeof(x) 来定义数组维数。而 strlen 则是在运行期计算的,用来计算字符串的实际长度,不是类型占内存的大小。例如,char str[20] = "0123456789",字符数组 str 是编译期大小已经固定的数组,在 32 位机器下,为 sizeof(char) * 20 = 20,而其 strlen 大小则是在运行期确定的,所以其值为字符串的实际长度 10。
  5. 当数组作为参数传给函数时,传递的是指针,而不是数组,即传递的是数组的首地址。
不使用 sizeof,如何求 int 占用的字节数?
#include <stdio.h>
#define MySizeof(Value) (char *)(&value + 1) - (char *)&value
int main() {
    int i;
    double f;
    double *q;
    printf("%d\r\n", MySizeof(i));
    printf("%d\r\n", MySizeof(f));
    printf("%d\r\n", MySizeof(a));
    printf("%d\r\n", MySizeof(q));
    return 0;
}

输出为:

4 8 32 4

上例中,(char*)&Value 返回 Value 的地址的第一个字节,(char*)(&Value + 1) 返回 value 的地址的下一个地址的第一个字节,所以它们之差为它所占的字节数。

C 语言中 structunion 的区别是什么?

struct(结构体)与 union(联合体)是 C 语言中两种不同的数据结构,两者都是常见的复合结构,其区别主要表现在以下两个方面。

  1. 结构体与联合体虽然都是由多个不同的数据类型成员组成的,但不同之处在于联合体中所有成员共用一块地址空间,即联合体只存放了一个被选中的成员,而结构体中所有成员占用空间是累加的,其所有成员都存在,不同成员会存放在不同的地址。在计算一个结构型变量的总长度时,其内存空间大小等于所有成员长度之和(需要考虑字节对齐),而在联合体中,所有成员不能同时占用内存空间,它们不能同时存在,所以一个联合型变量的长度等于其最长的成员的长度
  2. 对于联合体的不同成员赋值,将会对其它的成员重写,原来成员的值就不存在了,而对结构体的不同成员赋值是互不影响的。

举个例子。下列代码执行结果是多少?

typedef union { double i; int k[5]; char c; } DATE;
typedef struct data { int cat; DATE cow; double dog; } too;
DATE max;
printf("%d", sizeof(too) + sizeof(max));

假设为 32 位机器,int 型占 4 个字节,double 型占 8 个字节,char 型占 1 个字节,而 DATE 是一个联合型变量,联合型变量共用空间,union 里面最大的变量类型是 int[5]所以占用 20 个字节,而由于 uniondouble 占了 8 个字节,因此 union 是要 8 个字节对齐,所占内存空间为 8 的倍数。为了实现 8 个字节对齐,所占空间为 24。而 data 是一个结构体变量,每个变量分开占用空间,依次为 sizeof(int) + sizeof(DATE) + sizeof(double) = 4 + 24 + 8 = 36 按照 8 字节对齐,占用空间为 40,所以结果为 40 + 24 = 64。

左值和右值是什么?

左值是指可以出现在等号左边的变量或表达式,它最重要的特点就是可写(可寻址)。也就是说,它的值可以被修改,如果一个变量或表达式的值不能被修改,那么它就不能作为左值。

右值是指只能出现在等号右边的变量或表达式。它最重要的特点是可读。一般的使用场景都是把一个右值赋值给一个左值。

通常,左值可以作为右值,但是右值不一定是左值。

什么是短路求值?
#include <stdio.h>
int main() {
    int i = 6;
    int j = 1;
    if (i > 0 || (j++) > 0);
    printf("%d\r\n", j);
    return 0;
}

输出结果为 1。

输出为什么不是 2,而是 1 呢?其实,这里就涉及一个短路计算的问题。由于 i 语句是个条件判断语句,里面是有两个简单语句进行或运算组合的复合语句,因为或运算中,只要参与或运算的两个表达式的值都为真,则整个运算结果为真,而由于变量 i 的值为 6,已经大于 0 了,而该语句已经为 true,则不需要执行后续的 j++ 操作来判断真假,所以后续的 j++ 操作不需要执行,j 的值仍然为 1。

因为短路计算的问题,对于 && 操作,由于在两个表达式的返回值中,如果有一个为假则整个表达式的值都为假,如果前一个语句的返回值为 false,则无论后一个语句的返回值是真是假,整个条件判断都为假,不用执行后一个语句,而 a > b 的返回值为 false,程序不执行表达式 n = c > d,所以,n 的值保持为初值 2。

++aa++ 有什么区别?两者是如何实现的?

a++ 的具体运算过程为

int temp = a;
a = a + 1;
return temp;

++a 的具体运算过程为

a = a + 1;
return a;

后置自增运算符需要把原来变量的值复制到一个临时的存储空间,等运算结束后才会返回这个临时变量的值。所以前置自增运算符效率比后置自增要高。

内存

C 语言中内存分配的方式有几种?
  1. 静态存储区分配

    内存分配在程序编译之前完成,且在程序的整个运行期间都存在,例如全局变量、静态变量等。

  2. 栈上分配

    在函数执行时,函数内的局部变量的存储单元在栈上创建,函数执行结束时这些存储单元自动释放。

  3. 堆上分配

堆与栈有什么区别?
  1. 申请方式

    栈的空间由操作系统自动分配/释放,堆上的空间手动分配/释放。

  2. 申请大小的限制

    栈空间有限。在 Windows 下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 WINDOWS 下,栈的大小是 2M(也有的说是 1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示 overflow。因此,能从栈获得的空间较小

    堆是很大的自由存储区。堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

  3. 申请效率

    栈由系统自动分配,速度较快。但程序员是无法控制的。

    堆是由 new 分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。

栈在 C 语言中有什么作用?
  1. C 语言中栈用来存储临时变量,临时变量包括函数参数和函数内部定义的临时变量。函数调用中和函数调用相关的函数返回地址,函数中的临时变量,寄存器等均保存在栈中,函数调动返回后从栈中恢复寄存器和临时变量等函数运行场景。
  2. 多线程编程的基础是栈,栈是多线程编程的基石,每一个线程都最少有一个自己专属的栈,用来存储本线程运行时各个函数的临时变量和维系函数调用和函数返回时的函数调用关系和函数运行场景。操作系统最基本的功能是支持多线程编程,支持中断和异常处理,每个线程都有专属的栈,中断和异常处理也具有专属的栈,栈是操作系统多线程管理的基石。
C 语言函数参数压栈顺序是怎样的?

从右至左。

C 语言参数入栈顺序的好处就是可以动态变化参数个数。自左向右的入栈方式,最前面的参数被压在栈底。除非知道参数个数,否则是无法通过栈指针的相对位移求得最左边的参数。这样就变成了左边参数的个数不确定,正好和动态参数个数的方向相反。因此,C 语言函数参数采用自右向左的入栈顺序,主要原因是为了支持可变长参数形式。

C++ 如何处理返回值?

C++ 函数返回可以按值返回和按常量引用返回,偶尔也可以按引址返回。

C++ 中拷贝赋值函数的形参能否进行值传递?

不能。如果是这种情况下,调用拷贝构造函数的时候,首先要将实参传递给形参,这个传递的时候又要调用拷贝构造函数 (aa = ex.aa; // 此处调用拷贝构造函数)。如此循环,无法完成拷贝,栈也会满。

class Example {
public:
    Example(int a) : aa(a) {} // 构造函数
    Example(Example &ex) // 拷贝构造函数(引用传递参数)
    {
        aa = ex.aa; // 此处调用拷贝构造函数
    }
private:
    int aa;
};
int main() {
    Example e1(10);
    Example e2 = e1;
    return 0;
}
C++ 的内存管理是怎样的?

在 C++ 中,虚拟内存分为代码段、数据段、BSS 段、堆区、文件映射区以及栈区六部分。

  • 代码段:包括只读存储区和文本区,其中只读存储区存储字符串常量,文本区存储程序的机器代码。
  • 数据段:存储程序中已初始化的全局变量和静态变量。
  • BSS 段:存储未初始化的全局变量和静态变量(局部 + 全局),以及所有被初始化为 0 的全局变量和静态变量。
  • 堆区:调用 new/malloc 函数时在堆区动态分配内存,同时需要调用 delete/free 来手动释放申请的内存。
  • 映射区:存储动态链接库以及调用 mmap 函数进行的文件映射。
  • 栈区:使用栈空间存储函数的返回地址、参数、局部变量、返回值。
什么是内存泄漏?

简单地说就是申请了一块内存空间,使用完毕后没有释放掉。

它的一般表现方式是程序运行时间越长,占用内存越多,最终用尽全部内存,整个系统崩溃。由程序申请的一块内存,且没有任何一个指针指向它,那么这块内存就泄露了。

如何判断内存泄漏?
  1. 良好的编码习惯,尽量在涉及内存的程序段,检测出内存泄露。当程式稳定之后,在来检测内存泄露时,无疑增加了排除的困难和复杂度。使用了内存分配的函数,一旦使用完毕,要记得要使用其相应的函数释放掉。
  2. 将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。
  3. Boost 中的 smart pointer。
  4. 一些常见的工具插件,如 ccmalloc、Dmalloc、Leaky 等等。

指针

数组指针和指针数组有什么区别?

数组指针就是指向数组的指针,它表示的是一个指针,这个指针指向的是一个数组,它的重点是指针。例如,int (*pa)[8] 声明了一个指针,该指针指向了一个有 8 个 int 型元素的数组。下面给出一个数组指针的示例。

#include <stdio.h>
#include <stdlib.h>
void main() {
    int b[12] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
    int (*p)[4];
    p = b;
    printf("%d\n", **(++p));
}

程序的输出结果为 5。

上例中,p 是一个数组指针,它指向一个包含有 4 个 int 类型数组的指针,刚开始 p 被初始化为指向数组 b 的首地址,++p 相当于把 p 所指向的地址向后移动 4 个 int 所占用的空间,此时 p 指向数组 {5, 6, 7, 8},语句 *(++p) 表示的是这个数组中第一个元素的地址(可以理解 p 为指向二维数组的指针,{1, 2, 3, 4}{5, 6, 7, 8}{9, 10, 11, 12}p 指向的就是 {1, 2, 3, 4} 的地址,*p 就是指向元素,{1, 2, 3, 4}**p 指向的就是 1),语句 **(++p) 会输出这个数组的第一个元素 5。

指针数组表示的是一个数组,而数组中的元素是指针。下面给出另外一个指针数组的示例。

#include <stdio.h>
int main() {
    int i;
    int *p[4];
    int a[4] = {1, 2, 3, 4};
    p[0] = &a[0];
    p[1] = &a[1];
    p[2] = &a[2];
    p[3] = &a[3];
    for (i = 0; i < 4; i++)
        printf("%d", *p[i]);
    printf("\n");
    return 0;
}

程序的输出结果为 1234。

函数指针和指针函数有什么区别?
  1. 函数指针

    如果在程序中定义了一个函数,那么在编译时系统就会为这个函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址。而且函数名表示的就是这个地址。既然是地址我们就可以定义一个指针变量来存放,这个指针变量就叫作函数指针变量,简称函数指针

    int (*p)(int, int);
    

    这个语句就定义了一个指向函数的指针变量 p。首先它是一个指针变量,所以要有一个“*”,即 ( *p );其次前面的 int 表示这个指针变量可以指向返回值类型为 int 型的函数;后面括号中的两个 int 表示这个指针变量可以指向有两个参数且都是 int 型的函数。所以合起来这个语句的意思就是:定义了一个指针变量 p,该指针变量可以指向返回值类型为 int 型,且有两个整型参数的函数。p 的类型为 int (*)(int, int)

    我们看到,函数指针的定义就是将“函数声明”中的“函数名”改成“( * 指针变量名 )”。但是这里需要注意的是:“( * 指针变量名 )两端的括号不能省略,括号改变了运算符的优先级。如果省略了括号,就不是定义函数指针而是一个函数声明了,即声明了一个返回值类型为指针型的函数。

    最后需要注意的是,指向函数的指针变量没有 ++-- 运算。

    #include <stdio.h>
    int Max(int, int); // 函数声明
    int main(void) {
        int (*p)(int, int); // 定义一个函数指针
        int a, b, c;
        p = Max; // 把函数 Max 赋给指针变量 p, 使 p 指向 Max 函数
        printf("please enter a and b:");
        scanf("%d%d", &a, &b);
        c = (*p)(a, b); // 通过函数指针调用 Max 函数
        printf("a = %d\nb = %d\nmax = %d\n", a, b, c);
        return 0;
    }
    int Max(int x, int y) // 定义 Max 函数
    {
        int z;
        if (x > y) {
            z = x;
        } else {
            z = y;
        }
        return z;
    }
    
  2. 指针函数

    首先它是一个函数,只不过这个函数的返回值是一个地址值。函数返回值必须用同类型的指针变量来接受,也就是说,指针函数一定有“函数返回值”,而且,在主调函数中,函数返回值必须赋给同类型的指针变量。

    类型名 * 函数名(函数参数列表);

    其中,后缀运算符括号“()”表示这是一个函数,其前缀运算符星号“*`”表示此函数为指针型函数,其函数值为指针,即它带回来的值的类型为指针,当调用这个函数后,将得到一个“指向返回值为……的指针(地址)”,“类型名”表示函数返回的指针指向的类型。

    “(函数参数列表)”中的括号为函数调用运算符,在调用语句中,即使函数不带参数,其参数表的一对括号也不能省略。其示例如下:

    int *pfun(int, int);
    

    由于“*”的优先级低于“()”的优先级,因而 pfun 首先和后面的“()”结合,也就意味着,pfun 是一个函数。即:

    int *(pfun(int, int));
    

    接着再和前面的“*”结合,说明这个函数的返回值是一个指针。由于前面还有一个 int,也就是说,pfun 是一个返回值为整型指针的函数。

    #include <stdio.h>
    float *find(float (*pointer)[4], int n); // 函数声明
    int main(void) {
        static float score[][4] = {{60, 70, 80, 90}, {56, 89, 34, 45}, {34, 23, 56, 45}};
        float *p;
        int i, m;
        printf("Enter the number to be found:");
        scanf("%d", &m);
        printf("the score of NO.%d are:\n", m);
        p = find(score, m - 1);
        for (i = 0; i < 4; i++)
            printf("%5.2f\t", *(p + i));
        return 0;
    }
    float *find(float (*pointer)[4], int n) /* 定义指针函数 */
    {
        float *pt;
        pt = *(pointer + n);
        return (pt);
    }
    

    共有三个学生的成绩,函数 find() 被定义为指针函数,其形参 pointer 是指针指向包含 4 个元素的一维数组的指针变量。pointer + n 指向 score 的第 n + 1 行。*(pointer + 1) 指向第一行的第 0 个元素。pt 是一个指针变量,它指向浮点型变量。main() 函数中调用 find() 函数,将 score 数组的首地址传给 pointer

数组名和指针的区别与联系是什么?
  1. 数据保存方面

    指针保存的是地址(保存目标数据地址,自身地址由编译器分配),内存访问偏移量为 4 个字节,无论其中保存的是何种数据均已地址类型进行解析。

    数组保存的数据。数组名表示的是第一个元素的地址,内存偏移量是保存数据类型的内存偏移量;只有对数组名取地址(&数组名)时数组名才表示整个数组,内存偏移量是整个数组的大小sizeof(数组名))。

  2. 数据访问方面

    指针对数据的访问方式是间接访问,需要用到解引用符号(*)。

    数组对数据的访问则是直接访问,可通过下标访问或数组名 + 元素偏移量的方式。

  3. 使用环境

    指针多用于动态数据结构(如链表等等)和动态内存开辟。

    数组多用于存储固定个数且类型统一的数据结构(如线性表等等)和隐式分配。

指针进行强制类型转换后与地址进行加法运算,结果是什么?

假设在 32 位机器上,在对齐为 4 的情况下,sizeof(long) 的结果为 4 字节,sizeof(char*) 的结果为 4 字节,sizeof(short int) 的结果与 sizeof(short) 的结果都为 2 字节,sizeof(char) 的结果为 1 字节,sizeof(int) 的结果为 4 字节,由于 32 位机器上是 4 字节对齐,以如下结构体为例:

struct BBB {
    long num;
    char *name;
    short int data;
    char ha;
    short ba[5];
} *p;

p = 0x100000;p + 0x200 = ? (ulong) p + 0x200 = ? (char*) p + 0x200 = ?

其实,在 32 位机器下,sizeof(struct BBB) = sizeof(*p) = 4 + 4 + 2 + 2 + 1 + 3 /* 补齐 */ + 2 * 5 + 2 /* 补齐 */ = 24 字节,而 p = 0x100000,那么 p + 0x200 = 0x1000000 + 0x200 * 24 指针加法,加出来的是指针所指类型的字节长度的整倍数,就是 p 偏移 sizeof(*p) * 0x200

(ulong) p + 0x200 = 0x10000000 + 0x200 经过 ulong 后,已经不再是指针加法,而变成一个数值加法了。

(char*) p + 0x200 = 0x1000000 + 0x200 * sizeof(char) 结果类型是 char*

常量指针,指向常量的指针,指向常量的常量指针有什么区别?
  1. 常量指针

    int *const p;
    

    先看 const 再看 *,是 p 是一个常量类型的指针,不能修改这个指针的指向,但是这个指针所指向的地址上存储的值可以修改

  2. 指向常量的指针

    const int *p;
    

    先看 * 再看 const,定义一个指针指向一个常量,不能通过指针来修改这个指针指向的值

  3. 指向常量的常量指针

    const int *const p;
    

    对于“指向常量的常量指针”,就必须同时满足上述 1 和 2 中的内容,既不可以修改指针的值,也不可以修改指针指向的值

指针和引用的异同是什么?如何相互转换?

相同

  1. 都是地址的概念,指针指向某一内存、它的内容是所指内存的地址;引用则是某块内存的别名。
  2. 从内存分配上看:两者都占内存,程序为指针会分配内存,一般是 4 个字节;而引用的本质是指针常量,指向对象不能变,但指向对象的值可以变。两者都是地址概念,所以本身都会占用内存。

区别

  1. 指针是实体,而引用是别名。
  2. 指针和引用的自增(++)运算符意义不同,指针是对内存地址自增,而引用是对值的自增。
  3. 引用使用时无需解引用 (*),指针需要解引用;(关于解引用大家可以看看这篇博客,传送门)。
  4. 引用只能在定义时被初始化一次,之后不可变;指针可变。
  5. 引用不能为空,指针可以为空。
  6. sizeof 引用”得到的是所指向的变量(对象)的大小,而“sizeof 指针”得到的是指针本身的大小,在 32 位系统指针变量一般占用 4 字节内存。
#include "stdio.h"
int main() {
    int x = 5;
    int *p = &x;
    int &q = x;
    printf("%d %d\n", *p, sizeof(p));
    printf("%d %d\n", q, sizeof(q));
}
// 结果
5  8
5  4

由结果可知,引用使用时无需解引用 (*),指针需要解引用;我用的是 64 位操作系统,“sizeof 指针”得到的是指针本身的大小,即 8 个字节。而“sizeof 引用”得到的是对象本身的大小,即 int 的大小,4 个字节。

转换

  1. 指针转引用:把指针用 * 就可以转换成对象,可以用在引用参数当中。

  2. 引用转指针:把引用类型的对象用 & 取地址就获得指针了。

int a = 5;
int *p = &a;
void fun(int &x) {} // 此时调用 fun 可使用 :fun(*p);
// p 是指针,加个 * 号后可以转换成该指针指向的对象,此时 fun 的形参是一个引用值,
// p 指针指向的对象会转换成引用 X。
野指针是什么?
  1. 野指针是指向不可用内存的指针,当指针被创建时,指针不可能自动指向 NULL,这时,默认值是随机的,此时的指针成为野指针。
  2. 当指针被 freedelete 释放掉时,如果没有把指针设置为 NULL,则会产生野指针,因为释放掉的仅仅是指针指向的内存,并没有把指针本身释放掉。
  3. 第三个造成野指针的原因是指针操作超越了变量的作用范围。
如何避免野指针?
  1. 对指针进行初始化。

    // 将指针初始化为 NULL。
    char *p = NULL;
    // 用 malloc 分配内存
    char *p = (char *)malloc(sizeof(char));
    // 用已有合法的可访问的内存地址对指针初始化
    char num[30] = {0};
    char *p = num;
    
  2. 指针用完后释放内存,将指针赋 NULL

    delete(p);
    p = NULL;
    

    注:malloc 函数分配完内存后需注意:

    a. 检查是否分配成功(若分配成功,返回内存的首地址;分配不成功,返回 NULL。可以通过 if 语句来判断)

    b. 清空内存中的数据(malloc 分配的空间里可能存在垃圾值,用 memsetbzero 函数清空内存)

    // s 是需要置零的空间的起始地址;n 是要置零的数据字节个数。
    void bzero(void *s, int n);
    // 如果要清空空间的首地址为 p,value 为值,size 为字节数。
    void memset(void *start, int value, int size);
    
C++ 中的智能指针是什么?

智能指针是一个类,用来存储指针(指向动态分配对象的指针)。

C++ 程序设计中使用堆内存是非常频繁的操作,堆内存的申请和释放都由程序员自己管理。程序员自己管理堆内存可以提高了程序的效率,但是整体来说堆内存的管理是麻烦的,C++11 中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。

智能指针的内存泄漏如何解决?

为了解决循环引用导致的内存泄漏,引入了弱指针 weak_ptrweak_ptr 的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但是不会指向引用计数的共享内存,但是可以检测到所管理的对象是否已经被释放,从而避免非法访问。

预处理

预处理器标识 #error 的目的是什么?

#error 预处理指令的作用是,编译程序时,只要遇到 #error 就会生成一个编译错误提示消息并停止编译。其语法格式为:#error error-message

下面举个例子:

程序中往往有很多的预处理指令

#ifdef XXX
...
#else
#endif

当程序比较大时,往往有些宏定义是在外部指定的(如 makefile),或是在系统头文件中指定的,当你不太确定当前是否定义了 XXX 时,就可以改成如下这样进行编译:

#ifdef XXX
...
#error "XXX has been defined"
#else
#endif

这样,如果编译时出现错误,输出了 XXX has been defined,表明宏 XXX 已经被定义了。

定义常量谁更好?#define 还是 const

尺有所短,寸有所长,defineconst 都能定义常量,效果虽然一样,但是各有侧重。

define 既可以替代常数值,又可以替代表达式,甚至是代码段,但是容易出错,而 const 的引入可以增强程序的可读性,它使程序的维护与调试变得更加方便。具体而言,它们的差异主要表现在以下 3 个方面。

  1. define 只是用来进行单纯的文本替换define 常量的生命周期止于编译期不分配内存空间,它存在于程序的代码段,在实际程序中,它只是一个常数;而 const 常量存在于程序的数据段,并在堆栈中分配了空间const 常量在程序中确确实实存在,并且可以被调用、传递。

  2. const 常量有数据类型,而 define 常量没有数据类型。编译器可以对 const 常量进行类型安全检查,如类型、语句结构等,而 define 不行。

  3. 很多 IDE支持调试 const 定义的常量,而不支持 define 定义的常量由于 const 修饰的变量可以排除程序之间的不安全性因素,保护程序中的常量不被修改,而且对数据类型也会进行相应的检查,极大地提高了程序的健壮性,所以一般更加倾向于用 const 来定义常量类型

typedefdefine 有什么区别?

typedefdefine 都是替一个对象取一个别名,以此来增强程序的可读性,但是它们在使用和作用上也存在着以下 4 个方面的不同。

  1. 原理不同

    #define 是 C 语言中定义的语法,它是预处理指令,在预处理时进行简单而机械的字符串替换,不做正确性检查,不管含义是否正确照样代入,只有在编译已被展开的源程序时,才会发现可能的错误并报错。

    例如,#define PI 3.1415926,当程序执行 area = PI * r * r 语句时,PI 会被替换为 3.1415926。于是该语句被替换为 area = 3.1415926 * r * r。如果把 #define 语句中的数字 9 写成了 g,预处理也照样代入,而不去检查其是否合理、合法。

    typedef 是关键字,它在编译时处理,所以 typedef 具有类型检查的功能。它在自己的作用域内给一个已经存在的类型一个别名,但是不能在一个函数定义里面使用标识符 typedef。例如,typedef int INTEGER,这以后就可用 INTEGER 来代替 int 作整型变量的类型说明了,例如:INTEGER a, b;

    typedef 定义数组、指针、结构等类型将带来很大的方便,不仅使程序书写简单而且使意义更为明确,因而增强了可读性。例如:typedef int a[10];

    表示 a 是整型数组类型,数组长度为 10。然后就可用 a 说明变量,例如:语句 a s1, s2; 完全等效于语句 int s1[10], s2[10]。同理,typedef void (*p)(void) 表示 p 是一种指向 void 型的指针类型。

  2. 功能不同

    typedef 用来定义类型的别名,这些类型不仅包含内部类型(intchar 等),还包括自定义类型(如 struct),可以起到使类型易于记忆的功能。

    例如:typedef int (*PF)(const char *, const char *)

    定义一个指向函数的指针的数据类型 PF,其中函数返回值为 int,参数为 const char *typedef 还有另外一个重要的用途,那就是定义机器无关的类型。例如,可以定义一个叫 REAL 的浮点类型,在目标机器上它可以获得最高的精度:typedef long double REAL,在不支持 long double 的机器上,该 typedef 看起来会是下面这样:typedef double REAL,在 double 都不支持的机器上,该 typedef 看起来会是这样:typedef float REAL

    #define 不只是可以为类型取别名,还可以定义常量、变量、编译开关等。

  3. 作用域不同

    #define 没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用,而 typedef 有自己的作用域。

    程序示例如下:

    void fun() {
        #define A int
    }
    
    void gun() {
        // 这里也可以使用 A,因为宏替换没有作用域,但如果上面用的是 typedef,那这里就不能用
        // A,不过,一般不在函数内使用 typedef
    }
    
  4. 对指针的操作不同

    两者修饰指针类型时,作用不同。

    #define INTPTR1 int*
    typedef int* INTPTR2;
    INTPTR1 p1, p2;
    INTPTR2 p3, p4;
    

    INTPTR1 p1, p2;INTPTR2 p3, p4; 的效果截然不同。INTPTR1 p1, p2; 进行字符串替换后变成 int* p1, p2;,要表达的意义是声明一个指针变量 p1 和一个整型变量 p2。而 INTPTR2 p3, p4;,由于 INTPTR2 是具有含义的,告诉我们这是一个指向整型数据的指针,那么 p3p4 都为指针变量,这句相当于 int* p3, *p4;。从这里可以看出,进行宏替换是不含任何意义的替换,仅仅为字符串替换;而用 typedef 为一种数据类型起的别名是带有一定含义的。

    程序示例如下:

    #define INTPTR1 int*
    typedef int* INTPTR2
    int a = 1;
    int b = 2;
    int c = 3;
    const INTPTR1 p1 = &a;
    const INTPTR2 p2 = &b;
    INTPTR2 const p3 = &c;
    

    上述代码中,const INTPTR1 p1 表示 p1 是一个常量指针,即不可以通过 p1 去修改 p1 指向的内容,但是 p1 可以指向其他内容。而对于 const INTPTR2 p2,由于 INTPTR2 表示的是个指针类型,因此用 const 去限定,表示封锁了这个指针类型,因此 p2 是一个指针常量,不可使 p2 再指向其他内容,但可以通过 p2 修改其当前指向的内容。INTPTR2 const p3 同样声明的是一个指针常量。

如何使用 define 声明一个常数,用以表明 1 年中有多少秒(忽略闰年问题)?
#define SECONDS_PER_YEAR (60 * 60 * 24 * 365) UL
#include<filename.h>#include"filename.h" 有什么区别?

对于 #include<filename.h>,编译器先从标准库路径开始搜索 filename.h,使得系统文件调用较快。而对于 #include"filename.h",编译器先从用户的工作路径开始搜索 filename.h,然后去寻找系统路径,使得自定义文件调用较快。

头文件的作用有哪些?

头文件的作用主要表现为以下两个方面:

  1. 通过头文件来调用库功能。出于对源代码保密的考虑,源代码不便(或不准)公开,只要向用户提供头文件和二进制的库即可。用户只需要按照头文件中的接口声明来调用库功能,而不必关心接口是怎么实现的。编译器会从库中提取相应的代码。
  2. 头文件能加强类型安全检查。当某个接口被实现或被使用时,其方式与头文件中的声明不一致,编译器就会指出错误,大大减轻程序员调试、改错的负担。
在头文件中定义静态变量是否可行,为什么?

不可行,如果在头文件中定义静态变量,会造成资源浪费的问题,同时也可能引起程序错误。因为如果在使用了该头文件的每个 C 语言文件中定义静态变量,按照编译的步骤,在每个头文件中都会单独存在一个静态变量,从而会引起空间浪费或者程序错误。所以,不推荐在头文件中定义任何变量,当然也包括静态变量。

写一个“标准”宏 MIN,这个宏输入两个参数并返回较小的一个?
#define MIN(A, B) ((A) <= (B) ? (A) : (B))
不使用流程控制语句,如何打印出 1~1000 的整数?

宏定义多层嵌套(10 * 10 * 10),printf 多次输出。

#include <stdio.h>
#define B P, P, P, P, P, P, P, P, P, P
#define P L, L, L, L, L, L, L, L, L, L
#define L I, I, I, I, I, I, I, I, I, I, N
#define I printf("%3d", i++)
#define N printf("\n")
int main() {
    int i = 1;
    B;
    return 0;
}

简便写法,同样使用多层嵌套:

#include <stdio.h>
#define A(x) x; x; x; x; x; x; x; x; x; x;
int main() {
    int n = 1;
    A(A(A(printf("%d", n++))));
    return 0;
}
C 语言中内存分配的方式有几种?
  1. 静态存储区分配

    内存分配在程序编译之前完成,且在程序的整个运行期间都存在,例如全局变量、静态变量等。

  2. 栈上分配

    在函数执行时,函数内的局部变量的存储单元在栈上创建,函数执行结束时这些存储单元自动释放。

  3. 堆上分配

堆与栈有什么区别?
  1. 申请方式

    栈的空间由操作系统自动分配/释放,堆上的空间手动分配/释放。

  2. 申请大小的限制

    栈空间有限。在 Windows 下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 Windows 下,栈的大小是 2M(也有的说是 1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示 overflow。因此,能从栈获得的空间较小

    堆是很大的自由存储区。堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

  3. 申请效率

    栈由系统自动分配,速度较快。但程序员是无法控制的。

    堆是由 new 分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。

栈在 C 语言中有什么作用?
  1. C 语言中栈用来存储临时变量,临时变量包括函数参数和函数内部定义的临时变量。函数调用中和函数调用相关的函数返回地址,函数中的临时变量,寄存器等均保存在栈中。
  2. 栈是多线程编程的基础,栈是多线程编程的基石,每一个线程都最少有一个自己专属的栈,用来存储本线程运行时各个函数的临时变量和维系函数调用和函数返回时的函数调用关系和函数运行场景。操作系统最基本的功能是支持多线程编程,支持中断和异常处理,每个线程都有专属的栈,中断和异常处理也具有专属的栈,栈是操作系统多线程管理的基石。
C 语言函数参数压栈顺序是怎样的?

从右至左。

C 语言参数入栈顺序的好处就是可以动态变化参数个数。自左向右的入栈方式,最前面的参数被压在栈底。除非知道参数个数,否则是无法通过栈指针的相对位移求得最左边的参数。这样就变成了左边参数的个数不确定,正好和动态参数个数的方向相反。因此,C 语言函数参数采用自右向左的入栈顺序,主要原因是为了支持可变长参数形式。

C++ 如何处理返回值?

C++ 函数返回可以按值返回和按常量引用返回,偶尔也可以按引址返回。

C++ 中拷贝赋值函数的形参能否进行值传递?

不能。如果是这种情况下,调用拷贝构造函数的时候,首先要将实参传递给形参,这个传递的时候又要调用拷贝构造函数 (aa = ex.aa; // 此处调用拷贝构造函数)。如此循环,无法完成拷贝,栈也会满。

class Example {
public:
    Example(int a) : aa(a) {} // 构造函数
    Example(Example &ex) // 拷贝构造函数(引用传递参数)
    {
        aa = ex.aa; // 此处调用拷贝构造函数
    }
private:
    int aa;
};
int main() {
    Example e1(10);
    Example e2 = e1;
    return 0;
}
C++ 的内存管理是怎样的?

在 C++ 中,虚拟内存分为代码段、数据段、BSS 段、堆区、文件映射区以及栈区六部分。

  • 代码段:包括只读存储区和文本区,其中只读存储区存储字符串常量,文本区存储程序的机器代码。
  • 数据段:存储程序中已初始化的全局变量和静态变量。
  • BSS 段:存储未初始化的全局变量和静态变量(局部 + 全局),以及所有被初始化为 0 的全局变量和静态变量。
  • 堆区:调用 new/malloc 函数时在堆区动态分配内存,同时需要调用 delete/free 来手动释放申请的内存。
  • 映射区:存储动态链接库以及调用 mmap 函数进行的文件映射。
  • 栈区:使用栈空间存储函数的返回地址、参数、局部变量、返回值。
什么是内存泄漏?

简单地说就是申请了一块内存空间,使用完毕后没有释放掉。

它的一般表现方式是程序运行时间越长,占用内存越多,最终用尽全部内存,整个系统崩溃。由程序申请的一块内存,且没有任何一个指针指向它,那么这块内存就泄露了。

如何判断内存泄漏?
  1. 良好的编码习惯,尽量在涉及内存的程序段,检测出内存泄露。当程式稳定之后,在来检测内存泄露时,无疑增加了排除的困难和复杂度。使用了内存分配的函数,一旦使用完毕,要记得要使用其相应的函数释放掉。
  2. 将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。
  3. Boost 中的 smart pointer。
  4. 一些常见的工具插件,如 ccmalloc、Dmalloc、Leaky 等等。

指针

数组指针和指针数组有什么区别?

数组指针就是指向数组的指针,它表示的是一个指针,这个指针指向的是一个数组,它的重点是指针。例如,int (*pa)[8] 声明了一个指针,该指针指向了一个有 8 个 int 型元素的数组。下面给出一个数组指针的示例。

#include <stdio.h>
#include <stdlib.h>
void main() {
    int b[12] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
    int (*p)[4];
    p = b;
    printf("%d\n", **(p + 1));
}

程序的输出结果为 5。

上例中,p 是一个数组指针,它指向一个包含有 4 个 int 类型数组的指针,刚开始 p 被初始化为指向数组 b 的首地址,p + 1 相当于把 p 所指向的地址向后移动 4 个 int 所占用的空间,此时 p 指向数组 {5, 6, 7, 8},语句 *(p + 1) 表示的是这个数组中第一个元素的地址(可以理解 p 为指向二维数组的指针,{1, 2, 3, 4}{5, 6, 7, 8}{9, 10, 11, 12}p 指向的就是 {1, 2, 3, 4} 的地址,*p 就是指向元素,{1, 2, 3, 4}**p 指向的就是 1),语句 **(p + 1) 会输出这个数组的第一个元素 5。

指针数组表示的是一个数组,而数组中的元素是指针。下面给出另外一个指针数组的示例。

#include <stdio.h>
int main() {
    int i;
    int *p[4];
    int a[4] = {1, 2, 3, 4};
    p[0] = &a[0];
    p[1] = &a[1];
    p[2] = &a[2];
    p[3] = &a[3];
    for (i = 0; i < 4; i++)
        printf("%d", *p[i]);
    printf("\n");
    return 0;
}

程序的输出结果为 1234。

函数指针和指针函数有什么区别?
  1. 函数指针

    如果在程序中定义了一个函数,那么在编译时系统就会为这个函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址。而且函数名表示的就是这个地址。既然是地址我们就可以定义一个指针变量来存放,这个指针变量就叫作函数指针变量,简称函数指针

    int (*p)(int, int);
    

    这个语句就定义了一个指向函数的指针变量 p。首先它是一个指针变量,所以要有一个“*”,即 ( *p );其次前面的 int 表示这个指针变量可以指向返回值类型为 int 型的函数;后面括号中的两个 int 表示这个指针变量可以指向有两个参数且都是 int 型的函数。所以合起来这个语句的意思就是:定义了一个指针变量 p,该指针变量可以指向返回值类型为 int 型,且有两个整型参数的函数。p 的类型为 int (*)(int, int)

    我们看到,函数指针的定义就是将“函数声明”中的“函数名”改成“( * 指针变量名 )”。但是这里需要注意的是:“( * 指针变量名 )两端的括号不能省略,括号改变了运算符的优先级。如果省略了括号,就不是定义函数指针而是一个函数声明了,即声明了一个返回值类型为指针型的函数。

    最后需要注意的是,指向函数的指针变量没有 ++-- 运算。

    #include <stdio.h>
    int Max(int, int); // 函数声明
    int main(void) {
        int (*p)(int, int); // 定义一个函数指针
        int a, b, c;
        p = Max; // 把函数 Max 赋给指针变量 p, 使 p 指向 Max 函数
        printf("please enter a and b:");
        scanf("%d%d", &a, &b);
        c = (*p)(a, b); // 通过函数指针调用 Max 函数
        printf("a = %d\nb = %d\nmax = %d\n", a, b, c);
        return 0;
    }
    int Max(int x, int y) // 定义 Max 函数
    {
        int z;
        if (x > y) {
            z = x;
        } else {
            z = y;
        }
        return z;
    }
    
  2. 指针函数

    首先它是一个函数,只不过这个函数的返回值是一个地址值。函数返回值必须用同类型的指针变量来接受,也就是说,指针函数一定有“函数返回值”,而且,在主调函数中,函数返回值必须赋给同类型的指针变量。

    类型名 * 函数名(函数参数列表);

    其中,后缀运算符括号“()”表示这是一个函数,其前缀运算符星号“*`”表示此函数为指针型函数,其函数值为指针,即它带回来的值的类型为指针,当调用这个函数后,将得到一个“指向返回值为……的指针(地址)”,“类型名”表示函数返回的指针指向的类型。

    “(函数参数列表)”中的括号为函数调用运算符,在调用语句中,即使函数不带参数,其参数表的一对括号也不能省略。其示例如下:

    int *pfun(int, int);
    

    由于“*”的优先级低于“()”的优先级,因而 pfun 首先和后面的“()”结合,也就意味着,pfun 是一个函数。即:

    int *(pfun(int, int));
    

    接着再和前面的“*”结合,说明这个函数的返回值是一个指针。由于前面还有一个 int,也就是说,pfun 是一个返回值为整型指针的函数。

    #include <stdio.h>
    float *find(float (*pointer)[4], int n); // 函数声明
    int main(void) {
        static float score[][4] = {{60, 70, 80, 90}, {56, 89, 34, 45}, {34, 23, 56, 45}};
        float *p;
        int i, m;
        printf("Enter the number to be found:");
        scanf("%d", &m);
        printf("the score of NO.%d are:\n", m);
        p = find(score, m - 1);
        for (i = 0; i < 4; i++)
            printf("%5.2f\t", *(p + i));
        return 0;
    }
    float *find(float (*pointer)[4], int n) /* 定义指针函数 */
    {
        float *pt;
        pt = *(pointer + n);
        return (pt);
    }
    

    共有三个学生的成绩,函数 find() 被定义为指针函数,其形参 pointer 是指针指向包含 4 个元素的一维数组的指针变量。pointer + n 指向 score 的第 n + 1 行。*(pointer + 1) 指向第一行的第 0 个元素。pt 是一个指针变量,它指向浮点型变量。main() 函数中调用 find() 函数,将 score 数组的首地址传给 pointer

数组名和指针的区别与联系是什么?
  1. 数据保存方面

    指针保存的是地址(保存目标数据地址,自身地址由编译器分配),内存访问偏移量为 4 个字节,无论其中保存的是何种数据均已地址类型进行解析。

    数组保存的数据。数组名表示的是第一个元素的地址,内存偏移量是保存数据类型的内存偏移量;只有对数组名取地址(&数组名)时数组名才表示整个数组,内存偏移量是整个数组的大小sizeof(数组名))。

  2. 数据访问方面

    指针对数据的访问方式是间接访问,需要用到解引用符号(*)。

    数组对数据的访问则是直接访问,可通过下标访问或数组名 + 元素偏移量的方式。

  3. 使用环境

    指针多用于动态数据结构(如链表等等)和动态内存开辟。

    数组多用于存储固定个数且类型统一的数据结构(如线性表等等)和隐式分配。

指针进行强制类型转换后与地址进行加法运算,结果是什么?

假设在 32 位机器上,在对齐为 4 的情况下,sizeof(long) 的结果为 4 字节,sizeof(char*) 的结果为 4 字节,sizeof(short int) 的结果与 sizeof(short) 的结果都为 2 字节,sizeof(char) 的结果为 1 字节,sizeof(int) 的结果为 4 字节,由于 32 位机器上是 4 字节对齐,以如下结构体为例:

struct BBB {
    long num;
    char *name;
    short int data;
    char ha;
    short ba[5];
} *p;

p = 0x100000;p + 0x200 = ? (ulong) p + 0x200 = ? (char*) p + 0x200 = ?

其实,在 32 位机器下,sizeof(struct BBB) = sizeof(*p) = 4 + 4 + 2 + 2 + 1 + 3 /* 补齐 */ + 2 * 5 + 2 /* 补齐 */ = 24 字节,而 p = 0x100000,那么 p + 0x200 = 0x1000000 + 0x200 * 24 指针加法,加出来的是指针所指类型的字节长度的整倍数,就是 p 偏移 sizeof(*p) * 0x200

(ulong) p + 0x200 = 0x10000000 + 0x200 经过 ulong 后,已经不再是指针加法,而变成一个数值加法了。

(char*) p + 0x200 = 0x1000000 + 0x200 * sizeof(char) 结果类型是 char*

常量指针,指向常量的指针,指向常量的常量指针有什么区别?
  1. 常量指针

    int *const p;
    

    先看 const 再看 *,是 p 是一个常量类型的指针,不能修改这个指针的指向,但是这个指针所指向的地址上存储的值可以修改

  2. 指向常量的指针

    const int *p;
    

    先看 * 再看 const,定义一个指针指向一个常量,不能通过指针来修改这个指针指向的值

  3. 指向常量的常量指针

    const int *const p;
    

    对于“指向常量的常量指针”,就必须同时满足上述 1 和 2 中的内容,既不可以修改指针的值,也不可以修改指针指向的值

指针和引用的异同是什么?如何相互转换?

相同

  1. 都是地址的概念,指针指向某一内存、它的内容是所指内存的地址;引用则是某块内存的别名。
  2. 从内存分配上看:两者都占内存,程序为指针会分配内存,一般是 4 个字节;而引用的本质是指针常量,指向对象不能变,但指向对象的值可以变。两者都是地址概念,所以本身都会占用内存。

区别

  1. 指针是实体,而引用是别名。
  2. 指针和引用的自增(++)运算符意义不同,指针是对内存地址自增,而引用是对值的自增。
  3. 引用使用时无需解引用 (*),指针需要解引用;(关于解引用大家可以看看这篇博客,传送门)。
  4. 引用只能在定义时被初始化一次,之后不可变;指针可变。
  5. 引用不能为空,指针可以为空。
  6. sizeof 引用”得到的是所指向的变量(对象)的大小,而“sizeof 指针”得到的是指针本身的大小,在 32 位系统指针变量一般占用 4 字节内存。
#include "stdio.h"
int main() {
    int x = 5;
    int *p = &x;
    int &q = x;
    printf("%d %d\n", *p, sizeof(p));
    printf("%d %d\n", q, sizeof(q));
}
// 结果
5  8
5  4

由结果可知,引用使用时无需解引用 (*),指针需要解引用;我用的是 64 位操作系统,“sizeof 指针”得到的是指针本身的大小,即 8 个字节。而“sizeof 引用”得到的是对象本身的大小,即 int 的大小,4 个字节。

转换

  1. 指针转引用:把指针用 * 就可以转换成对象,可以用在引用参数当中。

  2. 引用转指针:把引用类型的对象用 & 取地址就获得指针了。

int a = 5;
int *p = &a;
void fun(int &x) {} // 此时调用 fun 可使用 :fun(*p);
// p 是指针,加个 * 号后可以转换成该指针指向的对象,此时 fun 的形参是一个引用值,
// p 指针指向的对象会转换成引用 X。
野指针是什么?
  1. 野指针是指向不可用内存的指针,当指针被创建时,指针不可能自动指向 NULL,这时,默认值是随机的,此时的指针成为野指针。
  2. 当指针被 freedelete 释放掉时,如果没有把指针设置为 NULL,则会产生野指针,因为释放掉的仅仅是指针指向的内存,并没有把指针本身释放掉。
  3. 第三个造成野指针的原因是指针操作超越了变量的作用范围。
如何避免野指针?
  1. 对指针进行初始化。

    // 将指针初始化为 NULL。
    char *p = NULL;
    // 用 malloc 分配内存
    char *p = (char *)malloc(sizeof(char));
    // 用已有合法的可访问的内存地址对指针初始化
    char num[30] = {0};
    char *p = num;
    
  2. 指针用完后释放内存,将指针赋 NULL

    delete(p);
    p = NULL;
    

    注:malloc 函数分配完内存后需注意:

    a. 检查是否分配成功(若分配成功,返回内存的首地址;分配不成功,返回 NULL。可以通过 if 语句来判断)

    b. 清空内存中的数据(malloc 分配的空间里可能存在垃圾值,用 memsetbzero 函数清空内存)

    // s 是需要置零的空间的起始地址;n 是要置零的数据字节个数。
    void bzero(void *s, int n);
    // 如果要清空空间的首地址为 p,value 为值,size 为字节数。
    void memset(void *start, int value, int size);
    
C++ 中的智能指针是什么?

智能指针是一个类,用来存储指针(指向动态分配对象的指针)。

C++ 程序设计中使用堆内存是非常频繁的操作,堆内存的申请和释放都由程序员自己管理。程序员自己管理堆内存可以提高了程序的效率,但是整体来说堆内存的管理是麻烦的,C++11 中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。

智能指针的内存泄漏如何解决?

为了解决循环引用导致的内存泄漏,引入了弱指针 weak_ptrweak_ptr 的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但是不会指向引用计数的共享内存,但是可以检测到所管理的对象是否已经被释放,从而避免非法访问。

预处理

预处理器标识 #error 的目的是什么?

#error 预处理指令的作用是,编译程序时,只要遇到 #error 就会生成一个编译错误提示消息并停止编译。其语法格式为:#error error-message

下面举个例子:

程序中往往有很多的预处理指令

#ifdef XXX
...
#else
#endif

当程序比较大时,往往有些宏定义是在外部指定的(如 makefile),或是在系统头文件中指定的,当你不太确定当前是否定义了 XXX 时,就可以改成如下这样进行编译:

#ifdef XXX
...
#error "XXX has been defined"
#else
#endif

这样,如果编译时出现错误,输出了 XXX has been defined,表明宏 XXX 已经被定义了。

定义常量谁更好?#define 还是 const

尺有所短,寸有所长,defineconst 都能定义常量,效果虽然一样,但是各有侧重。

define 既可以替代常数值,又可以替代表达式,甚至是代码段,但是容易出错,而 const 的引入可以增强程序的可读性,它使程序的维护与调试变得更加方便。具体而言,它们的差异主要表现在以下 3 个方面。

  1. define 只是用来进行单纯的文本替换define 常量的生命周期止于编译期不分配内存空间,它存在于程序的代码段,在实际程序中,它只是一个常数;而 const 常量存在于程序的数据段,并在堆栈中分配了空间const 常量在程序中确确实实存在,并且可以被调用、传递。

  2. const 常量有数据类型,而 define 常量没有数据类型。编译器可以对 const 常量进行类型安全检查,如类型、语句结构等,而 define 不行。

  3. 很多 IDE支持调试 const 定义的常量,而不支持 define 定义的常量由于 const 修饰的变量可以排除程序之间的不安全性因素,保护程序中的常量不被修改,而且对数据类型也会进行相应的检查,极大地提高了程序的健壮性,所以一般更加倾向于用 const 来定义常量类型

typedefdefine 有什么区别?

typedefdefine 都是替一个对象取一个别名,以此来增强程序的可读性,但是它们在使用和作用上也存在着以下 4 个方面的不同。

  1. 原理不同

    #define 是 C 语言中定义的语法,它是预处理指令,在预处理时进行简单而机械的字符串替换,不做正确性检查,不管含义是否正确照样代入,只有在编译已被展开的源程序时,才会发现可能的错误并报错。

    例如,#define PI 3.1415926,当程序执行 area = PI * r * r 语句时,PI 会被替换为 3.1415926。于是该语句被替换为 area = 3.1415926 * r * r。如果把 #define 语句中的数字 9 写成了 g,预处理也照样代入,而不去检查其是否合理、合法。

    typedef 是关键字,它在编译时处理,所以 typedef 具有类型检查的功能。它在自己的作用域内给一个已经存在的类型一个别名,但是不能在一个函数定义里面使用标识符 typedef。例如,typedef int INTEGER,这以后就可用 INTEGER 来代替 int 作整型变量的类型说明了,例如:INTEGER a, b;

    typedef 定义数组、指针、结构等类型将带来很大的方便,不仅使程序书写简单而且使意义更为明确,因而增强了可读性。例如:typedef int a[10];

    表示 a 是整型数组类型,数组长度为 10。然后就可用 a 说明变量,例如:语句 a s1, s2; 完全等效于语句 int s1[10], s2[10]。同理,typedef void (*p)(void) 表示 p 是一种指向 void 型的指针类型。

  2. 功能不同

    typedef 用来定义类型的别名,这些类型不仅包含内部类型(intchar 等),还包括自定义类型(如 struct),可以起到使类型易于记忆的功能。

    例如:typedef int (*PF)(const char *, const char *)

    定义一个指向函数的指针的数据类型 PF,其中函数返回值为 int,参数为 const char *typedef 还有另外一个重要的用途,那就是定义机器无关的类型。例如,可以定义一个叫 REAL 的浮点类型,在目标机器上它可以获得最高的精度:typedef long double REAL,在不支持 long double 的机器上,该 typedef 看起来会是下面这样:typedef double REAL,在 double 都不支持的机器上,该 typedef 看起来会是这样:typedef float REAL

    #define 不只是可以为类型取别名,还可以定义常量、变量、编译开关等。

  3. 作用域不同

    #define 没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用,而 typedef 有自己的作用域。

    程序示例如下:

    void fun() {
        #define A int
    }
    
    void gun() {
        // 这里也可以使用 A,因为宏替换没有作用域,但如果上面用的是 typedef,那这里就不能用
        // A,不过,一般不在函数内使用 typedef
    }
    
  4. 对指针的操作不同

    两者修饰指针类型时,作用不同。

    #define INTPTR1 int*
    typedef int* INTPTR2;
    INTPTR1 p1, p2;
    INTPTR2 p3, p4;
    

    INTPTR1 p1, p2;INTPTR2 p3, p4; 的效果截然不同。INTPTR1 p1, p2; 进行字符串替换后变成 int* p1, p2;,要表达的意义是声明一个指针变量 p1 和一个整型变量 p2。而 INTPTR2 p3, p4;,由于 INTPTR2 是具有含义的,告诉我们这是一个指向整型数据的指针,那么 p3p4 都为指针变量,这句相当于 int* p3, *p4;。从这里可以看出,进行宏替换是不含任何意义的替换,仅仅为字符串替换;而用 typedef 为一种数据类型起的别名是带有一定含义的。

    程序示例如下:

    #define INTPTR1 int*
    typedef int* INTPTR2
    int a = 1;
    int b = 2;
    int c = 3;
    const INTPTR1 p1 = &a;
    const INTPTR2 p2 = &b;
    INTPTR2 const p3 = &c;
    

    上述代码中,const INTPTR1 p1 表示 p1 是一个常量指针,即不可以通过 p1 去修改 p1 指向的内容,但是 p1 可以指向其他内容。而对于 const INTPTR2 p2,由于 INTPTR2 表示的是个指针类型,因此用 const 去限定,表示封锁了这个指针类型,因此 p2 是一个指针常量,不可使 p2 再指向其他内容,但可以通过 p2 修改其当前指向的内容。INTPTR2 const p3 同样声明的是一个指针常量。

如何使用 define 声明一个常数,用以表明 1 年中有多少秒(忽略闰年问题)?
#define SECONDS_PER_YEAR (60 * 60 * 24 * 365) UL
#include<filename.h>#include"filename.h" 有什么区别?

对于 #include<filename.h>,编译器先从标准库路径开始搜索 filename.h,使得系统文件调用较快。而对于 #include"filename.h",编译器先从用户的工作路径开始搜索 filename.h,然后去寻找系统路径,使得自定义文件调用较快。

头文件的作用有哪些?

头文件的作用主要表现为以下两个方面:

  1. 通过头文件来调用库功能。出于对源代码保密的考虑,源代码不便(或不准)公开,只要向用户提供头文件和二进制的库即可。用户只需要按照头文件中的接口声明来调用库功能,而不必关心接口是怎么实现的。编译器会从库中提取相应的代码。
  2. 头文件能加强类型安全检查。当某个接口被实现或被使用时,其方式与头文件中的声明不一致,编译器就会指出错误,大大减轻程序员调试、改错的负担。
在头文件中定义静态变量是否可行,为什么?

不可行,如果在头文件中定义静态变量,会造成资源浪费的问题,同时也可能引起程序错误。因为如果在使用了该头文件的每个 C 语言文件中定义静态变量,按照编译的步骤,在每个头文件中都会单独存在一个静态变量,从而会引起空间浪费或者程序错误。所以,不推荐在头文件中定义任何变量,当然也包括静态变量。

写一个“标准”宏 MIN,这个宏输入两个参数并返回较小的一个?
#define MIN(A, B) ((A) <= (B) ? (A) : (B))
不使用流程控制语句,如何打印出 1~1000 的整数?

宏定义多层嵌套(10 * 10 * 10),printf 多次输出。

#include <stdio.h>
#define B P, P, P, P, P, P, P, P, P, P
#define P L, L, L, L, L, L, L, L, L, L
#define L I, I, I, I, I, I, I, I, I, I, N
#define I printf("%3d", i++)
#define N printf("\n")
int main() {
    int i = 1;
    B;
    return 0;
}

简便写法,同样使用多层嵌套:

#include <stdio.h>
#define A(x) x; x; x; x; x; x; x; x; x; x;
int main() {
    int n = 1;
    A(A(A(printf("%d", n++))));
    return 0;
}

变量

全局变量和静态变量的区别是什么?
  1. 全局变量的作用域为程序块,而局部变量的作用域为当前函数。
  2. 内存存储方式不同,全局变量(静态全局变量,静态局部变量)分配在全局数据区(静态存储空间),后者分配在栈区。
  3. 生命周期不同。全局变量随主程序创建而创建,随主程序销毁而销毁,局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在了。
  4. 使用方式不同。通过声明为全局变量,程序的各个部分都可以用到,而局部变量只能在局部使用。
全局变量可不可以定义在可被多个 .c 文件包含的头文件中?为什么?

可以,在不同的 .c 文件中以 static 形式来声明同名全局变量

可以在不同的 .c 文件中声明同名的全局变量,前提是其中只能有一个 .c 文件中对此变量赋初值,此时连接不会出错。

局部变量能否和全局变量重名?

能,局部会屏蔽全局。

局部变量可以与全局变量同名,在函数内引用这个变量时,会用到同名的局部变量,而不会用到全局变量。

对于有些编译器而言,在同一个函数内可以定义多个同名的局部变量,比如在两个循环体内都定义一个同名的局部变量,而那个局部变量的作用域就在那个循环体内。

函数

请写个函数在 main 函数执行前先运行

__attribute__ 可以设置函数属性(Function Attribute)、变量属性(Variable Attribute)和类型属性(Type Attribute)。

gnu 对于函数属性主要设置的关键字如下:

alias:      设置函数别名。
aligned:    设置函数对齐方式。
always_inline/gnu_inline:
函数是否是内联函数。
constructor/destructor:
主函数执行之前、之后执行的函数。
format:
指定变参函数的格式输入字符串所在函数位置以及对应格式输出的位置。
noreturn:
指定这个函数没有返回值。请注意,这里的没有返回值,并不是返回值是 void。而是像_exit/exit/abord 那样
执行完函数之后进程就结束的函数。
weak:指定函数属性为弱属性,而不是全局属性,一旦全局函数名称和指定的函数名称
命名有冲突,使用全局函数名称。

完整示例代码如下:

#include <stdio.h>
void before() __attribute__((constructor));
void after() __attribute__((destructor));
void before() {
    printf("this is function %s\n", __func__);
    return;
}
void after() {
    printf("this is function %s\n", __func__);
    return;
}
int main() {
    printf("this is function %s\n", __func__);
    return 0;
}
// 输出结果
// this is function before
// this is function main
// this is function after
为什么析构函数必须是虚函数?

将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们 new 一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。

为什么 C++ 默认的析构函数不是虚函数?

C++ 默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此 C++ 默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。

C++ 中析构函数的作用?

如果构造函数打开了一个文件,最后不需要使用时文件就要被关闭。析构函数允许类自动完成类似清理工作,不必调用其他成员函数。

析构函数也是特殊的类成员函数。简单来说,析构函数与构造函数的作用正好相反,它用来完成对象被删除前的一些清理工作,也就是专门的扫尾工作。

静态函数和虚函数的区别?

静态函数在编译的时候就已经确定运行时机,虚函数在运行的时候动态绑定。虚函数因为用了虚函数表机制,调用的时候会增加一次内存开销。

重载和覆盖有什么区别?
  1. 覆盖是子类和父类之间的关系,垂直关系;重载同一个类之间方法之间的关系,是水平关系。
  2. 覆盖只能由一个方法或者只能由一对方法产生关系;重载是多个方法之间的关系。
  3. 覆盖是根据对象类型(对象对应存储空间类型)来决定的;而重载关系是根据调用的实参表和形参表来选择方法体的。
虚函数表具体是怎样实现运行时多态的?

原理

虚函数表是一个类的虚函数的地址表,每个对象在创建时,都会有一个指针指向该类虚函数表,每一个类的虚函数表,按照函数声明的顺序,会将函数地址存在虚函数表中,当子类对象重写父类的虚函数的时候,父类的虚函数表中对应的位置会被子类的虚函数地址覆盖。

作用

在用父类的指针调用子类对象成员函数时,虚函数表会指明要调用的具体函数是哪个。

C 语言是怎么进行函数调用的?

大多数 CPU 上的程序实现使用栈来支持函数调用操作,栈被用来传递函数参数、存储返回信息、临时保存寄存器原有的值以备恢复以及用来存储局部变量。

函数调用操作所使用的栈部分叫做栈帧结构,每个函数调用都有属于自己的栈帧结构,栈帧结构由两个指针指定,帧指针(指向起始),栈指针(指向栈顶),函数对大多数数据的访问都是基于帧指针。下面是结构图:

图片

栈指针和帧指针一般都有专门的寄存器,通常使用 ebp 寄存器作为帧指针,使用 esp 寄存器做栈指针。

帧指针指向栈帧结构的头,存放着上一个栈帧的头部地址,栈指针指向栈顶。

请你说一说 select
  1. select 函数原型

    int select(int maxfdp, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);
    
  2. 文件描述符的数量

    单个进程能够监视的文件描述符的数量存在最大限制,通常是 1024,当然可以更改数量;(在 Linux 内核头文件中定义:#define __FD_SETSIZE 1024

  3. 就绪 fd 采用轮询的方式扫描

    select 返回的是 int,可以理解为返回的是 ready(准备好的)一个或者多个文件描述符,应用程序需要遍历整个文件描述符数组才能发现哪些 fd 句柄发生了事件,由于 select 采用轮询的方式扫描文件描述符(不知道那个文件描述符读写数据,所以需要把所有的 fd 都遍历),文件描述符数量越多,性能越差。

  4. 内核 / 用户空间内存拷贝

    select 每次都会改变内核中的句柄数据结构集(fd 集合),因而每次调用 select 都需要从用户空间向内核空间复制所有的句柄数据结构(fd 集合),产生巨大的开销。

  5. select 的触发方式

    select 的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行 IO 操作,那么之后每次调用 select 还是会将这些文件描述符通知进程。

  6. 优点

    a. select 的可移植性较好,可以跨平台;

    b. select 可设置的监听时间 timeout 精度更好,可精确到微秒,而 poll 为毫秒。

  7. 缺点

    a. select 支持的文件描述符数量上限为 1024,不能根据用户需求进行更改;

    b. select 每次调用时都要将文件描述符集合从用户态拷贝到内核态,开销较大;

    c. select 返回的就绪文件描述符集合,需要用户循环遍历所监听的所有文件描述符是否在该集合中,当监听描述符数量很大时效率较低。

请你说说 forkwaitexec 函数

父进程产生子进程使用 fork 拷贝出来一个父进程的副本,此时只拷贝了父进程的页表,两个进程都读同一块内存,当有进程写的时候使用写实拷贝机制分配内存,exec 函数可以加载一个 elf 文件去替换父进程,从此父进程和子进程就可以运行不同的程序了。fork 从父进程返回子进程的 pid,从子进程返回 0。调用了 wait 的父进程将会发生阻塞,直到有子进程状态改变,执行成功返回 0,错误返回 -1。exec 执行成功则子进程从新的程序开始运行,无返回值,执行失败返回 -1。

数组

以下代码表示什么意思?
*(a[1] + 1)*(& a\[1\]\[1\])、\(*\(\*a + 1)\)\[1\]

1. `*(a[1] + 1)`:`a\[1\]` 是数组的第二行的首地址,`a\[1\] + 1` 是第二行第二个元素的地址,`*(a[1] + 1)` 表示取该地址的值,即 `a\[1\]\[1\]`。
2. `*(&a[1][1])`:`a\[1\]\[1\]` 是第二行第二个元素的值,`&a[1][1]` 是该元素的地址,`*(&a[1][1])` 表示取该地址的值,即 `a\[1\]\[1\]`。
3. `(*(*a + 1))[1]`:`*a` 是数组的第一行的首地址,`*a + 1` 是第二行的首地址,`(*(*a + 1))` 是第二行的首地址,`(*(*a + 1))[1]` 是第二行第二个元素的值,即 `a\[1\]\[1\]`。
数组下标可以为负数吗?

可以。数组下标只是给出了一个与当前地址的偏移量而已,只要根据这个偏移量能定位得到目标地址即可。例如,对于一个数组 aa[-1] 表示数组首地址前一个地址的值。

位操作
如何求解整型数的二进制表示中 1 的个数?
#include <stdio.h>
int func(int x) {
    int countx = 0;
    while (x) {
        countx++;
        x = x & (x - 1);
    }
    return countx;
}
int main() {
    printf("%d\n", func(9999));
    return 0;
}

程序输出的结果为 8。

原理

  1. 当一个数被减 1 时,它最右边的那个值为 1 的 bit 将变为 0,同时其右边的所有的 bit 都会变成 1。
  2. 每次执行 x & (x - 1) 的作用是把 x 对应的二进制数中的最后一位 1 去掉。因此,循环执行这个操作直到 x 等于 0 的时候,循环的次数就是 x 对应的二进制数中 1 的个数。
如何求解二进制中 0 的个数?
int CountZeroBit(int num) {
    int count = 0;
    while (num + 1) {
        count++;
        num |= (num + 1); // 算法转换
    }
    return count;
}
int main() {
    int value = 25;
    int ret = CountZeroBit(value);
    printf("%d 的二进制位中 0 的个数为 %d\n", value, ret);
    return 0;
}
交换两个变量的值,不使用第三个变量。即 a = 3, b = 5,交换之后 a = 5, b = 3
  1. 算术方法

    a = a + b;
    b = a - b;
    a = a - b;
    
  2. 异或方法

    a = a ^ b; // 只适用于整数类型
    b = a ^ b;
    a = a ^ b;
    

    或者更简洁的写法:

    a ^= b ^= a;
    
给定一个整型变量 a,写两段代码,第一个设置 a 的 bit 3,第二个清除 a 的 bit 3。在以上两个操作中,要保持其他位不变。
#define BIT3 (0x1 << 3)
static int a;
void set_bit3(void) {
    a |= BIT3;
}
void clear_bit3(void) {
    a &= ~BIT3;
}

容器和算法

mapset 有什么区别?分别又是怎么实现的?
  • mapset 都是 C++ 的关联容器,其底层实现都是红黑树
  • 区别
    • map 中的元素是 key-value 对,关键字 起到索引的作用,值则表示与索引相关联的数据。
    • set 是关键字的简单集合,set 中每个元素只包含一个关键字。
    • set 的迭代器是 const 的,不允许修改元素的值;map 允许修改 value,但不允许修改 key
    • map 支持下标操作,set 不支持下标操作。
    • map 可以用 key 做下标,map 的下标运算符 [ ] 将关键码作为下标去执行查找,如果关键码不存在,则插入一个具有该关键码和 mapped_type 类型默认值的元素至 map 中。因此,下标运算符 [ ]map 应用中需要慎用const_map 不能用,只希望确定某一个关键值是否存在而不希望插入元素时也不应该使用,mapped_type 类型没有默认值也不应该使用。如果 find 能解决需要,尽可能用 find
STL 的 allocator 有什么作用?
  • STL 的分配器用于封装 STL 容器在内存管理上的底层细节。
  • 内存配置由 alloc::allocate() 负责,内存释放由 alloc::deallocate() 负责;对象构造由 ::construct() 负责,对象析构由 ::destroy() 负责。
  • 为了提升内存管理的效率,减少申请小内存造成的内存碎片问题,SGI STL 采用了两级配置器:
    • 当分配的空间大小超过 128B 时,使用第一级空间配置器(直接使用 malloc()realloc()free() 函数)。
    • 当分配的空间大小小于 128B 时,使用第二级空间配置器(采用内存池技术,通过空闲链表管理内存)。
STL 迭代器如何删除元素?
  • 序列容器(如 vectordeque
    • 使用 erase(iterator) 后,后续每个元素的迭代器都会失效,但后续每个元素都会往前移动一个位置,erase 会返回下一个有效的迭代器。
  • 关联容器(如 mapset
    • 使用 erase(iterator) 后,当前元素的迭代器失效,但其结构是红黑树,删除当前元素不会影响下一个元素的迭代器,所以在调用 erase 之前,记录下一个元素的迭代器即可。
  • list
    • 使用 erase 方法也会返回下一个有效的 iterator,因此可以使用类似序列容器的方法。
STL 中 map 数据如何存放的?
  • STL 中的 map 底层实现是 红黑树
  • unordered_map 的底层实现是 哈希表
STL 中 mapunordered_map 有什么区别?
  • map
    • 底层实现是红黑树。
    • 遍历结果是有序的,按照 key 的顺序排列。
    • 需要为 key 定义 operator<
  • unordered_map
    • 底层实现是哈希表。
    • 遍历结果是无序的。
    • 需要为 key 定义哈希函数 hash_valueoperator==
    • 插入、删除和查询的效率通常高于 map
vectorlist 的区别是什么?
  • vector
    • 底层实现是动态数组。
    • 支持随机访问。
    • 内存是连续的。
    • 在中间节点进行插入或删除操作会导致内存拷贝。
    • 一次性分配内存,不够时进行 2 倍扩容。
    • 随机访问性能好,插入删除性能差。
  • list
    • 底层实现是双向链表。
    • 不支持随机访问。
    • 内存是不连续的。
    • 插入或删除操作不会导致内存拷贝。
    • 每次插入新节点都会进行内存申请。
    • 随机访问性能差,插入删除性能好。
STL 中迭代器有什么作用?有指针为何还要迭代器?
  • 作用
    • 迭代器提供了一种统一的方式来遍历容器中的元素,而不需要暴露容器的内部实现细节。
    • 迭代器封装了指针操作,提供了更安全和更灵活的访问方式。
  • 与指针的区别
    • 迭代器是一个类模板,表现得像指针,但实际上是封装了指针功能的对象。
    • 迭代器通过重载操作符(如 *->++-- 等)来模拟指针的行为。
    • 迭代器返回的是对象引用,而不是对象的值,因此不能直接输出迭代器本身。
  • 原因
    • 不同的数据结构(如数组、链表、树等)有不同的访问方式,迭代器可以为每种数据结构提供统一的访问接口。
    • 迭代器可以提供比指针更高级的行为,例如在遍历时自动处理边界条件,避免越界错误。
epoll 的原理是什么?
  • 调用顺序

    int epoll_create(int size);
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
    
  • 原理

    • 首先创建一个 epoll 对象,epoll_create 用于创建一个 epoll 文件描述符。
    • 使用 epoll_ctl 对该对象进行操作,将需要监控的文件描述符添加到 epoll 实例中,这些描述符将以 epoll_event 结构体的形式组成一颗红黑树。
    • 调用 epoll_wait 阻塞等待,当某个 fd 上有事件发生时,内核会将对应的事件放入一个链表中,返回有事件发生的链表。
STL 里 resizereserve 的区别是什么?
  • resize
    • 改变当前容器中元素的数量(size())。
    • 如果新的大小大于当前大小,容器会新增元素,新元素的值为默认值。
    • 如果新的大小小于当前大小,容器会删除多余的元素。
  • reserve
    • 改变当前容器的最大容量(capacity)。
    • 不会改变容器中的元素数量,只是分配更多的内存空间,以容纳更多的元素。
    • 如果需要更大的容量,会重新分配内存,并将现有元素复制到新内存中。

类和数据抽象

C++ 中类成员的访问权限?

C++ 通过 publicprotectedprivate 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。

  • 在类的内部(定义类的代码内部),无论成员被声明为 publicprotected 还是 private,都是可以互相访问的,没有访问权限的限制。
  • 在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 privateprotected 属性的成员。
C++ 中 structclass 的区别是什么?
  • 在 C++ 中,structclass 都可以定义类,都可以继承。
  • 区别在于:
    • struct 的默认继承权限和默认访问权限是 public
    • class 的默认继承权限和默认访问权限是 private
    • class 还可以定义模板类形参,例如 template<class T>
C++ 类内可以定义引用数据成员吗?

可以,但必须通过成员函数初始化列表初始化。

面向对象与泛型编程是什么?
  • 面向对象编程(OOP)
    • 是一种程序设计思想,把对象作为程序的基本单元,一个对象包含了数据和操作数据的函数。
    • 特点包括封装、继承、多态。
  • 面向过程编程
    • 把计算机程序视为一系列的命令集合,即一组函数的顺序执行。
    • 通过将函数切分为子函数来降低系统的复杂度。
  • 泛型编程
    • 让类型参数化,使程序(算法)可以从逻辑功能上抽象,把被处理对象(数据)的类型作为参数传递。
    • 提高代码的复用性和灵活性。
什么是右值引用,跟左值有什么区别?
  • 左值和右值的概念
    • 左值:能对表达式取地址、或具名对象/变量。一般指表达式结束后依然存在的持久对象。
    • 右值:不能对表达式取地址,或匿名对象。一般指表达式结束就不再存在的临时对象。
  • 右值引用和左值引用的区别
    • 左值可以寻址,而右值不可以。
    • 左值可以被赋值,右值不可以被赋值,但可以用来给左值赋值。
    • 左值可变,右值不可变(仅对基础类型适用,用户自定义类型右值引用可以通过成员函数改变)。
析构函数可以为 virtual 型,构造函数则不能,为什么?
  • 构造函数不能声明为虚函数的原因
    • 虚函数的主要意义在于被派生类继承从而产生多态。派生类的构造函数中,编译器会加入构造基类的代码,如果基类的构造函数用到参数,则派生类在其构造函数的初始化列表中必须为基类给出参数。
    • 虚函数的意思是开启动态绑定,程序会根据对象的动态类型来选择要调用的方法。然而在构造函数运行的时候,这个对象的动态类型还不完整,没有办法确定它到底是什么类型,故构造函数不能动态绑定。
  • 析构函数可以声明为虚函数的原因
    • 当通过基类指针或引用删除派生类对象时,需要确保调用的是派生类的析构函数,而不是基类的析构函数,以避免内存泄漏。
    • 声明为虚函数后,可以实现多态,确保正确地调用派生类的析构函数。
C++ 中空类默认产生哪些类成员函数?

C++ 中空类默认会产生以下 6 个函数:

class Empty {
public:
    Empty(); // 默认构造函数
    Empty(const Empty&); // 拷贝构造函数
    ~Empty(); // 析构函数
    Empty& operator=(const Empty&); // 赋值运算符
    Empty* operator&(); // 取址运算符
    const Empty* operator&() const; // 取址运算符 const
};

面向对象

面向对象和面向过程有什么区别?
  • 出发点不同
    • 面向对象:使用符合常规思维的方式来处理客观世界的问题,强调把解决问题领域的“动作”直接映射到对象之间的接口上。
    • 面向过程:强调的是过程的抽象化与模块化,是以过程为中心构造或处理客观世界问题。
  • 层次逻辑关系不同
    • 面向对象:使用计算机逻辑来模拟客观世界中的物理存在,以对象的集合类作为处理问题的单位,尽可能地使计算机世界向客观世界靠拢,使用类的层次结构来体现类之间的继承与发展。
    • 面向过程:处理问题的基本单位是能清晰准确地表达过程的模块,用模块的层次结构概括模块或模块间的关系与功能,把客观世界的问题抽象成计算机可以处理的过程。
  • 数据处理方式与控制程序方式不同
    • 面向对象:将数据与对应的代码封装成一个整体,原则上其他对象不能直接修改其数据,即对象的修改只能由自身的成员函数完成,控制程序方式上是通过“事件驱动”来激活和运行程序的。
    • 面向过程:直接通过程序来处理数据,处理完毕后即可显示处理的结果,在控制方式上是按照设计调用或返回程序,不能自由导航,各模块之间存在着控制与被控制、调动与被调用的关系。
  • 分析设计与编码转换方式不同
    • 面向对象:贯穿于软件生命周期的分析、设计及编码中,是一种平滑的过程,从分析到设计再到编码是采用一致性的模型表示,实现的是一种无缝连接。
    • 面向过程:强调分析、设计及编码之间按规则进行转换贯穿于软件生命周期的分析、设计及编码中,实现的是一种有缝的连接。
面向对象的基本特征有哪些?
  • 抽象
    • 忽略一个主题中与当前目标无关的方面,以便更充分地注意与当前目标有关的方面。
    • 包括过程抽象和数据抽象。
  • 继承
    • 一种联结类的层次模型,允许和鼓励类的重用,提供了一种明确表述共性的方法。
    • 子类可以从基类继承方法和实例变量,并可以修改或增加新的方法。
  • 封装
    • 把过程和数据包围起来,对数据的访问只能通过已定义的接口。
    • 保证了模块具有较好的独立性,使得程序维护修改较为容易。
  • 多态
    • 允许不同类的对象对同一消息做出响应。
    • 包括参数化多态性和包含多态性,具有灵活、抽象、行为共享、代码共享的优势。
什么是深拷贝?什么是浅拷贝?
  • 深拷贝
    • 彻底的拷贝,两对象中所有的成员都是独立的一份,成员对象中的成员对象也是独立一份。
  • 浅拷贝
    • 某些成员变量可能是共享的,如果拷贝不够彻底,就是浅拷贝。
什么是友元?
  • 友元函数
    • 普通函数访问某个类中的私有或保护成员。
  • 友元类
    • 类 A 中的成员函数访问类 B 中的私有或保护成员。
基类的构造函数/析构函数是否能被派生类继承?
  • 基类的构造函数和析构函数不能被派生类继承。
  • 派生类需要自行声明构造函数和析构函数。
  • 派生类的构造函数需要调用基类的构造函数来初始化基类成员。
  • 基类的析构函数会由系统自动调用,派生类无需显式调用。
初始化列表和构造函数初始化的区别?
  • 构造函数初始化列表
    • 以冒号开始,后跟以逗号分隔的数据成员列表,每个成员后面跟一个放在括号中的初始化式。
    • 用于初始化类的成员变量,特别是 const 成员和引用类型成员。
    • 初始化顺序与成员变量在类中声明的顺序有关,而不是初始化列表中的顺序。
  • 构造函数体中的赋值
    • 在构造函数体中对成员变量赋值。
    • 适用于非 const 和非引用类型的成员变量。
    • 初始化和赋值的语义不同,初始化在对象创建时直接赋予初始值,而赋值是在对象创建后修改其值。
C++ 中有哪些情况只能用初始化列表,而不能用赋值?
  • 当类中含有 const(常量)、reference(引用)成员变量时,只能初始化,不能对它们进行赋值。
  • 当基类没有默认构造函数时,派生类的构造函数需要在初始化列表中调用基类的构造函数。
  • 如果成员类型是没有默认构造函数的类,也只能使用初始化列表。
类的成员变量的初始化顺序是什么?
  • 成员变量在使用初始化列表初始化时,与构造函数中初始化成员列表的顺序无关,只与定义成员变量的顺序有关。
  • 不使用初始化列表时,在构造函数内初始化的顺序与成员变量在构造函数中的位置有关。
  • 类成员在定义时,不能初始化。
  • 类中 const 成员常量必须在构造函数初始化列表中初始化。
  • 类中 static 成员变量,必须在类外初始化。
  • 静态变量进行初始化顺序是基类的静态变量先初始化,然后是它的派生类。全局变量和静态变量的初始化顺序是不确定的。
当一个类为另一个类的成员变量时,如何对其进行初始化?

示例程序如下:

class ABC {
public:
    ABC(int x, int y, int z);
private:
    int a;
    int b;
    int c;
};

class MyClass {
public:
    MyClass() : abc(1, 2, 3) {
    }
private:
    ABC abc;
};

上例中,因为 ABC 有了显式的带参数的构造函数,那么它是无法依靠编译器生成无参构造函数的,所以必须使用初始化列表:abc(1, 2, 3),才能构造 ABC 的对象。

C++ 能设计实现一个不能被继承的类吗?

在 Java 中定义了关键字 final,被 final 修饰的类不能被继承。但在 C++ 中没有 final 这个关键字,要实现这个要求需要一些技巧。

  • 方法一

    • 将类的构造函数和析构函数定义为私有函数。
    • 通过定义静态函数来创建和释放类的实例。

    示例代码:

    class FinalClass1 {
    public:
        static FinalClass1* GetInstance() {
            return new FinalClass1;
        }
        static void DeleteInstance(FinalClass1* pInstance) {
            delete pInstance;
            pInstance = nullptr;
        }
    private:
        FinalClass1() {}
        ~FinalClass1() {}
    };
    
  • 方法二

    • 使用模板类 MakeFinal,通过友元机制实现。

    示例代码:

    template <typename T>
    class MakeFinal {
        friend T;
    private:
        MakeFinal() {}
        ~MakeFinal() {}
    };
    
    class FinalClass2 : virtual public MakeFinal<FinalClass2> {
    public:
        FinalClass2() {}
        ~FinalClass2() {}
    };
    

    这样,FinalClass2 就不能被继承了。

构造函数没有返回值,那么如何得知对象是否构造成功?

构造函数没有返回值,但可以通过以下方式判断对象是否构造成功:

  • 在构造函数中抛出异常,表示构造失败。
  • 如果构造函数中抛出异常,对象的析构函数不会被执行。
  • 已经构造完毕的子对象会逆序地被析构。
Public 继承、protected 继承、private 继承的区别?
  • Public 继承
    • 基类的公有成员和保护成员在派生类中保持原有的可见性。
    • 基类的私有成员不可见。
  • Protected 继承
    • 基类的公有成员和保护成员在派生类中变为保护成员。
    • 基类的私有成员不可见。
  • Private 继承
    • 基类的公有成员和保护成员在派生类中变为私有成员。
    • 基类的私有成员不可见。
C++ 提供默认参数的函数吗?

C++ 支持为函数参数提供默认值。在函数声明或定义时,可以直接对参数赋值,这就是默认参数。在函数调用时,如果没有提供对应的参数值,编译器会使用默认值。

  • 语法

    void func(int param = defaultValue);
    
  • 注意事项

    • 默认参数必须从右向左定义,即后面的参数不能依赖前面的参数。
    • 如果一组重载函数都允许相同实参个数的调用,可能会引起调用的二义性。
什么是虚函数?
  • 定义
    • 虚函数是一种特殊的成员函数,允许派生类重写基类的函数实现。
    • 通过虚函数,可以实现多态,即通过基类指针或引用调用派生类的函数。
  • 作用
    • 在基类中声明虚函数后,派生类可以覆盖这些虚函数,从而在运行时动态地调用派生类的函数实现。
    • 通过虚函数表(vtable)机制,实现动态绑定。
纯虚函数指的是什么?
  • 定义
    • 纯虚函数是一种特殊的虚函数,声明时在函数声明的末尾加上 = 0
    • 纯虚函数没有具体的实现,必须在派生类中提供实现。
  • 作用
    • 纯虚函数用于定义接口,强制派生类实现这些接口。
    • 包含纯虚函数的类称为抽象类,不能实例化。
什么函数不能声明为虚函数?
  • 普通函数(非成员函数)。
  • 静态成员函数。
  • 内联成员函数。
  • 构造函数。
  • 友元函数。
C++ 中如何阻止一个类被实例化?
  • 将构造函数声明为私有。
  • 将析构函数声明为私有。
  • 使用抽象类(包含纯虚函数的类)。
C++ 中析构函数的作用?
  • 析构函数用于释放对象占用的资源,如关闭文件、释放动态分配的内存等。
  • 如果基类的析构函数不是虚函数,通过基类指针删除派生类对象时,不会调用派生类的析构函数,可能导致资源泄漏。
  • 基类的析构函数应该声明为虚函数,以确保正确地调用派生类的析构函数。
静态函数和虚函数的区别?
  • 静态函数:
    • 属于类,而不是类的某个对象。
    • 不能访问非静态成员变量和非静态成员函数。
    • 可以在类外直接调用。
  • 虚函数:
    • 属于类的对象。
    • 用于实现多态,通过虚函数表实现动态绑定。
    • 不能声明为静态函数。
重载和覆盖有什么区别?
  • 重载
    • 同一个类中,函数名相同,但参数列表不同(参数类型、参数个数或参数顺序不同)。
    • 用于提供多种函数实现,根据参数的不同选择不同的函数。
  • 覆盖
    • 派生类中的函数与基类中的虚函数同名,参数列表相同。
    • 用于实现多态,通过基类指针或引用调用派生类的函数实现。
虚函数表具体是怎样实现运行时多态的?
  • 原理
    • 每个类都有一个虚函数表(vtable),表中存储了该类所有虚函数的地址。
    • 每个对象都有一个指向其类的虚函数表的指针(vptr)。
    • 当通过基类指针或引用调用虚函数时,程序通过 vptr 查找 vtable,然后调用对应的函数地址。
  • 作用
    • 实现了动态绑定,允许在运行时根据对象的实际类型调用相应的函数。
C 语言是怎么进行函数调用的?
  • 栈帧结构
    • 每次函数调用时,都会创建一个栈帧,用于存储函数的局部变量、参数、返回地址等信息。
    • 栈帧由帧指针(ebp)和栈指针(esp)管理。
  • 调用过程
    • 将参数压入栈。
    • 调用函数,将返回地址压入栈。
    • 在被调用函数中,设置新的栈帧。
    • 执行函数体。
    • 恢复调用者的栈帧,返回结果。
    • 调用者继续执行。
请你说一说 select
  1. select 函数原型

    int select (int maxfdp, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);
    
  2. 文件描述符的数量

    单个进程能够监视的文件描述符的数量存在最大限制,通常是 1024,当然可以更改数量;(在 Linux 内核头文件中定义:#define __FD_SETSIZE 1024

  3. 就绪 fd 采用轮询的方式扫描

    select 返回的是 int,可以理解为返回的是 ready(准备好的)一个或者多个文件描述符,应用程序需要遍历整个文件描述符数组才能发现哪些 fd 句柄发生了事件。由于 select 采用轮询的方式扫描文件描述符(不知道哪个文件描述符读写数据,所以需要把所有的 fd 都遍历),文件描述符数量越多,性能越差。

  4. 内核 / 用户空间内存拷贝

    select 每次都会改变内核中的句柄数据结构集(fd 集合),因而每次调用 select 都需要从用户空间向内核空间复制所有的句柄数据结构(fd 集合),产生巨大的开销。

  5. select 的触发方式

    select 的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行 IO 操作,那么之后每次调用 select 还是会将这些文件描述符通知进程。

  6. 优点

    a. select 的可移植性较好,可以跨平台;
    b. select 可设置的监听时间 timeout 精度更好,可精确到微秒,而 poll 为毫秒。

  7. 缺点

    a. select 支持的文件描述符数量上限为 1024,不能根据用户需求进行更改;
    b. select 每次调用时都要将文件描述符集合从用户态拷贝到内核态,开销较大;
    c. select 返回的就绪文件描述符集合,需要用户循环遍历所监听的所有文件描述符是否在该集合中,当监听描述符数量很大时效率较低。

请你说说 fork, wait, exec 函数

父进程产生子进程使用 fork 拷贝出来一个父进程的副本,此时只拷贝了父进程的页表,两个进程都读同一块内存,当有进程写的时候使用写时拷贝机制分配内存,exec 函数可以加载一个 elf 文件去替换父进程,从此父进程和子进程就可以运行不同的程序了。fork 从父进程返回子进程的 pid,从子进程返回 0。调用了 wait 的父进程将会发生阻塞,直到有子进程状态改变,执行成功返回 0,错误返回 -1。exec 执行成功则子进程从新的程序开始运行,无返回值,执行失败返回 -1。

数组

以下代码表示什么意思?
*(a [1] + 1)*(&a [1][1])(*(a + 1))[1]

第一个:因为 a[1] 是第 2 行的地址,a[1] + 1 偏移一个单位(得到第 2 行第 2 列的地址),然后解引用取值,得到 a[1][1]
第二个:[] 优先级高,a[1][1] 取地址再取值。
第三个:a + 1 相当于 &a[1],所以 *(a + 1) = a[1],因此 (*(a + 1))[1] = a[1][1]

数组下标可以为负数吗?

可以,因为下标只是给出了一个与当前地址的偏移量而已,只要根据这个偏移量能定位得到目标地址即可。下面给出一个下标为负数的示例:

数组下标取负值的情况:

#include <stdio.h>

int main ()
{
    int i;
    int a [5] = {0, 1, 2, 3, 4};
    int *p = &a [4];

    for (i = -4; i <= 0; i++)
        printf ("%d %x\n", p [i], &p [i]);

    return 0;
}

从上例可以发现,在 C 语言中,数组的下标并非不可以为负数,当数组下标为负数时,编译可以通过,而且也可以得到正确的结果,只是它表示的意思却是从当前地址向前寻址

位操作

如何求解整型数的二进制表示中 1 的个数?

程序代码如下:

#include <stdio.h>

int func (int x)
{
    int countx = 0;

    while (x)
    {
        countx++;
        x = x & (x - 1);
    }

    return countx;
}

int main ()
{
    printf ("%d\n", func (9999));
    return 0;
}

程序输出的结果为 8。

在上例中,函数 func() 的功能是将 x 转化为二进制数,然后计算该二进制数中含有的 1 的个数。首先以 9 为例来分析,9 的二进制表示为 1001,8 的二进制表示为 1000,两者执行 & 操作之后结果为 1000,此时 1000 再与 0111(7 的二进制位)执行 & 操作之后结果为 0。

为了理解这个算法的核心,需要理解以下两个操作:

  1. 当一个数被减 1 时,它最右边的那个值为 1 的 bit 将变为 0,同时其右边的所有 bit 都会变成 1。
  2. 每次执行 x & (x - 1) 的作用是把 x 对应的二进制数中的最后一位 1 去掉。因此,循环执行这个操作直到 x 等于 0 的时候,循环的次数就是 x 对应的二进制数中 1 的个数。
如何求解二进制中 0 的个数

图示分析(以 25 为例):

int CountZeroBit (int num)
{
    int count = 0;

    while (num + 1)
    {
        count++;
        num |= (num + 1);	// 算法转换
    }

    return count;
}

int main ()
{
    int value = 25;
    int ret = CountZeroBit (value);
    printf ("%d 的二进制位中 0 的个数为 %d\n", value, ret);
    system ("pause");
    return 0;
}
交换两个变量的值,不使用第三个变量。即 a = 3, b = 5,交换之后 a = 5, b = 3

有两种解法,一种用算术算法,一种用 ^(异或)。

a = a + b;
b = a - b;
a = a - b;
a = a ^ b; // 只能对 int, char 等类型使用
b = a ^ b;
a = a ^ b;

or

a ^= b ^= a;
给定一个整型变量 a,写两段代码,第一个设置 a 的 bit 3,第二个清除 a 的 bit 3。在以上两个操作中,要保持其它位不变。
#define BIT3 (0x1 << 3)

static int a;

void set_bit3 (void)
{
    a |= BIT3;
}

void clear_bit3 (void)
{
    a &= ~BIT3;
}

容器和算法

map 和 set 有什么区别?分别又是怎么实现的?

map 和 set 都是 C++ 的关联容器,其底层实现都是红黑树(RB-Tree)。

由于 map 和 set 所开放的各种操作接口,RB-tree 也都提供了,所以几乎所有的 map 和 set 的操作行为,都只是转调 RB-tree 的操作行为。

map 和 set 的区别在于

  • map 中的元素是 key-value(键值对)对:关键字起到索引 的作用,值则表示与索引相关联的数据;set 与之相对就是关键字的简单集合,set 中每个元素只包含一个关键字
  • set 的迭代器是 const 的,不允许修改元素的值;map 允许修改 value,但不允许修改 key。其原因是因为 map 和 set 是根据关键字排序来保证其有序性的,如果允许修改 key 的话,那么首先需要删除该键,然后调节平衡,再插入修改后的键值,调节平衡,如此一来,严重破坏了 map 和 set 的结构,导致 iterator 失效,不知道应该指向改变前的位置,还是指向改变后的位置。所以 STL 中将 set 的迭代器设置成 const,不允许修改迭代器的值;而 map 的迭代器则不允许修改 key 值,允许修改 value 值
  • map 支持下标操作,set 不支持下标操作。map 可以用 key 做下标,map 的下标运算符 [] 将关键码作为下标去执行查找,如果关键码不存在,则插入一个具有该关键码和 mapped_type 类型默认值的元素至 map 中,因此 下标运算符 [] 在 map 应用中需要慎用,const_map 不能用,只希望确定某一个关键值是否存在而不希望插入元素时也不应该使用,mapped_type 类型没有默认值也不应该使用。如果 find 能解决需要,尽可能用 find。
STL 的 allocator 有什么作用?

STL 的分配器用于封装 STL 容器在内存管理上的底层细节。在 C++ 中,其内存配置和释放如下:

  • new 运算分两个阶段:(1) 调用 ::operator new 配置内存;(2) 调用对象构造函数构造对象内容
  • delete 运算分两个阶段:(1) 调用对象析构函数;(2) 调用 ::operator delete 释放内存

为了精密分工,STL allocator 将两个阶段操作区分开来:内存配置由 alloc::allocate() 负责,内存释放由 alloc::deallocate() 负责;对象构造由 ::construct() 负责,对象析构由 ::destroy() 负责。

同时为了提升内存管理的效率,减少申请小内存造成的内存碎片问题,SGI STL 采用了两级配置器,当分配的空间大小超过 128B 时,会使用第一级空间配置器;当分配的空间大小小于 128B 时,将使用第二级空间配置器。第一级空间配置器直接使用 malloc()realloc()free() 函数进行内存空间的分配和释放,而第二级空间配置器采用了内存池技术,通过空闲链表来管理内存。

STL 迭代器如何删除元素?

对于序列容器 vectordeque 来说,使用 erase(iterator) 后,后面的每个元素的迭代器都会失效,但是后面的每个元素都会往前移动一个位置,不过 erase 会返回下一个有效的迭代器;

对于关联容器 mapset 来说,使用了 erase(iterator) 后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素不会影响到下一个元素的迭代器,所以在调用 erase 之前,记录下一个元素的迭代器即可。

对于 list 来说,它使用了不连续分配的内存,并且它的 erase 方法也会返回下一个有效的 iterator,因此上面两种正确的方法都可以使用。

STL 中 MAP 数据如何存放的?

红黑树。unordered_map 底层结构是哈希表。

STL 中 map 与 unordered_map 有什么区别?
  • map 在底层使用了红黑树来实现,unordered_map 是 C++11 标准中新加入的容器,它的底层是使用 hash 表的形式来完成映射的功能。
  • map 是按照 operator< 比较判断元素是否相同,以及比较元素的大小,然后选择合适的位置插入到树中。所以,如果对 map 进行遍历(中序遍历)的话,输出的结果是有序的。顺序就是按照 operator< 定义的大小排序。
  • unordered_map 是计算元素的 Hash 值,根据 Hash 值判断元素是否相同。所以,对 unordered_map 进行遍历,结果是无序的。
  • 使用 map 时,需要为 key 定义 operator<。而 unordered_map 的使用需要定义 hash_value 函数并且重载 operator==。对于内置类型,如 string,这些都不用操心,可以使用默认的。对于自定义的类型做 key,就需要自己重载 operator< 或者 hash_value() 了。

所以说,当不需要结果排好序时,最好用 unordered_map,插入删除和查询的效率要高于 map。

vector 和 list 的区别是什么?
  1. vector 底层实现是数组;list 是双向链表。
  2. vector 支持随机访问,list 不支持。
  3. vector 是顺序内存,list 不是。
  4. vector 在中间节点进行插入删除会导致内存拷贝,list 不会。
  5. vector 一次性分配好内存,不够时才进行 2 倍扩容;list 每次插入新节点都会进行内存申请。
  6. vector 随机访问性能好,插入删除性能差;list 随机访问性能差,插入删除性能好。
STL 中迭代器有什么作用?有指针为何还要迭代器?
  1. 迭代器
    Iterator(迭代器)模式又称 Cursor(游标)模式,用于提供一种方法顺序访问一个聚合对象中各个元素,而又不需暴露该对象的内部表示。或者这样说可能更容易理解:Iterator 模式是运用于聚合对象的一种模式,通过运用该模式,使得我们可以在不知道对象内部表示的情况下,按照一定顺序(由 iterator 提供的方法)访问聚合对象中的各个元素。
    由于 Iterator 模式的以上特性:与聚合对象耦合,在一定程度上限制了它的广泛运用,一般仅用于底层聚合支持类,如 STL 的 list、vector、stack 等容器类及 ostream_iterator 等扩展 iterator。

  2. 迭代器和指针的区别
    迭代器不是指针,是类模板,表现的像指针。它只是模拟了指针的一些功能,通过重载了指针的一些操作符,->*++-- 等。迭代器封装了指针,是一个 “可遍历 STL(Standard Template Library)容器内全部或部分元素” 的对象,本质是封装了原生指针,是指针概念的一种提升(lift),提供了比指针更高级的行为,相当于一种智能指针,它可以根据不同类型的数据结构来实现不同的 ++-- 等操作。
    迭代器返回的是对象引用而不是对象的值,所以 cout 只能输出迭代器使用 * 取值后的值而不能直接输出其自身。

  3. 迭代器产生原因
    Iterator 类的访问方式就是把不同集合类的访问逻辑抽象出来,使得不用暴露集合内部的结构而达到循环遍历集合的效果。

epoll 的原理是什么?

调用顺序:

int epoll_create (int size);

int epoll_ctl (int epfd, int op, int fd, struct epoll_event *event);

int epoll_wait (int epfd, struct epoll_event *events, int maxevents, int timeout);

首先创建一个 epoll 对象,然后使用 epoll_ctl 对这个对象进行操作,把需要监控的描述符添加进去,这些描述符将会以 epoll_event 结构体的形式组成一棵红黑树,接着阻塞在 epoll_wait,进入大循环,当某个 fd 上有事件发生时,内核将会把其对应的结构体放入到一个链表中,返回有事件发生的链表。

STL 里 resize 和 reserve 的区别是什么?
  • 改变当前容器内含有元素的数量(size()),例如:vector v; v.resize(len); 此时 vsize 变为 len。如果原来 vsize 小于 len,那么容器新增 (len - size) 个元素,元素的值默认为 0。当 v.push_back(3); 之后,则是 3 放在了 v 的末尾,即下标为 len,此时容器的 sizelen + 1
  • 改变当前容器的最大容量(capacity),它不会生成元素,只是确定这个容器允许放入多少对象。如果 reserve(len) 的值大于当前的 capacity(),那么会重新分配一块能存 len 个对象的空间,然后把之前 v.size() 个对象通过 copy constructor 复制过来,销毁之前的内存。

类和数据抽象

C++ 中类成员的访问权限?

C++ 通过 publicprotectedprivate 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。在类的内部(定义类的代码内部),无论成员被声明为 publicprotected 还是 private,都是可以互相访问的,没有访问权限的限制。在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 privateprotected 属性的成员。

C++ 中 struct 和 class 的区别是什么?

在 C++ 中,可以用 structclass 定义类,都可以继承。区别在于:struct 的默认继承权限和默认访问权限是 public,而 class 的默认继承权限和默认访问权限是 private。另外,class 还可以定义模板类形参,比如 template

C++ 类内可以定义引用数据成员吗?

可以,但必须通过成员函数初始化列表初始化。

面向对象与泛型编程是什么?
  1. 面向对象编程(OOP)
    面向对象编程是一种程序设计思想。OOP 把对象作为程序的基本单元,一个对象包含了数据和操作数据的函数。

  2. 面向过程的程序设计
    面向过程的程序设计把计算机程序视为一系列的命令集合,即一组函数的顺序执行。为了简化程序设计,面向过程把函数继续切分为子函数,即把大块函数通过切割成小块函数来降低系统的复杂度。

  3. 泛型编程
    泛型编程让类型参数化,方便程序员编码。类型参数化使得程序(算法)可以从逻辑功能上抽象,把被处理对象(数据)的类型作为参数传递。

什么是右值引用,跟左值又有什么区别?

左值和右值的概念

  • 左值:能对表达式取地址、或具名对象 / 变量。一般指表达式结束后依然存在的持久对象。
  • 右值:不能对表达式取地址,或匿名对象。一般指表达式结束就不再存在的临时对象。

右值引用和左值引用的区别

  1. 左值可以寻址,而右值不可以。
  2. 左值可以被赋值,右值不可以被赋值,可以用来给左值赋值。
  3. 左值可变,右值不可变(仅对基础类型适用,用户自定义类型右值引用可以通过成员函数改变)。
析构函数可以为 virtual 型,构造函数则不能,为什么?

构造函数不能声明为虚函数,析构函数可以声明为虚函数,而且有时是必须声明为虚函数。不建议在构造函数和析构函数里面调用虚函数。

构造函数不能声明为虚函数的原因是:

虚函数的主要意义在于被派生类继承从而产生多态。派生类的构造函数中,编译器会加入构造基类的代码,如果基类的构造函数用到参数,则派生类在其构造函数的初始化列表中必须为基类给出参数,就是这个原因。虚函数的意思就是开启动态绑定,程序会根据对象的动态类型来选择要调用的方法。然而在构造函数运行的时候,这个对象的动态类型还不完整,没有办法确定它到底是什么类型,故构造函数不能动态绑定。(动态绑定是根据对象的动态类型而不是函数名,在调用构造函数之前,这个对象根本就不存在,它怎么动态绑定?)

C++ 中空类默认产生哪些类成员函数?

C++ 中空类默认会产生以下 6 个函数:默认构造函数、复制构造函数、析构函数、赋值运算符重载函数、取址运算符重载函数、const 取址运算符重载函数等。

class Empty
{
public:
    Empty (); // 缺省构造函数
    Empty (const Empty&); // 拷贝构造函数
    ~Empty (); // 析构函数
    Empty& operator=(const Empty&); // 赋值运算符
    Empty* operator&(); // 取址运算符
    const Empty* operator&() const; // 取址运算符 const
};

面向对象

面向对象和面向过程有什么区别?

面向对象与面向过程有以下四个方面的不同:

  1. 出发点不同
    面向对象使用符合常规思维的方式来处理客观世界的问题,强调把解决问题领域的 “动作” 直接映射到对象之间的接口上。而面向过程则强调的是过程的抽象化与模块化,是以过程为中心构造或处理客观世界问题。

  2. 层次逻辑关系不同
    面向对象使用计算机逻辑来模拟客观世界中的物理存在,以对象的集合类作为处理问题的单位,尽可能地使计算机世界向客观世界靠拢,以使处理问题的方式更清晰直接,面向对象使用类的层次结构来体现类之间的继承与发展。面向过程处理问题的基本单位是能清晰准确地表达过程的模块,用模块的层次结构概括模块或模块间的关系与功能,把客观世界的问题抽象成计算机可以处理的过程。

  3. 数据处理方式与控制程序方式不同
    面向对象将数据与对应的代码封装成一个整体,原则上其他对象不能直接修改其数据,即对象的修改只能由自身的成员函数完成,控制程序方式上是通过 “事件驱动” 来激活和运行程序的。而面向过程是直接通过程序来处理数据,处理完毕后即可显示处理的结果,在控制方式上是按照设计调用或返回程序,不能自由导航,各模块之间存在着控制与被控制,调动与被调用的关系。

  4. 分析设计与编码转换方式不同
    面向对象贯穿于软件生命周期的分析、设计及编码中,是一种平滑的过程,从分析到设计再到编码是采用一致性的模型表示,实现的是一种无缝连接。而面向过程强调分析、设计及编码之间按规则进行转换贯穿于软件生命周期的分析、设计及编码中,实现的是一种有缝的连接。

面向对象的基本特征有哪些?

面向对象的编程方法有四个基本特性:

  1. 抽象
    就是忽略一个主题中与当前目标无关的方面,以便更充分地注意与当前目标有关的方面。抽象并不打算了解全部问题,而只是选择其中的一部分,暂时不用部分细节。抽象包括两个方面,一是过程抽象,二是数据抽象。

    • 过程抽象是指任何一个明确定义功能的操作都可被使用者看作单个的实体看待,尽管这个操作实际上可能由一系列更低级的操作来完成。
    • 数据抽象定义了数据类型和施加于该类型对象上的操作,并限定了对象的值,只能通过使用这些操作修改和观察。
  2. 继承
    这是一种联结类的层次模型,并且允许和鼓励类的重用,它提供了一种明确表述共性的方法。对象的一个新类可以从现有的类中派生,这个过程称为类继承。新类继承了原始类的特性,新类称为原始类的派生类(子类),而原始类称为新类的基类(父类)。
    派生类可以从它的基类那里继承方法和实例变量,并且类可以修改或增加新的方法使之更适合特殊的需要。这也体现了大自然中一般与特殊的关系。继承性很好地解决了软件的可重用性问题。

  3. 封装
    就是把过程和数据包围起来,对数据的访问只能通过已定义的接口。面向对象的计算始于这个基本概念,即现实世界可以被描绘成一系列完全自治、封装的对象,这些对象通过一个受保护的接口访问其他对象。一旦定义了一个对象的特性,则有必要决定这些特性的可见性,即哪些特性对外部世界是可见的,哪些特性用于表示内部状态。
    在这个阶段定义对象的接口。通常,应禁止直接访问一个对象的实际表示,而应通过操作接口访问对象,这称为信息隐藏。封装保证了模块具有较好的独立性,使得程序维护修改较为容易。对应用程序的修改仅限于类的内部,因而可以将应用程序修改带来的影响减少到最低限度。

  4. 多态
    是指允许不同类的对象对同一消息做出响应。比如同样的复制 - 粘贴操作,在字处理程序和绘图程序中有不同的效果。多态性包括参数化多态性和包含多态性。多态性语言具有灵活、抽象、行为共享、代码共享的优势,很好地解决了应用程序函数同名问题。

什么是深拷贝?什么是浅拷贝?
  • 深拷贝:彻底的拷贝,两对象中所有的成员都是独立的一份,而且,成员对象中的成员对象也是独立一份。
  • 浅拷贝:某些成员变量可能是共享的。如果拷贝不够彻底,就是浅拷贝。
什么是友元?

有成员只能在类的成员函数内部访问,如果想在别处访问对象的私有成员,只能通过类提供的接口(成员函数)间接地进行。这固然能够带来数据隐藏的好处,利于将来程序的扩充,但也会增加程序书写的麻烦。

C++ 是从结构化的 C 语言发展而来的,需要照顾结构化设计程序员的习惯,所以在对私有成员可访问范围的问题上不可限制太死。C++ 设计者认为,如果有的程序员真的非常怕麻烦,就是想在类的成员函数外部直接访问对象的私有成员,那还是做一点妥协以满足他们的愿望为好,这也算是眼前利益和长远利益的折中。因此,C++ 就有了 友元(friend) 的概念。打个比方,这相当于是说:朋友是值得信任的,所以可以对他们公开一些自己的隐私。

友元提供了一种 普通函数或者类成员函数访问另一个类中的私有或保护成员 的机制。也就是说有两种形式的友元:

  1. 友元函数:普通函数访问某个类中的私有或保护成员。
  2. 友元类:类 A 中的成员函数访问类 B 中的私有或保护成员。
基类的构造函数 / 析构函数是否能被派生类继承?

基类的构造函数和析构函数不能被派生类继承。

  • 基类的构造函数:不能被派生类继承,派生类中需要声明自己的构造函数。设计派生类的构造函数时,不仅要考虑派生类所增加的数据成员初始化,也要考虑基类的数据成员的初始化。声明构造函数时,只需要对本类中新增成员进行初始化,对继承来的基类成员的初始化,需要调用基类构造函数完成。
  • 基类的析构函数:也不能被派生类继承,派生类需要自行声明析构函数。声明方法与一般(无继承关系时)类的析构函数相同,不需要显式地调用基类的析构函数,系统会自动隐式调用。需要注意的是,析构函数的调用次序与构造函数相反。
初始化列表和构造函数初始化的区别?

构造函数初始化列表以一个冒号开始,接着是以逗号分隔的数据成员列表,每个数据成员后面跟一个放在括号中的初始化式。例如:

Example::Example () : ival (0), dval (0.0) {} // ival 和 dval 是类的两个数据成员

上面的例子和下面不用初始化列表的构造函数看似没什么区别:

Example::Example ()
{
    ival = 0;
    dval = 0.0;
}

的确,这两个构造函数的结果是一样的。但区别在于:上面的构造函数(使用初始化列表的构造函数)显示地初始化类的成员;而没使用初始化列表的构造函数是对类的成员赋值,并没有进行显示的初始化。

初始化和赋值对内置类型的成员没有什么大的区别,像上面的任一个构造函数都可以。但有的时候必须用带有初始化列表的构造函数:

  1. 当成员类型是没有默认构造函数的类。若没有提供显示初始化式,则编译器隐式使用成员类型的默认构造函数,若类没有默认构造函数,则编译器尝试使用默认构造函数将会失败。
  2. const 成员或引用类型的成员。因为 const 对象或引用类型只能初始化,不能对他们赋值。
C++ 中有哪些情况只能用初始化列表,而不能用赋值?

构造函数初始化列表以一个冒号开始,接着是以逗号分隔的数据成员列表,每个数据成员后面都跟一个放在括号中的初始化式。例如,Example::Example() : ival(0), dval(0.0) {},其中 ivaldval 是类的两个数据成员。

在 C++ 语言中,赋值与初始化列表的原理不一样,赋值是删除原值,赋予新值,初始化列表开辟空间和初始化是同时完成的,直接给予一个值。

所以,在 C++ 中,赋值与初始化列表的使用情况也不一样,只能用初始化列表,而不能用赋值的情况一般有以下 3 种:

  1. 当类中含有 const(常量)、reference(引用)成员变量时,只能初始化,不能对它们进行赋值。常量不能被赋值,只能被初始化,所以必须在初始化列表中完成,C++ 的引用也一定要初始化,所以必须在初始化列表中完成。
  2. 派生类在构造函数中要对自身成员初始化,也要对继承过来的基类成员进行初始化。当基类没有默认构造函数的时候,通过在派生类的构造函数初始化列表中调用基类的构造函数实现。
  3. 如果成员类型是没有默认构造函数的类,也只能使用初始化列表。若没有提供显式初始化时,则编译器隐式使用成员类型的默认构造函数,此时编译器尝试使用默认构造函数将会失败。
类的成员变量的初始化顺序是什么?
  1. 成员变量在使用初始化列表初始化时,与构造函数中初始化成员列表的顺序无关,只与定义成员变量的顺序有关。因为成员变量的初始化次序是根据变量在内存中的次序有关,而内存中的排列顺序早在编译期就根据变量的定义次序决定了。
  2. 如果不使用初始化列表初始化,在构造函数内初始化时,此时与成员变量在构造函数中的位置有关。
  3. 注意:类成员在定义时,是不能初始化的。
  4. 注意:类中 const 成员常量必须在构造函数初始化列表中初始化。
  5. 注意:类中 static 成员变量,必须在类外初始化。
  6. 静态变量进行初始化顺序是基类的静态变量先初始化,然后是它的派生类。直到所有的静态变量都被初始化。这里需要注意全局变量和静态变量的初始化是不分次序的。这也不难理解,其实静态变量和全局变量都被放在公共内存区。可以把静态变量理解为带有 “作用域” 的全局变量。在一切初始化工作结束后,main 函数会被调用,如果某个类的构造函数被执行,那么首先基类的成员变量会被初始化。
当一个类为另一个类的成员变量时,如何对其进行初始化?

示例程序如下:

class ABC
{
public:
    ABC (int x, int y, int z);

private :
    int a;
    int b;
    int c;
};

class MyClass
{
public:
    MyClass () : abc (1, 2, 3)
    {
    }

private:
    ABC abc;
};

上例中,因为 ABC 有了显式的带参数的构造函数,那么它是无法依靠编译器生成无参构造函数的,所以必须使用初始化列表:abc (1, 2, 3),才能构造 ABC 的对象。

C++ 能设计实现一个不能被继承的类吗?

在 Java 中定义了关键字 final,被 final 修饰的类不能被继承。但在 C++ 中没有 final 这个关键字,要实现这个要求还是需要花费一些精力。

首先想到的是在 C++ 中,子类的构造函数会自动调用父类的构造函数。同样,子类的析构函数也会自动调用父类的析构函数。要想一个类不能被继承,我们只要把它的构造函数和析构函数都定义为私有函数。那么当一个类试图从它那继承的时候,必然会由于试图调用构造函数、析构函数而导致编译错误。

可是这个类的构造函数和析构函数都是私有函数了,我们怎样才能得到该类的实例呢?这难不倒我们,我们可以通过定义静态函数来创建和释放类的实例。

基于这个思路,我们可以写出如下的代码:

/ Define a class which can't be derived from  ///
class FinalClass1
{
public :
    static FinalClass1* GetInstance ()
    {
        return new FinalClass1;
    }

    static void DeleteInstance (FinalClass1* pInstance)
    {
        delete pInstance;
        pInstance = 0;
    }

private :
    FinalClass1 () {}
    ~FinalClass1 () {}
};

这个类是不能被继承,但在总觉得它和一般的类有些不一样,使用起来也有点不方便。比如,我们只能得到位于堆上的实例,而得不到位于栈上实例。能不能实现一个和一般类除了不能被继承之外其他用法都一样的类呢?办法总是有的,不过需要一些技巧。请看如下代码:

/ Define a class which can't be derived from  ///
template <typename T> class MakeFinal
{
friend T;
private :
    MakeFinal () {}
    ~MakeFinal () {}
};

class FinalClass2 : virtual public MakeFinal<FinalClass2>
{
public :
    FinalClass2 () {}
    ~FinalClass2 () {}
};

这个类使用起来和一般的类没有区别,可以在栈上、也可以在堆上创建实例。尽管类 MakeFinal<FinalClass2> 的构造函数和析构函数都是私有的,但由于类 FinalClass2 是它的友元函数,因此在 FinalClass2 中调用 MakeFinal<FinalClass2> 的构造函数和析构函数都不会造成编译错误。但当我们试图从 FinalClass2 继承一个类并创建它的实例时,却不能通过编译。

class Try : public FinalClass2
{
public :
    Try () {}
    ~Try () {}
};    Try temp;

由于类 FinalClass2 是从类 MakeFinal<FinalClass2> 虚继承过来的,在调用 Try 的构造函数的时候,会直接跳过 FinalClass2 而直接调用 MakeFinal<FinalClass2> 的构造函数。非常遗憾的是 Try 不是 MakeFinal<FinalClass2> 的友元,因此不能调用其私有的构造函数。

基于上面的分析,试图从 FinalClass2 继承的类,一旦实例化,都会导致编译错误,因此 FinalClass2 不能被继承。这就满足了我们设计要求。

构造函数没有返回值,那么如何得知对象是否构造成功?

这里的 “构造” 不单指分配对象本身的内存,而是指在建立对象时做的初始化操作(如打开文件、连接数据库等)。

因为构造函数没有返回值,所以通知对象的构造失败的唯一方法就是在构造函数中抛出异常。构造函数中抛出异常将导致对象的析构函数不被执行,当对象发生部分构造时,已经构造完毕的子对象将会逆序地被析构。

Public 继承、protected 继承、private 继承的区别?

Public(公有)继承
对于子类的对象而言,采用公有继承时,基类成员对子类对象的可见性与一般类成员对对象的可见性相同,公有成员可见,其他成员不可见。
对于子类而言,基类的公有成员和保护成员可见;基类的公有成员和保护成员作为派生类的成员时,它们都维持原有的可见性(基类 public 成员在子类中还是 public,基类 protected 成员在子类中还是 protected);基类的私有成员不可见,基类的私有成员依然是私有的,子类不可访问。

Protected(保护)继承
保护继承的特点是:基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元访问。基类的私有成员仍然是私有的。由此可以看出,基类的所有成员对子类的对象都是不可见的。

Private(私有)继承
私有继承的特点是,基类的公有成员和保护成员都作为派生类的私有成员,并且不能被这个派生类的子类所访问。

C++ 提供默认参数的函数吗?

C++ 可以给函数定义默认参数值。在函数调用时没有指定与形参相对应的实参时,就自动使用默认参数。

默认参数的语法与使用

  1. 在函数声明或定义时,直接对参数赋值,这就是默认参数。
  2. 在函数调用时,省略部分或全部参数。这时可以用默认参数来代替。

通常调用函数时,要为函数的每个参数给定对应的实参。例如:

void delay (int loops = 1000); // 函数声明

void delay (int loops) // 函数定义
{
    if (loops == 0)
    {
        return;
    }

    for (int i = 0; i < loops; i++);
}

在上例中,如果将 delay() 函数中的 loops 定义成默认值 1000,这样,以后无论何时调用 delay() 函数,都不用给 loops 赋值,程序都会自动将它当做值 1000 进行处理。例如,当执行 delay(2500) 调用时,loops 的参数值为显性化的,被设置为 2500;当执行 delay() 时,loops 将采用默认值 1000。

默认参数在函数声明中提供,当有声明又有定义时,定义中不允许默认参数。如果函数只有定义,则默认参数才可出现在函数定义中。例如:

void point (int = 3, int = 4); // 声明中给出默认值

void point (int x, int y) // 定义中不允许再给出默认值
{
    cout << x << endl;
    cout << y << endl;
}

如果一组重载函数(可能带有默认参数)都允许相同实参个数的调用,将会引起调用的二义性。例如:

void func (int); // 重载函数之一
void func (int, int = 4); // 重载函数之二,带有默认参数
void func (int = 3, int = 4); // 重载函数三,带有默认参数

func (7); // 错误:到底调用 3 个重载函数中的哪个?
func (20, 30); // 错误:到底调用后面两个重载函数的哪个?

虚函数

什么是虚函数?

指向基类的指针在操作它的多态类对象时,可以根据指向的不同类对象调用其相应的函数,这个函数就是虚函数。

虚函数的作用:在基类定义了虚函数后,可以在派生类中对虚函数进行重新定义,并且可以通过基类指针或引用,在程序的运行阶段动态地选择调用基类和不同派生类中的同名函数。(如果在派生类中没有对虚函数重新定义,则它继承其基类的虚函数。)

下面是一个虚函数的实例程序:

#include "stdafx.h"
#include <iostream>

using namespace std;

class Base
{
public:
    virtual void Print () // 父类虚函数
    {
        printf ("This is Class Base!\n");
    }
};

class Derived1 : public Base
{
public:
    void Print () override // 子类 1 虚函数
    {
        printf ("This is Class Derived1!\n");
    }
};

class Derived2 : public Base
{
public:
    void Print () override // 子类 2 虚函数
    {
        printf ("This is Class Derived2!\n");
    }
};

int main ()
{
    Base Cbase;
    Derived1 Cderived1;
    Derived2 Cderived2;

    Cbase.Print ();
    Cderived1.Print ();
    Cderived2.Print ();

    cout << "---------------" << endl;

    Base *p1 = &Cbase;
    Base *p2 = &Cderived1;
    Base *p3 = &Cderived2;

    p1->Print ();
    p2->Print ();
    p3->Print ();

    return 0;
}

输出结果:

This is Class Base!
This is Class Derived1!
This is Class Derived2!
---------------
This is Class Base!
This is Class Derived1!
This is Class Derived2!

需要注意的是,虚函数虽然非常好用,但是在使用虚函数时,并非所有的函数都需要定义成虚函数,因为实现虚函数是有代价的。在使用虚函数时,需要注意以下几个方面的内容:

  1. 只需要在声明函数的类体中使用关键字 virtual 将函数声明为虚函数,而定义函数时不需要使用关键字 virtual
  2. 当将基类中的某一成员函数声明为虚函数后,派生类中的同名函数自动成为虚函数。
  3. 非类的成员函数不能定义为虚函数,全局函数以及类的成员函数中静态成员函数和构造函数也不能定义为虚函数,但可以将析构函数定义为虚函数。
  4. 基类的析构函数应该定义为虚函数,否则会造成内存泄漏。基类析构函数未声明 virtual,基类指针指向派生类时,delete 指针不调用派生类析构函数。有 virtual,则先调用派生类析构再调用基类析构。
C++ 如何实现多态?

C++ 中通过虚函数实现多态。虚函数的本质就是通过基类指针访问派生类定义的函数。每个含有虚函数的类,其实例对象内部都有一个虚函数表指针。该虚函数表指针被初始化为本类的虚函数表的内存地址。所以,在程序中,不管对象类型如何转换,该对象内部的虚函数表指针都是固定的,这样才能实现动态地对对象函数进行调用,这就是 C++ 多态性的原理。

纯虚函数指的是什么?

纯虚函数是一种特殊的虚函数,格式一般如下:

class <类名>
{
    virtual <函数返回值类型> <虚函数名>(形参表)= 0;
    ...
};

由于在很多情况下,基类中不能对虚函数给出有意义的实现,只能把函数的实现留给派生类。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但是动物本身生成对象不合情理,此时就可以将动物类中的函数定义为纯虚函数。如果基类中有纯虚函数,那么在子类中必须实现这个纯虚函数,否则子类将无法被实例化,也无法实现多态。

含有纯虚函数的类称为抽象类,抽象类不能生成对象。纯虚函数永远不会被调用,它们主要用来统一管理子类对象。

什么函数不能声明为虚函数?

常见的不能声明为虚函数的有:普通函数(非成员函数);静态成员函数;内联成员函数;构造函数;友元函数。

  1. 为什么 C++ 不支持普通函数为虚函数?
    普通函数(非成员函数)只能被 overload,不能被 override,声明为虚函数也没有什么意思,因此编译器会在编译时绑定函数。

  2. 为什么 C++ 不支持构造函数为虚函数?
    主要是从语义上考虑,构造函数本来就是为了明确初始化对象成员才产生的,而虚函数主要是为了在不完全了解细节的情况下也能正确处理对象。构造函数是在对象创建时调用的,此时对象尚未完全构造,无法使用虚函数机制。

  3. 为什么 C++ 不支持内联成员函数为虚函数?
    内联函数是为了在代码中直接展开,减少函数调用的开销,而虚函数是为了在继承后对象能够准确地执行自己的动作,这两者是矛盾的。内联函数在编译时展开,而虚函数在运行时动态绑定。

  4. 为什么 C++ 不支持静态成员函数为虚函数?
    静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,它没有动态绑定的必要性。

  5. 为什么 C++ 不支持友元函数为虚函数?
    因为 C++ 不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。

C++ 中如何阻止一个类被实例化?

C++ 中可以通过使用抽象类,或者将构造函数声明为 private 阻止一个类被实例化。抽象类之所以不能被实例化,是因为抽象类不能代表一类具体的事物,它是对多种具有相似性的具体事物的共同特征的一种抽象。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但是动物本身生成对象不合情理。

结语

资料如有错误或者不合适的地方,请及时联系作者。


via:


网站公告

今日签到

点亮在社区的每一天
去签到