IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 嵌入式 -> 【cortex-m3/m4/m7常见死机、跑飞、异常、hardfault等查找方法】 -> 正文阅读

[嵌入式]【cortex-m3/m4/m7常见死机、跑飞、异常、hardfault等查找方法】

死机是所有软件从业者无法回避的坑,而死机问题导致的原因千奇百怪,对于可以稳定复现现场的问题,还是比较好处理的,最可怕的情形是你怎么也复现不了,但是在客户那偶发。在此对笔者解决过的死机问题做个分享,若有谬误,请指正。

总的思路为根据堆栈和寄存器,定位到出现异常的语句,然后从逻辑上分析导致其异常可能的原因。

  • 基础简介

在查找此类问题时,通用寄存器,堆栈,简单的汇编指令是需要必备的基础知识,以我们最常用的M3/M4内核做个简单介绍。具体知识可以自行学习cortex-M3权威手册等资料。

寄存器简介

?????? (cortex-M3权威手册第三章)

CM3 拥有通用寄存器 R0R15 以及一些特殊功能寄存器。

  • 通用目的寄存器 R0-R7
    R0R7 也被称为低组寄存器。所有指令都能访问它们。它们的字长全是 32 位,复位后
    的初始值是不可预料的。
  • 通用目的寄存器 R8-R12

R8‐R12 也被称为高组寄存器。这是因为只有很少的 16 位 Thumb 指令能访问它们, 32

位的指令则不受限制。它们也是 32 位字长,且复位后的初始值是不可预料的。

  • 堆栈指针 R13

R13 是堆栈指针。在 CM3 处理器内核中共有两个堆栈指针:

  1. 主堆栈指针(MSP),或写作 SP_main。这是缺省的堆栈指针,它由内核、异常服务例程以及所有需要特权访问的应用程序代码来使用。
  2. 进程堆栈指针(PSP),或写作 SP_process。用于常规的应用程序代码(不处于异常服

用例程中时)。

堆栈指针用于访问堆栈,并且 PUSH 指令和 POP 指令默认使用 SP。寄存器的 PUSH 和 POP 操作永远都是 4 字节对齐的

  • 连接寄存器 R14

常写做LR,用于在调用子程序时存储返回地址。

  • 程序计数器 R15

常写做PC,因为 CM3 内部使用了指令流水线,读 PC 时返回的值是当前指令的地址+4。

  • 程序状态寄存器(PSRs 或曰 PSR)

程序状态寄存器在其内部又被分为三个子状态寄存器:

  1. 应用程序 PSR(APSR)
  2. 中断号 PSR(IPSR)
  3. 执行 PSR(EPSR)

通过 MRS/MSR 指令,这 3 个 PSRs 即可以单独访问,也可以组合访问(2 个组合, 3 个组合都可以)。当使用三合一的方式访问时,应使用名字“xPSR”或者“PSR”。

  • 屏蔽寄存器
  1. PRIMASK 这是个只有 1 个位的寄存器。当它置 1 时, 就关掉所有可屏蔽的异常,只剩下 NMI和硬 fault 可以响应。它的缺省值是 0,表示没有关中断。
  2. FAULTMASK 这是个只有 1 个位的寄存器。当它置 1 时,只有 NMI 才能响应,所有其它的异常,包括中断和 fault,通通闭嘴。它的缺省值也是 0,表示没有关异常。
  3. BASEPRI 这个寄存器最多有 9 位(由表达优先级的位数决定)。它定义了被屏蔽优先级的阈值。当它被设成某个值后,所有优先级号大于等于此值的中断都被关(优先级号越大,优先级越低)。但若被设成 0,则不关闭任何中断, 0 也是缺省值。
  • 控制寄存器(CONTROL)

控制寄存器用于定义特权级别,还用于选择当前使用哪个堆栈指针。

  1. CONTROL[1] 堆栈指针选择

0=选择主堆栈指针 MSP(复位后缺省值)

1=选择进程堆栈指针 PSP

在线程或基础级(没有在响应异常——译注),可以使用 PSP。在 handler 模式下,只允许使用 MSP,所以此时不得往该位写 1。

  1. CONTROL[0] 0=特权级的线程模式

1=用户级的线程模式

Handler 模式永远都是特权级的。

栈内存操作

?????? 在 Cortex‐M3 中,除了可以使用 PUSH 和 POP 指令来处理堆栈外,内核还会在异常处理

的始末自动地执行 PUSH 与 POP 操作。

笼统地讲,堆栈操作就是对内存的读写操作,但是其地址由 SP 给出。寄存器的数据通

过 PUSH 操作存入堆栈,以后用 POP 操作从堆栈中取回。

?????? Cortex‐M3 使用的是“向下生长的满栈”模型。堆栈指针 SP 指向最后一个被压入堆栈的 32

位数值。在下一次压栈时, SP 先自减 4,再存入新的数值。

POP 操作刚好相反:先从 SP 指针处读出上一次被压入的值,再把 SP 指针自增 4。

虽然 POP 后被压入的数值还保存在栈中,但它已经无效了,因为为下次的 PUSH 将覆盖它的值。

异常与中断

(cortex-M3权威手册第七章)

所有能打断正常执行流的事件都称为异常。

Cortex‐M3 在内核水平上搭载了一个异常响应系统, 支持为数众多的系统异常和外部中断。其中,编号为 1-15 的对应系统异常,大于等于 16 的则全是外部中断。除了个别异常的优先级被定死外, 其它异常的优先级都是可编程的。

下表列出了 Cortex‐M3 可以支持的所有异常。有一定数量的系统异常是用于 fault 处理的,它们可以由多种错误条件引发。 NVIC 还提供了一些 fault 状态寄存器,以便于 fault 服务例程找出导致异常的具体原因。

  • 异常类型:

  • 优先级

优先级对于异常来说很关键的,它会影响一个异常是否能被响应,以及何时可以响应。优先级的数值越小,则优先级越高。 CM3 支持中断嵌套,使得高优先级异常会抢占(preempt)低优先级异常。有 3 个系统异常:复位, NMI 以及硬 fault,它们有固定的优先级,并且它们的优先级号是负数,从而高于所有其它异常。所有其它异常的优先级则都是可编程的(但不能编程为负数)。

  • 向量表:

当一个发生的异常被 CM3 内核接受,对应的异常 handler 就会执行。为了决定 handler 的入口地址, CM3 使用了“向量表查表机制”。这里使用一张向量表。向量表其实是一个 WORD(32 位整数)数组,每个下标对应一种异常,该下标元素的值则是该异常 handler 的入口地址。向量表的存储位置是可以设置的,通过 NVIC 中的一个重定位寄存器来指出向量表的地址。在复位后,该寄存器的值为 0。因此,在地址 0 处必须包含一张向量表,用于初始时的异常分配。

举个例子,如果发生了异常 3(SVC),则 NVIC 会计算出偏移移量是 3x4=0x0C,然后从那里取出服务例程的入口地址并跳入。 0 号异常的功能则是个另类,它并不是什么入口地址,而是给出了复位后 MSP 的初值。

  • Fault 类异常
  • 总线 Faults:
  1. 中断处理起始阶段的堆栈 PUSH 动作。 称为“入栈错误”
  2. 中断处理收尾阶段的堆栈 POP 动作。 称为“出栈错误”
  3. 在处理器启动中断处理序列(sequence)后的向量读取时。这是一种罕见的特殊情况,

被归类为硬 fault。

  • 存储器管理 faults
  1. 访问了 MPU 设置区域覆盖范围之外的地址
  2. 往只读 region 写数据
  3. 用户级下访问了只允许在特权级下访问的地址

  • 用法 faults
  1. 执行了未定义的指令
  2. 执行了协处理器指令(Cortex‐M3 不支持协处理器,但是可以通过 fault 异常机制来使用软件模拟协处理器的功能,从而可以方便地在其它 Cortex 处理器间移植)
  3. 尝试进入 ARM 状态(因为 CM3 不支持 ARM 状态,所以用法 fault 会在切换时产生。软件可以利用此机制来测试某处理器是否支持 ARM 状态)
  4. 无效的中断返回(LR 中包含了无效/错误的值)
  5. 使用多重加载/存储指令时,地址没有对齐。
  6. 除数为零
  7. 任何未对齐的访问

  • 硬 fault

