即使在fork之后立即调用exec,子进程仍然因SIGSEGV而终止
我正在尝试用C#(.NET 10.0)在Linux上创建一个终端仿真器(本例为Kubuntu 25.10)。因此,我必须使用 openpty API来获取一个PTY主从FD对,然后在完成一次 fork、dup 之后,将它们放入 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO 的插槽中,最后在 exec 子进程的目标程序之前执行。经过一番摸索,我发现了 forkpty 函数,它为你完成了其中的大部分工作,因此在子进程返回时你只需要执行 exec。
但当我尝试用C#完成此事时,子进程在返回与调用 exec 之间产生 SIGSEGV。我一度以为P/Invoke调用最初是以存根形式生成的,解析存根需要的不仅仅是一个浅显的 fork,但即使在 forkpty 之前对 execvp 进行一个空的热身调用(传入一个空的 file 参数,以便快速返回并返回错误),我仍然观察到一个立即的 SIGSEGV。在 strace 的输出中,我可以看到 forkpty 正在完成替换stdin、stdout和 stderr文件描述符等工作的过程。我相当确定 SIGSEGV 并非在 forkpty 内部发生。(回溯信息也支持这一点——下面简要提及。)
The 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);
当这运行时:
- 它输出:
Warm-up result: -1,表明对execvp的调用成功(并因为故意将文件设为空字符串而失败) - 父进程输出:
Child PID is: 12345 - 但strace输出显示,在这最后的输出之前,子进程已经接收到
SIGSEGV - 分段错误发生在代码进入
if的主体之前。为了排除异常发生在execvp内,我尝试添加一个sleep,但SIGSEGV仍然在fork返回给子进程时立即发生。
我可以捕获一个核心转储,但似乎并不太有用,因为被捕获时正在执行的镜像是JIT输出。如果把核心转储载入GDB,回溯只是一些在任何地方都不存在的内存地址。此外,回溯中既没有 forkpty 也没有 execvp。
(我已经尝试根据参考资料配置 "COMPlus" 的最小转储,但似乎没有效果。我发现一篇帖子指出向 /proc/self/coredump_filter 写入0xff会让系统生成的转储包含所有内容,虽然这使核心转储文件的大小翻倍以上,但GDB仍然看不到任何代码。)
我尝试获取JIT的汇编输出,但我看到的只是一个 call 指向代码中 forkpty 的某个地址,随后就为 execvp 设置参数并再一次调用。据我理解,这些 call 是按需解析的跳板,在一次调用后,这个跳板会被直接的 call 指向实际目标函数所替代。这种理解可能不对或不完整,但我不知道如何验证。正是基于这种就地(Just-In-Time)解析策略,我在 fork 之前对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在 fork时跟随子进程,并能够逐步调试子进程。看来在P/Invoke调用之后,实际返回给调用方之前有大量处理。我注意到有一个对 tls_get_addr 的调用。我在想 fork 是否会破坏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,我本以为它会在栈上执行一个单独的 push,结果如下:
(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 函数返回与在我的C#代码中对P/Invoke跳板返回之间执行的所有代码。除非我不知道某种神奇技巧,否则这似乎在字面意义上表明无法在C#代码中使用 fork。 :-(
ETA 3:在在 fork 之前禁用信号后,我认为我取得了进展。我仍然遇到一个 SIGSEGV,但现在它出现在来自已加载库的代码中,而不是JIT输出代码。
Thread 3.1 "THE THING" received signal SIGSEGV, Segmentation fault.
[Switching to Thread 0x7feffbfff6c0 (LWP 3390396)]
0x00007ffff7406790 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
=> 0x00007ffff7406790: 44 0f b6 04 17 movzbl (%rdi,%rdx,1),%r8d
(gdb) bt
#0 0x00007ffff7406790 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#1 0x00007ffff73c54cc in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#2 0x00007ffff73decb5 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#3 0x00007ffff73c5352 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#4 0x00007ffff47de83a in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#5 0x00007ffff47d8fe7 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#6 0x00007ffff47d70ca in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#7 0x00007ffff47e470b in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#8 0x00007ffff4836a6c in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#9 0x00007ffff46de852 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#10 0x00007ffff46e0eab in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#11 0x00007ffff46dfd31 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#12 0x00007ffff46e1c7b in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#13 0x00007ffff46e6288 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libclrjit.so
#14 0x00007ffff73cc04a in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#15 0x00007ffff7409f6e in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#16 0x00007ffff740981f in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#17 0x00007ffff7408f56 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#18 0x00007ffff7408a06 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#19 0x00007ffff7371664 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#20 0x00007ffff740c4cd in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#21 0x00007ffff740bd52 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#22 0x00007ffff7619d04 in ?? () from /usr/lib/dotnet/shared/Microsoft.NETCore.App/10.0.4/libcoreclr.so
#23 0x00007fff78f77bbf in ?? ()
#24 0x0000000000000000 in ?? ()
(gdb)
没有符号信息,仍然很难真正看清发生了什么。我尝试使用 dotnet-symbols 下载符号,但 libcoreclr.so 的符号名不在符号服务器中。
解决方案
(我不是专门的Linux上的C#专家,但能读懂AI的回答,且对C#、POSIX和线程有一般了解)
小注:如果execvp() 失败,你可以选择在控制台显示错误信息,并且应调用 _exit(),让失败的子进程以受控方式终止。
我认为在C#中使用fork()(以及clone())是不安全的,因为产生的新子进程将是一个单线程拷贝,而C#运行时假设仍然存在工作线程。我敢说fork() 要么在C#中不可用,要么应该有一个专门定制的包装器。进入/退出子进程时的任何线程特定代码,如果没有针对这个特定系统调用进行定制而假设线程ID不改变、且仍然存在其他线程,都会失败。
类似的问题:
如果你自己实现一个C 函数,同时执行forkpty() 和execvp(),并在失败时调用 _exit(),并从C#调用它,那么你将得到一个在子进程路径中永不返回的单一函数。