【说在前面的话】
【什么是Semihosting】
抛开翻译的美学不谈,hosting 的意思其实更贴这个词汇在生活中的原意——做东。也就是我们中国人请客时候说的“我做东”的做东,即:作为活动的主办方为客人提供服务。 Semi- 意为“做东做一半”——也就是大家说的“如做”。
客人(被服务的对象)是运行在MCU上的程序; 服务主要是 Libc 所许诺提供的服务;
与大家一般意义上的理解不同:
这里的主人并非是PC,而是运行在PC上、提供调试服务的程序,比如GCC命令行下的GDB和MDK下的Debugging模式等等。
在搞清楚了上述几个关键点后,Semihosting 的完整意涵就非常清楚了——由“PC上运行的调试程序”为“MCU上运行的应用程序”提供“Libc服务”的这个动作,叫做“Semi-hosting”。
这里,“hosting”准确翻译应该是“做东提供服务”,“semi-” 则体现了一个合作上的细节,即:
整个“为客服务”的过程一半由运行在MCU上的runtime库(libc runtime)提供接口, 经过调试仿真器(Debugger Adapter)的通讯后, 由运行在PC上的调试服务程序实际提供。
【Semihosting是如何成为嵌入式“阑尾”的】
支持Semihosting的上位机程序 调试仿真器(Debugger Adapter) 支持Semihosting的MCU运行时库(runtime)
这里,调试仿真器往往只是扮演一个透明数据通道的作用,对Semihosting服务本身来说虽然必须但并非关键。重点来了:
Arm Compiler 5和Arm Compiler 6在生成MCU代码时,其使用的Libc会默认开启对 Semihosting 的支持; 并不是所有的上位机调试程序都支持Semihosting——你说是吧?MDK?
是的,你没猜错,万众瞩目的MDK(严谨点,截止到MDK5),它大宝贝的居然不支持Semihosting。
那你猜,当Arm Compiler 5和Arm Compiler 6在你毫不知情的情况下默认开启了对Semihosting的支持,而MDK却不支持的时候,你调用任何 libc 的API会发生什么呢?
【“嵌入式阑尾炎”的症状和危害】
#include <time.h>
...
clock_t tClock = time();
...
MDK在全速运行的情况下,莫名其妙的暂停;如果目标代码出现在循环体中,甚至在我们按下F5以后仍然会暂停; 打开汇编调试界面,会发现PC指针停在一个 BKPT 0xAB 指令上:
BKPT 0xAB
在Arm Compiler 5和Arm Compiler 6中,Semihosting主要覆盖了一些常见的标准C库(libc)功能,这些功能使得嵌入式开发者能够在开发和调试过程中利用主机的资源来执行特定的操作。下面是Semihosting所覆盖的libc功能的主要类别:
1. 标准输入/输出(Standard I/O)
printf系列函数:例如
printf
、fprintf
、sprintf
等,用于格式化输出到标准输出设备(通常是主机的控制台)。scanf系列函数:例如
scanf
、fscanf
、sscanf
等,用于格式化输入从标准输入设备(通常是主机的键盘输入)。
2. 文件操作(File Operations)
fopen:打开文件。
fclose:关闭文件。
fread:从文件读取数据。
fwrite:向文件写入数据。
fseek:移动文件指针到指定位置。
ftell:获取文件指针当前位置。
fflush:刷新文件输出缓冲区。
3. 时间和日期(Time and Date)
time:获取当前时间。
clock:获取处理器时间。
difftime:计算两个时间点的时间差。
strftime:格式化时间和日期为字符串。
4. 错误处理(Error Handling)
perror:输出错误信息到标准错误设备。
strerror:返回与错误码对应的错误信息字符串。
5. 系统调用(System Calls)
exit:终止程序并返回状态码。
system:执行系统命令(在嵌入式系统中很少使用,但在主机上调试时可能有用)。
6. 其他辅助功能(Other Auxiliary Functions)
getenv:获取环境变量的值。
putenv:设置环境变量(不常见)。
remove:删除文件。
rename:重命名文件。
【“嵌入式阑尾炎”的潜伏与诱因】
五星上将麦克阿瑟曾评论道:某度看病,癌症起步。你这伪专家,把Semihosting说的这么可怕,“还编译器默认植入”,我怎么还活的好好的?我怎么从来没碰到过?
恕我直言,你可能符合以下特征:
大多数情况下使用的是Arm Compiler 5; 大多数情况下会默认使用 MicroLib; 在Arm Compiler 6下不选MicroLib的时候遇到“调试状态下一切正常,但下载程序直接跑就会死机”的现象——因此在小本本上默默记下了只能使用MicroLib的笔记; 从不使用 malloc 以外的 libc 函数,甚至包括 printf 用的程序模板是大佬做好的; 应用开发基于芯片厂商给的例子工程 使用类似RT-Thread这类“提供一站式服务”的软件平台。
瞎猫碰死耗子——运气好 有人替你负重前行
这里,有人替你负重前行很好理解,即某个第三方替你在系统中“切除了嵌入式阑尾Semihosting”,比如前面所说的:你用的是大佬提供的工程模板、你的应用是从芯片原厂的例子工程修改而来、你用了“全包服务”的RTOS平台等等。
最有意思的,其实是这里“瞎猫碰死耗子”的情况了。为了让问题看起来不那么“玄学”,让我们首先来了解一些基础知识:
MicroLib是一个裁剪版的Libc,它不仅删除了很多不常用的Libc服务,还对仅存的API进行了简化。因此,很多原本在普通Libc下会触发Semihosting的API调用,在MicroLib下要么直接“查无此人”,要么干脆返回失败(比如-1)。
在Arm Compiler 5下,main() 函数默认就是不带形参的,例如:
int main(void);
或者
void main(void);
而 Arm Compiler 6 在默认情况下所使用的Libc会使用带形参的main():
int main(int argc, char *argv[]);
你可不要简单的认为这是一个“形式上的形参”,Arm Compiler 6所使用的默认Libc真的会认真考虑如何获实际参数值的问题——而默认情况下,Libc会通过Semihosting的方式从上位机那里去读取。有意思的是,MicroLib会固定使用不带形参的main(),因此只要你勾选了MicroLib,也会侥幸绕开这个问题。
那么我聪明的朋友,你一定也明白隐藏在“狗屎运”后面的真相了吧?
实际上,对很多人来说,阻挡在从Arm Compiler 5向Arm Compiler 6迁移必经之路上的拦路虎之一就是名为BKPT 0xAB的Hardfault:
你以为你写了 int main(void) 编译器就不给main()函数传参数了么?你太天真了,Arm Compiler 6仍然会给main()传递参数,只不过你的main()函数不去读取罢了。
【简单易懂的“嵌入式阑尾”切除术】
//! \note for arm compiler 5
#undef __IS_COMPILER_ARM_COMPILER_5__
#if ((__ARMCC_VERSION >= 5000000) && (__ARMCC_VERSION < 6000000))
# define __IS_COMPILER_ARM_COMPILER_5__ 1
#endif
//! @}
//! \note for arm compiler 6
#undef __IS_COMPILER_ARM_COMPILER_6__
#if defined(__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050)
# define __IS_COMPILER_ARM_COMPILER_6__ 1
#endif
#undef __IS_COMPILER_ARM_COMPILER__
#if defined(__IS_COMPILER_ARM_COMPILER_5__) && __IS_COMPILER_ARM_COMPILER_5__ \
|| defined(__IS_COMPILER_ARM_COMPILER_6__) && __IS_COMPILER_ARM_COMPILER_6__
# define __IS_COMPILER_ARM_COMPILER__ 1
#endif
借助它们的帮助,我们可以很容易的通过判断 __IS_COMPILER_ARM_COMPILER_5__ 和 __IS_COMPILER_ARM_COMPILER_6__ 的值是否为“1”来确定当前的编译器版本。
如何在 Arm Compiler 6 下告知编译器 main() 函数不带输入参数
int main (int argc, char *argv[]);
哪怕你强行把 main() 函数写成无需输入参数的情况,编译器也还是会准备好参数——而准备参数的过程很有可能会因为触发Semihosting在”非调试模式“下导致Hardfault。为了解决这一问题,我们可以添加下面的代码:
# __IS_COMPILER_ARM_COMPILER_6__
__asm(".global __ARM_use_no_argv\n\t");
#
又因为 MicroLib 不会使用带参数的main(),因为我们可以根据(MDK所定义的一个宏)__MICROLIB,来做一个小小的区分:
__asm(".global __ARM_use_no_argv\n\t");
也就是当且仅当我们使用 Arm Compiler 6,且不使用MicroLib的时候,通过专门的语法结构来告诉编译器:main() 函数没有传入参数。
如何关闭 Semihosting
__asm(".global __use_no_semihosting");
Error: L6915E: Library reports error: __use_no_semihosting was requested,
but _sys_exit was referenced
#if __IS_COMPILER_ARM_COMPILER_6__
void _sys_exit(int ret)
{
(void)ret;
while(1) {}
}
#endif
/* 为 arm compiler 5 和 arm compiler 6 都添加这个空函数 */
void _ttywrch(int ch)
{
(void)ch;
}
Error: L6915E: Library reports error: __use_no_semihosting was requested,
but a semihosting fputc was linked in
Error: L6915E: Library reports error: __use_no_semihosting was requested,
but _sys_open was referenced
此时,解决问题的方法取决于你的工程中是否会使用printf。假设你确定自己不会使用到 printf,则可以通过添加如下的代码来让编译器(确切说是linker)满意:
int fputc(int ch, FILE *f)
{
(void) f;
(void) ch;
return ch;
}
FILEHANDLE $Sub$$_sys_open(const char *name, int openmode)
{
(void) name;
(void) openmode;
return 0;
}
如果确实需要使用printf,则可以:
打开MDK的RTE窗口,找到CMSIS Compiler 后展开
勾选 CORE
展开 STDOUT (API)后勾选自己心仪的重定位模式。比如,假设你想把printf重定位到某个具体的串行外设上(包括但不限于 UART、USB或者JLink-RTT),则推荐勾选Custom。
4、假设我们勾选了Custom,则需要在我们的某个C源代码中添加如下的函数:
int stdout_putchar(int ch)
{
/* 这里添加代码将 ch 发送到外设上 */
return ch;
}
这里,ch保存的是要发送的字节。我们可以通过这个函数将 printf 重定位到指定的外设上。注意:如果发送失败,则应该返回 -1。
如何解决使用 assert.h 引发的问题
很多代码都有使用 assert() 来截获错误的习惯,当我们使用 Arm Compiler 6 且开启 MicroLib的时候,由于 MicroLib并不提供对 assert() 底层函数的具体实现,当我们没有定义 NDEBUG 来关闭 assert() 时,会在链接阶段看到如下的编译错误:
Error: L6218E: Undefined symbol __aeabi_assert
知道原因后,解决也很简单:既然MicroLib没提供实现,我们就自己提供一个好了:
void __aeabi_assert(const char *chCond, const char *chLine, int wErrCode)
{
(void)chCond;
(void)chLine;
(void)wErrCode;
while(1) {
__NOP();
}
}
【说在后面的话】