编译调试 .NET Core 5.0 Preview 并分析 Span 的实现原理(一)


风晓
风晓 2023-12-31 10:19:01 53314 赞同 0 反对 0
分类: 资源
这次的文章主要介绍如何在 Linux 上编译调试最新的 .NET Core 5.0 Preview 与简单分析 Span 的实现原理。微软从 .NET Core 5.0 开始把 GIT 仓库 coreclr 与 corefx 合并移动到了 runtime 仓库,原有仓库仅用于维护 .NET Core 3.x

编译 .NET Core 5.0 Preview

本文编译的版本是 0d607a757372e3ecc8e942141d7f586a98694e42

创建 docker 容器

执行以下命令即可创建一个 ubuntu 18.04 的 docker 容器,注意创建时需要使用 --privileged 参数,否则无法使用 lldb 或者 gdb 调试程序。

docker run -it --privileged ubuntu:18.04

安装 cmake

.NET Core 5.0 要求的 cmake 版本非常高,我们需要添加第三方源来安装新版本的 cmake:

apt-get update
apt-get install apt-transport-https ca-certificates gnupg software-properties-common
wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc 2>/dev/null | apt-key add -
apt-add-repository 'deb https://apt.kitware.com/ubuntu/ bionic main'
apt-get update

安装依赖的类库与工具

这个步骤与之前版本的 .NET Core 相同:

apt-get install git wget locales locales-all vim
apt-get install cmake llvm-3.9 clang-9 libunwind8 libunwind8-dev gettext libicu-dev liblttng-ust-dev libcurl4-openssl-dev libssl-dev libnuma-dev libkrb5-dev

下载 .NET Core 源代码并编译

这个步骤也与之前的 .NET Core 相同,但因为 corefx 合并到了同一个仓库中,执行以下步骤以后会同时编译 corefx 的 dll 文件。注意这个步骤编译的是 Debug 版本的运行时,方便后面的调试。

git clone https://github.com/dotnet/runtime
cd runtime
./build.sh

编译完成后你可以在 artifacts 文件夹下找到编译结果。

使用 .NET Core 5.0 Preview 执行 Hello World 程序

接下来我们会看如何使用自己编译的 .NET Core 执行一个 Hello World 程序,.NET Core 5.0 会同时编译出 dotnet 程序,我们可以使用它代替 corerun 来简化运行步骤(不需要像以前的版本一样手动复制 corefx 的 dll或者设置 CORE_ROOT 环境变量)。但因为 runtime 仓库中不包括 sdk(sdk 在 sdk 仓库中,这次懒得编译),我们仍然需要另外安装一个官方的 .NET Core 用于创建与编译 Hello World 程序。

安装官方的 .NET Core 3.1 SDK

wget -q https://packages.microsoft.com/config/ubuntu/19.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
dpkg -i packages-microsoft-prod.deb
apt-get update
apt-get install dotnet-sdk-3.1

创建与编译 Hello World 程序

mkdir /console
cd /console
dotnet new console
dotnet build

执行 Hello World 程序

因为使用了 .NET Core 3.1 的 SDK 编译,我们还需要修改 程序名.runtimeconfig.json 中的运行时版本号,否则会出现版本号不一致而执行失败的问题。

cd /console/bin/Debug/netcoreapp3.1
vi console.runtimeconfig.json

需要修改两处:

  • runtimeOptions.tfm 修改到 netcoreapp5.0
  • runtimeOptions.framework.version 修改到 5.0.0

修改完以后使用以下命令即可执行:

/runtime/artifacts/bin/testhost/netcoreapp5.0-Linux-Debug-x64/dotnet console.dll

如果看到 Hello World 输出就代表执行成功了。

调试 .NET Core 5.0 Preview

在 linux 上调试 .NET Core 一般使用 lldb (gdb 也可以但是没有 SOS 插件支持),SOS 插件的源代码被搬到了 diagnostics 仓库,所以我们还需要下载编译这个仓库的源代码。

下载编译 diagnostics 仓库 (LLDB SOS 插件)

