跳到主要内容

线程通信、同步、互斥

怎么解决《2.2使用RTOS的优势与挑战》里提到的问题?

比如我们的音乐任务里,即使控制线程没有获得用户输入,播放线程也在“空转”——它在不断判断“g_flag”,这使得CPU的利用率很低。这个问题可以使用队列来解决。

再比如LED任务里,它需要不断轮询按键是否被按下,CPU利用率也很低。可以把程序拆分为2部分:按键检测(在中断里释放信号量),LED操作(获得信号量后操作LED)。

APP获取数据的4种方式​

4种方式​

以下图生活场景为例:

妈妈怎么知道卧室里小孩醒了?

  • 时不时进房间看一下:查询方式

简单,但是累

  • 进去房间陪小孩一起睡觉,小孩醒了会吵醒她:休眠-唤醒

不累,但是妈妈干不了活了

  • 妈妈要干很多活,但是可以陪小孩睡一会,定个闹钟:poll方式

要浪费点时间,但是可以继续干活。

妈妈要么是被小孩吵醒,要么是被闹钟吵醒。

  • 妈妈在客厅干活,小孩醒了他会自己走出房门告诉妈妈:异步通知

妈妈、小孩互不耽误。

这4种方法没有优劣之分,在不同的场合使用不同的方法。

在前面的项目里,播放线程就是以“查询方式”不断地查询g_flag是否为1。

后续我们可以把它改造为:

  • 使用消息队列的mq_receive:可以即刻返回(NON_BLOCK),也可以休眠-唤醒

  • 使用消息队列的mq_timedreceive:定个闹钟

  • 使用消息队列的mq_notify:异步通知

消息队列有这4种使用方式,其他对象比如信号量、互斥量,也有这些使用模式。

openvela里常用的机制​

openvala中,使用消息队列传递数据,使用信号量进行通知,使用互斥量进行独占式访问,使用信号实现异步通知,如下图所示:

openvela中常用的消息队列、信号量、互斥量、信号,列表如下:

<table style="width:100%;">

<colgroup> <col style="width: 4%" /> <col style="width: 20%" /> <col style="width: 18%" /> <col style="width: 28%" /> <col style="width: 27%" /> </colgroup> <tbody> <tr class="odd"> <td><strong>特性</strong></td> <td><strong>消息队列(Message Queue)</strong></td> <td><strong>信号量(Semaphore)</strong></td> <td><strong>互斥量(Mutex)</strong></td> <td><strong>信号(Signal)</strong></td> </tr> <tr class="even"> <td><strong>核心作用</strong></td> <td>进程 / 线程间传递数据(消息),实现异步通信</td> <td>控制多个进程 / 线程对共享资源的访问数量(如限制并发数)</td> <td>保证同一时间只有一个进程 / 线程访问共享资源(互斥访问)</td> <td>进程 / 线程间的异步事件通知(如异常处理、外部中断)</td> </tr> <tr class="odd"> <td><strong>数据传递</strong></td> <td>支持传递结构化数据(消息内容),可附带优先级</td> <td>不传递数据,仅通过计数器控制资源访问权限</td> <td>不传递数据,仅标记资源是否被占用</td> <td>可传递信号值(整数),部分信号携带附加信息(如 SIGSEGV 的内存地址)</td> </tr> <tr class="even"> <td><strong>使用场景</strong></td> <td><ol type="1"> <li><p>进程间需要传递复杂数据(如日志、命令)</p></li> <li><p>异步通信场景(如生产者 - 消费者模型)</p></li> <li><p>解耦发送方和接收方(无需实时响应)</p></li> </ol></td> <td><ol type="1"> <li><p>限制资源并发访问数(如数据库连接池、线程池)</p></li> <li><p>同步多个线程的执行顺序(如 barrier 机制)</p></li> <li><p>控制任务队列的处理节奏</p></li> </ol></td> <td><ol type="1"> <li><p>保护共享资源(如全局变量、硬件设备)</p></li> <li><p>防止多个线程同时执行临界区代码</p></li> <li><p>实现独占访问(如单例模式)</p></li> </ol></td> <td><ol type="1"> <li><p>处理异常事件(如程序崩溃、除零错误)</p></li> <li><p>进程间紧急通知(如终止信号 SIGTERM)</p></li> <li><p>定时任务触发(如 SIGALRM)</p></li> </ol></td> </tr> <tr class="odd"> <td><strong>同步方式</strong></td> <td>异步。写队列成功后会即刻返回,无需等待对方处理消息。如果队列满了,在中断上下文或者使用O_NONBLOCK时不会阻塞,其余时候都会阻塞</td> <td>同步(获取信号量失败时阻塞,直到被唤醒)</td> <td>同步(获取锁失败时阻塞,直到锁释放)</td> <td>异步(信号到达时打断程序正常执行,触发信号处理函数)</td> </tr> <tr class="even"> <td><strong>资源占用</strong></td> <td>较高(需存储消息数据,内核维护队列结构)</td> <td>较低(仅维护一个计数器)</td> <td>较低(维护锁状态和等待队列)</td> <td>极低(仅传递信号值,内核触发回调)</td> </tr> <tr class="odd"> <td><strong>典型操作</strong></td> <td>创建队列、发送消息(mq_send)、接收消息(mq_receive)、删除队列</td> <td>初始化(sem_init)、P 操作(sem_wait,减计数)、V 操作(sem_post,增计数)</td> <td>初始化(pthread_mutex_init)、加锁(pthread_mutex_lock)、解锁(pthread_mutex_unlock)</td> <td>注册处理函数(signal/sigaction)、发送信号(kill/pthread_kill)、忽略信号</td> </tr> <tr class="even"> <td><strong>关键点</strong></td> <td>消息可缓存,支持按类型 / 优先级接收,适合数据量较大的通信</td> <td>计数器初始值决定最大并发数(如初始值为 3,表示允许 3 个线程同时访问)</td> <td>具有 “所有权”(只有加锁的线程能解锁),支持递归锁(同一线程可重复加锁)</td> <td>信号处理函数需异步安全(避免使用 printf 等非线程安全函数),部分信号无法捕获(如 SIGKILL)</td> </tr> <tr class="odd"> <td><strong>主要区别</strong></td> <td>以 “数据传递” 为核心,解决进程间通信问题</td> <td>以 “资源计数” 为核心,解决并发控制问题</td> <td>以 “独占访问” 为核心,解决互斥问题</td> <td>以 “事件通知” 为核心,解决异步响应问题</td> </tr> </tbody> </table>

消息队列的使用​

要使用消息队列,需要先创建或打开它,然后就可以往里面写入数据、读出数据了:

API​

openvela的消息队列类似于“文件”,使用它时就像“open/read/write/close”一样有如下主要接口函数“mq_open/mq_send/mq_receive/mq_close”,对于发送、接收的功能,额外添加2个函数“mq_timedsend/mq_timedreceive”。

列表如下:

函数名功能描述
mq_open创建或打开一个消息队列
mq_close关闭消息队列描述符
mq_unlink删除消息队列(名称与内核对象解绑)
mq_send向消息队列发送消息
mq_timedsend在指定超时时间内发送消息
mq_receive从消息队列接收消息(获取优先级最高、最早的消息)
mq_timedreceive在指定超时时间内接收消息
mq_getattr获取消息队列的属性
mq_setattr设置消息队列的属性(仅mq_flags字段可修改)
mq_notify为消息队列注册一个异步事件通知。当消息队列接收到一条新消息时,系统会向注册的任务发送一个信号。

打开/关闭​

mq_open/mq_close/mq_unlink函数管理消息队列的创建、打开、关闭和删除。

mq_open()用于创建或打开消息队列。此函数打开一个已存在的消息队列,或根据 oflags 参数创建一个新的队列。成功调用后,它返回一个消息队列描述符 (mqd_t),供后续函数使用。

函数原型如下:

mqd_t mq_open(FAR const char *mq_name, int oflags, ...)

参数描述
mq_name指向消息队列名称的字符串,例如 "/my_queue"。
oflags操作标志位,可使用按位或(|)组合。

oflags 的常用值包括:

  • 访问模式(三选一):

    • O_RDONLY:以只读方式打开。

    • O_WRONLY:以只写方式打开。

    • O_RDWR:以读写方式打开。

  • 创建标志(可选):

    • O_CREAT:如果队列不存在,则创建它。

使用此标志时,mq_open 需要两个额外参数:mode_t mode 和 struct mq_attr *attr。

  • O_EXCL:与 O_CREAT 配合使用,如果队列已存在,则调用失败。

  • O_NONBLOCK:以非阻塞模式打开。影响后续的 mq_send() 和 mq_receive() 调用。

注意:mq_open函数里,可以传入属性,比如:

struct mq_attr attr;

attr.mq_maxmsg = 10; // 最大消息数

attr.mq_msgsize = 1024; // 单条消息最大长度(字节)

mqd_t mq = mq_open("/myqueue", O_CREAT \| O_RDWR, 0644, &attr);

如果不传入attr,那么消息队列默认可以容纳16个消息,每个消息大小是32字节。

mq_close() - 关闭消息队列。此函数断开调用任务与指定消息队列之间的连接。

函数原型如下:

int mq_close(mqd_t mqdes)

调用 mq_close() 并不会销毁消息队列本身,仅释放当前任务持有的描述符。其他任务仍可通过 mq_open() 访问该队列。

mq_unlink() - 删除消息队列。此函数从系统中删除一个消息队列。

函数原型如下:

int mq_unlink(FAR const char \*mq_name)

该接口会删除名字为 mq_name 的消息队列。当有一个或多个 Task 打开一个消息队列,此时调用 mq_unlink,需要等到所有引用该消息队列的 Task 都执行关闭操作后,才会删除消息队列。

注意:如果调用 mq_unlink() 时仍有任务打开了该队列,系统会将队列标记为“待删除”状态。系统会等待所有引用该队列的任务都调用 mq_close() 后,才真正释放队列资源。

传输函数​

mq_send和mq_timedsend、mq_receive和mq_timedreceive负责在任务间发送和接收消息。

mq_send() / mq_timedsend() - 发送消息,函数原型如下:

int mq_send(mqd_t mqdes, const void \*msg, size_t msglen, int prio)

int mq_timedsend(mqd_t mqdes, const char \*msg, size_t msglen, int prio,

const struct timespec \*abs_timeout);

发送消息的行为特性如下:

  • 队列已满:

    • 如果队列已满且未设置 O_NONBLOCK:

mq_send() 会永久阻塞,直到队列有可用空间。

mq_timedsend() 会阻塞,直到 abs_timeout指定的绝对时间超时。

  • 如果设置了 O_NONBLOCK,函数会立即返回错误,而不会阻塞。

<!-- -->

  • 消息长度: msglen 不能超过队列属性中定义的最大消息长度 (mq_msgsize)。

mq_receive() / mq_timedreceive()接收消息。这两个函数从指定队列中移除并返回优先级最高、等待时间最长的消息。

函数原型如下:

ssize_t mq_receive(mqd_t mqdes, void \*msg, size_t msglen, int \*prio);

ssize_t mq_timedreceive(mqd_t mqdes, void *msg, size_t msglen,

int \*prio, const struct timespec \*abstime);

接收消息的行为特性如下:

  • 队列为空:

    • 如果队列为空且未设置 O_NONBLOCK:

mq_receive() 会永久阻塞,直到有新消息到达。

mq_timedreceive() 会阻塞,直到 abstime 指定的绝对时间超时。

  • 如果设置了 O_NONBLOCK,函数会立即返回错误。

<!-- -->

  • 多任务等待: 如果有多个任务在等待同一个空队列,当新消息到达时,系统会唤醒等待时间最长且优先级最高的那个任务。

  • 缓冲区大小: msglen 必须大于或等于队列的最大消息长度 (mq_msgsize)。

abs_timeout结构体定义如下:

struct timespec {

time_t tv_sec; // 秒数(自UTC时间1970-01-01 00:00:00起)

long tv_nsec; // 纳秒数(范围:0 ~ 999,999,999)

};

时间类型:必须是绝对时间(而非相对时间)。例如,若希望等待 5 秒,需先通过 clock_gettime (CLOCK_REALTIME, &ts) 获取当前系统时间,再将 ts.tv_sec += 5,而非直接设置 tv_sec=5。

超时行为:若等待时间超过 abs_timeout 指定的时间,函数会返回 - 1 并设置 errno 为 ETIMEDOUT(表示 “操作超时”)。

注意事项:tv_nsec 的值不能超过 999,999,999,否则会导致时间计算错误(例如,1,000,000,000 纳秒需转换为 1 秒 + 0 纳秒)。

属性​

可以使用下面函数获得、设置消息队列的属性:

int mq_getattr(mqd_t mqdes, FAR struct mq_attr \*mq_stat);

int mq_setattr(mqd_t mqdes, FAR const struct mq_attr \*mq_stat,

FAR struct mq_attr \*oldstat);

struct mq_attr 结构体包含:

成员描述
mq_flags队列的标志(例如 O_NONBLOCK)。
mq_maxmsg队列可容纳的最大消息数。
mq_msgsize每条消息的最大字节数。
mq_curmsgs队列中当前的消息数(仅 mq_getattr() 可获取)。
**注意**:消息队列的mq_maxmsg、mq_msgsize是在创建时就确定了,无法通过mq_setattr来修改。

通知​

mq_notify() - 注册异步通知。此函数为消息队列注册一个异步事件通知。当消息队列接收到一条新消息时,系统会向注册的任务发送一个信号。

函数原型如下:

int mq_notify(mqd_t mqdes, const struct sigevent \*notification);

工作机制:

  • 当输入参数 notification 非 NULL 时,mq_notify 会在当前任务与消息队列建立通知关联。

  • 当一条新消息被放入消息队列时,系统会向该任务发送 notification 中定义的信号。

  • 一次性通知: 发送信号后,该注册关系会自动解除。您必须重新调用 mq_notify() 才能接收下一次通知。

  • 当 notification 为 NULL 时,函数会移除已存在的通知关联。

注意:在任何时刻,只有一个任务可以成功注册对某个消息队列的通知。

使用消息队列改造音乐任务​

实验源码为:

3-4-3_使用消息队列改造音乐任务\music

本节将实现:使用队列在“控制线程”和“播放线程”直接传递数据,如下图所示。

main函数里创建了一个消息队列:

g_mq = mq_open("/myaplay", O_RDWR \| O_CREAT, 0666, &mq_stat);

还创建了一个子线程:

pthread_create(&tid1, NULL, play_thread, NULL);

在主线程里根据用户输入的信息写队列:

while (1)

{

printf("enter 0~9 (q to exit): \n");

scanf("%c", &c);

if (c == 'q' \|\| c == 'Q')

return 0;

if (c \>= '0' && c \<= '9')

{

// if (g_flag == 0)

// g_char = c;

// g_flag = 1;

/* 3. write mq */

mq_send(g_mq, &c, 1, 0);

}

}

子线程读取队列,根据队列的数值决定播放哪个wav文件:

ret = mq_receive(g_mq, &g_char, 1, 0);

……

task_create("aplay", 100, 2048, aplay_main, argv);

信号量的使用​

线程之间,可以使用消息队列传递数据;不需要传递数据时,可以使用信号量进行同步。所谓“同步”,就是:A没做完事情,B等待A。这就像我们使用的flag:A执行完毕才设置flag为1;B循环等待flag为1才继续往下运行。

信号量,就是一个特殊的flag,它是一个整数:用于表示资源的数量。线程A可以增加资源数量,线程B可以等待获得资源——成功获得后资源数量减一。

要使用信号量,需要先创建或打开它,然后就可以对它执行post/wait操作了:

API​

以下是 POSIX 信号量接口函数的功能说明表格:

函数名功能描述
sem_init初始化一个未命名的信号量,设置其初始值和共享属性
sem_destroy销毁一个由 sem_init 初始化的未命名信号量,释放相关资源
sem_open打开或创建一个命名信号量,返回信号量描述符
sem_close关闭一个由 sem_open 打开的命名信号量描述符
sem_unlink删除命名信号量的名称,当所有进程关闭该信号量后实际删除
sem_wait信号量减 1 操作(P 操作),若信号量值为 0 则阻塞等待
sem_trywait信号量减 1 操作(非阻塞版),若信号量值为 0 则立即返回错误
sem_timedwait信号量减 1 操作(超时版),若信号量值为 0 则阻塞至超时
sem_post信号量加 1 操作(V 操作),若有进程等待则唤醒其中一个
sem_getvalue获取当前信号量的值,存储到指定的整数指针中

这些函数分为两类:一类用于命名信号量(sem_open/sem_close/sem_unlink),可用于不同进程间的同步;另一类用于未命名信号量(sem_init/sem_destroy),主要用于同一进程内的线程同步或共享内存中的进程同步。

未命名信号量创建与销毁​

sem_init被用于初始化一个未命名的信号量(用于进程内线程间同步)。sem_destroy被用来销毁此信号量。

函数原型如下:

int sem_init(sem_t \*sem, int pshared, unsigned int value);

int sem_destroy(sem_t \*sem);

sem_init参数说明:

参数描述
sem指向要初始化的信号量对象的指针
pshared标识信号量的共享范围(0 表示线程间共享,非 0 表示进程间共享)
value信号量的初始值

参数取值详细描述

  • pshared 的取值:

0:信号量用于同一进程内的线程间同步。

非 0:信号量用于不同进程间同步(需放在共享内存中)。

  • value 的取值:

初始值为非负整数,通常用于控制并发访问的资源数量。

若初始值为 0,调用 sem_wait 会阻塞直到有其他线程调用 sem_post。

sem_destroy参数说明:

参数描述
sem指向通过 sem_init 初始化的未命名信号量对象的指针

sem 必须是通过 sem_init 初始化的信号量,且当前没有线程阻塞在该信号量上,否则行为未定义。销毁后再次使用该信号量需重新初始化。

命名信号量的创建与销毁​

sem_open函数被用来创建或打开一个命名信号量(用于进程间同步)。sem_close 函数用于关闭已打开的命名信号量(减少引用计数,不删除信号量)。sem_unlink函数被用于删除命名信号量(当所有进程关闭该信号量后,系统会彻底释放其资源)。

函数原型如下:

sem_t \*sem_open(const char \*name, int oflag, mode_t mode, unsigned int value);

int sem_close(sem_t \*sem);

int sem_unlink(const char \*name);

sem_open参数说明:

参数描述
name信号量的名称(格式通常为 "/semname")
oflag打开方式标志(创建或打开已有信号量)
mode信号量的权限位(仅当创建新信号量时有效)
value信号量的初始值(仅当创建新信号量时有效)

参数取值详细描述:

  • oflag 的常用值:

    • 基础标志:

O_CREAT:若信号量不存在则创建。

O_EXCL:与 O_CREAT 同时使用,若信号量已存在则返回错误。

  • 可组合标志(可选):

O_RDWR:读写权限(通常信号量操作默认需要此权限)。

  • mode 的取值(八进制数):

    • 0644:所有者可读写,组用户可读,其他用户可读。

    • 0666:所有者、组用户均可读写,其他用户可读。

  • value 的取值:信号量的初始值

sem_close参数说明:

参数描述
sem指向通过 sem_open 打开的信号量对象的指针

sem 必须是通过 sem_open 返回的有效信号量指针,关闭后再次操作该指针会导致错误。

sem_unlink参数说明:

参数描述
name要删除的信号量的名称(与 sem_open 中使用的名称一致)

name 必须是已存在的命名信号量名称,格式为 "/semname",否则返回错误。

等待信号量​

信号量的基础操作为等待、释放,也被称为P操作、V操作。

等待信号量(P 操作):若信号量值大于 0,则将其减 1 并返回;若值小于或等于0,则阻塞等待或返回错误。

释放信号量(V 操作):将信号量值加 1,若有线程阻塞在 sem_wait 上,则唤醒其中一个。

sem_wait会尝试获得信号量,如果不成功则会“一直等待到成功”。sem_trywait会尝试获得信号量,成功则返回0,不成功也会立刻返回错误。sem_timedwait则引入一个超时时间,如果在这段时间内成功获得信号量则返回0,如果这段时间内都无法获得信号量则返回错误。

函数原型如下:

int sem_wait(sem_t \*sem);

int sem_trywait(sem_t \*sem);

int sem_timedwait(sem_t \*sem, const struct timespec \*abs_timeout);

sem_wait、sem_trywait参数说明:

参数描述
sem指向已初始化的信号量对象的指针

sem 必须是通过 sem_init 或 sem_open 初始化的有效信号量指针,否则行为未定义。

sem_timedwait参数说明:

参数描述
sem指向已初始化的信号量对象的指针
abs_timeout指向绝对时间结构体的指针,指定等待的超时时间上限

sem 必须是通过 sem_init 或 sem_open 初始化的有效信号量指针,否则行为未定义。

abs_timeout结构体定义如下:

struct timespec {

time_t tv_sec; // 秒数(自UTC时间1970-01-01 00:00:00起)

long tv_nsec; // 纳秒数(范围:0 ~ 999,999,999)

};

时间类型:必须是绝对时间(而非相对时间)。例如,若希望等待 5 秒,需先通过 clock_gettime (CLOCK_REALTIME, &ts) 获取当前系统时间,再将 ts.tv_sec += 5,而非直接设置 tv_sec=5。

超时行为:若等待时间超过 abs_timeout 指定的时间,函数会返回 - 1 并设置 errno 为 ETIMEDOUT(表示 “操作超时”)。

注意事项:tv_nsec 的值不能超过 999,999,999,否则会导致时间计算错误(例如,1,000,000,000 纳秒需转换为 1 秒 + 0 纳秒)。

释放信号量​

sem_post函数被用于释放信号量(V 操作):将信号量值加 1,若有线程阻塞在 sem_wait 上,则唤醒其中一个。

函数原型如下:

int sem_post(sem_t \*sem);

参数说明:

参数描述
sem指向已初始化的信号量对象的指针

sem 必须是通过 sem_init 或 sem_open 初始化的有效信号量指针,否则行为未定义。

调用后信号量值会增加,即使超过初始值也允许(无上限)。

使用信号量改造LED任务​

在LED任务里,它使用轮询模式获得按键状态。我们把它改造成:

  • 主循环里等待信号量

  • 按键中断里释放信号量

使用GPIO中断​

参考代码:apps/examples/gpio/gpio_main.c

首先,提供信号处理函数,示例代码为:

void signal_handler(int signum) {

}

signal(SIGINT, signal_handler);

然后配置GPIO为中断功能,示例代码如下:

ret = ioctl(fd_button, GPIOC_SETPINTYPE, GPIO_INTERRUPT_FALLING_PIN);

最后注册GPIO信号,示例代码如下:

struct sigevent notify;

notify.sigev_notify = SIGEV_SIGNAL; // 中断函数要发出信号

notify.sigev_signo = SIGINT; // 发出这个信号

// 设置GPIO让它发出信号

ret = ioctl(fd_button, GPIOC_REGISTER, (unsigned long)&notify);

在信号处理函数里post信号量​

代码如下:

static sem_t g_sem;

void my_signal_handler(int signo)

{

/* post semphore */

sem_post(&g_sem);

}

在主循环里等待信号量​

代码如下:

sem_init(&g_sem, 0, 0);

while (1)

{

if (sem_wait(&g_sem) == 0)

{

ret = ioctl(fd_led, GPIOC_WRITE, 0);

sleep(1);

ret = ioctl(fd_led, GPIOC_WRITE, 1);

}

}

上机操作​

如下启动led程序后,可以通过按键K1操作LED,也可以使用kill命令给led进程发送信号来操作LED。

按下K1,LED会亮一下;发送信号,LED也会亮一下。

互斥量的使用​

我们使用互斥量来保护临界资源,下面的代码保证了“同一时间”只有一个任务能运行“临界代码”,实现了“互斥操作”:

为何需要互斥操作​

如下实现一个全局变量的累加函数:

static volatile usigned int g_val ;

static void add_val(void)

{

g_val++;

}

然后创建线程A,它循环调用add_val一千万次;创建线程B,循环调用add_val一千万次。理论上,g_val最终值是2千万。

上机测试结果为:

nsh&gt; hello

exit add_thread

g_val = 10010648

原因是“add_val”函数被打断了。把“g_val++”拆分为3个步骤:

① 读内存,把g_val的值读入CPU寄存器R0

② R0里的值累加

③ 把R0的值写入内存g_val

假设当前g_val的值为1,线程A、B调度过程如下图所示:

① 线程A调用add_val,刚把g_val的读入R0,就发生了切换

② 切换的时候会保存线程A运行时的R0,即保存7

③ 线程B运行完add_val,成功把g_val从7加到了8

④ 线程A继续运行:内核把之前保存的7先恢复到R0,然后运行add_val函数的②③步,使得g_val的值为8

这个过程触发了矛盾:线程A、B都调用了add_val各一次,但是g_val只增加了1。

原因就是:“add_val”函数里g_val的累加被打断了,线程A在运行“add_val”的过程中,线程B打断了它并且也运行“add_val”。

我们可以使用信号量来保护“add_val”里对g_val的操作,代码如下:

static volatile usigned int g_val ;

static void add_val(void)

{

sem_wait(&g_sem);

g_val++;

sem_post(&g_sem);

}

上述代码保证了:“同一时间内,只有一个线程能访问g_val”,解决了本节前面提出的Bug。

本程序里把信号量的最大值限定为1时,可以使用信号量进行互斥操作:

  • 线程A想访问共享资源时,要先获得信号量:如果信号量的值为1,则线程A把值减为0,返回成功。

  • 线程B也想访问同一个共享资源,也要先获得信号量:这时信号量的值为0,线程B阻塞

  • 线程A可以独占地访问共享资源

  • 线程A用完共享资源后要释放信号量,这会唤醒线程B

  • 线程B成功获得信号量,可以独占地使用共享资源

这些共享资源如果不使用互斥方法进行保护,会导致程序问题,这些共享资源就被称为临界资源。

信号量导致的优先级反转问题​

上述代码还有问题。使用信号量进行互斥操作时,会导致优先级反转的问题。举例如下:

  • 低优先级任务 A 获取信号量,进入临界区。

  • 高优先级任务 B 启动,尝试获取同一信号量,因资源被占用而阻塞。

  • 中优先级任务 C 启动,由于其优先级高于任务 A,它抢占了 CPU。

结果:任务 C(以及其他中等优先级任务)持续运行,导致任务 A 无法执行并释放信号量。这使得高优先级任务 B 被中等优先级任务 C 无限期地阻塞,仿佛任务 B 的优先级低于任务 C。这种情况下优先级反转的问题就出现了,最高优先级的任务B反而被中优先级的任务C抢占了CPU。

使用互斥量(mutex)可以解决优先级反转的问题,互斥量是特殊的、最大值为1的信号量。

注意:openvela里互斥量能解决优先级反转的前提是配置了“CONFIG_PRIORITY_INHERITANCE”或“CONFIG_PRIORITY_PROTECT”,详情请看《3.6.2 信号量和互斥量的内部机制》。

互斥量的创建/销毁​

pthread_mutex_init 函数被用来初始化一个互斥锁,为后续的线程同步操作(加锁、解锁)做准备,可初始化普通互斥锁或带有特定属性的互斥锁。

pthread_mutex_destroy函数被用来销毁已初始化的互斥锁,释放互斥锁占用的资源,销毁后互斥锁不能再进行加锁、解锁操作,除非重新初始化。

函数原型如下:

int pthread_mutex_init(pthread_mutex_t \*mutex, const pthread_mutexattr_t \*attr);

int pthread_mutex_destroy(pthread_mutex_t \*mutex);

pthread_mutex_init 函数参数说明:

参数描述
mutex指向待初始化的互斥锁对象的指针
attr指向互斥锁属性对象的指针,若为 NULL 则使用默认属性初始化互斥锁

参数取值详细描述

  • mutex 必须是有效的 pthread_mutex_t 类型指针,未初始化的互斥锁直接使用会导致行为未定义。

  • attr 的取值:

NULL:使用默认属性,初始化后的互斥锁为普通互斥锁,默认属性下线程加锁后需自身解锁,其他线程加锁会阻塞。

非 NULL:需先通过 pthread_mutexattr_init 初始化属性对象,再设置特定属性(如互斥锁类型、进程共享属性等),最后传入此参数。

常见的属性设置场景包括:

  • 互斥锁类型:通过 pthread_mutexattr_settype 设置为 PTHREAD_MUTEX_NORMAL(普通锁,不检测死锁)、PTHREAD_MUTEX_ERRORCHECK(错误检测锁,检测死锁并返回错误)、PTHREAD_MUTEX_RECURSIVE(递归锁,允许同一线程多次加锁)等。

  • 进程共享属性:通过 pthread_mutexattr_setpshared 设置为 PTHREAD_PROCESS_PRIVATE(默认,仅进程内线程共享)或 PTHREAD_PROCESS_SHARED(可跨进程共享,需放在共享内存中)。

pthread_mutex_destroy函数参数说明:

参数描述
mutex指向已初始化的互斥锁对象的指针

参数取值详细描述:

  • mutex 需为已初始化的有效互斥锁指针,若传入未初始化或已销毁的互斥锁指针,会导致行为未定义。

  • 销毁前提:互斥锁必须处于未锁定状态,且没有线程阻塞在该互斥锁的加锁操作上,否则函数可能返回错误(如 EBUSY),且销毁操作失败。

  • 销毁后状态:互斥锁对象变为未初始化状态,若需再次使用,需重新调用 pthread_mutex_init 进行初始化。

属性初始化与销毁​

前面的pthread_mutex_init 函数可以传入一个attr参数,这个参数有专门的初始化函数:pthread_mutexattr_init函数。

pthread_mutexattr_init函数被用来初始化互斥锁属性对象,为后续设置互斥锁的特定属性(如类型、进程共享性等)做准备,属性对象可用于初始化多个互斥锁。

pthread_mutexattr_destroy 函数被用来销毁已初始化的互斥锁属性对象,释放属性对象占用的资源,销毁后属性对象不能再用于设置属性或初始化互斥锁,除非重新初始化。

函数原型如下:

int pthread_mutexattr_init(pthread_mutexattr_t \*attr);

int pthread_mutexattr_destroy(pthread_mutexattr_t \*attr);

pthread_mutexattr_init函数参数说明:

参数描述
attr指向待初始化的互斥锁属性对象的指针

参数取值详细描述

  • attr 必须是有效的 pthread_mutexattr_t 类型指针,未初始化的属性对象直接用于设置属性或初始化互斥锁,会导致行为未定义。

  • 初始化后状态:属性对象初始化为默认状态,默认属性对应的互斥锁为普通类型(PTHREAD_MUTEX_NORMAL)、进程内私有(PTHREAD_PROCESS_PRIVATE)。

pthread_mutexattr_destroy参数说明:

参数描述
attr指向已初始化的互斥锁属性对象的指针

参数取值详细描述

  • attr 需为已初始化的有效互斥锁属性指针,若传入未初始化或已销毁的属性指针,会导致行为未定义。

  • 销毁影响:属性对象销毁后,之前通过该属性对象初始化的互斥锁不受影响,互斥锁的属性会保持初始化时的设置。

互斥量的lock操作​

pthread_mutex_lock函数被用来对互斥锁进行加锁操作:若互斥锁当前未被锁定,则当前线程成功加锁并继续执行;若互斥锁已被其他线程锁定,则当前线程阻塞,直到互斥锁被解锁后再尝试加锁。

pthread_mutex_trylock函数尝试对互斥锁进行加锁操作(非阻塞):若互斥锁当前未被锁定,则当前线程成功加锁并返回成功;若互斥锁已被锁定,则直接返回错误,不阻塞当前线程。

函数原型如下:

int pthread_mutex_lock(pthread_mutex_t \*mutex);

int pthread_mutex_trylock(pthread_mutex_t \*mutex);

pthread_mutex_lock函数参数说明:

参数描述
mutex指向已初始化的互斥锁对象的指针

参数取值详细描述:

  • mutex 必须是通过 pthread_mutex_init 初始化的有效互斥锁指针,若传入未初始化或已销毁的互斥锁指针,会导致行为未定义。

  • 不同类型互斥锁的加锁差异:

普通互斥锁(PTHREAD_MUTEX_NORMAL):若同一线程对已加锁的互斥锁再次加锁,会导致死锁,且函数不会返回错误。

错误检测锁(PTHREAD_MUTEX_ERRORCHECK):若同一线程对已加锁的互斥锁再次加锁,函数会返回 EDEADLK 错误,避免死锁。

递归锁(PTHREAD_MUTEX_RECURSIVE):允许同一线程多次加锁,加锁次数与解锁次数需一致,最后一次解锁后其他线程才能加锁。

pthread_mutex_trylock函数参数说明:

参数描述
mutex指向已初始化的互斥锁对象的指针

参数取值详细描述:

  • mutex 需为已初始化的有效互斥锁指针,否则行为未定义,与 pthread_mutex_lock 的 mutex 参数要求一致。

  • 返回值差异:

成功加锁:返回 0。

互斥锁已被锁定:返回 EBUSY 错误,不会阻塞线程,线程可继续执行其他逻辑,后续可再次尝试加锁。

  • 与 pthread_mutex_lock 的核心区别:前者非阻塞,加锁失败立即返回;后者阻塞,直到加锁成功或出现其他错误。

互斥量的unlock操作​

pthread_mutex_unlock函数对互斥锁进行解锁操作:若当前线程是互斥锁的持有线程,则解锁后互斥锁变为未锁定状态,若有其他线程阻塞在该互斥锁的加锁操作上,会唤醒其中一个线程尝试加锁。

函数原型如下:

int pthread_mutex_unlock(pthread_mutex_t \*mutex);

pthread_mutex_unlock函数参数说明:

参数描述
mutex指向已初始化且处于锁定状态的互斥锁对象的指针

参数取值详细描述

  • mutex 必须是已初始化且当前被锁定的有效互斥锁指针,若传入未锁定、未初始化或已销毁的互斥锁指针,会导致行为未定义。

  • 解锁限制:

普通互斥锁和错误检测锁:仅允许持有互斥锁的线程解锁,若其他线程尝试解锁,错误检测锁会返回 EPERM 错误,普通互斥锁行为未定义。

递归锁(PTHREAD_MUTEX_RECURSIVE):需由持有线程按照加锁次数依次解锁,每次解锁会减少锁的计数,当计数减为 0 时,互斥锁才真正变为未锁定状态。

信号实例​

信号是在软件层次上对中断机制的模拟,在原理上说,一个任务接收到一个信号,与CPU接收到中断请求是一致的。信号是异步的,任务不必通过任何操作来等待信号的到达,它甚至不知道信号何时会到达。

信号的来源包括:

  • 硬件来源,比如按键触发

  • 软件来源,比如kill,raise等系统函数,比如一些非法运算操作等

实例1:GPIO中断发出信号​

在《3.4.5 使用信号量改造LED任务》里面,就用到了信号:GPIO驱动程序里中断函数发出了信号,而APP里为这个信号提供了处理函数。主循环里不知道何时收到信号,信号是“异步”的。

首先,提供信号处理函数,示例代码为:

void signal_handler(int signum) {

}

signal(SIGINT, signal_handler);

然后配置GPIO为中断功能,示例代码如下:

ret = ioctl(fd_button, GPIOC_SETPINTYPE, GPIO_INTERRUPT_FALLING_PIN);

最后注册GPIO信号,示例代码如下:

struct sigevent notify;

notify.sigev_notify = SIGEV_SIGNAL; // 中断函数要发出信号

notify.sigev_signo = SIGINT; // 发出这个信号

// 设置GPIO让它发出信号

ret = ioctl(fd_button, GPIOC_REGISTER, (unsigned long)&notify);

实例2:软件发出信号​

发送信号的API函数:

/* 当前任务向指定pid的任务(线程组)发送sig信号 */

int kill(pid_t pid, int sig);

接收方先注册信号处理函数(led.c):

signal(SIGINT, my_signal_handler);

发送方创建LED进程以便得到对方的PID(music.c):

led_pid = task_create("led", 100, 2048, led_main, argv);

发送方使用kill函数发送信号(music.c):

kill(led_pid, SIGINT);

实例3:写队列时发出信号​

配置内核禁止“CONFIG_DISABLE_MQUEUE_NOTIFICATION”以便使能“信号量能发送信号”,执行如下命令:

./build.sh vendor/allwinnertech/boards/r528/r528s3-velaevb1/configs/nsh/ menuconfig

根据下图进行配置:

play_thread里先注册信号处理函数(music.c):

signal(SIGUSR1, aplay_signal_handler);

然后第1次设置队列,让它能发出信号:

struct sigevent notification;

/* let mq, send signal to play_thread when get msg from mq */

notification.sigev_notify = SIGEV_SIGNAL; // 要发出信号

notification.sigev_signo = SIGUSR1; // 发出这个信号

mq_notify(g_mq, &notification);

以后主线程(music.c)写消息时,会向play_thread发信号SIGUSR1:

if (c \>= '0' && c \<= '9')

{

// if (g_flag == 0)

// g_char = c;

// g_flag = 1;

/* 3. write mq */

mq_send(g_mq, &c, 1, 0);

kill(led_pid, SIGINT);

}

play_thread的信号处理函数被调用,它再次使用mq_notify让队列以后还能发出信号,然后读取队列、播放音乐:

void aplay_signal_handler(int sig)

{

char file\[32\];

char \* argv\[3\] = {"/aplay", file, NULL};

// if (g_flag)

struct sigevent notification;

/* let mq, send signal to play_thread when get msg from mq */

notification.sigev_notify = SIGEV_SIGNAL; // 要发出信号

notification.sigev_signo = SIGUSR1; // 发出这个信号

mq_notify(g_mq, &notification);

/* 4. read mq */

printf("to recv msg \n");

int ret = mq_receive(g_mq, &g_char, 1, 0);

printf("recv msg: %c\n", g_char);