Сгенерированный код компилятора Tiny C излучает дополнительный (ненужный?) NOPs и JMPs

может кто-нибудь объяснить, почему этот код:

#include <stdio.h>

int main()
{
  return 0;
}

при компиляции с tcc с помощью код tcc.c производит этот asm:

00401000  |.  55               PUSH EBP
00401001  |.  89E5             MOV EBP,ESP
00401003  |.  81EC 00000000    SUB ESP,0
00401009  |.  90               NOP
0040100A  |.  B8 00000000      MOV EAX,0
0040100F  |.  E9 00000000      JMP fmt_vuln1.00401014
00401014  |.  C9               LEAVE
00401015  |.  C3               RETN

думаю, что

00401009  |.  90   NOP

может быть, для некоторого выравнивания памяти, но как насчет

0040100F  |.  E9 00000000     JMP fmt_vuln1.00401014
00401014  |.  C9              LEAVE

Я имею в виду, почему компилятор вставляет этот ближний прыжок, который переходит к далее инструкция, оставить бы выполнить в любом случае?

Я на 64-разрядной генерации Windows 32-разрядный исполняемый файл с использованием TCC 0.9.26.

2 ответов


лишний JMP перед функцией Эпилог

на JMP в нижней части, которая идет к следующему заявлению, это было исправлено в фиксации. версия 0.9.27 TCC решает эту проблему:

когда "return" является последним оператором блока верхнего уровня (очень распространенный и часто рекомендуемый случай) прыжок не нужен.

как по этой причине он существовал в первую очередь? Идея что каждая функция имеет возможную общую точку выхода. Если есть блок кода с возвратом в нем внизу, то JMP переходит к общей точке выхода, где выполняется очистка стека и ret выполняется. Первоначально генератор кода также испускал JMP инструкция ошибочно в конце функции тоже, если она появилась непосредственно перед окончательным } (закрывающая скобка). Исправление проверяет, есть ли return заявление, за которым следует закрывающая скобка на верхнем уровне функции. Если есть, то JMP отсутствует

пример кода, который имеет возврат в нижней области перед закрывающей скобкой:

int main(int argc, char *argv[])
{
  if (argc == 3) {
      argc++;
      return argc;
  }
  argc += 3;
  return argc;
}

сгенерированный код выглядит так:

  401000:       55                      push   ebp
  401001:       89 e5                   mov    ebp,esp
  401003:       81 ec 00 00 00 00       sub    esp,0x0
  401009:       90                      nop
  40100a:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]
  40100d:       83 f8 03                cmp    eax,0x3
  401010:       0f 85 11 00 00 00       jne    0x401027
  401016:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]
  401019:       89 c1                   mov    ecx,eax
  40101b:       40                      inc    eax
  40101c:       89 45 08                mov    DWORD PTR [ebp+0x8],eax
  40101f:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]

  ; Jump to common function exit point. This is the `return argc` inside the if statement
  401022:       e9 11 00 00 00          jmp    0x401038

  401027:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]
  40102a:       83 c0 03                add    eax,0x3
  40102d:       89 45 08                mov    DWORD PTR [ebp+0x8],eax
  401030:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]

  ; Jump to common function exit point. This is the `return argc` at end of the function 
  401033:       e9 00 00 00 00          jmp    0x401038

  ; Common function exit point
  401038:       c9                      leave
  401039:       c3                      ret

в версии до в 0.9.27 в return argc внутри оператора if будет переходить к общей точке выхода (эпилог функции). А также return argc в нижней части функции также переходит к тому же общему выходу точка функции. Проблема в том, что общая точка выхода из функции происходит сразу после верхнего уровня return argcтаким образом, побочным эффектом является дополнительный JMP, который относится к следующей инструкции.


NOP после пролога функции

на НОП не для выравнивания. Потому что Windows реализует странички для стека (программы, которые находятся в портативном исполняемом формате) TCC имеет два типа прологов. Если локальное пространство стека требуется

401000:       55                      push   ebp
401001:       89 e5                   mov    ebp,esp
401003:       81 ec 00 00 00 00       sub    esp,0x0

на sub esp,0 не оптимизированные. Это объем пространства стека, необходимый для локальных переменных (в данном случае 0). Если вы добавите некоторые локальные переменные, вы увидите 0x0 в SUB изменения инструкций совпадают с объемом пространства стека, необходимого для локальных переменных. Этот пролог требует 9 байт. Существует еще один пролог, чтобы справиться с случай, когда требуется пространство стека >= 4096 байт. Если вы добавите массив 4096 байт с чем-то вроде:

char somearray[4096] 

и посмотрите на полученную инструкцию, вы увидите, что функция prologue изменится на 10-байтовый пролог:

401000:       b8 00 10 00 00          mov    eax,0x1000
401005:       e8 d6 00 00 00          call   0x4010e0

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

в случае, когда требуется пространство стека НОП используется для заполнения пролога до 10 байт. Для случая, когда >= 4096 байт необходимы, количество байтов передается в EAX и функции __chkstk вызывается для выделения требуемого пространства стека.


TCC это не оптимизирующий компилятор, по крайней мере, не реально. Каждая инструкция, которую он испускал для main является неоптимальным или не требуется вообще, за исключением ret. IDK, почему вы думали, что JMP была единственной инструкцией, которая может не иметь смысла для производительности.

это по дизайну: TCC означает крошечный компилятор C. Сам компилятор разработан как простой, поэтому он намеренно не включает код для поиска многих видов процессы оптимизации. Обратите внимание на sub esp, 0: эта бесполезная инструкция явно исходит из заполнения шаблона функции-пролога, и TCC даже не ищет особый случай, когда смещение равно 0 байтам. Другой функции нужно пространство стека для локальных или выровнять стек перед вызовами дочерних функций, но это main() не делает. TCC не заботится, и слепо испускает sub esp,0 зарезервировать 0 байт. Обратите внимание (из ответа Майклза), что он использует imm32 кодирование, поэтому у него даже нет оптимизации ассемблер, который будет использовать imm8 кодировка. Вместо этого он жестко кодирует шаблон функции-пролога и заполняет только это 32-разрядное поле.

большая часть работы по созданию хороший оптимизирующий компилятор, который любой будет использовать на практике, является оптимизатором. Даже синтаксический анализ современного C++ - это мелочь по сравнению с надежным излучением эффективного asm (что даже gcc / clang / icc не может делать все время, даже не рассматривая автовекторизацию). Просто генерируя работу, но неэффективный asm прост по сравнению с оптимизацией; большая часть кодовой базы gcc-это оптимизация, а не синтаксический анализ. См. ответ Basile на почему так мало компиляторов C?


JMP (как вы можете видеть из ответа @MichaelPetch) имеет аналогичное объяснение: TCC (до недавнего времени) не оптимизировал случай, когда функция имеет только один путь возврата и не нуждается в JMP для общего эпилога.

есть даже NOP в середине функции. Это очевидно, пустая трата байтов кода и декодирование / выдача пропускной способности переднего плана и размера окна вне порядка. (Иногда выполнение NOP вне цикла или что-то стоит того, чтобы выровнять верхнюю часть цикла, которая разветвлена многократно, но NOP в середине базового блока в основном никогда не стоит, так что это не то, почему TCC положил его туда. И если NOP действительно помогает, вы, вероятно, могли бы сделать еще лучше, переупорядочивая инструкции или выбирая более крупные инструкции, чтобы сделать то же самое без NOP. Даже правильные оптимизирующие компиляторы, такие как gcc/clang/icc, не пытаются предсказать такой тонкий интерфейсный эффект.)

@MichaelPetch указывает, что TCC всегда хочет, чтобы его пролог функции был 10 байтами, потому что это single-pass компилятор (и он не знает, сколько места ему нужно для локальных до конца функции, когда он вернется и заполнит imm32). Но цели Windows нуждаются в зондировании стека при изменении ESP / RSP более чем на целую страницу (4096 байт), а альтернативный пролог для этого случая-10 байт вместо 9 для обычного без NOP. Таким образом, это еще один компромисс в пользу скорости компиляции над хорошим asm.


оптимизирующий компилятор будет xor-zero EAX (потому что это меньше и по крайней мере так же быстро, как mov eax,0), и оставьте все остальные инструкции. XOR-zeroing-одна из самых известных / распространенных / базовых оптимизаций Глазков x86 и имеет несколько преимуществ, кроме размера кода на некоторых современных x86 микроархитектурах.

main:
    xor eax,eax
    ret

некоторые оптимизирующие компиляторы все еще могут сделать кадр стека с EBP, но сорвать его с pop ebp было бы строго лучше, чем leave на всех процессорах, для этого особого случая, когда ESP = EBP так что mov esp,ebp часть leave не требуется. pop ebp по-прежнему 1 байт, но это также одна инструкция uop на современных процессорах, в отличие от leave что по крайней мере 2 или 3. (http://agner.org/optimize/, и см. также другие ссылки оптимизации производительности в x86 тег wiki.) Это то, что делает gcc. Это довольно распространенная ситуация; если вы нажмете некоторые другие регистры после создание кадра стека, вы должны указать ESP в нужном месте перед pop ebx или что-то еще.


критерии TCC заботится о компиляция скорость, не качество (скорость или размер) результирующего кода. Например, веб-сайт TCC сайт имеет эталон в линиях / сек и МБ / сек (источника C) против gcc3.2 -O0, где это ~9x быстрее на P4.

однако, TCC не полностью braindead:он, по-видимому, будет делать некоторые вставки, и, как указывает ответ Майкла, недавний патч действительно оставляет JMP (но все еще не бесполезный sub esp, 0).