Go-main如何被Linux内核加载执行的

文摘   2024-11-20 16:25   美国  

点击上方蓝字 江湖评谈设为关注/星标




前言

Go语言的go1.24-3ca78afb3b版本,默认的是静态链接。二进制可执行文件的入口点嵌入到内核态,是如何被LinuxKernel识别并加载运行的呢?此问题,本篇基于代码验证结果详细分析下。先看下内核态,再看下用户态。

内核态

一:概念

内核态,需要先了解几个linuxkernel函数

1.common_interrupt_return: 一个与中断处理有关的函数,通常用于中断服务例程中的返回操作。在嵌入式系统或操作系统内核的实现中,处理完中断后,需要返回到中断前的执行状态。

2.fixed_percpu_data:通常出现在操作系统内核中,特别是在多核处理器上。它表示每个 CPU 都有一些本地的、独立的数据,这些数据在每个处理器上都被维护和存储。

3.asm_exc_page_fault:它是与页错误(Page Fault)处理相关的汇编函数。在操作系统的内核中,页错误是非常常见的一种中断,通常发生在程序访问非法或不存在的内存时。

二:原理

当我们在Linux上运行elf可执行二进制的时候,execve函数(相当于windows的shell)会加载二进制,调用linuxkernel的函数fixed_percpu_data进行数据的初始化,比如分配子字段,寄存器,栈段等等。fixed_percpu_data首先会加载elf二进制的Entrypoint Address(可执行二进制文件用户态入口,后面简称:EA),因为EA是用户态,在内核态无法被识别。所以EA地址出现异常,被系统捕获调用了页异常,页异常则调用了common_interrupt_return中断例程进行处理,处理完成之后会切换寄存器,栈等数据到用户态,同时设置rip为用户态的入口EA,此时就会完整的执行用户态代码。

二:验证例子

通过一个小例子来确定上面的说法

#filename: hello.gopackage mainimport "fmt"func main() {    fmt.Println("Hello, World!")}

生成一个名为hello的二进制文件,它的EA如下:

#go build hello.go#root@Tyz:~/linuxkernel/linux-6.11.7# readelf -h helloELF Header:  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00   Class:                             ELF64  Data:                              2's complement, little endian  Version:                           1 (current)  OS/ABI:                            UNIX - System V  ABI Version:                       0  Type:                              EXEC (Executable file)  Machine:                           Advanced Micro Devices X86-64  Version:                           0x1  Entry point address:               0x469600

Qemu启动下linux内核引导bzImage,参数-d in_asm表示显示每条指令的地址以及其机器码,hellofs是echo的归档文件

qemu-system-x86_64 -d in_asm -kernel arch/x86_64/boot/bzImage -initrd hellofs  -append "nokaslr root=/dev/sda rdinit=/hello console=ttyS0" -s -S  -smp 1 -nographic

lldb:

#lldb vmlinux(lldb) gdb-remote 1234(lldb) b 0x469600(lldb) cProcess 1 resumingProcess 1 stopped* thread #1, stop reason = breakpoint 1.1    frame #0: 0x0000000000469600error: memory read failed for 0x469600(lldb) di -s 0xffffffff820016c3 -c5vmlinux`common_interrupt_return:    0xffffffff820016c3 <+243>: test   byte ptr [rsp + 0x20], 0x4    0xffffffff820016c8 <+248>: jne    0xffffffff820016cc        ; <+252>    0xffffffff820016ca <+250>: iretq      0xffffffff820016cc <+252>: push   rdi    0xffffffff820016cd <+253>: swapgs 

看到EA是被内核的common_interrupt_return函数所调用,如何进入到这个中断处理函数的呢?是页异常(asm_exc_page_fault),如何确定呢,单步运行下即可看到,如下:

(lldb) siThis version of LLDB has no plugin for the language "assembler". Inspection of frame variables will be limited.Process 1 stopped* thread #1, stop reason = instruction step into    frame #0: 0xffffffff82001280 vmlinux`asm_exc_page_fault at idtentry.h:623   620   /* Raw exception entries which need extra work */   621   DECLARE_IDTENTRY_RAW(X86_TRAP_UD,    exc_invalid_op);   622   DECLARE_IDTENTRY_RAW(X86_TRAP_BP,    exc_int3);-> 623   DECLARE_IDTENTRY_RAW_ERRORCODE(X86_TRAP_PF,  exc_page_fault);   624      625   #if defined(CONFIG_IA32_EMULATION)   626   DECLARE_IDTENTRY_RAW(IA32_SYSCALL_VECTOR,  int80_emulation);(lldb) bt* thread #1, stop reason = instruction step into  * frame #0: 0xffffffff82001280 vmlinux`asm_exc_page_fault at idtentry.h:623    frame #1: 0x0000000000000014 vmlinux`fixed_percpu_data + 20

上面显示我们获取到了一个调用堆栈:linux内核调用fixed_percpu_data加载用户态数据(比如EA),因为EA是用户态,所以它无法加载,报了页异常。页异常接管处理EA这个异常,进入了中断例程common_interrupt_return,处理完了之后就返回到页异常函数。

