25、共享内存与系统初始化:深入解析与实战指南

共享内存与系统初始化:深入解析与实战指南

共享内存的读写访问

在处理共享内存时,即使只是读取共享对象的当前值而不进行修改,也需要对读取操作进行保护。以16位处理器为例,如果直接复制一个32位的共享长整型变量,在复制最低有效16位和最高有效16位之间可能会发生中断。若中断导致该长整型变量的值被修改,那么复制的值的一半来自修改前,另一半来自修改后,从而造成数据损坏。

为防止这种数据损坏,当在代码序列的多个位置使用该值时,可以采用以下方法:先禁用中断,创建一个本地(私有)副本,再重新启用中断,最后使用本地副本。示例代码如下:

extern long shared_variable ;
long private_copy ;
/* make a copy with interrupts disabled */
disable() ;
private_copy = shared_variable ;
enable() ;
/* subsequent code can use "private_copy" */
/* without fear of data corruption. 
*/

此外,对于不同架构和CPU字长,“x = 0”成为临界区的情况如下表所示:
| Architecture | Operand (x) | 8-bit CPU | 16-bit CPU | 32-bit CPU |
| — | — | — | — | — |
| Load/Store Architecture(如ARM和MIPS等RISC处理器) | 8 bits | * | ✕ | ✕ |
| | 16 bits | * | ✕ | ✕ |
| | 32 bits | * | ✕ | ✕ |
| | 64 bits | * | ✕ | * |
| Other Architectures(如Intel x86等CISC处理器) | 8 bits | * | ✕ | ✕ |
| | 16 bits | * | ✕ | ✕ |
| | 32 bits | * | ✕ | ✕ |
| | 64 bits | * | ✕ | * |

注:该表假设单条指令存储的内存操作数不大于处理器的字长,但有些处理器有能存储双长度操作数的指令(如STRD)。

类型限定符“volatile”

在C语言中,关键字“volatile”可作为限定符添加到任何对象的声明中,用于表明该对象的值可能会被声明所在代码之外的机制异步修改,例如内存映射I/O设备状态端口的变化、直接内存访问或中断例程对内存中数据的修改。不过,添加该限定符并不能消除前面讨论的临界区问题。

“volatile”关键字的作用在于禁用某些编译器优化,否则这些优化可能导致代码出错。例如,以下代码旨在访问一个64位共享变量,而不禁用中断、任务切换或使用互斥锁:

unit64_t get_Shared(void)
{
    extern unit64_t shared ;
    unit64_t validated ;
    do validated = shared ; 
    /* 1st access */
    while (validated != shared ) ; 
    /* 2nd access */
    return validated ;
}

然而,优化编译器可能会注意到该函数从不修改“shared”,且“validated”只是“shared”的副本,从而完全消除循环。添加“volatile”关键字到共享变量的声明中,可防止这些优化,解决问题。

为谨慎起见,每个共享内存对象的声明都应使用“volatile”限定符。可以定义一个宏来更明显地标记共享对象:

#define  SHARED_MEMORY  volatile  
extern SHARED_MEMORY  long  shared; 

“volatile”关键字也可用于指针声明,并非指针本身可变,而是其指向的数据可变。例如:

volatile long *pv; /* pointer to shared data */ 

通常,由于“pv”指向的可变对象位于固定内存位置,常将“pv”设为常量指针:

volatile long * const pv = <initial‐value>; 

偶尔也会同时使用“volatile”和“const”来描述对象本身:

const volatile long * const pv = <initial‐value>; 

这种使用方式对标识符“pv”作用域内的代码有以下影响:
1. 第一个“const”防止在声明“ pv”的源代码中修改该对象。
2. “volatile”限定符告知优化器,对象“
pv”可能会被标识符“pv”作用域之外的机制修改。
3. 第二个“const”禁止对指针“pv”进行任何修改。

共享内存相关问题

