3、嵌入式C语言基础:指针与数组、结构体与联合体、位操作、volatile关键字、内存管理
各位同学,欢迎来到第三章。说实话,C语言基础这部分,很多工程师觉得「我都会啊」,但真到了写伺服驱动代码时,各种坑就冒出来了。我见过太多人因为指针用错导致电机飞车,因为volatile漏写导致电流采样值死活不对。今天咱们就把这些硬骨头啃下来。
本章核心:伺服驱动器的底层代码,90%的bug都出在这五个知识点上。不是语法不会,是嵌入式场景下的特殊用法没吃透。
3.1 指针与数组:伺服驱动里的「地址游戏」
指针这东西,说白了就是地址。但在嵌入式里,它不只是地址,更是操作硬件的钥匙。
指针和数组的关系
数组名就是首地址,这个大家都知道。但我在项目中遇到过一个问题:用sizeof求数组长度,结果传参后就不对了。为什么?因为数组作为函数参数时,会退化成指针。
// 错误示范
void process_data(uint16_t arr[]) {
size_t len = sizeof(arr) / sizeof(arr[0]); // 这里len永远是1或2!
}
// 正确做法
void process_data(uint16_t *arr, size_t len) {
for(size_t i = 0; i < len; i++) {
// 处理数据
}
}
函数指针:伺服里的回调利器
伺服驱动里,经常要用到函数指针。比如电流环的PI参数切换,不同工况用不同算法。我习惯把函数指针放在结构体里,这样切换就像换表格一样简单。
typedef struct {
float kp;
float ki;
float (*calc_func)(float err, float *integral);
} pi_controller_t;
// 两种算法
float pi_standard(float err, float *integral) { ... }
float pi_anti_windup(float err, float *integral) { ... }
// 使用时直接赋值
pi_controller_t current_pi = {1.5, 0.01, pi_standard};
我的习惯:指针变量命名时加个p_前缀,比如p_buffer。这样一眼就能看出是指针,避免误用。
3.2 结构体与联合体:打包数据,节省内存
结构体在伺服里太常用了。电机参数、编码器数据、PID系数,全得用结构体包起来。
结构体对齐:一个隐形杀手
你想想看,如果结构体成员顺序没排好,编译器会自动填充字节,导致结构体变大。我曾经在DSP上调试一个通信协议,结构体大小总是对不上,查了两天才发现是字节对齐的问题。
// 浪费内存的写法
typedef struct {
uint8_t status; // 1字节
uint32_t position; // 4字节
uint16_t speed; // 2字节
} motor_data_t; // 实际占用12字节!因为对齐填充
// 优化写法:按大小降序排列
typedef struct {
uint32_t position; // 4字节
uint16_t speed; // 2字节
uint8_t status; // 1字节
} motor_data_t; // 实际占用8字节,省了33%
联合体:寄存器操作的瑞士军刀
联合体让同一块内存有多种解读方式。比如读取32位编码器数据,可以同时按字节和整数值访问。
typedef union {
uint32_t value;
uint8_t bytes[4];
struct {
uint16_t low_word;
uint16_t high_word;
} words;
} encoder_data_t;
// 使用场景:通过SPI逐字节读取编码器
encoder_data_t enc;
enc.bytes[0] = spi_read();
enc.bytes[1] = spi_read();
enc.bytes[2] = spi_read();
enc.bytes[3] = spi_read();
// 现在 enc.value 就是完整的32位位置值
注意:联合体的字节序(大小端)跟芯片有关。ARM Cortex-M一般是小端,但有些DSP是大端。跨平台时一定要确认。
3.3 位操作:寄存器级的「手术刀」
伺服驱动里,位操作是基本功。配置定时器、设置GPIO、读写状态寄存器,全得靠它。
常用位操作技巧
| 操作 | 表达式 | 说明 |
|---|---|---|
| 置位某位 | REG |= (1 << n) |
将第n位置1 |
| 清零某位 | REG &= ~(1 << n) |
将第n位清0 |
| 翻转某位 | REG ^= (1 << n) |
将第n位取反 |
| 读取某位 | (REG >> n) & 1 |
获取第n位的值 |
| 连续多位操作 | REG = (REG & ~MASK) | (val << shift) |
先清零再赋值 |
实战:配置PWM输出引脚
// 假设GPIO控制寄存器地址为0x40020000
#define GPIO_CTRL (*(volatile uint32_t *)0x40020000)
#define PIN_PWM1 (1 << 5) // 第5位控制PWM1输出
// 使能PWM1输出
GPIO_CTRL |= PIN_PWM1;
// 禁用PWM1输出
GPIO_CTRL &= ~PIN_PWM1;
我的建议:位操作一定要加括号!1 << n的优先级容易搞错。另外,多用宏定义给位掩码起名字,代码可读性会好很多。
3.4 volatile关键字:告诉编译器「别优化我」
volatile可能是嵌入式里最容易被忽略的关键字。它的作用就一句话:告诉编译器,这个变量的值随时可能被意外改变,每次使用都必须从内存重新读取。
什么时候必须用volatile?
- 硬件寄存器:比如定时器计数值、ADC结果寄存器
- 中断服务程序修改的变量:主循环和中断共享的变量
- 多线程/多核共享变量:虽然裸机编程不常见,但RTOS里很关键
一个血的教训
我曾经调试一个伺服驱动器,电流采样值总是跳变。查了三天,最后发现是ADC中断里更新了一个全局变量,但主循环里没加volatile。编译器优化后,主循环一直读取寄存器里的旧值,根本没看到中断的更新。
// 错误写法
uint16_t adc_value; // 没加volatile!
void ADC_IRQHandler(void) {
adc_value = ADC->DR; // 中断里更新
}
void main_loop(void) {
while(1) {
if(adc_value > 3000) { // 编译器可能优化成只读一次!
// 过流保护
}
}
}
// 正确写法
volatile uint16_t adc_value; // 加上volatile
注意:volatile不能保证原子性。如果变量是32位的,而MCU是16位内核,读写操作可能被中断打断。这时需要关中断或使用原子操作。
3.5 内存管理:嵌入式里的「精打细算」
伺服驱动器的RAM通常只有几十KB到几百KB。内存管理做不好,轻则跑飞,重则电机失控。
静态分配 vs 动态分配
我个人强烈建议:嵌入式底层代码尽量用静态分配。malloc/free在MCU上风险太高——内存碎片、分配失败、不确定的执行时间,这些都是伺服驱动不能接受的。
// 推荐:静态分配
#define MAX_MOTORS 4
motor_control_t motors[MAX_MOTORS];
// 不推荐:动态分配
motor_control_t *p_motor = (motor_control_t *)malloc(sizeof(motor_control_t));
if(p_motor == NULL) {
// 处理分配失败... 但伺服里怎么处理?停机?
栈空间管理
中断服务程序里别用太多局部变量。我记得有一次,一个工程师在定时器中断里定义了一个512字节的数组,结果栈溢出,程序随机崩溃。查了两周才找到原因。
我的经验:中断函数里尽量用全局变量或静态变量。如果非要用局部变量,控制在几十字节以内。另外,链接脚本里给中断栈多分配一些空间,至少256字节。
const和static的妙用
- const:把常量放到Flash里,节省RAM。比如PID系数表、正弦表
- static:限制变量作用域,避免命名冲突。模块内部的全局变量都加static
// 正弦表放在Flash里
const float sin_table[256] = {0.0000, 0.0245, ...};
// 模块内部变量,外部不可见
static uint32_t last_tick = 0;
void timer_callback(void) {
// 只能在本文件内访问last_tick
}
本章小结:指针是地址,结构体是打包,联合体是复用,位操作是精细控制,volatile是防优化,内存管理是保稳定。这五个点,每一个都在伺服驱动里天天用。别觉得基础,基础不牢,地动山摇。