SIGSEGV
. Исследование проблемы позволило мне провести отличное сравнение между musl libc
и glibc
. Для начала посмотрим на стектрейс:==26267==ERROR: AddressSanitizer: SEGV on unknown address 0x7f9925764184
(pc 0x0000004c5d4d bp 0x000000000002 sp 0x7ffe7f8574d0 T0)
==26267==The signal is caused by a READ memory access.
0 0x4c5d4d in parse_text /scdoc/src/main.c:223:61
1 0x4c476c in parse_document /scdoc/src/main.c
2 0x4c3544 in main /scdoc/src/main.c:763:2
3 0x7f99252ab0b2 in __libc_start_main
/build/glibc-YYA7BZ/glibc-2.31/csu/../csu/libc-start.c:308:16
4 0x41b3fd in _start (/scdoc/scdoc+0x41b3fd)
В исходниках на данной строчке написано вот что:
if (!isalnum(last) || ((p->flags & FORMAT_UNDERLINE) && !isalnum(next))) {
Подсказка:
p
— это корректный, ненулевой указатель. Переменные last
и next
имеют тип uint32_t
. Сегфолт случается на втором вызове функции isalnum
. И, самое важное: воспроизводится только при использовании glibc, но не musl libc. Если вам пришлось перечитать код несколько раз, вы не одиноки: тут попросту нечему вызывать сегфолт.Поскольку было известно, что все дело в библиотеке glibc, я достал ее исходники и стал искать реализацию
isalnum
, готовясь столкнуться с какой-нибудь дурацкой херней. Но прежде чем я дойду до дурацкой херни, которой там, уж поверьте, навалом, давайте сначала быстренько поглядим на хороший вариант. Вот так функция isalnum
реализована в musl libc:int isalnum(int c)
{
return isalpha(c) || isdigit(c);
}
int isalpha(int c)
{
return ((unsigned)c|32)-'a' < 26;
}
int isdigit(int c)
{
return (unsigned)c-'0' < 10;
}
Как и ожидалось, для любого значения
c
функция отработает без сегфолта, потому что с какого хрена вообще isalnum
должна кидать сегфолт?Ладно, теперь сравним это с реализацией glibc. Как только вы откроете заголовок, вас будет встречать типичная GNU'шная галиматья, но пропустим ее и попытаемся найти
isalnum
.Первый результат такой:
enum
{
_ISupper = _ISbit (0), /* UPPERCASE. */
_ISlower = _ISbit (1), /* lowercase. */
// ...
_ISalnum = _ISbit (11) /* Alphanumeric. */
};
Похоже на деталь реализации, двигаемся дальше.
__exctype (isalnum);
Но что такое
__exctype
? Возвращаемся на несколько строчек вверх…#define __exctype(name) extern int name (int) __THROW
Ладно, по всей видимости это только прототип. Непонятно правда, зачем тут нужен макрос. Ищем дальше…
#if !defined __NO_CTYPE
# ifdef __isctype_f
__isctype_f (alnum)
// ...
Так, вот это уже похоже на что-то полезное. Что такое
__isctype_f
? Мотаем вверх…#ifndef __cplusplus
# define __isctype(c, type) ((*__ctype_b_loc ())[(int) (c)] & (unsigned short int) type)
#elif defined __USE_EXTERN_INLINES
# define __isctype_f(type) __extern_inline int is##type (int __c) __THROW { return (*__ctype_b_loc ())[(int) (__c)] & (unsigned short int) _IS##type; }
#endif
Ну начинается… Ладно, вместе как-нибудь разберемся. Видимо,
__isctype_f
— это инлайн-функция… стоп, это все находится внутри блока else препроцессорной инструкции #ifndef __cplusplus. Тупик. Где же isalnum
, мать ее, на самом деле определена? Ищем дальше… Может вот оно?#if !defined __NO_CTYPE
# ifdef __isctype_f
__isctype_f (alnum)
// ...
# elif defined __isctype
# define isalnum(c) __isctype((c), _ISalnum) // <- вот тут
Эй, это же «деталь реализации», которую мы видели раньше. Помните?
enum
{
_ISupper = _ISbit (0), /* UPPERCASE. */
_ISlower = _ISbit (1), /* lowercase. */
// ...
_ISalnum = _ISbit (11) /* Alphanumeric. */
};
Попробуем по-быстрому расковырять этот макрос:
# include <bits/endian.h>
# if __BYTE_ORDER == __BIG_ENDIAN
# define _ISbit(bit) (1 << (bit))
# else /* __BYTE_ORDER == __LITTLE_ENDIAN */
# define _ISbit(bit) ((bit) < 8 ? ((1 << (bit)) << 8) : ((1 << (bit)) >> 8))
# endif
Ну что за херня? Ладно, двигаем дальше и считаем, что это просто магическая константа. Другой макрос называется
__isctype
, который похож на недавно виденный нами __isctype_f
. Посмотрим еще разок на ветку #ifndef __cplusplus
:#ifndef __cplusplus
# define __isctype(c, type) ((*__ctype_b_loc ())[(int) (c)] & (unsigned short int) type)
#elif defined __USE_EXTERN_INLINES
// ...
#endif
Эээ…
Ну, по крайней мере мы нашли разыменовывание указателя, которое могло бы объяснить сегфолт. А что такое
__ctype_b_loc
?/* Эти объявления находятся в файле ctype-info.c.
Они должны совпадать с объявлениями в файле localeinfo.h.
В модели локалей, основывающейся на потоках, (см. `uselocale' в <locale.h>)
мы не можем пользоваться глобальными переменными, как раньше.
Вместо этого, ниже указаны функции-аксессоры, которые возвращают адрес
каждой переменной, относящейся к текущему потоку, если потоков несколько.
Они указывают на массивы из 384 элементов, поэтому для индексации можно
использовать значение `unsigned char' [0,255]; а также EOF (-1); или любое
значение `signed char' value [-128,-1). ISO C требует, чтобы функции ctype работали
со значениями типа `unsigned char' и EOF; мы также поддерживаем отрицательные
значения `signed char' для совместимости со старыми некорректными программами.
Массивы для конвертации регистра содержат значения типа `int`,
а не `unsigned char`, потому что `tolower(EOF)' должно возвращать EOF, а это значение
не помещается в диапазон `unsigned char`. Однако сейчас самое важное - то, что
эти массивы также используются для многобайтовых кодировок. */
extern const unsigned short int **__ctype_b_loc (void)
__THROW __attribute__ ((__const__));
extern const __int32_t **__ctype_tolower_loc (void)
__THROW __attribute__ ((__const__));
extern const __int32_t **__ctype_toupper_loc (void)
__THROW __attribute__ ((__const__));
Как круто с твоей стороны, glibc! Я просто обожаю иметь дело с локалями. Так или иначе, к моему упавшему приложению подключен gdb, и держа в уме всю полученную информацию я пишу вот это убожество:
(gdb) print ((unsigned int **(*)(void))__ctype_b_loc)()[next]
Cannot access memory at address 0x11dfa68
Сегфолт найден. В комменте была про это строчка: «ISO C требует, чтобы функции ctype работали со значениями типа `unsigned char' и EOF». Если найти это в спецификации, увидим:
Во всех реализациях [функций, объявленных в ctype.h], аргументом является int, значение которого должно помещаться в unsigned char, или же равняться значению макроса EOF.
Теперь становится очевидно, как исправить проблему. Мой косяк. Оказывается, я не могу скормить в
isalnum
произвольный символ UCS-32 для проверки на его вхождение в диапазоны 0x30-0x39, 0x41-0x5A и 0x61-0x7A.Но тут я возьму на себя смелость предположить: а может быть, функция
isalnum
вообще не должна кидать сегфолт, вне зависимости от того, что ей передадут? Может быть, даже если спецификация это разрешает, это не означает, что так надо делать? Может быть, ну просто в качестве безумной идеи, поведение этой функции не должно содержать пять макросов, проверять использование компилятора C++, зависеть от порядка байтов вашей архитектуры, таблицы поиска, данных о локали потока и разыменовывать два указателя?Еще разок посмотрим на версию в musl в качестве быстрого напоминания:
int isalnum(int c)
{
return isalpha(c) || isdigit(c);
}
int isalpha(int c)
{
return ((unsigned)c|32)-'a' < 26;
}
int isdigit(int c)
{
return (unsigned)c-'0' < 10;
}
Вот такие пироги.
Примечание переводчика: спасибо MaxGraey за ссылку на оригинал.
lorc
Ну наверное разработчики glibc наворотили это не от доброты душевной, а чтобы соответствовать стандарту, плюс быть совместимыми со старыми приложениями, которые вместо EOF используют -1 и при этом еще и поддерживать локали. И чтобы это все работало на процессорах с разным endianess.
Поэтому, я не совсем понимаю возмущение автора.
yleo
В glibc недостает assert-а, который-бы контролировал допустимость аргумента.
У меня даже стойкое дежавю, что лет 10 назад я то-ли субиметл такой патч, то-ли добавлял assert в заголовки на сборочной ферме.
4eyes
а) успешно выпиливается в релизной конфигурации
б) если не выпиливается, то кладёт на лопатки branch prediction
yleo
Так вы считаете что от assert-ов следует вообще отказаться по обозначенным "причинам"?
4eyes
Я считаю, что слово «причины» в данном случае не следует брать в кавычки, что в релизном билде ассерты не помогут, и что если кому-то нужна версия isalnum с валидацией параметров, то её всегда можно написать и назвать isalnum_safe.
4eyes
ну и к слову, а что решит assert? Заменит «segmentation fault» внутри isalnum() на «assertion failure», но только в отладочной сборке?
mayorovp
Заменит непонятное сообщение об ошибке на понятное, чтобы больше не пришлось гадать как это вообще возможно и лезть в потроха glibc.
Да, только в отладочной сборке.
4eyes
Признаюсь: я не настоящий разработчик на С, я маску на стройке нашел. Как плюсовик, когда стандартная библиотека начинает меня удивлять, я иду на cppreference.com и в третьем предложении документации к функции читаю: «The behavior is undefined if the value of ch is not representable as unsigned char and is not equal to EOF.»
Бесспорно, расковырять сорцы glibc полезно, особенно, если разобраться, зачем они сделали именно так, но исследование проблемы методом чтения документации тоже не заняло много времени.
mayorovp
Я тоже не настоящий разработчик на Си, и документацию я читать тоже умею. Вот только если код чужой — то может оказаться далеко не сразу очевидно, что нарушено именно это условие. Корректное сообщение об ошибке хотя бы в отладочной сборке надёжнее.
sergio_nsk
Как только прочёл предложение автора "Переменные last и next имеют тип uint32_t", сразу стало понятно, что проблема — в диапазоне чисел, осталось только пойти на https://en.cppreference.com/w/c/string/byte/isalnum за подтверждением. Минус бы автору за то, что специально до конца не раскрывал значение переменной next.
ikle
А ещё статическому анализатору поможет, который распознает assert и радостно сообщит «value out of range». (Но ни cppcheck, ни scan-build из clang-tools-7 в Debian 9 assert не помог увидеть ошибку.)
mayorovp
Хм, но почему простой assert кладёт на лопатки branch prediction? Вроде же наоборот, он должен без проблем им предсказываться...
4eyes
Я погорячился. Не будет он класть branch prediction на лопатки, т.к. процессор быстро поймет, что этот assert при нормальной работе приложения не срабатывает никогда.
Но и прироста производительности от него не будет: с точки зрения процессора, это не assert, а обычное сравнения (лишнее сравнение), какой-то переход и 2 ветки кода за ним. Через несколько итераций он начнет угадывать на какую ветку будет выполнен переход до сравнения.
mayorovp
Если правильно расставить подсказки (ЕМНИП, для старых процессоров есть префиксы, новые разделяют переходы вперёд/назад), то в правильной программе ошибок предсказания перехода на ассертах не будет в принципе.