Linux: Системное программирование, 2-е издание

Linux: Системное  программирование, 2-е издание
Автор: Роберт Лав
Год: 2014
ISBN: 978-5-496-00747-4
Страниц: 448
Язык: Русский
Формат: PDF
Размер: 12 Мб

Download

Роберт Лав стоит у истоков создания операционной системы Linux. Он внес существенный вклад в создание ядра Linux и настольной среды GNOME.
Эта книга представляет собой руководство по системному программированию для Linux, справочник по системным вызовам Linux, а также подробный рассказ о том, как писать более быстрый и умный код. Роберт Лав четко разграничивает стандартные функции POSIX и специальные службы, которые предлагаются лишь в Linux. Во втором издании вы изучите эту операционную систему как с теоретической, так и с прикладной точки зрения.

+

Введение и основополагающие концепции

Эта книга рассказывает о системном программировании, то есть написании системно­го программного обеспечения. Системные программы являются низкоуровневыми, взаимодействуют непосредственно с ядром и основными системными библиотеками.
Ваши командная оболочка и текстовый редактор, компилятор и отладчик, основные утилиты и системные демоны – все это системное программное обеспечение. К данной категории относятся также сетевой сервер, веб-сервер и база данных. Эти компоненты являются классическими образцами системного ПО и взаимодействуют в основном, а то и исключительно с ядром и библиотекой С. Другое программное обеспечение (например, прикладные про граммы с графическими пользовательскими интерфейса­ми) находится на более высоком уровне и взаимодействует с низкоуровневыми лишь эпизодически. Некоторые специалисты целыми днями пишут только системное про­граммное обеспечение, другие уделяют таким задачам лишь часть рабочего времени, но понимание системного ПО – это навык, который пригодится любому специалисту. Независимо от того, является такое программирование насущным хлебом конкретно­го инженера либо просто базисом для создания более высокоуровневых концепций, системное программирование – это сердце всего создаваемого нами софта.

Эта книга посвящена не всему системному программированию, а системному программированию в 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 положительно сказывается на качестве программирования, а также требуется при написании ассемблерного кода или разработке самой цепочки инструментов (последняя — классический пример системного програм­мирования).