美文网首页
03-汇编基础(3)

03-汇编基础(3)

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

    前言

    本篇文章主要讲解👇

    1. 状态寄存器
    2. 判断、选择和循环

    一、状态寄存器(CPSR)

    什么是状态寄存器?👇

    CPU内部的寄存器中,有一种特殊的寄存器(对于不同的处理器,个数和结构都可能不同)。这种寄存器在ARM中,被称为状态寄存器CPSR(current program status register)

    与其它寄存区的区别👇

    • 其它寄存器是用来存放数据的,整个寄存器只具有一个含义
    • CPSR寄存器是按位起作用的,也就是说它的每一位都有专门的含义,记录特定的信息。
    位域分布

    CPSR寄存器是32位的,其分布大致如下👇

    • CPSR的低8位(包括I、F、T和M[4:0])称为控制位,程序无法修改,除非CPU运行于特权模式下,程序才能修改控制位!
    • 8~27位保留位
    • N、Z、C、V 均为条件码标志位。它们的内容可被算术或逻辑运算的结果所改变,并且可以决定某条指令是否被执行!意义重大!

    整体分布如下图👇

    示例查看CPSR

    接下来,我们通过一个简单的示例,看看控制台中CPSR的值,示例👇

    void funcA() {
        int a = 1;
        int b = 2;
        if (a == b) {
            printf("a == b");
        } else {
            printf("error");
        }
    }
    

    查看汇编👇

    接下来通过lldb修改cpsr的值👇

    可以看到,我们通过修改cpsr的值,强行改变了代码的执行逻辑,最终执行了printf("a == b");

    内联汇编

    我们想在oc文件中写汇编的代码,除了在01-汇编基础(1)新建汇编.s格式的文件这种方式外,还有一种方式 👉 内联汇编

    在C/OC代码中嵌入汇编需要使用asm关键字(也可以使用asm、__asm。这个和编译器有关,在iOS环境下它们等价。),在asm的修饰下,代码列表、输出运算符列表、输入运算符列表和被更改的资源列表这4个部分被3个“:”分隔👇

    asm(  
         代码列表  
         : 输出运算符列表  
         : 输入运算符列表  
         : 被更改资源列表  
    );
    

    swift中貌似没有办法直接内联汇编,但可以通过和OC的桥接去处理。

    接下来我们开看看CPSR中最高的4位 N Z C V,每个位所表示的具体含义👇

    1.1 N(Negative)(符号标志位)

    CPSR的第31位是 N 👉 符号标志位。它记录相关指令执行后,其结果是否为负

    • 如果为负 N = 1
    • 如果是非负数 N = 0
    示例演示

    接下来我们执行一个简单的汇编指令,看看N符号标志位的值👇

    void funcA() {
        asm(
            "mov w0,#0xffffffff\n"
            "adds w0,w0,#0x0\n"
            );
    }
    
    • 执行adds指令前,cpsr为0x60000000,高4位为0110,那么 N = 0👇
    • 执行adds指令完成后,cpsr = 0x80000000,高4位为1000N = 1👇

    ⚠️注意:在ARM64的指令集中,有的指令的执行时影响状态寄存器的,比如add\sub\or等,他们大都是运算指令(进行逻辑或算数运算)

    1.2 Z(Zero)(0标志位)

    CPSR的第30位是 Z 👉 0标志位。它记录相关指令执行后,其结果是否为0

    • 如果结果为0,那么 Z = 1
    • 如果结果不为0,那么 Z = 0

    ⚠️注意,结果值 和 Z值 是的!

    我们可以这么理解👇

    • 在计算机中1表示逻辑真,表示肯定;0表示逻辑假,表示否定。
    • 结果为0的时候表示结果是0这个条件是肯定的为,所有Z = 1
    • 结果不为0的时候表示结果是0这个条件是否定的为,所以Z = 0
    示例演示

    看下面这个示例👇

    void funcA() {
        asm(
            "mov w0,#0x0\n"
            "adds w0,w0,#0x0\n"
            );
    }
    
    • adds执行前👇

    cpsr值 👉 0x60000000,其中 Z = 1

    • adds执行后👇

    cpsr值 👉 0x40000000,其中 Z = 0

    修改一下示例代码👇

    void funcA() {
        asm(
            "mov w0,#0x0\n"
            "adds w0,w0,#0x1\n"
            );
    }
    

    读者可自行操作,查看该情况下cpsr值的变化。

    同样的操作adds断点前后cpsr分别为:cpsr = 0x60000000cpsr = 0x00000000 👉 对应N = 1N = 0

    1.3 C(Carry)(进位标志位)

    CPSR的第29位是C 👉 进位标志位。一般情况下,进行无符号数的运算。

    • 加法运算 👉 当运算结果产生了进位时(无符号数溢出),C=1,否则C=0。
    • 减法运算(包括CMP) 👉 当运算时产生了借位时(无符号数溢出),C=0,否则C=1。

    对于位数为N的无符号数来说,其对应的二进制信息的最高位,即第N - 1位,就是它的最高有效位,而假想存在的第N位,就是相对于最高有效位的更高位。如下图所示👇

    进位 & 借位

    上面提到了进位借位,我们先来解释下它们。

    进位
    先看看进位的情况,我们知道,当两个数据相加的时候,有可能产生从最高有效位向更高位的进位。

    比如两个32位数据:0xaaaaaaaa + 0xaaaaaaaa,将产生进位。由于这个进位值在32位中无法保存,我们就只是简单的说这个进位值丢失了。其实CPU在运算的时候,并不丢弃这个进位制,而是记录在一个特殊的寄存器的某一位上ARM下就用C位来记录这个进位值。比如,下面的指令👇

    void funcA() {
        asm(
            "mov w0,#0xaaaaaaaa\n"//0xa 的二进制是 1010
            "adds w0,w0,w0\n" // 执行后 相当于 1010 << 1 进位1(无符号溢出) 所以C标记 为 1
            "adds w0,w0,w0\n" // 执行后 相当于 0101 << 1 进位0(无符号没溢出) 所以C标记 为 0
            "adds w0,w0,w0\n" // 重复上面操作
            "adds w0,w0,w0\n"
            );
    }
    
    • adds执行前
    • 第1次adds执行后
    • 第2次adds执行后
    • 第3次adds执行后
    • 第4次adds执行后

    综上,cpsr的值是这么变化的👇
    0x60000000 👉 0x30000000 👉 0x90000000 👉 0x30000000 👉 0x90000000
    对应的高4位的值的变化就是
    0110 👉 0011 👉 1001 👉 0011 👉 1001
    所以,第1次和第3次相加,无符号溢出了,所以 C = 1;而第2次和第4次相加,无符号没有溢出,所有C = 0。

    借位
    再开看看借位的情况,当两个数据做减法的时候,有可能向更高位借位

    比如两个32位数据:0x00000000 - 0x000000ff将产生借位,借位后相当于计算0x100000000 - 0x000000ff 👉 得到0xffffff01这个值。由于借了一位,C位用来标记借位,所以 C = 0
    比如下面指令👇

    void funcA() {
        asm(
            "mov w0,#0x0\n"
            "subs w0,w0,#0xff\n"
            "subs w0,w0,#0xff\n"
            "subs w0,w0,#0xff\n"
            );
    }
    

    进位的情况一样的调试,这里就不做演示了,得到的结果👇
    cpsr的值是这么变化的👇
    0x60000000 👉 0x80000000 👉 0xa0000000 👉 0xa0000000
    对应的高4位的值的变化就是
    0110 👉 1000 👉 1010 👉 1010
    所以,第一次相减借了一位,无符号溢出了,C=0;第2次和第3次相减时,无符号没有溢出,所以C=1。

    1.4 V(Overflow)(溢出标志)

    CPSR的第28位是V 👉 溢出标志位。在进行有符号数运算的时候,如果超过了机器所能标识的范围,称为溢出

    • 正数 + 正数 为负数 溢出
    • 负数 + 负数 为正数 溢出
    • 正数 + 负数 不可能溢出
    • 溢出 V = 1,不溢出 V = 0

    由于CPU并不知道有没有符号,所以CPSR寄存器CV同时标记C标记无符号V标记有符号。标志位会同时返回

    理解起来不难,这里就不做示例演示了。

    二、判断、选择和循环

    在讲判断、选择和循环之前,我们先来看看内存的五大分区👇

    • 栈区:参数、局部变量、临时数据。可短可写
    • 堆区:动态申请。可读可写
    • 全局静态区:可读可写
    • 常量区:只读
    • 代码区:存放代码,可读可执行

    详细的说明可以参考我之前写的内存五大分区

    2.1 基础知识点

    全局变量和常量

    全局变量和常量,在汇编中是怎么读取值的?我们先来看看下面这个例子👇

    int g = 12;
    
    int func(int a, int b) {
        printf("test");
        int c = a + g + b;
        return c;
    }
    

    查看汇编👇

    上图我们通过查看x0寄存器值可知,printf函数的参数来源为👇

    0x102c6dc54 <+20>: adrp   x0, 1
    0x102c6dc58 <+24>: add    x0, x0, #0x5ec            ; =0x5ec 
    

    X0存储的是一个地址,为字符串常量区。那么以上两条指令是怎么计算得出0x0000000102c6e5ec这个值?👇

    1. adrp 👉 Address Page 内存地址以页寻址
    2. 0x102c6dc54 <+20>: adrp x0, 1 👉 定位到某一页数据的开始(文件起始位置
      • 1的值左移12位变成0x1000
      • 当前pc的值低12位清零。0x102c6dc54 -> 0x102c6d000
      • 0x102c6d000 + 0x1000得到0x102c6e000。相当于pc后3位置为0,第4位加上x0后跟的值。
    3. 0x102c6dc58 <+24>: add x0, x0, #0x5ec 👉 偏移地址(当前代码偏移)
      • 0x102c6e000 + 0x5ec得到 0x102c6e5ec

    这样就得到了常量区字符串"test"的地址了。

    其中,0x102c6d000尾数为000意味着000~fff -> 0~4095大小为4096也就是4k。也就是定位到某一页数据的开始。

    • mac中 pagesize 4k
    • iOS中 pagesize 16k。这里是兼容的 👉 4k * 4 = 16k。

    我们继续调试,查看全局变量g的汇编处理👇

    上图红框处的指令,和上面一样的,最终计算出x9最终的值为0x0000000102c715f0,也就是全局变量g的值

    综上所述,全局变量和常量都是通过一个 基地址 + 偏移 获取。

    反汇编工具还原

    接下来,我们使用反汇编工具,演示一下将汇编代码还原成高级代码

    • 首先,编译要还原的工程,进入.app找到macho文件并拖入Hopper中👇
    • 反汇编工具Hopper分析完成后,搜索要分析的函数👇
    • 先看看汇编的代码👇
    0000000100005c40         sub        sp, sp, #0x20                               ; CODE XREF=-[ViewController viewDidLoad]+76
    0000000100005c44         stp        x29, x30, [sp, #0x10]
    0000000100005c48         add        x29, sp, #0x10
    0000000100005c4c         stur       w0, [x29, #-0x4]
    0000000100005c50         str        w1, [sp, #0x8]
    0000000100005c54         adrp       x0, #0x100006000                            ; argument #1 for method imp___stubs__printf
    0000000100005c58         add        x0, x0, #0x5ec                              ; "test"
    0000000100005c5c         bl         imp___stubs__printf
    0000000100005c60         ldur       w8, [x29, #-0x4]
    0000000100005c64         adrp       x9, #0x100009000
    0000000100005c68         add        x9, x9, #0x5f0                              ; _g
    0000000100005c6c         ldr        w10, [x9]                                   ; _g
    0000000100005c70         add        w8, w8, w10
    0000000100005c74         ldr        w10, [sp, #0x8]
    0000000100005c78         add        w8, w8, w10
    0000000100005c7c         str        w8, [sp, #0x4]
    0000000100005c80         ldr        w8, [sp, #0x4]
    0000000100005c84         mov        x0, x8
    0000000100005c88         ldp        x29, x30, [sp, #0x10]
    0000000100005c8c         add        sp, sp, #0x20
    0000000100005c90         ret
    

    上面的"test"那行是0x100006000 + 0x5ec = 0x1000065ec

    • 可以通过MachOView查找0x1000065ec👇
    • 同理,查看全局变量g
    0000000100005c64         adrp       x9, #0x100009000
    0000000100005c68         add        x9, x9, #0x5f0                              ; _g
    

    全局变量g的地址就是0x1000095f0👇

    • 接着,我们将上面的汇编代码还原👇
    0000000100005c40         sub        sp, sp, #0x20                               ; CODE XREF=-[ViewController viewDidLoad]+76
    // 拉伸栈空间
    0000000100005c44         stp        x29, x30, [sp, #0x10]
    0000000100005c48         add        x29, sp, #0x10
    // 参数入栈w0 w1
    0000000100005c4c         stur       w0, [x29, #-0x4]
    0000000100005c50         str        w1, [sp, #0x8]
    // 取常量“test”
    0000000100005c54         adrp       x0, #0x100006000                            ; argument #1 for method imp___stubs__printf
    0000000100005c58         add        x0, x0, #0x5ec                              ; "test"
    // 调用printf("test")
    0000000100005c5c         bl         imp___stubs__printf
    // w8 = a;
    0000000100005c60         ldur       w8, [x29, #-0x4]
    // 取全局变量g
    0000000100005c64         adrp       x9, #0x100009000
    0000000100005c68         add        x9, x9, #0x5f0                              ; _g
    // w10 = g
    0000000100005c6c         ldr        w10, [x9]                                   ; _g
    // w8 += w10
    0000000100005c70         add        w8, w8, w10
    // w10 = b
    0000000100005c74         ldr        w10, [sp, #0x8]
    // w8 += w10
    0000000100005c78         add        w8, w8, w10
    // 返回值存入x0
    0000000100005c7c         str        w8, [sp, #0x4]
    0000000100005c80         ldr        w8, [sp, #0x4]
    0000000100005c84         mov        x0, x8
    0000000100005c88         ldp        x29, x30, [sp, #0x10]
    // 栈平衡
    0000000100005c8c         add        sp, sp, #0x20
    0000000100005c90         ret
    

    经过上面的还原,不就是之前的原石代码么。。。结果完全一样!大家可以照着这个示例实操一遍,加深印象!

    2.2 判断

    接下来,我们来看看判断逻辑在汇编中是怎么执行的。

    if

    先看看我们最为熟悉的if判断,例如👇

    int  g = 12;
    
    void func(int a, int b) {
        if (a > b) {
            g = a;
        } else {
            g = b;
        }
    }
    

    我们直接用上面的反汇编工具Hopper来查看汇编👇

    上图的汇编难吗?其实很简单,做了一个cmp比较,然后执行代码块1和代码块2,就是满足了if的代码块else的代码块

    cmp指令

    cmp 把一个寄存器的内容和另一个寄存器的内容或立即数进行比较,但不存储结果,只是正确的更改标志(cpsr)
    一般cmp做完判断后会进行跳转,后面通常会跟上b指令

    b 跳转指令

    b本身代表跳转,后面跟标号会有其他操作:

    指令名称 指令含义
    bl 跳转到标号处执行,并且影响lr寄存器的值。用于函数返回。
    br 根据寄存器中的值跳转。
    b.gt 比较结果是 大于(greater than) 执行标号,否则不跳转。
    b.ge 比较结果是 大于等于(greater than or equal to) 执行标号,否则不跳转。
    b.lt 比较结果是 小于(less than) 执行标号,否则不跳转。
    b.le 比较结果是 小于等于(less than or equal to) 执行标号,否则不跳转。
    b.eq 比较结果是 等于(equal) 执行标号,否则不跳转。
    b.ne 比较结果是 不等于(not equal) 执行标号,否则不跳转。
    b.hi 比较结果是 无符号大于 执行标号,否则不跳转。
    b.hs 比较结果是 无符号大于等于 执行标号,否则不跳转。
    b.lo 比较结果是 无符号小于 执行标号,否则不跳转。
    b.ls 比较结果是 无符号小于等于 执行标号,否则不跳转。

    ⚠️注意:cmp后跟的标号条件是else

    再回过头看示例的汇编👇
    0000000100005c7c b.le loc_100005c94执行的是b.le,所以代码块1就是满足if大于的情况,代码块2就是else的情况。

    2.3 循环

    然后,我们看看循环的逻辑在汇编中执行的是什么指令。

    2.3.1 do-while

    首先看看do-while循环,例如👇

    void func() {
        int nSum = 0;
        int i = 0;
        do {
            nSum = nSum + 1;
            i++;
        } while (i < 100);
    }
    

    Hopper汇编👇

    上图汇编也很简单👇

    • 先初始化2个变量,2变量的地址是0xc0x8,刚好每个4字节(对应int类型
    • 接着执行循环do的部分
    • cmp就是while的判断条件,b.lt就是满足条件后跳转到do部分

    2.3.2 while

    一样,先看示例👇

    void func() {
        int nSum = 0;
        int i = 0;
        while (i < 100) {
            nSum = nSum + 1;
            i++;
        }
    }
    

    2.3.3 for

    最后我们来看看最常用的for循环,例子👇

    void func() {
        int nSum = 0;
        for (int i = 0; i < 100; i++) {
            nSum = nSum + 1;
        }
    }
    

    ⚠️注意:forwhile的汇编中,条件都是通过b.ge来判断的。

    2.4 选择

    最后我们来看看选择逻辑在汇编中执行的是什么指令。

    Switch 选择
    void func(int a) {
        switch (a) {
            case 1:
                printf("case 1");
                break;
            case 2:
                printf("case 2");
                break;
            case 3:
                printf("case 3");
                break;
            default:
                printf("case default");
                break;
        }
    }
    

    case > 3个的情况

    void func(int a) {
        switch (a) {
            case 1:
                printf("case 1");
                break;
            case 2:
                printf("case 2");
                break;
            case 3:
                printf("case 3");
                break;
            case 4:
                printf("case 4");
                break;
            default:
                printf("case default");
                break;
        }
    }
    

    上图是Hopper中分析的汇编代码,除了开始做的w8-=1之外,其余代码块的代码,接下来我们仔细分析一下👇

    • 代码块1
    0000000100005be0         mov        x9, x8
    0000000100005be4         ubfx       x9, x9, #0x0, #0x20
    0000000100005be8         cmp        x9, #0x3
    0000000100005bec         str        x9, [sp]
    
    1. mov x9, x8 👉 x8寄存器的值给x9寄存器,就是参数的值
    2. ubfx x9, x9, #0x0, #0x20 👉 ubfx的意思是针对清零(⚠️从高位开始),那么就是将x9的地址值中的0~32位清零(0x0十进制即00x20十进制即32
    3. cmp x9, #0x3 👉 比较 x9 和 0x3 的值。这里0x3最大 case - 最小 case差值
    4. str x9, [sp] 👉 x9入栈,也就是x8的低32位入栈。

    综上,就是参数 - 最小case - (最大case - 最小case),如果b.hi无符号大于了,就直接跳转去default分支

    • 代码块2
    0000000100005bf4         adrp       x8, #0x100005000
    0000000100005bf8         add        x8, x8, #0xc64               
    

    这里很简单,根据上篇文章02-汇编基础(2)中对adrp指令的分析可知,x8中存储的地址就是0x100005c64

    • 代码块3
    0000000100005bfc         ldr        x11, [sp]
    0000000100005c00         ldrsw      x10, [x8, x11, lsl #2]
    0000000100005c04         add        x9, x8, x10
    0000000100005c08         br         x9
    
    1. ldr x11, [sp] 👉 从栈中取数据给x11,栈中目前是x9。x9为x8的低32位
    2. ldrsw x10, [x8, x11, lsl #2] 👉 lsl #2左移2位的意思,那么这句的意思就是 x10 = x8 + (x11 << 2)
    3. add x9, x8, x10 👉 很简单,x9 = x8 + x10
    4. br x9 根据x9寄存器中的值进行跳转。
    举例算x9

    现在,我们来分析下x9中的值的计算过程(假如参数是2,即调用的func(2))👇

    1. x9最开始跟x8(即入参值)有关,,那么x9是x8的低32位,x9的值就是1(经过了subs w8, w8, #0x1减1了),那么ldr x11, [sp] x11也是1
    2. 接着经过ldrsw x10, [x8, x11, lsl #2],1(x11) << 2 = 4,然后4 + 0x100005c64(x8的地址) = 0x100005c68(x10的值),查询代码块5,可知x10的值是0xffffffb8👇

    0xffffffb8对应的十进制是-72👇

    1. 接着add x9, x8, x10,因为add指令算的是十六进制,x10是-72,对应的十六进制是0x48,所以x8+x10 = 0x100005c64 - 0x48(负数变减法) = 0x100005C1C = x9,最终x9就是0x100005C1C
    • 代码块4
    0000000100005c0c         adrp       x0, #0x100006000
    0000000100005c10         add        x0, x0, #0x5c8
    0000000100005c14         bl         imp___stubs__printf
    0000000100005c18         b          _func+144
    0000000100005c1c         adrp       x0, #0x100006000
    0000000100005c20         add        x0, x0, #0x5cf
    0000000100005c24         bl         imp___stubs__printf
    0000000100005c28         b          _func+144
    0000000100005c2c         adrp       x0, #0x100006000
    0000000100005c30         add        x0, x0, #0x5d6
    0000000100005c34         bl         imp___stubs__printf
    0000000100005c38         b          _func+144
    0000000100005c3c         adrp       x0, #0x100006000
    0000000100005c40         add        x0, x0, #0x5dd
    0000000100005c44         bl         imp___stubs__printf
    0000000100005c48         b          _func+144
    

    很明显,该代码块的汇编是在执行case代码块的逻辑,上面例子中最终得到x9的值是0x100005C1C,正好跳去case 2的汇编👇

    • 代码块5
    0000000100005c64         db  0xa8 ; '.'                                         ; DATA XREF=_func+48
    0000000100005c65         db  0xff ; '.'
    0000000100005c66         db  0xff ; '.'
    0000000100005c67         db  0xff ; '.'
    0000000100005c68         db  0xb8 ; '.'
    0000000100005c69         db  0xff ; '.'
    0000000100005c6a         db  0xff ; '.'
    0000000100005c6b         db  0xff ; '.'
    0000000100005c6c         db  0xc8 ; '.'
    0000000100005c6d         db  0xff ; '.'
    0000000100005c6e         db  0xff ; '.'
    0000000100005c6f         db  0xff ; '.'
    0000000100005c70         db  0xd8 ; '.'
    0000000100005c71         db  0xff ; '.'
    0000000100005c72         db  0xff ; '.'
    0000000100005c73         db  0xff ; '.'
    

    该代码块就好比是一张表,可以根据地址查到该地址对应存储的值

    汇编执行过程总结
    1. 首先通过参数 - 最小case 得到表中index
    2. index(最大case - 最小case) 无符号比较判断是否在区间内。
      • 不在区间内 👉 跳转defalult
      • 区间内 👉 表头地址 + index << 2获取偏移地址(为负数)
    3. 根据偏移的地址执行对应case逻辑

    ⚠️注意:表中为什么不直接存地址? 1.地址过长 2.有ASLR的存在

    Switch小结

    1. switch语句的分支< 3的时候没有必要使用表结构,相当于if

    2. 各个分支常量的差值较大的时候,编译器会在效率还是内存进行取舍,这个时候编译器还是会编译成类似于if-else的结构。比如:100、200、300、400这种case还是和if-else相同,10、20、30、40会生成一张表。所以在写switch逻辑的时候最好使用连续的值。至于具体逻辑编译器会根据case和差值进行优化选择。case越多,差值越小,数值越连贯 编译器会生成跳转表,否则还是if-else

    3. 在分支比较多的时候:在编译的时候会生成一个(跳转表每个地址四个字节)。

    4. 跳转表中数量为最大case - 最小case + 1为一共有多少种可能性。

    5. case分支的代码地址是连续的,使用的是用空间换时间的思想。

    总结

    1. 状态(标志)寄存器 CPSR
      • ARM64中cpsr寄存器(32位)为状态寄存器
      • 最高4位(28,29,30,31)为标志位。NZ(执行结果) CV(无符号/有符号溢出)
        • N标志(负标记位)
          • 执行结果负数 N = 1,非负数 N = 0
        • Z标志(0标记位)
          • 结果为0 Z = 1,结果非0 Z = 0
        • C标志(无符号数溢出)
          • 加法:进位 C = 1,否则 C = 0
          • 减法:借位 C = 0,否则 C = 1
        • V标志(有符号数溢出)
          • 正数 + 正数 = 负数 溢出 V = 1
          • 负数+ 负数 = 正数 溢出 V = 1
          • 正数 + 负数 不可能溢出 V = 0
          • 溢出 V = 1,不溢出 V = 0
    2. 判断、选择和循环

    相关文章

      网友评论

          本文标题:03-汇编基础(3)

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