EmbeddedRelated.com
Blogs

An Iterative Approach to USART HAL Design using ChatGPT

Jacob BeningoJune 19, 202311 comments

Artificial Intelligence (AI) and Machine Learning (ML) are taking the world by storm. I know that many embedded systems teams are often slow to adopt new technologies, and so far, the use of AI/ML is no different. However, to keep up in today’s competitive landscape, you must experiment, learn, and understand where and when to apply AI models to your work. If used correctly, they can remove the mundane and time-intensive portions of product development that you and your team don’t want to do anyways.

I’ve held the opinion for decades now that every embedded application needs to have a hardware abstraction layer (HAL). A HAL helps make embedded software more portable, reusable and decouples the application from the hardware it runs on. It enables better testing of embedded software and opens up the potential to use simulations to iterate on the design quickly. In today’s post, I’d like to show you how you can use ChatGPT to design your own USART HAL and how that HAL can be quickly extended and scaled for other microcontroller peripherals.

The Iterative AI Prompt Process

Developers can leverage many best practices when interacting with AI models to design and generate code. One best practice and process that we will use today is the iterative process for interacting with AI models. The idea beyond the iterative approach is that we aren’t going to write up a sophisticated, well-defined prompt that tells the model exactly what we want. Instead, we are going to use the following steps:

This article is available in PDF format for easy printing

1.Ask the model for something in general

2.Review its ouput

3.Document what is good about it and what needs improvement

4.Ask the model to make specific improvements, and then repeat from step 2 until we have our desired output or something very close to it.

We’ll consider each request from the AI model (ChatGPT) an iteration. With that in mind, let’s examine the first iteration and see where this process leads us.

Iteration #1 – A general request for a USART interface

Let’s ask ChatGPT to develop a software interface for a USART peripheral in C. The first iteration prompt might look something like the following:

The model outputs the following code:

#ifndef USART_H
#define USART_H

#include <stdint.h>

// USART configuration struct
typedef struct {
  uint32_t baud_rate;   // Baud rate for USART communication
  uint8_t data_bits;    // Number of data bits (5-9)
  uint8_t stop_bits;    // Number of stop bits (1 or 2)
  uint8_t parity;       // Parity mode (0: none, 1: odd, 2: even)
} USART_Config;

// Initialize USART with the given configuration
void USART_Init(USART_Config config);

// Send a single byte over USART
void USART_SendByte(uint8_t byte);

// Receive a single byte from USART
uint8_t USART_ReceiveByte(void);

// Send a byte array over USART
void USART_SendBytes(const uint8_t* data, uint32_t size);

// Receive a byte array from USART
void USART_ReceiveBytes(uint8_t* buffer, uint32_t size);

// Send a null-terminated string over USART
void USART_SendString(const char* str);

// Receive a null-terminated string from USART
void USART_ReceiveString(char* buffer, uint32_t buffer_size);

#endif  // USART_H

Not too shabby! The first pass has good and bad things about it. Let's review the code and see what we have.

The good observations:

  • It’s writing the interface as a header
  • Include guards were generated
  • Fixed width integer library is included
  • A separate configuration structure has been created for the initialization
  • We have a list of functions that will act as our interface
  • Function parameters make good use of const

The not-so-great observations, in my opinion, are the following:

  • The interface is too verbose and repetitive. For example, there is SendByte, SendBytes, SendString, etc.
  • The interface supports only a single USART device
  • I’m not a fan of variables with _ in them. I’d instead use Camel case
  • There’s no consideration for interrupts or DMA through callback functions
  • There is no way to detect if an error has occurred

After just a single request, we've come a long way from no code to some code. Let’s make some improvements in the next iteration.

Iteration #2 – Multiple Devices and a Simplified Interface

We currently have five improvements that we can make to the code. You could try to make all those changes in a single prompt. I have found that the more I try to do in a single prompt when iterating, the more unpredictable the output will become. If I ask for small incremental changes, I can better control the code generation's direction. I’ve seen ChatGPT take multiple requests from a single prompt and go off into the weeds, breaking things that already looked good.

Let's bundle three changes into this prompt. For the iteration, I requested the following:

ChatGPT took my requests and then produced the following updated code:

#ifndef USART_H
#define USART_H

#include <stdint.h>

// USART configuration struct
typedef struct {
  uint32_t baudRate;    // Baud rate for USART communication
  uint8_t dataBits;     // Number of data bits (5-9)
  uint8_t stopBits;     // Number of stop bits (1 or 2)
  uint8_t parity;       // Parity mode (0: none, 1: odd, 2: even)
} USART_Config;

// USART device struct
typedef struct {
  uint32_t usartId;     // Identifier for the USART device
  USART_Config config;  // Configuration for USART communication
} USART_Device;

// Initialize USART device with the given configuration
void USART_Init(USART_Device* device);

// Send data over USART
void USART_Send(USART_Device* device, const uint8_t* data, uint32_t size);

// Receive data from USART
void USART_Receive(USART_Device* device, uint8_t* buffer, uint32_t bufferSize);

// Send a null-terminated string over USART
void USART_SendString(USART_Device* device, const uint8_t* str);

// Receive a null-terminated string from USART
void USART_ReceiveString(USART_Device* device, uint8_t* buffer, uint32_t bufferSize);

#endif  // USART_H

I like this version much better than the first one. You can see that the code changes include the following:

  • Support for multiple USART devices
  • A simplified interface

However, there are still some whacky things I’d like to fix:

  • For a low-level interface like USART, I don’t like that there are string functions in the interface. This should be a higher-level library function.
  • Multiple devices are supported, but this USART_Device structure now couples the device and configuration together. This is not necessary and is a bit clunky.
  • Each function has a device pointer, but they are not const
  • The comments in the code are repetitive and don’t provide any insights other than code clutter
  • There’s no error handling
  • There’s no consideration for interrupts or DMA through callback functions.

Hmmm. You might now realize that our list of needed improvements is growing! We went from five things to improve to six! At any point, if we feel that the AI model is going in the wrong direction, we can step back or perform a new iteration. Worst case, we copy the code, make hand edits, and then feed those edits back into the model. However, as you’ll see, a few adjustments to our iterative prompts and we can make these improvements.

Iteration #3 – Good Bye Strings and Comments

I’m a big stickler for good code hygiene, so in this next iteration, we will clean up the code a bit more before adding any new features per se. The following prompt that I used was the following:

The result produced by ChatGPT for this request can be seen below:

#ifndef USART_H
#define USART_H

#include <stdint.h>

typedef enum {
  USART_1,
  USART_2,
  USART_3,
  // Add more USART interfaces as needed
  USART_COUNT
} usartDevice_t;

typedef struct {
  uint32_t baudRate;
  uint8_t dataBits;
  uint8_t stopBits;
  uint8_t parity;
} USART_Config;

void USART_Init(usartDevice_t device, USART_Config config);
void USART_Send(usartDevice_t device, const uint8_t* data, uint32_t size);
void USART_Receive(usartDevice_t device, uint8_t* buffer, uint32_t bufferSize);

#endif  // USART_H

I’m not going to lie; I’m starting to like how this module and interface are progressing. The interface looks slim and clean. We have a typedef list of USARTs to interact with, which could be part of a configuration file, but let’s not get too crazy yet. (You need to get a feel for how this works in simple cases before trying something more complex). 

There are a few nit-picky things in my review of this code:

  • I’m not a fan of USART_COUNT from a naming convention standpoint. I prefer USART_DEVICE_ID_MAX.
  • I’d prefer USART_Config to be usartConfig_t to match the naming conventions
  • There’s no error handling
  • There is no consideration for interrupts or DMA through callback functions.

Iteration #4 – Nit Picky Improvements

I’d like to make some additional adjustments before adding anything new. So, let’s fix those nit-picky things I found with the following prompt:

As you can see in the following output, the interface is now looking pretty good:

#ifndef USART_H
#define USART_H

#include <stdint.h>

typedef enum {
  USART_1,
  USART_2,
  USART_3,
  // Add more USART interfaces as needed
  USART_MAX
} usartDeviceID_t;

typedef struct {
  uint32_t baudRate;
  uint8_t dataBits;
  uint8_t stopBits;
  uint8_t parity;
} usartConfig_t;

void USART_Init(usartDeviceID_t device, const usartConfig_t config);
void USART_Send(usartDeviceID_t device, const uint8_t* data, uint32_t size);
void USART_Receive(usartDeviceID_t device, uint8_t* buffer, uint32_t bufferSize);

#endif  // USART_H

So what is currently missing? From my list, we still would like to have the following:

  • Functions that return error codes
  • Use the dependency inversion rule in the interface
  • There are no considerations for interrupts or DMA through callbacks

On to the next iteration!

Iteration #5 – Return Codes and Dependency Inversion

Let’s fix the issue with return codes and our interface's lack of dependency inversion. The following prompt will look something like the following:

The resulting code from this prompt can be seen below:

#ifndef USART_H
#define USART_H

#include <stdint.h>

typedef enum {
  USART_1,
  USART_2,
  USART_3,
  // Add more USART interfaces as needed
  USART_MAX
} usartDeviceID_t;

typedef enum {
  USART_OK,
  USART_ERROR
} usartError_t;

typedef struct {
  uint32_t baudRate;
  uint8_t dataBits;
  uint8_t stopBits;
  uint8_t parity;
  void (*callback)(usartError_t error); // Callback function for error handling
} usartConfig_t;

typedef struct {
  usartError_t (*USART_Init)(usartDeviceID_t device, const usartConfig_t* config);
  usartError_t (*USART_Send)(usartDeviceID_t device, const uint8_t* data, uint32_t size);
  usartError_t (*USART_Receive)(usartDeviceID_t device, uint8_t* buffer, uint32_t bufferSize);
} usartInterface_t;

extern const usartInterface_t USART_Interface;

#endif  // USART_H

Honestly, it’s starting to look like I designed this interface by hand! I now have an interface with function pointers to assign the implementation function. By doing this, I break dependencies and add some polymorphic behavior. There are still some improvements to this code, though:

  • The callback function probably would have void parameters
  • The USART_Receive function pointer has a bufferSize parameter which should just be the size
  • The header file is USART_H when it should be HAL_USART or USART_INTERFACE
  • The AI decided to define an extern usartInterfact variable that can be removed.

Iteration #6 – Manual Adjustments

I could use the prompt again to make the final adjustments to the code. Honestly, at a certain point, this feels tedious. So, for this iteration, I manually make any adjustments I want to the code such as: 

  • small naming convention changes
  • removing that extern variable
  • cleaning up the callback function. 

When I’m done with my changes, I copy and paste them back into ChatGPT and tell it that this is the final USART HAL. The code that I provided can be seen below:

The final adjustments to the interface that I made can be found below: 

#ifndef USART_INTERFACE_H
#define USART_INTERFACE_H

#include <stdint.h>

typedef enum {
  USART_1,
  USART_2,
  USART_3,
  // Add more USART interfaces as needed
  USART_MAX
} usartDeviceID_t;

typedef enum {
  USART_OK,
  USART_ERROR
} usartError_t;

typedef struct {
  uint32_t baudRate;
  uint8_t dataBits;
  uint8_t stopBits;
  uint8_t parity;
  void (*callback)(void);
} usartConfig_t;

typedef struct {
  usartError_t (*USART_Init)(usartDeviceID_t device, const usartConfig_t* config);
  usartError_t (*USART_Send)(usartDeviceID_t device, const uint8_t* data, uint32_t size);
  usartError_t (*USART_Receive)(usartDeviceID_t device, uint8_t* buffer, uint32_t size);
} usartInterface_t;

#endif  // USART_INTERFACE_H

We now have our final USART interface! The interface allows us to assign an implementation either statically during compile time or during run-time if necessary. We could assign an implementation for a target microcontroller or for a host-based simulation. I could even create tests that mock the interface! 

You may think that we are now done, but we've only just unlocked the power of creating a USART HAL using ChatGPT. If you are working on a real project, you'd still need a HAL for other peripherals. ChatGPT can help us there too!

Iteration #7 – Generate other HALs and Interfaces

So far, you’ve probably thought you could have just written this HAL by hand. You’d be absolutely correct. There are a few things that you may find when you use an AI model to generate your code:

  • Eventually, it produces what you would and may provide little extra value
  • It makes better code than you would have (includes error handling)
  • It provides you with ideas or insights you might not have thought of in the first place

At this point, we have a fairly reasonable USART HAL. The critical point to remember is that now that we have our USART HAL, we can use it as an additional input into the AI model to generate other HALs for us! These HALs will be produced far faster, within a dozen seconds, and will match our style.

For example, let’s produce an SPI Interface using the following prompt:

When I requested this interface inside the same model that produced my USART HAL, I also fed my final hand-updated USART HAL; I got the following for my SPI interface:

#ifndef SPI_INTERFACE_H
#define SPI_INTERFACE_H

#include <stdint.h>

typedef enum {
  SPI_1,
  SPI_2,
  SPI_3,
  // Add more SPI interfaces as needed
  SPI_MAX
} spiDeviceID_t;

typedef enum {
  SPI_OK,
  SPI_ERROR
} spiError_t;

typedef struct {
  uint32_t clockSpeed;
  uint8_t dataMode;
  uint8_t bitOrder;
  void (*callback)(void);
} spiConfig_t;

typedef struct {
  spiError_t (*SPI_Init)(spiDeviceID_t device, const spiConfig_t* config);
  spiError_t (*SPI_Transmit)(spiDeviceID_t device, const uint8_t* data, uint32_t size);
  spiError_t (*SPI_Receive)(spiDeviceID_t device, uint8_t* buffer, uint32_t size);
  spiError_t (*SPI_TransmitReceive)(spiDeviceID_t device, const uint8_t* txData, uint8_t* rxBuffer, uint32_t size);
} spiInterface_t;

#endif  // SPI_INTERFACE_H

The results match well with the USART interface design. You can see that some languages have been updated a bit, such as transmitting and receiving over reading and writing. However, it matches well with what I typically find for an SPI interface.

Conclusions

Our iterative journey to developing a USART HAL using ChatGPT resulted in a reasonable HAL. The initial development took several iterations to get right and even required us to make some hand-coded improvements. However, with the final version, we could request additional interfaces be generated for other peripherals like SPI. These new interfaces could be generated very quickly by the model. If you were to hand tweak the SPI interface and then request additional interfaces, you could quickly generate an entire HAL for your microcontroller that you could reuse from one project to the next. In fact, you could ask ChatGPT to extend those interfaces into other programming languages like C++!

As you saw in our design, AI models aren’t intelligent. They are just trained statistical models that calculate the highest probable next token in a sequence. They won’t always be correct, and they will require careful oversight. However, if appropriately used to augment your normal development processes, they could be a powerful tool to help you develop more robust and reusable embedded systems.




[ - ]
Comment by dcomerJune 20, 2023

Dang, it sure seemed to be a lot of work to say "Hello, World.". On the other hand, at least humans can rest assured that there are plenty of opportunities for blinky apps. Seriously, a very interesting journey of AI investigation and reporting on the development side. I could not help but wonder if AI would maintain the code, fix bugs, and add new feature requests. And while I am thinking of it, does this mean there will be no need for unit testing? Oops, I probably hit a few nerves with that snide remark. :)

BTW, maybe we can get rid of the check for "Are you a robot" before submitting a post. I'd bet many AI bots would like to answer "yes" in the near future.

Feeling a bit like a smart-ass tonight :)

Dave

[ - ]
Comment by beningjwJune 21, 2023

Haha. It's all good. 

Getting the model to output what I wanted definitely was a bit of work. I looked at it more that it was probably the inexperienced guy (me) working with the model. 

I think the power in it is that once I did the upfront work, I was able to quickly generate additional solutions extremely fast. I also could have just hand coded my interface and then fed it into the model, but I thought that would not be as fun. 

You definitely bring up a lot of great points. On the unit test front, I did consider trying to get the model to generate unit tests. I've been using TDD whenever I write code, so the fact I have an interface with no tests is uncomfortable. Perhaps attempting to add them could be something for a future post. (Will the AI model "know" how to generate unit tests if I tell it to take a TDD approach to writing the interfaces?)

Thanks for the comment! I didn't view them as "smart-ass" comments, they are important questions we should be considering as we evaluate this type of technology. 

[ - ]
Comment by lciummoJune 28, 2023

I think ChatGPT lifted most of that code from a driver I wrote in 1988 for a 68HC11 that's on my github!   I recognize it - I was  a "pioneer" in the use of typedef'd enums when everyone else was still using #define's.



[ - ]
Comment by beningjwJune 30, 2023

Thanks for the comment. 

ChatGPT definitely is trained using open source code. It's why the quality of the code generated is suspect. It hasn't been exposed to high quality safety/mission critical software. 

[ - ]
Comment by grantbJune 28, 2023

Entertaining as it is, I'm not seeing this a very useful. How to you attach your buffers? How does the application or next layer know there is data ready?  Who do you have to wrestle to get rid of the damn underscores? For someone that doesn't have your skills to guide this thing to completion, they'd be better off just stealing whatever they can from someone's github.

GB

PS. @lciummo - I would be interested in that vintage driver you wrote in 1988 for a 68HC11 that's on your github. I have a soft spot for the old Motorola parts. Or should I just ask AI to write what you would? ;-)


[ - ]
Comment by JustWilliamJune 28, 2023

HI,
I really liked your example.
Yes many people might say it is a lot of work or is not perfect but I believe you treated current iterations of LLM appropriately.

They should not be used and treated as infallible "super gurus" that instantly give you the "perfect" answer just as you would like it with the vaguest of requests. They are currently at the stage of very bright interns/assistants  ... and if you use them with that mindset then they are a very powerful and useful tool.  

 

[ - ]
Comment by beningjwJune 30, 2023

Thanks for the comment. I'm glad to hear that you thought it was interesting. 

I've found it fascinating the potential that this technology has. I think you are right about where the current state is. What is scary is that this is the beginning. It'll be interesting to see where these tools are in five years, as long as it's not held back by legislation. 

[ - ]
Comment by mwb1100June 29, 2023

The callback function needs at least one parameter - a usartDeviceID_t parameter that allows the callback to know which device the callback event is for.

Also, don't you need a separate callback for send and receive (or if keeping a single callback, another argument to specify)?

[ - ]
Comment by beningjwJune 30, 2023

Thanks for the comment. 

I agree with the callback needing our usartDeviceID_t. 

If I were doing this for production, I might add additional callback functions for other features. You could just have one callback to manage both Tx and Rx. It's less elegant because you have to add logic to determine what triggered the callback. 

[ - ]
Comment by Dimi_SlandNovember 10, 2023

Hello,

I'm new in your blog, but I 've learning very much. 

I already use this tool to help in develop, but not used already to create drivers HAL.

Thank you by example simple and didatic. I will go test and increase my code in this example. When work perfectly to family PIC18 I will show the answer complety.

Thaks Jacob

[ - ]
Comment by beningjwNovember 11, 2023

You're welcome! Good luck!

To post reply to a comment, click on the 'reply' button attached to each comment. To post a new comment (not a reply to a comment) check out the 'Write a Comment' tab at the top of the comments.

Please login (on the right) if you already have an account on this platform.

Otherwise, please use this form to register (free) an join one of the largest online community for Electrical/Embedded/DSP/FPGA/ML engineers: