Эта история началась с того, что мне захотелось поработать с интерпретатором одного очень экзотического языка программирования, а закончилась тем, что я освоил не менее экзотические (для меня) нюансы работы с памятью в С в 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-ом, и получил ошибки, из которых понятно, что для компиляции нужны <sys/socket.h> , <sys/mman.h> , и ещё несколько. Эти заголовочные файлы не являются частью стандартной библиотеки 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<<i,0);P(!p,die("OOM"))AP(p+HD))A x=bkt[i];P(x,bkt[i]=xX;DBG(xX=0);x)x=mb(i+1);A y=x+(HD<<i);MS(yV-HD,0,HD);yb=i;yX=bkt[i];bkt[i]=y;x)

При запуске под отладчиком программа падает с 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<<i,0);
              if(!p){
                return({die("OOM");});
              }
              (A)(p+32ll);
            });
        }
        A x=bkt[i];
        if(x){
          return({
              bkt[i]=(*(A *)((x)-24));
              x;
            });
        }
        x=mb(i+1);

        A y=x+(32ll<<i);  
        __builtin_memset(((V*)(y))-32ll,0,32ll);
        (*(UC*)((y)-32))=i;
        (*(A *)((y)-24))=bkt[i];
        bkt[i]=y;
        x; 
      });
}

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

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

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

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

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

#include<sys/mman.h>
#include<string.h>
#include<stdio.h>

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<sys/mman.h>
#include<string.h>
#include<stdio.h>

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<string.h>
#include<stdio.h>

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

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

#include<sys/mman.h>
#include<stdio.h>

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<sys/mman.h>
#include<string.h>
#include<stdio.h>

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 - это свежевыделенный блок

Ладно, вернёмся к 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, у него заполнены многие столбцы, ранее пустые

В-третьих, представим, что я бы решил отладиться не тем 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).

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