C语言——预处理指令

一、预定义符号

在C语言中,有一些预定义的符号(也称为预定义宏),它们提供了关于编译器和编译过程的信息。这些符号在编译时由编译器自动定义,可以在程序中使用。以下是一些常见的预定义符号(这里的下划线都是两个连续的下划线):

  1. __FILE__:当前源文件的名称(字符串字面量)。

  2. __LINE__:当前源代码行的行号(整数常量)。

  3. __DATE__:编译日期,格式为 "Mmm dd yyyy"(字符串字面量)。

  4. __TIME__:编译时间,格式为 "hh:mm:ss"(字符串字面量)。

  5. __STDC__:如果编译器遵循ISO C标准,则定义为1。

  6. __STDC_VERSION__:编译器遵循的ISO C标准的版本号,例如199901L表示C99,201112L表示C11。

  7. __STDC_HOSTED__:如果编译器运行在宿主环境中(即操作系统之上),则定义为1;如果编译器运行在独立环境中(没有操作系统),则未定义。

  8. __func__(C99及以后):当前函数的名称(字符串字面量)。

这些预定义符号可以在程序中用于调试、日志记录或其他需要编译时信息的场合。例如,你可以使用它们来打印出错信息的位置。

例子:

#include <stdio.h>

void some_function() {
    printf("Function: %s\n", __func__);
    printf("File: %s, Line: %d\n", __FILE__, __LINE__);
}

int main() {
    printf("Compiled on: %s %s\n", __DATE__, __TIME__);
    some_function();
    return 0;
}

运行结果:

在这个例子中,__func__ 用于打印当前函数的名称,__FILE__ 和 __LINE__ 用于打印当前文件名和行号,__DATE__ 和 __TIME__ 用于打印编译日期和时间。

可以通过__STDC__测试编译器是否遵循ANSI C标准(ANSI C标准和ISO C标准指的是同一个标准,只是在不同的时间段由不同的组织发布)。

可以通过下面的程序测试是否遵循ANSI C标准:

#include <stdio.h>

int main() {
#ifdef __STDC__
    printf("followed");
#else
    printf("not followed");
#endif
    return 0;
}

在Visual Studio 2022中运行得到:

Visual Studio 2022 默认情况下可能不会完全遵守 ANSI 标准。

我们可以通过在项目属性中设置预处理器定义来定义__STDC__宏。在Visual Studio中,你可以通过以下步骤来定义这个宏:

  1. 打开你的项目。

  2. 在菜单栏中选择“项目” > “属性”。

  3. 在属性页中,选择“配置属性” > “C/C++” > “预处理器”。

  4. 在“预处理器定义”中添加__STDC__

  5. 点击“应用”然后“确定”。

完成这些步骤后,重新编译你的程序,就会看到输出"followed"。

设置后的运行结果:

在linux环境下,使用gcc编译这段程序:

然后运行,可以得到:

说明gcc是遵循ANSI C标准的。

二、#define

在C语言中,#define 是一种预处理指令,用于定义宏。它告诉编译器在实际编译之前进行文本替换。

注意:宏定义后面没有分号

1、对象式宏(Object-like Macros)

对象宏是最简单的宏,通常用来定义常量或替换文本。它们没有参数,并在预处理器运行时简单地替换代码中出现的所有实例。例如:

#define BUFFER_SIZE 1024

在这个例子中,BUFFER_SIZE 是一个对象宏,它在所有后续代码中将被替换为 1024

对象式宏也可以用来定义字符串:

#define SITE_NAME "example.com"

或者进行更复杂的文本替换:

#define LONG_LINE "This is a very, very long " \
                   "line of code that we wrap " \
                   "across several lines for " \
                   "readability"

这里要使用续行符 \ ,在C语言中,\ 符号被称为续行符(Line Continuation Character)。它的作用是告诉编译器,当前这一行代码在逻辑上并未结束,而是延续到下一行。这在编写长表达式或宏定义时非常有用,因为它允许开发者将代码分成多行,以提高代码的可读性。

或者甚至可以进行一段代码的替换:

#define PRINT for(int i = 0;i < 10;i++) {\
    printf("*");\
}

对象式宏甚至可以定义为空,以便在编译时有选择地启用或禁用代码部分:

#define DEBUG 
// 定义为空宏

2、函数式宏(Function-like Macros)

函数式宏看起来像函数调用,但它们不会生成函数调用的开销。它们在预处理时像模板一样展开,可以带参数。例如:

#define MAX(a, b) ((a) > (b) ? (a) : (b))

这个例子定义了一个函数式宏 MAX,用来计算两个值的最大值。注意,宏参数周围的括号非常重要,它们确保了宏参数作为整体参与计算,避免了运算符优先级的问题,或者说防止实参是表达式的问题。

函数式宏也可以实现更复杂的代码结构,例如:

#define FOREACH(item, array) \
  for(int keep = 1, \
          count = 0,\
          size = sizeof (array) / sizeof *(array); \
      keep && count != size; \
      keep = !keep, count++) \
    for(item = (array) + count; keep; keep = !keep)

这个宏 FOREACH 可以用来遍历数组,它通过一系列的循环和计数变量实现。

#include <stdio.h>

#define FOREACH(item, array) \
  for(int keep = 1, \
		  count = 0,\
		  size = sizeof (array) / sizeof *(array); \
	  keep && count != size; \
	  keep = !keep, count++) \
	for(item = (array) + count; keep; keep = !keep)

int main() {
	int arr[5] = { 1,2,3,4,5 };
	int* item = arr;
	FOREACH(item, arr) {
		printf("%d ", *item);
	}
	return 0;
}

运行结果:

