C语言中的内存管理:从基础到实践
1. 引言
在编程项目中,内存资源管理至关重要,特别是在嵌入式系统中。多线程代码对于满足实时系统的响应时间要求至关重要,它可以将软件划分为一组更小、更简单(但相关)的任务,从而简化复杂应用程序的设计。在多线程代码中,每个线程操作自己的私有数据,但通常通过访问少量共享数据与其他线程进行通信。任何由多个线程或中断服务例程(ISR)操作的数据都会创建所谓的“共享内存”情况,需要仔细协调访问,否则可能会发生数据损坏。
2. C语言中的对象
在原始的C编程语言中,对象被定义为可以检查和修改的内存区域。对于本文而言,可以简单地将对象视为变量,包括标量(如char、int或指针)或聚合数据类型(如struct或union)。每个对象都具有许多不同的属性,如下表所示:
| 属性 | 描述 |
| ---- | ---- |
| 类型 | char、int、unsigned int等(还意味着大小、范围和分辨率) |
| 名称 | 用于访问对象的标识符 |
| 值 | 对象中保存的数据 |
| 地址 | 对象在内存中的位置 |
| 作用域 | 源代码中可以识别对象名称的部分 |
| 生命周期 | 对象创建和销毁的时间概念,即何时可以使用该对象 |
作用域和生命周期是对象的“位置和时间”。程序员使用“局部”和“全局”等词来描述对象的作用域,指的是在源代码中可以引用对象的位置。对象的内存分配方式决定了其生命周期,并对程序执行期间何时可以进行引用施加了第二个限制。
3. 作用域
大多数程序员并没有充分利用作用域提供的限制访问能力。声明通常只放在两个位置之一:(1)在所有函数外部创建全局变量;(2)在函数头之后立即创建函数局部的临时变量。然而,更深入地了解作用域可以进一步限制访问,更好地防止对象被意外误用。
3.1 细化局部作用域
局部作用域指的是在包含块中声明的对象。块(可以嵌套)以左花括号开始,以匹配的右花括号结束。对象的作用域从其声明点开始,到包含它的块的右花括号结束。在块中声明的对象不能在该块外部引用。因此:
1. 只要对象在不同的块中声明,同一个标识符可以用于多个对象;标识符与它们所引用的对象之间的对应关系由它们出现的块来解决。
2. 当对象的声明出现在尽可能最内层的块中时,可以实现对对象访问的最大限制。(不要将所有局部声明都放在函数的开头,考虑将它们移动到循环体或if语句的then或else块中。)
3. 当块嵌套时,在内层块中声明的对象优先于(“隐藏”)在外部块中使用相同标识符声明的对象。
以下是一个示例:
void main()
{
int a = 1;
printf("a = %d\n", a);
if (1)
{
char a = '2';
...
printf("a = %c\n", a);
}
...
printf("a = %d\n", a);
}
在这个示例中,char类型的对象a的作用域从声明到包含它的块结束。块外部的语句不能对该对象进行语法引用,提供了一些防止意外修改的保护。int类型的对象a的作用域从声明点到函数的右花括号。需要注意的是,内层块中声明的另一个a隐藏了外层的int类型的a。
3.2 细化全局作用域
在C语言中,在所有函数外部声明的对象具有全局作用域。这些声明通常放在源文件的顶部(或包含在源文件顶部的头文件中)。但实际上,全局意味着标识符只能从声明点到文件末尾访问。因此,一种简单的减少全局变量作用域(在文件上下文中)的方法是将其声明尽可能靠近源文件的末尾(即在使用它的第一个函数之前)。语言的语法规则将保护标识符在声明之前的语句中不被意外误用。
当程序由多个源文件组成时,在一个文件中定义的全局对象可以被任何其他文件中的代码引用,但需要满足两个条件:
1. 包含对全局对象引用的文件必须使用extern关键字声明它(否则可能会创建一个单独的实例)。
2. 包含全局对象定义的文件在声明中不能使用关键字static。
在引用文件中,标识符的作用域由extern声明的位置决定。如果声明是全局的,则标识符在文件中是全局的;将声明放在块中会使其在该块中是局部的。虽然extern关键字是建立对全局对象的文件间引用所必需的,但它将控制权交给了引用方而不是定义方,无法防止程序员未预期的文件间引用。
向全局对象的声明中添加关键字static会将其作用域限制在声明它的文件中。这个static关键字的非直观特性会使对象的标识符在链接过程中被隐藏,从而消除了从其他文件进行语法引用的任何可能性。许多程序员会创建一个宏来明确他们的意图,例如:
#define LOCAL2FILE static
...
LOCAL2FILE int b = 0; /* 这个 'b' 不能从其他文件访问! */
函数本质上是全局的。虽然在引用文件中通常需要函数原型声明来指定参数的数量和类型,但原型不需要包含extern关键字来建立文件间引用。链接器会自动解析文件间的函数引用,除非函数定义以关键字static开头。
4. 生命周期
对象会经历创建、初始化、使用和销毁的过程。生命周期的概念指的是对象存在的持续时间,即何时可以访问该对象,它决定了对象如何创建和销毁,从而决定了其内存管理是自动的还是程序员的责任。
C语言提供了三种基本的内存分配类型,如下表所示:
| 方法 | 对象创建时间 | 对象初始化时间 | 对象销毁时间 |
| ---- | ---- | ---- | ---- |
| 自动分配 | 每次程序进入声明它的函数时 | 如果在声明中指定,每次程序进入块时进行初始化 | 每次函数返回时 |
| 静态分配 | 程序首次加载到内存时一次 | 程序开始运行之前一次 | 程序停止时一次 |
| 动态分配 | 通过调用库函数malloc | 通过编写修改其内容的可执行语句 | 通过调用库函数free |
4.1 自动分配
自动内存分配是函数内部的默认分配方法。虽然不是必需的,但可以添加关键字auto作为声明前缀来明确分配方法。自动对象在从栈中为其分配内存时创建,这在每次进入包含它的函数时都会发生。如果指定了初始化,则在每次进入包含它的块时进行初始化。
例如:
void foo(void)
{
int32_t x[10];
...
for (;;)
{
int32_t y[10] = {10};
...
}
}
对应的汇编代码如下:
foo PUSH {LR} ; 保存返回地址
SUB SP,SP,#80 ; 在栈上分配x和y的内存
...
L1: ; 循环开始
MOV R0,SP ; R0 = y的地址
LDR R1,=40 ; R1 = 要清零的字节数
BL memclr ; 调用库函数清零内存
; 循环体
B L1
L2: ADD SP,SP,#80 ; 释放分配的内存
POP {LR} ; 恢复返回地址
RET
; 从函数返回
自动分配的明显缺点是缺乏持久性,即在函数体中声明的自动对象在函数的一次调用到另一次调用之间不会保留其值。其优点是通过重用节省内存,并且程序会以对程序员透明的方式自动管理这种内存资源的重用。
4.1.1 存储类“Register”
关键字register是显式或隐式使用关键字auto的显式替代方法。唯一的区别是编译器会尝试分配CPU的一个寄存器而不是内存区域,从而提供对对象的更快访问。实际上,这个关键字允许程序员告诉编译器如何优化代码。对于需要最大性能的实时程序员来说,寄存器分配可能听起来很诱人,但自C语言首次引入以来,编译器技术已经有了很大的改进,程序员辅助优化的需求不再那么重要。
知道何时进行寄存器分配并不明显。寄存器分配在每个函数内部是局部的。当一个函数调用另一个函数时,无法知道第二个函数会对寄存器做什么,因此在调用期间必须将所有寄存器变量保存在内存中(并在之后恢复)。由此产生的开销通常会抵消任何性能提升。更糟糕的是,并不总是能明显看出何时会发生这样的函数调用。一些看似简单的C运算符实际上是由库例程实现的,这些例程在没有函数调用的表达式求值时被调用。
使用关键字register并不能保证会分配寄存器。C语言允许编译器简单地忽略该关键字并使用自动内存分配。当然,请求对(大的)聚合对象进行寄存器分配会失败,因为它们的大小通常大于寄存器中的位数。更重要的是,大多数CPU可用于分配的寄存器数量非常有限(可能只有两三个);一旦分配完,额外的请求必须转换为自动分配。
4.2 静态分配
静态对象在程序首次加载到内存时(可能)进行一次分配和初始化。它们在内存中的位置在程序的整个执行过程中不会改变,因此静态对象直到程序终止才会被销毁。静态分配的优点是值的持久性。与使用自动分配的对象不同,存储在静态对象中的值在函数的一次执行到另一次执行之间会保留。
静态分配的缺点是一旦对象不再需要,就无法回收内存用于其他目的。大量使用静态对象可能会导致程序需要大量内存。一个经验不足的程序员试图通过将所有对象声明为全局来避免处理作用域限制,最终会产生非常“臃肿”的程序。
每个全局对象本质上都使用静态分配。经验不足的程序员有时也会将对象声明为全局,只是为了在函数的一次调用到另一次调用之间保留其值。然而,一种更好的在不使用全局作用域的情况下实现相同目的的方法是简单地在局部对象的声明中添加关键字static,例如:
// 不好的做法
/*
这些两个对象是全局的!
*/
unsigned total = 0;
unsigned count = 0;
void Enter_Score(unsigned score)
{
total += score;
count++;
printf("average score = %u\n", total / count);
}
// 好的做法
void Enter_Score(unsigned score)
{
/* 这两个对象是函数局部的! */
static unsigned total = 0;
static unsigned count = 0;
total += score;
count++;
printf("average score = %u\n", total / count);
}
然而,当静态对象被多个线程修改时,可能会出现问题。当对象是全局的时,自然会担心这种可能性。但是,将它们移动到函数的局部作用域并不能防止这种情况。如果不习惯使用静态局部变量,可能会倾向于将静态对象保持为全局,而不是去了解为什么限制它们的作用域没有帮助。但真正的问题与它们的作用域无关,而是它们是静态的这一事实。因此,首要任务应该是尽可能尝试将它们的分配方法更改为自动或动态。
5. 区分静态和自动分配的三个程序
有时,通过实际运行的程序来理解某些概念会更容易。下面的示例程序展示了静态和自动分配在对象创建、初始化和销毁方面的差异。
5.1 对象创建
#include <stdio.h>
void f1()
{
auto int a;
static int s;
printf(" &auto = %08X\n", &a);
printf("&static = %08X\n", &s);
}
void f2() { auto int x; f1(); }
int main() { f1(); f2(); f1(); return 0; }
程序输出:
&auto = 0008E8BC
&static = 0000BA28
&auto = 0008E89C
&static = 0000BA28
&auto = 0008E8BC
&static = 0000BA28
从输出可以看出,静态对象“s”的地址在所有三次调用中保持不变,这表明“s”只有一个实例,其在内存中的位置在程序的整个执行过程中保持固定。而自动对象“a”的地址在不同的调用中发生了变化,这表明它在程序执行过程中被多次创建和销毁。
5.2 对象初始化
示例11 - 3旨在提供一些关于具有自动分配和静态分配的对象在初始化方面如何不同的见解。程序由函数main、f1、f2、f3和f4组成。在函数f1和f2中,用于整数对象的分配方法对函数行为没有影响。在这两个函数中,每次函数被调用时,整数都会被函数体中的可执行语句重置为零。
6. 总结
通过对C语言中内存管理的学习,我们了解了对象的属性、作用域、生命周期以及不同的内存分配方法。合理利用作用域和内存分配方法可以帮助我们编写更高效、更健壮的程序。在实际编程中,我们应该根据具体需求选择合适的内存分配方式,避免数据损坏和内存浪费。同时,对于多线程编程中的共享内存问题,需要特别注意协调访问,确保数据的完整性。
以下是一个简单的mermaid流程图,展示了内存分配方法的选择过程:
graph TD;
A[是否需要持久化数据?] -->|是| B[静态分配];
A -->|否| C[是否需要频繁创建和销毁对象?];
C -->|是| D[自动分配];
C -->|否| E[动态分配];
在编程过程中,我们可以根据这个流程图来选择合适的内存分配方法,以提高程序的性能和可靠性。
7. 自动分配与静态分配的详细对比
为了更清晰地理解自动分配和静态分配的区别,我们进一步从多个方面进行对比。
7.1 内存使用效率
- 自动分配 :自动分配的对象在函数返回时会自动释放内存,这使得内存可以被重复使用。例如在递归函数中,每次递归调用都会创建新的自动对象,而在返回时释放这些对象占用的内存,避免了内存的浪费。但如果函数调用频繁,频繁的内存分配和释放操作可能会带来一定的开销。
- 静态分配 :静态对象在程序加载时就分配了内存,并且在整个程序运行期间都不会释放。如果静态对象使用过多,会导致程序占用大量的内存,尤其是在资源有限的嵌入式系统中,可能会引发内存不足的问题。
7.2 数据持久性
- 自动分配 :自动对象的生命周期仅限于函数的一次调用,每次函数调用结束后,对象的值不会保留。例如下面的代码:
void func() {
auto int num = 0;
num++;
printf("num = %d\n", num);
}
int main() {
func();
func();
return 0;
}
在这个例子中,每次调用
func
函数时,
num
都会被初始化为0,然后加1,输出结果都是1。
-
静态分配
:静态对象的值会在函数的多次调用之间保留。修改上面的代码如下:
void func() {
static int num = 0;
num++;
printf("num = %d\n", num);
}
int main() {
func();
func();
return 0;
}
此时,第一次调用
func
函数时,
num
初始化为0,加1后输出1;第二次调用时,
num
保留了上一次的值1,再加1后输出2。
7.3 线程安全性
- 自动分配 :由于自动对象是每个线程独立拥有的,不同线程之间的自动对象不会相互影响,因此在多线程环境中,自动分配的对象通常是线程安全的。
- 静态分配 :静态对象在多个线程之间是共享的,如果多个线程同时修改静态对象,可能会导致数据不一致的问题。例如下面的代码:
#include <stdio.h>
#include <pthread.h>
static int shared_num = 0;
void* thread_func(void* arg) {
for (int i = 0; i < 100000; i++) {
shared_num++;
}
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, thread_func, NULL);
pthread_create(&thread2, NULL, thread_func, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("shared_num = %d\n", shared_num);
return 0;
}
在这个例子中,两个线程同时对
shared_num
进行自增操作,由于自增操作不是原子操作,可能会导致数据不一致,最终输出的结果可能小于200000。
8. 动态分配的使用场景和注意事项
8.1 使用场景
- 需要在运行时确定对象大小 :当对象的大小在编译时无法确定,需要在运行时根据用户输入或其他条件来确定时,动态分配是一个很好的选择。例如,在处理用户输入的字符串时,我们不知道字符串的长度,就可以使用动态分配来分配足够的内存。
#include <stdio.h>
#include <stdlib.h>
int main() {
int length;
printf("请输入字符串的长度: ");
scanf("%d", &length);
char* str = (char*)malloc((length + 1) * sizeof(char));
if (str == NULL) {
printf("内存分配失败\n");
return 1;
}
printf("请输入字符串: ");
scanf("%s", str);
printf("你输入的字符串是: %s\n", str);
free(str);
return 0;
}
- 需要在程序运行期间动态调整对象大小 :有时候,我们需要在程序运行过程中根据实际情况动态调整对象的大小,例如动态数组的实现。
#include <stdio.h>
#include <stdlib.h>
int main() {
int* arr = (int*)malloc(5 * sizeof(int));
if (arr == NULL) {
printf("内存分配失败\n");
return 1;
}
for (int i = 0; i < 5; i++) {
arr[i] = i;
}
// 动态调整数组大小
int* new_arr = (int*)realloc(arr, 10 * sizeof(int));
if (new_arr == NULL) {
printf("内存重新分配失败\n");
free(arr);
return 1;
}
arr = new_arr;
for (int i = 5; i < 10; i++) {
arr[i] = i;
}
for (int i = 0; i < 10; i++) {
printf("%d ", arr[i]);
}
printf("\n");
free(arr);
return 0;
}
8.2 注意事项
-
内存泄漏
:动态分配的内存需要手动释放,如果忘记调用
free函数,会导致内存泄漏。例如下面的代码:
#include <stdlib.h>
void func() {
int* ptr = (int*)malloc(sizeof(int));
// 忘记释放内存
}
int main() {
func();
return 0;
}
- 悬空指针 :当释放了动态分配的内存后,如果仍然使用指向该内存的指针,就会产生悬空指针。例如:
#include <stdio.h>
#include <stdlib.h>
int main() {
int* ptr = (int*)malloc(sizeof(int));
*ptr = 10;
free(ptr);
// 悬空指针
printf("%d\n", *ptr);
return 0;
}
9. 多线程编程中的内存管理策略
在多线程编程中,内存管理更加复杂,需要特别注意共享内存的访问和同步。以下是一些常见的策略:
9.1 互斥锁
互斥锁是一种常用的同步机制,用于保护共享资源。当一个线程访问共享资源时,会先获取互斥锁,其他线程需要等待该线程释放互斥锁后才能访问。例如:
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t mutex;
static int shared_num = 0;
void* thread_func(void* arg) {
pthread_mutex_lock(&mutex);
for (int i = 0; i < 100000; i++) {
shared_num++;
}
pthread_mutex_unlock(&mutex);
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_mutex_init(&mutex, NULL);
pthread_create(&thread1, NULL, thread_func, NULL);
pthread_create(&thread2, NULL, thread_func, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
pthread_mutex_destroy(&mutex);
printf("shared_num = %d\n", shared_num);
return 0;
}
9.2 线程局部存储
线程局部存储(Thread Local Storage,TLS)允许每个线程拥有自己独立的变量副本,避免了线程之间的竞争。在C语言中,可以使用
__thread
关键字来声明线程局部变量。
#include <stdio.h>
#include <pthread.h>
__thread int thread_local_num = 0;
void* thread_func(void* arg) {
thread_local_num++;
printf("线程 %ld 的 thread_local_num = %d\n", pthread_self(), thread_local_num);
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, thread_func, NULL);
pthread_create(&thread2, NULL, thread_func, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
return 0;
}
10. 总结与展望
10.1 总结
C语言中的内存管理是一个复杂而重要的主题,涉及到对象的属性、作用域、生命周期以及不同的内存分配方法。合理选择内存分配方法可以提高程序的性能和可靠性,避免内存泄漏和数据损坏等问题。在多线程编程中,需要特别注意共享内存的访问和同步,采用合适的同步机制来保证数据的一致性。
10.2 展望
随着计算机技术的不断发展,内存管理技术也在不断进步。未来,可能会出现更加智能和高效的内存管理机制,例如自动垃圾回收机制在C语言中的应用,或者更加精细的内存分配算法,以满足不同应用场景的需求。同时,对于多线程编程中的内存管理,也会有更加完善的解决方案,降低程序员的开发难度,提高程序的并发性能。
以下是一个表格,总结了不同内存分配方法的特点:
| 分配方法 | 优点 | 缺点 | 使用场景 |
| ---- | ---- | ---- | ---- |
| 自动分配 | 内存可重用,自动管理 | 缺乏持久性 | 函数内部临时对象 |
| 静态分配 | 值的持久性 | 内存无法回收 | 需要在函数多次调用间保留值的对象 |
| 动态分配 | 运行时确定对象大小,可动态调整 | 需要手动管理,易出现内存泄漏 | 需要在运行时动态分配和调整大小的对象 |
下面是一个mermaid流程图,展示了多线程编程中内存管理的基本流程:
graph TD;
A[是否有共享内存?] -->|是| B[使用同步机制];
B --> C[访问共享内存];
C --> D[释放同步资源];
A -->|否| E[正常访问内存];
通过对这些知识的掌握和应用,我们可以在C语言编程中更加灵活地管理内存,编写出高质量的程序。
超级会员免费看
639

被折叠的 条评论
为什么被折叠?



