Восьмой аргумент: прототип без одного параметра и мусор в ответе
Функция возвращала правильный результат до тех пор, пока на стеке под ней случайно лежал нуль. Разбор по соглашению вызова SysV AMD64, дизассемблеру и объяснение, почему компоновщик про это молчит.
Симптом
Внутренняя библиотека, функция записи события. В журнале время от времени появлялись строки с чудовищным значением приоритета - то 140 733 193 388 032, то ноль, то осмысленное число. Воспроизводилось на одной сборке и не воспроизводилось на другой из того же дерева.
Определение жило в emit.c и принимало восемь параметров. В заголовке, которым пользовались все остальные единицы трансляции, стояло семь: когда-то добавили параметр и поправили не везде.
/* emit.h - то, что видели вызывающие */
int svc_emit(int fd, const char *tag, uint64_t ts, int level,
unsigned flags, size_t len, const void *data);
/* emit.c - то, чем функция была на самом деле */
int svc_emit(int fd, const char *tag, uint64_t ts,
int level, unsigned flags, size_t len,
const void *data, int prio)
{ ... }
Почему это компилируется и собирается
В C имя функции в объектном файле - это просто имя. Типы параметров в символ не входят, манглирования как в C++ нет, поэтому компоновщик видит svc_emit с одной стороны и svc_emit с другой и совершенно справедливо их соединяет. Несоответствие прототипов между единицами трансляции - неопределённое поведение, но диагностировать его на этапе компоновки нечем, если не включён LTO.
Что происходит в железе
В SysV AMD64 целочисленные аргументы передаются по порядку в rdi, rsi, rdx, rcx, r8, r9; седьмой и дальше - через стек, по восемь байт на аргумент. Внутри вызванной функции, сразу после call, седьмой аргумент лежит по [rsp+8], восьмой - по [rsp+16].
Вызывающий, видя семь параметров, кладёт на стек один - указатель data. Функция, знающая про восемь, читает оба слота:
$ objdump -d --no-show-raw-insn caller.o | sed -n '/call/{x;p;d};H'
mov %r13,%r9 # len -> 6-й, регистр
push %r14 # data -> 7-й, стек
call svc_emit
$ objdump -d --no-show-raw-insn emit.o | sed -n '1,12p'
0000000000000000 <svc_emit>:
push %rbp
mov %rsp,%rbp
mov 0x10(%rbp),%rax # data (7-й)
mov 0x18(%rbp),%ecx # prio (8-й) - здесь мусора нет гарантий
По [rbp+0x18] лежит то, что вызывающий туда не положил: остаток предыдущего кадра. Иногда это ноль - и тогда код уходит в ветку «приоритет по умолчанию», всё выглядит рабочим. Иногда там обрывок указателя - и в журнал попадает четырнадцатизначное число. Разница между сборками объяснялась ровно этим: другой порядок локальных переменных у вызывающего, другой мусор на стеке.
Чем поймал
Собрал всё с -flto. Компоновка с типами - единственное штатное место, где такое ловится:
emit.h:14:5: warning: type of 'svc_emit' does not match original
declaration [-Wlto-type-mismatch]
emit.c:31:5: note: 'svc_emit' was previously declared here
emit.c:31:5: note: code may be misoptimized unless
'-fno-strict-aliasing' is used
Дальше прогнал весь проект с набором предупреждений, который должен был стоять с самого начала: -Wstrict-prototypes, -Wmissing-prototypes, -Wmissing-declarations. Нашлось ещё два места того же класса:
- функция, объявленная как
void drain()вместоvoid drain(void)- в C это «неизвестное число аргументов», и вызов с лишним параметром проходит молча; - функция, в одной единице трансляции объявленная с
long, а в другой сint64_t- на нашей платформе это один тип, на 32-битной сборке была бы ровно та же история, что и с восьмым аргументом.
Что сделал, чтобы не повторилось
- Единственный источник объявления:
emit.cвключает собственныйemit.h, и-Wmissing-prototypesтеперь не даст определить внешнюю функцию без объявления. -fltoвключён в сборке, которая гоняется перед слиянием ветки. В обычной отладочной сборке он не нужен и только замедляет компоновку.- Все перечисленные предупреждения подняты до ошибок.
Вывод
Соглашение вызова - договор между двумя кусками машинного кода, и ни компилятор по отдельности, ни компоновщик его не проверяют. Проверить может только тот, кто видит обе стороны одновременно: LTO или человек с дизассемблером. Из этого же следует простое правило: прототип, который видят вызывающие, обязан приходить из того же файла, который включает сама реализация. Всё остальное - надежда на то, что на стеке случайно окажется нуль.
Комментарии (0)
Пока пусто.
Войти, чтобы комментировать.
Регистрация закрыта.