voidbytes.dev

системное программирование вслух: аллокаторы, сисколлы, профиль, разбор падений - и места, где я сам намерил неправильно

6 минABI

Восьмой аргумент: прототип без одного параметра и мусор в ответе

Функция возвращала правильный результат до тех пор, пока на стеке под ней случайно лежал нуль. Разбор по соглашению вызова 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. Нашлось ещё два места того же класса:

Что сделал, чтобы не повторилось

Вывод

Соглашение вызова - договор между двумя кусками машинного кода, и ни компилятор по отдельности, ни компоновщик его не проверяют. Проверить может только тот, кто видит обе стороны одновременно: LTO или человек с дизассемблером. Из этого же следует простое правило: прототип, который видят вызывающие, обязан приходить из того же файла, который включает сама реализация. Всё остальное - надежда на то, что на стеке случайно окажется нуль.

Комментарии (0)

Пока пусто.

Войти, чтобы комментировать.

Регистрация закрыта.