MIT6.s081 2021 Lab Traps

使用gdb调试xv6内核

从最近两个 Lab 开始,代码逻辑的复杂度明显上升,对内核进行调试可能是帮助理解操作系统机制的绝佳方法。因此在开始本 Lab 之前,我们先来配置一下针对 xv6 内核的 gdb 调试器。

  1. 安装 gdb-multiarch.

利用包管理工具进行安装,我使用的是 Ubuntu 系统,执行以下命令:

sudo apt install gdb-multiarch
  1. 在 xv6 项目根目录下可以看到 .gdbinit 文件,其中已经写好了一些 gdb 的初始化选项,使用文本编辑器或 cat 命令查看:
set confirm off                                                         
set architecture riscv:rv64                                             
target remote 127.0.0.1:26000                                           
symbol-file kernel/kernel                                               
set disassemble-next-line auto           
set riscv use-compressed-breakpoints yes
  1. ~/.config/gdb/ 目录下的文件 gdbinit 中(没有则新建)添加安全加载路径,否则可能无法加载 .gdbinit 的配置。
add-auto-load-safe-path <xv6项目的根目录>/.gdbinit
  1. 打开两个终端窗口(可以使用 tmux 进行分屏),都需要进入 xv6 根目录,第一个窗口输入 make-qemu 等待调试器连接,第二个窗口输入 gdb-multiarch 打开 gdb,如果前面配置正确,那么 gdb 并自动加载 .gdbinit 配置,与 qemu 连接,之后便可以开始正常调试了。

在这里插入图片描述

RISC-V assembly

一些有关 RISC-V 汇编的问题,最好先通过网上博客或手册简单了解一下 RISC-V 的基本指令。

Q1:

Which registers contain arguments to functions? For example, which register holds 13 in main’s call to printf?

A1:

可以参考 RISC-V 的 calling conventiona0 - a7: 这些寄存器用于传递函数的前八个整数或指针类型的参数,如果超出这些寄存器的数量,超出的部分会存放在栈上。观察指令 li a2,13 可知,13 作为 printf 的第二个参数,存放在寄存器 a2 中。

在这里插入图片描述

Q2:

Where is the call to function f in the assembly code for main? Where is the call to g? (Hint: the compiler may inline functions.)

A2:

调用函数 f 和函数 g 的代码被编译器优化,直接计算出了结果 12,作为 printf 的参数存入寄存器 a1 中:

26:   45b1                    li  a1,12

Q3:

At what address is the function printf located?

A3:

位于 0x638 地址处。

Q4:

What value is in the register ra just after the jalr to printf in main?

A4: 参考 riscv-calling,ra 用来存储函数调用的返回地址,因此 ra 的值为 jalr 1544(ra) 的后一条指令地址,即 0x38.

Q5:

Run the following code.

unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, &i);

What is the output? Here’s an ASCII table that maps bytes to characters.

The output depends on that fact that the RISC-V is little-endian. If the RISC-V were instead big-endian what would you set i to in order to yield the same output? Would you need to change 57616 to a different value?

Here’s a description of little- and big-endian and a more whimsical description.

A5:

  • %x 用于输出一个无符号十六进制整数。
  • %s 用于输出一个字符指针所指向的字符串,直到遇到空字符\0为止。

小端模式下,57616 的 十六进制表示为 e110,&i 首地址开始的字节分别为 0x72, 0x6c, 0x64, 0x0,对应 ASCII 表中的字符为 r, l, d,因此最终输出结果为 He110 World.

若采取大端模式,i 的值应当替换为 0x726c6400,57616 的值无需改变,因为十六进制的书写规则并没有改变(高位在左,低位在右)。

Q6:

In the following code, what is going to be printed after 'y='? (note: the answer is not a specific value.) Why does this happen?

printf("x=%d y=%d", 3);

A6:

关于可变参数的内容查看 《C Programming Language 2nd Edition》(K&R)的 7.3 节 Variable-length Argument Lists.

简而言之,这样的操作将引发未定义行为,此时 ap 指向了一个未知的内存区域,并将该区域的数据以整型的形式输出。

Backtrace

思路

思路其实很简单:对照 lecture notes 给出的栈的结构,从当前栈帧的起始地址 fp 开始,fp - 8 的位置存放着当前函数调用的返回地址(上一次函数调用处的下一条指令地址),即我们 需要打印 的地址,fp - 16 的位置存放着上一次函数调用所在栈帧的起始地址,将该地址作为新的 fp 重复上述步骤即可。

