C现代方法(第20章)笔记——底层程序设计

文章目录

  • 第20章 底层程序设计
    • 20.1 位运算符
      • 20.1.1 移位运算符
      • 20.1.2 按位取反运算符、按位与运算符、按位异或运算符和按位或运算符
      • 20.1.3 用位运算符访问位
      • 20.1.4 用位运算符访问位域
      • 20.1.5 程序——XOR加密
    • 20.2 结构中的位域
      • 20.2.1 位域是如何存储的
    • 20.3 其他底层技术
      • 20.3.1 定义依赖机器的类型
      • 20.3.2 用联合来提供数据的多个视角
      • 20.3.3 将指针作为地址使用
      • 20.3.4 volatile类型限定符
    • 20.4 对象的对齐(C1X)
      • 20.4.1 对齐运算符_Alignof(C1X)
      • 20.4.2 对齐指定符_Alignas和头(C1X)
    • 问与答
    • 写在最后

第20章 底层程序设计

——如果程序要关心不该关心的事,那这门语言就是低级的。

前面几章中讨论了C语言中高级的、与机器无关的特性。虽然这些特性对不少程序都够用了,但仍有一些程序需要进行位级别的操作。位操作和其他一些底层运算在编写系统程序(包括编译器和操作系统)、加密程序、图形程序以及其他一些需要高执行速度或高效地利用空间的程序时非常有用

20.1节介绍C语言的位运算符。位运算符提供了对单个位或位域的方便访问。20.2节介绍如何声明包含位域的结构。最后,20.3节描述如何使用一些普通的C语言特性(类型定义、联合和指针)来帮助编写底层程序。

本章中描述的一些技术需要用到数据在内存中如何存储的知识,可能会因不同的机器和编译器而不同。依赖于这些技术很可能会使程序丧失可移植性,因此除非必要,否则最好尽量避免使用它们。如果确实需要,尽量将使用限制在程序的特定模块中,不要将其分散在各处。同时最重要的是,确保在文档中记录所做的事!


20.1 位运算符

C语言提供了6个位运算符。这些运算符可以用于对整数数据进行位运算。这里先讨论2个移位运算符,然后再讨论其他4个位运算符(按位取反、按位与、按位异或,以及按位或)。


20.1.1 移位运算符

移位运算符可以通过将位向左或向右移动来变换整数的二进制表示C语言提供了2个移位运算符,见表20-1

表20-1 移位运算符

符号 含义
<< 左移位
>> 右移位

运算符<<运算符>>的操作数可以是任意整数类型(包括char型)。这两个运算符对两个操作数都会进行整数提升,返回值的类型是左操作数提升后的类型。

i << j的值是将i中的位左移j位后的结果。每次从i的最左端溢出一位,在i的最右端补一个0位。i >> j的值是将i中的位右移j位后的结果。如果i是无符号数或非负值,则需要在i的左端补0如果i是负值,其结果是由实现定义的:一些实现会在左端补0,其他一些实现会保留符号位而补1

可移植性技巧:为了可移植性,最好仅对无符号数进行移位运算。

下面的例子展示了对数13应用移位运算符的效果(简单起见,这些例子以及本节中的其他例子使用短整型,一般是16位):

unsigned short i, j; 
i = 13; /* i is now 13 (binary 0000000000001101) */ 
j = i << 2; /* j is now 52 (binary 0000000000110100) */ 
j = i >> 2; /* j is now 3 (binary 0000000000000011) */ 

如上面的例子所示,这两个运算符都不会改变它的操作数。如果要通过移位改变变量,需要使用复合赋值运算符<<=>>=

i = 13; /* i is now 13 (binary 0000000000001101) */ 
i <<= 2; /* i is now 52 (binary 0000000000110100) */ 
i >>= 2; /* i is now 3 (binary 0000000000000011) */

请注意!!移位运算符的优先级比算术运算符的优先级低,因此可能产生意料之外的结果。例如,i << 2 + 1等同于i << (2 + 1),而不是(i << 2) + 1


20.1.2 按位取反运算符、按位与运算符、按位异或运算符和按位或运算符

表20-2列出了余下的位运算符。

表20-2 其他位运算符

符号 含义
~ 按位取反
& 按位与
^ 按位异或
| 按位或

运算符~是一元运算符,对其操作数会进行整数提升。其他运算符都是二元运算符,对其操作数进行常用的算术转换。

