Як змінні зберігаються та витягуються із програмного стеку?


47

Вибачте заздалегідь за наївність цього питання. Я 50-річний художник, який намагається вперше правильно зрозуміти комп’ютери. Так ось іде.

Я намагався зрозуміти, як компілятор обробляє типи даних і змінні (в дуже загальному сенсі, я знаю, що в цьому є багато). Мені щось не вистачає в моєму розумінні взаємозв'язку між сховищем у "стеці" та типовим значенням та сховищем у "купі" та референтних типах (лапки мають на увазі, що я розумію, що ці терміни є абстракціями, а не сприймати їх занадто буквально в такому спрощеному контексті, як я формулюю це питання). У будь-якому разі, моя спрощена ідея полягає в тому, що такі типи, як булеві та цілі числа, переходять на "стек", тому що вони можуть, оскільки вони відомі сутності з точки зору місця для зберігання, і їх обсяг легко контролюється відповідно.

Але те, чого я не отримую, - це те, як змінні на стеку потім читаються програмою. Якщо я декларую і призначу xціле число, скажімо x = 3, і зберігання зарезервовано у стеці, а потім його значення 3зберігається там, а потім у ту ж функцію, яку я декларую і призначу yяк, скажімо 4, а потім слідую, що потім я використовую xв іншому виразі, (скажімо z = 5 + x), як програма може читати x, щоб оцінити, zколи вона знаходиться нижчеyна стеку? Мені явно чогось не вистачає. Це те, що розташування в стеку - це лише про час життя / область змінної, і що весь стек фактично доступний програмі весь час? Якщо так, то чи означає це, що існує якийсь інший індекс, який містить адреси лише змінних на стеку, щоб дозволити отримання значень? Але тоді я подумав, що вся суть стека полягає в тому, що значення зберігаються там же, що і адреса змінної? На мій чуйний погляд здається, що якщо є інший індекс, то ми говоримо про щось більше, як купу? Я очевидно дуже розгублений, і я просто сподіваюся, що на моє спрощене запитання є проста відповідь.

Дякуємо за прочитане.


7
@ fade2black Я не згоден - слід дати можливість відповіді розумної тривалості, яка підсумовує важливі моменти.
Девід Річербі

9
Ви робите надзвичайно поширену помилку, пов’язуючи тип значення з тим, де воно зберігається . Просто помилково говорити, що булі виходять на стек. Булі переходять у змінні , а змінні йдуть на стек, якщо їхнє життя, як відомо, коротке , і на купі, якщо їх життя не відомо, що воно коротке. Деякі думки про те, як це стосується C #, див. Blogs.msdn.microsoft.com/ericlippert/2010/09/30/…
Ерік Ліпперт

7
Крім того, не думайте про стек як про стек значень змінних . Подумайте про це як стек кадрів активації методів . У межах методу ви можете отримати доступ до будь-якої змінної активації цього методу, але ви не можете отримати доступ до змінних абонента, оскільки вони не знаходяться у кадрі, що знаходиться на вершині стека .
Ерік Ліпперт

5
Також: Я аплодую вам за те, що ви взяли на себе ініціативу, щоб дізнатись щось нове та вивчити деталі реалізації мови. Тут ви стикаєтеся з цікавим каменем спотикання: ви розумієте, що таке стек як абстрактний тип даних , але не як деталь реалізації для повторної активації та продовження . Останній не дотримується правил типу стека абстрактних даних; це трактує їх більше як настанови, ніж як правила. Вся суть мов програмування полягає в тому, щоб не розбиратися в цих абстрагованих деталях, щоб вирішити проблеми програмування.
Ерік Ліпперт

4
Дякую Еріку, Саві, мініатюру, ці коментарі та посилання є надзвичайно корисними. Я завжди відчуваю, що люди, як ви, повинні стогнати всередину, коли бачать таке питання, як моє, але, будь ласка, знайте про величезне хвилювання та задоволення від отримання відповідей!
Селін Етвуд

Відповіді:


24

Зберігання локальних змінних на стеку - це деталь реалізації - в основному оптимізація. Ви можете подумати про це так. При введенні функції десь виділяється простір для всіх локальних змінних. Потім ви можете отримати доступ до всіх змінних, оскільки ви якось знаєте їх розташування (це частина процесу розподілу). Виходячи з функції, простір розміщується (звільняється).

