0%

linux上搭建stm32开发环境

一、前言

为什么写这一篇博客呢,主要是大学时期搞单片机两年,手头上有开发板两个,各种小型板好几个,放着浪费。现在工作这么多年,已经对编译、环境、linux等很熟悉了。想着将这个技能重新拾起来,顺便给后代铺条路。

本文是基于linux上使用stm32cubemx生成工程,使用jlink烧写stm32,开发环境是vscode(宇宙第一编辑器),语言C/C++,可能扩展一下使用golang开发更方便。

二、环境搭建

我用的archlinux,不过其他系统都一样。需要安装下面的几个软件。

1. stm32cubemx 图形化配置stm32芯片生成hal库

这个东西官网有linux版,可以直接到官网搜索下载。因为用的是arch,所以直接aur安装就好了

1
yay -S stm32cubemx

2. arm-none-eabi-gcc 交叉编译工具

这个肯定有linux版,也是直接官网下载就好了。这个也有官方的包,pacman就能下载,不用aur,其他linux应该apt或yum也能安装

1
2
# arm-none-eabi-newlib 一些c标准库文件
sudo pacman -S arm-none-eabi-gcc arm-none-eabi-gdb arm-none-eabi-newlib

3. JLinkExe jlink下载工具

为什么是这个不使用openocd,主要是openocd对于jlink的支持不太完整(也可能是我不会用,但是我研究了好久没成功)。我烧写的时候发现如果我配置stm32的时候没有将debug信息配置好jtag,烧写就成了一次性工程,烧完之后再也连不上了。但是使用官方jlink就每这个问题,就算不配置debug也可以强行烧写。

同样的,官网直接下载linux版,我使用aur

1
yay -S jlink-software-and-documentation

4. 其他软件

看自己需要哈,我装了下面几个

  • vscode用来写代码,也可以vim、kate、notepad啥的
  • make用来编译使用makefile
  • cmake也可以替代make命令使用CMakeLists.txt编译

三、上手开发(先点个灯)

打开stm32cubemx,选芯片配置

我使用的是stm32f103zet6的开发板,所以选择这个芯片

第一步先配置一下时钟晶振,因为开发板默认外置了一个8MHz的晶振,所以我配置一个高速时钟选晶体/陶瓷共振器(crystal/ceramic resonator)

在时钟配置界面选择好HSE和PLLCLK,然后将时钟设置到最大的72MHz回车自动计算前面的参数就配置好了

开发板上面的8个灯对应的是PC0-PC7,那么就回到芯片界面配置一下gpio用来点灯,可以搜索PC0,找到芯片上闪光的那个,单击选择GPIO Output就可以了。我选了8个引脚都打开。

开始生成代码,为了看懂编译过程和命令,我先使用makefile搞,后面搞懂之后用cmake会更方便

选好后点击右上角的GENERATE CODE生成代码就好了。找到生成目录,用vscode打开这个目录。

目录结构如上,test.loc是stm32cubemx的工程文件,其他都是代码文件相关,有一个汇编的s文件,一个链接用的ld文件。

改改代码,在main.c中找到while(1),在里面输入点灯的操作,要写在USER CODE BEGIN x里面,否则重新配置芯片之后会将代码给覆盖了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Core/Src/main.c
int main(void)
{
...
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */

/* USER CODE BEGIN 3 */
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_0, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_1, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_2, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_3, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_4, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_5, GPIO_PIN_RESET);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_6, GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_7, GPIO_PIN_RESET);
}
/* USER CODE END 3 */
}

然后打开终端直接make就可以输出一个build/test.bin文件,可以用来烧写了

注:stm32cubemx的6.12.1版本有bug,报错STM32F103ZETx_FLASH.ld:56: synax error

修改很简单,在这个文件里面按照下面的方式修改

1
2
3
_estack = ORIGIN() + LENGTH();    /* end of RAM */
// 改成
_estack = ORIGIN(RAM) + LENGTH(RAM); /* end of RAM */
1
2
3
4
5
6
7
8
9
10
11
  /* Initialized data sections goes into RAM, load LMA copy after code */
.data :
{
...
} > AT> FLASH
// 改成
/* Initialized data sections goes into RAM, load LMA copy after code */
.data :
{
...
} >RAM AT> FLASH // 加上RAM
1
2
3
4
5
6
7
8
9
  .bss :
{
...
} >
// 改成
.bss :
{
...
} >RAM // 加上RAM
1
2
3
4
5
6
7
8
9
10
11
  /* User_heap_stack section, used to check that there is enough RAM left */
._user_heap_stack :
{
...
} >RAM
// 改成
/* User_heap_stack section, used to check that there is enough RAM left */
._user_heap_stack :
{
...
} >RAM // 加上RAM

然后再make就不会报错了

四、烧写stm32

1. jlink使用jtag烧写

直接上脚本吧,看着清晰一点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/bin/bash

jlink_script="flash.jlink"

rm -f "$jlink_script"
# 生成jlink脚本
# connect 连接设备
# h 暂停cpu
# loadfile "$1" 下载二进制
# r reset
# g 开始运行
# exit 断开连接
cat << EOF > "$jlink_script"
device STM32F103ZE
si JTAG
speed 4000
jtagconf -1,-1
connect
h
loadfile "$1"
r
g
exit
EOF

JLinkExe -commanderscript "$jlink_script"

使用JLinkExe执行脚本进行烧录,命令行只需要执行命令bash flash.sh build/test.bin就可以烧到单片机里面,然后就能看到效果了

五、调试stm32

1. jlink使用jtag调试

想要调试需要在stm32cubemx上选好调试的方式,把引脚配置好,否则会调试不起来。这里使用jtag-5调试

然后在vscode里面安装一个插件Cortex-Debug,之后在生成一个launch.json,选择Cortex Debug

需要加一些东西的改动,配置gdb路径,二进制路径,设备型号,调试方式等

F5开始调试就好了

六、进一步开发

1. 系统滴答时钟

stm32配置完外部晶振时钟源后会有一个默认的时钟中断用于记录tick,这个时钟是固定的1ms(暂时没找到不修改代码调整的方式)。HAL库中systick的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal.c
/**
* @brief This function configures the source of the time base.
* The time source is configured to have 1ms time base with a dedicated
* Tick interrupt priority.
* @note This function is called automatically at the beginning of program after
* reset by HAL_Init() or at any time when clock is reconfigured by HAL_RCC_ClockConfig().
* @note In the default implementation, SysTick timer is the source of time base.
* It is used to generate interrupts at regular time intervals.
* Care must be taken if HAL_Delay() is called from a peripheral ISR process,
* The SysTick interrupt must have higher priority (numerically lower)
* than the peripheral interrupt. Otherwise the caller ISR process will be blocked.
* The function is declared as __weak to be overwritten in case of other
* implementation in user file.
* @param TickPriority Tick interrupt priority.
* @retval HAL status
*/
__weak HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority)
{
/* Configure the SysTick to have interrupt in 1ms time basis*/
if (HAL_SYSTICK_Config(SystemCoreClock / (1000U / uwTickFreq)) > 0U)
{
return HAL_ERROR;
}

/* Configure the SysTick IRQ priority */
if (TickPriority < (1UL << __NVIC_PRIO_BITS))
{
HAL_NVIC_SetPriority(SysTick_IRQn, TickPriority, 0U);
uwTickPrio = TickPriority;
}
else
{
return HAL_ERROR;
}

/* Return function status */
return HAL_OK;
}

可以看到时钟被调整为1ms,传入HAL_SYSTICK_Config的是计数器的值,计数到达这个值触发中断。算一下

  • SystemCoreClock是核心时钟的频率,也就是计数器计数频率,如果什么都不除,这里就会造成1s的中断计数(8MHz计数8M,就是1s一次)
  • 除以1000就是代表搞1ms中断
  • uwTickFreq定义如下,代表真实想要的中断频率,默认就是1KHZ也就是除以1,还是1000,对应的正好是1ms中断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Drivers/STM32F1xx_HAL_Driver/Inc/stm32f1xx_hal.h
/** @defgroup HAL_TICK_FREQ Tick Frequency
* @{
*/
typedef enum
{
HAL_TICK_FREQ_10HZ = 100U,
HAL_TICK_FREQ_100HZ = 10U,
HAL_TICK_FREQ_1KHZ = 1U,
HAL_TICK_FREQ_DEFAULT = HAL_TICK_FREQ_1KHZ
} HAL_TickFreqTypeDef;

// Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal.c
HAL_TickFreqTypeDef uwTickFreq = HAL_TICK_FREQ_DEFAULT; /* 1KHz */
  • 系统时钟中断触发函数定义为SysTick_Handler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Core/Src/stm32f1xx_it.c
/**
* @brief This function handles System tick timer.
*/
void SysTick_Handler(void)
{
/* USER CODE BEGIN SysTick_IRQn 0 */

/* USER CODE END SysTick_IRQn 0 */
HAL_IncTick();
/* USER CODE BEGIN SysTick_IRQn 1 */

/* USER CODE END SysTick_IRQn 1 */
}

// Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal.c
/**
* @brief This function is called to increment a global variable "uwTick"
* used as application time base.
* @note In the default implementation, this variable is incremented each 1ms
* in SysTick ISR.
* @note This function is declared as __weak to be overwritten in case of other
* implementations in user file.
* @retval None
*/
__weak void HAL_IncTick(void)
{
uwTick += uwTickFreq;
}

这个HAL_IncTick是个__weak定义的,说明可以用户自定义,从里面的定义可以看出,这个uwTick保持ms级别计数。

  • uwTickFreq = 1,1ms,uwTick+1
  • uwTickFreq = 10,10ms,uwTick+10

uwTick和uwTickFreq对外暴露了,定义在这里

1
2
3
4
5
// Drivers/STM32F1xx_HAL_Driver/Inc/stm32f1xx_hal.h
/* Exported types ------------------------------------------------------------*/
extern __IO uint32_t uwTick;
extern uint32_t uwTickPrio;
extern HAL_TickFreqTypeDef uwTickFreq;

我们可以重写这个HAL_IncTick作为我们自己的定时器处理函数,可以从一个1ms定时器搞出很多个定时器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
volatile unsigned long jiffies = 0;

static void timer_handler(void) {
++jiffies;
}

/**
* @brief Period elapsed callback in non-blocking mode
* @param htim TIM handle
* @retval None
*/
void HAL_IncTick() {
// 还原原始调用
uwTick += uwTickFreq;
// 调用定时器处理函数
timer_handler();
}

虽然uwTick作为ms计数很不错,这里搞个好玩的,把linux的jiffies搞进来使用,单独记录一个。主要是想要应用层代码和HAL完全隔离,不想要太多依赖HAL库,这样好移植

2. uart串口日志打印

滴答时钟配置完了,现在调试手段还是只能通过单步断点,断点调试太麻烦也太不稳定。参考linux内核的方式将日志加到串口上,通过串口输出一下日志。

串口的配置很简单,这里使用UART1,主要是因为引脚比较好接线。

使用异步模式UART,同步模式代表的是USART会多一个clk的线。不配置中断,只是为了打印日志所以不搞太复杂,中断还要考虑实现buffer来处理缓冲区问题,可以但没必要,简单最好。

配置完就可以生成代码了,由于我们没开启中断,而且用来打日志,就只需要一个函数HAL_UART_Transmit。工程生成出来之后,可以看到main.c中有了对应代码。

1
2
// Core/Src/main.c
UART_HandleTypeDef huart1;

可以看到,配置很简单。但是我们是要搞日志输出的,写一下日志输出函数,分为log.c和log.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// log.h
#pragma once

#ifdef __cplusplus
extern "C" {
#endif /* __cplusplus */

/**
* 以网络结构显示数据
* @param data 数据首地址
* @param length 数据长度
*/
static void log_hex_fn(const void *data, int length, void (*log_print)(const char *, ...)) {
int i = 0, j = 0;
const char *pData = (const char *)data;

log_print(" ");
for (i = 0; i < 16; i++) {
log_print("%X ", i);
}
log_print(" ");
for (i = 0; i < 16; i++) {
log_print("%X", i);
}

log_print("\r\n");

for (i = 0; i < length; i += 16) {
log_print("%02x ", i / 16);
for (j = i; j < i + 16 && j < length; j++) {
log_print("%02x ", pData[j] & 0xff);
}
if (j == length && length % 16) {
for (j = 0; j < (16 - length % 16); j++) {
log_print(" ");
}
}
log_print(" ");
for (j = i; j < i + 16 && j < length; j++) {
if (pData[j] < 32 || pData[j] >= 127) {
log_print(".");
} else {
log_print("%c", pData[j] & 0xff);
}
}

log_print("\r\n");
}
}

void serial_printf(const char *fmt, ...);

void _print_current_time(void);
/**
* 切分文件名,将前面的斜杠去除
* @param fileName 文件名
*/
const char *_split_file_name(const char *fileName);

#define LOG_INFO(fmt, ...) \
_print_current_time(); \
serial_printf("[I][%s:%d %s] " fmt "\n", _split_file_name(__FILE__), __LINE__, __FUNCTION__, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...) \
_print_current_time(); \
serial_printf("[W][%s:%d %s] " fmt "\n", _split_file_name(__FILE__), __LINE__, __FUNCTION__, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) \
_print_current_time(); \
serial_printf("[E][%s:%d %s] " fmt "\n", __FILE__, __LINE__, __FUNCTION__, ##__VA_ARGS__)

#define LOG_HEX(data, len) log_hex_fn(data, len, serial_printf)

#ifdef __cplusplus
}
#endif /* __cplusplus */

对应源文件如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// log.c
#include <linux/jiffies.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>

#include "stm32f1xx.h"

extern UART_HandleTypeDef huart1;
static inline int serial_putc(unsigned char ch) {
unsigned timeout = 0xffff;
HAL_UART_Transmit(&huart1, &ch, 1, timeout);
return timeout ? 0 : -1;
}

static inline void serial_write(const char *s, unsigned n) {
while (*s && n-- > 0) {
if (*s == '\n') serial_putc('\r');
serial_putc(*s);
s++;
}
}

void serial_printf(const char *fmt, ...) {
va_list ap;
char buf[512];
int n;

va_start(ap, fmt);
n = vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);

serial_write(buf, n);
}

void _print_current_time(void) {
unsigned long msecs = jiffies_to_msecs(jiffies);
unsigned long secs = msecs / 1000;
unsigned long msec = msecs % 1000;
serial_printf("[%5lu.%03lu]", secs, msec);
}

const char *_split_file_name(const char *fileName) {
const char *pChar = fileName;
pChar = (strrchr(pChar, '/') ? strrchr(pChar, '/') + 1 : (strrchr(pChar, '\\') ? strrchr(pChar, '\\') + 1 : pChar));
return pChar;
}

实现很简单,在主函数里面使用一下LOG_INFO("hello, main!")就可以从串口输出日志了,开发板本身有232芯片直接把uart1转成usb了,插线到电脑上就可以使用了。

linux读取串口信息

我使用的是ch340的串口转USB的东西,CH340系列在linux上需要看看设备有没有驱动,没有需要下载

1
2
3
=> modinfo ch341
filename: /lib/modules/6.11.6-arch1-1/kernel/drivers/usb/serial/ch341.ko.zst
...

下载地址在 https://www.wch.cn/download/CH341SER_LINUX_ZIP.html

驱动没问题后,可以从dmesg中看到usb插上后映射到/dev/ttyUSBx,就可以使用minicom来进行串口读写了,打开命令如下

1
2
3
4
5
# -D /dev/ttyUSB0   打开设备是/dev/ttyUSB0
# -b 115200 设置波特率为115200
# -w 启动自动换行
# -h 使用hex显示
minicom -D /dev/ttyUSB0 -b 115200 -w

minicom快捷键

  • Ctrl+a,x: 退出
  • Ctrl+a,z: 打开帮助面板
  • Ctrl+a,c: 清屏
  • Ctrl+a,w: 启禁用自动换行
  • Ctrl+a,e: 启禁用输入显示

3. pwm呼吸灯

七、知识说明

1. usart和uart

  • UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)
  • USART(Universal Synchronous/Asynchronous Receiver/Transmitter,通用同步/异步收发器)

usart的同步模式会多一个clk线,CLK线上的时钟信号通常由主设备(通常是发送方)产生,并通过此线传输给从设备(通常是接收方)。主设备会通过改变CLK线的时钟频率或周期来控制数据的传输速率和格式。

2. gpio

2.1. gpio mode

  1. Output Push-Pull:
    • 工作原理:在这种模式下,GPIO引脚既有一个上拉电阻,也有一个下拉电阻。当输出为高电平时,上拉电阻将引脚拉高到电源电压;当输出为低电平时,下拉电阻将引脚拉低到地线电压。
    • 应用场景:这种模式是最常见的GPIO输出模式,因为它提供了稳定的电平输出和快速的切换速度。
    • 优点:可以快速切换高电平和低电平,并且由于有内部上拉或下拉电阻的存在,即使在外部条件导致引脚浮动时,也能快速恢复到稳定状态。
  2. Output Open Drain:
    • 工作原理:在这种模式下,GPIO引脚没有内部的上拉电阻,只有下拉电阻。当输出为高电平时,需要外部电路(如上拉电阻)来维持高电平;而当输出为低电平时,引脚会通过内部的下拉电阻直接连接到地线。
    • 应用场景:这种模式常用于需要与其他系统或模块进行线与(line-to-line)通信的场合,如I2C或SPI通信。在这种通信中,多个设备共享数据线,而Open Drain模式允许多个设备在数据线上同时处于高电平状态(通过外部上拉电阻),但只有一个设备可以处于低电平状态。
    • 优点:适合于多设备共享数据线的场景,并且可以更灵活地与其他系统进行通信协议的交互。

2.2. gpio pull-up/pull-down

主要用于输入模式,当没有外部输入时,引脚的默认状态