手写操作系统(四十三)-实现堆内存释放功能

代码、内容参考来自于包括《操作系统真象还原》、《一个64位操作系统的设计与实现》以及《ORANGE’S:一个操作系统的实现》。

内存的使用都是通过位图来管理的,无论内存的分配或释放,本质上都是在设置相关位图中的相应位,都是在读写位图。回收物理地址就是将物理内存池位图中的相应位清0,无需将该4KB物理页框逐字节清0。回收虚拟地址就是将虚拟内存池位图中的相应位清0。分配则是相反的,也就是将位图中相应位置为1 即可。

我们分配内存时在函数malloc_page中,步骤如下:

  • 在虚拟地址池中分配虚拟地址,相关的函数是vaddr_get,此函数操作的是内核虚拟内存池位图”kernel_vaddr.vaddr_bitmap”或用户虚拟内存池位图”pcb->userprog_vaddr.vaddr_bitmap”。
  • 在物理内存池中分配物理地址,相关的函数是palloc,此函数操作的是内核物理内存池位图”kernel_pool->pool_bitmap”或用户物理内存池位图”user_pool->pool_bitmap”。
  • 在页表中完成虚拟地址到物理地址的映射,相关的函数是page_table_add。

对应,释放内存方法定义在函数mfree_page:

  • 在物理地址池中释放物理页地址,相关的函数是pfree,操作的位图同palloc。
  • 在页表中去掉虚拟地址的映射,原理是将虚拟地址对应pte的P位置0,相关的函数是page_tablepte_remove。
  • 在虚拟地址池中释放虚拟地址,相关的函数是vaddr_remove,操作的位图同vaddr_get。

 

接下来就是实现函数mfree_page了。

同样修改/kernel/memory.c

先定义三个函数:

/* 将物理地址 pg_phy_addr 回收到物理内存池 */
void pfree(uint32_t pg_phy_addr) {
    struct pool* mem_pool;
    uint32_t bit_idx = 0;
    if(pg_phy_addr >= user_pool.phy_addr_start) {
        // 用户物理内存池
        mem_pool = &user_pool;
        bit_idx = (pg_phy_addr - user_pool.phy_addr_start) / PG_SIZE;
    } else {
        // 内核物理内存池
        mem_pool = &kernel_pool;
        bit_idx = (pg_phy_addr - kernel_pool.phy_addr_start) / PG_SIZE;
    }
    bitmap_set(&mem_pool->pool_bitmap, bit_idx, 0);     // 将位图中该位清 0
}


/* 去掉页表中虚拟地址 vaddr 的映射, 只去掉 vaddr 对应的 pte */
static void page_table_pte_remove(uint32_t vaddr) {
    uint32_t* pte = pte_ptr(vaddr);
    *pte &= ~PG_P_1;                                        // 将页表项 pte 的 P 位置 0
    asm volatile("invlpg %0" : : "m"(vaddr) : "memory");    // 更新 tlb
}


/* 在虚拟地址池中释放以 _vaddr 起始的连续 pg_cnt 个虚拟页地址 */
static void vaddr_remove(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt) {
    uint32_t bit_idx_start = 0, vaddr = (uint32_t) _vaddr, cnt = 0;

    if(pf == PF_KERNEL) {
        // 内核虚拟内存池
        bit_idx_start = (vaddr - kernel_vaddr.vaddr_start) / PG_SIZE;
        while(cnt < pg_cnt) {
            bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + (cnt++), 0);
        }

    } else {
        // 用户虚拟内存池
        struct task_struct* cur_thread = running_thread();
        bit_idx_start = (vaddr - cur_thread->userprog_vaddr.vaddr_start) / PG_SIZE;
        while(cnt < pg_cnt) {
            bitmap_set(&cur_thread->userprog_vaddr.vaddr_bitmap, bit_idx_start + (cnt++), 0);
        }
    }
}

函数pfree接受一个参数,即物理页框地址pg_phy_addr,功能是将物理页框回收到相应的物理内存池,也就是只回收一个物理页。先根据物理地址池的起始地址判断pg_phy_addr属于哪个物理内存池,用变量mem_pool指向物理内存池,bit_idx为物理地址在相应物理内存池中的偏移量,最后通过代码”bitmap_set(&mem_pool->pool_bitmap, bit_idx, 0)”在位图中回收该位。

