在嵌入式里混用 C 与 C++:为什么、怎么做、有哪些坑

第一次翻大型嵌入式项目的源码,很多人会被一件事搞懵:明明都是给 STM32 写的代码,怎么有的文件是 .c,有的却是 .cpp?C 和 C++ 混在一起写,到底是谁调谁?又是怎么接上的?

VESC 的固件里,ChibiOS 内核是 C,电机控制层却是 C++;Arduino 的底层驱动是 C,上面套的却是一层 C++ 封装。这不是作者没想清楚,而是一种非常成熟的分工方式。

这篇文章把"C 与 C++ 混用"这件事从头讲清楚:为什么要混用、C++ 调 C 和 C 调 C++ 分别怎么做、extern "C" 到底在干什么、以及嵌入式里用 C++ 有哪些坑。


一、为什么会有"C 和 C++ 混用"这回事

答案其实就一句话:C 做底座,C++ 做应用。

一个典型的 STM32 工程,从下到上是这样的:

  • 最底层是 C:RTOS 内核(FreeRTOS / ChibiOS)、CMSIS、厂商 HAL 库(ST / NXP)、启动文件、链接脚本。
  • 上层是 C++:应用逻辑、电机控制、FOC 算法、编码器、PID、通信协议栈。

底层必须是 C,因为它要当"通用底座"——任何语言、任何上层框架都要能调用它,C 的 ABI(函数调用约定 + 无名字修饰)就是事实上的公共标准。

上层用 C++,是因为上层业务复杂、需要抽象。当系统开始管理越来越多的设备、模块和控制逻辑时,单纯靠一堆分散的函数和 if-else 分支,代码会越来越难维护——这一点在《用函数指针在 C 里模拟虚函数表》里已经展开讲过,C++ 就是把这些"模拟"变成了语言本身的能力。

所以"混用"的本质是分工:C 负责和硬件、内核打交道,C++ 负责把复杂业务写得清楚。


二、C++ 到底给嵌入式带来了什么

核心是四个字:零成本抽象。模板、类、运算符重载,写对了编译出来和 C 一样大、一样快,但源码可读性和可维护性高一个量级。

C 的痛点 C++ 的解法
struct Motor { ... } + 一堆 motor_init() / motor_update() 全局函数 类:Motor 自带方法 m.update(),状态和函数绑在一起
忘 free、忘关外设、忘解锁 RAII:析构函数自动释放资源
写一份 float 版、再写一份 double 版,或用 void* + 函数指针(丑且危险) 模板:一份 PID<T> 通用,编译期展开,零运行时开销
vec3_add(a, vec3_mul(b, c)) 数学读起来费劲 运算符重载:a + b * c,FOC 里全是向量/四元数,差别巨大
回调只能传 void* 上下文 lambda / 函数对象,类型安全
三角函数表要手动算好贴进数组 constexpr:编译期算,运行时零成本

举两个最直观的例子。

例子一:PID 的模板

纯 C 里,如果既想用 float 又要用 double,通常只能写两份几乎一样的代码,或者退化成 void* 传参数。C++ 里一份就够了:

template <typename T>
struct PID {
T kp, ki, kd;
T integral = 0;
T prev_err = 0;

T update(T err, T dt) {
integral += err * dt;
T d = (err - prev_err) / dt;
prev_err = err;
return kp * err + ki * integral + kd * d;
}
};

PID<float> current_loop; // 电流环用 float
PID<double> vel_loop; // 速度环用 double

编译期会为 PID<float> 和 PID<double> 各展开一份,运行时没有任何额外开销。

例子二:FOC 里的向量运算

FOC 里有大量向量、四元数、坐标变换。纯 C 写出来是这样的:

vec3_t v = vec3_add(a, vec3_mul(b, k));

嵌套一多,括号就能把人绕晕。C++ 重载运算符之后:

Vec3 v = a + b * k;

公式读起来和数学书上一模一样,维护成本天差地别。

VESC 的电机控制层就是靠这些写出来的,代码量比纯 C 少,还更好改。


三、方向一:C++ 调 C(最常见,最顺)

这是主流做法,也是最简单的:C 库的接口对 C++ 天然可用。

把 main.c 改名 main.cpp,直接调 HAL 就行:

// main.cpp —— 把 main.c 改名 main.cpp 即可
#include "stm32g4xx_hal.h"

int main(void) {
HAL_Init();
// C++ 代码直接调 C 的 HAL 函数
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
}

这里能直接调,靠的是一个前提:C 头文件里得有 extern "C" 保护。STM32 的 HAL 头文件里都是这样的:

// stm32g4xx_hal.h 里(简化)
#ifdef __cplusplus
extern "C" {
#endif

HAL_StatusTypeDef HAL_Init(void);
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState);

#ifdef __cplusplus
}
#endif

为什么需要这个 extern "C"?这就引出下面要讲的核心问题:名字修饰。


四、方向二:C 调 C++,为什么不能直接调

