标签 GNU 下的文章

再谈C语言位域

我在日常工作中使用C语言中的位域(bit field)的场景甚少,原因大致有二:

* 一直从事于服务器后端应用的开发,现在的服务器的内存容量已经达到了数十G的水平,我们一般不需要为节省几个字节而使用内存布局更加紧凑的位域。
* 结构体中位域的实现是平台相关或Compiler相关的,移植性较差,我们不会贸然地给自己造“坑”的。

不过近期Linux技术内核社区(www.linux-kernel.cn) mail list中的一个问题让我觉得自己对bit field的理解还欠火候,于是乎我又花了些时间就着那个问题重新温习一遍bit field。

零、对bit field的通常认知

在C语言中,我们可以得到某个字节的内存地址,我们具备了操作任意内存字节的能力;在那个内存空间稀缺的年代,仅仅控制到字节级别还不足以满足C 程序员的胃口,为此C语言中又出现了bit级别内存的“有限操作能力” – 位域。这里所谓的“有限”指的是机器的最小粒度寻址单位是字节,我们无法像获得某个字节地址那样得到某个bit的地址,因此我们仅能通过字节的运算来设置 和获取某些bit的值。在C语言中,尝试获得一个bit field的地址是非法操作

struct flag_t {
    int a : 1;
};

struct flag_t flg;
printf("%p\n", &flg.a);

error: cannot take address of bit-field ‘a’

以下是C语言中bit field的一般形式:

struct foo_t {
    unsigned int b1 : n1,
                 b2 : n2,
                 … …
                 bn : nk;
};

其中n1,n2,nk为对应位域所占据的bit数。

位域(bit field)的出现让我们可以用变量名代表某些bit,并通过变量名直接获得和设置一些内存中bit的值,而不是通 过晦涩难以理解的位操作来进行,例如:

struct foo_t {
    unsigned int a : 3,
                 b : 2,
                 c : 4;
};

struct foo_t f;
f.a = 3;
f.b = 1;
f.c = 12;

另外使用位域我们可以在展现和存储相同信息的同时,自定义更加紧凑的内存布局,节约内存的使用量。这使得bit field在嵌入式领域,在驱动程序领域得到广泛的应用,比如可以仅用两个字节就可以将tcpheader从dataoffset到fin的信息全部表示 和存储起来:

struct tcphdr {
    … …
    __u16   doff:4,
            res1:4,
            cwr:1,
            ece:1,
            urg:1,
            ack:1,
            psh:1,
            rst:1,
            syn:1,
            fin:1;
    … …
};

一、存储单元(storage unit)

C标准允许unsigned int/signed int/int类型的位域声明,C99中加入了_Bool类型的位域。但像Gcc这样的编译器自行加入了一些扩展,比如支持short、char等整型类 型的位域字段,使用其他类型声明位域将得到错误的结果,比如:

struct flag_t {
    char* a : 1;
};
 error: bit-field ‘a’ has invalid type

C编译器究竟是如何为bit field分配存储空间的呢?我们以Gcc编译器(Ubuntu 12.04.2 x86_64 Gcc 4.7.2 )为例一起来探究一下。

我们先来看几个基本的bit field类型的例子:

struct bool_flag_t {
    _Bool a : 1,
          b : 1;
};

struct char_flag_t {
    unsigned char a : 2,
                  b : 3;
};

struct short_flag_t {
    unsigned short a : 2,
                   b : 3;
};

struct int_flag_t {
    int a : 2,
        b : 3;
};

int
main()
{
    printf("%ld\n", sizeof(struct bool_flag_t));
    printf("%ld\n", sizeof(struct char_flag_t));
    printf("%ld\n", sizeof(struct short_flag_t));
    printf("%ld\n", sizeof(struct int_flag_t));

    return 0;
}

编译执行后的输出结果为:
1
1
2
4

