手写操作系统(三十四)-键盘设备

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

1.概述

键盘操作涉及两个独立的芯片配合

键盘是个独立的设备,内部有个芯片叫键盘编码器,通常是 Intel 8048 或兼容芯片,用于当键盘上发送按键操作后,向键盘控制器报告哪个键被按下,或被弹起

键盘控制器位于主板内部,通常是 Intel 8042 或兼容芯片,用于将键盘编码器发送过来的编码进行解码,解码后保存发给中断代理发中断,之后处理器执行相应的中断处理程序读入8042处理保存过的按键信息。

8048是键盘上的芯片,其主要责任就是监控哪个键被按下。当键盘上发生按键操作时,8048要将按键信息传给8042。

所有按键都对应一个数值,叫键盘扫描码。

一个按键有两种状态,也就有两个码:

  • 按键处于按下状态,叫通码,如果按住不松手的情况下,会持续产生通码
  • 按键被松开弹起时产生的编码叫断码。

一个键的扫描码由通码和断码组成

无论是按下键,或是松开键,当键的状态改变后,键盘中的8048芯片把按键对应的扫描码(通码或断码)发送到主板上的8042芯片,由8042处理后保存在自己的寄存器中,然后向8259A发送中断信号,这样处理器便去执行键盘中断处理程序,将8042处理过的扫描码从它的寄存器中读取出来,继续进行下一步处理。

扫描码是硬件提供的编码集,ASCII是软件中约定的编码集,这两个是不同的编码方案。我们的键盘中断处理程序是同硬件打交道的,因此只能得到硬件提供的扫描码,扫描码是硬件提供的编码集,和ASCII不同,所以需要一个字符处理程序来将扫描码替换成ASCII码,可使用中断处理程序来完成。

 

2.键盘扫描码

键盘扫描码由键盘编码器决定,不同的编码方案便是不同的键盘扫描码,键的扫描码和键的物理位置无关

键盘扫描码有三套,其中第二套几乎是目前所使用键盘的标准

不管用哪套键盘扫描码,为了兼容,都会在 Intel 8042 中转换成第一套扫描码然后发送给中断代理 Intel 8259A 来用

我们在键盘的中断处理程序中只处理第一套键盘扫描码就可以了,如下表所示:

