Original:http://nullprogram.com/blog/2015/05/15/

Сирі виклики Linux за допомогою системних викликів

Ця стаття була перекладена на японську мову.

Ця стаття містить підписку.

Linux має елегантний і гарний дизайн, коли мова заходить про потоки: нитки - це не що інше, як процеси, які поділяють віртуальний адресний простір і таблицю дескрипторів файлів. Нитки, створені процесом, є додатковими дочірними процесами головного процесу "потоку". Вони маніпулюються за допомогою одного і того ж системного виклику системи керування процесом, позбавляючи потреби в окремому наборі системних викликів, пов'язаних з потоками. Це елегантно так само, що дескриптори файлів елегантні.

Зазвичай на Unix-подібних системах процеси створюються за допомогою fork(). Новий процес отримує власний адресний простір і таблицю дескрипторів файлів, яка починається як копія оригіналу. (Linux використовує copy-on-write, щоб ефективно виконувати цю частину.) Однак це занадто високий рівень для створення потоків, тому Linux має окремий системний виклик clone() . Він працює так само, як fork(), за винятком того, що він приймає ряд прапорів, щоб налаштувати свою поведінку, перш за все, щоб розділити частину контексту виконання батьків з дитиною.

Це так просто, що потрібно менше 15 інструкцій для створення нитки з власним стеком , немає необхідних бібліотек, і немає необхідності викликати Pthreads! У цій статті я продемонструю, як це зробити на x86-64. Весь код повинен бути написаний в синтаксисі NASM, оскільки, IMHO, це насправді найкраще (див. Nasm-mode ).

Я розміщую повну демонстрацію тут, якщо хочете побачити це все відразу:

X86-64 праймер

Я хочу, щоб ви мали змогу йти далі, навіть якщо ви не знайомі з збиранням x86_64, так що тут короткий грунтовник відповідних творів. Якщо ви вже знаєте збірку x86-64, не соромтеся переходити до наступного розділу.

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

Префікс "r" вказує на те, що вони є 64-бітними регістрами. Це не буде релевантним у цій статті, однак ім'я з переліченим словом "e" означає нижні 32-бітні з цих самих регістрів, а префікс не позначає найнижчі 16 бітів. Це тому, що x86 спочатку була 16-бітова архітектура , розширена до 32-біт, а потім до 64-біт. Історично кожен з цих регістрів мав специфічну унікальну мету, але на x86-64 вони майже повністю взаємозамінні.

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

Стек

Реєстр rsp вказує на "верхівку" стека викликів. Стек стежить за тим, хто називає поточну функцію, крім локальних змінних та інших функціональних станів ( фрейм стека ). Я ставляю "верхівку" в лапки, оскільки стек фактично росте вниз на x86 до нижчих адрес, тому покажчик стека вказує на найнижчу адресу в стекі. Ця частина інформації критична, коли мова йде про потоки, оскільки ми будемо виділяти наші власні стеки.

Стік також іноді використовується для передачі аргументів іншої функції. Це відбувається значно рідше в x86-64, особливо в System V ABI, що використовується Linux, де перші 6 аргументів передаються через регістри. Повертає значення повертається через rax. При виклику іншої функціональної функції ці аргументи / покажчики передаються в ці регістри у такому порядку:

Так, наприклад, для виконання виклику функції, як foo(1, 2, 3) , зберігати 1, 2 і 3 в rdi, rsi та rdx, а потім call цю функцію. Інструкція mov зберігає вихідний (другий) операнд у своєму (першому) операнді призначення. Інструкція call натискає поточне значення копіювання на стек, а потім встановлює rip ( стрибки ) на адресу цільової функції. Коли виклик готовий повернутися, він використовує команду ret щоб поплатити оригінальну копіювальну вартість зі стеків і назад, щоб повернути керування абоненту.

 mov rdi , 1 mov rsi , 2 mov rdx , 3 call foo 

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

Системні дзвінки

При здійсненні системного виклику реєстр аргументів трохи відрізняється . Зверніть увагу, що rcx було змінено на r10.

Кожен системний виклик має ціле число, що ідентифікує його. Цей номер відрізняється на кожній платформі, але, у випадку з Linux, він ніколи не зміниться . Замість call , rax встановлюється на номер потрібного системного виклику, а команда syscall виконує запит на ядро ​​ОС. До x86-64 це було зроблено зі старомодним перериванням. Оскільки переривання відбуваються повільно, надається спеціальна, статично розташована сторінка "vsyscall" (тепер застаріла як небезпека для безпеки ), пізніше vDSO , що дозволяє виконувати певні системні виклики як виклики функції. Нам потрібна syscall інструкція syscall в цій статті.

Так, наприклад, системний виклик write () має цей прототип C.

 ssize_t write ( int fd , const void * buf , size_t count ); 

На x86-64 системний виклик write () знаходиться у верхній частині таблиці системних викликів, оскільки виклик 1 (read () становить 0). Стандартний висновок - файловий дескриптор 1 за замовчуванням (стандартний ввід 0). Наступний біт коду буде записувати 10 байт даних із buffer адреси пам'яті (символу, визначеного в іншому місці програми збірки), до стандартного виводу. Число написаних байтів, або -1 за помилку, буде повернуто в рах.

 mov rdi , 1 ; fd mov rsi , buffer mov rdx , 10 ; 10 bytes mov rax , 1 ; SYS_write syscall 

Ефективні адреси

Ось одне останнє, що вам потрібно знати: регістри часто зберігають адресу пам'яті (тобто покажчик), і вам потрібно спосіб прочитати дані за цією адресою. У синтаксисі NASM оберніть реєстр у дужках (наприклад, [rax] ), який, якщо ви знайомі з C, буде таким самим, як переміщення покажчика.

