前文只是介绍了启动的过程,但是各类函数之间的调用,地址的变换,内存布局的变化并没有详细说明明,本节来完善。
还是从 BIOS 开始,入口点是 ,是一跳转指令 ,然后开始执行 BIOS 的代码,内存低 1M 的顶部 64KB 都是分配给 BIOS 的,所以此时内存布局为:
xv6 没有实际的 MBR,bootasm.S 和 bootmain.c 两文件联合在一起编译成二进制文件 bootblock 放在磁盘最开始的那个扇区,然后被 BIOS 加载到 处,从 处开始执行。
此时内存布局为:
bootmain 加载内核,来看看是怎么加载的,加载到哪儿。
elf = (struct elfhdr*)0x10000;
readseg((uchar*)elf, 4096, 0); //从磁盘读4096字节到物理地址 0x10000
这里 readseg 函数的意思是从磁盘的 1 扇区读取 4096字节到物理地址 0x1 0000 处。内核文件在磁盘的扇区 1 ,注意这里虽然参数传的是 0,但是函数内部加了 1,所以是从扇区 1 读取的。这个函数后面讲述磁盘再详述,这里知道作用就行。
0x1 0000 有什么意义?再来看一眼内存低 1M 的布局图:
所以没什么特殊意义,就是找了一块空闲地儿,来存放内核的开始的 4096 字节。
那这 4096 字节有什么用?这就加载内核了?当然不是,xv6 的内核有 200 多 KB,开始的 4096 字节只是包括了 elf 文件的一些头部信息:
这是从我虚拟机上截的图,使用 readelf -h kernel 命令来查看内核的 elf 头信息,从截图上可知程序头的相对 elf 文件开始的偏移量为 52 字节,有 3 个程序头,每个 32 字节,所以这 4096 字节至少包括内核的 elf 头和程序头表,而这是我们加载内核正需要的信息。
此时内存中的布局:
运行 bootmain.c 的时候是将 0x7c00 以下作为栈使用,根据内存低 1M 布局图可以看出,0x7c00 以下有大约 30K 的空闲空间可用,这段代码很少,栈空间用不了多少,30K 太足够了,不会有什么问题。
下面就开始正式加载内核了,加载到哪儿是一个问题,这就需要程序头中记载的信息了:
ph = (struct proghdr*)((uchar*)elf + elf->phoff); //第一个程序段的位置
eph = ph + elf->phnum;
for(; ph < eph; ph++){
pa = (uchar*)ph->paddr;
readseg(pa, ph->filesz, ph->off); //从ph->off所在的扇区读取ph->filesz字节到物理地址pa
if(ph->memsz > ph->filesz)
stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz); //调用 stosb 将段的剩余部分置零
上下结合来看得知将内核加载到了物理地址的 0x100000 处。
此时的内存布局:
end 为内核末尾地址,不同版本有稍许不同,可以在 kernel.sym 文件中查找,也可以直接读取 elf 的程序头,根据 计算出来。
前面都是在未开启分页机制下运行,涉及到的地址都是实际的物理地址,从 bootmain.c 中 跳到 entry.S 就开启分页机制,分页必然要建立页表,涉及到内存管理,下面一一来看:
xv6 在启动的时候建立了一个临时页表,在 main.c 文件的最后部分:
pde_t entrypgdir[NPDENTRIES] = {
// 将虚拟地址的[0,4M)映射到物理地址[0,4M)
[0] = (0) | PTE_P | PTE_W | PTE_PS,
// 将虚拟地址[800 0000,800 0000+40 0000)映射到[0,4M)
[KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS,
};
xv6 定义虚拟地址 0x800 0000 以上为内核部分,虚拟地址空间和物理地址空间具体怎么映射的后面建立正式的页表时候再说。
为啥要将虚拟地址不同的两部分映射到相同的物理地址?这需要看 entrypgdir 用在什么地方,entrypgdir 定义在 main.c 中,用在 entry.S 文件中。启动那篇文章说过,entry.S 主要就是开启分页机制。本身代码是在物理地址低 4M 内,必须保证分页机制前的线性地址与分页机制的虚拟地址对应的物理地址一致,也就是说即使开启了分页机制,但是还有一些地方用的是低地址,比如开启分页机制和跳到高地址之间的指令,从低地址到高地址有个过渡阶段,所以高低两个部分的地址空间都要映射。
entry.S 代码里面有这么几句指令:
# Turn on page size extension for 4Mbyte pages
# 开启页面大小扩展,每页 4 M
movl %cr4, %eax
orl $(CR4_PSE), %eax
movl %eax, %cr4
将 CR4 寄存器的 PSE 位置 1,以及设置页目录项的 PS 位,便可以设置每页的大小为 4M,但是此时对虚拟地址的解析有了变化,如果使用二级页表的话,我们是将虚拟地址的高 10 位作为页目录的索引,得到一级页表的物理地址,将中 10 位作为页表的索引,得到页框的物理地址,再加上后面 12 位的偏移地址得到最终目标的物理地址。示意图如下:
如果是使用一级页表的话,将虚拟地址的前 20 位作为页表的索引,得到页框的物理地址,加上后面 12 位的索引得到最终目标的物理地址,示意图如下:
但如果是开启页面大小扩展,有点类似一级页表,但又有所不同,它是将虚拟地址的高 10 位作为页表的索引,得到页框的物理地址,加上低 22 位的偏移量得到最终目标的物理地址,示意图如下:
所以这就解释了为什么 entrypgdir 简简单单的两项,两条语句就映射了 4M 的地址空间。那为什么要使用页面大小扩展呢?我合理的猜测下:就是简单方便,语句少,想想如果使用二级页表,页面大小不进行扩展只有 4K 的情况要怎么映射,两部分地址空间,得有两个页目录项,对应两个一级页表,4M 有 1024 个 4K,得有 1024 个页表项。虽然 4M 没有全用,不用全映射,但是总的来说使用页面大小扩展之后更加简单方便。
建立正式页表之前先来看看 xv6 是如何对内存进行组织管理的,任何一个操作系统都需要对内存进行管理,将内存以某种方式组织起来,用的时候可以分配,不再使用的时候回收。组织方式常见的有链式和位图,xv6 里面是用链表的形式将空闲空间给组织起来,相关代码在 kalloc.c 文件中,我们来具体分析一下:
首先定义了两个结构体:
struct run {
struct run *next;
};
struct {
struct spinlock lock;
int use_lock;
struct run *freelist;
} kmem;
这两个结构体什么意思,有什么用?看个图就明白了:
所以 kmem 就像个内存分配器,这个 freelist 就是这片空闲页链表的链头,分配内存的时候就将它先分配出去,然后每页里面有一个指针,指向下一个空闲页。有了这个了解之后来看具体的实现代码:
char* kalloc(void)
{
struct run *r; //声明run结构体指针
if(kmem.use_lock) //加锁
acquire(&kmem.lock);
r = kmem.freelist; //第一个空闲页地址赋给r
if(r)
kmem.freelist = r->next; //链头移动到下一页,相当于把链头给分配出去了
if(kmem.use_lock) //释放锁
release(&kmem.lock);
return (char*)r; //返回第一个空闲页的地址
}
代码很简单,就是加锁,取链头地址,链头移到下一个空闲页,释放锁,返回取到的链头地址。
void kfree(char *v) //释放页v
{
struct run *r;
//这个页应该在这些范围内且边界为4K的倍数
if((uint)v % PGSIZE || v < end || V2P(v) >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs.
memset(v, 1, PGSIZE); //将这个页填充无用信息,全置为1
if(kmem.use_lock) //取锁
acquire(&kmem.lock);
r = (struct run*)v; //头插法将这个页放在链首
r->next = kmem.freelist;
kmem.freelist = r;
if(kmem.use_lock) //释放锁
release(&kmem.lock);
}
基本上是 kalloc 的逆操作,先检查要释放的页合理与否,然后填充无效信息,再取锁,使用头插法将这个页放在链首,释放锁。从这看出这应该是用的头插法。
void freerange(void *vstart, void *vend) //连续释放vstart到vend之间的页
{
char *p;
p = (char*)PGROUNDUP((uint)vstart);
for(; p + PGSIZE <= (char*)vend; p += PGSIZE)
kfree(p);
}
还有两个函数 kinit1,kinit2 是上述 freerange 函数的封装:
void kinit1(void *vstart, void *vend) //kinit1(end, P2V(4*1024*1024));
{
initlock(&kmem.lock, "kmem");
kmem.use_lock = 0;
freerange(vstart, vend);
}
void kinit2(void *vstart, void *vend) //kinit2(P2V(4*1024*1024), P2V(PHYSTOP));
{
freerange(vstart, vend);
kmem.use_lock = 1;
}
它俩是在 main.c 的 main() 函数中被调用,调用的参数也已经注释在后边。调用这两个函数就是初始化内存,将内存一页一页的使用头插法链在一起。
要了解其他几个参数还需要先来了解 xv6 的虚拟地址空间和实际的物理地址空间的映射关系,这也有相应的结构体表示:
#define EXTMEM 0x100000 // Start of extended memory
#define PHYSTOP 0xE000000 // Top physical memory
#define DEVSPACE 0xFE000000 // 一些设备的地址,比如apic的一些寄存器
// Key addresses for address space layout (see kmap in vm.c for layout)
#define KERNBASE 0x80000000 // 内核的起始虚拟地址
#define KERNLINK (KERNBASE+EXTMEM) // 内核文件的链接地址
#define V2P(a) (((uint) (a)) - KERNBASE) //内核虚拟地址转物理地址
#define P2V(a) ((void *)(((char *) (a)) + KERNBASE)) //物理地址转内核虚拟地址
static struct kmap {
void *virt;
uint phys_start;
uint phys_end;
int perm;
} kmap[] = {
{ (void*)KERNBASE, 0, EXTMEM, PTE_W}, // I/O space
{ (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0}, // kern text+rodata
{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
{ (void*)DEVSPACE, DEVSPACE, 0, PTE_W}, // more devices
};
上面那一坨就是说明虚拟地址空间内核部分到物理内存的映射关系,看起来可能很麻杂,做了一张表格和图:
所以从这张图可以看出,内核部分的虚拟地址空间和物理地址空间就是一一对应的,只是相差了 0x8000 0000,所以这就是为什么简单的宏 V2P,P2V 就可以实现虚拟地址物理地址之间的转换,当然这只是内核部分才行。用户态部分的我们还没有涉及,用户态下的虚拟地址到物理地址之间的转换就必须要使用页表了,相关部分在进程我们再详述。
再者也可以看出 xv6 并没有使用全部的 4G 地址空间,有很大一部分都没有使用,除开这部分所有的物理内存实际都映射到内核中去了,那用户部分呢?用户部分是通过页表映射到了物理地址空间的空闲部分,这部分物理地址空间又可以通过 P2V 映射到内核部分去,是不是很绕,后面讲述进程的时候慢慢说这部分。
另外关于设备部分是直接映射的,是真的一一对应,虚拟地址和物理地址一样,这部分地址空间是分配给一些设别的,比如 APIC 的一些寄存器,详见:[再谈中断(APIC)]
实现上述的映射得建立相应的页表,来看相关代码:
#define PDX(va) (((uint)(va) >> PDXSHIFT) & 0x3FF) //高10位
#define PTX(va) (((uint)(va) >> PTXSHIFT) & 0x3FF) //中10位
#define PGADDR(d, t, o) ((uint)((d) << PDXSHIFT | (t) << PTXSHIFT | (o))) //d为高10位,t为中10位,o为低12位,将他们组合成虚拟地址
static pte_t * walkpgdir(pde_t *pgdir, const void *va, int alloc) //根据虚拟地址 va 返回相应的页表项地址
{
pde_t *pde; //页目录项地址
pte_t *pgtab; //一级页表地址
pde = &pgdir[PDX(va)]; //va取高12位->页目录项
if(*pde & PTE_P){ //若一级页表存在
pgtab = (pte_t*)P2V(PTE_ADDR(*pde)); //取一级页表的物理地址,转化成虚拟地址
} else {
if(!alloc || (pgtab = (pte_t*)kalloc()) == 0) //否则分配一页出来做页表
return 0;
// Make sure all those PTE_P bits are zero.
memset(pgtab, 0, PGSIZE); //初始化置0
*pde = V2P(pgtab) | PTE_P | PTE_W | PTE_U; //将新分配出来的以及页表记录在页目录中
}
return &pgtab[PTX(va)]; //va取中10位->页表项
}
static int mappages(pde_t *pgdir, void *va, uint size, uint pa, int perm)
{
char *a, *last;
pte_t *pte;
a = (char*)PGROUNDDOWN((uint)va); //虚拟地址va以4K为单位的下边界
last = (char*)PGROUNDDOWN(((uint)va) + size - 1); //偏移量,所以减1
for(;;){
if((pte = walkpgdir(pgdir, a, 1)) == 0) //获取地址a的页表项地址
return -1;
if(*pte & PTE_P) //如果该页本来就存在
panic("remap");
*pte = pa | perm | PTE_P; //填写地址a相应的页表项
if(a == last) //映射完了退出循环
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
mappages 映射虚拟地址 va 到物理地址 pa,映射大小为 size,实现方式将相应的页表项填进 pgdir 指向的页表中去。总的来说分为两步,调用 walkpgdir 获取虚拟地址相应的页表项,然后将物理地址属性位填进这个页表项。这就是映射一页的操作,重复这个操作映射从 va 开始的 size 大小区域。
现在有了内核映射的要求和实现方法,可以建立内核正式的页表了:
#define NELEM(x) (sizeof(x)/sizeof((x)[0])) //x有多少项
pde_t* setupkvm(void) //建立内核页表
{
pde_t *pgdir;
struct kmap *k;
if((pgdir = (pde_t*)kalloc()) == 0) //分配一页作为页目录表
return 0;
memset(pgdir, 0, PGSIZE); //页目录表置0
if (P2V(PHYSTOP) > (void*)DEVSPACE) //PHYSTOP的地址不能高于DEVSPACE
panic("PHYSTOP too high");
for(k = kmap; k < &kmap[NELEM(kmap)]; k++) //映射4项,循环4次
if(mappages(pgdir, k->virt, k->phys_end - k->phys_start, (uint)k->phys_start, k->perm) < 0) {
freevm(pgdir);
return 0;
}
return pgdir;
}
setupkvm() 相当于 mappages() 的封装,它循环四次,将 kmap 给出的信息当作参数传给 mappages,映射相应的地址空间。
注意 kmap 最后一项的 phys_end 为0,kmap 结构体中声明的物理地址都是无符号数,所以最后一项 k->phys_end - k->phys_start,如此计算也是没有问题的,对于数值问题有疑惑的请看我这篇文章:
建好页表就该切换页表,就是将页表的及地址赋给 CR3,看下面对 setupkvm() 封装的函数:
pde_t *kpgdir;
void kvmalloc(void)
{
kpgdir = setupkvm(); //建立页表
switchkvm(); //切换页表
}
void switchkvm(void)
{
lcr3(V2P(kpgdir)); //加载内核页表到cr3寄存器,cr3存放的是页目录物理地址
}
kpgdir 是个全局变量,为内核页表的地址,kvmalloc() 调用 setupkvm() 建立页表,返回的页表地址赋给 kpgdir,然后调用 switchkvm() 切换成内核页表,也就是将 kpgdir 的物理地址加载到 CR3 寄存器。
页表的事完成之后,内核完全运行在高地址之上了,相应的一些结构的地址也得切换到高地址上面去,比如说 GDTR 中存放的 GDT 地址和界限。最开始的 GDT 是在 bootasm.S 文件建立的,放在物理地址值的低 1M,后来分页机制开启之后使用的临时页表,映射了虚拟地址空间低 4M 和 内核之上的低 4M 到物理地址空间的低 4M,所以 GDTR 中的地址没问题,CPU 能够找到 GDT。但是切换成正式页表之后不再映射虚拟地址空间的低地址部分,低地址部分是给用户态用的,内核都处于高地址,所以 GDTR 中的地址不再有效。况且 GDT 还需要重新建立正式的 GDT,所以有了如下的 seginit():
void seginit(void) //设置内核用户的代码段和数据段
{
struct cpu *c;
c = &cpus[cpuid()]; //获取当前CPU
//建立段描述符,内核态用户态的代码段和数据段
c->gdt[SEG_KCODE] = SEG(STA_X|STA_R, 0, 0xffffffff, 0);
c->gdt[SEG_KDATA] = SEG(STA_W, 0, 0xffffffff, 0);
c->gdt[SEG_UCODE] = SEG(STA_X|STA_R, 0, 0xffffffff, DPL_USER);
c->gdt[SEG_UDATA] = SEG(STA_W, 0, 0xffffffff, DPL_USER);
lgdt(c->gdt, sizeof(c->gdt)); //加载到GDTR
}
每个 CPU 有自己的结构,cpus 这个结构体数组本身位于内核,内核现已运行在高地址,GDT 放在 CPU 结构体中,那么也就相当于放在了高地址上。设置好段描述符,建立好 GDT 之后,便将 GDT 的新地址和界限写进 GDTR 寄存器中去。
上述讲述了内核页表的过程,有了这全局的认识之后,来解决上述遗留的一些问题:
解决这两个问题,我们要来看看 xv6 的设计思路,当然只是看和内存相关比较紧密的部分:
最开始内核加载到物理地址 0x10 0000 处,xv6 内核很小,整个内核只有 200 多 K。内核一开始就先运行 entry.S 的代码,开启分页机制,分页当然得有页表,为简单方便将页面大小扩展到了 4M,制作了一个启动时用的临时页表,映射了低 4M 的内存。entry.S 代码运行完之后跳到 main() 中去。
int main(void)
{
kinit1(end, P2V(4*1024*1024)); // phys page allocator
kvmalloc(); // kernel page table
/*********/
seginit(); // segment descriptors
/*********/
startothers(); // start other processors
kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers()
/*********/
}
首先就是初始化内核结束点到 4M 之间的内存,kinit1() 使用的地址是虚拟地址,此时的页表只映射了低 4M,所以传的参数为 end 到 P2V(4*1024*1024)。
初始化了 end 到 4M 之间的内存区域之后就可以构建正式的内核页表映射更多的地址空间,所以紧接着调用了 kvmalloc() 建立内核部分的页表。
原本内核在低地址,由于分页机制的开启,内核跑到高地址上面去了,需要改变一些寄存器中记录的值,比如记录 GDT 地址和界限的 GDTR 寄存器,所以有了 seginit() 重新初始化 GDT,然后将 GDT 的虚拟地址和界限写到 GDTR 中去。
现在已经建立了正式的内核页表,映射了整个内核部分,有更多的虚拟地址空间可用,所以可以初始化更多的内存了,因此有了 kinit2(),初始化的区域是 4M 到 PHYSTOP,这个宏定义可以在一定范围内改变,从这个宏定义可以看出,xv6 实际并没有用到 32 位全部的 4G 空间。
那为什么 kinit2() 必须在 startothers() 后面呢?原因就在于其他 CPU 启动的时候也是用的那张临时页表,只映射了物理地址的低 4M, kinit2() 的初始化内存是用头插法依次链接在头部的,如果先执行 kinit2() 的话,那么在执行 startothers() 时候给 APs 分配内存的时候就会先分配高处的内存,而这些内存的地址临时页表是没有映射的,就会引发错误,所以 kinit2() 必须在 startothers() 之后。
至于其他 APs 的启动,大都重复 BSP 的过程,只不过 APs 的启动代码放在了 0x7000 处,其他的基本一样就不再赘述了。
本文讲述了 xv6 的内存管理部分,完善了启动过程中的内存布局变化,但也只涉及了内核部分,用户部分将和进程结合在一起叙述。好啦本文就到这里,有什么错误还请批评指正,也欢迎大家来同我讨论交流。
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/kPUIHqZi1Gx3GRNil4Bvmw
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。