Visual Studio调试C/C++指南

1. 前言

Visual Studio(VS)是微软开发的一款集成开发环境(IDE)软件,支持C/C++、C#、VB、Python等开发语言,开发桌面、Web等应用程序。VS功能极其强大,使用极其便利,用户数量最多,被誉为"宇宙第一IDE"。

熟悉地掌握基于VS的C/C++调试技术,可以大幅提升调试性能。随着VS版本的更新,其功能越来越强大,本文的内容是基于VS2019进行验证测试的,之前版本VS可能有少量特性不支持。

2. 基础

2.1. 调试

代码调试主要指使用调试工具来检查和修复代码中的错误和问题。代码调试主要有运行调试、打印调试、内存分析、静态分析、性能分析等。

2.2. 符号文件

符号文件(Symbol File)是指在编译程序时生成的包含调试信息的文件。它们通常与可执行文件或动态链接库(DLL)配对存在,用于提供程序的调试信息。VC生成的符号文件为PDB(Program Database)文件。其中存储变量名、函数名、代码行号、类型信息和栈信息等。exe/dll与pdb文件是一一对应的。每次重新编译代码,都会生成新的pdb。

2.3. 调试器

Microsoft Visual C/C++的调试器名称叫做"Visual Studio Debugger"。在调试exe时,其会读取exe文件中记录的PDB路径信息(这个路径是开发电脑编译时生成的PDB路径),如果这个PDB路径不存在,那么调试器会在exe目录去找PDB,如果依然找不到PDB,则启用无PDB调试。无PDB调试只能查看汇编信息和寄存器信息。

调试方式

3.1. 本地调试

VS工程默认即为本地调试(Local Windows Debugger)。选定启动工程,按F5或通过菜单Debug->Start Debugging。

  • 命令行参数(Command Arguments),给exe配置命令行参数。

  • 附加(Attach),默认No。Yes表示附加当前路径的进程进行调试。

3.2. 远程调试

  1. 将开发电脑上的Remote Debugger目录拷贝到生产电脑。
  1. 根据程序的类型x64/x86打开相应的目录,并打开生产电脑目录下的msvsmon.exe。

  2. 首次调用时,会弹出远程调试配置窗口,勾选所有的允许远程调试器与这些网络通信。

  3. 配置msvsmon.exe的Tools->Options。4015是默认的端口号,一般不建议修改。

  1. 获取生产电脑的IP,局域网网络通信,可以使用计算机名:端口号的方式,也可以使用IP:端口号的方法。但是在访问跨网关的局域网电脑时,计算机名可能无法解析出对应的IP地址,导致访问失败,所以更推荐IP:端口号的访问方式。

  2. 依据下图配置相关信息。

  3. 按F5启动调试,调试远程exe和调试本地exe后续操作完全一致。

3.3. 附加调试

  1. 打开exe。

  2. 从菜单启动Debug->Attach to Process,选择需要调试的进程进行附加。如果是远程进程,配置下图信息。

3.4. 外网调试

远程调试一般是针对局域网进行调试。但是有些时候,问题进程在外地,出差不方便或成本太高,非常需要一种能够穿透广域网进行调试的方法。最简单的方法是使用VPN将目标电脑远程连接到开发电脑,这样目标电脑和开发电脑就相当于处在同一个局域网,就可以使用普通的远程调试来进行外网目标电脑调试。

3.5. DLL调试

在DLL工程的属性中Debugging的Command中选择要执行的exe,然后在dll中设置相关断点。再按F5调试,即会中断在DLL工程的断点处。

4. 断点调试

int 3是x86-64架构CPU上的中断指令,用于在程序执行过程中触发软件中断。VS在给代码添加断点时,就是将指定行对应的代码修改为int 3指令,并且调试器接管代码。继续单步执行时,会还原int 3覆盖的代码。

4.1. 断点类型

4.1.1. 普通断点

在代码指定行按F9或右键菜单Breakpoint->Insert Breakpoint设置普通断点。

4.1.2. 条件断点

在断点上右键选择Conditions。

设置 i == 5, 然后点击Close。按F5执行,代码会停止在断点处,此时i==5。

指定当前断点触发指定次数时中断下来。

当前断点运行在指定线程时才中断下来。

4.1.3. 行为断点

行为断点(Actions Breakpoint),也称Tracepoint,因为断点触发时会在Output窗口打印信息。Continue Code Execution勾选表示不停止在断点处,如果选空表示停止在断点处。

Output窗口显示:The value of z is 0x0000001e. 0x2C74

$PID,是伪指令。可用的伪指令如下:

4.1.4. 数据断点

数据断点(Data Breakpoint),当然变量地址的内容发生改变时,即中断下来。如下图,Address编辑框可以直接填写变量的地址,也可以使用取地址符来获取变量的地址。数据断点只能针对有效数据设置断点,并且只能在已经开始调试之后,在Breakpoint窗口的菜单New->Data Breakpoint来设置。

4.1.5. 系统函数断点

例如想在CreateFile函数中下断点。可以使用dhb.exe在相应的

dbh.exe -s:srv*C:\Symbols*Symbol information -d C:\Windows\SysWOW64\kernel32.dll enum *CreateFile*

然后Breakpoints->New->Function Breakpoint:

运行就会断在系统API函数处,通过调用栈查找到调用的函数。

4.1.6. 软件断点

除了通过VS来添加断点外,我们也可以在代码中主动添加软件断点__debugbreak()/DebugBreak函数,或是断言ASSERT(0)。

__debugbreak()/DebugBreak是代码到此处立即中断,而断言则是根据参数逻辑值来决定是否中断。软件断点主要用来在代码潜在的异常出现时产生中断提示开发者。

4.2. 调试行为

4.2.1. 基本行为

工具栏或Debug菜单或鼠标右键都有调试行为的选项。

  1. Break All,中断当前所有正在执行的代码。当代码进入死循环时,点击Break All,代码即会中断下来,此时遍历线程查看函数调用栈,即能发现死循环代码位置。

  2. Stop Debugging,停止当前调试。

  3. Restart,重新开始调试。

  4. Show Next Statement,光标跳到下一次要执行的代码处。

  5. Step Into,快捷键F11,进入当前语句所调用的函数内部,并停在函数的第一行。如果当前行没有函数调用,则直接运行至下一行。

  6. Step Over,快捷键F10,执行当前语句,但不进入函数内部。

  7. Step Out, 快捷键Shift+F11,执行完当前函数,暂停在函数调用处。

  8. Step Backward,即退回到上一次代码暂停的位置,恢复上次调试的信息。Step Forward,前行到退回前的代码暂停的位置。这是基于栈快照进行的调试,VS会在几次中断时,保存对应栈信息,尤其是当我们想反复调试一个算法时,使用Step Backward会非常方便。

4.2.2. 高级行为

  1. Run To Cursor,运行到光标处中断,可以通过鼠标右键来执行,也可在光标处的代码行的图标上点击。
  1. Force Run To Cursor,强制运行到光标处,会跳过中间设置的断点。

  2. 自定义,鼠标放在箭头上,可以将下一个可执行代码的位置拖动到任意位置。拖动需要依赖先前的代码,否则可能产生异常。

5. 调试窗口

5.1. Output

Output Debug窗口主要输出调试过程的信息,主要包括:

  • Exception Messages

  • Step Filtering Messages

  • Module Load Messages

  • Module Unload Messages

  • Process Exit Messages

  • Thread Exit Messages

  • Program Qutput

其中Program Output是代码运行时输出的信息,主要通过Trace函数或OutputDebugString函数来将信息输出到Output窗口。如果是非调试状态下,OutputDebugString的输出信息则需要DbgView工具来接收并显示。

5.2. Locals

Locals窗口显示调试时当前执行代码所在函数所在的栈的局部变量的值和类型。

5.3. Autos

Autos窗口显示调试时当前执行代码所在函数所在的栈的上下文栈变量值和类型。

5.4. Watch

Watch窗口总共有4个,可以在菜单Debug->Windows->Wartch中选择。Watch窗口可以显示当前栈内存的局部变量,全局变量等,支持16进制形式显示,并支持实时修改变量值。

支持简单的表达式显示,如x+y, sizeof(x)等。

支持格式化显示,如(int*)(szBuff),4将Buff转换为int*,再格式化为4个元素显示。

Watch窗口还支持显示伪变量,如:

  • $tid -- 当前线程的的线程 ID

  • $pid -- 进程 ID

  • $cmdline -- 附加进程的命令行字符串

  • $user -- 运行在程序中的账户信息

  • $registername -- 显示指定寄存器的寄存器内容

  • $err -- 显示最近错误的错误码

  • $err, hr -- 显示最近错误的消息

5.5. Memory

Memory窗口是用来显示地址对应的内容,Memory窗口有4个从菜单Debug->Windows->Memory中选择。

Adress编辑框填写变量地址,Columns选择每行显示的内容数量。

5.6. QuickWatch

QuickWatch窗口是快捷观察、修改变量的窗口。

5.7. Disassembly

参数传递、函数返回等一些复杂的语法形式,要想理解其深层执行逻辑,就需要单步执行汇编代码来观察其隐藏逻辑细节。

5.8. Registers

寄存器窗口,显示当线程的寄存器信息。

5.9. Call Stack

函数调用栈窗口,显示当前线程的函数栈调用情况。可以通过Threads窗口选定当前线程。

5.10. Immediate Window

Debug->Windows->Immediate窗口。立即窗口主要用来查看、修改变量,执行函数,表达式等。

6. 多线程

6.1. Threads

Threads是显示当前进程的所有运行的线程信息的窗口。双击线程所在行,即将当前代码窗口、调用栈窗口等相关窗口的内容更新为选定线程。

当进程有多个工作者线程时,且只想调试其中一个线程时,可以将不关心的线程使用Freeze冰冻起来。冰冻的线程在按F5运行时,依然冰冻着不运行。使用Thaw解冻线程,线程将恢复为正常可以调试的状态。

6.2. 条件断点

在多线程中,根据线程信息来设置相应的断点来观察期望的变量信息。

6.3. Parallel Watch

Debug->Window->Parallel Watch可以显示几个线程同时调用的函数变量的情况,更方便地调试多线程。

6.4. 线程结构图

打开Debug->Windows->Parallel Stack窗口,会显示所有线程的栈,并显示当前线程栈。相比Threads窗口更直观。

7. 参数配置

7.1. 增量链接

增量链接在Debug下默认打开,在Release下默认关闭。通过Linker->General->Enable Incremental Linking来开启和关闭。增量链接还需要配置C/C++->General->Debug Information Format->Program Database for Edit And Continue (/ZI)。

增量链接的用处是在断点单步调试代码的时候,编辑代码,然后继续单步执行时,VS会自动增量链接,不用重新编译源代码,然后继续单步执行代码进行调试。

增量链接是调试时使用,增量编译则是编译时只编译修改的源文件,两者不相同。

7.2. 优化级别

Debug版本下,默认关闭代码优化,此时代码的调试信息与代码源文件是一一对应,调试更方便。Release版本下,默认使用O2优化,此时代码优化程度非常大,调试信息与代码源文件无法一一对应。如果想调试Release版本,则需要关闭优化。

7.3 宏展开

一些复杂的宏,非常难以调试。如何查看宏预处理之后展开形成的代码呢?C/C++->Preporcessor->Preprocess to a File配置默认是关闭的,打开此配置为Yes(/P)表示将生成预处理后的文件,文件与源文件同名,后缀为.i。在.i文件中可以查看所有宏展开的结果,以及其他预处理的结果。

7.4. 显示链接细节

VS 链接器默认只显示一些关键的链接信息。可以通过Linker->General->Show Progress配置Display all progress messages(/VERBOSE)来显示详细的链接信息,可以更方便地分析一些链接异常的错误。

7.5. 警告

7.5.1. 编译警告

为了提升代码的可靠性,警告也需要认真对等。

  • C/C++->All Options->Warning Level,选择合适的警告级别,初期可以选择W3级别。

  • C/C++->All Options->Treat Warning As Errors,初期可以不设置。

  • C/C++->All Options->Treat Specific Warnings As Errors,可以将一些关键警告设置为错误。

7.5.2. 链接警告

Linker->All Options->Treat Linker Warning As Errors,根据需要将警告作为错误对待。

Dump调试

8.1. 概念

Dump文件(Dump File),也叫转储文件,以.DMP为文件后缀。dump文件是进程在内存中的镜像文件,通过转换然后存储成以.DMP后缀的文件。dump文件根据存储时的选项不同,会生成不同大小的文件,其中记录信息也自然有所不同。

8.2. 转储文件生成

  1. 通过Windows进程管理器,选择指定的进程,右键生成转储文件。

  2. 通过函数MiniDumpWriteDump生成转储文件。

8.3. 调试转储文件

  • 选择与生成Dump文件相同版本的VS。

  • 启动VS并打开Dump文件。

  • 必须保证生成Dump文件的程序的PDB文件和源代码相一致。

  • VS2005打开Dump文件时,直接按F5调试,代码会停在出错的地方,通过Call Stack窗口查看。

  • VS2010打开Dump文件时,

需要通过Set symbol paths设置符号文件路径,也即PDB文件路径。然后点击Debug with Native Only,代码即会中断在出错的地方,通过Call Stack窗口查看相关信息。

变量溢出

变量内存溢出分为上溢(Overflow)和下溢(Underflow)。内存溢出导致的异常是C++代码最为难以调试的Bug之一,因为内存溢出导致的异常往往不会立即出现,而是展现在后面的函数中。因为内存溢出可能覆盖了后面的代码,导致后面的代码执行异常。

AddressSanitizer (ASan) 是一种编译器和运行时技术,它通过和编译器配合,通过插桩技术,向内存的前后添加标识,来识别运行时产生的上溢和下溢异常。其对代码执行性能影响较大,VS中默认是关闭的,需要手动打开。

ASan可以识别栈内存、堆内存、静态内存的溢出,另外也能检测重复释放,释放后使用内存。

通过C/C++->General->Enable Address Sanitizer使能,此配置与编辑并继续、增量链接和/RTC不兼容。

更多信息:AddressSanitizer | Microsoft Learn

资源泄露

电脑上的内核资源是有限的,申请使用完了,就要释放,否则资源可能不够用,导致后续申请失败而运行异常。所以资源泄露也是比较严重的问题,需要解决。

10.1. 内存泄露

在调试运行进程时,退出调试后,可能会在VS的Output窗口中显示如下信息:

这就是内存泄露的提示信息。上面的信息有时会指出内存泄露的位置,有的时候指出的位置却不准确。第三方工具 Visual Leak Detector,可以用来检测内存泄露,其主要是通过重载内存申请和释放函数,然后记录内存申请的地址和详细源文件行号,最后退出时检测所有申请的地址是否释放,如果没有释放,则打印出内存申请的信息。

Visual Leak Detector的使用非常简单,下载安装,然后在工程的启动接口文件中添加#include <vld.h>即可。

详细信息见:Home · KindDragon/vld Wiki · GitHub

10.2. 句柄泄露

除了内存泄露外,句柄也是容易泄露的资源。

  • 可以使用WinDbg的句柄快照对比功能找出未正常释放的句柄。

  • 可以使用第三方工具Deleaker,可以注册试用版本使用14天。Deleaker会检测出代码申请未释放的行号。

11. 静态调试

相比于动态调试(在代码运行时调试),静态调试是在代码编译时来调试,其效率更高。

11.1. 静态断言

静态断言static_assert是C++11中引入的新语法,可以在编译时进行一些判断,并打印相应信息。

static_assert(sizeof(int) == 4, "int must be 4 bytes"); // 检查 int 类型是否为 4 字节

template <typename T>
void process(T value) {
    static_assert(std::is_integral<T>::value, "T must be an integral type"); // 检查模板类型是否为整数类型
}

11.2. 静态分析

VS自带的静态分析,功能非常强大,能够发现很多隐藏的问题。开启静态分析之后,会在编译期间进行静态分析代码,所以会加大编译时间。建议定期开启静态分析检查代码,并修复相关问题。

另外还可以选择相应的静态分析规则:

12. 性能调试

VS提供了性能度量工具(Performance Profiler),帮助开发者优化代码和提高应用程序性能。Profiler的主要功能是诊断内存、CPU 使用率。Debug下的性能分析,因为有很多调试及优化的影响,所以很不准确,建议是在Release版本下进行性能调试分析。

通过Debug->Performance Profiler。C/C++更多用来分析CPU和Memory。

点击Start之后,目标程序开始执行,并开始监控性能,并通过Take Snapshot给当前进程拍摄快照。

12.1. 内存分析

通过Stop Collection或等进程结束,会显示如下信息。每个快照会保存详细的进程堆栈信息。

点击上面的内存分配次数、分配次数增量等数据,可以获取详细信息:

双击相应对象分配行,可以得到详细的对象信息包括调用堆栈信息,通过调用栈可以查看相应的代码:

12.2. CPU分析

内存分类统计:

内存分线程统计:

双击函数名,可以详细函数占比分析:

12.3. 性能提示

调试器在断点或单步执行操作中停止执行时,中断与上一个断点之间经过的时间会显示为在编辑器窗口中的提示。

更多信息见:在 Visual Studio 中度量性能 - Visual Studio (Windows) | Microsoft Learn

协同调试

VS提供了多人协作进行调试的功能,使用非常简单。

  1. 登录VS帐号。

  2. 点击如下图中红圈的图标,启用协作会话。

  1. 协作会话启动完成后,会显示如下信息。默认已经将协作邀请链接进行了拷贝。如果协作方只需要查看代码,不需要调试,可以点击Make read-only。
  1. 将协作邀请链接发给协作方并打开,弹出如下窗口。
  1. 打开VS Code,主持人开始调试,协作方也可以通过VS Code查看调试信息。

  2. 通过VS的File->Join Live Share Session来加入协作调试。

  3. 还可以进行协作的管理,以及协作时聊天。

14. 调试技巧

14.1. 小技巧

  • 使用快捷键,另外还可以自定义快捷键。

  • 固定数据提示信息

  • 格式化内存

调试器也能在 Watch 窗口中显示格式化的内存值,高达 64 个字节。你能用下面的说明符在表达式(变量或内存地址)后来格式化数据。

  • mb / m - BYTE

  • mw - WORD

  • md - DWORD

  • mq -- 8BYTE

  • ma -- 16BYTE

14.2. 管理员权限调试

通过Linker->Manifest File->UAC Execution Level->requireAdministrator,启用管理员权限,然后启用调试,则被调试的进程也将获取管理员权限。

14.3. 生成调用栈信息

cpp 复制代码
void printStackTrace() 
{
    HANDLE process = GetCurrentProcess();
    SymInitialize(process, nullptr, TRUE);

    void* stack[100];
    WORD frames = CaptureStackBackTrace(0, 100, stack, nullptr);

    SYMBOL_INFO* symbol = (SYMBOL_INFO*)malloc(sizeof(SYMBOL_INFO) + 256 * sizeof(char));
    symbol->MaxNameLen = 255;
    symbol->SizeOfStruct = sizeof(SYMBOL_INFO);

    for (WORD i = 0; i < frames; ++i) {
        SymFromAddr(process, (DWORD64)(stack[i]), 0, symbol);

        printf("[%d] %s\n", i, symbol->Name);
    }

    free(symbol);
}

14.4. 内存耗尽

Windows下的32位应用程序,只能申请2GB内存。所以在申请过的总内存过大时,可能超过2GB,导致程序异常。可以使用下面代码提示总内存过大的提示。

cpp 复制代码
void NoMoreMemory()
{
    LPVOID lpMsgBuf;
    if (!FormatMessage( 
    FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
    NULL,
    0x00000008, // Memory error	
     MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), // Default language
    (LPTSTR) &lpMsgBuf,
    0,
    NULL ))
    {
    return;
    }
    
    // Display the string.
    MessageBox( NULL, (LPCTSTR)lpMsgBuf, _T("Error"), MB_OK | MB_ICONINFORMATION );
    abort();
}

// Add to this function to the entry of EXE/DLL.
set_new_handler(NoMoreMemory);

14.5. 未初始化异常

14.5.1. Debug

VS在Debug下为了方便用户调试,编译器会强制将未初始化的变量强制赋值指定值做标记。

栈变量强制赋值0xCCCCCCCC,堆内存强制赋值为0xCDCDCDCD。

14.5.2. Release

在VS下,C/C++中的变量编译器不会对变量进行初始化。栈变量和堆内存都是随机的。养成变量初始化的习惯是提升代码质量的好习惯。

14.6. 调试运行时库代码

VS运行时库,有一些提供了代码,有一些没有提供。如CString的GetLength()函数。VS2012及之前的版本默认不会进入库函数,VS2015及之后版本默认在使用Step Into时会进入库函数。如果无法进入库函数,可以进入汇编调试,然后再Step Into就可以进入库函数了。

14.7. Debug和Release的差异

  1. 编译优化:

    • Debug 模式下,编译器不会进行任何优化,以便于调试和错误定位。

    • Release 模式下,编译器会进行各种优化,以提高程序的性能和执行效率。

  2. 调试信息:

    • Debug 模式下,编译器会生成完整的调试信息,包括变量名、行号等,方便进行调试。

    • Release 模式下,编译器会尽量减少调试信息的生成,以减小程序的体积。

  3. 运行时检查:

    • Debug 模式下,编译器会添加额外的运行时检查,如数组越界检查、内存泄漏检查等,以帮助发现程序中的错误。

    • Release 模式下,这些运行时检查通常会被禁用,以提高程序的执行速度。

  4. 断言和异常处理:

    • Debug 模式下,程序会更加严格地检查各种断言和异常,以帮助开发者发现问题。

    • Release 模式下,这些检查通常会被禁用或简化,以提高程序的稳定性和性能。

  5. 编译器定义宏:

    • Debug 模式下,编译器会定义 _DEBUG 宏,用于在代码中进行条件编译。

    • Release 模式下,编译器会定义 NDEBUG 宏,用于在代码中进行条件编译。

  6. 编译器优化标志:

    • Debug 模式下,编译器通常会使用 /Od 标志禁用优化。

    • Release 模式下,编译器通常会使用 /O2 标志启用最高级别的优化。

  7. 链接器优化:

    • Debug 模式下,链接器通常不会进行任何优化。

    • Release 模式下,链接器会进行各种优化,如去除未使用的函数和数据等。

  8. 运行时库:

    • Debug 模式下,程序会链接到 Debug 版本的运行时库,提供更多的错误检查和调试支持。

    • Release 模式下,程序会链接到 Release 版本的运行时库,以提高性能和减小程序体积。

14.8. 构建事件

有时需要在编译前后附加一些操作来简单调试,就可以使用编译事件配置,选择相应的构建事件然后配置命令行来执行需要附加的操作。

14.9. 日志

日志是调试工具的重要手段,除了使用Trace和OutputDebugString之外,还可以使用自定义的日志函数。

14.10. 调试窗口

VS自带的Spy++可以用来查看窗口信息,还可以用来监控窗口消息。

14.11. 查看DLL接口

dumpbin是VS自带的命令行工具,可以用来查看DLL的接口函数。通过Tools->Command Line来使用。

这是一个命令行工具,可以用于查看 DLL 文件的结构和内容。常用命令:

  • dumpbin /exports <DLL_FILE_PATH>: 查看 DLL 的导出函数

  • dumpbin /dependents <DLL_FILE_PATH>: 查看 DLL 的依赖项

  • dumpbin /headers <DLL_FILE_PATH>: 查看 DLL 的头部信息

15. 总结

调试是手段,不是目的,不能为了调试而调试。目的是更高效地开发,为此,减少问题,减少调试才是最重要的。

相关推荐
JSU_曾是此间年少12 分钟前
数据结构——线性表与链表
数据结构·c++·算法
朱一头zcy1 小时前
C语言复习第9章 字符串/字符/内存函数
c语言
此生只爱蛋1 小时前
【手撕排序2】快速排序
c语言·c++·算法·排序算法
何曾参静谧1 小时前
「C/C++」C/C++ 指针篇 之 指针运算
c语言·开发语言·c++
lulu_gh_yu2 小时前
数据结构之排序补充
c语言·开发语言·数据结构·c++·学习·算法·排序算法
ULTRA??3 小时前
C加加中的结构化绑定(解包,折叠展开)
开发语言·c++
凌云行者3 小时前
OpenGL入门005——使用Shader类管理着色器
c++·cmake·opengl
凌云行者3 小时前
OpenGL入门006——着色器在纹理混合中的应用
c++·cmake·opengl
~yY…s<#>4 小时前
【刷题17】最小栈、栈的压入弹出、逆波兰表达式
c语言·数据结构·c++·算法·leetcode
可均可可5 小时前
C++之OpenCV入门到提高004:Mat 对象的使用
c++·opencv·mat·imread·imwrite