可以看出Gcc为不同类型的bit field分配了不同大小的基本内存空间。_Bool和char类型的基本存储空间为1个字节;short类型的基本存储空间为2个字节,int型的为4 个字节。这些空间的分配是基于结构体内部的bit field的size没有超出基本空间的界限为前提的。以short_flag_t为例:

struct short_flag_t {
    unsigned short a : 2,
                   b : 3;
};

a、b两个bit field总共才使用了5个bit的空间,所以Compiler只为short_flag_t分配一个基本存储空间就可以存储下这两个bit field。如果bit field的size变大,size总和超出基本存储空间的size时,编译器会如何做呢?我们还是看例子:

struct short_flag_t {
    unsigned short a : 7,
                   b : 10;
};

将short_flag_t中的两个bit字段的size增大后,我们得到的sizeof(struct short_flag_t)变成了4,显然Compiler发现一个基础存储空间已经无法存储下这两个bit field了,就又为short_flag_t多分配了一个基本存储空间。这里我们所说的基本存储空间就称为“存储单元(storage unit)”它是Compiler在给bit field分配内存空间时的基本单位,并且这些分配给bit field的内存是以存储单元大小的整数倍递增的。但从上面来看,不同类型bit field的存储单元大小是不同的

sizeof(struct short_flag_t)变成了4,那a和b有便会有至少两种内存布局方式:
* a、b紧邻
* b在下一个可存储下它的存储单元中分配内存

具体采用哪种方式,是Compiler相关的,这会影响到bit field的可移植性。我们来测试一下Gcc到底采用哪种方式:

void
dump_native_bits_storage_layout(unsigned char *p, int bytes_num)
{

    union flag_t {
        unsigned char c;
        struct base_flag_t {
            unsigned int p7:1,
                         p6:1,
                         p5:1,
                         p4:1,
                         p3:1,
                         p2:1,
                         p1:1,
                         p0:1;
        } base;
    } f;

    for (int i = 0; i < bytes_num; i++) {
        f.c = *(p + i);
        printf("%d%d%d%d %d%d%d%d ",
                         f.base.p7,
                         f.base.p6, 
                         f.base.p5, 
                         f.base.p4, 
                         f.base.p3,
                         f.base.p2, 
                         f.base.p1, 
                         f.base.p0);
    }
    printf("\n");
}

struct short_flag_t {
    unsigned short a : 7,
                   b : 10;
};

 struct short_flag_t s;
 memset(&s, 0, sizeof(s));
 s.a = 113; /* 0111 0001 */
 s.b = 997; /* 0011 1110 0101 */

 dump_native_bits_storage_layout((unsigned char*)&s, sizeof(s));
 
编译执行后的输出结果为: 1000 1110 0000 0000 1010 0111 1100 0000。可以看出Gcc采用了第二种方式,即在为a分配内存后,发现该存储单元剩余的空间(9 bits)已经无法存储下字段b了,于是乎Gcc又分配了一个存储单元(2个字节)用来为b分配空间,而a与b之间也因此存在了空隙。

我们还可以通过匿名0长度位域字段的语法强制位域在下一个存储单元开始分配,例如:

struct short_flag_t {
    unsigned short a : 2,
                   b : 3;
};

这个结构体本来是完全可以在一个存储单元(2字节)内为a、b两个位域分配空间的。如果我们非要让b放在与a不同的存储单元中,我们可以通过加入 匿名0长度位域的方法来实现:

struct short_flag_t {
    unsigned short a : 2;
    unsigned short   : 0;
    unsigned short b : 3;
};

这样声明后,sizeof(struct short_flag_t)变成了4。

 struct short_flag_t s;
 memset(&s, 0, sizeof(s));
 s.a = 2; /* 10 */
 s.b = 4; /* 100 */

 dump_native_bits_storage_layout((unsigned char*)&s, sizeof(s));

执行后,输出的结果为:

0100 0000 0000 0000 0010 0000 0000 0000

可以看到位域b被强制放到了第二个存储单元中。如果没有那个匿名0长度的位域,那结果应该是这样的:

0100 1000 0000 0000

最后位域的长度是不允许超出其类型的最大长度的,比如:

struct short_flag_t {
    short a : 17;
};

error: width of ‘a’ exceeds its type

二、位域的位序

再回顾一下上一节的最后那个例子(不使用匿名0长度位域时):

 struct short_flag_t s;
 memset(&s, 0, sizeof(s));
 s.a = 2; /* 10 */
 s.b = 4; /* 100 */

dump bits的结果为0100 1000 0000 0000

怎么感觉输出的结果与s.a和s.b的值对不上啊!根据a和b的值,dump bits的输出似乎应该为1010 0000 0000 0000。对比这两个dump结果不同的部分:1010 0000 vs. 0100 1000,a和b的bit顺序恰好相反。之前一直与字节序做斗争,难不成bit也有序之分?事实就是这样的。bit也有order的概念,称为位序。位域字 段的内存位排序就称为该位域的位序。

我们来回顾一下字节序的概念,字节序分大端(big-endian,典型体系Sun Sparc)和小端(little-endian,典型体系Intel x86):
大端指的是数值(比如0×12345678)的逻辑最高位(0×12)放在起始地址(低地址)上,简称高位低址,就是高位放在起始地址
小端指的是数值(比如0×12345678)的逻辑最低位(0×78)放在起始地址(低地址)上,简称低位低址,就是低位放在起始地址

看下面例子:

int
main()
{
    char c[4];
    unsigned int i = 0×12345678;
    memcpy(c, &i, sizeof(i));

    printf("%p – 0x%x\n", &c[0], c[0]);
    printf("%p – 0x%x\n", &c[1], c[1]);
    printf("%p – 0x%x\n", &c[2], c[2]);
    printf("%p – 0x%x\n", &c[3], c[3]);
}

在x86 (小端机器)上输出结果如下:

0x7fff1a6747c0 – 0×78
0x7fff1a6747c1 – 0×56
0x7fff1a6747c2 – 0×34
0x7fff1a6747c3 – 0×12

在sparc(大端机器)上输出结果如下:

ffbffbd0 – 0×12
ffbffbd1 – 0×34
ffbffbd2 – 0×56
ffbffbd3 – 0×78

通过以上输出结果可以看出,小端机器的数值低位0×78放在了低地址0x7fff1a6747c0上;而大端机器则是将数值高位0×12放在了低 地址0xffbffbd0上。

机器的最小寻址单位是字节,bit无法寻址,也就没有高低地址和起始地址的概念,我们需要定义一下bit的“地址”。以一个字节为例,我们把从左到右的8个bit的位置(position)命名按顺序命名如下:

p7 p6 p5 p4 p3 p2 p1 p0

其中最左端的p7为起始地址。这样以一字节大小的数值10110101(b)为例,其在不同平台下的内存位序如下:

大端的含义是数值的最高位1(最左边的1)放在了起始位置p7上,即数值10110101的大端内存布局为10110101。
小端的含义是数值的最低位1(最右边的1)放在了起始位置p7上,即数值10110101的小端内存布局为10101101。

前面的函数dump_native_bits_storage_layout也是符合这一定义的,即最左为起始位置。

同理,对于一个bit个数为3且存储的数值为110(b)的位域而言,将其3个bit的位置按顺序命名如下:

p2 p1 p0

其在大端机器上的bit内存布局,即位域位序为: 110;
其在小端机器上的bit内存布局,即位域位序为: 011

在此基础上,理解上面例子中的疑惑就很简单了。

 s.a = 2; /* 10(b) ,大端机器上位域位序为 10,小端为01 */
 s.b = 4; /* 100(b),大端机器上位域位序为100,小端为001 */

于是在x86(小端)上的dump bits结果为:0100 1000 0000 0000
而在sparc(大端)上的dump bits结果为:1010 0000 0000 0000

