C语言——难点关键字:extern、static、struct、enum、union、volatile

在C语言的关键字中,extern、static、struct、enum、union、volatile可能是个难点。


一、extern

extern是C语言中的关键字,用于声明外部变量、函数和符号。

在C语言中,extern可以用来声明一个全局变量或函数,并指示它们是在其他文件中定义的。

extern可以在一个文件中声明一个全局变量或函数,在另一个文件中定义该变量或函数。这样,当编译器找不到变量或函数的定义时,它会在其他文件中寻找对应的extern声明。这种方式可以用于分割大型程序的源代码,将不同的功能模块放在不同的文件中进行开发。另外,对于函数的声明,如果函数已经在当前文件中定义了,那么extern关键字可以省略。因为默认情况下,函数的声明是全局可见的。

extern的一般语法格式如下:

extern 数据类型 变量名;
extern 返回类型 函数名(参数列表);

1、extern关键字在C语言中具体有以下几种作用

1、声明外部变量:extern可以在一个文件中声明一个全局变量,并指示该变量是在其他文件中定义的。这样,编译器在找不到变量的定义时会在其他文件中查找对应的extern声明。

// 文件1.c
extern int x; // 声明外部变量x

int main() {
    x = 10; // 在当前文件中给外部变量x赋值
    return 0;
}

// 文件2.c
int x; // 定义外部变量x

2、声明外部函数:extern可以在一个文件中声明一个函数,并指示该函数是在其他文件中定义的。这样,编译器在找不到函数的定义时会在其他文件中查找对应的extern声明。

// 文件1.c
extern void printHello(); // 声明外部函数printHello

int main() {
    printHello(); // 调用在其他文件中定义的外部函数printHello
    return 0;
}

// 文件2.c
#include <stdio.h>

void printHello() { // 定义外部函数printHello
    printf("Hello World!\n");
}

3、引用外部符号:extern可以用来引用其他文件中定义的全局变量、函数或符号。通过extern关键字,我们可以在当前文件中使用其他文件中定义的变量或函数。

4、共享全局变量:extern可以用于在多个源文件中共享全局变量。我们可以在一个文件中声明全局变量为extern,然后在其他文件中定义该变量,从而实现全局变量在多个文件中的共享。

5、跨文件函数调用:extern可以用于在一个文件中声明一个函数,在另一个文件中定义该函数。这样,我们可以在一个文件中调用在另一个文件中定义的函数,从而实现函数的跨文件调用。

2、使用extern的注意事项

1、extern关键字不能用于定义变量或函数,只能用于声明。因此,变量或函数的实际定义应该在其他地方进行。

2、当使用extern声明一个全局变量时,表示该变量是在其他文件中定义的。在当前文件中使用该变量时,需要确保该变量的定义在编译链接时可以找到。

3、可以多次使用extern关键字来声明同一个变量或函数,但只有第一次是有效的。

4、extern关键字不分配内存空间,它只是告诉编译器某个变量或函数是在其他地方定义的。

总之,extern关键字在C语言中用于声明外部变量、函数和符号,引用其他文件中定义的全局变量、函数或符号,实现全局变量的共享和函数的跨文件调用。使用extern时需要确保被引用的变量、函数或符号的定义在编译链接时可以找到。

二、static

static关键字,可以用于修饰变量、函数和块,具有重要作用。

总之,static关键字可以用于修饰全局变量、局部变量、函数和块,用于限制变量或函数的作用域,扩展变量的生命周期,并提供额外的封装和安全性。

1、static具体作用

1、修饰全局变量:当static修饰全局变量时,该变量的作用域仅限于当前文件。其他文件无法访问该变量,即使使用extern关键字声明也不行。这种使用方式可以实现全局变量的封装,避免了全局命名空间的污染。

// file1.c
#include <stdio.h>

static int count = 0;

void increment_count() {
    count++;
}

int get_count() {
    return count;
}

// file2.c
#include <stdio.h>

extern void increment_count();
extern int get_count();

int main() {
    increment_count();
    printf("Count: %d\n", get_count()); // 输出:Count: 1
    return 0;
}

在上面的示例中,我们在file1.c中定义了一个静态全局变量count,并提供了两个函数increment_count和get_count来分别增加count的值和获取count的值。由于count是静态全局变量,在其他文件中无法访问它的值。在file2.c中,我们使用extern关键字声明了这两个函数,并在main函数中调用increment_count函数并打印出get_count函数返回的值。

