正文
前言
本文主要总结嵌入式系统c语言编程中,主要的错误处理方式。文中涉及的代码运行环境如下:
一、错误概念
1.1 错误分类
从严重性而言,程序错误可分为致命性和非致命性两类。对于致命性错误,无法执行恢复动作,最多只能在用户屏幕上打印出错消息或将其写入日志文件,然后终止程序;而对于非致命性错误,多数本质上是暂时的(如资源短缺),一般恢复动作是延迟一些时间后再次尝试。
从交互性而言,程序错误可分为用户错误和内部错误两类。用户错误呈现给用户,通常指明用户操作上的错误;而程序内部错误呈现给程序员(可能携带用户不可接触的数据细节),用于查错和排障。
应用程序开发者可决定恢复哪些错误以及如何恢复。例如,若磁盘已满,可考虑删除非必需或已过期的数据;若网络连接失败,可考虑短时间延迟后重建连接。选择合理的错误恢复策略,可避免应用程序的异常终止,从而改善其健壮性。
1.2 处理步骤
错误处理即处理程序运行时出现的任何意外或异常情况。典型的错误处理包含五个步骤:
程序执行时发生软件错误。该错误可能产生于被底层驱动或内核映射为软件错误的硬件响应事件(如除零)。
以一个错误指示符(如整数或结构体)记录错误的原因及相关信息。
程序检测该错误(读取错误指示符,或由其主动上报);
程序决定如何处理错误(忽略、部分处理或完全处理);
恢复或终止程序的执行。
上述步骤用c语言代码表述如下:
int func(){ int biserroccur = 0; //do something that might invoke errors if(biserroccur) //stage 1: error occurred return -1; //stage 2: generate error indicator //... return 0;}int main(void){ if(func() != 0) //stage 3: detect error { //stage 4: handle error } //stage 5: recover or abort return 0;}
调用者可能希望函数返回成功时表示完全成功,失败时程序恢复到调用前的状态(但被调函数很难保证这点)。
二 、错误传递
2.1 返回值和回传参数
c语言通常使用返回值来标志函数是否执行成功,调用者通过if等语句检查该返回值以判断函数执行情况。常见的几种调用形式如下:
if((p = malloc(100)) == null) //...if((c = getchar()) == eof) //...if((ticks = clock()) testopen file failed: no such file or directory[wangxiaoyuan_@localhost test1]$ ./glberr nonexistentfile.h 2> testcannot open file 'nonexistentfile.h'(no such file or directory)!
也可仿照errno的定义和处理,定制自己的错误代码:
int *_fperrno(void){ static int dwlocalerrno = 0; return &dwlocalerrno;}#define errno (*_fperrno())#define eoutofrange 1//define other error macros...int callee(void){ errno = 1; return -1;}int main(void){ errno = 0; if((-1 == callee()) && (eoutofrange == errno)) printf(callee failed(errno:%d)!, errno); return 0;}
借助全局状态标志,可充分利用函数的接口(返回值和参数表)。但与返回值一样,它隐含地要求调用者在调用函数后检查该标志,而这种约束同样脆弱。
此外,全局状态标志存在重用和覆盖的风险。而函数返回值是无名的临时变量,由函数产生且只能被调用者访问。调用完成后即可检查或拷贝返回值,然后原始的返回对象将消失而不能被重用。又因为无名,返回值不能被覆盖。
2.3 局部跳转(goto)
使用goto语句可直接跳转到函数内的错误处理代码处。以除零错误为例:
double division(double fdividend, double fdivisor){ return fdividend/fdivisor;}int main(void){ int dwflag = 0; if(1 == dwflag) { raiseexception: printf(the divisor cannot be 0!); exit(1); } dwflag = 1; double fdividend = 0.0, fdivisor = 0.0; printf(enter the dividend: ); scanf(%lf, &fdividend); printf(enter the divisor : ); scanf(%lf, &fdivisor); if(0 == fdivisor) //不太严谨的浮点数判0比较 goto raiseexception; printf(the quotient is %.2lf, division(fdividend, fdivisor)); return 0;}
执行结果如下:
[wangxiaoyuan_@localhost test1]$ ./testenter the dividend: 10enter the divisor : 0the divisor cannot be 0![wangxiaoyuan_@localhost test1]$ ./testenter the dividend: 10enter the divisor : 2the quotient is 5.00
虽然goto语句会破坏代码结构性,但却非常适用于集中错误处理。伪代码示例如下:
callerfunc(){ if((ret = calleefunc1()) < 0); goto errhandle; if((ret = calleefunc2()) < 0); goto errhandle; if((ret = calleefunc3()) < 0); goto errhandle; //... return;errhandle: //handle error(e.g. printf) return;}
2.4 非局部跳转(setjmp/longjmp)
局部goto语句只能跳到所在函数内部的标号上。若要跨越函数跳转,需要借助标准c库提供非局部跳转函数setjmp()和longjmp()。它们分别承担非局部标号和goto的作用,非常适用于处理发生在深层嵌套函数调用中的出错情况。“非局部跳转”是在栈上跳过若干调用帧,返回到当前函数调用路径上的某个函数内。
#include int setjmp(jmp_buf env);void longjmp(jmp_buf env,int val);
函数setjmp()将程序运行时的当前系统堆栈环境保存在缓冲区env结构中。初次调用该函数时返回值为0。longjmp()函数根据setjmp()所保存的env结构恢复先前的堆栈环境,即“跳回”先前调用setjmp时的程序执行点。此时,setjmp()函数返回longjmp()函数所设置的参数val值,程序将继续执行setjmp调用后的下一条语句(仿佛从未离开setjmp)。参数val为非0值,若设置为0,则setjmp()函数返回1。
可见,setjmp()有两类返回值,用于区分是首次直接调用(返回0)和还是由其他地方跳转而来(返回非0值)。对于一个setjmp可有多个longjmp,因此可由不同的非0返回值区分这些longjmp。
举个简单例子说明 setjmp/longjmp的非局部跳转:
jmp_buf gjmpbuf;void func1(){ printf(enter func1); if(0)longjmp(gjmpbuf, 1);}void func2(){ printf(enter func2); if(0)longjmp(gjmpbuf, 2);}void func3(){ printf(enter func3); if(1)longjmp(gjmpbuf, 3);}int main(void){ int dwjmpret = setjmp(gjmpbuf); printf(dwjmpret = %d, dwjmpret); if(0 == dwjmpret) { func1(); func2(); func3(); } else { switch(dwjmpret) { case 1: printf(jump back from func1); break; case 2: printf(jump back from func2); break; case 3: printf(jump back from func3); break; default: printf(unknown func!); break; } } return 0;}
执行结果为:
dwjmpret = 0enter func1enter func2enter func3dwjmpret = 3jump back from func3
当setjmp/longjmp嵌在单个函数中使用时,可模拟pascal语言中嵌套函数定义(即函数内中定义一个局部函数)。当setjmp/longjmp跨越函数使用时,可模拟面向对象语言中的异常(exception) 机制。
模拟异常机制时,首先通过setjmp()函数设置一个跳转点并保存返回现场,然后使用try块包含那些可能出现错误的代码。可在try块代码中或其调用的函数内,通过longjmp()函数抛出(throw)异常。抛出异常后,将跳回setjmp()函数所设置的跳转点并执行catch块所包含的异常处理程序。
以除零错误为例:
jmp_buf gjmpbuf;void raiseexception(void){ printf(exception is raised: ); longjmp(gjmpbuf, 1); //throw,跳转至异常处理代码 printf(this line should never get printed!);}double division(double fdividend, double fdivisor){ return fdividend/fdivisor;}int main(void){ double fdividend = 0.0, fdivisor = 0.0; printf(enter the dividend: ); scanf(%lf, &fdividend); printf(enter the divisor : ); if(0 == setjmp(gjmpbuf)) //try块 { scanf(%lf, &fdivisor); if(0 == fdivisor) //也可将该判断及raiseexception置于division内 raiseexception(); printf(the quotient is %.2lf, division(fdividend, fdivisor)); } else //catch块(异常处理代码) { printf(the divisor cannot be 0!); } return 0;}
执行结果为:
enter the dividend: 10enter the divisor : 0exception is raised: the divisor cannot be 0!
通过组合使用setjmp/longjmp函数,可对复杂程序中可能出现的异常进行集中处理。根据longjmp()函数所传递的返回值来区分处理各种不同的异常。
使用setjmp/longjmp函数时应注意以下几点:
必须先调用setjmp()函数后调用longjmp()函数,以恢复到先前被保存的程序执行点。若调用顺序相反,将导致程序的执行流变得不可预测,很容易导致程序崩溃。
longjmp()函数必须在setjmp()函数的作用域之内。在调用setjmp()函数时,它保存的程序执行点环境只在当前主调函数作用域以内(或以后)有效。若主调函数返回或退出到上层(或更上层)的函数环境中,则setjmp()函数所保存的程序环境也随之失效(函数返回时堆栈内存失效)。这就要求setjmp()不可该封装在一个函数中,若要封装则必须使用宏(详见《c语言接口与实现》“第4章 异常与断言”)。
通常将jmp_buf变量定义为全局变量,以便跨函数调用longjmp。
通常,存放在存储器中的变量将具有longjmp时的值,而在cpu和浮点寄存器中的变量则恢复为调用setjmp时的值。因此,若在调用setjmp和longjmp之间修改自动变量或寄存器变量的值,当setjmp从longjmp调用返回时,变量将维持修改后的值。若要编写使用非局部跳转的可移植程序,必须使用volatile属性。
使用异常机制不必每次调用都检查一次返回值,但因为程序中任何位置都可能抛出异常,必须时刻考虑是否捕捉异常。在大型程序中,判断是否捕捉异常会是很大的思维负担,影响开发效率。相比之下,通过返回值指示错误有利于调用者在最近出错的地方进行检查。此外,返回值模式中程序的运行顺序一目了然,对维护者可读性更高。因此,应用程序中不建议使用setjmp/longjmp“异常处理”机制(除非库或框架)。
2.5 信号(signal/raise)
在某些情况下,主机环境或操作系统可能发出信号(signal)事件,指示特定的编程错误或严重事件(如除0或中断等)。这些信号本意并非用于错误捕获,而是指示与正常程序流不协调的外部事件。
为处理信号,需要使用以下信号相关函数:
#include typedef void (*fpsigfunc)(int);fpsigfunc signal(int signo, fpsigfunc fphandler);int raise(int signo);
其中,参数signo是unix系统定义的信号编号(正整数),不允许用户自定义信号。参数fphandler是常量sig_dfl、常量sig_ign或当接收到此信号后要调用的信号处理函数(signal handler)的地址。若指定sig_dfl,则接收到此信号后调用系统的缺省处理函数;若指定sig_ ign,则向内核表明忽略此信号(sigkill和sigstop不可忽略)。某些异常信号(如除数为零)不太可能恢复,此时信号处理函数可在程序终止前正确地清理某些资源。信号处理函数所收到的异常信息仅是一个整数(待处理的信号事件),这点与setjmp()函数类似。
signal()函数执行成功时返回前次挂接的处理函数地址,失败时则返回sig_err。信号通过调用raise()函数产生并被处理函数捕获。
以除零错误为例:
void fphandler(int dwsigno){ printf(exception is raised, dwsigno=%d!, dwsigno);}int main(void){ if(sig_err == signal(sigfpe, fphandler)) { fprintf(stderr, fail to set sigfpe handler!); exit(exit_failure); } double fdividend = 10.0, fdivisor = 0.0; if(0 == fdivisor) { raise(sigfpe); exit(exit_failure); } printf(the quotient is %.2lf, fdividend/fdivisor); return 0;}
执行结果为exception is raised, dwsigno=8!(0.0不等同于0,因此系统未检测到浮点异常)。
若将被除数(dividend)和除数(divisor)改为整型变量:
int main(void){ if(sig_err == signal(sigfpe, fphandler)) { fprintf(stderr, fail to set sigfpe handler!); exit(exit_failure); } int dwdividend = 10, dwdivisor = 0; double fquotient = dwdividend/dwdivisor; printf(the quotient is %.2lf, fquotient); return 0;}
则执行后循环输出exception is raised, dwsigno=8!。这是因为进程捕捉到信号并对其进行处理时,进程正在执行的指令序列被信号处理程序临时中断,它首先执行该信号处理程序中的指令。若从信号处理程序返回(未调用exit或longjmp),则继续执行在捕捉到信号时进程正在执行的正常指令序列。因此,每次系统调用信号处理函数后,异常控制流还会返回除0指令继续执行。而除0异常不可恢复,导致反复输出异常。
规避方法有两种:
将sigfpe信号变成系统默认处理,即signal(sigfpe, sig_dfl)。
此时执行输出为floating point exception。
利用setjmp/longjmp跳过引发异常的指令:
jmp_buf gjmpbuf;void fphandler(int dwsigno){ printf(exception is raised, dwsigno=%d!, dwsigno); longjmp(gjmpbuf, 1);}int main(void){ if(sig_err == signal(sigfpe, sig_dfl)) { fprintf(stderr, fail to set sigfpe handler!); exit(exit_failure); } int dwdividend = 10, dwdivisor = 0; if(0 == setjmp(gjmpbuf)) { double fquotient = dwdividend/dwdivisor; printf(the quotient is %.2lf, fquotient); } else { printf(the divisor cannot be 0!); } return 0;}
注意,在信号处理程序中还可使用sigsetjmp/siglongjmp函数进行非局部跳转。相比setjmp函数,sigsetjmp函数增加一个信号屏蔽字参数。
三 错误处理
3.1 终止(abort/exit)
致命性错误无法恢复,只能终止程序。例如,当空闲堆管理程序无法提供可用的连续空间时(调用malloc返回null),用户程序的健壮性将严重受损。若恢复的可能性渺茫,则最好终止或重启程序。
标准c库提供exit()和abort()函数,分别用于程序正常终止和异常终止。两者都不会返回到调用者中,且都导致程序被强行结束。
exit()及其相似函数原型声明如下:
#include void exit(int status);void _exit(int status);#include void _exit(int status);
其中,exit和_exit由iso c说明,而_exit由posix.1说明。因此使用不同的头文件。
iso c定义_exit旨在为进程提供一种无需运行终止处理程序(exit handler)或信号处理程序(signal handler)而终止的方法,是否冲洗标准i/o流则取决于实现。unix系统中_exit 和_exit同义,两者均直接进入内核,而不冲洗标准i/o流。_exit函数由exit调用,处理unix特定的细节。
exit()函数首先调用执行各终止处理程序,然后按需多次调用fclose函数关闭所有已打开的标准i/o流(将所有缓冲的输出数据冲洗写到文件上),然后调用_exit函数进入内核。
标准函数库中有一种“缓冲i/o(buffered i/o)”机制。该机制对于每个打开的文件,在内存中维护一片缓冲区。每次读文件时会连续读出若干条记录,下次读文件时就可直接从内存缓冲区中读取;每次写文件时也仅仅写入内存缓冲区,等满足一定条件(如缓冲区填满,或遇到换行符等特定字符)时再将缓冲区内容一次性写入文件。
通过尽可能减少read和write调用的次数,该机制可显著提高文件读写速度,但也给编程带来某些麻烦。例如,向文件内写入一些数据时,若未满足特定条件,数据会暂存在缓冲区内。开发者并不知晓这点,而调用_ exit()函数直接关闭进程,导致缓冲区数据丢失。因此,若要保证数据完整性,必须调用exit()函数,或在调用 _exit()函数前先通过fflush()函数将缓冲区内容写入指定的文件。
例如,调用printf函数(遇到换行符' '时自动读出缓冲区中内容)函数后再调用exit:
int main(void){ printf(using exit...); printf(this is the content in buffer); exit(0); printf(this line will never be reached);}
执行输出为:
using exit...this is the content in buffer(结尾无换行符)
调用printf函数后再调用_exit:
int main(void){ printf(using _exit...); printf(this is the content in buffer); fprintf(stdout, standard output stream); fprintf(stderr, standard error stream); //fflush(stdout); _exit(0);}
执行输出为:
using _exit...standard error stream(结尾无换行符)
若取消fflush句注释,则执行输出为:
using _exit...standard error streamthis is the content in bufferstandard output stream(结尾无换行符)
通常,标准错误是不带缓冲的,打开至终端设备的流(如标准输入和标准输出)是行缓冲的(遇换行符则执行i/o操作);其他所有流则是全缓冲的(填满标准i/o缓冲区后才执行i/o操作)。
三个exit函数都带有一个整型参数status,称之为终止状态(或退出状态)。该参数取值通常为两个宏,即exit_success(0)和exit_failure(1)。大多数unix shell都可检查进程的终止状态。若(a)调用这些函数时不带终止状态,或(b)main函数执行了无返回值的return语句,或(c) main函数未声明返回类型为整型,则该进程的终止状态未定义。但若main函数的返回类型为整型,且执行到最后一条语句时返回(隐式返回),则该进程的终止状态为0。
exit系列函数是最简单直接的错误处理方式,但程序出错终止时无法捕获异常信息。iso c规定一个进程可以注册32个终止处理函数。这些函数可编写为自定义的清理代码,将由exit()函数自动调用,并可使用atexit()函数进行注册。
#include int atexit(void (*func)(void));
该函数的参数是一个无参数无返回值的终止处理函数。exit()函数按注册的相反顺序调用这些函数。同一函数若注册多次,则被调用多次。即使不调用exit函数,程序退出时也会执行atexit注册的函数。
通过结合exit()和atexit()函数,可在程序出错终止时抛出异常信息。以除零错误为例:
double division(double fdividend, double fdivisor){ return fdividend/fdivisor;}void raiseexception1(void){ printf(exception is raised: );}void raiseexception2(void){ printf(the divisor cannot be 0!);}int main(void){ double fdividend = 0.0, fdivisor = 0.0; printf(enter the dividend: ); scanf(%lf, &fdividend); printf(enter the divisor : ); scanf(%lf, &fdivisor); if(0 == fdivisor) { atexit(raiseexception2); atexit(raiseexception1); exit(exit_failure); } printf(the quotient is %.2lf, division(fdividend, fdivisor)); return 0;}
执行结果为:
enter the dividend: 10enter the divisor : 0exception is raised: the divisor cannot be 0!
注意,通过atexit()注册的终止处理函数必须显式(使用return语句)或隐式地正常返回,而不能通过调用exit()或longjmp()等其他方式终止,否则将导致未定义的行为。例如,在gcc4.1.2编译环境下,调用exit()终止时仍等效于正常返回;而vc6.0编译环境下,调用exit()的处理函数将阻止其他已注册的处理函数被调用,并且可能导致程序异常终止甚至崩溃。
嵌套调用exit()函数将导致未定义的行为,因此在终止处理函数或信号处理函数中尽量不要调用exit()。
abort()函数原型声明如下:
#include void abort(void);
该函数将sigabrt信号发送给调用进程(进程不应忽略此信号)。
iso c规定,调用abort将向主机环境递送一个未成功终止的通知,其方法是调用raise(sigabrt)函数。因此,abort()函数理论上的实现为:
void abort(void){ raise(sigabrt); exit(exit_failure);}
可见,即使捕捉到sigabrt信号且相应信号处理程序返回,abort()函数仍然终止程序。posix.1也说明abort()函数并不理会进程对此信号的阻塞和忽略。
进程捕捉到sigabrt信号后,可在其终止之前执行所需的清理操作(如调用exit)。若进程不在信号处理程序中终止自己,posix.1声明当信号处理程序返回时,abort()函数终止该进程。
iso c规定,abort()函数是否冲洗输出流、关闭已打开文件及删除临时文件由实现决定。posix.1则要求若abort()函数终止进程,则它对所有打开标准i/o流的效果应当与进程终止前对每个流调用fclose相同。为提高可移植性,若希望冲洗标准i/o流,则应在调用abort()之前执行这种操作。
3.2 断言(assert)
abort()和exit()函数无条件终止程序。也可使用断言(assert)有条件地终止程序。
assert是诊断调试程序时经常使用的宏,定义在内。该宏的典型实现如下:
#ifdef ndebug #define assert(expr) ((void) 0)#else extern void __assert((const char *, const char *, int, const char *)); #define assert(expr) ((void) ((expr) || (__assert(#expr, __file__, __line__, __function__), 0)))#endif
可见,assert宏仅在调试版本(未定义ndebug)中有效,且调用__assert()函数。该函数将输出发生错误的文件名、代码行、函数名以及条件表达式:
void __assert(const char *assertion, const char * filename, int linenumber, register const char * function){ fprintf(stderr, [%s(%d)%s] assertion '%s' failed., filename, linenumber, ((function == null) ? unknownfunc : function), assertion); abort();}
因此,assert宏实际上是一个带有错误说明信息的abort(),并做了前提条件检查。若检查失败(断言表达式为逻辑假),则报告错误并终止程序;否则继续执行后面的语句。
使用者也可按需定制assert宏。例如,另一实现版本为:
#undef assert#ifdef ndebug #define assert(expr) ((void) 0)#else #define assert(expr) ((void) ((expr) || (fprintf(stderr, [%s(%d)] assertion '%s' failed., __file__, __line__, #expr), abort(), 0)))#endif
注意,expr1||expr2表达式作为单独语句出现时,等效于条件语句if(!(expr1))expr2。这样,assert宏就可扩展为一个表达式,而不是一条语句。逗号表达式expr2返回最后一个表达式的值(即0),以符合||操作符的要求。
使用断言时应注意以下几点:
断言用于检测理论上绝不应该出现的情况,如入参指针为空、除数为0等。
对比以下两种情况:
char *strcpy(char *pszdst, const char *pszsrc){ char *pszdstorig = pszdst; assert((pszdst != null) && (pszsrc != null)); while((*pszdst++ = *pszsrc++) != '�'); return pszdstorig;}file *openfile(const char *pszname, const char *pszmode){ file *pfile = fopen(pszname, pszmode); assert(pfile != null); if(null == pfile) return null; //... return pfile;}
strcpy()函数中断言使用正确,因为入参字符串指针不应为空。openfile()函数中则不能使用断言,因为用户可能需要检查某个文件是否存在,而这并非错误或异常。
2)assert是宏不是函数,在调试版本和非调试版本中行为不同。因此必须确保断言表达式的求值不会产生副作用,如修改变量和改变方法的返回值。不过,可根据这一副作用测试断言是否打开:
int main(void){ int dwchg = 0; assert(dwchg = 1); if(0 == dwchg) printf(assertion should be enabled!); return 0;}
不应使用断言检查公共方法的参数(应使用参数校验代码),但可用于检查传递给私有方法的参数。
可使用断言测试方法执行的前置条件和后置条件,以及执行前后的不变性。
断言条件不成立时,会调用abort()函数终止程序,应用程序没有机会做清理工作(如关闭文件和数据库)。
3.3 封装
为减少错误检查和处理代码的重复性,可对函数调用或错误输出进行封装。
封装具有错误返回值的函数
通常针对频繁调用的基础性系统函数,如内存和内核对象操作等。举例如下:
pid_t fork(void) //首字母大写,以区分系统函数fork(){ pid_t pid; if((pid = fork())<0) { fprintf(stderr, fork error: %s, strerror(errno)); exit(0); } return pid;}
fork()函数出错退出时依赖系统清理资源。若还需清理其他资源(如已创建的临时文件),可增加一个负责清理的回调函数。
注意,并非所有系统函数都可封装,应根据具体业务逻辑确定。
封装错误输出
通常需要使用iso c变长参数表特性。例如《unix网络编程》中将输出至标准出错文件的代码封装如下:
#include #include #define have_vsnprintf 1#define maxline 4096 /* max text line length */int daemon_proc; /* set nonzero by daemon_init() */static void err_doit(int errnoflag, int level, const char * fmt, va_list ap){ int errno_save, n; char buf[maxline + 1]; errno_save = errno; /* value caller might want printed. */#ifdef have_vsnprintf vsnprintf(buf, maxline, fmt, ap);#else vsprintf(buf, fmt, ap); /* this is not safe */#endif n = strlen(buf); if (errnoflag) { snprintf(buf + n, maxline - n, : %s, strerror(errno_save)); } strcat(buf, ); if (daemon_proc) { syslog(level, buf); } else { fflush(stdout); /* in case stdout and stderr are the same */ fputs(buf, stderr); fflush(stderr); } return;}void err_ret(const char * fmt, ...){ va_list ap; va_start(ap, fmt); err_doit(1, log_info, fmt, ap); va_end(ap); return;}
零跑与大华联手研发的“凌芯01”明年“上车”
[组图]200米四键遥控模块
纽扣电池的性能测试标准IEC61951.1怎么做?
BTB连接器故障处理的方法以及弹片微针模组的应用
智能照明控制系统在数据中心的应用
嵌入式编程错误处理机制设计
FTTH技术应用的几个误区分析
推动产业园区数字化转型,打造绿色低碳产业园区
海凌科个位数微安级功耗蓝牙WiFi模块 BLE5.2蓝牙+2.4G WiFi二合一
“北大量子号”的三位队员C位出道
AI信任与人才:可持续发展的智能+未来
利用新型MEMS开关提高测试能力和系统生产力
2020年Q1季度我国蜂窝物联网和IPTV规模稳扩大
Mybatis-Plus使用技巧与隐患分析
从中电数通智慧城市安全智脑看“人本”理念的重要性
Q1季度全球智能手表出货量同比增长12%,苹果以36.3%的市场份额居首
220v转110电路图
奔驰乌尼莫克,8个前进挡,6个倒退档,通过一切障碍物是没有问题,这款超级越野车直接碾压路虎
小米6之所以提前发布时间是为了错开华为p10的发布?小米怕了?
供庆R&S罗德与施瓦茨UP300音频分析仪