Язык Си. Рождение легенды

Практический курс по C/C++: https://stepik.org/course/193691

Сегодня математика составляет основу выражения современных научных знаний, а в основе математики лежат числа и операции сними, попросту говоря, вычисления. По-английски computing. Соответственно, устройство способное производить вычисления, звучит как computer. Отсюда и происходит знакомое нам русское слово компьютер. То есть, компьютер – это попросту вычислительная машина.

Первые попытки создать механические вычислительные машины предпринимались с незапамятных времен. Первой документально зафиксированной в истории считается машина Вильгельма Шиккарда, созданная в 1623 году. Однако есть предположения, что еще до этого в 16 веке известный изобретатель Леонардо да Винчи предпринимал попытки создания подобных механизмов. Удалось ли ему это или нет – доподлинно неизвестно. Но, наверное, самой известной счетной машиной является арифмометр Блеза Паскаля, созданный в 1645 году. И более совершенная конструкция, сделанная Готфридом Вильгельмом Лейбницем. Его счетная машина была способна не только складывать и вычитать, но также делить и умножать.

Однако все эти механические счетные машины автоматизировали одно-два действия с двумя числами: складывали, умножали, делили. Более продвинутую идею в 19 веке предложил Чарльз Беббидж сначала в виде разностной машины (1822 год), а позже – аналитической. Именно в аналитической машине у него появляется идея программного управления вычислениями. Правда, дальше идеи дело не пошло. Сложность построения его машины превосходила технологии того времени. Но с ней связан один интересный исторический факт. Когда леди Августа Ада Лавлейс переводила конспект лекций Беббиджа по устройству его аналитической машины на английский язык, то сопроводила перевод своими многочисленными комментариями, среди которых оказался полный набор команд для вычисления чисел Бернулли с помощью аналитической машины. Этот набор команд считается первой документально зафиксированной программой в истории человечества, а Ада Лавлейс – первым программистом.

Но все эти механические устройства, в большинстве своем, были громоздкими, неудобными и ненадежными. Поэтому оставались на уровне идей или единичных воплощений. Новый виток развития вычислительных машин начался в XX веке благодаря зарождению электроники. Одним из основоположников современной вычислительной техники стал английский математик Алан Тьюринг (1912 – 1954 гг.). В 1936 году он представил общую идею абстрактной универсальной вычислительной машины, которая выполняла различные задачи в зависимости от загруженных в нее данных. Вскоре, эта идея получила название универсальной машины Тьюринга. Затем эту идею развил венгерско-американский математик Джон фон Нейман (1903 – 1957 гг.). Он предложил команды и данные хранить в единой, однородной памяти компьютера. До этого команды следовало записывать в один тип памяти, а данные независимо в другую. Это приводило к заметным неудобствам. Поэтому идея фон Неймана по объединению команд и данных в единой памяти оказалась прорывной для того времени. Она стала настолько удачной, что сейчас практически вся вычислительная техника построена по этому принципу – по архитектуре, предложенной Джоном фон Нейманом.

Первое же поколение компьютеров (с 1944 года), построенных на электронных лампах, использовали принцип однородности памяти для данных и команд. Уже тогда сразу была принята на вооружение архитектура фон Неймана. И вычислительная техника, созданная по этому принципу, получила название машины фон Неймана.

Второе поколение компьютеров (с середины 1950-х) было выполнено на транзисторах. Схемотехнически, это те же лампы, но реализованные на уровне полупроводников. В результате вычислительная техника стала гораздо меньших объемов, потребляла меньше энергии, стала надежнее и с большим быстродействием. Внутреннее устройство компьютеров этого и всех последующих поколений уже базировалось на архитектуре фон Неймана.

Третье поколение компьютеров (с 1960-х годов) были разработаны на базе интегральных схем (микросхем), которые объединяли внутри себя схемы из большого числа транзисторов. Это привело к еще большему сокращению размеров компьютерной техники, повышению надежности и быстродействию.

