你的浏览器版本过低,可能导致网站不能正常访问!
为了你能正常使用网站功能,请使用这些浏览器。

详解 C/C++ 堆栈的工作机制

[复制链接]
gaosmile 发布时间:2020-12-17 22:37
前言

. U( R, f1 a- n$ c3 b7 p9 P0 C
我们经常会讨论这样的问题:什么时候数据存储在堆栈 (Stack) 中,什么时候数据存储在堆 (Heap) 中。我们知道,局部变量是存储在堆栈中的;debug 时,查看堆栈可以知道函数的调用顺序;函数调用时传递参数,事实上是把参数压入堆栈,听起来,堆栈象一个大杂烩。那么,堆栈 (Stack) 到底是如何工作的呢?本文将详解 C/C++ 堆栈的工作机制。阅读时请注意以下几点:

$ I. X* O/ V- U! a  J
1)本文讨论的编译环境是 Visual C/C++,由于高级语言的堆栈工作机制大致相同,因此对其他编译环境或高级语言如 C# 也有意义。

0 S/ ^4 D+ L% S$ F# I9 O
2)本文讨论的堆栈,是指程序为每个线程分配的默认堆栈,用以支持程序的运行,而不是指程序员为了实现算法而自己定义的堆栈。
) E( {7 x" c6 T! N) o# e0 Q. D
3)  本文讨论的平台为 intel x86。
  h; o/ l6 c( E% B
4)本文的主要部分将尽量避免涉及到汇编的知识,在本文最后可选章节,给出前面章节的反编译代码和注释。
+ A( M! [! J  J5 e
5)结构化异常处理也是通过堆栈来实现的(当你使用 try…catch 语句时,使用的就是  c++ 对 windows 结构化异常处理的扩展),但是关于结构化异常处理的主题太复杂了,本文将不会涉及到。
. m0 A: k2 b: L. a1 c8 y
从一些基本的知识和概念开始
  m7 J0 ?3 Q; F! l5 N, {
1) 程序的堆栈是由处理器直接支持的。在 intel x86 的系统中,堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同),如下图所示:
微信图片_20201217223422.png
因此,栈顶地址是不断减小的,越后入栈的数据,所处的地址也就越低。

) R/ M  {& x, ?" o+ w8 N1 L
2) 在 32 位系统中,堆栈每个数据单元的大小为 4 字节。小于等于 4 字节的数据,比如字节、字、双字和布尔型,在堆栈中都是占 4 个字节的;大于 4 字节的数据在堆栈中占4字节整数倍的空间。
- d: L" W! ?& g% _( O. ]
3) 和堆栈的操作相关的两个寄存器是 EBP 寄存器和 ESP 寄存器的,本文中,你只需要把 EBP 和 ESP 理解成 2 个指针就可以了。ESP 寄存器总是指向堆栈的栈顶,执行 PUSH 命令向堆栈压入数据时,ESP减4,然后把数据拷贝到ESP指向的地址;执行POP 命令时,首先把 ESP 指向的数据拷贝到内存地址/寄存器中,然后 ESP 加 4。EBP 寄存器是用于访问堆栈中的数据的,它指向堆栈中间的某个位置(具体位置后文会具体讲解),函数的参数地址比 EBP 的值高,而函数的局部变量地址比 EBP 的值低,因此参数或局部变量总是通过 EBP 加减一定的偏移地址来访问的,比如,要访问函数的第一个参数为 EBP+8。
: r( ]& h( |0 g1 D
4) 堆栈中到底存储了什么数据?包括了:函数的参数,函数的局部变量,寄存器的值(用以恢复寄存器),函数的返回地址以及用于结构化异常处理的数据(当函数中有 try…catch 语句时才有,本文不讨论)。这些数据是按照一定的顺序组织在一起的, 我们称之为一个堆栈帧(Stack Frame)。一个堆栈帧对应一次函数的调用。在函数开始时,对应的堆栈帧已经完整地建立了(所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的);在函数退出时,整个函数帧将被销毁。

; n- k: W& ?5 C4 U" w7 Q% y5 ^
5) 在文中,我们把函数的调用者称为 caller(调用者),被调用的函数称为callee(被调用者)。之所以引入这个概念,是因为一个函数帧的建立和清理,有些工作是由 Caller 完成的,有些则是由 Callee 完成的。

