引言

很多有 5 年以上 Linux C 开发经验的嵌入式工程师,在转向 C++ 时都会陷入一个致命误区:一开始就去啃厚厚的《C++ Primer》,花了几个月时间学了一堆模板元编程、异常处理、RTTI 等工作中 90% 场景根本用不到的高级特性,结果到了实际项目中,连一个简单的类都写不好,甚至还不如用 C 写得顺手。

嵌入式 C++ 的核心哲学是:用最少的特性,解决最实际的问题。

本文作为《嵌入式 Linux C++ 从入门到实战》系列的第一篇,将彻底摒弃学院派的教学方式,直接带你搭建最精简的 Linux C++ 开发环境,只学习 C++ 相比 C 最核心、最刚需的语法差异。我保证,你跟着本文一步步操作,1 小时内就能写出第一个可运行的 C++ 程序,并能将你现有的任意一个 C 小工具无缝改造成 C++ 版本

前置要求:具备基础的 Linux C 开发经验,了解 gcc 编译、Makefile 基本使用、gdb 基本调试方法。


一、Linux C++ 开发环境搭建(15 分钟)

嵌入式 Linux C++ 开发环境与 C 开发环境高度兼容,你不需要重新安装操作系统或虚拟机,只需要在现有 C 环境基础上补充几个工具即可。

1.1 g++ 编译器安装与基本使用

C++ 程序使用g++编译器编译,而不是 C 语言的gcc。虽然 gcc 也能编译 C++ 代码,但它不会自动链接 C++ 标准库,会导致各种奇怪的链接错误。

Ubuntu/Debian 系统安装:

# 更新软件源
sudo apt update

# 安装完整的C/C++开发工具链
sudo apt install build-essential g++ gdb make -y

# 验证安装
g++ --version
gdb --version

CentOS/RHEL 系统安装:

sudo yum groupinstall "Development Tools"
sudo yum install gcc-c++ gdb make -y

最基础的编译命令:

# 单文件编译
g++ -o hello hello.cpp

# 运行程序
./hello

常用编译选项(与 gcc 基本一致,但有几个 C++ 专属选项):

选项 说明 C++ 专属
-o <file> 指定输出文件名
-g 生成调试信息,用于 gdb 调试
-O2 开启二级优化,嵌入式发布版本必用
-Wall 开启所有警告
-std=c++11 指定 C++ 标准版本(推荐 C++11 及以上)
-lstdc++ 手动链接 C++ 标准库(极少需要)

嵌入式开发最佳实践:统一使用-std=c++11标准。C++11 是嵌入式领域最成熟、最广泛支持的标准,几乎所有现代交叉编译器都完美支持。C++17 及以上版本在很多旧款嵌入式芯片的编译器中支持不佳。

1.2 C++ 编译链接过程与 C 的区别

很多 C 工程师转 C++ 时遇到的第一个坑就是编译链接错误。虽然整体流程都是 "预处理→编译→汇编→链接",但 C++ 有几个关键差异:

  1. 名字修饰(Name Mangling)C++ 为了支持函数重载,会对函数名进行修饰。例如void func(int)会被修饰成_Z4funci,而void func(double)会被修饰成_Z4funcd。这就是为什么 C++ 代码不能直接调用 C 编译的库,反之亦然。

  2. 全局构造与析构C++ 全局对象的构造函数会在main函数执行之前调用,析构函数会在main函数执行之后调用。这一点在嵌入式开发中非常重要,因为它会影响系统的启动时间和初始化顺序。

  3. 标准库链接g++ 会自动链接libstdc++.so,而 gcc 不会。如果你用 gcc 编译 C++ 代码,必须手动加上-lstdc++选项。

1.3 gdb 调试 C++ 程序的特殊技巧

gdb 完全支持 C++ 调试,但有几个针对 C++ 特性的特殊命令,是 C 工程师必须掌握的:

1. 查看类成员变量

gdb

# 假设我们有一个类Person,实例化了对象p
(gdb) p p  # 直接打印对象,会显示所有成员变量
$1 = {name = "Zhang San", age = 25, id = 1001}

# 打印单个成员变量
(gdb) p p.age
$2 = 25

2. 在类成员函数上设置断点

gdb

