Даже при вызове exec сразу после разветвления дочерний процесс завершается с помощью SIGSEGV.C#

Место общения программистов C#
Anonymous
Даже при вызове exec сразу после разветвления дочерний процесс завершается с помощью SIGSEGV.

Сообщение Anonymous »

Я пытаюсь создать эмулятор терминала на C# (.NET 10.0), который может работать в Linux (в данном случае kubuntu 25.10). Таким образом, я должен использовать API openpty для получения пары FD главный/ведомый PTY, а затем, после выполнения разветвления, скопировать их в слоты STDIN_FILENO, STDOUT_FILENO и STDERR_FILENO перед выполнением нужной программы для дочернего процесса. После некоторого поиска я обнаружил функцию forkpty, которая выполняет за вас большую часть этой работы, так что все, что вам нужно сделать после ее возврата в дочернем процессе, — это exec.
Но когда я пытаюсь сделать это из C#, дочерний процесс генерирует SIGSEGV между возвратом и вызовом exec. У меня была мысль, что, возможно, вызовы P/Invoke изначально генерировались как заглушки, и для разрешения заглушки требовалось нечто большее, чем неглубокая вилка, но даже если я сделаю фиктивный прогревочный вызов execvp перед вызовом forkpty (с пустым аргументом файла, чтобы он быстро возвращался с ошибкой), я все равно наблюдаю немедленный вызов SIGSEGV. В выводе strace я вижу, что forkpty выполняет работу по замене файловых дескрипторов stdin, stdout и stderr, среди прочего. Я вполне уверен, что SIGSEGV не происходит внутри самого forkpty. (Это также подтверждается обратной трассировкой, кратко упомянутой ниже.)
SIGSEGV также не запускается вызовом execvp; если я полностью закомментирую вызов forkpty, так что родительский вызов просто принесет себя в жертву непосредственно exec, тогда все будет происходить так, как и ожидалось. Процесс заменяется и запускается bash (хотя pty все еще находится в каноническом режиме :-P).
Код:

Код: Выделить всё

    var term = new termios() { ... /* a bunch of flags */ };
var win = new winsize() { ... /* 80x25 character, 640x400 pixel */ };

string filePath = "/usr/bin/bash";

nint fileNamePtr = Marshal.StringToHGlobalAnsi(filePath);

nint argvPtr = Marshal.AllocHGlobal(2 * nint.Size);

Marshal.WriteIntPtr(argvPtr, 0, Marshal.StringToHGlobalAnsi(Path.GetFileName(filePath)));
Marshal.WriteIntPtr(argvPtr, nint.Size, 0);

nint emptyString = argvPtr + nint.Size; // we just wrote a 0 to this address

int result = execvp(emptyString, 0);
Console.WriteLine("Warm-up result: {0}", result);

sleep(0); // also warmup

int childPID = forkpty(
out int masterFD,
name: null,
ref term,
ref win);

if (childPID == 0)
{
// to verify whether child code is running -- SIGSEGV happens before this sleep could have returned
//sleep(15);

execvp(fileNamePtr, argvPtr);
}

Console.WriteLine("Child PID is: {0}", childPID);
При запуске:
  • Он выводит: Результат прогрева: -1, что указывает на то, что вызов execvp сработал (и не удалось из-за намеренной передачи пустой строки для файла)
  • Родительский процесс выводит: Дочерний PID: 12345
  • Но вывод strace показывает, что дочерний процесс уже получил SIGSEGV перед этим последним выводом.
  • Ошибка сегментации происходит до того, как код попадает в тело оператора if. Чтобы исключить исключение, возникающее в execvp, я попытался добавить сон, но SIGSEGV по-прежнему происходит сразу после возврата fork к дочернему элементу.
Я могу записать дамп ядра, но это не кажется очень полезным, поскольку изображение, которое выполняется при его захвате, представляет собой вывод JIT. Если я загружаю дамп ядра в GDB, обратная трассировка представляет собой просто последовательность адресов памяти, которые на самом деле нигде не существуют. Следует отметить, что ни forkpty, ни execvp не включены в обратную трассировку.
(Я пробовал настроить минидампы «COMPlus» на основе найденных мной ссылок, но, похоже, это ничего не дало. Я нашел одно сообщение, в котором указывалось, что запись 0xff в /proc/self/coredump_filter приведет к тому, что сгенерированные системой дампы будут включать все, но хотя это привело к увеличению более чем вдвое размер файла дампа ядра, GDB по-прежнему не видит никакого кода.)
Я пытался получить выходные данные ассемблера из JIT, но все, что я вижу, это вызов по одному адресу в том месте, где forkpty находится в коде, за которым немедленно следует настройка параметров для execvp и еще один вызов. Насколько я понимаю, эти вызовы относятся к батутам, которые разрешаются по требованию, но после выполнения одного вызова батут заменяется прямым вызовом фактической целевой функции. (Это понимание может быть неправильным или неполным, но я не знаю, как его проверить.) Именно на этой стратегии своевременного разрешения я пытался выполнить фиктивные прогревающие вызовы к конечным точкам P/Invoke перед разветвлением, но безрезультатно.
Что может произойти? Как я могу отладить это дальше?
Я могу очистить свое тестовое приложение и добавить его в репозиторий, если это будет полезно.
ETA: вот дизассемблирование, полученное с помощью Sharplab.io с целью x64:

