MSYS2, GDB и управление памятью

Эта история началась с того, что мне захотелось поработать с интерпретатором одного очень экзотического языка программирования, а закончилась тем, что я освоил не менее экзотические (для меня) нюансы работы с памятью в С в Windows и POSIX, и того, как работает отладчик gdb в Windows.

Итак, захотелось мне поработать с интерпретатором одного ну очень-очень экзотического языка программирования. Исходный код интерпретатора открыт, но готовых собранных исполняемых файлов нет, значит придётся собирать самому. В исходниках файлы с расширением ».c», рядом лежит makefile. На Linux-машине всё собралось сразу и без проблем, интерпретатор запускался и успешно интерпретировал те выражения, которые я в него вводил. Но мне ещё захотелось получить Windows-вариант.

Сборка под MSYS2

Если разработчики пишут C-код для Linux, то можно попытаться скомпилировать его при помощи Microsoft Visual C++ , но шансов мало. Лучше попробовать вариант gcc, который собирает код для Windows, и такие варианты есть. Мне больше всего нравится дистрибутив MSYS2, который содержит несколько toolchain-ов, из которых я пользуюсь ucrt64 toolchain. Это gcc, сконфигурированный создавать исполняемые файлы, использующие Microsoft-овскую C-шную стандартную библиотеку ucrt. Попробовал собрать интерпретатор этим toolchain-ом, и получил ошибки, из которых понятно, что для компиляции нужны , , и ещё несколько. Эти заголовочные файлы не являются частью стандартной библиотеки C, это POSIX. Досадно.

Хмм, подумал я, а может эти функции не являются критически важными для интерпретатора, и можно их как то исключить/закомментить? Ну зачем мне на первых порах TCP-сокеты? С такой мыслью я ринулся читать исходный код интерпретатора, и ааа, мама, что это?

Z I fm(I f)_(ST stat s;fstat(f,&s)<0?0:s.st_mode)
Z A frd(I f,N i,N n)_(P(i||n+1,en0())DIR*a=fdopendir(f);P(!a,ei0())A x=emp(tC);ST dirent*e;W((e=readdir(a)),S s=e->d_name;x=apc(cts(x,s,SL(s)),10))closedir(a);x)
Z A frS(I f,N n)_(C b[1024];A x=emp(tC);I k=1;W(n&&k,k=read(f,b,MIN(SZ b,n));P(k<0,eo(x))n-=k;x=cts(x,b,k);P(f<3&&k-SZ b,x))x)
Z A frs(I f,N i,N n)_(I(i&&lseek(f,i,SEEK_CUR)<0,mr(N(frS(f,i))))frS(f,n))
Z A frm(I f,N i,N n)_(L m=lseek(f,0,SEEK_END);P(m<0,eo0())n=MIN(n,MAX(m-i,0));n?mf(f,i,n):emp(tC))
Z A fr(A x/*1*/,N i,N n)_(Xz(frs(gl(x),i,n))I f=N(o(x,O_RDONLY));P(f<3,frs(f,i,n))I m=fm(f);x=(S_ISDIR(m)?frd:S_ISREG(m)?frm:frs)(f,i,n);close(f);x)              // read

Код этот ещё более, хм, экзотичен, чем язык, который интерпретируется. Теоретически это C, но на деле авторы используют ядрёный набор макросов, чтобы код получался очень экономным и сжатым. Никакого «каждый оператор на новой строке», вместо этого «одна функция — одна строка, пробелы-разделители придумали трусы». Это очень далеко от идеоматического C, читать такой код весьма непросто. Одно из двух: или автор — инопланетянин, или читабельность — дело привычки. Но мне это не помогло, удалить POSIX-функционал из интерпретатора я не мог. Дважды досадно.

Я отступил и занялся другими делами, но спустя некоторое время ко мне пришла такая мысль. Дистрибутив MSYS2 устроен интересным образом: он предназначен для создания нативных Windows-приложений, и большинство инструментов в нём сами являются нативными Windows-приложениями, но не все. Некоторые инструменты написаны для POSIX-систем, и для них MSYS2 содержит слой эмуляции, взятый из Cygwin. Более того, в MSYS2 есть вариант gcc toolchain, который собирает Windows-приложения, использующие этот слой эмуляции. А что, подумал я, это вариант! Этот toolchain так и называется, «msys2», я его доустановил и попробовал собрать с ним. И сработало, интерпретатор запустился! Есть, конечно, небольшое неудобство: получившийся исполняемый файл зависит от слоя POSIX-эмуляции, который реализован в виде msys-2.0.dll . Если запускать из msys2 bash, то всё хорошо, поэтому что эта dll-ка лежит в »/usr/bin/» , который находится в PATH. Но если запускать другим способом, то dll-ку найти не получается. Выход простой: скопировать её в каталог собранного приложения и потом таскать с приложением.

Отладка интерпретатора

Получив вожделенный интерпретатор, я принялся играться с ним. Со временем у меня набрался список вопросов, на которые не было ответов в документации, ответы были только внутри интерпретатора. Но его исходный код очень уж инопланетянский! Главная проблема в том, что там очень много маленьких макросов, и это макросы, которые используют макросы, очень глубокие цепочки вложенности. Навигация по таким цепям макросов весьма непроста, но и чёрт бы с ней, можно же заставить gcc-шный препроцессор раскрыть макросы! Так я и сделал. Код стал чуточку читабельней. Оказалось, что функций там тоже много, и это функции, которые вызывают функции, и тоже большие цепочки вложенности. Уфф.

При чтении кода разработчик выполняет его в своей голове. Когда это становится непросто, то можно взять на помощь реального выполнятора: запустить код в отладчике. В дистрибутиве MSYS2 для gcc toolchain-ов есть gdb, его и попробуем. Ставим точку останова в main (), запускаем, точка останова срабатывает. Нажимаем «Продолжить», и эээ, а почему?

