C語言宏的用法詳解


1、簡介

宏在C語言中是一段有名稱的代碼片段。無論何時使用到這個宏的時候,宏的內容都會被這段代碼替換掉。主要有兩種宏,他們的區別主要是在使用上面,一種是在使用時類似於數據對象稱爲Object-like,另一種在使用時類似於函數調用稱爲Function-like。在C語言使用#define來定義宏
你可以將任意的有效的標識符定義爲宏,設置C語言的關鍵字也可以。但是在C語言中defined不可以作爲宏的名稱。在C++中以下的關鍵字也不可以作爲宏的名稱and,and_eq,bitand,bitor,compl,not,not_eq,or,or_eq,xor,xor_eq

2、兩種宏的類型

2.1 Object-like宏

Object-like宏,可以比較簡單的進行代碼段的替換。這種方式最常用做表示常量數字。例如:

#define BUFFER_SIZE 1024

使用該宏的時候就可以用來替換數字。

foo = (char *) malloc (BUFFER_SIZE);

預處理器將會把該宏替換爲對應的數字,如下所示。

foo = (char *) malloc (1024);

按照慣例,宏一般都寫作大寫字母。

多行的宏

宏結束於#define的行尾,如果有必要的話,可以在末尾添加反斜槓來將宏定義成多行。

#define NUMBERS 1, \
                2, \
                3
int x[] = { NUMBERS };
//→ int x[] = { 1, 2, 3 };

多次宏替換

如果宏定義的代碼段依然是宏的話,預處理器會繼續進行宏替換的操作。

#define TABLESIZE BUFSIZE
#define BUFSIZE 1024
TABLESIZE
//→ BUFSIZE
//→ 1024

最終TABLESIZE會被替換成1024

2.2 Function-like宏

宏還可以被定義成下面的形式,使用該宏的時候,類似於調用函數,這類宏的定義中,宏的名稱後面緊跟一堆括號(與括號之間不能有空格)。

#define lang_init()  c_init()
lang_init()
//→ c_init()

調用該類宏的時候,也必須跟一個括號,如果不跟括號的話,會顯示語法錯誤。

3 宏的參數

Function-like宏可以接受參數,類似於真正的函數一樣。參數必須是有效的C語言標識符,使用逗號隔開

#define min(X, Y)  ((X) < (Y) ? (X) : (Y))
  x = min(a, b);          //→  x = ((a) < (b) ? (a) : (b));
  y = min(1, 2);          //→  y = ((1) < (2) ? (1) : (2));
  z = min(a + 28, *p);    //→  z = ((a + 28) < (*p) ? (a + 28) : (*p));

在上面的例子中,x = min(a, b)調用宏的時候,將入參a,b替換到形參X, Y在宏內的位置,就變成了x = ((a) < (b) ? (a) : (b))

4 字符串化

字符串化指的是,可以在宏的參數前面加入#,使入參變成字符串。
例如:

#include <stdio.h>
#define str(expr) printf("%s\r\n", #expr)

int main()
{
    str(abc);
    str(12345);
    return 0;
}

這裏運行代碼會打印:

abc
12345

str宏的入參,都變成了字符串打印了出來。

5 連接符號

在宏中,可以使用兩個#將兩個符號連接成一個符號。

#include <stdio.h>
#define A1 printf("print A1\r\n")
#define A2 printf("print A2\r\n")
#define A(NAME) A##NAME
int main()
{
    A(1);
    return 0;
}

這裏會打印

print A1

在該例子中,調用宏A(1)時,NAME爲1。A##NAME這個符號連接,即將A和1連接成了一個符號A1,然後執行宏A1的內容。最終打印出來了print A1

6、 可變參數

定義宏可以接受可變數量的參數,類似於定義函數一樣。如下就是一個例子

#include <stdio.h>
#define myprintf(...) fprintf (stderr, __VA_ARGS__)
int main()
{
    myprintf("1234\r\n");
    return 0;
}

這裏會輸出

1234

這種形式的宏,會把…的代表的參數擴展到後面的__VA_ARGS__中。在該例子中,就會擴展爲fprintf(stderr, "1234\r\n")
如果你的參數比較複雜,上面的myprintf還可以定義爲如下的形式,用自定義的名稱args來表示參數的含義:

#define myprintf(args...) fprintf (stderr, args)

7 預定義宏

標準預定義宏

標準的預定義宏都是用雙下劃線開頭和結尾,例如__FILE____LINE__,表示文件的名稱和該行代碼的行號。

#include <stdio.h>

int main()
{
    printf("FILE:%s,LINE:%d\r\n",__FILE__, __LINE__);
    printf("DATA:%s\r\n",__DATE__);
    printf("TIME:%s\r\n",__TIME__);
    printf("STDC:%d\r\n",__STDC__);
    printf("STDC_VERSION:%d\r\n",__STDC_VERSION__);
    printf("STDC_HOSTED:%d\r\n",__STDC_HOSTED__);
#ifdef __cplusplus
    printf("cplusplus:%d\r\n", __cplusplus);    
#else
    printf("complied by c\r\n");    
#endif
    
    return 0;
}

輸出如下

FILE:macro.c,LINE:5
DATA:Jan 13 2019
TIME:21:41:14
STDC:1
STDC_VERSION:201112
STDC_HOSTED:1
complied by c

本文件名爲macro.c,並且該行代碼爲第5行。
__DATA__表示當前的日期
__TIME__表示當前的時間
__STDC__在正常的操作中,此宏爲1,表示編譯器符合ISO C標準
__STDC_VERSION__表示ISO C的版本
__STDC_HOSTED__如果值爲1的話,表示目標環境有完成的標準C庫
__cplusplus如果該宏被定義了,表示是被C++編譯器編譯的

常見的預定義宏

該節中的宏是GNU C編譯器的擴展實現。

#include <stdio.h>

int main()
{
    printf("__COUNTER_%d\r\n", __COUNTER__);
    printf("__COUNTER_%d\r\n", __COUNTER__);    
    printf("__GNUC__:%d\r\n",__GNUC__);
    printf("__GNUC_MINOR__:%d\r\n",__GNUC_MINOR__);
    printf("__GNUC_PATCHLEVEL__:%d\r\n",__GNUC_PATCHLEVEL__);
    #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
        printf("little endian\r\n");
    #elif __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
        printf("big endian\r\n");
    #elif __BYTE_ORDER__ == __ORDER_PDP_ENDIAN__
        printf('pdp endian\r\n')
    #endif
    #if __LP64__ == 1
        printf("64bit env\r\n");
    #else
        printf("other bit env\r\n");
    #endif
    return 0;
}

輸出

__COUNTER_0
__COUNTER_1
__GNUC__:7
__GNUC_MINOR__:3
__GNUC_PATCHLEVEL__:0
little endian
64bit env


__COUNTER_:是生成一個唯一的數字。
__GNUC____GNUC_MINOR____GNUC_PATCHLEVEL__確定了你的GCC版本號。例如我的環境就是7.3.0
__BYTE_ORDER__表示當前環境的字節序
__LP64__ 表示當前環境是不是64位,如果該值爲1,則環境爲64位環境
更多GNU C編譯器的預定義宏可以 點此連接查看

系統特定的預定義宏

系統特定的預定義宏,在不同的操作系統和CPU上面,呈現的結果可能會有所不同。例如我的環境是Linux X86_64平臺。執行下面的代碼

#include <stdio.h>

int main()
{
    printf("__unix_:%d\r\n", __unix__);
    printf("__x86_64__:%d\r\n", __x86_64__);
    return 0;
}

輸出結果是:

__unix_:1
__x86_64__:1

如果是其他操作系統的CPU平臺的話,執行的結果會有所不同。

C++的命名操作符

在第一節就說過C++ 中有and,and_eq,bitand,bitor,compl,not,not_eq,or,or_eq,xor,xor_eq這些命名不可以用作宏的名稱。是因爲在C++ 中系統將這些關鍵字預定義成了操作符。

命名操作符 符號
and &&
and_eq &=
bitand &
bitor |
compl ~
not !
not_eq !=
or ||
or_eq |=
xor ^
xor_eq ^=

所以在C++ 中,你可以使用命名操作符來代替這些符號。例如:

