gcc/linux下的c++异常实现

概述

本文不一定具有很好的说教性,仅作为自我学习的笔记。不妨可参阅国外大神博文C++ exceptions under the hood链接中包含了大量的例子。

偶有在对ELF做分析的时候看到如下图一些注释,部分关键字看不懂,比如什么FDE, unwind , __gxx_personality_v0,__cxa_end_catch ,__cxa_start_catch等是什么?
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
由于笔者在Android开发领域,因此偏向ARM下做实现,部分源码有时候会参阅AOSP或者linux版本。

操作系统环境如下:
在这里插入图片描述

站在编译器角度学习

Itanium曾提出了相关异常规范,在很多GUN编译器都遵循相关ABI规范,具体可参阅:
Itanium C++ ABI: Exception Handling ($Revision: 1.22 $)

其中有两个术语要进行同步:

landing pad:异常处理中会进行两个操作,其中一个是运行资源回收代码,另一个是异常捕获处理代码。这两个代码块我们称为landing pad

personality routines:异常处理的核心函数,用于判断当前函数是否可以处理异常或者进行栈回溯操作(unwind)在我们c++中这个函数名字为 __gxx_personality_v0

个人理解如下:

//mythrow.cpp
#include <stdio.h>class MyClass
{
public:~MyClass(){}
};
class MyFakeExeception{};
class MyFakeExeception2{};void testThrow(int param)
{if (param == 1){throw MyFakeExeception();}else if (param == 2){throw MyFakeExeception2();}
}void myCleanFun(void *p)
{printf("myCleanFun invoke \r\n");
}
void testCatch(int param)
{printf("testCatch start \r\n");try{MyClass __attribute__((cleanup(myCleanFun))) mypro;//...// do thing with mypro ;testThrow(param);}catch (const MyFakeExeception &e){printf("testCatch catch MyFakeExeception\r\n");}catch (const MyFakeExeception2 &e){printf("testCatch catch MyFakeExeception2 \r\n");}catch (...){printf("testCatch catch ...\r\n");}printf("testCatch done \r\n");
}

我们以testCatch函数举例。在调用testThrow函数会触发异常那么调用到personality routines函数此时假设判断函数可以捕获异常,那么首先我们需要释放掉try函数块的资源,在本例中需要调用MyClass析构函数和myCleanFun函数,这部分代码便是一个landing pad,在处理完成后需要调用catch块也是一个landing pad

那么编译器是如何完成异常的识别和栈回溯(unwind)的呢?

我们在创建一个mymain.cpp文件使其可以完整运行。

//mymain.cpp
#include <stdio.h>
extern void testCatch(int param);
int main(int argc, char **args)
{testCatch(argc);return 0;
}
//编译成中间文件
g++ -O0 -ggdb -c  mythrow.cpp -o  mythrow.o
//编译成中间文件
g++ -O0 -ggdb -c  mymain.cpp -o  mymain.o
//链接成可执行文件
g++ -O0 -ggdb  mythrow.o mymain.o  -o  app

当我们运行app输出:

testCatch start 
myCleanFun invoke 
testCatch catch MyFakeExeception
testCatch done

当我们输出输出app文件的节头 其中有几个非常有趣的节区:

readelf -S -W app

输出:
在这里插入图片描述
.eh_frame_hdr.eh_frame 用于栈回溯使用。
.gcc_except_table 也被称为LSDA(Language Specific Data Area)节,这个节专门用于存储跟语言相关的特性,这里用于存储函数可以捕获哪些异常已经try catch的信息(可以理解为java字节码中异常表)。
有了这几个节我们便可以在异常时候进行栈回溯以及landing pad寻找。

栈回溯是什么

我们知道异常发生时候需要进行分支操作,即跳转别的地方处理异常,处理完成后跳转回正常业务流程代码。那么这个流程就需要进行上下文的恢复和存储等(注:栈回溯在正常的函数调用也是需要的而非局限于异常)。

使用如下命令可查看.eh_frame_hdr.eh_frame 存储的内容

readelf -wF app

在这里插入图片描述
.eh_frame_hdr.eh_frame里面就包含了被称为CIEFDE东西,可以辅助我们调试或者栈回溯的时候进行上下文恢复。
如果你有兴趣了解更多可以参阅(参考文献是x86架构不过不影响理解):
linux 栈回溯(x86_64 )

另一个问题编译器是如何构造这个节的呢?
这是通过编译器的CFI directives,指令列表可参阅CFI-directives

我们使用下列命令仅进行汇编

g++ -O0 -ggdb -S mythrow.cpp

在这里插入图片描述

调用throw会发生什么

我们继续如法炮制查看对应的汇编函数

g++ -O0 -ggdb -S mythrow.cpp

我们查看testThrow函数对应的汇编

_Z9testThrowi:
.LFB3:.loc 1 19 1.cfi_startprocstp	x29, x30, [sp, -32]!.cfi_def_cfa_offset 32.cfi_offset 29, -32.cfi_offset 30, -24mov	x29, spstr	w0, [sp, 28].loc 1 20 5ldr	w0, [sp, 28]cmp	w0, 1bne	.L3.loc 1 22 32mov	x0, 1bl	__cxa_allocate_exceptionmov	x3, x0mov	x2, 0adrp	x0, _ZTI16MyFakeExeceptionadd	x1, x0, :lo12:_ZTI16MyFakeExeceptionmov	x0, x3bl	__cxa_throw
.L3:.loc 1 24 10ldr	w0, [sp, 28]cmp	w0, 2bne	.L5.loc 1 26 33mov	x0, 1bl	__cxa_allocate_exceptionmov	x3, x0mov	x2, 0adrp	x0, _ZTI17MyFakeExeception2add	x1, x0, :lo12:_ZTI17MyFakeExeception2mov	x0, x3bl	__cxa_throw
.L5:.loc 1 28 1nopldp	x29, x30, [sp], 32.cfi_restore 30.cfi_restore 29.cfi_def_cfa_offset 0ret.cfi_endproc

注:testThrow函数经过c++编译器名称粉碎(name mangling)之后变为_Z9testThrowi
上面的汇编的代码可以看到我们可以知道对于c++的每个throw关键字,会生成一对__cxa_allocate_exception__cxa_throw函数的调用。
__cxa_allocate_exception函数用于分配异常对象创建,而__cxa_throw就是完成异常抛出给personality routines
我们在IDA PRO查看这两个函数发现都在plt节中
在这里插入图片描述
在这里插入图片描述
所以这两个函数在SO中被动态加载,使用ldd查看所依赖的so。

 ldd app

在这里插入图片描述
这里函数具体位于libstdc++.so.6

 nm -D /lib/aarch64-linux-gnu/libstdc++.so.6 | grep -i "__cxa_allocate_exception"nm -D /lib/aarch64-linux-gnu/libstdc++.so.6 | grep -i "__cxa_throw"

在这里插入图片描述
在这里插入图片描述
当然我们最重要的 __gxx_personality_v0也在这个库里面。
在这里插入图片描述
我查看linux源码的时候可以对应如下的源码

void
__cxa_throw(void *thrown_object, std::type_info *tinfo, void (_LIBCXXABI_DTOR_FUNC *dest)(void *)) {//..._Unwind_RaiseException(&exception_header->unwindHeader);//...//如果没找到可以处理异常landing pad就结束程序failed_throw(exception_header);
}

我们重点下_Unwind_RaiseException做了什么

_Unwind_Reason_Code LIBGCC2_UNWIND_ATTRIBUTE
_Unwind_RaiseException(struct _Unwind_Exception *exc)
{struct _Unwind_Context this_context, cur_context;_Unwind_Reason_Code code;unsigned long frames;/* Set up this_context to describe the current stack frame.  */uw_init_context (&this_context);cur_context = this_context;/* Phase 1: Search.  Unwind the stack, calling the personality routinewith the _UA_SEARCH_PHASE flag set.  Do not modify the stack yet.  */while (1){_Unwind_FrameState fs;/* Set up fs to describe the FDE for the caller of cur_context.  Thefirst time through the loop, that means __cxa_throw.  */code = uw_frame_state_for (&cur_context, &fs);if (code == _URC_END_OF_STACK)/* Hit end of stack with no handler found.  */return _URC_END_OF_STACK;if (code != _URC_NO_REASON)/* Some error encountered.  Usually the unwinder doesn'tdiagnose these and merely crashes.  */return _URC_FATAL_PHASE1_ERROR;/* Unwind successful.  Run the personality routine, if any.  */if (fs.personality){code = (*fs.personality) (1, _UA_SEARCH_PHASE, exc->exception_class,exc, &cur_context);if (code == _URC_HANDLER_FOUND)break;else if (code != _URC_CONTINUE_UNWIND)return _URC_FATAL_PHASE1_ERROR;}/* Update cur_context to describe the same frame as fs.  */uw_update_context (&cur_context, &fs);}/* Indicate to _Unwind_Resume and associated subroutines that thisis not a forced unwind.  Further, note where we found a handler.  */exc->private_1 = 0;exc->private_2 = uw_identify_context (&cur_context);cur_context = this_context;code = _Unwind_RaiseException_Phase2 (exc, &cur_context, &frames);if (code != _URC_INSTALL_CONTEXT)return code;uw_install_context (&this_context, &cur_context, frames);
}

上面的代码将异常处理分为两个阶段:搜索阶段和回溯处理阶段。
在搜索阶段借助.eh_frame_hdr,.eh_frame以及.gcc_except_table 判断调用栈是否存在一个处理异常的调用__gxx_personality_v0判断。如果没有就返回到上一级函数__cxa_throw结束程序。
如果找到调用_Unwind_RaiseException_Phase2设置好上下文进行栈回溯的流程直到到异常处理块(期间会不断释放栈资源)。

我们使用gdb做一个有趣的实验查看对应的调用栈,

//给个性函数设置断点
(gdb) break  __gxx_personality_v0
(gdb) r
(gdb) bt

输出如下
在这里插入图片描述
可见和我们猜测差不多。

调用try catch会发生什么

如法炮制查看testCatch函数汇编

g++ -O0 -ggdb -S mythrow.cpp

对于没有耐心的同学可以跳过这段代码看结论

_Z9testCatchi:
.LFB5:.loc 1 35 1.cfi_startproc.cfi_personality 0x9b,DW.ref.__gxx_personality_v0.cfi_lsda 0x1b,.LLSDA5stp	x29, x30, [sp, -80]!.cfi_def_cfa_offset 80.cfi_offset 29, -80.cfi_offset 30, -72mov	x29, spstp	x19, x20, [sp, 16].cfi_offset 19, -64.cfi_offset 20, -56str	w0, [sp, 44].loc 1 35 1adrp	x0, :got:__stack_chk_guardldr	x0, [x0, #:got_lo12:__stack_chk_guard]ldr	x1, [x0]str	x1, [sp, 72]mov	x1, 0.loc 1 36 11adrp	x0, .LC1add	x0, x0, :lo12:.LC1
.LEHB0:bl	puts
.LEHE0:
.LBB2:.loc 1 43 18ldr	w0, [sp, 44]
.LEHB1:bl	_Z9testThrowi
.LEHE1:.loc 1 40 54add	x0, sp, 48
.LEHB2:bl	_Z10myCleanFunPv
.LEHE2:.loc 1 40 54 is_stmt 0 discriminator 1add	x0, sp, 48bl	_ZN7MyClassD1Ev
.L13:
.LBE2:.loc 1 57 11 is_stmt 1adrp	x0, .LC2add	x0, x0, :lo12:.LC2
.LEHB3:bl	puts
.LEHE3:.loc 1 58 1nopadrp	x0, :got:__stack_chk_guardldr	x0, [x0, #:got_lo12:__stack_chk_guard]ldr	x2, [sp, 72]ldr	x1, [x0]subs	x2, x2, x1mov	x1, 0beq	.L17b	.L23
.L18:
.LBB3:.loc 1 40 54mov	x20, x0mov	x19, x1add	x0, sp, 48bl	_Z10myCleanFunPvadd	x0, sp, 48bl	_ZN7MyClassD1Evmov	x0, x20mov	x1, x19b	.L9
.L19:
.L9:
.LBE3:.loc 1 45 5cmp	x1, 1beq	.L10cmp	x1, 2beq	.L11b	.L24
.L10:
.LBB4:.loc 1 45 36 discriminator 1bl	__cxa_begin_catchstr	x0, [sp, 64].loc 1 47 15 discriminator 1adrp	x0, .LC3add	x0, x0, :lo12:.LC3
.LEHB4:bl	puts
.LEHE4:.loc 1 48 5bl	__cxa_end_catchb	.L13
.L11:
.LBE4:
.LBB5:.loc 1 49 37bl	__cxa_begin_catchstr	x0, [sp, 56].loc 1 51 15adrp	x0, .LC4add	x0, x0, :lo12:.LC4
.LEHB5:bl	puts
.LEHE5:.loc 1 52 5bl	__cxa_end_catchb	.L13
.L24:
.LBE5:.loc 1 53 12bl	__cxa_begin_catch.loc 1 55 15adrp	x0, .LC5add	x0, x0, :lo12:.LC5
.LEHB6:bl	puts
.LEHE6:
.LEHB7:.loc 1 56 5bl	__cxa_end_catchb	.L13
.L20:
.LBB6:.loc 1 48 5mov	x19, x0bl	__cxa_end_catchmov	x0, x19bl	_Unwind_Resume
.L21:
.LBE6:
.LBB7:.loc 1 52 5mov	x19, x0bl	__cxa_end_catchmov	x0, x19bl	_Unwind_Resume
.LEHE7:
.L22:
.LBE7:.loc 1 56 5mov	x19, x0bl	__cxa_end_catchmov	x0, x19
.LEHB8:bl	_Unwind_Resume
.LEHE8:
.L23:.loc 1 58 1bl	__stack_chk_fail
.L17:ldp	x19, x20, [sp, 16]ldp	x29, x30, [sp], 80.cfi_restore 30.cfi_restore 29.cfi_restore 19.cfi_restore 20.cfi_def_cfa_offset 0ret.cfi_endproc

每个catch会生成如下三个函数的调用:
__cxa_begin_catch
__cxa_end_catch
_Unwind_Resume

__cxa_begin_catch: 主要将异常对象放到栈上,方便异常处理代码使用。
__cxa_end_catch:异常代码运行后,进行异常资源清理操作
_Unwind_Resume:异常处理完成需要回到正常业务逻辑代码上。

我们查看Android下__cxa_begin_catch对应的源码

extern "C" void* __cxa_begin_catch(void* exc) {_Unwind_Exception *exception = static_cast<_Unwind_Exception*>(exc);__cxa_exception* header = reinterpret_cast<__cxa_exception*>(exception+1)-1;__cxa_eh_globals* globals = __cxa_get_globals();if (!isOurCxxException(exception->exception_class)) {if (globals->caughtExceptions) {fatalError("Can't handle non-C++ exception!");}}// Check rethrow flagheader->handlerCount = (header->handlerCount < 0) ?(-header->handlerCount+1) : (header->handlerCount+1);if (header != globals->caughtExceptions) {header->nextException = globals->caughtExceptions;globals->caughtExceptions = header;}globals->uncaughtExceptions -= 1;//返回异信息。然后会被放入栈上return header->adjustedPtr;}

相关源码可参阅cxxabi.cc

gcc_except_table相关

gcc_except_table也是在汇编文件生成指定数据

g++ -O0 -ggdb -S mythrow.cpp

当你阅读mythrow.s文件的时候会看到一个定义节directives指令

.section	.gcc_except_table,"a",@progbits.align	2
.LLSDA5:.byte	0xff.byte	0x9b.uleb128 .LLSDATT5-.LLSDATTD5
.LLSDATTD5:.byte	0x1.uleb128 .LLSDACSE5-.LLSDACSB5
.LLSDACSB5:.uleb128 .LEHB0-.LFB5.uleb128 .LEHE0-.LEHB0.uleb128 0.uleb128 0....

具体解析可以参阅其他文献。

使用nothrow会怎么样(或者noexcept)?
我们给抛出异常的函数添加不会抛出异常
在这里插入图片描述
未声明前相关区大小
在这里插入图片描述
声明后节区大小
在这里插入图片描述
运行后

testCatch start 
terminate called after throwing an instance of 'MyFakeExeception'
Aborted (core dumped)

声明不会抛出异常的函数会减少生成相关文件大小使其更紧凑,但需要注意代码是否会违背规则的情况存在。

参考文献:
C++ exceptions under the hood
c++ 异常处理(上)
c++ 异常处理(下)
Itanium C++ ABI: Exception Handling ($Revision: 1.22 $)
What is the “C++ ABI Specification” referred to in GCC’s manual?
gcc_except_table
CPP 异常处理机制初探
Unwind 栈回溯详解
Chapter 8. Exception Frames
Serial- / Socket IO and GCC nothrow attribute
Itanium C++ ABI
C++对象模型之RTTI的实现原理
Android cxxabi

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

如若内容造成侵权/违法违规/事实不符,请联系瞎胡闹网进行投诉反馈,一经查实,立即删除!

相关文章

万字长文解析AQS抽象同步器核心原理(深入阅读AQS源码)

AQS抽象同步器核心原理 在争用激烈的场景下使用基于CAS自旋实现的轻量级锁有两个大的问题&#xff1a; CAS恶性空自旋会浪费大量的CPU资源。在SMP架构的CPU上会导致“总线风暴”。 解决CAS恶性空自旋的有效方式之一是以空间换时间&#xff0c;较为常见的方案有两种&#xff…

哔哩哔哩 B站 bilibili 视频倍速设置 视频倍速可自定义

目录 一、复制如下代码 二、在B站视频播放页面进入控制台 三、将复制的代码粘贴到下方输入框&#xff0c;并 回车Enter 即可 四、然后就可以了 一、复制如下代码 &#xff08;该代码用于设置倍速为3&#xff0c;最后的数值是多少就是多少倍速&#xff0c;可以带小数点&#…

Kafka为什么这么快?

Kafka 是一个基于发布-订阅模式的消息系统&#xff0c;它可以在多个生产者和消费者之间传递大量的数据。Kafka 的一个显著特点是它的高吞吐率&#xff0c;即每秒可以处理百万级别的消息。那么 Kafka 是如何实现这样高得性能呢&#xff1f;本文将从七个方面来分析 Kafka 的速度优…

PCI控制器扫描PCI总线过程解析

1、PCI的三种地址空间介绍 地址空间描述命令说明I/O空间供给设备驱动程序使用I/O操作命令对设备对应的I/O地址空间进行访问&#xff0c;此类访问不可预取存储空间供给设备驱动程序使用memory操作命令对设备的Memory空间进行访问&#xff0c;其中Memory操作命令又可分为Prefech…

从PC传输文件到ipad

首先从PC上进入iTunes。剩下步骤如图所示。

【iPhone和iPad设备管理和文件传输工具】iMazing 2.9.9 for Mac

文章来源于&#xff1a;风云社区 iMazing 2.9.9 for Mac iMazing- 管理你的iPhone。获取可信赖的软件来传输和保存您的音乐&#xff0c;消息&#xff0c;文件和数据。安全备份任何iPhone&#xff0c;iPad或iPod touch。iMazing功能强大且用户友好&#xff0c;是Mac和PC的最佳…

ipad如何投屏到xbmc_询问操作技巧:在iPad上设置文件传输拖放区,XBMC,并使用控制台模拟器享受复古游戏...

ipad如何投屏到xbmc Once a week we round up some of the reader questions we’ve answered and share them with everyone; this week we’re taking a look at setting up file transfer drop zones, installing XBMC on the iPad, and setting up a console emulator. 每周…

iOS系统与Windows电脑如何做到无线传输文件?

iOS系统与Windows电脑传输文件&#xff0c;大多数人可能会选择使用微信或QQ的 “文件传输助手”&#xff0c;今天我教大家一种一分钟设置&#xff0c;便可永久传输的简单方法。快点赞收藏起来吧&#xff01;&#xff01; &#xff08;此处用iPad 第8代 2020为例子&#xff09;…

Nginx详解 第三部分:Nginx高级配置(附配置实例)

Part 3 一、网页的状态页二、Nginx第三方模块2.1 echo 模块 三、变量3.1 内置变量3.1.1 常用内置变量3.1.2 举个例子 3.2 自定义变量 四、自定义访问日志 (优化)4.1 自定义访问日志的格式4.2 自定义json 格式日志 五、Nginx压缩功能&#xff08;重要&#xff09;六、HTTPS 功能…

Java“牵手”天猫商品历史价格信息API接口数据,天猫API接口申请指南

天猫平台商品历史价格接口是开放平台提供的一种API接口&#xff0c;通过调用API接口&#xff0c;开发者可以获取天猫商品的标题、价格、库存、月销量、总销量、库存、详情描述、图片、最低价、当前价格、价格信息等详细信息 。 获取商品历史价格接口API是一种用于获取电商平台…

美国慌了,满世界找稀土替代却找不到,最终还是得求中国

中国先后对稀土、镓、锗等稀有金属材料的出口采取限制措施&#xff0c;美国一开始并不慌&#xff0c;毕竟全球还有美国自己、澳大利亚、蒙古等国家拥有稀土矿藏&#xff0c;因此美国以为可以迅速找到替代&#xff0c;然而如今却发现事情并不简单。 中国占有的稀土矿藏确实不算最…

家用计算机音效部件图示,唱吧新版自定义音效设置方法(附上最佳音效设置参数图)...

新版唱吧发布后&#xff0c;觉得唱吧设计的音效越来越专业 真的很爱这个版本 愿研发GG们继续研发更多更牛的音效&#xff01;我们这些唱粉也跟着一起进步。 除了界面大改版之外&#xff0c;录音功能也有大的改进喔&#xff01; 一眼望去&#xff0c;以前的音效又增加了若干固定…

Java的异常与错误

对比 Exception 和 Error&#xff0c;另外&#xff0c;运行时异常与一般异常有什么区别&#xff1f; Exception 和 Error 都是继承了 Throwable 类&#xff0c;在 Java 中只有 Throwable 类型的实例才可以被抛出&#xff08;throw&#xff09;或者捕获&#xff08;catch&#x…

渗透测试工具ZAP入门教程(1)-安装和快速开始

介绍 ZAP Zed Attack Proxy&#xff08;ZAP&#xff09;是一个免费的开源渗透测试工具&#xff0c;在 软件安全项目 &#xff08;SSP&#xff09;。ZAP 专为测试 Web 应用程序而设计&#xff0c;既灵活又可扩展。 ZAP的核心是所谓的“中间人代理”。它位于测试人员的浏览器和…

叁腾科技更新招股书:营收、净利双降,创始人鲁永祥有股权被冻结

日前&#xff0c;上海叁腾科技有限公司&#xff08;Sentage Holdings Inc.&#xff0c;下称“叁腾科技”&#xff09;向美国证券交易委员会&#xff08;SEC&#xff09;递交了更新后的招股书。招股书显示&#xff0c;叁腾科技拟在纳斯达克上市。 根据招股书&#xff0c;叁腾科…

任意系统升Windows10(microsoft官网)

打开https://www.microsoft.com/zh-cn/software-download/windows10 点击“立即下载工具” 下载好后点击“保留” 单击“MediaCreationTool20H2.exe”

竞逐新能源汽车续航,背靠广汽的巨湾技研能否打好“技术牌”?

续航、安全性能包括充电速度一直都是新能源汽车行业发展掣肘。 2020年以来&#xff0c;国内外主力电动乘用车续航普遍突破600公里&#xff0c;消费者续航焦虑逐步化解&#xff0c;但随之而来的是&#xff0c;需求侧对充电便捷性的考量&#xff0c;能否像传统车加油一样实现快速…

GD32E230C8T6_OTA升级

GD32E230C8T6_OTA升级 运行环境简介程序的起始地址进行分区总体流程图Bootloader 程序Bootloader 编译设置APP 分区部分APP 编译设置重点步骤 运行环境 1.Windows10 2. Keil5(MDK5) Version 5.27.0.0 3. MCU GD32E230C8T6 简介 本例程主要分析在线升级(OTA)的实现过程, 主要…

个人配置电脑清单 (windows+黑苹果)

自配第一套,3A游戏入门套 AMD锐龙 R5 3500X盒装微星B450 迫击炮 Max主板套装 1218元 酷兽DDR4 8G 3200MHz内存条 2条 301元 耕升1660 super 6g 炫光 1487元 爱国者极光风扇 4个 110元 航嘉 金牌500W WD500K电源 265元 先马&#xff08;SAMA&#xff09;鲁班1 机箱 179元 …

耕升 GeForce RTX 4070 星极皓月 OC给玩家带来DLSS3+2K光追百帧游戏体验

在2023年4月12日晚上21:00,耕升 GeForce RTX 4070 系列全系显卡正式解禁。作为NVIDIA 核心AIC合作伙伴之一的耕升&#xff0c;也紧跟步伐推出全新耕升 GeForce RTX 4070 星极皓月 OC 和 耕升 GeForce RTX 4070 星极幻姬 OC。其建议零售价为人民币5199元。耕升 GeForce RTX 4070…