返回博客
extern "C" 详解 —— C 与 C++ 混合编程中的名称修饰与链接问题

extern "C" 详解 —— C 与 C++ 混合编程中的名称修饰与链接问题

引言

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_initiuart_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++ 类型编码示例
voidvf()_Z1fv
intif(int)_Z1fi
charcf(char)_Z1fc
floatff(float)_Z1ff
doubledf(double)_Z1fd
boolbf(bool)_Z1fb
unsigned intjf(unsigned)_Z1fj
longlf(long)_Z1fl
const char*PKcf(const char*)_Z1fPKc
int*Pif(int*)_Z1fPi
int&Rif(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:符号是 addmultiply
  • 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 中,addmultiply 有了最终的绝对地址(0x11400x1150),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 兼容,不能用于类成员函数

记住这些规则

  1. 写 C 库头文件时,永远加 #ifdef __cplusplus extern "C" 保护
  2. C++ 项目调用 C 库时,确认头文件有保护或手动 extern "C" { #include } 包裹
  3. C++ 实现的函数要给 C 调用时,在定义和声明处都加 extern "C"
  4. 中断 Handler 用 C++ 实现时,必须加 extern "C"
  5. 链接报错带参数类型签名时(如 uart_init(int)),首先检查 extern "C" 是否遗漏