作者 | robot
在C/C++开发领域,运行时库(Run Time Library)是一个非常重要且基础的概念,但是相关的介绍文章却很少,以至于对很多开发同学来说,这是一个偏神秘的存在,本文作者查阅了大量资料,并结合自己的理解,希望能够通俗易懂的科普和揭秘一下这一领域,内容包括什么是C/C++运行时库,它的主要功能,各平台的存在形式,以及开发中要注意的问题。
一、认识C/C++运行时库
1. 初识概念
你是否知道,我们开发的C/C++程序,在运行过程中,背后有一个基础模块,在默默提供着支持?
这背后的基础模块,也叫基础库,或运行时库。
见下图,支撑C/C++程序运行的,除了CPU/内存等硬件和操作系统之外,还有C/C++运行时库:
那么,它具体在干什么呢?要理解这一概念,需要先从C/C++语言说起。
2. 编程语言
基本每种编程语言,包括C/C++语言,都包括两个部分:
- 语法部分:比如判断、循环、定义变量、函数、类、注释等,及一些内置类型。
- 标准库部分:该语言最常用的函数或类库,比如输入输出、内存管理、字符串操作、数学函数、线程相关等。因为太基础常用,所以被纳入语言标准的一部分,称为标准库。
狭义的语言,仅仅是指语法部分,可称之为语言本身;广义的语言,是指语法部分+标准库部分。
语言的标准库+各种第三方库,组成了我们程序中常见的各种库。
C/C++运行时库,就是在运行时,为语言的这两部分提供基础支持的库。或者简言之,C/C++运行时库,就是C/C++程序在运行时依赖的基础库。
3. 其它语言的运行时库
除了C/C++语言之外,其它语言其实也有自己的运行时库。
比如Java,其运行时库就是Java运行时环境(JRE),它包括Java虚拟机(JVM)和Java标准库。
- 比如Python,其运行时库是指Python的解释器 + Python标准库。
- 比如JavaScript,其运行时库是指浏览器 + JavaScript解释器。
总的来说,语言的运行时库,就是为该语言编写的程序,在运行时提供基础支持的库或环境。
二、C/C++运行时库的功能
这一部分,我们来看看C/C++运行时库的具体功能。
对于 C 和 C++,其实是两种不同的语言,后者是在前者的基础上扩展而来,它们的运行时库,也是有两个,我们分开来看看。
1. C运行时库
C语言包括如下常用函数:
- 输入输出函数,比如 printf、scanf、puts
- 内存管理函数,比如 malloc、free、realloc
- 文件系列函数,比如 fopen、fread、fwrite
- 字符串相关函数,比如 strcpy、strcmp、strlen
- 数学函数,比如 sin、cos、sqrt
这些函数是怎么实现的?是由谁提供的?
这些函数并不是由操作系统提供的,而是由C语言提供的,准确说是由C运行时库提供的。
比如内存相关:
Linux系统原本提供的内存分配函数(也叫API),是brk、mmap等,而Windows系统提供的API是HeapAlloc、HeapFree、VirtualAlloc等,C语言(准确说是C运行时库)把各操作系统提供的API封装成统一的malloc、free。
如下图,Linux下malloc调用系统API的堆栈:
Windows下malloc调用系统API的堆栈:
(事实上,malloc内部并不是简单调用各系统API,而是做了一个内存池,小内存从池中分配,大内存调系统API)
对于文件:
- C语言提供的fopen、fread、fwrite等,即FILE系列的缓冲文件,同样是调用各平台的系统API实现的。Linux下是调用open、write等非缓冲文件接口,Windows下是调用CreateFile、WriteFile等。
- 除了上面说的平台相关函数外,C运行时库里,还有一些是平台无关或关系不大的函数,比如字符串、数学相关函数等。
- 另外C运行时库为了支持程序的运行,还在统一的main函数前后做了一些逻辑,比如在main之前初始化一些全局变量、环境变量、命令行参数等,在main之后做一些资源的清理等。
总结一下,C运行时库里提供了一系列常用的库函数,包括把平台相关函数封装成统一的接口、平台无关的,以及给我们提供了一个统一的main入口。
这些库函数是事先编译好的,通常随编译器一起发布,编译器在编译我们的程序时,自动帮我们做了链接,让我们无感。
这也是为什么教科书说C语言是可移植语言的原因之一,因为这些跨平台实现帮我们屏蔽了操作系统的差异,否则就需要调各操作系统的API,为不同平台编写不同的代码了。
2. C++运行时库
C++语言,是在C语言的基础上提供了更多的功能特性,包括:
- 语言本身,提供了类、多态、new/delete、异常、RTTI等。
- 标准库,提供了string、vector、list、map等各种容器和算法,以及输入输出流、智能指针、日期时间、线程相关等。
C++运行时库,也是在C运行时库的基础上,为C++语言的这两部分特性提供支持。
这些库,语言提供者们帮我们实现好,放在一个动态库或静态库中,编译器最终做链接即可。
3. 总结一下
C/C++运行时库的具体功能,综合来看,有几方面:
- 支持程序的启动和退出。包括main之前的全局变量、环境变量、命令行参数的初始化,和main之后的资源清理等。
- 把一些平台相关API封装成统一的库函数或类,便于我们跨平台开发。
- 其它常用功能的实现,封装成函数或类。
- 少量语言特性的支持,比如异常处理、RTTI等。
三、各平台的C/C++运行时库
前面介绍了C/C++运行时库的通用概念和主要功能,这部分介绍一下在各平台的具体存在形式。
为了更好的理解,在之前先介绍一下语言的标准和实现、动态库与静态库,以及各平台的库文件格式。
1. 标准与实现
C和C++语言,都是只有一种标准,但有多种实现。
(1) 一种标准
即 ISO 的C/C++标准委员会制定的,但这个标准按时间顺序,有多个版本,后一个版本在前一个版本基础上改进,增加新的特性,同时也可能废弃一些特性。
对于C语言,有C89/C90、C99、C11等;C++ 有 C++98、C++11、C++17、C++20 等。
C标准各版本简介:
- C89:ANSI C,第一个版本,是美国标准,有32个关键字,1989年
- C90:和C89差不多,被国际ISO标准采纳,1990年
- C99:1999年发布,增加了多个特性,包括变长数组、inline关键字、//注释、宏可变参数
- C11:2011年发布,增加了多线程、内存对齐、Unicode等支持
C++标准各版本简介:
- C++98:C++的第一个标准版本,1998年由国际标准化组织(ISO)发布。
- C++03:对C++98的一个小修订,2003年发布。
- C++11:2011年发布,重大更新,引入了很多新特性,如auto类型、范围for循环、列表初始化、lambda表达式等。
- C++14:2014年发布,对C++11的一个小修订。
- C++17:2017年发布,重大更新,引入了很多新特性,如结构化绑定、并行算法、模板参数自动推导等。
- C++20:2020年发布。引入了很多新特性,如概念、协程、模块等。
语言的标准,只是定义了语言的语法语义,以及有哪些头文件、库函数声明等,但并不负责语言及这些库函数的实现。
(2) 多种实现
语言的具体实现,是由各编译器厂商完成的,包括语法语义的实现,和标准库(运行时库)的实现。
主要的有Linux下GNU的实现,Windows下的MSVC实现、以及LLVM的实现等,后文会具体介绍。
标准和实现的关系:
标准和实现不一定是完全一致的。比如某编译器版本,可能对标准某特性的遵循不够完善,也有可能某个编译器先实现了某语言特性,然后才被纳入标准。
2. 静态库与动态库
与我们自己开发的库是分为静态库与动态库(也叫共享库)一样,C/C++运行时库也是分为静态库与动态库。比如在Linux平台,静态库的后缀是.a,动态库的后缀是.so。
- 动态库(共享库)会被多个程序共享,好处是程序体积小,但缺点是运行时会多一些依赖。
- 静态库正好相反,优点是运行时少依赖,但缺点是体积大,因为它被链接进可执行文件内部。
这些静态库或动态库,是由厂商开发,提前编译好,然后在编译器编译链接我们的程序时,自动链接进去。
3. 库的文件格式
不同平台的库文件的二进制格式是不一样的,文件名后缀也不一样,为了避免在后文中部分同学疑惑,这里简单介绍一下各平台的库文件格式。
可执行/库文件格式,常见的就是三种,我列成一个表格:
注:
- ELF:Executable and Linkable Format
- PE:Portable Executable
- Mach-O:Mach Object file format
4. 各平台的具体实现
现在我们开始看看各平台的具体实现,包括Linux平台的GNU实现、Windows的实现、LLVM的实现、移动端的实现,以及其它嵌入式平台的实现。
(1) GNU的实现
这是Linux后台开发最常见的实现,其C运行时库是GNU C Library,简称glibc。
- GNU简介:GNU是一个开源项目,全称GNU's Not Unix,目标是开发一个操作系统,但由于其内核开发缓慢,我们实际上用到的是GNU的各种上层工具+Linux内核的结合,即GNU/Linux。
- GNU项目具体包括:GCC(多种编译器集合)、C库(即glibc)、bash及各种命令行工具、编辑器等。我们日常接触到的Linux,其实准确叫GNU/Linux,它是由GNU的各种上层工具+Linux内核组成。常见的各种Linux发行版(Ubuntu、Debian、CentOS等),都属于GNU/Linux。
在Linux平台,一个最简单的C语言程序,在运行时会依赖哪些库?
如下Hello World代码:
// hello.c
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
使用 gcc 编译,默认动态链接C运行时库:
gcc hello.c -o hello
使用 ldd 命令查看依赖:
图中的libc.so,即C的运行时库。
其实严格来说,图中的这三个库,都是C程序的运行时库,因为都是为程序的运行提供支持的。
顺便看看文件大小:
Linux平台,一个最简单的C++程序,运行时会依赖哪些库?
如下Hello World代码:
// hello.cpp
#include <iostream>
int main() {
std::cout << "Hello World!" << std::endl;
return 0;
}
使用g++编译,默认动态链接C/C++运行时库:
g++ hello.cpp -o hello
用 ldd 命令查看依赖:
图中的libstdc++.so,即C++的运行时库。
附其它几个库简介:
- linux-vdso.so:虚拟库,用于程序更高效的调用部分内核接口
- libm.so:数学库
- libgcc_s.so:gcc的支持库,用异常处理、RTTI等
- ld-linux-x86-64.so:动态库的加载器
库的静态与动态:
gcc、g++对于C/C++运行时库,默认都是链接到其动态库版,但也可以链接到其静态库版。
方法是:
- C库:gcc指定 -static 参数,这样就可以链接到静态库 libc.a(事实上-static会把所有库都静态链接)
- C++库:g++ 指定 -static-libstdc++ 参数,这样就可以链接到静态库版libstdc++.a
一个C程序使用 -static 静态链接后,再看看其依赖的库:
看看文件大小:
图中看出,二进制不再依赖 libc.so 等库了,这些库被静态链接到了可执行文件内部,体积也比之前大了。
(2) Windows的实现
在Windows平台,微软也实现了自己的C/C++运行时库,一般随Visual Studio发行。
一个最简单的C/C++程序,使用vs2022编译,默认指定/MD选项
用 Depends 工具查看其依赖的动态库:
图中的红框部分,就是C/C++运行时库。
在 Visual Studio 里,也可以设置动态或静态链接运行时库,方法是:
工程设置里(C/C++ -> 代码生成 -> 运行库)
- /MT 多线程,即链接到静态库版
- /MD 多线程DLL,即链接到动态库版
另外还有个 /MTd 、/MDd 是用于调试版本。
当设置 /MT 即静态链接后,就不再依赖这些库了,如下图:
hello.exe 文件体积从 13K 增加到 200K。
Windows平台C/C++运行时库动态版的文件名,不同的版本不一样。
- 早期的Vc6,是 msvcrt.dll 和 msvcp6.dll
- 后来的版本,是 msvcrXX.dll 和 msvcpXX.dll(XX为版本号)
- 从vs2015开始,微软对其进行了重构,拆分成了ucrtbase.dll、msvcp140.dll、vcruntime140.dll等
(3) LLVM的实现
LLVM是一个较新的开源项目,可以说是专门为做编译器相关而生,是一个更优的编译器,各种配套的工具也比较完善,近年来越来越多的项目开始使用LLVM。
LLVM里用于C系语言(C、C++ 和 Objective-C)的编译器前端,叫Clang。
除了做编译器外,LLVM也开发了自己的C++库,名字叫 libc++ (不同于GNU的 libstdc++)。LLVM也有自己的C库,不过目前还不够完善。
LLVM是一个跨平台项目,支持多种平台,包括Linux、Windows、macOS、iOS、Android等。
比如在Linux平台,其C++运行时库的文件名,动态库版叫libc++.so,静态库版叫libc++.a。
(4) 移动端的实现
① iOS:
在iOS及macOS平台,苹果使用了LLVM的Clang作为Xcode内置的C/C++编译器。
- 其C运行时库文件名是 libSystem.dylib,这个库也包含了其它系统库的功能。
- 其C++运行时库是 libc++.dylib 或 libc++.a。
② Android:
Android平台一般使用Java或Kotlin开发,但在某些性能要求高的场合,也会使用C/C++开发,即NDK开发。
- Android平台的C运行时库,叫Bionic libc,这是Google为Android专门开发的,比glibc更轻量,更适合移动设备。它同时提供了动态库版本(libc.so)和静态库版本(libc.a)。
- Android NDK的C++运行时库,以前支持三种:libc++(即LLVM的)、libstdc++(即GNU的)和STLport,后来从NDK r18开始,只支持libc++了。
Android平台里的 libc++ 库的名字不太一样,其动态库版叫 libc++_shared.so,静态库版本叫 libc++_static.a。
是链接到静态库还是动态库,可以在工程里指定,比如:
APP_STL := c++_shared
(5) 其它实现
除了上面常见的实现之外,还有一些早期的C/C++编译器,也都带有自己的运行时库,比如Turbo C(很多人在学校里用的)、Borland C++(有人用过吗)、C++Builder 等。
在一些嵌入式平台,会使用一些更轻量的C运行时库,主要有开源的 Newlib、uClibc、musl 等。
5. 总结一下
总结一个表格:
四、C/C++运行时库相关问题
在开发中,我们常碰到的C/C++运行时库相关问题,主要有多实例问题和多版本问题,下面分别介绍一下。
1. 运行时库的多实例问题
为简单起见,这里先只考虑单一开发环境下(即只有一个编译器和运行时库版本),进程内有多个运行时库实例的问题。
先看一段代码:
- 动态库导出一个接口函数:
char *GetData() {
char *data = malloc(100);
strcpy(data, "Hello World!"); // 仅演示,工作中不要用strcpy
return data;
}
- 主程序调用动态库的接口函数:
int main() {
char *data = GetData();
free(data);
return 0;
}
这段代码,在有些平台下运行(主要是Windows平台),可能会crash。
为什么会crash,根本原因是和运行时库的内存堆有关,下面具体讲讲。
(1) 单内存堆
在C运行时库内部,有一个内存堆,也就是一个内存池,如下图:
图片
大多数情况下,进程内只有一个C运行时库实例,也就是只有一个内存堆。
上述代码的依赖关系:
App.exe
A.dll
crt.dll
注:用缩进表示依赖关系,crt.dll 表示C运行时库,相当于linux的 libc.so
A.dll 向 crt.dll 申请内存,用完后被 App.exe 再归还给 crt.dll,这样没问题。
但是当进程内里有多个内存堆时,情况就不一样了。
(2) 多内存堆
当进程内有多个C运行时库实例时,就会有多个内存堆实例。
比如在Windows平台,A.dll 设置 /MT 选项,即静态链接C/C++运行时库,主程序默认/MD选项,即动态链接C/C++运行时库。
这时的依赖关系:
App.exe
A.dll (静态链接crt)
crt.dll
这种场景下,A.dll 会从自己的内存堆中分配内存,用完后被 App.exe 归还给了 crt.dll 的内存堆,这样就引起了内存堆的结构异常,出现crash。
(3) 隐藏更深的情况
下面再看一种隐藏更深的情况,工作中更为常见。
动态库 A 导出一个接口:
void GetData(std::string &data) {
data = "Hello World!"; // 这句赋值内部,string会分配内存
}
在主程序或另一个动态库中,调用动态库 A 的接口:
void Test() {
std::string data;
GetData(data);
// data析构,释放string内部之前分配的内存,导致crash
}
上述代码在静态链接C/C++运行时库时,会出现crash。
这里crash的本质,其实和前面一样的,即一个模块的分配的内存,交给了另一个模块释放。
除此之外,这个代码,还有另一个风险,即如果两个模块使用了不同的编译器或C++库(比如一个使用GCC编译,一个使用LLVM编译),就可能会出现string的内存结构不一致而异常。
(4) 其它情况
在进程内有多C/C++运行时库实例时,还有一些其它有问题的情况,比如跨模块传递文件指针、跨模块传递环境变量等。
比如:
// 动态库导出该接口
void WriteData(FILE *file) {
fwrite(...);
fclose(file);
}
// 主程序里
int main() {
FILE *file = fopen(...);
WriteData(file);
return 0;
}
(5) 总结一下
运行时库的多实例,是由静态链接C/C++运行时库引起。在这种多实例场景下,一些不太好的代码写法,就会表现出问题。
如何避免这些问题,建议:
① 作为动态库的设计者:
- 尽量做到内存「谁分配谁释放」的原则
- 尽量避免库间接口传递C/C++对象
这样将会有更好的兼容性,即使在进程内有多个C/C++运行时库时,也不会有问题。
② 作为App的总体设计者:
尽量保证进程内只有一份C/C++运行时库实例
这样也是会有更好的兼容性,能避免很多潜在问题。
如果用生活中的例子来类比,多运行时库,就相当于一个公司对接了多个银行,某个部门从 A 银行借来的钱,被另一个部门还给了 B 银行,这样就引起了问题。
解决方法就是谁借的钱,谁来还,另外就是一个公司尽量只对接一个银行。
关于运行时库的多实例,幸运的是,大部分平台的编译器,已经帮我们规避了可能会导致运行时库多实例的问题。
我简单测了一下:
- Linux平台:对于主程序和动态库,都默认动态链接C/C++运行时库,主程序允许静态链接C/C++运行时库(方法是 -static 选项),动态库不支持静态链接C/C++运行时库。
- Windows平台:对于主程序和动态库,都支持动态或静态链接C/C++运行时库。
- iOS和macOS平台:不支持静态链接C/C++运行时库。
- Android平台:动态库支持动态或静态链接C++运行时库,默认静态链接,这可能导致风险,官方文档提供了说明。
所以总结来看,这个问题主要是在Windows和Android平台可能会出现,其它平台编译器不提供这种设置(特殊方法除外,比如自己开发一个运行时库并静态链接)。
如果你是做Windows和Android平台开发,或者做跨平台开发,涉及到这两个平台,就必须考虑运行时库多实例问题,否则就可以不用太操心这个问题,编译器已经防止了你犯错。
2. 运行时库的多版本问题
最后这一部分,简单说说运行时库的多版本问题。
和我们自己开发的软件或库,会不断升级,会有多个版本一样,C/C++运行时库,也是在不断升级,有很多个版本。
运行时库的多版本,会引起两个问题:
- 编译时用的库,和运行时用的库,版本不一致
- 编译同一个App的多个部分,用的库版本不一致
后者在开发一些大型项目,不同模块分属不同团队开发时,更容易出现。
这种不一致,可能会引起不匹配,从而产生各种问题。
(1) 问题现象
有些是在链接时不通过,提示符号冲突或找不到。比如:
undefined reference to `std::string::operator=(std::string const&)'
undefined reference to `std::ostream::operator<<(std::ostream& (*)(std::ostream&))'
(也有可能是设置不统一引起)
有些是在启动时,动态库加载器检测出异常,提示缺少符号,比如:
./test: /lib64/libstdc++.so.6: version `CXXABI_1.3.9' not found (required by ./test)
./test: /lib64/libstdc++.so.6: version `GLIBCXX_3.4.21' not found (required by ./test)
./test: /lib64/libstdc++.so.6: version `GLIBCXX_3.4.22' not found (required by ./test)
有些是启动时没检测出异常,而在运行时会有莫名其妙的问题。
(2) 解决方法
解决的方法,无它,只能是尽量保证一致,包括:
- 编译各模块时,用的开发环境(包括编译器版本+运行时库版本+参数设置)一致。
- 运行时和编译时的库环境一致。
具体怎么保持一致,有如下一些方法:
① 静态链接
即不再依赖环境中的C/C++运行时库,用体积换取少依赖。这个在Windows平台较常用,Android平台在只有一个动态库时,官方也推荐静态链接。
② 开发环境多版本共存时
选择其中一个编译器和C/C++运行时库版本。比如在Windows平台选择工具集(v142/v143等),Linux平台通过环境变量选择GCC版本、Android平台环境变量选择NDK版本(r23/r25等)。
③ 运行环境多版本共存时
通过指定搜索路径选择其中一个版本。比如Linux平台在二进制中指定(rpath)、环境变量指定(LD_LIBRARY_PATH)。
④ Docker
Linux平台常用,即运行时提供一个隔离的干净的环境。
五、总结
本文介绍了什么是C/C++运行时库,运行时库的主要的功能,各平台的存在形式,以及开发要注意的问题,包括多实例问题和多版本问题等。遵守一些开发原则,以及保证运行时库单实例、开发和运行环境的一致,可以避免很多潜在问题。