为什么在Android中使用binder通信机制?
众所周知linux中的进程通信有很多种方式,比如说管道、消息队列、socket机制等。socket我们再熟悉不过了,然而其作为一款通用的接口,通信开销大,数据传输效率低,主要用在跨网络间的进程间通信以及在本地的低速通信。消息队列和管道都是采用存储-转发模式,效率上面也有点低,因为这种模式的数据传输要经过两次的内存拷贝,先从发送方的缓存区拷贝到内核开辟的缓存区中,然后再从内核拷贝到接受方的缓存区。传统的ipc没有任何的安全措施,两个进程之间没有办法鉴别对方的身份,而在Android中,每个应用在安装后都会被分配一个uid,所以这个身份也有了保障,也更安全。为了保障安全和高效率,Android提供了一套全新的ipc通信机制也就是binder。

binder通信模型
一个进程间通信可以简单理解成为Client-server模式,binder机制在Android系统中的一个模型如下:

Client获得到server端的proxy对象。
Client通过调用proxy对象的方法向server发送请求。
proxy对象通过binder设备节点,把Client请求信息发送到linux内核空间,由binder驱动获取并发送到服务进程。
服务进程处理Client请求,通过linux内核的binder驱动把结果返回给proxy对象。
客户端收到proxy的返回结果。
当client的线程进入到binder驱动后,内核会把client的线程挂起,然后进入服务进程,一直到结果返回,Client线程才会被唤醒,所以这个过程又类似于“线程迁移”,就是一个线程进入到另外一个进程中带着结果返回。

binder组成结构
Binder驱动

binder是内核中的一个字符驱动设备位于/dev/binder。这个设备是Android系统IPC的核心部分,客户端的服务代理用来通过它向server发送请求,服务器也是通过它把处理结果返回给客户端的服务代理对象。这部分内容,在Android中通过一个IPCThreadState对象封装了对Binder驱动的操作。
Service Manager

这个东西主要用来负责管理服务。Android中提供的系统服务都要通过Service Manager注册自己,将自己添加进服务管理链表中,为客户端提供服务。而客户端如果要和特定的系统服务端通讯,就需要向Service Manager来查询和获得所需要服务。可以看出Service Manager是系统服务对象的管理中心。
服务(Server)

需要强调的是这里服务是指的是System Server,而不是SDK server,向客户端提供服务。
客户端

一般是指Android系统上面的应用程序。它可以请求Server中的服务。
代理对象 
是指在客户端应用程序中获取生成的Server代理(proxy)类对象。从应用程序角度看代理对象和本地对象没有差别,都可以调用其方法,方法都是同步的,并且返回相应的结果。
从源码分析MediaPlayerService
和其他介绍binder机制的博客文章一样,接下来我也会从MediaPlayerService的角度去介绍bidner(建议大家看下深入理解Android这本书),源码在framework\base\Media\MediaServer\Main_mediaserver.cpp中。之所以选择MPS,是因为这个service牵涉了许多重要的服务,比如说音频、摄像等。

int main(int argc, char** argv)
{
    // 打开驱动设备,将设备文件描述符映射到内存,这快内存作为数据交互的地方
    sp<ProcessState> proc(ProcessState::self());

sp<IServiceManager> sm = defaultServiceManager();

LOGI("ServiceManager: %p", sm.get());

waitBeforeAdding( String16("media.audio_flinger") );

AudioFlinger::instantiate();

waitBeforeAdding( String16("media.player") );

// 注册MediaPlayerService,
    MediaPlayerService::instantiate();

waitBeforeAdding( String16("media.camera") );

CameraService::instantiate();

waitBeforeAdding( String16("media.audio_policy") );

AudioPolicyService::instantiate();

ProcessState::self()->startThreadPool();

IPCThreadState::self()->joinThreadPool();

}

首先看下ProcessState的构造函数

ProcessState::ProcessState()
: mDriverFD(open_driver())// 开启binder
, mVMStart(MAP_FAILED) // 内存映射的起始地址
, mManagesContexts(false)
, mBinderContextCheckFunc(NULL)
, mBinderContextUserData(NULL)
, mThreadPoolStarted(false)
, mThreadPoolSeq(1)
{

......
    mVMStart = mmap(0, BINDER_VM_SIZE, PROT_READ, MAP_PRIVATE | MAP_NORESERVE, mDriverFD, 0);
    .....
}

上述ProcessState构造函数代码首先调用open_driver()方法,进入看看先:

static int open_driver()
{
    if (gSingleProcess) {
        return -1;
    }

// 打开binder设备,设备成功打开返回一个设备的文件描述符
    int fd = open("/dev/binder", O_RDWR);   
    if (fd >= 0) {
        // 改变已经打开的文件的性质。如果FD_CLOEXEC位是0,执行execve的过程中,文件保持打开。反之则关闭。
        fcntl(fd, F_SETFD, FD_CLOEXEC);
        int vers;
        #if defined(HAVE_ANDROID_OS)
        status_t result = ioctl(fd, BINDER_VERSION, &vers);
        #else
        status_t result = -1;
        errno = EPERM;
        #endif
        if (result == -1) {
            LOGE("Binder ioctl to obtain version failed: %s", strerror(errno));
            close(fd);
            fd = -1;
        }
        if (result != 0 || vers != BINDER_CURRENT_PROTOCOL_VERSION) {
            LOGE("Binder driver protocol does not match user space protocol!");
            close(fd);
            fd = -1;
        }
    #if defined(HAVE_ANDROID_OS)
    size_t maxThreads = 15;
    result = ioctl(fd, BINDER_SET_MAX_THREADS, &maxThreads);
    if (result == -1) {
        LOGE("Binder ioctl to set max threads failed: %s", strerror(errno));
    }
    #endif

} else {
        LOGW("Opening '/dev/binder' failed: %s\n", strerror(errno));
    }
    return fd;
}

open_driver是打开binder设备 /dev/binder文件,打开成功后会返回一个文件描述符,接着调用fcntl函数来改变刚才打开的设备文件的性质。接着在调用ioctl函数,ioctl是设备驱动程序中对设备的I/O通道进行管理的函数。所谓对I/O通道进行管理,就是对设备的一些特性进行控制,例如串口的传输波特率、马达的转速等等,BINDER_SET_MAX_THREADS 就是用户程序对设备的控制命令,maxThreads则是补充参数,这句话的意思就是告诉binder驱动,这个文件描述符支持的最大线程数。在构造函数中使用mmap函数将该文件描述符映射到内存中,以便使用这块内存来接受数据。构造函数中做了两件比较重要的事情:

打开了/dev/binder设备文件,获取了一个与设备文件相关联的文件描述符,这也就相当于获得了一个与内核的binder驱动有了交互的通道。
使用mmap函数将该文件描述符映射到内存中,以便使用这块内存来接受数据。
接着再回到ProcessState::self()中来看看:

sp<ProcessState> ProcessState::self()
{
    if (gProcess != NULL) return gProcess;
    AutoMutex _l(gProcessMutex);
    if (gProcess == NULL) gProcess = new ProcessState;
    return gProcess;
}
这个self仅仅是返回了ProcessState对象,ProcessState是以单例模式设计的,主要用来维护当前进程和binder设备通信时的一个状态。

有了和binder驱动的交互通道后,接下来做了什么呢?我们看下以下这段代码:

sp<IServiceManager> sm = defaultServiceManager();
defaultServiceManager的业务实现在IServiceManager.cpp中,原型如下:

sp<IServiceManager> defaultServiceManager(){
    if (gDefaultServiceManager != NULL)return gDefaultServiceManager;
    AutoMutex _l(gDefaultServiceManagerLock);
    if (gDefaultServiceManager == NULL) {
        gDefaultServiceManager = interface_cast<IServiceManager>(ProcessState::self()->getContextObject(NULL));
    }
    return gDefaultServiceManager;
}

最重要的就是这句代码了:

gDefaultServiceManager = interface_cast<IServiceManager>(ProcessState::self()->getContextObject(NULL))
1
再回到ProcessState中去看看getContextObject的实现

sp<IBinder> ProcessState::getContextObject(const sp<IBinder>& caller)
{
    if (supportsProcesses()) {
        return getStrongProxyForHandle(0);
    } else {
        return getContextObject(String16("default"), caller);
    }
}

supportsProcesses()就是说当前的设备是否支持进程,这毫无疑问,真实的设备肯定是支持的,所以接下来肯定是要走getStrongProxyForHandle(0)方法的。

sp<IBinder> ProcessState::getStrongProxyForHandle(int32_t handle)
{
    sp<IBinder> result;

AutoMutex _l(mLock);

handle_entry* e = lookupHandleLocked(handle);

if (e != NULL) {
        IBinder* b = e->binder;
        if (b == NULL || !e->refs->attemptIncWeak(this)) {
            b = new BpBinder(handle); // 创建一个BpBinder
            e->binder = b;// 给资源项填充这个binder
            if (b) e->refs = b->getWeakRefs();
            result = b;
        } else {
            // This little bit of nastyness is to allow us to add a primary
            // reference to the remote proxy when this team doesn't have one
            // but another team is sending the handle to us.
            result.force_set(b);
            e->refs->decWeak(this);
        }
    }
    // 返回了new BpBinder(0); /
    return result;
}

lookupHandleLocked函数用来查找对应的资源,一开始肯定是没有的,所以走b==null的分支,在这个分支里面使用handler==0作为参数新建了一个BpBinder,然后把这个binder赋值给了handle_entry的binder属性,最后把这个BpBinder对象返回,也就是说返回的是new BpBinder(0)。我觉得这里面应该是把handle_entry资源项和当前进程挂钩的,要不然这里为啥要把BpBinder和这个资源项绑定在一起呢。

在这里有两个比较重要的东西,就是BpBinder和BBinder,他们两个是成双成对的出现,BpBinder是客户端和服务端的代理类,而BBinder也就是BpBinder对立的一端,代表客户端要交互的目的端。换种说法讲就是BpBinder如果是客户端,那么BBinder则是服务端。

接下来gDefaultServiceManager = interface_cast(ProcessState::self()->getContextObject(NULL))就应该转变为如下的代码了:

gDefaultServiceManager = interface_cast<IServiceManager>(BpBinder)
1
这个interface_cast方法描述如下:

template<typename INTERFACE>
inline sp<INTERFACE> interface_cast(const sp<IBinder>& obj)
{
    return INTERFACE::asInterface(obj);
}

interface_cast是一个模板方法,INTERFACE代表的就是IserviceManager,里面调用的是IserviceManager.asInterface方法,而在这个方法里面,则是利用了BpBinder对象构建了一个BpManagerService对象,我们知道BpBinder和BBinder两者是一个相互通信的关系,那么为什么这里又来了个BpManagerService,BpManagerService实现了IServiceManager中的业务函数,并且BpManagerService中有个mRemote对象指向了BpBinder对象。就这样,BpManagerService实现了业务函数并且也有了和服务端进行通信的代表。接下来就分析一下服务的注册了。

在MediaPlayerService.cpp中:

void MediaPlayerService::instantiate() {
    // defaultServiceManager实际上返回的是BpManagerService,是IServiceManager的后代
    defaultServiceManager()->addService(String16("media.player"), new   MediaPlayerService());
}

defaultServiceManager()返回的就是BpManagerService,其实现了IServiceManager的业务方法,所以我们要去BpManagerService中看看addService的实现:

virtual status_t addService(const String16& name, const sp<IBinder>& service)
{
    Parcel data, reply; // 数据包
    data.writeInterfaceToken(IServiceManager::getInterfaceDescriptor());
    data.writeString16(name);
    data.writeStrongBinder(service);
    // 将数据打包后,把通信的工作交给了BpServiceManager
    // 这个remote其实就也就是mRemote,也就是BpBinder对象
    // 数据打包后,传给了BpBinder对象的transact函数,也就意味着把通信的工作交给了BpBinder对象来完成
    status_t err = remote()->transact(ADD_SERVICE_TRANSACTION, data, &reply);

// 所以addService函数其实就是一个业务层的函数,工作很简单,就是把数据打包后,再交给通信层去处理
    return err == NO_ERROR ? reply.readExceptionCode() : err;
}

Parcel我们可以理解成一个在进程间传送的数据包,data是我们要发送的数据包,而reply则是服务返回过来的数据包,数据打包后,交由remote()函数的transact方法执行,remote()方法返回的就是BpManagerService中的mRemote对象,这个对象指向的是之前的BpBinder对象,所以与服务通信的工作就交给了BpBinder对象来完成,下面前往BpBinder中去看看这个方法的实现:

status_t BpBinder::transact(uint32_t code, const Parcel& data, Parcel* reply,   uint32_t flags)
{
    // Once a binder has died, it will never come back to life.
    if (mAlive) {
        status_t status = IPCThreadState::self()->transact(mHandle, code, data, reply, flags);
        if (status == DEAD_OBJECT) mAlive = 0;
        return status;
    }

return DEAD_OBJECT;
}
1
2
3
4
5
6
7
8
9
10
11
BpBinder其实没有什么可以做的实际工作,也就是个表象,这个真实工作又交给了IPCThreadState的transact,IPCThreadState才是在进程中真正干活的伙计,前往IPCThreadState.cpp中看看:

status_t IPCThreadState::transact(int32_t handle,
                              uint32_t code, const Parcel& data,
                              Parcel* reply, uint32_t flags)
{
......

if (err == NO_ERROR) {
    LOG_ONEWAY(">>>> SEND from pid %d uid %d %s", getpid(), getuid(),
        (flags & TF_ONE_WAY) == 0 ? "READ REPLY" : "ONE WAY");
    // handle值为0,代表通信的目的端
    // BC_TRANSACTION参数: 应用程序向binder设备发送消息的消息码。
    // 而binder设备向应用程序回复消息码以BR_开头。

// 先发数据,然后等待结果返回
    // 其实在这个方法里面只是把数据写入到mOut中,并没有发出去
    err = writeTransactionData(BC_TRANSACTION, flags, handle, code, data, NULL);
}

........
if ((flags & TF_ONE_WAY) == 0) {
   ............
    #endif
    if (reply) {
        err = waitForResponse(reply);
    } else {
        Parcel fakeReply;
        err = waitForResponse(&fakeReply);
    }
    #if 0
    .........
} else {
    err = waitForResponse(NULL, NULL);
}

return err;
}

writeTransactionData看样子就只是把数据写入到一个地方了,原型如下:

status_t IPCThreadState::writeTransactionData(int32_t cmd, uint32_t binderFlags,
int32_t handle, uint32_t code, const Parcel& data, status_t* statusBuffer)
{
    binder_transaction_data tr;

//handle是0,表示目的端 
    tr.target.handle = handle;
    // 消息码
    tr.code = code;
    tr.flags = binderFlags;

const status_t err = data.errorCheck();
    if (err == NO_ERROR) {
        tr.data_size = data.ipcDataSize();
        tr.data.ptr.buffer = data.ipcData();
        tr.offsets_size = data.ipcObjectsCount()*sizeof(size_t);
        tr.data.ptr.offsets = data.ipcObjects();
    } else if (statusBuffer) {
        tr.flags |= TF_STATUS_CODE;
        *statusBuffer = err;
        tr.data_size = sizeof(status_t);
        tr.data.ptr.buffer = statusBuffer;
        tr.offsets_size = 0;
        tr.data.ptr.offsets = NULL;
    } else {
        return (mLastError = err);
    }

mOut.writeInt32(cmd);
    mOut.write(&tr, sizeof(tr));

return NO_ERROR;
}

这个方法只是直接把命令直接写到out中去,并没有把这个消息发出去,所以继续往下看waitForResponse方法,承载了发送和接受数据的工作肯定就在这里面了:

status_t IPCThreadState::waitForResponse(Parcel *reply, status_t *acquireResult)
{
int32_t cmd;
int32_t err;

while (1) {
    if ((err=talkWithDriver()) < NO_ERROR) break;
    err = mIn.errorCheck();
    if (err < NO_ERROR) break;
    if (mIn.dataAvail() == 0) continue;

cmd = mIn.readInt32();

IF_LOG_COMMANDS() {
        alog << "Processing waitForResponse Command: "
            << getReturnString(cmd) << endl;
    }

switch (cmd) {
    case BR_TRANSACTION_COMPLETE:
        if (!reply && !acquireResult) goto finish;
        break;

case BR_DEAD_REPLY:
        err = DEAD_OBJECT;
        goto finish;

case BR_FAILED_REPLY:
        err = FAILED_TRANSACTION;
        goto finish;

case BR_ACQUIRE_RESULT:
        {
            LOG_ASSERT(acquireResult != NULL, "Unexpected brACQUIRE_RESULT");
            const int32_t result = mIn.readInt32();
            if (!acquireResult) continue;
            *acquireResult = result ? NO_ERROR : INVALID_OPERATION;
        }
        goto finish;

case BR_REPLY:
        {
            binder_transaction_data tr;
            err = mIn.read(&tr, sizeof(tr));
            LOG_ASSERT(err == NO_ERROR, "Not enough command data for brREPLY");
            if (err != NO_ERROR) goto finish;

if (reply) {
                if ((tr.flags & TF_STATUS_CODE) == 0) {
                    reply->ipcSetDataReference(
                        reinterpret_cast<const uint8_t*>(tr.data.ptr.buffer),
                        tr.data_size,
                        reinterpret_cast<const size_t*>(tr.data.ptr.offsets),
                        tr.offsets_size/sizeof(size_t),
                        freeBuffer, this);
                } else {
                    err = *static_cast<const status_t*>(tr.data.ptr.buffer);
                    freeBuffer(NULL,
                        reinterpret_cast<const uint8_t*>(tr.data.ptr.buffer),
                        tr.data_size,
                        reinterpret_cast<const size_t*>(tr.data.ptr.offsets),
                        tr.offsets_size/sizeof(size_t), this);
                }
            } else {
                freeBuffer(NULL,
                    reinterpret_cast<const uint8_t*>(tr.data.ptr.buffer),
                    tr.data_size,
                    reinterpret_cast<const size_t*>(tr.data.ptr.offsets),
                    tr.offsets_size/sizeof(size_t), this);
                continue;
            }
        }
        goto finish;

default:
        err = executeCommand(cmd);
        if (err != NO_ERROR) goto finish;
        break;
    }
}

finish:
if (err != NO_ERROR) {
    if (acquireResult) *acquireResult = err;
    if (reply) reply->setError(err);
    mLastError = err;
}

return err;
}

waitForResponse承载了发送和接受数据,那么发送给binder驱动,这个过程是怎么通信的呢?看下talkWithDriver函数:

status_t IPCThreadState::talkWithDriver(bool doReceive)
{
LOG_ASSERT(mProcess->mDriverFD >= 0, "Binder driver is not opened");

// binder_write_read是与binder驱动交换数据的结构
binder_write_read bwr;

// Is the read buffer empty?
const bool needRead = mIn.dataPosition() >= mIn.dataSize();

// We don't want to write anything if we are still reading
// from data left in the input buffer and the caller
// has requested to read the next data.
const size_t outAvail = (!doReceive || needRead) ? mOut.dataSize() : 0;
// 请求命令的填充
bwr.write_size = outAvail;
bwr.write_buffer = (long unsigned int)mOut.data();

// This is what we'll read.
if (doReceive && needRead) {
    //  接受数据缓冲区信息的填充,如果以后收到数据了,就直接填在mIn中
    bwr.read_size = mIn.dataCapacity();
    bwr.read_buffer = (long unsigned int)mIn.data();
} else {
    bwr.read_size = 0;
}

IF_LOG_COMMANDS() {
    TextOutput::Bundle _b(alog);
    if (outAvail != 0) {
        alog << "Sending commands to driver: " << indent;
        const void* cmds = (const void*)bwr.write_buffer;
        const void* end = ((const uint8_t*)cmds)+bwr.write_size;
        alog << HexDump(cmds, bwr.write_size) << endl;
        while (cmds < end) cmds = printCommand(alog, cmds);
        alog << dedent;
    }
    alog << "Size of receive buffer: " << bwr.read_size
        << ", needRead: " << needRead << ", doReceive: " << doReceive << endl;
}

// Return immediately if there is nothing to do.
// 如果没有任何的数据交换,则立马返回
if ((bwr.write_size == 0) && (bwr.read_size == 0)) return NO_ERROR;

bwr.write_consumed = 0;
bwr.read_consumed = 0;
status_t err;
do {
    IF_LOG_COMMANDS() {
        alog << "About to read/write, write size = " << mOut.dataSize() << endl;
    }
#if defined(HAVE_ANDROID_OS)
    if (ioctl(mProcess->mDriverFD, BINDER_WRITE_READ, &bwr) >= 0)
        err = NO_ERROR;
    else
        err = -errno;
#else
    err = INVALID_OPERATION;
#endif
    IF_LOG_COMMANDS() {
        alog << "Finished read/write, write size = " << mOut.dataSize() << endl;
    }
} while (err == -EINTR);

IF_LOG_COMMANDS() {
    alog << "Our err: " << (void*)err << ", write consumed: "
        << bwr.write_consumed << " (of " << mOut.dataSize()
        << "), read consumed: " << bwr.read_consumed << endl;
}

if (err >= NO_ERROR) {
    if (bwr.write_consumed > 0) {
        if (bwr.write_consumed < (ssize_t)mOut.dataSize())
            mOut.remove(0, bwr.write_consumed);
        else
            mOut.setDataSize(0);
    }
    if (bwr.read_consumed > 0) {
        mIn.setDataSize(bwr.read_consumed);
        mIn.setDataPosition(0);
    }
    IF_LOG_COMMANDS() {
        TextOutput::Bundle _b(alog);
        alog << "Remaining data size: " << mOut.dataSize() << endl;
        alog << "Received commands from driver: " << indent;
        const void* cmds = mIn.data();
        const void* end = mIn.data() + mIn.dataSize();
        alog << HexDump(cmds, mIn.dataSize()) << endl;
        while (cmds < end) cmds = printReturnCommand(alog, cmds);
        alog << dedent;
    }
    return NO_ERROR;
}

return err;
}

看来这里和binder驱动设备进行通信并不是使用read/write方式,而是ioctl方式。这样就完成了binder通信了。

就分析到这里吧。有空再深入研究

从源码角度分析Android中的Binder机制的前因后果相关推荐

  1. HashMap,ArrayMap,SparseArray 源码角度分析,Android中的数据结构你该如何去选择?

    table = newTab; 可以看到当我们的table数组存储的节点值大于threshold时,会按我们的当前数组大小的两倍生成一个新的数组,并把旧数组上的数据复制到新数组上这就是我们的HashM ...

  2. 从源码角度解析Android中APK安装过程

    从源码角度解析Android中APK的安装过程 1. Android中APK简介 Android应用Apk的安装有如下四种方式: 1.1 系统应用安装 没有安装界面,在开机时自动完成 1.2 网络下载 ...

  3. 带你从源码角度分析ViewGroup中事件分发流程

    序言 这篇博文不是对事件分发机制全面的介绍,只是从源码的角度分析ACTION_DOWN.ACTION_MOVE.ACTION_UP事件在ViewGroup中的分发逻辑,了解各个事件在ViewGroup ...

  4. Android开发知识(二十三)从源码角度分析ListView的滑动复用机制

    文章目录 前言 认识RecycleBin机制 ListView的布局方式 ListView的元素创建流程 ListView滑动加载过程 前言 ListView作为一个常用的列表控件,虽然现在基本被Re ...

  5. 从源码角度分析Android系统的异常捕获机制是如何运行的

    我们在开发的时候经常会遇到各种异常,当程序遇到异常,便会将异常信息抛到LogCat中,那这个过程是怎么实现的呢? 我们以一个例子开始: import android.app.Activity; imp ...

  6. 【Android 插件化】Hook 插件化框架 ( 从源码角度分析加载资源流程 | Hook 点选择 | 资源冲突解决方案 )

    Android 插件化系列文章目录 [Android 插件化]插件化简介 ( 组件化与插件化 ) [Android 插件化]插件化原理 ( JVM 内存数据 | 类加载流程 ) [Android 插件 ...

  7. 从源码角度看Android系统SystemServer进程启动过程

    SystemServer进程是由Zygote进程fork生成,进程名为system_server,主要用于创建系统服务. 备注:本文将结合Android8.0的源码看SystemServer进程的启动 ...

  8. 从源码角度看Android系统Zygote进程启动过程

    在Android系统中,DVM.ART.应用程序进程和SystemServer进程都是由Zygote进程创建的,因此Zygote又称为"孵化器".它是通过fork的形式来创建应用程 ...

  9. Mybatis底层原理学习(二):从源码角度分析一次查询操作过程

    在阅读这篇文章之前,建议先阅读一下我之前写的两篇文章,对理解这篇文章很有帮助,特别是Mybatis新手: 写给mybatis小白的入门指南 mybatis底层原理学习(一):SqlSessionFac ...

最新文章

  1. Nginx源码分析:核心数据结构ngx_cycle_t与内存池概述
  2. C语言fgetpos()函数:获得当前文件的读写指针(转)
  3. javascript 循环语句 while、do-while、for-in、for用法区别
  4. Python--unique()与nunique()函数
  5. 让win7提速几倍的10个方法
  6. linux文件操作命令介绍(一)
  7. JAMStack-SSR/SSG 框架
  8. 在mysql中存储生日,php中计算今天是否为用户生日
  9. 60.Linux 启动过程
  10. Java Web实战篇-代码之美
  11. 被Gartner评为十大安全技术的IAST是什么
  12. 一文掌握常见常用Java集合框架
  13. c语言如何让正数带负号
  14. 股票自动委托下单html,股票怎么设置自动挂单?股票交易挂单规则
  15. (翻译)用户难以阅读全大写文本的原因
  16. Android 集成百度身份证识别
  17. 深圳国际会展中心希尔顿花园酒店亮相粤港澳大湾区核心地段
  18. 可信验证与TPCM技术
  19. ST-FOC-02-测量无刷电机参数
  20. OpenWrt使用sstp-client(1)

热门文章

  1. mikumikudance[MMD]快捷键使用
  2. 2018阿里巴巴春招面试
  3. 我相信希望,我相信明天~~
  4. vmware黑苹果安装vmware tools方法
  5. pyautogui中region参数是什么意思?
  6. java基础 流程控制语句循环流程控制控制循环语句
  7. matlab的udt,西门子PLC的UDT是干什么的?如何使用?终于讲清楚了
  8. flutter 多渠道打包 不同环境不同包名 product flavors flavorDimensions
  9. matlab高斯消去法程序代码,如何在MATLAB用高斯消去法求解线性方程组程序
  10. centos7installan安装postgresql9.6,解决重音不敏感”排序规则,以及扩展pgcrypto函数