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

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

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

8 ]' z2 e0 [; l: s" H, q. n. e# [
我们经常会讨论这样的问题:什么时候数据存储在堆栈 (Stack) 中,什么时候数据存储在堆 (Heap) 中。我们知道,局部变量是存储在堆栈中的;debug 时,查看堆栈可以知道函数的调用顺序;函数调用时传递参数,事实上是把参数压入堆栈,听起来,堆栈象一个大杂烩。那么,堆栈 (Stack) 到底是如何工作的呢?本文将详解 C/C++ 堆栈的工作机制。阅读时请注意以下几点:
& v% v+ m: V/ f; d+ s, n3 d3 R
1)本文讨论的编译环境是 Visual C/C++,由于高级语言的堆栈工作机制大致相同,因此对其他编译环境或高级语言如 C# 也有意义。

! \$ G& ?- p3 q6 {$ u5 q9 E" ~9 ~/ O
2)本文讨论的堆栈,是指程序为每个线程分配的默认堆栈,用以支持程序的运行,而不是指程序员为了实现算法而自己定义的堆栈。
% w1 o" }8 m3 x+ P0 k6 F
3)  本文讨论的平台为 intel x86。
: \5 c$ Q; ^( X3 ?+ L7 D  Q
4)本文的主要部分将尽量避免涉及到汇编的知识,在本文最后可选章节,给出前面章节的反编译代码和注释。
' G( P  q3 d! G( s, [
5)结构化异常处理也是通过堆栈来实现的(当你使用 try…catch 语句时,使用的就是  c++ 对 windows 结构化异常处理的扩展),但是关于结构化异常处理的主题太复杂了,本文将不会涉及到。
/ I$ H* L$ S! [! i) @  ]
从一些基本的知识和概念开始

6 q8 w; N# _6 q, I! h" h- U* b2 ?
1) 程序的堆栈是由处理器直接支持的。在 intel x86 的系统中,堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同),如下图所示:
微信图片_20201217223422.png
因此,栈顶地址是不断减小的,越后入栈的数据,所处的地址也就越低。

8 F" }8 ^" v" @- ~. e; _8 j( \
2) 在 32 位系统中,堆栈每个数据单元的大小为 4 字节。小于等于 4 字节的数据,比如字节、字、双字和布尔型,在堆栈中都是占 4 个字节的;大于 4 字节的数据在堆栈中占4字节整数倍的空间。

1 T# T( _" N) x% A, q& w
3) 和堆栈的操作相关的两个寄存器是 EBP 寄存器和 ESP 寄存器的,本文中,你只需要把 EBP 和 ESP 理解成 2 个指针就可以了。ESP 寄存器总是指向堆栈的栈顶,执行 PUSH 命令向堆栈压入数据时,ESP减4,然后把数据拷贝到ESP指向的地址;执行POP 命令时,首先把 ESP 指向的数据拷贝到内存地址/寄存器中,然后 ESP 加 4。EBP 寄存器是用于访问堆栈中的数据的,它指向堆栈中间的某个位置(具体位置后文会具体讲解),函数的参数地址比 EBP 的值高,而函数的局部变量地址比 EBP 的值低,因此参数或局部变量总是通过 EBP 加减一定的偏移地址来访问的,比如,要访问函数的第一个参数为 EBP+8。

; @1 q1 D) k6 j* S: p6 L7 D6 H
4) 堆栈中到底存储了什么数据?包括了:函数的参数,函数的局部变量,寄存器的值(用以恢复寄存器),函数的返回地址以及用于结构化异常处理的数据(当函数中有 try…catch 语句时才有,本文不讨论)。这些数据是按照一定的顺序组织在一起的, 我们称之为一个堆栈帧(Stack Frame)。一个堆栈帧对应一次函数的调用。在函数开始时,对应的堆栈帧已经完整地建立了(所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的);在函数退出时,整个函数帧将被销毁。

; B# j& W4 L) K8 ]3 t/ L: k+ j6 O
5) 在文中,我们把函数的调用者称为 caller(调用者),被调用的函数称为callee(被调用者)。之所以引入这个概念,是因为一个函数帧的建立和清理,有些工作是由 Caller 完成的,有些则是由 Callee 完成的。

& f- b1 |1 _- j
开始讨论堆栈是如何工作的
0 ~" c  k& N; _: X1 H& S% ]* T
我们来讨论堆栈的工作机制。堆栈是用来支持函数的调用和执行的,因此,我们下面将通过一组函数调用的例子来讲解,看下面的代码:
  1. 6 \# D: h0 L5 \
  2. int foo1(int m, int n)3 u0 m) [% h* e1 n) H
  3. {
    ; j) q5 b2 h- ^- M; a1 D; O) c# m! e
  4.     int p=m*n;! O" A  J% H% i% P$ {* h
  5.     return p;. o. A2 ~' {* F9 S
  6. }- w% L5 b' L( W/ @% i
  7. int foo(int a, int b)5 y# ?' c" ?  f) t9 q; R
  8. {
    1 o9 b3 {. d! Z# r! f+ C8 f2 W# F
  9.     int c=a+1;      F( R: j: V6 w6 Z1 m* [
  10.     int d=b+1;    ) u) \/ d* N! }7 Z; V3 [+ e* c9 o
  11.     int e=foo1(c,d);    ( n* U; L+ a6 U4 W& v' ~. {" L
  12.     return e;
    : `) a% W" y7 d+ ?
  13. }# z' r/ i# R7 M& O* p
  14. int main()
    4 q- K5 N( H( r; b8 }' C$ h
  15. {, p& K2 }" k$ a! [3 A0 c4 G
  16.     int result=foo(3,4);) D$ o" a' d& g
  17.     return 0;0 t1 j3 B5 A. O  @5 Q9 q  z3 r: E% ~
  18. }
复制代码
  M& C; R7 q0 h% R
这段代码本身并没有实际的意义,我们只是用它来跟踪堆栈。下面的章节我们来跟踪堆栈的建立,堆栈的使用和堆栈的销毁。) @9 Q& y1 I# _" q9 s2 R

$ L  H" Z" G& c# g
堆栈的建立

) T4 q4 ^5 U8 Y7 B6 H! o  k5 u% M
我们从main函数执行的第一行代码,即 int result=foo(3,4); 开始跟踪。这时 main 以及之前的函数对应的堆栈帧已经存在在堆栈中了,如下图所示:
微信图片_20201217223428.png
图1
参数入栈

' ~* v( |2 H2 E) r
当 foo 函数被调用,首先,caller(此时caller为main函数)把 foo 函数的两个参数:a=3,b=4 压入堆栈。参数入栈的顺序是由函数的调用约定 (Calling Convention) 决定的,我们将在后面一个专门的章节来讲解调用约定。一般来说,参数都是从右往左入栈的,因此,b=4 先压入堆栈,a=3 后压入,如图:
微信图片_20201217223431.png
图2
6 t, s6 z8 S; a0 e% @( O4 L* f
返回地址入栈

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

% I9 u/ b8 d8 F" U$ e
返回地址入栈后,代码跳转到被调用函数 foo 中执行。到目前为止,堆栈帧的前一部分,是由 caller 构建的;而在此之后,堆栈帧的其他部分是由 callee 来构建。

& E# q# J& F  h6 t
EBP指针入栈- S6 n$ c% v) ~3 f
   
在 foo 函数中,首先将 EBP 寄存器的值压入堆栈。因为此时 EBP 寄存器的值还是用于 main 函数的,用来访问 main 函数的参数和局部变量的,因此需要将它暂存在堆栈中,在 foo 函数退出时恢复。同时,给 EBP 赋于新值。
8 T: m8 R+ i' ]9 B
1)将 EBP 压入堆栈

2 I: N$ @- j/ g3 x" k" H% J
2)把 ESP 的值赋给 EBP
微信图片_20201217223437.png
图4
   
这样一来,我们很容易发现当前EBP寄存器指向的堆栈地址就是 EBP 先前值的地址,你还会发现发现,EBP+4 的地址就是函数返回值的地址,EBP+8 就是函数的第一个参数的地址(第一个参数地址并不一定是 EBP+8,后文中将讲到)。因此,通过 EBP 很容易查找函数是被谁调用的或者访问函数的参数(或局部变量)。
2 ]" R! y! U/ K3 t; l4 I. ]. t
为局部变量分配地址
接着,foo 函数将为局部变量分配地址。程序并不是将局部变量一个个压入堆栈的,而是将 ESP 减去某个值,直接为所有的局部变量分配空间,比如在 foo 函数中有 ESP=ESP-0x00E4,(根据烛秋兄在其他编译环境上的测试,也可能使用 push 命令分配地址,本质上并没有差别,特此说明)如图所示:
% K1 h1 u! Q" l9 M5 f; U; C" _
微信图片_20201217223440.png
图5
     
奇怪的是,在 debug 模式下,编译器为局部变量分配的空间远远大于实际所需,而且局部变量之间的地址不是连续的(据我观察,总是间隔 8 个字节)如下图所示:
微信图片_20201217223443.png
图6
   
我还不知道编译器为什么这么设计,或许是为了在堆栈中插入调试数据,不过这无碍我们今天的讨论。

# |8 w; Q& |+ T# r; F
通用寄存器入栈
     
最后,将函数中使用到的通用寄存器入栈,暂存起来,以便函数结束时恢复。在 foo 函数中用到的通用寄存器是 EBX,ESI,EDI,将它们压入堆栈,如图所示:
微信图片_20201217223446.png
图7
   
至此,一个完整的堆栈帧建立起来了。

2 D) L" A5 X- O/ U# ?( v& f" c
堆栈特性分析
  
上一节中,一个完整的堆栈帧已经建立起来,现在函数可以开始正式执行代码了。本节我们对堆栈的特性进行分析,有助于了解函数与堆栈帧的依赖关系。

; Q+ i: P& N6 Q' @% x3 G
1)一个完整的堆栈帧建立起来后,在函数执行的整个生命周期中,它的结构和大小都是保持不变的;不论函数在什么时候被谁调用,它对应的堆栈帧的结构也是一定的。
. h( E: t9 G+ v6 B, z0 }" ]4 a; h! g1 E$ l
2)在 A 函数中调用B函数,对应的,是在A函数对应的堆栈帧“下方”建立 B 函数的堆栈帧。例如在 foo 函数中调用 foo1 函数,foo1 函数的堆栈帧将在 foo 函数的堆栈帧下方建立。如下图所示:
微信图片_20201217223451.png
图8
3)函数用 EBP 寄存器来访问参数和局部变量。我们知道,参数的地址总是比 EBP 的值高,而局部变量的地址总是比 EBP 的值低。而在特定的堆栈帧中,每个参数或局部变量相对于 EBP 的地址偏移总是固定的。因此函数对参数和局部变量的的访问是通过 EBP 加上某个偏移量来访问的。比如,在 foo 函数中,EBP+8 为第一个参数的地址,EBP-8 为第一个局部变量的地址。
; {. u4 @( ?4 d: D& O
4)如果仔细思考,我们很容易发现 EBP 寄存器还有一个非常重要的特性,请看下图中:
微信图片_20201217223454.png
图9
   
我们发现,EBP 寄存器总是指向先前的 EBP,而先前的 EBP 又指向先前的先前的 EBP,这样就在堆栈中形成了一个链表!这个特性有什么用呢,我们知道 EBP+4 地址存储了函数的返回地址,通过该地址我们可以知道当前函数的上一级函数(通过在符号文件中查找距该函数返回地址最近的函数地址,该函数即当前函数的上一级函数),以此类推,我们就可以知道当前线程整个的函数调用顺序。事实上,调试器正是这么做的,这也就是为什么调试时我们查看函数调用顺序时总是说“查看堆栈”了。

! S" E" y" U3 P# B3 U1 z
返回值是如何传递的

" Z6 A5 Q. ]# D2 ~1 x) x
堆栈帧建立起后,函数的代码真正地开始执行,它会操作堆栈中的参数,操作堆栈中的局部变量,甚至在堆(Heap)上创建对象,balabala….,终于函数完成了它的工作,有些函数需要将结果返回给它的上一层函数,这是怎么做的呢?
   
首先,caller 和 callee 在这个问题上要有一个“约定”,由于 caller 是不知道 callee 内部是如何执行的,因此 caller 需要从 callee 的函数声明就可以知道应该从什么地方取得返回值。同样的,callee 不能随便把返回值放在某个寄存器或者内存中而指望Caller 能够正确地获得的,它应该根据函数的声明,按照“约定”把返回值放在正确的”地方“。下面我们来讲解这个“约定”:
6 `* t# ]! I: N: ~" i3 D! J
1)首先,如果返回值等于 4 字节,函数将把返回值赋予EAX寄存器,通过 EAX 寄存器返回。例如返回值是字节、字、双字、布尔型、指针等类型,都通过 EAX 寄存器返回。

5 c. Z$ I% f  J' V% V  U
2)如果返回值等于 8 字节,函数将把返回值赋予 EAX 和 EDX 寄存器,通过 EAX 和 EDX 寄存器返回,EDX 存储高位 4 字节,EAX存储低位 4 字节。例如返回值类型为 __int64 或者 8 字节的结构体通过 EAX 和 EDX 返回。
. E6 P4 c+ a! T, w9 K3 `
3)  如果返回值为 double 或 float 型,函数将把返回值赋予浮点寄存器,通过浮点寄存器返回。

- K# h9 _7 U4 r; D0 c
4)如果返回值是一个大于 8 字节的数据,将如何传递返回值呢?这是一个比较麻烦的问题,我们将详细讲解:

7 j9 t/ g* x/ s4 c1 l8 J" H
我们修改 foo 函数的定义如下并将它的代码做适当的修改:
  1. * y" p" v; }. a( P8 Y% F3 ], [
  2. MyStruct foo(`int a, int b)`, S. j' w4 ^& T2 t
  3. {" f& G& D, h6 T7 \8 T
  4.     ...- h  G. y8 A/ r1 l
  5. }
复制代码
. U, g9 K1 i9 N. s7 K9 R0 @8 E6 n' o  X
MyStruct定义为:

  1. : x" L7 P  w6 b# S% ^9 S0 W, H1 o+ B" D: V
  2. struct MyStruct
    % q7 T/ E6 K+ ^  ?
  3. {! _7 A% Q& x$ o) m' D; A# T
  4.     int value1;
    : z# B2 x5 a( @$ D* T9 O
  5.     __int64 value2;/ S5 k% l# m3 C1 L; q
  6.     bool value3;9 B4 m7 X) x+ r9 @
  7. };
复制代码
8 v- g6 k: m0 E$ r, ?/ b. J1 }6 V" d' z
这时,在调用 foo 函数时参数的入栈过程会有所不同,如下图所示:
微信图片_20201217223457.png
图10
   
caller 会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer 指向 caller 局部变量区的一块未命名的地址,这块地址将用来存储 callee 的返回值。函数返回时,callee 把返回值拷贝到ReturnValuePointer 指向的地址中,然后把 ReturnValuePointer 的地址赋予 EAX 寄存器。函数返回后,caller 通过 EAX 寄存器找到 ReturnValuePointer,然后通过ReturnValuePointer 找到返回值,最后,caller 把返回值拷贝到负责接收的局部变量上(如果接收返回值的话)。
   
你或许会有这样的疑问,函数返回后,对应的堆栈帧已经被销毁,而ReturnValuePointer 是在该堆栈帧中,不也应该被销毁了吗?对的,堆栈帧是被销毁了,但是程序不会自动清理其中的值,因此 ReturnValuePointer 中的值还是有效的。
堆栈帧的销毁
   
当函数将返回值赋予某些寄存器或者拷贝到堆栈的某个地方后,函数开始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反:(堆栈帧的销毁过程就不一一画图说明了)

5 J+ U1 f5 ?+ r# B4 Z; L9 d
    1)如果有对象存储在堆栈帧中,对象的析构函数会被函数调用。

6 I1 P1 K' s$ ^; r# ^! C# ^, n, i, _. C
    2)从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。
  r- f4 w! g  p7 P3 T4 D+ h0 [
    3)ESP 加上某个值,回收局部变量的地址空间(加上的值和堆栈帧建立时分配给局部变量的地址大小相同)。

( H9 U0 C# I8 t+ C0 |
    4)从堆栈中弹出先前的 EBP 寄存器的值,恢复 EBP 寄存器。

: X# c3 X) Y; s% d. j
    5)从堆栈中弹出函数的返回地址,准备跳转到函数的返回地址处继续执行。
8 o7 C  O$ `  g/ Z- V
    6)ESP 加上某个值,回收所有的参数地址。
+ R  ^) [; a; ?5 z, v8 x4 n( \
前面 1-5 条都是由 callee 完成的。而第 6 条,参数地址的回收,是由 caller 或者callee 完成是由函数使用的调用约定(calling convention )来决定的。下面的小节我们就来讲解函数的调用约定。
3 \! u2 U3 a' O
函数的调用约定(calling convention)

) u/ M9 a1 t6 r
函数的调用约定 (calling convention) 指的是进入函数时,函数的参数是以什么顺序压入堆栈的,函数退出时,又是由谁(Caller还是Callee)来清理堆栈中的参数。有 2 个办法可以指定函数使用的调用约定:

2 P2 I; v# Y) f% b
1)在函数定义时加上修饰符来指定,如
  1. : Q  }* n/ t3 h' x2 ^1 I
  2. void __thiscall mymethod();$ ]5 |) D0 |+ W5 `0 \
  3. {% Y6 ]) J$ ]! m$ ]
  4.     ...1 `6 Y9 L0 ?5 d
  5. }
复制代码
! c% I( n1 M4 z: n/ s* j0 ?
2)在 VS 工程设置中为工程中定义的所有的函数指定默认的调用约定:在工程的主菜单打开 Project|Project Property|Configuration Properties|C/C++|Advanced|Calling Convention,选择调用约定(注意:这种做法对类成员函数无效)。

  s- x/ C- a% S1 M
常用的调用约定有以下3种:
% V) Q5 Y/ ^; s5 q
1)__cdecl。这是 VC 编译器默认的调用约定。其规则是:参数从右向左压入堆栈,函数退出时由 caller 清理堆栈中的参数。这种调用约定的特点是支持可变数量的参数,比如 printf 方法。由于 callee 不知道caller到底将多少参数压入堆栈,因此callee 就没有办法自己清理堆栈,所以只有函数退出之后,由 caller 清理堆栈,因为 caller 总是知道自己传入了多少参数。

: M( [# Q2 f/ F' a1 K! E6 x
2)__stdcall。所有的 Windows API 都使用 __stdcall。其规则是:参数从右向左压入堆栈,函数退出时由 callee 自己清理堆栈中的参数。由于参数是由 callee 自己清理的,所以 __stdcall 不支持可变数量的参数。

! S4 H3 X% I: Y3 }, c: |" p
3) __thiscall。类成员函数默认使用的调用约定。其规则是:参数从右向左压入堆栈,x86 构架下 this 指针通过 ECX 寄存器传递,函数退出时由 callee 清理堆栈中的参数,x86构架下this指针通过ECX寄存器传递。同样不支持可变数量的参数。如果显式地把类成员函数声明为使用__cdecl或者__stdcall,那么,将采用__cdecl或者__stdcall的规则来压栈和出栈,而this指针将作为函数的第一个参数最后压入堆栈,而不是使用ECX寄存器来传递了。
8 L1 Q6 m3 p* t0 X; i- R* J
反编译代码的跟踪(不熟悉汇编可跳过)

