Wyświetlanie adresu pamięci (wirtualnej) jest stosunkowo prostym zadaniem, bowiem jest to nic innego jak liczba, której typowy rozmiar wynosi 64-bity (lub 32-bity). Specyfika tej liczby polega jednak na tym, iż jest ona przedstawiana w formacie szesnastkowym (heksadecymalnym). Odwzorowaniem adresu pamięci w języku C++ (tak samo jak w języku C) nie jest jednak typ liczbowy lecz typ wskaźnikowy (ang. pointer). Typ obiektu, którego adres przechowuje wskaźnik nie ma znaczenia z punktu widzenia wyświetlania adresu pamięci (z pewnym wyjątkiem dla ”rodziny” typu char *, o czym będzie mowa w dalszej części artykułu) dlatego dostępne rozwiązania mogą bazować na typie void * lub const void *. To co należy mieć na uwadze to fakt, że nie istnieje jeden sposób i format wyświetlania adresu pamięci. W artykule tym przedstawię dwa rozwiązania stosowane w języku C++, oparte odpowiednio na funkcji printf (plik nagłówkowy cstdio) oraz operatorze << (metody obiektu std::cout przeciążonej dla typu wskaźnikowego void const *).
Funkcja printf
Funkcja printf należy do najczęściej używanych funkcji wśród programistów języka C (należy ona do biblioteki standardowej języka C, a więc jest też dostępna w języku C++). Jest to funkcja wariadayczna (ang. variadic function), która jako pierwszy parametr przyjmuje łańcuch tekstowy do wyświetlenia (parametr format typu const char *), przy czym może on zawierać specyfikatory formatu (ang. format specifiers), które docelowo zastępowane są przez kolejne argumenty przekazane do tej funkcji. W naszym przypadku będzie tylko jeden argument dodatkowy w postaci wskaźnika reprezentującego adres w pamięci (oczywiście nie należy przywiązywać uwagi do tej wartości, bowiem będzie ona z pewnością inna po każdym uruchomieniu przykładowego programu).
Nasze rozważanie rozpoczniemy od specyfikatora formatu ”%p”, który odpowiedzialny jest za wyświetlanie adresu pamięci przechowywanego w elemencie typu wskaźnikowego. Formalnie typem odpowiadającym temu specyfikatorowi jest void * i w zasadzie powinno wykonać się jawne rzutowanie:
#include <cstdio> int main() { int x = 42; printf("%p\n", static_cast<void *>(&x)); }
0x7ffe3b9cb94c
Warto zauważyć, że zarówno w przypadku języka C jak i C++ zachodzi niejawna konwersja z dowolnego typu wskaźnikowego na typ void * i jawne rzutowanie nie jest wtedy potrzebne. W przypadku funkcji wariadycznej nie wystarczy jednak, że typy są konwertowalne, bowiem aby mieć pewność, że nie dojdzie do niezdefiniowanego zachowania (ang. undefined behaviour) istotne jest aby typy te były takie same (typ argumentu i typ odpowiadający specyfikatorowi).
Wartością dodaną wynikającą z zastosowania tego specyfikatora jest prefiks ”0x”. który jednoznacznie informuje nas, że mamy do czynienie z wartością liczbową zapisaną w formacie szesnastkowym. Specyfikator ”%p” będzie w wielu przypadkach wystarczający. Co jednak jeśli zechcemy aby adres pamięci był zapisany z wykorzystaniem dużych liter (w przypadku formatu szesnastkowego są to oczywiście litery od A do F) ? W tym przypadku musimy potraktować wskaźnik jako typ liczbowy (wykonując odpowiednie rzutowanie) i zastosować specyfikator formatu ’’%x” (dla zapisu małymi literami) lub ”%X” (dla zapisu z dużymi literami). Ponieważ będziemy mieć do czynienia z adresem 64-bitowym, należy również dodać specyfikator odpowiedzialny za rozmiar czyli ”%l” (w przypadku jego braku wartość wskaźnika zostanie ”obcięta” do młodszych 32-bitów). Jeżeli chodzi o prefiks, możemy skorzystać z flagi ”#”, która spowoduje dodanie prefiksu ”0x” lub ”0X” w zależności od tego, czy używamy specyfikatora formatu ”%x” czy ”%X”. Jeżeli chodzi o wartość wskaźnika wykonamy rzutowanie na typ uintptr_t (plik nagłówkowy cstdint), który jest typem liczbowym bez znaku (najprawdopodobniej będzie to unsigned long), o rozmiarze równym typowi wskaźnikowemu. Zobaczmy teraz jak to rozwiązanie sprawdza się w praktyce:
#include <cstdio> #include <cstdint> int main() { int x = 42; printf("%#lx\n", reinterpret_cast<uintptr_t>(&x)); printf("%#lX\n", reinterpret_cast<uintptr_t>(&x)); }
0x7ffdd86a201c 0X7FFDD86A201C
Po załączeniu pliku nagłówkowego cinttypes, możesz także skorzystać z analogicznych makr takich jak PRIxPTR czy PRIXPTR, pod którymi kryje się łańcuch tekstowy ze specfyfikatorami ”%l” i odpowiednio ”%x” lub ”%X”.
#include <cstdio> #include <cinttypes> int main() { int x = 42; printf("%#" PRIxPTR "\n", reinterpret_cast<uintptr_t>(&x)); printf("%#" PRIXPTR "\n", reinterpret_cast<uintptr_t>(&x)); }
0x7ffcc5cb4e5c 0X7FFCC5CB4E5C
Jak widać flaga ”#” ”podąża” za specyfikatorem ”%x” / ”%X”, co oznacza, iż prefiks ”0x” wystąpi dla liczby w formacie szesnastkowym z małymi literami (specyfikator ”%x”), natomiast prefiks ”0X” odpowiednio dla liczby w formacie szesnastkowym z dużymi literami (specyfikator ”%X”). Jeżeli chcesz zastosować kombinację, w której prefiks ”0x” wystąpi w połączeniu z liczbą w formacie szesnastkowym z dużymi literami (sam osobiście preferuję taki zapis) lub odwrotnie (prefiks ”0X” w połączeniu z liczbą w formacie szesnastkowym z małymi literami), wówczas możesz jawnie napisać ten prefiks samodzielnie, rezygnując z flagi ”#”:
#include <cstdio> #include <cstdint> int main() { int x = 42; printf("0X%lx\n", reinterpret_cast<uintptr_t>(&x)); printf("0x%lX\n", reinterpret_cast<uintptr_t>(&x)); }
0X7ffd9cad423c 0x7FFD9CAD423C
Analogicznie z makrami PRIxPTR i PRIXPTR:
#include <cstdio> #include <cinttypes> int main() { int x = 42; printf("0X%" PRIxPTR "\n", reinterpret_cast<uintptr_t>(&x)); printf("0x%" PRIXPTR "\n", reinterpret_cast<uintptr_t>(&x)); }
0X7ffdeb1f5cdc 0x7FFDEB1F5CDC
Metoda operator <<
Operator << (standardowy, czyli wbudowany w język C++, operator << jest operatorem przesunięcia bitowego w lewo) w połączeniu z obiektem std::cout (ang. character output) jest powszechnie używany do wyświetlania danych na standardowe wyjście. Biblioteka standardowa języka C++ implementuje metodę operator<< w ramach szablonu klasy std::basic_ostream (plik nagłówkowy ostream). Jego specjalizacja dla typu char (std::basic_ostream<char>) nosi nazwę std::ostream, będąc jednocześnie typem obiektu std::cout. W przypadku typu wskaźnikowego mamy do czynienia z następującym przeciążeniem metody operator<<:
template<class CharT, class Traits = std::char_traits<CharT>> class basic_ostream : virtual public std::basic_ios<CharT, Traits> { // ... public: basic_ostream& operator<<(const void *value); // ... };
Typem parametru tego operatora jest const void *, co umożliwia obsługę (wyświetlanie adresu) dowolnego typu wskaźnikowego (z pewnym wyjątkiem, o którym mowa będzie w kolejnym punkcie artykułu), bez możliwości przypadkowej modyfikacji obiektu do którego odnosi się wskaźnik (zwróć uwagę na słowo kluczowe const). Zastosowanie typu void const * jest bardzo wygodnym rozwiązaniem, bowiem umożliwia dopasowanie tej wersji operatora << dla różnych typów wskaźnikowych. Inaczej mówiąc, typ obiektu do którego odnosi się wskaźnik nie ma tutaj znaczenia, dzięki czemu nie ma potrzeby definiowania szablonowej wersji operatora << (dla typu T*). Nie bez znaczenia jest także to, że nie musimy jawnie rzutować dowolnego typu wskaźnikowego na typ void const *, bowiem konwersja taka odbywa się niejawnie, co przekłada się wprost na wygodę użytkowania tego operatora. Dzięki mechanizmowi przeciążeń nie ma także możliwości popełnienia błędów takich jak podczas używania printf, w której niezgodność typu argumentu ze specyfikatorem formatu oznacza niezdefiniowane zachowanie. Stosowanie operatora << dla typu wskaźnikowego nie różni się niczym od używania go dla innych typów:
#include <iostream> int main() { int x = 42; std::cout << &x << '\n'; }
0x7ffe6a9da81c
Jak widać otrzymujemy tutaj stosowny prefiks ”0x”, natomiast wyświetlony adres zawiera małe litery. Aby wyświetlić duże litery, możemy posłużyć się manipulatorem std::uppercase (plik nagłówkowy ios), jednak w przypadku typu wskaźnikowego to rozwiązanie nie zadziała:
#include <iostream> int main() { int x = 42; std::cout << std::uppercase << &x << '\n'; }
0x7fffcf0becdc
Aby osiągnąć spodziewany efekt, należy zrezygnować z metody operator<< dla typu void const * na rzecz jej wersji przeciążonej dla typu całkowitego bez znaku:
template<class CharT, class Traits = std::char_traits<CharT>> class basic_ostream : virtual public std::basic_ios<CharT, Traits> { // ... public: basic_ostream& operator<<(unsigned long value); // ... };
Wybór tej wersji operatorora <<, będzie możliwy po wykonaniu rzutowania typu wskaźnikowego na typ całkowity bez znaku (w tym celu ponownie posłużymy się typem uintptr_t). Tak otrzymaną wartość należy wyświetlić w formacie szesnastkowym (domyślnie jest to format dziesiętny), co osiągniemy posługując się manipulatorem std::hex (plik nagłówkowy ios):
#include <iostream> #include <cinttypes> int main() { int x = 42; std::cout << "0x" << std::uppercase << std::hex << reinterpret_cast<uintptr_t>(&x) << '\n'; }
0x7FFF3DF16EAC
Istnieje jeszcze manipulator std::showbase (plik nagłówkowy ios) który działa analogicznie jak flaga ”#” dla funkcji printf. Inaczej mówiąc prefiks ”0x” pojawia się dla małych liter (std::nouppercase), a ”0X” dla dużych liter (std::uppercase):
#include <iostream> #include <cstdint> int main() { int x = 42; std::cout << std::showbase; std::cout << std::uppercase << std::hex << reinterpret_cast<uintptr_t>(&x) << '\n'; std::cout << std::nouppercase << std::hex << reinterpret_cast<uintptr_t>(&x) << '\n'; }
0X7FFF5B0D57DC 0x7fff5b0d57dc
Podczas używania tych manipulatorów, należy mieć na uwadze, że ich działanie ma charakter trwały. Aby ”wrócić” do systemu dziesiętnego oraz małych liter, należy zastosować odpowiadające manipulatory: std::dec oraz std::nouppercase (plik nagłówkowy ios). Dysponując wymienionymi manipulatorami otrzymujemy rozwiązanie umożliwiające nam formatowanie adresu na różne sposoby:
// Common hexadecimal format std::cout << std::hex; std::cout << "0x" << std::nouppercase << reinterpret_cast<uintptr_t>(&x) << '\n'; std::cout << "0X" << std::nouppercase << reinterpret_cast<uintptr_t>(&x) << '\n'; std::cout << "0x" << std::uppercase << reinterpret_cast<uintptr_t>(&x) << '\n'; std::cout << "0X" << std::uppercase << reinterpret_cast<uintptr_t>(&x) << '\n'; // Return back to lowercase and decimal format std::cout << std::nouppercase << std::dec; }
0x7ffc01501dec 0X7ffc01501dec 0x7FFC01501DEC 0X7FFC01501DEC
W powyższym przykładzie nie było problemu z przywróceniem stanu obiektu std::cout, ponieważ zmienialiśmy tylko dwa jego parametry (format liczbowy i wielkość liter). W ogólności lepiej jest zapisywać poprzedni stan (flagi) obiektu std::cout i przywracać go po wyświetleniu adresu:
#include <iostream> #include <cstdint> int main() { int x = 42; // Store previous state std::ios_base::fmtflags f(std::cout.flags()); // Common hexadecimal format std::cout << std::hex; std::cout << "0x" << std::nouppercase << reinterpret_cast<uintptr_t>(&x) << '\n'; std::cout << "0X" << std::nouppercase << reinterpret_cast<uintptr_t>(&x) << '\n'; std::cout << "0x" << std::uppercase << reinterpret_cast<uintptr_t>(&x) << '\n'; std::cout << "0X" << std::uppercase << reinterpret_cast<uintptr_t>(&x) << '\n'; // Restore previous state std::cout.flags(f); }
0x7ffd8a33e158 0X7ffd8a33e158 0x7FFD8A33E158 0X7FFD8A33E158
Należy jednak mieć na uwadze, że taki sposób przywracania stanu nie jest bezpieczny z punktu widzenia wyjątków. Potrzebne jest zatem rozwiązanie bazujące na technice RAII (ang. Resource Acquisition Is Initialzation), którego dobry przykład można znaleźć chociażby w biblotece Boost (io_state).
Szablon funkcji operator <<
Przejdźmy teraz do problematycznego (z punktu widzenia wyświetlania adresu) wyjątku jakim jest typ char * (oraz różne jego warianty). Próba wyświetlenia takiego adresu za pośrednictwem operatora << oraz obiektu std::cout spowoduje wyświetlenie łańcucha tekstowego rozpoczynającego się pod tym adresem (a nie samego adresu):
#include <iostream> int main() { char const hello[] = "Hello World !!!"; std::cout << hello << '\n'; }
Hello World !!!
Taki output nie powinien nas dziwić tym bardziej, że powyższy kod źródłowy to program typu „Hello World„. Wyświetlanie łańcucha tekstowego (zamiast adresu) jest więc w tym przypadku spodziewanym rezultatem. Odpowiedzialna za takie zachowanie jest kolekcja przeciążonych operatorów <<, które nie są już metodami klasy, ale globalnie dostępnymi szablonami funkcji. Wśród nich znajdują się między innymi takie, które obsługują ”rodzinę” typu char *:
template<class Traits> basic_ostream<char,Traits>& operator<<(basic_ostream<char,Traits>& os, const char *s); template<class Traits> basic_ostream<char,Traits>& operator<<(basic_ostream<char,Traits>& os, const signed char *s); template< class Traits > basic_ostream<char,Traits>& operator<<(basic_ostream<char,Traits>& os, const unsigned char *s);
Aby więc wyświetlić taki adres, należy rzutować go na typ void const *:
#include <iostream> int main() { char const hello[] = "Hello World !!!"; std::cout << static_cast<void const *>(hello) << '\n'; }
0x7ffde3f51a90
Analogicznie można stosować rozwiązanie oparte na manipulatorach oraz rzutowaniu na typ uintptr_t:
#include <iostream> #include <cstdint> int main() { char const hello[] = "Hello World !!!"; std::cout << "0x" << std::uppercase << std::hex << reinterpret_cast<uintptr_t>(hello) << '\n'; }
0x7FFC6E661EF0
Zmodyfikujmy nieco nasz przykład zastępując tablicę znajdującą się na stosie na wskaźnik do literału, który typowo znajduje się w pamięci do odczytu:
#include <iostream> #include <cstdint> int main() { char const *hello = "Hello World !!!"; std::cout << "0x" << std::uppercase << std::hex << reinterpret_cast<uintptr_t>(hello) << '\n'; }
0x400B55
Jak widać w przypadku ”niskich” adresów pomijane są wiodące zera. Aby je dodać należy skorzystać z manipulatora std::setw (plik nagłówkowy iomanip) informującego o wymaganej szerokości wyświetlanego tekstu (u nas jest to 16) oraz std::setfill (plik nagłówkowy iomanip) wypełniającego ciąg znaków zerami w przypadku gdy tekst będzie krótszy od wymaganego:
#include <iostream> #include <cstdint> #include <iomanip> int main() { char const *hello = "Hello World !!!"; std::cout << "0x" << std::uppercase << std::hex << std::setw(16) << std::setfill('0') << reinterpret_cast<uintptr_t>(hello) << '\n'; }
0x0000000000400CB7
W przypadku funkcji printf aby osiągnąć analogiczny efekt należy uzupełnić łańcuch formatujący o ciąg ”016” lub ”16.16”:
#include <cstdio> #include <cinttypes> int main() { char const *hello = "Hello World"; printf("0x%016" PRIXPTR "\n", reinterpret_cast<uintptr_t>(hello)); }
0x00000000004005F4
Podsumowanie
- Wyświetlanie wartości wskaźnika (adresu pamięci) możliwe jest z wykorzystaniem funkcji wariadycznej printf (jej pochodzenie sięga biblioteki standardowej języka C) – odpowiada za to specyfikator formatu ”%p”, który dodatkowo umieszcza prefiks w postaci ”0x”
- Elastyczne formatowanie wyświetlanego adresu, oparte na funkcji printf, wymaga rzutowania na typ całkowity bez znaku taki jak uintptr_t oraz używania specyfikatorów ”%l” (wartość 64-bitowa) oraz ”%x” (małe litery w formacie szesnastkowym) lub %X (duże litery w formacie szesnastkowym) .
- Obecność specyfikatora formatu ”%x” lub ”%X” umożliwia podanie flagi ”#”, która dodaje odpowiednio prefiks ”0x” lub ”0X”
- Podczas wyświetlania adresu za pomocą funkcji printf, możesz posłużyć makrami takimi jak PRIxPTR oraz PRIXPTR
- Uzupełniając format podany do funkcji printf o zapis ”016” lub ”16.16” mamy pewność, że wyświetlonych zostanie dokładnie 16 znaków adresu, a w przypadku ”niskich” adresów nastąpi wypełnienie zerami
- Wyświetlanie wartości wskaźnika (adresu pamięci) wykonuje się tak jak dla innych typów podczas używania operatora << właściwego dla typu obiektu std::cout (typ std::ostream będący specjalizacją szablonu std::basic_ostream dla parametru typu char)
- Za wyświetlanie wskaźnika odpowiada przeciążona metoda opertator<< (dla typu const void *) szablonu klasy std::basic_ostream
- Bardziej elastyczna metoda oparta na operatorze << wymaga rzutowania na typ całkowity bez znaku taki jak uintptr_t (podobnie jak w przypadku funkcji printf) oraz stosowania manipulatorów takich jak: std::uppercase, std::nouppercase, std::hex
- Manipulator std::showbase działa analogicznie jak flaga ”#” dla funkcji printf
- Manipulatory std::setw i std::setfill działa analogicznie jak ”016” (”16.16”) dla funkcji printf
- Jeżeli do wyświetlania adresu używasz manipulatorów miej na uwadze zmieniający się stan obiektu std::cout
- Dla ”rodziny” typu char * wybierana jest globalna (nie będąca metodą szablonu klasy std::basic_ostream), szablonowa wersja operatora <<, której zadaniem jest wyświetlenia łańcucha tekstowego znajdującego się pod adresem zawartym we wskaźniku typu char * (lub jego wariantu takiego jak: char const *, signed char const *, unsigned char const *) – aby więc wyświetlić adres będący wartością takiego wskaźnika, należy wykonać rzutowanie do typu void const *