4 m9 a& o$ d$ i# d! m
开始讨论堆栈是如何工作的

, e& x8 E# @( R' h: Y2 Q* }& @
我们来讨论堆栈的工作机制。堆栈是用来支持函数的调用和执行的,因此,我们下面将通过一组函数调用的例子来讲解,看下面的代码:
  1. % m+ q0 z) g! t! x" k. s' m; g
  2. int foo1(int m, int n)
    + J: w1 [+ h# [! V' M' U. t) D
  3. {
    . m. k! W3 [) S* o& x: m; ~
  4.     int p=m*n;# Y: U4 o. F& j( l
  5.     return p;0 ]( l6 O) V( T5 `
  6. }
    ( M! ^( W8 d8 t) a
  7. int foo(int a, int b)) p$ o& t/ k, ?4 ]1 E3 ~
  8. {
    5 P  T$ E9 i1 p9 O; t# f) c0 ~6 ~
  9.     int c=a+1;    - Z" |, [; {% c3 `
  10.     int d=b+1;    4 c" g( I+ E" f' V
  11.     int e=foo1(c,d);   
    1 G% w8 b3 M& f: V
  12.     return e;, [. q- S  ^! M! ]" ~* w
  13. }
    1 ?* p6 N( R8 z$ x9 K& e. j
  14. int main()
    + X. n. ~/ }9 w, M& K
  15. {* W3 F) q3 k5 j0 ]9 A! i$ x
  16.     int result=foo(3,4);
    : Y% u/ T4 I/ O
  17.     return 0;" J( ~  b+ e/ N4 P' z/ D3 V% d: ~. B
  18. }
复制代码
, E& N$ m6 d) K6 u9 ?% {8 d
这段代码本身并没有实际的意义,我们只是用它来跟踪堆栈。下面的章节我们来跟踪堆栈的建立,堆栈的使用和堆栈的销毁。
4 x# g# K/ I8 o  Q1 _
$ O$ n/ Q, u$ O) Z
堆栈的建立

- M5 T. R( f5 P& R/ p% s& c
我们从main函数执行的第一行代码,即 int result=foo(3,4); 开始跟踪。这时 main 以及之前的函数对应的堆栈帧已经存在在堆栈中了,如下图所示:
微信图片_20201217223428.png
图1
参数入栈
* S2 P9 X/ i( r+ p# V
当 foo 函数被调用,首先,caller(此时caller为main函数)把 foo 函数的两个参数:a=3,b=4 压入堆栈。参数入栈的顺序是由函数的调用约定 (Calling Convention) 决定的,我们将在后面一个专门的章节来讲解调用约定。一般来说,参数都是从右往左入栈的,因此,b=4 先压入堆栈,a=3 后压入,如图:
微信图片_20201217223431.png
图2

: H  f4 ~8 r$ W' s3 v
返回地址入栈

! U0 v4 ]$ s7 ~* [9 ]
我们知道,当函数结束时,代码要返回到上一层函数继续执行,那么,函数如何知道该返回到哪个函数的什么位置执行呢?函数被调用时,会自动把下一条指令的地址压入堆栈,函数结束时,从堆栈读取这个地址,就可以跳转到该指令执行了。如果当前"call foo"指令的地址是 0x00171482 ,由于 call 指令占 5 个字节,那么下一个指令的地址为 0x00171487,0x00171487 将被压入堆栈:
微信图片_20201217223435.png
图3代码跳转到被调用函数执行

* R. t: o& x9 g$ |( f8 g4 ~
返回地址入栈后,代码跳转到被调用函数 foo 中执行。到目前为止,堆栈帧的前一部分,是由 caller 构建的;而在此之后,堆栈帧的其他部分是由 callee 来构建。
$ J4 d8 D2 Z1 {
EBP指针入栈, R7 r% {. z$ V$ t3 s
   
在 foo 函数中,首先将 EBP 寄存器的值压入堆栈。因为此时 EBP 寄存器的值还是用于 main 函数的,用来访问 main 函数的参数和局部变量的,因此需要将它暂存在堆栈中,在 foo 函数退出时恢复。同时,给 EBP 赋于新值。
# {/ r! Q$ b6 b9 B7 N
1)将 EBP 压入堆栈
3 @; {# B, L3 |/ r0 o- }  M4 n; U
2)把 ESP 的值赋给 EBP
微信图片_20201217223437.png
图4
   
这样一来,我们很容易发现当前EBP寄存器指向的堆栈地址就是 EBP 先前值的地址,你还会发现发现,EBP+4 的地址就是函数返回值的地址,EBP+8 就是函数的第一个参数的地址(第一个参数地址并不一定是 EBP+8,后文中将讲到)。因此,通过 EBP 很容易查找函数是被谁调用的或者访问函数的参数(或局部变量)。
- j1 L* X! t; Q# Q7 W
为局部变量分配地址
接着,foo 函数将为局部变量分配地址。程序并不是将局部变量一个个压入堆栈的,而是将 ESP 减去某个值,直接为所有的局部变量分配空间,比如在 foo 函数中有 ESP=ESP-0x00E4,(根据烛秋兄在其他编译环境上的测试,也可能使用 push 命令分配地址,本质上并没有差别,特此说明)如图所示:
0 e2 X) D2 ^' U) r6 L1 [3 l
微信图片_20201217223440.png
图5
     
奇怪的是,在 debug 模式下,编译器为局部变量分配的空间远远大于实际所需,而且局部变量之间的地址不是连续的(据我观察,总是间隔 8 个字节)如下图所示:
微信图片_20201217223443.png
图6
   
我还不知道编译器为什么这么设计,或许是为了在堆栈中插入调试数据,不过这无碍我们今天的讨论。
* L8 L  E/ I2 s/ F3 h/ z1 j4 }6 U
通用寄存器入栈
     
最后,将函数中使用到的通用寄存器入栈,暂存起来,以便函数结束时恢复。在 foo 函数中用到的通用寄存器是 EBX,ESI,EDI,将它们压入堆栈,如图所示:
微信图片_20201217223446.png
图7
   
至此,一个完整的堆栈帧建立起来了。

* Z$ a9 }9 p4 }: a
堆栈特性分析
  
