1. 项目概述:为什么需要跨语言的动态库互操作?
在软件开发中,我们常常会遇到“技术栈混合”的场景。比如,一个核心的计算模块用C++写成,性能卓越但维护成本高;而一个新的用户界面或网络服务想用Rust来构建,看重其内存安全和现代化的包管理。又或者,一个庞大的遗留系统主体是C++,我们想逐步用Rust重写其中的某些组件,以提升安全性和可维护性。这时候,一个核心问题就浮出水面:如何让这两种语言编写的代码“对话”?
动态链接库(在Windows上叫DLL,在Linux/macOS上叫.so或.dylib)正是解决这个问题的桥梁。它允许我们将功能模块编译成独立的二进制文件,在运行时被主程序加载和调用。C++作为系统编程的“老将”,其动态库生态已经非常成熟。Rust作为后起之秀,凭借零成本抽象和 fearless concurrency 等特性,也越来越多地用于系统底层和性能关键型组件。让Rust和C++的DLL相互调用,本质上就是让新旧两代系统级语言握手言和,实现优势互补。
我最近就在一个图像处理项目中实践了这一点:核心的图像滤波和变换算法库是多年前用C++写的,稳定但代码风格陈旧;新的业务逻辑和API层我打算用Rust重写。直接重写整个C++库不现实,所以我的目标就是让Rust代码能够无缝调用这些C++函数,同时,也尝试将一些新的、用Rust编写的安全加密模块封装成DLL,供原有的C++程序使用。这个过程踩了不少坑,也积累了一些心得,这篇文章就来详细拆解一下Rust与C++动态库相互调用的完整流程、核心原理和避坑指南。
2. 核心概念与前置知识梳理
在动手写代码之前,我们必须统一几个关键概念,这能避免后续很多沟通上的“鸡同鸭讲”。
2.1 理解应用程序二进制接口(ABI)
ABI可以理解为编译后的二进制代码之间相互调用的“协议”。它规定了函数名如何映射到符号(Name Mangling)、参数和返回值如何传递(调用约定,如stdcall、cdecl)、数据在内存中如何布局(结构体对齐)等。C语言有一个非常稳定和简单的ABI,通常被称为“C ABI”。这也是为什么不同编译器(如GCC和MSVC)甚至不同语言编写的代码,只要都遵循C ABI,就能互相调用。
C++的ABI则复杂得多。为了实现函数重载、命名空间、类成员函数等特性,编译器会对函数名进行复杂的修饰(Name Mangling)。不同编译器(甚至同一编译器的不同版本)的修饰规则都可能不同。因此,直接暴露一个C++类方法给外部调用是极其不可靠的。
核心策略:为了实现跨语言互操作,我们必须建立一个“中立区”。这个中立区就是C ABI。无论是Rust调用C++,还是C++调用Rust,我们都需要在边界处编写一层使用extern "C"(在C++中)或#[no_mangle]和extern "C"(在Rust中)包装的函数。这些函数使用C风格的数据类型(如int、float、指针)和简单的调用约定,作为双方通信的“外交官”。
2.2 数据类型的映射与转换
跨语言调用时,数据必须能被双方理解。以下是一些基本类型的映射关系,这是安全传递数据的基础:
| C/C++ 类型 | Rust 类型 (在extern "C"块中) | 说明 |
|---|---|---|
int | std::os::raw::c_int | 通常对应i32 |
unsigned int | std::os::raw::c_uint | 通常对应u32 |
char | std::os::raw::c_char | 在Rust中是i8(有符号) |
float/double | f32/f64 | 直接对应 |
void* | *mut std::ffi::c_void | 通用指针,需谨慎处理 |
const char* | *const std::os::raw::c_char | C风格字符串指针 |
对于复杂数据,如结构体,双方的定义必须内存布局完全一致。这通常意味着要禁用Rust结构体的自动重排(#[repr(C)]),并仔细匹配C++结构体的成员顺序和对齐方式。字符串的传递尤其需要注意所有权和生命周期,C端通常使用const char*(调用者负责内存),而Rust端需要使用CString来管理。
2.3 构建工具链的选择
- C++侧:在Windows上,主要选择是Microsoft Visual Studio的MSVC工具链(
cl.exe)或MinGW-w64(GCC for Windows)。如果你的Rust工具链使用的是msvc目标(默认),那么最好使用MSVC来编译C++ DLL,以确保运行时库(如msvcrt.dll)的一致性,避免冲突。如果使用MinGW,则Rust也需要对应使用gnu目标。 - Rust侧:使用
cargo进行项目管理。关键是要正确配置Cargo.toml,特别是当Rust需要链接到已有的C++库时,需要使用build.rs构建脚本。当将Rust代码编译为供C++调用的DLL时,需要指定crate-type = ["cdylib"]。
一个重要的实践心得:在项目初期就统一工具链。如果你团队的主力开发环境是Visual Studio,那么坚持使用MSVC编译C++ DLL,Rust也用stable-msvc。这能省去大量因运行时库不匹配导致的“无法找到入口点”或“初始化例程失败”的调试时间。
3. 场景一:Rust调用C++动态库
这是比较常见的场景,目的是复用已有的、成熟的C++库功能。我们的任务是在C++库外面包裹一层C接口,然后让Rust通过FFI来调用这层接口。
3.1 C++侧:创建并暴露C接口
假设我们有一个简单的C++类MathCalculator,我们想暴露其add方法。
math_lib.h(C++头文件)
#ifndef MATH_LIB_H #define MATH_LIB_H // 这是原始的C++类,对外部(尤其是Rust)不可见其实现细节。 class MathCalculator { public: MathCalculator(double baseValue); double add(double value); private: double m_base; }; // 以下是暴露给C(以及Rust)的纯C接口。 // 使用 extern "C" 来禁止C++的名称修饰,确保函数名在二进制文件中是简单的“create_calculator”。 #ifdef __cplusplus extern "C" { #endif // 构造函数包装:创建对象并返回不透明的指针(void*)。 // 调用约定使用 __stdcall(Windows常见)或保持默认(通常就是__cdecl)。 __declspec(dllexport) void* create_calculator(double base_value); // 成员函数包装:通过不透明指针操作对象。 __declspec(dllexport) double calculator_add(void* calculator, double value); // 析构函数包装:销毁对象,释放内存。 __declspec(dllexport) void destroy_calculator(void* calculator); #ifdef __cplusplus } #endif #endif // MATH_LIB_Hmath_lib.cpp(C++实现文件)
#include "math_lib.h" #include <stdexcept> // 可选的,用于异常处理 MathCalculator::MathCalculator(double baseValue) : m_base(baseValue) {} double MathCalculator::add(double value) { return m_base + value; } // C接口实现 extern "C" { __declspec(dllexport) void* create_calculator(double base_value) { // 使用 `new` 在堆上分配,返回指针。 // 注意:这里可能抛出的C++异常必须被捕获,不能越过FFI边界。 try { return new MathCalculator(base_value); } catch (...) { return nullptr; // 简单的错误处理,返回空指针 } } __declspec(dllexport) double calculator_add(void* calculator, double value) { if (!calculator) { // 处理空指针,可以返回一个错误值或触发Rust端的panic。 // 更好的做法是返回一个错误码,这里简化为返回NaN。 return std::numeric_limits<double>::quiet_NaN(); } auto calc = static_cast<MathCalculator*>(calculator); return calc->add(value); } __declspec(dllexport) void destroy_calculator(void* calculator) { delete static_cast<MathCalculator*>(calculator); } }关键点解析:
- 不透明指针(Opaque Pointer):我们将C++对象的指针(
MathCalculator*)转换为void*传递给外部。外部代码(Rust)只知道这是一个“句柄”,不知道其内部结构,所有操作都必须通过我们提供的C函数进行。这是封装C++对象的经典模式。 __declspec(dllexport):这是MSVC特有的语法,用于指明该函数需要从DLL中导出。在Linux/macOS的GCC/Clang下,需要在函数声明前加__attribute__((visibility("default")))。- 异常处理:C++异常绝不能越过FFI边界,因为Rust或其他C语言调用者无法安全地展开C++的异常栈。必须在C接口内部用
try...catch捕获所有异常,并转换为错误码或哨兵值(如nullptr,NaN)返回。
使用Visual Studio创建一个“动态链接库(DLL)”项目,编译生成math_lib.dll和math_lib.lib(导入库)。
3.2 Rust侧:声明并链接外部函数
在Rust项目中,我们需要告诉编译器存在这些外部函数,并链接到对应的DLL。
步骤1:使用build.rs确保链接正确(可选但推荐)创建build.rs文件,其作用是在cargo build时执行,帮助我们设置链接器参数。
// build.rs fn main() { println!("cargo:rustc-link-search=native=./lib"); println!("cargo:rustc-link-lib=dylib=math_lib"); }这告诉链接器:在./lib目录下寻找库文件,并链接名为math_lib的动态库(在Windows上会查找math_lib.dll和math_lib.lib)。你需要将编译好的math_lib.dll和math_lib.lib(或Linux下的.so和.a)放到项目根目录的lib文件夹下。
步骤2:在Rust中声明外部函数接口创建一个模块(如ffi.rs)来集中管理FFI声明。
// src/ffi.rs use std::os::raw::{c_double, c_void}; // 使用 `extern "C"` 块声明来自C ABI的外部函数。 // `#[link(name = "math_lib", kind = "dylib")]` 属性指定链接的库。 #[link(name = "math_lib", kind = "dylib")] extern "C" { // 对应 C++ 的 `void* create_calculator(double)` pub fn create_calculator(base_value: c_double) -> *mut c_void; // 对应 C++ 的 `double calculator_add(void*, double)` pub fn calculator_add(calculator: *mut c_void, value: c_double) -> c_double; // 对应 C++ 的 `void destroy_calculator(void*)` pub fn destroy_calculator(calculator: *mut c_void); }步骤3:创建安全的Rust封装层直接操作裸指针(*mut c_void)是不安全且不符合Rust习惯的。我们应该用struct和impl将其包装起来,实现Droptrait以确保资源被释放。
// src/lib.rs 或 src/main.rs mod ffi; pub struct Calculator { // 内部持有一个指向C++对象的指针 ptr: *mut std::ffi::c_void, } impl Calculator { /// 创建一个新的计算器实例。 /// # 安全性 /// 调用底层不安全的FFI函数。 pub fn new(base_value: f64) -> Option<Self> { let ptr = unsafe { ffi::create_calculator(base_value) }; if ptr.is_null() { None // C++构造函数可能失败(如抛出异常并被捕获返回nullptr) } else { Some(Calculator { ptr }) } } pub fn add(&self, value: f64) -> f64 { unsafe { ffi::calculator_add(self.ptr, value) } } } // 为Calculator实现Drop,确保C++对象被正确销毁。 impl Drop for Calculator { fn drop(&mut self) { if !self.ptr.is_null() { unsafe { ffi::destroy_calculator(self.ptr) }; self.ptr = std::ptr::null_mut(); } } } // 示例用法 fn main() { if let Some(mut calc) = Calculator::new(10.0) { let result = calc.add(5.0); println!("10 + 5 = {}", result); // 输出 15 } else { eprintln!("Failed to create calculator."); } // calc离开作用域时,Drop::drop会被自动调用,销毁C++对象。 }实操心得与避坑指南:
- 库文件放置与路径:最简单的方法是将DLL放在与Rust可执行文件相同的目录下。对于开发,在
build.rs中设置link-search是好的做法。对于发布,你需要将DLL打包进安装程序或指定PATH环境变量。 - 调试符号:在Debug模式下编译C++ DLL时,确保生成PDB文件(程序数据库)。当Rust程序崩溃在FFI调用中时,调试器(如VS Code + MSVC)需要PDB来显示C++侧的调用栈,否则你只能看到一堆无名的内存地址,极其难调试。
panic = “abort”:在Rust的Cargo.toml中,如果你的Crate类型是cdylib(供C调用),并且C++代码没有为Rust的panic做准备,建议设置panic = “abort”。因为默认的unwind panic机制可能与C++的异常处理机制冲突,导致未定义行为。
4. 场景二:C++调用Rust动态库
这个场景下,Rust变成了服务的提供方。我们需要将Rust代码编译成C动态库,并暴露出一组C ABI函数。
4.1 Rust侧:编译为cdylib并暴露C接口
步骤1:配置Cargo.toml
[package] name = "rust_math_lib" version = "0.1.0" edition = "2021" # 关键配置:指定生成C兼容的动态链接库。 [lib] name = "rustmath" # 生成的库文件基础名,在Windows上会是 `rustmath.dll` crate-type = ["cdylib"] # 如果担心panic跨FFI传播,可以设置panic策略。 # [profile.release] # panic = "abort"步骤2:编写Rust库代码并暴露C接口
// src/lib.rs use std::ffi::{CStr, CString}; use std::os::raw::{c_char, c_double, c_int}; // 定义一个简单的Rust结构体,使用 #[repr(C)] 确保其内存布局与C兼容。 #[repr(C)] pub struct Point { x: c_double, y: c_double, } // 暴露给C的函数必须使用 `extern "C"` 和 `#[no_mangle]`。 // `#[no_mangle]` 禁止Rust编译器对函数名进行混淆,确保C端能找到名为 `add_numbers` 的符号。 #[no_mangle] pub extern "C" fn add_numbers(a: c_int, b: c_int) -> c_int { a + b // 简单的加法 } // 操作结构体 #[no_mangle] pub extern "C" fn create_point(x: c_double, y: c_double) -> Point { Point { x, y } } #[no_mangle] pub extern "C" fn point_distance(p1: &Point, p2: &Point) -> c_double { let dx = p1.x - p2.x; let dy = p1.y - p2.y; (dx * dx + dy * dy).sqrt() } // 处理字符串(需要特别注意内存管理!) // 约定:调用者(C++)负责释放返回的 `char*`。 // 这里使用 `Box::into_raw` 将所有权转移给C端。 #[no_mangle] pub extern "C" fn greet(name: *const c_char) -> *mut c_char { // 将C字符串转换为Rust的 &str,需要unsafe。 let name_str = unsafe { if name.is_null() { "anonymous" } else { CStr::from_ptr(name).to_str().unwrap_or("anonymous") } }; let greeting = format!("Hello, {} from Rust!", name_str); // 将Rust String 转换为 CString,然后泄漏其指针给调用者。 CString::new(greeting).unwrap().into_raw() } // 提供一个函数让C++端释放字符串内存。 // 必须与 `greet` 函数配对使用,使用相同的分配器(Rust的全局分配器)。 #[no_mangle] pub extern "C" fn free_string(s: *mut c_char) { if !s.is_null() { unsafe { drop(CString::from_raw(s)) }; // 重新获取所有权并drop,释放内存。 } }使用cargo build --release编译,会在target/release下生成rustmath.dll(以及rustmath.lib导入库,在Windows上)。
4.2 C++侧:加载并调用Rust DLL
现在,我们在C++程序中像使用普通C DLL一样使用这个Rust库。
步骤1:创建C风格的头文件
// rust_math_lib.h #ifndef RUST_MATH_LIB_H #define RUST_MATH_LIB_H #ifdef _WIN32 #ifdef RUSTMATH_EXPORTS #define RUST_API __declspec(dllexport) #else #define RUST_API __declspec(dllimport) #endif #else #define RUST_API __attribute__((visibility("default"))) #endif #ifdef __cplusplus extern "C" { #endif // 基本类型函数 RUST_API int add_numbers(int a, int b); // 结构体相关 typedef struct { double x; double y; } Point; RUST_API Point create_point(double x, double y); RUST_API double point_distance(const Point* p1, const Point* p2); // 字符串相关(注意内存管理约定) RUST_API char* greet(const char* name); RUST_API void free_string(char* s); #ifdef __cplusplus } #endif #endif // RUST_MATH_LIB_H步骤2:在C++项目中链接并使用在Visual Studio项目中,你需要:
- 将
rust_math_lib.h添加到头文件目录。 - 将
rustmath.lib(导入库)添加到链接器的附加依赖项。 - 确保
rustmath.dll在运行时可以被找到(放在exe同级目录或系统PATH)。
然后就可以在C++代码中调用:
#include <iostream> #include "rust_math_lib.h" int main() { // 调用简单函数 int sum = add_numbers(5, 7); std::cout << "5 + 7 = " << sum << std::endl; // 使用结构体 Point p1 = create_point(0.0, 0.0); Point p2 = create_point(3.0, 4.0); double dist = point_distance(&p1, &p2); std::cout << "Distance: " << dist << std::endl; // 使用字符串(必须配对使用) const char* name = "World"; char* greeting = greet(name); std::cout << greeting << std::endl; free_string(greeting); // 切记释放内存! return 0; }核心注意事项:
- 内存管理是最大的坑:谁分配,谁释放。Rust代码中通过
into_raw()返回的指针,其内存是由Rust分配器分配的,必须在Rust的上下文中释放(通过from_raw())。上面例子中的free_string函数就是这个目的。绝对不能用C++的delete或free来释放Rust分配的内存,反之亦然,否则会导致堆损坏。 - 错误处理:上述例子省略了错误处理。更健壮的做法是让FFI函数返回一个包含错误码和结果的结构体,或者使用全局的错误变量(
errno风格),但这会增加复杂性。对于简单库,可以约定返回特定值(如-1、nullptr)表示错误。 - 线程安全:确保你的Rust代码是线程安全的(例如,不包含内部可变性的全局变量,或使用
Mutex保护)。如果Rust库使用了Rayon等并行库,要特别注意初始化问题。
5. 高级话题与深度优化
当基础调用跑通后,我们会面临更复杂的需求。
5.1 复杂数据结构的传递
传递数组、向量或哈希表等复杂结构非常棘手。通常有两种策略:
- 序列化/反序列化:在边界处将数据转换为字节流(如JSON、CBOR、Protobuf)或简单的内存布局(长度+指针)。例如,传递一个整数数组:
C++端需要分配数组并将其指针和长度传递给这个函数。// Rust 端导出函数 #[no_mangle] pub extern "C" fn sum_array(ptr: *const i32, len: usize) -> i32 { let slice = unsafe { std::slice::from_raw_parts(ptr, len) }; slice.iter().sum() } - 提供全套的CRUD接口:为复杂数据结构提供创建、添加元素、获取元素、销毁等全套C接口函数。这相当于用C API重新实现了一遍该数据结构的操作,工作量大但类型安全。
5.2 回调函数与闭包
让C++调用Rust定义的回调函数,或者让Rust调用C++的回调,是更高级的交互。这涉及到将函数指针跨越FFI边界传递。
Rust设置C++回调示例:
// Rust 端定义回调函数类型 type Callback = extern "C" fn(data: i32); // 存储回调函数的全局变量(需用 `Mutex` 或 `AtomicPtr` 保护,此处简化) static mut USER_CALLBACK: Option<Callback> = None; #[no_mangle] pub extern "C" fn set_callback(cb: Callback) { unsafe { USER_CALLBACK = Some(cb); } } #[no_mangle] pub extern "C" fn trigger_event(data: i32) { unsafe { if let Some(cb) = USER_CALLBACK { cb(data); // 调用C++传过来的函数 } } }C++端需要定义一个extern "C"函数,并将其指针通过set_callback传给Rust。
重要警告:跨越FFI边界的回调函数绝对不能抛出异常(C++端)或panic(Rust端),并且其生命周期管理必须非常小心,避免悬垂指针。
5.3 使用bindgen自动化生成绑定
手动编写FFI绑定既枯燥又容易出错。bindgen是一个强大的工具,它可以解析C/C++头文件,自动生成对应的Rust FFI代码。
- 在
Cargo.toml中添加依赖:bindgen = "0.69" - 创建一个
build.rs,使用bindgen生成绑定代码到$OUT_DIR/bindings.rs。 - 在你的Rust库中引入这个生成的文件。
这能极大提升开发效率,尤其是面对庞大的C/C++库时。但bindgen生成的代码可能包含大量你需要或不熟悉的类型定义,需要仔细审查。
6. 实战问题排查与调试技巧
即使按照步骤操作,也难免会遇到各种诡异的问题。以下是我踩过的一些坑和解决方法。
6.1 常见错误与解决方案
| 错误现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
链接错误:undefined reference或LNK2019 | 1. 库名或函数名拼写错误。 2. 未正确指定库路径( link-search)。3. C++函数未用 extern "C"导出,导致名称修饰不匹配。4. Rust端声明的函数签名(参数/返回类型)与C++ DLL不匹配。 | 1. 使用dumpbin /exports your.dll(Windows)或nm -D your.so(Linux)查看DLL实际导出的函数名,与Rust声明严格比对。2. 检查 build.rs中的路径和库名。3. 确保C++头文件中的函数声明在 extern "C"块内,并正确使用了__declspec(dllexport)。 |
运行时错误:DLL load failed或找不到指定模块 | 1. 目标DLL不在可执行文件的搜索路径中。 2. DLL依赖的其他动态库(如MSVCRT、特定版本的VC++ Redistributable)缺失。 3. 32位/64位不匹配。 | 1. 将DLL复制到exe同级目录。 2. 使用 Dependency Walker或Visual Studio的dumpbin /dependents检查DLL的依赖项并确保存在。3. 确认所有组件(Rust目标、C++编译目标、DLL)都是同一架构(x86或x64)。 |
| 运行时崩溃:访问冲突、段错误 | 1. 内存管理错误:在Rust中释放了C++的内存,或反之。 2. 悬垂指针:C++对象已被销毁,但Rust仍持有其指针。 3. 线程安全问题:从多个线程调用了非线程安全的FFI函数。 4. 数据结构布局不匹配: #[repr(C)]使用错误或结构体成员顺序/对齐不一致。 | 1. 严格遵循“谁分配,谁释放”原则,仔细检查所有into_raw/from_raw和new/delete的配对。2. 确保Rust封装类型的生命周期管理正确(如 Drop实现)。3. 审查代码的线程安全性,必要时使用互斥锁。 4. 使用 std::mem::size_of和align_of在双方打印结构体信息进行比对。 |
| 函数调用后结果不正确 | 1. 调用约定不匹配(如__stdcallvs__cdecl)。2. 参数或返回值类型映射错误(如 int与c_int长度不同)。3. 浮点数处理差异。 | 1. 在FFI声明中显式指定调用约定,如extern "stdcall"(Rust不稳定特性,通常用extern "C"默认即可,Windows下MSVC的__cdecl是默认的)。确保双方一致。2. 使用 std::os::raw中的明确定义的类型。3. 对于精度要求极高的场景,注意不同平台和编译器下的浮点数行为。 |
6.2 调试技巧
- 混合调试:在VS Code中,可以配置
launch.json,同时调试Rust和C++代码。你需要安装ms-vscode.cpptools和rust-lang.rust-analyzer扩展。关键是在launch.json中设置正确的程序路径、符号路径(PDB文件)和源代码映射。 - 日志输出:在FFI边界两侧大量使用日志(如
println!、OutputDebugString、日志文件)。记录函数入口、参数值、出口和返回值。这是定位问题最朴实但最有效的方法。 - 使用
Process Monitor:当遇到“DLL未找到”问题时,Windows上的Process Monitor(ProcMon)可以监视进程对所有文件的读写操作,清晰展示它在哪里寻找DLL,非常有用。
7. 总结与最佳实践建议
经过几个项目的磨合,我总结出以下几点心得,能让Rust与C++的DLL互调之路走得更顺畅:
- 接口极简主义:FFI边界上的接口设计务必保持极简。只传递基本类型、指针和
#[repr(C)]的简单结构体。复杂交互通过序列化或增加中间层来解决。 - 明确所有权与生命周期:这是Rust的核心,也是FFI最易出错的地方。为每一个跨越边界的资源(内存、句柄)清晰地定义所有者,并编写对应的释放函数。在Rust侧用
struct和Drop进行封装是很好的实践。 - 错误处理前置:在FFI接口设计阶段就规划好错误传递机制。是使用返回值错误码、输出参数,还是设置全局错误状态?统一风格,并在文档中明确说明。
- 自动化绑定:对于大型或稳定的C/C++库,毫不犹豫地使用
bindgen。它能节省大量时间,并减少手动编写带来的笔误。 - 充分的测试与交叉验证:为FFI层编写全面的单元测试和集成测试。不仅测试正常流程,更要测试边界情况(空指针、非法值、重复释放等)。可以编写小的C/C++测试程序来调用Rust库,反之亦然,进行交叉验证。
- 文档至关重要:为每一个暴露的FFI函数编写详细的文档,说明其功能、参数含义、返回值、内存管理责任以及线程安全性。这对自己未来的维护和团队协作都价值连城。
最后,虽然FFI带来了巨大的灵活性,但它也引入了复杂性和安全隐患。在决定使用FFI之前,不妨先评估一下是否有更简单的替代方案,比如将C++代码整个用Rust重写(如果规模不大),或者通过进程间通信(IPC)来解耦。但当确实需要将两个强大但不同的世界连接起来时,遵循上述的路径和原则,你就能搭建起一座坚固可靠的桥梁。