#include <iostream>
using namespace std;
int main()
{
    int a = 10;
    int b = 20;
    int c = a bitor b; // a | b
    int d = a bitand b; //a & b
    cout << "c = " << c << endl;
    cout << "d = " << d << endl;

    if ( true and (a > b))
	    cout << "true" << endl;
    else
	    cout << "false" << endl;
        
    return 0;
}

輸出:

c = 30
d = 0
false

8、取消宏定義與重複宏定義

取消宏定義

使用#undef可以將已經定義的宏取消掉

#define BUFSIZE 1020
#undef BUFSIZE

如果在#undef之後再使用BUFSIZE就會報錯,沒有定義BUFSIZE

重複宏定義

如果兩個宏定義之間,僅有空格和註釋不同的話,兩個宏定義還是同一個宏定義。
例如:

#define FOUR (2 + 2)
#define FOUR         (2    +    2)
#define FOUR (2 /* two */ + 2)

這三個宏定義實際上是相同的,不算是重複定義。
而下面的宏定義則是不同的,編譯器會給出宏重複定義的警告。也只有最後一個宏纔會生效

#define FOUR (2 + 2)
#define FOUR ( 2+2 )
#define FOUR (2 * 2)
#define FOUR(score,and,seven,years,ago) (2 + 2)

9、幾個常見的使用場景

替代魔法數字

這個可能是在C語言中非常常見的一種用法了,就是使用宏來替代一個魔法的數字,增加代碼可讀性。

#include <stdio.h>
#include <stdlib.h>

#define BUFSIZE 1024
int main()
{
    char *buf = (char *)malloc(BUFSIZE);
    free(buf);
    return 0;
}

LOG日誌與do{}while(0)

#include <stdio.h>
#include <stdlib.h>
#define BUFSIZE 1024
#define LOG(str) \
do \
{\
    fprintf(stderr, "[%s:%d %s %s]:%s\r\n",  __FILE__, __LINE__, __DATE__, __TIME__, str); \
}while(0)
int main()
{
    char *buf = (char *)malloc(BUFSIZE);
    LOG("malloc for buf");
    free(buf);
    return 0;
}

輸出內容:

[macro.c:12 Jan 13 2019 22:38:33]:malloc for buf

這裏定義了LOG宏,可以打印日誌,輸出當前的代碼文件和行數,以及時間和用戶定義的內容。自行擴展可以增加更豐富的內容。
這裏使用了一個do{} while(0)來包含宏的內容。看似這個do() while(0)沒有什麼意義。但是這是一個編寫宏內多行代碼段的好習慣。

  • 使用do{}while(0)包含的話,可以作爲一個獨立的block,進行變量定義等一些複雜的操作
  • 該用法主要是防止在使用宏的過程中出現錯誤。
    例如
#define foo() \
    fun1(); \
    fun2()
if (a > 10)
    foo()

在這種情況下,if後面沒有跟大括號,我們foo宏裏面定義的是兩個語句,其中fun2是在if條件判斷之外的。這樣就不符合我們的預期了。

如果使用大括號來避免上面的錯誤,還會出現下面的錯誤:

#include <stdio.h>
#include <stdlib.h>
#define add(x, y) {x += 1; y += 2;}

int main()
{
    int x = 10;
    int y = 20;
    if (x > y)
        add(x, y);
    else
        ;

    return 0;
}

這裏在add(x, y)之後有個分號。會造成else匹配不到if編譯錯誤。所以爲了防止發生這些錯誤,可以使用do{}while(0)將函數體包含。

Linux內核中offsetof

在Linux的內核代碼中,大量的使用到了offsetof這個宏,該宏的作用就是計算出一個結構體中的變量的偏移值是多少。

#include <stdio.h>
#include <stdlib.h>
#define offsetof(TYPE, MEMBER) ((int) &((TYPE *)0)->MEMBER)
typedef struct myStructTag
{
    int a;
    double b;
    float c;
    char szStr[20];
    long int l;
}myStruct;
int main()
{
    printf("%d\r\n", offsetof(myStruct, a));
    printf("%d\r\n", offsetof(myStruct, b));
    printf("%d\r\n", offsetof(myStruct, c));
    printf("%d\r\n", offsetof(myStruct, szStr));
    printf("%d\r\n", offsetof(myStruct, l));
}

輸出結果:

0
8
16
20
40