上一节中,一个完整的堆栈帧已经建立起来,现在函数可以开始正式执行代码了。本节我们对堆栈的特性进行分析,有助于了解函数与堆栈帧的依赖关系。

% t/ U$ e5 C5 r. e/ `# x/ `
1)一个完整的堆栈帧建立起来后,在函数执行的整个生命周期中,它的结构和大小都是保持不变的;不论函数在什么时候被谁调用,它对应的堆栈帧的结构也是一定的。

! ^* ~0 R8 t0 ~" l, [9 W5 O0 L: W' g
2)在 A 函数中调用B函数,对应的,是在A函数对应的堆栈帧“下方”建立 B 函数的堆栈帧。例如在 foo 函数中调用 foo1 函数,foo1 函数的堆栈帧将在 foo 函数的堆栈帧下方建立。如下图所示:
微信图片_20201217223451.png
图8
3)函数用 EBP 寄存器来访问参数和局部变量。我们知道,参数的地址总是比 EBP 的值高,而局部变量的地址总是比 EBP 的值低。而在特定的堆栈帧中,每个参数或局部变量相对于 EBP 的地址偏移总是固定的。因此函数对参数和局部变量的的访问是通过 EBP 加上某个偏移量来访问的。比如,在 foo 函数中,EBP+8 为第一个参数的地址,EBP-8 为第一个局部变量的地址。

0 j9 x7 K( e/ c2 X3 I
4)如果仔细思考,我们很容易发现 EBP 寄存器还有一个非常重要的特性,请看下图中:
微信图片_20201217223454.png
图9
   
我们发现,EBP 寄存器总是指向先前的 EBP,而先前的 EBP 又指向先前的先前的 EBP,这样就在堆栈中形成了一个链表!这个特性有什么用呢,我们知道 EBP+4 地址存储了函数的返回地址,通过该地址我们可以知道当前函数的上一级函数(通过在符号文件中查找距该函数返回地址最近的函数地址,该函数即当前函数的上一级函数),以此类推,我们就可以知道当前线程整个的函数调用顺序。事实上,调试器正是这么做的,这也就是为什么调试时我们查看函数调用顺序时总是说“查看堆栈”了。

