文章目录
这是学习韦东山老师嵌入式C语言的视频,自己整理的学习笔记
原md文件已上传gitee:
1. 变量与指针
2个口诀:
- 变量变量,能变,就是能读能写,必定在内存里
- 指针指针,保存的是地址,32位处理器中地址都是32位的,无论是什么类型的指针变量,都是4字节
变量
变量变量,能变,就是能读能写,必定在内存里
- 能变:变量,顾名思义,其值可以在程序执行过程中发生变化。这意味着变量是可以读写的,即我们可以获取变量的当前值(读操作),也可以改变变量的值(写操作)。
- 在内存里:变量存储在程序的内存空间中。程序运行时,操作系统会为程序分配一定的内存空间,变量就被分配在这块内存空间中的某个位置。通过变量的地址,我们可以访问和修改它的值。
在嵌入式C语言编程中,由于内存资源有限,合理地使用和管理变量显得尤为重要。通常,我们需要考虑变量的生命周期、作用域以及存储类别,以确保程序的高效运行和内存的有效利用。
指针
指针指针,保存的是地址,32位处理器中地址都是32位的,无论是什么类型的指针变量,都是4字节
- 保存的是地址:指针是一个特殊的变量,它存储的是另一个变量的内存地址。通过这个地址,我们可以间接地访问和修改该变量的值。
- 32位处理器中地址都是32位的:在32位处理器上,内存地址通常是一个32位的整数。这意味着无论我们指向何种类型的变量(如int、char、float等),指针变量本身都占用4个字节(32位)的内存空间来存储这个地址。
在嵌入式C语言编程中,指针的使用非常广泛,特别是在操作硬件寄存器、动态内存分配以及数据结构管理等方面。然而,由于嵌入式系统的内存限制和实时性要求,指针的使用也需要格外小心,以避免内存泄漏、野指针等问题。
示例
下面是一个简单的C语言示例,展示了变量和指针的使用:
#include <stdio.h>
int main() {
int myVariable = 10; // 定义一个整型变量并初始化
int *myPointer; // 定义一个整型指针变量
myPointer = &myVariable; // 将指针指向myVariable的地址
printf("Value of myVariable: %d\n", myVariable); // 直接访问变量值
printf("Address of myVariable: %p\n", (void *)&myVariable); // 打印变量地址
printf("Value pointed by myPointer: %d\n", *myPointer); // 通过指针间接访问变量值
printf("Address stored in myPointer: %p\n", (void *)myPointer); // 打印指针存储的地址值
return 0;
}
2. sizeof和关键字
2.1 sizeof
sizeof
是一个运算符,用于计算变量、类型或对象在内存中所占的字节数。对于指针变量,sizeof(p)
返回的是指针变量本身所占用的内存大小,而不是它所指向的内存区域的大小。而sizeof(*p)
则返回指针p
所指向的对象的大小。
示例:
int arr[10] = {0};
int *p = arr;
printf("Size of pointer p: %zu bytes\n", sizeof(p)); // 输出指针p的大小
printf("Size of object pointed by p: %zu bytes\n", sizeof(*p)); // 输出指针p所指向的int类型对象的大小
printf("Size of array arr: %zu bytes\n", sizeof(arr)); // 输出整个数组的大小
2.2 volatile
volatile
是一个类型限定符,用于告诉编译器不要对某个变量进行特定的优化,因为该变量的值可能在程序的正常控制流之外被改变。这通常发生在以下几种情况:
-
硬件寄存器访问:在嵌入式编程中,程序员经常需要直接访问硬件寄存器。这些寄存器的值可能会在任何时候由硬件自身改变,而程序并不知道这种改变。使用
volatile
可以确保每次访问该变量时都会从内存中读取其实际值,而不是使用编译器可能为其存储的缓存值。 -
中断服务程序:当中断发生时,中断服务程序(ISR)可能会修改某些全局变量的值。如果这些变量没有被声明为
volatile
,编译器可能会认为这些变量在中断之外不会被修改,从而进行优化,导致程序行为不正确。 -
多线程环境:在多线程环境中,一个线程可能修改了一个变量的值,而另一个线程需要读取这个值。如果不使用
volatile
,编译器可能会优化掉对变量的多次读取,导致第二个线程读取到的不是最新的值。 -
外部设备或信号:当变量与外部设备或信号(如传感器输入)相关联时,这些设备或信号可能会在任何时候改变变量的值。
使用volatile
可以确保编译器在每次使用变量时都进行实际的内存访问,从而避免了由于优化而导致的潜在问题。但是,滥用volatile
也可能导致性能下降,因此应该只在确实需要的时候使用它。
示例
volatile int hardwareRegister = 0; // 假设这是硬件的某个寄存器地址
// 在某个中断服务程序中更新这个寄存器的值
ISR_HANDLER() {
hardwareRegister = newValue; // newValue是从硬件读取的新值
}
// 在主程序中读取这个寄存器的值
int main() {
while (1) {
int value = hardwareRegister; // 使用volatile确保从硬件寄存器读取实际值
// ... 处理value ...
}
}
2.3 const
const
是C语言中的一个关键字,它用于声明一个对象或变量为只读,这意味着一旦为const
变量赋了初值,就不能再修改它的值。const
提供了编译时的类型检查
const
的基本用法
- 声明常量:这是
const
最常见的用法,用于声明一个值在程序运行期间保持不变。
const int MY_CONSTANT = 10; // MY_CONSTANT的值不能更改
- 指向常量的指针:指针可以指向一个常量,这意味着你不能通过这个指针来修改它所指向的值。
const int *p = &MY_CONSTANT; // p指向一个常量,不能通过p来修改MY_CONSTANT的值
- 常量指针:指针本身也可以是常量,这意味着你不能改变这个指针指向的地址,但是你可以修改它所指向的值(如果它不是指向常量的指针)。
int x = 10;
int y = 20;
int *const ptr = &x; // ptr是一个常量指针,它总是指向x的地址
// ptr = &y; // 这条语句会编译错误,因为ptr是一个常量指针
*ptr = y; // 这条语句是合法的,因为ptr指向的值可以被修改
- 指向常量的常量指针:结合上述两种用法,你可以有一个指向常量的常量指针,既不能改变指针指向的地址,也不能通过指针修改它所指向的值。
const int *const p = &MY_CONSTANT; // p指向一个常量,且p本身也是一个常量
const
在嵌入式C语言编程中的重要作用
-
代码可读性和维护性:使用
const
可以清晰地表明哪些值在程序执行期间是不变的,这有助于其他开发者理解代码,并减少由于不小心修改常量值而导致的错误。 -
防止意外修改:在嵌入式系统中,某些关键的值或配置可能需要保持恒定。使用
const
可以确保这些值不会被意外修改,从而提高系统的稳定性。 -
优化:编译器知道
const
变量的值在程序运行期间不会改变,因此可能会对这些变量进行特殊的优化处理,比如将它们存储在只读内存区域,或者在编译时将其值直接嵌入到代码中。 -
硬件抽象:在嵌入式编程中,经常需要定义一些与硬件相关的常量,如寄存器的地址、位掩码等。使用
const
可以确保这些值不会被误修改,并且提供了一种清晰的方式来组织和访问这些硬件相关的值。 -
API设计:在设计库或API时,使用
const
可以清晰地表明哪些函数参数或返回值是只读的,这有助于防止调用者误修改这些值,并提高了API的易用性和安全性。
示例
下面是一个简单的嵌入式C语言示例,展示了const
的不同用法:
#include <stdio.h>
// 声明一个常量
const int MAX_BUFFER_SIZE = 1024;
// 声明一个指向常量的指针
const char *str = "Hello, World!";
// 声明一个常量指针
int array[10];
const int *const ptr_to_array = array; // 指向数组第一个元素的常量指针
int main() {
// 尝试修改常量的值,会导致编译错误
// MAX_BUFFER_SIZE = 2048; // 错误
// 尝试通过指向常量的指针修改值,也会导致编译错误
// *str = 'h'; // 错误
// 尝试修改常量指针的值,也会导致编译错误
// ptr_to_array = array + 1; // 错误
// 但是可以通过常量指针修改它所指向的值(如果它不是指向常量的指针)
*ptr_to_array = 42; // 合法,因为array不是const
return 0;
}
2.4 static
static
是C语言中的一个关键字,它有多种用途,并且其效果会根据static
被应用的上下文而有所不同。在嵌入式C语言编程中,static
的使用尤为重要,因为它可以帮助控制变量的可见性、生命周期以及内存分配。
static
的基本用法
- 局部静态变量:在函数内部定义的静态变量。其生命周期会延续到整个程序执行期间,而不是仅限于定义它的函数执行期间。它们只初始化一次,通常在程序开始运行时。
void count() {
static int calls = 0; // 静态局部变量,只初始化一次
calls++;
printf("This function has been called %d times.\n", calls);
}
在上面的例子中,每次调用count
函数时,calls
的值都会增加,因为它在函数调用之间是持续存在的。
- 文件作用域静态变量:在函数外部定义的静态变量,其作用域仅限于定义它的源文件。这意味着其他源文件不能访问它。
static int file_scope_var = 42; // 只在当前文件可见
使用文件作用域的静态变量,可以隐藏实现细节,避免在其他文件中意外地使用或修改这些变量。
- 静态函数:声明为
static
的函数只能在其定义的源文件中被调用。这使得函数成为内部实现的一部分,而不是库的公共接口。
static void internal_function() {
// ...
}
static
在嵌入式C语言编程中的重要作用
-
内存管理:在嵌入式系统中,内存资源通常非常有限。使用
static
变量可以确保这些变量在程序的生命周期内都存在,而不是在每次函数调用时都在栈上分配和释放内存。这有助于减少栈的使用,并可能降低内存碎片化的风险。 -
初始化和状态保持:
static
变量只初始化一次,这对于需要在程序启动时初始化并持续存在的状态或配置信息特别有用。在嵌入式系统中,这可能包括硬件设置、配置参数或系统状态。 -
跨函数调用保持数据:由于
static
局部变量的生命周期跨越多个函数调用,它们可以用于在函数调用之间传递数据或保持状态,而无需使用全局变量或动态内存分配。 -
模块化和封装:通过使用
static
函数和变量,可以隐藏模块的实现细节,只暴露必要的接口给外部调用者。这有助于实现更好的模块化和代码封装,提高代码的可维护性和可重用性。 -
中断服务程序中的状态保持:在嵌入式系统中,中断服务程序(ISR)经常需要快速响应,并且不能包含复杂的逻辑或过多的局部变量。使用
static
变量可以在ISR之间保持状态,而无需使用全局变量。
示例
下面是一个简单的嵌入式C语言示例,展示了static
的不同用法:
#include <stdio.h>
// 静态全局变量,只在本源文件中可见
static int static_global_var = 0;
// 静态函数,只在本源文件中可调用
static void static_function() {
static_global_var++; // 访问静态全局变量
printf("Static function called, static_global_var = %d\n", static_global_var);
}
void public_function() {
static int static_local_var = 0; // 静态局部变量,只初始化一次
static_local_var++;
printf("Public function called, static_local_var = %d\n", static_local_var);
static_function(); // 调用静态函数
}
int main() {
public_function(); // 输出:Public function called, static_local_var = 1, Static function called, static_global_var = 1
public_function(); // 输出:Public function called, static_local_var = 2, Static function called, static_global_var = 2
return 0;
}
函数内的静态变量示例:
int countCalls() {
static int callCount = 0; // 只在第一次调用时初始化
callCount++;
return callCount;
}
int main() {
printf("%d\n", countCalls()); // 输出1
printf("%d\n", countCalls()); // 输出2,因为callCount在函数调用之间保持其值
return 0;
}
文件作用域的静态变量示例:
// file1.c
static int fileScopeVariable = 42; // 只在file1.c中可见
void printVariable() {
printf("%d\n", fileScopeVariable);
}
// file2.c
extern void printVariable(); // 可以调用printVariable,但看不到fileScopeVariable
int main() {
printVariable(); // 输出42
// fileScopeVariable = 100; // 这会导致编译错误,因为fileScopeVariable在file2.c中不可见
return 0;
}
2.5 extern
extern
用于声明一个变量或函数是在其他文件中定义的,允许当前文件引用它。这常用于跨文件共享全局变量或函数。
示例:
在globals.h
头文件中:
extern int globalVariable; // 声明在其他地方定义的全局变量
在globals.c
源文件中:
#include "globals.h"
int globalVariable = 100; // 定义全局变量
在另一个源文件中:
#include "globals.h"
int main() {
printf("%d\n", globalVariable); // 输出100,因为globalVariable在globals.c中定义,并通过extern在当前文件中声明
return 0;
}
3. 字节对齐
- 字节对齐的概念
字节对齐是计算机存储数据的一种策略,它要求数据的起始地址是某个固定数值(通常是2、4、8、16等)的倍数。这种对齐方式有助于减少处理器访问数据的开销,提高数据访问的效率。
- 为什么需要字节对齐
处理器访问内存时,通常不是按字节逐一访问的,而是按固定大小的块(比如4字节或8字节)进行访问。如果数据的起始地址不是块大小的倍数,处理器就需要进行额外的操作来读取数据,这会增加访问的开销。通过字节对齐,可以确保数据的起始地址与处理器访问内存时的块大小相匹配,从而提高访问效率。
- 结构体中的字节对齐
在C语言中,结构体是一种复合数据类型,由多个成员组成。每个成员在内存中都有自己的地址。由于字节对齐的要求,结构体成员的排列可能并不是按照定义顺序紧密排列的,而是会在某些成员之间插入一些填充字节(padding),以确保每个成员的起始地址都满足对齐要求。
- 使用
sizeof
查看结构体大小
sizeof
是C语言中的一个运算符,用于获取数据类型或对象在内存中所占用的字节数。当应用于结构体时,sizeof
返回的是整个结构体对象所占用的内存大小,这个大小包括了所有成员以及填充字节。
- 字节对齐对
sizeof
的影响
由于结构体中可能存在填充字节,因此使用sizeof
获取的结构体大小可能会大于结构体成员大小的总和。填充字节的数量取决于编译器、目标平台以及结构体成员的类型和顺序。
- 如何控制字节对齐
在某些情况下,为了节省内存空间或者满足特定的硬件要求,我们可能需要控制结构体的字节对齐方式。C语言提供了一些编译器特定的扩展来支持这种控制。例如,在GCC编译器中,可以使用__attribute__((packed))
来告诉编译器不要在结构体成员之间插入填充字节,实现紧凑的字节布局。但是需要注意的是,紧凑布局可能会导致访问结构体成员的效率降低。
- 示例
有以下结构体定义:
struct MyStruct {
char a; // 1 byte
short b; // 2 bytes, assuming a 2-byte alignment
int c; // 4 bytes, assuming a 4-byte alignment on a 32-bit system
double d; // 8 bytes, assuming an 8-byte alignment on a typical system
};
在不考虑编译器优化和填充的情况下,我们可能会认为这个结构体的大小是1 + 2 + 4 + 8 = 15
字节。但实际上,由于字节对齐的要求,编译器可能会在char a
和short b
之间、short b
和int c
之间以及int c
和double d
之间插入填充字节。因此,使用sizeof(struct MyStruct)
得到的结果可能会大于15字节。
4. 类型转换
在C语言及类似编程语言中,隐式类型转换(也称为自动类型转换)是编译器在编译时自动执行的一种类型转换,它通常发生在表达式计算、赋值操作或函数调用等场景中,而无需程序员显式指定。隐式类型转换通常遵循一定的规则,以确保程序的正确性和效率。
- 隐式类型转换的规则
-
算术运算中的类型转换:当算术运算符(如加、减、乘、除等)的操作数类型不同时,编译器会尝试将它们转换为同一类型,以便进行运算。转换的方向通常是向数据长度更大的类型进行,以保证运算的精度。例如,
int
和long
类型进行运算时,int
类型会被转换为long
类型。 -
浮点运算中的类型转换:所有的浮点运算都是以双精度(
double
)进行的。即使表达式中只包含单精度(float
)浮点数,它们也会被自动转换为双精度进行运算。这是为了保持浮点运算的精度。 -
整型提升:在表达式中,字符类型(
char
)和短整型(short
)通常会被提升为int
类型(在特殊情况下,可能会提升为unsigned int
)。这种提升是为了确保整型运算在CPU的相应运算器件内执行,因为CPU内整型运算器(ALU)的操作数的字节长度一般就是int
的字节长度。 -
赋值运算中的类型转换:在赋值操作中,如果赋值号两边量的数据类型不同,赋值号右边的量会被转换为左边的类型。如果右边的数据类型长度比左边长,转换过程中可能会丢失一部分数据,导致精度降低。
隐式类型转换的安全性
- 隐式类型转换由编译器自动进行,因此具有足够的安全性。这种安全性主要体现在两个方面:内存单元访问的安全性和转换结果的安全性。编译器会确保在转换过程中不会访问到非法的内存单元,同时转换结果也应当是合理和可预测的。
然而,尽管隐式类型转换通常是安全的,但程序员仍需要谨慎处理某些情况。例如,当将一个较大的整数类型转换为较小的整数类型时,如果原值超出了目标类型的表示范围,就会发生溢出,导致结果不正确。此外,将一个带符号类型转换为无符号类型时,如果原值是负数,转换结果将是一个很大的无符号数,这可能会导致意外的行为。
- 隐式类型转换的示例
以下是一些隐式类型转换的示例:
int a = 10;
double b = 3.14;
double c = a * b; // 隐式类型转换,a被转换为double类型后与b相乘
char d = 'A';
int e = d + 1; // 隐式类型转换,d被提升为int类型后加1
short f = 10000;
int g = f; // 隐式类型转换,f被提升为int类型后赋值给g
在示例中,编译器会自动执行必要的类型转换,以确保表达式或赋值操作的正确性。
几个核心问题
1. 有值的全局变量的初始化
全局变量在程序的生命周期中是持续存在的,并且它们的初始化发生在程序执行主函数main
之前。对于那些在源文件中显式初始化了值的全局变量,编译器会在编译时将这些初始值嵌入到程序中。当程序加载到内存并执行时,这些初始值会被复制到相应的全局变量内存地址中。
在嵌入式系统中,程序和数据可能存储在非易失性存储器(如Flash)中,而运行时则需要将数据加载到RAM中。这通常通过启动代码(Bootloader)来实现,它会负责将Flash中的数据和代码段复制到RAM的适当位置。这个过程可能包括使用类似memcpy
的操作来拷贝有初始值的全局变量。
2. 初始值为0、没有初始化的全局变量,怎么初始化?
在C语言中,未显式初始化的全局变量和静态变量会被自动初始化为0。这些变量通常位于程序的BSS段或ZI段中。BSS段(Block Started by Symbol)包含了程序中所有未初始化的全局变量和静态变量,而ZI段(Zero Initialized)则包含了那些显式初始化为0的全局变量和静态变量。
在程序启动时,系统会负责将这些段清零。这通常是通过一个简单的内存清零操作(类似memset
)来完成的,因为所有的这些变量都被初始化为0,所以不需要从Flash或其他存储介质中复制数据。这个清零过程是在main
函数被调用之前完成的,确保全局变量和静态变量在使用前已经处于正确的初始状态。
3. 调用main函数
在全局变量和静态变量初始化完成之后,程序的控制权会转移到main
函数。这标志着程序执行的正式开始。在此之前,系统已经完成了一系列的初始化工作,包括硬件初始化、内存初始化、中断向量表设置等。这些工作通常由启动代码或特定的初始化函数来完成,它们确保程序在调用main
函数之前处于正确的运行环境。
4. 局部变量在哪?
局部变量是函数内部定义的变量,它们的存储位置与全局变量不同。局部变量通常存储在栈(Stack)上。栈是一种后进先出(LIFO)的数据结构,用于存储函数调用的上下文信息以及局部变量的值。
每次函数调用时,都会在栈上分配一块内存来存储该函数的局部变量和参数。这块内存的大小在编译时确定,并且会在函数返回时自动释放。由于栈的大小是有限的,因此递归调用过深或局部变量过多都可能导致栈溢出错误。
5. 局部变量的初始化
局部变量的初始化发生在函数调用时。对于显式初始化的局部变量,编译器会在函数体的开头插入相应的初始化代码。这些初始化代码会在函数第一次被调用时执行,确保局部变量在使用前已经被赋予正确的初始值。
对于未显式初始化的局部变量,它们的初始值是未定义的。这意味着这些变量在函数开始时可能包含任何值,取决于它们在内存中的位置以及之前的内存使用情况。因此,在使用未初始化的局部变量之前,最好先显式地给它们赋值,以避免潜在的问题。
6. 栈的作用
栈在程序执行中扮演着至关重要的角色。它主要用于以下方面:
- 函数调用和返回:栈用于保存函数调用的上下文信息,包括指令指针、返回地址以及局部变量和参数的存储位置。当函数被调用时,这些信息被推入栈中;当函数返回时,这些信息从栈中弹出,使程序能够正确地返回到调用点并继续执行。
- 局部变量存储:局部变量和函数参数通常存储在栈上。这样可以在函数调用期间为它们提供临时的存储空间,并在函数返回后自动释放这些空间。
- 动态内存管理:虽然栈主要用于存储局部变量和函数参数,但某些编程语言和运行时环境也允许使用栈来进行动态内存分配和释放。这种用法需要谨慎处理,以避免栈溢出或其他相关问题。