【AICon】探索八个行业创新案例,教你在教育、金融、医疗、法律等领域实践大模型技术! >>> 了解详情
写点什么

理解 Golang 中函数调用的原理

  • 2019-12-03
  • 本文字数:13235 字

    阅读完需:约 43 分钟

理解 Golang 中函数调用的原理

2.1 函数调用

函数是 Go 语言中的一等公民,理解和掌握函数的调用过程是深入学习 Golang 时无法跳过的步骤,这里会介绍 Go 语言中函数调用的过程和实现原理并与 C 语言中函数执行的过程进行对比,同时对参数传递的原理进行剖析,让读者能够清楚地知道 Go 在函数的执行过程中究竟都做了哪些工作。


本文将从函数的调用惯例和参数的传递方法两个方面分别介绍函数的执行过程,同时在这里会默认阅读这篇文章的读者已经掌握了栈、寄存器 等概念,文章并不会就这两部分内容展开进行介绍。

__1. 调用惯例

在计算机科学中,调用惯例其实就是指在实现层面上,一个函数(子程序)如何接受主程序传递的参数并如何将返回值传递回主程序。不同语言对于传递参数和返回值的实现上会有一些差异,不过无论是在 C、Go 语言这种比较接近系统的编程语言,还是 Ruby、Python 这类语言,它们在_函数调用_上往往都具有相同的形式,也就是一般包含函数名、参数列表两个部分:


somefunction(arg0, arg1)
复制代码


虽然它们的调用形式看起来差不多,但是在这里我们需要考虑 C 和 Go 这两门语言究竟是如何实现调用惯例的,这对于我们理解的 Go 语言的函数调用原理会有非常大的帮助。

__1.1. C 语言

如果想要了解 C 语言中的函数调用的原理,我们可以通过 gcc 或者 clang 将 C 语言的代码编译成汇编语言,从汇编语言中可以一窥函数调用的具体过程,作者使用的是编译器和内核的版本如下:


$ gcc --versiongcc (Ubuntu 4.8.2-19ubuntu1) 4.8.2Copyright (C) 2013 Free Software Foundation, Inc.This is free software; see the source for copying conditions.  There is NOwarranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ uname -aLinux iZ255w13cy6Z 3.13.0-32-generic #57-Ubuntu SMP Tue Jul 15 03:51:08 UTC 2014 x86_64 x86_64 x86_64 GNU/Linux
复制代码


gcc 和 clang 虽然在编译 C 语言代码时生成的汇编语言可能有比较大的差别,但是生成代码的结构不会有太大的区别,需要注意的是不同内核版本的操作系统生成的汇编指令可能有比较大的不同,不过对于我们这些只是想要了解实现原理的开发者来说没有太多的影响。


假设我们有以下的 C 语言代码,代码中只包含两个函数,其中一个是主函数 main,另一个就是我们定义的函数 my_function,代码非常简单:


int my_function(int arg1, int arg2) {    return arg1 + arg2;}
int main() { int i = my_function(1, 2);}
复制代码


接下来我们可以使用 gcc -S main.c 将当前的文件编译成包含汇编语言的 main.s 文件,为了减少一些与函数调用无关的噪音,我们将生成的汇编代码经过一些简单地删减展示出来:


my_function:.LFB0:    .cfi_startproc    pushq    %rbp    .cfi_def_cfa_offset 16    .cfi_offset 6, -16    movq    %rsp, %rbp    .cfi_def_cfa_register 6    movl    %edi, -4(%rbp)    movl    %esi, -8(%rbp)    movl    -8(%rbp), %eax    movl    -4(%rbp), %edx    addl    %edx, %eax // arg1 + arg2    popq    %rbp    .cfi_def_cfa 7, 8    ret    .cfi_endprocmain:.LFB1:    .cfi_startproc    pushq    %rbp    .cfi_def_cfa_offset 16    .cfi_offset 6, -16    movq    %rsp, %rbp    .cfi_def_cfa_register 6    subq    $16, %rsp    movl    $2, %esi // 处理第二个参数    movl    $1, %edi // 处理第一个参数    call    my_function    movl    %eax, -4(%rbp)    leave    .cfi_def_cfa 7, 8    ret    .cfi_endproc
复制代码


我们可以看到在调用 my_function 函数之前,上述代码将该函数需要的两个参数分别存到了 edi 和 esi 寄存器中;在 my_function 执行时,它会先从寄存器中取出数据并放置到堆栈上,随后通过汇编指令在 eax 寄存器上进行计算,最后的结果其实是通过另一个寄存器 eax 返回的,main 函数在 my_function 返回之后将返回值存储到堆栈上的 i 变量中。


当我们将 my_function 函数的参数增加至 8 个之后,我们再重新编译当前的 C 语言代码,这时可以得到更长的汇编语言代码:


// main.cint my_function(int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7, int arg8) {    return arg1 + arg2 + arg3 + arg4 + arg5 + arg6 + arg7 + arg8;}
int main() { my_function(1, 2, 3, 4, 5, 6, 7, 8);}
// main.smain:.LFB1: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $16, %rsp movl $8, 8(%rsp) movl $7, (%rsp) movl $6, %r9d movl $5, %r8d movl $4, %ecx movl $3, %edx movl $2, %esi movl $1, %edi call my_function leave .cfi_def_cfa 7, 8 ret .cfi_endproc
复制代码


从这段代码我们可以看出如下的几个问题,首先,前六个参数的传递依然是通过寄存器进行的,分别存储在 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器中,这里的寄存器的使用顺序也是有讲究的,作为调用惯例,传递参数时第一个参数一定会放在 edi 寄存器中,第二个参数放在 esi 寄存器,以此类推;最后的第七和第八两个参数都通过栈进行传递,我们可以通过这张图片来简单理解 main 函数在调用 my_function 时的堆栈:


需要说的是,rbp 是一个存储函数调用堆栈基址指针的寄存器,也就是说当前函数栈是从哪里开始的;另一个寄存器 rsp 存储的就是当前函数调用栈栈顶的位置,也就是当前栈的内存分配到了哪里,这两个寄存器表示一个函数栈的开始和结束。



在调用函数之前通过 subq $16, %rsp 指令分配了 16 个字节的栈地址,随后按照从右向左的顺序对参数进行压栈,将第八和第七这两个参数放置到栈中,在这之后将余下的 6 个参数存储到寄存器后才运行 call my_function 指令调用 my_function 函数:


my_function:.LFB0:    .cfi_startproc    pushq    %rbp    .cfi_def_cfa_offset 16    .cfi_offset 6, -16    movq    %rsp, %rbp    .cfi_def_cfa_register 6    movl    %edi, -4(%rbp)    movl    %esi, -8(%rbp)    movl    %edx, -12(%rbp)    movl    %ecx, -16(%rbp)    movl    %r8d, -20(%rbp)    movl    %r9d, -24(%rbp)    movl    -8(%rbp), %eax // eax = 2    movl    -4(%rbp), %edx // edx = 1    addl    %eax, %edx     // edx = eax + edx = 3    movl    -12(%rbp), %eax    addl    %eax, %edx     movl    -16(%rbp), %eax    addl    %eax, %edx     movl    -20(%rbp), %eax     addl    %eax, %edx    movl    -24(%rbp), %eax    addl    %eax, %edx    movl    16(%rbp), %eax  // eax = 7    addl    %eax, %edx      // edx = eax + edx = 28    movl    24(%rbp), %eax  // eax = 8    addl    %edx, %eax      // edx = eax + edx = 36    popq    %rbp    .cfi_def_cfa 7, 8    ret    .cfi_endproc
复制代码


因为 my_function 函数没有调用其他的函数,所以并没有通过 subq 修改栈指针寄存器 rsp 中的内容,它的执行过程就是先将寄存器中的全部数据都转移到栈上,然后利用 eax 寄存器计算并返回结果。


简单总结一下,如果我们在 x86-64 的机器上使用 C 语言中调用一个函数,函数的参数是通过寄存器和栈传递的,在 x86_64 的机器上,6 个以下(含 6 个)的参数会按照顺序分别使用 edi、esi、edx、ecx、r8d 和 r9d 六个寄存器传递,超过 6 个的剩余参数会通过栈进行传递;函数的返回值是通过 eax 寄存器进行传递的,这也就是为什么 C 语言中不支持多个返回值。