! W5 e7 S" k0 x7 [
返回值是如何传递的
, [( j" o' W: D. w; Q
堆栈帧建立起后,函数的代码真正地开始执行,它会操作堆栈中的参数,操作堆栈中的局部变量,甚至在堆(Heap)上创建对象,balabala….,终于函数完成了它的工作,有些函数需要将结果返回给它的上一层函数,这是怎么做的呢?
   
首先,caller 和 callee 在这个问题上要有一个“约定”,由于 caller 是不知道 callee 内部是如何执行的,因此 caller 需要从 callee 的函数声明就可以知道应该从什么地方取得返回值。同样的,callee 不能随便把返回值放在某个寄存器或者内存中而指望Caller 能够正确地获得的,它应该根据函数的声明,按照“约定”把返回值放在正确的”地方“。下面我们来讲解这个“约定”:

, Q) ^& u/ M: A: r8 V1)首先,如果返回值等于 4 字节,函数将把返回值赋予EAX寄存器,通过 EAX 寄存器返回。例如返回值是字节、字、双字、布尔型、指针等类型,都通过 EAX 寄存器返回。

2 F& X3 q- Q6 |) ]# P; G
2)如果返回值等于 8 字节,函数将把返回值赋予 EAX 和 EDX 寄存器,通过 EAX 和 EDX 寄存器返回,EDX 存储高位 4 字节,EAX存储低位 4 字节。例如返回值类型为 __int64 或者 8 字节的结构体通过 EAX 和 EDX 返回。

& r- N4 ?2 P/ l( E
3)  如果返回值为 double 或 float 型,函数将把返回值赋予浮点寄存器,通过浮点寄存器返回。

% t  w; j! K$ ~+ |
4)如果返回值是一个大于 8 字节的数据,将如何传递返回值呢?这是一个比较麻烦的问题,我们将详细讲解:

7 U3 E1 e* ]3 k7 V2 d( }9 o) g( m
我们修改 foo 函数的定义如下并将它的代码做适当的修改:

  1. 9 k1 |/ H  u, u& h, i9 Q: ]- r7 h
  2. MyStruct foo(`int a, int b)`
    $ u9 u* n5 y) ~* {$ N
  3. {. k! I2 P: Y$ L9 N7 U! T  f
  4.     ...
    1 v% ]. R( W* n: p
  5. }
复制代码
5 V. f7 w$ }  t% L0 X0 e* N) I. Q; Z
MyStruct定义为:

  1. & P5 [% A2 C8 b
  2. struct MyStruct
    , v4 D2 T  A; T9 z/ m- B
  3. {$ B7 S( O9 L" e- @9 F- D& d: u$ [
  4.     int value1;
    ( w; q  x. t6 m' g5 P* x4 S/ f
  5.     __int64 value2;5 v! o! L4 i: F7 K$ Y
  6.     bool value3;
    * m3 u/ b9 A8 Q0 a
  7. };
复制代码

- v& I# Z" m& I" \0 Q
这时,在调用 foo 函数时参数的入栈过程会有所不同,如下图所示:
微信图片_20201217223457.png
图10
   
caller 会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer 指向 caller 局部变量区的一块未命名的地址,这块地址将用来存储 callee 的返回值。函数返回时,callee 把返回值拷贝到ReturnValuePointer 指向的地址中,然后把 ReturnValuePointer 的地址赋予 EAX 寄存器。函数返回后,caller 通过 EAX 寄存器找到 ReturnValuePointer,然后通过ReturnValuePointer 找到返回值,最后,caller 把返回值拷贝到负责接收的局部变量上(如果接收返回值的话)。
   
你或许会有这样的疑问,函数返回后,对应的堆栈帧已经被销毁,而ReturnValuePointer 是在该堆栈帧中,不也应该被销毁了吗?对的,堆栈帧是被销毁了,但是程序不会自动清理其中的值,因此 ReturnValuePointer 中的值还是有效的。
堆栈帧的销毁
   
当函数将返回值赋予某些寄存器或者拷贝到堆栈的某个地方后,函数开始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反:(堆栈帧的销毁过程就不一一画图说明了)

7 G' M1 R/ C# z/ U! n- n" S
    1)如果有对象存储在堆栈帧中,对象的析构函数会被函数调用。

& j5 l/ b7 p/ y: B  S+ |
    2)从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。

5 u: t2 F) r( M% |) x  a
    3)ESP 加上某个值,回收局部变量的地址空间(加上的值和堆栈帧建立时分配给局部变量的地址大小相同)。