Стек - це один із способів реалізації цього процесу - ви можете розглядати його як якусь "швидку купу", яка має обмежений розмір і тому підходить лише для невеликих змінних. В якості додаткової оптимізації всі локальні змінні зберігаються в одному блоці. Оскільки кожна локальна змінна має відомий розмір, ви знаєте зміщення кожної змінної в блоці, і саме так ви отримуєте доступ до неї. Це на відміну від змінних, виділених у купі, адреси яких самі зберігаються в інших змінних.

Ви можете вважати стек дуже схожим на класичну структуру даних стека, з однією суттєвою різницею: вам дозволяється отримувати доступ до елементів, розташованих під вершиною стека. Дійсно, ви можете отримати доступ до го елемента зверху. Ось так ви можете отримати доступ до всіх своїх локальних змінних за допомогою натискання та вискакування. Єдине натискання відбувається при вступі в функцію, і єдине вискакування після виходу з функції.k

Насамкінець, зазначу, що на практиці деякі локальні змінні зберігаються в регістрах. Це оскільки доступ до регістрів швидший, ніж доступ до стеку. Це ще один спосіб реалізації простору для локальних змінних. Ще раз ми точно знаємо, де зберігається змінна (на цей раз не через зміщення, а через ім’я регістра), і такий тип зберігання підходить лише для невеликих даних.


1
"Виділений в одному блоці" - це ще одна деталь реалізації. Це не має значення. Компілятор знає, як потрібна пам'ять для локальних змінних, він виділяє цю пам'ять їй один або кілька блоків, а потім створює локальні змінні в цій пам'яті.
MSalters

Спасибі, виправлено. Дійсно, деякі з цих «блоків» - це лише регістри.
Yuval Filmus

1
Вам дуже потрібен стек, щоб зберігати зворотні адреси, якщо це так. Ви могли реалізувати рекурсію без стека досить легко, передавши покажчик на зворотну адресу на купі.
Yuval Filmus

1
@MikeCaron Стек майже не має нічого спільного з рекурсією. Чому б ви "видували змінні" в інших стратегіях реалізації?
садок

1
@gardenhead найбільш очевидною альтернативою (і тією, яка насправді є / була використана) є статичне розподілення змінних кожної процедури. Швидкий, простий, передбачуваний ... але не допускаються рекурсії чи повторний досвід. Це і звичайний стек - не єдині альтернативи звичайно (динамічно розподіляючи все - це інше), але вони зазвичай обговорюють, коли виправдовують стеки :)
hobbs

23

Наявність yу стеку фізично не перешкоджає xдоступу до них, що, як ви вказали, робить комп'ютерні стеки відмінними від інших стеків.

Коли компілюється програма, позиції змінних у стеці також заздалегідь визначені (у контексті функції). У вашому прикладі, якщо стек містить xз y«зверху» це, то програма знає заздалегідь , що xбуде 1 пункт нижче верхньої частини стека в той час як всередині функції. Оскільки апаратне забезпечення комп'ютера може явно вимагати 1 елемент нижче верхньої частини стека, комп'ютер може отримати, xнавіть якщо він yіснує.

Це те, що розташування в стеку - це лише про час життя / область змінної, і що весь стек фактично доступний програмі весь час?

Так. При виході з функції, покажчик стека переміщається назад в попереднє положення, ефективно видаляючи xі y, але технічно вони все ще будуть там , поки пам'ять не використовується для чого - то ще. Крім того, якщо ваша функція викликає іншу функцію, xі yвона все ще буде там, і до неї можна отримати доступ, навмисно переходячи занадто далеко в стек.


1
Це здається найбільш чистим на сьогодні відповідь з точки зору того, щоб не говорити поза межами знань, які ОП подає до столу. +1 за дійсне націлювання на ОП!
Бен І.

1
Я також згоден! Хоча всі відповіді надзвичайно корисні, і я дуже вдячний, моє оригінальне повідомлення було мотивоване, оскільки я відчуваю (d), що вся ця штука / купа абсолютно необхідна для розуміння того, як виникає розрізнення значення / еталонного типу, але я не міг ' я не бачу, як, якби ти міг коли-небудь переглядати верхню частину стека. Тож ваша відповідь звільняє мене від цього. (У мене таке саме відчуття, як у мене, коли я вперше зрозумів, що всі різні закони зворотного квадрата у фізиці просто випадають з геометрії випромінювання, що виходить із сфери, і ви можете скласти просту схему, щоб побачити це.)
Селін Етвуд