Четвертое поколение вычислительной техники (с 1970-х годов) ознаменовано появлением специального типа микросхем, известных, как микропроцессор. Эта микросхема выполняла внутри себя все арифметические, логические операции и операции управления, записанные в машинном коде. Это дало новый скачок в развитии компьютерной техники. Благодаря миниатюризации и энергоэффективности компьютеры стали доступны массовому пользователю.

Практически всю современную вычислительную технику можно отнести к четвертому поколению. Конечно, она стала намного быстрее, с более сложным внутренним устройством, разнообразнее, но ничего принципиально нового в ней не появилось. Также присутствует центральный процессор, несколько вспомогательных процессоров, память, различные внешние и внутренние устройства, и все это объединено по архитектуре, предложенной Джоном фон Нейманом.

Архитектура современного компьютера

Если внимательнее посмотреть на общее устройство компьютера, то мы увидим:

  • центральный процессор (CPU – central processing unit), который непрерывно выполняет поступающие в него команды;
  • оперативное запоминающее устройство (ОЗУ, англ. RAM – random access memory), в котором можно хранить данные и команды для процессора;
  • шину, соединяющую центральный процессор, память и другие дополнительные устройства, соединенные через контроллеры.

Как видите, процессор взаимодействует с другими компонентами через контроллеры. Почему это так сделано? Почему бы все эти устройства напрямую не подключать к шине? Зачем потребовался посредник в виде контроллера? Дело в том, что процессор понятия не имеет обо всех этих устройствах: ни о клавиатуре, ни о мониторе, ни о жестких дисках. У него нет инструкций, как, например, управлять считыванием информации с клавиатуры или с жесткого диска, или как выводить что-либо на монитор. Все, что умеет делать процессор – это выдавать определенные данные в шину и читать их оттуда. Именно так происходит взаимодействие с контроллерами: центральный процессор указывает адрес обращения к контроллеру, данные, и команды для контроллера. На основе принятых данных и команд контроллер выполняет определенные действия, например, указывает монитору вывести определенную информацию. И так с каждым устройством: клавиатурой, жестким диском и т.д.

Так как в шину процессор передает разные типы информации: данные для контроллера, его адрес и команды, то шина имеет соответствующие отдельные каналы, которые так и называют: шина данных, шина адресов, шина управления. Благодаря подключению устройств через контроллеры центральный процессор получает возможность взаимодействовать с ними на универсальном уровне. Если в будущем появится новый тип внешних носителей, например, какие-нибудь квантовые флэшки, или био-принтеры или что-либо еще, то это не потребует схемотехнического изменения самого процессора. Через контроллеры все новые устройства смогут, по-прежнему, обмениваться информацией с центральным процессором и полноценно выполнять свои функции.

Правда, из-за этого появляется еще один нюанс: процессор должен знать, какие команды, какие данные и в какой последовательности следует передавать на контроллеры, чтобы внешние устройства корректно ее принимали и обрабатывали. Для этого разработчики устройств совместно с ними поставляют специальные программы, которые называются драйверами. Центральный процессор для взаимодействия с тем или иным устройством просто выполняет соответствующую программу-драйвер и, таким образом, передает нужному контроллеру корректные управляющие команды. Так, в целом, происходит взаимодействие между процессором и любым внешним устройством.

Память и выполнение команд центральным процессором

Давайте теперь посмотрим, что из себя представляет память. Практически во всей современной вычислительной технике, память можно представить в виде последовательностей упорядоченных ячеек, каждая из которых имеет свой физический номер (физический адрес). А сама ячейка, почти во всех схемотехнических реализациях, представляет собой восемь бит информации. Напомню, что один бит кодирует информацию двумя состояниями, которые, обычно, обозначаются числами 0 и 1. Число 0 – бит выключен; 1 – бит включен. Соответственно группа из восьми таких бит способна кодировать:

