嵌入式Linux八股(一)——语言篇

一、语言篇

01.C语言基础

01.static关键字作用

控制变量和函数的生命周期、限制作用域、实现文件内的私有性以及定义类的静态成员。

1.函数内的静态变量:在函数内部定义的变量可以使用 static 修饰符进行声明,使其成为静态变量。静态变量的生命周期延长到整个程序运行期间,而不是只在函数调用时存在。

void test()
{
     static int i = 0;//对于static修饰的变量,如果未初始化默认为0
    //static int i=0; static int i;int i;三种结果一致
    //但是对于int i=0;结果全为0
     printf("%d ", i);
     i++;
}
int main(int argc, char *argv[])
{
     for (int i = 0; i < 5; i++)
     {
          test();
     }
     printf("\n");
     return 0;
}
//输出 0 1 2 3 4

2.文件内的静态变量和函数:在文件中定义的变量和函数可以使用 static 修饰符进行声明,使其成为文件内的私有变量或函数,限制外部链接性。static 修饰的变量或函数只能在当前文件内访问,无法被其他文件引用

3.类的静态成员变量和静态成员函数:在类中定义的静态成员变量和静态成员函数属于整个类,而不是类对象的一部分。静态成员变量只有一个副本,被所有类对象共享;静态成员函数可以直接通过类名访问,而无需创建类对象。

#include <iostream>
​
class MyClass {
public:
    static int count; // 静态成员变量
    int id;
​
    MyClass() {
        count++; // 在构造函数中对静态成员变量进行自增操作
        id = count;
    }
​
    static void printCount() { // 静态成员函数
        std::cout << "Total objects created: " << count << std::endl;
    }
};
​
int MyClass::count = 0; // 静态成员变量的定义和初始化
​
int main() {
    MyClass obj1;
    MyClass obj2;
    MyClass obj3;
​
    MyClass::printCount(); // 直接通过类名调用静态成员函数
​
    return 0;
}
//输出结果为: Total objects created: 3 

02.extern

extern 关键字在 C 和 C++ 中有不同的用法和作用。

在 C 中,extern 关键字用于声明一个全局变量或函数,表示该变量或函数是在其他文件中定义的。通过使用 extern 关键字,可以在当前文件中引用其他文件中定义的全局变量或函数。

示例: 假设我们有两个文件:main.cutils.c

utils.c 文件中定义了一个全局变量 int globalVar = 10;,以及一个函数 void utilsFunc();

现在我们想在 main.c 文件中使用 globalVarutilsFunc(),那么可以在 main.c 文件中使用 extern 关键字进行声明:

// main.c
extern int globalVar;
extern void utilsFunc();

int main() {
    utilsFunc(); // 调用 utilsFunc 函数
    printf("Global variable: %d\n", globalVar); // 使用 globalVar 变量
    return 0;
}

这样,在 main.c 文件中就能够引用 utils.c 文件中定义的全局变量和函数。

在 C++ 中,extern 关键字的用法与 C 中类似,但还可以用于声明外部链接的变量和函数,以及显式实例化模板。

示例:

// utils.cpp
int globalVar = 10;

void utilsFunc() {
    // 函数实现
}
// main.cpp
extern int globalVar;
extern void utilsFunc();

int main() {
    utilsFunc(); // 调用 utilsFunc 函数
    cout << "Global variable: " << globalVar << endl; // 使用 globalVar 变量
    return 0;
}

在这个示例中,我们使用 extern 关键字声明了 globalVarutilsFunc(),使得在 main.cpp 文件中可以引用 utils.cpp 文件中定义的全局变量和函数。

总结一下,extern 关键字的作用是在当前文件中声明一个全局变量或函数,表明它是在其他文件中定义的。通过使用 extern 关键字,可以在当前文件中引用其他文件中定义的全局变量或函数。

03.extern "C"

extern "C" 是一个用于 C++ 中的关键字,用于指示编译器按照 C 语言的约定进行函数的命名和调用约定,实现类C和C++的混合编程。在C++源文件中的语句前面加上extern "C",表明它按照类C的编译和连接规约来编译和连接,而不是C++的编译的连接规约。这样在类C

在 C 中,函数的名称和参数列表会经过一种称为名称修饰(name mangling)的过程,以支持函数重载和其他特性。这使得 C 函数无法直接与 C 语言中的函数进行链接。

使用 extern "C" 关键字可以告诉编译器将某个函数的链接规范设置为 C 语言的规范,从而使得该函数能够与 C 语言代码进行兼容

示例:

// C++ 源文件
#include <iostream>

extern "C" {
    void func(); // 使用 extern "C" 关键字声明函数
}

void func() {
    std::cout << "Hello from C++!" << std::endl;
}

int main() {
    func(); // 调用 func 函数
    return 0;
}

在上面的示例中,我们在 C++ 源文件中使用 extern "C" 关键字声明了一个函数 func()。此时编译器会按照 C 语言的函数命名和调用约定来处理该函数。

这样,在 C++ 中定义的函数 func() 就可以被其他使用 C 语言编写的代码所链接和调用。

需要注意的是,extern "C" 只适用于函数的声明,而不适用于函数的定义。因此,我们通常将 extern "C" 的声明放在头文件中,并将函数的定义放在源文件中。

04.const关键字

1.修饰局部变量 用const修饰变量时,一定要给变脸初始化,否则之后就不能再进行赋值了。

2.常量指针与指针常量(const 后面的整体不能变)

a.常量指针:指针指向的内容是常量。

const int *p
int const *p
#include<stdio.h>
int main()
{
    int a=5,b=10;
    const int*p=&a;
    //*p=20;//错误,不能通过这个指针改变变量的值
    a=20;
    printf("%p %d\n",p,*p);//0x7ffe6f512b98 20
    p=&b;//常量指针指向的值不能改变,但是常量指针可以指向其他的地址
    printf("%p %d\n",p,*p);//0x7ffe6f512b9c 10
    return 0;
}

b.指针常量:是指指针本身是个常量,不能在指向其他的地址

int* const p

需要注意的是,指针常量指向的地址不能改变,但是地址中保存的数值是可以改变的,可以通过其他指向改地址的指针来修改。

int a=5;
int* const p=&a;
a=6;

c.指向常量的常指针

是以上两种的结合,指针指向的位置不能改变并且也不能通过这个指针改变变量的值,但是依然可以通过其他的普通指针改变变量的值。

const int* const p

3.修饰函数参数

1、防止修改指针指向的内容

void strcpy(char *strDestination, const char *strSource);

其中 strSource 是输入参数,strDestination 是输出参数。给 strSource 加上 const 修饰后,如果函数体内的语句试图改动 strSource 的内容,编译器将指出错误。

2、防止修改指针指向的地址

void swap ( int * const p1 , int * const p2 )

指针p1和指针p2指向的地址都不能修改。

4.修饰函数返回值

如果给以“指针传递”方式的函数返回值加 const 修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const 修饰的同类型指针。

const char * GetString(void);
如下语句将出现编译错误:
char *str = GetString();
正确的用法是
const char *str = GetString();

05.const与define区别

  1. #define 宏是在预处理阶段展开; const 常量是编译运行阶段使用。

  2. #define 宏没有类型,不做任何类型检查,仅仅是展开。const 常量有具体的类型,在编译阶段会执行类型检查。

  3. 宏定义不分配内存,变量定义分配内存。const 常量会在内存中分配(可以是堆中也可以是栈中)。

  4. const 可以节省空间,避免不必要的内存分配。 例如:

#define NUM 3.14159 //常量宏
const doulbe Num = 3.14159; //此时并未将Pi放入ROM中 ......
double i = num; //此时为Pi分配内存,以后不再分配!
double I = NUM; //编译期间进行宏替换,分配内存
double j = num; //没有内存分配
double J = NUM; //再进行宏替换,又一次分配内存!

(5)宏替换只作替换,不做计算,不做表达式求解

06.voliate

voliate的作用是作为指令关键字,用voliate 声明的变量表示该变量随时可能发生变化(因为编译器优化时可能将其放入寄存器中)告诉编译器不做任何优化,每次从内存中直接读取值,而不是使用寄存器中的副本 因为访问寄存器要比访问内存单元快的多,所以编译器一般都会作减少存取内存的优化,但有可能会读脏数据。当要求使用volatile声明变量值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。

主要用途:

  1. 多任务环境下各任务间共享的标志:在多线程程序中,某些变量可能会被多个线程同时访问和修改,为了确保变量的可见性和一致性,可以使用 volatile 关键字修饰这些变量。

  2. 中断处理:当一个变量在断服务程序中被修改,而在程序的其它位置被检测,就需要考虑加volatile

  3. 读硬件寄存器时(如某传感器的端口/裸机程序编写时)并行设备的硬件寄存器。存储器映射的硬件寄存器通常加volatile,因为寄存器随时可以被外设硬件修改。当声明指向设备寄存器的指针时一定要用volatile,告诉编译器不要对存储在这个地址的数据进行假设。

07.手写str相关函数

  • strlen 用于计算字符串的长度,即该字符串中非空字符的个数,不包括结尾符号 \0

    int strlen(const char*str){
        if(str==NULL)return NULL;
        int len=0;
        while ((*str)!='\0')
        {
            len++;
            str++;
        }
        return len;
    }

  • strcpy 用于将一个字符串复制到另一个字符串中。

    char *strcpy(char *dest, const char *src){
        assert(dest&&src);
        char*ret=dest;//用于保存目标字符串的起始位置,并将其作为最终的返回值。
        while((*dest++=*src++)!='\0');
        return ret;
    }

  • strcat 用于将源字符串追加到目标字符串的末尾。

    char *strcat(char *dest, const char *src){
        assert(dest&&src);
        char*ret=dest;
        while((*dest++)!='\0');
        dest--;
        while ((*dest++=*src++)!='\0');   
        return ret;
    }

  • strcmp 函数在C语言中是用来比较两个字符串的函数

    int strcmp(const char *str1, const char *str2){
        assert(str1&&str2);
        int ret=0;
        while(*str1==*str2){
            if(*str1=='\0')
                return 0;
            str1++;
            str2++;
        }
        ret=*str1-*str2;
        if(ret>0)return 1;
        else if(ret<0)return -1;
        return 0;
    }

  • strstr 用于在一个字符串中查找另一个字符串。

08.malloc/calloc/realloc/mem*

//在堆区上申请size大小的空间,返回堆区上这个空间的起始地址,开辟失败会返回一个空指针
void* malloc (size_t size);//并不会对内存进行初始化,所以它分配的内存块中的内容是未定义的(即垃圾值)

//在堆区上申请num个size大小的空间,返回堆区上这个空间的起始地址,并且把所有元素初始化成0
void* calloc (size_t num, size_t size);

//调整动态内存空间大小,返回值为调整之后的内存起始位置
void* realloc (void* ptr, size_t size);

在 C 语言中,有四个常用的内存操作函数,它们分别是:

  1. memcpy():用于在内存块之间进行复制。它的原型如下:

    void* memcpy(void* dest, const void* src, size_t n);

    其中 dest 是目标内存块的指针,src 是源内存块的指针,n 是要复制的字节数。该函数将从源内存块复制 n 字节的数据到目标内存块中。

  2. memset():用于在内存块中设置指定值。它的原型如下:

    void* memset(void* ptr, int value, size_t n);

    其中 ptr 是要设置值的内存块的指针,value 是要设置的值(通常是一个无符号字符),n 是要设置的字节数。该函数将内存块的前 n 个字节设置为指定值。

  3. memcmp():用于比较两个内存块的内容。它的原型如下:

    int memcmp(const void* ptr1, const void* ptr2, size_t n);

    其中 ptr1ptr2 分别是要比较的两个内存块的指针,n 是要比较的字节数。该函数按字节比较两个内存块的内容,如果两个内存块相等,则返回 0;如果 ptr1 指向的内容小于 ptr2 指向的内容,则返回一个负值;如果 ptr1 指向的内容大于 ptr2 指向的内容,则返回一个正值。

  4. memmove():与 memcpy() 类似,也用于在内存块之间进行复制,但 memmove() 能够处理重叠的内存块。它的原型如下:

    void* memmove(void* dest, const void* src, size_t n);

    其中 dest 是目标内存块的指针,src 是源内存块的指针,n 是要复制的字节数。与 memcpy() 不同的是,memmove() 能够处理源内存块和目标内存块重叠的情况,因此更加安全。

09.手写atoi()和atof()

  • atoi()将字符串转换成一个 32 位有符号整数,atoi()会扫描参数字符串,跳过前面的空格字符,直到遇上数字或正负号才开始做转换,而再遇到非数字或字符串时(“0”)才结束转化,并将结果返回(返回转换后的整型数)

    int atoi(char* str) {
        assert(str);
        while (*str == ' ') {
            str++; //跳过字符串开头的空格字符
        }
        long long ans = 0;
        int sign = 1;
        if (*str == '-') {
            sign = -1;
            str++;
        } else if (*str == '+') {
            str++;
        }
        while (*str >= '0' && *str <= '9') {
            ans = ans * 10 + sign * (*str - '0');
            str++;
            if (ans > 0 && ans > INT_MAX)
                ans = INT_MAX;//2147483647
            else if (ans < 0 && ans < INT_MIN)
                ans = INT_MIN;//-2147483648
        }
        return ans;
    }

  • atof 函数用于将字符串转换为对应的浮点数。

10.宏定义

  1. 用宏来实现比较两个值的大小并返回较小值

#define MIN(a, b) ((a) < (b) ? (a) : (b))
  1. 写一个宏定义,不用中间变量,实现两变量的交换

#define swap(a,b) a+=b;b=a-b;a=a-b;
#define swap(a,b) a=a^b;b=a^b;a=a^b;//连续跟同一个数异或两次则得到它本身
  1. C语言自定义寄存器操作

//寄存器地址的定义
#define UART_BASE_ADRS (0x10000000)     /* 串口的基地址 */
#define UART_RHR *(volatile unsigned char *)(UART_BASE_ADRS + 0)  /* 数据接受寄存器 */
#define UART_THR *(volatile unsigned char *)(UART_BASE_ADRS + 0)  /* 数据发送寄存器 */

//寄存器读写操作
UART_THR = ch; /* 发送数据 */
ch = UART_RHR; /* 接收数据 */
#define WRITE_REG(addr, ch) *(volatile unsigned char *)(addr) = ch
#define READ_REG(addr, ch) ch = *(volatile unsigned char *)(addr)

11.定义和声明

变量的声明和定义: 声明是仅仅告诉编译器,有个某类型的变量会被使用,但是编译器并不会为它分配任何内存。而定义就是分配了内存。 函数的声明和定义:声明一般在头文件里,对编译器说:这里我有一个函数叫function() 让编译器知道这个函数的存在。定义:一般在源文件里,具体就是函数的实现过程写明函数体。

  • 变量和函数未声明的错误产生于编译阶段,编译阶段检查的是语法错误

  • 变量和函数未定义的错误产生于链接阶段,链接阶段关心的是怎么实现

12.位操作

  • 指定位反转

#define REVERSE_BIT(num, pos) ((num) ^ (1 << (pos)))
//用于反转给定数字 num 的第 pos 位。
//和0异或等于本身,和1异或等于取反
unsigned int reverse_bit(unsigned int num, int pos) {
    return num ^ (1 << pos);//具体实现是将 1 左移 pos 位,然后与 num 进行异或操作,即可实现指定位的反转。
}
  • 指定位清零

unsigned int clear_bit(unsigned int num, int pos) {
    return num & ~(1 << pos);
}
  • 提取某一位

    unsigned int get_bit(unsigned int num, int pos) {
        return (num & (1 << pos)) >> pos;
    }
  • 一个整数的每个比特是0还是1

    void checkBits(int num) {
        for (int i = 31; i >= 0; i--) {
            int bit = (num >> i) & 1;
            printf("%d", bit);
        }
        printf("\n");
    }  
  • 指定位置1

unsigned int set_bit(unsigned int num, int pos) {
    return num | (1 << pos);
}

13.大小端

大端存储模式:数据的低位保存在内存中的高地址中,数据的高位保存在内存中的低地址中; 小端存储模式:数据的低位保存在内存中的低地址中,数据的高位保存在内存中的高地址中;

在这里插入图片描述

14.如何判断大小端存储

使用字节

int check_endianness() {
   	int n=0x11223344;
    char a=n;
    // 如果最低地址存放的是最低有效位,则为小端存储
    if (a==68) {
        return 1; // 小端存储
    } else {
        return 0; // 大端存储
    }
}

使用指针,类型强转

#include <stdio.h>

int check_endianness() {
    unsigned int num = 1;
    char *ptr = (char *)&num;

    // 如果最低地址存放的是最低有效位,则为小端存储
    if (*ptr) {
        return 1; // 小端存储
    } else {
        return 0; // 大端存储
    }
}

使用union

#include <stdio.h>
//联合体变量中的成员是共用一个首地址,共占同一段内存空间,所以在任意时刻只能存放其中一个成员的值。
union {
    int i;
    char c;
} test;

int main() {
    test.i = 1;
    if (test.c == 1) {
        printf("This system is little endian.\n");
    } else {
        printf("This system is big endian.\n");
    }
    return 0;
}

15.函数栈帧

C语言之函数栈帧(动图详解)_函数调用栈帧过程(带图详解)-CSDN博客

CPU眼里的:{函数括号} | 栈帧 | 堆栈 | 栈变量哔哩哔哩bilibili

1.函数正式调用(call)前会进行形参实例化,分配存储空间,形参实例化的顺序是从右向左。

2.临时空间的开辟,是在对应函数栈帧的内部通过mov命令的方式开辟的。

3.函数调用完毕,栈帧结构被释放。

4.临时变量具有临时性的本质是:栈帧具有临时性。

5.调动函数是有成本的,体现在时间和空间上,本质是形成和释放栈帧有成本。

6.函数调用,因拷贝而形成的临时变量,变量和变量之间的位置关系是有规律的。

7.函数的栈帧是自己形成的,esp减多少是由编译器决定的。即栈帧的大小是由编译器决定的。编译器有能力知道所有类型对应定义变量的大小。

1.相关寄存器 eax 通用寄存器,保存临时数据,常用于返回值 ebx 通用寄存器,保存临时数据 ebp 栈底寄存器 esp 栈顶寄存器 eip 指令寄存器,保存当前指令的下一条指令的地址

2.部分汇编指令 push 数据入栈,同时esp栈顶寄存器也要发生改变 pop 数据弹出至指定位置,同时esp栈顶寄存器也要发生改变 call 函数调用,1.压入返回地址 2.转入目标函数 jump 通过修改eip,转入目标函数,进行调用 ret 恢复返回地址,压入eip,类似于pop eip指令

16.递归

函数递归指的是在函数内部调用自身的过程。

递归的实现就是:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因

优缺点:

  • 简洁性:递归可以用较少的代码实现复杂的功能

  • 灵活性:递归可以应对未知深度的数据结构,因为它不需要提前知道要处理的嵌套层级

  • 栈溢出:如果递归深度过大或者没有正确的终止条件,递归函数可能会导致栈溢出,从而导致程序崩溃。

  • 隐式堆栈:递归调用会创建隐式的函数调用堆栈,其中保存了每个递归调用的状态。如果递归层数很深,堆栈可能会占用大量内存空间,从而增加程序的内存消耗。

17.strlen和sizeof

  • sizeof 是C 语言的一种单目运算符,sizeof以字节的形式给出操作数的存储空间的大小,在字符数组中会统计‘\0’所占空间,sizeof 也可以对一个函数调用求值,其结果是函数返回类型的大小。sizeof不能计算动态分配空间的大小。对于整数常量,如 sizeof(1),它并不是表示 1 的大小,而是表示 1 所属的数据类型的大小。

  • strlen 是一个库函数。strlen 计算的是字符串的长度。

int main()
{
    char arr[] = "123456789";
    printf("%ld\n", strlen(arr));//9
    printf("%ld\n", sizeof(arr));//10
    //因为不是字符串,所以末尾没有\0;没有\0,当使用strlen函数进行计算是就不知道在哪里结束;计算结果就是我们想不到的随机值(
    char arr1[] = {'1','2','3','4','5','6','7','8','9'};
    printf("%ld\n", strlen(arr1));//18
    printf("%ld\n", sizeof(arr1));//9
    printf("%ld\n", sizeof(1));   //4
    printf("%ld\n", sizeof(1.1)); //8
    return 0;
}

18.字符串库函数

  1. sprintf

    //sprintf() 函数会根据 format 字符串中的格式说明符,将可变参数列表中的数据转换为字符串,并将结果写入到 str 指向的字符串中。	返回值:字符串长度
    int sprintf(char *str, const char *format, ...);
    
    
    int main()
    {
        char buffer[50] = ""; // 存储输出字符串的缓冲区
        int value = 255;
        float floatValue = 3.14;
        // Integer: 123, Float: 3.14
        sprintf(buffer, "Integer: %d, Float: %.2f", value, floatValue);
    
        // 打印输出的字符串
        printf("%s\n", buffer);
        sprintf(buffer, "Hex value:%x,%X,%4X,%04X", value,value,255,255); // 输出为 "Hex value:ff,FF,  FF,00FF"
        printf("%s\n", buffer);
        return 0;
    }
  2. sscanf

    //用于按照指定的格式从字符串中读取数据并存储到变量中
    int sscanf(const char *str, const char *format, ...);
    
    #include <stdio.h>
    #include <string.h>
    
    int main()
    {
        char str[] = "$AZCT,P,ACQU,38.77V,45.05mA,120.02GB*hh";
        char header[10] = "", tail[10] = "", state[10] = "";
        float voltage = 0, current = 0, memory_free = 0;
    
        /*  %[^,]:表示提取一个不包含逗号的字符串,直到遇到逗号为止。逗号是作为分隔符来使用的。
           %*[^,]:表示跳过一个不包含逗号的字符串,即不保存其值。*/
        sscanf(str, "%[^,],P,%[^,],%fV,%fmA,%fGB%s", header, state, &voltage, &current, &memory_free, tail);
    
        return 0;
    }
  3. strchr

    //strchr() 函数用于在字符串中查找指定字符的第一次出现的位置,并返回该字符在字符串中的地址。如果找到了指定字符,则返回该字符的地址;如果没有找到,则返回 NULL。
    char *strchr(const char *str, int c);
  4. strtok

    //strtok() 函数用于将字符串分割成一个个标记(token)。该函数会在字符串中找到指定的分隔符(delimiter)出现的位置,并将字符串分割成两部分,返回第一个分割出来的标记,并在内部维护一个静态指针指向剩余的未处理部分。
    //delim为分隔符字符(如果传入字符串,则传入的字符串中每个字符均为分割符)
    char *strtok(char *str, const char *delim);
    
    #include <stdio.h>
    #include <string.h>
    
    int main() {
        char str[] = "Hello,world;how-are:you";
        char *token;
        const char *delim = ",;-:"; // 字符串形式的分隔符,包含多个字符
    
        // 第一次调用 strtok(),传入待分割的字符串
        token = strtok(str, delim);
    
        // 循环调用 strtok() 获取分割出的标记,直到返回 NULL
        while (token != NULL) {
            printf("Token: %s\n", token);
            // 继续调用 strtok(),传入 NULL,获取下一个分割出的标记
            token = strtok(NULL, delim);
        }
    
        return 0;
    }
  • ato*

    //将字符串转换为整数
    int atoi(const char *str);
    //将字符串转换为长整型整数(long类型)
    long atol(const char* nptr);
    //将字符串转换为长长整型整数(long long类型)
    long long atoll(const char* nptr);
    //将字符串转换为浮点数(float类型)
    double atof(const char *nptr);

19.malloc底层实现原理

malloc的底层实现原理-CSDN博客

malloc 背后的虚拟内存 和 malloc实现原理-CSDN博客

malloc申请内存,当申请内存小于128K则由brk分配,大于128K,mmap系统调用,不在推_edata指针,并且可以直接free,完成单独释放。

  1. malloc_init()初始化:将分配程序标识为已经初始化,sbrk找到操作系统中最后一个有效的内存地址,然后建立起指向需要管理的内存的指针

  2. 内存块结构

    typedef struct s_block *t_block;
    struct s_block
    {
        size_t size;  /* 数据区大小 */
        t_block next; /* 指向下个块的指针 */
        int free;     /* 是否是空闲块 */
        int padding;  /* 填充4字节,保证meta块长度为8的倍数 */
        char data[1]  /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
    };
    //为了完全地管理内存,我们需要能够追踪要分配和回收哪些内存,malloc 返回的每块内存的起始处首先要有这个结构:
    struct mem_control_block
    {
        int is_available; // 是否空闲
        int size;         // 内存块大小
    };

  3. 获取内存块:所要申请的内存是由多个内存块构成的链表。如何在block链中查找合适的block

    1. First fit:从头开始,使用第一个数据区大小大于要求size的块所谓此次分配的块

    2. Best fit:从头开始,遍历所有块,使用数据区大小大于size且差值最小的块作为此次分配的块

    3. 如果现有block都不能满足size的要求,则需要在链表最后开辟一个新的block

  4. 程序需要内存时,malloc() 首先遍历空闲区域,看是否有大小合适的内存块,如果有,就分配,如果没有,就向操作系统申请(发生系统调用)。为了保证分配给程序的内存的连续性,malloc() 只会在一个空闲区域中分配,而不能将多个空闲区域联合起来。

  5. malloc() 和 free() 所做的工作主要是对已有内存块的分拆和合并,并没有频繁地向操作系统申请内存,这大大提高了内存分配的效率。

  6. 进程第一次读写malloc分配的内存时候,发生缺页中断,这个时候,内核才分配这块内存对应的物理页。也就是说,如果用malloc分配了A这块内容,然后从来不访问它,那么,A对应的物理页是不会被分配的。

void *malloc(long numbytes)
{
    void *current_location;
    struct mem_control_block *current_location_mcb;
    void *memory_location;
    /*1:将分配程序标识为已经初始化,找到操作系统中最后一个有效的内存地址,然后建立起指向需要管理的内存的指针*/
    if (!has_initialized)
    {
        malloc_init();
    }
    numbytes = numbytes + sizeof(struct mem_control_block);
    memory_location = 0;
    current_location = managed_memory_start;
    while (current_location ! = last_valid_address)
    {
        current_location_mcb = (struct mem_control_block *)current_location;
        if (current_location_mcb->is_available)
        {
            if (current_location_mcb->size >= numbytes)
            {
                current_location_mcb->is_available = 0;
                memory_location = current_location;
                break;
            }
        }
        current_location = current_location + current_location_mcb->size;
    }
    if (!memory_location)
    {
        sbrk(numbytes);
        memory_location = last_valid_address;
        last_valid_address = last_valid_address + numbytes;
        current_location_mcb = memory_location;
        current_location_mcb->is_available = 0;
        current_location_mcb->size = numbytes;
    }
    memory_location = memory_location + sizeof(struct mem_control_block);
    return memory_location;
}

20.内存碎片

02.内存分配

01.C内存分区

内存布局 | 爱编程的大丙 (subingwen.cn)

