美文网首页
02-汇编基础(2)

02-汇编基础(2)

作者: 深圳_你要的昵称 | 来源:发表于2021-04-02 17:04 被阅读0次

    前言

    本篇文章主要以汇编的角度,分析函数的本质,在分析函数的过程中,就会解决上篇文章最后的死循环问题。

    一、基础知识点

    接着上篇文章01-汇编基础(1)的内容,我们再介绍几个常见的基础知识点。

    1.1 栈

    在讲函数之前,先来看看,因为函数的实现代码的作用域就对应在内存的中。
    栈是一种具有特殊的访问方式的存储空间(后进先出, Last In Out Firt,LIFO)。我们都知道,栈的操作无非就是👇

    • 入栈 👉 push
    • 出栈 👉 pop

    1.1.1 栈的结构

    栈的结构就好比只有一个口的管子,如下图👇

    • 刚开始有2个指针 👉 栈顶和栈底,同时指向空栈的底部
    • 开辟空间
      • 以前是:变量push(入栈),内存就开辟空间
      • 现在是:先开辟空间,再push(入栈)变量

    那么问题来了 👉 系统怎么知道开辟多大的空间呢?

    编译器决定的,因为你的代码被编译后,编译器就知道要申请多少空间大小的栈了。

    1.1.2 SP FP 寄存器

    根据SP 和 FP 寄存器可以查看栈的空间大小,因为👇

    • sp寄存器在任意时刻会保存我们栈顶的地址
    • fp寄存器也称为x29寄存器,属于通用寄存器,但是在某些时刻我们利用它保存栈底的地址!
      • 没有出现函数嵌套调用的时候不需要fp,相当于分界点

    ⚠️ ARM64开始,取消32位的 LDM,STM,PUSH,POP指令! 取而代之的是ldr\ldp str\stp。
    ARM64里面 对栈的操作是16字节对齐的!!

    ARM64中,是先开辟一段栈空间,fp移动到栈顶再往栈中存放内容,原因上面说过,编译期就已经确定大小,所以不存在push操作,同时, iOS中内存是高地址向低地址的方向开辟栈空间的。

    1.2 函数调用栈

    我们都知道,函数的实现是在栈中进行的,函数执行完毕后栈的空间自动释放,那么在汇编中,常见的函数调用栈的开辟和恢复的代码👇

    //开辟栈空间
    sub    sp, sp, #0x40             ; 拉伸0x40(64字节)空间
    stp    x29, x30, [sp, #0x30]     ;x29\x30 寄存器入栈保护
    add    x29, sp, #0x30            ; x29指向栈帧的底部
    ... 
    //恢复栈空间
    ldp    x29, x30, [sp, #0x30]     ;恢复x29/x30 寄存器的值
    add    sp, sp, #0x40             ;栈平衡
    ret
    

    上面的汇编代码的执行过程,如下图所示👇

    1. 通过sub减指令来开辟空间,此时sp指向低地址位置,x29也是fp指向栈底,即高地址位置
    2. 函数ret即调用完毕返回之前,需要通过add加指令,恢复sp寄存器地址指向,这就是所谓的栈平衡
    3. 恢复后数据并不销毁,下次再拉伸栈空间后,会先覆盖再读取。如果先读取,读取的就是垃圾数据

    二、内存读写指令

    注意⚠️ 读/写数据是都是往高地址进行操作。

    读/写的指令主要有2个👇

    1. str(store register)指令 👉 将数据从寄存器中读出来,存到内存中.
    2. ldr(load register)指令 👉 将数据从内存中读出来,存到寄存器中

    str ldr 是内存寄存器交互的专门的指令。

    还有2个也很常用的指令stp和 ldp,意思是可以同时操作2个寄存器的读和写。

    练习

    写一个函数,功能很简单,就是x0和x1 交换数据,目的是熟悉上面的stp ldp指令意思。代码如下👇

    .text
    .global _C
    
    _C:
        sub  sp, sp, #0x20        ;拉伸栈空间32个字节
        stp  x0, x1, [sp, #0x10]  
        ldp  x1, x0, [sp, #0x10]  
        add  sp, sp, #0x20        ;恢复栈空间
        ret
    

    上面代码,第一行和倒数第二行,常规操作,对栈空间的拉伸与恢复,重点是中间2句代码(汇编代码从右往左看)👇

    • stp x0, x1, [sp, #0x10]
      • [sp, #0x10] 👉 []的意思是寻址,sp的地址再加0x10,但是注意⚠️sp本身的地址指向是不变的
      • stp x0, x1 👉 这个上面讲过,操作2个寄存器x0和x1,将其中的值存储到内存中
    • ldp x1, x0, [sp, #0x10] 👉 看完了上面的stp操作,再来看ldp,就很简单了,将sp的地址+0x10这个地址的值从内存中取出来,依次存储到寄存器x1和x0

    至此,上面的代码就完成了x0和x1寄存器中值的交换,以前我们知道将a和b值交换时,需要用到第三个temp变量,那么这里内存就充当了temp的角色,如下图所示👇

    示例调试

    但是,注意⚠️内存中的值是没有变化的,sp寄存器的指向地址也没变,变化的只是 x0 和 x1寄存器中的值,不信?接下来我们可以调试看看。

    上图在0x104631c8c断点处,对x0和x1分别赋值0xa和0xb,再读取sp的地址值,然后接着单步往下执行下面👇

    上图发现,x0和x1已交换完毕,但是再次读取sp地址时,是没有变化的,依旧是0x000000016b7d1190。再继续单步执行👇

    sp还原了,栈空间释放,这时候0xa,0xb还依然存在内存中,并没有释放,会有问题吗?其实仔细想想,我们每次sub拉伸栈空间后,都是通过str或stp对内存空间的值进行写数据覆盖的,所以不会有问题。我们可以通过view memory查看内存👇

    上图中,输入0x000000016b7d1190地址查看,果然发现a和b均在内存中没有释放。

    2.1 bl和ret指令

    接下来我们看看bl和ret指令。

    bl标号

    • 将下一条指令的地址放入lr(x30)寄存器
    • 转到标号处执行指令

    b就是跳转,l就是将下一条指令的地址放入lr(x30)寄存器。还是看上面的例子,查看跳转C函数后,lr寄存器的地址,如下图👇

    lr相当于保存的回家的路

    ret

    默认使用lr(x30)寄存器的值,通过底层指令提示CPU此处作为下条指令地址!

    ret只会看lr
    注意⚠️ ARM64平台的特色指令,它面向硬件做了优化处理。

    2.2 x30寄存器

    x30寄存器,就是我们上面说的lr寄存器,存放的是函数的返回地址。当ret指令执行时就会去寻找x30寄存器保存的地址值

    案例演示

    我们还是用一个案例演示给大家看看,很简单,C函数中bl跳转到D函数(C函数调用D函数)👇

    .text
    .global _C, _D
    
    _C:
        mov x0,#0xaaaa
        bl _D
        mov x0,#0xaaaa
        ret
    
    _D:
        mov x0,#0xbbbb
        ret
    

    调用的代码👇

    int C();
    int D();
    - (void)viewDidLoad {
        [super viewDidLoad];
        printf("C");
        C();
        printf("D");
    }
    

    C();这行打上断点,run,查看汇编👇

    当前lr指向的是bl c()这条指令的地址,接着step into进入到C()中👇

    然后跳转到D()中👇

    此时lr的地址又发生了变化,变成了0x00000001021e5c78,接着往下执行,回到C()中👇

    lr的地址和在D()中的一样,没变化,继续执行下去,你会发现,断点执行一直在0x1021e5c780x1021e5c7c这两句中跳转,返不回去viewDidLoad中了,发生了死循环。

    这个就是我们上一篇01-汇编基础(1)中最后碰到的死循环问题,现在我们来分析一下:

    既然我们知道了bl指令的作用,就是保存回去的地址(回家的路),那我们得想办法保存回到viewDidLoad的地址,而且必须在bl之前进行保存,因为上图中的现象可见 👉 遇到bl,lr就会改变

    现在我们知道了何时保存,即bl之前,但是保存在哪里呢?

    如果保存到其它寄存器,是没法保证系统是否会覆盖其它寄存器的地址值的,那么接得想办法保存在自己的一个私有的区域,这个区域是哪里呢?很显然,就是函数本身的栈区

    至此,我们知道了,在bl之前将lr的地址保存在函数自己的栈区内

    接下来,就是如何写汇编实现这个保存操作了。既然不会写,那不如我们不写汇编,写OC,然后看汇编底层是如何处理的。

    void a() {
        b();
        return;;
    }
    
    void b() {
        
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        a();
    }
    

    step into进入查看a()的汇编👇

    看来,重点就是第一条和ret前一条的指令了,我们先来看第一条指令的含义,老规矩,从右往左看👇

    • stp x29, x30, [sp, #-0x10]!
      • [sp, #-0x10]! 👉 因为是#-0x10负数,就是拉伸16个字节的空间,注意这个感叹号!,意思就是将这个值赋给sp,整体就相当于sp -= 0x10
      • stp x29, x30 👉 很简单,sp的地址拉伸后,依次存入x29和x30寄存器,那么x29地址就是sp,x30地址就是sp - 0x08

    分析完第一句指令后,再来看ldp指令,就没那么难了👇

    • ldp x29, x30, [sp], #0x10
      • [sp], #0x10 👉 不难猜到,就是恢复sp指针指向,整体就相当于sp += 0x10,恢复栈空间
      • ldp x29, x30 👉 就是将栈区的值给x29,x30

    系统的整明白了,再回到自己定义的C()和D()中,照着写就行了👇

    .text
    .global _C, _D
    
    _C:
        str x30, [sp,#-0x10]!
        mov x0,#0xaaaa
        bl _D
        mov x0,#0xaaaa
        ldr x30,[sp],#0x10
        ret
    
    _D:
        mov x0,#0xbbbb
        ret
    

    run,调试看看👇

    step into进入C函数👇

    接着step into进入D函数👇

    接着单步往下执行👇

    原来lr中保存的0x0000000100c7dc50,就是保存回到C函数的地址。我们再看看sp寄存器地址,是0x000000016f1851a0,通过view memory看看里面的值👇

    我们知道,sp寄存器是指向栈顶的地址,再回过头来看看ViewDidLoad中bl跳转C()函数的汇编👇

    上图中0x0100c7dcd0,不就是bl跳转C()函数的下一条指令的地址吗,这就验证了我们之前分析的,ViewDidLoad的lr寄存器的值被保存到了它自己的栈里面。

    然后继续往下执行ldr x30,[sp],#0x10,x30的值就取到了0x0100c7dcd0,就能跳转回ViewDidLoad了,这个时候死循环就已经解决了。

    综上所述
    ⚠️ 在函数嵌套调用的时候,需要将x30入栈

    如果拉伸的是8字节

    如果只拉伸8字节的空间,结果会怎样?👇

    _C:
        str x30, [sp,#-0x8]!
        mov x0,#0xaaaa
        bl _D
        mov x0,#0xaaaa
        ldr x30,[sp],#0x8
        ret
    

    这里 str x30, [sp,#-0x8]!只拉伸8字节,run👇

    错误是报在ldr x30,[sp],#0x8这行,说明,拉伸空间没问题,但是要恢复内存,返回ViewDidLoad时报错了,即从内存读数据,存到x30寄存器的时候报错了

    所以,栈中一定要保持16字节对齐的原则!

    三、函数的参数和返回值

    接下来,我们看看汇编是怎么处理带有参数和返回值的函数。例如👇

    int sum(int a, int b) {
        return a + b;
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        sum(10,20);
    }
    

    sum(10,20);这行打上断点,查看汇编👇

    上图红框处,对w0,w1赋值的不就是10跟20吗,接着step into查看sum函数的汇编👇

    最终,返回ViewDidLoad之前,结果是保存在寄存器w0中。于是,我们自己实现一个sum函数的汇编,可以这么写👇

    .text
    .global _sum
    
    _sum:
        add x0,x0,x1
        ret
    

    x0 = x0 + x1,因为参数就是保存在 x0和x1之中。

    调用的👇

    int sum(int a, int b);
    - (void)viewDidLoad {
        [super viewDidLoad];
        printf("%d",sum(10,20));
    }
    

    运行看看👇

    1. ARM64下,函数的参数是存放在X0到X7(W0到W7)这8个寄存器里面的。
    2. 如果超过8个参数,就会入栈
    3. 函数的返回值是放在X0寄存器里面的。
    参数超过8个的情况
    int test(int a, int b, int c ,int d, int e, int f, int g, int h, int i) {
        return a + b + c + d + e + f + g + h + i;
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        test(1, 2, 3, 4, 5, 6, 7, 8, 9);
    }
    

    参数分布与sp指向如下图所示👇

    接着我们step into去到test函数中👇

    整个累加的过程如下图所示👇

    最终函数返回值放入w0中。

    如果在release模式下test不会被调用(被优化掉,因为没有意义,且对app没有影响。)

    返回值

    • 函数的返回值一般是一个指针,不会超过8字节。所以,x0寄存器就完全够用了。
    • 如果要返回一个结构体类型超过8字节

    请看下面的实例👇

    // str结构体占用24字节大小
    struct str {
        int a;
        int b;
        int c;
        int d;
        int e;
        int f;
    };
    
    struct str getStr(int a, int b, int c, int d, int e, int f) {
        struct str str1;
        str1.a = a;
        str1.b = b;
        str1.c = c;
        str1.d = d;
        str1.e = e;
        str1.f = f;
        return str1;
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        struct str str2 = getStr(1,2,3,4,5,6);
    }
    

    打上断点,查看汇编👇

    接着step into进入到getStr函数中👇

    getStr整个汇编赋值的过程如下图所示👇

    最终会发现,这里并没有以 x0 作为返回值,而是将返回值写入上一个函数(ViewDidLoad函数)的栈x8寄存器中。

    综上,如果返回值大于8字节,返回值会保存在上一个函数栈空间

    结构体成员超过8个

    如果结构体成员超过8个呢,是个什么情况?

    struct str {
        int a;
        int b;
        int c;
        int d;
        int e;
        int f;
        int g;
        int h;
        int i;
        int j;
    };
    
    struct str getStr(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j) {
        struct str str1;
        str1.a = a;
        str1.b = b;
        str1.c = c;
        str1.d = d;
        str1.e = e;
        str1.f = f;
        str1.g = g;
        str1.h = h;
        str1.i = i;
        str1.j = j;
        return str1;
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        struct str str2 = getStr(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
        printf("%d",func(10,20));
    }
    

    ViewDidLoad汇编👇

    ASMPrj`-[ViewController viewDidLoad]:
        // 拉伸栈空间6*16=96字节大小
        0x100f31c80 <+0>:   sub    sp, sp, #0x60             ; =0x60 
        // 将x29 和x30的值存到栈中,地址是sp+0x50 👉 `保存回家的路`
        0x100f31c84 <+4>:   stp    x29, x30, [sp, #0x50]
        // x29指向sp+0x50这个地址
        0x100f31c88 <+8>:   add    x29, sp, #0x50            ; =0x50 
        // 参数x0和x1 入栈
        0x100f31c8c <+12>:  stur   x0, [x29, #-0x8]
        0x100f31c90 <+16>:  stur   x1, [x29, #-0x10]
        // x8存入参数x0的值
        0x100f31c94 <+20>:  ldur   x8, [x29, #-0x8]
        // x9指向 x29 - 0x20
        0x100f31c98 <+24>:  sub    x9, x29, #0x20            ; =0x20 
        // x8 存入 x29 - 0x20
        0x100f31c9c <+28>:  stur   x8, [x29, #-0x20]
        // adrp 👉 address page 内存中取数据
        // ADRP指令
        // * 编译时,首先会计算出当前PC到exper的偏移量#offset_to_exper 
        // * pc的低12位清零,然后加上偏移量,给register
        // * 得到的地址,是含有label的4KB对齐内存区域的base地址;
        0x100f31ca0 <+32>:  adrp   x8, 4 // 此处的偏移量是4
    
        // x8 所指的内存取出来
        0x100f31ca4 <+36>:  add    x8, x8, #0x4e0            ; =0x4e0 
        0x100f31ca8 <+40>:  ldr    x8, [x8]
        0x100f31cac <+44>:  str    x8, [x9, #0x8]
        0x100f31cb0 <+48>:  adrp   x8, 4
        0x100f31cb4 <+52>:  add    x8, x8, #0x458            ; =0x458 
        0x100f31cb8 <+56>:  ldr    x1, [x8]
        0x100f31cbc <+60>:  mov    x0, x9
        0x100f31cc0 <+64>:  bl     0x100f32524               ; symbol stub for: objc_msgSendSuper2
    
        // x8指向 sp + 0x8
        0x100f31cc4 <+68>:  add    x8, sp, #0x8              ; =0x8 
        0x100f31cc8 <+72>:  mov    w0, #0x1
        0x100f31ccc <+76>:  mov    w1, #0x2
        0x100f31cd0 <+80>:  mov    w2, #0x3
        0x100f31cd4 <+84>:  mov    w3, #0x4
        0x100f31cd8 <+88>:  mov    w4, #0x5
        0x100f31cdc <+92>:  mov    w5, #0x6
        0x100f31ce0 <+96>:  mov    w6, #0x7
        0x100f31ce4 <+100>: mov    w7, #0x8
        // sp的值给x9
        0x100f31ce8 <+104>: mov    x9, sp
        // w10中存储 9
        0x100f31cec <+108>: mov    w10, #0x9
        // w10中保存x9的地址
        0x100f31cf0 <+112>: str    w10, [x9]
        // w10中存储 10
        0x100f31cf4 <+116>: mov    w10, #0xa
        // x9偏移4字节,再存入w10
        0x100f31cf8 <+120>: str    w10, [x9, #0x4]
        // 跳转getStr函数
        0x100f31cfc <+124>: bl     0x100f31bf4               ; getStr at ViewController.m:30
        0x100f31d00 <+128>: ldp    x29, x30, [sp, #0x50]
        0x102499d04 <+132>: add    sp, sp, #0x60             ; =0x60 
        0x102499d08 <+136>: ret  
    

    接着看getStr汇编👇

    ASMPrj`getStr:
    ->  0x1004ddbf4 <+0>:   sub    sp, sp, #0x30             ; =0x30 
        // 从上一个栈空间 获取9 和 10
        0x1004ddbf8 <+4>:   ldr    w9, [sp, #0x30]
        0x1004ddbfc <+8>:   ldr    w10, [sp, #0x34]
        // 参数入栈
        0x1004ddc00 <+12>:  str    w0, [sp, #0x2c]
        0x1004ddc04 <+16>:  str    w1, [sp, #0x28]
        0x1004ddc08 <+20>:  str    w2, [sp, #0x24]
        0x1004ddc0c <+24>:  str    w3, [sp, #0x20]
        0x1004ddc10 <+28>:  str    w4, [sp, #0x1c]
        0x1004ddc14 <+32>:  str    w5, [sp, #0x18]
        0x1004ddc18 <+36>:  str    w6, [sp, #0x14]
        0x1004ddc1c <+40>:  str    w7, [sp, #0x10]
        0x1004ddc20 <+44>:  str    w9, [sp, #0xc]
        0x1004ddc24 <+48>:  str    w10, [sp, #0x8]
        // 获取参数分别存入上一个栈x8所指向的地址中
        0x1004ddc28 <+52>:  ldr    w9, [sp, #0x2c]
        0x1004ddc2c <+56>:  str    w9, [x8]
        0x1004ddc30 <+60>:  ldr    w9, [sp, #0x28]
        0x1004ddc34 <+64>:  str    w9, [x8, #0x4]
        0x1004ddc38 <+68>:  ldr    w9, [sp, #0x24]
        0x1004ddc3c <+72>:  str    w9, [x8, #0x8]
        0x1004ddc40 <+76>:  ldr    w9, [sp, #0x20]
        0x1004ddc44 <+80>:  str    w9, [x8, #0xc]
        0x1004ddc48 <+84>:  ldr    w9, [sp, #0x1c]
        0x1004ddc4c <+88>:  str    w9, [x8, #0x10]
        0x1004ddc50 <+92>:  ldr    w9, [sp, #0x18]
        0x1004ddc54 <+96>:  str    w9, [x8, #0x14]
        0x1004ddc58 <+100>: ldr    w9, [sp, #0x14]
        0x1004ddc5c <+104>: str    w9, [x8, #0x18]
        0x1004ddc60 <+108>: ldr    w9, [sp, #0x10]
        0x1004ddc64 <+112>: str    w9, [x8, #0x1c]
        0x1004ddc68 <+116>: ldr    w9, [sp, #0xc]
        0x1004ddc6c <+120>: str    w9, [x8, #0x20]
        0x1004ddc70 <+124>: ldr    w9, [sp, #0x8]
        0x1004ddc74 <+128>: str    w9, [x8, #0x24]
        // 栈平衡
        0x1004ddc78 <+132>: add    sp, sp, #0x30             ; =0x30 
        0x1004ddc7c <+136>: ret   
    

    整个执行的过程如下图所示👇

    上图所示,参数和返回值都存在上一个函数(ViewDidLoad)的栈中,并且返回值的地址在高位,参数在低位。

    四、函数的局部变量

    最后,我们来看看函数的局部变量,先看下面示例👇

    int func(int a, int b) {
        int c = 6;
        return  a + b + c;
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        func(10, 20);
    }
    

    首先看看func的汇编👇

    上图可知 👉 函数的局部变量放在函数自己的栈里面!

    嵌套调用

    如果是嵌套调用的场景呢?会是怎样的情况,例如👇

    int func1(int a, int b) {
        int c = 6;
        int d = func2(a, b, c);
        int e = func2(a, b, c);
        return  d + e;
    }
    
    int func2(int a, int b, int c) {
        int d = a + b + c;
        printf("%d",d);
        return d;
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        func1(10, 20);
    }
    

    汇编代码👇

    上图可见,参数和返回值依然被保存到栈中。

    现场保护包含:FP,LR,参数,返回值。

    总结

      • 是一种具有特殊的访问方式的存储空间(后进先出,LIFO)
      • SP和FP寄存器
        • sp寄存器在任意时刻保存栈顶的地址
        • fp(x29)寄存器属于通用寄存器,在某些时刻利用它保存栈底的地址(嵌套调用)
      • ARM64里面栈的操作16字节对齐
      • 栈读写指令
        • 读:ldr(load register)指令LDR、LDP
        • 写:str(store register)指令STR、STP
      • 汇编指令:
        • sub sp, sp,#0x10 ;拉伸栈空间16个字节
        • stp x0,x1,[sp];往sp所在位置存放x0和x1
        • ldp x0,x1,[sp];读取sp存入x0和x1
        • add sp,#0x10;恢复栈空间
      • 简写:
        • stp x0, x1,[sp,#-0x10]!;前提条件是正好开辟的空间放满栈。先开辟空间,存入值,再改变sp的值。
        • ldp x0,x1,[sp],#0x10
    • bl指令
      • 跳转指令:bl标号,转到标号处执行指令并将下一条指令的地址保存到lr寄存器
      • B代表跳转
      • L代表lr(x30)寄存器
    • ret指令
      • 类似函数中的return
      • 让CPU执行lr寄存器所指向的指令
      • 有跳转需要“保护现场”
    • 函数
      • 函数调用栈
        • ARM64中栈是递减栈,向低地址延伸的栈
        • SP寄存器指向栈顶的位置
        • X29(fp)寄存器指向栈底的位置
      • 函数的参数
        • ARM64中,默认情况下参数是放在X0~X7的8个寄存器中
        • 如果是浮点数,会用浮点寄存器
        • 如果超过8个参数会用栈传递(多过8个的参数在函数调用结束后参数不会释放,相当于局部变量,属于调用方,只有调用方函数执行结束栈平衡后才释放。)
      • 函数的返回值
        • 一般情况下函数的返回值使用X0寄存器保存
        • 如果返回值大于了8个字节(放不下),就会利用内存。写入上一个调用栈内部,用X8寄存器作为参照。
      • 函数的局部变量
        • 使用栈保存局部变量
        • 函数的嵌套调用
          • 会将X29(fp),X30(lr)寄存器入栈保护。
          • 同时现场保护的还有:FP,LR,参数,返回值。

    相关文章

      网友评论

          本文标题:02-汇编基础(2)

          本文链接:https://www.haomeiwen.com/subject/rtzghltx.html