# 方式1:类名::函数名
(gdb) b Person::showInfo
Breakpoint 1 at 0x4008a6: file hello.cpp, line 15.

# 方式2:文件名:行号(推荐,更直观)
(gdb) b hello.cpp:15

3. 查看虚函数表(进阶)

gdb

# 查看对象的虚函数表指针
(gdb) p *(void**)p
$3 = (void *) 0x400c80 <vtable for Person+16>

# 查看虚函数表内容
(gdb) x/3a 0x400c80
0x400c80 <_ZTV6Person+16>: 0x4008a6 <Person::showInfo()>
0x400c88 <_ZTV6Person+24>: 0x4008d2 <Person::~Person()>

嵌入式调试注意事项:如果调试交叉编译的程序,需要使用对应架构的 gdb(如arm-linux-gnueabihf-gdb),并且确保编译时加上了-g选项。


二、命名空间 namespace(10 分钟)

命名空间是 C++ 相比 C 最基础也是最重要的特性之一,它的唯一目的就是解决大型工程中的命名冲突问题

2.1 为什么需要命名空间

在 C 语言中,所有全局变量和函数都在同一个全局命名空间中。当工程规模超过 10 万行代码,或者集成多个第三方库时,命名冲突几乎是不可避免的。

例如:

// 模块A:uart.c
void init() {
    // 初始化串口
}

// 模块B:gpio.c
void init() {
    // 初始化GPIO
}

这两个init函数在链接时会报 "多重定义" 错误。在 C 语言中,我们只能通过给函数名加前缀来解决:uart_init()gpio_init()。但这种方式非常丑陋,而且当前缀越来越长时,代码可读性会急剧下降。

C++ 的命名空间完美解决了这个问题:

// 模块A:uart.cpp
namespace uart {
    void init() {
        // 初始化串口
    }
}

// 模块B:gpio.cpp
namespace gpio {
    void init() {
        // 初始化GPIO
    }
}

// 调用
int main() {
    uart::init();  // 调用串口初始化
    gpio::init();  // 调用GPIO初始化
    return 0;
}

2.2 std 标准命名空间的正确使用方式

C++ 标准库的所有内容都定义在std命名空间中。很多新手喜欢在代码开头写using namespace std;,这是一个非常不好的习惯,尤其是在大型工程中。

错误用法(不推荐):

#include <iostream>
using namespace std;  // 污染全局命名空间

int main() {
    cout << "Hello World" << endl;
    return 0;
}

正确用法(推荐):

#include <iostream>

int main() {
    std::cout << "Hello World" << std::endl;
    return 0;
}

折中用法(在 cpp 文件中使用):

#include <iostream>

// 只在当前文件中引入std命名空间,不会影响其他文件
using std::cout;
using std::endl;

int main() {
    cout << "Hello World" << endl;
    return 0;
}

绝对禁止:在头文件中使用using namespace std;!这会导致所有包含这个头文件的代码都被污染,引发难以排查的命名冲突。

2.3 嵌入式工程中命名空间的命名规范

