C. Глава 1. Посимвольный ввод и вывод

Jan 25, 2018 02:57


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

(Б. Керниган, Д. Ритчи "Язык программирования C")

1. Чтение
Добавим к системной функции write еще и read. Тогда мы получим возможность вводить данные в работающую программу. Эти данные могут поступать как с клавиатуры, так и из файлов на диске, а также по конвейеру из других программ. Добавление read делается с головокружительной простотой:

$ cat write.s > read.s
$ vi read.s
Лст. 1-1. Копирование файла в новый и правка.



Рис. 1-1. Функция read.

Меняется только write на read и номер системной функции 0. Как получить объектный модуль, вы уже видели. Сделайте это сами, а потом мы займемся очень полезным мероприятием: избавимся от бесконечного набора командных строк.

2. Управление простыми проектами без make
Здесь я предлагаю воспользоваться вот такой полезной вещью:

#!/bin/bash

cc -S $1.c -fno-builtin -o - | sed '
/\.type/d
/\.LF/d
/\.cfi/d
/\.ident/d
/\.-main/d
/\.section/d' > $1.s

as $1.s -o $1.o
ld -s $1.o crt0.o write.o read.o -o $1
Лст. 1-2. Очень простой командный файл для компиляции.

Это скрипт для оболочки bash, указание об этом для операционной системы есть в самой первой строке. Я назвал этот файл просто m, чтобы не колотить долго по клавиатуре. Коротко о том, как он работает. Вы уже можете разобрать знакомые строки. Но кое-что для новичков нуждается в пояснении. Не углубляясь в детали программирования для оболочки, запомним следующее:

$1 - это первый параметр, передаваемый в скрипт из командной строки. В нашем случае, ожидается имя главного файла программы на языке C. То есть фактически $1 представляет hello. Командный процессор просто производит подстановку. Остальные объектные модули должны быть уже подготовлены. Компилятор обрабатывает исходный файл на языке C, но перед записью в файл hello.s очищает его от макросов, при помощи потокового редактора sed. Вывод cc подается по конвейеру через фильтр. Программа для sed состоит из набора шаблонов для удаления строк, где они встречаются, поэтому очень важны малозаметные символы апострофов '.

Оставшиеся командные строки вам уже знакомы. Всюду, вместо $1, скрипт подставит hello, или другое имя с вашей программой. Только не используйте расширение файла .c - этот скрипт не настолько умен, чтобы его игнорировать.

Немного о ключах в командных строках. cc: Как вы уже знаете, -S это указание компилятору вывести результат в файл с ассемблерным кодом. -fno-builtin - это указание игнорировать имена встроенных функций. Это мы проясним потом, хотя пока ни одной такой функции не используется.

as: -o (output) Это ключ для имени выходного файла, по умолчанию мы получим файл a.out

ld: -o Как и выше, а -s (strip) удаление отладочной информации из исполняемого файла. И в самом деле, нам она только лишняя обуза. Итак, делаем скрипт исполняемым:

$ chmod m 700
Лст. 1-3. Изменение прав доступа к файлу.

и пользуемся им на здоровье.

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



Рис. 1-2. Новая версия hello.

Компилируем программу и запускаем:



Рис. 1-3. Новая версия. (Еще и работает!)

Вот так просто. Вместо длинных командных строк. По сути дела, мы выполнили операции 1, 2 и 5 из предыдущей главы (см. Рис. 0-8). По аналогии с производством можно сказать так: с помощью скрипта/команды m мы из мастера, который бегает между работниками, превратились в начальника цеха, который не занимается мелочами.

Теперь пришло время обратить внимание на сам код в программе C. Сначала на то, что в языке используется понятие типа данных. Есть несколько элементарных типов, мы не будем нудно перечислять, а затронем только используемые.

char - это числовой тип для представления букв, цифр и знков препинания. Занимает размер в 1 байт. Может представлять беззнаковые числа в диапазоне 0т 0 до 255.

int - это числовой тип для представления целых чисел. Любой элемент данных такого типа занимает 4 байта. Представляет числа со знаком в диапазоне от -2147483647 до 2147483647.
Тип данных записывается перед объявлением имени переменной. Так как char - это всего один байт, то для выделения памяти для строки используют массивы. Квадратные скобки - оператор индекса в массивах. Если в квадратных скобках ничего не указано, то компилятор может узнать о размере массива из его инициализирующей части. Например, ask имеет размер 19 байт, так как такова длина строки, записанная после оператора присваивания - знака равенства.

Кстати, строки в языке C всегда записываются в двойных кавычках, а одиночные символы - в одиночных кавычках.

Переменная-массив answer содержит заготовку ответа пользователю и достаточный запас для остатка - имени пользователя. На этот раз мы не просто инициализировали строку, но еще и предусмотрели 256 байтов.

В квадратных скобках указывается размер массива. Массив name резервирует память в размере 32 байта, но ничем не инициализирован.

Кроме того, в программе есть две переменные целого типа i и j, для перебора символов в строке. Как можно заметить, язык допускает перечисление нескольких переменных после объявления типа.

И еще одна очень важная вещь: каждая инструкция в языке C обязательно заканчивается точкой с запятой. Объявление переменной - тоже инструкция.

В этой книжке мы часто будем просматривать код ассемблера, сгенерированный для наших программ или отдельных функций. Это поможет лучше понять язык C, мы увидим его подобно врачу, который смотрит рентгеновский снимок. Сейчас давайте посмотрим на распределение памяти, которое создал компилятор для переменных.

.file "hello.c"
.globl ask
.data //------- сегмент инициализированных данных --------
.align 16 // атрибут выравнивания объекта
.size ask, 20 // размер данных
ask: // метка (фактически адрес объекта)
.string "What is your name?\n" // инициализированная часть
.globl answer
.align 32
.size answer, 256
answer:
.string "Hello, "
.zero 248 // заполнение памяти нулями
//----------------- сегмент неинициализированных данных ------
.comm name,32,32 // имя, размер, выравнивание
.comm i,4,4
.comm j,4,4
.text //------------- сегмент кода -----------------------
.globl main
main:
pushq %rbp
movq %rsp, %rbp
Лст. 1-4. Память для переменных в программе hello v2.

Данные делятся на инициализированные и нет. В первом случае, они “зашиваются” в код программы. Во втором, - для данных выделяется память необходимого размера, но это происходит в процессе загрузки. Инициализированные данные влияют на размер скомпилированной программы, а неинициализированные нет. Неиспользованная память в сегменте данных заполняется нулями. Как мы можем видеть, это делает компилятор, заполняющий остаток строки нулями. Компилятор, как нетрудно догадаться, сам принимал решение, в каких сегментах размещать объявленные в программе переменные. Что касается выравнивания (alignment), то это способ расположить данные в памяти так, чтобы их выборка производилась как можно быстрее. Выравнивание приводит к небольшой потере памяти: для ask мы использовали всего 20 байт памяти, но из-за выравнивания с границей 16 было израсходовано 16+4 и еще 12 байтов образовали дыру, не занятую ничем. Такова оптимизация по умолчанию, которую предлагает компилятор, - по скорости выполнения.

Теперь к коду самой программы. Начнем с пояснения вызова функции. Первой в программе C всегда вызывается функция main (не считая стартового кода). Все остальное, внутри фигурных скобок, делает пользователь, то есть, тот, кто писал программу. Сначала мы вызываем функцию write, которая должна вывести вопрос на экран. Эта функция принимает три аргумента: дескриптор файла, адрес данных, которые надо вывести, и длинну (размер) данных.

Размер данных в нашем случае выполняет оператор sizeof. Он похож на функцию, но называется оператором. Формально это и есть функция, но sizeof выполняется во время компиляции, а не при работе программы. (Математики обычно называют операторами плохо ведущие себя функции.) Здесь sizeof избавляет нас от подсчета букв в строках.

1 - это дескриптор файла stdout, а 0 - stdin (есть еще 2 - stderr). Для каждой запущенной программы автоматически открываются эти три файловых потока. Правда, они имеют указанные символические имена (STanDard OUTtput, standard input, standard error), но у нас пока все так просто, что мы можем позволить себе роскошь пользоваться совсем небольшими числами.

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

И наконец, третий аргумент - это размер данных. Чтобы знать, когда остановиться. Функция read имеет совершенно аналогичный набор аргументов. Список аргументов этих функций совершенно логичен: куда/откуда, что, и в каком количестве писать или читать.

После того, как функция read запишет в переменную name ввод пользователя, программа должна составить строку из приветствия, имени пользователя, и восклицательного знака. Для этого мы можем просто дописать в строку answer имя пользователя, или то, что он ввел, используя зарезеревированное место. Но что значит “дописать”? Мы должны объяснить это буквально: копируем символы из строки name в строку answer начиная с места после пробела. Копирование должно вовремя остановиться. Для этого мы должны вычислить длину введенного имени. (Можно было бы узнать эту длину другим способом, если вы наблюдательны и можете мыслить по аналогии.)

Цикл for (это тоже оператор, оператор цикла) занимается подсчетом длины. Вообще-то, он может заниматься чем угодно, но в нашем случае он подсчитывает байты. Этот пример цикла не использует инструкции, которые выполняются при его проходе. Бывают и такие циклы. В скобках этого оператора задается начальное значение переменной цикла, i, затем проверяется условие: не равен ли очередной байт нулю?, и затем, переменная цикла увеличивается на единицу. Если условие нарушено - цикл прекращается. Как результат, мы в этом случае используем саму переменную i.
Оператор != - имеет смысл “не равно”. Нулевой байт, как и каждый предстваитель типа char, закавычен апострофами. Причем, это не символ нуля, это именно нулевой байт, поэтому он записывается как \0, а не 0. Дело в том, что '0' - это код символа нуль, он равен 0x30 в шестнадцатеричной системе счисления, а '\0' - это 0x00 (числа записаны по правилам языка C в системе с основанием 16. Десятичный эквивалент 0x30 3*16 = 48).

Следующий цикл for использует переменную j для обращения к name по индексу элемента массива. Здесь проверяется аналогичное условие равенства байта нулю. Таким образом, мы достигаем конца строки name. В языке C признаком конца строки считается нулевой байт, называемый также нуль-терминатором или завершающим нулем, это синонимы. Конструкция answer[i++] означает, что сначала выбирается i-й байт массива answer, а затем i увеличивается на единицу. i++ это пример постинкремента, а у нас есть еще и другой пример, когда переменная сначала инкрементируется, а потом используется. Но об этом чуть ниже.

Последний цикл for имеет инструкцию, именно поэтому в нем нет точки с запятой после закрывающей скобки при for. Инструкция перенесена в следующую строку. Собственно, инструкция как раз и состоит из оператора for и последующего присваивания-копирования.

Странным может показаться последующее, где мы дописываем строку ответа. Когда второй цикл выполняется последний раз, то i увеличивается на единицу, так же, как и во всех предыдущих случаях. Тогда зачем мы обращается к предыдущему элементу answer[i-1]? Все дело в том, что когда данные в файловый поток поступают с клавиатуры, туда же идет и клавиша Enter. И лишь после этого строка завершается нулем. Все логично, вы же ввели строку в файл? Вот строку и получайте. Она интерпретируется как перевод строки, и восклицательный знак переедет на новую строку. Но такая строка, разорванная на две части, нас не устраивает. Поэтому мы подавляем ненужный символ, просто записывая на его место восклицательный знак. А затем, вместо последнего символа записываем перевод строки, символ '\n' или байт 0x0A, что то же самое. И после этого записываем завершающий ноль. Чтобы не затереть \n, мы сначала увеличиваем i на единицу, а затем используем. Это пример преинкремента (предварительного увеличения) ++i. И наконец, записываем на экран строку, которая получилась в результате.

Конечно, в таком сыром виде язык никто не использует. Возможность писать функции предоставляет неисчерпаемые возможности по упорядочиванию и организации кода. И естественно, начинать нужно с самого начала, с работы над самыми простыми элементами текста, отдельными символами.

