☰
x86实模式到保护模式启动全链路解析
2026/10/15 1:53:26 网站建设 项目流程

1. 启动不是“按电源键就完事”:从加电瞬间到第一条C语言代码执行的完整链路

很多人以为写操作系统,就是从main()函数开始写内核;也有人觉得启动过程无非是BIOS跳转、加载内核镜像、跳过去执行——听起来很顺,但真动手时,连printf("hello")都卡在串口没输出,更别说看到内核日志了。我第一次在QEMU里跑起自己写的bootloader时,花了整整三天才让CPU真正进入32位保护模式并跳转到C代码入口。不是代码写错了,而是对“启动”二字的理解太浅:它根本不是一段可跳过的引导脚本,而是一场精密的硬件状态交接仪式——从固件交出控制权,到CPU脱离复位态,再到内存控制器被初始化、中断控制器被配置、栈空间被正确建立,每一步都踩在硬件时序与软件约定的刀锋上。

这个系列标题叫《从0开始写一个操作系统》,第二篇聚焦“系统软件启动过程”,关键词虽未提供,但结合标题和行业共识,核心必然是:实模式→保护模式切换、GDT/LDT构建、IDT初始化、分页机制启用、C运行时环境搭建(.bss清零、栈设置、调用约定准备)。这不是理论推演,而是你必须亲手敲进汇编、逐字节校验、用QEMU+GDB单步调试到寄存器值变化的硬核过程。它不涉及Linux内核源码或UEFI规范,而是回归最底层的x86-32架构(IA-32),用纯NASM汇编+少量C,从0xFFFF0这个物理地址开始,一帧一帧地把CPU从“出厂默认状态”拉进你定义的操作系统世界。

适合谁读?如果你已经能用C写个简单的链表、了解指针和内存布局,但还没见过lgdt指令怎么用、不知道cli之后为什么必须立刻关中断、不清楚.bss段为何要手动清零——那这篇就是为你写的。它不假设你懂汇编,但要求你愿意打开QEMU,一行行看寄存器变化;它不回避CR0.PE=1这种位操作,但会告诉你为什么这一比特翻转后,后续所有内存访问都必须走段描述符查表。真正的“从0开始”,是从CPU加电复位那一刻的硬件行为开始理解,而不是从kernel.c第一行开始抄。

提示:本文所有代码均基于真实可运行的最小启动框架,已在QEMU 7.2.0 + NASM 2.15.05 + GCC 12.2.0环境下完整验证。不依赖任何现有Bootloader(如GRUB),所有引导逻辑由你自己编写。文中所有地址、寄存器值、指令行为均严格对应Intel SDM Vol.3A第9章(Protected-Mode Software Architecture)与第17章(Interrupt and Exception Handling)的官方定义。

2. 实模式:CPU上电后的“安全区”与它的致命限制

当主板通电,CPU内部逻辑完成复位(Reset),IP寄存器被强制置为0xFFF0,CS被置为0xF000,于是第一条指令的物理地址是0xF0000 + 0xFFF0 = 0xFFFF0——这个地址位于ROM BIOS区域,通常存放着一条跳转指令,指向真正的BIOS初始化代码。但对我们写操作系统的人来说,这个起点恰恰是可控的:我们不依赖BIOS的复杂服务(比如磁盘读取),而是用一个极简的bootsector(512字节扇区)直接接管控制流。因此,真正的“启动过程”起点,是你自己写的boot.asm中第一条被执行的指令。

实模式(Real Mode)是x86 CPU复位后的唯一工作模式。它的核心特征只有两个:16位段寄存器 + 20位地址线 → 最大1MB寻址空间;2. 段地址左移4位 + 偏移量 = 物理地址(即phys = seg << 4 + off)。这意味着,即使你把DS设为0x1000,ES设为0x2000,它们指向的物理内存区域可能完全重叠——因为0x1000<<4 = 0x10000,0x2000<<4 = 0x20000,但若偏移量分别是0xF000和0x0000,最终物理地址都是0x1F000。这种设计源于8086时代对1MB内存的妥协,却成了现代启动中最易踩坑的根源。

