从裸机启动开始运行一个C++程序(十一)

news2024/11/18 20:40:57

前序文章请看:
从裸机启动开始运行一个C++程序(十)
从裸机启动开始运行一个C++程序(九)
从裸机启动开始运行一个C++程序(八)
从裸机启动开始运行一个C++程序(七)
从裸机启动开始运行一个C++程序(六)
从裸机启动开始运行一个C++程序(五)
从裸机启动开始运行一个C++程序(四)
从裸机启动开始运行一个C++程序(三)
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)

Hello, C World!

我们虽然已经成功驱动C语言代码了,但仅仅是通过bochs断点来看看出入栈那也未免太无聊了,咱们肯定是希望能用C语言来写功能的。

我相信很多读者应该跟我一样,第一反应就是写个Hello, World!,但当前我们在内核态上运行程序,这个过程会复杂很多。标准库中的printf函数是要依赖OS所提供的stdout接口的,只有这样程序才能知道要把需要输出的数据送到哪里,OS也才能通过控制台来显式。而现在咱们什么都没,所以只能自己来实现文字输出。

虽然没人给我们提供stdout,但咱们是在内核态呀!是可以直接写显存的呀!咱们直接给显存里写数据,不就可以达到输出字符的功能了吗?

思路有了,接下来我们需要确定细节。之前已经尝试了局部变量,编译器会按照栈空间的方式来处理,也就是取决于进入函数之前ssesp的值。可现在咱们要操作显存,这是一个固定的内存地址,这如何操作呢?我们来做个实验,看看下面的程序会如何编译:

void Entry() {
  unsigned char *p = (unsigned char *)0xb8000; // 尝试定义一个指针
  *p = 0x40; // 看看这个值究竟会写到哪里 
}

上面我们定义了一个指针,值是0xb8000,那么这样做能否让他真的指向现存呢?咱们用-S参数来把它编译成汇编看看结果(省略无关内容):

_Entry:                                 
	push	ebp
	mov	ebp, esp
	push	eax
	mov	eax, 753664
	mov	dword ptr [ebp - 4], eax
	mov	eax, dword ptr [ebp - 4]
	mov	byte ptr [eax], 64 ; 重点关注这一行
	add	esp, 4
	pop	ebp
	ret

可以看到,ebp - 4就是这个变量p的位置,由于是栈寄存器,所以默认取的是ss段,也就是说p的实际地址是ss:ebp-4。我们之前配置好了ss寄存器,所以这里没有问题。

但后面,解指针操作*p = 0x40这一步,我们看到首先从ebp-4的位置读取数据到eax中,然后直接操作eax取址来写数据。但eax是通用寄存器,它的默认段是ds段,也就是说,mov byte [eax], 64其实是mov byte [ds:eax], 64

因此我们得出结论:指针的值,是ds段对应的偏移地址,而并非实际的物理地址。

了解了这个就好办了,在进入Entry函数之前,我们只要把ds配置成显存段即可,这样进入Entry后,指针的值就是显存段的偏移地址。

[bits 32]
section .text 

begin:

mov ax, 00011_00_0b ; 选择3号段,数据段
mov ss, ax
mov eax, 0x1000
mov esp, eax    ; 设置初始栈顶
mov ebp, eax    ; ebp也记录初始栈顶

; 把ds配成显存段
mov ax, 00010_00_0b
mov ds, ax
; 进入Entry后,指针的偏移地址就是相对0xb8000extern Entry
call Entry

hlt

然后我们尝试在Entry()中操作显存:

void Entry() {
  unsigned char *p = (unsigned char *)0x0; // 指向显存首地址
  *p = 'H';
  p[1] = 0x0f; // 黑底白色
  p[2] = 'i';
  p[3] = 0x0f;
}

咱们构建并运行一下,看看效果:
运行效果1

没问题!确实可以这么搞。

不过先别急着去封装putchar,这样做是有个潜在问题的,请看下面示例:

void Entry() {
  int a = 5;
  int *p = &a;
  *p = 10; // 这一步会不会有问题呢?
}

既然我们已经知道,指针的值是相对于ds段的偏移地址了,那我们对局部变量取地址取到的是什么?是ds段的还是ss段的呢?还是,编译成汇编看看结果就很清晰了:

