D1 От C-кода до бинарной программы
Вы уже изучили и использовали C на этапе E, а также запускали на процессоре C-программы, которые компилировали самостоятельно. После знакомства с разработкой на C в Linux вы уже знаете, что программа может быть запущена только после того, как C-код будет преобразован в бинарный исполняемый файл. Так как же компилятор превращает C-код в исполняемый файл? Какова связь между исполняемым файлом и инструкциями, выполняемыми процессором? Чтобы ответить на эти вопросы, мы воспользуемся некоторыми инструментами Linux и подробнее разберём этапы этого процесса.
Препроцессинг
Препроцессинг — это этап перед собственно компиляцией, и по своей сути он представляет собой обработку текста. Препроцессинг в основном включает следующие задачи:
- Подключение заголовочных файлов
- Подстановка макросов
- Удаление комментариев
- Объединение строк, разделённых символами продолжения строки (
\в конце строки) - Обработка условной компиляции
#ifdef/#else/#endif - Обработка оператора преобразования в строку
# - Обработка оператора склеивания токенов
##
Например, рассмотрим следующий C-код:
// a.c
#include <stdio.h>
#define MSG "Hello \
World!\n"
#define _str(x) #x
#define _concat(a, b) a##b
int main() {
printf(MSG /* "hi!\n" */);
#ifdef __riscv
printf("Hello RISC-V!\n");
#endif
_concat(pr, intf)(_str(RISC-V));
return 0;
}
Вы можете выполнить препроцессинг приведённого выше C-кода с помощью:
gcc -E a.c
Посмотрите результат препроцессинга
Попробуйте выполнить приведённую выше команду gcc, а затем сравните результат препроцессинга с исходным файлом.
Один вопрос, который стоит обсудить: как gcc находит заголовочные файлы? Чтобы ответить на этот вопрос, мы можем изучить логи инструмента и соответствующие руководства.
Как находятся заголовочные файлы
- Попробуйте выполнить
gcc -E a.c --verbose > /dev/nullи найдите в выводе содержимое, связанное с заголовочными файлами. - Найдите и прочитайте описание опции
-Iвman gcc.
После того как разберётесь с этим, попробуйте создать несколько файлов stdio.h, а затем с помощью опции -I заставьте gcc подключить созданный вами stdio.h вместо файла из стандартной библиотеки. Используйте опцию -E, чтобы проверить, соответствует ли результат препроцессинга вашим ожиданиям.
Посмотрите результат препроцессинга (2)
Попробуйте выполнить препроцессинг с помощью gcc для архитектуры RISC-V:
riscv64-linux-gnu-gcc -E a.c
Проверьте результат препроцессинга в этом случае. Какие новые изменения вы заметили?
Макрос __riscv в приведённом выше C-коде является особым: он предопределён в riscv64-linux-gnu-gcc. Поэтому, даже несмотря на то что он напрямую не определён в C-коде, riscv64-linux-gnu-gcc всё равно считает его определённым. Все предопределённые макросы можно посмотреть с помощью следующей команды:
echo | gcc -dM -E - | sort
Эта команда заставляет gcc выполнить препроцессинг пустого файла, затем вывести все макросы, определённые в ходе этого процесса, и отсортировать их.
Сравните предопределённые макросы gcc и riscv64-linux-gnu-gcc
Попробуйте сравнить предопределённые макросы gcc и riscv64-linux-gnu-gcc, чтобы понять различия между ними во время препроцессинга. Вам достаточно получить общее представление об этих различиях; нет необходимости глубоко разбираться в конкретном смысле каждого макроса.
Подсказка:
- Использование
diffили связанных команд поможет быстро найти различия между двумя файлами. - Если вы хотите понять смысл некоторых макросов, обратитесь к руководству
gcc.
Компиляция
В широком смысле компиляция — это процесс преобразования одного языка в другой. Для компилятора C компиляция — это процесс преобразования C в целевой язык. Этот целевой язык связан с ISA и обычно представляет собой язык ассемблера целевой ISA. Например, на компьютере с архитектурой x86 gcc преобразует C в ассемблер x86, а riscv64-linux-gnu-gcc преобразует C в ассемблер riscv64.
Этот процесс включает множество деталей, и здесь мы не будем углубляться в то, как реализован каждый его шаг. Вместо этого мы воспользуемся подходящими инструментами, чтобы понять, что делает каждый шаг, и сформировать базовое представление о процессе компиляции. Для этого вам необходимо установить clang, который функционально эквивалентен gcc и также является компилятором C.
apt-get install clang
Мы будем использовать следующую программу, чтобы наблюдать этапы процесса компиляции:
// a.c
#include <stdio.h>
int main() { // compute 10 + 20
int x = 10, y = 20;
int z = x + y;
printf("z = %d\n", z);
return 0;
}
Разберитесь в процессе компиляции
Попробуйте прочитать man clang, особенно введение в этапы компиляции, чтобы получить общее представление о процессе компиляции.
Лексический анализ
Лексический анализ распознаёт и записывает каждый токен в исходном файле, включая идентификаторы, ключевые слова, константы, строки, операторы, фигурные скобки, точки с запятой и так далее. Если встречается недопустимый токен, например @, сообщается об ошибке. Результат лексического анализа можно посмотреть с помощью:
clang -fsyntax-only -Xclang -dump-tokens a.c
Можно увидеть, что результат лексического анализа также записывает положение каждого токена в формате имя файла:номер строки:номер столбца.
На самом деле исходный файл C по сути является текстовым файлом, поэтому его также можно рассматривать как строку, а инструмент лексического анализа — как программу сопоставления строк.
Лексический анализ и подсветка синтаксиса
Вы, вероятно, использовали подсветку синтаксиса в редакторе. На самом деле эту функцию реализовать несложно: в соответствии с определениями стандарта C можно написать простой инструмент лексического анализа, который распознаёт некоторые ключевые слова в C-коде и выводит их разными цветами. Если вы хотите выводить результат в терминал, можно использовать цветовые возможности ANSI escape codes.
Синтаксический анализ
Синтаксический анализ организует распознанные токены в древовидную структуру в соответствии с синтаксисом C, тем самым проясняя иерархическую структуру исходной программы: от файлов и функций до операторов, выражений, переменных и так далее. Если встречается синтаксическая ошибка, например пропущенная точка с запятой, сообщается об ошибке. Результат синтаксического анализа обычно представляется в виде Abstract Syntax Tree (AST, абстрактного синтаксического дерева). Результат синтаксического анализа можно посмотреть с помощью:
clang -fsyntax-only -Xclang -ast-dump a.c
Семантический анализ
Семантический анализ определяет тип каждого выражения в AST в соответствии с семантикой C. В ходе этого процесса совместимые типы преобразуются в соответствии со стандартом C, например выполняется арифметическое продвижение типов. Случаи, не соответствующие семантике, сообщаются как ошибки. К случаям, которые синтаксически корректны, но семантически неверны, относятся неопределённые ссылки, несовпадение типов операндов для операторов, например struct mytype a; int b = a + 1;, а также несоответствие типа или количества аргументов при вызове функции.
В clang типы выражений уже включаются в вывод AST. На самом деле большинство компиляторов не разделяют строго синтаксический и семантический анализ.
Важным применением семантического анализа является статический анализ программ, то есть анализ исходного кода без запуска программы. По своей сути это анализ семантической информации в AST. Анализировать можно стиль и соглашения оформления кода, потенциальные дефекты программного обеспечения, уязвимости безопасности, проблемы производительности и так далее. Например, рассмотрим следующую программу:
// a.c
#include <stdlib.h>
int main() {
int *p = malloc(sizeof(*p) * 10);
free(p);
*p = 0;
return 0;
}
Эта программа соответствует синтаксису C, и каждый оператор, рассматриваемый по отдельности, также соответствует семантике C. Если напрямую скомпилировать её командой gcc a.c, ошибка не будет выдана, а скомпилированная программа даже может успешно выполниться. Однако если добавить опцию компиляции -Wall, gcc выполнит дополнительные проверки кода и выдаст предупреждение о проблеме use-after-free, то есть об обращении к памяти после её освобождения. Инструменты, которые сообщают о потенциальных проблемах в коде с помощью статического анализа программ, называются lint-инструментами.
Аналогично можно вызвать lint-инструмент clang для анализа приведённой выше программы:
clang a.c --analyze -Xanalyzer -analyzer-output=text
Серьёзно относитесь к lint-инструментам
Некоторые начинающие считают, что требование от компилятора выдавать больше предупреждений создаёт дополнительную работу при программировании. На самом деле использование lint-инструментов почти ничего не стоит разработчикам, но при этом помогает обнаруживать множество потенциальных проблем. Если эти проблемы попадают на этап выполнения программы, разработчикам приходится платить гораздо более высокую цену за их отладку. Особенно в крупных проектах проблемы, вызванные кодом, подобным приведённому выше, могут быть трудно обнаружимы, а программа может внезапно завершиться с ошибкой после долгой работы, что сильно усложняет отладку. Поэтому крупные проекты обычно активно используют lint-инструменты, чтобы максимально повысить качество проекта.
Генерация промежуточного кода
Промежуточный код — это ISA, определённая компилятором для задач компиляции. Его также называют Intermediate Representation (IR, промежуточное представление) или Intermediate Language (промежуточный язык). Промежуточный код, сгенерированный clang, можно посмотреть с помощью:
clang -S -emit-llvm a.c
cat a.ll
Вспомните модель конечного автомата: основная задача компиляции — перевести автомат C-программы в автомат ISA, то есть преобразовать переменные C в регистры или память, а операторы C — в последовательности инструкций. Поскольку промежуточный код также можно рассматривать как ISA, генерацию промежуточного кода можно понимать с точки зрения модели конечного автомата: переменные C переводятся в переменные промежуточного кода, например %1, %2, %3; операторы C переводятся в инструкции промежуточного кода, например alloca, store, load, add, call и ret. Разумеется, процесс перевода должен опираться на семантику, извлечённую из AST, чтобы поведение переведённого промежуточного кода было эквивалентно поведению исходной C-программы.
Почему бы не переводить сразу в целевой язык, то есть в связанную с процессором ISA? С одной стороны, существует множество процессорных ISA. Если бы мы напрямую переводили в каждую процессорную ISA и при этом хотели оптимизировать программы, каждую технику оптимизации пришлось бы реализовывать отдельно для разных ISA, что повысило бы стоимость сопровождения компилятора. Если же сначала переводить в промежуточный код, а затем в процессорную ISA, техники оптимизации достаточно реализовать только для промежуточного кода.
С другой стороны, промежуточный код может служить мостом между множеством исходных языков, например C, Fortran и Haskell, и множеством целевых языков, например x86, ARM и RISC-V. Предположим, существует исходных языков и целевых языков. При прямом переводе в целевые языки потребовалось бы реализовать модулей трансляции. Если ввести промежуточный код и разделить процесс компиляции на frontend и backend, используя промежуточный код как границу между ними, потребуется всего модулей трансляции: frontend-модулей переводят исходных языков в промежуточный код, а backend-модулей переводят промежуточный код в целевых языков.
frontend backend
+----------+ +------------+
C -> | Clang | -+ +-> | llvm-x86 | -> x86
+----------+ | | +------------+
+----------+ +-> +----------+ -+ +------------+
Fortran -> | llvm-gcc | ---> | llvm-opt | ---> | llvm-arm | -> ARM
+----------+ +-> +----------+ -+ +------------+
+----------+ | | +------------+
Haskell -> | GHC | -+ +-> | llvm-riscv | -> RISC-V
+----------+ LLVM IR LLVM IR +------------+
Разные компиляторы могут использовать разный промежуточный код. Например, промежуточный код, используемый clang, называется LLVM IR, а промежуточный код, используемый gcc, называется GIMPLE. Однако нам не нужно разбираться в конкретных деталях промежуточного кода. Достаточно общего понимания генерации промежуточного кода с точки зрения модели конечного автомата.
Оптимизация при компиляции
Оптимизация при компиляции — важный этап современной разработки программного обеспечения. Благодаря оптимизации разработчики могут сосредоточиться на бизнес-логике программы и не слишком задумываться о производительности во время разработки; компиляторы обычно обеспечивают достаточно хороший нижний уровень производительности. В реальных проектах широко применяются техники оптимизации при компиляции, и в будущем вы также будете запускать множество оптимизированных программ на процессорах, разработанных вами самостоятельно. Поэтому понимание некоторых распространённых техник оптимизации и причин, по которым компиляторы генерируют определённые последовательности инструкций, поможет в будущей отладке и оптимизации архитектуры.
Определение корректности оптимизации при компиляции
Оптимизацию при компиляции можно понимать с точки зрения поведения программы: если две программы в некотором смысле «эквивалентны», то «простая» программа может заменить «сложную». Поведение, при котором операторы выполняются один за другим в соответствии со стандартом C, называется «строгим выполнением». Используя «строгое выполнение» как эталон, стандарт C строго определяет упомянутую выше «эквивалентность»: оптимизированная программа должна сохранять «наблюдаемое поведение программы» (стандарт C99, раздел 5.1.2.3, пункт 6), которое конкретно включает:
- Обращения к переменным, квалифицированным
volatile, должны выполняться строго. - При завершении программы данные, записанные в файлы, должны соответствовать строгому выполнению.
- Ввод и вывод интерактивных устройств (
stdio.h) должны соответствовать строгому выполнению.
«Наблюдаемое поведение» описывает влияние C-программы на внешний мир с внешней точки зрения. Например, второй пункт требует, чтобы внешние операции без требований реального времени «выглядели одинаково» в конце, а третий пункт требует, чтобы внешние операции с требованиями реального времени «выглядели одинаково» во время выполнения. Первый пункт ограничивает внутреннее поведение C-программы и на самом деле связан с «memory-mapped I/O», упоминавшимся на этапе F. Здесь мы не будем подробно на этом останавливаться; связанное содержимое будет продолжено на этапе D.
Таким образом, пока оптимизированная программа сохраняет наблюдаемое поведение, оптимизация является «корректной». При этом, если в оптимизированной программе меньше переменных или операторов, можно ожидать более высокой производительности.
Примеры техник оптимизации при компиляции
Ниже приведены некоторые распространённые техники оптимизации при компиляции. Обратите внимание, что в процессе компиляции техники оптимизации не применяются непосредственно к C-коду, но для удобства понимания мы используем C-код, чтобы показать семантику до и после оптимизации.
- Распространение констант — если значение переменной является константой, это значение можно подставить в места использования переменной. Если после подстановки получается константное выражение, его значение можно вычислить сразу. В следующем примере значение
aявляется константой, поэтомуa + 2тоже является константой, вследствие чегоbтакже становится константой. Далееb * 3также является константой. Компилятор может вычислить эти константные выражения напрямую и заменить их результатами вычислений, поэтому ему не нужно генерировать соответствующие инструкции, например сложение и умножение, чтобы вычислять эти выражения во время выполнения.
// Before | After
int a = 1; | int a = 1;
int b = a + 2; | int b = 3;
printf("%d\n", b * 3); | printf("%d\n", 9);
- Удаление мёртвого кода — недостижимый код или переменные, которые больше не используются, могут быть удалены. В следующем примере макрос
DEBUGопределён как0, поэтому код внутри блокаifникогда не будет выполняться и может быть удалён. После удаления кода внутриifпеременнаяaбольше не используется и также может быть удалена.
// Before | After
#define DEBUG 0 | #define DEBUG 0
int fun(int x) { | int fun(int x) {
int a = x + 3; | return x / 2;
if (DEBUG) { | }
printf("a = %d\n", a); |
} |
return x / 2; |
} |
- Удаление избыточных операций — присваивания, значения которых перезаписываются до того, как будут прочитаны, можно удалить. В следующем примере присваивание
a = 3будет перезаписано выражениемa = f(), поэтому первое можно удалить. Аналогично можно удалить присваиванияa = f()иa = 7.
// Before | After
int a; | int a;
a = 3; | f();
a = f(); | a = 10;
a = 7; |
a = 10; |
Можно ли оптимизировать ещё сильнее?
В оптимизированном результате выше вызов функции f() сохранён. Можно ли дополнительно удалить f()? Почему?
- Снижение силы операции — замена сложных операций более простыми. В следующем примере
i * 4иi << 2имеют одинаковую семантику, когда их поведение определено. Но на большинстве компьютеров инструкции умножения дороже инструкций сдвига. Замена первого вторым может повысить производительность программы.
// Before | After
int x = a[i * 4]; | int x = a[i << 2];
- Устранение общих подвыражений — для подвыражений, вычисляемых несколько раз, можно использовать промежуточную переменную, сохраняющую результат, чтобы последующий код мог напрямую ссылаться на него без повторного вычисления. В следующем примере
a * bвстречается дважды. Введениеtempдля хранения результатаa * bпозволяет убрать одно умножение и повысить производительность.
// Before | After
int x = a * b - 1; | int temp = a * b;
int y = a * b * 2; | int x = temp - 1;
| int y = temp * 2;
- Вынесение инвариантного кода из цикла — код, результат которого одинаков в каждой итерации цикла, можно вынести перед циклом и вычислить один раз. В следующем примере выражение
a + 2даёт одинаковый результат в каждой итерации, поэтому его можно вынести перед циклом и вычислить один раз, избегая повторного вычисления в каждой итерации.
// Before | After
int a = f1(); | int x = f1() + 2;
for (i = 0; i < 10; i ++) { | for (i = 0; i < 10; i ++) {
int x = a + 2; | int y = f2(x);
int y = f2(x); | sum += y + i;
sum += y + i; | }
} |
Можно ли оптимизировать ещё сильнее? (2)
В оптимизированном результате выше f2(x) всё ещё находится внутри цикла. Можно ли вынести f2(x) перед циклом и вычислить там? Почему?
- Встраивание функций — для небольших функций можно развернуть их код непосредственно в месте вызова, чтобы избежать накладных расходов на вызов функции. В следующем примере
f1(x, 3)внутриf2()можно напрямую развернуть в вычислениеx + 3, избежав процесса вызова и возврата из функции и тем самым повысив производительность.
// Before | After
int f1(int x, int y) { | int f1(int x, int y) {
return x + y; | return x + y;
} | }
int f2(int x) { | int f2(int x) {
return f1(x, 3); | return x + 3;
} | }
Можно ли оптимизировать ещё сильнее? (3)
В примере выше предположим, что f1(x, 3) — единственный вызов f1() в этом исходном файле. Можно ли применить удаление мёртвого кода и удалить f1() из оптимизированного результата? Почему?
Помимо описанных выше техник существует множество других техник оптимизации при компиляции, например анализ индукционных переменных, разворачивание циклов, программная конвейеризация, автоматическое распараллеливание, анализ алиасов и указателей и так далее. Здесь мы не будем подробно их рассматривать. Заинтересованные студенты могут обратиться к соответствующим материалам.
Включение оптимизации при компиляции
Можно передать clang опцию -O1, чтобы включить больше оптимизаций:
clang -S -emit-llvm -O1 a.c
cat a.ll
Сравните результаты оптимизации при компиляции
Попробуйте сравнить промежуточный код, сгенерированный до и после добавления -O1. Какие различия вы заметили после добавления -O1?
Сравните результаты оптимизации при компиляции (2)
Попробуйте добавить ключевое слово volatile перед int x = 10, y = 20;, а затем снова сгенерировать промежуточный код с -O1. Какие отличия вы заметили по сравнению с промежуточным кодом, сгенерированным ранее?
Компиляторы обычно предоставляют разные уровни оптимизации, позволяя разработчикам выбирать компромисс между производительностью программы, размером кода, временем компиляции и другими показателями. Например, в gcc уровни оптимизации производительности программы расположены так: -Ofast > -O3 > -O2 > -O1 > -Og > -O0 (по умолчанию). Чем выше уровень оптимизации, тем выше производительность сгенерированной программы, но тем больше времени занимает компиляция. Большинство программных проектов, компилируемых с помощью gcc или clang, обычно используют -O2, что позволяет программам работать с хорошей производительностью. При -O3 gcc также пытается получить более высокую производительность за счёт генерации большего объёма кода. -Ofast действует более агрессивно: он может даже использовать стратегии оптимизации, нарушающие стандарты языка, в обмен на более высокую производительность. -Og использует только стратегии оптимизации, удобные для отладки. По сравнению с -O0, он может повысить производительность программы, сохраняя исходную структуру программы, например циклы и вызовы функций, чтобы сгенерированная последовательность инструкций хорошо соответствовала C-коду и была удобна для отладки.
Помимо производительности программы, gcc также предоставляет уровни оптимизации, ориентированные на размер кода: -Oz > -Os > -O1 > -O0 (по умолчанию). Для clang перечисленные выше опции оптимизации также применимы.
Один уровень оптимизации обычно включает множество техник оптимизации. Для gcc можно использовать -Q --help=optimizers, чтобы посмотреть техники оптимизации, включённые соответствующим уровнем. Например:
gcc -Q --help=optimizers -O1
Так можно увидеть, какие техники оптимизации включает -O1. Для clang можно использовать -ftime-report, чтобы посмотреть подэтапы, или passes, процесса компиляции:
clang -S -emit-llvm -O1 a.c -ftime-report
Разумеется, мы не требуем от вас понимания конкретных деталей каждой техники оптимизации. Если вам интересно, вы можете обратиться к соответствующим руководствам.
Генерация целевого кода
Генерация целевого кода переводит оптимизированный промежуточный код в целевой код, то есть в процессорную ISA. Аналогично мы можем понимать генерацию целевого кода с точки зрения модели конечного автомата: переменные промежуточного кода переводятся в переменные процессорной ISA, то есть %1, %2, %3 и так далее преобразуются в регистры или адреса памяти; инструкции промежуточного кода преобразуются в инструкции процессорной ISA, то есть такие инструкции, как alloca, store, load, add, call и ret, преобразуются в инструкции ISA процессора. Целевой код, сгенерированный clang, можно посмотреть с помощью:
clang -S a.c
cat a.s
Приведённая выше команда clang по умолчанию генерирует ассемблерный код для той же ISA, что и локальное окружение, например x86. Также можно передать clang опцию компиляции --target=xxx, чтобы генерировать соответствующий ассемблерный код. Например, --target=riscv64-linux-gnu заставляет clang генерировать ассемблер riscv64:
clang -S a.c --target=riscv64-linux-gnu
Процесс компиляции, при котором генерируется ассемблерный код для ISA, отличной от ISA локального окружения, называется кросс-компиляцией.
Используем riscv64 в качестве примера
Поскольку в системе отсутствует runtime-окружение riscv32, с настройками системы по умолчанию невозможно сгенерировать код riscv32, а последующие шаги также не смогут создать исполняемый файл riscv32.
Для удобства здесь мы используем riscv64, чтобы продемонстрировать процесс компиляции RISC-V. По сравнению с riscv32, riscv64 добавляет лишь несколько инструкций. Однако для понимания общего соответствия между C-кодом и ассемблерным кодом вам на данном этапе не нужно глубоко разбираться в конкретной семантике каждой инструкции.
Разберитесь в связи между C-кодом и последовательностями инструкций riscv
Прочитайте ассемблерный код riscv64, полученный при кросс-компиляции с помощью clang, и попробуйте указать, какой фрагмент ассемблерного кода был скомпилирован из какого фрагмента C-кода.
Разберитесь в связи между C-кодом и последовательностями инструкций riscv (2)
Добавьте -O1 и выполните компиляцию снова, чтобы получить ассемблерный код riscv64. Какие различия вы заметили в сгенерированном ассемблерном коде? Как он соответствует C-коду?
Как последний этап компиляции, генерация целевого кода также может выполняться с помощью gcc:
gcc -S a.c # compile to local ISA
riscv64-linux-gnu-gcc -S a.c # cross-compile to riscv64
Во время генерации целевого кода компилятор также выполняет оптимизации, связанные с целевой ISA. Например, при преобразовании переменных промежуточного кода в регистры ISA или память компилятор пытается размещать часто используемые переменные в регистрах, а менее часто используемые — в памяти. В современных компьютерах процессор обращается к регистрам гораздо эффективнее, чем к памяти, поэтому размещение часто используемых переменных в регистрах может повысить общую производительность программы. При переводе инструкций компилятор также старается генерировать последовательности с меньшим количеством инструкций, что также может повысить производительность. Эти стратегии имеют определённое теоретическое обоснование; здесь мы не будем подробно их рассматривать. Заинтересованные студенты могут обратиться к материалам по теории компиляторов.
Создание и выполнение бинарных файлов
Ассемблирование
Результатом компиляции является ассемблерный код. Мы уже знаем, что язык ассемблера является символьным представлением инструкций, поэтому ассемблерный код по своей сути представляет собой читаемый текст. Но схемы процессора не могут понимать текст, поэтому ассемблерный код всё ещё необходимо преобразовать в бинарное кодирование инструкций. Это задача этапа ассемблирования, а инструмент, выполняющий эту работу, называется ассемблером.
Ассемблер работает достаточно прямолинейно: в общем случае он обращается к руководству ISA и одну за другой переводит текстовые инструкции из ассемблерного кода в соответствующие бинарные кодировки, создавая объектный файл. Можно заставить clang сгенерировать ассемблированный объектный файл с помощью:
clang -c a.c
ls a.o
Однако содержимое объектного файла уже не является текстом, поэтому при открытии его в текстовом редакторе вы увидите нечитаемое содержимое. Чтобы посмотреть содержимое объектного файла, нужны инструменты разбора бинарных файлов, которые преобразуют бинарное содержимое в читаемый текст. Например, можно использовать objdump из пакета binutils (Binary Utilities) для разбора объектного файла:
objdump -d a.o
Процесс получения ассемблерного кода обратно из объектного файла называется дизассемблированием. Приведённая выше команда выдаёт дизассемблерный код x86, который похож на ассемблерный файл, сгенерированный компилятором. На самом деле, когда на этапе F вы вручную декодировали бинарные инструкции sISA, вы делали нечто похожее на дизассемблирование!
Чтобы получить объектный файл riscv64, необходимо выполнить кросс-компиляцию:
clang -c a.c --target=riscv64-linux-gnu
riscv64-linux-gnu-objdump -d a.o
Аналогично для создания объектных файлов можно использовать gcc:
gcc -c a.c
riscv64-linux-gnu-gcc -c a.c
Либо для дизассемблирования можно использовать инструментарий LLVM. Он способен автоматически распознавать архитектуру ISA, соответствующую объектному файлу, что более удобно:
llvm-objdump -d a.o # supports both x86 and RISC-V object files
Посмотрите результат дизассемблирования объектного файла riscv64
Используя приведённые выше команды, посмотрите результат дизассемблирования объектного файла riscv64 и сравните его с ассемблерным файлом, сгенерированным компилятором.
Компоновка
Компоновка объединяет несколько объектных файлов в окончательный исполняемый файл. Можно заставить clang создать скомпонованный исполняемый файл с помощью:
clang a.c
ls a.out
Исполняемые файлы также можно дизассемблировать с помощью objdump.
Однако вы обнаружите, что по сравнению с объектным файлом до компоновки (a.o) скомпонованный исполняемый файл содержит значительно больше содержимого. Чтобы подробнее понять, откуда оно берётся, можно посмотреть лог команды clang:
clang a.c --verbose
В конце лога можно увидеть команду, связанную с компоновкой. В ней также присутствует несколько объектных файлов с именами вида crt*.o. Здесь crt — сокращение от C runtime, то есть среды выполнения C-программ. Иными словами, процесс компоновки объединяет объектный файл, полученный после компиляции и ассемблирования a.c, с уже существующими объектными файлами, связанными со средой выполнения C, и в итоге создаёт исполняемый файл. Можно предположить, что исполняемый файл включает эти объектные файлы среды выполнения для предоставления необходимой поддержки при запуске исполняемого файла.
Аналогично можно создать исполняемый файл с помощью gcc:
gcc a.c
Также с помощью кросс-компиляции можно создать исполняемый файл riscv64:
clang a.c --target=riscv64-linux-gnu
riscv64-linux-gnu-gcc a.c
Посмотрите результат дизассемблирования исполняемого файла riscv64
Попробуйте создать исполняемый файл riscv64, посмотреть результат его дизассемблирования и сравнить его с объектным файлом до компоновки.
Процесс компоновки включает множество деталей. Сейчас вам не нужно в них углубляться; мы познакомимся с ними на этапе C.
Выполнение
Для x86 после компиляции исполняемого файла его можно запустить так:
./a.out
Вы уже знакомы с процессом выполнения инструкций процессором: выборка, декодирование, выполнение и обновление PC. Если последовательность инструкций программы размещена в памяти, а PC указывает на первую инструкцию, процессор автоматически выполнит программу.
Сравните производительность до и после оптимизации при компиляции
Ранее мы познакомились с различными опциями оптимизации при компиляции. Теперь вы можете на практике увидеть силу этих опций. В качестве примера можно использовать программу суммирования последовательности, измерить время выполнения программы при разных уровнях оптимизации и понять влияние разных уровней оптимизации на производительность программы.
Чтобы различия в производительности было проще измерить, необходимо увеличить последний член последовательности, тем самым увеличив время выполнения. Если последний член очень велик, можно также изменить тип переменной sum на long long. Для измерения времени выполнения команды можно использовать команду time. Например, time ls выводит время выполнения ls.
После настройки последнего члена последовательности скомпилируйте программу и измерьте время выполнения при -O0, -O1 и -O2.
Если вам интересно, можно также посмотреть соответствующий ассемблерный код с помощью дизассемблирования и попытаться понять по нему: почему было получено соответствующее повышение производительности? Какие техники оптимизации мог применить компилятор? Чтобы ответить на эти вопросы, возможно, потребуется RTFM или STFW и изучение назначения некоторых ассемблерных инструкций.
Однако скомпилированный исполняемый файл хранится во внешнем хранилище, например на диске или SSD. Как же он попадает в память? Вспомните процессор, реализованный в Logisim: мы напрямую подключали последовательность инструкций к схеме как константы. Для процессора, разработанного на RTL, мы использовали среду симуляции, чтобы прочитать последовательность инструкций в память, подключённую к процессору. Суть обоих способов заключается в том, что мы вручную выполняли работу по «размещению последовательности инструкций программы в памяти». Но в приведённой выше команде ./a.out мы вручную этого не делали.
Когда мы выполняем ./a.out, кто именно выполняет работу по «размещению последовательности инструкций программы в памяти», чтобы процессор мог выбрать их из памяти и выполнить? На самом деле в современных операционных системах есть специальная программа, называемая загрузчиком (loader). Её задача — читать, или загружать, другие исполняемые файлы из внешнего хранилища в память, а затем переходить к соответствующей точке входа программы и начинать выполнение инструкций.
Но процессор способен выполнять только инструкции, соответствующие спецификации его ISA. Например, большинство студентов используют компьютеры с процессорами x86, поэтому они могут выполнять только исполняемые файлы x86 и не могут выполнять исполняемые файлы RISC-V. Если насильно заставить процессор x86 выполнять последовательность инструкций RISC-V, при декодировании он может распознать инструкции RISC-V как инструкции x86 с другим поведением или даже как недопустимые инструкции, поэтому процессор не сможет выполнять их в соответствии с исходной семантикой RISC-V, и результат программы не будет соответствовать ожиданиям. Поэтому при загрузке программы загрузчик проверяет, к какой ISA относится последовательность инструкций программы. Если она не соответствует ISA текущего процессора, загрузчик прекращает загрузку и сообщает об ошибке.
Загрузка программы является обязательным этапом перед её выполнением, поэтому загрузчик является частью среды выполнения. Объектные файлы crtxxx.o, упомянутые выше, содержат часть функциональности загрузчика. Среда выполнения также содержит другую функциональность. Например, во время выполнения ./a.out роль среды выполнения проявляется и в следующем:
- До начала выполнения программы она выполняет различные операции инициализации. Когда вы раньше изучали C, вы могли считать, что программа начинается с
main(). Но если это так, каким образом аргументы программы, введённые в командной строке, попадают вargcиargvфункцииmain()? На самом деле эту работу также выполняет среда выполнения: только после завершения ряда подготовительных операций, таких как загрузка программы и подготовка аргументов, среда выполнения вызываетmain().
Действительно ли программа начинается с main()?
Попробуйте проверить свою гипотезу с помощью strace или gdb.
Подсказка: в gdb можно использовать команду starti, чтобы остановить программу на её первой инструкции.
- Во время выполнения программы среда выполнения предоставляет поддержку библиотечных функций, таких как
printf(). Пример кода выше напрямую вызываетprintf(), хотя мы не писали кодprintf(), однако при выполнении./a.outпрограмма действительно успешно выводит информацию черезprintf(). Поэтому у нас есть основания предположить, что объектные файлыcrtxxx.oпрямо или косвенно предоставляют способ выполненияprintf(). На самом деле библиотечные функции действительно являются частью среды выполнения. - После завершения выполнения программы среда выполнения предоставляет функциональность выхода из программы. Когда вы раньше изучали C, вы могли считать, что программа завершается сразу после возврата из
main(). Но если вы понимаете, что перед выполнениемmain()среда выполнения делает множество подготовительных действий, легко предположить, что после возврата изmain()управление должно вернуться в среду выполнения, которая затем выполняет очистку перед завершением программы.
Действительно ли программа заканчивается после возврата из main()?
Попробуйте проверить свою гипотезу с помощью strace или gdb.
В общем случае одной программы недостаточно, чтобы она могла выполняться. В широком смысле вся функциональность, поддерживающая выполнение программы, относится к среде выполнения.
Поведение, определённое руководством, и стандарты кодирования
Подобно тому как руководство ISA определяет семантику инструкций, семантика C также определяется соответствующим руководством. После появления C в 1970-х годах язык получил широкое распространение благодаря своей эффективности, гибкости и переносимости, однако варианты и расширения C в разных компиляторах создали проблемы совместимости. Чтобы решить эту проблему, ANSI в 1983 году создала комитет по стандартизации C. За многие годы развития стандарт C эволюционировал через различные версии, включая C90, C99, C11, C17 и C23. Эти версии обычно называются по году выпуска стандарта; например, C11 был выпущен примерно в 2011 году. В «One Student One Chip» мы используем C99 как пример, чтобы понять некоторые определения и понятия стандарта C и тем самым сформировать более полное представление о поведении C-программ.
Реализация спецификации стандарта
По своей сути стандартная спецификация — это набор определений и соглашений, обычно представленный в виде руководства. Чтобы стандартная спецификация могла применяться на практике, она должна быть реализована в некоторой форме; такая форма называется реализацией стандартной спецификации. Например, ISA по сути является стандартной спецификацией, а процессор, реализующий эту спецификацию с помощью цифровых схем, является реализацией ISA. Очевидно, поведение процессора должно соответствовать спецификации ISA.
Аналогично, стандарт C также имеет соответствующие реализации. Раздел 3.12 руководства C99 определяет понятие «implementation» следующим образом:
конкретный набор программного обеспечения, работающий в определённой среде
трансляции при определённых управляющих параметрах, который выполняет трансляцию
программ для определённой среды выполнения и поддерживает выполнение функций
в этой среде выполнения
Иными словами, реализация стандарта C — это конкретный набор программного обеспечения, работающий в определённой среде трансляции и используемый для преобразования программ для определённой среды выполнения и поддержки выполнения функций в этой среде. В знакомых нам терминах реализация стандарта C — это компилятор, используемый для преобразования программы, и среда выполнения, используемая для поддержки выполнения программы. Здесь «компилятор» понимается в широком смысле и включает ассемблер и компоновщик. Аналогично, компилятор и среда выполнения должны соответствовать стандарту C.
Стандартные спецификации определяют множество деталей, включая различные вещи, которые «должны» или «не должны» происходить. Это чётко определённое поведение, и соответствующее поведение конкретной реализации обязано следовать стандартной спецификации. Но стандартная спецификация не может чётко определить поведение во всех возможных ситуациях. Ниже мы продолжим обсуждение этого вопроса.
Семантика выполнения программы
Вспомните модель конечного автомата компьютерных систем: процесс выполнения C-программы — это процесс изменения состояния программы посредством операторов C. Такое понимание основано на модели конечного автомата. Теперь посмотрим, как стандарт C определяет «выполнение программы»; это поможет нам глубже понять детали выполнения программ.
Раздел 5.1.2.3 руководства C99 определяет «выполнение программы». Разберём эти определения по очереди:
1 Семантические описания в настоящем Международном стандарте описывают поведение
абстрактной машины, в которой вопросы оптимизации не имеют значения.
В руководстве семантические описания выполнения программ относятся к абстрактной машине и не затрагивают оптимизацию. Здесь понятие абстрактной машины похоже на модельную машину, которую мы обсуждали при знакомстве с ISA: рассматриваются только её функции и поведение, а не конкретная реализация.
2 Обращение к volatile-объекту, изменение объекта, изменение файла или вызов
функции, выполняющей любую из этих операций, являются побочными эффектами,
то есть изменениями состояния среды выполнения. Вычисление выражения в общем
случае включает как вычисление значения, так и инициирование побочных эффектов.
Вычисление значения lvalue-выражения также включает определение идентичности
обозначаемого объекта.
Обращение к объекту volatile, изменение объекта, изменение файла или вызов функции, выполняющей любую из этих операций, называются побочными эффектами, то есть изменениями состояния среды выполнения. Вычисление выражения обычно включает и вычисление значения, и инициирование побочных эффектов. Вычисление lvalue-выражения также включает определение того, какой именно объект оно обозначает.
Понятия «access» и «modify» определены в главе 3 руководства, Terms, definitions, and symbols. В частности, «access» означает чтение или изменение значения объекта, а «modify» включает также случай, когда новое сохраняемое значение совпадает со старым.
3 Sequenced before — асимметричное, транзитивное попарное отношение между
вычислениями, выполняемыми одним потоком, которое задаёт частичный порядок
среди этих вычислений. Для любых двух вычислений A и B, если A sequenced before B,
то выполнение A должно предшествовать выполнению B. (И наоборот, если A
sequenced before B, то B sequenced after A.) Если A не sequenced before и не
sequenced after B, то A и B являются unsequenced. Вычисления A и B являются
indeterminately sequenced, если A выполняется либо до, либо после B, но не
указано, какой именно порядок выбран. Наличие sequence point между вычислениями
выражений A и B означает, что все вычисления значений и побочные эффекты,
связанные с A, sequenced before всех вычислений значений и побочных эффектов,
связанных с B. (Сводка sequence points приведена в приложении C.)
Sequenced before— асимметричное и транзитивное попарное отношение, определённое для вычислений, выполняемых в одном потоке; оно задаёт частичный порядок среди этих вычислений.- Для любых двух вычислений
AиB, еслиAsequenced beforeB, то выполнениеAпроисходит до выполненияB. - И наоборот, если
Asequenced beforeB, тоBsequenced afterA. - Если
Aне sequenced before и не sequenced afterB, тоAиBявляются unsequenced. - Если
Aвыполняется доBили послеB, но стандарт не указывает, какой именно порядок выбран, тоAиBявляются indeterminately sequenced. - Если между вычислениями выражений
AиBсуществует sequence point, то все вычисления значений и побочные эффекты, связанные сA, выполняются до всех вычислений значений и побочных эффектов, связанных сB. - В приложении C приведена сводка sequence points.
Пункт 3 строго определяет допустимые отношения порядка между различными вычислениями с помощью sequence points. В сочетании с побочными эффектами, определёнными в пункте 2, это строго задаёт семантику выполнения всей программы. Например, рассмотрим следующую программу:
a = 1;
b = a + 2;
Согласно сводке sequence points в приложении C, между вычислениями выражений в expression statements существует sequence point. Согласно определению sequence point это означает, что все вычисления значений и побочные эффекты, связанные с выражением a = 1, должны произойти и вступить в силу до вычисления b = a + 2. Поэтому в момент вычисления b = a + 2 значение a уже изменено на 1, и программа в этот момент считывает из переменной a значение 1.
Вам может показаться, что это не очень полезно. Рассмотрим следующий пример. Что выведет эта программа?
#include <stdio.h>
int f() { printf("in f()\n"); return 1; }
int g() { printf("in g()\n"); return 2; }
int h() { printf("in h()\n"); return 3; }
int main () {
int result = f() + g() * h();
return 0;
}
На самом деле программа может вывести вызовы функций в любом порядке, поскольку несколько вызовов функций внутри одного expression statement являются indeterminately sequenced, то есть могут выполняться в любом порядке. Если поведение программы зависит от определённого порядка вызовов, результат выполнения может не соответствовать ожиданиям.
4 В абстрактной машине все выражения вычисляются так, как предписано семантикой.
Конкретная реализация не обязана вычислять часть выражения, если она может
определить, что его значение не используется и что при этом не создаются
необходимые побочные эффекты (включая эффекты, вызванные вызовом функции или
обращением к volatile-объекту).
В абстрактной машине все выражения вычисляются в соответствии с определённой для них семантикой. В конкретной реализации, если значение части выражения не используется и эта часть не создаёт необходимых побочных эффектов, включая побочные эффекты вызовов функций или обращений к объектам volatile, такую часть выражения вычислять не обязательно.
Пункт 4 фактически указывает на возможности оптимизации вычисления выражений.
5 Когда работа абстрактной машины прерывается получением сигнала, значения
объектов, которые не являются lock-free atomic-объектами и не имеют тип
volatile sig_atomic_t, являются unspecified, как и состояние окружения
операций с плавающей точкой. Значение любого объекта, изменённого обработчиком,
если этот объект не является lock-free atomic-объектом и не имеет тип
volatile sig_atomic_t, становится indeterminate после завершения обработчика;
то же относится к состоянию окружения операций с плавающей точкой, если
обработчик изменил его и не восстановил исходное состояние.
Пункт 5 связан с механизмом сигналов и выходит за рамки текущего этапа обучения, поэтому здесь мы не будем его подробно рассматривать.
6 Минимальные требования к соответствующей стандарту реализации:
- Обращения к volatile-объектам вычисляются строго в соответствии с правилами
абстрактной машины.
- При завершении программы все данные, записанные в файлы, должны быть идентичны
результату, который дало бы выполнение программы согласно абстрактной семантике.
- Динамика ввода и вывода интерактивных устройств должна соответствовать 7.21.3.
Смысл этих требований состоит в том, чтобы небуферизованный или построчно
буферизованный вывод появлялся как можно скорее, обеспечивая появление
приглашений до того, как программа начнёт ожидать ввод.
Это и есть наблюдаемое поведение программы.
Реализация, соответствующая стандарту, должна удовлетворять как минимум этим трём требованиям. Это и есть «сохранение наблюдаемого поведения программы», которое мы обсуждали выше в разделе об оптимизации при компиляции.
7 То, что считается интерактивным устройством, определяется реализацией.
То, что именно считается интерактивным устройством, определяется конкретной реализацией.
8 Каждая реализация может определять более строгие соответствия между
абстрактной и фактической семантикой.
Каждая реализация может дополнительно определять более строгие соответствия между абстрактной семантикой и фактической семантикой.
Ранее мы упоминали, что «стандартные спецификации не могут чётко определить поведение во всех ситуациях». Понимание того, как конкретные реализации обрабатывают такое поведение, поможет нам лучше понять работу компьютерных систем и писать код, в большей степени соответствующий стандарту и избегающий подобных ситуаций.
На примере C99 поведение, которое невозможно однозначно определить, делится на следующие категории. Все соответствующие понятия определены в главе 3 руководства, Terms, definitions, and symbols.
Неуточнённое поведение
Руководство C99 определяет Unspecified Behavior следующим образом:
использование неуточнённого значения или иное поведение, для которого настоящий
Международный стандарт предусматривает две или более возможности и не предъявляет
дополнительных требований к тому, какая из них выбирается в конкретном случае
Иными словами, для результата такого поведения стандарт C предоставляет несколько вариантов, но не указывает, какой из них необходимо выбрать. Конкретная реализация может выбрать один из них.
В руководстве C99 приведён следующий пример:
Примером unspecified behavior является порядок вычисления аргументов функции.
То есть порядок вычисления аргументов при вызове функции не уточнён. Это можно проверить с помощью следующей программы:
// a.c
#include <stdio.h>
void f(int x, int y) {
printf("x = %d, y = %d\n", x, y);
}
int main() {
int i = 1;
f(i ++, i ++);
return 0;
}
В системе yzh компиляция и запуск программы с помощью gcc и clang соответственно дают следующие результаты:
$ gcc a.c && ./a.out
x = 2, y = 1
$ clang a.c && ./a.out
x = 1, y = 2
Как видно, даже для одного и того же кода программы разные компиляторы могут создавать программы с разными результатами выполнения.
Попробуйте на практике unspecified behavior
Попробуйте скомпилировать и запустить приведённую выше программу в своей системе и посмотрите результат.
Если вы попробуете скомпилировать и запустить эту программу в своей системе, результат может отличаться от результата yzh. Если это так, это означает, что даже один и тот же компилятор разных версий может создавать разные результаты выполнения для этой программы, и это всё равно соответствует стандарту C. Более того, компилятор мог бы случайным образом выбирать порядок вычисления аргументов функции, и даже такое поведение всё равно соответствовало бы стандарту C!
if (rand() & 1) { evaluate from left to right; }
else { evaluate from right to left; }
Поскольку ни стандарт C, ни его реализация не являются ошибочными, проблема может заключаться только в программе. На самом деле поведение приведённой выше программы зависит от unspecified behavior стандарта C, из-за чего программа ведёт себя по-разному при использовании разных компиляторов. Если мы не хотим такого результата, следует писать код, на который unspecified behavior не влияет. Например, перепишем приведённый выше код следующим образом:
int i = 1;
int x = i ++;
int y = i ++;
f(x, y);
Тогда независимо от порядка вычисления, используемого в f(x, y), поведение программы всегда приводит к выводу x = 1, y = 2, поэтому оно не зависит от unspecified behavior.
Попробуйте понять порядок вычисления аргументов через sequence points
Найдите соответствующий раздел о вычислении аргументов в руководстве C99. Как руководство описывает порядок вычисления аргументов с точки зрения sequence points?
Поведение, определяемое реализацией
Руководство C99 определяет Implementation-defined Behavior следующим образом:
неуточнённое поведение, для которого каждая реализация документирует,
каким образом делается выбор
Иными словами, такое поведение является особым случаем unspecified behavior, но конкретная реализация обязана документировать, каким образом она делает выбор. В отличие от обычного unspecified behavior, после того как конкретная реализация задокументировала свой выбор для implementation-defined behavior, она не может произвольно его менять. Конкретная реализация должна следовать не только стандарту C, но и собственной документации. Поэтому программы, содержащие такое поведение, всё ещё могут давать одинаковый результат при многократной компиляции и запуске в конкретном окружении, включающем компилятор и среду выполнения.
Распространённый пример — длина целочисленных типов. На самом деле стандарт C никогда не определял, какой длины должен быть каждый целочисленный тип. Раздел 5.2.4.2 руководства C99 говорит следующее о длинах целочисленных типов:
Реализация обязана документировать все пределы, указанные в этом подразделе;
они определяются в заголовочных файлах <limits.h> и <float.h>.
Дополнительные пределы определены в <stdint.h>.
Относительно длин целочисленных типов раздел 5.2.4.2.1 руководства C99 говорит:
Приведённые ниже значения должны быть заменены константными выражениями,
пригодными для использования в директивах препроцессора #if. Кроме того,
за исключением CHAR_BIT и MB_LEN_MAX, следующие значения должны быть заменены
выражениями того же типа, который имело бы выражение, являющееся объектом
соответствующего типа после integer promotions. Их implementation-defined
значения должны быть не меньше по абсолютной величине приведённых ниже
и иметь тот же знак.
Здесь перечислим некоторые значения, упоминаемые как the values given below:
| Значение | Описание | |
|---|---|---|
SCHAR_MIN | Минимальное значение signed char | |
SCHAR_MAX | Максимальное значение signed char | |
UCHAR_MAX | Максимальное значение unsigned char | |
SHRT_MIN | Минимальное значение short int | |
SHRT_MAX | Максимальное значение short int | |
USHRT_MAX | Максимальное значение unsigned short int | |
INT_MIN | Минимальное значение int | |
INT_MAX | Максимальное значение int | |
UINT_MAX | Максимальное значение unsigned int | |
LONG_MIN | Минимальное значение long int | |
LONG_MAX | Максимальное значение long int | |
ULONG_MAX | Максимальное значение unsigned long int | |
LLONG_MIN | Минимальное значение long long int | |
LLONG_MAX | Максимальное значение long long int | |
ULLONG_MAX | Максимальное значение unsigned long long int |
Как видно, стандарт C определяет только минимальный диапазон значений для каждого целочисленного типа. Конкретные реализации могут определять для них более широкие диапазоны, но не могут определять диапазоны меньше минимальных диапазонов, установленных стандартом C.
Почему стандарт C устанавливает такие правила? Назначение стандарта C можно понять из аннотации руководства C99:
Настоящий Международный стандарт определяет форму и устанавливает интерпретацию
программ, выраженных на языке программирования C. Его цель — способствовать
переносимости, надёжности, сопровождаемости и эффективному выполнению программ
на языке C на различных вычислительных системах.
Для обеспечения максимальной совместимости с различными компьютерными системами стандарт C должен учитывать следующее:
- Поддержку старых компьютерных систем, поэтому многие правила нельзя делать слишком жёсткими. Внимательные читатели могли заметить, что в стандарте C нижняя граница минимального значения
signed charравна , тогда как минимальное число, представимое в 8-битном дополнительном коде, равно . Это связано с тем, что стандарт C учитывает некоторые старые компьютеры, использовавшие sign-magnitude или ones' complement, где 8-битное знаковое число может иметь минимальное значение . Если бы стандарт C определил нижнюю границу минимального значенияsigned charкак , он не был бы совместим с такими компьютерами.
Какова длина одного байта?
Вы можете не задумываясь ответить: «8 бит». Но на самом деле раздел 3.6 стандарта C говорит, что количество бит в одном байте определяется реализацией:
Байт состоит из непрерывной последовательности битов, количество которых
определяется реализацией.
Очевидно, это сделано ради совместимости со старыми компьютерными системами: исторически длина одного байта на разных компьютерах определялась разным количеством бит, от 1 бита до 48 бит. Поэтому стандарт C не может напрямую определить 1 byte как 8 bits; он оставляет это конкретной реализации.
Внимательные читатели могли также заметить, что приведённый выше фрагмент стандарта C косвенно описывает длины целочисленных типов через диапазоны значений, а не напрямую с помощью формулировок вроде 4 bytes. Это также позволяет избежать использования понятия byte, которое неоднозначно между разными реализациями.
- Поддержку будущих компьютерных систем, поэтому многие правила также должны оставлять пространство для будущего. Стандарт C определяет только минимальный диапазон значений каждого целочисленного типа именно для того, чтобы оставить такое пространство. Возьмём в качестве примера тип
int. В некоторых окружениях 1990-х годов, например Turbo C, который использовался в качестве среды разработки в некоторых старых учебниках,intимеет длину 16 бит. В 32-битных системах, например Windows VC 6.0 или GCC в Linux,intимеет длину 32 бита. В современных 64-битных системахintвсё ещё имеет длину 32 бита.
Посмотрите диапазоны целочисленных типов в Linux
Среда выполнения Linux также является частью конкретной реализации стандарта C. Можно посмотреть /usr/include/limits.h, чтобы понять диапазоны целочисленных типов в этой конкретной реализации.
Итак, программы, содержащие implementation-defined behavior, могут выдавать одинаковый результат при многократной компиляции и запуске в определённом окружении. Но если программу перенести в другое окружение, необходимо учитывать различия implementation-defined behavior между новым и старым окружением. Например, если 32-битная программа переносится в 16-битное окружение — хотя в настоящее время такое требование встречается очень редко — необходимо учитывать возможность переполнения данных типа int. Одно из решений — заменить их на long, поскольку стандарт C требует, чтобы long имел диапазон значений не меньше … , который способен вместить диапазон int 32-битного окружения.
Поведение, зависящее от локали
Руководство C99 определяет Locale-specific Behavior следующим образом:
поведение, зависящее от местных соглашений, связанных с национальностью,
культурой и языком, которые документируются каждой реализацией
Иными словами, результат такого поведения зависит от местных соглашений страны или региона, культуры и языка. Конкретная реализация обязана документировать результат такого поведения. Это особый случай implementation-defined behavior.
Одним из примеров является расширенный набор символов. Какие символы входят в расширенный набор символов, является поведением, зависящим от локали. Рассмотрим следующую программу:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <assert.h>
#define 主函数 main
#define 返回 return
char* 字符串拼接(char *串1, char *串2) {
char *新串 = malloc(strlen(串1) + strlen(串2) + 1);
assert(新串);
strcpy(新串, 串1);
strcat(新串, 串2);
返回 新串;
}
int 主函数() {
char *信息 = 字符串拼接("一生一芯", "很简单");
printf("%s\n", 信息);
free(信息);
返回 0;
}
Приведённая выше программа использует строки и идентификаторы, содержащие китайские символы. В конкретной реализации, поддерживающей китайские символы, эта программа может успешно компилироваться и выполняться; в реализации, поддерживающей только базовый набор символов, то есть ASCII, эта программа не сможет скомпилироваться.
Обычно locale-specific behavior необходимо учитывать при разработке интернационализированного программного обеспечения, или i18n. Помимо наборов символов, к locale-specific behavior относятся символ десятичной точки, обозначения валют, форматы времени и даты и так далее. Однако «One Student One Chip» не связан с разработкой такого программного обеспечения, поэтому вам не требуется глубоко изучать эту тему.
Неопределённое поведение
Руководство C99 определяет Undefined Behavior следующим образом:
поведение при использовании непереносимой или ошибочной программной конструкции
либо ошибочных данных, для которого настоящий Международный стандарт
не предъявляет никаких требований
Это вид ошибочного поведения, при котором программа или данные не соответствуют стандарту, но стандарт C не накладывает никаких ограничений на результат такого поведения. Иными словами, стандарту C соответствует любой результат. Неформально говоря, «может произойти что угодно». Руководство C99 перечисляет некоторые возможные результаты:
Возможное undefined behavior варьируется от полного игнорирования ситуации
с непредсказуемыми результатами, до поведения во время трансляции или выполнения
программы документированным способом, характерным для данного окружения
(с диагностическим сообщением или без него), либо до прекращения трансляции
или выполнения (с выдачей диагностического сообщения).
К таким результатам относятся:
- Сообщение об ошибке и завершение во время компиляции или выполнения
- Обработка в соответствии с документацией конкретной реализации, возможно без предупреждения
- Полностью непредсказуемые результаты
Одним из примеров undefined behavior является выход за границы буфера. Рассмотрим следующую программу:
// a.c
#include <stdio.h>
int main() {
int a[10] = {0};
printf("a[10] = %d\n", a[10]);
return 0;
}
Попробуйте на практике undefined behavior
Попробуйте несколько раз скомпилировать и запустить приведённую выше программу в своей системе и понаблюдайте за результатами.
Приведённая выше программа при вызове printf() обращается к a[10], то есть к элементу за пределами массива. Согласно стандарту C значение a[10] является неопределённым. Поэтому и ситуация, когда компилятор сообщает об ошибке, и ситуация, когда программа сообщает об ошибке во время выполнения, и ситуация, когда программа выводит 0 или какое-либо мусорное значение — всё это соответствует стандарту C.
Программы, содержащие undefined behavior, с высокой вероятностью не смогут стабильно выдавать правильные результаты даже при многократной компиляции и запуске. Если ваша программа выдаёт разные результаты при разных запусках, после исключения внешних источников случайности можно заподозрить наличие undefined behavior.
Почему RTFM?
Выше мы цитировали оригинальный текст стандарта C, потому что хотим, чтобы каждый увидел точные определения некоторых понятий в стандарте C. В большинстве других материалов вы, скорее всего, не встретите аналогичных понятий и определений. Например, даже если вы проходили курс по C или изучали C по другим материалам, возможно, вы впервые слышите о таких понятиях, как «undefined behavior» и «sequence point».
Это показывает, что учебники и материалы по C, с которыми вы сталкивались, не охватывают весь язык C. На самом деле стандарт C точно определяет каждую деталь языка, и у нас есть основания полагать, что авторы стандарта C понимают C глубже, чем авторы книг и блогов.
Разумеется, разные люди изучают C с разными целями. Если цель — писать простые программы на C или сдавать экзамены, большинства учебников и учебных материалов уже достаточно. Но если поверх этого вы хотите глубже изучить C, понять базовые принципы работы компьютерных систем или даже построить собственную компьютерную систему, чтение стандарта C является лучшим выбором.
Цель «One Student One Chip» относится ко второму случаю. В отличие от других микросхем процессор предназначен для запуска программного обеспечения. Поэтому мы рассматриваем проектирование процессорного чипа с точки зрения компьютерных систем: вам необходимо не только научиться проектировать процессор, но и научиться запускать на нём программы, чтобы вы могли проверять, выполняются ли программы правильно и хорошо.
По мере углубления обучения вы постепенно будете сталкиваться с вопросами, которые книги и блоги не могут объяснить достаточно ясно. В этот момент необходимо понять, что вы начинаете входить в область профессионалов. Умение читать руководства — это пропуск к профессиональному уровню.
Однако руководства содержат огромный объём информации. Когда мы рекомендуем читать руководства, мы не ожидаем, что вы освоите всё за короткое время. Гораздо важнее сформировать привычку обращаться к руководствам: когда вы хотите глубоко разобраться в проблеме, вам следует подумать о чтении соответствующего раздела руководства; даже если вы находите ответы в интернете, всё равно следует проверить, что говорит руководство, если только найденные ответы явно не цитируют руководство — в таком случае это отличные ответы. Если вы сможете сформировать такую привычку и применять её на практике, ваше понимание C уже будет находиться в верхнем 1% отрасли.
Список ссылок для описанных выше видов поведения
В приложении J руководства C99 перечислены все случаи четырёх описанных выше категорий поведения в стандарте C. При необходимости обращайтесь к нему.
Двоичный интерфейс приложений (ABI)
Мы уже знаем, что конкретная реализация стандарта C представляет собой набор программного обеспечения, прежде всего компилятор и среду выполнения. Компилятор отвечает за создание бинарного исполняемого файла программы, а среда выполнения отвечает за поддержку выполнения программы. С одной стороны, бинарный исполняемый файл программы связан с ISA, и программа выполняется на процессоре соответствующей ISA. С другой стороны, среда выполнения включает библиотечные функции и часть функциональности операционной системы, и программе необходимо взаимодействовать с ними до, во время и после выполнения.
Таким образом, понятия программы, компилятора, операционной системы, библиотечных функций и ISA связаны между собой как единая компьютерная система. Эта связь обычно оформляется в виде соглашений и спецификаций. Это и есть Application Binary Interface, сокращённо ABI. То есть ABI — это спецификация интерфейса между программой и перечисленными выше компонентами на бинарном уровне.
Поскольку стандарт C должен быть совместим со всевозможными компьютерными системами, он не может точно определить результаты многих видов поведения. Но для конкретной компьютерной системы многие условия уже фиксированы. Являясь соглашением бинарного уровня конкретной компьютерной системы, ABI можно рассматривать как документацию конкретной реализации стандарта C. Поэтому многие варианты выбора для implementation-defined behavior на уровне стандарта C записываются в ABI.
Например, для конкретной ISA ширина байта и регистров общего назначения фиксирована. На основе этого ABI может определить диапазоны значений целочисленных типов C. Вся компьютерная система следует единому пониманию этих диапазонов, определённому ABI, и таким образом совместно поддерживает выполнение программы.
Как спецификация, ABI включает:
- Набор инструкций процессора, структуру регистров, организацию стека, типы обращения к памяти и так далее
- Размер, размещение и выравнивание базовых типов данных, непосредственно доступных процессору
- Соглашения о вызовах, определяющие, как передаются аргументы функций и как получаются возвращаемые значения
- То, каким образом приложения инициируют системные вызовы к операционной системе
- Форматы объектных файлов, поддерживаемые библиотеки времени выполнения и так далее
Ещё раз мы видим, что результат выполнения программы на конкретной компьютерной системе зависит от исходного кода, компилятора, среды выполнения, ISA, аппаратного обеспечения и других факторов. ABI также является важным проявлением совместной работы программного и аппаратного обеспечения компьютерной системы для поддержки выполнения программ. Пока вам достаточно базового представления об ABI. На этапе D мы будем использовать RISC-V в качестве примера для знакомства с конкретным содержимым ABI.
RTFM
Разумеется, помимо руководства стандарта C, мы также рекомендуем в свободное время читать другие руководства, включая руководство RISC-V, руководство Verilog и руководство RISC-V ABI и другие. Они могут предоставить наиболее полную и авторитетную информацию для понимания соответствующих деталей. Если сейчас их читать очень сложно, не переживайте. По мере накопления базовых знаний в процессе обучения возвращаться к этим руководствам будет становиться всё легче.
yyz