fixed_percpu_data-》exc_page_fault-》common_interrupt_return

以上是内核态的初步过程,当然到这个地方还没完。因为我们还没看到实际调用EA地方。

页异常之后,它需要在错误代码的返回(error_return)里面进行了内核态到用户态切换

(lldb) n(lldb) nProcess 1 stopped* thread #1, stop reason = step over    frame #0: 0xffffffff82001a60 vmlinux`error_return at entry_64.S:1090   1087     1088  SYM_CODE_START_LOCAL(error_return)   1089    UNWIND_HINT_REGS-> 1090    DEBUG_ENTRY_ASSERT_IRQS_OFF   1091    testb  $3, CS(%rsp)   1092    jz  restore_regs_and_return_to_kernel   1093    jmp  swapgs_restore_regs_and_return_to_usermode

swapgs_restore_regs_and_return_to_usermode这个函数还原用户态寄存器和切换到用户态模式,为调用EA做准备。

SYM_INNER_LABEL(swapgs_restore_regs_and_return_to_usermode, SYM_L_GLOBAL)        IBRS_EXIT#ifdef CONFIG_XEN_PV        ALTERNATIVE "", "jmp xenpv_restore_regs_and_return_to_usermode", X86_FEATURE_XENPV#endif#ifdef CONFIG_MITIGATION_PAGE_TABLE_ISOLATION        ALTERNATIVE "", "jmp .Lpti_restore_regs_and_return_to_usermode", X86_FEATURE_PTI#endif
STACKLEAK_ERASE POP_REGS add $8, %rsp /* orig_ax */ UNWIND_HINT_IRET_REGS
.Lswapgs_and_iret: swapgs CLEAR_CPU_BUFFERS /* Assert that the IRET frame indicates user mode. */ testb $3, 8(%rsp) jnz .Lnative_iret        ud2

指令jnz .Lnative_iret函数会把内核态调到用户态

.Lnative_iret:          UNWIND_HINT_IRET_REGS        /*         * Are we returning to a stack segment from the LDT?  Note: in         * 64-bit mode SS:RSP on the exception stack is always valid.         */#ifdef CONFIG_X86_ESPFIX64        testb   $4, (SS-RIP)(%rsp)        jnz     native_irq_return_ldt#endif

jnz native_irq_return_ldt对应的ASM:

0xffffffff820016c8 <+248>: jne    0xffffffff820016cc        ; <+252>0xffffffff820016ca <+250>: iretq  

iretq接收到的rip正是当fixed_percpu_data加载用户态EA的时候,如下:

(lldb) sProcess 1 stopped* thread #1, stop reason = breakpoint 1.1    frame #0: 0x0000000000469600->  0x469600: jmp    0x465ec0    0x469605: int3       0x469606: int3       0x469607: int3   

这个时候,才是真正的从内核态切换到了用户态,但是用户态是如何运行的呢?下面继续看用户态。

用户态

用上面的例子go代码,我们看下Go-main入口用户态的堆栈:

#lldb hello(lldb) b main.main(lldb) r(lldb) bt* thread #1, name = 'hello', stop reason = breakpoint 1.1  * frame #0: 0x0000000000490fa0 hello`main.main at hello.go:5    frame #1: 0x000000000043400b hello`runtime.main at proc.go:283    frame #2: 0x0000000000467fc1 hello`runtime.goexit.abi0 at asm_amd64.s:1700

它的起始堆栈是一个名为:runtime.goexit.abi0的函数。这个函数作为替代Glibc的入口点,似乎有点别扭,goexit不是退出函数吗?继续看。

我们依旧从EA入手,它是连接内核态和用户态的关键

# objdump -d ./hello | grep "469600" -C 5  4695fc:  cc                     int3  4695fd:  cc                     int3  4695fe:  cc                     int3  4695ff:  cc                     int3
0000000000469600 <_rt0_amd64_linux>: 469600: e9 bb c8 ff ff jmp 465ec0 <_rt0_amd64> 469605: cc int3 469606: cc int3 469607: cc int3 469608: cc int3  469609:  cc                     int3

它的调用:_rt0_amd64_linux-》_rt0_amd64。后者原型如下:

TEXT _rt0_amd64(SB),NOSPLIT,$-8  MOVQ  0(SP), DI  // argc  LEAQ  8(SP), SI  // argv  JMP  runtime·rt0_go(SB)

看到了rt0_go函数(记住这个函数,后面会提到),这个函数继续运行直到go用户态main入口运行为止。如果继续跟踪的话,会很多。这里不妨逆向思维,从runtime.goexit.abi0入手。当我们通过lldb debug hello的时候,测试其地址:0x0000000000467fc1并不会运行,这说明它确实不是用户态堆栈的第一个函数。一般的堆栈显示函数,都是通过寄存器rsp来确定,那么这里说明在某个地方修改了rsp导致的。而这个修改的函数即是:gogo<>(SB)