C代码经过预处理、编译、汇编、链接4步后生成一个二进制可执行程序。

在没有运行程序前,也就是说程序没有加载到内存前,可执行程序内部已经分好3段信息,分别为代码区(text)数据区(data)未初始化数据区(bss)3 个部分(有些人直接把 data 和 bss 合起来叫做静态区或全局区)。

  • 代码区(text segment)

    加载的是可执行文件代码段,所有的可执行代码都加载到代码区,这块内存是不可以在运行期间修改的。

  • 未初始化数据区(BSS)

    加载的是可执行文件BSS段,位置可以分开亦可以紧靠数据段,存储于数据段的数据(全局未初始化,静态未初始化数据)的生存周期为整个程序运行过程。

  • 已初始化数据区(data segment)

    加载的是可执行文件数据段,存储于数据段(全局初始化,静态初始化数据,文字常量(只读))的数据的生存周期为整个程序运行过程。

  • 栈区(stack)

    栈是一种先进后出的内存结构,由编译器自动分配释放,存放函数的参数值、返回值、局部变量等。在程序运行过程中实时加载和释放,因此,局部变量的生存周期为申请到释放该段栈空间。

  • 堆区(heap)

    堆是一个大容器,它的容量要远远大于栈,但没有栈那样先进后出的顺序。用于动态内存分配。堆在内存中位于BSS区和栈区之间。一般由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收。

    类型作用域生命周期存储位置
    局部变量一对{}内当前函数栈区
    static局部变量一对{}内整个程序运行期初始化在data段,未初始化在BSS段
    extern变量整个程序整个程序运行期初始化在data段,未初始化在BSS段
    static全局变量当前文件整个程序运行期初始化在data段,未初始化在BSS段
    extern函数整个程序整个程序运行期代码区
    static函数当前文件整个程序运行期代码区
    register变量一对{}内当前函数运行时存储在CPU寄存器
    字符串常量当前文件整个程序运行期data段

02.堆与栈的区别

堆与栈实际上是操作系统对进程占用的内存空间的两种管理方式,主要有如下几种区别:

(1)管理方式不同。

栈由操作系统自动分配释放,用于存放函数的参数值、局部变量等,无需我们手动控制;堆的申请和释放工作由程序员控制,容易产生内存泄漏;

(2)空间大小不同。

每个进程拥有的栈大小要远远小于堆大小。理论上,进程可申请的堆大小为虚拟内存大小,对于 x86 和 x64 计算机,默认堆栈大小为 1 MB。在 Itanium 芯片组上,默认大小为 4 MB。linux下默认的堆栈空间大小是8M或10M,不同的发行版本可能不太一样。可以使用ulimit指令查看栈空间大小,指令ulimit -s或者ulimit -a:

(3)生长方向不同。

堆的生长方向向上,内存地址由低到高;栈的生长方向向下,内存地址由高到低。

(4)分配方式不同。

堆都是动态分配的,没有静态分配的堆。栈有 2 种分配方式:静态分配和动态分配。静态分配是由操作系统完成的,比如局部变量的分配。动态分配由alloca()函数分配,但是栈的动态分配和堆是不同的,它的动态分配是由操作系统进行释放,无需我们手工实现。

(5)分配效率不同。

栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。

(6)存放内容不同。

栈存放的内容,函数返回地址、相关参数、局部变量和寄存器内容等。当主函数调用另外一个函数的时候,要对当前函数执行断点进行保存,需要使用栈来实现,首先入栈的是主函数下一条语句的地址,即扩展指针寄存器的内容(EIP),然后是当前栈帧的底部地址,即扩展基址指针寄存器内容(EBP),再然后是被调函数的实参等,一般情况下是按照从右向左的顺序入栈,之后是被调函数的局部变量,注意静态变量是存放在数据段或者 BSS 段,是不入栈的。出栈的顺序正好相反,最终栈顶指向主函数下一条语句的地址,主程序又从该地址开始执行。堆,一般情况堆顶使用一个字节的空间来存放堆的大小,而堆中具体存放内容是由程序员来填充的。

堆和栈相比,由于大量 malloc()/free() 或 new/delete 的使用,容易造成大量的内存碎片,并且可能引发用户态和核心态的切换,效率较低。栈相比于堆,在程序中应用较为广泛,最常见的是函数的调用过程由栈来实现,函数返回地址、EBP、实参和局部变量都采用栈的方式存放。虽然栈有众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,主要还是用堆。

无论是堆还是栈,在内存使用时都要防止非法越界,越界导致的非法内存访问可能会摧毁程序的堆、栈数据,轻则导致程序运行处于不确定状态,获取不到预期结果,重则导致程序异常崩溃,这些都是我们编程时与内存打交道时应该注意的问题。

03.在局部数组中定义一个大数组可以吗?很大的数组,比如2048

不可以,会爆栈,栈溢出 在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,例如,在 WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数,在VC6下面,默认的栈空间大小是1M。当然,这个值可以修改。如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从获得的空间较小。 局部数组,具有局部作用域,当函数调用结束之后,数组也就被操作系统销毁了,即回收了他的内存空间

解决方法,(解决局部大数组爆栈和局部作用域的问题)

  • 定义成全局数组

  • 加static放在静态存储区

  • 数组用malloc申请空间放在堆区定义一个指针指向这个数组,栈中只占用一个指针的大小

04.在1G内存的计算机中能否通过malloc申请大于1G的内存?为什么?

可以 因为malloc函数是在程序的虚拟地址空间申请的内存,与物理内存没有直接的关系。虚拟地址与物理地址之间的映射是由操作系统完成的,操作系统可通过虚拟内存技术扩大内存。

嵌入式面经大全(6/30)--C/C++常见面试题(一)_牛客博客 (nowcoder.net)

05.malloc和new的区别

在 C++ 中,newdelete 用于动态分配和释放内存。下面是它们的基本用法:

使用 new 动态分配内存:

// 分配单个对象的内存并初始化
int *ptr = new int(10);

// 分配数组内存
int *arr = new int[5];

// 分配自定义类型对象内存并初始化
MyClass *obj = new MyClass();

使用 delete 释放动态分配的内存

// 释放单个对象内存
delete ptr;

// 释放数组内存
delete[] arr;

// 释放自定义类型对象内存
delete obj;

需要注意的是,在使用 newdelete 时应该遵循以下原则:

  1. 对于使用 new 分配的内存,务必使用 delete 来释放,对于使用 new[] 分配的数组内存,务必使用 delete[] 来释放。

  2. 避免对未分配的内存使用 deletedelete[],否则会导致未定义行为。

  3. 避免内存泄漏,即确保在不再需要动态分配的内存时及时释放。

  4. 对于自定义类型,需要正确实现析构函数来释放对象可能持有的资源。

06.malloc与free

在 C 语言中,mallocfree 用于动态分配和释放内存。下面是它们的基本用法:

// 分配 10 个整数大小的内存空间
int *ptr = (int*)malloc(10 * sizeof(int));

// 分配指定大小的内存空间
void *mem = malloc(100);

int main() {
    // 分配 5 个整数大小的内存空间
    int *ptr = (int*)malloc(5 * sizeof(int));

    if (ptr == NULL) {
        printf("内存分配失败\n");
        return 1;
    }

    // 使用 memset 设置内存内容为 0
    memset(ptr, 0, 5 * sizeof(int));

    // 打印设置后的内容
    for (int i = 0; i < 5; i++) {
        printf("%d ", ptr[i]);
    }

    // 释放内存
    free(ptr);

    return 0;
}

需要注意的是,在使用 mallocfree 时应该遵循以下原则:

  1. 使用 malloc 分配内存后,需要进行类型转换来匹配所需的指针类型。

  2. 对于使用 malloc 分配的内存,务必使用 free 来释放,确保释放的是正确的内存地址。

  3. 避免对未分配的内存使用 free,否则会导致未定义行为。

  4. 避免多次释放同一块内存,以避免出现内存错误。

07.new和delete的实现原理,delete是如何知道释放内存的大小的?

new 的实现原理:当程序使用 new 操作符时,编译器会生成一段代码来执行以下操作:

  1. 调用 operator new 函数,该函数会在堆中分配一块内存。

  2. 调用对象的构造函数,初始化对象。

  3. 返回指向该对象的指针。

delete 的实现原理:当程序使用 delete 操作符时,编译器会生成一段代码来执行以下操作:

  1. 调用对象的析构函数,释放对象占用的资源,

  2. 调用 operator delete 函数,将内存释放回堆。

  3. delete 如何知道释放内存的大小:delete 操作符并不知道要释放的内存大小,它只需要知道要释放的指针地址。当对象被 new 分配内存时,编译器会在堆中存储有关对象大小的信息,包括对象的长度和其他元数据。当使用 delete 操作符释放对象时,编译器使用这些元数据来确定要释放的内存块的大小。因此,如果在使用 new 时使用了错误的长度,可能会导致 delete 操作符释放错误的内存块,从而引起程序错误或崩溃。

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

内存泄露:是指在程序运行过程中,动态分配的内存没有被正确释放或者释放的时机不当,导致这部分内存无法再被程序访问和利用,从而造成系统内存资源的浪费。

