1. 项目概述
最近在做一个工业质检的小项目,需要从多个USB摄像头实时抓取图像进行分析。一开始想得很简单,不就是用OpenCV的VideoCapture读个流,然后imwrite保存一下嘛。结果真上手才发现,坑是一个接一个:摄像头打不开、画面卡顿、保存图片慢导致丢帧、多路同时采集时互相干扰……这些问题在单摄像头demo里可能遇不到,但一旦上到生产环境,尤其是对稳定性和实时性有要求的时候,就全暴露出来了。
折腾了小半个月,总算把一套相对稳定、高效的四路USB摄像头实时截图系统给跑通了。这里说的“实时截图”,不是指手动按一下按钮保存一张,而是程序能自动地、周期性地从每一路摄像头抓取最新的一帧图像,保存到硬盘,并且整个过程不能影响视频流的流畅采集和实时显示。这背后涉及到开发环境配置、摄像头驱动兼容性、多线程同步、内存管理、文件I/O优化等一系列问题。今天我就把踩过的坑和最终的解决方案,从头到尾捋一遍,希望能帮到有类似需求的兄弟。无论你是做安防监控、视频会议、还是像我一样的机器视觉应用,这套思路应该都能直接拿来用。
2. 开发环境搭建与OpenCV配置
2.1 Visual Studio与OpenCV版本选择
工欲善其事,必先利其器。环境没配好,后面全是玄学问题。我的主力开发环境是Windows 10/11 + Visual Studio 2019。为什么不选VS 2022?主要是考虑到一些老旧产线电脑可能还没升级到最新的系统,VS2019的兼容性更好,对应的VC++运行库(v142)也更普及。如果你机器上缺这个,去微软官网搜“Microsoft Visual C++ 2015-2022 Redistributable”装一下就行,这是运行我们编译好的程序所必须的。
OpenCV的版本选择更有讲究。我强烈建议使用OpenCV 4.5及以上版本。原因有三:第一,4.x版本对C++11/14/17的现代特性支持更好,代码写起来更舒服;第二,也是最重要的,4.5版本之后对Windows下多摄像头并发的支持有了质的提升,特别是通过CAP_DSHOW(DirectShow)后端,打开多个摄像头的成功率和稳定性远高于老旧的3.4.x版本。我实测过,用OpenCV 3.4同时开两个1080p的摄像头,第三个就经常报错,而4.8.0版本开四个都很稳。
2.2 OpenCV源码编译与项目配置
很多人图省事,直接下载官网的预编译包(那个.exe文件)。对于新手和快速验证想法,这没问题。但如果你想真正掌控你的项目,尤其是后期可能需要裁剪模块、启用CUDA加速或者调试源码,自己从源码编译是唯一的选择。这个过程听起来复杂,其实按步骤来也就十几分钟。
首先,去OpenCV的GitHub仓库下载源码。我推荐用4.8.0这个稳定版本。
git clone -b 4.8.0 https://github.com/opencv/opencv.git git clone -b 4.8.0 https://github.com/opencv/opencv_contrib.git # 额外模块,可选但推荐接下来是编译,这里有个关键决策:编译静态库(.lib)还是动态库(.dll)?
- 静态库:所有OpenCV代码都打包进你的.exe文件。好处是分发简单,一个exe走天下,不用担心用户电脑缺DLL。缺点是exe体积会非常大(轻松上百MB),而且如果你有多个程序都用OpenCV,内存里会有多份拷贝。
- 动态库:生成独立的
.dll文件。exe体积小,多个程序可以共享同一份DLL内存。缺点是发布程序时必须把对应的opencv_world480.dll(Debug版是opencv_world480d.dll)一起带上。
对于我们的多摄像头截图程序,我建议用动态库。因为程序本身逻辑不复杂,但OpenCV库很大,用动态库可以方便更新和共享。编译工具用CMake-GUI,配置时注意几个关键选项:
| CMake 选项 | 推荐值 | 说明 |
|---|---|---|
BUILD_SHARED_LIBS | ON | 生成动态链接库(DLL)。 |
OPENCV_EXTRA_MODULES_PATH | 你的opencv_contrib/modules路径 | 启用额外功能模块(如人脸识别、文本检测)。 |
WITH_CUDA | 根据需求 | 如果你有NVIDIA显卡且需要GPU加速,可以打开。但初次编译建议关掉,能快很多。 |
CMAKE_INSTALL_PREFIX | 例如D:/opencv/install | 指定编译后库文件的安装路径,后面配置VS就指向这里。 |
BUILD_opencv_world | ON | 将所有模块打包成一个opencv_world480.lib,极大简化链接步骤,强烈推荐。 |
点击Configure,选择“Visual Studio 16 2019”和“x64”,然后Generate。完成后用VS2019打开生成的.sln文件,在“解决方案配置”里选“Release”,然后生成“ALL_BUILD”,最后生成“INSTALL”。这个过程视电脑性能,大概要半小时到一小时。
编译安装完成后,D:/opencv/install目录下就是我们需要的所有东西了:include文件夹里是头文件,x64/vc15/lib里是.lib导入库,bin里是运行时需要的.dll。
2.3 Visual Studio项目属性配置
这是新手最容易出错的地方。新建一个VC++控制台空项目,一定要在“解决方案平台”那里选择x64,和你编译的OpenCV架构一致。
然后右键项目 -> 属性,进行配置(注意配置选“所有配置”,这样Debug和Release就一起设好了):
- VC++目录 -> 包含目录:添加
D:\opencv\install\include和D:\opencv\install\include\opencv2。 - VC++目录 -> 库目录:添加
D:\opencv\install\x64\vc15\lib。 - 链接器 -> 输入 -> 附加依赖项:添加
opencv_world480.lib。这里有个技巧,可以区分Debug和Release:- Debug配置下加:
opencv_world480d.lib - Release配置下加:
opencv_world480.lib你也可以用宏$(Configuration)来简化:opencv_world480$(Configuration).lib,但注意opencv_world480.lib本身没有d后缀,而Debug版有,所以更稳妥的方法是分别设置。
- Debug配置下加:
- 环境变量(可选但推荐):将
D:\opencv\install\x64\vc15\bin添加到系统的PATH环境变量中,并重启VS。这样运行时系统就能找到opencv_world480.dll了。如果不加,就需要把dll文件复制到你的exe同级目录下。
踩坑记录:最常遇到的链接错误是
LNK2019: 无法解析的外部符号。99%的原因就是上面三步没做对:要么包含目录没加对,编译器找不到头文件;要么库目录没加对,链接器找不到.lib文件;要么附加依赖项的名字写错了(比如漏了d)。另一个常见运行时错误是“找不到opencv_world480.dll”,就是PATH没设或者dll没拷贝到位。
配置好后,写个最简单的测试程序,能打开摄像头显示画面,环境就算搭成了。
3. 多摄像头初始化与设备管理
3.1 理解VideoCapture与后端(Backend)
OpenCV的cv::VideoCapture类是我们操作摄像头的入口。在Windows下,它底层其实是通过两种主要的API来和摄像头驱动打交道:DirectShow (DSHOW)和Media Foundation (MSMF)。
- CAP_DSHOW:比较老的技术,从Windows XP时代就有了,兼容性极好,几乎是个USB摄像头就能认。但效率相对低一些,某些高级功能(比如直接获取MJPG压缩流)支持不好。
- CAP_MSMF:Vista之后微软推的新一代多媒体框架,更现代,性能更好,对H.264等现代编码支持更佳。但在某些特别老或者非标准的摄像头上可能会初始化失败。
当你写cv::VideoCapture cap(0);时,OpenCV会按照一个默认的优先级列表去尝试各种后端,通常先试MSMF,不行再试DSHOW。但在多摄像头场景下,让OpenCV自己选后端是个灾难。我遇到过两个摄像头,一个被MSMF打开,一个被DSHOW打开,结果两个的帧率、延迟特性完全不同,同步起来非常头疼。
所以,最佳实践是显式指定后端。对于USB摄像头,我推荐统一使用CAP_DSHOW,求稳。
cv::VideoCapture cap0(0, cv::CAP_DSHOW); cv::VideoCapture cap1(1, cv::CAP_DSHOW); // ... 以此类推统一后端能确保所有摄像头的行为一致,减少很多莫名其妙的兼容性问题。
3.2 设备ID的“漂移”问题与解决方案
你以为cv::VideoCapture cap(0, cv::CAP_DSHOW);里的0永远对应你插在某个USB口上的那个摄像头?太天真了。这个索引号是操作系统在启动时枚举USB设备动态分配的,和物理端口的对应关系是不固定的。今天开机摄像头A是0,B是1;明天可能就反过来了,或者重启一下程序顺序就变了。
这对于需要固定视角的应用(比如“左摄像头看正面,右摄像头看侧面”)是致命的。解决方法有几个:
- 物理固定:插上就别动了,并且标记好线缆。这是最笨但最有效的方法,适合部署后就不变的场景。
- 软件识别:通过读取摄像头的“友好名称”或唯一ID来识别。遗憾的是,大部分普通USB摄像头通过OpenCV直接获取不到唯一序列号。但我们可以通过Windows的DirectShow接口(
ICreateDevEnum)枚举所有视频设备,获取设备的完整名称,里面通常包含厂商和型号信息。虽然同型号的摄像头还是分不清,但至少比纯数字ID靠谱。网上有很多用DirectShow或Windows.Media.CaptureAPI来枚举设备的C++例子,可以集成到项目里。 - 特征匹配:程序启动时,让每个摄像头拍一张特征图(比如对着不同的二维码或特定图案),通过图像内容来识别和绑定逻辑位置。这个方法最可靠,但实现也最复杂。
对于大多数项目,我建议采用“物理固定 + 启动自检”的策略。即固定USB口,程序启动时遍历所有摄像头ID(比如0-9),尝试打开并显示一帧,让用户确认哪个画面对应哪个逻辑位置,然后程序把这个映射关系保存下来。
3.3 多摄像头并发初始化的稳定性
同时打开四个摄像头,最怕的就是资源冲突。你可能遇到:
- 第二个摄像头死活打不开,提示“设备被占用”。
- 四个都能打开,但帧率奇低,像幻灯片。
- 程序一退出,摄像头指示灯还亮着,需要拔插USB才能恢复。
这些问题根源在于UVC驱动和USB带宽。USB总线带宽是有限的。一个USB 2.0的理论带宽是480Mbps,但实际可用远低于此。一个1080p 30fps的未压缩YUV流,大概需要:1920 * 1080 * 1.5 (YUV420) * 30 ≈ 93 Mbps。 四个这样的流就要近400Mbps,已经接近USB 2.0的极限了,所以卡顿、丢帧是必然的。USB 3.0会好很多。
初始化最佳实践:
- 顺序打开,而非同时打开:不要在一个循环里
push_back四个VideoCapture。先打开一个,设置好参数,等几毫秒,再开下一个。给驱动一点反应时间。 - 设置合理的分辨率:如果不是必须,别用最高分辨率。
640x480或1280x720对于很多截图应用足够了。cap.set(cv::CAP_PROP_FRAME_WIDTH, 1280); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 720); - 关闭自动参数:自动曝光、自动白平衡、自动对焦这些功能虽然方便,但会引入不确定的延迟,并且在多摄像头时可能互相干扰。尽量设为手动或固定值。
cap.set(cv::CAP_PROP_AUTO_EXPOSURE, 0); // 0=手动,1=自动 cap.set(cv::CAP_PROP_EXPOSURE, -6); // 具体值需要根据摄像头调试 cap.set(cv::CAP_PROP_AUTOFOCUS, 0); // 关闭自动对焦 - 显式释放资源:程序退出时,确保对每个
VideoCapture对象调用cap.release(),并调用cv::destroyAllWindows()。这能保证驱动句柄被正确释放,避免“幽灵占用”。
4. 实时帧捕获与内存管理优化
4.1 理解cv::Mat的引用计数与深浅拷贝
这是OpenCV性能优化的核心知识点。cv::Mat是图像数据的容器,它包含一个“头”(header,存储宽、高、类型等元信息)和一个指向实际像素数据的指针(data)。默认的赋值操作(=)和传参是浅拷贝(shallow copy),只复制“头”,多个Mat对象共享同一份像素数据。
cv::Mat frame; cap.read(frame); // frame的data指向摄像头驱动提供的内存 cv::Mat frame_shallow = frame; // 浅拷贝!frame_shallow和frame共享data frame_shallow.setTo(0); // 这下frame也全黑了!因为修改的是同一块内存。在多线程环境下,如果多个线程同时读写同一个cv::Mat(即使只是读),而其中一个线程触发了写时复制(比如调用了clone(),copyTo(),或者某些修改图像的函数),就可能导致数据竞争或崩溃。
我们的策略是:
- 采集线程:每个摄像头一个独立线程,负责不断调用
cap.read(tempFrame),然后将tempFrame深拷贝到一个供主线程或其他处理线程读取的共享缓冲区。 - 处理/保存线程:从共享缓冲区浅拷贝获取图像进行处理。如果处理过程会修改图像(比如画框、缩放),则需要在处理线程内部进行深拷贝。
// 采集线程伪代码 void captureThread(int camId, cv::Mat& sharedFrame, std::mutex& frameMutex) { cv::VideoCapture cap(camId, cv::CAP_DSHOW); cv::Mat localFrame; while (running) { if (cap.read(localFrame)) { std::lock_guard<std::mutex> lock(frameMutex); localFrame.copyTo(sharedFrame); // 深拷贝到共享区 } } }4.2 实现高效的同步截图循环
“实时截图”的关键是,截图操作不能阻塞视频流的采集。你不能在cap.read()之后直接cv::imwrite(),因为写磁盘很慢(几十毫秒),这段时间摄像头的新帧来了就会被丢弃,导致视频卡顿。
标准做法是:生产者-消费者模型。
- 生产者(采集线程):高速循环,不断读取最新帧,更新缓冲区。
- 消费者(主线程/保存线程):定时(例如每秒)或按外部触发,从缓冲区浅拷贝取出当前帧,然后交给另一个专门的保存线程去执行耗时的磁盘写入操作。
这里引入一个帧率控制与截图触发机制。我们通常在主循环里用cv::waitKey(delay)来控制整体节奏。假设我们想要25FPS的显示和采集,那么delay可以设为40ms(1000/25=40)。截图可以基于帧计数器来触发,比如每25帧(即每秒)截一次。
int frameCounter = 0; const int SAVE_INTERVAL = 25; // 每25帧保存一次,即1秒一次(假设25fps) std::queue<std::pair<int, cv::Mat>> saveQueue; // 保存任务队列 std::mutex queueMutex; while (true) { // 1. 从各摄像头采集线程的共享缓冲区中获取最新帧 (浅拷贝) std::vector<cv::Mat> currentFrames(4); { std::lock_guard<std::mutex> lock(g_sharedFrameMutex); for(int i=0; i<4; ++i) { currentFrames[i] = g_sharedFrames[i]; // 浅拷贝 } } // 2. 显示 for(int i=0; i<4; ++i) { cv::imshow("Cam " + std::to_string(i), currentFrames[i]); } // 3. 触发截图 if (++frameCounter >= SAVE_INTERVAL) { frameCounter = 0; for(int i=0; i<4; ++i) { if(!currentFrames[i].empty()) { std::lock_guard<std::mutex> qLock(queueMutex); // !!! 注意:这里必须深拷贝,因为currentFrames马上会被下一帧覆盖 saveQueue.push({i, currentFrames[i].clone()}); } } // 通知保存线程有新任务 saveCondition.notify_one(); } // 4. 控制循环速度并检测退出 if(cv::waitKey(40) == 27) break; // 40ms对应~25fps,ESC退出 }4.3 异步文件保存与性能瓶颈突破
上面代码中,我们把需要保存的图像clone()后放到了一个队列里。现在需要一个独立的保存线程来消费这个队列。
void saveWorkerThread() { while (true) { std::pair<int, cv::Mat> task; { std::unique_lock<std::mutex> lock(queueMutex); // 等待队列非空或退出信号 saveCondition.wait(lock, []{return !saveQueue.empty() || stopSaving;}); if (stopSaving && saveQueue.empty()) break; task = std::move(saveQueue.front()); saveQueue.pop(); } // 执行耗时的保存操作 std::string filename = generateFilename(task.first); cv::imwrite(filename, task.second); } }这样做的好处是巨大的:主循环(负责采集和显示)永远不会被慢速的磁盘I/O阻塞。即使cv::imwrite因为磁盘忙要花100ms,也只是让保存队列变长,视频流依然流畅。这就是异步处理的核心思想。
性能实测对比:在我的测试机上(SATA SSD),同步保存4张1080p的JPEG图片(质量95)大约需要80-120ms,这期间主线程完全卡住。改为异步后,主线程的循环延迟稳定在40ms(25fps),保存操作在后台默默进行,对前端体验零影响。
5. 图像保存策略与文件管理
5.1 生成有意义的文件名
保存一堆image1.jpg,image2.jpg是毫无意义的。我们需要能体现时间和摄像头来源的文件名。使用C++11的<chrono>和<iomanip>库可以方便地生成高精度时间戳。
#include <chrono> #include <sstream> #include <iomanip> std::string generateTimestamp() { auto now = std::chrono::system_clock::now(); auto ms = std::chrono::duration_cast<std::chrono::milliseconds>( now.time_since_epoch()) % 1000; auto timer = std::chrono::system_clock::to_time_t(now); std::tm bt = *std::localtime(&timer); std::ostringstream oss; oss << std::put_time(&bt, "%Y%m%d_%H%M%S"); // 格式: 20231026_143022 oss << '.' << std::setfill('0') << std::setw(3) << ms.count(); // 添加毫秒 .345 return oss.str(); } std::string generateFilename(int cameraId) { std::string timestamp = generateTimestamp(); std::ostringstream filename; filename << "cam_" << std::setfill('0') << std::setw(2) << cameraId << "_" << timestamp << ".jpg"; // 示例: cam_00_20231026_143022.345.jpg return filename.str(); }这种命名方式保证了文件名的唯一性,并且按文件名排序就是按时间排序,非常利于后续的检索和管理。
5.2 组织文件目录结构
如果程序长时间运行,图片会非常多。一个好的目录结构是必须的。我推荐按日期创建子文件夹。
#include <direct.h> // for _mkdir on Windows #include <sys/stat.h> // for mkdir on Linux bool createDirectoryIfNotExists(const std::string& path) { #ifdef _WIN32 int ret = _mkdir(path.c_str()); #else int ret = mkdir(path.c_str(), 0755); #endif return (ret == 0 || errno == EEXIST); } std::string getOutputPath() { auto now = std::chrono::system_clock::now(); auto timer = std::chrono::system_clock::to_time_t(now); std::tm bt = *std::localtime(&timer); std::ostringstream oss; oss << "captures/" << std::put_time(&bt, "%Y-%m/%d/"); // captures/2023-10/26/ std::string dirPath = oss.str(); if (createDirectoryIfNotExists(dirPath)) { return dirPath; } else { // 创建失败,退回当前目录 std::cerr << "Failed to create directory: " << dirPath << std::endl; return "./"; } } // 在保存线程中使用 std::string baseDir = getOutputPath(); std::string fullPath = baseDir + generateFilename(cameraId); cv::imwrite(fullPath, image);5.3 平衡图像质量与保存速度
cv::imwrite的第三个参数可以控制保存质量,这对于节省磁盘空间和提升保存速度至关重要。
std::vector<int> compression_params; compression_params.push_back(cv::IMWRITE_JPEG_QUALITY); compression_params.push_back(90); // 质量因子,0-100,越高画质越好文件越大 // compression_params.push_back(cv::IMWRITE_PNG_COMPRESSION); // compression_params.push_back(3); // PNG压缩级别,0-9,越高压缩比越高速度越慢 cv::imwrite(filename, image, compression_params);经验之谈:对于监控、质检这类应用,JPEG质量设为85-90是一个很好的平衡点。肉眼几乎看不出和100的区别,但文件大小能减少30%-50%,写入速度也能快不少。如果后续需要做精确的图像分析(如测量、OCR),可以考虑用无损的PNG格式,但要做好磁盘空间和速度的心理准备。
6. 系统稳定性与错误处理
6.1 摄像头断线重连机制
USB摄像头不是绝对可靠的,可能会被意外拔掉、驱动崩溃、被其他软件抢占。一个健壮的系统必须能处理这些异常。
核心思路:状态检测 + 延迟重试。
- 在采集线程的循环中,不仅检查
cap.read()的返回值,还要定期(比如每100帧)检查cap.isOpened()。 - 如果
read失败或isOpened返回false,说明摄像头丢失。此时应该:- 调用
cap.release()释放资源。 - 记录错误日志。
- 进入一个重试循环,每隔几秒尝试重新
open设备。 - 重新打开后,需要重新设置分辨率、曝光等参数。
- 调用
void captureThread(int camId) { cv::VideoCapture cap; int failCount = 0; const int MAX_RETRY = 10; const int RETRY_DELAY_MS = 2000; while (running) { if (!cap.isOpened()) { // 尝试打开摄像头 if (!cap.open(camId, cv::CAP_DSHOW)) { failCount++; std::this_thread::sleep_for(std::chrono::milliseconds(RETRY_DELAY_MS)); if (failCount > MAX_RETRY) { std::cerr << "Camera " << camId << " failed after " << MAX_RETRY << " retries." << std::endl; break; // 放弃该摄像头 } continue; } // 打开成功,进行参数设置 cap.set(cv::CAP_PROP_FRAME_WIDTH, 1280); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 720); // ... 其他参数 failCount = 0; std::cout << "Camera " << camId << " reconnected." << std::endl; } cv::Mat frame; if (!cap.read(frame)) { std::cerr << "Failed to read frame from camera " << camId << std::endl; cap.release(); // 读取失败,认为连接已断,释放并进入重连逻辑 continue; } // ... 正常处理帧 } cap.release(); }6.2 资源监控与日志记录
程序需要长时间无监督运行,完善的日志系统是排查问题的生命线。不要只用std::cout,要输出到文件,并包含时间戳和日志级别(INFO, WARN, ERROR)。
class Logger { public: enum LogLevel { INFO, WARNING, ERROR }; static void log(LogLevel level, const std::string& message) { std::ofstream file("app.log", std::ios::app); auto now = std::chrono::system_clock::now(); auto t = std::chrono::system_clock::to_time_t(now); file << "[" << std::put_time(std::localtime(&t), "%F %T") << "] "; switch(level) { case INFO: file << "[INFO] "; break; case WARNING: file << "[WARN] "; break; case ERROR: file << "[ERROR] "; break; } file << message << std::endl; } }; // 使用 Logger::log(Logger::INFO, "Camera 0 initialized successfully."); Logger::log(Logger::ERROR, "Failed to write image: " + filename);同时,可以监控一些关键指标,比如每个摄像头的帧率、保存队列的长度、内存占用等。如果保存队列持续增长,说明磁盘写入速度跟不上采集速度,可能需要降低截图频率或图像质量。
7. 完整代码框架与总结
把上面的所有部分组合起来,一个完整的、健壮的四路USB摄像头实时截图系统的骨架就清晰了。它包含以下几个核心模块:
- 配置管理模块:读取配置文件,设置摄像头ID、分辨率、截图间隔、保存路径等。
- 设备管理模块:负责摄像头的初始化、参数设置、状态监控和断线重连。
- 采集线程池:每个摄像头一个独立线程,负责高速抓帧并更新共享缓冲区。
- 主控制线程:负责刷新显示、触发截图(将任务放入队列)、处理用户输入(如退出)。
- 保存工作线程:从队列中取出任务,执行文件保存,处理文件名和路径。
- 日志与监控模块:记录运行状态,报警异常。
几个我实际踩过的大坑:
- USB带宽不足:这是多摄像头系统的头号杀手。如果四个摄像头都跑1080p@30fps,USB 2.0 Hub肯定撑不住。解决方案:降低分辨率或帧率;使用USB 3.0接口和集线器;检查主板USB控制器是否共享带宽。
- 驱动冲突:某些笔记本自带的摄像头驱动和USB摄像头的驱动会打架。尝试在设备管理器里暂时禁用内置摄像头。
- 杀毒软件/防火墙干扰:特别是某些“主动防御”功能,可能会拦截程序对摄像头的访问。将你的程序添加到白名单。
cv::imwrite在Debug模式下极慢:Debug版OpenCV和VC++运行时库没有优化,imwrite可能比Release版慢10倍以上。性能测试一定要用Release模式。
最后,这套系统虽然以C++/OpenCV实现,但其架构思想是通用的。核心就是解耦:将高速的数据采集、实时的画面显示、慢速的磁盘I/O、以及可能更耗时的图像分析(比如调用AI模型)分别放到不同的线程或进程中去,用缓冲区(如队列)连接它们。只要把握住这个原则,不管是用Python、C#还是其他语言,都能构建出稳定高效的视频处理应用。