瑾年aimee头像
C语言和嵌入式(STM32)知识点梳理封面图

C语言和嵌入式(STM32)知识点梳理

文章摘要

本文系统梳理了嵌入式STM32开发中常用的C语言知识点,面向初学者和有一定基础但希望巩固的开发者。内容涵盖:整数、浮点、布尔、枚举、数组、结构体等数据类型;const、volatile、static、extern、__IO等变量修饰符;位操作符(<<、>>、|、&、~、^)、指针与地址、函数、宏定义、条件编译等核心语法。还深入讲解了嵌入式内存布局(代码段、数据段、BSS、堆、栈)、中断服务函数编写规范、回调机制、常见编程陷阱及调试建议。最后通过一个按键中断控制LED闪烁频率并串口打印的实战例子,将上述知识点串联起来。适合配合STM32 HAL库和CubeMX工具学习,帮助读者快速掌握嵌入式C编程的必备技能。


C语言和嵌入式(STM32)知识点梳理

数据类型 —— 你的变量是做什么工作的

整数型(最常用)
类型别称 / ST推荐写法位数范围什么时候用
charint8_t8-128 ~ 127存 ASCII 字符或小整数
unsigned charuint8_t80 ~ 255串口收发的一字节数据、GPIO 引脚状态
shortint16_t16-32768 ~ 32767中等范围的计数
unsigned shortuint16_t160 ~ 65535ADC 值(0~4095)、数组长度
intint32_t32-2^31 ~ 2^31-1普通运算、计数
unsigned intuint32_t320 ~ 2^32-1毫秒时间戳、寄存器配置、地址
long longint64_t64很大大数运算、文件大小
unsigned long longuint64_t64超大文件系统用

为什么 ST 的 HAL 库用 uint8_t 而不直接用 unsigned char? 因为不同芯片的 int 可能长度不同,而 uint8_t 明确告诉编译器“我就要 8 位无符号整数”,跨平台安全。这些类型定义在 <stdint.h> 里,CubeMX 生成的工程已经包含了。

浮点型 float / double
  • float:32 位,精度约 6~7 位十进制有效数字。STM32F4 有硬件 FPU,运算很快,可用于传感器数值滤波、FFT 等。

  • double:64 位,精度更高。一般不用,除非必须。

注意:浮点数不能用 == 直接比较相等,因为存在误差。应使用 fabs(a - b) < 0.001 这种范围判断。

布尔型 bool

bool / _Bool:true (1) 或 false (0)。需包含 <stdbool.h>。HAL 库里很多函数返回 HAL_OK(0)或 HAL_ERROR(非0),本质就是枚举。

枚举 enum

给一系列固定值起名字,增强可读性。 在 HAL 库中,状态 HAL_StatusTypeDef(HAL_OK, HAL_ERROR, HAL_BUSY, HAL_TIMEOUT)就是枚举。

数组
  • 基础:连续存放的相同类型数据。数组名本身就是指向首元素的指针。

  • 与嵌入式结合:

    • 串口接收缓冲区必须是数组。

    • DMA 搬运的数据目的地也是数组。

    • 访问时下标从 0 开始,rx_buffer[0] 是第一个字节。

结构体 struct

打包不同类型的数据为一个整体。嵌入式开发中无处不在。 配置 GPIO 时,我们就是填充这样一个结构体,然后传给 HAL_GPIO_Init()。

为什么用结构体? 如果你有 20 个引脚要配置,用结构体数组遍历就很高效,而不用写 20 次寄存器操作。

联合体 union:多个变量共享同一块内存,适合解析不同格式数据,初学者先了解即可。

变量修饰符 —— 决定变量的行为

const(常量)

被 const 修饰的变量程序运行时不能修改,通常放在 Flash 里节省 RAM。 如果你定义很大的常量表(比如字库、正弦波表),务必加 const,否则会挤爆 RAM。

volatile(易变的)

告诉编译器:这个变量可能在任何时候被改变(比如在中断里),不要过度优化,每次都要从原始地址读取。

最典型的错误场景:

uint8_t flag = 0;
// 在中断中修改 flag
// 主循环 while(!flag); // 可能被优化成死循环

解决办法:volatile uint8_t flag = 0;

在 HAL 里,外设状态寄存器就是 volatile 类型的,你写 __IO 实际就是 volatile。

static(静态的)

有两个用法:

  1. 修饰局部变量:变量只会初始化一次,函数退出后值还在,只会被本函数访问。 很适合在回调里累计接收字节数。

  2. 修饰全局变量/函数:只在当前 .c 文件可见,避免命名冲突。每个模块的私有函数和变量都应加 static。