_Entry: 
	push	ebp
	mov	ebp, esp
	sub	esp, 8
	mov	dword ptr [ebp - 4], 5   ; int a = 5;
	lea	eax, [ebp - 4]           ; 注意看这一句
	mov	dword ptr [ebp - 8], eax
	mov	eax, dword ptr [ebp - 8]
	mov	dword ptr [eax], 10		 ; 解指针时仍然是用ds:eax
	add	esp, 8
	pop	ebp
	ret

再次解释一下这里的lea命令,这个就是取地址命令,也就是取[ebp-4]的地址,其实等价于我们理解的mov eax, ebp-4,但是因为没有这个汇编指令,所以必须写成lea eax, [ebp-4]。而因为这里是ebp,所以它匹配的段是ss

那么这样就出现了一个很严重的问题,我们取地址的时候是取的ss段的偏移地址,但是解指针的时候却是ds段的偏移地址。这显然是要出大问题的呀!

为什么会发生这样的现象?其实很容易理解,因为对于C语言来说,我们通常认为,能走到C语言的过程中开始,就应当使用上层程序语言的思路来进行开发了,而不应该到处还在纠结这些底层实现。所以在C语言的世界观中,「栈段」和「数据段」应该是一起的才对,至少在C的语义层面,不应该区分它。

既然如此,我们直接把ds给成显存段就是一个不合理的操作了,我们应当按照C的标准要求,将dsss保持一致才对,这样无论是栈空间还是指针的值,都在同一个段中。

可如果我么把ds也配成数据段的话,写显存的需求要怎么办呢?这个问题,咱们还是要回归到C语言的语义本身上来。如果说你并不理解底层显存这件事的话,让你用C语言输出一个字符,你会首先想到什么?肯定是通过putchar函数来完成。而至于这个函数内部怎么实现的,会把这个数据写到哪里,那应该是OS操心的事。

因此,解决方案也就很清晰了,我们要实现类似于putchar的函数来专门进行输出,而不是直接使用显存的偏移地址。不过putchar还存在光标管理、换行等问题,我们稍后再来实现,现在先简单针对「写显存」这件事。

既然在C语言中没法制定段寄存器,那么在制定段写数据的这件事就只能由汇编来实现了,因此,咱们在工程中新建一个文件asm_func.nas,专门用来实现一些C语言无法直接实现的功能,同样地,它也需要参与Kernel的链接过程。

首先,咱们就先做一个最简单的,实现在显存段的制定地址写一个制定的数据这样一个功能,函数原型是:

void SetVMem(long addr, unsigned char data); // 在显存的addr偏移地址出写入data数据

由于addr是表示偏移地址,在32位环境下,偏移地址应该也是32位数据类型,所以这里写了long。(C语言32位环境下long类型是32位的。)

我们之前已经实现过单个参数传递的调用过程,我们知道在call之前会把参数压栈,过程中再通过ebp+8去找到参数。多个参数则是同样的做法,只不过要进行多次压栈。而对于C语言的函数调用有一个规定,就是按照函数声明的逆序进行压栈。对于上面的函数来说就是会先压栈data,然后压栈addr,然后再call SetVMem这样。

另一个要注意的问题是,虽然dataunsigned char类型,只占一个字节,但由于push操作是匹配指令集位宽的,也就是32位,所以它在栈中实际上也会占4个字节的大小。

这样就明确了,ebp+8就是addrebp+12就是data(这里注意,先压栈的在上面,data是先压栈的,所以它在更高地址的位置)。我们就可以来实现SetVMem了,下面是asm_func.nas的内容:

[bits 32]
section .text 

global SetVMem ; 告诉链接器下面这个标签是外部可用的
SetVMem:
    ; 现场记录
    push ebp
    mov ebp, esp
    ; 过程中用到的寄存器都要先记录
    push ebx
    push ecx
    push edx

    mov bx, es ; 用bx记录原本的es,用于后续恢复现场(这里是因为寄存器还够用,如果不够用的话就还是要压栈)
    ; 把es配成显存段
    mov dx, 00010_00_0b
    mov es, dx
    ; 通过参数找到addr和data
    mov edx, [ebp+8]  ; addr
    mov ecx, [ebp+12] ; data
    ; 通过es加偏移地址来操作显存
    mov [es:edx], cl  ; 由于data是1字节的,所以其实只有cl是有效数据

    ; 现场还原
    mov es, bx
    pop edx
    pop ecx
    pop ebx
    mov esp, ebp
    pop ebp
    ; 回跳
    ret

然后我们也将kernal.nas中的段寄存器重新配置:

[bits 32]
section .text 

begin:

mov ax, 00011_00_0b ; 选择3号段,数据段
mov ss, ax
; ds要跟ss一致
mov ds, ax
; es也初始化为数据段(防止后续出问题,先初始化)
mov es, ax

; 初始化栈
mov eax, 0x1000
mov esp, eax    ; 设置初始栈顶
mov ebp, eax    ; ebp也记录初始栈顶

extern Entry
call Entry

hlt

那么,在entry.c中如何调用呢?自然也是通过函数声明了, 不过这里要按照C语言的方式进行声明,下面是修改后的entry.c:

// 函数声明,实现是用汇编的,链接时会匹配
extern void SetVMem(long addr, unsigned char data);

void Entry() {
  SetVMem(0, 'H');
  SetVMem(1, 0x0f);
  SetVMem(2, 'i');
  SetVMem(3, 0x0f);
}

记得要把asm_fun.nas的处理也写在makefile中,不然setVMem函数声明了却没有实现,会链接报错的:

.PHONY: all
all: sys

.PHONY: run
run: bochsrc sys
	bochs -qf bochsrc

a.img:
	rm -f a.img
	bximage -q -func=create -hd=4096M $@

sys: a.img mbr.bin kernel_final.bin
	dd if=mbr.bin of=a.img conv=notrunc
	dd if=kernel_final.bin of=a.img bs=512 seek=1 conv=notrunc

mbr.bin: mbr.nas
	nasm mbr.nas -o mbr.bin

kernel.o: kernel.nas
	nasm kernel.nas -f elf -o kernel.o

entry.o: entry.c
	x86_64-elf-gcc -c -m32 -march=i386 entry.c -o entry.o

asm_func.o: asm_func.nas
	nasm asm_func.nas -f elf -o asm_func.o

kernel_final.out: kernel.o entry.o asm_func.o
	x86_64-elf-ld -m elf_i386 kernel.o entry.o asm_func.o -o kernel_final.out

kernel_final.bin: kernel_final.out
	x86_64-elf-objcopy -I elf32-i386 -S -R ".eh_frame" -R ".comment" -O binary kernel_final.out kernel_final.bin

.PHONY: clean
clean:
	-rm -f .DS_Store
	-rm -f *.bin 
	-rm -f *.img
	-rm -f *.o
	-rm -f *.out
	-rm -f *.gas

下面是运行结果:
运行结果2

由此,我们实现了C跟汇编的联动,在不去魔改C配置的情况下,用汇编实现局部功能的方式,间接实现了在C中控制显存的功能。

目前的项目工程会我会上传到附件中,作为一个单独目录,读者可以参考。

继续封装一把

现在这种情况,我们调用SetVMem函数来输出着实是有点奇怪了,所以咱们乘胜追击,来封装一个putchar函数。

同样地,我们需要一个全局变量来记录光标信息,不过这次是在C语言上了,难度会低很多:

extern void SetVMem(long addr, unsigned char data);

// 定义光标信息
typedef struct {
  long offset; // 暂时只需要一个偏移量
} CursorInfo;

CursorInfo g_cursor_info = {0}; // 全局变量,保存光标信息

// 这里我们按照C标准库中的函数原型来定义
int putchar(int ch) {
  if (ch == '\n') { // 处理换行
    g_cursor_info.offset += 80 * 2; // 一行是80字符
    g_cursor_info.offset -= ((g_cursor_info.offset / 2) % 80) * 2; // 回到行首
  } else {
    SetVMem(g_cursor_info.offset++, (unsigned char)ch);
    SetVMem(g_cursor_info.offset++, 0x0f);
  }
  return ch;
}

void Entry() {
  putchar('H');
  putchar('i');
}

看似没有问题,不过如果直接运行的话,你会发现,又触发异常中断了。唉,内核态程序真的好脆弱呀~

原因主要是全局变量的处理上。我们之前已经踩过了栈空间和指针不匹配的坑,现在终于踩到了一个新坑,就是全局变量的坑上。排查问题的思路相同,还是编译成汇编来看,不过因为上面代码稍微有点复杂,我们简化一下,只看全局变量的情况:

int g_value = 5; // 定义全局变量
void Entry() {
  g_value = 10;
}

上面这个程序会怎样被编译呢?我们看看结果:

.text
	.globl	g_value
	.data
	.align 4
	.type	g_value, @object
	.size	g_value, 4
g_value:                 ; 注意这里,全局变量用的是标签直接表示地址
	.long	5
	.text
	.globl	Entry
	.type	Entry, @function
Entry:
	push	ebp
	mov	ebp, esp
	mov	DWORD PTR g_value, 10 ; 这里也是使用标签来表示地址的。
	nop
	pop	ebp
	ret

可以看到,全局变量并不是通过mov指令来存储的,而是随着指令本身,一起加载到了内存中。既然是用标签来表示地址的,按照前面我们编写汇编时的规律,这个标签,将会被翻译为相对于文件的偏移地址。

这是继ssds后,出现的第三个偏移地址,相对于文件的偏移地址。注意,当前这个文件是后续要参与链接的,所以事实上它并不是相对于entry.gas的偏移地址,而是相对于kernel_final.bin的位置,请大家一定要明确。既然是当前文件的位置,那自然,它的物理地址取决于实际指令加载的内存位置,而这个内存加载位置确实以cs的段为段基址的。

其实不仅是全局变量,函数指针也会有类似的问题,比如:

void f() {}
void Entry() {
  void (*fptr)() = f; // 这里的fptr其实取的是f相对于cs的偏移地址,读者可以自行验证
}

既然如此,我们难道要将dsss转换成代码段吗?这显然是不可行的,毕竟代码段只能用来执行,不能用来操作(因为Type字段配置问题)。

虽然我们不应该让dsss选择代码段,但我们可以让代码段和数据段的基址相同(大小可以不同)。换言之,由于C语言代码编译后,代码段之间会夹杂很多全局、静态数据在里面,因此,代码段应当作为数据段的一部分,并且他们的首地址相同。当然,数据段可以比代码段长一些。

由此,我们不得不再次回MBR重新配置一下GDT:

; 3号段-数据段(要包含和对其代码段)
; 基址0x8000,大小4MB
mov [es:0x18], word 0x03ff ; Limit=0x400,这是低8位
mov [es:0x1a], word 0x8000 ; Base=0x00008000,这是低16位
mov [es:0x1c], byte 0x0000 ; 这是Base的16~23位
mov [es:0x1d], byte 1_00_1_001_0b ; P=1, DPL=0, S=1, Type=001b, A=0
mov [es:0x1e], byte 1_1_00_0000b  ; G=1, D/B=1, AVL=00, Limit的高4位是0000
mov [es:0x1f], byte 0x00   ; 这是Base的高8

不过这样做马上也能发现一些隐患,比如说数据段的跨度其实包含了显存,而且也包含了GDT的位置。这显然是非常不安全的。

正常的OS肯定不会在MBR中直接加载好所有的配置,MBR只加载一个Boot程序,并进入IA-32模式,然后在这个Boot程序中,再重新读盘,并把真正的Kernel加载到更高地址(至少是大于1MB的部分)去,那么此时再去重新分段就不会出现我们现在这种Kernel只能在前1MB的空间里,于是不得不把段首分在这里的这种情况了。

但目前我们就先这样做吧,把程序跑起来再说。将数据段重新配置后,我们再来尝试一下运行包含了之前entry.c代码的整个工程,看看putchar函数能否如预期那样执行:
运行结果3

可以看到,在尝试操作全局变量的时候仍然出现了问题,而且这个0x08049144着实是个很奇怪的地址,这是为什么呢?

原因在于,ld在链接时,有一个默认的代码加载偏移地址,这个地址并不是0,所以,相当于代码中所有标签都加上了一个偏移量。

而现在我们需要让他们统一起来,因此要在链接时添加一个参数,指定文件基础偏移量是0,这个参数是-Ttext=0,完整的链接命令如下:

x86_64-elf-ld -m elf_i386 -Ttext=0 kernel.o entry.o asm_func.o -o kernel_final.out

这下再运行,没问题了!
运行结果4

乘胜追击

既然putchar都实现了,我们就再乘胜追击一下,实现一个puts吧,废话不多说,直接上代码:

extern void SetVMem(long addr, unsigned char data);

// 定义光标信息
typedef struct {
  long offset; // 暂时只需要一个偏移量
} CursorInfo;

CursorInfo g_cursor_info = {0}; // 全局变量,保存光标信息

int putchar(int ch) {
  if (ch == '\n') { // 处理换行
    g_cursor_info.offset += 80 * 2; // 一行是80字符
    g_cursor_info.offset -= ((g_cursor_info.offset / 2) % 80) * 2; // 回到行首
  } else {
    SetVMem(g_cursor_info.offset++, (unsigned char)ch);
    SetVMem(g_cursor_info.offset++, 0x0f);
  }
  return ch;
}

int puts(const char *str) {
  // 处理C字符串,需要向后找到0结尾,逐一调用putchar
  for (const char *p = str; *p != '0'; p++) {
    putchar(*p);
  }
  return 0;
}

void Entry() {
  puts("Hello, World!\nThe 2nd line.");
}

运行结果如下:
运行结果4

完美!撒花!

小结

本篇我们克服了各种难处,终于用C语言成功输出了Hello, World!,还成功实现了换行。非常不容易!下一篇我们要整理一下当前的工程,并且继续实现一些基础的C能力供后续使用。

本篇的工程代码将会上传至附件,分上下两部分,供读者参考。

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

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

相关文章

Vant和ElementPlus在vue的hash模式的路由下路由离开拦截使用Dialog和MessageBox失效

问题复现 ElementPlus&#xff1a;当点击返回或者地址栏回退时&#xff0c;MessageBox无效 <template><div>Element Plus Dialog 路由离开拦截测试</div><el-button type"primary" click"$router.back()">返回</el-button>…

Vue3 + TypeScript

Vue3 TS开发环境创建 1. 创建环境 vite除了支持基础阶段的纯TS环境之外&#xff0c;还支持 Vue TS开发环境的快速创建, 命令如下&#xff1a; $ npm create vitelatest vue-ts-pro -- --template vue-ts 说明&#xff1a; npm create vitelatest 基于最新版本的vite进行…

Linux搭建文件服务器

搭建简单文件服务器 基于centos7.9搭建http文件服务器基于centos7.9搭建nginx文件服务器基于ubuntu2204搭建http文件服务器 IP环境192.168.200.100VMware17 基于centos7.9搭建http文件服务器 安装httpd [rootlocalhost ~]# yum install -y httpd关闭防火墙以及selinux [roo…

【Qt-20】Qt信号与槽

一、什么是信号和槽 信号是特定情况下被发射的事件&#xff0c;发射信号使用emit关键字&#xff0c;定义信号使用signals关键字&#xff0c;在signals前面不能使用public、private、protected等限定符&#xff0c;信号只用声明&#xff0c;不需也不能对其进行定义实现。另外&am…

【Jetson 设备】window10主机下使用VNC可视化控制Jetson Orin NX

文章目录 前言VNC连接搭建(WiFi模式)Jetson Orin NX操作本地主机操作 VNC连接搭建(以太网模式)Jetson Orin NX操作本地主机操作 总结 前言 最近需要使用Jetson Orin NX对一些深度学习算法进行测试&#xff0c;为了方便主机与Jetson Orin NX之间的数据的传输&#xff0c;以及方…

MATLAB - 不能使用PYTHON,缺少matplotlib模块的解决办法

matlab缺少python-matplotlib模块的解决办法 1. 前言、概述2. 解决办法3. 可能出现问题4. 结果 1. 前言、概述 起因是我用习惯的colormap函数getPyPlot_cMap不能用了&#xff1a;【这个函数要调用PYTHON】 报错的地方&#xff1a; ModuleNotFoundError: No module named ‘ma…

html中公用css、js提取、使用

前言 开发中&#xff0c;页面会有引用相同的css、js的情况&#xff0c;如需更改则每个页面都需要调整&#xff0c;重复性工作较多&#xff0c;另外在更改内容之后上传至服务器中会有缓存问题&#xff0c;特针对该情况对公用css、js进行了提取并对引用时增加了版本号 一、提取…

微信小程序开发之会议OA的会议界面,投票界面

一、自定义组件 1&#xff0c;自定义组件介绍 从小程序基础库版本 1.6.3 开始&#xff0c;小程序支持简洁的组件化编程。所有自定义组件相关特性都需要基础库版本 1.6.3 或更高。 开发者可以将页面内的功能模块抽象成自定义组件&#xff0c;以便在不同的页面中重复使用&#xf…

grpc实现跨语言(go与java)服务通信

Golang微服务实战&#xff1a;使用gRPC实现跨语言通信&#xff01;随着微服务架构的发展&#xff0c;越来越多的企业开始采用微服务架构来构建分布式系统。在微服务架构中&#xff0c;服务之间的通信是非常重要的。而gRPC作为一种高效、跨平台、跨语言的RPC框架&#xff0c;成为…

小目标检测闲谈

学术界在小目标检测领域的研究进展似乎已经相对缓慢&#xff0c;这一趋势在年度顶级学术会议的相关论文中也有所体现。这部分停滞可能与深度学习领域整体的发展趋势有关。然而&#xff0c;小目标检测仍然是一个具有重要应用潜力的领域&#xff0c;尤其在实际部署中&#xff0c;…

ChatGPT(1):ChatGPT初识

1 ChatGPT原理 ChatGPT 是基于 GPT-3.5 架构的一个大型语言模型&#xff0c;它的工作原理涵盖了深度学习和自然语言处理技术。以下是 ChatGPT 的工作原理的一些关键要点&#xff1a; 神经网络架构&#xff1a;ChatGPT 的核心是一个深度神经网络&#xff0c;采用了变种的 Tran…

1 tcp协议20问

1什么是TCP网络分层 1.1分层描述 网络访问层&#xff1a; 2 TCP的三次握⼿中为什么是三次&#xff1f;为什么不是两次、四次&#xff1f; 两次握手的话&#xff0c;服务端会单方面认为建立已经成功&#xff0c;但是对于客户端而言&#xff0c;可能只是开个玩笑的&#xff0c…

形式化验证笔记

参考视频&#xff1a; 形式化验证的原理与新应用【DatenLord达坦科技】形式化验证入门(我强推&#xff01;&#xff01;&#xff01;&#xff01;&#xff01;) 形式化验证&#xff1a;在状态机表征的空间里面进行搜索&#xff0c;验证某个模型是否按规范执行且测试覆盖率达到1…

058:mapboxGL监听键盘事件,通过panBy控制前后左右移动

第058个 点击查看专栏目录 本示例是介绍演示如何在vue+mapbox中监听键盘事件,控制前后左右移动。 本例通过panBy方法来移动一定距离的地图,通过.addEventListener的方法来监听键盘的按键动作。注意这里面style中一定要设置好pitch,不能为0,不然就撞墙,不能移动了。 直接复…

OpenCV 笔记(2):图像的属性以及像素相关的操作

Part11. 图像的属性 11.1 Mat 的主要属性 在前文中&#xff0c;我们大致了解了 Mat 的基本结构以及它的创建与赋值。接下来我们通过一个例子&#xff0c;来看看 Mat 所包含的常用属性。 先创建一个 3*4 的四通道的矩阵&#xff0c;并打印出其相关的属性&#xff0c;稍后会详细…

数据库索引种类

文章目录 索引的优缺点优点缺点 聚簇索引特点优点缺点 非聚簇索引特点优点缺点使用场景&#xff1a; 在MyISAM与InnoDB中的使用 索引的优缺点 索引概述 MySQL官方将索引定义为帮助MySQL高效获取数据的数据结构。索引的本质是一种排好序的快速查找数据结构&#xff0c;用于满足…

YOLOv5/v7/v8改进实验(五)之使用timm更换YOLOv5模型主干网络Backbone篇

&#x1f680;&#x1f680; 前言 &#x1f680;&#x1f680; timm 库实现了最新的几乎所有的具有影响力的视觉模型&#xff0c;它不仅提供了模型的权重&#xff0c;还提供了一个很棒的分布式训练和评估的代码框架&#xff0c;方便后人开发。更难能可贵的是它还在不断地更新迭…

(H5轮播)vue一个轮播里显示多个内容/一屏展示两个半内容

效果图 : html: <div class"content"><van-swipeclass"my-swipe com-long-swipe-indicator":autoplay"2500"indicator-color"#00C4FF"><van-swipe-itemclass"flex-row-wrap"v-for"(items, index) in M…

Kubernetes 进阶

Kubernetes 进阶  Service 控制器  Ingress 对象(对外暴露应用)  管理应用程序配置  K8s 数据卷与持久数据卷  再谈有状态应用部署:StatefulSet控制器  K8s 安全访问控制  K8s 部署利器Helm初探 Service 控制器 • Service存在的意义 • Pod与…

更改Kali Linux系统语言以及安装zenmap

目录 更改Kali Linux系统语言 安装 Zenmap 更改Kali Linux系统语言以及安装zenmap 在使用kali的过程中&#xff0c;会遇到许多问题&#xff0c;其中一个就是看不懂英语&#xff0c;下面是如何更换语言的步骤。 更改Kali Linux系统语言 首先&#xff0c;打开kali&#xff0…