同时我们可以看出这里是根据位域进行单独赋值的,这样位域的位序是也是以位域为单位排列的,即每个位域内部独立排序, 而不是按照存储单元(这里的存储单元是16bit)或按字节内bit序排列的。

三、tcphdr定义分析

前面提到过在linux-kernel.cn mail list中的那个问题大致如下:

tcphdr定义中的大端代码:

__u16   doff:4,
        res1:4,
        cwr:1,
        ece:1,
        urg:1,
        ack:1,
        psh:1,
        rst:1,
        syn:1,
        fin:1;

问题是其对应的小端代码该如何做字段排序?似乎有两种方案摆在面前:

方案1:
__u16    res1:4,
         doff:4,
         fin:1,
         syn:1,
         rst:1,
         psh:1,
         ack:1,
         urg:1,
         ece:1,
         cwr:1;

or

方案2:
__u16   cwr:1,
        ece:1,
        urg:1,
        ack:1,
        psh:1,
        rst:1,
        syn:1,
        fin:1,
        res1:4
        doff:4;

个人觉得这两种方案从理论上都是没错的,关键还是看tcphdr是如何进行pack的,是按__u16整体打包,还是按byte打包。原代码中使用的是方 案1,推测出tcphdr采用的是按byte打包的方式,这样我们只需调换byte内的bit顺序即可。res1和doff是一个字节内的两个位域,如果 按自己打包,他们两个的顺序对调即可在不同端的平台上得到相同的结果。用下面实例解释一下:

假设在大端系统上,doff和res1的值如下:

doff res1
1100 1010 大端

在大端系统上pack后,转化为网络序:

doff res1
1100 1010 网络序

小端系统接收后,转化为本地序:

0101 0011

很显然,我们应该按如下方法对应:

res1 doff
0101 0011

也就相当于将doff和res1的顺序对调,这样在小端上依旧可以得到相同的值。

libiconv库链接问题一则

与在Solaris系统上不同,Linux的libc库中包含了libiconv库中函数的定义,因此在Linux上使用libiconv库相关函数,编译时是不需要显式-liconv的。但最近我的一位同事在某redhat enterprise server 5.6机器上编译程序时却遇到了找不到iconv库函数符号的链接问题,到底是怎样一回事呢?这里分享一下问题查找过程。

一、现场重现

这里借用一下这位同事的测试程序以及那台机器,重现一下问题过程:
/*test.c */


#include <iconv.h>

int main(void)
{
    int r;
    char *sin, *sout;
    size_t lenin, lenout;
    char *src = "你好!";
    char dst[256] = {0};
    iconv_t c_pt;  

    sin = src;
    lenin = strlen(src)+1;

    sout = dst;
    lenout = 256;

    if ((c_pt = iconv_open("UTF-8", "GB2312")) == (iconv_t)(-1)){
        printf("iconv_open error!. errno[%d].\n", errno);
        return -1;
    }

    if ((r = iconv(c_pt, (char **)&sin, &lenin, &sout, &lenout)) != 0){
        printf("iconv error!. errno[%d].\n", r);
        return -1;
    }  

    iconv_close(c_pt);

    printf("SRC[%s], DST[%s].\n", src, dst);

    return 0;
}

根据之前的经验,我们按如下命令编译该程序:

$> gcc -g -o test test.c

/tmp/ccyQ5blC.o: In function `main':
/home/tonybai/tmp/test.c:28: undefined reference to `libiconv_open'
/home/tonybai/tmp/test.c:33: undefined reference to `libiconv'
/home/tonybai/tmp/test.c:38: undefined reference to `libiconv_close'

咦,这是咋搞的呢?怎么找不到iconv库的符号!!!显式加上iconv的链接指示再试试。

$> gcc -g -o test test.c -liconv

这回编译OK了。的确如那位同事所说出现了怪异的情况。

二、现场取证

惯性思维让我首先提出疑问:难道是这台机器上的libc版本有差异,检查一下libc中是否定义了iconv相关符号。