extern(外部的)

告诉编译器这个变量或函数在别的 .c 文件里定义了,这里只是声明。 例如 main.c 里用 extern UART_HandleTypeDef huart1; 来使用在 usart.c 中定义的串口句柄。

寄存器访问修饰符 __IO、__I、__O

这些是 ST 定义的:

  • __IO = volatile,可读写(如 GPIO 数据寄存器)

  • __I = volatile const,只读

  • __O = volatile,只写

一些操作符

位操作符 —— 寄存器的灵魂
左移 << 和右移 >>
  • a << n:把 a 的二进制位全部向左移动 n 位,右边补 0。

  • a >> n:把 a 右移 n 位,左边补 0(无符号数)或补符号位(有符号数,嵌入式基本用无符号)。

为什么用它? 代码里不会写 GPIO_PIN_12 = 0x1000,而是写成 GPIO_PIN_12 = 1 << 12,这样一眼就知道是第 12 位。

嵌入式里的典型用法:

GPIOA->ODR |= (1 << 12);   // 设置 PA12 为高电平
按位或 | 和 或等 |=
  • a | b:两个数的每一位,只要有一个为 1,结果位就是 1。

  • a |= b:等价于 a = a | b。

用途:把寄存器里的某些位置1,而不影响其他位。

GPIOA->ODR |= (1 << 12);   // 将第12位置1,其余位不变
按位与 & 和 与等 &=
  • a & b:两位都是 1 结果才为 1。

  • a &= b:等价于 a = a & b。

用途1:读取某一位或几位(屏蔽其他位)

if (GPIOA->IDR & (1 << 0)) { /* PA0 为高电平 */ }

用途2:把某些位清零(结合取反)

GPIOA->ODR &= ~(1 << 12);   // 将第12位清零
按位取反 ~

~a:把 a 的所有位翻转(0 变 1,1 变 0)。 最重要的用法是和 &= 一起,清指定位:

reg &= ~(1 << n);   // 清零第 n 位而不影响其他位

注意:~ 的优先级很高,所以 ~(1 << 12) 不加括号也能正确,但为了可读性,经常写 ( ~(1 << 12) )。

按位异或 ^ 和 异或等 ^=

a ^ b:两位不同结果就是 1,相同就是 0。 翻转某位(Toggle):

GPIOA->ODR ^= (1 << 12);   // 翻转第12位

HAL 库的函数 HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_12) 内部就是这样实现的。

总结常用组合:

操作写法效果
位置 1reg \|= (1 << n)第 n 位置 1
位清 0reg &= ~(1 << n)第 n 位清 0
位翻转reg ^= (1 << n)第 n 位取反
读某位if(reg & (1 << n))判断第 n 位是否为 1
截取几位val = (reg >> n) & 0x03取第 n, n+1 两位
结构体与成员访问符 —— 包装复杂的配置
结构体定义
typedef struct {
  uint32_t Pin;
  uint32_t Mode;
  uint32_t Pull;
  uint32_t Speed;
} GPIO_InitTypeDef;

用 typedef 给这个结构体类型起了一个别名 GPIO_InitTypeDef。

点运算符 .

当你有一个结构体变量时,用 . 访问成员:

GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.Pin = GPIO_PIN_12;
箭头运算符 ->

当你有一个指向结构体的指针时,用 -> 访问成员:

GPIO_InitTypeDef *p = &GPIO_InitStruct;
p->Pin = GPIO_PIN_12;   // 等价于 (*p).Pin = ...

HAL 库中外设句柄都是结构体指针,所以:

HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
指针与地址操作符 —— 操控内存和外设的钥匙
取地址符 &

当 & 是一元运算符(后面只跟一个变量),它是取地址。

uint8_t data = 0x55;
uint8_t *ptr = &data;   // ptr 指向 data

嵌入式里给 DMA 传数据时:

HAL_ADC_Start_DMA(&hadc1, (uint32_t*)&adc_value, 1);
解引用符 *

当 * 是一元运算符,它是访问指针指向的内容。

uint8_t value = *ptr;   // 读取 ptr 指向的值
*ptr = 0xAA;            // 修改 ptr 指向的值

读寄存器:

uint32_t temp = *((volatile uint32_t*)0x40020014);  // 读取 GPIOA ODR 地址

这就是最底层访问硬件的方式。

指针定义时的 *

规范:每定义一个指针,放一行。

uint8_t *ptr1;
uint8_t *ptr2;
指针与数组
uint8_t tx_buf[] = "Hello";
HAL_UART_Transmit(&huart1, tx_buf, 5, 100);   // tx_buf 即指向首元素的指针