在嵌入式开发中,我们通常按照以下规则命名命名空间:

  1. 一级命名空间:公司或项目名称(如mycompanysmartlock
  2. 二级命名空间:模块名称(如uartgpiodisplay
  3. 三级命名空间:子模块或功能(如uart::debuggpio::led

示例:

namespace smartlock {
    namespace uart {
        void init(int baudrate);
        void send(const char* data, int len);
    }

    namespace gpio {
        namespace led {
            void on(int id);
            void off(int id);
        }
    }
}

// 调用
smartlock::uart::init(115200);
smartlock::gpio::led::on(0);

三、引用 &(嵌入式开发必备)(15 分钟)

引用是 C++ 提供的一个语法糖,它本质上是一个 "变量的别名"。在嵌入式开发中,引用的使用频率甚至超过了指针,是必须掌握的核心特性。

3.1 引用与指针的本质区别

很多 C 工程师会把引用和指针混为一谈,但它们有本质的区别:

特性 指针 引用
定义 存储变量地址的变量 变量的别名
空值 可以为 NULL 不能为 NULL,必须初始化
重新赋值 可以指向其他变量 不能,永远指向初始化时的变量
内存占用 占用 4/8 字节(与架构有关) 不占用额外内存
多级 支持多级指针(int** 不支持多级引用

代码示例:

int a = 10;

// 指针
int* p = &a;
*p = 20;  // 解引用才能修改a的值

// 引用
int& ref = a;  // ref是a的别名
ref = 30;      // 直接修改a的值,不需要解引用

std::cout << a << std::endl;  // 输出30

3.2 引用替代二级指针作为函数出参

在 C 语言中,当我们需要函数返回多个值时,通常使用指针作为出参。如果需要返回一个指针类型的值,就必须使用二级指针,这非常容易出错。

C 语言写法(二级指针):

// 分配内存,通过二级指针返回
void allocate_memory(int** ptr, int size) {
    *ptr = (int*)malloc(size * sizeof(int));
}

int main() {
    int* arr = NULL;
    allocate_memory(&arr, 10);  // 必须传地址
    // 使用arr
    free(arr);
    return 0;
}

C++ 写法(引用):

// 分配内存,通过指针引用返回
void allocate_memory(int*& ptr, int size) {
    ptr = new int[size];
}

int main() {
    int* arr = NULL;
    allocate_memory(arr, 10);  // 直接传变量,不需要取地址
    // 使用arr
    delete[] arr;
    return 0;
}

可以看到,引用的写法更加简洁直观,而且避免了二级指针容易出现的解引用错误。

3.3 引用在函数参数传递中的优势

在 C 语言中,函数参数传递默认是值传递。当传递一个大的结构体时,会发生一次完整的拷贝,这在资源受限的嵌入式系统中是非常昂贵的。

C 语言写法(值传递,有拷贝):

typedef struct {
    int id;
    char name[32];
    float temperature;
    float humidity;
} SensorData;

// 传递结构体,发生拷贝
void print_sensor_data(SensorData data) {
    printf("ID: %d, Temp: %.2f, Hum: %.2f\n", data.id, data.temperature, data.humidity);
}

C++ 写法(const 引用,无拷贝):

struct SensorData {
    int id;
    char name[32];
    float temperature;
    float humidity;
};

// const引用传递,无拷贝,同时防止数据被修改
void print_sensor_data(const SensorData& data) {
    std::cout << "ID: " << data.id 
              << ", Temp: " << data.temperature 
              << ", Hum: " << data.humidity << std::endl;
}

嵌入式开发最佳实践:所有非基本类型(结构体、类、数组)的函数参数,都应该使用const引用传递。这可以避免不必要的拷贝,提高程序运行效率。


四、const 高阶用法(15 分钟)

const 是 C++ 中最强大的关键字之一,它的用法比 C 语言丰富得多。在嵌入式开发中,const 不仅可以提高代码的可读性和可维护性,还可以帮助编译器进行更好的优化,甚至可以保护硬件寄存器不被误修改。

4.1 const 修饰变量(替代 #define 定义常量)

在 C 语言中,我们通常使用#define来定义常量,但#define是预处理指令,没有类型检查,容易出错。C++ 推荐使用const来定义常量。

C 语言写法:

#define MAX_BUFFER_SIZE 1024
#define PI 3.1415926

C++ 写法:

const int MAX_BUFFER_SIZE = 1024;
const double PI = 3.1415926;

const常量相比#define的优势:

  1. 有类型检查,编译器会在编译时发现类型不匹配的错误
  2. 有作用域限制,可以定义在函数内部或命名空间中
  3. 可以取地址,而#define不能
  4. 支持复杂类型(如结构体、类)

4.2 const 修饰函数参数

当我们不希望函数修改传入的参数时,应该用const修饰它。这不仅可以防止参数被误修改,还可以让函数接受const类型的参数。

// 错误:不能修改const参数
void bad_func(const int& x) {
    x = 10;  // 编译错误
}

// 正确:只读访问参数
void good_func(const int& x) {
    std::cout << x << std::endl;
}

4.3 const 修饰函数返回值

当函数返回一个引用或指针时,如果不希望返回值被修改,应该用const修饰返回值。

class Array {
private:
    int data[10];
public:
    // 返回const引用,防止外部修改数组元素
    const int& operator[](int index) const {
        return data[index];
    }
};

int main() {
    Array arr;
    std::cout << arr[0] << std::endl;  // 正确
    arr[0] = 10;  // 编译错误,不能修改const引用
    return 0;
}

4.4 const 在嵌入式硬件编程中的应用

这是 const 在嵌入式开发中最有价值的用法之一。我们可以用const来修饰指向硬件寄存器的指针,保护只读寄存器不被误写。

// 定义GPIO寄存器地址
#define GPIOA_BASE 0x40020000

// GPIOA输入数据寄存器(只读)
const volatile uint32_t* const GPIOA_IDR = (const volatile uint32_t*) (GPIOA_BASE + 0x10);

// GPIOA输出数据寄存器(可写)
volatile uint32_t* const GPIOA_ODR = (volatile uint32_t*) (GPIOA_BASE + 0x14);

int main() 
{
    // 正确:读取输入寄存器
    uint32_t input = *GPIOA_IDR;
    
    // 错误:不能写入只读寄存器,编译时就会报错
    *GPIOA_IDR = 0x1234;  // 编译错误
    
    // 正确:写入输出寄存器
    *GPIOA_ODR = 0x0001;
    
    return 0;
}

在这个例子中,GPIOA_IDR被定义为const volatile uint32_t* const,这意味着:

  • 第一个const:指针指向的内容是只读的,不能修改
  • volatile:告诉编译器不要优化对这个地址的访问
  • 最后一个const:指针本身是只读的,不能指向其他地址

这种写法可以在编译阶段就防止对只读寄存器的误写,避免了运行时难以排查的硬件错误。


五、实战:将 C 语言 Hello World 改造成 C++ 版本(5 分钟)

现在,让我们把一个经典的 C 语言 Hello World 程序改造成 C++ 版本,巩固今天所学的知识。

C 语言版本:

#include <stdio.h>
#include <stdlib.h>

#define MAX_NAME_LEN 32

typedef struct {
    char name[MAX_NAME_LEN];
    int age;
} Person;

void print_person(const Person* p) 
{
    printf("Name: %s, Age: %d\n", p->name, p->age);
}

int main() 
{
    Person p;
    snprintf(p.name, MAX_NAME_LEN, "Zhang San");
    p.age = 25;
    
    print_person(&p);
    
    return 0;
}

C++ 版本:

#include <iostream>
#include <cstring>  // C标准库头文件在C++中加c前缀

const int MAX_NAME_LEN = 32;  // 用const替代#define

struct Person 
{
    char name[MAX_NAME_LEN];
    int age;
};

// 用const引用替代const指针
void print_person(const Person& p)
{
    std::cout << "Name: " << p.name << ", Age: " << p.age << std::endl;
}

int main() 
{
    Person p;
    strncpy(p.name, "Zhang San", MAX_NAME_LEN);
    p.age = 25;
    
    print_person(p);  // 直接传对象,不需要取地址
    
    return 0;
}

编译运行:

g++ -std=c++11 -o hello hello.cpp
./hello
# 输出:Name: Zhang San, Age: 25

恭喜你!你已经成功写出了第一个 C++ 程序。这个程序虽然简单,但已经用到了今天学习的所有核心知识点:g++ 编译、const 常量、引用、std 命名空间。


六、今日总结与作业

今日核心知识点总结

  1. 开发环境:使用 g++ 编译 C++ 程序,推荐使用 C++11 标准
  2. 命名空间:解决命名冲突问题,禁止在头文件中使用using namespace std
  3. 引用:变量的别名,替代二级指针作为出参,用于函数参数传递避免拷贝
  4. const:替代 #define 定义常量,修饰函数参数和返回值,保护只读硬件寄存器

今日作业

  1. 将你最近写的一个 C 语言小工具(如串口调试、LED 控制)改造成 C++ 版本
  2. 尝试使用 gdb 调试你的 C++ 程序,设置断点并查看变量值
  3. 思考:在你的项目中,哪些地方可以用引用替代指针来提高代码可读性

下一篇预告

 

下一篇文章: << 【C++ -Day2】C++ 函数与内存管理 | 彻底告别 char * 字符串处理>>

原创不易,如果本文对你有帮助,欢迎点赞、收藏、关注三连!有任何问题都可以在评论区留言,我会及时回复。

 

更多推荐