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

Получение оптимального кода
Всем известно, что лучший способ получить быстрый, компактный код для встроенных приложений заключается в эксплуатации на полную навыков программиста-ассемблерщика. Конечно, программист должен быть знаком с архитектурой конкретного устройства, набором команд и местным ассемблером, и он также должен быть всесторонне проинформирован о задачах данного приложения. Очевидно, что в таком случае компилятор не может конкурировать с таким мастером спорта по ассемблеру, поскольку он не может быть в курсе всех требований приложения.
Это может показаться очевидным, но здесь имеется один неучтенный фактор — человеческая природа. Но мы поговорим об этом ниже.
Подход компилятора
Современные встроенные компиляторы очень хороши в своем деле. Как правило, нынешний компилятор имеет обширный набор вариантов оптимизации и предлагает разработчику детальное управление процессом генерации кода. Компиляторы также выискивают последовательности кода, которые могут привести к оптимальному результату.
Чтобы посмотреть на то, как компиляторы обрабатывают обычный код для встраиваемых систем, воспользуемся конструкцией switch языка C. Конструкция switch может принимать одну из четырех форм:
- с небольшим количеством значений case
- с большим количеством значений case, которые выстроены в непрерывной последовательности
- с большим количеством значений case, которые в большинстве своем выстроены в непрерывной последовательности, но не все
- с большим количеством значений case, которые выстроены в хаотичной последовательности
Мы рассмотрим каждый из этих случаев. Примеры обрабатывались с помощью компилятора Sourcery CodeBench компании Mentor Embedded для микроконтроллера Coldfire с минимальной оптимизацией. Ассемблер для Coldfire хорошо читаемый, поэтому был выбран именно он.
Несколько значений case
Как правило, switch используют, когда логика структуры if/else становится достаточно запутанной. В данной ситуации задействовано небольшое количество значений case:
switch (x)
{
case 1:
a();
case 22:
b();
case 83:
c();
}
Для большинства архитектур процессоров эффективным путем обработки такой логики является организация нескольких сравнений и прыжков, как в примере ниже, где %d0 выделено под x:
moveq #22,%d1
cmp.l %d0,%d1
jeq .L4
moveq #83,%d1
cmp.l %d0,%d1
jeq .L5
moveq #1,%d1
cmp.l %d0,%d1
jne .L6
.L3:
jsr a
.L4:
jsr b
.L5:
jsr c
.L6:
Непрерывная последовательность
В других ситуациях может быть задействовано большое количество значений case. Большинство компиляторов не устанавливает планку максимально возможного количества значений, но имеется аргумент, огораживающий от использования большого числа кейсов, и он заключается в том, что в результате такого беспредела очень сильно страдает читаемость кода. Иногда значения case выстраивают в четкой, правильной последовательности, как в данном случае:
switch (x)
{
case 1:
a();
case 2:
b();
case 3:
c();
case 4:
d();
case 5:
e();
}
Компилятор заметит такую последовательность и сгенерирует следующий код:
add.l %d0,%d0
move.w .L8(%pc,%d0.l),%d0
ext.l %d0
jmp %pc@(2,%d0:l)
.balignw 2,0x284c
.swbeg &6
.L8:
.word .L9-.L8
.word .L3-.L8
.word .L4-.L8
.word .L5-.L8
.word .L6-.L8
.word .L7-.L8
.L3:
jsr a
.L4:
jsr b
.L5:
jsr c
.L6:
jsr d
.L7:
jsr e
.L9:
В этом коде значение х используется в качестве индекса в таблице переходов. Это значительно быстрее, чем идти по длинной последовательности сравнений.
Почти непрерывная последовательность
Другим возможным случаем является последовательность, которая не совсем непрерывная, вроде этой:
switch (x)
{
case 1:
a();
case 2:
b();
case 3:
c();
case 4:
d();
case 6:
e();
}
И опять компилятор распознает последовательность подобного рода и создает таблицу переходов:
add.l %d0,%d0
move.w .L8(%pc,%d0.l),%d0
ext.l %d0
jmp %pc@(2,%d0:l)
.balignw 2,0x284c
.swbeg &7
.L8:
.word .L9-.L8
.word .L3-.L8
.word .L4-.L8
.word .L5-.L8
.word .L6-.L8
.word .L9-.L8
.word .L7-.L8
.L3:
jsr a
.L4:
jsr b
.L5:
jsr c
.L6:
jsr d
.L7:
jsr e
.L9:
В этот раз таблица включает в себя запись-заглушку для размещения пропущенного значения.
Хаотичная последовательность
Ну и наконец последний пример являет собой неупорядоченную структуру без намека на четкую последовательность:
switch (x)
{
case 11:
a();
case 23:
b();
case 113:
c();
case 40:
d();
case 5:
e();
}
В таком случае компилятор возвращается к последовательности сравнений, то есть:
moveq #23,%d1
cmp.l %d0,%d1
jeq .L5
moveq #23,%d1
cmp.l %d0,%d1
jlt .L8
moveq #5,%d1
cmp.l %d0,%d1
jeq .L3
moveq #11,%d1
cmp.l %d0,%d1
jeq .L4
jra .L9
.L8:
moveq #40,%d1
cmp.l %d0,%d1
jeq .L6
moveq #113,%d1
cmp.l %d0,%d1
jeq .L7
jra .L9
.L4:
jsr a
.L5:
jsr b
.L7:
jsr c
.L6:
jsr d
.L3:
jsr e
.L9:
Интересно моментом является то, что компилятор понимает, что при большом числе значений, он может сократить количество сравнений путем их разделения на группы с «большими» и «маленькими» значениями. Возможно, архитектуры ARM и x86 справляются с такой задачей даже лучше.
Человеческий подход
Так как же человек закодирует в ассемблере эти четыре последовательности? Как уже было сказано выше, если в команде программистов есть эксперт, то было бы естественно ожидать, что его решение будет сравнимо с результатом компилятора, а может даже будет лучше.
Тем не менее, это маловероятно. Хорошо обученный программист знает, что желательно писать читабельный и «удобный» код, даже если придется пожертвовать долей производительности. Поэтому использование преимущества непрерывной или почти непрерывной последовательности, заключающегося в индексировании таблицы переходов, было бы плохой практикой, так как будущие изменения программы не могут быть внесены в такую структуру. Ни один разработчик не желал бы переписывать целый кусок кода, зная, что в программу могут быть внесены изменения.
Компилятор не побрезгует каждый раз переписывать код, так что в большинстве случаев он будет выдавать оптимальный результат. Использование конструкции switch позволяет получить понятный и легко поддерживаемый код, так что разработчикам стоит пользоваться в первую очередь языками высокого уровня.
Перевод © digitrode.ru