QNX學習 -- API之訊息傳遞

oadaaa發表於2018-10-12

大家都知道QNX是個微核心結構的作業系統,靠的是程式間通訊來實現整個系統功能的。那麼具體到寫一個程式的時候,到底這個通訊是如何完成的呢?這章就是具體介紿最底層的訊息傳遞API的。訊息傳遞是通過核心進行的,所以所謂的API,實際也就是最底層的核心呼叫了。需要指出的是,真正在QNX上寫程式的時候,很少會直接用到這些API,而是利用更高層的API,不過,知道這些底層的API對於將來理解建立在這些API上的介面,應該會有幫助的。

頻道(Channel)與連線(Connect) 
      訊息傳遞是基於伺服器與客戶端的模式來進行的,那麼客戶端怎樣才能與伺服器端通訊呢?最簡單的,當然是指定對方的程式號。要傳送的一方,將訊息加一個頭,告訴核心“把這個訊息發給pid 12345"就行了。其實這也是QNX4時候的做法。但QNX6開始完整支援POSIX執行緒後,這種方法似乎就不太適合了。如果伺服器,有兩個執行緒,分別進行不同的服務,那該怎麼辦呢?或者你會說“把這個訊息發給pid 12345 tid 3"就行了。可是,如果某一個服務,不是由單一執行緒來進行服務的,而是有一組執行緒進行的,那又怎麼辦呢?為此,QNX6抽象出了”頻道“(Channel)這個概念。一個頻道,就是一個服務的入口;至於這個頻道到底具體有多少執行緒為其服務,那都是伺服器端自己的事情。一個伺服器如果有多個服務,它也可以開多個頻道。而客戶端,在向“頻道”傳送訊息前,需要先建立連線(Connection),然後將訊息在連線上發出去。這樣同一個客戶端,如果需要,可以與同一個頻道建立多個連線。所以,大致上通訊的準備過程是這樣的:

伺服器

程式碼: 

     ChannelId = ChannelCreate(Flags);

客戶端

程式碼: 

     ConnectionId = ConnectAttach(Node, Pid, Chid, Index, Flag);

        伺服器端就不用解釋了,客戶端要建立連線的話,它需要Node,這個就是機器號。如果過網路(透明分佈處理)時這個值決定了哪一臺機器;如果客戶端與伺服器在同一臺機器裡時,這個數字是0,或者說ND_LOCAL_NODE;pid是服備器的程式號;而chid就是伺服器呼叫 ChannelCreate()後得到的頻道號了。Index與Flag以後再討論。基本上客戶端就是同"Node這臺機器裡的,Pid這個程式的,Chid頻道"做一個連線。有了連線以後,就可以進行訊息傳遞了。
連線的終止是ConnectDetach(),而頻道的結束則是ChannelDestroy()了。不過,一般伺服器都是長久存在的,不大有需要ChannelDestroy()的時候。

傳送(Send),接收(Receive)和應答(Reply) 
       QNX的訊息傳遞,與我們傳統常見的程式間通訊最大的不同,就是這是一個"同步的"訊息傳遞。一個訊息傳遞,都要經過傳送,接收和應答三個部份,所謂的 SRR過程。具體來說,客戶端在連線上"傳送"訊息,一旦傳送,客戶端會被阻塞,伺服器端會接收到訊息,進行處理,最後,將處理結果"應答"給客戶端;只有伺服器"應答"了以後,客戶端的阻塞狀態才會被解除。這種同步的過程,不但保證的客戶端與伺服器端的時序,也大大簡化了程式設計。具體用API來說,就是這樣。
伺服器

程式碼: 

ReceiveId = MsgReceive(ChannelId, ReceiveBuffer, ReceiveBufLength, &MsgInfo);
(... 檢查Buffer裡的訊息進行處理 ...)
MsgReply(RceeiveId, ReplyStatus, ReplyBuf, ReplyLen);

客戶端

程式碼: 