函数page_table_pte_remove接受一个参数,即虚拟地址vaddr,将pte中的P位置0。函数体中,先调用pte_ptr(vaddr)获取虚拟地址所在的pte指针,然后在下一行,通过代码”*pte &=~PG_P_1″使pte中的P位取反为0。除此之外,就是刷新tlb。pte已经被修改,因此咱们要把此pte更新到tlb中,tlb页表的高速缓存,俗称快表,tlb是处理器提供的、用于加速虚拟地址到物理地址的转换过程。当页表发生变化时,tlb中缓存的数据也应该及时更新,否则程序会运行出错。更新TLB有两种方式,一是用invlpg指令更新单条虚拟地址条目,另外一个是重新加载cr3寄存器,这将直接清空TLB,相当于更新整个页表。这里只更新了虚拟地址vaddr对应的pte,invlpg指令去单独更新vaddr对应的缓存。invlpg的指令格式为”invlpg m”,其中m是操作数,表示虚拟地址内存,m并不是立即数形式的虚拟地址,它必须是实实在在的内存地址的形式。比如更新虚拟地址0x12345678的缓存,指令是 invalpg [0x12345678],而不是 invalpg 0x12345678,invalpg是汇编指令,因此用下面的一行内联汇编代码”asm volatile (“invlpg %0″::”m” (vaddr):”memory”)”来更新虚拟地址vaddr在 tlb缓存中的条目,也就是把页表中vaddr所在的pte重新写入tlb。invlpg的操作数m是内存地址,因此位于内联汇编代码输入部中的vaddr,其约束是内存约束m。

函数是vaddr_remove接受3个参数,pf是虚拟内存池标志,_vaddr是待释放的虚拟地址,pg_cnt是连续的虚拟页框数。函数功能是在虚拟地址池中释放以_vaddr起始的连续pg_cnt个虚拟页地址。虚拟内存地址的管理是在虚拟内存池的位图中,因此先根据pf判断是处理哪个虚拟内存池,然后再用位图函数bitmap_set将虚拟地址在虚拟内存池位图中相应的位清0。如果是内核,就针对内核的虚拟内存池kernel_vaddr操作,先bit_idx_start = (vaddr – kernel_vaddr.vaddr_start) / PG_SIZE计算虚拟地址vaddr在位图kernel_vaddr中的偏移量,存入变量bit_idx_start中,然后循环pg_cnt次,依次将虚拟内存池位图中的相应位清0,针对用户虚拟内存池的处理与此同理。

接下来整合到函数mfree_page

修改/kernel/memory.c

添加mfree_page函数

/* 释放以虚拟地址 vaddr 为起始的 cnt 个物理页框 */
void mfree_page(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt) {
    uint32_t pg_phy_addr;
    uint32_t vaddr = (uint32_t) _vaddr, page_cnt = 0;
    ASSERT(pg_cnt >= 1 && vaddr % PG_SIZE == 0);
    // 获取虚拟地址 vaddr 对应的物理地址
    pg_phy_addr = addr_v2p(vaddr);

    // 确保待释放的物理内存在低端 1MB+1KB 大小的页目录 + 1KB 大小的页表地址外
    ASSERT((pg_phy_addr % PG_SIZE) == 0 && pg_phy_addr >= 0x102000);

    // 判断 pg_phy_addr 属于用户物理内存池还是内核物理内存池
    if(pg_phy_addr >= user_pool.phy_addr_start) {
        // 位于用户物理内存池
        vaddr -= PG_SIZE;
        while(page_cnt < pg_cnt) {
            vaddr += PG_SIZE;
            pg_phy_addr = addr_v2p(vaddr);

            // 确保物理地址属于用户物理地址池
            ASSERT((pg_phy_addr % PG_SIZE) == 0 && pg_phy_addr >= user_pool.phy_addr_start);

            // 先将对应的物理页框归还到内存池
            pfree(pg_phy_addr);

            // 再从页表中清除此虚拟地址所在的页表项 pte
            page_table_pte_remove(vaddr);

            page_cnt++;
        }
        // 清空虚拟地址的位图中的相应位
        vaddr_remove(pf, _vaddr, pg_cnt);

    } else {
        // 位于内核物理内存池
        vaddr -= PG_SIZE;
        while (page_cnt < pg_cnt) {
            vaddr += PG_SIZE;
            pg_phy_addr = addr_v2p(vaddr);

            // 确保待释放的物理内存只属于内核物理地址池
            ASSERT((pg_phy_addr % PG_SIZE) == 0 &&
                   pg_phy_addr >= kernel_pool.phy_addr_start &&
                   pg_phy_addr < user_pool.phy_addr_start);

            // 先将对应的物理页框归还到内存池
            pfree(pg_phy_addr);

            // 再从页表中清除此虚拟地址所在的页表框 pte
            page_table_pte_remove(vaddr);

            page_cnt++;
        }
        // 清空虚拟地址的位图中的相应位
        vaddr_remove(pf, _vaddr, pg_cnt);
    }
}