这个FOREACH宏是为C语言提供一种类似于其他高级语言中的 foreach 循环的机制。这个宏使用两层 for 循环来遍历数组中的每个元素。

详细介绍这个函数式宏:

#define FOREACH(item, array) \

这一行开始定义了一个名为 FOREACH 的宏,它需要两个参数:itemarrayitem 将成为指向当前数组元素的指针,array 是要迭代的数组。

  for(int keep = 1, \
          count = 0,\
          size = sizeof (array) / sizeof *(array); \

这里初始化了三个变量:keepcountsize

  • keep 用来控制循环的继续或终止。开始时被设为 1(true),表示循环应该执行。
  • count 用来在数组中迭代,一开始设为 0。
  • size 用来记录数组中元素的数量,通过 sizeof (array) / sizeof *(array) 计算得出。sizeof (array) 给出整个数组所占的字节数,sizeof *(array) 给出数组中单个元素所占的字节数。二者相除就得到了数组中元素的数量。
      keep && count != size; \

这里是外层 for 循环的条件部分,它表明只要 keeptruecount 小于 size,循环就应该继续执行。

      keep = !keep, count++) \

这是外层 for 循环的迭代部分。在每次迭代的最后,keep 的值会被取反(这里的作用主要是在内层循环中使用),并将 count 加 1 以指向下一个数组元素。

    for(item = (array) + count; keep; keep = !keep)

这是内层 for 循环,它实际上只执行一次。item 被赋值为指向当前数组元素的指针,其偏移是 count。内层循环的条件是 keep 仍然为 true。由于内层循环内部没有语句,因此它实际上只用来赋值 item。在内层循环的迭代部分,keep 再次被取反,这会导致内层循环结束。

对于内层循环,实际上的作用就是给 item 赋值,这里的 keep 就是保证内层循环在每次外层循环中只进行一次。为什么不只用一个单独的语句例如这样:

    item = (array) + count;

而是使用一个只循环一次的循环呢?

实际上就是为了使用形式是这样:

int my_array[] = {1, 2, 3, 4, 5};
int *item;
FOREACH(item, my_array) {
    // 这里可以使用 *item 来访问数组的当前元素
    printf("%d\n", *item);
}

如果使用上面的一个单独语句的形式,就无法像这样的形式使用了。

3、注意事项

1.多加圆括号

宏在编译阶段就被替换,而且这种替换就只是单纯简单的替换。就是因为这里是简单的替换,所以会导致一些小问题。例如下面:

#include <stdio.h>

#define NUM 2 + 3

int main() {
	printf("%d\n", 3 * NUM * 2);
	return 0;
}

可能有人会认为这里会打印36,但是实际上的运行结果:

就是因为这里是简单的替换,没有经过其他的处理,这里实际上是被替换成了:

	printf("%d\n", 3 * 2 + 3 * 2);

所以会是上面的结果。

应当改为:

#define NUM (2 + 3)

上面提到了函数式宏宏参数周围的括号非常重要,它们确保了宏参数作为整体参与计算,所以说宏参数周围要多加圆括号。

例如下面(错误示例):

#include <stdio.h>

#define MUL(a,b) a * b

int main() {
	printf("%d\n", MUL(2 + 3,3 + 2));
	return 0;
}

这里的运行结果:

这不是我们期望的结果,这里的原因就是这里的宏被踢换成了:

	printf("%d\n", 2 + 3 * 3 + 2);

所以没有的到我们想要的结果。

上面的代码应当改为(正确示例):

#include <stdio.h>

#define MUL(a,b) ((a) * (b))

int main() {
	printf("%d\n", MUL(2 + 3,3 + 2));
	return 0;
}

运行结果:

这才是我们需要的结果。

2.字符串中的宏名不会被替换

#include <stdio.h>

#define NUM 100

int main() {
	printf("NUM");
	return 0;
}

这里的运行结果为:

字符串中的宏名不会被替换。

3.宏定义不用加分号

可能有很多人将宏定义与typedef弄混,typedef最后是要加分号的:

typedef unsigned int uint;

而宏定义是不用加分号的:

#define NUM 100

但是可能有些情况加了分号也没错,但是你要清楚这是错误的:

#include <stdio.h>

#define NUM 100;

int main() {
	int n = NUM;
	printf("%d\n",n);
	return 0;
}

这里是可以编译通过的,运行结果也是对的:

这里是因为替换成了这样:

	int n = 100;;

这里的两个分号的第二个分号就是一个空语句,就像下面这样:

	int n = 100;
    ;

这里语法没有问题,所以这里只是一个巧合,如果代码是下面这样,便会报错:

#include <stdio.h>

#define NUM 100;

int main() {
	printf("%d\n",NUM);
	return 0;
}

这里编译器会报错:

因为这里被替换成了:

	printf("%d\n",100;);

这里是有语法错误的。

4、宏的替换规则

  1. 参数替换:当宏被调用时,如果宏的参数包含任何 #define 创建的宏,这些参数会先被替换。这意味着,参数中的宏会在宏体内部展开之前被处理。

  2. 宏展开:替换文本(即宏的体)被插入到程序原来调用宏的地方,同时,宏参数会被替换成实际传递给宏的值。这相当于一个文本替换过程,不涉及编译器的语法或语义分析。

  3. 再次扫描替换:展开后的代码会再次被扫描以查找并替换其他 #define 定义的宏。这意味着在宏展开之后,如果新插入的文本中包含了其他宏,这些宏也会被展开。

注意事项:

  • 避免递归:宏在展开时不可以直接或间接地引用自身,这样会引起递归,导致预处理器进入无限循环。预处理器通过某些机制避免这种情况发生,比如一旦宏开始展开,它的名称就会暂时从预处理器的搜索范围内移除,直到展开完成。

  • 字符串常量中的宏不会被替换:如果代码中的字符串常量包含了能与宏同名的文本,预处理器不会将其视为宏,因此不会进行替换。字符串常量是在编译时处理的字面量文本,应该保持原样。

三、#和##

1、字符串化操作符 #

当在宏定义中使用 # 时,它被称为字符串化(Stringizing)操作符。它的作用是将宏参数转换成一个字符串字面量。这意味着,它会在参数的左右两边分别添加双引号 ("), 从而生成一个字符串。

例如,在宏定义 #define STR(x) #x 中,当你使用 STR(test) 时,它会展开成 "test"

#include <stdio.h>

#define STR(para) #para

int main() {
	int a = 10;
	printf("The value of %s if %d\n", STR(a), a);
	return 0;
}

这里会被替换成:

	printf("The value of %s if %d\n", "a", a);

所以运行结果:

还可以这样:

#include <stdio.h>

#define PRINT(para) printf("The value of %s is %d\n",#para,para)

int main() {
	int a = 10;
	PRINT(a);
	return 0;
}

运行结果:

甚至可以是这样:

#include <stdio.h>

#define PRINT(para) printf("The value of "#para" is %d\n", para)

int main() {
	int a = 10;
	PRINT(a);
	return 0;
}

运行结果:

实际上这里是被替换成了:

    printf("The value of ""a"" is %d\n", para);

在C语言中,字符串字面量可以被直接连接在一起。这意味着,如果你在代码中写下了两个相邻的字符串字面量,它们会被编译器自动合并成一个字符串。这个特性称为字符串字面量的拼接。或者说这是因为两个连续的双引号 "" 之间的空字符串被忽略,然后它们被合并成一个字符串。

所以这里会合成为:

    printf("The value of a is %d\n", para);

所以这里的运行结果是上面那样。

2、连接操作符 ##

## 在宏定义中用作连接(Token-pasting)操作符。它的目的是将两个宏参数连接起来,形成一个新的符号(Token)。这可以用于动态地生成变量名、函数名或其他标识符。

例如,假设有一个宏定义 #define CONCAT(a, b) a ## b,使用 CONCAT(name, 1) 会展开成 name1,连接了 name 和 1 两个标识符。

#include <stdio.h>

#define CAT(para,num) para##num

int main() {
	int num1 = 10;
	printf("%d\n", CAT(num, 1));
	return 0;
}

运行结果:

这里是被替换成了:

	printf("%d\n", num1);

使用示例:

#include <stdio.h>

#define CAT(para,num) para##num

int main() {
	int i = 0;
	for (i = 0; i < 10; i++) {
		int CAT(num, i) = i * 10;
		printf("%d ", CAT(num, i));
	}
	printf("\n");
	return 0;
}

运行结果:

四、对比函数式宏和函数

1、函数式宏(Function-like Macros)

  1. 宏是通过#define预处理器指令定义的。例如:
    #define SQUARE(x) ((x) * (x))
  2. 宏在编译前由预处理器处理,代码中的宏调用会被替换为宏定义中的代码。
  3. 宏不进行类型检查,因此可以接受任何类型的参数。
  4. 宏调用没有函数调用的开销,因为它是直接替换的,这可能带来性能优势。
  5. 宏参数在宏体内可能会被多次求值,这可能导致副作用(如多次修改全局变量)。
  6. 宏可能会降低代码的可读性和维护性,因为它们不是真正的函数,可能会导致难以追踪的错误。

2、函数(Functions)

  1. 函数是通过函数声明和定义来创建的。例如:
    int square(int x) {
        return x * x;
    }
  2. 函数在编译时被处理,包括类型检查和代码优化。
  3. 函数有严格的类型检查,参数和返回值必须符合声明的类型。
  4. 函数调用涉及参数压栈、跳转和返回等开销,这可能比宏调用慢。
  5. 函数参数通常只被求值一次,减少了由于多次求值引起的副作用。
  6. 函数通常提供更好的可读性和维护性,因为它们遵循标准的代码组织和调用规则。

3、宏的一些特性

宏的参数中可以是一个类型,例如 int 。

例如:

#include <stdio.h>

#define DECLARETION(type,name) type name

int main() {
	DECLARETION(int, a);
    //被替换成了int a;
	a = 0;
	printf("%d\n", a);
	return 0;
}

运行结果:

这里的这个宏可以声明一个特定类型和名字的变量。

还可以这样:

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

#define MALLOC(num,type) (type*)malloc(sizeof(type) * num)

#define FREE(ptr) free(ptr);ptr = NULL

int main() {
	int* arr = MALLOC(20, int);
    //被替换为int* arr = (int*)malloc(sizeof(int) * 20);
	if (arr == NULL) {
		perror("malloc");
		return -1;
	}
	int i = 0;
	for (i = 0; i < 20; i++) {
		arr[i] = i * 10;
	}
	for (i = 0; i < 20; i++) {
		printf("%d ", arr[i]);
	}
	printf("\n");
	FREE(arr);
    //被替换成free(arr);ptr = NULL;
	return 0;
}

运行结果:

五、其他预处理指令

1、#include文件包含

1.介绍

在C语言中,#include 是一个预处理指令,用于将一个源文件的内容插入到另一个源文件中。这通常用于包含标准库或自定义库的头文件,以便在程序中使用这些库中定义的函数、类型和宏。

#include 指令的基本语法如下:

#include <header_file>

#include "header_file"
  • <header_file>:用于包含标准库头文件。查找策略是,编译器会在标准库头文件目录中查找这些文件。
  • "header_file":用于包含用户自定义的头文件。查找策略是,编译器首先在当前源文件所在的目录查找,如果未找到,则会在标准库头文件目录中查找。

Linux的标准库头文件目录为:

    /usr/include

Visual Studio 2022的标准库头文件目录为:

C:\Program Files\Microsoft Visual Studio\2022\<Edition>\VC\Tools\MSVC\<Version>\include
  • <Edition> 可能是 CommunityProfessional, 或 Enterprise
  • <Version> 是安装的MSVC编译器版本,例如 14.30 或 14.31

用途

  1. 包含标准库头文件:例如,#include <stdio.h> 用于包含标准输入输出库,允许使用 printf 和 scanf 等函数。
  2. 包含自定义头文件:如果项目中有多个源文件,可以在每个源文件中使用 #include 来包含共享的头文件,这些头文件可能定义了函数原型、全局变量或宏等。
  3. 模块化编程:通过 #include,可以将程序分解为多个模块,每个模块负责不同的功能,有助于代码的组织和维护。

注意事项

  • 头文件通常包含函数和变量的声明,而不是定义。这样可以避免在多个源文件中重复定义导致的链接错误。
  • 使用 #include 时,确保头文件的路径正确,特别是对于自定义的头文件。
  • 头文件中可以使用 #ifndef#define#endif 等预处理指令来防止头文件内容被重复包含,这称为头文件保护。

2.头文件保护

假设这里我们有一个 test.h 头文件:


int Add(int a,int b);//一个函数声明

我们在源文件 test.c 中多次包含它:

#include "test.h"
#include "test.h"
#include "test.h"
#include "test.h"
#include "test.h"
#include "test.h"

int main() {

	return 0;
}

然后生成 test.i 文件可以看到:

# 1 "test.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "test.c"
# 1 "test.h" 1

int Add(int a,int b);
# 2 "test.c" 2
# 1 "test.h" 1

int Add(int a,int b);
# 3 "test.c" 2
# 1 "test.h" 1

int Add(int a,int b);
# 4 "test.c" 2
# 1 "test.h" 1

int Add(int a,int b);
# 5 "test.c" 2
# 1 "test.h" 1

int Add(int a,int b);
# 6 "test.c" 2
# 1 "test.h" 1

int Add(int a,int b);
# 7 "test.c" 2

int main() {

 return 0;
}

这里的 test.i 文件由于我们包含了六次 test.h 头文件,而产生了六个 Add 函数的声明,如果我们多次包含的头文件的代码很多,就会导致产生的 test.i 文件很大,那怎么防止头文件多次包含呢?就可以使用下面方法:

将头文件的内容改为这样:

#ifndef __TEST_H__
#define __TEST_H__

int Add(int a,int b);//一个函数声明

#endif

我们可以将函数声明等放到中间,这样就可以保证这些函数声明只被包含一次。

就像这里,如果是第一次包含这个头文件,那么 __TEST_H__ 宏就没有被定义,所以下一步就是定义这个宏,然后将这个头文件的内容包含进源文件,然后就是这个分支结束 #endif ,如果再一次包含这个头文件,第一个指令 #ifndef __TEST_H__ 就会检测到这个宏被定义过了,就直接跳到了 #endif 后面了,头文件内容就不会被再次包含。

我们再次执行上面的步骤,发现生成的 test.i 文件是这样的:

# 1 "test.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "test.c"
# 1 "test.h" 1



int Add(int a,int b);
# 2 "test.c" 2






int main() {

 return 0;
}

可以发现这里的函数声明只有一个。

实际上这个代码被称为“头文件保护”(header file guard)或“包含卫士”(include guard)。这种机制用于防止头文件的内容在同一编译单元中被多次包含,从而避免重复定义错误。

为什么要头文件保护:

在大型项目中,头文件可能会被多个源文件包含。如果没有头文件保护,每次包含头文件时,其内容都会被重复处理,可能导致重复定义错误,尤其是在定义全局变量或结构体时。使用头文件保护可以确保头文件的内容在每个编译单元中只被处理一次。

我们可能会在 stdio.h 头文件中看到:

#ifndef _INC_STDIO
#define _INC_STDIO

//函数声明、类型定义、宏定义等。

#endif // _INC_STDIO

头文件保护的工作原理:

  1. #ifndef _INC_STDIO:这行代码检查是否未定义宏 _INC_STDIO

  2. #define _INC_STDIO:如果 _INC_STDIO 未被定义,这行代码将定义它。这样,如果在同一编译单元中再次尝试包含此头文件,由于 _INC_STDIO 已经被定义,头文件的内容将不会再次被处理。

  3. 中间的代码:这部分包含头文件的主要内容,如函数声明、类型定义、宏定义等。

  4. #endif // _INC_STDIO:这行代码结束 #ifndef 条件编译指令。#endif 确保所有在 #ifndef#endif 之间的代码只有在 _INC_STDIO 未定义的情况下才会被处理。

这里的宏 _INC_STDIO 的命名规则:头文件保护的宏名称通常是根据头文件的名称来构造的,以确保其唯一性。在这个例子中,_INC_HEADERINC 部分通常是“include”的缩写,用于指示这是一个与包含(include)相关的宏。HEADER 是这个头文件的名称,以确保这个宏名的唯一性。

3、#pragma once

#pragma once 是一个非标准的预处理器指令,用于实现与头文件保护(include guards)类似的功能,即防止头文件的内容在同一编译单元中被多次包含。这个指令的目的是简化头文件保护的实现,并减少代码量。当编译器遇到 #pragma once 指令时,它会确保该头文件在同一编译单元中只被包含一次。这意味着,如果头文件已经被包含过,编译器将忽略后续的包含尝试。

优点

  1. 简化代码:不需要像使用头文件保护那样定义宏和编写 #ifndef#define#endif 结构,代码更简洁。
  2. 避免宏名称冲突:不需要为每个头文件选择唯一的宏名称,减少了潜在的命名冲突。

缺点

  1. 非标准#pragma once 不是C或C++标准的一部分,这意味着它可能不被所有编译器支持。虽然大多数现代编译器都支持它,但在某些旧的或特定的编译器中可能不适用。
  2. 可能不兼容:在跨平台或跨编译器开发时,使用 #pragma once 可能会遇到兼容性问题。

示例:

#pragma once
//函数声明、类型定义、宏定义等。

2、#pragma

1.#pragma pack()

#pragma pack() 是一个编译器特定的指令,用来改变编译器在内存中对结构体(struct)和联合体(union)的成员进行对齐的方式。这个指令可以帮助开发者控制数据结构的布局,降低内存占用,或确保与特定硬件或网络协议的二进制兼容性。

在没有特定对齐指令的情况下,编译器通常会按照目标平台的自然对齐方式对齐成员,这意味着数据会根据其类型的大小(通常是2、4、8或16字节)来对齐。这样的自然对齐可以提高处理器访问内存的效率,但有时会使用更多的内存(因为会填充空白字节来保证对齐)。

使用#pragma pack(),你可以减少或修改这些对齐填充,但这可能会牺牲性能以减少内存使用。

#pragma pack 的基本语法如下:

#pragma pack(push, n) // n 是新的对齐字节数
struct MyStruct {
    char a;
    int b;
    // ...
};
#pragma pack(pop)
  • #pragma pack(push, n):保存当前对齐设置,并设置新的对齐值为 n 字节。
  • #pragma pack(pop):恢复到最近的 #pragma pack(push,...) 设置的对齐。 

n 必须是2的幂(1、2、4、8等),并且通常不会超过处理器或操作系统的自然对齐大小。

示例:

没有使用 #pragma pack 的结构体:

struct MyStruct {
    char a;     // 占用 1 字节
                // 3 字节对齐填充
    int b;      // 占用 4 字节
};

在一个32位系统上,自然对齐可能是4字节。在这种情况下,MyStruct 会占用8字节(1字节实际数据 + 3字节填充 + 4字节实际数据)。

如果我们使用 #pragma pack

#pragma pack(push, 1)
struct MyStruct {
    char a;     // 占用 1 字节
    int b;      // 占用 4 字节
};
#pragma pack(pop)

现在,MyStruct 的总大小将是5字节,因为填充被取消了。

注意事项

  • 对齐对于性能至关重要,尤其是在数据密集型程序中,不适当的对齐可能会导致性能显著下降。
  • 修改默认的对齐可能会导致与其他编译器或平台的二进制兼容问题。
  • 在进行系统编程时,#pragma pack 可能非常有用,尤其是在与硬件设备直接通信或在网络通信中处理特定格式的数据包时。
  • #pragma pack 是非标准的,不同的编译器可能有不同的实现,这意味着在移植代码时需要特别注意。

2.更多与#pragma相关的预处理指令

  1. #pragma once: 这是一个常见的指令,用于防止头文件被多次包含。它告诉编译器在当前包含保护下只编译该文件一次,即使它在多个地方被包含。这类似于使用 #ifndef, #define, 和 #endif 的包含保护,但更简洁。

  2. #pragma warning(disable: n): 在某些编译器(如Microsoft Visual C++)中,这个指令用于禁用特定的编译器警告。n 是警告编号,你可以指定要禁用的警告。

  3. #pragma message("message"): 这个指令用于在编译时输出自定义消息。它通常用于调试或记录编译过程中的特定条件。

  4. #pragma hdrstop: 在某些编译器(如Borland C++)中,这个指令用于指定预编译头文件的停止位置。它有助于加快编译速度。

  5. #pragma region#pragma endregion: 这些指令用于在代码编辑器中创建可折叠的代码区域。这在大型代码库中特别有用,可以帮助组织和简化代码视图。

  6. #pragma pack(): 如前所述,这个指令用于控制结构体和联合体的内存对齐。

  7. #pragma comment(lib, "library"): 在Microsoft Visual C++中,这个指令用于将库文件链接到项目中。它允许你指定要链接的库名称。

  8. #pragma inline: 在某些编译器中,这个指令用于指示编译器将函数视为内联函数。

  9. #pragma code_seg(["section-name"[,"section-class"]]): 在Microsoft Visual C++中,这个指令用于指定代码段应该放在哪个内存段中。

  10. #pragma data_seg(["section-name"[,"section-class"]]): 这个指令用于指定数据段应该放在哪个内存段中。

这些 #pragma 指令的可用性和功能取决于你使用的编译器。在使用这些指令时,应查阅相应编译器的文档以确保正确使用。由于 #pragma 指令是非标准的,它们可能会导致代码的可移植性降低。因此,在使用这些指令时,应谨慎考虑其对代码跨平台兼容性的影响。

3、#undef

用于取消定义一个宏。当你使用 #define 定义了一个宏之后,你可以使用 #undef 来取消这个定义,从而使得该宏在后续的代码中不再有效。

#define MAX_VALUE 100
// 一些代码...
#undef MAX_VALUE

在这个例子中,MAX_VALUE 宏在 #undef 指令之后就不再定义了。这意味着在 #undef 之后的代码中,如果再次使用 MAX_VALUE,它将不再被预处理器替换。

#undef 的主要用途包括:

  1. 条件编译:你可以根据不同的条件定义或取消定义宏,从而控制哪些代码块被编译。

  2. 宏的重定义:在取消一个宏的定义后,你可以重新定义它,可能使用不同的值或定义。

  3. 确保宏未定义:在某些情况下,你可能需要确保一个宏在特定的代码块中未定义,这时可以使用 #undef

注意事项:

  • #undef 只能取消已经用 #define 定义过的宏。如果尝试取消一个未定义的宏,预处理器不会报错,但这样做没有效果。

  • 在使用 #undef 时,确保你清楚地知道哪些宏被取消定义了,以避免在后续代码中出现未预期的行为。

  • #undef 通常与条件编译指令(如 #ifdef, #ifndef, #if, #elif 等)一起使用,以控制代码的编译流程。

4、#error

#error指令在预处理时会产生一个错误信息,并且会停止编译过程。这通常用于在编译时检测到某些不应出现的条件或配置错误时,强制编译失败并通知用户。例如,你可以用它来确保编译器支持你代码所需的特定版本的语言特性:

#if __STDC_VERSION__ < 199901L
#error "需要C99或更高版本的支持"
#endif

这里可以强制编译器支持你代码所需的特定版本。

5、#line

#line指令用来修改编译器所报告的行号和文件名信息。这在你通过预处理器生成源代码或者在编译过程中包含了自动生成的代码时非常有用,因为它可以使编译器报告的错误和警告信息更加准确地反映原始源代码的位置。它的一般形式是:

#line linenum filename

例如:

#line 100 "test.c"

这会使得编译器从此处开始,将错误和警告报告在"test.c"的100行,而非实际代码文件中的行号。

实际上这个预处理指令就是通过更改第一节我们提到的预定义符号__LINE____FILE__来实现这个功能的。

6、#warning

#warning指令是GCC特有的,不是标准C或C++的一部分,它在预处理时会产生一个警告信息。尽管它不会像#error那样停止编译过程,但可以用来提醒开发者注意可能的问题或需要重视的地方。例如,提醒开发者某个特性已经被弃用:

#warning "这个特性已弃用,请使用新的API。"

请注意,不是所有的编译器都支持#warning预处理指令,它主要在GCC中使用。

7、#include_next

#include_next同样是GCC的特有指令,不属于标准C或C++。它指示预处理器在搜索路径上继续搜索下一个包含文件,忽略之前已经包含过的同名文件。这对于在编写包装或修改第三方库的头文件时非常有用,因为它允许在不修改原始头文件的情况下,重定义或扩展库的功能。语法与#include相似,但它的作用是找到并包含路径中下一个同名文件,而不是第一个。

#include_next <stdio.h>

这告诉编译器继续在它的搜索路径中查找另一个"stdio.h"文件,并将其包含进来。

六、条件编译

1、介绍

C语言中的条件编译是预处理器(preprocessor)的一个功能,它允许根据特定的条件来决定某部分代码是否参与编译。这样可以在编译时根据不同的需求和条件选择性地忽略或包含代码片段。

条件编译主要通过以下几个预处理指令来控制:

  1. #if: 用来检查一个条件是否为真,如果为真,则编译随后的代码。
  2. #elif: 与#if配合使用,在#if或另一个#elif的条件不满足时,检查另一个条件。
  3. #else: 在前面的#if#elif的条件不满足时,编译#else后的代码。
  4. #endif: 结束一个#if#elif#else#ifdef#ifndef条件编译块。#endif 后一般要注释上结束的是哪一个宏的判断。
  5. #ifdef: 如果定义了某个宏,则编译随后的代码。
  6. #ifndef: 如果没有定义某个宏,则编译随后的代码。

2、示例

例一:defined预处理指令运算符

你可能会在 stdio.h 头文件中看到这些代码:

#ifndef _STDIO_H_ // 如果没有定义_STDIO_H_宏
#define _STDIO_H_ // 定义_STDIO_H_宏,防止头文件被重复包含,上面有提到,这里是头文件保护

#ifdef _WIN32 // 如果定义了_WIN32宏,通常表示代码在Windows平台上编译
    #include <windows_stdio.h> // 包含Windows特定的stdio实现
#elif defined(__unix__) || defined(__unix) // 如果定义了__unix__或__unix宏,通常表示代码在Unix/Linux平台上编译
    #include <unix_stdio.h> // 包含Unix/Linux特定的stdio实现
#else // 如果没有定义上述宏,可能是其他平台
    #error "Unsupported platform" // 输出错误信息,表示平台不受支持
#endif

// 其他stdio.h的通用声明和定义

#endif // 结束_STDIO_H_的条件编译块

这里有一个语句:

#elif defined(__unix__) || defined(__unix)

这一句中的defined是一个预处理指令运算符,用来检查一个宏是否已经被定义。如果已定义,结果为真(非零)。它在#if#elif这样的预处理指令中使用,来帮助编译器决定是否包含特定的代码块。

这条指令的意思是,“如果__unix__或者__unix中的任意一个宏已经定义,那么执行随后的代码”。这是一种在不同平台间共享代码时常用的方法,因为某些平台可能会定义__unix__,而其他平台可能会定义__unix,这样的条件编译可以确保在任意一个(或两个都)定义的情况下,特定平台相关的代码会被包含。

defined预处理指令运算符:

如果 #ifdef 或者 #ifndef 只检查一个是否已定义或者未定义的话,那么:

#if defined(MACRO_NAME)
#ifdef MACRO_NAME

这两个语句就是等价的,以及下面的:

#if !defined(MACRO_NAME)
#ifndef MACRO_NAME

这两个语句也是等价的。

但是如果需要检查多个空宏,就不能通过一般的分支语句中经常使用的逻辑运算符(如&&||)来对多个空宏进行检查,这样是错误的:

#include <stdio.h>

#define MIN

//#define MAX

int main() {
#ifdef MIN && MAX
	printf("Both MIN and MAX are defined.\n");
#endif
	return 0;
}

就像这一段代码,运行结果是:​​​​​​

显然是错误的,因为 MAX 明显没有被定义,而且这里会有警告,是由于预处理器指令#ifdef后面跟了无效的内容。#ifdef指令只能检查单个空宏是否被定义,而不能接受空宏组成的逻辑表达式,因为空宏是没有值的,对于逻辑表达式来说,两个或多个没有值的宏是没法运算的

正确的代码应该是:

#include <stdio.h>

#define MIN

//#define MAX

int main() {
#if defined(MIN) && defined(MAX)
	printf("Both MIN and MAX are defined.\n");
#endif
	return 0;
}

这里运行后不会打印 Both MIN and MAX are defined. 这句话。

另外就是在多分支语句中要使用 #elif 判断一些空宏的话,也是要使用 defined 的。例如:

#include <stdio.h>

#define MIN
//#define MAX

int main() {
#if defined(MIN) && defined(MAX)
    printf("Both MIN and MAX are defined.\n");
#elif defined(MIN)
    printf("Only MIN is defined.\n");
#elif defined(MAX)
    printf("Only MAX is defined.\n");
#else
    printf("Neither MIN nor MAX is defined.\n");
#endif
    return 0;
}

这样写是正确的,运行结果为:

可见运行结果也是正确的。

如果写成下面这样:

#elif MIN
    printf("Only MIN is defined.\n");
#elif MAX
    printf("Only MAX is defined.\n");

编译器就会报错。问题出现在使用#elif MIN#elif MAX这样的预处理器指令。#if#elif需要一个可求值的条件表达式,而直接使用MINMAX并不构成有效的预处理器条件表达式,因为它们本身是一个空宏,本身没有任何值。

上面我们所说的都是基于空宏这个条件的一些问题,要使用 defined ,那如果这个宏不是空宏呢?

我们将上面的两个宏的值都设为1:

#include <stdio.h>

#define MIN 1
#define MAX 1

int main() {
#if MIN && MAX
    printf("Both MIN and MAX are defined.\n");
#elif MIN
    printf("Only MIN is defined.\n");
#elif MAX
    printf("Only MAX is defined.\n");
#else
    printf("Neither MIN nor MAX is defined.\n");
#endif
    return 0;
}

这样就不会出现那些问题了,因为这次的宏都具有了具体的值,对于逻辑运算符也可以运算了,对于#if#elif也有了一个具体的值。

但实际上,我们上面所说的使用 defined 的方法对于空宏和有值宏都是可用的,而这里的这种 defined 方法只对有值宏有用,对于空宏是会出现警告或者报错的。

而且这里的这个不使用 defined 的方法对于一些情况也会出现错误。例如有值宏的值为0时,虽然这里是有值宏,但是由于它的值为零,它在预处理器条件表达式中会被视为假,所以就可能导致一些问题,例如我们将上面的两个宏的值都改为0:

#include <stdio.h>

#define MIN 0
#define MAX 0

int main() {
#if MIN && MAX
    printf("Both MIN and MAX are defined.\n");
#elif MIN
    printf("Only MIN is defined.\n");
#elif MAX
    printf("Only MAX is defined.\n");
#else
    printf("Neither MIN nor MAX is defined.\n");
#endif
    return 0;
}

如果我们使用这种没用 defined 的方法,就会出现错误。这里的运行结果:

显然是错误的,因为这里的两个宏都是被定义过的。

如果我们使用带有 defined 的方法,就不会出现错误,就像下面这样:

#include <stdio.h>

#define MIN 0
#define MAX 0

int main() {
#if defined(MIN) && defined(MAX)
    printf("Both MIN and MAX are defined.\n");
#elif defined(MIN)
    printf("Only MIN is defined.\n");
#elif defined(MAX)
    printf("Only MAX is defined.\n");
#else
    printf("Neither MIN nor MAX is defined.\n");
#endif
    return 0;
}

运行结果:

可见这里是正确的。

总结,当我们要检查多个宏、空宏或者是值为0的有值宏时,最好使用 defined 预处理指令运算符来判断宏是否被定义。事实上,使用 defined 的方法是一种更加安全且通用的做法,它可以确保预处理器条件在检查宏是否被定义时不会因宏的值是0、非0或未定义而变得复杂或出错。

例二:嵌套分支

#define FEATURE_A 1
#define FEATURE_B 1

#if defined(FEATURE_A)
    // 如果定义了FEATURE_A,则编译以下代码
    #if defined(FEATURE_B)
        // 如果同时定义了FEATURE_A和FEATURE_B,则编译以下代码
        // 特性A和B都存在的代码
    #else
        // 只定义了FEATURE_A,没有定义FEATURE_B的代码
    #endif
#else
    // 如果没有定义FEATURE_A,则编译以下代码
    #if defined(FEATURE_B)
        // 只定义了FEATURE_B,没有定义FEATURE_A的代码
    #else
        // 特性A和B都不存在的代码
    #endif
#endif

在这个例子中,代码根据FEATURE_AFEATURE_B是否被定义来决定哪部分代码应该被包括在最终编译结果中。

例三:头文件中常见

#ifdef __cplusplus
extern "C" {
#endif

void C_function(); // C语言函数的声明

#ifdef __cplusplus
}
#endif

这个结构在C++和C混合编程时非常常见,尤其是在头文件中。它的目的是确保C++代码能够正确地调用C语言编写的函数。下面详细解释这个结构的作用和原理:

结构解析

开始部分:

#ifdef __cplusplus
extern "C" {
#endif

这部分代码用于检查是否在C++环境中编译(通过检查 __cplusplus 宏是否定义)。如果是,那么 extern "C" 告诉C++编译器,随后的代码应该按照C语言的链接规则(没有名称重整)来处理。extern "C" 是C++中的一个链接规范(linkage specification),用于告诉编译器以C语言的方式处理接下来的函数声明或定义。在C++中,函数名会根据函数参数类型等信息进行名称重整(name mangling),以便支持函数重载等特性。而C语言没有这种名称重整,因此使用 extern "C" 可以确保C++编译器不对函数名进行重整,从而使得C语言代码能够正确地调用这些函数。后面的 { 是包含代码块用的,是与下面的 } 呼应的,一对花括号代表这一块代码块使用C语言的方式处理接下来的函数声明或定义。

结束部分:

#ifdef __cplusplus
}
#endif

这部分代码标志着 extern "C" 块的结束。由于 extern "C" 块是以左大括号 { 开始,因此必须有个对应的右大括号 } 结束这个块。这个右大括号只有在C++环境下才会出现,因此必须放在 #ifdef __cplusplus 检查内。这样就避免了在C编译器中插入无法理解的 } 符号。

使用场景

当一个项目中同时包含C和C++代码时,可能需要从C++代码中调用C语言编写的函数。为了确保这种调用的正确性,C语言函数的声明需要放在 extern "C" 块中。这样,当这个头文件被C++编译器编译时,extern "C" 块内的函数声明会以C语言的方式处理;而当被C编译器编译时,由于 __cplusplus 未定义,extern "C" 块内的内容会被忽略,不会影响C语言的编译。

如果上面的示例代码在C++环境下会被预处理成:

extern "C" {

void C_function(); // C语言函数的声明

}

如果在C语言环境下会被预处理成:


void C_function(); // C语言函数的声明

例四:编译器的操作

#include <stdio.h>

#define MAX_SIZE 100

int main() {
#if MAX_SIZE < 100
	printf("less than 100\n");
#elif MAX_SIZE == 100
	printf("equal to 100\n");
#else
	printf("greater than 100\n");
#endif
	return 0;
}

当我们使用gcc对上面的代码进行这段指令时:

gcc -E test.c -o test.i

可以看到得到的 test.i 文件中主函数的内容变成了这样:

int main() {



 printf("equal to 100\n");



 return 0;
}

实际上编译器会通过这些条件编译指令来判断那些代码需要保留,那些代码需要抛弃。

七、命令行中定义或取消定义宏

1、介绍

许多C的编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程,这通常是通过使用编译器的命令行选项来实现的。这种技术允许开发者根据不同的编译时条件来生成程序的不同版本,这对于适应不同的硬件配置或满足特定的编译要求非常有用。

在GCC和Clang等流行的编译器中,你可以使用 -D 选项来定义宏(这里的 -D 选项与宏之间可以没有空格)。例如:

gcc -DARRAY_SIZE=100 source.c

在这个例子中,-DARRAY_SIZE=100 定义了一个名为 ARRAY_SIZE 的宏,其值为 100。在源文件 source.c 中,你可以使用 #ifdef#if 等预处理指令来检查 ARRAY_SIZE 是否被定义,并根据其值来编译不同的代码。

2、示例

假设你有一个源文件 array.c,其中包含一个数组的定义,你希望根据不同的宏定义来改变数组的大小:

#include <stdio.h>

#ifdef ARRAY_SIZE//检查是否自定义了大小
#define ARRAY_LENGTH ARRAY_SIZE//使用自定义大小
#else
#define ARRAY_LENGTH 50//默认大小
#endif

int main() {
    int myArray[ARRAY_LENGTH];
    printf("Array size: %d\n", ARRAY_LENGTH);
    return 0;
}

使用以下命令:

gcc -DARRAY_SIZE=100 array.c

编译后运行得到:

使用以下命令:

gcc -DARRAY_SIZE=200 array.c

编译运行后得到:

可以看到这里是可以在命令行定义符号的。

3、其他编译器选项

除了 -D 选项,编译器还提供了其他一些相关的选项,例如:

  • -U:用于取消定义一个宏。
  • -include:用于包含一个头文件,类似于在源文件中使用 #include

这些选项提供了灵活的预处理控制,使得开发者可以根据不同的编译时条件来生成定制的程序版本。

八、offsetof宏的实现

在我之前的文章《C语言——自定义数据类型(结构体内存对齐)-CSDN博客》中也详细介绍了这个宏。

offsetof 宏定义于 C 语言的标准库头文件 <stddef.h> 中,用于计算结构体中成员相对于结构体起始地址的字节偏移量。该宏对于实现一些底层、与内存布局相关的操作非常有用,特别是在涉及到从结构体成员地址推导出结构体实例地址的场合。

offsetof 宏的定义通常如下:

#define offsetof(type, member) ((size_t)&(((type *)0)->member))

这里的 type 是结构体体类型,member 是结构体中成员的名字。宏的工作原理是将一个假想的结构体的指针设置为 0(即假设结构体在内存地址 0 处开始,就是为了使起始地址与起始偏移量相同,都为0),然后通过访问结构体成员的方式来获取该成员相对于结构体起始位置的偏移量。

请注意,由于这种实现方式,offsetof 宏并不访问该成员的实际内存,它仅仅计算了一个偏移量。因此,即使结构体实例并不存在于内存地址 0,这种计算方法依然是有效的。

示例:

#include <stdio.h>
#include <stddef.h>

typedef struct example {
    int a;
    char b;
    int c;
}Example;

int main() {
    printf("%zu\n", offsetof(Example,a));
    printf("%zu\n", offsetof(Example,b));
    printf("%zu\n", offsetof(Example,c));
    return 0;
}

运行结果:

  • 23
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值