Android 平台 Native 代码的崩溃捕获机制及实现
yuyutoo 2024-10-11 21:39 6 浏览 0 评论
进行更多扩展操作,如:
打印logcat和应用日志
上报crash次数
对不同的crash做不同的恢复措施
可以针对业务不断改进和适应
二、现有的方案
其实3个方案在Android平台的实现原理都是基本一致的,综合考虑,可以基于coffeecatch改进。
三、信号机制
1.程序奔溃
在Unix-like系统中,所有的崩溃都是编程错误或者硬件错误相关的,系统遇到不可恢复的错误时会触发崩溃机制让程序退出,如除零、段地址错误等。
异常发生时,CPU通过异常中断的方式,触发异常处理流程。不同的处理器,有不同的异常中断类型和中断处理方式。
linux把这些中断处理,统一为信号量,可以注册信号量向量进行处理。
信号机制是进程之间相互传递消息的一种方法,信号全称为软中断信号。
2.信号机制
函数运行在用户态,当遇到系统调用、中断或是异常的情况时,程序会进入内核态。信号涉及到了这两种状态之间的转换。
(1) 信号的接收
接收信号的任务是由内核代理的,当内核接收到信号后,会将其放到对应进程的信号队列中,同时向进程发送一个中断,使其陷入内核态。注意,此时信号还只是在队列中,对进程来说暂时是不知道有信号到来的。
(2) 信号的检测
进程陷入内核态后,有两种场景会对信号进行检测:
进程从内核态返回到用户态前进行信号检测
进程在内核态中,从睡眠状态被唤醒的时候进行信号检测
当发现有新信号时,便会进入下一步,信号的处理。
(3) 信号的处理
信号处理函数是运行在用户态的,调用处理函数前,内核会将当前内核栈的内容备份拷贝到用户栈上,并且修改指令寄存器(eip)将其指向信号处理函数。
接下来进程返回到用户态中,执行相应的信号处理函数。
信号处理函数执行完成后,还需要返回内核态,检查是否还有其它信号未处理。如果所有信号都处理完成,就会将内核栈恢复(从用户栈的备份拷贝回来),同时恢复指令寄存器(eip)将其指向中断前的运行位置,最后回到用户态继续执行进程。
至此,一个完整的信号处理流程便结束了,如果同时有多个信号到达,上面的处理流程会在第2步和第3步骤间重复进行。
(4) 常见信号量类型
四、捕捉native crash
1.注册信号处理函数
第一步就是要用信号处理函数捕获到native crash(SIGSEGV, SIGBUS等)。在posix系统,可以用sigaction():
#include <signal.h>
signum:代表信号编码,可以是除SIGKILL及SIGSTOP外的任何一个特定有效的信号,如果为这两个信号定义自己的处理函数,将导致信号安装错误。
act:指向结构体sigaction的一个实例的指针,该实例指定了对特定信号的处理,如果设置为空,进程会执行默认处理。
oldact:和参数act类似,只不过保存的是原来对相应信号的处理,也可设置为NULL。
struct sigaction sa_old; memset(&sa, 0, sizeof(sa)); sigemptyset(&sa.sa_mask); sa.sa_sigaction = my_handler; sa.sa_flags = SA_SIGINFO;if (sigaction(sig, &sa, &sa_old) == 0) { ... }
2.设置额外栈空间
#include <signal.h>int sigaltstack(const stack_t *ss, stack_t *oss);
SIGSEGV很有可能是栈溢出引起的,如果在默认的栈上运行很有可能会破坏程序运行的现场,无法获取到正确的上下文。而且当栈满了(太多次递归,栈上太多对象),系统会在同一个已经满了的栈上调用SIGSEGV的信号处理函数,又再一次引起同样的信号。
我们应该开辟一块新的空间作为运行信号处理函数的栈。可以使用sigaltstack在任意线程注册一个可选的栈,保留一下在紧急情况下使用的空间。(系统会在危险情况下把栈指针指向这个地方,使得可以在一个新的栈上运行信号处理函数)
stack_t stack; memset(&stack, 0, sizeof(stack));/* Reserver the system default stack size. We don't need that much by the way. */stack.ss_size = SIGSTKSZ; stack.ss_sp = malloc(stack.ss_size); stack.ss_flags = 0;/* Install alternate stack size. Be sure the memory region is valid until you revert it. */if (stack.ss_sp != NULL && sigaltstack(&stack, NULL) == 0) { ... }
3.兼容其他signal处理
static void my_handler(const int code, siginfo_t *const si, void *const sc) {
某些信号可能在之前已经被安装过信号处理函数,而sigaction一个信号量只能注册一个处理函数,这意味着我们的处理函数会覆盖其他人的处理信号
保存旧的处理函数,在处理完我们的信号处理函数后,在重新运行老的处理函数就能完成兼容。
五、注意事项
1.防止死锁或者死循环
首先我们要了解async-signal-safe和可重入函数概念:
A signal handler function must be very careful, since processing elsewhere may be interrupted at some arbitrary point in the execution of the program.
POSIX has the concept of “safe function”. If a signal interrupts the execution of an unsafe function, and handler either calls an unsafe function or handler terminates via a call to longjmp() or siglongjmp() and the program subsequently calls an unsafe function, then the behavior of the program is undefined.
回想下在“信号机制”一节中的图示,进程捕捉到信号并对其进行处理时,进程正在执行的正常指令序列就被信号处理程序临时中断,它首先执行该信号处理程序中的指令(类似发生硬件中断)。但在信号处理程序中,不能判断捕捉到信号时进程执行到何处。如果进程正在执行malloc,在其堆中分配另外的存储空间,而此时由于捕捉到信号而插入执行该信号处理程序,其中又调用malloc,这时会发生什么?这可能会对进程造成破坏,因为malloc通常为它所分配的存储区维护一个链表,而插入执行信号处理程序时,进程可能正在更改此链表。(参考《UNIX环境高级编程》)
Single UNIX Specification说明了在信号处理程序中保证调用安全的函数。这些函数是可重入的并被称为是异步信号安全(async-signal-safe)。除了可重入以外,在信号处理操作期间,它会阻塞任何会引起不一致的信号发送。下面是这些异步信号安全函数:
但即使我们自己在信号处理程序中不使用不可重入的函数,也无法保证保存的旧的信号处理程序中不会有非异步信号安全的函数。所以要使用alarm保证信号处理程序不会陷入死锁或者死循环的状态。
static void signal_handler(const int code, siginfo_t *const si,void *const sc) {/* Ensure we do not deadlock. Default of ALRM is to die. * (signal() and alarm() are signal-safe) */ signal(code, SIG_DFL); signal(SIGALRM, SIG_DFL); /* Ensure we do not deadlock. Default of ALRM is to die. * (signal() and alarm() are signal-safe) */ (void) alarm(8); .... }
2.在哪里打印堆栈
(1) 子进程
考虑到信号处理程序中的诸多限制,一般会clone一个新的进程,在其中完成解析堆栈等任务。
下面是Google Breakpad的流程图,在新的进程中DoDump,使用ptrace解析crash进程的堆栈,同时信号处理程序等待子进程完成任务后,再调用旧的信号处理函数。父子进程使用管道通信。
(2) 子线程
在我的实验中,在子进程或者信号处理函数中,经常无法回调给java层。于是我选择了在初始化的时候就建立了子线程并一直等待,等到捕捉到crash信号时,唤醒这条线程dump出crash堆栈,并把crash堆栈回调给java。
static void nativeInit(JNIEnv* env, jclass javaClass, jstring packageNameStr, jstring tombstoneFilePathStr, jobject obj) { ... initCondition(); pthread_t thd;int ret = pthread_create(&thd, NULL, DumpThreadEntry, NULL); if(ret) { qmlog("%s", "pthread_create error"); } }void* DumpThreadEntry(void *argv) { JNIEnv* env = NULL;if((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) { LOGE("AttachCurrentThread() failed"); estatus = 0;return &estatus; }while (true) {//等待信号处理函数唤醒 waitForSignal();//回调native异常堆栈给java层 throw_exception(env);//告诉信号处理函数已经处理完了 notifyThrowException(); }if((*g_jvm)->DetachCurrentThread(g_jvm) != JNI_OK) { LOGE("DetachCurrentThread() failed"); estatus = 0;return &estatus; } return &estatus; }
六、收集native crash原因
信号处理函数的入参中有丰富的错误信息,下面我们来一一分析。
/*信号处理函数*/void (*sa_sigaction)(const int code, siginfo_t *const si, void * const sc) siginfo_t { int si_signo; /* Signal number 信号量 */ int si_errno; /* An errno value */ int si_code; /* Signal code 错误码 */ }
1.code
发生native crash之后,logcat中会打出如下一句信息:
signal 11 (SIGSEGV), code 0 (SI_USER), fault addr 0x0
根据code去查表,其实就可以知道发生native crash的大致原因:
代码的一部分如下,其实就是根据不同的code,输出不同信息,这些都是固定的。
case SIGFPE:switch(code) {case FPE_INTDIV:return "Integer divide by zero";case FPE_INTOVF:return "Integer overflow";case FPE_FLTDIV:return "Floating-point divide by zero";case FPE_FLTOVF:return "Floating-point overflow";case FPE_FLTUND:return "Floating-point underflow";case FPE_FLTRES:return "Floating-point inexact result";case FPE_FLTINV:return "Invalid floating-point operation";case FPE_FLTSUB:return "Subscript out of range";default:return "Floating-point"; }break;case SIGSEGV:switch(code) {case SEGV_MAPERR:return "Address not mapped to object";case SEGV_ACCERR:return "Invalid permissions for mapped object";default:return "Segmentation violation"; } break;
2.pc值
信号处理函数中的第三个入参sc是uc_mcontext的结构体,是cpu相关的上下文,包括当前线程的寄存器信息和奔溃时的pc值。能够知道崩溃时的pc,就能知道崩溃时执行的是那条指令。
不过这个结构体的定义是平台相关,不同平台、不同cpu架构中的定义都不一样:
x86-64架构:uc_mcontext.gregs[REG_RIP]
arm架构:uc_mcontext.arm_pc
3.共享库名字和相对偏移地址
(1) dladdr()
pc值是程序加载到内存中的绝对地址,我们需要拿到奔溃代码相对于共享库的相对偏移地址,才能使用addr2line分析出是哪一行代码。通过dladdr()可以获得共享库加载到内存的起始地址,和pc值相减就可以获得相对偏移地址,并且可以获得共享库的名字。
Dl_info info; if (dladdr(addr, &info) != 0 && info.dli_fname != NULL) { void * const nearest = info.dli_saddr; //相对偏移地址 const uintptr_t addr_relative = ((uintptr_t) addr - (uintptr_t) info.dli_fbase); ... }
作为有追求的我们,肯定不满足于仅仅通过一个函数就获得答案。我们尝试下如何手工分析出相对地址。首先要了解下进程的地址空间布局。
(2) Linux下进程的地址空间布局
任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中还要有其动态环境,其中最重要的就是堆栈。
上图中Random stack offset和Random mmap offset等随机值意在防止恶意程序。Linux通过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱布局,以免恶意程序通过计算访问栈、库函数等地址。
栈(stack),作为进程的临时数据区,增长方向是从高地址到低地址。
(3) /proc/self/maps:检查各个模块加载在内存的地址范围
在Linux系统中,/proc/self/maps保存了各个程序段在内存中的加载地址范围,grep出共享库的名字,就可以知道共享库的加载基值是多少。
得到相对偏移地址之后,使用readelf查看共享库的符号表,就可以知道是哪个函数crash了。
七、获取堆栈
1.原理
在前一步,我们获取了奔溃时的pc值和各个寄存器的内容,通过SP和FP所限定的stack frame,就可以得到母函数的SP和FP,从而得到母函数的stack frame(PC,LR,SP,FP会在函数调用的第一时间压栈),以此追溯,即可得到所有函数的调用顺序。
2.实现
在4.1.1以上,5.0以下:使用安卓系统自带的libcorkscrew.so
5.0以上:安卓系统中没有了libcorkscrew.so,使用自己编译的libunwind
#ifdef USE_UNWIND/* Frame buffer initial position. */ t->frames_size = 0;/* Skip us and the caller. */ t->frames_skip = 0;/* 使用libcorkscrew解堆栈 */#ifdef USE_CORKSCREW t->frames_size = backtrace_signal(si, sc, t->frames, 0, BACKTRACE_FRAMES_MAX); #else /* Unwind frames (equivalent to backtrace()) */ _Unwind_Backtrace(coffeecatch_unwind_callback, t); #endif/* 如果无法加载libcorkscrew,则使用自己编译的libunwind解堆栈 */#ifdef USE_LIBUNWINDif (t->frames_size == 0) { size_t i; t->frames_size = unwind_signal(si, sc, t->uframes, 0,BACKTRACE_FRAMES_MAX); for(i = 0 ; i < t->frames_size ; i++) { t->frames[i].absolute_pc = (uintptr_t) t->uframes[i]; t->frames[i].stack_top = 0; t->frames[i].stack_size = 0; __android_log_print(ANDROID_LOG_DEBUG, TAG, "absolute_pc:%x", t->frames[i].absolute_pc); } } #endif
libunwind是一个独立的开源库,高版本的安卓源码中也使用了libunwind作为解堆栈的工具,并针对安卓做了一些适配。下面是使用libunwind解堆栈的主循环,每次循环解一层堆栈。
static ALWAYS_INLINE intslow_backtrace (void **buffer, int size, unw_context_t *uc){ unw_cursor_t cursor; unw_word_t ip;int n = 0;if (unlikely (unw_init_local (&cursor, uc) < 0))return 0;while (unw_step (&cursor) > 0) {if (n >= size)return n;if (unw_get_reg (&cursor, UNW_REG_IP, &ip) < 0)return n; buffer[n++] = (void *) (uintptr_t) ip; } return n; }
八、获取函数符号
(1) libcorkscrew
可以通过libcorkscrew中的get_backtrace_symbols函数获得函数符号。
/* * Describes the symbols associated with a backtrace frame. */typedef struct { uintptr_t relative_pc; uintptr_t relative_symbol_addr;char* map_name;char* symbol_name;char* demangled_name; } backtrace_symbol_t;/* * Gets the symbols for each frame of a backtrace. * The symbols array must be big enough to hold one symbol record per frame. * The symbols must later be freed using free_backtrace_symbols. */void get_backtrace_symbols(const backtrace_frame_t* backtrace, size_t frames, backtrace_symbol_t* backtrace_symbols);
(2) dladdr
更通用的方法是通过dladdr获得函数名字。
int dladdr(void *addr, Dl_info *info); typedef struct {const char *dli_fname; /* Pathname of shared object that contains address */ void *dli_fbase; /* Base address at which shared object is loaded */ const char *dli_sname; /* Name of symbol whose definition overlaps addr */ void *dli_saddr; /* Exact address of symbol named in dli_sname */} Dl_info;
传入每一层堆栈的相对偏移地址,就可以从dli_fname中获得函数名字。
九、获得java堆栈
如何获得native crash所对应的java层堆栈,这个问题曾经困扰了我一段时间。这里有一个前提:我们认为crash线程就是捕获到信号的线程,虽然这在SIGABRT下不一定可靠。有了这个认知,接下来就好办了。在信号处理函数中获得当前线程的名字,然后把crash线程的名字传给java层,在java里dump出这个线程的堆栈,就是crash所对应的java层堆栈了。
在c中获得线程名字:
char* getThreadName(pid_t tid) {if (tid <= 1) {return NULL; }char* path = (char *) calloc(1, 80);char* line = (char *) calloc(1, THREAD_NAME_LENGTH); snprintf(path, PATH_MAX, "proc/%d/comm", tid); FILE* commFile = NULL;if (commFile = fopen(path, "r")) { fgets(line, THREAD_NAME_LENGTH, commFile); fclose(commFile); } free(path);if (line) {int length = strlen(line);if (line[length - 1] == '\n') { line[length - 1] = '\0'; } } return line; }
然后传给java层:
/** * 根据线程名获得线程对象,native层会调用该方法,不能混淆 * @param threadName * @return */ @Keep public static Thread getThreadByName(String threadName) {if (TextUtils.isEmpty(threadName)) {return null; } Set<Thread> threadSet = Thread.getAllStackTraces().keySet(); Thread[] threadArray = threadSet.toArray(new Thread[threadSet.size()]); Thread theThread = null;for(Thread thread : threadArray) {if (thread.getName().equals(threadName)) { theThread = thread; } } Log.d(TAG, "threadName: " + threadName + ", thread: " + theThread); return theThread; }
十、 结果展示
经过诸多探索,终于得到了完美的堆栈:
java.lang.Error: signal 11 (Address not mapped to object) at address 0x0
在native层构造了一个Error传给java,所以在java层可以很轻松地根据堆栈进行业务上的处理。
public interface CrashHandleListener { @Keep void onCrash(int id, Error e); }
另外初始化时就建立等待回调线程的方式,提供了稳定的给java层的回调。在回调中我们打印了app的状态信息,包括activity的堆栈、app是否在前台等,以及打印crash前的logcat日志和把应用日志flush进文件。针对某些具体的native crash还做了业务上的处理,例如遇到热补丁框架相关的crash时就回滚补丁。
在用户环境中的很多native crash单靠堆栈是解决不了的,logcat是非常重要的补充。好几例webview crash都是通过发生crash时的logcat定位的。比如我们曾经遇到过的一个的webview crash:
#00 pc 00039874 /system/lib/libc.so (tgkill+12)
单凭堆栈根本看不出来是什么问题,但是在logcat中却看到这样一个warning log:
05-21 15:09:28.423 W/System.err(16811): java.lang.NullPointerException: Attempt to get length of null array05-21 15:09:28.424 W/System.err(16811): at java.io.ByteArrayInputStream.<init>(ByteArrayInputStream.java:60)05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.fetcher.HttpImageFetcher.fetchFromNetwork(HttpImageFetcher.java:86)05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.fetcher.BaseFetcher.fetch(BaseFetcher.java:24)05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.delaystream.DelayInputStream.read(DelayInputStream.java:36)05-21 15:09:28.424 W/System.err(16811): at com.tencent.*.InlineImage.delaystream.DelayHttpInputStream.read(DelayHttpInputStream.java:12)05-21 15:09:28.424 W/System.err(16811): at java.io.InputStream.read(InputStream.java:181)05-21 15:09:28.424 W/System.err(16811): at org.chromium.android_webview.InputStreamUtil.read(InputStreamUtil.java:54)
查代码发现是我们在WebViewClient的shouldInterceptRequest接口中的业务代码发生了NullPointerException, 传进去WebView内部变成了natvie crash,问题解决。
相关推荐
- Mysql和Oracle实现序列自增(oracle创建序列的sql)
-
Mysql和Oracle实现序列自增/*ORACLE设置自增序列oracle本身不支持如mysql的AUTO_INCREMENT自增方式,我们可以用序列加触发器的形式实现,假如有一个表T_WORKM...
- 关于Oracle数据库12c 新特性总结(oracle数据库19c与12c)
-
概述今天主要简单介绍一下Oracle12c的一些新特性,仅供参考。参考:http://docs.oracle.com/database/121/NEWFT/chapter12102.htm#NEWFT...
- MySQL CREATE TABLE 简单设计模板交流
-
推荐用MySQL8.0(2018/4/19发布,开发者说同比5.7快2倍)或同类型以上版本....
- mysql学习9:创建数据库(mysql5.5创建数据库)
-
前言:我也是在学习过程中,不对的地方请谅解showdatabases;#查看数据库表createdatabasename...
- MySQL面试题-CREATE TABLE AS 与CREATE TABLE LIKE的区别
-
执行"CREATETABLE新表ASSELECT*FROM原表;"后,新表与原表的字段一致,但主键、索引不会复制到新表,会把原表的表记录复制到新表。...
- Nike Dunk High Volt 和 Bright Spruce 预计将于 12 月推出
-
在街上看到的PandaDunk的超载可能让一些球鞋迷们望而却步,但Dunk的浪潮仍然强劲,看不到尽头。我们看到的很多版本都是为女性和儿童制作的,这种新配色为后者引入了一种令人耳目一新的新选择,而...
- 美国多功能舰载雷达及美国海军舰载多功能雷达系统技术介绍
-
多功能雷达AN/SPY-1的特性和技术能力,该雷达已经在美国海军服役了30多年,其修改-AN/SPY-1A、AN/SPY-1B(V)、AN/SPY-1D、AN/SPY-1D(V),以及雷神...
- 汽车音响怎么玩,安装技术知识(汽车音响怎么玩,安装技术知识视频)
-
全面分析汽车音响使用或安装技术常识一:主机是大多数人最熟习的音响器材,有关主机的各种性能及规格,也是耳熟能详的事,以下是一些在使用或安装时,比较需要注意的事项:LOUDNESS:几年前的主机,此按...
- 【推荐】ProAc Response系列扬声器逐个看
-
有考牌(公认好声音)扬声器之称ProAcTablette小音箱,相信不少音响发烧友都曾经,或者现在依然持有,正当大家逐渐掌握Tablette的摆位设定与器材配搭之后,下一步就会考虑升级至表现更全...
- #本站首晒# 漂洋过海来看你 — BLACK&DECKER 百得 BDH2000L无绳吸尘器 开箱
-
作者:初吻给了烟sco混迹张大妈时日不短了,手没少剁。家里有了汪星人,吸尘器使用频率相当高,偶尔零星打扫用卧式的实在麻烦(汪星人:你这分明是找借口,我掉毛是满屋子都有,铲屎君都是用卧式满屋子吸的,你...
- 专题|一个品牌一件产品(英国篇)之Quested(罗杰之声)
-
Quested(罗杰之声)代表产品:Q212FS品牌介绍Quested(罗杰之声)是录音监听领域的传奇品牌,由英国录音师RogerQuested于1985年创立。在成立Quested之前,Roger...
- 常用半导体中英对照表(建议收藏)(半导体英文术语)
-
作为一个源自国外的技术,半导体产业涉及许多英文术语。加之从业者很多都有海外经历或习惯于用英文表达相关技术和工艺节点,这就导致许多英文术语翻译成中文后,仍有不少人照应不上或不知如何翻译。为此,我们整理了...
- Fyne Audio F502SP 2.5音路低音反射式落地音箱评测
-
FyneAudio的F500系列,有新成员了!不过,新成员不是新的款式,却是根据原有款式提出特别版。特别版产品在原有型号后标注了SP字样,意思是SpecialProduction。Fyne一共推出...
- 有哪些免费的内存数据库(In-Memory Database)
-
以下是一些常见的免费的内存数据库:1.Redis:Redis是一个开源的内存数据库,它支持多种数据结构,如字符串、哈希表、列表、集合和有序集合。Redis提供了快速的读写操作,并且支持持久化数据到磁...
- RazorSQL Mac版(SQL数据库查询工具)
-
RazorSQLMac特别版是一款看似简单实则功能非常出色的SQL数据库查询、编辑、浏览和管理工具。RazorSQLformac特别版可以帮你管理多个数据库,支持主流的30多种数据库,包括Ca...
你 发表评论:
欢迎- 一周热门
-
-
前端面试:iframe 的优缺点? iframe有那些缺点
-
带斜线的表头制作好了,如何填充内容?这几种方法你更喜欢哪个?
-
漫学笔记之PHP.ini常用的配置信息
-
其实模版网站在开发工作中很重要,推荐几个参考站给大家
-
推荐7个模板代码和其他游戏源码下载的网址
-
[干货] JAVA - JVM - 2 内存两分 [干货]+java+-+jvm+-+2+内存两分吗
-
正在学习使用python搭建自动化测试框架?这个系统包你可能会用到
-
织梦(Dedecms)建站教程 织梦建站详细步骤
-
【开源分享】2024PHP在线客服系统源码(搭建教程+终身使用)
-
2024PHP在线客服系统源码+完全开源 带详细搭建教程
-
- 最近发表
-
- Mysql和Oracle实现序列自增(oracle创建序列的sql)
- 关于Oracle数据库12c 新特性总结(oracle数据库19c与12c)
- MySQL CREATE TABLE 简单设计模板交流
- mysql学习9:创建数据库(mysql5.5创建数据库)
- MySQL面试题-CREATE TABLE AS 与CREATE TABLE LIKE的区别
- Nike Dunk High Volt 和 Bright Spruce 预计将于 12 月推出
- 美国多功能舰载雷达及美国海军舰载多功能雷达系统技术介绍
- 汽车音响怎么玩,安装技术知识(汽车音响怎么玩,安装技术知识视频)
- 【推荐】ProAc Response系列扬声器逐个看
- #本站首晒# 漂洋过海来看你 — BLACK&DECKER 百得 BDH2000L无绳吸尘器 开箱
- 标签列表
-
- mybatis plus (70)
- scheduledtask (71)
- css滚动条 (60)
- java学生成绩管理系统 (59)
- 结构体数组 (69)
- databasemetadata (64)
- javastatic (68)
- jsp实用教程 (53)
- fontawesome (57)
- widget开发 (57)
- vb net教程 (62)
- hibernate 教程 (63)
- case语句 (57)
- svn连接 (74)
- directoryindex (69)
- session timeout (58)
- textbox换行 (67)
- extension_dir (64)
- linearlayout (58)
- vba高级教程 (75)
- iframe用法 (58)
- sqlparameter (59)
- trim函数 (59)
- flex布局 (63)
- contextloaderlistener (56)