硬 fault 是上文讨论的总线 fault、存储器管理 fault 以及用法 fault 上访的结果。如果这些 fault 的服务例程无法执行,它们就会成为上访(escalation)成硬 fault。

中断/异常的响应序列

(cortex-M3权威手册第九章)

当CM3开始响应一个中断时,会在它看不见的体内奔涌起三股暗流:

  1. 入栈: 把8个寄存器的值压入栈
  2. 取向量:从向量表中找出对应的服务程序入口地址
  3. 选择堆栈指针MSP/PSP,更新堆栈指针SP,更新连接寄存器LR,更新程序计数器PC
  • 入栈

响应异常的第一个行动,就是自动保存现场的必要部分:依次把xPSR, PC, LR, R12以及

R3‐R0由硬件自动压入适当的堆栈中:如果当响应异常时,当前的代码正在使用PSP,则压入

PSP,即使用线程堆栈;否则压入MSP,使用主堆栈。一旦进入了服务例程,就将一直使用

主堆栈。

  • 取向量

从向量表中找出正确的异常向量,然后在服务程序的入口处预取指。

  • 更新寄存器
  1. SP:在入栈中会把堆栈指针(PSP或MSP)更新到新的位置。在执行服务例程后,

将由MSP负责对堆栈的访问。

  1. PSR: IPSR位段(地处PSR的最低部分)会被更新为新响应的异常编号。
  2. PC:在向量取出完毕后, PC将指向服务例程的入口地址,
  3. LR: LR的用法将被重新解释,其值也被更新成一种特殊的值,称为“EXC_RETURN”,

并且在异常返回时使用。 后面会有详细解释。

  • 异常返回

当异常服务例程执行完毕后,需要很正式地做一个“异常返回”动作序列,从而恢复先前的系统状态,才能使被中断的程序得以继续执行。从形式上看,有3种途径可以触发异常返回序列,如表9.2所示;不管使用哪一种,都需要用到先前储的LR的值。

  1. 出栈

先前压入栈中的寄存器在这里恢复。内部的出栈顺序与入栈时的相对应,堆栈指针的值也改回去。

  1. 更新NVIC寄存器

伴随着异常的返回,它的活动位也被硬件清除。对于外部中断,倘若

中断输入再次被置为有效,悬起位也将再次置位,新一次的中断响应序列也可随之再次开始

  • 异常返回值

前面已经讲到,在进入异常服务程序后, LR的值被自动更新为特殊的EXC_RETURN,这

是一个高28位全为1的值,只有[3:0]的值有特殊含义,如表9.3所示。当异常服务例程把这个

值送往PC时,就会启动处理器的中断返回序列。

合法的EXC_RETURN值及其功能:

  1. 0xFFFF_FFF1 返回handler模式
  2. 0xFFFF_FFF9 返回线程模式,并使用主堆栈(SP=MSP)
  3. 0xFFFF_FFFD 返回线程模式,并使用线程堆栈(SP=PSP)

如果主程序在线程模式下运行,并且在使用 MSP时被中断 ,则在服务例程中LR=0xFFFF_FFF9(主程序被打断前的LR已被自动入栈)。

如果主程序在线程模式下运行 ,并且在使用 PSP时被中断 ,则在服务例程中LR=0xFFFF_FFFD(主程序被打断前的LR已被自动入栈)。

如果主程序在Handler模式下运行,则在服务例程中LR=0xFFFF_FFF1(主程序被打断前的LR已被自动入栈)。

?????? 自编写示例(keil+RTT)

  • 断点加在异常中断的入口处,这样能够保存刚出现问题时候的现场。
  • 查看寄存器界面,LR值为0xFFFFFFFD,表示出现问题时是在线程中,需要查看线程栈:
  • 查看psp指针,得到当前的栈顶地址0x20008EC8
  • 查看当前线程栈:
  • 由第二章的介绍,我们知道了出现异常时, 内核会自动压栈8个寄存器,以保存问题现场,其中第6个为lr指针,保存链接的地址,由上图得到lr=0x08015BDB,PC指针为0.
  • 在代码的汇编界面,输入lr地址进行跳转,找到出问题的代码行:
  • 从上面的代码看到,由于Test函数没有赋值,是一个空函数,因此导致跳转时的地址PC为0,该地址不可访问,导致异常。