4. Функции getc и putc
Обе эти функции были написаны для того, чтобы проще было извлекать символы из потока, или наоборот, помещать их туда. Очень часто такие действия требуются для раздельного перебора символов с какими-то целями. Входной поток символов может управлять работой программы. Но давайте напишем функции getc, putc и проверим их работу в простой программе.

int getc( int f )
{
int r,e;
e = read( f, (int*)&r, 1 );
if( e == -1 || e == 0 ) return -1;
return r;
}
Лст. 1-5. Код функции getc.

Функция получает в качестве аргумента дескриптор файла и возвращает считанный символ. Если происходит ошибка при операции чтения, то read вернет число -1 в переменную e и установит код (причину) ошибки. Мы эту ситуацию пока не обрабатываем (не пользуемся переменной errno, да и бог с ней). Для нас важно, что если read возвращает не -1 и не 0, а что-то другое, то значит, все в порядке. А именно, read возвращает число реально прочитанных байтов. Если read прочитает символ конца файла, EOF, он же число -1, то она, (функция read), вернет ноль.

Как уже нам известно, read передает считанную из файла информацию через указатель в своем втором аргументе. Сначала мы получаем адрес той переменной, куда надо записать результат, а затем преобразуем его в указатель на целое и (третий аргумент), читаем один элемент данных. Так что, мы можем прочитать из файлового потока и -1 в том числе. Но обратите внимание, что на этот раз -1 попадает не в e, а в r, а это другое дело. В e в этом случае окажется 1.

Следующая инструкция проверяет, чем является e. Если это -1, то мы выходим из функции со значением -1. Если это 0, то мы тоже выходим из функции с тем же значением -1. Таким образом, ситуация ошибки также обрабатывается как конец файла, что в целом логично - файл-то, с точки зрения функции, все равно, накрылся медным тазом, до конца он прочитан, или нет. Если же все прошло нормально, то getc вернет считанный символ как целое число. В том числе, и символ UNICODE, который, в подавляющем числе случаев, занимает не более 2 байтов. Если read вернет 0, то из файла читайть больше нечего, а это равносильно концу файла и getc снова должна вернуть EOF, то есть, -1.

Функция putc действует обратным образом и записывает в поток символ, как целое число. Она использует системную функцию write.

int putc( int c, int f )
{
if( write( f, &c, 1 ) )
return c;
else return -1;
}
Лст. 1-6. Код функции putc.

Эта функция получает два аргумента: символ для записи c, и идентификатор файла f. В случае успеха, то есть, если write вернет число, отличное от нуля, функция putc возвращает записанный символ, иначе -1.

Поскольку очень часто UNIX программы используются в конвейерах или просто терминале, то файловые потоки известны заранее. Это stdin и stdout (числа 0 и 1, в нашем нестрогом изложении). Поэтому были придуманы еще две функции getchar и putchar:

int getchar()
{
return getc( 0 );
}

int putchar( int c )
{
return putc( c, 1 );
}
Лст. 1-7. Код функций getchar и putchar.

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

Имена функций getchar - взять (прочитать, извлечь) символ и putchar - положить (вывести, напечатать) символ просты и понятны всем англоязычным, а теперь и русским, пользователям языка C, С-программистам. К сожалению, этого не скажешь про множество других функций.

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

5. Тест написанных функций на примере Enigma
Была такая машинка шифровальная, широко известная по грешным делам Кригсмарине времен WW2 (Функшлюссель Ц). Вояки выкупили коммерческую машинку, усилили ее добавочными дисками и снабдили ею свой флот, бывший, в основном, подводным. Предполагалось, что расшифровка займет три месяца, а за это время соответствующая информация устареет. Сама суть шифрования заключалась в том, что каждая буква в тексте преобразовывалась в другую по заданному ключу. И если частотный анализ позволял легко восстановить исходную букву для одной операции, то для двух сделать это было уже труднее, трех, четырех - очень сложно и так далее...

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

К сожалению, мы еще не добрались до разбора аргументов командной строки, поэтому просто зашьем ключ шифрования в саму программу. Пусть его длина равняется четырем, как пин-код в телефоне. Однако, мы можем использовать любые символы, набираемые с клавиатуры.

