blog · git · desktop · images · contact & privacy · gopher


Speicherort globaler und statischer Variablen in C

Vor einiger Zeit habe ich einige Blogpostings zum grundlegenden Memory Management von Userspace-Prozessen unter Linux geschrieben: 0, 1.

Schauen wir uns noch einmal kurz das berühmte Bild an:

+----------+··+------:····························:------+------+·······:-------+··+--------+
| Programm |  | Heap :-->                      <--: mmap | vdso |    <--: Stack |  | Kernel |
+----------+··+------:····························:------+------+·······:-------+··+--------+
^             ^                                                                 ^  ^
|             |                                                                 |  |
0x08048000    0x08xxxxxx                                               0xBFxxxxxx  0xC0000000

Anmerkungen: Dieses Bild wurde erstellt, um den Adressraum auf einem 32-Bit-Linux darzustellen. Bei 64 Bit sieht das minimal anders aus. Außerdem: Das „Programm“-Segment ist auch als .text bekannt.

Wie wird dieser Speicher nun benutzt? Wo werden Variablen gewöhnlicherweise abgelegt, wenn man in C programmiert? Meine vorigen Artikel haben sich hiermit beschäftigt:

Es gibt aber noch zwei andere „Klassen“ von Variablen in C: Globale Variablen und Variablen in einer Funktion, die dort als „static“ markiert sind. Wie funktionieren diese? Wo werden die abgelegt?

static vs. static

Das Keyword static hat mehrere Bedeutungen:

  1. Wenn es vor einer globalen Variable benutzt wird, dann ist diese Variable nur innerhalb der aktuellen Datei „sichtbar“.
  2. Wenn es vor einer Variablen innerhalb einer Funktion benutzt wird, dann „überlebt“ diese Variable mehrere Funktionsaufrufe und behält ihren Wert bei.

Der erste Punkt ist nicht wirklich relevant für diesen Artikel. Es wird der Geltungsbereich einer Variablen verändert, aber das passiert auf einer ganz anderen Ebene. Im Endeffekt sind auch solche Variablen nur irgendwo im Speicher abgelegt und können von überall aus angesprochen werden – sofern man ihre Adressen kennt.

Globale Variablen – ob static oder nicht – sind interessant. Sie leben nicht auf dem Heap, nicht im mmap-Bereich und auch nicht auf dem Stack. Und statische Variablen in Funktionen sind interessant, weil sie mehrere Funktionsaufrufe überleben. Folglich können auch sie nicht wie gewöhnlich auf dem Stack liegen.

Aber wo leben diese Variablen dann?

Ein neuer Speicherbereich

Man betrachte folgenden Code:

#include <signal.h>
#include <stdio.h>
#include <sys/types.h>

int global_i;
int global_i_initialized = 123;

int
main()
{
    static int function_i;
    static int function_i_initialized = 456;

    printf("%p\n", (void *)&global_i);
    printf("%p\n", (void *)&global_i_initialized);
    printf("%p\n", (void *)&function_i);
    printf("%p\n", (void *)&function_i_initialized);

    kill(0, SIGSTOP);

    return 0;
}

Wenn man das laufen lässt, dann gibt das Programm zunächst ein paar Adressen aus und hält sich dann selbst an. Daraufhin kann man den Adressbereich des Programms untersuchen:

$ ./bla 
0x6009a8
0x60099c
0x6009a4
0x600998

[1]+  Stopped                 ./bla