Мені це подобається, тому що це завжди дуже корисно, коли ти можеш побачити, як і чому якесь явище на більш високому рівні (наприклад, у мові) насправді пов'язане з якимсь більш базовим явищем трохи далі вниз по дереву абстракції. Навіть якщо це зберігається досить просто.
Селін Етвуд

1
@CelineAtwood Зауважте, що спроба отримати доступ до змінних "примусово" після їх видалення зі стека призведе до непередбачуваної / невизначеної поведінки, і цього робити не слід. Зверніть увагу: я не сказав "не можу", оскільки деякі мови дозволять вам спробувати. Все-таки це буде помилка програмування, і цього слід уникати.
code_dredd

12

Щоб надати конкретний приклад того, як компілятор управляє стеком і як отримують доступ до значень на стеку, ми можемо переглянути візуальні зображення, плюс код, сформований GCCу середовищі Linux із i386 як цільовою архітектурою.

1. Складіть рамки

Як відомо, стек - це розташування в адресному просторі запущеного процесу, який використовується функціями або процедурами , в тому сенсі, що простір виділяється в стеку для змінних, оголошених локально, а також аргументів, переданих функції ( простір для змінних, оголошених поза будь-якою функцією (тобто глобальних змінних), виділяється в іншій області у віртуальній пам'яті). Простір, виділений для всіх даних функції, відсилається до кадру стека . Ось візуальне зображення декількох фреймів стека (від Комп'ютерні системи: Перспектива програміста ):

Рамка стека CSAPP

2. Управління рамкою стека та змінним місцем розташування

Для того, щоб значеннями, записаними в стек в межах певного кадру стека, керував компілятор і зчитувався програмою, повинен існувати певний метод обчислення позицій цих значень і отримання їх адреси пам'яті. У цьому допомагають регістри в процесорі, які називаються покажчиком стека та базовим вказівником.

Базовий вказівник, ebpяк правило, містить адресу пам'яті нижньої частини або основи стека. Позиції всіх значень у кадрі стека можуть бути обчислені, використовуючи адресу в базовому покажчику як еталонну. Це зображено на малюнку вище: наприклад %ebp + 4, пам'ять, що зберігається в базовому покажчику плюс 4, наприклад.

3. Згенерований компілятором код

Але те, що я не отримую, - це те, як змінні в стеку потім читаються програмою. Якщо я оголошу і призначу x як ціле число, скажімо, x = 3, а зберігання зарезервоване у стеці, а потім його значення 3 зберігається там, а потім у тій же функції я оголошую і призначаю y як, скажімо, 4, а потім слідуючи, що потім я використовую x в іншому виразі (скажімо, z = 5 + x), як програма може прочитати х, щоб оцінити z, коли це нижче y на стеці?

Скористаємося простою прикладною програмою, написаною на С, щоб побачити, як це працює:

int main(void)
{
        int x = 3;
        int y = 4;
        int z = 5 + x;

        return 0;
}

Розглянемо текст складання, створений GCC для цього вихідного тексту C (я трохи очистив його для наочності):

main:
    pushl   %ebp              # save previous frame's base address on stack
    movl    %esp, %ebp        # use current address of stack pointer as new frame base address
    subl    $16, %esp         # allocate 16 bytes of space on stack for function data
    movl    $3, -12(%ebp)     # variable x at address %ebp - 12
    movl    $4, -8(%ebp)      # variable y at address %ebp - 8
    movl    -12(%ebp), %eax   # write x to register %eax
    addl    $5, %eax          # x + 5 = 9
    movl    %eax, -4(%ebp)    # write 9 to address %ebp - 4 - this is z
    movl    $0, %eax
    leave

Те , що ми спостерігаємо , що змінні х, у і г розташовані за адресами %ebp - 12, %ebp -8і %ebp - 4, відповідно. Іншими словами, розташування змінних у кадрі стека для main()обчислюється за допомогою адреси пам'яті, збереженої в регістрі процесора %ebp.

4. Дані в пам'яті поза вказівкою стека виходять за межі поля

Мені явно чогось не вистачає. Це те, що розташування в стеку - це лише про час життя / область змінної, і що весь стек фактично доступний програмі весь час? Якщо так, то чи означає це, що існує якийсь інший індекс, який містить адреси лише змінних на стеку, щоб дозволити отримання значень? Але тоді я подумав, що вся суть стека полягає в тому, що значення зберігаються там же, що і адреса змінної?

Стек - це область у віртуальній пам'яті, використанням якої керує компілятор. Компілятор генерує код таким чином, що значення за вказівником стека (значення поза вершиною стека) ніколи не посилаються. Коли викликається функція, положення вказівника стека змінюється, щоб створити простір на стеку, який вважається не "поза межами", так би мовити.

Коли функції викликаються та повертаються, покажчик стека зменшується та збільшується. Дані, записані в стек, не зникають після того, як вони виходять за межі, але компілятор не генерує інструкцій, що посилаються на ці дані, оскільки компілятор не може обчислити адреси цих даних за допомогою %ebpабо %esp.

5. Підсумок

Код, який може бути безпосередньо виконаний процесором, генерується компілятором. Компілятор керує стеком, стековими кадрами для функцій та регістрів процесора. Одна стратегія, яка використовується GCC для відстеження розташування змінних у фреймах стека в коді, призначеному для виконання на архітектурі i386, полягає у використанні адреси пам'яті в базовому покажчику кадру стека %ebp, як орієнтиру та запису значень змінних у місця розташування у кадрах стека при компенсуванні адреси в %ebp.


Моє, якщо я запитаю, звідки взялося це зображення? Це виглядає підозріло знайомим ... :-) Це, можливо, було в минулому підручнику.
Велика качка

1
nvmd. Я щойно побачив посилання. Це було те, що я думав. +1 для спільного використання цієї книги.
Велика качка

1
+1 для демо-версії gcc :)
flow2k

9

Є два спеціальні регістри: ESP (стек-покажчик) та EBP (базовий покажчик). Коли викликається процедура, зазвичай проводяться перші дві операції

push        ebp  
mov         ebp,esp 

Перша операція зберігає значення EBP на стеці, а друга операція завантажує значення покажчика стека в базовий покажчик (для доступу до локальних змінних). Отже, EBP вказує на те саме місце, що і ESP.

Assembler переводить імена змінних у компенсації EBP. Наприклад, якщо у вас є дві локальні змінні x,y, і у вас є щось подібне

  x = 1;
  y = 2;
  return x + y;

то це може бути переведено на щось подібне

   push        ebp  
   mov         ebp,esp
   mov  DWORD PTR [ ebp + 6],  1   ;x = 1
   mov  DWORD PTR [ ebp + 14], 2   ;y = 2
   mov  eax, [ ebp + 6 ]
   add  [ ebp + 14 ], eax          ; x + y 
   mov  eax, [ ebp + 14 ] 
   ...  

Значення зміщення 6 і 14 обчислюються під час компіляції.

Це приблизно так, як це працює. Детальні відомості див. У книзі компілятора.


14
Це специфічно для Intel x86. На ARM використовується регістр SP (R13), а також FP (R11). А на x86 відсутність регістрів означає, що агресивні компілятори не використовуватимуть EBP, оскільки це може бути отримано з ESP. Це зрозуміло в останньому прикладі, коли всю адресу, що стосується EBP, можна перевести на ESP-відносну, не потребуючи інших змін.
MSalters

Ви не пропускаєте SUB на ESP, щоб звільнити місця для x, y в першу чергу?
Хаген фон Ейцен

@HagenvonEitzen, певно. Я просто хотів висловити уявлення про доступ до змінних, виділених у стеці, за допомогою апаратних регістрів.
fade2black

Низик, коментарі, будь ласка !!!
fade2black

8

Ви плутаєтеся через те, що локальні змінні, що зберігаються в стеку, не мають доступу до правила доступу стека: First In Last Out, або просто FILO .

Вся справа в тому, що правило FILO застосовується до послідовностей викликів функцій та фреймів стека , а не до локальних змінних.

Що таке рама стека?

Коли ви вводите функцію, їй надається деякий об'єм пам'яті на стеці, який називається фреймом стека. Локальні змінні функції зберігаються у кадрі стека. Ви можете уявити, що розмір кадру стека змінюється від функції до функції, оскільки кожна функція має різні числа та розміри локальних змінних.

Те, як локальні змінні зберігаються у кадрі стека, не має нічого спільного з FILO. (Навіть порядок появи ваших локальних змінних у вихідному коді не гарантує, що локальні змінні будуть зберігатися в тому порядку.) Як ви правильно вивели у своєму запитанні, "є інший індекс, який містить адреси лише змінних. на стеку, щоб дозволити отримання значень ". Адреси локальних змінних зазвичай обчислюються за базовою адресою , такою як гранична адреса кадру стека та значення зміщення, характерні для кожної локальної змінної.

Отже, коли ця поведінка FILO з'являється?

Тепер, що станеться, якщо ви викликаєте іншу функцію? Функція виклику повинна мати власну рамку стека, і саме ця рамка стека висувається в стек . Тобто кадр стека функції виклику розміщується вгорі кадру стека функції виклику. І якщо ця функція виклику викликає іншу функцію, її кадр стека знову буде висунутий у верхній частині стека.

Що відбувається, якщо функція повертається? Коли функція викликається повертається до функції абонента, кадр стеки функції викликається сторони є вискочив з стека, звільняючи простір для майбутнього використання.

Отже, з вашого запитання:

Це те, що розташування в стеку - це лише про час життя / область змінної, і що весь стек фактично доступний програмі весь час?

Ви тут абсолютно правильні, оскільки значення місцевих змінних на кадрі стека насправді не стираються, коли функція повертається. Значення просто залишається там, хоча місце пам'яті, де воно зберігається, не належить до кадру стека жодної функції. Значення стирається, коли якась інша функція отримує свій фрейм стека, який включає в себе розташування і записує якесь інше значення в це місце пам'яті.

Тоді що відрізняє стек від купи?

Стек і купа однакові в тому сенсі, що вони обидва імена, які посилаються на деякий простір у пам'яті. Оскільки ми можемо отримати доступ до будь-якого місця в пам'яті з його адресою, ви можете отримати доступ до будь-якого місця в стеці чи купі.

Різниця випливає з обіцянки комп'ютерної системи щодо того, як вона буде ними користуватися. Як ви вже говорили, купа - це еталонний тип. Оскільки значення в купі не мають жодного відношення до якогось конкретного кадру стека, область значення не прив’язана до жодної функції. Локальна змінна, однак, розміщена у межах функції, і хоча ви можете отримати доступ до будь-якого значення локальної змінної, яке знаходиться поза рамкою поточної функції поточної функції, система намагатиметься переконатися, що подібного типу поведінки не відбувається, використовуючи штабельні кадри. Це дає нам певну ілюзію, що локальна змінна привласнюється до певної функції.


4

Існує багато способів реалізації локальних змінних системою виконання мови. Використання стека - поширене ефективне рішення, яке використовується у багатьох практичних випадках.

Інтуїтивно, вказівник стека spзберігається навколо під час виконання (за фіксованою адресою чи в реєстрі - це дійсно має значення). Припустимо, що кожен "натискання" збільшує покажчик стека.

Під час компіляції компілятор визначає адресу кожної змінної, оскільки sp - Kде Kє константа, яка залежить лише від області застосування змінної (отже, вона може бути обчислена під час компіляції).

Зауважте, що ми тут використовуємо слово "стек" у вільному значенні. Доступ до цього стеку не здійснюється лише через операції push / pop / top, але також доступний за допомогою sp - K.

Наприклад, розгляньте цей псевдокод:

procedure f(int x, int y) {
  print(x,y);    // (1)
  if (...) {
    int z=x+y; // (2)
    print(x,y,z);  // (3)
  }
  print(x,y); // (4)
  return;
}

Коли викликається процедура, аргументи x,yможна передавати на стек. Для простоти, припустимо, умовою є те, що абонент натискає xспочатку, потім y.

Потім компілятор у точці (1) можна знайти xв sp - 2і yв sp - 1.

У точці (2) нова область змінної приведена в область застосування. Компілятор генерує код, який підсумовує x+y, тобто, на що вказує sp - 2і sp - 1, і висуває результат суми на стек.