HAL_UART_Transmit(&huart1, tx_buf, len) 中的 tx_buf 就是这种用法。

空指针 NULL

使用指针前一定要判断是否为 NULL,否则访问 NULL 指针会导致 HardFault。

赋值与复合赋值操作符
符号等效示例
=赋值a = 5
+=a = a + bcount += 1 常用于计数
-=a = a - bremain -= 8
*=a = a * bscale *= 2
/=a = a / bavg /= total
%=a = a % bindex %= 4 保证 0~3 范围

前面讲的 |=, &=, ^=, <<=, >>= 也一样。

关系与逻辑操作符 —— 一切判断的基础
关系运算符

== != > < >= <=

规范切记:== 是判断相等,= 是赋值,千万不要在 if 里写错。 一个好习惯:把常量写在左边 if (5 == a),这样写成 = 时会编译报错。

逻辑运算符
  • &&:逻辑与,两边都为真才真。

  • ||:逻辑或,一边为真就真。

  • !:逻辑非,真变假,假变真。

自增自减 —— 循环利器
后置与前置的区别
  • i++:先使用 i 的当前值,再 i 加 1。

  • ++i:i 先加 1,再使用新值。

在 for 循环里两者效果一样,但在表达式中不同:

int a = 5, b;
b = a++;   // b=5, a=6
b = ++a;   // b=6, a=6

在寄存器操作中谨慎:不建议在寄存器赋值时使用自增,容易混淆。保持代码清晰第一位。

强制类型转换 —— 给数据“换马甲”

格式:(新的类型)变量

uint32_t address = (uint32_t)&buffer;

DMA 配置时常转为 (uint32_t*) 因为 DMA 寄存器操作按 32 位地址。

规范:转换时要想清楚需不需要转,不要随意转指针,否则会破坏数据。

条件表达式(三元运算符)
条件 ? 值1 : 值2;
位段与特殊属性(了解即可)

__attribute__ 是编译器指令:

  • __attribute__((packed)) 让结构体紧凑存储,常用于解析通信包。

  • __attribute__((section(".mysec"))) 把变量放到指定内存段。

STM32 还用 __IO 就是 volatile 的宏替换。

字符对照表

符号名字在 STM32 中的常见用途
*解引用 / 指针定义访问指针指向的值 / 声明指针
&取地址 / 按位与 / 引用取变量地址 / 屏蔽位 / 按位与操作
\|按位或寄存器置位
~按位取反配合 &= 清零
^按位异或LED 翻转,位运算
<<左移生成位掩码 (1<<n)
>>右移提取字段
->结构体指针成员访问外设结构体成员
.结构体变量成员配置结构赋初值
++/--自增减循环计数
[]数组下标访问缓冲元素
()函数调用 / 优先运算函数调用,或提高优先级
{}代码块函数体、循环体
==相等判断条件判断
=赋值变量赋值
!=不等条件判断
? :三元简化 if/else

使用规范的核心原则:

  1. 改变寄存器某位用 |= 或 &= ~,不要直接 = 覆盖,除非你确实要重置整个寄存器。

  2. 指针使用前要确保不是 NULL。

  3. 不要在 if 里把 == 错写成 =。

  4. 多写括号,避免优先级的坑,比如 (a & 0x01) == 0x01。

指针与地址 —— 操作硬件的核心

什么是指针?什么是地址?
  • 地址:内存中每个字节都有一个编号,就像门牌号。0x20000000 是 SRAM 起始地址。

  • 指针变量:存储地址的变量。

为什么要用指针?
  1. 函数间共享数据:太大或需要修改的数据,不拷贝,直接传地址。

    HAL_UART_Transmit(&huart1, tx_buf, len, timeout);   // tx_buf 是地址
  2. 外设寄存器的访问:

    #define GPIOA ((GPIO_TypeDef *)0x40020000)
    GPIOA->ODR |= (1<<12);

    所有外设都映射到内存地址,HAL 库把它们封装为结构体指针。 GPIOA 是一个指向 GPIO 结构体的指针,这个结构体的内存就是 GPIO 寄存器的物理地址。

  3. DMA 配置:

    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, 100);

    这里将数组地址强转为 uint32_t* 传给 HAL 库,DMA 直接把数据搬到这个地址。

指针与数组的关系

数组名可以当指针用,指向首元素。

uint8_t tx_data[] = "Hello";
HAL_UART_Transmit(&huart1, tx_data, 5, 100);
函数指针(回调的秘密)

可以把函数当作参数传递吗?可以,这就是函数指针。 定义:void (*callback)(uint16_t pin); 这是个指针,指向一个返回 void、参数为 uint16_t 的函数。

