C++ 基础与原生互操作(一):语言、工具链与程序构建

当 C# 项目需要调用厂商 DLL 时,文档里常会同时出现 .h.lib.dllextern "C"__declspec(dllexport)。如果只从 P/Invoke 声明入手,很容易把“函数签名写对”当成全部问题,却不知道这个 DLL 是怎样生成的、为什么符号找不到,以及头文件究竟是不是程序的一部分。

本系列面向已有 C# 等语言经验、需要阅读或维护原生 SDK 的开发者。目标不是穷举 C++ 语法,而是建立一套够用的模型:看懂常见代码,知道怎样编译和链接,并能为跨语言调用设计合理边界。示例使用 C++20 中成熟、常见的能力。

1. C++ 到底是什么

C++ 是一门由 ISO 标准定义的通用编程语言。标准描述语法、类型系统、抽象机制和标准库接口,但不会给你一个名为“C++”的唯一运行程序。真正把源码变成目标平台程序的是具体工具链,例如:

  • Microsoft Visual C++(MSVC):Windows 和 Visual Studio 中常见;
  • GCC:Linux 发行版中常见;
  • Clang/LLVM:Windows、Linux、macOS 均可使用。

“C++”“编译器”“标准库”“IDE”不是同一个东西。Visual Studio 是开发环境,cl.exe 才是 MSVC 编译器驱动程序;GCC 的 C++ 驱动通常叫 g++,Clang 的通常叫 clang++

C++ 常被称为编译型语言,因为典型实现会在运行前把源代码编译、链接为目标平台机器码。不过,语言标准主要规定程序的可观察行为,并不强制实现必须采用某一条内部流水线。工程上把它理解为“通常提前编译成特定平台的原生程序”就够用了。

2. 第一个程序

1
2
3
4
5
6
7
8
9
#include <iostream>
#include <string>

int main()
{
    const std::string name = "C++";
    std::cout << "Hello, " << name << '\n';
    return 0;
}

这几行已经包含了不少 C++ 概念:

  • #include 由预处理阶段处理,把所需声明引入当前翻译单元;
  • std::stringstd::cout 来自标准库的 std 命名空间;
  • const 表示初始化后不能通过这个名称修改对象;
  • main 是常规宿主环境中 C++ 可执行程序的入口,返回 0 通常表示成功;
  • 语句通常以分号结束,代码块由花括号界定。

不要为了少写几个字符就在头文件或大型项目中使用 using namespace std;。显式写出 std:: 能说明名称来自哪里,也可降低大型工程中的名称冲突风险。

2.1 在 Windows 上编译

在 “Developer PowerShell for Visual Studio” 中执行:

1
2
cl /std:c++20 /EHsc /W4 hello.cpp
.\hello.exe

/std:c++20 选择语言模式,/EHsc 启用常规 C++ 异常语义,/W4 打开较严格的警告。普通 PowerShell 若没有初始化 Visual Studio 开发环境,可能找不到 cl,这不等于编译器没有安装。

2.2 在 Linux 上编译

1
2
g++ -std=c++20 -Wall -Wextra -Wpedantic hello.cpp -o hello
./hello

编译器警告应当进入日常反馈闭环,但“零警告”不等于程序不存在未定义行为、生命周期错误或业务缺陷。

3. 从源码到程序发生了什么

可以先用四个阶段理解常规构建:

1
2
3
4
5
6
7
源文件与头文件
    ↓ 预处理:展开 #include、宏和条件编译
翻译单元
    ↓ 编译:语法/类型检查并生成目标代码
目标文件(Windows 常见 .obj,Unix 常见 .o)
    ↓ 链接:解析跨文件符号和依赖库
可执行文件或库(.exe、.dll、.so、.a、.lib 等)

实际工具链可能把若干阶段合并执行,但排错时区分它们很重要:

  • 语法错误、类型不匹配通常是编译错误;
  • “无法解析的外部符号”或 undefined reference 通常是链接错误;
  • 程序启动后找不到 DLL / .so 是加载阶段问题;
  • 成功加载后入口点不存在,往往是导出名、架构或声明不匹配。

4. .cpp.h 各自负责什么

头文件通常放供其他翻译单元使用的声明,源文件放定义:

1
2
3
4
// temperature.h
#pragma once

double celsius_to_fahrenheit(double value);
1
2
3
4
5
6
7
// temperature.cpp
#include "temperature.h"

double celsius_to_fahrenheit(double value)
{
    return value * 9.0 / 5.0 + 32.0;
}
1
2
3
4
5
6
7
8
// main.cpp
#include "temperature.h"
#include <iostream>

int main()
{
    std::cout << celsius_to_fahrenheit(25.0) << '\n';
}

声明告诉编译器“这个名称是什么”,定义提供实体或实现。头文件本身通常不是独立编译单元;它会被 #include 到一个或多个源文件中。#pragma once 用于避免同一头文件在一个翻译单元中被重复包含,主流编译器均支持;跨工具链公共头也常使用传统 include guard。

分别编译再链接时,命令大致如下:

1
2
cl /std:c++20 /EHsc /W4 /c temperature.cpp main.cpp
link temperature.obj main.obj /OUT:temperature.exe

如果头文件只写了声明,却没有把包含定义的目标文件或库交给链接器,源码可以通过编译,最后仍会链接失败。

5. 先掌握这些基础语法

下面的函数足以串起变量、分支、循环、容器和函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <vector>

double average_positive(const std::vector<double>& values)
{
    double sum = 0.0;
    int count = 0;

    for (const double value : values)
    {
        if (value <= 0.0)
        {
            continue;
        }

        sum += value;
        ++count;
    }

    return count == 0 ? 0.0 : sum / count;
}

读函数签名时从外向内看:它名为 average_positive,返回 double,接收一个 std::vector<double> 的常量引用。const ...& 表示函数借用现有容器、不会复制整个 vector,并承诺不通过该引用修改它。

C# 开发者还应注意:

  • C++ 局部变量不会自动获得 C# 字段那样的默认值;未初始化的基础类型可能具有不确定值;
  • 整数除法仍会截断小数,5 / 2 得到 2
  • == 比较对象时调用何种语义由类型和运算符定义决定;
  • 编译通过不代表访问越界、悬空指针等错误会被运行时统一拦截。

6. C++、C 和 C++/CLI 不要混为一谈

  • C 是另一门语言,和 C++ 有大量共同语法,但拥有自己的标准和类型规则;
  • 原生 C++ 通常生成目标平台的原生代码,可构建 DLL、.so 和可执行文件;
  • C++/CLI 是 Microsoft 面向 .NET 互操作的语言扩展,可同时接触托管与原生世界,但不是跨平台 C++ 的同义词;
  • P/Invoke 描述 .NET 如何调用非托管库的函数入口,不会直接理解任意 C++ 类、模板或重载。

因此,看到“C++ DLL”时还不能断定它能直接 P/Invoke。你首先需要知道它导出了哪些符号,以及是否提供了稳定的 C 兼容入口。第四、第五篇会把这条边界完整接起来。

7. 本篇小结

C++ 是语言,MSVC、GCC、Clang 是常见工具链实现。源文件经过预处理、编译和链接,最终成为可执行文件或库;头文件主要提供声明,不是 DLL 本身。遇到原生 SDK 时,先区分编译、链接、加载和调用阶段,很多看似神秘的 P/Invoke 故障就有了明确位置。

参考资料

Licensed under CC BY-NC-SA 4.0