运算符~&^|对操作数的每一位执行布尔运算~运算符会产生对操作数求反的结果,即将每一个0替换为1,将每一个1替换为0。运算符&对两个操作数相应的位执行逻辑与运算。运算符^|相似(都是对两个操作数执行逻辑或运算),不同的是,当两个操作数的位都是1时,^产生0|产生1

请注意!!不要将位运算符&|与逻辑运算符&&||相混淆。有时候位运算会得到与逻辑运算相同的结果,但它们绝不等同。

下面的例子演示了运算符~&^|的作用:

unsigned short i, j, k; 
i = 21; /* i is now 21 (binary 0000000000010101) */ 
j = 56; /* j is now 56 (binary 0000000000111000) */ 
k = ~i; /* k is now 65514 (binary 1111111111101010) */ 
k = i & j; /* k is now 16 (binary 0000000000010000) */ 
k = i ^ j; /* k is now 45 (binary 0000000000101101) */ 
k = i | j; /* k is now 61 (binary 0000000000111101) */

其中对~i所显示的值是基于unsigned short类型的值占有16位的假设。

对运算符~需要特别注意,因为它可以帮助我们使底层程序的可移植性更好。假设我们需要一个整数,它的所有位都为1。最好的方法是使用~0,因为它不会依赖于整数所包含的位的个数。类似地,如果我们需要一个整数,除了最后5位其他的位全都为1,我们可以写成~0x1f

运算符~&^|有不同的优先级(从左往右,由最高~最低|)。

因此,可以在表达式中组合使用这些运算符,而不必加括号。例如,可以写i & ~j|k而不需要写成(i & (~j))|k;同样,可以写i ^ j & ~k而不需要写成i ^ (j & (~k))。当然,仍然可以使用括号来避免混淆

请注意!!运算符&^|的优先级比关系运算符和判等运算符低。因此,下面的语句不会得到期望的结果:

if (status & 0x4000 != 0) ... 

这条语句会先计算0x4000 != 0(结果是1),接着判断status & 1是否非0,而不是判断status & 0x4000是否非0

复合赋值运算符&=^=|=分别对应于位运算符&^|

i = 21; /* i is now 21 (binary 0000000000010101) */ 
j = 56; /* j is now 56 (binary 0000000000111000) */ 
i &= j; /* i is now 16 (binary 0000000000010000) */ 
//i &= j 等价于 i = i & j

i ^= j; /* i is now 40 (binary 0000000000101000) */ 
//i ^= j 等价于 i = i ^ j

i |= j; /* i is now 56 (binary 0000000000111000) */ 
//i |= j 等价于 i = i | j

按位取反可以通过使用位取反操作符~普通赋值操作符=来实现,而不需要单独的复合赋值运算符,且按位取反是一元操作(单目运算),因此C语言没有提供按位取反赋值运算符。


20.1.3 用位运算符访问位

在进行底层编程时,经常会需要将信息存储为单个位或一组位。例如,在编写图形程序时,可能会需要将两个或更多个像素挤在一个字节中。使用位运算符就可以提取或修改存储在少数几个位中的数据。

假设i是一个16位的unsigned short变量,来看看如何对i进行最常用的单位运算。

  • 位的设置。假设我们需要设置i的第4位。(假定最高有效位为第15位,最低有效位为第0位。)设置第4位的最简单方法是将i的值与常量0x0010(一个在第4位上为1“掩码”)进行或运算

    i = 0x0000; /* i is now 0000000000000000 */ 
    i |= 0x0010; /* i is now 0000000000010000 */ 
    

    更通用的做法是,如果需要设置的位的位置存储在变量j中,可以使用移位运算符来构造掩码:

    [惯用法] i |= 1 << j; /* sets bit j */ 
    

    例如,如果j的值为3,则1 << j0x0008

  • 位的清除。要清除i的第4位,可以使用第4位为0、其他位为1的掩码:

    i = 0x00ff; /* i is now 0000000011111111 */ 
    i &= ~0x0010; /* i is now 0000000011101111 */ 
    

    按照类似的思路,我们可以很容易地编写语句来清除一个特定的位,这个位的位置存储在一个变量中:

    [惯用法]  i &= ~(1 << j); /* clears bit j */ 
    
  • 位的测试。下面的if语句测试i的第4位是否被设置:

    if (i & 0x0010) ... /* tests bit 4 */ 
    

    如果要测试第j位是否被设置,可以使用下面的语句:

    [惯用法] if (i & 1 << j)... /* tests bit j */ 
    

