4.3: Системні виклики
- Page ID
- 34541
Системні виклики - це те, як програми користувацького простору взаємодіють з ядром. Загальний принцип, що лежить в основі того, як вони працюють, описаний нижче.
Кожен системний виклик має номер системного виклику, який відомий як користувальницький простір, так і ядро. Наприклад, обидва знають, що номер системного виклику 10 - open (), номер системного виклику 11 читається () і т.д.
Бінарний інтерфейс програми (ABI) дуже схожий на API, але замість того, щоб бути для програмного забезпечення, це апаратне забезпечення. API визначить, в якому регістрі слід ввести номер системного виклику, щоб ядро могло його знайти, коли його попросять виконати системний виклик.
Системні виклики не є хорошими без аргументів; наприклад, open () має повідомити ядру, який саме файл потрібно відкрити. Ще раз ABI визначить, в які регістри слід вводити аргументи для системного виклику.
Щоб насправді виконати системний виклик, повинен бути якийсь спосіб зв'язатися з ядром, який ми хочемо зробити системний виклик. Усі архітектури визначають інструкцію, яка зазвичай називається break або щось подібне, що сигналізує апаратному забезпеченню, яке ми хочемо зробити системний виклик.
Зокрема, ця інструкція скаже апаратному забезпеченню змінити вказівник інструкції, щоб вказати на обробник системних викликів ядра (коли операційна система встановлює себе, вона повідомляє апаратному забезпеченню, де живе обробник системних викликів). Отже, як тільки простір користувача викликає інструкцію break, він втратив контроль над програмою і передав його ядру.
В іншому операція досить пряма вперед. Ядро шукає в заздалегідь визначеному регістрі номер системного виклику і шукає його в таблиці, щоб побачити, яку функцію слід викликати. Ця функція викликається, робить те, що їй потрібно зробити, і поміщає своє значення, що повертається в інший регістр, визначений ABI як регістр повернення.
Останній крок полягає в тому, щоб ядро зробило інструкцію переходу назад до програми користувацького простору, щоб воно могло продовжувати роботу там, де воно залишилося. Програма userspace отримує необхідні їй дані з реєстру повернення, і щасливо продовжує свій шлях!
Хоча деталі процесу можуть стати досить волохатими, це в основному все, що їх стосується системного виклику.
Хоча ви можете зробити все вищезазначене вручну для кожного системного виклику, системні бібліотеки зазвичай роблять більшу частину роботи за вас. Стандартною бібліотекою, яка займається системними викликами на UNIX подібних системах, є libc; ми дізнаємося більше про її ролі в наступних тижнях.
Оскільки системні бібліотеки зазвичай займаються тим, щоб системи викликали вас, нам потрібно зробити деякі зломи низького рівня, щоб проілюструвати, як саме працюють системні дзвінки.
Ми проілюструємо, як працює, мабуть, найпростіший системний виклик getpid (). Цей виклик не приймає аргументів і повертає ідентифікатор поточної запущеної програми (або процесу; ми розглянемо процес більш пізніми тижнями).
1 #include <stdio.h>
/* for syscall() */
#include <sys/syscall.h>
5 #include <unistd.h>
/* system call numbers */
#include <asm/unistd.h>
10 void function(void)
{
int pid;
pid = __syscall(__NR_getpid);
15 }
Ми починаємо з написання невеликої програми C, яку ми можемо почати, щоб проілюструвати механізм системних викликів. Перше, що слід зазначити, це те, що існує аргумент syscall, наданий системними бібліотеками для безпосереднього здійснення системних викликів. Це забезпечує простий спосіб для програмістів безпосередньо здійснювати системні дзвінки без необхідності знати точні процедури мови збірки для здійснення дзвінка на їх апаратному забезпеченні. Так чому ж ми взагалі використовуємо getpid ()? По-перше, набагато зрозуміліше використовувати символічне ім'я функції у вашому коді. Однак, що ще важливіше, getpid () може працювати дуже по-різному в різних системах. Наприклад, у Linux виклик getpid () може бути кешований, тому, якщо він буде запущений двічі, системна бібліотека не візьме на себе штраф за необхідність зробити весь системний виклик, щоб знову дізнатися ту саму інформацію.
За угодою в Linux номери системних викликів визначаються у файлі asm/unistd.h з джерела ядра. Перебуваючи в підкаталозі asm, це відрізняється для кожної архітектури, на якій працює Linux. Знову ж таки, за угодою, номери системних викликів отримують ім'я #define, що складається з __NR_. Таким чином, ви можете бачити, що наш код буде робити системний виклик getpid, зберігаючи значення в pid.
Ми розглянемо, як кілька архітектур реалізують цей код під капотом. Ми будемо дивитися на реальний код, так що речі можуть отримати досить волохаті. Але дотримуйтеся цього - саме так працює ваша система!
PowerPC - це архітектура RISC, поширена на старих комп'ютерах Apple, і ядро пристроїв, таких як остання версія Xbox.
1
/* On powerpc a system call basically clobbers the same registers like a
* function call, with the exception of LR (which is needed for the
* "sc; bnslr" sequence) and CR (where only CR0.SO is clobbered to signal
5 * an error return status).
*/
#define __syscall_nr(nr, type, name, args...) \
unsigned long __sc_ret, __sc_err; \
10 { \
register unsigned long __sc_0 __asm__ ("r0"); \
register unsigned long __sc_3 __asm__ ("r3"); \
register unsigned long __sc_4 __asm__ ("r4"); \
register unsigned long __sc_5 __asm__ ("r5"); \
15 register unsigned long __sc_6 __asm__ ("r6"); \
register unsigned long __sc_7 __asm__ ("r7"); \
\
__sc_loadargs_##nr(name, args); \
__asm__ __volatile__ \
20 ("sc \n\t" \
"mfcr %0 " \
: "=&r" (__sc_0), \
"=&r" (__sc_3), "=&r" (__sc_4), \
"=&r" (__sc_5), "=&r" (__sc_6), \
25 "=&r" (__sc_7) \
: __sc_asm_input_##nr \
: "cr0", "ctr", "memory", \
"r8", "r9", "r10","r11", "r12"); \
__sc_ret = __sc_3; \
30 __sc_err = __sc_0; \
} \
if (__sc_err & 0x10000000) \
{ \
errno = __sc_ret; \
35 __sc_ret = -1; \
} \
return (type) __sc_ret
#define __sc_loadargs_0(name, dummy...) \
40 __sc_0 = __NR_##name
#define __sc_loadargs_1(name, arg1) \
__sc_loadargs_0(name); \
__sc_3 = (unsigned long) (arg1)
#define __sc_loadargs_2(name, arg1, arg2) \
45 __sc_loadargs_1(name, arg1); \
__sc_4 = (unsigned long) (arg2)
#define __sc_loadargs_3(name, arg1, arg2, arg3) \
__sc_loadargs_2(name, arg1, arg2); \
__sc_5 = (unsigned long) (arg3)
50 #define __sc_loadargs_4(name, arg1, arg2, arg3, arg4) \
__sc_loadargs_3(name, arg1, arg2, arg3); \
__sc_6 = (unsigned long) (arg4)
#define __sc_loadargs_5(name, arg1, arg2, arg3, arg4, arg5) \
__sc_loadargs_4(name, arg1, arg2, arg3, arg4); \
55 __sc_7 = (unsigned long) (arg5)
#define __sc_asm_input_0 "0" (__sc_0)
#define __sc_asm_input_1 __sc_asm_input_0, "1" (__sc_3)
#define __sc_asm_input_2 __sc_asm_input_1, "2" (__sc_4)
60 #define __sc_asm_input_3 __sc_asm_input_2, "3" (__sc_5)
#define __sc_asm_input_4 __sc_asm_input_3, "4" (__sc_6)
#define __sc_asm_input_5 __sc_asm_input_4, "5" (__sc_7)
#define _syscall0(type,name) \
65 type name(void) \
{ \
__syscall_nr(0, type, name); \
}
70 #define _syscall1(type,name,type1,arg1) \
type name(type1 arg1) \
{ \
__syscall_nr(1, type, name, arg1); \
}
75
#define _syscall2(type,name,type1,arg1,type2,arg2) \
type name(type1 arg1, type2 arg2) \
{ \
__syscall_nr(2, type, name, arg1, arg2); \
80 }
#define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3) \
type name(type1 arg1, type2 arg2, type3 arg3) \
{ \
85 __syscall_nr(3, type, name, arg1, arg2, arg3); \
}
#define _syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4) \
type name(type1 arg1, type2 arg2, type3 arg3, type4 arg4) \
90 { \
__syscall_nr(4, type, name, arg1, arg2, arg3, arg4); \
}
#define _syscall5(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,type5,arg5) \
95 type name(type1 arg1, type2 arg2, type3 arg3, type4 arg4, type5 arg5) \
{ \
__syscall_nr(5, type, name, arg1, arg2, arg3, arg4, arg5); \
}
Цей фрагмент коду з заголовка ядра asm/unistd.h показує, як ми можемо реалізувати системні виклики на PowerPC. Виглядає це дуже складно, але його можна розбити крок за кроком.
По-перше, перейдіть до кінця прикладу, де визначені макроси _SysCallN. Ви можете бачити, що існує багато макросів, кожен з яких поступово приймає ще один аргумент. Ми зосередимося на найпростішій версії, _syscall0 для початку. Він приймає лише два аргументи, тип повернення системного виклику (наприклад, C int або char тощо) та ім'я системного виклику. Для getpid це буде зроблено як _syscall0 (int, getpid).
Легко поки що! Тепер ми повинні почати розбирати макрос __syscall_nr. Це не відрізняється від того, де ми були раніше, ми приймаємо кількість аргументів як перший параметр, тип, ім'я, а потім фактичні аргументи.
Першим кроком є оголошення деяких імен для регістрів. Те, що це по суті робить, говорить __sc_0 посилається на r0 (тобто регістр 0). Компілятор, як правило, використовує регістри, як він хоче, тому важливо, що ми надаємо йому обмеження, щоб він не вирішив використовувати регістр, який нам потрібен в деякій спеціальній манері.
Потім ми викликаємо sc_loadargs з цікавим параметром ##. Це просто команда вставки, яка замінюється змінною nr. Таким чином, для нашого прикладу він розширюється до __sc_loadargs_0 (ім'я, аргументи);. __sc_loadargs ми можемо побачити нижче встановлює __sc_0 як номер системного виклику; зверніть увагу на оператор вставки знову з префіксом __NR_, про який ми говорили, та іменем змінної, яка посилається на певний регістр.
Таким чином, все це складно виглядає код насправді робить це ставить номер системного виклику в регістрі 0! Слідуючи коду, ми бачимо, що інші макроси розміщуватимуть аргументи системного виклику в r3 через r7 (ви можете мати лише максимум 5 аргументів для системного виклику).
Тепер ми готові зайнятися розділом __asm__. Те, що ми маємо тут називається вбудованої збірки, тому що це асемблер код змішаний прямо з вихідним кодом. Точний синтаксис трохи складний, щоб перейти прямо тут, але ми можемо вказати на важливі частини.
Просто ігноруйте біт __volatile__ поки що; він говорить компілятору, що цей код непередбачуваний, тому він не повинен намагатися і бути розумним з ним. Знову почнемо в кінці і працюємо назад. Всі речі після двокрапок - це спосіб спілкування з компілятором про те, що вбудована збірка робить регістри процесора. Компілятор повинен знати, щоб він не намагався використовувати будь-який з цих регістрів способами, які можуть спричинити аварійне завершення роботи.
Але цікавою частиною є два твердження збірки в першому аргументі. Той, який виконує всю роботу, - це виклик sc. Це все, що вам потрібно зробити, щоб зробити ваш системний виклик!
Так що ж відбувається, коли цей дзвінок зроблений? Ну, процесор переривається знає, щоб передати управління певній частині налаштування коду під час завантаження системи для обробки переривань. Переривань багато, системні виклики - лише один. Цей код буде шукати в регістрі 0, щоб знайти номер системного виклику; потім він шукає таблицю і знаходить правильну функцію для переходу до обробки цього системного виклику. Ця функція отримує свої аргументи в регістрах 3 - 7.
Отже, що відбувається після запуску та завершення обробника системних викликів? Контроль повертається до наступної інструкції після sc, в цьому випадку команда забору пам'яті. Це, по суті, говорить «переконайтеся, що все присвячено пам'яті»; пам'ятаєте, як ми говорили про трубопроводи в надскалярній архітектурі? Ця інструкція гарантує, що все, що ми думаємо, було записано в пам'ять насправді було, і не пробивається через трубопровід десь.
Ну, ми майже закінчили! Єдине, що залишилося - повернути значення з системного виклику. Ми бачимо, що __sc_ret встановлюється з r3, а __sc_err встановлюється з r0. Це цікаво; про що ці дві цінності?
Один - значення, що повертається, а одне - значення помилки. Навіщо потрібні дві змінні? Системні виклики можуть вийти з ладу, як і будь-яка інша функція. Проблема полягає в тому, що системний виклик може повернути будь-яке можливе значення; ми не можемо сказати, що «негативне значення вказує на збій», оскільки негативне значення може бути цілком прийнятним для певного системного виклику.
Таким чином, наша функція системного виклику, перед поверненням, гарантує, що її результат знаходиться в регістрі r3 і будь-який код помилки знаходиться в регістрі r0. Ми перевіряємо код помилки, щоб побачити, чи встановлений верхній біт; це означало б негативне число. Якщо так, то встановлюємо глобальне значення errno (це стандартна змінна для отримання інформації про помилки при збої виклику) і ставимо return рівним -1. Звичайно, якщо отриманий дійсний результат, ми повертаємо його безпосередньо.
Таким чином, наша функція виклику повинна перевірити значення, що повертається не -1; якщо це так, він може перевірити errno, щоб знайти точну причину, чому виклик не вдалося.
І це весь системний виклик на PowerPC!
Нижче ми маємо той же інтерфейс, що реалізований для процесора x86.
1 /* user-visible error numbers are in the range -1 - -124: see <asm-i386/errno.h> */
#define __syscall_return(type, res) \
do { \
5 if ((unsigned long)(res) >= (unsigned long)(-125)) { \
errno = -(res); \
res = -1; \
} \
return (type) (res); \
10 } while (0)
/* XXX - _foo needs to be __foo, while __NR_bar could be _NR_bar. */
#define _syscall0(type,name) \
type name(void) \
15 { \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name)); \
20 __syscall_return(type,__res);
}
#define _syscall1(type,name,type1,arg1) \
type name(type1 arg1) \
25 { \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(arg1))); \
30 __syscall_return(type,__res);
}
#define _syscall2(type,name,type1,arg1,type2,arg2) \
type name(type1 arg1,type2 arg2) \
35 { \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2))); \
40 __syscall_return(type,__res);
}
#define _syscall3(type,name,type1,arg1,type2,arg2,type3,arg3) \
type name(type1 arg1,type2 arg2,type3 arg3) \
45 { \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
50 "d" ((long)(arg3))); \
__syscall_return(type,__res); \
}
#define _syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4) \
55 type name (type1 arg1, type2 arg2, type3 arg3, type4 arg4) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
60 : "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
"d" ((long)(arg3)),"S" ((long)(arg4))); \
__syscall_return(type,__res); \
}
65 #define _syscall5(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4, \
type5,arg5) \
type name (type1 arg1,type2 arg2,type3 arg3,type4 arg4,type5 arg5) \
{ \
long __res; \
70 __asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
"d" ((long)(arg3)),"S" ((long)(arg4)),"D" ((long)(arg5))); \
__syscall_return(type,__res); \
75 }
#define _syscall6(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4, \
type5,arg5,type6,arg6) \
type name (type1 arg1,type2 arg2,type3 arg3,type4 arg4,type5 arg5,type6 arg6) \
80 { \
long __res; \
__asm__ volatile ("push %%ebp ; movl %%eax,%%ebp ; movl %1,%%eax ; int $0x80 ; pop %%ebp" \
: "=a" (__res) \
: "i" (__NR_##name),"b" ((long)(arg1)),"c" ((long)(arg2)), \
85 "d" ((long)(arg3)),"S" ((long)(arg4)),"D" ((long)(arg5)), \
"0" ((long)(arg6))); \
__syscall_return(type,__res); \
}
Архітектура x86 сильно відрізняється від PowerPC, яку ми розглядали раніше. x86 класифікується як процесор CISC, на відміну від RISC PowerPC, і має значно менше регістрів.
Почніть з перегляду найпростішого макросу _syscall0. Він просто викликає INT інструкцію зі значенням 0x80. Ця інструкція змушує процесор підняти переривання 0x80, який перейде до коду, який обробляє системні виклики в ядрі.
Ми можемо почати перевіряти, як передавати аргументи з довшими макросами. Зверніть увагу, як реалізація PowerPC каскадні макроси вниз, додаючи один аргумент за раз. Ця реалізація має трохи більше скопійованого коду, але трохи простіше слідувати.
Імена регістрів x86 засновані на літерах, а не на числових іменах регістрів PowerPC. З нульового аргументу макросу ми бачимо, що завантажується лише регістр A; з цього ми можемо сказати, що номер системного виклику очікується в реєстрі EAX. Коли ми починаємо завантажувати регістри в інших макросах, ви можете побачити короткі імена регістрів в аргументах до виклику __asm__.
Ми бачимо щось трохи цікавіше в __syscall6, макрос приймає 6 аргументів. Зверніть увагу на поштовх і поп інструкції? Вони працюють зі стеком на x86, «штовхаючи» значення у верхній частині стека в пам'яті та вискакуючи значення зі стека назад у пам'ять. Таким чином, у випадку наявності шести регістрів нам потрібно зберегти значення регістра ebp в пам'яті, поставити наш аргумент в (інструкція mov), зробити наш системний виклик, а потім відновити початкове значення в ebp. Тут ви можете побачити недолік недостатньої кількості регістрів; магазини в пам'ять дорогі, тому чим більше ви можете їх уникнути, тим краще.
Інша річ, яку ви можете помітити, що немає нічого подібного до інструкції забору пам'яті, яку ми бачили раніше з PowerPC. Це пояснюється тим, що на x86 ефект від усіх інструкцій буде гарантовано видно при завершенні. Це простіше для компілятора (і програміста) для програмування, але пропонує меншу гнучкість.
Єдине, що залишилося контрастувати - це повернене значення. На PowerPC у нас було два регістри з поверненими значеннями з ядра, один зі значенням і один з кодом помилки. Однак на x86 ми маємо лише одне повернене значення, яке передається в __syscall_return. Цей макрос перетворює повернене значення до unsigned long і порівнює його з (залежним від архітектури та ядра) діапазоном від'ємних значень, які можуть представляти коди помилок (зверніть увагу, що значення errno є позитивним, тому негативний результат від ядра заперечується). Однак це означає, що системні виклики не можуть повернути невеликі негативні значення, так як вони не відрізняються від кодів помилок. Деякі системні виклики, які мають цю вимогу, такі як getpriority (), додають зсув до їх поверненого значення, щоб змусити його завжди бути позитивним; це залежить від простору користувача, щоб зрозуміти це і відняти це постійне значення, щоб повернутися до «реального» значення.