安装 LLDB 与 LLDB 的开发文件:

apt-get install clang llvm lldb liblldb-3.9-dev

下载编译 diagnostics 仓库:

git clone https://github.com/dotnet/diagnostics
cd diagnostics
./build.sh

编译成功后你可以在 /diagnostics/artifacts/bin/Linux.x64.Debug/libsosplugin.so 找到 SOS 插件的 dll 文件。

使用 LLDB 调试 .NET Core

SOS 插件需要在执行到达 LoadLibraryExW 后才可以正常使用,使用 LLDB 的 -o 参数可以省略每次调试的时候都要做的准备工作:

cd /console/bin/Debug/netcoreapp3.1
lldb \
  -o "plugin load /diagnostics/artifacts/bin/Linux.x64.Debug/libsosplugin.so" \
  -o "process launch -s" \
  -o "process handle -s false SIGUSR1 SIGUSR2" \
  -o "b LoadLibraryExW" \
  -o "c" \
  -o "br del 1" \
  -o "sos Help" \
   /runtime/artifacts/bin/testhost/netcoreapp5.0-Linux-Debug-x64/dotnet console.dll

执行以后会停在 LoadLibraryExW 并打印出 SOS 插件的帮助,接下来我们可以使用 SOS 插件给托管函数下断点:

sos bpmd console.dll console.Program.Main

然后使用 c 命令继续执行程序,直到触发断点:

c

到达断点(JIT 编译后的托管函数 Main)以后我们可以使用 SOS 插件打印这个托管函数编译出来的汇编内容:

sos u $rip

如果到此都没有问题,那么接下来我们可以开始分析 Span 的实现原理了。

Span 与 Memory 简介

Span 与 Memory 是微软推出的,用于表示某段子内容的数据类型,它们的主要目的是为了减少内存分配与复制,例如取 "abcdefg" 的子字符串 "def",传统的方法 (Substring) 会分配一个长度为 3 的新字符串然后复制 "def" 过去,但 Span 与 Memory 可以直接使用原有的对象、子内容的开始位置与子内容的长度来表示一段子内容。在其他语言中也有类似 Span 与 Memory 的概念,例如 go 中的 slice,c 中指针与长度的结合 (例如 struct char_view { char* ptr, size_t size; }),与 c++ 中的 string_view 和 span 类型。

Span 与 Memory 的区别在于,Memory 是一个普通的类型,只保存 原有的对象子内容的开始地址 与 子内容的长度,在内存中的表现可以参考下图:

Memory 与很早就存在的 ArraySegment 实质上是一样的,只是支持更多的类型,它们都不需要运行时或者编译器的额外支持。

Span 则特殊很多,它保存了子内容的开始地址与长度(不保存原始对象的地址),使得它不需要计算开始地址并且允许指向托管对象以外的内容 (例如从 stackalloc 分配)。Span 在内存中的表现可以参考下图:

Span 是一个 ref struct 类型 (这个类型可以说是专门为 Span 发明的),ref struct 只能保存在于栈上或者作为其他 ref struct 的成员 (最终来说只能保存在于栈上),Span 只能存在于栈上主要有以下原因:

  • GC 处理 Span 对象的成本很高,所以不应该大范围使用
  • Span 的读写是非原子的(两个指针大小),如果允许在堆上就有可能被多个线程同时访问
  • Span 可以由 stackalloc 生成,而 Span 自身并不会标记来源是托管对象还是栈空间

因为 Span 需要运行时的额外支持,在 .NET Framework 与 Mono 上使用的 Span (从 Nuget 包安装的) 实际上与 Memory 一样,只有在 .Net Core 上才有以上的特性。

此外,因为部分对象的内容不可修改 (例如 string),所以还有配套的 ReadOnlySpan 与 ReadOnlyMemory,它们除了在编译器层面上限制修改以外,与原类型没有什么区别。

调试分析 Span 的实现原理

接下来我们可以调试一个示例程序,简单分析 Span 在运行时中的实现原理 (这次分析不涉及到 JIT 部分,虽然 JIT 部分很少)。