为了使针对位的操作更容易,经常会给位命名。例如,如果想要使一个数的第012位分别对应蓝色(BLUE)绿色(GREEN)红色(RED)。首先,定义分别代表这三个位的位置的名字:

#define BLUE 0 
#define GREEN 1 
#define RED 2 

设置、清除或测试BLUE位可以如下进行:

i |= BLUE; /* sets BLUE bit */ 
i &= ~BLUE; /* clears BLUE bit */ 
if (i & BLUE) ... /* tests BLUE bit */ 

同时设置、清除或测试几个位也一样简单:

i |= BLUE | GREEN; /* sets BLUE and GREEN bits */ 
i &= ~(BLUE | GREEN); /* clears BLUE and GREEN bits */ 
if (i & (BLUE | GREEN)) ... /* tests BLUE and GREEN bits */ 

其中if语句测试BLUE位或GREEN位是否被设置了。


20.1.4 用位运算符访问位域

处理一组连续的位(位域)比处理单个位要复杂一点。下面是2种最常见的位域操作的例子。

  • 修改位域。修改位域需要使用按位与(用来清除位域),接着使用按位或(用来将新的位存入位域)。下面的语句显示了如何将二进制值101存入变量i的第4~6位:

    i = i & ~0x0070 | 0x0050; /* stores 101 in bits 4-6 */
    

    运算符&清除了i的第4位至第6位,接着运算符|设置了第6位和第4位。注意,使用i |= 0x0050并不总是可行,这只会设置第6位和第4位,但不会改变第5位。为了使上面的例子更通用,我们假设变量j包含了需要存储到i的第4~6位的值。需要在执行按位或操作之前将j移至相应的位置:

    i = (i & ~0x0070) | (j << 4); /* stores j in bits 4-6 */
    

    运算符|的优先级比运算符&<<的优先级低,因此可以去掉圆括号:

    i = i & ~0x0070 | j << 4; 
    
  • 获取位域。当位域处在数的右端(最低有效位)时,获得它的值非常方便。例如,下面的语句获取了变量i的第0~2位:

    j = i & 0x0007; /* retrieves bits 0-2 */ 
    

    如果位域不在i的右端,那首先需要将位域移位至右端,再使用运算符&提取位域。例如,要获取i的第4~6位,可以使用下面的语句:

    j = (i >> 4) & 0x0007; /* retrieves bits 4-6 */ 
    

20.1.5 程序——XOR加密

对数据加密的一种最简单的方法就是,将每一个字符与一个密钥进行异或(XOR)运算。假设密钥是一个&字符。如果将它与字符z异或,会得到字符\(假定使用ASCII字符集)。具体计算如下:

    00100110&的ASCII码)
XOR 01111010 (z的ASCII码)
    01011100 (\的ASCII码)

要将消息解密,只需采用相同的算法。换言之,只需将加密后的消息再次加密,即可得到原始的消息。例如,如果将&字符与\字符异或,就可以得到原来的字符 z

    00100110&的ASCII码)
XOR 01011100 (\的ASCII码)
    01111010 (z的ASCII码)

下面的程序xor.c通过将每个字符与&字符进行异或来加密消息。原始消息可以由用户输入,或者使用输入重定向(22.1节)从文件读入。加密后的消息可以在屏幕上显示,也可以通过输出重定向(22.1节)存入文件中。例如,假设文件msg包含下面的内容:

Trust not him with your secrets, who, when left 
alone in your room, turns over your papers. 
            --Johann Kaspar Lavater (1741-1801)

为了对文件msg加密,并将加密后的消息存储在文件newmsg中,需要使用下面的命令:

xor <msg >newmsg  # windows系统使用cmd,不要用powershell

文件newmsg将包含下面的内容:

rTSUR HIR NOK QORN _IST UCETCRU, QNI, QNCH JC@R 
GJIHC OH _IST TIIK, RSTHU IPCT _IST VGVCTU. 
            --lINGHH mGUVGT jGPGRCT (1741-1801) 

要恢复原始消息,需要使用命令

xor <newmsg  # windows系统使用cmd,不要用powershell

将原始消息显示在屏幕上。