$ cat /proc/$(pgrep bla)/maps 
00400000-00401000 r-xp 00000000 00:21 325277             /tmp/bla
00600000-00601000 rw-p 00000000 00:21 325277             /tmp/bla  <---------
7f4ee52fa000-7f4ee5495000 r-xp 00000000 08:01 10227509   /usr/lib/libc-2.22.so
7f4ee5495000-7f4ee5694000 ---p 0019b000 08:01 10227509   /usr/lib/libc-2.22.so
7f4ee5694000-7f4ee5698000 r--p 0019a000 08:01 10227509   /usr/lib/libc-2.22.so
7f4ee5698000-7f4ee569a000 rw-p 0019e000 08:01 10227509   /usr/lib/libc-2.22.so
7f4ee569a000-7f4ee569e000 rw-p 00000000 00:00 0 
7f4ee569e000-7f4ee56c0000 r-xp 00000000 08:01 10227099   /usr/lib/ld-2.22.so
7f4ee5888000-7f4ee588b000 rw-p 00000000 00:00 0 
7f4ee58be000-7f4ee58bf000 rw-p 00000000 00:00 0 
7f4ee58bf000-7f4ee58c0000 r--p 00021000 08:01 10227099   /usr/lib/ld-2.22.so
7f4ee58c0000-7f4ee58c1000 rw-p 00022000 08:01 10227099   /usr/lib/ld-2.22.so
7f4ee58c1000-7f4ee58c2000 rw-p 00000000 00:00 0 
7ffe48b2f000-7ffe48b50000 rw-p 00000000 00:00 0          [stack]
7ffe48bd1000-7ffe48bd3000 r--p 00000000 00:00 0          [vvar]
7ffe48bd3000-7ffe48bd5000 r-xp 00000000 00:00 0          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0  [vsyscall]

$ fg
./bla

Man sieht also, dass alle vier Variablen in einem Speicherbereich über .text liegen, der les- und schreibbar ist (00600000-00601000). Das ist auch schon die Antwort auf unsere erste Frage. Ja, die fraglichen Variablentypen leben nicht auf dem Stack oder Heap oder mmap. Stattdessen bekommen sie ihren ganz eigenen Speicher und sind dadurch tatsächlich während der gesamten Laufzeit des Programms verfügbar.

So weit, so gut.

Initialisierte und uninitialisierte Variablen

Es macht einen Unterschied, ob man Variablen initialisiert oder nicht. Deswegen habe ich oben vier verschiedene eingeführt. Zur Laufzeit liegen die zwar möglicherweise im selben Bereich, sie leben aber in unterschiedlichen Sektionen. Das ist ein recht subtiler Unterschied. Deutlicher wird es, wenn man sich den Assembler-Code anschaut:

$ gcc -Wall -Wextra -S -o bla.S bla.c

Ich beschränke mich hier auf die relevanten Abschnitte. Schauen wir uns zunächst initialisierte Variablen an, da diese etwas einfacher zu verstehen sind.

Am Anfang des Codes findet man das hier:

    .data
    .align 4
    .type   global_i_initialized, @object
    .size   global_i_initialized, 4
global_i_initialized:
    .long   123

Und am Ende das:

    .data
    .align 4
    .type   function_i_initialized.2802, @object
    .size   function_i_initialized.2802, 4
function_i_initialized.2802:
    .long   456

Über .data wird die gleichnamige Sektion in der finalen ELF-Datei zusammengestellt. Einfach ausgedrückt: Hier kann man Daten ablegen und zur Laufzeit wird diese Sektion samt Daten dann in den Speicher geladen, irgendwo in die Nähe von .text. Ganz einfach, oder? Interessant zu sehen ist, dass beide Variablen hier gespeichert werden. Das heißt also, dass der einzige Unterschied zwischen den beiden der Scope auf C-Ebene ist. Man kann function_i_initialized nicht außerhalb von main (oder wo auch immer sie deklariert wurde) über ihren Namen ansprechen.

Okay, jetzt zu uninitialisierten Variablen. Am Anfang des Codes kann man nun sehen:

.comm   global_i,4,4

Und am Ende wiederum:

.comm   function_i.2801,4,4

