C++手写操作系统学习笔记(二)——端口,中断和外设

1.端口

1.I/O端口

在实现gdt后,操作系统可以进入安全模式,下一步将继续讨论如何与硬件进行交流。
CPU与外设的连接由I/O接口来完成,I/O接口也叫设备控制器,主要完成通信与控制的功能。而I/O端口是设备控制器(I/O接口)中可以被CPU直接访问的寄存器,根据实际情况可以有不同的数据宽度(位数),主要包括数据寄存器,状态寄存器,控制寄存器。
I/O端口一般采用独立编址,在汇编中提供专门的指令:in/out指令 进行对端口的操作。

2.内联汇编

GCC支持在C/C++中直接嵌入汇编代码(GCC inline assembly),从而扩展C/C++的功能,有两种方法:
基本内联汇编:

__asm__ [volatile] ("assembl code")

扩展内联汇编: 使C/C++中变量作为汇编中的操作数

__asm__ [volatile] ("assembly code" : output : input )

例如:

"outb %0, %1" :: "a"(data),"Nd"(portnumber)
  • %0-9:序号占位符,对应output和input从左到右出现的次序。
  • 由于是写入端口指令,故没有output
  • a表示输入指定eax,Nd表示输入0-255的立即数
  • =a表示输出存入eax

3.端口实现

这里实际上就是使用c++对汇编中in/out指令进行面向对象封装,未加入任何其他的功能。

首先在port.h中定义端口相关的类:

#ifndef __PORT_H
#define __PORT_H

#include "types.h"

    class Port
    {
        protected:
            uint16_t portnumber;
            Port(uint16_t portnumber);
            ~Port();
    };//定义port抽象类:无需实例化只需被其他类继承。

    class Port8Bit :public Port  //公有继承:表示派生类Port8Bit的成员变量扔保持原有的状态
    {
        public:
            Port8Bit(uint16_t portnumber);
            ~Port8Bit();
            virtual void Write(uint8_t data);
            virtual uint8_t Read();//virtual:定义虚函数:这样可以在派生类中实现方法重写,这是面向对象中多态性的体现。
    };
    
    class Port8Bit_slow :public Port
    {
        public:
            Port8Bit_slow(uint16_t portnumber);
            ~Port8Bit_slow();
            virtual void Write(uint8_t data);//重写Write函数
    };


    class Port16Bit :public Port
    { ... };
    class Port32Bit :public Port
 	{ ... };//使用完全相同的方法构造16b和32b端口的函数

#endif

老规矩,在port.cpp中实现Port等类的具体方法:

#include "port.h"
#include "types.h"

Port::Port(uint16_t portnumber)
{
    this->portnumber = portnumber;//端口基类构造函数:初始化端口号
}
Port::~Port(){}
    
Port8Bit ::Port8Bit(uint16_t portnumber):Port(portnumber){}//Port8bit继承自基类Port,这里冒号表示初始化列表,是类给成员变量赋值的方法。
Port8Bit ::~Port8Bit(){}
void Port8Bit::Write(uint8_t data)
{
    __asm__ volatile("outb %0, %1" :: "a"(data),"Nd"(portnumber));
    //向端口写入一个字节
}

uint8_t Port8Bit::Read()
{
    uint8_t result;
    __asm__ volatile("inb %1, %0" :"=a"(result):"Nd"(portnumber));
    return result;
    //从端口读取一个字节并返回
}

Port8Bit_slow ::Port8Bit_slow(uint16_t portnumber):Port(portnumber){}
Port8Bit_slow ::~Port8Bit_slow(){}
void Port8Bit_slow::Write(uint8_t data)
{
    __asm__ volatile("outb %0,%1\njmp 1f\nl:jmp 1f\n1:" :: "a"(data),"Nd"(portnumber));
    //使用两次无用的jmp指令,减慢写的速度。
}

Port16Bit ::Port16Bit(uint16_t portnumber)
:Port(portnumber){}
...
Port32Bit ::Port32Bit(uint16_t portnumber)
:Port(portnumber){}
...
//使用完全相同的方法编写16b和32b端口内容,这里不在重复。
}

2.中断

1.中断介绍

中断是我们实现最终与硬件交流最后也是最关键的步骤。中断是计算机执行过程中CPU暂时停止现行程序,转去处理某些异常或特殊情况,完毕后在返回断点继续执行。通过中断源是否来自CPU内部可分为内中断(异常)和外中断。