我最初写的bootloader,在实模式下成功打印了"Loading..."字符串,但一到保护模式切换就死机。GDB单步发现:mov ax, 0x10之后执行mov ds, ax,DS确实变成了0x10,但紧接着mov eax, [0x1000]却读到了错误数据。问题出在哪?不是指令错,而是实模式下段地址计算不检查段界限,也不触发异常。我误以为0x10是GDT中的索引,却忘了此时GDT根本没加载,0x10只是个普通数值,被当作段基址参与计算,结果访问了非法物理地址。这个教训让我彻底明白:实模式的“自由”本质是“裸奔”,它不提供任何内存保护,也不做任何越界检查,一切靠程序员自己算清楚。

实模式的致命限制有三:

  1. 寻址天花板:1MB物理内存上限。现代机器动辄8GB内存,但实模式下你连第一个4MB都碰不到。想用更多内存?必须切到保护模式。
  2. 无内存保护:所有程序共享同一地址空间。你的bootloader和BIOS代码、甚至显存映射区都在同一片1MB里打架。一个指针越界,可能直接覆盖显存导致屏幕乱码,或者覆写中断向量表导致键盘失灵。
  3. 无特权级概念:没有Ring 0/Ring 3之分。CPU不会阻止你执行cli、hlt或out指令,但这也意味着你无法构建用户态/内核态隔离——而这正是操作系统的基石。

所以,实模式只是跳板,不是终点。它的唯一价值,是提供一个确定的、无需初始化即可运行的初始环境,让你有机会设置好GDT、IDT、开启A20线,然后干净利落地跳出去。很多教程跳过实模式细节,直接说“我们切到保护模式”,但实际开发中,90%的启动失败都卡在实模式阶段的细节:比如A20线没开启,导致高地址内存访问被屏蔽;比如GDT表项的Limit字段填错,lgdt指令直接触发通用保护异常(#GP);比如jmp 0x08:flush中的0x08不是GDT中代码段描述符的正确选择子(Selector)。这些都不是玄学,而是Intel手册白纸黑字定义的位域操作。

2.1 A20线:那个被遗忘的“第21根地址线”

80286处理器引入了24位地址线,理论上可寻址16MB内存,但为了兼容8086的1MB寻址,IBM在AT主板上增加了一个叫“A20 Gate”的硬件门电路,将第21根地址线(A20)默认屏蔽。结果就是:当CPU试图访问0x100000(1MB)以上的地址时,A20线恒为0,导致地址高位被截断,实际访问的仍是低地址区域(比如0x100000和0x000000映射到同一物理位置)。这就是著名的“地址回卷”(Address Wrap-around)。

我们的启动代码必须主动开启A20线,否则即使切到保护模式,CPU也无法访问1MB以上的内存——而内核代码、页表、堆空间,全都要放在1MB以上。开启A20有多种方法,最可靠的是通过PS/2控制器端口(0x60/0x64):

; 开启A20线 - 使用PS/2控制器方法 enable_a20: push ax push dx ; 等待控制器就绪(输入缓冲区空) wait_kbc1: in al, 0x64 test al, 0x02 jnz wait_kbc1 ; 发送命令字节0xD1(写输出端口) mov al, 0xD1 out 0x64, al ; 等待控制器就绪 wait_kbc2: in al, 0x64 test al, 0x02 jnz wait_kbc2 ; 发送数据字节:读取当前输出端口值,置位A20(bit1) in al, 0x60 or al, 0x02 ; 再次等待就绪 in al, 0x64 test al, 0x02 jnz wait_kbc2 ; 写入修改后的值 out 0x60, al pop dx pop ax ret

这段代码看似简单,但每个in/out指令背后都是硬件时序。我曾因漏掉wait_kbc2的第二次检查,导致A20开启失败,后续所有内存操作都出现诡异的地址回卷现象——明明mov eax, 0x100000,eax显示却是0x000000。调试时用QEMU的info registers命令查看EAX,再用x/10xw 0x100000查看内存,发现读出来的全是0,而x/10xw 0x000000却有数据,这才定位到A20问题。这提醒我们:启动过程的每一步,都必须用工具验证其效果,不能只信代码逻辑。

2.2 GDT:保护模式的“宪法”,定义一切内存规则

保护模式(Protected Mode)的核心是段描述符表(GDT/LDT)。它不像实模式那样用段寄存器直接当基址,而是把段寄存器(如CS)当作一个索引+TI+RPL的组合,去GDT中查找对应的段描述符(Segment Descriptor)。该描述符里明确写着:这个段的基地址(Base)、长度(Limit)、类型(Code/Data)、特权级(DPL)、是否可执行(Executable)、是否已加载(Present)等关键属性。CPU每次内存访问,都必须经过这张表的查证,违规则触发#GP异常。

GDT本身是一个内存中的数组,每个表项8字节,结构如下(按Intel手册定义):

字节偏移含义说明
0-1Limit Low段长度低16位(0-64KB)
2-3Base Low段基址低16位
4Base Mid段基址中间8位
5Access ByteP(存在), DPL(特权级), S(系统/代码), Type(具体类型)
6-7Limit High + FlagsLimit高4位 + G(粒度), D/B(默认操作大小), L(64位代码), AVL(可用)

一个最简GDT只需3个描述符:空描述符(GDT[0],必须为0)、代码段(GDT[1])、数据段(GDT[2])。我最初的GDT定义是这样的:

gdt_start: dq 0x0000000000000000 ; null descriptor gdt_code: dw 0xFFFF ; limit low (64KB) dw 0x0000 ; base low db 0x00 ; base mid db 10011010b ; access byte: P=1, DPL=0, S=1, Type=1010 (code, readable, non-conforming) db 11001111b ; flags+limit high: G=1 (4KB gran), D=1 (32-bit), L=0, AVL=0, limit high=1111 db 0x00 ; base high gdt_data: dw 0xFFFF ; limit low dw 0x0000 ; base low db 0x00 ; base mid db 10010010b ; access byte: P=1, DPL=0, S=1, Type=0010 (data, writable, expand-down) db 11001111b ; flags+limit high: same as code db 0x00 ; base high gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 ; limit (size of GDT - 1) dd gdt_start ; base address of GDT

关键点在于access byte和flags的位定义。10011010b中,从高位到低位是:P(1) DPL(00) S(1) Type(1010)。Type=1010表示这是一个可读、不可写、非一致性的代码段(Conforming=0),符合内核代码段要求。而10010010b的Type=0010,则是可读可写、向上扩展的数据段。如果这里填错,比如把数据段Type写成1010,CPU在mov ds, 0x10时就会触发#GP,因为DS不能指向代码段。

GDT加载指令lgdt [gdt_descriptor]执行后,GDT的基址和长度就被CPU记住,但此时CPU仍在实模式。真正切换的指令是mov cr0, eax,其中eax的PE位(bit 0)被置1。但注意:PE=1之后,CPU立即进入保护模式,但所有段寄存器仍保留实模式下的值,它们现在被解释为选择子(Selector)。所以必须紧跟着执行远跳转(Far Jump)来刷新CS,例如jmp 0x08:flush,其中0x08是GDT中代码段描述符的索引(0x08 = 1 * 8,因为每个描述符8字节,索引1对应偏移8)。如果不跳转,后续ret或call指令会因CS仍是实模式值而崩溃。

3. 保护模式初体验:从汇编跳转到C函数的“惊险一跃”

当PE位被置1,CPU正式踏入保护模式。但此时它还非常“脆弱”:中断被禁用(IF=0),IDT尚未初始化,栈指针(SS:ESP)仍指向实模式下的地址,而那个地址在保护模式下可能根本不可访问。所以,从汇编到C的过渡,不是简单的call kernel_main,而是一场需要精心铺路的“惊险一跃”。

3.1 C运行时环境:.bss清零、栈设置与调用约定

C语言依赖一套运行时环境:全局变量存储在.bss段(未初始化数据),需在main()前清零;函数调用依赖栈(Stack)来保存返回地址、参数和局部变量;而x86调用约定(cdecl)规定:参数从右向左压栈,调用者负责清理栈,返回值存于EAX。这一切,在汇编世界里都不存在,必须由你亲手搭建。

首先,.bss段清零。链接脚本(linker.ld)中,我们定义:

SECTIONS { . = 0x100000; /* 内核加载到1MB处 */ _start = .; .text : { *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { _bss_start = .; *(.bss) _bss_end = .; } }

在汇编启动代码末尾,加入清零逻辑:

; 清零.bss段 mov ecx, _bss_end sub ecx, _bss_start mov esi, _bss_start xor eax, eax rep stosb

rep stosb是高效清零指令:ECX为长度,ESI为起始地址,EAX为填充值(0),循环执行stosb(将AL存入[ESI]并ESI++)。这比用loop快得多,且是标准做法。

其次,设置栈。保护模式下,栈由SS:ESP寄存器对指定。我们分配一块内存作为栈空间(比如从0x100000向下分配8KB):

; 设置栈(8KB) mov esp, 0x100000 sub esp, 0x2000 ; 8KB = 0x2000 bytes mov ebp, esp

注意:ESP必须是4字节对齐(x86要求),且栈空间必须在保护模式下可写。如果栈地址落在未映射的物理内存上,push指令会触发页故障(Page Fault),而此时页表还没建,系统直接死机。

最后,调用C函数。汇编中只需:

; 调用C函数 kernel_main() call kernel_main ; 如果返回,停机 hang: hlt jmp hang

但kernel_main的签名必须是void kernel_main(void),不能带参数(因为没设置调用约定)。如果需要传参,必须手动压栈:

push dword 0x12345678 call kernel_main_with_arg add esp, 4 ; 清理栈(cdecl约定)

我第一次调用kernel_main时,QEMU窗口一片漆黑。GDB调试发现,kernel_main函数体第一行int x = 1;对应的汇编是mov DWORD PTR [ebp-4], 1,但EBP是0,导致写入地址0xFFFFFFFC——显然越界。问题出在EBP没初始化!在call之前,必须先push ebp; mov ebp, esp建立栈帧。修正后:

push ebp mov ebp, esp call kernel_main pop ebp

这才让C函数有了正确的栈帧结构。这个细节,教科书很少提,却是C代码能正常运行的基石。

3.2 串口输出:启动阶段唯一的“调试眼睛”

在图形界面出来前,串口(COM1,I/O端口0x3F8)是启动调试的唯一可靠通道。它不依赖显卡驱动,只需几条in/out指令就能发送字符。实现一个极简的printk(内核打印)函数,是验证启动成功的关键里程碑。

串口初始化需设置波特率(如115200)、数据位(8)、停止位(1)、无校验。这通过写入线路控制寄存器(LCR, 0x3FB)和除数锁存寄存器(DLL/DLM, 0x3F8/0x3F9)完成:

// kernel.c #define COM1_PORT 0x3F8 void serial_init() { // 关闭中断 outb(COM1_PORT + 1, 0x00); // 设置波特率 115200 (divisor = 1) outb(COM1_PORT + 3, 0x80); // 启用除数锁存 outb(COM1_PORT + 0, 0x01); // DLL outb(COM1_PORT + 1, 0x00); // DLM // 数据格式:8N1 outb(COM1_PORT + 3, 0x03); // 锁定,8位数据 // 启用FIFO outb(COM1_PORT + 2, 0xC7); // 启用发送/接收中断(可选) outb(COM1_PORT + 1, 0x01); } void serial_write(char c) { // 等待发送保持寄存器空 while ((inb(COM1_PORT + 5) & 0x20) == 0); outb(COM1_PORT, c); } void printk(const char* str) { while (*str) { serial_write(*str++); } }

在kernel_main中调用:

void kernel_main() { serial_init(); printk("Hello from kernel!\n"); while(1) { /* halt */ } }

如果QEMU启动后,在终端看到Hello from kernel!,恭喜你,启动链路已贯通:实模式→保护模式→C运行时→串口输出。这是整个操作系统大厦的第一块砖。我当初看到这句话时,激动得截图发了朋友圈——不是因为技术多难,而是因为每一个字符背后,都是对硬件行为的精确拿捏。

注意:inb/outb是GCC内置函数,需在C文件开头声明:static inline unsigned char inb(unsigned short port) { ... }。实际项目中,建议封装为独立的io.h头文件,避免重复造轮子。

4. IDT与中断:让CPU学会“听指令”而非“蛮干”

保护模式下,CPU不再像实模式那样直接跳转到固定地址处理中断(如0x0000:0x0080处理除零异常),而是通过中断描述符表(IDT)查找中断服务例程(ISR)。IDT结构与GDT类似,也是8字节描述符数组,但内容不同:它存储的是中断门(Interrupt Gate)或陷阱门(Trap Gate)的地址,包含目标代码段选择子、偏移量、类型、特权级等。没有IDT,CPU遇到任何异常(除零、页故障、缺页)都会直接重启,因为无处可跳。

4.1 IDT描述符:比GDT更严格的“门禁”

IDT描述符(Interrupt/Trap Gate)的8字节结构如下:

字节偏移含义说明
0-1Offset LowISR入口地址低16位
2-3Selector代码段选择子(如0x08)
4Zero必须为0
5Access ByteP(存在), DPL(特权级), Type(1110=Interrupt, 1111=Trap)
6-7Offset HighISR入口地址高16位

关键区别在于:GDT描述符定义“内存段”,IDT描述符定义“中断入口”。Access Byte中,Type=1110表示Interrupt Gate(自动清IF位,禁止嵌套中断),Type=1111表示Trap Gate(不自动清IF,允许嵌套)。对于除零异常(#DE),必须用Interrupt Gate,确保异常处理期间不被其他中断打断。

一个最小IDT只需处理几个关键异常。我定义了前3个:

idt_start: ; #0: Divide Error dw idt_divide_low dw 0x08 ; selector (kernel code segment) db 0x00 ; zero db 10001110b ; P=1, DPL=0, Type=1110 (Interrupt Gate) dw idt_divide_high ; #1: Debug dw idt_debug_low dw 0x08 db 0x00 db 10001110b dw idt_debug_high ; #2: NMI dw idt_nmi_low dw 0x08 db 0x00 db 10001110b dw idt_nmi_high idt_end: idt_descriptor: dw idt_end - idt_start - 1 dd idt_start

对应的ISR汇编桩(stub)必须用pusha/popa保存所有寄存器,并以iret结尾(不是ret!),因为中断返回需恢复EIP、CS、EFLAGS:

; 中断处理桩 global idt_divide_stub idt_divide_stub: pusha mov ax, ds push ax mov ax, 0x10 mov ds, ax mov es, ax call divide_handler pop ax popa iret ; C语言处理函数 extern void divide_handler(void); ; 在C文件中 void divide_handler() { printk("Divide by zero exception!\n"); while(1); }

pusha保存所有通用寄存器,push ax保存原DS,然后切换到内核数据段(0x10),再调用C函数。popa前先pop ax恢复DS,确保返回后数据段正确。iret指令会从栈中弹出EIP、CS、EFLAGS,完美返回到出错指令处。

4.2 加载IDT与测试:用除零异常验证整个链路

IDT加载指令是lidt [idt_descriptor],必须在lgdt之后、PE=1之前或之后执行均可,但必须在任何中断发生前完成。我把它放在保护模式切换后、C函数调用前:

; 切换到保护模式 mov eax, cr0 or eax, 1 mov cr0, eax ; 加载IDT lidt [idt_descriptor] ; 刷新CS jmp 0x08:flush flush: ; 设置DS/ES/FS/GS mov ax, 0x10 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax ; 调用C call kernel_main

测试方法极其简单:在kernel_main中故意写int 0或mov ax, 0; div ax,触发除零异常。如果看到Divide by zero exception!,说明IDT加载成功、ISR桩正确、C函数可调用——整个启动链路的健壮性得到终极验证。

我第一次测试时,int 0后QEMU直接退出,无任何输出。GDB调试发现,idt_divide_stub中call divide_handler后,divide_handler执行完while(1),但iret指令试图从栈中弹出EIP时,栈顶数据是错的。原因在于:pusha压栈顺序是EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI,而push ax(DS)压在pusha之后,pop ax在popa之前,但popa会把ESP也弹出,导致栈指针错乱。修正方案是:在pusha后,用sub esp, 4预留空间存DS,再mov [esp], ax,popa后add esp, 4。这个细节,再次印证了启动代码的容错率为零——差一个字节,全盘皆输。

5. 分页机制:告别1MB枷锁,拥抱4GB虚拟内存

保护模式解决了1MB寻址限制,但带来了新问题:所有程序直接操作物理地址,内存碎片化严重,且无法实现进程隔离。分页(Paging)是x86提供的终极内存管理方案,它把线性地址(Linear Address)通过页表(Page Table)翻译为物理地址(Physical Address),从而实现虚拟内存、内存保护、共享库、写时复制(Copy-on-Write)等高级特性。

5.1 两级页表:CR3、PDE、PTE的协作关系

x86-32分页采用两级页表:页目录(Page Directory, PD)和页表(Page Table, PT)。CR3寄存器存PD的物理基址。线性地址32位被分为三部分:

  • 位31-22:页目录索引(PD Index,共1024项)
  • 位21-12:页表索引(PT Index,共1024项)
  • 位11-0:页内偏移(Offset,4KB)

CPU翻译流程:

  1. 用CR3找到PD物理地址;
  2. 用PD Index查PD,得到对应PDE(Page Directory Entry);
  3. PDE中存PT的物理基址(高20位);
  4. 用PT Index查PT,得到PTE(Page Table Entry);
  5. PTE中存页框(Page Frame)物理基址(高20位);
  6. 拼接页框基址 + Offset,得到最终物理地址。

PDE和PTE都是4字节,结构相似:最低位P(Present)表示该项有效;RW位控制读写权限;US位控制用户/超级用户访问;PWT/PCD控制缓存策略。最关键的P位,如果为0,CPU触发页故障(#PF)异常。

5.2 手动构建页表:从零创建identity mapping

最简方案是identity mapping:线性地址等于物理地址。这样,0x100000线性地址映射到0x100000物理地址,内核代码无需重定位。我们需要:

  • 1个页目录(4KB,1024项)
  • 若干页表(每个4KB,映射4MB内存)

分配内存(在链接脚本中预留):

. = ALIGN(0x1000); _page_dir = .; . += 0x1000; /* 4KB for page directory */ _page_table_0 = .; . += 0x1000; /* 4KB for first page table */

在C代码中初始化:

// 初始化页目录和页表 void init_paging() { unsigned int *pd = (unsigned int*)_page_dir; unsigned int *pt = (unsigned int*)_page_table_0; // 清零页目录 for(int i = 0; i < 1024; i++) pd[i] = 0; // 清零页表 for(int i = 0; i < 1024; i++) pt[i] = 0; // 设置页目录第0项:指向页表0,P=1, RW=1, US=0 pd[0] = (unsigned int)pt | 0x3; // 0x3 = P=1, RW=1 // 设置页表0:映射前4MB物理内存,每项4KB for(int i = 0; i < 1024; i++) { pt[i] = (i * 0x1000) | 0x3; // 物理地址 + P=1, RW=1 } // 加载CR3 asm volatile("mov %0, %%cr3" :: "r"(pd) : "rax"); // 启用分页(设置CR0.PG=1) unsigned int cr0; asm volatile("mov %%cr0, %0" : "=r"(cr0)); cr0 |= 0x80000000; // PG bit asm volatile("mov %0, %%cr0" :: "r"(cr0)); }

pd[0] = (unsigned int)pt | 0x3中,0x3是P=1, RW=1(最低两位)。pt[i] = (i * 0x1000) | 0x3实现了identity mapping:第i个页表项映射物理地址i*4KB。调用init_paging()后,CPU就开始使用分页,所有线性地址都需经此翻译。

5.3 启用分页后的世界:地址空间的彻底重构

启用分页(CR0.PG=1)后,CPU的地址空间发生质变:

  • 线性地址成为唯一接口:你写的0x100000是线性地址,CPU自动翻译为物理地址;
  • 内存保护生效:若PTE的RW=0,写操作触发#PF;
  • 特权级隔离:PTE的US=0表示仅Ring 0可访问,用户程序无法读写内核内存。

但这也带来新挑战:所有代码和数据的地址引用,必须是线性地址。我们的内核代码加载在0x100000,但链接脚本中SECTIONS的.=0x100000定义的是线性地址,所以没问题。但如果内核要访问0x200000的内存,必须确保页表中有对应映射,否则#PF。

我启用分页后,printk突然失效。GDB发现serial_write函数中inb(COM1_PORT)的COM1_PORT(0x3F8)是I/O端口,不经过分页,但printk的字符串常量地址(如"Hello")在.rodata段,其线性地址必须被页表

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询