автордың кітабын онлайн тегін оқу Linux. Системное программирование
Р. Лав
Linux. Системное программирование. 2-е изд.
Переводчик О. Сивченко
Технический редактор В. Конаш
Литературный редактор В. Конаш
Художники Л. Адуевская, Н. Гринчик
Корректоры Т. Курьянович, Е. Павлович
Верстка А. Барцевич
Р. Лав
Linux. Системное программирование. 2-е изд.. — СПб.: Питер, 2014.
ISBN 978-5-496-00747-4
© ООО Издательство "Питер", 2014
Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав.
Предисловие
Есть старая шутка, что разработчики ядра Linux, рассердившись, могут в сердцах бросить: «Все ваше пользовательское пространство — просто тестовая нагрузка для ядра!»
Говоря такое, разработчики просто пытаются умыть руки и снять с себя ответственность за любые случаи, в которых ядру не удается обеспечивать максимально эффективную работу пользовательского кода. По мнению создателей ядра, программистам, работающим в пользовательском пространстве, следует просто посторониться и исправлять собственный код, ведь проблемы могут объясняться чем угодно, но не недостатками ядра.
Уже более трех лет назад один из ведущих разработчиков ядра Linux прочитал лекцию под названием «Почему пользовательское пространство — ерунда». Целью лекции было доказать, что обычно корень проблем лежит не в ядре. Выступив перед переполненной аудиторией, этот специалист привел примеры отвратительного пользовательского кода, на который практически всем пользователям Linux приходится полагаться ежедневно. Другие разработчики ядра создали специальные инструменты, демонстрирующие, как сильно пользовательские программы могут злоупотреблять оборудованием и растрачивать заряд ничего не подозревающего ноутбука.
Однако если пользовательский код и может быть банальной «тестовой нагрузкой», которая порой возмущает разработчиков ядра, необходимо признать, что и сами разработчики ядра ежедневно вынуждены работать с тем самым пользовательским кодом. Если бы его не было, ядро годилось бы для выполнения единственной функции — вывода на экран перемежающихся серий ABABAB.
В настоящее время Linux является наиболее гибкой и мощной из когда-либо созданных операционных систем. Linux с успехом применяется как в крошечных сотовых телефонах и внедренных устройствах, так и на 90 % из 500 мощнейших суперкомпьютеров в мире. Ни одна другая операционная система не обладает такими превосходными возможностями масштабирования и адаптации к нюансам разнообразных типов оборудования и экосистем.
Однако код, работающий в пользовательском пространстве Linux, способен взаимодействовать со всеми этими платформами не менее эффективно, чем ядро, обеспечивая функционирование реальных прикладных приложений и утилит, с которыми и приходится работать нам с вами.
В своей книге Роберт Лав (Robert Love) поставил перед собой титаническую задачу — рассказать читателю практически обо всех системных вызовах, происходящих в Linux. В результате получился настоящий фолиант, позволяющий вам полностью понять, как работает Linux «с пользовательской точки зрения» и как максимально эффективно использовать всю мощь этой операционной системы.
Прочтя данную книгу, вы научитесь писать код, который будет работать во всех дистрибутивах Linux на самом разном оборудовании. Книга поможет вам осознать, как функционирует Linux и как наиболее эффективно задействовать ее гибкость.
В конечном итоге эта книга научит вас писать код, который никто уже не назовет ерундой, а это самое главное.
Грег Кроах-Хартман (Greg Kroah-Hartman)
Вступление
Данная книга рассказывает о системном программировании в Linux. Системное программирование — это практика написания системного ПО, низкоуровневый код которого взаимодействует непосредственно с ядром и основными системными библиотеками. Иными словами, речь далее пойдет в основном о системных вызовах Linux и низкоуровневых функциях, в частности тех, которые определены в библиотеке C.
Есть немало пособий, посвященных системному программированию для UNIX-систем, но вы почти не найдете таких, которые рассматривают данную тему достаточно подробно и фокусируются именно на Linux. Еще меньше подобных книг учитывают новейшие релизы Linux и продвинутые интерфейсы, ориентированные исключительно на Linux. Эта книга не только лишена всех перечисленных недостатков, но и обладает важным достоинством: дело в том, что я написал массу кода для Linux, как для ядра, так и для системных программ, расположенных непосредственно «над ядром». На самом деле я реализовал на практике ряд системных вызовов и других функций, описанных далее. Соответственно книга содержит богатый материал, рассказывая не только о том, как должныработать системные интерфейсы, но и о том, как они действительно работают и как вы сможете использовать их с максимальной эффективностью. Таким образом, данная книга одновременно является и руководством по системному программированию для Linux, и справочным пособием, описывающим системные вызовы Linux, и подробным повествованием о том, как создавать более интеллектуальный и быстрый код. Текст написан простым, доступным языком. Независимо от того, является ли создание системного кода вашей основной работой, эта книга научит полезным приемам, которые помогут вам стать по-настоящему высокопрофессиональным программистом.
Целевая аудиторияинеобходимые предпосылки
Пособие предназначается для читателей, знакомых с программированием на языке C и с применяемой в Linux экосистемой программирования. Не обязательно быть экспертом в этих темах, но в них нужно как минимум ориентироваться. Если вам не приходилось работать с текстовыми редакторами для UNIX — наиболее известными и хорошо себя зарекомендовавшими являются Emacs и vim, — поэкспериментируйте с ними. Кроме того, следует в общих чертах представлять работу с gcc, gdb, make и др. Существует еще множество инструментов и практикумов по программированию для Linux; в приложении Б в конце перечислены некоторые полезные источники.
Кроме того, я ожидаю от читателя определенных знаний в области системного программирования для Linux и UNIX. Эта книга начинается с самых основ, ее темы постепенно усложняются вплоть до обсуждения наиболее продвинутых интерфейсов и приемов оптимизации. Надеюсь, пособие понравится читателям с самыми разными уровнями подготовки, научит их чему-то ценному и новому. Пока писал книгу, я сам узнал немало интересного.
У меня были определенные предположения об убеждениях и мотивации читателя. Инженеры, желающие (более качественно) программировать на системном уровне, являются основной целевой аудиторией, но книга будет интересна и программистам, которые специализируются на высокоуровневом коде и желают приобрести более солидные базовые знания. Любознательным хакерам пособие также понравится, утолит их жажду нового. Книга задумывалась так, чтобы заинтересовать большинство программистов.
В любом случае, независимо от ваших мотивов, надеюсь, что чтение окажется для вас интересным!
Краткое содержание
Книга разделена на 11 глав и 2 приложения.
•Глава 1. Введение и основополагающие концепции. Она — введение в проблему. Здесь делается обзор Linux, системного программирования, ядра, библиотеки C и компилятора C. Главу следует изучить даже самым опытным пользователям.
• Глава 2. Файловый ввод-вывод. Тут дается вводная информация о файлах — наиболее важной абстракции в экосистеме UNIX, а также файловом вводе/выводе, который является основой процесса программирования для Linux. Подробно рассматриваются считывание информации из файлов и запись информации в них, а также другие базовые операции файлового ввода-вывода. Итоговая часть главы рассказывает, как ядро Linux внедряет (реализует) концепцию файлов и управляет ими.
• Глава 3. Буферизованный ввод-вывод. Здесь обсуждается проблема, связанная с базовыми интерфейсами ввода-вывода — управление размером буфера, — и рассказывается о буферизованном вводе-выводе вообще, а также стандартном вводе-выводе в частности как о возможных решениях.
• Глава 4. Расширенный файловый ввод-вывод. Завершает трио тем о вводе-выводе и рассказывает о продвинутых интерфейсах ввода-вывода, способах распределения памяти и методах оптимизации. В заключение главы мы поговорим о том, как избегать подвода головок, и о роли планировщика ввода-вывода, работающего в ядре Linux.
• Глава 5. Управление процессами. В ней читатель познакомится со второй по важности абстракцией UNIX — процессом — и семейством системных вызовов, предназначенных для базового управления процессами, в частности древним феноменом ветвления (fork).
• Глава 6. Расширенное управление процессами. Здесь продолжается обсуждение процессов. Глава начинается с рассмотрения продвинутых способов управления процессами, в частности управления в реальном времени.
• Глава 7. Поточность. Здесь обсуждаются потоки и многопоточное программирование. Глава посвящена в основном высокоуровневым концепциям проектирования. В частности, в ней читатель познакомится с API многопоточности POSIX, который называется Pthreads.
• Глава 8. Управление файламиикаталогами. Тут обсуждаются вопросы создания, перемещения, копирования, удаления и других приемов, связанных с управлением файлами и каталогами.
• Глава 9. Управление памятью. В ней рассказывается об управлении памятью. Глава начинается с ознакомления с основными концепциями UNIX, связанными с памятью, в частности с адресным пространством процесса и подкачкой страниц. Далее мы поговорим об интерфейсах, к которым можно обращаться для получения памяти и через которые можно возвращать память обратно в ядро. В заключение мы ознакомимся с продвинутыми интерфейсами, предназначенными для управления памятью.
• Глава 10. Сигналы. Здесь рассматриваются сигналы. Глава начинается с обсуждения природы сигналов и их роли в системе UNIX. Затем описываются сигнальные интерфейсы, от самых простых к наиболее сложным.
• Глава 11. Время. Она посвящена обсуждению времени, спящего режима и управления часами. Здесь рассмотрены все базовые интерфейсы вплоть до часов POSIX и таймеров высокого разрешения.
• Приложение А. В нем рассматриваются многие языковые расширения, предоставляемые gcc и GNU C, в частности атрибуты, позволяющие сделать функцию константной, чистой или внутристрочной.
• Приложение Б.Здесь собрана библиография работ, которые я рекомендую для дальнейшего изучения. Они служат не только важным дополнением к изложенному в книге материалу, но и рассказывают об обязательных темах, не затронутых в моей работе.
Версии, рассмотренные в книге
Системный интерфейс Linux определяется как бинарный (двоичный) интерфейс приложений и интерфейс программирования приложений, предоставляемый благодаря взаимодействию трех сущностей: ядра Linux (центра операционной системы), библиотеки GNU C (glibc) и компилятора GNU C (gcc — в настоящее время он официально называется набором компиляторов для GNU и применяется для работы с различными языками, но нас интересует только C). В этой книге рассмотрен системный интерфейс, определенный с применением версии ядра Linux 3.9, версий glibc 2.17 и gcc 4.8. Более новые интерфейсы этих компонентов должны и далее соответствовать интерфейсам и поведениям, документированным в данной книге. Аналогично многие интерфейсы, о которых нам предстоит поговорить, давно используются в составе Linux и поэтому обладают обратной совместимостью с более ранними версиями ядра, glibc и gcc.
Если любую развивающуюся операционную систему можно сравнить со скользящей мишенью, то Linux — это просто гепард в прыжке. Прогресс измеряется днями, а не годами, частые релизы ядра и других компонентов постоянно меняют и правила игры, и само игровое поле. Ни в одной книге не удалось бы сделать достаточно долговечный слепок такого динамичного явления.
Тем не менее экосистема, в которой протекает системное программирование, очень стабильна. Разработчикам ядра приходится проявлять недюжинную изобретательность, чтобы не повредить системные вызовы, разработчики glibc крайне высоко ценят прямую и обратную совместимость, а цепочка инструментов Linux (набор программ для написания кода) создает взаимно совместимый код в различных версиях. Следовательно, при всей динамичности Linux системное программирование для этой операционной системы остается стабильным. Книга, представляющая собой «мгновенный снимок» системы, особенно на современном этапе развития Linux, обладает исключительной фактической долговечностью. Я пытаюсь сказать: не беспокойтесь, что системные интерфейсы вскоре изменятся, и смело покупайте эту книгу!
Условные обозначения
В книге применяются следующие условные обозначения.
Курсивный шрифт
Им обозначаются новые термины и понятия.
Шрифт для названий
Используется для обозначения URL, адресов электронной почты, а также сочетаний клавиш и названий элементов интерфейса.
Шрифт для команд
Применяется для обозначения программных элементов — переменных и названий функций, типов данных, переменных окружения, операторов и ключевых слов и т. д.
Шрифт для листингов
Используется в листингах программного кода.
Примечание
Данная врезка содержит совет, замечание практического характера или общее замечание.
Внимание
Такая врезка содержит какое-либо предостережение.
Большинство примеров кода в книге представляют собой краткие фрагменты, которые легко можно использовать повторно. Они выглядят примерно так:
while (1) {
int ret;
ret = fork ();
if(ret== –1)
perror("fork");
}
Пришлось проделать огромную работу, чтобы фрагменты кода получились столь краткими и при этом не утратили практической ценности. Для работы вам не потребуется никаких специальных заголовочных файлов, переполненных безумными макросами и сокращениями, о смысле которых остается только догадываться. Я не писал нескольких гигантских программ, а ограничился многочисленными, но сжатыми примерами, которые, будучи практическими и наглядными, сделаны максимально компактными и ясными. Надеюсь, при первом прочтении книги они послужат вам удобным пособием, а на последующих этапах работы станут хорошим справочным материалом.
Почти все примеры являются самодостаточными. Это означает, что вы можете просто скопировать их в текстовый редактор и смело использовать на практике. Если не указано иное, сборка всех фрагментов кода должна происходить без применения каких-либо специальных индикаторов компилятора (в отдельных случаях понадобится связь со специальной библиотекой). Рекомендую следующую команду для компиляции файла исходников:
$ gcc -Wall -Wextra -O2 -g -o snippet snippet.c
Она собирает файл исходного кода snippet.c в исполняемый бинарный файл snippet, обеспечивая выполнение многих предупреждающих проверок, значительных, но разумных оптимизаций, а также отладку. Код из книги должен компилироваться без возникновения ошибок или предупреждений — хотя, конечно, вам для начала может потребоваться построить скелетное приложение на базе того или иного фрагмента кода.
Когда в каком-либо разделе вы знакомитесь с новой функцией, она записывается в обычном для UNIX формате справочной страницы такого вида:
#include <fcntl.h>
int posix_fadvise (int fd, off_t pos, off_t len, int advice);
Все необходимые заголовки и определения находятся вверху, за ними следует полный прототип вызова.
Работа с примерами кода
Эта книга написана, чтобы помочь вам при работе. В принципе, вы можете использовать код, содержащийся в ней, в ваших программах и документации. Можете не связываться с нами и не спрашивать разрешения, если собираетесь воспользоваться небольшим фрагментом кода. Например, если вы пишете программу и кое-где вставляете в нее код из книги, никакого особого разрешения не требуется. Однако если вы запишете на диск примеры из книги и начнете раздавать или продавать такие диски, то на это необходимо получить разрешение. Если вы цитируете это издание, отвечая на вопрос, или воспроизводите код из него в качестве примера, разрешение не нужно. Если вы включаете значительный фрагмент кода из данной книги в документацию по вашему продукту, необходимо разрешение.
Благодарности
Эта книга могла появиться на свет только благодаря участию множества умных и великодушных людей. Конечно, любой их список будет неполным, но я искренне хотел бы поблагодарить за участие всех, кто помогал мне в работе, воодушевлял меня, делился знаниями и поддерживал.
Энди Орам (Andy Oram) — феноменальный редактор и удивительный человек. Работа над книгой никогда бы не завершилась без его упорного труда. Энди относится к редкому типу людей, которые обладают одновременно и глубокими техническими знаниями, и поэтическим языковым чутьем.
У этой книги были превосходные технические рецензенты, истинные мастера своего искусства, без участия которых эта работа была бы бледной тенью того, что вы сейчас читаете. Техническими рецензентами книги выступили Джереми Эллисон (Jeremy Allison), Роберт Пи-Джи Дэй (Robert P. J. Day), Кеннет Гейсшет (Kenneth Geisshirt), Джоуи Шоу (Joey Shaw) и Джеймс Уилкокс (James Willcox). Они славно поработали, и если в книге и найдутся ошибки, то это только моя вина.
Мои коллеги из Google были и остаются самой умной и самоотверженной командой инженеров, вместе с которыми я имел счастье работать. «Покой нам только снится» — такой фразой в лучшем ее смысле можно охарактеризовать этот коллектив. Спасибо вам за проекты в области системного программирования, которые помогли мне создать этот труд, и за атмосферу, подвигающую человека на творческие свершения, например написание подобной книги.
По многим причинам я хотел бы выразить благодарность и глубокое уважение Полу Амичи (Paul Amici), Майки Бэббиту (Mikey Babbitt), Нату Фридману (Nat Friedman), Мигелю де Иказе (Miguel de Icaza), Грегу Кроаху-Хартману (Greg Kroah-Hartman), Дорис Лав (Doris Love), Линде Лав (Linda Love), Тиму О’Рейли (Tim O’Reilly), Сальваторе Рибаудо (Salvatore Ribaudo) и его семье, Крису Ривере (Chris Rivera), Кэролин Родон (Carolyn Rodon), Саре Стюарт (Sarah Stewart), Питеру Тейчману (Peter Teichman), Линусу Торвальдсу (Linus Torvalds), Джону Троубриджу (Jon Trowbridge), Джереми ван Дорену (Jeremy van Doren) и его семье, Луису Вилье (Luis Villa), Стиву Вайсбергу (Steve Weisberg) и его семье, а также Хелен Уиснант (Helen Whisnant).
Наконец, благодарю моих родителей — Боба (Bob) и Элен (Elaine).
Роберт Лав, Бостон
Отиздательства
Ваши замечания, предложения и вопросы отправляйте по адресу электронной почты vinitski@minsk.piter.com (издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На сайте издательства http://www.piter.com вы найдете подробную информацию о наших книгах.
Глава 1. Введение и основополагающие концепции
Эта книга рассказывает о системном программировании, то есть написании системного программного обеспечения. Системные программы являются низкоуровневыми, взаимодействуют непосредственно с ядром и основными системными библиотеками. Ваши командная оболочка и текстовый редактор, компилятор и отладчик, основные утилиты и системные демоны — все это системное программное обеспечение. К данной категории относятся также сетевой сервер, веб-сервер и база данных. Эти компоненты являются классическими образцами системного ПО и взаимодействуют в основном, а то и исключительно с ядром и библиотекой C. Другое программное обеспечение (например, прикладные программы с графическими пользовательскими интерфейсами) находится на более высоком уровне и взаимодействует с низкоуровневыми лишь эпизодически. Некоторые специалисты целыми днями пишут только системное программное обеспечение, другие уделяют таким задачам лишь часть рабочего времени, но понимание системного ПО — это навык, который пригодится любому специалисту. Независимо от того, является такое программирование насущным хлебом конкретного инженера либо просто базисом для создания более высокоуровневых концепций, системное программирование — это сердце всего создаваемого нами софта.
Эта книга посвящена не всему системному программированию, а системному программированию в Linux. Linux — это современная UNIX-подобная операционная система, написанная с нуля Линусом Торвальдсом и стихийным сообществом программистов со всего мира. Linux разделяет цели и философию UNIX, но при этом не является лишь разновидностью UNIX, а идет своим путем, возвращаясь в русло UNIX, где это желательно, и отклоняясь, когда сие целесообразно. Однако, если не считать базового строения, Linux довольно самобытна. По сравнению с традиционными системами UNIX Linux поддерживает множество дополнительных системных вызовов, работает иначе и предлагает новые возможности.
Системное программирование
Программирование для UNIX изначально зарождалось именно как системное. Исторически системы UNIX не включали значительного количества высокоуровневых концепций. Даже при программировании в среде разработки, например в системе X Window, в полной мере задействовались системные API ядра UNIX. Соответственно, можно сказать, что эта книга — о программировании для Linux вообще. Однако учтите, что в книге не рассматриваются среды разработки для Linux, например вообще не затрагивается тема make. Основное содержание книги — это API системного программирования, предоставляемые для использования на современной машине Linux.
Можно сравнить системное программирование с программированием приложений — и мы сразу заметим как значительное сходство, так и важные различия этих областей. Важная черта системного программирования заключается в том, что программист, специализирующийся в этой области, должен обладать глубокими знаниями оборудования и операционной системы, с которыми он имеет дело. Системные программы взаимодействуют в первую очередь с ядром и системными библиотеками, а прикладные опираются и на высокоуровневые библиотеки. Такие высокоуровневые библиотеки абстрагируют детальные характеристики оборудования и операционной системы. У подобного абстрагирования есть несколько целей: переносимость между различными системами, совместимость с разными версиями этих систем, создание удобного в использовании (либо более мощного, либо и то и другое) высокоуровневого инструментария. Соотношение, насколько активно конкретное приложение использует высокоуровневые библиотеки и насколько — систему, зависит от уровня стека, для которого было написано приложение. Некоторые приложения создаются для взаимодействия исключительно с высокоуровневыми абстракциями. Однако даже такие абстракции, весьма отдаленные от самых низких уровней системы, лучше всего получаются у специалиста, имеющего навыки системного программирования. Те же проверенные методы и понимание базовой системы обеспечивают более информативное и разумное программирование для всех уровней стека.
Зачем изучать системное программирование
В течение прошедшего десятилетия в написании приложений наблюдалась тенденция к уходу от системного программирования к высокоуровневой разработке. Это делалось как с помощью веб-инструментов (например, JavaScript), так и посредством управляемого кода (Java). Тем не менее такие разработки не свидетельствуют об отмирании системного программирования. Действительно, ведь кому-то приходится писать и интерпретатор JavaScript, и виртуальную машину Java, которые создаются именно на уровне системного программирования. Более того, даже разработчики, которые программируют на Python, Ruby или Scala, только выиграют от знаний в области системного программирования, поскольку будут понимать всю подноготную машины. Качество кода при этом гарантированно улучшится независимо от части стека, для которой он будет создаваться.
Несмотря на описанную тенденцию в программировании приложений, большая часть кода для UNIX и Linux по-прежнему создается на системном уровне. Этот код написан преимущественно на C и C++ и существует в основном на базе интерфейсов, предоставляемых библиотекой C и ядром. Это традиционное системное программирование с применением Apache, bash,cp, Emacs, init,gcc,gdb,glibc,ls,mv, vim и X. В обозримом будущем эти приложения не сойдут со сцены.
К области системного программирования часто относят и разработку ядра или как минимум написание драйверов устройств. Однако эта книга, как и большинство работ по системному программированию, никак не касается разработки ядра. Ее основной фокус — системное программирование для пользовательского пространства, то есть уровень, который находится выше ядра. Тем не менее знания о ядре будут полезным дополнительным багажом при чтении последующего текста. Написание драйверов устройств — это большая и объемная тема, которая подробно описана в книгах, посвященных конкретно данному вопросу.
Что такое системный интерфейс и как я пишу системные приложения для Linux? Что именно при этом мне предоставляют ядро и библиотека C? Как мне удается создавать оптимальный код, какие приемы возможны в Linux? Какие интересные системные вызовы есть в Linux, но отсутствуют в других UNIX-подобных системах? Как все это работает? Именно эти вопросы составляют суть данной книги.
Краеугольные камни системного программирования
В системном программировании для Linux можно выделить три основных краеугольных камня: системные вызовы, библиотеку C и компилятор C. О каждом из этих феноменов следует рассказать отдельно.
Системные вызовы
Системные вызовы — это начало и конец системного программирования. Системные вызовы (в англоязычной литературе встречается сокращение syscall) — это вызовы функций, совершаемые из пользовательского пространства. Они направлены из приложений (например, текстового редактора или вашей любимой игры) к ядру. Смысл системного вызова — запросить у операционной системы определенную службу или ресурс. Системные вызовы включают как всем знакомые операции, например read() и write(), так и довольно экзотические, в частности get_thread_area() и set_tid_address().
В Linux реализуется гораздо меньше системных вызовов, чем в ядрах большинства других операционных систем. Например, в системах с архитектурой x86-64 таких вызовов насчитывается около 300 — сравните это с Microsoft Windows, где предположительно задействуются тысячи подобных вызовов. При работе с ядром Linux каждая машинная архитектура (например, Alpha, x86-64 или PowerPC) может дополнять этот стандартный набор системных вызовов своими собственными. Следовательно, системные вызовы, доступные в конкретной архитектуре, могут отличаться от доступных в другой. Тем не менее значительное подмножество всех системных вызовов — более 90 % — реализуется во всех архитектурах. К этим разделяемым 90 % относятся и общие интерфейсы, о которых мы поговорим в данной книге.
Активация системных вызовов. Невозможно напрямую связать приложения пользовательского пространства с пространством ядра. По причинам, связанным с обеспечением безопасности и надежности, приложениям пользовательского пространства нельзя разрешать непосредственно исполнять код ядра или манипулировать данными ядра. Вместо этого ядро должно предоставлять механизм, с помощью которого пользовательские приложения будут «сигнализировать» ядру о требовании активировать системный вызов. После этого приложение сможет осуществить системное прерывание ядра (trap) в соответствии с этим строго определенным механизмом и выполнить только тот код, который разрешит выполнить ядро. Детали этого механизма в разных архитектурах немного различаются. Например, в процессорах i386 пользовательское приложение выполняет инструкцию программного прерывания int со значением 0x80. Эта инструкция осуществляет переключение на работу с пространством ядра — защищенной областью, — где ядром выполняется обработчик программного прерывания. Что же такое обработчик прерывания 0x80? Это не что иное, как обработчик системного вызова!
Приложение сообщает ядру, какой системный вызов требуется выполнить и с какими параметрами. Это делается посредством аппаратных регистров. Системные вызовы обозначаются по номерам, начиная с 0. В архитектуре i386, чтобы запросить системный вызов 5 (обычно это вызов open()), пользовательское приложение записывает 5 в регистр eax, после чего выдает инструкцию int.
Передача параметров обрабатывается схожим образом. Так, в архитектуре i386 регистр применяется для всех возможных параметров — например, регистры ebx, ecx, edx, esi и edi в таком же порядке содержат первые пять параметров. В редких случаях, когда системный вызов имеет более пяти параметров, всего один регистр применяется для указания на буфер в пользовательском пространстве, где хранятся все эти параметры. Разумеется, у большинства системных вызовов имеется всего пара параметров.
В других архитектурах активация системных вызовов обрабатывается иначе, хотя принцип остается тем же. Вам, как системному программисту, обычно не нужно знать, как именно ядро обрабатывает системные вызовы. Эта информация уже интегрирована в стандартные соглашения вызова, соблюдаемые в конкретной архитектуре, и автоматически обрабатывается компилятором и библиотекой C.
Библиотека C
Библиотека C (libc) — это сердце всех приложений UNIX. Даже если вы программируете на другом языке, то библиотека C, скорее всего, при этом задействуется. Она обернута более высокоуровневыми библиотеками и предоставляет основные службы, а также способствует активации системных вызовов. В современных системах Linux библиотека C предоставляется в форме GNUlibc, сокращенно glibc (произносится как «джи-либ-си», реже «глиб-си»).
Библиотека GNU C предоставляет гораздо больше возможностей, чем может показаться из ее названия. Кроме реализации стандартной библиотеки C, glibc дает обертки для системных вызовов, поддерживает работу с потоками и основные функции приложений.
КомпиляторC
В Linux стандартный компилятор языка C предоставляется в форме коллекции компиляторов GNU (GNU Compiler Collection, сокращенно gcc). Изначально gcc представляла собой версию cc (компилятора C) для GNU. Соответственно gcc расшифровывалась как GNU C Compiler. Однако впоследствии добавилась поддержка других языков, поэтому сегодня gcc служит общим названием всего семейства компиляторов GNU. При этом gcc — это еще и двоичный файл, используемый для активации компилятора C. В этой книге, говоря о gcc, я, как правило, имею в виду программу gcc, если из контекста не следует иное.
Компилятор, используемый в UNIX-подобных системах, в частности в Linux, имеет огромное значение для системного программирования, поскольку помогает внедрять стандарт языка C (см. подразд. «Стандарты языка С» разд. «Стандарты» данной главы), а также системный двоичный интерфейс приложений (см. разд. «API и ABI» текущей главы), о которых будет рассказано далее.
С++
В этой главе речь пойдет в основном о языке C — лингва франка системного программирования. Однако C++ также играет важную роль.
В настоящее время C++ уступил ведущие позиции в системном программировании своему старшему собрату C. Исторически разработчики Linux всегда отдавали C предпочтение перед C++: основные библиотеки, демоны, утилиты и, разумеется, ядро Linux написаны на C. Влияние C++ как «улучшенного C» в большинстве «нелинуксовых» систем можно назвать каким угодно, но не универсальным, поэтому в Linux C++ также занимает подчиненное положение относительно C.
Тем не менее далее в тексте в большинстве случаев вы можете заменять «C» на «С++». Действительно, C++ — отличная альтернатива C, подходящая для решения практически любых задач в области системного программирования. C++ может связываться с кодом на C, активизировать системные вызовы Linux, использовать glibc.
При написании на C++ в основу системного программирования закладывается еще два краеугольных камня — стандартная библиотека C++ и компилятор GNUC++. Стандартная библиотека C++ реализует системные интерфейсы C++ и использует стандарт ISOC++ 11. Он обеспечивается библиотекой libstdc++ (иногда используется название libstdcxx). Компилятор GNUC++ — это стандартный компилятор для кода на языке C++ в системах Linux. Он предоставляется в двоичном файле g++.
API и ABI
Разумеется, программист заинтересован, чтобы его код работал на всех системах, которые планируется поддерживать, как в настоящем, так и в будущем. Хочется быть уверенными, что программы, создаваемые на определенном дистрибутиве Linux, будут работать на других дистрибутивах, а также иных поддерживаемых архитектурах Linux и более новых (а также ранних) версиях Linux.
На системном уровне существует два отдельных множества определений и описаний, которые влияют на такую переносимость. Одно из этих множеств называется интерфейсом программирования приложений (Application Programming Interface, API), а другое — двоичным интерфейсом приложения (Application Binary Interface, ABI). Обе эти концепции определяют и описывают интерфейсы между различными компонентами программного обеспечения.
API
API определяет интерфейсы, на которых происходит обмен информацией между двумя компонентами программного обеспечения на уровне исходного кода. API обеспечивает абстракцию, предоставляя стандартный набор интерфейсов — как правило, это функции, — которые один программный компонент (обычно, но не обязательно это более высокоуровневый компонент из пары) может вызывать из другого (обычно более низкоуровневого). Например, API может абстрагировать концепцию отрисовки текста на экране с помощью семейства функций, обеспечивающих все необходимые аспекты для отрисовки текста. API просто определяет интерфейс; тот компонент программы, который обеспечивает работу API, обычно называется реализацией этого API.
API часто называют «контрактом». Это неверно как минимум в юридическом смысле этого слова, поскольку API не имеет ничего общего с двусторонним соглашением. Пользователь API (обычно более высокоуровневая программа) располагает нулевым входным сигналом для данного API и реализацией этой сущности. Пользователь может применять API «как есть» или не использовать его вообще: возьми или не трогай! Задача API — просто гарантировать, что, если оба компонента ПО воспользуются этим API, они будут совместимы на уровне исходного кода. Это означает, что пользователь API сможет успешно скомпилироваться с зависимостью от реализации этого API.
Практическим примером API служат интерфейсы, определенные в соответствии со стандартом C и реализуемые стандартной библиотекой C. Этот API определяет семейство простейших и критически важных функций, таких как процедуры для управления памятью и манипуляций со строками.
На протяжении всей книги мы будем опираться на разнообразные API, например стандартную библиотеку ввода-вывода, которая будет подробно рассмотрена в гл. 3. Самые важные API, используемые при системном программировании в Linux, описаны в разд. «Стандарты» данной главы.
ABI
Если API определяет интерфейсы в исходном коде, то ABI предназначен для определения двоичного интерфейса между двумя и более программными компонентами в конкретной архитектуре. ABI определяет, как приложение взаимодействует с самим собой, с ядром и библиотеками. В то время как API обеспечивает совместимость на уровне исходного кода, ABI отвечает за совместимостьна двоичном уровне. Это означает, что фрагмент объектного кода будет функционировать в любой системе с таким же ABI без необходимости перекомпиляции.
ABI помогают решать проблемы, связанные с соглашениями на уровне вызовов, порядком следования байтов, использованием регистров, активацией системных вызовов, связыванием, поведением библиотек и форматом двоичных объектов. Например, соглашения на уровне вызовов определяют, как будут вызываться функции, как аргументы передаются функциям, какие регистры сохраняются, а какие — искажаются, как вызывающая сторона получает возвращаемое значение.
Несколько раз предпринимались попытки определить единый ABI для многих операционных систем, взаимодействующих с конкретной архитектурой (в частности, для различных UNIX-подобных систем, работающих на i386), но эти усилия не увенчались какими-либо заметными успехами. Напротив, в операционных системах, в том числе Linux, сохраняется тенденция к определению собственных ABI по усмотрению разработчиков. ABI тесно связаны с архитектурой; абсолютное большинство ABI оперирует машинно-специфичными концепциями, в частности Alpha или x86-64. Таким образом, ABI является как элементом операционной системы (например, Linux), так и элементом архитектуры (допустим, x86-64).
Системные программисты должны ориентироваться в ABI, но запоминать их обычно не требуется. Структура ABI определяется цепочкой инструментов — компилятором, компоновщиком и т. д. — и никак иначе обычно не проявляется. Однако знание ABI положительно сказывается на качестве программирования, а также требуется при написании ассемблерного кода или разработке самой цепочки инструментов (последняя — классический пример системного программирования).
Стандарты
Системное программирование для UNIX — старинное искусство. Основы программирования для UNIX остаются незыблемыми в течение десятилетий. Однако сами системы UNIX развиваются достаточно динамично. Поведение изменяется — добавляются новые возможности. Чтобы как-то справиться с хаосом, целые группы, занятые стандартизацией, кодифицируют системные интерфейсы в специальных официальных документах. Существует множество таких стандартов, но фактически Linux официально не подчиняется каким-либо из них. Linux просто стремится соответствовать двум наиболее важным и превалирующим стандартам — POSIX и Single UNIX Specification (SUS, единая спецификация UNIX).
В POSIX и SUS, в частности, документирован API языка C для интерфейса, обеспечивающего взаимодействие с UNIX-подобными операционными системами. Фактически эти стандарты определяют системное программирование или как минимум его общее подмножество для UNIX-совместимых систем.
История POSIXиSUS
В середине 1980-х годов Институт инженеров по электротехнике и электронике (Institute of Electrical and Electronics Engineers, IEEE) возглавил начинания по стандартизации системных интерфейсов в UNIX-подобных операционных системах. Ричард Столлман (Richard Stallman), основатель движения Free Software, предложил назвать этот стандарт POSIX (произносится «пазикс», Portable Operating System Interface — интерфейс переносимых операционных систем UNIX).
Первым результатом этой работы, обнародованным в 1988 году, стал стандарт IEEE Std 1003.1-1988 (сокращенно POSIX 1988). В 1990 году IEEE пересмотрел стандарт POSIX, выпустив новую версию IEEE Std 1003.1-1990 (POSIX 1990). Необязательная поддержка работы в реальном времени и потоков была документирована соответственно в стандартах IEEE Std 1003.1b-1993 (POSIX 1993 или POSIX.1b) и IEEE Std 1003.1c-1995 (POSIX 1995 или POSIX.1c). В 2001 году необязательные стандарты были объединены с базовым POSIX 1990, образовав единый стандарт IEEE Std 1003.1-2001 (POSIX 2001). Последняя на этот момент версия была выпущена в декабре 2008 года и называется IEEE Std 1003.1-2008 (POSIX 2008). Все основные стандарты POSIX сокращаются до аббревиатур вида POSIX.1, версия от 2008 года является новейшей.
В конце 1980-х — начале 1990-х годов между производителями UNIX-подобных систем бушевали настоящие «юниксовые войны»: каждый старался закрепить за своим продуктом статус единственной настоящей UNIX-системы. Несколько крупных производителей сплотились вокруг The Open Group — промышленного консорциума, сформировавшегося в результате слияния Open Software Foundation (OSF) и X/Open. The Open Group стала заниматься сертификацией, публикацией научных статей и тестированием соответствия. В начале 1990-х годов, когда «юниксовые войны» были в самом разгаре, The Open Group выпустила Единую спецификацию UNIX (SUS). Популярность SUS быстро росла, во многом благодаря тому, что она была бесплатной, а стандарт POSIX оставался дорогостоящим. В настоящее время SUS включает в себя новейший стандарт POSIX.
Первая версия SUS была опубликована в 1994 году. Затем последовали пересмотренные версии, выпущенные в 1997-м (SUSv2) и 2002 году (SUSv3). Последний вариант SUS, SUSv4, был опубликован в 2008 году. В SUSv4 пересмотрен стандарт IEEE Std 1003.1-2008, объединяемый в рамках этой спецификации с несколькими другими стандартами. В данной книге я буду делать оговорки, когда системные вызовы и другие интерфейсы стандартизируются по POSIX. Стандартизацию по SUS я отдельно указывать не буду, так как SUS входит в состав POSIX.
Стандарты языка C
Знаменитая книга Денниса Ричи (Dennis Ritchie) и Брайана Кернигана (Brian Kernighan) TheCProgramming Language, впервые опубликованная в 1978 году, в течение многих лет использовалась как неофициальная спецификация языка C. Эта версия C была известна в кругах специалистов под названием K&R C. Язык C уже стремительно заменял BASIC и другие языки того времени, превращаясь в лингва франка микрокомпьютерного программирования, поэтому в 1983 году Американский национальный институт стандартов (ANSI) сформировал специальный комитет. Этот орган должен был разработать официальную версию C и стандартизировать самый популярный на тот момент язык программирования. Новая версия включала в себя разнообразные доработки и усовершенствования, сделанные различными производителями, а также новый язык C++. Это был долгий и трудоемкий процесс, но к 1989 году версия ANSIC была готова. В 1990 году Международная организация по стандартизации (ISO) ратифицировала стандарт ISO C90, основанный на ANSI C с небольшими модификациями.
В 1995 году ISO выпустила обновленную (но редко используемую) версию языка C, которая называется ISO C95. В 1999 году последовала новая, значительно пересмотренная версия языка — ISO C99. В ней множество нововведений, в частности внутристрочные функции, новые типы данных, массивы переменной длины, комментарии в стиле C++ и новые библиотечные функции. Последняя версия этого стандарта называется ISO C11, в которой следует отметить формализованную модель памяти. Она обеспечивает переносимость при использовании потоков в многоплатформенной среде.
Что касается C++, ISO-стандартизация этого языка протекала медленнее. В 1998 году после долгих лет разработки и выпуска компилятора, не обладавшего прямой совместимостью, был ратифицирован первый стандарт C, ISO C98. Он значительно улучшил совместимость между различными компиляторами, однако некоторые аспекты этого стандарта ограничивали согласованность и переносимость. Стандарт ISOC++03 появился в 2003 году. В нем были исправлены некоторые ошибки, а также добавлены изменения, облегчившие работу разработчикам компиляторов, но незаметные на пользовательском уровне. Следующий стандарт ISO, самый актуальный в настоящее время, называется C++11 (ранее он обозначался как C++0x, поскольку не исключалась более ранняя дата выхода). В этой версии появилось множество дополнений как на уровне языка, так и в стандартных библиотеках. На самом деле их оказалось настолько большое количество, что многие даже считают язык C++11 совершенно самостоятельным, независимым от более ранних версий C++.
Linux и стандарты
Как было сказано выше, Linux стремится соответствовать стандартам POSIX и SUS. В Linux предоставляются интерфейсы, документированные в SUSv4 и POSIX 2008, а также поддерживается работа в реальном времени (POSIX.1b) и работа с потоками (POSIX.1c). Гораздо важнее, что Linux стремится работать в соответствии с требованиями POSIX и SUS. В принципе, любое несоответствие стандартам является ошибкой. Считается, что Linux также соответствует POSIX.1 и SUSv3, но, поскольку никакой официальной сертификации POSIX или SUS не проводилось (в частности, во всех существующих версиях Linux), нельзя сказать, что Linux официально соответствует POSIX или SUS.
Что касается языковых стандартов, в Linux все хорошо. Компилятор gccязыка C соответствует стандарту ISO C99; планируется обеспечить поддержку C11. Компилятор g++языка C++ соответствует стандарту ISO C++03, поддержка стандарта C++11 находится в разработке. Кроме того, компиляторы gcc и g++_реализуют расширения для языков C и C++. Все эти расширения объединяются под общим названием GNU C и документированы в приложении А.
Linux не может похвастаться большими достижениями в области обеспечения прямой совместимости1, хотя сегодня и в этой области ситуация значительно улучшилась. Интерфейсы, документированные в соответствии со стандартами, в частности стандартная библиотека C, очевидно, навсегда останутся совместимыми на уровне исходников. Двоичная совместимость поддерживается как минимум на уровне основной, крупной версии glibc, а поскольку язык C стандартизирован, gcc всегда будет компилировать код, написанный на правильном языке C. Правда, различные специфичные расширения gccмогут устаревать и в конце концов исчезать из новых релизов gcc. Важнее всего, что ядро Linux гарантирует стабильность системных вызовов. Если системный вызов реализован в стабильной версии ядра Linux, можно быть уверенными, что такой вызов точно сработает.
В различных дистрибутивах Linux многие компоненты операционной системы определяются в LSB (Linux Standard Base). LSB — это совместный проект нескольких производителей Linux, проводящийся под эгидой Linux Foundation (ранее эта организация называлась Free Standards Group). LSB дополняет POSIX и SUS, а также добавляет собственные стандарты. Организация стремится предоставить двоичный стандарт, позволяющий в неизменном виде выполнять объектный код в системах, отвечающих этому стандарту. Большинство производителей Linux в той или иной степени придерживаются LSB.
Стандартыиэта книга
В данной книге я намеренно стараюсь не разглагольствовать о каком-либо стандарте. Слишком часто авторы книг по системному программированию для UNIX излишне увлекаются сравнениями, как интерфейс работает по одним стандартам и как по другим, как конкретный системный вызов реализован в той или иной системе, — словом, льют воду. Эта книга посвящена именно системному программированию в современных вариантах Linux, в которых используются новейшие версии ядра Linux (3.9), компилятора gcc (4.8) и библиотеки C (2.17).
Системные интерфейсы можно считать практически неизменными — разработчики ядра Linux проделали огромную работу, чтобы, например, никогда не пришлось ломать интерфейсы системных вызовов. Эти интерфейсы обеспечивают известный уровень совместимости на уровне исходного кода и двоичном уровне, поэтому выбранный в данной книге подход позволяет подробно рассмотреть детали системных интерфейсов Linux, абстрагируясь от проблем совместимости с многочисленными другими UNIX-подобными системами и не думая о соответствии всем стандартам. Мы будем говорить только о Linux, поэтому можем позволить себе подробно остановиться на ультрасовременных интерфейсах этой операционной системы, которые, несомненно, останутся востребованными и действующими в обозримом будущем. В основу книги положены глубокие знания Linux, информация о реализации и поведении таких компонентов, как ядро и gcc. Эту работу можно считать повествованием разработчика-ветерана, полным проверенных методов и советов по оптимизации.
Концепции программирования в Linux
В этом разделе вы найдете краткий обзор сервисов, предоставляемых в системе Linux. Все UNIX-подобные системы, включая Linux, предлагают общий набор абстракций и интерфейсов. На самом деле в этой взаимосовместимости и заключается суть UNIX. Такие абстракции, как файл и процесс, интерфейсы для управления конвейерами и сокетами и т. д., являются главной составляющей систем UNIX.
Этот обзор предполагает, что вы знакомы с системой Linux. Имеется в виду, что вы умеете обращаться с командной оболочкой, использовать базовые команды и компилировать простую программу на C. Это не обзор Linux или системы для программирования в ней, а рассказ об основах системного программирования Linux.
Файлы и файловая система
Файл — это самая простая и базовая абстракция в Linux. Linux придерживается философии «все есть файл», пусть и не так строго, как некоторые другие системы — достаточно вспомнить Plan 92. Следовательно, многочисленные взаимодействия представляют собой считывание из файлов и запись в них, даже если объект, с которым вы имеете дело, совсем не похож на «традиционный» файл.
Вообще, чтобы получить доступ к файлу, его сначала нужно открыть. Файлы можно открывать для чтения, записи или того и другого сразу. На открытый файл указывает уникальный дескриптор, отображающий метаданные, ассоциированные с открытым файлом, обратно на сам этот файл. В ядре Linux такой дескриптор управляется целым числом (в системе типов C целому числу соответствует тип int). Эта сущность, называемая файловым дескриптором, сокращенно обозначается fd. Дескрипторы файлов совместно используются в системе и пользовательском пространстве. Пользовательские программы применяют их непосредственно для доступа к файлам. Значительная часть системного программирования в Linux сводится к открытию файлов, манипуляциям с ними, закрытию файлов и использованию файловых дескрипторов иными способами.
Обычные файлы
Сущности, которые большинству из нас известны под названием «файлы», в Linux именуются обычными файлами. Обычный файл содержит байты данных, организованные в виде линейного массива, который называется потоком байтов. В Linux для файла не задается никаких других видов упорядочения или форматирования. Байты могут иметь любые значения и быть организованы внутри файла любыми способами. На системном уровне Linux не регламентирует для файлов никакой структуры, кроме организации в виде потока байтов. В некоторых операционных системах, например VMS, используются высокоструктурированные файлы, в которых применяются так называемые записи. В Linux такие записи отсутствуют.
Любые байты внутри файла могут использоваться для считывания или записи. Эти операции всегда начинаются с указанного байта, который можно назвать местоположением в файле. Это местоположение называется файловой позицией или смещением файла. Файловая позиция — это важнейший элемент метаданных, который ядро ассоциирует с каждым открытием файла. Когда файл открывается впервые, его файловая позиция равна нулю. Обычно по мере того, как байты из файла считываются либо в него записывается информация (байт за байтом), значение файловой позиции увеличивается. Файловую позицию можно также вручную устанавливать в определенное значение, причем оно может находиться даже за пределами (за последним байтом) конкретного файла. Когда файловая позиция находится за пределами файла, промежуточные байты будут заполняться нулями. Вполне возможно воспользоваться этим способом и задать файловую позицию дальше конца файла, однако вы никак не сможете установить эту позицию перед началом файла. Правда, такая практика кажется бессмысленной и действительно она почти не применяется. Файловая позиция начинается с нуля; она не может иметь отрицательное значение. При записи в байт в середине файла значение, которое ранее находилось по этому смещению, заменяется новым, поэтому вы не сможете расширить файл, записывая информацию в его середину. Как правило, запись в файл происходит в его конце. Максимальное значение файловой позиции ограничено только размером типа C, используемого для хранения файла. В современных системах Linux максимальное значение этого параметра равно 64 бит.
Размер файла измеряется в байтах и называется его длиной. Можно сказать, что длина — это просто количество байтов в линейном массиве, составляющем файл. Длину файла можно изменить с помощью операции, которая называется усечением. Файл можно укоротить, уменьшив его размер по сравнению с исходным. В результате будут удалены байты, расположенные в конце файла. Термин «усечение» немного неудачный, поскольку им обозначается и удлинение файла, то есть увеличение его размера по сравнению с исходным. В таком случае новые байты (добавляемые в конце файла) заполняются нулями. Файл может быть пуст (иметь нулевую длину) и, соответственно, не содержать ни одного валидного байта. Максимальная длина файла, как и файловая позиция, ограничена лишь размерами тех типов C, которые применяются ядром Linux для управления файлами. Однако в конкретных файловых системах могут действовать собственные ограничения, из-за которых потолок длины файла существенно снижается.
Отдельно взятый файл можно одновременно открыть несколько раз как в ином, так и в том же самом процессе. Каждому открытому экземпляру файла присваивается уникальный дескриптор. С другой стороны, процессы могут совместно использовать свои файловые дескрипторы, один дескриптор может применяться в нескольких процессах. Ядро не накладывает никаких ограничений на параллельный доступ к файлу. Множественные процессы вполне могут одновременно считывать информацию из файла и записывать туда новые данные. Результаты такой параллельной работы зависят от упорядочения отдельных операций и, в принципе, непредсказуемы. Программы пользовательского пространства обычно должны взаимно координироваться, чтобы обеспечить правильную синхронизацию параллельных обращений к файлам.
Хотя доступ к файлам обычно осуществляется по их именам, непосредственная связь файла с его названием отсутствует. В действительности ссылка на файл выполняется по индексному дескриптору3. Этому дескриптору присваивается целочисленное значение, уникальное для файловой системы (но не обязательно уникальное во всей системе в целом). Данное значение называется номером индексного дескриптора. В индексном дескрипторе сохраняются метаданные, ассоциированные с файлом, например отметка о времени его последнего изменения, владелец файла, тип, длина и местоположение данных файла, но имя файла там не сохраняется! Индексный дескриптор одновременно является и физическим объектом, расположенным на диске в UNIX-подобной файловой системе, и концептуальной сущностью, представленной как структура данных в ядре Linux.
Каталоги и ссылки
Обращение к файлам по их индексным дескрипторам — довольно трудоемкий процесс (а также потенциальная брешь в системе безопасности), поэтому из пользовательского пространства файлы обычно вызываются по имени, а не по индексному дескриптору. Для предоставления имен, по которым можно обращаться к файлам, используются каталоги. Каталог представляет собой отображение понятных человеку имен в номера индексных дескрипторов. Пара, состоящая из имени и индексного дескриптора, называется ссылкой. Физическая форма этого отображения, присутствующая на диске, например простая таблица или хеш, реализуется и управляется кодом ядра, поддерживающим конкретную файловую систему. В принципе, каталог ничем не отличается от обычного файла, за исключением того, что в нем содержатся лишь отображения имен в индексные дескрипторы. Ядро непосредственно пользуется этими отображениями для разрешения имен в индексные дескрипторы.
Когда из пользовательского пространства приходит запрос на открытие файла с указанным именем, ядро открывает каталог, в котором содержится файл с таким названием, и ищет данное имя. По имени файла ядро получает номер его индексного дескриптора. По этому номеру находится сам индексный дескриптор. Индексный дескриптор содержит метаданные, ассоциированные с файлом, в частности информацию о том, в каком именно фрагменте диска записаны данные этого файла.
Сначала на диске присутствует лишь один корневой каталог. К нему обычно ведет путь /. Однако, как известно, в любой системе, как правило, множество каталогов. Как ядро узнает, в каком именно нужно искать файл с заданным именем?
Выше мы говорили о том, что каталоги во многом похожи на обычные файлы. Действительно, с ними даже ассоциированы свои индексные дескрипторы, поэтому ссылки внутри каталогов могут указывать на индексные дескрипторы, находящиеся в других каталогах. Это означает, что одни каталоги можно вкладывать в другие, образуя иерархические структуры, что, в свою очередь, позволяет использовать полные путикэлементам, знакомые каждому пользователю UNIX, например /home/blackbeard/concorde.png.
Когда мы запрашиваем у ядра открытие подобного пути к файлу, оно обходит все записи каталогов, указанные в пути к элементу. Так удается найти индексный дескриптор следующей записи. В предыдущем примере ядро начинает работу с /, получает индексный дескриптор home, идет туда, получает индексный дескриптор blackbeard, идет туда и, наконец, получает индексный дескриптор concorde.png. Эта операция называется разрешением каталога или разрешением путикэлементу. Кроме того, в ядре Linux используется кэш, называемый кэшем каталогов. В кэше каталогов сохраняются результаты разрешения каталогов, впоследствии обеспечивающие более быстрый поиск с учетом временной локальности4.
Если имя пути начинается с корневого каталога, говорят, что путь полностью уточнен. Его называют абсолютным путемкэлементу. Некоторые имена путей уточнены не полностью, а указываются относительно какого-то другого каталога (например, todo/plunder). Такие пути называются относительными. Если ядру предоставляется относительный путь, то оно начинает разрешение пути с текущего рабочего каталога. Отсюда ядро ищет путь к каталогу todo. В каталоге todo ядро получает индексный дескриптор plunder. В результате комбинации относительного пути к элементу и пути к текущему рабочему каталогу получается полностью уточненный путь.
Хотя каталоги и воспринимаются как обычные файлы, ядро не позволяет их открывать и производить с ними те же манипуляции, что и с обычными файлами. Для работы с каталогами используется специальный набор системных вызовов. Эти системные вызовы предназначаются для добавления и удаления ссылок — в принципе, на этом перечень разумных операций с каталогами заканчивается. Если бы можно было манипулировать каталогами прямо из пользовательского пространства, без посредничества ядра, то единственной простой ошибки хватило бы для повреждения всей файловой системы.
Жесткие ссылки
С учетом всего вышесказанного ничто вроде бы не препятствует разрешению множества имен в один и тот же индексный дескриптор. Действительно, это допускается. Когда множественные ссылки отображают различные имена на один и тот же индексный дескриптор, эти ссылки называются жесткими.
Благодаря жестким ссылкам в файловых системах обеспечивается создание сложных структур, где множественные имена путей могут указывать на одни и те же данные. Эти жесткие ссылки могут находиться в одном каталоге, а также в двух и более различных каталогах. В любом случае ядро просто разрешает имя пути в верный индексный дескриптор. Например, можно поставить жесткую ссылку на конкретный индексный дескриптор, ссылающийся на определенный фрагмент данных из двух мест — /home/bluebeard/treasure.txt и /home/blackbeard/to_steal.txt.
При удалении файла он отсоединяется от структуры каталогов. Для этого нужно просто удалить из каталога пару, в которой содержится имя файла и его индексный дескриптор. Однако, поскольку в Linux поддерживаются жесткие ссылки, файловая система не может просто уничтожать индексный дескриптор и ассоциированные с ним данные при каждой операции удаления. Что, если на этот файл были проставлены и другие жесткие ссылки из файловой системы? Чтобы гарантировать, что файл не будет уничтожен, пока не исчезнут все указывающие на него жесткие ссылки, в каждом индексном дескрипторе содержится счетчик ссылок, отслеживающий количество ссылок в файловой системе, указывающих на этот дескриптор. Когда путь к элементу отсоединяется от файловой системы, значение этого счетчика уменьшается на 1. Лишь если значение счетчика ссылок достигает нуля, и индексный дескриптор, и ассоциированные с ним данные окончательно удаляются из файловой системы.
Символьные ссылки
Жесткие ссылки не могут связывать файловые системы, поскольку номер индексного дескриптора не имеет смысла вне его собственной файловой системы. Чтобы ссылки могли соединять информацию из различных файловых систем, становясь при этом и более простыми, и менее прозрачными, в системах UNIX применяются так называемые символьные ссылки.
Символьные ссылки похожи на обычные файлы. Такая ссылка имеет свой индексный дескриптор и ассоциированный с ним фрагмент данных, содержащий полное имя пути к связанному файлу. Таким образом, символьные ссылки могут указывать куда угодно, в том числе на файлы и каталоги, расположенные в иных файловых системах, и даже на несуществующие файлы и каталоги. Символьная ссылка, указывающая на несуществующий файл, называется сломанной.
C использованием символьных ссылок связано больше издержек, чем при работе с жесткими ссылками, так как символьная ссылка, в сущности, требует разрешения двух файлов: самой символьной ссылки и связанного с ней файла. При использовании жестких ссылок такие дополнительные затраты отсутствуют — нет разницы между обращениями к файлам, обладающим одной связью в файловой системе либо несколькими связями. Издержки при работе с символьными ссылками минимальны, но тем не менее они воспринимаются отрицательно.
Кроме того, символьные ссылки менее прозрачны, чем жесткие. Использование жестких ссылок — совершенно очевидный процесс. Более того, не так просто найти файл, на который проставлено несколько жестких ссылок! Для манипуляций же с символьными ссылками требуются специальные системные вызовы. Эта непрозрачность зачастую воспринимается как положительный момент, так как в символьной ссылке ее структура выражается открытым текстом. Символьные ссылки используются именно как инструменты быстрого доступа (ярлыки), а не как внутрисистемные ссылки.
Специальные файлы
Специальные файлы — это объекты ядра, представленные в виде файлов. С годами в системах UNIX накопилось множество типов поддерживаемых специальных файлов. В Linux поддерживается четыре типа таких файлов: файлы блочных устройств, файлы символьных устройств, именованные каналы5 и доменные сокеты UNIX. Специальные файлы обеспечивают возможности встраивания определенных абстракций в файловую систему и, таким образом, поддерживают парадигму «все есть файл». Для создания специального файла в Linux предоставляется специальный системный вызов.
Доступ к устройствам в системах UNIX осуществляется через файлы устройств, которые выглядят и действуют как обычные файлы, расположенные в файловой системе. Файлы устройств можно открывать, считывать из них информацию и записывать ее в них. Из пользовательского пространства можно получать доступ к файлам устройств и манипулировать устройствами в системе (как физическими, так и виртуальными). Как правило, все устройства в UNIX подразделяются на две группы — символьные устройства и блочные устройства. Каждому типу устройства соответствует свой специальный файл устройства.
Доступ к символьному устройству осуществляется как к линейной последовательности байтов. Драйвер устройства ставит байты в очередь, один за другим, а программа из пользовательского пространства считывает байты в порядке, в котором они были помещены в очередь. Типичным примером символьного устройства является клавиатура. Если пользователь наберет на клавиатуре последовательность peg, то приложению потребуется считать из файла-устройства клавиатуры сначала p, потом e и, наконец, g — именно в таком порядке. Когда больше не остается символов, которые необходимо прочитать, устройство возвращает «конец файла» (EOF). Если какой-то символ будет пропущен или символы будут прочтены в неправильном порядке, то операция получится фактически бессмысленной. Доступ к символьным устройствам происходит через файлы символьных устройств.
Напротив, доступ к блочному устройству происходит как к массиву байтов. Драйвер устройства отображает массив байтов на устройство с возможностью позиционирования, и пользовательское пространство может в произвольном порядке обращаться к любым валидным байтам массива, то есть можно сначала прочитать байт 12, потом байт 7, потом опять байт 12. Блочные устройства — это обычно устройства для хранения информации. Жесткие диски, дисководы гибких дисков, CD-ROM, флэш-накопители — все это примеры блочных устройств. Доступ к ним осуществляется через файлы блочных устройств.
Именованные каналы (часто обозначаемые аббревиатурой FIFO — «первым пришел, первым обслужен») — это механизм межпроцессного взаимодействия (IPC), предоставляющего канал связи для дескриптора файла. Доступ к именованному каналу выполняется через специальный файл. Обычные конвейеры применяются именно для того, чтобы «перекачивать» вывод одной программы во ввод другой; они создаются в памяти посредством системного вызова и не существуют в какой-либо файловой системе. Именованные каналы действуют как и обычные, но обращение к ним происходит через файл, называемый специальным файлом FIFO. Несвязанные процессы могут обращаться к этому файлу и обмениваться информацией.
Последний тип специальных файлов — это сокеты. Сокеты обеспечивают усовершенствованную разновидность межпроцессного взаимодействия между двумя несвязанными процессами — не только на одной машине, но и даже на двух разных. На самом деле сокеты являются основополагающей концепцией всего программирования для сетей и Интернета. Существует множество разновидностей сокетов, в том числе доменные сокеты UNIX. Последние используются для взаимодействия на локальной машине. В то время как сокеты, обменивающиеся информацией по Интернету, могут использовать пару из хост-имени и порта для идентификации «цели» взаимодействия, доменные сокеты используют для этого специальный файл, расположенный в файловой системе. Часто его называют просто сокет-файлом.
Файловые системы и пространства имен
Linux, как и все системы UNIX, предоставляет глобальное и единое пространство имен для файлов и каталогов. В некоторых операционных системах отдельные физические и логические диски разделяются на самостоятельные пространства имен. Например, для доступа к файлу на дискете может использоваться путь A:\plank.jpg, а пути ко всем файлам на жестком диске будут начинаться с C:\. В UNIX тот же файл с дискеты может быть доступен через /media/floppy/plank.jpg или даже через /home/captain/stuff/plank.jpg, в одном ряду с файлами с других носителей. В этом и выражается единство пространства имен в UNIX.
Файловая система — это набор файлов и каталогов формальной и валидной иерархии. Файловые системы можно по отдельности добавлять к глобальному пространству имен и удалять их из этого глобального пространства файлов и каталогов. Данные операции называются монтированием и размонтированием. Каждая файловая система может быть индивидуально монтирована к конкретной точке пространства имен. Она обычно называется точкой монтирования. В дальнейшем из точки монтирования открывается доступ к корневому каталогу файловой системы. Например, CD может быть монтирован в точке /media/cdrom, в результате чего корневой каталог файловой системы компакт-диска также будет доступен через /media/cdrom. Файловая система, которая была монтирована первой, находится в корне пространства имен, /, и называется корневой файловой системой. Во всех системах Linux всегда имеется корневая файловая система. Монтирование других файловых систем в тех или иных точках в Linux не является обязательным.
Файловые системы обычно существуют физически (то есть сохраняются на диске), хотя в Linux также поддерживаются виртуальные файловые системы, существующие лишь в памяти, и сетевые файловые системы, существующие одновременно на нескольких машинах, работающих в сети. Физические файловые системы находятся на блочных устройствах хранения данных, в частности на компакт-дисках, дискетах, картах флэш-памяти, жестких дисках. Некоторые из этих устройств являются сегментируемыми — это означает, что их дисковое пространство можно разбить на несколько файловых систем, каждой из которых можно управлять отдельно. В Linux поддерживаются самые разные файловые системы, решительно любые, которые могут встретиться среднему пользователю на практике. В частности, Linux поддерживает файловые системы, специфичные для определенных носителей (например, ISO9660), сетевые файловые системы (NFS), нативные файловые системы (ext4), файловые системы из других разновидностей UNIX (XFS), а также файловые системы, не относящиеся к семейству UNIX (FAT).
Наименьшим адресуемым элементом блочного устройства является сектор. Сектор — это физический атрибут устройства. Объем секторов может быть равен различным степеням двойки, довольно распространены секторы размером 512 байт. Блочное устройство не может передавать элемент данных размером меньше, чем сектор этого устройства, а также не может получать доступ к такому мелкому фрагменту данных. При операциях ввода-вывода должны задействоваться один или несколько секторов.
Аналогично, наименьшим логически адресуемым элементом файловой системы является блок. Блок — это абстракция, применяемая в файловой системе, а не на физическом носителе, на котором эта система находится. Обычно размер блока равен степени двойки от размера сектора. В Linux блоки, как правило, крупнее сектора, но они должны быть меньше размера страницы (под страницей в модели памяти Linux понимается наименьший элемент, адресуемый блоком управления памятью — аппаратным компонентом)6. Размеры большинства блоков составляют 512 байт, 1 Кбайт и 4 Кбайт.
Исторически в системах UNIX было только одно разделяемое пространство имен, видимое для всех пользователей и всех процессов в системе. В Linux используется инновационный подход и поддерживаются пространства имен отдельных процессов. В таком случае каждый процесс может иметь уникальное представление файла системы и иерархии каталогов7. По умолчанию каждый процесс наследует пространство имен своего родительского процесса, но процесс также может создать собственное пространство имен со своим набором точек монтирования и уникальным корневым каталогом.
Процессы
Если файлы являются самой фундаментальной абстракцией системы UNIX, то следующая по важности — процесс. Процессы — это объектный код, находящийся в процессе исполнения: активные, работающие программы. Однако процессы — это не просто объектный код, так как они состоят из данных, ресурсов, состояния и виртуализованного процессора.
Процесс начинает свой жизненный цикл в качестве исполняемого объектного кода. Это код в формате, пригодном для исполнения на машине и понятный ядру. Наиболее распространенный подобный формат в Linux называется форматом исполняемых и компонуемых файлов (ELF). Исполняемый формат содержит метаданные и множество разделов с кодом и данными. Разделы — это линейные фрагменты объектного кода, именно в такой линейной форме загружаемые в память. Все байты одного раздела обрабатываются одинаково, имеют одни и те же права доступа и, как правило, используются в одних и тех же целях.
К самым важным и распространенным разделам относятся текстовый раздел, раздел данных и раздел bss. В текстовом разделе содержатся исполняемый код и данные только для чтения, в частности константные переменные. Обычно этот раздел помечается как доступный только для чтения и исполняемый. В разделе данных хранятся инициализированные данные (например, переменные C с определенными значениями). Обычно этот раздел помечается как доступный для чтения и записи. Раздел bss содержит неинициализированные глобальные данные. Стандарт C требует, чтобы все глобальные переменные C по умолчанию инициализировались нулями, поэтому нет необходимости хранить нули в объектном коде на диске. Вместо этого объектный код может просто перечислять неинициализированные переменные в разделе bss, а при загрузке раздела в память ядро отобразит на него нулевую страницу (страницу, содержащую только нули). Раздел bss задумывался исключительно в качестве оптимизации. Название bss, в сущности, является пережитком, оно означает block started by symbol (блок, начинающийся с символа). Другими примерами распространенных разделов исполняемых файлов в формате ELF являются абсолютный раздел (содержащий неперемещаемые символы) и неопределенный раздел, также называемый общей корзиной (catchall).
Кроме того, процесс ассоциирован с различными системными ресурсами, которые выделяются и управляются ядром. Как правило, процессы запрашивают ресурсы и манипулируют ими только посредством системных вызовов. К ресурсам относятся таймеры, ожидающие сигналы, открытые файлы, сетевые соединения, аппаратное обеспечение и механизмы межпроцессного взаимодействия. Ресурсы процесса, а также относящиеся к нему данные и статистика сохраняются внутри ядра в дескрипторе процесса.
Процесс — это абстракция виртуализации. Ядро Linux поддерживает как вытесняющую многозадачность, так и виртуальную память, поэтому предоставляет каждому процессу и виртуализованный процессор, и виртуализованное представление памяти. Таким образом, с точки зрения процесса система выглядит так, как будто только он ею управляет. Соответственно, даже если конкретный процесс может быть диспетчеризован наряду со многими другими процессами, он работает так, как будто он один обладает полным контролем над системой. Ядро незаметно и прозрачно вытесняет и переназначает процессы, совместно используя системные процессоры на всех работающих процессах данной системы. Процессы этого даже не знают. Аналогичным образом каждый процесс получает отдельное линейное адресное пространство, как если бы он один контролировал всю память, доступную в системе. С помощью виртуальной памяти и подкачки страниц ядро обеспечивает одновременное сосуществование сразу многих процессов в системе. Каждый процесс при этом работает в собственном адресном пространстве. Ядро управляет такой виртуализацией, опираясь на аппаратную поддержку, обеспечиваемую многими современными процессорами. Таким образом, операционная система может параллельно управлять состоянием множественных, не зависящих друг от друга процессов.
Потоки
Каждый процесс состоит из одного или нескольких потоков выполнения, обычно называемых просто потоками. Поток — это единица активности в процессе. Можно также сказать, что поток — это абстракция, отвечающая за выполнение кода и поддержку процесса в рабочем состоянии.
Большинство процессов состоят только из одного потока и именуются однопоточными. Если процесс содержит несколько потоков, его принято называть многопоточным. Традиционно программы UNIX являются однопоточными, это связано с присущей UNIX простотой, быстрым созданием процессов и надежными механизмами межпроцессного взаимодействия. По всем этим причинам многопоточность не имеет в UNIX существенного значения.
Поток состоит из стека (в котором хранятся локальные переменные этого процесса, точно как в стеке процесса, используемом в однопоточных системах), состояния процесса и актуального местоположения в объектном коде. Информация об этом местоположении обычно хранится в указателе команд процесса. Большинство остальных элементов процесса разделяются между всеми его потоками, особенно это касается адресного пространства. Таким образом, потоки совместно используют абстракцию виртуальной памяти, поддерживая абстракцию виртуализованного процессора.
На внутрисистемном уровне в ядре Linux реализуется уникальная разновидность потоков: фактически они представляют собой обычные процессы, которые по мере необходимости разделяют определенные ресурсы. В пользовательском пространстве Linux реализует потоки в соответствии со стандартом POSIX 1003.1c (также называемым Pthreads). Самый современный вариант реализации потоков в Linux именуется Native POSIX Threading Library (NPTL). Эта библиотека входит в состав glibc. Подробнее мы поговорим о потоках в гл. 7.
Иерархия процессов
Для идентификации каждого процесса применяется уникальное положительное целое число, называемое идентификатором процесса, или ID процесса (pid). Таким образом, первый процесс имеет идентификатор 1, а каждый последующий процесс получает новый, уникальный pid.
В Linux процессы образуют строгую иерархию, называемую деревом процессов. Корень дерева находится в первом процессе, называемом процессом инициализации и обычно принадлежащем программе init. Новые процессы создаются с помощью системного вызова fork(). В результате этого вызова создается дубликат вызывающего процесса. Исходный процесс называется предком, а порожденный — потомком. У каждого процесса, кроме самого первого, есть свой предок. Если родительский процесс завершается раньше дочернего (потомка), то ядро переназначает предка для потомка, делая его потомком процесса инициализации.
Когда процесс завершается, он еще какое-то время остается в системе. Ядро сохраняет фрагменты процесса в памяти, обеспечивая процессу-предку доступ к информации о процессе-потомке, актуальной на момент завершения потомка. Такое запрашивание называется обслуживанием завершенного процесса. Когда родительский процесс обслужит дочерний, последний полностью удаляется. Процесс, который уже завершился, но еще не был обслужен, называется зомби. Инициализирующий процесс по порядку обслуживает все свои дочерние процессы, гарантируя, что процессы с переназначенными родителями не останутся в состоянии зомби на неопределенный срок.
Пользователиигруппы
Авторизация в Linux обеспечивается с помощью системы пользователей и групп. Каждому пользователю присваивается уникальное положительное целое число, называемое пользовательским ID (uid). Каждый процесс, в свою очередь, ассоциируется ровно с одним uid, обозначающим пользователя, который запустил процесс. Этот идентификатор называется реальным uid процесса. Внутри ядра Linux uid является единственной концепцией, представляющей пользователя. Однако пользователи называют себя и обращаются к другим пользователям по именам пользователей (username), а не по числовым значениям. Пользовательские имена и соответствующие им uid сохраняются в каталоге /etc/passwd, а библиотечные процедуры сопоставляют предоставленные пользователями имена и соответствующие uid.
При входе в систему пользователь вводит свое имя и пароль в программу входа (login). Если эта программа получает верную пару, состоящую из логина и пароля, то login порождает пользовательскую оболочку входа, также указываемую в /etc/passwd, и делает uid оболочки равным uid вошедшего пользователя. Процессы-потомки наследуют uid своих предков.
Uid 0 соответствует особому пользователю, который называется root. Этот пользователь обладает особыми привилегиями и может делать в системе практически что угодно. Например, только root-пользователь имеет право изменить uid процесса. Следовательно, программа входа (login) работает как root.
Кроме реального uid, каждый процесс также обладает действительным uid, сохраненным uid и uid файловой системы. В то время как реальный uid процесса всегда совпадает с uid пользователя, запустившего процесс, действительный uid может изменяться по определенным правилам, чтобы процесс мог выполняться с правами, соответствующими различным пользователям. В сохраненном uid записывается исходный действительный uid; его значение используется при определении, на какие значения действительного uid может переключаться пользователь. Uid файловой системы, который, как правило, равен действительному uid, используется для верификации доступа к файловой системе.
Каждый пользователь может принадлежать к одной или нескольким группам, в частности к первичной группе, она же группа входавсистему, указанной в /etc/passwd, а также к нескольким дополнительным группам, которые перечисляются в /etc/group. Таким образом, каждый процесс ассоциируется с соответствующим групповым ID (gid) и имеет действительный gid, сохраненный gidи gid файловой системы. Обычно процессы ассоциированы с пользовательской группой входа, а не с какими-либо дополнительными группами.
Определенные проверки безопасности позволяют процессам выполнять те или иные операции лишь при условии, что процесс соответствует заданным критериям. Исторически это решение в UNIX было жестко детерминированным: процессы с uid 0 имели доступ, а остальные — нет. Сравнительно недавно в Linux эта система была заменена более универсальной, в которой используется концепция возможностей (capabilities). Вместо обычной двоичной проверки ядро, с учетом возможностей, может предоставлять доступ на базе гораздо более филигранных настроек.
Права доступа
Стандартный механизм прав доступа и безопасности в Linux остался таким же, каким изначально был в UNIX.
Каждый файл ассоциирован с пользователем-владельцем, владеющей группой и тремя наборами битов доступа. Эти биты описывают права пользователя-владельца, владеющей группы и всех остальных, связанные с чтением файла, записью в него и его исполнением. На каждый из этих трех классов действий приходится по три бита, всего имеем девять бит. Информация о владельце файла и правах хранится в индексном дескрипторе файла.
В случае с обычными файлами назначение прав доступа кажется очевидным: они регламентируют возможности открытия файла для чтения, открытия для записи и исполнения файла. Права доступа, связанные с чтением и записью, аналогичны для обычных и специальных файлов, но сама информация, которая считывается из специального файла или записывается в него, зависит от разновидности специального файла. Например, если предоставляется доступ к каталогу для чтения, это означает, что пользователь может открыть каталог и увидеть список его содержимого. Право записи позволяет добавлять в каталог новые ссылки, а право исполнения разрешает открыть каталог и ввести его название в имя пути. В табл. 1.1 перечислены все девять бит доступа, их восьмеричные значения (распространенный способ представления девяти бит), их текстовые значения (отображаемые командой ls) и их права.
Таблица 1.1.Биты доступа и их значения
Бит
Восьмеричное значение
Текстовое значение
Соответствующие права
8
400
r————
Владелец может читать
7
200
-w———-
Владелец может записывать
6
100
—x———
Владелец может исполнять
5
040
—-r——-
Члены группы могут читать
4
020
——w——
Члены группы могут записывать
3
010
——-x—-
Члены группы могут исполнять
2
004
———r—
Любой может читать
1
002
———-w-
Любой может записывать
0
001
————x
Любой может исполнять
Кроме исторически сложившихся в UNIX прав доступа, Linux также поддерживает списки контроля доступа (ACL). Они позволяют предоставлять гораздо более детализированные и точные права, соответственно, более полный контроль над безопасностью. Эти преимущества приобретаются за счет общего усложнения прав доступа и увеличения требуемого для этой информации дискового пространства.
Сигналы
Сигналы — это механизм, обеспечивающий односторонние асинхронные уведомления. Сигнал может быть отправлен от ядра к процессу, от процесса к другому процессу либо от процесса к самому себе. Обычно сигнал сообщает процессу, что произошло какое-либо событие, например возникла ошибка сегментации или пользователь нажал Ctrl+C.
Ядро Linux реализует около 30 разновидностей сигналов (точное количество зависит от конкретной архитектуры). Каждый сигнал представлен числовой константой и текстовым названием. Например, сигнал SIGHUP используется, чтобы сообщить о зависании терминала. В архитектуре x86-64 этот сигнал имеет значение 1.
Сигналы прерывают исполнение работающего процесса. В результате процесс откладывает любую текущую задачу и немедленно выполняет заранее определенное действие. Все сигналы, за исключением SIGKILL (всегда завершает процесс) и SIGSTOP (всегда останавливает процесс), оставляют процессам возможность выбора того, что должно произойти после получения конкретного сигнала. Так, процесс может совершить действие, заданное по умолчанию (в частности, завершение процесса, завершение процесса с созданием дампа, остановка процесса или отсутствие действия), — в зависимости от полученного сигнала. Кроме того, процессы могут явно выбирать, будут они обрабатывать сигнал или проигнорируют его. Проигнорированные сигналы бесшумно удаляются. Если сигнал решено обработать, выполняется предоставляемая пользователем функция, которая называется обработчиком сигнала. Программа переходит к выполнению этой функции, как только получит сигнал. Когда обработчик сигнала возвращается, контроль над программой передается обратно инструкции, работа которой была прервана. Сигналы являются асинхронными, поэтому обработчики сигналов не должны срывать выполнение прерванного кода. Таким образом, речь идет о выполнении только функций, которые безопасны для выполнениявасинхронной среде, они также называются сигналобезопасными.
Межпроцессное взаимодействие
Самые важные задачи операционной системы — это обеспечение обмена информацией между процессами и предоставление процессам возможности уведомлять друг друга о событиях. Ядро Linux реализует большинство из исторически сложившихся в UNIX механизмов межпроцессного взаимодействия. В частности, речь идет о механизмах, определенных и стандартизированных как в SystemV, так и в POSIX. Кроме того, в ядре Linux имеется пара собственных механизмов подобного взаимодействия.
К механизмам межпроцессного взаимодействия, поддерживаемым в Linux, относятся именованные каналы, семафоры, очереди сообщений, разделяемая память и фьютексы.
Заголовки
Системное программирование в Linux всецело зависит от нескольких заголовков. И само ядро, и glibcпредоставляют заголовки, применяемые при системном программировании. К ним относятся стандартный джентльменский набор C (например, <string.h>) и обычные заголовки UNIX (к примеру, <unistd.h>).
Обработка ошибок
Само собой разумеется, что проверка наличия ошибок и их обработка — задача первостепенной важности. В системном программировании ошибка характеризуется возвращаемым значением функции и описывается с помощью специальной переменной errno. glibcявно предоставляет поддержку errno как для библиотечных, так и для системных вызовов. Абсолютное большинство интерфейсов, рассмотренных в данной книге, используют для сообщения об ошибках именно этот механизм.
Функции уведомляют вызывающую сторону об ошибках посредством специального возвращаемого значения, которое обычно равно –1 (точное значение, используемое в данном случае, зависит от конкретной функции). Значение ошибки предупреждает вызывающую сторону, что возникла ошибка, но никак не объясняет, почему она произошла. Переменная errno используется для выяснения причины ошибки.
Эта переменная объявляется в <errno.h> следующим образом:
extern int errno;
Ее значение является валидным лишь непосредственно после того, как функция, задающая errno, указывает на ошибку (обычно это делается путем возвращения -1). Дело в том, что после успешного выполнения функции значение этой переменной вполне может быть изменено.
Переменная errno доступна для чтения или записи напрямую; это модифицируемое именуемое выражение. Значение errno соответствует текстовому описанию конкретной ошибки. Препроцессор #define также ассоциирует переменную errno с числовым значением. Например, препроцессор определяет EACCES равным 1 и означает «доступ запрещен». В табл. 1.2 перечислены стандартные определения и соответствующие им описания ошибок.
Таблица 1.2.Ошибки и их описание
Обозначение препроцессора
Описание
E2BIG
Список аргументов слишком велик
EACCES
Доступ запрещен
EAGAIN
Повторить попытку (ресурс временно недоступен)
EBADF
Недопустимый номер файла
EBUSY
Заняты ресурс или устройство
ECHILD
Процессы-потомки отсутствуют
EDOM
Математический аргумент вне области функции
EEXIST
Файл уже существует
EFAULT
Недопустимый адрес
EFBIG
Файл слишком велик
EINTR
Системный вызов был прерван
EINVAL
Недействительный аргумент
EIO
Ошибка ввода-вывода
EISDIR
Это каталог
EMFILE
Слишком много файлов открыто
EMLINK
Слишком много ссылок
ENFILE
Переполнение таблицы файлов
ENODEV
Такое устройство отсутствует
ENOENT
Такой файл или каталог отсутствует
ENOEXEC
Ошибка формата исполняемого файла
ENOMEM
Недостаточно памяти
ENOSPC
Израсходовано все пространство на устройстве
ENOTDIR
Это не каталог
ENOTTY
Недопустимая операция управления вводом-выводом
ENXIO
Такое устройство или адрес не существует
EPERM
Операция недопустима
EPIPE
Поврежденный конвейер
ERANGE
Результат слишком велик
EROFS
Файловая система доступна только для чтения
ESPIPE
Недействительная операция позиционирования
ESRCH
Такой процесс отсутствует
ETXTBSY
Текстовый файл занят
EXDEV
Неверная ссылка
Библиотека C предоставляет набор функций, позволяющих представлять конкретное значение errno в виде соответствующего ему текстового представления. Эта возможность необходима только для отчетности об ошибках и т. п. Проверка наличия ошибок и их обработка может выполняться на основе одних лишь определений препроцессора, а также напрямую через errno.
Первая подобная функция такова:
#include <stdio.h>
void perror (const char *str);
Эта функция печатает на устройстве stderr (standard error — «стандартная ошибка») строковое представление текущей ошибки, взятое из errno, добавляя в качестве префикса строку, на которую указывает параметр str. Далее следует двоеточие. Для большей информативности имя отказавшей функции следует включать в строку. Например:
if (close (fd) == –1)
perror ("close");
В библиотеке C также предоставляются функции strerror() и strerror_r(), прототипированные как:
#include <string.h>
char * strerror (int errnum);
и
#include <string.h>
int strerror_r (int errnum, char *buf, size_t len);
Первая функция возвращает указатель на строку, описывающую ошибку, выданную errnum. Эта строка не может быть изменена приложением, однако это можно сделать с помощью последующих вызовов perror() и strerror(). Соответственно, такая функция не является потокобезопасной.
Функция strerror_r(), напротив, является потокобезопасной. Она заполняет буфер, имеющий длину len, на который указывает buf. При успешном вызове strerror_r() возвращается 0, при сбое — –1. Забавно, но при ошибке она выдает errno.
Для некоторых функций к допустимым возвращаемым значениям относится вся область возвращаемого типа. В таких случаях перед вызовом функции errno нужно присвоить значение 0, а по завершении проверить ее новое значение (такие функции могут возвратить ненулевое значение errno, только когда действительно возникла ошибка). Например:
errno = 0;
arg = strtoul (buf, NULL, 0);
if (errno)
perror ("strtoul");
При проверке errno мы зачастую забываем, что это значение может быть изменено любым библиотечным или системным вызовом. Например, в следующем коде есть ошибка:
if (fsync (fd) == –1) {
fprintf (stderr, "fsyncfailed!\n");
if (errno == EIO)
fprintf (stderr, "I/O error on %d!\n", fd);
}
Если необходимо сохранить значение errno между вызовами нескольких функций, делаем это:
if (fsync (fd) == –1) {
const int err = errno;
fprintf (stderr, "fsync failed: %s\n", strerror (errno));
if (err == EIO) {
/* если ошибка связана с вводом-выводом — уходим */
fprintf (stderr, "I/O error on %d!\n", fd);
exit (EXIT_FAILURE);
}
}
В однопоточных программах errno является глобальной переменной, как было показано выше в этом разделе. Однако в многопоточных программах errno сохраняется отдельно в каждом потоке для обеспечения потокобезопасности.
Добро пожаловать в системное программирование
В этой главе мы рассмотрели основы системного программирования в Linux и сделали обзор операционной системы Linux с точки зрения программиста. В следующей главе мы обсудим основы файлового ввода-вывода, в частности поговорим о чтении файлов и записи информации в них. Многие интерфейсы в Linux реализованы как файлы, поэтому файловый ввод-вывод имеет большое значение для решения разных задач, а не только для работы с обычными файлами.
Итак, все общие моменты изучены, настало время приступить к настоящему системному программированию. В путь!
1 Возможно, опытные пользователи Linux помнят переход с a.out на ELF, переход с libc5 на glibc, изменения gcc, фрагментацию шаблонов ABI C++ и т. д. К счастью, эти времена давно позади.
2 Plan 9 — это операционная система, разработанная в BellLabs и часто характеризующаяся как наследница UNIX. В ней воплощено несколько инновационных идей, и она четко придерживается философии «все есть файл».
3 См.: http://ru.wikipedia.org/wiki/Inode. — Примеч. пер.
4 Временная локальность — это высокая вероятность обращения к конкретному ресурсу после другой, более ранней операции доступа к нему же. Временная локальность характерна для многих ресурсов компьютера.
5 Именованный канал называется в оригинале named pipe, более точный перевод — именованный конвейер. Тем не менее мы остановимся на варианте «именованный канал» (http://ru.wikipedia.org/wiki/Именованный_канал), как на более употребительном в русском языке, а термин pipe будем далее переводить как «конвейер». — Примеч. пер.
6 Это искусственное ограничение возможностей ядра, установленное ради обеспечения простоты с учетом, что в будущем, возможно, системы значительно усложнятся.
7 Этот подход был впервые реализован в операционной системе Plan 9 производства BellLabs.
1
Возможно, опытные пользователи Linux помнят переход с a.out на ELF, переход с libc5 на glibc, изменения gcc, фрагментацию шаблонов ABI C++ и т. д. К счастью, эти времена давно позади.
2
Plan 9 — это операционная система, разработанная в BellLabs и часто характеризующаяся как наследница UNIX. В ней воплощено несколько инновационных идей, и она четко придерживается философии «все есть файл».
3
См.: http://ru.wikipedia.org/wiki/Inode. — Примеч. пер.
4
Временная локальность — это высокая вероятность обращения к конкретному ресурсу после другой, более ранней операции доступа к нему же. Временная локальность характерна для многих ресурсов компьютера.
5
Именованный канал называется в оригинале named pipe, более точный перевод — именованный конвейер. Тем не менее мы остановимся на варианте «именованный канал» (http://ru.wikipedia.org/wiki/Именованный_канал), как на более употребительном в русском языке, а термин pipe будем далее переводить как «конвейер». — Примеч. пер.
6
Это искусственное ограничение возможностей ядра, установленное ради обеспечения простоты с учетом, что в будущем, возможно, системы значительно усложнятся.
7
Этот подход был впервые реализован в операционной системе Plan 9 производства BellLabs.
Глава 2. Файловый ввод-вывод
В этой и трех следующих главах мы поговорим о файлах. В UNIX-подобных системах много сущностей представлено именно в виде файлов, поэтому данные главы критически важны для понимания UNIX. В этой главе мы обсудим основы ввода-вывода, подробно рассмотрим системные вызовы, которые представляют простейшие и наиболее распространенные способы работы с файлами. Следующая глава посвящена стандартному вводу-выводу из библиотеки C. В гл. 4 эта тема получает дальнейшее развитие, в ней обсуждаются более сложные и специализированные интерфейсы файлового ввода-вывода. Наконец, в гл. 8 данные темы получают логическое завершение: в ней рассматриваются манипуляции, выполняемые при работе с файлами и каталогами.
Прежде чем из файла можно будет считать информацию или записать туда новые данные, файл нужно открыть. Ядро поддерживает попроцессный список открытых файлов, называемый файловой таблицей. Она индексируется с помощью неотрицательных целых чисел, называемых файловыми дескрипторами (часто они именуются сокращенно fd). Каждая запись в списке содержит информацию об открытом файле, в частности указатель на хранимую в памяти копию файлового дескриптора и ассоциированных с ним метаданных. К метаданным относятся, в частности, файловая позиция и режимы доступа. Как пользовательское пространство, так и пространство ядра пользуются файловыми дескрипторами как уникальными cookie. При открытии файла возвращается его дескриптор, а при последующих операциях (считывание информации, запись и т. д.) дескриптор файла принимается как первичный аргумент.
В языке C дескрипторы файлов относятся к типу int. Факт, что для них не выделен специальный тип, зачастую считается странным, но исторически именно такая ситуация сложилась в UNIX. Для каждого процесса Linux задается максимальное количество файлов, которые он может открыть. Файловые дескрипторы начинаются со значения 0 и могут дойти до значения, на единицу меньше максимального. По умолчанию максимальное значение равно 1024, но при необходимости его можно повысить до 1 048 576. Файловые дескрипторы не могут иметь отрицательных значений, поэтому значение –1 часто применяется для индикации ошибки, полученной от функции. При отсутствии ошибки функция вернула бы валидный (допустимый) дескриптор файла.
Каждый процесс традиционно имеет не менее трех открытых файловых дескрипторов: 0, 1 и 2, если, конечно, процесс явно не закрывает один из них. Файловый дескриптор 0 соответствует стандартному вводу (stdin), дескриптор 1 — стандартному выводу (stdout), дескриптор 2 — стандартной ошибке (stderr). Библиотека С не ссылается непосредственно на эти целые числа, а предоставляет препроцессорные определения STDIN_FILENO, STDOUT_FILENO и STDERR_FILENO для каждого из вышеописанных вариантов соответственно. Как правило, stdin подключен к терминальному устройству ввода (обычно это пользовательская клавиатура), а stdout и stderr — к дисплею терминала. Пользователи могут переназначать эти стандартные файловые дескрипторы и даже направлять по конвейеру вывод одной программы во ввод другой. Именно так оболочка реализует переназначения и конвейеры.
Дескрипторы могут ссылаться не только на обычные файлы, но и на файлы устройств и конвейеры, каталоги и фьютексы, FIFO и сокеты. Это соответствует парадигме «все есть файл». Практически любая информация, которую можно читать или записывать, доступна по файловому дескриптору.
По умолчанию процесс-потомок получает копию таблицы файлов своего процесса-предка. Список открытых файлов и режимы доступа к ним, актуальные файловые позиции и другие метаданные не меняются. Однако изменение, связан
...