$ nm /lib64/libc.so.6 |grep iconv
000000397141e040 T iconv
000000397141e1e0 T iconv_close
000000397141ddc0 T iconv_open

iconv的函数都定义了呀!怎么会链接不到?

我们再来看看已经编译成功的那个test到底连接到哪个iconv库了。

$ ldd test
    linux-vdso.so.1 =>  (0x00007fff77d6b000)
    libiconv.so.2 => /usr/local/lib/libiconv.so.2 (0x00002abbeb09e000)
    libc.so.6 => /lib64/libc.so.6 (0×0000003971400000)
    /lib64/ld-linux-x86-64.so.2 (0×0000003971000000)

哦,系统里居然在/usr/local/lib下面单独安装了一份libiconv。gcc显然是链接到这里的libiconv了,但gcc怎么会链接到这里了呢?

三、大侦探的分析^_^

Gcc到底做了什么呢?我们看看其verbose的输出结果。

$ gcc -g -o test test.c -liconv -v
使用内建 specs。
目标:x86_64-redhat-linux
配置为:../configure –prefix=/usr –mandir=/usr/share/man –infodir=/usr/share/info –enable-shared –enable-threads=posix –enable-          checking=release –with-system-zlib –enable-__cxa_atexit –disable-libunwind-exceptions –enable-libgcj-multifile –enable-languages=c,c++,   objc,obj-c++,java,fortran,ada –enable-java-awt=gtk –disable-dssi –disable-plugin –with-java-home=/usr/lib/jvm/java-1.4.2-gcj-1.4.2.0/jre –with-cpu=generic –host=x86_64-redhat-linux
线程模型:posix
gcc 版本 4.1.2 20080704 (Red Hat 4.1.2-50)
 /usr/libexec/gcc/x86_64-redhat-linux/4.1.2/cc1 -quiet -v test.c -quiet -dumpbase test.c -mtune=generic -auxbase test -g -version -o /tmp/     ccypZm0v.s
忽略不存在的目录“/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../x86_64-redhat-linux/include”
#include "…" 搜索从这里开始:
#include <…> 搜索从这里开始:
 /usr/local/include
 /usr/lib/gcc/x86_64-redhat-linux/4.1.2/include
 /usr/include
搜索列表结束。
GNU C 版本 4.1.2 20080704 (Red Hat 4.1.2-50) (x86_64-redhat-linux)
    由 GNU C 版本 4.1.2 20080704 (Red Hat 4.1.2-50) 编译。
GGC 准则:–param ggc-min-expand=100 –param ggc-min-heapsize=131072
Compiler executable checksum: ef754737661c9c384f73674bd4e06594
 as -V -Qy -o /tmp/ccaqvDgX.o /tmp/ccypZm0v.s
GNU assembler version 2.17.50.0.6-14.el5 (x86_64-redhat-linux) using BFD version 2.17.50.0.6-14.el5 20061020
 /usr/libexec/gcc/x86_64-redhat-linux/4.1.2/collect2 –eh-frame-hdr -m elf_x86_64 –hash-style=gnu -dynamic-linker /lib64/ld-linux-x86-64.so.  2 -o test /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../lib64/crt1.o /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../lib64/crti.o /usr/   lib/gcc/x86_64-redhat-linux/4.1.2/crtbegin.o -L/usr/lib/gcc/x86_64-redhat-linux/4.1.2 -L/usr/lib/gcc/x86_64-redhat-linux/4.1.2 -L/usr/lib/gcc/ x86_64-redhat-linux/4.1.2/../../../../lib64 -L/lib/../lib64
-L/usr/lib/../lib64 /tmp/ccaqvDgX.o -liconv -lgcc –as-needed -lgcc_s –no-as-needed -lc -lgcc –as-needed -lgcc_s –no-as-needed /usr/lib/gcc/x86_64-redhat-linux/4.1.2/crtend.o /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../lib64/crtn.o

