2016年10月19日 星期三

實例研討: 從 C++ 學習 C 高級技巧

很多人聲稱:
  • function pointer 是 C 高級技巧
  • 學習 C++ 讓我變成更好的 C 程式員。
這中間的因果關係,有沒有一個實際案例可以說明?筆者最近研究 libmodbus 發現這是一個極佳案例,很適合用來解釋上面這兩句話。專案規模不大,易於說明,無論你是 C or C++ fans 相信都能從中獲益。

libmodbus

libmodbus 是處理 Modbus protocol 的一個開源 C 程式庫,Modbus protocol 之前小弟已經寫過很多文章解釋過了,這邊就快速帶過。一言以蔽之,無論是走 serial line (Modbus RTU/ASCII) 或是 TCP (Modbus TCP),通訊方式都是採取一問一答的形式,差別只在於編碼方式,所以相較於 http 這種大部頭協議,Modbus 大概是 10 分鐘就可以講完的東西:


如上圖所示,Master 發命令給 Slave,然後等待 Slave 回應,就這麼一站一站問下去 (Command 與 Response 都帶有站號),沒什麼特別的吧?(相較於 http 還有一堆長連接與 proxy 之類的鬼東西)。本篇文章將聚焦在 Slave 的行為上。

編譯 libmodbus

還記得小弟之前寫過的文章嗎?不要傻傻的直接從 main() 開始追起,讓程式展現自我吧!
  1. ./configure
  2. make CFLAGS='-pg -g -O0 -Wall' LDLIBS+='-pg' LDFLAGS+='-static -ldl'
這裡你必須先對 unit-test-server.c 動點手腳以免 process 被殺掉後一無所獲,這在另一篇文章中也分享過了,這邊不再重複。

執行 unit-test-server

進入到 test/ 目錄,執行 ./unit-test-server rtu,如果你沒有 USB to Serial 的裝置,可以改下 ./unit-test-server tcp,接著用 modscan or modpoll 試著對 unit-test-server 通訊一下,然後用 kill -SIGUSR1 PID 殺掉 unit-test-server process。

觀察 call graph

透過 gprof + graphviz,我們得到下面這張圖:


Slave 接收 Command 的地方在 modbus_receive():


modbus_receive() 原始碼如下:
1
2
3
4
5
6
7
8
9
10
/* Receive the request from a modbus master */
int modbus_receive(modbus_t *ctx, uint8_t *req)
{
    if (ctx == NULL) {
        errno = EINVAL;
        return -1;
    }
 
    return ctx->backend->receive(ctx, req);
}
疑?可是我們上圖看到的是 _modbus_rtu_receive(),搜尋 _modbus_rtu_receive(),我們找到一段跟 backend & _modbus_rtu_receive() 有關的程式碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const modbus_backend_t _modbus_rtu_backend = {
    _MODBUS_BACKEND_TYPE_RTU,
    _MODBUS_RTU_HEADER_LENGTH,
    _MODBUS_RTU_CHECKSUM_LENGTH,
    MODBUS_RTU_MAX_ADU_LENGTH,
    _modbus_set_slave,
    _modbus_rtu_build_request_basis,
    _modbus_rtu_build_response_basis,
    _modbus_rtu_prepare_response_tid,
    _modbus_rtu_send_msg_pre,
    _modbus_rtu_send,
    _modbus_rtu_receive, //<------
    _modbus_rtu_recv,
    _modbus_rtu_check_integrity,
    _modbus_rtu_pre_check_confirmation,
    _modbus_rtu_connect,
    _modbus_rtu_close,
    _modbus_rtu_flush,
    _modbus_rtu_select,
    _modbus_rtu_free
};
接著如果您 ./unit-test-server rtu 與 ./unit-test-server tcp 都試過,你會發現進入點都是 modbus_receive(),也都呼叫了 _modbus_receive_msg():


搜尋 _modbus_tcp_receive,同樣會找到 modbus_backend_t 變數定義 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const modbus_backend_t _modbus_tcp_backend = {
    _MODBUS_BACKEND_TYPE_TCP,
    _MODBUS_TCP_HEADER_LENGTH,
    _MODBUS_TCP_CHECKSUM_LENGTH,
    MODBUS_TCP_MAX_ADU_LENGTH,
    _modbus_set_slave,
    _modbus_tcp_build_request_basis,
    _modbus_tcp_build_response_basis,
    _modbus_tcp_prepare_response_tid,
    _modbus_tcp_send_msg_pre,
    _modbus_tcp_send,
    _modbus_tcp_receive, //<------
    _modbus_tcp_recv,
    _modbus_tcp_check_integrity,
    _modbus_tcp_pre_check_confirmation,
    _modbus_tcp_connect,
    _modbus_tcp_close,
    _modbus_tcp_flush,
    _modbus_tcp_select,
    _modbus_tcp_free
};
看來 modbus_backend_t 扮演了關鍵角色,我們接著往下看。

物件導向 C 語言

先來看看 modbus_backend_t  本尊長相:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
typedef struct _modbus_backend {
    unsigned int backend_type;
    unsigned int header_length;
    unsigned int checksum_length;
    unsigned int max_adu_length;
    int (*set_slave) (modbus_t *ctx, int slave);
    int (*build_request_basis) (modbus_t *ctx, int function, int addr,
                                int nb, uint8_t *req);
    int (*build_response_basis) (sft_t *sft, uint8_t *rsp);
    int (*prepare_response_tid) (const uint8_t *req, int *req_length);
    int (*send_msg_pre) (uint8_t *req, int req_length);
    ssize_t (*send) (modbus_t *ctx, const uint8_t *req, int req_length);
    int (*receive) (modbus_t *ctx, uint8_t *req);
    ssize_t (*recv) (modbus_t *ctx, uint8_t *rsp, int rsp_length);
    int (*check_integrity) (modbus_t *ctx, uint8_t *msg,
                            const int msg_length);
    int (*pre_check_confirmation) (modbus_t *ctx, const uint8_t *req,
                                   const uint8_t *rsp, int rsp_length);
    int (*connect) (modbus_t *ctx);
    void (*close) (modbus_t *ctx);
    int (*flush) (modbus_t *ctx);
    int (*select) (modbus_t *ctx, fd_set *rset, struct timeval *tv, int msg_length);
    void (*free) (modbus_t *ctx);
} modbus_backend_t;
有沒有發現?這其實就是 C++ 中的 abstract base class,甚至可以直接翻譯成 C++ 而不會有任何違和感(精確一點的說法,下面的 class modbus_backend_t 稱為 interface class):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class modbus_backend_t
{
public:
    virtual ~modbus_backend_t();
    virtual int backend_type()=0;
    virtual int header_length()=0;
    virtual int checksum_length()=0;
    virtual int max_adu_length()=0;
    virtual int set_slave(modbus_t *ctx, int slave)=0;
    virtual int build_request_basis(modbus_t *ctx, int function, int addr,
                                int nb, uint8_t *req)=0;
    //..........
    virtual int flush(modbus_t *ctx)=0;
    virtual int select(modbus_t *ctx, fd_set *rset, struct timeval *tv, int msg_length)=0;
    virtual void free(modbus_t *ctx)=0;
};
 
class _modbus_tcp_backend : public modbus_backend_t
{
public:
    _modbus_tcp_backend();
    ~_modbus_tcp_backend();
    virtual int backend_type(){ return _MODBUS_BACKEND_TYPE_TCP; }
    virtual int header_length(){ return _MODBUS_TCP_HEADER_LENGTH; }
    virtual int checksum_length(){ return _MODBUS_TCP_CHECKSUM_LENGTH; }
    //...
}
所以 libmodbus 透過 backend 這個界面,就不需要知道需要呼叫 _modbus_rtu_receive() 還是 _modbus_tcp_receive(),此種實作手法在其他開源專案中也很常見,例如 libevent。

Template method pattern in C

還記得前面提到 _modbus_rtu_receive() 與 _modbus_tcp_receive() 都呼叫了 _modbus_receive_msg() 了嗎?這個 function 並未被嵌入到 backend 裡,這隻共用的 function 則會依據選擇的 backend(RTU/TCP) 呼叫對應的 backend function:


如果打開 _modbus_receive_msg() 瞧瞧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
int _modbus_receive_msg(modbus_t *ctx, uint8_t *msg, msg_type_t msg_type)
{
    int rc;
    fd_set rset;
    struct timeval tv;
    struct timeval *p_tv;
    int length_to_read;
    int msg_length = 0;
    _step_t step;
 
    if (ctx->debug) {
        if (msg_type == MSG_INDICATION) {
            printf("Waiting for a indication...\n");
        } else {
            printf("Waiting for a confirmation...\n");
        }
    }
 
    /* Add a file descriptor to the set */
    FD_ZERO(&rset);
    FD_SET(ctx->s, &rset);
 
    /* We need to analyse the message step by step.  At the first step, we want
     * to reach the function code because all packets contain this
     * information. */
    step = _STEP_FUNCTION;
    length_to_read = ctx->backend->header_length + 1;
 
    if (msg_type == MSG_INDICATION) {
        /* Wait for a message, we don't know when the message will be
         * received */
        p_tv = NULL;
    } else {
        tv.tv_sec = ctx->response_timeout.tv_sec;
        tv.tv_usec = ctx->response_timeout.tv_usec;
        p_tv = &tv;
    }
 
    while (length_to_read != 0) {
        rc = ctx->backend->select(ctx, &rset, p_tv, length_to_read);
        if (rc == -1) {
            _error_print(ctx, "select");
            if (ctx->error_recovery & MODBUS_ERROR_RECOVERY_LINK) {
                int saved_errno = errno;
 
                if (errno == ETIMEDOUT) {
                    _sleep_response_timeout(ctx);
                    modbus_flush(ctx);
                } else if (errno == EBADF) {
                    modbus_close(ctx);
                    modbus_connect(ctx);
                }
                errno = saved_errno;
            }
            return -1;
        }
 
        rc = ctx->backend->recv(ctx, msg + msg_length, length_to_read);
        if (rc == 0) {
            errno = ECONNRESET;
            rc = -1;
        }
 
        if (rc == -1) {
            _error_print(ctx, "read");
            if ((ctx->error_recovery & MODBUS_ERROR_RECOVERY_LINK) &&
                (errno == ECONNRESET || errno == ECONNREFUSED ||
                 errno == EBADF)) {
                int saved_errno = errno;
                modbus_close(ctx);
                modbus_connect(ctx);
                /* Could be removed by previous calls */
                errno = saved_errno;
            }
            return -1;
        }
 
        /* Display the hex code of each character received */
        if (ctx->debug) {
            int i;
            for (i=0; i < rc; i++)
                printf("<%.2X>", msg[msg_length + i]);
        }
 
        /* Sums bytes received */
        msg_length += rc;
        /* Computes remaining bytes */
        length_to_read -= rc;
 
        if (length_to_read == 0) {
            switch (step) {
            case _STEP_FUNCTION:
                /* Function code position */
                length_to_read = compute_meta_length_after_function(
                    msg[ctx->backend->header_length],
                    msg_type);
                if (length_to_read != 0) {
                    step = _STEP_META;
                    break;
                } /* else switches straight to the next step */
            case _STEP_META:
                length_to_read = compute_data_length_after_meta(
                    ctx, msg, msg_type);
                if ((msg_length + length_to_read) > (int)ctx->backend->max_adu_length) {
                    errno = EMBBADDATA;
                    _error_print(ctx, "too many data");
                    return -1;
                }
                step = _STEP_DATA;
                break;
            default:
                break;
            }
        }
 
        if (length_to_read > 0 &&
            (ctx->byte_timeout.tv_sec > 0 || ctx->byte_timeout.tv_usec > 0)) {
            /* If there is no character in the buffer, the allowed timeout
               interval between two consecutive bytes is defined by
               byte_timeout */
            tv.tv_sec = ctx->byte_timeout.tv_sec;
            tv.tv_usec = ctx->byte_timeout.tv_usec;
            p_tv = &tv;
        }
        /* else timeout isn't set again, the full response must be read before
           expiration of response timeout (for CONFIRMATION only) */
    }
 
    if (ctx->debug)
        printf("\n");
 
    return ctx->backend->check_integrity(ctx, msg, msg_length);
}
顯然 _modbus_receive_msg() 正在進行一個解封包的動作,而 Modbus TCP/RTU 封包結構其實很相近:

差別只在於 Modbus TCP Header 多了一個 header 拿掉 checksum 而已,所以 libmodbus 作者的高明之處就是 Modbus Slave 他用同一個 SOP 處理 Modbus RTU/TCP,而沒有寫出兩份 code。例如 RTU 有 checksum,但 TCP 不用,那作者怎麼處理的呢?
1
2
3
4
static int _modbus_tcp_check_integrity(modbus_t *ctx, uint8_t *msg, const int msg_length)
{
    return msg_length;
}
bypass 直接返回 packet length,而 _modbus_rtu_check_integrity 則是進行 CRC 檢查:
1
2
3
4
5
6
7
8
9
10
11
/* The check_crc16 function shall return 0 is the message is ignored and the
   message length if the CRC is valid. Otherwise it shall return -1 and set
   errno to EMBADCRC. */
static int _modbus_rtu_check_integrity(modbus_t *ctx, uint8_t *msg,
                                       const int msg_length)
{
    uint16_t crc_calculated;
    uint16_t crc_received;
    int slave = msg[0];
    //........................
}
在 C++ 裡這種手法稱為 Template method,虛擬碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class modbus_backend_t
{
     
protected:
    virtual int select(modbus_t *ctx,
                        fd_set *rset,
                        struct timeval *tv,
                        int msg_length)=0;
    virtual ssize_t recv(modbus_t *ctx, uint8_t *rsp, int rsp_length)=0;
    virtual int check_integrity(modbus_t *ctx, uint8_t *msg,
                        const int msg_length)=0;
     
public:
    virtual ~modbus_backend_t();
    //...
    //call select, recv, check_integrity of _modbus_rtu(tcp)_backend
    int _modbus_receive_msg(modbus_t *ctx, uint8_t *msg, msg_type_t msg_type)
    //...
};
 
 
class _modbus_rtu_backend : public modbus_backend_t
{
protected:   
    virtual int select(modbus_t *ctx,
                        fd_set *rset,
                        struct timeval *tv,
                        int msg_length)=0;
    virtual ssize_t recv(modbus_t *ctx, uint8_t *rsp, int rsp_length)=0;
    virtual int check_integrity(modbus_t *ctx, uint8_t *msg,
                        const int msg_length)=0;   
public:
    _modbus_tcp_backend();
    ~_modbus_tcp_backend();
}
由上述程式可以看到我們不公開 select, recv, check_integrity 三個 member function,而且是 pure virtual member function 留給 modbus_backend_t 後代去實作,而 _modbus_receive_msg() 則在 modbus_backend_t 就已經定義實作完畢(注意他不是 virtual function),_modbus_receive_msg() 內部會呼叫 select, recv, check_integrity,這些細節則留到子代實作。細節可以參考 Effective C++

尾聲

相信看到這裡,很多人跟小弟一樣恍然大悟原來真的可以用 C 實作物件導向,如果您所在的平台只有 C 可以用或者 C++ 不成熟或者您覺得 C++ 有太多陷阱,此種手法對您發展大型 C 程式一定相當有助益,歡迎留言討論 :)

7 則留言:

  1. 這種寫法第一次看到是在STM32F401C 的CubeMx 看到的,想說真的是可以全部靠C 語言寫就夠了XDDD
    只知道這種做法很棒啊,可以抽象化,不知道在C++ 裡面也是這樣實作的....

    回覆刪除
    回覆
    1. 感謝回應,小弟只挖出了這一點例子,盼更多人分享相關知識

      刪除
  2. 最近剛寫MCU modbus 找了很多資料 想請問一下 該如何實作 傳送一個陣列的值給device端 好比說array[]={0x08,0x03,0x00,0x01..} 讓device端接收並回傳的範例code 感謝!!!!

    回覆刪除
    回覆
    1. 一切盡在libmodbus中,其實關鍵就在libmodbus的_modbus_receive_msg裡,sample code範圍太大沒辦法回答喔,也不知您用何種MCU

      刪除
    2. OK 我在try看看 :)感謝你

      刪除
    3. 套句電影的台詞「刀法我給了,得多少,在你們」

      刪除
  3. 正確來說,c可以寫物件,但無物件導向的特性。
    而Design pattern是用來解決c/c++原本能做,而java這種純物件導向語言做起來很麻煩的技巧整理。

    回覆刪除