充分理解 C/C++ 重要概念:运行时库

开发
本文介绍了什么是C/C++运行时库,运行时库的主要的功能,各平台的存在形式,以及开发要注意的问题,包括多实例问题和多版本问题等。

作者 | 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++运行时库,运行时库的主要的功能,各平台的存在形式,以及开发要注意的问题,包括多实例问题和多版本问题等。遵守一些开发原则,以及保证运行时库单实例、开发和运行环境的一致,可以避免很多潜在问题。

责任编辑:赵宁宁 来源: 腾讯技术工程
相关推荐

2010-01-27 14:14:48

C++程序运行时间

2011-08-19 15:05:29

异常处理

2023-11-21 16:31:51

C++语言

2024-12-09 13:00:00

C++类型安全

2011-12-27 09:39:12

C#运行时

2011-07-10 15:36:54

C++

2010-01-15 10:41:06

CC++

2015-07-20 15:44:46

Swift框架MJExtension反射

2014-09-02 10:39:53

Go语言C语言

2024-11-27 08:26:00

C++模板静态

2010-02-02 11:16:28

C++异常

2011-05-18 17:33:15

CC++

2024-03-21 09:15:58

JS运行的JavaScrip

2023-12-18 11:15:03

2009-02-10 09:03:59

动态语言CLRVB.NET

2010-02-06 09:53:26

C++ void

2010-02-01 16:13:15

C++继承

2019-07-12 09:30:12

DashboardDockerDNS

2021-09-11 15:38:23

容器运行镜像开放

2023-11-28 11:51:01

C++函数
点赞
收藏

51CTO技术栈公众号