正如在例子中看到的,程序不会改变某些字符,包括数字。将这些字符与&异或会产生不可见的控制字符,这在一些操作系统中会引发问题。在第22章中,我们会看到在读和写包含控制字符的文件时,如何避免问题的发生。而这里为了安全,我们将使用isprint函数(23.5节)来确保原始字符和新字符(加密后的字符)都是可打印字符(即不是控制字符)。如果不满足条件,就让程序写原始字符,而不用新字符。下面是完成的程序,这个程序相当短小:

/*
xor.c
-- Performs XOR encryption
*/
#include  
#include  
#define KEY '&' 
int main(void) 
{ 
    int orig_char, new_char; 
    while ((orig_char = getchar()) != EOF) { 
        new_char = orig_char ^ KEY; 
    if (isprint(orig_char) && isprint(new_char)) 
        putchar(new_char); 
    else 
        putchar(orig_char); 
    } 
    return 0; 
} 

20.2 结构中的位域

虽然20.1节的方法可以操作位域,但这些方法不易使用,而且可能会引起一些混淆。幸运的是,C语言提供了另一种选择——声明其成员表示位域的结构。

例如,来看看MS-DOS操作系统(通常简称为DOS)是如何存储文件的创建和最后修改日期的。由于日、月和年都是很小的数,将它们按整数存储会很浪费空间。DOS只为日期分配了16位,其中5位用于日(day)4位用于月(month)7位用于年(year)

year month day
15-9 8-5 4-0

利用位域,可以定义相同形式的C结构:

struct file_date { 
    unsigned int day: 5; 
    unsigned int month: 4; 
    unsigned int year: 7; 
}; 

每个成员后面的数指定了它所占用位的长度。由于所有的成员的类型都一样,如果需要,可以简化声明:

struct file_date {
    unsigned int day: 5, month: 4, year: 7;
}; 

位域的类型必须是intunsigned intsigned int。使用int会引起二义性,因为有些编译器将位域的最高位作为符号位,另一些编译器则不会。

可移植性技巧: 将所有的位域声明为unsigned intsigned int

C99开始,位域也可以具有类型_Bool,以及其他额外的位域类型。可以将位域像结构的其他成员一样使用,如下面的例子所示:

struct file_date fd; 
fd.day = 28; 
fd.month = 12; 
fd.year = 8; /* represents 1988 */

注意,year成员是根据其相距1980年(根据微软的描述,这是DOS出现的时间)的时间而存储的。在这些赋值语句之后,变量fd的形式如下所示:

0 0 0 1 0 0 0 1 1 0 0 1 1 1 0 0
15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0

使用位运算符可以达到同样的效果,甚至可能使程序更快些。然而,使程序更易读通常比节省几微秒更重要。

使用位域有一个限制,这个限制对结构的其他成员不适用。因为通常意义上讲,位域没有地址,所以C语言不允许将&运算符用于位域。由于这条规则,像scanf这样的函数无法直接向位域中存储数据:

scanf("%d", &fd.day); /*** WRONG ***/ 

当然,可以用scanf函数将输入读入到一个普通的变量中,然后再赋值给fd.day


20.2.1 位域是如何存储的

我们来仔细看一下编译器如何处理包含位域成员的结构的声明。C标准在如何存储位域方面给编译器保留了相当的自由度。

编译器处理位域的相关规则与“存储单元”的概念有关。存储单元的大小是由实现定义的,通常为8位、16位或32位。当编译器处理结构的声明时,会将位域逐个放入存储单元,位域之间没有间隙,直到剩下的空间不够存放下一个位域。这时,一些编译器会跳到下一个存储单元的开始,而另一些则会将位域拆开跨存储单元存放。(具体哪种情况会发生是由实现定义的。)位域存放的顺序(从左至右,还是从右至左)也是由实现定义的。

前面的file_date例子假设存储单元是16位的(8位的存储单元也可以,编译器只要将month字段拆开,跨两个存储单元存放即可)。也可以假设位域是从右至左存储的(第一个位域会占据低序号的位)。

C语言允许省略位域的名字。未命名的位域经常用作字段间的“填充”,以保证其他位域存储在适当的位置。考虑与DOS文件关联的时间,存储方式如下:

struct file_time { 
    unsigned int seconds: 5; 
    unsigned int minutes: 6; 
    unsigned int hours: 5; 
}; 

(你可能会奇怪,怎么可能将秒——0~59范围内的数——存储在一个5位的字段中呢?实际上,DOS将秒数除以2,因此seconds成员实际存储的是0~29范围内的数。)如果并不关心seconds字段,则可以不给它命名:

struct file_time { 
    unsigned int : 5; /* not used */ 
    unsigned int minutes: 6; 
    unsigned int hours: 5; 
}; 

其他的位域仍会正常对齐,如同seconds字段存在时一样。

另一个用来控制位域存储的技巧是指定未命名的字段长度为0

struct s { 
    unsigned int a: 4; 
    unsigned int : 0; /* 0-length bit-field */ 
    unsigned int b: 8; 
}; 

长度为0的位域是给编译器的一个信号,告诉编译器将下一个位域在一个存储单元的起始位置对齐。假设存储单元是8位,编译器会给成员a分配4位,接着跳过余下的4位直到下一个存储单元,然后给成员b分配8位。如果存储单元是16位,编译器会给成员a分配4位,接着跳过12位,然后给成员b分配8位。


20.3 其他底层技术

前面几章中讲过的一些C语言特性也经常用于编写底层程序。作为本章的结尾,我们来看几个重要的例子:定义代表存储单元的类型使用联合来回避通常的类型检查,以及将指针作为地址使用。本节中还将介绍18.3节中没有讨论的volatile类型限定符。


20.3.1 定义依赖机器的类型

根据定义,char类型占据1字节,因此我们有时将字符当作字节,并用它们来存储一些并不一定是字符形式的数据。但这样做时,最好定义一个BYTE类型:

typedef unsigned char BYTE; 

对于不同的机器,我们还可能需要定义其他类型。x86体系结构大量使用了16位的字,因此下面的定义会比较有用:

typedef unsigned short WORD;

稍后的例子中会用到BYTEWORD类型。


20.3.2 用联合来提供数据的多个视角

虽然16.4节的例子中已经介绍了有关联合的便捷的使用方式,但在C语言中,联合经常被用于一个完全不同的目的:从两个或更多个角度看待内存块

这里根据20.2节中描述的file_date结构给出一个简单的例子。由于一个file_date结构正好放入两个字节中,可以将任何两个字节的数据当作一个file_date结构。特别是可以将一个unsigned short值当作一个file_date结构(假设短整数是16位)。下面定义的联合可以使我们方便地将一个短整数与文件日期相互转换:

union int_date { 
    unsigned short i; 
    struct file_date fd; 
}; 

通过这个联合,可以以两个字节的形式获取磁盘中文件的日期,然后提取出其中的 monthdayyear字段的值。反之,也可以以file_date结构构造一个日期,然后作为两个字节写入磁盘中。

下面的函数举例说明了如何使用int_date联合。当传入unsigned short参数时,这个函数将其以文件日期的形式显示出来:

void print_date(unsigned short n) 
{ 
    union int_date u; 
    u.i = n; 
    printf("%d/%d/%d\n", u.fd.month, u.fd.day, u.fd.year + 1980); 
} 

在使用寄存器时,这种用联合来提供数据的多个视角的方法会非常有用,因为寄存器通常划分为较小的单元。以x86处理器为例,它包含16位寄存器——AXBXCXDX。每一个寄存器都可以看作两个8位的寄存器。例如,AX可以被划分为AHAL这两个寄存器。

当针对基于x86的计算机编写底层程序时,可能会用到表示寄存器AXBXCXDX中的值的变量。我们需要访问16位寄存器和8位寄存器,同时要考虑它们之间的关系(改变AX的值会影响AHAL,改变AHAL也会同时改变AX)。为了解决这一问题,可以构造两个结构,一个包含对应于16位寄存器的成员,另一个包含对应于8位寄存器的成员。然后构造一个包含这两个结构的联合:

union { 
    struct { 
        WORD ax, bx, cx, dx; 
    } word; 
    struct { 
        BYTE al, ah, bl, bh, cl, ch, dl, dh; 
    } byte; 
} regs;

word结构的成员会和byte结构的成员相互重叠。例如,ax会使用与alah同样的内存空间。当然,这恰恰就是我们所需要的。下面是一个使用regs联合的例子:

regs.byte.ah = 0x12; 
regs.byte.al = 0x34; 
printf("AX: %hx\n", regs.word.ax); 

ahal的改变也会影响ax,所以输出是

AX: 1234 

