C/C++特性

1. C++特性

基础特性:

  • 面向对象编程 (OOP)
    • 类(class)和对象(object)
    • 封装、继承、多态
    • 构造函数和析构函数
  • STL容器
    • std::vector、std::map
  • 函数
    • 重载
    • 默认参数
  • 模板
    • 类模板
    • 函数模板
  • 异常处理 try、catch、throw
  • 多线程 std::thread、std::mutex、std::async、std::future

版本特性:

  • C++98 (1998年),首个国际标准版本,奠定了C++的基础。
  • C++03 (2003年),对 C++98 的小幅修订,主要修复了一些问题。
  • C++11 (2011年),重大更新,引入了自动类型推导(auto)、智能指针、lambda表达式等新特性、nullptr。
  • C++14 (2014年),对 C++11 的补充,增强了泛型编程和 lambda 表达式的功能。
  • C++17 (2017年),引入了结构化绑定(std::make_pair)、std::optional、std::filesystem等新特性。
  • C++20 (2020年),新增了概念(Concepts)、范围(Ranges)、协程(Coroutines)等重大特性。

1.1. 基础特性

1.1.1. 多态

字面意思是多种形态,实际上是指:

  • 同一个函数,对不同的类对象表现出不同的行为;
  • 父类的指针或引用可以指向子类对象,并调用子类重写的方法。

当类之间存在层次结构,并且类之间是通过继承关联时,就会用到多态。简而言之就是基类和派生都用到同一个函数,但是定义不一样。需要在基类使用关键字virtual。

静态多态(编译时多态是指在编译阶段就能确定调用哪个函数):重载、模板

动态多态(运行时多态是指在程序运行时才能确定调用哪个函数):虚函数和继承

1.1.2. 虚函数

// 使用virtual关键字声明函数
virtual 返回类型 函数名(参数列表);

// 纯虚函
virtual 返回类型 函数名(参数列表) = 0;
  • 纯虚函数没有函数体(即没有实现)。
  • 包含纯虚函数的类称为抽象类。
  • 抽象类不能直接创建对象
  • 抽象类只能作为基类。
  • 派生类必须实现纯虚函数:如果一个派生类没有实现基类中的所有纯虚函数,那么该派生类也会成为抽象类。
  • 抽象类适合作为接口。

在 C++ 中,抽象类是不能直接实例化的。如果尝试直接创建抽象类的对象,编译器会报错。这是因为抽象类中包含至少一个纯虚函数,而纯虚函数没有实现,因此抽象类是一个不完整的类型,无法创建对象。

error: cannot declare variable 'obj' to be of abstract type 'AbstractClass'

1.1.3. 虚函数表与虚函数表指针

虚函数表是一个存储虚函数地址的数组,每个包含虚函数的类都有一个对应的虚函数表(虚函数表是占用内存的)。目的是为了当调用虚函数时,程序通过虚函数表查找并调用正确的函数实现。就比如一个派生类如果重写了基类的虚函数,派生类的虚函数表中会替换为派生类的函数地址。反之,如果没有重写,则派生类会继承基类的实现,派生类的虚函数表中会使用基类的函数地址。

虚函数表指针(vptr)是一个指向虚函数表的指针,每个包含虚函数的对象在内存中都有一个隐藏的虚函数表指针。虚函数表指针是对象的一部分,在内存上应该跟成员变量是放在一起的;

函数表和虚函数表指针的工作原理:

  • 编译阶段:
    • 编译器为每个包含虚函数的类生成一个虚函数表。
    • 编译器在每个对象的内存布局中添加一个vptr
  • 运行时:
    • 当通过基类指针或引用调用虚函数时:
      • 程序通过对象的vptr找到虚函数表。
      • 根据虚函数表中的条目调用正确的函数

1.1.4. STL容器

STL是指C++标准模板库,包含了容器、迭代器、算法、函数、一些其他辅助功能,功能强大。

STL容器部分常用的就是std::vectorstd::liststd::mapstd::set

容器 特点 底层实现 存储状态
std::vector 支持随机访问;尾部插入和删除高效;
需要预分配内存,可能引发内存重新分配;
动态数组 内存连续
std::list 任意位置插入和删除高效;不支持随机访问; 双向链表 内存不连续
std::map 键值对集合,键唯一;基于自动排序;插入、删除和查找高效 红黑树 元素按键排序存储,内存不连续
std::set 有序集合,元素唯一;基于自动排序;插入、删除和查找高效 红黑树 元素按值排序存储,内存不连续

