嵌入式软件开发中的调试与实时操作系统应用
在软件开发过程中,尤其是嵌入式软件开发,调试信息的输出、实时操作系统的使用以及汇编代码与C代码的交互等都是非常重要的环节。下面将详细介绍这些方面的内容。
1. 使用UART进行Printf输出
在软件开发时,利用C/C++中的
printf
函数显示调试信息是很常见的需求。一种常用的方法是将
printf
消息定向到UART接口,并在调试主机上显示。
首先,需要在调试主机(如PC)上安装终端软件,例如Tera Term或Putty。在Keil MDK中,可以通过“Manage Run - Time Environment”对话框安装一段程序代码来重定向
printf
消息,具体步骤为:Compiler -> I/O -> STDOUT。STDOUT支持的类型有以下几种:
- User:将
printf
消息定向到外设接口。
- Breakpoint:用于支持半主机模式的调试工具,如Arm Development Studio。
- EVR (Event Recorder):一种软件方法,允许调试器通过调试连接访问事件信息。
- ITM:Instrumentation Trace Macrocell(仅适用于Armv8 - M Mainline和Armv7 - M处理器,Cortex - M23处理器不可用),可将
printf
消息定向到跟踪接口。
添加STDOUT软件组件后,会出现一个名为“retarget_io.c”的文件。当项目中添加了带有用户定义输出支持的STDOUT选项后,还需要添加“用户定义”输出的程序代码。以下是一个示例:
// Device specific header
#include "IOTKit_CM33_FP.h" /* Device header */
#include "Board_LED.h"
/* ::Board Support:LED */
#include <stdio.h>
extern void UART_Config(void);
extern int UART_SendChar(int txchar);
// Retargeting support
int stdout_putchar (int ch);
int main(void)
{
int i;
LED_Initialize();
UART_Config();
printf ("Hello world\n");
while (1) {
LED_On(0);
for (i=0;i<100000;i++){
__NOP();
}
LED_Off(0);
for (i=0;i<100000;i++){
__NOP();
}
}
}
// Function used by retarget_io.c
int stdout_putchar (int ch)
{
return UART_SendChar(ch);
}
“retarget_io.c”文件要求定义“stdout_putchar(int ch)”函数,在上述示例中,该函数调用了UART输出函数“UART_SendChar()”来传输字符。同时,还需要添加UART接口的初始化函数,在示例中为“UART_Config()”。“UART_Config()”和“UART_SendChar()”函数通常在一个名为“uart_funcs.c”的单独程序文件中实现。
要在调试主机上收集UART显示消息,需要一个UART转USB适配器,有些开发板已经内置了此功能。程序代码编译并执行后,连接到开发板的终端软件将显示“Hello world”消息。
2. 使用ITM进行Printf输出
Armv8 - M Mainline处理器(如Cortex - M33处理器)和Armv7 - M处理器支持Instrumentation Trace Macrocell(ITM)功能,允许调试工具通过跟踪连接收集调试消息。要通过ITM输出调试消息,需要启用STDOUT选项并选择ITM输出类型。
在项目中添加ITM标准输出(STDOUT)支持比添加UART STDOUT支持更简单,只需要添加
printf
代码和“stdio.h” C头文件,示例代码如下:
// Device specific header
#include "IOTKit_CM33_FP.h" /* Device header */
#include "Board_LED.h"
/* ::Board Support:LED */
#include <stdio.h>
int main(void)
{
int i;
LED_Initialize();
printf ("Hello world\n");
while (1) {
LED_On(0);
for (i=0;i<100000;i++){
__NOP();
}
LED_Off(0);
for (i=0;i<100000;i++){
__NOP();
}
}
}
虽然在程序代码中启用ITM的
printf
功能很容易,但要使其正常运行还需要进行额外的调试配置。如果使用通过SWO引脚收集跟踪信息的低成本调试适配器,需要确保在调试设置中使用Serial Wire Debug(SWD)协议,因为JTAG协议的TDO引脚和SWO引脚共用,不能同时使用。不过,如果使用并行跟踪端口模式,则可以安全地使用JTAG协议,因为不会有引脚分配冲突。
配置调试设置以启用SWO引脚跟踪时,还需要在调试设置中启用跟踪,并确保以下几点:
- 跟踪时钟速度的设置与所使用的硬件平台相匹配。
- 大多数调试适配器使用Non - Return to Zero(NRZ)输出模式。
- 启用刺激端口#0。
跟踪配置设置完成后,软件可以编译并开始调试会话。在执行软件之前,需要从下拉菜单中启用Debug (printf) 查看器。启用Debug (printf) 查看器并启动应用程序后,将显示“Hello world”的
printf
消息。
3. 使用实时操作系统——RTX
Keil MDK的一个显著特点是能够轻松地将RTX RTOS集成到软件项目中。当需要将处理任务划分为多个并发任务时,通常需要使用RTOS。在这些应用中,RTOS用于以下方面:
- 任务调度:大多数RTOS设计中的任务调度支持任务优先级功能。
- 任务间事件和消息通信(如邮箱)。
- 信号量(包括MUTEX)。
- 处理进程隔离(可选,需要MPU支持),操作系统还可以利用堆栈限制检查来检测堆栈溢出错误。
市场上的一些RTOS可能包含通信栈和文件系统等功能,这些功能在MDK Professional和MDK Plus中需要作为单独的软件组件添加。与Linux等全功能操作系统不同,大多数RTOS(如RTX)不需要虚拟内存支持功能,如Memory Management Unit(MMU),因此它们的内存占用很小,可以适用于小型微控制器设备。
Keil RTX是为微控制器系统设计的免版税RTOS之一,具有以下特点:
- 开源,在Github上以宽松的Apache 2.0许可证发布(更多信息可在https://github.com/ARM - software/CMSIS_5/tree/develop/CMSIS/RTOS2/RTX 找到)。
- 具有商业质量,完全可配置,响应速度快。
- 基于开放的CMSIS - RTOS 2 API设计(更多信息可在www.keil.com/pack/doc/CMSIS/RTOS2/html/index.html 找到)。
- 与多种工具链兼容,如Arm/Keil、IAR EW - ARM和GCC。
需要注意的是,CMSIS - RTOS API已经得到了增强,现在有版本2可用。为Armv8 - M处理器开发的软件需要使用CMSIS - RTOS 2的RTX代码,Armv8 - M架构不支持CMSIS - RTOS版本1的RTX代码。
创建一个仅包含一个切换LED的应用线程的基于RTX的应用程序的步骤如下:
1.
添加RTX OS到Keil MDK项目
:在“Manage Run - Time Environment”窗口中选择RTX软件。需要注意的是,该窗口中有多个RTX选项,必须选择正确的RTX组件类型,包括:
- RTX集成是以源代码形式还是库形式。
- RTOS在安全世界还是非安全世界中运行(由于安全和非安全异常的EXC_RETURN代码值不同,这需要与项目的实际情况相匹配)。
2.
在程序代码中添加应用线程
:应用代码需要:
- 包含名为“cmsis_os2.h”的头文件,以便程序代码可以访问操作系统功能。
- 添加操作系统初始化(osKernelInitialize)、操作系统线程创建(osThreadNew)和操作系统启动(osKernelStart)的函数调用。
- 包含切换LED的线程代码(thread_led)。
3.
定制RTX配置
:可以在“RTX_Config.h”文件中定制一系列操作系统配置。为了方便配置,该文件包含元数据,允许在编辑操作系统配置时使用“Configuration Wizard”。默认情况下,RTX RTOS使用SysTick定时器生成周期性的操作系统滴答中断,它使用CMSIS - CORE文件“system_
.c”中定义的“SystemCoreClock”变量和“RTX_Config.h”中定义的“OS_TICK_FREQ”(内核滴答频率)来计算所需的时钟分频比。
项目编译完成后,即可下载到开发板进行测试。RTX RTOS功能丰富,更多详细信息可参考CMSIS - RTOS2文档(https://arm - software.github.io/CMSIS_5/RTOS2/html/index.html )。
4. 内联汇编
内联汇编允许在C代码中添加汇编代码序列。在为Cortex - M处理器编写程序时,内联汇编用于创建操作系统的上下文切换例程、操作系统的SVCall处理程序,在某些情况下还用于故障处理程序(例如,从堆栈帧中提取堆栈寄存器)。
使用内联汇编时,汇编代码需要按照特定工具链的代码语法编写。从Arm Compiler 5迁移到基于LLVM编译器技术的Arm Compiler 6时,内联汇编功能会发生变化。幸运的是,LLVM中的内联汇编支持与广泛使用的GCC编译器高度兼容,因此在很多情况下,GCC的内联汇编代码可以在Arm Compiler 6中复用。
对于GCC和Arm Compiler 6,带有参数支持的内联汇编代码片段的一般语法如下:
__asm ("
inst1 op1, op2, ... \n"
" inst2 op1, op2, ... \n"
...
" instN op1, op2, ... \n"
: output_operands /* optional */
: input_operands /* optional */
: clobbered_operands /* optional */
);
当汇编指令不需要参数时,语法可以很简单,例如:
void Sleep(void)
{ // Enter sleep using WFI instruction
__asm (" WFI\n");
return;
}
如果汇编代码需要输入和输出参数,或者需要修改其他寄存器,则需要定义输入和输出操作数以及受影响的寄存器列表。例如,将一个值乘以10的内联汇编代码如下:
int my_mul_10(int DataIn)
{
int DataOut;
__asm(" movs r3, #10\n"
" mul r2, %[input], r3\n"
" movs %[output], r2\n"
:[output] "=r" (DataOut)
:[input] "r" (DataIn)
: "cc", "r2", "r3");
return DataOut;
}
上述代码中的“__asm”表示内联汇编代码文本的开始,代码中使用了寄存器符号名称(“input”和“output”)。自GCC 3.1版本发布以及最近的LLVM编译器发布以来,符号名称功能有助于软件开发人员编写直观的代码。
在上述内联汇编代码示例中,内联汇编代码文本后面有几行操作数,操作数的顺序为:
- 输出操作数
- 输入操作数
- 受影响的操作数
由于汇编代码修改了寄存器R2和R3的值以及条件标志(“cc”),这些寄存器需要添加到受影响的操作数列表中。
可以在C文件中创建汇编函数。在声明内联汇编函数时,可以使用“naked” C函数属性来防止C编译器生成C函数的序言和尾声(即函数体前后的额外指令序列)。例如,上述内联汇编示例可以重写为:
/* r0 is used as input parameter as well as return result */
int __attribute__((naked)) my_mul_10(int DataIn)
{
__asm(" movs r3, #10\n\t"
" mul r0, r0, r3\n\t"
" bx lr\n\t"
);
}
对于这种类型的内联汇编函数,不需要提供操作数(输入操作数、输出操作数和受影响的操作数)。但在创建这种类型的函数时,必须充分理解并遵循函数之间的交互以及函数参数和结果传递的标准做法。对于Arm架构,这些信息可以在“Procedure Call Standard for the Arm Architecture”文档中找到。
5. Arm架构的过程调用标准
当使用汇编语言编写的函数需要与其他C代码交互时,需要遵循一系列要求,以确保软件函数之间的接口正常工作。这些要求记录在“Procedure Call Standard for the Arm Architecture”(AAPCS)文档中,该文档描述了多个软件函数在Arm处理器上运行时如何相互交互。
遵循AAPCS文档中规定的编程约定有以下好处:
- 各种软件组件(包括不同工具链生成的编译程序映像)可以无缝交互。
- 软件代码可以在多个项目中复用。
- 可以避免将汇编代码与编译器生成的程序代码或第三方程序代码集成时出现的问题。
即使创建的应用程序只包含汇编代码(在现代编程环境中这种情况很少见),遵循AAPCS指南也是有用的,因为调试工具可能会根据AAPCS文档中定义的做法对汇编函数的操作做出假设。
AAPCS文档涵盖的主要方面如下:
-
函数调用中的寄存器使用
:文档详细说明了哪些寄存器是调用者保存的,哪些是被调用者保存的。例如,函数或子例程应保留R4 - R11的值。如果这些寄存器在函数或子例程中被更改,则应将这些值保存在堆栈中,并在返回调用代码之前恢复。
-
函数参数传递
:在简单情况下,输入参数可以使用R0(第一个参数)、R1(第二个参数)、R2(第三个参数)和R3(第四个参数)传递给函数。如果要使用64位值作为输入参数,则使用一对32位寄存器(例如R0 - R1)。如果四个寄存器(R0 - R3)不足以传递所有参数(例如,需要向函数传递四个以上的参数),则使用堆栈(详细信息可在AAPCS中找到)。如果涉及浮点数据处理,并且编译流程指定了Hard - ABI,则可以使用浮点寄存器组中的寄存器。
-
返回结果传递给调用者
:通常,函数的返回值存储在R0中。如果返回结果是64位,则使用R1和R0。与参数传递类似,如果涉及浮点数据处理,并且编译流程指定了Hard - ABI,则可以使用浮点寄存器组中的寄存器。
-
堆栈对齐
:如果汇编函数需要调用C函数,应确保当前选择的堆栈指针指向双字对齐的地址位置(例如0x20002000、0x20002008、0x20002010等)。这是Embedded - ABI(EABI)标准的要求,该要求允许符合EABI的C编译器在生成程序代码时假设堆栈指针指向双字对齐的位置。如果汇编代码不直接或间接调用任何C函数,则汇编代码不需要在函数边界处保持堆栈指针与双字地址对齐。
对于简单的函数调用(假设不使用浮点寄存器进行数据传递,并且需要的寄存器少于四个),调用者和被调用者函数之间的数据传输如下表所示:
| 操作 | 寄存器使用 |
| ---- | ---- |
| 参数传递 | R0 - R3(最多四个参数),64位值用R0 - R1,更多参数用堆栈 |
| 返回结果 | R0(32位),R0 - R1(64位) |
除了参数和结果的使用外,还需要注意以下几点:
- 函数内部的代码必须确保离开函数时“被调用者保存寄存器”的值与进入函数时相同。
- 调用函数的代码必须确保如果“调用者保存寄存器”中的数据稍后需要再次访问,则在调用C函数之前将这些数据保存到内存(如堆栈)中,因为C函数可以清除调用者保存寄存器中的数据。
在使用Arm工具链中的Arm汇编器(armasm)时,汇编器提供了“REQUIRE8”指令来指示函数是否需要双字堆栈对齐,以及“PRESERVE8”指令来指示函数是否保留了双字对齐。
综上所述,在嵌入式软件开发中,掌握调试信息输出、实时操作系统的使用以及汇编与C代码的交互等知识,对于开发高效、稳定的软件至关重要。通过合理运用这些技术,可以提高开发效率,减少调试时间,提升软件的质量和性能。
嵌入式软件开发中的调试与实时操作系统应用(续)
6. 不同调试输出方式对比
在前面我们介绍了使用UART和ITM进行
printf
输出的方法,下面对这两种方式进行一个简单的对比,以便在实际开发中能根据需求做出合适的选择。
| 对比项 | UART | ITM |
|---|---|---|
| 硬件要求 | 需要UART转USB适配器,部分开发板有内置 | 需要支持ITM功能的处理器,如Armv8 - M Mainline和Armv7 - M处理器,使用SWO引脚时需注意协议选择 |
| 配置复杂度 | 需添加STDOUT软件组件,实现UART相关函数,配置终端软件 | 启用STDOUT并选ITM输出类型,还需进行额外调试配置 |
| 输出速度 | 相对较慢,受UART通信速率限制 | 速度较快,利用跟踪连接收集调试消息 |
| 适用场景 | 适合需要长时间稳定输出调试信息,对速度要求不高的场景 | 适合需要快速输出大量调试信息,用于性能分析等场景 |
7. RTX实时操作系统深入分析
RTX作为一款优秀的实时操作系统,在嵌入式开发中有着广泛的应用。除了前面提到的基本使用步骤,我们再深入分析一下它的一些关键特性。
7.1 任务调度机制
RTX的任务调度支持任务优先级功能,这意味着可以根据任务的重要性和紧急程度为每个任务分配不同的优先级。高优先级的任务可以在低优先级任务执行过程中抢占CPU资源,从而保证关键任务能够及时得到处理。例如,在一个智能家居系统中,传感器数据采集任务的优先级可以设置得较高,以确保能及时获取环境信息;而一些显示更新任务的优先级可以设置得较低。
7.2 任务间通信
RTX提供了任务间事件和消息通信机制,如邮箱。任务之间可以通过邮箱进行数据交换和同步。例如,一个数据处理任务可以将处理好的数据发送到邮箱,另一个显示任务则从邮箱中读取数据并进行显示。这种机制使得不同任务之间能够协同工作,提高系统的整体性能。
7.3 信号量与互斥锁
信号量和互斥锁是RTX用于实现资源共享和同步的重要机制。信号量可以用于控制对有限资源的访问,例如在一个多任务系统中,有多个任务需要访问一个串口资源,通过信号量可以确保同一时间只有一个任务能够使用串口。互斥锁则主要用于保护临界区,防止多个任务同时访问临界资源而导致数据不一致的问题。
8. 内联汇编的高级应用
内联汇编除了前面提到的基本应用场景,还有一些高级应用值得我们关注。
8.1 性能优化
在某些对性能要求极高的场景下,内联汇编可以用于优化代码性能。例如,在进行一些复杂的数学运算时,使用汇编代码可以直接操作寄存器,避免了C语言编译器生成的一些额外指令,从而提高运算速度。以下是一个简单的示例,使用内联汇编实现两个整数的加法:
int add(int a, int b)
{
int result;
__asm(" ADD %[result], %[a], %[b]\n"
: [result] "=r" (result)
: [a] "r" (a), [b] "r" (b)
: );
return result;
}
8.2 硬件交互
内联汇编还可以用于直接与硬件进行交互。例如,在访问一些特殊的硬件寄存器时,使用汇编代码可以更方便地进行操作。以下是一个访问特定地址硬件寄存器的示例:
#define REGISTER_ADDRESS 0x40000000
void write_register(int value)
{
__asm(" LDR R0, =%[address]\n"
" STR %[value], [R0]\n"
:
: [address] "i" (REGISTER_ADDRESS), [value] "r" (value)
: "R0");
}
9. 开发流程总结
为了让大家更清晰地了解整个嵌入式软件开发的流程,下面用一个mermaid流程图来总结:
graph LR
A[需求分析] --> B[选择调试输出方式]
B --> C{是否使用RTOS}
C -- 是 --> D[添加RTX OS到项目]
C -- 否 --> E[编写普通代码]
D --> F[添加应用线程]
F --> G[定制RTX配置]
G --> H[编写代码]
E --> H
H --> I[使用内联汇编优化或实现特定功能]
I --> J[遵循AAPCS进行函数交互]
J --> K[编译代码]
K --> L[下载到开发板测试]
10. 常见问题及解决方法
在实际开发过程中,可能会遇到一些常见的问题,下面为大家列举并给出解决方法。
10.1 UART输出无显示
- 可能原因 :UART转USB适配器未正常连接,波特率设置不一致,UART初始化函数未正确实现。
- 解决方法 :检查适配器连接,确保波特率与代码中设置一致,检查“UART_Config()”函数实现。
10.2 ITM输出无显示
- 可能原因 :调试配置未正确设置,SWO引脚使用时协议选择错误,Trace时钟速度不匹配。
- 解决方法 :检查调试设置,确保使用SWD协议,调整Trace时钟速度与硬件平台匹配。
10.3 RTX任务调度异常
- 可能原因 :任务优先级设置不合理,任务间资源竞争导致死锁。
- 解决方法 :重新评估任务优先级,使用信号量和互斥锁来解决资源竞争问题。
通过以上对嵌入式软件开发中调试输出、实时操作系统、内联汇编以及过程调用标准等方面的详细介绍,相信大家对整个开发流程和关键技术有了更深入的理解。在实际开发中,要根据具体需求灵活运用这些技术,不断积累经验,提高开发效率和软件质量。
超级会员免费看

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