mfree_page函数接受3个参数,pf是内存池标志,_vaddr是待释放的虚拟地址,pg_cnt是连续的页框数,功能是释放以虚拟地址vaddr为起始的cnt个物理页框。

内存回收工作分为三大步骤,先调用pfree清空物理地址位图中的相应位,再调用page_table_pte_remove删除页表中此地址的pte,最后调用vaddr_remove清除虚拟地址位图中的相应位。

pg_phy_addr = addr_v2p(vaddr)先调用addr_v2p(vaddr)获取虚拟地址vaddr对应的物理地址,将其保存在pg_phy_addr 中,然后根据pg_phy_addr的值判断它属于内核的物理内存池还是用户物理内存池。 内核物理内存池 kernel_pool的地址位于用户物理内存池user_pool的前面,即kernel_pool的地址在低地址处,所以可以通过代码”if (pg_phy_addr >= user_pool.phy_addr_start)”判断物理地址pg_phy_addr所属的物理内存池。 如果pg_phy_addr大于user_pool的起始物理地址,说明pg_phy_addr属于用户内存池。

接下来是针对用户物理内存池的处理,通过while循环处理pg_cnt个页框,每个循环中,都是调用addr_v2p(vaddr)得到vaddr 的物理地址pg_phy_addr,然后调用pfree(pg_phy_addr)在物理内存池位图中清0相应位。 再调用page_table pte_remove(vaddr)清除pte,pg_cnt个页框处理完成后,再调用函数vaddr_remove(pf,_vaddr, pg_cnt)在虚拟地址池位图中清0相应位。 后面针对内核内存池的逻辑是一样的。

 

上面实现的mfree_page只能释放页框级别的内存块。 sys_free 是内存释放的统一接口,无论是页框级别的内存和小的内存块,都统一用sys_free处理。  sys_free针对这两种内存的处理有各自的方法,对于大内存的处理称之为释放,就是把页框在虚拟内存池和物理内存池的位图中将相应位置0。对于小内存的处理称之为回收,是将arena中的内存块重新放回到内存块描述符中的空闲块链表free_list。

同样修改/kernel/memory.c

添加sys_free函数

sys_free只接受1个参数,内存指针ptr,函数功能是释放ptr指向的内存。

/* 回收内存 ptr */
void sys_free(void* ptr) {
    ASSERT(ptr != NULL);

    if(ptr != NULL) {
        enum pool_flags PF;
        struct pool* mem_pool;

        // 判断是线程, 还是进程
        if(running_thread()->pgdir == NULL) {
            ASSERT((uint32_t) ptr >= K_HEAP_START);
            PF = PF_KERNEL;
            mem_pool = &kernel_pool;

        } else {
            PF = PF_USER;
            mem_pool = &user_pool;
        }

        lock_acquire(&mem_pool->lock);
        struct mem_block* b = ptr;
        // 把 mem_block 转换成 arena, 获取元信息
        struct arena* a = block2arena(b);

        ASSERT(a->large == 0 || a->large == 1);
        if(a->desc == NULL && a->large == true) {
            // 大于 1024 的内存
            mfree_page(PF, a, a->cnt);

        } else {
            // 小于等于 1024 的内存块
            // 先将内存块回收到 free_list
            list_append(&a->desc->free_list, &b->free_elem);

            // 再判断此 arena 中的内存块是否都是空闲, 如果是就释放 arena(整个arena)
            if(++a->cnt == a->desc->blocks_per_arena) {
                uint32_t block_idx;
                for(block_idx = 0; block_idx < a->desc->blocks_per_arena; block_idx++) {
                    struct mem_block* b = arena2block(a, block_idx);
                    ASSERT(elem_find(&a->desc->free_list, &b->free_elem));
                    list_remove(&b->free_elem);
                }
                mfree_page(PF, a, 1);
            }
        }
        lock_release(&mem_pool->lock);
    }
}

在函数体前部,判断调用者是内核线程,还是用户进程,并初始化内存池标记PF和内存池指针mem_pool。随后在struct mem_block* b = ptr将ptr赋值给内存块指针b,然后通过”struct arena* a=block2arena(b)”获取内存块b所在的 arena指针,此目的是获取arena中的元信息,通过元信息中的变量desc和large的值分别进行下一步处理。

如果a->desc的值为NULL并且a->large的值为true,这说明待释放的内存(也就是ptr指向的内存)并不是在arena中的小内存块,而是大于1024字节的大内存,其大小是1个或多个页框,页框的数量是由arena元信息中的变量cnt记录的。这是我们在sysmalloc中进行内存分配时约定好的, “a->desc为NULL,并且a->large为true”表示此arena只被用于大内存分配,并不是被拆分成多个内存块,因此不存在此arena被多次共享的情况,完全可以释放。如果代码”if (a->desc==NULL && a->large==true)”条件成立的话,就调用”mfree_page(PF, a, a->cnt)”释放a->cnt个页框。如果不成立的话,表示待释放的内存是小内存块,流程就进入了“list_append(&a->desc->free_list, &b->free_elem)”,将此内存块回收到此内存块描述符的free_list中。

接下来在if(++a->cnt == a->desc->blocks_per_arena) 将“++a->cnt”后的结果与内存块描述符中的blocks_per_arena比较,如果相等,这表示此arena中的空闲内存块已经达到最大数,说明此arena已经没人使用了,可以释放。

接下来通过for循环,将此arena中的所有内存块从内存块描述符的free_list中去掉,遍历结束后,通 过”mfree_page(PF, a, 1)”释放此arena。

对应在/kernel/memory.h添加三个函数

/* 释放以虚拟地址 vaddr 为起始的 cnt 个物理页框 */
void mfree_page(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt);

/* 将物理地址 pg_phy_addr 回收到物理内存池 */
void pfree(uint32_t pg_phy_addr);

/* 回收内存 ptr */
void sys_free(void* ptr);

void* sys_malloc(uint32_t size);

 

接下来修改main.c文件看看效果

#include "print.h"
#include "init.h"
#include "debug.h"
#include "memory.h"
#include "thread.h"
#include "interrupt.h"
#include "console.h"
#include "process.h"
#include "syscall-init.h"
#include "syscall.h"
#include "stdio.h"

void k_thread_a(void*);
void k_thread_b(void*);
void u_prog_a(void);
void u_prog_b(void);

int prog_a_pid = 0, prog_b_pid = 0;

int main() {
    put_str("I am kernel\n");
    init_all();

    intr_enable();    // 打开中断, 使时钟中断起作用
    thread_start("k_thread_a", 31, k_thread_a, "I am thread_a");
    thread_start("k_thread_b", 31, k_thread_b, "I am thread_b ");

    while(1);
    return 0;
}

/* 在线程中运行的函数 */
void k_thread_a(void* arg) {
    char* para = arg;
    void* addr1;
    void* addr2;
    void* addr3;
    void* addr4;
    void* addr5;
    void* addr6;
    void* addr7;
    console_put_str(" thread_a start\n");
    int max = 1000;
    while (max-- > 0) {
        int size = 128;
        addr1 = sys_malloc(size);
        size *= 2;
        addr2 = sys_malloc(size);
        size *= 2;
        addr3 = sys_malloc(size);
        sys_free(addr1);
        addr4 = sys_malloc(size);
        size *= 2; size *= 2; size *= 2; size *= 2;
        size *= 2; size *= 2; size *= 2;
        addr5 = sys_malloc(size);
        addr6 = sys_malloc(size);
        sys_free(addr5);
        size *= 2;
        addr7 = sys_malloc(size);
        sys_free(addr6);
        sys_free(addr7);
        sys_free(addr2);
        sys_free(addr3);
        sys_free(addr4);
    }
    console_put_str(" thread_a end\n");
    while(1);
}

/* 在线程中运行的函数 */
void k_thread_b(void* arg) {
    char* para = arg;
    void* addr1;
    void* addr2;
    void* addr3;
    void* addr4;
    void* addr5;
    void* addr6;
    void* addr7;
    void* addr8;
    void* addr9;
    int max = 1000;
    console_put_str(" thread_b start\n");
    while (max-- > 0) {
        int size = 9;
        addr1 = sys_malloc(size);
        size *= 2;
        addr2 = sys_malloc(size);
        size *= 2;
        sys_free(addr2);
        addr3 = sys_malloc(size);
        sys_free(addr1);
        addr4 = sys_malloc(size);
        addr5 = sys_malloc(size);
        addr6 = sys_malloc(size);
        sys_free(addr5);
        size *= 2;
        addr7 = sys_malloc(size);
        sys_free(addr6);
        sys_free(addr7);
        sys_free(addr3);
        sys_free(addr4);

        size *= 2; size *= 2; size *= 2;
        addr1 = sys_malloc(size);
        addr2 = sys_malloc(size);
        addr3 = sys_malloc(size);
        addr4 = sys_malloc(size);
        addr5 = sys_malloc(size);
        addr6 = sys_malloc(size);
        addr7 = sys_malloc(size);
        addr8 = sys_malloc(size);
        addr9 = sys_malloc(size);
        sys_free(addr1);
        sys_free(addr2);
        sys_free(addr3);
        sys_free(addr4);
        sys_free(addr5);
        sys_free(addr6);
        sys_free(addr7);
        sys_free(addr8);
        sys_free(addr9);
    }
    console_put_str(" thread_b end\n");
    while(1);
}

/* 测试用户进程 */
void u_prog_a(void) {
    char* name = "prog_a";
    printf(" I am %s, my pid:%d%c", name, getpid(),'\n');
    while(1);
}

/* 测试用户进程 */
void u_prog_b(void) {
    char* name = "prog_b";
    printf(" I am %s, my pid:%d%c", name, getpid(), '\n');
    while(1);
}

执行结果如下:

0x3 的二进制是 11,这表示用了 2 个页框,原因是创建两个线程时各用了1个页框做其pcb。

 

接下来完成系统调用 malloc 和 free

同样修改lib/user/syscall.h

#ifndef __LIB_USER_SYSCALL_H
#define __LIB_USER_SYSCALL_H

#include "stdint.h"

/* 用来存放子功能号 */
enum SYSCALL_NR {
    SYS_GETPID,
    SYS_WRITE,
    SYS_MALLOC,
    SYS_FREE
};

uint32_t getpid(void);

uint32_t write(char* str);

void* malloc(uint32_t size);

void free(void* ptr);

#endif

lib/user/syscall.c添加两个函数

/* 申请 size 字节大小的内存, 并返回结果 */
void* malloc(uint32_t size) {
    return (void*) _syscall1(SYS_MALLOC, size);
}


/* 释放 ptr 指向的内存 */
void free(void* ptr) {
    _syscall1(SYS_FREE, ptr);
}

 

修改userprog/syscall-init.c

#include "memory.h"
/* 初始化系统调用 */
void syscall_init(void) {
    put_str("syscall_init start\n");
    syscall_table[SYS_GETPID] = sys_getpid;
    syscall_table[SYS_WRITE] = sys_write;
    syscall_table[SYS_MALLOC] = sys_malloc;
    syscall_table[SYS_FREE] = sys_free;
    put_str("syscall_init done\n");
}

 

修改main.c查看结果

#include "print.h"
#include "init.h"
#include "debug.h"
#include "memory.h"
#include "thread.h"
#include "interrupt.h"
#include "console.h"
#include "process.h"
#include "syscall-init.h"
#include "syscall.h"
#include "stdio.h"

void k_thread_a(void*);
void k_thread_b(void*);
void u_prog_a(void);
void u_prog_b(void);

int prog_a_pid = 0, prog_b_pid = 0;

int main() {
    put_str("I am kernel\n");
    init_all();

    intr_enable();    // 打开中断, 使时钟中断起作用
    process_execute(u_prog_a, "u_prog_a");
    process_execute(u_prog_b, "u_prog_b");
    thread_start("k_thread_a", 31, k_thread_a, "I am thread_a");
    thread_start("k_thread_b", 31, k_thread_b, "I am thread_b");

    while(1);
    return 0;
}

/* 在线程中运行的函数 */
void k_thread_a(void* arg) {
    void* addr1 = sys_malloc(256);
    void* addr2 = sys_malloc(255);
    void* addr3 = sys_malloc(254);
    console_put_str(" thread_a malloc addr:0x");
    console_put_int((int)addr1);
    console_put_char(',');
    console_put_int((int)addr2);
    console_put_char(',');
    console_put_int((int)addr3);
    console_put_char('\n');

    int cpu_delay = 100000;
    while(cpu_delay-- > 0);
    sys_free(addr1);
    sys_free(addr2);
    sys_free(addr3);
    while(1);
}

/* 在线程中运行的函数 */
void k_thread_b(void* arg) {
    void* addr1 = sys_malloc(256);
    void* addr2 = sys_malloc(255);
    void* addr3 = sys_malloc(254);
    console_put_str(" thread_b malloc addr:0x");
    console_put_int((int)addr1);
    console_put_char(',');
    console_put_int((int)addr2);
    console_put_char(',');
    console_put_int((int)addr3);
    console_put_char('\n');

    int cpu_delay = 100000;
    while(cpu_delay-- > 0);
    sys_free(addr1);
    sys_free(addr2);
    sys_free(addr3);
    while(1);
}

/* 测试用户进程 */
void u_prog_a(void) {
    void* addr1 = malloc(256);
    void* addr2 = malloc(255);
    void* addr3 = malloc(254);
    printf(" prog_a malloc addr:0x%x,0x%x,0x%x\n", (int)addr1, (int)addr2, (int)addr3);

    int cpu_delay = 100000;
    while(cpu_delay-- > 0);
    free(addr1);
    free(addr2);
    free(addr3);
    while(1);
}

/* 测试用户进程 */
void u_prog_b(void) {
    void* addr1 = malloc(256);
    void* addr2 = malloc(255);
    void* addr3 = malloc(254);
    printf(" prog_b malloc addr:0x%x,0x%x,0x%x\n", (int)addr1, (int)addr2, (int)addr3);

    int cpu_delay = 100000;
    while(cpu_delay-- > 0);
    free(addr1);
    free(addr2);
    free(addr3);
    while(1);
}

执行结果如下:

本次main.c中启了4个任务,分别是2个用户进程和2个内核线程。 它们分别都申请了256、255、254字节大小的内存,因此它们对应的内存块规格都应该是256字节。 所有内核线程共享内存空间,因此线程函数k_thread_a和k_thread_b所申请的内存应该会有地址累加的情况。 用户进程拥有独立的内存空间,因此在申请内存时,都会从自己的堆空间从头算起,并不会产生地址累加的情况。 256的十六进制形式是0x100,比较方便查看地址累加的情况,这就是我们选择规格为256字节内存块的原因。

用户进程prog_a三次malloc调用,返回的地址分别是0x804800c、0x804810c、 0x804820c,它们各相差0x100,也就是差256字节,用户进程prog_b与它相同,原因是用户进程独享内 存空间,虚拟地址并不冲突。 线程thread_a调用sys_malloc后返回的地址分别是0xc013400c、0xc013410c、 0xc013420c,地址之间也是相差0x100,即256字节。 下面的线程thread_b同样调用sys_malloc,返回的 地址出现了累加的现象,在k_thread_a地址分配的基础上,从0xc013430c开始,然后依次是0xc013440c 和 Oxc013450c,原因是内核线程共享内存空间,虚拟地址必须唯一。

 

参考

郑钢著操作系统真象还原

田宇著一个64位操作系统的设计与实现

丁渊著ORANGE’S:一个操作系统的实现

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