MsgSend(ConnectionId, SendBuf, SendLen, ReplyBuf, ReplyLen);
(... 由OS將這個執行緒掛起 ...)
(... 當伺服器MsgReply()後,OS 解除執行緒的阻塞狀態, 客戶端可以檢查自己的 ReceiveBuf 看看應答結果 ...)

        伺服器端在頻道上進行接收,處理完後應答;客戶端則是在連線上傳送,要注意在傳送的同時,客戶端還提供了接收應答用的緩衝。如果你細心的話,或許你會問,伺服器端的MsgReceive()與客戶端的MsgSend()沒有同步,會不會有問題呢?比如,如果MsgSend()時,伺服器沒有在 MsgReceive(),會出什麼事呢?答案是OS依然會把傳送執行緒掛起,傳送執行緒從執行狀態(RUNNING)轉入“傳送阻塞”狀態(SEND BLOCK),一直等到伺服器來MsgReceive()時,再將SendBuf裡的東西複製到ReceiveBuffer裡去,同時傳送執行緒的狀態變成 “應答阻塞”(REPLY BLOCK)。 
        同樣的,如果伺服器呼叫MsgReceive()時,沒有客戶端,伺服器執行緒也會被掛起,進入“接收阻塞”狀態(RECEIVE BLOCK)。 
在應答時,還可以用MsgError()來告訴傳送方有錯誤發生了。因為MsgReply()也可以返回一個狀態,或許你會問這兩者之間有什麼區別?MsgReply(rcvid, EINVAL, 0, 0);的結果是,MsgSend() 這個函式的返回值是22(EINVAL);而MsgError(rcvid, EINVAL);的結果,是MsgSend()返回-1,而errno被設為EINVAL。

資料區與iov 
        除了用線性的緩衝區進行訊息傳遞以外,為了方便使用,還提供了用iov_t來“彙集”資料。也就是說,可以一次傳送幾塊資料。好象下面的圖這樣子。雖然在客戶端藍色的Header同紅色的databuf是兩塊不相鄰的記憶體,但傳遞到伺服器端的ReceiveBuffer裡,就是連續的了。也就是說在伺服器端,要想得到原來databuf裡的資料,只需要(ReceiveBuffer + sizeof(header))就可以了。(要注意資料結構對其)
客戶端

程式碼: 

SETIOV(&iov[0], &header, sizeof(header));
SETIOV(&iov[1], databuf, datalen);
MsgSendvs(ConnectionId, iov, 2, Replybf, ReplyLen);

"header" 與 "databuf"是不連續的兩塊資料。伺服器接收後,"header""databuf"被連續地存在ReceiveBuffer裡。

程式碼: 

ReceiveId = MsgReceive(ChannelId, ReceiveBuffer, ReceiveBufLength, &MsgInfo);

header = (struct header *)ReceiveBUffer;
databuf = (char *)((char *)header + sizeof(*header));

例子 
        好了,有了以上這些基本函式(核心呼叫),我們就可以寫一個客戶端和一個伺服器端,進行最基本的通訊了。 
服務囂:這個伺服器,準務好頻道後,就從頻道上接收資訊。如果資訊是字串”Hello“的話,這個伺服器應答一個”World“字串。如果收到的信處是字串“Ni Hao", 那麼它會應答”Zhong Guo",其它任何訊息都用MsgError()回答一個錯誤。

程式碼: 

$ cat simple_server.c

// Simple server
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/neutrino.h>
int main()
{
        int chid, rcvid, status;
        char buf[128];

        if ((chid = ChannelCreate(0)) == -1) {
                perror("ChannelCreate");
                return -1;
        }

        printf("Server is ready, pid = %d, chid = %d\n", getpid(), chid);

        for (;;) {
                if ((rcvid = MsgReceive(chid, buf, sizeof(buf), NULL)) == -1) {
                        perror("MsgReceive");
                        return -1;
                }

                printf("Server: Received '%s'\n", buf);

              /* Based on what we receive, return some message */
                if (strcmp(buf, "Hello") == 0) {
                        MsgReply(rcvid, 0, "World", strlen("World") + 1);
                } else if (strcmp(buf, "Ni Hao") == 0) {
                        MsgReply(rcvid, 0, "Zhong Guo", strlen("Zhong Guo") + 1);
                } else {
                        MsgError(rcvid, EINVAL);
                }
        }

        ChannelDestroy(chid);
        return 0;
}

客戶端:客戶端通過從命令列得到的伺服器的程式號與頻道號,與伺服器建立連線。然後向伺服器傳送三遍"Hello"和”Ni Hao",並檢查返回值。最後發一個“unknown"看是不是MsgSend()會得到一個出錯返回。

程式碼: 

$ cat simple_client.c

//simple client
#include <stdio.h>
#include <string.h>
#include <sys/neutrino.h>