注意,尽管AL寄存器是AX的“低位”部分,AH寄存器则是“高位”部分,但在byte结构中,alah之前。究其原因,当数据项多于一个字节时,在内存中有两种存储方式:“自然”序(先存储最左边的字节)或者相反的顺序(最后存储最左边的字节)。 第一种方式叫作大端(big-endian),第二种方式叫作小端(little-endian)C对存储的顺序没有要求,因为这取决于程序执行时所使用的CPU。一些CPU使用大端方法,另一些使用小端方法。这与byte结构有什么关系呢?原来,x86处理器假设数据按小端方式存储,所以regs.word.ax的第一个字节是低位字节。

通常我们不用担心字节存储的顺序。但是,在底层对内存进行操作的程序必须注意字节的存储顺序(regs的例子就是如此)。处理含有非字符数据的文件时也需要当心字节的存储顺序。

请注意!!用联合来提供数据的多个视角时要特别小心。把原始格式下有效的数据看作其他格式时就不一定有效了,因此有可能会引发意想不到的问题。


20.3.3 将指针作为地址使用

11.1节中我们已经看到,指针实际上就是一种内存地址。虽然通常不需要知道其细节,但在编写底层程序时,这些细节就很重要了。

地址所包含的位数与整数(或长整数)一致。构造一个指针来表示某个特定的地址是十分方便的:只需要将整数强制转换成指针就行。例如,下面的例子将地址1000(十六进制)存入一个指针变量:

BYTE *p; 
p = (BYTE *) 0x1000; /* p contains address 0x1000 */ 

程序——查看内存单元

下一个程序允许用户查看计算机内存段,这主要得益于C允许把整数用作指针。大多数CPU执行程序时处于“保护模式”,这就意味着程序只能访问那些分配给它的内存。这种方式还可以阻止对其他应用程序和操作系统本身所占用内存的访问。因此我们只能看到程序本身分配到的内存,如果要对其他内存地址进行访问,则将导致程序崩溃。

程序viewmemory.c先显示了该程序主函数的地址和主函数中一个变量的地址。这可以给用户一个线索去了解哪个内存区可以被探测。程序接下来提示用户输入地址(以十六进制整数格式)和需要查看的字节数,然后从该指定地址开始显示指定字节数的内存块内容。

字节按10个一组的方式显示(最后一组例外,有可能少于10个)。每组字节的地址显示在一行的开头,后面是该组中的字节(按十六进制数形式),再后面是该组字节的字符显示(以防字节恰好是表示字符的,有时候会出现这种情况)。只有打印字符(使用isprint函数判断)才会被显示,其他字符显示为点号。

假设int类型的值使用32位存储,且地址也是32位。地址按惯例用十六进制显示。

/*
viewmemory.c
--Allows the user to view regions of computer memory
*/
#include  
#include  
typedef unsigned char BYTE; 
int main(void) 
{ 
    unsigned int addr; 
    int i, n; 
    BYTE *ptr; 
    printf("Address of main function: %x\n", (unsigned int) main); 
    printf("Address of addr variable: %x\n", (unsigned int) &addr); 
    printf("\nEnter a (hex) address: "); 
    scanf("%x", &addr); 
    printf("Enter number of bytes to view: "); 
    scanf("%d", &n); 
    printf("\n"); 
    printf(" Address             Bytes              Characters\n"); 
    printf(" ------- -----------------------------  ----------\n"); 
    ptr = (BYTE *) addr; 
    for (; n > 0; n -= 10) { 
        printf("%8X ", (unsigned int) ptr); 
        for (i = 0; i < 10 && i < n; i++) 
            printf("%.2X ", *(ptr + i)); 
        for (; i < 10; i++) 
            printf(" "); 
        printf(" "); 
        for (i = 0; i < 10 && i < n; i++){ 
            BYTE ch = *(ptr + i); 
            if (!isprint(ch)) 
                ch = '.'; 
            printf("%c", ch); 
        } 
        printf("\n"); 
        ptr += 10; 
    } 
    return 0; 
} 

这个程序看起来有些复杂,这是因为n的值有可能不是10的整数倍,所以最后一组可能不到10字节。有两条for语句由条件i < 10 && i < n控制,这个条件让循环执行10次或n次(10n中的较小值)。还有一条for语句处理最后一组中缺失的字节,为每个缺失的字节显示三个空格。这样,跟在最后一组字节后面的字符就可以与前面的各行对齐了。

转换说明符%X在这个程序中与%x是类似的,这在7.1节中讨论过。不同的是%X按大写显示十六进制数位ABCDEF,而%x按小写显示这些字母。