У пункті (3) zдрукується. Компілятор знає, що це остання змінна за обсягом, тому її вказує sp - 1. Цього більше немає y, оскільки spзмінилося. Тим не менш, для друку yкомпілятор знає, що він може знайти його в цій області на sp - 2. Аналогічно, xзараз це знайдено в sp - 3.

У точці (4) виходимо з області. zз'являється, і yзнову знаходиться за адресою sp - 1, і xзнаходиться за адресою sp - 2.

Коли ми повернемось, fабо абонент вискакує x,yзі стека.

Отже, обчислення Kдля компілятора - це питання підрахунку кількості змінних за обсягом, приблизно. У реальному світі це насправді складніше, оскільки не всі змінні мають однаковий розмір, тому обчислення Kтрохи складніші. Іноді стек також містить зворотну адресу для f, тому він також Kповинен "пропустити". Але це технічні особливості.

Зауважте, що в деяких мовах програмування речі можуть стати ще складнішими, якщо потрібно обробляти більш складні функції. Наприклад, вкладені процедури потребують дуже ретельного аналізу, оскільки Kтепер доводиться "пропускати" багато зворотних адрес, особливо якщо вкладена процедура є рекурсивною. Функції закриття / лямбда / анонімні також потребують певної обережності для обробки "захоплених" змінних. Все ж, наведений вище приклад повинен ілюструвати основну ідею.


3

Найпростіша ідея - мислити змінні як виправляти імена для адрес у пам'яті. Дійсно, деякі асемблери таким чином відображають машинний код ("зберігати значення 5 в адресі i", де iім'я змінної).

Деякі з цих адрес є "абсолютними", як глобальні змінні, деякі "відносні", як локальні змінні. Змінні (тобто адреси) у функціях відносно деякого місця на "стеку", яке є різним для кожного виклику функції; таким чином одне ім’я може посилатися на різні фактичні об'єкти, а кругові дзвінки до тієї ж функції - це незалежні виклики, що працюють на незалежній пам'яті.


2

Елементи даних, які можуть перейти на стек, ставляться на стек - Так! Це преміум місце. Також, як тільки ми натиснули xв стек, а потім натиснули yв стек, в ідеалі ми не зможемо отримати доступ, xпоки не yбуде. Нам потрібно з'явитись, yщоб отримати доступ x. Ви правильно їх зрозуміли.

Стек не є змінними, а frames

Де ви помилилися - про саму стек. На стеку це не ті елементи даних, які безпосередньо висуваються. Швидше, на стеку stack-frameштовхається щось, що називається . Цей стек-кадр містить елементи даних. Хоча ви не можете отримати доступ до кадрів у глибині стека, ви можете отримати доступ до верхнього кадру та всіх елементів даних, що містяться в ньому.

Скажімо, ми маємо наші елементи даних у двох кадрах frame-xта frame-y. Ми штовхали їх один за одним. Тепер, поки frame-yзнаходиться на вершині frame-x, ви не можете в ідеалі отримати доступ до будь-якого елемента даних всередині frame-x. Тільки frame-yвидно. Але з огляду на те, що frame-yвидно, ви можете отримати доступ до всіх елементів даних, що в ньому є. Весь кадр видно, оголюючи всі елементи даних, що містяться в ньому.

Кінець відповіді. Більше (rant) на цих кадрах

Під час компіляції складається список усіх функцій програми. Тоді для кожної функції список нарощуваних елементів даних проводиться. Потім для кожної функції робиться a stack-frame-template. Цей шаблон є структурою даних, яка містить усі вибрані змінні, простір для вхідних даних функції, вихідних даних тощо. Тепер під час виконання, коли виклик функції, копія цього матеріалу templateставиться на стек - разом із усіма вхідними та проміжними змінними . Коли ця функція викликає якусь іншу функцію, тоді свіжа копія цієї функції stack-frameставиться в стек. Тепер, поки ця функція працює, елементи даних цієї функції зберігаються. Як тільки ця функція закінчується, її стек-кадр вискакує. Теперцей стек-кадр активний, і ця функція може отримати доступ до всіх його змінних.

Зауважте, що структура та склад стека-кадру змінюється від мови програмування до мови програмування. Навіть у межах мови можуть бути незначні відмінності в різних реалізаціях.


Дякуємо, що розглянули CS. Я зараз програміст кілька днів беру уроки фортепіано :)

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.