从汇编看c++的new和delete

下面是c++源码:

class X {
private:
int _x;
public:
X(int xx = ) : _x(xx) {}
~X() {}
}; int main() {
X* xp = new X;
delete xp; }

代码很简单,在main函数里面先用new构造一个堆对象,然后用delelte释放此对象。

接下来看构造堆对象的汇编码:

  :     X* xp = new X;

    push    ;压入对象的大小4byte,为调用operator new函数传递参数
call ??@YAPAXI@Z ; 调用operator new函数
add esp, ;operator new调用结束,堆栈指针下移4byte,释放为operator new的参数分配的栈空间
mov DWORD PTR $T2579[ebp], eax;寄存器eax里面存放申请到的堆空间首地址,存入临时变量ST2579
cmp DWORD PTR $T2579[ebp], ;将临时变量ST2579的值与0比较,即检测申请的对空间首地址是否为NULL
je SHORT $LN3@main;如果检测结果相等,就跳转到标号$LN3@main处执行,否则顺序执行。这里是顺序执行
push ;将0压栈,为调用构造函数传递参数
mov ecx, DWORD PTR $T2579[ebp];将临时变量ST2579的值(里面保存申请到的堆空间首地址)给寄存器ecx,作为隐含参数传递给构造函数
;这个隐含参数就是this指针
call ??0X@@QAE@H@Z ; 调用对象的构造函数
mov DWORD PTR tv70[ebp], eax;构造函数调用完毕,寄存器eax里面存放的是对象首地址,这里将首地址给临时变量tv70
jmp SHORT $LN4@main;跳转到标号$LN4@main处执行
$LN3@main:
mov DWORD PTR tv70[ebp], ;如果申请对空间失败,将0给临时变量tv70
$LN4@main:
mov eax, DWORD PTR tv70[ebp];将tv70的值给寄存器eax
mov DWORD PTR _xp$[ebp], eax;将寄存器eax的值给指针变量xp

从汇编码可以看到,用new构造堆对象时,大致的流程是:

1 调用operator new申请堆空间

2 对申请到的堆空间首地址进行检查,防止申请失败,返回空指针

3 如果申请堆空间成功,就调用构造函数 并将堆空间首地址给xp指针。

因此,c++中用new申请堆空间与用malloc不同,前者自动检测堆空间是否申请成功。

下面看析构堆对象的汇编码:

 :     delete xp;

    mov    ecx, DWORD PTR _xp$[ebp];将xp指针的值(即堆对象首地址)给寄存器ecx
mov DWORD PTR $T2591[ebp], ecx;将ecx的值给临时变量ST2591
mov edx, DWORD PTR $T2591[ebp];将临时变量ST2591的值给寄存器edx
mov DWORD PTR $T2590[ebp], edx;将寄存器edx里面的值给临时变量ST2590
cmp DWORD PTR $T2590[ebp], ;将临时变量ST2590的值与0比较,即检测传进来的堆对象首地址是否为空指针
je SHORT $LN5@main;如果为空指针,则跳转到标号$LN5@main处执行,否则,顺序执行。这里顺序执行
push ;压入对象类型标志 1 单个对象 3 对象数组 0 只调用析构函数,不调用释放堆空间(在多重继承时有用)
mov ecx, DWORD PTR $T2590[ebp];将临时变量ST2590的值给寄存器ecx,作为隐函数参数(this指针)传递给析构代理函数
call ??_GX@@QAEPAXI@Z;调用析构代理函数
mov DWORD PTR tv75[ebp], eax;析构代理函数执行完毕,寄存器eax里面保存堆对象首地址,将eax的值给临时变量tv75
jmp SHORT $LN1@main;跳转到标号$LN1@main处执行
$LN5@main:
mov DWORD PTR tv75[ebp], ;如果检测堆对象首地址失败,临时变量tv75的值赋0
$LN1@main:;标号后面是main函数结束时的代码

从汇编码可以看到,调用delete并不像operator new一样直接调用析构函数和operator delete函数,而是调用了析构代理函数来完成operator delete的功能。之所以要用代理函数,是因为某些情况下要释放的对象不止一个。

下面是析构代理函数汇编码:

??_GX@@QAEPAXI@Z PROC                    ; X::`scalar deleting destructor', COMDAT
; _this$ = ecx
push ebp
mov ebp, esp
push ecx;寄存器ecx里面存储的是对对象首地址,这里压栈的目的是为了保存这个地址预留空间
mov DWORD PTR _this$[ebp], ecx;将ecx里面的值存到刚才预留的空间里面
mov ecx, DWORD PTR _this$[ebp];将堆对象首地址给寄存器ecx,作为隐含参数传递给析构函数
call ??1X@@QAE@XZ ; 调用析构函数
mov eax, DWORD PTR ___flags$[ebp];这里获取在调用析构代理函数之前,传进来的标志,并将其给寄存器eax
and eax, ;将eax里面(对象类型标志)的值与1相与 目的是判断是否调用delete函数释放堆空间
je SHORT $LN1@scalar;如果相与的结果为0,则跳到标号$LN1@scalar处执行,否则,顺序执行,这里顺序执行
mov ecx, DWORD PTR _this$[ebp];将堆对象首地址作为给寄存器ecx
push ecx;压栈ecx的值,为调用delete函数传递参数
call ??@YAXPAX@Z ; 调用delete函数
add esp, ;delete函数调用完毕,栈顶指针下移4byte,释放为delete函数的参数分配的栈空间
$LN1@scalar:
mov eax, DWORD PTR _this$[ebp];将堆对象首地址给寄存器eax
;做为返回值
mov esp, ebp
pop ebp
ret
??_GX@@QAEPAXI@Z ENDP

从汇编码可以看到,析构代理函数先调用真正的析构函数,然后再调用delete函数释放申请到的堆空间。在这中间有一个判断过程,即通过对象类型标志,判断是否调用operator delete释放堆空间。

通过上面的汇编码,可以看到delete的流程大致是:

1 检测对象首地址值是否为空

2 如果不为空,就调用析构代理函数,否则,就不调用析构代理函数

3 在析构代理函数里面调用对象的析构函数和delete函数(如果对象标志不为0的话)

从对c++中的delete调用过程来看,delete不会自动的将指针变量xp的值清零。因此,后续程序中如果使用xp指针仍然可行。指针变量xp和xp所指向的对象,最大的差别就是哪一个声明已经结束了。调用delete后,xp所指向的对象已将被析构,变得不合法,但是地址本身仍然代表一个合法的程序空间。

对象数组

下面来看new和delete应用于对象数组的情况

c++源码如下:

class X {
private:
int _x;
public:
X(int xx = ) : _x(xx) {}
~X() {}
}; int main() {
X* xp = new X[];
delete [] xp; }

上述代码在用new在堆中构造了2个对象,然后通过delete释放。

下面里看构造过程汇编码:

   X* xp = new X[];
00F035BD push 0Ch ;为调用operator new运算符传递参数,即要申请的堆空间的大小
;这里每个对象只有4byte,但是却要申请12byte,是因为对于申请数组对象
;堆空间首地址4byte用来存储对象个数
00F035BF call operator new ;调用operator new运算符
00F035C4 add esp, ;operator new调用结束,栈顶指针下移4byte,释放为operator new传递参数而分配的栈空间
00F035C7 mov dword ptr [ebp-0F8h],eax;寄存器eax里面保存了申请到的堆空间首地址
00F035CD mov dword ptr [ebp-], ;
00F035D4 cmp dword ptr [ebp-0F8h], ;将返回的堆空间首地址与0比较,检测是否申请成功
00F035DB je main+97h (0F03617h) ;如果申请不成功,即返回NULL,则跳到地址0F03617h处执行 否则,顺序执行 这里顺序执行
00F035DD mov eax,dword ptr [ebp-0F8h] ;将堆空间首地址给寄存器eax
00F035E3 mov dword ptr [eax], ;将2写入堆空间首地址所在内存,即在堆空间首地址4byte处保存对象个数
00F035E9 push offset X::~X (0F011DBh) ;将析构函数地址压栈,作为构造代理函数参数
00F035EE push offset X::`default constructor closure' ;将构造函数地址压栈,作为构造代理函数参数
00F035F3 push 2 ;将对象个数压栈,作为构造代理函数参数
00F035F5 push 4 ;将对象大小压栈,作为构造代理函数参数
00F035F7 mov ecx,dword ptr [ebp-0F8h] ;将堆空间首地址给寄存器ecx
00F035FD add ecx,4 ;ecx里面的值加4 即跳过堆空间首地址4byte,此时ecx里面保存的是第一个堆对象首地址
00F03600 push ecx ;压栈ecx,作为构造代理函数参数
00F03601 call `eh vector constructor iterator' (0F011E5h) ;调用构造代理函数
00F03606 mov edx,dword ptr [ebp-0F8h] ;将堆空间首地址给寄存器edx
00F0360C add edx, ;edx里面的值加4 即跳过堆空间首地址4byte,此时edx里面保存的是第一个堆对象首地址
00F0360F mov dword ptr [ebp-10Ch],edx
00F03615 jmp main+0A1h (0F03621h) ;跳到地址0F03621h处执行
00F03617 mov dword ptr [ebp-10Ch], ;如果堆空间申请失败,赋空指针
00F03621 mov eax,dword ptr [ebp-10Ch]
00F03627 mov dword ptr [ebp-104h],eax
00F0362D mov dword ptr [ebp-],0FFFFFFFFh
00F03634 mov ecx,dword ptr [ebp-104h]
00F0363A mov dword ptr [ebp-14h],ecx ;堆空间中第一个堆对象首地址给了xp

上面的汇编码流程大致是:

1 调用operator new分配堆空间

2 调用构造代理函数构造堆对象,在调用构造代理函数时,通过压栈,像其传递了5个参数,分别是 a)第一个堆对象首地址 b)堆对象大小

c)堆对象个数 d)构造函数地址 e)析构函数地址

3 传回第一个堆对象首地址,而不是申请到的堆空间首地址

从上面汇编码中还可以看到,申请到的堆空间首地址用来存放的是对象的个数,因此,堆空间总大小并不是对象大小 * 对象个数

下面是构造代理函数的汇编码,只看相关部分:

00F03792  mov         dword ptr [ebp-20h],
00F03799 mov dword ptr [ebp-],
00F037A0 mov dword ptr [ebp-1Ch], ;将该内存的值初始化为0 相当于for循环中,循环变量初始化为0
00F037A7 jmp `eh vector constructor iterator'+52h (0F037B2h) ;跳转到地址0F037B2h处执行
00F037A9 mov eax,dword ptr [ebp-1Ch] ;循环变量的值给寄存器eax
00F037AC add eax,1 ;寄存器eax里面的值加1,即循环变量加1
00F037AF mov dword ptr [ebp-1Ch],eax ;将循环变量保存到内存中
00F037B2 mov ecx,dword ptr [ebp-1Ch] ;循环变量值给了寄存器ecx
00F037B5 cmp ecx,dword ptr [ebp+10h];将ecx里面的值和 dword ptr [ebp+10h]内存所代表的值(对象个数2)比较,相当于循环变量的比较
00F037B8 jge `eh vector constructor iterator'+6Bh (0F037CBh) ;如果循环变量的值大于等于2,就跳转到地址0F037CBh执行,否则顺序执行
00F037BA mov ecx,dword ptr [ebp+] ;将第一个堆对象对象首地址给寄存器ecx,作为隐含参数给构造函数
00F037BD call dword ptr [ebp+14h] ;调用构造函数
00F037C0 mov edx,dword ptr [ebp+] ;将第一个堆对象首地址给寄存器edx
00F037C3 add edx,dword ptr [ebp+0Ch] ;edx里面的值加上对象大小,即修改指针,指向下一个堆对象首地址
00F037C6 mov dword ptr [ebp+],edx ;堆对象首地址首地址保存到内存中
00F037C9 jmp `eh vector constructor iterator'+49h (0F037A9h);调转到地址0F037A9h处执行
;======================下面还有代码,但是是完成构造函数之后,因此省略========================

构造代理函数的作用就是循环调用构造函数,依次构造数组中的每一对象

下面是释放数组对象的汇编码:

delete [] xp;
00A2363D mov eax,dword ptr [ebp-14h];将堆空间中第一个对象首地址给寄存器eax
;接下来是对eax的值的传递过程
00A23640 mov dword ptr [ebp-0E0h],eax
00A23646 mov ecx,dword ptr [ebp-0E0h]
00A2364C mov dword ptr [ebp-0ECh],ecx ;最后eax的值(即堆空间第一个对象首地址)给了ebp-0ECh所在内存
00A23652 cmp dword ptr [ebp-0ECh], ;检测该内存里面的值是否为空
00A23659 je main+0F0h (0A23670h) ;如果为空,跳到地址0A23670h处执行,否则,顺序执行 这里是顺序执行
00A2365B push ;压入释放对象类型标志,1为单个对象,3为释放对象数组,0仅表示执行析构函数,不释放堆空间
;压入的值将作为参数传递给析构代理函数
00A2365D mov ecx,dword ptr [ebp-0ECh] ;将堆空间中第一个对象首地址给寄存器ecx,作为隐含参数传递给析构代理函数
00A23663 call X::`vector deleting destructor' (0A211EAh) ;调用vector deleting destructor函数
00A23668 mov dword ptr [ebp-10Ch],eax
00A2366E jmp main+0FAh (0A2367Ah)
00A23670 mov dword ptr [ebp-10Ch],0
13:
14: }

vector deleting destructor函数的汇编码:

X::`vector deleting destructor':
00A21B10 push ebp
00A21B11 mov ebp,esp
00A21B13 sub esp,0CCh
00A21B19 push ebx
00A21B1A push esi
00A21B1B push edi
00A21B1C push ecx ;在调用该函数之前,ecx寄存器保存的是堆空间中,第一个对象的首地址
;这里将首地址压入栈中保存,因为下面的代码中将用到寄存器ecx
00A21B1D lea edi,[ebp-0CCh]
00A21B23 mov ecx,33h
00A21B28 mov eax,0CCCCCCCCh
00A21B2D rep stos dword ptr es:[edi]
;==========================================以上代码为函数入口部分====================
00A21B2F pop ecx ;将栈顶里面的值(保存着堆空间中第一个对象首地址)弹出,存到寄存器ecx,
00A21B30 mov dword ptr [ebp-8],ecx ;将对象首地址存放到ebp-8所代表的内存
00A21B33 mov eax,dword ptr [ebp+8] ;对象类型标标志被保存在了寄存器eax中
00A21B36 and eax,2 ;将eax里面的值和2相与,检测是否为对象数组标志(因为标志只能为0 1 3,如果不为3 结果肯定为0)
00A21B39 je X::`vector deleting destructor'+61h (0A21B71h);如果结果为0,即不是对象数组标志
;跳转到地址0A21B71h处执行 否则 顺序执行 这里顺序执行
00A21B3B push offset X::~X (0A211DBh);将析构函数的地址压栈,作为参数传递给析构代理函数
00A21B40 mov eax,dword ptr [this] ;this指针指向堆空间中第一个对象首地址,这里将该值给寄存器eax
00A21B43 mov ecx,dword ptr [eax-] ;将向上偏移堆空间中第一个对象首地址4byte处内存内容(即申请到的堆空间首地址处
;内存内容,该内存里面保存着对象个数)给寄存器ecx
00A21B46 push ecx ;将ecx压栈,作为参数传递给析构代理函数
00A21B47 push ;将对象大小压栈,作为参数传递给析构代理函数
00A21B49 mov edx,dword ptr [this] ;将堆空间中第一个对象首地址给寄存器edx
00A21B4C push edx ;将edx的值压栈,作为参数传递给析构代理函数
00A21B4D call `eh vector destructor iterator' (0A211F4h) ;调用析构代理函数
00A21B52 mov eax,dword ptr [ebp+8] ;获取对象类型标志,其值在调用该函数之前被压入栈中
00A21B55 and eax,1;将寄存器eax的值和1相与,目的是判断是否要调用delete函数释放堆空间
00A21B58 je X::`vector deleting destructor'+59h (0A21B69h) ;如果相与结果为0,就跳转到地址0A21B69h处执行,否则
;顺序执行,这里顺序执行
00A21B5A mov eax,dword ptr [this] ;将堆空间中第一个对象首地址给寄存器eax
00A21B5D sub eax, ;将eax里面的值减4,即修正了eax里面的值,此时,eax里面保存的是申请到的堆空间首地址
00A21B60 push eax ;将eax的值压栈,作为隐含参数传递给operator delete
00A21B61 call operator delete (0A21087h) ;调用operator delete,释放堆空间
00A21B66 add esp, ;调用operator delete结束,释放为其传参时的栈空间
00A21B69 mov eax,dword ptr [this];将堆空间中第一个对象首地址给寄存器eax
00A21B6C sub eax, ;将eax的值减4,即修正eax里面的值,此时,eax里面存储的是申请到的堆空间首地址
00A21B6F jmp X::`vector deleting destructor'+80h (0A21B90h) ;跳转到地址0A21B90h处执行
;=====================下面代码是传进来的对象标志不是3时执行的代码=====================
00A21B71 mov ecx,dword ptr [this] ;如果对象标志不是3,将跳转到这里执行。将堆空间中对象首地址给寄存器ecx,作为隐含参数调用析构函数
00A21B74 call X::~X (0A211DBh) ;调用析构函数
00A21B79 mov eax,dword ptr [ebp+8] ;将对象类型标志给寄存器eax
00A21B7C and eax,1 ;和上面一样,判断是否释放堆空间
00A21B7F je X::`vector deleting destructor'+7Dh (0A21B8Dh) ;如果对象标志为0,就跳转到地址0A21B8Dh处执行
;否则,顺序执行
00A21B81 mov eax,dword ptr [this] ;将堆对象首地址给寄存器eax
00A21B84 push eax ;压入eax,作为operator delete的参数
00A21B85 call operator delete (0A21087h) ;调用operator delete
00A21B8A add esp, ;operator delete调用结束,释放为其传递参数分配的栈空间
00A21B8D mov eax,dword ptr [this] ;将堆对象首地址给寄存器eax,作为返回值
00A21B90 pop edi
00A21B91 pop esi
00A21B92 pop ebx
00A21B93 add esp,0CCh
00A21B99 cmp ebp,esp
00A21B9B call @ILT+(__RTC_CheckEsp) (0A21136h)
00A21BA0 mov esp,ebp
00A21BA2 pop ebp
00A21BA3 ret

vector deleting destructor总体流程也是先调用析构代理函数,然后调用operator delete释放空间(如果对象标志不为0的话),并且在调用析构代理函数时也传进4个参数 a)堆空间中第一个对象首地址 b)对象大小 c)对象个数 d)虚函数地址

下面是析构代理函数的汇编码:

mov         ecx,dword ptr [ebp+0Ch]  ;获取堆对象个数,给寄存器ecx
00A236E0 imul ecx,dword ptr [ebp+10h] ;ebp+10h所代表的内存里面存放对象大小,这条指令将ecx的值和ebp+10h
;的值相乘,将结果保存在ecx里面
;ecx里面此时保存的是所有堆对象所占大小
00A236E4 add ecx,dword ptr [ebp+] ;ebp+8所代表的的内存存放堆空间中第一个对象首地址,这里将它与ecx相加
;结果保存在ecx里面,此时ecx存放的是堆空间中最后一个对象后面的内存地址
;这么做是为了从最后一个对象开始析构
00A236E7 mov dword ptr [ebp+],ecx ;将ecx的值保存到ebp+8所代表的内存
00A236EA mov dword ptr [ebp-],
00A236F1 mov edx,dword ptr [ebp+10h] ;将对象个数给寄存器edx,相当于for循环里面的循环技术变量
00A236F4 sub edx, ;edx里面的值减1
00A236F7 mov dword ptr [ebp+10h],edx ;将edx里面的值存放到ebp+10h所代表的的内存
00A236FA js `eh vector destructor iterator'+6Dh (0A2370Dh) ;如果edx-1时为父,跳转到地址0A2370Dh处执行
00A236FC mov eax,dword ptr [ebp+8] ;将ebp+8内存的值(即最后一个堆对象后面的内存地址)给寄存器eax
00A236FF sub eax,dword ptr [ebp+0Ch] ;ebp+0ch所代表的内存存放对象大小,这里用eax-对象大小
;这里依次得到从最后一个堆对象到第一个堆对象首地址,
;存放到寄存器eax
00A23702 mov dword ptr [ebp+8],eax;将eax的值保存到ebp+8所代表的内存
00A23705 mov ecx,dword ptr [ebp+8];将堆对象首地址给ecx寄存器,作为隐含参数传递给析构函数
00A23708 call dword ptr [ebp+14h] ;ebp+14h所代表的内存里面存放的是析构函数地址,这里调用析构函数
00A2370B jmp `eh vector destructor iterator'+51h (0A236F1h);跳转到地址0A236F1h执行
;后面是析构完对象之后的结束代码

可以看到,析构代理函数也是循环调用析构函数,以与构造对象相反的顺序析构对象。

比较对单个堆对象调用delete和对堆对象数组调用delete[]可以发现,两种情况最主要的差别是在用最后用delete释放堆空间时,delete[]会对目标指针(即对delete来说,目标指针为堆对象首地址,对delete[]来说,是堆中第一个堆对象首地址)进行减4调整.因此,如果是释放单个对象堆空间,错误的使用delete[],那么,执行中间检测时,会判断对象类型标记为3,进行目标指针调整,结果会释放错误的堆空间。同理,如果释放对象数组,而错误的使用delet,那么,执行中间检测时,会判断对象类型标记为1,不进行目标指针调整,堆空间的释放也会发生错误。

基本数据类型

下面是c++源码:

class X {
private:
int _x;
public:
X(int xx = ) : _x(xx) {}
virtual ~X() {}
}; class Y {
private:
int _y;
public:
Y(int yy = ) : _y(yy) {}
virtual ~Y() {}
}; class Z : public X, public Y {
private:
int _z;
public:
Z(int zz = ) : _z(zz){}
virtual ~Z() {}
}; int main() {
int* ip1 = new int[];
delete [] ip1; int* ip2 = new int;
delete ip2; }

c++代码中ip1指向的是堆中基本类型int数组的首地址,ip1指向堆中单个基本类型int的首地址。

下面是mian函数里面的汇编码:

 :     int* ip1 = new int[];
00141A3E push ;将申请的堆空间大小压栈,作为参数传递给operator new
00141A40 call operator new (141177h) ;调用operator new
00141A45 add esp, ;栈顶指针减4 释放为调用operator new传参时分配的栈空间
00141A48 mov dword ptr [ebp-104h],eax ;寄存器eax里面存有申请到的堆空间首地址,下面
;的代码都是一些传值操作
00141A4E mov eax,dword ptr [ebp-104h]
00141A54 mov dword ptr [ip1],eax ;eax的值传给了指针变量ip1
: delete [] ip1;
00141A57 mov eax,dword ptr [ip1] ;将ip1的值(指向堆空间首地址)给寄存器eax
00141A5A mov dword ptr [ebp-0F8h],eax ;下面是一些传值操作
00141A60 mov ecx,dword ptr [ebp-0F8h]
00141A66 push ecx ;ecx里面保存了堆空间首地址,这里将ecx压栈,为调用delete传参
00141A67 call operator delete (141082h) ;调用delete
00141A6C add esp, ;栈顶指针减4,释放为调用delete传参时分配的栈空间
:
: int* ip2 = new int;
00141A6F push ;将申请的堆空间大小压栈,作为餐宿传递给operator new
00141A71 call operator new (141177h);调用operator new
00141A76 add esp, ;栈顶指针减4 释放为调用operator new传参时分配的栈空间
00141A79 mov dword ptr [ebp-0ECh],eax ;寄存器eax里面存有申请到的堆空间首地址,下面
;的代码都是一些传值操作
00141A7F mov eax,dword ptr [ebp-0ECh]
00141A85 mov dword ptr [ip2],eax ;eax的值给指针变量ip2
: delete ip2;
00141A88 mov eax,dword ptr [ip2] ;将ip2的值(指向堆空间首地址)给寄存器eax
00141A8B mov dword ptr [ebp-0E0h],eax ;下面是一些传值操作
00141A91 mov ecx,dword ptr [ebp-0E0h]
00141A97 push ecx ;ecx里面保存了堆空间首地址,这里将ecx压栈,为调用delete传参
00141A98 call operator delete (141082h);调用delete
00141A9D add esp, ;栈顶指针减4,释放为调用delete传参时分配的栈空间

从汇编码可以看到,由于基本数据类型没有构造函数和析构函数,因此,这两种情况都只是简单的调用new分配空间,调用delete释放空间。并且还可以看到,和堆中对象数组不同,堆中基本类型数组没有在申请到的堆空间首地址处存放对象个数,ip1,ip2都直接指向的是各自申请到的堆空间首地址,正因为如此,对于基本类型,delete和delete[]效果一样。

上一篇:编写高质量的Python代码系列(三)之类与继承


下一篇:【转】color颜色十六进制编码大全