顶级C程序员之路

共 12689字,需浏览 26分钟

 ·

2021-05-25 19:14


今天分享一篇几年前在CSDN上发表的文章。C语言是我非常喜欢的语言,也是众多高级语言的鼻祖。

C语言的优势:
门槛低:核心特性和标准库都比较简单;
上限高:语法精简,编程自由度高,可以玩出各种高端操作;
性能强:极少抽象, 几乎完全对应到汇编实现,性能强,首选底层系统开发语言。

TIOBE编程社区揭晓了各大编程语言的排行情况:


Google搜索排行榜:


知乎热门讨论:


大量重量级软件都是C写的,比如Linux,UNIX,IOS内核,windows内核,Android内核,jvm虚拟机,CPython,Nginx,Redis,MySQL,GCC,GDB等等,可以看到,整个世界的基础架构都是在 C 语言之上运行的,C语言是你必须学习的语言。因为这个世界上绝大多数编程语言都是 C-like 的语言,也是在不同的方面来解决 C 语言的各种问题。学习 C 语言是一个合格的程序员的必经之路。


下面章节进入今天的主题
深入理解字节,字节序与字节对齐
                       
一 总述
  
作为一个职业的coder玩家,首先应该对计算机的字节有所了解。我们经常谈到的2进制流,字节(字符)流,数据类型流(针对编程),结构流等说法,2进制流,0和1的操作,属于cpu级,从字符流向上都是我们玩家关心,字节流属于操作系统级,今天谈的就是字节流操作。

二  字节
   
