【正点原子STM32】C语言重点知识(配置MDK支持C99、位操作清零置一、带参数的宏定义、头文件的条件编译和代码条件编译、关键字、结构体指针、代码规范)

发布时间:2024年01月23日

一、stdint.h简介

  • 配置MDK支持C99

二、位操作

  • 如何给寄存器某个位赋值(清零置一)

三、宏定义

  • 带参数的宏定义

四、条件编译

  • 头文件的条件编译和代码条件编译

五、extern声明
六、类型别名(typedef)

  • 类型别名应用

七、结构体

  • 应用举例(定义&使用)
  • 应用举例(ST源码,使用类型别名)

八、指针

  • 指针使用的两大最常见问题

九、代码规范

  • 其他规范

十、总结


【C语言】关键字

一、stdint.h简介

在这里插入图片描述
stdint.hC 语言标准库中的一个头文件,它在 C99 标准中被引入,目的是提供更为可移植性的整数类型定义。这个头文件定义了一组与位数相关的整数类型,例如有符号整数(integers)和无符号整数(unsigned integers),以确保在不同平台上的整数类型的大小和范围是一致的。

以下是 stdint.h 中定义的一些常见的整数类型:

  • int8_t:8 位有符号整数

  • int16_t:16 位有符号整数

  • int32_t:32 位有符号整数

  • int64_t:64 位有符号整数

  • uint8_t:8 位无符号整数

  • uint16_t:16 位无符号整数

  • uint32_t:32 位无符号整数

  • uint64_t:64 位无符号整数

此外,还有一些标准宏定义用于表示最小和最大的整数值,例如:

  • INT8_MIN, INT16_MIN, INT32_MIN, INT64_MIN:有符号整数的最小值
  • INT8_MAX, INT16_MAX, INT32_MAX, INT64_MAX:有符号整数的最大值
  • UINT8_MAX, UINT16_MAX, UINT32_MAX, UINT64_MAX:无符号整数的最大值

这些类型和宏的定义旨在提供可靠的整数大小和范围,以便程序员可以编写更加可移植的代码,而不用担心不同平台上整数类型的差异。

在 MDK5.34 路径下的 include 文件夹中,stdint.h 可能包含有适用于 ARM Cortex-M 微控制器的特定定义。这使得在嵌入式系统中使用 stdint.h 更加方便,因为可以根据具体的微控制器架构来定义整数类型。

配置MDK支持C99

Options for Target 目标选项 > C/C++ > C99 Mode
在这里插入图片描述

二、位操作

按位运算符、逻辑运算符
在这里插入图片描述
位操作是一种对二进制位进行操作的方法,常用于处理底层硬件控制、优化算法和处理位图等场景。以下是常见的位操作运算符及其含义:

  1. &(按位与):

    • 对两个二进制数的每一位执行逻辑与操作。结果中的每一位都是两个数对应位上的最小值。
    • 例如:1010 & 1100 = 1000
  2. |(按位或):

    • 对两个二进制数的每一位执行逻辑或操作。结果中的每一位都是两个数对应位上的最大值。
    • 例如:1010 | 1100 = 1110
  3. ^(按位异或):

    • 对两个二进制数的每一位执行逻辑异或操作。结果中的每一位都是两个数对应位上的不同值。
    • 例如:1010 ^ 1100 = 0110
  4. ~(按位取反):

    • 对一个二进制数的每一位执行逻辑取反操作,即将 0 变为 1,将 1 变为 0。
    • 例如:~1010 = 0101
  5. <<(左移):

    • 将一个二进制数的所有位向左移动指定的位数,右侧补零。
    • 例如:1010 << 2 = 101000
  6. >>(右移):

    • 将一个二进制数的所有位向右移动指定的位数,左侧根据符号位补零或补一(对于有符号整数)。
    • 例如:1010 >> 2 = 10

这些位操作运算符在低级别的编程和嵌入式系统开发中经常被使用,因为它们可以高效地操作二进制数据。在处理寄存器、位掩码和优化算法时,位操作非常有用。
在这里插入图片描述

如何给寄存器某个位赋值(清零置一)

在这里插入图片描述
在 C 语言中,对寄存器的某个位进行赋值通常使用位操作来实现。

常见的三种方式:

方法一:

temp &= 0xFFFFFFBF;  // 清除位6,将其置为0
temp |= 0x00000040;  // 将位6设置为1

这里使用了按位与运算 & 和按位或运算 |。首先,temp &= 0xFFFFFFBF; 将位6清零,然后 temp |= 0x00000040; 将位6设置为1。

方法二:

temp &= ~(1 << 6);   // 清除位6,将其置为0
temp |= 1 << 6;      // 将位6设置为1

这里使用了位掩码和按位异或运算。temp &= ~(1 << 6); 使用位掩码 ~(1 << 6) 清零位6,然后 temp |= 1 << 6; 将位6设置为1。

方法三:

temp ^= 1 << 6;      // 切换(翻转)位6的值

这里使用了按位异或运算 ^temp ^= 1 << 6; 会将位6的值进行翻转,即如果位6原来是0,则变成1;如果原来是1,则变成0。

这些方法的选择通常取决于编码的风格和习惯,方法二是比较常见和推荐的一种方式,因为它直接使用位操作符,更加清晰和易读。

三、宏定义

在这里插入图片描述
宏定义是 C 语言中的一种预处理指令,用于创建代码中的简单文本替换
通过宏定义,你可以为一个标识符(宏名)指定一个特定的文本,在程序编译前会进行文本替换,提高了代码的可读性、易改性,同时也可以用于一些常量或者简单表达式的定义。

你提到的宏定义的基本语法为:

#define 标识符 字符串

其中,标识符是宏定义的名字,字符串是与该标识符相关联的文本。这个文本可以是常数、表达式、格式串等。

下面是两个宏定义的例子:

#define PI 3.14159

这个宏定义将标识符 PI 关联到常数 3.14159,在代码中使用 PI 将被替换为 3.14159

#define HSE_VALUE 8000000U

这个宏定义将标识符 HSE_VALUE 关联到常数 8000000U,在代码中使用 HSE_VALUE 将被替换为 8000000U

在实际的程序开发中,宏定义经常用于定义常数、配置参数、简化代码等方面。需要注意的是,在使用宏定义时,要确保宏名和关联的文本不会与其他部分产生冲突,以免造成意外的替换。

带参数的宏定义

宏定义为什么要使用do{……}while(0)形式
在这里插入图片描述
带参数的宏定义是 C 语言中一种强大的工具,允许在代码中实现简单的代码生成。带参数的宏定义的语法如下:

#define 宏名(参数列表) 代码块

其中,宏名是宏定义的名字,参数列表是传递给宏的参数,代码块是宏的具体实现。

例子中定义了一个带参数的宏 LED1(x),它接受一个参数 x。这个宏用于控制 LED1 的状态,根据传入的参数 x 的值来设置 GPIO 的输出状态。

在宏定义中,使用了 do { ... } while(0) 结构。这种结构的目的是创建一个语句块,尽管在 C 语言中并不需要这样的结构,但在宏定义中使用它的好处在于可以确保宏在被调用时总是作为一个语句块执行。

这样设计的原因是避免在宏的使用过程中受到语法的限制,例如:

if (condition)
    LED1(1);
else
    do_something_else();

如果没有 do { ... } while(0) 结构,上面的代码可能会因为缺少大括号而导致语法错误。使用这种结构,即使在 if 语句中使用宏,也能确保宏始终作为一个整体执行,避免了潜在的错误。

总的来说,带参数的宏定义是一种强大的代码生成工具,但在使用时需要小心,确保宏定义的展开不会引发意外的行为。

四、条件编译

【预处理命令】
在这里插入图片描述
条件编译是一种在编译阶段根据条件选择性地包含或排除代码的技术。在 C 语言中,条件编译主要通过预处理指令来实现。以下是常用的条件编译指令:

  1. #if:编译预处理条件指令,类似于 if 语句,根据条件判断是否编译一段代码。

    #if condition
        // code to be compiled if condition is true
    #endif
    
  2. #ifdef:判断某个宏是否已被定义,如果宏已经定义,则编译相应的代码。

    #ifdef MACRO_NAME
        // code to be compiled if MACRO_NAME is defined
    #endif
    
  3. #ifndef:判断某个宏是否未被定义,如果宏未定义,则编译相应的代码。

    #ifndef MACRO_NAME
        // code to be compiled if MACRO_NAME is not defined
    #endif
    
  4. #elif:若前面的条件不满足,则判定新的条件,类似于 else if

    #if condition1
        // code to be compiled if condition1 is true
    #elif condition2
        // code to be compiled if condition2 is true
    #endif
    
  5. #else:若前面的条件不满足,则执行后面的语句,类似于 else

    #if condition
        // code to be compiled if condition is true
    #else
        // code to be compiled if condition is false
    #endif
    
  6. #endif#if#ifdef#ifndef 的结束标志,标识条件编译块的结束。

    #if condition
        // code to be compiled if condition is true
    #endif
    

这些条件编译指令允许根据不同的条件选择性地包含或排除代码,从而实现在不同情况下编译不同的代码。这在处理不同平台、不同配置或不同功能需求时非常有用。

头文件的条件编译和代码条件编译

在这里插入图片描述
头文件的条件编译和代码条件编译都是 C 语言中常用的技术,用于在编译时根据条件选择性地包含或排除代码。

头文件的条件编译:

#ifndef _LED_H
#define _LED_H

#include "./SYSTEM/sys/sys.h"

// 此处是头文件的内容

#endif
  • #ifndef _LED_H:如果宏 _LED_H 未被定义,则执行以下代码。
  • #define _LED_H:定义宏 _LED_H,表示头文件已被包含,防止重复包含。
  • #include "./SYSTEM/sys/sys.h":包含其他头文件或声明。

这样的结构确保头文件内容只会在第一次被包含时有效,避免了重复包含的问题。

代码条件编译:

#if SYS_SUPPORT_OS
    // code
#endif
  • #if SYS_SUPPORT_OS:如果宏 SYS_SUPPORT_OS 的值为真(非零),则编译以下代码块;否则,忽略该代码块。
  • // code:在条件满足时编译的代码块。

这样的结构可以根据 SYS_SUPPORT_OS 宏的值来选择性地编译一段代码。如果 SYS_SUPPORT_OS 宏为真,那么 // code 部分将被编译;否则,该部分将被忽略。

总的来说,这两种条件编译技术在软件开发中常用于处理不同的编译环境、配置选项,以及实现代码的可移植性和灵活性。

五、extern声明

在这里插入图片描述
extern 关键字在 C 语言中用于声明一个变量或函数,表示该变量或函数是在其他文件中定义的,以便在当前文件中引用。

示例中使用了 extern 来声明变量和函数:

  1. extern uint16_t g_usart_rx_sta;:这行代码声明了一个 uint16_t 类型的全局变量 g_usart_rx_sta,表示该变量在其他文件中定义,当前文件中只是引用该变量。

  2. extern void delay_us(uint32_t nus);:这行代码声明了一个返回类型为 void、带有一个 uint32_t 类型参数的函数 delay_us。同样,它表示该函数在其他文件中定义,当前文件中只是引用该函数。

通过这样的声明,编译器知道这些变量和函数在其他文件中定义,而在当前文件中只是引用。在链接阶段,链接器会负责将这些引用与实际的定义关联起来。

这种机制在多个文件构成的大型项目中非常有用,允许不同的源文件之间共享变量和函数,从而实现模块化开发。

六、类型别名(typedef)

在这里插入图片描述
typedef 是 C 语言中用于为现有数据类型创建新的名字(类型别名)的关键字。这可以用来简化复杂的类型声明,提高代码的可读性。

你的示例中使用了 typedef 来创建三个新的数据类型别名:

typedef unsigned char 		uint8_t;
typedef unsigned short int 	uint16_t;
typedef unsigned int 		uint32_t;

这里分别为 unsigned charunsigned short intunsigned int 这三种数据类型创建了新的名字 uint8_tuint16_tuint32_t。这些新的名字可以在代码中像普通的数据类型一样使用。

例如,你可以使用这些类型别名来声明变量:

uint8_t myByte;     // 使用 uint8_t 声明变量
uint16_t myInt;     // 使用 uint16_t 声明变量
uint32_t myLong;    // 使用 uint32_t 声明变量

这样做的好处是,使得代码更具可读性,并且如果以后需要更改底层的数据类型,只需要修改 typedef 部分而不需要修改整个代码。这也有助于提高代码的可维护性。

类型别名应用

在这里插入图片描述
在示例中,使用了 typedef 来创建了一个名为 GPIO_TypeDef 的结构体类型别名,使得在定义变量时更加简洁、易读。

首先,未使用 typedef 的结构体定义:

struct GPIO_TypeDef
{
     __IO uint32_t   CRL;
     __IO uint32_t   CRH;
     // 其他成员...
};

struct GPIO_TypeDef gpiox;

在这个定义中,struct GPIO_TypeDef 是结构体类型的标签,需要在每次声明变量时都加上 struct 关键字。

为了避免每次都写完整的类型,可以使用 typedef 来创建结构体类型别名:

typedef struct
{
     __IO uint32_t   CRL;
     __IO uint32_t   CRH;
     // 其他成员...
} GPIO_TypeDef;

GPIO_TypeDef gpiox;

这样,typedef 关键字允许你在定义结构体的同时为其创建了一个类型别名 GPIO_TypeDef,在后续的代码中可以直接使用 GPIO_TypeDef 来声明变量,而不再需要写 struct

这种方式提高了代码的可读性,使得结构体类型的使用更加简洁,并且如果以后需要修改结构体的定义,只需修改一处 typedef 部分而不会涉及到变量声明的地方。

七、结构体

在这里插入图片描述
结构体(struct),它是由若干基本数据类型集合组成的一种自定义数据类型,也被称为聚合类型。结构体允许你将不同类型的数据组织在一起,形成一个新的数据类型。

以下是结构体的基本定义语法:

struct 结构体名
{
    成员列表;
} 变量名列表(可选)

你的例子中定义了一个名为 student 的结构体,并声明了两个结构体变量 stu1stu2

struct student
{
    char *name;     /* 姓名 */
    int num;        /* 学号 */
    int age;        /* 年龄 */
    char group;     /* 所在学习小组 */
    float score;    /* 成绩 */
} stu1, stu2;

这个结构体包含了五个成员:姓名(name)、学号(num)、年龄(age)、所在学习小组(group)和成绩(score)。而后面的 stu1stu2 是两个结构体变量的实例。

你可以通过点操作符来访问结构体的成员,例如:

stu1.num = 12345;
stu1.age = 20;
stu1.score = 95.5;

这样的定义和使用方式使得你能够更灵活地组织和操作一组相关的数据。

应用举例(定义&使用)

在这里插入图片描述
示例定义了一个名为 student 的结构体,然后声明了两个结构体变量 stu3stu4。接着,对其中一个结构体变量 stu3 进行了赋值操作。这样的结构体定义和使用方式在实际编程中非常常见,用于组织和管理相关的数据。

#include <stdio.h>

// 定义结构体
struct student
{
    char *name;      /* 姓名 */
    int num;         /* 学号 */
    int age;         /* 年龄 */
    char group;      /* 所在学习小组 */
    float score;     /* 成绩 */
};

int main()
{
    // 声明结构体变量
    struct student stu3, stu4;

    // 对结构体变量进行赋值
    stu3.name = "张三";
    stu3.num = 1;
    stu3.age = 18;
    stu3.group = 'A';
    stu3.score = 80.9;

    // 输出结构体变量的值
    printf("姓名: %s\n", stu3.name);
    printf("学号: %d\n", stu3.num);
    printf("年龄: %d\n", stu3.age);
    printf("小组: %c\n", stu3.group);
    printf("成绩: %.2f\n", stu3.score);

    return 0;
}

这个简单的程序演示了结构体的定义和使用。在实际的应用中,结构体通常用于表示实体的属性,比如学生、员工等。通过定义结构体,可以更清晰地组织数据,并方便地对其进行操作。在上述代码中,你可以看到如何声明结构体、定义结构体变量,并对结构体成员进行赋值和输出。

应用举例(ST源码,使用类型别名)

在这里插入图片描述
在这个例子中,我们看到了一种在嵌入式开发中常见的用法,即使用类型别名来定义一个结构体,以方便后续使用。这段代码摘自 STM32 HAL 库中的 stm32f1xx_hal_gpio.h 头文件,该头文件定义了 STM32 系列微控制器的 GPIO 初始化结构体类型。

typedef struct
{
  uint32_t Pin;      /* 引脚号 */
  uint32_t Mode;     /* 工作模式 */
  uint32_t Pull;     /* 上下拉 */
  uint32_t Speed;    /* IO 速度 */
} GPIO_InitTypeDef;

这里使用 typedef 创建了一个名为 GPIO_InitTypeDef 的类型别名,它是一个结构体类型,包含了四个成员:PinModePullSpeed。这样的类型别名使得在代码中使用该结构体更为方便,而不需要每次都写完整的结构体声明。

在实际的 STM32 项目中,你可以使用这个结构体类型来初始化 GPIO 引脚的配置,例如:

GPIO_InitTypeDef GPIO_InitStruct = {0};

// 初始化 GPIO 引脚配置
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;

HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

这段代码演示了如何使用 GPIO_InitTypeDef 结构体类型来初始化 GPIO 引脚的配置。这种方式使得代码更为清晰,同时也提高了可读性和可维护性。

八、指针

在这里插入图片描述
指针就是内存的地址
指针变量是保存了指针的变量

以下是一些补充说明:

  1. 指针的定义和初始化:

    char *p_str = "This is a test!";
    

    这里定义了一个指向字符型数据的指针 p_str,并将其初始化为指向字符串常量 “This is a test!” 的首地址。这个指针可以用来访问字符串的各个字符。

  2. 指针的解引用:

    *p_str;
    

    * 运算符用于解引用指针,取得指针所指内存地址上的值。在这个例子中,*p_str 表示取得 p_str 所指向的字符串的第一个字符。

  3. 取地址运算符:

    &p_str;
    

    & 运算符用于取得变量的地址。在这个例子中,&p_str 表示取得 p_str 变量的地址。

  4. 指针变量的类型:

    int *p_int;
    

    这里定义了一个指向整数型数据的指针变量 p_int。指针变量的类型应与所指向的数据类型相匹配。

指针是 C 语言中一个强大而灵活的特性,能够提供直接的内存访问和更高效的数据处理。然而,正确地使用指针也需要谨慎,因为错误的指针操作可能导致程序崩溃或产生难以调试的 bug。
在这里插入图片描述
在这个例子中,buf 是一个包含 5 个 uint8_t 类型元素的数组,而 p_buf 是指向 buf 数组首元素的指针。下面解释每个操作的效果:

uint8_t buf[5] = {1, 3, 5, 7, 9};
uint8_t *p_buf = buf;

*p_buf = 10;  // 修改 buf[0] 的值为 10
// 此时 buf 数组变为 {10, 3, 5, 7, 9}

p_buf[0] = 20;  // 修改 buf[0] 的值为 20
// 此时 buf 数组变为 {20, 3, 5, 7, 9}

p_buf[1] = 30;  // 修改 buf[1] 的值为 30
// 此时 buf 数组变为 {20, 30, 5, 7, 9}

p_buf++;  // 将指针 p_buf 移动到下一个元素,即 buf[2]

*p_buf = 40;  // 修改 buf[2] 的值为 40
// 此时 buf 数组变为 {20, 30, 40, 7, 9}

p_buf[0] = 50;  // 修改 buf[2] 的值为 50
// 此时 buf 数组变为 {20, 30, 50, 7, 9}

这个例子演示了指针和数组的关系。指针 p_buf 指向数组 buf 的首元素,通过指针操作可以修改数组中相应位置的值。每次对指针的操作都会影响到指针指向的位置。

指针使用的两大最常见问题

在这里插入图片描述
两大指针使用常见问题是非常重要的,确保正确分配内存并避免越界访问对于程序的稳定性和安全性至关重要。

1. 未分配(申请)内存就用

在这种情况下,指针没有被正确初始化,试图通过未分配内存的指针进行写操作可能导致不可预测的结果。正确的做法是先分配足够的内存,然后再使用指针。

char *p_buf = (char *)malloc(sizeof(char) * 3);  // 分配内存
if (p_buf != NULL) {
    p_buf[0] = 100;
    p_buf[1] = 120;
    p_buf[2] = 150;
    // 使用 p_buf
    free(p_buf);  // 释放内存
}

2. 越界使用

越界访问指的是尝试访问数组或分配内存之外的内存区域。这可能导致程序崩溃或者产生不可预测的行为。正确的做法是确保指针在合法的范围内进行访问。

uint8_t buf[5] = {1, 3, 5, 7, 9};
uint8_t *p_buf = buf;

// 正确的访问
for (int i = 0; i < 5; i++) {
    p_buf[i] = p_buf[i] + 10;
}

// 错误的越界访问
// p_buf[5] = 200;  // 这里越界了,是错误的操作

总体而言,在使用指针时,始终要确保正确地分配了内存,并在访问数组或者其他数据结构时保持越界访问的风险最小。

九、代码规范

《嵌入式单片机 C代码规范与风格.pdf》
在这里插入图片描述
遵循这样的代码规范可以提高代码的可读性和可维护性。下面是一些简要的解释和例子,以更清晰地说明规范:

  1. 小写字母和驼峰命名法:

    int my_variable;
    void myFunction() {
        // 函数体
    }
    
  2. Doxygen 风格注释:

    /**
     * @brief 这是一个函数的简要说明
     * @param 参数1 描述参数1
     * @param 参数2 描述参数2
     * @return 返回值说明
     */
    int myFunction(int param1, int param2) {
        // 函数体
    }
    
  3. 使用空格进行对齐:

    if (condition) {
        // if 语句块
    } else {
        // else 语句块
    }
    
  4. 函数之间留有一个空行:

    void function1() {
        // 函数体
    }
    
    void function2() {
        // 函数体
    }
    
  5. 独立程序块之间有一个空行:

    // 程序块1
    
    // 程序块2
    
  6. 全局变量和指针命名:

    int g_global_variable;
    int *p_global_pointer;
    
  7. 语句单独占一行,使用大括号:

    if (condition) {
        // if 语句块
    } else {
        // else 语句块
    }
    
    for (int i = 0; i < 10; i++) {
        // for 循环体
    }
    

以上规范是很好的实践,可以提高代码的一致性和可读性,使得代码更容易理解和维护。

其他规范

  1. 宏定义命名规范:

    #define MAX_BUFFER_SIZE 256
    

    使用大写字母和下划线,确保宏定义的可读性和清晰性。

  2. 枚举命名规范:

    enum Color {
        RED,
        GREEN,
        BLUE
    };
    

    使用大写字母,枚举成员使用大写字母,用下划线分隔。

  3. 文件命名规范:

文件名一般使用小写字母,可以用下划线或驼峰命名法。文件名应该清晰地反映文件的内容。

  1. 局部变量命名规范:
void myFunction() {
    int local_variable;
    // 函数体
}

使用小写字母和驼峰命名法。

  1. 避免使用全局变量:

    尽量避免使用全局变量,尤其是在不同模块之间共享全局变量。推荐使用函数参数和返回值来传递信息。

  2. 避免过长的函数和复杂的嵌套:

    函数应该足够简洁,一个函数应该完成一个特定的任务。过长的函数和复杂的嵌套结构会降低代码的可读性。

  3. 常量命名规范:

    const int MAX_RETRY_COUNT = 5;
    

    使用大写字母和下划线,以便清晰地区分常量。

  4. 错误处理和日志记录:

    错误处理应该及时和清晰,对于日志记录,使用合适的级别,确保日志信息对于调试和问题追踪是有用的。

  5. 一次只做一件事(Single Responsibility Principle):

    每个函数和模块应该有一个清晰的目标,并只负责完成这个目标。这有助于提高代码的可维护性和可测试性。

这些规范可以根据团队的具体需求进行调整,但保持一致性和清晰性是至关重要的。

十、总结

在这里插入图片描述
在这里插入图片描述

文章来源:https://blog.csdn.net/m0_62140641/article/details/135607921
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。