Hằng số, biến và phép toán
I. Hằng số
int f() { return 0x123; /* 291 */ }
int g() { return -1; }
int h() { return 0x1234; /* 4660 */ }
int i() { return 0xbb8; /* 3000 */ }
Hãy xem cách các hằng số này được nạp vào máy tính trong C. Đầu tiên, xét hằng số 0x123, biểu diễn thập phân là 291, nó nằm trong phạm vi biểu diễn của toán hạng tức thời trong lệnh addi. Kết quả dịch ngược như sau:
Tương tự, return -1 cũng vậy. 1 có thể được biểu diễn bằng toán hạng tức thời, và 0xfff biểu diễn cho -1, có thể được thể hiện qua một lệnh addi.
0x1234 vượt quá phạm vi biểu diễn 12 bit của toán hạng tức thời. Do đó, ta cần kết hợp lui và addi. Lệnh lui nạp 0x1 vào 20 bit cao, sau đó addi nạp phần 234 còn lại.
Đối với 0xbb8, ta cũng phải chia thành hai phần để nạp. Lưu ý rằng toán hạng tức thời của addi không đủ để biểu diễn 0xbb8, trong khi lui với giá trị 0x1 (4096) lại lớn hơn 0xbb8. Vì vậy, ta buộc phải thực hiện phép toán với số âm: đầu tiên dùng lui để nạp 1 vào 20 bit cao (4096), sau đó dùng addi để nạp một số âm, cuối cùng thu được 0xbb8.
Kết luận:
Đối với việc nạp hằng số 64-bit trên RV64:
long long j() { return 0x1234567800000000; }
long long k() { return 0x1234567887654321; }
Với hàm long j():
Thực hiện qua li và addi.
Đầu tiên, nạp phần khác 0 vào a0: a0 = (0x12345678 >> 3)
Sau đó, qua lệnh tiếp theo: a0 = a0 << 35;
Với hàm long k():
Đối với một hằng số 64-bit phức tạp, trình biên dịch trực tiếp đặt nó vào bộ nhớ, trong đó LC0 là biểu diễn thập phân của 0x123456... Lệnh ld a0, LC0 nạp trực tiếp giá trị từ địa chỉ LC0 vào thanh ghi a0.
Khi nạp hằng số 64-bit trên RV32, ta chỉ cần dùng hai thanh ghi 32-bit để lưu trữ một dữ liệu 64-bit.
Quy định cụ thể về kích thước và căn chỉnh của biến nằm trong hai bộ ABI số nguyên của RISC-V, trong đó RV32 được định nghĩa trong ILP32 ABI, và 64-bit được định nghĩa trong LP64 ABI.
Trong quá trình cấp phát biến, các biến thường được đặt trong thanh ghi. RV32 chỉ có 32 thanh ghi, trong khi truy cập bộ nhớ chậm hơn nhưng dung lượng rất lớn (8~32GB).
Tất cả các biến đều được cấp phát trong không gian bộ nhớ và cần được đọc vào thanh ghi khi truy cập.
Về phân bố bộ nhớ của chương trình, nó được chia thành vùng dữ liệu tĩnh, vùng heap và vùng stack.
Heap và stack là các vùng động, tăng lên khi biến tăng.
Các biến toàn cục được đặt trong vùng data, ví dụ:
#include <stdio.h>
#include <stdlib.h>
int g;
void f(int n) {
static int sl;
int l, *h = malloc(4);
printf("n = %2d, &g = %p, &sl = %p, &l = %p, h = %p\n", n, &g, &sl, &l, h);
if (n < 5) f(n + 1);
}
int main() { f(1); printf("===\n"); f(1); return 0; }
Trong đó, static int sl được đặt trong vùng data. int g cũng là biến toàn cục, cũng nằm trong vùng data.
Biến địa phương không tĩnh, tức int l, được đặt trong vùng stack. Loại cuối cùng là biến động, cần malloc để cấp phát động, tức là nằm trong vùng heap.
Về truy cập biến trong RISC-V, mã ví dụ như sau:
#define def(type, name) \
volatile type name ## _a; \
volatile type name ## _b; \
void f_##name () { name ## _a = name ## _b; }
def(_Bool, _Bool)
def(char, char)
def(signed char, signed_char)
def(short, short)
def(unsigned short, unsigned_short)
def(int, int)
def(unsigned int, unsigned_int)
def(long, long)
def(long long, long_long)
def(void *, void_)
def(float, float)
def(double, double)
rv32gcc -O2 -S a.c
rv64gcc -O2 -S a.c
Phát hiện lệnh truy cập cho các kiểu biến khác nhau:
Việc căn chỉnh biến không đúng sẽ làm giảm hiệu quả truy cập:
Không căn chỉnh nghĩa là addr(n) % align(n) != 0, ví dụ biến int được đặt tại địa chỉ 0x13.
Phần cứng hỗ trợ truy cập bộ nhớ không căn chỉnh: mạch phức tạp hơn và cần hai chu kỳ trở lên.
Phần mềm hỗ trợ truy cập bộ nhớ không căn chỉnh: ném ngoại lệ, hiệu suất rất thấp.
Phép toán và lệnh
Đối với các toán tử trong C, lệnh RV có sự tương ứng như sau:
+, -, *, /, % add, sub, mul, div, rem
= mv, lệnh truy cập bộ nhớ
&, |, ^ and, or, xor
~ xori r, r, -1
<<, >> sll, srl, sra
! sltiu r, r, 1
<, > slt
Về tối ưu hóa biên dịch, chúng ta thường dùng -O trong quá trình biên dịch:
-O0 - Mỗi lần tính toán, đọc biến từ bộ nhớ trước, sau mỗi lần tính toán ghi ngay lại vào bộ nhớ.
-O1 - Trước khi bắt đầu tính toán, đọc biến từ bộ nhớ; quá trình tính toán diễn ra trong thanh ghi; sau khi kết thúc toàn bộ tính toán, ghi lại vào bộ nhớ.
-O2 - Trình biên dịch trực tiếp tính toán kết quả luôn 😂.
Đối với số có dấu và không dấu:
#include <stdint.h>
int32_t add1( int32_t a, int32_t b) { return a + b; }
uint32_t add2(uint32_t a, uint32_t b) { return a + b; }
int32_t cmp1( int32_t a, int32_t b) { return a < b; }
int32_t cmp2(uint32_t a, uint32_t b) { return a < b; }
int32_t shr1( int32_t a, int32_t b) { return a >> b; }
uint32_t shr2(uint32_t a, int32_t b) { return a >> b; }
int64_t zext1( int32_t a) { return a; }
uint64_t zext2(uint32_t a) { return a; }
Mã dịch ngược của add1 và add2 giống hệt nhau. Kết luận: Theo quan điểm của phần cứng RISC-V, phép cộng có dấu và không dấu hoàn toàn giống nhau, có thể dùng chung một mô-đun cộng để tính toán.
Về hành vi so sánh, số có dấu dùng slt, số không dấu dùng sltu.
Dịch phải: số có dấu dùng sra, số không dấu dùng srl.
II. Rẽ nhánh có điều kiện
So với mã C, các lệnh RV tương ứng như sau:
Đối với vòng lặp, biểu diễn máy của vòng lặp là một lệnh nhảy có điều kiện nhảy ngược lại.
int f(int n) {
int y = 0;
for (int i = 0; i < n; i ++) {
y += i;
}
return y;
}
Nhảy = tiếp tục vòng lặp
Không nhảy = thoát khỏi vòng lặp
Cảnh giác với hành vi không xác định.
Tràn số nguyên khi cộng là hành vi không xác định.
Ví dụ:
#include <stdio.h>
#include <limits.h>
int foo(int x) { return (x + 1) > x; }
int main() {
printf("INT_MAX=%d, cmp: %d%d\n", INT_MAX, (INT_MAX + 1) > INT_MAX, foo(INT_MAX));
return 0;
}
gcc -w a.c && ./a.out
gcc -w -O2 a.c && ./a.out
clang -w a.c && ./a.out
clang -w -O2 a.c && ./a.out
Mã có vẻ tương đương về mặt ngữ nghĩa nhưng lại cho kết quả chạy khác nhau.
Nguyên nhân: Tràn số nguyên khi cộng số có dấu là hành vi không xác định (UB).
Các máy khác nhau có thể dùng các phương pháp biểu diễn số có dấu khác nhau (mã gốc, mã bù, mã bù hai…).
Chuẩn C khó có thể định nghĩa thống nhất hành vi chính xác của tràn số nguyên có dấu.
Ví dụ, mã gốc và mã bù 32-bit không thể biểu diễn -2147483648.
Do đó, nó trở thành UB, và trình biên dịch có thể xử lý tùy ý.
Đối với phép dịch chuyển:
#include <stdio.h>
int main() {
int i = 30;
printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
printf("1 << %d = 0x%08x\n", i, 1 << i); i ++;
return 0;
}
Có hay không dùng -O2 để biên dịch, cho kết quả khác nhau.
Kết quả như sau: