从零开始在Win上添加一块QEMU开发板(四)实现简单USART
文章目录
- 一、前言背景
- 二、QEMU的字符设备模拟
- 三、USART的发送
- 1. USART发送的QEMU字符设备模拟
- 2. MMIO设计
- 3. 中断连接
- 4. 复位
- 三、代码验证
- 1. 输出到serial控制台
- 2. 输出到文件
一、前言背景
QEMU是一款开源的模拟器及虚拟机管理器。而QEMU内置支持了一些开发板,我们可以基于这些内置的板子来做操作系统等软件的配置。但是实际市面上很多板子QEMU中是没有提供支持的,这需要我们根据QEMU的源码自定义一些开发板,然后再重新编译,以满足灵活的需求。
这是基于自己的理解写的一份学习笔记,方便我日后查阅,也给新来的朋友给予一些帮助。
从零开始在Win上添加一块QEMU开发板(三)让板子跑起来 中我们实现了CPU的继承和内存的虚拟化,让板子已经可以运行一些代码了。本篇将继续实践阶段,目标是基于自定义的 ricky 开发板模型,完成 STM32 USART 模块的模拟,并在 QEMU 中运行一个简单的测试程序,通过串口打印输出 “HelloWorld”。
二、QEMU的字符设备模拟
UART 的收发过程涉及数据交互,因此在模拟过程中,我们需要实现 QEMU 模型中相应的数据传输功能。
QEMU 提供了 CharBackend
结构体来完成字符设备的模拟,它是连接前端设备与后端设备的核心结构。
常见的数据发送接口包括 qemu_chr_fe_write()
和 qemu_chr_fe_write_all()
,它们用于从设备前端向后端发送数据。对于接收数据的处理,QEMU 提供了回调机制,可以通过注册 IOCanReadHandler *fd_can_read
和 IOReadHandler *fd_read
回调来接收来自后端的数据并传给设备模拟器。
CharBackend 可绑定多种类型的后端设备(Chardev *chr
),包括但不限于:
- 主机终端(如 stdio 或 serial)
- 网络(如 tcp 或 udp)
- 文件
- 虚拟设备(如 null 或 mux)
这是CharBackend
结构体的定义:
struct CharBackend {Chardev *chr;IOEventHandler *chr_event;IOCanReadHandler *chr_can_read;IOReadHandler *chr_read;BackendChangeHandler *chr_be_change;void *opaque;unsigned int tag;bool fe_is_open;
};
我们可以使用 system/system.h
中的 serial_hd(x)
接口(这是一个 Chardev
)来使用QEMU命令行上的serial窗口输出和接收数据。
我们在类型属性结构体 RickySocUsartState
中创建一个 CharBackend
对象:
struct RickySocUsartState {/* <private> */SysBusDevice parent_obj;/* <public> */CharBackend chr;
};
并通过属性 "chardev"
传入到 CharBackend
的 Chardev *chr
对象:
static const Property ricky_soc_usart_properties[] = {DEFINE_PROP_CHR("chardev", RickySocUsartState, chr),
};static void ricky_soc_usart_class_init(ObjectClass *klass, void *data)
{DeviceClass *dc = DEVICE_CLASS(klass);device_class_set_props(dc, ricky_soc_usart_properties);
}
并在SoC中创建USART对象并传入 "chardev"
对象为 serial_hd(serial_id)
:
void ricky_soc_usart_create(RickySocUsartState *usart, int serial_id, qemu_irq irq, hwaddr base, Error **errp)
{DeviceState *dev;SysBusDevice *busdev;dev = DEVICE(usart);qdev_prop_set_chr(dev, "chardev", serial_hd(serial_id));if (!sysbus_realize(SYS_BUS_DEVICE(usart), errp)) {return;}
}
并绑定 s->chr
的接收回调函数
static int ricky_soc_usart_can_receive(void *opaque)
{RickySocUsartState *s = RICKY_SOC_USART(opaque);if (!(s->usart_sr & USART_SR_RXNE)) {return 1;}return 0;
}static void ricky_soc_usart_receive(void *opaque, const uint8_t *buf, int size)
{// RickySocUsartState *s = RICKY_SOC_USART(opaque);// DeviceState *d = DEVICE(s);}
static void ricky_soc_usart_realize(DeviceState *dev, Error **errp)
{RickySocUsartState *s = RICKY_SOC_USART(dev);qemu_chr_fe_set_handlers(&s->chr, ricky_soc_usart_can_receive,ricky_soc_usart_receive, NULL, NULL,s, NULL, true);
}
此处我们暂未实现 ricky_soc_usart_receive()
,仅通过 ricky_soc_usart_can_receive()
判断是否可以接收数据。根据手册,当一个字符被接收并转移至接收寄存器时,状态寄存器 SR 的 RXNE 位将被置位,表示数据可以被读取。
三、USART的发送
1. USART发送的QEMU字符设备模拟
在STM32F1x 的 Reference Manual 中可以看到USART章节:
USART introduction
The universal synchronous asynchronous receiver transmitter (USART) offers a flexible
means of full-duplex data exchange with external equipment requiring an industry standard
NRZ asynchronous serial data format. The USART offers a very wide range of baud rates
using a fractional baud rate generator.
It supports synchronous one-way communication and half-duplex single wire
communication. It also supports the LIN (local interconnection network), Smartcard Protocol
and IrDA (infrared data association) SIR ENDEC specifications, and modem operations
(CTS/RTS). It allows multiprocessor communication.
High speed data communication is possible by using the DMA for multibuffer configuration
可以看到USART具有很多功能,在本次模拟中,我们聚焦其最基本的异步串口发送功能。
下面节选了一部分手册内容:
通过手册我们可以知道,USART的数据发送的步骤是:
- 通过在
USART_CR1
寄存器上置位UE
位来激活USART - 编程
USART_CR1
的M
位来定义字长。 - 在
USART_CR2
中编程停止位的位数。 - 如果采用多缓冲器通信,配置
USART_CR3
中的DMA使能位(DMAT
)。按多缓冲器通信中
的描述配置DMA寄存器。 - 利用
USART_BRR
寄存器选择要求的波特率。 - 设置
USART_CR1
中的TE
位,发送一个空闲帧作为第一次数据发送。 - 把要发送的数据写进
USART_DR
寄存器(此动作清除TXE
位)。在只有一个缓冲器的情况
下,对每个待发送的数据重复步骤7。 - 在
USART_DR
寄存器中写入最后一个数据字后,要等待TC=1
,它表示最后一个数据帧的
传输结束。当需要关闭USART或需要进入停机模式之前,需要确认传输结束,避免破坏
最后一次传输。
但是我们本次使用的是QEMU的字符设备模拟进行模拟USART的数据输出,所以我们的数据不存在时序,只需要确定大小端,以及在合适的地方执行:
qemu_chr_fe_write_all(&s->chr, &ch, 1);
而清零 TXE
位是通过对数据寄存器的写操作来完成的。TXE
位由硬件来设置,它表明:
● 数据已经从 TDR
移送到移位寄存器,数据发送已经开始
● TDR
寄存器被清空
● 下一个数据可以被写进 USART_DR
寄存器而不会覆盖先前的数据
而QEMU的字符设备模拟时,数据发送是同步的,也就说数据写进 USART_DR
寄存器是就会立即执行 qemu_chr_fe_write_all(&s->chr, &ch, 1);
,执行完之后也就立即置位了,也就是操作是:
ch = value;s->usart_sr &= ~(SART_SR_TXE); // 硬件清零TXE位qemu_chr_fe_write_all(&s->chr, &ch, 1);s->usart_sr |= USART_SR_TXE; // 硬件置位TXE位
TXE
位的置位与清除通常由硬件自动完成。在 QEMU 模拟中,若无并发问题,整个发送过程可以简化为:
ch = value;qemu_chr_fe_write_all(&s->chr, &ch, 1);
2. MMIO设计
USART 是通过 MMIO(Memory-Mapped I/O) 实现与 CPU 的交互,因此需要完成 MMIO 区域的初始化,并将 MMIO 的读写操作转发到对应的回调函数:
读函数uint64_t (*read)(void *opaque, hwaddr addr, unsigned size)
写函数 void (*write)(void *opaque, hwaddr addr, uint64_t data, unsigned size)
在 QEMU 的字符设备模拟中,由于不涉及实际时序,我们可以省略字长、波特率、停止位等参数的模拟。这些通常只在真实硬件通信中起作用。
以下为 USART 所使用的一部分寄存器信息:
MMIO 初始化
static void ricky_soc_usart_init(Object *obj)
{RickySocUsartState *s = RICKY_SOC_USART(obj);memory_region_init_io(&s->mmio, obj, &ricky_soc_usart_ops, s,TYPE_RICKY_SOC_USART, 0x400);sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->mmio);
}
当然也得在Soc中创建USART对象时映射基地址(这里通过形参 base
传入):
void ricky_soc_usart_create(RickySocUsartState *usart, int serial_id, qemu_irq irq, hwaddr base, Error **errp)
{DeviceState *dev;SysBusDevice *busdev;dev = DEVICE(usart);qdev_prop_set_chr(dev, "chardev", serial_hd(serial_id));if (!sysbus_realize(SYS_BUS_DEVICE(usart), errp)) {return;}busdev = SYS_BUS_DEVICE(dev);sysbus_mmio_map(busdev, 0, base);
}
USART 的读写回调函数如下所示,具体根据手册中的寄存器映射来实现逻辑:
static void ricky_soc_usart_write(void *opaque, hwaddr addr,uint64_t val64, unsigned int size)
{RickySocUsartState *s = RICKY_SOC_USART(opaque);uint32_t value = val64;unsigned char ch;switch (addr) {case USART_SR:if (value <= 0x3FF) {/* I/O being synchronous, TXE is always set. In addition, it mayonly be set by hardware, so keep it set here. */s->usart_sr = value | USART_SR_TXE;} else {s->usart_sr &= value;}ricky_soc_usart_update_irq(s);return;case USART_DR:if (value < 0xF000) {ch = value;/* XXX this blocks entire thread. Rewrite to use* qemu_chr_fe_write and background I/O callbacks */qemu_chr_fe_write_all(&s->chr, &ch, 1);/* XXX I/O are currently synchronous, making it impossible forsoftware to observe transient states where TXE or TC aren'tset. Unlike TXE however, which is read-only, software mayclear TC by writing 0 to the SR register, so set it againon each write. */s->usart_sr |= USART_SR_TC;ricky_soc_usart_update_irq(s);}return;case USART_BRR:s->usart_brr = value;return;case USART_CR1:s->usart_cr1 = value;ricky_soc_usart_update_irq(s);return;case USART_CR2:s->usart_cr2 = value;return;case USART_CR3:s->usart_cr3 = value;return;case USART_GTPR:s->usart_gtpr = value;return;default:qemu_log_mask(LOG_GUEST_ERROR,"%s: Bad offset 0x%"HWADDR_PRIx"\n", __func__, addr);}
}static uint64_t ricky_soc_usart_read(void *opaque, hwaddr addr,unsigned int size)
{RickySocUsartState *s = RICKY_SOC_USART(opaque);uint64_t retvalue = 0;switch (addr) {case USART_SR:retvalue = s->usart_sr;qemu_chr_fe_accept_input(&s->chr);break;case USART_DR:retvalue = s->usart_dr & 0x3FF;s->usart_sr &= ~USART_SR_RXNE;qemu_chr_fe_accept_input(&s->chr);ricky_soc_usart_update_irq(s);break;case USART_BRR:retvalue = s->usart_brr;break;case USART_CR1:retvalue = s->usart_cr1;break;case USART_CR2:retvalue = s->usart_cr2;break;case USART_CR3:retvalue = s->usart_cr3;break;case USART_GTPR:retvalue = s->usart_gtpr;break;default:qemu_log_mask(LOG_GUEST_ERROR,"%s: Bad offset 0x%"HWADDR_PRIx"\n", __func__, addr);return 0;}return retvalue;
}static const MemoryRegionOps ricky_soc_usart_ops = {.read = ricky_soc_usart_read,.write = ricky_soc_usart_write,.endianness = DEVICE_NATIVE_ENDIAN,
};
MMIO 的实现应以手册为参考依据,必要时可根据模拟器需求进行简化或优化。
我们在可能会触发中断的位置留下函数 ricky_soc_usart_update_irq()
,后续可以通过在该函数加入条件判断,来判断是否触发中断。
3. 中断连接
中断的实现通过 sysbus_init_irq()
初始化并挂入 SYS_BUS_DEVICE
中(这里存在QEMU的GPIO设计,大至是当我们挂入一个 SYS_BUS_DEVICE
设备中,每次挂入都会获得一个编号,从0开始取号):
static void ricky_soc_usart_init(Object *obj)
{RickySocUsartState *s = RICKY_SOC_USART(obj);sysbus_init_irq(SYS_BUS_DEVICE(obj), &s->irq);memory_region_init_io(&s->mmio, obj, &ricky_soc_usart_ops, s,TYPE_RICKY_SOC_USART, 0x400);sysbus_init_mmio(SYS_BUS_DEVICE(obj), &s->mmio);
}
并在SoC中将中断连接形参 irq
上(这里 sysbus_connect_irq()
的第二个形参 n
就是通过前面说的取号得到的数字来索引相应的 qemu_irq
的):
void ricky_soc_usart_create(RickySocUsartState *usart, int serial_id, qemu_irq irq, hwaddr base, Error **errp)
{DeviceState *dev;SysBusDevice *busdev;dev = DEVICE(usart);qdev_prop_set_chr(dev, "chardev", serial_hd(serial_id));if (!sysbus_realize(SYS_BUS_DEVICE(usart), errp)) {return;}busdev = SYS_BUS_DEVICE(dev);sysbus_mmio_map(busdev, 0, base);sysbus_connect_irq(busdev, 0, irq);
}
参入的形参 irq
与NVIC的中断号息息相关:
static const int usart_irq[RICKY_SOC_USART_NUM] = {37, 38, 39};
/* USART */for (i = 0; i < RICKY_SOC_USART_NUM; i++) {ricky_soc_usart_create(&(s->usart[i]), i, qdev_get_gpio_in(armv7m, usart_irq[i]), usart_addr[i], errp);}
它对应着USART的全局中断服务函数。
我们目前仅支持 TXE
、TC
、RXNE
三类中断事件,你可以按照需求添加更多:
static void ricky_soc_usart_update_irq(RickySocUsartState *s)
{uint32_t mask = s->usart_sr & s->usart_cr1;if (mask & (USART_SR_TXE | USART_SR_TC | USART_SR_RXNE)) {qemu_set_irq(s->irq, 1);} else {qemu_set_irq(s->irq, 0);}
}
4. 复位
根据各寄存器的手册复位值编写设备复位函数:
static void ricky_soc_usart_reset(DeviceState *dev)
{RickySocUsartState *s = RICKY_SOC_USART(dev);s->usart_sr = USART_SR_RESET;s->usart_dr = 0x00000000;s->usart_brr = 0x00000000;s->usart_cr1 = 0x00000000;s->usart_cr2 = 0x00000000;s->usart_cr3 = 0x00000000;s->usart_gtpr = 0x00000000;ricky_soc_usart_update_irq(s);
}
最后,在 class 初始化中绑定复位函数:
static void ricky_soc_usart_class_init(ObjectClass *klass, void *data)
{DeviceClass *dc = DEVICE_CLASS(klass);device_class_set_legacy_reset(dc, ricky_soc_usart_reset); // RESETdevice_class_set_props(dc, ricky_soc_usart_properties);dc->realize = ricky_soc_usart_realize;
}
三、代码验证
1. 输出到serial控制台
使用STM32CubeMX生成代码,由于没有没有完成GPIO和RCC的模拟,我注释掉了相应的生成代码:
#include "main.h"
#include "usart.h"
#include "gpio.h"
void SystemClock_Config(void);int main(void)
{HAL_Init();// SystemClock_Config();// MX_GPIO_Init();MX_USART1_UART_Init();const uint8_t str[] = "Hello World!\r\n";HAL_UART_Transmit(&huart1, str, strlen((const char *)str), 0xFFFF);while (1){}}
编译得到ELF文件后,在我们刚刚制作的QEMU板中验证:
至此,成功在QEMU的字符模拟设备中输出 "Hello World!\n"
。
2. 输出到文件
当然,我们前面提到的QEMU的字符模拟设备,可以不只是 serial,还可以是文件、Tcp等等。
这里我们用输出到文件举个例子,
可以直接通过指令 -chardev
创建一个 file
字符设备,并通过 id=uart0
重定向 -serial
:
${ROOT}/build/qemu-system-ricky.exe \-M RickyBoard \-kernel ${DEMO_PATH} \-monitor stdio \-d in_asm \-chardev file,id=uart0,path=uart_output.log \-serial chardev:uart0
这样就可以在文件 uart_output.log
输出 "Hello World!\n"
了。
但如果想在代码里操作呢?
这里我们也是一样再创建一个 Chardev *chardev
:
void ricky_soc_usart_create(RickySocUsartState *usart, int serial_id, qemu_irq irq, hwaddr base, Error **errp)
{DeviceState *dev;SysBusDevice *busdev;char filename[64];snprintf(filename, sizeof(filename), "file:output/uart%d.log", serial_id);char chardevname[64];snprintf(chardevname, sizeof(chardevname), "ricky_usart_chr%d", serial_id);Chardev *chardev = qemu_chr_new(chardevname, filename, NULL);dev = DEVICE(usart);// qdev_prop_set_chr(dev, "chardev", serial_hd(serial_id));qdev_prop_set_chr(dev, "chardev", chardev);if (!sysbus_realize(SYS_BUS_DEVICE(usart), errp)) {return;}busdev = SYS_BUS_DEVICE(dev);sysbus_mmio_map(busdev, 0, base);sysbus_connect_irq(busdev, 0, irq);
}
传入这个新的 chardev
作为QEMU的字符模拟设备,这里传入的 "file"
的字符设备(label
中需要包含字符设备的类型),它会在 qemu_chr_parse_compat()
中设置 filename
为文件路径:
Chardev *qemu_chr_new(const char *label, const char *filename,GMainContext *context)
{return qemu_chr_new_permit_mux_mon(label, filename, false, context);
}
static Chardev *qemu_chr_new_permit_mux_mon(const char *label,const char *filename,bool permit_mux_mon,GMainContext *context)
{return qemu_chr_new_from_name(label, filename, permit_mux_mon, context,true);
}
static Chardev *qemu_chr_new_from_name(const char *label, const char *filename,bool permit_mux_mon,GMainContext *context, bool replay)
{const char *p;Chardev *chr;QemuOpts *opts;Error *err = NULL;if (strstart(filename, "chardev:", &p)) {chr = qemu_chr_find(p);if (replay && chr) {qemu_chardev_set_replay(chr, &err);if (err) {error_report_err(err);return NULL;}}return chr;}opts = qemu_chr_parse_compat(label, filename, permit_mux_mon);if (!opts)return NULL;chr = do_qemu_chr_new_from_opts(opts, context, replay, &err);if (!chr) {error_report_err(err);goto out;}if (qemu_opt_get_bool(opts, "mux", 0)) {assert(permit_mux_mon);monitor_init_hmp(chr, true, &err);if (err) {error_report_err(err);object_unparent(OBJECT(chr));chr = NULL;goto out;}}out:qemu_opts_del(opts);return chr;
}
QemuOpts *qemu_chr_parse_compat(const char *label, const char *filename,bool permit_mux_mon)
{char host[65], port[33], width[8], height[8];int pos;const char *p;QemuOpts *opts;Error *local_err = NULL;opts = qemu_opts_create(qemu_find_opts("chardev"), label, 1, &local_err);if (local_err) {error_report_err(local_err);return NULL;}if (strstart(filename, "mon:", &p)) {if (!permit_mux_mon) {error_report("mon: isn't supported in this context");return NULL;}filename = p;qemu_opt_set(opts, "mux", "on", &error_abort);if (strcmp(filename, "stdio") == 0) {/* Monitor is muxed to stdio: do not exit on Ctrl+C by default* but pass it to the guest. Handle this only for compat syntax,* for -chardev syntax we have special option for this.* This is what -nographic did, redirecting+muxing serial+monitor* to stdio causing Ctrl+C to be passed to guest. */qemu_opt_set(opts, "signal", "off", &error_abort);}}if (strcmp(filename, "null") == 0 ||strcmp(filename, "pty") == 0 ||strcmp(filename, "msmouse") == 0 ||strcmp(filename, "wctablet") == 0 ||strcmp(filename, "braille") == 0 ||strcmp(filename, "testdev") == 0 ||strcmp(filename, "stdio") == 0) {qemu_opt_set(opts, "backend", filename, &error_abort);return opts;}if (strstart(filename, "vc", &p)) {qemu_opt_set(opts, "backend", "vc", &error_abort);if (*p == ':') {if (sscanf(p+1, "%7[0-9]x%7[0-9]", width, height) == 2) {/* pixels */qemu_opt_set(opts, "width", width, &error_abort);qemu_opt_set(opts, "height", height, &error_abort);} else if (sscanf(p+1, "%7[0-9]Cx%7[0-9]C", width, height) == 2) {/* chars */qemu_opt_set(opts, "cols", width, &error_abort);qemu_opt_set(opts, "rows", height, &error_abort);} else {goto fail;}}return opts;}if (strcmp(filename, "con:") == 0) {qemu_opt_set(opts, "backend", "console", &error_abort);return opts;}if (strstart(filename, "COM", NULL)) {qemu_opt_set(opts, "backend", "serial", &error_abort);qemu_opt_set(opts, "path", filename, &error_abort);return opts;}if (strstart(filename, "file:", &p)) {qemu_opt_set(opts, "backend", "file", &error_abort);qemu_opt_set(opts, "path", p, &error_abort);return opts;}if (strstart(filename, "pipe:", &p)) {qemu_opt_set(opts, "backend", "pipe", &error_abort);qemu_opt_set(opts, "path", p, &error_abort);return opts;}if (strstart(filename, "pty:", &p)) {qemu_opt_set(opts, "backend", "pty", &error_abort);qemu_opt_set(opts, "path", p, &error_abort);return opts;}if (strstart(filename, "tcp:", &p) ||strstart(filename, "telnet:", &p) ||strstart(filename, "tn3270:", &p) ||strstart(filename, "websocket:", &p)) {if (sscanf(p, "%64[^:]:%32[^,]%n", host, port, &pos) < 2) {host[0] = 0;if (sscanf(p, ":%32[^,]%n", port, &pos) < 1)goto fail;}qemu_opt_set(opts, "backend", "socket", &error_abort);qemu_opt_set(opts, "host", host, &error_abort);qemu_opt_set(opts, "port", port, &error_abort);if (p[pos] == ',') {if (!qemu_opts_do_parse(opts, p + pos + 1, NULL, &local_err)) {error_report_err(local_err);goto fail;}}if (strstart(filename, "telnet:", &p)) {qemu_opt_set(opts, "telnet", "on", &error_abort);} else if (strstart(filename, "tn3270:", &p)) {qemu_opt_set(opts, "tn3270", "on", &error_abort);} else if (strstart(filename, "websocket:", &p)) {qemu_opt_set(opts, "websocket", "on", &error_abort);}return opts;}if (strstart(filename, "udp:", &p)) {qemu_opt_set(opts, "backend", "udp", &error_abort);if (sscanf(p, "%64[^:]:%32[^@,]%n", host, port, &pos) < 2) {host[0] = 0;if (sscanf(p, ":%32[^@,]%n", port, &pos) < 1) {goto fail;}}qemu_opt_set(opts, "host", host, &error_abort);qemu_opt_set(opts, "port", port, &error_abort);if (p[pos] == '@') {p += pos + 1;if (sscanf(p, "%64[^:]:%32[^,]%n", host, port, &pos) < 2) {host[0] = 0;if (sscanf(p, ":%32[^,]%n", port, &pos) < 1) {goto fail;}}qemu_opt_set(opts, "localaddr", host, &error_abort);qemu_opt_set(opts, "localport", port, &error_abort);}return opts;}if (strstart(filename, "unix:", &p)) {qemu_opt_set(opts, "backend", "socket", &error_abort);if (!qemu_opts_do_parse(opts, p, "path", &local_err)) {error_report_err(local_err);goto fail;}return opts;}if (strstart(filename, "/dev/parport", NULL) ||strstart(filename, "/dev/ppi", NULL)) {qemu_opt_set(opts, "backend", "parallel", &error_abort);qemu_opt_set(opts, "path", filename, &error_abort);return opts;}if (strstart(filename, "/dev/", NULL)) {qemu_opt_set(opts, "backend", "serial", &error_abort);qemu_opt_set(opts, "path", filename, &error_abort);return opts;}error_report("'%s' is not a valid char driver", filename);fail:qemu_opts_del(opts);return NULL;
}
我们运行程序,最后发现已经打印在了 output/uart0.log
: