前置准备

实现机器为VMWare的虚拟机,操作系统为 Debian-11(无桌面版本),内核版本为 5.10.0,指令集为 AMD64(i7 9700K),编译器为 GCC-10

QEMU 虚拟化支持

理论上只需要 qemu 提供软件虚拟化即可,所以硬件虚拟化非必要,libvirt 等相关组件也可以不需要;这里只安装 QEMU

apt install qemu-kvm

其它

使用 clangd 工具链,代码风格对齐 Linux

Lab 1: C, Assembly, Tools, and Bootstrapping

Lab1 主要汇编、工具链及引导部分。汇编使用 GNU 风格,可以 CS:APP 书籍进行学习。

安装 Lab1 的流程,执行 make && make qemu 之后会有报错,由于装的操作系统无桌面,gtk 也就没有安装。

# qemu-system-i386 -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::25000 -D qemu.log
Unable to init server: Could not connect: Connection refused
gtk initialization failed

非图形版本修改如下:

-QEMUOPTS = -drive file=$(OBJDIR)/kern/kernel.img,index=0,media=disk,format=raw -serial mon:stdio -gdb tcp::$(GDBPORT)
+QEMUOPTS = -drive file=$(OBJDIR)/kern/kernel.img,index=0,media=disk,format=raw -nographic -gdb tcp::$(GDBPORT)

解释一下 qemu 的这条命令

qemu-system-i386 -drive file=obj/kern/kernel.img,index=0,media=disk,format=raw -nographic -gdb tcp::25000 -D qemu.log
  • drive 指定驱动类型
  • format=raw 文件格式,其他的如有 qcow2
  • nographic 无图形页面
  • gdb 接受 gdb 的远程连接,后续 make gdb 调试会使用到这个点

make qemu-gdbmake qemu 多了一个参数 -S,作用为 freeze CPU at startup。

启动

内存布局

+------------------+  <- 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
/\/\/\/\/\/\/\/\/\/\ /\/\/\/\/\/\/\/\/\/\
| |
| Unused |
| |
+------------------+ <- depends on amount of RAM
| |
| |
| Extended Memory |
| |
| |
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000

简而言之,最初的处理器最大寻址只有 0xFFFFF,然后预留 64KB 给 BIOS 作为保留使用,完全给用户使用的内存空间只有起始的 640KB(0x00000000 ~ 0x000A0000).

BIOS

通过 gdb 跟踪到第一条执行的指令为 ljmp,地址为 physical address = 16 * segment + offset[CS:IP] 为 [f000:fff0] 的情况下地址为 0xffff0 = 16 * 0xf000 + 0xfff0.

[f000:fff0]    0xffff0: ljmp   $0x3630,$0xf000e05b

为了使 BIOS 加电就被执行,约定好将 BIOS 放在 0xFFFF0 这个位置,机器加电后就将控制权交给 BIOS.

引导程序

You will not need to learn much about programming specific devices in this class: writing device drivers is in practice a very important part of OS development, but from a conceptual or architectural viewpoint it is also one of the least interesting.

像课程说的那样,和驱动的相关的东西,了解就略过。此处 描述了从硬盘控制器中读取数据的说明,对应 out*() 簇函数。

引导进程位于 boot/boot.Sboot/main.c 中,阅读代码后回答以下几个问题:

  • At what point does the processor start executing 32-bit code? What exactly causes the switch from 16- to 32-bit mode?

    使用 ljmp 切换为保护模式,ljmp 随后的 movw 指令为在 32-bit 执行的第一条指令。

  • What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?

    call *0x10018 为 bootloader 最后一条执行的指令(也就是 ((void (*)(void)) (ELFHDR->e_entry))(); 这行代码); repnz insl 为读取内核的第一条指令,从磁盘文件中读取出内核的数据。

  • Where is the first instruction of the kernel?

    地址为 0x10018 这条指令 movw 为内核第一条执行的指令。

  • How does the boot loader decide how many sectors it must read in order to fetch the entire kernel from disk? Where does it find this information?

    先从第1(下标为 0)个扇区读取8个扇区的数据,然后通过 ELF 的格式进行解析,通过 ELF 文件头中的 e_phoff 字段拿到程序段表的文件偏移,再通过这个偏移取到每一个段的大小和偏移。