+ u: X3 w9 Q+ }
    4)从堆栈中弹出先前的 EBP 寄存器的值,恢复 EBP 寄存器。
2 Z3 e2 d. A* W6 S' ^% V# i
    5)从堆栈中弹出函数的返回地址,准备跳转到函数的返回地址处继续执行。
0 X3 x' w3 \# k+ q+ R4 f- I
    6)ESP 加上某个值,回收所有的参数地址。

6 f) t) J6 x! |/ m; P# e% ~
前面 1-5 条都是由 callee 完成的。而第 6 条,参数地址的回收,是由 caller 或者callee 完成是由函数使用的调用约定(calling convention )来决定的。下面的小节我们就来讲解函数的调用约定。
$ v' j* \3 R+ H1 g
函数的调用约定(calling convention)
& C0 j. t  e- E, _% F
函数的调用约定 (calling convention) 指的是进入函数时,函数的参数是以什么顺序压入堆栈的,函数退出时,又是由谁(Caller还是Callee)来清理堆栈中的参数。有 2 个办法可以指定函数使用的调用约定:
. O& ~6 Z, r1 ^6 X3 `. E) S0 h
1)在函数定义时加上修饰符来指定,如
  1. 1 d" `2 o5 u$ k: }1 r, p0 X" C
  2. void __thiscall mymethod();
    9 L  j0 N8 s/ _: D1 ?0 W: J# z
  3. {
    $ v1 U0 x" v9 \5 S# i! l# u
  4.     ...' O5 h7 l- u$ E6 @* f& ^3 m3 n
  5. }
复制代码

: H, H+ Y3 j- K5 Q
2)在 VS 工程设置中为工程中定义的所有的函数指定默认的调用约定:在工程的主菜单打开 Project|Project Property|Configuration Properties|C/C++|Advanced|Calling Convention,选择调用约定(注意:这种做法对类成员函数无效)。
) g& L+ F: [1 V
常用的调用约定有以下3种:

3 v  L9 }, z8 O% U0 f0 e& X6 t
1)__cdecl。这是 VC 编译器默认的调用约定。其规则是:参数从右向左压入堆栈,函数退出时由 caller 清理堆栈中的参数。这种调用约定的特点是支持可变数量的参数,比如 printf 方法。由于 callee 不知道caller到底将多少参数压入堆栈,因此callee 就没有办法自己清理堆栈,所以只有函数退出之后,由 caller 清理堆栈,因为 caller 总是知道自己传入了多少参数。
' I3 @% J5 _/ t+ i% a0 c- B4 @
2)__stdcall。所有的 Windows API 都使用 __stdcall。其规则是:参数从右向左压入堆栈,函数退出时由 callee 自己清理堆栈中的参数。由于参数是由 callee 自己清理的,所以 __stdcall 不支持可变数量的参数。
, B( ?: k) s, a6 h" G( U) D3 }
3) __thiscall。类成员函数默认使用的调用约定。其规则是:参数从右向左压入堆栈,x86 构架下 this 指针通过 ECX 寄存器传递,函数退出时由 callee 清理堆栈中的参数,x86构架下this指针通过ECX寄存器传递。同样不支持可变数量的参数。如果显式地把类成员函数声明为使用__cdecl或者__stdcall,那么,将采用__cdecl或者__stdcall的规则来压栈和出栈,而this指针将作为函数的第一个参数最后压入堆栈,而不是使用ECX寄存器来传递了。
0 G/ V3 K! U4 @' p; E+ ^. z* q% `
反编译代码的跟踪(不熟悉汇编可跳过)

4 V  Q" f6 \7 L3 I, t
以下代码为和 foo 函数对应的堆栈帧建立相关的代码的反编译代码,我将逐行给出注释,可对照前文中对堆栈的描述:

2 O& L* y+ d1 u% W8 b
main 函数中 int result=foo(3,4); 的反汇编:
  1. & a& V* E1 ?& t, j
  2. 008A147E  push        4 //b=4 压入堆栈
    ! w9 @; g; T% P# `; V
  3. 008A1480  push        3 //a=3 压入堆栈,到达图2的状态3 ^# K! _# E  n, E- o) f* F
  4. 008A1482  call        foo (8A10F5h) //函数返回值入栈,转入foo中执行,到达图3的状态: o) J' q* }0 u1 w8 L4 g
  5. 008A1487  add         esp,8 //foo返回,由于采用__cdecl,由Caller清理参数
    % r! V( S* a/ J3 q( p
  6. 008A148A  mov         dword ptr [result],eax //返回值保存在EAX中,把EAX赋予result变量
复制代码

2 c3 }5 T5 b) j% \) d
下面是 foo 函数代码正式执行前和执行后的反汇编代码

  1. / }# e. `6 T. L; D- O; w; G+ S
  2. 008A13F0  push        ebp //把ebp压入堆栈
    3 @% d6 i1 K$ r! X
  3. 008A13F1  mov         ebp,esp //ebp指向先前的ebp,到达图4的状态
    ! e. r6 h9 n1 l$ `
  4. 008A13F3  sub         esp,0E4h //为局部变量分配0E4字节的空间,到达图5的状态
    ! C6 M. r+ M( m; J
  5. 008A13F9  push        ebx //压入EBX
    . J. N- r7 B' w# K
  6. 008A13FA  push        esi //压入ESI2 P  H: P% y$ E# p1 ~5 ?! f
  7. 008A13FB  push        edi //压入EDI,到达图7的状态
    ) v% d' X* s. b- r  R& j
  8. 008A13FC  lea         edi,[ebp-0E4h] //以下4行把局部变量区初始化为每个字节都等于cch
    ! b8 N" f6 P3 h! A
  9. 008A1402  mov         ecx,39h
    / Q- i* y8 N2 l0 G; k1 A' ^
  10. 008A1407  mov         eax,0CCCCCCCCh
    0 G* O1 h1 z- F: l
  11. 008A140C  rep stos    dword ptr es:[edi]
    ; S) z% F0 b. U% `  t. a9 E
  12. ...... //省略代码执行N行
    ; C- c& P+ U9 {8 ]5 @% y
  13. ......  x+ [9 M. @' F' Z9 A5 m+ W
  14. 008A1436  pop         edi //恢复EDI. u! u/ v. z+ S: `, u5 }5 s! t5 R
  15. 008A1437  pop         esi //恢复ESI
    9 c$ U# E; V$ P0 Y( \+ h
  16. 008A1438  pop         ebx //恢复EBX
    ' d, [. t# L$ V' \% e3 b
  17. 008A1439  add         esp,0E4h //回收局部变量地址空间( R8 O% Q. H/ u. B% P  q5 t; B
  18. 008A143F  cmp         ebp,esp //以下3行为Runtime Checking,检查ESP和EBP是否一致$ Z& g- K# |$ s  z% N3 D
  19. 008A1441  call        @ILT+330(__RTC_CheckEsp) (8A114Fh)
    * `- h% p- F! ]- ]* S: v
  20. 008A1446  mov         esp,ebp
    " b1 U. U+ I7 I+ ?* |5 f, N9 w2 c
  21. 008A1448  pop         ebp //恢复EBP
    1 q! a! Y4 o7 J2 F. c3 D! v
  22. 008A1449  ret //弹出函数返回地址,跳转到函数返回地址执行                                            //(__cdecl调用约定,Callee未清理参数)
复制代码

6 V: {& e0 F# J3 m6 b
收藏 评论0 发布时间:2020-12-17 22:37

举报

0个回答

所属标签

关于
我们是谁
投资者关系
意法半导体可持续发展举措
创新与技术
意法半导体官网
联系我们
联系ST分支机构
寻找销售人员和分销渠道
社区
媒体中心
活动与培训
隐私策略
隐私策略
Cookies管理
行使您的权利
官方最新发布
STM32N6 AI生态系统
STM32MCU,MPU高性能GUI
ST ACEPACK电源模块
意法半导体生物传感器
STM32Cube扩展软件包
关注我们
st-img 微信公众号
st-img 手机版