int main(int argc, char **argv)
{
        pid_t spid;
        int chid, coid, i;
        char buf[128];

        if (argc < 3) {
                fprintf(stderr, "Usage: simple_client <pid> <chid>\n");
                return -1;
        }

        spid = atoi(argv[1]);
        chid = atoi(argv[2]);

        if ((coid = ConnectAttach(0, spid, chid, 0, 0)) == -1) {
                perror("ConnectAttach");
                return -1;
        }

        /* sent 3 pairs of "Hello" and "Ni Hao" */
        for (i = 0; i < 3; i++) {
                sprintf(buf, "Hello");
                printf("client: sent '%s'\n", buf);
                if (MsgSend(coid, buf, strlen(buf) + 1, buf, sizeof(buf)) != 0) {
                        perror("MsgSend");
                        return -1;
                }
                printf("client: returned '%s'\n", buf);

                sprintf(buf, "Ni Hao");
                printf("client: sent '%s'\n", buf);
                if (MsgSend(coid, buf, strlen(buf) + 1, buf, sizeof(buf)) != 0) {
                        perror("MsgSend");
                        return -1;
                }
                printf("client: returned '%s'\n", buf);
        }

        /* sent a bad message, see if we get an error */
        sprintf(buf, "Unknown");
        printf("client: sent '%s'\n", buf);
        if (MsgSend(coid, buf, strlen(buf) + 1, buf, sizeof(buf)) != 0) {
                perror("MsgSend");
                return -1;
        }

        ConnectDetach(coid);

        return 0;
}

分別編譯後的執行結果是這樣的:

伺服器:

程式碼: 

$ ./simple_server
Server is ready, pid = 36409378, chid = 2
Server: Received 'Hello'
Server: Received 'Ni Hao'
Server: Received 'Hello'
Server: Received 'Ni Hao'
Server: Received 'Hello'
Server: Received 'Ni Hao'
Server: Received 'Unknown'
Server: Received ''

客戶端:

程式碼: 

$ ./simple_client 36409378 2
client: sent 'Hello'
client: returned 'World'
client: sent 'Ni Hao'
client: returned 'Zhong Guo'
client: sent 'Hello'
client: returned 'World'
client: sent 'Ni Hao'
client: returned 'Zhong Guo'
client: sent 'Hello'
client: returned 'World'
client: sent 'Ni Hao'
client: returned 'Zhong Guo'
client: sent 'Unknown'
MsgSend: Invalid argument

 

可變訊息長度 
        從上面的程式也可以看出來,訊息傳遞的實質是把資料從一個緩衝,複製到(另一個程式的)另一個緩衝裡去。問題是,如何確定緩衝的大小呢?上述的例子裡,伺服器端用了一個128位元組的緩衝,萬一客戶端傳送一個比如說512位元組的訊息,是不是訊息傳遞就會出錯了呢?
答案是,傳遞依然成功,但是,只有SendBuffer的最初的128個位元組的資料會被複制。設計思想是,伺服器必須發現這樣的情形,並設法取得完整的資料。 
在MsgRecieve()時,第四個引數是一個 struct _msg_info。核心會在進行訊息傳遞的同時,填充這個結構,從而告訴讓你得到一些資訊。在這個結構中,"msglen"告訴你這次訊息傳遞你實際收到了多少位元組(在我們的例子裡,就是128),而"srcmsglen"則告訴你傳送方的實際Buffer會有多大(在我們的例子裡,是512)。通過比較這兩個值,伺服器端就可以判斷有沒有收到全部資料。
        一旦伺服器知道了還有更多的資料沒有收到,那該怎麼辦呢?QNX提供了 MsgRead()這個特殊函式。伺服器端可以用這個函式,從傳送緩衝中“讀取”資料。MsgRead()基本上就是告訴核心,從傳送緩衝的某個指定偏移開始,讀取一定長的資料回來。所以伺服器端這部份的程式碼基本上是這樣的。

程式碼: 

int rcvid;
struct _msg_info info;
char buf[128], *totalmsg;

...

rcvid = MsgReceive(chid, buf, 128, &info);
...
if (info->srcmsglen > info->msglen) {
    totalmsg = malloc(info->srcmsglen);
    if (!totalmsg) {
        MsgError(rcvid, ENOMEM);
        continue;
    } 
    memcpy(totalmsg, buf , 128);
    if (MsgRead(rcvid, &totalmsg[128], 128, info->srcmsglen - info->msglen) == -1) {
        MsgError(rcvid, EINVAL);
        continue;
    }
} else {
    totalmsg = buf;
}

/* Now totalmsg point to a full message, don't forget to free() it later on,
 * if totalmsg is malloc()'d here
 */

       你或者會問,為什麼訊息接收都已經結束了,伺服器端還能去讀取客戶端的資料?這是因為從一開始我們就提到的,QNX的訊息傳遞是“同步”的。還記得嗎?在伺服器端“應答”之前,客戶端是被阻塞的;也說是說客戶端的傳送緩衝會一直保留在那裡,不會變化。(另外再開個執行緒去把這個緩衝搞亂甚至free掉?當然可以。不過,這是你客戶端程式的BUG了)

        與此相近的,有的時候,伺服器需要返回大量的資料給客戶端(比如說1M)。伺服器不希望 malloc(1024 * 1024),然後MsgReply(),然後再free()。(在嵌入式程式裡,經常地進行malloc()/free()不是一個很好的習慣)那麼伺服器也可以用一個小的定長緩衝,比方說16K,然後把資料“一部份一部份地寫回”客戶端的應答緩衝裡。好象下面的樣子。要記得最後還是要做一個 MsgReply() 以讓客戶端繼續執行。

程式碼: 

char *buf[16 * 1024];
unsigned offset;

    for  (offset = 0; offset < 1024 * 1024; offset += 16 * 1024) {
        /* moving data into buffer */
        MsgWrite(rcvid, buffer, 16 * 1024, offset);
    }
    /* 1MB returned, Reply() to let client go */
    MsgReply(rcvid, 0, 0, 0);

例項 
        以下是QNX的C庫中的read()和write()函式實裝,有了前面的基礎,應該很好理解了。先不管fd是如何得到的,只要理解fd就是 ConnectAttach()返加的連線號就可以了。雖然read()是從伺服器取得資料,而write()是向伺服器輸出資料,但實質上,它們都是向伺服器提出一個請求,由伺服器來應答。而對於write()來說,這是一個io_write_t,一個MsgWritev()把請求與要傳遞的資料一起發給伺服器;而對於read()來說,請求被封裝在 io_read_t 裡,MsgSend()把這請求傳給伺服器,read()的結果緩衝,則做為應答緩衝,由伺服器MsgReply()時填入。
read():

#include <unistd.h>
#include <sys/iomsg.h>
ssize_t read(int fd, void *buff, size_t nbytes) {
   io_read_t   msg;
   msg.i.type = _IO_READ;
   msg.i.combine_len = sizeof msg.i;
   msg.i.nbytes = nbytes;
   msg.i.xtype = _IO_XTYPE_NONE;
   msg.i.zero = 0;
   return MsgSend(fd, &msg.i, sizeof msg.i, buff, nbytes);   
}

#include <unistd.h>
#include <sys/iomsg.h>

ssize_t write(int fd, const void *buff, size_t nbytes) {
   io_write_t   msg;
   iov_t   iov[2];

   msg.i.type = _IO_WRITE;
   msg.i.combine_len = sizeof msg.i;
   msg.i.xtype = _IO_XTYPE_NONE;
   msg.i.nbytes = nbytes;
   msg.i.zero = 0;
   SETIOV(iov + 0, &msg.i, sizeof msg.i);
   SETIOV(iov + 1, buff, nbytes);
   return MsgSendv(fd, iov, 2, 0, 0);
}

伺服器端應該是怎樣進行處理的?想想MsgRead()/MsgWrite(),你應該不難想像伺服器端是如何工作的吧。

脈衝(Pulse) 
       脈衝其實更像一個短訊息,也是在“連線”上傳送的。脈衝最大的特點是它是非同步的。傳送方不必要等接收方應答,直接可以繼續執行。但是,這種非同步性也給脈衝帶來了限制。脈衝能攜帶的資料量有限,只有一個8位的"code"域用來區分不同的脈衝,和一個32位的“value"域來攜帶資料。脈衝最主要的用途就是用來進行“通知”(Notification)。不僅是使用者程式,核心也會生成傳送特殊的“系統脈衝”到使用者程式,以通知某一特殊情況的發生。
脈衝的接收比較簡單,如果你知道頻道上不會有別的訊息,只有脈衝的話,可以用MsgReceivePulse()來只接收脈衝;如果頻道既可以接收訊息,也可以接收脈衝時,就直接用MsgReceive(),只要確保接收緩衝(ReveiveBuf)至少可以容下一個脈衝(sizeof struct _pulse)就可以了。在後一種情況下,如果MsgReceive()返回的rcvid是0,就代表接收到了一個脈衝,反之,則收到了一個訊息。所以,一個既接收脈衝,又接收訊息的伺服器,可以是這樣的。

union {
    struct _pulse pulse;
    msg_header   header;
} msgs;
if ((rcvid = MsgReceive(chid, &msgs, sizeof(msgs), &info)) == -1) {
    perror("MsgReceive");
    continue;
}
if (rcvid == 0) {
    process_pulse(&msgs, &info);
} else {
    process_message(&msgs, &info);
}
       脈衝的傳送,最直接的就是MsgSendPulse()。不過,這個函式通常只在一個程式中,用在一個執行緒要通知另一個執行緒的情形。在跨程式的時候,通常不會用到這個函式,而是用到下面將要提到的 MsgDeliverEvent()。
與訊息傳遞相比,訊息傳遞永遠是在程式間進行的。也就是說,不會有一個程式向核心傳送資料的情形。而脈衝就不一樣,除了使用者程式間可以發脈衝以外,核心也會向使用者程式傳送“系統脈衝”來通知某一事件的發生。

訊息傳遞的方向與MsgDeliverEvent() 
       從一開始就提到,QNX的訊息傳遞是客戶、伺服器型的。也就是說,總是由客戶端向伺服器端傳送請求,等待被回覆的。但在現實情況中,客戶端與伺服器端並不是很容易區分開來的。有的伺服器端為了處理客戶端的請求,本身就需要向別的伺服器傳送訊息;有的客戶端需要從不同的伺服器那裡得到服務,而不能阻塞在某一特定的伺服器上;還有的時候,兩個程式間的資料是互相流動的,這應該怎麼辦呢?
也許有人認為,兩個程式互為通訊就可以了。每個程式都建立自己的頻道,然後都與對方的頻道建一個連線就好了;這樣,需要的時候,就可以直接通過連線向對方傳送訊息了。就好象管道(pipe)或是socketpair一樣。請注意,這種設計在QNX的訊息傳遞中是應該避免的。因為很容易就造成死鎖。一個常見的情形是這樣的。
程式A:MsgSend() 到程式B 
程式B:MsgReceive()接收到訊息 
程式B:處理訊息,然後MsgSend()給程式A 
因為程式A正在阻塞狀態中,無法接收並處理B的請求;所以A會在STATE_REPLY裡,而B則會因MsgSend()而進入STATE_SEND,兩個程式就互為死鎖住了。當然,如果A和B都使用多執行緒,專門用一個執行緒來MsgReceive(),這個情形或許可以避免;但你要保證 MsgReceive()的執行緒不會去MsgSend(),否則一樣會死鎖。在程式簡單的時候或許你還有控制,如果程式變得複雜,又或者你寫的只是一個程式庫,別人怎麼來用你完全沒有控制,那麼最好還是不要用這種設計。
在QNX中,正確的方法是這樣的。 
客戶端: 準備一個“通知事件”(Notification Event),並把這個事件用MsgSend()發給伺服器端,意思是:“如果xxx情況發生的話,請用這個事件通知我”。
伺服器: 收到這個訊息後,記錄下當時的rcvid,和傳過來的事件,然後應答“好的,知道了”。 
客戶端: 因為有了伺服器的應答,客戶端不再阻塞,可以去做別的事 
伺服器: 在某個時刻,客戶端所要求的“xxx情況”滿足了,伺服器呼叫 MsgDeliverEvent(rcvid, event);以通知客戶端 
客戶端: 收到通知,再用MsgSend()發關“xxx 情況的資料在哪裡?” 
伺服器: 用MsgReply()把資料返回給客戶端 
具體的例子,可以參考MsgDeliverEvent()的文件說明。

路徑名(Path Name)
       現在來回想一下我們最初的例子,客戶端與伺服器是怎樣取得連線的?客戶端需要伺服器的 nd, pid, chid,才能與伺服器正確地建立連線。在我們的例子裡,我們是讓伺服器顯示這幾個數,然後在客戶端的啟動時,通過命令列裡傳給客戶端。但是,在一個現實的系統裡,程式不斷地啟動、終止;伺服器與客戶端的起動過程也無法控制,這種方法顯然是行不通的。
QNX的解決辦法,是把“路徑名”與上述的“服務頻道”概念巧妙地結合起來。讓伺服器程式可以註冊一個路徑名,與服務頻道的nd, pid, chid關聯起來。這樣,客戶端就不需要知道伺服器的nd, pid, chid,而只要請求連線版務器路徑名就可以了。具體來說 name_attach()就是用來建立一個頻道,併為頻道註冊一個名字的;而name_open()則是用來連線註冊過的伺服器頻道;具體的例子,可以在name_attach()的文件裡找到,這裡就不再重複了。

相關文章