Код: Выделить всё

    int childPID = forkpty(
out int masterFD,
name: null,
ref term,
ref win);

L0285: lea r8, [rbp-0x30]   ; parameter 2 loaded from a local
L0289: lea rcx, [rbp-0x60]  ; parameter 0 loaded from a local
L028d: lea r9, [rbp-0x40]   ; parameter 3 loaded from a local
L0291: xor edx, edx         ; parameter 1 == NULL
L0293: call 0x00007ffe91060030 ; call to forkpty P/Invoke trampoline

L0298: mov [rbp-0xe8], eax
L029e: mov eax, [rbp-0xe8]  ; unclear to me why this line exists

L02a4: mov [rbp-0x54], eax  ; stashing return value in childPID

if (childPID == 0)
{

L02a7: cmp dword ptr [rbp-0x54], 0  ; set flags based on value of childPID
L02ab: sete al                      ; AL = 1 iff childPID == 0
L02ae: movzx eax, al                ; expand AL out to the full register
L02b1: mov [rbp-0x64], eax          ; stash in a temporary location
L02b4: cmp dword ptr [rbp-0x64], 0  ; check value of: (childPID == 0)
L02b8: je short L02ce               ; it was zero? skip the if block

execvp(fileNamePtr, argvPtr);

L02ba: mov rcx, [rbp-0x48]     ; parameter 0 loaded from local
L02be: mov rdx, [rbp-0x50]     ; parameter 1 loaded from local
L02c2: call 0x00007ffe91060048 ; call to execvp P/Invoke trampoline
Возможно, я что-то упускаю, но не понимаю, как это может вызвать SIGSEGV. Должно быть, это что-то вне моего контроля, либо в эпилоге первого вызова P/Invoke, либо в настройке второго??
ETA 2: Я узнал, как заставить GDB следовать за дочерним процессом в разветвлении, и смог шаг за шагом отладить дочерний процесс. Похоже, что после вызова P/Invoke требуется значительная обработка, прежде чем он фактически вернется к вызывающей стороне. Я заметил одну вещь: вызов tls_get_addr. Интересно, нарушается ли TLS при разветвлении, поскольку меняется идентичность потока, но если TLS — это просто общий диапазон адресов, который по-разному отображается в разных потоках, этого не должно происходить.
Только что во время сеанса отладки я останавливал инструкцию за инструкцией и остановился на этой инструкции:

Код: Выделить всё

(gdb) x/20i 0x00007fff78f341f0
=> 0x7fff78f341f0:      push   %rbp
0x7fff78f341f1:      sub    $0x150,%rsp
0x7fff78f341f8:      lea    0x150(%rsp),%rbp
0x7fff78f34200:      vxorps %xmm8,%xmm8,%xmm8
0x7fff78f34205:      vmovdqu32 %zmm8,-0x140(%rbp)
0x7fff78f3420c:      vmovdqu32 %zmm8,-0x100(%rbp)
0x7fff78f34213:      vmovdqu32 %zmm8,-0xc0(%rbp)
0x7fff78f3421a:      vmovdqu32 %zmm8,-0x80(%rbp)
0x7fff78f34221:      vmovdqa %xmm8,-0x40(%rbp)
0x7fff78f34226:      xor    %eax,%eax
0x7fff78f34228:      mov    %rax,-0x30(%rbp)
0x7fff78f3422c:      mov    $0xc857ba0e,%eax
0x7fff78f34231:      mov    %rax,-0x150(%rbp)
0x7fff78f34238:      mov    %rdi,-0x8(%rbp)
0x7fff78f3423c:      mov    %rsi,-0x10(%rbp)
0x7fff78f34240:      mov    %edx,-0x14(%rbp)
0x7fff78f34243:      mov    %rcx,-0x20(%rbp)
0x7fff78f34247:      xor    %eax,%eax
0x7fff78f34249:      mov    %rax,-0x30(%rbp)
0x7fff78f3424d:      nop
(gdb)
Затем я запустил nexti, который, как я ожидал, выполнит одно нажатие в стек, и произошло следующее:

Код: Выделить всё

(gdb) ni

Thread 3.1 "THE THING" received signal SIGSEGV, Segmentation fault.
0x00007fff7829fb9c in ?? ()
=> 0x00007fff7829fb9c:  ff 10                   call   *(%rax)
(gdb) bt
#0  0x00007fff7829fb9c in ?? ()
#1  0x00007feffbffe630 in ?? ()
#2  0x00007feffbffe889 in ?? ()
#3  0x00007feffbffe600 in ?? ()
#4  0x00007feffbffe640 in ?? ()
#5  0x00007feffbffe8b8 in ?? ()
#6  0x0000000000000001 in ?? ()
#7  0x00007feffbffe7f8 in ?? ()
#8  0x0000000000000000 in ?? ()
(gdb)
Я не понимаю, как push rbp переключил этот поток на совершенно другой стек вызовов и попытался выполнить инструкцию call [rax].
В любом случае, насколько я могу судить, это весь код, который выполняется между возвратом фактической функции forkpty и возвратом вызова батута P/Invoke в моем коде C#. Если нет какого-то волшебного трюка, о котором я не знаю, я думаю, это может указывать на то, что с fork буквально невозможно работать из кода C#. :-(

Вернуться в «C#»