Thread 1 "k" received signal SIGSEGV, Segmentation fault.
0x0000000100429adb in mb (i=23) at m.c:25
25 Z A mb(U i)_(P(i>=L(bkt),V*p=mm(HD<

При запуске под отладчиком программа падает с Segmentation Fault, хотя при запуске без отладчика всё работает хорошо. Никакой разницы быть не должно, параметры запуска полностью совпадают. Отладчик, ты должен был помогать решать проблемы, а не создавать их!

Я не очень хорошо разбираюсь в gdb, запомнил только, что команды step/next выполняют строку исходного кода. Но этот интерпретатор написан так, что у него одна строка кода — это целая функция, включая название! Действие «шагнуть» в этом случае выполняет всю функцию целиком, что не очень помогает. В примере выше отладчик вывел строку кода, в которой он упал, это целиковая функция. Чтобы понять, в каком именно месте функции упала программа, можно дизассемблировать код и посмотреть, какая инструкция расположена по тому адресу, откуда выбросилось исключение:

   0x0000000100429ace <+218>:	mov    rax,QWORD PTR [rsp+0x30]
   0x0000000100429ad3 <+223>:	sub    rax,0x20
   0x0000000100429ad7 <+227>:	vpxor  xmm0,xmm0,xmm0
=> 0x0000000100429adb <+231>:	vmovdqu YMMWORD PTR [rax],ymm0
   0x0000000100429adf <+235>:	mov    rax,QWORD PTR [rsp+0x30]

Мда, мои познания ассемблера закончились на уровне i386, а тут совсем незнакомая инструкция, погуглил — это из набора AVX. Целиком дизассемблированный код функции я поленился разбирать, но вот несколько предшествующих инструкций я вроде бы понимаю: сначала в rax помещается значение какой-то локальной переменной, потом уменьшается на 32.

Что ж, вернёмся обратно к исходникам, возьмём результат, выданный препроцессором для этой функции и попробуем причесать для читабельности.

typedef void V;
typedef unsigned long long A;
typedef unsigned int U;

static A bkt[24];
static A mb(U i){
    return({
        if(i>=(sizeof(bkt)/sizeof((bkt)[0]))){
          return({
              V*p=mm(32ll<

Имея этот исходник, можно потрассироваться по машинному коду при помощи gdb-команд stepi/nexti, которые шагают по отдельным машинным инструкциям. При этом gdb позволяет просматривать значения C-шных переменных по имени, можно посмотреть, чему равен x и y. Так мы быстро добираемся до нужного места: страшные AVX-инструкции, оказывается, это __builtin_memset (). А, ну тогда дело понятное, тут или начальный указатель неправильный, или с длиной участка памяти накосячили.

Я ещё немного поразбирался с этой функцией. Как я понял, это самое ядро собственного менеджера памяти интерпретатора, и функция mb () распределяет память блоками, обращаясь к функции mm (), которая, собственно, запрашивает память у операционки через вызов mmap (). И при отладке выходило, что всё должно работать, указатель в __builtin_memset () передаётся правильный, и длина тоже правильная.

Тут меня торкнуло:, а может ну его, к лешему, этот __builtin_memset (), заменю его на обычный memset (). Я не поверил своим глазам, когда это сработало! Интерпретатор стал вполне нормально работать под отладчиком, я мог ставить точки останова. Я издал торжествующий вопль.

Погружение в работу с памятью

Казалось бы, теперь у меня был интерпретатор, чтобы играться с языком, и исходники, чтобы разбираться в них. Но проблема __builtin_memset (), который падал только под отладчиком, не отпускала меня. Чтобы отвязаться от интерпретатора, я написал небольшой воспроизводящий двойной пример. Вот первый вариант:

#include
#include
#include

int main() {
  void* p = mmap(0, 2048, PROT_READ|PROT_WRITE,MAP_NORESERVE|MAP_PRIVATE|MAP_ANON,-1,0);
  char* cp = (char*)p;
  cp += 128;
  memset(cp, 10, 32);
  printf("Done\n");
}

Этот пример работает и сам, и под отладчиком. Вот второй вариант:

#include
#include
#include

int main() {
  void* p = mmap(0, 2048, PROT_READ|PROT_WRITE,MAP_NORESERVE|MAP_PRIVATE|MAP_ANON,-1,0);
  char* cp = (char*)p;
  cp += 128;
  __builtin_memset(cp, 0, 32);
  printf("Done\n");
}

Этот пример работает сам, падает под отладчиком.

Тыкс, какие гипотезы у нас есть? Ну, во-первых, может дело в mmap ()? Это легко проверить, выделив память на стеке.

#include
#include

int main() {
  char cp[1024];
  char* cp2 = (&cp[0]) + 4;
  __builtin_memset(cp2, 0, 32);
  printf("Done\n");
}

Ух ты, на стеке всё работает даже под отладчиком, то есть mmap () всё-таки влияет. Пойдём дальше:, а может дело именно в mmap ()? Убираем __builtin_memset () совсем, заменив на обращение к памяти по указателю.

#include
#include

int main() {
  void* p = mmap(0, 2048, PROT_READ|PROT_WRITE,MAP_NORESERVE|MAP_PRIVATE|MAP_ANON,-1,0);
  char* cp = (char*)p;
  cp += 128;
  *cp = 'v';
  printf("Done\n");
}

Программа работает сама, под отладчиком падает. Эээ что? То есть при обращении к памяти по указателю оно падает, а через memset () — не падает, это как так? Добавим обращение к памяти по указателю после memset ()

#include
#include
#include

int main() {
  void* p = mmap(0, 2048, PROT_READ|PROT_WRITE,MAP_NORESERVE|MAP_PRIVATE|MAP_ANON,-1,0);
  char* cp = (char*)p;
  char* np = cp + 8;
  cp += 128;
  memset(cp, 10, 32);
  *np = 20;
  printf("Done\n");
}

Так работает и само, и под отладчиком. Этот memset () делает какую-то чёртову магию внутри. И эта магия как-то связана с mmap (), потому что без mmap () всё работает. А ещё оно связано с gdb.

На всякий случай я решил попробовать глянуть, всё ли в порядке с mmap (), точно ли он распределяет память? В самом gdb я не нашёл способа поглядеть распределение виртуальной памяти отлаживаемого процесса в Windows. Поэтому взял Sysinternals, в нём есть vmmap. Расставив паузы в программе, я убедился, что mmap () действительно выделяет участок. Тогда почему, чёрт возьми, оно падает при обращении к нему?

0x6fffffff000 - это свежевыделенный блок
0×6fffffff000 — это свежевыделенный блок

Ладно, вернёмся к gdb и попробуем понять магию memset (), зайдя отладчиком вовнутрь. Эта функция реализована всё в той же msys-2.0.dll . Я попробовал подключить отладочную информацию, вызвав «add-symbol-file /usr/bin/msys-2.0.dbg», но это не сильно помогло, потому что сама функция написана на ассемблере, вот тут (https://github.com/msys2/msys2-runtime/blob/msys2–3.6.1/winsup/cygwin/x86_64/memset.S) её исходники. Ладно, не беда, функция небольшая, и легко увидеть, что никакой магии тут нет. А где тогда магия?

Давайте попробуем mmap (). Его исходники вот тут (https://github.com/msys2/msys2-runtime/blob/msys2–3.6.1/winsup/cygwin/mm/mmap.cc#L837), почитаем.

extern "C" void *
mmap (void *addr, size_t len, int prot, int flags, int fd, off_t off)
{
  syscall_printf ("addr %p, len %lu, prot %y, flags %y, fd %d, off %Y",
		  addr, len, prot, flags, fd, off);

Ой как интересно, а что это за отладочная печать тут? Ба, да не может быть, в MSYS2 есть strace, не знал! Хочу-хочу, запускаю вариант программы, который делает mmap ()+memset (), читаю вывод. Таак, вот логирование нашего mmap (), а вот это что ещё такое?

 1340  176067 [main] a 1237 mmap: addr 0x0, len 2048, prot 0x3, flags 0x4022, fd -1, off 0x0
  184  176251 [main] a 1237 mmap: 0x6FFFFFFF0000 = mmap()
--- Process 12700, exception c0000005 at 00000001800413df

Это что же такое получается, exception есть, но программа продолжает выполняться дальше как ни в чём не бывало?

Почитав ещё исходники в том же файле, где определена mmap (), я заметил очень интересную функцию (https://github.com/msys2/msys2-runtime/blob/msys2–3.6.1/winsup/cygwin/mm/mmap.cc#L752).

mmap_region_status mmap_is_attached_or_noreserve (void *addr, size_t len)

Оказывается, если mmap () вызывается с флагом MAP_NORESERVE, то память не выделяется на самом деле, вместо этого адреса запоминаются в специальном списке. При этом ошибки доступа к памяти перехватываются библиотекой, и настоящее выделение памяти происходит в этом обработчике (https://github.com/msys2/msys2-runtime/blob/msys2–3.6.1/winsup/cygwin/exceptions.cc#L735), который дёрнется при первом обращении к недовыделенной памяти. Это уже горячо, это почти объясняет то, почему всё работает, если запускать программу, и падает, если запускать под gdb: отладчики вмешиваются в исключения, которые прилетают в программу. Код в обработчике прерывания даже пытается определять, что он запущен под отладчиком.

Но всё равно не сходится: эта логика обработки ошибок обращения к памяти должна работать всегда, а получается, что она работает, или когда нет отладчика, или когда он есть и происходит memset (). И не работает, когда есть отладчик и к памяти обращаются из кода самой программы.

Мне потребовалось очень много времени, прежде чем мне в голову пришла идея: если обработчик исключения не срабатывает под отладчиком, то, может, это отладчик его убирает? Гуглив некоторое время «gdb msys2» и «gdb cygwin», я добрался вот до такого интересного файла (https://github.com/bminor/binutils-gdb/blob/master/gdb/windows-nat.c#L1180):

bool
windows_per_inferior::handle_access_violation
     (const EXCEPTION_RECORD *rec)
{
#ifdef __CYGWIN__
  /* See if the access violation happened within the cygwin DLL
     itself.  Cygwin uses a kind of exception handling to deal with
     passed-in invalid addresses.  gdb should not treat these as real
     SEGVs since they will be silently handled by cygwin.  A real SEGV
     will (theoretically) be caught by cygwin later in the process and
     will be sent as a cygwin-specific-signal.  So, ignore SEGVs if
     they show up within the text segment of the DLL itself.  */
  const char *fn;
  CORE_ADDR addr = (CORE_ADDR) (uintptr_t) rec->ExceptionAddress;

  if ((!cygwin_exceptions && (addr >= cygwin_load_start
			      && addr < cygwin_load_end))
      || (find_pc_partial_function (addr, &fn, NULL, NULL)
	  && startswith (fn, "KERNEL32!IsBad")))
    return true;
#endif
  return false;
}

Оказывается, в cygwin/msys2 сборках gdb устанавливает свой обработчик исключений, который проверяет, из какого кода прилетело исключение. Если оно прилетело из cygwin1.dll / msys-2.0.dll, то gdb передаёт исключение дальше по цепочке, и оно попадает в обработчик из этой dll-ки.

Итак, вот она, магия. А ещё если программа выполняется без отладчика, то mmap () откладывает выделение памяти до первого обращения к ней, и для этого устанавливает свой обработчик исключений. Если программа выполняется под отладчиком gdb, собранным для MSYS2, то такой отладчик применяет специальную обработку для исключений, которые выбрасываются из кода msys-2.0.dll (например, из memset ()), позволяя нормально отработать отложенному выделению памяти. Но если обратиться к недовыделенной памяти напрямую — будет плохо.

Заключение

Для полноты картины нужно добавить ещё несколько важных деталей.

Во-первых, специальную обработку исключений из кода Cygwin-слоя эмуляции можно отключить в gdb. Есть команда «set cygwin-exceptions» для управления этим флагом, значение можно посмотреть через «show cygwin-exceptions».

Во-вторых, а почему память после mmap (), собственно, недовыделенная? Я же видел её в vmmap? Причина в том, как Cygwin эмулирует POSIX-вызов mmap () на Windows. Флаг MAP_NORESERVE означает, что под выделяемые страницы не будет зарезервировано место в swap-файле до первого обращения. При этом обращаться к такой странице памяти в POSIX-системах вполне можно. А Cygwin в этом случае вызывает VirtualAlloc () с типом аллоцирования MEM_RESERVE, который резервирует адресное пространство, но обращаться к такой памяти нельзя, нужно сначала ещё раз вызвать VirtualAlloc () с MEM_COMMIT, что и происходит из обработчика исключения, установленного в msys-2.0.dll . Это очень важный нюанс эмуляции.

Этот блок уже COMMITED, у него заполнены многие столбцы, ранее пустые
Этот блок уже COMMITED, у него заполнены многие столбцы, ранее пустые

В-третьих, представим, что я бы решил отладиться не тем gdb, который идёт в msys toolchain, а другим, например тем, который в ucrt64 toolchain. Такой gdb не содержал бы специальной обработки исключений из функций msys-2.0.dll, и там я бы получал Segmentation Fault в отладчике всегда, даже при вызове memset (). Возможно, тогда я бы быстрее понял, что дело в нюансах эмуляции mmap () через VirtualAlloc ().

В четвёртых, я правильно сделал, что даже не стал пытаться компилировать через Microsoft Visual C++ , и дело даже не в использовании POSIX-функционала. Если внимательно посмотреть на пример C-кода выше, который я попробовал довести до читабельности, то можно там увидеть вот такие конструкции

({some_statement1;some_statement2;})

Оно применяется в сочетании с return, и называется expression statement: блок кода, который возвращает значение. Это gcc-расширение языка, и Visual C++ так не умеет.

В пятых: если вы смотрите на код gdb, который реализует специальную поддержку для исключений из Cygwin, и видите там называние библиотеки «cygwin1.dll», то логично спросить:, а откуда появляется поддержка для «msys-2.0.dll»? Это делается патчем из MSYS2 (https://github.com/msys2/MSYS2-packages/blob/master/gdb/1005-msysize.patch).

Спасибо, что прочитали.

© Habrahabr.ru