避免内存泄露的几种方式

  • 显式释放内存:new就有delete,有malloc就有free,保证它们一定成对出现

  • 避免重复分配内存:程序在使用动态分配的内存时,应该避免重复分配内存,特别是在循环中。如果需要多次分配内存,可以使用realloc函数重新调整内存块的大小,以减少内存碎片的产生。

  • 使用智能指针:智能指针是一种自动管理内存的工具,可以避免手动释放内存的繁琐操作。智能指针会在对象不再被使用时自动释放内存,并且可以避免内存泄漏和悬空指针等问题。

03.指针

01.数组名和指针

  • 数组名是一个常量指针,它在程序执行过程中不会改变,指向的地址是数组的首地址。而指向数组首元素的指针可以被重新赋值,可以指向其他元素或者其他数组。

  • 数组名不能进行指针运算,而指向数组首元素的指针可以进行指针运算,例如加减操作。这是因为数组名是一个常量指针,它指向的地址是不可修改的。

    int main() {
        int arr[10]={0,1,2,3,4,5,6,7,8,9};
        int*p=arr;
        printf("%p %d\t%p %d\n", p,*p,arr,*arr);//0x7ffc5bb44a50 0        0x7ffc5bb44a50 0
        p++;
        //arr++;//error: lvalue required as increment operand  是一个右值,不能进行自增操作
        //arr+=1;//error: assignment to expression with array type 不能对数组名进行赋值操作
        printf("%p %d\t%p %d\n",p, *p,(arr+1),*(arr+1));//0x7ffc5bb44a54 1        0x7ffc5bb44a54 1
        return 0;
    }

  • 对数组名使用sizeof操作符时,返回的是整个数组占用内存的大小。对指向数组首元素的指针使用sizeof操作符时,返回的是指针类型的大小。

    int main() {
        int arr[10]={0,1,2,3,4,5,6,7,8,9};
        int*p=arr;
        //对数组名使用sizeof操作符时,返回的是整个数组占用内存的大小(以字节为单位),而对指向数组首元素的指针使用sizeof操作符时,返回的是指针类型的大小。
        printf("sizeof(arr)=%lu sizeof(p)=%lu\n", sizeof(arr),sizeof(p));//sizeof(arr)=40 sizeof(p)=8
        return 0;
    }

  • 在函数调用时,数组名作为参数传递给函数时,它实际上是传递给函数的一个指针,即指向数组首元素的指针。因此,数组名和指向数组首元素的指针在作为函数参数传递时,可以互换使用。

  • 数组名在声明时必须指定数组的大小,而指向数组首元素的指针在声明时可以不指定数组的大小。

02.数组指针和指针数组

  • 指针数组:指针数组是数组,用来存放指针的。

    int* arr1[10];	//整型指针的数组(arr1数组里面有10个元素,每个元素是int*类型)
    char* arr2[4];	//一级字符指针的数组(arr2数组里面有4个元素,每个元素是char*类型)
    char* *arr3[5];	//二级字符指针数组(arr3数组里面有5个元素,每个元素是char**类型)
    int arr1[]={1,2,3,4,5};
    int arr2[]={2,3,4,5,6};
    int arr3[]={3,4,5,6,7};
    //parr数组里面,存了三个数组名(arr1,arr2,arr3),类型都是int*
    //通过三个数组名,可以分别找到该数组名对应的首元素地址
    int* parr[]={arr1,arr2,arr3};
    for (int i=0; i<3; i++){
         for (int j=0; j<5; j++){
         //通过parr[i]找到存储的每个数组首元素地址,通过parr[i]+j找到每个元素的地址,解引用找到每个元素。
              printf("%d ",*(parr[i]+j));
         }
         printf("\n");
    }

  • 数组指针指向数组的指针 ,用来存放数组的地址。

  • arr + 1 以后跳过了4个字节&arr +1 以后跳过了40个字节,40是个字节归刚好是,arr数组十个元素的总大小,又因为数组在内存中是连续存放的。所以&arr + 1 就跳过了整个数组。所以这里&arr 代表的就是整个数组的地址。如果需要一个指针来存储这个数组地址,那就会用到我们的数组指针。

    int arr[10] = { 0 };
    printf("arr = %p\n", arr);         //arr = 0x7ffdd1460ab0
    printf("arr+1 = %p\n", arr + 1);   //arr+1 = 0x7ffdd1460ab4
    printf("&arr= %p\n", &arr);        //&arr= 0x7ffdd1460ab0
    printf("&arr+1= %p\n", &arr + 1);  //&arr+1= 0x7ffdd1460ad8int arr[10] = { 0 };
    //arr=&arr[0] -- 首元素地址
    int(*p)[10]=&arr;//数组的地址,定义了一个指向包含 10 个整型元素的数组的指针
    
    #include <iostream>
    int main() {
        int arr[5] = {1, 2, 3, 4, 5};
        int (*p)[5] = &arr; // 声明一个指向含有5个整型元素的数组的指针,并将其指向 arr 数组
        //p是数组指针,指向arr数组
        //数组名*p,数组的地址解引用,就拿到了这个数组
        //(*p)[0];	数组的第一个元素
        std::cout << "arr[0]: " << (*p)[0] << std::endl; // 输出 arr[0] 的值
        std::cout << "arr[3]: " << (*p)[3] << std::endl; // 输出 arr[3] 的值
        return 0;
    }
    
    #include <stdio.h>
    #include <unistd.h>
    #include <stdio.h>
    int main()
    {   /*  0 1 2 3 
            4 5 6 7
            8 9 10 11
        */
        int a[3][4] = {{0, 1, 2, 3}, {4, 5, 6, 7}, {8, 9, 10, 11}};
        int(*p)[4];
        p = a;
        printf("%ld\n", sizeof(*(p)));//16 *p指向a[0]
        printf("p=%p a[0]=%p\n", p,a[0]); //p=0x7ffd740986e0 a[0]=0x7ffd740986e0
        printf("**p=%d a[0][0]=%d\n",**p,a[0][0]);//**p=0 a[0][0]=0
        printf("*(*p+1)=%d a[0][1]=%d\n",*(*p+1),a[0][1]);//*(*p+1)=1 a[0][1]=1
        p++;
        printf("p=%p a[1]=%p\n",p,a[1]); //p=0x7ffd740986f0 a[1]=0x7ffd740986f0
        printf("**p=%d a[1][0]=%d\n",**p,a[1][0]);//**p=4 a[1][0]=4
        printf("*(*p+1)=%d a[1][1]=%d\n",*(*p+1),a[1][1]);//*(*p+1)=5 a[1][1]=5
    
        return (0);
    }

    *二维数组数组名:一维数组的数组名是首元素的地址,二维数组的数组名也是首元素的地址,这里二维数组的首元素是第一个维数组,因为二维数组也可以看作是由数个一维数组组成的,二维数组的数组名就代表了第一个一维数组的地址。*

    在 C 语言中,二维数组名实际上也是一个指向数组的指针。具体来说,二维数组名代表了数组的首地址,也就是第一个元素的地址。这个地址可以被解释为指向包含一维数组的指针。

    举例来说,如果有这样一个二维数组:

    int arr[3][4];

    那么 arr 就代表了整个二维数组的首地址。你可以将它看作一个指向包含 4 个整型元素的一维数组的指针。在内存中,二维数组以行优先的顺序存储,因此 arr 所指向的一维数组就是第一行的数组。

    需要注意的是,由于二维数组在内存中是连续存储的,因此可以使用一维数组的方式来处理二维数组。比如,可以通过 arr[i][j] 或者 *(*(arr + i) + j) 来访问二维数组中的元素

03.函数指针和指针函数的区别

函数指针和指针函数是两个不同的概念:

  1. 函数指针(Function Pointer):

  • 函数指针的定义: 函数的返回值类型(*指针名)(函数的参数列表类型)

  • 函数指针是指向函数的指针变量。在 C 语言中,函数名实际上就是指向函数代码块首地址的指针,因此可以将函数名赋值给函数指针变量。

  • 通过函数指针,可以动态地调用不同的函数,实现函数指针的多态性。函数指针可以作为函数的参数传递,也可以作为函数的返回值。

  • 函数指针的声明方式类似于指针,例如 void (*funcPtr)(int); 表示一个指向参数为整型、返回类型为 void 的函数的函数指针。

    #include <stdio.h>
    int max(int a, int b) {
         return a > b ? a : b;
    }
    int main() {
        int(*funcPtr)(int,int);//定义一个函数指针
        funcPtr = max;//函数名是指向函数代码块首地址的指针,funcPtr指向max函数
        int ans=(*funcPtr)(10,20);//指针调用函数
        int ans1 = funcPtr(10, 20);//这种写法省略了解引用操作,直接通过函数指针调用函数,效果是一样的。
        printf("ans=%d,ans1=%d\n", ans,ans1);
        return 0;
    }
    
    typedef struct DispOpr{
        char* name;
        int (*DeviceInit)(void);
        int (*DeviceExit)(void);
        int(*GetDispBufferInfo)(PDispBuff);
        int(*FlushRegion)(PRegion ,PDispBuff);
        struct DispOpr*ptNext;
    }DispOpr,*PDispOpr;
    
    static DispOpr g_tFramebufferOpr = {
        .name="fb",
        .DeviceInit=FbDeviceInit,
        .DeviceExit=FbDeviceExit,
        .GetDispBufferInfo=FbGetDispBufferInfo,
        .FlushRegion=FbFlushRegion,    
    };

  1. 指针函数(Pointer to Function):

  • 指针函数是一个返回指针的函数,即函数的返回类型是指针类型。

  • 由于函数返回值是地址,故传递方式为地址传递,地址传递需要保证地址是有效的,需要没有被回收或释放掉的地址,否则会段错误,非法访问内存

  • 指针函数可以动态地分配内存空间,并返回指向该内存空间的指针,常用于动态内存管理和数据结构中。

  • 例如,int* createArray(int size); 是一个返回指向整型数组的指针的指针函数,用于动态创建一个整型数组并返回指向该数组的指针。

04.指针和引用

引用不是新定义一个变量,而是给已存在的变量取一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间

int a = 10,b=20;
// int& ra; // 引用在定义时必须初始化,该条语句编译时会出错
int& ra = a;//一个变量可以有多个引用
int& rra = a;
printf("%d %d %d\n", a, ra, rra);//10 10 10
printf("%p %p %p\n", &a, &ra, &rra);//0x7ffccb52cc30 0x7ffccb52cc30 0x7ffccb52cc30
a=b;  错误!这不是改变引用对象,而是改变了引用对象所指向的值
printf("%d %d %d\n", a, ra, rra);//20 20 20
printf("%p %p %p\n", &a, &ra, &rra);//0x7ffccb52cc30 0x7ffccb52cc30 0x7ffccb52cc30

指针(Pointers):

  1. 定义和声明:指针是一个包含变量地址的特殊类型的变量。通过在变量名前面加上 * 符号来声明指针,例如 int *ptr;

  2. 空指针:指针可以为空,即指向空地址或者没有指向任何有效的内存单元。

  3. 指针运算:指针可以进行运算,比如加法、减法等,以便访问数组中的元素或者直接修改地址。

  4. 指针的重新赋值:可以将指针重新赋值为另外的地址。

  5. 指针的指向可以改变:指针可以在运行时指向不同的变量或对象。

引用(References):

  1. 定义和声明:引用是一个已存在对象的别名,通过在变量名前面加上 & 符号来声明引用,例如 int &ref = var;

  2. 不能为空:引用在声明时必须初始化,且一旦引用绑定了对象,就不能再绑定到其他对象。

  3. 引用本质上是对象:引用在底层实现上通常是一个指针,但语法上看起来更像是对对象的直接访问。

  4. 不需要解引用操作:使用引用时不需要显式地进行解引用操作,而指针需要使用 * 运算符来解引用。

相同点:

  1. 间接访问:指针和引用都可以用于间接访问变量,可以通过它们来修改变量的值。

  2. 传递参数:指针和引用都可以用作函数的参数,从而实现对函数外部变量的引用传递。

05.野指针和悬空指针

野指针:是没有被初始化过的指针,所以不确定指针具体指向。因为“野指针”可能指向任意内存段,因此它可能会损坏正常的数据,也有可能引发其他未知错误。

int *p;//未初始化,野指针

悬空指针:指针最初指向的内存已经被释放了的一种指针。指针指向的内存已释放,但指针的值没有被清零,对悬空指针操作的结果不可预知。

#include <iostream>
using namespace std;
 
int main()
{
    int *p = new int(5);
    cout<<"*p = "<<*p<<endl;
    free(p);  // p 在释放后成为悬空指针
    p = NULL; // 非悬空指针
    return 0;
}

int main()
{
    int *p;
    {
        int tmp = 10;
        p = &tmp;
    }
    //超出了变量的作用范围,p 在此处成为悬空指针
    return 0;
} 

//指向了函数局部变量
int* getVal() {
    int tmp = 10;
    return &tmp;
}
 
int main()
{
    int *p = getVal(); //悬空指针
    cout<<"*p = "<<*p<<endl;
    return 0;
}
06.智能指针

C++ 智能指针 - 全部用法详解-CSDN博客

智能指针是一种用于管理动态分配内存的工具,它能够在对象不再需要时自动释放内存,从而避免内存泄漏问题。智能指针通过封装原始指针,并在其析构函数中调用 deletefree 来释放内存,从而确保在对象生命周期结束时内存得到正确释放。

在C++中,标准库提供了两种常用的智能指针:std::unique_ptrstd::shared_ptr

  1. std::unique_ptrstd::unique_ptr 是一种独占所有权的智能指针,它确保同一时间只有一个 std::unique_ptr 可以指向给定的资源。当 std::unique_ptr 被销毁时,它所管理的资源会被自动释放。

    示例:

    #include <iostream>
    #include <memory>
    
    int main() {
        std::unique_ptr<int> ptr(new int(10));
        std::cout << *ptr << std::endl;
        // 不需要手动调用 delete,当 ptr 超出作用域时会自动释放内存
        return 0;
    }

  2. std::shared_ptrstd::shared_ptr 允许多个智能指针共享同一份资源,它使用引用计数来管理内存。只有当最后一个指向资源的 std::shared_ptr 被销毁时,资源才会被释放。

    示例:

    #include <iostream>
    #include <memory>
    
    int main() {
        std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
        std::shared_ptr<int> ptr2 = ptr1; // ptr1 和 ptr2 共享同一份资源
        std::cout << *ptr1 << " " << *ptr2 << std::endl;
        // 不需要手动调用 delete,当所有指向资源的 shared_ptr 都超出作用域时会自动释放内存
        return 0;
    }

使用智能指针可以简化内存管理,避免手动管理内存带来的潜在错误,提高代码的可读性和可维护性。但需要注意的是,智能指针并不能解决所有的内存管理问题,例如循环引用问题,因此在使用智能指针时仍需谨慎。

07.指针大小

在C语言中,指针的大小取决于系统架构和编译器的位数。一般情况下,指针的大小为系统的地址总线宽度的大小,即指针的大小通常等于机器字长。在大多数现代系统中,指针的大小为4字节(32位系统)或8字节(64位系统)

指针类型决定了指针进行解引用操作时能够访问的空间大小(能操作几个字节)。int可以访问4个字节,char可以访问1个字节,double*可以访问8个字节

数据类型大小

短整型:short 	 	(2字节) (-32768~32767)
整型:  int        (4字节) 
长整型:long       (4/8字节)(-2147483648~2147483647)
长长整型:long long  (8字节)-9223372036854775808~+9223372036854775807(约九百亿亿)

字符型:char (1字节) (-128~127)
字符型:unsigned  char (1字节) (0~255)

单精度型:float  (4字节)(3.4E-38~3.4E+38)(7位有效数字)
双精度型:double (8字节)(1.7E-308~1.7E+308)(16位有效数字)
长双精度型:long double (16字节)

04.C++

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

面向对象编程是一种以对象为基本单位,通过封装、继承和多态等机制来组织和管理代码的编程范式。它具有以下基本特征:

  1. 封装(Encapsulation):封装是将数据和对数据的操作封装在一个单独的实体中,即类。通过封装,类可以隐藏其内部实现细节,只提供对外部可见的接口,从而保护数据的安全性和完整性。其他对象只能通过类的接口来访问和操作数据,而无法直接访问类的内部实现。

  2. 继承(Inheritance):继承是指一个类可以派生出子类,子类会继承父类的属性和方法。通过继承,子类可以重用父类的代码,并且可以在不修改父类的情况下添加新的属性和方法,实现代码的扩展和复用。继承还允许通过多态来实现基于父类的统一接口对不同子类进行操作。

  3. 多态(Polymorphism):多态是指同一种操作或函数可以作用于不同类型的对象,并且可以根据对象的实际类型执行不同的行为。多态可以提高代码的灵活性和可扩展性,使得程序可以根据实际需要处理不同类型的对象,而无需显式判断对象的类型。

  4. 抽象(Abstraction):抽象是指通过对类的建模,将具体的对象归纳为更高层次的概念和类型。抽象可以忽略不必要的细节,只关注对象的一些关键属性和行为,从而提高代码的可理解性和可维护性。抽象可以通过接口、抽象类和纯虚函数等方式来实现。

这些基本特征共同构成了面向对象编程的核心思想和方法,使得程序可以更加模块化、可扩展和易于理解。通过合理地运用这些特征,可以设计出清晰、灵活且易于维护的面向对象程序。

02.C++中重载和覆盖的区别

重载(overload)

  1. 重载指的是函数具有的不同的参数列表,而函数名相同的函数。重载要求参数列表必须不同,比如参数的类型不同、参数的个数不同、参数的顺序不同。

  2. 如果仅仅是函数的返回值不同是没办法重载的,因为重载要求参数列表必须不同。

  3. 程序是根据参数列表来确定具体要调用哪个函数的

    void Fun(int a);
    void Fun(double a);
    void Fun(int a, int b);
    void Fun(double a, int b);
    //上面四个函数都可以构成函数重载
    
    int Fun(int a)
    void Fun(int a)
    //上面两个是无法构成函数重载的,参数列表必须不同