假设你在维护一份已有的纯 C 工程,想往里塞一个用 C++ 写的算法模块。你可能会直觉地这么写:

// algo.cpp —— C++ 写的算法
double filter_value(double x) {
return x * 0.5;
}
// main.c —— C 代码直接声明并调用
extern double filter_value(double x);

double y = filter_value(3.14);

分别编译没问题,但一链接就报错:

undefined reference to `filter_value'

你已经声明了、也调用了,为什么还找不到?原因是 名字修饰(Name Mangling)。

名字修饰到底发生了什么

C++ 为了支持函数重载——也就是同名函数靠参数类型区分——必须把参数类型信息"编码"进符号名里。否则链接器看到两个 foo 就分不清了。

在 GCC/Clang(遵循 Itanium C++ ABI)下,这条规则是这样的:

  • C++ 的 double filter_value(double) 会被编译成 _Z12filter_valued
  • 而 C 的 double filter_value(double) 编译出来还是 filter_value

用 nm 看两个目标文件的符号表,一目了然:

# C++ 版本
g++ -c algo.cpp -o algo.o
nm algo.o | grep filter
# 0000000000000000 T _Z12filter_valued

# C 版本
gcc -c algo.c -o algo.o
nm algo.o | grep filter
# 0000000000000000 T filter_value

那个 _Z12filter_valued 可以拆开读:

  • _Z:这是一个全局符号
  • 12:函数名 filter_value 的长度是 12 个字符
  • filter_value:函数名本体
  • d:参数类型是 double

C 链接器按裸名 filter_value 去找符号,结果 C++ 目标文件里存的是 _Z12filter_valued,名字对不上,自然就报 undefined reference。


五、extern "C" 到底做了什么

extern "C" 就是告诉 C++ 编译器:这一段按 C 的规则命名,别修饰。

extern "C" double filter_value(double x) {
return x * 0.5;
}

再编译一次,符号表里就恢复成了裸名:

g++ -c algo.cpp -o algo.o
nm algo.o | grep filter
# 0000000000000000 T filter_value

这样 C 代码就能找到它了。

但 extern "C" 有几个硬性规则,记清楚能少踩很多坑:

  1. 不能重载。extern "C" 块里同名函数只能有一个——因为修饰之后名字都一样,链接器根本分不清哪个是哪个。

  2. 只能用于自由函数,不能用于类或成员函数。类的成员函数必须是 C++ 链接,因为它的调用约定(隐含 this 指针)是 C 没有的。

  3. 边界上只能传 C 兼容的类型。double、int、指针、POD 结构体可以;std::string、类对象、引用、模板实例化这些,C 侧根本不认识,不能跨过这条边界。


六、正确的头文件写法

要让同一个头文件既被 .c 包含、又被 .cpp 包含,标准写法是套一层条件编译:

// algo.h
#ifdef __cplusplus
extern "C" {
#endif

double filter_value(double x);

#ifdef __cplusplus
}
#endif

原理是:

  • .c 文件包含时,__cplusplus 宏不存在,头文件内容原样展开,就是普通的 C 声明。
  • .cpp 文件包含时,__cplusplus 存在,函数声明被包进 extern "C" {},按 C 链接处理。

这一层保护,就是所有厂商 HAL 头文件里都有的那个东西。你以后自己写"给 C 用的 C++ 库",也照这个模板来。


七、跨语言传复杂数据:不透明指针

前面说了,extern "C" 边界上不能传 std::string、不能传类对象。那如果需要传递"有状态的对象"怎么办?

答案是 不透明指针(opaque pointer):C 侧只拿一个 struct Xxx* 指针,至于这个结构体里面长什么样,C 侧永远不知道。

// filter.h
#ifdef __cplusplus
extern "C" {
#endif

typedef struct Filter Filter;

Filter* filter_create(float cutoff);
void filter_destroy(Filter* f);
double filter_process(Filter* f, double x);

#ifdef __cplusplus
}
#endif

注意:这里只 typedef 了一个不完整的 struct Filter,没有给出它的定义。C 侧能用 Filter* 这个指针类型,但无法访问它内部的任何成员。

真正的定义藏在 C++ 里:

// filter.cpp
#include "filter.h"

struct Filter {
double cutoff;
double prev;
};

Filter* filter_create(float cutoff) {
Filter* f = new Filter;
f->cutoff = cutoff;
f->prev = 0.0;
return f;
}

void filter_destroy(Filter* f) {
delete f;
}

double filter_process(Filter* f, double x) {
// 一阶低通:y = a*x + (1-a)*prev
double a = f->cutoff;
f->prev = a * x + (1.0 - a) * f->prev;
return f->prev;
}

这样,C 侧完全不知道 Filter 内部用了什么,实现细节被彻底封装起来了——这本身就是一种很好的设计。

一个小提醒:上面的 new / delete 在 PC 上没问题,但在 MCU 上要慎用(见第九节的坑)。嵌入式里通常改成静态内存池或预分配数组,把 create 换成"从池子里取一个"。


八、完整案例:往 C 工程里塞一个 C++ 滤波算法

把前面几节串起来,给一个从编译到链接都能跑通的完整例子。

文件结构

.
├── filter.h # C / C++ 共用的头文件(extern "C" 保护)
├── filter.cpp # C++ 实现
└── main.c # 纯 C 的调用方

filter.h

#ifdef __cplusplus
extern "C" {
#endif

typedef struct Filter Filter;

Filter* filter_create(float cutoff);
void filter_destroy(Filter* f);
double filter_process(Filter* f, double x);

#ifdef __cplusplus
}
#endif

filter.cpp

#include "filter.h"

struct Filter {
double cutoff;
double prev;
};

Filter* filter_create(float cutoff) {
Filter* f = new Filter;
f->cutoff = cutoff;
f->prev = 0.0;
return f;
}

void filter_destroy(Filter* f) {
delete f;
}

double filter_process(Filter* f, double x) {
double a = f->cutoff;
f->prev = a * x + (1.0 - a) * f->prev;
return f->prev;
}

main.c

#include <stdio.h>
#include "filter.h"

int main(void) {
Filter* f = filter_create(0.3f);

double y = 0.0;
for (int i = 0; i < 5; ++i) {
double in = (i == 0) ? 1.0 : 0.0; // 一个阶跃输入
y = filter_process(f, in);
printf("step %d -> %.4f\n", i, y);
}

filter_destroy(f);
return 0;
}

编译和链接

g++ -c filter.cpp -o filter.o    # C++ 文件用 g++ 编译
gcc -c main.c -o main.o # C 文件用 gcc 编译
g++ main.o filter.o -o app # 关键:链接用 g++,而不是 gcc
./app

输出:

step 0 -> 0.3000
step 1 -> 0.2100
step 2 -> 0.1470
step 3 -> 0.1029
step 4 -> 0.0720

最后一步是重点,也是最容易踩的坑:链接必须用 g++,不能用 gcc。

因为 filter.o 里可能用到了 C++ 标准库(比如前面的 std::sin、std::vector),这些符号在 libstdc++ 里。gcc 链接时不会自动带上 C++ 运行库,会报一堆 undefined reference to std::xxx;g++ 链接则会自动链接 libstdc++。

一句话记住:只要目标文件里混了 C++ 编译产物,链接就用 g++。

如果你用 CMake,就不用操这个心——它根据文件扩展名自动判断语言,链接时也会正确选择链接器:

add_executable(app main.c filter.cpp)   # 自动混编,自动用 C++ 链接

九、嵌入式里用 C++ 的几个坑

C++ 上 MCU 不是没代价的,这几个坑要先挂个印象:

1. 关掉异常和 RTTI

C++ 的异常(try/throw)和 RTTI(dynamic_cast/typeid)都会显著增大体积、引入运行时开销。嵌入式里几乎必开这两个编译选项:

-fno-exceptions -fno-rtti

代价是:不能用 throw,不能用 dynamic_cast。但嵌入式里本来也很少用这两个。

2. 少用 / 不用 new

MCU 上堆很小,频繁 new/delete 会产生碎片,导致"内存明明够,却申请不出来"的诡异问题。优先用:

  • 全局或 static 对象(静态分配,地址确定)
  • 自定义内存池(从一个大数组里划块)
  • 编译期就确定大小的 std::array

3. 全局对象的构造函数

C++ 允许定义全局对象,它们的构造函数必须在 main() 之前跑完。这个"跑构造函数"的动作由启动代码里的 __libc_init_array 完成。好消息是:主流工具链的启动文件(startup_xxx.s)已经帮你调好了,别自己乱改启动文件就行。

但反过来意味着:别在全局对象构造函数里做依赖硬件的事——那时候时钟、外设还没初始化。

4. 中断服务函数

ISR 要进中断向量表,签名是固定的(无参数、无返回值)。所以:

  • ❌ 不能是非静态成员函数——它隐含 this 指针,签名对不上。
  • ✅ 用自由函数,或者类的静态成员函数。
class Encoder {
public:
static void TIM_IRQHandler(); // 可以作为 ISR
};

5. 模板和虚函数的体积

模板用得克制,展开后和 C 手写没区别;但同一个模板实例化出几十种类型,代码体积会跟着涨。虚函数会引入 vtable 指针和间接调用开销,性能敏感的热路径里要留意。


十、什么时候用哪种,一句话总结

场景 推荐做法
全新 STM32 工程 整份用 C++,main.cpp,C++ 调 C HAL(VESC 路线)
已有纯 C 工程,想加一个 C++ 算法模块 extern "C" 包一层,C 调 C++
只想做 PC 端工具 / 上位机 纯 C++,不用纠结混用

最后用一句话收尾:

C 是底座,C++ 是上层;C++ 调 C 走直路,C 调 C++ 走 extern "C" 这座桥。桥的两头,只能走 C 认识的东西。