2. C特性

基础特性:

  • 结构化编程
    • 代码模块化:通过头文件(.h)和源文件(.c)实现
    • 控制结构:if-else、switch、for、while、do-while循环
  • 数据类型
    • 基本数据类型:int、char、float、double、void
    • 派生类型:数组、指针、结构体(struct)、联合体(union)
    • 枚举类型:enum
    • 类型限定符:const、volatile、restrict(C99)
  • 指针与内存管理
    • 指针操作:取地址(&)、解引用(*)、指针算术运算
    • 动态内存分配:malloc()、calloc()、realloc()、free()
    • 函数指针:支持指向函数的指针,实现回调机制
  • 预处理
    • #define、#undef、#include、#if、#ifdef
  • 标准库
    • stdio.h、string.h

版本特性:

  • C89/C90(1989/1990年):首个标准版本,核心语法和标准库的正式规范
  • C99(1999年):内联函数、变长数组、单行注释、混合声明与代码(允许在语句之后声明变量)
  • C11(2011年):联合体、静态断言(_Static_assert)、多线程支持头文件(
  • C17/C18(2018年): 无新特性、对C11的修订和澄清

2.1 基础特性

2.1.1. sizeof()与结构体内存布局

sizeof()运算符,用于计算其操作数在内存中所占的字节数。返回size_t类型(无符号整型)。

常见用法:

// 1. 获取数组大小
int arr[10];
size_t arr_size = sizeof(arr);                        // 整个数组大小:10 * sizeof(int)
size_t element_count = sizeof(arr) / sizeof(arr[0]);  // 数组元素个数:10

// 2. 动态内存分配
int *p = (int*)malloc(10 * sizeof(int));  // 分配10个int的空间

// 3. 结构体大小计算
struct Point {
    int x, y;
};
size_t struct_size = sizeof(struct Point);

这里重点整理下结构体的内存分布

规则:

  • 成员对齐规则:偏移量必须是其类型大小与对齐系数较小者的整数倍;
  • 结构体总大小必须是其最宽基本类型成员与对齐系数较小者的整数倍;
  • #pragma pack允许用户覆盖编译器的默认对齐规则。

这意味着什么呢?如果在不使用#pragma pack的情况下,结构体时内存对齐的。

// 示例:分析以下结构体在32位系统(默认4字节对齐)下的大小
struct Example32 {
    char a;      // 1字节,偏移 0,占用1字节
    int b;       // 4字节,需从偏移4开始(需要4字节对齐,偏移必须是4的倍数),当前偏移1,故a后填充3字节
    short c;     // 2字节,直接放置(需要2字节对齐,偏移必须是2的倍数)
    double d;    // 8字节,需从偏移8开始(需要4字节对齐,偏移必须是4的倍数),当前偏移10,故c后填充2字节
};
// 32位系统默认:通常是4字节对齐
// 最大对齐要求:max(1, 4, 2, 4) = 4字节
// 大小是:(1 + 3) + 4 + (2 + 2) + 8 = 20字节

// 在64位系统下的大小
struct Example64 {
    char a;      // 1字节,偏移 0,占用1字节
    int b;       // 4字节,需从偏移4开始(需要4字节对齐,偏移必须是4的倍数),当前偏移1,故a后填充3字节
    short c;     // 2字节,直接放置(需要2字节对齐,偏移必须是2的倍数)
    double d;    // 8字节,需从偏移16开始(需要8字节对齐,偏移必须是8的倍数),当前偏移10,故c后填充6字节
};
// 最大对齐要求:max(1, 4, 2, 8) = 8字节
// 大小是:(1 + 3) + 4 + (2 + 6) + 8 = 24字节

注意:

  • 每个成员按自己的对齐要求放置;
  • 填充只发生在前一个成员结束位置不满足后一个成员对齐要求时;
  • 结构体总大小必须是最大对齐要求的倍数,如果不满足则进行尾部填充;
    • 尾部填充是在所有成员都放置完毕之后,最后一步进行的。

如果使用#pragma pack的话

#pragma pack(1)  // 设置对齐系数为n(n通常是1, 2, 4, 8, 16)
struct Example {
    char a;      // 1字节
    int b;       // 4字节(紧跟在char后面)
    short c;     // 2字节(紧跟在int后面)
};
// 大小是:1 + 4 + 2 = 7字节(无填充)
#pragma pack()   // 恢复默认对齐

2.1.2. 联合体的内存分布

联合体(Union)是C语言中的一种特殊数据结构,所有成员共享同一块内存。没什么特殊的,顺带整理下。

规则:

  • 共享内存:所有成员从同一内存地址开始;
  • 大小由最大成员决定:联合体大小 = 最大成员的大小;
  • 同一时刻只能使用一个成员:写入一个成员会覆盖其他成员的值;
// 在64位系统下的大小
union Example64 {
    char a;           // 1字节
    int b;            // 4字节
    double c;         // 8字节
    struct {
        long long x;  // 8字节,直接放置(需要8字节对齐,偏移必须是8的倍数)
        int y;        // 4字节,直接放置(需要4字节对齐,偏移必须是4的倍数)
                      // 由于结构体总大小不满足8字节倍数,所以尾部填充4字节到16
    } d;              // 16字节,对齐要求8(按long long对齐)
};

// 计算:
// 先计算结构体d的大小为16字节
// 最大成员大小 = max(1, 4, 8, 16) = 16
// 最大对齐要求 = max(1, 4, 8, 8) = 8
// 联合体大小 = 16字节

2.1.3. 指针与二级指针

指针:存储变量内存地址的变量,解引用一次得到目标变量的值。

二级指针:指向指针(指针变量内存地址的变量)的指针,解引用一次得到一级指针的值(目标变量的地址),解引用两次得到目标变量的值

    // ┌───────────────┬────────────┬────────────┐
    // │               │  Address   │  Value     │
    // ├───────────────┼────────────┼────────────┤
    // │  variable     │  0x1000    │  0         │
    // ├───────────────┼────────────┼────────────┤
    // │  pVariable    │  0x2000    │  0x1000    │
    // ├───────────────┼────────────┼────────────┤
    // │  ppVariable   │  0x3000    │  0x2000    │
    // └───────────────┴────────────┴────────────┘

    int variable = 0;
    void *pVariable = &variable;   // 一级void指针
    void **ppVariable = &pVariable; // 二级void指针

    // 二级指针解引用层级分析:
    printf("&ppVariable = %p\n", &ppVariable);             // 0x3000 (ppVariable自己的地址)
    printf("ppVariable = %p\n", ppVariable);               // 0x2000 (ppVariable的值,也就是pVariable的地址)
    printf("*ppVariable = %p\n", *(int *)*ppVariable);     // 0x1000 (pVariable的值,也就是variable的地址)
    printf("**ppVariable = %d\n", *(int *)*ppVariable);    // 42 (value的值)

注意:

  • 这里对于void**不能直接解引用,需要转换
  • 当解引用类型不匹配时
    • 大小匹配但解释不同(通常不会立即崩溃)
    • 访问超出边界(很可能崩溃),比如解引用要读两个字节类型,但实际只有一个字节有效,会读取相邻内存,很可能导致段错误,从而崩溃。

常见问题

整型数据溢出与回绕

在整理溢出与回绕前,快速回顾下原码、反码与补码。

  • 原码,最符合人类直觉,最高位为符号位,剩余位表示绝对值。不适合通用整数运算。
    • +1:0000 0001,-1:1000 0001
  • 反码,正数的反码等于原码,负数符号位为 1,数值位是绝对值的按位取反。
    • +1:0000 0001,-1:1111 1110
  • 补码,是一种二进制有符号数表示法,在计算机中使用。正数的补码与原码相同,负数的补码为反码+1。
    • +1:0000 0001,-1:1111 1111

有符号整数溢出:

通常发生在同符号相加或异符号相减的情况下,导致符号位的异常。

以有符号8位为例,例如 127 (0111 1111) + 1 (0000 0001),结果为1000 0000(补码),这里理想结果应该是128。

补码的数学定义: \(\text{数值} = \begin{cases} x, & 0 \leq x \leq 2^{n-1}-1 & \quad (\text{正数或零}) \\ x - 2^n, & 2^{n-1} \leq x \leq 2^n-1 & \quad (\text{负数}) \end{cases}\)

x = 128,n = 8,套用上面公式带入可得出 128 - 256 = -128,所以这里的实际计算结果为-128,与预期的128结果不符。

int8_t有符号整型溢出。是未定义行为,意味着不同编译器的行为下结果可能不一样(通常回绕),需要注意有符号整型的溢出。溢出往往伴随着风险。

无符号整数溢出:

是明确定义行为,遵循“模运算”规则,数值回绕。

  • 当值超过整型最大值时,会从0开始重新计数。
  • 当值低于0时,会从最大值开始回绕。

例如 2(0000 0010) - 254(1111 1110) 等价为 2(0000 0010) + 254(0000 0010)(补码),结果为0000 0100(补码)

Q: 上述都是用补码进行运算,为什么不用原码运算?
A: 对于原码来说,虽然更符合人的的直接,但是加减运算比较麻烦,需要处理符号位,存在两个0等问题。而补码,硬件实现简单高效,一个加法器搞定所有加减法(无论是正数加正数、正数加负数,还是负数加负数,全部直接用无符号加法器计算),这是计算机采用补码的根本原因。

二级指针用法错误

我有一个朋友🤥,他需要实现一个接口,希望通过这个接口,能够获取结构体中的指针对象,并且外部能够获取并修改这个变量。

在这种情况下,用指针肯定是不行的,指针只能取接目标指针的地址,我们需要的是获取这个地址的值,所以自然要用二级指针。于是设计了这样一个接口。

int32_t CmnGetModbusRegisterVariable(uint16_t          u16Addr,
                                     void            **ppVariable,
                                     uint8_t          *u8Access,
                                     int16_t          *ps16VarMaxinum,
                                     int16_t          *ps16VarMininum,
                                     ModbusDataType_E *eType,
                                     uint8_t          *u8Factor)

接口设计好了,我们应该怎么去接这个变量呢?那我们肯定需要用一个指针去接。这样才能修改指针的值。

    void* pValue = NULL;
    // 错误
    CmnGetModbusRegisterVariable(u16ParamAddr, (void **)pValue, NULL, &s16ParamMaxinum, &s16ParamMininum, &eType, &u8Factor);

    // 正确
    CmnGetModbusRegisterVariable(u16ParamAddr, &pValue, NULL, &s16ParamMaxinum, &s16ParamMininum, &eType, &u8Factor);

但是经过测试始终接不到值,接口里面的二级指针是有的值的但是传不出来,而且程序还崩溃,有点小红温了。

发现了这是一个低级错误,对指针理解上出了问题,当时可能代码写昏头了,认为我需要传入一个指针进去,去接这个变量,但是没有值,难道是因为没有传地址进去?甚至创建了一个临时变量,将pValue指向临时变量,约等于将变量的地址传进去,但是变量的地址怎么能修改呢?所以pValue一直接不到值。

实际上就是错误把pValue当成了二级指针传,但实际上传的是一个NULL指针本身,对NULL指针进行操作直接崩溃触发段错误。

void* pValue = NULL;这里要理解pValue是我声明的指针变量,他指向NULL,没有有效的对象。这个时候我需要将指针变量的地址传进去,通过函数内部改变pValue的指向,从而获取并改变pValue指向对象的值。

指针变量 ≠ 它指向的内存,pValue本身也是一个变量,它占内存(32 位 MCU 上一般 4 字节),它存的是一个地址。

类型不匹配导致的内存越界写崩溃

问题背景和上一个问题一样,对于void *pValue的操作问题,因为我要对获取到的变量进行写操作,改变目标地址的值,我用void* 类型去接了不同类型的数据,但是没有分别做处理

    *(uint16_t*)pVariable = u16Value;
    // 当pVariable实际指向uint8_t变量时,程序在操作某些寄存器地址发生崩溃

uint8_t只占1字节,uint16_t写入2字节,必然越界写内存。

是否立刻崩溃,取决于:相邻内存是否可写?是否覆盖了关键数据?是否结构体边界?是否保护块?

崩溃是必然。必须按照真实类型写入!!

    // 正确做法
    switch (reg->u8DataType) {
    case REG_U8:
        *(uint8_t*)reg->pVariable = (uint8_t)u16Value;
        break;
    case REG_U16:
        *(uint16_t*)reg->pVariable = u16Value;
        break;
    case REG_S16:
        *(int16_t*)reg->pVariable = (int16_t)u16Value;
        break;
    }

results matching ""

    No results matching ""