以下是示例程序的代码:

using System;

namespace console
{
    class Program
    {
        static void Main(string[] args)
        {
            Span<byte> span = new byte[10] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
            span = span.Slice(5, 2);
            GC.Collect();
            Console.WriteLine(span.Length);
        }
    }
}

使用 LLDB 查看生成的汇编代码

编译示例程序与执行 LLDB 的命令请参考前面的内容,执行后可以使用以下命令给托管函数 Main 下断点然后执行到断点,并查看汇编代码:

sos bpmd console.dll console.Program.Main
c
sos u $rip

输出如下:

(lldb) sos bpmd console.dll console.Program.Main
Adding pending breakpoints...
(lldb) c
Process 6460 resuming
JITTED console!console.Program.Main(System.String[])
Setting breakpoint: breakpoint set --address 0x00007FFF7BB352D0 [console.Program.Main(System.String[])]
Process 6460 stopped
* thread #1, name = 'dotnet', stop reason = breakpoint 3.1
    frame #0: 0x00007fff7bb352d0
->  0x7fff7bb352d0: pushq  %rbp
    0x7fff7bb352d1: pushq  %r13
    0x7fff7bb352d3: subq   $0x48, %rsp
    0x7fff7bb352d7: vzeroupper
(lldb) sos u $rip
Normal JIT generated code
console.Program.Main(System.String[])
ilAddr is 00007FFFF18BB250 pImport is 00005576894771F0
Begin 00007FFF7BB352D0, size bc

/console/Program.cs @ 9:
>>> 00007fff7bb352d0 55                   push    rbp
00007fff7bb352d1 4155                 push    r13
00007fff7bb352d3 4883ec48             sub     rsp, 0x48
00007fff7bb352d7 c5f877               vzeroupper
00007fff7bb352da 488d6c2450           lea     rbp, [rsp + 0x50]
00007fff7bb352df 4c8bef               mov     r13, rdi
00007fff7bb352e2 488d7db0             lea     rdi, [rbp - 0x50]
00007fff7bb352e6 b910000000           mov     ecx, 0x10
00007fff7bb352eb 33c0                 xor     eax, eax
00007fff7bb352ed f3ab                 rep     stosd	dword ptr es:[rdi], eax
00007fff7bb352ef 498bfd               mov     rdi, r13
00007fff7bb352f2 48897df0             mov     qword ptr [rbp - 0x10], rdi
00007fff7bb352f6 48bfe05fd87bff7f0000 movabs  rdi, 0x7fff7bd85fe0
00007fff7bb35300 be0a000000           mov     esi, 0xa
00007fff7bb35305 e8063fe079           call    0x7ffff5939210 (JitHelp: CORINFO_HELP_NEWARR_1_VC)
00007fff7bb3530a 488945d8             mov     qword ptr [rbp - 0x28], rax
00007fff7bb3530e 48bf2894e07bff7f0000 movabs  rdi, 0x7fff7be09428
00007fff7bb35318 e8b396e079           call    0x7ffff593e9d0 (JitHelp: CORINFO_HELP_FIELDDESC_TO_STUBRUNTIMEFIELD)
00007fff7bb3531d 488945d0             mov     qword ptr [rbp - 0x30], rax
00007fff7bb35321 488b7dd8             mov     rdi, qword ptr [rbp - 0x28]
00007fff7bb35325 488b75d0             mov     rsi, qword ptr [rbp - 0x30]
00007fff7bb35329 e8829f307a           call    0x7ffff5e3f2b0 (System.Runtime.CompilerServices.RuntimeHelpers.InitializeArray(System.Array, System.RuntimeFieldHandle), mdToken: 0000000006003730)
00007fff7bb3532e 488b7dd8             mov     rdi, qword ptr [rbp - 0x28]
00007fff7bb35332 e8f9ecffff           call    0x7fff7bb34030 (System.Span`1[[System.Byte, System.Private.CoreLib]].op_Implicit(Byte[]), mdToken: 00000000060012B1)
00007fff7bb35337 488945c0             mov     qword ptr [rbp - 0x40], rax
00007fff7bb3533b 488955c8             mov     qword ptr [rbp - 0x38], rdx
00007fff7bb3533f c5fa6f45c0           vmovdqu xmm0, xmmword ptr [rbp - 0x40]
00007fff7bb35344 c5fa7f45e0           vmovdqu xmmword ptr [rbp - 0x20], xmm0

/console/Program.cs @ 10:
00007fff7bb35349 488d7de0             lea     rdi, [rbp - 0x20]
00007fff7bb3534d be05000000           mov     esi, 0x5
00007fff7bb35352 ba02000000           mov     edx, 0x2
00007fff7bb35357 e844edffff           call    0x7fff7bb340a0 (System.Span`1[[System.Byte, System.Private.CoreLib]].Slice(Int32, Int32), mdToken: 00000000060012BE)
00007fff7bb3535c 488945b0             mov     qword ptr [rbp - 0x50], rax
00007fff7bb35360 488955b8             mov     qword ptr [rbp - 0x48], rdx
00007fff7bb35364 c5fa6f45b0           vmovdqu xmm0, xmmword ptr [rbp - 0x50]
00007fff7bb35369 c5fa7f45e0           vmovdqu xmmword ptr [rbp - 0x20], xmm0

/console/Program.cs @ 11:
00007fff7bb3536e e845b3ffff           call    0x7fff7bb306b8 (System.GC.Collect(), mdToken: 0000000006000361)

/console/Program.cs @ 12:
00007fff7bb35373 488d7de0             lea     rdi, [rbp - 0x20]
00007fff7bb35377 e87cecffff           call    0x7fff7bb33ff8 (System.Span`1[[System.Byte, System.Private.CoreLib]].get_Length(), mdToken: 00000000060012AC)
00007fff7bb3537c 8bf8                 mov     edi, eax
00007fff7bb3537e e8a5fcffff           call    0x7fff7bb35028 (System.Console.WriteLine(Int32), mdToken: 0000000006000089)

/console/Program.cs @ 13:
00007fff7bb35383 90                   nop
00007fff7bb35384 488d65f8             lea     rsp, [rbp - 0x8]
00007fff7bb35388 415d                 pop     r13
00007fff7bb3538a 5d                   pop     rbp
00007fff7bb3538b c3                   ret

我们可以看到 00007fff7bb35305 处的指令从托管堆分配了数组,00007fff7bb35329 处的指令初始化了数组内容,00007fff7bb35332 处的指令生成了第一个 span 对象,00007fff7bb35357 处的指令生成了第二个 span 对象。你可以从每一段汇编代码上标记的文件名与行数找到对应的 C# 代码。

分析栈上的内容

接下来我们会分析栈上的内容,包括数组的地址与 span 的内容等。

注意栈上会保存临时变量和不使用的参数,这是因为之前的编译没有使用 Release 配置,你可以使用 Release 配置编译再按这里的步骤试试有什么不同 (可能会更难理解一些),使用 Release 配置时请关闭分层编译,使用 export COMPlus_TieredCompilation=0 即可关闭。

首先我们来看看分配数组之前栈上 (当前帧) 有什么内容:

(lldb) b 0x00007fff7bb35305
Breakpoint 4: address = 0x00007fff7bb35305 # 分配数组的指令
(lldb) c
Process 6460 resuming
Process 6460 stopped
* thread #1, name = 'dotnet', stop reason = breakpoint 4.1
    frame #0: 0x00007fff7bb35305
->  0x7fff7bb35305: callq  0x7ffff5939210            ; JIT_NewArr1VC_MP_FastPortable at jithelpers.cpp:2560
    0x7fff7bb3530a: movq   %rax, -0x28(%rbp)
    0x7fff7bb3530e: movabsq $0x7fff7be09428, %rdi     ; imm = 0x7FFF7BE09428
    0x7fff7bb35318: callq  0x7ffff593e9d0            ; JIT_GetRuntimeFieldStub at jithelpers.cpp:3635
(lldb) p/x $rsp
(unsigned long) $2 = 0x00007fffffffd220 # 栈顶
(lldb) p/x $rbp
(unsigned long) $3 = 0x00007fffffffd270 # 帧底
(lldb) p $rbp - $rsp
(unsigned long) $4 = 80 # 当前帧大小
(lldb) memory read -s 1 -c 80 0x00007fffffffd220
0x7fffffffd220: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................ # 本地变量使用的空间
0x7fffffffd230: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................ # 本地变量使用的空间
0x7fffffffd240: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................ # 本地变量使用的空间
0x7fffffffd250: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................ # 本地变量使用的空间
0x7fffffffd260: b0 d5 00 54 ff 7f 00 00 00 00 00 00 00 00 00 00  ...T............ # rbp-0x10 是 args 参数,rbp-0x8 是上一帧 r13 的值

接下来我们看看原始数组的地址与数组的内容,数组的本地变量 (临时变量) 会保存到 $rbp-0x28,我们可以直接看这个地址中的内容。

(lldb) b 0x00007fff7bb3532e
Breakpoint 5: address = 0x00007fff7bb3532e # 初始化数组后的指令
(lldb) c
Process 6460 resuming
Process 6460 stopped
* thread #1, name = 'dotnet', stop reason = breakpoint 5.1
    frame #0: 0x00007fff7bb3532e
->  0x7fff7bb3532e: movq   -0x28(%rbp), %rdi
    0x7fff7bb35332: callq  0x7fff7bb34030
    0x7fff7bb35337: movq   %rax, -0x40(%rbp)
    0x7fff7bb3533b: movq   %rdx, -0x38(%rbp)
(lldb) p/x $rbp-0x28
(unsigned long) $6 = 0x00007fffffffd248
(lldb) memory read -s 1 -c 8 0x00007fffffffd248
0x7fffffffd248: 70 ed 00 54 ff 7f 00 00                          p..T....
(lldb) dumpobj 7fff5400ed70 # SOS 插件提供的命令,用于输出托管对象信息
Name:        System.Byte[]
MethodTable: 00007fff7bd85fe0
EEClass:     00007fff7bd85f30
Size:        34(0x22) bytes
Array:       Rank 1, Number of elements 10, Type Byte
Content:     ..........
Fields:
None
(lldb) memory read -s 1 -c 26 0x7fff5400ed70 # 显示数组对象的内容
0x7fff5400ed70: e0 5f d8 7b ff 7f 00 00 0a 00 00 00 00 00 00 00  ._.{............ # 0~8 是类型信息,8~16 是长度
0x7fff5400ed80: 01 02 03 04 05 06 07 08 09 0a                    .......... # 16~26 是数组内容

接下来我们可以继续执行,然后看看各个 Span 的内容:

(lldb) b 0x00007fff7bb3536e
Breakpoint 6: address = 0x00007fff7bb3536e
(lldb) c
Process 6460 resuming
Process 6460 stopped
* thread #1, name = 'dotnet', stop reason = breakpoint 6.1
    frame #0: 0x00007fff7bb3536e
->  0x7fff7bb3536e: callq  0x7fff7bb306b8
    0x7fff7bb35373: leaq   -0x20(%rbp), %rdi
    0x7fff7bb35377: callq  0x7fff7bb33ff8
    0x7fff7bb3537c: movl   %eax, %edi
(lldb) memory read -s 1 -c 16 $rbp-0x40
0x7fffffffd230: 80 ed 00 54 ff 7f 00 00 0a 00 00 00 00 00 00 00  ...T............ # 第一个 span (临时变量) 的开始地址与长度
(lldb) memory read -s 1 -c 16 $rbp-0x50
0x7fffffffd220: 85 ed 00 54 ff 7f 00 00 02 00 00 00 00 00 00 00  ...T............ # 第二个 span (临时变量) 的开始地址与长度
(lldb) memory read -s 1 -c 16 $rbp-0x20
0x7fffffffd250: 85 ed 00 54 ff 7f 00 00 02 00 00 00 00 00 00 00  ...T............ # 本地变量 span 中的开始地址与长度

从输出中我们可以看到,第一个 span 的地址是 0x7fff5400ed80,这刚好是数组地址 0x7fff5400ed70 加上类型信息 (8) 与长度 (8) 以后的值,
也就是数组的内容,使用以下命令可以查看这个 span 指向的内容:

(lldb) memory read -s 1 -c 10 0x7fff5400ed80
0x7fff5400ed80: 01 02 03 04 05 06 07 08 09 0a                    ..........

而第二个 span 的地址 0x7fff5400ed85 则是第一个 span 的地址加 5,并且长度为 2,使用以下命令可以查看这个 span 指向的内容:

(lldb) memory read -s 1 -c 2 0x7fff5400ed85
0x7fff5400ed85: 06 07                                            ..

最后再看看栈上 (当前帧) 的内容:

(lldb) memory read -s 1 -c 80 0x00007fffffffd220
0x7fffffffd220: 85 ed 00 54 ff 7f 00 00 02 00 00 00 00 00 00 00  ...T............ # 本地变量 span 中的开始地址与长度
0x7fffffffd230: 80 ed 00 54 ff 7f 00 00 0a 00 00 00 00 00 00 00  ...T............ # 第一个 span (临时变量) 的开始地址与长度
0x7fffffffd240: 98 ed 00 54 ff 7f 00 00 70 ed 00 54 ff 7f 00 00  ...T....p..T.... # 用于初始化数组的句柄,原始数组对象 (临时变量)
0x7fffffffd250: 85 ed 00 54 ff 7f 00 00 02 00 00 00 00 00 00 00  ...T............ # 第二个 span (临时变量) 的开始地址与长度
0x7fffffffd260: b0 d5 00 54 ff 7f 00 00 00 00 00 00 00 00 00 00  ...T............ # args 参数与上一帧 r13 的值

如果您发现该资源为电子书等存在侵权的资源或对该资源描述不正确等,可点击“私信”按钮向作者进行反馈;如作者无回复可进行平台仲裁,我们会在第一时间进行处理!

评价 0 条
风晓L1
粉丝 1 资源 2038 + 关注 私信
最近热门资源
桌面通用(全架构)【在双系统环境下隐藏Windows启动菜单】操作指南  2121
银河麒麟桌面操作系统V10(SP1)2203-如何进行远程桌面互访?  2026
银河麒麟桌面操作系统【保留数据盘重装系统】  1837
麒麟系统各种原因开不了机解决(合集)  1654
桌面通用(全架构)【rpm包转成deb包】操作方法  934
银河麒麟桌面操作系统 V10-SP1 双系统安装 efi 分区问题  920
统信系统安装(合集)  870
统信桌面专业版【手动分区安装UOS系统】介绍  852
统启动异常几种类型(initramfs 模式)  692
Linux系统软件包的导出  26
最近下载排行榜
桌面通用(全架构)【在双系统环境下隐藏Windows启动菜单】操作指南 0
银河麒麟桌面操作系统V10(SP1)2203-如何进行远程桌面互访? 0
银河麒麟桌面操作系统【保留数据盘重装系统】 0
麒麟系统各种原因开不了机解决(合集) 0
桌面通用(全架构)【rpm包转成deb包】操作方法 0
银河麒麟桌面操作系统 V10-SP1 双系统安装 efi 分区问题 0
统信系统安装(合集) 0
统信桌面专业版【手动分区安装UOS系统】介绍 0
统启动异常几种类型(initramfs 模式) 0
Linux系统软件包的导出 0
作者收入月榜
1

prtyaa 收益393.72元

2

zlj141319 收益221.42元

3

1843880570 收益214.2元

4

IT-feng 收益213.03元

5

风晓 收益208.24元

6

777 收益172.82元

7

Fhawking 收益106.6元

8

信创来了 收益105.89元

9

克里斯蒂亚诺诺 收益91.08元

10

技术-小陈 收益79.65元

请使用微信扫码

加入交流群

请使用微信扫一扫!