該宏的入參第一項TYPE爲結構體的類型,第二項MEMBER爲結構體中的變量名稱。該宏將0強轉爲TYPE *類型的指針,然後獲取該結構體指針指向具體成員的地址。因爲結構體指針的地址爲0,所以取地址得到的成員地址就是以0爲基址的偏移值。
有了該宏,我們就可以通過任意一個結構體成員的地址來得到結構體指針的地址了。

Linux內核中container_of宏

該宏的作用就是通過結構體任意成員的地址來獲取結構體指針。該宏需要藉助上一節的offsetof。
下面是使用該宏的代碼:

#include <stdio.h>
#include <stdlib.h>
#define offsetof(TYPE, MEMBER) ((int) &((TYPE *)0)->MEMBER)
#define container_of(ptr, type, member) ({ \
    const typeof(((type *)0)->member) * __mptr = (ptr); \
    (type *)((char *)__mptr - offsetof(type, member)); })

typedef struct myStructTag
{
    int a;
    double b;
    float c;
    char szStr[20];
    long int l;
}myStruct;
int main()
{
    myStruct *p = (myStruct *)malloc(sizeof(myStruct));
    printf("base ptr=%p\r\n", p);
    printf("base ptr by l=%p\r\n", container_of(&p->l, myStruct, l));
}

輸出內容:

base ptr=0x55cc10d66260
base ptr by l=0x55cc10d66260

可以看出,通過container_of算出來的基址和直接打印的p的地址是相同的。Linux內核中很多基礎的抽象數據結構,例如雙線鏈表等,都大量使用到了container_of這個宏。有了這個宏,我們就可以寫出來數據無關的抽象數據結構,例如我們可以寫一個沒有數據域的雙向鏈表。

struct list_head {
    struct list_head *next, *prev;
};

實現的時候,我們只需要關係鏈表的操作即可,完全沒有任何數據域的干擾。而在使用時,我們只需要把鏈表節點定義爲具體數據結構中的一個節點即可。

struct person 
{ 
    int age; 
    char name[20];
    struct list_head list; 
};

插入和刪除操作僅需要操作鏈表的節點,而通過container_of這個宏,我們完全可以通過鏈表的指針去獲取到整個數據結構的首地址。這樣就把數據結構抽象了,和具體的數據完全剝離。

VPP中節點註冊的例子

VLIB_REGISTER_NODE宏的定義

首先看一段VPP中節點註冊的宏的定義:

#define VLIB_REGISTER_NODE(x,...)                                       \
    __VA_ARGS__ vlib_node_registration_t x;                             \
static void __vlib_add_node_registration_##x (void)                     \
    __attribute__((__constructor__)) ;                                  \
static void __vlib_add_node_registration_##x (void)                     \
{                                                                       \
    vlib_main_t * vm = vlib_get_main();                                 \
    x.next_registration = vm->node_main.node_registrations;             \
    vm->node_main.node_registrations = &x;                              \
}                                                                       \
__VA_ARGS__ vlib_node_registration_t x
  1. 在該代碼段中,VLIB_REGISTER_NODE宏有一個參數x,和可變參數。
  2. __VA_ARGS__ vlib_node_registration_t x聲明瞭一個vlib_node_registration_t結構體變量 x,這裏作用是僅聲明。
  3. static void __vlib_add_node_registration_##x (void) \ __attribute__((__constructor__))這段代碼是聲明瞭一個函數,使用##連接符根據參數來生成函數名稱。__constructor__是GNU編譯器的一個擴展,把該函數作爲構造函數,指明該函數會在模塊初始化時調用。
  4. 接下來就是__vlib_add_node_registration_##x 函數的定義了。具體的內容我們可以先無視掉。
  5. 而最後一行,又定義了一遍x。這個需要結合宏調用的地方來看了。總之,這個宏聲明瞭一個變量x,然後定義了一個

VLIB_REGISTER_NODE宏的使用

看完了宏的定義,我們看一下該宏是怎樣調用的。

VLIB_REGISTER_NODE (ip4_icmp_echo_request_node,static) = {
  .function = ip4_icmp_echo_request,
  .name = "ip4-icmp-echo-request",

  .vector_size = sizeof (u32),

  .format_trace = format_icmp_input_trace,

  .n_next_nodes = 1,
  .next_nodes = {
    [0] = "ip4-load-balance",
  },
};