在这里插入图片描述

关键问题是 什么时候停止 ?可以看到上述 backtrace 的过程就好像是在遍历一个链表,当链表的 next 域为空指针时链表到达末尾,那 traceback 完成后fp 的值应该是什么?为了寻找这个问题的答案,我选择先不设置终止条件,让它一直向上搜索,最后发现,返回地址最终为一个很小的值,这个地址显然不是我们想要的,在此之前应该退出,即本次 traceback 的尽头是 0x80001c92.

在这里插入图片描述

但打印出来的函数调用的返回地址似乎并没有什么规律,因此我又尝试将遍历过程中的栈帧起始地址 fp 打印出来,得到以下结果:

在这里插入图片描述

结合提示:

Xv6 allocates one page for each stack in the xv6 kernel at PAGE-aligned address.

原因就很明显了,在打印第三个返回地址时,此时栈帧起始地址为 0x3fffffa000,注意该地址后 12 二进制数为 0,且页面大小为 4KB,因此该地址位于一个页面的起始地址。又因为 xv6 内核只为每个 内核栈 分配一个页面的存储空间,该页面的起始地址按页面大小对齐,所以此时已经到达一个内核栈的顶端,无需继续遍历。

弄清楚了这些,代码的编写就很简单了:

void backtrace(void) {printf("backtrace:\n");uint64 fp = r_fp();uint64 top = PGROUNDUP(fp);do {printf("%p\n", *(uint64 *)(fp - 8));fp = *(uint64 *)(fp - 16);} while (fp < top); // reach the top of kernel stack
}

代码

--- a/kernel/defs.h
+++ b/kernel/defs.h
@@ -80,6 +80,7 @@ int             pipewrite(struct pipe*, uint64, int);void            printf(char*, ...);void            panic(char*) __attribute__((noreturn));void            printfinit(void);
+void			backtrace(void); // here// proc.cint             cpuid(void);
diff --git a/kernel/printf.c b/kernel/printf.c
index e1347de..a068cbd 100644
--- a/kernel/printf.c
+++ b/kernel/printf.c
@@ -114,6 +114,23 @@ printf(char *fmt, ...)release(&pr.lock);}+// here
+void backtrace(void) {
+	printf("backtrace:\n");
+	uint64 fp = r_fp();
+	uint64 top = PGROUNDUP(fp);
+
+	do {
+		printf("%p\n", *(uint64 *)(fp - 8));
+		fp = *(uint64 *)(fp - 16);
+	} while (lower < top);
+}
+voidpanic(char *s){
diff --git a/kernel/riscv.h b/kernel/riscv.h
index 1691faf..fae7bf3 100644
--- a/kernel/riscv.h
+++ b/kernel/riscv.h
@@ -331,6 +331,15 @@ sfence_vma()asm volatile("sfence.vma zero, zero");}+// here
+static inline uint64
+r_fp()
+{
+  uint64 x;
+  asm volatile("mv %0, s0" : "=r" (x) );
+  return x;
+}
+#define PGSIZE 4096 // bytes per page#define PGSHIFT 12  // bits of offset within a page
diff --git a/kernel/sysproc.c b/kernel/sysproc.c
index e8bcda9..f27c007 100644
--- a/kernel/sysproc.c
+++ b/kernel/sysproc.c
@@ -70,6 +70,7 @@ sys_sleep(void)sleep(&ticks, &tickslock);}release(&tickslock);
+  backtrace(); // herereturn 0;}

Alarm

思路

目前为止感觉最复杂的一题,需要对 trap 机制有一个比较深入的理解,建议在上手之前先仔细阅读与 trap 有关的代码:kernel/trampoline.Skernel/trap.c,这里也推荐一位博主写的两篇有关 xv6 的 trap 机制的博客:

6.S081——陷阱部分(一文读懂xv6系统调用)——xv6源码完全解析系列(5)

6.S081——补充材料——RISC-V架构中的异常与中断详解

test0: invoke handler

我们不妨按照提示的顺序来进行,不关注 sys_sigreturn,先把 sys_sigalarm 的功能实现。

实际上,sys_sigalarm 函数的功能很简单,只是简单地将用户态下传递的参数 tickshandler 存入进程的 struct proc 结构体中。实现调用 handler 的操作需要在内核态下的 usertrap 中完成,具体来说,针对时钟中断导致的 trap 将在 if(which_dev == 2) 后的语句中被处理。有两个目标需要完成: 定时函数调用

定时的逻辑比较清楚,在 struct proc 中添加变量 ticksum,代表从上次 handler 处理完成开始进程累计的时钟中断次数,该变量在进程初始化时设置为 0,随后每次遇到时钟中断,都自增 1,如果自增后的值达到了设定的间隔 ticks,则将其复位为 0,调用 handler 函数。

函数调用是一个需要考虑的问题,这里不能直接利用函数指针 handler 进行函数调用,因为 handler 指向的函数位于用户空间下,而 usertrap 位于内核态下,页表的地址映射不同,无法直接根据用户空间下的虚拟地址进行寻址(直接调用引发的错误如下图所示),需要在本次中断结束返回到用户态之后执行。因此正确的做法应该是设置进程 struct procepc 寄存器为函数指针 handler,这样在中断处理完成,进程回到用户态并被 CPU 调度执行后,寄存器 pc 将被设置预先保存的 epc 的值,这样函数 handler 就被成功调度执行了。至此,test0 应该成功通过。

在这里插入图片描述

在进入到 test1&2 之前,有必要说一说我的一些思考:在上面的讨论中,我们知道内核无法直接根据函数指针 handler 的值进行用户空间函数的调用,那能否在内核态下根据进程的用户态页表和给定的虚拟地址,利用软件地址转换机制(vm.c 中的 walkaddr 函数)来将用户空间的虚拟地址转换为物理地址进行寻址呢(这也是我最开始的想法)?答案是不行,因为即便是在内核态下,程序中的地址仍然是虚拟地址,也就是说即便知道用户态函数实际存储的物理地址,我们也只有在 给出一个虚拟地址,该虚拟地址经过内核页表地址转换之后,刚好得到了正确的物理地址, 才可能成功。而实际上,尽管内核 KERNBASEPHYSTOP 地址都是直接映射,但内核页表中可能并没有所需要的页表项,因此,这并不会成功。

test1/test2(): resume interrupted code

test1 的目标是,存储和恢复中断处理前后的寄存器状态。那么问题就来了:为什么需要存储这些寄存器?需要存储哪些寄存器?

其实最开始,我是有些纠结寄存器状态的存储目的是什么,认为可能是与内核态和用户态切换有关,但仔细想想,这部分的工作应该是由 trampoline.Susertrapret 来完成的,那么为什么还需要存储和恢复寄存器?

事实上,在系统未关闭中断的情况下,时钟中断可能在程序执行的任何时刻发生,且在返回到原程序位置继续执行之前还需要执行预先设定好的 handler 函数,那么寄存器状态的保存将是必要的。一方面在执行 handler 函数期间,如果 handler 函数包含一些对局部变量的处理,那么通用寄存器的值将会发生改变,从而使得中断返回时程序的执行结果与预期不符;另一方面,由于 epc 的值被手动改变,如果执行完 handler 之后不恢复中断发生时的保存的 pc 值,那么 pc 将会指向 handler 函数末尾的下一条指令,中断因此无法正常返回。 简单来说,这部分的操作相当于手动模拟了 线程 的切换。

另一个问题是:需要存储哪些寄存器?好吧,在解决这个 Lab 时我其实偷了点懒,没有去仔细琢磨,只是简单地将整个 trapframe 中所有的寄存器都保存下来。但根据上面的讨论,再结合 RISC-V 的 calling convention,应该不难得出答案。

最后的 test2 就比较简单了,目标是:

Prevent re-entrant calls to the handler----if a handler hasn’t returned yet, the kernel shouldn’t call it again.

解决的办法有很多,可以额外在 strcut proc 添加一个变量,用来表示进程当前是否正处在处理 handler 的过程中,如果是,则不进行 ticksum 的自增操作。这里我采用了一点 小技巧 :不添加额外的变量,而是在处理 handler 前将 ticksum 置为负数,并在自增前判断 ticksum 是否非负,在 sys_sigreturn 时再将它置为 0,本质上与添加变量的操作大差不差。

代码