从这个结果来看,gcc在search iconv.h这个头文件时,首先找到的是/usr/local/include/iconv.h,而不是/usr/include/iconv.h。这两个文件有啥不同么?

在/usr/local/include/iconv.h中,我找到如下代码:


#ifndef LIBICONV_PLUG
#define iconv_open libiconv_open
#endif
extern iconv_t iconv_open (const char* tocode, const char* fromcode);

libiconv_open vs iconv_open,卧槽!!!再对比一下前面编译时输出的错误信息:

/tmp/ccyQ5blC.o: In function `main':
/home/tonybai/tmp/test.c:28: undefined reference to `libiconv_open'
/home/tonybai/tmp/test.c:33: undefined reference to `libiconv'
/home/tonybai/tmp/test.c:38: undefined reference to `libiconv_close'

大侦探醒悟了!大侦探带你还原一下真实情况。

我们在执行gcc -g -o test test.c时, 根据gcc -v中include search dir的顺序,gcc首先search到的是/usr/local/include/iconv.h,而这里iconv_open等函数被预编译器替换成 了libiconv_open等加上了lib前缀的函数,而这些函数符号显然在libc中是无法找到的,libc中只有不带lib前缀的 iconv_open等函数的定义。大侦探也是一时眼拙了,没有细致查看gcc的编译错误信息中的内容,这就是问题所在!

gcc -g -o test test.c -liconv为何可以顺利编译通过呢?gcc是如何找到/usr/local/lib下的libiconv的呢?大侦探再次为大家还原一下真相。

我们在执行gcc -g -o test test.c -liconv时,gcc同 样首先search到的是/usr/local/include/iconv.h,然后编译test.c源码,ok;接下来启动ld程序进行链接;ld找 到了libiconv,ld是怎么找到iconv的呢,libiconv在/usr/local/lib下,ld显然是到这个目录下search了。我们 通过执行下面命令可以知晓ld的默认搜索路径:

$> ld -verbose|grep SEARCH
SEARCH_DIR("/usr/x86_64-redhat-linux/lib64"); SEARCH_DIR("/usr/local/lib64"); SEARCH_DIR("/lib64"); SEARCH_DIR("/usr/lib64"); SEARCH_DIR("/usr/x86_64-redhat-linux/lib"); SEARCH_DIR("/usr/lib64"); SEARCH_DIR("/usr/local/lib"); SEARCH_DIR("/lib"); SEARCH_DIR("/usr/lib");

ld的默认search路径中有/usr/local/lib(我之前一直是以为/usr/local/lib不是gcc/ld的默认搜索路径的),因此找到libiconv就不足为奇了。

四、问题解决

我们不想显式的加上-liconv,那如何解决这个问题呢?我们是否可以强制gcc先找到/usr/include/iconv.h呢?我们先来做个试验。

$ gcc -g -o test test.c -liconv -I ~/include -v

忽略不存在的目录“/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../x86_64-redhat-linux/include”
#include "…" 搜索从这里开始:
#include <…> 搜索从这里开始:
 /home/tonybai/include
 /usr/local/include
 /usr/lib/gcc/x86_64-redhat-linux/4.1.2/include
 /usr/include
搜索列表结束。

试验结果似乎让我们觉得可行,我们通过-I指定的路径被放在了第一的位置进行search。我们来尝试一下强制gcc先search /usr/include。

$ gcc -g -o test test.c -I ~/include -v

忽略不存在的目录“/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../x86_64-redhat-linux/include”
忽略重复的目录“/usr/include”
  因为它是一个重复了系统目录的非系统目录
#include "…" 搜索从这里开始:
#include <…> 搜索从这里开始:
 /usr/local/include
 /usr/lib/gcc/x86_64-redhat-linux/4.1.2/include
 /usr/include
搜索列表结束。

糟糕!/usr/include被忽略了!还是从/usr/local/include开始,方案失败。

似乎剩下的唯一方案就是将/usr/local/lib下的那份libiconv卸载掉!那就这么做吧^_^!

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats