【BSP开发经验】用户态栈回溯技术

前言

在内核中有一个非常好用的函数dump_stack, 该函数在我们调试内核的过程中可以打印出函数调用关系,该函数可以帮助我们进行内核调试,以及让我们了解内核的调用关系。同时当内核发生崩溃的时候就会自己将自己的调用栈输出到串口。 栈回溯非常有利于我们进行问题定位与代码跟踪。

在用户态如果想要展现出函数的调用栈,我们通常就需要使用gdb工具。在调试的时候可以使用gdb进行单步调试并显示栈。或者在程序崩溃的时候产生转储文件,再通过gdb进行分析崩溃时的程序堆栈。但是这样的工具似乎并不能完全替代dump_stack函数的作用。比如说通过dump_stack可以清晰的了解到一个函数是被从哪些地方进行的调用,以及通过dump_stack可以在一些错误的位置打印调用信息。

C库backtrace使用

#include <execinfo.h>
#include <stdio.h>
#include <stdlib.h>
#define BT_BUF_SIZE 100
void print_backtrace() {void *bt_buffer[BT_BUF_SIZE];int bt_size = backtrace(bt_buffer, BT_BUF_SIZE);char **bt_strings = backtrace_symbols(bt_buffer, bt_size);printf("backtrace:\n");for (int i = 0; i < bt_size; i++) {printf("%x %s\n", bt_strings[i]);}free(bt_strings);
}
int func_c() {print_backtrace();return 0;
}
int func_b() {return func_c();
}
int func_a() {return func_b();
}
int main() {return func_a();
}

上面是使用C库 backtrace进行栈回溯的例程,我们可以发现使用C库中的backtrace理论上可以轻松实现栈回溯功能。

在这里插入图片描述

但是嵌入式编译器往往对于这个接口的支持非常弱,很多情况下使用这个接口编译器是不支持的,就算支持很多时候是得不到函数的调用栈的,所以我们需要自己实现函数backtrace的功能。

ARM64 栈回溯实现

arm64的backtrace实现是最简单的,因为arm64 支持FP,且寄存器信息被存储于栈顶位置并且栈的结构非常固定。

arm64寄存器

下面是Arm64程序调用标准规定的通用寄存器的使用方法。

参数寄存器(X0-X7)函数参数数量小于等于8个时,使用X0-X7传递,大于8个时,多余的使用栈传递,函数返回时返回值保存在X0中。

调用者保存的临时寄存器(X9-X15) 调用者若使用到了X9-X15寄存器,在调用子函数之前,需要将X9-X15寄存器保存到自己的栈中,子函数使用这些寄存器的时候不需要保存和恢复。

被调用者保存的寄存器(X19-X29) 被调用者若使用到这些寄存器,需要将其保存到自己的栈中,返回时从栈中恢复。
特殊用途的寄存器

X8是间接结果寄存器。用于传递间接结果的地址位置,例如,函数返回一个大结构。

X16-X17过程内调用暂存寄存器。。

X18平台寄存器。

X29是栈帧(FP)寄存器。保存了调用函数的栈帧地址。

X30保存了返回地址(LR)。函数返回后跳转到该地址处运行。

arm64栈结构

在这里插入图片描述

arm64调用规则

实例代码:

nt func3()
{anycall_dump_stack();return 0;
}void func2()
{func3();
}void func1()
{func2();
}
int main()
{func1();
}

下图是main汇编代码

0000000000400804 <func3>:400804:	a9bf7bfd 	stp	x29, x30, [sp, #-16]!400808:	910003fd 	mov	x29, sp40080c:	97ffffc1 	bl	400710 <anycall_dump_stack@plt>400810:	52800000 	mov	w0, #0x0                   	// #0400814:	a8c17bfd 	ldp	x29, x30, [sp], #16400818:	d65f03c0 	ret000000000040081c <func2>:40081c:	a9bf7bfd 	stp	x29, x30, [sp, #-16]!400820:	910003fd 	mov	x29, sp400824:	97fffff8 	bl	400804 <func3>400828:	d503201f 	nop40082c:	a8c17bfd 	ldp	x29, x30, [sp], #16400830:	d65f03c0 	ret0000000000400834 <func1>:400834:	a9bf7bfd 	stp	x29, x30, [sp, #-16]!400838:	910003fd 	mov	x29, sp40083c:	97fffff8 	bl	40081c <func2>400840:	d503201f 	nop400844:	a8c17bfd 	ldp	x29, x30, [sp], #16400848:	d65f03c0 	ret000000000040084c <main>:40084c:	a9bf7bfd 	stp	x29, x30, [sp, #-16]!400850:	910003fd 	mov	x29, sp400854:	97fffff8 	bl	400834 <func1>400858:	52800000 	mov	w0, #0x0                   	// #040085c:	a8c17bfd 	ldp	x29, x30, [sp], #16400860:	d65f03c0 	ret

主要查看main函数的入口位置,函数的入口最早做的就是对函数跳转的现场进行保存:

40084c: a9bf7bfd stp x29, x30, [sp, #-16]!

这一行表示把上一个函数的FP和LR寄存器push保存到sp-16的位置上,并且对sp地址-16操作,也就是说对于 main 函数预留了16 bytes的堆栈空间进行使用。

400850: 910003fd mov x29, sp

第二行,表示更新main函数使用的堆栈帧地址到FP中。这样通过FP寄存器我们可以在后续调用中对main函数的栈帧再进行保存。参考后面调用func1函数的操作。

400854: 97fffff8 bl 400834 <func1>
这一步会执行跳转操作,同时会把返回地址更新到LR寄存器。

在FUNC1 子函数中,我们看到依然是同样的套路,第一步会先把FP和LR寄存器保存到堆栈中:

400834: a9bf7bfd stp x29, x30, [sp, #-16]!
这一行就把main函数使用的FP和LR寄存器保存到堆栈中了,并且对SP寄存器地址-16,含义就是预留了16 bytes的堆栈空间给func1使用。再接着看该函数的最后返回:

400844: a8c17bfd ldp x29, x30, [sp], #16
这里把上一级main函数使用的FP和LR从堆栈中恢复出来了。同时对sp寄存器执行+16操作,从而恢复上一级函数的堆栈指针现场,然后调用ret操作:

400848: d65f03c0 ret
这一行会自动把LR寄存器保存的地址赋值给PC,也就因此跳转回main函数继续运行。

arm64栈回溯方式

所以 arm64的栈回溯其实只需要不断对FP进行解引用,分别得到每一个栈帧的起始地址,然后就可以得到每一个栈中保存的函数返回地址与下一个栈帧地址。

代码大致如下:

在这里插入图片描述

实现效果

在这里插入图片描述

ARM 栈回溯实现

相对于ARM64 arm实现栈回溯要困难一些,因为arm的寄存器直接存储在栈底,需要借助FP去寻找到每一个栈底。

arm寄存器

arm栈结构

Arm 处理器总共有 37 个寄存器,其可以分为以下 2 类:

  1. 通用寄存器( 31 个)
    1. 不分组寄存器( R0 — R7 ),共 8 个。
    2. 分组寄存器( R8 — R14 )共22个(R8-R12,五个,一共52=10,R13-14,两个,一共是216=12,总共10+12=22个)
    3. PC 指针( R15 ),共1个
  2. 程序状态寄存器( 6个 )
    1. CPSR( 1个 )
    2. SPSR( 5个 )
      在这里插入图片描述

arm调用规则

想要比较容易的在arm中实现栈回溯需要在编译的是时候添加-mapcs -marm参数来保证 编译器编出按照固定规则入栈的代码。

000106e8 <func3>:106e8:	e1a0c00d 	mov	ip, sp106ec:	e92dd800 	push	{fp, ip, lr, pc}106f0:	e24cb004 	sub	fp, ip, #4106f4:	ebffffc0 	bl	105fc <anycall_dump_stack@plt>106f8:	e3a03000 	mov	r3, #0106fc:	e1a00003 	mov	r0, r310700:	e89da800 	ldm	sp, {fp, sp, pc}00010704 <func2>:10704:	e1a0c00d 	mov	ip, sp10708:	e92dd800 	push	{fp, ip, lr, pc}1070c:	e24cb004 	sub	fp, ip, #410710:	ebfffff4 	bl	106e8 <func3>10714:	e320f000 	nop	{0}10718:	e89da800 	ldm	sp, {fp, sp, pc}0001071c <func1>:1071c:	e1a0c00d 	mov	ip, sp10720:	e92dd800 	push	{fp, ip, lr, pc}10724:	e24cb004 	sub	fp, ip, #410728:	ebfffff5 	bl	10704 <func2>1072c:	e320f000 	nop	{0}10730:	e89da800 	ldm	sp, {fp, sp, pc}00010734 <main>:10734:	e1a0c00d 	mov	ip, sp10738:	e92dd800 	push	{fp, ip, lr, pc}1073c:	e24cb004 	sub	fp, ip, #410740:	ebfffff5 	bl	1071c <func1>10744:	e3a03000 	mov	r3, #010748:	e1a00003 	mov	r0, r31074c:	e89da800 	ldm	sp, {fp, sp, pc}

再添加-mapcs之后所有的入栈都将按照

   10734:	e1a0c00d 	mov	ip, sp10738:	e92dd800 	push	{fp, ip, lr, pc}

arm栈回溯实现

在这里插入图片描述

在这里插入图片描述

实现效果

在这里插入图片描述

MIPS 栈回溯实现

MIPS栈回溯相比于ARM与ARM64则更为复杂。因为MIPS平台,FP指针默认指向栈顶,而返回地址存在了栈底,所以说需要使用其他方法进行栈回溯。

MIPS寄存器

在这里插入图片描述

v0, v1: 用做函数调用的返回值。当这两个寄存器不够存放返回值时,就需要使用堆栈,调用者在堆栈里分配一个匿名的结构,设置一个指向该参数的指针,返回时v0指向这个对应的结构(由编译器自动完成)。

a0- a3: 用来传递前四个参数给子程序,不够的用堆栈。a0-a3和v0-v1以及ra一起来支持子程序/过程调用,分别用以传递参数,返回结果和存放返回地址。当需要使用更多的寄存器时,就需要使用堆栈,MIPS编译器总是为参数在堆栈中留有空间以防有参数需要存储。

fp: 不同的编译器对此寄存器的解释不同,GNU MIPS C编译器使用其作为帧指针,指向堆栈里的过程帧(一个子函数)的第一个字,子函数可以用其做一个偏移访问栈帧里的局部变量,sp也可以较为灵活的移动,因为在函数退出之前使用fp来恢复。

MIPS调用规则

在这里插入图片描述

如图 描述的是一种典型的(MIPS O32)嵌入式芯片的Stack Frame组织方式。在这张图中,计算机的栈空间采用的是向下增长的方式(MIPS架构没有专门入栈和出栈指令,栈的增长方向不定,可能是高地址向低地址增长,或是相反),SP(stack pointer)就是当前函数的栈指针,它指向的是栈底的位置。Current Frame所示即为当前函数(被调用者)的Frame,Caller’s Frame是当前函数的调用者的Frame 。
在没有BP(base pointer)寄存器的目标架构中,进入一个函数时需要将当前栈指针向下移动n字节,这个大小为n字节的存储空间就是此函数的Stack Frame的存储区域。此后栈指针便不再移动(在Linux内核代码TODO里面写着要加上在函数内部调整栈的考虑 – 虽然这通常不会发生),只能在函数返回时再将栈指针加上这个偏移量恢复栈现场。由于不能随便移动栈指针,所以寄存器压栈和出栈都必须指定偏移量,这与x86架构的计算机对栈的使用方式有着明显的不同。
RISC计算机一般借助于一个返回地址寄存器RA(return address)来实现函数的返回。几乎在每个函数调用中都会使用到这个寄存器,所以在很多情况下RA寄存器会被保存在堆栈上以避免被后面的函数调用修改,当函数需要返回时,从堆栈上取回RA然后跳转。移动SP和保存寄存器的动作一般处在函数的开头,叫做Function Prologue;
注意如果当前函数是叶子函数(不存在对其它函数的调用,就不保存ra寄存器,反之就保存)。恢复这些寄存器状态的动作一般放在函数的最后,叫做Function Epilogue。

我们可以看一下mips平台的反汇编代码:

004012fc <func3>:4012fc:	27bdffe0 	addiu	sp,sp,-32401300:	afbf001c 	sw	ra,28(sp)401304:	afbe0018 	sw	s8,24(sp)401308:	03a0f021 	move	s8,sp40130c:	0c100479 	jal	4011e4 <anycall_dump_stack>401310:	00000000 	nop401314:	0000c021 	move	t8,zero401318:	03001021 	move	v0,t840131c:	03c0e821 	move	sp,s8401320:	8fbf001c 	lw	ra,28(sp)401324:	8fbe0018 	lw	s8,24(sp)401328:	27bd0020 	addiu	sp,sp,3240132c:	03e00008 	jr	ra401330:	00000000 	nop00401334 <func2>:401334:	27bdffe0 	addiu	sp,sp,-32401338:	afbf001c 	sw	ra,28(sp)40133c:	afbe0018 	sw	s8,24(sp)401340:	03a0f021 	move	s8,sp401344:	0c1004bf 	jal	4012fc <func3>401348:	00000000 	nop40134c:	03c0e821 	move	sp,s8401350:	8fbf001c 	lw	ra,28(sp)401354:	8fbe0018 	lw	s8,24(sp)401358:	27bd0020 	addiu	sp,sp,3240135c:	03e00008 	jr	ra401360:	00000000 	nop00401364 <func1>:401364:	27bdffe0 	addiu	sp,sp,-32401368:	afbf001c 	sw	ra,28(sp)40136c:	afbe0018 	sw	s8,24(sp)401370:	03a0f021 	move	s8,sp401374:	0c1004cd 	jal	401334 <func2>401378:	00000000 	nop40137c:	03c0e821 	move	sp,s8401380:	8fbf001c 	lw	ra,28(sp)401384:	8fbe0018 	lw	s8,24(sp)401388:	27bd0020 	addiu	sp,sp,3240138c:	03e00008 	jr	ra401390:	00000000 	nop00401394 <main>:401394:	27bdffe0 	addiu	sp,sp,-32401398:	afbf001c 	sw	ra,28(sp)40139c:	afbe0018 	sw	s8,24(sp)4013a0:	03a0f021 	move	s8,sp4013a4:	0c1004d9 	jal	401364 <func1>4013a8:	00000000 	nop4013ac:	03001021 	move	v0,t84013b0:	03c0e821 	move	sp,s84013b4:	8fbf001c 	lw	ra,28(sp)4013b8:	8fbe0018 	lw	s8,24(sp)4013bc:	27bd0020 	addiu	sp,sp,324013c0:	03e00008 	jr	ra4013c4:	00000000 	nop

我们可以看出函数调用都是先使用addiu sp,sp xxx开辟栈,然后将使用 sw ra,xxx压栈保存在栈底。

MIPS栈回溯实现

在MIPS平台开始栈回溯的时候,我们可以获取的信息有寄存器SP,PC,RA的内容,使用PC和RA我们可以得到当前函数和上一级函数地址,问题在于怎样通过SP寻找到上一级函数的栈,在没有直接获取栈地址的方法的情况下需要通过进行代码分析来实现。

在这里插入图片描述

004012fc <func3>:4012fc:	27bdffe0 	addiu	sp,sp,-32401300:	afbf001c 	sw	ra,28(sp)401304:	afbe0018 	sw	s8,24(sp)401308:	03a0f021 	move	s8,sp40130c:	0c100479 	jal	4011e4 <anycall_dump_stack>401310:	00000000 	nop401314:	0000c021 	move	t8,zero401318:	03001021 	move	v0,t840131c:	03c0e821 	move	sp,s8401320:	8fbf001c 	lw	ra,28(sp)401324:	8fbe0018 	lw	s8,24(sp)401328:	27bd0020 	addiu	sp,sp,3240132c:	03e00008 	jr	ra401330:	00000000 	nop
  1. anycall_dump_stack 获取sp,ra寄存器地址,其中ra指向func3的0x401314。
  2. 从func3的返回地址(0x401314)开始向上进行命令查找,在0x401300的位置可以查找到ra寄存器入栈指令sw ra,xxx(0xafbf),取出立即数作为raoffset,其为返回地址在栈空间中的偏移。
  3. 继续向上在0x4012fc查找到开辟栈空间的指令addiu sp,sp,-32(0x27bd),去除立即数 stacksize 即为func3的栈空间大小。
  4. 如此ra=sp[raoffset/sizeof(long))] 就可以获取到func1的返回地址,即func2中的0x40134c。
  5. 然后nsp=sp+stacksize,可得func2的栈顶。
  6. 如此可继续向上回溯。

实现效果

在这里插入图片描述

对外接口

int anycall_backtrace(void **array, int size)

获取从当前函数开始的回溯结果保存于array,最大深度size。

char ** anycall_backtrace_symbols(void *const *array, int size)

解析array,并返回符号信息。

int anycall_dump_stack(void)

打印从当前位置开始的堆栈信息

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/13665.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

溪谷联运SDK功能全面解析

近期&#xff0c;备受用户关注的手游联运10.0.0版本上线了&#xff0c;不少用户也选择了版本更新&#xff0c;其中也再次迎来了SDK的更新。溪谷软件和大家一起盘点一下溪谷SDK的功能都有哪些吧。 一、溪谷SDK具有完整的运营功能和高度扩展性 1.登录&#xff1a;登录是SDK最基础…

物体检测算法-R-CNN,SSD,YOLO

物体检测算法-R-CNN&#xff0c;SSD&#xff0c;YOLO 1 R-CNN2 SSD3 Yolo总结 1 R-CNN R-CNN&#xff08;Region-based Convolutional Neural Network&#xff09;是一种基于区域的卷积神经网络&#xff0c;是第一个成功将深度学习应用到目标检测上的算法。它主要由三个步骤组…

LeetCode 131题详解:高效分割回文串的递归与动态规划方法

❤️❤️❤️ 欢迎来到我的博客。希望您能在这里找到既有价值又有趣的内容&#xff0c;和我一起探索、学习和成长。欢迎评论区畅所欲言、享受知识的乐趣&#xff01; 推荐&#xff1a;数据分析螺丝钉的首页 格物致知 终身学习 期待您的关注 导航&#xff1a; LeetCode解锁100…

关于如何创建一个可配置的 SpringBoot Web 项目的全局异常处理

前情概要 这个问题其实困扰了我一周时间&#xff0c;一周都在 Google 上旅游&#xff0c;我要如何动态的设置 RestControllerAdvice 里面的 basePackages 以及 baseClasses 的值呢&#xff1f;经过一周的时间寻求无果之后打算决定放弃的我终于找到了一些关键的线索。 当然在此…

为什么我用save保存更新,数据库不更新,反而新增一条

今天发现一个奇怪的问题&#xff1a; 为什么我用save保存更新的数据后&#xff0c;数据库不更新&#xff0c;但是增加了一条空数据&#xff0c;我的前台也把数据用json传上去了&#xff0c;也成功了&#xff0c;但是数据库没有更新相应行的数据&#xff0c;而是新增了一条数据&…

实现顺序表各种基本运算的算法

实验一&#xff1a;实现顺序表各种基本运算的算法 一、实验目的与要求 目的: 领会顺序表存储结构和掌握顺序表中各种基本运算算法设计。 内容: 编写一个程序sqlist.cpp,实现顺序表的各种基本运算和整体建表算法(假设顺序表的元素类型ElemType为char),并在此基础上设计一个…

计组期末必考大题

一.寻址方式详解 1.直接寻址 指令地址码直接给到操作数所在的存储单元地址 2.间接寻址 A为操作数EA的地址 3.寄存寻址 4.寄存器间接寻址 5.变址寻址 6.基地址寻址 7.小结 二、指令周期详解 一、基本概念 指令周期:去除指令并执行指令所需要的时间指令周期:由若干个CPU周…

C++/ cuda kernel中的模版元编程识别 kernel 模版的数据类型

1&#xff0c;模版元编程 模板元编程是一种利用 C 模板系统在编译时进行计算和生成代码的技术。其原理基于模板特化、递归、模板参数推导等特性&#xff0c;通过模板实例化和展开&#xff0c;在编译时生成代码&#xff0c;以实现在编译期间进行复杂计算和代码生成的目的。 2&am…

前端笔记-day07

学成在线网站 文章目录 效果图代码展示index.htmlindex.cssbase.css 效果图 代码展示 index.html <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-w…

键盘盲打是练出来的

键盘盲打是练出来的&#xff0c;那该如何练习呢&#xff1f;很简单&#xff0c;看着屏幕提示跟着练。屏幕上哪里有提示呢&#xff1f;请看我的截屏&#xff1a; 截屏下方有8个带字母的方块按钮&#xff0c;这个就是提示&#xff0c;也就是我们常说的8个基准键位&#xff0c;我…

spring boot多模块项目中父项目与子项目的连接

如题&#xff0c;spring boot多模块项目中&#xff0c;父项目在本级的pom.xml中&#xff0c;引入子项目&#xff0c;类似代码如下&#xff1a; ruoyi-modules/pom.xml&#xff1a; <modules><module>ruoyi-system</module><module>ruoyi-gen</modu…

【linux】详解vim编辑器

基本指令 【linux】详解linux基本指令-CSDN博客 【linux】详解linux基本指令-CSDN博客 vim的基本概念 vim有很多模式&#xff0c;小编只介绍三种就能让大家玩转vim了&#xff0c; 分别是&#xff1a; 正常/普通/命令模式 插入模式 末行/底行模式 命令模式 控制屏幕光标的…

【C++初阶】--- C++入门(上)

目录 一、C的背景及简要介绍1.1 什么是C1.2 C发展史1.3 C的重要性 二、C关键字三、命名空间2.1 命名空间定义2.2 命名空间使用 四、C输入 & 输出 一、C的背景及简要介绍 1.1 什么是C C语言是结构化和模块化的语言&#xff0c;适合处理较小规模的程序。对于复杂的问题&…

Excel 下划线转驼峰

Excel 下划线转驼峰 LOWER(LEFT(SUBSTITUTE(PROER(A1),"_",""),1))&RIGHT(SUBSTITUTE(PROPER(A1),"_",""),LEN(SUBSTITUTE(PROPER(A1),"_",""))-1)

微博:一季度运营利润9.11亿元,经营效率持续提升

5月23日&#xff0c;微博发布2024年第一季度财报。一季度微博总营收3.955亿美元&#xff0c;约合28.44亿元人民币&#xff0c;超华尔街预期。其中&#xff0c;广告营收达到3.39亿美元&#xff0c;约合24.39亿元人民币。一季度调整后运营利润达到1.258亿美元&#xff0c;约合9.1…

【论文极速读】 LLava: 指令跟随的多模态大语言模型

【论文极速读】 LLava: 指令跟随的多模态大语言模型 FesianXu 20240331 at Tencent WeChat Search Team 前言 如何将已预训练好的大规模语言模型&#xff08;LLM&#xff09;和多模态模型&#xff08;如CLIP&#xff09;进行融合&#xff0c;形成一个多模态大语言模型&#xf…

【MATLAB】基于EMD-PCA-LSTM的回归预测模型

有意向获取代码&#xff0c;请转文末观看代码获取方式~ 1 基本定义 基于EMD-PCA-LSTM的回归预测模型是一种结合了经验模态分解&#xff08;Empirical Mode Decomposition, EMD&#xff09;、主成分分析&#xff08;Principal Component Analysis, PCA&#xff09;和长短期记忆…

redis集群不允许操作多个key解决方案、redis key负载均衡方案

前提 在cluster redis 中进行同一个命令处理不同的key会报错:CROSSSLOT Keys in request dont hash to the same slot,例如: 此示例使用sdiff 命令对pool_1与pool_2进行diff操作。 那么我们在业务场景中就需要将集群redis中的不同key进行操作,我们该如何处理呢? 本次的…

CSS单行、同行文本左右对齐

再项目需求中&#xff0c;UI小姐姐常常要考虑项目的排版样式更简洁高级&#xff0c;常常会在项目设置内容或者字体两端对齐的效果&#xff0c;比如&#xff0c;在做表单时我们经常遇到让上下两个字段对齐的情况&#xff0c;比如姓名&#xff0c; 手机号码&#xff0c; 出生地等…

0406 组合放大电路

组合放大电路 共射-共基放大电路共集-共集放大电路 4.6.1 共射—共基放大电路 4.6.2 共集—共集放大电路 共射-共基放大电路 共集-共集放大电路 (a) 原理图 (b)交流通路 T1、T2构成复合管&#xff0c;可等效为一个NPN管