HAL 库的回调机制: 你写的 HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) 就是一个满足签名的函数,HAL 库通过函数指针调用它。你不需要传地址,HAL 内部通过覆写弱函数自动链接。

弱函数 __weak:HAL 库定义的空函数,如果你不重写,它就不执行。你重写了,链接器自动用你的版本覆盖。

函数 —— 模块化的基石

声明与定义
  • 声明:告诉编译器有这么一个函数,返回类型、参数。通常在 .h 文件中。

  • 定义:在 .c 中写实现。

参数传递
  • 值传递:拷贝一份,函数内修改不影响外部。

  • 指针传递:传地址,函数内可修改外部变量。必须掌握。

中断服务函数 (ISR) 规则
  • 无参数、无返回值。

  • 名字必须和启动文件里的向量表一致(CubeMX 自动生成)。

  • 里面不能做耗时操作,不能调用会阻塞的函数。

  • 常用做法:在 ISR 里设置标志 volatile uint8_t flag = 1;,然后在主循环处理。

回调函数

就是一个函数指针的运用。HAL 库有大量以 Callback 结尾的函数,你实现它,库帮你调。 你不需要知道 HAL 库何时调用它,只知道它会被自动调用就行。

宏定义与预处理 —— 代码的万金油

#define 宏

定义常量:

#define LED_PIN GPIO_PIN_12

好处:修改时只需改一处。

宏函数(带参数的宏):

#define SET_BIT(REG, BIT)   ((REG) |= (BIT))
#define CLEAR_BIT(REG, BIT) ((REG) &= ~(BIT))

因为宏是文本替换,所以要加很多括号。HAL 库中大量使用。

#include 包含头文件

把别的文件的内容粘贴过来。一般:

  • #include <stdint.h> 系统头文件用尖括号。

  • #include "my_header.h" 自己写的用双引号。

条件编译 —— 防止重复包含

每个头文件都应该有这样的结构:

#ifndef __LED_H
#define __LED_H
​
// 头文件内容
​
#endif

第一次包含时,__LED_H 还没有定义,就定义它并包含内容;第二次再包含,因为已经定义,里面的内容就跳过了。CubeMX 生成的头文件都有这个。

用途:

  • 调试代码:#ifdef DEBUG ... #endif

  • 适配不同硬件:#if defined(STM32F407xx) 选择对应代码

常用的 HAL 宏
__HAL_RCC_GPIOA_CLK_ENABLE()   // 使能 GPIOA 时钟
__HAL_GPIO_EXTI_CLEAR_IT(pin)  // 清除中断标志

这些宏最终操作的都是寄存器的特定位,提高了可读性。

内存与堆栈 —— 程序的家

程序的“四大件”

当你把一个 .elf 文件烧录到芯片里,内存(Flash 和 RAM)是这样分的:

区域存储介质内容生命周期
代码段 (.text)Flash程序的机器码整个运行期
只读数据段 (.rodata)Flashconst 常量、字符串整个运行期
已初始化数据段 (.data)Flash → 启动时复制到 SRAM已初始化的全局变量、静态变量(如 int count = 5;)整个运行期
BSS 段SRAM(启动时清 0)未初始化的全局变量、静态变量(如 int flag;)整个运行期
堆 (Heap)SRAM动态分配的内存(malloc/free)按需分配/释放
栈 (Stack)SRAM局部变量、函数调用参数、返回地址函数退出即释放
局部变量和全局变量的存储位置
  • 局部变量:定义在函数内部,存在栈上,函数退出就消失。

  • 全局变量:定义在所有函数外部,存在数据段/BSS段,整个程序运行期间都在。

  • 静态局部变量:static int local = 0; 也在数据段,但只在本函数可见,值会保持。

堆和栈的注意事项
  • 栈溢出:局部变量太大(比如 uint8_t buffer[4096])或递归调用太深,会把栈撑爆,引发 HardFault。嵌入式里,大缓冲区都用全局数组或静态数组(存在数据段或 BSS 段),或者直接指定放在特定内存区域。

  • 尽量避免 malloc:在嵌入式系统中,堆管理容易产生碎片,且难以追踪泄漏。CubeMX 生成的 FreeRTOS 动态分配是安全的,但自己写代码时尽量用静态分配。

循环与条件 —— 控制程序的走向

无限循环 while(1)

嵌入式程序永远不能结束,所以 main() 最后必须有个死循环:

while(1) {
    // 主循环代码
}
for 循环

常用在初始化、遍历数组、延时等。

if / else

if / else

switch / case

适合状态机。比如解析串口命令:

switch(cmd) {
    case 'A':
        // 处理 A
        break;
    case 'B':
        // 处理 B
        break;
    default:
        break;
}

必须在每个 case 后加 break,否则会顺次执行下去。

位操作 —— 寄存器的灵魂

为什么嵌入式里这么多 |、&、~、<<?因为一个 32 位寄存器里的每一个二进制位都有独立的功能。你要只改某一位而不影响其他位,就必须用位操作。

运算符含义例子用途
&按位与val & 0x0F取低四位
\|按位或GPIOA->ODR \|= (1<<5)将第5位置1(点亮 PA5)
~按位取反flags &= ~0x08将第3位清零
^按位异或LED ^= 1翻转特定位
<<左移1 << 12生成 GPIO_PIN_12 掩码
>>右移val >> 4取出高半字节

常用头文件一览

C标准库头文件
  • <stdint.h>:uint8_t, uint32_t 等精确宽度类型,必须包含。

  • <stdbool.h>:bool, true, false。

  • <string.h>:memset, memcpy, strlen 等内存操作。嵌入式常用 memset(buf, 0, sizeof(buf)) 清缓冲区。

  • <stdio.h>:printf, sprintf。一般会重定向 printf 到串口,用于调试输出。

  • <stdlib.h>:atoi, itoa 等转换函数,很少用 malloc/free。

STM32专用头文件
  • stm32f4xx_hal.h:HAL 库总入口,会自动包含你配置的外设头文件。

  • main.h:CubeMX 生成,包含所有外设句柄的 extern 声明和引脚宏定义。

  • 各外设头文件:stm32f4xx_hal_gpio.h, stm32f4xx_hal_uart.h 等。

  • stm32f4xx_it.h:中断服务函数声明。

  • 你自己编写的模块:bsp_led.h, task_sensor.h 等。

嵌入式 C 的编程习惯与坑

初始化一定要清零
uint8_t buffer[100] = {0};
变量先赋值再使用
宏定义的多参数要加括号
#define MAX(a,b) ((a) > (b) ? (a) : (b))

每个参数和整体都应加括号,防止展开后运算优先级错误。

使用 static 保护私有函数
static void delay_soft(int n) { ... }

bsp_led.c 里有一个内部辅助函数,应该加 static,防止其他文件调用。

遇到 HardFault 怎么办?

通常是数组越界、野指针、访问了禁用外设的地址、堆栈溢出。可以学会查看 SCB->CFSR 寄存器初步定位,当然现在初学还不用深究。

结合 STM32 的实战演示

下面用一个“按键中断控制 LED 闪烁频率并打印”的例子,把上面的概念串起来。

main.h(CubeMX配置后自动生成了很多宏):

#define LED_PIN   GPIO_PIN_12
#define LED_PORT  GPIOA
#define BUTTON_PIN GPIO_PIN_0
#define BUTTON_PORT GPIOA

main.c:

#include "main.h"
#include "stm32f4xx_hal.h"
​
volatile uint8_t blink_interval = 500;  // 初始500ms,由中断修改
​
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    if(GPIO_Pin == BUTTON_PIN) {
        // 按一次,闪烁间隔减50ms,最低100ms
        if(blink_interval > 100) blink_interval -= 50;
        else blink_interval = 500;
    }
}
​
int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_USART2_UART_Init();
​
    uint8_t print_buf[64];
    while(1) {
        HAL_GPIO_TogglePin(LED_PORT, LED_PIN);
        int len = snprintf((char*)print_buf, sizeof(print_buf),
                           "Blink interval: %d ms\r\n", blink_interval);
        HAL_UART_Transmit(&huart2, print_buf, len, 100);
        HAL_Delay(blink_interval);
    }
}

这个例子包含了:

  • volatile 修饰被中断修改的变量

  • 全局数组作为打印缓冲区

  • 指针强转 (uint8_t*)print_buf

  • 外设句柄 huart2(在其他文件定义,这里用 extern 或 main.h 声明)

  • 回调函数 HAL_GPIO_EXTI_Callback

  • 宏 LED_Pin 等

  • 死循环 while(1)

建议

  • 不要死记硬背:每学一个新外设,就去 CubeMX 配置一下,看看生成的代码里出现了哪些 C 语言元素,对照着理解。

  • 写代码时可以模仿 HAL 库的代码风格,比如用 HAL_StatusTypeDef 作为返回值,用结构体传参。

  • 把报错信息当老师:遇到编译错误,仔细阅读提示,比如 undefined reference 是函数没定义或没添加文件,implicit declaration 是忘包含头文件。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/m0_52771048/article/details/161261561

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--