中断
内中断(Exception)
(硬件)中断(Interruption)
故障(fault)
自陷(trap)
终止(abort)
可屏蔽中断
不可屏蔽中断

中断处理的过程大致为:

中断处理程序的内容(诸如如何解决溢出,除数为0等)是BIOS固件中的内容,不需要手动编写,实际编写的是如何进入这些程序的接口函数。

2.IDT(中断描述符表)

在计算机内部中断主要由中断描述符表实现(IDT)。IDT将每个异常或中断向量分别与它们的处理过程联系起来。与GDT和LDT表类似,IDT也是由8字节长描述符组成的一个数组。与GDT不同的是,表中第一项可以包含描述符。为了构成IDT表中的一个索引值,处理器把异常或中断的向量号*8。因为最多只有256个中断或异常向量,所以IDT无需包含多于256个描述符。IDT中可以含有少于256个描述符,因为只有可能发生的异常或中断才需要描述符。不过IDT中所有空描述符项应该设置其存在位标志为0。
处理器使用IDTR寄存器来定位IDT表的位置。 这个寄存器中含有IDT表32位的基地址和16位的长度(限长)值。IDT表基地址应该对其在8字节边界上以提高处理器的访问效率。限长值是以字节为单位的IDT表的长度。
IDTR示意图(图片来自网络):
IDTR
中断描述符(图片来自网络)

3.可编程中断控制器

外部设备产生的中断信号并不会直接通过INTR发给CPU,而是先发送给可编程中断控制器(PIC Programmable Interrupt Controller),再由中断控制器发送给CPU。可编程中断控制器是CPU与外设之间的中断处理的桥梁。 下图是x86架构下中断控制器8259a:

4.中断服务程序——接收一个中断信号

当中断发生时,CPU跳转执行中断服务程序,但C++编译器不会指定在执行中断服务程序过程中使用哪些寄存器,换句话说,在中断处理过程中保存现场的阶段涉及到寄存器操作,无法由C++执行,这时需要在汇编程序中编写中断服务程序,实现保存现场,恢复现场,中断返回等操作。 同时,和loader.s一样,核心代码仍然通过在汇编中调用C++函数实现。

interuptstubs.s

.set IRQ_BASE,0x20

.section .text

.extern __ZN16InterruptManager15handleInterruptEhj 
;这里引用外部函数InterruptManager,经过编译后名字会改变。通过反汇编指令得到其编译后的名字(由于是C++函数所以不能像之前那样使用 extern "c" (使用c编译)令其名字保持不变)
.global __ZN16InterruptManager22IgnoreInterruptRequestEv

.macro HandleException num                                      
.global __ZN16InterruptManager16handleException\num\()Ev        ;定义函数名,\num:使用变量num,\()分隔符
__ZN16InterruptManager16handleException\num\()Ev :
    movb $\num, (interruptnumber)
    jmp int_bottom
.endm

.macro HandleInterruptRequest num
.global __ZN16InterruptManager26handleInterruptRequest\num\()Ev
__ZN16InterruptManager26handleInterruptRequest\num\()Ev:
    movb $\num + IRQ_BASE, (interruptnumber)
    jmp int_bottom   ;取得中断号后,跳转到中断服务程序
.endm
;.macro:定义宏:批量声明函数,这里批量定义了中断处理函数(Interrupt)和异常(Exception)处理函数。

;批量定义中断和异常处理函数,调试时不需要都写出来。
HandleInterruptRequest 0x00
HandleInterruptRequest 0x01

int_bottom:                         ;这是一个中断服务程序
    pusha                           ;所有通用寄存器压栈
    pushl %ds
    pushl %es
    pushl %fs
    pushl %gs                       ;这是保存现场的过程

    pushl %esp                      ;pushl:压入四字节
    push (interruptnumber)          ;这里将handleInterrupt函数的两个参数:esp和interruptnumber压栈
    call __ZN16InterruptManager15handleInterruptEhj

    movl %eas, %esp   ;将函数返回值(esp)送入esp

    popl %gs
    popl %fs
    popl %es
    popl %ds
    popa                           ;恢复现场

.global __ZN16InterruptManager22IgnoreInterruptRequestEv
__ZN16InterruptManager22IgnoreInterruptRequestEv:    
    iret   ;中断服务程序的最后一条指令。IRET指令将推入堆栈的段地址和偏移地址弹出,使程序返回到原来发生中断的地方。其作用是从中断中恢复中断前的状态,

.data
    interruptnumber: .byte 0       ;初始化interruptnumber变量

下面编写中断控制的具体内容:将中断处理程序封装到中断管理的类中,定义中断描述符,IDTR,中断处理入口等函数。
interrupts.h

#ifndef __INTERRUPTS_H
#define __INTERRUPTS_H

#include "types.h"
#include "port.h"
#include "gdt.h"

    class InterruptManager
    {
    public:
        InterruptManager(GDT* gdt);
        ~InterruptManager();
        static uint32_t HandleInterrupt(uint8_t InterruptNumber, uint32_t esp);  
         //定义最重要的中断处理程序,参数:中断号和当前栈指针
    protected:
        struct GateDescriptor
        {
            uint16_t handlerAddressLowBits;
            uint16_t gdt_codeSegmentSelector;
            uint8_t reserved;
            uint8_t access;
            uint16_t handlerAddressHighhBits;
        }__attribute__((packed));
	  //定义中断门描述符(IDT表项)

        static GateDescriptor interruptDescriptorTable[256];
	   //定义IDT

        struct InterruptDescriptorTablePointer
        {
            uint16_t size;
            uint32_t base;
        }__attribute__((packed))
	//定义IDTR寄存器
	
        static void SetInterruptDescriptorTableEntry  
        {
            uint8_t InterruptNumber,
            uint16_t codeSegmentSelectorOffset,
            void (*handler)(),
            uint8_t DescriptorPrivilegeLevel,
            uint8_t DescriptorType
        };//定义获取IDT表地址函数,参数:中断号,代码地址,处理函数指针,优先级,中断类型等信息,作为IDT表入口函数。
        static void IgnoreInterruptRequest()
        static void HandleInterruptRequest0x00();
        static void HandleInterruptRequest0x01();

        Port8BitSlow picMasterCommand;
        Port8BitSlow picMasterData;
        Port8BitSlow picSlaveCommand;
        Port8BitSlow picSlaveData;
	   //定义中断控制器的四个端口
    };

#endif

interrupts.cpp

#include "interrupts.h"
#include "types.h"


void printf(char* str);
InterruptManager::GateDescriptor InterruptManager::interruptDescriptorTable[256]

void InterruptManager::SetInterruptDescriptorTableEntry
{
    uint8_t InterruptNumber,
    uint16_t codeSegmentSelectorOffset,
    void (*handler)(),
    uint8_t DescriptorPrivilegeLevel,
    uint8_t DescriptorType{
        const uint8_t IDT_DESC_PRESENT = 0x80;

        interruptDescriptorTable[InterruptNumber].handlerAddressLowBits = ((uint32_t)handler) & 0xFFFF;
        interruptDescriptorTable[InterruptNumber].handlerAddressHighhBits = (((uint32_t)handler)>>16) & 0xFFF;
        interruptDescriptorTable[InterruptNumber].gdt_codeSegmentSelector = codeSegmentSelectorOffset;
        interruptDescriptorTable[InterruptNumber].access = IDT_DESC_PRESENT | DescriptorType |((DescriptorPrivilegeLevel&3)<<5);    
        interruptDescriptorTable[InterruptNumber].reserved = 0;
        //根据handler函数,为IDT中的中断描述符赋值
    }
}


InterruptManager::InterruptManager(GDT* gdt)
:picMasterCommand(0x20),
picMasterData(0x21),
picSlaveCommand(0xA0),
picSlaveData(0xA1)
{
    uint32_t CodeSegment = gdt->CodeSegmentSelector();//gdt代码段选择子选择代码段

    const uint8_t IDT_INTERRUPT_GATE = 0xe;//中断门识别符(如上图:type为0111)

    for(uint16_t i=0;i<256;i++){
        SetInterruptDescriptorTableEntry(i, CodeSegment, &InterruptIgnore, 0, IDT_INTERRUPT_GATE);   
	}
	//IDT中所有空描述符项应该设置其存在位标志为0

	//定义中断处理函数,调试过程中不用都写出来
    SetInterruptDescriptorTableEntry(0x20, CodeSegment, &HandleInterruptRequest0x00, 0, IDT_INTERRUPT_GATE);
    SetInterruptDescriptorTableEntry(0x21, CodeSegment, &HandleInterruptRequest0x01, 0, IDT_INTERRUPT_GATE);


    picMasterCommand.Write(0x11);
    picSlaveCommand.Write(0x11);

    picMasterData.Write(0x20);
    picSlaveData.Write(0x28);

    picMasterData.Write(0x40);
    picSlaveData.Write(0x02);

    picMasterData.Write(0x01);
    picSlaveData.Write(0x01);

    picMasterData.Write(0x00);
    picSlaveData.Write(0x00);	
    //这里直接操作硬件可编程中断控制器,是8259a初始化流程

    InterruptDescriptorTablePointer idt;//IDT表指针(IDTR寄存器)
    idt.size = 256 * sizeof(GateDescriptor) -1;
    idt.base = (uint32_t)InterruptDescriptorTable;
	//初始化中断描述符表
    asm volatile("lidt %0": : "m"(idt));
	//lidt指令:将IDT表加载到idtr寄存器
}

InterruptManager::~InterruptManager(){}

void InterruptManager::Activate(){
	asm("sti");//开中断指令
}


uint32_t InterruptManager::handleInterrupt(uint8_t InterruptNumber, uint32_t esp)
{
    printf("INTERRUPTS");

    return esp;
};

最后在kernel.cpp中初始化一个中断:

	...
    GDT gdt;
    InterruptManager Interrupts(&gdt);
    Interrupts.Activate();
    while(1);
}

此时,屏幕上将会显示INTERRUPTS,证明成功运行Interrupts.Activate()。

注意: 如果虚拟机出现这种情况:

说明出现了编译器无法发现的错误(如有关位操作的问题),这时只能逐行比对全部代码。

5.获取中断并继续

我们已经成功接收到了一个来自硬件的中断,但程序就此停止了。接下来需要让C++实现获取中断后返回原有程序继续执行。解决方法是:创建InterruptHandler类以构造指向InterruptManager的静态指针,通过这个指针实现程序中断和中断返回。同时,InterruptHandler为需要获取中断的外设(鼠标键盘)提供驱动程序的接口,而无需操作InterruptManager,是对下层函数的封装。
interrupts.h

    class InterruptHandler
    {
    protected:
        uint8_t interruptNumber;
        InterruptManager* interruptManager;//指向InterruptManager的静态指针。
        InterruptHandler(uint8_t interruptNumber, InterruptManager* interruptManager);
        ~InterruptHandler();
    public:
        uint32_t HandleInterrupt(uint32_t esp);
    };
    
    class InterruptManager
    {
    friend class InterruptHandler;//使可以调用InterruptHandler类的方法。
	public:
		...
        void Activate();
        void Deactivate();
        static uint32_t handleInterrupt(uint8_t interruptNumber, uint32_t esp);//中断处理的入口
    	uint32_t DoHandleInterrupt(uint8_t interruptNumber, uint32_t esp);//真正执行中断处理的函数

    protected:
        static InterruptManager* ActiveInterruptManager; //定义正在执行的终端管理器:每次只会有一个正在执行
        InterruptHandler* handlers[256];//定义所有中断管理器(的指针)
        ...

Interrupts.cpp

InterruptHandler::InterruptHandler(uint8_t interruptNumber, InterruptManager* interruptManager)
{
    this-> interruptNumber = interruptNumber;
    this-> interruptManager = interruptManager;
    interruptManager -> handlers[interruptNumber] = this;

}
InterruptHandler::~InterruptHandler()
{
    if(interruptManager->handlers[interruptNumber]==this)
        interruptManager -> handlers[interruptNumber] = 0;
}
//构造函数:创建指向InterruptManager的类指针,同时初始化中断;
//析构函数:结束中断,将对应的中断号置0.

uint32_t InterruptHandler::HandleInterrupt(uint32_t esp)
{
    return esp;
}
...
InterruptManager* InterruptManager::ActiveInterruptManager = 0;//初始化正在活动中断为0
... ...
InterruptManager::~InterruptManager()
{
    Deactivate();
}

void InterruptManager::Activate()
{
    //if(ActiveInterruptManager !=0)
    //  ActiveInterruptManager -> Deactivate();   如果有多个IDT要先结束执行当前中断。
    ActiveInterruptManager = this;//定义正在活动的中断
    asm("sti");//开中断
}
void InterruptManager::Deactivate()
{
    if(ActiveInterruptManager ==this){
        ActiveInterruptManager = 0;
        asm("cli");//关中断
    }
}
//在开始执行和结束执行函数中分别开中断和关中断以实现多重中断

uint32_t InterruptManager::handleInterrupt(uint8_t interruptNumber, uint32_t esp)
{
    if(ActiveInterruptManager !=0)
        return ActiveInterruptManager -> DoHandleInterrupt(interruptNumber,esp);
    return esp;
}

uint32_t InterruptManager::DoHandleInterrupt(uint8_t interruptNumber, uint32_t esp)
{

    if(handlers[interruptNumber] !=0){
        esp = handlers[interruptNumber]->HandleInterrupt(esp);

    }
    else if(interruptNumber!=0x20){//忽略定时器中端0x20
        char* foo = (char*)"unhandled interrupt 0x00";
        const char* hex = "0123456789ABCDEF";
        foo[22] = hex[(interruptNumber>>4)&0x0f];
        foo[23] = hex[interruptNumber & 0x0f];
        printf(foo);//这样可以输出:unhandled interrupt 中端号
    }

    
    if(0x20 <= interruptNumber && interruptNumber < 0x30){//在此区间则为硬件中断,选择(主从)控制器进行处理
        picMasterCommand.Write(0x20);
        if(0x28 <= interruptNumber)
            picSlaveCommand.Write(0x20);
    }

    return esp;
}

3.获取键盘和鼠标

实现中断意味着我们已经拥有了与硬件交流的接口,对鼠标,键盘等硬件的操作本质上就是接收识别特定种类的中断并让我们的操作系统作出相应的操作。下面是对鼠标,键盘驱动程序的编写

1.键盘

键盘驱动直接读写 i8042 芯片,通过 i8042 间接的向键盘中的 i8048 发命令。所以对于驱动来说,直接发生联系的只有 i8042 。i8042,i8048 这样的芯片,本身就是一个小的处理器,它的内部有自己的处理器,有自己的 Ram,有自己的寄存器,等等。
i8042 有 4 个 8 bits 的寄存器,他们是 Status Register(状态寄存器),Output Buffer(输出缓冲器),Input Buffer(输入缓冲器),Control Register(控制寄存器)。使用两个 IO 端口,60h 和 64h。四个寄存器共用两个端口,在不同的场景下,有不同的含义。 (可参考微机原理)
这里省略的库文件编写,直接给出keyboard.cpp的内容:

#include "keyboard.h"

void printf(char* str);
KeyBoardDriver::KeyBoardDriver(InterruptManager* manager)
    : InterruptHandler(0x21,manager),//0x21对应来自键盘的中断向量
    dataport(0x60),
    commandport(0x64)//初始化i8042芯片
{
    while(commandport.Read() & 0x1)
        dataport.Read();

    commandport.Write(0xae);  //activate interrupts
    commandport.Write(0x20);  //get current state
    uint8_t status = (dataport.Read() | 1) & ~0x10; //将读到的state最后一位置1,并清除第5位
    commandport.Write(0x60);//set state
    dataport.Write(status);
    dataport.Write(0xf4);//激活键盘
}

KeyBoardDriver::~KeyBoardDriver(){}

uint32_t  KeyBoardDriver::HandleInterrupt(uint32_t esp)
//注意这里的HandleInterrupt为virtual类型以实现函数重写,因为他是之前HandleInterrupt函数功能的扩展:加入了处理键盘中断的功能
{
    uint8_t key = dataport.Read();

    static bool shift = false;
    switch(key){//对应键盘的通码,dataport读取的key都对应键盘的一个字符。
        case 0x02: if(shift) printf("!"); else printf("1");break;
        case 0x03: if(shift) printf("@"); else printf("2");break;
		... ...
		
        case 0x1c: printf("\n"); break;
        case 0x39: printf(" "); break;
        case 0x2a: case 0x36: shift = true; break;
        case 0xaa: case 0xb6: shift = false; break;//定义shift状态,分别对应shift按下和抬起(通码和断码)的中断号
        case 0x45: break;
        //需要注意的是,USB的Keyboard,是用了另外一套的Scan Code,如果你的键盘通过USB连接则无法获取。详情请参考USB协议。
        default:
            if(key<0x80){//原本HandleInterrupt函数的内容作为default
                char* foo = (char*)"unhandled interrupt 0x00";
                const char* hex = "0123456789ABCDEF";
                foo[22] = hex[(key>>4)&0x0f];
                foo[23] = hex[key & 0x0f];
                printf(foo);
            }
    }
    return esp;
}

2.鼠标

鼠标控制器仍然由8042芯片控制,启动过程与键盘类似,通过加上硬件偏移0x20来区分鼠标和键盘端口,同样省略mouse.h的内容:

mouse.cpp

#include "mouse.h"

MouseDriver::MouseDriver(InterruptManager* manager)
    : InterruptHandler(0x2C,manager),
    dataport(0x60),
    commandport(0x64),
    offset(0),//记录偏移量
    buttons(0),//记录是否按下鼠标
    x(40),
    y(12)  //新增全局变量记录光标的横纵坐标
{
    uint16_t* VideoMemory = (uint16_t*)0xb8000;
    VideoMemory[y*80+x] = ((VideoMemory[y*80+x]&0xf000) >> 4) | ((VideoMemory[y*80+x]&0x0f00)<<4) | (VideoMemory[y*80+x]&0x00ff);
    //屏幕每个坐标由16b寄存器表示,通过将其前四位和后四位颠倒,实现屏幕颜色颠倒。以这种方式模拟鼠标的操作。
    
    commandport.Write(0xa8);//activate interupts
    commandport.Write(0x20);//get current state
    uint8_t status = (dataport.Read() | 2) & ~0x20;
    commandport.Write(0x60);//set state
    dataport.Write(status);

    commandport.Write(0xd4);//把紧随该命令的参数发给鼠标。
    dataport.Write(0xf4);//清理output Buffer。
    dataport.Read();
}

MouseDriver::~MouseDriver(){}

void printf(const char*);

uint32_t  MouseDriver::HandleInterrupt(uint32_t esp)
{
    uint8_t status = commandport.Read();
    if(!(status&0x20)) return esp;

    buffer[offset] = dataport.Read();//记录鼠标的偏移量
    offset = (offset+1) % 3;

    if(offset == 0){

        uint16_t* VideoMemory = (uint16_t*)0xb8000;
        VideoMemory[y*80+x] = ((VideoMemory[y*80+x]&0xf000) >> 4) | ((VideoMemory[y*80+x]&0x0f00)<<4) | (VideoMemory[y*80+x]&0x00ff);
        //反转颜色
        x += buffer[1]; if(x<0) x=0; else if(x>=80) x=79;
        y -= buffer[2]; if(y<0) y=0; else if(y>=25) y=24;
		//更新光标的xy坐标
        VideoMemory[y*80+x] = ((VideoMemory[y*80+x]&0xf000) >> 4) | ((VideoMemory[y*80+x]&0x0f00)<<4) | (VideoMemory[y*80+x]&0x00ff);
		//再次反转颜色使屏幕恢复
        for(uint8_t i=0;i<3;i++){
            if((buffer[0]&(1<<i)) != (buttons&(1<<i))){
                VideoMemory[y*80+x] = ((VideoMemory[y*80+x]&0xf000) >> 4) | ((VideoMemory[y*80+x]&0x0f00)<<4) | (VideoMemory[y*80+x]&0x00ff);
               //检测鼠标是否被按下,信息存储在buffer[1]的后三位
            }
        }
        buttons = buffer[0];
    }
    return esp;
}

最后,在kernel.cpp中加载鼠标与键盘驱动:

... ...
    InterruptManager Interrupts(0x20,&gdt);
    KeyBoardDriver keyboard(&Interrupts);
    MouseDriver mouse(&Interrupts);
    Interrupts.Activate();

    while(1);
}

可以看到:可以正确接收和反馈来自键盘和鼠标的中断(鼠标操作还有一点问题)

至此,我们通过直接访问屏幕内存的方法实现了鼠标与键盘的操作。可以被视为简单的设备驱动程序(Driver),但这不是一个好的方法。我的理解是:根据I/O系统的层次结构,在驱动程序与用户层I/O间还需要有一个设备独立性软件:将鼠标移动,鼠标点击等操作作为单独的函数封装在相应的类中,只需调用特定的函数指针即可完成对应操作。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值