☰
STM32嵌入式C++工程调试链路补全:VSCode+GDB+OpenOCD实战
2026/10/6 1:40:20 网站建设 项目流程

1. 从“还差活滴”说起:这个项目到底在补什么

“哟哟哟,咱们还差活滴”——这个标题一看就是系列连载里那种带着点自嘲、又有点不服气的口吻。前面几篇大概率已经把STM32的工程骨架、C++的基本引入、外设的初步驱动都搭起来了,但真正让一个嵌入式项目“活起来”的东西,往往不是那些能跑通的最小示例,而是调试链路、构建体系、工具链协同这些“脏活累活”。这篇要补的,正是这些。

我自己做STM32的C++项目时,最深的体会是:C语言那套“能编译、能烧录、能点灯”的流程,放到C++上会立刻暴露出一堆问题。比如构造函数什么时候跑、全局对象的初始化顺序、中断向量表里的C++函数能不能直接挂、new/delete要不要重载、异常和RTTI要不要关。这些问题不解决,项目就是“半死不活”的状态——能跑,但不敢改,一改就崩。所以这篇的核心,是把STM32上C++工程从“能编译”推进到“可调试、可维护、可扩展”的状态。

关键词里出现了STM32、嵌入式、C++、GDB、VSCode,这五个词基本勾勒出了整条工具链:芯片是STM32,语言是C++,调试靠GDB,编辑器用VSCode。热搜词里还有大量关于VSCode配置、GDB调试命令、STM32芯片包安装、LD文件、CAN通信、ADC通道切换的内容,说明读者群体大概率是正在从“Keil点灯”往“开源工具链+现代编辑器”迁移的开发者。这篇就按这个路径来拆,把每个环节的“为什么”和“怎么做”都讲透。

适合谁看?如果你已经能用Keil或者STM32CubeIDE跑通一个C工程,想往C++迁移;或者你已经在用VSCode但调试总是断不下来、变量看不到、断点打不中;再或者你听说GDB很强但一直没搞明白它和STM32怎么连起来——那这篇就是写给你的。下面从整体设计思路开始,一层层往下拆。

2. 整体设计与思路拆解

2.1 为什么STM32上的C++不能照搬PC那套

PC上写C++,你有操作系统兜底,有完整的标准库,有动态链接器帮你处理全局构造。STM32上没有这些东西。上电之后,复位向量指向的那段汇编会先把.data段从Flash搬到RAM,把.bss段清零,然后跳到main。C++的全局对象构造函数,必须在这之后、main之前被调用,否则你在全局对象里访问的成员变量全是未初始化的垃圾值。

这就是为什么STM32的C++工程必须有一个__libc_init_array的调用,或者自己写一个.init_array段的遍历。很多教程只告诉你“在main前面加一句__libc_init_array()”,但不说为什么。原因就是:C++编译器会把所有全局对象的构造函数指针放进.init_array段,链接器把它放在Flash里,启动代码需要手动遍历这个段并逐个调用。你不做这一步,全局对象的构造函数永远不会执行。

另一个坑是中断向量表。STM32的启动文件里,中断服务函数是用C写的,名字固定,比如SysTick_Handler。如果你在C++里定义一个同名的函数,链接器会报重复定义。解决办法是用extern "C"把中断函数包起来,告诉编译器“这个名字按C的规则来,不要做C++的名称修饰”。这个细节在纯C项目里根本不存在,但一旦引入C++,就是必踩的坑。

2.2 工具链选型:为什么是VSCode + GDB而不是Keil

Keil的好处是开箱即用,芯片包一装,点一下就能编译烧录调试。但它的编辑器体验、代码导航、版本控制集成,和VSCode差了一个时代。更重要的是,Keil的编译器是ARMCC/ARMCLANG,和GCC的行为有差异,很多C++特性支持程度不一样。如果你以后想把代码移植到Linux嵌入式或者别的ARM平台,GCC工具链的通用性更好。

VSCode + GDB + OpenOCD这套组合,本质上是把“编辑、构建、调试”三件事解耦。VSCode负责编辑和任务调度,Makefile或CMake负责构建,OpenOCD负责和ST-Link通信,GDB负责调试逻辑。每一层都可以单独替换:你可以换编译器、换调试器、换烧录工具,只要接口对得上。这种灵活性在长期项目里非常值钱。