различных вариантов. Например, целые числа в диапазоне от 0 до 255. Такая неделимая единица информации из 8 бит получила название байт. То есть, каждую ячейку памяти можно интерпретировать как один байт, в которой хранится целое число от 0 до 255.

Центральный процессор способен через шину заносить в любую ячейку оперативной памяти некоторое значение в этом диапазоне [0; 255], либо осуществлять запрос на получение значения также из любой существующей ячейки памяти. Но что в итоге нам это дает? Очевидно то, что в такой памяти можно хранить и команды для центрального процессора и данные. Причем, как команды, так и данные кодируются обычными числами.

Для определенности предположим, что в оперативной памяти, начиная с 1000-й ячейки, хранятся следующие числа (приведены в шестнадцатиричной записи):

B8 22 11 00 FF 01 CA 31 F6 53 8B 5C 24 04 8D 34 48 39 C3 72 EB C3

И пусть компьютер оснащен 32-разрядным процессором архитектуры x86. Чтобы на вход процессора поступило первое число B8, расположенное по адресу 1000, счетчик команд этого процессора должен быть равен 1000. Да, внутри каждого процессора есть специальные внутренние хранилища данных, которые называются регистрами. В этих регистрах сохраняется промежуточная, вспомогательная информация для обработки текущей информации. Доступ к регистрам для чтения или записи (если это допустимо) выполняется очень быстро (быстрее, чем обращение к оперативной памяти). Так вот, один из регистров процессора – это счетчик команд (program counter) или его еще называют указателем инструкции (instruction pointer). Он содержит адрес ячейки памяти, в которой хранится следующая для выполнения команда. В нашем примере – это число 1000.

Итак, на вход процессора поступает число B8 из 1000-й ячейки памяти. В этом числе закодирована команда (на языке ассемблера):

movl [адрес памяти], %eax

Она означает, что процессор должен взять 4 байта данных (32 бита) из оперативной памяти, расположенной по указанному адресу и занести эти данные в регистр процессора eax. Сам же адрес памяти кодируется следующими 4 байтами (32 битами):

22 11 00 FF

В итоге, получаем команду (на языке ассемблера):

movl $0xFF001122, %eax

Как только процессор выполнит эту команду, счетчик команд увеличится на 5 и станет ссылаться на число 01:

B8 22 11 00 FF 01 CA 31 F6 53 8B 5C 24 04 8D 34 48 39 C3 72 EB C3

Это число будет восприниматься как новая команда, которая на уровне языка ассемблер означает сложение двух регистров:

addl [первый регистр], [второй регистр]

Чтобы процессор понимал, какие два регистра складывать, берется следующий байт CA. В итоге, команда 01 CA означает прибавить к содержимому регистра edx значение из регистра ecx:

addl %ecx, %edx

После этого счетчик команд увеличивается на 2 и ссылается на следующую команду 31. И так последовательно, такт за тактом происходит выполнение команд процессором в бесконечном цикле. При этом все команды могут быть представлены обычными числами и записаны в оперативной памяти, наряду с данными. Но, благодаря регистру счетчику команд (или указателя инструкций), процессор точно знает откуда брать следующую команду на выполнение и не путает команды с данными. Мало того, центральный процессор никогда не отдыхает, не останавливается, а работает постоянно по непрерывному циклу обработки команд, выполняя все новые и новые инструкции, пока компьютер не будет выключен. Скорость обработки команд определяется тактовой частотой процессора. Например, тактовая частота 1 ГГц означает выполнение одного миллиарда команд в секунду. А размер порции данных, обрабатываемых процессором за одну операцию, называют машинным словом. Как правило, размер машинного слова совпадает с разрядностью процессора. Например, для 32-разрядного процессора машинное слово обычно 32 бита; для 64-разрядного – 64 бита.

Все данные в памяти компьютера – это числа

Итак, получается, что в каждой ячейке оперативной памяти можно хранить только числа, например, в диапазоне от 0 до 255. И центральный процессор способен обрабатывать исключительно числовые данные. Спрашивается, как же тогда с помощью вычислительной машины работать с текстами, изображениями, звуками, да и любыми другими не числовыми данными? Выход только один – преобразовать их в набор чисел. Например, процессор компьютера понятия не имеет, что такое символ или буква. Вместо нее в памяти сохраняется ее кодовое представление в соответствии с используемой кодовой таблицей. Например, достаточно известная таблица ASCII содержит следующие коды символов (приведен фрагмент):

В соответствии с ней, например, строка «I love C» будет представлена последовательностью чисел:

73 32 108 111 118 101 32 67

Это ее представление в памяти, и для процессора, а на экране, при использовании кодовой таблицы ASCII, мы увидим соответствующую строку.

Подобные преобразования выполняются и для всех других типов данных. В частности, изображения – это наборы отдельных точек (пикселей), цвет которых определяется числовым значением в соответствии с цветовой палитрой:

И вообще, любые данные, предназначенные для компьютерной обработки, предварительно нужно перевести в набор чисел (говорят оцифровать) и только после этого использовать в вычислительной технике. И, наоборот, такие устройства, как мониторы, принтеры, звуковые колонки и т.д. преобразовывают цифровую (то есть, числовую) информацию обратно в исходный, привычный для нас вид. Так происходит круговорот цифровой информации в вычислительной технике.

Операционная система

Но вернемся к вопросу взаимодействия между процессором и другими узлами компьютера (оперативной памятью, внешними устройствами). Как мы увидели, это происходит не таким уж и тривиальным способом. И если бы перед нами поставили задачу написать, например, программу для вычисления обратных матриц с выводом результата на экран, имея только «железо» в виде центрального процессора, памяти, клавиатуры, дисплея, жесткого диска, нам было бы очень непросто ее выполнить. Пришлось бы вначале прописывать на программном уровне взаимодействие процессора с дисплеем, клавиатурой, жестким диском. Затем, порядок запуска и выполнения программы вычисления матрицы, контролировать цикл обработки команд процессора, так как он работает непрерывно, и решать еще очень и очень много других вспомогательных задач, не относящихся непосредственно к поставленной перед нами начальной цели. Это жутко неудобно! Но выход из этой ситуации есть и он очевиден. Нужно написать программную оболочку, которая бы брала на себя управление всеми периферийными устройствами, памятью и пользовательскими программами. И такая оболочка представляет собой не что иное, как операционная система. Как правило, она автоматически загружается при включении компьютера и предоставляет простой, универсальный доступ ко всем имеющимся его ресурсам посредством специальных программ, называемых драйверами. А нам, как прикладным программистам, остается только обратиться к установленной операционной системе и запросить, например, нужный объем памяти для хранения данных, или «попросить» вывести некоторую информацию на экран, или открыть файл на внешнем носителе и занести туда какие-либо данные. И так далее. Все эти операции берет на себя операционная система. Нас не интересует, как конкретно она это делает, главное, чтобы предоставляла доступ к нужным ресурсам и максимально быстро выполняла указанный запрос. Такие запросы к ОС со стороны пользовательских программ называются системными вызовами. Именно через системные вызовы происходит взаимодействие со всем многообразием внешних устройств, подключенных к компьютеру, а также выполняются некоторые внутренние инструкции, за которые отвечает операционная система, например, выделение памяти под нужные нам данные.

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

История создания языка Си

Вообще, первые операционные системы появились еще в 1960-х годах. Вначале они не имели графического интерфейса и все взаимодействие выполнялось на уровне команд. Позже стали появляться графические пользовательские интерфейсы, но вплоть до середины 1990-х они не были массовым явлением. Пока в 1995 году не вышла известная ОС Windows 95 со встроенным графическим интерфейсом, которая завоевала очень большую популярность. С тех пор вся линейка ОС Windows поставлялась со встроенным графическим интерфейсом и популярна по сей день.

Другое распространенное семейство ОС известно под общим названием Unix. К нему относятся различные вариации Linux, такие как Debian, Ubuntu, Fedora, Gentoo и многие другие, а также различные семейства BSD: FreeBSD, OpenBSD и так далее. Интересно, что графический интерфейс в ОС Unix реализован не на уровне ядра системы (как это сделано в ОС Windows), а на уровне пользовательской программы, которую можно поменять, если потребовалась другая графическая оболочка. Также открытый исходный код дистрибутивов Unix позволяют на их основе относительно просто создавать свои вариации операционных систем. На практике этим довольно часто пользуются. Например, так появилась первая версия ОС Android от компании Google.

Но для нас намного важнее, что именно зарождение Unix способствовало появлению первой версии языка Си. Все начиналось в далеких 1960-х годах. На тот момент некто Кен Томпсон сотрудник фирмы Bell Laboratories принимал участи в создании ОС под названием MULTICS. Проект оказался неудачным, но опыт был получен. Несколько позже, работая с довольно устаревшей даже на тот момент машиной PDP-7, Кен Томпсон решил улучшить ее системное программное обеспечение и, немного-немало, написать свою версию ОС. И он это сделал. В шутку Брайан Керниган назвал эту систему UNICS. Название пошло в народ, только последние две буквы изменились на X, получилось UNIX. Так появилась новая на тот момент ОС Unix для компьютера PDP-7. Но это была устаревшая техника, поэтому Кен Томпсон уже совместно с Деннисом Ритчи решили ее перенести на более совершенную машину PDP-11. Но для этого требовалось переписать ОС. Чтобы не выполнять всю эту кропотливую работу на уровне языка Ассемблер, то есть, по сути, на уровне машинных команд, Кен Томпсон решил воспользоваться языком более высокого уровня под названием «B» - усеченный вариант другого языка BCPL. Но этот язык оказался слишком неудобным для создания ОС. Тогда Деннис Ритчи предложил расширить (усовершенствовать) язык «B» и, недолго думая, назвал новый язык следующей буквой английского алфавита «C». Так в 1972 году появилась первая версия языка Си. Ее автором считается Деннис Ритчи – сотрудник Bell Laboratories. Именно на этом новом языке программирования «С» в 1973 году Кен Томпсон написал ОС Unix для компьютера PDP-11. А в 1974 году вышла совместная статья Кена Томпсона и Денниса Ритчи, где они подробно рассказали о своих разработках. Благодаря тому, что машина PDP-11 на тот момент была довольно распространена, то желающих попробовать новую ОС, а также язык Си, нашлось немало. С этого началось торжественное шествие и становление языка программирования Си, который остается популярным и востребованным до наших дней.

Особенность этого языка в том, что он был создан программистами для программистов в первую очередь для того, чтобы уйти с низкого ассемблерного уровня и позволить писать программы на более высоком абстрактном уровне. Но, при этом, сохраняя все преимущества низкоуровневого программирования, то есть, предоставляя программисту полный контроль и полную свободу действий. Поэтому язык Си можно отнести к самым низкоуровневым из высокоуровневых языков программирования. Благодаря этому программы, написанные на нем, эффективно переводятся в машинный код, максимально быстро работают и могут выполнять все допустимые операции, ограниченные только конкретной архитектурой компьютера. Этот язык удобен для написания ОС, игровых приложений, используется в системах дополненной реальности, искусственном интеллекте, управлении различным оборудованием, на нем реализованы автопилоты самолетов, кораблей. Также на языке Си, а точнее, на С++ написаны различные интерпретаторы для таких языков, как Python, Java, C#. То есть, везде, где нам важна скорость и/или полный контроль за процессом выполнения программы, в том числе и в реальном времени, язык Си оказывается незаменим. Именно поэтому идеи, заложенные в него 50 лет назад, актуальны и поныне. Если бы не было такого языка программирования, его пришлось бы создать!

Практический курс по C/C++: https://stepik.org/course/193691

Видео по теме