内核

内核在硬盘中的分布紧跟着 bootloader,在 bootloader 中将内核镜像读取至物理内存 0x100000 处,由于内核镜像的是 ELF 格式,直接通过 ELF 找到 e_entry(.txt) 段,然后进项跳转,进入内核的代码段中;

内核的代码段起始位置通过 kern/entry.S 中的 .global _start 指定了入口。由于我们一般把内核放在高内存区域,尽量和用户使用的内存部分错开。可以在链接的情况下指定虚拟地址(通过 kern/kernel.ld),观察 obj/kern/kernel.asm 中每一条指令的地址,起始地址为 0xF0100000 指令为 add 0x1bad(%eax),%dh,该地址在 kern/kernel.ld 中被指定,其中 0xF0100000 为虚拟地址,0x100000 为物理地址(bootloader 读取)

readelf -h obj/kern/kernel 可以看到程序段头 Number of program headers 的值为 3. 内核的入口地址为 Entry point address 值为 9x10000c.

readelf -l obj/kern/kernel 可以看到详细的信息 PhysAddr 为物理地址,VirtAddr 为虚拟地址,MemSiz 为段的大小。以此部分内容返回查看 boot/main.c 的逻辑更清晰。

kern/entry.S 中做了一个简单的映射,通过 _start = RELOC(entry)entry 的虚拟地址设置为了 0xF0100000

// readelf -h  obj/kern/kernel
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x10000c
Start of program headers: 52 (bytes into file)
Start of section headers: 91220 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 3
Size of section headers: 40 (bytes)
Number of section headers: 14
Section header string table index: 13 // readelf -S obj/kern/kernel
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS f0100000 001000 0024ed 00 AX 0 0 16
[ 2] .rodata PROGBITS f01024f0 0034f0 000533 00 A 0 0 4
[ 3] .stab PROGBITS f0102a24 003a24 004519 0c A 4 0 4
[ 4] .stabstr STRTAB f0106f3d 007f3d 0017aa 00 A 0 0 1
[ 5] .data PROGBITS f0109000 00a000 009500 00 WA 0 0 4096
[ 6] .got.plt PROGBITS f0112500 013500 00000c 04 WA 0 0 4
[ 7] .data.rel.local PROGBITS f0113000 014000 001044 00 WA 0 0 4096
[ 8] .data.rel.ro[...] PROGBITS f0114044 015044 00001c 00 WA 0 0 4
[ 9] .bss PROGBITS f0114060 015060 000661 00 WA 0 0 32
[10] .comment PROGBITS 00000000 0156c1 000027 01 MS 0 0 1
[11] .symtab SYMTAB 00000000 0156e8 000890 10 12 78 4
[12] .strtab STRTAB 00000000 015f78 000463 00 0 0 1
[13] .shstrtab STRTAB 00000000 0163db 000078 00 0 0 1 // readelf -l obj/kern/kernel
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x001000 0xf0100000 0x00100000 0x086e7 0x086e7 R E 0x1000
LOAD 0x00a000 0xf0109000 0x00109000 0x0b6c1 0x0b6c1 RW 0x1000
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10

由于涉及到了虚拟内存,故需要使用 CPU 特性,开启内存分页。参考Intel文档Volume 3: 4.1 PAGING MODES AND CONTROL BITS

Software enables paging by using the MOV to CR0 instruction to set CR0.PG. Before doing so, software should ensure that control register CR3 contains the physical address of the first paging structure that the processor will use for linear-address translation.

增加编译选项 -no-pie -fno-pic 来避免产生的位置无关的指令,如 __x86.get_pc_thunk.bx,对阅读汇编代码更友好一些。

调整优化等级 O1O0,直接让 C 和汇编对应。比如在 i386_init() 中,开启优化后栈空间占用了 0x0c 的大小,但是我们只用了两个变量,应该为 0x08.

调试可以使用 gdb 的 i r edp 来查看 edp 寄存器的值,p/x addr 对 addr 进行十六进制的输出。

晚上上面的修改动作后,通过阅读 obj/kern/kernel.asm,定位到栈大小为 32768(8*PGSIZE),最前设置的栈底为 0x00,栈顶为 bootstacktop,进入到 i86_init() 中后,栈栈底为 bootstacktop+4

之前有一篇读CS:APP文章,描述的是x86-64下的函数调用过程。对于函数的调用链关键点在于这几个元素

  • 返回地址

    • 在执行 call 指令时,会将call指令的下一条指令地址压栈
    • 在执行 ret 指令时,从栈弹出并且跳转(大概的逻辑)
  • 栈基寄存器,为当前函数的栈底地址,在进入一个函数中压栈,返回前退栈

backtrace 要求输出每一个函数的的 ebp eip args,在一行中显示。

  • ebp 直接从 edp 寄存器中读取
  • eip 为函数的返回地址,在栈底的底下(ebp+4)
  • args 参数,和 x64 不同的是 x86 全部使用栈传递参数,对寄存器的利用不高。这样来看backtrace变得更轻松一些。

本质上为 上一个栈底地址作为元素被压入当前栈中,所以获取到当前的 ebp 寄存器的再进行反复的回溯就可以解决获取到 edp rip args.

回溯的终点为 entry.S 里面设置的 movl $0x0,%ebp

int mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
cprintf("Stack backtrace:\n"); uint32_t ebp = *(uint32_t *)read_ebp();
while (ebp != 0x00) {
cprintf(" ebp %08x", ebp); uint32_t eip = *(uint32_t *)(ebp + 4);
cprintf(" eip %08x", eip); cprintf(" args");
struct Eipdebuginfo info;
debuginfo_eip(eip, &info);
// for (int i = 0; i < info.eip_fn_narg; i++) {
for (int i = 0; i < 5; i++) {
cprintf(" %08x", *(uint32_t *)(ebp + 8 + i * 4));
}
cprintf("\n %s:%d: %.*s+%d\n", info.eip_file, info.eip_line, info.eip_fn_namelen, info.eip_fn_name, eip - info.eip_fn_addr); ebp = *(uint32_t *)ebp;
} return 0;
}

在实现的时候,理论上 read_ebp() 返回的值应该指针,但是需要解地址才能够得到和 gdb 中 info reg ebp 相同的值

后续调试的时候结果是在 mon_backtrace 处的断点还未运行 mov %esp %ebp

Stack backtrace:
ebp f010ff18 eip f01000a1 args 00000000 00000000 00000000 f010004a f0111308
ebp f010ff38 eip f0100076 args 00000000 00000001 f010ff78 f010004a f0111308
ebp f010ff58 eip f0100076 args 00000001 00000002 f010ff98 f010004a f0111308
ebp f010ff78 eip f0100076 args 00000002 00000003 f010ffb8 f010004a f0111308
ebp f010ff98 eip f0100076 args 00000003 00000004 00000000 f010004a f0111308
ebp f010ffb8 eip f0100076 args 00000004 00000005 00000000 f010004a f0111308
ebp f010ffd8 eip f0100102 args 00000005 00001aac 00000660 00000000 00000000
ebp f010fff8 eip f010003e args 00000003 00001003 00002003 00003003 00004003

目前只有一堆和地址相关的东西,没有可读性,所以更进一步,补充函数名称,文件及返回地址所在行号。

JOS预先提供了一个帮助函数 debuginfo_eip,和一个结构体 struct Eipdebuginfo。函数名称,文件名,及返回地址所在行号都定义在结构体内,只需要补充实现完这个 debugifo_eip 就可以获取到相关的信息。目前的输出信息如下:

eip_file=kern/init.c eip_fn_name=i386_init:F(0,1) eip_fn_addr=f010009a eip_line=0, eip_fn_narg=0

需要对 eip_fn_name 进行修改,eip_line 补充获取。

根据实验提供的方向,我们可以通过读取 .stab 的内容读取相关信息,使用命令 objdump -G obj/kern/kernel 可以得到

// 这里截取截取部分输出
obj/kern/kernel: file format elf32-i386 Contents of .stab section: Symnum n_type n_othr n_desc n_value n_strx String
...
obj/kern/kernel: file format elf32-i386
Contents of .stab section:
Symnum n_type n_othr n_desc n_value n_strx String
-1 HdrSym 0 1477 000017fa 1
0 SO 0 0 f0100000 1 {standard input}
1 SOL 0 0 f010000c 18 kern/entry.S
...
13 SLINE 0 83 f010003e 0
14 SO 0 2 f0100040 31 kern/entrypgdir.c
15 OPT 0 0 00000000 49 gcc2_compiled.
16 GSYM 0 0 00000000 64 entry_pgtable:G(0,1)=ar(0,2)=r(0,2);0;4294967295;;0;1023;(0,3)=(0,4)=(0,5)=r(0,5);0;4294967295;
17 LSYM 0 0 00000000 160 pte_t:t(0,3)
18 LSYM 0 0 00000000 173 uint32_t:t(0,4)
19 LSYM 0 0 00000000 189 unsigned int:t(0,5)
20 GSYM 0 0 00000000 209 entry_pgdir:G(0,6)=ar(0,2);0;1023;(0,7)=(0,4)
21 LSYM 0 0 00000000 255 pde_t:t(0,7)
22 SO 0 0 f0100040 0
23 SO 0 2 f0100040 268 kern/init.c
24 OPT 0 0 00000000 49 gcc2_compiled.
25 FUN 0 0 f0100040 280 test_backtrace:F(0,1)=(0,1)
26 LSYM 0 0 00000000 308 void:t(0,1)
27 PSYM 0 0 00000008 320 x:p(0,2)=r(0,2);-2147483648;2147483647;
28 LSYM 0 0 00000000 360 int:t(0,2)
29 SLINE 0 13 00000000 0
30 SLINE 0 14 00000006 0
31 SLINE 0 15 00000019 0
...

对照符号表的结构体 struct Stab

// Entries in the STABS table are formatted as follows.
struct Stab {
uint32_t n_strx; // index into string table of name
uint8_t n_type; // type of symbol
uint8_t n_other; // misc info (usually empty)
uint16_t n_desc; // description field
uintptr_t n_value; // value of symbol
};

inc/stab.h 中使用到的 n_type

  • SO 主源文件,可以通过这个字段来找到对应的源文件
  • FUN 函数名,对应的函数名称
  • SLINE 代码段行号

。stab 符号表的内容依次按照源文件/函数名称/代码段行号排布。比如 test_backtrace 的实现在 kern/init.c 内,行号为 13.

 10 // Test the stack backtrace function (lab 1 only)
11 void
12 test_backtrace(int x)
13 {
14 cprintf("entering test_backtrace %d\n", x);
15 if (x > 0)
16 test_backtrace(x-1);
17 else
18 mon_backtrace(0, 0, 0);
19 cprintf("leaving test_backtrace %d\n", x);
20 }

对应 test_backtrace 的查找算法,在本实验中使用二分查抄,关键点为 eip 地址

  • 全局范围内,比较 eip 的地址找到类型为 SO 对应的源文件的行范围,这里为 kern/init.c
  • 缩小范围为该源文件内的符号表,找到类型 FUN 找到对应的函数行范围,这里为 test_backtrace
  • 缩小范围为函数范围的符号表,找到类型为 SLINE 的行,取字段 n_desc 这里为 13
  • 对应的返回地址在函数的偏移计算,直接 eip 地址减去 eip_fn_addr 即可

函数参数个数准确输出

这个还是解析 .stab 内容得到结果。实现非常简单,只需要从 fun 开始找到 PSYM 类型行的个数就可以

for (lline = lfun + 1; lline < rline && stabs[lline].n_type != N_SLINE; lline++)
if (stabs[lline].n_type == N_PSYM)
info->eip_fn_narg++;
info->eip_line = stabs[lline].n_desc;

最终输出结果如下,由于这个结果不能通过 case 的校验,所以只能作为扩展

K> backtrace
Stack backtrace:
ebp f0110f38 eip f0100f2b args 00000001 f0110f58
kern/monitor.c:96: runcmd+323
ebp f0110fa8 eip f0100fbb args f01142c9
kern/monitor.c:135: monitor+95
ebp f0110fd8 eip f010012d args
kern/init.c:24: i386_init+128
ebp f0110ff8 eip f010003e args
kern/entry.S:44: <unknown>+0

TODO

虚拟内存的映射逻辑,在下一个 Lab 中展开。

控制台颜色输出,非操作系统核心内容,暂时跳过。

LAB1 总结

一个关于内核启动的内存布局图

              +------------------+  <- 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
+------------------+ <- (2GB+Kernel Program Size)
| (JOS) Kernel |
+--------> +------------------+ <- 0xF0100000 (2GB+1MB)
| | |
| +------------------+ <- 0xF0000000 (2GB)
| /\/\/\/\/\/\/\/\/\/\
|
| /\/\/\/\/\/\/\/\/\/\
| | |
3 | Unused |
| | |
| ------------------+ <- depends on amount of RAM
| | |
| | |
| | Extended Memory |
| | |
| +------------------+ <- 0x00100000 (1MB+4KB)
+--------- | (JOS) 1st Page |
+-----> +------------------+ <- 0x00100000 (1MB)
| +-- | BIOS ROM |
| | +------------------+ <- 0x000F0000 (960KB)
| | | 16-bit devices, |
2 | | expansion ROMs |
| 1 +------------------+ <- 0x000C0000 (768KB)
| | | VGA Display |
| | +------------------+ <- 0x000A0000 (640KB)
+-- v - | (JOS) 1st Sec |
+-> +------------------+ <- 0x00007C00 (31KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000
  1. BIOS 加载硬盘镜像的第一个扇区至 0x7C00,并且跳转
  2. 1扇区将后面的操作系统加载进内存 0x100000 及将操作系统的程序段加载至高内存区域 0xF00100000,然后跳转至内核的入口地址 e_entry
  3. 设置内核内存空间运行环境,跳转

MIT6.828 Lab 1: C, Assembly, Tools, and Bootstrapping的更多相关文章

  1. mit-6.828 Lab Tools

    Lab Tools 目录 Lab Tools 写在前面 GDB GNU GPL (通用公共许可证) QEMU ELF 可执行文件的格式 Verbose mode Makefile 写在前面 操作系统小 ...

  2. MIT-6.828-JOS-lab1:C, Assembly, Tools, and Bootstrapping

    Lab1:Booting a PC 概述 本文主要介绍lab1,从内容上分为三部分,part1简单介绍了汇编语言,物理内存地址空间,BIOS.part2介绍了BIOS从磁盘0号扇区读取boot loa ...

  3. MIT 6.828 Lab 1/ Part 2

    Exercise 03 - obj/boot/boot.asm 反汇编文件 截取asm部分文件并注释理解 # Set up the important data segment registers ( ...

  4. MIT6.828课程JOS在macOS下的环境配置

    本文将介绍如何在macOS下配置MIT6.828 JOS实验的环境. 写JOS之前,在网上搜寻JOS的开发环境,很多博客和文章都提到"不是32位linux就不好配置,会浪费大量时间在配置环境 ...

  5. MIT6.828准备:MacOS下搭建xv6和risc-v环境

    本文介绍在MacOS下搭建Mit6.828/6.S081 fall2019实验环境的详细过程,包括riscv工具链.qemu和xv6,对于Linux系统同样可以参考. 介绍 只有了解底层原理才能写好上 ...

  6. MIT6.828 JOS系统 lab2

    MIT6.828 LAB2:http://pdos.csail.mit.edu/6.828/2014/labs/lab2/ LAB2里面主要讲的是系统的分页过程,还有就是简单的虚拟地址到物理地址的过程 ...

  7. MIT6.828 虚拟地址转化为物理地址——二级分页

    这个分页,主要是在mit6.828的lab2的背景下来说的. Mit6.828 Lab2:http://pdos.csail.mit.edu/6.828/2014/labs/lab2/ lab2主要讲 ...

  8. 《MIT 6.828 Lab 1 Exercise 12》实验报告

    本实验的网站链接:MIT 6.828 Lab 1 Exercise 12. 题目 Exercise 12. Modify your stack backtrace function to displa ...

  9. 《MIT 6.828 Lab 1 Exercise 11》实验报告

    本实验的网站链接:MIT 6.828 Lab 1 Exercise 11. 题目 The above exercise should give you the information you need ...

  10. 《MIT 6.828 Lab 1 Exercise 10》实验报告

    本实验的网站链接:MIT 6.828 Lab 1 Exercise 10. 题目 Exercise 10. To become familiar with the C calling conventi ...

随机推荐

  1. 利用高级组策略管理AGPM复制组策略GPO

    有时候管理多个林,在一个林中配置了GPO之后,想复制出来用到其它林里.默认系统的组策略管理里没有这个功能.但是微软在微软企业桌面优化套件Microsoft Desktop Optimization P ...

  2. SQL注入篇——sqli-labs各关卡方法介绍|1-65

    主要是记下来了每关通过可以采用的注入方式,可能部分关卡的通关方式写的不全面,欢迎指出,具体的获取数据库信息请手动操作一下. 环境初始界面如下: sql注入流程语句: order by 3--+ #判断 ...

  3. avue常用场景记录

    接手的一个项目使用的是avue这个傻瓜式的专门给后端人员用的框架,文档不够友好,使用起来各种蛋疼(咱专业前端基本上不使用).为此,专门记录一下.当前avue版本2.8.12,如果要切换avue的版本, ...

  4. 使用kubeoperator安装的k8s集群以及采用的containerd容器运行时,关于采用的是cgroup 驱动还是systemd 驱动的说明

    使用kubeoperator安装的k8s集群,默认使用的是systemd驱动 # kubectl get cm -n kube-system NAME DATA AGE calico-config 4 ...

  5. 腾讯云主机安全【等保三级】CentOS7安全基线检查策略

    转载自:https://secvery.com/8898.html 注意:注意,注意:处理前请先做备份,处理前请先做备份,处理前请先做备份 1.确保配置了密码尝试失败的锁定 编辑/etc/pam.d/ ...

  6. PAT (Basic Level) Practice (中文)1015 德才论 分数 25

    宋代史学家司马光在<资治通鉴>中有一段著名的"德才论":"是故才德全尽谓之圣人,才德兼亡谓之愚人,德胜才谓之君子,才胜德谓之小人.凡取人之术,苟不得圣人,君子 ...

  7. hive数据导出到linux本地

    方法1(hive下执行):insert overwrite local directory 'Linux本地目录' row format delimited fields terminated by  ...

  8. MatrixOne从入门到实践01——初识MatrixOne

    初识MatrixOne 简介 MatrixOrigin 矩阵起源 是一家数据智能领域的创新企业,其愿景是成为数字世界的核心技术提供者. 物理世界的数字化和智能化无处不在.我们致力于建设开放的技术开源社 ...

  9. TomCat之安装

    TomCat 之安装(伪分布式版本) 本次安装是使用的伪分布式的安装(即一台机器安装两个tomcat) 1.通过scp导入tomcat安装包 2.解压缩成俩个文件 3.修改第一个tomcat的配置文件 ...

  10. Java中的名称命名规范

    包名:多单词组成时所有字母都小写:xxxyyyzzz 类名.接口名:多单词组成时,所有单词的首字母大写:XxxYyyZzz 变量名.方法名:多单词组成时,第一个单词首字母小写,第二个单词开始每个单词首 ...