点击上方蓝字 江湖评谈设为关注/星标
前言
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.go
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
生成一个名为hello的二进制文件,它的EA如下:
#go build hello.go
#root@Tyz:~/linuxkernel/linux-6.11.7# readelf -h hello
ELF 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) c
Process 1 resuming
Process 1 stopped
* thread #1, stop reason = breakpoint 1.1
frame #0: 0x0000000000469600
error: memory read failed for 0x469600
(lldb) di -s 0xffffffff820016c3 -c5
vmlinux`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) si
This version of LLDB has no plugin for the language "assembler". Inspection of frame variables will be limited.
Process 1 stopped
* thread
frame
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
626 DECLARE_IDTENTRY_RAW(IA32_SYSCALL_VECTOR, int80_emulation);
(lldb) bt
* thread
* frame
frame
上面显示我们获取到了一个调用堆栈:linux内核调用fixed_percpu_data加载用户态数据(比如EA),因为EA是用户态,所以它无法加载,报了页异常。页异常接管处理EA这个异常,进入了中断例程common_interrupt_return,处理完了之后就返回到页异常函数。
fixed_percpu_data-》exc_page_fault-》common_interrupt_return
以上是内核态的初步过程,当然到这个地方还没完。因为我们还没看到实际调用EA地方。
页异常之后,它需要在错误代码的返回(error_return)里面进行了内核态到用户态切换
n
n
Process 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
ALTERNATIVE "", "jmp xenpv_restore_regs_and_return_to_usermode", X86_FEATURE_XENPV
ALTERNATIVE "", "jmp .Lpti_restore_regs_and_return_to_usermode", X86_FEATURE_PTI
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.
*/
testb $4, (SS-RIP)(%rsp)
jnz native_irq_return_ldt
jnz native_irq_return_ldt对应的ASM:
0xffffffff820016c8 <+248>: jne 0xffffffff820016cc ; <+252>
0xffffffff820016ca <+250>: iretq
iretq接收到的rip正是当fixed_percpu_data加载用户态EA的时候,如下:
s
Process 1 stopped
thread #1, stop reason = breakpoint 1.1
frame #0: 0x0000000000469600
0x469600: jmp 0x465ec0
0x469605: int3
0x469606: int3
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
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)
410 :
TEXT 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) di
hello`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 0x000000c0000487d8
0xc0000487d8: 0x0000000000467fc1 0x0000000000000000
0xc0000487e8: 0x0000000000000000 0x0000000000000000
还记得上面main的堆栈地址:0x0000000000467fc1 ,跟rsp所指向的值完美的契合
main堆栈:frame #2: 0x0000000000467fc1 hello`runtime.goexit.abi0 at asm_amd64.s:1700
rsp指值: 0xc0000487d8: 0x0000000000467fc1
堆栈runtime.main at proc.go:283代码如下:
//file:go/src/runtime/proc.go:147
func 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是不太一样的,这种搞法,也算是一种真正的静态运行了。
以上记录,分享,以及避免忘却。
往期精彩回顾