下面是用GCC编译这个程序并在运行Linuxx86系统下测试的结果:

Address of main function: 804847c 
Address of addr variable: bff41154 

Enter a (hex) address: 8048000
Enter number of bytes to view: 40

Address              Bytes              Characters 
-------- -----------------------------  ----------
 8048000 7F 45 4C 46 01 01 01 00 00 00  .ELF...... 
 804800A 00 00 00 00 00 00 02 00 03 00  .......... 
 8048014 01 00 00 00 C0 83 04 08 34 00  ........4. 
 804801E 00 00 C0 0A 00 00 00 00 00 00  .......... 

让程序从地址8048000开始显示40个字节,这是main函数之前的地址。注意7F字节以及其后所跟的表示字母ELF的字节。这4个字节标识了可执行文件存储的格式(即ELF)。可执行和链接格式(Executable and Linking Format, ELF)广泛应用于包括Linux在内的UNIX系统。8048000x86平台下ELF可执行文件的默认装载地址。

再次运行该程序,这次显示从addr变量的地址开始的一些字节:

Address of main function: 804847c 
Address of addr variable: bfec5484 

Enter a (hex) address: bfec5484
Enter number of bytes to view: 64

Address                Bytes               Characters 
--------   -----------------------------   ---------- 
BFEC5484   84 54 EC BF B0 54 EC BF F4 6F   .T...T...O 
BFEC548E   68 00 34 55 EC BF C0 54 EC BF   h.4U...T.. 
BFEC5498   08 55 EC BF E3 3D 57 00 00 00   .U...=W... 
BFEC54A2   00 00 A0 BC 55 00 08 55 EC BF   ....U..U.. 
BFEC54AC   E3 3D 57 00 01 00 00 00 34 55   .=W.....4U 
BFEC54B6   EC BF 3C 55 EC BF 56 11 55 00   ..<U..V.U. 
BFEC54C0   F4 6F 68 00 .oh. 

存储在这个内存区域的数据都不是字符格式,所以有点难以理解。但我们知道一点:addr变量占了这个区域的前4个字节。如果对这4个字节进行反转,就得到了BFEC5484,这就是用户输入的地址。为什么要反转呢?这是因为x86处理器按小端方式存储数据,如本节前面所述。


20.3.4 volatile类型限定符

在一些计算机中,一部分内存空间是“易变”的,保存在这种内存空间的值可能会在程序运行期间发生改变,即使程序自身并未试图存放新值。例如,一些内存空间可能被用于保存直接来自输入设备的数据。

volatile类型限定符使我们可以通知编译器,程序中的某些数据是“易变”的volatile限定符通常用于指向易变内存空间的指针的声明中:

volatile BYTE *p; /* p will point to a volatile byte */ 

为了解为什么要使用volatile,假设指针p指向的内存空间用于存放用户通过键盘输入的最近一个字符。这个内存空间是易变的:用户每输入一个新字符,这里的值都会发生改变。我们可能使用下面的循环获取键盘输入的字符,并将其存入一个缓冲区数组中:

while (缓冲区未满) { 
    等待输入; 
    buffer[i] = *p; 
    if (buffer[i++] == '\n') 
        break; 
} 

比较好的编译器可能会注意到这个循环既没有改变p,也没有改变*p,因此编译器可能会对程序进行优化,使*p只被取一次:

在寄存器中存储*p; 
while (缓冲区未满) { 
    等待输入; 
    buffer[i] = 存储在寄存器中的值; 
    if (buffer[i++] == '\n') 
        break; 
} 

优化后的程序会不断复制同一个字符来填满缓冲区,这并不是我们想要的程序。将p声明为指向易变的数据的指针可以避免这一问题的发生,因为volatile限定符会通知编译器*p每一次都必须从内存中重新取值。


20.4 对象的对齐(C1X)

受硬件布线的限制,或者为了提高存储器访问效率,要求特定类型的对象在存储器里的位置只能开始于某些特定的字节地址(内存地址都是按字节来顺序编排的,从第一个字节开始,每个字节都有一个地址,这些地址都叫字节地址),而这些字节地址都是某个数值N的特定倍数(以不超过实际的存储空间为限),这称为对齐(alignment)。更进一步,我们称那个对象是对齐于N