覆盖(重写override)

  1. 覆盖是存在类中,子类重写从基类继承过来的虚函数。在基类中,将虚函数声明为 virtual,子类函数名、返回值、参数列表都必须和基类相同。

    class Animal {
    public:
        virtual void makeSound() {
            cout << "Animal makes a sound" << endl;
        }
    };
    
    class Dog : public Animal {
    public:
        void makeSound() override {
            cout << "Dog barks" << endl;
        }
    };

  2. 当子类的对象调用成员函数的时候,如果成员函数有被覆盖则调用子类中覆盖的版本,否则调用从基类继承过来的函数

  3. 如果子类覆盖的是基类的虚函数,可以用来实现多态。

03.C++有几种构造函数

在C++中,构造函数是一种特殊类型的成员函数,用于在对象创建时对其进行初始化。C++中的构造函数有以下几种类型:

  1. 默认构造函数:

    • 如果类没有显式地定义构造函数,则编译器会生成一个默认构造函数。

    • 默认构造函数不带任何参数,且不执行任何操作。

  2. 有参构造函数:

    • 带参构造函数接受一个或多个参数,用于在对象创建时初始化成员变量

    • 可以定义多个带参构造函数,每个构造函数可以接受不同的参数。

  3. 拷贝构造函数:

    • 拷贝构造函数用于将一个对象的值复制到另一个对象中。

    • 它通常接受一个引用类型的参数,用于指定要复制的对象。

  4. 移动构造函数:

    • 移动构造函数用于在对象之间转移资源所有权。

    • 它通常接受一个右值引用类型的参数。

示例:

class Person {
public:
    // 默认构造函数
    Person() {
        name = "";
        age = 0;
    }
    // 带参构造函数
    Person(string n, int a) {
        name = n;
        age = a;
    }
    // 拷贝构造函数
    Person(const Person& other) {
        name = other.name;
        age = other.age;
    }
    // 移动构造函数
    Person(Person&& other) noexcept {
        name = std::move(other.name);
        age = other.age;
    }
private:
    string name;
    int age;
};

构造函数是C++中一种重要的特殊成员函数,它们允许在创建对象时对其进行初始化。通过合理设计构造函数,可以使代码更加清晰、可读性更高,并提高程序的可维护性。

04.为什么一个类作为基类被继承,其析构函数必须是虚函数?

当一个类作为基类被继承时,如果其析构函数不是虚函数,则可能会导致派生类的内存泄漏问题。这是因为当一个指向派生类对象的基类指针或引用被删除时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数。这样就会导致派生类中的动态分配的资源无法被释放,从而造成内存泄漏。

通过将基类的析构函数声明为虚函数,可以确保在删除基类指针时,派生类的析构函数也会被正确调用,从而可以释放派生类中的动态分配的资源。

示例:

#include <iostream>
using namespace std;
class Base {
public:
    Base(){
        cout << "Base construct" << endl;
    };
    ~Base() {
        cout << "Base destructor" << endl;
    }
};

class Derived : public Base {
public:
    Derived(){
       cout << "Derived construct" << endl; 
    };
    ~Derived()  {
        cout << "Derived destructor" << endl;
    }
};

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

将基类析构函数声明为虚函数

#include <iostream>
using namespace std;
class Base {
public:
    Base(){
        cout << "Base construct" << endl;
    };
    virtual ~Base() {
        cout << "Base destructor" << endl;
    }
};

class Derived : public Base {
public:
    Derived(){
       cout << "Derived construct" << endl; 
    };
    ~Derived() override {
        cout << "Derived destructor" << endl;
    }
};

int main() {
    Base* ptr = new Derived();
    delete ptr; // 调用派生类的析构函数
    return 0;
}
// Base construct
// Derived construct
// Derived destructor	//子类先释放
// Base destructor		//然后基类

05.构造函数、析构函数的执行顺序?构造函数和拷贝构造的内部都干了啥?

在C++中,构造函数和析构函数的执行顺序如下:

  1. 构造函数执行顺序:

    • 在创建一个对象时,先执行基类的构造函数

    • 然后按照成员变量在类中的声明顺序依次执行各个成员变量的构造函数

    • 最后执行派生类的构造函数。

  2. 析构函数执行顺序:

    • 在一个对象被销毁时,析构函数的执行顺序与构造函数相反。即先执行派生类的析构函数

    • 然后按照成员变量的声明顺序依次执行各个成员变量的析构函数

    • 最后执行基类的析构函数。

关于构造函数和拷贝构造函数的内部工作:

  1. 构造函数:

    • 构造函数用于初始化对象的数据成员。在构造函数内部,可以对对象的成员变量进行赋值、分配内存、调用其他函数等操作,以确保对象被正确初始化。

    • 构造函数的目的是在对象创建时执行必要的初始化操作,使得对象处于一个合法的状态。

  2. 拷贝构造函数:

    • 拷贝构造函数是一种特殊的构造函数,用于将一个对象的值复制到另一个对象中。通常在对象初始化或对象传递过程中调用。

    • 拷贝构造函数的主要作用是执行深拷贝(deep copy)操作,确保在拷贝对象时所有资源都能正确复制,避免浅拷贝(shallow copy)导致的问题。

    • 拷贝构造函数的形参通常是一个引用类型的参数,以便接受要复制的对象。在拷贝构造函数内部,需要将传入对象的数据复制到当前对象中,以实现对象的复制。

06.深拷贝和浅拷贝

  1. 浅拷贝:

    • 浅拷贝是指仅复制对象本身的成员变量值,而不复制对象所引用的资源。这意味着多个对象会共享同一份资源,当其中一个对象对资源进行修改时,会影响到其他对象。

    • 浅拷贝通常是通过默认的拷贝构造函数或赋值运算符实现的。它仅复制指针或引用,而不复制指针或引用所指向的内容。

  2. 深拷贝:

    • 深拷贝是指在对象拷贝过程中,不仅复制对象本身的成员变量值,还复制对象所引用的资源。这样每个对象都有自己独立的资源副本,互不影响。

    • 深拷贝通常需要自定义拷贝构造函数和赋值运算符重载,确保对象的资源被正确地复制。

示例:

#include <iostream>

class MyClass {
public:
    int* data;

    // 构造函数
    MyClass(int value) {
        data = new int(value);
    }

    // 拷贝构造函数(浅拷贝)
    MyClass(const MyClass& other) {
        data = other.data; // 仅复制指针,共享同一个资源
    }

    // 深拷贝构造函数
    MyClass(const MyClass& other) {
        data = new int(*other.data); // 深拷贝资源,创建新的副本
    }

    ~MyClass() {
        delete data; // 释放动态分配的内存
    }
};

int main() {
    MyClass obj1(10);
    MyClass obj2 = obj1; // 浅拷贝,obj1和obj2共享同一个资源

    obj2.data = new int(20); // 修改obj2的资源,不影响obj1

    std::cout << *obj1.data << std::endl; // 输出:10
    std::cout << *obj2.data << std::endl; // 输出:20

    return 0;
}

在上面的示例中,MyClass 类包含了一个动态分配的整型数据 data。其中,浅拷贝构造函数仅复制指针,导致多个对象共享同一个资源;而深拷贝构造函数通过重新分配内存并复制资源,使每个对象都有独立的资源副本。

main 函数中,创建了两个对象 obj1obj2,并通过浅拷贝将 obj1 的值复制给 obj2。修改 obj2 的资源并不会影响到 obj1,因为它们共享同一个资源。最终输出结果显示了这一点。

07.this指针

对于成员函数中的this指针,它的类型是 类类型* const,即指向当前类类型的非常量指针常量,即成员函数中,不能给this指针赋值。

在C++中,this指针是一个特殊的指针,它指向当前对象。this指针可以在类的成员函数中使用,用于指代调用该成员函数的对象。

当调用一个对象的成员函数时,编译器会隐式地将对象的地址作为参数传递给成员函数,这个参数就是this指针。因此,在成员函数内部,可以使用this指针来访问对象的成员变量和成员函数。

