Опасность вызова функций без объявленного прототипа в C

Ещё один пост про тонкости линковки. Предыдущий лежит здесь. На этот раз речь пойдёт преимущественно о старых исходниках, переносе их в 64-х битный режим, ну и немного про режим сборки «вся программа». Пример основан на реальных событиях исходниках.

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

 $ cat t1.c   
  int main()   
  {   
   int * a;   
   a = (int *)foo();   
   *a = 10;   
  }   

 $ cat t2.c
 #include <stdlib.h>  
 int * foo(void)  
 {  
   int * a = malloc(sizeof(int));  
   *a = 100;  
   return a;  
 }

 $ lcc t1.c t2.c -Wl,-Tdata=0x700000000  
 lcc: "1.c", line 5: warning: function "foo" declared implicitly  
      [-Wimplicit-function-declaration]  
    a = (int *)foo();  
          ^  

Видно что в t1.c функция foo не имеет прототипа, и что именно мы вызываем становится понятно только после линковки. Поэтому и ругается компилятор.

Сразу скажу, что используется компилятор Эльбруса, и на gcc я не смог это воспроизвести. И это вовсе не комплимент в сторону gcc (ну или моих рук). Опция -Wl,-Tdata=0x700000000 нужна чтобы секция данных начиналась с больших адресов (допустимых только в 64-битном режиме). Теперь запустим пример и получим:

$ ./a.out
Segmentation fault

Казалось бы, что тут не так? Начнём рассмотрение со строчки a = (int *)foo();. На первый взгляд всё корректно. Но в реальности при сборке объектника из t1.c компилятор ничего не знает о функции foo, поэтому подставляет прототип по умолчанию, который возвращает int. Это приводит к генерации следующего кода:

 o7. CALL     proc:foo ()     :4<sint32>           // 't1.c' 4  
 o8. I2P      o7:4<sint32>    :8<sint32 *>          // 't1.c' 4  
 o9. WRITE     loc:a <- o8:8:(sint32 *)              // 't1.c' 4  

Видно что мы берём возвращаемое из функции значение как int размера 4 байта, и приводим его к (int *) размера 8 байт. На 32-х битной системе это работает нормально (очевидно, что (int *) там тоже 4 байта). Проблемы возникают на больших адресах 64-х битного режима. Думаю теперь становится понятно зачем была нужна опция -Wl,-Tdata=0x700000000. Она заставляет malloc выдавать указатели со значениями > 2^32. Соответственно в момент преобразования значения в int мы теряем значимые биты, что приводит к ошибке сегментирования.

А теперь про режим сборки «вся программа», он же -fwhole, он же -flto. В данном режиме подобные ошибки становятся видны, т.к. оба модуля становятся видны, и мы можем подставить корректный вызов. Но возникает вопрос - а надо ли? Тут моё мнение разошлось с мнением более умных людей, которые считают что в режиме сборки «вся программа» нужно эмулировать ошибки обычного линкера,т.е. генерить некорректный код и ломаться тогда когда этого никто не ожидает.

В общем мораль сего поста такова - всегда объявляйте прототип вызываемой функции.