以下是一些关于共享内存的问题及解答:
1. 判断题
- (a) 共享内存导致的数据损坏仅会在多线程程序中发生。(错误)
- (b) 共享内存导致的数据损坏仅会在抢占式多线程程序中发生。(错误)
- (c) 共享函数仅指被多个线程通过名称调用的函数。(错误)
- (d) 修改局部静态对象的函数永远不是线程安全的。(正确)
- (e) 不修改局部静态对象的函数本质上是线程安全的。(错误)
- (f) 递归函数本质上是可重入的。(错误)
- (g) 为变量声明添加类型限定符“const”可防止其在程序执行期间被修改。(错误)
- (h) 为共享对象声明添加类型限定符“volatile”,告知编译器其他线程可能改变其值,使编译器生成保护数据不损坏的代码。(错误)
2. 需要额外编码保护共享数据访问的组合
- (a) 非抢占式系统中的中断服务程序(ISR)和线程。
- (b) 抢占式系统中的ISR和线程。
- (c) 非抢占式系统中的两个线程。
- (d) 抢占式系统中的两个线程。
- (e) 两个ISR。
3. 即使地址未明确提供给其他线程也可能成为共享的内存对象
- (a) 所有函数外部声明的数据。
- (b) 函数内声明的静态内存。
- (d) 从堆中分配的动态内存。
4. 被视为线程特定的数据类型
- (c) 函数内声明的自动内存。
- (e) 函数参数。
5. 对于数据类型(int8_t、int16_t、int32_t、int64_t)和CPU字长(8位、16位、32位)的哪些组合,赋值语句“x = 0;”(“x”为共享对象)是必须保护以防数据损坏的临界区 :需根据不同架构和字长具体分析,参考前面的表格。
6. 对于数据类型(int8_t、int16_t、int32_t、int64_t)和CPU字长(8位、16位、32位)的哪些组合,赋值语句“x++;”(“x”为共享对象)是必须保护以防数据损坏的临界区(假设处理器使用加载/存储架构) :同样需结合架构和字长分析。
7. 以下函数在多线程应用中被多个线程调用时会导致运行时错误,解释问题原因并修改函数,不改变参数列表和返回值

int ExtractWords(char *sentence, char *words[]) 
{ 
    char *word; 
    int count; 
    count = 0; 
    word = sentence; 
    for (;;) 
    { 
        word = strtok(word, ",;.?!");  
        if (!word) break; 
        words[count++] = word; 
        word = NULL;  
    } 
    return count; 
} 

问题原因: strtok 函数不是线程安全的,多个线程同时调用会导致数据混乱。修改方法可在关键部分禁用中断保护临界区。
8. 判断以下函数哪些是线程安全的
- (a) int f1(int select) :线程安全,因为使用静态常量数组,不会被修改。
- (b) char *f2(char *b) :非线程安全,使用静态数组存储结果,多个线程调用会相互覆盖。
- (c) long long int f3(void) :非线程安全,修改静态变量,多个线程调用会有竞争。
- (d) unsigned f4(unsigned a, unsigned b) :线程安全,没有使用共享资源。
9. 设计 strtok 函数的线程安全版本,仅在必要时禁用中断保护临界区 :可在 strtok 函数内部关键操作时禁用中断,操作完成后重新启用。
10. 设计以下函数的线程安全版本,不添加代码保护临界区

double AvgSoFar(double value) 
{ 
    static double total = 0.0 ; 
    static int count = 0 ; 
    return (total += value) / ++count ; 
} 

可使用原子操作或无锁算法来实现线程安全。

系统初始化

桌面应用程序开发者通常无需担心系统初始化,因为大多数应用程序由操作系统加载和执行,而操作系统在启动过程中已完成系统初始化,提供了合适的运行时环境。但嵌入式应用几乎总是从断电状态开始,通电时处理器没有栈、堆、中断系统和定时器,还需初始化I/O设备,并将变量初始值从ROM复制到RAM。

系统初始化的很多操作与硬件相关,取决于CPU类型、内存数量和类型以及连接的I/O设备。同时也与软件相关,每个编译器和/或实时内核可能对内存组织、中断系统配置和定时器滴答率有特定要求。

下面以使用IAR Systems Embedded Workbench编写的C程序,在基于ARM Cortex - M3处理器的Texas Instruments LM3S811微控制器上运行为例,介绍系统初始化。

内存布局

设计初始化代码的一个好起点是考虑编译器的期望。大多数应用将内存划分为五个区域:程序代码(“text”)、已初始化静态变量(“data”)、未初始化静态变量(“bss”)、栈和堆。尽管这些区域的相对位置可能不同,但初始向量表和代码必须位于非易失性内存(如闪存EPROM)中,向量表位于地址0,其他部分位于读写内存(RAM)中。

CPU和向量表

通电时,ARM Cortex - M3处理器自动从地址0(向量表第一个条目)加载栈指针(R13),从地址4(向量表第二个条目)加载程序计数器(R15),然后开始执行。因此,初始向量表及其指向的代码必须存于断电后内容不丢失的非易失性内存中。

栈可声明为32位无符号长整型数组。由于第一次压栈将存储在地址SP - 4,SP应初始化为数组最后一个条目之后的32位字的地址。例如:

static unsigned long pulStack[128] @ ".noinit" ;  

SP的初始值可指定为 &stack[128] ,为避免重复指定栈大小(128),也可指定为 ((unsigned long) &pulStack[0]) + sizeof(pulStack) ,简化为 (unsigned long) pulStack + sizeof(pulStack)

加载到程序计数器的值是启动函数的入口点地址,该函数负责执行所需的额外初始化,然后分支到 main 函数的入口点启动应用程序。