首先宏的參數x傳入了ip4_icmp_echo_request_node,在宏的擴展時,x都會被替換成傳入的參數。
而第二個參數是static,所以定義變量x時,都會static修飾。
最後在定義之後,有等號和大括號。這裏是對宏的代碼中最後一行__VA_ARGS__ vlib_node_registration_t x進行結構體賦值操作。這裏就可以理解爲什麼__VA_ARGS__ vlib_node_registration_t x定義在宏裏面進行了兩次了。第一次是僅聲明,後面定義的函數僅需要該值的地址去進行註冊。而在宏的代碼段的最後,是真正的結構體定義。
最後這段代碼展開變成了下面的樣子:

static vlib_node_registration_t ip4_icmp_echo_request_node;                             
static void __vlib_add_node_registration_ip4_icmp_echo_request_node (void)                     
    __attribute__((__constructor__)) ;                                  
static void __vlib_add_node_registration_ip4_icmp_echo_request_node (void)                     
{                                                                       
    vlib_main_t * vm = vlib_get_main();                                 
    ip4_icmp_echo_request_node.next_registration = vm->node_main.node_registrations;             
    vm->node_main.node_registrations = &ip4_icmp_echo_request_node;                              
}                                                                       
static vlib_node_registration_t ip4_icmp_echo_request_node = {
  .function = ip4_icmp_echo_request,
  .name = "ip4-icmp-echo-request",

  .vector_size = sizeof (u32),

  .format_trace = format_icmp_input_trace,

  .n_next_nodes = 1,
  .next_nodes = {
    [0] = "ip4-load-balance",
  },
};

VPP中錯誤碼的定義

在實際C語言編程中,會有很多錯誤碼的和對應的錯誤提示的定義。在VPP的代碼中使用下面的方式來進行錯誤碼和錯誤字符串的定義。

#include <stdio.h>
#define foreach_ethernet_arp_error					\
  _ (replies_sent, "ARP replies sent")					\
  _ (l2_type_not_ethernet, "L2 type not ethernet")			\
  _ (l3_type_not_ip4, "L3 type not IP4")				\
  _ (l3_src_address_not_local, "IP4 source address not local to subnet") \
  _ (l3_dst_address_not_local, "IP4 destination address not local to subnet") \
  _ (l3_src_address_is_local, "IP4 source address matches local interface") \
  _ (l3_src_address_learned, "ARP request IP4 source address learned")  \
  _ (replies_received, "ARP replies received")				\
  _ (opcode_not_request, "ARP opcode not request")                      \
  _ (proxy_arp_replies_sent, "Proxy ARP replies sent")			\
  _ (l2_address_mismatch, "ARP hw addr does not match L2 frame src addr") \
  _ (gratuitous_arp, "ARP probe or announcement dropped") \
  _ (interface_no_table, "Interface is not mapped to an IP table") \
  _ (interface_not_ip_enabled, "Interface is not IP enabled") \

static char *ethernet_arp_error_strings[] = {
#define _(sym,string) string,
  foreach_ethernet_arp_error
#undef _
};
typedef enum
{
#define _(sym,string) ETHERNET_ARP_ERROR_##sym,
  foreach_ethernet_arp_error
#undef _
    ETHERNET_ARP_N_ERROR,
} ethernet_arp_input_error_t;

int main()
{
    printf("%s\r\n", ethernet_arp_error_strings[ETHERNET_ARP_ERROR_interface_no_table]);
}

輸出:

Interface is not mapped to an IP table

foreach_ethernet_arp_error中定義了該模塊所有錯誤類型和錯誤碼的對應關係。 ethernet_arp_error_strings定義了錯誤字符串的集合。
ethernet_arp_input_error_t定義了錯誤碼的集合。
我們可以通過錯誤碼作爲索引去ethernet_arp_error_strings中查找對應的錯誤字符串。
這樣我們就可以很方便的擴展和修改錯誤類型和錯誤碼了。只需要修改foreach_ethernet_arp_error中的定義即可。
這裏錯誤碼和錯誤字符串都是用過宏來自動生成的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章