Ці вирази кронштейнів, які називаються ефективною адресою , можуть бути обмеженими математичними виразами, щоб компенсувати цю базову адресу цілком у межах однієї інструкції. Цей вираз може включати інший регістр ( індекс ), скалярний сигнал двох (бітове зсув) і негайне підписане зміщення . Наприклад, [rax + rdx*8 + 12] . Якщо rax - це покажчик на структуру, а rdx - це індекс масиву до елемента в масиві на цій структурі, для читання цього елемента потрібна лише одна команда. NASM досить розумний, щоб дозволити програмісту збірки злегка зламати цю форму з більш складними виразами, поки вона може зменшити її до форми [base + index*2^exp + offset] .

Подробиці адресації не є важливими для цієї статті, тому не хвилюйтеся надто сильно, якщо це не має сенсу.

Виділити стек

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

Тривіальним способом зробити це було б резервувати деякі фіксовані .BSS (нульова ініціалізована) сховище для потоків у самому виконуваному файлі, але я хочу зробити це правильним шляхом і виділяти стек динамічно, як і Pthreads, або будь-які інші потоки рішень бібліотека, буде. В іншому випадку додаток буде обмежений фіксованою кількістю потоків для часу компіляції.

Ви можете не просто читати і писати до довільних адрес у віртуальній пам'яті, спочатку потрібно попросити ядро ​​виділити сторінки . Для цього є дві системні виклики на Linux:

На x86-64, mmap () - системний виклик 9. Я визначу функцію для виділення стека з цим прототипом C.

 void * stack_create ( void ); 

Системний виклик mmap () містить 6 аргументів, але при створенні анонімної карти пам'яті останні два аргументи ігноруються. Для наших цілей він виглядає як цей прототип C.

 void * mmap ( void * addr , size_t length , int prot , int flags ); 

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

 %define SYS_mmap 9 %define STACK_SIZE ( 4096 * 1024 ) ; 4 MB stack_create : mov rdi , 0 mov rsi , STACK_SIZE mov rdx , PROT_WRITE | PROT_READ mov r10 , MAP_ANONYMOUS | MAP_PRIVATE | MAP_GROWSDOWN mov rax , SYS_mmap syscall ret 

Тепер ми можемо виділити нові стеки (або буфер розміру стека) за потребою.

Нерест нитки

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

 long sys_clone ( unsigned long flags , void * child_stack ); 

Наша нитка буде мати цей прототип C. Він приймає функцію як аргумент і запускає потоку, що виконує цю функцію.

 long thread_create ( void ( * )( void )); 

Аргумент покажчика функції передано через rdi, за ABI. Зберігайте це для збереження на стек ( push ) під час підготовки до виклику stack_create (). Коли він повернеться, адреса нижнього кінця стеків буде виражена.

 thread_create : push rdi call stack_create lea rsi , [ rax + STACK_SIZE - 8 ] pop qword [rsi] mov rdi , CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | \ CLONE_PARENT | CLONE_THREAD | CLONE_IO mov rax , SYS_clone syscall ret 

Другий аргумент для клонування () - це вказівник на високу адресу стек (зокрема, безпосередньо над стеком). Отже, нам потрібно додати STACK_SIZE щоб витримати, щоб досягти високого рівня. Це зроблено за допомогою інструкції, що вказує на: Незважаючи на дужки, він фактично не читає пам'ять за цією адресою, але замість цього зберігає адресу в регістрі призначення (rsi). Я перемістив його назад на 8 байт, тому що я розміщую покажчик функції потоку у верхній частині нового стека в наступній інструкції. Ви зрозумієте, чому в мить.

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

Як видно, для створення нитки з клоном () потрібно багато прапорів. Більшість речей не використовується за замовчуванням, тому потрібно активувати багато варіантів. Перегляньте сторінку клонів (2) для повної інформації про ці прапори.

Буде створено нову нитку, і syscall повернеться в кожному з двох потоків за тією ж інструкцією, точно так само, як fork (). Всі регістри будуть однаковими між потоками, за винятком rax, що буде 0 в новій гілці, а rsp, що має те ж значення, що і rsi в новій гілці (покажчик на новий стек).

Ось справжня класна частина , і причина розгалуження не потрібна. Немає причин перевіряти rax, щоб визначити, чи є ми оригінальним потоком (у цьому випадку ми повертаємось до абонента), або якщо ми є новою гілкою (в цьому випадку ми перейдемо до функції потоку). Пам'ятаєте, як ми розмістили новий стек за допомогою функції потоку? Коли нова нитка повернеться (ret), вона перейде до функції потоку з абсолютно порожнім стеком. Початковий потік, використовуючи оригінальний стек, повернеться до абонента.

Значення, яке повертає thread_create (), - це ідентифікатор процесу нового потоку, який по суті є об'єктом потоку (наприклад, pthread_t Pthread).

Очищення

Функція потоку повинна бути обережною, щоб не повертатися (ret), оскільки повернути ніде. Він випаде зі стеків та припинить програму з несправністю сегментації. Пам'ятайте, що потоки - це просто процеси? Він повинен використовувати сискультат exit() для завершення. Це не призведе до зупинки інших потоків.

 %define SYS_exit 60 exit : mov rax , SYS_exit syscall 

Перед тим як вийти, він повинен випустити свій стек з системним викликом munmap(), щоб не було витоку ресурсів закінченої нитки. Еквівалентом pthread_join () основним батьком буде використання системного виклику wait4() у процесі потоку.

Більше розвідки

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