//go/src/runtime/asm_amd64.s:410TEXT gogo<>(SB), NOSPLIT, $0        get_tls(CX)        MOVQ    DX, g(CX)        MOVQ    DX, R14         // set the g register        MOVQ    gobuf_sp(BX), SP        // restore SP        MOVQ    gobuf_ret(BX), AX        MOVQ    gobuf_ctxt(BX), DX        MOVQ    gobuf_bp(BX), BP        MOVQ    $0, gobuf_sp(BX)        // clear to help garbage collector        MOVQ    $0, gobuf_ret(BX)        MOVQ    $0, gobuf_ctxt(BX)        MOVQ    $0, gobuf_bp(BX)        MOVQ    gobuf_pc(BX), BX        JMP     BX

看下它的ASM,果然在地址0x46528c出修改了rsp

(lldb) dihello`gogo:->  0x465280 <+0>:  mov    qword ptr fs:[-0x8], rdx    0x465289 <+9>:  mov    r14, rdx    0x46528c <+12>: mov    rsp, qword ptr [rbx]

那么这个rsp是否是runtime.goexit.abi0的堆栈地址呢?

(lldb) register read rsp     rsp = 0x000000c0000487d8(lldb) x/8gx 0x000000c0000487d80xc0000487d8: 0x0000000000467fc1 0x00000000000000000xc0000487e80x0000000000000000 0x0000000000000000

还记得上面main的堆栈地址:0x0000000000467fc1 ,跟rsp所指向的值完美的契合

main堆栈:frame #2:     0x0000000000467fc1 hello`runtime.goexit.abi0 at asm_amd64.s:1700rsp指值: 0xc0000487d8: 0x0000000000467fc1

堆栈runtime.main at proc.go:283代码如下:

//file:go/src/runtime/proc.go:147func main() {        mp := getg().m        //中间省略,便于观看        fn()    //这里调用了go main入口函数,所以断点需要两个main.main        exit(0) //可以看到这个函数不会返回         //后面省略}

gogo<>(SB)函数的 JMP  BX正是调用了runtime.main,后者则调用了用户态main进行用户态程序运行。

而go-main用户态真正的第一个堆栈函数,也即是gogo<>(SB),看下其堆栈:

(lldb)b asm_amd64.s:410(lldb)r(lldb) bt* thread #1, name = 'hello', stop reason = breakpoint 9.1  * frame #0: 0x0000000000465280 hello`gogo at asm_amd64.s:412    frame #1: 0x0000000000439a57 hello`runtime.execute at proc.go:3260    frame #2: 0x000000000043badc hello`runtime.schedule at proc.go:4063    frame #3: 0x000000000043752d hello`runtime.mstart1 at proc.go:1858    frame #4: 0x0000000000437435 hello`runtime.mstart0 at proc.go:1808    frame #5: 0x000000000043badc hello`runtime.schedule at proc.go:4063    frame #6: 0x000000000043752d hello`runtime.mstart1 at proc.go:1858

重启lldb,继续向前跟踪:

(lldb) b proc.go:1858(lldb)r(lldb) bt thread #1, name = 'hello', stop reason = breakpoint 10.1  * frame #0: 0x0000000000437528 hello`runtime.mstart1 at proc.go:1858    frame #1: 0x0000000000437435 hello`runtime.mstart0 at proc.go:1808    frame #2: 0x0000000000437435 hello`runtime.mstart0 at proc.go:1808    frame #3: 0x0000000000466085 hello`runtime.mstart.abi0 at asm_amd64.s:395    frame #4: 0x000000000046600f hello`runtime.rt0_go.abi0 at asm_amd64.s:358

终于看到了久违的rt0-go(前面说记住这个函数会提到)了,这个函数跟上面用户态跟踪完美的闭环契合了。如此就是用户态运行的过程了。

结尾

以上分析了go二进制可执行文件在linux系统下内核态和用户态运行的过程,总结下内核态:

fixed_percpu_data-》exc_page_fault-》common_interrupt_return-》swapgs_restore_regs_and_return_to_usermode-》Lnative_iret-》EA

用户态:

EA-》_rt0_amd64_linux-》_rt0_amd64-》rt0_go-》........-》 gogo<>(SB)-》runtime.main->main.main

这里面有几个难点,其一堆栈的缺失,比如jmp指令跳转它就不会显示在堆栈中,此时我们就需要所有指令集进行分析,可以通过Qemu的参数-d。另外一种,比如本篇的gogo<>(SB)函数更改了rsp栈,可以通过rsp等于某个特定值或者lldb脚本自动运行来确认。最后一个就是Qemu的参数-initrd归档文件。

跟踪这些也算比较简单,虽然花费了点脑细胞。go主要是基于用户态EA不能在内核态加载的异常,通过内核态的机制,进行了用户态数据的加载。此后切换内核态到用户态,最后调用用户态代码的整个过程。整个过程不经过linux的任何非kernel库,只用kernel函数和go自己的汇编函数。

go的某些运行机制跟Rust/C++/.NET是不太一样的,这种搞法,也算是一种真正的静态运行了。

以上记录,分享,以及避免忘却。

往期精彩回顾

从.NET9看Golang

2024年5月Tiobe编程语言排行:Go会挤掉C#的排名吗


江湖评谈
记录,分享,自由。
 最新文章