2 n9 B9 ^3 ]4 D( I
以下代码为和 foo 函数对应的堆栈帧建立相关的代码的反编译代码,我将逐行给出注释,可对照前文中对堆栈的描述:

, Y- O/ l% _0 J
main 函数中 int result=foo(3,4); 的反汇编:
  1. 7 V6 w+ i- a4 ~5 [! F+ ~
  2. 008A147E  push        4 //b=4 压入堆栈: @! \' n4 g5 c1 C4 G
  3. 008A1480  push        3 //a=3 压入堆栈,到达图2的状态7 T9 t: K, K, D! Z+ b$ y/ R6 x& ]1 p
  4. 008A1482  call        foo (8A10F5h) //函数返回值入栈,转入foo中执行,到达图3的状态
    2 B' G8 V9 e3 F8 ?0 Z' W( g
  5. 008A1487  add         esp,8 //foo返回,由于采用__cdecl,由Caller清理参数! l+ G# b% b2 E+ i. i  l
  6. 008A148A  mov         dword ptr [result],eax //返回值保存在EAX中,把EAX赋予result变量
复制代码
" y- K. I& W8 ]& V: y5 j% y/ c
下面是 foo 函数代码正式执行前和执行后的反汇编代码
  1. 2 y3 |# B3 `$ m8 u& O% H
  2. 008A13F0  push        ebp //把ebp压入堆栈
    + I) A/ O9 `1 g( q
  3. 008A13F1  mov         ebp,esp //ebp指向先前的ebp,到达图4的状态
    : N- Y% z; ~: K/ ]) U$ b
  4. 008A13F3  sub         esp,0E4h //为局部变量分配0E4字节的空间,到达图5的状态+ b; J& W. b3 v
  5. 008A13F9  push        ebx //压入EBX
    * o, `: x' C% K5 y
  6. 008A13FA  push        esi //压入ESI
    0 D* P4 b  y% G/ j
  7. 008A13FB  push        edi //压入EDI,到达图7的状态
    ) B5 i3 {( e+ n1 R- y
  8. 008A13FC  lea         edi,[ebp-0E4h] //以下4行把局部变量区初始化为每个字节都等于cch5 s5 u8 g$ _4 a/ q' F" u4 O, f% D
  9. 008A1402  mov         ecx,39h
    / ]# }! _3 O% S5 R1 a: V
  10. 008A1407  mov         eax,0CCCCCCCCh
    2 B' k+ M( {5 `: k$ ~) j& d
  11. 008A140C  rep stos    dword ptr es:[edi]0 b1 v4 l1 P1 T. d4 K' c0 r
  12. ...... //省略代码执行N行
    / t1 L! G( x4 B  V
  13. ......
    $ @9 j+ W) d$ f
  14. 008A1436  pop         edi //恢复EDI7 v* f6 Y1 J4 y* U! I
  15. 008A1437  pop         esi //恢复ESI
    ' v3 u, z/ H4 U
  16. 008A1438  pop         ebx //恢复EBX) Y) g7 ]3 f5 _" Y
  17. 008A1439  add         esp,0E4h //回收局部变量地址空间/ f& w) s3 c. z; K: T* j; ~
  18. 008A143F  cmp         ebp,esp //以下3行为Runtime Checking,检查ESP和EBP是否一致
    4 z$ ]( d" x* {5 T5 L* Z$ C
  19. 008A1441  call        @ILT+330(__RTC_CheckEsp) (8A114Fh)
    8 J0 J4 G" w  }/ |5 i0 x# P
  20. 008A1446  mov         esp,ebp
    4 S& t; E- X0 C& G3 f5 X! r1 r
  21. 008A1448  pop         ebp //恢复EBP2 W4 e8 @/ y3 @) }
  22. 008A1449  ret //弹出函数返回地址,跳转到函数返回地址执行                                            //(__cdecl调用约定,Callee未清理参数)
复制代码
5 T; K* [8 I. z1 @5 D: p
收藏 评论0 发布时间:2020-12-17 22:37

举报

0个回答

所属标签

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