diff --git a/Makefile b/Makefile
index 7a7e380..bc4d47a 100644
--- a/Makefile
+++ b/Makefile
@@ -188,6 +188,7 @@ UPROGS=\$U/_grind\$U/_wc\$U/_zombie\
+	$U/_alarmtest\diff --git a/kernel/proc.c b/kernel/proc.c
index 22e7ce4..80096f7 100644
--- a/kernel/proc.c
+++ b/kernel/proc.c
@@ -119,6 +119,7 @@ allocproc(void)found:p->pid = allocpid();p->state = USED;
+  p->ticksum = 0; // here// Allocate a trapframe page.if((p->trapframe = (struct trapframe *)kalloc()) == 0){
diff --git a/kernel/proc.h b/kernel/proc.h
index f6ca8b7..c1d5a23 100644
--- a/kernel/proc.h
+++ b/kernel/proc.h
@@ -105,4 +105,10 @@ struct proc {struct file *ofile[NOFILE];  // Open filesstruct inode *cwd;           // Current directorychar name[16];               // Process name (debugging)
+
+  int ticks;         // here
+  void (*handler)();
+  int ticksum;
+
+  struct trapframe strapframe;};
diff --git a/kernel/syscall.c b/kernel/syscall.c
index c1b3670..d4e5585 100644
--- a/kernel/syscall.c
+++ b/kernel/syscall.c
@@ -104,6 +104,8 @@ extern uint64 sys_unlink(void);extern uint64 sys_wait(void);extern uint64 sys_write(void);extern uint64 sys_uptime(void);
+extern uint64 sys_sigalarm(void); // here
+extern uint64 sys_sigreturn(void);static uint64 (*syscalls[])(void) = {[SYS_fork]    sys_fork,
@@ -127,6 +129,8 @@ static uint64 (*syscalls[])(void) = {[SYS_link]    sys_link,[SYS_mkdir]   sys_mkdir,[SYS_close]   sys_close,
+[SYS_sigalarm]  sys_sigalarm,  // here
+[SYS_sigreturn] sys_sigreturn,};void
diff --git a/kernel/syscall.h b/kernel/syscall.h
index bc5f356..a040610 100644
--- a/kernel/syscall.h
+++ b/kernel/syscall.h
@@ -20,3 +20,5 @@#define SYS_link   19#define SYS_mkdir  20#define SYS_close  21
+#define SYS_sigalarm  22  // here
+#define SYS_sigreturn 23
diff --git a/kernel/sysproc.c b/kernel/sysproc.c
index f27c007..ee859ed 100644
--- a/kernel/sysproc.c
+++ b/kernel/sysproc.c
@@ -96,3 +96,28 @@ sys_uptime(void)release(&tickslock);return xticks;}
+
+// here
+uint64 sys_sigalarm(void) {
+	struct proc *p = myproc();
+
+	if (argint(0, &(p->ticks)) < 0) {
+		return -1;
+	}
+
+	if (argaddr(1, (uint64 *)&(p->handler)) < 0) {
+		return -1;
+	}
+
+	return 0;
+}
+
+uint64 sys_sigreturn(void) {
+	struct proc *p = myproc();
+	
+	// restore registers
+	memmove(p->trapframe, &(p->strapframe), sizeof(p->strapframe));
+
+	p->ticksum = 0;
+	return 0;
+}
diff --git a/kernel/trap.c b/kernel/trap.c
index a63249e..447e6d8 100644
--- a/kernel/trap.c
+++ b/kernel/trap.c@@ -77,8 +77,17 @@ usertrap(void)exit(-1);// give up the CPU if this is a timer interrupt.
-  if(which_dev == 2)
+  if(which_dev == 2) {
+	// here
+	if (p->ticks > 0 && p->ticksum >= 0 && ++(p->ticksum) >= p->ticks) {
+	  // save registers
+	  memmove(&(p->strapframe), p->trapframe, sizeof(p->strapframe));
+
+	  p->ticksum = -1; // prevent re-entrant calls to the handler
+	  p->trapframe->epc = (uint64)p->handler;
+	}yield();
+  }usertrapret();}
diff --git a/user/user.h b/user/user.h
index b71ecda..422a4c1 100644
--- a/user/user.h
+++ b/user/user.h
@@ -23,6 +23,8 @@ int getpid(void);char* sbrk(int);int sleep(int);int uptime(void);
+int sigalarm(int ticks, void (*handler)()); // here
+int sigreturn(void);// ulib.cint stat(const char*, struct stat*);
diff --git a/user/usys.pl b/user/usys.pl
index 01e426e..84c6784 100755
--- a/user/usys.pl
+++ b/user/usys.pl
@@ -36,3 +36,5 @@ entry("getpid");entry("sbrk");entry("sleep");entry("uptime");
+entry("sigalarm"); # here
+entry("sigreturn");

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

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