2、修饰局部变量:当static修饰局部变量时,该变量的生命周期被扩展至整个程序运行期间,而不仅仅限于其所在的代码块。每次执行到该变量定义的位置时,都会初始化一次,并且保留上一次的值。这种使用方式常用于需要保存状态的情况,如计数器或标志位。

#include <stdio.h>

void increment() {
    static int counter = 0; // 静态局部变量

counter++;
printf("Counter: %d\n", counter);
}

int main() {
    increment(); // 输出:Counter: 1
    increment(); // 输出:Counter: 2
    increment(); // 输出:Counter: 3

return 0;
}

在上面的示例中,我们定义了一个名为increment的函数,并在该函数内部定义了一个静态局部变量counter。每次调用increment函数时,都会对counter进行递增并打印出其值。由于counter是静态局部变量,它的生命周期被扩展至整个程序运行期间,每次调用increment函数时都会保留上一次的值。

比如下面这个按键的写法(来源于正点原子):

//按键处理函数
//返回按键值
//mode:0,不支持连续按;1,支持连续按;
//0,没有任何按键按下
//1,KEY0按下
//2,KEY1按下
//3,KEY3按下 WK_UP
//注意此函数有响应优先级,KEY0>KEY1>KEY_UP!!
u8 KEY_Scan(u8 mode)
{	 
	static u8 key_up=1;//按键按松开标志
	if(mode)key_up=1;  //支持连按		  
	if(key_up&&(KEY0==0||KEY1==0||WK_UP==1))
	{
		delay_ms(10);//去抖动 
		key_up=0;
		if(KEY0==0)return KEY0_PRES;
		else if(KEY1==0)return KEY1_PRES;
		else if(WK_UP==1)return WKUP_PRES;
	}else if(KEY0==1&&KEY1==1&&WK_UP==0)key_up=1; 	    
 	return 0;// 无按键按下
}

3、修饰函数:当static修饰函数时,该函数的作用域仅限于当前文件,其他文件无法调用该函数。这种使用方式可以实现函数的封装,避免了全局命名空间的污染,并提供额外的安全性,防止在其他文件中误调用。

// file1.c
#include <stdio.h>

static void private_function() {
    printf("This is a private function.\n");
}

void public_function() {
    printf("This is a public function.\n");
    private_function(); // 在同一文件中可以调用私有函数
}

// file2.c
#include <stdio.h>

extern void public_function();

int main() {
    public_function(); // 输出:This is a public function.
    return 0;
}

在上面的示例中,我们在file1.c中定义了一个静态函数private_function和一个公共函数public_function。私有函数private_function只能在同一文件中调用,而公共函数public_function可以在其他文件中使用。在file2.c中,我们使用extern关键字声明了public_function,并在main函数中调用它。
4、修饰块:当static修饰块内的局部变量时,该变量的生命周期被扩展至整个程序运行期间,与修饰局部变量的效果相同。

2、使用static的注意事项及特点

1、静态变量的作用域仅限于当前文件或当前代码块。这意味着其他文件无法直接访问静态全局变量,并且在块内的静态变量只能在该块内部使用。

2、静态变量的生命周期会被扩展至整个程序运行期间。这意味着静态变量在程序启动时初始化一次,在整个程序执行过程中保留其值,而不像非静态变量在每次进入代码块时都重新初始化。

3、静态函数只能在当前文件内部调用,其他文件无法直接调用。这样可以实现函数的封装,避免了全局命名空间的污染,并提供额外的安全性。

4、使用static修饰符不改变变量或函数的存储方式或访问权限。静态变量在内存中的存储位置与非静态变量相同,而静态函数仅在当前文件中可见。

5、静态变量和函数的命名应该有明确的含义并符合编码规范,以便于他人理解和维护代码。

6、尽量避免滥用static关键字。静态变量和函数具有较长的生命周期和局部作用域,因此应仔细考虑是否真正需要这种特性。

7、静态变量的存储空间是在编译时就确定的,并且它们的值在程序执行期间保持不变。因此,静态变量适用于需要在多次函数调用之间保持状态的情况,但也会增加程序的存储空间使用量。在使用static关键字时,应根据具体情况权衡存储空间的使用和代码的可读性、可维护性。

三、struct

更详细的内容请看这篇文章我爱学C语言之结构体

结构体(Struct)是一种用户自定义的数据类型,用于封装不同类型的数据项在内存中的连续分配。它可以将多个不同类型的数据组合在一起,形成一个新的数据类型,在程序中更方便地操作和管理这些数据。结构体由多个成员变量(也称为字段)组成,每个成员变量可以具有不同的数据类型。通过使用结构体,可以将相关的数据项打包在一起,提高代码的可读性、可维护性和可扩展性。
结构体的定义通常放在函数外部,类似于全局变量的定义。语法如下:

struct 结构体名
 {
    类型 成员1;
    类型 成员2;
    // ...
};

结构体可以包含基本数据类型(如int、float等)、指针、数组以及其他结构体作为其成员变量。例如:

struct Person
 {
    char name[20];
    int age;
    float height;
};

可以使用以下方式声明并初始化结构体变量:

struct Person person1;  // 声明一个person1结构体变量
person1.age = 20;       // 对结构体成员赋值

也可以直接在声明时进行初始化:

struct Person person2 = {"Bob", 25, 1.75};  // 声明并初始化一个person2结构体变量

访问结构体成员变量时使用点运算符(.):

printf("Name: %s\n", person2.name);
printf("Age: %d\n", person2.age);
printf("Height: %.2f\n", person2.height);

结构体成员也可以是指针类型,此时可以使用箭头运算符(->)来访问指向结构体的成员。例如:

struct Person *ptrPerson = &person1;
ptrPerson->age = 30;   // 通过指针访问age成员变量并赋值
printf("Age: %d\n", ptrPerson->age);   // 通过指针访问age成员变量并读取其值

通过点运算符或箭头运算符,可以方便地访问结构体的成员变量,并进行赋值和读取操作。这有助于组织和管理相关的数据项,并提高代码的可读性和可维护性。结构体还可以作为函数的参数和返回值,从而实现传递多个相关的数据项。通过结构体,可以将代码组织得更加清晰,并且方便地对数据进行操作和管理。

如下,关于pid和电机控制的两个结构体实例:

typedef struct pid_t
{
  float p; //pid比例系数           
  float i;//pid积分系数
  float d;//pid微分系数
  float target_val;//pid目标值
  float actual_val;//pid实际值
  float err[3];//连续的三次误差

  float pout;//比例环节输出
  float iout;//积分环节输出
  float dout;//微分环节输出
  float Sout;//总输出

  float input_max_err;   // 输入最大误差;
  float output_deadband; 

  uint32_t pid_mode;//pid模式
  uint32_t max_out;//总输出最大值
  uint32_t integral_limit;//积分限幅值

  void (*f_param_init)(struct pid_t *pid,
                       uint32_t pid_mode,
                       uint32_t max_output,
                       uint32_t inte_limit,
                       float p,
                       float i,
                       float d);//pid初始化函数
  void (*f_pid_reset)(struct pid_t *pid, float p, float i, float d);//pid重置函数

} pid_t;

typedef struct
{
Motor_direction dir;   //电机方向
Motor_state state;     //电机状态
float speed;           //电机速度
float pwm;       
pid_t pid;                  //电机PID参数
char * name;				//电机符号
void  (*motor_open)( );	    //电机启动函数
void  (*motor_close)( );	//电机关闭函数
void  (*motor_set_pwm)();   //设置PWM的函数
void  (*motor_set_speed)( );//电机PID速度环
float  (*motor_get_speed)();//获取电机速度
}motor_t;

四、enum

enum(枚举)是一种用户自定义的数据类型,用于定义一组具名的常量。枚举常量通常用于表示一组相关的取值,可以提高代码的可读性和可维护性。枚举通过在一个范围内列举可能的值来定义。每个值都有一个与之关联的名字,称为枚举常量。枚举常量在内部被赋予了整数值,其中第一个枚举常量默认值为0,后续枚举常量的值逐个递增。

枚举的定义通常放在函数外部,类似于全局变量的定义。语法如下:

enum 枚举名 {
    常量1,
    常量2,
    // ...
};

例如,我们可以定义一个表示星期几的枚举类型:

enum Weekday {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday
};

在这个例子中,枚举类型Weekday定义了7个枚举常量,分别是Monday、Tuesday、Wednesday、Thursday、Friday、Saturday和Sunday。它们对应的整数值依次为0、1、2、3、4、5和6。

要声明一个枚举变量,可以使用枚举类型名加上枚举常量进行声明:

enum Weekday today;  // 声明一个today枚举变量

可以将枚举常量赋值给枚举变量,并使用它们进行操作:

today = Monday;  // 将Monday赋值给today变量

if (today == Sunday) {
    printf("Today is Sunday!\n");
} else {
    printf("Today is not Sunday.\n");
}

除了逐个指定整数值外,还可以使用赋值运算符(=)为枚举常量指定特定的整数值。例如:

enum Month {
    January = 1,
    February = 2,
    March = 3,
    // ...
};

在这个例子中,January的值为1,February的值为2,以此类推。

