文章摘要
本文系统梳理了嵌入式STM32开发中常用的C语言知识点,面向初学者和有一定基础但希望巩固的开发者。内容涵盖:整数、浮点、布尔、枚举、数组、结构体等数据类型;const、volatile、static、extern、__IO等变量修饰符;位操作符(<<、>>、|、&、~、^)、指针与地址、函数、宏定义、条件编译等核心语法。还深入讲解了嵌入式内存布局(代码段、数据段、BSS、堆、栈)、中断服务函数编写规范、回调机制、常见编程陷阱及调试建议。最后通过一个按键中断控制LED闪烁频率并串口打印的实战例子,将上述知识点串联起来。适合配合STM32 HAL库和CubeMX工具学习,帮助读者快速掌握嵌入式C编程的必备技能。
C语言和嵌入式(STM32)知识点梳理
数据类型 —— 你的变量是做什么工作的
整数型(最常用)
| 类型 | 别称 / ST推荐写法 | 位数 | 范围 | 什么时候用 |
|---|---|---|---|---|
char | int8_t | 8 | -128 ~ 127 | 存 ASCII 字符或小整数 |
unsigned char | uint8_t | 8 | 0 ~ 255 | 串口收发的一字节数据、GPIO 引脚状态 |
short | int16_t | 16 | -32768 ~ 32767 | 中等范围的计数 |
unsigned short | uint16_t | 16 | 0 ~ 65535 | ADC 值(0~4095)、数组长度 |
int | int32_t | 32 | -2^31 ~ 2^31-1 | 普通运算、计数 |
unsigned int | uint32_t | 32 | 0 ~ 2^32-1 | 毫秒时间戳、寄存器配置、地址 |
long long | int64_t | 64 | 很大 | 大数运算、文件大小 |
unsigned long long | uint64_t | 64 | 超大 | 文件系统用 |
为什么 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(静态的)
有两个用法:
-
修饰局部变量:变量只会初始化一次,函数退出后值还在,只会被本函数访问。 很适合在回调里累计接收字节数。
-
修饰全局变量/函数:只在当前
.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) 内部就是这样实现的。
总结常用组合:
| 操作 | 写法 | 效果 |
|---|---|---|
| 位置 1 | reg \|= (1 << n) | 第 n 位置 1 |
| 位清 0 | reg &= ~(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 + b | count += 1 常用于计数 |
-= | a = a - b | remain -= 8 |
*= | a = a * b | scale *= 2 |
/= | a = a / b | avg /= total |
%= | a = a % b | index %= 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 |
使用规范的核心原则:
-
改变寄存器某位用
|=或&= ~,不要直接=覆盖,除非你确实要重置整个寄存器。 -
指针使用前要确保不是
NULL。 -
不要在
if里把==错写成=。 -
多写括号,避免优先级的坑,比如
(a & 0x01) == 0x01。
指针与地址 —— 操作硬件的核心
什么是指针?什么是地址?
-
地址:内存中每个字节都有一个编号,就像门牌号。
0x20000000是 SRAM 起始地址。 -
指针变量:存储地址的变量。
为什么要用指针?
-
函数间共享数据:太大或需要修改的数据,不拷贝,直接传地址。
HAL_UART_Transmit(&huart1, tx_buf, len, timeout); // tx_buf 是地址 -
外设寄存器的访问:
#define GPIOA ((GPIO_TypeDef *)0x40020000) GPIOA->ODR |= (1<<12);所有外设都映射到内存地址,HAL 库把它们封装为结构体指针。
GPIOA是一个指向 GPIO 结构体的指针,这个结构体的内存就是 GPIO 寄存器的物理地址。 -
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) | Flash | const 常量、字符串 | 整个运行期 |
| 已初始化数据段 (.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