热搜词里“vscode配置stm32开发环境”出现频率很高,说明很多人卡在配置这一步。配置的核心其实就三件事:告诉VSCode用什么命令编译、用什么命令烧录、用什么配置调试。下面会逐个拆。

2.3 调试链路:GDB到底在调什么

很多人对GDB的理解停留在“命令行调试器”,觉得不如IDE的图形化断点直观。但GDB在嵌入式里的角色是“协议翻译器”:它把你在编辑器里点的断点、看的变量,翻译成OpenOCD能理解的JTAG/SWD命令,再通过ST-Link打到芯片上。

具体链路是这样的:VSCode的调试插件(Cortex-Debug)启动GDB,GDB通过TCP连到OpenOCD的3333端口,OpenOCD通过ST-Link的SWD接口和STM32的调试单元通信。你在VSCode里看到的变量值,是GDB通过OpenOCD从芯片RAM里读出来的。断点分两种:硬件断点和软件断点。STM32的Flash里打软件断点需要修改Flash内容,所以通常用硬件断点,但硬件断点数量有限(Cortex-M3/M4一般6个)。断点打多了打不中,往往就是这个原因。

理解了这条链路,排查问题就有方向了:连不上,先看OpenOCD有没有起来;断点不中,先看断点数量超没超;变量看不到,先看优化等级是不是太高把变量优化掉了。这些后面会展开。

3. 核心细节解析与实操要点

3.1 启动文件里的C++初始化:__libc_init_array怎么加

STM32CubeMX生成的启动文件是startup_stm32xxxx.s,里面在调用main之前有一段:

bl __libc_init_array bl main

如果你用的是CubeMX生成的工程,这一句通常已经在了。但如果你用的是自己写的启动文件,或者从旧工程移植过来的,很可能只有bl main。这时候全局对象的构造函数不会执行,表现为:全局对象里的成员变量是随机值,单例模式拿到的实例是空的。

加这一句的位置很关键:必须在.data段搬运和.bss段清零之后,在main之前。顺序错了,构造函数里访问的全局变量还是未初始化的。另外,__libc_init_array是newlib提供的,如果你用的是--specs=nano.specs,它也在。但如果你完全不用标准库,就需要自己写一个遍历.init_array段的函数:

extern void (*__init_array_start[])(void); extern void (*__init_array_end[])(void); void call_constructors(void) { for (void (**p)(void) = __init_array_start; p < __init_array_end; p++) { (*p)(); } }

然后在启动文件里调用call_constructors。这个函数的原理是:链接器把所有全局构造函数指针放在.init_array段,__init_array_start和__init_array_end是链接脚本里定义的符号,标记这个段的首尾。遍历并调用即可。

注意:如果你同时用了__libc_init_array和自己写的遍历,构造函数会被调用两次。全局对象的构造函数如果有副作用(比如初始化硬件),调两次可能出问题。二选一即可。

3.2 中断服务函数的C++写法:extern "C"不能少

在C++文件里写中断函数,必须这样:

extern "C" void SysTick_Handler(void) { // 你的代码 }

不加extern "C",编译器会把函数名修饰成_Z15SysTick_Handlerv之类的东西,链接器找不到SysTick_Handler这个符号,启动文件里的向量表就指向了空地址,一进中断就HardFault。

更麻烦的是,如果你在C++里定义了一个和启动文件里弱定义同名的函数,但没加extern "C",链接器不会报错,而是把两个不同名字的函数都放进去了。向量表指向的是C那个,你C++写的那个永远不会被调用。这种问题最难查,因为编译链接都过,就是中断不执行。

我的习惯是:所有中断函数统一放在一个.cpp文件里,文件开头用extern "C" {把所有中断函数包起来。这样既保证了符号名正确,又能在中断里调用C++的类和模板。

3.3 优化等级对调试的影响:-O0和-Og怎么选

GDB调试时最常见的抱怨是“变量看不到”或者“单步跳来跳去”。这通常是优化等级的问题。-O2会把变量优化到寄存器里,GDB读不到内存中的值;会把循环展开,单步时行号乱跳;会内联函数,断点打不中。

调试阶段用-O0最稳,但STM32的Flash和RAM有限,-O0的代码体积可能是-O2的两三倍。如果芯片容量紧张,可以用-Og,这是GCC专门为调试优化的等级,在保持调试体验的同时做了一些不影响调试的优化。实测下来,-Og的代码体积比-O0小不少,变量也基本能看到。

发布阶段再切到-Os或-O2。但要注意,切换优化等级后一定要重新全量编译,因为不同等级下头文件的依赖关系可能不同,增量编译容易出诡异问题。

3.4 LD文件里的段布局:.init_array放哪

链接脚本(.ld文件)决定了各个段在Flash和RAM里的位置。C++引入后,需要关注这几个段:

段名内容位置
.text代码Flash
.rodata常量Flash
.data已初始化全局变量Flash,启动时搬到RAM
.bss未初始化全局变量RAM,启动时清零
.init_array全局构造函数指针Flash
.fini_array全局析构函数指针Flash

.init_array必须放在Flash里,且必须在.text之后、.data之前或之后都行,只要__init_array_start和__init_array_end能正确标记首尾。CubeMX生成的LD文件通常已经包含了这些段,但如果你自己写LD文件,漏掉.init_array就会导致构造函数不被调用。

检查方法:编译后用arm-none-eabi-objdump -h your.elf看段列表,确认.init_array存在且大小不为0。再用arm-none-eabi-nm your.elf | grep init_array看符号地址。

3.5 VSCode的tasks.json和launch.json:最小可用配置

VSCode本身不是IDE,它靠tasks.json定义构建任务,靠launch.json定义调试配置。STM32项目的最小配置如下。

tasks.json里定义一个build任务:

{ "version": "2.0.0", "tasks": [ { "label": "build", "type": "shell", "command": "make", "args": ["-j4"], "group": { "kind": "build", "isDefault": true }, "problemMatcher": ["$gcc"] } ] }

launch.json里定义调试配置,用Cortex-Debug插件:

{ "version": "0.2.0", "configurations": [ { "name": "Debug (OpenOCD)", "type": "cortex-debug", "request": "launch", "servertype": "openocd", "cwd": "${workspaceFolder}", "executable": "build/your_project.elf", "device": "STM32F407VG", "configFiles": [ "interface/stlink.cfg", "target/stm32f4x.cfg" ], "svdFile": "STM32F407.svd", "runToEntryPoint": "main" } ] }

svdFile指向芯片的SVD文件,配了之后可以在VSCode的调试侧边栏看到所有外设寄存器的值,非常方便。SVD文件可以从芯片厂商官网或者Keil的芯片包里找。

提示:runToEntryPoint设为main可以让调试器启动后自动停在main,省得手动打断点。但如果你要调试全局构造函数,就把它去掉,让程序停在复位向量。

3.6 OpenOCD的配置文件:interface和target怎么配

OpenOCD需要两个配置文件:一个描述调试器硬件(interface),一个描述目标芯片(target)。ST-Link的interface配置通常是interface/stlink.cfg,STM32F4的target配置是target/stm32f4x.cfg。这两个文件在OpenOCD安装目录的scripts文件夹里。

常见问题是ST-Link固件版本太老,OpenOCD不认。报错通常是“unable to find a matching CMSIS-DAP device”或者“stlink version mismatch”。解决办法是用ST官方的ST-Link Utility或者STM32CubeProgrammer升级ST-Link固件。升级后OpenOCD就能正常识别了。

另一个问题是芯片读保护。如果芯片被设置了读保护,OpenOCD连接时会报“target not halted”或者“flash protected”。需要先解除读保护,但解除读保护会擦除整个Flash,操作前确认代码有备份。

4. 实操过程与核心环节实现

4.1 从零搭建:工程目录结构

一个可维护的STM32 C++工程,目录结构建议这样:

project/ ├── .vscode/ │ ├── tasks.json │ └── launch.json ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ └── stm32f4xx_hal_conf.h │ └── Src/ │ ├── main.cpp │ ├── stm32f4xx_it.cpp │ └── system_stm32f4xx.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F4xx_HAL_Driver/ ├── Middlewares/ ├── build/ ├── Makefile ├── STM32F407VGTx_FLASH.ld └── startup_stm32f407xx.s

Core/Src里放应用代码,Drivers里放HAL库和CMSIS,Middlewares放第三方库。build目录放编译产物,不纳入版本控制。Makefile可以用CubeMX生成的,也可以自己写。自己写的好处是可控,坏处是要处理一堆源文件路径和编译选项。

Makefile的核心部分:

CXX = arm-none-eabi-g++ CC = arm-none-eabi-gcc AS = arm-none-eabi-gcc -x assembler-with-cpp LD = arm-none-eabi-g++ OBJCOPY = arm-none-eabi-objcopy SIZE = arm-none-eabi-size CXXFLAGS = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard CXXFLAGS += -O0 -g3 -Wall -fno-exceptions -fno-rtti CXXFLAGS += -ffunction-sections -fdata-sections CXXFLAGS += -ICore/Inc -IDrivers/STM32F4xx_HAL_Driver/Inc CXXFLAGS += -IDrivers/CMSIS/Device/ST/STM32F4xx/Include CXXFLAGS += -IDrivers/CMSIS/Include LDFLAGS = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard LDFLAGS += -TSTM32F407VGTx_FLASH.ld LDFLAGS += -Wl,--gc-sections LDFLAGS += -specs=nano.specs -specs=nosys.specs LDFLAGS += -Wl,-Map=build/your_project.map

-fno-exceptions和-fno-rtti是嵌入式C++的标配。异常需要额外的运行时支持,RTTI需要类型信息表,两者都会显著增加代码体积。STM32上一般不用,关掉能省不少Flash。

-ffunction-sections和-fdata-sections配合--gc-sections,可以把没用的函数和数据从最终镜像里剔除。C++的模板和虚函数表容易产生大量冗余代码,这两个选项能有效控制体积。

4.2 编译烧录:make和openocd的命令行操作

编译就是make -j4,-j4表示用4个线程并行编译,速度取决于CPU核心数。编译完成后用arm-none-eabi-size build/your_project.elf看体积:

text data bss dec hex filename 28432 120 4520 33072 8130 build/your_project.elf

text是Flash占用,data是已初始化全局变量(同时占Flash和RAM),bss是未初始化全局变量(只占RAM)。STM32F407VG有1MB Flash和192KB RAM,这个体积很宽裕。

烧录用OpenOCD命令行:

openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \ -c "program build/your_project.elf verify reset exit"

program命令会烧录并校验,verify确保写入正确,reset让芯片复位运行,exit让OpenOCD退出。如果只想烧录不校验,去掉verify能快一点,但不推荐,Flash写入偶尔会出错,校验能及时发现。

4.3 GDB调试实战:连接、断点、变量查看

用GDB调试STM32,有两种方式:命令行GDB和VSCode图形化。先讲命令行,理解了底层再用图形化会更清楚。

启动OpenOCD(在一个终端里):

openocd -f interface/stlink.cfg -f target/stm32f4x.cfg

OpenOCD会监听3333端口(GDB)和4444端口(Telnet)。然后在另一个终端启动GDB:

arm-none-eabi-gdb build/your_project.elf

在GDB里连接目标:

(gdb) target extended-remote localhost:3333 (gdb) monitor reset halt (gdb) load (gdb) break main (gdb) continue

monitor reset halt让芯片复位并停在复位向量,load把elf烧进去,break main在main打断点,continue运行到main。这时候可以用next单步、print variable看变量、info registers看寄存器。

常用GDB命令整理成表:

命令作用
target extended-remote localhost:3333连接OpenOCD
monitor reset halt复位并暂停
load烧录elf
break func在函数打断点
break file:line在文件行号打断点
continue继续运行
next单步跳过
step单步进入
print var打印变量
info locals打印当前作用域所有局部变量
info registers打印寄存器
backtrace打印调用栈
x/16xw 0x20000000以16进制查看内存

VSCode的Cortex-Debug插件把这些命令图形化了,但底层是一样的。如果VSCode里断点不中,可以打开GDB的日志看实际发了什么命令。在launch.json里加"showDevDebugOutput": true可以看到GDB和OpenOCD的通信日志。

4.4 一个完整的调试案例:全局构造函数没执行

假设你写了一个全局对象:

class Led { public: Led() { GPIO_Init(...); } void toggle() { HAL_GPIO_TogglePin(...); } }; Led led; // 全局对象 int main() { while (1) { led.toggle(); HAL_Delay(500); } }

烧录后LED不闪。排查步骤:

  1. 确认__libc_init_array在启动文件里被调用。用arm-none-eabi-objdump -d build/your_project.elf | grep -A5 "bl.*main"看main之前的调用。
  2. 确认.init_array段存在且非空。用arm-none-eabi-objdump -h build/your_project.elf | grep init_array。
  3. 在GDB里break Led::Led,看构造函数有没有被调用。如果没停,说明.init_array没被遍历。
  4. 检查LD文件里.init_array的定义,确认__init_array_start和__init_array_end符号存在。

这个案例的根因通常是启动文件里漏了bl __libc_init_array,或者LD文件里.init_array段被放到了错误的位置。补上之后LED就闪了。

4.5 性能与体积的平衡:优化选项的实际影响

我拿一个实际的STM32F407工程做了对比,代码量差不多的情况下:

优化等级Flash占用RAM占用调试体验
-O048KB12KB最好,变量全可见
-Og36KB10KB好,大部分变量可见
-O228KB9KB差,变量常被优化
-Os24KB8KB差,单步跳转乱

调试阶段用-O0或-Og,发布用-Os。如果Flash紧张,优先用-Os加--gc-sections,再考虑关掉不用的HAL模块。HAL库的很多模块可以通过stm32f4xx_hal_conf.h里的宏开关控制,关掉不用的能省不少空间。

注意:切换优化等级后,一定要make clean再全量编译。增量编译在优化等级变化时容易出问题,因为目标文件的依赖关系没有更新。

5. 常见问题与排查技巧实录

5.1 GDB连不上OpenOCD的几种原因

这是最高频的问题。现象是VSCode启动调试后卡住,或者报“Failed to connect to OpenOCD”。

排查顺序:

  1. OpenOCD有没有起来?在终端手动运行openocd -f interface/stlink.cfg -f target/stm32f4x.cfg,看有没有报错。如果报“no device found”,检查ST-Link的USB连接和驱动。
  2. 端口有没有被占用?OpenOCD默认用3333和4444端口。如果之前启动的OpenOCD没退干净,端口会被占。用netstat -ano | findstr 3333(Windows)或lsof -i:3333(Linux/Mac)查。
  3. 芯片有没有被读保护?如果OpenOCD报“target not halted”,可能是芯片读保护了。用STM32CubeProgrammer连接看能不能识别,如果提示读保护,需要先解除。
  4. ST-Link固件版本太老?OpenOCD对ST-Link固件有最低版本要求。用STM32CubeProgrammer升级固件。

5.2 断点打不中的硬件断点数量限制

Cortex-M3/M4的硬件断点数量一般是6个,Cortex-M0是4个。如果你在VSCode里打了超过6个断点,后面的断点不会生效,但VSCode不会提示。表现是程序运行到断点处不停。

解决办法:用条件断点减少断点数量,或者用watchpoint(数据断点)替代。数据断点也是硬件资源,数量更少(一般2个)。如果断点需求超过硬件限制,可以在代码里手动加__BKPT()指令,但这样需要重新编译。

提示:VSCode的Cortex-Debug插件在断点列表里会用不同颜色标记硬件断点和软件断点。软件断点在Flash里需要修改指令,STM32的Flash写入次数有限,不建议大量使用。

5.3 变量显示optimized out的解决

GDB里print variable显示optimized out,说明编译器把这个变量优化掉了。解决办法:

  1. 降低优化等级到-O0或-Og。
  2. 给变量加volatile关键字,告诉编译器不要优化它。但volatile会影响性能,只对调试关键的变量加。
  3. 用-fno-inline禁止内联,这样函数调用栈更清晰。
  4. 在GDB里用info registers看变量是不是在寄存器里,如果在,可以直接读寄存器。

5.4 Flash烧录失败与读保护的解除

烧录时报“flash write failed”或者“verify failed”,可能原因:

  • Flash没擦除干净。OpenOCD的program命令默认会先擦除,但如果芯片有读保护,擦除会失败。
  • 芯片写保护。STM32的Flash可以按扇区写保护,如果目标扇区被保护,写入会失败。用STM32CubeProgrammer看Option Bytes里的写保护设置。
  • 供电不足。ST-Link的供电能力有限,如果板子上有多个外设,可能供电不足导致烧录失败。用外部电源供电试试。

解除读保护会擦除整个Flash,操作前确认代码有备份。解除方法:用STM32CubeProgrammer连接,在Option Bytes里把Read Out Protection设为Level 0,然后应用。

5.5 常见问题速查表

现象可能原因排查方法
全局对象构造没执行启动文件缺__libc_init_arrayobjdump看main前调用
中断不执行中断函数没加extern "C"nm看符号名
断点不中硬件断点超限减少断点数量
变量optimized out优化等级太高降到-O0或-Og
GDB连不上OpenOCD没起或端口占用手动运行OpenOCD
烧录失败读保护或供电不足CubeProgrammer检查
程序跑飞栈溢出加大栈或查递归
HardFault空指针或未对齐访问看CFSR寄存器

5.6 几个我踩过的坑

第一个坑:在中断里调用printf。HAL库的printf重定向到UART后,在中断里调用会阻塞,因为UART发送是同步的。如果中断频率高,系统会卡死。解决办法是用DMA发送或者环形缓冲区,中断里只往缓冲区写,主循环里发。

第二个坑:C++的new没有重载。默认的new会调用malloc,而malloc在STM32上需要堆空间。如果你没在启动文件里分配堆,new会返回空指针。解决办法是重载new和delete,用静态内存池或者直接禁用动态分配。

第三个坑:虚函数表放在Flash里,但虚函数调用需要读Flash。如果Flash等待周期设置不对,虚函数调用会变慢甚至出错。STM32F4的Flash等待周期根据主频设置,168MHz需要5个等待周期。CubeMX生成的SystemInit会设置,但如果你自己改时钟,记得同步改等待周期。

第四个坑:-fno-exceptions关了异常,但标准库的某些部分(比如std::vector的at)会抛异常。关了异常后这些函数的行为未定义。解决办法是避免用会抛异常的标准库函数,或者用-fno-exceptions的同时定义__EXCEPTIONS为空。

5.7 调试之外的工程化建议

调试链路通了之后,下一步是把工程规范化。几个建议:

  • 用CMake替代Makefile。CMake的跨平台性和依赖管理比手写Makefile强,尤其是引入第三方库的时候。
  • 加单元测试。STM32上的单元测试可以在PC上跑,把硬件相关的部分抽象成接口,用mock实现。这样大部分逻辑不需要上板就能测。
  • 用Git做版本控制。.vscode目录可以纳入版本控制,但build目录要忽略。.gitignore里加build/和*.o。
  • 加CI。GitHub Actions可以跑编译检查,确保每次提交都能编译通过。STM32的交叉编译工具链可以在CI里安装。

这些不是必须的,但项目稍微大一点就会用到。早点搭好,后面省事。

6. 关于这套工具链的个人体会

我用这套VSCode + GDB + OpenOCD的组合做了几个STM32的C++项目,最大的感受是:前期配置麻烦,后期效率高。Keil点一下就能调试,但代码导航和重构体验差;VSCode配置要写几个json文件,但配好之后,代码跳转、查找引用、重命名、Git集成都是一流的。

GDB的命令行看起来吓人,但常用的就那十几个命令。而且VSCode的Cortex-Debug插件已经把大部分操作图形化了,只有在排查诡异问题的时候才需要直接敲GDB命令。理解了GDB和OpenOCD的通信链路,排查问题就有方向,不会像无头苍蝇一样乱试。

C++在STM32上的坑,大部分集中在启动阶段和中断处理。把__libc_init_array、extern "C"、.init_array段这几个点搞清楚,后面就是正常的C++编程了。优化等级和调试体验的平衡是个取舍,调试阶段别舍不得关优化,发布阶段再打开。

最后分享一个小技巧:在launch.json里加"preLaunchTask": "build",这样每次启动调试前会自动编译,省得忘了编译导致调试的是旧代码。这个配置我用了很久,很稳。

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

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

立即咨询