相关文章

接口测试工具Postman

Postman Postman介绍 开发API后&#xff0c;用于API测试的工具。在我们平时开发中&#xff0c;特别是需要与接口打交道时&#xff0c;无论是写接口还是用接口&#xff0c;拿到接口后肯定都得提前测试一下。在开发APP接口的过程中&#xff0c;一般接口写完之后&#xff0c;后端…

python基础篇(8):异常处理

在Python编程中&#xff0c;异常是程序运行时发生的错误&#xff0c;它会中断程序的正常执行流程。异常处理机制使得程序能够捕获这些错误&#xff0c;并进行适当的处理&#xff0c;从而避免程序崩溃。 1 错误类型 代码的错误一般会有语法错误和异常错误两种&#xff0c;语法错…

CAN总线(下)

位时序 为了灵活调整每个采样点的位置&#xff0c;使采样点对齐数据位中心附近&#xff0c;CAN总线对每一个数据位的时长进行了更细的划分&#xff0c; 分为同步段&#xff08;SS&#xff09;、传播时间段&#xff08;PTS&#xff09;、相位缓冲段1&#xff08;PBS1&#xff0…

Python实战训练(方程与拟合曲线)

1.方程 求e^x-派&#xff08;3.14&#xff09;的解 用二分法来求解&#xff0c;先简单算出解所在的区间&#xff0c;然后用迭代法求逼近解&#xff0c;一般不能得到精准的解&#xff0c;所以设置一个能满足自己进度的标准来判断解是否满足 这里打印出解x0是因为在递归过程中…

详解AT_dp_l Deque(区间动态规划)

题目 思路 考虑模拟博弈过程。 题目可以看成:先手希望X - Y最大&#xff0c;后手希望X - Y最小。 显然游戏过程中剩下的数必然是连续的一段。设 dp[i,j]​ 表示剩下下标为 [i,j] 的数时&#xff0c;先手&#xff08;并非当前的先手而是开始时的先手&#xff0c;下同&#xf…

Zabbix触发器

目录 触发器基础概念 创建和管理触发器 示例 定义一个触发器 在 Zabbix 中&#xff0c;触发器&#xff08;Trigger&#xff09;用于定义在监控数据满足特定条件时触发警报或动作。触发器是实现监控告警和自动响应的核心组件之一。以下是关于 Zabbix 触发器的详细解释和用法…

【JAVA多线程】线程池概论

目录 1.概述 2.ThreadPoolExector 2.1.参数 2.2.新任务提交流程 2.3.拒绝策略 2.4.代码示例 1.概述 线程池的核心&#xff1a; 线程池的实现原理是个标准的生产消费者模型&#xff0c;调用方不停向线程池中写数据&#xff0c;线程池中的线程组不停从队列中取任务。 实现…

动手学深度学习(Pytorch版)代码实践 -循环神经网络-54循环神经网络概述

54循环神经网络概述 1.潜变量自回归模型 使用潜变量h_t总结过去信息 2.循环神经网络概述 ​ 循环神经网络&#xff08;recurrent neural network&#xff0c;简称RNN&#xff09;源自于1982年由Saratha Sathasivam 提出的霍普菲尔德网络。循环神经网络&#xff0c;是指在全…

封锁-封锁模式(共享锁、排他锁)、封锁协议(两阶段封锁协议)

一、引言 1、封锁技术是目前大多数商用DBMS采用的并发控制技术&#xff0c;封锁技术通过在数据库对象上维护锁来实现并发事务非串行调度的冲突可串行化 2、基于锁的并发控制的基本思想是&#xff1a; 当一个事务对需要访问的数据库对象&#xff0c;例如关系、元组等进行操作…

uniapp跨域问题解决

找到menifest文件&#xff0c;在文件的最后添加如下代码&#xff1a; // h5 解决跨域问题"h5":{"devServer": {"proxy": {"/adminapi": {"target": "https://www.demo.com", // 目标访问网址"changeOrigin…

基于SpringBoot+Vue的招生管理系统(带1w+文档)

基于SpringBootVue的招生管理系统(带1w文档&#xff09; 通过招生管理系统的研究可以更好地理解系统开发的意义&#xff0c;而且也有利于发展更多的智能系统&#xff0c;解决了人才的供给和需求的平衡问题&#xff0c;招生管理系统的开发建设&#xff0c;由于其开发周期短&…

【Linux】进程优先级 + 环境变量

前言 在了解进程状态之后&#xff0c;本章我们将来学习一下进程优先级&#xff0c;还有环境变量等。。 目录 1.进程优先级1.1 为什么要有优先级&#xff1f; 2.进程的其他概念2.1 竞争性与独立性2.2 并行与并发2.3 进程间优先级的体现&#xff1a;2.3.1 O(1) 调度算法&#xf…

【IMU】 确定性误差与IMU_TK标定原理

1、确定性误差 MEMS IMU确定性误差模型 K 为比例因子误差 误差来源:器件的输出往往为脉冲值或模数转换得到的值,需要乘以一个刻度系数才能转换成角速度或加速度值,若该系数不准,便存在刻度系数误差。 T 为交轴耦合误差 误差来源:如下图,b坐标系是正交的imu坐标系,s坐标系的三…

跨境干货|最新注册Google账号方法分享

谷歌账号对做跨境外贸业务的人来说是刚需&#xff0c;目前来说大部分的海外社媒平台、工具都可以用谷歌账号来注册。但是仍然有很多朋友并不知道如何注册这个谷歌账号&#xff0c;今天就来给大家分享2个注册谷歌账号的方法&#xff0c;一个是手机号注册&#xff0c;一个是如何跳…

SpringBoot+mail 轻松实现各类邮件自动推送

一、简介 在实际的项目开发过程中&#xff0c;经常需要用到邮件通知功能。例如&#xff0c;通过邮箱注册&#xff0c;邮箱找回密码&#xff0c;邮箱推送报表等等&#xff0c;实际的应用场景非常的多。 早期的时候&#xff0c;为了能实现邮件的自动发送功能&#xff0c;通常会…

Ubuntu 22.04.4 LTS 安装配置 MySQL Community Server 8.0.37 LTS

1 安装mysql-server sudo apt update sudo apt-get install mysql-server 2 启动mysql服务 sudo systemctl restart mysql.service sudo systemctl enable mysql.service #查看服务 sudo systemctl status mysql.service 3 修改mysql root密码 #默认密码为空 sudo mysql …

基于Android Studio订餐管理项目

目录 项目介绍 图片展示 运行环境 获取方式 项目介绍 能够实现登录&#xff0c;注册、首页、订餐、购物车&#xff0c;我的。 用户注册后&#xff0c;登陆客户端即可完成订餐、浏览菜谱等功能&#xff0c;点餐&#xff0c;加入购物车&#xff0c;结算&#xff0c;以及删减…

【Spring Cloud】微服务的简单搭建

文章目录 &#x1f343;前言&#x1f384;开发环境安装&#x1f333;服务拆分的原则&#x1f6a9;单一职责原则&#x1f6a9;服务自治&#x1f6a9;单向依赖 &#x1f340;搭建案例介绍&#x1f334;数据准备&#x1f38b;工程搭建&#x1f6a9;构建父子工程&#x1f388;创建父…

LabVIEW幅频特性测试系统

使用LabVIEW软件开发的幅频特性测试系统。该系统整合了Agilent 83732B信号源与Agilent 8563EC频谱仪&#xff0c;通过LabVIEW编程实现自动控制和数据处理&#xff0c;提供了成本效益高、操作简便的解决方案&#xff0c;有效替代了昂贵的专用仪器&#xff0c;提高了测试效率和设…

聊天室时间构思

记得选择数据库的Data.sql 如果有一方发信息&#xff0c;显示时间&#xff0c;显示发送信息 设置计时器&#xff0c;如果在一分钟&#xff0c;60*1000L毫秒有回复&#xff0c;不显示时间&#xff0c;否则显示时间在显示信息 具体就看哔哩哔哩哔哩哔哩 设置两个时间&#xff0…