什么是可重入函数?
可重入函数 指的是一个函数,它可以被多个任务(或线程)并发调用,并且在任何时候中断,再次进入该函数执行,其结果都是正确和可预测的。

一个可重入函数在执行过程中,可以被“打断”,安全地”再次进入,而不会破坏自身的数据或导致逻辑错误。
核心思想:函数的执行不依赖于任何“持久性”的状态。
为什么需要可重入函数?
可重入函数主要为了解决 并发 和 中断 带来的问题。
想象一个场景:一个函数正在处理一个全局变量,此时发生了中断(或另一个线程抢占了CPU),中断服务程序也调用了同一个函数。

- 非可重入函数:如果这个函数依赖全局变量,那么中断服务程序可能会修改这个全局变量,导致函数返回后,原始计算结果出错,这就是 “重入” 导致的 “数据竞争”。
- 可重入函数:它不依赖全局变量,所有数据都通过参数传入,结果通过返回值或指针参数传出,无论被调用多少次,都不会互相干扰。
主要应用场景:
- 多线程编程:多个线程同时调用同一个函数。
- 中断服务程序:在主程序执行时,一个中断发生了,ISR 也调用了这个函数。
- 信号处理:在程序执行时,收到了一个信号,信号处理函数也调用了这个函数。
可重入函数的关键特性
一个函数要成为可重入函数,必须满足以下条件:
- 不使用任何静态或全局数据:函数内部不能访问或修改全局变量、静态局部变量等,因为这些数据是共享的,在并发环境下会被多个执行流修改,导致数据不一致。
- 不返回任何指向静态或全局数据的指针:函数不能返回一个指向全局数据或静态数据的地址,因为调用者可能会在函数返回后修改这个数据,从而影响到其他正在使用这个数据的调用。
- 只使用局部数据:所有需要的数据都必须通过函数参数传入,或者作为函数内部的局部变量创建,局部变量存储在 栈 上,每个函数调用都有自己独立的栈空间,因此是线程安全的。
- 不调用任何不可重入的函数:如果一个函数内部调用了
malloc、printf、fopen等标准库函数,而它自己又依赖于这些函数的内部状态(malloc的内存池),那么它本身也就不可重入了。
可重入 vs. 线程安全
这是一个非常容易混淆的概念,但它们有明确的区别。
| 特性 | 可重入 | 线程安全 |
|---|---|---|
| 核心关注点 | 函数能否被中断后再次安全进入。 | 函数能否被多个线程并发调用。 |
| 依赖关系 | 线程安全的函数不一定是可重入的。 | 可重入的函数一定是线程安全的。 |
| 实现方式 | 不依赖任何静态/全局状态,只使用局部变量和参数。 | 可以使用同步机制(如互斥锁、信号量)来保护共享数据。 |
| 中断安全性 | 必须是中断安全的。 | 不一定,一个线程安全的函数如果在执行时被中断,ISR 调用它可能会导致死锁(如果锁了锁)或数据竞争。 |
举个例子来理解它们的区别:
#include <stdio.h>
#include <string.h>
// 1. 不可重入,也不是线程安全的
void unsafe_function() {
static int counter = 0; // 静态变量,共享状态
counter++;
printf("Counter is now: %d\n", counter);
}
// 2. 可重入,也是线程安全的
int reentrant_function(int a, int b) {
int result = a + b; // 只使用局部变量
return result;
}
// 3. 线程安全,但不可重入
#include <pthread.h>
pthread_mutex_t my_mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_data = 0;
void thread_safe_but_not_reentrant() {
pthread_mutex_lock(&my_mutex); // 加锁
shared_data++; // 修改共享数据
printf("Shared data is now: %d\n", shared_data);
pthread_mutex_unlock(&my_mutex); // 解锁
}
分析 thread_safe_but_not_reentrant 为什么不可重入:
假设主线程正在执行这个函数,它成功获取了锁 my_mutex,一个中断发生了,中断服务程序也调用了 thread_safe_but_not_reentrant,当 ISR 试图获取同一个锁时,它会因为锁已经被主线程持有而阻塞,主线程正在被 ISR 阻塞,无法继续执行以解锁,这就造成了 “死锁”,这个函数在ISR环境下是不安全的,即不可重入。
如何编写可重入函数?(最佳实践)
- 避免使用全局变量和静态变量:这是最重要的一条,所有数据都应通过函数参数传递。
- 使用局部变量:将所有临时数据声明为函数内部的局部变量。
- 谨慎使用标准库函数:并非所有标准库函数都是可重入的。
- 不可重入:
malloc,free,printf,scanf,strtok,rand,asctime,getenv等,它们内部通常使用静态缓冲区。 - 可重入版本:C标准库提供了一组带
_r后缀的可重入版本,malloc_r,printf_r,strtok_r等,在嵌入式或对实时性要求高的环境中,应优先使用这些版本。
- 不可重入:
- 如果必须访问共享资源,使用同步机制:在函数内部,如果需要访问一个全局资源(比如硬件寄存器),应该使用互斥锁来保护,但请记住,这样做会让函数变得不可重入(如上例所示),在这种情况下,你需要明确这个函数是否需要在ISR中调用,并做出权衡。
经典示例:strtok vs. strtok_r
这是一个展示可重入重要性的绝佳例子。
strtok - 不可重入
strtok 的功能是分割字符串,它的内部实现使用了一个静态指针来记住当前分割的位置。
#include <stdio.h>
#include <string.h>
void print_tokens(const char* str) {
char* token;
// 第一次调用,strstr不能是NULL
token = strtok((char*)str, " ");
while (token != NULL) {
printf("Token: %s\n", token);
// 后续调用,strstr必须是NULL,以继续上一次的分割
token = strtok(NULL, " ");
}
}
int main() {
char str1[] = "hello world";
char str2[] = "foo bar";
// 在多线程环境下,这会导致严重问题
// 即使是单线程,下面这个例子也说明了问题
print_tokens(str1); // 输出 "hello", "world"
print_tokens(str2); // 输出 "foo", "bar"
// 但如果在第一次调用未完成时发生中断,ISR也调用了strtok,
// 那么ISR的调用会覆盖掉主调用的静态指针,导致主调用的逻辑错误。
return 0;
}
strtok 依赖一个静态的、隐藏的指针来记录分割进度,如果两个线程同时调用 strtok,它们会互相干扰这个静态指针,导致分割结果混乱。strtok 是 不可重入 的。
strtok_r - 可重入
strtok_r 是 strtok 的可重入版本,它通过一个额外的 char** saveptr 参数,让调用者自己来保存分割状态。
#include <stdio.h>
#include <string.h>
void print_tokens_r(const char* str, char* saveptr) {
char* token;
token = strtok_r((char*)str, " ", &saveptr);
while (token != NULL) {
printf("Token: %s\n", token);
token = strtok_r(NULL, " ", &saveptr);
}
}
int main() {
char str1[] = "hello world";
char str2[] = "foo bar";
char* saveptr1; // 用于保存str1的分割状态
char* saveptr2; // 用于保存str2的分割状态
print_tokens_r(str1, &saveptr1); // 输出 "hello", "world"
print_tokens_r(str2, &saveptr2); // 输出 "foo", "bar"
// 即使在多线程环境下,只要每个线程有自己的saveptr变量,
// 它们就不会互相干扰。
return 0;
}
strtok_r 将原本函数内部的“状态”(分割位置)交给了调用者来管理(通过 saveptr 指针),每个调用者都拥有自己独立的 saveptr,因此可以安全地并发执行。strtok_r 是 可重入 的。
| 概念 | 核心思想 | 关键点 |
|---|---|---|
| 可重入函数 | 函数可以被中断,然后再次安全进入,结果正确。 | 不依赖任何持久性(静态/全局)状态,只使用局部变量和参数。 |
| 线程安全函数 | 函数可以被多个线程并发调用,结果正确。 | 可以通过锁等同步机制保护共享数据。 |
| 关系 | 可重入 => 线程安全,但反之不成立。 | 线程安全的函数在ISR中可能死锁,因此不可重入。 |
在编写高性能、高可靠性的 C 代码,尤其是在操作系统、驱动、网络库等底层模块时,始终将函数设计为可重入的 是一个非常好的编程习惯,它能极大地减少并发相关的 Bug,提高代码的健壮性和可维护性。