真实案例

一、RT-Tread assert断言。

? ? ? ? ? ??程序仿真,进入如下断言。

  • 通过栈调用窗口,我们可以看到出现问题时的调用,大概确认当前异常的位置,是在创建信号量时,如下:

  • 查看寄存器界面,通过SP指针,得到当前的栈顶0x802b39c0:

  • 打开keil汇编界面,从当前运行的语句的while循环向上,找到该函数压栈的地方,由于是从右向左操作,可以看到先压入lr,然后压入r7~r3,一共压了6个寄存器,如下:

  • 在memory界面,输入栈顶地址,找到当前的线程栈,从栈顶向下数6个,找到lr寄存器0x8026c345,得到调用断言函数前的语句地址:

  • 在汇编界面右键选择show Diassembly By Address,输入该地址进行跳转:

  • 可以看到,标红线的语句导致的断言,进入该函数,如下:

  • 可以看到,由于rt_interrupt_get_nest ()==0,导致断言RT_ASSERT(0)。错误描述为该函数不能在中断中调用。进入该函数:

  • 返回值为一个全局变量,返回当前的中断数,该变量在rt_interrupt_enter中自加,在rt_interrupt_leave中自减,推测为在使用时没有成对,导致中断退出后,没有减去1.在随后的线程正常运行时,中断计数值不为0,导致产生断言。

搜索代码中的调用进入中断函数osIntEnter(rt_interrupt_enter函数的封装),发现在USB枚举特殊字符串回调函数中,当序号不是0x80时,直接退出,没有释放该标志。

二、中断服务函数中调用RTT操作系统导致异常。

以太网设备,在客户处偶发设备无响应的问题(keil+GD32F307+LWIP+RTT)

由于难以复现,无法在仿真调试的环境下查找,此时可以通过客户处异常现场,读取当前寄存器和堆栈数据的方式查找问题(注意你的程序应与客户处设备的程序完全一致,否则很多地址对应不上,所以代码管理很重要)。

前提:

  • 有出现问题版本固件对应的源代码。
  • 可以通过J-link连接问题设备。
  1. 使用j-link commander工具,输入connect指令连接设备
  2. 输入regs命令
  3. 如果此时返回信息为Error:CPU is not halted,则输入h指令即可挂起CPU
  4. 保存当前截图。
  5. 通过savebin c\:ram.bin [ram起始地址,如0x20000000] [保存长度,如0xc000]保存RAM区数据
  • 通过上述操作,得到截图如下:

  • 从上面的截图可以得到:
  1. 程序最终进入了异常中断HardFault中,在源代码中输入此时的PC指针0x0802E3B4,代码定位到RTT的HardFault服务程序rt_hw_hard_fault_exception函数。
  2. CONTROL寄存器值为0,表示当前处于主栈中。
  3. 通过MSP指针获取到当前的主栈栈顶0x2000EF24。
  • 打开保存的RAM区文件,跳转到栈顶处,我们需要找到进入hardfault时系统自动保存的8个寄存器:

  • 现在我们需要从rt_hw_hard_fault_exception函数开始反推压栈情况,如下图:
  1. 进入该函数执行了压栈r4,lr,2个寄存器

  1. 搜索函数rt_hw_hard_fault_exception的调用,代码如下,可以得到前面先后压栈lr、r0,lr,r4~r11:

  1. 再向前8个寄存器即进入hardfault前内核主动压得8个栈。
  • 由上面的分析,我们得到了出现问题时如下寄存器的值:
  1. r4: 6d000000? lr: 080055c9?
  2. lr: fffffff1 r0 fffffff1? lr: fffffff1
  3. r4~r11:0000006d 2000bfb4 20006f58 00000000 00001ea0 00000001 00004ea8 deadeafe
  4. r0~r3 r12 lr pc xpsr:af080004 2000c168 00000000 20011274 2000f5e0 0802f2ef 08021100 2100004d
  • 通过在map文件/仿真中搜索pc指针0x08021100,得到函数_rt_scheduler_stack_check,即异常产生前的执行语句。
  1. 在map文件中通过地址查找对应的资源时,由于地址可能不完全对应,一般会把最后1个或2个数字删除再查找。
  • 查看_rt_scheduler_stack_check的汇编代码,看到出问题的语句,分析是由于r4异常导致的,此时r4是0x0000006d,正常其应该指向的是thread结构体的地址,很明显当前值非法。

  • 继续向前看,通过MOV语句得到r4由r0赋值,看一下_rt_scheduler_stack_check的压栈情况,压入了r4和lr,通过lr找到上一级的语句,查找r0的赋值过程:

  • 现在我们继续根据堆栈进行反查,当前lr为0x0802f2ef:

  • lr为0x0802f2ef对应的函数为rt_schedule,即rt_schedule调用_rt_scheduler_stack_check。在该语句处向上查找,确定最终给r0赋值的起始语句。

  • 通过汇编语句分析,找到起始计算的语句如下。

  • rt_thread_ready_priority_group为全局变量,代表当前已准备好的线程ID,从map中找到其地址:0x20006f64,从RAM数据文件中查看其数值,为0,即没有可调度的线程:

再看rt_thread_ready_table和线程地址双向链表rt_thread_priority_table,均没有可调度线程。

但是正常情况下,idle线程是一直处于就绪状态下的,因此上面的数据至少应包含idle线程,可以基本确认,问题出自此方面。

通过查找map文件找到idle线程结构体,分析其当前状态标志位stat,值为2,查看结构体定义注释,表示处于挂起状态RT_THREAD_SUSPEND:

  • 再回到出问题的函数rt_schedule,变量from_thread存储上一个线程地址,值存在r5中,查看r5的值为0x2000bfb4,查看map文件,对应的为idle结构体,可以确定,之前的线程是idle线程。那么很有可能是在前面的某些操作,导致idle被挂起。
  • V

  • 现在我们继续通过堆栈和函数压栈情况反查调用流程:
  1. rt_schedule压栈6个得到lr:0802fe43,找到函数rt_thread_sleep

  1. rt_thread_sleep压栈6个,找到lr:0802c66d,找到函数osDelay

  1. 压栈2个,找到lr:080315a9,找到函数sys_mbox_post,
  2. 压栈4个,找到lr:080269bd,找到函数eth_device_ready,
  3. 压栈2个,找到lr:08010a29,找到函数EthDeviceReadyNotify,
  4. 压栈2个,找到lr:0800ec8f,找到函数ENET_IRQHandler。

问题定位,原因是因为在中断服务程序中调用延迟函数rt_thread_sleep,导致的异常,RT-Thread的使用指导中有明确的的说明,在中断中是不允许操作线程相关的调度功能的。那么为什么中断中调用延迟函数会导致异常呢,我们从idle中断被挂起及线程优先级数组均为初始值来分析:

在上电时,初始化函数rt_system_scheduler_init会把双向链表每个节点都连接到该节点本身

在线程退出时,会调用rt_schedule_remove_thread初始化当前线程的节点链表,而这个函数会在下面的情况调用:

  1. 在线程退出时,rt_thread_exit
  2. 在线程挂起时,rt_thread_suspend
  3. 在线程注销时,rt_thread_detach、rt_thread_delete

由于初始化函数只会执行一次,如果是打印机重启导致变量复位,那么可以通过被初始化成0的变量当前值来确定是否复位了,通过map文件和当前ram数据,查找多个初始化为0的全局变量,如历史记录数据,值均为非0,可以基本排除复位原因。