__1.2. Golang

介绍了 C 语言中函数调用的流程之后,接下来我们再来剖析一下 Golang 中函数调用时参数和返回值如何传递的。在这里我们以下面这个非常简单的代码片段为例简单分析一下:


package main
func myFunction(a, b int) (int, int) { return a + b, a - b}
func main() { myFunction(66, 77)}
复制代码


上述代码片段中的 myFunction 函数接受两个类型为 int 的函数并返回两个类型为 int 的参数,main 函数在调用该函数时会将 66 和 77 两个参数传递到当前函数中,经过编译我们可以得到如下所示的伪汇编代码:


"".main STEXT size=68 args=0x0 locals=0x28    0x0000 00000 (main.go:7)    TEXT    "".main(SB), $40-0    0x0000 00000 (main.go:7)    MOVQ    (TLS), CX    0x0009 00009 (main.go:7)    CMPQ    SP, 16(CX)    0x000d 00013 (main.go:7)    JLS    61    0x000f 00015 (main.go:7)    SUBQ    $40, SP // 分配 40 字节栈空间    0x0013 00019 (main.go:7)    MOVQ    BP, 32(SP) // 将基址指针存储到栈上    0x0018 00024 (main.go:7)    LEAQ    32(SP), BP    0x001d 00029 (main.go:8)    MOVQ    $66, (SP)  // 第一个参数    0x0025 00037 (main.go:8)    MOVQ    $77, 8(SP) // 第二个参数    0x002e 00046 (main.go:8)    PCDATA    $0, $0    0x002e 00046 (main.go:8)    CALL    "".myFunction(SB)    0x0033 00051 (main.go:9)    MOVQ    32(SP), BP    0x0038 00056 (main.go:9)    ADDQ    $40, SP    0x003c 00060 (main.go:9)    RET
复制代码


需要注意的是编译这段 Golang 代码时,需要使用如下的命令进行编译 GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go,如果不加上 -N -l 的参数,编译器会对汇编代码进行优化,编译结果会跟这里的差别非常大。


根据 main 函数生成的汇编指令,我们可以分析出在 main 函数中调用 myFunction 之前的堆栈情况:



main 函数中,通过 SUBQ $40, SP 指令一共在栈上分配了 40 字节的内存空间,最开始的 8 个字节存储了 main 函数的栈基址指针,之后的 16 个字节是为函数 myFunction 的两个返回值预留的空间,最后的 16 字节存储了该函数调用时需要的两个参数,压栈的顺序和 C 语言中一样,也是从右到左。


接下来就调用了汇编指令 CALL "".myFunction(SB),这个指令首先会将当前函数 main 的返回值压栈,然后改变当前的栈指针 SP 并开始执行 myFunction 的汇编指令:


"".myFunction STEXT nosplit size=49 args=0x20 locals=0x0    0x0000 00000 (main.go:3)    TEXT    "".myFunction(SB), NOSPLIT, $0-32    0x0000 00000 (main.go:3)    MOVQ    $0, "".~r2+24(SP) // 初始化第一个返回值    0x0009 00009 (main.go:3)    MOVQ    $0, "".~r3+32(SP) // 初始化第二个返回值    0x0012 00018 (main.go:4)    MOVQ    "".a+8(SP), AX    // AX = 66    0x0017 00023 (main.go:4)    ADDQ    "".b+16(SP), AX   // AX = AX + 77 = 143    0x001c 00028 (main.go:4)    MOVQ    AX, "".~r2+24(SP) // (24)SP = AX = 143    0x0021 00033 (main.go:4)    MOVQ    "".a+8(SP), AX    // AX = 66    0x0026 00038 (main.go:4)    SUBQ    "".b+16(SP), AX   // AX = AX - 77 = -11    0x002b 00043 (main.go:4)    MOVQ    AX, "".~r3+32(SP) // (32)SP = AX = -11    0x0030 00048 (main.go:4)    RET
复制代码


从上述的汇编代码中我们可以看出,当前函数在执行时首先会将 main 函数中的预留的两个返回值地址置成 int 类型的默认值 0,然后根据栈的相对位置获取参数并进行加减操作,最终将值存储回栈中,所以经过分析 myFunction 执行之后的堆栈信息如下:



myFunction 函数返回之后,main 函数就会通过以下的指令来恢复栈基址指针并销毁已经失去作用的 40 字节的栈空间:


0x0033 00051 (main.go:9)    MOVQ    32(SP), BP    0x0038 00056 (main.go:9)    ADDQ    $40, SP    0x003c 00060 (main.go:9)    RET
复制代码


通过对 Golang 伪汇编语言语言的分析,我们发现 Go 语言传递和接受参数使用的都是栈,它没有像 C 语言一样在函数参数较少时使用寄存器传递参数,同时使用栈代替 eax 寄存器传递返回值也能够同时返回多个结果,但是需要注意的是,函数入参和出参的内存空间都需要调用方在栈上进行分配,这种使用栈进行参数传递的方式虽然跟使用寄存器相比在性能上会有一些损失,但是也能够带来其他的好处:


  1. 能够降低实现的复杂度;

  2. 不需要考虑超过寄存器个数的参数应该如何传递;

  3. 更方便的兼容不同的硬件;

  4. 不同 CPU 的寄存器差别比较大;

  5. 函数可以具有多个返回值;

  6. 栈上的内存地址与相比寄存器的个数是无限的;

  7. 使用寄存器支持多个返回值也会非常困难,超出寄存器个数的返回值也需要使用栈来传递;

__2. 参数传递

除了函数的调用惯例之外,函数的参数在调用时究竟是传值还是传引用也是一个非常有趣的问题,很多人都会说无论是传值还是传引用本质上都是对值的传递,这种论调其实并没有什么意义,我们其实需要知道对函数中参数的修改会不会影响调用方栈上的内容。


不同语言对于参数传递的方式可能设计有所不同,Java 在传递基本类型时会对字面量进行拷贝,不过传递对象参数时就是对象在堆中的地址,相比于 Java 这种稍显复杂的设计,Golang 的设计就简单了很多,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝,这一节剩下的内容就会帮助验证这个结论的正确性。

__2.1. 整型和数组

我们构建一个如下的函数 myFunction,它接受两个参数,一个整型变量 i 和另一个是数组 arr,这个函数会将传入的两个参数的地址打印出来,在最外层的主函数也会在方法调用前后分别打印两个参数的地址:


func myFunction(i int, arr [2]int) {    fmt.Printf("in my_funciton - i=%p arr=%p\n", &i, &arr)}
func main() { i := 30 arr := [2]int{66, 77} fmt.Printf("before calling - i=%p arr=%p\n", &i, &arr) myFunction(i, arr) fmt.Printf("after calling - i=%p arr=%p\n", &i, &arr)}
$ go run main.gobefore calling - i=0xc00009a000 arr=0xc00009a010in my_funciton - i=0xc00009a008 arr=0xc00009a020after calling - i=0xc00009a000 arr=0xc00009a010
复制代码


通过命令运行这段代码我们会发现,main 函数和被调用者 myFunction 中参数的地址是完全不同的。


但是无论是调用 myFunction 前还是调用 myFunction 后,两个参数的地址都没有变化,如果我们尝试在函数内部对参数进行修改又会怎么样呢?


func myFunction(i int, arr [2]int) {    i = 29    arr[1] = 88    fmt.Printf("in my_funciton - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)}
func main() { i := 30 arr := [2]int{66, 77} fmt.Printf("before calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr) myFunction(i, arr) fmt.Printf("after calling - i=(%d, %p) arr=(%v, %p)\n", i, &i, arr, &arr)}
$ go run main.gobefore calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)in my_funciton - i=(29, 0xc000072028) arr=([66 88], 0xc000072040)after calling - i=(30, 0xc000072008) arr=([66 77], 0xc000072010)
复制代码


可以看到在 myFunction 中对参数的修改也仅仅影响了当前的函数栈,同时并没有对调用方的函数栈有任何的影响,我们也能给出如下的结论 - Go 语言中对于整型和数组类型的参数都是值传递的,也就是在调用函数时会对内容进行拷贝,需要注意的是如果当前数组的大小非常的大,这种直接复制传值的方式就会对性能造成比较大的影响。

__2.2. 结构体和指针

接下来我们需要再来看一下在 Golang 中的另外两种常见的类型 - 结构体和指针在参数的传递过程中是传值还是传引用的。在这里定义一个只包含一个成员变量的简单结构体 MyStruct 以及接受两个参数的 myFunction 方法:


type MyStruct struct {    i int}
func myFunction(a MyStruct, b *MyStruct) { a.i = 31 b.i = 41 fmt.Printf("in my_function - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)}
func main() { a := MyStruct{i: 30} b := &MyStruct{i: 40} fmt.Printf("before calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b) myFunction(a, b) fmt.Printf("after calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b)}
$ go run main.gobefore calling - a=({30}, 0xc000018178) b=(&{40}, 0xc00000c028)in my_function - a=({31}, 0xc000018198) b=(&{41}, 0xc00000c038)after calling - a=({30}, 0xc000018178) b=(&{41}, 0xc00000c028)
复制代码


从这段代码中我们其实可以看出,如果传递的是结构体,那么在传递参数时依然会对结构体中的全部内容进行拷贝,而传递指针时复制的其实也是指针的内容 - 地址,所以对指针的修改其实就是修改指针背后的值,b.i 可以被理解成 (*b).i,也就是我们先获取指针 b 背后的结构体,再修改结构体中的成员变量,传递的指针其实也就是地址。我们简单对上述代码进行修改,看看当前结构体在内存中是如何布局的:


package main
import "unsafe"import "fmt"
type MyStruct struct { i int j int}
func myFunction(ms *MyStruct) { ptr := unsafe.Pointer(ms) for i := 0; i < 2; i++ { c := (*int)(unsafe.Pointer((uintptr(ptr) + uintptr(8*i)))) *c += i + 1 fmt.Printf("[%p] %d\n", c, *c) }}
func main() { a := &MyStruct{i: 40, j: 50} myFunction(a) fmt.Printf("[%p] %v\n", a, a)}
$ go run main.go[0xc000018180] 41[0xc000018188] 52[0xc000018180] &{41 52}
复制代码


在这段代码中,我们直接通过指针的方式修改结构体中的成员变量,结构体在内存中其实就是一篇连续的空间,其中的多个成员变量连续布局,指向结构体的指针其实也是指向这个结构体的首地址,如果我们将 MyStruct 指针修改成 int 类型的,那么对新指针的去引用(dereference)就会返回一个整型变量 i,由于一个 int 类型的变量占 8 个字节,所以将指针移动 8 位之后就能获取下一个结构的成员变量 j


如果我们将上述代码中大部分无关的信息省略并只对其中的 myFuncionMyStruct 这两段代码进行编译:


type MyStruct struct {    i int    j int}
func myFunction(ms *MyStruct) *MyStruct { return ms}
// assembly"".myFunction STEXT nosplit size=20 args=0x10 locals=0x0 0x0000 00000 (main.go:8) TEXT "".myFunction(SB), NOSPLIT, $0-16 0x0000 00000 (main.go:8) FUNCDATA $0, gclocals·aef1f7ba6e2630c93a51843d99f5a28a(SB) 0x0000 00000 (main.go:8) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (main.go:8) MOVQ $0, "".~r1+16(SP) // 初始化返回值 0x0009 00009 (main.go:9) MOVQ "".ms+8(SP), AX // 复制引用 0x000e 00014 (main.go:9) MOVQ AX, "".~r1+16(SP) // 返回引用 0x0013 00019 (main.go:9) RET
复制代码


在这段汇编语言中我们发现当参数是指针时,其实也会使用 MOVQ "".ms+8(SP), AX 指令先对引用进行复制,然后再将复制后的指针作为返回值传递回调用方。



所以其实将指针作为参数传入某一个函数时,其实在函数内部会对指针进行复制,也就是会同时出现两个指针指向原有的内存空间,所以 Go 语言中『传指针』其实也是传值。

__2.3. 小结

当我们对 Go 语言中大多数常见的数据结构进行验证之后,其实就能够推测出 Go 语言在传递参数时其实使用的就是传值的方式,接收方收到参数时会对这些参数进行复制;了解到这一点之后,在传递数组或者内存占用非常大的结构体时,我们在一些函数中应该尽量使用指针作为参数类型来避免发生大量数据的拷贝而影响性能。

__3. 总结

这一节我们详细介绍了 Go 语言中方法调用时的调用惯例,包括参数传递的过程和原理,简单梳理一下方法调用的过程:Go 通过堆栈的方式对函数的参数和返回值进行传递和接受,在调用函数之前会在栈上为返回值分配合适的内存空间,随后按照入参从右到左按顺序压栈,被调用方接受参数时会对参数进行拷贝后再进行计算,返回值最终会被放置到调用者预留好的栈空间上,Go 语言函数调用的原理可以总结成以下的几条规则:


  1. 通过堆栈传递参数,入栈的顺序是从右到左;

  2. 函数返回值通过堆栈传递并由调用者预先分配内存空间;

  3. 调用函数时都是传值,接收方会对入参进行复制再计算;

__4. Reference


**本文转载自 Draveness 技术博客。


原文链接:https://draveness.me/golang/basic/golang-function-call.html


2019-12-03 15:161722

评论 1 条评论

发布
用户头像
老哥 fmt.Printf("after calling - a=(%d, %p) b=(%v, %p)\n", a, &a, b, &b) 这里打印的&b是指向指针的指针
2020-01-03 10:54
回复
没有更多了
发现更多内容

声网 X Yalla:面对面不如线上见,中东年轻人最偏爱的语聊房是怎样“炼”成的?

声网

人工智能 产品方案

“区块链+物联网”的发展现状和应用案例

CECBC

浅析“关于区块链解决资本主义问题还是社会主义问题”的对与错

CECBC

明道云与阿里1688对接案例

明道云

毕业设计:设计电商秒杀系统

张大彪

零代码平台在大型企业的进化之路

明道云

最详细的多线程讲解!

愚者

Java 多线程

生态和场景一站式集成?来看看小程序的“共享主义”

蚂蚁集团移动开发平台 mPaaS

小程序 支付宝 移动开发 生态 mPaaS

模块三外包学生管理系统架构文档

kitten

架构师训练 模块三

Vue深入学习1—mustache模板引擎原理

魁首

Vue 3

三分钟评估 你的CMDB是“磐石”还是“豆腐渣”

鹿小U

DevOps 运维自动化 CMDB IT运维

模块三作业:外包学生管理系统架构文档

babos

#架构实战营

Linux之kill命令

入门小站

Linux

在线MarkDown转HTML工具

入门小站

百度智能云在视频云解决方案市场位居前三!

百度大脑

云计算 云服务 IDC

JAVA的多线程与高并发

愚者

Java 多线程

原来ReadWriteLock也能开发高性能缓存,看完我也能和面试官好好聊聊了!

冰河

Java 并发编程 多线程 高并发 异步编程

字节跳动内测音乐流媒体服务:能否在音乐领域分一杯羹

石头IT视角

模块三作业

Tina

真的太刺激了,蚂蚁金服难忘的四面经历:Linxu+数据库+数据结构+算法+计算机网络

Java 程序员 架构 面试 计算机

Java版人脸检测详解下篇:开发java应用并做成docker镜像同步

编程菌

Java 编程 程序员 技术 技术栈

【架构设计模块三】:学生信息管理系统的架构文档

Ryoma

Win10系统下基于Docker构建Appium容器连接Android模拟器Genymotion完成移动端Python自动化测试

刘悦的技术博客

Python Docker 自动化 自动化测试 Genymotion

模块三作业

秀聪

架构训练营

ONES Performance 研发效能管理解决方案

万事ONES

研发效能 解决方案 ONES

首张区块链《代理出口货物证明》 区块链技术首次应用于出口退税

CECBC

记一次小有成就的代码审计

网络安全学海

网络安全 信息安全 渗透测试 安全漏洞 代码审计

中国高校竟然有两个“智能”专业?

脑极体

网络攻防学习笔记 Day87

穿过生命散发芬芳

网络攻防 7月日更

Go语言:代码检查和优化!

微客鸟窝

Go 语言

模块3.架构设计

脉动

理解 Golang 中函数调用的原理_文化 & 方法_Draveness_InfoQ精选文章