[Статья] Самоучитель по программированию для начинающих ¶
By: ББ on 2020-03-08 17 ч.
[Статья] Самоучитель по программированию для начинающих
Краткий Самоучитель по Основам Программирования и по Компьютерным Технологиям в вопросах и ответах для начинающих
Вы пришли сюда чтобы набирать знания. Вы готовы экспериментировать со своим компьютером и провести за ним половину вашей жизни, возясь с самодельными программами и наблюдая, как они оживают. Вы хотите узнать, как я прошел/прошла этот путь и как я ко всему этому отношусь. Вы в правильном месте.
Перед тем как мы начнем занятия, я хочу сказать несколько вещей:
1. Вы видите кусок кода. Что с ним нужно сделать? Нужно заставить его работать. Для чего? Для того, чтобы можно было с ним играться и экспериментировать. Тот, кто этого не делает, тот ничему не научится.
2. Эта книга должна быть у вас на столе.
По английски djvu (копия, поменяйте расширение на djvu)
То же, но pdf (копия)
По русски, хороший перевод, pdf (копия)
Дело не в том, что я заставляю вас учить именно этот язык программирования. Просто на нем даются примеры в старых книжках, а также в справочных страницах, встроенных в Linux. Это основной язык программирования, многие остальные языки являются лишь обертками над ним (поверьте мне). К тому же синтаксис языка настолько всем понравился, что другие решили в своих языках сделать так же, поэтому все языки программирования немного похожи.
3. Что делать, если вы встретили незнакомое слово? Загляните в словарик, который дается в конце самоучителя. Если не помогло, то используйте поисковую систему (любую). Опять не помогло? Спросите здесь, в этом топике.
Глава 1. Берем быка за рога
Что такое компилятор??
Compiler, программа, которая берет ваш код в виде простого текстового файлика, и произведет на свет бинарник - файл, способный запускаться.
Программу можно собирать по частям. Результатом может быть не только самостоятельное приложение, например, можно сделать библиотеку, которая сама по себе не запускается, но из нее торчат функции, которые другие программы могут использовать.
Линкер (linker) - программа, которая склеивает новую программу из кусков и делает так, чтобы все адреса указывали туда, куда надо, чтобы все куски работали вместе.
Зачем в программе комментарии??
Комментарий - лучший друг программиста. Он позволяет писать произвольный текст прямо в коде, в том числе с использованием кириллицы. Выглядит вот так:
/* это комментарий, я могу писать в нем то, что хочу */
Комментарии используются для общения программистов между собой. Добавление комментария, объясняющего работу сложного куска кода считается признаком хорошего тона.
Кроме того, очень удобно временно закомментировать ненужный фрагмент кода, если нужно, чтобы он перестал работать, а выбрасывать жалко.
Компилятор игнорирует комментарии, для него главное, чтобы знаки начала и конца комментария были на месте.
Что такое переменная??
Маленький кусочек оперативной памяти, в котором можно хранить числовое значение. Переменную можно прочитать и использовать ее значение где-нибудь. Можно записать в переменную новое значение, при этом прежнее значение теряется. Например, можно сложить значение переменной с единичкой, а результат потом записать обратно (инкрементирование).
У каждой переменной есть размер, который измеряется в байтах. Очень часто переменная имеет размер машинного слова, например на 32-битном intel размер слова - 32 бита (4 байта).
У каждой переменной есть свой тип. Тип - это ... как бы вам объяснить. От типа зависит, каким образом эта переменная обрабатывается, какие инструкции процессора понадобятся.
Основные типы:
int - переменная, хранящая целое число (без дробей), по размеру часто равная машинному слову (4 либо 8 байт), работает быстро, много инструкций не требует, удобная во всех отношениях. Максимальное значение зависит от размеров переменной и от того, знаковая она или нет
unsigned int - то же, только беззнаковая (обрабатывается немного по другому)
char - по традиции, размер этой переменной равен одному байту. Может хранить число от -128 до +127
unsigned char - хранит число от 0 до 255, занимает один байт.
long int - в два раза больше чем int
long long int - в два раза больше чем long int
float - переменная, использующая вычисления с плавающей запятой (floating point). Пользоваться с осторожностью.
double - то же, двойная точность, размер в два раза больше
Для того, чтобы получить размер переменной, есть оператор sizeof(), который вычисляется компилятором на этапе сборки. В скобки нужно поставить саму переменную, либо название типа. Это полезно, например, если нужно прямо на ходу выделить немного места для переменной, понадобится указать размер куска памяти в байтах, в который она поместится.
Если вы хотите завести себе переменную, нужно выбрать, где она будет храниться. Тут есть варианты:
Глобальная переменная. Видна во всем блоке из любой функции. Будет висеть в памяти, пока программа не закончится. Новички ее очень любят. Не надо заморачиваться и думать, где она видна, а где нет.
Автоматическая переменная. Можно завести переменную внутри функции. Память для нее выделяется на ходу во время старта функции (в стеке, ну или в регистре). Когда функция выходит, считается, что эта память освобождена. Переменная видна только внутри функции. Можно изъ**нуться и сделать указатель на переменную и передать этот указатель в другую функцию (дочернюю), чтобы та могла писать по указателю, но тут надо быть осторожным, после освобождения пользоваться переменной нельзя (нужно просто не передавать этот указатель в родительскую функцию и не хранить его в глобальной переменной).
Иногда нужно выделять место для переменной прямо на ходу, при этом чтобы переменная держалась в памяти до тех пор, пока она нужна. Для этого есть куча (heap). Чтобы выделить место в куче, нужно дернуть системный вызов malloc(), при этом указать размер нужного куска в байтах. В замен мы получим указатель на этот кусок (или ошибку, если места больше нет). Этот кусок будет держаться до тех пор, пока мы не освободим его вручную с помощью free().
Spurdo просил добавить, что когда наша программа запускается, ей выделяется адресное пространство, в котором помещается куча и стек. Создавать кучу не надо, она будет доступна при старте программы.
Можно надергать себе несколько кусков, а потом в каждом куске хранить указатель на следующий кусок, и так объединять куски в цепочки (linked list, линкованный список). Нужно только указатель на первый кусок не потерять (глобальная переменная тут в самый раз).
Если указатель указывает на освобожденный кусок, его лучше поменять, чтобы он указывал по адресу 0x00000000 (NULL), чтобы избежать использования после освобождения (это не обязательно, просто правило хорошего тона). Многие уязвимости в программах основываются на этом трюке использования после освобождения.
Переменные имеют разный размер в зависимости от машины??
Да. Я не знаю, почему так. Есть переменные фиксированного размера:
uint32_t - 32 бита, буква u значит unsigned
uint16_t - 16 бит
uint8_t - 8 бит и другие...
Что такое указатель??
Обычная переменная, размер такой же, как int. Способ использования совсем другой, ведь это адрес в памяти. Можно читать и писать по этому адресу, можно менять сам адрес. Можно заниматься адресной арифметикой (про это будет отдельная интересная глава).
Процесс доставания значения из памяти называется разЫменовывание (dereferencing).
У любого объекта, хранящегося в памяти, можно взять адрес (и сохранить его в указатель).
Чтобы разыменовать указатель, нужно знать размер и тип элемента, на который он указывает. В самом деле, сколько байт нужно оттуда прочитать, чтобы разыменовать указатель на int? Правильно, sizeof(int) (точный размер зависит от машины, для которой компилировался бинарник).
Что будет, если я разыменую указатель, который указывает не туда??
Буквально то и будет. Компьютер прочитает значение по указанному адресу. Если процессор в этот момент находится в режиме реальном (или kernel mode), то просто прочитается память. Таким образом можно прочитать bios загрузчик, таблицу прерываний, другие программы, висящие в памяти. Если была запись по неправильному указателю, то это приведет к поломке других программ и повисанию системы (не сразу, постепенно). Лечится только ребутом.
С появлением режима защищенного, можно сказать, стало лучше. Такое обращение приводит к срабатыванию защиты и вылету программы (сразу, или чуть погодя). Так называемый Segmentation Fault - это он и есть.
Как хранить отрицательные числа в компьютере??
Компьютер хранит числа в виде единичек и нулей, верно? Как это выглядит?
Берем unsigned char, пишем в него какое-нибудь число от балды, например 42.
0b00101010
0x2a
Нам нужно отличать десятичные числа от двоичных и шестнадцатиричных. Префикс 0b значит двоичную систему (доступны цифры 0 и 1), префикс 0x значит шестнадцатеричную (десять цифр и еще шесть латинских букв). Префикса нет - значит обычное десятичное число. Если вначале 0 - значит восьмеричная система (после 7 идет 0). В любом случае старшие разряды находятся слева, младшие справа.
Зачем нужна шестнадцатеричная (hexadecimal) система??
В ней удобно записывать значения байтов. Если вы будете исследовать содержимое жесткого диска или залезете в чужую программу с помощью отладчика, то вы увидете байты в hex нотации. Каждый байт разделяется на две половинки (nibbles) по четыре бита. Каждому биту полагается одна 16-ричная цифра от 0 до f.
А зачем восьмеричная??
Это редкая система используется больше для традиции, можно задавать разрешение на доступ к файлам таким способом. Каждой такой цифре соответствует не 4 а 3 бита. Число 777 значит три группы по три бита, все выставленные в единички (разрешить все права для всех, никогда не выставляйте такой приоритет на вашем файле с паролями).
Ну как хранить отрицательные числа в компьютере??
Максимальное число для unsigned char - это 255
0b11111111
0xff
Погоди, а что будет, если превысить значение переменной??
Будет переполнение, после 255 идет 0, потом 1, потом 2 и т. д.
Как узнать, что случилось переполнение??
Никак. Перед сложением двух чисел нужно их проверить, будет переполнение или нет. Есть еще флаг переполнения в статус-регистре (carry flag), но пользоваться им почему-то не разрешает язык. Тут есть свои заморочки.
И все таки как хранить отрицательные числа в компьютере??
Signed отличается от unsigned тем, что старший бит зарезервирован под знак. Если он равен единице, значит число меньше нуля (отрицательное).
3 0b00000011 0x03
2 0b00000010 0x02
1 0b00000001 0x01
0 0b00000000 0x00
-1 0b11111111 0xff
-2 0b11111110 0xfe
-3 0b11111101 0xfd
-4 0b11111100 0xfc
Такой хитрый способ называется Two's complement (en.wikipedia.org/wiki/Two's_complement)
Если значение переменной постепенно увеличивать, то после +127 наступит -128, потом -127 итд
126 0b01111110 0x7e
127 0b01111111 0x7f
-128 0b10000000 0x80
-127 0b10000001 0x81
-126 0b10000010 0x82
-125 0b10000011 0x83
После -1 наступит переполнение, будет 0, потом 1, потом 2 итд. В процессоре включится флаг переноса (carry flag), но мы его игнорируем.
-2 0b11111110 0xfe
-1 0b11111111 0xff
0 0b00000000 0x00
1 0b00000001 0x01
Как конвертировать число в другой знак (как сделать число отрицательным)??
Очень просто. Инвертируем все биты (0 = 1; 1 = 0), затем добавляем единичку.
7 0b00000111
~
= -8 0b11111000
+1
= -7 0b11111001
А обратно??
То же самое. Инвертируем все биты и добавляем единичку.
-7 0b11111001
~
= 6 0b00000110
+1
= 7 0b00000111
Как сконвертировать число между системами счисления??
Берем программу-калькулятор, переключаем в инженерный режим, находим переключатель систем счисления, вводим число, смотрим, как это число меняется при переключении систем.
А вручную??
Вы уроки информатики в школе помните? Берем двоичное число 0b00010010
Каждый разряд означает какую-то цифру, единичка значит, что мы берем эту цифру, нолик - не берем. Чем старше разряд, тем выше число (степень двойки).
0 0 0 1 0 0 1 0
128 64 32 16 8 4 2 1
16 + 2 = 18
На самом деле число 16 нужно не "взять", а умножить на 1, а затем взять (взять один раз)
Обратно:
0 1 8
1100100 1010 1
сложение в столбик
= 0b00000000
+ 0b00001010 = 0b00001010
+ 0b00000001 = 0b00001011
+ 0b00000001 = 0b00001100
+ 0b00000001 = 0b00001101
+ 0b00000001 = 0b00001110
+ 0b00000001 = 0b00001111
+ 0b00000001 = 0b00010000
+ 0b00000001 = 0b00010001
+ 0b00000001 = 0b00010010
Число 0b00000001 взять восемь раз, число 0b1010 (десятичное 10) взять один раз, сложить это все вместе.
Вместо восьми единичек можно было умножить единичку на шесть (сначала единичку на два, потом единичку на четыре, потом сложить).
Умножение на два делается просто, сдвигаем биты влево на один шаг. Освободившееся место справа займет нолик.
Умножение на четыре - это два умножения на два.
Что такое функция??
Кусок кода, который висит в памяти и ждет, пока его вызовут. Функции могут вызывать другие функции. Инструкции для процессора выполняются по очереди (процессор пытается параллелить выполнение чтобы немного увеличить скорость, но все равно получается по очереди). После того, как кусок кода отработает, процессор должен прыгнуть обратно туда, откуда функция была вызвана, для этого есть специальная инструкция (ret).
Как функция узнает, куда ей надо прыгнуть в конце??
Интрукция ret достает адрес возврата из стека и прыгает по нему.
Откуда в стеке взялся адрес возврата??
Инструкция call, перед тем как прыгнуть на указанный адрес (на первую инструкцию той функции, которую мы хотим вызвать), заталкивает адрес возврата в стек. Адрес возврата - это то место, которое идет после инструкции call.
Если адрес возврата в стеке испорчен, инструкция ret все равно по нему прыгнет??
Да. Многие вирусы проникают в систему именно так, модифицируя стек, если программист расположил буфер в стеке и прозевал проверку на границы.
Зачем функция что-то возвращает??
Возвращаемое значение - маленький кусочек данных (чаще int), по которому можно судить, сработала функция как надо, или нет. Вызовы функций могут участвовать в выражениях, например можно сложить два числа, одно простое (константа), а другое - это вызов функции, который сработает, и возвращаемое значение будет участвовать в сложении. Удобно.
Есть функции, которые ничего не возвращают. Есть те, которые возвращают указатель на что-то (либо нулевой указатель, если что-то пошло не так).
Аргументы, зачем они??
Маленькие куски данных, которые передаются (копируются) в функцию и которые нужны ей для работы. Например, чтобы открыть файл на чтение, нужно передать функции fopen() имя этого файла. Как? Нужно, чтобы строка с именем лежала где-то в памяти, указатель на строку передаем как аргумент функции, а уже сама функция будет читать буквы оттуда. Если все пойдет хорошо, функция вернет указатель на структуру FILE. Можно открыть несколько файлов одновременно, и эти указатели помогут не запутаться, какой файл где.
Аргументы заталкиваются в стек перед вызовом функции. Следом заталкивается адрес возврата. Вызываемая функция может их читать и менять, это не влияет на переменные в родительской функции.
Функция может вызываться одновременно из нескольких мест. В каждом случае стек будет свой и аргументы будут свои. Если вы увлекаетесь многотредовостью, то у каждого треда должен быть свой стек.
Аргументы нужно давать в правильном порядке с правильными типами и размерами, потому что функция будет читать их из стека. Компилятор проверяет, чтобы все сходилось, если не сходится, компилятор отказывается собирать программу.
Может ли функция вызывать саму себя??
Да, это называется рекурсия. При каждом вызове в стек засовывается новая копия аргументов функции и адрес возврата. Если этот процесс зациклится, то так можно быстро засрать свободное место, и будет Stack Overflow с последующим вылетом.
А вообще рекурсия выручает часто, если аккуратно пользоваться.
Что такое стек (stack)??
Это область памяти, лежит рядом с кучей (heap) и растет ей навстречу. В нее можно заталкивать (push) значения и вытаскивать их в обратном порядке (pop). Фишка в том, что не надо знать адрес в памяти, чтобы это делать. Процессор сам подвинет указатель стека (stack pointer) на новое место.
При заталкивании указатель стека уменьшается на величину элемента (= размер машинного слова), затем это машинное слово пишется в память по указанному адресу.
При вытаскивании сначала читается память, затем указатель увеличивается обратно.
То есть стек растет вниз. Это похоже на школьную тетрадку. Один конспект (heap) пишется как обычно, другой (stack) пишется в ту же тетрадку, но в перевернутую вверх тармашками (начиная с последней страницы). Однажды два конспекта встретятся.
Стек используется главным образом для хранения адресов возврата и прочего барахла, функции размещают свои автоматические переменные в стеке. Главное не забыть в конце вернуть указатель стека на место (язык программирования позаботится об этом, вручную редактировать указатель стека вам никто не даст (не, ну есть способ сделать и это, с помощью ассемблерной вставки, наверное)).
Spurdo просил добавить, что слова "куча" и "стек" я использую в контексте управления памятью. Потому что в университете студенты учат структуры данных, среди которых тоже есть куча и стек.
Если функции могут вызывать друг друга, то какая из них включится первой??
Если мы делаем приложение, а не библиотеку, то это приложение должно как-то запускаться. Одна из функций должна быть главной.
Такая функция есть, она должна называться main. Она запустится автоматически, через ее аргументы можно прочитать аргументы командной строки чтобы выяснить, что хотел пользователь, когда запускал нашу программу. Возвращаемое значение (тип int) после завершения программы станет будущим exit-code, по нему другие программы, вызвавшие нашу программу, смогут выяснить, хорошо закончилась программа, или не очень.
Практика!!
Берем код. Пример кода на языке Си
Самый простой пример - это helloworld, их вы найдете не мало, весь интернет только ими и наполнен. Мы пойдем дальше, прочитаем аргумент коммандной строки (наша программа выводит ту фразу, которую мы захотим):
#include <stdio.h>
int main (int argc, char **argv) {
fprintf(stdout, "argument #1 = %s\n", argv[1]);
return 0;
};
Загрузите этот текст в редактор, который оборудован подсветкой синтаксиса, вы сразу увидите, что редактор выделяет цветом строковые литералы (которые между двойными кавычками), а внутри них escape-последовательности (\n - символ переноса строки), и кучу всего другого. Красиво, правда?
Что такое строковый литерал??
Способ ввести в память строку из букв, буквы попадут в память друг за другом, по одному байту (char) на букву. Если присутствует кириллица, то на каждую букву кириллицы будет вставляться два байта (multibyte character) кодировки UTF-8 (если вы на Linux'е), об этом потом будет разговор.
Строковые литералы защищены от записи. Если программа попытается на ходу поменять в них буквы, будет SegFault.
В конце автоматически добавляется нулевой байт. Нулевой байт имеет сокровенное значение - он означает конец последовательности символов. В языке Си строка может быть любой длины, лишь бы на конце был нулевой байт. Если его нет, то при выводе на экран вслед за строкой высыпится то, что лежало в памяти возле нее. Ничего не напоминает? (Heartbleed, там механизм немного другой, но последствия похожие).
В соседнем лагере (Turbo Pascal, Delphi) строки устроены немного по другому, там длина строки хранится в первых двух байтах строки, отсюда ограничение на ее длину 65535 байт. У турбо паскаля вообще 255 (один байт).
Я ничего не понела, можно по подробнее про этот пример кода??
#include <stdio.h>
Директива include говорит, что нужно пойти в директорию, где лежат инклУды (хИдеры, header), открыть текстовый файл с указанным именем, и вывалить его содержимое прямо в текст программы. Там будут объявления функций, будут указаны аргументы, которые функции принимают, их типы, возвращаемые значения. Без них компилер будет ругаться, что не может проверить, правильно ли мы вызываем функции. До кучи там лежат всякие констАнты, енУмы, прочее барахло.
int main (int argc, char **argv) {
Ага, int значит, что сейчас будет что-то, что является типом int либо возвращает тип int. main - ага, после него идут круглые скобки, значит это имя функции, внутри скобок аргументы, а внутри фигурных скобок лежит тело функции - собственно, сами команды, которые будут выполнены. Имя могло бы быть любым, но если мы хотим, чтобы функция сработала автоматически, она должна называться main. avrc и argv - это имена для аргументов они видны только внутри функции, вместо avrc argv можно придумать любые другие имена. argc - это цифра, хранящая число аргументов, argv - сами аргументы, argv - это указатель на массив строк, если его разыменовать, получится указатель на char, который можно сунуть в fprintf. Используя адресную арифметику можно аккуратно прочитать все строки, по одной на каждый аргумент командной строки.
fprintf(stdout, "argument #1 = %s\n", argv[1]);
Незнакомое название, есть круглые скобки, значит это вызов функции (мы ведь внутри функции, верно?). похоже, это функция из стандартной библиотеки, открываем терминал вводим 'man fprintf', выскакивает малуал на функции семейства printf. Они занимаются тем, что выводят поток символов. Первый аргумент - это указатель на структуру FILE - мы можем выбрать, в какой файл писать, круто. В качестве файла мы вставляем stdout - это какая-то переменная, которая ссылается на стандартный поток вывода, она определена где-то наверху. Второй аргумент - строка формата, то есть сюда нужно отправить указатель на первую букву в строке, мы воткнули сюда строковый литерал, который записал строку в память, а вместо себя вернул указатель на ее первую букву, как обычно. \n - это так выглядит символ переноса строки. %s - значит здесь вместо %s вставится содержимое слежующего аргумента, который должен быть указателем на char. Действительно, мы даем ему этот указатель. Есть хитрый способ, с помошью которого можно создать функцию, которая принимает переменное число аргументов, printf - как раз такая. Эта функция возвращает количество букв, которые она хотела записать, мы, однако, не используем это значение, никуда не сохраняем его.
return 0;
Кодовое слово, требует прервать выполнение функции и вернуть значение 0, ведь мы внутри функции, которая возвращает целое число (int). По традиции 0 означает успешное завершение программы.
Что означает единичка в квадратных скобках??
Индекс массива. Мы достаем из массива элемент №1 (нумерация начинается с нуля). Это адресная арифметика, мы займемся ей позже (эта веселая тема требует отдельного разговора).
Как это запустить??
Берем Linux, открываем терминал, вводим
gcc
Компилятор ругается, что ему не дали входной файл. Значит у нас есть компилятор! Можем продолжать. Создаем простой текстовый файлик, копируем туда код, дадим файлу имя programma1.c с расширением .c в конце
Открываем терминал на этот раз в той папке где лежит файлик:
gcc -Wall programma1.c -o programma1
gcc - наш компилятор
-Wall - включить все предупреждающие сообщения
programma1.c - имя входного файла
-o programma1 - имя выходного бинарника
Рядом с файлом появился еще один. Это бинарник. Можем запустить его:
./programma1
argument #1 = (null)
Наша программа умеет что-то печатать в консоль. Круто. Почему (null)? Потому что мы передали в функцию fprintf нулевой указатель вместо строки. Попробуем по иному:
./programma1 Hello Runion
argument #1 = Hello
Неплохо. Мы дали программе аргументы, первый аргумент попал на выход. Второе слово - это второй аргумент. Попробуем по иному:
./programma1 'Hello Runion'
argument #1 = Hello Runion
У вас получилось запустить этот код? Поздравляю, самое сложное позади, дальше будет проще. Поиграйтесь с кодом, попробуйте по пускать ошибки и смотреть, как компилятор будет ругаться на них. Он покажет вам номер строки, которая его не устраивает.
Продолжение в следующем посте. Устал(а) писать. На вычитку нет сил.
Ах, да! Этот цикл статей я публикую только на Runion. Nikkon, прикрепи топик наверх, пусть посетители школы, прежде чем постить, сначала прочитают мой самоучитель. Вопросы тоже можно слать сюда, в эту тему.