举一个实际的例子。假设在某台计算机上,int类型的对象,可以位于0x000000040x000000080x0000000C等字节地址上,都是4的倍数(但不能超过物理内存芯片可以提供的实际地址范围);long long int类型的对象,只能位于0x000000080x000000100x000000180x00000020等字节地址上,都是8的倍数(但不能超过物理内存芯片可以提供的实际地址范围)。

再比如,char类型的对象可以位于任何字节地址上,如0x000000010x000000020x00000003等,都是1的倍数(但不能超过内存的实际地址范围)。

注意!!这里没有提到字节0x00000000。在C中,这是一个特殊的字节地址,任何对象都不能起始于这个位置。

对于完整的对象类型来说,“对齐”限制了它在存储器中可以被分配到的地址。实际上,这是一个由C实现定义的整数值。

未对齐的存储器访问对不同的计算机来说会有不同的效果。在有些硬件架构上(比如Intel x86系列),未对齐的访问不会引发实质性的问题,也不会影响结果的正确性,但会使处理器对存储器的访问变得笨拙;在另一些硬件架构上,未对齐的访问将导致总线错误。


20.4.1 对齐运算符_Alignof(C1X)

C11开始,可以用运算符_Alignof得到指定类型的对齐值。_Alignof运算符的操作数要求是用括号括起来的类型名:

[_Alignof 表达式] _Alignof(类型名)

_Alignof运算符的结果类型是size_t。注意,这个运算符不能应用于函数类型和不完整的对象类型。如果应用于数组,则返回元素类型的对齐需求。下面是一个应用_Alignof运算符的例子:

# include  
void f (void) 
{ 
    printf("%zu, %zu, %zu, %zu\n", 
        _Alignof (char), 
        _Alignof (int), 
        _Alignof (int [33]), 
        _Alignof (struct {char c; float f;}) 
    ); 
}
//'z'常用于指定整数类型的长度为size_t

20.4.2 对齐指定符_Alignas和头(C1X)

C11开始在变量的声明里新增了对齐指定符。为此,还新增了关键字_Alignas。对齐指定符的语法格式为

[对齐指定符] _Alignas(类型名) 
            _Alignas(常量表达式)

以上的第一种形式等价于_Alignas (_Alignof (类型名))。对齐指定符只能在声明里使用,或者在复合字面量中使用,强制被声明的变量按指定的要求对齐。例如:

int _Alignas(8) foo; 
struct s {int a; int _Alignas (8) bar;}; 

以上代码将使int类型的对象foo和结构类型的成员bar8字节对齐。

C11新增了一个头,它很简单,只是定义了4个宏。宏alignas被定义为关键字_Alignas;宏alignof被定义为关键字_Alignof;宏__alignas_is_defined__alignof_is_defined分别被定义为整型常量1,并分别表示alignasalignof已经定义。


问与答

问1:为什么说&|运算符产生的结果有时会跟&&||一样,但又不总是如此呢?

答:我们来比较一下i & ji && j(对|||是类似的)。只要ij的值是01(任何组合都可以),两个表达式的值就是一样的。然而,一旦ij是其他的值,两个表达式的值就不会始终一致。例如,如果i的值是1,而j的值是2,那么i & j的值是0ij之间没有哪一位同为1),而i && j的值是1。如果i的值是3,而j的值是2,那么i & j的值是2,而i && j的值是1。另一个区别是副作用。计算i & j++始终会使j自增,而计算i && j++有时会使j自增。

问2:谁还会在意DOS存储文件日期的方式呢?DOS不是已经被淘汰了吗?

答:大部分情况下是这样的。但是,目前仍然有大量的文件是多年前创建的,其日期是按DOS格式存储的。不管怎样,DOS文件日期是一个很好的示例,它可以告诉我们如何使用位域。

问3“大端”“小端”这两个术语是从哪里来的?

答:在Jonathan Swift的小说《格列佛游记》中,两个虚拟的小人国 LilliputBlefuscu为煮熟的鸡蛋应该从大的一端敲开还是从小的一端敲开而争执不休。选择当然是任意的,就像数据项中字节的顺序一样。


写在最后

本文是博主阅读《C语言程序设计:现代方法(第2版·修订版)》时所作笔记,日后会持续更新后续章节笔记。欢迎各位大佬阅读学习,如有疑问请及时联系指正,希望对各位有所帮助,Thank you very much!

你可能感兴趣的:(C语言,c语言,笔记,开发语言)