19、函数重入问题:可重入函数与不可重入函数,线程安全
函数重入,说白了就是同一个函数被多个执行流同时调用时,会不会出乱子。
我刚开始做嵌入式开发那会儿,觉得函数就是函数,你调我也调,各调各的,能有什么问题?直到有一次,一个中断服务程序里调了一个库函数,主循环也在调同一个函数。结果呢?全局变量被踩得稀碎,系统直接跑飞。从那以后,我对“重入”这两个字就特别敏感。
今天咱们就把这个事彻底聊透。
19.1 什么是函数重入?
先看一个最简单的场景:
int counter = 0;
void increment(void) {
counter++;
}
这个函数,如果在主循环里跑,同时中断里也调它,会发生什么?
counter++ 看起来是一条语句,但底层是三条指令:
- 读 counter 到寄存器
- 寄存器加1
- 写回 counter
假设主循环刚读完 counter(值为5),还没来得及写回,中断来了。中断里也调了 increment(),它完整地执行了一遍:读、加、写,counter 变成6。然后中断返回,主循环继续执行第二步:寄存器加1(注意,寄存器里存的还是5),然后写回。结果 counter 又变成6。
两次调用,本该加2,结果只加了1。这就是典型的不可重入问题。
可重入函数:可以被多个任务/中断同时调用,不会产生数据错乱。
不可重入函数:依赖全局或静态数据,多路同时调用时行为不可预测。
19.2 不可重入的典型特征
我个人习惯,判断一个函数是否可重入,先看它有没有以下“雷区”:
- 使用全局或静态变量:这是最常见的坑。多个执行流共享同一份数据,不加保护就是灾难。
- 调用不可重入函数:你调了一个库函数,它内部用了静态变量,那你的函数也跟着“脏”了。
- 返回指向静态数据的指针:比如
char *get_name(void) { static char buf[32]; ... return buf; },两次调用返回同一个地址,后一次会覆盖前一次的数据。 - 使用硬件资源而不加保护:比如操作同一个串口、同一个ADC寄存器。
举个例子,标准C库里的 strtok() 就是典型的不可重入函数。它内部用了一个静态指针来记住当前解析位置。你在两个线程里同时调它,结果就是乱套的。
注意:很多RTOS和标准库提供了“可重入版本”,比如 strtok_r(),它把状态数据交给调用者管理,而不是藏在函数内部。能用 _r 版本就别用原版。
19.3 如何写出可重入函数?
其实原则很简单,就几条:
- 只使用局部变量:局部变量在栈上分配,每个调用实例都有自己的副本,互不干扰。
- 如果必须用全局数据,加锁保护:但注意,中断里不能加锁(会导致死锁),所以中断里调用的函数最好完全不用全局数据。
- 参数传递代替静态数据:需要“记住”的状态,让调用者传进来,或者通过参数返回。
- 不要返回内部静态缓冲区的指针:要么让调用者提供缓冲区,要么动态分配(但动态分配本身也要考虑重入)。
来看一个对比:
// 不可重入版本
static int seed = 42;
int my_rand(void) {
seed = seed * 1103515245 + 12345;
return seed & 0x7FFFFFFF;
}
// 可重入版本
int my_rand_r(int *seedp) {
*seedp = *seedp * 1103515245 + 12345;
return *seedp & 0x7FFFFFFF;
}
第二个版本,状态由调用者管理。每个线程/中断自己维护一个 seed 变量,互不干扰。我在项目中写随机数生成器时,一律用这种模式。
19.4 中断与重入:一个特殊场景
嵌入式里最头疼的就是中断和主循环共享函数。我曾经踩过一个坑:
一个温度传感器驱动,读取函数里用了一个全局数组做临时缓冲区。主循环每秒读一次,没问题。后来加了一个过温中断,中断里也调了同一个读取函数。结果主循环读到一半的数据被中断覆盖了,温度值忽高忽低,触发了误报警。
解决方案?要么给全局缓冲区加临界区保护,要么把临时缓冲区改成局部变量。我选了后者——局部变量在栈上,中断有自己的栈,天然隔离。
经验之谈:中断服务程序里调用的函数,最好全部是可重入的。如果做不到,至少确保中断和主循环不会同时调用同一个不可重入函数。用临界区保护也可以,但临界区要尽量短,否则影响中断响应。
19.5 线程安全与可重入的关系
这两个概念经常被混用,其实有区别:
| 概念 | 核心 | 典型手段 |
|---|---|---|
| 可重入函数 | 函数本身不依赖共享状态,多路同时调用无副作用 | 只用局部变量,状态由调用者管理 |
| 线程安全函数 | 多线程调用时,通过同步机制保证数据一致性 | 加锁、原子操作、临界区 |
可重入函数一定是线程安全的——因为它根本不需要锁。但线程安全的函数不一定是可重入的。比如一个函数用互斥锁保护了全局变量,它在多线程下是安全的,但如果中断里也调它,中断里拿不到锁(或者锁被主线程持有),就死锁了。
所以,在嵌入式裸机或RTOS环境下,我建议优先追求“可重入”,而不是“线程安全”。可重入是更严格的约束,写出来的代码更健壮。
19.6 知识体系图
下面这张图梳理了函数重入问题的核心脉络:
19.7 避坑指南
最后,分享几个我亲身踩过的坑:
- 别信库函数文档:有些库函数文档没写是否可重入,我建议你直接看源码。如果看到 static 变量,立刻警惕。
- 中断里别用 printf:printf 内部用了全局缓冲区,而且可能加锁。中断里调 printf,轻则输出乱码,重则死锁。我一般用专门的“中断安全”日志输出,比如直接写环形缓冲区。
- 小心编译器优化:有时候你写了可重入的代码,但编译器优化后,局部变量可能被优化成全局的。用 volatile 可以防止一些优化,但根本办法是检查生成的汇编。
- RTOS 的任务栈要够大:可重入函数依赖栈来隔离数据,如果栈溢出,局部变量互相覆盖,可重入就失效了。我习惯给每个任务多分配 20% 的栈空间。
嗯,函数重入这个问题,说大不大,说小不小。但一旦出问题,查起来特别费劲——因为它是偶发的,跟时序强相关。你写代码时多花十分钟考虑重入问题,可能省下后面几天的调试时间。
记住一句话:能写成可重入的,就别写成不可重入的。这是嵌入式开发里性价比最高的习惯之一。