因为计算机用二进制,所以希望基本存储单位的是2的n次方(和硬件设计有关)。这样读取字节的时候,开销不会太高,可以达到最大性能,因为刚开始,计算机是美国发明的,西文字符(英文字母大小写,数字,其他特殊字符等将近有1百多个,所以用7位来表示,这样可以把所有西文字符表达完,再加上一位校检位,一共8位,由于ASCⅡ的广泛应用,所有后来,一个字节占8位就成了国际规定的标准了,一直沿用至今(有待研究,但不是今天的主题)。
   
一个字节占8个2进制位,数据类型流,就是在c语言里面的数据类型占多少个字节。然后直接操作数据类型。在目前的32位系统中,c语言的基本数据类型有以下几种:

Char  占一个字节 (-2^7 - 2^7-1 ,-128 到 127)最高位 为符号位
Unsigned char 占一个字节   (0-2^8,0到255)
Short   占2个字节 (-2^15 - 2^15-1 , -32768到32767)最高位 为符号位
Unsigned short 占2个字节 (0 - 2^16 , 0到65536)
Int (字长,对于32位机为32位,16位机为16位,长度不固定,和系统平台有关,处理器位数有关,代表寻址空间),在32位机占4个字节(-2^31-2^31 )最高位 为符号位

Unsigned  int 占4个字节(0-2^32 )
Long int 为4个字节,在16,32位机都占4个字节(-2^31-2^31 )最高位 为符号位
Unsigned  long int占4个字节(0-2^32 )
sizeof(short) <= sizeof(int) <= sizeof(long)  

在32位机 int和long都是32位,没有什么大的区别,但还是有些小的区别,有时最好用long,他大小固定,如果到其他平台,比如64位,他还是占4个字节,而int却占8个字节,可以增加代码的可移植性。

Long long int 占8个字节(c99标准)  
Unsigned long long int 占8个字节(c99标准)  

浮点类型 由于浮点类型和整形的编码不一样,所以浮点型需要特殊分析。
Float 占4个字节  
Double 占8个字节 


三 字节序

为什么有字节序这个概念存在呢?

不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序 这个叫做主机序 ,就是多个字节在内存中摆放位置顺序和解释顺序。

最常见的有两种 :

1. Little endian:将低序字节存储在起始地址  4321
2. Big endian:将高序字节存储在起始地址    1234

LE little-endian 
最符合人的思维的字节序,地址低位存储值的低位 ,地址高位存储值的高位,怎么讲是最符合人的思维的字节序,是因为从人的第一观感来说低位值小,就应该放在内存地址小的地方,也即内存地址低位,反之,高位值就应该放在内存地址大的地方,也即内存地址高位。

BE big-endian 
最直观的字节序,地址低位存储值的高位 ,地址高位存储值的低位,为什么说直观,不要考虑对应关系,只需要把内存地址从左到右按照由低到高的顺序写出,把值按照通常的高位到低位的顺序写出两者对照,一个字节一个字节的填充进去 。

例子:如果我们将0x1234abcd写入到以0x0000开始的内存中,则结果为
内存地址        big-endian   little-endian
0x0000          0x12               0xcd
0x0001          0x34               0xab
0x0002          0xab               0x34
0x0003          0xcd               0x12


网络字节序

网络字节序是TCP/IP协议栈中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释。网络字节顺序采用big endian排序方式。

为了进行转换 bsd socket提供了转换的函数 有下面四个:
linux的源代码(/include/netinet/in.h)
# if __BYTE_ORDER == __BIG_ENDIAN 
/* The host byte order is the same as network byte order, 
   so these functions are all just identity.  */ 
# define ntohl(x) (x) 
# define ntohs(x) (x) 
# define htonl(x) (x) 
# define htons(x) (x) 
# else 
#  if __BYTE_ORDER == __LITTLE_ENDIAN 
#   define ntohl(x) __bswap_32 (x) 
#   define ntohs(x) __bswap_16 (x) 
#   define htonl(x) __bswap_32 (x) 
#   define htons(x) __bswap_16 (x) 
#  endif 
# endif

htons 把unsigned short类型从主机序转换到网络序
htonl 把unsigned long类型从主机序转换到网络序
ntohs 把unsigned short类型从网络序转换到主机序
ntohl 把unsigned long类型从网络序转换到主机序

在使用little endian的系统中这些函数会把字节序进行转换, 在使用big endian类型的系统中这些函数会定义成空,什么都不做。

htonl(x)我简化为下:
 #define  htonl(x)  \  //连接符,连接下一行
  ((unsigned long ) \
( \
(((unsigned long)(x)&0x000000ff<<24)| \
(((unsigned long)(x)&0x0000ff00)<<8)| \
(((unsigned long)(x)&0x00ff0000)>>8)| \
(((unsigned long)(x)&0xff000000)>>24)\
))

一般c语言编写程序的字节序都是系统相关的,叫主机序,即指系统处理器本身所采用的字节序,java的字节码是big-endian 和网络字节序一样,所以他和网络通信不需要关心字节序问题,如果要和其他平台进行通信,都要进行字节序转换,一般采用标准化的网络序进行传输:

发送端:主机序->网络序
接收端: 网络序->主机序

不管哪种字节序,目的都是大家对01二进制串解释都一样, 不然两方的解释不一样就可能会产生严重的问题。

对于IP头定义里面ihl和version字段需要考虑主机字节序;__be 表示big endian

网络上流传一个测试自己系统是什么字节序函数代码:
byte_type get_sys_byte_order()
{
     union  {
         int  b;
         char a[4];
     }U;
     U.b = 0x01;
     if(0x01 == U.a[0] ) {
         return   little_endian_type;
      }else {
         return   big_endian_type;
      } 
} 

注:不同的CPU上运行不同的操作系统,字节序也是不同的,参见下表:

处理器 
  操作系统
 字节序
Alpha            
全部
 Little endian
ARM
全部
 Little endian
Intelx86
全部     
 Little endian 
AMD
全部     
 Little endian
MIPS              
NT   
 Little endian
MIPS             
UNIX     
 Big endian 
PowerPC           
NT      
 Little endian
PowerPC          
非NT     
 Big endian 

x86,AMD,ARM等芯片平台是小端字节序系统

PowerPC ,PPC等芯片平台是大端字节序系统

为什么不统一字节序?

1. 计算都是从低位开始的,因此计算机内部处理采用小端序,效率较高。
2. 对于大端序,由于符号位在高位,因此对于数据正负或大小的判断也就方便许多;其次,大端序也更符合人类的阅读习惯。
3. 由于大小端各有优劣,各个芯片厂商的坚持自己设计,字节序的问题也就一直没有统一。

字节序总结
  • 不同处理器之间采用的字节序可能不同。

  • 有些处理器的字节序是确定的,有些处理器的字节序是可配置的。

  • 网络序一般统一为大端序。

  • 数据从本地传输到网络,需要转换为网络序,接收到的网络数据需要转换为本地序后使用。

  • C提供了一组接口用于整型数据在本地序和网络序之间的转换。

  • 多字节数据对象才需要转字节序,例如int,short等,而char不需要。

  • 由于处理器是按照IEEE标准处理float和double的,因此也不需要转字节序。

  • 由于Java虚拟机的存在,Java不需要考虑大小端的问题。


四 字节对齐

首先我看哈程序的优化种类:
Cpu级优化((读内存),流水线,cache,现在多核等)->2进制级(即01代码)优化(现在估计没有人去做了)->汇编级(指令)优化->高级程序里面的代码级优化(位运算,前++和后++,数组和指针,if else和switch case等优化)->算法优化(流程优化)->软件架构级优化......

而现在我们讨论的字节对齐属于cpu级优化,可以加速cpu读取内存时间。

什么是字节对齐?

现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。 

为什么要字节对齐

原因一: CPU读取内存效率
各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。

比如有些架构的CPU在访问一个没有进行对齐的变量的时候会发生错误(比如高通平台,一般的手机平台都采用美国高通公司开发平台,对于无线上网卡来说,现在都是多核,要么是arm9+arm11,要么是arm9+2个Qdsp(Q是表示高通的dsp处理器)等处理器架构,然而在Qdsp中,如果访问了非对齐的内存,就会直接发生错误,直接把系统crush掉)那么在这种架构下编程必须保证字节对齐。

其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit。

而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。 

原因二 : Cache亲和性

结构的数据跨越两个cache line,就意味着两次load或者两次store。如果数据结构是cache line对齐的,  就有可能减少一次读写。


掌握字节对齐的估算方法

学会估算结构大小,可以帮助我们更好地设计程序的数据结构,比如合理地节约内存,提供数据访问效率等。

1. 对于基本数据类型对齐要求:
Char 类型一个字节对齐,可以从任何内存地址读取
Short2个字节,要求是从偶内存地址读取
Int 4个字节(32位系统),要求是变量的内存地址必须被4整除
Long和int一样(在32位系统中)
Double 8个字节,要求是变量的内存地址必须被8整除

2. 对于struct和union对齐要求是:
在c语言中存在struct和union结构体类型,属于复杂类型,成员中自身对齐值最大的那个值 
注:结构的总大小为结构的字节边界数(即该结构中占用最大空间的变量的类型所占用的字节数)的倍数, 对于结构体最终大小,还要参考,指定对齐值n

以下部分属于具体计算方法(有很多公式,但这些公式你可能从来没有看到过),不想看可以跳过这一节。

struct
s表示结构体,假使结构体有m个成员, 定义A(x)表示第x个成员的对齐值, X(i)表示其第i个成员(按顺序从上往下)所占的大小, H(x)表示前面x个成员最终占内存大小, 则结构体的大小可以通过下面公式估算出来:

初始值--基本类型type对齐值:
  A(char) = 1;
  A(short) = 2;
  A(int) = 4;
  A(long) = 4;
  A(float) = 4;
  A(double) = 8;

基本类型成员对齐值,type为基本类型成员x对应的基本类型:
第x个成员对齐值:
A(x) = min(A(type),n );    公式1
整个结构对齐值:
A(s) = min(max(A(1),A(2),...,A(m)),n)    公式2

则struct结构体前x+1个成员和前x成员之间计算公式:
If(H(x)%A(x+1) == 0)
  H(x+1)= H(x)+X(x+1);
Else
  H(x+1)= H(x)+A(x+1)-H(x)%A(x+1)+X(x+1); 
其中 H(1) = X(1),A(1)= X(1);   公式3

则结构体最终结构体大小X(s)为:
If(H(m)%A(s) == 0)
   X(s) = H(m);
Else
   X(s) = H(m)+A(s)-H(m)%A(s);  公式4 

union
U表示这个结构体,X(i)表示其第i个成员(按顺序从上往下)所占的大小;假使结构体有m个成员;结构体的最终对齐值  

A(u) = min(max(A(1),A(2),...,A(m)),n) 公式5

定义H(x)表示前面x个成员实际占内存大小;A(x)表示第x个成员的对齐值,可以有公式1给出, Union结构体最终占内存大小为X(u), 则:

则union结构体前x+1个成员和前x成员之间计算公式:
H(x+1)= max(X(x+1), H(x));
其中 H(1) = X(1);   公式6

则最终union结构体大小X(u)为:
If(H(m)%A(u) == 0)
   X(u) = H(m);
Else
   X(u) = H(m)+A(u)-H(m)%A(u);  公式7 

公式4和公式7一般实现

指定对齐值

VC/VS编译器

如果我们想指定对齐值,可以在VC IDE中,可以这样修改:[Project]|[Settings],c/c++选项卡Category的Code Generation选项的Struct Member Alignment中修改,默认是8字节,针对全部变量,如果想动态改变部分,在vc中可以用宏命令 

#pragma pack (n)时的指定对齐值n
#pragma pack()取消,之间的数据都是指定为n,但不一定为对齐n。
最终的数据成员对齐值为: 自身对齐值和指定对齐值中小的那个值。

GCC编译器
__attribute__((aligned(n))) 表示所定义的变量为n字节对齐。

__attribute__((__packed__))  表示结构体内不填充多余的字节,一般用于通信协议结构体定义;

__attribute__((__aligned__(SMP_CACHE_BYTES)))表示结构按cache对齐,提高数据访问效率。


结构中带有结构
不必考虑整个子结构,只考虑子结构的基本类型并参照前面的规则来分配空间。空结构(即不带任何的方法和数据)占用的1字节的空间。

枚举中(enum) 
枚举始终占用4字节的空间。

结构中成员
结构的静态成员不对结构的大小产生影响,因为静态变量的存储位置与结构的实例地址无关,要理解上面的对齐规则,最好是分析一些典型的对齐例子:

例子1:
struct MyStruct { 
  char dda; 
  double dda1; 
  int type 
}; 
默认指定对齐值n = 8(vc),其他自己查看,则n = 8;
由公式1 得到此结构体的最终对齐值为 A(s) = 8
有上面公式2 ,3 可以得到 X(MyStruct)(=sizeof(MyStruct)) :
H(1) = 1, H(2) =(H(1)) 1+7+8 = 16; H(3) =(H(2)) 16+4 = 20;
X(s) = (H(3))20 + (A(s)-H(3)%A(s)) 4 = 24;
所以sizeof(MyStruct) = 24;

例子2:
#pragma  pack(2)
struct MyStruct { 
  char dda;   A(1) = 1 
  double dda1; A(2) = 2 
  int type ;  A(3) = 2
}; 
#pragma  pack()
由公式1,2得到此结构体的最终对齐值为 A(s) = 2
有上面公式3 ,4 可以得到 X(MyStruct)(sizeof(MyStruct)) :
H(1) = 1, H(2) =(H(1)) 1+ (A(2)-H(1)%A(2)) 1+(X2)8 = 10; 因为H(2)%A(3)==0;
所以 H(3) =(H(2)) 10+4 = 14;
因为H(3)%A(s) == 0;
所以X(s) = (H(3)) 14;
所以sizeof(MyStruct) = 14;

例子3:
这里有个结构体嵌套例子,对于结构体中的结构体成员,不要认为它的对齐方式就是他的大小,看下面的例子:
struct s1{
char a[8]; 
};
struct s2{
double d; 
};
struct s3{
s1 s; 
char a; 
};
struct s4{
s2 s; 
char a; 
};
默认指定对齐值n = 8;
A(s1) = 1;A(s2) =min(min(A(double), 8),8) =8 ; 
A(s3) = min(max(A(x1)=min(A(s1) = 1,8) = 1,A(x2) = 1),8) = 1;
A(s4) = min(max(A(x1)=min(A(s2) = 8,8) = 8,A(x2) = 1),8) = 8;
X(s1) = 8;
X(s2) = 8;
X(s3) = 9;
X(s4) = 16;

以上只是一些测试例子,真实的结构体都比较庞大,一般用sizeof就可以了,但心里要清楚,每个成员的偏移量和填充的字节,这些都可以由上面的公式推出来(平时最应该注意的),我这里就暂时不推导了。

字节对齐利弊

1. 对齐意味着可能有内存浪费(特别是数组这样连续分配的数据结构),所以需要在空间和时间两方面权衡。
2. 跨平台通信场景,每个平台程序字节对齐不一样,可能会导致内存空间解析出错,所以一般采用1字节对齐,中间不含填充数据,但这样会导致一些属性访问性能下降,需要综合考虑。

字节对齐总结
  • 结构体成员合理安排位置,节省空间,提高性能

  • 跨平台数据结构可考虑1字节对齐,节省空间,解析安全,影响访问效率

  • 跨平台数据结构进行结构优化(对齐填充),提高访问效率,解析风险,不节省空间

  • 本地数据采用chace对齐,提高访问效率

  • 32位与64位默认对齐数不一样


五 最后总结
 
对于字节的理解,其实这些还不够,掌握这些只是作为顶级c程序员最基本的要求(路还很长),细节需要参考一下cpu的手册。

其实在实际编程当中,出现字节对齐的原因是通信的要求,如果是通过tcp/ip(互联网)通信,这样一般协议头部都是一个字节对齐,这样对方解释的时候是只需按协议解析就正确了;

或者是动态库调用,给别人的接口函数对应参数,如果是没有满足字节对齐的要求(不相同),如果进行强制类型转换或者按偏移量访问变量,就有可能出现错误(某些嵌入式cpu)或者意想不到问题,所以对于嵌入式开发的程序员最好是心里有数。
  
最后,让我们来设计一个memcpy函数,为什么要设计这个函数呢,如果你看过很多大型工程代码,你就明白了,这个函数使用率相当高,strcpy这个的优化版本内部都是调用memcpy来完成,这是系统函数,每个平台自己都实现了这个函数,而且里面充满很多编程技巧,看看自己会长见识。

参考:
高通芯片平台的memcpy函数:


技术: 内存对齐    循环展开 (局部性原理)  批量copy

glibc库函数:


技术: 内存对齐  批量copy  

DPDK库函数:


技术: 指令预取  局部性原理   向量指令  cache对齐

注: 本文是我早期发表在CSDN上的文章,比较粗糙,现用公众号记录一下,希望和大家一起努力,朝顶级C程序员前进
浏览 46
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报