среда, 12 октября 2011 г.

FPC-Notes: «Hello, World!»

program Hello;

begin
 WriteLn('Hello, World!') 
end.
$ fpc hello
Target OS: Linux for x86-64 
Compiling hello.pp
Linking hello
5 lines compiled, 0.9 sec
$ ./hello
Hello, World!
$

Начнем по традиции с простейшей программы, делающей хоть что-то... Кто сказал «что-то полезное»? До чего-то полезного нам еще пилить и пилить. Достаточно типовой «Hello, World!» можно увидеть во врезке. Теперь можно сохранить этот текст в файл (например, hello.pp1), скомпилировать командой fpc hello и запустить. См. консольный фрагмент — если вы работаете в Linux или FreeBSD, результат должен быть похож. Да и в других системах отличия не особо существенны. Еще тут может выпасть предупреждение от компоновщика — на него внимания обращать не надо. Получилось? Теперь будем разбираться, что именно.

В действительности, это не самый минимальный пример — я таки оставил одну необязательную строку, а именно — первую... Но давайте по порядку. Исходный код программы начинается с необязательного заголовка, состоящего из ключевого слова program и идентификатора. Заканчиваться идентификатор должен точкой с запятой, как и любое предложение языка. Впрочем, не совсем так.

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

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

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

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

Затем, как мы можем видеть, идет исполняемый блок, начинающийся с ключевого слова begin и заканчивающийся ключевым словом end с точкой. Между ними расположены собственно исполняемые операторы. В нашем случае — один оператор вызова процедуры. Системная процедура WriteLn() записывает строку в стандартный вывод (или в произвольный текстовый файл, если он указан).

Глядя на вывод компилятора можно подумать, что формирование исполняемого файла происходит в два этапа — «Compiling» и «Linking». Так оно и есть. И мы сейчас немного о них поговорим.

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

Пара слов о компиляции

Если мы посмотрим в каталог, где только что скомпилировали нашу программу, то помимо исходного hello.pp и конечного исполняемого hello (hello.exe на некоторых ОС), увидим файл hello.o. Это так называемый объектный файл, содержащий результат компиляции — машинный код, не содержащий служебной информации, необходимой для запуска программы, зато содержащий служебную информацию, нужную компоновщику, а именно — символьные имена функций, переменных и т.д., которые компоновщик уже потом преобразует в адреса.

.section .text
    .balign 8,0x90
.globl  PASCALMAIN
    .type   PASCALMAIN,@function
PASCALMAIN:
.globl main
    .type   main,@function
main:
# [hello.pp]
# [3] begin
    subq    $8,%rsp
    movq    %rbx,(%rsp)
    call    FPC_INITIALIZEUNITS
# [4] WriteLn('Hello, World!')
    call    fpc_get_output
    movq    %rax,%rbx
    movq    %rbx,%rsi
    movq    $_$HELLO$_Ld1,%rdx
    movl    $0,%edi
    call    fpc_write_text_shortstr 
    call    FPC_IOCHECK
    movq    %rbx,%rdi
    call    fpc_writeln_end
    call    FPC_IOCHECK
# [5] end.
    call    FPC_DO_EXIT
    movq    (%rsp),%rbx
    addq    $8,%rsp
    ret

.section .rodata
    .balign 8
.globl      _$HELLO$_Ld1
_$HELLO$_Ld1:
    .ascii "\015Hello, World!\000"

Мы можем дизассемблировать этот файл посредством утилиты objdump, однако это не лучший способ. Гораздо удобнее воспользоваться тем, что FPC позволяет получить и сохранить ассемблерный результат компиляции2. Для этого нам нужно выполнить команду fpc -al hello. Рядом с прочими появился еще один файл — hello.s, который содержит ассемблерный код. Тем, кто раньше пользовался ассемблером вроде MASM или TASM этот код покажется странным и непривычным, поскольку составлен в так называемом AT&T-синтаксисе, а не в синтаксисе Intel3. Файл довольно большой, поэтому я не буду приводить его полностью, а возьму только то, что имеет отношение к непосредственно коду. Всякая служебная и отладочная информация нам сейчас ни к чему.

На врезке видно, что́ у меня получилось. Если у вас не 64-битная система, то соответствующий фрагмент может отличаться, скорее всего — именами регистров и суффиксами команд. Тем не менее, общий смысл должен сохраниться — исполняемый блок pascal-программы объявлен в виде функции с двумя именами PASCALMAIN и main, а строка, которую мы указали в явном виде при вызове WriteLn() помещена в секцию неизменяемых данных под автоматически сгенерированным именем _$HELLO$_Ld1.

Из последовательности команд можно сделать вывод, что данный код не обращается напрямую к каким-либо системным вызовам, а использует подпрограммы из стандартной библиотеки Free Pascal. Об этом нам явственно сообщают префиксы fpc_ и FPC_. Этих подпрограмм мы не писали, и в файле hello.o их нет. Но где-то же они есть... Это «где-то» — стандартный модуль System и, соответственно, объектный файл system.o, который расположен в каталоге модулей компилятора. Этот модуль используется всегда, а объектный файл так же всегда передается компоновщику, который сопоставляет символьные имена там и там и подставляет вместо них нужные адреса — компонует программу из множества отдельных объектных файлов.

Однако мы нигде не видим собственно вызова WriteLn(). Вместо него вызываются целых три разных подпрограммы: fpc_get_output, fpc_write_text_shortstr и fpc_writeln_end... Вот так, неожиданно мы узнали «страшную тайну» — процедура WriteLn() на самом деле настоящей процедурой не является, а обрабатывается компилятором совершенно особым образом. Как должны компилироваться и вызываться настоящие процедуры и функции мы обязательно рассмотрим когда-нибудь потом. Пока можно отметить, что синтаксис языка вообще не позволяет объявить процедуру такого типа. Тем не менее, WriteLn() и еще несколько процедур ввода/вывода предусмотрены в определении языка и должны быть так или иначе реализованы. Просто отметим это как факт и не будем заморачиваться.

Может возникнуть закономерный вопрос: а зачем нам знать, что WriteLn() — ненормальная? Отвечаю: хотя бы для того, чтобы не терять в дальнейшем времени на попытки выяснить, как объявить свою процедуру с аналогичным синтаксисом, или почему не получается использовать с ней процедурные переменные...

Что еще интересного мы можем узнать из нашего ассемблерного листинга? Обратим внимание на секцию данных — совершенно четко видно, что строка, заданная нами в программе непосредственно между одинарными кавычками — строковый литерал — а) трактуется как «короткая» (тип ShortString), но б) при этом завершается нулевым байтом, чтобы при необходимости приведения ее в дальнейшем к типу «длинной» строки или PChar не требовалось выполнять какие-либо преобразования. Как показали эксперименты, если строковый литерал содержит символы не из первой половины кодовой таблицы ASCII (например, русские буквы), и при этом явно определена кодировка исходника, посредством ключа -FcXXX или директивы {$CODEPAGE XXX}, такой литерал будет представлен уже в виде UnicodeString. Должен заметить, что внутреннее представление литералов зависит от версии компилятора и может поменяться в дальнейшем. Его стоит иметь в виду, но полагаться на него в серьезных проектах нельзя.

Теперь становится очевидным известное из документации ограничение для непосредственно указываемых строк в 255 байт.

Пара слов о компоновке

Компоновка может производиться на некоторых платформах самим FPC, а на других (и в частности, на моем Linux-amd64) посредством GNU-компоновщика ld [3]. Принципиальных отличий между этими двумя способами нет — при нормальной работе компоновщик вызывается компилятором автоматически, без участия пользователя. Мы лучше рассмотрим другой момент.

Скомпилировав исполняемый файл, как было описано в начале заметки, мы можем изрядно удивиться, посмотрев на его размер — более 100 KB (конкретно сейчас на моей системе получается 155 KB). Казалось бы, зачем для такой простой задачи так много кода? Впрочем, в предыдущем подразделе я написал, что помимо объектного кода собственно программы, компоновщику передается еще и объектный файл модуля System, а ведь там не только те несколько подпрограмм, вызов которых присутствует в листинге, но и множество других для разнообразных задач. Вот только эти задачи актуальны не для нашей программы, а для каких-то других. Таким образом получается, что мы тянем за собой кучу неиспользуемого кода, что не есть хорошо.

Чтобы этого не происходило, следует использовать так называемое «умное связывание» (smartlinking). В этом случае, компоновщик использует не большой объектный файл, такой как system.o, а маленькие объектные фрагменты, собранные в объектную библиотеку (или объектный архив), т.е. в файл libpsystem.a, откуда он берет только нужные данной программе фрагменты (подпрограммы, переменные и т.п.). Включив «умное связывание» через параметр командной строки -XX, т.е. выполнив fpc -XX hello, мы увидим, что размер исполняемого файла резко сократился (у меня сейчас 27 KB).

Однако, надо бы разобраться, откуда берутся .a-файлы. Хотя о модулях мы еще не говорили, замечу, что при умолчательной компиляции из модуля генерируется обычный объектный файл, а не библиотека. Чтобы получить библиотеку, модуль нужно компилировать с ключом -CX, или указать в самом модуле директиву компилятора {$SMARTLINK ON}. Для большинства стандартных модулей RTL (и в частности, System) это уже сделано, так что нам остается только не забыть -XX. Для модулей, компилируемых на месте, самописных или полученных откуда-то в исходном коде, генерацию .a надо контролировать.

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

Еще один нюанс заключается в том, что «умное связывание» не может избавить нас от неиспользуемого кода в виртуальных методах объектов.

На этом я, пожалуй, закончу приветствовать мир...


1) FPC ищет исходные тексты по имени, подставляя расширения .pas, .pp и .p. Второй вариант указывает, что исходник предназначен именно для FPC, а не для произвольного компилятора Pascal. Именно его мы и будем использовать.

2) В некоторых источниках можно прочитать, что FPC не генерирует сам машинный код, а работает через промежуточный ассемблер. Для современных версий Free Pascal это не совсем верно. На самом деле на архитектурах x86 и x86-64 (наиболее распространенных) компилятор может использовать внешний ассемблер, но без особого указания генерирует машинный код самостоятельно. Тогда как, например, для процессоров ARM внешний ассемблер действительно необходим.

3) Различия ассемблеров совершенно определенно не являются предметом этих заметок, так что могу лишь порекомендовать ознакомиться с краткой характеристикой на Википедии [1] или обратиться к руководству по GNU-ассемблеру [2].


Ссылки

[1] Википедия: AT&T-синтаксис
http://ru.wikipedia.org/wiki/AT&T-синтаксис [ru]

[2] Documentation for binutils 2.21: Using as
http://sourceware.org/binutils/docs-2.21/as/ [en]

[3] Documentation for binutils 2.21: LD
http://sourceware.org/binutils/docs-2.21/ld/ [en]

6 комментариев:

  1. > Вернемся к нашим баранам...

    Может быть, стоит поместить описание разных секций программы в виде комментариев прямо в код? Новичкам будет нагляднее, мне кажется

    > Пока можно отметить, что синтаксис языка вообще не позволяет объявить процедуру такого типа.

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

    > Теперь становится очевидным известное из документации ограничение для непосредственно указываемых строк в 255 байт.

    Т.е. строковые литералы всё ещё реализуются как ShortString, я правильно понял? И вдобавок, как ASCII? Зря, зря...

    А вообще интересно было читать, давно я не сталкивался с Паскалем. Буду ждать продолжения.

    ОтветитьУдалить
  2. 1. Про секции я буду писать подробно и отдельно. Здесь не хотелось на этом останавливаться.

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

    3. По крайней мере во Free Pascal литералы реализуются как ShortString. При этом строковую константу большего размера объявить можно — как сумму таких литералов.

    Что касается ASCII — так это более чем логично, что исходник находится в однобайтовой кодировке (впрочем, вполне работает с UTF-8, но размер литерала ограничивается все равно в байтах, а не символах), а литералы вынуждены быть в кодировке исходника.

    ОтветитьУдалить
  3. > Впрочем, тогда вообще какой-то логической стройностью не особо заморачивались.

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

    > При этом строковую константу большего размера объявить можно — как сумму таких литералов.

    И что случится, если строка получится больше 255 байт? Обрежется или переконвертируется в длинную строку? И если обрежет UTF-8, то потенциально обрежет посреди символа?

    ОтветитьУдалить
  4. Так и FPC прекрасно бутстрапится. То, что вместо функции оператор — это другой вопрос. Ну и переменное число аргументов тут не поможет. На «Hello, World!» этого не заметно, но на самом деле WriteLn() — это переменное число вызовов, т.е. внутренние fpc_write_нечто() вызываются для каждого аргумента (и зависят от его типа).

    Строковый литерал больше 255 символов просто не скомпилируется, зачем что-то обрезать? А константа, полученная из литералов сложением, будет описана как AnsiString.

    ОтветитьУдалить
  5. > То, что вместо функции оператор — это другой вопрос.

    ОК, сформулирую иначе: невозможно написать то, что принято называть "стандартной библиотекой языка Pascal" (включая read/readln/write/writeln) на языке Pascal.

    > на самом деле WriteLn() — это переменное число вызовов

    Я когда-то крякал программы на TurboPascal-е, сталкивался =)

    > А константа, полученная из литералов сложением, будет описана как AnsiString.

    Хорошо, спасибо.

    ОтветитьУдалить
  6. Я немного проапдейтил абзац про строки — там есть тонкости...

    ОтветитьУдалить