在执行开始前,向量表还有另外两个条目必须有初始值。由于不可屏蔽中断和硬故障无法禁用,这两个异常的处理程序必须存在,其入口点地址分别存储在向量表地址8和12的条目。其他中断初始时禁用,所有中断优先级为默认值。因此,向量表至少前四个条目必须填充。使用IAR Embedded Workbench时,可使用以下代码在C中创建向量表并强制其位于地址0:

//*********************************************************************************** 
// A union that describes the entries of the vector table. The union is needed 
// since the first entry is the stack pointer and the remainder are function pointers. 
//*********************************************************************************** 
typedef union 
{ 
    void (*pfnHandler)(void); 
    unsigned long ulPtr; 
} uVectorEntry;  

//*********************************************************************************** 
// The vector table. Note that the proper constructs must be placed on 
// this to ensure that it ends up at physical address 0x0000.0000. 
//*********************************************************************************** 
__root const uVectorEntry __vector_table[] @ ".intvec" = 
{ 
    {.ulPtr = (unsigned long) pulStack + sizeof(pulStack)}, // Initial SP 
    __iar_program_start, // Initial PC 
    NmiSR, // The NMI handler 
    FaultISR, // The hard fault handler 
    ...  
};

该表的声明使用了IAR Embedded Workbench编译器支持的一些C语言扩展:
1. 声明开头的“__root”防止链接器移除该变量,即使标识符“__vector_table”在程序其他地方未被名称引用。
2. “@ “.intvec””使链接器将向量表定位在地址0。
3. 表中的第一个条目使用了C99特性“指定初始化器”,大括号内的表达式“ .ulPtr = ”告知编译器该值将赋给名为“ulPtr”的联合成员。

C运行时环境

处理器启动运行但在执行进入 main 函数之前,启动函数必须初始化位于读写内存中的已初始化静态变量(“data”)和未初始化静态变量(“bss”)。

从非易失性内存复制初始值到数据区域

所有静态变量(隐式包括所有全局变量)必须由启动函数初始化。那些在声明中指定了初始值的静态变量作为一个连续组位于“data”区域,其初始值常量作为对应的连续组位于非易失性内存的文本区域。在进入 main 函数之前,启动函数必须将这些常量复制到读写内存的数据区域的相应目的地。使用IAR Embedded Workbench IDE创建的程序的启动函数如下:

unsigned long *pulSrc, *pulDest, *pulEnd; 
// 
// Copy the data segment initializers from flash to SRAM. 
// 
pulSrc = __segment_begin("DATA_ID") ; 
pulDest = __segment_begin("DATA_I") ; 
pulEnd = __segment_end("DATA_I") ; 
while(pulDest < pulEnd) 
{ 
    *pulDest++ = *pulSrc++; 
} 

除了通过从非易失性内存复制来初始化数据部分,在某些微控制器上,将程序代码复制到读写内存也是可取的,因为非易失性内存的访问时间通常比读写内存稍慢。

清零未初始化静态变量

C语言有个约定,“bss”区域的未初始化静态变量在启动时会被填充为零。该区域包括所有声明时未指定初始值的静态对象。由于有些程序的代码依赖此特性,这是初始化C运行时环境的必要部分。使用IAR Embedded Workbench IDE创建的程序包含以下代码来实现:

unsigned long *pulDest, *pulEnd; 
// 
// Zero fill the bss segment. 
// 
pulDest = __segment_begin("DATA_Z"); 
pulEnd = __segment_end("DATA_Z"); 
while(pulDest < pulEnd) 
{ 
    *pulDest++ = 0; 
} 
设置堆

堆传统上实现为动态内存块的链表。通常,链表最初应包含一个对应整个堆空间的空闲块。分配内存时,搜索链表以找到足够大的块满足请求,搜索可使用首次适应或最佳适应算法。若未找到足够大的块,通过返回NULL指针向请求者表明失败;否则,将选定块分割为两个块,一个分配给请求者,另一个保留剩余空闲空间。

管理堆的代码作为一组库例程提供,它们可以是常规C运行时库的一部分,也可自行替换。

许多嵌入式应用可完全避免使用动态内存,从而节省管理堆所需的代码空间。但拥有堆通常很方便,有时实时内核创建线程、队列、信号量或互斥锁所需的数据结构也需要堆。在很多情况下,这可能是堆的唯一用途,无需提供释放分配块的函数,这样不仅完全消除了堆碎片化的可能性,还因释放内存时无需重新组合相邻空闲块而大大减小了代码大小。

必须偶尔释放堆空间的嵌入式应用必须设计为避免堆碎片化。常见策略是将每个请求的大小向上取整,使所有分配块大小相同,或创建包装函数将堆划分为一组分配池,每个池具有固定块大小。尽管块可以释放,但这些技术可防止碎片化,也消除了重新组合相邻空闲块的需要,从而简化并提高了代码速度。

由于这些原因,可能会发现有多个版本的堆库,或者可以使用不同的配置选项编译堆库。例如,FreeRTOS内核包含三组不同的堆函数源代码,一个消除了释放内存的函数,第二个提供该函数但不组合相邻空闲块,第三个只是标准C运行时库函数 malloc free 的包装器。

共享内存与系统初始化:深入解析与实战指南

堆管理策略对比

为了更清晰地理解不同堆管理策略的差异,下面通过表格进行对比:
| 堆管理策略 | 释放内存功能 | 合并相邻空闲块 | 适用场景 | 优缺点 |
| — | — | — | — | — |
| 消除释放内存函数 | 无 | 无 | 嵌入式应用中堆仅用于特定数据结构创建,无需释放内存的场景 | 优点:完全消除堆碎片化,减小代码大小;缺点:缺乏灵活性,无法释放不再使用的内存 |
| 提供释放内存函数但不合并相邻空闲块 | 有 | 无 | 偶尔需要释放堆空间,但对堆碎片化不太敏感的场景 | 优点:可释放内存,一定程度上提高内存利用率;缺点:可能存在一定的内存浪费,随着时间推移可能出现小块空闲内存 |
| 标准C运行时库函数包装器 | 有 | 有 | 对堆管理要求较高,需要灵活分配和释放内存,且能处理堆碎片化的场景 | 优点:功能完整,与标准C库兼容;缺点:代码复杂度高,可能导致较大的代码开销 |

系统初始化流程总结

系统初始化是一个复杂且关键的过程,下面通过mermaid流程图展示其主要步骤:

graph TD;
    A[通电] --> B[CPU加载栈指针和程序计数器]
    B --> C[执行启动函数]
    C --> D[复制数据段初始值到SRAM]
    D --> E[清零未初始化静态变量]
    E --> F[设置堆]
    F --> G[执行额外初始化操作]
    G --> H[跳转到main函数启动应用]
共享内存与系统初始化的关联

共享内存的正确管理和系统初始化是紧密相关的。系统初始化过程中对内存的布局和初始化,为共享内存的使用提供了基础环境。例如,在初始化过程中正确设置栈、堆和静态变量区域,能够确保共享内存对象有合适的存储位置。同时,共享内存的读写访问规则和类型限定符的使用,也会影响系统初始化代码的设计。比如,在复制数据段初始值时,如果涉及到共享变量,就需要考虑如何保护这些变量的读写操作,避免数据损坏。

实际应用中的注意事项

在实际的嵌入式应用开发中,需要注意以下几点:
1. 硬件适配 :不同的CPU、内存和I/O设备组合可能需要不同的初始化代码。在进行系统初始化时,要仔细参考硬件手册,确保初始化操作与硬件特性匹配。例如,某些特定型号的微控制器可能对内存访问速度有特殊要求,需要根据实际情况决定是否将程序代码复制到读写内存。
2. 线程安全 :在多线程环境下,共享内存的使用必须保证线程安全。无论是使用 volatile 限定符还是禁用中断等方法,都要确保在关键操作时不会出现数据竞争和损坏。例如,在设计线程安全的函数时,要充分考虑函数内部使用的共享资源,避免多个线程同时访问和修改。
3. 堆管理优化 :根据应用的实际需求选择合适的堆管理策略。如果应用对内存空间和代码大小有严格限制,可以考虑避免使用动态内存或采用简单的堆管理方式;如果需要频繁分配和释放内存,则要选择能够有效处理堆碎片化的策略。
4. 代码可维护性 :在编写系统初始化代码和处理共享内存的代码时,要注重代码的可维护性。使用清晰的变量命名、添加必要的注释,并且将功能模块化,便于后续的调试和扩展。例如,将堆管理代码封装成独立的函数或模块,方便修改和替换不同的堆管理策略。

总结

共享内存的读写访问、类型限定符的使用以及系统初始化是嵌入式系统开发中不可或缺的重要部分。通过正确处理共享内存的读写保护、合理使用 volatile 限定符,可以确保数据的完整性和程序的正确性。而系统初始化过程中的内存布局、静态变量初始化和堆管理等操作,为整个系统的稳定运行提供了基础保障。在实际开发中,要根据具体的硬件和软件需求,灵活运用这些知识和技术,不断优化代码,提高系统的性能和可靠性。

希望本文能够帮助读者深入理解共享内存和系统初始化的相关知识,并在实际项目中应用这些知识解决遇到的问题。在未来的嵌入式开发中,随着硬件技术的不断发展和应用需求的日益复杂,对共享内存和系统初始化的要求也会越来越高,我们需要不断学习和探索,以适应新的挑战。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值