枚举还可以作为函数的参数和返回值,从而实现传递一组相关的取值。通过枚举,可以提高代码的可读性和可维护性,以及减少出错的机会。

1、enum的主要作用

1、增加代码可读性:通过为一组相关的取值定义有意义的名称,枚举可以使代码更易于阅读和理解。使用枚举常量来代替硬编码的整数值,可以提高代码的可读性。

2、提供类型安全性:枚举类型在编译时会进行类型检查,只能赋予声明过的枚举常量或相同枚举类型的值。这可以避免意外地给枚举变量赋予无效的值,提供了一定的类型安全性。

3、减少出错机会:使用枚举可以减少程序员在编写代码时犯错的机会。通过使用枚举常量而不是直接使用数字,可以防止输入错误的整数值,从而降低了出错的概率。

4、提高代码可维护性:当需要修改一组相关的取值时,只需修改枚举定义即可,而不必在整个代码中搜索和替换每个使用该取值的地方。这样可以提高代码的可维护性,并降低维护成本。

5、枚举作为函数参数和返回值:枚举常量可以作为函数的参数和返回值,从而实现传递一组相关的取值。这可以使函数的目的更加清晰,并增强了函数的可读性和可理解性。

2、使用enum的注意事项

1、枚举常量的取值范围:枚举常量的取值范围由枚举类型的定义决定。在操作枚举变量时,要注意不要超出枚举常量的取值范围,否则可能导致意外的结果。

2、默认整数值的假设:枚举常量默认会被赋予整数值,其中第一个枚举常量默认值为0,后续枚举常量的值逐个递增。如果在定义枚举类型时指定了特定的整数值,则后续的枚举常量将以指定值作为基础进行递增。因此,在处理枚举类型时,要注意默认整数值的假设。

3、枚举类型的大小和存储方式:枚举类型的大小和存储方式取决于编译器的实现。通常情况下,枚举类型的大小等于其底层整数类型的大小,但这并非是C语言标准的要求。因此,在编写跨平台代码时要注意枚举类型的存储方式和大小的差异。

4、枚举作为函数参数和返回值的类型检查:当枚举类型作为函数的参数或返回值时,要确保传递的值或返回的值与枚举类型兼容。否则,在编译时可能会产生警告或错误。

五、union

union(联合)是一种特殊的数据类型,它允许在同一内存空间中存储不同类型的数据。与结构体不同,联合中的成员共享同一块内存,但只能同时存储其中一个成员的值。联合的大小取决于所使用的最大成员的大小。

联合由关键字union定义,其语法如下:

union UnionName {
    member1;
    member2;
    ...
};

其中,UnionName 是联合的名称,member1、member2 等是联合的成员。

#include <stdio.h>

union Data {
   int i;
   float f;
   char str[20];
};

int main() {
   union Data data;

   // 存储整数
   data.i = 10;
   printf("data.i : %d\n", data.i);

   // 存储浮点数
   data.f = 3.14;
   printf("data.f : %.2f\n", data.f);

   // 存储字符串
   strcpy(data.str, "Hello");
   printf("data.str : %s\n", data.str);

   return 0;
}

输出结果:

data.i : 10
data.f : 3.14
data.str : Hello

在这个示例中,我们定义了一个联合 Data,它包含了一个整数成员 i、一个浮点数成员 f 和一个字符数组成员 str。在主函数中,我们创建了一个 Data 类型的变量 data。首先将整数值 10 存储到联合的整数成员 i 中,并打印出来。然后,我们将浮点数值 3.14 存储到联合的浮点数成员 f 中,并打印出来。最后,我们将字符串 “Hello” 存储到联合的字符数组成员 str 中,并打印出来。

需要注意的是,由于联合的成员共享同一块内存空间,改变一个成员的值会影响其他成员的值。在本例中,存储新的成员值会覆盖之前存储的成员值。

1、使用联合时需要注意以下几点:

1、联合中的成员共享同一块内存空间:联合的各个成员共享同一块内存空间,改变一个成员的值会影响其他成员的值。因此,在使用联合时要确保对成员的操作是安全和合理的。

2、只能同时存储一个成员的值:在任意时刻,联合只能存储其中一个成员的值。存储新的成员值将覆盖之前存储的成员值。

3、成员的大小:联合的大小取决于所使用的最大成员的大小。因此,在使用联合时要考虑到成员的大小,以免超出联合的存储空间。

4、访问成员:可以使用运算符"."来访问联合的成员。但是,由于联合只能同时存储一个成员的值,因此要确保访问正确的成员。

5、联合的初始化:可以对联合进行初始化,但只能为其中的一个成员赋值。

2、使用联合的场景包括:

存储不同类型的数据:当需要在同一内存空间中存储不同类型的数据时,可以使用联合来实现。

节省内存空间:由于联合只会分配足够存储最大成员的内存空间,因此可以节省内存空间。

联合是一种特殊的数据类型,可以在同一内存空间中存储不同类型的数据。使用联合时要注意成员共享内存、只能同时存储一个成员的值以及成员的大小等细节。联合适用于存储不同类型数据或节省内存空间的场景。

六、volatile

volatile 关键字用于告诉编译器该变量是易变的,可能会被意外修改,从而禁止编译器对该变量进行优化。它适用于多线程环境下的共享数据访问、与硬件设备交互以及与信号处理函数交互等场景。但需要注意,volatile 关键字并不能解决多线程竞争的问题,还需要使用其他同步机制来确保数据的一致性和正确性。

1、使用volatile的作用

1、禁止编译器优化:编译器在编译过程中会进行各种优化,例如寄存器缓存、指令重排等。对于 volatile 变量,编译器不会对其进行这些优化,每次访问都会从内存中读取或写入,确保变量的值与实际情况一致。

2、保证多线程环境下共享数据的可见性:当多个线程访问同一块共享内存时,如果不使用 volatile 修饰共享变量,编译器可能会对变量进行缓存优化,使得不同线程之间的变量更新无法及时可见。使用 volatile 关键字可以告诉编译器,该变量可能会被其他线程修改,需要从内存中读取最新值。

3、与硬件设备交互:在嵌入式开发中,常常需要直接访问硬件设备的寄存器。由于这些寄存器的值可能会被硬件设备修改,因此需要使用 volatile 关键字来确保每次读取或写入都与实际硬件操作相对应。

4、与信号处理函数交互:在信号处理函数中,通常需要访问全局变量。由于信号处理函数是在异步环境下执行的,可能会中断程序的正常流程,因此需要使用 volatile 关键字来防止编译器对变量进行优化,确保信号处理函数能够正确地访问和更新变量。

下面是一个使用 volatile 的示例:

#include <stdio.h>
#include <unistd.h>

volatile int flag = 0;

void* thread_func(void* arg) {
    sleep(1);
    flag = 1;
    return NULL;
}

int main() {
    pthread_t tid;
    pthread_create(&tid, NULL, thread_func, NULL);

    while(!flag) {
        // 等待子线程修改 flag 的值
    }

    printf("flag is set to %d\n", flag);

    return 0;
}

在这个示例中,我们定义了一个 volatile 整数变量 flag,它用于标识一个条件是否满足。在主函数中,我们创建了一个子线程,在 1 秒钟之后将 flag 设置为 1。

在主线程中,我们使用一个循环来等待子线程修改 flag 的值。由于 flag 是 volatile 类型,编译器不会对其进行优化,保证每次访问都从内存中读取最新的值。当子线程将 flag 设置为 1 后,主线程退出循环,并打印出 flag 的值。

需要注意的是,虽然 volatile 关键字确保每次访问 flag 都从内存中读取最新的值,但它并不能解决多线程竞争的问题,也不能保证并发操作的正确性。在实际的多线程编程中,还需要使用其他同步机制(如互斥锁、条件变量)来保证数据的一致性和正确性。

2、volatile的通常使用情况

1、多线程环境下访问共享数据:当多个线程访问同一块共享内存时,为了避免编译器对变量进行优化(如寄存器缓存、指令重排等),我们可以使用 volatile 关键字来告诉编译器不要对该变量进行优化。

2、访问硬件寄存器:当我们需要直接访问硬件设备的寄存器时,为了确保每次读取或写入都与实际硬件操作相对应,我们可以将该寄存器定义为 volatile 类型。

3、与信号处理函数交互:在信号处理函数中,通常需要访问全局变量,这样就需要使用 volatile 关键字来防止编译器进行优化,以确保信号处理函数能够正确地访问和更新变量。

3、volatile 的使用需要注意以下几点

1、volatile 只影响编译器的代码优化行为,并不改变变量本身的存储方式或语义。

2、volatile 不提供原子性。它只能保证每次访问变量都从内存中读取或写入,而不能保证并发操作的正确性。

3、volatile 不能解决多线程竞争的问题,它只是告诉编译器不要对变量进行优化。如果涉及到多线程竞争,还需要使用其他同步机制(如互斥锁、原子操作)来确保数据的一致性和正确性。

文章结束,有误之处,望大佬指正

下一篇笔记关于(#)
结构体详解
所有C关键字简介

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