//enigma.c

// функции, которые мы тестируем:

int getc( int f )
{
int r,e;
e = read( f, (int*)&r, 1 );
if( e == -1 || e == 0 ) return -1;
return r;
}

int putc( int c, int f )
{
if( write( f, &c, 1 ) )
return c;
else return -1;
}

int getchar()
{
return getc( 0 );
}

int putchar( int c )
{
return putc( c, 1 );
}

// тестовая программа для функций:

char *key = "qwer"; // это ключ шифрования

int crypt( int c ) // шифровальная машина
{
static int i = 0;
int e;
i = i > 3 ? 0 : i; // "ротор" машины
e = key[i++] ^ c; // функция шифрования
return e;
}

main()
{
int c;

while( 1 ) // бесконечный цикл
{
c = getchar();
if( c == -1 ) return;
c = crypt( c );
putchar( c );
}
}
Лст. 1-8. Библиотечка для теста и шифровальная машина.

Работает все это достаточно просто. Программа работает в бесконечном цикле. В этом нет ничего страшного, в таком же цикле работает и любая операционная система. Всегда наступит некое событие, которое это цикл прервет (хотя бы Страшный Суд). В нашем случае цикл прервется в конце файла, поступающего на вход машины.

Внутри цикла читается очередной символ в переменную c. Сразу же проверяем: не пора ли закругляться? Если нет, запускаем машину, то есть, входим в функцию crypt. Функция получает символ и шифрует его. Зашифрованный символ помещается в ту же переменную, откуда был взят. Затем остается только направить его в выходной поток. Вот и вся работа на этом уровне.

Внутри crypt содержится код, на основе статической переменной i, симулирующий работу колеса настоящей машины. Чтобы "колесо" действительно вращалось, мы проверям значение i, и в случае, если оно больше 3, присваиваем ему значение 0. Таким образом, мы перебираем значение ключа key по кругу, что и означает работу колеса.

Сама же функция шифрования - это просто логическая операция XOR, предложенная еще американским инженером из AT&T Джилбертом Вернамом для шифрования телеграфных кодов. Она обратима, как мы позже увидим.

Посмотрим, как работает машинка:



Рис. 1-4. Работа шифровальной машины и тест функций.

Мы создали кодированный файл wetterbericht на диске. При попытке напечатать его мы получили абракадабру, содержащую непечатаемые символы. Для бумажной машинки это недопустимо, но для компьютерного файла, - почему бы и нет? В крайнем случае, мы всегда можем напечатать шестнадцатеричный дамп этого файла.

Мы также просто можем расшифровать зашифрованный файл:



Рис. 1-5. Повторное шифрование шифра.

"Весит" наша машинка всего 904 байта.

Если взять не ключ длиной 4 байта, а случайную последовательность символов очень большой длины, например, читаемую из отдельного файла, то можно обеспечить абсолютно стойкое шифрование. Такой прием использовали дипломаты и шпионы в докомпьютерную эру. Случайный файл назывался шифроблокнотом. Если оба корреспондента имеют такой большой файл, размером в несколько гигабайт, то можно шифровать не меньшее количество текста, при условии, что каждый кусок "ключа" используется однократно. Сгенерировать такой файл можно, имея генератор белого шума (например, на основе диода, включенного определенным образом) и АЦП на 8 бит, если направить поток образующихся данных в файл. Это будет истинно случайная последовательность, а не псевдослучайная, которая обычно применяется в компьютерах.

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

Что касается нашей машинки, это всего лишь игрушка, не так ли? Читателю предлагается как-нибудь на досуге выяснить, что означает:

a1 eb b5 cc a1 cc b5 cc a1 c3 b5 c7 a0 f1 44 52
a1 e0 b5 c2 a0 f0 b5 c7 a0 f5 4b 78
Лст. 1-9. Послание потомкам.

Метод кодирования и длина ключа прежние. Надо найти ключ и закодированный текст.

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

А пока займитесь переносом кода в отдельные объектные модули.


Дальше

символы и байты, enigma, c

Previous post Next post
Up