Es ist nun wichtig, zu betonen, dass beide Variablen nur „deklariert“ wurden – ihre Größe und Alignment wurde bekanntgegeben –, aber ihnen wurde kein Wert zugewiesen. Noch nicht einmal „0“. Klar, warum sollte man hier auch einen Wert festlegen? Der Programmierer hat die Variablen nicht initialisiert, also ist es ihm auch egal, welchen Wert sie (initial) haben. Was er eigentlich will, ist eine gewisse Speichermenge. Und das ist, was tatsächlich passiert: Diese „Deklarationen“ erhöhen einen Counter. Mehr nicht. Am Ende wird die resultierende Größe in der Binary gespeichert. Führt man das Programm dann aus, allokiert der Loader den notwendigen Speicher. All das passiert innerhalb der .bss-Sektion.

Interessantes Detail: Adressierung relativ zu RIP

Oftmals wird Speicher direkt über die Adresse referenziert oder relativ zum aktuellen Stack Frame. Benutzt man aber .data und .bss, dann wird ein anderes interessantes Verfahren zur Adressierung offenbar.

Man nehme folgenden Code:

int
main()
{
    static int function_i;

    function_i = 5;

    return 0;
}

Schaut man sich den Assembler-Code dazu an, dann springt diese Instruktion ins Auge:

movl    $5, function_i.2285(%rip)

Das wird „RIP-relative addressing“ genannt. Das Register RIP beinhält die Adresse der nächsten Instruktion. Dadurch, dass .data und .bss so nah an .text liegen, ist der Weg nicht weit, und es bietet sich an, Variablen darin über eine Adresse in .text als Basis zu adressieren.

Was für einen Zweck hat das? Ist das nicht unnötig kompliziert? Das Ding ist: „RIP-relative addressing“ vereinfacht das Erzeugen von position-independent code.

Randbemerkung an C-Anfänger

Manchmal schreibt man als Anfänger Code dieser Art:

#include <stdio.h>

char *
foo(void)
{
    char s[] = "hello world";
    return s;
}

int
main()
{
    char *t;

    t = foo();
    printf("%s\n", t);

    return 0;
}

Glücklicherweise warnt der Compiler heute sehr deutlich. Das Problem ist, dass man die Adresse von etwas aus der Funktion zurückgibt, was eigentlich nur innerhalb des Stack Frames genau dieser Funktion existiert. Wenn foo() also fertig ist, dann zeigt t nach wie vor in den ehemaligen Stack Frame. Es ist jetzt aber unbekannt, was sich nun an dieser Stelle im Speicher befindet.

(Der Knackpunkt ist, zu verstehen, dass man nur einen Pointer zurückgibt und nicht den gesamten String.)

Die Lösung des Problems ist nun NICHT, die Variable s als static zu deklarieren.

Man betrachte:

#include <stdio.h>

char *
foo(char c)
{
    static char s[] = "hello world";
    s[0] = c;
    return s;
}

int
main()
{
    char *t, *u;

    t = foo('x');
    u = foo('y');
    printf("%s\n", t);
    printf("%s\n", u);

    return 0;
}

Was wäre hier zu erwarten? Löst das nicht unser Problem? Hast du nicht irgendwo gelesen, dass „statische Variablen nicht verschwinden, wenn die Funktion zuende ist“? Außerdem spuckt der Compiler keine Warnung aus! Das ist es!

Nein, ist es nicht.

Was foo() jetzt zurückgibt, ist eine Adresse aus der .data-Sektion. Und es gibt dieselbe Adresse bei jedem Aufruf zurück. Ich will darauf hinaus: static s sorgt NICHT dafür, dass man eine Variable erzeugt, die halt einfach nach dem Funktionsaufruf weiterlebt. Mit dieser Denkweise könnte man nämlich meinen, dass bei jedem Funktionsaufruf eine neue „unsterbliche“ Variable erzeugt wird. Nein!

Man kriegt nur eine Variable.

Zusammenfassung

Das war nun ein sehr grober Überblick. Da gibt es noch viel zu lernen.