那么原因很可能是在idle线程执行过程中,被以太网中断打断,以太网中断在某些条件下调用延迟函数,延迟函数中有操作线程状态的函数调用。

  • 之前代码看到了rt_schedule,其内部没有修改线程状态的操作,那么继续看调用它的rt_thread_sleep函数,看到语句rt_thread_suspend,前面提到过该语句会挂起线程,并将线程从就绪的链表中移除,此处会导致线程调度系统的异常。

  • 继续向前,找到调用延迟函数的地方,如下图,可以看到,在以太网中断中,通过邮箱传递接收和发送事件,当中断发生过于频繁处理不及时,会导致邮箱满从而触发延迟操作,正确的做法应该是:
  1. 调用sys_mbox_trypost,失败即退出,此时tcp协议下不返回ACK,会触发重试
  2. 增大邮箱
  3. 或者将中断函数内部程序封装到一个线程中,在线程中进行邮箱发送、等待和释放中断标志等操作。

?三、电压跌落导致sRam写操作失败导致的异常

? ? ? ?

观察上图的PC指针(指向当前代码运行地址)和SP指针(栈顶地址),完全是非法地址。

此时通过指令savebin c\:ram.bin 0x20000000 0xc000将RAM区读出来,对其中的栈区信息与map文件比较,发现代码应该是运行到了上电外设IO初始化时,给外部一个模块(可以当做一个大功率器件)供电的IO初始化后面,猜测与模块供电有关,但是上面的寄存器值无法解释。

  1. 在分析代码时,发现开关中断的函数di() ei()调用的函数操作的寄存器为FAULTMASK

该关中断寄存器会把正常中断和异常中断都关掉,若此时产生异常,则无法进入hardfault异常中断。

修改为使用PRIMASK寄存器开关中断后,抓取到的寄存器信息如下:

?????? PC指针指向了RAM区,SP指针的第四字节位于系统栈区,但是高字节应该是0x2000 0000(sRam起始地址),此处无法对应上。

?????? 而IPSR信息显示,此时进入了Hardfault,但是监控的Hardfault函数位于Flash中,与PC指针对应不上。

?????? 此时再看代码,会发现中断向量初始化函数位于外设IO初始化函数之后,而查看BOOT的map文件,发现Hardfault正是位于0x2000 23BA处,原因定位,产生异常时,由于中断向量还没有指向监控程序的中断向量位置,寄存器中存储的是BOOT的中断向量表,因此产生异常跳转时,PC指针为BOOT的Hardfault函数地址。

?????? 调整代码,将中断向量初始化函数移到最开始,再次死机时,PC指针指向了监控程序的Hardfault函数。

?????? 至此,只有SP寄存器的地址无法解释,怀疑由于它的异常导致死机。

  1. 为分析导致Hardfault的原因,将寄存器地址0xE000 ED00 ~ 0xE000 EF00的数据读出,这些寄存器为M3内核NVIC寄存器区域,通过挨个寄存器数值检查,发现产生HARDFAULT的原因为存储器管理 fault 状态寄存器(MFSR)IACCVIOL1,代表取指访问非法,因此基本可以确认,由于栈顶寄存器数据异常,导致去指定位置取值时异常。
  2. 与硬件一起测量MCUVDD引脚与模块上电IO,发现当该IO置起时,VDD有一个明显的下拉。

通道1黄色表笔:3.8V_EN

通道2绿色表笔:3.3VMCU供电电压

通道1黄色表笔:3.8V_EN,

通道3蓝色表笔:模块使能开机电流

模块电源开启时模块开机电流接近10A,造成3.3V电压跌落(如图1所示)。电压

跌至2.1V后,回弹到3.7V最后趋于3.3V稳定状态。

查看MCU数据手册

正常工作电压为2.6V,询问芯片厂商,复位电压为1.8V。因此怀疑是由于电压

突降,导致此时寄存器数据写入时部分BIT位没有写入成功,导致后面的访问异常。

  嵌入式 最新文章
基于高精度单片机开发红外测温仪方案
89C51单片机与DAC0832
基于51单片机宠物自动投料喂食器控制系统仿
《痞子衡嵌入式半月刊》 第 68 期
多思计组实验实验七 简单模型机实验
CSC7720
启明智显分享| ESP32学习笔记参考--PWM(脉冲
STM32初探
STM32 总结
【STM32】CubeMX例程四---定时器中断(附工
上一篇文章      下一篇文章      查看所有文章
加:2021-12-06 15:25:34  更:2021-12-06 15:26:08 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2025年1日历 -2025/1/9 1:07:37-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码