按键通码断码
ESC0x10x81
10x20x82
20x30x83
30x40x84
40x50x85
50x60x86
60x70x87
70x80x88
80x90x89
90xa0x8a
00xb0x8b
0xc0x8c
=0xd0x8d
Backspace0xe0x8e
Tab0xf0x8f
Q0x100x90
W0x110x91
E0x120x92
R0x130x93
T0x140x94
Y0x150x95
U0x160x96
I0x170x97
O0x180x98
P0x190x99
[0x1a0x9a
]0x1b0x9b
Enter0x1c0x9c
Left Ctrl0x1d0x9d
A0x1e0x9e
S0x1f0x9f
D0x200xa0
F0x210xa1
G0x220xa2
H0x230xa3
J0x240xa4
K0x250xa5
L0x260xa6
;0x270xa7
0x280xa8
`0x290xa9
Left Shift0x2a0xaa
\0x2b0xab
Z0x2c0xac
X0x2d0xad
C0x2e0xae
V0x2f0xaf
B0x300xb0
N0x310xb1
M0x320xb2
,0x330xb3
.0x340xb4
/0x350xb5
Right Shift0x360xb6
*(小键盘)0x370xb7
Left Alt0x380xb8
Space0x390xb9
CapsLock0x3a0xba

 

大多数情况下,第一套扫描码中的通码和断码都是 1 字节大小,断码 = 0x80 + 通码

第二套扫描码一般通码是 1 字节大小,断码在通码前再加 1 字节的 0xF0,共 2 字节

Intel 8042 负责将第二套扫描码转换成第一套扫描码

对于通码和断码,通码的最高位为0,表示按下,断码的最高位为1,表示松开,所以通码和断码之间差了 0x80

有些键是 0xe0 作为前缀,不为1字节,那这个键是后来扩展进来的按键

对于 Ctrl+a 这样的组合键

按下的控制键(Ctrl)会被先保存到输出缓冲区寄存器中,等下一个常规键按下之后,算作组合键,进行组合键的按键处理

为了让我们获取击键的过程,在每一次击键动作的“按下”、“按下保持”和“弹起”三个阶段,确切地说是每次8048向8042发扫描码的时候,8042都会向中断代理(我们是8259A)发一次中断,即“键被按下”时发中断,“持续按着不松手”时会持续发中断,“松开手,键被弹起”时也发中断,因此,我们的键盘中断处理程序每次都会随着键盘发出的扫描码而去执行,也就是也会收到完整的击键过程,包括键的持续按压状态。

Intel 8042 的输出缓冲区寄存器只有 8 位宽度,所以每收到1字节扫描码就会向中断代理发送中断信号,只要8042收到1字节的扫描码后,它就会向中断代理发中断信号。因此按键时所发的中断次数,取决于该键扫描码中包含的字节数,通常情况下键的通码和断码各1个,因此通常情况下一个字符会发两次中断,但有的按键的扫描码是多个字节,如右alt键,每按一次将产生4次中断。

 

3.8042简介

和键盘相关的芯片只有8042和8048,它们都是独立的处理器,都有自己的寄存器和内存。

Intel 8048 芯片或兼容芯片位于键盘中,它是键盘编码器,是键盘对外表现击键信息、帮助键盘“说话”的部件。它除了负责监控按键扫描码外,还用来对键盘设置,比如设置键盘上的各种LED显示灯的开启和关闭。

Intel 8042芯片或兼容芯片被集成在主板上的南桥芯片中,它是键盘控制器,也就是键盘的IO接口,因此它是8048的代理,也是前面所得到的处理器和键盘的中间层。8048通过PS/2,USB等接口与8042通信,处理器通过端口与8042通信(IO接口就是外部硬件的代理,它和处理器都位于主机内部,因此处理器与IO接口可以通过端口直接通信)。

既然8042是8048的IO接口,对8048的编程也是通过8042完成的。

8042 有 4 个 8 位的寄存器,如图:

处理器把对8048的控制命令临时放在8042的寄存器中,让8042把控制命令发送给8048,此时8042充当了8048的参数输入缓冲区。

8048 把工作成果临时提交到 8042 的寄存器中,好让处理器能从 8042 的寄存器中获取它8048的工作成果,此时8042充当了8048的结果输出缓冲区。

当需要把数据从处理器发到 8042 时(数据传送尚未发生时),0x60 端口的作用是输入缓冲区,此时应该用 out指令写入 0x60端口。

当数据已从 8048 发到 8042 时,0x60 端口的作用是输出缓冲区,此时应该用in指令从8042的0x60端口(输出缓冲区寄存器)读取 8048 的输出结果。

 

输出缓冲区寄存器:

8位宽度,只读,键盘驱动程序通过 in (必须用 in 读取,不然 8042 无法继续响应)读取来自8048的扫描码、 来自8048的命令应答以及对8042本身设置时,8042自身的应答也从该寄存器中获取。

 

输入缓冲区寄存器:

8位宽度,只写,键盘驱动程序通过 out指令向此寄存器写入对8048的控制命令、参数等,对于8042本身的控制命令也是写入此寄存器。

 

状态寄存器:

8位宽度,只读,反映 8048 和 8042 的内部工作状态。

  • 位0:置1时表示输出缓冲区寄存器已满, 处理器通过 in指令读取后该位自动置0。
  • 位1:置1时表示输入缓冲区寄存器已满,8042将值读取后该位自动置 0。
  • 位2:系统标志位, 最初加电时为0, 自检通过后置为1。
  • 位3:置1时, 表示输入缓冲区中的内容是命令, 置0时, 输入缓冲区中的内容是普通数据。
  • 位4:置1时表示键盘启用, 置0时表示键盘禁用。
  • 位5:置1 时表示发送超时。
  • 位6:置1时表示接收超时。
  • 位7:来自8048的数据在奇偶校验时出错。

 

控制寄存器:

8位宽度,只写,用于写入命令控制字

  • 位0:置1时启用键盘中断。
  • 位1:置1时启用鼠标中断。
  • 位2:设置状态寄存器的位2。
  • 位3:置1时, 状态寄存器的位4无效。
  • 位4:置1时禁止键盘。
  • 位5:置1时禁止鼠标。
  • 位6:将第二套键盘扫描码转换为第一套键盘扫描码。
  • 位7:保留位, 默认为0。

 

4.测试键盘中断处理程序

一个中断向量号要有一个中断入口,我们用用宏VECTOR来实现的中断入口,因此所有的中断入口程序几乎都一样,只是中断向量号不同。

宏展开后,中断入口程序名为intr%1entry,其中%1是中断向量号,在入口程序中用这个中断向量号作为idt_table中的索引,调用最终C语言版本的中断处理程序。到目前为止,我们只为时钟添加了中断处理程序,它的中断向量号是0x20,因此在kernel. S中,”VECTOR 0x20,ZERO”是最后一个中断入口。

8259A的IR引脚,键盘的中断信号接在主片的IR1引脚上,也就是它对应的中断向量为0x21,因此我们要修改一下kernel. S,添加一句”VECTOR 0x21,ZERO”.

直接在kernel/kernel.S添加8259A 中的全部中断

VECTOR 0x20, ZERO   ; 时钟中断对应的入口
VECTOR 0x21, ZERO   ; 键盘中断对应的入口
VECTOR 0x22, ZERO   ; 级联用的

VECTOR 0x23, ZERO   ; 串口2对应的入口
VECTOR 0x24, ZERO   ; 串口1对应的入口
VECTOR 0x25, ZERO   ; 并口2对应的入口
VECTOR 0x26, ZERO   ; 软盘对应的入口
VECTOR 0x27, ZERO   ; 并口1对应的入口

VECTOR 0x28, ZERO   ; 实时时钟对应的入口
VECTOR 0x29, ZERO   ; 重定向
VECTOR 0x2a, ZERO   ; 保留
VECTOR 0x2b, ZERO   ; 保留
VECTOR 0x2c, ZERO   ; ps/2鼠标

VECTOR 0x2d, ZERO   ; fpu浮点单元异常
VECTOR 0x2e, ZERO   ; 硬盘
VECTOR 0x2f, ZERO   ; 保留

中断入口程序必须在中断描述符表idt中注册,在中断描述符表中注册中断描述符是在文件interrupt.c中用函数idt_desc_init实现的,它所注册的中断描述符的数量依赖于IDT_DESC_CNT,为此我们要把IDT_DESC_CNT改为合适的数。

暂时先把时钟中断关闭,只打开键盘的中断,完成这个目的,就是写8259A的中断屏蔽寄存器。

/kernel/interrupt.c修改如下:

#include "interrupt.h"
#include "stdint.h"
#include "global.h"
#include "io.h"
#include "print.h"

#define IDT_DESC_CNT 0x30           //目前共支持的中断数,33
#define PIC_M_CTRL 0x20             //主片的控制端口是0x20
#define PIC_M_DATA 0x21             //主片的数据端口是0x21
#define PIC_S_CTRL 0xa0             //从片的控制端口是0xa0
#define PIC_S_DATA 0xa1             //从片的数据端口是0xa1
#define EFLAGS_IF 0x00000200    //eflags寄存器中的if=1
#define GET_FLAGS(EFLAG_VAR) asm volatile("pushfl;popl %0" : "=g"(EFLAG_VAR))
//"=g" 指示编译器将结果放在任意通用寄存器中,并将其赋值给 EFLAG_VAR。
//pushfl 指令将标志寄存器 EFLAGS 的值入栈,然后 popl %0 指令将栈顶的值弹出到指定的操作数 %0 中。
//当调用 GET_FLAGS(EFLAG_VAR) 宏时,它将 EFLAGS 寄存器的值存储到 EFLAG_VAR 变量中。

/*中断门描述符结构体*/
struct gate_desc{
    uint16_t func_offset_low_word;  //低32位——0~15位:中断处理程序在目标代码段内的偏移量的第15~0位
    uint16_t selector;              //低32位——16~31位:目标CS段选择子
    uint8_t dcount;                 //高32位——0~7位:此项为双字计数字段,是门描述符中的第4字节,为固定值
    uint8_t attribute;              //高32位——8~15位:P+DPL+S+TYPE
    uint16_t func_offset_high_word; //高32位——16~31位:中断处理程序在目标代码段内的偏移量的第16~31位
};

//静态函数声明,非必须
static void make_idt_desc(struct gate_desc* p_gdesc,uint8_t attr,intr_handler function);
static struct gate_desc idt[IDT_DESC_CNT];  //IDT是中描述符表,实际上是中断门描述符数组

extern intr_handler intr_entry_table[IDT_DESC_CNT]; //指针格式应该与数组类型一致,这里intr_entry_table中的元素类型就是function,就是.text的地址。所以用intr_handle来引用。声明引用定义在kernel.S中的中断处理函数入口数组
char* intr_name[IDT_DESC_CNT];      //用于保存异常的名字
/*定义中断处理函数:在kernel.S中定义的intrXXentry只是中断处理程序的入口,最终调用的是ide_table中的处理程序*/
intr_handler idt_table[IDT_DESC_CNT];   //idt_table为函数数组,里面保持了中断处理函数的指针

/*创建中断门描述符*/
static void make_idt_desc(struct gate_desc* p_gdesc,uint8_t attr,intr_handler function){ //intr_handler是个空指针类型,仅用来表示地址 
    //中断门描述符的指针、中断描述符内的属性、中断描述符内对应的中断处理函数
    p_gdesc->func_offset_low_word = (uint32_t)function & 0x0000ffff;
    p_gdesc->selector = SELECTOR_K_CODE;
    p_gdesc->dcount = 0;
    p_gdesc->attribute = attr;
    p_gdesc->func_offset_high_word = ((uint32_t)function & 0xffff0000) >> 16;
}

/*初始化中断描述符表*/
static void idt_desc_init(void){
    int i;
    for(i = 0;i < IDT_DESC_CNT;i++){
        make_idt_desc(&idt[i],IDT_DESC_ATTR_DPL0,intr_entry_table[i]);
    }
    put_str("   idt_desc_init done\n");
}

/*初始化可编程中断控制器8259A*/
static void pic_init(void){
    /*初始化主片*/
    outb(PIC_M_CTRL,0x11);          //ICW1:边沿触发,级联8259,需要ICW4
    outb(PIC_M_DATA,0x20);          //ICW2:起始中断向量号为0x20,也就是IR[0-7]为0x20~0x27
    outb(PIC_M_DATA,0x04);          //ICW3:IR2接从片
    outb(PIC_M_DATA,0x01);          //ICW4:8086模式,正常EOI
    /*初始化从片*/
    outb(PIC_S_CTRL,0x11);          //ICW1:边沿触发,级联8259,需要ICW4
    outb(PIC_S_DATA,0x28);          //ICW2:起始中断向量号为0x28,也就是IR[8-15]为0x28~0x2F
    outb(PIC_S_DATA,0x02);          //ICW3:设置从片连接到主片的IR2引脚
    outb(PIC_S_DATA,0x01);          //ICW4:8086模式,正常EOI
    // /*打开主片上IR0,也就是目前只接受时钟产生的中断*/
    // outb(PIC_M_DATA,0xfe);
    // outb(PIC_S_DATA,0xff);
    /*测试键盘,只打开键盘中断,其他全部关闭*/
    outb(PIC_M_DATA,0xfd);
    outb(PIC_S_DATA,0xff);
    put_str("   pic_init done\n");
}

/*通用的中断处理函数,一般用在异常出现时的处理*/
static void general_intr_handler(uint8_t vec_nr){
    if(vec_nr == 0x27 || vec_nr == 0x2f){   //IRQ7和IRQ5会产生伪中断,无需处理;0x2f是从片8259A的最后一个IRQ引脚,保留项
        return;
    }
    //将光标置零,从屏幕左上角清出一片打印异常信息的区域,方便阅读
    set_cursor(0);
    int cursor_pos = 0;
    while (cursor_pos < 320)
    {
        put_char(' ');
        cursor_pos++;
    }
    set_cursor(0);      //重置光标为屏幕左上角
    put_str("!!!!!!! excetion message begin !!!!!!!\n");
    set_cursor(88);     //从第2行第8个字符开始打印
    put_str(intr_name[vec_nr]);
    if(vec_nr == 14){   //若为PageFault,将缺失的地址打印出来并悬停
        int page_fault_vaddr = 0;
        asm("movl %%cr2,%0" : "=r"(page_fault_vaddr));     //将cr2的值转存到page_fault中。cr2是存放造成page_fault的地址
        put_str("\npage fault addr is ");
        put_int(page_fault_vaddr);
    }
    put_str("!!!!!!! excetion message end !!!!!!!\n");
    //能进入中断处理程序就表示已经处在关中断情况下
    //不会出现调度进程的情况,故下面的死循环不会再被打断
    while (1);
}

/*初始化idt_table*/
static void exception_init(void){
    //将idt_table的元素都指向通用的中断处理函数,名称为unknown
    int i;
    for(i = 0;i < IDT_DESC_CNT;i++){
        idt_table[i] = general_intr_handler;    //默认为general_intr_handler,以后会由register_handler来注册具体函数
        intr_name[i] = "unknown";
    }
    intr_name[0] = "#DE Divide Error";
    intr_name[1] = "#DB Debug Exception";
    intr_name[2] = "NMI Interrupt";
    intr_name[3] = "BP Breakpoint Exception";
    intr_name[4] = "#OF Overflow Exception";
    intr_name[5] = "#BR BOUND Range Exceeded Exception";
    intr_name[6] = "#UD Invalid Opcode Exception";
    intr_name[7] = "#NM Device Not Available Exception";
    intr_name[8] = "#DF Double Fault Exception";
    intr_name[9] = "Coprocessor Segment Overrun";
    intr_name[10] = "#TS Invalid TSS Exception";
    intr_name[11] = "#NP Segment Not Present";
    intr_name[12] = "#SS Stack Fault Exception";
    intr_name[13] = "#GP General Protection Exception";
    intr_name[14] = "#PF Page-Fault Exception";
    //intr_name[15]是intel保留项,未使用
    intr_name[16] = "#MF x87 FPU Floating-Point Error";
    intr_name[17] = "#AC Alignment Check Exception";
    intr_name[18] = "#MC Machine-Check Exception";
    intr_name[19] = "#XF SIMD Floating-Point Exception";
}

/*完成所有有关中断的初始化工作*/
void idt_init(){
    put_str("idt_init start\n");
    idt_desc_init();                //初始化中断描述符表
    exception_init();
    pic_init();                     //初始化8259A
    /*加载idt*/
    // uint64_t idt_operand = ((sizeof(idt)-1) | (uint64_t)((uint32_t)idt << 16)); //书上是错的
    uint64_t idt_operand = ((sizeof(idt)-1) | ((uint64_t)(uint32_t)idt << 16)); //低16位是idt的大小,高48位是IDT的基址。因为idt是32位,左移16位后会丢失高16位,所以先转换为64位再左移
    asm volatile("lidt %0" : : "m" (idt_operand));   //加载IDT,IDT的0~15位是表界限,16~47位是表基址
    put_str("idt_init done\n");
}

/*开中断,并返回开中断前的状态*/
enum intr_status intr_enable(){
    enum intr_status old_status;
    if(INTR_ON == intr_get_status()){
        old_status = INTR_ON;
        return old_status;
    }else{
        old_status = INTR_OFF;
        asm volatile("sti");    //开中断
        return old_status;
    }
}

/*关中断,并返回关中断前的状态*/
enum intr_status intr_disable(){
    enum intr_status old_status;
    if(INTR_ON == intr_get_status()){
        old_status = INTR_ON;
        asm volatile("cli" : : : "memory"); //关中断,cli指令将IF位置0
        return old_status;
    }else{
        old_status = INTR_OFF;
        return old_status;
    }
}

/*将中断状态设置为status*/
enum intr_status intr_set_status(enum intr_status status){
    return status & INTR_ON ? intr_enable() : intr_disable();
}

/*获取当前中断状态*/
enum intr_status intr_get_status(){
    uint32_t eflags = 0;
    GET_FLAGS(eflags);
    return (EFLAGS_IF & eflags) ? INTR_ON : INTR_OFF;
}

/*在中断处理程序数组第vector_no个元素中注册安装中断处理程序function*/
void register_handler(uint8_t vector_no,intr_handler function){
    idt_table[vector_no] = function;    //idt_table数组中的函数是在进入中断后根据中断向量号调用的
}

将IDT_DESC_CNT改为了0x30,目前8259A所支持的全部外部中断。

中断屏蔽寄存器,只打开了键盘中断,即位1为0,其他位都为1,因此写入的值为0xfd。

从片上的中断屏蔽寄存器,屏蔽了从片上的所有中断,所以值为 0xff。

其实只更改了一点

#define IDT_DESC_CNT    0x30
...
static void pic_init(void){
    ...
    // 测试键盘, 只打开键盘中断, 其它全部关闭
    outb (PIC_M_DATA, 0xfd);
    outb (PIC_S_DATA, 0xff);

    put_str("    pic init done\n");
}

 

device/keyboard.c

#include "keyboard.h"
#include "print.h"
#include "interrupt.h"
#include "io.h"
#include "global.h"

#define KBD_BUF_PORT 0x60   //键盘buffer寄存器端口号为0x60

/*键盘中断处理程序*/
static void intr_keyboard_handler(void){
    /*必须要读取输出缓冲区寄存器,否则8042不在继续响应键盘中断*/
    uint8_t scancode = inb(KBD_BUF_PORT);
    put_int(scancode);
    return;
}

/*键盘初始化*/
void keyboard_init(){
    put_str("keyboard init start\n");
    register_handler(0x21,intr_keyboard_handler);
    put_str("keyboard init done\n");
}

这里简单演示键盘中断机制。

intr_keyboard_handler就是键盘中断处理程序,每收到一个中断,就调用inb(KBD_BUF_PORT)读取 8042 的输出缓冲区寄存器,打印对应的通码和断码。

函数 inb是有返回值的,它返回的是从端口读取的数据,根据ABI约定,返回值是存放在寄存器eax中的。

 

对应keyboard.h

#ifndef __DEVICE_KEYBOARD_H
#define __DEVICE_KEYBOARD_H
#include "stdint.h"
//函数声明

/*键盘初始化*/
void keyboard_init(void);
#endif

 

/kernel/init.c加上键盘初始化

#include "init.h"
#include "print.h"
#include "interrupt.h"
#include "../device/timer.h"
#include "memory.h"
#include "../thread/thread.h"
#include "console.h"
#include "keyboard.h"

/*负责初始化所有模块*/
void init_all(){
    put_str("init_all\n");
    idt_init();     //初始化中断
    mem_init();
    thread_init();
    timer_init();   //初始化PIT
    console_init(); //控制台初始化最好放在开中断之前
    keyboard_init();
}

 

main函数注释之前的线程代码。

#include "print.h"
#include "init.h"
#include "thread.h"
#include "interrupt.h"
#include "console.h"

int main(){
    put_str("\nI am kernel\n");
    init_all();
    intr_enable(); // 打开中断, 使时钟中断起作用

    while(1);
    return 0;
}

 

更新makefile文件

OBJS = $(BUILD_DIR)/main.o $(BUILD_DIR)/init.o $(BUILD_DIR)/interrupt.o \
    $(BUILD_DIR)/timer.o $(BUILD_DIR)/kernel.o $(BUILD_DIR)/print.o $(BUILD_DIR)/debug.o \
    $(BUILD_DIR)/string.o $(BUILD_DIR)/memory.o $(BUILD_DIR)/bitmap.o $(BUILD_DIR)/thread.o \
    $(BUILD_DIR)/switch.o $(BUILD_DIR)/list.o $(BUILD_DIR)/sync.o $(BUILD_DIR)/console.o  \
    $(BUILD_DIR)/keyboard.o
$(BUILD_DIR)/keyboard.o: device/keyboard.c device/keyboard.h lib/kernel/print.h lib/stdint.h kernel/interrupt.h lib/kernel/io.h \
    kernel/global.h
    $(CC) $(CFLAGS) $< -o $@

 

执行结果如下:

键盘按下松开a,输出,0x1E 为 a 键的通码,0x9E为a 键的断码。

键盘按下松开b,输出如下:

 

 

5.参考

郑钢著操作系统真象还原

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

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

暂无评论

发送评论 编辑评论

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