共享库中的外部变量是如何工作的?

5

假设我写了一个类似以下代码的简单动态库:

lib.h

#pragma once

extern int x;
extern int p(void);

lib.c

#include <lib.h>
#include <stdio.h>

x = 0;
int p(void) {
    printf("lib: %d\n", x++);
    return 0;
}

a.c

#include <lib.h>
#include <stdio.h>

int main(void) {
    for (; !p(); x--) printf("a.c: %d\n", x);
    return 0;
}

b.c

#include <lib.h>
#include <stdio.h>

int main(void) {
    for (; !p(); x = 0) printf("b.c: %d\n", x);
    return 0;
}

a和b会打印出不同的结果,可能会发生以下几种情况:
- 链接错误:x被声明为extern但从未定义。 - 每个进程都有自己的x,包括lib(b.c始终为0,a.c递减,lib递增)。 - 每个进程都有自己的x与lib共享(a.c和b.c始终为1,lib始终为0)。 - 所有进程都共享同一个x,包括lib(a.c、b.c和lib返回随机值)。 - 所有进程都共享同一个x,包括lib,直到除lib以外的其他进程写入它,然后该进程获得自己版本的x,不与lib共享(在某个网站上阅读到的)。 (lib始终递增,b.c始终打印0,a.c递减)。
通常会发生什么?编译器/平台之间是否存在任何不一致之处?我们可以强制执行一种行为吗(我想到了__declspec(dllexport)、编译器标志等)?

1
我认为链接器错误部分并不相关,因为您将不得不做一些工作来产生一个可执行结果,忽略未定义的符号。在运行时的情况下,共享库在每个进程中单独链接到运行时,并且可写页面默认为写时复制。在正常情况下,a和b都有自己的x副本(大星号),没有单独的lib进程。在Windows上,如果明确请求,dll可能包含在进程之间共享的数据,但更常见和灵活的方法是明确创建和使用共享内存段来共享内存。 - Art Yerkes
据我所知,如果链接器不知道lib导出的extern int x,则链接器会报错,这可能意味着使用该链接器时库无法导出变量。至于您评论中的其余内容,如果我理解正确:例如写入到extern int x将不可见于其他进程,但对于lib,只有在调用该进程时才能看到(就像情况3一样,除了最初所有进程共享相同的x)。是这样吗?还是您(通过“写入时复制”)的意思是当您写入一个变量时,它将创建一个lib不使用的新变量? - yyny
顺便说一下,将这个变成答案 :) - yyny
请参见https://dev59.com/3mIk5IYBdhLWcg3wU8yo。 - nos
1个回答

3

这个问题有几个方面:

a和b会打印出什么?我能想到可能会发生的几件事情:

Linker error: x declared extern but never defined.

由于a和b可能还没有被编译成可执行文件,因此不会打印任何内容。当然,您需要链接lib.so、lib.a或一个导入库lib.lib,以将可执行文件暴露给x的可链接定义,否则什么也不起作用(大多数情况下是这样,如果努力尝试可能会更复杂)。

Each process gets it's own x, including lib. (b.c is always 0, a.c counts down, lib counts up)

在您的场景中,lib不是一个进程,而是一个共享库。这个共享库在每个进程空间中被单独加载和链接,只要有东西以动态加载器(ld-linux.so,在Windows上为ntdll.dll)理解的方式引用它。每个进程在其地址空间中观察到已加载库的副本,并且库本身看到相同的副本,因此运行a应该永远打印0后跟1。 p()被运行并测试,x被打印,x被递减回0。b也将永远打印0后跟1。p()被运行并测试,x被打印,x被设置为0。请注意,p()打印x++,因此增量是在捕获printf参数的值之后发生的。包含a和b的程序所引用的x变量对于每次运行a或b都是特定的。这通常是通过在操作系统级别映射实际可加载库的页面到内存中并将它们设置为“写时复制”来完成的,其中主机进程尝试更改会导致操作系统首先分配新页面并在第一次复制旧内容时进行复制。结果是,未修改的已加载库部分占用的实际内存较少。

Each process gets it's own x to share with lib. (a.c and b.c are always 1, lib is always 0)

Lib不是一个独立的进程。在执行p()时,它所看到的x与a链接的x相同。

All processes share the same x, including lib. (a.c, b.c and lib return random values)

通常不是这种情况(也请参见下文)。
All processes share the same x, including lib, until someone other than lib writes to it, then that process gets it's own version of x, not shared with lib (Read this online somewhere). (lib always increments, b.c always prints 0, a.c counts down)

一些旧的运行时系统不支持分离地址空间,例如AmigaDOS。但你很少会遇到这种情况。

What typically happens? Are there any inconsistencies between compilers/platforms we should know about? Can we force one behaviour (I am thinking __declspec(dllexport), compiler flags, etc.)?

在绝大多数情况下,每个进程与在该进程中加载的给定库的一个实例共享外部变量。除非您采取特定措施,否则这是预期的。
在评论中,还有一些其他问题:
Can windows dlls (or others) export non-function data.

是的,当构建导入库时,请在.def文件中使用DATA限定符。 对于其他函数,与导出函数无异。 但是,您将收到指向目标变量的指针,而不是绑定到占用空间。

Asterisk, see below?

在Windows上,section拥有一个SHARED属性,会导致加载器在使用DLL的每个进程中分配相同的页面。这不是默认设置,您需要采用平台特定的pragma方式来实现它。有很多原因不要使用这个功能。
大多数时候,当dll想要在许多进程中加载的副本中共享状态时,它使用主机系统的共享内存API(通常为CreateFileMapping或mmap)。这样可以实现灵活性(例如,所有进程可以共享一个x的版本,与具有另一个x副本的所有b进程分开)。请注意,使用SHARED可能会导致运行a崩溃b,并且另一个长时间运行的用户c加载可能会使a或b无法重新启动,直到重启计算机。

谢谢,这很有帮助。然而,虽然我知道lib不是(技术上)一个进程,而且我也从未声称过它是,但我认为将共享库与进程进行比较会使解释它们变得更容易。在共享库之间共享内存就像在进程之间共享内存一样,这不是C标准定义的,但是其他大型标准(POSIX、Windows等)通常实现了这个功能。事实上,据我所知,在进程之间共享内存的工作方式与在共享库中相同。 - yyny
还有一个问题:在共享库中返回静态变量的指针总是有效的(在写入时修改变量),对吗? - yyny
是的,每个人都在进程空间中观察相同的地址,因此您可以在共享库中返回静态和全局指针,用户和您的库代码将观察到相同的值(在写入时修改,因此其他进程不会观察到相同的更改)。在进程之间共享内存在根本上是不同的,因为共享库没有自己的生命。它所做的一切都是代表它所在的进程。进程之间共享内存是两个进程做出的选择,而共享库无法退出进程地址空间。 - Art Yerkes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接