
引言
C 和 C++ 是嵌入式开发中最常用的两种语言。很多项目同时使用两者——底层驱动和 HAL 用 C 写,上层业务逻辑用 C++ 写。C++ 本身就兼容大部分 C 语法,按理说混合编译应该很顺畅。
但实际操作时,你经常会遇到这样的报错:
undefined reference to `uart_init'
明明 uart_init 函数已经在 uart.c 中实现了,头文件也正确 include 了,为什么链接器就是找不到?
答案藏在 C++ 的一个"隐藏特性"里:名称修饰(Name Mangling)。而 extern "C" 就是解决这个问题的关键。
问题重现
先用一个最小的例子展示问题。三个文件:
// uart.h
#ifndef UART_H
#define UART_H
void uart_init(int baudrate);
void uart_send(const char *data, int len);
#endif
// uart.c (用 C 编译器编译)
#include "uart.h"
void uart_init(int baudrate)
{
// 配置波特率寄存器...
}
void uart_send(const char *data, int len)
{
// 发送数据...
}
// main.cpp (用 C++ 编译器编译)
#include "uart.h"
int main()
{
uart_init(115200);
uart_send("Hello", 5);
return 0;
}
编译:
gcc -c uart.c -o uart.o # C 编译器编译
g++ -c main.cpp -o main.o # C++ 编译器编译
g++ main.o uart.o -o app # 链接
链接阶段报错:
main.o: In function `main':
main.cpp:(.text+0x15): undefined reference to `uart_init(int)'
main.cpp:(.text+0x2a): undefined reference to `uart_send(char const*, int)'
注意错误信息中函数名带了参数类型签名 uart_init(int)、uart_send(char const*, int)——这就是名称修饰的痕迹。
根因:名称修饰(Name Mangling)
C 的符号命名
C 编译器生成目标文件时,函数名直接作为符号名。uart_init 在 .o 中的符号就是 uart_init,简单直接:
$ nm uart.o
00000000 T uart_init
00000020 T uart_send
C++ 的符号命名
C++ 编译器则会对函数名进行修饰(mangle),将参数类型、命名空间、类名等信息编码到符号名中:
$ nm main.o
U _Z9uart_initi
U _Z9uart_sendPKci
00000000 T main
uart_init(int) 变成了 _Z9uart_initi,uart_send(const char*, int) 变成了 _Z9uart_sendPKci。
链接器是按符号名匹配的。main.o 需要 _Z9uart_initi,但 uart.o 中只有 uart_init——名字对不上,链接失败。
C++ 为什么要修饰
C++ 支持 C 不支持的很多特性,这些特性要求同名函数能共存:
函数重载:
void print(int x); // 符号: _Z5printi
void print(double x); // 符号: _Z5printd
void print(const char*); // 符号: _Z5printPKc
三个函数名字都是 print,C 编译器无法区分,但 C++ 编译器通过不同的修饰名让它们在符号表中共存。
命名空间:
namespace hal {
void init(); // 符号: _ZN3hal4initEv
}
namespace app {
void init(); // 符号: _ZN3app4initEv
}
类成员函数:
class Uart {
void send(int data); // 符号: _ZN4Uart4sendEi
};
如果 C++ 不做名称修饰,这些同名函数就会在链接时冲突。名称修饰是 C++ 实现函数重载和命名空间的底层基础设施。
名称修饰规则详解
GCC/Clang 的 Itanium ABI
GCC 和 Clang 遵循 Itanium C++ ABI 标准,修饰规则如下:
_Z + [命名空间/类前缀] + 函数名长度 + 函数名 + 参数类型编码
参数类型编码表:
| C++ 类型 | 编码 | 示例 |
|---|---|---|
void | v | f() → _Z1fv |
int | i | f(int) → _Z1fi |
char | c | f(char) → _Z1fc |
float | f | f(float) → _Z1ff |
double | d | f(double) → _Z1fd |
bool | b | f(bool) → _Z1fb |
unsigned int | j | f(unsigned) → _Z1fj |
long | l | f(long) → _Z1fl |
const char* | PKc | f(const char*) → _Z1fPKc |
int* | Pi | f(int*) → _Z1fPi |
int& | Ri | f(int&) → _Z1fRi |
指针用 P 前缀,const 用 K 前缀,引用用 R 前缀。
命名空间和类用 N...E 包裹:
namespace hal { void init(int); }
→ _ZN3hal4initEi
^ ^^^ ^^^^ ^
| |N| |名字||参数
| 命名空间 E结束
_Z前缀
MSVC 的修饰风格
MSVC 使用不同的修饰方案,以 ? 开头:
void uart_init(int) → ?uart_init@@YAXH@Z
namespace hal { void init() } → ?init@hal@@YAXXZ
两种修饰方案互不兼容。这也是为什么不同编译器编译的 C++ 库不能直接链接——但 C 库可以,因为 C 符号没有修饰。
c++filt:反修饰工具
看到修饰后的符号名一头雾水?用 c++filt 还原:
$ echo "_Z9uart_initi" | c++filt
uart_init(int)
$ echo "_ZN3hal4initEi" | c++filt
hal::init(int)
$ echo "_ZN4Uart4sendEi" | c++filt
Uart::send(int)
也可以直接配合 nm 使用:
$ nm main.o | c++filt
U uart_init(int)
U uart_send(char const*, int)
00000000 T main
extern "C" 的作用
extern "C" 告诉 C++ 编译器:这些函数使用 C 的链接约定——不要对它们的名字做修饰。
语法
单个声明:
extern "C" void uart_init(int baudrate);
块声明(包裹多个函数):
extern "C" {
void uart_init(int baudrate);
void uart_send(const char *data, int len);
int uart_read(char *buf, int max_len);
}
包含整个头文件:
extern "C" {
#include "uart.h"
}
加了 extern "C" 后,C++ 编译器对这些函数使用 C 的符号命名规则。main.o 中的符号从 _Z9uart_initi 变回 uart_init,和 uart.o 中的符号一致,链接成功。
在头文件中兼容 C/C++
最常见的做法是在头文件中使用条件编译,让同一个头文件在 C 和 C++ 中都能使用:
// uart.h
#ifndef UART_H
#define UART_H
#ifdef __cplusplus
extern "C" {
#endif
void uart_init(int baudrate);
void uart_send(const char *data, int len);
int uart_read(char *buf, int max_len);
#ifdef __cplusplus
}
#endif
#endif // UART_H
__cplusplus 是 C++ 编译器预定义的宏。当 C 编译器处理这个头文件时,extern "C" 块被跳过(C 不认识这个语法);当 C++ 编译器处理时,extern "C" 生效。
这是 C/C++ 混合编程头文件的标准写法,几乎所有正规的 C 库都应该这样写。
典型使用场景
场景 1:C++ 项目调用 C 库
这是最常见的场景。嵌入式项目中,芯片厂商的 HAL 库、RTOS API(FreeRTOS、RT-Thread)都是 C 写的。在 C++ 业务代码中调用时必须用 extern "C":
// 在 C++ 代码中
extern "C" {
#include "nrf_gpio.h"
#include "FreeRTOS.h"
#include "task.h"
}
class App {
public:
void start() {
nrf_gpio_pin_set(LED_PIN); // 调用 C 函数
xTaskCreate(taskFunc, "task1", 256, NULL, 1, NULL);
}
};
如果 HAL 头文件已经自带了 #ifdef __cplusplus 保护(大多数都有),则直接 include 即可,不需要额外的 extern "C" 包裹。
场景 2:C 项目调用 C++ 函数
反过来也有需求——C 代码调用 C++ 实现的函数。需要在 C++ 侧将接口声明为 extern "C":
// cpp_module.h
#ifdef __cplusplus
extern "C" {
#endif
// 这些函数用 C++ 实现,但暴露 C 风格的接口
int sensor_init(void);
int sensor_read(float *temperature);
void sensor_deinit(void);
#ifdef __cplusplus
}
#endif
// cpp_module.cpp
#include "cpp_module.h"
#include <cmath> // 可以使用 C++ 特性
class SensorDriver {
// 内部用 C++ 实现...
};
static SensorDriver g_driver;
extern "C" int sensor_init(void) {
// C++ 实现,但对外是 C 接口
return 0;
}
extern "C" int sensor_read(float *temperature) {
*temperature = g_driver.getTemp();
return 0;
}
C 代码可以直接调用 sensor_init()、sensor_read(),完全不需要知道底层是 C++ 实现的。
场景 3:回调函数注册
很多 C 库通过函数指针注册回调。如果回调函数是 C++ 写的,必须声明为 extern "C":
// C 库定义的回调类型
// typedef void (*timer_callback_t)(void);
extern "C" void my_timer_callback(void)
{
// 注意:这里不能使用 C++ 的类成员函数
// 但可以调用静态方法或全局 C++ 函数
App::getInstance().onTimerExpired();
}
void setup() {
timer_register_callback(my_timer_callback);
}
如果不加 extern "C",回调函数的符号名会被修饰,C 库通过函数指针调用时,虽然不会直接链接报错(函数指针是地址,不涉及符号查找),但在某些平台上调用约定可能不同,导致运行时问题。加上 extern "C" 是良好的习惯。
命名空间与 extern "C"
namespace 下的 extern "C"
extern "C" 可以放在命名空间内部,但效果可能出乎意料:
namespace hal {
extern "C" void gpio_init(void);
}
这个 gpio_init 的符号名是 gpio_init——没有命名空间前缀。extern "C" 使它遵循 C 链接约定,C 中没有命名空间的概念,所以命名空间对符号名没有影响。
但在 C++ 代码中,你仍然可以通过 hal::gpio_init() 或直接 gpio_init() 来调用它。
extern "C" 函数不能重载
因为 extern "C" 禁用了名称修饰,失去了区分同名函数的能力:
extern "C" void process(int x); // OK
extern "C" void process(double x); // 编译错误!符号名都是 process,冲突
这是 extern "C" 最重要的限制——C 的链接约定天生不支持函数重载。
extern "C" 与类
类的成员函数不能声明为 extern "C":
class Uart {
extern "C" void send(int data); // 编译错误!
};
原因很简单:成员函数有隐含的 this 指针参数,这是 C++ 特有的概念,无法映射到 C 的链接约定。
但 static 成员函数 可以在某些编译器中用作 C 回调(它没有 this 指针),不过严格来说也不应该声明为 extern "C"。最安全的做法是用一个独立的 extern "C" 函数做桥接。
嵌入式实战
startup.s 中的 main
在 C++ 嵌入式项目中,main() 函数需要被 startup 的汇编代码调用。startup.s 中通常写的是:
bl main
汇编代码直接用 main 这个符号名。如果 main 是用 C++ 编译的,C++ 标准规定 main 不做名称修饰(这是特例),所以通常没问题。但为了明确起见,很多项目会这样写:
extern "C" int main(void)
{
// ...
}
中断 Handler 的声明
ARM Cortex-M 的中断向量表在 startup.s 中定义,Handler 函数以符号名引用:
.word UART0_IRQHandler
.word SPI0_IRQHandler
.word TIMER0_IRQHandler
如果你用 C++ 实现这些 Handler,必须用 extern "C" 声明,否则符号名不匹配:
extern "C" void UART0_IRQHandler(void)
{
// 处理 UART 中断
if (NRF_UART0->EVENTS_RXDRDY) {
// ...
}
}
extern "C" void TIMER0_IRQHandler(void)
{
// 处理定时器中断
}
如果忘记 extern "C",中断触发时芯片会跳转到默认的 Default_Handler(通常是死循环),而不是你实现的函数——因为链接器把你的 _ZN20UART0_IRQHandlerEv 和向量表中的 UART0_IRQHandler 当成了两个不同的符号。
CMSIS/HAL 头文件的标准写法
打开任何一个 STM32 或 Nordic 的 HAL 头文件,你都会看到这个模式:
// stm32f4xx_hal_uart.h
#ifndef __STM32F4xx_HAL_UART_H
#define __STM32F4xx_HAL_UART_H
#ifdef __cplusplus
extern "C" {
#endif
#include "stm32f4xx_hal_def.h"
typedef struct {
uint32_t BaudRate;
uint32_t WordLength;
// ...
} UART_InitTypeDef;
HAL_StatusTypeDef HAL_UART_Init(UART_HandleTypeDef *huart);
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart,
uint8_t *pData, uint16_t Size,
uint32_t Timeout);
#ifdef __cplusplus
}
#endif
#endif
这样无论是 C 还是 C++ 项目,都可以直接 include 这个头文件。
动手验证:编译器与汇编实操
理论说再多,不如实际编译一遍看看效果。以下用 GCC 工具链演示。
准备测试代码
// math_ops.h
#ifndef MATH_OPS_H
#define MATH_OPS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
// math_ops.c
#include "math_ops.h"
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
// app.cpp
#include "math_ops.h"
int main() {
int sum = add(3, 4);
int product = multiply(5, 6);
return sum + product;
}
第一步:分别编译,查看符号表
# C 编译器编译
gcc -c math_ops.c -o math_ops_c.o
# C++ 编译器编译同一个源文件(改扩展名)
cp math_ops.c math_ops.cpp
g++ -c math_ops.cpp -o math_ops_cpp.o
# 编译 C++ 主程序
g++ -c app.cpp -o app.o
对比符号表:
$ nm math_ops_c.o
0000000000000000 T add
0000000000000014 T multiply
$ nm math_ops_cpp.o
0000000000000000 T _Z3addii
0000000000000014 T _Z8multiplyii
$ nm app.o
U _Z3addii
U _Z8multiplyii
0000000000000000 T main
清晰可见:
- C 编译的
.o:符号是add、multiply - C++ 编译的
.o:符号是_Z3addii、_Z8multiplyii app.o需要的是 C++ 修饰后的符号,和 C 编译的math_ops_c.o对不上
第二步:查看汇编输出中的差异
# C 编译器生成的汇编
gcc -S math_ops.c -o math_ops_c.s
# C++ 编译器生成的汇编
g++ -S math_ops.cpp -o math_ops_cpp.s
C 编译器的汇编(关键片段):
.globl add
.type add, @function
add:
lea eax, [rdi+rsi]
ret
.globl multiply
.type multiply, @function
multiply:
mov eax, edi
imul eax, esi
ret
C++ 编译器的汇编(关键片段):
.globl _Z3addii
.type _Z3addii, @function
_Z3addii:
lea eax, [rdi+rsi]
ret
.globl _Z8multiplyii
.type _Z8multiplyii, @function
_Z8multiplyii:
mov eax, edi
imul eax, esi
ret
函数体的汇编指令完全一样——唯一的区别就是 .globl 后面的符号名。这就是名称修饰的本质:只影响符号名,不影响生成的机器码。
第三步:加上 extern "C" 再对比
修改头文件:
// math_ops.h(加上 extern "C" 保护)
#ifndef MATH_OPS_H
#define MATH_OPS_H
#ifdef __cplusplus
extern "C" {
#endif
int add(int a, int b);
int multiply(int a, int b);
#ifdef __cplusplus
}
#endif
#endif
重新编译 C++ 主程序:
g++ -c app.cpp -o app.o
nm app.o
输出:
U add
U multiply
0000000000000000 T main
app.o 中的符号从 _Z3addii 变回了 add——和 math_ops_c.o 中的符号一致。现在可以成功链接:
g++ app.o math_ops_c.o -o app # 链接成功
第四步:用 c++filt 批量反修饰
在大型项目中,nm 的输出可能有成百上千个修饰符号。配合 c++filt:
$ nm app.o | c++filt
U add
U multiply
0000000000000000 T main
如果没加 extern "C" 时:
$ nm app_no_extern.o | c++filt
U add(int, int)
U multiply(int, int)
0000000000000000 T main
c++filt 将 _Z3addii 还原成了 add(int, int),一目了然。
第五步:用 objdump 查看链接后的最终符号
$ objdump -t app | grep -E "(add|multiply)"
0000000000001140 g F .text 000000000000000e add
0000000000001150 g F .text 000000000000000c multiply
链接后的 ELF 中,add 和 multiply 有了最终的绝对地址(0x1140、0x1150),main 中的调用指令会跳转到这些地址。
小结
| 编译方式 | nm 看到的符号 | 能否与 C 的 .o 链接 |
|---|---|---|
| C++ 编译,无 extern "C" | _Z3addii | 不能 |
| C++ 编译,有 extern "C" | add | 能 |
| C 编译 | add | 能 |
常见坑与最佳实践
坑 1:忘记 extern "C"
典型错误信息特征——报错中的函数名带参数类型:
undefined reference to `uart_init(int)' ← C++ 修饰风格
而不是:
undefined reference to `uart_init' ← C 风格
看到带括号和参数类型的错误,99% 是 extern "C" 遗漏。
坑 2:extern "C" 嵌套
extern "C" 可以嵌套,内层生效:
extern "C" {
extern "C" { // 合法,不影响
void func(); // 仍然是 C 链接
}
}
不会出错,但没必要。头文件互相 include 时可能无意中产生嵌套,不用担心。
坑 3:对 static 函数加 extern "C"
static 函数只在本文件内可见,不导出符号,不参与链接。给它加 extern "C" 没有意义:
extern "C" static void helper(void); // 没有实际作用
编译器不会报错,但这是多余的。
坑 4:C++ 独有特性通过 extern "C" 暴露
extern "C" 函数的参数和返回值类型必须是 C 兼容的。以下是不行的:
extern "C" void process(std::string &s); // C 不认识 std::string
extern "C" void handle(std::vector<int> &v); // C 不认识 std::vector
extern "C" bool check(); // bool 在 C99 前不存在
应该用 C 兼容的类型:char*、int*、int 等。
最佳实践:头文件模板
每个 C 库的头文件都应该用这个模板:
#ifndef MODULE_NAME_H
#define MODULE_NAME_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// 类型定义
typedef struct {
uint32_t baudrate;
uint8_t data_bits;
} uart_config_t;
// 函数声明
int uart_init(const uart_config_t *config);
int uart_send(const uint8_t *data, uint16_t len);
int uart_read(uint8_t *buf, uint16_t max_len);
void uart_deinit(void);
#ifdef __cplusplus
}
#endif
#endif // MODULE_NAME_H
这个模板保证了:
- C 编译器正常编译(忽略
extern "C"块) - C++ 编译器使用 C 链接约定(符号不修饰)
- 头文件保护防止重复包含
- 类型使用
stdint.h的固定宽度类型,C/C++ 都兼容
总结
extern "C" 解决的核心问题是 C++ 名称修饰导致的符号不匹配。
| 概念 | 要点 |
|---|---|
| 名称修饰 | C++ 将参数类型、命名空间编码进符号名,C 不修饰 |
| extern "C" | 让 C++ 编译器对指定函数使用 C 的链接约定(不修饰) |
| #ifdef __cplusplus | 让同一个头文件兼容 C 和 C++ 编译器 |
| c++filt | 反修饰工具,将 _Z9uart_initi 还原为 uart_init(int) |
| 限制 | extern "C" 函数不能重载,参数必须 C 兼容,不能用于类成员函数 |
记住这些规则
- 写 C 库头文件时,永远加
#ifdef __cplusplus extern "C"保护 - C++ 项目调用 C 库时,确认头文件有保护或手动
extern "C" { #include }包裹 - C++ 实现的函数要给 C 调用时,在定义和声明处都加
extern "C" - 中断 Handler 用 C++ 实现时,必须加
extern "C" - 链接报错带参数类型签名时(如
uart_init(int)),首先检查extern "C"是否遗漏