this指针在C++中主要用于以下几个方面的功能:

  1. 访问对象的成员变量和成员函数:通过this指针可以在类的成员函数中访问当前对象的成员变量和成员函数。

    #include <iostream>
    
    class MyClass {
    public:
        int data;
    
        MyClass(int data) : data(data) {}
    
        void setData(int data) {
            this->data = data; // 使用this指针访问成员变量
        }
    
        void showData() {
            std::cout << "Data: " << this->data << std::endl; // 使用this指针访问成员变量
        }
    
        void callAnotherMemberFunction() {
            this->showData(); // 使用this指针调用另一个成员函数
        }
    };

  2. 区分参数和成员变量:当成员变量和成员函数的参数名字相同时,可以使用this指针来区分它们,以便访问成员变量。

    #include <iostream>
    
    class MyClass {
    public:
        int data;
    
        MyClass(int data) : data(data) {}
    
        void setData(int data) {
            this->data = data; // 使用this指针访问成员变量
        }
    
        void printData(int data) {
            std::cout << "Parameter value: " << data << std::endl;
            std::cout << "Member variable value: " << this->data << std::endl; // 使用this指针访问成员变量
        }
    };
    
    int main() {
        MyClass obj(10);
    
        obj.setData(20); // 设置成员变量的值
    
        obj.printData(30); // 显示参数和成员变量的值
    
        return 0;
    }

  3. 返回对象本身:在成员函数中可以使用this指针来返回对象本身,实现链式调用

    #include <iostream>
    
    class MyClass {
    public:
        int data;
    
        MyClass(int data) : data(data) {}
    
        MyClass& setData(int data) {
            this->data = data; // 使用this指针访问成员变量
            return *this; // 返回对象本身的引用
        }
    
        MyClass& addData(int value) {
            this->data += value; // 使用this指针访问成员变量
            return *this; // 返回对象本身的引用
        }
    
        void displayData() {
            std::cout << "Data: " << this->data << std::endl;
        }
    };
    
    int main() {
        MyClass obj(10);
    
        obj.setData(20).addData(5).displayData(); // 链式调用
    
        return 0;
    }

  4. 传递当前对象给其他函数:在某些情况下,可以使用this指针将当前对象作为参数传递给其他函数。

    #include <iostream>
    
    class MyClass {
    public:
        int data;
    
        MyClass(int data) : data(data) {}
    
        void processObject() {
            // 将当前对象作为参数传递给其他函数
            someOtherFunction(this);
        }
    };
    
    void someOtherFunction(MyClass* obj) {
        std::cout << "Received object with data: " << obj->data << std::endl;
    }
    
    int main() {
        MyClass obj(10);
        obj.processObject(); // 调用成员函数,将当前对象传递给其他函数进行处理
    
        return 0;
    }

08.什么是虚函数

在C++中,虚函数是一种允许在派生类中重写(override)的基类函数。通过将基类函数声明为虚函数,可以实现多态性(polymorphism),使程序在运行时能够根据对象的实际类型来调用相应的函数。

当基类中的成员函数被声明为虚函数时,在派生类中对该函数进行重写时,如果对象是通过基类指针或引用访问的,那么在运行时将会根据对象的实际类型来调用相应的重写函数,而不是根据指针或引用的类型来决定调用哪个函数,这就是多态性的体现。

要将一个函数声明为虚函数,只需在函数声明前面加上关键字virtual即可。子类中重写该虚函数时不需要再使用virtual关键字,但最好保持一致以增强可读性。

以下是一个简单的示例代码,演示了虚函数的用法:

#include <iostream>

class Base {
public:
    virtual void display() {
        std::cout << "Base::display() called" << std::endl;
    }
};

class Derived : public Base {
public:
    void display() override {
        std::cout << "Derived::display() called" << std::endl;
    }
};

int main() {
    Base* basePtr = new Derived(); // 通过基类指针访问派生类对象
    Base* basePtr1 = new Base(); 
    // 调用虚函数,会根据对象的实际类型调用相应的函数
    basePtr->display(); // Derived::display() called
    basePtr1->display(); // Base::display() called
    delete basePtr;
    delete basePtr1;
    return 0;
}

在这个示例中,Base类中的display函数被声明为虚函数,并在Derived类中进行了重写。在main函数中,我们通过基类指针basePtr访问了Derived类对象,并调用了display函数。由于display函数是虚函数,因此程序在运行时会根据对象的实际类型来调用Derived类中的display函数,从而实现多态性的效果。

09.纯虚函数

在C++中,纯虚函数是一种在基类中声明但没有定义的虚函数。通过将一个虚函数声明为纯虚函数,可以使得包含该纯虚函数的类称为抽象类,而抽象类不能被实例化,只能被用作基类,需要在派生类中实现(override)这些纯虚函数。

声明一个纯虚函数的语法是在函数声明的结尾处加上= 0,例如:

class AbstractClass {
public:
    virtual void pureVirtualFunction() = 0;
};

由于AbstractClass中包含了一个纯虚函数pureVirtualFunction,因此AbstractClass成为了一个抽象类,不能被直接实例化。任何继承自AbstractClass的派生类都必须实现pureVirtualFunction,否则派生类也会成为抽象类。

以下是一个示例代码,演示了如何使用纯虚函数和抽象类:

#include <iostream>

class AbstractClass {
public:
    virtual void pureVirtualFunction() = 0;
};

class ConcreteClass : public AbstractClass {
public:
    void pureVirtualFunction() override {
        std::cout << "ConcreteClass::pureVirtualFunction() called" << std::endl;
    }
};

int main() {
    // AbstractClass abstractObj; // 无法实例化抽象类

    ConcreteClass concreteObj;
    concreteObj.pureVirtualFunction(); // 调用重写的纯虚函数

    return 0;
}

10.C++的struct和class的区别

差异特性structclass
成员访问范围默认public默认private
继承关系访问范围默认public默认private
类型struct 是值类型class 是引用类型
{}初始化1、纯数据或纯数据+普通方法的结构体支持;2、带构造函数或虚方法的结构体不支持不支持

在C++中,类中的成员和继承的访问权限有三种:publicprotectedprivate。这些关键字控制了类的成员对外部代码和派生类的可见性和可访问性。下面是它们的作用:

  1. public

    • 公有成员对外部代码和派生类都是可见和可访问的。

    • 可以在类的外部和派生类中直接访问公有成员。

  2. protected

    • 保护成员对外部代码不可见,但对派生类可见。

    • 可以在派生类的成员函数中直接访问保护成员。

    • 不能在类的外部直接访问保护成员。

  3. private

    • 私有成员对外部代码和派生类都不可见。

    • 不能在类的外部和派生类中直接访问私有成员。

下面是一个示例,演示了这三种访问权限的用法:

#include <iostream>
using namespace std;

class Base {
public:
    int publicVar;      // 公有成员
protected:
    int protectedVar;   // 保护成员
private:
    int privateVar;     // 私有成员
public:
    void display() {
        cout << "Public member: " << publicVar << endl;
        cout << "Protected member: " << protectedVar << endl;
        cout << "Private member: " << privateVar << endl;
    }
};

class Derived : public Base {
public:
    void accessBaseMembers() {
        cout << "Derived accessing base members:" << endl;
        cout << "Public member in derived: " << publicVar << endl;  // 可以访问
        cout << "Protected member in derived: " << protectedVar << endl; // 可以访问
        // cout << "Private member in derived: " << privateVar << endl;  // 错误,不能访问
    }
};

int main() {
    Base obj;
    obj.publicVar = 10;       // 可以访问
    // obj.protectedVar = 20;    // 错误,不能访问
    // obj.privateVar = 30;      // 错误,不能访问
    obj.display();            // 可以访问

    Derived derivedObj;
    derivedObj.accessBaseMembers(); // 可以访问基类的公有和保护成员

    return 0;
}

在这个示例中,Base 类有三种类型的成员变量,分别是 publicVarprotectedVarprivateVar。然后,Derived 类通过 public 继承方式继承了 Base 类,因此在 Derived 类中可以访问 Base 类的公有和保护成员。

11.NULL和nullptr

C++中NULL和nullptr的区别_nullptr和null区别-CSDN博客

NULL在C++中就是0,这是因为在C++中void* 类型是不允许隐式转换成其他类型的,所以之前C++中用0来代表空指针,但是在重载整形的情况下,会出现上述的问题。所以,C++11加入了nullptr,可以保证在任何情况下都代表空指针,而不会出现上述的情况,因此,建议以后还是都用nullptr替代NULL吧,而NULL就当做0使用。

12.vector/list

std::vector 是一个动态数组容器,可以在运行时动态增加或减少元素的数量。它的内部实现是基于连续的内存空间,元素在内存中是连续存储的。由于元素的连续存储,std::vector 支持通过索引快速访问元素,时间复杂度为 O(1),并且支持随机访问迭代器。

主要特点包括:

  • 支持动态增长:当元素数量超过容量时,std::vector 会自动重新分配更大的内存空间。

  • 随机访问:可以通过索引在常数时间内访问任何元素。

  • 不支持在中间位置高效地插入或删除元素:插入或删除元素可能需要移动后续元素。

std::list 是一个双向链表容器,它的内部实现是通过指针将元素串联起来的。每个元素都包含了指向前一个元素和后一个元素的指针。由于元素的非连续存储,std::list 不支持随机访问,但支持在任意位置高效地插入或删除元素。

主要特点包括:

  • 高效的插入和删除操作:在任意位置插入或删除元素的时间复杂度为 O(1)。

  • 不支持随机访问

  • 每个元素占用额外的内存空间:除了存储元素本身的数据外,还需要存储指向前一个和后一个元素的指针。

选择使用场景

  • 如果需要频繁地在容器的末尾进行插入或删除操作,并且不需要频繁地访问中间位置的元素,那么 std::list 可能更适合。

  • 如果需要频繁地在容器中进行随机访问,并且插入或删除操作相对较少,那么 std::vector 可能更适合。

  • 对于其他情况,可以根据具体需求和性能要求选择合适的容器类型。

std::map 是 C++ 标准库提供的一种关联容器,它以键-值对(key-value pair)的形式存储数据,并根据键进行有序存储。std::map 内部基于红黑树(Red-Black Tree)实现,这使得插入、删除和查找操作的时间复杂度都是 O(log n),其中 n 是元素的数量。

  • 13
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jay-juice

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值